@tekmidian/pai 0.35.1 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.metadata_never_index +0 -0
- 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/{checkpoint-block-D3rm4dAJ.mjs → checkpoint-block-DKYxCkBL.mjs} +7 -6
- package/dist/checkpoint-block-DKYxCkBL.mjs.map +1 -0
- 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/daemon/index.mjs +19 -19
- package/dist/{daemon-Hnu6-HDD.mjs → daemon--N2JFnUs.mjs} +39 -39
- package/dist/daemon--N2JFnUs.mjs.map +1 -0
- package/dist/daemon-DJEFqV84.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-BGH0COXb.mjs → factory-Bsp7xOpO.mjs} +9 -12
- package/dist/{factory-BGH0COXb.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 +209 -70
- package/dist/hooks/context-compression-hook.mjs.map +4 -4
- package/dist/hooks/whisper-reinject.mjs +59 -0
- package/dist/hooks/whisper-reinject.mjs.map +7 -0
- package/dist/hooks/whisper-rules.mjs +24 -9
- package/dist/hooks/whisper-rules.mjs.map +2 -2
- 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-BALUE11K.mjs → postgres-BVme6qX0.mjs} +7 -4
- package/dist/postgres-BVme6qX0.mjs.map +1 -0
- package/dist/{pick-aWhenqjE.mjs → program-BnMNFb4O.mjs} +1171 -223
- package/dist/program-BnMNFb4O.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-228XjABm.mjs} +234 -14
- package/dist/work-queue-worker-228XjABm.mjs.map +1 -0
- package/dist/work-queue-worker-LRA9Fj9z.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 +515 -0
- package/src/hooks/ts/lib/context-fill.ts +585 -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/post-tool-use/whisper-reinject.ts +88 -0
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +112 -30
- package/src/hooks/ts/user-prompt/whisper-rules.ts +62 -8
- package/statusline-command.sh +16 -0
- package/dist/checkpoint-block-D3rm4dAJ.mjs.map +0 -1
- package/dist/daemon-Hnu6-HDD.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-aWhenqjE.mjs.map +0 -1
- package/dist/postgres-BALUE11K.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,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
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* whisper-reinject — put the output-shape rules back in view mid-turn.
|
|
5
|
+
*
|
|
6
|
+
* The whisper rules are injected once per user message, by the UserPromptSubmit
|
|
7
|
+
* hook. That is the wrong place for the rules that govern how a turn is
|
|
8
|
+
* *written*, because a long turn does not write its answer next to that
|
|
9
|
+
* injection: it runs twenty tool calls first. Attention follows the tool
|
|
10
|
+
* output, the injected block scrolls out of working memory, and by the time the
|
|
11
|
+
* answer is composed the rules are gone. Nothing in the loop re-checks them, so
|
|
12
|
+
* compliance decays with turn length — exactly the turns where it matters most.
|
|
13
|
+
*
|
|
14
|
+
* This fires after a tool call instead, so the reminder lands where the decay
|
|
15
|
+
* happens. It stays quiet for short sequences (nothing has decayed yet) and
|
|
16
|
+
* emits a compressed set — not all of them — every few calls after that. The
|
|
17
|
+
* full rule set is deliberately not repeated: a wall of text re-read every few
|
|
18
|
+
* calls is skimmed, and it costs context that the actual work needs.
|
|
19
|
+
*
|
|
20
|
+
* Only rules about the shape of the reply belong here. Rules about actions
|
|
21
|
+
* (what may be sent, published, or committed) are enforced at the point of
|
|
22
|
+
* action, not by reminding.
|
|
23
|
+
*/
|
|
24
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
|
|
28
|
+
/** Stay silent below this many tool calls — a short turn has not drifted yet. */
|
|
29
|
+
const QUIET_BELOW = 6;
|
|
30
|
+
|
|
31
|
+
/** Re-emit every N calls once past QUIET_BELOW. */
|
|
32
|
+
const EVERY = 8;
|
|
33
|
+
|
|
34
|
+
function counterFile(sessionId: string): string {
|
|
35
|
+
const safe = sessionId.replace(/[^A-Za-z0-9_-]/g, "") || "nosession";
|
|
36
|
+
return join(tmpdir(), "pai-whisper-reinject", `${safe}.count`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function bump(path: string): number {
|
|
40
|
+
try {
|
|
41
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
42
|
+
const n = existsSync(path) ? Number.parseInt(readFileSync(path, "utf-8").trim(), 10) || 0 : 0;
|
|
43
|
+
const next = n + 1;
|
|
44
|
+
writeFileSync(path, String(next));
|
|
45
|
+
return next;
|
|
46
|
+
} catch {
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function localTime(): string {
|
|
52
|
+
const d = new Date();
|
|
53
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
54
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readStdin(): string {
|
|
58
|
+
try {
|
|
59
|
+
return readFileSync(0, "utf-8");
|
|
60
|
+
} catch {
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function main(): void {
|
|
66
|
+
let sessionId = "";
|
|
67
|
+
try {
|
|
68
|
+
const raw = readStdin();
|
|
69
|
+
if (raw.trim()) sessionId = (JSON.parse(raw) as { session_id?: string }).session_id ?? "";
|
|
70
|
+
} catch { /* no session id — fall back to a shared counter */ }
|
|
71
|
+
|
|
72
|
+
const n = bump(counterFile(sessionId));
|
|
73
|
+
if (n < QUIET_BELOW || n % EVERY !== 0) return;
|
|
74
|
+
|
|
75
|
+
const lines = [
|
|
76
|
+
`CURRENT LOCAL TIME: ${localTime()} — use verbatim for any [YYYY-MM-DD HH:MM] stamp. Never estimate it, never increment a previous one.`,
|
|
77
|
+
`You are ${n} tool calls into this turn. Before the next one, check:`,
|
|
78
|
+
"- One command at a time: say what you are about to run, run it, say what came back. Do not batch several steps into one call.",
|
|
79
|
+
"- Report what CHANGED, not what you learned. A finding is not a deliverable.",
|
|
80
|
+
"- Prove it: show the reading that says it failed before and works now.",
|
|
81
|
+
"- Found a fault while working? Fix it. Filing it is not fixing it.",
|
|
82
|
+
"- Corrections stay to one line. No re-litigating your own mistakes.",
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
console.log(`<system-reminder>\n${lines.join("\n")}\n</system-reminder>`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
main();
|
|
@@ -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,43 @@ 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
|
+
const cachedHandover = readContextHandoverCache(hookInput.session_id);
|
|
705
|
+
const handoverIsFresh =
|
|
706
|
+
cachedHandover !== null &&
|
|
707
|
+
(!previousCompactionAt || new Date(cachedHandover.generatedAt) > new Date(previousCompactionAt));
|
|
708
|
+
|
|
709
|
+
const sourceLabel = handoverIsFresh
|
|
710
|
+
? `model-written handover (${cachedHandover!.model}, generated ${cachedHandover!.generatedAt}, ` +
|
|
711
|
+
`threshold=${cachedHandover!.threshold}) + mechanical scrape`
|
|
712
|
+
: 'mechanical scrape only (no fresh model-written handover was available)';
|
|
713
|
+
|
|
714
|
+
const handoverBlock = handoverIsFresh
|
|
715
|
+
? [
|
|
716
|
+
'',
|
|
717
|
+
'--- MODEL-WRITTEN HANDOVER (decisions, reasoning, open threads — not in the scrape above) ---',
|
|
718
|
+
cachedHandover!.summary,
|
|
719
|
+
'--- end model-written handover ---',
|
|
720
|
+
].join('\n')
|
|
721
|
+
: '';
|
|
722
|
+
|
|
643
723
|
const injection = [
|
|
644
724
|
'<system-reminder>',
|
|
645
725
|
`SESSION STATE RECOVERED AFTER COMPACTION (${compactType}, ~${tokenDisplay} tokens)`,
|
|
726
|
+
`HANDOVER SOURCE: ${sourceLabel}`,
|
|
646
727
|
'',
|
|
647
728
|
stateText,
|
|
648
729
|
noteInfo,
|
|
730
|
+
handoverBlock,
|
|
649
731
|
'',
|
|
650
732
|
'IMPORTANT: This session state was captured before context compaction.',
|
|
651
733
|
'Use it to maintain continuity. Continue the conversation from where',
|
|
@@ -687,8 +769,8 @@ async function main() {
|
|
|
687
769
|
} catch { /* non-fatal */ }
|
|
688
770
|
|
|
689
771
|
// Send ntfy.sh notification
|
|
690
|
-
const ntfyMessage =
|
|
691
|
-
? `Auto-pause: ~${
|
|
772
|
+
const ntfyMessage = ntfyTokenDisplay && ntfyTokenDisplay !== 'unknown'
|
|
773
|
+
? `Auto-pause: ~${ntfyTokenDisplay} tokens`
|
|
692
774
|
: 'Context compressing';
|
|
693
775
|
await sendNtfyNotification(ntfyMessage);
|
|
694
776
|
|
|
@@ -17,21 +17,38 @@
|
|
|
17
17
|
* If the file doesn't exist, no advisor guidance is injected.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
21
21
|
import { join } from "node:path";
|
|
22
|
-
import { homedir } from "node:os";
|
|
22
|
+
import { homedir, tmpdir } from "node:os";
|
|
23
23
|
|
|
24
24
|
const WHISPER_FILE = join(homedir(), ".claude", "whisper-rules.md");
|
|
25
25
|
const ADVISOR_FILE = join(homedir(), ".claude", "advisor-mode.json");
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Read the rule file, dropping everything that is there for the author rather
|
|
29
|
+
* than for the reader: "#" comment lines and blank lines.
|
|
30
|
+
*
|
|
31
|
+
* The rules are injected on every single prompt, so each line is paid for on
|
|
32
|
+
* every turn — which is the standing argument against letting the file grow
|
|
33
|
+
* headings and explanations. Stripping them here settles that: the file may be
|
|
34
|
+
* organised into sections with as much commentary as it takes to keep it
|
|
35
|
+
* maintainable, and none of it reaches the prompt. Only the rules do.
|
|
36
|
+
*
|
|
37
|
+
* A line whose rule text legitimately starts with "#" can be escaped as "\\#".
|
|
38
|
+
*/
|
|
27
39
|
function getWhisperRules(): string {
|
|
28
|
-
if (existsSync(WHISPER_FILE))
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
if (!existsSync(WHISPER_FILE)) return "";
|
|
41
|
+
try {
|
|
42
|
+
return readFileSync(WHISPER_FILE, "utf-8")
|
|
43
|
+
.split("\n")
|
|
44
|
+
.map((l) => l.trim())
|
|
45
|
+
.filter((l) => l.length > 0 && !l.startsWith("#"))
|
|
46
|
+
.map((l) => (l.startsWith("\\#") ? l.slice(1) : l))
|
|
47
|
+
.join("\n")
|
|
48
|
+
.trim();
|
|
49
|
+
} catch {
|
|
50
|
+
return "";
|
|
33
51
|
}
|
|
34
|
-
return "";
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
interface AdvisorConfig {
|
|
@@ -113,9 +130,46 @@ function getAdvisorGuidance(): string {
|
|
|
113
130
|
}
|
|
114
131
|
}
|
|
115
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Local wall-clock time, as data rather than as an instruction to go and look.
|
|
135
|
+
*
|
|
136
|
+
* A rule that says "use the local timestamp" is only ever as reliable as the
|
|
137
|
+
* model's willingness to stop and fetch one; the cheap substitute is a guess,
|
|
138
|
+
* and a guessed clock is worse than none — it reads as a measurement. Putting
|
|
139
|
+
* the real value in front of the model removes the choice.
|
|
140
|
+
*/
|
|
141
|
+
function currentLocalTime(): string {
|
|
142
|
+
const d = new Date();
|
|
143
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
144
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Reset the mid-turn tool-call counter that whisper-reinject increments.
|
|
149
|
+
*
|
|
150
|
+
* This hook fires exactly once per user message, which is the only place that
|
|
151
|
+
* knows where one turn ends and the next begins. Without the reset the counter
|
|
152
|
+
* is a session total, and "you are N tool calls into this turn" stops being
|
|
153
|
+
* true after the first turn — a reminder that misstates its own trigger is one
|
|
154
|
+
* the reader learns to discount.
|
|
155
|
+
*/
|
|
156
|
+
function resetReinjectCounter(): void {
|
|
157
|
+
try {
|
|
158
|
+
const raw = readFileSync(0, "utf-8");
|
|
159
|
+
const sessionId = raw.trim() ? (JSON.parse(raw) as { session_id?: string }).session_id ?? "" : "";
|
|
160
|
+
const safe = sessionId.replace(/[^A-Za-z0-9_-]/g, "") || "nosession";
|
|
161
|
+
const f = join(tmpdir(), "pai-whisper-reinject", `${safe}.count`);
|
|
162
|
+
if (existsSync(f)) unlinkSync(f);
|
|
163
|
+
} catch { /* best effort — a stale count is not worth failing the hook over */ }
|
|
164
|
+
}
|
|
165
|
+
|
|
116
166
|
function main() {
|
|
167
|
+
resetReinjectCounter();
|
|
168
|
+
|
|
117
169
|
const parts: string[] = [];
|
|
118
170
|
|
|
171
|
+
parts.push(`CURRENT LOCAL TIME: ${currentLocalTime()} — use this verbatim for any [YYYY-MM-DD HH:MM] stamp; never estimate or increment it.`);
|
|
172
|
+
|
|
119
173
|
const rules = getWhisperRules();
|
|
120
174
|
if (rules) parts.push(rules);
|
|
121
175
|
|
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'
|