@tekmidian/pai 0.5.6 → 0.6.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 (137) hide show
  1. package/ARCHITECTURE.md +72 -1
  2. package/README.md +107 -3
  3. package/dist/{auto-route-BG6I_4B1.mjs → auto-route-C-DrW6BL.mjs} +3 -3
  4. package/dist/{auto-route-BG6I_4B1.mjs.map → auto-route-C-DrW6BL.mjs.map} +1 -1
  5. package/dist/cli/index.mjs +1897 -1569
  6. package/dist/cli/index.mjs.map +1 -1
  7. package/dist/clusters-JIDQW65f.mjs +201 -0
  8. package/dist/clusters-JIDQW65f.mjs.map +1 -0
  9. package/dist/{config-Cf92lGX_.mjs → config-BuhHWyOK.mjs} +21 -6
  10. package/dist/config-BuhHWyOK.mjs.map +1 -0
  11. package/dist/daemon/index.mjs +12 -9
  12. package/dist/daemon/index.mjs.map +1 -1
  13. package/dist/{daemon-D9evGlgR.mjs → daemon-D3hYb5_C.mjs} +670 -219
  14. package/dist/daemon-D3hYb5_C.mjs.map +1 -0
  15. package/dist/daemon-mcp/index.mjs +4597 -4
  16. package/dist/daemon-mcp/index.mjs.map +1 -1
  17. package/dist/{db-4lSqLFb8.mjs → db-BtuN768f.mjs} +9 -2
  18. package/dist/db-BtuN768f.mjs.map +1 -0
  19. package/dist/db-DdUperSl.mjs +110 -0
  20. package/dist/db-DdUperSl.mjs.map +1 -0
  21. package/dist/{detect-BU3Nx_2L.mjs → detect-CdaA48EI.mjs} +1 -1
  22. package/dist/{detect-BU3Nx_2L.mjs.map → detect-CdaA48EI.mjs.map} +1 -1
  23. package/dist/{detector-Bp-2SM3x.mjs → detector-jGBuYQJM.mjs} +2 -2
  24. package/dist/{detector-Bp-2SM3x.mjs.map → detector-jGBuYQJM.mjs.map} +1 -1
  25. package/dist/{factory-Bzcy70G9.mjs → factory-Ygqe_bVZ.mjs} +7 -5
  26. package/dist/{factory-Bzcy70G9.mjs.map → factory-Ygqe_bVZ.mjs.map} +1 -1
  27. package/dist/helpers-BEST-4Gx.mjs +420 -0
  28. package/dist/helpers-BEST-4Gx.mjs.map +1 -0
  29. package/dist/hooks/capture-all-events.mjs +19 -4
  30. package/dist/hooks/capture-all-events.mjs.map +4 -4
  31. package/dist/hooks/capture-session-summary.mjs +38 -0
  32. package/dist/hooks/capture-session-summary.mjs.map +3 -3
  33. package/dist/hooks/cleanup-session-files.mjs +6 -12
  34. package/dist/hooks/cleanup-session-files.mjs.map +4 -4
  35. package/dist/hooks/context-compression-hook.mjs +105 -111
  36. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  37. package/dist/hooks/initialize-session.mjs +26 -17
  38. package/dist/hooks/initialize-session.mjs.map +4 -4
  39. package/dist/hooks/inject-observations.mjs +220 -0
  40. package/dist/hooks/inject-observations.mjs.map +7 -0
  41. package/dist/hooks/load-core-context.mjs +18 -2
  42. package/dist/hooks/load-core-context.mjs.map +4 -4
  43. package/dist/hooks/load-project-context.mjs +102 -97
  44. package/dist/hooks/load-project-context.mjs.map +4 -4
  45. package/dist/hooks/observe.mjs +354 -0
  46. package/dist/hooks/observe.mjs.map +7 -0
  47. package/dist/hooks/stop-hook.mjs +174 -90
  48. package/dist/hooks/stop-hook.mjs.map +4 -4
  49. package/dist/hooks/sync-todo-to-md.mjs +31 -33
  50. package/dist/hooks/sync-todo-to-md.mjs.map +4 -4
  51. package/dist/index.d.mts +32 -9
  52. package/dist/index.d.mts.map +1 -1
  53. package/dist/index.mjs +6 -9
  54. package/dist/indexer-D53l5d1U.mjs +1 -0
  55. package/dist/{indexer-backend-CIMXedqk.mjs → indexer-backend-jcJFsmB4.mjs} +37 -127
  56. package/dist/indexer-backend-jcJFsmB4.mjs.map +1 -0
  57. package/dist/{ipc-client-Bjg_a1dc.mjs → ipc-client-CoyUHPod.mjs} +2 -7
  58. package/dist/{ipc-client-Bjg_a1dc.mjs.map → ipc-client-CoyUHPod.mjs.map} +1 -1
  59. package/dist/latent-ideas-bTJo6Omd.mjs +191 -0
  60. package/dist/latent-ideas-bTJo6Omd.mjs.map +1 -0
  61. package/dist/neighborhood-BYYbEkUJ.mjs +135 -0
  62. package/dist/neighborhood-BYYbEkUJ.mjs.map +1 -0
  63. package/dist/note-context-BK24bX8Y.mjs +126 -0
  64. package/dist/note-context-BK24bX8Y.mjs.map +1 -0
  65. package/dist/postgres-CKf-EDtS.mjs +846 -0
  66. package/dist/postgres-CKf-EDtS.mjs.map +1 -0
  67. package/dist/{reranker-D7bRAHi6.mjs → reranker-CMNZcfVx.mjs} +1 -1
  68. package/dist/{reranker-D7bRAHi6.mjs.map → reranker-CMNZcfVx.mjs.map} +1 -1
  69. package/dist/{search-_oHfguA5.mjs → search-DC1qhkKn.mjs} +2 -58
  70. package/dist/search-DC1qhkKn.mjs.map +1 -0
  71. package/dist/{sqlite-WWBq7_2C.mjs → sqlite-l-s9xPjY.mjs} +160 -3
  72. package/dist/sqlite-l-s9xPjY.mjs.map +1 -0
  73. package/dist/state-C6_vqz7w.mjs +102 -0
  74. package/dist/state-C6_vqz7w.mjs.map +1 -0
  75. package/dist/stop-words-BaMEGVeY.mjs +326 -0
  76. package/dist/stop-words-BaMEGVeY.mjs.map +1 -0
  77. package/dist/{indexer-CMPOiY1r.mjs → sync-BOsnEj2-.mjs} +14 -216
  78. package/dist/sync-BOsnEj2-.mjs.map +1 -0
  79. package/dist/themes-BvYF0W8T.mjs +148 -0
  80. package/dist/themes-BvYF0W8T.mjs.map +1 -0
  81. package/dist/{tools-DV_lsiCc.mjs → tools-DcaJlYDN.mjs} +162 -273
  82. package/dist/tools-DcaJlYDN.mjs.map +1 -0
  83. package/dist/trace-CRx9lPuc.mjs +137 -0
  84. package/dist/trace-CRx9lPuc.mjs.map +1 -0
  85. package/dist/{vault-indexer-DXWs9pDn.mjs → vault-indexer-Bi2cRmn7.mjs} +174 -138
  86. package/dist/vault-indexer-Bi2cRmn7.mjs.map +1 -0
  87. package/dist/zettelkasten-cdajbnPr.mjs +708 -0
  88. package/dist/zettelkasten-cdajbnPr.mjs.map +1 -0
  89. package/package.json +1 -2
  90. package/src/hooks/ts/capture-all-events.ts +6 -0
  91. package/src/hooks/ts/lib/project-utils/index.ts +50 -0
  92. package/src/hooks/ts/lib/project-utils/notify.ts +75 -0
  93. package/src/hooks/ts/lib/project-utils/paths.ts +218 -0
  94. package/src/hooks/ts/lib/project-utils/session-notes.ts +363 -0
  95. package/src/hooks/ts/lib/project-utils/todo.ts +178 -0
  96. package/src/hooks/ts/lib/project-utils/tokens.ts +39 -0
  97. package/src/hooks/ts/lib/project-utils.ts +40 -999
  98. package/src/hooks/ts/post-tool-use/observe.ts +327 -0
  99. package/src/hooks/ts/pre-compact/context-compression-hook.ts +6 -0
  100. package/src/hooks/ts/session-end/capture-session-summary.ts +41 -0
  101. package/src/hooks/ts/session-start/initialize-session.ts +7 -1
  102. package/src/hooks/ts/session-start/inject-observations.ts +254 -0
  103. package/src/hooks/ts/session-start/load-core-context.ts +7 -0
  104. package/src/hooks/ts/session-start/load-project-context.ts +8 -1
  105. package/src/hooks/ts/stop/stop-hook.ts +28 -0
  106. package/templates/claude-md.template.md +7 -74
  107. package/templates/skills/user/.gitkeep +0 -0
  108. package/dist/chunker-CbnBe0s0.mjs +0 -191
  109. package/dist/chunker-CbnBe0s0.mjs.map +0 -1
  110. package/dist/config-Cf92lGX_.mjs.map +0 -1
  111. package/dist/daemon-D9evGlgR.mjs.map +0 -1
  112. package/dist/db-4lSqLFb8.mjs.map +0 -1
  113. package/dist/db-Dp8VXIMR.mjs +0 -212
  114. package/dist/db-Dp8VXIMR.mjs.map +0 -1
  115. package/dist/indexer-CMPOiY1r.mjs.map +0 -1
  116. package/dist/indexer-backend-CIMXedqk.mjs.map +0 -1
  117. package/dist/mcp/index.d.mts +0 -1
  118. package/dist/mcp/index.mjs +0 -500
  119. package/dist/mcp/index.mjs.map +0 -1
  120. package/dist/postgres-FXrHDPcE.mjs +0 -358
  121. package/dist/postgres-FXrHDPcE.mjs.map +0 -1
  122. package/dist/schemas-BFIgGntb.mjs +0 -3405
  123. package/dist/schemas-BFIgGntb.mjs.map +0 -1
  124. package/dist/search-_oHfguA5.mjs.map +0 -1
  125. package/dist/sqlite-WWBq7_2C.mjs.map +0 -1
  126. package/dist/tools-DV_lsiCc.mjs.map +0 -1
  127. package/dist/vault-indexer-DXWs9pDn.mjs.map +0 -1
  128. package/dist/zettelkasten-e-a4rW_6.mjs +0 -901
  129. package/dist/zettelkasten-e-a4rW_6.mjs.map +0 -1
  130. package/templates/README.md +0 -181
  131. package/templates/skills/createskill-skill.template.md +0 -78
  132. package/templates/skills/history-system.template.md +0 -371
  133. package/templates/skills/hook-system.template.md +0 -913
  134. package/templates/skills/sessions-skill.template.md +0 -102
  135. package/templates/skills/skill-system.template.md +0 -214
  136. package/templates/skills/terminal-tabs.template.md +0 -120
  137. package/templates/templates.md +0 -20
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PostToolUse Hook - Observation Capture
4
+ *
5
+ * Classifies each tool call as a structured observation and sends it to the
6
+ * PAI daemon via IPC. Fire-and-forget with a 5-second timeout. Never blocks
7
+ * Claude Code (always exits 0).
8
+ */
9
+
10
+ import { connect } from 'net';
11
+ import { sha256 } from '../../../utils/hash.js';
12
+ import { basename } from 'path';
13
+ import { isProbeSession } from '../lib/project-utils.js';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ interface HookData {
20
+ session_id: string;
21
+ tool_name: string;
22
+ tool_input: Record<string, unknown>;
23
+ tool_response?: unknown;
24
+ cwd?: string;
25
+ }
26
+
27
+ type ObservationType = 'change' | 'discovery' | 'decision' | 'feature';
28
+
29
+ interface Observation {
30
+ type: ObservationType;
31
+ title: string;
32
+ narrative: string;
33
+ tool_name: string;
34
+ tool_input_summary: string;
35
+ files_read: string[];
36
+ files_modified: string[];
37
+ concepts: string[];
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Tools to skip entirely
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const SKIP_TOOLS = new Set([
45
+ 'ToolSearch',
46
+ 'AskUserQuestion',
47
+ 'EnterPlanMode',
48
+ 'ExitPlanMode',
49
+ 'Skill',
50
+ ]);
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Inline IPC sender — avoids importing src/daemon/ipc-client.ts at build time
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function sendToDaemon(method: string, params: Record<string, unknown>): Promise<void> {
57
+ return new Promise((resolve) => {
58
+ const timeout = setTimeout(() => { resolve(); }, 5000);
59
+ try {
60
+ const socket = connect('/tmp/pai.sock', () => {
61
+ const req = JSON.stringify({ id: Date.now().toString(), method, params }) + '\n';
62
+ socket.write(req);
63
+ socket.on('data', () => { clearTimeout(timeout); socket.destroy(); resolve(); });
64
+ socket.on('error', () => { clearTimeout(timeout); resolve(); });
65
+ });
66
+ socket.on('error', () => { clearTimeout(timeout); resolve(); });
67
+ } catch {
68
+ clearTimeout(timeout);
69
+ resolve();
70
+ }
71
+ });
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Concept extraction from file paths
76
+ // ---------------------------------------------------------------------------
77
+
78
+ const SKIP_SEGMENTS = new Set([
79
+ 'src', 'dist', 'lib', 'bin', 'test', 'tests', 'spec', 'specs',
80
+ 'node_modules', 'Users', 'home', 'usr', 'var', 'tmp', 'etc',
81
+ 'hooks', 'scripts', 'config', 'configs', 'assets', 'static',
82
+ 'public', 'private', 'build', 'out', 'output', 'generated',
83
+ 'ts', 'js', 'mjs', 'cjs',
84
+ ]);
85
+
86
+ function extractConcepts(paths: string[]): string[] {
87
+ const concepts = new Set<string>();
88
+ for (const p of paths) {
89
+ const segments = p.split('/').filter(Boolean);
90
+ for (const seg of segments) {
91
+ // Drop extensions
92
+ const clean = seg.replace(/\.[^.]+$/, '');
93
+ if (clean.length > 2 && !SKIP_SEGMENTS.has(clean) && !/^\d+$/.test(clean)) {
94
+ concepts.add(clean);
95
+ }
96
+ }
97
+ }
98
+ return Array.from(concepts).slice(0, 10);
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Inline classifier
103
+ // ---------------------------------------------------------------------------
104
+
105
+ function str(v: unknown): string {
106
+ if (typeof v === 'string') return v;
107
+ return '';
108
+ }
109
+
110
+ function truncate(s: string, len: number): string {
111
+ return s.length > len ? s.slice(0, len) : s;
112
+ }
113
+
114
+ function classify(toolName: string, toolInput: Record<string, unknown>): Observation | null {
115
+ if (SKIP_TOOLS.has(toolName)) return null;
116
+
117
+ let type: ObservationType = 'discovery';
118
+ let title = '';
119
+ let narrative = '';
120
+ let tool_input_summary = '';
121
+ const files_read: string[] = [];
122
+ const files_modified: string[] = [];
123
+
124
+ switch (toolName) {
125
+ case 'Edit':
126
+ case 'MultiEdit': {
127
+ const fp = str(toolInput.file_path);
128
+ const name = fp ? basename(fp) : 'file';
129
+ type = 'change';
130
+ title = `Modified ${name}`;
131
+ narrative = fp ? `Edited ${fp}` : 'Edited a file';
132
+ tool_input_summary = fp;
133
+ files_modified.push(...(fp ? [fp] : []));
134
+ break;
135
+ }
136
+
137
+ case 'Write':
138
+ case 'NotebookEdit': {
139
+ const fp = str(toolInput.file_path);
140
+ const name = fp ? basename(fp) : 'file';
141
+ type = 'change';
142
+ title = `Created ${name}`;
143
+ narrative = fp ? `Wrote ${fp}` : 'Wrote a file';
144
+ tool_input_summary = fp;
145
+ files_modified.push(...(fp ? [fp] : []));
146
+ break;
147
+ }
148
+
149
+ case 'Read': {
150
+ const fp = str(toolInput.file_path);
151
+ const name = fp ? basename(fp) : 'file';
152
+ type = 'discovery';
153
+ title = `Read ${name}`;
154
+ narrative = fp ? `Read ${fp}` : 'Read a file';
155
+ tool_input_summary = fp;
156
+ files_read.push(...(fp ? [fp] : []));
157
+ break;
158
+ }
159
+
160
+ case 'Grep': {
161
+ const pattern = str(toolInput.pattern);
162
+ type = 'discovery';
163
+ title = `Searched for '${truncate(pattern, 40)}'`;
164
+ narrative = `Grep search: ${pattern}`;
165
+ tool_input_summary = pattern;
166
+ const gPath = str(toolInput.path || toolInput.file_path);
167
+ if (gPath) files_read.push(gPath);
168
+ break;
169
+ }
170
+
171
+ case 'Glob': {
172
+ const pattern = str(toolInput.pattern);
173
+ type = 'discovery';
174
+ title = `Found files: ${truncate(pattern, 40)}`;
175
+ narrative = `Glob pattern: ${pattern}`;
176
+ tool_input_summary = pattern;
177
+ break;
178
+ }
179
+
180
+ case 'Bash': {
181
+ const cmd = str(toolInput.command);
182
+ const cmdLower = cmd.toLowerCase();
183
+
184
+ if (/git\s+commit/.test(cmdLower)) {
185
+ type = 'decision';
186
+ // Try to extract the commit message after -m "..."
187
+ const mMatch = cmd.match(/-m\s+["']([^"']+)/);
188
+ const msg = mMatch ? mMatch[1] : cmd;
189
+ title = `Committed: ${truncate(msg, 60)}`;
190
+ narrative = `Git commit: ${msg}`;
191
+ tool_input_summary = truncate(cmd, 120);
192
+ } else if (/git\s+push/.test(cmdLower)) {
193
+ type = 'decision';
194
+ title = 'Pushed to remote';
195
+ narrative = `Git push: ${truncate(cmd, 80)}`;
196
+ tool_input_summary = truncate(cmd, 120);
197
+ } else if (/\b(jest|vitest|pytest|bun\s+test|npm\s+test|yarn\s+test|pnpm\s+test|node\s+--test)\b/.test(cmdLower)) {
198
+ type = 'feature';
199
+ title = 'Ran tests';
200
+ narrative = `Test run: ${truncate(cmd, 80)}`;
201
+ tool_input_summary = truncate(cmd, 120);
202
+ } else if (/\b(build|compile|bun\s+run\s+build|tsc|esbuild|webpack|vite\s+build)\b/.test(cmdLower)) {
203
+ type = 'feature';
204
+ title = 'Built project';
205
+ narrative = `Build: ${truncate(cmd, 80)}`;
206
+ tool_input_summary = truncate(cmd, 120);
207
+ } else {
208
+ type = 'discovery';
209
+ title = `Ran: ${truncate(cmd, 60)}`;
210
+ narrative = `Bash: ${truncate(cmd, 120)}`;
211
+ tool_input_summary = truncate(cmd, 120);
212
+ }
213
+ break;
214
+ }
215
+
216
+ case 'Task': {
217
+ const prompt = str(toolInput.prompt || toolInput.description);
218
+ type = 'discovery';
219
+ title = `Delegated: ${truncate(prompt, 60)}`;
220
+ narrative = `Spawned agent: ${truncate(prompt, 200)}`;
221
+ tool_input_summary = truncate(prompt, 200);
222
+ break;
223
+ }
224
+
225
+ case 'WebFetch': {
226
+ const url = str(toolInput.url);
227
+ type = 'discovery';
228
+ title = `Fetched: ${truncate(url, 60)}`;
229
+ narrative = `Web fetch: ${url}`;
230
+ tool_input_summary = url;
231
+ break;
232
+ }
233
+
234
+ case 'WebSearch': {
235
+ const query = str(toolInput.query);
236
+ type = 'discovery';
237
+ title = `Searched web: ${truncate(query, 60)}`;
238
+ narrative = `Web search: ${query}`;
239
+ tool_input_summary = query;
240
+ break;
241
+ }
242
+
243
+ default: {
244
+ // mcp__* tools and anything else
245
+ if (toolName.startsWith('mcp__')) {
246
+ type = 'discovery';
247
+ title = `MCP: ${toolName}`;
248
+ narrative = `Called MCP tool ${toolName}`;
249
+ tool_input_summary = toolName;
250
+ } else {
251
+ // Generic fallback — still capture rather than skip
252
+ type = 'discovery';
253
+ title = `Tool: ${toolName}`;
254
+ narrative = `Called ${toolName}`;
255
+ tool_input_summary = JSON.stringify(toolInput).slice(0, 120);
256
+ }
257
+ break;
258
+ }
259
+ }
260
+
261
+ const allPaths = [...files_read, ...files_modified];
262
+ const concepts = extractConcepts(allPaths);
263
+
264
+ return {
265
+ type,
266
+ title,
267
+ narrative,
268
+ tool_name: toolName,
269
+ tool_input_summary,
270
+ files_read,
271
+ files_modified,
272
+ concepts,
273
+ };
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Main
278
+ // ---------------------------------------------------------------------------
279
+
280
+ async function main() {
281
+ try {
282
+ // Read stdin
283
+ const chunks: Buffer[] = [];
284
+ for await (const chunk of process.stdin) {
285
+ chunks.push(chunk as Buffer);
286
+ }
287
+ const raw = Buffer.concat(chunks).toString('utf-8').trim();
288
+ if (!raw) process.exit(0);
289
+
290
+ const hookData: HookData = JSON.parse(raw);
291
+
292
+ // Skip probe/health-check sessions
293
+ if (isProbeSession(hookData.cwd)) process.exit(0);
294
+
295
+ // Skip uninteresting tools
296
+ if (SKIP_TOOLS.has(hookData.tool_name)) process.exit(0);
297
+
298
+ // Classify
299
+ const obs = classify(hookData.tool_name, hookData.tool_input);
300
+ if (!obs) process.exit(0);
301
+
302
+ // Content-hash dedup key
303
+ const hash = sha256(hookData.session_id + hookData.tool_name + obs.title).slice(0, 16);
304
+
305
+ // Fire-and-forget to daemon
306
+ await sendToDaemon('observation_store', {
307
+ session_id: hookData.session_id,
308
+ type: obs.type,
309
+ title: obs.title,
310
+ narrative: obs.narrative,
311
+ tool_name: obs.tool_name,
312
+ tool_input_summary: obs.tool_input_summary,
313
+ files_read: obs.files_read,
314
+ files_modified: obs.files_modified,
315
+ concepts: obs.concepts,
316
+ content_hash: hash,
317
+ cwd: hookData.cwd ?? '',
318
+ });
319
+
320
+ process.exit(0);
321
+ } catch {
322
+ // Never block Claude Code
323
+ process.exit(0);
324
+ }
325
+ }
326
+
327
+ main();
@@ -26,6 +26,7 @@ import {
26
26
  renameSessionNote,
27
27
  updateTodoContinue,
28
28
  calculateSessionTokens,
29
+ isProbeSession,
29
30
  WorkItem,
30
31
  } from '../lib/project-utils';
31
32
 
@@ -342,6 +343,11 @@ function saveCumulativeState(notesDir: string, data: TranscriptData, notePath: s
342
343
  // ---------------------------------------------------------------------------
343
344
 
344
345
  async function main() {
346
+ // Skip probe/health-check sessions (e.g. CodexBar ClaudeProbe)
347
+ if (isProbeSession()) {
348
+ process.exit(0);
349
+ }
350
+
345
351
  let hookInput: HookInput | null = null;
346
352
 
347
353
  try {
@@ -58,6 +58,9 @@ async function main() {
58
58
  // Write session file
59
59
  writeFileSync(join(sessionDir, filename), sessionDoc);
60
60
 
61
+ // Also store structured summary via daemon IPC for the observations system
62
+ await storeStructuredSummary(data.conversation_id, sessionInfo);
63
+
61
64
  // Exit successfully
62
65
  process.exit(0);
63
66
  } catch (error) {
@@ -182,4 +185,42 @@ For detailed tool outputs, see: \`\${PAI_DIR}/History/raw-outputs/${timestamp.su
182
185
  `;
183
186
  }
184
187
 
188
+ async function storeStructuredSummary(
189
+ sessionId: string,
190
+ info: { focus: string; filesChanged: string[]; commandsExecuted: string[]; toolsUsed: string[]; duration: number }
191
+ ): Promise<void> {
192
+ try {
193
+ const cwd = process.cwd();
194
+ const net = await import('net');
195
+
196
+ await new Promise<void>((resolve, _reject) => {
197
+ const client = net.createConnection('/tmp/pai.sock', () => {
198
+ const msg = JSON.stringify({
199
+ id: 1,
200
+ method: 'session_summary_store',
201
+ params: {
202
+ session_id: sessionId,
203
+ cwd,
204
+ request: null, // We don't have the original request
205
+ investigated: null,
206
+ learned: null,
207
+ completed: info.filesChanged.length > 0
208
+ ? `Modified ${info.filesChanged.length} file(s): ${info.filesChanged.slice(0, 5).join(', ')}`
209
+ : null,
210
+ next_steps: null,
211
+ observation_count: 0, // Will be filled by daemon from actual count
212
+ }
213
+ }) + '\n';
214
+ client.write(msg);
215
+ });
216
+
217
+ client.on('data', () => { client.end(); resolve(); });
218
+ client.on('error', () => resolve()); // Silent failure
219
+ setTimeout(() => { client.destroy(); resolve(); }, 3000);
220
+ });
221
+ } catch {
222
+ // Silent failure — don't disrupt session end
223
+ }
224
+ }
225
+
185
226
  main();
@@ -24,7 +24,7 @@ import { existsSync, statSync, readFileSync, writeFileSync } from 'fs';
24
24
  import { join } from 'path';
25
25
  import { tmpdir } from 'os';
26
26
  import { PAI_DIR } from '../lib/pai-paths';
27
- import { sendNtfyNotification, isWhatsAppEnabled } from '../lib/project-utils';
27
+ import { sendNtfyNotification, isWhatsAppEnabled, isProbeSession } from '../lib/project-utils';
28
28
 
29
29
  // Debounce duration in milliseconds (prevents duplicate SessionStart events)
30
30
  const DEBOUNCE_MS = 2000;
@@ -111,6 +111,12 @@ async function main() {
111
111
  process.exit(0);
112
112
  }
113
113
 
114
+ // Skip probe/health-check sessions (e.g. CodexBar ClaudeProbe)
115
+ if (isProbeSession()) {
116
+ console.error('Probe session detected - skipping session initialization');
117
+ process.exit(0);
118
+ }
119
+
114
120
  // Check debounce to prevent duplicate notifications
115
121
  // (IDE extension can fire multiple SessionStart events)
116
122
  if (shouldDebounce()) {
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * inject-observations.ts
5
+ *
6
+ * SessionStart hook that injects recent project observation context into Claude's
7
+ * session as a <system-reminder>. Provides progressive disclosure of recent activity
8
+ * so Claude has immediate awareness of what has been happening in this project.
9
+ *
10
+ * Flow:
11
+ * 1. Read session data (session_id, cwd) from stdin
12
+ * 2. Call daemon via IPC: observation_recent with { cwd, limit: 25 }
13
+ * (daemon resolves project_id from cwd internally via registry lookup)
14
+ * 3. Format as progressive disclosure context block
15
+ * 4. Output to stdout as <system-reminder> (injected into session by Claude Code)
16
+ *
17
+ * Silent on any failure — never blocks session start.
18
+ */
19
+
20
+ import { connect } from 'net';
21
+ import { randomUUID } from 'crypto';
22
+ import { isProbeSession } from '../lib/project-utils.js';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Types
26
+ // ---------------------------------------------------------------------------
27
+
28
+ interface HookData {
29
+ session_id?: string;
30
+ cwd?: string;
31
+ hook_event_name?: string;
32
+ }
33
+
34
+ interface ObservationRow {
35
+ id: number;
36
+ session_id: string;
37
+ project_id: number | null;
38
+ project_slug: string | null;
39
+ type: string;
40
+ title: string;
41
+ narrative: string | null;
42
+ created_at: string; // ISO string after JSON serialization
43
+ }
44
+
45
+ interface ObservationRecentResult {
46
+ rows: ObservationRow[];
47
+ project_slug?: string;
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Inline IPC client — mirrors the pattern in observe.ts
52
+ // Hooks can't import from src/daemon/ at runtime, so we inline this.
53
+ // ---------------------------------------------------------------------------
54
+
55
+ async function callDaemon(method: string, params: Record<string, unknown>): Promise<unknown> {
56
+ return new Promise((resolve, reject) => {
57
+ const timeout = setTimeout(() => reject(new Error('timeout')), 5000);
58
+ let buffer = '';
59
+
60
+ try {
61
+ const socket = connect('/tmp/pai.sock', () => {
62
+ socket.write(JSON.stringify({ id: randomUUID(), method, params }) + '\n');
63
+ });
64
+
65
+ socket.on('data', (chunk: Buffer) => {
66
+ buffer += chunk.toString();
67
+ const nl = buffer.indexOf('\n');
68
+ if (nl !== -1) {
69
+ clearTimeout(timeout);
70
+ try {
71
+ const response = JSON.parse(buffer.slice(0, nl));
72
+ socket.destroy();
73
+ if (response.ok) resolve(response.result);
74
+ else reject(new Error(response.error ?? 'daemon error'));
75
+ } catch (e) {
76
+ socket.destroy();
77
+ reject(e);
78
+ }
79
+ }
80
+ });
81
+
82
+ socket.on('error', () => {
83
+ clearTimeout(timeout);
84
+ reject(new Error('daemon unavailable'));
85
+ });
86
+ } catch {
87
+ clearTimeout(timeout);
88
+ reject(new Error('daemon unavailable'));
89
+ }
90
+ });
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Time formatting
95
+ // ---------------------------------------------------------------------------
96
+
97
+ function timeAgo(date: Date): string {
98
+ const diffMs = Date.now() - date.getTime();
99
+ const diffSec = Math.floor(diffMs / 1000);
100
+ const diffMin = Math.floor(diffSec / 60);
101
+ const diffHour = Math.floor(diffMin / 60);
102
+ const diffDay = Math.floor(diffHour / 24);
103
+
104
+ if (diffSec < 60) return 'just now';
105
+ if (diffMin < 60) return `${diffMin}m ago`;
106
+ if (diffHour < 24) return `${diffHour}h ago`;
107
+ if (diffDay < 7) return `${diffDay}d ago`;
108
+
109
+ // Older than a week: show date string
110
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Type label
115
+ // ---------------------------------------------------------------------------
116
+
117
+ function typeLabel(type: string): string {
118
+ switch (type) {
119
+ case 'change': return '[change]';
120
+ case 'discovery': return '[discovery]';
121
+ case 'decision': return '[decision]';
122
+ case 'bugfix': return '[bugfix]';
123
+ case 'feature': return '[feature]';
124
+ case 'refactor': return '[refactor]';
125
+ default: return `[${type}]`;
126
+ }
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Truncate
131
+ // ---------------------------------------------------------------------------
132
+
133
+ function truncate(s: string, maxLen: number): string {
134
+ return s.length > maxLen ? s.slice(0, maxLen - 1) + '\u2026' : s;
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Format observations as progressive disclosure context
139
+ // ---------------------------------------------------------------------------
140
+
141
+ function formatContext(
142
+ projectSlug: string,
143
+ observations: ObservationRow[]
144
+ ): string {
145
+ if (observations.length === 0) return '';
146
+
147
+ // Count distinct sessions
148
+ const sessionSet = new Set(observations.map(o => o.session_id));
149
+ const sessionCount = sessionSet.size;
150
+
151
+ // Most recent observation
152
+ const newest = new Date(observations[0].created_at);
153
+ const lastActivity = timeAgo(newest);
154
+
155
+ // Timeline: show most recent 15, keep titles to 80 chars
156
+ const timelineObs = observations.slice(0, 15);
157
+ const timeline = timelineObs
158
+ .map(o => {
159
+ const t = timeAgo(new Date(o.created_at));
160
+ const label = typeLabel(o.type);
161
+ const title = truncate(o.title, 80);
162
+ return `- [${t}] ${label} ${title}`;
163
+ })
164
+ .join('\n');
165
+
166
+ const showingNote = observations.length > 15
167
+ ? `(showing most recent 15 of ${observations.length}, use observation_search for more)`
168
+ : `(showing ${observations.length} observation${observations.length !== 1 ? 's' : ''})`;
169
+
170
+ const lines: string[] = [
171
+ `<system-reminder>`,
172
+ `OBSERVATION CONTEXT (auto-injected)`,
173
+ ``,
174
+ `## Recent Activity (${projectSlug})`,
175
+ `${observations.length} observations across ${sessionCount} session${sessionCount !== 1 ? 's' : ''} | Last activity: ${lastActivity}`,
176
+ ``,
177
+ `### Recent Timeline`,
178
+ timeline,
179
+ showingNote,
180
+ `</system-reminder>`,
181
+ ];
182
+
183
+ return lines.join('\n');
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Main
188
+ // ---------------------------------------------------------------------------
189
+
190
+ async function main() {
191
+ try {
192
+ // Skip probe/health-check sessions
193
+ if (isProbeSession()) {
194
+ process.exit(0);
195
+ }
196
+
197
+ // Skip subagent sessions — they don't need observation context
198
+ const claudeProjectDir = process.env.CLAUDE_PROJECT_DIR || '';
199
+ const isSubagent = claudeProjectDir.includes('/.claude/agents/') ||
200
+ process.env.CLAUDE_AGENT_TYPE !== undefined;
201
+ if (isSubagent) {
202
+ process.exit(0);
203
+ }
204
+
205
+ // Read hook data from stdin
206
+ let hookData: HookData = {};
207
+ try {
208
+ const chunks: Buffer[] = [];
209
+ for await (const chunk of process.stdin) {
210
+ chunks.push(chunk as Buffer);
211
+ }
212
+ const raw = Buffer.concat(chunks).toString('utf-8').trim();
213
+ if (raw) {
214
+ hookData = JSON.parse(raw);
215
+ }
216
+ } catch {
217
+ // Non-fatal — fall back to process.cwd()
218
+ }
219
+
220
+ const cwd = hookData.cwd || process.cwd();
221
+
222
+ // Fetch recent observations for this cwd — daemon resolves the project internally
223
+ let observations: ObservationRow[];
224
+ let projectSlug: string;
225
+
226
+ try {
227
+ const result = await callDaemon('observation_recent', { cwd, limit: 25 }) as ObservationRecentResult;
228
+ observations = result?.rows ?? [];
229
+ projectSlug = result?.project_slug ?? '';
230
+ } catch {
231
+ // Daemon unavailable or Postgres not configured — silent exit
232
+ process.exit(0);
233
+ }
234
+
235
+ if (!observations || observations.length === 0 || !projectSlug) {
236
+ // No data or no matching project — nothing to inject
237
+ process.exit(0);
238
+ }
239
+
240
+ // Format and output
241
+ const context = formatContext(projectSlug, observations);
242
+ if (context) {
243
+ // Output to stdout — Claude Code captures this and injects into session context
244
+ console.log(context);
245
+ }
246
+
247
+ process.exit(0);
248
+ } catch {
249
+ // Never block session start
250
+ process.exit(0);
251
+ }
252
+ }
253
+
254
+ main();
@@ -28,6 +28,7 @@
28
28
  import { readFileSync, existsSync } from 'fs';
29
29
  import { join } from 'path';
30
30
  import { PAI_DIR, SKILLS_DIR } from '../lib/pai-paths';
31
+ import { isProbeSession } from '../lib/project-utils';
31
32
 
32
33
  async function main() {
33
34
  try {
@@ -42,6 +43,12 @@ async function main() {
42
43
  process.exit(0);
43
44
  }
44
45
 
46
+ // Skip probe/health-check sessions (e.g. CodexBar ClaudeProbe)
47
+ if (isProbeSession()) {
48
+ console.error('Probe session detected - skipping CORE context loading');
49
+ process.exit(0);
50
+ }
51
+
45
52
  // Get CORE skill path using PAI paths library
46
53
  const coreSkillPath = join(SKILLS_DIR, 'CORE/SKILL.md');
47
54