{"version":3,"file":"config-Bn9jpblO.mjs","names":[],"sources":["../src/notifications/types.ts","../src/tasks/types.ts","../src/config/json-store.ts","../src/config/yaml-store.ts","../src/config/main-config.ts","../src/daemon/config.ts"],"sourcesContent":["/**\n * types.ts — Unified Notification Framework type definitions\n *\n * Defines the channel registry, event routing, and configuration schema\n * for PAI's notification subsystem.\n */\n\n// ---------------------------------------------------------------------------\n// Channel identifiers\n// ---------------------------------------------------------------------------\n\nexport type ChannelId = \"ntfy\" | \"whatsapp\" | \"macos\" | \"voice\" | \"cli\";\n\n// ---------------------------------------------------------------------------\n// Notification event types\n// ---------------------------------------------------------------------------\n\n/**\n * The semantic type of a notification event.\n * Used to route events to the appropriate channels.\n */\nexport type NotificationEvent =\n  | \"error\"\n  | \"progress\"\n  | \"completion\"\n  | \"info\"\n  | \"debug\";\n\n// ---------------------------------------------------------------------------\n// Notification mode\n// ---------------------------------------------------------------------------\n\n/**\n * The current notification mode.\n *\n * - \"auto\"      — Use the per-event routing table (default)\n * - \"voice\"     — All events go to voice (WhatsApp TTS)\n * - \"whatsapp\"  — All events go to WhatsApp text\n * - \"ntfy\"      — All events go to ntfy.sh\n * - \"macos\"     — All events go to macOS notifications\n * - \"cli\"       — All events go to CLI stdout only\n * - \"off\"       — Suppress all notifications\n */\nexport type NotificationMode =\n  | \"auto\"\n  | \"voice\"\n  | \"whatsapp\"\n  | \"ntfy\"\n  | \"macos\"\n  | \"cli\"\n  | \"off\";\n\n// ---------------------------------------------------------------------------\n// Per-channel configuration\n// ---------------------------------------------------------------------------\n\nexport interface NtfyChannelConfig {\n  enabled: boolean;\n  /** ntfy.sh topic URL, e.g. \"https://ntfy.sh/my-topic\" */\n  url?: string;\n  /** ntfy priority: min | low | default | high | urgent */\n  priority?: \"min\" | \"low\" | \"default\" | \"high\" | \"urgent\";\n}\n\nexport interface WhatsAppChannelConfig {\n  enabled: boolean;\n  /** Optional recipient (phone, JID, or contact name). Omit for self-chat. */\n  recipient?: string;\n}\n\nexport interface MacOsChannelConfig {\n  enabled: boolean;\n}\n\nexport interface VoiceChannelConfig {\n  enabled: boolean;\n  /** Kokoro voice name, e.g. \"bm_george\", \"af_bella\". Default: \"bm_george\" */\n  voiceName?: string;\n}\n\nexport interface CliChannelConfig {\n  enabled: boolean;\n}\n\nexport interface ChannelConfigs {\n  ntfy: NtfyChannelConfig;\n  whatsapp: WhatsAppChannelConfig;\n  macos: MacOsChannelConfig;\n  voice: VoiceChannelConfig;\n  cli: CliChannelConfig;\n}\n\n// ---------------------------------------------------------------------------\n// Routing table\n// ---------------------------------------------------------------------------\n\n/**\n * Maps each event type to the ordered list of channels that should receive it.\n * Only channels that are enabled in `channels` and present in this list are used.\n */\nexport type RoutingTable = {\n  [K in NotificationEvent]: ChannelId[];\n};\n\nexport const DEFAULT_ROUTING: RoutingTable = {\n  error:      [\"whatsapp\", \"macos\", \"ntfy\", \"cli\"],\n  completion: [\"whatsapp\", \"macos\", \"ntfy\", \"cli\"],\n  info:       [\"cli\"],\n  progress:   [\"cli\"],\n  debug:      [],\n};\n\n// ---------------------------------------------------------------------------\n// Top-level notification config (embedded in PaiDaemonConfig)\n// ---------------------------------------------------------------------------\n\nexport interface NotificationConfig {\n  /** Current routing mode. Default: \"auto\" */\n  mode: NotificationMode;\n  /** Per-channel configuration */\n  channels: ChannelConfigs;\n  /** Event → channel routing (used in \"auto\" mode) */\n  routing: RoutingTable;\n}\n\nexport const DEFAULT_CHANNELS: ChannelConfigs = {\n  ntfy: {\n    enabled: false,\n    url: undefined,\n    priority: \"default\",\n  },\n  whatsapp: {\n    enabled: true,\n    recipient: undefined,\n  },\n  macos: {\n    enabled: true,\n  },\n  voice: {\n    enabled: false,\n    voiceName: \"bm_george\",\n  },\n  cli: {\n    enabled: true,\n  },\n};\n\nexport const DEFAULT_NOTIFICATION_CONFIG: NotificationConfig = {\n  mode: \"auto\",\n  channels: DEFAULT_CHANNELS,\n  routing: DEFAULT_ROUTING,\n};\n\n// ---------------------------------------------------------------------------\n// Notification payload\n// ---------------------------------------------------------------------------\n\nexport interface NotificationPayload {\n  /** Semantic event type — used for routing */\n  event: NotificationEvent;\n  /** The notification message body */\n  message: string;\n  /** Optional title (used by macOS, ntfy) */\n  title?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Provider interface\n// ---------------------------------------------------------------------------\n\nexport interface NotificationProvider {\n  readonly channelId: ChannelId;\n  /**\n   * Send a notification.\n   * Returns true on success, false on failure (failure is non-fatal).\n   */\n  send(payload: NotificationPayload, config: NotificationConfig): Promise<boolean>;\n}\n\n// ---------------------------------------------------------------------------\n// Send result\n// ---------------------------------------------------------------------------\n\nexport interface SendResult {\n  channelsAttempted: ChannelId[];\n  channelsSucceeded: ChannelId[];\n  channelsFailed: ChannelId[];\n  mode: NotificationMode;\n}\n","/**\n * types.ts — Task Bus type definitions\n *\n * Defines the provider registry, ownership resolution, and configuration schema\n * for PAI's cross-session task subsystem.\n *\n * The task bus routes work between PAI sessions through an external tracker.\n * A session files a task; a routine reads it later and dispatches it to the\n * session that owns it — spawning one if none is running.\n *\n * See Notes/docs/task-bus.md for the architecture and its constraints.\n */\n\n// ---------------------------------------------------------------------------\n// Provider identifiers\n// ---------------------------------------------------------------------------\n\nexport type ProviderId = \"todoist\";\n\n// ---------------------------------------------------------------------------\n// Ownership\n// ---------------------------------------------------------------------------\n\n/**\n * Prefix marking a tracker label as a PAI ownership assertion.\n * A task labelled `pai:acme-api` is owned by the `acme-api` project.\n */\nexport const OWNER_LABEL_PREFIX = \"pai:\";\n\n/**\n * How a task's owner was determined. Recorded so the routine can explain\n * itself, and so a mis-resolution is diagnosable rather than silent.\n *\n * - \"label\"     — an explicit `pai:<project>` label (authoritative)\n * - \"container\" — the enclosing sub-project name matched a PAI alias (fallback)\n * - \"none\"      — unresolved; the task stays in the findings inbox\n */\nexport type OwnerSource = \"label\" | \"container\" | \"none\";\n\nexport interface TaskOwner {\n  /** Resolved PAI project short name, e.g. \"acme-api\". Null when UNROUTED. */\n  project: string | null;\n  /** Absolute path to the project root. Null when UNROUTED. */\n  rootPath: string | null;\n  source: OwnerSource;\n  /**\n   * The raw string that resolution was attempted against, kept for diagnostics\n   * when `source` is \"none\" — e.g. a \"Reading List 📚\" container matches no\n   * PAI project, which is expected rather than a fault.\n   */\n  rawHint?: string;\n}\n\n/** An unresolved owner. UNROUTED is a normal state, not an error. */\nexport const UNROUTED: TaskOwner = {\n  project: null,\n  rootPath: null,\n  source: \"none\",\n};\n\n// ---------------------------------------------------------------------------\n// Tasks\n// ---------------------------------------------------------------------------\n\nexport type TaskPriority = \"p1\" | \"p2\" | \"p3\" | \"p4\";\n\nexport interface Task {\n  /** Provider-native task ID. Opaque; never parsed. */\n  id: string;\n  title: string;\n  /**\n   * Full procedure AND reasoning — enough that the task is actionable months\n   * later, or by the user alone, without re-deriving anything. Enforced at\n   * filing time rather than left to discipline.\n   */\n  body: string;\n  owner: TaskOwner;\n  /** ISO 8601 date or datetime. Null when the task has no due date. */\n  due: string | null;\n  /**\n   * The tracker's own recurrence text, e.g. \"every day at 08:00\". Null for a\n   * one-off.\n   *\n   * Kept verbatim rather than parsed into a rule because it is also the only\n   * way to write a recurrence back: Todoist re-parses this string, and it is\n   * what lets a due date be restored without destroying the recurrence.\n   */\n  recurrence?: string | null;\n  priority: TaskPriority;\n  labels: string[];\n  /**\n   * Stable reference to the artifact this task is about. Prefer a `hook://`\n   * URL over a filesystem path — it survives renames and moves, and opens in\n   * DEVONthink To Go on iOS.\n   */\n  sourceUrl?: string;\n  /** True for organizational headers that cannot be completed. */\n  isHeader?: boolean;\n}\n\n/** A task being filed. `owner` is a project short name, resolved on write. */\nexport interface NewTask {\n  title: string;\n  body: string;\n  owner?: string | null;\n  due?: string;\n  priority?: TaskPriority;\n  labels?: string[];\n  sourceUrl?: string;\n  /**\n   * Sub-project to file into, created if absent.\n   *\n   * The convention is one sub-project per PAI project under the bus root. It\n   * previously existed only in the shape of the data, so every session had to\n   * re-derive it — and a session that inferred cautiously filed flat instead,\n   * which is exactly the pile the convention prevents.\n   */\n  into?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Provider interface\n// ---------------------------------------------------------------------------\n\nexport interface ListOptions {\n  /** Only tasks due on or before this ISO date. Omit for all open tasks. */\n  dueBefore?: string;\n  /** Restrict to one resolved owner. Omit for every owner. */\n  owner?: string;\n  /** Include tasks that resolved to UNROUTED. Default: true. */\n  includeUnrouted?: boolean;\n  limit?: number;\n}\n\nexport interface TaskProvider {\n  readonly providerId: ProviderId;\n\n  /**\n   * False when no credential is configured. The bus degrades to a no-op\n   * rather than failing — a user without a tracker still gets working PAI.\n   */\n  isConfigured(): boolean;\n\n  listOpen(opts: ListOptions): Promise<Task[]>;\n  add(task: NewTask): Promise<Task>;\n  complete(id: string): Promise<void>;\n\n  /**\n   * Rewrite a task's due date through the tracker's natural-language field.\n   *\n   * Optional, and deliberately expressed as a string rather than a date: a\n   * recurring task's schedule and its next occurrence are the same field, so\n   * moving the date without the rule silently downgrades a routine to a one-off.\n   * A provider that cannot express both at once should not offer this.\n   */\n  setDue?(id: string, dueString: string): Promise<void>;\n\n  /**\n   * Sub-projects under the bus root — the set of addresses a task can be filed\n   * against, one per session.\n   *\n   * Optional because it is not universal: a tracker may address work by tag or\n   * list rather than by nested project, and forcing a nesting concept onto one\n   * that has none would mean faking it. A provider without these simply does\n   * not offer session-scoped inboxes, and callers say so rather than failing.\n   */\n  listSubProjects?(): Promise<Array<{ id: string; name: string }>>;\n  findOrCreateSubProject?(name: string): Promise<{ id: string; created: boolean }>;\n\n  /**\n   * The comment thread on a task, oldest first.\n   *\n   * Optional because not every tracker has threaded comments. Where it exists,\n   * the thread is usually where the reasoning lives — the question, the answer,\n   * the correction — and completing the task takes it out of view. That is what\n   * the archive exists to keep.\n   */\n  listComments?(taskId: string): Promise<Array<{ id: string; content: string; postedAt?: string }>>;\n\n  /**\n   * One task by id, whether open or completed.\n   *\n   * Needed because archiving runs at or after completion, and a completed task\n   * is gone from `listOpen` — which is exactly the moment its discussion stops\n   * being visible and most needs keeping.\n   */\n  getTask?(id: string): Promise<Task | null>;\n}\n\n// ---------------------------------------------------------------------------\n// Configuration\n// ---------------------------------------------------------------------------\n\nexport interface TodoistProviderConfig {\n  enabled: boolean;\n  /**\n   * API token. Resolution order is apiKey → TODOIST_API_KEY env → unconfigured.\n   *\n   * Never read this from another tool's config file. PAI ships as a product;\n   * scraping ~/.claude.json for a key belonging to the Todoist MCP is not\n   * acceptable even though the key is sitting there.\n   */\n  apiKey?: string;\n  /**\n   * Tracker project ID that roots the bus (the \"Claude 🤖\" project).\n   *\n   * Stored as an ID, never a name. Todoist's project search silently returns\n   * zero results for names containing emoji — resolving by name would report\n   * \"no tasks\" instead of failing, which is the exact class of silent failure\n   * this subsystem exists to surface.\n   */\n  rootProjectId?: string;\n  /** Section ID for the findings inbox. Tasks land here when UNROUTED. */\n  findingsSectionId?: string;\n}\n\nexport interface TaskConfig {\n  /** Master switch. When false the bus is inert. */\n  enabled: boolean;\n  providers: {\n    todoist: TodoistProviderConfig;\n  };\n  /**\n   * Dispatch work to the owning session automatically, spawning one if absent.\n   * Requires AIBroker. When false — or when AIBroker is unavailable — PAI\n   * reports which project owns each task and leaves acting to the user.\n   */\n  autoDispatch: boolean;\n\n  /**\n   * Seconds AIBroker may spend on a single dispatch, spawn included.\n   *\n   * Passed down to the transport so both sides share one deadline. Raise it on\n   * a loaded machine where sessions are slow to start accepting input.\n   */\n  dispatchTimeoutSecs?: number;\n\n  /**\n   * Project a task goes to when it carries the bare `pai` marker and its\n   * location says nothing — an Inbox capture, typically.\n   *\n   * This is the one thing a task's location cannot express: \"an AI should take\n   * this, and I do not know which one yet\". Everything else is answered by the\n   * project the task sits in.\n   *\n   * Unset means such a task stays UNROUTED, which is a legitimate choice: it\n   * then surfaces in the findings inbox for triage rather than being guessed at.\n   */\n  defaultOwner?: string;\n}\n\nexport const DEFAULT_TASK_CONFIG: TaskConfig = {\n  enabled: false,\n  providers: {\n    todoist: {\n      enabled: false,\n    },\n  },\n  autoDispatch: false,\n};\n\n// ---------------------------------------------------------------------------\n// Dispatch results\n// ---------------------------------------------------------------------------\n\n/**\n * What happened to one task during a dispatch run.\n *\n * - \"delivered\"    — sent to an already-running session, and confirmed submitted\n * - \"queued\"       — typed into a live session that was mid-turn, so submission\n *                    could not be confirmed inside the window. This is delivery:\n *                    Claude Code holds typed input until the current turn ends.\n *                    Never retried — the text is already in the input box, so a\n *                    second attempt is a second copy, not a retry. One trigger\n *                    arrived three times on 2026-08-01 for exactly that reason.\n * - \"spawned\"      — none running; one was launched, came up, and received it\n * - \"unrouted\"     — no owner resolved; left in the findings inbox\n * - \"unlaunchable\" — an owner resolved but no PAI alias exists to launch it\n * - \"unreachable\"  — a session was launched but never became ready to accept input\n * - \"skipped\"      — autoDispatch is off, or no transport; reported only\n *\n * `unlaunchable` and `unreachable` are distinct because the fixes differ:\n * the first is a setup gap (register an alias), the second is a runtime\n * failure (find out why the session did not come up). Collapsing them would\n * send users looking in the wrong place.\n *\n * None of these are errors. A task that could not be delivered is a routing\n * result to report, not an exception to throw.\n */\nexport type DispatchOutcome =\n  | \"delivered\"\n  | \"queued\"\n  | \"spawned\"\n  | \"unrouted\"\n  | \"unlaunchable\"\n  | \"unreachable\"\n  | \"skipped\";\n\nexport interface DispatchResult {\n  task: Task;\n  outcome: DispatchOutcome;\n  /** Session the task reached, when it reached one. */\n  session?: string;\n  /** Why the task did not reach a session. Present on failure outcomes. */\n  reason?: string;\n}\n","/**\n * json-store.ts — read/write JSON config files without destroying them\n *\n * The failure this exists to prevent:\n *\n *     try { return JSON.parse(read(path)); } catch { return {}; }\n *     ... later ...\n *     write(path, JSON.stringify(ourData));\n *\n * An unreadable file becomes an empty object, and the next write makes that\n * permanent. Silently, exit code 0. This shape appeared three times in this\n * repo against three different files, and twice in AIBroker.\n *\n * The distinction that matters is between *missing* and *unreadable*:\n *\n *   missing     — legitimate first run. Start fresh; writing is safe.\n *   unreadable  — the file exists and we could not parse it. Those bytes are\n *                 the only copy of something. Never overwrite them.\n *\n * Collapsing the second into the first is the bug.\n *\n * NOT everything deserves this guard. For a transient buffer — an undelivered\n * message queue, a cache — starting fresh IS the correct recovery, and\n * refusing to write would disable the feature permanently. Use `writeJsonAtomic`\n * alone there: it still prevents a crash from truncating a good file, without\n * blocking recovery. Reserve `readJsonStrict` for data a user cannot rebuild.\n */\n\nimport {\n  existsSync,\n  readFileSync,\n  writeFileSync,\n  copyFileSync,\n  renameSync,\n  unlinkSync,\n  mkdirSync,\n} from \"node:fs\";\nimport { dirname } from \"node:path\";\n\n/**\n * Read a JSON file, distinguishing \"absent\" from \"damaged\".\n *\n * @param path   file to read\n * @param label  how to name it to the user, e.g. \"~/.claude.json\"\n * @throws if the file exists but cannot be read or parsed\n */\nexport function readJsonStrict(path: string, label = path): Record<string, unknown> {\n  if (!existsSync(path)) return {};\n\n  let raw: string;\n  try {\n    raw = readFileSync(path, \"utf8\");\n  } catch (e) {\n    throw new Error(\n      `Could not read ${label}: ${e instanceof Error ? e.message : String(e)}\\n` +\n      `Refusing to continue — writing now would replace its contents with ours alone.`\n    );\n  }\n\n  try {\n    return JSON.parse(raw) as Record<string, unknown>;\n  } catch (e) {\n    throw new Error(\n      `${label} exists but is not valid JSON: ${e instanceof Error ? e.message : String(e)}\\n` +\n      `Refusing to continue — overwriting it would destroy whatever it holds.\\n` +\n      `Repair the file, or move it aside and re-run this command.`\n    );\n  }\n}\n\n/**\n * Leaf values that mean \"type word\", not data.\n *\n * A config whose top-level keys map to bare type words is a serialized schema,\n * not a configuration — the exact shape found over the live config on\n * 2026-09-18 (399 bytes: `socketPath: string`, `indexIntervalSecs: int`, …).\n * No real config carries three of these at its top level; a dump carries\n * nothing else.\n */\nconst SCHEMA_TYPE_WORDS = new Set([\n  \"string\", \"int\", \"integer\", \"number\", \"bool\", \"boolean\",\n  \"float\", \"double\", \"object\", \"array\", \"any\", \"unknown\",\n]);\n\nfunction schemaDumpHits(data: Record<string, unknown>): number {\n  let hits = 0;\n  for (const v of Object.values(data)) {\n    if (typeof v === \"string\" && SCHEMA_TYPE_WORDS.has(v.trim())) hits += 1;\n  }\n  return hits;\n}\n\n/**\n * Write JSON without risking the existing file.\n *\n * Keeps a `.bak-pai` copy of the previous contents, then writes to a temp file\n * and renames. Rename is atomic within a filesystem, so a crash mid-write\n * leaves the original intact rather than truncated — which is how these files\n * become corrupt in the first place.\n */\nexport function writeJsonAtomic(\n  path: string,\n  data: Record<string, unknown>,\n  opts: { backup?: boolean; label?: string } = {}\n): void {\n  const { backup = true, label = path } = opts;\n  if (schemaDumpHits(data) >= 3) {\n    throw new Error(\n      `${label}: refusing to write — the object looks like a type schema, not values ` +\n        `(several top-level keys map to bare type words). That is the corruption ` +\n        `signature of 2026-09-18: the caller serialized the schema instead of the ` +\n        `config. Nothing was written.`\n    );\n  }\n  const serialized = JSON.stringify(data, null, 2) + \"\\n\";\n\n  const dir = dirname(path);\n  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n\n  if (backup && existsSync(path)) {\n    try {\n      copyFileSync(path, `${path}.bak-pai`);\n    } catch (e) {\n      throw new Error(\n        `Could not back up ${label}: ${e instanceof Error ? e.message : String(e)}\\n` +\n        `Refusing to write without a backup.`\n      );\n    }\n  }\n\n  const tmp = `${path}.tmp-pai-${process.pid}`;\n  try {\n    writeFileSync(tmp, serialized, \"utf8\");\n    renameSync(tmp, path);\n  } catch (e) {\n    try { if (existsSync(tmp)) unlinkSync(tmp); } catch { /* best effort */ }\n    throw new Error(\n      `Failed to write ${label}: ${e instanceof Error ? e.message : String(e)}\\n` +\n      `The original is unchanged.`\n    );\n  }\n}\n","/**\n * yaml-store.ts — generic, comment-preserving YAML file helpers shared by\n * every PAI config file that has a YAML form (config.yaml, voices.yaml;\n * workers.yaml keeps its own writer in workers-config.ts because it needs\n * provider-specific key-quoting, but its atomic-write tail is this module's\n * writeYamlFileAtomic).\n */\n\nimport {\n  existsSync,\n  readFileSync,\n  writeFileSync,\n  copyFileSync,\n  renameSync,\n  unlinkSync,\n  mkdirSync,\n  chmodSync,\n} from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport { Document, YAMLMap, parseDocument, type Node, type Pair, type Scalar } from \"yaml\";\n\nexport class YamlStoreError extends Error {}\n\n/**\n * Validate `text` parses as YAML, back up any existing file to `.bak-pai`,\n * then write via temp file + rename (atomic within a filesystem) at mode\n * 0600 — every PAI-owned YAML file can carry secrets, key or no key.\n */\nexport function writeYamlFileAtomic(path: string, text: string, opts: { label?: string } = {}): void {\n  const label = opts.label ?? path;\n  const parsed = parseDocument(text);\n  if (parsed.errors.length) {\n    throw new YamlStoreError(`refusing to write ${label}: ${parsed.errors[0].message} (previous file left unchanged)`);\n  }\n\n  const dir = dirname(path);\n  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n  if (existsSync(path)) {\n    try {\n      copyFileSync(path, `${path}.bak-pai`);\n    } catch (e) {\n      throw new YamlStoreError(\n        `Could not back up ${label}: ${e instanceof Error ? e.message : String(e)}\\nRefusing to write without a backup.`\n      );\n    }\n  }\n  const tmp = `${path}.tmp-pai-${process.pid}`;\n  try {\n    writeFileSync(tmp, text, { encoding: \"utf8\", mode: 0o600 });\n    renameSync(tmp, path);\n    chmodSync(path, 0o600);\n  } catch (e) {\n    try {\n      if (existsSync(tmp)) unlinkSync(tmp);\n    } catch {\n      /* best effort */\n    }\n    throw new YamlStoreError(`Failed to write ${label}: ${e instanceof Error ? e.message : String(e)}`);\n  }\n}\n\n/** Deep-equal for plain JSON-shaped values (objects/arrays/scalars). */\nexport function deepEqualJson(a: unknown, b: unknown): boolean {\n  return JSON.stringify(a) === JSON.stringify(b);\n}\n\nfunction isPlainObject(v: unknown): v is Record<string, unknown> {\n  return typeof v === \"object\" && v !== null && !Array.isArray(v);\n}\n\n/**\n * Apply the diff between `before` and `after` (arbitrary nested plain JSON\n * objects) onto a YAML Document, touching only the leaves/keys that actually\n * changed — this is what lets hand-written comments elsewhere in the file\n * survive a `set`/`unset`. Arrays and non-object values are replaced\n * wholesale when they differ; only plain objects are diffed key by key.\n */\nexport function syncPlainObjectIntoYamlDoc(\n  doc: Document,\n  before: unknown,\n  after: unknown,\n  path: (string | number)[] = []\n): void {\n  if (isPlainObject(after) && isPlainObject(before)) {\n    for (const key of Object.keys(after)) {\n      syncPlainObjectIntoYamlDoc(doc, before[key], after[key], [...path, key]);\n    }\n    for (const key of Object.keys(before)) {\n      if (!(key in after)) doc.deleteIn([...path, key]);\n    }\n    return;\n  }\n  if (after === undefined) {\n    if (before !== undefined) doc.deleteIn(path);\n    return;\n  }\n  if (!deepEqualJson(before, after)) {\n    doc.setIn(path, after);\n  }\n}\n\n/**\n * Attach a `# text` comment directly above the key at `path` inside a YAML\n * Document — used for the annotated section headers `pai config yaml`\n * writes. No-op if the path does not resolve to a mapping entry (e.g. the\n * key was omitted from the document).\n */\nexport function setYamlKeyComment(doc: Document, path: (string | number)[], text: string): void {\n  const parentPath = path.slice(0, -1);\n  const lastKey = path[path.length - 1];\n  const parent = parentPath.length ? doc.getIn(parentPath, true) : doc.contents;\n  if (!(parent instanceof YAMLMap)) return;\n  const pair = (parent.items as Pair[]).find((p) => (p.key as Scalar)?.value === lastKey);\n  if (pair) {\n    (pair.key as Node & { commentBefore?: string }).commentBefore = ` ${text}`;\n  }\n}\n","/**\n * main-config.ts — the dual-format (config.yaml / config.json) engine behind\n * the main PAI config file. Deliberately has NO dependency on\n * src/daemon/config.ts (which owns path resolution, PaiDaemonConfig and\n * DEFAULTS) so that module can import this one for loadConfig() without a\n * cycle; every path this module touches is passed in explicitly.\n *\n * Precedence, same shape as workers.yaml/workers/config.ts: config.yaml wins\n * when it exists, config.json otherwise. `pai config yaml` (see\n * src/cli/commands/config.ts) performs the one-time JSON→YAML conversion;\n * every writer that goes through readMainConfigRaw/writeMainConfigRaw (see\n * src/daemon/config.ts) keeps working unmodified either way.\n */\n\nimport { existsSync, readFileSync, renameSync } from \"node:fs\";\nimport { dirname, join, basename } from \"node:path\";\nimport { Document, parseDocument } from \"yaml\";\nimport { readJsonStrict, writeJsonAtomic } from \"./json-store.js\";\nimport { writeYamlFileAtomic, syncPlainObjectIntoYamlDoc, deepEqualJson, setYamlKeyComment } from \"./yaml-store.js\";\n\nexport class MainConfigError extends Error {}\n\n/** The config.yaml sibling of a given config.json path (same directory). */\nexport function yamlSiblingPath(jsonPath: string): string {\n  return join(dirname(jsonPath), \"config.yaml\");\n}\n\n/** The path a user should be pointed at: config.yaml when it exists (same\n *  precedence as readDualFormatConfigRaw), else config.json. */\nexport function resolvedMainConfigPath(jsonPath: string, yamlPath: string = yamlSiblingPath(jsonPath)): string {\n  return existsSync(yamlPath) ? yamlPath : jsonPath;\n}\n\n/**\n * Read the raw main config: config.yaml if it exists, else config.json\n * (readJsonStrict — missing is `{}`, damaged throws), matching\n * readWorkersSection's precedence for workers.yaml/JSON.\n */\nexport function readDualFormatConfigRaw(jsonPath: string, yamlPath: string = yamlSiblingPath(jsonPath)): Record<string, unknown> {\n  if (existsSync(yamlPath)) {\n    let text: string;\n    try {\n      text = readFileSync(yamlPath, \"utf8\");\n    } catch (e) {\n      throw new MainConfigError(`Could not read ${yamlPath}: ${e instanceof Error ? e.message : String(e)}`);\n    }\n    const doc = parseDocument(text);\n    if (doc.errors.length) {\n      throw new MainConfigError(`${yamlPath}: ${doc.errors[0].message}`);\n    }\n    const parsed = doc.toJS();\n    if (parsed === null || parsed === undefined) return {};\n    if (typeof parsed !== \"object\" || Array.isArray(parsed)) {\n      throw new MainConfigError(`${yamlPath}: top level must be a mapping`);\n    }\n    return parsed as Record<string, unknown>;\n  }\n  return readJsonStrict(jsonPath, jsonPath);\n}\n\n/**\n * Write the raw main config back: comment-preserving diff-sync into\n * config.yaml when it exists, else plain writeJsonAtomic to config.json —\n * this is the one seam every writer of the main config (CLI commands,\n * identity, notifications, setup, workers) routes through, so a config.yaml\n * on disk is honored no matter which of them made the change.\n */\nexport function writeDualFormatConfigRaw(\n  jsonPath: string,\n  raw: Record<string, unknown>,\n  yamlPath: string = yamlSiblingPath(jsonPath)\n): void {\n  if (existsSync(yamlPath)) {\n    let text: string;\n    try {\n      text = readFileSync(yamlPath, \"utf8\");\n    } catch (e) {\n      throw new MainConfigError(`Could not read ${yamlPath}: ${e instanceof Error ? e.message : String(e)}`);\n    }\n    const doc = parseDocument(text);\n    if (doc.errors.length) {\n      throw new MainConfigError(`${yamlPath}: ${doc.errors[0].message}`);\n    }\n    const before = (doc.toJS() ?? {}) as Record<string, unknown>;\n    syncPlainObjectIntoYamlDoc(doc, before, raw);\n    try {\n      writeYamlFileAtomic(yamlPath, String(doc), { label: yamlPath });\n    } catch (e) {\n      throw new MainConfigError(e instanceof Error ? e.message : String(e));\n    }\n    return;\n  }\n  writeJsonAtomic(jsonPath, raw, { label: jsonPath });\n}\n\n// ---------------------------------------------------------------------------\n// JSON → YAML migration (\"pai config yaml\")\n// ---------------------------------------------------------------------------\n\n/**\n * Short annotation for every top-level PaiDaemonConfig field, shown as a `#`\n * comment above the field in the generated config.yaml. Mirrors (and must be\n * kept in sync with) the doc comments on PaiDaemonConfig in\n * src/daemon/config.ts — kept as a separate short-form table rather than\n * parsed from the TS source at runtime.\n */\nexport const MAIN_CONFIG_SECTION_COMMENTS: Record<string, string> = {\n  socketPath: \"Unix Domain Socket path for daemon IPC.\",\n  indexIntervalSecs: \"How often the daemon re-indexes changed files, in seconds.\",\n  embedIntervalSecs: \"How often the daemon runs the embedding pass, in seconds.\",\n  embedOnStartup:\n    \"Run an embed pass 60s after daemon start. Off by default: with a large backlog it makes every restart a CPU storm.\",\n  maintenanceHour: \"Local hour (0-23) to anchor the recurring index/embed cycle to, so maintenance runs in a fixed window.\",\n  storageBackend: 'Storage backend: \"sqlite\" (default) or \"postgres\".',\n  postgres: 'PostgreSQL connection settings, used when storageBackend is \"postgres\".',\n  embeddingModel: \"Embedding model name, used for semantic/hybrid search.\",\n  logLevel: \"Daemon log level: debug, info, warn, or error.\",\n  vaultPath: \"Obsidian vault root path for zettelkasten indexing, if any.\",\n  vaultProjectId: \"Registry project_id used for vault chunks in memory_chunks. Default: auto-detected.\",\n  notifications: \"Notification subsystem configuration.\",\n  search: \"Search defaults, applied when an MCP tool or CLI call doesn't specify one.\",\n  tasks: \"Task bus — optional external tracker for cross-session work.\",\n  identity:\n    'Who \"me\" is — addresses that count as the user\\'s own. Empty by default and never guessed: nothing is self-addressed until this is set.',\n  workers:\n    \"Non-provider worker settings (pane, routing, tree, cache keepalive, fallback). Providers/classes/mcp_sets live in workers.yaml, not here.\",\n};\n\n/** Keys whose leading-underscore string value is a JSON stand-in for a\n *  comment (e.g. `\"_comment\": \"...\"`, `\"_deliverToNote\": \"...\"`). Stripped\n *  from the generated YAML and re-attached as a real `#` comment above the\n *  nearest remaining sibling key in the same object. */\nfunction isCommentKey(key: string): boolean {\n  return key.startsWith(\"_\");\n}\n\ninterface CollectedComment {\n  path: (string | number)[];\n  text: string;\n}\n\n/** Recursively strip `_`-prefixed comment keys out of a plain JSON value,\n *  returning the cleaned value plus where each stripped comment belongs. */\nfunction stripUnderscoreComments(value: unknown, path: (string | number)[] = []): { cleaned: unknown; comments: CollectedComment[] } {\n  if (Array.isArray(value)) {\n    const comments: CollectedComment[] = [];\n    const cleaned = value.map((v, i) => {\n      const r = stripUnderscoreComments(v, [...path, i]);\n      comments.push(...r.comments);\n      return r.cleaned;\n    });\n    return { cleaned, comments };\n  }\n  if (typeof value === \"object\" && value !== null) {\n    const obj = value as Record<string, unknown>;\n    const cleaned: Record<string, unknown> = {};\n    const comments: CollectedComment[] = [];\n    const localComments: string[] = [];\n    for (const [k, v] of Object.entries(obj)) {\n      if (isCommentKey(k)) {\n        if (typeof v === \"string\" && v.trim()) localComments.push(v.trim());\n        continue;\n      }\n      const r = stripUnderscoreComments(v, [...path, k]);\n      cleaned[k] = r.cleaned;\n      comments.push(...r.comments);\n    }\n    // Attach every local comment above the first remaining key in this\n    // object (in source order) — good enough for the JSON-workaround shape\n    // this exists to convert (a `_comment` sibling explaining the object).\n    const firstKey = Object.keys(cleaned)[0];\n    if (localComments.length && firstKey !== undefined) {\n      comments.unshift({ path: [...path, firstKey], text: localComments.join(\" \") });\n    }\n    return { cleaned, comments };\n  }\n  return { cleaned: value, comments: [] };\n}\n\nexport interface MainConfigMigrateResult {\n  yamlPath: string;\n  yamlText: string;\n  /** Path the pre-migration JSON was renamed to; null on a dry run. */\n  backupPath: string | null;\n  dryRun: boolean;\n}\n\n/**\n * `pai config yaml`: convert config.json to config.yaml. Strips `_`-prefixed\n * JSON-comment-workaround keys into real `#` comments, adds a short\n * annotation above every top-level section (MAIN_CONFIG_SECTION_COMMENTS),\n * verifies the generated YAML re-parses to the exact same data before\n * writing anything, then renames the JSON aside to\n * `config.json.migrated-<YYYY-MM-DD>` (never deleted). Refuses when\n * config.yaml already exists unless `force`. `dryRun` computes and returns\n * the would-be YAML text without touching either file.\n */\nexport function migrateMainConfigToYaml(\n  jsonPath: string,\n  opts: { dryRun?: boolean; force?: boolean; yamlPath?: string; sectionComments?: Record<string, string> } = {}\n): MainConfigMigrateResult {\n  const yamlPath = opts.yamlPath ?? yamlSiblingPath(jsonPath);\n  if (existsSync(yamlPath) && !opts.force) {\n    throw new MainConfigError(`${yamlPath} already exists — edit it directly, or pass --force to regenerate it from ${jsonPath}`);\n  }\n  if (!existsSync(jsonPath)) {\n    throw new MainConfigError(`${jsonPath} does not exist — nothing to migrate`);\n  }\n\n  const raw = readJsonStrict(jsonPath, jsonPath);\n  const { cleaned, comments } = stripUnderscoreComments(raw);\n  const cleanedObj = cleaned as Record<string, unknown>;\n\n  const doc = new Document(cleanedObj);\n  const sectionComments = opts.sectionComments ?? MAIN_CONFIG_SECTION_COMMENTS;\n  for (const [key, text] of Object.entries(sectionComments)) {\n    if (key in cleanedObj) setYamlKeyComment(doc, [key], text);\n  }\n  for (const c of comments) {\n    setYamlKeyComment(doc, c.path, c.text);\n  }\n  const yamlText = String(doc);\n\n  // Round-trip check: the generated YAML must re-parse to exactly the data\n  // it was built from before anything is written or renamed.\n  const reparsed = parseDocument(yamlText).toJS();\n  if (!deepEqualJson(reparsed, cleanedObj)) {\n    throw new MainConfigError(\n      `${yamlPath}: generated YAML does not re-parse to the same data as ${jsonPath} — aborting, nothing written`\n    );\n  }\n\n  if (opts.dryRun) {\n    return { yamlPath, yamlText, backupPath: null, dryRun: true };\n  }\n\n  writeYamlFileAtomic(yamlPath, yamlText, { label: yamlPath });\n\n  const stamp = new Date().toISOString().slice(0, 10);\n  const backupPath = join(dirname(jsonPath), `${basename(jsonPath)}.migrated-${stamp}`);\n  renameSync(jsonPath, backupPath);\n\n  return { yamlPath, yamlText, backupPath, dryRun: false };\n}\n","/**\n * config.ts — Configuration loader for PAI Daemon\n *\n * Loads config from ~/.claude/pai/config.json (the pre-2026-09-19 location\n * was ~/.config/pai/config.json, briefly ~/.claude/pai.json in between —\n * see paiConfigFilePath/migrateConfigFile).\n * Deep-merges with defaults so partial configs work fine.\n * Expands ~ in path values at runtime.\n */\n\nimport { existsSync, mkdirSync, writeFileSync } from \"node:fs\";\nimport { homedir, userInfo } from \"node:os\";\nimport { join, dirname } from \"node:path\";\nimport type { NotificationConfig } from \"../notifications/types.js\";\nimport { DEFAULT_NOTIFICATION_CONFIG } from \"../notifications/types.js\";\nimport type { TaskConfig } from \"../tasks/types.js\";\nimport { DEFAULT_TASK_CONFIG } from \"../tasks/types.js\";\nimport { paiSocketPath } from \"../runtime-paths.js\";\nimport {\n  paiHomePath,\n  resolvePaiFile,\n  migratePaiFile,\n  PaiFileMigrationError,\n  type MigrateFileResult,\n} from \"../config/pai-home.js\";\nimport {\n  yamlSiblingPath,\n  readDualFormatConfigRaw,\n  writeDualFormatConfigRaw,\n  MainConfigError,\n} from \"../config/main-config.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface SearchConfig {\n  /** Default search mode: 'keyword', 'semantic', or 'hybrid'. Default: 'keyword'. */\n  mode: \"keyword\" | \"semantic\" | \"hybrid\";\n  /** Enable cross-encoder reranking by default. Default: true. */\n  rerank: boolean;\n  /** Recency boost half-life in days. 0 = off. Default: 90. */\n  recencyBoostDays: number;\n  /** Default max results. Default: 10. */\n  defaultLimit: number;\n  /** Default snippet length for MCP results. Default: 200. */\n  snippetLength: number;\n}\n\nexport interface PostgresConfig {\n  /** Connection string — if set, overrides individual host/port/etc. fields */\n  connectionString?: string;\n  /** Postgres host (default: \"localhost\") */\n  host?: string;\n  /** Postgres port (default: 5432) */\n  port?: number;\n  /** Postgres database name (default: \"pai\") */\n  database?: string;\n  /** Postgres user (default: \"pai\") */\n  user?: string;\n  /** Postgres password (default: \"pai\") */\n  password?: string;\n  /** Maximum pool connections (default: 5) */\n  maxConnections?: number;\n  /** Connection timeout in ms (default: 5000) */\n  connectionTimeoutMs?: number;\n}\n\n/**\n * Idle-triggered prompt-cache keepalive for interactive Claude Code sessions\n * (see docs/cache-keepalive.md, \"Interactive sessions\"). Distinct from\n * `workers.cacheKeepaliveSecs` (src/workers/config.ts), which re-arms a\n * *worker provider's* cache via trivial worker spawns on a fixed timer: this\n * beats a live interactive session only when it has actually gone idle long\n * enough to risk its 1h ephemeral cache expiring, and only within working\n * hours — a fixed timer would beat while the user is active (no-op, wasted\n * quota) or overnight (never pays back before the next real prompt anyway).\n */\nexport interface SessionsCacheKeepaliveConfig {\n  /** Off by default — arming it is the operator's explicit call. */\n  enabled: boolean;\n  /** Beat a session once it has been idle at least this long. Must be < the\n   *  provider's cache TTL (60 for the 1h ephemeral cache) or the beat is too\n   *  late to matter. */\n  idleMinutes: number;\n  /** Cap on consecutive beats per idle stretch; resets when the user prompts\n   *  the session again for real (not with the keepalive word itself). */\n  maxBeats: number;\n  /** Local time window \"HH:MM-HH:MM\" outside which no beats are sent. */\n  activeHours: string;\n  /** Skip sessions whose context is too small to be worth a beat. */\n  minContextTokens: number;\n  /** The exact word typed into the session; kept to one word so the\n   *  UserPromptSubmit hook can recognise it and answer minimally. */\n  prompt: string;\n}\n\nexport interface SessionsConfig {\n  cacheKeepalive: SessionsCacheKeepaliveConfig;\n}\n\nexport interface PaiDaemonConfig {\n  /** Unix Domain Socket path for IPC */\n  socketPath: string;\n\n  /** Index schedule interval in seconds (default: 300 = 5 minutes) */\n  indexIntervalSecs: number;\n\n  /** Embedding schedule interval in seconds (default: 600 = 10 minutes) */\n  embedIntervalSecs: number;\n  /** Run an embed pass 60s after daemon start. Off by default: with a large\n   *  backlog it makes every restart a CPU storm, and it ignores the interval. */\n  embedOnStartup: boolean;\n\n  /** Local hour (0-23) to anchor the recurring index/embed cycle to. When unset,\n   *  the cycle is anchored to daemon start, so a daytime restart pins every\n   *  later pass to daytime too — a 24h interval does not by itself mean \"at\n   *  night\". Set this to run maintenance in a fixed window regardless of when\n   *  the machine last booted. */\n  maintenanceHour?: number;\n\n  /** Storage backend: \"sqlite\" (default) or \"postgres\" */\n  storageBackend: \"sqlite\" | \"postgres\";\n\n  /** PostgreSQL connection config (used when storageBackend = \"postgres\") */\n  postgres?: PostgresConfig;\n\n  /** Embedding model name (used for semantic/hybrid search) */\n  embeddingModel: string;\n\n  /** Log level */\n  logLevel: \"debug\" | \"info\" | \"warn\" | \"error\";\n\n  /** Obsidian vault root path for zettelkasten indexing. If set, vault indexing runs alongside project indexing. */\n  vaultPath?: string;\n\n  /** Registry project_id to use for vault chunks in memory_chunks. Default: auto-detected. */\n  vaultProjectId?: number;\n\n  /** Notification subsystem configuration */\n  notifications: NotificationConfig;\n\n  /** Search defaults — applied when MCP tool or CLI doesn't specify a value */\n  search: SearchConfig;\n\n  /** Task bus — optional external tracker for cross-session work */\n  tasks: TaskConfig;\n\n  /** Who \"me\" is — addresses that count as the user's own. */\n  identity: IdentityConfig;\n\n  /** Interactive-session settings (currently just the cache keepalive). */\n  sessions: SessionsConfig;\n}\n\n/**\n * The user's own identity, for anything that delivers back to them.\n *\n * This exists so \"my own address\" is a fact the system can check rather than\n * something a model infers from context. An assistant deciding on the spot\n * whether an address looks like the user's is exactly the judgement that should\n * not be re-made per message.\n *\n * Empty by default and never guessed at install time: an empty `selfEmails`\n * means nothing is self-addressed, so anything reading this fails closed.\n */\nexport interface IdentityConfig {\n  /**\n   * Where digests and \"mail me X\" requests are delivered.\n   *\n   * Must be a mailbox separate from the account doing the sending. Gmail files\n   * a message sent from an account to itself — or to one of its own domain\n   * aliases — under Sent only, and it never reaches the inbox. The send reports\n   * success, so this fails silently and looks exactly like delivery. Observed\n   * 2026-08-01: owner@example.ch → owner@example.de, sent fine, invisible.\n   *\n   * Where a separate mailbox is not available, deliver by writing the message\n   * and adding the INBOX label to it rather than relying on the send path.\n   */\n  deliverTo?: string;\n\n  /**\n   * Every address that counts as the user's own.\n   *\n   * Used as an allowlist by anything that may act without review — outbound\n   * mail being the case that motivated it. Membership is the whole test: an\n   * address that is not listed is not the user's, however similar it looks.\n   * Plus-aliases and domain aliases must be listed explicitly rather than\n   * pattern-matched, because the patterns that would match them also match\n   * addresses belonging to other people.\n   */\n  selfEmails: string[];\n\n  /** The account used to send on the user's behalf, when one is configured. */\n  sendingAccount?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Per-user Postgres isolation\n// ---------------------------------------------------------------------------\n\n/** Derive a per-user Postgres database name: pai_<username> */\nfunction perUserDbName(): string {\n  const username = userInfo().username;\n  // Sanitize: only allow alphanumeric and underscore for Postgres identifiers\n  const safe = username.replace(/[^a-zA-Z0-9_]/g, \"_\").toLowerCase();\n  return `pai_${safe}`;\n}\n\n/** Derive the per-user connection string */\nfunction perUserConnectionString(): string {\n  const db = perUserDbName();\n  return `postgresql://pai:pai@localhost:5432/${db}`;\n}\n\n// ---------------------------------------------------------------------------\n// Defaults\n// ---------------------------------------------------------------------------\n\n/**\n * idleMinutes=50 sits under the 60-minute ephemeral-1h TTL with margin for\n * scheduler jitter; maxBeats=6 (~5h of coverage at one beat per idle stretch)\n * and activeHours 08:00-22:00 keep an unattended overnight machine from\n * beating all night for a session nobody will return to before the cache\n * would have expired anyway.\n */\nexport const DEFAULT_SESSIONS_CACHE_KEEPALIVE: SessionsCacheKeepaliveConfig = {\n  enabled: false,\n  idleMinutes: 50,\n  maxBeats: 6,\n  activeHours: \"08:00-22:00\",\n  minContextTokens: 20_000,\n  prompt: \"keepalive\",\n};\n\nexport const DEFAULTS: PaiDaemonConfig = {\n  socketPath: paiSocketPath(),\n  indexIntervalSecs: 300,\n  embedIntervalSecs: 600,\n  embedOnStartup: false,\n  storageBackend: \"sqlite\",\n  postgres: {\n    connectionString: perUserConnectionString(),\n    maxConnections: 5,\n    connectionTimeoutMs: 5000,\n  },\n  embeddingModel: \"Snowflake/snowflake-arctic-embed-m-v1.5\",\n  logLevel: \"info\",\n  notifications: DEFAULT_NOTIFICATION_CONFIG,\n  tasks: DEFAULT_TASK_CONFIG,\n  // Deliberately empty. An install must not guess who the user is: a wrong\n  // guess here is an address that can be mailed without review.\n  identity: { selfEmails: [] },\n  sessions: { cacheKeepalive: { ...DEFAULT_SESSIONS_CACHE_KEEPALIVE } },\n  search: {\n    mode: \"keyword\",\n    rerank: true,\n    recencyBoostDays: 90,\n    defaultLimit: 10,\n    snippetLength: 200,\n  },\n};\n\n/** Config template — generated at runtime so the DB name is per-user */\nfunction configTemplate(): string {\n  return `{\n  \"socketPath\": \"/tmp/pai.sock\",\n  \"indexIntervalSecs\": 300,\n  \"embedIntervalSecs\": 600,\n  \"storageBackend\": \"sqlite\",\n  \"postgres\": {\n    \"connectionString\": \"${perUserConnectionString()}\",\n    \"maxConnections\": 5,\n    \"connectionTimeoutMs\": 5000\n  },\n  \"embeddingModel\": \"Snowflake/snowflake-arctic-embed-m-v1.5\",\n  \"logLevel\": \"info\",\n  \"vaultPath\": \"\",\n  \"vaultProjectId\": 0,\n  \"search\": {\n    \"mode\": \"keyword\",\n    \"rerank\": true,\n    \"recencyBoostDays\": 90,\n    \"defaultLimit\": 10,\n    \"snippetLength\": 200\n  }\n}\n`;\n}\n\n// ---------------------------------------------------------------------------\n// Path helpers\n// ---------------------------------------------------------------------------\n\n/** Expand a leading ~ to the real home directory */\nexport function expandHome(p: string): string {\n  if (p === \"~\" || p.startsWith(\"~/\") || p.startsWith(\"~\\\\\")) {\n    return join(homedir(), p.slice(1));\n  }\n  return p;\n}\n\n/** Canonical location since 2026-09-19: under the PAI_HOME namespace dir,\n *  so nothing PAI writes can collide with a file Claude Code itself owns. */\nconst NEW_CONFIG_FILE = paiHomePath(\"config.json\");\n\n/** Briefly the canonical location between 2026-09-19's two migrations —\n *  read during the transition, never written to again. */\nconst OLD_CONFIG_FILE = join(homedir(), \".claude\", \"pai.json\");\n\n/** Where the config lived before 2026-09-19 — read during the transition,\n *  never written to once NEW_CONFIG_FILE exists (see `pai config migrate`). */\nconst LEGACY_CONFIG_FILE = join(homedir(), \".config\", \"pai\", \"config.json\");\n\n/**\n * The path any read/write of the PAI config actually uses: PAI_CONFIG_FILE\n * (tests, power users) first, else the new PAI_HOME location if it exists,\n * else the most recent old location that is actually on disk (printing a\n * one-time notice), else the new location (the target a first write creates).\n */\nexport function paiConfigFilePath(): string {\n  const override = process.env.PAI_CONFIG_FILE;\n  if (override) return override;\n  return resolvePaiFile(NEW_CONFIG_FILE, [OLD_CONFIG_FILE, LEGACY_CONFIG_FILE], \"pai config migrate\");\n}\n\nexport const CONFIG_FILE = paiConfigFilePath();\nexport const CONFIG_DIR = dirname(CONFIG_FILE);\n\n/** config.yaml — the canonical main config once `pai config yaml` has run,\n *  read/written by readMainConfigRaw/writeMainConfigRaw instead of\n *  CONFIG_FILE whenever it exists (see src/config/main-config.ts). */\nexport function paiConfigYamlFilePath(): string {\n  return yamlSiblingPath(CONFIG_FILE);\n}\n\n/**\n * Read the raw main config object exactly as every non-typed writer\n * (identity, notifications, obsidian, setup, workers, memory settings) needs\n * it: config.yaml when it exists, else CONFIG_FILE. `path` overrides the\n * JSON location (tests, or workers/config.ts's parametrized CONFIG_FILE) —\n * its YAML sibling (same dir, `config.yaml`) is what gets preferred.\n */\nexport function readMainConfigRaw(path: string = CONFIG_FILE): Record<string, unknown> {\n  try {\n    return readDualFormatConfigRaw(path);\n  } catch (e) {\n    throw e instanceof MainConfigError ? new Error(e.message) : e;\n  }\n}\n\n/**\n * Write the raw main config object back through the same seam: a\n * comment-preserving sync into config.yaml when it exists, else a plain\n * writeJsonAtomic to `path`. Every writer of the main config must call this\n * instead of touching CONFIG_FILE directly, so a config.yaml on disk is\n * honored no matter which of them made the change.\n */\nexport function writeMainConfigRaw(raw: Record<string, unknown>, path: string = CONFIG_FILE): void {\n  try {\n    writeDualFormatConfigRaw(path, raw);\n  } catch (e) {\n    throw e instanceof MainConfigError ? new Error(e.message) : e;\n  }\n}\n\nexport const ConfigMigrationError = PaiFileMigrationError;\nexport type ConfigMigrateResult = MigrateFileResult;\n\n/**\n * `pai config migrate`: move config.json (from ~/.claude/pai.json or\n * ~/.config/pai/config.json, whichever is found) to ~/.claude/pai/config.json\n * byte-for-byte, verify the copy, then rename the old file aside as\n * config.json.migrated-<YYYYMMDD> (never deleted).\n */\nexport function migrateConfigFile(opts: { dryRun?: boolean } = {}): ConfigMigrateResult {\n  return migratePaiFile(NEW_CONFIG_FILE, [OLD_CONFIG_FILE, LEGACY_CONFIG_FILE], opts);\n}\n\n// ---------------------------------------------------------------------------\n// Deep merge (handles nested objects, not arrays)\n// ---------------------------------------------------------------------------\n\nfunction deepMerge<T extends object>(\n  target: T,\n  source: Record<string, unknown>\n): T {\n  const result = { ...target };\n  for (const key of Object.keys(source)) {\n    const srcVal = source[key];\n    if (srcVal === undefined || srcVal === null) continue;\n    const tgtVal = (target as Record<string, unknown>)[key];\n    if (\n      typeof srcVal === \"object\" &&\n      !Array.isArray(srcVal) &&\n      typeof tgtVal === \"object\" &&\n      tgtVal !== null &&\n      !Array.isArray(tgtVal)\n    ) {\n      (result as Record<string, unknown>)[key] = deepMerge(\n        tgtVal as object,\n        srcVal as Record<string, unknown>\n      );\n    } else {\n      (result as Record<string, unknown>)[key] = srcVal;\n    }\n  }\n  return result;\n}\n\n// ---------------------------------------------------------------------------\n// Config loader\n// ---------------------------------------------------------------------------\n\n/**\n * Load configuration: config.yaml (see paiConfigYamlFilePath) if it exists,\n * else CONFIG_FILE (see paiConfigFilePath), else defaults. Returns defaults\n * deep-merged with any values found in the file.\n */\nexport function loadConfig(): PaiDaemonConfig {\n  if (!existsSync(CONFIG_FILE) && !existsSync(paiConfigYamlFilePath())) {\n    return { ...DEFAULTS };\n  }\n\n  let parsed: Record<string, unknown>;\n  try {\n    parsed = readMainConfigRaw();\n  } catch (e) {\n    process.stderr.write(\n      `[pai-daemon] Could not read config: ${e instanceof Error ? e.message : String(e)}\\n`\n    );\n    return { ...DEFAULTS };\n  }\n\n  // Compat: config.json may use \"obsidianVaultPath\" (legacy key) instead of \"vaultPath\".\n  // Map it across so the daemon picks it up correctly.\n  if (parsed.obsidianVaultPath && !parsed.vaultPath) {\n    parsed.vaultPath = parsed.obsidianVaultPath;\n    process.stderr.write(\n      `[pai-daemon] Config: mapped obsidianVaultPath → vaultPath (${parsed.vaultPath})\\n`\n    );\n  }\n\n  return deepMerge(DEFAULTS, parsed);\n}\n\n/**\n * Ensure CONFIG_DIR exists and write a default config template to CONFIG_FILE\n * if none exists yet. Call this only from the `serve` command.\n */\nexport function ensureConfigDir(): void {\n  if (!existsSync(CONFIG_DIR)) {\n    mkdirSync(CONFIG_DIR, { recursive: true });\n    process.stderr.write(\n      `[pai-daemon] Created config directory: ${CONFIG_DIR}\\n`\n    );\n  }\n\n  if (!existsSync(CONFIG_FILE) && !existsSync(paiConfigYamlFilePath())) {\n    try {\n      writeFileSync(CONFIG_FILE, configTemplate(), \"utf-8\");\n      process.stderr.write(\n        `[pai-daemon] Wrote default config to: ${CONFIG_FILE}\\n`\n      );\n    } catch (e) {\n      process.stderr.write(\n        `[pai-daemon] Could not write default config: ${e}\\n`\n      );\n    }\n  }\n}\n"],"mappings":"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"}