@tekmidian/pai 0.36.2 → 0.38.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 (157) hide show
  1. package/dist/{auto-route-Byf8ENXj.mjs → auto-route-DM7GhJ8y.mjs} +2 -2
  2. package/dist/{auto-route-Byf8ENXj.mjs.map → auto-route-DM7GhJ8y.mjs.map} +1 -1
  3. package/dist/cli/index.mjs +5 -4
  4. package/dist/cli/index.mjs.map +1 -1
  5. package/dist/cli/program.d.mts.map +1 -1
  6. package/dist/cli/program.mjs +5 -4
  7. package/dist/{clusters-wZgTCYCB.mjs → clusters-Do4tEGyc.mjs} +1 -1
  8. package/dist/{clusters-wZgTCYCB.mjs.map → clusters-Do4tEGyc.mjs.map} +1 -1
  9. package/dist/{context-handover-cache-PtNvj_8D.mjs → context-handover-cache-BpUojjsi.mjs} +2 -2
  10. package/dist/{context-handover-cache-PtNvj_8D.mjs.map → context-handover-cache-BpUojjsi.mjs.map} +1 -1
  11. package/dist/daemon/index.mjs +8 -8
  12. package/dist/{daemon-BZ93KRBo.mjs → daemon-CGg1VCbA.mjs} +25 -25
  13. package/dist/{daemon-BZ93KRBo.mjs.map → daemon-CGg1VCbA.mjs.map} +1 -1
  14. package/dist/daemon-DsGGiIJM.mjs +20 -0
  15. package/dist/daemon-mcp/index.mjs +549 -1
  16. package/dist/daemon-mcp/index.mjs.map +1 -1
  17. package/dist/{detector--Gg5JRN5.mjs → detector-CMap-9vw.mjs} +1 -1
  18. package/dist/{detector--Gg5JRN5.mjs.map → detector-CMap-9vw.mjs.map} +1 -1
  19. package/dist/detector-DtLExmHN.mjs +5 -0
  20. package/dist/factory-BXzqRYVZ.mjs +3 -0
  21. package/dist/{factory-Bsp7xOpO.mjs → factory-DD2T33C9.mjs} +5 -5
  22. package/dist/{factory-Bsp7xOpO.mjs.map → factory-DD2T33C9.mjs.map} +1 -1
  23. package/dist/hooks/context-compression-hook.mjs +164 -14
  24. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  25. package/dist/hooks/initialize-session.mjs +6 -0
  26. package/dist/hooks/initialize-session.mjs.map +3 -3
  27. package/dist/hooks/load-core-context.mjs +6 -0
  28. package/dist/hooks/load-core-context.mjs.map +3 -3
  29. package/dist/hooks/load-project-context.mjs +6 -0
  30. package/dist/hooks/load-project-context.mjs.map +3 -3
  31. package/dist/hooks/post-compact-inject.mjs +6 -0
  32. package/dist/hooks/post-compact-inject.mjs.map +3 -3
  33. package/dist/hooks/route-agents-to-worker.mjs +518 -0
  34. package/dist/hooks/route-agents-to-worker.mjs.map +7 -0
  35. package/dist/hooks/status-line.mjs +400 -0
  36. package/dist/hooks/status-line.mjs.map +7 -0
  37. package/dist/hooks/stop-hook.mjs +6 -0
  38. package/dist/hooks/stop-hook.mjs.map +3 -3
  39. package/dist/hooks/subagent-stop-hook.mjs +6 -0
  40. package/dist/hooks/subagent-stop-hook.mjs.map +3 -3
  41. package/dist/hooks/sync-todo-to-md.mjs +6 -0
  42. package/dist/hooks/sync-todo-to-md.mjs.map +3 -3
  43. package/dist/hooks/update-tab-on-action.mjs +6 -0
  44. package/dist/hooks/update-tab-on-action.mjs.map +3 -3
  45. package/dist/hooks/update-tab-titles.mjs +6 -0
  46. package/dist/hooks/update-tab-titles.mjs.map +3 -3
  47. package/dist/hooks/worker-proxy.mjs +858 -0
  48. package/dist/hooks/worker-proxy.mjs.map +7 -0
  49. package/dist/hooks/worker-status-line.mjs +545 -0
  50. package/dist/hooks/worker-status-line.mjs.map +7 -0
  51. package/dist/{indexer-backend-nQZuEx6N.mjs → indexer-backend-Cox9BCo-.mjs} +1 -1
  52. package/dist/{indexer-backend-nQZuEx6N.mjs.map → indexer-backend-Cox9BCo-.mjs.map} +1 -1
  53. package/dist/{kg-entity-DbOMPdF9.mjs → kg-entity-r8duqhi9.mjs} +1 -1
  54. package/dist/{kg-entity-DbOMPdF9.mjs.map → kg-entity-r8duqhi9.mjs.map} +1 -1
  55. package/dist/{latent-ideas-Bn6A5-5P.mjs → latent-ideas-BC1oINZ-.mjs} +2 -2
  56. package/dist/{latent-ideas-Bn6A5-5P.mjs.map → latent-ideas-BC1oINZ-.mjs.map} +1 -1
  57. package/dist/{link-boost-fYjUnxCN.mjs → link-boost-QFLrJwD6.mjs} +1 -1
  58. package/dist/{link-boost-fYjUnxCN.mjs.map → link-boost-QFLrJwD6.mjs.map} +1 -1
  59. package/dist/{main-resolver-BAbhKpeX.mjs → main-resolver-DlaLOFBA.mjs} +1 -1
  60. package/dist/{main-resolver-BAbhKpeX.mjs.map → main-resolver-DlaLOFBA.mjs.map} +1 -1
  61. package/dist/{main-resolver-Dxh444GO.mjs → main-resolver-IhZo4pI0.mjs} +1 -1
  62. package/dist/{neighborhood-DpaEM991.mjs → neighborhood-D9MJ1c8f.mjs} +1 -1
  63. package/dist/{neighborhood-DpaEM991.mjs.map → neighborhood-D9MJ1c8f.mjs.map} +1 -1
  64. package/dist/{note-context-DrcY4cWm.mjs → note-context-d1wT_-GA.mjs} +1 -1
  65. package/dist/{note-context-DrcY4cWm.mjs.map → note-context-d1wT_-GA.mjs.map} +1 -1
  66. package/dist/{postgres-BVme6qX0.mjs → postgres--BjPtLa0.mjs} +1 -1
  67. package/dist/{postgres-BVme6qX0.mjs.map → postgres--BjPtLa0.mjs.map} +1 -1
  68. package/dist/{program-C-fUghPv.mjs → program-Y0hAiVy8.mjs} +851 -101
  69. package/dist/program-Y0hAiVy8.mjs.map +1 -0
  70. package/dist/providers-DUshcB-d.mjs +4031 -0
  71. package/dist/providers-DUshcB-d.mjs.map +1 -0
  72. package/dist/{query-feedback-C1T6kS18.mjs → query-feedback-BUJxgw5B.mjs} +1 -1
  73. package/dist/{query-feedback-C1T6kS18.mjs.map → query-feedback-BUJxgw5B.mjs.map} +1 -1
  74. package/dist/query-feedback-DhyLOe5S.mjs +3 -0
  75. package/dist/router-1zi8jiNF.mjs +3 -0
  76. package/dist/{router-CsDm7HvK.mjs → router-DK_sLsUL.mjs} +1 -1
  77. package/dist/{router-CsDm7HvK.mjs.map → router-DK_sLsUL.mjs.map} +1 -1
  78. package/dist/skills/Worker/SKILL.md +63 -0
  79. package/dist/{sources-D8ZdNfvK.mjs → sources-Bi7--33T.mjs} +1 -1
  80. package/dist/{sources-D8ZdNfvK.mjs.map → sources-Bi7--33T.mjs.map} +1 -1
  81. package/dist/{sqlite-D1IaR8Am.mjs → sqlite-DtaL1glm.mjs} +1 -1
  82. package/dist/{sqlite-D1IaR8Am.mjs.map → sqlite-DtaL1glm.mjs.map} +1 -1
  83. package/dist/{state-qtmrBWCm.mjs → state-8Hm9E4tW.mjs} +1 -1
  84. package/dist/{state-WaXhLr6R.mjs → state-BY2L6-vX.mjs} +1 -1
  85. package/dist/{state-WaXhLr6R.mjs.map → state-BY2L6-vX.mjs.map} +1 -1
  86. package/dist/{themes-XPkj_bfP.mjs → themes-BN0a2duq.mjs} +1 -1
  87. package/dist/{themes-XPkj_bfP.mjs.map → themes-BN0a2duq.mjs.map} +1 -1
  88. package/dist/{tools-DEt6YPfc.mjs → tools-CGPqpU3A.mjs} +1 -1
  89. package/dist/{tools-ceiy7ANX.mjs → tools-y2bJpKom.mjs} +14 -14
  90. package/dist/{tools-ceiy7ANX.mjs.map → tools-y2bJpKom.mjs.map} +1 -1
  91. package/dist/{trace-DfyGmMG_.mjs → trace-h23JCcFD.mjs} +1 -1
  92. package/dist/{trace-DfyGmMG_.mjs.map → trace-h23JCcFD.mjs.map} +1 -1
  93. package/dist/{vault-indexer-CFvlPUMB.mjs → vault-indexer-DgsPjMgs.mjs} +1 -1
  94. package/dist/{vault-indexer-CFvlPUMB.mjs.map → vault-indexer-DgsPjMgs.mjs.map} +1 -1
  95. package/dist/{work-queue-worker-B8W8_3Rn.mjs → work-queue-worker-Dva_v_pI.mjs} +4 -4
  96. package/dist/{work-queue-worker-B8W8_3Rn.mjs.map → work-queue-worker-Dva_v_pI.mjs.map} +1 -1
  97. package/dist/{work-queue-worker-HN2Ufg-L.mjs → work-queue-worker-gsKd2LJa.mjs} +4 -4
  98. package/dist/{zettelkasten-CvjmMghT.mjs → zettelkasten-vo7psPdT.mjs} +3 -3
  99. package/dist/{zettelkasten-CvjmMghT.mjs.map → zettelkasten-vo7psPdT.mjs.map} +1 -1
  100. package/docs/commands/README.md +29 -0
  101. package/docs/commands/backup.md +1 -1
  102. package/docs/commands/clear-names.md +1 -1
  103. package/docs/commands/daemon.md +1 -1
  104. package/docs/commands/db.md +1 -1
  105. package/docs/commands/end.md +1 -1
  106. package/docs/commands/help.md +1 -1
  107. package/docs/commands/identity.md +1 -1
  108. package/docs/commands/kg.md +1 -1
  109. package/docs/commands/mcp.md +1 -1
  110. package/docs/commands/memory.md +1 -1
  111. package/docs/commands/notify.md +1 -1
  112. package/docs/commands/observation.md +1 -1
  113. package/docs/commands/obsidian.md +1 -1
  114. package/docs/commands/pause.md +1 -1
  115. package/docs/commands/project.md +1 -1
  116. package/docs/commands/projects.md +1 -1
  117. package/docs/commands/registry.md +1 -1
  118. package/docs/commands/restore.md +1 -1
  119. package/docs/commands/session.md +1 -1
  120. package/docs/commands/sessions.md +1 -1
  121. package/docs/commands/setup.md +1 -1
  122. package/docs/commands/shell-init.md +1 -1
  123. package/docs/commands/skill.md +1 -1
  124. package/docs/commands/task.md +1 -1
  125. package/docs/commands/topic.md +1 -1
  126. package/docs/commands/update.md +1 -1
  127. package/docs/commands/worker.md +398 -0
  128. package/docs/commands/zettel.md +1 -1
  129. package/docs/worker.md +434 -0
  130. package/package.json +1 -1
  131. package/scripts/build-hooks.mjs +46 -1
  132. package/src/hooks/pre-compact.sh +1 -0
  133. package/src/hooks/session-autosave.sh +1 -0
  134. package/src/hooks/session-stop.sh +1 -0
  135. package/src/hooks/ts/lib/context-fill.test.ts +104 -0
  136. package/src/hooks/ts/lib/context-fill.ts +73 -0
  137. package/src/hooks/ts/lib/handover-evidence.ts +227 -0
  138. package/src/hooks/ts/lib/worker-session.test.ts +24 -0
  139. package/src/hooks/ts/lib/worker-session.ts +23 -0
  140. package/src/hooks/ts/post-tool-use/sync-todo-to-md.ts +2 -0
  141. package/src/hooks/ts/post-tool-use/update-tab-on-action.ts +2 -0
  142. package/src/hooks/ts/pre-compact/context-compression-hook.ts +17 -0
  143. package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +128 -0
  144. package/src/hooks/ts/session-start/initialize-session.ts +2 -0
  145. package/src/hooks/ts/session-start/load-core-context.ts +2 -0
  146. package/src/hooks/ts/session-start/load-project-context.ts +2 -0
  147. package/src/hooks/ts/session-start/post-compact-inject.ts +2 -0
  148. package/src/hooks/ts/stop/stop-hook.ts +2 -0
  149. package/src/hooks/ts/subagent-stop/subagent-stop-hook.ts +2 -0
  150. package/src/hooks/ts/user-prompt/update-tab-titles.ts +2 -0
  151. package/statusline-command.sh +17 -0
  152. package/dist/daemon-DRdoA489.mjs +0 -20
  153. package/dist/detector-DGAk1iBR.mjs +0 -5
  154. package/dist/factory-CrokPMk2.mjs +0 -3
  155. package/dist/program-C-fUghPv.mjs.map +0 -1
  156. package/dist/query-feedback-BBMBp96K.mjs +0 -3
  157. package/dist/router-BMkOb62X.mjs +0 -3
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../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", "../../../src/workers/status.ts", "../../../src/workers/scope.ts", "../../../src/workers/report.ts", "../../../src/workers/render.ts", "../../../src/workers/viewer.ts", "../../../src/workers/standalone/status-line.ts"],
4
+ "sourcesContent": ["/**\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\";\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 models: {\n default: string;\n /** Alias used for cheap/fast work (spotcheck, research). */\n fast?: string;\n };\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 /** Context window of the provider's model, for the context meter. Default 200000. */\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/** Cost/quality tier of a provider's model, 1 (cheapest) \u2026 5 (most expensive). */\nexport type CostTier = 1 | 2 | 3 | 4 | 5;\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 * 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\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}\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 function defaultWorkersConfig(): WorkersConfig {\n return {\n enabled: false,\n active: null,\n providers: {},\n classes: {},\n mcpSets: {},\n pane: { ...DEFAULT_PANE },\n logDir: DEFAULT_LOG_DIR,\n routing: { ...DEFAULT_ROUTING, order: [] },\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 fast = m.fast === undefined ? undefined : str(m.fast);\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: fast ? { default: defaultModel, fast } : { default: defaultModel },\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[]> = {};\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 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 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 };\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 */\nexport function readWorkersSection(): {\n raw: Record<string, unknown>;\n workers: WorkersConfig;\n} {\n const raw = readJsonStrict(CONFIG_FILE, \"~/.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): void {\n raw.workers = workers;\n writeJsonAtomic(CONFIG_FILE, 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 const DEFAULT_CONTEXT_WINDOW = 200_000;\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/** Context window used by the meter when the init event carries none. */\nexport function providerContextWindow(p: WorkerProvider): number {\n return p.contextWindow ?? DEFAULT_CONTEXT_WINDOW;\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", "/**\n * status.ts \u2014 the live per-worker status file.\n *\n * One JSON file per run, rewritten atomically on every turn, read by `ps`,\n * `follow`, the status line and the pane logic. Same field set the Python\n * runner wrote, plus `provider` (routing is multi-provider now) and `session`\n * (the AIBroker identity of the launching session, see scope.ts).\n */\n\nimport { existsSync, readFileSync, renameSync, writeFileSync, readdirSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { shortText } from \"./args.js\";\nimport { statusPath } from \"./paths.js\";\n\nexport interface WorkerSessionRef {\n /** AIBroker/iTerm session id (the iTerm UUID). */\n id: string;\n /** AIBroker name of that session, when it has one. */\n name: string;\n}\n\nexport interface WorkerStatus {\n id: string;\n pid: number;\n label: string;\n cwd: string;\n /** iTerm session id of the launching terminal (\"\" outside iTerm). */\n term: string;\n provider: string;\n model: string;\n state: \"running\" | \"done\" | \"failed\" | \"killed\" | \"lost\";\n started: string;\n updated: string;\n turns: number;\n tools: number;\n last: string;\n rc: number | null;\n secs: number | null;\n /** Launching session resolved through AIBroker, when it was. */\n session?: WorkerSessionRef | null;\n /** Claude Code session id (system/init) \u2014 what `resume` continues. */\n claudeSession?: string | null;\n /** Context meter: tokens of the last assistant turn (input+cache+output). */\n contextTokens?: number | null;\n /** Context meter: window size (init model info or provider default). */\n contextWindow?: number | null;\n /** Chain this stage belongs to (the chain id), when it is a chain stage. */\n parent?: string;\n /** Class name of the chain stage (\"draft\", \"implement\", \u2026). */\n stage?: string;\n}\n\n/** Context-meter percentage 0\u2013100, null when the numbers are missing. */\nexport function contextPercent(s: Pick<WorkerStatus, \"contextTokens\" | \"contextWindow\">): number | null {\n if (!s.contextTokens || !s.contextWindow) return null;\n return Math.round((s.contextTokens / s.contextWindow) * 100);\n}\n\n/** Timestamp format shared by status files and the ledger. */\nexport function nowStamp(d: Date = new Date()): string {\n const p = (n: number) => String(n).padStart(2, \"0\");\n return (\n `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ` +\n `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`\n );\n}\n\n// second-resolution ids repeat when two workers/chains start together in one\n// process; a repeat gets a monotonic suffix so files never collide\nlet lastId = \"\";\nlet idSeq = 0;\n\nexport function newWorkerId(d: Date = new Date(), pid = process.pid): string {\n const p = (n: number) => String(n).padStart(2, \"0\");\n const base = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}-${pid}`;\n if (base === lastId) {\n idSeq += 1;\n return `${base}-${String(idSeq).padStart(2, \"0\")}`;\n }\n lastId = base;\n idSeq = 0;\n return base;\n}\n\n/** Write status atomically (temp + rename) and stamp `updated`. */\nexport function saveStatus(logDir: string, status: WorkerStatus, d: Date = new Date()): void {\n status.updated = nowStamp(d);\n const path = statusPath(logDir, status.id);\n const tmp = `${path}.tmp`;\n writeFileSync(tmp, JSON.stringify(status), \"utf8\");\n renameSync(tmp, path);\n}\n\n/** Load every status file in the logDir, oldest id first, skipping damage. */\nexport function loadStatuses(logDir: string): WorkerStatus[] {\n if (!existsSync(logDir)) return [];\n const out: WorkerStatus[] = [];\n for (const name of readdirSync(logDir).sort()) {\n if (!name.endsWith(\".status\")) continue;\n try {\n out.push(JSON.parse(readFileSync(join(logDir, name), \"utf8\")) as WorkerStatus);\n } catch {\n // a half-written or damaged status file is not worth a crash\n }\n }\n return out;\n}\n\nexport function loadStatus(logDir: string, id: string): WorkerStatus | null {\n const path = statusPath(logDir, id);\n if (!existsSync(path)) return null;\n try {\n return JSON.parse(readFileSync(path, \"utf8\")) as WorkerStatus;\n } catch {\n return null;\n }\n}\n\nexport function alive(pid: number | null | undefined): boolean {\n if (!pid || pid <= 0) return false;\n try {\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n\n/** \"42s\" under 90s, \"7m\" above \u2014 the coarse age the table and bar show. */\nexport function ageOf(ts: string, now: Date = new Date()): string {\n const t = Date.parse(ts.replace(\" \", \"T\"));\n if (Number.isNaN(t)) return \"?\";\n const s = Math.max(0, Math.floor((now.getTime() - t) / 1000));\n return s < 90 ? `${s}s` : `${Math.floor(s / 60)}m`;\n}\n\n/** One-line description of a tool call, e.g. \"Bash: npm test\". */\nexport function describeTool(name: string, inp: unknown): string {\n if (typeof inp !== \"object\" || inp === null) return name;\n const i = inp as Record<string, unknown>;\n const get = (k: string) => (typeof i[k] === \"string\" ? (i[k] as string) : \"\");\n if (name === \"Bash\") return `Bash: ${shortText(get(\"command\"), 70)}`;\n if (name === \"Read\" || name === \"Edit\" || name === \"Write\" || name === \"MultiEdit\") {\n const file = get(\"file_path\").split(\"/\").pop() ?? \"\";\n return `${name}: ${file}`;\n }\n if (name === \"Grep\" || name === \"Glob\") return `${name}: ${shortText(get(\"pattern\"), 50)}`;\n if (name === \"WebSearch\") return `${name}: ${shortText(get(\"query\"), 50)}`;\n if (name === \"WebFetch\") return `${name}: ${shortText(get(\"url\"), 60)}`;\n return name;\n}\n", "/**\n * scope.ts \u2014 which workers \"belong\" to the terminal asking about them.\n *\n * Two identities, in priority order:\n *\n * 1. AIBroker's session registry (~/.aibroker/session-names.json): iTerm\n * session UUID \u2192 session name. The launching session's UUID is stored in\n * each worker's status file as `session.id`, so every pane of a named\n * session sees its workers \u2014 regardless of tab layout.\n * 2. The iTerm tab key (`w<window>t<tab>` prefix of ITERM_SESSION_ID): the\n * original heuristic, kept as the fallback when no registry entry\n * matches (AIBroker absent, unnamed session, non-iTerm terminal).\n *\n * If neither is available, viewers fall back to \"all workers\".\n */\n\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { WorkerStatus } from \"./status.js\";\n\nexport const AIBROKER_REGISTRY = join(homedir(), \".aibroker\", \"session-names.json\");\n\n/**\n * `w<window>t<tab>` prefix of an iTerm session id, \"\" if empty or malformed.\n * Panes split from the same tab share this prefix and differ only in `p<n>`.\n */\nexport function tabKey(term: string): string {\n if (!term) return \"\";\n const head = term.split(\"p\", 1)[0];\n const parts = head.slice(1).split(\"t\");\n if (head.startsWith(\"w\") && parts.length === 2 && parts.every((p) => /^\\d+$/.test(p))) {\n return head;\n }\n return \"\";\n}\n\n/** Tab key of the iTerm tab this process runs in, \"\" if not inside iTerm2. */\nexport function currentTabKey(env: NodeJS.ProcessEnv = process.env): string {\n return tabKey(env.ITERM_SESSION_ID ?? \"\");\n}\n\n/** The iTerm UUID part of an ITERM_SESSION_ID (after the last colon). */\nexport function itermUuid(term: string): string {\n if (!term) return \"\";\n return term.split(\":\").pop() ?? \"\";\n}\n\nexport interface SessionIdentity {\n id: string;\n name: string;\n}\n\n/**\n * Resolve the AIBroker session for an ITERM_SESSION_ID. Reads the persistent\n * name registry (the same store `aibroker_rename` writes); returns null when\n * AIBroker is absent or the session is not in it \u2014 callers then use the tab\n * key, which is the pre-AIBroker behaviour.\n */\nexport function resolveSession(\n term: string,\n registryPath: string = AIBROKER_REGISTRY\n): SessionIdentity | null {\n const uuid = itermUuid(term);\n if (!uuid) return null;\n let names: Record<string, unknown>;\n try {\n if (!existsSync(registryPath)) return null;\n names = JSON.parse(readFileSync(registryPath, \"utf8\")) as Record<string, unknown>;\n } catch {\n return null;\n }\n const name = names[uuid];\n if (typeof name !== \"string\" || !name) return null;\n return { id: uuid, name };\n}\n\n/**\n * True when `worker` was launched from `term` (the terminal asking about it).\n * Session-id match first; the tab key only when no registry entry matched \u2014\n * a session without a name keeps the tab-scoped behaviour it always had.\n */\nexport function workerInScope(worker: WorkerStatus, term: string): boolean {\n if (!term) return false;\n const uuid = itermUuid(term);\n if (worker.session?.id && uuid) return worker.session.id === uuid;\n return tabKey(worker.term) === tabKey(term) && tabKey(term) !== \"\";\n}\n\n/**\n * Registry key for pane files: the session id when AIBroker knows this\n * terminal, else the tab key, else the raw iTerm UUID.\n */\nexport function scopeKey(term: string): string {\n const session = resolveSession(term);\n if (session) return session.id;\n return currentTabKey() || itermUuid(term);\n}\n\n/** `[Name]` when the worker has an AIBroker session name, else \"\". */\nexport function sessionTag(worker: { session?: { name?: string } | null }): string {\n return worker.session?.name ? `[${worker.session.name}]` : \"\";\n}\n", "/**\n * report.ts \u2014 the worker contract: terse, structured final reports.\n *\n * Headless workers run with an appended system prompt that fixes their output\n * shape: act, verify, then finish with ONE final message that is a JSON object\n * and nothing else. The runner parses it out of the result; the viewer renders\n * it as a compact block instead of a wall of text.\n */\n\nimport { relative } from \"node:path\";\nimport { shortText } from \"./args.js\";\nimport type { Paint } from \"./render.js\";\n\n/** Appended to the caller's system prompt on every headless run (2h). */\nexport const WORKER_CONTRACT_PROMPT = [\n \"You are a headless implementation worker, run non-interactively by an orchestrating session.\",\n \"No narration, no timestamps, no greetings, no summaries of what you read; act, verify, then stop.\",\n \"Your ONE final message is a JSON object and nothing else \u2014 no prose before or after, no code fence:\",\n '{\"changed\":[{\"path\":\"\u2026\",\"summary\":\"\u2026\"}],\"commands\":[\"\u2026\"],\"checks\":[{\"name\":\"\u2026\",\"ok\":true,\"detail\":\"\u2026\"}],\"open\":[\"\u2026\"],\"notes\":\"one line\"}',\n \"changed: files you touched (path + one-line summary). commands: the commands that verify the work.\",\n \"checks: each with ok true/false and the evidence in detail. open: what you could not finish, if anything.\",\n \"notes: one line, the headline a reviewer reads first.\",\n].join(\"\\n\");\n\nexport interface WorkerReport {\n changed?: Array<{ path?: string; summary?: string }>;\n commands?: string[];\n checks?: Array<{ name?: string; ok?: boolean; detail?: string }>;\n open?: string[];\n notes?: string;\n}\n\n/**\n * Extract the JSON report from a worker's final message. Accepts a bare\n * object, a ```json fenced block, or an object embedded in surrounding text.\n * Only objects that look like the contract count (at least one of changed /\n * checks / notes) \u2014 any other JSON falls through to null so the raw text is\n * kept as-is.\n */\nexport function parseWorkerReport(text: string): WorkerReport | null {\n const trimmed = (text ?? \"\").trim();\n if (!trimmed) return null;\n const candidates: string[] = [];\n const fence = trimmed.match(/```(?:json)?\\s*(\\{[\\s\\S]*?\\})\\s*```/);\n if (fence) candidates.push(fence[1]);\n if (trimmed.startsWith(\"{\")) {\n // a bare object may still carry trailing punctuation/newlines\n candidates.push(trimmed);\n }\n // object embedded in prose: first \"{\" to the matching last \"}\"\n const first = trimmed.indexOf(\"{\");\n const last = trimmed.lastIndexOf(\"}\");\n if (first >= 0 && last > first) candidates.push(trimmed.slice(first, last + 1));\n for (const cand of candidates) {\n try {\n const v = JSON.parse(cand) as unknown;\n if (typeof v === \"object\" && v !== null && !Array.isArray(v) && looksLikeReport(v)) {\n return v as WorkerReport;\n }\n } catch {\n // try the next candidate\n }\n }\n return null;\n}\n\n/** The contract's fingerprint \u2014 guards against unrelated JSON in a reply. */\nfunction looksLikeReport(v: object): boolean {\n const o = v as WorkerReport;\n return Array.isArray(o.changed) || Array.isArray(o.checks) || typeof o.notes === \"string\";\n}\n\n/**\n * Render a parsed report as the compact block the viewer shows: changed paths\n * with summaries, checks with \u2713/\u2717, open items, notes. Empty report \u2192 [].\n */\nexport function renderReport(c: Paint, prefix: string, r: WorkerReport, cwd = \"\"): string[] {\n const out: string[] = [];\n const has = (xs: unknown[] | undefined) => (xs ?? []).length > 0;\n if (has(r.changed)) {\n out.push(`${prefix}${c(\"bold\", \"changed\")}`);\n for (const ch of r.changed!) {\n out.push(`${prefix} ${relShort(ch.path ?? \"?\", cwd)} \u2014 ${shortText(ch.summary ?? \"\", 90)}`);\n }\n }\n if (has(r.checks)) {\n out.push(`${prefix}${c(\"bold\", \"checks\")}`);\n for (const ck of r.checks!) {\n const mark = ck.ok === false ? c(\"red\", \"\u2717\") : c(\"green\", \"\u2713\");\n const detail = ck.detail ? c(\"dim\", ` \u00B7 ${shortText(ck.detail, 80)}`) : \"\";\n out.push(`${prefix} ${mark} ${ck.name ?? \"?\"}${detail}`);\n }\n }\n if (has(r.commands)) {\n out.push(`${prefix}${c(\"bold\", \"commands\")}`);\n for (const cmd of r.commands!) out.push(`${prefix} ${c(\"dim\", shortText(cmd, 100))}`);\n }\n if (has(r.open)) {\n out.push(`${prefix}${c(\"bold\", \"open\")}`);\n for (const o of r.open!) out.push(`${prefix} ${c(\"yellow\", shortText(o, 100))}`);\n }\n if (r.notes) out.push(`${prefix}${c(\"bold\", \"notes\")} ${shortText(r.notes, 120)}`);\n return out;\n}\n\n/** repo-relative display of a changed path when it lies under cwd. */\nfunction relShort(p: string, cwd: string): string {\n if (!cwd) return p;\n const r = relative(cwd, p);\n return r && !r.startsWith(\"..\") ? r : p;\n}\n", "/**\n * render.ts \u2014 turn worker events into the lines a human reads.\n *\n * Ports the glm-ps transcript rendering: the gutter rules (dim `>` for reads\n * and searches, `$` for shell, magenta `~` + red/green diff for edits, green\n * `+` for writes), Read-result trimming (first 3 lines then a count), and the\n * result footer. Colors are applied only when the output is a TTY, so MCP\n * tool output stays plain text.\n */\n\nimport { relative, basename } from \"node:path\";\nimport { shortText } from \"./args.js\";\nimport { ageOf, contextPercent, type WorkerStatus, alive } from \"./status.js\";\nimport { sessionTag } from \"./scope.js\";\nimport { parseWorkerReport, renderReport } from \"./report.js\";\n\nexport type ColorEnabled = boolean;\n\nconst CODES = {\n dim: \"2\",\n bold: \"1\",\n red: \"31\",\n green: \"32\",\n yellow: \"33\",\n blue: \"34\",\n mag: \"35\",\n cyan: \"36\",\n} as const;\n\nexport type ColorName = keyof typeof CODES;\n\nexport function makeColor(enabled: ColorEnabled) {\n return (name: ColorName, s: string): string =>\n enabled ? `\\x1b[${CODES[name]}m${s}\\x1b[0m` : s;\n}\n\nexport type Paint = ReturnType<typeof makeColor>;\n\n/** Path relative to cwd when it lies inside it, otherwise unchanged. */\nexport function relPath(path: string, cwd: string): string {\n if (!path || !cwd) return path;\n let r: string;\n try {\n r = relative(cwd, path);\n } catch {\n return path;\n }\n return r.startsWith(\"..\") ? path : r;\n}\n\n/** Minimal line diff for Edit previews: common prefix/suffix, one hunk. */\nexport function unifiedDiffLines(oldStr: string, newStr: string): string[] {\n const oldL = oldStr.split(\"\\n\");\n const newL = newStr.split(\"\\n\");\n let start = 0;\n while (start < oldL.length && start < newL.length && oldL[start] === newL[start]) start++;\n let endOld = oldL.length;\n let endNew = newL.length;\n while (endOld > start && endNew > start && oldL[endOld - 1] === newL[endNew - 1]) {\n endOld--;\n endNew--;\n }\n const out: string[] = [];\n for (let i = start; i < endOld; i++) out.push(\"-\" + oldL[i]);\n for (let i = start; i < endNew; i++) out.push(\"+\" + newL[i]);\n return out;\n}\n\nexport interface ToolUseBlock {\n type?: string;\n text?: string;\n name?: string;\n id?: string;\n input?: unknown;\n /** tool_result blocks only: the call this answers. */\n tool_use_id?: string;\n is_error?: boolean;\n content?: unknown;\n}\n\nexport interface StreamEventLike {\n type?: string;\n subtype?: string;\n model?: string;\n cwd?: string;\n message?: { content?: ToolUseBlock[] };\n result?: string;\n is_error?: boolean;\n num_turns?: number;\n duration_ms?: number;\n /** ISO stamp the runner attaches to every mirrored event (2g). */\n _ts?: string;\n /** Operator text (type: \"operator\"). */\n text?: string;\n}\n\n/**\n * `HH:MM:SS` at an offset east of UTC in minutes (Date.getTimezoneOffset()\n * negated), from any ISO stamp the runner wrote \u2014 `Z` or a local `+HH:MM`.\n * null when the stamp cannot be parsed. Offsets make this testable without\n * depending on the machine's zone; the default is this machine's.\n */\nexport function clockOf(ts: string, offMin = -new Date().getTimezoneOffset()): string | null {\n const t = Date.parse(ts);\n if (Number.isNaN(t)) return null;\n return new Date(t + offMin * 60_000).toISOString().slice(11, 19);\n}\n\n/** `YYYY-MM-DD` at the same offset \u2014 the local day a date separator shows. */\nexport function dayOf(ts: string, offMin = -new Date().getTimezoneOffset()): string | null {\n const t = Date.parse(ts);\n if (Number.isNaN(t)) return null;\n return new Date(t + offMin * 60_000).toISOString().slice(0, 10);\n}\n\n/** The gutter of one rendered event: its three shapes and its width. */\nexport interface Gutter {\n /** first row: `HH:MM:SS \u2502 ` (dim). */\n first: string;\n /** later rows of one event, no wrapping: blanks of the same width. */\n cont: string;\n /** wrapped continuation rows: blank time, the `\u2502` bar kept (dim). */\n barCont: string;\n /** printable columns first/cont/barCont occupy. */\n width: number;\n}\n\n/**\n * The transcript gutter (2g): `HH:MM:SS \u2502 ` from the event's `_ts`, dim, with\n * the worker tag in front when several run at once. Continuation lines get\n * blanks of the same width so wrapped text stays aligned; when the viewer\n * wraps lines itself, continuation rows carry the `\u2502` bar instead (barCont)\n * so the bar runs unbroken down the pane. null when the event carries no\n * stamp (logs from before 2g render with the plain prefix). The time is the\n * stamp's wall clock at `offMin` \u2014 the default renders local time, whatever\n * zone stamped the log (old logs were stamped in UTC).\n */\nexport function gutterFor(\n c: Paint,\n e: { _ts?: string },\n tag?: string,\n offMin?: number\n): Gutter | null {\n if (!e._ts) return null;\n const time = clockOf(e._ts, offMin) ?? (e._ts.length >= 19 ? e._ts.slice(11, 19) : e._ts);\n const head = tag ? `${tag} ${time}` : time;\n const width = head.length + 3; // + \" \u2502 \"\n return {\n first: c(\"dim\", `${head} \u2502 `),\n cont: \" \".repeat(width),\n barCont: c(\"dim\", `${\" \".repeat(head.length)} \u2502 `),\n width,\n };\n}\n\n/**\n * The ticker's tool part: `$ <command>` for Bash (first 60 chars), the file\n * basename for the file tools, the bare name for everything else.\n */\nexport function tickerTool(name: string, inp: unknown): string {\n const i = (typeof inp === \"object\" && inp !== null ? inp : {}) as Record<string, unknown>;\n const get = (k: string) => (typeof i[k] === \"string\" ? (i[k] as string) : \"\");\n if (name === \"Bash\") return `$ ${shortText(get(\"command\").replace(/\\s+/g, \" \").trim(), 60)}`;\n if (name === \"Read\" || name === \"Edit\" || name === \"Write\" || name === \"MultiEdit\") {\n const base = get(\"file_path\").split(\"/\").pop() ?? \"\";\n return base || name;\n }\n return name;\n}\n\n/**\n * The liveness line: `\u22EF 12s \u00B7 run tests before the fix \u00B7 $ bun run test` \u2014\n * seconds since the last *rendered* event, the worker's last stated intent\n * (its last assistant text, \u226460 chars) and the tool it is currently running.\n * Parts that are empty drop out.\n */\nexport function tickerText(secs: number, intent: string, tool: string, meter?: string | null): string {\n const head = `\u22EF ${secs}s`;\n const parts = [intent, tool].map((p) => p.trim()).filter(Boolean);\n const tail = parts.join(\" \u00B7 \");\n const line = tail ? `${head} \u00B7 ${tail}` : head;\n return meter ? `${line} \u00B7 ${meter}` : line;\n}\n\n/**\n * One blank line between turns, none inside one: a blank goes before an\n * assistant message that follows a tool result or an operator message (the\n * worker starting to speak again after its tools were answered / it was told\n * something), not between the text, tool calls and results of one turn.\n */\nexport function blankBetween(prev: { type?: string } | null, e: { type?: string }): boolean {\n if (!prev) return false;\n if (e.type !== \"assistant\") return false;\n return prev.type === \"user\" || prev.type === \"operator\";\n}\n\n/** The worker's last stated intent: the first line of its last text, \u226460. */\nexport function intentOf(text: string): string {\n const first = text.trim().split(\"\\n\").find((l) => l.trim()) ?? \"\";\n return shortText(first.trim().replace(/\\s+/g, \" \"), 60);\n}\n\n/** Compact token count: 84k, 200k, 900. */\nfunction fmtK(n: number): string {\n return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);\n}\n\n/**\n * The context meter `ctx 84k/200k (42%)`, yellow from 70 %, red from 85 %.\n * null when the numbers are missing or below `minPct` (the table only shows\n * it past 60; the pane liveness line always shows it).\n */\nexport function contextMeter(\n c: Paint,\n s: Pick<WorkerStatus, \"contextTokens\" | \"contextWindow\">,\n minPct = 0\n): string | null {\n const pct = contextPercent(s);\n if (pct === null || pct <= minPct) return null;\n const label = `ctx ${fmtK(s.contextTokens ?? 0)}/${fmtK(s.contextWindow ?? 0)} (${pct}%)`;\n if (pct > 85) return c(\"red\", label);\n if (pct > 70) return c(\"yellow\", label);\n return label;\n}\n\nfunction renderToolUse(\n c: Paint,\n prefix: string,\n name: string,\n inp: unknown,\n cwd: string\n): string[] {\n const i = (typeof inp === \"object\" && inp !== null ? inp : {}) as Record<string, unknown>;\n const get = (k: string) => (typeof i[k] === \"string\" ? (i[k] as string) : \"\");\n if (name === \"Read\") {\n return [`${prefix}${c(\"dim\", \">\")} Reading ${relPath(get(\"file_path\"), cwd)}`];\n }\n if (name === \"Grep\" || name === \"Glob\") {\n return [\n `${prefix}${c(\"dim\", \">\")} Searching ${c(\"cyan\", get(\"pattern\"))} in ${relPath(get(\"path\") || \".\", cwd)}`,\n ];\n }\n if (name === \"Bash\") {\n return [`${prefix}${c(\"dim\", \"$\")} ${shortText(get(\"command\"), 160)}`];\n }\n if (name === \"Edit\") {\n const out = [`${prefix}${c(\"mag\", \"~\")} Editing ${relPath(get(\"file_path\"), cwd)}`];\n for (const line of unifiedDiffLines(get(\"old_string\"), get(\"new_string\"))) {\n if (line.startsWith(\"-\")) out.push(`${prefix} ${c(\"red\", line)}`);\n else if (line.startsWith(\"+\")) out.push(`${prefix} ${c(\"green\", line)}`);\n else out.push(`${prefix} ${c(\"dim\", line)}`);\n }\n return out;\n }\n if (name === \"Write\") {\n const n = get(\"content\").split(\"\\n\").length;\n return [`${prefix}${c(\"green\", \"+\")} Writing ${relPath(get(\"file_path\"), cwd)} (${n} lines)`];\n }\n if (name === \"WebSearch\" || name === \"WebFetch\") {\n return [`${prefix}${c(\"dim\", \">\")} ${name} ${shortText(get(\"query\") || get(\"url\"), 100)}`];\n }\n return [`${prefix}${c(\"dim\", \">\")} ${name} ${shortText(JSON.stringify(i), 120)}`];\n}\n\n/** Render one stream-json event; `tools` maps tool_use_id \u2192 tool name. */\nexport function renderEvent(\n c: Paint,\n prefix: string,\n e: StreamEventLike,\n cwd: string,\n tools: Record<string, string>,\n gutter?: { first: string; cont: string } | null\n): string[] {\n const out: string[] = [];\n if (e.type === \"system\" && e.subtype === \"init\") {\n out.push(\n `${prefix}${c(\"dim\", `worker started \u00B7 model ${e.model ?? \"?\"} \u00B7 cwd ${basename(e.cwd || cwd || \"\")}`)}`\n );\n } else if (e.type === \"operator\") {\n // split per line so the gutter continuation pads wrapped text\n for (const ln of String(e.text ?? \"\").split(\"\\n\")) {\n out.push(`${prefix}${c(\"cyan\", \"\u00BB \" + ln)}`);\n }\n } else if (e.type === \"assistant\") {\n for (const b of e.message?.content ?? []) {\n if (b.type === \"text\" && (b.text ?? \"\").trim()) {\n for (const ln of (b.text ?? \"\").trim().split(\"\\n\")) out.push(`${prefix}${ln}`);\n } else if (b.type === \"tool_use\") {\n out.push(...renderToolUse(c, prefix, b.name ?? \"?\", b.input, cwd));\n }\n }\n } else if (e.type === \"user\") {\n for (const b of e.message?.content ?? []) {\n if (b.type !== \"tool_result\") continue;\n let content: unknown = (b as { content?: unknown }).content ?? \"\";\n if (Array.isArray(content)) {\n content = content\n .map((x) => (typeof x === \"object\" && x !== null && \"text\" in x ? String((x as { text?: string }).text ?? \"\") : \"\"))\n .join(\"\\n\");\n }\n const text = String(content);\n const isError = (b as { is_error?: boolean }).is_error === true;\n if (!isError && tools[(b as { tool_use_id?: string }).tool_use_id ?? \"\"] === \"Read\") {\n // keep Read results as the tool returned them (`<lineno>\\t<code>`),\n // only their count is summarised\n const lines = text.split(\"\\n\");\n for (const ln of lines.slice(0, 3)) out.push(`${prefix}${c(\"dim\", ln)}`);\n if (lines.length > 3) {\n out.push(`${prefix}${c(\"dim\", ` \u2026 ${lines.length} lines`)}`);\n }\n } else if (isError) {\n out.push(`${prefix} ${c(\"red\", \"! \" + shortText(text, 200))}`);\n } else if (text.trim()) {\n out.push(`${prefix} ${c(\"dim\", shortText(text, 120))}`);\n }\n }\n } else if (e.type === \"result\") {\n const ok = !e.is_error;\n const mark = ok ? c(\"green\", \"\u2713 done\") : c(\"red\", \"\u2717 failed\");\n out.push(`${prefix}${mark} \u00B7 ${e.num_turns ?? \"?\"} turns \u00B7 ${Math.floor((e.duration_ms ?? 0) / 1000)}s`);\n // a contract-compliant final message renders as the compact report block\n const report = parseWorkerReport(String(e.result ?? \"\"));\n if (report) {\n out.push(...renderReport(c, prefix, report, cwd));\n } else {\n for (const ln of String(e.result ?? \"\").trim().split(\"\\n\")) {\n out.push(`${prefix} ${ln}`);\n }\n }\n }\n if (!gutter) return out;\n return out.map((ln, i) => (i === 0 ? gutter.first : gutter.cont) + ln);\n}\n\n/** Transcript header: id, provider, label, session name, project dir. */\nexport function headerLine(\n c: Paint,\n s: { id: string; label: string; cwd: string; provider?: string; session?: { name?: string } | null }\n): string {\n const bits = [s.provider ? `[${s.provider}]` : \"\", sessionTag(s)].filter(Boolean).join(\" \");\n const sep = bits ? ` ${bits}` : \"\";\n return c(\"bold\", `\u2501\u2501 ${s.id}${sep} ${s.label} (${basename(s.cwd)})`);\n}\n\n/** The chain label behind a stage label: strip the trailing \" \u00B7 <stage>\". */\nfunction chainLabelOf(stages: WorkerStatus[]): string {\n const first = stages[0];\n if (!first) return \"\";\n const suffix = first.stage ? ` \u00B7 ${first.stage}` : \"\";\n return first.label.endsWith(suffix) && suffix\n ? first.label.slice(0, first.label.length - suffix.length)\n : first.label;\n}\n\n/**\n * The ps table (RUNNING + FINISHED last 8). Chain stages carry `parent` and\n * render as a tree under one `chain <id>` header; plain workers render as\n * before.\n */\nexport function renderTable(\n c: Paint,\n statuses: WorkerStatus[],\n scopeLabel: string,\n now: Date = new Date()\n): string {\n const running: WorkerStatus[] = [];\n const done: WorkerStatus[] = [];\n for (const s of statuses) {\n if (s.state === \"running\" && alive(s.pid)) running.push(s);\n else {\n if (s.state === \"running\") s.state = \"lost\";\n done.push(s);\n }\n }\n // group stages by their chain id, keeping first-seen order\n const group = (list: WorkerStatus[]): { s: WorkerStatus; chain: string | null }[] => {\n const out: { s: WorkerStatus; chain: string | null }[] = [];\n for (const s of list) {\n out.push({ s, chain: s.parent ?? null });\n }\n return out;\n };\n const treeLine = (line: string, chain: string | null, last: boolean): string => {\n if (chain === null) return line;\n const mark = last ? \"\u2514\" : \"\u251C\";\n const bar = last ? \" \" : \"\u2502\";\n return line.startsWith(\" \")\n ? ` ${bar} ${line.slice(6)}`\n : ` ${mark} ${line.slice(2)}`;\n };\n\n const clock = `${String(now.getHours()).padStart(2, \"0\")}:${String(now.getMinutes()).padStart(2, \"0\")}:${String(now.getSeconds()).padStart(2, \"0\")}`;\n const lines: string[] = [c(\"bold\", `Workers ${clock}`), \"\"];\n lines.push(c(\"bold\", `RUNNING (${running.length})`));\n if (!running.length) lines.push(\" none\");\n const runEntries = group(running);\n for (let i = 0; i < runEntries.length; i++) {\n const { s, chain } = runEntries[i];\n if (chain) {\n const prev = runEntries[i - 1];\n if (!prev || prev.chain !== chain) {\n const stages = runEntries.filter((e) => e.chain === chain).map((e) => e.s);\n lines.push(` ${c(\"bold\", `chain ${chain}`)} ${chainLabelOf(stages)}`);\n }\n }\n const last = !chain || !runEntries[i + 1] || runEntries[i + 1].chain !== chain;\n const meter = contextMeter(c, s, 60);\n lines.push(\n treeLine(\n ` ${c(\"cyan\", s.id)} [${s.provider}] ${ageOf(s.started, now).padStart(4)} old turns ${String(s.turns).padStart(2)} tools ${String(s.tools).padStart(2)} ${basename(s.cwd)}${meter ? \" \" + meter : \"\"}`,\n chain,\n last\n )\n );\n lines.push(treeLine(` task: ${s.label}`, chain, last));\n lines.push(\n treeLine(` now: ${c(\"yellow\", s.last)} (${ageOf(s.updated, now)} ago)`, chain, last)\n );\n }\n lines.push(\"\");\n lines.push(c(\"bold\", \"FINISHED (last 8)\"));\n const doneEntries = group(done.slice(-8));\n for (let i = 0; i < doneEntries.length; i++) {\n const { s, chain } = doneEntries[i];\n if (chain) {\n const prev = doneEntries[i - 1];\n if (!prev || prev.chain !== chain) {\n const stages = doneEntries.filter((e) => e.chain === chain).map((e) => e.s);\n lines.push(` ${c(\"bold\", `chain ${chain}`)} ${chainLabelOf(stages)}`);\n }\n }\n const last = !chain || !doneEntries[i + 1] || doneEntries[i + 1].chain !== chain;\n const col = s.state === \"done\" ? \"green\" : \"red\";\n const tag = sessionTag(s);\n lines.push(\n treeLine(\n ` ${s.id} [${s.provider}]${tag ? \" \" + tag : \"\"} ${c(col, s.state.padEnd(6))} rc=${s.rc} ${String(s.secs ?? \"?\").padStart(4)}s turns ${String(s.turns).padStart(2)} tools ${String(s.tools).padStart(2)} ${basename(s.cwd)} ${s.label}`,\n chain,\n last\n )\n );\n }\n lines.push(\"\");\n lines.push(c(\"dim\", \"worker follow live transcript of running workers\"));\n lines.push(c(\"dim\", \"worker <id> replay one worker\"));\n lines.push(c(\"dim\", scopeLabel));\n return lines.join(\"\\n\");\n}\n\n/** One-line status-bar summary (same shape glm-ps --status printed). */\nexport function renderStatusLine(\n mine: WorkerStatus[],\n now: Date = new Date(),\n c: Paint = makeColor(true)\n): string {\n if (!mine.length) return \"\";\n const running = mine.filter((s) => s.state === \"running\" && alive(s.pid));\n const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, \"0\")}-${String(now.getDate()).padStart(2, \"0\")}`;\n const doneToday = mine.filter((s) => s.state !== \"running\" && s.started.startsWith(today));\n const ok = doneToday.filter((s) => s.state === \"done\").length;\n const bad = doneToday.length - ok;\n const providers = new Set(running.map((s) => s.provider));\n const providerTag = providers.size === 1 ? [...providers][0] : \"workers\";\n let head = `${providerTag} \u25B6${running.length}`;\n const parts = running.slice(0, 3).map((s) => {\n // context load joins the summary once it passes 60 % (yellow >70, red >85)\n const meter = contextMeter(c, s, 60);\n return (\n `${s.id.slice(-4)} ${s.label.slice(0, 26)} ${ageOf(s.started, now)} \u00B7 ${s.last.slice(0, 30)}` +\n (meter ? ` ${meter}` : \"\")\n );\n });\n if (parts.length) head += \" \" + parts.join(\" | \");\n if (doneToday.length) head += ` \u2713${ok} \u2717${bad} today`;\n return head;\n}\n", "/**\n * viewer.ts \u2014 ps / follow / replay / status line over the worker logDir.\n *\n * Scoping: workers launched from this terminal's AIBroker session (or, as the\n * fallback, its iTerm tab) unless --all or an explicit worker id is given.\n * Outside iTerm, everything degrades to \"all workers\" \u2014 the Python behaviour.\n *\n * Transcripts render with a `HH:MM:SS \u2502 ` gutter (2g): dim, taken from the\n * `_ts` stamp on every mirrored event (local wall clock \u2014 stamps carry a local\n * offset and old UTC stamps are converted), the worker tag in front when\n * several run at once, a date separator when the day changes, and \u2014 on a TTY \u2014\n * a liveness line (`\u22EF 12s \u00B7 run tests before the fix \u00B7 $ bun run test`) that\n * is rewritten in place between events. Attaching to a worker that is already\n * running first replays its last events (backfill), then continues live.\n *\n * Following one worker turns the pane into a small chat (see chatui.ts): the\n * transcript scrolls in a region that ends two rows above the bottom, the\n * prompt row (`\u203A `, readline editing) and the ticker row stay fixed, and every\n * submitted line is said to the worker while it runs and resumes it (same\n * Claude session) once it has finished. Lines the pane wraps itself keep the\n * `\u2502` bar on continuation rows, so no content ever lands left of the bar.\n * Non-TTY output keeps the plain scrolling behaviour.\n */\n\nimport { existsSync, openSync, readSync, closeSync, readFileSync } from \"node:fs\";\nimport { createInterface } from \"node:readline\";\nimport { spawn, type SpawnOptions } from \"node:child_process\";\nimport { eventsPath } from \"./paths.js\";\nimport { alive, loadStatuses, type WorkerStatus } from \"./status.js\";\nimport { currentTabKey, resolveSession, workerInScope } from \"./scope.js\";\nimport { sayToWorker } from \"./operator.js\";\nimport {\n CHAT_HELP,\n CHAT_HINT,\n CHAT_PROMPT,\n chatEnter,\n chatInsertLine,\n chatLeave,\n chatPromptRow,\n chatScrollRegion,\n chatTickerRow,\n holdAutoExit,\n parseChatLine,\n wrapText,\n} from \"./chatui.js\";\nimport {\n blankBetween,\n contextMeter,\n dayOf,\n gutterFor,\n headerLine,\n intentOf,\n makeColor,\n renderEvent,\n renderStatusLine,\n renderTable,\n tickerText,\n tickerTool,\n type Gutter,\n type Paint,\n type StreamEventLike,\n} from \"./render.js\";\n\n/** How many existing events a fresh follow pane replays before going live. */\nexport const BACKFILL_EVENTS = 200;\n\n// ---------------------------------------------------------------------------\n// ps\n// ---------------------------------------------------------------------------\n\nexport function psOutput(\n logDir: string,\n showAll: boolean,\n env: NodeJS.ProcessEnv = process.env,\n color = process.stdout.isTTY === true\n): string {\n const c = makeColor(color);\n const term = env.ITERM_SESSION_ID ?? \"\";\n const statuses = loadStatuses(logDir);\n const scoped = showAll || !term ? statuses : statuses.filter((s) => workerInScope(s, term));\n const scopeLabel =\n showAll || !term\n ? \"scope: all workers\"\n : resolveSession(term)\n ? `scope: session ${resolveSession(term)!.name}`\n : `scope: tab ${currentTabKey(env)} (this iTerm tab)`;\n return renderTable(c, scoped, scopeLabel);\n}\n\n// ---------------------------------------------------------------------------\n// one event \u2192 rendered lines (shared by replay, backfill and the live tail)\n// ---------------------------------------------------------------------------\n\n/** What applyEvent() carries between the events of one transcript. */\nexport interface FollowState {\n /** day separator already printed (\"\" before the first stamped event). */\n lastDay: string;\n /** tool_use id \u2192 tool name (Edit previews and Read trimming need it). */\n tools: Record<string, string>;\n /** the worker's last stated intent \u2014 its last assistant text, \u226460 chars. */\n intent: string;\n /** the ticker's tool part of the last tool_use (\"$ bun run test\"). */\n tool: string;\n /** the last event seen, for the blank line between turns. */\n prev: StreamEventLike | null;\n}\n\nexport function initialFollowState(intent = \"waiting for first event\"): FollowState {\n return { lastDay: \"\", tools: {}, intent, tool: \"\", prev: null };\n}\n\n/** One applied event: what to print, and how the ticker changes. */\nexport interface FollowStep {\n /** rendered lines (\"\" among them marks the blank line between turns). */\n lines: string[];\n /** day separator to print first, when the stamp's local day changed. */\n day: string | null;\n /** the event produced visible output \u2014 the ticker clock restarts. */\n activity: boolean;\n state: FollowState;\n}\n\n/**\n * Gutter the rendered body of one event, wrapping when the pane width is\n * known. Unwrapped (null `wrapWidth`, e.g. piped output): the first row gets\n * the stamped gutter, later rows of the event blanks \u2014 exactly the pre-chat\n * rendering. Wrapped: every row is folded at `wrapWidth` columns and each\n * continuation row carries the blank gutter with the `\u2502` bar, so the bar runs\n * unbroken down the pane and no content ever lands left of it.\n */\nexport function gutterBody(\n body: string[],\n gutter: Gutter | null,\n wrapWidth: number | null\n): string[] {\n if (!gutter) return body;\n if (wrapWidth === null || wrapWidth <= gutter.width) {\n return body.map((ln, i) => (i === 0 ? gutter.first : gutter.cont) + ln);\n }\n const out: string[] = [];\n for (const ln of body) {\n for (const piece of wrapText(ln, wrapWidth - gutter.width)) {\n out.push((out.length === 0 ? gutter.first : gutter.barCont) + piece);\n }\n }\n return out;\n}\n\n/**\n * Render one event and advance the follow state. Everything the viewer shows\n * between events of one worker comes from here: replay, the backfill on\n * attach and the live tail all use it, so they space identically \u2014 events\n * back to back, one blank line between turns. `activity` is false for events\n * that render nothing (stream noise, empty tool results): they leave the\n * ticker's \"since last event\" clock running. `wrapWidth` (the pane's column\n * count, re-read on resize) makes the viewer wrap rows itself; null keeps\n * the terminal's own wrapping.\n */\nexport function applyEvent(\n c: Paint,\n s: FollowState,\n e: StreamEventLike,\n cwd: string,\n tag?: string,\n offMin?: number,\n wrapWidth?: number | null\n): FollowStep {\n const tools = { ...s.tools };\n if (e.type === \"assistant\") {\n for (const b of e.message?.content ?? []) {\n if (b.type === \"tool_use\" && b.id) tools[b.id] = b.name ?? \"?\";\n }\n }\n const day = typeof e._ts === \"string\" ? (dayOf(e._ts, offMin) ?? rawDay(e._ts)) : \"\";\n const state: FollowState = {\n lastDay: day && day !== s.lastDay ? day : s.lastDay,\n tools,\n intent: s.intent,\n tool: s.tool,\n prev: e,\n };\n if (e.type === \"assistant\") {\n for (const b of e.message?.content ?? []) {\n if (b.type === \"text\" && (b.text ?? \"\").trim()) state.intent = intentOf(b.text ?? \"\");\n else if (b.type === \"tool_use\") state.tool = tickerTool(b.name ?? \"?\", b.input);\n }\n }\n const gutter = gutterFor(c, e, tag, offMin);\n const prefix = \"\";\n const body = gutterBody(renderEvent(c, prefix, e, cwd, tools), gutter, wrapWidth ?? null);\n const lines = blankBetween(s.prev, e) ? [\"\", ...body] : body;\n return {\n lines,\n day: day && day !== s.lastDay ? day : null,\n activity: lines.some((ln) => ln !== \"\"),\n state,\n };\n}\n\n/** `YYYY-MM-DD` straight out of an unparsable stamp, \"\" when it has none. */\nfunction rawDay(ts: string): string {\n return /^\\d{4}-\\d{2}-\\d{2}/.test(ts) ? ts.slice(0, 10) : \"\";\n}\n\n/**\n * The events a fresh follow replays before going live: the last `cap`\n * non-empty log lines, oldest first.\n */\nexport function backfillLines(raw: string, cap = BACKFILL_EVENTS): string[] {\n return raw.split(\"\\n\").filter((l) => l.trim()).slice(-cap);\n}\n\n// ---------------------------------------------------------------------------\n// replay\n// ---------------------------------------------------------------------------\n\n/** The rendered transcript of one worker, from the start, as one string. */\nexport function replayOutput(\n logDir: string,\n wid: string,\n color = process.stdout.isTTY === true,\n tailLines?: number\n): string {\n const path = eventsPath(logDir, wid);\n if (!existsSync(path)) {\n throw new Error(`no event log for ${wid}`);\n }\n const c = makeColor(color);\n const st =\n loadStatuses(logDir).find((s) => s.id === wid) ??\n ({ id: wid, label: \"\", cwd: \"\", provider: \"\" } as WorkerStatus);\n const wrapWidth = typeof process.stdout.columns === \"number\" ? process.stdout.columns : null;\n const out: string[] = [headerLine(c, st)];\n const raw = readFileSync(path, \"utf8\");\n const lines = tailLines !== undefined ? raw.split(\"\\n\").slice(-tailLines) : raw.split(\"\\n\");\n let state = initialFollowState(\"\");\n for (const line of lines) {\n if (!line.trim()) continue;\n let e: StreamEventLike;\n try {\n e = JSON.parse(line) as StreamEventLike;\n } catch {\n continue;\n }\n const step = applyEvent(c, state, e, st.cwd ?? \"\", undefined, undefined, wrapWidth);\n if (step.day) out.push(c(\"dim\", `\u2500\u2500 ${step.day} \u2500\u2500`));\n out.push(...step.lines);\n state = step.state;\n }\n return out.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// follow\n// ---------------------------------------------------------------------------\n\ninterface FollowHandle {\n fd: number;\n buf: string;\n}\n\n/**\n * The follow exit decision for one worker: it is over once its result event\n * was rendered, or once its status left \"running\" while its pid is gone \u2014 a\n * worker killed without writing a result still ends. A status never read\n * (undefined) means \"not over\": follow keeps waiting for the first event.\n */\nexport function workerEnded(\n resultRendered: boolean,\n state: WorkerStatus[\"state\"] | undefined,\n pidAlive: boolean\n): boolean {\n return resultRendered || (state !== undefined && state !== \"running\" && !pidAlive);\n}\n\n// ---------------------------------------------------------------------------\n// operator input (2i): this pane's stdin drives say / resume\n// ---------------------------------------------------------------------------\n\n/** What makeOperatorInput() needs from the follow around it. */\nexport interface OperatorInputDeps {\n /** the worker typed lines go to right now (null: none, lines are ignored). */\n target: () => string | null;\n /** forwards one message to a running worker. */\n say: (id: string, text: string) => Promise<string>;\n /** whether a status file exists for the id (say failed \u2192 resume, or note). */\n workerKnown: (id: string) => boolean;\n /** continues a finished worker (same Claude session). */\n resume: (text: string, id: string) => void;\n /** one line of feedback in the pane. */\n note: (s: string) => void;\n paint: Paint;\n /** chat mode only: the echoed line replaces the \"\u00BB sent\" note. */\n sent?: (id: string) => void;\n}\n\n/**\n * One typed stdin line \u2192 say (worker running) or resume (worker finished):\n * the operator channel of a `follow <id>` pane. Trimmed; empty lines and a\n * missing target are ignored.\n */\nexport function makeOperatorInput(d: OperatorInputDeps): (raw: string) => void {\n return (raw: string) => {\n const text = raw.trim();\n if (!text) return;\n const id = d.target();\n if (id === null) return;\n d.say(id, text).then(\n () => (d.sent ? d.sent(id) : d.note(d.paint(\"dim\", `\u00BB sent to ${id}`))),\n (e: Error) => {\n if (d.workerKnown(id)) d.resume(text, id);\n else d.note(d.paint(\"red\", `\u00BB ${e.message}`));\n }\n );\n };\n}\n\n/** What followWorkers writes to (process.stdout, or a fake in tests). */\nexport interface FollowStream {\n write(s: string): boolean;\n isTTY?: boolean;\n columns?: number;\n rows?: number;\n on?(event: \"resize\", fn: () => void): unknown;\n removeListener?(event: \"resize\", fn: () => void): unknown;\n}\n\n/** The child of a `pai worker resume` spawn (tests inject a fake). */\nexport interface ResumeChild {\n stdout?: { on(event: \"data\", cb: (chunk: Buffer) => void): unknown };\n on(event: \"close\", cb: (code: number | null) => void): unknown;\n}\n\n/** Test seams for followWorkers: the streams, the resume spawn, the prompt. */\nexport interface FollowIO {\n stdin?: NodeJS.ReadableStream;\n stdout?: FollowStream;\n /** replaces the `pai worker resume` spawn (tests record instead of run). */\n spawnResume?: (id: string, text: string) => ResumeChild;\n /** current unsent prompt text (tests force a draft to hold the countdown). */\n promptLine?: () => string;\n}\n\n/**\n * Tail one worker (target) or the running workers of this scope, live.\n * Workers whose event log already exists are first replayed (last\n * BACKFILL_EVENTS events), then tailed. Auto-exit: with a target, wait\n * `autoExit` seconds after its end; without one, exit once no worker in\n * scope has run for that many seconds in a row, never within the first 30 s.\n *\n * With a target on a TTY the pane becomes a chat (chatui.ts): transcript in\n * a scroll region, fixed prompt and ticker rows, submitted lines said to the\n * worker (or resuming it after it finished) and echoed as `\u00BB` rows. A draft\n * in the prompt holds the auto-exit countdown. Non-TTY output keeps the\n * plain scrolling behaviour; FORCE_TTY=1 emits the chat layout over a pipe.\n */\nexport async function followWorkers(\n logDir: string,\n target: string | null,\n showAll: boolean,\n autoExit: number,\n env: NodeJS.ProcessEnv = process.env,\n color = process.stdout.isTTY === true,\n io?: FollowIO\n): Promise<void> {\n const c = makeColor(color);\n const out_ = io?.stdout ?? process.stdout;\n const in_ = io?.stdin ?? process.stdin;\n // FORCE_TTY=1: the TTY layout over a pipe (tests, recorded panes)\n const tty = out_.isTTY === true || env.FORCE_TTY === \"1\";\n const term = env.ITERM_SESSION_ID ?? \"\";\n const scopeTab = target || showAll ? \"\" : currentTabKey(env);\n const handles = new Map<string, FollowHandle>();\n const seenHeader = new Set<string>();\n const finished = new Set<string>();\n const states = new Map<string, FollowState>();\n const started = Date.now();\n let idleSince: number | null = null;\n let aborted = false;\n // liveness state: rewritten in place between events, TTY only\n let lastEventAt = Date.now();\n let meterStatus: WorkerStatus | null = null;\n const onInt = () => {\n aborted = true;\n };\n process.once(\"SIGINT\", onInt);\n\n // --- the chat layout (target + TTY): transcript region + two fixed rows\n const chat = tty && target !== null;\n let rows = out_.rows ?? 24;\n const columns = (): number | null => (typeof out_.columns === \"number\" ? out_.columns : null);\n let fill = 0; // transcript rows filled since the region was (re)set\n const regionRows = () => Math.max(1, rows - 2);\n /** Every line the pane shows goes through here: plain newline, or a row\n * inserted above the fixed prompt/ticker rows (chatui.chatInsertLine). */\n const out = (line: string) => {\n if (!chat) {\n out_.write(line + \"\\n\");\n return;\n }\n const r = chatInsertLine(line, fill, regionRows());\n out_.write(r.seq);\n fill = r.fill;\n };\n\n // The ticker redraws its line in place: in the chat layout that is its own\n // bottom row (save-cursor, draw, restore-cursor); the plain mode writes\n // CR + erase-to-end-of-line, never a newline. The control bytes live in\n // their own string literals \u2014 bundling them onto a template literal makes\n // the bundler fold them in as raw chars, and a raw CR inside a template\n // literal is normalised to LF by the language, which is how the ticker\n // once scrolled a blank line per tick.\n const eraseLiveness = () => {\n if (chat) return;\n if (tty) out_.write(\"\\r\\x1b[K\");\n };\n let ticker = initialFollowState();\n const writeLiveness = () => {\n if (!tty) return;\n const secs = Math.max(0, Math.floor((Date.now() - lastEventAt) / 1000));\n const meter = meterStatus ? contextMeter(c, meterStatus) : null;\n const text = tickerText(secs, ticker.intent, ticker.tool, meter);\n if (chat) out_.write(chatTickerRow(text, rows));\n else {\n out_.write(\"\\r\\x1b[K\");\n out_.write(text);\n }\n };\n\n const runningIds = (): string[] =>\n loadStatuses(logDir)\n .filter(\n (s) =>\n s.state === \"running\" &&\n alive(s.pid) &&\n (target !== null || showAll || (scopeTab ? workerInScope(s, term) : true))\n )\n .map((s) => s.id);\n\n // --- one rendered event, shared by the backfill and the live tail\n const emitEvent = (e: StreamEventLike, wid: string, st: WorkerStatus, multi: boolean) => {\n if (e.type === \"operator\" && chat && suppressMirror(String(e.text ?? \"\"))) return;\n if (!seenHeader.has(wid)) {\n eraseLiveness();\n out(headerLine(c, st));\n seenHeader.add(wid);\n }\n const state = states.get(wid) ?? initialFollowState();\n const step = applyEvent(\n c,\n state,\n e,\n st.cwd ?? \"\",\n multi ? c(\"cyan\", wid.slice(-4)) : undefined,\n undefined,\n columns()\n );\n if (step.day) {\n out(c(\"dim\", `\u2500\u2500 ${step.day} \u2500\u2500`));\n }\n states.set(wid, step.state);\n if (wid === target) ticker = step.state;\n eraseLiveness();\n for (const ln of step.lines) {\n out(ln);\n }\n if (step.activity) lastEventAt = Date.now();\n if (e.type === \"result\") {\n meterStatus = st;\n finished.add(wid);\n } else if (wid === target || target === null) {\n meterStatus = st;\n }\n };\n\n // --- attach: replay what is already in the log, then tail from its end\n const attachHandle = (wid: string, path: string, st: WorkerStatus, multi: boolean): FollowHandle => {\n const handle = { fd: openSync(path, \"r\"), buf: \"\" };\n try {\n const existing = readFileSync(path, \"utf8\");\n if (existing.trim()) {\n for (const line of backfillLines(existing)) {\n let e: StreamEventLike;\n try {\n e = JSON.parse(line) as StreamEventLike;\n } catch {\n continue;\n }\n emitEvent(e, wid, st, multi);\n }\n } else {\n // no event yet: show the header now, not at the first event\n if (!seenHeader.has(wid)) {\n out(headerLine(c, st));\n seenHeader.add(wid);\n }\n }\n // the backfill already rendered the file; the live tail starts at EOF\n const sink = Buffer.alloc(65536);\n for (;;) {\n let n: number;\n try {\n n = readSync(handle.fd, sink, 0, sink.length, null);\n } catch {\n break;\n }\n if (n <= 0) break;\n }\n } catch {\n // unreadable log: tail from wherever the fd happens to be\n }\n return handle;\n };\n\n // --- say / resume from this pane's stdin\n const noteLine = (s: string) => {\n eraseLiveness();\n out(s);\n };\n // SpawnOptions (not the stdio-tuple overload): we only read stdout and\n // want the plain ChildProcess shape; tests inject a fake that records\n const spawnResume =\n io?.spawnResume ??\n ((id: string, text: string): ResumeChild =>\n spawn(\"pai\", [\"worker\", \"resume\", id, text, \"--print-id\", \"--no-pane\"], {\n stdio: [\"ignore\", \"pipe\", \"inherit\"],\n } as SpawnOptions) as unknown as ResumeChild);\n const resumeTarget = (text: string, id: string) => {\n noteLine(c(\"dim\", `\u00BB resuming ${id} \u2026`));\n const child = spawnResume(id, text);\n let idOut = \"\";\n child.stdout?.on(\"data\", (chunk: Buffer) => {\n idOut += chunk.toString(\"utf8\");\n });\n child.on(\"close\", (rc: number | null) => {\n const newId = idOut.trim().split(\"\\n\").pop() ?? \"\";\n if (rc === 0 && /^\\d{8}-\\d{6}-\\d+$/.test(newId)) {\n noteLine(c(\"dim\", `\u00BB resumed as ${newId}`));\n target = newId;\n finished.delete(newId);\n seenHeader.delete(newId);\n states.delete(newId);\n ticker = initialFollowState(\"resumed\");\n lastEventAt = Date.now();\n } else {\n noteLine(c(\"red\", `\u00BB resume failed (rc=${rc})`));\n }\n });\n };\n const handleOperatorLine = makeOperatorInput({\n target: () => target,\n say: (id, text) => sayToWorker(logDir, id, text),\n workerKnown: (id) => loadStatuses(logDir).some((s) => s.id === id),\n resume: resumeTarget,\n note: noteLine,\n paint: c,\n ...(chat ? { sent: () => undefined } : {}), // the echo replaces the note\n });\n\n // --- the chat line: readline editing on the prompt row\n // the readline interface keeps stdin flowing \u2014 without a close, the process\n // outlives follow itself (a pane then never closes, however long ago the\n // worker ended), so it is closed in the finally block below\n const terminalIn = (in_ as { isTTY?: boolean }).isTTY === true;\n let hintUp = true;\n let rlIn: ReturnType<typeof createInterface> | null = null;\n let onResize: (() => void) | null = null;\n // an echoed line comes back as a mirrored operator event within moments \u2014\n // remember what was echoed and swallow the twin, so the pane shows what\n // was typed exactly once\n const echoed = new Map<string, { n: number; until: number }>();\n const suppressMirror = (text: string): boolean => {\n const g = echoed.get(text);\n if (!g || Date.now() > g.until) return false;\n g.n -= 1;\n if (g.n <= 0) echoed.delete(text);\n return true;\n };\n const drawPrompt = () => {\n if (!chat || terminalIn) return;\n out_.write(chatPromptRow(rows, CHAT_PROMPT, hintUp ? c(\"dim\", CHAT_HINT) : undefined));\n };\n if (chat) {\n const echoOperator = (text: string) => {\n const id = target;\n if (id === null) return;\n echoed.set(text, { n: 1, until: Date.now() + 10_000 });\n const st = states.get(id) ?? initialFollowState();\n const step = applyEvent(\n c,\n st,\n { type: \"operator\", _ts: new Date().toISOString(), text },\n \"\",\n undefined,\n undefined,\n columns()\n );\n if (step.day) out(c(\"dim\", `\u2500\u2500 ${step.day} \u2500\u2500`));\n for (const ln of step.lines) out(ln);\n states.set(id, step.state);\n };\n const handleChatLine = (raw: string) => {\n hintUp = false;\n const act = parseChatLine(raw);\n switch (act.kind) {\n case \"message\":\n if (!act.text) break;\n echoOperator(act.text);\n handleOperatorLine(act.text);\n break;\n case \"resume\":\n if (!act.text) {\n out(c(\"dim\", \"usage: /resume <text>\"));\n break;\n }\n echoOperator(act.text);\n if (target !== null) resumeTarget(act.text, target);\n break;\n case \"help\":\n for (const ln of CHAT_HELP) out(c(\"dim\", ln));\n break;\n case \"status\": {\n const s =\n target !== null ? loadStatuses(logDir).find((x) => x.id === target) : undefined;\n out(c(\"dim\", s ? `${s.id} \u00B7 ${s.state} \u00B7 ${s.last}` : `${target ?? \"?\"} \u00B7 no status`));\n break;\n }\n case \"quit\":\n aborted = true;\n break;\n }\n drawPrompt(); // on a pipe readline does not repaint the prompt itself\n };\n rlIn = createInterface({\n input: in_,\n output: out_ as unknown as NodeJS.WriteStream,\n terminal: terminalIn,\n });\n rlIn.on(\"line\", handleChatLine);\n // Ctrl-C: an empty prompt leaves, a draft clears; Ctrl-D (close) leaves\n rlIn.on(\"SIGINT\", () => {\n if ((rlIn?.line ?? \"\").trim() === \"\") aborted = true;\n else rlIn?.write(null, { ctrl: true, name: \"u\" });\n });\n rlIn.on(\"close\", () => {\n aborted = true;\n });\n out_.write(chatEnter(rows));\n if (terminalIn) {\n rlIn.setPrompt(CHAT_PROMPT);\n rlIn.prompt();\n out_.write(c(\"dim\", CHAT_HINT));\n } else {\n drawPrompt();\n }\n // resize: re-read the geometry, rebuild the region, fill it afresh\n onResize = () => {\n if (typeof out_.rows === \"number\") rows = out_.rows;\n fill = 0;\n out_.write(chatScrollRegion(rows));\n drawPrompt();\n };\n out_.on?.(\"resize\", onResize);\n } else if (target !== null && (in_ as { isTTY?: boolean }).isTTY) {\n // plain operator channel: TTY stdin, non-TTY stdout\n rlIn = createInterface({ input: in_ });\n rlIn.on(\"line\", handleOperatorLine);\n }\n\n /** The prompt's unsent text \u2014 a draft holds the auto-exit countdown. */\n const promptText = () => (io?.promptLine ? io.promptLine() : (rlIn?.line ?? \"\"));\n\n try {\n for (;;) {\n if (aborted) return;\n const statuses = new Map(loadStatuses(logDir).map((s) => [s.id, s]));\n const wanted = target ? [target] : runningIds();\n for (const wid of wanted) {\n if (handles.has(wid) || finished.has(wid)) continue;\n const path = eventsPath(logDir, wid);\n const st = statuses.get(wid) ?? ({ id: wid, label: \"\", cwd: \"\", provider: \"\" } as WorkerStatus);\n if (existsSync(path)) {\n const multi = target === null || handles.size > 0;\n handles.set(wid, attachHandle(wid, path, st, multi));\n states.set(wid, states.get(wid) ?? initialFollowState());\n } else if (!seenHeader.has(wid)) {\n // worker just started, no event yet: name it instead of a blank pane\n out(headerLine(c, st));\n seenHeader.add(wid);\n }\n }\n const multi = handles.size > 1 || target === null;\n let progressed = false;\n\n for (const [wid, h] of [...handles.entries()]) {\n const st = statuses.get(wid) ?? ({ id: wid, label: \"\", cwd: \"\", provider: \"\" } as WorkerStatus);\n // read everything appended since the last poll (fd position advances)\n const buffer = Buffer.alloc(65536);\n for (;;) {\n let n: number;\n try {\n n = readSync(h.fd, buffer, 0, buffer.length, null);\n } catch {\n n = 0;\n }\n if (n <= 0) break;\n h.buf += buffer.toString(\"utf8\", 0, n);\n }\n const lines = h.buf.split(\"\\n\");\n h.buf = lines.pop() ?? \"\";\n for (const line of lines) {\n if (!line.trim()) continue;\n progressed = true;\n let e: StreamEventLike;\n try {\n e = JSON.parse(line) as StreamEventLike;\n } catch {\n continue;\n }\n emitEvent(e, wid, st, multi);\n }\n const ended = workerEnded(finished.has(wid), st.state, alive(st.pid));\n if (ended) {\n if (!finished.has(wid)) {\n eraseLiveness();\n out(\n `${multi ? c(\"cyan\", wid.slice(-4)) + c(\"dim\", \" \u2503 \") : \" \"}${c(\"red\", \"\u2717 \" + (st.state || \"ended\"))} \u00B7 ${st.last ?? \"\"}`\n );\n finished.add(wid);\n }\n closeSync(h.fd);\n handles.delete(wid);\n }\n }\n\n const lingerOn = target; // resume swaps `target` under us (see above)\n if (lingerOn !== null && finished.has(lingerOn)) {\n if (autoExit) {\n // a draft in the prompt holds the countdown: the operator may be\n // about to say or resume something\n if (holdAutoExit(promptText())) {\n writeLiveness();\n await sleep(250);\n continue;\n }\n const until = Date.now() + autoExit * 1000;\n while (\n Date.now() < until &&\n !aborted &&\n target === lingerOn &&\n !holdAutoExit(promptText())\n ) {\n writeLiveness();\n await sleep(250);\n }\n // interrupted, resumed inside the window, or a draft appeared: follow on\n if (aborted || target !== lingerOn || holdAutoExit(promptText())) continue;\n eraseLiveness();\n out(c(\"dim\", \"closing\"));\n return;\n }\n // no auto-exit: only a terminal follow with no wired stdin is done \u2014\n // an interactive one stays up for say / resume input\n if (rlIn === null) return;\n }\n if (autoExit && !target) {\n if (runningIds().length) {\n idleSince = null;\n } else if (idleSince === null) {\n idleSince = Date.now();\n } else if (Date.now() - started >= 30_000 && Date.now() - idleSince >= autoExit * 1000) {\n eraseLiveness();\n out(c(\"dim\", \"closing\"));\n return;\n }\n }\n if (!progressed) {\n writeLiveness();\n await sleep(500);\n }\n }\n } finally {\n // close before anything else: an open readline keeps the process (and the\n // iTerm pane running it) alive long after follow has decided to end\n rlIn?.close();\n process.removeListener(\"SIGINT\", onInt);\n if (onResize) out_.removeListener?.(\"resize\", onResize);\n if (chat) out_.write(chatLeave(rows));\n for (const h of handles.values()) {\n try {\n closeSync(h.fd);\n } catch {\n /* already closed */\n }\n }\n }\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((r) => setTimeout(r, ms));\n}\n\n// ---------------------------------------------------------------------------\n// status line\n// ---------------------------------------------------------------------------\n\n/** Workers of this terminal for the status bar; \"\" when there are none. */\nexport function statusLineOutput(\n logDir: string,\n term: string,\n cwd: string,\n now: Date = new Date()\n): string {\n const statuses = loadStatuses(logDir);\n const mine = statuses.filter((s) => {\n const sameScope = term && workerInScope(s, term);\n const sameDir = cwd && s.cwd.startsWith(cwd);\n return sameScope || (!term && sameDir);\n });\n return renderStatusLine(mine, now);\n}\n", "#!/usr/bin/env node\n\n/**\n * Standalone worker status-line entry point.\n *\n * statusline-command.sh calls this (argv: <ITERM_SESSION_ID> <PWD>) on every\n * status refresh, so it must stay a plain node script with no CLI framework\n * and no daemon IPC: read the config, read the worker statuses, print one\n * line or nothing. Built to dist/worker-status-line.mjs by scripts/build-hooks.mjs\n * and symlinked to ~/.claude/worker-status-line.mjs.\n */\n\nimport { readWorkersSection } from \"../config.js\";\nimport { workersLogDir } from \"../paths.js\";\nimport { statusLineOutput } from \"../viewer.js\";\n\nconst term = process.argv[2] ?? process.env.ITERM_SESSION_ID ?? \"\";\nconst cwd = process.argv[3] ?? process.cwd();\n\ntry {\n const out = statusLineOutput(workersLogDir(readWorkersSection().workers), term, cwd);\n if (out) process.stdout.write(out + \"\\n\");\n} catch {\n // no workers section or a broken config: no line, never an error in the bar\n}\n"],
5
+ "mappings": ";;;AAiBA,SAAS,WAAAA,gBAAe;AACxB,SAAS,QAAAC,aAAY;;;ACUrB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAUA,SAAS,eAAe,MAAc,QAAQ,MAA+B;AAClF,MAAI,CAAC,WAAW,IAAI,EAAG,QAAO,CAAC;AAE/B,MAAI;AACJ,MAAI;AACF,UAAM,aAAa,MAAM,MAAM;AAAA,EACjC,SAAS,GAAG;AACV,UAAM,IAAI;AAAA,MACR,kBAAkB,KAAK,KAAK,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC,CAAC;AAAA;AAAA,IAExE;AAAA,EACF;AAEA,MAAI;AACF,WAAO,KAAK,MAAM,GAAG;AAAA,EACvB,SAAS,GAAG;AACV,UAAM,IAAI;AAAA,MACR,GAAG,KAAK,kCAAkC,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC,CAAC;AAAA;AAAA;AAAA,IAGtF;AAAA,EACF;AACF;;;AC3DA,SAAS,SAAS,gBAAgB;AAClC,SAAS,YAAY;;;AC8Fd,IAAM,kBAAgC;AAAA,EAC3C,OAAY,CAAC,YAAY,SAAS,QAAQ,KAAK;AAAA,EAC/C,YAAY,CAAC,YAAY,SAAS,QAAQ,KAAK;AAAA,EAC/C,MAAY,CAAC,KAAK;AAAA,EAClB,UAAY,CAAC,KAAK;AAAA,EAClB,OAAY,CAAC;AACf;AAeO,IAAM,mBAAmC;AAAA,EAC9C,MAAM;AAAA,IACJ,SAAS;AAAA,IACT,KAAK;AAAA,IACL,UAAU;AAAA,EACZ;AAAA,EACA,UAAU;AAAA,IACR,SAAS;AAAA,IACT,WAAW;AAAA,EACb;AAAA,EACA,OAAO;AAAA,IACL,SAAS;AAAA,EACX;AAAA,EACA,OAAO;AAAA,IACL,SAAS;AAAA,IACT,WAAW;AAAA,EACb;AAAA,EACA,KAAK;AAAA,IACH,SAAS;AAAA,EACX;AACF;AAEO,IAAM,8BAAkD;AAAA,EAC7D,MAAM;AAAA,EACN,UAAU;AAAA,EACV,SAAS;AACX;;;ACoGO,IAAM,sBAAkC;AAAA,EAC7C,SAAS;AAAA,EACT,WAAW;AAAA,IACT,SAAS;AAAA,MACP,SAAS;AAAA,IACX;AAAA,EACF;AAAA,EACA,cAAc;AAChB;;;ACxOO,SAAS,gBAAwB;AACtC,SAAO,QAAQ,IAAI,mBAAmB;AACxC;;;AH0HA,SAAS,gBAAwB;AAC/B,QAAM,WAAW,SAAS,EAAE;AAE5B,QAAM,OAAO,SAAS,QAAQ,kBAAkB,GAAG,EAAE,YAAY;AACjE,SAAO,OAAO,IAAI;AACpB;AAGA,SAAS,0BAAkC;AACzC,QAAM,KAAK,cAAc;AACzB,SAAO,uCAAuC,EAAE;AAClD;AAMO,IAAM,WAA4B;AAAA,EACvC,YAAY,cAAc;AAAA,EAC1B,mBAAmB;AAAA,EACnB,mBAAmB;AAAA,EACnB,gBAAgB;AAAA,EAChB,gBAAgB;AAAA,EAChB,UAAU;AAAA,IACR,kBAAkB,wBAAwB;AAAA,IAC1C,gBAAgB;AAAA,IAChB,qBAAqB;AAAA,EACvB;AAAA,EACA,gBAAgB;AAAA,EAChB,UAAU;AAAA,EACV,eAAe;AAAA,EACf,OAAO;AAAA;AAAA;AAAA,EAGP,UAAU,EAAE,YAAY,CAAC,EAAE;AAAA,EAC3B,QAAQ;AAAA,IACN,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,kBAAkB;AAAA,IAClB,cAAc;AAAA,IACd,eAAe;AAAA,EACjB;AACF;AAyCO,IAAM,aAAa,KAAK,QAAQ,GAAG,WAAW,KAAK;AACnD,IAAM,cAAc,KAAK,YAAY,aAAa;;;AF9IlD,IAAM,gBAAgB;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAuDO,IAAM,kBAAkB;AAExB,IAAM,eAAkC;AAAA,EAC7C,SAAS;AAAA,EACT,UAAU;AAAA,EACV,cAAc;AAChB;AAEO,IAAMC,mBAAwC;AAAA,EACnD,OAAO,CAAC;AAAA,EACR,iBAAiB;AAAA,EACjB,cAAc;AAChB;AAEO,SAAS,uBAAsC;AACpD,SAAO;AAAA,IACL,SAAS;AAAA,IACT,QAAQ;AAAA,IACR,WAAW,CAAC;AAAA,IACZ,SAAS,CAAC;AAAA,IACV,SAAS,CAAC;AAAA,IACV,MAAM,EAAE,GAAG,aAAa;AAAA,IACxB,QAAQ;AAAA,IACR,SAAS,EAAE,GAAGA,kBAAiB,OAAO,CAAC,EAAE;AAAA,EAC3C;AACF;AAMO,IAAM,qBAAN,cAAiC,MAAM;AAAC;AAE/C,SAAS,IAAI,MAAc,KAAoB;AAC7C,QAAM,IAAI,mBAAmB,UAAU,IAAI,KAAK,GAAG,EAAE;AACvD;AAEA,SAAS,IAAI,GAAoB;AAC/B,SAAO,OAAO,MAAM,WAAW,IAAI;AACrC;AAEA,SAAS,cAAc,MAAc,KAA8B;AACjE,MAAI,OAAO,QAAQ,YAAY,QAAQ,KAAM,KAAI,cAAc,IAAI,IAAI,mBAAmB;AAC1F,QAAM,IAAI;AAEV,QAAM,WAAW,EAAE,aAAa,SAAY,cAAc,IAAI,EAAE,QAAQ;AACxE,MAAI,aAAa,eAAe,aAAa,UAAU;AACrD,QAAI,cAAc,IAAI,aAAa,IAAI,IAAI,EAAE,QAAQ,CAAC,uCAAuC;AAAA,EAC/F;AACA,QAAM,SAAS,EAAE,WAAW,SAAY,WAAW,IAAI,EAAE,MAAM;AAC/D,MAAI,WAAW,YAAY,WAAW,SAAS;AAC7C,QAAI,cAAc,IAAI,WAAW,IAAI,IAAI,EAAE,MAAM,CAAC,mCAAmC;AAAA,EACvF;AAEA,QAAM,UAAU,IAAI,EAAE,OAAO;AAG7B,MAAI,CAAC,WAAW,aAAa,SAAU,KAAI,cAAc,IAAI,YAAY,aAAa;AAEtF,QAAM,UACJ,EAAE,YAAY,UAAa,EAAE,YAAY,QAAQ,IAAI,EAAE,OAAO,MAAM,KAChE,OACA,IAAI,EAAE,OAAO;AAEnB,QAAM,YAAY,EAAE,WAAW,SAAY,CAAC,IAAI,EAAE;AAClD,MAAI,OAAO,cAAc,YAAY,cAAc,MAAM;AACvD,QAAI,cAAc,IAAI,WAAW,mBAAmB;AAAA,EACtD;AACA,QAAM,IAAI;AACV,QAAM,eAAe,IAAI,EAAE,OAAO;AAClC,MAAI,CAAC,aAAc,KAAI,cAAc,IAAI,mBAAmB,aAAa;AACzE,QAAM,OAAO,EAAE,SAAS,SAAY,SAAY,IAAI,EAAE,IAAI;AAE1D,QAAM,MAA8B,CAAC;AACrC,MAAI,EAAE,QAAQ,QAAW;AACvB,QAAI,OAAO,EAAE,QAAQ,YAAY,EAAE,QAAQ,QAAQ,MAAM,QAAQ,EAAE,GAAG,GAAG;AACvE,UAAI,cAAc,IAAI,QAAQ,oCAAoC;AAAA,IACpE;AACA,eAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQ,EAAE,GAA8B,GAAG;AACrE,UAAI,OAAO,MAAM,SAAU,KAAI,cAAc,IAAI,QAAQ,CAAC,IAAI,kBAAkB;AAChF,UAAI,CAAC,IAAI;AAAA,IACX;AAAA,EACF;AAEA,QAAM,cAAc,EAAE,gBAAgB,SAAY,SAAY,EAAE;AAChE,MAAI,gBAAgB,QAAW;AAC7B,QAAI,OAAO,gBAAgB,YAAY,cAAc,KAAK,cAAc,KAAK;AAC3E,UAAI,cAAc,IAAI,gBAAgB,oCAAoC;AAAA,IAC5E;AAAA,EACF;AAEA,QAAM,gBAAgB,EAAE,kBAAkB,SAAY,SAAY,EAAE;AACpE,MAAI,kBAAkB,QAAW;AAC/B,QAAI,OAAO,kBAAkB,YAAY,iBAAiB,GAAG;AAC3D,UAAI,cAAc,IAAI,kBAAkB,qCAAqC;AAAA,IAC/E;AAAA,EACF;AAEA,QAAM,cAAc,IAAI,EAAE,WAAW;AACrC,MAAI,aAAa,YAAY,CAAC,aAAa;AACzC,QAAI,cAAc,IAAI,gBAAgB,iGAAiG;AAAA,EACzI;AAEA,QAAM,WAAW,EAAE,aAAa,SAAY,SAAY,EAAE;AAC1D,MAAI,aAAa,QAAW;AAC1B,QAAI,OAAO,aAAa,YAAY,CAAC,OAAO,UAAU,QAAQ,KAAK,WAAW,KAAK,WAAW,GAAG;AAC/F,UAAI,cAAc,IAAI,aAAa,2DAAsD;AAAA,IAC3F;AAAA,EACF;AAEA,MAAI;AACJ,MAAI,EAAE,SAAS,QAAW;AACxB,QAAI,CAAC,MAAM,QAAQ,EAAE,IAAI,KAAK,EAAE,KAAK,KAAK,CAAC,MAAM,OAAO,MAAM,QAAQ,GAAG;AACvE,UAAI,cAAc,IAAI,SAAS,kCAAkC,cAAc,KAAK,IAAI,CAAC,EAAE;AAAA,IAC7F;AACA,eAAW,KAAK,EAAE,MAAkB;AAClC,UAAI,CAAE,cAAoC,SAAS,CAAC,GAAG;AACrD,YAAI,cAAc,IAAI,SAAS,IAAI,CAAC,yBAAyB,cAAc,KAAK,IAAI,CAAC,GAAG;AAAA,MAC1F;AAAA,IACF;AACA,WAAO,EAAE;AAAA,EACX;AAEA,SAAO;AAAA,IACL,SAAS,EAAE,YAAY,SAAY,OAAO,EAAE,YAAY;AAAA,IACxD;AAAA,IACA;AAAA,IACA;AAAA,IACA,QAAQ,OAAO,EAAE,SAAS,cAAc,KAAK,IAAI,EAAE,SAAS,aAAa;AAAA,IACzE;AAAA,IACA,GAAI,IAAI,EAAE,IAAI,IAAI,EAAE,MAAM,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC;AAAA,IAC3C,GAAI,cAAc,EAAE,YAAY,IAAI,CAAC;AAAA,IACrC,GAAI,WAAW,WAAW,EAAE,OAAO,IAAI,CAAC;AAAA,IACxC,GAAI,IAAI,EAAE,UAAU,IAAI,EAAE,YAAY,IAAI,EAAE,UAAU,EAAE,IAAI,CAAC;AAAA,IAC7D,GAAI,gBAAgB,SAAY,EAAE,YAAY,IAAI,CAAC;AAAA,IACnD,GAAI,kBAAkB,SAAY,EAAE,cAAc,IAAI,CAAC;AAAA,IACvD,GAAI,aAAa,SAAY,EAAE,SAAS,IAAI,CAAC;AAAA,IAC7C,GAAI,OAAO,EAAE,KAAK,IAAI,CAAC;AAAA,EACzB;AACF;AAMO,SAAS,mBAAmB,KAA6B;AAC9D,QAAM,IAAI,qBAAqB;AAC/B,MAAI,QAAQ,UAAa,QAAQ,KAAM,QAAO;AAC9C,MAAI,OAAO,QAAQ,YAAY,MAAM,QAAQ,GAAG,GAAG;AACjD,QAAI,IAAI,2BAA2B;AAAA,EACrC;AACA,QAAM,IAAI;AAEV,QAAM,YAA4C,CAAC;AACnD,MAAI,EAAE,cAAc,QAAW;AAC7B,QAAI,OAAO,EAAE,cAAc,YAAY,EAAE,cAAc,QAAQ,MAAM,QAAQ,EAAE,SAAS,GAAG;AACzF,UAAI,cAAc,0CAA0C;AAAA,IAC9D;AACA,eAAW,CAAC,MAAM,CAAC,KAAK,OAAO,QAAQ,EAAE,SAAS,GAAG;AACnD,gBAAU,IAAI,IAAI,cAAc,MAAM,CAAC;AAAA,IACzC;AAAA,EACF;AAIA,QAAM,UAAuC,CAAC;AAC9C,QAAM,aAAa,EAAE,YAAY,SAAY,EAAE,UAAU,EAAE;AAC3D,MAAI,eAAe,QAAW;AAC5B,QAAI,OAAO,eAAe,YAAY,eAAe,QAAQ,MAAM,QAAQ,UAAU,GAAG;AACtF,UAAI,EAAE,YAAY,SAAY,aAAa,UAAU,wGAAmG;AAAA,IAC1J;AACA,eAAW,CAAC,KAAK,MAAM,KAAK,OAAO,QAAQ,UAAU,GAAG;AACtD,UAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,CAAC,MAAM,QAAQ,MAAM,GAAG;AAC3E,cAAM,IAAI;AACV,cAAM,WAAW,IAAI,EAAE,QAAQ;AAC/B,YAAI,EAAE,aAAa,WAAc,CAAC,YAAY,SAAS,SAAS,GAAG,IAAI;AACrE,cAAI,YAAY,GAAG,aAAa,qBAAqB,QAAQ,GAAG;AAAA,QAClE;AACA,YAAI;AACJ,YAAI,EAAE,QAAQ,QAAW;AACvB,cAAI,CAAC,MAAM,QAAQ,EAAE,GAAG,KAAK,EAAE,IAAI,KAAK,CAAC,MAAM,OAAO,MAAM,QAAQ,GAAG;AACrE,gBAAI,YAAY,GAAG,QAAQ,6CAA6C;AAAA,UAC1E;AACA,gBAAM,EAAE;AAAA,QACV;AACA,YAAI;AACJ,YAAI,EAAE,gBAAgB,QAAW;AAC/B,cACE,OAAO,EAAE,gBAAgB,YACzB,CAAC,OAAO,UAAU,EAAE,WAAW,KAC/B,EAAE,cAAc,KAChB,EAAE,cAAc,GAChB;AACA,gBAAI,YAAY,GAAG,gBAAgB,+BAA0B;AAAA,UAC/D;AACA,wBAAc,EAAE;AAAA,QAClB;AACA,YAAI;AACJ,YAAI,EAAE,gBAAgB,QAAW;AAC/B,cAAI,CAAC,MAAM,QAAQ,EAAE,WAAW,KAAK,EAAE,YAAY,KAAK,CAAC,MAAM,OAAO,MAAM,QAAQ,GAAG;AACrF,gBAAI,YAAY,GAAG,gBAAgB,kCAAkC,cAAc,KAAK,IAAI,CAAC,EAAE;AAAA,UACjG;AACA,qBAAW,KAAK,EAAE,aAAyB;AACzC,gBAAI,CAAE,cAAoC,SAAS,CAAC,GAAG;AACrD,kBAAI,YAAY,GAAG,gBAAgB,IAAI,CAAC,yBAAyB,cAAc,KAAK,IAAI,CAAC,GAAG;AAAA,YAC9F;AAAA,UACF;AACA,wBAAc,EAAE;AAAA,QAClB;AACA,YAAI;AACJ,YAAI,EAAE,UAAU,QAAW;AACzB,cAAI,CAAC,MAAM,QAAQ,EAAE,KAAK,KAAK,EAAE,MAAM,KAAK,CAAC,MAAM,OAAO,MAAM,QAAQ,GAAG;AACzE,gBAAI,YAAY,GAAG,UAAU,oCAAoC;AAAA,UACnE;AACA,kBAAQ,EAAE;AAAA,QACZ;AACA,cAAM,MAAmB;AAAA,UACvB,GAAI,WAAW,EAAE,SAAS,IAAI,CAAC;AAAA,UAC/B,GAAI,MAAM,EAAE,IAAI,IAAI,CAAC;AAAA,UACrB,GAAI,gBAAgB,SAAY,EAAE,YAAY,IAAI,CAAC;AAAA,UACnD,GAAI,cAAc,EAAE,YAAY,IAAI,CAAC;AAAA,UACrC,GAAI,QAAQ,EAAE,MAAM,IAAI,CAAC;AAAA,QAC3B;AACA,gBAAQ,GAAG,IAAI,OAAO,KAAK,GAAG,EAAE,SAAS,MAAM,CAAC;AAAA,MAClD,OAAO;AACL,cAAM,IAAI,IAAI,MAAM;AACpB,YAAI,CAAC,KAAK,EAAE,SAAS,GAAG,EAAG,KAAI,YAAY,GAAG,IAAI,mBAAmB,CAAC,GAAG;AACzE,gBAAQ,GAAG,IAAI;AAAA,MACjB;AAAA,IACF;AAAA,EACF;AAEA,QAAM,UAAoC,CAAC;AAC3C,MAAI,EAAE,YAAY,QAAW;AAC3B,QAAI,OAAO,EAAE,YAAY,YAAY,EAAE,YAAY,QAAQ,MAAM,QAAQ,EAAE,OAAO,GAAG;AACnF,UAAI,YAAY,qDAAgD;AAAA,IAClE;AACA,eAAW,CAAC,SAAS,OAAO,KAAK,OAAO,QAAQ,EAAE,OAAO,GAAG;AAC1D,UAAI,CAAC,MAAM,QAAQ,OAAO,KAAK,QAAQ,KAAK,CAAC,MAAM,OAAO,MAAM,QAAQ,GAAG;AACzE,YAAI,YAAY,OAAO,IAAI,sCAAsC;AAAA,MACnE;AACA,cAAQ,OAAO,IAAI;AAAA,IACrB;AAAA,EACF;AAEA,MAAI,OAAO,EAAE,GAAG,aAAa;AAC7B,MAAI,EAAE,SAAS,QAAW;AACxB,QAAI,OAAO,EAAE,SAAS,YAAY,EAAE,SAAS,KAAM,KAAI,SAAS,mBAAmB;AACnF,UAAM,KAAK,EAAE;AACb,QAAI,GAAG,YAAY,UAAa,OAAO,GAAG,YAAY,UAAW,KAAI,iBAAiB,iBAAiB;AACvG,QAAI,GAAG,aAAa,WAAc,OAAO,GAAG,aAAa,YAAY,GAAG,YAAY,IAAI;AACtF,UAAI,kBAAkB,qCAAqC;AAAA,IAC7D;AACA,QAAI,GAAG,iBAAiB,UAAa,OAAO,GAAG,iBAAiB,UAAU;AACxE,UAAI,sBAAsB,kBAAkB;AAAA,IAC9C;AAEA,WAAO;AAAA,MACL,SAAS,GAAG,YAAY,SAAY,aAAa,UAAU,GAAG,YAAY;AAAA,MAC1E,UAAU,GAAG,aAAa,SAAY,aAAa,WAAW,GAAG;AAAA,MACjE,cAAc,GAAG,iBAAiB,SAAY,aAAa,eAAe,GAAG;AAAA,IAC/E;AAAA,EACF;AAEA,MAAI,UAAU,EAAE,GAAGA,kBAAiB,OAAO,CAAC,EAAc;AAC1D,MAAI,EAAE,YAAY,QAAW;AAC3B,QAAI,OAAO,EAAE,YAAY,YAAY,EAAE,YAAY,KAAM,KAAI,YAAY,mBAAmB;AAC5F,UAAM,IAAI,EAAE;AACZ,QAAI,EAAE,UAAU,QAAW;AACzB,UAAI,CAAC,MAAM,QAAQ,EAAE,KAAK,KAAK,EAAE,MAAM,KAAK,CAAC,MAAM,OAAO,MAAM,QAAQ,GAAG;AACzE,YAAI,kBAAkB,oCAAoC;AAAA,MAC5D;AACA,cAAQ,QAAQ,EAAE;AAAA,IACpB;AACA,QAAI,EAAE,oBAAoB,UAAa,OAAO,EAAE,oBAAoB,UAAU;AAC5E,UAAI,4BAA4B,kBAAkB;AAAA,IACpD;AACA,QAAI,EAAE,iBAAiB,UAAa,OAAO,EAAE,iBAAiB,WAAW;AACvE,UAAI,yBAAyB,iBAAiB;AAAA,IAChD;AACA,cAAU;AAAA,MACR,OAAO,QAAQ;AAAA,MACf,iBAAiB,EAAE,oBAAoB,SAAYA,iBAAgB,kBAAkB,EAAE;AAAA,MACvF,cAAc,EAAE,iBAAiB,SAAYA,iBAAgB,eAAe,EAAE,iBAAiB;AAAA,IACjG;AAAA,EACF;AAEA,QAAM,SAAS,EAAE,WAAW,UAAa,EAAE,WAAW,OAAO,OAAO,IAAI,EAAE,MAAM;AAChF,MAAI,WAAW,QAAQ,WAAW,UAAU,EAAE,UAAU,YAAY;AAAA,EAGpE;AAEA,SAAO;AAAA,IACL,SAAS,EAAE,YAAY,SAAY,EAAE,UAAU,EAAE,YAAY;AAAA,IAC7D;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,QAAQ,IAAI,EAAE,MAAM,KAAK,EAAE;AAAA,IAC3B;AAAA,EACF;AACF;AAWO,SAAS,qBAGd;AACA,QAAM,MAAM,eAAe,aAAa,2BAA2B;AACnE,SAAO,EAAE,KAAK,SAAS,mBAAmB,IAAI,OAAO,EAAE;AACzD;AAYO,SAAS,WAAW,GAAmB;AAC5C,MAAI,MAAM,OAAO,EAAE,WAAW,IAAI,EAAG,QAAOC,MAAKC,SAAQ,GAAG,EAAE,MAAM,CAAC,CAAC;AACtE,SAAO;AACT;;;AM9dO,SAAS,cAAc,QAA+B;AAC3D,SAAO,WAAW,OAAO,MAAM;AACjC;;;ACLA,SAAS,cAAAC,aAAY,gBAAAC,eAAc,cAAAC,aAAY,iBAAAC,gBAAe,mBAAmB;AACjF,SAAS,QAAAC,aAAY;AA2Cd,SAAS,eAAe,GAAyE;AACtG,MAAI,CAAC,EAAE,iBAAiB,CAAC,EAAE,cAAe,QAAO;AACjD,SAAO,KAAK,MAAO,EAAE,gBAAgB,EAAE,gBAAiB,GAAG;AAC7D;AAsCO,SAAS,aAAa,QAAgC;AAC3D,MAAI,CAACC,YAAW,MAAM,EAAG,QAAO,CAAC;AACjC,QAAM,MAAsB,CAAC;AAC7B,aAAW,QAAQ,YAAY,MAAM,EAAE,KAAK,GAAG;AAC7C,QAAI,CAAC,KAAK,SAAS,SAAS,EAAG;AAC/B,QAAI;AACF,UAAI,KAAK,KAAK,MAAMC,cAAaC,MAAK,QAAQ,IAAI,GAAG,MAAM,CAAC,CAAiB;AAAA,IAC/E,QAAQ;AAAA,IAER;AAAA,EACF;AACA,SAAO;AACT;AAYO,SAAS,MAAM,KAAyC;AAC7D,MAAI,CAAC,OAAO,OAAO,EAAG,QAAO;AAC7B,MAAI;AACF,YAAQ,KAAK,KAAK,CAAC;AACnB,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGO,SAAS,MAAM,IAAY,MAAY,oBAAI,KAAK,GAAW;AAChE,QAAM,IAAI,KAAK,MAAM,GAAG,QAAQ,KAAK,GAAG,CAAC;AACzC,MAAI,OAAO,MAAM,CAAC,EAAG,QAAO;AAC5B,QAAM,IAAI,KAAK,IAAI,GAAG,KAAK,OAAO,IAAI,QAAQ,IAAI,KAAK,GAAI,CAAC;AAC5D,SAAO,IAAI,KAAK,GAAG,CAAC,MAAM,GAAG,KAAK,MAAM,IAAI,EAAE,CAAC;AACjD;;;ACrHA,SAAS,WAAAC,gBAAe;AACxB,SAAS,QAAAC,aAAY;AAGd,IAAM,oBAAoBA,MAAKD,SAAQ,GAAG,aAAa,oBAAoB;AAM3E,SAAS,OAAOE,OAAsB;AAC3C,MAAI,CAACA,MAAM,QAAO;AAClB,QAAM,OAAOA,MAAK,MAAM,KAAK,CAAC,EAAE,CAAC;AACjC,QAAM,QAAQ,KAAK,MAAM,CAAC,EAAE,MAAM,GAAG;AACrC,MAAI,KAAK,WAAW,GAAG,KAAK,MAAM,WAAW,KAAK,MAAM,MAAM,CAAC,MAAM,QAAQ,KAAK,CAAC,CAAC,GAAG;AACrF,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAQO,SAAS,UAAUC,OAAsB;AAC9C,MAAI,CAACA,MAAM,QAAO;AAClB,SAAOA,MAAK,MAAM,GAAG,EAAE,IAAI,KAAK;AAClC;AAoCO,SAAS,cAAc,QAAsBC,OAAuB;AACzE,MAAI,CAACA,MAAM,QAAO;AAClB,QAAM,OAAO,UAAUA,KAAI;AAC3B,MAAI,OAAO,SAAS,MAAM,KAAM,QAAO,OAAO,QAAQ,OAAO;AAC7D,SAAO,OAAO,OAAO,IAAI,MAAM,OAAOA,KAAI,KAAK,OAAOA,KAAI,MAAM;AAClE;;;ACzEO,IAAM,yBAAyB;AAAA,EACpC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,EAAE,KAAK,IAAI;;;ACJX,IAAM,QAAQ;AAAA,EACZ,KAAK;AAAA,EACL,MAAM;AAAA,EACN,KAAK;AAAA,EACL,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,KAAK;AAAA,EACL,MAAM;AACR;AAIO,SAAS,UAAU,SAAuB;AAC/C,SAAO,CAAC,MAAiB,MACvB,UAAU,QAAQ,MAAM,IAAI,CAAC,IAAI,CAAC,YAAY;AAClD;AAyKA,SAAS,KAAK,GAAmB;AAC/B,SAAO,KAAK,MAAO,GAAG,KAAK,MAAM,IAAI,GAAI,CAAC,MAAM,OAAO,CAAC;AAC1D;AAOO,SAAS,aACd,GACA,GACA,SAAS,GACM;AACf,QAAM,MAAM,eAAe,CAAC;AAC5B,MAAI,QAAQ,QAAQ,OAAO,OAAQ,QAAO;AAC1C,QAAM,QAAQ,OAAO,KAAK,EAAE,iBAAiB,CAAC,CAAC,IAAI,KAAK,EAAE,iBAAiB,CAAC,CAAC,KAAK,GAAG;AACrF,MAAI,MAAM,GAAI,QAAO,EAAE,OAAO,KAAK;AACnC,MAAI,MAAM,GAAI,QAAO,EAAE,UAAU,KAAK;AACtC,SAAO;AACT;AAmOO,SAAS,iBACd,MACA,MAAY,oBAAI,KAAK,GACrB,IAAW,UAAU,IAAI,GACjB;AACR,MAAI,CAAC,KAAK,OAAQ,QAAO;AACzB,QAAM,UAAU,KAAK,OAAO,CAAC,MAAM,EAAE,UAAU,aAAa,MAAM,EAAE,GAAG,CAAC;AACxE,QAAM,QAAQ,GAAG,IAAI,YAAY,CAAC,IAAI,OAAO,IAAI,SAAS,IAAI,CAAC,EAAE,SAAS,GAAG,GAAG,CAAC,IAAI,OAAO,IAAI,QAAQ,CAAC,EAAE,SAAS,GAAG,GAAG,CAAC;AAC3H,QAAM,YAAY,KAAK,OAAO,CAAC,MAAM,EAAE,UAAU,aAAa,EAAE,QAAQ,WAAW,KAAK,CAAC;AACzF,QAAM,KAAK,UAAU,OAAO,CAAC,MAAM,EAAE,UAAU,MAAM,EAAE;AACvD,QAAMC,OAAM,UAAU,SAAS;AAC/B,QAAM,YAAY,IAAI,IAAI,QAAQ,IAAI,CAAC,MAAM,EAAE,QAAQ,CAAC;AACxD,QAAM,cAAc,UAAU,SAAS,IAAI,CAAC,GAAG,SAAS,EAAE,CAAC,IAAI;AAC/D,MAAI,OAAO,GAAG,WAAW,UAAK,QAAQ,MAAM;AAC5C,QAAM,QAAQ,QAAQ,MAAM,GAAG,CAAC,EAAE,IAAI,CAAC,MAAM;AAE3C,UAAM,QAAQ,aAAa,GAAG,GAAG,EAAE;AACnC,WACE,GAAG,EAAE,GAAG,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,MAAM,GAAG,EAAE,CAAC,IAAI,MAAM,EAAE,SAAS,GAAG,CAAC,SAAM,EAAE,KAAK,MAAM,GAAG,EAAE,CAAC,MAC1F,QAAQ,IAAI,KAAK,KAAK;AAAA,EAE3B,CAAC;AACD,MAAI,MAAM,OAAQ,SAAQ,OAAO,MAAM,KAAK,KAAK;AACjD,MAAI,UAAU,OAAQ,SAAQ,YAAO,EAAE,UAAKA,IAAG;AAC/C,SAAO;AACT;;;AC4UO,SAAS,iBACd,QACAC,OACAC,MACA,MAAY,oBAAI,KAAK,GACb;AACR,QAAM,WAAW,aAAa,MAAM;AACpC,QAAM,OAAO,SAAS,OAAO,CAAC,MAAM;AAClC,UAAM,YAAYD,SAAQ,cAAc,GAAGA,KAAI;AAC/C,UAAM,UAAUC,QAAO,EAAE,IAAI,WAAWA,IAAG;AAC3C,WAAO,aAAc,CAACD,SAAQ;AAAA,EAChC,CAAC;AACD,SAAO,iBAAiB,MAAM,GAAG;AACnC;;;ACpyBA,IAAM,OAAO,QAAQ,KAAK,CAAC,KAAK,QAAQ,IAAI,oBAAoB;AAChE,IAAM,MAAM,QAAQ,KAAK,CAAC,KAAK,QAAQ,IAAI;AAE3C,IAAI;AACF,QAAM,MAAM,iBAAiB,cAAc,mBAAmB,EAAE,OAAO,GAAG,MAAM,GAAG;AACnF,MAAI,IAAK,SAAQ,OAAO,MAAM,MAAM,IAAI;AAC1C,QAAQ;AAER;",
6
+ "names": ["homedir", "join", "DEFAULT_ROUTING", "join", "homedir", "existsSync", "readFileSync", "renameSync", "writeFileSync", "join", "existsSync", "readFileSync", "join", "homedir", "join", "term", "term", "term", "bad", "term", "cwd"]
7
+ }
@@ -296,4 +296,4 @@ async function indexAllWithBackend(backend, registryDb) {
296
296
 
297
297
  //#endregion
298
298
  export { embedChunksWithBackend, indexAllWithBackend };
299
- //# sourceMappingURL=indexer-backend-nQZuEx6N.mjs.map
299
+ //# sourceMappingURL=indexer-backend-Cox9BCo-.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"indexer-backend-nQZuEx6N.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-Cox9BCo-.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":";;;;;;;;;;;;;;;;;;;AAuCA,eAAsB,qBACpB,SACA,WACA,UACA,cACA,QACA,MACkB;CAClB,MAAM,UAAU,KAAK,UAAU,aAAa;CAE5C,IAAI;CACJ,IAAI;AACJ,KAAI;AACF,YAAU,aAAa,SAAS,OAAO;AACvC,SAAO,SAAS,QAAQ;SAClB;AACN,SAAO;;CAGT,MAAM,OAAO,WAAW,QAAQ;CAChC,MAAM,QAAQ,KAAK,MAAM,KAAK,QAAQ;CACtC,MAAM,OAAO,KAAK;AAIlB,KADqB,MAAM,QAAQ,YAAY,WAAW,aAAa,KAClD,KAAM,QAAO;AAGlC,OAAM,QAAQ,oBAAoB,WAAW,aAAa;CAG1D,MAAM,YAAY,cAAc,QAAQ;CACxC,MAAM,YAAY,KAAK,KAAK;CAE5B,MAAM,SAAqB,UAAU,KAAK,GAAG,OAAO;EAClD,IAAI,QAAQ,WAAW,cAAc,GAAG,EAAE,WAAW,EAAE,QAAQ;EAC/D;EACA;EACA;EACA,MAAM;EACN,WAAW,EAAE;EACb,SAAS,EAAE;EACX,MAAM,EAAE;EACR,MAAM,EAAE;EACR;EACA,WAAW;EACZ,EAAE;AAGH,OAAM,QAAQ,aAAa,OAAO;AAClC,OAAM,QAAQ,WAAW;EAAE;EAAW,MAAM;EAAc;EAAQ;EAAM;EAAM;EAAO;EAAM,CAAC;AAE5F,QAAO;;AAOT,eAAsB,wBACpB,SACA,WACA,UACA,gBACsB;CACtB,MAAM,SAAsB;EAAE,gBAAgB;EAAG,eAAe;EAAG,cAAc;EAAG;CAEpF,MAAM,eAA2F,EAAE;CAEnG,MAAM,eAAe,KAAK,UAAU,YAAY;AAChD,KAAI,WAAW,aAAa,CAC1B,cAAa,KAAK;EAAE,SAAS;EAAc,UAAU;EAAU,QAAQ;EAAU,MAAM;EAAa,CAAC;CAGvG,MAAM,YAAY,KAAK,UAAU,SAAS;AAC1C,MAAK,MAAM,WAAW,YAAY,UAAU,EAAE;EAE5C,MAAM,OAAO,WADG,SAAS,UAAU,QAAQ,CACX;AAChC,eAAa,KAAK;GAAE;GAAS,UAAU;GAAU,QAAQ;GAAU;GAAM,CAAC;;CAG5E,MAAM,WAAW,KAAK,UAAU,QAAQ;AACxC,MAAK,MAAM,WAAW,YAAY,SAAS,CACzC,cAAa,KAAK;EAAE;EAAS,UAAU;EAAU,QAAQ;EAAS,MAAM;EAAW,CAAC;CAItF;EACE,MAAM,YAAY,KAAK,KAAK;AAC5B,OAAK,MAAM,WAAW,YAAY,SAAS,EAAE;GAE3C,MAAM,OAAO,uBADI,SAAS,QAAQ,CACW;AAC7C,OAAI,CAAC,KAAM;GAEX,MAAM,gBAAgB,GADN,SAAS,UAAU,QAAQ,CACV;GAGjC,MAAM,aAAuB;IAC3B,IAHS,QAAQ,WAAW,eAAe,GAAG,GAAG,EAAE;IAG/C;IAAW,QAAQ;IAAS,MAAM;IACtC,MAAM;IAAe,WAAW;IAAG,SAAS;IAC5C,MAJW,WAAW,KAAK;IAIrB;IAAM;IAAW,WAAW;IACnC;AACD,OAAI;AACF,UAAM,QAAQ,aAAa,CAAC,WAAW,CAAC;WAClC;;;AAMZ,KAAI,CAAC,6BAA6B,SAAS,CACzC,MAAK,MAAM,WAAW,iBAAiB,SAAS,CAC9C,cAAa,KAAK;EAAE;EAAS,UAAU;EAAU,QAAQ;EAAW,MAAM;EAAS,CAAC;AAIxF,KAAI,kBAAkB,mBAAmB,UAAU;AACjD,OAAK,MAAM,WAAW,YAAY,eAAe,CAC/C,cAAa,KAAK;GAAE;GAAS,UAAU;GAAgB,QAAQ;GAAS,MAAM;GAAW,CAAC;EAI5F;GACE,MAAM,YAAY,KAAK,KAAK;AAC5B,QAAK,MAAM,WAAW,YAAY,eAAe,EAAE;IAEjD,MAAM,OAAO,uBADI,SAAS,QAAQ,CACW;AAC7C,QAAI,CAAC,KAAM;IAEX,MAAM,gBAAgB,GADN,SAAS,gBAAgB,QAAQ,CAChB;IAGjC,MAAM,aAAuB;KAC3B,IAHS,QAAQ,WAAW,eAAe,GAAG,GAAG,EAAE;KAG/C;KAAW,QAAQ;KAAS,MAAM;KACtC,MAAM;KAAe,WAAW;KAAG,SAAS;KAC5C,MAJW,WAAW,KAAK;KAIrB;KAAM;KAAW,WAAW;KACnC;AACD,QAAI;AACF,WAAM,QAAQ,aAAa,CAAC,WAAW,CAAC;YAClC;;;AAMZ,MAAI,eAAe,SAAS,SAAS,EAAE;GACrC,MAAM,mBAAmB,eAAe,MAAM,GAAG,GAAiB;GAClE,MAAM,iBAAiB,KAAK,kBAAkB,YAAY;AAC1D,OAAI,WAAW,eAAe,CAC5B,cAAa,KAAK;IAAE,SAAS;IAAgB,UAAU;IAAkB,QAAQ;IAAU,MAAM;IAAa,CAAC;GAEjH,MAAM,kBAAkB,KAAK,kBAAkB,SAAS;AACxD,QAAK,MAAM,WAAW,YAAY,gBAAgB,EAAE;IAElD,MAAM,OAAO,WADG,SAAS,kBAAkB,QAAQ,CACnB;AAChC,iBAAa,KAAK;KAAE;KAAS,UAAU;KAAkB,QAAQ;KAAU;KAAM,CAAC;;;;AAKxF,OAAM,kBAAkB;CAExB,IAAI,kBAAkB;AAEtB,MAAK,MAAM,EAAE,SAAS,UAAU,QAAQ,UAAU,cAAc;AAC9D,MAAI,mBAAmB,mBAAmB;AACxC,SAAM,kBAAkB;AACxB,qBAAkB;;AAEpB;EAEA,MAAM,UAAU,SAAS,UAAU,QAAQ;AAC3C,MAAI;AAGF,OAFgB,MAAM,qBAAqB,SAAS,WAAW,UAAU,SAAS,QAAQ,KAAK,EAElF;IACX,MAAM,MAAM,MAAM,QAAQ,YAAY,WAAW,QAAQ;AACzD,WAAO;AACP,WAAO,iBAAiB,IAAI;SAE5B,QAAO;UAEH;AAEN,UAAO;;;CAKX,MAAM,4BAAY,IAAI,KAAa;AACnC,MAAK,MAAM,EAAE,SAAS,cAAc,aAClC,WAAU,IAAI,SAAS,UAAU,QAAQ,CAAC;CAG5C,MAAM,eAAe,MAAM,QAAQ,sBAAsB,UAAU;CAEnE,MAAM,aAAuB,EAAE;AAC/B,MAAK,MAAM,KAAK,cAAc;EAC5B,MAAM,WAAW,EAAE,SAAS,UAAU,GAAG,EAAE,MAAM,GAAG,GAAkB,GAAG;AACzE,MAAI,CAAC,UAAU,IAAI,SAAS,CAC1B,YAAW,KAAK,EAAE;;AAItB,KAAI,WAAW,SAAS,EACtB,OAAM,QAAQ,YAAY,WAAW,WAAW;AAGlD,QAAO;;AAOT,MAAM,mBAAmB;;AAIzB,MAAM,8BAA8B;;AAEpC,MAAM,8BAA8B;;;;;;;;;;;;;;;AA8BpC,eAAsB,uBACpB,SACA,YACA,cACA,SACiB;CACjB,MAAM,EAAE,oBAAoB,uBAAuB,MAAM,OAAO;CAEhE,MAAM,YAAY,SAAS,aAAa;CACxC,MAAM,YAAY,SAAS,aAAa;CACxC,MAAM,WAAW,KAAK,KAAK,GAAG;CAE9B,MAAM,OAAO,MAAM,QAAQ,sBAAsB,QAAW,UAAU;AACtE,KAAI,KAAK,WAAW,EAAG,QAAO;CAE9B,MAAM,QAAQ,KAAK;CACnB,IAAI,WAAW;CAGf,MAAM,qCAAqB,IAAI,KAAoD;AACnF,MAAK,MAAM,OAAO,MAAM;EACtB,MAAM,QAAQ,mBAAmB,IAAI,IAAI,WAAW;AACpD,MAAI,MACF,OAAM;MAEN,oBAAmB,IAAI,IAAI,YAAY;GAAE,OAAO;GAAG,YAAY,IAAI;GAAM,CAAC;;CAG9E,MAAM,SAAS,QAAgB,cAAc,IAAI,IAAI,IAAI,WAAW;CACpE,MAAM,iBAAiB,MAAM,KAAK,mBAAmB,SAAS,CAAC,CAC5D,KAAK,CAAC,KAAK,EAAE,OAAO,kBAAkB,KAAK,MAAM,IAAI,CAAC,IAAI,MAAM,gBAAgB,WAAW,GAAG,CAC9F,KAAK,KAAK;AACb,SAAQ,OAAO,MACb,4BAA4B,MAAM,4BAA4B,mBAAmB,KAAK,eAAe,eAAe,IACrH;CAGD,IAAI,mBAAmB;CACvB,IAAI,kBAAkB;AAEtB,MAAK,IAAI,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK,kBAAkB;AAEtD,MAAI,cAAc,EAAE;AAClB,WAAQ,OAAO,MACb,2CAA2C,SAAS,GAAG,MAAM,gCAC9D;AACD;;AAKF,MAAI,KAAK,KAAK,IAAI,UAAU;AAC1B,WAAQ,OAAO,MACb,0CAA0C,SAAS,GAAG,MAAM,WAAW,UAAU,wCAClF;AACD;;EAGF,MAAM,QAAQ,KAAK,MAAM,GAAG,IAAI,iBAAiB;AAIjD,QAAM,kBAAkB;EAExB,MAAM,OAAO,MAAM,mBAAmB,MAAM,KAAK,MAAM,EAAE,KAAK,CAAC;AAO/D,QAAM,QAAQ,IACZ,MAAM,KAAK,KAAK,MAAM,QAAQ,gBAAgB,IAAI,IAAI,mBAAmB,KAAK,GAAG,CAAC,CAAC,CACpF;AAMD,OAAK,MAAM,EAAE,YAAY,UAAU,OAAO;AACxC,OAAI,eAAe,kBAAkB;AACnC,QAAI,qBAAqB,GACvB,SAAQ,OAAO,MACb,yBAAyB,MAAM,iBAAiB,CAAC,IAAI,gBAAgB,oBACtE;IAEH,MAAM,OAAO,mBAAmB,IAAI,WAAW;AAC/C,YAAQ,OAAO,MACb,0BAA0B,MAAM,WAAW,CAAC,IAAI,MAAM,SAAS,IAAI,uBAAuB,KAAK,KAChG;AACD,uBAAmB;AACnB,sBAAkB;;AAEpB;;AAGF,cAAY,MAAM;EAGlB,MAAM,YAAY,MAAM,MAAM,SAAS;AACvC,UAAQ,OAAO,MACb,yBAAyB,SAAS,GAAG,MAAM,WAAW,MAAM,UAAU,WAAW,CAAC,IAAI,UAAU,KAAK,KACtG;;AAIH,KAAI,qBAAqB,GACvB,SAAQ,OAAO,MACb,yBAAyB,MAAM,iBAAiB,CAAC,IAAI,gBAAgB,oBACtE;AAGH,QAAO;;AAOT,eAAsB,oBACpB,SACA,YACoD;CACpD,MAAM,WAAW,WACd,QAAQ,+EAA+E,CACvF,KAAK;CAER,MAAM,SAAsB;EAAE,gBAAgB;EAAG,eAAe;EAAG,cAAc;EAAG;AAEpF,MAAK,MAAM,WAAW,UAAU;AAC9B,QAAM,kBAAkB;EACxB,MAAM,IAAI,MAAM,wBAAwB,SAAS,QAAQ,IAAI,QAAQ,WAAW,QAAQ,iBAAiB;AACzG,SAAO,kBAAkB,EAAE;AAC3B,SAAO,iBAAiB,EAAE;AAC1B,SAAO,gBAAgB,EAAE;;AAG3B,QAAO;EAAE,UAAU,SAAS;EAAQ,QAAQ;EAAQ"}
@@ -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-XPkj_bfP.mjs";
3
+ import { t as zettelThemes } from "./themes-BN0a2duq.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-Bn6A5-5P.mjs.map
191
+ //# sourceMappingURL=latent-ideas-BC1oINZ-.mjs.map