@tekmidian/pai 0.5.7 → 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 +87 -1
  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 +1482 -1628
  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 +11 -8
  12. package/dist/daemon/index.mjs.map +1 -1
  13. package/dist/{daemon-2ND5WO2j.mjs → daemon-D3hYb5_C.mjs} +669 -218
  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-DdUperSl.mjs +110 -0
  18. package/dist/db-DdUperSl.mjs.map +1 -0
  19. package/dist/{detect-BU3Nx_2L.mjs → detect-CdaA48EI.mjs} +1 -1
  20. package/dist/{detect-BU3Nx_2L.mjs.map → detect-CdaA48EI.mjs.map} +1 -1
  21. package/dist/{detector-Bp-2SM3x.mjs → detector-jGBuYQJM.mjs} +2 -2
  22. package/dist/{detector-Bp-2SM3x.mjs.map → detector-jGBuYQJM.mjs.map} +1 -1
  23. package/dist/{factory-Bzcy70G9.mjs → factory-Ygqe_bVZ.mjs} +7 -5
  24. package/dist/{factory-Bzcy70G9.mjs.map → factory-Ygqe_bVZ.mjs.map} +1 -1
  25. package/dist/helpers-BEST-4Gx.mjs +420 -0
  26. package/dist/helpers-BEST-4Gx.mjs.map +1 -0
  27. package/dist/hooks/capture-all-events.mjs +2 -2
  28. package/dist/hooks/capture-all-events.mjs.map +3 -3
  29. package/dist/hooks/capture-session-summary.mjs +38 -0
  30. package/dist/hooks/capture-session-summary.mjs.map +3 -3
  31. package/dist/hooks/cleanup-session-files.mjs +6 -12
  32. package/dist/hooks/cleanup-session-files.mjs.map +4 -4
  33. package/dist/hooks/context-compression-hook.mjs +93 -104
  34. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  35. package/dist/hooks/initialize-session.mjs +14 -11
  36. package/dist/hooks/initialize-session.mjs.map +4 -4
  37. package/dist/hooks/inject-observations.mjs +220 -0
  38. package/dist/hooks/inject-observations.mjs.map +7 -0
  39. package/dist/hooks/load-core-context.mjs +2 -2
  40. package/dist/hooks/load-core-context.mjs.map +3 -3
  41. package/dist/hooks/load-project-context.mjs +90 -91
  42. package/dist/hooks/load-project-context.mjs.map +4 -4
  43. package/dist/hooks/observe.mjs +354 -0
  44. package/dist/hooks/observe.mjs.map +7 -0
  45. package/dist/hooks/stop-hook.mjs +94 -107
  46. package/dist/hooks/stop-hook.mjs.map +4 -4
  47. package/dist/hooks/sync-todo-to-md.mjs +31 -33
  48. package/dist/hooks/sync-todo-to-md.mjs.map +4 -4
  49. package/dist/index.d.mts +30 -7
  50. package/dist/index.d.mts.map +1 -1
  51. package/dist/index.mjs +5 -8
  52. package/dist/indexer-D53l5d1U.mjs +1 -0
  53. package/dist/{indexer-backend-CIMXedqk.mjs → indexer-backend-jcJFsmB4.mjs} +37 -127
  54. package/dist/indexer-backend-jcJFsmB4.mjs.map +1 -0
  55. package/dist/{ipc-client-Bjg_a1dc.mjs → ipc-client-CoyUHPod.mjs} +2 -7
  56. package/dist/{ipc-client-Bjg_a1dc.mjs.map → ipc-client-CoyUHPod.mjs.map} +1 -1
  57. package/dist/latent-ideas-bTJo6Omd.mjs +191 -0
  58. package/dist/latent-ideas-bTJo6Omd.mjs.map +1 -0
  59. package/dist/neighborhood-BYYbEkUJ.mjs +135 -0
  60. package/dist/neighborhood-BYYbEkUJ.mjs.map +1 -0
  61. package/dist/note-context-BK24bX8Y.mjs +126 -0
  62. package/dist/note-context-BK24bX8Y.mjs.map +1 -0
  63. package/dist/postgres-CKf-EDtS.mjs +846 -0
  64. package/dist/postgres-CKf-EDtS.mjs.map +1 -0
  65. package/dist/{reranker-D7bRAHi6.mjs → reranker-CMNZcfVx.mjs} +1 -1
  66. package/dist/{reranker-D7bRAHi6.mjs.map → reranker-CMNZcfVx.mjs.map} +1 -1
  67. package/dist/{search-_oHfguA5.mjs → search-DC1qhkKn.mjs} +2 -58
  68. package/dist/search-DC1qhkKn.mjs.map +1 -0
  69. package/dist/{sqlite-WWBq7_2C.mjs → sqlite-l-s9xPjY.mjs} +160 -3
  70. package/dist/sqlite-l-s9xPjY.mjs.map +1 -0
  71. package/dist/state-C6_vqz7w.mjs +102 -0
  72. package/dist/state-C6_vqz7w.mjs.map +1 -0
  73. package/dist/stop-words-BaMEGVeY.mjs +326 -0
  74. package/dist/stop-words-BaMEGVeY.mjs.map +1 -0
  75. package/dist/{indexer-CMPOiY1r.mjs → sync-BOsnEj2-.mjs} +14 -216
  76. package/dist/sync-BOsnEj2-.mjs.map +1 -0
  77. package/dist/themes-BvYF0W8T.mjs +148 -0
  78. package/dist/themes-BvYF0W8T.mjs.map +1 -0
  79. package/dist/{tools-DV_lsiCc.mjs → tools-DcaJlYDN.mjs} +162 -273
  80. package/dist/tools-DcaJlYDN.mjs.map +1 -0
  81. package/dist/trace-CRx9lPuc.mjs +137 -0
  82. package/dist/trace-CRx9lPuc.mjs.map +1 -0
  83. package/dist/{vault-indexer-k-kUlaZ-.mjs → vault-indexer-Bi2cRmn7.mjs} +134 -132
  84. package/dist/vault-indexer-Bi2cRmn7.mjs.map +1 -0
  85. package/dist/zettelkasten-cdajbnPr.mjs +708 -0
  86. package/dist/zettelkasten-cdajbnPr.mjs.map +1 -0
  87. package/package.json +1 -2
  88. package/src/hooks/ts/lib/project-utils/index.ts +50 -0
  89. package/src/hooks/ts/lib/project-utils/notify.ts +75 -0
  90. package/src/hooks/ts/lib/project-utils/paths.ts +218 -0
  91. package/src/hooks/ts/lib/project-utils/session-notes.ts +363 -0
  92. package/src/hooks/ts/lib/project-utils/todo.ts +178 -0
  93. package/src/hooks/ts/lib/project-utils/tokens.ts +39 -0
  94. package/src/hooks/ts/lib/project-utils.ts +40 -1018
  95. package/src/hooks/ts/post-tool-use/observe.ts +327 -0
  96. package/src/hooks/ts/session-end/capture-session-summary.ts +41 -0
  97. package/src/hooks/ts/session-start/inject-observations.ts +254 -0
  98. package/dist/chunker-CbnBe0s0.mjs +0 -191
  99. package/dist/chunker-CbnBe0s0.mjs.map +0 -1
  100. package/dist/config-Cf92lGX_.mjs.map +0 -1
  101. package/dist/daemon-2ND5WO2j.mjs.map +0 -1
  102. package/dist/db-Dp8VXIMR.mjs +0 -212
  103. package/dist/db-Dp8VXIMR.mjs.map +0 -1
  104. package/dist/indexer-CMPOiY1r.mjs.map +0 -1
  105. package/dist/indexer-backend-CIMXedqk.mjs.map +0 -1
  106. package/dist/mcp/index.d.mts +0 -1
  107. package/dist/mcp/index.mjs +0 -500
  108. package/dist/mcp/index.mjs.map +0 -1
  109. package/dist/postgres-FXrHDPcE.mjs +0 -358
  110. package/dist/postgres-FXrHDPcE.mjs.map +0 -1
  111. package/dist/schemas-BFIgGntb.mjs +0 -3405
  112. package/dist/schemas-BFIgGntb.mjs.map +0 -1
  113. package/dist/search-_oHfguA5.mjs.map +0 -1
  114. package/dist/sqlite-WWBq7_2C.mjs.map +0 -1
  115. package/dist/tools-DV_lsiCc.mjs.map +0 -1
  116. package/dist/vault-indexer-k-kUlaZ-.mjs.map +0 -1
  117. package/dist/zettelkasten-e-a4rW_6.mjs +0 -901
  118. package/dist/zettelkasten-e-a4rW_6.mjs.map +0 -1
  119. package/templates/README.md +0 -181
  120. package/templates/skills/CORE/Aesthetic.md +0 -333
  121. package/templates/skills/CORE/CONSTITUTION.md +0 -1502
  122. package/templates/skills/CORE/HistorySystem.md +0 -427
  123. package/templates/skills/CORE/HookSystem.md +0 -1082
  124. package/templates/skills/CORE/Prompting.md +0 -509
  125. package/templates/skills/CORE/ProsodyAgentTemplate.md +0 -53
  126. package/templates/skills/CORE/ProsodyGuide.md +0 -416
  127. package/templates/skills/CORE/SKILL.md +0 -741
  128. package/templates/skills/CORE/SkillSystem.md +0 -213
  129. package/templates/skills/CORE/TerminalTabs.md +0 -119
  130. package/templates/skills/CORE/VOICE.md +0 -106
  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();
@@ -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();
@@ -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();