@tekmidian/pai 0.35.2 → 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.
Files changed (126) hide show
  1. package/dist/{auto-route-DVM3U2ZY.mjs → auto-route-Byf8ENXj.mjs} +4 -4
  2. package/dist/{auto-route-DVM3U2ZY.mjs.map → auto-route-Byf8ENXj.mjs.map} +1 -1
  3. package/dist/{checkpoint-block-D3rm4dAJ.mjs → checkpoint-block-DKYxCkBL.mjs} +7 -6
  4. package/dist/checkpoint-block-DKYxCkBL.mjs.map +1 -0
  5. package/dist/cli/index.mjs +25 -17
  6. package/dist/cli/index.mjs.map +1 -1
  7. package/dist/cli/probe2.mjs +2 -0
  8. package/dist/cli/program.d.mts.map +1 -1
  9. package/dist/cli/program.mjs +16 -267
  10. package/dist/{clusters-CzGxefB7.mjs → clusters-wZgTCYCB.mjs} +2 -2
  11. package/dist/{clusters-CzGxefB7.mjs.map → clusters-wZgTCYCB.mjs.map} +1 -1
  12. package/dist/{config-BSkVcvfq.mjs → config-B64vFg14.mjs} +3 -12
  13. package/dist/{config-BSkVcvfq.mjs.map → config-B64vFg14.mjs.map} +1 -1
  14. package/dist/config-C_ErGddD.mjs +3 -0
  15. package/dist/daemon/index.mjs +19 -19
  16. package/dist/{daemon-B8L2f5vy.mjs → daemon--N2JFnUs.mjs} +33 -38
  17. package/dist/daemon--N2JFnUs.mjs.map +1 -0
  18. package/dist/daemon-DJEFqV84.mjs +20 -0
  19. package/dist/daemon-mcp/index.mjs +2 -2
  20. package/dist/{db-BtuN768f.mjs → db-Ca5qfsMC.mjs} +2 -4
  21. package/dist/{db-BtuN768f.mjs.map → db-Ca5qfsMC.mjs.map} +1 -1
  22. package/dist/db-O-cyAPfS.mjs +3 -0
  23. package/dist/db-XEwJbuGO.mjs +3 -0
  24. package/dist/{db-CYmBWcjh.mjs → db-a1ixZQjr.mjs} +2 -4
  25. package/dist/{db-CYmBWcjh.mjs.map → db-a1ixZQjr.mjs.map} +1 -1
  26. package/dist/{detect-Bf2z-oKB.mjs → detect-CdaA48EI.mjs} +1 -1
  27. package/dist/{detect-Bf2z-oKB.mjs.map → detect-CdaA48EI.mjs.map} +1 -1
  28. package/dist/{detector-BU-bsDXs.mjs → detector--Gg5JRN5.mjs} +3 -5
  29. package/dist/{detector-BU-bsDXs.mjs.map → detector--Gg5JRN5.mjs.map} +1 -1
  30. package/dist/detector-DGAk1iBR.mjs +5 -0
  31. package/dist/embeddings-CEBGrzwu.mjs +3 -0
  32. package/dist/{embeddings-Bn86ssxR.mjs → embeddings-DOLZnT1X.mjs} +2 -12
  33. package/dist/{embeddings-Bn86ssxR.mjs.map → embeddings-DOLZnT1X.mjs.map} +1 -1
  34. package/dist/{factory-vPTPoBR7.mjs → factory-Bsp7xOpO.mjs} +9 -12
  35. package/dist/{factory-vPTPoBR7.mjs.map → factory-Bsp7xOpO.mjs.map} +1 -1
  36. package/dist/factory-CrokPMk2.mjs +3 -0
  37. package/dist/{helpers-crDEr6S2.mjs → helpers-IjZkXBhj.mjs} +1 -1
  38. package/dist/{helpers-crDEr6S2.mjs.map → helpers-IjZkXBhj.mjs.map} +1 -1
  39. package/dist/hooks/context-compression-hook.mjs +209 -70
  40. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  41. package/dist/index.mjs +10 -10
  42. package/dist/{indexer-backend-Bg7VDpGt.mjs → indexer-backend-nQZuEx6N.mjs} +3 -3
  43. package/dist/{indexer-backend-Bg7VDpGt.mjs.map → indexer-backend-nQZuEx6N.mjs.map} +1 -1
  44. package/dist/{ipc-client-aVKVERjJ.mjs → ipc-client-BmypMNYk.mjs} +13 -7
  45. package/dist/ipc-client-BmypMNYk.mjs.map +1 -0
  46. package/dist/{kg-entity-r8duqhi9.mjs → kg-entity-DbOMPdF9.mjs} +1 -1
  47. package/dist/{kg-entity-r8duqhi9.mjs.map → kg-entity-DbOMPdF9.mjs.map} +1 -1
  48. package/dist/{latent-ideas-BL9m2HF9.mjs → latent-ideas-Bn6A5-5P.mjs} +4 -4
  49. package/dist/{latent-ideas-BL9m2HF9.mjs.map → latent-ideas-Bn6A5-5P.mjs.map} +1 -1
  50. package/dist/{link-boost-QFLrJwD6.mjs → link-boost-fYjUnxCN.mjs} +1 -1
  51. package/dist/{link-boost-QFLrJwD6.mjs.map → link-boost-fYjUnxCN.mjs.map} +1 -1
  52. package/dist/{main-resolver-CNSqU8wo.mjs → main-resolver-BAbhKpeX.mjs} +12 -14
  53. package/dist/main-resolver-BAbhKpeX.mjs.map +1 -0
  54. package/dist/main-resolver-Dxh444GO.mjs +4 -0
  55. package/dist/{migrate-fLD6rAdO.mjs → migrate-Cjzeefn9.mjs} +2 -2
  56. package/dist/{migrate-fLD6rAdO.mjs.map → migrate-Cjzeefn9.mjs.map} +1 -1
  57. package/dist/{neighborhood-BX89_nty.mjs → neighborhood-DpaEM991.mjs} +2 -2
  58. package/dist/{neighborhood-BX89_nty.mjs.map → neighborhood-DpaEM991.mjs.map} +1 -1
  59. package/dist/{note-context-d1wT_-GA.mjs → note-context-DrcY4cWm.mjs} +1 -1
  60. package/dist/{note-context-d1wT_-GA.mjs.map → note-context-DrcY4cWm.mjs.map} +1 -1
  61. package/dist/{pai-marker-B20KqhA8.mjs → pai-marker-CHtbJMwJ.mjs} +1 -1
  62. package/dist/{pai-marker-B20KqhA8.mjs.map → pai-marker-CHtbJMwJ.mjs.map} +1 -1
  63. package/dist/{postgres-CI3FjGq0.mjs → postgres-BVme6qX0.mjs} +2 -2
  64. package/dist/{postgres-CI3FjGq0.mjs.map → postgres-BVme6qX0.mjs.map} +1 -1
  65. package/dist/{pick-DatgS3Nk.mjs → program-BnMNFb4O.mjs} +1171 -223
  66. package/dist/program-BnMNFb4O.mjs.map +1 -0
  67. package/dist/query-feedback-BBMBp96K.mjs +3 -0
  68. package/dist/{query-feedback-D4U56Hz6.mjs → query-feedback-C1T6kS18.mjs} +2 -4
  69. package/dist/{query-feedback-D4U56Hz6.mjs.map → query-feedback-C1T6kS18.mjs.map} +1 -1
  70. package/dist/reranker-CwTCNsgA.mjs +3 -0
  71. package/dist/{reranker-CMNZcfVx.mjs → reranker-xPm04PXx.mjs} +2 -8
  72. package/dist/{reranker-CMNZcfVx.mjs.map → reranker-xPm04PXx.mjs.map} +1 -1
  73. package/dist/router-BMkOb62X.mjs +3 -0
  74. package/dist/{router-i9S19Usg.mjs → router-CsDm7HvK.mjs} +2 -4
  75. package/dist/{router-i9S19Usg.mjs.map → router-CsDm7HvK.mjs.map} +1 -1
  76. package/dist/{runtime-paths-B0P1TvUr.mjs → runtime-paths-rni52zHX.mjs} +1 -1
  77. package/dist/{runtime-paths-B0P1TvUr.mjs.map → runtime-paths-rni52zHX.mjs.map} +1 -1
  78. package/dist/search-CfPpJAWQ.mjs +4 -0
  79. package/dist/{search-C32zQ0V0.mjs → search-Rpk1cSBC.mjs} +4 -15
  80. package/dist/{search-C32zQ0V0.mjs.map → search-Rpk1cSBC.mjs.map} +1 -1
  81. package/dist/{sources-BDwN0B8i.mjs → sources-D8ZdNfvK.mjs} +2 -2
  82. package/dist/{sources-BDwN0B8i.mjs.map → sources-D8ZdNfvK.mjs.map} +1 -1
  83. package/dist/{sqlite-C6FHnMkn.mjs → sqlite-D1IaR8Am.mjs} +3 -3
  84. package/dist/{sqlite-C6FHnMkn.mjs.map → sqlite-D1IaR8Am.mjs.map} +1 -1
  85. package/dist/state-WaXhLr6R.mjs +70 -0
  86. package/dist/{state-DTvy-jRB.mjs.map → state-WaXhLr6R.mjs.map} +1 -1
  87. package/dist/state-qtmrBWCm.mjs +3 -0
  88. package/dist/{stop-words-BaMEGVeY.mjs → stop-words-Hfu8u22w.mjs} +1 -1
  89. package/dist/{stop-words-BaMEGVeY.mjs.map → stop-words-Hfu8u22w.mjs.map} +1 -1
  90. package/dist/{sync--BoxBBok.mjs → sync-BWbe8JTg.mjs} +3 -3
  91. package/dist/{sync--BoxBBok.mjs.map → sync-BWbe8JTg.mjs.map} +1 -1
  92. package/dist/{themes-BObEGMWn.mjs → themes-XPkj_bfP.mjs} +3 -3
  93. package/dist/{themes-BObEGMWn.mjs.map → themes-XPkj_bfP.mjs.map} +1 -1
  94. package/dist/tools-DEt6YPfc.mjs +5 -0
  95. package/dist/{tools-C1lCHerL.mjs → tools-ceiy7ANX.mjs} +28 -65
  96. package/dist/tools-ceiy7ANX.mjs.map +1 -0
  97. package/dist/{trace-h23JCcFD.mjs → trace-DfyGmMG_.mjs} +1 -1
  98. package/dist/{trace-h23JCcFD.mjs.map → trace-DfyGmMG_.mjs.map} +1 -1
  99. package/dist/{utils-BAxjW3j8.mjs → utils-9Err2RBW.mjs} +2 -22
  100. package/dist/{utils-BAxjW3j8.mjs.map → utils-9Err2RBW.mjs.map} +1 -1
  101. package/dist/utils-DhMex3Ox.mjs +3 -0
  102. package/dist/{vault-indexer-CUF9edbW.mjs → vault-indexer-CFvlPUMB.mjs} +2 -2
  103. package/dist/{vault-indexer-CUF9edbW.mjs.map → vault-indexer-CFvlPUMB.mjs.map} +1 -1
  104. package/dist/{work-queue-worker-BcDGAcF3.mjs → work-queue-worker-228XjABm.mjs} +234 -14
  105. package/dist/work-queue-worker-228XjABm.mjs.map +1 -0
  106. package/dist/work-queue-worker-LRA9Fj9z.mjs +11 -0
  107. package/dist/{zettelkasten-W-h8G2is.mjs → zettelkasten-CvjmMghT.mjs} +4 -4
  108. package/dist/{zettelkasten-W-h8G2is.mjs.map → zettelkasten-CvjmMghT.mjs.map} +1 -1
  109. package/package.json +1 -1
  110. package/src/hooks/ts/lib/context-fill.test.ts +515 -0
  111. package/src/hooks/ts/lib/context-fill.ts +585 -0
  112. package/src/hooks/ts/lib/context-handover-cache.ts +46 -0
  113. package/src/hooks/ts/lib/transcript-text.test.ts +125 -0
  114. package/src/hooks/ts/lib/transcript-text.ts +71 -0
  115. package/src/hooks/ts/pre-compact/context-compression-hook.ts +112 -30
  116. package/statusline-command.sh +16 -0
  117. package/dist/checkpoint-block-D3rm4dAJ.mjs.map +0 -1
  118. package/dist/daemon-B8L2f5vy.mjs.map +0 -1
  119. package/dist/ipc-client-aVKVERjJ.mjs.map +0 -1
  120. package/dist/main-resolver-CNSqU8wo.mjs.map +0 -1
  121. package/dist/pick-DatgS3Nk.mjs.map +0 -1
  122. package/dist/rolldown-runtime-95iHPtFO.mjs +0 -18
  123. package/dist/state-DTvy-jRB.mjs +0 -102
  124. package/dist/tools-C1lCHerL.mjs.map +0 -1
  125. package/dist/work-queue-worker-BcDGAcF3.mjs.map +0 -1
  126. /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
