@tekmidian/pai 0.59.0 → 0.60.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.
- package/.claude-plugin/plugin.json +8 -33
- package/PLUGIN-ARCHITECTURE.md +4 -6
- package/dist/{aibroker-client-B8Wtasez.mjs → aibroker-client-BYG-GtAg.mjs} +1 -1
- package/dist/{chain-Cltx0NjY.mjs → chain-DFlzAfDE.mjs} +4 -4
- package/dist/{chain-Cltx0NjY.mjs.map → chain-DFlzAfDE.mjs.map} +1 -1
- package/dist/cli/index.mjs +12 -12
- package/dist/cli/program.mjs +12 -12
- package/dist/{config-DCYWFaEi.mjs → config-GceCMUxa.mjs} +1 -1
- package/dist/{config-D1omG_UU.mjs → config-msER8xj_.mjs} +16 -4
- package/dist/config-msER8xj_.mjs.map +1 -0
- package/dist/{context-handover-cache-Dt4HW1Ro.mjs → context-handover-cache-DF0xWVB0.mjs} +4 -4
- package/dist/{context-handover-cache-Dt4HW1Ro.mjs.map → context-handover-cache-DF0xWVB0.mjs.map} +1 -1
- package/dist/daemon/index.mjs +12 -12
- package/dist/daemon-BppwJtrH.mjs +19 -0
- package/dist/{daemon-CmqJfeb9.mjs → daemon-DArRKBlk.mjs} +97 -23
- package/dist/daemon-DArRKBlk.mjs.map +1 -0
- package/dist/daemon-mcp/index.mjs +9 -9
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/{factory-DiuntvwG.mjs → factory-ChfAvCzO.mjs} +7 -7
- package/dist/{factory-DiuntvwG.mjs.map → factory-ChfAvCzO.mjs.map} +1 -1
- package/dist/{factory-C9wGUe30.mjs → factory-D6I2jmjQ.mjs} +1 -1
- package/dist/{fallback-B_2ozQHw.mjs → fallback-B7VEMMRn.mjs} +80 -9
- package/dist/fallback-B7VEMMRn.mjs.map +1 -0
- package/dist/hooks/block-sleep-poll.mjs +7590 -0
- package/dist/hooks/block-sleep-poll.mjs.map +7 -0
- package/dist/hooks/capture-all-events.mjs +8 -4
- package/dist/hooks/capture-all-events.mjs.map +2 -2
- package/dist/hooks/capture-session-summary.mjs.map +1 -1
- package/dist/hooks/capture-tool-output.mjs.map +1 -1
- package/dist/hooks/cleanup-session-files.mjs.map +1 -1
- package/dist/hooks/context-compression-hook.mjs +6 -6
- package/dist/hooks/context-compression-hook.mjs.map +3 -3
- package/dist/hooks/initialize-session.mjs.map +1 -1
- package/dist/hooks/load-core-context.mjs.map +1 -1
- package/dist/hooks/load-project-context.mjs.map +2 -2
- package/dist/hooks/post-compact-inject.mjs +1 -1
- package/dist/hooks/post-compact-inject.mjs.map +2 -2
- package/dist/hooks/route-agents-to-worker.mjs +4 -3
- package/dist/hooks/route-agents-to-worker.mjs.map +3 -3
- package/dist/hooks/security-validator.mjs +4 -3
- package/dist/hooks/security-validator.mjs.map +3 -3
- package/dist/hooks/stop-hook.mjs.map +1 -1
- package/dist/hooks/sync-todo-to-md.mjs.map +1 -1
- package/dist/hooks/whisper-rules.mjs +4 -3
- package/dist/hooks/whisper-rules.mjs.map +3 -3
- package/dist/hooks/worker-proxy.mjs +4 -3
- package/dist/hooks/worker-proxy.mjs.map +3 -3
- package/dist/hooks/worker-status-line.mjs +9 -6
- package/dist/hooks/worker-status-line.mjs.map +3 -3
- package/dist/hooks/worker-supervision.mjs +4 -3
- package/dist/hooks/worker-supervision.mjs.map +3 -3
- package/dist/{main-resolver-DYNxxNwJ.mjs → main-resolver-BqTjt-GL.mjs} +100 -21
- package/dist/main-resolver-BqTjt-GL.mjs.map +1 -0
- package/dist/main-resolver-CCojcYr2.mjs +7 -0
- package/dist/{planner-DsZZEZCA.mjs → planner-DbpsMZqY.mjs} +83 -30
- package/dist/planner-DbpsMZqY.mjs.map +1 -0
- package/dist/{program-B5miXqQa.mjs → program-QSuci3RH.mjs} +278 -92
- package/dist/program-QSuci3RH.mjs.map +1 -0
- package/dist/router-C5hPk9YP.mjs +3 -0
- package/dist/{router-BdxdsAvs.mjs → router-DjFZIoDl.mjs} +2 -1
- package/dist/router-DjFZIoDl.mjs.map +1 -0
- package/dist/{run-XaA-UPdW.mjs → run-BFxuNnIJ.mjs} +100 -17
- package/dist/run-BFxuNnIJ.mjs.map +1 -0
- package/dist/{run-env-DyTWgZC9.mjs → run-env-kbWVwUvU.mjs} +2 -2
- package/dist/{run-env-DyTWgZC9.mjs.map → run-env-kbWVwUvU.mjs.map} +1 -1
- package/dist/{server-CzqHGbKt.mjs → server-B0vBmfJt.mjs} +2 -2
- package/dist/{server-CzqHGbKt.mjs.map → server-B0vBmfJt.mjs.map} +1 -1
- package/dist/{session-keepalive-DDsEo81Q.mjs → session-keepalive-CUcFa8ld.mjs} +5 -5
- package/dist/{session-keepalive-DDsEo81Q.mjs.map → session-keepalive-CUcFa8ld.mjs.map} +1 -1
- package/dist/{tools-Diro2s8J.mjs → tools-CaTZnIau.mjs} +2 -2
- package/dist/{tools-Diro2s8J.mjs.map → tools-CaTZnIau.mjs.map} +1 -1
- package/dist/{tools-KgL1rkPD.mjs → tools-_7MF8Ebb.mjs} +3 -3
- package/dist/{wakeup-D0uvqLsE.mjs → wakeup-CTX3rlz-.mjs} +2 -2
- package/dist/wakeup-CTX3rlz-.mjs.map +1 -0
- package/dist/{work-queue-worker-DL9lu8sN.mjs → work-queue-worker-B4eZGy5n.mjs} +2 -2
- package/dist/{work-queue-worker-DL9lu8sN.mjs.map → work-queue-worker-B4eZGy5n.mjs.map} +1 -1
- package/dist/work-queue-worker-DuhZmfDB.mjs +11 -0
- package/docker/init.sql +1 -1
- package/docs/commands/README.md +4 -4
- package/docs/commands/daemon.md +6 -6
- package/docs/commands/session.md +2 -2
- package/docs/commands/worker.md +5 -2
- package/docs/workers-config.md +5 -1
- package/package.json +1 -1
- package/plugins/context-preservation/hooks/hooks.json +38 -31
- package/plugins/context-preservation/plugin.json +1 -4
- package/plugins/core/hooks/hooks.json +51 -46
- package/plugins/core/plugin.json +1 -4
- package/plugins/creative/plugin.json +1 -5
- package/plugins/observability/hooks/hooks.json +87 -72
- package/plugins/observability/plugin.json +1 -5
- package/plugins/productivity/hooks/hooks.json +22 -19
- package/plugins/productivity/plugin.json +2 -5
- package/plugins/semantic-search/plugin.json +1 -16
- package/plugins/ui/hooks/hooks.json +17 -14
- package/plugins/ui/plugin.json +1 -5
- package/plugins/zettelkasten/plugin.json +1 -14
- package/src/hooks/session-autosave.sh +4 -2
- package/src/hooks/session-stop.sh +2 -2
- package/src/hooks/ts/capture-all-events.ts +3 -3
- package/src/hooks/ts/lib/metadata-extraction.ts +3 -2
- package/src/hooks/ts/lib/pai-paths.test.ts +1 -1
- package/src/hooks/ts/lib/sleep-poll-gate.test.ts +62 -0
- package/src/hooks/ts/lib/sleep-poll-gate.ts +77 -0
- package/src/hooks/ts/post-tool-use/capture-tool-output.ts +1 -1
- package/src/hooks/ts/post-tool-use/sync-todo-to-md.ts +1 -1
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +1 -1
- package/src/hooks/ts/pre-tool-use/block-sleep-poll.test.ts +44 -0
- package/src/hooks/ts/pre-tool-use/block-sleep-poll.ts +50 -0
- package/src/hooks/ts/session-end/capture-session-summary.ts +1 -1
- package/src/hooks/ts/session-start/initialize-session.ts +2 -2
- package/src/hooks/ts/session-start/load-core-context.ts +2 -2
- package/src/hooks/ts/session-start/load-project-context.ts +1 -1
- package/src/hooks/ts/stop/stop-hook.ts +1 -1
- package/src/hooks/ts/user-prompt/cleanup-session-files.ts +1 -1
- package/statusline-command.sh +26 -11
- package/dist/config-D1omG_UU.mjs.map +0 -1
- package/dist/daemon-B6hx1Ysz.mjs +0 -19
- package/dist/daemon-CmqJfeb9.mjs.map +0 -1
- package/dist/fallback-B_2ozQHw.mjs.map +0 -1
- package/dist/main-resolver-DYNxxNwJ.mjs.map +0 -1
- package/dist/main-resolver-Df65bPAi.mjs +0 -7
- package/dist/planner-DsZZEZCA.mjs.map +0 -1
- package/dist/program-B5miXqQa.mjs.map +0 -1
- package/dist/router-BXOf_e9e.mjs +0 -3
- package/dist/router-BdxdsAvs.mjs.map +0 -1
- package/dist/run-XaA-UPdW.mjs.map +0 -1
- package/dist/wakeup-D0uvqLsE.mjs.map +0 -1
- package/dist/work-queue-worker-BY7xn4pw.mjs +0 -11
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { a as paiHomePath, o as resolvePaiFile } from "./pai-home-CeOIYltV.mjs";
|
|
2
|
-
import { C as providerCostTier, E as resolveCapability, _ as expandHome, f as WORKER_CLASSES, h as classModelCapability, o as DEFAULT_LOG_DIR, p as WorkersConfigError, v as getProviderOrNative } from "./run-env-
|
|
2
|
+
import { C as providerCostTier, E as resolveCapability, _ as expandHome, f as WORKER_CLASSES, h as classModelCapability, o as DEFAULT_LOG_DIR, p as WorkersConfigError, v as getProviderOrNative } from "./run-env-kbWVwUvU.mjs";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -401,4 +401,4 @@ function stopProxy(logDir) {
|
|
|
401
401
|
|
|
402
402
|
//#endregion
|
|
403
403
|
export { statusPath as _, clearCooldown as a, probeQuota as c, resolveTarget as d, setCooldown as f, panesDir as g, ledgerPath as h, capabilityForRun as i, quotaSkipThreshold as l, eventsPath as m, ensureProxyRunning as n, isQuotaFailure as o, ensureNoMcpConfig as p, stopProxy as r, nextAutoProvider as s, DEFAULT_PROXY_PORT as t, resolveCapabilityRun as u, workersLogDir as v };
|
|
404
|
-
//# sourceMappingURL=server-
|
|
404
|
+
//# sourceMappingURL=server-B0vBmfJt.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server-CzqHGbKt.mjs","names":[],"sources":["../src/workers/paths.ts","../src/workers/routing.ts","../src/workers/proxy/server.ts"],"sourcesContent":["/**\n * paths.ts — 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 { existsSync, mkdirSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { expandHome, DEFAULT_LOG_DIR, type WorkersConfig } from \"./config.js\";\nimport { paiHomePath, resolvePaiFile } from \"../config/pai-home.js\";\n\n/**\n * Absolute logDir for the given config. An explicit `logDir` in workers.yaml\n * is honored verbatim (the user chose it). Otherwise this resolves like every\n * other PAI_HOME file: PAI_HOME/logs/workers if it exists, else the old\n * ~/.claude/logs/workers (with a one-time notice), else the new path.\n */\nexport function workersLogDir(config: WorkersConfig): string {\n const expanded = expandHome(config.logDir);\n if (config.logDir !== DEFAULT_LOG_DIR) return expanded;\n return resolvePaiFile(paiHomePath(\"logs\", \"workers\"), [expanded], \"pai config migrate --logs\");\n}\n\n/** The strict empty MCP config for headless runs, written on demand. */\nexport function ensureNoMcpConfig(logDir: string): string {\n const path = noMcpConfigPath(logDir);\n if (!existsSync(path)) {\n mkdirSync(logDir, { recursive: true });\n writeFileSync(path, '{ \"mcpServers\": {} }\\n', \"utf8\");\n }\n return path;\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 — 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 * routing.ts — provider selection: flag > class > active (possibly \"auto\").\n *\n * Auto-routing walks `workers.routing.order` — or the class's own `order` —\n * and takes the first provider that is enabled, out of cooldown, (when it\n * defines a quotaProbe) under its quotaSkipAt threshold, and — when the class\n * constrains it — within `maxCostTier` and carrying all `requireTags`. A run\n * that dies of a quota/rate error puts its provider in cooldown for\n * cooldownMinutes; when that happens before the first tool call and\n * retryOnQuota is set, the runner restarts the same task on the next provider\n * (ledger: WORKER-REROUTE).\n *\n * An explicit --provider or a class mapping that pins a provider always\n * bypasses all of this.\n */\n\nimport { execFileSync } from \"node:child_process\";\nimport { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport {\n type WorkerEngine,\n type WorkerProvider,\n type WorkersConfig,\n type ProviderTag,\n WORKER_CLASSES,\n WorkersConfigError,\n classModelCapability,\n providerCostTier,\n getProviderOrNative,\n resolveCapability,\n} from \"./config.js\";\nimport { routingStatePath } from \"./paths.js\";\n\nconst QUOTA_SKIP_DEFAULT = 95;\nconst PROBE_TIMEOUT_MS = 10_000;\n\nexport interface RoutingState {\n /** provider name → ISO timestamp when its cooldown ends */\n cooldowns: Record<string, string>;\n}\n\nexport function readRoutingState(logDir: string): RoutingState {\n const path = routingStatePath(logDir);\n if (!existsSync(path)) return { cooldowns: {} };\n try {\n const parsed = JSON.parse(readFileSync(path, \"utf8\")) as RoutingState;\n return { cooldowns: parsed.cooldowns ?? {} };\n } catch {\n return { cooldowns: {} };\n }\n}\n\nexport function writeRoutingState(logDir: string, state: RoutingState): void {\n const path = routingStatePath(logDir);\n const dir = dirname(path);\n if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n const tmp = `${path}.tmp`;\n writeFileSync(tmp, JSON.stringify(state, null, 2) + \"\\n\", \"utf8\");\n renameSync(tmp, path);\n}\n\nexport function cooldownRemaining(\n state: RoutingState,\n provider: string,\n now: Date = new Date()\n): number {\n const end = state.cooldowns[provider];\n if (!end) return 0;\n const ms = Date.parse(end) - now.getTime();\n return ms > 0 ? ms : 0;\n}\n\nexport function setCooldown(\n logDir: string,\n provider: string,\n minutes: number,\n now: Date = new Date()\n): void {\n const state = readRoutingState(logDir);\n state.cooldowns[provider] = new Date(now.getTime() + minutes * 60_000).toISOString();\n writeRoutingState(logDir, state);\n}\n\nexport function clearCooldown(logDir: string, provider: string): void {\n const state = readRoutingState(logDir);\n if (!(provider in state.cooldowns)) return;\n delete state.cooldowns[provider];\n writeRoutingState(logDir, state);\n}\n\n/**\n * Run a provider's quotaProbe and return its percentage (0–100), or null when\n * there is no probe or it printed nothing usable. A probe must never break\n * routing: failures read as \"unknown\", not as \"full\".\n */\nexport function probeQuota(provider: WorkerProvider): number | null {\n if (!provider.quotaProbe) return null;\n try {\n const out = execFileSync(\"/bin/sh\", [\"-c\", provider.quotaProbe], {\n timeout: PROBE_TIMEOUT_MS,\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n });\n const m = out.match(/(\\d+(?:\\.\\d+)?)/);\n if (!m) return null;\n return Math.min(100, Math.max(0, Math.round(parseFloat(m[1]))));\n } catch {\n return null;\n }\n}\n\nexport function quotaSkipThreshold(provider: WorkerProvider): number {\n return provider.quotaSkipAt ?? QUOTA_SKIP_DEFAULT;\n}\n\n/** Does this provider exceed its quota threshold right now? */\nexport function quotaExceeded(provider: WorkerProvider): boolean {\n const used = probeQuota(provider);\n return used !== null && used >= quotaSkipThreshold(provider);\n}\n\nexport interface ResolvedTarget {\n providerName: string;\n provider: WorkerProvider;\n /** Model alias from the class (\"glm/fast\" → \"fast\"), null = provider default. */\n modelAlias: string | null;\n /** MCP servers (or set names) from the class target, null when it sets none. */\n classMcp: string[] | null;\n /** How the provider was chosen — names the bypass rule for rerouting. */\n via: \"flag\" | \"class\" | \"active\" | \"auto\";\n}\n\nexport class NoProviderError extends WorkersConfigError {}\n\nfunction mustExist(config: WorkersConfig, name: string): WorkerProvider {\n const p = getProviderOrNative(config, name);\n if (!p) {\n throw new NoProviderError(\n `no worker provider named \"${name}\". Configured: ` +\n `${Object.keys(config.providers).join(\", \") || \"(none)\"}.` +\n `\\nAdd one with: pai worker providers add <name> --base-url <url> --key-file <path> --model <model>`\n );\n }\n return p;\n}\n\nfunction mustBeRunnable(name: string, p: WorkerProvider): WorkerProvider {\n if (!p.enabled) {\n throw new NoProviderError(\n `provider \"${name}\" is disabled. Enable it with: pai worker providers enable ${name}`\n );\n }\n return p;\n}\n\n/** Why one provider of a routing order did not qualify (message fragment). */\nfunction exclusionReason(\n config: WorkersConfig,\n state: RoutingState,\n name: string,\n cls?: { maxCostTier?: number; requireTags?: string[] }\n): string | null {\n const p = getProviderOrNative(config, name);\n if (!p) return \"not configured\";\n if (!p.enabled) return \"disabled\";\n if (cooldownRemaining(state, name) > 0) return \"cooldown\";\n if (quotaExceeded(p)) return \"quota\";\n const tier = providerCostTier(p);\n if (cls?.maxCostTier !== undefined && tier > cls.maxCostTier) {\n return `cost tier ${tier} > max ${cls.maxCostTier}`;\n }\n if (cls?.requireTags?.length) {\n const have = p.tags ?? [];\n const missing = cls.requireTags.filter((t) => !have.includes(t as ProviderTag));\n if (missing.length) return `missing tags: ${missing.join(\", \")}`;\n }\n return null;\n}\n\n/**\n * Resolve which provider (and model alias) a run uses.\n *\n * @param flagProvider --provider value, highest precedence\n * @param className --class value (the old --role), looked up in workers.classes\n */\nexport function resolveTarget(\n config: WorkersConfig,\n logDir: string,\n opts: { flagProvider?: string; className?: string } = {}\n): ResolvedTarget {\n if (opts.flagProvider) {\n return {\n providerName: opts.flagProvider,\n provider: mustBeRunnable(opts.flagProvider, mustExist(config, opts.flagProvider)),\n modelAlias: null,\n classMcp: null,\n via: \"flag\",\n };\n }\n\n // class constraints (present whether or not the target pins a provider)\n const clsTarget = opts.className ? config.classes[opts.className] : undefined;\n const cls =\n typeof clsTarget === \"object\" && clsTarget !== null ? clsTarget : undefined;\n\n if (opts.className && clsTarget !== undefined && typeof clsTarget !== \"object\") {\n const [name, alias] = clsTarget.split(\"/\");\n return {\n providerName: name,\n provider: mustBeRunnable(name, mustExist(config, name)),\n modelAlias: alias ?? null,\n classMcp: null,\n via: \"class\",\n };\n }\n if (opts.className && cls?.provider) {\n const provider = mustBeRunnable(cls.provider, mustExist(config, cls.provider));\n return {\n providerName: cls.provider,\n provider,\n modelAlias: null,\n classMcp: cls.mcp ?? null,\n via: \"class\",\n };\n }\n if (opts.className && clsTarget === undefined) {\n // a standard class may simply be unconfigured (it then routes like a run\n // without a class); anything else is a typo and must not pass silently\n if (!(WORKER_CLASSES as readonly string[]).includes(opts.className)) {\n throw new NoProviderError(\n `no class named \"${opts.className}\". Standard classes: ${WORKER_CLASSES.join(\", \")}.` +\n `Defined: ${Object.keys(config.classes).join(\", \") || \"(none)\"}.` +\n `\\nSet one with: pai worker classes set ${opts.className}=<provider[/alias]>`\n );\n }\n }\n\n if (config.active !== \"auto\") {\n const name = config.active;\n if (!name) {\n throw new NoProviderError(\n `no active worker provider. Add one with: ` +\n `pai worker providers add <name> --base-url <url> --key-file <path> --model <model>` +\n `\\n(or point one that exists at it: pai worker providers use <name>)`\n );\n }\n return {\n providerName: name,\n provider: mustBeRunnable(name, mustExist(config, name)),\n modelAlias: null,\n classMcp: null,\n via: \"active\",\n };\n }\n\n // auto: first provider in (class or global) order that is enabled,\n // cooled-down-free, under quota and within the class constraints\n const state = readRoutingState(logDir);\n const order = cls?.order ?? config.routing.order;\n const excluded: string[] = [];\n for (const name of order) {\n const p = getProviderOrNative(config, name);\n const why = exclusionReason(config, state, name, cls);\n if (why) {\n excluded.push(`${name}: ${why}`);\n continue;\n }\n // exclusionReason already returned null (no exclusion) above, which is\n // only possible when getProviderOrNative found a real provider\n return { providerName: name, provider: p!, modelAlias: null, classMcp: cls?.mcp ?? null, via: \"auto\" };\n }\n throw new NoProviderError(\n `auto-routing found no usable provider${opts.className ? ` for class \"${opts.className}\"` : \"\"} ` +\n `(order: [${order.join(\", \")}]).` +\n `${excluded.length ? `\\nExcluded: ${excluded.join(\"; \")}.` : \"\"}` +\n `\\nClear a cooldown with: pai worker providers enable <name>; widen the class with: pai worker classes set ${opts.className ?? \"<class>\"}=<target>`\n );\n}\n\n/**\n * Next provider after `from` in auto order, applying the same filters.\n * Used by rerouting; null when the order is exhausted.\n */\nexport function nextAutoProvider(\n config: WorkersConfig,\n logDir: string,\n from: string,\n now: Date = new Date()\n): string | null {\n const state = readRoutingState(logDir);\n const order = config.routing.order;\n const start = order.indexOf(from);\n for (let i = start + 1; i < order.length; i++) {\n const name = order[i];\n const p = getProviderOrNative(config, name);\n if (!p || !p.enabled) continue;\n const end = state.cooldowns[name];\n if (end && Date.parse(end) > now.getTime()) continue;\n if (quotaExceeded(p)) continue;\n return name;\n }\n return null;\n}\n\n/**\n * Was this failure a quota/rate failure? Detected from the result text the\n * endpoint produced (HTTP status is not visible in the stream events).\n */\nexport function isQuotaFailure(resultText: string): boolean {\n return /usage limit reached|rate limit|quota/i.test(resultText);\n}\n\nexport interface CapabilityRunResolution {\n providerName: string;\n provider: WorkerProvider;\n model: string;\n engine: WorkerEngine;\n fellBack: boolean;\n capability: string;\n}\n\n/**\n * Whether a run should resolve through `resolveCapability` instead of\n * `resolveTarget`: an explicit `--capability` always does. A `--class`\n * whose implied capability is not \"default\" (e.g. `image`) does too, but\n * only when the class itself names no provider — a class already pinned to\n * one (`image: glm/image`) always wins unchanged, same as any other class.\n */\nexport function capabilityForRun(\n config: WorkersConfig,\n opts: { capabilityFlag?: string; className?: string }\n): string | null {\n if (opts.capabilityFlag) return opts.capabilityFlag;\n if (!opts.className) return null;\n const capability = classModelCapability(opts.className);\n if (capability === \"default\") return null;\n const clsTarget = config.classes[opts.className];\n const namesProvider = typeof clsTarget === \"string\" ? true : Boolean(clsTarget?.provider);\n return namesProvider ? null : capability;\n}\n\n/**\n * resolveCapability, turned into a runnable provider (NoProviderError-shaped\n * on failure). Unlike resolveCapability itself — which falls back to the\n * active provider's default model so `describeCapabilities` has something to\n * report — an actual run refuses the fallback: silently running a normal\n * model on a prompt written for a capability it does not have (an image\n * prompt fed to a text model, say) produces a confusing non-answer instead\n * of a clear error naming the config key to set.\n */\nexport function resolveCapabilityRun(config: WorkersConfig, capability: string): CapabilityRunResolution {\n const r = resolveCapability(config, capability);\n if (r.fellBack) {\n throw new NoProviderError(\n `no provider declares the \"${capability}\" capability — configure one with: ` +\n `pai worker capability ${capability} <provider>[,<provider>…], or ` +\n `pai worker model ${capability} <model-id> --provider <name>`\n );\n }\n // \"image\" specifically promises real pixel output — a provider whose only\n // claim to it is a `models.image` entry from the classes-based idiom\n // (`image: glm/image`, a plain model choice on an unchanged claude/codex\n // engine) cannot deliver that through this cross-provider run path, so a\n // run refuses it rather than spawning claude on a prompt it cannot answer.\n if (capability === \"image\" && r.engine !== \"image\") {\n throw new NoProviderError(\n `provider \"${r.provider}\" has no image engine (it would run \"${r.model}\" on its normal ` +\n `${r.engine} engine, not generate an image) — configure an engine=image provider with: ` +\n `pai worker providers add <name> --engine image --url <url> --key <key> --model <id>, then ` +\n `pai worker capability image <name>`\n );\n }\n const provider = getProviderOrNative(config, r.provider);\n if (!provider) {\n throw new NoProviderError(`capability \"${capability}\" resolved to unknown provider \"${r.provider}\"`);\n }\n return {\n providerName: r.provider,\n provider,\n model: r.model,\n engine: r.engine,\n fellBack: r.fellBack,\n capability,\n };\n}\n","/**\n * server.ts — the PAI worker proxy: Anthropic Messages API on the front,\n * OpenAI Chat Completions on the back, loopback only.\n *\n * One proxy serves every OpenAI-protocol provider: Claude Code points\n * ANTHROPIC_BASE_URL at `http://127.0.0.1:8797/<provider>` and the provider\n * name in the path selects the upstream (`upstreamUrl` + `keyFile` from the\n * workers config, re-read per request so config edits apply without a\n * restart). `run` starts the proxy on demand (detached, pid file under the\n * logDir) and `pai worker proxy stop` stops it again.\n *\n * All translation lives in translate.ts; this file is only HTTP plumbing.\n */\n\nimport { spawn } from \"node:child_process\";\nimport { createServer, type Server } from \"node:http\";\nimport { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from \"node:fs\";\nimport { connect } from \"node:net\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { readWorkersSection, resolveProviderKey, type WorkerProvider } from \"../config.js\";\nimport { workersLogDir } from \"../paths.js\";\nimport {\n anthropicError,\n anthropicToOpenAi,\n openAiToAnthropic,\n OpenAiStreamTranslator,\n type AnthropicRequest,\n} from \"./translate.js\";\n\nexport const DEFAULT_PROXY_PORT = 8797;\nconst HOST = \"127.0.0.1\";\n\n/** How providers are looked up — swapped out by the tests. */\nexport type ProviderResolver = () => Record<string, WorkerProvider>;\n\nconst configProviders: ProviderResolver = () => readWorkersSection().workers.providers;\n\nexport function proxyPidPath(logDir: string): string {\n return join(logDir, \"proxy.pid\");\n}\n\n// ---------------------------------------------------------------------------\n// HTTP server\n// ---------------------------------------------------------------------------\n\ninterface ProxyServerOptions {\n port?: number;\n resolveProvider?: ProviderResolver;\n /** Test hook: called with every (translated) upstream request body. */\n onUpstreamRequest?: (url: string, body: unknown) => void;\n}\n\n/**\n * Create (but not start) the proxy server. Routes:\n * GET /healthz → 200 ok\n * POST /<provider>/v1/messages (and /<provider>/messages) → translated call\n */\nexport function createProxyServer(opts: ProxyServerOptions = {}): Server {\n const resolveProvider = opts.resolveProvider ?? configProviders;\n return createServer((req, res) => {\n const url = new URL(req.url ?? \"/\", `http://${HOST}`);\n if (req.method === \"GET\" && url.pathname === \"/healthz\") {\n res.writeHead(200, { \"content-type\": \"text/plain\" });\n res.end(\"ok\");\n return;\n }\n const m = url.pathname.match(/^\\/([a-zA-Z0-9_-]+)\\/(v1\\/)?messages$/);\n if (req.method !== \"POST\" || !m) {\n res.writeHead(404, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(anthropicError(404, `no such route: ${req.method} ${url.pathname}`)));\n return;\n }\n const providerName = m[1];\n const chunks: Buffer[] = [];\n req.on(\"data\", (c: Buffer) => chunks.push(c));\n req.on(\"end\", () => {\n void handleMessages(providerName, Buffer.concat(chunks).toString(\"utf8\"), resolveProvider, res, opts);\n });\n req.on(\"error\", () => {\n res.writeHead(400, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(anthropicError(400, \"request body read failed\")));\n });\n });\n}\n\nasync function handleMessages(\n providerName: string,\n bodyText: string,\n resolveProvider: ProviderResolver,\n res: import(\"node:http\").ServerResponse,\n opts: ProxyServerOptions\n): Promise<void> {\n const fail = (status: number, message: string) => {\n res.writeHead(status, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(anthropicError(status, JSON.stringify({ message }))));\n };\n let reqBody: AnthropicRequest;\n try {\n reqBody = JSON.parse(bodyText) as AnthropicRequest;\n } catch {\n fail(400, \"request body is not valid JSON\");\n return;\n }\n const provider = resolveProvider()[providerName];\n if (!provider) {\n fail(404, `no worker provider named \"${providerName}\"`);\n return;\n }\n if (provider.protocol !== \"openai\" || !provider.upstreamUrl) {\n fail(400, `provider \"${providerName}\" is not an openai-protocol provider`);\n return;\n }\n\n const model = provider.models.default;\n const openaiBody = anthropicToOpenAi(reqBody, model);\n opts.onUpstreamRequest?.(`${provider.upstreamUrl}/chat/completions`, openaiBody);\n\n let upstream: Response;\n try {\n const headers: Record<string, string> = { \"content-type\": \"application/json\" };\n let token: string | null;\n try {\n token = resolveProviderKey(provider);\n } catch (e) {\n fail(500, e instanceof Error ? e.message : String(e));\n return;\n }\n if (token) headers.authorization = `Bearer ${token}`;\n upstream = await fetch(`${provider.upstreamUrl}/chat/completions`, {\n method: \"POST\",\n headers,\n body: JSON.stringify(openaiBody),\n });\n } catch (e) {\n fail(502, `upstream unreachable: ${e instanceof Error ? e.message : String(e)}`);\n return;\n }\n\n if (!upstream.ok) {\n const text = await upstream.text().catch(() => \"\");\n res.writeHead(upstream.status, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(anthropicError(upstream.status, text)));\n return;\n }\n\n if (reqBody.stream) {\n res.writeHead(200, {\n \"content-type\": \"text/event-stream\",\n \"cache-control\": \"no-cache\",\n connection: \"keep-alive\",\n });\n const translator = new OpenAiStreamTranslator(model);\n let buf = \"\";\n if (!upstream.body) {\n res.end(translator.finish());\n return;\n }\n const reader = upstream.body.getReader();\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n buf += new TextDecoder().decode(value, { stream: true });\n let nl: number;\n while ((nl = buf.indexOf(\"\\n\")) >= 0) {\n const line = buf.slice(0, nl).trim();\n buf = buf.slice(nl + 1);\n if (!line.startsWith(\"data:\")) continue;\n const payload = line.slice(5).trim();\n if (payload === \"[DONE]\") {\n res.write(translator.finish());\n } else {\n try {\n res.write(translator.feed(JSON.parse(payload) as Parameters<typeof translator.feed>[0]));\n } catch {\n // skip a malformed chunk rather than kill the stream\n }\n }\n }\n }\n } finally {\n res.write(translator.finish()); // no-op when [DONE] already finished it\n res.end();\n }\n return;\n }\n\n const json = (await upstream.json().catch(() => null)) as unknown;\n if (!json) {\n fail(502, \"upstream returned a non-JSON body\");\n return;\n }\n res.writeHead(200, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(openAiToAnthropic(json as Parameters<typeof openAiToAnthropic>[0], model)));\n}\n\n// ---------------------------------------------------------------------------\n// Start / stop\n// ---------------------------------------------------------------------------\n\n/** Listen on host:port (loopback). Resolves with the bound port. */\nexport function listenProxy(server: Server, port = DEFAULT_PROXY_PORT): Promise<number> {\n return new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(port, HOST, () => {\n const addr = server.address();\n resolve(typeof addr === \"object\" && addr ? addr.port : port);\n });\n });\n}\n\n/** True when something already accepts connections on the port. */\nexport function proxyListening(port = DEFAULT_PROXY_PORT, timeoutMs = 400): Promise<boolean> {\n return new Promise((resolve) => {\n const sock = connect({ port, host: HOST });\n const done = (ok: boolean) => {\n sock.removeAllListeners();\n sock.destroy();\n resolve(ok);\n };\n sock.setTimeout(timeoutMs, () => done(false));\n sock.once(\"connect\", () => done(true));\n sock.once(\"error\", () => done(false));\n });\n}\n\n/**\n * Where the standalone proxy artefact lives, found from this module's own\n * location (dist/cli bundle → ../hooks/, dev checkout → <repo>/dist/hooks).\n */\nexport function standaloneProxyPath(): string | null {\n let dir = dirname(fileURLToPath(import.meta.url));\n for (let i = 0; i < 6; i++) {\n for (const cand of [\n join(dir, \"worker-proxy.mjs\"),\n join(dir, \"hooks\", \"worker-proxy.mjs\"),\n join(dir, \"dist\", \"hooks\", \"worker-proxy.mjs\"),\n ]) {\n if (existsSync(cand)) return cand;\n }\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/**\n * Ensure a proxy is listening on the port; start the detached standalone when\n * nothing answers. Returns the base URL (`http://127.0.0.1:<port>`).\n */\nexport async function ensureProxyRunning(port: number, logDir: string): Promise<string> {\n if (await proxyListening(port)) return `http://${HOST}:${port}`;\n const script = standaloneProxyPath();\n if (!script) {\n throw new Error(\n `the PAI worker proxy is not running and its build was not found — ` +\n `run \\`bun run build\\` (or start one with: pai worker proxy --port ${port})`\n );\n }\n if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });\n const child = spawn(process.execPath, [script, \"--port\", String(port)], {\n detached: true,\n stdio: \"ignore\",\n });\n child.unref();\n writeFileSync(proxyPidPath(logDir), `${child.pid}\\n`, \"utf8\");\n for (let i = 0; i < 40; i++) {\n if (await proxyListening(port)) return `http://${HOST}:${port}`;\n await new Promise((r) => setTimeout(r, 250));\n }\n throw new Error(`the PAI worker proxy did not come up on port ${port} (see ${proxyPidPath(logDir)})`);\n}\n\n/** `pai worker proxy stop`: SIGTERM the pid from the pid file. */\nexport function stopProxy(logDir: string): string {\n const path = proxyPidPath(logDir);\n if (!existsSync(path)) return \"no proxy pid file — nothing to stop\";\n const pid = parseInt(readFileSync(path, \"utf8\").trim(), 10);\n unlinkSync(path);\n if (!Number.isFinite(pid)) return \"stale proxy pid file removed\";\n try {\n process.kill(pid, \"SIGTERM\");\n } catch {\n return `proxy pid ${pid} was not running (pid file removed)`;\n }\n return `proxy pid ${pid} stopped`;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"server-B0vBmfJt.mjs","names":[],"sources":["../src/workers/paths.ts","../src/workers/routing.ts","../src/workers/proxy/server.ts"],"sourcesContent":["/**\n * paths.ts — 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 { existsSync, mkdirSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { expandHome, DEFAULT_LOG_DIR, type WorkersConfig } from \"./config.js\";\nimport { paiHomePath, resolvePaiFile } from \"../config/pai-home.js\";\n\n/**\n * Absolute logDir for the given config. An explicit `logDir` in workers.yaml\n * is honored verbatim (the user chose it). Otherwise this resolves like every\n * other PAI_HOME file: PAI_HOME/logs/workers if it exists, else the old\n * ~/.claude/logs/workers (with a one-time notice), else the new path.\n */\nexport function workersLogDir(config: WorkersConfig): string {\n const expanded = expandHome(config.logDir);\n if (config.logDir !== DEFAULT_LOG_DIR) return expanded;\n return resolvePaiFile(paiHomePath(\"logs\", \"workers\"), [expanded], \"pai config migrate --logs\");\n}\n\n/** The strict empty MCP config for headless runs, written on demand. */\nexport function ensureNoMcpConfig(logDir: string): string {\n const path = noMcpConfigPath(logDir);\n if (!existsSync(path)) {\n mkdirSync(logDir, { recursive: true });\n writeFileSync(path, '{ \"mcpServers\": {} }\\n', \"utf8\");\n }\n return path;\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 — 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 * routing.ts — provider selection: flag > class > active (possibly \"auto\").\n *\n * Auto-routing walks `workers.routing.order` — or the class's own `order` —\n * and takes the first provider that is enabled, out of cooldown, (when it\n * defines a quotaProbe) under its quotaSkipAt threshold, and — when the class\n * constrains it — within `maxCostTier` and carrying all `requireTags`. A run\n * that dies of a quota/rate error puts its provider in cooldown for\n * cooldownMinutes; when that happens before the first tool call and\n * retryOnQuota is set, the runner restarts the same task on the next provider\n * (ledger: WORKER-REROUTE).\n *\n * An explicit --provider or a class mapping that pins a provider always\n * bypasses all of this.\n */\n\nimport { execFileSync } from \"node:child_process\";\nimport { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport {\n type WorkerEngine,\n type WorkerProvider,\n type WorkersConfig,\n type ProviderTag,\n WORKER_CLASSES,\n WorkersConfigError,\n classModelCapability,\n providerCostTier,\n getProviderOrNative,\n resolveCapability,\n} from \"./config.js\";\nimport { routingStatePath } from \"./paths.js\";\n\nconst QUOTA_SKIP_DEFAULT = 95;\nconst PROBE_TIMEOUT_MS = 10_000;\n\nexport interface RoutingState {\n /** provider name → ISO timestamp when its cooldown ends */\n cooldowns: Record<string, string>;\n}\n\nexport function readRoutingState(logDir: string): RoutingState {\n const path = routingStatePath(logDir);\n if (!existsSync(path)) return { cooldowns: {} };\n try {\n const parsed = JSON.parse(readFileSync(path, \"utf8\")) as RoutingState;\n return { cooldowns: parsed.cooldowns ?? {} };\n } catch {\n return { cooldowns: {} };\n }\n}\n\nexport function writeRoutingState(logDir: string, state: RoutingState): void {\n const path = routingStatePath(logDir);\n const dir = dirname(path);\n if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n const tmp = `${path}.tmp`;\n writeFileSync(tmp, JSON.stringify(state, null, 2) + \"\\n\", \"utf8\");\n renameSync(tmp, path);\n}\n\nexport function cooldownRemaining(\n state: RoutingState,\n provider: string,\n now: Date = new Date()\n): number {\n const end = state.cooldowns[provider];\n if (!end) return 0;\n const ms = Date.parse(end) - now.getTime();\n return ms > 0 ? ms : 0;\n}\n\nexport function setCooldown(\n logDir: string,\n provider: string,\n minutes: number,\n now: Date = new Date()\n): void {\n const state = readRoutingState(logDir);\n state.cooldowns[provider] = new Date(now.getTime() + minutes * 60_000).toISOString();\n writeRoutingState(logDir, state);\n}\n\nexport function clearCooldown(logDir: string, provider: string): void {\n const state = readRoutingState(logDir);\n if (!(provider in state.cooldowns)) return;\n delete state.cooldowns[provider];\n writeRoutingState(logDir, state);\n}\n\n/**\n * Run a provider's quotaProbe and return its percentage (0–100), or null when\n * there is no probe or it printed nothing usable. A probe must never break\n * routing: failures read as \"unknown\", not as \"full\".\n */\nexport function probeQuota(provider: WorkerProvider): number | null {\n if (!provider.quotaProbe) return null;\n try {\n const out = execFileSync(\"/bin/sh\", [\"-c\", provider.quotaProbe], {\n timeout: PROBE_TIMEOUT_MS,\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n });\n const m = out.match(/(\\d+(?:\\.\\d+)?)/);\n if (!m) return null;\n return Math.min(100, Math.max(0, Math.round(parseFloat(m[1]))));\n } catch {\n return null;\n }\n}\n\nexport function quotaSkipThreshold(provider: WorkerProvider): number {\n return provider.quotaSkipAt ?? QUOTA_SKIP_DEFAULT;\n}\n\n/** Does this provider exceed its quota threshold right now? */\nexport function quotaExceeded(provider: WorkerProvider): boolean {\n const used = probeQuota(provider);\n return used !== null && used >= quotaSkipThreshold(provider);\n}\n\nexport interface ResolvedTarget {\n providerName: string;\n provider: WorkerProvider;\n /** Model alias from the class (\"glm/fast\" → \"fast\"), null = provider default. */\n modelAlias: string | null;\n /** MCP servers (or set names) from the class target, null when it sets none. */\n classMcp: string[] | null;\n /** How the provider was chosen — names the bypass rule for rerouting. */\n via: \"flag\" | \"class\" | \"active\" | \"auto\";\n}\n\nexport class NoProviderError extends WorkersConfigError {}\n\nfunction mustExist(config: WorkersConfig, name: string): WorkerProvider {\n const p = getProviderOrNative(config, name);\n if (!p) {\n throw new NoProviderError(\n `no worker provider named \"${name}\". Configured: ` +\n `${Object.keys(config.providers).join(\", \") || \"(none)\"}.` +\n `\\nAdd one with: pai worker providers add <name> --base-url <url> --key-file <path> --model <model>`\n );\n }\n return p;\n}\n\nfunction mustBeRunnable(name: string, p: WorkerProvider): WorkerProvider {\n if (!p.enabled) {\n throw new NoProviderError(\n `provider \"${name}\" is disabled. Enable it with: pai worker providers enable ${name}`\n );\n }\n return p;\n}\n\n/** Why one provider of a routing order did not qualify (message fragment). */\nfunction exclusionReason(\n config: WorkersConfig,\n state: RoutingState,\n name: string,\n cls?: { maxCostTier?: number; requireTags?: string[] }\n): string | null {\n const p = getProviderOrNative(config, name);\n if (!p) return \"not configured\";\n if (!p.enabled) return \"disabled\";\n if (cooldownRemaining(state, name) > 0) return \"cooldown\";\n if (quotaExceeded(p)) return \"quota\";\n const tier = providerCostTier(p);\n if (cls?.maxCostTier !== undefined && tier > cls.maxCostTier) {\n return `cost tier ${tier} > max ${cls.maxCostTier}`;\n }\n if (cls?.requireTags?.length) {\n const have = p.tags ?? [];\n const missing = cls.requireTags.filter((t) => !have.includes(t as ProviderTag));\n if (missing.length) return `missing tags: ${missing.join(\", \")}`;\n }\n return null;\n}\n\n/**\n * Resolve which provider (and model alias) a run uses.\n *\n * @param flagProvider --provider value, highest precedence\n * @param className --class value (the old --role), looked up in workers.classes\n */\nexport function resolveTarget(\n config: WorkersConfig,\n logDir: string,\n opts: { flagProvider?: string; className?: string } = {}\n): ResolvedTarget {\n if (opts.flagProvider) {\n return {\n providerName: opts.flagProvider,\n provider: mustBeRunnable(opts.flagProvider, mustExist(config, opts.flagProvider)),\n modelAlias: null,\n classMcp: null,\n via: \"flag\",\n };\n }\n\n // class constraints (present whether or not the target pins a provider)\n const clsTarget = opts.className ? config.classes[opts.className] : undefined;\n const cls =\n typeof clsTarget === \"object\" && clsTarget !== null ? clsTarget : undefined;\n\n if (opts.className && clsTarget !== undefined && typeof clsTarget !== \"object\") {\n const [name, alias] = clsTarget.split(\"/\");\n return {\n providerName: name,\n provider: mustBeRunnable(name, mustExist(config, name)),\n modelAlias: alias ?? null,\n classMcp: null,\n via: \"class\",\n };\n }\n if (opts.className && cls?.provider) {\n const provider = mustBeRunnable(cls.provider, mustExist(config, cls.provider));\n return {\n providerName: cls.provider,\n provider,\n modelAlias: null,\n classMcp: cls.mcp ?? null,\n via: \"class\",\n };\n }\n if (opts.className && clsTarget === undefined) {\n // a standard class may simply be unconfigured (it then routes like a run\n // without a class); anything else is a typo and must not pass silently\n if (!(WORKER_CLASSES as readonly string[]).includes(opts.className)) {\n throw new NoProviderError(\n `no class named \"${opts.className}\". Standard classes: ${WORKER_CLASSES.join(\", \")}.` +\n `Defined: ${Object.keys(config.classes).join(\", \") || \"(none)\"}.` +\n `\\nSet one with: pai worker classes set ${opts.className}=<provider[/alias]>`\n );\n }\n }\n\n if (config.active !== \"auto\") {\n const name = config.active;\n if (!name) {\n throw new NoProviderError(\n `no active worker provider. Add one with: ` +\n `pai worker providers add <name> --base-url <url> --key-file <path> --model <model>` +\n `\\n(or point one that exists at it: pai worker providers use <name>)`\n );\n }\n return {\n providerName: name,\n provider: mustBeRunnable(name, mustExist(config, name)),\n modelAlias: null,\n classMcp: null,\n via: \"active\",\n };\n }\n\n // auto: first provider in (class or global) order that is enabled,\n // cooled-down-free, under quota and within the class constraints\n const state = readRoutingState(logDir);\n const order = cls?.order ?? config.routing.order;\n const excluded: string[] = [];\n for (const name of order) {\n const p = getProviderOrNative(config, name);\n const why = exclusionReason(config, state, name, cls);\n if (why) {\n excluded.push(`${name}: ${why}`);\n continue;\n }\n // exclusionReason already returned null (no exclusion) above, which is\n // only possible when getProviderOrNative found a real provider\n return { providerName: name, provider: p!, modelAlias: null, classMcp: cls?.mcp ?? null, via: \"auto\" };\n }\n throw new NoProviderError(\n `auto-routing found no usable provider${opts.className ? ` for class \"${opts.className}\"` : \"\"} ` +\n `(order: [${order.join(\", \")}]).` +\n `${excluded.length ? `\\nExcluded: ${excluded.join(\"; \")}.` : \"\"}` +\n `\\nClear a cooldown with: pai worker providers enable <name>; widen the class with: pai worker classes set ${opts.className ?? \"<class>\"}=<target>`\n );\n}\n\n/**\n * Next provider after `from` in auto order, applying the same filters.\n * Used by rerouting; null when the order is exhausted.\n */\nexport function nextAutoProvider(\n config: WorkersConfig,\n logDir: string,\n from: string,\n now: Date = new Date()\n): string | null {\n const state = readRoutingState(logDir);\n const order = config.routing.order;\n const start = order.indexOf(from);\n for (let i = start + 1; i < order.length; i++) {\n const name = order[i];\n const p = getProviderOrNative(config, name);\n if (!p || !p.enabled) continue;\n const end = state.cooldowns[name];\n if (end && Date.parse(end) > now.getTime()) continue;\n if (quotaExceeded(p)) continue;\n return name;\n }\n return null;\n}\n\n/**\n * Was this failure a quota/rate failure? Detected from the result text the\n * endpoint produced (HTTP status is not visible in the stream events).\n */\nexport function isQuotaFailure(resultText: string): boolean {\n return /usage limit reached|rate limit|quota/i.test(resultText);\n}\n\nexport interface CapabilityRunResolution {\n providerName: string;\n provider: WorkerProvider;\n model: string;\n engine: WorkerEngine;\n fellBack: boolean;\n capability: string;\n}\n\n/**\n * Whether a run should resolve through `resolveCapability` instead of\n * `resolveTarget`: an explicit `--capability` always does. A `--class`\n * whose implied capability is not \"default\" (e.g. `image`) does too, but\n * only when the class itself names no provider — a class already pinned to\n * one (`image: glm/image`) always wins unchanged, same as any other class.\n */\nexport function capabilityForRun(\n config: WorkersConfig,\n opts: { capabilityFlag?: string; className?: string }\n): string | null {\n if (opts.capabilityFlag) return opts.capabilityFlag;\n if (!opts.className) return null;\n const capability = classModelCapability(opts.className);\n if (capability === \"default\") return null;\n const clsTarget = config.classes[opts.className];\n const namesProvider = typeof clsTarget === \"string\" ? true : Boolean(clsTarget?.provider);\n return namesProvider ? null : capability;\n}\n\n/**\n * resolveCapability, turned into a runnable provider (NoProviderError-shaped\n * on failure). Unlike resolveCapability itself — which falls back to the\n * active provider's default model so `describeCapabilities` has something to\n * report — an actual run refuses the fallback: silently running a normal\n * model on a prompt written for a capability it does not have (an image\n * prompt fed to a text model, say) produces a confusing non-answer instead\n * of a clear error naming the config key to set.\n */\nexport function resolveCapabilityRun(config: WorkersConfig, capability: string): CapabilityRunResolution {\n const r = resolveCapability(config, capability);\n if (r.fellBack) {\n throw new NoProviderError(\n `no provider declares the \"${capability}\" capability — configure one with: ` +\n `pai worker capability ${capability} <provider>[,<provider>…], or ` +\n `pai worker model ${capability} <model-id> --provider <name>`\n );\n }\n // \"image\" specifically promises real pixel output — a provider whose only\n // claim to it is a `models.image` entry from the classes-based idiom\n // (`image: glm/image`, a plain model choice on an unchanged claude/codex\n // engine) cannot deliver that through this cross-provider run path, so a\n // run refuses it rather than spawning claude on a prompt it cannot answer.\n if (capability === \"image\" && r.engine !== \"image\") {\n throw new NoProviderError(\n `provider \"${r.provider}\" has no image engine (it would run \"${r.model}\" on its normal ` +\n `${r.engine} engine, not generate an image) — configure an engine=image provider with: ` +\n `pai worker providers add <name> --engine image --url <url> --key <key> --model <id>, then ` +\n `pai worker capability image <name>`\n );\n }\n const provider = getProviderOrNative(config, r.provider);\n if (!provider) {\n throw new NoProviderError(`capability \"${capability}\" resolved to unknown provider \"${r.provider}\"`);\n }\n return {\n providerName: r.provider,\n provider,\n model: r.model,\n engine: r.engine,\n fellBack: r.fellBack,\n capability,\n };\n}\n","/**\n * server.ts — the PAI worker proxy: Anthropic Messages API on the front,\n * OpenAI Chat Completions on the back, loopback only.\n *\n * One proxy serves every OpenAI-protocol provider: Claude Code points\n * ANTHROPIC_BASE_URL at `http://127.0.0.1:8797/<provider>` and the provider\n * name in the path selects the upstream (`upstreamUrl` + `keyFile` from the\n * workers config, re-read per request so config edits apply without a\n * restart). `run` starts the proxy on demand (detached, pid file under the\n * logDir) and `pai worker proxy stop` stops it again.\n *\n * All translation lives in translate.ts; this file is only HTTP plumbing.\n */\n\nimport { spawn } from \"node:child_process\";\nimport { createServer, type Server } from \"node:http\";\nimport { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from \"node:fs\";\nimport { connect } from \"node:net\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { readWorkersSection, resolveProviderKey, type WorkerProvider } from \"../config.js\";\nimport { workersLogDir } from \"../paths.js\";\nimport {\n anthropicError,\n anthropicToOpenAi,\n openAiToAnthropic,\n OpenAiStreamTranslator,\n type AnthropicRequest,\n} from \"./translate.js\";\n\nexport const DEFAULT_PROXY_PORT = 8797;\nconst HOST = \"127.0.0.1\";\n\n/** How providers are looked up — swapped out by the tests. */\nexport type ProviderResolver = () => Record<string, WorkerProvider>;\n\nconst configProviders: ProviderResolver = () => readWorkersSection().workers.providers;\n\nexport function proxyPidPath(logDir: string): string {\n return join(logDir, \"proxy.pid\");\n}\n\n// ---------------------------------------------------------------------------\n// HTTP server\n// ---------------------------------------------------------------------------\n\ninterface ProxyServerOptions {\n port?: number;\n resolveProvider?: ProviderResolver;\n /** Test hook: called with every (translated) upstream request body. */\n onUpstreamRequest?: (url: string, body: unknown) => void;\n}\n\n/**\n * Create (but not start) the proxy server. Routes:\n * GET /healthz → 200 ok\n * POST /<provider>/v1/messages (and /<provider>/messages) → translated call\n */\nexport function createProxyServer(opts: ProxyServerOptions = {}): Server {\n const resolveProvider = opts.resolveProvider ?? configProviders;\n return createServer((req, res) => {\n const url = new URL(req.url ?? \"/\", `http://${HOST}`);\n if (req.method === \"GET\" && url.pathname === \"/healthz\") {\n res.writeHead(200, { \"content-type\": \"text/plain\" });\n res.end(\"ok\");\n return;\n }\n const m = url.pathname.match(/^\\/([a-zA-Z0-9_-]+)\\/(v1\\/)?messages$/);\n if (req.method !== \"POST\" || !m) {\n res.writeHead(404, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(anthropicError(404, `no such route: ${req.method} ${url.pathname}`)));\n return;\n }\n const providerName = m[1];\n const chunks: Buffer[] = [];\n req.on(\"data\", (c: Buffer) => chunks.push(c));\n req.on(\"end\", () => {\n void handleMessages(providerName, Buffer.concat(chunks).toString(\"utf8\"), resolveProvider, res, opts);\n });\n req.on(\"error\", () => {\n res.writeHead(400, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(anthropicError(400, \"request body read failed\")));\n });\n });\n}\n\nasync function handleMessages(\n providerName: string,\n bodyText: string,\n resolveProvider: ProviderResolver,\n res: import(\"node:http\").ServerResponse,\n opts: ProxyServerOptions\n): Promise<void> {\n const fail = (status: number, message: string) => {\n res.writeHead(status, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(anthropicError(status, JSON.stringify({ message }))));\n };\n let reqBody: AnthropicRequest;\n try {\n reqBody = JSON.parse(bodyText) as AnthropicRequest;\n } catch {\n fail(400, \"request body is not valid JSON\");\n return;\n }\n const provider = resolveProvider()[providerName];\n if (!provider) {\n fail(404, `no worker provider named \"${providerName}\"`);\n return;\n }\n if (provider.protocol !== \"openai\" || !provider.upstreamUrl) {\n fail(400, `provider \"${providerName}\" is not an openai-protocol provider`);\n return;\n }\n\n const model = provider.models.default;\n const openaiBody = anthropicToOpenAi(reqBody, model);\n opts.onUpstreamRequest?.(`${provider.upstreamUrl}/chat/completions`, openaiBody);\n\n let upstream: Response;\n try {\n const headers: Record<string, string> = { \"content-type\": \"application/json\" };\n let token: string | null;\n try {\n token = resolveProviderKey(provider);\n } catch (e) {\n fail(500, e instanceof Error ? e.message : String(e));\n return;\n }\n if (token) headers.authorization = `Bearer ${token}`;\n upstream = await fetch(`${provider.upstreamUrl}/chat/completions`, {\n method: \"POST\",\n headers,\n body: JSON.stringify(openaiBody),\n });\n } catch (e) {\n fail(502, `upstream unreachable: ${e instanceof Error ? e.message : String(e)}`);\n return;\n }\n\n if (!upstream.ok) {\n const text = await upstream.text().catch(() => \"\");\n res.writeHead(upstream.status, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(anthropicError(upstream.status, text)));\n return;\n }\n\n if (reqBody.stream) {\n res.writeHead(200, {\n \"content-type\": \"text/event-stream\",\n \"cache-control\": \"no-cache\",\n connection: \"keep-alive\",\n });\n const translator = new OpenAiStreamTranslator(model);\n let buf = \"\";\n if (!upstream.body) {\n res.end(translator.finish());\n return;\n }\n const reader = upstream.body.getReader();\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n buf += new TextDecoder().decode(value, { stream: true });\n let nl: number;\n while ((nl = buf.indexOf(\"\\n\")) >= 0) {\n const line = buf.slice(0, nl).trim();\n buf = buf.slice(nl + 1);\n if (!line.startsWith(\"data:\")) continue;\n const payload = line.slice(5).trim();\n if (payload === \"[DONE]\") {\n res.write(translator.finish());\n } else {\n try {\n res.write(translator.feed(JSON.parse(payload) as Parameters<typeof translator.feed>[0]));\n } catch {\n // skip a malformed chunk rather than kill the stream\n }\n }\n }\n }\n } finally {\n res.write(translator.finish()); // no-op when [DONE] already finished it\n res.end();\n }\n return;\n }\n\n const json = (await upstream.json().catch(() => null)) as unknown;\n if (!json) {\n fail(502, \"upstream returned a non-JSON body\");\n return;\n }\n res.writeHead(200, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(openAiToAnthropic(json as Parameters<typeof openAiToAnthropic>[0], model)));\n}\n\n// ---------------------------------------------------------------------------\n// Start / stop\n// ---------------------------------------------------------------------------\n\n/** Listen on host:port (loopback). Resolves with the bound port. */\nexport function listenProxy(server: Server, port = DEFAULT_PROXY_PORT): Promise<number> {\n return new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(port, HOST, () => {\n const addr = server.address();\n resolve(typeof addr === \"object\" && addr ? addr.port : port);\n });\n });\n}\n\n/** True when something already accepts connections on the port. */\nexport function proxyListening(port = DEFAULT_PROXY_PORT, timeoutMs = 400): Promise<boolean> {\n return new Promise((resolve) => {\n const sock = connect({ port, host: HOST });\n const done = (ok: boolean) => {\n sock.removeAllListeners();\n sock.destroy();\n resolve(ok);\n };\n sock.setTimeout(timeoutMs, () => done(false));\n sock.once(\"connect\", () => done(true));\n sock.once(\"error\", () => done(false));\n });\n}\n\n/**\n * Where the standalone proxy artefact lives, found from this module's own\n * location (dist/cli bundle → ../hooks/, dev checkout → <repo>/dist/hooks).\n */\nexport function standaloneProxyPath(): string | null {\n let dir = dirname(fileURLToPath(import.meta.url));\n for (let i = 0; i < 6; i++) {\n for (const cand of [\n join(dir, \"worker-proxy.mjs\"),\n join(dir, \"hooks\", \"worker-proxy.mjs\"),\n join(dir, \"dist\", \"hooks\", \"worker-proxy.mjs\"),\n ]) {\n if (existsSync(cand)) return cand;\n }\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/**\n * Ensure a proxy is listening on the port; start the detached standalone when\n * nothing answers. Returns the base URL (`http://127.0.0.1:<port>`).\n */\nexport async function ensureProxyRunning(port: number, logDir: string): Promise<string> {\n if (await proxyListening(port)) return `http://${HOST}:${port}`;\n const script = standaloneProxyPath();\n if (!script) {\n throw new Error(\n `the PAI worker proxy is not running and its build was not found — ` +\n `run \\`bun run build\\` (or start one with: pai worker proxy --port ${port})`\n );\n }\n if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });\n const child = spawn(process.execPath, [script, \"--port\", String(port)], {\n detached: true,\n stdio: \"ignore\",\n });\n child.unref();\n writeFileSync(proxyPidPath(logDir), `${child.pid}\\n`, \"utf8\");\n for (let i = 0; i < 40; i++) {\n if (await proxyListening(port)) return `http://${HOST}:${port}`;\n await new Promise((r) => setTimeout(r, 250));\n }\n throw new Error(`the PAI worker proxy did not come up on port ${port} (see ${proxyPidPath(logDir)})`);\n}\n\n/** `pai worker proxy stop`: SIGTERM the pid from the pid file. */\nexport function stopProxy(logDir: string): string {\n const path = proxyPidPath(logDir);\n if (!existsSync(path)) return \"no proxy pid file — nothing to stop\";\n const pid = parseInt(readFileSync(path, \"utf8\").trim(), 10);\n unlinkSync(path);\n if (!Number.isFinite(pid)) return \"stale proxy pid file removed\";\n try {\n process.kill(pid, \"SIGTERM\");\n } catch {\n return `proxy pid ${pid} was not running (pid file removed)`;\n }\n return `proxy pid ${pid} stopped`;\n}\n"],"mappings":"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"}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { a as paiHomePath } from "./pai-home-CeOIYltV.mjs";
|
|
2
|
-
import { y as writeJsonAtomic } from "./config-
|
|
3
|
-
import { T as readWorkersSection } from "./run-env-
|
|
4
|
-
import {
|
|
5
|
-
import { v as workersLogDir } from "./server-
|
|
2
|
+
import { y as writeJsonAtomic } from "./config-msER8xj_.mjs";
|
|
3
|
+
import { T as readWorkersSection } from "./run-env-kbWVwUvU.mjs";
|
|
4
|
+
import { D as worktreesDir, O as appendLedger, x as sessionMapPath, y as itermUuid } from "./run-BFxuNnIJ.mjs";
|
|
5
|
+
import { v as workersLogDir } from "./server-B0vBmfJt.mjs";
|
|
6
6
|
import { i as sendToSession, n as fetchLiveSessions } from "./aibroker-client-BK1h--W_.mjs";
|
|
7
7
|
import { createReadStream, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
@@ -579,4 +579,4 @@ async function runSessionKeepaliveTick(config, overrides = {}) {
|
|
|
579
579
|
|
|
580
580
|
//#endregion
|
|
581
581
|
export { totalUsageTokens as a, parseSessionUsage as i, runSessionKeepaliveTick as n, sessionKeepaliveLedgerSummary as r, loadSessionKeepaliveState as t };
|
|
582
|
-
//# sourceMappingURL=session-keepalive-
|
|
582
|
+
//# sourceMappingURL=session-keepalive-CUcFa8ld.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-keepalive-DDsEo81Q.mjs","names":["fetchLiveSessionsDefault","sendToSessionDefault"],"sources":["../src/audit/session-usage.ts","../src/daemon/session-keepalive.ts"],"sourcesContent":["/**\n * session-usage.ts — parse the usage numbers out of a Claude Code session\n * (or subagent, or pai-worker event-mirror) JSONL transcript.\n *\n * All three log shapes share the same assistant-message envelope\n * ({ type: \"assistant\", message: { id, model, usage } }), so one parser\n * covers `pai audit tokens session` and the per-log readings feeding\n * `pai audit tokens spawn`.\n *\n * Streaming writes one JSONL line per content block of the same logical\n * turn, repeating message.id and usage each time — summing every line would\n * multiply usage by the block count. Keeping only the first line seen per\n * message.id is what the reference script (and this parser) count instead.\n */\n\nimport { createReadStream, existsSync } from \"node:fs\";\nimport { createInterface } from \"node:readline\";\nimport { readFileSync } from \"node:fs\";\n\nexport interface UsageTotals {\n cache_read_input_tokens: number;\n cache_creation_input_tokens: number;\n input_tokens: number;\n output_tokens: number;\n}\n\nexport interface CacheCreationSplit {\n ephemeral5m: number;\n ephemeral1h: number;\n}\n\nexport interface CompactionEvent {\n trigger: string;\n preTokens: number;\n turnIndex: number;\n}\n\nexport interface ModelSwitch {\n turnIndex: number;\n from: string;\n to: string;\n cacheRead: number;\n cacheCreation: number;\n}\n\nexport interface FallbackEvent {\n turnIndex: number;\n from: string;\n to: string;\n category: string;\n scope: string;\n}\n\nexport interface SessionUsageReport {\n path: string;\n sizeBytes: number;\n turns: number;\n totals: UsageTotals;\n /** Per-model assistant-turn counts. */\n models: Record<string, number>;\n /** cache_read + cache_creation + input on the first / last assistant turn seen. */\n firstTurnContext: number | null;\n lastTurnContext: number | null;\n cacheCreationSplit: CacheCreationSplit;\n /** Average / max of the per-turn context value across all turns; null if no turns. */\n avgContext: number | null;\n maxContext: number | null;\n /** Turns whose per-turn context value exceeds the report's threshold. */\n turnsAboveThreshold: number;\n /** Turns whose cache_creation_input_tokens exceeds 20000. */\n cacheRebuildTurns: number;\n /** Real human-authored user prompts (excludes tool-result-only \"user\" lines). */\n userPrompts: number;\n /**\n * Sum over assistant turns of the real user prompts seen before that turn;\n * multiplied by per-prompt hook tokens it gives the tokens UserPromptSubmit\n * output occupied across the whole session.\n */\n promptExposure: number;\n compactions: CompactionEvent[];\n /** ISO timestamp of the first assistant turn. */\n firstTurnAt: string | null;\n /** Model of the last folded turn; drives switch detection. */\n lastModel: string | null;\n modelSwitches: ModelSwitch[];\n fallbacks: FallbackEvent[];\n /** Timestamp (ms) of the last folded assistant turn; drives idle-gap detection. */\n lastTurnAtMs: number | null;\n /** Gaps between consecutive assistant turns exceeding 60 minutes — evidence\n * for whether the session ever went idle long enough to risk its cache TTL\n * (see sessions.cacheKeepalive, src/daemon/config.ts). */\n idleGapsOver60min: number;\n /** Real user prompts whose text is exactly the configured keepalive word\n * (set only when parseSessionUsage is called with one); null otherwise. */\n keepaliveBeats: number | null;\n}\n\nconst USAGE_KEYS: (keyof UsageTotals)[] = [\n \"cache_read_input_tokens\",\n \"cache_creation_input_tokens\",\n \"input_tokens\",\n \"output_tokens\",\n];\n\nfunction emptyTotals(): UsageTotals {\n return { cache_read_input_tokens: 0, cache_creation_input_tokens: 0, input_tokens: 0, output_tokens: 0 };\n}\n\nexport interface AssistantLine {\n type?: string;\n uuid?: string;\n subtype?: string;\n /** Claude Code marks skill expansions and injected system reminders isMeta; they fire no UserPromptSubmit hook. */\n isMeta?: boolean;\n /** Present on newer logs: { kind: \"human\" } for a typed prompt. */\n origin?: { kind?: string };\n compactMetadata?: { trigger?: string; preTokens?: number };\n timestamp?: string;\n originalModel?: string;\n fallbackModel?: string;\n apiRefusalCategory?: string;\n scope?: string;\n message?: {\n id?: string;\n model?: string;\n usage?: Record<string, unknown> & {\n cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number };\n };\n content?: string | Array<{ type?: string }>;\n stop_reason?: string | null;\n };\n}\n\n/**\n * `type:\"system\"` `subtype:\"compact_boundary\"` lines mark a compaction.\n * turnIndex is the count of assistant turns already folded when it fired,\n * so it lines up with the turn numbering the text/JSON report prints.\n */\nexport function isCompactBoundary(line: AssistantLine): boolean {\n return line.type === \"system\" && line.subtype === \"compact_boundary\";\n}\n\nexport function parseCompactionEvent(line: AssistantLine, turnIndex: number): CompactionEvent | null {\n const meta = line.compactMetadata;\n if (!meta || typeof meta.preTokens !== \"number\") return null;\n return { trigger: meta.trigger ?? \"unknown\", preTokens: meta.preTokens, turnIndex };\n}\n\n/**\n * `type:\"system\"` `subtype:\"model_refusal_fallback\"` lines mark an\n * automatic model switch fired by an API safety refusal (e.g. \"cyber\"\n * category), not a user or config choice — worth flagging separately since\n * it can rebuild the whole prompt cache mid-session.\n */\nexport function isModelFallback(line: AssistantLine): boolean {\n return line.type === \"system\" && line.subtype === \"model_refusal_fallback\";\n}\n\nexport function parseFallbackEvent(line: AssistantLine, turnIndex: number): FallbackEvent {\n return {\n turnIndex,\n from: line.originalModel ?? \"unknown\",\n to: line.fallbackModel ?? \"unknown\",\n category: line.apiRefusalCategory ?? \"unknown\",\n scope: line.scope ?? \"unknown\",\n };\n}\n\n/**\n * Fold one already-parsed JSONL line into a report being accumulated.\n * Exported separately so both the streaming file reader below and tests\n * (which build fixtures as arrays of objects, not files) share one path.\n * Returns the turn's context value when a new turn was counted, else null\n * (non-assistant line, no usage, or a duplicate message.id already seen).\n */\nexport function foldAssistantLine(\n report: Pick<\n SessionUsageReport,\n | \"turns\"\n | \"totals\"\n | \"models\"\n | \"firstTurnContext\"\n | \"lastTurnContext\"\n | \"cacheCreationSplit\"\n | \"maxContext\"\n | \"turnsAboveThreshold\"\n | \"cacheRebuildTurns\"\n | \"firstTurnAt\"\n | \"lastModel\"\n | \"modelSwitches\"\n | \"lastTurnAtMs\"\n | \"idleGapsOver60min\"\n >,\n seenIds: Set<string>,\n line: AssistantLine,\n threshold: number\n): number | null {\n if (line.type !== \"assistant\") return null;\n const message = line.message;\n const usage = message?.usage;\n if (!usage) return null;\n const id = message?.id ?? line.uuid;\n if (id) {\n if (seenIds.has(id)) return null;\n seenIds.add(id);\n }\n report.turns++;\n for (const key of USAGE_KEYS) {\n const v = usage[key];\n if (typeof v === \"number\") report.totals[key] += v;\n }\n const context =\n (Number(usage.cache_read_input_tokens) || 0) +\n (Number(usage.cache_creation_input_tokens) || 0) +\n (Number(usage.input_tokens) || 0);\n if (report.firstTurnContext === null) {\n report.firstTurnContext = context;\n report.firstTurnAt = line.timestamp ?? null;\n }\n const atMs = line.timestamp ? Date.parse(line.timestamp) : NaN;\n if (!Number.isNaN(atMs)) {\n if (report.lastTurnAtMs !== null && atMs - report.lastTurnAtMs > 60 * 60 * 1000) {\n report.idleGapsOver60min++;\n }\n report.lastTurnAtMs = atMs;\n }\n report.lastTurnContext = context;\n report.maxContext = report.maxContext === null ? context : Math.max(report.maxContext, context);\n if (context > threshold) report.turnsAboveThreshold++;\n if ((Number(usage.cache_creation_input_tokens) || 0) > 20000) report.cacheRebuildTurns++;\n const model = message?.model ?? \"unknown\";\n report.models[model] = (report.models[model] ?? 0) + 1;\n if (report.lastModel !== null && report.lastModel !== model) {\n report.modelSwitches.push({\n turnIndex: report.turns,\n from: report.lastModel,\n to: model,\n cacheRead: Number(usage.cache_read_input_tokens) || 0,\n cacheCreation: Number(usage.cache_creation_input_tokens) || 0,\n });\n }\n report.lastModel = model;\n const split = usage.cache_creation;\n if (split) {\n report.cacheCreationSplit.ephemeral5m += Number(split.ephemeral_5m_input_tokens) || 0;\n report.cacheCreationSplit.ephemeral1h += Number(split.ephemeral_1h_input_tokens) || 0;\n }\n return context;\n}\n\n/**\n * True for a real human-authored `type:\"user\"` prompt line: content is a\n * plain string, or a content array with no `tool_result` block. Claude Code\n * encodes tool results as `type:\"user\"` messages whose content array is\n * entirely (or partly) tool_result blocks — those must not count as prompts.\n */\nexport function isRealUserPrompt(line: AssistantLine): boolean {\n if (line.type !== \"user\") return false;\n if (line.isMeta) return false;\n if (line.origin?.kind && line.origin.kind !== \"human\") return false;\n const content = line.message?.content;\n if (typeof content === \"string\") return true;\n if (Array.isArray(content)) return !content.some((block) => block?.type === \"tool_result\");\n return false;\n}\n\n/** Plain text of a real user prompt: the string content, or its first text block. */\nexport function extractUserPromptText(line: AssistantLine): string {\n const content = line.message?.content;\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n const block = content.find((b) => b?.type === \"text\") as { text?: string } | undefined;\n return block?.text ?? \"\";\n }\n return \"\";\n}\n\n/**\n * Parse a session/subagent/worker-event JSONL file into a usage report.\n * `keepaliveWord`, when given, counts real user prompts whose text exactly\n * matches it (trimmed) — the sessions.cacheKeepalive beat prompt — into\n * `keepaliveBeats`; omitted, `keepaliveBeats` stays null.\n */\nexport async function parseSessionUsage(\n path: string,\n threshold = 200_000,\n keepaliveWord?: string\n): Promise<SessionUsageReport> {\n const report: SessionUsageReport = {\n path,\n sizeBytes: existsSync(path) ? readFileSync(path).byteLength : 0,\n turns: 0,\n totals: emptyTotals(),\n models: {},\n firstTurnContext: null,\n lastTurnContext: null,\n cacheCreationSplit: { ephemeral5m: 0, ephemeral1h: 0 },\n avgContext: null,\n maxContext: null,\n turnsAboveThreshold: 0,\n cacheRebuildTurns: 0,\n userPrompts: 0,\n promptExposure: 0,\n compactions: [],\n firstTurnAt: null,\n lastModel: null,\n modelSwitches: [],\n fallbacks: [],\n lastTurnAtMs: null,\n idleGapsOver60min: 0,\n keepaliveBeats: keepaliveWord ? 0 : null,\n };\n const seenIds = new Set<string>();\n let contextSum = 0;\n\n const rl = createInterface({ input: createReadStream(path, \"utf8\"), crlfDelay: Infinity });\n for await (const raw of rl) {\n if (!raw.trim()) continue;\n let obj: AssistantLine;\n try {\n obj = JSON.parse(raw) as AssistantLine;\n } catch {\n continue;\n }\n if (obj.type === \"user\") {\n if (isRealUserPrompt(obj)) {\n report.userPrompts++;\n if (keepaliveWord && extractUserPromptText(obj).trim() === keepaliveWord) {\n report.keepaliveBeats = (report.keepaliveBeats ?? 0) + 1;\n }\n }\n continue;\n }\n if (isCompactBoundary(obj)) {\n const event = parseCompactionEvent(obj, report.turns);\n if (event) report.compactions.push(event);\n continue;\n }\n if (isModelFallback(obj)) {\n report.fallbacks.push(parseFallbackEvent(obj, report.turns));\n continue;\n }\n const context = foldAssistantLine(report, seenIds, obj, threshold);\n if (context !== null) {\n contextSum += context;\n report.promptExposure += report.userPrompts;\n }\n }\n report.avgContext = report.turns > 0 ? Math.round(contextSum / report.turns) : null;\n return report;\n}\n\nexport function totalUsageTokens(totals: UsageTotals): number {\n return totals.cache_read_input_tokens + totals.cache_creation_input_tokens + totals.input_tokens + totals.output_tokens;\n}\n","/**\n * session-keepalive.ts — idle-triggered prompt-cache keepalive beat for live\n * interactive Claude Code sessions (`sessions.cacheKeepalive`, see config.ts\n * and docs/cache-keepalive.md, \"Interactive sessions\").\n *\n * One beat = one trivial prompt typed into a session through AIBroker's\n * send_to_session (src/cli/lib/aibroker-client.ts), sent with `noReply: true`\n * so the target sees only the bare word typed into its input line and\n * nothing is queued into its mailbox as a peer message demanding a reply.\n * A cache READ refreshes the provider's ephemeral prompt-cache TTL at\n * roughly 0.1x the cost of the 2x rewrite a cold cache forces on the next\n * real prompt.\n *\n * Distinct from workers/keepalive.ts, which beats a *worker provider's*\n * cache on a fixed timer regardless of activity: this only beats a session\n * that has actually been idle long enough to be at risk, and only inside a\n * configured working-hours window, so it never fires while the user is\n * present at the keyboard or overnight when nobody will read the reply\n * before the cache would have expired anyway.\n */\n\nimport { existsSync, readFileSync, readdirSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport {\n fetchLiveSessions as fetchLiveSessionsDefault,\n sendToSession as sendToSessionDefault,\n type AiBrokerSessionMeta,\n} from \"../cli/lib/aibroker-client.js\";\nimport { writeJsonAtomic } from \"../config/json-store.js\";\nimport { paiHomePath } from \"../config/pai-home.js\";\nimport { appendLedger } from \"../workers/ledger.js\";\nimport { readWorkersSection } from \"../workers/config.js\";\nimport { workersLogDir } from \"../workers/paths.js\";\nimport { worktreesDir } from \"../workers/worktree.js\";\nimport { itermUuid, sessionMapPath, type SessionMapEntry } from \"../workers/scope.js\";\nimport { isRealUserPrompt, extractUserPromptText, type AssistantLine } from \"../audit/session-usage.js\";\nimport type { SessionsCacheKeepaliveConfig } from \"./config.js\";\n\n// ---------------------------------------------------------------------------\n// State file — per-session beat counters, rebuildable (see json-store.ts on\n// when NOT to use readJsonStrict: a damaged file here just resets counters,\n// never blocks the feature).\n// ---------------------------------------------------------------------------\n\nexport interface SessionKeepaliveEntry {\n /** Beats sent since the last real (non-keepalive) user prompt. */\n beats: number;\n /** Identity of the last real user prompt observed, so a fresh one can be\n * told apart from the keepalive's own echo landing back in the transcript. */\n lastRealPromptKey: string | null;\n /** ISO stamp of the last beat sent. */\n lastBeatAt: string | null;\n}\n\nexport type SessionKeepaliveState = Record<string, SessionKeepaliveEntry>;\n\nexport function sessionKeepaliveStatePath(): string {\n return paiHomePath(\"session-keepalive.json\");\n}\n\nfunction emptyEntry(): SessionKeepaliveEntry {\n return { beats: 0, lastRealPromptKey: null, lastBeatAt: null };\n}\n\nexport function loadSessionKeepaliveState(path: string = sessionKeepaliveStatePath()): SessionKeepaliveState {\n if (!existsSync(path)) return {};\n try {\n return JSON.parse(readFileSync(path, \"utf8\")) as SessionKeepaliveState;\n } catch {\n return {};\n }\n}\n\nexport function saveSessionKeepaliveState(\n state: SessionKeepaliveState,\n path: string = sessionKeepaliveStatePath()\n): void {\n writeJsonAtomic(path, state, { backup: false, label: path });\n}\n\n// ---------------------------------------------------------------------------\n// Active-hours window\n// ---------------------------------------------------------------------------\n\n/** Parse \"HH:MM-HH:MM\" into minutes-since-midnight. Throws on a malformed\n * spec — an explicit config error beats a window that is silently always\n * on or always off. */\nexport function parseActiveHours(spec: string): { startMin: number; endMin: number } {\n const m = spec.match(/^(\\d{1,2}):(\\d{2})-(\\d{1,2}):(\\d{2})$/);\n if (!m) {\n throw new Error(`sessions.cacheKeepalive.activeHours: invalid \"${spec}\" (want \"HH:MM-HH:MM\")`);\n }\n return {\n startMin: Number(m[1]) * 60 + Number(m[2]),\n endMin: Number(m[3]) * 60 + Number(m[4]),\n };\n}\n\n/**\n * Whether `now` (local time) sits inside the window. A window that wraps\n * midnight (e.g. \"22:00-06:00\") is honoured by inverting the test instead of\n * requiring startMin < endMin.\n */\nexport function isWithinActiveHours(now: Date, spec: string): boolean {\n const { startMin, endMin } = parseActiveHours(spec);\n const nowMin = now.getHours() * 60 + now.getMinutes();\n if (startMin <= endMin) return nowMin >= startMin && nowMin < endMin;\n return nowMin >= startMin || nowMin < endMin;\n}\n\n// ---------------------------------------------------------------------------\n// Transcript lookup\n// ---------------------------------------------------------------------------\n\n/**\n * Full path to a live session's transcript under ~/.claude/projects, or null\n * when none is found — the case for a session too new to have written a file\n * yet. A worker running in a worktree DOES write a transcript here (Claude\n * Code encodes its worktree cwd as the project dir name), so workers are not\n * excluded \"for free\" — see isWorkerSession.\n */\nexport function findSessionTranscript(\n sessionId: string,\n projectsDir: string = join(homedir(), \".claude\", \"projects\")\n): string | null {\n let projectDirs: string[];\n try {\n projectDirs = readdirSync(projectsDir);\n } catch {\n return null;\n }\n for (const projectDir of projectDirs) {\n const candidate = join(projectsDir, projectDir, `${sessionId}.jsonl`);\n if (existsSync(candidate)) return candidate;\n }\n return null;\n}\n\n/**\n * AIBroker's `fetchLiveSessions()` identifies a session by its iTerm2 pane id\n * (e.g. an AIBroker/iTerm UUID), not the Claude session id `<uuid>.jsonl`\n * transcripts are named after — the two are unrelated identifiers. The\n * status line bridges them on every refresh via `claude-session-map.json`\n * (see `recordSessionMapEntry` in ../workers/scope.ts): this picks, among the\n * map entries whose `term` pane UUID matches, the most recently written one.\n * Returns null when the pane has no (fresh enough) mapped Claude session.\n */\nexport function resolveClaudeSessionIdFromMap(paneId: string, logDir: string): string | null {\n const path = sessionMapPath(logDir);\n if (!paneId || !existsSync(path)) return null;\n let map: Record<string, SessionMapEntry>;\n try {\n map = JSON.parse(readFileSync(path, \"utf8\")) as Record<string, SessionMapEntry>;\n } catch {\n return null;\n }\n let best: SessionMapEntry | null = null;\n for (const entry of Object.values(map)) {\n if (!entry.term || itermUuid(entry.term) !== paneId) continue;\n if (!best || entry.ts > best.ts) best = entry;\n }\n return best?.session ?? null;\n}\n\n/** Claude Code's project-dir encoding of a cwd: every \"/\" becomes \"-\". */\nfunction encodeProjectDirName(cwd: string): string {\n return cwd.replace(/\\//g, \"-\");\n}\n\n/**\n * Is this live \"claude\"-kind session actually a worker pane rather than an\n * interactive one? `claude -p` workers running in a worktree write their\n * transcript under ~/.claude/projects too (encoded cwd = the worktree dir),\n * so a missing transcript is NOT how workers get excluded — this predicate\n * is. Either signal is enough:\n * (a) the transcript's project-dir name is the encoded form of a path\n * under <logDir>/worktrees (every worker worktree lives there), or\n * (b) the broker's session name/paiName names a path under the workers\n * log dir or one of its worktrees (best-effort: AIBroker does not\n * guarantee this, but honors it when present).\n */\nexport function isWorkerSession(\n meta: AiBrokerSessionMeta,\n transcriptPath: string | null,\n logDir: string\n): boolean {\n const worktreesPrefix = encodeProjectDirName(worktreesDir(logDir));\n if (transcriptPath) {\n const projectDirName = transcriptPath.split(\"/\").slice(0, -1).pop() ?? \"\";\n if (projectDirName.startsWith(worktreesPrefix)) return true;\n }\n const rawPrefix = worktreesDir(logDir);\n for (const field of [meta.name, meta.paiName]) {\n if (field && (field.includes(rawPrefix) || field.includes(logDir))) return true;\n }\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Tail snapshot: last turn's context, mid-turn flag, last real prompt\n// ---------------------------------------------------------------------------\n\nexport interface TranscriptSnapshot {\n /** cache_read + cache_creation + input on the most recent assistant turn seen. */\n context: number | null;\n /** True when the last line in the file shows the session still working: an\n * assistant turn with no usage yet (streaming) or a stop_reason other than\n * end_turn/stop_sequence (e.g. mid tool-call), or a real user prompt with\n * no reply behind it yet. A beat sent now would land mid-generation. */\n midTurn: boolean;\n /** The last real (human-authored) user prompt seen, if any. */\n lastRealPrompt: { key: string; text: string } | null;\n}\n\nfunction isMidTurn(line: AssistantLine | null): boolean {\n if (!line) return false;\n if (line.type === \"user\" && isRealUserPrompt(line)) return true;\n if (line.type === \"assistant\") {\n const stopReason = line.message?.stop_reason;\n if (stopReason === undefined || stopReason === null) return true;\n return stopReason !== \"end_turn\" && stopReason !== \"stop_sequence\";\n }\n return false;\n}\n\nexport function readTranscriptSnapshot(path: string): TranscriptSnapshot {\n let lines: string[];\n try {\n lines = readFileSync(path, \"utf8\").split(\"\\n\");\n } catch {\n return { context: null, midTurn: false, lastRealPrompt: null };\n }\n\n let lastLine: AssistantLine | null = null;\n let context: number | null = null;\n let lastRealPrompt: { key: string; text: string } | null = null;\n\n for (let i = lines.length - 1; i >= 0; i--) {\n const raw = lines[i].trim();\n if (!raw) continue;\n let obj: AssistantLine;\n try {\n obj = JSON.parse(raw) as AssistantLine;\n } catch {\n continue;\n }\n if (lastLine === null) lastLine = obj;\n\n if (context === null && obj.type === \"assistant\" && obj.message?.usage) {\n const usage = obj.message.usage;\n context =\n (Number(usage.cache_read_input_tokens) || 0) +\n (Number(usage.cache_creation_input_tokens) || 0) +\n (Number(usage.input_tokens) || 0);\n }\n\n if (lastRealPrompt === null && isRealUserPrompt(obj)) {\n const key = obj.uuid ?? obj.timestamp ?? String(i);\n lastRealPrompt = { key, text: extractUserPromptText(obj).trim() };\n }\n\n if (context !== null && lastRealPrompt !== null) break;\n }\n\n return { context, midTurn: isMidTurn(lastLine), lastRealPrompt };\n}\n\n// ---------------------------------------------------------------------------\n// Ledger — same file WORKER-KEEPALIVE lines go to (workers.logDir/ledger.log)\n// ---------------------------------------------------------------------------\n\n/** Ledger event tag; `pai daemon keepalive` and any log-tailer key off this. */\nexport const SESSION_KEEPALIVE_EVENT = \"SESSION-KEEPALIVE\";\n\nexport function sessionKeepaliveLedgerPath(): string {\n const { workers } = readWorkersSection();\n return join(workersLogDir(workers), \"ledger.log\");\n}\n\n/** The counts/last lines `pai daemon keepalive` prints, scoped to this event. */\nexport function sessionKeepaliveLedgerSummary(\n path: string = sessionKeepaliveLedgerPath(),\n lastN = 10\n): { sent: number; skipped: number; lastLines: string[] } {\n if (!existsSync(path)) return { sent: 0, skipped: 0, lastLines: [] };\n const lines = readFileSync(path, \"utf8\")\n .split(\"\\n\")\n .filter((l) => l.includes(SESSION_KEEPALIVE_EVENT));\n const sent = lines.filter((l) => / result=sent(\\s|$)/.test(l)).length;\n return { sent, skipped: lines.length - sent, lastLines: lines.slice(-lastN) };\n}\n\n// ---------------------------------------------------------------------------\n// The tick\n// ---------------------------------------------------------------------------\n\nexport interface SessionKeepaliveDeps {\n now: () => Date;\n fetchLiveSessions: () => Promise<AiBrokerSessionMeta[]>;\n /** AIBroker pane id (fetchLiveSessions' `sessionId`) -> Claude transcript\n * session id, via the status line's claude-session-map.json bridge. */\n resolveClaudeSessionId: (paneId: string) => string | null;\n findTranscript: (sessionId: string) => string | null;\n mtimeMs: (path: string) => number | null;\n readSnapshot: (path: string) => TranscriptSnapshot;\n sendBeat: (sessionId: string, text: string) => Promise<{ ok: boolean; error?: string }>;\n loadState: () => SessionKeepaliveState;\n saveState: (s: SessionKeepaliveState) => void;\n ledger: (kv: Record<string, string | number | null | undefined>) => void;\n}\n\nexport interface SessionKeepaliveResult {\n sessionId: string;\n result: \"sent\" | string; // \"skipped:<reason>\"\n}\n\nfunction defaultDeps(): SessionKeepaliveDeps {\n const { workers } = readWorkersSection();\n const logDir = workersLogDir(workers);\n return {\n now: () => new Date(),\n fetchLiveSessions: fetchLiveSessionsDefault,\n resolveClaudeSessionId: (paneId) => resolveClaudeSessionIdFromMap(paneId, logDir),\n findTranscript: (id) => findSessionTranscript(id),\n mtimeMs: (p) => {\n try {\n return statSync(p).mtimeMs;\n } catch {\n return null;\n }\n },\n readSnapshot: readTranscriptSnapshot,\n sendBeat: (id, text) => sendToSessionDefault(id, text, undefined, { noReply: true }),\n loadState: () => loadSessionKeepaliveState(),\n saveState: (s) => saveSessionKeepaliveState(s),\n ledger: (kv) => appendLedger(sessionKeepaliveLedgerPath(), SESSION_KEEPALIVE_EVENT, kv),\n };\n}\n\n/**\n * One tick over every live interactive session: beat the ones that qualify,\n * skip (with a reason) the ones that don't. Returns one result per live\n * \"claude\"-kind session for tests to assert against; writes the ledger line\n * and (when any counter changed) the state file as side effects.\n */\nexport async function runSessionKeepaliveTick(\n config: SessionsCacheKeepaliveConfig,\n overrides: Partial<SessionKeepaliveDeps> = {}\n): Promise<SessionKeepaliveResult[]> {\n if (!config.enabled) return [];\n\n const d: SessionKeepaliveDeps = { ...defaultDeps(), ...overrides };\n const sessions = await d.fetchLiveSessions();\n const state = d.loadState();\n const now = d.now();\n const { workers } = readWorkersSection();\n const logDir = workersLogDir(workers);\n const results: SessionKeepaliveResult[] = [];\n let anyChanged = false;\n\n for (const s of sessions) {\n if (s.kind !== \"claude\") continue;\n const paneId = s.sessionId;\n\n const claudeId = d.resolveClaudeSessionId(paneId);\n if (!claudeId) {\n d.ledger({ session: null, pane: paneId, result: \"skipped:unmapped\" });\n results.push({ sessionId: paneId, result: \"skipped:unmapped\" });\n continue;\n }\n const sessionId = claudeId;\n const entry = { ...(state[sessionId] ?? emptyEntry()) };\n let changed = false;\n\n const transcript = d.findTranscript(sessionId);\n\n if (isWorkerSession(s, transcript, logDir)) {\n d.ledger({ session: sessionId, pane: paneId, result: \"skipped:worker\" });\n results.push({ sessionId, result: \"skipped:worker\" });\n continue;\n }\n\n if (!transcript) {\n d.ledger({ session: sessionId, pane: paneId, result: \"skipped:no-transcript\" });\n results.push({ sessionId, result: \"skipped:no-transcript\" });\n continue;\n }\n\n const snapshot = d.readSnapshot(transcript);\n\n // A fresh real prompt (not the keepalive's own echo) resets the beat\n // count — the idle stretch it capped is over.\n if (snapshot.lastRealPrompt && snapshot.lastRealPrompt.key !== entry.lastRealPromptKey) {\n entry.lastRealPromptKey = snapshot.lastRealPrompt.key;\n if (snapshot.lastRealPrompt.text !== config.prompt) entry.beats = 0;\n changed = true;\n }\n\n const mtime = d.mtimeMs(transcript);\n const idleMin = mtime === null ? null : (now.getTime() - mtime) / 60_000;\n\n const skip: string | null =\n mtime === null || idleMin === null\n ? \"no-mtime\"\n : idleMin < config.idleMinutes\n ? `idle:${idleMin.toFixed(1)}min`\n : !isWithinActiveHours(now, config.activeHours)\n ? \"hours\"\n : snapshot.context === null || snapshot.context < config.minContextTokens\n ? \"context\"\n : snapshot.midTurn\n ? \"mid-turn\"\n : entry.beats >= config.maxBeats\n ? \"max-beats\"\n : null;\n\n const ledgerBase = {\n session: sessionId,\n pane: paneId,\n idle_min: idleMin === null ? null : idleMin.toFixed(1),\n context: snapshot.context,\n };\n\n if (skip) {\n d.ledger({ ...ledgerBase, beat: `${entry.beats}/${config.maxBeats}`, result: `skipped:${skip}` });\n results.push({ sessionId, result: `skipped:${skip}` });\n } else {\n const sent = await d.sendBeat(paneId, config.prompt);\n if (sent.ok) {\n entry.beats += 1;\n entry.lastBeatAt = now.toISOString();\n changed = true;\n d.ledger({ ...ledgerBase, beat: `${entry.beats}/${config.maxBeats}`, result: \"sent\" });\n results.push({ sessionId, result: \"sent\" });\n } else {\n d.ledger({ ...ledgerBase, beat: `${entry.beats}/${config.maxBeats}`, result: \"skipped:send-failed\" });\n results.push({ sessionId, result: \"skipped:send-failed\" });\n }\n }\n\n if (changed) {\n state[sessionId] = entry;\n anyChanged = true;\n }\n }\n\n if (anyChanged) d.saveState(state);\n return results;\n}\n\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"session-keepalive-CUcFa8ld.mjs","names":["fetchLiveSessionsDefault","sendToSessionDefault"],"sources":["../src/audit/session-usage.ts","../src/daemon/session-keepalive.ts"],"sourcesContent":["/**\n * session-usage.ts — parse the usage numbers out of a Claude Code session\n * (or subagent, or pai-worker event-mirror) JSONL transcript.\n *\n * All three log shapes share the same assistant-message envelope\n * ({ type: \"assistant\", message: { id, model, usage } }), so one parser\n * covers `pai audit tokens session` and the per-log readings feeding\n * `pai audit tokens spawn`.\n *\n * Streaming writes one JSONL line per content block of the same logical\n * turn, repeating message.id and usage each time — summing every line would\n * multiply usage by the block count. Keeping only the first line seen per\n * message.id is what the reference script (and this parser) count instead.\n */\n\nimport { createReadStream, existsSync } from \"node:fs\";\nimport { createInterface } from \"node:readline\";\nimport { readFileSync } from \"node:fs\";\n\nexport interface UsageTotals {\n cache_read_input_tokens: number;\n cache_creation_input_tokens: number;\n input_tokens: number;\n output_tokens: number;\n}\n\nexport interface CacheCreationSplit {\n ephemeral5m: number;\n ephemeral1h: number;\n}\n\nexport interface CompactionEvent {\n trigger: string;\n preTokens: number;\n turnIndex: number;\n}\n\nexport interface ModelSwitch {\n turnIndex: number;\n from: string;\n to: string;\n cacheRead: number;\n cacheCreation: number;\n}\n\nexport interface FallbackEvent {\n turnIndex: number;\n from: string;\n to: string;\n category: string;\n scope: string;\n}\n\nexport interface SessionUsageReport {\n path: string;\n sizeBytes: number;\n turns: number;\n totals: UsageTotals;\n /** Per-model assistant-turn counts. */\n models: Record<string, number>;\n /** cache_read + cache_creation + input on the first / last assistant turn seen. */\n firstTurnContext: number | null;\n lastTurnContext: number | null;\n cacheCreationSplit: CacheCreationSplit;\n /** Average / max of the per-turn context value across all turns; null if no turns. */\n avgContext: number | null;\n maxContext: number | null;\n /** Turns whose per-turn context value exceeds the report's threshold. */\n turnsAboveThreshold: number;\n /** Turns whose cache_creation_input_tokens exceeds 20000. */\n cacheRebuildTurns: number;\n /** Real human-authored user prompts (excludes tool-result-only \"user\" lines). */\n userPrompts: number;\n /**\n * Sum over assistant turns of the real user prompts seen before that turn;\n * multiplied by per-prompt hook tokens it gives the tokens UserPromptSubmit\n * output occupied across the whole session.\n */\n promptExposure: number;\n compactions: CompactionEvent[];\n /** ISO timestamp of the first assistant turn. */\n firstTurnAt: string | null;\n /** Model of the last folded turn; drives switch detection. */\n lastModel: string | null;\n modelSwitches: ModelSwitch[];\n fallbacks: FallbackEvent[];\n /** Timestamp (ms) of the last folded assistant turn; drives idle-gap detection. */\n lastTurnAtMs: number | null;\n /** Gaps between consecutive assistant turns exceeding 60 minutes — evidence\n * for whether the session ever went idle long enough to risk its cache TTL\n * (see sessions.cacheKeepalive, src/daemon/config.ts). */\n idleGapsOver60min: number;\n /** Real user prompts whose text is exactly the configured keepalive word\n * (set only when parseSessionUsage is called with one); null otherwise. */\n keepaliveBeats: number | null;\n}\n\nconst USAGE_KEYS: (keyof UsageTotals)[] = [\n \"cache_read_input_tokens\",\n \"cache_creation_input_tokens\",\n \"input_tokens\",\n \"output_tokens\",\n];\n\nfunction emptyTotals(): UsageTotals {\n return { cache_read_input_tokens: 0, cache_creation_input_tokens: 0, input_tokens: 0, output_tokens: 0 };\n}\n\nexport interface AssistantLine {\n type?: string;\n uuid?: string;\n subtype?: string;\n /** Claude Code marks skill expansions and injected system reminders isMeta; they fire no UserPromptSubmit hook. */\n isMeta?: boolean;\n /** Present on newer logs: { kind: \"human\" } for a typed prompt. */\n origin?: { kind?: string };\n compactMetadata?: { trigger?: string; preTokens?: number };\n timestamp?: string;\n originalModel?: string;\n fallbackModel?: string;\n apiRefusalCategory?: string;\n scope?: string;\n message?: {\n id?: string;\n model?: string;\n usage?: Record<string, unknown> & {\n cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number };\n };\n content?: string | Array<{ type?: string }>;\n stop_reason?: string | null;\n };\n}\n\n/**\n * `type:\"system\"` `subtype:\"compact_boundary\"` lines mark a compaction.\n * turnIndex is the count of assistant turns already folded when it fired,\n * so it lines up with the turn numbering the text/JSON report prints.\n */\nexport function isCompactBoundary(line: AssistantLine): boolean {\n return line.type === \"system\" && line.subtype === \"compact_boundary\";\n}\n\nexport function parseCompactionEvent(line: AssistantLine, turnIndex: number): CompactionEvent | null {\n const meta = line.compactMetadata;\n if (!meta || typeof meta.preTokens !== \"number\") return null;\n return { trigger: meta.trigger ?? \"unknown\", preTokens: meta.preTokens, turnIndex };\n}\n\n/**\n * `type:\"system\"` `subtype:\"model_refusal_fallback\"` lines mark an\n * automatic model switch fired by an API safety refusal (e.g. \"cyber\"\n * category), not a user or config choice — worth flagging separately since\n * it can rebuild the whole prompt cache mid-session.\n */\nexport function isModelFallback(line: AssistantLine): boolean {\n return line.type === \"system\" && line.subtype === \"model_refusal_fallback\";\n}\n\nexport function parseFallbackEvent(line: AssistantLine, turnIndex: number): FallbackEvent {\n return {\n turnIndex,\n from: line.originalModel ?? \"unknown\",\n to: line.fallbackModel ?? \"unknown\",\n category: line.apiRefusalCategory ?? \"unknown\",\n scope: line.scope ?? \"unknown\",\n };\n}\n\n/**\n * Fold one already-parsed JSONL line into a report being accumulated.\n * Exported separately so both the streaming file reader below and tests\n * (which build fixtures as arrays of objects, not files) share one path.\n * Returns the turn's context value when a new turn was counted, else null\n * (non-assistant line, no usage, or a duplicate message.id already seen).\n */\nexport function foldAssistantLine(\n report: Pick<\n SessionUsageReport,\n | \"turns\"\n | \"totals\"\n | \"models\"\n | \"firstTurnContext\"\n | \"lastTurnContext\"\n | \"cacheCreationSplit\"\n | \"maxContext\"\n | \"turnsAboveThreshold\"\n | \"cacheRebuildTurns\"\n | \"firstTurnAt\"\n | \"lastModel\"\n | \"modelSwitches\"\n | \"lastTurnAtMs\"\n | \"idleGapsOver60min\"\n >,\n seenIds: Set<string>,\n line: AssistantLine,\n threshold: number\n): number | null {\n if (line.type !== \"assistant\") return null;\n const message = line.message;\n const usage = message?.usage;\n if (!usage) return null;\n const id = message?.id ?? line.uuid;\n if (id) {\n if (seenIds.has(id)) return null;\n seenIds.add(id);\n }\n report.turns++;\n for (const key of USAGE_KEYS) {\n const v = usage[key];\n if (typeof v === \"number\") report.totals[key] += v;\n }\n const context =\n (Number(usage.cache_read_input_tokens) || 0) +\n (Number(usage.cache_creation_input_tokens) || 0) +\n (Number(usage.input_tokens) || 0);\n if (report.firstTurnContext === null) {\n report.firstTurnContext = context;\n report.firstTurnAt = line.timestamp ?? null;\n }\n const atMs = line.timestamp ? Date.parse(line.timestamp) : NaN;\n if (!Number.isNaN(atMs)) {\n if (report.lastTurnAtMs !== null && atMs - report.lastTurnAtMs > 60 * 60 * 1000) {\n report.idleGapsOver60min++;\n }\n report.lastTurnAtMs = atMs;\n }\n report.lastTurnContext = context;\n report.maxContext = report.maxContext === null ? context : Math.max(report.maxContext, context);\n if (context > threshold) report.turnsAboveThreshold++;\n if ((Number(usage.cache_creation_input_tokens) || 0) > 20000) report.cacheRebuildTurns++;\n const model = message?.model ?? \"unknown\";\n report.models[model] = (report.models[model] ?? 0) + 1;\n if (report.lastModel !== null && report.lastModel !== model) {\n report.modelSwitches.push({\n turnIndex: report.turns,\n from: report.lastModel,\n to: model,\n cacheRead: Number(usage.cache_read_input_tokens) || 0,\n cacheCreation: Number(usage.cache_creation_input_tokens) || 0,\n });\n }\n report.lastModel = model;\n const split = usage.cache_creation;\n if (split) {\n report.cacheCreationSplit.ephemeral5m += Number(split.ephemeral_5m_input_tokens) || 0;\n report.cacheCreationSplit.ephemeral1h += Number(split.ephemeral_1h_input_tokens) || 0;\n }\n return context;\n}\n\n/**\n * True for a real human-authored `type:\"user\"` prompt line: content is a\n * plain string, or a content array with no `tool_result` block. Claude Code\n * encodes tool results as `type:\"user\"` messages whose content array is\n * entirely (or partly) tool_result blocks — those must not count as prompts.\n */\nexport function isRealUserPrompt(line: AssistantLine): boolean {\n if (line.type !== \"user\") return false;\n if (line.isMeta) return false;\n if (line.origin?.kind && line.origin.kind !== \"human\") return false;\n const content = line.message?.content;\n if (typeof content === \"string\") return true;\n if (Array.isArray(content)) return !content.some((block) => block?.type === \"tool_result\");\n return false;\n}\n\n/** Plain text of a real user prompt: the string content, or its first text block. */\nexport function extractUserPromptText(line: AssistantLine): string {\n const content = line.message?.content;\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n const block = content.find((b) => b?.type === \"text\") as { text?: string } | undefined;\n return block?.text ?? \"\";\n }\n return \"\";\n}\n\n/**\n * Parse a session/subagent/worker-event JSONL file into a usage report.\n * `keepaliveWord`, when given, counts real user prompts whose text exactly\n * matches it (trimmed) — the sessions.cacheKeepalive beat prompt — into\n * `keepaliveBeats`; omitted, `keepaliveBeats` stays null.\n */\nexport async function parseSessionUsage(\n path: string,\n threshold = 200_000,\n keepaliveWord?: string\n): Promise<SessionUsageReport> {\n const report: SessionUsageReport = {\n path,\n sizeBytes: existsSync(path) ? readFileSync(path).byteLength : 0,\n turns: 0,\n totals: emptyTotals(),\n models: {},\n firstTurnContext: null,\n lastTurnContext: null,\n cacheCreationSplit: { ephemeral5m: 0, ephemeral1h: 0 },\n avgContext: null,\n maxContext: null,\n turnsAboveThreshold: 0,\n cacheRebuildTurns: 0,\n userPrompts: 0,\n promptExposure: 0,\n compactions: [],\n firstTurnAt: null,\n lastModel: null,\n modelSwitches: [],\n fallbacks: [],\n lastTurnAtMs: null,\n idleGapsOver60min: 0,\n keepaliveBeats: keepaliveWord ? 0 : null,\n };\n const seenIds = new Set<string>();\n let contextSum = 0;\n\n const rl = createInterface({ input: createReadStream(path, \"utf8\"), crlfDelay: Infinity });\n for await (const raw of rl) {\n if (!raw.trim()) continue;\n let obj: AssistantLine;\n try {\n obj = JSON.parse(raw) as AssistantLine;\n } catch {\n continue;\n }\n if (obj.type === \"user\") {\n if (isRealUserPrompt(obj)) {\n report.userPrompts++;\n if (keepaliveWord && extractUserPromptText(obj).trim() === keepaliveWord) {\n report.keepaliveBeats = (report.keepaliveBeats ?? 0) + 1;\n }\n }\n continue;\n }\n if (isCompactBoundary(obj)) {\n const event = parseCompactionEvent(obj, report.turns);\n if (event) report.compactions.push(event);\n continue;\n }\n if (isModelFallback(obj)) {\n report.fallbacks.push(parseFallbackEvent(obj, report.turns));\n continue;\n }\n const context = foldAssistantLine(report, seenIds, obj, threshold);\n if (context !== null) {\n contextSum += context;\n report.promptExposure += report.userPrompts;\n }\n }\n report.avgContext = report.turns > 0 ? Math.round(contextSum / report.turns) : null;\n return report;\n}\n\nexport function totalUsageTokens(totals: UsageTotals): number {\n return totals.cache_read_input_tokens + totals.cache_creation_input_tokens + totals.input_tokens + totals.output_tokens;\n}\n","/**\n * session-keepalive.ts — idle-triggered prompt-cache keepalive beat for live\n * interactive Claude Code sessions (`sessions.cacheKeepalive`, see config.ts\n * and docs/cache-keepalive.md, \"Interactive sessions\").\n *\n * One beat = one trivial prompt typed into a session through AIBroker's\n * send_to_session (src/cli/lib/aibroker-client.ts), sent with `noReply: true`\n * so the target sees only the bare word typed into its input line and\n * nothing is queued into its mailbox as a peer message demanding a reply.\n * A cache READ refreshes the provider's ephemeral prompt-cache TTL at\n * roughly 0.1x the cost of the 2x rewrite a cold cache forces on the next\n * real prompt.\n *\n * Distinct from workers/keepalive.ts, which beats a *worker provider's*\n * cache on a fixed timer regardless of activity: this only beats a session\n * that has actually been idle long enough to be at risk, and only inside a\n * configured working-hours window, so it never fires while the user is\n * present at the keyboard or overnight when nobody will read the reply\n * before the cache would have expired anyway.\n */\n\nimport { existsSync, readFileSync, readdirSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport {\n fetchLiveSessions as fetchLiveSessionsDefault,\n sendToSession as sendToSessionDefault,\n type AiBrokerSessionMeta,\n} from \"../cli/lib/aibroker-client.js\";\nimport { writeJsonAtomic } from \"../config/json-store.js\";\nimport { paiHomePath } from \"../config/pai-home.js\";\nimport { appendLedger } from \"../workers/ledger.js\";\nimport { readWorkersSection } from \"../workers/config.js\";\nimport { workersLogDir } from \"../workers/paths.js\";\nimport { worktreesDir } from \"../workers/worktree.js\";\nimport { itermUuid, sessionMapPath, type SessionMapEntry } from \"../workers/scope.js\";\nimport { isRealUserPrompt, extractUserPromptText, type AssistantLine } from \"../audit/session-usage.js\";\nimport type { SessionsCacheKeepaliveConfig } from \"./config.js\";\n\n// ---------------------------------------------------------------------------\n// State file — per-session beat counters, rebuildable (see json-store.ts on\n// when NOT to use readJsonStrict: a damaged file here just resets counters,\n// never blocks the feature).\n// ---------------------------------------------------------------------------\n\nexport interface SessionKeepaliveEntry {\n /** Beats sent since the last real (non-keepalive) user prompt. */\n beats: number;\n /** Identity of the last real user prompt observed, so a fresh one can be\n * told apart from the keepalive's own echo landing back in the transcript. */\n lastRealPromptKey: string | null;\n /** ISO stamp of the last beat sent. */\n lastBeatAt: string | null;\n}\n\nexport type SessionKeepaliveState = Record<string, SessionKeepaliveEntry>;\n\nexport function sessionKeepaliveStatePath(): string {\n return paiHomePath(\"session-keepalive.json\");\n}\n\nfunction emptyEntry(): SessionKeepaliveEntry {\n return { beats: 0, lastRealPromptKey: null, lastBeatAt: null };\n}\n\nexport function loadSessionKeepaliveState(path: string = sessionKeepaliveStatePath()): SessionKeepaliveState {\n if (!existsSync(path)) return {};\n try {\n return JSON.parse(readFileSync(path, \"utf8\")) as SessionKeepaliveState;\n } catch {\n return {};\n }\n}\n\nexport function saveSessionKeepaliveState(\n state: SessionKeepaliveState,\n path: string = sessionKeepaliveStatePath()\n): void {\n writeJsonAtomic(path, state, { backup: false, label: path });\n}\n\n// ---------------------------------------------------------------------------\n// Active-hours window\n// ---------------------------------------------------------------------------\n\n/** Parse \"HH:MM-HH:MM\" into minutes-since-midnight. Throws on a malformed\n * spec — an explicit config error beats a window that is silently always\n * on or always off. */\nexport function parseActiveHours(spec: string): { startMin: number; endMin: number } {\n const m = spec.match(/^(\\d{1,2}):(\\d{2})-(\\d{1,2}):(\\d{2})$/);\n if (!m) {\n throw new Error(`sessions.cacheKeepalive.activeHours: invalid \"${spec}\" (want \"HH:MM-HH:MM\")`);\n }\n return {\n startMin: Number(m[1]) * 60 + Number(m[2]),\n endMin: Number(m[3]) * 60 + Number(m[4]),\n };\n}\n\n/**\n * Whether `now` (local time) sits inside the window. A window that wraps\n * midnight (e.g. \"22:00-06:00\") is honoured by inverting the test instead of\n * requiring startMin < endMin.\n */\nexport function isWithinActiveHours(now: Date, spec: string): boolean {\n const { startMin, endMin } = parseActiveHours(spec);\n const nowMin = now.getHours() * 60 + now.getMinutes();\n if (startMin <= endMin) return nowMin >= startMin && nowMin < endMin;\n return nowMin >= startMin || nowMin < endMin;\n}\n\n// ---------------------------------------------------------------------------\n// Transcript lookup\n// ---------------------------------------------------------------------------\n\n/**\n * Full path to a live session's transcript under ~/.claude/projects, or null\n * when none is found — the case for a session too new to have written a file\n * yet. A worker running in a worktree DOES write a transcript here (Claude\n * Code encodes its worktree cwd as the project dir name), so workers are not\n * excluded \"for free\" — see isWorkerSession.\n */\nexport function findSessionTranscript(\n sessionId: string,\n projectsDir: string = join(homedir(), \".claude\", \"projects\")\n): string | null {\n let projectDirs: string[];\n try {\n projectDirs = readdirSync(projectsDir);\n } catch {\n return null;\n }\n for (const projectDir of projectDirs) {\n const candidate = join(projectsDir, projectDir, `${sessionId}.jsonl`);\n if (existsSync(candidate)) return candidate;\n }\n return null;\n}\n\n/**\n * AIBroker's `fetchLiveSessions()` identifies a session by its iTerm2 pane id\n * (e.g. an AIBroker/iTerm UUID), not the Claude session id `<uuid>.jsonl`\n * transcripts are named after — the two are unrelated identifiers. The\n * status line bridges them on every refresh via `claude-session-map.json`\n * (see `recordSessionMapEntry` in ../workers/scope.ts): this picks, among the\n * map entries whose `term` pane UUID matches, the most recently written one.\n * Returns null when the pane has no (fresh enough) mapped Claude session.\n */\nexport function resolveClaudeSessionIdFromMap(paneId: string, logDir: string): string | null {\n const path = sessionMapPath(logDir);\n if (!paneId || !existsSync(path)) return null;\n let map: Record<string, SessionMapEntry>;\n try {\n map = JSON.parse(readFileSync(path, \"utf8\")) as Record<string, SessionMapEntry>;\n } catch {\n return null;\n }\n let best: SessionMapEntry | null = null;\n for (const entry of Object.values(map)) {\n if (!entry.term || itermUuid(entry.term) !== paneId) continue;\n if (!best || entry.ts > best.ts) best = entry;\n }\n return best?.session ?? null;\n}\n\n/** Claude Code's project-dir encoding of a cwd: every \"/\" becomes \"-\". */\nfunction encodeProjectDirName(cwd: string): string {\n return cwd.replace(/\\//g, \"-\");\n}\n\n/**\n * Is this live \"claude\"-kind session actually a worker pane rather than an\n * interactive one? `claude -p` workers running in a worktree write their\n * transcript under ~/.claude/projects too (encoded cwd = the worktree dir),\n * so a missing transcript is NOT how workers get excluded — this predicate\n * is. Either signal is enough:\n * (a) the transcript's project-dir name is the encoded form of a path\n * under <logDir>/worktrees (every worker worktree lives there), or\n * (b) the broker's session name/paiName names a path under the workers\n * log dir or one of its worktrees (best-effort: AIBroker does not\n * guarantee this, but honors it when present).\n */\nexport function isWorkerSession(\n meta: AiBrokerSessionMeta,\n transcriptPath: string | null,\n logDir: string\n): boolean {\n const worktreesPrefix = encodeProjectDirName(worktreesDir(logDir));\n if (transcriptPath) {\n const projectDirName = transcriptPath.split(\"/\").slice(0, -1).pop() ?? \"\";\n if (projectDirName.startsWith(worktreesPrefix)) return true;\n }\n const rawPrefix = worktreesDir(logDir);\n for (const field of [meta.name, meta.paiName]) {\n if (field && (field.includes(rawPrefix) || field.includes(logDir))) return true;\n }\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Tail snapshot: last turn's context, mid-turn flag, last real prompt\n// ---------------------------------------------------------------------------\n\nexport interface TranscriptSnapshot {\n /** cache_read + cache_creation + input on the most recent assistant turn seen. */\n context: number | null;\n /** True when the last line in the file shows the session still working: an\n * assistant turn with no usage yet (streaming) or a stop_reason other than\n * end_turn/stop_sequence (e.g. mid tool-call), or a real user prompt with\n * no reply behind it yet. A beat sent now would land mid-generation. */\n midTurn: boolean;\n /** The last real (human-authored) user prompt seen, if any. */\n lastRealPrompt: { key: string; text: string } | null;\n}\n\nfunction isMidTurn(line: AssistantLine | null): boolean {\n if (!line) return false;\n if (line.type === \"user\" && isRealUserPrompt(line)) return true;\n if (line.type === \"assistant\") {\n const stopReason = line.message?.stop_reason;\n if (stopReason === undefined || stopReason === null) return true;\n return stopReason !== \"end_turn\" && stopReason !== \"stop_sequence\";\n }\n return false;\n}\n\nexport function readTranscriptSnapshot(path: string): TranscriptSnapshot {\n let lines: string[];\n try {\n lines = readFileSync(path, \"utf8\").split(\"\\n\");\n } catch {\n return { context: null, midTurn: false, lastRealPrompt: null };\n }\n\n let lastLine: AssistantLine | null = null;\n let context: number | null = null;\n let lastRealPrompt: { key: string; text: string } | null = null;\n\n for (let i = lines.length - 1; i >= 0; i--) {\n const raw = lines[i].trim();\n if (!raw) continue;\n let obj: AssistantLine;\n try {\n obj = JSON.parse(raw) as AssistantLine;\n } catch {\n continue;\n }\n if (lastLine === null) lastLine = obj;\n\n if (context === null && obj.type === \"assistant\" && obj.message?.usage) {\n const usage = obj.message.usage;\n context =\n (Number(usage.cache_read_input_tokens) || 0) +\n (Number(usage.cache_creation_input_tokens) || 0) +\n (Number(usage.input_tokens) || 0);\n }\n\n if (lastRealPrompt === null && isRealUserPrompt(obj)) {\n const key = obj.uuid ?? obj.timestamp ?? String(i);\n lastRealPrompt = { key, text: extractUserPromptText(obj).trim() };\n }\n\n if (context !== null && lastRealPrompt !== null) break;\n }\n\n return { context, midTurn: isMidTurn(lastLine), lastRealPrompt };\n}\n\n// ---------------------------------------------------------------------------\n// Ledger — same file WORKER-KEEPALIVE lines go to (workers.logDir/ledger.log)\n// ---------------------------------------------------------------------------\n\n/** Ledger event tag; `pai daemon keepalive` and any log-tailer key off this. */\nexport const SESSION_KEEPALIVE_EVENT = \"SESSION-KEEPALIVE\";\n\nexport function sessionKeepaliveLedgerPath(): string {\n const { workers } = readWorkersSection();\n return join(workersLogDir(workers), \"ledger.log\");\n}\n\n/** The counts/last lines `pai daemon keepalive` prints, scoped to this event. */\nexport function sessionKeepaliveLedgerSummary(\n path: string = sessionKeepaliveLedgerPath(),\n lastN = 10\n): { sent: number; skipped: number; lastLines: string[] } {\n if (!existsSync(path)) return { sent: 0, skipped: 0, lastLines: [] };\n const lines = readFileSync(path, \"utf8\")\n .split(\"\\n\")\n .filter((l) => l.includes(SESSION_KEEPALIVE_EVENT));\n const sent = lines.filter((l) => / result=sent(\\s|$)/.test(l)).length;\n return { sent, skipped: lines.length - sent, lastLines: lines.slice(-lastN) };\n}\n\n// ---------------------------------------------------------------------------\n// The tick\n// ---------------------------------------------------------------------------\n\nexport interface SessionKeepaliveDeps {\n now: () => Date;\n fetchLiveSessions: () => Promise<AiBrokerSessionMeta[]>;\n /** AIBroker pane id (fetchLiveSessions' `sessionId`) -> Claude transcript\n * session id, via the status line's claude-session-map.json bridge. */\n resolveClaudeSessionId: (paneId: string) => string | null;\n findTranscript: (sessionId: string) => string | null;\n mtimeMs: (path: string) => number | null;\n readSnapshot: (path: string) => TranscriptSnapshot;\n sendBeat: (sessionId: string, text: string) => Promise<{ ok: boolean; error?: string }>;\n loadState: () => SessionKeepaliveState;\n saveState: (s: SessionKeepaliveState) => void;\n ledger: (kv: Record<string, string | number | null | undefined>) => void;\n}\n\nexport interface SessionKeepaliveResult {\n sessionId: string;\n result: \"sent\" | string; // \"skipped:<reason>\"\n}\n\nfunction defaultDeps(): SessionKeepaliveDeps {\n const { workers } = readWorkersSection();\n const logDir = workersLogDir(workers);\n return {\n now: () => new Date(),\n fetchLiveSessions: fetchLiveSessionsDefault,\n resolveClaudeSessionId: (paneId) => resolveClaudeSessionIdFromMap(paneId, logDir),\n findTranscript: (id) => findSessionTranscript(id),\n mtimeMs: (p) => {\n try {\n return statSync(p).mtimeMs;\n } catch {\n return null;\n }\n },\n readSnapshot: readTranscriptSnapshot,\n sendBeat: (id, text) => sendToSessionDefault(id, text, undefined, { noReply: true }),\n loadState: () => loadSessionKeepaliveState(),\n saveState: (s) => saveSessionKeepaliveState(s),\n ledger: (kv) => appendLedger(sessionKeepaliveLedgerPath(), SESSION_KEEPALIVE_EVENT, kv),\n };\n}\n\n/**\n * One tick over every live interactive session: beat the ones that qualify,\n * skip (with a reason) the ones that don't. Returns one result per live\n * \"claude\"-kind session for tests to assert against; writes the ledger line\n * and (when any counter changed) the state file as side effects.\n */\nexport async function runSessionKeepaliveTick(\n config: SessionsCacheKeepaliveConfig,\n overrides: Partial<SessionKeepaliveDeps> = {}\n): Promise<SessionKeepaliveResult[]> {\n if (!config.enabled) return [];\n\n const d: SessionKeepaliveDeps = { ...defaultDeps(), ...overrides };\n const sessions = await d.fetchLiveSessions();\n const state = d.loadState();\n const now = d.now();\n const { workers } = readWorkersSection();\n const logDir = workersLogDir(workers);\n const results: SessionKeepaliveResult[] = [];\n let anyChanged = false;\n\n for (const s of sessions) {\n if (s.kind !== \"claude\") continue;\n const paneId = s.sessionId;\n\n const claudeId = d.resolveClaudeSessionId(paneId);\n if (!claudeId) {\n d.ledger({ session: null, pane: paneId, result: \"skipped:unmapped\" });\n results.push({ sessionId: paneId, result: \"skipped:unmapped\" });\n continue;\n }\n const sessionId = claudeId;\n const entry = { ...(state[sessionId] ?? emptyEntry()) };\n let changed = false;\n\n const transcript = d.findTranscript(sessionId);\n\n if (isWorkerSession(s, transcript, logDir)) {\n d.ledger({ session: sessionId, pane: paneId, result: \"skipped:worker\" });\n results.push({ sessionId, result: \"skipped:worker\" });\n continue;\n }\n\n if (!transcript) {\n d.ledger({ session: sessionId, pane: paneId, result: \"skipped:no-transcript\" });\n results.push({ sessionId, result: \"skipped:no-transcript\" });\n continue;\n }\n\n const snapshot = d.readSnapshot(transcript);\n\n // A fresh real prompt (not the keepalive's own echo) resets the beat\n // count — the idle stretch it capped is over.\n if (snapshot.lastRealPrompt && snapshot.lastRealPrompt.key !== entry.lastRealPromptKey) {\n entry.lastRealPromptKey = snapshot.lastRealPrompt.key;\n if (snapshot.lastRealPrompt.text !== config.prompt) entry.beats = 0;\n changed = true;\n }\n\n const mtime = d.mtimeMs(transcript);\n const idleMin = mtime === null ? null : (now.getTime() - mtime) / 60_000;\n\n const skip: string | null =\n mtime === null || idleMin === null\n ? \"no-mtime\"\n : idleMin < config.idleMinutes\n ? `idle:${idleMin.toFixed(1)}min`\n : !isWithinActiveHours(now, config.activeHours)\n ? \"hours\"\n : snapshot.context === null || snapshot.context < config.minContextTokens\n ? \"context\"\n : snapshot.midTurn\n ? \"mid-turn\"\n : entry.beats >= config.maxBeats\n ? \"max-beats\"\n : null;\n\n const ledgerBase = {\n session: sessionId,\n pane: paneId,\n idle_min: idleMin === null ? null : idleMin.toFixed(1),\n context: snapshot.context,\n };\n\n if (skip) {\n d.ledger({ ...ledgerBase, beat: `${entry.beats}/${config.maxBeats}`, result: `skipped:${skip}` });\n results.push({ sessionId, result: `skipped:${skip}` });\n } else {\n const sent = await d.sendBeat(paneId, config.prompt);\n if (sent.ok) {\n entry.beats += 1;\n entry.lastBeatAt = now.toISOString();\n changed = true;\n d.ledger({ ...ledgerBase, beat: `${entry.beats}/${config.maxBeats}`, result: \"sent\" });\n results.push({ sessionId, result: \"sent\" });\n } else {\n d.ledger({ ...ledgerBase, beat: `${entry.beats}/${config.maxBeats}`, result: \"skipped:send-failed\" });\n results.push({ sessionId, result: \"skipped:send-failed\" });\n }\n }\n\n if (changed) {\n state[sessionId] = entry;\n anyChanged = true;\n }\n }\n\n if (anyChanged) d.saveState(state);\n return results;\n}\n\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAiGA,MAAM,aAAoC;CACxC;CACA;CACA;CACA;CACD;AAED,SAAS,cAA2B;AAClC,QAAO;EAAE,yBAAyB;EAAG,6BAA6B;EAAG,cAAc;EAAG,eAAe;EAAG;;;;;;;AAiC1G,SAAgB,kBAAkB,MAA8B;AAC9D,QAAO,KAAK,SAAS,YAAY,KAAK,YAAY;;AAGpD,SAAgB,qBAAqB,MAAqB,WAA2C;CACnG,MAAM,OAAO,KAAK;AAClB,KAAI,CAAC,QAAQ,OAAO,KAAK,cAAc,SAAU,QAAO;AACxD,QAAO;EAAE,SAAS,KAAK,WAAW;EAAW,WAAW,KAAK;EAAW;EAAW;;;;;;;;AASrF,SAAgB,gBAAgB,MAA8B;AAC5D,QAAO,KAAK,SAAS,YAAY,KAAK,YAAY;;AAGpD,SAAgB,mBAAmB,MAAqB,WAAkC;AACxF,QAAO;EACL;EACA,MAAM,KAAK,iBAAiB;EAC5B,IAAI,KAAK,iBAAiB;EAC1B,UAAU,KAAK,sBAAsB;EACrC,OAAO,KAAK,SAAS;EACtB;;;;;;;;;AAUH,SAAgB,kBACd,QAiBA,SACA,MACA,WACe;AACf,KAAI,KAAK,SAAS,YAAa,QAAO;CACtC,MAAM,UAAU,KAAK;CACrB,MAAM,QAAQ,SAAS;AACvB,KAAI,CAAC,MAAO,QAAO;CACnB,MAAM,KAAK,SAAS,MAAM,KAAK;AAC/B,KAAI,IAAI;AACN,MAAI,QAAQ,IAAI,GAAG,CAAE,QAAO;AAC5B,UAAQ,IAAI,GAAG;;AAEjB,QAAO;AACP,MAAK,MAAM,OAAO,YAAY;EAC5B,MAAM,IAAI,MAAM;AAChB,MAAI,OAAO,MAAM,SAAU,QAAO,OAAO,QAAQ;;CAEnD,MAAM,WACH,OAAO,MAAM,wBAAwB,IAAI,MACzC,OAAO,MAAM,4BAA4B,IAAI,MAC7C,OAAO,MAAM,aAAa,IAAI;AACjC,KAAI,OAAO,qBAAqB,MAAM;AACpC,SAAO,mBAAmB;AAC1B,SAAO,cAAc,KAAK,aAAa;;CAEzC,MAAM,OAAO,KAAK,YAAY,KAAK,MAAM,KAAK,UAAU,GAAG;AAC3D,KAAI,CAAC,OAAO,MAAM,KAAK,EAAE;AACvB,MAAI,OAAO,iBAAiB,QAAQ,OAAO,OAAO,eAAe,OAAU,IACzE,QAAO;AAET,SAAO,eAAe;;AAExB,QAAO,kBAAkB;AACzB,QAAO,aAAa,OAAO,eAAe,OAAO,UAAU,KAAK,IAAI,OAAO,YAAY,QAAQ;AAC/F,KAAI,UAAU,UAAW,QAAO;AAChC,MAAK,OAAO,MAAM,4BAA4B,IAAI,KAAK,IAAO,QAAO;CACrE,MAAM,QAAQ,SAAS,SAAS;AAChC,QAAO,OAAO,UAAU,OAAO,OAAO,UAAU,KAAK;AACrD,KAAI,OAAO,cAAc,QAAQ,OAAO,cAAc,MACpD,QAAO,cAAc,KAAK;EACxB,WAAW,OAAO;EAClB,MAAM,OAAO;EACb,IAAI;EACJ,WAAW,OAAO,MAAM,wBAAwB,IAAI;EACpD,eAAe,OAAO,MAAM,4BAA4B,IAAI;EAC7D,CAAC;AAEJ,QAAO,YAAY;CACnB,MAAM,QAAQ,MAAM;AACpB,KAAI,OAAO;AACT,SAAO,mBAAmB,eAAe,OAAO,MAAM,0BAA0B,IAAI;AACpF,SAAO,mBAAmB,eAAe,OAAO,MAAM,0BAA0B,IAAI;;AAEtF,QAAO;;;;;;;;AAST,SAAgB,iBAAiB,MAA8B;AAC7D,KAAI,KAAK,SAAS,OAAQ,QAAO;AACjC,KAAI,KAAK,OAAQ,QAAO;AACxB,KAAI,KAAK,QAAQ,QAAQ,KAAK,OAAO,SAAS,QAAS,QAAO;CAC9D,MAAM,UAAU,KAAK,SAAS;AAC9B,KAAI,OAAO,YAAY,SAAU,QAAO;AACxC,KAAI,MAAM,QAAQ,QAAQ,CAAE,QAAO,CAAC,QAAQ,MAAM,UAAU,OAAO,SAAS,cAAc;AAC1F,QAAO;;;AAIT,SAAgB,sBAAsB,MAA6B;CACjE,MAAM,UAAU,KAAK,SAAS;AAC9B,KAAI,OAAO,YAAY,SAAU,QAAO;AACxC,KAAI,MAAM,QAAQ,QAAQ,CAExB,QADc,QAAQ,MAAM,MAAM,GAAG,SAAS,OAAO,EACvC,QAAQ;AAExB,QAAO;;;;;;;;AAST,eAAsB,kBACpB,MACA,YAAY,KACZ,eAC6B;CAC7B,MAAM,SAA6B;EACjC;EACA,WAAW,WAAW,KAAK,GAAG,aAAa,KAAK,CAAC,aAAa;EAC9D,OAAO;EACP,QAAQ,aAAa;EACrB,QAAQ,EAAE;EACV,kBAAkB;EAClB,iBAAiB;EACjB,oBAAoB;GAAE,aAAa;GAAG,aAAa;GAAG;EACtD,YAAY;EACZ,YAAY;EACZ,qBAAqB;EACrB,mBAAmB;EACnB,aAAa;EACb,gBAAgB;EAChB,aAAa,EAAE;EACf,aAAa;EACb,WAAW;EACX,eAAe,EAAE;EACjB,WAAW,EAAE;EACb,cAAc;EACd,mBAAmB;EACnB,gBAAgB,gBAAgB,IAAI;EACrC;CACD,MAAM,0BAAU,IAAI,KAAa;CACjC,IAAI,aAAa;CAEjB,MAAM,KAAK,gBAAgB;EAAE,OAAO,iBAAiB,MAAM,OAAO;EAAE,WAAW;EAAU,CAAC;AAC1F,YAAW,MAAM,OAAO,IAAI;AAC1B,MAAI,CAAC,IAAI,MAAM,CAAE;EACjB,IAAI;AACJ,MAAI;AACF,SAAM,KAAK,MAAM,IAAI;UACf;AACN;;AAEF,MAAI,IAAI,SAAS,QAAQ;AACvB,OAAI,iBAAiB,IAAI,EAAE;AACzB,WAAO;AACP,QAAI,iBAAiB,sBAAsB,IAAI,CAAC,MAAM,KAAK,cACzD,QAAO,kBAAkB,OAAO,kBAAkB,KAAK;;AAG3D;;AAEF,MAAI,kBAAkB,IAAI,EAAE;GAC1B,MAAM,QAAQ,qBAAqB,KAAK,OAAO,MAAM;AACrD,OAAI,MAAO,QAAO,YAAY,KAAK,MAAM;AACzC;;AAEF,MAAI,gBAAgB,IAAI,EAAE;AACxB,UAAO,UAAU,KAAK,mBAAmB,KAAK,OAAO,MAAM,CAAC;AAC5D;;EAEF,MAAM,UAAU,kBAAkB,QAAQ,SAAS,KAAK,UAAU;AAClE,MAAI,YAAY,MAAM;AACpB,iBAAc;AACd,UAAO,kBAAkB,OAAO;;;AAGpC,QAAO,aAAa,OAAO,QAAQ,IAAI,KAAK,MAAM,aAAa,OAAO,MAAM,GAAG;AAC/E,QAAO;;AAGT,SAAgB,iBAAiB,QAA6B;AAC5D,QAAO,OAAO,0BAA0B,OAAO,8BAA8B,OAAO,eAAe,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;ACxS5G,SAAgB,4BAAoC;AAClD,QAAO,YAAY,yBAAyB;;AAG9C,SAAS,aAAoC;AAC3C,QAAO;EAAE,OAAO;EAAG,mBAAmB;EAAM,YAAY;EAAM;;AAGhE,SAAgB,0BAA0B,OAAe,2BAA2B,EAAyB;AAC3G,KAAI,CAAC,WAAW,KAAK,CAAE,QAAO,EAAE;AAChC,KAAI;AACF,SAAO,KAAK,MAAM,aAAa,MAAM,OAAO,CAAC;SACvC;AACN,SAAO,EAAE;;;AAIb,SAAgB,0BACd,OACA,OAAe,2BAA2B,EACpC;AACN,iBAAgB,MAAM,OAAO;EAAE,QAAQ;EAAO,OAAO;EAAM,CAAC;;;;;AAU9D,SAAgB,iBAAiB,MAAoD;CACnF,MAAM,IAAI,KAAK,MAAM,wCAAwC;AAC7D,KAAI,CAAC,EACH,OAAM,IAAI,MAAM,iDAAiD,KAAK,wBAAwB;AAEhG,QAAO;EACL,UAAU,OAAO,EAAE,GAAG,GAAG,KAAK,OAAO,EAAE,GAAG;EAC1C,QAAQ,OAAO,EAAE,GAAG,GAAG,KAAK,OAAO,EAAE,GAAG;EACzC;;;;;;;AAQH,SAAgB,oBAAoB,KAAW,MAAuB;CACpE,MAAM,EAAE,UAAU,WAAW,iBAAiB,KAAK;CACnD,MAAM,SAAS,IAAI,UAAU,GAAG,KAAK,IAAI,YAAY;AACrD,KAAI,YAAY,OAAQ,QAAO,UAAU,YAAY,SAAS;AAC9D,QAAO,UAAU,YAAY,SAAS;;;;;;;;;AAcxC,SAAgB,sBACd,WACA,cAAsB,KAAK,SAAS,EAAE,WAAW,WAAW,EAC7C;CACf,IAAI;AACJ,KAAI;AACF,gBAAc,YAAY,YAAY;SAChC;AACN,SAAO;;AAET,MAAK,MAAM,cAAc,aAAa;EACpC,MAAM,YAAY,KAAK,aAAa,YAAY,GAAG,UAAU,QAAQ;AACrE,MAAI,WAAW,UAAU,CAAE,QAAO;;AAEpC,QAAO;;;;;;;;;;;AAYT,SAAgB,8BAA8B,QAAgB,QAA+B;CAC3F,MAAM,OAAO,eAAe,OAAO;AACnC,KAAI,CAAC,UAAU,CAAC,WAAW,KAAK,CAAE,QAAO;CACzC,IAAI;AACJ,KAAI;AACF,QAAM,KAAK,MAAM,aAAa,MAAM,OAAO,CAAC;SACtC;AACN,SAAO;;CAET,IAAI,OAA+B;AACnC,MAAK,MAAM,SAAS,OAAO,OAAO,IAAI,EAAE;AACtC,MAAI,CAAC,MAAM,QAAQ,UAAU,MAAM,KAAK,KAAK,OAAQ;AACrD,MAAI,CAAC,QAAQ,MAAM,KAAK,KAAK,GAAI,QAAO;;AAE1C,QAAO,MAAM,WAAW;;;AAI1B,SAAS,qBAAqB,KAAqB;AACjD,QAAO,IAAI,QAAQ,OAAO,IAAI;;;;;;;;;;;;;;AAehC,SAAgB,gBACd,MACA,gBACA,QACS;CACT,MAAM,kBAAkB,qBAAqB,aAAa,OAAO,CAAC;AAClE,KAAI,gBAEF;OADuB,eAAe,MAAM,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC,KAAK,IAAI,IACpD,WAAW,gBAAgB,CAAE,QAAO;;CAEzD,MAAM,YAAY,aAAa,OAAO;AACtC,MAAK,MAAM,SAAS,CAAC,KAAK,MAAM,KAAK,QAAQ,CAC3C,KAAI,UAAU,MAAM,SAAS,UAAU,IAAI,MAAM,SAAS,OAAO,EAAG,QAAO;AAE7E,QAAO;;AAmBT,SAAS,UAAU,MAAqC;AACtD,KAAI,CAAC,KAAM,QAAO;AAClB,KAAI,KAAK,SAAS,UAAU,iBAAiB,KAAK,CAAE,QAAO;AAC3D,KAAI,KAAK,SAAS,aAAa;EAC7B,MAAM,aAAa,KAAK,SAAS;AACjC,MAAI,eAAe,UAAa,eAAe,KAAM,QAAO;AAC5D,SAAO,eAAe,cAAc,eAAe;;AAErD,QAAO;;AAGT,SAAgB,uBAAuB,MAAkC;CACvE,IAAI;AACJ,KAAI;AACF,UAAQ,aAAa,MAAM,OAAO,CAAC,MAAM,KAAK;SACxC;AACN,SAAO;GAAE,SAAS;GAAM,SAAS;GAAO,gBAAgB;GAAM;;CAGhE,IAAI,WAAiC;CACrC,IAAI,UAAyB;CAC7B,IAAI,iBAAuD;AAE3D,MAAK,IAAI,IAAI,MAAM,SAAS,GAAG,KAAK,GAAG,KAAK;EAC1C,MAAM,MAAM,MAAM,GAAG,MAAM;AAC3B,MAAI,CAAC,IAAK;EACV,IAAI;AACJ,MAAI;AACF,SAAM,KAAK,MAAM,IAAI;UACf;AACN;;AAEF,MAAI,aAAa,KAAM,YAAW;AAElC,MAAI,YAAY,QAAQ,IAAI,SAAS,eAAe,IAAI,SAAS,OAAO;GACtE,MAAM,QAAQ,IAAI,QAAQ;AAC1B,cACG,OAAO,MAAM,wBAAwB,IAAI,MACzC,OAAO,MAAM,4BAA4B,IAAI,MAC7C,OAAO,MAAM,aAAa,IAAI;;AAGnC,MAAI,mBAAmB,QAAQ,iBAAiB,IAAI,CAElD,kBAAiB;GAAE,KADP,IAAI,QAAQ,IAAI,aAAa,OAAO,EAAE;GAC1B,MAAM,sBAAsB,IAAI,CAAC,MAAM;GAAE;AAGnE,MAAI,YAAY,QAAQ,mBAAmB,KAAM;;AAGnD,QAAO;EAAE;EAAS,SAAS,UAAU,SAAS;EAAE;EAAgB;;;AAQlE,MAAa,0BAA0B;AAEvC,SAAgB,6BAAqC;CACnD,MAAM,EAAE,YAAY,oBAAoB;AACxC,QAAO,KAAK,cAAc,QAAQ,EAAE,aAAa;;;AAInD,SAAgB,8BACd,OAAe,4BAA4B,EAC3C,QAAQ,IACgD;AACxD,KAAI,CAAC,WAAW,KAAK,CAAE,QAAO;EAAE,MAAM;EAAG,SAAS;EAAG,WAAW,EAAE;EAAE;CACpE,MAAM,QAAQ,aAAa,MAAM,OAAO,CACrC,MAAM,KAAK,CACX,QAAQ,MAAM,EAAE,SAAS,wBAAwB,CAAC;CACrD,MAAM,OAAO,MAAM,QAAQ,MAAM,qBAAqB,KAAK,EAAE,CAAC,CAAC;AAC/D,QAAO;EAAE;EAAM,SAAS,MAAM,SAAS;EAAM,WAAW,MAAM,MAAM,CAAC,MAAM;EAAE;;AA2B/E,SAAS,cAAoC;CAC3C,MAAM,EAAE,YAAY,oBAAoB;CACxC,MAAM,SAAS,cAAc,QAAQ;AACrC,QAAO;EACL,2BAAW,IAAI,MAAM;EACFA;EACnB,yBAAyB,WAAW,8BAA8B,QAAQ,OAAO;EACjF,iBAAiB,OAAO,sBAAsB,GAAG;EACjD,UAAU,MAAM;AACd,OAAI;AACF,WAAO,SAAS,EAAE,CAAC;WACb;AACN,WAAO;;;EAGX,cAAc;EACd,WAAW,IAAI,SAASC,cAAqB,IAAI,MAAM,QAAW,EAAE,SAAS,MAAM,CAAC;EACpF,iBAAiB,2BAA2B;EAC5C,YAAY,MAAM,0BAA0B,EAAE;EAC9C,SAAS,OAAO,aAAa,4BAA4B,EAAE,yBAAyB,GAAG;EACxF;;;;;;;;AASH,eAAsB,wBACpB,QACA,YAA2C,EAAE,EACV;AACnC,KAAI,CAAC,OAAO,QAAS,QAAO,EAAE;CAE9B,MAAM,IAA0B;EAAE,GAAG,aAAa;EAAE,GAAG;EAAW;CAClE,MAAM,WAAW,MAAM,EAAE,mBAAmB;CAC5C,MAAM,QAAQ,EAAE,WAAW;CAC3B,MAAM,MAAM,EAAE,KAAK;CACnB,MAAM,EAAE,YAAY,oBAAoB;CACxC,MAAM,SAAS,cAAc,QAAQ;CACrC,MAAM,UAAoC,EAAE;CAC5C,IAAI,aAAa;AAEjB,MAAK,MAAM,KAAK,UAAU;AACxB,MAAI,EAAE,SAAS,SAAU;EACzB,MAAM,SAAS,EAAE;EAEjB,MAAM,WAAW,EAAE,uBAAuB,OAAO;AACjD,MAAI,CAAC,UAAU;AACb,KAAE,OAAO;IAAE,SAAS;IAAM,MAAM;IAAQ,QAAQ;IAAoB,CAAC;AACrE,WAAQ,KAAK;IAAE,WAAW;IAAQ,QAAQ;IAAoB,CAAC;AAC/D;;EAEF,MAAM,YAAY;EAClB,MAAM,QAAQ,EAAE,GAAI,MAAM,cAAc,YAAY,EAAG;EACvD,IAAI,UAAU;EAEd,MAAM,aAAa,EAAE,eAAe,UAAU;AAE9C,MAAI,gBAAgB,GAAG,YAAY,OAAO,EAAE;AAC1C,KAAE,OAAO;IAAE,SAAS;IAAW,MAAM;IAAQ,QAAQ;IAAkB,CAAC;AACxE,WAAQ,KAAK;IAAE;IAAW,QAAQ;IAAkB,CAAC;AACrD;;AAGF,MAAI,CAAC,YAAY;AACf,KAAE,OAAO;IAAE,SAAS;IAAW,MAAM;IAAQ,QAAQ;IAAyB,CAAC;AAC/E,WAAQ,KAAK;IAAE;IAAW,QAAQ;IAAyB,CAAC;AAC5D;;EAGF,MAAM,WAAW,EAAE,aAAa,WAAW;AAI3C,MAAI,SAAS,kBAAkB,SAAS,eAAe,QAAQ,MAAM,mBAAmB;AACtF,SAAM,oBAAoB,SAAS,eAAe;AAClD,OAAI,SAAS,eAAe,SAAS,OAAO,OAAQ,OAAM,QAAQ;AAClE,aAAU;;EAGZ,MAAM,QAAQ,EAAE,QAAQ,WAAW;EACnC,MAAM,UAAU,UAAU,OAAO,QAAQ,IAAI,SAAS,GAAG,SAAS;EAElE,MAAM,OACJ,UAAU,QAAQ,YAAY,OAC1B,aACA,UAAU,OAAO,cACf,QAAQ,QAAQ,QAAQ,EAAE,CAAC,OAC3B,CAAC,oBAAoB,KAAK,OAAO,YAAY,GAC3C,UACA,SAAS,YAAY,QAAQ,SAAS,UAAU,OAAO,mBACrD,YACA,SAAS,UACP,aACA,MAAM,SAAS,OAAO,WACpB,cACA;EAEhB,MAAM,aAAa;GACjB,SAAS;GACT,MAAM;GACN,UAAU,YAAY,OAAO,OAAO,QAAQ,QAAQ,EAAE;GACtD,SAAS,SAAS;GACnB;AAED,MAAI,MAAM;AACR,KAAE,OAAO;IAAE,GAAG;IAAY,MAAM,GAAG,MAAM,MAAM,GAAG,OAAO;IAAY,QAAQ,WAAW;IAAQ,CAAC;AACjG,WAAQ,KAAK;IAAE;IAAW,QAAQ,WAAW;IAAQ,CAAC;cAEzC,MAAM,EAAE,SAAS,QAAQ,OAAO,OAAO,EAC3C,IAAI;AACX,SAAM,SAAS;AACf,SAAM,aAAa,IAAI,aAAa;AACpC,aAAU;AACV,KAAE,OAAO;IAAE,GAAG;IAAY,MAAM,GAAG,MAAM,MAAM,GAAG,OAAO;IAAY,QAAQ;IAAQ,CAAC;AACtF,WAAQ,KAAK;IAAE;IAAW,QAAQ;IAAQ,CAAC;SACtC;AACL,KAAE,OAAO;IAAE,GAAG;IAAY,MAAM,GAAG,MAAM,MAAM,GAAG,OAAO;IAAY,QAAQ;IAAuB,CAAC;AACrG,WAAQ,KAAK;IAAE;IAAW,QAAQ;IAAuB,CAAC;;AAI9D,MAAI,SAAS;AACX,SAAM,aAAa;AACnB,gBAAa;;;AAIjB,KAAI,WAAY,GAAE,UAAU,MAAM;AAClC,QAAO"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { i as populateSlugs } from "./search-BaTbyWjK.mjs";
|
|
2
|
-
import { a as formatDetectionJson, r as detectProject, t as buildWakeupContext } from "./wakeup-
|
|
2
|
+
import { a as formatDetectionJson, r as detectProject, t as buildWakeupContext } from "./wakeup-CTX3rlz-.mjs";
|
|
3
3
|
import { n as cosineSimilarity } from "./embeddings-DfrYNIng.mjs";
|
|
4
4
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
5
5
|
import { isAbsolute, join, resolve } from "node:path";
|
|
@@ -1470,4 +1470,4 @@ async function toolMemoryKgSearch(federation, params) {
|
|
|
1470
1470
|
|
|
1471
1471
|
//#endregion
|
|
1472
1472
|
export { formatProject as A, toolProjectInfo as C, toolMemoryGet as D, combineHybridResults as E, toolMemorySearch as O, toolProjectHealth as S, toolProjectTodo as T, toolZettelThemes as _, toolMemoryWakeup as a, toolSessionRoute as b, toolKgInvalidate as c, toolZettelConverse as d, toolZettelExplore as f, toolZettelSurprise as g, toolZettelSuggest as h, toolMemoryTaxonomy as i, lookupProjectId as j, detectProjectFromPath as k, toolKgQuery as l, toolZettelHealth as m, toolMemoryFeedback as n, toolKgAdd as o, toolZettelGodNotes as p, toolMemoryTunnels as r, toolKgContradictions as s, toolMemoryKgSearch as t, toolZettelCommunities as u, toolRegistrySearch as v, toolProjectList as w, toolProjectDetect as x, toolSessionList as y };
|
|
1473
|
-
//# sourceMappingURL=tools-
|
|
1473
|
+
//# sourceMappingURL=tools-CaTZnIau.mjs.map
|