@tekmidian/pai 0.36.2 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{auto-route-Byf8ENXj.mjs → auto-route-DM7GhJ8y.mjs} +2 -2
- package/dist/{auto-route-Byf8ENXj.mjs.map → auto-route-DM7GhJ8y.mjs.map} +1 -1
- package/dist/cli/index.mjs +5 -4
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/program.d.mts.map +1 -1
- package/dist/cli/program.mjs +5 -4
- package/dist/{clusters-wZgTCYCB.mjs → clusters-Do4tEGyc.mjs} +1 -1
- package/dist/{clusters-wZgTCYCB.mjs.map → clusters-Do4tEGyc.mjs.map} +1 -1
- package/dist/{context-handover-cache-PtNvj_8D.mjs → context-handover-cache-BpUojjsi.mjs} +2 -2
- package/dist/{context-handover-cache-PtNvj_8D.mjs.map → context-handover-cache-BpUojjsi.mjs.map} +1 -1
- package/dist/daemon/index.mjs +8 -8
- package/dist/{daemon-BZ93KRBo.mjs → daemon-CGg1VCbA.mjs} +25 -25
- package/dist/{daemon-BZ93KRBo.mjs.map → daemon-CGg1VCbA.mjs.map} +1 -1
- package/dist/daemon-DsGGiIJM.mjs +20 -0
- package/dist/daemon-mcp/index.mjs +549 -1
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/{detector--Gg5JRN5.mjs → detector-CMap-9vw.mjs} +1 -1
- package/dist/{detector--Gg5JRN5.mjs.map → detector-CMap-9vw.mjs.map} +1 -1
- package/dist/detector-DtLExmHN.mjs +5 -0
- package/dist/factory-BXzqRYVZ.mjs +3 -0
- package/dist/{factory-Bsp7xOpO.mjs → factory-DD2T33C9.mjs} +5 -5
- package/dist/{factory-Bsp7xOpO.mjs.map → factory-DD2T33C9.mjs.map} +1 -1
- package/dist/hooks/context-compression-hook.mjs +164 -14
- package/dist/hooks/context-compression-hook.mjs.map +4 -4
- package/dist/hooks/initialize-session.mjs +6 -0
- package/dist/hooks/initialize-session.mjs.map +3 -3
- package/dist/hooks/load-core-context.mjs +6 -0
- package/dist/hooks/load-core-context.mjs.map +3 -3
- package/dist/hooks/load-project-context.mjs +6 -0
- package/dist/hooks/load-project-context.mjs.map +3 -3
- package/dist/hooks/post-compact-inject.mjs +6 -0
- package/dist/hooks/post-compact-inject.mjs.map +3 -3
- package/dist/hooks/route-agents-to-worker.mjs +518 -0
- package/dist/hooks/route-agents-to-worker.mjs.map +7 -0
- package/dist/hooks/status-line.mjs +400 -0
- package/dist/hooks/status-line.mjs.map +7 -0
- package/dist/hooks/stop-hook.mjs +6 -0
- package/dist/hooks/stop-hook.mjs.map +3 -3
- package/dist/hooks/subagent-stop-hook.mjs +6 -0
- package/dist/hooks/subagent-stop-hook.mjs.map +3 -3
- package/dist/hooks/sync-todo-to-md.mjs +6 -0
- package/dist/hooks/sync-todo-to-md.mjs.map +3 -3
- package/dist/hooks/update-tab-on-action.mjs +6 -0
- package/dist/hooks/update-tab-on-action.mjs.map +3 -3
- package/dist/hooks/update-tab-titles.mjs +6 -0
- package/dist/hooks/update-tab-titles.mjs.map +3 -3
- package/dist/hooks/worker-proxy.mjs +858 -0
- package/dist/hooks/worker-proxy.mjs.map +7 -0
- package/dist/hooks/worker-status-line.mjs +545 -0
- package/dist/hooks/worker-status-line.mjs.map +7 -0
- package/dist/{indexer-backend-nQZuEx6N.mjs → indexer-backend-Cox9BCo-.mjs} +1 -1
- package/dist/{indexer-backend-nQZuEx6N.mjs.map → indexer-backend-Cox9BCo-.mjs.map} +1 -1
- package/dist/{kg-entity-DbOMPdF9.mjs → kg-entity-r8duqhi9.mjs} +1 -1
- package/dist/{kg-entity-DbOMPdF9.mjs.map → kg-entity-r8duqhi9.mjs.map} +1 -1
- package/dist/{latent-ideas-Bn6A5-5P.mjs → latent-ideas-BC1oINZ-.mjs} +2 -2
- package/dist/{latent-ideas-Bn6A5-5P.mjs.map → latent-ideas-BC1oINZ-.mjs.map} +1 -1
- package/dist/{link-boost-fYjUnxCN.mjs → link-boost-QFLrJwD6.mjs} +1 -1
- package/dist/{link-boost-fYjUnxCN.mjs.map → link-boost-QFLrJwD6.mjs.map} +1 -1
- package/dist/{main-resolver-BAbhKpeX.mjs → main-resolver-DlaLOFBA.mjs} +1 -1
- package/dist/{main-resolver-BAbhKpeX.mjs.map → main-resolver-DlaLOFBA.mjs.map} +1 -1
- package/dist/{main-resolver-Dxh444GO.mjs → main-resolver-IhZo4pI0.mjs} +1 -1
- package/dist/{neighborhood-DpaEM991.mjs → neighborhood-D9MJ1c8f.mjs} +1 -1
- package/dist/{neighborhood-DpaEM991.mjs.map → neighborhood-D9MJ1c8f.mjs.map} +1 -1
- package/dist/{note-context-DrcY4cWm.mjs → note-context-d1wT_-GA.mjs} +1 -1
- package/dist/{note-context-DrcY4cWm.mjs.map → note-context-d1wT_-GA.mjs.map} +1 -1
- package/dist/{postgres-BVme6qX0.mjs → postgres--BjPtLa0.mjs} +1 -1
- package/dist/{postgres-BVme6qX0.mjs.map → postgres--BjPtLa0.mjs.map} +1 -1
- package/dist/{program-C-fUghPv.mjs → program-Y0hAiVy8.mjs} +851 -101
- package/dist/program-Y0hAiVy8.mjs.map +1 -0
- package/dist/providers-DUshcB-d.mjs +4031 -0
- package/dist/providers-DUshcB-d.mjs.map +1 -0
- package/dist/{query-feedback-C1T6kS18.mjs → query-feedback-BUJxgw5B.mjs} +1 -1
- package/dist/{query-feedback-C1T6kS18.mjs.map → query-feedback-BUJxgw5B.mjs.map} +1 -1
- package/dist/query-feedback-DhyLOe5S.mjs +3 -0
- package/dist/router-1zi8jiNF.mjs +3 -0
- package/dist/{router-CsDm7HvK.mjs → router-DK_sLsUL.mjs} +1 -1
- package/dist/{router-CsDm7HvK.mjs.map → router-DK_sLsUL.mjs.map} +1 -1
- package/dist/skills/Worker/SKILL.md +63 -0
- package/dist/{sources-D8ZdNfvK.mjs → sources-Bi7--33T.mjs} +1 -1
- package/dist/{sources-D8ZdNfvK.mjs.map → sources-Bi7--33T.mjs.map} +1 -1
- package/dist/{sqlite-D1IaR8Am.mjs → sqlite-DtaL1glm.mjs} +1 -1
- package/dist/{sqlite-D1IaR8Am.mjs.map → sqlite-DtaL1glm.mjs.map} +1 -1
- package/dist/{state-qtmrBWCm.mjs → state-8Hm9E4tW.mjs} +1 -1
- package/dist/{state-WaXhLr6R.mjs → state-BY2L6-vX.mjs} +1 -1
- package/dist/{state-WaXhLr6R.mjs.map → state-BY2L6-vX.mjs.map} +1 -1
- package/dist/{themes-XPkj_bfP.mjs → themes-BN0a2duq.mjs} +1 -1
- package/dist/{themes-XPkj_bfP.mjs.map → themes-BN0a2duq.mjs.map} +1 -1
- package/dist/{tools-DEt6YPfc.mjs → tools-CGPqpU3A.mjs} +1 -1
- package/dist/{tools-ceiy7ANX.mjs → tools-y2bJpKom.mjs} +14 -14
- package/dist/{tools-ceiy7ANX.mjs.map → tools-y2bJpKom.mjs.map} +1 -1
- package/dist/{trace-DfyGmMG_.mjs → trace-h23JCcFD.mjs} +1 -1
- package/dist/{trace-DfyGmMG_.mjs.map → trace-h23JCcFD.mjs.map} +1 -1
- package/dist/{vault-indexer-CFvlPUMB.mjs → vault-indexer-DgsPjMgs.mjs} +1 -1
- package/dist/{vault-indexer-CFvlPUMB.mjs.map → vault-indexer-DgsPjMgs.mjs.map} +1 -1
- package/dist/{work-queue-worker-B8W8_3Rn.mjs → work-queue-worker-Dva_v_pI.mjs} +4 -4
- package/dist/{work-queue-worker-B8W8_3Rn.mjs.map → work-queue-worker-Dva_v_pI.mjs.map} +1 -1
- package/dist/{work-queue-worker-HN2Ufg-L.mjs → work-queue-worker-gsKd2LJa.mjs} +4 -4
- package/dist/{zettelkasten-CvjmMghT.mjs → zettelkasten-vo7psPdT.mjs} +3 -3
- package/dist/{zettelkasten-CvjmMghT.mjs.map → zettelkasten-vo7psPdT.mjs.map} +1 -1
- package/docs/commands/README.md +29 -0
- package/docs/commands/backup.md +1 -1
- package/docs/commands/clear-names.md +1 -1
- package/docs/commands/daemon.md +1 -1
- package/docs/commands/db.md +1 -1
- package/docs/commands/end.md +1 -1
- package/docs/commands/help.md +1 -1
- package/docs/commands/identity.md +1 -1
- package/docs/commands/kg.md +1 -1
- package/docs/commands/mcp.md +1 -1
- package/docs/commands/memory.md +1 -1
- package/docs/commands/notify.md +1 -1
- package/docs/commands/observation.md +1 -1
- package/docs/commands/obsidian.md +1 -1
- package/docs/commands/pause.md +1 -1
- package/docs/commands/project.md +1 -1
- package/docs/commands/projects.md +1 -1
- package/docs/commands/registry.md +1 -1
- package/docs/commands/restore.md +1 -1
- package/docs/commands/session.md +1 -1
- package/docs/commands/sessions.md +1 -1
- package/docs/commands/setup.md +1 -1
- package/docs/commands/shell-init.md +1 -1
- package/docs/commands/skill.md +1 -1
- package/docs/commands/task.md +1 -1
- package/docs/commands/topic.md +1 -1
- package/docs/commands/update.md +1 -1
- package/docs/commands/worker.md +398 -0
- package/docs/commands/zettel.md +1 -1
- package/docs/worker.md +434 -0
- package/package.json +1 -1
- package/scripts/build-hooks.mjs +46 -1
- package/src/hooks/pre-compact.sh +1 -0
- package/src/hooks/session-autosave.sh +1 -0
- package/src/hooks/session-stop.sh +1 -0
- package/src/hooks/ts/lib/context-fill.test.ts +104 -0
- package/src/hooks/ts/lib/context-fill.ts +73 -0
- package/src/hooks/ts/lib/handover-evidence.ts +227 -0
- package/src/hooks/ts/lib/worker-session.test.ts +24 -0
- package/src/hooks/ts/lib/worker-session.ts +23 -0
- package/src/hooks/ts/post-tool-use/sync-todo-to-md.ts +2 -0
- package/src/hooks/ts/post-tool-use/update-tab-on-action.ts +2 -0
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +17 -0
- package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +128 -0
- package/src/hooks/ts/session-start/initialize-session.ts +2 -0
- package/src/hooks/ts/session-start/load-core-context.ts +2 -0
- package/src/hooks/ts/session-start/load-project-context.ts +2 -0
- package/src/hooks/ts/session-start/post-compact-inject.ts +2 -0
- package/src/hooks/ts/stop/stop-hook.ts +2 -0
- package/src/hooks/ts/subagent-stop/subagent-stop-hook.ts +2 -0
- package/src/hooks/ts/user-prompt/update-tab-titles.ts +2 -0
- package/statusline-command.sh +17 -0
- package/dist/daemon-DRdoA489.mjs +0 -20
- package/dist/detector-DGAk1iBR.mjs +0 -5
- package/dist/factory-CrokPMk2.mjs +0 -3
- package/dist/program-C-fUghPv.mjs.map +0 -1
- package/dist/query-feedback-BBMBp96K.mjs +0 -3
- package/dist/router-BMkOb62X.mjs +0 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"detector
|
|
1
|
+
{"version":3,"file":"detector-CMap-9vw.mjs","names":[],"sources":["../src/topics/detector.ts"],"sourcesContent":["/**\n * Topic shift detection engine.\n *\n * Accepts a context summary (recent conversation text) and determines whether\n * the conversation has drifted away from the currently-routed project.\n *\n * Algorithm:\n * 1. Run keyword memory_search against the context text (no project filter)\n * 2. Score results by project — sum of BM25 scores per project\n * 3. Compare the top-scoring project against the current project\n * 4. If a different project dominates by more than the confidence threshold,\n * report a topic shift.\n *\n * Design decisions:\n * - Keyword search only (no semantic) — fast, no embedding requirement\n * - Works with or without an active daemon (direct DB access path)\n * - Stateless: callers supply currentProject; detector has no session memory\n * - Minimal: returns a plain result object, not MCP content arrays\n */\n\nimport type { Database } from \"better-sqlite3\";\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport { searchMemory, populateSlugs } from \"../memory/search.js\";\nimport type { SearchResult } from \"../memory/search.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface TopicCheckParams {\n /** Recent conversation context (a few sentences or tool call summaries) */\n context: string;\n /** The project slug the session is currently routed to. May be null/empty. */\n currentProject?: string;\n /**\n * Minimum confidence [0,1] to declare a shift. Default: 0.6.\n * Higher = less sensitive, fewer false positives.\n */\n threshold?: number;\n /**\n * Maximum results to draw from memory search (candidates). Default: 20.\n * More candidates = more accurate scoring, slightly slower.\n */\n candidates?: number;\n}\n\nexport interface TopicCheckResult {\n /** Whether a significant topic shift was detected. */\n shifted: boolean;\n /** The project slug the session is currently routed to (echoed from input). */\n currentProject: string | null;\n /** The project slug that best matches the context, or null if no clear match. */\n suggestedProject: string | null;\n /**\n * Confidence score for the suggested project [0,1].\n * Represents the fraction of total score mass held by the top project.\n * 1.0 = all matching chunks belong to one project.\n * 0.5 = two projects are equally matched.\n */\n confidence: number;\n /** Number of memory chunks that contributed to scoring. */\n chunkCount: number;\n /** Top-3 scoring projects with their normalised scores (for debugging). */\n topProjects: Array<{ slug: string; score: number }>;\n}\n\n// ---------------------------------------------------------------------------\n// Core algorithm\n// ---------------------------------------------------------------------------\n\n/**\n * Detect whether the provided context text best matches a different project\n * than the session's current routing.\n *\n * Works with either a raw SQLite Database or a StorageBackend.\n * For the StorageBackend path, keyword search is used.\n * For the raw Database path (legacy/direct), searchMemory() is called.\n */\nexport async function detectTopicShift(\n registryDb: Database,\n federation: Database | StorageBackend,\n params: TopicCheckParams\n): Promise<TopicCheckResult> {\n const threshold = params.threshold ?? 0.6;\n const candidates = params.candidates ?? 20;\n const currentProject = params.currentProject?.trim() || null;\n\n if (!params.context || params.context.trim().length === 0) {\n return {\n shifted: false,\n currentProject,\n suggestedProject: null,\n confidence: 0,\n chunkCount: 0,\n topProjects: [],\n };\n }\n\n // -------------------------------------------------------------------------\n // Run memory search across ALL projects (no project filter)\n // -------------------------------------------------------------------------\n\n let results: SearchResult[];\n\n const isBackend = (x: Database | StorageBackend): x is StorageBackend =>\n \"backendType\" in x;\n\n if (isBackend(federation)) {\n results = await federation.searchKeyword(params.context, {\n maxResults: candidates,\n });\n } else {\n results = searchMemory(federation, params.context, {\n maxResults: candidates,\n });\n }\n\n if (results.length === 0) {\n return {\n shifted: false,\n currentProject,\n suggestedProject: null,\n confidence: 0,\n chunkCount: 0,\n topProjects: [],\n };\n }\n\n // Populate project slugs from the registry\n const withSlugs = populateSlugs(results, registryDb);\n\n // -------------------------------------------------------------------------\n // Score projects by summing BM25 scores of matching chunks\n // -------------------------------------------------------------------------\n\n const projectScores = new Map<string, number>();\n\n for (const r of withSlugs) {\n const slug = r.projectSlug;\n if (!slug) continue;\n projectScores.set(slug, (projectScores.get(slug) ?? 0) + r.score);\n }\n\n if (projectScores.size === 0) {\n return {\n shifted: false,\n currentProject,\n suggestedProject: null,\n confidence: 0,\n chunkCount: withSlugs.length,\n topProjects: [],\n };\n }\n\n // Sort by total score descending\n const ranked = Array.from(projectScores.entries())\n .sort((a, b) => b[1] - a[1]);\n\n const totalScore = ranked.reduce((sum, [, s]) => sum + s, 0);\n\n // Top-3 for reporting (normalised to [0,1] fraction of total mass)\n const topProjects = ranked.slice(0, 3).map(([slug, score]) => ({\n slug,\n score: totalScore > 0 ? score / totalScore : 0,\n }));\n\n const topSlug = ranked[0][0];\n const topRawScore = ranked[0][1];\n const confidence = totalScore > 0 ? topRawScore / totalScore : 0;\n\n // -------------------------------------------------------------------------\n // Determine if a shift occurred\n // -------------------------------------------------------------------------\n\n // A shift is detected when:\n // 1. confidence >= threshold (the top project dominates)\n // 2. The top project is different from currentProject\n // 3. There is a currentProject to compare against\n // (if no current project, we still return the best match but no \"shift\")\n\n const isDifferent =\n currentProject !== null &&\n topSlug !== currentProject;\n\n const shifted = isDifferent && confidence >= threshold;\n\n return {\n shifted,\n currentProject,\n suggestedProject: topSlug,\n confidence,\n chunkCount: withSlugs.length,\n topProjects,\n };\n}\n"],"mappings":";;;;;;;;;;;AA8EA,eAAsB,iBACpB,YACA,YACA,QAC2B;CAC3B,MAAM,YAAY,OAAO,aAAa;CACtC,MAAM,aAAa,OAAO,cAAc;CACxC,MAAM,iBAAiB,OAAO,gBAAgB,MAAM,IAAI;AAExD,KAAI,CAAC,OAAO,WAAW,OAAO,QAAQ,MAAM,CAAC,WAAW,EACtD,QAAO;EACL,SAAS;EACT;EACA,kBAAkB;EAClB,YAAY;EACZ,YAAY;EACZ,aAAa,EAAE;EAChB;CAOH,IAAI;CAEJ,MAAM,aAAa,MACjB,iBAAiB;AAEnB,KAAI,UAAU,WAAW,CACvB,WAAU,MAAM,WAAW,cAAc,OAAO,SAAS,EACvD,YAAY,YACb,CAAC;KAEF,WAAU,aAAa,YAAY,OAAO,SAAS,EACjD,YAAY,YACb,CAAC;AAGJ,KAAI,QAAQ,WAAW,EACrB,QAAO;EACL,SAAS;EACT;EACA,kBAAkB;EAClB,YAAY;EACZ,YAAY;EACZ,aAAa,EAAE;EAChB;CAIH,MAAM,YAAY,cAAc,SAAS,WAAW;CAMpD,MAAM,gCAAgB,IAAI,KAAqB;AAE/C,MAAK,MAAM,KAAK,WAAW;EACzB,MAAM,OAAO,EAAE;AACf,MAAI,CAAC,KAAM;AACX,gBAAc,IAAI,OAAO,cAAc,IAAI,KAAK,IAAI,KAAK,EAAE,MAAM;;AAGnE,KAAI,cAAc,SAAS,EACzB,QAAO;EACL,SAAS;EACT;EACA,kBAAkB;EAClB,YAAY;EACZ,YAAY,UAAU;EACtB,aAAa,EAAE;EAChB;CAIH,MAAM,SAAS,MAAM,KAAK,cAAc,SAAS,CAAC,CAC/C,MAAM,GAAG,MAAM,EAAE,KAAK,EAAE,GAAG;CAE9B,MAAM,aAAa,OAAO,QAAQ,KAAK,GAAG,OAAO,MAAM,GAAG,EAAE;CAG5D,MAAM,cAAc,OAAO,MAAM,GAAG,EAAE,CAAC,KAAK,CAAC,MAAM,YAAY;EAC7D;EACA,OAAO,aAAa,IAAI,QAAQ,aAAa;EAC9C,EAAE;CAEH,MAAM,UAAU,OAAO,GAAG;CAC1B,MAAM,cAAc,OAAO,GAAG;CAC9B,MAAM,aAAa,aAAa,IAAI,cAAc,aAAa;AAkB/D,QAAO;EACL,SANA,mBAAmB,QACnB,YAAY,kBAEiB,cAAc;EAI3C;EACA,kBAAkB;EAClB;EACA,YAAY,UAAU;EACtB;EACD"}
|
|
@@ -56,7 +56,7 @@ async function createStorageBackend(config, opts = {}) {
|
|
|
56
56
|
* backend on success, or an error string describing why it failed.
|
|
57
57
|
*/
|
|
58
58
|
async function attemptPostgres(config) {
|
|
59
|
-
const { PostgresBackend } = await import("./postgres
|
|
59
|
+
const { PostgresBackend } = await import("./postgres--BjPtLa0.mjs");
|
|
60
60
|
const pgConfig = config.postgres ?? {};
|
|
61
61
|
let backend = null;
|
|
62
62
|
try {
|
|
@@ -85,7 +85,7 @@ const ESCALATE_AFTER_ATTEMPTS = 5;
|
|
|
85
85
|
/** Tell the user the backend is down, through whatever channels are configured. */
|
|
86
86
|
async function notifyBackendDown(attempts, lastError) {
|
|
87
87
|
try {
|
|
88
|
-
const { routeNotification } = await import("./router-
|
|
88
|
+
const { routeNotification } = await import("./router-1zi8jiNF.mjs");
|
|
89
89
|
const { loadConfig } = await import("./config-C_ErGddD.mjs");
|
|
90
90
|
await routeNotification({
|
|
91
91
|
event: "error",
|
|
@@ -97,7 +97,7 @@ async function notifyBackendDown(attempts, lastError) {
|
|
|
97
97
|
/** And say when it comes back, so the alert is not left hanging. */
|
|
98
98
|
async function notifyBackendRecovered(attempts, since) {
|
|
99
99
|
try {
|
|
100
|
-
const { routeNotification } = await import("./router-
|
|
100
|
+
const { routeNotification } = await import("./router-1zi8jiNF.mjs");
|
|
101
101
|
const { loadConfig } = await import("./config-C_ErGddD.mjs");
|
|
102
102
|
const mins = since ? Math.max(1, Math.round((Date.now() - since) / 6e4)) : null;
|
|
103
103
|
await routeNotification({
|
|
@@ -141,10 +141,10 @@ async function connectPostgres(config, waitForever) {
|
|
|
141
141
|
}
|
|
142
142
|
async function createSQLiteBackend() {
|
|
143
143
|
const { openFederation } = await import("./db-O-cyAPfS.mjs");
|
|
144
|
-
const { SQLiteBackend } = await import("./sqlite-
|
|
144
|
+
const { SQLiteBackend } = await import("./sqlite-DtaL1glm.mjs");
|
|
145
145
|
return new SQLiteBackend(openFederation());
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
//#endregion
|
|
149
149
|
export { getBackendOutage as n, humanDuration as r, createStorageBackend as t };
|
|
150
|
-
//# sourceMappingURL=factory-
|
|
150
|
+
//# sourceMappingURL=factory-DD2T33C9.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"factory-Bsp7xOpO.mjs","names":[],"sources":["../src/storage/outage.ts","../src/storage/factory.ts"],"sourcesContent":["/**\n * outage.ts — is the storage backend actually reachable right now?\n *\n * The daemon retries a dead backend forever, which is correct: a Postgres\n * container that is down will usually come back, and giving up would lose the\n * work queue. What was wrong is that it did so in complete silence.\n *\n * Observed 2026-07-26: the container was down for roughly two days. The daemon\n * logged \"Postgres unavailable\" 144 times over 36 minutes, the work queue backed\n * up, session notes for the whole period were never written — and\n * `pai daemon status` reported \"Index: idle\" throughout. The one command anyone\n * would run to check said everything was fine.\n *\n * So the outage is recorded where the status command can see it, and escalated\n * once through the notification channels that were already configured and\n * already unused for this.\n */\n\nexport interface BackendOutage {\n backend: string;\n /** When the current run of failures began. */\n since: number;\n /** Consecutive failed attempts so far. */\n attempts: number;\n lastError: string;\n}\n\nlet current: BackendOutage | null = null;\n\n/** Record that the backend is currently unreachable. */\nexport function setBackendOutage(outage: BackendOutage): void {\n current = outage;\n}\n\n/**\n * Record that the backend answered.\n *\n * Called on every successful connection, including the first — so a daemon that\n * never had a problem reports none, and one that recovered stops reporting an\n * outage that has ended.\n */\nexport function clearBackendOutage(): void {\n current = null;\n}\n\n/** The current outage, or null when the backend is answering. */\nexport function getBackendOutage(): BackendOutage | null {\n return current;\n}\n\n/**\n * Human-readable elapsed time, for a status line rather than a log.\n *\n * Exported because the status command needs the duration on its own, to colour\n * it separately from the rest of the sentence. Without this it kept a second\n * copy of the same rounding, which is how two renderings of one outage drift.\n */\nexport function humanDuration(ms: number): string {\n const mins = Math.max(1, Math.round(ms / 60_000));\n return mins < 60 ? `${mins} min` : `${(mins / 60).toFixed(1)} h`;\n}\n\n/** Human-readable duration, for a status line rather than a log. */\nexport function describeOutage(o: BackendOutage, now = Date.now()): string {\n return `${o.backend} unreachable for ${humanDuration(now - o.since)}, ${o.attempts} attempts — ${o.lastError}`;\n}\n","/**\n * Storage backend factory.\n *\n * Reads the daemon config and returns the appropriate StorageBackend.\n *\n * When Postgres is the configured backend we NEVER silently fall back to\n * SQLite — doing so would split the corpus across two databases. Instead:\n * - Daemon (waitForPostgres: true) retries Postgres forever with capped\n * backoff until it comes up (handles the boot race where launchd starts\n * the daemon before Docker Desktop / Postgres is ready).\n * - CLI / one-shot callers (default) retry a few times, then throw a clear\n * error rather than returning a wrong/empty SQLite database.\n */\n\nimport type { PaiDaemonConfig } from \"../daemon/config.js\";\nimport type { StorageBackend } from \"./interface.js\";\nimport { setBackendOutage, clearBackendOutage } from \"./outage.js\";\n\nexport interface StorageBackendOptions {\n /**\n * When true, retry Postgres indefinitely instead of giving up. Used by the\n * long-lived daemon so a not-yet-ready Postgres at boot is tolerated.\n * Defaults to false (one-shot CLI behaviour: bounded retries, then throw).\n */\n waitForPostgres?: boolean;\n}\n\n/** Backoff schedule (ms) for the bounded CLI retry path. */\nconst CLI_RETRY_DELAYS_MS = [500, 1_000, 2_000];\n\n/** Backoff cap (ms) for the daemon's infinite retry path. */\nconst DAEMON_RETRY_CAP_MS = 15_000;\n\n/**\n * Create and return the configured StorageBackend.\n *\n * Auto-behaviour:\n * - storageBackend = \"sqlite\" → SQLiteBackend always\n * - storageBackend = \"postgres\" → PostgresBackend (retried; never falls back)\n */\nexport async function createStorageBackend(\n config: PaiDaemonConfig,\n opts: StorageBackendOptions = {}\n): Promise<StorageBackend> {\n if (config.storageBackend === \"postgres\") {\n return await connectPostgres(config, opts.waitForPostgres ?? false);\n }\n\n // Default: SQLite\n return createSQLiteBackend();\n}\n\n/**\n * Attempt a single Postgres connection (ensure DB + test). Returns the live\n * backend on success, or an error string describing why it failed.\n */\nasync function attemptPostgres(\n config: PaiDaemonConfig\n): Promise<{ backend: StorageBackend } | { error: string }> {\n const { PostgresBackend } = await import(\"./postgres.js\");\n const pgConfig = config.postgres ?? {};\n\n let backend: InstanceType<typeof PostgresBackend> | null = null;\n try {\n // Ensure the per-user database exists and has the schema applied.\n await PostgresBackend.ensureDatabase(pgConfig);\n\n backend = new PostgresBackend(pgConfig);\n const err = await backend.testConnection();\n if (err) {\n await backend.close().catch(() => {});\n return { error: err };\n }\n return { backend };\n } catch (e) {\n if (backend) await backend.close().catch(() => {});\n return { error: e instanceof Error ? e.message : String(e) };\n }\n}\n\n/**\n * Consecutive failures before the outage is escalated to the user.\n *\n * Not the first failure: a container restarting, or the daemon starting before\n * Docker is up, recovers within a few seconds and is not worth a notification.\n * By the fifth attempt the backoff has already spent tens of seconds, which is\n * long enough that something is actually wrong.\n */\nconst ESCALATE_AFTER_ATTEMPTS = 5;\n\n/** Tell the user the backend is down, through whatever channels are configured. */\nasync function notifyBackendDown(attempts: number, lastError: string): Promise<void> {\n try {\n const { routeNotification } = await import(\"../notifications/router.js\");\n const { loadConfig } = await import(\"../daemon/config.js\");\n await routeNotification(\n {\n event: \"error\",\n title: \"PAI: storage backend unreachable\",\n message:\n `Postgres has not answered in ${attempts} attempts (${lastError}). ` +\n `Indexing, session notes and the work queue are stalled until it returns. ` +\n `Check the container, then \\`pai daemon status\\`.`,\n },\n loadConfig().notifications\n );\n } catch {\n // A notification that cannot be sent must never take the daemon down with\n // it — the daemon retrying is still the useful behaviour here.\n }\n}\n\n/** And say when it comes back, so the alert is not left hanging. */\nasync function notifyBackendRecovered(\n attempts: number,\n since: number | null\n): Promise<void> {\n try {\n const { routeNotification } = await import(\"../notifications/router.js\");\n const { loadConfig } = await import(\"../daemon/config.js\");\n const mins = since ? Math.max(1, Math.round((Date.now() - since) / 60_000)) : null;\n await routeNotification(\n {\n event: \"completion\",\n title: \"PAI: storage backend back\",\n message:\n `Postgres answered after ${attempts} attempts` +\n (mins ? `, ${mins} min down` : \"\") +\n `. The queue will drain on its own.`,\n },\n loadConfig().notifications\n );\n } catch {\n /* same reasoning as above */\n }\n}\n\nasync function connectPostgres(\n config: PaiDaemonConfig,\n waitForever: boolean\n): Promise<StorageBackend> {\n let attempt = 0;\n let lastError = \"unknown error\";\n let outageSince: number | null = null;\n let escalated = false;\n\n // eslint-disable-next-line no-constant-condition\n while (true) {\n attempt++;\n const result = await attemptPostgres(config);\n if (\"backend\" in result) {\n if (attempt > 1) {\n process.stderr.write(\n `[pai-daemon] Connected to PostgreSQL backend (after ${attempt} attempts).\\n`\n );\n } else {\n process.stderr.write(\"[pai-daemon] Connected to PostgreSQL backend.\\n\");\n }\n // An outage that ended must stop being reported, or the status command\n // trades one wrong answer for another.\n clearBackendOutage();\n if (escalated) void notifyBackendRecovered(attempt, outageSince);\n return result.backend;\n }\n\n lastError = result.error;\n\n if (!waitForever && attempt > CLI_RETRY_DELAYS_MS.length) {\n // Bounded CLI path exhausted — fail loudly, never silently use SQLite.\n throw new Error(\n `Postgres backend unreachable after ${attempt} attempts: ${lastError}. ` +\n `Is Docker Desktop / Postgres running? Refusing to fall back to SQLite ` +\n `(would split the corpus). Start Postgres and retry.`\n );\n }\n\n const delayMs = waitForever\n ? Math.min(DAEMON_RETRY_CAP_MS, 1_000 * 2 ** Math.min(attempt - 1, 4))\n : CLI_RETRY_DELAYS_MS[attempt - 1];\n\n process.stderr.write(\n `[pai-daemon] Postgres unavailable (${lastError}). ` +\n `Retry ${attempt}${waitForever ? \"\" : `/${CLI_RETRY_DELAYS_MS.length + 1}`} ` +\n `in ${delayMs}ms...\\n`\n );\n\n // Publish the outage so `pai daemon status` can report it. Without this the\n // daemon retries silently forever and status still reads \"idle\" — which is\n // what happened for two days in July: 144 retries over 36 minutes, session\n // notes never written, and the one command anyone would run to check\n // reporting that everything was fine.\n setBackendOutage({\n backend: \"postgres\",\n since: outageSince ?? (outageSince = Date.now()),\n attempts: attempt,\n lastError: String(lastError),\n });\n\n // And escalate once, out loud, rather than only into a log nobody tails.\n // Once — not per retry — because a notification that repeats every few\n // seconds is filtered within a minute and stops being a signal at all.\n if (waitForever && attempt === ESCALATE_AFTER_ATTEMPTS && !escalated) {\n escalated = true;\n void notifyBackendDown(attempt, String(lastError));\n }\n\n await new Promise((r) => setTimeout(r, delayMs));\n }\n}\n\nasync function createSQLiteBackend(): Promise<StorageBackend> {\n const { openFederation } = await import(\"../memory/db.js\");\n const { SQLiteBackend } = await import(\"./sqlite.js\");\n const db = openFederation();\n return new SQLiteBackend(db);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"factory-DD2T33C9.mjs","names":[],"sources":["../src/storage/outage.ts","../src/storage/factory.ts"],"sourcesContent":["/**\n * outage.ts — is the storage backend actually reachable right now?\n *\n * The daemon retries a dead backend forever, which is correct: a Postgres\n * container that is down will usually come back, and giving up would lose the\n * work queue. What was wrong is that it did so in complete silence.\n *\n * Observed 2026-07-26: the container was down for roughly two days. The daemon\n * logged \"Postgres unavailable\" 144 times over 36 minutes, the work queue backed\n * up, session notes for the whole period were never written — and\n * `pai daemon status` reported \"Index: idle\" throughout. The one command anyone\n * would run to check said everything was fine.\n *\n * So the outage is recorded where the status command can see it, and escalated\n * once through the notification channels that were already configured and\n * already unused for this.\n */\n\nexport interface BackendOutage {\n backend: string;\n /** When the current run of failures began. */\n since: number;\n /** Consecutive failed attempts so far. */\n attempts: number;\n lastError: string;\n}\n\nlet current: BackendOutage | null = null;\n\n/** Record that the backend is currently unreachable. */\nexport function setBackendOutage(outage: BackendOutage): void {\n current = outage;\n}\n\n/**\n * Record that the backend answered.\n *\n * Called on every successful connection, including the first — so a daemon that\n * never had a problem reports none, and one that recovered stops reporting an\n * outage that has ended.\n */\nexport function clearBackendOutage(): void {\n current = null;\n}\n\n/** The current outage, or null when the backend is answering. */\nexport function getBackendOutage(): BackendOutage | null {\n return current;\n}\n\n/**\n * Human-readable elapsed time, for a status line rather than a log.\n *\n * Exported because the status command needs the duration on its own, to colour\n * it separately from the rest of the sentence. Without this it kept a second\n * copy of the same rounding, which is how two renderings of one outage drift.\n */\nexport function humanDuration(ms: number): string {\n const mins = Math.max(1, Math.round(ms / 60_000));\n return mins < 60 ? `${mins} min` : `${(mins / 60).toFixed(1)} h`;\n}\n\n/** Human-readable duration, for a status line rather than a log. */\nexport function describeOutage(o: BackendOutage, now = Date.now()): string {\n return `${o.backend} unreachable for ${humanDuration(now - o.since)}, ${o.attempts} attempts — ${o.lastError}`;\n}\n","/**\n * Storage backend factory.\n *\n * Reads the daemon config and returns the appropriate StorageBackend.\n *\n * When Postgres is the configured backend we NEVER silently fall back to\n * SQLite — doing so would split the corpus across two databases. Instead:\n * - Daemon (waitForPostgres: true) retries Postgres forever with capped\n * backoff until it comes up (handles the boot race where launchd starts\n * the daemon before Docker Desktop / Postgres is ready).\n * - CLI / one-shot callers (default) retry a few times, then throw a clear\n * error rather than returning a wrong/empty SQLite database.\n */\n\nimport type { PaiDaemonConfig } from \"../daemon/config.js\";\nimport type { StorageBackend } from \"./interface.js\";\nimport { setBackendOutage, clearBackendOutage } from \"./outage.js\";\n\nexport interface StorageBackendOptions {\n /**\n * When true, retry Postgres indefinitely instead of giving up. Used by the\n * long-lived daemon so a not-yet-ready Postgres at boot is tolerated.\n * Defaults to false (one-shot CLI behaviour: bounded retries, then throw).\n */\n waitForPostgres?: boolean;\n}\n\n/** Backoff schedule (ms) for the bounded CLI retry path. */\nconst CLI_RETRY_DELAYS_MS = [500, 1_000, 2_000];\n\n/** Backoff cap (ms) for the daemon's infinite retry path. */\nconst DAEMON_RETRY_CAP_MS = 15_000;\n\n/**\n * Create and return the configured StorageBackend.\n *\n * Auto-behaviour:\n * - storageBackend = \"sqlite\" → SQLiteBackend always\n * - storageBackend = \"postgres\" → PostgresBackend (retried; never falls back)\n */\nexport async function createStorageBackend(\n config: PaiDaemonConfig,\n opts: StorageBackendOptions = {}\n): Promise<StorageBackend> {\n if (config.storageBackend === \"postgres\") {\n return await connectPostgres(config, opts.waitForPostgres ?? false);\n }\n\n // Default: SQLite\n return createSQLiteBackend();\n}\n\n/**\n * Attempt a single Postgres connection (ensure DB + test). Returns the live\n * backend on success, or an error string describing why it failed.\n */\nasync function attemptPostgres(\n config: PaiDaemonConfig\n): Promise<{ backend: StorageBackend } | { error: string }> {\n const { PostgresBackend } = await import(\"./postgres.js\");\n const pgConfig = config.postgres ?? {};\n\n let backend: InstanceType<typeof PostgresBackend> | null = null;\n try {\n // Ensure the per-user database exists and has the schema applied.\n await PostgresBackend.ensureDatabase(pgConfig);\n\n backend = new PostgresBackend(pgConfig);\n const err = await backend.testConnection();\n if (err) {\n await backend.close().catch(() => {});\n return { error: err };\n }\n return { backend };\n } catch (e) {\n if (backend) await backend.close().catch(() => {});\n return { error: e instanceof Error ? e.message : String(e) };\n }\n}\n\n/**\n * Consecutive failures before the outage is escalated to the user.\n *\n * Not the first failure: a container restarting, or the daemon starting before\n * Docker is up, recovers within a few seconds and is not worth a notification.\n * By the fifth attempt the backoff has already spent tens of seconds, which is\n * long enough that something is actually wrong.\n */\nconst ESCALATE_AFTER_ATTEMPTS = 5;\n\n/** Tell the user the backend is down, through whatever channels are configured. */\nasync function notifyBackendDown(attempts: number, lastError: string): Promise<void> {\n try {\n const { routeNotification } = await import(\"../notifications/router.js\");\n const { loadConfig } = await import(\"../daemon/config.js\");\n await routeNotification(\n {\n event: \"error\",\n title: \"PAI: storage backend unreachable\",\n message:\n `Postgres has not answered in ${attempts} attempts (${lastError}). ` +\n `Indexing, session notes and the work queue are stalled until it returns. ` +\n `Check the container, then \\`pai daemon status\\`.`,\n },\n loadConfig().notifications\n );\n } catch {\n // A notification that cannot be sent must never take the daemon down with\n // it — the daemon retrying is still the useful behaviour here.\n }\n}\n\n/** And say when it comes back, so the alert is not left hanging. */\nasync function notifyBackendRecovered(\n attempts: number,\n since: number | null\n): Promise<void> {\n try {\n const { routeNotification } = await import(\"../notifications/router.js\");\n const { loadConfig } = await import(\"../daemon/config.js\");\n const mins = since ? Math.max(1, Math.round((Date.now() - since) / 60_000)) : null;\n await routeNotification(\n {\n event: \"completion\",\n title: \"PAI: storage backend back\",\n message:\n `Postgres answered after ${attempts} attempts` +\n (mins ? `, ${mins} min down` : \"\") +\n `. The queue will drain on its own.`,\n },\n loadConfig().notifications\n );\n } catch {\n /* same reasoning as above */\n }\n}\n\nasync function connectPostgres(\n config: PaiDaemonConfig,\n waitForever: boolean\n): Promise<StorageBackend> {\n let attempt = 0;\n let lastError = \"unknown error\";\n let outageSince: number | null = null;\n let escalated = false;\n\n // eslint-disable-next-line no-constant-condition\n while (true) {\n attempt++;\n const result = await attemptPostgres(config);\n if (\"backend\" in result) {\n if (attempt > 1) {\n process.stderr.write(\n `[pai-daemon] Connected to PostgreSQL backend (after ${attempt} attempts).\\n`\n );\n } else {\n process.stderr.write(\"[pai-daemon] Connected to PostgreSQL backend.\\n\");\n }\n // An outage that ended must stop being reported, or the status command\n // trades one wrong answer for another.\n clearBackendOutage();\n if (escalated) void notifyBackendRecovered(attempt, outageSince);\n return result.backend;\n }\n\n lastError = result.error;\n\n if (!waitForever && attempt > CLI_RETRY_DELAYS_MS.length) {\n // Bounded CLI path exhausted — fail loudly, never silently use SQLite.\n throw new Error(\n `Postgres backend unreachable after ${attempt} attempts: ${lastError}. ` +\n `Is Docker Desktop / Postgres running? Refusing to fall back to SQLite ` +\n `(would split the corpus). Start Postgres and retry.`\n );\n }\n\n const delayMs = waitForever\n ? Math.min(DAEMON_RETRY_CAP_MS, 1_000 * 2 ** Math.min(attempt - 1, 4))\n : CLI_RETRY_DELAYS_MS[attempt - 1];\n\n process.stderr.write(\n `[pai-daemon] Postgres unavailable (${lastError}). ` +\n `Retry ${attempt}${waitForever ? \"\" : `/${CLI_RETRY_DELAYS_MS.length + 1}`} ` +\n `in ${delayMs}ms...\\n`\n );\n\n // Publish the outage so `pai daemon status` can report it. Without this the\n // daemon retries silently forever and status still reads \"idle\" — which is\n // what happened for two days in July: 144 retries over 36 minutes, session\n // notes never written, and the one command anyone would run to check\n // reporting that everything was fine.\n setBackendOutage({\n backend: \"postgres\",\n since: outageSince ?? (outageSince = Date.now()),\n attempts: attempt,\n lastError: String(lastError),\n });\n\n // And escalate once, out loud, rather than only into a log nobody tails.\n // Once — not per retry — because a notification that repeats every few\n // seconds is filtered within a minute and stops being a signal at all.\n if (waitForever && attempt === ESCALATE_AFTER_ATTEMPTS && !escalated) {\n escalated = true;\n void notifyBackendDown(attempt, String(lastError));\n }\n\n await new Promise((r) => setTimeout(r, delayMs));\n }\n}\n\nasync function createSQLiteBackend(): Promise<StorageBackend> {\n const { openFederation } = await import(\"../memory/db.js\");\n const { SQLiteBackend } = await import(\"./sqlite.js\");\n const db = openFederation();\n return new SQLiteBackend(db);\n}\n"],"mappings":"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"}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// src/hooks/ts/lib/worker-session.ts
|
|
4
|
+
function isWorkerSession(env = process.env) {
|
|
5
|
+
return env.PAI_WORKER === "1";
|
|
6
|
+
}
|
|
7
|
+
|
|
3
8
|
// src/hooks/ts/pre-compact/context-compression-hook.ts
|
|
4
|
-
import { existsSync as existsSync9, readFileSync as
|
|
9
|
+
import { existsSync as existsSync9, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "fs";
|
|
5
10
|
import { basename as basename4, dirname, join as join9 } from "path";
|
|
6
11
|
import { tmpdir as tmpdir3 } from "os";
|
|
7
12
|
import { connect } from "net";
|
|
@@ -164,7 +169,7 @@ async function sendNtfyNotification(message, retries = 2) {
|
|
|
164
169
|
console.error(`ntfy.sh attempt ${attempt + 1} error: ${error}`);
|
|
165
170
|
}
|
|
166
171
|
if (attempt < retries) {
|
|
167
|
-
await new Promise((
|
|
172
|
+
await new Promise((resolve3) => setTimeout(resolve3, 1e3));
|
|
168
173
|
}
|
|
169
174
|
}
|
|
170
175
|
console.error("ntfy.sh notification failed after all retries");
|
|
@@ -960,12 +965,145 @@ function readContextHandoverCache(sessionId) {
|
|
|
960
965
|
}
|
|
961
966
|
}
|
|
962
967
|
|
|
968
|
+
// src/hooks/ts/lib/handover-evidence.ts
|
|
969
|
+
import { readFileSync as readFileSync8 } from "node:fs";
|
|
970
|
+
import { resolve as resolve2 } from "node:path";
|
|
971
|
+
var MIN_TOKEN_LENGTH = 4;
|
|
972
|
+
var FOOTER_NAMED_MISSING = 5;
|
|
973
|
+
var ECHO_HEAD_LENGTH = 80;
|
|
974
|
+
var INJECTED_MARKERS = [
|
|
975
|
+
"HANDOVER FROM THE PREVIOUS SESSION",
|
|
976
|
+
"MODEL-WRITTEN HANDOVER",
|
|
977
|
+
"SESSION STATE RECOVERED AFTER COMPACTION"
|
|
978
|
+
];
|
|
979
|
+
var HANDOVER_WRITERS = /* @__PURE__ */ new Set(["Write", "Edit", "NotebookEdit"]);
|
|
980
|
+
var TOKEN_PATTERNS = [
|
|
981
|
+
// git hashes: 7-40 hex chars with at least one letter (a plain number is not a hash)
|
|
982
|
+
/\b(?=[0-9a-f]*[a-f])[0-9a-f]{7,40}\b/g,
|
|
983
|
+
// package@version
|
|
984
|
+
/(?:@[\w.-]+\/)?[\w.-]+@\d+\.\d+\.\d+(?:-[\w.]+)?/g,
|
|
985
|
+
// semver
|
|
986
|
+
/\bv?\d+\.\d+\.\d+(?:-[\w.]+)?\b/g,
|
|
987
|
+
// absolute paths (two or more segments)
|
|
988
|
+
/(?:^|[\s(`'"])(\/[\w.-]+(?:\/[\w.-]+)+)/gm,
|
|
989
|
+
// relative paths with a slash and an extension
|
|
990
|
+
/(?:^|[\s(`'"])((?:~\/|\.\.?\/)?[\w.-]+(?:\/[\w.-]+)+\.[a-z0-9]{1,5})\b/gm,
|
|
991
|
+
// process ids
|
|
992
|
+
/\bpid\s*[:=#]?\s*(\d{3,7})\b/gi,
|
|
993
|
+
// ISO dates and timestamps
|
|
994
|
+
/\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?Z?)?\b/g,
|
|
995
|
+
// numbers with units — digits only are kept
|
|
996
|
+
/\b(\d[\d,]*)\s*(?:tokens?|ms|MB|GB)\b/g
|
|
997
|
+
];
|
|
998
|
+
function normalise(text) {
|
|
999
|
+
return text.replace(/(\d),(?=\d{3}\b)/g, "$1");
|
|
1000
|
+
}
|
|
1001
|
+
function stripSystemReminders(text) {
|
|
1002
|
+
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "");
|
|
1003
|
+
}
|
|
1004
|
+
function extractCheckableTokens(markdown) {
|
|
1005
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1006
|
+
const out = [];
|
|
1007
|
+
for (const pattern of TOKEN_PATTERNS) {
|
|
1008
|
+
pattern.lastIndex = 0;
|
|
1009
|
+
for (const match of markdown.matchAll(pattern)) {
|
|
1010
|
+
const raw = (match[1] ?? match[0]).trim();
|
|
1011
|
+
const token = normalise(raw);
|
|
1012
|
+
if (token.length < MIN_TOKEN_LENGTH || seen.has(token)) continue;
|
|
1013
|
+
seen.add(token);
|
|
1014
|
+
out.push(token);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
return out;
|
|
1018
|
+
}
|
|
1019
|
+
function buildHaystack(transcriptPaths, handoverPath, handoverHead) {
|
|
1020
|
+
const parts = [];
|
|
1021
|
+
const resolvedHandover = handoverPath ? resolve2(handoverPath) : null;
|
|
1022
|
+
const pushUserText = (text) => {
|
|
1023
|
+
if (INJECTED_MARKERS.some((m) => text.includes(m))) return;
|
|
1024
|
+
parts.push(stripSystemReminders(text));
|
|
1025
|
+
};
|
|
1026
|
+
for (const path of transcriptPaths) {
|
|
1027
|
+
let raw;
|
|
1028
|
+
try {
|
|
1029
|
+
raw = readFileSync8(path, "utf-8");
|
|
1030
|
+
} catch {
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
for (const line of raw.split("\n")) {
|
|
1034
|
+
if (!line.trim()) continue;
|
|
1035
|
+
let entry;
|
|
1036
|
+
try {
|
|
1037
|
+
entry = JSON.parse(line);
|
|
1038
|
+
} catch {
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
const content = entry.message?.content;
|
|
1042
|
+
if (content === void 0) continue;
|
|
1043
|
+
if (entry.type === "user") {
|
|
1044
|
+
if (typeof content === "string") {
|
|
1045
|
+
pushUserText(content);
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
if (!Array.isArray(content)) continue;
|
|
1049
|
+
for (const part of content) {
|
|
1050
|
+
if (part?.type === "text" && typeof part.text === "string") pushUserText(part.text);
|
|
1051
|
+
else if (part?.type === "tool_result") {
|
|
1052
|
+
const text = contentToText(part.content);
|
|
1053
|
+
if (handoverHead && text.startsWith(handoverHead)) continue;
|
|
1054
|
+
parts.push(text);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
} else if (entry.type === "assistant" && Array.isArray(content)) {
|
|
1058
|
+
for (const part of content) {
|
|
1059
|
+
if (part?.type === "text" && typeof part.text === "string") parts.push(part.text);
|
|
1060
|
+
else if (part?.type === "tool_use") {
|
|
1061
|
+
const target = part.input?.file_path;
|
|
1062
|
+
if (resolvedHandover && HANDOVER_WRITERS.has(part.name ?? "") && typeof target === "string" && resolve2(target) === resolvedHandover) {
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
parts.push(JSON.stringify(part.input ?? {}));
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return normalise(parts.join("\n"));
|
|
1072
|
+
}
|
|
1073
|
+
function footerFor(found, missing) {
|
|
1074
|
+
const total = found.length + missing.length;
|
|
1075
|
+
if (total === 0) return "Evidence: no checkable identifiers";
|
|
1076
|
+
let footer = `Evidence: ${found.length}/${total} identifiers found in the transcript`;
|
|
1077
|
+
if (missing.length > 0) {
|
|
1078
|
+
const named = missing.slice(0, FOOTER_NAMED_MISSING).join(", ");
|
|
1079
|
+
const more = missing.length - FOOTER_NAMED_MISSING;
|
|
1080
|
+
footer += `; unverified: ${named}${more > 0 ? ` +${more} more` : ""}`;
|
|
1081
|
+
}
|
|
1082
|
+
return footer;
|
|
1083
|
+
}
|
|
1084
|
+
function bindHandoverEvidence(handoverMarkdown, handoverPath, transcriptPaths) {
|
|
1085
|
+
try {
|
|
1086
|
+
const tokens = extractCheckableTokens(handoverMarkdown);
|
|
1087
|
+
if (tokens.length === 0) {
|
|
1088
|
+
return { total: 0, found: [], missing: [], footer: footerFor([], []) };
|
|
1089
|
+
}
|
|
1090
|
+
const head = handoverMarkdown.trim().slice(0, ECHO_HEAD_LENGTH);
|
|
1091
|
+
const haystack = buildHaystack(transcriptPaths, handoverPath, head);
|
|
1092
|
+
const found = [];
|
|
1093
|
+
const missing = [];
|
|
1094
|
+
for (const token of tokens) (haystack.includes(token) ? found : missing).push(token);
|
|
1095
|
+
return { total: tokens.length, found, missing, footer: footerFor(found, missing) };
|
|
1096
|
+
} catch {
|
|
1097
|
+
return { total: 0, found: [], missing: [], footer: "Evidence: check unavailable" };
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
963
1101
|
// src/hooks/ts/pre-compact/context-compression-hook.ts
|
|
964
1102
|
var DAEMON_SOCKET = process.env.PAI_SOCKET ?? "/tmp/pai.sock";
|
|
965
1103
|
var DAEMON_TIMEOUT_MS = 3e3;
|
|
966
1104
|
function getTranscriptStats(transcriptPath) {
|
|
967
1105
|
try {
|
|
968
|
-
const content =
|
|
1106
|
+
const content = readFileSync9(transcriptPath, "utf-8");
|
|
969
1107
|
const lines = content.trim().split("\n");
|
|
970
1108
|
let userMessages = 0;
|
|
971
1109
|
let assistantMessages = 0;
|
|
@@ -995,7 +1133,7 @@ function parseTranscript(transcriptPath, scratchpadDir) {
|
|
|
995
1133
|
workItems: []
|
|
996
1134
|
};
|
|
997
1135
|
try {
|
|
998
|
-
const raw =
|
|
1136
|
+
const raw = readFileSync9(transcriptPath, "utf-8");
|
|
999
1137
|
const lines = raw.trim().split("\n");
|
|
1000
1138
|
const seenSummaries = /* @__PURE__ */ new Set();
|
|
1001
1139
|
for (const line of lines) {
|
|
@@ -1141,7 +1279,7 @@ function loadCumulativeState(notesDir) {
|
|
|
1141
1279
|
try {
|
|
1142
1280
|
const filePath = join9(notesDir, CUMULATIVE_STATE_FILE);
|
|
1143
1281
|
if (!existsSync9(filePath)) return null;
|
|
1144
|
-
const raw = JSON.parse(
|
|
1282
|
+
const raw = JSON.parse(readFileSync9(filePath, "utf-8"));
|
|
1145
1283
|
return {
|
|
1146
1284
|
userMessages: raw.userMessages || [],
|
|
1147
1285
|
summaries: raw.summaries || [],
|
|
@@ -1159,7 +1297,7 @@ function readPreviousCompactionTimestamp(notesDir) {
|
|
|
1159
1297
|
try {
|
|
1160
1298
|
const filePath = join9(notesDir, CUMULATIVE_STATE_FILE);
|
|
1161
1299
|
if (!existsSync9(filePath)) return null;
|
|
1162
|
-
const raw = JSON.parse(
|
|
1300
|
+
const raw = JSON.parse(readFileSync9(filePath, "utf-8"));
|
|
1163
1301
|
return typeof raw.lastUpdated === "string" ? raw.lastUpdated : null;
|
|
1164
1302
|
} catch {
|
|
1165
1303
|
return null;
|
|
@@ -1197,7 +1335,7 @@ function saveCumulativeState(notesDir, data, notePath) {
|
|
|
1197
1335
|
}
|
|
1198
1336
|
}
|
|
1199
1337
|
function enqueueRegistryScan() {
|
|
1200
|
-
return new Promise((
|
|
1338
|
+
return new Promise((resolve3) => {
|
|
1201
1339
|
let done = false;
|
|
1202
1340
|
let timer = null;
|
|
1203
1341
|
function finish() {
|
|
@@ -1211,7 +1349,7 @@ function enqueueRegistryScan() {
|
|
|
1211
1349
|
client.destroy();
|
|
1212
1350
|
} catch {
|
|
1213
1351
|
}
|
|
1214
|
-
|
|
1352
|
+
resolve3();
|
|
1215
1353
|
}
|
|
1216
1354
|
const client = connect(DAEMON_SOCKET, () => {
|
|
1217
1355
|
const msg = JSON.stringify({
|
|
@@ -1232,7 +1370,7 @@ function enqueueRegistryScan() {
|
|
|
1232
1370
|
});
|
|
1233
1371
|
}
|
|
1234
1372
|
function enqueueSessionSummary(payload) {
|
|
1235
|
-
return new Promise((
|
|
1373
|
+
return new Promise((resolve3) => {
|
|
1236
1374
|
let done = false;
|
|
1237
1375
|
let buffer = "";
|
|
1238
1376
|
let timer = null;
|
|
@@ -1247,7 +1385,7 @@ function enqueueSessionSummary(payload) {
|
|
|
1247
1385
|
client.destroy();
|
|
1248
1386
|
} catch {
|
|
1249
1387
|
}
|
|
1250
|
-
|
|
1388
|
+
resolve3(ok);
|
|
1251
1389
|
}
|
|
1252
1390
|
const client = connect(DAEMON_SOCKET, () => {
|
|
1253
1391
|
const msg = JSON.stringify({
|
|
@@ -1299,6 +1437,7 @@ function enqueueSessionSummary(payload) {
|
|
|
1299
1437
|
});
|
|
1300
1438
|
}
|
|
1301
1439
|
async function main() {
|
|
1440
|
+
if (isWorkerSession()) return;
|
|
1302
1441
|
if (isProbeSession()) {
|
|
1303
1442
|
process.exit(0);
|
|
1304
1443
|
}
|
|
@@ -1306,8 +1445,8 @@ async function main() {
|
|
|
1306
1445
|
try {
|
|
1307
1446
|
const decoder = new TextDecoder();
|
|
1308
1447
|
let input = "";
|
|
1309
|
-
const timeoutPromise = new Promise((
|
|
1310
|
-
setTimeout(
|
|
1448
|
+
const timeoutPromise = new Promise((resolve3) => {
|
|
1449
|
+
setTimeout(resolve3, 500);
|
|
1311
1450
|
});
|
|
1312
1451
|
const readPromise = (async () => {
|
|
1313
1452
|
for await (const chunk of process.stdin) {
|
|
@@ -1352,7 +1491,7 @@ async function main() {
|
|
|
1352
1491
|
notePath = createSessionNote(notesInfo.path, "Untitled Session");
|
|
1353
1492
|
} else {
|
|
1354
1493
|
try {
|
|
1355
|
-
let noteContent =
|
|
1494
|
+
let noteContent = readFileSync9(notePath, "utf-8");
|
|
1356
1495
|
if (noteContent.includes("**Status:** Completed")) {
|
|
1357
1496
|
noteContent = noteContent.replace("**Status:** Completed", "**Status:** In Progress");
|
|
1358
1497
|
writeFileSync5(notePath, noteContent);
|
|
@@ -1374,7 +1513,7 @@ ${state}` : `Context compression triggered at ~${tokenDisplay} tokens with ${sta
|
|
|
1374
1513
|
const newPath = renameSessionNote(notePath, title);
|
|
1375
1514
|
if (newPath !== notePath) {
|
|
1376
1515
|
try {
|
|
1377
|
-
let noteContent =
|
|
1516
|
+
let noteContent = readFileSync9(newPath, "utf-8");
|
|
1378
1517
|
noteContent = noteContent.replace(
|
|
1379
1518
|
/^(# Session \d+:)\s*.*$/m,
|
|
1380
1519
|
`$1 ${title}`
|
|
@@ -1408,9 +1547,11 @@ If this note still has a generic title (e.g. "New Session", "Context Compression
|
|
|
1408
1547
|
rename it based on actual work done and add a rich summary.` : "";
|
|
1409
1548
|
let sourceLabel;
|
|
1410
1549
|
let handoverBlock;
|
|
1550
|
+
let handoverSummary = null;
|
|
1411
1551
|
try {
|
|
1412
1552
|
const cachedHandover = readContextHandoverCache(hookInput.session_id);
|
|
1413
1553
|
const handoverIsFresh = cachedHandover !== null && (!previousCompactionAt || new Date(cachedHandover.generatedAt) > new Date(previousCompactionAt));
|
|
1554
|
+
handoverSummary = handoverIsFresh ? cachedHandover.summary : null;
|
|
1414
1555
|
sourceLabel = handoverIsFresh ? `model-written handover (${cachedHandover.model}, generated ${cachedHandover.generatedAt}, threshold=${cachedHandover.threshold}) + mechanical scrape` : "mechanical scrape only (no fresh model-written handover was available)";
|
|
1415
1556
|
handoverBlock = handoverIsFresh ? [
|
|
1416
1557
|
"",
|
|
@@ -1423,6 +1564,15 @@ rename it based on actual work done and add a rich summary.` : "";
|
|
|
1423
1564
|
sourceLabel = `mechanical scrape only (error resolving handover cache: ${err})`;
|
|
1424
1565
|
handoverBlock = "";
|
|
1425
1566
|
}
|
|
1567
|
+
if (handoverSummary !== null && typeof hookInput.transcript_path === "string") {
|
|
1568
|
+
try {
|
|
1569
|
+
const evidence = bindHandoverEvidence(handoverSummary, null, [hookInput.transcript_path]);
|
|
1570
|
+
handoverBlock += `
|
|
1571
|
+
${evidence.footer}`;
|
|
1572
|
+
} catch (err) {
|
|
1573
|
+
console.error(`Evidence check skipped: ${err}`);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1426
1576
|
const injection = [
|
|
1427
1577
|
"<system-reminder>",
|
|
1428
1578
|
`SESSION STATE RECOVERED AFTER COMPACTION (${compactType}, ~${tokenDisplay} tokens)`,
|