@tekmidian/pai 0.40.1 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +1 -0
  2. package/dist/aibroker-client-BP74gGwD.mjs +215 -0
  3. package/dist/aibroker-client-BP74gGwD.mjs.map +1 -0
  4. package/dist/aibroker-client-C6m71p6O.mjs +3 -0
  5. package/dist/{auto-route-BwzmlW3W.mjs → auto-route-_5zzJz5q.mjs} +2 -2
  6. package/dist/{auto-route-BwzmlW3W.mjs.map → auto-route-_5zzJz5q.mjs.map} +1 -1
  7. package/dist/browser-mcp/index.d.mts +1 -0
  8. package/dist/browser-mcp/index.mjs +352 -0
  9. package/dist/browser-mcp/index.mjs.map +1 -0
  10. package/dist/{chain-DhVHVnmT.mjs → chain-DGXNoTwc.mjs} +93 -320
  11. package/dist/chain-DGXNoTwc.mjs.map +1 -0
  12. package/dist/cli/index.mjs +12 -10
  13. package/dist/cli/index.mjs.map +1 -1
  14. package/dist/cli/program.mjs +12 -10
  15. package/dist/{clusters-DRHhfli9.mjs → clusters-j0ZuNYp9.mjs} +1 -1
  16. package/dist/{clusters-DRHhfli9.mjs.map → clusters-j0ZuNYp9.mjs.map} +1 -1
  17. package/dist/{config-D1G9IFpn.mjs → config-B64vFg14.mjs} +3 -50
  18. package/dist/config-B64vFg14.mjs.map +1 -0
  19. package/dist/{config-DD52BPB-.mjs → config-C_ErGddD.mjs} +1 -1
  20. package/dist/{config-Dl8lT4Lu.mjs → config-DgzR5sfb.mjs} +2 -2
  21. package/dist/{config-Dl8lT4Lu.mjs.map → config-DgzR5sfb.mjs.map} +1 -1
  22. package/dist/{context-handover-cache-C6_rs7JT.mjs → context-handover-cache-dUnSZwaA.mjs} +4 -4
  23. package/dist/{context-handover-cache-C6_rs7JT.mjs.map → context-handover-cache-dUnSZwaA.mjs.map} +1 -1
  24. package/dist/daemon/index.mjs +13 -12
  25. package/dist/daemon/index.mjs.map +1 -1
  26. package/dist/daemon-CKBkhOsZ.mjs +23 -0
  27. package/dist/{daemon-CpdVUnl_.mjs → daemon-DsvP8KMy.mjs} +357 -27
  28. package/dist/daemon-DsvP8KMy.mjs.map +1 -0
  29. package/dist/daemon-mcp/index.mjs +20 -6
  30. package/dist/daemon-mcp/index.mjs.map +1 -1
  31. package/dist/detector-C-7KRi42.mjs +5 -0
  32. package/dist/{detector-smpCm7ku.mjs → detector-C73Nxm94.mjs} +1 -1
  33. package/dist/{detector-smpCm7ku.mjs.map → detector-C73Nxm94.mjs.map} +1 -1
  34. package/dist/{factory-Bc5EyN54.mjs → factory-DEb9NmHJ.mjs} +7 -7
  35. package/dist/{factory-Bc5EyN54.mjs.map → factory-DEb9NmHJ.mjs.map} +1 -1
  36. package/dist/factory-Dbo8yNPi.mjs +3 -0
  37. package/dist/{fallback-CdXv-Np0.mjs → fallback-5UJO-RyX.mjs} +5 -4
  38. package/dist/{fallback-CdXv-Np0.mjs.map → fallback-5UJO-RyX.mjs.map} +1 -1
  39. package/dist/hooks/worker-status-line.mjs +9 -4
  40. package/dist/hooks/worker-status-line.mjs.map +3 -3
  41. package/dist/hooks/worker-supervision.mjs +593 -0
  42. package/dist/hooks/worker-supervision.mjs.map +7 -0
  43. package/dist/{indexer-backend-rohVVKrA.mjs → indexer-backend-BPTzKFZU.mjs} +1 -1
  44. package/dist/{indexer-backend-rohVVKrA.mjs.map → indexer-backend-BPTzKFZU.mjs.map} +1 -1
  45. package/dist/{ipc-client-C7AVpsyv.mjs → ipc-client-BmypMNYk.mjs} +2 -2
  46. package/dist/{ipc-client-C7AVpsyv.mjs.map → ipc-client-BmypMNYk.mjs.map} +1 -1
  47. package/dist/{kg-entity-DbOMPdF9.mjs → kg-entity-r8duqhi9.mjs} +1 -1
  48. package/dist/{kg-entity-DbOMPdF9.mjs.map → kg-entity-r8duqhi9.mjs.map} +1 -1
  49. package/dist/{latent-ideas-iNp5s55R.mjs → latent-ideas-CtSca4qG.mjs} +2 -2
  50. package/dist/{latent-ideas-iNp5s55R.mjs.map → latent-ideas-CtSca4qG.mjs.map} +1 -1
  51. package/dist/{link-boost-NNzADHX_.mjs → link-boost-Bucnz3n8.mjs} +1 -1
  52. package/dist/{link-boost-NNzADHX_.mjs.map → link-boost-Bucnz3n8.mjs.map} +1 -1
  53. package/dist/{main-resolver-CM1IHbuu.mjs → main-resolver-BgIrC-D9.mjs} +4 -214
  54. package/dist/main-resolver-BgIrC-D9.mjs.map +1 -0
  55. package/dist/main-resolver-DXGuygFG.mjs +7 -0
  56. package/dist/{neighborhood-AGL7oXQ2.mjs → neighborhood-CM7Jgb51.mjs} +1 -1
  57. package/dist/{neighborhood-AGL7oXQ2.mjs.map → neighborhood-CM7Jgb51.mjs.map} +1 -1
  58. package/dist/{note-context-BHwYpSFf.mjs → note-context-DCwE2A-M.mjs} +1 -1
  59. package/dist/{note-context-BHwYpSFf.mjs.map → note-context-DCwE2A-M.mjs.map} +1 -1
  60. package/dist/{planner-Cm3g6fWH.mjs → planner-BLDJt3ZS.mjs} +6 -5
  61. package/dist/{planner-Cm3g6fWH.mjs.map → planner-BLDJt3ZS.mjs.map} +1 -1
  62. package/dist/{postgres-Dwxe290H.mjs → postgres-CtOzfoQU.mjs} +1 -1
  63. package/dist/{postgres-Dwxe290H.mjs.map → postgres-CtOzfoQU.mjs.map} +1 -1
  64. package/dist/{program-AR0qlYRg.mjs → program-CruRnLtc.mjs} +99 -84
  65. package/dist/program-CruRnLtc.mjs.map +1 -0
  66. package/dist/query-feedback-DGoVRaWD.mjs +3 -0
  67. package/dist/{query-feedback-DZfjI34w.mjs → query-feedback-Dqz-HC__.mjs} +1 -1
  68. package/dist/{query-feedback-DZfjI34w.mjs.map → query-feedback-Dqz-HC__.mjs.map} +1 -1
  69. package/dist/router-BTIGZR2_.mjs +3 -0
  70. package/dist/{router-DHracTUC.mjs → router-DD6D4j31.mjs} +1 -1
  71. package/dist/{router-DHracTUC.mjs.map → router-DD6D4j31.mjs.map} +1 -1
  72. package/dist/runtime-paths-rni52zHX.mjs +50 -0
  73. package/dist/runtime-paths-rni52zHX.mjs.map +1 -0
  74. package/dist/scope-NIwCGxRI.mjs +351 -0
  75. package/dist/scope-NIwCGxRI.mjs.map +1 -0
  76. package/dist/{server-BOuAOj9b.mjs → server-Cp7tNzNC.mjs} +19 -2
  77. package/dist/server-Cp7tNzNC.mjs.map +1 -0
  78. package/dist/skills/Worker/SKILL.md +13 -0
  79. package/dist/{sources-ju6AA7lL.mjs → sources-CS43CpuS.mjs} +1 -1
  80. package/dist/{sources-ju6AA7lL.mjs.map → sources-CS43CpuS.mjs.map} +1 -1
  81. package/dist/{sqlite-kFZeWCJ2.mjs → sqlite-Cq3gJcq9.mjs} +1 -1
  82. package/dist/{sqlite-kFZeWCJ2.mjs.map → sqlite-Cq3gJcq9.mjs.map} +1 -1
  83. package/dist/{state-ahFAbD8b.mjs → state-BTa10SOM.mjs} +1 -1
  84. package/dist/{state-ahFAbD8b.mjs.map → state-BTa10SOM.mjs.map} +1 -1
  85. package/dist/{state-DH_mp5Xe.mjs → state-RXepvbnO.mjs} +1 -1
  86. package/dist/{themes-DqhNUUQk.mjs → themes-B7h9YDiy.mjs} +1 -1
  87. package/dist/{themes-DqhNUUQk.mjs.map → themes-B7h9YDiy.mjs.map} +1 -1
  88. package/dist/{tools-COna9vZR.mjs → tools-BW3NLMPk.mjs} +1 -1
  89. package/dist/{tools-CMlHa9lZ.mjs → tools-LlzcV_eK.mjs} +14 -14
  90. package/dist/{tools-CMlHa9lZ.mjs.map → tools-LlzcV_eK.mjs.map} +1 -1
  91. package/dist/{trace-DTP6k4B_.mjs → trace-u4hT-pLD.mjs} +1 -1
  92. package/dist/{trace-DTP6k4B_.mjs.map → trace-u4hT-pLD.mjs.map} +1 -1
  93. package/dist/{vault-indexer-YMH22mLU.mjs → vault-indexer-mata9S7b.mjs} +1 -1
  94. package/dist/{vault-indexer-YMH22mLU.mjs.map → vault-indexer-mata9S7b.mjs.map} +1 -1
  95. package/dist/{work-queue-worker-B3nl8yyn.mjs → work-queue-worker-7Vkli4Yh.mjs} +4 -4
  96. package/dist/{work-queue-worker-B3nl8yyn.mjs.map → work-queue-worker-7Vkli4Yh.mjs.map} +1 -1
  97. package/dist/work-queue-worker-D1p2V-ae.mjs +14 -0
  98. package/dist/{zettelkasten-DDvKRm2V.mjs → zettelkasten-F0D_XfQ4.mjs} +3 -3
  99. package/dist/{zettelkasten-DDvKRm2V.mjs.map → zettelkasten-F0D_XfQ4.mjs.map} +1 -1
  100. package/docs/commands/README.md +2 -2
  101. package/docs/commands/mcp.md +4 -4
  102. package/docs/commands/worker.md +3 -0
  103. package/docs/worker.md +40 -0
  104. package/package.json +1 -1
  105. package/plugins/productivity/hooks/hooks.json +5 -0
  106. package/src/hooks/ts/user-prompt/worker-supervision.ts +123 -0
  107. package/statusline-command.sh +65 -73
  108. package/dist/chain-DhVHVnmT.mjs.map +0 -1
  109. package/dist/config-D1G9IFpn.mjs.map +0 -1
  110. package/dist/daemon-BSL9sXxm.mjs +0 -22
  111. package/dist/daemon-CpdVUnl_.mjs.map +0 -1
  112. package/dist/detector-BLVOCiJ_.mjs +0 -5
  113. package/dist/factory-C7ua_3ik.mjs +0 -3
  114. package/dist/main-resolver-CM1IHbuu.mjs.map +0 -1
  115. package/dist/main-resolver-E5WlilhM.mjs +0 -6
  116. package/dist/program-AR0qlYRg.mjs.map +0 -1
  117. package/dist/query-feedback-CGcEiGtY.mjs +0 -3
  118. package/dist/router-CAtyVQud.mjs +0 -3
  119. package/dist/server-BOuAOj9b.mjs.map +0 -1
  120. package/dist/work-queue-worker-BM1GcAAq.mjs +0 -14
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/hooks/ts/user-prompt/worker-supervision.ts", "../../../src/hooks/ts/lib/worker-session.ts", "../../../src/workers/config.ts", "../../../src/config/json-store.ts", "../../../src/daemon/config.ts", "../../../src/notifications/types.ts", "../../../src/tasks/types.ts", "../../../src/runtime-paths.ts", "../../../src/workers/paths.ts"],
4
+ "sourcesContent": ["#!/usr/bin/env node\n\n/**\n * worker-supervision.ts\n *\n * UserPromptSubmit hook that surfaces worker supervision events into the\n * session that owns the workers. The PAI daemon watches every worker's\n * status file (src/workers/supervision.ts) and appends one JSON line per\n * finished/failed/stalled event to\n * <logDir>/supervision/<claude-session-id>.events. AIBroker pushes the same\n * line into the terminal when it can; this hook is the channel that works\n * without one \u2014 on the next prompt, every line the session has not yet seen\n * is injected as context, and the cursor moves past it.\n *\n * Together the two channels replace polling: the orchestrator gets told its\n * worker finished, failed or stalled, and never sleeps to find out.\n */\n\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { isWorkerSession } from \"../lib/worker-session.js\";\nimport { readWorkersSection } from \"../../../workers/config.js\";\nimport { workersLogDir } from \"../../../workers/paths.js\";\n\ninterface HookInput {\n session_id?: string;\n}\n\n/**\n * Lines this session has already had injected. The count (not a byte offset)\n * is the cursor because the file is append-only by construction; a recreated\n * file simply clamps back to zero and replays its fresh content.\n */\nfunction readCursor(path: string): number {\n try {\n return Math.max(0, Number(readFileSync(path, \"utf8\").trim()) || 0);\n } catch {\n return 0;\n }\n}\n\nfunction main(): void {\n if (isWorkerSession()) return; // workers supervise nothing\n\n let raw = \"\";\n try {\n raw = readFileSync(0, \"utf8\");\n } catch {\n return;\n }\n if (!raw.trim()) return;\n let input: HookInput;\n try {\n input = JSON.parse(raw) as HookInput;\n } catch {\n return;\n }\n const session = input.session_id;\n if (!session) return;\n\n // logDir is wherever workers.logDir says (default ~/.claude/logs/workers);\n // a missing or broken workers section means no supervision to surface\n let logDir: string;\n try {\n logDir = workersLogDir(readWorkersSection().workers);\n } catch {\n return;\n }\n\n const dir = join(logDir, \"supervision\");\n const eventsPath = join(dir, `${session}.events`);\n const cursorPath = join(dir, `${session}.cursor`);\n if (!existsSync(eventsPath)) return;\n\n let lines: string[];\n try {\n lines = readFileSync(eventsPath, \"utf8\").split(\"\\n\").filter((l) => l.trim());\n } catch {\n return;\n }\n const fresh = lines.slice(readCursor(cursorPath));\n if (fresh.length === 0) return;\n\n // events AIBroker already typed into this terminal have a push receipt;\n // this hook is the fallback channel and must not repeat them\n let pushedIds = new Set<string>();\n try {\n pushedIds = new Set(\n readFileSync(join(dir, `${session}.pushed`), \"utf8\")\n .split(\"\\n\")\n .map((l) => l.trim())\n .filter(Boolean)\n );\n } catch {\n // no receipts means nothing was pushed, everything surfaces here\n }\n\n const texts = fresh\n .map((l) => {\n try {\n const ev = JSON.parse(l) as { id?: string; text?: string };\n if (ev.id && pushedIds.has(ev.id)) return null;\n return ev.text ?? null;\n } catch {\n return null;\n }\n })\n .filter((t): t is string => t !== null);\n try {\n writeFileSync(cursorPath, String(lines.length), \"utf8\");\n } catch {\n // an unwritable cursor only means these lines surface once more\n }\n if (texts.length === 0) return; // damaged lines are consumed, not re-shown\n\n process.stdout.write(\n `<system-reminder>\\nWORKER EVENTS (delivered by the daemon's supervision \u2014 no polling needed):\\n` +\n texts.join(\"\\n\") +\n `\\n</system-reminder>\\n`\n );\n}\n\nmain();\n", "/**\n * Worker-session detection.\n *\n * A disposable headless worker (a `claude -p` run started by an orchestrating\n * session, possibly against a different model provider with a different\n * context window) shares the project directory and the hook configuration\n * with the real session that spawned it. Left alone, the hooks treat it as a\n * session in its own right: they inject project context into it, create and\n * rename a numbered session note for it, autosave it, and enqueue a\n * model-written handover for it. Its compactions also land in the project's\n * transcript folder, where they are indistinguishable from a real session's\n * and drag the measured compaction trigger down (observed 2026-09-17: two\n * workers compacting at ~151k tokens pulled a project's trigger from ~784k\n * to ~151k, and the real session's handover fired at ~50k tokens).\n *\n * The launcher marks such sessions with `PAI_WORKER=1`. Every hook that does\n * per-session bookkeeping returns immediately when this predicate is true.\n * Deliberately NOT guarded: the security validator (a worker's shell\n * commands must still be checked) and observability capture.\n */\nexport function isWorkerSession(env: NodeJS.ProcessEnv = process.env): boolean {\n return env.PAI_WORKER === \"1\";\n}\n", "/**\n * config.ts \u2014 the `workers` section of ~/.config/pai/config.json\n *\n * Everything that knows about worker providers reads this module: the CLI\n * (`pai worker \u2026`), the MCP tools (worker_*), and the Agent-routing hook.\n * Keep it free of commander/MCP imports so all three layers stay thin over it.\n *\n * Keys are never stored here \u2014 only paths to 0600 files. A provider without a\n * keyFile is a local server and gets the placeholder token \"local\".\n *\n * `protocol: \"openai\"` providers run through the PAI proxy (src/workers/proxy):\n * `upstreamUrl` is their Chat Completions base, and `run` points\n * ANTHROPIC_BASE_URL at the local proxy with the provider name in the path.\n * `engine: \"codex\"` providers run through the Codex CLI instead of Claude\n * Code.\n */\n\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { readJsonStrict, writeJsonAtomic } from \"../config/json-store.js\";\nimport { CONFIG_FILE } from \"../daemon/config.js\";\nimport { contextWindowFromModelId, DEFAULT_CONTEXT_WINDOW } from \"../utils/model-window.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/**\n * Wire protocol a provider speaks: \"anthropic\" natively, \"openai\" through the\n * PAI proxy (which translates the Anthropic Messages API to Chat Completions).\n */\nexport type WorkerProtocol = \"anthropic\" | \"openai\";\n\n/** Runner executable behind a provider: Claude Code or the Codex CLI. */\nexport type WorkerEngine = \"claude\" | \"codex\";\n\nexport interface WorkerProvider {\n enabled: boolean;\n /** Wire protocol the baseUrl speaks. */\n protocol: WorkerProtocol;\n /** Anthropic-compatible Messages API base. */\n baseUrl: string;\n /** 0600 file holding the auth token; null for local servers (\"local\"). */\n keyFile: string | null;\n /** Model id per capability; see MODEL_CAPABILITIES. Only default is required. */\n models: { default: string } & Partial<Record<ModelCapability, string>>;\n /**\n * Model id \u2192 tier alias, overriding the built-in mapping (models.fast \u2192\n * haiku tier, models.default \u2192 sonnet tier). Lets a provider pin an extra\n * model id to a tier, e.g. a heavyweight default to the opus tier.\n */\n modelTiers?: Record<string, ModelTier>;\n /** Extra environment variables for runs through this provider (string values). */\n env: Record<string, string>;\n note?: string;\n /**\n * OpenAI Chat Completions base (e.g. \"https://api.openai.com/v1\"). Required\n * for protocol \"openai\"; read by the PAI proxy, never by the runner.\n */\n upstreamUrl?: string;\n /** Runner for this provider; \"codex\" goes through the Codex CLI. */\n engine?: WorkerEngine;\n /** Optional shell command printing 0\u2013100 (percent of quota used). */\n quotaProbe?: string;\n /** Auto-routing skips the provider at or above this percentage. Default 95. */\n quotaSkipAt?: number;\n /**\n * Context window of the provider's model, for the context meter. No\n * default: the meter only shows a window the init event announced (or\n * this explicit value, for engines without one, e.g. codex).\n */\n contextWindow?: number;\n /** Cost tier 1 (cheapest) \u2026 5 (most expensive); classes cap it via maxCostTier. */\n costTier?: number;\n /** Capability tags; classes filter auto-routing via requireTags. */\n tags?: ProviderTag[];\n}\n\nexport interface WorkersPaneConfig {\n enabled: boolean;\n /** Font size (points) of the follow-pane profile's font. */\n fontSize: number;\n /** Seconds a pane lingers after its worker goes quiet. */\n autoExitSecs: number;\n}\n\nexport interface WorkersRoutingConfig {\n /** Provider names in preference order; first usable one wins. */\n order: string[];\n /** Minutes a provider sits in cooldown after a quota/rate failure. */\n cooldownMinutes: number;\n /** Reroute a failed-before-first-tool run to the next provider. */\n retryOnQuota: boolean;\n}\n\n/** Sub-worker caps: how deep the worker tree may grow, how wide per parent. */\nexport interface WorkersTreeConfig {\n /** Maximum nesting depth of sub-workers (top-level = 0). */\n maxDepth: number;\n /** Maximum concurrently running children per parent. */\n maxChildren: number;\n}\n\n/** Cost/quality tier of a provider's model, 1 (cheapest) \u2026 5 (most expensive). */\nexport type CostTier = 1 | 2 | 3 | 4 | 5;\n\n/** The tier aliases the CLI and daemon tables understand \u2014 class proxies, not\n * Anthropic model names: any provider's model maps onto one of these. */\nexport type ModelTier = \"haiku\" | \"sonnet\" | \"opus\";\n\nconst MODEL_TIERS: readonly ModelTier[] = [\"haiku\", \"sonnet\", \"opus\"];\n\nexport const DEFAULT_COST_TIER = 3;\n\n/** Tags a provider may carry; classes filter auto-routing on them. */\nexport const PROVIDER_TAGS = [\n \"code\",\n \"vision\",\n \"image-gen\",\n \"long-context\",\n \"fast\",\n \"reasoning\",\n] as const;\n\nexport type ProviderTag = (typeof PROVIDER_TAGS)[number];\n\n/** The standard task classes; `workers.classes` maps each to a target. */\nexport const WORKER_CLASSES = [\n \"draft\",\n \"plan\",\n \"implement\",\n \"review\",\n \"research\",\n \"spotcheck\",\n \"simple\",\n \"complex\",\n \"image\",\n] as const;\n\nexport type WorkerClassName = (typeof WORKER_CLASSES)[number];\n\n/**\n * The named model capabilities a provider may carry a preference for, set\n * per provider under `workers.providers.<name>.models`. \"default\" is the\n * required catch-all; the others name what a model is *for* \u2014 \"fast\" the\n * cheap tier (spotcheck, haiku-tier spawns), \"image\" the image class.\n * Everything resolves through resolveModelCapability, falling back to default.\n */\nexport const MODEL_CAPABILITIES = [\"default\", \"fast\", \"image\"] as const;\n\nexport type ModelCapability = (typeof MODEL_CAPABILITIES)[number];\n\n/** Is this string the name of a known model capability? */\nexport function isModelCapability(v: string): v is ModelCapability {\n return (MODEL_CAPABILITIES as readonly string[]).includes(v);\n}\n\n/**\n * Which model capability a class runs on when its target names no alias: the\n * image class uses the image model; every other class the provider default.\n */\nconst CLASS_MODEL_CAPABILITY: Record<WorkerClassName, ModelCapability> = {\n draft: \"default\",\n plan: \"default\",\n implement: \"default\",\n review: \"default\",\n research: \"default\",\n spotcheck: \"default\",\n simple: \"default\",\n complex: \"default\",\n image: \"image\",\n};\n\n/** Capability for a run's class (default when unset or not a standard class). */\nexport function classModelCapability(className?: string): ModelCapability {\n return (className && CLASS_MODEL_CAPABILITY[className as WorkerClassName]) || \"default\";\n}\n\n/**\n * A class target: \"<provider>\", \"<provider>/<modelAlias>\", or an object. The\n * object either pins a `provider` (plus optional `mcp` allowlist) or only\n * constrains auto-routing (`maxCostTier`, `requireTags`, per-class `order`).\n */\nexport type ClassTarget =\n | string\n | {\n provider?: string;\n mcp?: string[];\n /** Auto-routing may only use providers at or below this cost tier. */\n maxCostTier?: number;\n /** Auto-routing may only use providers carrying all these tags. */\n requireTags?: string[];\n /** Provider order for this class; defaults to routing.order. */\n order?: string[];\n };\n\n/**\n * State of the machine-wide Claude Code fallback (`pai worker fallback on`):\n * every new Claude Code process runs on `provider` until `fallback off`.\n * `saved` holds what ~/.claude/settings.json carried before the switch so\n * `off` can restore it exactly.\n */\nexport interface WorkersFallback {\n /** Provider every new Claude Code process is pointed at. */\n provider: string;\n /**\n * settings.json before the switch. `env` records the previous value of\n * every env key fallback touched (null = the key was absent), `model` the\n * previous top-level model pin (null = none), `envExisted` whether an env\n * block existed at all.\n */\n saved: {\n env: Record<string, string | null>;\n model: string | null;\n envExisted: boolean;\n };\n /** ISO stamp of the switch (shown by `fallback status`). */\n on: string;\n}\n\nexport interface WorkersConfig {\n enabled: boolean;\n /** Provider name, or \"auto\" for routing.order resolution. */\n active: string | null;\n providers: Record<string, WorkerProvider>;\n /** Class \u2192 target; see ClassTarget. (Reads the legacy `roles` key.) */\n classes: Record<string, ClassTarget>;\n /** MCP set name \u2192 server names; `--mcp <set>` and class `mcp` expand these. */\n mcpSets: Record<string, string[]>;\n pane: WorkersPaneConfig;\n logDir: string;\n routing: WorkersRoutingConfig;\n /** Sub-worker caps (workers.tree). */\n tree: WorkersTreeConfig;\n /** Machine-wide Claude Code fallback; null = off. */\n fallback: WorkersFallback | null;\n}\n\n// ---------------------------------------------------------------------------\n// Defaults\n// ---------------------------------------------------------------------------\n\nexport const DEFAULT_LOG_DIR = \"~/.claude/logs/workers\";\n\nexport const DEFAULT_PANE: WorkersPaneConfig = {\n enabled: true,\n fontSize: 13,\n autoExitSecs: 60,\n};\n\nexport const DEFAULT_ROUTING: WorkersRoutingConfig = {\n order: [],\n cooldownMinutes: 30,\n retryOnQuota: true,\n};\n\nexport const DEFAULT_TREE: WorkersTreeConfig = {\n maxDepth: 2,\n maxChildren: 4,\n};\n\n/**\n * The default MCP sets every config starts from. `desktop` names the clickr\n * server so `--mcp desktop` hands a worker the machine controls (read-only\n * tools always; actuating ones after the operator hands the controls over,\n * see `pai worker controls`). A user's mcpSets section is merged over this,\n * so `desktop: []` removes the set deliberately.\n */\nexport const DEFAULT_MCP_SETS: Record<string, string[]> = {\n desktop: [\"clickr\"],\n};\n\nexport function defaultWorkersConfig(): WorkersConfig {\n return {\n enabled: false,\n active: null,\n providers: {},\n classes: {},\n mcpSets: { ...DEFAULT_MCP_SETS },\n pane: { ...DEFAULT_PANE },\n logDir: DEFAULT_LOG_DIR,\n routing: { ...DEFAULT_ROUTING, order: [] },\n tree: { ...DEFAULT_TREE },\n fallback: null,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Validation\n// ---------------------------------------------------------------------------\n\nexport class WorkersConfigError extends Error {}\n\nfunction bad(path: string, why: string): never {\n throw new WorkersConfigError(`workers${path}: ${why}`);\n}\n\nfunction str(v: unknown): string {\n return typeof v === \"string\" ? v : \"\";\n}\n\nfunction parseProvider(name: string, raw: unknown): WorkerProvider {\n if (typeof raw !== \"object\" || raw === null) bad(`.providers.${name}`, \"must be an object\");\n const p = raw as Record<string, unknown>;\n\n const protocol = p.protocol === undefined ? \"anthropic\" : str(p.protocol);\n if (protocol !== \"anthropic\" && protocol !== \"openai\") {\n bad(`.providers.${name}.protocol`, `\"${str(p.protocol)}\" is neither \"anthropic\" nor \"openai\"`);\n }\n const engine = p.engine === undefined ? \"claude\" : str(p.engine);\n if (engine !== \"claude\" && engine !== \"codex\") {\n bad(`.providers.${name}.engine`, `\"${str(p.engine)}\" is neither \"claude\" nor \"codex\"`);\n }\n\n const baseUrl = str(p.baseUrl);\n // openai providers reach the model through the PAI proxy; their baseUrl is\n // the proxy URL, filled in by the runner \u2014 only anthropic needs one here.\n if (!baseUrl && protocol !== \"openai\") bad(`.providers.${name}.baseUrl`, \"is required\");\n\n const keyFile =\n p.keyFile === undefined || p.keyFile === null || str(p.keyFile) === \"\"\n ? null\n : str(p.keyFile);\n\n const modelsRaw = p.models === undefined ? {} : p.models;\n if (typeof modelsRaw !== \"object\" || modelsRaw === null) {\n bad(`.providers.${name}.models`, \"must be an object\");\n }\n const m = modelsRaw as Record<string, unknown>;\n const defaultModel = str(m.default);\n if (!defaultModel) bad(`.providers.${name}.models.default`, \"is required\");\n const models: WorkerProvider[\"models\"] = { default: defaultModel };\n for (const key of Object.keys(m)) {\n if (key === \"default\") continue;\n if (!isModelCapability(key)) {\n bad(\n `.providers.${name}.models.${key}`,\n `\"${key}\" is not a model capability (from: ${MODEL_CAPABILITIES.join(\", \")})`\n );\n }\n const id = str(m[key]);\n if (!id) bad(`.providers.${name}.models.${key}`, \"must be a non-empty model id\");\n models[key] = id;\n }\n\n let modelTiers: Record<string, ModelTier> | undefined;\n if (p.modelTiers !== undefined) {\n if (typeof p.modelTiers !== \"object\" || p.modelTiers === null || Array.isArray(p.modelTiers)) {\n bad(`.providers.${name}.modelTiers`, \"must be an object of model id \u2192 haiku | sonnet | opus\");\n }\n modelTiers = {};\n for (const [id, tier] of Object.entries(p.modelTiers as Record<string, unknown>)) {\n if (typeof tier !== \"string\" || !MODEL_TIERS.includes(tier as ModelTier)) {\n bad(\n `.providers.${name}.modelTiers.${id}`,\n `must be \"haiku\", \"sonnet\" or \"opus\" (got ${JSON.stringify(tier)})`\n );\n }\n modelTiers[id] = tier as ModelTier;\n }\n }\n\n const env: Record<string, string> = {};\n if (p.env !== undefined) {\n if (typeof p.env !== \"object\" || p.env === null || Array.isArray(p.env)) {\n bad(`.providers.${name}.env`, \"must be an object of string values\");\n }\n for (const [k, v] of Object.entries(p.env as Record<string, unknown>)) {\n if (typeof v !== \"string\") bad(`.providers.${name}.env.${k}`, \"must be a string\");\n env[k] = v;\n }\n }\n\n const quotaSkipAt = p.quotaSkipAt === undefined ? undefined : p.quotaSkipAt;\n if (quotaSkipAt !== undefined) {\n if (typeof quotaSkipAt !== \"number\" || quotaSkipAt < 0 || quotaSkipAt > 100) {\n bad(`.providers.${name}.quotaSkipAt`, \"must be a number between 0 and 100\");\n }\n }\n\n const contextWindow = p.contextWindow === undefined ? undefined : p.contextWindow;\n if (contextWindow !== undefined) {\n if (typeof contextWindow !== \"number\" || contextWindow <= 0) {\n bad(`.providers.${name}.contextWindow`, \"must be a positive number of tokens\");\n }\n }\n\n const upstreamUrl = str(p.upstreamUrl);\n if (protocol === \"openai\" && !upstreamUrl) {\n bad(`.providers.${name}.upstreamUrl`, `is required for protocol \"openai\" (the Chat Completions base, e.g. \"https://api.openai.com/v1\")`);\n }\n\n const costTier = p.costTier === undefined ? undefined : p.costTier;\n if (costTier !== undefined) {\n if (typeof costTier !== \"number\" || !Number.isInteger(costTier) || costTier < 1 || costTier > 5) {\n bad(`.providers.${name}.costTier`, \"must be an integer 1 (cheapest) \u2026 5 (most expensive)\");\n }\n }\n\n let tags: ProviderTag[] | undefined;\n if (p.tags !== undefined) {\n if (!Array.isArray(p.tags) || p.tags.some((x) => typeof x !== \"string\")) {\n bad(`.providers.${name}.tags`, `must be an array of tags from: ${PROVIDER_TAGS.join(\", \")}`);\n }\n for (const t of p.tags as string[]) {\n if (!(PROVIDER_TAGS as readonly string[]).includes(t)) {\n bad(`.providers.${name}.tags`, `\"${t}\" is not a tag (from: ${PROVIDER_TAGS.join(\", \")})`);\n }\n }\n tags = p.tags as ProviderTag[];\n }\n\n return {\n enabled: p.enabled === undefined ? true : p.enabled === true,\n protocol,\n baseUrl,\n keyFile,\n models,\n ...(modelTiers ? { modelTiers } : {}),\n env,\n ...(str(p.note) ? { note: str(p.note) } : {}),\n ...(upstreamUrl ? { upstreamUrl } : {}),\n ...(engine !== \"claude\" ? { engine } : {}),\n ...(str(p.quotaProbe) ? { quotaProbe: str(p.quotaProbe) } : {}),\n ...(quotaSkipAt !== undefined ? { quotaSkipAt } : {}),\n ...(contextWindow !== undefined ? { contextWindow } : {}),\n ...(costTier !== undefined ? { costTier } : {}),\n ...(tags ? { tags } : {}),\n };\n}\n\n/**\n * Parse and validate a raw `workers` value. Missing section \u2192 defaults.\n * Unknown-but-typed garbage \u2192 WorkersConfigError naming the offending field.\n */\nexport function parseWorkersConfig(raw: unknown): WorkersConfig {\n const d = defaultWorkersConfig();\n if (raw === undefined || raw === null) return d;\n if (typeof raw !== \"object\" || Array.isArray(raw)) {\n bad(\"\", \"section must be an object\");\n }\n const w = raw as Record<string, unknown>;\n\n const providers: Record<string, WorkerProvider> = {};\n if (w.providers !== undefined) {\n if (typeof w.providers !== \"object\" || w.providers === null || Array.isArray(w.providers)) {\n bad(\".providers\", \"must be an object keyed by provider name\");\n }\n for (const [name, p] of Object.entries(w.providers)) {\n providers[name] = parseProvider(name, p);\n }\n }\n\n // classes is canonical; a config that still carries the pre-classes `roles`\n // key is migrated by reading it here \u2014 the next write stores only `classes`.\n const classes: Record<string, ClassTarget> = {};\n const classesRaw = w.classes !== undefined ? w.classes : w.roles;\n if (classesRaw !== undefined) {\n if (typeof classesRaw !== \"object\" || classesRaw === null || Array.isArray(classesRaw)) {\n bad(w.classes !== undefined ? \".classes\" : \".roles\", \"must be an object of class \u2192 provider[/alias] or {provider, mcp, maxCostTier, requireTags, order}\");\n }\n for (const [cls, target] of Object.entries(classesRaw)) {\n if (typeof target === \"object\" && target !== null && !Array.isArray(target)) {\n const o = target as Record<string, unknown>;\n const provider = str(o.provider);\n if (o.provider !== undefined && (!provider || provider.includes(\" \"))) {\n bad(`.classes.${cls}.provider`, `invalid provider \"${provider}\"`);\n }\n let mcp: string[] | undefined;\n if (o.mcp !== undefined) {\n if (!Array.isArray(o.mcp) || o.mcp.some((x) => typeof x !== \"string\")) {\n bad(`.classes.${cls}.mcp`, \"must be an array of MCP server or set names\");\n }\n mcp = o.mcp as string[];\n }\n let maxCostTier: number | undefined;\n if (o.maxCostTier !== undefined) {\n if (\n typeof o.maxCostTier !== \"number\" ||\n !Number.isInteger(o.maxCostTier) ||\n o.maxCostTier < 1 ||\n o.maxCostTier > 5\n ) {\n bad(`.classes.${cls}.maxCostTier`, \"must be an integer 1 \u2026 5\");\n }\n maxCostTier = o.maxCostTier;\n }\n let requireTags: string[] | undefined;\n if (o.requireTags !== undefined) {\n if (!Array.isArray(o.requireTags) || o.requireTags.some((x) => typeof x !== \"string\")) {\n bad(`.classes.${cls}.requireTags`, `must be an array of tags from: ${PROVIDER_TAGS.join(\", \")}`);\n }\n for (const t of o.requireTags as string[]) {\n if (!(PROVIDER_TAGS as readonly string[]).includes(t)) {\n bad(`.classes.${cls}.requireTags`, `\"${t}\" is not a tag (from: ${PROVIDER_TAGS.join(\", \")})`);\n }\n }\n requireTags = o.requireTags as string[];\n }\n let order: string[] | undefined;\n if (o.order !== undefined) {\n if (!Array.isArray(o.order) || o.order.some((x) => typeof x !== \"string\")) {\n bad(`.classes.${cls}.order`, \"must be an array of provider names\");\n }\n order = o.order as string[];\n }\n const obj: ClassTarget = {\n ...(provider ? { provider } : {}),\n ...(mcp ? { mcp } : {}),\n ...(maxCostTier !== undefined ? { maxCostTier } : {}),\n ...(requireTags ? { requireTags } : {}),\n ...(order ? { order } : {}),\n };\n classes[cls] = Object.keys(obj).length ? obj : {};\n } else {\n const t = str(target);\n if (!t || t.includes(\" \")) bad(`.classes.${cls}`, `invalid target \"${t}\"`);\n classes[cls] = t;\n }\n }\n }\n\n const mcpSets: Record<string, string[]> = { ...DEFAULT_MCP_SETS };\n if (w.mcpSets !== undefined) {\n if (typeof w.mcpSets !== \"object\" || w.mcpSets === null || Array.isArray(w.mcpSets)) {\n bad(\".mcpSets\", \"must be an object of set name \u2192 [server names]\");\n }\n for (const [setName, servers] of Object.entries(w.mcpSets)) {\n if (!Array.isArray(servers) || servers.some((x) => typeof x !== \"string\")) {\n bad(`.mcpSets.${setName}`, \"must be an array of MCP server names\");\n }\n mcpSets[setName] = servers as string[];\n }\n }\n\n let pane = { ...DEFAULT_PANE };\n if (w.pane !== undefined) {\n if (typeof w.pane !== \"object\" || w.pane === null) bad(\".pane\", \"must be an object\");\n const pc = w.pane as Record<string, unknown>;\n if (pc.enabled !== undefined && typeof pc.enabled !== \"boolean\") bad(\".pane.enabled\", \"must be boolean\");\n if (pc.fontSize !== undefined && (typeof pc.fontSize !== \"number\" || pc.fontSize <= 0)) {\n bad(\".pane.fontSize\", \"must be a positive number of points\");\n }\n if (pc.autoExitSecs !== undefined && typeof pc.autoExitSecs !== \"number\") {\n bad(\".pane.autoExitSecs\", \"must be a number\");\n }\n // legacy fontScale (a relative scale, superseded by fontSize) is tolerated and ignored\n pane = {\n enabled: pc.enabled === undefined ? DEFAULT_PANE.enabled : pc.enabled === true,\n fontSize: pc.fontSize === undefined ? DEFAULT_PANE.fontSize : pc.fontSize,\n autoExitSecs: pc.autoExitSecs === undefined ? DEFAULT_PANE.autoExitSecs : pc.autoExitSecs,\n };\n }\n\n let routing = { ...DEFAULT_ROUTING, order: [] as string[] };\n if (w.routing !== undefined) {\n if (typeof w.routing !== \"object\" || w.routing === null) bad(\".routing\", \"must be an object\");\n const r = w.routing as Record<string, unknown>;\n if (r.order !== undefined) {\n if (!Array.isArray(r.order) || r.order.some((x) => typeof x !== \"string\")) {\n bad(\".routing.order\", \"must be an array of provider names\");\n }\n routing.order = r.order as string[];\n }\n if (r.cooldownMinutes !== undefined && typeof r.cooldownMinutes !== \"number\") {\n bad(\".routing.cooldownMinutes\", \"must be a number\");\n }\n if (r.retryOnQuota !== undefined && typeof r.retryOnQuota !== \"boolean\") {\n bad(\".routing.retryOnQuota\", \"must be boolean\");\n }\n routing = {\n order: routing.order,\n cooldownMinutes: r.cooldownMinutes === undefined ? DEFAULT_ROUTING.cooldownMinutes : r.cooldownMinutes,\n retryOnQuota: r.retryOnQuota === undefined ? DEFAULT_ROUTING.retryOnQuota : r.retryOnQuota === true,\n };\n }\n\n let tree = { ...DEFAULT_TREE };\n if (w.tree !== undefined) {\n if (typeof w.tree !== \"object\" || w.tree === null || Array.isArray(w.tree)) {\n bad(\".tree\", \"must be an object\");\n }\n const t = w.tree as Record<string, unknown>;\n if (t.maxDepth !== undefined) {\n if (typeof t.maxDepth !== \"number\" || !Number.isInteger(t.maxDepth) || t.maxDepth < 0) {\n bad(\".tree.maxDepth\", \"must be a non-negative integer\");\n }\n }\n if (t.maxChildren !== undefined) {\n if (typeof t.maxChildren !== \"number\" || !Number.isInteger(t.maxChildren) || t.maxChildren < 1) {\n bad(\".tree.maxChildren\", \"must be a positive integer\");\n }\n }\n tree = {\n maxDepth: t.maxDepth === undefined ? DEFAULT_TREE.maxDepth : t.maxDepth,\n maxChildren: t.maxChildren === undefined ? DEFAULT_TREE.maxChildren : t.maxChildren,\n };\n }\n\n const active = w.active === undefined || w.active === null ? null : str(w.active);\n if (active !== null && active !== \"auto\" && !(active in providers)) {\n // Tolerated at parse time (a provider may have been removed while active\n // still names it) but every consumer resolves it to a clear error.\n }\n\n let fallback: WorkersFallback | null = null;\n if (w.fallback !== undefined && w.fallback !== null) {\n if (typeof w.fallback !== \"object\" || Array.isArray(w.fallback)) {\n bad(\".fallback\", \"must be an object (written by `pai worker fallback on`)\");\n }\n const f = w.fallback as Record<string, unknown>;\n const provider = str(f.provider);\n if (!provider) bad(\".fallback.provider\", \"is required\");\n const savedRaw = f.saved;\n if (typeof savedRaw !== \"object\" || savedRaw === null || Array.isArray(savedRaw)) {\n bad(\".fallback.saved\", \"must be an object\");\n }\n const s = savedRaw as Record<string, unknown>;\n if (typeof s.env !== \"object\" || s.env === null || Array.isArray(s.env)) {\n bad(\".fallback.saved.env\", \"must be an object of env key \u2192 previous value or null\");\n }\n const env: Record<string, string | null> = {};\n for (const [k, v] of Object.entries(s.env as Record<string, unknown>)) {\n env[k] = v === null || typeof v === \"string\" ? (v as string | null) : null;\n }\n if (s.model !== null && s.model !== undefined && typeof s.model !== \"string\") {\n bad(\".fallback.saved.model\", \"must be a string or null\");\n }\n fallback = {\n provider,\n saved: {\n env,\n model: typeof s.model === \"string\" ? s.model : null,\n envExisted: s.envExisted === true,\n },\n on: str(f.on) || new Date(0).toISOString(),\n };\n }\n\n return {\n enabled: w.enabled === undefined ? d.enabled : w.enabled === true,\n active,\n providers,\n classes,\n mcpSets,\n pane,\n logDir: str(w.logDir) || d.logDir,\n routing,\n tree,\n fallback,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Read / write\n// ---------------------------------------------------------------------------\n\n/**\n * Read the whole config file and return (raw, workers) \u2014 the raw record so\n * callers can rewrite it preserving every other section, the parsed+validated\n * workers section. Unreadable config throws (readJsonStrict), missing is fine.\n * `path` overrides the config location (tests, CLAUDE_SETTINGS_PATH-style\n * dry runs); default ~/.config/pai/config.json.\n */\nexport function readWorkersSection(path: string = CONFIG_FILE): {\n raw: Record<string, unknown>;\n workers: WorkersConfig;\n} {\n const raw = readJsonStrict(path, \"~/.config/pai/config.json\");\n return { raw, workers: parseWorkersConfig(raw.workers) };\n}\n\n/** Write the workers section back into the config file, atomically. */\nexport function writeWorkersSection(\n raw: Record<string, unknown>,\n workers: WorkersConfig,\n path: string = CONFIG_FILE\n): void {\n // null fallback (off) is omitted rather than written, so a config that\n // never used it does not gain a `\"fallback\": null` line from an unrelated\n // worker mutation.\n raw.workers = workers.fallback ? workers : { ...workers, fallback: undefined };\n writeJsonAtomic(path, raw, { label: \"~/.config/pai/config.json\" });\n}\n\n/** Expand a leading ~ (config values are written with `~` to stay portable). */\nexport function expandHome(p: string): string {\n if (p === \"~\" || p.startsWith(\"~/\")) return join(homedir(), p.slice(1));\n return p;\n}\n\n// ---------------------------------------------------------------------------\n// Runnability checks (proxy / codex providers included)\n// ---------------------------------------------------------------------------\n\nexport function assertProviderRunnable(name: string, p: WorkerProvider): void {\n if (p.protocol === \"openai\" && !p.upstreamUrl) {\n throw new WorkersConfigError(\n `provider \"${name}\" uses protocol \"openai\" but has no upstreamUrl \u2014 ` +\n `set its Chat Completions base (e.g. \"https://api.openai.com/v1\") ` +\n `so the PAI proxy knows where to translate to.`\n );\n }\n}\n\n/** Where a provider's key file lives, or null. Shared by run + test + add. */\nexport function providerKeyPath(p: WorkerProvider): string | null {\n return p.keyFile ? expandHome(p.keyFile) : null;\n}\n\nexport { DEFAULT_CONTEXT_WINDOW } from \"../utils/model-window.js\";\n\n/** Cost tier of a provider for class filtering (unset = 3, the middle). */\nexport function providerCostTier(p: WorkerProvider): number {\n return p.costTier ?? DEFAULT_COST_TIER;\n}\n\n/**\n * The model id for a capability: the provider's preference for it, else its\n * default model \u2014 the one resolution rule every consumer (image class, fast\n * tier spawns, env pins) goes through.\n */\nexport function resolveModelCapability(p: WorkerProvider, capability: ModelCapability): string {\n return p.models[capability] ?? p.models.default;\n}\n\n/**\n * Context window used by the meter when the run reports none: an explicit\n * `contextWindow` first, then whatever the default model's id declares\n * (\"[1m]\" \u2192 1,000,000), then the last-resort default.\n */\nexport function providerContextWindow(p: WorkerProvider): number {\n return (\n p.contextWindow ??\n contextWindowFromModelId(p.models.default) ??\n DEFAULT_CONTEXT_WINDOW\n );\n}\n\n/** Directory under which inline keys (MCP `key` field) are stored, 0600. */\nexport function keysDir(): string {\n return join(homedir(), \".config\", \"pai\", \"keys\");\n}\n", "/**\n * json-store.ts \u2014 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 \u2014 legitimate first run. Start fresh; writing is safe.\n * unreadable \u2014 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 \u2014 an undelivered\n * message queue, a cache \u2014 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 \u2014 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 \u2014 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 * 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 \u2014 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 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 * config.ts \u2014 Configuration loader for PAI Daemon\n *\n * Loads config from ~/.config/pai/config.json (XDG convention).\n * Deep-merges with defaults so partial configs work fine.\n * Expands ~ in path values at runtime.\n */\n\nimport { existsSync, readFileSync, mkdirSync, writeFileSync } from \"node:fs\";\nimport { homedir, userInfo } from \"node:os\";\nimport { join } 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\";\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 \u2014 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\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 \u2014 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 \u2014 applied when MCP tool or CLI doesn't specify a value */\n search: SearchConfig;\n\n /** Task bus \u2014 optional external tracker for cross-session work */\n tasks: TaskConfig;\n\n /** Who \"me\" is \u2014 addresses that count as the user's own. */\n identity: IdentityConfig;\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 \u2014 or to one of its own domain\n * aliases \u2014 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 \u2192 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 \u2014 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\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 search: {\n mode: \"keyword\",\n rerank: true,\n recencyBoostDays: 90,\n defaultLimit: 10,\n snippetLength: 200,\n },\n};\n\n/** Config template \u2014 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\nexport const CONFIG_DIR = join(homedir(), \".config\", \"pai\");\nexport const CONFIG_FILE = join(CONFIG_DIR, \"config.json\");\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 from ~/.config/pai/config.json.\n * Returns defaults merged with any values found in the file.\n */\nexport function loadConfig(): PaiDaemonConfig {\n if (!existsSync(CONFIG_FILE)) {\n return { ...DEFAULTS };\n }\n\n let raw: string;\n try {\n raw = readFileSync(CONFIG_FILE, \"utf-8\");\n } catch (e) {\n process.stderr.write(\n `[pai-daemon] Could not read config file at ${CONFIG_FILE}: ${e}\\n`\n );\n return { ...DEFAULTS };\n }\n\n let parsed: Record<string, unknown>;\n try {\n parsed = JSON.parse(raw) as Record<string, unknown>;\n } catch (e) {\n process.stderr.write(\n `[pai-daemon] Config file is not valid JSON: ${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 \u2192 vaultPath (${parsed.vaultPath})\\n`\n );\n }\n\n return deepMerge(DEFAULTS, parsed);\n}\n\n/**\n * Ensure ~/.config/pai/ exists and write a default config.json template\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)) {\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", "/**\n * types.ts \u2014 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\" \u2014 Use the per-event routing table (default)\n * - \"voice\" \u2014 All events go to voice (WhatsApp TTS)\n * - \"whatsapp\" \u2014 All events go to WhatsApp text\n * - \"ntfy\" \u2014 All events go to ntfy.sh\n * - \"macos\" \u2014 All events go to macOS notifications\n * - \"cli\" \u2014 All events go to CLI stdout only\n * - \"off\" \u2014 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 \u2192 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 \u2014 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 \u2014 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 \u2014 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\" \u2014 an explicit `pai:<project>` label (authoritative)\n * - \"container\" \u2014 the enclosing sub-project name matched a PAI alias (fallback)\n * - \"none\" \u2014 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\" \u2014 e.g. a \"Reading List \uD83D\uDCDA\" 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 \u2014 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 \u2014 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 \u2014 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 \u2014 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 \u2014 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 \u2014 the question, the answer,\n * the correction \u2014 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` \u2014 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 \u2192 TODOIST_API_KEY env \u2192 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 \uD83E\uDD16\" project).\n *\n * Stored as an ID, never a name. Todoist's project search silently returns\n * zero results for names containing emoji \u2014 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 \u2014 or when AIBroker is unavailable \u2014 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 \u2014 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\" \u2014 sent to an already-running session, and confirmed submitted\n * - \"queued\" \u2014 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 \u2014 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\" \u2014 none running; one was launched, came up, and received it\n * - \"unrouted\" \u2014 no owner resolved; left in the findings inbox\n * - \"unlaunchable\" \u2014 an owner resolved but no PAI alias exists to launch it\n * - \"unreachable\" \u2014 a session was launched but never became ready to accept input\n * - \"skipped\" \u2014 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 * runtime-paths.ts \u2014 the shared files PAI talks to the running daemon through.\n *\n * Sockets, logs and pidfiles live at fixed absolute paths under /tmp, and every\n * one of them is shared with a *live* daemon. That makes them the same hazard\n * as any other piece of real user state a test can reach: writing one from a\n * test does not fail, it corrupts something already running, silently.\n *\n * The home guard in test/setup-home-guard.ts does not cover them. It works by\n * redirecting HOME, so it protects paths *derived* from the home directory and\n * nothing else \u2014 a hardcoded \"/tmp/pai.sock\" walks straight past it. Verified\n * on 2026-08-01 by snapshotting all five and running the suite: nothing writes\n * to them today. But \"no test does this yet\" is not a property, and the next\n * daemon test to be written would clobber the live socket with nothing to stop\n * it.\n *\n * So each path is read from an environment variable with the historical /tmp\n * value as its default. Behaviour is unchanged for every real invocation; the\n * test setup overrides the variables and the whole class becomes unreachable\n * rather than merely absent, the same way the home guard works.\n *\n * Read at call time, not at module load: the test setup file runs before any\n * module is imported, but resolving these eagerly would still bake in whatever\n * the environment held at import and make the override order matter.\n */\n\n/** IPC socket for the PAI daemon. */\nexport function paiSocketPath(): string {\n return process.env.PAI_SOCKET_PATH ?? \"/tmp/pai.sock\";\n}\n\n/** IPC socket for the AIBroker daemon \u2014 read by PAI, owned by AIBroker. */\nexport function aibrokerSocketPath(): string {\n return process.env.PAI_AIBROKER_SOCKET_PATH ?? \"/tmp/aibroker.sock\";\n}\n\n/** Where the daemon's stdout and stderr are collected. */\nexport function daemonLogPath(): string {\n return process.env.PAI_DAEMON_LOG_PATH ?? \"/tmp/pai-daemon.log\";\n}\n\n/** Pidfile for the running daemon. */\nexport function daemonPidPath(): string {\n return process.env.PAI_DAEMON_PID_PATH ?? \"/tmp/pai-daemon.pid\";\n}\n\n/** Where the launchd scheduler tick writes its output. */\nexport function schedulerLogPath(): string {\n return process.env.PAI_SCHEDULER_LOG_PATH ?? \"/tmp/pai-scheduler.log\";\n}\n\n/**\n * Every runtime path, for the test guard to redirect in one place.\n *\n * Listed here rather than in the guard so that adding a path and forgetting to\n * protect it is not possible: the guard iterates this.\n */\nexport const RUNTIME_PATH_ENV_VARS = [\n \"PAI_SOCKET_PATH\",\n \"PAI_AIBROKER_SOCKET_PATH\",\n \"PAI_DAEMON_LOG_PATH\",\n \"PAI_DAEMON_PID_PATH\",\n \"PAI_SCHEDULER_LOG_PATH\",\n] as const;\n", "/**\n * paths.ts \u2014 where worker run artefacts live.\n *\n * Everything a run writes (event mirror, status file, pane registry, routing\n * state, the empty MCP config) sits under one logDir so the whole tree is\n * disposable and configurable: `workers.logDir`, default ~/.claude/logs/workers.\n */\n\nimport { join } from \"node:path\";\nimport { expandHome, type WorkersConfig } from \"./config.js\";\n\n/** Absolute logDir for the given config. */\nexport function workersLogDir(config: WorkersConfig): string {\n return expandHome(config.logDir);\n}\n\nexport function statusPath(logDir: string, id: string): string {\n return join(logDir, `${id}.status`);\n}\n\nexport function eventsPath(logDir: string, id: string): string {\n return join(logDir, `${id}.jsonl`);\n}\n\nexport function ledgerPath(logDir: string): string {\n return join(logDir, \"ledger.log\");\n}\n\nexport function routingStatePath(logDir: string): string {\n return join(logDir, \"routing-state.json\");\n}\n\nexport function panesDir(logDir: string): string {\n return join(logDir, \"panes\");\n}\n\n/**\n * The strict empty MCP config handed to headless workers. Written into the\n * logDir on demand (never into the user's vendor config directory \u2014 this is\n * PAI state, not vendor state).\n */\nexport function noMcpConfigPath(logDir: string): string {\n return join(logDir, \"no-mcp.json\");\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["existsSync", "readFileSync", "writeFileSync", "join", "homedir", "join", "DEFAULT_ROUTING", "join", "homedir", "readFileSync", "join", "existsSync", "writeFileSync"]
7
+ }
@@ -296,4 +296,4 @@ async function indexAllWithBackend(backend, registryDb) {
296
296
 
297
297
  //#endregion
298
298
  export { embedChunksWithBackend, indexAllWithBackend };
299
- //# sourceMappingURL=indexer-backend-rohVVKrA.mjs.map
299
+ //# sourceMappingURL=indexer-backend-BPTzKFZU.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"indexer-backend-rohVVKrA.mjs","names":[],"sources":["../src/memory/indexer/async.ts"],"sourcesContent":["/**\n * Backend-aware async indexer for PAI federation memory.\n *\n * Provides the same functionality as sync.ts but writes through the\n * StorageBackend interface instead of directly to better-sqlite3.\n * Used when the daemon is configured with the Postgres backend.\n *\n * The SQLite path still uses sync.ts directly (which is faster for SQLite\n * due to synchronous transactions).\n */\n\nimport { readFileSync, statSync, existsSync } from \"node:fs\";\nimport { join, relative, basename } from \"node:path\";\nimport type { Database } from \"better-sqlite3\";\nimport type { StorageBackend, ChunkRow } from \"../../storage/interface.js\";\nimport { chunkMarkdown } from \"../chunker.js\";\nimport {\n sha256File,\n chunkId,\n detectTier,\n walkMdFiles,\n walkContentFiles,\n isPathTooBroadForContentScan,\n parseSessionTitleChunk,\n yieldToEventLoop,\n INDEX_YIELD_EVERY,\n} from \"./helpers.js\";\nimport type { IndexResult } from \"./types.js\";\n\nexport type { IndexResult };\n\n// ---------------------------------------------------------------------------\n// Single-file indexing via StorageBackend\n// ---------------------------------------------------------------------------\n\n/**\n * Index a single file through the StorageBackend interface.\n * Returns true if the file was re-indexed (changed or new), false if skipped.\n */\nexport async function indexFileWithBackend(\n backend: StorageBackend,\n projectId: number,\n rootPath: string,\n relativePath: string,\n source: string,\n tier: string,\n): Promise<boolean> {\n const absPath = join(rootPath, relativePath);\n\n let content: string;\n let stat: ReturnType<typeof statSync>;\n try {\n content = readFileSync(absPath, \"utf8\");\n stat = statSync(absPath);\n } catch {\n return false;\n }\n\n const hash = sha256File(content);\n const mtime = Math.floor(stat.mtimeMs);\n const size = stat.size;\n\n // Change detection\n const existingHash = await backend.getFileHash(projectId, relativePath);\n if (existingHash === hash) return false;\n\n // Delete old chunks\n await backend.deleteChunksForFile(projectId, relativePath);\n\n // Chunk the content\n const rawChunks = chunkMarkdown(content);\n const updatedAt = Date.now();\n\n const chunks: ChunkRow[] = rawChunks.map((c, i) => ({\n id: chunkId(projectId, relativePath, i, c.startLine, c.endLine),\n projectId,\n source,\n tier,\n path: relativePath,\n startLine: c.startLine,\n endLine: c.endLine,\n hash: c.hash,\n text: c.text,\n updatedAt,\n embedding: null,\n }));\n\n // Insert chunks + update file record\n await backend.insertChunks(chunks);\n await backend.upsertFile({ projectId, path: relativePath, source, tier, hash, mtime, size });\n\n return true;\n}\n\n// ---------------------------------------------------------------------------\n// Project-level indexing via StorageBackend\n// ---------------------------------------------------------------------------\n\nexport async function indexProjectWithBackend(\n backend: StorageBackend,\n projectId: number,\n rootPath: string,\n claudeNotesDir?: string | null,\n): Promise<IndexResult> {\n const result: IndexResult = { filesProcessed: 0, chunksCreated: 0, filesSkipped: 0 };\n\n const filesToIndex: Array<{ absPath: string; rootBase: string; source: string; tier: string }> = [];\n\n const rootMemoryMd = join(rootPath, \"MEMORY.md\");\n if (existsSync(rootMemoryMd)) {\n filesToIndex.push({ absPath: rootMemoryMd, rootBase: rootPath, source: \"memory\", tier: \"evergreen\" });\n }\n\n const memoryDir = join(rootPath, \"memory\");\n for (const absPath of walkMdFiles(memoryDir)) {\n const relPath = relative(rootPath, absPath);\n const tier = detectTier(relPath);\n filesToIndex.push({ absPath, rootBase: rootPath, source: \"memory\", tier });\n }\n\n const notesDir = join(rootPath, \"Notes\");\n for (const absPath of walkMdFiles(notesDir)) {\n filesToIndex.push({ absPath, rootBase: rootPath, source: \"notes\", tier: \"session\" });\n }\n\n // Synthetic session-title chunks for Notes files\n {\n const updatedAt = Date.now();\n for (const absPath of walkMdFiles(notesDir)) {\n const fileName = basename(absPath);\n const text = parseSessionTitleChunk(fileName);\n if (!text) continue;\n const relPath = relative(rootPath, absPath);\n const syntheticPath = `${relPath}::title`;\n const id = chunkId(projectId, syntheticPath, 0, 0, 0);\n const hash = sha256File(text);\n const titleChunk: ChunkRow = {\n id, projectId, source: \"notes\", tier: \"session\",\n path: syntheticPath, startLine: 0, endLine: 0,\n hash, text, updatedAt, embedding: null,\n };\n try {\n await backend.insertChunks([titleChunk]);\n } catch {\n // Skip title chunks that cause backend errors\n }\n }\n }\n\n if (!isPathTooBroadForContentScan(rootPath)) {\n for (const absPath of walkContentFiles(rootPath)) {\n filesToIndex.push({ absPath, rootBase: rootPath, source: \"content\", tier: \"topic\" });\n }\n }\n\n if (claudeNotesDir && claudeNotesDir !== notesDir) {\n for (const absPath of walkMdFiles(claudeNotesDir)) {\n filesToIndex.push({ absPath, rootBase: claudeNotesDir, source: \"notes\", tier: \"session\" });\n }\n\n // Synthetic title chunks for claude notes dir\n {\n const updatedAt = Date.now();\n for (const absPath of walkMdFiles(claudeNotesDir)) {\n const fileName = basename(absPath);\n const text = parseSessionTitleChunk(fileName);\n if (!text) continue;\n const relPath = relative(claudeNotesDir, absPath);\n const syntheticPath = `${relPath}::title`;\n const id = chunkId(projectId, syntheticPath, 0, 0, 0);\n const hash = sha256File(text);\n const titleChunk: ChunkRow = {\n id, projectId, source: \"notes\", tier: \"session\",\n path: syntheticPath, startLine: 0, endLine: 0,\n hash, text, updatedAt, embedding: null,\n };\n try {\n await backend.insertChunks([titleChunk]);\n } catch {\n // Skip title chunks that cause backend errors\n }\n }\n }\n\n if (claudeNotesDir.endsWith(\"/Notes\")) {\n const claudeProjectDir = claudeNotesDir.slice(0, -\"/Notes\".length);\n const claudeMemoryMd = join(claudeProjectDir, \"MEMORY.md\");\n if (existsSync(claudeMemoryMd)) {\n filesToIndex.push({ absPath: claudeMemoryMd, rootBase: claudeProjectDir, source: \"memory\", tier: \"evergreen\" });\n }\n const claudeMemoryDir = join(claudeProjectDir, \"memory\");\n for (const absPath of walkMdFiles(claudeMemoryDir)) {\n const relPath = relative(claudeProjectDir, absPath);\n const tier = detectTier(relPath);\n filesToIndex.push({ absPath, rootBase: claudeProjectDir, source: \"memory\", tier });\n }\n }\n }\n\n await yieldToEventLoop();\n\n let filesSinceYield = 0;\n\n for (const { absPath, rootBase, source, tier } of filesToIndex) {\n if (filesSinceYield >= INDEX_YIELD_EVERY) {\n await yieldToEventLoop();\n filesSinceYield = 0;\n }\n filesSinceYield++;\n\n const relPath = relative(rootBase, absPath);\n try {\n const changed = await indexFileWithBackend(backend, projectId, rootBase, relPath, source, tier);\n\n if (changed) {\n const ids = await backend.getChunkIds(projectId, relPath);\n result.filesProcessed++;\n result.chunksCreated += ids.length;\n } else {\n result.filesSkipped++;\n }\n } catch {\n // Skip files that cause backend errors (e.g. null bytes in Postgres)\n result.filesSkipped++;\n }\n }\n\n // Prune stale paths\n const livePaths = new Set<string>();\n for (const { absPath, rootBase } of filesToIndex) {\n livePaths.add(relative(rootBase, absPath));\n }\n\n const dbChunkPaths = await backend.getDistinctChunkPaths(projectId);\n\n const stalePaths: string[] = [];\n for (const p of dbChunkPaths) {\n const basePath = p.endsWith(\"::title\") ? p.slice(0, -\"::title\".length) : p;\n if (!livePaths.has(basePath)) {\n stalePaths.push(p);\n }\n }\n\n if (stalePaths.length > 0) {\n await backend.deletePaths(projectId, stalePaths);\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Embedding generation via StorageBackend\n// ---------------------------------------------------------------------------\n\nconst EMBED_BATCH_SIZE = 50;\nconst EMBED_YIELD_EVERY = 1;\n\n/** Default ceiling on one pass. See EmbedPassOptions. */\nconst DEFAULT_MAX_CHUNKS_PER_PASS = 5_000;\n/** Default wall-clock ceiling on one pass, in milliseconds. */\nconst DEFAULT_MAX_MILLIS_PER_PASS = 120_000;\n\nexport interface EmbedPassOptions {\n /**\n * Stop the pass after this many chunks. Unbounded passes are the reason a\n * six-figure backlog starves the indexer: the daemon serialises indexing and\n * embedding against each other, so a pass that runs for hours means nothing\n * new gets indexed for hours. Bounding the pass costs nothing — every\n * embedding is written as it is produced, so the next pass resumes exactly\n * where this one stopped.\n */\n maxChunks?: number;\n /** Stop the pass after this much wall-clock time, whichever bound hits first. */\n maxMillis?: number;\n}\n\n/**\n * Generate and store embeddings for unembedded chunks via the StorageBackend.\n *\n * The pass is deliberately bounded (see EmbedPassOptions) and resumable: it\n * takes a slice of the backlog, embeds it, and returns so the scheduler can run\n * an index pass before coming back. Draining the whole backlog in one call\n * starves indexing for as long as the call runs.\n *\n * The optional `shouldStop` callback is checked between every batch. When it\n * returns true the embed loop exits early so the caller (e.g. the daemon\n * shutdown handler) can close the pool without racing against active queries.\n *\n * Returns the number of newly embedded chunks.\n */\nexport async function embedChunksWithBackend(\n backend: StorageBackend,\n shouldStop?: () => boolean,\n projectNames?: Map<number, string>,\n options?: EmbedPassOptions,\n): Promise<number> {\n const { generateEmbeddings, serializeEmbedding } = await import(\"../embeddings.js\");\n\n const maxChunks = options?.maxChunks ?? DEFAULT_MAX_CHUNKS_PER_PASS;\n const maxMillis = options?.maxMillis ?? DEFAULT_MAX_MILLIS_PER_PASS;\n const deadline = Date.now() + maxMillis;\n\n const rows = await backend.getUnembeddedChunkIds(undefined, maxChunks);\n if (rows.length === 0) return 0;\n\n const total = rows.length;\n let embedded = 0;\n\n // Build a summary of what needs embedding: count chunks per project_id\n const projectChunkCounts = new Map<number, { count: number; samplePath: string }>();\n for (const row of rows) {\n const entry = projectChunkCounts.get(row.project_id);\n if (entry) {\n entry.count++;\n } else {\n projectChunkCounts.set(row.project_id, { count: 1, samplePath: row.path });\n }\n }\n const pName = (pid: number) => projectNames?.get(pid) ?? `project ${pid}`;\n const projectSummary = Array.from(projectChunkCounts.entries())\n .map(([pid, { count, samplePath }]) => ` ${pName(pid)}: ${count} chunks (e.g. ${samplePath})`)\n .join(\"\\n\");\n process.stderr.write(\n `[pai-daemon] Embed pass: ${total} unembedded chunks across ${projectChunkCounts.size} project(s)\\n${projectSummary}\\n`\n );\n\n // Track current project for transition logging\n let currentProjectId = -1;\n let projectEmbedded = 0;\n\n for (let i = 0; i < rows.length; i += EMBED_BATCH_SIZE) {\n // Check cancellation between every batch before touching the pool again\n if (shouldStop?.()) {\n process.stderr.write(\n `[pai-daemon] Embed pass cancelled after ${embedded}/${total} chunks (shutdown requested)\\n`\n );\n break;\n }\n\n // Yield the daemon back to the indexer rather than run past the deadline.\n // The remaining rows are simply picked up by the next scheduled pass.\n if (Date.now() >= deadline) {\n process.stderr.write(\n `[pai-daemon] Embed pass yielding after ${embedded}/${total} chunks (${maxMillis}ms budget spent); resuming next pass\\n`\n );\n break;\n }\n\n const batch = rows.slice(i, i + EMBED_BATCH_SIZE);\n\n // Keep IPC responsive: the forward pass below is synchronous inside the\n // model, so yield before entering it rather than between chunks.\n await yieldToEventLoop();\n\n const vecs = await generateEmbeddings(batch.map((r) => r.text));\n // Issue the writes together rather than one round-trip at a time. Measured\n // on this machine the model embeds 40-67 chunks/s in isolation while the\n // daemon managed ~5/s: the difference was one sequential UPDATE per chunk,\n // so the pass spent most of its budget waiting on the network, not working.\n // Concurrency is bounded by the batch size, which the pool handles; the\n // SQLite backend is synchronous and simply ignores the difference.\n await Promise.all(\n batch.map((row, j) => backend.updateEmbedding(row.id, serializeEmbedding(vecs[j])))\n );\n\n // Attribute the batch to projects only after it is durably stored, and walk\n // it in order so a batch straddling a project boundary credits each side\n // correctly. Rows arrive ordered by project, so a boundary is a real\n // transition, not the per-chunk flapping this logging used to produce.\n for (const { project_id, path } of batch) {\n if (project_id !== currentProjectId) {\n if (currentProjectId !== -1) {\n process.stderr.write(\n `[pai-daemon] Finished ${pName(currentProjectId)}: ${projectEmbedded} chunks embedded\\n`\n );\n }\n const info = projectChunkCounts.get(project_id);\n process.stderr.write(\n `[pai-daemon] Embedding ${pName(project_id)} (${info?.count ?? \"?\"} chunks, starting at ${path})\\n`\n );\n currentProjectId = project_id;\n projectEmbedded = 0;\n }\n projectEmbedded++;\n }\n\n embedded += batch.length;\n\n // Log progress with current file path for context\n const lastChunk = batch[batch.length - 1];\n process.stderr.write(\n `[pai-daemon] Embedded ${embedded}/${total} chunks (${pName(lastChunk.project_id)}: ${lastChunk.path})\\n`\n );\n }\n\n // Log final project completion\n if (currentProjectId !== -1) {\n process.stderr.write(\n `[pai-daemon] Finished ${pName(currentProjectId)}: ${projectEmbedded} chunks embedded\\n`\n );\n }\n\n return embedded;\n}\n\n// ---------------------------------------------------------------------------\n// Global indexing via StorageBackend\n// ---------------------------------------------------------------------------\n\nexport async function indexAllWithBackend(\n backend: StorageBackend,\n registryDb: Database,\n): Promise<{ projects: number; result: IndexResult }> {\n const projects = registryDb\n .prepare(\"SELECT id, root_path, claude_notes_dir FROM projects WHERE status = 'active'\")\n .all() as Array<{ id: number; root_path: string; claude_notes_dir: string | null }>;\n\n const totals: IndexResult = { filesProcessed: 0, chunksCreated: 0, filesSkipped: 0 };\n\n for (const project of projects) {\n await yieldToEventLoop();\n const r = await indexProjectWithBackend(backend, project.id, project.root_path, project.claude_notes_dir);\n totals.filesProcessed += r.filesProcessed;\n totals.chunksCreated += r.chunksCreated;\n totals.filesSkipped += r.filesSkipped;\n }\n\n return { projects: projects.length, result: totals };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"indexer-backend-BPTzKFZU.mjs","names":[],"sources":["../src/memory/indexer/async.ts"],"sourcesContent":["/**\n * Backend-aware async indexer for PAI federation memory.\n *\n * Provides the same functionality as sync.ts but writes through the\n * StorageBackend interface instead of directly to better-sqlite3.\n * Used when the daemon is configured with the Postgres backend.\n *\n * The SQLite path still uses sync.ts directly (which is faster for SQLite\n * due to synchronous transactions).\n */\n\nimport { readFileSync, statSync, existsSync } from \"node:fs\";\nimport { join, relative, basename } from \"node:path\";\nimport type { Database } from \"better-sqlite3\";\nimport type { StorageBackend, ChunkRow } from \"../../storage/interface.js\";\nimport { chunkMarkdown } from \"../chunker.js\";\nimport {\n sha256File,\n chunkId,\n detectTier,\n walkMdFiles,\n walkContentFiles,\n isPathTooBroadForContentScan,\n parseSessionTitleChunk,\n yieldToEventLoop,\n INDEX_YIELD_EVERY,\n} from \"./helpers.js\";\nimport type { IndexResult } from \"./types.js\";\n\nexport type { IndexResult };\n\n// ---------------------------------------------------------------------------\n// Single-file indexing via StorageBackend\n// ---------------------------------------------------------------------------\n\n/**\n * Index a single file through the StorageBackend interface.\n * Returns true if the file was re-indexed (changed or new), false if skipped.\n */\nexport async function indexFileWithBackend(\n backend: StorageBackend,\n projectId: number,\n rootPath: string,\n relativePath: string,\n source: string,\n tier: string,\n): Promise<boolean> {\n const absPath = join(rootPath, relativePath);\n\n let content: string;\n let stat: ReturnType<typeof statSync>;\n try {\n content = readFileSync(absPath, \"utf8\");\n stat = statSync(absPath);\n } catch {\n return false;\n }\n\n const hash = sha256File(content);\n const mtime = Math.floor(stat.mtimeMs);\n const size = stat.size;\n\n // Change detection\n const existingHash = await backend.getFileHash(projectId, relativePath);\n if (existingHash === hash) return false;\n\n // Delete old chunks\n await backend.deleteChunksForFile(projectId, relativePath);\n\n // Chunk the content\n const rawChunks = chunkMarkdown(content);\n const updatedAt = Date.now();\n\n const chunks: ChunkRow[] = rawChunks.map((c, i) => ({\n id: chunkId(projectId, relativePath, i, c.startLine, c.endLine),\n projectId,\n source,\n tier,\n path: relativePath,\n startLine: c.startLine,\n endLine: c.endLine,\n hash: c.hash,\n text: c.text,\n updatedAt,\n embedding: null,\n }));\n\n // Insert chunks + update file record\n await backend.insertChunks(chunks);\n await backend.upsertFile({ projectId, path: relativePath, source, tier, hash, mtime, size });\n\n return true;\n}\n\n// ---------------------------------------------------------------------------\n// Project-level indexing via StorageBackend\n// ---------------------------------------------------------------------------\n\nexport async function indexProjectWithBackend(\n backend: StorageBackend,\n projectId: number,\n rootPath: string,\n claudeNotesDir?: string | null,\n): Promise<IndexResult> {\n const result: IndexResult = { filesProcessed: 0, chunksCreated: 0, filesSkipped: 0 };\n\n const filesToIndex: Array<{ absPath: string; rootBase: string; source: string; tier: string }> = [];\n\n const rootMemoryMd = join(rootPath, \"MEMORY.md\");\n if (existsSync(rootMemoryMd)) {\n filesToIndex.push({ absPath: rootMemoryMd, rootBase: rootPath, source: \"memory\", tier: \"evergreen\" });\n }\n\n const memoryDir = join(rootPath, \"memory\");\n for (const absPath of walkMdFiles(memoryDir)) {\n const relPath = relative(rootPath, absPath);\n const tier = detectTier(relPath);\n filesToIndex.push({ absPath, rootBase: rootPath, source: \"memory\", tier });\n }\n\n const notesDir = join(rootPath, \"Notes\");\n for (const absPath of walkMdFiles(notesDir)) {\n filesToIndex.push({ absPath, rootBase: rootPath, source: \"notes\", tier: \"session\" });\n }\n\n // Synthetic session-title chunks for Notes files\n {\n const updatedAt = Date.now();\n for (const absPath of walkMdFiles(notesDir)) {\n const fileName = basename(absPath);\n const text = parseSessionTitleChunk(fileName);\n if (!text) continue;\n const relPath = relative(rootPath, absPath);\n const syntheticPath = `${relPath}::title`;\n const id = chunkId(projectId, syntheticPath, 0, 0, 0);\n const hash = sha256File(text);\n const titleChunk: ChunkRow = {\n id, projectId, source: \"notes\", tier: \"session\",\n path: syntheticPath, startLine: 0, endLine: 0,\n hash, text, updatedAt, embedding: null,\n };\n try {\n await backend.insertChunks([titleChunk]);\n } catch {\n // Skip title chunks that cause backend errors\n }\n }\n }\n\n if (!isPathTooBroadForContentScan(rootPath)) {\n for (const absPath of walkContentFiles(rootPath)) {\n filesToIndex.push({ absPath, rootBase: rootPath, source: \"content\", tier: \"topic\" });\n }\n }\n\n if (claudeNotesDir && claudeNotesDir !== notesDir) {\n for (const absPath of walkMdFiles(claudeNotesDir)) {\n filesToIndex.push({ absPath, rootBase: claudeNotesDir, source: \"notes\", tier: \"session\" });\n }\n\n // Synthetic title chunks for claude notes dir\n {\n const updatedAt = Date.now();\n for (const absPath of walkMdFiles(claudeNotesDir)) {\n const fileName = basename(absPath);\n const text = parseSessionTitleChunk(fileName);\n if (!text) continue;\n const relPath = relative(claudeNotesDir, absPath);\n const syntheticPath = `${relPath}::title`;\n const id = chunkId(projectId, syntheticPath, 0, 0, 0);\n const hash = sha256File(text);\n const titleChunk: ChunkRow = {\n id, projectId, source: \"notes\", tier: \"session\",\n path: syntheticPath, startLine: 0, endLine: 0,\n hash, text, updatedAt, embedding: null,\n };\n try {\n await backend.insertChunks([titleChunk]);\n } catch {\n // Skip title chunks that cause backend errors\n }\n }\n }\n\n if (claudeNotesDir.endsWith(\"/Notes\")) {\n const claudeProjectDir = claudeNotesDir.slice(0, -\"/Notes\".length);\n const claudeMemoryMd = join(claudeProjectDir, \"MEMORY.md\");\n if (existsSync(claudeMemoryMd)) {\n filesToIndex.push({ absPath: claudeMemoryMd, rootBase: claudeProjectDir, source: \"memory\", tier: \"evergreen\" });\n }\n const claudeMemoryDir = join(claudeProjectDir, \"memory\");\n for (const absPath of walkMdFiles(claudeMemoryDir)) {\n const relPath = relative(claudeProjectDir, absPath);\n const tier = detectTier(relPath);\n filesToIndex.push({ absPath, rootBase: claudeProjectDir, source: \"memory\", tier });\n }\n }\n }\n\n await yieldToEventLoop();\n\n let filesSinceYield = 0;\n\n for (const { absPath, rootBase, source, tier } of filesToIndex) {\n if (filesSinceYield >= INDEX_YIELD_EVERY) {\n await yieldToEventLoop();\n filesSinceYield = 0;\n }\n filesSinceYield++;\n\n const relPath = relative(rootBase, absPath);\n try {\n const changed = await indexFileWithBackend(backend, projectId, rootBase, relPath, source, tier);\n\n if (changed) {\n const ids = await backend.getChunkIds(projectId, relPath);\n result.filesProcessed++;\n result.chunksCreated += ids.length;\n } else {\n result.filesSkipped++;\n }\n } catch {\n // Skip files that cause backend errors (e.g. null bytes in Postgres)\n result.filesSkipped++;\n }\n }\n\n // Prune stale paths\n const livePaths = new Set<string>();\n for (const { absPath, rootBase } of filesToIndex) {\n livePaths.add(relative(rootBase, absPath));\n }\n\n const dbChunkPaths = await backend.getDistinctChunkPaths(projectId);\n\n const stalePaths: string[] = [];\n for (const p of dbChunkPaths) {\n const basePath = p.endsWith(\"::title\") ? p.slice(0, -\"::title\".length) : p;\n if (!livePaths.has(basePath)) {\n stalePaths.push(p);\n }\n }\n\n if (stalePaths.length > 0) {\n await backend.deletePaths(projectId, stalePaths);\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Embedding generation via StorageBackend\n// ---------------------------------------------------------------------------\n\nconst EMBED_BATCH_SIZE = 50;\nconst EMBED_YIELD_EVERY = 1;\n\n/** Default ceiling on one pass. See EmbedPassOptions. */\nconst DEFAULT_MAX_CHUNKS_PER_PASS = 5_000;\n/** Default wall-clock ceiling on one pass, in milliseconds. */\nconst DEFAULT_MAX_MILLIS_PER_PASS = 120_000;\n\nexport interface EmbedPassOptions {\n /**\n * Stop the pass after this many chunks. Unbounded passes are the reason a\n * six-figure backlog starves the indexer: the daemon serialises indexing and\n * embedding against each other, so a pass that runs for hours means nothing\n * new gets indexed for hours. Bounding the pass costs nothing — every\n * embedding is written as it is produced, so the next pass resumes exactly\n * where this one stopped.\n */\n maxChunks?: number;\n /** Stop the pass after this much wall-clock time, whichever bound hits first. */\n maxMillis?: number;\n}\n\n/**\n * Generate and store embeddings for unembedded chunks via the StorageBackend.\n *\n * The pass is deliberately bounded (see EmbedPassOptions) and resumable: it\n * takes a slice of the backlog, embeds it, and returns so the scheduler can run\n * an index pass before coming back. Draining the whole backlog in one call\n * starves indexing for as long as the call runs.\n *\n * The optional `shouldStop` callback is checked between every batch. When it\n * returns true the embed loop exits early so the caller (e.g. the daemon\n * shutdown handler) can close the pool without racing against active queries.\n *\n * Returns the number of newly embedded chunks.\n */\nexport async function embedChunksWithBackend(\n backend: StorageBackend,\n shouldStop?: () => boolean,\n projectNames?: Map<number, string>,\n options?: EmbedPassOptions,\n): Promise<number> {\n const { generateEmbeddings, serializeEmbedding } = await import(\"../embeddings.js\");\n\n const maxChunks = options?.maxChunks ?? DEFAULT_MAX_CHUNKS_PER_PASS;\n const maxMillis = options?.maxMillis ?? DEFAULT_MAX_MILLIS_PER_PASS;\n const deadline = Date.now() + maxMillis;\n\n const rows = await backend.getUnembeddedChunkIds(undefined, maxChunks);\n if (rows.length === 0) return 0;\n\n const total = rows.length;\n let embedded = 0;\n\n // Build a summary of what needs embedding: count chunks per project_id\n const projectChunkCounts = new Map<number, { count: number; samplePath: string }>();\n for (const row of rows) {\n const entry = projectChunkCounts.get(row.project_id);\n if (entry) {\n entry.count++;\n } else {\n projectChunkCounts.set(row.project_id, { count: 1, samplePath: row.path });\n }\n }\n const pName = (pid: number) => projectNames?.get(pid) ?? `project ${pid}`;\n const projectSummary = Array.from(projectChunkCounts.entries())\n .map(([pid, { count, samplePath }]) => ` ${pName(pid)}: ${count} chunks (e.g. ${samplePath})`)\n .join(\"\\n\");\n process.stderr.write(\n `[pai-daemon] Embed pass: ${total} unembedded chunks across ${projectChunkCounts.size} project(s)\\n${projectSummary}\\n`\n );\n\n // Track current project for transition logging\n let currentProjectId = -1;\n let projectEmbedded = 0;\n\n for (let i = 0; i < rows.length; i += EMBED_BATCH_SIZE) {\n // Check cancellation between every batch before touching the pool again\n if (shouldStop?.()) {\n process.stderr.write(\n `[pai-daemon] Embed pass cancelled after ${embedded}/${total} chunks (shutdown requested)\\n`\n );\n break;\n }\n\n // Yield the daemon back to the indexer rather than run past the deadline.\n // The remaining rows are simply picked up by the next scheduled pass.\n if (Date.now() >= deadline) {\n process.stderr.write(\n `[pai-daemon] Embed pass yielding after ${embedded}/${total} chunks (${maxMillis}ms budget spent); resuming next pass\\n`\n );\n break;\n }\n\n const batch = rows.slice(i, i + EMBED_BATCH_SIZE);\n\n // Keep IPC responsive: the forward pass below is synchronous inside the\n // model, so yield before entering it rather than between chunks.\n await yieldToEventLoop();\n\n const vecs = await generateEmbeddings(batch.map((r) => r.text));\n // Issue the writes together rather than one round-trip at a time. Measured\n // on this machine the model embeds 40-67 chunks/s in isolation while the\n // daemon managed ~5/s: the difference was one sequential UPDATE per chunk,\n // so the pass spent most of its budget waiting on the network, not working.\n // Concurrency is bounded by the batch size, which the pool handles; the\n // SQLite backend is synchronous and simply ignores the difference.\n await Promise.all(\n batch.map((row, j) => backend.updateEmbedding(row.id, serializeEmbedding(vecs[j])))\n );\n\n // Attribute the batch to projects only after it is durably stored, and walk\n // it in order so a batch straddling a project boundary credits each side\n // correctly. Rows arrive ordered by project, so a boundary is a real\n // transition, not the per-chunk flapping this logging used to produce.\n for (const { project_id, path } of batch) {\n if (project_id !== currentProjectId) {\n if (currentProjectId !== -1) {\n process.stderr.write(\n `[pai-daemon] Finished ${pName(currentProjectId)}: ${projectEmbedded} chunks embedded\\n`\n );\n }\n const info = projectChunkCounts.get(project_id);\n process.stderr.write(\n `[pai-daemon] Embedding ${pName(project_id)} (${info?.count ?? \"?\"} chunks, starting at ${path})\\n`\n );\n currentProjectId = project_id;\n projectEmbedded = 0;\n }\n projectEmbedded++;\n }\n\n embedded += batch.length;\n\n // Log progress with current file path for context\n const lastChunk = batch[batch.length - 1];\n process.stderr.write(\n `[pai-daemon] Embedded ${embedded}/${total} chunks (${pName(lastChunk.project_id)}: ${lastChunk.path})\\n`\n );\n }\n\n // Log final project completion\n if (currentProjectId !== -1) {\n process.stderr.write(\n `[pai-daemon] Finished ${pName(currentProjectId)}: ${projectEmbedded} chunks embedded\\n`\n );\n }\n\n return embedded;\n}\n\n// ---------------------------------------------------------------------------\n// Global indexing via StorageBackend\n// ---------------------------------------------------------------------------\n\nexport async function indexAllWithBackend(\n backend: StorageBackend,\n registryDb: Database,\n): Promise<{ projects: number; result: IndexResult }> {\n const projects = registryDb\n .prepare(\"SELECT id, root_path, claude_notes_dir FROM projects WHERE status = 'active'\")\n .all() as Array<{ id: number; root_path: string; claude_notes_dir: string | null }>;\n\n const totals: IndexResult = { filesProcessed: 0, chunksCreated: 0, filesSkipped: 0 };\n\n for (const project of projects) {\n await yieldToEventLoop();\n const r = await indexProjectWithBackend(backend, project.id, project.root_path, project.claude_notes_dir);\n totals.filesProcessed += r.filesProcessed;\n totals.chunksCreated += r.chunksCreated;\n totals.filesSkipped += r.filesSkipped;\n }\n\n return { projects: projects.length, result: totals };\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { p as paiSocketPath } from "./config-D1G9IFpn.mjs";
1
+ import { i as paiSocketPath } from "./runtime-paths-rni52zHX.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { connect } from "node:net";
4
4
 
@@ -155,4 +155,4 @@ var PaiClient = class {
155
155
 
156
156
  //#endregion
157
157
  export { PaiClient as t };
158
- //# sourceMappingURL=ipc-client-C7AVpsyv.mjs.map
158
+ //# sourceMappingURL=ipc-client-BmypMNYk.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"ipc-client-C7AVpsyv.mjs","names":[],"sources":["../src/daemon/ipc-client.ts"],"sourcesContent":["/**\n * ipc-client.ts — IPC client for the PAI Daemon MCP shim\n *\n * PaiClient connects to the Unix Domain Socket served by daemon.ts\n * and forwards tool calls to the daemon. Uses a fresh socket connection per\n * call (connect → write JSON + newline → read response line → parse → destroy).\n * This keeps the client stateless and avoids connection management complexity.\n *\n * Adapted from the Coogle ipc-client pattern (which was adapted from Whazaa).\n */\n\nimport { connect, Socket } from \"node:net\";\nimport { randomUUID } from \"node:crypto\";\nimport type {\n NotificationConfig,\n NotificationMode,\n NotificationEvent,\n SendResult,\n} from \"../notifications/types.js\";\nimport type { TopicCheckParams, TopicCheckResult } from \"../topics/detector.js\";\nimport type { AutoRouteResult } from \"../session/auto-route.js\";\nimport { paiSocketPath } from \"../runtime-paths.js\";\n\n// ---------------------------------------------------------------------------\n// Protocol types\n// ---------------------------------------------------------------------------\n\n/** Default socket path */\nexport const IPC_SOCKET_PATH = paiSocketPath();\n\n/** Timeout for IPC calls (60 seconds) */\nconst IPC_TIMEOUT_MS = 60_000;\n\ninterface IpcRequest {\n id: string;\n method: string;\n params: Record<string, unknown>;\n}\n\ninterface IpcResponse {\n id: string;\n ok: boolean;\n result?: unknown;\n error?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Client\n// ---------------------------------------------------------------------------\n\n/**\n * Thin IPC proxy that forwards tool calls to pai-daemon over a Unix\n * Domain Socket. Each call opens a fresh connection, sends one NDJSON request,\n * reads the response, and closes. Stateless and simple.\n */\nexport class PaiClient {\n private readonly socketPath: string;\n\n constructor(socketPath?: string) {\n this.socketPath = socketPath ?? IPC_SOCKET_PATH;\n }\n\n /**\n * Call a PAI tool by name with the given params.\n * Returns the tool result or throws on error.\n *\n * `timeoutMs` overrides the default 60s wait — for a caller on a hook's\n * critical path (e.g. the threshold-triggered handover enqueue in\n * `cli/commands/session/autosave.ts`) where the actual work happens later,\n * asynchronously, in the daemon's worker loop, and only the cheap\n * enqueue handshake itself should ever be waited on.\n */\n async call(method: string, params: Record<string, unknown>, timeoutMs?: number): Promise<unknown> {\n return this.send(method, params, timeoutMs);\n }\n\n /**\n * Check daemon status.\n */\n async status(): Promise<Record<string, unknown>> {\n const result = await this.send(\"status\", {});\n return result as Record<string, unknown>;\n }\n\n /**\n * Trigger an immediate index run.\n */\n async triggerIndex(): Promise<void> {\n await this.send(\"index_now\", {});\n }\n\n // -------------------------------------------------------------------------\n // Notification methods\n // -------------------------------------------------------------------------\n\n /**\n * Get the current notification config from the daemon.\n */\n async getNotificationConfig(): Promise<{\n config: NotificationConfig;\n activeChannels: string[];\n }> {\n const result = await this.send(\"notification_get_config\", {});\n return result as { config: NotificationConfig; activeChannels: string[] };\n }\n\n /**\n * Patch the notification config on the daemon (and persist to disk).\n */\n async setNotificationConfig(patch: {\n mode?: NotificationMode;\n channels?: Partial<NotificationConfig[\"channels\"]>;\n routing?: Partial<NotificationConfig[\"routing\"]>;\n }): Promise<{ config: NotificationConfig }> {\n const result = await this.send(\"notification_set_config\", patch as Record<string, unknown>);\n return result as { config: NotificationConfig };\n }\n\n /**\n * Send a notification via the daemon (routes to configured channels).\n */\n async sendNotification(payload: {\n event: NotificationEvent;\n message: string;\n title?: string;\n }): Promise<SendResult> {\n const result = await this.send(\"notification_send\", payload as Record<string, unknown>);\n return result as SendResult;\n }\n\n // -------------------------------------------------------------------------\n // Topic detection methods\n // -------------------------------------------------------------------------\n\n /**\n * Check whether the provided context text has drifted to a different project\n * than the session's current routing.\n */\n async topicCheck(params: TopicCheckParams): Promise<TopicCheckResult> {\n const result = await this.send(\"topic_check\", params as unknown as Record<string, unknown>);\n return result as TopicCheckResult;\n }\n\n // -------------------------------------------------------------------------\n // Session routing methods\n // -------------------------------------------------------------------------\n\n /**\n * Automatically detect which project a session belongs to.\n * Tries path match, PAI.md marker walk, then topic detection (if context given).\n */\n async sessionAutoRoute(params: {\n cwd?: string;\n context?: string;\n }): Promise<AutoRouteResult | null> {\n // session_auto_route returns a ToolResult (content array). Extract the text\n // and parse JSON from it.\n const result = await this.send(\"session_auto_route\", params as Record<string, unknown>);\n const toolResult = result as { content?: Array<{ text: string }>; isError?: boolean };\n if (toolResult.isError) return null;\n const text = toolResult.content?.[0]?.text ?? \"\";\n // Text is either JSON (on match) or a human-readable \"no match\" message\n try {\n return JSON.parse(text) as AutoRouteResult;\n } catch {\n return null;\n }\n }\n\n // -------------------------------------------------------------------------\n // Internal transport\n // -------------------------------------------------------------------------\n\n /**\n * Send a single IPC request and wait for the response.\n * Opens a new socket connection per call — simple and reliable.\n */\n private send(\n method: string,\n params: Record<string, unknown>,\n timeoutMs: number = IPC_TIMEOUT_MS\n ): Promise<unknown> {\n const socketPath = this.socketPath;\n\n return new Promise((resolve, reject) => {\n let socket: Socket | null = null;\n let done = false;\n let buffer = \"\";\n let timer: ReturnType<typeof setTimeout> | null = null;\n\n function finish(error: Error | null, value?: unknown): void {\n if (done) return;\n done = true;\n if (timer !== null) {\n clearTimeout(timer);\n timer = null;\n }\n try {\n socket?.destroy();\n } catch {\n // ignore\n }\n if (error) {\n reject(error);\n } else {\n resolve(value);\n }\n }\n\n socket = connect(socketPath, () => {\n const request: IpcRequest = {\n id: randomUUID(),\n method,\n params,\n };\n socket!.write(JSON.stringify(request) + \"\\n\");\n });\n\n socket.on(\"data\", (chunk: Buffer) => {\n buffer += chunk.toString();\n const nl = buffer.indexOf(\"\\n\");\n if (nl === -1) return;\n\n const line = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 1);\n\n let response: IpcResponse;\n try {\n response = JSON.parse(line) as IpcResponse;\n } catch {\n finish(new Error(`IPC parse error: ${line}`));\n return;\n }\n\n if (!response.ok) {\n finish(new Error(response.error ?? \"IPC call failed\"));\n } else {\n finish(null, response.result);\n }\n });\n\n socket.on(\"error\", (e: NodeJS.ErrnoException) => {\n if (e.code === \"ENOENT\" || e.code === \"ECONNREFUSED\") {\n finish(\n new Error(\n \"PAI daemon not running. Start it with: pai daemon serve\"\n )\n );\n } else {\n finish(e);\n }\n });\n\n socket.on(\"end\", () => {\n if (!done) {\n finish(new Error(\"IPC connection closed before response\"));\n }\n });\n\n timer = setTimeout(() => {\n finish(new Error(`IPC call timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n });\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;AA4BA,MAAa,kBAAkB,eAAe;;AAG9C,MAAM,iBAAiB;;;;;;AAwBvB,IAAa,YAAb,MAAuB;CACrB,AAAiB;CAEjB,YAAY,YAAqB;AAC/B,OAAK,aAAa,cAAc;;;;;;;;;;;;CAalC,MAAM,KAAK,QAAgB,QAAiC,WAAsC;AAChG,SAAO,KAAK,KAAK,QAAQ,QAAQ,UAAU;;;;;CAM7C,MAAM,SAA2C;AAE/C,SADe,MAAM,KAAK,KAAK,UAAU,EAAE,CAAC;;;;;CAO9C,MAAM,eAA8B;AAClC,QAAM,KAAK,KAAK,aAAa,EAAE,CAAC;;;;;CAUlC,MAAM,wBAGH;AAED,SADe,MAAM,KAAK,KAAK,2BAA2B,EAAE,CAAC;;;;;CAO/D,MAAM,sBAAsB,OAIgB;AAE1C,SADe,MAAM,KAAK,KAAK,2BAA2B,MAAiC;;;;;CAO7F,MAAM,iBAAiB,SAIC;AAEtB,SADe,MAAM,KAAK,KAAK,qBAAqB,QAAmC;;;;;;CAYzF,MAAM,WAAW,QAAqD;AAEpE,SADe,MAAM,KAAK,KAAK,eAAe,OAA6C;;;;;;CAY7F,MAAM,iBAAiB,QAGa;EAIlC,MAAM,aADS,MAAM,KAAK,KAAK,sBAAsB,OAAkC;AAEvF,MAAI,WAAW,QAAS,QAAO;EAC/B,MAAM,OAAO,WAAW,UAAU,IAAI,QAAQ;AAE9C,MAAI;AACF,UAAO,KAAK,MAAM,KAAK;UACjB;AACN,UAAO;;;;;;;CAYX,AAAQ,KACN,QACA,QACA,YAAoB,gBACF;EAClB,MAAM,aAAa,KAAK;AAExB,SAAO,IAAI,SAAS,SAAS,WAAW;GACtC,IAAI,SAAwB;GAC5B,IAAI,OAAO;GACX,IAAI,SAAS;GACb,IAAI,QAA8C;GAElD,SAAS,OAAO,OAAqB,OAAuB;AAC1D,QAAI,KAAM;AACV,WAAO;AACP,QAAI,UAAU,MAAM;AAClB,kBAAa,MAAM;AACnB,aAAQ;;AAEV,QAAI;AACF,aAAQ,SAAS;YACX;AAGR,QAAI,MACF,QAAO,MAAM;QAEb,SAAQ,MAAM;;AAIlB,YAAS,QAAQ,kBAAkB;IACjC,MAAM,UAAsB;KAC1B,IAAI,YAAY;KAChB;KACA;KACD;AACD,WAAQ,MAAM,KAAK,UAAU,QAAQ,GAAG,KAAK;KAC7C;AAEF,UAAO,GAAG,SAAS,UAAkB;AACnC,cAAU,MAAM,UAAU;IAC1B,MAAM,KAAK,OAAO,QAAQ,KAAK;AAC/B,QAAI,OAAO,GAAI;IAEf,MAAM,OAAO,OAAO,MAAM,GAAG,GAAG;AAChC,aAAS,OAAO,MAAM,KAAK,EAAE;IAE7B,IAAI;AACJ,QAAI;AACF,gBAAW,KAAK,MAAM,KAAK;YACrB;AACN,4BAAO,IAAI,MAAM,oBAAoB,OAAO,CAAC;AAC7C;;AAGF,QAAI,CAAC,SAAS,GACZ,QAAO,IAAI,MAAM,SAAS,SAAS,kBAAkB,CAAC;QAEtD,QAAO,MAAM,SAAS,OAAO;KAE/B;AAEF,UAAO,GAAG,UAAU,MAA6B;AAC/C,QAAI,EAAE,SAAS,YAAY,EAAE,SAAS,eACpC,wBACE,IAAI,MACF,0DACD,CACF;QAED,QAAO,EAAE;KAEX;AAEF,UAAO,GAAG,aAAa;AACrB,QAAI,CAAC,KACH,wBAAO,IAAI,MAAM,wCAAwC,CAAC;KAE5D;AAEF,WAAQ,iBAAiB;AACvB,2BAAO,IAAI,MAAM,4BAA4B,UAAU,IAAI,CAAC;MAC3D,UAAU;IACb"}
1
+ {"version":3,"file":"ipc-client-BmypMNYk.mjs","names":[],"sources":["../src/daemon/ipc-client.ts"],"sourcesContent":["/**\n * ipc-client.ts — IPC client for the PAI Daemon MCP shim\n *\n * PaiClient connects to the Unix Domain Socket served by daemon.ts\n * and forwards tool calls to the daemon. Uses a fresh socket connection per\n * call (connect → write JSON + newline → read response line → parse → destroy).\n * This keeps the client stateless and avoids connection management complexity.\n *\n * Adapted from the Coogle ipc-client pattern (which was adapted from Whazaa).\n */\n\nimport { connect, Socket } from \"node:net\";\nimport { randomUUID } from \"node:crypto\";\nimport type {\n NotificationConfig,\n NotificationMode,\n NotificationEvent,\n SendResult,\n} from \"../notifications/types.js\";\nimport type { TopicCheckParams, TopicCheckResult } from \"../topics/detector.js\";\nimport type { AutoRouteResult } from \"../session/auto-route.js\";\nimport { paiSocketPath } from \"../runtime-paths.js\";\n\n// ---------------------------------------------------------------------------\n// Protocol types\n// ---------------------------------------------------------------------------\n\n/** Default socket path */\nexport const IPC_SOCKET_PATH = paiSocketPath();\n\n/** Timeout for IPC calls (60 seconds) */\nconst IPC_TIMEOUT_MS = 60_000;\n\ninterface IpcRequest {\n id: string;\n method: string;\n params: Record<string, unknown>;\n}\n\ninterface IpcResponse {\n id: string;\n ok: boolean;\n result?: unknown;\n error?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Client\n// ---------------------------------------------------------------------------\n\n/**\n * Thin IPC proxy that forwards tool calls to pai-daemon over a Unix\n * Domain Socket. Each call opens a fresh connection, sends one NDJSON request,\n * reads the response, and closes. Stateless and simple.\n */\nexport class PaiClient {\n private readonly socketPath: string;\n\n constructor(socketPath?: string) {\n this.socketPath = socketPath ?? IPC_SOCKET_PATH;\n }\n\n /**\n * Call a PAI tool by name with the given params.\n * Returns the tool result or throws on error.\n *\n * `timeoutMs` overrides the default 60s wait — for a caller on a hook's\n * critical path (e.g. the threshold-triggered handover enqueue in\n * `cli/commands/session/autosave.ts`) where the actual work happens later,\n * asynchronously, in the daemon's worker loop, and only the cheap\n * enqueue handshake itself should ever be waited on.\n */\n async call(method: string, params: Record<string, unknown>, timeoutMs?: number): Promise<unknown> {\n return this.send(method, params, timeoutMs);\n }\n\n /**\n * Check daemon status.\n */\n async status(): Promise<Record<string, unknown>> {\n const result = await this.send(\"status\", {});\n return result as Record<string, unknown>;\n }\n\n /**\n * Trigger an immediate index run.\n */\n async triggerIndex(): Promise<void> {\n await this.send(\"index_now\", {});\n }\n\n // -------------------------------------------------------------------------\n // Notification methods\n // -------------------------------------------------------------------------\n\n /**\n * Get the current notification config from the daemon.\n */\n async getNotificationConfig(): Promise<{\n config: NotificationConfig;\n activeChannels: string[];\n }> {\n const result = await this.send(\"notification_get_config\", {});\n return result as { config: NotificationConfig; activeChannels: string[] };\n }\n\n /**\n * Patch the notification config on the daemon (and persist to disk).\n */\n async setNotificationConfig(patch: {\n mode?: NotificationMode;\n channels?: Partial<NotificationConfig[\"channels\"]>;\n routing?: Partial<NotificationConfig[\"routing\"]>;\n }): Promise<{ config: NotificationConfig }> {\n const result = await this.send(\"notification_set_config\", patch as Record<string, unknown>);\n return result as { config: NotificationConfig };\n }\n\n /**\n * Send a notification via the daemon (routes to configured channels).\n */\n async sendNotification(payload: {\n event: NotificationEvent;\n message: string;\n title?: string;\n }): Promise<SendResult> {\n const result = await this.send(\"notification_send\", payload as Record<string, unknown>);\n return result as SendResult;\n }\n\n // -------------------------------------------------------------------------\n // Topic detection methods\n // -------------------------------------------------------------------------\n\n /**\n * Check whether the provided context text has drifted to a different project\n * than the session's current routing.\n */\n async topicCheck(params: TopicCheckParams): Promise<TopicCheckResult> {\n const result = await this.send(\"topic_check\", params as unknown as Record<string, unknown>);\n return result as TopicCheckResult;\n }\n\n // -------------------------------------------------------------------------\n // Session routing methods\n // -------------------------------------------------------------------------\n\n /**\n * Automatically detect which project a session belongs to.\n * Tries path match, PAI.md marker walk, then topic detection (if context given).\n */\n async sessionAutoRoute(params: {\n cwd?: string;\n context?: string;\n }): Promise<AutoRouteResult | null> {\n // session_auto_route returns a ToolResult (content array). Extract the text\n // and parse JSON from it.\n const result = await this.send(\"session_auto_route\", params as Record<string, unknown>);\n const toolResult = result as { content?: Array<{ text: string }>; isError?: boolean };\n if (toolResult.isError) return null;\n const text = toolResult.content?.[0]?.text ?? \"\";\n // Text is either JSON (on match) or a human-readable \"no match\" message\n try {\n return JSON.parse(text) as AutoRouteResult;\n } catch {\n return null;\n }\n }\n\n // -------------------------------------------------------------------------\n // Internal transport\n // -------------------------------------------------------------------------\n\n /**\n * Send a single IPC request and wait for the response.\n * Opens a new socket connection per call — simple and reliable.\n */\n private send(\n method: string,\n params: Record<string, unknown>,\n timeoutMs: number = IPC_TIMEOUT_MS\n ): Promise<unknown> {\n const socketPath = this.socketPath;\n\n return new Promise((resolve, reject) => {\n let socket: Socket | null = null;\n let done = false;\n let buffer = \"\";\n let timer: ReturnType<typeof setTimeout> | null = null;\n\n function finish(error: Error | null, value?: unknown): void {\n if (done) return;\n done = true;\n if (timer !== null) {\n clearTimeout(timer);\n timer = null;\n }\n try {\n socket?.destroy();\n } catch {\n // ignore\n }\n if (error) {\n reject(error);\n } else {\n resolve(value);\n }\n }\n\n socket = connect(socketPath, () => {\n const request: IpcRequest = {\n id: randomUUID(),\n method,\n params,\n };\n socket!.write(JSON.stringify(request) + \"\\n\");\n });\n\n socket.on(\"data\", (chunk: Buffer) => {\n buffer += chunk.toString();\n const nl = buffer.indexOf(\"\\n\");\n if (nl === -1) return;\n\n const line = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 1);\n\n let response: IpcResponse;\n try {\n response = JSON.parse(line) as IpcResponse;\n } catch {\n finish(new Error(`IPC parse error: ${line}`));\n return;\n }\n\n if (!response.ok) {\n finish(new Error(response.error ?? \"IPC call failed\"));\n } else {\n finish(null, response.result);\n }\n });\n\n socket.on(\"error\", (e: NodeJS.ErrnoException) => {\n if (e.code === \"ENOENT\" || e.code === \"ECONNREFUSED\") {\n finish(\n new Error(\n \"PAI daemon not running. Start it with: pai daemon serve\"\n )\n );\n } else {\n finish(e);\n }\n });\n\n socket.on(\"end\", () => {\n if (!done) {\n finish(new Error(\"IPC connection closed before response\"));\n }\n });\n\n timer = setTimeout(() => {\n finish(new Error(`IPC call timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n });\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;AA4BA,MAAa,kBAAkB,eAAe;;AAG9C,MAAM,iBAAiB;;;;;;AAwBvB,IAAa,YAAb,MAAuB;CACrB,AAAiB;CAEjB,YAAY,YAAqB;AAC/B,OAAK,aAAa,cAAc;;;;;;;;;;;;CAalC,MAAM,KAAK,QAAgB,QAAiC,WAAsC;AAChG,SAAO,KAAK,KAAK,QAAQ,QAAQ,UAAU;;;;;CAM7C,MAAM,SAA2C;AAE/C,SADe,MAAM,KAAK,KAAK,UAAU,EAAE,CAAC;;;;;CAO9C,MAAM,eAA8B;AAClC,QAAM,KAAK,KAAK,aAAa,EAAE,CAAC;;;;;CAUlC,MAAM,wBAGH;AAED,SADe,MAAM,KAAK,KAAK,2BAA2B,EAAE,CAAC;;;;;CAO/D,MAAM,sBAAsB,OAIgB;AAE1C,SADe,MAAM,KAAK,KAAK,2BAA2B,MAAiC;;;;;CAO7F,MAAM,iBAAiB,SAIC;AAEtB,SADe,MAAM,KAAK,KAAK,qBAAqB,QAAmC;;;;;;CAYzF,MAAM,WAAW,QAAqD;AAEpE,SADe,MAAM,KAAK,KAAK,eAAe,OAA6C;;;;;;CAY7F,MAAM,iBAAiB,QAGa;EAIlC,MAAM,aADS,MAAM,KAAK,KAAK,sBAAsB,OAAkC;AAEvF,MAAI,WAAW,QAAS,QAAO;EAC/B,MAAM,OAAO,WAAW,UAAU,IAAI,QAAQ;AAE9C,MAAI;AACF,UAAO,KAAK,MAAM,KAAK;UACjB;AACN,UAAO;;;;;;;CAYX,AAAQ,KACN,QACA,QACA,YAAoB,gBACF;EAClB,MAAM,aAAa,KAAK;AAExB,SAAO,IAAI,SAAS,SAAS,WAAW;GACtC,IAAI,SAAwB;GAC5B,IAAI,OAAO;GACX,IAAI,SAAS;GACb,IAAI,QAA8C;GAElD,SAAS,OAAO,OAAqB,OAAuB;AAC1D,QAAI,KAAM;AACV,WAAO;AACP,QAAI,UAAU,MAAM;AAClB,kBAAa,MAAM;AACnB,aAAQ;;AAEV,QAAI;AACF,aAAQ,SAAS;YACX;AAGR,QAAI,MACF,QAAO,MAAM;QAEb,SAAQ,MAAM;;AAIlB,YAAS,QAAQ,kBAAkB;IACjC,MAAM,UAAsB;KAC1B,IAAI,YAAY;KAChB;KACA;KACD;AACD,WAAQ,MAAM,KAAK,UAAU,QAAQ,GAAG,KAAK;KAC7C;AAEF,UAAO,GAAG,SAAS,UAAkB;AACnC,cAAU,MAAM,UAAU;IAC1B,MAAM,KAAK,OAAO,QAAQ,KAAK;AAC/B,QAAI,OAAO,GAAI;IAEf,MAAM,OAAO,OAAO,MAAM,GAAG,GAAG;AAChC,aAAS,OAAO,MAAM,KAAK,EAAE;IAE7B,IAAI;AACJ,QAAI;AACF,gBAAW,KAAK,MAAM,KAAK;YACrB;AACN,4BAAO,IAAI,MAAM,oBAAoB,OAAO,CAAC;AAC7C;;AAGF,QAAI,CAAC,SAAS,GACZ,QAAO,IAAI,MAAM,SAAS,SAAS,kBAAkB,CAAC;QAEtD,QAAO,MAAM,SAAS,OAAO;KAE/B;AAEF,UAAO,GAAG,UAAU,MAA6B;AAC/C,QAAI,EAAE,SAAS,YAAY,EAAE,SAAS,eACpC,wBACE,IAAI,MACF,0DACD,CACF;QAED,QAAO,EAAE;KAEX;AAEF,UAAO,GAAG,aAAa;AACrB,QAAI,CAAC,KACH,wBAAO,IAAI,MAAM,wCAAwC,CAAC;KAE5D;AAEF,WAAQ,iBAAiB;AACvB,2BAAO,IAAI,MAAM,4BAA4B,UAAU,IAAI,CAAC;MAC3D,UAAU;IACb"}
@@ -173,4 +173,4 @@ function updateEntityFeedbackWeight(db, entityId, normalizedRating, alpha = .1)
173
173
 
174
174
  //#endregion
175
175
  export { kgContradictions as a, kgAdd as i, updateEntityFeedbackWeight as n, kgInvalidate as o, upsertKgEntity as r, kgQuery as s, listKgEntities as t };
176
- //# sourceMappingURL=kg-entity-DbOMPdF9.mjs.map
176
+ //# sourceMappingURL=kg-entity-r8duqhi9.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"kg-entity-DbOMPdF9.mjs","names":[],"sources":["../src/memory/kg.ts","../src/memory/kg-entity.ts"],"sourcesContent":["/**\n * Temporal Knowledge Graph — kg_triples CRUD layer.\n *\n * Uses the Postgres connection pool from the storage backend.\n * Triples are time-scoped: valid_from/valid_to enable point-in-time queries.\n * Invalidation sets valid_to = NOW() instead of deleting rows.\n */\n\nimport type { Pool } from \"pg\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface KgTriple {\n id: number;\n subject: string;\n predicate: string;\n object: string;\n project_id?: number;\n source_session?: string;\n valid_from: Date;\n valid_to?: Date;\n confidence: \"EXTRACTED\" | \"INFERRED\" | \"AMBIGUOUS\";\n created_at: Date;\n}\n\nexport interface KgAddParams {\n subject: string;\n predicate: string;\n object: string;\n project_id?: number;\n source_session?: string;\n confidence?: \"EXTRACTED\" | \"INFERRED\" | \"AMBIGUOUS\";\n}\n\nexport interface KgQueryParams {\n subject?: string;\n predicate?: string;\n object?: string;\n project_id?: number;\n as_of?: Date;\n include_invalidated?: boolean;\n}\n\nexport interface KgContradiction {\n subject: string;\n predicate: string;\n objects: string[];\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction rowToTriple(row: Record<string, unknown>): KgTriple {\n return {\n id: row.id as number,\n subject: row.subject as string,\n predicate: row.predicate as string,\n object: row.object as string,\n project_id: row.project_id as number | undefined,\n source_session: row.source_session as string | undefined,\n valid_from: new Date(row.valid_from as string),\n valid_to: row.valid_to ? new Date(row.valid_to as string) : undefined,\n confidence: row.confidence as \"EXTRACTED\" | \"INFERRED\" | \"AMBIGUOUS\",\n created_at: new Date(row.created_at as string),\n };\n}\n\n// ---------------------------------------------------------------------------\n// Core operations\n// ---------------------------------------------------------------------------\n\n/**\n * Add a new triple to the knowledge graph.\n * Returns the inserted triple.\n */\nexport async function kgAdd(pool: Pool, params: KgAddParams): Promise<KgTriple> {\n const confidence = params.confidence ?? \"EXTRACTED\";\n const result = await pool.query<Record<string, unknown>>(\n `INSERT INTO kg_triples\n (subject, predicate, object, project_id, source_session, confidence)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING *`,\n [\n params.subject,\n params.predicate,\n params.object,\n params.project_id ?? null,\n params.source_session ?? null,\n confidence,\n ]\n );\n return rowToTriple(result.rows[0]);\n}\n\n/**\n * Query triples by subject, predicate, object, and/or project.\n * Supports point-in-time queries via as_of.\n * By default only returns currently-valid triples (valid_to IS NULL).\n */\nexport async function kgQuery(pool: Pool, params: KgQueryParams): Promise<KgTriple[]> {\n const conditions: string[] = [];\n const values: unknown[] = [];\n let idx = 1;\n\n if (params.subject !== undefined) {\n conditions.push(`subject = $${idx++}`);\n values.push(params.subject);\n }\n if (params.predicate !== undefined) {\n conditions.push(`predicate = $${idx++}`);\n values.push(params.predicate);\n }\n if (params.object !== undefined) {\n conditions.push(`object = $${idx++}`);\n values.push(params.object);\n }\n if (params.project_id !== undefined) {\n conditions.push(`project_id = $${idx++}`);\n values.push(params.project_id);\n }\n\n if (params.as_of !== undefined) {\n // Valid at the given timestamp: started before or at as_of, and not yet ended\n conditions.push(`valid_from <= $${idx++}`);\n values.push(params.as_of);\n conditions.push(`(valid_to IS NULL OR valid_to > $${idx++})`);\n values.push(params.as_of);\n } else if (!params.include_invalidated) {\n // Default: only currently-valid (no valid_to set)\n conditions.push(`valid_to IS NULL`);\n }\n\n const where = conditions.length > 0 ? `WHERE ${conditions.join(\" AND \")}` : \"\";\n const result = await pool.query<Record<string, unknown>>(\n `SELECT * FROM kg_triples ${where} ORDER BY valid_from DESC`,\n values\n );\n return result.rows.map(rowToTriple);\n}\n\n/**\n * Invalidate a triple by setting valid_to = NOW().\n * Does not delete the row — preserves history.\n */\nexport async function kgInvalidate(pool: Pool, tripleId: number): Promise<void> {\n await pool.query(\n `UPDATE kg_triples SET valid_to = NOW() WHERE id = $1 AND valid_to IS NULL`,\n [tripleId]\n );\n}\n\n/**\n * Find contradictions: cases where the same (subject, predicate) pair has\n * multiple currently-valid objects.\n */\nexport async function kgContradictions(\n pool: Pool,\n subject: string\n): Promise<KgContradiction[]> {\n const result = await pool.query<{ subject: string; predicate: string; objects: string[] }>(\n `SELECT subject, predicate, array_agg(object ORDER BY object) AS objects\n FROM kg_triples\n WHERE subject = $1\n AND valid_to IS NULL\n GROUP BY subject, predicate\n HAVING COUNT(*) > 1`,\n [subject]\n );\n return result.rows.map((row) => ({\n subject: row.subject,\n predicate: row.predicate,\n objects: row.objects,\n }));\n}\n","/**\n * kg-entity.ts — Entity content-addressing with multi-tenant support.\n *\n * Provides UUID5-style deterministic content hashes for KG entities and edges,\n * ensuring that the same entity name always maps to the same ID within a tenant.\n * This enables idempotent upserts and stable foreign keys for kg_triples.\n *\n * Multi-tenant support: each tenant namespace gets its own entity ID space.\n * The default tenant is \"default\" for single-user deployments.\n */\n\nimport { createHash } from \"node:crypto\";\nimport type { Database } from \"better-sqlite3\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface KgEntity {\n entity_id: string;\n tenant_id: string;\n name: string;\n type: string;\n description?: string;\n first_seen?: number;\n last_seen?: number;\n mention_count: number;\n feedback_weight: number;\n}\n\nexport interface KgEntityUpsertParams {\n name: string;\n type?: string;\n description?: string;\n tenantId?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Content addressing\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a deterministic entity ID (UUID5-style) for a given name and tenant.\n *\n * The ID is a hex digest derived from \"tenant_id:name\" so the same entity\n * always receives the same ID within a tenant namespace.\n *\n * @param name Entity name (case-preserved)\n * @param tenantId Tenant namespace (default: \"default\")\n */\nexport function entityContentId(name: string, tenantId = \"default\"): string {\n return createHash(\"sha256\")\n .update(`entity:${tenantId}:${name}`)\n .digest(\"hex\")\n .slice(0, 32); // 128-bit hex string — UUID5-compatible length\n}\n\n/**\n * Generate a deterministic edge ID for a (source, relation, target) triple\n * within a tenant namespace.\n *\n * @param source Source entity name\n * @param relation Relation/predicate verb phrase\n * @param target Target entity name\n * @param tenantId Tenant namespace (default: \"default\")\n */\nexport function edgeContentId(\n source: string,\n relation: string,\n target: string,\n tenantId = \"default\"\n): string {\n return createHash(\"sha256\")\n .update(`edge:${tenantId}:${source}:${relation}:${target}`)\n .digest(\"hex\")\n .slice(0, 32);\n}\n\n// ---------------------------------------------------------------------------\n// SQLite entity upsert (for federation.db)\n// ---------------------------------------------------------------------------\n\n/**\n * Upsert a KG entity in the federation SQLite database.\n *\n * If the entity already exists for this tenant:\n * - Updates last_seen to now\n * - Increments mention_count\n * - Updates description if provided (overwrites older description)\n *\n * Returns the entity_id for use as a foreign key in kg_triples.\n */\nexport function upsertKgEntity(\n db: Database,\n params: KgEntityUpsertParams\n): string {\n const tenantId = params.tenantId ?? \"default\";\n const entityId = entityContentId(params.name, tenantId);\n const now = Date.now();\n\n db.prepare(`\n INSERT INTO kg_entities\n (entity_id, tenant_id, name, type, description, first_seen, last_seen, mention_count, feedback_weight)\n VALUES\n (?, ?, ?, ?, ?, ?, ?, 1, 0.5)\n ON CONFLICT(entity_id) DO UPDATE SET\n last_seen = excluded.last_seen,\n mention_count = mention_count + 1,\n description = COALESCE(excluded.description, description),\n type = CASE WHEN excluded.type != 'unknown' THEN excluded.type ELSE type END\n `).run(\n entityId,\n tenantId,\n params.name,\n params.type ?? \"unknown\",\n params.description ?? null,\n now,\n now\n );\n\n return entityId;\n}\n\n/**\n * Look up a KG entity by name within a tenant.\n * Returns null if the entity does not exist.\n */\nexport function findKgEntity(\n db: Database,\n name: string,\n tenantId = \"default\"\n): KgEntity | null {\n const entityId = entityContentId(name, tenantId);\n const row = db.prepare(\n \"SELECT * FROM kg_entities WHERE entity_id = ? AND tenant_id = ?\"\n ).get(entityId, tenantId) as KgEntity | undefined;\n return row ?? null;\n}\n\n/**\n * List KG entities for a tenant, optionally filtered by type.\n *\n * @param db Federation SQLite database\n * @param tenantId Tenant namespace (default: \"default\")\n * @param type Optional entity type filter\n * @param limit Maximum entities to return (default: 100)\n */\nexport function listKgEntities(\n db: Database,\n tenantId = \"default\",\n type?: string,\n limit = 100\n): KgEntity[] {\n if (type) {\n return db.prepare(\n \"SELECT * FROM kg_entities WHERE tenant_id = ? AND type = ? ORDER BY mention_count DESC LIMIT ?\"\n ).all(tenantId, type, limit) as KgEntity[];\n }\n return db.prepare(\n \"SELECT * FROM kg_entities WHERE tenant_id = ? ORDER BY mention_count DESC LIMIT ?\"\n ).all(tenantId, limit) as KgEntity[];\n}\n\n// ---------------------------------------------------------------------------\n// Feedback weight update (MR2 — EMA)\n// ---------------------------------------------------------------------------\n\n/**\n * Apply an EMA (Exponential Moving Average) feedback update to an entity's weight.\n *\n * EMA formula: new_weight = old_weight + alpha * (target - old_weight)\n *\n * @param db Federation SQLite database\n * @param entityId Entity ID to update\n * @param normalizedRating Rating normalized to [0, 1] (e.g., rating/5 for 1-5 scale)\n * @param alpha EMA learning rate (default: 0.1)\n */\nexport function updateEntityFeedbackWeight(\n db: Database,\n entityId: string,\n normalizedRating: number,\n alpha = 0.1\n): void {\n const row = db.prepare(\n \"SELECT feedback_weight FROM kg_entities WHERE entity_id = ?\"\n ).get(entityId) as { feedback_weight: number } | undefined;\n\n if (!row) return;\n\n const newWeight = row.feedback_weight + alpha * (normalizedRating - row.feedback_weight);\n db.prepare(\n \"UPDATE kg_entities SET feedback_weight = ? WHERE entity_id = ?\"\n ).run(newWeight, entityId);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"kg-entity-r8duqhi9.mjs","names":[],"sources":["../src/memory/kg.ts","../src/memory/kg-entity.ts"],"sourcesContent":["/**\n * Temporal Knowledge Graph — kg_triples CRUD layer.\n *\n * Uses the Postgres connection pool from the storage backend.\n * Triples are time-scoped: valid_from/valid_to enable point-in-time queries.\n * Invalidation sets valid_to = NOW() instead of deleting rows.\n */\n\nimport type { Pool } from \"pg\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface KgTriple {\n id: number;\n subject: string;\n predicate: string;\n object: string;\n project_id?: number;\n source_session?: string;\n valid_from: Date;\n valid_to?: Date;\n confidence: \"EXTRACTED\" | \"INFERRED\" | \"AMBIGUOUS\";\n created_at: Date;\n}\n\nexport interface KgAddParams {\n subject: string;\n predicate: string;\n object: string;\n project_id?: number;\n source_session?: string;\n confidence?: \"EXTRACTED\" | \"INFERRED\" | \"AMBIGUOUS\";\n}\n\nexport interface KgQueryParams {\n subject?: string;\n predicate?: string;\n object?: string;\n project_id?: number;\n as_of?: Date;\n include_invalidated?: boolean;\n}\n\nexport interface KgContradiction {\n subject: string;\n predicate: string;\n objects: string[];\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction rowToTriple(row: Record<string, unknown>): KgTriple {\n return {\n id: row.id as number,\n subject: row.subject as string,\n predicate: row.predicate as string,\n object: row.object as string,\n project_id: row.project_id as number | undefined,\n source_session: row.source_session as string | undefined,\n valid_from: new Date(row.valid_from as string),\n valid_to: row.valid_to ? new Date(row.valid_to as string) : undefined,\n confidence: row.confidence as \"EXTRACTED\" | \"INFERRED\" | \"AMBIGUOUS\",\n created_at: new Date(row.created_at as string),\n };\n}\n\n// ---------------------------------------------------------------------------\n// Core operations\n// ---------------------------------------------------------------------------\n\n/**\n * Add a new triple to the knowledge graph.\n * Returns the inserted triple.\n */\nexport async function kgAdd(pool: Pool, params: KgAddParams): Promise<KgTriple> {\n const confidence = params.confidence ?? \"EXTRACTED\";\n const result = await pool.query<Record<string, unknown>>(\n `INSERT INTO kg_triples\n (subject, predicate, object, project_id, source_session, confidence)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING *`,\n [\n params.subject,\n params.predicate,\n params.object,\n params.project_id ?? null,\n params.source_session ?? null,\n confidence,\n ]\n );\n return rowToTriple(result.rows[0]);\n}\n\n/**\n * Query triples by subject, predicate, object, and/or project.\n * Supports point-in-time queries via as_of.\n * By default only returns currently-valid triples (valid_to IS NULL).\n */\nexport async function kgQuery(pool: Pool, params: KgQueryParams): Promise<KgTriple[]> {\n const conditions: string[] = [];\n const values: unknown[] = [];\n let idx = 1;\n\n if (params.subject !== undefined) {\n conditions.push(`subject = $${idx++}`);\n values.push(params.subject);\n }\n if (params.predicate !== undefined) {\n conditions.push(`predicate = $${idx++}`);\n values.push(params.predicate);\n }\n if (params.object !== undefined) {\n conditions.push(`object = $${idx++}`);\n values.push(params.object);\n }\n if (params.project_id !== undefined) {\n conditions.push(`project_id = $${idx++}`);\n values.push(params.project_id);\n }\n\n if (params.as_of !== undefined) {\n // Valid at the given timestamp: started before or at as_of, and not yet ended\n conditions.push(`valid_from <= $${idx++}`);\n values.push(params.as_of);\n conditions.push(`(valid_to IS NULL OR valid_to > $${idx++})`);\n values.push(params.as_of);\n } else if (!params.include_invalidated) {\n // Default: only currently-valid (no valid_to set)\n conditions.push(`valid_to IS NULL`);\n }\n\n const where = conditions.length > 0 ? `WHERE ${conditions.join(\" AND \")}` : \"\";\n const result = await pool.query<Record<string, unknown>>(\n `SELECT * FROM kg_triples ${where} ORDER BY valid_from DESC`,\n values\n );\n return result.rows.map(rowToTriple);\n}\n\n/**\n * Invalidate a triple by setting valid_to = NOW().\n * Does not delete the row — preserves history.\n */\nexport async function kgInvalidate(pool: Pool, tripleId: number): Promise<void> {\n await pool.query(\n `UPDATE kg_triples SET valid_to = NOW() WHERE id = $1 AND valid_to IS NULL`,\n [tripleId]\n );\n}\n\n/**\n * Find contradictions: cases where the same (subject, predicate) pair has\n * multiple currently-valid objects.\n */\nexport async function kgContradictions(\n pool: Pool,\n subject: string\n): Promise<KgContradiction[]> {\n const result = await pool.query<{ subject: string; predicate: string; objects: string[] }>(\n `SELECT subject, predicate, array_agg(object ORDER BY object) AS objects\n FROM kg_triples\n WHERE subject = $1\n AND valid_to IS NULL\n GROUP BY subject, predicate\n HAVING COUNT(*) > 1`,\n [subject]\n );\n return result.rows.map((row) => ({\n subject: row.subject,\n predicate: row.predicate,\n objects: row.objects,\n }));\n}\n","/**\n * kg-entity.ts — Entity content-addressing with multi-tenant support.\n *\n * Provides UUID5-style deterministic content hashes for KG entities and edges,\n * ensuring that the same entity name always maps to the same ID within a tenant.\n * This enables idempotent upserts and stable foreign keys for kg_triples.\n *\n * Multi-tenant support: each tenant namespace gets its own entity ID space.\n * The default tenant is \"default\" for single-user deployments.\n */\n\nimport { createHash } from \"node:crypto\";\nimport type { Database } from \"better-sqlite3\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface KgEntity {\n entity_id: string;\n tenant_id: string;\n name: string;\n type: string;\n description?: string;\n first_seen?: number;\n last_seen?: number;\n mention_count: number;\n feedback_weight: number;\n}\n\nexport interface KgEntityUpsertParams {\n name: string;\n type?: string;\n description?: string;\n tenantId?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Content addressing\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a deterministic entity ID (UUID5-style) for a given name and tenant.\n *\n * The ID is a hex digest derived from \"tenant_id:name\" so the same entity\n * always receives the same ID within a tenant namespace.\n *\n * @param name Entity name (case-preserved)\n * @param tenantId Tenant namespace (default: \"default\")\n */\nexport function entityContentId(name: string, tenantId = \"default\"): string {\n return createHash(\"sha256\")\n .update(`entity:${tenantId}:${name}`)\n .digest(\"hex\")\n .slice(0, 32); // 128-bit hex string — UUID5-compatible length\n}\n\n/**\n * Generate a deterministic edge ID for a (source, relation, target) triple\n * within a tenant namespace.\n *\n * @param source Source entity name\n * @param relation Relation/predicate verb phrase\n * @param target Target entity name\n * @param tenantId Tenant namespace (default: \"default\")\n */\nexport function edgeContentId(\n source: string,\n relation: string,\n target: string,\n tenantId = \"default\"\n): string {\n return createHash(\"sha256\")\n .update(`edge:${tenantId}:${source}:${relation}:${target}`)\n .digest(\"hex\")\n .slice(0, 32);\n}\n\n// ---------------------------------------------------------------------------\n// SQLite entity upsert (for federation.db)\n// ---------------------------------------------------------------------------\n\n/**\n * Upsert a KG entity in the federation SQLite database.\n *\n * If the entity already exists for this tenant:\n * - Updates last_seen to now\n * - Increments mention_count\n * - Updates description if provided (overwrites older description)\n *\n * Returns the entity_id for use as a foreign key in kg_triples.\n */\nexport function upsertKgEntity(\n db: Database,\n params: KgEntityUpsertParams\n): string {\n const tenantId = params.tenantId ?? \"default\";\n const entityId = entityContentId(params.name, tenantId);\n const now = Date.now();\n\n db.prepare(`\n INSERT INTO kg_entities\n (entity_id, tenant_id, name, type, description, first_seen, last_seen, mention_count, feedback_weight)\n VALUES\n (?, ?, ?, ?, ?, ?, ?, 1, 0.5)\n ON CONFLICT(entity_id) DO UPDATE SET\n last_seen = excluded.last_seen,\n mention_count = mention_count + 1,\n description = COALESCE(excluded.description, description),\n type = CASE WHEN excluded.type != 'unknown' THEN excluded.type ELSE type END\n `).run(\n entityId,\n tenantId,\n params.name,\n params.type ?? \"unknown\",\n params.description ?? null,\n now,\n now\n );\n\n return entityId;\n}\n\n/**\n * Look up a KG entity by name within a tenant.\n * Returns null if the entity does not exist.\n */\nexport function findKgEntity(\n db: Database,\n name: string,\n tenantId = \"default\"\n): KgEntity | null {\n const entityId = entityContentId(name, tenantId);\n const row = db.prepare(\n \"SELECT * FROM kg_entities WHERE entity_id = ? AND tenant_id = ?\"\n ).get(entityId, tenantId) as KgEntity | undefined;\n return row ?? null;\n}\n\n/**\n * List KG entities for a tenant, optionally filtered by type.\n *\n * @param db Federation SQLite database\n * @param tenantId Tenant namespace (default: \"default\")\n * @param type Optional entity type filter\n * @param limit Maximum entities to return (default: 100)\n */\nexport function listKgEntities(\n db: Database,\n tenantId = \"default\",\n type?: string,\n limit = 100\n): KgEntity[] {\n if (type) {\n return db.prepare(\n \"SELECT * FROM kg_entities WHERE tenant_id = ? AND type = ? ORDER BY mention_count DESC LIMIT ?\"\n ).all(tenantId, type, limit) as KgEntity[];\n }\n return db.prepare(\n \"SELECT * FROM kg_entities WHERE tenant_id = ? ORDER BY mention_count DESC LIMIT ?\"\n ).all(tenantId, limit) as KgEntity[];\n}\n\n// ---------------------------------------------------------------------------\n// Feedback weight update (MR2 — EMA)\n// ---------------------------------------------------------------------------\n\n/**\n * Apply an EMA (Exponential Moving Average) feedback update to an entity's weight.\n *\n * EMA formula: new_weight = old_weight + alpha * (target - old_weight)\n *\n * @param db Federation SQLite database\n * @param entityId Entity ID to update\n * @param normalizedRating Rating normalized to [0, 1] (e.g., rating/5 for 1-5 scale)\n * @param alpha EMA learning rate (default: 0.1)\n */\nexport function updateEntityFeedbackWeight(\n db: Database,\n entityId: string,\n normalizedRating: number,\n alpha = 0.1\n): void {\n const row = db.prepare(\n \"SELECT feedback_weight FROM kg_entities WHERE entity_id = ?\"\n ).get(entityId) as { feedback_weight: number } | undefined;\n\n if (!row) return;\n\n const newWeight = row.feedback_weight + alpha * (normalizedRating - row.feedback_weight);\n db.prepare(\n \"UPDATE kg_entities SET feedback_weight = ? WHERE entity_id = ?\"\n ).run(newWeight, entityId);\n}\n"],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  import "./embeddings-DOLZnT1X.mjs";
2
2
  import { n as TITLE_STOP_WORDS } from "./stop-words-Hfu8u22w.mjs";
3
- import { t as zettelThemes } from "./themes-DqhNUUQk.mjs";
3
+ import { t as zettelThemes } from "./themes-B7h9YDiy.mjs";
4
4
  import { mkdirSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
 
@@ -188,4 +188,4 @@ function handleIdeaMaterialize(params, vaultPath) {
188
188
 
189
189
  //#endregion
190
190
  export { handleGraphLatentIdeas, handleIdeaMaterialize };
191
- //# sourceMappingURL=latent-ideas-iNp5s55R.mjs.map
191
+ //# sourceMappingURL=latent-ideas-CtSca4qG.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"latent-ideas-iNp5s55R.mjs","names":[],"sources":["../src/graph/latent-ideas.ts"],"sourcesContent":["/**\n * latent-ideas.ts — graph_latent_ideas and idea_materialize endpoint handlers\n *\n * \"Latent ideas\" are recurring themes in the vault that exist as embedding\n * clusters but have NO dedicated note written about them yet. PAI surfaces\n * these by running the same agglomerative clustering used by graph_clusters /\n * zettelThemes and then filtering OUT any cluster whose label is well-matched\n * by an existing note title.\n *\n * The materialize endpoint writes a new Markdown note to the vault filesystem\n * and returns its content so the plugin can open it immediately.\n */\n\nimport { mkdirSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { TITLE_STOP_WORDS } from \"../utils/stop-words.js\";\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport { zettelThemes } from \"../zettelkasten/themes.js\";\n\n// ---------------------------------------------------------------------------\n// Public param / result types\n// ---------------------------------------------------------------------------\n\nexport interface GraphLatentIdeasParams {\n project_id: number;\n /** Minimum notes in a cluster (default: 3) */\n min_cluster_size?: number;\n /** Cap on returned ideas (default: 15) */\n max_ideas?: number;\n /** How far back to look in days (default: 180) */\n lookback_days?: number;\n /** Cosine similarity clustering threshold (default: 0.65) */\n similarity_threshold?: number;\n}\n\nexport interface LatentIdeaSourceNote {\n vault_path: string;\n title: string;\n /** How strongly this note relates to the theme (0-1) */\n relevance: number;\n}\n\nexport interface LatentIdea {\n id: number;\n /** Auto-generated cluster label from zettelThemes */\n label: string;\n /** Number of notes touching this theme */\n size: number;\n /** 0-1, how likely this is a real coherent idea */\n confidence: number;\n /** Notes that contribute to this theme */\n source_notes: LatentIdeaSourceNote[];\n /** Cleaned-up version of label for a potential note title */\n suggested_title: string;\n /** Most common folder among source notes */\n suggested_folder: string;\n /** Number of distinct session date-folders (e.g. \"2026/03\") touching this theme */\n sessions_count: number;\n}\n\nexport interface GraphLatentIdeasResult {\n ideas: LatentIdea[];\n total_clusters_analyzed: number;\n /** How many clusters already have a matching note (excluded from results) */\n materialized_count: number;\n}\n\n// ---------------------------------------------------------------------------\n// Materialize params / result\n// ---------------------------------------------------------------------------\n\nexport interface IdeaMaterializeParams {\n idea_label: string;\n /** User-chosen title for the new note */\n title: string;\n /** Vault-relative folder path where the note should be created */\n folder: string;\n /** Vault-relative paths of the source notes to link from the new note */\n source_paths: string[];\n project_id: number;\n}\n\nexport interface IdeaMaterializeResult {\n /** Vault-relative path of the created note */\n vault_path: string;\n /** Generated markdown content */\n content: string;\n /** Number of wikilinks inserted */\n links_created: number;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: check if a cluster already has a matching note\n// ---------------------------------------------------------------------------\n\n/**\n * Returns true when any existing vault note title closely matches the cluster\n * label — meaning a dedicated note already exists for this topic.\n *\n * Matching strategy (simple, fast, no embeddings needed):\n * 1. Lowercase both sides and split into words.\n * 2. Remove stop words from the label words.\n * 3. If ≥ 60% of the significant label words appear in a note title → match.\n */\n// TITLE_STOP_WORDS imported from utils/stop-words.ts\n\nfunction labelMatchesTitle(label: string, title: string): boolean {\n const labelWords = label\n .toLowerCase()\n .split(/[\\s\\-_/]+/)\n .filter((w) => w.length > 2 && !TITLE_STOP_WORDS.has(w));\n\n if (labelWords.length === 0) return false;\n\n const titleLower = title.toLowerCase();\n const matchCount = labelWords.filter((w) => titleLower.includes(w)).length;\n return matchCount / labelWords.length >= 0.6;\n}\n\n/**\n * Check whether any note indexed in the vault has a title matching the label.\n * Fetches all vault file rows via StorageBackend for efficiency.\n */\nasync function clusterHasMatchingNote(\n backend: StorageBackend,\n label: string,\n notePaths: string[]\n): Promise<boolean> {\n // First check the notes already in the cluster themselves — if any cluster\n // member's title matches the label it IS the index note → materialized.\n const pathSet = new Set(notePaths);\n\n // Fetch all vault files (bounded — vault rarely > 50k notes)\n const rows = await backend.getAllVaultFiles();\n\n for (const row of rows) {\n if (!row.title) continue;\n // Skip notes already counted inside the cluster — they don't count as\n // \"dedicated notes\"; we only skip a cluster if a SEPARATE note exists.\n if (pathSet.has(row.vaultPath)) continue;\n if (labelMatchesTitle(label, row.title)) return true;\n }\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: generate a clean suggested title\n// ---------------------------------------------------------------------------\n\nfunction toSuggestedTitle(label: string): string {\n // Remove leading/trailing whitespace, capitalize each word, remove stop words\n // that are all-lowercase at the start of the title.\n const words = label\n .trim()\n .split(/\\s+/)\n .map((w, i) => {\n const lower = w.toLowerCase();\n // Drop leading stop words (but keep if they're the only word)\n if (i === 0 && TITLE_STOP_WORDS.has(lower) && label.trim().split(/\\s+/).length > 1) {\n return \"\";\n }\n return w.charAt(0).toUpperCase() + w.slice(1);\n })\n .filter(Boolean);\n\n return words.join(\" \") || label;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: find most common folder\n// ---------------------------------------------------------------------------\n\nfunction mostCommonFolder(vaultPaths: string[]): string {\n const counts = new Map<string, number>();\n for (const p of vaultPaths) {\n const parts = p.split(\"/\");\n const folder = parts.length > 1 ? parts.slice(0, -1).join(\"/\") : \"\";\n counts.set(folder, (counts.get(folder) ?? 0) + 1);\n }\n\n let best = \"\";\n let bestCount = 0;\n for (const [folder, count] of counts) {\n if (count > bestCount) {\n bestCount = count;\n best = folder;\n }\n }\n return best;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: count distinct session date-folders\n// ---------------------------------------------------------------------------\n\n/**\n * Heuristic: vault notes are often stored in date-based folders like\n * \"2026/03/15\" or \"Daily/2026-03\". We extract the first numeric path\n * segment that looks like a year (2020-2030) and group by year+month.\n *\n * Falls back to counting distinct top-level folders.\n */\nfunction countDistinctSessions(vaultPaths: string[]): number {\n const sessions = new Set<string>();\n const yearMonthRe = /\\b(202\\d)\\D?(0[1-9]|1[0-2])\\b/;\n\n for (const p of vaultPaths) {\n const m = yearMonthRe.exec(p);\n if (m) {\n sessions.add(`${m[1]}-${m[2]}`);\n } else {\n // Fallback: use top-level folder as a proxy for \"session bucket\"\n const topFolder = p.split(\"/\")[0];\n sessions.add(topFolder);\n }\n }\n return sessions.size;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: calculate confidence score\n// ---------------------------------------------------------------------------\n\n/**\n * Confidence combines:\n * - Cluster size (normalized, capped at 20 for max contribution)\n * - Folder diversity (0-1 already)\n * - Sessions count (normalized, capped at 5)\n *\n * Formula: 0.4 * sizeScore + 0.35 * folderDiversity + 0.25 * sessionScore\n */\nfunction calcConfidence(\n size: number,\n folderDiversity: number,\n sessionsCount: number\n): number {\n const sizeScore = Math.min(size / 20, 1.0);\n const sessionScore = Math.min(sessionsCount / 5, 1.0);\n const raw = 0.4 * sizeScore + 0.35 * folderDiversity + 0.25 * sessionScore;\n return Math.round(raw * 100) / 100;\n}\n\n// ---------------------------------------------------------------------------\n// Main handler: graph_latent_ideas\n// ---------------------------------------------------------------------------\n\nexport async function handleGraphLatentIdeas(\n backend: StorageBackend,\n params: GraphLatentIdeasParams\n): Promise<GraphLatentIdeasResult> {\n const minClusterSize = params.min_cluster_size ?? 3;\n const maxIdeas = params.max_ideas ?? 15;\n const lookbackDays = params.lookback_days ?? 180;\n const similarityThreshold = params.similarity_threshold ?? 0.65;\n\n const { project_id: vaultProjectId } = params;\n if (!vaultProjectId) {\n throw new Error(\n \"graph_latent_ideas: project_id is required (pass the vault project's numeric ID)\"\n );\n }\n\n // Run the same clustering algorithm used by graph_clusters\n const themeResult = await zettelThemes(backend, {\n vaultProjectId,\n lookbackDays,\n minClusterSize,\n maxThemes: maxIdeas * 3, // Over-fetch — many will be filtered as materialized\n similarityThreshold,\n });\n\n const ideas: LatentIdea[] = [];\n let materializedCount = 0;\n\n for (const theme of themeResult.themes) {\n const notePaths = theme.notes.map((n) => n.path);\n\n // Check if a dedicated note already exists for this theme\n if (await clusterHasMatchingNote(backend, theme.label, notePaths)) {\n materializedCount++;\n continue;\n }\n\n // This is a latent idea — no dedicated note exists yet\n const suggestedFolder = mostCommonFolder(notePaths);\n const sessionsCount = countDistinctSessions(notePaths);\n const confidence = calcConfidence(theme.size, theme.folderDiversity, sessionsCount);\n\n // Build source notes with relevance scores\n // Relevance is approximated by position in cluster (centroid-closest first)\n // zettelThemes returns notes in no guaranteed order; assign uniform relevance\n // decreasing from 1.0 to 0.5 across the list.\n const sourceNotes: LatentIdeaSourceNote[] = theme.notes.map((n, idx) => ({\n vault_path: n.path,\n title: n.title ?? n.path.split(\"/\").pop()?.replace(/\\.md$/i, \"\") ?? n.path,\n relevance: Math.round((1.0 - (idx / Math.max(theme.notes.length - 1, 1)) * 0.5) * 100) / 100,\n }));\n\n ideas.push({\n id: theme.id,\n label: theme.label,\n size: theme.size,\n confidence,\n source_notes: sourceNotes,\n suggested_title: toSuggestedTitle(theme.label),\n suggested_folder: suggestedFolder,\n sessions_count: sessionsCount,\n });\n\n if (ideas.length >= maxIdeas) break;\n }\n\n // Sort by confidence descending\n ideas.sort((a, b) => b.confidence - a.confidence);\n\n return {\n ideas,\n total_clusters_analyzed: themeResult.themes.length + materializedCount,\n materialized_count: materializedCount,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Materialize handler: idea_materialize\n// ---------------------------------------------------------------------------\n\nexport function handleIdeaMaterialize(\n params: IdeaMaterializeParams,\n vaultPath: string\n): IdeaMaterializeResult {\n const { idea_label, title, folder, source_paths } = params;\n\n // Sanitize filename: replace characters illegal in filenames\n const safeTitle = title.replace(/[/\\\\:*?\"<>|]/g, \"-\");\n const fileName = `${safeTitle}.md`;\n\n // Vault-relative path (forward slashes, no leading slash)\n const relFolder = folder.replace(/^\\/+|\\/+$/g, \"\");\n const vault_path = relFolder ? `${relFolder}/${fileName}` : fileName;\n\n // Absolute filesystem path\n const absPath = join(vaultPath, vault_path);\n const absDir = dirname(absPath);\n\n // Build wikilinks from source_paths\n const wikilinks = source_paths\n .map((p) => {\n // Derive a display name: filename without extension\n const name = p.split(\"/\").pop()?.replace(/\\.md$/i, \"\") ?? p;\n // Relative wikilink — use just the filename (Obsidian resolves by title)\n return `- [[${name}]]`;\n })\n .join(\"\\n\");\n\n const links_created = source_paths.length;\n\n const content = [\n `# ${title}`,\n \"\",\n `*Materialized from latent idea: \"${idea_label}\"*`,\n `*Sources: ${links_created} notes*`,\n \"\",\n \"## Related Notes\",\n \"\",\n wikilinks || \"*(no source notes)*\",\n \"\",\n \"## Notes\",\n \"\",\n \"<!-- Add your thoughts about this idea here -->\",\n \"\",\n ].join(\"\\n\");\n\n // Write the file (create parent directories as needed)\n mkdirSync(absDir, { recursive: true });\n writeFileSync(absPath, content, \"utf-8\");\n\n return {\n vault_path,\n content,\n links_created,\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"latent-ideas-CtSca4qG.mjs","names":[],"sources":["../src/graph/latent-ideas.ts"],"sourcesContent":["/**\n * latent-ideas.ts — graph_latent_ideas and idea_materialize endpoint handlers\n *\n * \"Latent ideas\" are recurring themes in the vault that exist as embedding\n * clusters but have NO dedicated note written about them yet. PAI surfaces\n * these by running the same agglomerative clustering used by graph_clusters /\n * zettelThemes and then filtering OUT any cluster whose label is well-matched\n * by an existing note title.\n *\n * The materialize endpoint writes a new Markdown note to the vault filesystem\n * and returns its content so the plugin can open it immediately.\n */\n\nimport { mkdirSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { TITLE_STOP_WORDS } from \"../utils/stop-words.js\";\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport { zettelThemes } from \"../zettelkasten/themes.js\";\n\n// ---------------------------------------------------------------------------\n// Public param / result types\n// ---------------------------------------------------------------------------\n\nexport interface GraphLatentIdeasParams {\n project_id: number;\n /** Minimum notes in a cluster (default: 3) */\n min_cluster_size?: number;\n /** Cap on returned ideas (default: 15) */\n max_ideas?: number;\n /** How far back to look in days (default: 180) */\n lookback_days?: number;\n /** Cosine similarity clustering threshold (default: 0.65) */\n similarity_threshold?: number;\n}\n\nexport interface LatentIdeaSourceNote {\n vault_path: string;\n title: string;\n /** How strongly this note relates to the theme (0-1) */\n relevance: number;\n}\n\nexport interface LatentIdea {\n id: number;\n /** Auto-generated cluster label from zettelThemes */\n label: string;\n /** Number of notes touching this theme */\n size: number;\n /** 0-1, how likely this is a real coherent idea */\n confidence: number;\n /** Notes that contribute to this theme */\n source_notes: LatentIdeaSourceNote[];\n /** Cleaned-up version of label for a potential note title */\n suggested_title: string;\n /** Most common folder among source notes */\n suggested_folder: string;\n /** Number of distinct session date-folders (e.g. \"2026/03\") touching this theme */\n sessions_count: number;\n}\n\nexport interface GraphLatentIdeasResult {\n ideas: LatentIdea[];\n total_clusters_analyzed: number;\n /** How many clusters already have a matching note (excluded from results) */\n materialized_count: number;\n}\n\n// ---------------------------------------------------------------------------\n// Materialize params / result\n// ---------------------------------------------------------------------------\n\nexport interface IdeaMaterializeParams {\n idea_label: string;\n /** User-chosen title for the new note */\n title: string;\n /** Vault-relative folder path where the note should be created */\n folder: string;\n /** Vault-relative paths of the source notes to link from the new note */\n source_paths: string[];\n project_id: number;\n}\n\nexport interface IdeaMaterializeResult {\n /** Vault-relative path of the created note */\n vault_path: string;\n /** Generated markdown content */\n content: string;\n /** Number of wikilinks inserted */\n links_created: number;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: check if a cluster already has a matching note\n// ---------------------------------------------------------------------------\n\n/**\n * Returns true when any existing vault note title closely matches the cluster\n * label — meaning a dedicated note already exists for this topic.\n *\n * Matching strategy (simple, fast, no embeddings needed):\n * 1. Lowercase both sides and split into words.\n * 2. Remove stop words from the label words.\n * 3. If ≥ 60% of the significant label words appear in a note title → match.\n */\n// TITLE_STOP_WORDS imported from utils/stop-words.ts\n\nfunction labelMatchesTitle(label: string, title: string): boolean {\n const labelWords = label\n .toLowerCase()\n .split(/[\\s\\-_/]+/)\n .filter((w) => w.length > 2 && !TITLE_STOP_WORDS.has(w));\n\n if (labelWords.length === 0) return false;\n\n const titleLower = title.toLowerCase();\n const matchCount = labelWords.filter((w) => titleLower.includes(w)).length;\n return matchCount / labelWords.length >= 0.6;\n}\n\n/**\n * Check whether any note indexed in the vault has a title matching the label.\n * Fetches all vault file rows via StorageBackend for efficiency.\n */\nasync function clusterHasMatchingNote(\n backend: StorageBackend,\n label: string,\n notePaths: string[]\n): Promise<boolean> {\n // First check the notes already in the cluster themselves — if any cluster\n // member's title matches the label it IS the index note → materialized.\n const pathSet = new Set(notePaths);\n\n // Fetch all vault files (bounded — vault rarely > 50k notes)\n const rows = await backend.getAllVaultFiles();\n\n for (const row of rows) {\n if (!row.title) continue;\n // Skip notes already counted inside the cluster — they don't count as\n // \"dedicated notes\"; we only skip a cluster if a SEPARATE note exists.\n if (pathSet.has(row.vaultPath)) continue;\n if (labelMatchesTitle(label, row.title)) return true;\n }\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: generate a clean suggested title\n// ---------------------------------------------------------------------------\n\nfunction toSuggestedTitle(label: string): string {\n // Remove leading/trailing whitespace, capitalize each word, remove stop words\n // that are all-lowercase at the start of the title.\n const words = label\n .trim()\n .split(/\\s+/)\n .map((w, i) => {\n const lower = w.toLowerCase();\n // Drop leading stop words (but keep if they're the only word)\n if (i === 0 && TITLE_STOP_WORDS.has(lower) && label.trim().split(/\\s+/).length > 1) {\n return \"\";\n }\n return w.charAt(0).toUpperCase() + w.slice(1);\n })\n .filter(Boolean);\n\n return words.join(\" \") || label;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: find most common folder\n// ---------------------------------------------------------------------------\n\nfunction mostCommonFolder(vaultPaths: string[]): string {\n const counts = new Map<string, number>();\n for (const p of vaultPaths) {\n const parts = p.split(\"/\");\n const folder = parts.length > 1 ? parts.slice(0, -1).join(\"/\") : \"\";\n counts.set(folder, (counts.get(folder) ?? 0) + 1);\n }\n\n let best = \"\";\n let bestCount = 0;\n for (const [folder, count] of counts) {\n if (count > bestCount) {\n bestCount = count;\n best = folder;\n }\n }\n return best;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: count distinct session date-folders\n// ---------------------------------------------------------------------------\n\n/**\n * Heuristic: vault notes are often stored in date-based folders like\n * \"2026/03/15\" or \"Daily/2026-03\". We extract the first numeric path\n * segment that looks like a year (2020-2030) and group by year+month.\n *\n * Falls back to counting distinct top-level folders.\n */\nfunction countDistinctSessions(vaultPaths: string[]): number {\n const sessions = new Set<string>();\n const yearMonthRe = /\\b(202\\d)\\D?(0[1-9]|1[0-2])\\b/;\n\n for (const p of vaultPaths) {\n const m = yearMonthRe.exec(p);\n if (m) {\n sessions.add(`${m[1]}-${m[2]}`);\n } else {\n // Fallback: use top-level folder as a proxy for \"session bucket\"\n const topFolder = p.split(\"/\")[0];\n sessions.add(topFolder);\n }\n }\n return sessions.size;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: calculate confidence score\n// ---------------------------------------------------------------------------\n\n/**\n * Confidence combines:\n * - Cluster size (normalized, capped at 20 for max contribution)\n * - Folder diversity (0-1 already)\n * - Sessions count (normalized, capped at 5)\n *\n * Formula: 0.4 * sizeScore + 0.35 * folderDiversity + 0.25 * sessionScore\n */\nfunction calcConfidence(\n size: number,\n folderDiversity: number,\n sessionsCount: number\n): number {\n const sizeScore = Math.min(size / 20, 1.0);\n const sessionScore = Math.min(sessionsCount / 5, 1.0);\n const raw = 0.4 * sizeScore + 0.35 * folderDiversity + 0.25 * sessionScore;\n return Math.round(raw * 100) / 100;\n}\n\n// ---------------------------------------------------------------------------\n// Main handler: graph_latent_ideas\n// ---------------------------------------------------------------------------\n\nexport async function handleGraphLatentIdeas(\n backend: StorageBackend,\n params: GraphLatentIdeasParams\n): Promise<GraphLatentIdeasResult> {\n const minClusterSize = params.min_cluster_size ?? 3;\n const maxIdeas = params.max_ideas ?? 15;\n const lookbackDays = params.lookback_days ?? 180;\n const similarityThreshold = params.similarity_threshold ?? 0.65;\n\n const { project_id: vaultProjectId } = params;\n if (!vaultProjectId) {\n throw new Error(\n \"graph_latent_ideas: project_id is required (pass the vault project's numeric ID)\"\n );\n }\n\n // Run the same clustering algorithm used by graph_clusters\n const themeResult = await zettelThemes(backend, {\n vaultProjectId,\n lookbackDays,\n minClusterSize,\n maxThemes: maxIdeas * 3, // Over-fetch — many will be filtered as materialized\n similarityThreshold,\n });\n\n const ideas: LatentIdea[] = [];\n let materializedCount = 0;\n\n for (const theme of themeResult.themes) {\n const notePaths = theme.notes.map((n) => n.path);\n\n // Check if a dedicated note already exists for this theme\n if (await clusterHasMatchingNote(backend, theme.label, notePaths)) {\n materializedCount++;\n continue;\n }\n\n // This is a latent idea — no dedicated note exists yet\n const suggestedFolder = mostCommonFolder(notePaths);\n const sessionsCount = countDistinctSessions(notePaths);\n const confidence = calcConfidence(theme.size, theme.folderDiversity, sessionsCount);\n\n // Build source notes with relevance scores\n // Relevance is approximated by position in cluster (centroid-closest first)\n // zettelThemes returns notes in no guaranteed order; assign uniform relevance\n // decreasing from 1.0 to 0.5 across the list.\n const sourceNotes: LatentIdeaSourceNote[] = theme.notes.map((n, idx) => ({\n vault_path: n.path,\n title: n.title ?? n.path.split(\"/\").pop()?.replace(/\\.md$/i, \"\") ?? n.path,\n relevance: Math.round((1.0 - (idx / Math.max(theme.notes.length - 1, 1)) * 0.5) * 100) / 100,\n }));\n\n ideas.push({\n id: theme.id,\n label: theme.label,\n size: theme.size,\n confidence,\n source_notes: sourceNotes,\n suggested_title: toSuggestedTitle(theme.label),\n suggested_folder: suggestedFolder,\n sessions_count: sessionsCount,\n });\n\n if (ideas.length >= maxIdeas) break;\n }\n\n // Sort by confidence descending\n ideas.sort((a, b) => b.confidence - a.confidence);\n\n return {\n ideas,\n total_clusters_analyzed: themeResult.themes.length + materializedCount,\n materialized_count: materializedCount,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Materialize handler: idea_materialize\n// ---------------------------------------------------------------------------\n\nexport function handleIdeaMaterialize(\n params: IdeaMaterializeParams,\n vaultPath: string\n): IdeaMaterializeResult {\n const { idea_label, title, folder, source_paths } = params;\n\n // Sanitize filename: replace characters illegal in filenames\n const safeTitle = title.replace(/[/\\\\:*?\"<>|]/g, \"-\");\n const fileName = `${safeTitle}.md`;\n\n // Vault-relative path (forward slashes, no leading slash)\n const relFolder = folder.replace(/^\\/+|\\/+$/g, \"\");\n const vault_path = relFolder ? `${relFolder}/${fileName}` : fileName;\n\n // Absolute filesystem path\n const absPath = join(vaultPath, vault_path);\n const absDir = dirname(absPath);\n\n // Build wikilinks from source_paths\n const wikilinks = source_paths\n .map((p) => {\n // Derive a display name: filename without extension\n const name = p.split(\"/\").pop()?.replace(/\\.md$/i, \"\") ?? p;\n // Relative wikilink — use just the filename (Obsidian resolves by title)\n return `- [[${name}]]`;\n })\n .join(\"\\n\");\n\n const links_created = source_paths.length;\n\n const content = [\n `# ${title}`,\n \"\",\n `*Materialized from latent idea: \"${idea_label}\"*`,\n `*Sources: ${links_created} notes*`,\n \"\",\n \"## Related Notes\",\n \"\",\n wikilinks || \"*(no source notes)*\",\n \"\",\n \"## Notes\",\n \"\",\n \"<!-- Add your thoughts about this idea here -->\",\n \"\",\n ].join(\"\\n\");\n\n // Write the file (create parent directories as needed)\n mkdirSync(absDir, { recursive: true });\n writeFileSync(absPath, content, \"utf-8\");\n\n return {\n vault_path,\n content,\n links_created,\n };\n}\n"],"mappings":"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"}