@tekmidian/pai 0.66.1 → 0.66.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{aibroker-client-B8c42Lh8.mjs → aibroker-client-Ck3p3k_-.mjs} +2 -2
- package/dist/{aibroker-client-C5Fw7DNz.mjs → aibroker-client-WCZi_tn-.mjs} +3 -3
- package/dist/{aibroker-client-C5Fw7DNz.mjs.map → aibroker-client-WCZi_tn-.mjs.map} +1 -1
- package/dist/{auto-route-o0BOsXn7.mjs → auto-route-BnizyALK.mjs} +65 -4
- package/dist/auto-route-BnizyALK.mjs.map +1 -0
- package/dist/auto-route-D6fW1Q8z.mjs +3 -0
- package/dist/{chain-CZfKIEq9.mjs → chain-Fz4NLm4D.mjs} +3 -4
- package/dist/{chain-CZfKIEq9.mjs.map → chain-Fz4NLm4D.mjs.map} +1 -1
- package/dist/cli/index.mjs +18 -23
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/program.mjs +17 -22
- package/dist/{config-YinjgXEJ.mjs → config-CnsDMETf.mjs} +1 -1
- package/dist/{config-BbLFD7Uf.mjs → config-Ddc4DIPk.mjs} +5 -3
- package/dist/config-Ddc4DIPk.mjs.map +1 -0
- package/dist/{context-handover-cache-pkHzmL3c.mjs → context-handover-cache-DcPEW6vf.mjs} +1221 -13
- package/dist/context-handover-cache-DcPEW6vf.mjs.map +1 -0
- package/dist/daemon/index.mjs +17 -19
- package/dist/daemon/index.mjs.map +1 -1
- package/dist/daemon-Bl8TpHdM.mjs +5048 -0
- package/dist/daemon-Bl8TpHdM.mjs.map +1 -0
- package/dist/daemon-ZhHU6D0i.mjs +18 -0
- package/dist/daemon-mcp/index.mjs +12 -9
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/{embeddings-CcscYWwk.mjs → embeddings-D0WOQpYR.mjs} +1 -1
- package/dist/{embeddings-Bx3q0QOY.mjs → embeddings-DMVzpvRZ.mjs} +18 -9
- package/dist/embeddings-DMVzpvRZ.mjs.map +1 -0
- package/dist/{env-JNEIrQWg.mjs → env-DiolswKQ.mjs} +1 -1
- package/dist/{env-JNEIrQWg.mjs.map → env-DiolswKQ.mjs.map} +1 -1
- package/dist/factory-BUnZgnKW.mjs +2892 -0
- package/dist/factory-BUnZgnKW.mjs.map +1 -0
- package/dist/factory-CrVlo2hY.mjs +8 -0
- package/dist/{fallback-CIiq2Raw.mjs → fallback-awOmq_9A.mjs} +23 -11
- package/dist/fallback-awOmq_9A.mjs.map +1 -0
- package/dist/{chunker-BH4i-F2b.mjs → helpers-CZsi_49C.mjs} +221 -2
- package/dist/helpers-CZsi_49C.mjs.map +1 -0
- package/dist/hooks/block-sleep-poll.mjs +6 -5
- package/dist/hooks/block-sleep-poll.mjs.map +3 -3
- package/dist/hooks/context-compression-hook.mjs +1 -0
- package/dist/hooks/context-compression-hook.mjs.map +2 -2
- package/dist/hooks/load-project-context.mjs +6 -5
- package/dist/hooks/load-project-context.mjs.map +3 -3
- package/dist/hooks/post-compact-inject.mjs +6 -5
- package/dist/hooks/post-compact-inject.mjs.map +3 -3
- package/dist/hooks/route-agents-to-worker.mjs +6 -5
- package/dist/hooks/route-agents-to-worker.mjs.map +3 -3
- package/dist/hooks/security-validator.mjs +1 -0
- package/dist/hooks/security-validator.mjs.map +2 -2
- package/dist/hooks/whisper-rules.mjs +1 -0
- package/dist/hooks/whisper-rules.mjs.map +2 -2
- package/dist/hooks/worker-guard.mjs +6 -5
- package/dist/hooks/worker-guard.mjs.map +3 -3
- package/dist/hooks/worker-proxy.mjs +6 -5
- package/dist/hooks/worker-proxy.mjs.map +3 -3
- package/dist/hooks/worker-status-line.mjs +6 -5
- package/dist/hooks/worker-status-line.mjs.map +3 -3
- package/dist/hooks/worker-supervision.mjs +6 -5
- package/dist/hooks/worker-supervision.mjs.map +3 -3
- package/dist/index.mjs +6 -7
- package/dist/{ipc-client-D16Xw6Uo.mjs → ipc-client-DkxRay-o.mjs} +2 -2
- package/dist/{ipc-client-D16Xw6Uo.mjs.map → ipc-client-DkxRay-o.mjs.map} +1 -1
- package/dist/{main-resolver-DPPwHNtn.mjs → main-resolver-CVGPICy-.mjs} +7 -7
- package/dist/{main-resolver-DPPwHNtn.mjs.map → main-resolver-CVGPICy-.mjs.map} +1 -1
- package/dist/main-resolver-DDShdDBm.mjs +7 -0
- package/dist/{migrate-BD7D8EEh.mjs → migrate-Bq0esMOa.mjs} +2 -2
- package/dist/{migrate-BD7D8EEh.mjs.map → migrate-Bq0esMOa.mjs.map} +1 -1
- package/dist/{pai-home-Cm9rcJgX.mjs → pai-home-UncxWxlX.mjs} +1 -1
- package/dist/{pai-home-Cm9rcJgX.mjs.map → pai-home-UncxWxlX.mjs.map} +1 -1
- package/dist/{pai-marker-D1MMswkz.mjs → pai-marker-DXVpFsYz.mjs} +1 -1
- package/dist/{pai-marker-D1MMswkz.mjs.map → pai-marker-DXVpFsYz.mjs.map} +1 -1
- package/dist/{planner-Dm5Acyo1.mjs → planner-GdiYe2FO.mjs} +10 -7
- package/dist/{planner-Dm5Acyo1.mjs.map → planner-GdiYe2FO.mjs.map} +1 -1
- package/dist/postgres-DKyiCA5V.mjs +5 -0
- package/dist/{postgres-Ceqsa64C.mjs → postgres-H85VSYHI.mjs} +364 -9
- package/dist/postgres-H85VSYHI.mjs.map +1 -0
- package/dist/{program-JJ0TwSHI.mjs → program-D3MSfx86.mjs} +142 -100
- package/dist/program-D3MSfx86.mjs.map +1 -0
- package/dist/{query-feedback-BIaZTTFO.mjs → query-feedback-BPdapXKd.mjs} +2 -2
- package/dist/{query-feedback-BIaZTTFO.mjs.map → query-feedback-BPdapXKd.mjs.map} +1 -1
- package/dist/{reranker-DKv80KO5.mjs → reranker-3lnggwgq.mjs} +1 -1
- package/dist/{reranker-DKv80KO5.mjs.map → reranker-3lnggwgq.mjs.map} +1 -1
- package/dist/{reranker-CFiEzHQu.mjs → reranker-CZ2mP4cf.mjs} +1 -1
- package/dist/router-BK-hFeQ6.mjs +3 -0
- package/dist/{router-a5G7q8_3.mjs → router-BaTbc9VX.mjs} +2 -2
- package/dist/{router-a5G7q8_3.mjs.map → router-BaTbc9VX.mjs.map} +1 -1
- package/dist/{run-BCpbxqA5.mjs → run-D9YbWsnL.mjs} +599 -22
- package/dist/run-D9YbWsnL.mjs.map +1 -0
- package/dist/{run-env-DRcp7A8K.mjs → run-env-CQRnxNht.mjs} +3 -3
- package/dist/{run-env-DRcp7A8K.mjs.map → run-env-CQRnxNht.mjs.map} +1 -1
- package/dist/{runtime-paths-CHTg3ywb.mjs → runtime-paths-Bw3Jk8qJ.mjs} +4 -2
- package/dist/runtime-paths-Bw3Jk8qJ.mjs.map +1 -0
- package/dist/{search-BOSphCJ1.mjs → search-CNAGTiJP.mjs} +2 -2
- package/dist/{search-BOSphCJ1.mjs.map → search-CNAGTiJP.mjs.map} +1 -1
- package/dist/{sources-CijVso3n.mjs → sources-CM2g-CLT.mjs} +2 -2
- package/dist/{sources-CijVso3n.mjs.map → sources-CM2g-CLT.mjs.map} +1 -1
- package/dist/{stop-words-BdQuaE9K.mjs → stop-words-DtxaTWU_.mjs} +1 -1
- package/dist/{stop-words-BdQuaE9K.mjs.map → stop-words-DtxaTWU_.mjs.map} +1 -1
- package/dist/{utils-C9HsYDpO.mjs → utils-DddRwMsG.mjs} +2 -2
- package/dist/{utils-C9HsYDpO.mjs.map → utils-DddRwMsG.mjs.map} +1 -1
- package/dist/{zettelkasten-CtGHQWPU.mjs → zettelkasten-BRIaHzFm.mjs} +149 -7
- package/dist/zettelkasten-BRIaHzFm.mjs.map +1 -0
- package/dist/zettelkasten-G0-ZTybr.mjs +5 -0
- package/docs/commands/memory.md +2 -0
- package/docs/commands/worker.md +2 -0
- package/package.json +1 -1
- package/dist/async-C2Bm_Lal.mjs +0 -300
- package/dist/async-C2Bm_Lal.mjs.map +0 -1
- package/dist/auto-route-o0BOsXn7.mjs.map +0 -1
- package/dist/chunker-BH4i-F2b.mjs.map +0 -1
- package/dist/clusters-BCtD3fbe.mjs +0 -169
- package/dist/clusters-BCtD3fbe.mjs.map +0 -1
- package/dist/config-BbLFD7Uf.mjs.map +0 -1
- package/dist/context-handover-cache-pkHzmL3c.mjs.map +0 -1
- package/dist/daemon-DXlaVCkw.mjs +0 -1741
- package/dist/daemon-DXlaVCkw.mjs.map +0 -1
- package/dist/daemon-DyORmxZu.mjs +0 -19
- package/dist/detector-BEPFsINR.mjs +0 -3
- package/dist/detector-BRTtAWrM.mjs +0 -65
- package/dist/detector-BRTtAWrM.mjs.map +0 -1
- package/dist/embeddings-Bx3q0QOY.mjs.map +0 -1
- package/dist/factory-CNpPQQ-5.mjs +0 -249
- package/dist/factory-CNpPQQ-5.mjs.map +0 -1
- package/dist/factory-DY7x73mE.mjs +0 -3
- package/dist/fallback-CIiq2Raw.mjs.map +0 -1
- package/dist/federation-db-BTyoufBh.mjs +0 -139
- package/dist/federation-db-BTyoufBh.mjs.map +0 -1
- package/dist/federation-db-HfIFc7FG.mjs +0 -3
- package/dist/helpers-BRCJg0G3.mjs +0 -223
- package/dist/helpers-BRCJg0G3.mjs.map +0 -1
- package/dist/indexer-backend-DFF2FrYx.mjs +0 -5
- package/dist/indexer-backend-isSLg6yE.mjs +0 -1
- package/dist/kg-entity-DCOcsVFD.mjs +0 -29
- package/dist/kg-entity-DCOcsVFD.mjs.map +0 -1
- package/dist/latent-ideas-wwAXquGe.mjs +0 -191
- package/dist/latent-ideas-wwAXquGe.mjs.map +0 -1
- package/dist/link-boost-CnI7UVMJ.mjs +0 -34
- package/dist/link-boost-CnI7UVMJ.mjs.map +0 -1
- package/dist/main-resolver-BKz_OuEh.mjs +0 -7
- package/dist/merge-2gqRPFu2.mjs +0 -3
- package/dist/merge-DgU9OgZy.mjs +0 -6
- package/dist/merge-DgU9OgZy.mjs.map +0 -1
- package/dist/module-paths-DdRzbkUI.mjs +0 -44
- package/dist/module-paths-DdRzbkUI.mjs.map +0 -1
- package/dist/neighborhood-2FsmzoxG.mjs +0 -114
- package/dist/neighborhood-2FsmzoxG.mjs.map +0 -1
- package/dist/note-context-De63k8na.mjs +0 -106
- package/dist/note-context-De63k8na.mjs.map +0 -1
- package/dist/postgres-Ceqsa64C.mjs.map +0 -1
- package/dist/program-JJ0TwSHI.mjs.map +0 -1
- package/dist/query-feedback-B_iigYj-.mjs +0 -3
- package/dist/registry-db-C7voqML9.mjs +0 -213
- package/dist/registry-db-C7voqML9.mjs.map +0 -1
- package/dist/registry-db-JHPhA8vF.mjs +0 -3
- package/dist/registry-postgres-nOfyBSIW.mjs +0 -795
- package/dist/registry-postgres-nOfyBSIW.mjs.map +0 -1
- package/dist/registry-sqlite-DrCW1aRK.mjs +0 -593
- package/dist/registry-sqlite-DrCW1aRK.mjs.map +0 -1
- package/dist/router-CXUGsv85.mjs +0 -3
- package/dist/run-BCpbxqA5.mjs.map +0 -1
- package/dist/runtime-paths-CHTg3ywb.mjs.map +0 -1
- package/dist/search-Bf3Kub3F.mjs +0 -3
- package/dist/server-DgmAHyFK.mjs +0 -403
- package/dist/server-DgmAHyFK.mjs.map +0 -1
- package/dist/session-keepalive-DxjTHGcK.mjs +0 -582
- package/dist/session-keepalive-DxjTHGcK.mjs.map +0 -1
- package/dist/sqlite-CqsTy6xo.mjs +0 -936
- package/dist/sqlite-CqsTy6xo.mjs.map +0 -1
- package/dist/state-DW8zdweW.mjs +0 -3
- package/dist/state-HyjqTihC.mjs +0 -76
- package/dist/state-HyjqTihC.mjs.map +0 -1
- package/dist/themes-CTaOj3e1.mjs +0 -148
- package/dist/themes-CTaOj3e1.mjs.map +0 -1
- package/dist/tools-BcOKPBtg.mjs +0 -1489
- package/dist/tools-BcOKPBtg.mjs.map +0 -1
- package/dist/tools-C-_l0o8Z.mjs +0 -6
- package/dist/trace-CtV0RO6n.mjs +0 -137
- package/dist/trace-CtV0RO6n.mjs.map +0 -1
- package/dist/utils-BrX9FLeK.mjs +0 -3
- package/dist/vault-indexer-BG1sEVyM.mjs +0 -537
- package/dist/vault-indexer-BG1sEVyM.mjs.map +0 -1
- package/dist/wakeup-D8n33hYV.mjs +0 -335
- package/dist/wakeup-D8n33hYV.mjs.map +0 -1
- package/dist/work-queue-worker-4tLa5gpQ.mjs +0 -567
- package/dist/work-queue-worker-4tLa5gpQ.mjs.map +0 -1
- package/dist/work-queue-worker-rFZvTO3p.mjs +0 -11
- package/dist/zettelkasten-CtGHQWPU.mjs.map +0 -1
|
@@ -1,19 +1,113 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { a as slugify, i as parseSessionFilename, n as decodeEncodedDir, t as buildEncodedDirMap } from "./migrate-
|
|
3
|
-
import { n as ensurePaiMarker, t as discoverPaiMarkers } from "./pai-marker-
|
|
4
|
-
import { i as getRegistryBackend } from "./factory-
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { k as
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
1
|
+
import { c as ok, g as warn, i as err, n as dim, r as encodeDir, s as now, t as bold } from "./utils-DddRwMsG.mjs";
|
|
2
|
+
import { a as slugify, i as parseSessionFilename, n as decodeEncodedDir, t as buildEncodedDirMap } from "./migrate-Bq0esMOa.mjs";
|
|
3
|
+
import { n as ensurePaiMarker, t as discoverPaiMarkers } from "./pai-marker-DXVpFsYz.mjs";
|
|
4
|
+
import { i as getRegistryBackend } from "./factory-BUnZgnKW.mjs";
|
|
5
|
+
import { y as writeJsonAtomic } from "./config-Ddc4DIPk.mjs";
|
|
6
|
+
import { a as paiHomePath, o as resolvePaiFile, r as migratePaiFile } from "./pai-home-UncxWxlX.mjs";
|
|
7
|
+
import { r as daemonPidPath } from "./runtime-paths-Bw3Jk8qJ.mjs";
|
|
8
|
+
import { D as readWorkersSection, k as resolveModelCapability, t as buildRunEnv, v as defaultWorkersConfig } from "./run-env-CQRnxNht.mjs";
|
|
9
|
+
import { C as itermUuid, F as appendLedger, P as worktreesDir, T as sessionMapPath, ct as resolveTarget, lt as ensureNoMcpConfig, nt as DEFAULT_PROXY_PORT, pt as workersLogDir, rt as ensureProxyRunning } from "./run-D9YbWsnL.mjs";
|
|
10
|
+
import { i as sendToSession, n as fetchLiveSessions } from "./aibroker-client-WCZi_tn-.mjs";
|
|
11
|
+
import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { homedir, setPriority, tmpdir, userInfo } from "node:os";
|
|
13
|
+
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
|
12
14
|
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
13
16
|
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";
|
|
14
17
|
import { basename as basename$1, join as join$1, resolve as resolve$1 } from "path";
|
|
15
18
|
import { homedir as homedir$1 } from "os";
|
|
19
|
+
import { createInterface } from "node:readline";
|
|
16
20
|
|
|
21
|
+
//#region src/cli/commands/detect.ts
|
|
22
|
+
/**
|
|
23
|
+
* Project detection logic for PAI.
|
|
24
|
+
*
|
|
25
|
+
* detectProject(cwd) — given a filesystem path, returns the best matching
|
|
26
|
+
* project from the registry:
|
|
27
|
+
* 1. Exact path match
|
|
28
|
+
* 2. Longest parent match (project whose root_path is an ancestor of cwd)
|
|
29
|
+
*
|
|
30
|
+
* Exported for use by the CLI `pai project detect` command and the MCP
|
|
31
|
+
* `project_detect` tool.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* Detect which registered project a filesystem path belongs to.
|
|
35
|
+
*
|
|
36
|
+
* @param cwd Absolute path to detect (defaults to process.cwd())
|
|
37
|
+
* @returns The best matching project, or null if no match
|
|
38
|
+
*/
|
|
39
|
+
async function detectProject(cwd) {
|
|
40
|
+
const target = resolve(cwd ?? process.cwd());
|
|
41
|
+
const backend = await getRegistryBackend();
|
|
42
|
+
const projects = await backend.listProjectsByPathLengthDesc({ excludeArchived: true });
|
|
43
|
+
let matched = null;
|
|
44
|
+
let matchType = "exact";
|
|
45
|
+
for (const p of projects) {
|
|
46
|
+
const root = resolve(p.root_path);
|
|
47
|
+
if (target === root) {
|
|
48
|
+
matched = p;
|
|
49
|
+
matchType = "exact";
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
if (!matched && target.startsWith(root + "/")) {
|
|
53
|
+
matched = p;
|
|
54
|
+
matchType = "parent";
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!matched) return null;
|
|
59
|
+
const sessionCount = await backend.countSessionsForProject(matched.id);
|
|
60
|
+
const lastDate = await backend.getMostRecentSessionDate(matched.id);
|
|
61
|
+
const relative = matchType === "parent" ? target.slice(resolve(matched.root_path).length + 1) : null;
|
|
62
|
+
return {
|
|
63
|
+
id: matched.id,
|
|
64
|
+
slug: matched.slug,
|
|
65
|
+
display_name: matched.display_name,
|
|
66
|
+
root_path: matched.root_path,
|
|
67
|
+
encoded_dir: matched.encoded_dir,
|
|
68
|
+
type: matched.type,
|
|
69
|
+
status: matched.status,
|
|
70
|
+
session_count: sessionCount,
|
|
71
|
+
last_session_date: lastDate,
|
|
72
|
+
match_type: matchType,
|
|
73
|
+
relative_path: relative
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Format a DetectedProject for human-readable CLI output.
|
|
78
|
+
*/
|
|
79
|
+
function formatDetection(d) {
|
|
80
|
+
const lines = [
|
|
81
|
+
`slug: ${d.slug}`,
|
|
82
|
+
`display_name: ${d.display_name}`,
|
|
83
|
+
`root_path: ${d.root_path}`,
|
|
84
|
+
`type: ${d.type}`,
|
|
85
|
+
`status: ${d.status}`,
|
|
86
|
+
`match: ${d.match_type}${d.relative_path ? ` (+${d.relative_path})` : ""}`,
|
|
87
|
+
`sessions: ${d.session_count}`
|
|
88
|
+
];
|
|
89
|
+
if (d.last_session_date) lines.push(`last_session: ${d.last_session_date}`);
|
|
90
|
+
return lines.join("\n");
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Format a DetectedProject as JSON for machine consumption.
|
|
94
|
+
*/
|
|
95
|
+
function formatDetectionJson(d) {
|
|
96
|
+
return JSON.stringify({
|
|
97
|
+
slug: d.slug,
|
|
98
|
+
display_name: d.display_name,
|
|
99
|
+
root_path: d.root_path,
|
|
100
|
+
encoded_dir: d.encoded_dir,
|
|
101
|
+
type: d.type,
|
|
102
|
+
status: d.status,
|
|
103
|
+
match_type: d.match_type,
|
|
104
|
+
relative_path: d.relative_path,
|
|
105
|
+
session_count: d.session_count,
|
|
106
|
+
last_session_date: d.last_session_date
|
|
107
|
+
}, null, 2);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
//#endregion
|
|
17
111
|
//#region src/registry/moved.ts
|
|
18
112
|
/**
|
|
19
113
|
* moved.ts — reconnect a project to transcripts that moved out from under it.
|
|
@@ -553,6 +647,386 @@ async function cmdScan(opts = {}) {
|
|
|
553
647
|
} else process.stderr.write(`[registry-scan] ${result.projectsScanned} projects, ${result.sessionsScanned} sessions (${result.projectsNew}+${result.sessionsNew} new).\n`);
|
|
554
648
|
}
|
|
555
649
|
|
|
650
|
+
//#endregion
|
|
651
|
+
//#region src/daemon/pass-priority.ts
|
|
652
|
+
/**
|
|
653
|
+
* pass-priority.ts — run heavy index/embed pass work at background priority.
|
|
654
|
+
*
|
|
655
|
+
* The daemon also serves MCP, so its own priority must stay normal. The heavy
|
|
656
|
+
* work runs in a child (`pai memory pass <name>`) spawned under
|
|
657
|
+
* `taskpolicy -b nice -n 19` (darwin) or `nice -n 19 ionice -c3` (linux);
|
|
658
|
+
* onnxruntime threads inherit the child's priority. The child reports over
|
|
659
|
+
* stdout as one JSON line: {ok:true,result} or {ok:false,error}.
|
|
660
|
+
*/
|
|
661
|
+
function onPath(bin) {
|
|
662
|
+
return (process.env.PATH ?? "").split(delimiter).some((d) => d && existsSync(join(d, bin)));
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Wrap `node <entry> <passArgs>` in the platform's low-priority incantation.
|
|
666
|
+
* Darwin uses nice 19, not 20: measured on macOS, node started with nice 20
|
|
667
|
+
* reverts to nice 0 within a second; 19 sticks.
|
|
668
|
+
*/
|
|
669
|
+
function buildPassCommand(platform, node, entry, passArgs, have = onPath) {
|
|
670
|
+
const run = [
|
|
671
|
+
node,
|
|
672
|
+
entry,
|
|
673
|
+
...passArgs
|
|
674
|
+
];
|
|
675
|
+
let wrap;
|
|
676
|
+
if (platform === "darwin") wrap = have("taskpolicy") ? [
|
|
677
|
+
"taskpolicy",
|
|
678
|
+
"-b",
|
|
679
|
+
"nice",
|
|
680
|
+
"-n",
|
|
681
|
+
"19"
|
|
682
|
+
] : [
|
|
683
|
+
"nice",
|
|
684
|
+
"-n",
|
|
685
|
+
"19"
|
|
686
|
+
];
|
|
687
|
+
else if (platform === "win32") wrap = [];
|
|
688
|
+
else wrap = have("ionice") ? [
|
|
689
|
+
"nice",
|
|
690
|
+
"-n",
|
|
691
|
+
"19",
|
|
692
|
+
"ionice",
|
|
693
|
+
"-c3"
|
|
694
|
+
] : [
|
|
695
|
+
"nice",
|
|
696
|
+
"-n",
|
|
697
|
+
"19"
|
|
698
|
+
];
|
|
699
|
+
const [cmd, ...rest] = [...wrap, ...run];
|
|
700
|
+
return {
|
|
701
|
+
cmd,
|
|
702
|
+
args: rest
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
/** The CLI entry the child runs: dist/cli/index.mjs next to the daemon's dist. */
|
|
706
|
+
function cliEntry() {
|
|
707
|
+
return join(dirname(dirname(process.argv[1] ?? "")), "cli", "index.mjs");
|
|
708
|
+
}
|
|
709
|
+
/** Spawning itself failed (binary missing): the caller may fall back in-process. */
|
|
710
|
+
var PassSpawnError = class extends Error {};
|
|
711
|
+
const children = /* @__PURE__ */ new Set();
|
|
712
|
+
process.on("exit", () => children.forEach((c) => c.kill()));
|
|
713
|
+
/**
|
|
714
|
+
* Run one pass in a low-priority child. Resolves with the child's result;
|
|
715
|
+
* rejects with the child's error (pass failure) or PassSpawnError (no child).
|
|
716
|
+
*/
|
|
717
|
+
function runPassChild(name, extraArgs = []) {
|
|
718
|
+
const { cmd, args } = buildPassCommand(process.platform, process.execPath, cliEntry(), [
|
|
719
|
+
"memory",
|
|
720
|
+
"pass",
|
|
721
|
+
name,
|
|
722
|
+
...extraArgs
|
|
723
|
+
]);
|
|
724
|
+
return new Promise((resolve, reject) => {
|
|
725
|
+
const child = spawn(cmd, args, {
|
|
726
|
+
stdio: [
|
|
727
|
+
"ignore",
|
|
728
|
+
"pipe",
|
|
729
|
+
"pipe"
|
|
730
|
+
],
|
|
731
|
+
env: {
|
|
732
|
+
...process.env,
|
|
733
|
+
PAI_PASS_BACKGROUND: "1"
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
children.add(child);
|
|
737
|
+
let out = "";
|
|
738
|
+
let err = "";
|
|
739
|
+
child.stdout.on("data", (d) => out += d);
|
|
740
|
+
child.stderr.on("data", (d) => err = (err + d).slice(-4e3));
|
|
741
|
+
child.on("error", (e) => {
|
|
742
|
+
children.delete(child);
|
|
743
|
+
reject(new PassSpawnError(`cannot spawn ${cmd}: ${e.message}`));
|
|
744
|
+
});
|
|
745
|
+
child.on("close", (code, signal) => {
|
|
746
|
+
children.delete(child);
|
|
747
|
+
const line = out.trim().split("\n").pop() ?? "";
|
|
748
|
+
let msg;
|
|
749
|
+
try {
|
|
750
|
+
msg = JSON.parse(line);
|
|
751
|
+
} catch {}
|
|
752
|
+
if (code === 0 && msg?.ok) return resolve(msg.result);
|
|
753
|
+
reject(new Error(msg?.error ?? `pass child ${name} exited ${code ?? signal}: ${err.trim().split("\n").pop() ?? ""}`));
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Inside the wrapper, keep nice at 19: measured on macOS, node's nice resets
|
|
759
|
+
* to 0 about a second after start (5s re-apply interval holds it; the
|
|
760
|
+
* taskpolicy -b QoS is unaffected).
|
|
761
|
+
*/
|
|
762
|
+
function holdNice() {
|
|
763
|
+
const apply = () => {
|
|
764
|
+
try {
|
|
765
|
+
setPriority(process.pid, 19);
|
|
766
|
+
} catch {}
|
|
767
|
+
};
|
|
768
|
+
apply();
|
|
769
|
+
setInterval(apply, 5e3).unref();
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* `--background` for CLI commands: re-exec this very command under the
|
|
773
|
+
* low-priority wrapper and exit with its status. Inside the re-exec (or a
|
|
774
|
+
* daemon-spawned pass child, which sets the same env) it only holds nice.
|
|
775
|
+
*/
|
|
776
|
+
function reexecBackground() {
|
|
777
|
+
if (process.env.PAI_PASS_BACKGROUND === "1") return holdNice();
|
|
778
|
+
const { cmd, args } = buildPassCommand(process.platform, process.execPath, process.argv[1], process.argv.slice(2));
|
|
779
|
+
const r = spawnSync(cmd, args, {
|
|
780
|
+
stdio: "inherit",
|
|
781
|
+
env: {
|
|
782
|
+
...process.env,
|
|
783
|
+
PAI_PASS_BACKGROUND: "1"
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
if (r.error) {
|
|
787
|
+
process.stderr.write(`--background: cannot spawn ${cmd} (${r.error.message}); running at normal priority\n`);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
process.exit(r.status ?? 1);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
//#endregion
|
|
794
|
+
//#region src/memory/wakeup.ts
|
|
795
|
+
/**
|
|
796
|
+
* Wake-up context system — progressive context loading inspired by mempalace.
|
|
797
|
+
*
|
|
798
|
+
* Layers:
|
|
799
|
+
* L0 Identity (~100 tokens) — user identity from ~/.pai/identity.txt. Always loaded.
|
|
800
|
+
* L1 Essential Story (~500-800t) — top session notes for the project, key lines extracted.
|
|
801
|
+
* L2 On-Demand — triggered by topic queries (handled by memory_search).
|
|
802
|
+
* L3 Deep Search — unlimited federated memory search (memory_search tool).
|
|
803
|
+
*/
|
|
804
|
+
/** Maximum tokens for the L1 essential story block. Approx 4 chars/token.
|
|
805
|
+
* Measured 2026-09-20: 800 tokens was mostly stale bullets. Reduced to 300.
|
|
806
|
+
*/
|
|
807
|
+
const L1_TOKEN_BUDGET = 300;
|
|
808
|
+
L1_TOKEN_BUDGET * 4;
|
|
809
|
+
/** Maximum session notes to scan when building L1. Reduced from 10 to 3 (2026-09-20). */
|
|
810
|
+
const L1_MAX_NOTES = 3;
|
|
811
|
+
/** Sections to extract from session notes (in priority order). */
|
|
812
|
+
const EXTRACT_SECTIONS = [
|
|
813
|
+
"Work Done",
|
|
814
|
+
"Key Decisions",
|
|
815
|
+
"Next Steps",
|
|
816
|
+
"Checkpoint"
|
|
817
|
+
];
|
|
818
|
+
/** Identity file location. */
|
|
819
|
+
const IDENTITY_FILE = join(homedir(), ".pai", "identity.txt");
|
|
820
|
+
/**
|
|
821
|
+
* Load L0 identity. Prefers the PAI_HOME location (identity.txt migrated by
|
|
822
|
+
* `pai config migrate`), falling back to the legacy ~/.pai/identity.txt so a
|
|
823
|
+
* not-yet-migrated file keeps working. Returns "" if neither exists. Never
|
|
824
|
+
* throws.
|
|
825
|
+
*/
|
|
826
|
+
function loadL0Identity(opts = {}) {
|
|
827
|
+
const newPath = opts.newPath ?? paiHomePath("identity.txt");
|
|
828
|
+
const legacyPath = opts.legacyPath ?? IDENTITY_FILE;
|
|
829
|
+
const path = existsSync(newPath) ? newPath : legacyPath;
|
|
830
|
+
if (!existsSync(path)) return "";
|
|
831
|
+
try {
|
|
832
|
+
return readFileSync(path, "utf-8").trim();
|
|
833
|
+
} catch {
|
|
834
|
+
return "";
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Move ~/.pai/identity.txt into PAI_HOME, following the same copy /
|
|
839
|
+
* verify-byte-identical / rename-aside semantics as every other per-user
|
|
840
|
+
* file (see migratePaiFile). `from`/`to` are overridable for tests.
|
|
841
|
+
*/
|
|
842
|
+
function migrateIdentityFile(opts = {}) {
|
|
843
|
+
return migratePaiFile(opts.to ?? paiHomePath("identity.txt"), [opts.from ?? IDENTITY_FILE], { dryRun: opts.dryRun });
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Find the Notes directory for a project given its root_path from the registry.
|
|
847
|
+
* Checks local Notes/ first, then central ~/.claude/projects/... path.
|
|
848
|
+
*/
|
|
849
|
+
function findNotesDirForProject(rootPath) {
|
|
850
|
+
const localCandidates = [
|
|
851
|
+
join(rootPath, "Notes"),
|
|
852
|
+
join(rootPath, "notes"),
|
|
853
|
+
join(rootPath, ".claude", "Notes")
|
|
854
|
+
];
|
|
855
|
+
for (const p of localCandidates) if (existsSync(p)) return p;
|
|
856
|
+
const encoded = rootPath.replace(/\//g, "-").replace(/\./g, "-").replace(/ /g, "-");
|
|
857
|
+
const centralNotes = join(homedir(), ".claude", "projects", encoded, "Notes");
|
|
858
|
+
if (existsSync(centralNotes)) return centralNotes;
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Recursively find all .md session note files in a Notes directory.
|
|
863
|
+
* Handles both flat layout (Notes/*.md) and month-subdirectory layout
|
|
864
|
+
* (Notes/YYYY/MM/*.md). Returns files sorted newest-first by filename
|
|
865
|
+
* (note numbers are monotonically increasing, so lexicographic = newest-last,
|
|
866
|
+
* so we reverse).
|
|
867
|
+
*/
|
|
868
|
+
function findSessionNotes(notesDir) {
|
|
869
|
+
const result = [];
|
|
870
|
+
const scanDir = (dir) => {
|
|
871
|
+
if (!existsSync(dir)) return;
|
|
872
|
+
let entries;
|
|
873
|
+
try {
|
|
874
|
+
entries = readdirSync(dir, { withFileTypes: true }).map((e) => ({
|
|
875
|
+
name: e.name,
|
|
876
|
+
isDir: e.isDirectory()
|
|
877
|
+
}));
|
|
878
|
+
} catch {
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
for (const entry of entries) {
|
|
882
|
+
const fullPath = join(dir, entry.name);
|
|
883
|
+
if (entry.isDir) scanDir(fullPath);
|
|
884
|
+
else if (entry.name.match(/^\d{3,4}[\s_-].*\.md$/)) result.push(fullPath);
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
scanDir(notesDir);
|
|
888
|
+
const dateOf = (p) => basename(p).match(/(\d{4}-\d{2}-\d{2})/)?.[1] ?? "";
|
|
889
|
+
const numberOf = (p) => parseInt(basename(p).match(/^(\d+)/)?.[1] ?? "0", 10);
|
|
890
|
+
const mtimeOf = (p) => {
|
|
891
|
+
try {
|
|
892
|
+
return statSync(p).mtimeMs;
|
|
893
|
+
} catch {
|
|
894
|
+
return 0;
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
result.sort((a, b) => {
|
|
898
|
+
const dateA = dateOf(a);
|
|
899
|
+
const dateB = dateOf(b);
|
|
900
|
+
if (dateA !== dateB) {
|
|
901
|
+
if (!dateA) return 1;
|
|
902
|
+
if (!dateB) return -1;
|
|
903
|
+
return dateB.localeCompare(dateA);
|
|
904
|
+
}
|
|
905
|
+
const byNumber = numberOf(b) - numberOf(a);
|
|
906
|
+
if (byNumber !== 0) return byNumber;
|
|
907
|
+
return mtimeOf(b) - mtimeOf(a);
|
|
908
|
+
});
|
|
909
|
+
return result;
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Extract the most important lines from a session note.
|
|
913
|
+
* Prioritises: Work Done items, Key Decisions, Next Steps, Checkpoint headings.
|
|
914
|
+
* Returns a condensed string under maxChars.
|
|
915
|
+
*/
|
|
916
|
+
function normalizeLine(line) {
|
|
917
|
+
return line.replace(/^(- \[[ x]\] |- |\* |\d+\.\s)/, "").trim().replace(/\s+/g, " ");
|
|
918
|
+
}
|
|
919
|
+
function extractKeyLines(content, maxChars, seen = /* @__PURE__ */ new Set(), seenLabels = /* @__PURE__ */ new Set()) {
|
|
920
|
+
const lines = content.split("\n");
|
|
921
|
+
const selected = [];
|
|
922
|
+
let inTargetSection = false;
|
|
923
|
+
let currentSection = "";
|
|
924
|
+
let charCount = 0;
|
|
925
|
+
let pendingLabel = null;
|
|
926
|
+
for (const line of lines) {
|
|
927
|
+
const h2Match = line.match(/^## (.+)$/);
|
|
928
|
+
const h3Match = line.match(/^### (.+)$/);
|
|
929
|
+
if (h2Match) {
|
|
930
|
+
currentSection = h2Match[1];
|
|
931
|
+
inTargetSection = EXTRACT_SECTIONS.some((s) => currentSection.toLowerCase().includes(s.toLowerCase()));
|
|
932
|
+
pendingLabel = null;
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
if (h3Match) {
|
|
936
|
+
if (inTargetSection) pendingLabel = h3Match[1];
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
if (!inTargetSection) continue;
|
|
940
|
+
const trimmed = line.trim();
|
|
941
|
+
if (!trimmed || trimmed.startsWith("<!--") || trimmed === "---") continue;
|
|
942
|
+
if (trimmed.startsWith("- ") || trimmed.startsWith("* ") || trimmed.match(/^\d+\./) || trimmed.startsWith("**")) {
|
|
943
|
+
const normalized = normalizeLine(trimmed);
|
|
944
|
+
if (seen.has(normalized)) continue;
|
|
945
|
+
let labelText = "";
|
|
946
|
+
let labelKey = "";
|
|
947
|
+
if (pendingLabel !== null) {
|
|
948
|
+
labelKey = pendingLabel.toLowerCase().trim();
|
|
949
|
+
if (!seenLabels.has(labelKey)) labelText = `[${pendingLabel}]`;
|
|
950
|
+
}
|
|
951
|
+
if (charCount + labelText.length + trimmed.length + 2 > maxChars) break;
|
|
952
|
+
if (labelText) {
|
|
953
|
+
selected.push(labelText);
|
|
954
|
+
seenLabels.add(labelKey);
|
|
955
|
+
charCount += labelText.length + 1;
|
|
956
|
+
}
|
|
957
|
+
pendingLabel = null;
|
|
958
|
+
seen.add(normalized);
|
|
959
|
+
selected.push(trimmed);
|
|
960
|
+
charCount += trimmed.length + 1;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return selected.join("\n");
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Build the L1 essential story block.
|
|
967
|
+
*
|
|
968
|
+
* Reads the most recent session notes for the project and extracts the key
|
|
969
|
+
* lines (Work Done, Key Decisions, Next Steps) within the token budget.
|
|
970
|
+
*
|
|
971
|
+
* @param rootPath The project root path (from the registry).
|
|
972
|
+
* @param tokenBudget Max tokens to consume. Default 800 (~3200 chars).
|
|
973
|
+
* @returns Formatted L1 block, or empty string if no notes found.
|
|
974
|
+
*/
|
|
975
|
+
function buildL1EssentialStory(rootPath, tokenBudget = L1_TOKEN_BUDGET) {
|
|
976
|
+
const charBudget = tokenBudget * 4;
|
|
977
|
+
const notesDir = findNotesDirForProject(rootPath);
|
|
978
|
+
if (!notesDir) return "";
|
|
979
|
+
const noteFiles = findSessionNotes(notesDir).slice(0, L1_MAX_NOTES);
|
|
980
|
+
if (noteFiles.length === 0) return "";
|
|
981
|
+
const sections = [];
|
|
982
|
+
const seen = /* @__PURE__ */ new Set();
|
|
983
|
+
const seenLabels = /* @__PURE__ */ new Set();
|
|
984
|
+
let remaining = charBudget;
|
|
985
|
+
for (const noteFile of noteFiles) {
|
|
986
|
+
if (remaining <= 50) break;
|
|
987
|
+
let content;
|
|
988
|
+
try {
|
|
989
|
+
content = readFileSync(noteFile, "utf-8");
|
|
990
|
+
} catch {
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
const name = basename(noteFile);
|
|
994
|
+
const titleMatch = name.match(/^\d+ - (\d{4}-\d{2}-\d{2}) - (.+)\.md$/);
|
|
995
|
+
const dateLabel = titleMatch ? titleMatch[1] : "";
|
|
996
|
+
const titleLabel = titleMatch ? titleMatch[2] : name.replace(/^\d+ - /, "").replace(/\.md$/, "");
|
|
997
|
+
const perNoteChars = Math.min(remaining, Math.floor(charBudget / noteFiles.length) + 200);
|
|
998
|
+
const extracted = extractKeyLines(content, perNoteChars, seen, seenLabels);
|
|
999
|
+
const hasContentLine = extracted.split("\n").some((l) => !/^\[.*\]$/.test(l));
|
|
1000
|
+
if (!extracted || !hasContentLine) continue;
|
|
1001
|
+
const noteBlock = `[${dateLabel} - ${titleLabel}]\n${extracted}`;
|
|
1002
|
+
sections.push(noteBlock);
|
|
1003
|
+
remaining -= noteBlock.length + 1;
|
|
1004
|
+
}
|
|
1005
|
+
if (sections.length === 0) return "";
|
|
1006
|
+
return sections.join("\n\n");
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Build the combined wake-up context block (L0 + L1).
|
|
1010
|
+
*
|
|
1011
|
+
* Returns a formatted string suitable for injection as a system-reminder,
|
|
1012
|
+
* or an empty string if both layers are empty.
|
|
1013
|
+
*
|
|
1014
|
+
* @param rootPath Project root path for L1 note lookup. Optional.
|
|
1015
|
+
* @param tokenBudget L1 token budget. Default 800.
|
|
1016
|
+
* @param opts.skipStory Skip the L1 essential story. Set when a handover
|
|
1017
|
+
* checkpoint was already injected — the story re-summarises the same note
|
|
1018
|
+
* the handover came from, at lower quality.
|
|
1019
|
+
*/
|
|
1020
|
+
function buildWakeupContext(rootPath, tokenBudget = L1_TOKEN_BUDGET, opts = {}) {
|
|
1021
|
+
const identity = loadL0Identity();
|
|
1022
|
+
const essentialStory = rootPath && !opts.skipStory ? buildL1EssentialStory(rootPath, tokenBudget) : "";
|
|
1023
|
+
if (!identity && !essentialStory) return "";
|
|
1024
|
+
const parts = [];
|
|
1025
|
+
if (identity) parts.push(`## L0 Identity\n\n${identity}`);
|
|
1026
|
+
if (essentialStory) parts.push(`## L1 Essential Story\n\n${essentialStory}`);
|
|
1027
|
+
return parts.join("\n\n");
|
|
1028
|
+
}
|
|
1029
|
+
|
|
556
1030
|
//#endregion
|
|
557
1031
|
//#region src/hooks/ts/lib/pai-paths.ts
|
|
558
1032
|
/**
|
|
@@ -2228,6 +2702,82 @@ async function extractAndStoreTriples$1(backend, params) {
|
|
|
2228
2702
|
return stats;
|
|
2229
2703
|
}
|
|
2230
2704
|
|
|
2705
|
+
//#endregion
|
|
2706
|
+
//#region src/daemon/daemon/state.ts
|
|
2707
|
+
/** RegistryBackend-based access (projects, sessions, tags, aliases, links, compaction_log). */
|
|
2708
|
+
let registryBackend;
|
|
2709
|
+
let storageBackend;
|
|
2710
|
+
let daemonConfig;
|
|
2711
|
+
let startTime = Date.now();
|
|
2712
|
+
/** True while a project index pass is running. */
|
|
2713
|
+
let indexInProgress = false;
|
|
2714
|
+
let lastIndexTime = 0;
|
|
2715
|
+
let indexSchedulerTimer = null;
|
|
2716
|
+
/** True while an embedding pass is running. */
|
|
2717
|
+
let embedInProgress = false;
|
|
2718
|
+
let lastEmbedTime = 0;
|
|
2719
|
+
let embedSchedulerTimer = null;
|
|
2720
|
+
/** True while a vault index pass is running. */
|
|
2721
|
+
let vaultIndexInProgress = false;
|
|
2722
|
+
let lastVaultIndexTime = 0;
|
|
2723
|
+
/** Interval timer of the worker prompt-cache keepalive (null = not armed). */
|
|
2724
|
+
let cacheKeepaliveTimer = null;
|
|
2725
|
+
/** Mutable notification config — loaded from disk at startup, patchable at runtime. */
|
|
2726
|
+
let notificationConfig;
|
|
2727
|
+
/**
|
|
2728
|
+
* Set to true when a SIGTERM/SIGINT is received so that long-running loops
|
|
2729
|
+
* (embed, index) can detect the signal and exit their inner loops before the
|
|
2730
|
+
* pool/backend is closed.
|
|
2731
|
+
*/
|
|
2732
|
+
let shutdownRequested = false;
|
|
2733
|
+
function setRegistryBackend(b) {
|
|
2734
|
+
registryBackend = b;
|
|
2735
|
+
}
|
|
2736
|
+
function setStorageBackend(b) {
|
|
2737
|
+
storageBackend = b;
|
|
2738
|
+
}
|
|
2739
|
+
function setDaemonConfig(c) {
|
|
2740
|
+
daemonConfig = c;
|
|
2741
|
+
}
|
|
2742
|
+
function setStartTime(t) {
|
|
2743
|
+
startTime = t;
|
|
2744
|
+
}
|
|
2745
|
+
function setNotificationConfig(c) {
|
|
2746
|
+
notificationConfig = c;
|
|
2747
|
+
}
|
|
2748
|
+
function setShutdownRequested(v) {
|
|
2749
|
+
shutdownRequested = v;
|
|
2750
|
+
}
|
|
2751
|
+
function setIndexInProgress(v) {
|
|
2752
|
+
indexInProgress = v;
|
|
2753
|
+
}
|
|
2754
|
+
function setLastIndexTime(v) {
|
|
2755
|
+
lastIndexTime = v;
|
|
2756
|
+
}
|
|
2757
|
+
function setIndexSchedulerTimer(v) {
|
|
2758
|
+
indexSchedulerTimer = v;
|
|
2759
|
+
}
|
|
2760
|
+
function setEmbedInProgress(v) {
|
|
2761
|
+
embedInProgress = v;
|
|
2762
|
+
}
|
|
2763
|
+
function setLastEmbedTime(v) {
|
|
2764
|
+
lastEmbedTime = v;
|
|
2765
|
+
}
|
|
2766
|
+
function setEmbedSchedulerTimer(v) {
|
|
2767
|
+
embedSchedulerTimer = v;
|
|
2768
|
+
}
|
|
2769
|
+
function setVaultIndexInProgress(v) {
|
|
2770
|
+
vaultIndexInProgress = v;
|
|
2771
|
+
}
|
|
2772
|
+
function setLastVaultIndexTime(v) {
|
|
2773
|
+
lastVaultIndexTime = v;
|
|
2774
|
+
}
|
|
2775
|
+
function setCacheKeepaliveTimer(v) {
|
|
2776
|
+
cacheKeepaliveTimer = v;
|
|
2777
|
+
}
|
|
2778
|
+
/** Failing passes by name ("index" | "embed" | "vault"); absent = healthy. */
|
|
2779
|
+
const passFailures = /* @__PURE__ */ new Map();
|
|
2780
|
+
|
|
2231
2781
|
//#endregion
|
|
2232
2782
|
//#region src/daemon/session-summary-worker.ts
|
|
2233
2783
|
/**
|
|
@@ -3171,6 +3721,664 @@ function cleanup() {
|
|
|
3171
3721
|
saveIfDirty();
|
|
3172
3722
|
}
|
|
3173
3723
|
|
|
3724
|
+
//#endregion
|
|
3725
|
+
//#region src/daemon/daemon-pid.ts
|
|
3726
|
+
/**
|
|
3727
|
+
* daemon-pid.ts — the one way PAI finds the running daemon's PID.
|
|
3728
|
+
*
|
|
3729
|
+
* Never by command-line pattern: `pgrep -f` matches any process whose argv
|
|
3730
|
+
* mentions the text (a worker whose spec quotes it, an editor), and the caller
|
|
3731
|
+
* then signals the wrong process. The service manager knows the PID; when none
|
|
3732
|
+
* is in use (`pai daemon serve` in a container) the daemon's own pidfile does.
|
|
3733
|
+
*/
|
|
3734
|
+
const LAUNCHD_LABEL = "com.pai.pai-daemon";
|
|
3735
|
+
const SYSTEMD_UNIT = "pai-daemon.service";
|
|
3736
|
+
/** `pid = N` from `launchctl print gui/<uid>/<label>`; undefined when not running. */
|
|
3737
|
+
function parseLaunchctlPid(out) {
|
|
3738
|
+
const m = /^\s*pid = (\d+)\s*$/m.exec(out);
|
|
3739
|
+
return m ? Number(m[1]) : void 0;
|
|
3740
|
+
}
|
|
3741
|
+
/** `MainPID=N` from `systemctl --user show -p MainPID`; 0 means not running. */
|
|
3742
|
+
function parseSystemdMainPid(out) {
|
|
3743
|
+
const n = Number(/^MainPID=(\d+)\s*$/m.exec(out)?.[1]);
|
|
3744
|
+
return n > 0 ? n : void 0;
|
|
3745
|
+
}
|
|
3746
|
+
const run = (cmd, args) => {
|
|
3747
|
+
const r = spawnSync(cmd, args, { encoding: "utf8" });
|
|
3748
|
+
return {
|
|
3749
|
+
status: r.status,
|
|
3750
|
+
stdout: r.stdout ?? ""
|
|
3751
|
+
};
|
|
3752
|
+
};
|
|
3753
|
+
/** Start time of a process (epoch ms) or undefined when it does not exist. */
|
|
3754
|
+
function processStart(pid, sh) {
|
|
3755
|
+
const r = sh("ps", [
|
|
3756
|
+
"-o",
|
|
3757
|
+
"lstart=",
|
|
3758
|
+
"-p",
|
|
3759
|
+
String(pid)
|
|
3760
|
+
]);
|
|
3761
|
+
if (r.status !== 0 || !r.stdout.trim()) return void 0;
|
|
3762
|
+
const t = Date.parse(r.stdout.trim());
|
|
3763
|
+
return Number.isNaN(t) ? void 0 : t;
|
|
3764
|
+
}
|
|
3765
|
+
/**
|
|
3766
|
+
* PID from the pidfile, only if that process is alive and started no later
|
|
3767
|
+
* than the pidfile was written (ps start times have 1 s resolution). A process
|
|
3768
|
+
* that started after the file is a reused PID and is not the daemon.
|
|
3769
|
+
*/
|
|
3770
|
+
function pidfilePid(path = daemonPidPath(), sh = run) {
|
|
3771
|
+
try {
|
|
3772
|
+
const pid = parseInt(readFileSync(path, "utf8").trim(), 10);
|
|
3773
|
+
if (!(pid > 0)) return void 0;
|
|
3774
|
+
const started = processStart(pid, sh);
|
|
3775
|
+
if (started === void 0 || started > statSync(path).mtimeMs + 2e3) return void 0;
|
|
3776
|
+
return pid;
|
|
3777
|
+
} catch {
|
|
3778
|
+
return;
|
|
3779
|
+
}
|
|
3780
|
+
}
|
|
3781
|
+
/** The running daemon's PID: service manager first, pidfile second, else undefined. */
|
|
3782
|
+
function resolveDaemonPid(plat = process.platform, sh = run, path = daemonPidPath()) {
|
|
3783
|
+
if (plat === "darwin") {
|
|
3784
|
+
const r = sh("launchctl", ["print", `gui/${process.getuid?.() ?? userInfo().uid}/${LAUNCHD_LABEL}`]);
|
|
3785
|
+
const pid = r.status === 0 ? parseLaunchctlPid(r.stdout) : void 0;
|
|
3786
|
+
if (pid) return pid;
|
|
3787
|
+
} else if (plat === "linux") {
|
|
3788
|
+
const r = sh("systemctl", [
|
|
3789
|
+
"--user",
|
|
3790
|
+
"show",
|
|
3791
|
+
"-p",
|
|
3792
|
+
"MainPID",
|
|
3793
|
+
SYSTEMD_UNIT
|
|
3794
|
+
]);
|
|
3795
|
+
const pid = r.status === 0 ? parseSystemdMainPid(r.stdout) : void 0;
|
|
3796
|
+
if (pid) return pid;
|
|
3797
|
+
}
|
|
3798
|
+
return pidfilePid(path, sh);
|
|
3799
|
+
}
|
|
3800
|
+
function writeDaemonPidfile(path = daemonPidPath()) {
|
|
3801
|
+
try {
|
|
3802
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
3803
|
+
writeFileSync(path, `${process.pid}\n`, "utf8");
|
|
3804
|
+
} catch {}
|
|
3805
|
+
}
|
|
3806
|
+
/** Remove the pidfile only if it is ours (a newer daemon may own it). */
|
|
3807
|
+
function removeDaemonPidfile(path = daemonPidPath()) {
|
|
3808
|
+
try {
|
|
3809
|
+
if (parseInt(readFileSync(path, "utf8").trim(), 10) === process.pid) unlinkSync(path);
|
|
3810
|
+
} catch {}
|
|
3811
|
+
}
|
|
3812
|
+
|
|
3813
|
+
//#endregion
|
|
3814
|
+
//#region src/audit/session-usage.ts
|
|
3815
|
+
/**
|
|
3816
|
+
* session-usage.ts — parse the usage numbers out of a Claude Code session
|
|
3817
|
+
* (or subagent, or pai-worker event-mirror) JSONL transcript.
|
|
3818
|
+
*
|
|
3819
|
+
* All three log shapes share the same assistant-message envelope
|
|
3820
|
+
* ({ type: "assistant", message: { id, model, usage } }), so one parser
|
|
3821
|
+
* covers `pai audit tokens session` and the per-log readings feeding
|
|
3822
|
+
* `pai audit tokens spawn`.
|
|
3823
|
+
*
|
|
3824
|
+
* Streaming writes one JSONL line per content block of the same logical
|
|
3825
|
+
* turn, repeating message.id and usage each time — summing every line would
|
|
3826
|
+
* multiply usage by the block count. Keeping only the first line seen per
|
|
3827
|
+
* message.id is what the reference script (and this parser) count instead.
|
|
3828
|
+
*/
|
|
3829
|
+
const USAGE_KEYS = [
|
|
3830
|
+
"cache_read_input_tokens",
|
|
3831
|
+
"cache_creation_input_tokens",
|
|
3832
|
+
"input_tokens",
|
|
3833
|
+
"output_tokens"
|
|
3834
|
+
];
|
|
3835
|
+
function emptyTotals() {
|
|
3836
|
+
return {
|
|
3837
|
+
cache_read_input_tokens: 0,
|
|
3838
|
+
cache_creation_input_tokens: 0,
|
|
3839
|
+
input_tokens: 0,
|
|
3840
|
+
output_tokens: 0
|
|
3841
|
+
};
|
|
3842
|
+
}
|
|
3843
|
+
/**
|
|
3844
|
+
* `type:"system"` `subtype:"compact_boundary"` lines mark a compaction.
|
|
3845
|
+
* turnIndex is the count of assistant turns already folded when it fired,
|
|
3846
|
+
* so it lines up with the turn numbering the text/JSON report prints.
|
|
3847
|
+
*/
|
|
3848
|
+
function isCompactBoundary(line) {
|
|
3849
|
+
return line.type === "system" && line.subtype === "compact_boundary";
|
|
3850
|
+
}
|
|
3851
|
+
function parseCompactionEvent(line, turnIndex) {
|
|
3852
|
+
const meta = line.compactMetadata;
|
|
3853
|
+
if (!meta || typeof meta.preTokens !== "number") return null;
|
|
3854
|
+
return {
|
|
3855
|
+
trigger: meta.trigger ?? "unknown",
|
|
3856
|
+
preTokens: meta.preTokens,
|
|
3857
|
+
turnIndex
|
|
3858
|
+
};
|
|
3859
|
+
}
|
|
3860
|
+
/**
|
|
3861
|
+
* `type:"system"` `subtype:"model_refusal_fallback"` lines mark an
|
|
3862
|
+
* automatic model switch fired by an API safety refusal (e.g. "cyber"
|
|
3863
|
+
* category), not a user or config choice — worth flagging separately since
|
|
3864
|
+
* it can rebuild the whole prompt cache mid-session.
|
|
3865
|
+
*/
|
|
3866
|
+
function isModelFallback(line) {
|
|
3867
|
+
return line.type === "system" && line.subtype === "model_refusal_fallback";
|
|
3868
|
+
}
|
|
3869
|
+
function parseFallbackEvent(line, turnIndex) {
|
|
3870
|
+
return {
|
|
3871
|
+
turnIndex,
|
|
3872
|
+
from: line.originalModel ?? "unknown",
|
|
3873
|
+
to: line.fallbackModel ?? "unknown",
|
|
3874
|
+
category: line.apiRefusalCategory ?? "unknown",
|
|
3875
|
+
scope: line.scope ?? "unknown"
|
|
3876
|
+
};
|
|
3877
|
+
}
|
|
3878
|
+
/**
|
|
3879
|
+
* Fold one already-parsed JSONL line into a report being accumulated.
|
|
3880
|
+
* Exported separately so both the streaming file reader below and tests
|
|
3881
|
+
* (which build fixtures as arrays of objects, not files) share one path.
|
|
3882
|
+
* Returns the turn's context value when a new turn was counted, else null
|
|
3883
|
+
* (non-assistant line, no usage, or a duplicate message.id already seen).
|
|
3884
|
+
*/
|
|
3885
|
+
function foldAssistantLine(report, seenIds, line, threshold) {
|
|
3886
|
+
if (line.type !== "assistant") return null;
|
|
3887
|
+
const message = line.message;
|
|
3888
|
+
const usage = message?.usage;
|
|
3889
|
+
if (!usage) return null;
|
|
3890
|
+
const id = message?.id ?? line.uuid;
|
|
3891
|
+
if (id) {
|
|
3892
|
+
if (seenIds.has(id)) return null;
|
|
3893
|
+
seenIds.add(id);
|
|
3894
|
+
}
|
|
3895
|
+
report.turns++;
|
|
3896
|
+
for (const key of USAGE_KEYS) {
|
|
3897
|
+
const v = usage[key];
|
|
3898
|
+
if (typeof v === "number") report.totals[key] += v;
|
|
3899
|
+
}
|
|
3900
|
+
const context = (Number(usage.cache_read_input_tokens) || 0) + (Number(usage.cache_creation_input_tokens) || 0) + (Number(usage.input_tokens) || 0);
|
|
3901
|
+
if (report.firstTurnContext === null) {
|
|
3902
|
+
report.firstTurnContext = context;
|
|
3903
|
+
report.firstTurnAt = line.timestamp ?? null;
|
|
3904
|
+
}
|
|
3905
|
+
const atMs = line.timestamp ? Date.parse(line.timestamp) : NaN;
|
|
3906
|
+
if (!Number.isNaN(atMs)) {
|
|
3907
|
+
if (report.lastTurnAtMs !== null && atMs - report.lastTurnAtMs > 3600 * 1e3) report.idleGapsOver60min++;
|
|
3908
|
+
report.lastTurnAtMs = atMs;
|
|
3909
|
+
}
|
|
3910
|
+
report.lastTurnContext = context;
|
|
3911
|
+
report.maxContext = report.maxContext === null ? context : Math.max(report.maxContext, context);
|
|
3912
|
+
if (context > threshold) report.turnsAboveThreshold++;
|
|
3913
|
+
if ((Number(usage.cache_creation_input_tokens) || 0) > 2e4) report.cacheRebuildTurns++;
|
|
3914
|
+
const model = message?.model ?? "unknown";
|
|
3915
|
+
report.models[model] = (report.models[model] ?? 0) + 1;
|
|
3916
|
+
if (report.lastModel !== null && report.lastModel !== model) report.modelSwitches.push({
|
|
3917
|
+
turnIndex: report.turns,
|
|
3918
|
+
from: report.lastModel,
|
|
3919
|
+
to: model,
|
|
3920
|
+
cacheRead: Number(usage.cache_read_input_tokens) || 0,
|
|
3921
|
+
cacheCreation: Number(usage.cache_creation_input_tokens) || 0
|
|
3922
|
+
});
|
|
3923
|
+
report.lastModel = model;
|
|
3924
|
+
const split = usage.cache_creation;
|
|
3925
|
+
if (split) {
|
|
3926
|
+
report.cacheCreationSplit.ephemeral5m += Number(split.ephemeral_5m_input_tokens) || 0;
|
|
3927
|
+
report.cacheCreationSplit.ephemeral1h += Number(split.ephemeral_1h_input_tokens) || 0;
|
|
3928
|
+
}
|
|
3929
|
+
return context;
|
|
3930
|
+
}
|
|
3931
|
+
/**
|
|
3932
|
+
* True for a real human-authored `type:"user"` prompt line: content is a
|
|
3933
|
+
* plain string, or a content array with no `tool_result` block. Claude Code
|
|
3934
|
+
* encodes tool results as `type:"user"` messages whose content array is
|
|
3935
|
+
* entirely (or partly) tool_result blocks — those must not count as prompts.
|
|
3936
|
+
*/
|
|
3937
|
+
function isRealUserPrompt(line) {
|
|
3938
|
+
if (line.type !== "user") return false;
|
|
3939
|
+
if (line.isMeta) return false;
|
|
3940
|
+
if (line.origin?.kind && line.origin.kind !== "human") return false;
|
|
3941
|
+
const content = line.message?.content;
|
|
3942
|
+
if (typeof content === "string") return true;
|
|
3943
|
+
if (Array.isArray(content)) return !content.some((block) => block?.type === "tool_result");
|
|
3944
|
+
return false;
|
|
3945
|
+
}
|
|
3946
|
+
/** Plain text of a real user prompt: the string content, or its first text block. */
|
|
3947
|
+
function extractUserPromptText(line) {
|
|
3948
|
+
const content = line.message?.content;
|
|
3949
|
+
if (typeof content === "string") return content;
|
|
3950
|
+
if (Array.isArray(content)) return content.find((b) => b?.type === "text")?.text ?? "";
|
|
3951
|
+
return "";
|
|
3952
|
+
}
|
|
3953
|
+
/**
|
|
3954
|
+
* Parse a session/subagent/worker-event JSONL file into a usage report.
|
|
3955
|
+
* `keepaliveWord`, when given, counts real user prompts whose text exactly
|
|
3956
|
+
* matches it (trimmed) — the sessions.cacheKeepalive beat prompt — into
|
|
3957
|
+
* `keepaliveBeats`; omitted, `keepaliveBeats` stays null.
|
|
3958
|
+
*/
|
|
3959
|
+
async function parseSessionUsage(path, threshold = 2e5, keepaliveWord) {
|
|
3960
|
+
const report = {
|
|
3961
|
+
path,
|
|
3962
|
+
sizeBytes: existsSync(path) ? readFileSync(path).byteLength : 0,
|
|
3963
|
+
turns: 0,
|
|
3964
|
+
totals: emptyTotals(),
|
|
3965
|
+
models: {},
|
|
3966
|
+
firstTurnContext: null,
|
|
3967
|
+
lastTurnContext: null,
|
|
3968
|
+
cacheCreationSplit: {
|
|
3969
|
+
ephemeral5m: 0,
|
|
3970
|
+
ephemeral1h: 0
|
|
3971
|
+
},
|
|
3972
|
+
avgContext: null,
|
|
3973
|
+
maxContext: null,
|
|
3974
|
+
turnsAboveThreshold: 0,
|
|
3975
|
+
cacheRebuildTurns: 0,
|
|
3976
|
+
userPrompts: 0,
|
|
3977
|
+
promptExposure: 0,
|
|
3978
|
+
compactions: [],
|
|
3979
|
+
firstTurnAt: null,
|
|
3980
|
+
lastModel: null,
|
|
3981
|
+
modelSwitches: [],
|
|
3982
|
+
fallbacks: [],
|
|
3983
|
+
lastTurnAtMs: null,
|
|
3984
|
+
idleGapsOver60min: 0,
|
|
3985
|
+
keepaliveBeats: keepaliveWord ? 0 : null
|
|
3986
|
+
};
|
|
3987
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
3988
|
+
let contextSum = 0;
|
|
3989
|
+
const rl = createInterface({
|
|
3990
|
+
input: createReadStream(path, "utf8"),
|
|
3991
|
+
crlfDelay: Infinity
|
|
3992
|
+
});
|
|
3993
|
+
for await (const raw of rl) {
|
|
3994
|
+
if (!raw.trim()) continue;
|
|
3995
|
+
let obj;
|
|
3996
|
+
try {
|
|
3997
|
+
obj = JSON.parse(raw);
|
|
3998
|
+
} catch {
|
|
3999
|
+
continue;
|
|
4000
|
+
}
|
|
4001
|
+
if (obj.type === "user") {
|
|
4002
|
+
if (isRealUserPrompt(obj)) {
|
|
4003
|
+
report.userPrompts++;
|
|
4004
|
+
if (keepaliveWord && extractUserPromptText(obj).trim() === keepaliveWord) report.keepaliveBeats = (report.keepaliveBeats ?? 0) + 1;
|
|
4005
|
+
}
|
|
4006
|
+
continue;
|
|
4007
|
+
}
|
|
4008
|
+
if (isCompactBoundary(obj)) {
|
|
4009
|
+
const event = parseCompactionEvent(obj, report.turns);
|
|
4010
|
+
if (event) report.compactions.push(event);
|
|
4011
|
+
continue;
|
|
4012
|
+
}
|
|
4013
|
+
if (isModelFallback(obj)) {
|
|
4014
|
+
report.fallbacks.push(parseFallbackEvent(obj, report.turns));
|
|
4015
|
+
continue;
|
|
4016
|
+
}
|
|
4017
|
+
const context = foldAssistantLine(report, seenIds, obj, threshold);
|
|
4018
|
+
if (context !== null) {
|
|
4019
|
+
contextSum += context;
|
|
4020
|
+
report.promptExposure += report.userPrompts;
|
|
4021
|
+
}
|
|
4022
|
+
}
|
|
4023
|
+
report.avgContext = report.turns > 0 ? Math.round(contextSum / report.turns) : null;
|
|
4024
|
+
return report;
|
|
4025
|
+
}
|
|
4026
|
+
function totalUsageTokens(totals) {
|
|
4027
|
+
return totals.cache_read_input_tokens + totals.cache_creation_input_tokens + totals.input_tokens + totals.output_tokens;
|
|
4028
|
+
}
|
|
4029
|
+
|
|
4030
|
+
//#endregion
|
|
4031
|
+
//#region src/daemon/session-keepalive.ts
|
|
4032
|
+
/**
|
|
4033
|
+
* session-keepalive.ts — idle-triggered prompt-cache keepalive beat for live
|
|
4034
|
+
* interactive Claude Code sessions (`sessions.cacheKeepalive`, see config.ts
|
|
4035
|
+
* and docs/cache-keepalive.md, "Interactive sessions").
|
|
4036
|
+
*
|
|
4037
|
+
* One beat = one trivial prompt typed into a session through AIBroker's
|
|
4038
|
+
* send_to_session (src/cli/lib/aibroker-client.ts), sent with `noReply: true`
|
|
4039
|
+
* so the target sees only the bare word typed into its input line and
|
|
4040
|
+
* nothing is queued into its mailbox as a peer message demanding a reply.
|
|
4041
|
+
* A cache READ refreshes the provider's ephemeral prompt-cache TTL at
|
|
4042
|
+
* roughly 0.1x the cost of the 2x rewrite a cold cache forces on the next
|
|
4043
|
+
* real prompt.
|
|
4044
|
+
*
|
|
4045
|
+
* Distinct from workers/keepalive.ts, which beats a *worker provider's*
|
|
4046
|
+
* cache on a fixed timer regardless of activity: this only beats a session
|
|
4047
|
+
* that has actually been idle long enough to be at risk, and only inside a
|
|
4048
|
+
* configured working-hours window, so it never fires while the user is
|
|
4049
|
+
* present at the keyboard or overnight when nobody will read the reply
|
|
4050
|
+
* before the cache would have expired anyway.
|
|
4051
|
+
*/
|
|
4052
|
+
function sessionKeepaliveStatePath() {
|
|
4053
|
+
return paiHomePath("session-keepalive.json");
|
|
4054
|
+
}
|
|
4055
|
+
function emptyEntry() {
|
|
4056
|
+
return {
|
|
4057
|
+
beats: 0,
|
|
4058
|
+
lastRealPromptKey: null,
|
|
4059
|
+
lastBeatAt: null
|
|
4060
|
+
};
|
|
4061
|
+
}
|
|
4062
|
+
function loadSessionKeepaliveState(path = sessionKeepaliveStatePath()) {
|
|
4063
|
+
if (!existsSync(path)) return {};
|
|
4064
|
+
try {
|
|
4065
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
4066
|
+
} catch {
|
|
4067
|
+
return {};
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
4070
|
+
function saveSessionKeepaliveState(state, path = sessionKeepaliveStatePath()) {
|
|
4071
|
+
writeJsonAtomic(path, state, {
|
|
4072
|
+
backup: false,
|
|
4073
|
+
label: path
|
|
4074
|
+
});
|
|
4075
|
+
}
|
|
4076
|
+
/** Parse "HH:MM-HH:MM" into minutes-since-midnight. Throws on a malformed
|
|
4077
|
+
* spec — an explicit config error beats a window that is silently always
|
|
4078
|
+
* on or always off. */
|
|
4079
|
+
function parseActiveHours(spec) {
|
|
4080
|
+
const m = spec.match(/^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/);
|
|
4081
|
+
if (!m) throw new Error(`sessions.cacheKeepalive.activeHours: invalid "${spec}" (want "HH:MM-HH:MM")`);
|
|
4082
|
+
return {
|
|
4083
|
+
startMin: Number(m[1]) * 60 + Number(m[2]),
|
|
4084
|
+
endMin: Number(m[3]) * 60 + Number(m[4])
|
|
4085
|
+
};
|
|
4086
|
+
}
|
|
4087
|
+
/**
|
|
4088
|
+
* Whether `now` (local time) sits inside the window. A window that wraps
|
|
4089
|
+
* midnight (e.g. "22:00-06:00") is honoured by inverting the test instead of
|
|
4090
|
+
* requiring startMin < endMin.
|
|
4091
|
+
*/
|
|
4092
|
+
function isWithinActiveHours(now, spec) {
|
|
4093
|
+
const { startMin, endMin } = parseActiveHours(spec);
|
|
4094
|
+
const nowMin = now.getHours() * 60 + now.getMinutes();
|
|
4095
|
+
if (startMin <= endMin) return nowMin >= startMin && nowMin < endMin;
|
|
4096
|
+
return nowMin >= startMin || nowMin < endMin;
|
|
4097
|
+
}
|
|
4098
|
+
/**
|
|
4099
|
+
* Full path to a live session's transcript under ~/.claude/projects, or null
|
|
4100
|
+
* when none is found — the case for a session too new to have written a file
|
|
4101
|
+
* yet. A worker running in a worktree DOES write a transcript here (Claude
|
|
4102
|
+
* Code encodes its worktree cwd as the project dir name), so workers are not
|
|
4103
|
+
* excluded "for free" — see isWorkerSession.
|
|
4104
|
+
*/
|
|
4105
|
+
function findSessionTranscript(sessionId, projectsDir = join(homedir(), ".claude", "projects")) {
|
|
4106
|
+
let projectDirs;
|
|
4107
|
+
try {
|
|
4108
|
+
projectDirs = readdirSync(projectsDir);
|
|
4109
|
+
} catch {
|
|
4110
|
+
return null;
|
|
4111
|
+
}
|
|
4112
|
+
for (const projectDir of projectDirs) {
|
|
4113
|
+
const candidate = join(projectsDir, projectDir, `${sessionId}.jsonl`);
|
|
4114
|
+
if (existsSync(candidate)) return candidate;
|
|
4115
|
+
}
|
|
4116
|
+
return null;
|
|
4117
|
+
}
|
|
4118
|
+
/**
|
|
4119
|
+
* AIBroker's `fetchLiveSessions()` identifies a session by its iTerm2 pane id
|
|
4120
|
+
* (e.g. an AIBroker/iTerm UUID), not the Claude session id `<uuid>.jsonl`
|
|
4121
|
+
* transcripts are named after — the two are unrelated identifiers. The
|
|
4122
|
+
* status line bridges them on every refresh via `claude-session-map.json`
|
|
4123
|
+
* (see `recordSessionMapEntry` in ../workers/scope.ts): this picks, among the
|
|
4124
|
+
* map entries whose `term` pane UUID matches, the most recently written one.
|
|
4125
|
+
* Returns null when the pane has no (fresh enough) mapped Claude session.
|
|
4126
|
+
*/
|
|
4127
|
+
function resolveClaudeSessionIdFromMap(paneId, logDir) {
|
|
4128
|
+
const path = sessionMapPath(logDir);
|
|
4129
|
+
if (!paneId || !existsSync(path)) return null;
|
|
4130
|
+
let map;
|
|
4131
|
+
try {
|
|
4132
|
+
map = JSON.parse(readFileSync(path, "utf8"));
|
|
4133
|
+
} catch {
|
|
4134
|
+
return null;
|
|
4135
|
+
}
|
|
4136
|
+
let best = null;
|
|
4137
|
+
for (const entry of Object.values(map)) {
|
|
4138
|
+
if (!entry.term || itermUuid(entry.term) !== paneId) continue;
|
|
4139
|
+
if (!best || entry.ts > best.ts) best = entry;
|
|
4140
|
+
}
|
|
4141
|
+
return best?.session ?? null;
|
|
4142
|
+
}
|
|
4143
|
+
/** Claude Code's project-dir encoding of a cwd: every "/" becomes "-". */
|
|
4144
|
+
function encodeProjectDirName(cwd) {
|
|
4145
|
+
return cwd.replace(/\//g, "-");
|
|
4146
|
+
}
|
|
4147
|
+
/**
|
|
4148
|
+
* Is this live "claude"-kind session actually a worker pane rather than an
|
|
4149
|
+
* interactive one? `claude -p` workers running in a worktree write their
|
|
4150
|
+
* transcript under ~/.claude/projects too (encoded cwd = the worktree dir),
|
|
4151
|
+
* so a missing transcript is NOT how workers get excluded — this predicate
|
|
4152
|
+
* is. Either signal is enough:
|
|
4153
|
+
* (a) the transcript's project-dir name is the encoded form of a path
|
|
4154
|
+
* under <logDir>/worktrees (every worker worktree lives there), or
|
|
4155
|
+
* (b) the broker's session name/paiName names a path under the workers
|
|
4156
|
+
* log dir or one of its worktrees (best-effort: AIBroker does not
|
|
4157
|
+
* guarantee this, but honors it when present).
|
|
4158
|
+
*/
|
|
4159
|
+
function isWorkerSession(meta, transcriptPath, logDir) {
|
|
4160
|
+
const worktreesPrefix = encodeProjectDirName(worktreesDir(logDir));
|
|
4161
|
+
if (transcriptPath) {
|
|
4162
|
+
if ((transcriptPath.split("/").slice(0, -1).pop() ?? "").startsWith(worktreesPrefix)) return true;
|
|
4163
|
+
}
|
|
4164
|
+
const rawPrefix = worktreesDir(logDir);
|
|
4165
|
+
for (const field of [meta.name, meta.paiName]) if (field && (field.includes(rawPrefix) || field.includes(logDir))) return true;
|
|
4166
|
+
return false;
|
|
4167
|
+
}
|
|
4168
|
+
function isMidTurn(line) {
|
|
4169
|
+
if (!line) return false;
|
|
4170
|
+
if (line.type === "user" && isRealUserPrompt(line)) return true;
|
|
4171
|
+
if (line.type === "assistant") {
|
|
4172
|
+
const stopReason = line.message?.stop_reason;
|
|
4173
|
+
if (stopReason === void 0 || stopReason === null) return true;
|
|
4174
|
+
return stopReason !== "end_turn" && stopReason !== "stop_sequence";
|
|
4175
|
+
}
|
|
4176
|
+
return false;
|
|
4177
|
+
}
|
|
4178
|
+
function readTranscriptSnapshot(path) {
|
|
4179
|
+
let lines;
|
|
4180
|
+
try {
|
|
4181
|
+
lines = readFileSync(path, "utf8").split("\n");
|
|
4182
|
+
} catch {
|
|
4183
|
+
return {
|
|
4184
|
+
context: null,
|
|
4185
|
+
midTurn: false,
|
|
4186
|
+
lastRealPrompt: null
|
|
4187
|
+
};
|
|
4188
|
+
}
|
|
4189
|
+
let lastLine = null;
|
|
4190
|
+
let context = null;
|
|
4191
|
+
let lastRealPrompt = null;
|
|
4192
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
4193
|
+
const raw = lines[i].trim();
|
|
4194
|
+
if (!raw) continue;
|
|
4195
|
+
let obj;
|
|
4196
|
+
try {
|
|
4197
|
+
obj = JSON.parse(raw);
|
|
4198
|
+
} catch {
|
|
4199
|
+
continue;
|
|
4200
|
+
}
|
|
4201
|
+
if (lastLine === null) lastLine = obj;
|
|
4202
|
+
if (context === null && obj.type === "assistant" && obj.message?.usage) {
|
|
4203
|
+
const usage = obj.message.usage;
|
|
4204
|
+
context = (Number(usage.cache_read_input_tokens) || 0) + (Number(usage.cache_creation_input_tokens) || 0) + (Number(usage.input_tokens) || 0);
|
|
4205
|
+
}
|
|
4206
|
+
if (lastRealPrompt === null && isRealUserPrompt(obj)) lastRealPrompt = {
|
|
4207
|
+
key: obj.uuid ?? obj.timestamp ?? String(i),
|
|
4208
|
+
text: extractUserPromptText(obj).trim()
|
|
4209
|
+
};
|
|
4210
|
+
if (context !== null && lastRealPrompt !== null) break;
|
|
4211
|
+
}
|
|
4212
|
+
return {
|
|
4213
|
+
context,
|
|
4214
|
+
midTurn: isMidTurn(lastLine),
|
|
4215
|
+
lastRealPrompt
|
|
4216
|
+
};
|
|
4217
|
+
}
|
|
4218
|
+
/** Ledger event tag; `pai daemon keepalive` and any log-tailer key off this. */
|
|
4219
|
+
const SESSION_KEEPALIVE_EVENT = "SESSION-KEEPALIVE";
|
|
4220
|
+
function sessionKeepaliveLedgerPath() {
|
|
4221
|
+
const { workers } = readWorkersSection();
|
|
4222
|
+
return join(workersLogDir(workers), "ledger.log");
|
|
4223
|
+
}
|
|
4224
|
+
/** The counts/last lines `pai daemon keepalive` prints, scoped to this event. */
|
|
4225
|
+
function sessionKeepaliveLedgerSummary(path = sessionKeepaliveLedgerPath(), lastN = 10) {
|
|
4226
|
+
if (!existsSync(path)) return {
|
|
4227
|
+
sent: 0,
|
|
4228
|
+
skipped: 0,
|
|
4229
|
+
lastLines: []
|
|
4230
|
+
};
|
|
4231
|
+
const lines = readFileSync(path, "utf8").split("\n").filter((l) => l.includes(SESSION_KEEPALIVE_EVENT));
|
|
4232
|
+
const sent = lines.filter((l) => / result=sent(\s|$)/.test(l)).length;
|
|
4233
|
+
return {
|
|
4234
|
+
sent,
|
|
4235
|
+
skipped: lines.length - sent,
|
|
4236
|
+
lastLines: lines.slice(-lastN)
|
|
4237
|
+
};
|
|
4238
|
+
}
|
|
4239
|
+
function defaultDeps() {
|
|
4240
|
+
const { workers } = readWorkersSection();
|
|
4241
|
+
const logDir = workersLogDir(workers);
|
|
4242
|
+
return {
|
|
4243
|
+
now: () => /* @__PURE__ */ new Date(),
|
|
4244
|
+
fetchLiveSessions,
|
|
4245
|
+
resolveClaudeSessionId: (paneId) => resolveClaudeSessionIdFromMap(paneId, logDir),
|
|
4246
|
+
findTranscript: (id) => findSessionTranscript(id),
|
|
4247
|
+
mtimeMs: (p) => {
|
|
4248
|
+
try {
|
|
4249
|
+
return statSync(p).mtimeMs;
|
|
4250
|
+
} catch {
|
|
4251
|
+
return null;
|
|
4252
|
+
}
|
|
4253
|
+
},
|
|
4254
|
+
readSnapshot: readTranscriptSnapshot,
|
|
4255
|
+
sendBeat: (id, text) => sendToSession(id, text, void 0, { noReply: true }),
|
|
4256
|
+
loadState: () => loadSessionKeepaliveState(),
|
|
4257
|
+
saveState: (s) => saveSessionKeepaliveState(s),
|
|
4258
|
+
ledger: (kv) => appendLedger(sessionKeepaliveLedgerPath(), SESSION_KEEPALIVE_EVENT, kv)
|
|
4259
|
+
};
|
|
4260
|
+
}
|
|
4261
|
+
/**
|
|
4262
|
+
* One tick over every live interactive session: beat the ones that qualify,
|
|
4263
|
+
* skip (with a reason) the ones that don't. Returns one result per live
|
|
4264
|
+
* "claude"-kind session for tests to assert against; writes the ledger line
|
|
4265
|
+
* and (when any counter changed) the state file as side effects.
|
|
4266
|
+
*/
|
|
4267
|
+
async function runSessionKeepaliveTick(config, overrides = {}) {
|
|
4268
|
+
if (!config.enabled) return [];
|
|
4269
|
+
const d = {
|
|
4270
|
+
...defaultDeps(),
|
|
4271
|
+
...overrides
|
|
4272
|
+
};
|
|
4273
|
+
const sessions = await d.fetchLiveSessions();
|
|
4274
|
+
const state = d.loadState();
|
|
4275
|
+
const now = d.now();
|
|
4276
|
+
const { workers } = readWorkersSection();
|
|
4277
|
+
const logDir = workersLogDir(workers);
|
|
4278
|
+
const results = [];
|
|
4279
|
+
let anyChanged = false;
|
|
4280
|
+
for (const s of sessions) {
|
|
4281
|
+
if (s.kind !== "claude") continue;
|
|
4282
|
+
const paneId = s.sessionId;
|
|
4283
|
+
const claudeId = d.resolveClaudeSessionId(paneId);
|
|
4284
|
+
if (!claudeId) {
|
|
4285
|
+
d.ledger({
|
|
4286
|
+
session: null,
|
|
4287
|
+
pane: paneId,
|
|
4288
|
+
result: "skipped:unmapped"
|
|
4289
|
+
});
|
|
4290
|
+
results.push({
|
|
4291
|
+
sessionId: paneId,
|
|
4292
|
+
result: "skipped:unmapped"
|
|
4293
|
+
});
|
|
4294
|
+
continue;
|
|
4295
|
+
}
|
|
4296
|
+
const sessionId = claudeId;
|
|
4297
|
+
const entry = { ...state[sessionId] ?? emptyEntry() };
|
|
4298
|
+
let changed = false;
|
|
4299
|
+
const transcript = d.findTranscript(sessionId);
|
|
4300
|
+
if (isWorkerSession(s, transcript, logDir)) {
|
|
4301
|
+
d.ledger({
|
|
4302
|
+
session: sessionId,
|
|
4303
|
+
pane: paneId,
|
|
4304
|
+
result: "skipped:worker"
|
|
4305
|
+
});
|
|
4306
|
+
results.push({
|
|
4307
|
+
sessionId,
|
|
4308
|
+
result: "skipped:worker"
|
|
4309
|
+
});
|
|
4310
|
+
continue;
|
|
4311
|
+
}
|
|
4312
|
+
if (!transcript) {
|
|
4313
|
+
d.ledger({
|
|
4314
|
+
session: sessionId,
|
|
4315
|
+
pane: paneId,
|
|
4316
|
+
result: "skipped:no-transcript"
|
|
4317
|
+
});
|
|
4318
|
+
results.push({
|
|
4319
|
+
sessionId,
|
|
4320
|
+
result: "skipped:no-transcript"
|
|
4321
|
+
});
|
|
4322
|
+
continue;
|
|
4323
|
+
}
|
|
4324
|
+
const snapshot = d.readSnapshot(transcript);
|
|
4325
|
+
if (snapshot.lastRealPrompt && snapshot.lastRealPrompt.key !== entry.lastRealPromptKey) {
|
|
4326
|
+
entry.lastRealPromptKey = snapshot.lastRealPrompt.key;
|
|
4327
|
+
if (snapshot.lastRealPrompt.text !== config.prompt) entry.beats = 0;
|
|
4328
|
+
changed = true;
|
|
4329
|
+
}
|
|
4330
|
+
const mtime = d.mtimeMs(transcript);
|
|
4331
|
+
const idleMin = mtime === null ? null : (now.getTime() - mtime) / 6e4;
|
|
4332
|
+
const skip = mtime === null || idleMin === null ? "no-mtime" : idleMin < config.idleMinutes ? `idle:${idleMin.toFixed(1)}min` : !isWithinActiveHours(now, config.activeHours) ? "hours" : snapshot.context === null || snapshot.context < config.minContextTokens ? "context" : snapshot.midTurn ? "mid-turn" : entry.beats >= config.maxBeats ? "max-beats" : null;
|
|
4333
|
+
const ledgerBase = {
|
|
4334
|
+
session: sessionId,
|
|
4335
|
+
pane: paneId,
|
|
4336
|
+
idle_min: idleMin === null ? null : idleMin.toFixed(1),
|
|
4337
|
+
context: snapshot.context
|
|
4338
|
+
};
|
|
4339
|
+
if (skip) {
|
|
4340
|
+
d.ledger({
|
|
4341
|
+
...ledgerBase,
|
|
4342
|
+
beat: `${entry.beats}/${config.maxBeats}`,
|
|
4343
|
+
result: `skipped:${skip}`
|
|
4344
|
+
});
|
|
4345
|
+
results.push({
|
|
4346
|
+
sessionId,
|
|
4347
|
+
result: `skipped:${skip}`
|
|
4348
|
+
});
|
|
4349
|
+
} else if ((await d.sendBeat(paneId, config.prompt)).ok) {
|
|
4350
|
+
entry.beats += 1;
|
|
4351
|
+
entry.lastBeatAt = now.toISOString();
|
|
4352
|
+
changed = true;
|
|
4353
|
+
d.ledger({
|
|
4354
|
+
...ledgerBase,
|
|
4355
|
+
beat: `${entry.beats}/${config.maxBeats}`,
|
|
4356
|
+
result: "sent"
|
|
4357
|
+
});
|
|
4358
|
+
results.push({
|
|
4359
|
+
sessionId,
|
|
4360
|
+
result: "sent"
|
|
4361
|
+
});
|
|
4362
|
+
} else {
|
|
4363
|
+
d.ledger({
|
|
4364
|
+
...ledgerBase,
|
|
4365
|
+
beat: `${entry.beats}/${config.maxBeats}`,
|
|
4366
|
+
result: "skipped:send-failed"
|
|
4367
|
+
});
|
|
4368
|
+
results.push({
|
|
4369
|
+
sessionId,
|
|
4370
|
+
result: "skipped:send-failed"
|
|
4371
|
+
});
|
|
4372
|
+
}
|
|
4373
|
+
if (changed) {
|
|
4374
|
+
state[sessionId] = entry;
|
|
4375
|
+
anyChanged = true;
|
|
4376
|
+
}
|
|
4377
|
+
}
|
|
4378
|
+
if (anyChanged) d.saveState(state);
|
|
4379
|
+
return results;
|
|
4380
|
+
}
|
|
4381
|
+
|
|
3174
4382
|
//#endregion
|
|
3175
4383
|
//#region src/hooks/ts/lib/context-handover-cache.ts
|
|
3176
4384
|
/**
|
|
@@ -3203,5 +4411,5 @@ function writeContextHandoverCache(cache) {
|
|
|
3203
4411
|
}
|
|
3204
4412
|
|
|
3205
4413
|
//#endregion
|
|
3206
|
-
export {
|
|
3207
|
-
//# sourceMappingURL=context-handover-cache-
|
|
4414
|
+
export { shutdownRequested as $, indexInProgress as A, claudeProjectsDir as At, setEmbedInProgress as B, handleSessionSummary as C, loadScanConfig as Ct, daemonConfig as D, saveScanConfig as Dt, cacheKeepaliveTimer as E, resolveHome as Et, notificationConfig as F, formatDetection as Ft, setLastIndexTime as G, setIndexInProgress as H, passFailures as I, formatDetectionJson as It, setRegistryBackend as J, setLastVaultIndexTime as K, registryBackend as L, lastEmbedTime as M, scanTranscriptFolders as Mt, lastIndexTime as N, transcriptFiles as Nt, embedInProgress as O, upsertProject as Ot, lastVaultIndexTime as P, detectProject as Pt, setVaultIndexInProgress as Q, setCacheKeepaliveTimer as R, getGitContext as S, cmdScan as St, spawnSummarizer as T, performScan as Tt, setIndexSchedulerTimer as U, setEmbedSchedulerTimer as V, setLastEmbedTime as W, setStartTime as X, setShutdownRequested as Y, setStorageBackend as Z, markCompleted as _, buildWakeupContext as _t, sessionKeepaliveLedgerSummary as a, updateTodoContinue as at, contentToText as b, reexecBackground as bt, removeDaemonPidfile as c, findLatestNote as ct, cleanup as d, addWorkToSessionNote as dt, startTime as et, dequeue as f, appendCheckpoint as ft, loadQueue as g, findNotesDir$1 as gt, hasPendingOrProcessingOfType as h, archiveSessionFilesToSessionsDir as ht, runSessionKeepaliveTick as i, sessionIdFromTranscript as it, indexSchedulerTimer as j, findMovedProjects as jt, embedSchedulerTimer as k, upsertSession as kt, resolveDaemonPid as l, findNotesDir as lt, getStats as m, getCurrentNotePath as mt, writeContextHandoverCache as n, vaultIndexInProgress as nt, parseSessionUsage as o, appendCheckpointToNote as ot, enqueue as p, finalizeSessionNote as pt, setNotificationConfig as q, loadSessionKeepaliveState as r, extractAndStoreTriples$1 as rt, totalUsageTokens as s, applyContinue as st, readContextHandoverCache as t, storageBackend as tt, writeDaemonPidfile as u, readBodyFile as ut, markFailed as v, migrateIdentityFile as vt, migrateSummaryCooldowns as w, migrateScanConfig as wt, findLatestJsonl as x, runPassChild as xt, migrateWorkQueue as y, PassSpawnError as yt, setDaemonConfig as z };
|
|
4415
|
+
//# sourceMappingURL=context-handover-cache-DcPEW6vf.mjs.map
|