@tekmidian/pai 0.42.5 → 0.42.6
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/ARCHITECTURE.md +9 -9
- package/README.md +26 -8
- package/dist/{aibroker-client-BP74gGwD.mjs → aibroker-client-Bn9SnGMa.mjs} +3 -3
- package/dist/{aibroker-client-BP74gGwD.mjs.map → aibroker-client-Bn9SnGMa.mjs.map} +1 -1
- package/dist/{aibroker-client-C6m71p6O.mjs → aibroker-client-D5NT0S1N.mjs} +1 -1
- package/dist/{auto-route-_5zzJz5q.mjs → auto-route-CcD0OWWB.mjs} +4 -4
- package/dist/{auto-route-_5zzJz5q.mjs.map → auto-route-CcD0OWWB.mjs.map} +1 -1
- package/dist/{chain-DTII7xeL.mjs → chain-BbcfP77K.mjs} +5 -4
- package/dist/{chain-DTII7xeL.mjs.map → chain-BbcfP77K.mjs.map} +1 -1
- package/dist/cli/index.mjs +23 -21
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/program.d.mts.map +1 -1
- package/dist/cli/program.mjs +23 -21
- package/dist/{clusters-j0ZuNYp9.mjs → clusters-Cw_P6lO-.mjs} +2 -2
- package/dist/{clusters-j0ZuNYp9.mjs.map → clusters-Cw_P6lO-.mjs.map} +1 -1
- package/dist/{config-B64vFg14.mjs → config-DCu8THKi.mjs} +41 -9
- package/dist/{config-B64vFg14.mjs.map → config-DCu8THKi.mjs.map} +1 -1
- package/dist/config-DeqFqa1N.mjs +3 -0
- package/dist/config-DxMZjpvY.mjs +1289 -0
- package/dist/config-DxMZjpvY.mjs.map +1 -0
- package/dist/context-handover-cache-B9NKSjwR.mjs +3242 -0
- package/dist/context-handover-cache-B9NKSjwR.mjs.map +1 -0
- package/dist/daemon/index.mjs +23 -22
- package/dist/daemon/index.mjs.map +1 -1
- package/dist/daemon-BNjbVsQU.mjs +24 -0
- package/dist/{daemon-B8N1egl0.mjs → daemon-BppziPi6.mjs} +52 -38
- package/dist/daemon-BppziPi6.mjs.map +1 -0
- package/dist/daemon-mcp/index.mjs +144 -117
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/db-BOBfewFH.mjs +3 -0
- package/dist/{db-a1ixZQjr.mjs → db-DJ8rk0VM.mjs} +15 -6
- package/dist/{db-a1ixZQjr.mjs.map → db-DJ8rk0VM.mjs.map} +1 -1
- package/dist/db-DbY2O8QO.mjs +3 -0
- package/dist/{db-Ca5qfsMC.mjs → db-Ddns80yf.mjs} +15 -6
- package/dist/{db-Ca5qfsMC.mjs.map → db-Ddns80yf.mjs.map} +1 -1
- package/dist/{detect-CdaA48EI.mjs → detect-Bf2z-oKB.mjs} +1 -1
- package/dist/{detect-CdaA48EI.mjs.map → detect-Bf2z-oKB.mjs.map} +1 -1
- package/dist/detector-CkwkXuGh.mjs +5 -0
- package/dist/{detector-C73Nxm94.mjs → detector-DWXkeTlk.mjs} +2 -2
- package/dist/{detector-C73Nxm94.mjs.map → detector-DWXkeTlk.mjs.map} +1 -1
- package/dist/{embeddings-CEBGrzwu.mjs → embeddings-ClqKtLZ8.mjs} +1 -1
- package/dist/{embeddings-DOLZnT1X.mjs → embeddings-DvRKcQyt.mjs} +1 -1
- package/dist/{embeddings-DOLZnT1X.mjs.map → embeddings-DvRKcQyt.mjs.map} +1 -1
- package/dist/factory-47Qf_df7.mjs +3 -0
- package/dist/{factory-DEb9NmHJ.mjs → factory-DNYAqjH0.mjs} +8 -8
- package/dist/{factory-DEb9NmHJ.mjs.map → factory-DNYAqjH0.mjs.map} +1 -1
- package/dist/{fallback-oJl3BaIN.mjs → fallback-Cwt4hmh5.mjs} +182 -122
- package/dist/fallback-Cwt4hmh5.mjs.map +1 -0
- package/dist/{helpers-IjZkXBhj.mjs → helpers-crDEr6S2.mjs} +1 -1
- package/dist/{helpers-IjZkXBhj.mjs.map → helpers-crDEr6S2.mjs.map} +1 -1
- package/dist/hooks/capture-all-events.mjs +117 -34
- package/dist/hooks/capture-all-events.mjs.map +4 -4
- package/dist/hooks/capture-session-summary.mjs +110 -33
- package/dist/hooks/capture-session-summary.mjs.map +4 -4
- package/dist/hooks/capture-tool-output.mjs +105 -28
- package/dist/hooks/capture-tool-output.mjs.map +4 -4
- package/dist/hooks/cleanup-session-files.mjs +54 -13
- package/dist/hooks/cleanup-session-files.mjs.map +2 -2
- package/dist/hooks/context-compression-hook.mjs +7565 -122
- package/dist/hooks/context-compression-hook.mjs.map +4 -4
- package/dist/hooks/initialize-session.mjs +55 -14
- package/dist/hooks/initialize-session.mjs.map +2 -2
- package/dist/hooks/inject-observations.mjs +79 -18
- package/dist/hooks/inject-observations.mjs.map +3 -3
- package/dist/hooks/load-core-context.mjs +55 -14
- package/dist/hooks/load-core-context.mjs.map +2 -2
- package/dist/hooks/load-project-context.mjs +185 -103
- package/dist/hooks/load-project-context.mjs.map +4 -4
- package/dist/hooks/mcp-deferred-gate.mjs +54 -13
- package/dist/hooks/mcp-deferred-gate.mjs.map +2 -2
- package/dist/hooks/mcp-deferred-reminder.mjs +9 -0
- package/dist/hooks/mcp-deferred-reminder.mjs.map +3 -3
- package/dist/hooks/observe.mjs +54 -13
- package/dist/hooks/observe.mjs.map +2 -2
- package/dist/hooks/post-compact-inject.mjs +7419 -16
- package/dist/hooks/post-compact-inject.mjs.map +4 -4
- package/dist/hooks/route-agents-to-worker.mjs +7778 -171
- package/dist/hooks/route-agents-to-worker.mjs.map +4 -4
- package/dist/hooks/route-edits-to-worker.mjs +90 -0
- package/dist/hooks/route-edits-to-worker.mjs.map +7 -0
- package/dist/hooks/security-validator.mjs +147 -6
- package/dist/hooks/security-validator.mjs.map +4 -4
- package/dist/hooks/session-commands.mjs +9 -0
- package/dist/hooks/session-commands.mjs.map +3 -3
- package/dist/hooks/stop-hook.mjs +182 -105
- package/dist/hooks/stop-hook.mjs.map +4 -4
- package/dist/hooks/subagent-stop-hook.mjs +3 -0
- package/dist/hooks/subagent-stop-hook.mjs.map +1 -1
- package/dist/hooks/sync-todo-to-md.mjs +54 -13
- package/dist/hooks/sync-todo-to-md.mjs.map +2 -2
- package/dist/hooks/update-tab-on-action.mjs +3 -0
- package/dist/hooks/update-tab-on-action.mjs.map +1 -1
- package/dist/hooks/update-tab-titles.mjs +3 -0
- package/dist/hooks/update-tab-titles.mjs.map +1 -1
- package/dist/hooks/whisper-reinject.mjs +3 -0
- package/dist/hooks/whisper-reinject.mjs.map +1 -1
- package/dist/hooks/whisper-rules.mjs +134 -29
- package/dist/hooks/whisper-rules.mjs.map +4 -4
- package/dist/hooks/worker-proxy.mjs +7737 -117
- package/dist/hooks/worker-proxy.mjs.map +4 -4
- package/dist/hooks/worker-status-line.mjs +7791 -144
- package/dist/hooks/worker-status-line.mjs.map +4 -4
- package/dist/hooks/worker-supervision.mjs +7721 -115
- package/dist/hooks/worker-supervision.mjs.map +4 -4
- package/dist/index.d.mts +4 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +10 -10
- package/dist/{indexer-backend-BPTzKFZU.mjs → indexer-backend-CdvCJCkD.mjs} +3 -3
- package/dist/{indexer-backend-BPTzKFZU.mjs.map → indexer-backend-CdvCJCkD.mjs.map} +1 -1
- package/dist/{ipc-client-BmypMNYk.mjs → ipc-client-C6NryMMb.mjs} +2 -2
- package/dist/{ipc-client-BmypMNYk.mjs.map → ipc-client-C6NryMMb.mjs.map} +1 -1
- package/dist/{kg-entity-r8duqhi9.mjs → kg-entity-BSjM5xZh.mjs} +1 -1
- package/dist/{kg-entity-r8duqhi9.mjs.map → kg-entity-BSjM5xZh.mjs.map} +1 -1
- package/dist/{latent-ideas-CtSca4qG.mjs → latent-ideas-BfL4nCQ3.mjs} +4 -4
- package/dist/{latent-ideas-CtSca4qG.mjs.map → latent-ideas-BfL4nCQ3.mjs.map} +1 -1
- package/dist/{link-boost-Bucnz3n8.mjs → link-boost-C5PaNdzm.mjs} +1 -1
- package/dist/{link-boost-Bucnz3n8.mjs.map → link-boost-C5PaNdzm.mjs.map} +1 -1
- package/dist/main-resolver-CnfnWa8C.mjs +7 -0
- package/dist/{main-resolver-DnkxPYkr.mjs → main-resolver-DvYu9jbl.mjs} +21 -11
- package/dist/main-resolver-DvYu9jbl.mjs.map +1 -0
- package/dist/{migrate-Cjzeefn9.mjs → migrate-CfERwXhY.mjs} +2 -2
- package/dist/{migrate-Cjzeefn9.mjs.map → migrate-CfERwXhY.mjs.map} +1 -1
- package/dist/{neighborhood-CM7Jgb51.mjs → neighborhood-CETR_P4y.mjs} +2 -2
- package/dist/{neighborhood-CM7Jgb51.mjs.map → neighborhood-CETR_P4y.mjs.map} +1 -1
- package/dist/{note-context-DCwE2A-M.mjs → note-context-DCcRWBoN.mjs} +1 -1
- package/dist/{note-context-DCwE2A-M.mjs.map → note-context-DCcRWBoN.mjs.map} +1 -1
- package/dist/pai-home-ChAOupZc.mjs +164 -0
- package/dist/pai-home-ChAOupZc.mjs.map +1 -0
- package/dist/{pai-marker-CHtbJMwJ.mjs → pai-marker-B20KqhA8.mjs} +1 -1
- package/dist/{pai-marker-CHtbJMwJ.mjs.map → pai-marker-B20KqhA8.mjs.map} +1 -1
- package/dist/{planner-D5Ui7DAm.mjs → planner-pXRc-8yt.mjs} +7 -6
- package/dist/{planner-D5Ui7DAm.mjs.map → planner-pXRc-8yt.mjs.map} +1 -1
- package/dist/{postgres-CtOzfoQU.mjs → postgres-CUSXWgTG.mjs} +2 -2
- package/dist/{postgres-CtOzfoQU.mjs.map → postgres-CUSXWgTG.mjs.map} +1 -1
- package/dist/{program-zpoYnJGz.mjs → program-LLNPpxlj.mjs} +8308 -6065
- package/dist/program-LLNPpxlj.mjs.map +1 -0
- package/dist/query-feedback-C7rSfiXU.mjs +3 -0
- package/dist/{query-feedback-Dqz-HC__.mjs → query-feedback-CYyOGi3y.mjs} +20 -6
- package/dist/query-feedback-CYyOGi3y.mjs.map +1 -0
- package/dist/{reranker-CwTCNsgA.mjs → reranker-B5qUTamW.mjs} +1 -1
- package/dist/{reranker-xPm04PXx.mjs → reranker-HHmhZk6v.mjs} +1 -1
- package/dist/{reranker-xPm04PXx.mjs.map → reranker-HHmhZk6v.mjs.map} +1 -1
- package/dist/router-CremHKTI.mjs +3 -0
- package/dist/{router-DD6D4j31.mjs → router-DBAZfE7X.mjs} +2 -2
- package/dist/{router-DD6D4j31.mjs.map → router-DBAZfE7X.mjs.map} +1 -1
- package/dist/{run-D7K5UALn.mjs → run-DDdT9teG.mjs} +530 -210
- package/dist/run-DDdT9teG.mjs.map +1 -0
- package/dist/{runtime-paths-rni52zHX.mjs → runtime-paths-B0P1TvUr.mjs} +1 -1
- package/dist/{runtime-paths-rni52zHX.mjs.map → runtime-paths-B0P1TvUr.mjs.map} +1 -1
- package/dist/{search-Rpk1cSBC.mjs → search-B69cQwDB.mjs} +3 -3
- package/dist/{search-Rpk1cSBC.mjs.map → search-B69cQwDB.mjs.map} +1 -1
- package/dist/{search-CfPpJAWQ.mjs → search-fb-5ovj4.mjs} +2 -2
- package/dist/{server-D4vAieF4.mjs → server-Ch99xt5K.mjs} +15 -14
- package/dist/server-Ch99xt5K.mjs.map +1 -0
- package/dist/skills/Advisor/SKILL.md +3 -3
- package/dist/skills/Providers/SKILL.md +30 -0
- package/dist/skills/Reconstruct/SKILL.md +1 -1
- package/dist/skills/VaultContext/SKILL.md +1 -1
- package/dist/skills/Whisper/SKILL.md +3 -3
- package/dist/skills/Worker/SKILL.md +1 -1
- package/dist/{sources-CS43CpuS.mjs → sources-KBGEA_E6.mjs} +2 -2
- package/dist/{sources-CS43CpuS.mjs.map → sources-KBGEA_E6.mjs.map} +1 -1
- package/dist/{sqlite-Cq3gJcq9.mjs → sqlite-By6r2YhZ.mjs} +3 -3
- package/dist/{sqlite-Cq3gJcq9.mjs.map → sqlite-By6r2YhZ.mjs.map} +1 -1
- package/dist/{state-CY6I-Hnc.mjs → state-DnYGRmS7.mjs} +1 -1
- package/dist/{state-BRdB-Bf1.mjs → state-PJrgfDLW.mjs} +1 -1
- package/dist/{state-BRdB-Bf1.mjs.map → state-PJrgfDLW.mjs.map} +1 -1
- package/dist/{stop-words-Hfu8u22w.mjs → stop-words-BaMEGVeY.mjs} +1 -1
- package/dist/{stop-words-Hfu8u22w.mjs.map → stop-words-BaMEGVeY.mjs.map} +1 -1
- package/dist/{sync-BWbe8JTg.mjs → sync-Btcb6xA4.mjs} +3 -3
- package/dist/{sync-BWbe8JTg.mjs.map → sync-Btcb6xA4.mjs.map} +1 -1
- package/dist/{themes-B7h9YDiy.mjs → themes-BVoAnLZj.mjs} +3 -3
- package/dist/{themes-B7h9YDiy.mjs.map → themes-BVoAnLZj.mjs.map} +1 -1
- package/dist/{tools-BW3NLMPk.mjs → tools-BradfQk7.mjs} +4 -3
- package/dist/{tools-LlzcV_eK.mjs → tools-UrSHIe29.mjs} +30 -233
- package/dist/tools-UrSHIe29.mjs.map +1 -0
- package/dist/{trace-u4hT-pLD.mjs → trace-Cy8quQTm.mjs} +1 -1
- package/dist/{trace-u4hT-pLD.mjs.map → trace-Cy8quQTm.mjs.map} +1 -1
- package/dist/{utils-DhMex3Ox.mjs → utils-BD8g8oSf.mjs} +1 -1
- package/dist/{utils-9Err2RBW.mjs → utils-CJymHkYm.mjs} +1 -1
- package/dist/{utils-9Err2RBW.mjs.map → utils-CJymHkYm.mjs.map} +1 -1
- package/dist/{vault-indexer-mata9S7b.mjs → vault-indexer-C5i-OAyr.mjs} +2 -2
- package/dist/{vault-indexer-mata9S7b.mjs.map → vault-indexer-C5i-OAyr.mjs.map} +1 -1
- package/dist/wakeup-TnGjJY3I.mjs +218 -0
- package/dist/wakeup-TnGjJY3I.mjs.map +1 -0
- package/dist/work-queue-worker-BA5IDBwQ.mjs +567 -0
- package/dist/work-queue-worker-BA5IDBwQ.mjs.map +1 -0
- package/dist/work-queue-worker-DLM2wlqM.mjs +14 -0
- package/dist/{zettelkasten-F0D_XfQ4.mjs → zettelkasten-BBjA7GcP.mjs} +4 -4
- package/dist/{zettelkasten-F0D_XfQ4.mjs.map → zettelkasten-BBjA7GcP.mjs.map} +1 -1
- package/docs/commands/README.md +30 -3
- package/docs/commands/audit.md +88 -0
- package/docs/commands/backup.md +2 -2
- package/docs/commands/clear-names.md +1 -1
- package/docs/commands/config.md +59 -0
- 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/launch.md +45 -0
- package/docs/commands/mcp.md +1 -1
- package/docs/commands/memory.md +3 -3
- 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 +46 -1
- package/docs/commands/projects.md +46 -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 +95 -6
- package/docs/commands/zettel.md +1 -1
- package/docs/provider-abstraction.md +1 -1
- package/docs/provider-independence-details.md +8 -5
- package/docs/task-bus.md +1 -1
- package/docs/worker.md +136 -83
- package/docs/workers-config.md +446 -0
- package/package.json +4 -2
- package/scripts/build-hooks.mjs +56 -3
- package/src/hooks/pre-compact.sh +5 -2
- package/src/hooks/session-stop.sh +29 -4
- package/src/hooks/ts/capture-all-events.ts +3 -3
- package/src/hooks/ts/lib/advisor-budget.test.ts +98 -0
- package/src/hooks/ts/lib/advisor-budget.ts +90 -0
- package/src/hooks/ts/lib/agent-gate.test.ts +101 -0
- package/src/hooks/ts/lib/agent-gate.ts +112 -0
- package/src/hooks/ts/lib/context-fill.ts +2 -2
- package/src/hooks/ts/lib/edit-gate.test.ts +52 -0
- package/src/hooks/ts/lib/edit-gate.ts +51 -0
- package/src/hooks/ts/lib/mcp-deferred-reminder.test.ts +8 -0
- package/src/hooks/ts/lib/pai-paths.test.ts +93 -0
- package/src/hooks/ts/lib/pai-paths.ts +168 -24
- package/src/hooks/ts/lib/project-utils/index.ts +1 -0
- package/src/hooks/ts/lib/project-utils/paths.ts +5 -4
- package/src/hooks/ts/lib/project-utils.ts +1 -0
- package/src/hooks/ts/post-tool-use/capture-tool-output.ts +2 -2
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +1 -1
- package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +46 -99
- package/src/hooks/ts/pre-tool-use/route-edits-to-worker.test.ts +47 -0
- package/src/hooks/ts/pre-tool-use/route-edits-to-worker.ts +96 -0
- package/src/hooks/ts/pre-tool-use/security-validator.ts +2 -1
- package/src/hooks/ts/session-end/capture-session-summary.ts +4 -4
- package/src/hooks/ts/session-start/initialize-session.ts +3 -3
- package/src/hooks/ts/session-start/inject-observations.test.ts +56 -0
- package/src/hooks/ts/session-start/inject-observations.ts +28 -8
- package/src/hooks/ts/session-start/load-core-context.ts +4 -4
- package/src/hooks/ts/session-start/load-project-context.ts +2 -2
- package/src/hooks/ts/session-start/mcp-deferred-reminder.ts +2 -0
- package/src/hooks/ts/session-start/session-commands.ts +4 -0
- package/src/hooks/ts/session-start/session-start-worker-guard.test.ts +36 -0
- package/src/hooks/ts/stop/stop-hook.ts +6 -11
- package/src/hooks/ts/user-prompt/whisper-rules.test.ts +49 -0
- package/src/hooks/ts/user-prompt/whisper-rules.ts +88 -47
- package/statusline-command.sh +277 -116
- package/templates/agent-prefs.example.md +8 -8
- package/templates/claude-md.template.md +1 -1
- package/templates/voices.example.json +3 -3
- package/dist/config-C_ErGddD.mjs +0 -3
- package/dist/config-_lQI_W08.mjs +0 -640
- package/dist/config-_lQI_W08.mjs.map +0 -1
- package/dist/context-handover-cache-pNFdBNrU.mjs +0 -1548
- package/dist/context-handover-cache-pNFdBNrU.mjs.map +0 -1
- package/dist/daemon-B8N1egl0.mjs.map +0 -1
- package/dist/daemon-DHfyoTlk.mjs +0 -23
- package/dist/db-O-cyAPfS.mjs +0 -3
- package/dist/db-XEwJbuGO.mjs +0 -3
- package/dist/detector-C-7KRi42.mjs +0 -5
- package/dist/factory-Dbo8yNPi.mjs +0 -3
- package/dist/fallback-oJl3BaIN.mjs.map +0 -1
- package/dist/main-resolver-BAfOMppW.mjs +0 -7
- package/dist/main-resolver-DnkxPYkr.mjs.map +0 -1
- package/dist/program-zpoYnJGz.mjs.map +0 -1
- package/dist/query-feedback-DGoVRaWD.mjs +0 -3
- package/dist/query-feedback-Dqz-HC__.mjs.map +0 -1
- package/dist/router-BTIGZR2_.mjs +0 -3
- package/dist/run-D7K5UALn.mjs.map +0 -1
- package/dist/server-D4vAieF4.mjs.map +0 -1
- package/dist/tools-LlzcV_eK.mjs.map +0 -1
- package/dist/work-queue-worker-AEmfVE0F.mjs +0 -14
- package/dist/work-queue-worker-DIT-VLSh.mjs +0 -2136
- package/dist/work-queue-worker-DIT-VLSh.mjs.map +0 -1
- /package/dist/{indexer-D7MvSQPY.mjs → indexer-C5Le7XE7.mjs} +0 -0
|
@@ -1,2136 +0,0 @@
|
|
|
1
|
-
import { t as openFederation } from "./db-a1ixZQjr.mjs";
|
|
2
|
-
import { c as extractAndStoreTriples$1, f as performScan, i as applyContinue, l as planLlmSpawn, n as writeContextHandoverCache, t as readContextHandoverCache } from "./context-handover-cache-pNFdBNrU.mjs";
|
|
3
|
-
import { d as registryDb, k as storageBackend, n as daemonConfig } from "./state-BRdB-Bf1.mjs";
|
|
4
|
-
import { t as detectTopicShift } from "./detector-C73Nxm94.mjs";
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
-
import { homedir } from "node:os";
|
|
7
|
-
import { basename, dirname, join } from "node:path";
|
|
8
|
-
import { randomUUID } from "node:crypto";
|
|
9
|
-
import { copyFileSync as copyFileSync$1, existsSync as existsSync$1, linkSync as linkSync$1, mkdirSync as mkdirSync$1, readFileSync as readFileSync$1, readdirSync as readdirSync$1, renameSync as renameSync$1, writeFileSync as writeFileSync$1 } from "fs";
|
|
10
|
-
import { basename as basename$1, join as join$1, resolve as resolve$1 } from "path";
|
|
11
|
-
import { homedir as homedir$1 } from "os";
|
|
12
|
-
|
|
13
|
-
//#region src/daemon/work-queue.ts
|
|
14
|
-
/**
|
|
15
|
-
* work-queue.ts — Persistent work queue for the PAI Daemon
|
|
16
|
-
*
|
|
17
|
-
* Provides a durable, file-backed queue that survives daemon restarts.
|
|
18
|
-
* Items are processed sequentially to avoid concurrent writes to the same
|
|
19
|
-
* session note. Failed items are retried with exponential backoff.
|
|
20
|
-
*
|
|
21
|
-
* Queue file: ~/.config/pai/work-queue.json
|
|
22
|
-
* Written atomically (write temp → rename) to prevent corruption.
|
|
23
|
-
*/
|
|
24
|
-
const QUEUE_FILE = join(homedir(), ".config", "pai", "work-queue.json");
|
|
25
|
-
const MAX_QUEUE_SIZE = 1e3;
|
|
26
|
-
const MAX_QUEUE_FILE_BYTES = 1024 * 1024;
|
|
27
|
-
const COMPLETED_TTL_MS = 3600 * 1e3;
|
|
28
|
-
const FAILED_TTL_MS = 1440 * 60 * 1e3;
|
|
29
|
-
/** Backoff delays in ms by attempt number (0-indexed). */
|
|
30
|
-
const BACKOFF_MS = [
|
|
31
|
-
5e3,
|
|
32
|
-
3e4,
|
|
33
|
-
3e5
|
|
34
|
-
];
|
|
35
|
-
let _queue = [];
|
|
36
|
-
let _dirty = false;
|
|
37
|
-
/** Load queue from disk. Call once at daemon startup. */
|
|
38
|
-
function loadQueue() {
|
|
39
|
-
if (!existsSync(QUEUE_FILE)) {
|
|
40
|
-
_queue = [];
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
try {
|
|
44
|
-
const raw = readFileSync(QUEUE_FILE, "utf-8");
|
|
45
|
-
const parsed = JSON.parse(raw);
|
|
46
|
-
if (!Array.isArray(parsed)) {
|
|
47
|
-
process.stderr.write("[work-queue] Invalid queue file format — starting empty.\n");
|
|
48
|
-
_queue = [];
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
_queue = parsed.map((item) => {
|
|
52
|
-
if (item.status === "processing") return {
|
|
53
|
-
...item,
|
|
54
|
-
status: "pending"
|
|
55
|
-
};
|
|
56
|
-
return item;
|
|
57
|
-
});
|
|
58
|
-
const stats = getStats();
|
|
59
|
-
process.stderr.write(`[work-queue] Loaded ${_queue.length} items from disk (pending=${stats.pending}, failed=${stats.failed}).\n`);
|
|
60
|
-
} catch (e) {
|
|
61
|
-
process.stderr.write(`[work-queue] Could not load queue file: ${e}\n`);
|
|
62
|
-
_queue = [];
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
/** Persist queue to disk atomically. */
|
|
66
|
-
function saveQueue() {
|
|
67
|
-
const dir = dirname(QUEUE_FILE);
|
|
68
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
69
|
-
const tmpFile = QUEUE_FILE + ".tmp";
|
|
70
|
-
try {
|
|
71
|
-
writeFileSync(tmpFile, JSON.stringify(_queue, null, 2), "utf-8");
|
|
72
|
-
renameSync(tmpFile, QUEUE_FILE);
|
|
73
|
-
_dirty = false;
|
|
74
|
-
} catch (e) {
|
|
75
|
-
process.stderr.write(`[work-queue] Could not persist queue: ${e}\n`);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
/** Persist only if there are unsaved changes. */
|
|
79
|
-
function saveIfDirty() {
|
|
80
|
-
if (_dirty) saveQueue();
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* Enforce the maximum queue size cap.
|
|
84
|
-
* Strategy: first drop oldest completed, then oldest low-priority pending.
|
|
85
|
-
*/
|
|
86
|
-
function enforceMaxSize() {
|
|
87
|
-
if (_queue.length <= MAX_QUEUE_SIZE) return;
|
|
88
|
-
const excess = _queue.length - MAX_QUEUE_SIZE;
|
|
89
|
-
const toDropCompleted = _queue.filter((i) => i.status === "completed").sort((a, b) => a.createdAt.localeCompare(b.createdAt)).slice(0, excess);
|
|
90
|
-
const dropIds = new Set(toDropCompleted.map((i) => i.id));
|
|
91
|
-
_queue = _queue.filter((i) => !dropIds.has(i.id));
|
|
92
|
-
if (_queue.length <= MAX_QUEUE_SIZE) return;
|
|
93
|
-
const remainingExcess = _queue.length - MAX_QUEUE_SIZE;
|
|
94
|
-
const toDropLow = _queue.filter((i) => i.status === "pending" && i.priority >= 4).sort((a, b) => a.priority - b.priority || a.createdAt.localeCompare(b.createdAt)).slice(0, remainingExcess);
|
|
95
|
-
const dropLowIds = new Set(toDropLow.map((i) => i.id));
|
|
96
|
-
_queue = _queue.filter((i) => !dropLowIds.has(i.id));
|
|
97
|
-
process.stderr.write(`[work-queue] Pruned queue to ${_queue.length} items (cap=${MAX_QUEUE_SIZE}).\n`);
|
|
98
|
-
}
|
|
99
|
-
/**
|
|
100
|
-
* Add a new work item to the queue.
|
|
101
|
-
* Returns the created WorkItem.
|
|
102
|
-
*/
|
|
103
|
-
function enqueue(params) {
|
|
104
|
-
const item = {
|
|
105
|
-
id: randomUUID(),
|
|
106
|
-
type: params.type,
|
|
107
|
-
priority: params.priority ?? 3,
|
|
108
|
-
payload: params.payload,
|
|
109
|
-
status: "pending",
|
|
110
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
111
|
-
attempts: 0,
|
|
112
|
-
maxAttempts: params.maxAttempts ?? 3
|
|
113
|
-
};
|
|
114
|
-
_queue.push(item);
|
|
115
|
-
enforceMaxSize();
|
|
116
|
-
_dirty = true;
|
|
117
|
-
saveIfDirty();
|
|
118
|
-
process.stderr.write(`[work-queue] Enqueued ${item.type} (id=${item.id}, priority=${item.priority}).\n`);
|
|
119
|
-
return item;
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* Pick the next pending item that is ready to process (respects nextRetryAt).
|
|
123
|
-
* Returns null if no eligible item exists.
|
|
124
|
-
* Highest priority (lowest number) is processed first; ties broken by createdAt.
|
|
125
|
-
*/
|
|
126
|
-
function dequeue() {
|
|
127
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
128
|
-
const eligible = _queue.filter((i) => {
|
|
129
|
-
if (i.status !== "pending") return false;
|
|
130
|
-
if (i.nextRetryAt && i.nextRetryAt > now) return false;
|
|
131
|
-
return true;
|
|
132
|
-
}).sort((a, b) => {
|
|
133
|
-
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
134
|
-
return a.createdAt.localeCompare(b.createdAt);
|
|
135
|
-
});
|
|
136
|
-
if (eligible.length === 0) return null;
|
|
137
|
-
const item = eligible[0];
|
|
138
|
-
item.status = "processing";
|
|
139
|
-
item.attempts += 1;
|
|
140
|
-
_dirty = true;
|
|
141
|
-
saveIfDirty();
|
|
142
|
-
return item;
|
|
143
|
-
}
|
|
144
|
-
/**
|
|
145
|
-
* Mark an item as completed.
|
|
146
|
-
*/
|
|
147
|
-
function markCompleted(id) {
|
|
148
|
-
const item = _queue.find((i) => i.id === id);
|
|
149
|
-
if (!item) return;
|
|
150
|
-
item.status = "completed";
|
|
151
|
-
item.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
152
|
-
item.error = void 0;
|
|
153
|
-
_dirty = true;
|
|
154
|
-
saveIfDirty();
|
|
155
|
-
}
|
|
156
|
-
/**
|
|
157
|
-
* Mark an item as failed.
|
|
158
|
-
* If attempts < maxAttempts, schedules a retry with exponential backoff.
|
|
159
|
-
* Otherwise, leaves status as 'failed'.
|
|
160
|
-
*/
|
|
161
|
-
function markFailed(id, errorMsg) {
|
|
162
|
-
const item = _queue.find((i) => i.id === id);
|
|
163
|
-
if (!item) return;
|
|
164
|
-
item.error = errorMsg;
|
|
165
|
-
if (item.attempts < item.maxAttempts) {
|
|
166
|
-
const backoffMs = BACKOFF_MS[item.attempts - 1] ?? BACKOFF_MS[BACKOFF_MS.length - 1];
|
|
167
|
-
item.status = "pending";
|
|
168
|
-
item.nextRetryAt = new Date(Date.now() + backoffMs).toISOString();
|
|
169
|
-
process.stderr.write(`[work-queue] Item ${id} failed (attempt ${item.attempts}/${item.maxAttempts}), retry in ${backoffMs / 1e3}s: ${errorMsg}\n`);
|
|
170
|
-
} else {
|
|
171
|
-
item.status = "failed";
|
|
172
|
-
process.stderr.write(`[work-queue] Item ${id} exhausted retries (${item.maxAttempts} attempts): ${errorMsg}\n`);
|
|
173
|
-
}
|
|
174
|
-
_dirty = true;
|
|
175
|
-
saveIfDirty();
|
|
176
|
-
}
|
|
177
|
-
function getStats() {
|
|
178
|
-
const stats = {
|
|
179
|
-
pending: 0,
|
|
180
|
-
processing: 0,
|
|
181
|
-
completed: 0,
|
|
182
|
-
failed: 0,
|
|
183
|
-
total: _queue.length
|
|
184
|
-
};
|
|
185
|
-
for (const item of _queue) stats[item.status]++;
|
|
186
|
-
return stats;
|
|
187
|
-
}
|
|
188
|
-
/**
|
|
189
|
-
* Returns true if any pending or processing item of the given type exists.
|
|
190
|
-
* Used for debouncing work items that are expensive to run concurrently.
|
|
191
|
-
*/
|
|
192
|
-
function hasPendingOrProcessingOfType(type) {
|
|
193
|
-
return _queue.some((i) => i.type === type && (i.status === "pending" || i.status === "processing"));
|
|
194
|
-
}
|
|
195
|
-
/**
|
|
196
|
-
* Remove completed and permanently-failed items older than their TTL.
|
|
197
|
-
* Also force-cleans all completed items if the queue file exceeds 1 MB.
|
|
198
|
-
*/
|
|
199
|
-
function cleanup() {
|
|
200
|
-
const now = Date.now();
|
|
201
|
-
const before = _queue.length;
|
|
202
|
-
let forceCleanCompleted = false;
|
|
203
|
-
try {
|
|
204
|
-
if (existsSync(QUEUE_FILE)) {
|
|
205
|
-
const { size } = statSync(QUEUE_FILE);
|
|
206
|
-
if (size > MAX_QUEUE_FILE_BYTES) {
|
|
207
|
-
forceCleanCompleted = true;
|
|
208
|
-
process.stderr.write(`[work-queue] Queue file exceeds 1 MB (${size} bytes) — force-cleaning completed items.\n`);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
} catch {}
|
|
212
|
-
_queue = _queue.filter((item) => {
|
|
213
|
-
if (item.status === "completed") {
|
|
214
|
-
if (forceCleanCompleted) return false;
|
|
215
|
-
return now - (item.completedAt ? new Date(item.completedAt).getTime() : 0) < COMPLETED_TTL_MS;
|
|
216
|
-
}
|
|
217
|
-
if (item.status === "failed") return now - new Date(item.createdAt).getTime() < FAILED_TTL_MS;
|
|
218
|
-
return true;
|
|
219
|
-
});
|
|
220
|
-
const removed = before - _queue.length;
|
|
221
|
-
const stats = getStats();
|
|
222
|
-
if (removed > 0 || before === 0) process.stderr.write(`[work-queue] Cleanup: removed ${removed} items. Queue stats: pending=${stats.pending}, processing=${stats.processing}, completed=${stats.completed}, failed=${stats.failed}.\n`);
|
|
223
|
-
_dirty = removed > 0;
|
|
224
|
-
saveIfDirty();
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
//#endregion
|
|
228
|
-
//#region src/hooks/ts/lib/pai-paths.ts
|
|
229
|
-
/**
|
|
230
|
-
* PAI Path Resolution - Single Source of Truth
|
|
231
|
-
*
|
|
232
|
-
* This module provides consistent path resolution across all PAI hooks.
|
|
233
|
-
* It handles PAI_DIR detection whether set explicitly or defaulting to ~/.claude
|
|
234
|
-
*
|
|
235
|
-
* ALSO loads .env file from PAI_DIR so all hooks get environment variables
|
|
236
|
-
* without relying on Claude Code's settings.json injection.
|
|
237
|
-
*
|
|
238
|
-
* Usage in hooks:
|
|
239
|
-
* import { PAI_DIR, HOOKS_DIR, SKILLS_DIR } from './lib/pai-paths';
|
|
240
|
-
*/
|
|
241
|
-
/**
|
|
242
|
-
* Load .env file and inject into process.env
|
|
243
|
-
* Must run BEFORE PAI_DIR resolution so .env can set PAI_DIR if needed
|
|
244
|
-
*/
|
|
245
|
-
function loadEnvFile() {
|
|
246
|
-
const possiblePaths = [resolve$1(process.env.PAI_DIR || "", ".env"), resolve$1(homedir$1(), ".claude", ".env")];
|
|
247
|
-
for (const envPath of possiblePaths) if (existsSync$1(envPath)) try {
|
|
248
|
-
const content = readFileSync$1(envPath, "utf-8");
|
|
249
|
-
for (const line of content.split("\n")) {
|
|
250
|
-
const trimmed = line.trim();
|
|
251
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
252
|
-
const eqIndex = trimmed.indexOf("=");
|
|
253
|
-
if (eqIndex > 0) {
|
|
254
|
-
const key = trimmed.substring(0, eqIndex).trim();
|
|
255
|
-
let value = trimmed.substring(eqIndex + 1).trim();
|
|
256
|
-
if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
|
|
257
|
-
value = value.replace(/\$HOME/g, homedir$1());
|
|
258
|
-
value = value.replace(/^~(?=\/|$)/, homedir$1());
|
|
259
|
-
if (process.env[key] === void 0) process.env[key] = value;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
break;
|
|
263
|
-
} catch {}
|
|
264
|
-
}
|
|
265
|
-
loadEnvFile();
|
|
266
|
-
/**
|
|
267
|
-
* Smart PAI_DIR detection with fallback
|
|
268
|
-
* Priority:
|
|
269
|
-
* 1. PAI_DIR environment variable (if set)
|
|
270
|
-
* 2. ~/.claude (standard location)
|
|
271
|
-
*/
|
|
272
|
-
const PAI_DIR = process.env.PAI_DIR ? resolve$1(process.env.PAI_DIR) : resolve$1(homedir$1(), ".claude");
|
|
273
|
-
/**
|
|
274
|
-
* Common PAI directories
|
|
275
|
-
*/
|
|
276
|
-
const HOOKS_DIR = join$1(PAI_DIR, "Hooks");
|
|
277
|
-
const SKILLS_DIR = join$1(PAI_DIR, "Skills");
|
|
278
|
-
const AGENTS_DIR = join$1(PAI_DIR, "Agents");
|
|
279
|
-
const HISTORY_DIR = join$1(PAI_DIR, "History");
|
|
280
|
-
const COMMANDS_DIR = join$1(PAI_DIR, "Commands");
|
|
281
|
-
/**
|
|
282
|
-
* Validate PAI directory structure on first import
|
|
283
|
-
* This fails fast with a clear error if PAI is misconfigured
|
|
284
|
-
*/
|
|
285
|
-
function validatePAIStructure() {
|
|
286
|
-
if (!existsSync$1(PAI_DIR)) {
|
|
287
|
-
console.error(`PAI_DIR does not exist: ${PAI_DIR}`);
|
|
288
|
-
console.error(` Expected ~/.claude or set PAI_DIR environment variable`);
|
|
289
|
-
process.exit(1);
|
|
290
|
-
}
|
|
291
|
-
if (!existsSync$1(HOOKS_DIR)) {
|
|
292
|
-
console.error(`PAI hooks directory not found: ${HOOKS_DIR}`);
|
|
293
|
-
console.error(` Your PAI_DIR may be misconfigured`);
|
|
294
|
-
console.error(` Current PAI_DIR: ${PAI_DIR}`);
|
|
295
|
-
process.exit(1);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
validatePAIStructure();
|
|
299
|
-
|
|
300
|
-
//#endregion
|
|
301
|
-
//#region src/hooks/ts/lib/project-utils/paths.ts
|
|
302
|
-
/**
|
|
303
|
-
* Path utilities — encoding, Notes/Sessions directory discovery and creation.
|
|
304
|
-
*/
|
|
305
|
-
const PROJECTS_DIR = join$1(PAI_DIR, "projects");
|
|
306
|
-
/**
|
|
307
|
-
* Encode a path the same way Claude Code does:
|
|
308
|
-
* - Replace / with -
|
|
309
|
-
* - Replace . with -
|
|
310
|
-
* - Replace space with -
|
|
311
|
-
*/
|
|
312
|
-
function encodePath(path) {
|
|
313
|
-
return path.replace(/\//g, "-").replace(/\./g, "-").replace(/ /g, "-");
|
|
314
|
-
}
|
|
315
|
-
/** Get the project directory for a given working directory. */
|
|
316
|
-
function getProjectDir(cwd) {
|
|
317
|
-
return join$1(PROJECTS_DIR, encodePath(cwd));
|
|
318
|
-
}
|
|
319
|
-
/** Get the Notes directory for a project (central location). */
|
|
320
|
-
function getNotesDir(cwd) {
|
|
321
|
-
return join$1(getProjectDir(cwd), "Notes");
|
|
322
|
-
}
|
|
323
|
-
/**
|
|
324
|
-
* Find Notes directory — checks local first, falls back to central.
|
|
325
|
-
* Does NOT create the directory.
|
|
326
|
-
*/
|
|
327
|
-
function findNotesDir(cwd) {
|
|
328
|
-
if (basename$1(cwd).toLowerCase() === "notes" && existsSync$1(cwd)) return {
|
|
329
|
-
path: cwd,
|
|
330
|
-
isLocal: true
|
|
331
|
-
};
|
|
332
|
-
const localPaths = [
|
|
333
|
-
join$1(cwd, "Notes"),
|
|
334
|
-
join$1(cwd, "notes"),
|
|
335
|
-
join$1(cwd, ".claude", "Notes")
|
|
336
|
-
];
|
|
337
|
-
for (const path of localPaths) if (existsSync$1(path)) return {
|
|
338
|
-
path,
|
|
339
|
-
isLocal: true
|
|
340
|
-
};
|
|
341
|
-
return {
|
|
342
|
-
path: getNotesDir(cwd),
|
|
343
|
-
isLocal: false
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
/** Get the sessions/ directory from a project directory path. */
|
|
347
|
-
function getSessionsDirFromProjectDir(projectDir) {
|
|
348
|
-
return join$1(projectDir, "sessions");
|
|
349
|
-
}
|
|
350
|
-
/** Ensure the sessions/ directory exists (from project dir path). */
|
|
351
|
-
function ensureSessionsDirFromProjectDir(projectDir) {
|
|
352
|
-
const sessionsDir = getSessionsDirFromProjectDir(projectDir);
|
|
353
|
-
if (!existsSync$1(sessionsDir)) {
|
|
354
|
-
mkdirSync$1(sessionsDir, { recursive: true });
|
|
355
|
-
console.error(`Created sessions directory: ${sessionsDir}`);
|
|
356
|
-
}
|
|
357
|
-
return sessionsDir;
|
|
358
|
-
}
|
|
359
|
-
/**
|
|
360
|
-
* Publish every project-root .jsonl into sessions/ as well, WITHOUT removing it.
|
|
361
|
-
*
|
|
362
|
-
* This used to renameSync, and that is how PAI destroyed its users' sessions.
|
|
363
|
-
*
|
|
364
|
-
* `claude --resume <uuid>` finds a transcript only at the project root. Move it
|
|
365
|
-
* into sessions/ and the session becomes permanently unresumable — measured
|
|
366
|
-
* 2026-08-04: `claude --resume b3462801` (867 KB of real work, sessions/ only)
|
|
367
|
-
* answers "No conversation found with session ID", while a top-level id is found
|
|
368
|
-
* fine. Nothing warned; the id still looked valid everywhere PAI displayed it.
|
|
369
|
-
*
|
|
370
|
-
* The damage was not occasional. This ran from a UserPromptSubmit hook excluding
|
|
371
|
-
* only the CURRENT session, so every prompt anyone typed unresumed every other
|
|
372
|
-
* session in the project. One PAI project measured 1 transcript at top level
|
|
373
|
-
* against 52 underneath. Among the casualties was 046bb712 — the exact id PAI's
|
|
374
|
-
* own handover tells the user to resume.
|
|
375
|
-
*
|
|
376
|
-
* A hardlink satisfies both sides, which is why this is a two-line fix rather
|
|
377
|
-
* than a redesign: the archive genuinely has consumers that read sessions/
|
|
378
|
-
* (session-summary-worker, registry/moved, session/autosave), and `--resume`
|
|
379
|
-
* needs the root path. One inode, two names, no copy, no window where the file
|
|
380
|
-
* is missing from either place.
|
|
381
|
-
*
|
|
382
|
-
* Never unlink the source. Tidying up another tool's store was the whole
|
|
383
|
-
* mistake; a stale duplicate is free, a lost session is not. Every caller of
|
|
384
|
-
* `transcriptFiles()` was checked before choosing this — they all test emptiness
|
|
385
|
-
* (`.length > 0`), never count, so the duplicate cannot skew a project's stats.
|
|
386
|
-
*
|
|
387
|
-
* `excludeFile` keeps a hook from archiving the transcript it is itself watching
|
|
388
|
-
* being written. It is not a "finished sessions only" guard and must not be read
|
|
389
|
-
* as one: it excludes exactly one file, the caller's own, so with two sessions
|
|
390
|
-
* live in one project each still archives the other mid-turn. A consumer that
|
|
391
|
-
* needs "finished only" has to enforce that itself — this function cannot know
|
|
392
|
-
* what is live.
|
|
393
|
-
*
|
|
394
|
-
* Returns the number of files newly archived.
|
|
395
|
-
*/
|
|
396
|
-
function archiveSessionFilesToSessionsDir(projectDir, excludeFile, silent = false) {
|
|
397
|
-
const sessionsDir = ensureSessionsDirFromProjectDir(projectDir);
|
|
398
|
-
if (!existsSync$1(projectDir)) return 0;
|
|
399
|
-
const files = readdirSync$1(projectDir);
|
|
400
|
-
let archivedCount = 0;
|
|
401
|
-
for (const file of files) {
|
|
402
|
-
if (!file.endsWith(".jsonl") || file === excludeFile) continue;
|
|
403
|
-
const sourcePath = join$1(projectDir, file);
|
|
404
|
-
const destPath = join$1(sessionsDir, file);
|
|
405
|
-
if (existsSync$1(destPath)) continue;
|
|
406
|
-
try {
|
|
407
|
-
linkSync$1(sourcePath, destPath);
|
|
408
|
-
if (!silent) console.error(`Archived ${file} → sessions/ (still resumable)`);
|
|
409
|
-
archivedCount++;
|
|
410
|
-
} catch (error) {
|
|
411
|
-
try {
|
|
412
|
-
copyFileSync$1(sourcePath, destPath);
|
|
413
|
-
if (!silent) console.error(`Copied ${file} → sessions/ (hardlink unavailable)`);
|
|
414
|
-
archivedCount++;
|
|
415
|
-
} catch {
|
|
416
|
-
if (!silent) console.error(`Could not archive ${file}: ${error}`);
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
return archivedCount;
|
|
421
|
-
}
|
|
422
|
-
/** Find TODO.md — check local first, fallback to central. */
|
|
423
|
-
function findTodoPath(cwd) {
|
|
424
|
-
const localPaths = [
|
|
425
|
-
join$1(cwd, "TODO.md"),
|
|
426
|
-
join$1(cwd, "notes", "TODO.md"),
|
|
427
|
-
join$1(cwd, "Notes", "TODO.md"),
|
|
428
|
-
join$1(cwd, ".claude", "TODO.md")
|
|
429
|
-
];
|
|
430
|
-
for (const path of localPaths) if (existsSync$1(path)) return path;
|
|
431
|
-
return join$1(getNotesDir(cwd), "TODO.md");
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
//#endregion
|
|
435
|
-
//#region src/hooks/ts/lib/project-utils/session-notes.ts
|
|
436
|
-
/**
|
|
437
|
-
* Session note creation, editing, checkpointing, renaming, and finalization.
|
|
438
|
-
*/
|
|
439
|
-
/** Get or create the YYYY/MM subdirectory for the current month inside notesDir. */
|
|
440
|
-
function getMonthDir(notesDir) {
|
|
441
|
-
const now = /* @__PURE__ */ new Date();
|
|
442
|
-
const monthDir = join$1(notesDir, String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, "0"));
|
|
443
|
-
if (!existsSync$1(monthDir)) mkdirSync$1(monthDir, { recursive: true });
|
|
444
|
-
return monthDir;
|
|
445
|
-
}
|
|
446
|
-
/**
|
|
447
|
-
* Get the next note number (4-digit format: 0001, 0002, etc.).
|
|
448
|
-
* Numbers are scoped per YYYY/MM directory.
|
|
449
|
-
*/
|
|
450
|
-
function getNextNoteNumber(notesDir) {
|
|
451
|
-
const files = readdirSync$1(getMonthDir(notesDir)).filter((f) => f.match(/^\d{3,4}[\s_-]/)).sort();
|
|
452
|
-
if (files.length === 0) return "0001";
|
|
453
|
-
let maxNumber = 0;
|
|
454
|
-
for (const file of files) {
|
|
455
|
-
const digitMatch = file.match(/^(\d+)/);
|
|
456
|
-
if (digitMatch) {
|
|
457
|
-
const num = parseInt(digitMatch[1], 10);
|
|
458
|
-
if (num > maxNumber) maxNumber = num;
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
return String(maxNumber + 1).padStart(4, "0");
|
|
462
|
-
}
|
|
463
|
-
/**
|
|
464
|
-
* Get the current (latest) note file path, or null if none exists.
|
|
465
|
-
* Searches current month → previous month → flat notesDir (legacy).
|
|
466
|
-
*/
|
|
467
|
-
function getCurrentNotePath(notesDir) {
|
|
468
|
-
if (!existsSync$1(notesDir)) return null;
|
|
469
|
-
const findLatestIn = (dir) => {
|
|
470
|
-
if (!existsSync$1(dir)) return null;
|
|
471
|
-
const files = readdirSync$1(dir).filter((f) => f.match(/^\d{3,4}[\s_-].*\.md$/)).sort((a, b) => {
|
|
472
|
-
return parseInt(a.match(/^(\d+)/)?.[1] || "0", 10) - parseInt(b.match(/^(\d+)/)?.[1] || "0", 10);
|
|
473
|
-
});
|
|
474
|
-
if (files.length === 0) return null;
|
|
475
|
-
return join$1(dir, files[files.length - 1]);
|
|
476
|
-
};
|
|
477
|
-
const now = /* @__PURE__ */ new Date();
|
|
478
|
-
const found = findLatestIn(join$1(notesDir, String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, "0")));
|
|
479
|
-
if (found) return found;
|
|
480
|
-
const prevDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
|
481
|
-
const prevFound = findLatestIn(join$1(notesDir, String(prevDate.getFullYear()), String(prevDate.getMonth() + 1).padStart(2, "0")));
|
|
482
|
-
if (prevFound) return prevFound;
|
|
483
|
-
return findLatestIn(notesDir);
|
|
484
|
-
}
|
|
485
|
-
/**
|
|
486
|
-
* Create a new session note.
|
|
487
|
-
* Format: "NNNN - YYYY-MM-DD - New Session.md" filed into YYYY/MM subdirectory.
|
|
488
|
-
* Claude MUST rename at session end with a meaningful description.
|
|
489
|
-
*/
|
|
490
|
-
function createSessionNote(notesDir, description) {
|
|
491
|
-
const noteNumber = getNextNoteNumber(notesDir);
|
|
492
|
-
const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
493
|
-
const monthDir = getMonthDir(notesDir);
|
|
494
|
-
const filename = `${noteNumber} - ${date} - New Session.md`;
|
|
495
|
-
const filepath = join$1(monthDir, filename);
|
|
496
|
-
writeFileSync$1(filepath, `# Session ${noteNumber}: ${description}
|
|
497
|
-
|
|
498
|
-
**Date:** ${date}
|
|
499
|
-
**Status:** In Progress
|
|
500
|
-
|
|
501
|
-
---
|
|
502
|
-
|
|
503
|
-
## Work Done
|
|
504
|
-
|
|
505
|
-
<!-- PAI will add completed work here during session -->
|
|
506
|
-
|
|
507
|
-
---
|
|
508
|
-
|
|
509
|
-
## Next Steps
|
|
510
|
-
|
|
511
|
-
<!-- To be filled at session end -->
|
|
512
|
-
|
|
513
|
-
---
|
|
514
|
-
|
|
515
|
-
**Tags:** #Session
|
|
516
|
-
`);
|
|
517
|
-
console.error(`Created session note: ${filename}`);
|
|
518
|
-
return filepath;
|
|
519
|
-
}
|
|
520
|
-
/** Append a checkpoint to the current session note. */
|
|
521
|
-
function appendCheckpoint(notePath, checkpoint) {
|
|
522
|
-
if (!existsSync$1(notePath)) {
|
|
523
|
-
console.error(`Note file not found, recreating: ${notePath}`);
|
|
524
|
-
try {
|
|
525
|
-
const parentDir = join$1(notePath, "..");
|
|
526
|
-
if (!existsSync$1(parentDir)) mkdirSync$1(parentDir, { recursive: true });
|
|
527
|
-
const noteFilename = basename$1(notePath);
|
|
528
|
-
const numberMatch = noteFilename.match(/^(\d+)/);
|
|
529
|
-
writeFileSync$1(notePath, `# Session ${numberMatch ? numberMatch[1] : "0000"}: Recovered\n\n**Date:** ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}\n**Status:** In Progress\n\n---\n\n## Work Done\n\n<!-- PAI will add completed work here during session -->\n\n---\n\n## Next Steps\n\n<!-- To be filled at session end -->\n\n---\n\n**Tags:** #Session\n`);
|
|
530
|
-
console.error(`Recreated session note: ${noteFilename}`);
|
|
531
|
-
} catch (err) {
|
|
532
|
-
console.error(`Failed to recreate note: ${err}`);
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
const content = readFileSync$1(notePath, "utf-8");
|
|
537
|
-
const checkpointText = `\n### Checkpoint ${(/* @__PURE__ */ new Date()).toISOString()}\n\n${checkpoint}\n`;
|
|
538
|
-
const nextStepsIndex = content.indexOf("## Next Steps");
|
|
539
|
-
writeFileSync$1(notePath, nextStepsIndex !== -1 ? content.substring(0, nextStepsIndex) + checkpointText + content.substring(nextStepsIndex) : content + checkpointText);
|
|
540
|
-
console.error(`Checkpoint added to: ${basename$1(notePath)}`);
|
|
541
|
-
}
|
|
542
|
-
/** Add work items to the "Work Done" section of a session note. */
|
|
543
|
-
function addWorkToSessionNote(notePath, workItems, sectionTitle) {
|
|
544
|
-
if (!existsSync$1(notePath)) {
|
|
545
|
-
console.error(`Note file not found: ${notePath}`);
|
|
546
|
-
return;
|
|
547
|
-
}
|
|
548
|
-
let content = readFileSync$1(notePath, "utf-8");
|
|
549
|
-
let workText = "";
|
|
550
|
-
if (sectionTitle) workText += `\n### ${sectionTitle}\n\n`;
|
|
551
|
-
for (const item of workItems) {
|
|
552
|
-
const checkbox = item.completed !== false ? "[x]" : "[ ]";
|
|
553
|
-
workText += `- ${checkbox} **${item.title}**\n`;
|
|
554
|
-
if (item.details && item.details.length > 0) for (const detail of item.details) workText += ` - ${detail}\n`;
|
|
555
|
-
}
|
|
556
|
-
const workDoneMatch = content.match(/## Work Done\n\n(<!-- .*? -->)?/);
|
|
557
|
-
if (workDoneMatch) {
|
|
558
|
-
const insertPoint = content.indexOf(workDoneMatch[0]) + workDoneMatch[0].length;
|
|
559
|
-
content = content.substring(0, insertPoint) + workText + content.substring(insertPoint);
|
|
560
|
-
} else {
|
|
561
|
-
const nextStepsIndex = content.indexOf("## Next Steps");
|
|
562
|
-
if (nextStepsIndex !== -1) content = content.substring(0, nextStepsIndex) + workText + "\n" + content.substring(nextStepsIndex);
|
|
563
|
-
}
|
|
564
|
-
writeFileSync$1(notePath, content);
|
|
565
|
-
console.error(`Added ${workItems.length} work item(s) to: ${basename$1(notePath)}`);
|
|
566
|
-
}
|
|
567
|
-
/** Sanitize a string for use in a filename. */
|
|
568
|
-
function sanitizeForFilename(str) {
|
|
569
|
-
return str.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").substring(0, 50);
|
|
570
|
-
}
|
|
571
|
-
/**
|
|
572
|
-
* Return true if the candidate string should be rejected as a meaningful name.
|
|
573
|
-
* Rejects file paths, shebangs, timestamps, system noise, XML tags, hashes, etc.
|
|
574
|
-
*/
|
|
575
|
-
function isMeaninglessCandidate(text) {
|
|
576
|
-
const t = text.trim();
|
|
577
|
-
if (!t) return true;
|
|
578
|
-
if (t.length < 5) return true;
|
|
579
|
-
if (t.startsWith("/") || t.startsWith("~")) return true;
|
|
580
|
-
if (t.startsWith("#!")) return true;
|
|
581
|
-
if (t.includes("[object Object]")) return true;
|
|
582
|
-
if (/^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/.test(t)) return true;
|
|
583
|
-
if (/^\d{1,2}:\d{2}(:\d{2})?(\s*(AM|PM))?$/i.test(t)) return true;
|
|
584
|
-
if (/^<[a-z-]+[\s/>]/i.test(t)) return true;
|
|
585
|
-
if (/^[0-9a-f]{10,}$/i.test(t)) return true;
|
|
586
|
-
if (/^Exit code \d+/i.test(t)) return true;
|
|
587
|
-
if (/^Error:/i.test(t)) return true;
|
|
588
|
-
if (/^This session is being continued/i.test(t)) return true;
|
|
589
|
-
if (/^\(Bash completed/i.test(t)) return true;
|
|
590
|
-
if (/^Task Notification$/i.test(t)) return true;
|
|
591
|
-
if (/^New Session$/i.test(t)) return true;
|
|
592
|
-
if (/^Recovered Session$/i.test(t)) return true;
|
|
593
|
-
if (/^Continued Session$/i.test(t)) return true;
|
|
594
|
-
if (/^Untitled Session$/i.test(t)) return true;
|
|
595
|
-
if (/^Context Compression$/i.test(t)) return true;
|
|
596
|
-
if (/^[A-Fa-f0-9]{8,}\s+Output$/i.test(t)) return true;
|
|
597
|
-
return false;
|
|
598
|
-
}
|
|
599
|
-
/**
|
|
600
|
-
* Extract a meaningful name from session note content and summary.
|
|
601
|
-
* Looks at Work Done section headers, bold text, and summary.
|
|
602
|
-
*/
|
|
603
|
-
function extractMeaningfulName(noteContent, summary) {
|
|
604
|
-
const workDoneMatch = noteContent.match(/## Work Done\n\n([\s\S]*?)(?=\n---|\n## Next)/);
|
|
605
|
-
if (workDoneMatch) {
|
|
606
|
-
const workDoneSection = workDoneMatch[1];
|
|
607
|
-
const subheadings = workDoneSection.match(/### ([^\n]+)/g);
|
|
608
|
-
if (subheadings && subheadings.length > 0) {
|
|
609
|
-
const firstHeading = subheadings[0].replace("### ", "").trim();
|
|
610
|
-
if (!isMeaninglessCandidate(firstHeading) && firstHeading.length > 5 && firstHeading.length < 60) return sanitizeForFilename(firstHeading);
|
|
611
|
-
}
|
|
612
|
-
const boldMatches = workDoneSection.match(/\*\*([^*]+)\*\*/g);
|
|
613
|
-
if (boldMatches && boldMatches.length > 0) {
|
|
614
|
-
const firstBold = boldMatches[0].replace(/\*\*/g, "").trim();
|
|
615
|
-
if (!isMeaninglessCandidate(firstBold) && firstBold.length > 3 && firstBold.length < 50) return sanitizeForFilename(firstBold);
|
|
616
|
-
}
|
|
617
|
-
const numberedItems = workDoneSection.match(/^\d+\.\s+\*\*([^*]+)\*\*/m);
|
|
618
|
-
if (numberedItems && !isMeaninglessCandidate(numberedItems[1])) return sanitizeForFilename(numberedItems[1]);
|
|
619
|
-
}
|
|
620
|
-
if (summary && summary.length > 5 && summary !== "Session completed." && !isMeaninglessCandidate(summary)) {
|
|
621
|
-
const cleanSummary = summary.replace(/[^\w\s-]/g, " ").trim().split(/\s+/).slice(0, 5).join(" ");
|
|
622
|
-
if (cleanSummary.length > 3 && !isMeaninglessCandidate(cleanSummary)) return sanitizeForFilename(cleanSummary);
|
|
623
|
-
}
|
|
624
|
-
return "";
|
|
625
|
-
}
|
|
626
|
-
/**
|
|
627
|
-
* Rename a session note with a meaningful name.
|
|
628
|
-
* Always uses "NNNN - YYYY-MM-DD - Description.md" format.
|
|
629
|
-
* Returns the new path, or original path if rename fails.
|
|
630
|
-
*/
|
|
631
|
-
function renameSessionNote(notePath, meaningfulName) {
|
|
632
|
-
if (!meaningfulName || !existsSync$1(notePath)) return notePath;
|
|
633
|
-
const dir = join$1(notePath, "..");
|
|
634
|
-
const oldFilename = basename$1(notePath);
|
|
635
|
-
const correctMatch = oldFilename.match(/^(\d{3,4}) - (\d{4}-\d{2}-\d{2}) - .*\.md$/);
|
|
636
|
-
const legacyMatch = oldFilename.match(/^(\d{3,4})_(\d{4}-\d{2}-\d{2})_.*\.md$/);
|
|
637
|
-
const match = correctMatch || legacyMatch;
|
|
638
|
-
if (!match) return notePath;
|
|
639
|
-
const [, noteNumber, date] = match;
|
|
640
|
-
const titleCaseName = meaningfulName.split(/[\s_-]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(" ").trim();
|
|
641
|
-
const newFilename = `${noteNumber.padStart(4, "0")} - ${date} - ${titleCaseName}.md`;
|
|
642
|
-
const newPath = join$1(dir, newFilename);
|
|
643
|
-
if (newFilename === oldFilename) return notePath;
|
|
644
|
-
try {
|
|
645
|
-
renameSync$1(notePath, newPath);
|
|
646
|
-
console.error(`Renamed note: ${oldFilename} → ${newFilename}`);
|
|
647
|
-
return newPath;
|
|
648
|
-
} catch (error) {
|
|
649
|
-
console.error(`Could not rename note: ${error}`);
|
|
650
|
-
return notePath;
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
/**
|
|
654
|
-
* Finalize session note — mark as complete, add summary, rename with meaningful name.
|
|
655
|
-
* IDEMPOTENT: subsequent calls are no-ops if already finalized.
|
|
656
|
-
* Returns the final path (may be renamed).
|
|
657
|
-
*/
|
|
658
|
-
function finalizeSessionNote(notePath, summary) {
|
|
659
|
-
if (!existsSync$1(notePath)) {
|
|
660
|
-
console.error(`Note file not found: ${notePath}`);
|
|
661
|
-
return notePath;
|
|
662
|
-
}
|
|
663
|
-
let content = readFileSync$1(notePath, "utf-8");
|
|
664
|
-
if (content.includes("**Status:** Completed")) {
|
|
665
|
-
console.error(`Note already finalized: ${basename$1(notePath)}`);
|
|
666
|
-
return notePath;
|
|
667
|
-
}
|
|
668
|
-
content = content.replace("**Status:** In Progress", "**Status:** Completed");
|
|
669
|
-
if (!content.includes("**Completed:**")) {
|
|
670
|
-
const completionTime = (/* @__PURE__ */ new Date()).toISOString();
|
|
671
|
-
content = content.replace("---\n\n## Work Done", `**Completed:** ${completionTime}\n\n---\n\n## Work Done`);
|
|
672
|
-
}
|
|
673
|
-
const nextStepsMatch = content.match(/## Next Steps\n\n(<!-- .*? -->)/);
|
|
674
|
-
if (nextStepsMatch) content = content.replace(nextStepsMatch[0], `## Next Steps\n\n${summary || "Session completed."}`);
|
|
675
|
-
writeFileSync$1(notePath, content);
|
|
676
|
-
console.error(`Session note finalized: ${basename$1(notePath)}`);
|
|
677
|
-
const meaningfulName = extractMeaningfulName(content, summary);
|
|
678
|
-
if (meaningfulName) return renameSessionNote(notePath, meaningfulName);
|
|
679
|
-
return notePath;
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
//#endregion
|
|
683
|
-
//#region src/hooks/ts/lib/project-utils/todo.ts
|
|
684
|
-
/**
|
|
685
|
-
* TODO.md management — creation, task updates, checkpoints, and Continue section.
|
|
686
|
-
*/
|
|
687
|
-
/**
|
|
688
|
-
* Ensure TODO.md exists. Creates it with default structure if missing.
|
|
689
|
-
* Returns the path to the TODO.md file.
|
|
690
|
-
*/
|
|
691
|
-
function ensureTodoMd(cwd) {
|
|
692
|
-
const todoPath = findTodoPath(cwd);
|
|
693
|
-
if (!existsSync$1(todoPath)) {
|
|
694
|
-
const parentDir = join$1(todoPath, "..");
|
|
695
|
-
if (!existsSync$1(parentDir)) mkdirSync$1(parentDir, { recursive: true });
|
|
696
|
-
writeFileSync$1(todoPath, `# TODO
|
|
697
|
-
|
|
698
|
-
## Current Session
|
|
699
|
-
|
|
700
|
-
- [ ] (Tasks will be tracked here)
|
|
701
|
-
|
|
702
|
-
## Backlog
|
|
703
|
-
|
|
704
|
-
- [ ] (Future tasks)
|
|
705
|
-
|
|
706
|
-
---
|
|
707
|
-
|
|
708
|
-
*Last updated: ${(/* @__PURE__ */ new Date()).toISOString()}*
|
|
709
|
-
`);
|
|
710
|
-
console.error(`Created TODO.md: ${todoPath}`);
|
|
711
|
-
}
|
|
712
|
-
return todoPath;
|
|
713
|
-
}
|
|
714
|
-
/**
|
|
715
|
-
* The Claude Code session UUID, read off the transcript path.
|
|
716
|
-
*
|
|
717
|
-
* Claude Code names a transcript `<session-uuid>.jsonl`, so the hooks that
|
|
718
|
-
* receive a transcript path are holding the session's identity without knowing
|
|
719
|
-
* it. Both session-end writers had one available and neither used it.
|
|
720
|
-
*
|
|
721
|
-
* Shape-checked rather than trusted: only a UUID is returned, so a renamed,
|
|
722
|
-
* archived or otherwise unexpected filename yields undefined and the caller
|
|
723
|
-
* falls back to the session line rather than writing a `session-id` that
|
|
724
|
-
* identifies nothing. A wrong id is worse than none — it would make two
|
|
725
|
-
* different sessions compare equal.
|
|
726
|
-
*/
|
|
727
|
-
function sessionIdFromTranscript(transcriptPath) {
|
|
728
|
-
if (!transcriptPath) return void 0;
|
|
729
|
-
const base = basename$1(transcriptPath).replace(/\.jsonl$/, "");
|
|
730
|
-
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(base) ? base : void 0;
|
|
731
|
-
}
|
|
732
|
-
/**
|
|
733
|
-
* Update the ## Continue section at the top of TODO.md.
|
|
734
|
-
*
|
|
735
|
-
* This is the unattended writer: the pre-compact hook and the daemon's
|
|
736
|
-
* work-queue worker both come through here. It used to build its own block and
|
|
737
|
-
* strip the existing section with `/## Continue\n[\s\S]*?\n---\n+/` — a
|
|
738
|
-
* non-greedy match to the first `---`, which cuts a rich checkpoint in half at
|
|
739
|
-
* the first horizontal rule inside its body and leaves the remainder orphaned
|
|
740
|
-
* in the document.
|
|
741
|
-
*
|
|
742
|
-
* More importantly it was the writer that actually destroyed model-authored
|
|
743
|
-
* checkpoints: `pai pause` wrote one, then this ran seconds later on session
|
|
744
|
-
* end and replaced it with `Working directory: … Check the latest session note
|
|
745
|
-
* for details.`
|
|
746
|
-
*
|
|
747
|
-
* It now delegates to the shared checkpoint module in "auto" mode, which means
|
|
748
|
-
* it inherits the preservation rules rather than reimplementing them. Guarding
|
|
749
|
-
* here rather than at each of the three call sites is deliberate: any future
|
|
750
|
-
* caller inherits the behaviour without knowing it exists.
|
|
751
|
-
*/
|
|
752
|
-
function updateTodoContinue(cwd, noteFilename, state, tokenDisplay, sessionId) {
|
|
753
|
-
ensureTodoMd(cwd);
|
|
754
|
-
const result = applyContinue({
|
|
755
|
-
rootPath: cwd,
|
|
756
|
-
authored: "auto",
|
|
757
|
-
sessionLine: noteFilename.replace(/\.md$/, ""),
|
|
758
|
-
sessionId,
|
|
759
|
-
cwd,
|
|
760
|
-
body: state?.trim() || void 0
|
|
761
|
-
});
|
|
762
|
-
if (result.action === "preserved") {
|
|
763
|
-
console.error("TODO.md ## Continue left intact — authored checkpoint for this session");
|
|
764
|
-
return;
|
|
765
|
-
}
|
|
766
|
-
if (result.action === "failed") {
|
|
767
|
-
console.error(`TODO.md ## Continue update failed: ${result.error}`);
|
|
768
|
-
return;
|
|
769
|
-
}
|
|
770
|
-
try {
|
|
771
|
-
const todoPath = result.path;
|
|
772
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
773
|
-
let content = readFileSync$1(todoPath, "utf-8");
|
|
774
|
-
content = content.replace(/(\n---\s*)*(\n\*Last updated:.*\*\s*)+$/g, "");
|
|
775
|
-
content = content.trimEnd() + `\n\n---\n\n*Last updated: ${now}*\n`;
|
|
776
|
-
writeFileSync$1(todoPath, content);
|
|
777
|
-
} catch {}
|
|
778
|
-
console.error(result.carriedForward ? "TODO.md ## Continue section updated (previous content carried forward)" : "TODO.md ## Continue section updated");
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
//#endregion
|
|
782
|
-
//#region src/daemon/templates/session-summary-prompt.ts
|
|
783
|
-
/**
|
|
784
|
-
* Build the prompt string to send to the summarizer model.
|
|
785
|
-
*
|
|
786
|
-
* Returns a single string suitable for piping to `claude --model <model> --print`.
|
|
787
|
-
*/
|
|
788
|
-
function buildSessionSummaryPrompt(params) {
|
|
789
|
-
const { userMessages, gitLog, cwd, date, filesModified, existingNote } = params;
|
|
790
|
-
const userSection = userMessages.length > 0 ? userMessages.map((m, i) => `[${i + 1}] ${m}`).join("\n\n") : "(No user messages extracted)";
|
|
791
|
-
const gitSection = gitLog.trim() || "(No git commits during this session)";
|
|
792
|
-
const filesSection = filesModified && filesModified.length > 0 ? filesModified.map((f) => `- ${f}`).join("\n") : "";
|
|
793
|
-
return `You are summarizing a coding session. Given the user messages and git commits below, write a session note.
|
|
794
|
-
|
|
795
|
-
Project directory: ${cwd}
|
|
796
|
-
Date: ${date}
|
|
797
|
-
|
|
798
|
-
Focus on:
|
|
799
|
-
- What problems were encountered and how they were solved
|
|
800
|
-
- Key architectural decisions and their rationale
|
|
801
|
-
- What was built (reference actual files and code patterns)
|
|
802
|
-
- What was left unfinished or needs follow-up
|
|
803
|
-
|
|
804
|
-
Do NOT include:
|
|
805
|
-
- Mechanical metadata (token counts, checkpoint timestamps)
|
|
806
|
-
- System messages or tool results verbatim
|
|
807
|
-
- Generic descriptions — be specific about what happened
|
|
808
|
-
- Markdown frontmatter or YAML headers
|
|
809
|
-
${existingNote ? `\nAn existing session note is provided below. Merge the new information into it,
|
|
810
|
-
preserving what was already written. Add new work items and update the summary.
|
|
811
|
-
Do NOT duplicate existing content.
|
|
812
|
-
|
|
813
|
-
EXISTING NOTE:
|
|
814
|
-
${existingNote}
|
|
815
|
-
` : ""}
|
|
816
|
-
Format your response EXACTLY as follows (no extra text before or after):
|
|
817
|
-
|
|
818
|
-
TOPIC: [A short topic label, max 60 characters, describing the WORK DONE — not quoting user messages. Format as "Topic1, Topic2, and Topic3" if multiple themes. Example: "Session Summary Worker, Topic Detection"]
|
|
819
|
-
|
|
820
|
-
# Session: [Descriptive title summarizing what was ACCOMPLISHED, max 60 characters. Describe the work done, not the user's request. Bad: "Dark Mode Button Does Nothing". Good: "Dark Mode Toggle, Keyboard IPC, and Audio Fix"]
|
|
821
|
-
|
|
822
|
-
**Date:** ${date}
|
|
823
|
-
**Status:** In Progress
|
|
824
|
-
|
|
825
|
-
---
|
|
826
|
-
|
|
827
|
-
## Work Done
|
|
828
|
-
|
|
829
|
-
[Organize by theme, not chronologically. Group related work under descriptive bullet points.
|
|
830
|
-
Use checkbox format: - [x] for completed items, - [ ] for incomplete items.
|
|
831
|
-
Include specific file names, function names, and technical details.]
|
|
832
|
-
|
|
833
|
-
## Key Decisions
|
|
834
|
-
|
|
835
|
-
[List important choices made during the session with brief rationale.
|
|
836
|
-
Skip this section entirely if no significant decisions were made.]
|
|
837
|
-
|
|
838
|
-
## Known Issues
|
|
839
|
-
|
|
840
|
-
[What was left unfinished, bugs discovered, or follow-up items needed.
|
|
841
|
-
Skip this section entirely if nothing is pending.]
|
|
842
|
-
|
|
843
|
-
---
|
|
844
|
-
|
|
845
|
-
USER MESSAGES:
|
|
846
|
-
${userSection}
|
|
847
|
-
|
|
848
|
-
GIT COMMITS:
|
|
849
|
-
${gitSection}
|
|
850
|
-
${filesSection ? `\nFILES MODIFIED:\n${filesSection}` : ""}`;
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
//#endregion
|
|
854
|
-
//#region src/daemon/session-summary-worker.ts
|
|
855
|
-
/**
|
|
856
|
-
* session-summary-worker.ts — AI-powered session note generation
|
|
857
|
-
*
|
|
858
|
-
* Processes `session-summary` work items by:
|
|
859
|
-
* 1. Finding the current session's JSONL transcript
|
|
860
|
-
* 2. Extracting user messages and assistant context
|
|
861
|
-
* 3. Gathering git commits from the session period
|
|
862
|
-
* 4. Spawning Claude (sonnet for compaction, opus for session end) to generate a structured summary
|
|
863
|
-
* 5. Comparing the new topic against the existing note's topic
|
|
864
|
-
* 6. Creating a NEW note if the topic shifted, or updating the existing one
|
|
865
|
-
*
|
|
866
|
-
* Topic detection: the summarizer outputs a TOPIC: line as the first line of
|
|
867
|
-
* its response. This is compared against the existing note's title using word
|
|
868
|
-
* overlap. If overlap is below ~30%, a new note is created.
|
|
869
|
-
*
|
|
870
|
-
* Designed to run inside the daemon's work queue worker. All errors are
|
|
871
|
-
* thrown (not swallowed) so the work queue retry logic handles them.
|
|
872
|
-
*/
|
|
873
|
-
/** Minimum interval between summaries for the same project (ms). */
|
|
874
|
-
const SUMMARY_COOLDOWN_MS = 1800 * 1e3;
|
|
875
|
-
/** Maximum JSONL content to feed to the summarizer (characters). */
|
|
876
|
-
/** Max JSONL chars per model. Opus/Sonnet can handle much more than Haiku. */
|
|
877
|
-
const MAX_JSONL_CHARS = {
|
|
878
|
-
haiku: 5e4,
|
|
879
|
-
sonnet: 2e5,
|
|
880
|
-
opus: 5e5
|
|
881
|
-
};
|
|
882
|
-
/** Maximum user messages to include in the prompt. */
|
|
883
|
-
const MAX_USER_MESSAGES = 30;
|
|
884
|
-
/** File tracking last summary timestamps per project. */
|
|
885
|
-
const COOLDOWN_FILE = join(homedir(), ".config", "pai", "summary-cooldowns.json");
|
|
886
|
-
/** Claude Code projects directory. */
|
|
887
|
-
const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
888
|
-
function loadCooldowns() {
|
|
889
|
-
try {
|
|
890
|
-
if (existsSync(COOLDOWN_FILE)) return JSON.parse(readFileSync(COOLDOWN_FILE, "utf-8"));
|
|
891
|
-
} catch {}
|
|
892
|
-
return {};
|
|
893
|
-
}
|
|
894
|
-
function saveCooldowns(cooldowns) {
|
|
895
|
-
try {
|
|
896
|
-
writeFileSync(COOLDOWN_FILE, JSON.stringify(cooldowns, null, 2), "utf-8");
|
|
897
|
-
} catch {}
|
|
898
|
-
}
|
|
899
|
-
function isOnCooldown(cwd) {
|
|
900
|
-
const lastRun = loadCooldowns()[cwd];
|
|
901
|
-
if (!lastRun) return false;
|
|
902
|
-
return Date.now() - lastRun < SUMMARY_COOLDOWN_MS;
|
|
903
|
-
}
|
|
904
|
-
function markCooldown(cwd) {
|
|
905
|
-
const cooldowns = loadCooldowns();
|
|
906
|
-
cooldowns[cwd] = Date.now();
|
|
907
|
-
const cutoff = Date.now() - 1440 * 60 * 1e3;
|
|
908
|
-
for (const key of Object.keys(cooldowns)) if (cooldowns[key] < cutoff) delete cooldowns[key];
|
|
909
|
-
saveCooldowns(cooldowns);
|
|
910
|
-
}
|
|
911
|
-
/**
|
|
912
|
-
* Encode a cwd path the same way Claude Code does for its project directories.
|
|
913
|
-
* Replaces /, space, dot, and hyphen with -.
|
|
914
|
-
*/
|
|
915
|
-
function encodeProjectPath(cwd) {
|
|
916
|
-
return cwd.replace(/[\/\s.\-]/g, "-");
|
|
917
|
-
}
|
|
918
|
-
/**
|
|
919
|
-
* Find the most recently modified JSONL file for the given project.
|
|
920
|
-
*
|
|
921
|
-
* Claude Code stores transcripts in:
|
|
922
|
-
* ~/.claude/projects/<encoded-path>/sessions/*.jsonl
|
|
923
|
-
* ~/.claude/projects/<encoded-path>/<uuid>.jsonl (legacy)
|
|
924
|
-
*/
|
|
925
|
-
function findLatestJsonl(cwd) {
|
|
926
|
-
const projectDir = join(CLAUDE_PROJECTS_DIR, encodeProjectPath(cwd));
|
|
927
|
-
if (!existsSync(projectDir)) {
|
|
928
|
-
process.stderr.write(`[session-summary] No Claude project dir found: ${projectDir}\n`);
|
|
929
|
-
return null;
|
|
930
|
-
}
|
|
931
|
-
const candidates = [];
|
|
932
|
-
const sessionsDir = join(projectDir, "sessions");
|
|
933
|
-
if (existsSync(sessionsDir)) try {
|
|
934
|
-
for (const f of readdirSync(sessionsDir)) {
|
|
935
|
-
if (!f.endsWith(".jsonl")) continue;
|
|
936
|
-
const fullPath = join(sessionsDir, f);
|
|
937
|
-
try {
|
|
938
|
-
const st = statSync(fullPath);
|
|
939
|
-
candidates.push({
|
|
940
|
-
path: fullPath,
|
|
941
|
-
mtime: st.mtimeMs
|
|
942
|
-
});
|
|
943
|
-
} catch {}
|
|
944
|
-
}
|
|
945
|
-
} catch {}
|
|
946
|
-
try {
|
|
947
|
-
for (const f of readdirSync(projectDir)) {
|
|
948
|
-
if (!f.endsWith(".jsonl")) continue;
|
|
949
|
-
const fullPath = join(projectDir, f);
|
|
950
|
-
try {
|
|
951
|
-
const st = statSync(fullPath);
|
|
952
|
-
candidates.push({
|
|
953
|
-
path: fullPath,
|
|
954
|
-
mtime: st.mtimeMs
|
|
955
|
-
});
|
|
956
|
-
} catch {}
|
|
957
|
-
}
|
|
958
|
-
} catch {}
|
|
959
|
-
if (candidates.length === 0) {
|
|
960
|
-
process.stderr.write(`[session-summary] No JSONL files found in ${projectDir}\n`);
|
|
961
|
-
return null;
|
|
962
|
-
}
|
|
963
|
-
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
964
|
-
return candidates[0].path;
|
|
965
|
-
}
|
|
966
|
-
/**
|
|
967
|
-
* Parse a JSONL transcript and extract relevant content.
|
|
968
|
-
* Filters noise, truncates to model-appropriate size from the end of the file.
|
|
969
|
-
*/
|
|
970
|
-
function extractFromJsonl(jsonlPath, tier = "sonnet") {
|
|
971
|
-
const result = {
|
|
972
|
-
userMessages: [],
|
|
973
|
-
filesModified: [],
|
|
974
|
-
sessionStartTime: ""
|
|
975
|
-
};
|
|
976
|
-
let raw;
|
|
977
|
-
try {
|
|
978
|
-
raw = readFileSync(jsonlPath, "utf-8");
|
|
979
|
-
} catch (e) {
|
|
980
|
-
throw new Error(`Could not read JSONL at ${jsonlPath}: ${e}`);
|
|
981
|
-
}
|
|
982
|
-
const maxChars = MAX_JSONL_CHARS[tier] ?? 2e5;
|
|
983
|
-
if (raw.length > maxChars) {
|
|
984
|
-
const truncPoint = raw.indexOf("\n", raw.length - maxChars);
|
|
985
|
-
raw = truncPoint >= 0 ? raw.slice(truncPoint + 1) : raw.slice(-MAX_JSONL_CHARS);
|
|
986
|
-
}
|
|
987
|
-
const lines = raw.trim().split("\n");
|
|
988
|
-
const seenMessages = /* @__PURE__ */ new Set();
|
|
989
|
-
for (const line of lines) {
|
|
990
|
-
if (!line.trim()) continue;
|
|
991
|
-
let entry;
|
|
992
|
-
try {
|
|
993
|
-
entry = JSON.parse(line);
|
|
994
|
-
} catch {
|
|
995
|
-
continue;
|
|
996
|
-
}
|
|
997
|
-
if (entry.timestamp && !result.sessionStartTime) result.sessionStartTime = String(entry.timestamp);
|
|
998
|
-
if (entry.type === "user") {
|
|
999
|
-
const msg = entry.message;
|
|
1000
|
-
if (msg?.content) {
|
|
1001
|
-
const text = contentToText$2(msg.content);
|
|
1002
|
-
if (text && !isNoise(text) && !seenMessages.has(text)) {
|
|
1003
|
-
seenMessages.add(text);
|
|
1004
|
-
result.userMessages.push(text.slice(0, 500));
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
if (entry.type === "assistant") {
|
|
1009
|
-
const msg = entry.message;
|
|
1010
|
-
if (msg?.content && Array.isArray(msg.content)) {
|
|
1011
|
-
for (const block of msg.content) if (block.type === "tool_use") {
|
|
1012
|
-
const name = block.name;
|
|
1013
|
-
const input = block.input;
|
|
1014
|
-
if ((name === "Edit" || name === "Write") && input?.file_path) {
|
|
1015
|
-
const fp = String(input.file_path);
|
|
1016
|
-
if (!result.filesModified.includes(fp)) result.filesModified.push(fp);
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
if (result.userMessages.length > MAX_USER_MESSAGES) result.userMessages = result.userMessages.slice(-MAX_USER_MESSAGES);
|
|
1023
|
-
return result;
|
|
1024
|
-
}
|
|
1025
|
-
/** Convert Claude content (string or content block array) to plain text. */
|
|
1026
|
-
function contentToText$2(content) {
|
|
1027
|
-
if (typeof content === "string") return content;
|
|
1028
|
-
if (Array.isArray(content)) return content.map((c) => {
|
|
1029
|
-
if (typeof c === "string") return c;
|
|
1030
|
-
const block = c;
|
|
1031
|
-
if (block?.text) return String(block.text);
|
|
1032
|
-
if (block?.content) return String(block.content);
|
|
1033
|
-
return "";
|
|
1034
|
-
}).join(" ").trim();
|
|
1035
|
-
return "";
|
|
1036
|
-
}
|
|
1037
|
-
/** Filter out noise entries that shouldn't be included in the summary. */
|
|
1038
|
-
function isNoise(text) {
|
|
1039
|
-
if (!text || text.length < 3) return true;
|
|
1040
|
-
if (text.includes("<task-notification>")) return true;
|
|
1041
|
-
if (text.includes("[object Object]")) return true;
|
|
1042
|
-
if (text.startsWith("<system-reminder>")) return true;
|
|
1043
|
-
if (/^(yes|ok|sure|go|continue|weiter|thanks|thank you)\.?$/i.test(text.trim())) return true;
|
|
1044
|
-
if (text.startsWith("Tool Result:") || text.startsWith("tool_result")) return true;
|
|
1045
|
-
return false;
|
|
1046
|
-
}
|
|
1047
|
-
/**
|
|
1048
|
-
* Get git log for the session period.
|
|
1049
|
-
* Falls back gracefully if git is not available or the dir is not a repo.
|
|
1050
|
-
*/
|
|
1051
|
-
async function getGitContext(cwd, sinceTime) {
|
|
1052
|
-
let since = "6 hours ago";
|
|
1053
|
-
if (sinceTime) {
|
|
1054
|
-
const asNum = Number(sinceTime);
|
|
1055
|
-
if (!isNaN(asNum) && asNum > 1e9) since = (/* @__PURE__ */ new Date(asNum * 1e3)).toISOString();
|
|
1056
|
-
else since = sinceTime;
|
|
1057
|
-
}
|
|
1058
|
-
try {
|
|
1059
|
-
const { execFile: execFileCb } = await import("node:child_process");
|
|
1060
|
-
const { promisify } = await import("node:util");
|
|
1061
|
-
const { stdout } = await promisify(execFileCb)("git", [
|
|
1062
|
-
"log",
|
|
1063
|
-
"--format=%h %ai %s",
|
|
1064
|
-
`--since=${since}`,
|
|
1065
|
-
"--stat",
|
|
1066
|
-
"--no-color"
|
|
1067
|
-
], {
|
|
1068
|
-
cwd,
|
|
1069
|
-
timeout: 1e4,
|
|
1070
|
-
env: {
|
|
1071
|
-
...process.env,
|
|
1072
|
-
GIT_TERMINAL_PROMPT: "0"
|
|
1073
|
-
}
|
|
1074
|
-
});
|
|
1075
|
-
return stdout.trim();
|
|
1076
|
-
} catch {
|
|
1077
|
-
return "";
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
/**
|
|
1081
|
-
* Find the `claude` CLI binary.
|
|
1082
|
-
* Checks PATH first, then common installation locations.
|
|
1083
|
-
*/
|
|
1084
|
-
function findClaudeBinary() {
|
|
1085
|
-
const candidates = [
|
|
1086
|
-
join(homedir(), ".local", "bin", "claude"),
|
|
1087
|
-
join(homedir(), ".claude", "local", "claude"),
|
|
1088
|
-
"/usr/local/bin/claude",
|
|
1089
|
-
"/opt/homebrew/bin/claude"
|
|
1090
|
-
];
|
|
1091
|
-
for (const candidate of candidates) try {
|
|
1092
|
-
if (existsSync(candidate)) return candidate;
|
|
1093
|
-
} catch {}
|
|
1094
|
-
return "claude";
|
|
1095
|
-
}
|
|
1096
|
-
/**
|
|
1097
|
-
* Spawn a Claude model via the CLI to generate a session summary.
|
|
1098
|
-
* Pipes the prompt via stdin. Tier selection (resolved to the configured
|
|
1099
|
-
* provider's model id, falling back to the tier alias when no provider is
|
|
1100
|
-
* configured — see planLlmSpawn):
|
|
1101
|
-
* - opus: session end (best quality for final summary, runs once)
|
|
1102
|
-
* - sonnet: auto-compaction (good quality for incremental checkpoints, runs often)
|
|
1103
|
-
* - haiku: fallback / budget mode
|
|
1104
|
-
* Returns the generated text, or null if spawning fails.
|
|
1105
|
-
*/
|
|
1106
|
-
async function spawnSummarizer(prompt, tier = "sonnet") {
|
|
1107
|
-
const claudeBin = findClaudeBinary();
|
|
1108
|
-
if (!claudeBin) {
|
|
1109
|
-
process.stderr.write("[session-summary] Claude CLI not found in PATH or common locations.\n");
|
|
1110
|
-
return null;
|
|
1111
|
-
}
|
|
1112
|
-
let plan;
|
|
1113
|
-
try {
|
|
1114
|
-
plan = await planLlmSpawn(tier);
|
|
1115
|
-
} catch (e) {
|
|
1116
|
-
process.stderr.write(`[session-summary] could not plan ${tier} spawn: ${e}\n`);
|
|
1117
|
-
return null;
|
|
1118
|
-
}
|
|
1119
|
-
const { spawn } = await import("node:child_process");
|
|
1120
|
-
return new Promise((resolve) => {
|
|
1121
|
-
let timer = null;
|
|
1122
|
-
const child = spawn(claudeBin, plan.args, {
|
|
1123
|
-
env: plan.env,
|
|
1124
|
-
stdio: [
|
|
1125
|
-
"pipe",
|
|
1126
|
-
"pipe",
|
|
1127
|
-
"pipe"
|
|
1128
|
-
]
|
|
1129
|
-
});
|
|
1130
|
-
let stdout = "";
|
|
1131
|
-
let stderr = "";
|
|
1132
|
-
child.stdout.on("data", (chunk) => {
|
|
1133
|
-
stdout += chunk.toString();
|
|
1134
|
-
});
|
|
1135
|
-
child.stderr.on("data", (chunk) => {
|
|
1136
|
-
stderr += chunk.toString();
|
|
1137
|
-
});
|
|
1138
|
-
child.on("error", (err) => {
|
|
1139
|
-
if (timer) {
|
|
1140
|
-
clearTimeout(timer);
|
|
1141
|
-
timer = null;
|
|
1142
|
-
}
|
|
1143
|
-
process.stderr.write(`[session-summary] ${plan.model} spawn error: ${err.message}\n`);
|
|
1144
|
-
resolve(null);
|
|
1145
|
-
});
|
|
1146
|
-
child.on("close", (code) => {
|
|
1147
|
-
if (timer) {
|
|
1148
|
-
clearTimeout(timer);
|
|
1149
|
-
timer = null;
|
|
1150
|
-
}
|
|
1151
|
-
if (code !== 0) {
|
|
1152
|
-
process.stderr.write(`[session-summary] ${plan.model} exited with code ${code}: ${stderr.slice(0, 300)}\n`);
|
|
1153
|
-
resolve(null);
|
|
1154
|
-
} else resolve(stdout.trim() || null);
|
|
1155
|
-
});
|
|
1156
|
-
timer = setTimeout(() => {
|
|
1157
|
-
process.stderr.write(`[session-summary] ${plan.model} timed out — killing process.\n`);
|
|
1158
|
-
child.kill("SIGTERM");
|
|
1159
|
-
resolve(null);
|
|
1160
|
-
}, plan.timeoutMs);
|
|
1161
|
-
child.stdin.write(prompt);
|
|
1162
|
-
child.stdin.end();
|
|
1163
|
-
});
|
|
1164
|
-
}
|
|
1165
|
-
/**
|
|
1166
|
-
* Extract the TOPIC: line from the summarizer output.
|
|
1167
|
-
* Returns the topic string, or null if not found.
|
|
1168
|
-
*/
|
|
1169
|
-
function extractTopic(summaryText) {
|
|
1170
|
-
const match = summaryText.match(/^TOPIC:\s*(.+)$/m);
|
|
1171
|
-
if (!match) return null;
|
|
1172
|
-
return match[1].trim();
|
|
1173
|
-
}
|
|
1174
|
-
/**
|
|
1175
|
-
* Extract the topic from an existing session note.
|
|
1176
|
-
* First checks for a <!-- TOPIC: ... --> comment (stored by previous summaries).
|
|
1177
|
-
* Falls back to the H1 "# Session NNNN: Title" line.
|
|
1178
|
-
*
|
|
1179
|
-
* The HTML comment is the reliable source because the H1 gets renamed by
|
|
1180
|
-
* renameSessionNote, which can add/change words and cause false topic shifts.
|
|
1181
|
-
*/
|
|
1182
|
-
function extractExistingNoteTitle(notePath) {
|
|
1183
|
-
try {
|
|
1184
|
-
const content = readFileSync(notePath, "utf-8");
|
|
1185
|
-
const topicComment = content.match(/<!-- TOPIC:\s*(.+?)\s*-->/);
|
|
1186
|
-
if (topicComment) return topicComment[1].trim();
|
|
1187
|
-
const match = content.match(/^# Session \d+:\s*(.+)$/m);
|
|
1188
|
-
if (match) return match[1].trim();
|
|
1189
|
-
} catch {}
|
|
1190
|
-
return null;
|
|
1191
|
-
}
|
|
1192
|
-
/**
|
|
1193
|
-
* Compute word overlap ratio between two topic strings.
|
|
1194
|
-
* Returns a value in [0, 1] — 1.0 means identical word sets.
|
|
1195
|
-
*
|
|
1196
|
-
* Uses lowercased, normalized words. Stop words and very short words
|
|
1197
|
-
* are excluded to avoid false positives on common terms.
|
|
1198
|
-
*/
|
|
1199
|
-
function computeTopicOverlap(topicA, topicB) {
|
|
1200
|
-
const stopWords = new Set([
|
|
1201
|
-
"a",
|
|
1202
|
-
"an",
|
|
1203
|
-
"the",
|
|
1204
|
-
"and",
|
|
1205
|
-
"or",
|
|
1206
|
-
"but",
|
|
1207
|
-
"in",
|
|
1208
|
-
"on",
|
|
1209
|
-
"at",
|
|
1210
|
-
"to",
|
|
1211
|
-
"for",
|
|
1212
|
-
"of",
|
|
1213
|
-
"with",
|
|
1214
|
-
"by",
|
|
1215
|
-
"from",
|
|
1216
|
-
"is",
|
|
1217
|
-
"was",
|
|
1218
|
-
"are",
|
|
1219
|
-
"were",
|
|
1220
|
-
"be",
|
|
1221
|
-
"been",
|
|
1222
|
-
"being",
|
|
1223
|
-
"have",
|
|
1224
|
-
"has",
|
|
1225
|
-
"had",
|
|
1226
|
-
"do",
|
|
1227
|
-
"does",
|
|
1228
|
-
"did",
|
|
1229
|
-
"will",
|
|
1230
|
-
"would",
|
|
1231
|
-
"could",
|
|
1232
|
-
"should",
|
|
1233
|
-
"may",
|
|
1234
|
-
"might",
|
|
1235
|
-
"can",
|
|
1236
|
-
"shall",
|
|
1237
|
-
"this",
|
|
1238
|
-
"that",
|
|
1239
|
-
"these",
|
|
1240
|
-
"those",
|
|
1241
|
-
"it",
|
|
1242
|
-
"its",
|
|
1243
|
-
"new",
|
|
1244
|
-
"session",
|
|
1245
|
-
"work",
|
|
1246
|
-
"done"
|
|
1247
|
-
]);
|
|
1248
|
-
const normalize = (text) => {
|
|
1249
|
-
const words = text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !stopWords.has(w));
|
|
1250
|
-
return new Set(words);
|
|
1251
|
-
};
|
|
1252
|
-
const wordsA = normalize(topicA);
|
|
1253
|
-
const wordsB = normalize(topicB);
|
|
1254
|
-
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
|
1255
|
-
let intersection = 0;
|
|
1256
|
-
for (const w of wordsA) if (wordsB.has(w)) intersection++;
|
|
1257
|
-
const union = new Set([...wordsA, ...wordsB]).size;
|
|
1258
|
-
return union > 0 ? intersection / union : 0;
|
|
1259
|
-
}
|
|
1260
|
-
/** Threshold: below this overlap ratio, we consider topics different. */
|
|
1261
|
-
const TOPIC_OVERLAP_THRESHOLD = .15;
|
|
1262
|
-
/**
|
|
1263
|
-
* Write (or update) the session note with the AI-generated summary.
|
|
1264
|
-
*
|
|
1265
|
-
* Strategy:
|
|
1266
|
-
* - Find the current month's latest note
|
|
1267
|
-
* - If it's from today, compare topics:
|
|
1268
|
-
* - Same topic (overlap >= 30%) → update existing note
|
|
1269
|
-
* - Different topic (overlap < 30%) → create a NEW note
|
|
1270
|
-
* - If it's from a different day, create a new note
|
|
1271
|
-
*/
|
|
1272
|
-
/**
|
|
1273
|
-
* Return true if the summarizer output has a meaningful body — i.e. content
|
|
1274
|
-
* beyond the TOPIC:/title/metadata/horizontal-rule scaffolding.
|
|
1275
|
-
*
|
|
1276
|
-
* A summary that is only headers (no Work Done items) must NOT create or update
|
|
1277
|
-
* a note: doing so births an empty-bodied scaffold that later rename/finalize
|
|
1278
|
-
* can strip to a footer-only stub. This is the born-stub failure mode.
|
|
1279
|
-
*/
|
|
1280
|
-
function summaryHasContent(summaryText) {
|
|
1281
|
-
return summaryText.replace(/^TOPIC:.*$/m, "").replace(/^# Session:.*$/m, "").replace(/^\*\*(Date|Status|Completed):\*\*.*$/gm, "").replace(/^#{1,6}\s.*$/gm, "").replace(/^---$/gm, "").replace(/<!--[\s\S]*?-->/g, "").trim().length > 0;
|
|
1282
|
-
}
|
|
1283
|
-
function writeSessionNote(cwd, summaryText, filesModified, sessionId) {
|
|
1284
|
-
if (!summaryHasContent(summaryText)) {
|
|
1285
|
-
process.stderr.write(`[session-summary] Summary has no meaningful body — skipping note write.\n`);
|
|
1286
|
-
return null;
|
|
1287
|
-
}
|
|
1288
|
-
const notesInfo = findNotesDir(cwd);
|
|
1289
|
-
let notePath = sessionId && findNoteBySessionId(notesInfo.path, sessionId) || getCurrentNotePath(notesInfo.path);
|
|
1290
|
-
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1291
|
-
const newTopic = extractTopic(summaryText);
|
|
1292
|
-
if (notePath) {
|
|
1293
|
-
const noteFilename = basename(notePath);
|
|
1294
|
-
const dateMatch = noteFilename.match(/(\d{4}-\d{2}-\d{2})/);
|
|
1295
|
-
if ((dateMatch ? dateMatch[1] : "") === today) {
|
|
1296
|
-
const existingTitle = extractExistingNoteTitle(notePath);
|
|
1297
|
-
let topicShifted = false;
|
|
1298
|
-
if (newTopic && existingTitle) {
|
|
1299
|
-
const overlap = computeTopicOverlap(newTopic, existingTitle);
|
|
1300
|
-
process.stderr.write(`[session-summary] Topic overlap: ${(overlap * 100).toFixed(1)}% (new="${newTopic}", existing="${existingTitle}")\n`);
|
|
1301
|
-
if (overlap < TOPIC_OVERLAP_THRESHOLD) {
|
|
1302
|
-
topicShifted = true;
|
|
1303
|
-
process.stderr.write(`[session-summary] Topic shift detected (word overlap) — creating new note.\n`);
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1306
|
-
if (!topicShifted) {
|
|
1307
|
-
const boundaryPath = join(notesInfo.path, "topic-boundary.json");
|
|
1308
|
-
if (existsSync(boundaryPath)) try {
|
|
1309
|
-
const boundary = JSON.parse(readFileSync(boundaryPath, "utf-8"));
|
|
1310
|
-
if (boundary.timestamp) {
|
|
1311
|
-
if (Date.now() - new Date(boundary.timestamp).getTime() < 1800 * 1e3) {
|
|
1312
|
-
topicShifted = true;
|
|
1313
|
-
process.stderr.write(`[session-summary] Topic shift detected (boundary marker) — ${boundary.previousProject} → ${boundary.suggestedProject}\n`);
|
|
1314
|
-
}
|
|
1315
|
-
}
|
|
1316
|
-
unlinkSync(boundaryPath);
|
|
1317
|
-
} catch {}
|
|
1318
|
-
}
|
|
1319
|
-
if (topicShifted) notePath = createNoteFromSummary(notesInfo.path, summaryText, sessionId);
|
|
1320
|
-
else {
|
|
1321
|
-
updateNoteWithSummary(notePath, summaryText);
|
|
1322
|
-
process.stderr.write(`[session-summary] Updated existing note: ${noteFilename}\n`);
|
|
1323
|
-
}
|
|
1324
|
-
} else notePath = createNoteFromSummary(notesInfo.path, summaryText, sessionId);
|
|
1325
|
-
} else notePath = createNoteFromSummary(notesInfo.path, summaryText, sessionId);
|
|
1326
|
-
if (notePath) {
|
|
1327
|
-
const titleMatch = summaryText.match(/^# Session:\s*(.+)$/m);
|
|
1328
|
-
if (titleMatch) {
|
|
1329
|
-
const title = titleMatch[1].trim();
|
|
1330
|
-
if (title.length > 5 && title.length < 80) {
|
|
1331
|
-
const newPath = renameSessionNote(notePath, title);
|
|
1332
|
-
if (newPath !== notePath) notePath = newPath;
|
|
1333
|
-
}
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
return notePath;
|
|
1337
|
-
}
|
|
1338
|
-
/**
|
|
1339
|
-
* Update an existing session note's Work Done section with AI-generated content.
|
|
1340
|
-
*/
|
|
1341
|
-
function updateNoteWithSummary(notePath, summaryText) {
|
|
1342
|
-
if (!existsSync(notePath)) return;
|
|
1343
|
-
let content = readFileSync(notePath, "utf-8");
|
|
1344
|
-
const newTopic = extractTopic(summaryText);
|
|
1345
|
-
if (newTopic) if (content.includes("<!-- TOPIC:")) content = content.replace(/<!-- TOPIC:.*?-->/, `<!-- TOPIC: ${newTopic} -->`);
|
|
1346
|
-
else content = content.replace(/^(# Session .+)$/m, `$1\n<!-- TOPIC: ${newTopic} -->`);
|
|
1347
|
-
const workDoneMatch = summaryText.match(/## Work Done\n\n([\s\S]*?)(?=\n## Key Decisions|\n## Known Issues|\n\*\*Tags|\n$)/);
|
|
1348
|
-
if (workDoneMatch) {
|
|
1349
|
-
const aiWorkContent = workDoneMatch[1].trim();
|
|
1350
|
-
const sectionHeader = `\n### AI Summary (${(/* @__PURE__ */ new Date()).toISOString().split("T")[1].split(".")[0]})\n\n${aiWorkContent}\n`;
|
|
1351
|
-
const nextStepsIdx = content.indexOf("## Next Steps");
|
|
1352
|
-
const knownIssuesIdx = content.indexOf("## Known Issues");
|
|
1353
|
-
const insertBefore = knownIssuesIdx !== -1 ? knownIssuesIdx : nextStepsIdx !== -1 ? nextStepsIdx : content.length;
|
|
1354
|
-
content = content.slice(0, insertBefore) + sectionHeader + "\n" + content.slice(insertBefore);
|
|
1355
|
-
}
|
|
1356
|
-
const decisionsMatch = summaryText.match(/## Key Decisions\n\n([\s\S]*?)(?=\n## Known Issues|\n\*\*Tags|\n$)/);
|
|
1357
|
-
if (decisionsMatch) {
|
|
1358
|
-
const decisions = decisionsMatch[1].trim();
|
|
1359
|
-
if (decisions && !content.includes("## Key Decisions")) {
|
|
1360
|
-
const nextStepsIdx = content.indexOf("## Next Steps");
|
|
1361
|
-
const insertAt = nextStepsIdx !== -1 ? nextStepsIdx : content.length;
|
|
1362
|
-
content = content.slice(0, insertAt) + `## Key Decisions\n\n${decisions}\n\n` + content.slice(insertAt);
|
|
1363
|
-
}
|
|
1364
|
-
}
|
|
1365
|
-
const issuesMatch = summaryText.match(/## Known Issues\n\n([\s\S]*?)(?=\n\*\*Tags|\n$)/);
|
|
1366
|
-
if (issuesMatch) {
|
|
1367
|
-
const issues = issuesMatch[1].trim();
|
|
1368
|
-
if (issues && !content.includes("## Known Issues")) {
|
|
1369
|
-
const nextStepsIdx = content.indexOf("## Next Steps");
|
|
1370
|
-
const insertAt = nextStepsIdx !== -1 ? nextStepsIdx : content.length;
|
|
1371
|
-
content = content.slice(0, insertAt) + `## Known Issues\n\n${issues}\n\n` + content.slice(insertAt);
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
writeFileSync(notePath, content, "utf-8");
|
|
1375
|
-
}
|
|
1376
|
-
/**
|
|
1377
|
-
* Create a brand new session note from the AI summary.
|
|
1378
|
-
*/
|
|
1379
|
-
/**
|
|
1380
|
-
* Find the note this session already owns, by the marker its first write left.
|
|
1381
|
-
*
|
|
1382
|
-
* The previous resolver, getCurrentNotePath, returned the numerically highest
|
|
1383
|
-
* note in the month directory. That is a position, not an identity, and it is
|
|
1384
|
-
* why one session produced many notes: whenever a session's own note was not
|
|
1385
|
-
* the current maximum — two sessions in one project, or a number collision, of
|
|
1386
|
-
* which one corpus had 110 — the lookup missed and the checkpoint created a
|
|
1387
|
-
* new file instead of updating. Measured consequence in that corpus: 303 of
|
|
1388
|
-
* 407 notes sat in 31 same-title groups, 48 of them sharing a single title.
|
|
1389
|
-
*
|
|
1390
|
-
* Matching on the session id makes the answer exact: a session either has a
|
|
1391
|
-
* note or it does not, regardless of what else the directory contains.
|
|
1392
|
-
*/
|
|
1393
|
-
function findNoteBySessionId(notesDir, sessionId) {
|
|
1394
|
-
if (!sessionId || !existsSync(notesDir)) return null;
|
|
1395
|
-
const marker = `<!-- SESSION: ${sessionId} -->`;
|
|
1396
|
-
const now = /* @__PURE__ */ new Date();
|
|
1397
|
-
const dirs = [0, -1].map((delta) => {
|
|
1398
|
-
const d = new Date(now.getFullYear(), now.getMonth() + delta, 1);
|
|
1399
|
-
return join(notesDir, String(d.getFullYear()), String(d.getMonth() + 1).padStart(2, "0"));
|
|
1400
|
-
});
|
|
1401
|
-
for (const dir of dirs) {
|
|
1402
|
-
if (!existsSync(dir)) continue;
|
|
1403
|
-
for (const f of readdirSync(dir)) {
|
|
1404
|
-
if (!f.endsWith(".md")) continue;
|
|
1405
|
-
const p = join(dir, f);
|
|
1406
|
-
try {
|
|
1407
|
-
if (readFileSync(p, "utf-8").includes(marker)) return p;
|
|
1408
|
-
} catch {}
|
|
1409
|
-
}
|
|
1410
|
-
}
|
|
1411
|
-
return null;
|
|
1412
|
-
}
|
|
1413
|
-
function createNoteFromSummary(notesDir, summaryText, sessionId) {
|
|
1414
|
-
try {
|
|
1415
|
-
const notePath = createSessionNote(notesDir, "New Session");
|
|
1416
|
-
const noteFilename = basename(notePath);
|
|
1417
|
-
const numberMatch = noteFilename.match(/^(\d+)/);
|
|
1418
|
-
const noteNumber = numberMatch ? numberMatch[1] : "0000";
|
|
1419
|
-
const titleMatch = summaryText.match(/^# Session:\s*(.+)$/m);
|
|
1420
|
-
const title = titleMatch ? titleMatch[1].trim() : "New Session";
|
|
1421
|
-
const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1422
|
-
const topic = extractTopic(summaryText);
|
|
1423
|
-
const aiBody = summaryText.replace(/^TOPIC:.*$/m, "").replace(/^# Session:.*$/m, "").replace(/^\*\*Date:\*\*.*$/m, "").replace(/^\*\*Status:\*\*.*$/m, "").replace(/^---$/m, "").trim();
|
|
1424
|
-
writeFileSync(notePath, `# Session ${noteNumber}: ${title}
|
|
1425
|
-
${topic ? `<!-- TOPIC: ${topic} -->` : ""}${sessionId ? `\n<!-- SESSION: ${sessionId} -->` : ""}
|
|
1426
|
-
|
|
1427
|
-
**Date:** ${date}
|
|
1428
|
-
**Status:** In Progress
|
|
1429
|
-
|
|
1430
|
-
---
|
|
1431
|
-
|
|
1432
|
-
${aiBody}
|
|
1433
|
-
|
|
1434
|
-
---
|
|
1435
|
-
|
|
1436
|
-
## Next Steps
|
|
1437
|
-
|
|
1438
|
-
<!-- To be filled at session end -->
|
|
1439
|
-
|
|
1440
|
-
---
|
|
1441
|
-
|
|
1442
|
-
**Tags:** #Session
|
|
1443
|
-
`, "utf-8");
|
|
1444
|
-
process.stderr.write(`[session-summary] Created AI-powered note: ${noteFilename}\n`);
|
|
1445
|
-
return notePath;
|
|
1446
|
-
} catch (e) {
|
|
1447
|
-
process.stderr.write(`[session-summary] Failed to create note: ${e}\n`);
|
|
1448
|
-
return null;
|
|
1449
|
-
}
|
|
1450
|
-
}
|
|
1451
|
-
/**
|
|
1452
|
-
* Look up the integer project_id from the registry DB for a given slug.
|
|
1453
|
-
* Returns null if not found or registryDb is not yet initialized.
|
|
1454
|
-
*/
|
|
1455
|
-
function lookupProjectId(slug) {
|
|
1456
|
-
try {
|
|
1457
|
-
if (!registryDb) return null;
|
|
1458
|
-
return registryDb.prepare("SELECT id FROM projects WHERE slug = ? LIMIT 1").get(slug)?.id ?? null;
|
|
1459
|
-
} catch {
|
|
1460
|
-
return null;
|
|
1461
|
-
}
|
|
1462
|
-
}
|
|
1463
|
-
/**
|
|
1464
|
-
* Extract structured KG triples from a session summary and store them.
|
|
1465
|
-
*
|
|
1466
|
-
* This is best-effort: any error is logged but never propagated.
|
|
1467
|
-
* Requires Postgres backend — silently no-ops on SQLite.
|
|
1468
|
-
*/
|
|
1469
|
-
async function extractAndStoreTriples(params) {
|
|
1470
|
-
try {
|
|
1471
|
-
if (!storageBackend || storageBackend.backendType !== "postgres") return;
|
|
1472
|
-
const pool = storageBackend.getPool?.();
|
|
1473
|
-
if (!pool) {
|
|
1474
|
-
process.stderr.write("[session-summary] Triple extraction: no pool available.\n");
|
|
1475
|
-
return;
|
|
1476
|
-
}
|
|
1477
|
-
const cfg = daemonConfig;
|
|
1478
|
-
if (cfg && cfg.kg_extraction_enabled === false) {
|
|
1479
|
-
process.stderr.write("[session-summary] Triple extraction disabled via kg_extraction_enabled=false.\n");
|
|
1480
|
-
return;
|
|
1481
|
-
}
|
|
1482
|
-
const federationDb = openFederation();
|
|
1483
|
-
let result;
|
|
1484
|
-
try {
|
|
1485
|
-
result = await extractAndStoreTriples$1(pool, {
|
|
1486
|
-
summaryText: params.summaryText,
|
|
1487
|
-
projectSlug: params.projectSlug,
|
|
1488
|
-
projectId: params.projectId,
|
|
1489
|
-
sessionId: params.sessionId,
|
|
1490
|
-
gitLog: params.gitLog,
|
|
1491
|
-
model: "sonnet",
|
|
1492
|
-
federationDb
|
|
1493
|
-
});
|
|
1494
|
-
} finally {
|
|
1495
|
-
federationDb.close();
|
|
1496
|
-
}
|
|
1497
|
-
process.stderr.write(`[session-summary] Triple extraction complete: ${result.extracted} extracted, ${result.added} added, ${result.superseded} superseded.\n`);
|
|
1498
|
-
} catch (err) {
|
|
1499
|
-
process.stderr.write(`[session-summary] Triple extraction failed: ${err}\n`);
|
|
1500
|
-
}
|
|
1501
|
-
}
|
|
1502
|
-
/**
|
|
1503
|
-
* Process a `session-summary` work item.
|
|
1504
|
-
*
|
|
1505
|
-
* This is the main function called by work-queue-worker.ts.
|
|
1506
|
-
* Throws on fatal errors (work queue will retry with backoff).
|
|
1507
|
-
*/
|
|
1508
|
-
async function handleSessionSummary(payload) {
|
|
1509
|
-
const { cwd, sessionId, projectSlug, transcriptPath, force } = payload;
|
|
1510
|
-
if (!cwd) throw new Error("session-summary payload missing cwd");
|
|
1511
|
-
process.stderr.write(`[session-summary] Starting for ${cwd}${sessionId ? ` (session=${sessionId})` : ""}${force ? " (force=true)" : ""}\n`);
|
|
1512
|
-
if (!force && isOnCooldown(cwd)) {
|
|
1513
|
-
process.stderr.write("[session-summary] Skipping — last summary was less than 30 minutes ago.\n");
|
|
1514
|
-
return;
|
|
1515
|
-
}
|
|
1516
|
-
let jsonlPath = transcriptPath || null;
|
|
1517
|
-
if (jsonlPath && !existsSync(jsonlPath)) {
|
|
1518
|
-
process.stderr.write(`[session-summary] Provided transcript path not found: ${jsonlPath}\n`);
|
|
1519
|
-
jsonlPath = null;
|
|
1520
|
-
}
|
|
1521
|
-
if (!jsonlPath) jsonlPath = findLatestJsonl(cwd);
|
|
1522
|
-
if (!jsonlPath) {
|
|
1523
|
-
process.stderr.write("[session-summary] No JSONL transcript found — skipping.\n");
|
|
1524
|
-
return;
|
|
1525
|
-
}
|
|
1526
|
-
process.stderr.write(`[session-summary] Using transcript: ${jsonlPath}\n`);
|
|
1527
|
-
const selectedModel = payload.model ?? (force ? "haiku" : "sonnet");
|
|
1528
|
-
const extracted = extractFromJsonl(jsonlPath, selectedModel);
|
|
1529
|
-
if (extracted.userMessages.length === 0) {
|
|
1530
|
-
process.stderr.write("[session-summary] No user messages found in transcript — skipping.\n");
|
|
1531
|
-
return;
|
|
1532
|
-
}
|
|
1533
|
-
process.stderr.write(`[session-summary] Extracted ${extracted.userMessages.length} user messages, ${extracted.filesModified.length} modified files.\n`);
|
|
1534
|
-
const gitLog = await getGitContext(cwd, extracted.sessionStartTime);
|
|
1535
|
-
if (gitLog) process.stderr.write(`[session-summary] Got git context (${gitLog.split("\n").length} lines).\n`);
|
|
1536
|
-
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1537
|
-
const existingNotePath = getCurrentNotePath(findNotesDir(cwd).path);
|
|
1538
|
-
let existingNote;
|
|
1539
|
-
if (existingNotePath) {
|
|
1540
|
-
const dateMatch = basename(existingNotePath).match(/(\d{4}-\d{2}-\d{2})/);
|
|
1541
|
-
if (dateMatch && dateMatch[1] === today) try {
|
|
1542
|
-
existingNote = readFileSync(existingNotePath, "utf-8");
|
|
1543
|
-
} catch {}
|
|
1544
|
-
}
|
|
1545
|
-
const prompt = buildSessionSummaryPrompt({
|
|
1546
|
-
userMessages: extracted.userMessages,
|
|
1547
|
-
gitLog,
|
|
1548
|
-
cwd,
|
|
1549
|
-
date: today,
|
|
1550
|
-
filesModified: extracted.filesModified,
|
|
1551
|
-
existingNote
|
|
1552
|
-
});
|
|
1553
|
-
process.stderr.write(`[session-summary] Sending ${prompt.length} char prompt to ${selectedModel}...\n`);
|
|
1554
|
-
const summaryText = await spawnSummarizer(prompt, selectedModel);
|
|
1555
|
-
if (!summaryText) {
|
|
1556
|
-
process.stderr.write(`[session-summary] ${selectedModel} did not produce output — falling back to mechanical checkpoint.\n`);
|
|
1557
|
-
markCooldown(cwd);
|
|
1558
|
-
return;
|
|
1559
|
-
}
|
|
1560
|
-
process.stderr.write(`[session-summary] ${selectedModel} produced ${summaryText.length} char summary.\n`);
|
|
1561
|
-
const notePath = writeSessionNote(cwd, summaryText, extracted.filesModified, sessionId);
|
|
1562
|
-
if (notePath) process.stderr.write(`[session-summary] Session note written: ${basename(notePath)}\n`);
|
|
1563
|
-
await extractAndStoreTriples({
|
|
1564
|
-
summaryText,
|
|
1565
|
-
projectSlug: projectSlug ?? basename(cwd),
|
|
1566
|
-
projectId: projectSlug ? lookupProjectId(projectSlug) : null,
|
|
1567
|
-
sessionId: sessionId ?? cwd,
|
|
1568
|
-
gitLog,
|
|
1569
|
-
model: selectedModel
|
|
1570
|
-
});
|
|
1571
|
-
markCooldown(cwd);
|
|
1572
|
-
process.stderr.write("[session-summary] Done.\n");
|
|
1573
|
-
}
|
|
1574
|
-
|
|
1575
|
-
//#endregion
|
|
1576
|
-
//#region src/daemon/templates/context-handover-prompt.ts
|
|
1577
|
-
function buildContextHandoverPrompt(params) {
|
|
1578
|
-
const { turns, gitLog, cwd, previousHandover } = params;
|
|
1579
|
-
const turnsSection = turns.length > 0 ? turns.join("\n\n") : "(No turns extracted)";
|
|
1580
|
-
const gitSection = gitLog.trim() || "(No git commits during this session)";
|
|
1581
|
-
return `This session is approaching a context-window compaction. Write the \
|
|
1582
|
-
handover that would let a successor continue WITHOUT re-reading this \
|
|
1583
|
-
conversation and WITHOUT asking the user to repeat themselves.
|
|
1584
|
-
|
|
1585
|
-
Project directory: ${cwd}
|
|
1586
|
-
${previousHandover ? `\nA PREVIOUS HANDOVER already exists for this session (from an earlier, \
|
|
1587
|
-
lower threshold). Carry it forward — start your response with a one-line \
|
|
1588
|
-
pointer to it ("Carries forward from the previous handover, which stays for \
|
|
1589
|
-
its detail.") and then write only what has happened SINCE it that a compact \
|
|
1590
|
-
would also lose. Do not re-derive or repeat what it already covered.\n\n\
|
|
1591
|
-
PREVIOUS HANDOVER:\n${previousHandover}\n` : ""}
|
|
1592
|
-
SCOPING RULE — the single most important instruction here: this is a DIFF \
|
|
1593
|
-
against everything already durable, not a summary of the session. Assume \
|
|
1594
|
-
the successor can and will read git history, the issue tracker, and any \
|
|
1595
|
-
notes files themselves. Do NOT restate anything recoverable there — no file \
|
|
1596
|
-
inventories, no "implemented X" restatements, no chronology of commands run. \
|
|
1597
|
-
Write down ONLY what existed exclusively in this conversation and would \
|
|
1598
|
-
otherwise be gone: the reasoning, not the result.
|
|
1599
|
-
|
|
1600
|
-
Write ONLY the sections below that this session actually has content for — \
|
|
1601
|
-
omit a section entirely rather than writing "none" or padding it:
|
|
1602
|
-
|
|
1603
|
-
WHERE THIS SESSION ENDED — the concrete state right now: what is verified \
|
|
1604
|
-
working (tests green, tree clean, etc.) versus merely attempted, any branch \
|
|
1605
|
-
or working-tree state that isn't obvious from git status alone (e.g. a \
|
|
1606
|
-
detached HEAD, an intentionally uncommitted change, a stash).
|
|
1607
|
-
|
|
1608
|
-
WHAT CLOSED — each item paired with the evidence that closed it, not just \
|
|
1609
|
-
the claim that it did.
|
|
1610
|
-
|
|
1611
|
-
WHAT IS FILED AND STILL OPEN — each with its next concrete step or who/what \
|
|
1612
|
-
it is waiting on.
|
|
1613
|
-
|
|
1614
|
-
DECISIONS & REASONING — choices actually made, each with why this way and \
|
|
1615
|
-
not another way.
|
|
1616
|
-
|
|
1617
|
-
REJECTED ALTERNATIVES — approaches considered and set aside, and why. An \
|
|
1618
|
-
alternative without a reason is not worth recording.
|
|
1619
|
-
|
|
1620
|
-
NEGATIVE RESULTS — something investigated that turned out NOT to be the \
|
|
1621
|
-
answer, kept WITH the reasoning that ruled it out (e.g. "X looked like the \
|
|
1622
|
-
cause — it wasn't; the actual mechanism was Y"). Without the reasoning \
|
|
1623
|
-
attached, a successor re-investigates the same dead end.
|
|
1624
|
-
|
|
1625
|
-
TRAPS — anything discovered that is invisible from the repo alone and would \
|
|
1626
|
-
otherwise bite a successor immediately (a required manual step, an ordering \
|
|
1627
|
-
constraint, a state that looks fine but isn't).
|
|
1628
|
-
|
|
1629
|
-
CRITICAL VALUES — any specific id, path, URL, credential name, version \
|
|
1630
|
-
number, or configuration value that appeared in conversation and whose loss \
|
|
1631
|
-
would force someone to go find it again.
|
|
1632
|
-
|
|
1633
|
-
THE FEW THINGS THAT MATTER MOST, IF NOTHING ELSE IS READ — at most 3 bullets. \
|
|
1634
|
-
Forces a priority judgement; do not pad this to fill space.
|
|
1635
|
-
|
|
1636
|
-
Format your response as markdown with those section headers (only the ones \
|
|
1637
|
-
you have content for), no other structure.
|
|
1638
|
-
|
|
1639
|
-
---
|
|
1640
|
-
|
|
1641
|
-
CONVERSATION:
|
|
1642
|
-
${turnsSection}
|
|
1643
|
-
|
|
1644
|
-
GIT COMMITS:
|
|
1645
|
-
${gitSection}`;
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
//#endregion
|
|
1649
|
-
//#region src/daemon/context-handover-worker.ts
|
|
1650
|
-
/**
|
|
1651
|
-
* context-handover-worker.ts — the threshold-triggered pre-compaction
|
|
1652
|
-
* handover.
|
|
1653
|
-
*
|
|
1654
|
-
* `session-summary-worker.ts` already spawns a model to write session notes,
|
|
1655
|
-
* but only from the PreCompact hook itself — which fires at the compaction
|
|
1656
|
-
* boundary, far too late to be injected INTO that same compaction's
|
|
1657
|
-
* post-compact context. This worker runs earlier, off a token-count
|
|
1658
|
-
* threshold (see ../hooks/ts/lib/context-fill.ts), so a model-written
|
|
1659
|
-
* handover already exists in a cache file by the time compaction happens.
|
|
1660
|
-
*
|
|
1661
|
-
* Deliberately narrow: this does not write a session note, does not touch
|
|
1662
|
-
* the session-summary cooldown file, and does not run KG extraction. It
|
|
1663
|
-
* writes one thing — a cache file the PreCompact hook can read — because its
|
|
1664
|
-
* only job is to exist before compaction does, not to duplicate what
|
|
1665
|
-
* session-summary-worker already does well.
|
|
1666
|
-
*
|
|
1667
|
-
* The prompt itself (see templates/context-handover-prompt.ts) treats the
|
|
1668
|
-
* handover as a DIFF against everything already durable — git, the tracker,
|
|
1669
|
-
* the notes — not a session summary, and a refresh run carries the previous
|
|
1670
|
-
* cached handover forward rather than overwriting it.
|
|
1671
|
-
*/
|
|
1672
|
-
/** How much of the tail of the transcript to feed the summarizer. Generous —
|
|
1673
|
-
* this runs at most twice per session, not on every tool call. */
|
|
1674
|
-
const MAX_TURN_CHARS = 15e4;
|
|
1675
|
-
/** Max turns to include (a hard floor even if MAX_TURN_CHARS is not hit). */
|
|
1676
|
-
const MAX_TURNS = 120;
|
|
1677
|
-
/**
|
|
1678
|
-
* Interleaved "User:"/"Assistant:" turns, oldest-kept-from-the-end so a long
|
|
1679
|
-
* transcript is truncated to its most recent content rather than its
|
|
1680
|
-
* earliest. Unlike session-summary-worker's extractFromJsonl (which keeps
|
|
1681
|
-
* only user messages, because a session note doesn't need the assistant's
|
|
1682
|
-
* own reasoning) this keeps BOTH sides — the whole point of this prompt is
|
|
1683
|
-
* the assistant's reasoning, which lives in assistant turns.
|
|
1684
|
-
*/
|
|
1685
|
-
function extractTurns(jsonlPath) {
|
|
1686
|
-
let raw;
|
|
1687
|
-
try {
|
|
1688
|
-
raw = readFileSync(jsonlPath, "utf-8");
|
|
1689
|
-
} catch {
|
|
1690
|
-
return [];
|
|
1691
|
-
}
|
|
1692
|
-
if (raw.length > MAX_TURN_CHARS) {
|
|
1693
|
-
const truncPoint = raw.indexOf("\n", raw.length - MAX_TURN_CHARS);
|
|
1694
|
-
raw = truncPoint >= 0 ? raw.slice(truncPoint + 1) : raw.slice(-MAX_TURN_CHARS);
|
|
1695
|
-
}
|
|
1696
|
-
const turns = [];
|
|
1697
|
-
for (const line of raw.trim().split("\n")) {
|
|
1698
|
-
if (!line.trim()) continue;
|
|
1699
|
-
let entry;
|
|
1700
|
-
try {
|
|
1701
|
-
entry = JSON.parse(line);
|
|
1702
|
-
} catch {
|
|
1703
|
-
continue;
|
|
1704
|
-
}
|
|
1705
|
-
if (entry.type === "user") {
|
|
1706
|
-
const msg = entry.message;
|
|
1707
|
-
const text = contentToText$2(msg?.content);
|
|
1708
|
-
if (text && text.length >= 3 && !text.startsWith("<system-reminder>")) turns.push(`User: ${text.slice(0, 2e3)}`);
|
|
1709
|
-
} else if (entry.type === "assistant") {
|
|
1710
|
-
const msg = entry.message;
|
|
1711
|
-
const text = contentToText$2(msg?.content);
|
|
1712
|
-
if (text && text.length >= 3) turns.push(`Assistant: ${text.slice(0, 4e3)}`);
|
|
1713
|
-
}
|
|
1714
|
-
}
|
|
1715
|
-
return turns.slice(-MAX_TURNS);
|
|
1716
|
-
}
|
|
1717
|
-
/**
|
|
1718
|
-
* Process a `context-handover` work item.
|
|
1719
|
-
*
|
|
1720
|
-
* THROWS on every failure path (missing input, no transcript, no turns, an
|
|
1721
|
-
* empty/failed summarizer run) rather than logging and returning silently.
|
|
1722
|
-
* That used to be a deliberate choice — avoid the work queue's own retry
|
|
1723
|
-
* piling up redundant LLM calls — but it made a real failure invisible: a
|
|
1724
|
-
* daemon restart mid-spawn (session 77084e72-...) left no cache, no error
|
|
1725
|
-
* anywhere the work queue's own stats could show, and a trigger-side marker
|
|
1726
|
-
* that (at the time) had already been set to "done". Throwing here makes
|
|
1727
|
-
* work-queue-worker.ts call markFailed(), which logs prominently AND is
|
|
1728
|
-
* visible via `work_queue_stats` / `pai daemon status` — not just a stderr
|
|
1729
|
-
* line that can scroll away. It is a second, faster (seconds-to-minutes
|
|
1730
|
-
* backoff) retry path alongside the primary one in context-handover-
|
|
1731
|
-
* trigger.ts (which retries on its own ~5-minute cadence based on whether a
|
|
1732
|
-
* cache actually appeared); the two are complementary, not redundant — a
|
|
1733
|
-
* failed cache write costs nothing to retry twice.
|
|
1734
|
-
*/
|
|
1735
|
-
async function handleContextHandover(payload) {
|
|
1736
|
-
const { cwd, sessionId, transcriptPath, threshold, urgent } = payload;
|
|
1737
|
-
if (!cwd || !sessionId) throw new Error("[context-handover] payload missing cwd or sessionId");
|
|
1738
|
-
process.stderr.write(`[context-handover] Starting for ${cwd} (session=${sessionId}, threshold=${threshold}${urgent ? ", urgent" : ""}).\n`);
|
|
1739
|
-
let jsonlPath = transcriptPath && existsSync(transcriptPath) ? transcriptPath : null;
|
|
1740
|
-
if (!jsonlPath) jsonlPath = findLatestJsonl(cwd);
|
|
1741
|
-
if (!jsonlPath) throw new Error(`[context-handover] No transcript found for ${cwd} (session=${sessionId})`);
|
|
1742
|
-
const turns = extractTurns(jsonlPath);
|
|
1743
|
-
if (turns.length === 0) throw new Error(`[context-handover] No turns extracted from ${jsonlPath} (session=${sessionId})`);
|
|
1744
|
-
const prompt = buildContextHandoverPrompt({
|
|
1745
|
-
turns,
|
|
1746
|
-
gitLog: await getGitContext(cwd),
|
|
1747
|
-
cwd,
|
|
1748
|
-
previousHandover: readContextHandoverCache(sessionId)?.summary
|
|
1749
|
-
});
|
|
1750
|
-
process.stderr.write(`[context-handover] Sending ${prompt.length} char prompt to sonnet...\n`);
|
|
1751
|
-
const summary = await spawnSummarizer(prompt, "sonnet");
|
|
1752
|
-
if (!summary || !summary.trim()) throw new Error(`[context-handover] sonnet produced no output (session=${sessionId})`);
|
|
1753
|
-
writeContextHandoverCache({
|
|
1754
|
-
sessionId,
|
|
1755
|
-
cwd,
|
|
1756
|
-
threshold,
|
|
1757
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1758
|
-
model: "sonnet",
|
|
1759
|
-
summary: summary.trim()
|
|
1760
|
-
});
|
|
1761
|
-
process.stderr.write(`[context-handover] Wrote cache for session ${sessionId} (${summary.length} chars).\n`);
|
|
1762
|
-
}
|
|
1763
|
-
|
|
1764
|
-
//#endregion
|
|
1765
|
-
//#region src/daemon/topic-detect-worker.ts
|
|
1766
|
-
/**
|
|
1767
|
-
* topic-detect-worker.ts — Topic shift detection for session note splitting
|
|
1768
|
-
*
|
|
1769
|
-
* Processes `topic-detect` work items by:
|
|
1770
|
-
* 1. Extracting recent user messages from the JSONL transcript
|
|
1771
|
-
* 2. Running the BM25-based topic shift detector against the PAI memory DB
|
|
1772
|
-
* 3. If a shift is detected, recording a topic boundary marker
|
|
1773
|
-
*
|
|
1774
|
-
* The actual note splitting is handled by session-summary-worker.ts when it
|
|
1775
|
-
* processes the next `session-summary` work item — it uses the TOPIC: line
|
|
1776
|
-
* from the summarizer to decide whether to create a new note.
|
|
1777
|
-
*
|
|
1778
|
-
* This worker provides an additional signal: project-level topic shift
|
|
1779
|
-
* (e.g., conversation moved from project A to project B). The session
|
|
1780
|
-
* summary worker handles intra-project topic shifts (e.g., from "dark mode"
|
|
1781
|
-
* to "keyboard IPC" within the same project).
|
|
1782
|
-
*/
|
|
1783
|
-
const MAX_CONTEXT_MESSAGES = 5;
|
|
1784
|
-
const MAX_CONTEXT_CHARS = 2e3;
|
|
1785
|
-
/**
|
|
1786
|
-
* Extract recent user messages from a JSONL transcript for topic detection.
|
|
1787
|
-
* Takes only the last few messages to represent the current topic.
|
|
1788
|
-
*/
|
|
1789
|
-
function extractRecentContext(jsonlPath) {
|
|
1790
|
-
try {
|
|
1791
|
-
const raw = readFileSync(jsonlPath, "utf-8");
|
|
1792
|
-
const lines = (raw.length > 5e4 ? raw.slice(-5e4) : raw).trim().split("\n");
|
|
1793
|
-
const messages = [];
|
|
1794
|
-
for (let i = lines.length - 1; i >= 0 && messages.length < MAX_CONTEXT_MESSAGES; i--) {
|
|
1795
|
-
const line = lines[i].trim();
|
|
1796
|
-
if (!line) continue;
|
|
1797
|
-
try {
|
|
1798
|
-
const entry = JSON.parse(line);
|
|
1799
|
-
if (entry.type === "user") {
|
|
1800
|
-
const msg = entry.message;
|
|
1801
|
-
if (msg?.content) {
|
|
1802
|
-
const text = contentToText$1(msg.content);
|
|
1803
|
-
if (text && text.length > 3) messages.unshift(text.slice(0, 500));
|
|
1804
|
-
}
|
|
1805
|
-
}
|
|
1806
|
-
} catch {}
|
|
1807
|
-
}
|
|
1808
|
-
return messages.join("\n\n").slice(0, MAX_CONTEXT_CHARS);
|
|
1809
|
-
} catch {
|
|
1810
|
-
return "";
|
|
1811
|
-
}
|
|
1812
|
-
}
|
|
1813
|
-
/** Convert Claude content (string or content block array) to plain text. */
|
|
1814
|
-
function contentToText$1(content) {
|
|
1815
|
-
if (typeof content === "string") return content;
|
|
1816
|
-
if (Array.isArray(content)) return content.map((c) => {
|
|
1817
|
-
if (typeof c === "string") return c;
|
|
1818
|
-
const block = c;
|
|
1819
|
-
if (block?.text) return String(block.text);
|
|
1820
|
-
if (block?.content) return String(block.content);
|
|
1821
|
-
return "";
|
|
1822
|
-
}).join(" ").trim();
|
|
1823
|
-
return "";
|
|
1824
|
-
}
|
|
1825
|
-
const TOPIC_BOUNDARY_FILE = "topic-boundary.json";
|
|
1826
|
-
/**
|
|
1827
|
-
* Write a topic boundary marker into the Notes directory.
|
|
1828
|
-
* The session-summary-worker checks for this file and uses it as an
|
|
1829
|
-
* additional signal that a new note should be created.
|
|
1830
|
-
*/
|
|
1831
|
-
function writeTopicBoundary(cwd, boundary) {
|
|
1832
|
-
try {
|
|
1833
|
-
const boundaryPath = join(findNotesDir(cwd).path, TOPIC_BOUNDARY_FILE);
|
|
1834
|
-
writeFileSync(boundaryPath, JSON.stringify(boundary, null, 2), "utf-8");
|
|
1835
|
-
process.stderr.write(`[topic-detect] Wrote topic boundary marker: ${boundaryPath}\n`);
|
|
1836
|
-
} catch (e) {
|
|
1837
|
-
process.stderr.write(`[topic-detect] Could not write boundary marker: ${e}\n`);
|
|
1838
|
-
}
|
|
1839
|
-
}
|
|
1840
|
-
/**
|
|
1841
|
-
* Process a `topic-detect` work item.
|
|
1842
|
-
*
|
|
1843
|
-
* Called by work-queue-worker.ts. Throws on fatal errors so the work queue
|
|
1844
|
-
* retry logic handles them.
|
|
1845
|
-
*/
|
|
1846
|
-
async function handleTopicDetect(payload) {
|
|
1847
|
-
const { cwd, currentProject, transcriptPath, sessionId } = payload;
|
|
1848
|
-
if (!cwd) throw new Error("topic-detect payload missing cwd");
|
|
1849
|
-
process.stderr.write(`[topic-detect] Starting for ${cwd}${currentProject ? ` (project=${currentProject})` : ""}${sessionId ? ` (session=${sessionId})` : ""}\n`);
|
|
1850
|
-
if (!registryDb || !storageBackend) {
|
|
1851
|
-
process.stderr.write("[topic-detect] Registry DB or storage backend not available — skipping.\n");
|
|
1852
|
-
return;
|
|
1853
|
-
}
|
|
1854
|
-
let context = payload.context || "";
|
|
1855
|
-
if (!context && transcriptPath && existsSync(transcriptPath)) context = extractRecentContext(transcriptPath);
|
|
1856
|
-
if (!context || context.trim().length < 10) {
|
|
1857
|
-
process.stderr.write("[topic-detect] Insufficient context for topic detection — skipping.\n");
|
|
1858
|
-
return;
|
|
1859
|
-
}
|
|
1860
|
-
process.stderr.write(`[topic-detect] Context: ${context.length} chars, checking against memory...\n`);
|
|
1861
|
-
const result = await detectTopicShift(registryDb, storageBackend, {
|
|
1862
|
-
context,
|
|
1863
|
-
currentProject,
|
|
1864
|
-
threshold: .6,
|
|
1865
|
-
candidates: 20
|
|
1866
|
-
});
|
|
1867
|
-
process.stderr.write(`[topic-detect] Result: shifted=${result.shifted}, suggested=${result.suggestedProject}, confidence=${result.confidence.toFixed(2)}, chunks=${result.chunkCount}\n`);
|
|
1868
|
-
if (result.topProjects.length > 0) process.stderr.write(`[topic-detect] Top projects: ${result.topProjects.map((p) => `${p.slug}(${(p.score * 100).toFixed(0)}%)`).join(", ")}\n`);
|
|
1869
|
-
if (result.shifted) {
|
|
1870
|
-
writeTopicBoundary(cwd, {
|
|
1871
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1872
|
-
previousProject: result.currentProject,
|
|
1873
|
-
suggestedProject: result.suggestedProject,
|
|
1874
|
-
confidence: result.confidence,
|
|
1875
|
-
context: context.slice(0, 200)
|
|
1876
|
-
});
|
|
1877
|
-
try {
|
|
1878
|
-
const notePath = getCurrentNotePath(findNotesDir(cwd).path);
|
|
1879
|
-
if (notePath) appendCheckpoint(notePath, `Topic shift detected: conversation moved from **${result.currentProject}** to **${result.suggestedProject}** (confidence: ${(result.confidence * 100).toFixed(0)}%). A new session note will be created for the new topic.`);
|
|
1880
|
-
} catch (e) {
|
|
1881
|
-
process.stderr.write(`[topic-detect] Could not append checkpoint: ${e}\n`);
|
|
1882
|
-
}
|
|
1883
|
-
}
|
|
1884
|
-
process.stderr.write("[topic-detect] Done.\n");
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
//#endregion
|
|
1888
|
-
//#region src/daemon/work-queue-worker.ts
|
|
1889
|
-
/**
|
|
1890
|
-
* work-queue-worker.ts — Daemon worker loop for the persistent work queue
|
|
1891
|
-
*
|
|
1892
|
-
* Runs every 5 seconds to drain the queue.
|
|
1893
|
-
* Handles 'session-end' work items by reading the transcript, extracting
|
|
1894
|
-
* work summaries, updating the session note, and updating TODO.md.
|
|
1895
|
-
* Handles 'session-summary' items by spawning Haiku for AI-powered note generation.
|
|
1896
|
-
* Handles 'context-handover' items by spawning Sonnet to write the
|
|
1897
|
-
* threshold-triggered pre-compaction reasoning handover (see
|
|
1898
|
-
* context-handover-worker.ts and hooks/ts/lib/context-fill.ts).
|
|
1899
|
-
* Handles 'registry-scan' items by running performScan() against the registry DB.
|
|
1900
|
-
*
|
|
1901
|
-
* Handles 'topic-detect' items by running BM25-based topic shift detection.
|
|
1902
|
-
* Other item types (note-update, todo-update) are stubs — they log and
|
|
1903
|
-
* complete immediately, ready for future expansion.
|
|
1904
|
-
*/
|
|
1905
|
-
const WORKER_INTERVAL_MS = 5e3;
|
|
1906
|
-
const HOUSEKEEPING_INTERVAL_MS = 600 * 1e3;
|
|
1907
|
-
let workerTimer = null;
|
|
1908
|
-
let housekeepingTimer = null;
|
|
1909
|
-
/** Start the background worker and housekeeping timers. */
|
|
1910
|
-
function startWorker() {
|
|
1911
|
-
process.stderr.write("[work-queue-worker] Starting worker loop.\n");
|
|
1912
|
-
workerTimer = setInterval(async () => {
|
|
1913
|
-
try {
|
|
1914
|
-
await processNextItem();
|
|
1915
|
-
} catch (e) {
|
|
1916
|
-
process.stderr.write(`[work-queue-worker] Uncaught error in worker loop: ${e}\n`);
|
|
1917
|
-
}
|
|
1918
|
-
}, WORKER_INTERVAL_MS);
|
|
1919
|
-
housekeepingTimer = setInterval(() => {
|
|
1920
|
-
try {
|
|
1921
|
-
cleanup();
|
|
1922
|
-
} catch (e) {
|
|
1923
|
-
process.stderr.write(`[work-queue-worker] Housekeeping error: ${e}\n`);
|
|
1924
|
-
}
|
|
1925
|
-
}, HOUSEKEEPING_INTERVAL_MS);
|
|
1926
|
-
process.stderr.write("[work-queue-worker] Worker started (interval=5s, housekeeping=10min).\n");
|
|
1927
|
-
}
|
|
1928
|
-
/** Stop the worker timers gracefully. */
|
|
1929
|
-
function stopWorker() {
|
|
1930
|
-
if (workerTimer !== null) {
|
|
1931
|
-
clearInterval(workerTimer);
|
|
1932
|
-
workerTimer = null;
|
|
1933
|
-
}
|
|
1934
|
-
if (housekeepingTimer !== null) {
|
|
1935
|
-
clearInterval(housekeepingTimer);
|
|
1936
|
-
housekeepingTimer = null;
|
|
1937
|
-
}
|
|
1938
|
-
process.stderr.write("[work-queue-worker] Worker stopped.\n");
|
|
1939
|
-
}
|
|
1940
|
-
/**
|
|
1941
|
-
* Signal that new work has been enqueued.
|
|
1942
|
-
* The worker will run on its next tick — we don't need to reset the timer
|
|
1943
|
-
* since 5 s is fast enough. The flag allows future optimisations.
|
|
1944
|
-
*/
|
|
1945
|
-
function notifyNewWork() {}
|
|
1946
|
-
async function processNextItem() {
|
|
1947
|
-
const item = dequeue();
|
|
1948
|
-
if (!item) return;
|
|
1949
|
-
process.stderr.write(`[work-queue-worker] Processing ${item.type} (id=${item.id}, attempt=${item.attempts}).\n`);
|
|
1950
|
-
try {
|
|
1951
|
-
switch (item.type) {
|
|
1952
|
-
case "session-end":
|
|
1953
|
-
await handleSessionEnd(item);
|
|
1954
|
-
break;
|
|
1955
|
-
case "session-summary":
|
|
1956
|
-
await handleSessionSummary(item.payload);
|
|
1957
|
-
break;
|
|
1958
|
-
case "context-handover":
|
|
1959
|
-
await handleContextHandover(item.payload);
|
|
1960
|
-
break;
|
|
1961
|
-
case "topic-detect":
|
|
1962
|
-
await handleTopicDetect(item.payload);
|
|
1963
|
-
break;
|
|
1964
|
-
case "registry-scan":
|
|
1965
|
-
await handleRegistryScan();
|
|
1966
|
-
break;
|
|
1967
|
-
case "note-update":
|
|
1968
|
-
case "todo-update":
|
|
1969
|
-
process.stderr.write(`[work-queue-worker] Item type '${item.type}' is not yet implemented — completing as no-op.\n`);
|
|
1970
|
-
break;
|
|
1971
|
-
default: throw new Error(`Unknown work item type: ${item.type}`);
|
|
1972
|
-
}
|
|
1973
|
-
markCompleted(item.id);
|
|
1974
|
-
process.stderr.write(`[work-queue-worker] Completed ${item.type} (id=${item.id}).\n`);
|
|
1975
|
-
} catch (e) {
|
|
1976
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1977
|
-
markFailed(item.id, msg);
|
|
1978
|
-
}
|
|
1979
|
-
}
|
|
1980
|
-
/**
|
|
1981
|
-
* Process a 'session-end' work item.
|
|
1982
|
-
*
|
|
1983
|
-
* Expected payload:
|
|
1984
|
-
* transcriptPath: string — absolute path to the .jsonl transcript
|
|
1985
|
-
* cwd: string — working directory of the session
|
|
1986
|
-
* message?: string — COMPLETED: line extracted by the hook (optional)
|
|
1987
|
-
*/
|
|
1988
|
-
async function handleSessionEnd(item) {
|
|
1989
|
-
const { transcriptPath, cwd, message: hookMessage } = item.payload;
|
|
1990
|
-
if (!transcriptPath) throw new Error("session-end payload missing transcriptPath");
|
|
1991
|
-
if (!cwd) throw new Error("session-end payload missing cwd");
|
|
1992
|
-
let transcript;
|
|
1993
|
-
try {
|
|
1994
|
-
transcript = readFileSync(transcriptPath, "utf-8");
|
|
1995
|
-
} catch (e) {
|
|
1996
|
-
throw new Error(`Could not read transcript at ${transcriptPath}: ${e}`);
|
|
1997
|
-
}
|
|
1998
|
-
const lines = transcript.trim().split("\n");
|
|
1999
|
-
const workItems = extractWorkFromTranscript(lines);
|
|
2000
|
-
let message = hookMessage ?? "";
|
|
2001
|
-
if (!message) {
|
|
2002
|
-
const lastEntry = tryParseJson(lines[lines.length - 1]);
|
|
2003
|
-
if (lastEntry?.type === "assistant" && lastEntry.message?.content) {
|
|
2004
|
-
const m = contentToText(lastEntry.message.content).match(/COMPLETED:\s*(.+?)(?:\n|$)/i);
|
|
2005
|
-
if (m) message = m[1].trim().replace(/\*+/g, "").replace(/\[.*?\]/g, "").trim();
|
|
2006
|
-
}
|
|
2007
|
-
}
|
|
2008
|
-
const currentNotePath = getCurrentNotePath(findNotesDir(cwd).path);
|
|
2009
|
-
if (currentNotePath) {
|
|
2010
|
-
if (workItems.length > 0) {
|
|
2011
|
-
addWorkToSessionNote(currentNotePath, workItems);
|
|
2012
|
-
process.stderr.write(`[work-queue-worker] Added ${workItems.length} work item(s) to note.\n`);
|
|
2013
|
-
} else if (message) {
|
|
2014
|
-
addWorkToSessionNote(currentNotePath, [{
|
|
2015
|
-
title: message,
|
|
2016
|
-
completed: true
|
|
2017
|
-
}]);
|
|
2018
|
-
process.stderr.write("[work-queue-worker] Added completion message to note.\n");
|
|
2019
|
-
}
|
|
2020
|
-
finalizeSessionNote(currentNotePath, message || "Session completed.");
|
|
2021
|
-
process.stderr.write(`[work-queue-worker] Finalized session note: ${basename(currentNotePath)}.\n`);
|
|
2022
|
-
if (workItems.length === 0 && !message) process.stderr.write("[work-queue-worker] Nothing to hand over — leaving TODO.md ## Continue intact.\n");
|
|
2023
|
-
else try {
|
|
2024
|
-
const stateLines = [];
|
|
2025
|
-
stateLines.push(`Working directory: ${cwd}`);
|
|
2026
|
-
if (workItems.length > 0) {
|
|
2027
|
-
stateLines.push("", "Work completed:");
|
|
2028
|
-
for (const wi of workItems.slice(0, 5)) stateLines.push(`- ${wi.title}`);
|
|
2029
|
-
}
|
|
2030
|
-
if (message) stateLines.push("", `Last completed: ${message}`);
|
|
2031
|
-
updateTodoContinue(cwd, basename(currentNotePath), stateLines.join("\n"), "session-end", sessionIdFromTranscript(transcriptPath));
|
|
2032
|
-
} catch (todoError) {
|
|
2033
|
-
process.stderr.write(`[work-queue-worker] Could not update TODO.md: ${todoError}\n`);
|
|
2034
|
-
}
|
|
2035
|
-
} else process.stderr.write("[work-queue-worker] No current session note found — skipping note update.\n");
|
|
2036
|
-
try {
|
|
2037
|
-
const archivedCount = archiveSessionFilesToSessionsDir(dirname(transcriptPath));
|
|
2038
|
-
if (archivedCount > 0) process.stderr.write(`[work-queue-worker] Archived ${archivedCount} session file(s) to sessions/.\n`);
|
|
2039
|
-
} catch (moveError) {
|
|
2040
|
-
process.stderr.write(`[work-queue-worker] Could not move session files: ${moveError}\n`);
|
|
2041
|
-
}
|
|
2042
|
-
}
|
|
2043
|
-
/**
|
|
2044
|
-
* Run performScan() against the registry DB.
|
|
2045
|
-
*
|
|
2046
|
-
* Debounce: if another registry-scan job is already pending in the queue,
|
|
2047
|
-
* we still run this one (dequeue already picked it), but enqueueRegistryScan()
|
|
2048
|
-
* checks before enqueuing so duplicates rarely reach here.
|
|
2049
|
-
*/
|
|
2050
|
-
async function handleRegistryScan() {
|
|
2051
|
-
if (!registryDb) throw new Error("registry-scan: registryDb not initialized yet");
|
|
2052
|
-
const t0 = Date.now();
|
|
2053
|
-
process.stderr.write("[work-queue-worker] Running registry scan...\n");
|
|
2054
|
-
const result = performScan(registryDb);
|
|
2055
|
-
const elapsed = Date.now() - t0;
|
|
2056
|
-
process.stderr.write(`[work-queue-worker] Registry scan complete: ${result.projectsScanned} projects (${result.projectsNew} new, ${result.projectsUpdated} updated), ${result.sessionsScanned} sessions (${result.sessionsNew} new) in ${elapsed}ms.\n`);
|
|
2057
|
-
if (result.skipped.length > 0) process.stderr.write(`[work-queue-worker] Registry scan: ${result.skipped.length} project(s) skipped (path not found).\n`);
|
|
2058
|
-
}
|
|
2059
|
-
/**
|
|
2060
|
-
* Enqueue a registry-scan work item — debounced.
|
|
2061
|
-
* If a registry-scan item is already pending or processing, skip enqueue.
|
|
2062
|
-
*/
|
|
2063
|
-
function enqueueRegistryScan() {
|
|
2064
|
-
if (hasPendingOrProcessingOfType("registry-scan")) {
|
|
2065
|
-
process.stderr.write("[work-queue-worker] Registry scan already pending/processing — skipping duplicate enqueue.\n");
|
|
2066
|
-
return;
|
|
2067
|
-
}
|
|
2068
|
-
enqueue({
|
|
2069
|
-
type: "registry-scan",
|
|
2070
|
-
priority: 5,
|
|
2071
|
-
payload: {}
|
|
2072
|
-
});
|
|
2073
|
-
}
|
|
2074
|
-
function tryParseJson(line) {
|
|
2075
|
-
try {
|
|
2076
|
-
return JSON.parse(line);
|
|
2077
|
-
} catch {
|
|
2078
|
-
return null;
|
|
2079
|
-
}
|
|
2080
|
-
}
|
|
2081
|
-
function contentToText(content) {
|
|
2082
|
-
if (typeof content === "string") return content;
|
|
2083
|
-
if (Array.isArray(content)) return content.map((c) => {
|
|
2084
|
-
if (typeof c === "string") return c;
|
|
2085
|
-
const block = c;
|
|
2086
|
-
if (block?.text) return String(block.text);
|
|
2087
|
-
if (block?.content) return String(block.content);
|
|
2088
|
-
return "";
|
|
2089
|
-
}).join(" ").trim();
|
|
2090
|
-
return "";
|
|
2091
|
-
}
|
|
2092
|
-
function extractWorkFromTranscript(lines) {
|
|
2093
|
-
const workItems = [];
|
|
2094
|
-
const seenSummaries = /* @__PURE__ */ new Set();
|
|
2095
|
-
for (const line of lines) {
|
|
2096
|
-
const entry = tryParseJson(line);
|
|
2097
|
-
if (!entry || entry.type !== "assistant") continue;
|
|
2098
|
-
const msg = entry.message;
|
|
2099
|
-
if (!msg?.content) continue;
|
|
2100
|
-
const content = contentToText(msg.content);
|
|
2101
|
-
const summaryMatch = content.match(/SUMMARY:\s*(.+?)(?:\n|$)/i);
|
|
2102
|
-
if (summaryMatch) {
|
|
2103
|
-
const summary = summaryMatch[1].trim();
|
|
2104
|
-
if (summary && !seenSummaries.has(summary) && summary.length > 5) {
|
|
2105
|
-
seenSummaries.add(summary);
|
|
2106
|
-
const details = [];
|
|
2107
|
-
const actionsMatch = content.match(/ACTIONS:\s*(.+?)(?=\n[A-Z]+:|$)/is);
|
|
2108
|
-
if (actionsMatch) {
|
|
2109
|
-
const actionLines = actionsMatch[1].split("\n").map((l) => l.replace(/^[-*•]\s*/, "").replace(/^\d+\.\s*/, "").trim()).filter((l) => l.length > 3 && l.length < 100);
|
|
2110
|
-
details.push(...actionLines.slice(0, 3));
|
|
2111
|
-
}
|
|
2112
|
-
workItems.push({
|
|
2113
|
-
title: summary,
|
|
2114
|
-
details: details.length > 0 ? details : void 0,
|
|
2115
|
-
completed: true
|
|
2116
|
-
});
|
|
2117
|
-
}
|
|
2118
|
-
}
|
|
2119
|
-
const completedMatch = content.match(/COMPLETED:\s*(.+?)(?:\n|$)/i);
|
|
2120
|
-
if (completedMatch && workItems.length === 0) {
|
|
2121
|
-
const completed = completedMatch[1].trim().replace(/\*+/g, "").replace(/\[.*?\]/g, "").trim();
|
|
2122
|
-
if (completed && !seenSummaries.has(completed) && completed.length > 5) {
|
|
2123
|
-
seenSummaries.add(completed);
|
|
2124
|
-
workItems.push({
|
|
2125
|
-
title: completed,
|
|
2126
|
-
completed: true
|
|
2127
|
-
});
|
|
2128
|
-
}
|
|
2129
|
-
}
|
|
2130
|
-
}
|
|
2131
|
-
return workItems;
|
|
2132
|
-
}
|
|
2133
|
-
|
|
2134
|
-
//#endregion
|
|
2135
|
-
export { enqueue as a, stopWorker as i, notifyNewWork as n, getStats as o, startWorker as r, loadQueue as s, enqueueRegistryScan as t };
|
|
2136
|
-
//# sourceMappingURL=work-queue-worker-DIT-VLSh.mjs.map
|