+ }
@@ -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
- /** Turn Claude content (string or content block array) into plain text. */
63
- function contentToText(content: unknown): string {
64
- if (typeof content === 'string') return content;
65
- if (Array.isArray(content)) {
66
- return content
67
- .map((c) => {
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
- if (!data.filesModified.includes(block.input.file_path)) {
179
- data.filesModified.push(block.input.file_path);
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
- const files = data.filesModified.slice(-10);
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 tokenCount = 0;
543
+ let ntfyTokenDisplay: string | null = null;
511
544
 
512
545
  if (hookInput?.transcript_path) {
513
546
  const stats = getTranscriptStats(hookInput.transcript_path);
514
- tokenCount = calculateSessionTokens(hookInput.transcript_path);
515
- const tokenDisplay = tokenCount > 1000
516
- ? `${Math.round(tokenCount / 1000)}k`
517
- : String(tokenCount);
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 = tokenCount > 0
691
- ? `Auto-pause: ~${Math.round(tokenCount / 1000)}k tokens`
772
+ const ntfyMessage = ntfyTokenDisplay && ntfyTokenDisplay !== 'unknown'
773
+ ? `Auto-pause: ~${ntfyTokenDisplay} tokens`
692
774
  : 'Context compressing';
693
775
  await sendNtfyNotification(ntfyMessage);
694
776
 
@@ -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'