@tekmidian/pai 0.35.2 → 0.36.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{auto-route-DVM3U2ZY.mjs → auto-route-Byf8ENXj.mjs} +4 -4
- package/dist/{auto-route-DVM3U2ZY.mjs.map → auto-route-Byf8ENXj.mjs.map} +1 -1
- package/dist/cli/index.mjs +25 -17
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/probe2.mjs +2 -0
- package/dist/cli/program.d.mts.map +1 -1
- package/dist/cli/program.mjs +16 -267
- package/dist/{clusters-CzGxefB7.mjs → clusters-wZgTCYCB.mjs} +2 -2
- package/dist/{clusters-CzGxefB7.mjs.map → clusters-wZgTCYCB.mjs.map} +1 -1
- package/dist/{config-BSkVcvfq.mjs → config-B64vFg14.mjs} +3 -12
- package/dist/{config-BSkVcvfq.mjs.map → config-B64vFg14.mjs.map} +1 -1
- package/dist/config-C_ErGddD.mjs +3 -0
- package/dist/{checkpoint-block-D3rm4dAJ.mjs → context-handover-cache-PtNvj_8D.mjs} +40 -8
- package/dist/context-handover-cache-PtNvj_8D.mjs.map +1 -0
- package/dist/daemon/index.mjs +19 -19
- package/dist/{daemon-B8L2f5vy.mjs → daemon-BZ93KRBo.mjs} +33 -38
- package/dist/daemon-BZ93KRBo.mjs.map +1 -0
- package/dist/daemon-DRdoA489.mjs +20 -0
- package/dist/daemon-mcp/index.mjs +2 -2
- package/dist/{db-BtuN768f.mjs → db-Ca5qfsMC.mjs} +2 -4
- package/dist/{db-BtuN768f.mjs.map → db-Ca5qfsMC.mjs.map} +1 -1
- package/dist/db-O-cyAPfS.mjs +3 -0
- package/dist/db-XEwJbuGO.mjs +3 -0
- package/dist/{db-CYmBWcjh.mjs → db-a1ixZQjr.mjs} +2 -4
- package/dist/{db-CYmBWcjh.mjs.map → db-a1ixZQjr.mjs.map} +1 -1
- package/dist/{detect-Bf2z-oKB.mjs → detect-CdaA48EI.mjs} +1 -1
- package/dist/{detect-Bf2z-oKB.mjs.map → detect-CdaA48EI.mjs.map} +1 -1
- package/dist/{detector-BU-bsDXs.mjs → detector--Gg5JRN5.mjs} +3 -5
- package/dist/{detector-BU-bsDXs.mjs.map → detector--Gg5JRN5.mjs.map} +1 -1
- package/dist/detector-DGAk1iBR.mjs +5 -0
- package/dist/embeddings-CEBGrzwu.mjs +3 -0
- package/dist/{embeddings-Bn86ssxR.mjs → embeddings-DOLZnT1X.mjs} +2 -12
- package/dist/{embeddings-Bn86ssxR.mjs.map → embeddings-DOLZnT1X.mjs.map} +1 -1
- package/dist/{factory-vPTPoBR7.mjs → factory-Bsp7xOpO.mjs} +9 -12
- package/dist/{factory-vPTPoBR7.mjs.map → factory-Bsp7xOpO.mjs.map} +1 -1
- package/dist/factory-CrokPMk2.mjs +3 -0
- package/dist/{helpers-crDEr6S2.mjs → helpers-IjZkXBhj.mjs} +1 -1
- package/dist/{helpers-crDEr6S2.mjs.map → helpers-IjZkXBhj.mjs.map} +1 -1
- package/dist/hooks/context-compression-hook.mjs +217 -70
- package/dist/hooks/context-compression-hook.mjs.map +4 -4
- package/dist/index.mjs +10 -10
- package/dist/{indexer-backend-Bg7VDpGt.mjs → indexer-backend-nQZuEx6N.mjs} +3 -3
- package/dist/{indexer-backend-Bg7VDpGt.mjs.map → indexer-backend-nQZuEx6N.mjs.map} +1 -1
- package/dist/{ipc-client-aVKVERjJ.mjs → ipc-client-BmypMNYk.mjs} +13 -7
- package/dist/ipc-client-BmypMNYk.mjs.map +1 -0
- package/dist/{kg-entity-r8duqhi9.mjs → kg-entity-DbOMPdF9.mjs} +1 -1
- package/dist/{kg-entity-r8duqhi9.mjs.map → kg-entity-DbOMPdF9.mjs.map} +1 -1
- package/dist/{latent-ideas-BL9m2HF9.mjs → latent-ideas-Bn6A5-5P.mjs} +4 -4
- package/dist/{latent-ideas-BL9m2HF9.mjs.map → latent-ideas-Bn6A5-5P.mjs.map} +1 -1
- package/dist/{link-boost-QFLrJwD6.mjs → link-boost-fYjUnxCN.mjs} +1 -1
- package/dist/{link-boost-QFLrJwD6.mjs.map → link-boost-fYjUnxCN.mjs.map} +1 -1
- package/dist/{main-resolver-CNSqU8wo.mjs → main-resolver-BAbhKpeX.mjs} +12 -14
- package/dist/main-resolver-BAbhKpeX.mjs.map +1 -0
- package/dist/main-resolver-Dxh444GO.mjs +4 -0
- package/dist/{migrate-fLD6rAdO.mjs → migrate-Cjzeefn9.mjs} +2 -2
- package/dist/{migrate-fLD6rAdO.mjs.map → migrate-Cjzeefn9.mjs.map} +1 -1
- package/dist/{neighborhood-BX89_nty.mjs → neighborhood-DpaEM991.mjs} +2 -2
- package/dist/{neighborhood-BX89_nty.mjs.map → neighborhood-DpaEM991.mjs.map} +1 -1
- package/dist/{note-context-d1wT_-GA.mjs → note-context-DrcY4cWm.mjs} +1 -1
- package/dist/{note-context-d1wT_-GA.mjs.map → note-context-DrcY4cWm.mjs.map} +1 -1
- package/dist/{pai-marker-B20KqhA8.mjs → pai-marker-CHtbJMwJ.mjs} +1 -1
- package/dist/{pai-marker-B20KqhA8.mjs.map → pai-marker-CHtbJMwJ.mjs.map} +1 -1
- package/dist/{postgres-CI3FjGq0.mjs → postgres-BVme6qX0.mjs} +2 -2
- package/dist/{postgres-CI3FjGq0.mjs.map → postgres-BVme6qX0.mjs.map} +1 -1
- package/dist/{pick-DatgS3Nk.mjs → program-C-fUghPv.mjs} +1298 -223
- package/dist/program-C-fUghPv.mjs.map +1 -0
- package/dist/query-feedback-BBMBp96K.mjs +3 -0
- package/dist/{query-feedback-D4U56Hz6.mjs → query-feedback-C1T6kS18.mjs} +2 -4
- package/dist/{query-feedback-D4U56Hz6.mjs.map → query-feedback-C1T6kS18.mjs.map} +1 -1
- package/dist/reranker-CwTCNsgA.mjs +3 -0
- package/dist/{reranker-CMNZcfVx.mjs → reranker-xPm04PXx.mjs} +2 -8
- package/dist/{reranker-CMNZcfVx.mjs.map → reranker-xPm04PXx.mjs.map} +1 -1
- package/dist/router-BMkOb62X.mjs +3 -0
- package/dist/{router-i9S19Usg.mjs → router-CsDm7HvK.mjs} +2 -4
- package/dist/{router-i9S19Usg.mjs.map → router-CsDm7HvK.mjs.map} +1 -1
- package/dist/{runtime-paths-B0P1TvUr.mjs → runtime-paths-rni52zHX.mjs} +1 -1
- package/dist/{runtime-paths-B0P1TvUr.mjs.map → runtime-paths-rni52zHX.mjs.map} +1 -1
- package/dist/search-CfPpJAWQ.mjs +4 -0
- package/dist/{search-C32zQ0V0.mjs → search-Rpk1cSBC.mjs} +4 -15
- package/dist/{search-C32zQ0V0.mjs.map → search-Rpk1cSBC.mjs.map} +1 -1
- package/dist/{sources-BDwN0B8i.mjs → sources-D8ZdNfvK.mjs} +2 -2
- package/dist/{sources-BDwN0B8i.mjs.map → sources-D8ZdNfvK.mjs.map} +1 -1
- package/dist/{sqlite-C6FHnMkn.mjs → sqlite-D1IaR8Am.mjs} +3 -3
- package/dist/{sqlite-C6FHnMkn.mjs.map → sqlite-D1IaR8Am.mjs.map} +1 -1
- package/dist/state-WaXhLr6R.mjs +70 -0
- package/dist/{state-DTvy-jRB.mjs.map → state-WaXhLr6R.mjs.map} +1 -1
- package/dist/state-qtmrBWCm.mjs +3 -0
- package/dist/{stop-words-BaMEGVeY.mjs → stop-words-Hfu8u22w.mjs} +1 -1
- package/dist/{stop-words-BaMEGVeY.mjs.map → stop-words-Hfu8u22w.mjs.map} +1 -1
- package/dist/{sync--BoxBBok.mjs → sync-BWbe8JTg.mjs} +3 -3
- package/dist/{sync--BoxBBok.mjs.map → sync-BWbe8JTg.mjs.map} +1 -1
- package/dist/{themes-BObEGMWn.mjs → themes-XPkj_bfP.mjs} +3 -3
- package/dist/{themes-BObEGMWn.mjs.map → themes-XPkj_bfP.mjs.map} +1 -1
- package/dist/tools-DEt6YPfc.mjs +5 -0
- package/dist/{tools-C1lCHerL.mjs → tools-ceiy7ANX.mjs} +28 -65
- package/dist/tools-ceiy7ANX.mjs.map +1 -0
- package/dist/{trace-h23JCcFD.mjs → trace-DfyGmMG_.mjs} +1 -1
- package/dist/{trace-h23JCcFD.mjs.map → trace-DfyGmMG_.mjs.map} +1 -1
- package/dist/{utils-BAxjW3j8.mjs → utils-9Err2RBW.mjs} +2 -22
- package/dist/{utils-BAxjW3j8.mjs.map → utils-9Err2RBW.mjs.map} +1 -1
- package/dist/utils-DhMex3Ox.mjs +3 -0
- package/dist/{vault-indexer-CUF9edbW.mjs → vault-indexer-CFvlPUMB.mjs} +2 -2
- package/dist/{vault-indexer-CUF9edbW.mjs.map → vault-indexer-CFvlPUMB.mjs.map} +1 -1
- package/dist/{work-queue-worker-BcDGAcF3.mjs → work-queue-worker-B8W8_3Rn.mjs} +201 -13
- package/dist/work-queue-worker-B8W8_3Rn.mjs.map +1 -0
- package/dist/work-queue-worker-HN2Ufg-L.mjs +11 -0
- package/dist/{zettelkasten-W-h8G2is.mjs → zettelkasten-CvjmMghT.mjs} +4 -4
- package/dist/{zettelkasten-W-h8G2is.mjs.map → zettelkasten-CvjmMghT.mjs.map} +1 -1
- package/package.json +1 -1
- package/src/hooks/ts/lib/context-fill.test.ts +628 -0
- package/src/hooks/ts/lib/context-fill.ts +668 -0
- package/src/hooks/ts/lib/context-handover-cache.ts +46 -0
- package/src/hooks/ts/lib/transcript-text.test.ts +125 -0
- package/src/hooks/ts/lib/transcript-text.ts +71 -0
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +131 -30
- package/statusline-command.sh +16 -0
- package/dist/checkpoint-block-D3rm4dAJ.mjs.map +0 -1
- package/dist/daemon-B8L2f5vy.mjs.map +0 -1
- package/dist/ipc-client-aVKVERjJ.mjs.map +0 -1
- package/dist/main-resolver-CNSqU8wo.mjs.map +0 -1
- package/dist/pick-DatgS3Nk.mjs.map +0 -1
- package/dist/rolldown-runtime-95iHPtFO.mjs +0 -18
- package/dist/state-DTvy-jRB.mjs +0 -102
- package/dist/tools-C1lCHerL.mjs.map +0 -1
- package/dist/work-queue-worker-BcDGAcF3.mjs.map +0 -1
- /package/dist/{indexer-AEcT8wHf.mjs → indexer-D7MvSQPY.mjs} +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context-handover-cache.ts — the well-known file where the threshold-
|
|
3
|
+
* triggered pre-compaction handover (see ../../../daemon/context-handover-
|
|
4
|
+
* worker.ts) is written, and where context-compression-hook.ts looks for it
|
|
5
|
+
* at compaction time.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately dependency-light (fs/os/path only). The daemon worker that
|
|
8
|
+
* WRITES this file needs the heavier machinery in session-summary-worker.ts
|
|
9
|
+
* (spawning Claude, git log, etc.), but the PreCompact hook that READS it
|
|
10
|
+
* runs as a short-lived process on every compaction and must not drag in
|
|
11
|
+
* daemon state, database pools, or anything else that module graph carries —
|
|
12
|
+
* this file is the shared seam so neither side has to.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
export type HandoverThreshold = "warmup" | "refresh";
|
|
20
|
+
|
|
21
|
+
export interface ContextHandoverCache {
|
|
22
|
+
sessionId: string;
|
|
23
|
+
cwd: string;
|
|
24
|
+
threshold: HandoverThreshold;
|
|
25
|
+
generatedAt: string; // ISO timestamp
|
|
26
|
+
model: string;
|
|
27
|
+
summary: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function contextHandoverCachePath(sessionId: string): string {
|
|
31
|
+
return join(tmpdir(), `pai-context-handover-${sessionId}.json`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readContextHandoverCache(sessionId: string): ContextHandoverCache | null {
|
|
35
|
+
const path = contextHandoverCachePath(sessionId);
|
|
36
|
+
if (!existsSync(path)) return null;
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(path, "utf-8")) as ContextHandoverCache;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function writeContextHandoverCache(cache: ContextHandoverCache): void {
|
|
45
|
+
writeFileSync(contextHandoverCachePath(cache.sessionId), JSON.stringify(cache, null, 2), "utf-8");
|
|
46
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { contentToText, isNoiseFilePath, preferCwdFiles } from "./transcript-text.js";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// contentToText — bug 1: "[object Object]" from a stringified nested array
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
describe("contentToText", () => {
|
|
9
|
+
it("returns a plain string unchanged", () => {
|
|
10
|
+
expect(contentToText("hello")).toBe("hello");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("concatenates the text of type:text blocks", () => {
|
|
14
|
+
expect(
|
|
15
|
+
contentToText([
|
|
16
|
+
{ type: "text", text: "first" },
|
|
17
|
+
{ type: "text", text: "second" },
|
|
18
|
+
])
|
|
19
|
+
).toBe("first second");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("BUG 1: ignores a tool_result block instead of stringifying its nested content array", () => {
|
|
23
|
+
// This is the exact shape a tool_result user-turn takes: content is an
|
|
24
|
+
// ARRAY of blocks, not a string or a .text field. The old code fell back
|
|
25
|
+
// to String(c.content) here, producing "[object Object],[object Object]".
|
|
26
|
+
const content = [
|
|
27
|
+
{
|
|
28
|
+
type: "tool_result",
|
|
29
|
+
tool_use_id: "abc",
|
|
30
|
+
content: [
|
|
31
|
+
{ type: "text", text: "some tool output" },
|
|
32
|
+
{ type: "text", text: "more tool output" },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
const result = contentToText(content);
|
|
37
|
+
expect(result).not.toContain("[object Object]");
|
|
38
|
+
expect(result).toBe(""); // tool_result is ignored entirely, not partially stringified
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("ignores tool_use and image blocks", () => {
|
|
42
|
+
const content = [
|
|
43
|
+
{ type: "tool_use", name: "Edit", input: { file_path: "/x.ts" } },
|
|
44
|
+
{ type: "image", source: { data: "..." } },
|
|
45
|
+
{ type: "text", text: "the actual request" },
|
|
46
|
+
];
|
|
47
|
+
expect(contentToText(content)).toBe("the actual request");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns empty string for non-string, non-array content", () => {
|
|
51
|
+
expect(contentToText(null)).toBe("");
|
|
52
|
+
expect(contentToText(undefined)).toBe("");
|
|
53
|
+
expect(contentToText(42)).toBe("");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// isNoiseFilePath — bug 2: tmp/jobs paths crowding out real source files
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
describe("isNoiseFilePath", () => {
|
|
62
|
+
it("excludes a tmp/ path segment", () => {
|
|
63
|
+
expect(isNoiseFilePath("/Users/me/project/jobs/42/tmp/msg8.txt")).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("excludes a jobs/ path segment even without a nested tmp/", () => {
|
|
67
|
+
expect(isNoiseFilePath("/Users/me/project/jobs/42/attachment.txt")).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("excludes a scratchpad/ path segment", () => {
|
|
71
|
+
expect(isNoiseFilePath("/private/tmp/claude-501/proj/scratchpad/notes.md")).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("excludes anything under an explicit scratchpad dir even without the word in the path", () => {
|
|
75
|
+
expect(isNoiseFilePath("/private/tmp/claude-501/proj/work/sess/draft.txt", "/private/tmp/claude-501/proj/work/sess")).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("does not exclude a real source file", () => {
|
|
79
|
+
expect(isNoiseFilePath("/Users/me/project/src/hooks/ts/pre-compact/context-compression-hook.ts")).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("does not false-positive on a directory merely containing 'tmp' as a substring", () => {
|
|
83
|
+
expect(isNoiseFilePath("/Users/me/project/src/attempt/file.ts")).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// preferCwdFiles — bug 2 continued: don't let noise crowd the last-10 slice
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
describe("preferCwdFiles", () => {
|
|
92
|
+
it("orders cwd files before non-cwd files", () => {
|
|
93
|
+
const files = [
|
|
94
|
+
"/other/repo/a.ts",
|
|
95
|
+
"/Users/me/project/src/real.ts",
|
|
96
|
+
"/other/repo/b.ts",
|
|
97
|
+
"/Users/me/project/src/other-real.ts",
|
|
98
|
+
];
|
|
99
|
+
const result = preferCwdFiles(files, "/Users/me/project");
|
|
100
|
+
expect(result).toEqual([
|
|
101
|
+
"/Users/me/project/src/real.ts",
|
|
102
|
+
"/Users/me/project/src/other-real.ts",
|
|
103
|
+
"/other/repo/a.ts",
|
|
104
|
+
"/other/repo/b.ts",
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns the list unchanged when cwd is not provided", () => {
|
|
109
|
+
const files = ["/a.ts", "/b.ts"];
|
|
110
|
+
expect(preferCwdFiles(files)).toEqual(files);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("real regression case: real source files survive a slice(-10) after noise filtering + cwd preference", () => {
|
|
114
|
+
// Reproduces the reported digest: 8 tmp/jobs paths plus 2 real source
|
|
115
|
+
// files. isNoiseFilePath already drops the 8 at collection time in the
|
|
116
|
+
// hook, so this only has the 2 real files left — they must not be pushed
|
|
117
|
+
// out of a 10-slot slice by anything.
|
|
118
|
+
const realFiles = [
|
|
119
|
+
"/Users/me/project/src/a.ts",
|
|
120
|
+
"/Users/me/project/src/b.ts",
|
|
121
|
+
];
|
|
122
|
+
const result = preferCwdFiles(realFiles, "/Users/me/project").slice(-10);
|
|
123
|
+
expect(result).toEqual(realFiles);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transcript-text.ts — small, pure pieces of transcript parsing that are
|
|
3
|
+
* worth testing in isolation.
|
|
4
|
+
*
|
|
5
|
+
* Split out of context-compression-hook.ts (a hook entrypoint that calls
|
|
6
|
+
* `main()` and `process.exit()` at import time, which makes it unsafe to
|
|
7
|
+
* import directly from a test) so the two bug fixes that live here can be
|
|
8
|
+
* covered by a real regression test rather than only a manual before/after
|
|
9
|
+
* digest.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Turn Claude content (string or content block array) into plain text.
|
|
14
|
+
*
|
|
15
|
+
* BUG (found in ~1 in 4 injected digests on this machine): a "user" turn is
|
|
16
|
+
* not always something the user typed — a tool_result is also delivered as a
|
|
17
|
+
* user-role message, and its `content` block looks like
|
|
18
|
+
* `{ type: "tool_result", content: [...] }` where the inner `content` is
|
|
19
|
+
* itself an array of blocks, not a string. The old code fell back to
|
|
20
|
+
* `String(c.content)` for anything without a `.text` field, and `String()`
|
|
21
|
+
* on an array of objects produces literal `"[object Object],[object
|
|
22
|
+
* Object]"` — which is exactly what showed up under "Recent user requests".
|
|
23
|
+
* Only ever take the `.text` of a `type: "text"` block; everything else
|
|
24
|
+
* (tool_use, tool_result, image, and any nested content) is ignored rather
|
|
25
|
+
* than stringified.
|
|
26
|
+
*/
|
|
27
|
+
export function contentToText(content: unknown): string {
|
|
28
|
+
if (typeof content === 'string') return content;
|
|
29
|
+
if (Array.isArray(content)) {
|
|
30
|
+
return content
|
|
31
|
+
.map((c) => {
|
|
32
|
+
if (typeof c === 'string') return c;
|
|
33
|
+
if (c && typeof c === 'object' && c.type === 'text' && typeof c.text === 'string') {
|
|
34
|
+
return c.text;
|
|
35
|
+
}
|
|
36
|
+
return '';
|
|
37
|
+
})
|
|
38
|
+
.join(' ')
|
|
39
|
+
.trim();
|
|
40
|
+
}
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True for a modified-file path that is noise for a handover, not signal.
|
|
46
|
+
*
|
|
47
|
+
* BUG: a real digest showed 8 of 10 "Files modified" slots filled with
|
|
48
|
+
* throwaway paths under a jobs/<id>/tmp/ directory (msg8.txt, msg9.txt …),
|
|
49
|
+
* crowding out the two real source files also edited that session. Anything
|
|
50
|
+
* under a `tmp/`, `jobs/`, or `scratchpad/` path segment is excluded — those
|
|
51
|
+
* are working scratch space, not the codebase the next session needs to
|
|
52
|
+
* remember it touched.
|
|
53
|
+
*/
|
|
54
|
+
export function isNoiseFilePath(filePath: string, scratchpadDir?: string): boolean {
|
|
55
|
+
if (scratchpadDir && filePath.startsWith(scratchpadDir)) return true;
|
|
56
|
+
const segments = filePath.split('/');
|
|
57
|
+
return segments.includes('tmp') || segments.includes('jobs') || segments.includes('scratchpad');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Order a modified-file list so entries inside `cwd` are preferred when a
|
|
62
|
+
* caller has to truncate to a fixed slot count — an unrelated repo touched
|
|
63
|
+
* in passing should not crowd out the files that actually matter for this
|
|
64
|
+
* session's working directory.
|
|
65
|
+
*/
|
|
66
|
+
export function preferCwdFiles(files: string[], cwd?: string): string[] {
|
|
67
|
+
if (!cwd) return files;
|
|
68
|
+
const inCwd = files.filter((f) => f.startsWith(cwd));
|
|
69
|
+
const outCwd = files.filter((f) => !f.startsWith(cwd));
|
|
70
|
+
return [...inCwd, ...outCwd];
|
|
71
|
+
}
|
|
@@ -28,10 +28,12 @@ import {
|
|
|
28
28
|
findNotesDir,
|
|
29
29
|
renameSessionNote,
|
|
30
30
|
updateTodoContinue,
|
|
31
|
-
calculateSessionTokens,
|
|
32
31
|
isProbeSession,
|
|
33
32
|
WorkItem,
|
|
34
33
|
} from '../lib/project-utils';
|
|
34
|
+
import { getContextFill, formatContextFill } from '../lib/context-fill.js';
|
|
35
|
+
import { contentToText, isNoiseFilePath, preferCwdFiles } from '../lib/transcript-text.js';
|
|
36
|
+
import { readContextHandoverCache } from '../lib/context-handover-cache.js';
|
|
35
37
|
|
|
36
38
|
interface HookInput {
|
|
37
39
|
session_id: string;
|
|
@@ -40,6 +42,10 @@ interface HookInput {
|
|
|
40
42
|
hook_event_name: string;
|
|
41
43
|
compact_type?: string;
|
|
42
44
|
trigger?: string;
|
|
45
|
+
/** Session's scratchpad directory, when the harness provides one — used to
|
|
46
|
+
* exclude scratch files from the "Files modified" list (see
|
|
47
|
+
* isNoiseFilePath). */
|
|
48
|
+
scratchpad_dir?: string;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
const DAEMON_SOCKET = process.env.PAI_SOCKET ?? '/tmp/pai.sock';
|
|
@@ -52,29 +58,21 @@ interface TranscriptData {
|
|
|
52
58
|
captures: string[];
|
|
53
59
|
lastCompleted: string;
|
|
54
60
|
filesModified: string[];
|
|
61
|
+
/** Count of modified-file paths dropped as noise (tmp/jobs/scratchpad). */
|
|
62
|
+
filesExcluded: number;
|
|
55
63
|
workItems: WorkItem[];
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
// ---------------------------------------------------------------------------
|
|
59
67
|
// Helpers
|
|
60
68
|
// ---------------------------------------------------------------------------
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (typeof c === 'string') return c;
|
|
69
|
-
if (c?.text) return c.text;
|
|
70
|
-
if (c?.content) return String(c.content);
|
|
71
|
-
return '';
|
|
72
|
-
})
|
|
73
|
-
.join(' ')
|
|
74
|
-
.trim();
|
|
75
|
-
}
|
|
76
|
-
return '';
|
|
77
|
-
}
|
|
69
|
+
//
|
|
70
|
+
// contentToText, isNoiseFilePath, and preferCwdFiles live in
|
|
71
|
+
// ../lib/transcript-text.ts, not here — this file calls `main()` and
|
|
72
|
+
// `process.exit()` at import time, which makes it unsafe to import from a
|
|
73
|
+
// test. Splitting the pure, bug-fixed logic out is what lets those two bugs
|
|
74
|
+
// (the "[object Object]" content bug and the tmp/jobs noise-crowding bug)
|
|
75
|
+
// carry a real regression test instead of only a manual before/after digest.
|
|
78
76
|
|
|
79
77
|
function getTranscriptStats(transcriptPath: string): { messageCount: number; isLarge: boolean } {
|
|
80
78
|
try {
|
|
@@ -101,13 +99,14 @@ function getTranscriptStats(transcriptPath: string): { messageCount: number; isL
|
|
|
101
99
|
// Unified transcript parser — single pass extracts everything
|
|
102
100
|
// ---------------------------------------------------------------------------
|
|
103
101
|
|
|
104
|
-
function parseTranscript(transcriptPath: string): TranscriptData {
|
|
102
|
+
function parseTranscript(transcriptPath: string, scratchpadDir?: string): TranscriptData {
|
|
105
103
|
const data: TranscriptData = {
|
|
106
104
|
userMessages: [],
|
|
107
105
|
summaries: [],
|
|
108
106
|
captures: [],
|
|
109
107
|
lastCompleted: '',
|
|
110
108
|
filesModified: [],
|
|
109
|
+
filesExcluded: 0,
|
|
111
110
|
workItems: [],
|
|
112
111
|
};
|
|
113
112
|
|
|
@@ -175,8 +174,11 @@ function parseTranscript(transcriptPath: string): TranscriptData {
|
|
|
175
174
|
if (block.type === 'tool_use') {
|
|
176
175
|
const tool = block.name;
|
|
177
176
|
if ((tool === 'Edit' || tool === 'Write') && block.input?.file_path) {
|
|
178
|
-
|
|
179
|
-
|
|
177
|
+
const fp = block.input.file_path;
|
|
178
|
+
if (isNoiseFilePath(fp, scratchpadDir)) {
|
|
179
|
+
data.filesExcluded++;
|
|
180
|
+
} else if (!data.filesModified.includes(fp)) {
|
|
181
|
+
data.filesModified.push(fp);
|
|
180
182
|
}
|
|
181
183
|
}
|
|
182
184
|
}
|
|
@@ -220,10 +222,19 @@ function formatSessionState(data: TranscriptData, cwd?: string): string | null {
|
|
|
220
222
|
for (const c of recentCaptures) parts.push(`- ${c.slice(0, 150)}`);
|
|
221
223
|
}
|
|
222
224
|
|
|
223
|
-
|
|
225
|
+
// Noise (tmp/jobs/scratchpad) is already excluded at collection time — see
|
|
226
|
+
// isNoiseFilePath. Here, prefer files inside the session's own working
|
|
227
|
+
// directory when there are more than fit in the slice, so an unrelated
|
|
228
|
+
// repo touched in passing doesn't crowd out the files that matter.
|
|
229
|
+
const files = preferCwdFiles(data.filesModified, cwd).slice(-10);
|
|
224
230
|
if (files.length > 0) {
|
|
225
231
|
parts.push('\nFiles modified this session:');
|
|
226
232
|
for (const f of files) parts.push(`- ${f}`);
|
|
233
|
+
} else if (data.filesExcluded > 0) {
|
|
234
|
+
parts.push(
|
|
235
|
+
`\nFiles modified this session: none in the working directory ` +
|
|
236
|
+
`(excluded ${data.filesExcluded} noise path(s) under tmp/jobs/scratchpad)`
|
|
237
|
+
);
|
|
227
238
|
}
|
|
228
239
|
|
|
229
240
|
if (data.lastCompleted) {
|
|
@@ -315,6 +326,7 @@ function loadCumulativeState(notesDir: string): TranscriptData | null {
|
|
|
315
326
|
captures: raw.captures || [],
|
|
316
327
|
lastCompleted: raw.lastCompleted || '',
|
|
317
328
|
filesModified: raw.filesModified || [],
|
|
329
|
+
filesExcluded: raw.filesExcluded || 0,
|
|
318
330
|
workItems: raw.workItems || [],
|
|
319
331
|
};
|
|
320
332
|
} catch {
|
|
@@ -322,6 +334,26 @@ function loadCumulativeState(notesDir: string): TranscriptData | null {
|
|
|
322
334
|
}
|
|
323
335
|
}
|
|
324
336
|
|
|
337
|
+
/**
|
|
338
|
+
* Peek at `.compact-state.json`'s `lastUpdated` — the timestamp of THIS
|
|
339
|
+
* session's previous compaction — without pulling it into TranscriptData's
|
|
340
|
+
* contract. Read before `saveCumulativeState` overwrites the file for the
|
|
341
|
+
* current compaction, so callers can tell "a cached handover generated
|
|
342
|
+
* since the last compaction" from "a cached handover left over from before
|
|
343
|
+
* it, now stale". Returns null when there is no previous compaction (the
|
|
344
|
+
* first one this session) — treated as "any cached handover is fresh".
|
|
345
|
+
*/
|
|
346
|
+
function readPreviousCompactionTimestamp(notesDir: string): string | null {
|
|
347
|
+
try {
|
|
348
|
+
const filePath = join(notesDir, CUMULATIVE_STATE_FILE);
|
|
349
|
+
if (!existsSync(filePath)) return null;
|
|
350
|
+
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
351
|
+
return typeof raw.lastUpdated === 'string' ? raw.lastUpdated : null;
|
|
352
|
+
} catch {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
325
357
|
function mergeTranscriptData(accumulated: TranscriptData | null, current: TranscriptData): TranscriptData {
|
|
326
358
|
if (!accumulated) return current;
|
|
327
359
|
|
|
@@ -339,6 +371,7 @@ function mergeTranscriptData(accumulated: TranscriptData | null, current: Transc
|
|
|
339
371
|
captures: mergeArrays(accumulated.captures, current.captures),
|
|
340
372
|
lastCompleted: current.lastCompleted || accumulated.lastCompleted,
|
|
341
373
|
filesModified: mergeArrays(accumulated.filesModified, current.filesModified),
|
|
374
|
+
filesExcluded: accumulated.filesExcluded + current.filesExcluded,
|
|
342
375
|
workItems: [...accumulated.workItems, ...newWorkItems],
|
|
343
376
|
};
|
|
344
377
|
}
|
|
@@ -507,19 +540,33 @@ async function main() {
|
|
|
507
540
|
}
|
|
508
541
|
|
|
509
542
|
const compactType = hookInput?.compact_type || hookInput?.trigger || 'auto';
|
|
510
|
-
let
|
|
543
|
+
let ntfyTokenDisplay: string | null = null;
|
|
511
544
|
|
|
512
545
|
if (hookInput?.transcript_path) {
|
|
513
546
|
const stats = getTranscriptStats(hookInput.transcript_path);
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
547
|
+
|
|
548
|
+
// BUG (real headers pulled from this machine's history): "~995073k
|
|
549
|
+
// tokens", "~2135399k tokens" — 995 million and 2.1 billion. The old
|
|
550
|
+
// source, calculateSessionTokens, sums usage across EVERY line in the
|
|
551
|
+
// transcript, which is a lifetime token-spend counter, not a context
|
|
552
|
+
// fill reading — it necessarily exceeds the window on any long session
|
|
553
|
+
// and is not what "context compression triggered at ~X tokens" means.
|
|
554
|
+
// getContextFill reads the most recent usage entry only (or the
|
|
555
|
+
// statusline's own reading), which is the actual current fill and can
|
|
556
|
+
// never legitimately exceed the window. formatContextFill still
|
|
557
|
+
// clamps-and-flags rather than trust that, and reports "unknown"
|
|
558
|
+
// instead of guessing when neither source has an answer.
|
|
559
|
+
const fill = getContextFill({
|
|
560
|
+
sessionId: hookInput.session_id,
|
|
561
|
+
transcriptPath: hookInput.transcript_path,
|
|
562
|
+
});
|
|
563
|
+
const tokenDisplay = formatContextFill(fill).text;
|
|
564
|
+
ntfyTokenDisplay = tokenDisplay;
|
|
518
565
|
|
|
519
566
|
// -----------------------------------------------------------------
|
|
520
567
|
// Single-pass transcript parsing + cumulative state merge
|
|
521
568
|
// -----------------------------------------------------------------
|
|
522
|
-
const data = parseTranscript(hookInput.transcript_path);
|
|
569
|
+
const data = parseTranscript(hookInput.transcript_path, hookInput.scratchpad_dir);
|
|
523
570
|
|
|
524
571
|
// Find notes directory early — needed for cumulative state
|
|
525
572
|
let notesInfo: { path: string; isLocal: boolean };
|
|
@@ -533,6 +580,10 @@ async function main() {
|
|
|
533
580
|
|
|
534
581
|
// Load accumulated state from previous compactions and merge
|
|
535
582
|
const accumulated = loadCumulativeState(notesInfo.path);
|
|
583
|
+
// Read BEFORE saveCumulativeState (below) overwrites this file for the
|
|
584
|
+
// CURRENT compaction — this is the previous one's timestamp, the
|
|
585
|
+
// freshness line a cached handover has to clear (see the injection step).
|
|
586
|
+
const previousCompactionAt = readPreviousCompactionTimestamp(notesInfo.path);
|
|
536
587
|
const merged = mergeTranscriptData(accumulated, data);
|
|
537
588
|
const state = formatSessionState(merged, hookInput.cwd);
|
|
538
589
|
|
|
@@ -640,12 +691,62 @@ async function main() {
|
|
|
640
691
|
? `\nSESSION NOTE: ${notePath}\nIf this note still has a generic title (e.g. "New Session", "Context Compression"),\nrename it based on actual work done and add a rich summary.`
|
|
641
692
|
: '';
|
|
642
693
|
|
|
694
|
+
// -------------------------------------------------------------------
|
|
695
|
+
// ADDITIVE: a model-written handover (decisions/reasoning/open threads
|
|
696
|
+
// — see the threshold-triggered context-handover-worker) sits ALONGSIDE
|
|
697
|
+
// the mechanical scrape above, never replaces it. The scrape is the
|
|
698
|
+
// floor and stays intact on every path, so this is never worse than
|
|
699
|
+
// the pre-existing behaviour — only sometimes better. "Fresh" means
|
|
700
|
+
// generated after this session's PREVIOUS compaction: a handover
|
|
701
|
+
// cached before that point describes state the last compaction
|
|
702
|
+
// already accounted for, not what has happened since.
|
|
703
|
+
//
|
|
704
|
+
// BUG (live): a production digest was found with NO "HANDOVER SOURCE:"
|
|
705
|
+
// line at all — its cause was never pinned down for certain (the
|
|
706
|
+
// deployed hook is a compiled dist/ artifact reached through
|
|
707
|
+
// ${PAI_DIR}/Hooks/..., a separate build step from editing this
|
|
708
|
+
// source, which is itself a plausible way for "works when I run the
|
|
709
|
+
// .ts source" and "missing in production" to diverge). Regardless of
|
|
710
|
+
// cause: sourceLabel and handoverBlock are now computed in their own
|
|
711
|
+
// try/catch, so ANY failure in the cache read or the freshness
|
|
712
|
+
// comparison degrades to an explicit error label rather than being
|
|
713
|
+
// capable of preventing the line from existing at all.
|
|
714
|
+
// -------------------------------------------------------------------
|
|
715
|
+
let sourceLabel: string;
|
|
716
|
+
let handoverBlock: string;
|
|
717
|
+
try {
|
|
718
|
+
const cachedHandover = readContextHandoverCache(hookInput.session_id);
|
|
719
|
+
const handoverIsFresh =
|
|
720
|
+
cachedHandover !== null &&
|
|
721
|
+
(!previousCompactionAt || new Date(cachedHandover.generatedAt) > new Date(previousCompactionAt));
|
|
722
|
+
|
|
723
|
+
sourceLabel = handoverIsFresh
|
|
724
|
+
? `model-written handover (${cachedHandover!.model}, generated ${cachedHandover!.generatedAt}, ` +
|
|
725
|
+
`threshold=${cachedHandover!.threshold}) + mechanical scrape`
|
|
726
|
+
: 'mechanical scrape only (no fresh model-written handover was available)';
|
|
727
|
+
|
|
728
|
+
handoverBlock = handoverIsFresh
|
|
729
|
+
? [
|
|
730
|
+
'',
|
|
731
|
+
'--- MODEL-WRITTEN HANDOVER (decisions, reasoning, open threads — not in the scrape above) ---',
|
|
732
|
+
cachedHandover!.summary,
|
|
733
|
+
'--- end model-written handover ---',
|
|
734
|
+
].join('\n')
|
|
735
|
+
: '';
|
|
736
|
+
} catch (err) {
|
|
737
|
+
console.error(`Failed to resolve handover cache — falling back to scrape-only: ${err}`);
|
|
738
|
+
sourceLabel = `mechanical scrape only (error resolving handover cache: ${err})`;
|
|
739
|
+
handoverBlock = '';
|
|
740
|
+
}
|
|
741
|
+
|
|
643
742
|
const injection = [
|
|
644
743
|
'<system-reminder>',
|
|
645
744
|
`SESSION STATE RECOVERED AFTER COMPACTION (${compactType}, ~${tokenDisplay} tokens)`,
|
|
745
|
+
`HANDOVER SOURCE: ${sourceLabel}`,
|
|
646
746
|
'',
|
|
647
747
|
stateText,
|
|
648
748
|
noteInfo,
|
|
749
|
+
handoverBlock,
|
|
649
750
|
'',
|
|
650
751
|
'IMPORTANT: This session state was captured before context compaction.',
|
|
651
752
|
'Use it to maintain continuity. Continue the conversation from where',
|
|
@@ -687,8 +788,8 @@ async function main() {
|
|
|
687
788
|
} catch { /* non-fatal */ }
|
|
688
789
|
|
|
689
790
|
// Send ntfy.sh notification
|
|
690
|
-
const ntfyMessage =
|
|
691
|
-
? `Auto-pause: ~${
|
|
791
|
+
const ntfyMessage = ntfyTokenDisplay && ntfyTokenDisplay !== 'unknown'
|
|
792
|
+
? `Auto-pause: ~${ntfyTokenDisplay} tokens`
|
|
692
793
|
: 'Context compressing';
|
|
693
794
|
await sendNtfyNotification(ntfyMessage);
|
|
694
795
|
|
package/statusline-command.sh
CHANGED
|
@@ -116,6 +116,22 @@ context_size=$(echo "$input" | jq -r '.context_window.context_window_size // 200
|
|
|
116
116
|
context_used_k=$(( (context_pct * context_size / 100) / 1000 ))
|
|
117
117
|
context_max_k=$((context_size / 1000))
|
|
118
118
|
|
|
119
|
+
# Persist the authoritative context reading so hooks (which never receive
|
|
120
|
+
# context_window on their own stdin) can read it instead of re-deriving an
|
|
121
|
+
# estimate from the transcript. Best-effort only — a failure here must never
|
|
122
|
+
# affect the status line itself.
|
|
123
|
+
statusline_session_id=$(echo "$input" | jq -r '.session_id // empty' 2>/dev/null)
|
|
124
|
+
if [ -n "$statusline_session_id" ]; then
|
|
125
|
+
context_state_file="${TMPDIR:-/tmp}/pai-context-${statusline_session_id}.json"
|
|
126
|
+
jq -n \
|
|
127
|
+
--argjson used_percentage "${context_pct:-0}" \
|
|
128
|
+
--argjson context_window_size "${context_size:-200000}" \
|
|
129
|
+
--arg session_id "$statusline_session_id" \
|
|
130
|
+
--argjson timestamp "$(date +%s000)" \
|
|
131
|
+
'{used_percentage: $used_percentage, context_window_size: $context_window_size, session_id: $session_id, timestamp: $timestamp}' \
|
|
132
|
+
> "$context_state_file" 2>/dev/null || true
|
|
133
|
+
fi
|
|
134
|
+
|
|
119
135
|
# Tokyo Night Storm Color Scheme
|
|
120
136
|
BACKGROUND='\033[48;2;36;40;59m'
|
|
121
137
|
BRIGHT_PURPLE='\033[38;2;187;154;247m'
|