greprag 0.3.0 → 0.5.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/hook.js CHANGED
@@ -1,13 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
- /** GrepRAG Hook — thin HTTP pipe for Claude Code hooks.
3
+ /** GrepRAG Hook — Stop-hook turn capture for episodic memory.
4
4
  *
5
- * Subcommands: retrieve, store, compact, signal, summary, recover, farewell, recap, handoff
6
- *
7
- * Lifecycle:
8
- * recap(SessionStart:startup) → retrieve → [agent turn] → store → farewell(SessionEnd)
9
- * summary(PreCompact) → compact → recover(SessionStart:compact)
10
- * PostToolUse(ExitPlanMode | AskUserQuestion) → signal */
5
+ * On Stop, build the per-turn envelope from the transcript and POST to
6
+ * /v1/memory/turn. No LLM, no enrichment — the API just inserts the row.
7
+ * See C:/greprag/docs/episodic-memory.md.
8
+ */
11
9
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
12
10
  if (k2 === undefined) k2 = k;
13
11
  var desc = Object.getOwnPropertyDescriptor(m, k);
@@ -44,55 +42,12 @@ var __importStar = (this && this.__importStar) || (function () {
44
42
  Object.defineProperty(exports, "__esModule", { value: true });
45
43
  const path = __importStar(require("path"));
46
44
  const fs = __importStar(require("fs"));
47
- const os = __importStar(require("os"));
45
+ const crypto = __importStar(require("crypto"));
46
+ const child_process_1 = require("child_process");
47
+ const project_anchor_1 = require("./project-anchor");
48
48
  const API_URL_DEFAULT = 'https://api.greprag.com';
49
- const GREPRAG_DIR = path.join(os.homedir(), '.greprag');
50
- function ensureDir() {
51
- if (!fs.existsSync(GREPRAG_DIR))
52
- fs.mkdirSync(GREPRAG_DIR, { recursive: true });
53
- }
54
- function getConfig(cwd) {
55
- ensureEnv(cwd);
56
- return {
57
- apiUrl: process.env.GREPRAG_API_URL || API_URL_DEFAULT,
58
- apiKey: process.env.GREPRAG_API_KEY || '',
59
- enabled: process.env.MEMORY_HOOK_ENABLED === 'true',
60
- };
61
- }
62
- function flagPath(name) {
63
- ensureDir();
64
- return path.join(GREPRAG_DIR, `${name}.flag`);
65
- }
66
- function setFlag(name) {
67
- fs.writeFileSync(flagPath(name), Date.now().toString());
68
- }
69
- function consumeFlag(name) {
70
- const p = flagPath(name);
71
- try {
72
- fs.unlinkSync(p);
73
- return true;
74
- }
75
- catch {
76
- return false;
77
- }
78
- }
79
- function getCachePath() {
80
- ensureDir();
81
- return path.join(GREPRAG_DIR, 'pending-prompt.json');
82
- }
83
- function cachePrompt(data) {
84
- fs.writeFileSync(getCachePath(), JSON.stringify(data));
85
- }
86
- function readCachedPrompt() {
87
- try {
88
- const data = JSON.parse(fs.readFileSync(getCachePath(), 'utf-8'));
89
- fs.unlinkSync(getCachePath()); // consume once
90
- return data;
91
- }
92
- catch {
93
- return null;
94
- }
95
- }
49
+ const MAX_FIELD_CHARS = 500_000; // safety cap per text field
50
+ // ---------- Env + config ---------------------------------------------------
96
51
  function loadEnvFile(filePath) {
97
52
  try {
98
53
  const content = fs.readFileSync(filePath, 'utf-8');
@@ -109,14 +64,11 @@ function loadEnvFile(filePath) {
109
64
  (value.startsWith("'") && value.endsWith("'"))) {
110
65
  value = value.slice(1, -1);
111
66
  }
112
- if (!process.env[key]) {
67
+ if (!process.env[key])
113
68
  process.env[key] = value;
114
- }
115
69
  }
116
70
  }
117
- catch {
118
- // File doesn't exist — fine
119
- }
71
+ catch { /* file missing — fine */ }
120
72
  }
121
73
  function ensureEnv(cwd) {
122
74
  const homeDir = process.env.HOME || process.env.USERPROFILE || '';
@@ -125,6 +77,14 @@ function ensureEnv(cwd) {
125
77
  if (!process.env.GREPRAG_API_KEY)
126
78
  loadEnvFile(path.join(cwd, '.env'));
127
79
  }
80
+ function getConfig(cwd) {
81
+ ensureEnv(cwd);
82
+ return {
83
+ apiUrl: process.env.GREPRAG_API_URL || API_URL_DEFAULT,
84
+ apiKey: process.env.GREPRAG_API_KEY || '',
85
+ enabled: process.env.MEMORY_HOOK_ENABLED === 'true',
86
+ };
87
+ }
128
88
  async function apiCall(url, apiKey, body) {
129
89
  const res = await fetch(url, {
130
90
  method: 'POST',
@@ -140,659 +100,679 @@ async function apiCall(url, apiKey, body) {
140
100
  }
141
101
  return res.json();
142
102
  }
143
- /** SessionStart:compact — flag that next prompt is system-generated, not user.
144
- * Also clears the retrieval dedup cache so previously-injected memories
145
- * can resurface after compaction resets Claude's context. */
146
- async function compact(input) {
147
- setFlag('skip-next');
148
- // Clear dedup cache for this session
149
- const sessionId = input.session_id;
150
- if (sessionId) {
151
- const cwd = input.cwd || process.cwd();
152
- const cfg = getConfig(cwd);
153
- if (cfg.enabled && cfg.apiKey) {
154
- await apiCall(`${cfg.apiUrl}/v1/memory/cache/clear`, cfg.apiKey, { sessionId });
103
+ function extractText(content) {
104
+ if (typeof content === 'string')
105
+ return content.trim();
106
+ if (!Array.isArray(content))
107
+ return '';
108
+ return content
109
+ .filter((b) => b?.type === 'text')
110
+ .map((b) => b.text || '')
111
+ .join('\n').trim();
112
+ }
113
+ /** Extract text from a tool_result's content field. Can be a string or
114
+ * an array of text blocks. */
115
+ function extractToolResultText(content) {
116
+ if (typeof content === 'string')
117
+ return content;
118
+ if (!Array.isArray(content))
119
+ return '';
120
+ return content
121
+ .filter((b) => b?.type === 'text' || typeof b === 'string')
122
+ .map((b) => typeof b === 'string' ? b : (b.text || ''))
123
+ .join('\n');
124
+ }
125
+ /** Build a tool_call entry from a tool_use block. */
126
+ function summarizeToolUse(block) {
127
+ const name = block.name || 'unknown';
128
+ const input = (block.input || {});
129
+ const tc = { name };
130
+ switch (name) {
131
+ case 'Bash': {
132
+ const cmd = input.command || '';
133
+ tc.target = input.description || cmd.split(/\s+/)[0] || '';
134
+ tc.brief = cmd.length > 800 ? cmd.slice(0, 800) + '…' : cmd;
135
+ break;
136
+ }
137
+ case 'Edit':
138
+ case 'Write':
139
+ case 'MultiEdit':
140
+ case 'NotebookEdit': {
141
+ tc.target = input.file_path || input.notebook_path || '';
142
+ break;
143
+ }
144
+ case 'Read': {
145
+ tc.target = input.file_path || '';
146
+ if (input.offset || input.limit) {
147
+ tc.brief = `offset=${input.offset ?? 0} limit=${input.limit ?? '?'}`;
148
+ }
149
+ break;
150
+ }
151
+ case 'Grep': {
152
+ tc.target = input.pattern || '';
153
+ const ctx = [input.path, input.glob, input.type].filter(Boolean).join(' ');
154
+ if (ctx)
155
+ tc.brief = ctx;
156
+ break;
157
+ }
158
+ case 'Glob': {
159
+ tc.target = input.pattern || '';
160
+ break;
161
+ }
162
+ case 'WebFetch':
163
+ case 'WebSearch': {
164
+ tc.target = input.url || input.query || '';
165
+ break;
166
+ }
167
+ case 'Agent':
168
+ case 'Task': {
169
+ tc.target = input.description || input.subagent_type || '';
170
+ tc.brief = (input.prompt || '').slice(0, 200);
171
+ break;
172
+ }
173
+ default: {
174
+ // Unknown tool — best-effort summary
175
+ const json = JSON.stringify(input);
176
+ tc.brief = json.length > 200 ? json.slice(0, 200) + '…' : json;
155
177
  }
156
178
  }
179
+ return tc;
157
180
  }
158
- /** UserPromptSubmit inject signals + assertive-recall directive, cache prompt for store pairing.
159
- *
160
- * Auto-injection of memory content was removed in favor of agent-initiated `greprag recall`.
161
- * The retrieve call still runs, but only for entity context + novelty/stress signals — the
162
- * agent decides when to pull actual memory content. */
163
- async function retrieve(input) {
164
- const cwd = input.cwd || process.cwd();
165
- const cfg = getConfig(cwd);
166
- if (!cfg.enabled || !cfg.apiKey)
167
- return;
168
- const prompt = input.prompt || '';
169
- const sessionId = input.session_id || '';
170
- const profileId = path.basename(cwd);
171
- // After compaction, the first prompt is system-generated — skip caching it
172
- const skipStore = consumeFlag('skip-next');
173
- // Compute stress streak directly from transcript — zero timing lag.
174
- // Count consecutive assistant turns without tool_use from the tail.
175
- const stressStreak = computeTranscriptStress(input.transcript_path);
176
- // Fetch signals (entities + novelty) + inbox count in parallel.
177
- // No content injection — `greprag recall` handles that on demand.
178
- const [result, countResult] = await Promise.all([
179
- apiCall(`${cfg.apiUrl}/v1/memory/retrieve`, cfg.apiKey, {
180
- query: prompt,
181
- profileId,
182
- sessionId,
183
- stressStreak,
184
- }),
185
- apiCall(`${cfg.apiUrl}/v1/memory/messages/count`, cfg.apiKey, {
186
- profileId,
187
- sessionId,
188
- }),
189
- ]);
190
- let context = '';
191
- // Prepend message notification if any pending
192
- const pending = countResult?.ok ? countResult.count ?? 0 : 0;
193
- if (pending > 0) {
194
- const s = pending === 1 ? 'message' : 'messages';
195
- context += `[${pending} pending ${s} — run: greprag inbox --profile ${profileId}]\n`;
181
+ /** Parse the transcript, extracting the latest turn (since the last real user message). */
182
+ function parseLatestTurn(transcriptPath) {
183
+ const empty = {
184
+ userPrompt: '', agentResponse: '', toolCalls: [],
185
+ filesTouched: [], model: null, status: 'completed',
186
+ userStartTime: null,
187
+ };
188
+ if (!transcriptPath)
189
+ return empty;
190
+ let lines;
191
+ try {
192
+ lines = fs.readFileSync(transcriptPath, 'utf-8').split('\n');
196
193
  }
197
- // Entity context (still useful every turn — canonical summaries, cheap)
198
- let entityContext = [];
199
- let chainMeta;
200
- if (result?.ok) {
201
- const r = result.result;
202
- const entities = r?.entityContext;
203
- if (entities && entities.length > 0) {
204
- entityContext = entities;
205
- const entLines = entities.map(e => {
206
- if (e.summary)
207
- return `- ${e.name}: ${e.summary}`;
208
- return `- ${e.name} — [${e.status}]`;
209
- });
210
- context += `[Entities: ${entLines.join(' | ')}]\n`;
194
+ catch {
195
+ return empty;
196
+ }
197
+ // Walk backwards to find the last real user message (not a tool_result).
198
+ let userStartIdx = -1;
199
+ let userPrompt = '';
200
+ let userStartTime = null;
201
+ for (let i = lines.length - 1; i >= 0; i--) {
202
+ const line = lines[i].trim();
203
+ if (!line)
204
+ continue;
205
+ let entry;
206
+ try {
207
+ entry = JSON.parse(line);
208
+ }
209
+ catch {
210
+ continue;
211
+ }
212
+ const msg = (entry.message || entry);
213
+ const role = (msg.role || entry.type || '');
214
+ if (role !== 'user')
215
+ continue;
216
+ if (Array.isArray(msg.content)) {
217
+ const isToolResult = msg.content
218
+ .some(b => b?.type === 'tool_result');
219
+ if (isToolResult)
220
+ continue;
221
+ }
222
+ const text = extractText(msg.content);
223
+ if (text && !text.startsWith('<system') && !text.startsWith('{')) {
224
+ userPrompt = text;
225
+ userStartIdx = i;
226
+ if (typeof entry.timestamp === 'string')
227
+ userStartTime = entry.timestamp;
228
+ break;
211
229
  }
212
- chainMeta = r.chainMeta;
213
- }
214
- // GrepRAG signal line — novelty hints at how much matched in the corpus,
215
- // stress hints at the agent's flow state. Both inform whether `greprag recall` is worth running.
216
- const parts = [];
217
- if (chainMeta) {
218
- const nov = typeof chainMeta.noveltyRatio === 'number' ? Math.round(chainMeta.noveltyRatio * 100) : null;
219
- const streak = typeof chainMeta.stressStreak === 'number' ? chainMeta.stressStreak : 0;
220
- const state = streak >= 2 ? `stress: ${streak} (expanded)` : streak === 1 ? 'stress: 1' : 'action';
221
- parts.push(`novelty: ${nov !== null ? nov + '%' : '?'} | ${state}`);
222
- }
223
- const incomplete = entityContext.filter(e => e.status !== 'complete');
224
- if (incomplete.length > 0) {
225
- parts.push(`entities: <entity-summary name="Name">what it is</entity-summary> for: ${incomplete.map(e => e.name).join(', ')}`);
226
- }
227
- if (parts.length > 0) {
228
- context += `\n[greprag: ${parts.join(' | ')}]`;
229
- }
230
- // Assertive-recall directive — tells the agent to pull memory themselves when the
231
- // question touches prior context. Glued to the user turn, so it reads as instruction.
232
- context += `\n[greprag-recall: memory may hold relevant prior context (decisions, specs, past bugs, how something was implemented). When the user's question touches anything not obvious from the current session, run Bash: \`greprag recall "precise query" --profile ${profileId}\`. It's cheap (~$0.000007/call) — use it freely, chain multiple queries if needed. Rate what comes back via <mfb> before ending your turn.]`;
233
- // Cache prompt for store pairing. No retrieval metadata — nothing was auto-injected;
234
- // feedback (if any) will come from agent-initiated `greprag recall` calls.
235
- if (prompt && !skipStore) {
236
- cachePrompt({ prompt, sessionId, profileId });
237
230
  }
238
- if (context) {
239
- process.stdout.write(JSON.stringify({
240
- hookSpecificOutput: {
241
- hookEventName: 'UserPromptSubmit',
242
- additionalContext: context,
243
- },
244
- }));
231
+ if (userStartIdx === -1)
232
+ return empty;
233
+ // Walk forward from the user message, collecting assistant text + tool_use.
234
+ const agentTextParts = [];
235
+ const toolCalls = [];
236
+ const filesTouched = new Set();
237
+ let model = null;
238
+ let status = 'completed';
239
+ for (let i = userStartIdx + 1; i < lines.length; i++) {
240
+ const line = lines[i].trim();
241
+ if (!line)
242
+ continue;
243
+ let entry;
244
+ try {
245
+ entry = JSON.parse(line);
246
+ }
247
+ catch {
248
+ continue;
249
+ }
250
+ const msg = (entry.message || entry);
251
+ const role = (msg.role || entry.type || '');
252
+ if (role === 'assistant') {
253
+ if (!model && typeof msg.model === 'string')
254
+ model = msg.model;
255
+ if (Array.isArray(msg.content)) {
256
+ for (const b of msg.content) {
257
+ if (b?.type === 'text' && typeof b.text === 'string') {
258
+ agentTextParts.push(b.text);
259
+ }
260
+ else if (b?.type === 'tool_use') {
261
+ const tc = summarizeToolUse(b);
262
+ tc._useId = b.id || undefined;
263
+ toolCalls.push(tc);
264
+ // Files touched derivation
265
+ const input = (b.input || {});
266
+ if (['Edit', 'Write', 'MultiEdit'].includes(tc.name) && input.file_path) {
267
+ filesTouched.add(input.file_path);
268
+ }
269
+ else if (tc.name === 'NotebookEdit' && input.notebook_path) {
270
+ filesTouched.add(input.notebook_path);
271
+ }
272
+ }
273
+ }
274
+ }
275
+ }
276
+ else if (role === 'user') {
277
+ // tool_result blocks — pair to tool_use by tool_use_id, capture stdout
278
+ if (Array.isArray(msg.content)) {
279
+ for (const b of msg.content) {
280
+ if (b?.type === 'tool_result') {
281
+ const useId = b.tool_use_id || '';
282
+ const out = extractToolResultText(b.content);
283
+ if (useId && out) {
284
+ const tc = toolCalls.find(c => c._useId === useId);
285
+ if (tc)
286
+ tc._output = out;
287
+ }
288
+ }
289
+ }
290
+ }
291
+ const text = extractText(msg.content);
292
+ if (text.includes('[Request interrupted by user]'))
293
+ status = 'interrupted';
294
+ }
245
295
  }
296
+ return {
297
+ userPrompt,
298
+ agentResponse: agentTextParts.join('\n\n').trim(),
299
+ toolCalls,
300
+ filesTouched: Array.from(filesTouched).sort(),
301
+ model,
302
+ status,
303
+ userStartTime,
304
+ };
246
305
  }
247
- /** Detect action in the last assistant turn — reads transcript for tool_use blocks.
248
- * A single turn spans multiple requestIds (text tool_use → tool_result → text → ...).
249
- * Walk backwards through the transcript; stop at a real user message (non-tool_result). */
250
- function detectAction(transcriptPath, agentProse) {
251
- // Fast path: code blocks in the text response
252
- if (/```/.test(agentProse))
253
- return true;
254
- // Read transcript for tool_use blocks in the last assistant turn
255
- if (!transcriptPath)
256
- return false;
306
+ /** Walk subagent transcripts under <session>/subagents/ and pull tool_use
307
+ * blocks that fired during the current turn. Returns flattened ToolCall[]
308
+ * augmented with _output where a matching tool_result exists in the same
309
+ * subagent transcript. Also returns files_touched derived from subagent
310
+ * Edit/Write/MultiEdit/NotebookEdit calls.
311
+ *
312
+ * Subagents have their own transcript files because Claude Code scopes them
313
+ * to their own session. The main transcript only sees the Agent tool_use
314
+ * itself — not the work the subagent did. Without this flattening, any
315
+ * commit/deploy/PR a subagent fires would be invisible to artifact detection.
316
+ */
317
+ function parseSubagentCalls(transcriptPath, sinceTime) {
318
+ const empty = { toolCalls: [], filesTouched: [] };
319
+ if (!sinceTime)
320
+ return empty;
321
+ // Subagent transcripts live at <transcriptPath without .jsonl>/subagents/*.jsonl
322
+ const sessionDir = transcriptPath.replace(/\.jsonl$/, '');
323
+ const subagentsDir = path.join(sessionDir, 'subagents');
324
+ if (!fs.existsSync(subagentsDir))
325
+ return empty;
326
+ let files = [];
257
327
  try {
258
- const raw = fs.readFileSync(transcriptPath, 'utf-8');
259
- const lines = raw.split('\n');
260
- for (let i = lines.length - 1; i >= 0; i--) {
261
- const line = lines[i].trim();
262
- if (!line)
328
+ files = fs.readdirSync(subagentsDir).filter(f => f.endsWith('.jsonl'));
329
+ }
330
+ catch {
331
+ return empty;
332
+ }
333
+ const sinceMs = new Date(sinceTime).getTime();
334
+ const merged = [];
335
+ const filesTouched = new Set();
336
+ for (const fname of files) {
337
+ const fpath = path.join(subagentsDir, fname);
338
+ let raw;
339
+ try {
340
+ raw = fs.readFileSync(fpath, 'utf-8');
341
+ }
342
+ catch {
343
+ continue;
344
+ }
345
+ // Two passes: collect tool_use blocks (filtered by time), then pair
346
+ // tool_results to populate _output.
347
+ const localCalls = [];
348
+ for (const line of raw.split('\n')) {
349
+ const trimmed = line.trim();
350
+ if (!trimmed)
263
351
  continue;
264
352
  let entry;
265
353
  try {
266
- entry = JSON.parse(line);
354
+ entry = JSON.parse(trimmed);
267
355
  }
268
356
  catch {
269
357
  continue;
270
358
  }
359
+ const ts = typeof entry.timestamp === 'string' ? new Date(entry.timestamp).getTime() : NaN;
360
+ if (!Number.isFinite(ts) || ts < sinceMs)
361
+ continue;
271
362
  const msg = (entry.message || entry);
272
363
  const role = (msg.role || entry.type || '');
273
- // User entry: tool_result = still same turn, real message = turn boundary
274
- if (role === 'user') {
275
- if (Array.isArray(msg.content)) {
276
- const isToolResult = msg.content
277
- .some(b => b?.type === 'tool_result');
278
- if (isToolResult)
279
- continue; // same turn — keep walking
364
+ if (role !== 'assistant' || !Array.isArray(msg.content))
365
+ continue;
366
+ for (const b of msg.content) {
367
+ if (b?.type !== 'tool_use')
368
+ continue;
369
+ const tc = summarizeToolUse(b);
370
+ tc._useId = b.id || undefined;
371
+ localCalls.push(tc);
372
+ const input = (b.input || {});
373
+ if (['Edit', 'Write', 'MultiEdit'].includes(tc.name) && input.file_path) {
374
+ filesTouched.add(input.file_path);
280
375
  }
281
- break; // real user message end of turn
282
- }
283
- // Assistant entry: check for tool_use
284
- if (role === 'assistant' && Array.isArray(msg.content)) {
285
- for (const b of msg.content) {
286
- if (b?.type === 'tool_use')
287
- return true;
376
+ else if (tc.name === 'NotebookEdit' && input.notebook_path) {
377
+ filesTouched.add(input.notebook_path);
288
378
  }
289
379
  }
290
- // Skip system, file-history-snapshot, etc.
291
380
  }
292
- }
293
- catch { /* transcript read failed — fall through */ }
294
- return false;
295
- }
296
- /** Count consecutive substantial prose turns (no tool_use, 50+ words) from the
297
- * transcript tail. Short responses (< 50 words) are neutral — don't increment
298
- * the streak but don't reset it either. Action always resets. */
299
- const STRESS_MIN_WORDS = 50;
300
- function computeTranscriptStress(transcriptPath) {
301
- if (!transcriptPath)
302
- return 0;
303
- try {
304
- const raw = fs.readFileSync(transcriptPath, 'utf-8');
305
- const lines = raw.split('\n');
306
- let streak = 0;
307
- let turnHasAction = false;
308
- let turnTextLength = 0;
309
- let inTurn = false;
310
- for (let i = lines.length - 1; i >= 0; i--) {
311
- const line = lines[i].trim();
312
- if (!line)
381
+ // Pair tool_results to outputs (same subagent transcript)
382
+ for (const line of raw.split('\n')) {
383
+ const trimmed = line.trim();
384
+ if (!trimmed)
313
385
  continue;
314
386
  let entry;
315
387
  try {
316
- entry = JSON.parse(line);
388
+ entry = JSON.parse(trimmed);
317
389
  }
318
390
  catch {
319
391
  continue;
320
392
  }
321
393
  const msg = (entry.message || entry);
322
394
  const role = (msg.role || entry.type || '');
323
- if (role === 'assistant') {
324
- inTurn = true;
325
- if (Array.isArray(msg.content)) {
326
- for (const b of msg.content) {
327
- if (b?.type === 'tool_use')
328
- turnHasAction = true;
329
- if (b?.type === 'text' && typeof b.text === 'string') {
330
- turnTextLength += b.text.split(/\s+/).filter(Boolean).length;
331
- }
332
- }
333
- }
334
- }
335
- else if (role === 'user') {
336
- const isToolResult = Array.isArray(msg.content) &&
337
- msg.content.some(b => b?.type === 'tool_result');
338
- if (isToolResult)
339
- continue; // still same turn
340
- // Real user message = turn boundary
341
- if (inTurn) {
342
- if (turnHasAction)
343
- break; // action resets — stop counting
344
- if (turnTextLength >= STRESS_MIN_WORDS)
345
- streak++; // substantial prose
346
- // short prose: neutral — don't increment, don't reset
395
+ if (role !== 'user' || !Array.isArray(msg.content))
396
+ continue;
397
+ for (const b of msg.content) {
398
+ if (b?.type !== 'tool_result')
399
+ continue;
400
+ const useId = b.tool_use_id || '';
401
+ const out = extractToolResultText(b.content);
402
+ if (useId && out) {
403
+ const tc = localCalls.find(c => c._useId === useId);
404
+ if (tc)
405
+ tc._output = out;
347
406
  }
348
- turnHasAction = false;
349
- turnTextLength = 0;
350
- inTurn = false;
351
407
  }
352
408
  }
353
- return streak;
354
- }
355
- catch {
356
- return 0;
357
- }
358
- }
359
- /** Parse <entity-summary> tags from agent response. */
360
- function parseEntitySummaries(text) {
361
- const results = [];
362
- const regex = /<entity-summary\s+name="([^"]+)">([\s\S]*?)<\/entity-summary>/g;
363
- let match;
364
- while ((match = regex.exec(text)) !== null) {
365
- const name = match[1].trim();
366
- const summary = match[2].trim();
367
- if (name && summary)
368
- results.push({ name, summary });
409
+ merged.push(...localCalls);
369
410
  }
370
- return results;
371
- }
372
- /** Parse <profile-summary> tag from agent response. Returns null if not found. */
373
- function parseProfileSummary(text) {
374
- const match = text.match(/<profile-summary>([\s\S]*?)<\/profile-summary>/);
375
- return match && match[1].trim() ? match[1].trim() : null;
411
+ return { toolCalls: merged, filesTouched: Array.from(filesTouched).sort() };
376
412
  }
377
- /** Parse <profile-detail> tags from agent response. Returns array of details. */
378
- function parseProfileDetails(text) {
379
- const results = [];
380
- const regex = /<profile-detail>([\s\S]*?)<\/profile-detail>/g;
381
- let match;
382
- while ((match = regex.exec(text)) !== null) {
383
- const detail = match[1].trim();
384
- if (detail)
385
- results.push(detail);
386
- }
387
- return results;
388
- }
389
- /** Parse <mfb>[...]</mfb> from agent response. Returns null if not found. */
390
- function parseFeedbackTag(text) {
391
- const match = text.match(/<mfb>([\s\S]*?)<\/mfb>/);
392
- if (!match)
393
- return null;
413
+ // ---------- Git branch + artifact detection -------------------------------
414
+ function getBranch(cwd) {
394
415
  try {
395
- const parsed = JSON.parse(match[1]);
396
- if (!Array.isArray(parsed))
397
- return null;
398
- // Filter out template entries (s=0) and invalid entries
399
- return parsed.filter((e) => typeof e.id === 'string' && typeof e.s === 'number' && e.s >= 1 && e.s <= 5);
416
+ const out = (0, child_process_1.execSync)('git branch --show-current', { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
417
+ return out.trim() || null;
400
418
  }
401
419
  catch {
402
420
  return null;
403
421
  }
404
422
  }
405
- /** Stop pair cached prompt with agent response, store the turn + ship feedback. */
406
- async function store(input) {
407
- const cwd = input.cwd || process.cwd();
408
- const cfg = getConfig(cwd);
409
- if (!cfg.enabled || !cfg.apiKey)
410
- return;
411
- const agentProse = input.last_assistant_message || '';
412
- if (!agentProse)
413
- return;
414
- // Read the cached prompt from the retrieve hook
415
- const cached = readCachedPrompt();
416
- const userMessage = cached?.prompt || '';
417
- const sessionId = cached?.sessionId || input.session_id || `hook-${Date.now()}`;
418
- const profileId = cached?.profileId || path.basename(cwd);
419
- // Detect action: check transcript for tool_use in last assistant turn, fall back to code blocks
420
- const hasAction = detectAction(input.transcript_path, agentProse);
421
- // Store the turn memory
422
- const storePromise = apiCall(`${cfg.apiUrl}/v1/memory/store`, cfg.apiKey, {
423
- profileId,
424
- sessionId,
425
- userMessage,
426
- agentProse,
427
- hasAction,
428
- crystallization: 'turn',
429
- });
430
- // Parse and ship feedback if present. With auto-injection disabled, `<mfb>` tags
431
- // come from agent-initiated `greprag recall` calls — parse unconditionally.
432
- // Server resolves short→full memory IDs via LIKE; retrievalScore/retrievalMeta are
433
- // unavailable here (the recall ran out-of-band) so we ship what we have.
434
- let feedbackPromise = null;
435
- const ratings = parseFeedbackTag(agentProse);
436
- if (ratings && ratings.length > 0) {
437
- feedbackPromise = apiCall(`${cfg.apiUrl}/v1/memory/feedback`, cfg.apiKey, {
438
- ratings: ratings.map(r => ({
439
- memoryId: r.id,
440
- relevanceScore: r.s,
441
- reason: r.r || undefined,
442
- })),
443
- profileId,
444
- sessionId,
445
- triggerQuery: cached?.prompt,
446
- });
447
- }
448
- // Parse and ship entity summaries if present
449
- let entityPromise = null;
450
- const entitySummaries = parseEntitySummaries(agentProse);
451
- if (entitySummaries.length > 0) {
452
- entityPromise = apiCall(`${cfg.apiUrl}/v1/memory/entities/summarize`, cfg.apiKey, {
453
- summaries: entitySummaries,
454
- });
455
- }
456
- // Parse and ship profile summary/details if present
457
- let profilePromise = null;
458
- const profSummary = parseProfileSummary(agentProse);
459
- const profDetails = parseProfileDetails(agentProse);
460
- if (profSummary || profDetails.length > 0) {
461
- profilePromise = apiCall(`${cfg.apiUrl}/v1/memory/profile/summary`, cfg.apiKey, {
462
- profileId,
463
- summary: profSummary || undefined,
464
- details: profDetails.length > 0 && !profSummary ? profDetails : undefined,
465
- });
466
- }
467
- // Run store + feedback + entity summaries + profile summary in parallel
468
- await Promise.all([storePromise, feedbackPromise, entityPromise, profilePromise].filter(Boolean));
469
- }
470
- /** Find the most recently modified plan file in ~/.claude/plans/ */
471
- function readNewestPlanFile() {
472
- const plansDir = path.join(os.homedir(), '.claude', 'plans');
473
- try {
474
- const files = fs.readdirSync(plansDir)
475
- .filter(f => f.endsWith('.md'))
476
- .map(f => {
477
- const full = path.join(plansDir, f);
478
- return { path: full, mtime: fs.statSync(full).mtimeMs };
479
- })
480
- .sort((a, b) => b.mtime - a.mtime);
481
- if (!files.length)
482
- return null;
483
- // Only consider plans modified in the last 60 seconds (freshly written)
484
- if (Date.now() - files[0].mtime > 60_000)
485
- return null;
486
- return fs.readFileSync(files[0].path, 'utf-8');
487
- }
488
- catch {
423
+ /** Parse a PR URL (number + url) out of `gh pr create` stdout. */
424
+ function parsePrFromOutput(out, cmd) {
425
+ const m = out.match(/https:\/\/github\.com\/[^\/\s]+\/[^\/\s]+\/pull\/(\d+)/);
426
+ if (!m)
489
427
  return null;
490
- }
491
- }
492
- /** Format AskUserQuestion tool data into readable Q&A text. */
493
- function formatQuestionAnswers(toolInput, toolResponse) {
494
- const questions = toolInput.questions;
495
- const answers = toolResponse.answers;
496
- if (!questions?.length)
497
- return '';
498
- const lines = [];
499
- for (const q of questions) {
500
- const question = q.question || '';
501
- const answer = answers?.[question] || '';
502
- if (question) {
503
- lines.push(`Q: ${question}`);
504
- lines.push(`A: ${answer || '(no answer)'}`);
505
- lines.push('');
506
- }
507
- }
508
- return lines.join('\n').trim();
509
- }
510
- /** PostToolUse — capture high-signal tool events (plan approvals, user answers). */
511
- async function signal(input) {
512
- const cwd = input.cwd || process.cwd();
513
- const cfg = getConfig(cwd);
514
- if (!cfg.enabled || !cfg.apiKey)
515
- return;
516
- const toolName = input.tool_name || '';
517
- const sessionId = input.session_id || `hook-${Date.now()}`;
518
- const profileId = path.basename(cwd);
519
- let userMessage = '';
520
- let agentProse = '';
521
- let crystallization = '';
522
- if (toolName === 'ExitPlanMode') {
523
- const planContent = readNewestPlanFile();
524
- if (!planContent)
525
- return; // no plan file found
526
- userMessage = '[Plan Approved]';
527
- // Cap at ~2000 words to avoid oversized payloads
528
- const words = planContent.split(/\s+/);
529
- agentProse = words.length > 2000
530
- ? words.slice(0, 2000).join(' ') + '\n\n[truncated]'
531
- : planContent;
532
- crystallization = 'plan';
533
- }
534
- else if (toolName === 'AskUserQuestion') {
535
- const toolInput = input.tool_input || {};
536
- const toolResponse = input.tool_response || {};
537
- const formatted = formatQuestionAnswers(toolInput, toolResponse);
538
- if (!formatted)
539
- return;
540
- userMessage = formatted;
541
- agentProse = '';
542
- crystallization = 'decision';
543
- }
544
- else {
545
- return; // unknown tool, ignore
546
- }
547
- await apiCall(`${cfg.apiUrl}/v1/memory/store`, cfg.apiKey, {
548
- profileId,
549
- sessionId,
550
- userMessage,
551
- agentProse,
552
- crystallization,
553
- });
428
+ // Try to pull --title "..." from the command for a clean title
429
+ const titleM = cmd.match(/--title\s+["']([^"']+)["']/);
430
+ return { id: '#' + m[1], url: m[0], title: titleM ? titleM[1] : undefined };
431
+ }
432
+ /** Parse a release URL out of `gh release create` stdout. */
433
+ function parseReleaseFromOutput(out, cmd) {
434
+ const m = out.match(/https:\/\/github\.com\/[^\/\s]+\/[^\/\s]+\/releases\/tag\/(\S+)/);
435
+ if (!m)
436
+ return null;
437
+ const titleM = cmd.match(/--title\s+["']([^"']+)["']/);
438
+ return { id: m[1], url: m[0], title: titleM ? titleM[1] : m[1] };
554
439
  }
555
- function extractText(content) {
556
- if (typeof content === 'string')
557
- return content.trim();
558
- if (!Array.isArray(content))
559
- return '';
560
- return content
561
- .filter((b) => b?.type === 'text')
562
- .map((b) => b.text || '')
563
- .join('\n').trim();
440
+ /** Parse `npm publish` stdout. Format: `+ pkg@1.2.3`. */
441
+ function parseNpmPublishFromOutput(out) {
442
+ const m = out.match(/^\+\s+(\S+@\S+)/m);
443
+ if (!m)
444
+ return null;
445
+ return { id: m[1], url: null, title: m[1] };
564
446
  }
565
- function parseTranscript(transcriptPath) {
566
- const userReqs = [];
567
- const files = new Set();
568
- const cmds = [];
569
- const responses = new Map();
570
- let raw;
571
- try {
572
- raw = fs.readFileSync(transcriptPath, 'utf-8');
573
- }
574
- catch {
447
+ /** Parse `wrangler deploy` stdout. Picks the "Current Version ID" line plus
448
+ * worker name and URL. */
449
+ function parseWranglerDeployFromOutput(out) {
450
+ const m = out.match(/Current Version ID:\s+([a-f0-9-]+)/i);
451
+ if (!m)
575
452
  return null;
576
- }
577
- for (const line of raw.split('\n')) {
578
- if (!line.trim())
453
+ const urlM = out.match(/https:\/\/\S+\.workers\.dev/);
454
+ const workerM = out.match(/Uploaded (\S+)/);
455
+ return { id: m[1], url: urlM ? urlM[0] : null, title: workerM ? workerM[1] : undefined };
456
+ }
457
+ /** Detect artifacts from tool_calls. Real artifact IDs are parsed from tool
458
+ * outputs where possible; falls back to "pending" if output is missing. */
459
+ function detectArtifacts(toolCalls, cwd) {
460
+ const events = [];
461
+ let commitCount = 0;
462
+ let pushDetected = false;
463
+ for (const tc of toolCalls) {
464
+ if (tc.name !== 'Bash')
579
465
  continue;
580
- let entry;
581
- try {
582
- entry = JSON.parse(line);
466
+ const cmd = (tc.brief || '').trim();
467
+ const out = tc._output || '';
468
+ if (/^git commit\b/.test(cmd) || /&&\s*git commit\b/.test(cmd))
469
+ commitCount++;
470
+ if (/^git push\b/.test(cmd) || /&&\s*git push\b/.test(cmd))
471
+ pushDetected = true;
472
+ if (/^gh pr create\b/.test(cmd) || /&&\s*gh pr create\b/.test(cmd)) {
473
+ const parsed = parsePrFromOutput(out, cmd);
474
+ events.push({
475
+ artifactType: 'pr',
476
+ artifactId: parsed?.id || 'pending',
477
+ artifactUrl: parsed?.url || null,
478
+ title: parsed?.title || cmd.slice(0, 120),
479
+ });
583
480
  }
584
- catch {
585
- continue;
481
+ if (/^gh release create\b/.test(cmd) || /&&\s*gh release create\b/.test(cmd)) {
482
+ const parsed = parseReleaseFromOutput(out, cmd);
483
+ events.push({
484
+ artifactType: 'release',
485
+ artifactId: parsed?.id || 'pending',
486
+ artifactUrl: parsed?.url || null,
487
+ title: parsed?.title || cmd.slice(0, 120),
488
+ });
586
489
  }
587
- const msg = (entry.message || entry);
588
- const role = (msg.role || entry.type || '');
589
- const content = msg.content;
590
- const reqId = (entry.requestId || '');
591
- if (role === 'user') {
592
- const text = extractText(content);
593
- if (text.length > 5 && !text.startsWith('<system') && !text.startsWith('{'))
594
- userReqs.push(text.slice(0, 300));
490
+ if (/\bnpm publish\b/.test(cmd)) {
491
+ const parsed = parseNpmPublishFromOutput(out);
492
+ events.push({
493
+ artifactType: 'release',
494
+ artifactId: parsed?.id || 'pending',
495
+ artifactUrl: parsed?.url || null,
496
+ title: parsed?.title || cmd.slice(0, 120),
497
+ });
595
498
  }
596
- if (role === 'assistant') {
597
- const text = extractText(content);
598
- if (text.length > 20 && reqId && text.length > (responses.get(reqId)?.length || 0))
599
- responses.set(reqId, text);
600
- if (Array.isArray(content)) {
601
- for (const b of content) {
602
- const block = b;
603
- if (block?.type !== 'tool_use')
604
- continue;
605
- const inp = (block.input || {});
606
- if (typeof inp !== 'object')
499
+ if (/wrangler deploy\b/.test(cmd) || /npm run deploy\b/.test(cmd)) {
500
+ const parsed = parseWranglerDeployFromOutput(out);
501
+ events.push({
502
+ artifactType: 'deploy',
503
+ artifactId: parsed?.id || 'pending',
504
+ artifactUrl: parsed?.url || null,
505
+ title: parsed?.title || cmd.slice(0, 120),
506
+ });
507
+ }
508
+ if (/^git merge\b/.test(cmd)) {
509
+ events.push({
510
+ artifactType: 'merge', artifactId: 'pending',
511
+ artifactUrl: null, title: cmd.slice(0, 200),
512
+ });
513
+ }
514
+ }
515
+ // For commits: lookback in git log to recover SHA + subject for each commit.
516
+ if (commitCount > 0) {
517
+ try {
518
+ const log = (0, child_process_1.execSync)(`git log -${commitCount} --format=%H|%s`, {
519
+ cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
520
+ });
521
+ for (const line of log.trim().split('\n')) {
522
+ const idx = line.indexOf('|');
523
+ if (idx < 0)
524
+ continue;
525
+ const sha = line.slice(0, idx);
526
+ const subject = line.slice(idx + 1);
527
+ if (sha)
528
+ events.push({
529
+ artifactType: 'commit', artifactId: sha,
530
+ artifactUrl: null, title: subject,
531
+ });
532
+ }
533
+ }
534
+ catch { /* git log failed */ }
535
+ }
536
+ // For pushes: capture HEAD SHA at hook time as the push tip.
537
+ if (pushDetected) {
538
+ try {
539
+ const sha = (0, child_process_1.execSync)('git rev-parse HEAD', {
540
+ cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
541
+ }).trim();
542
+ events.push({
543
+ artifactType: 'push', artifactId: sha,
544
+ artifactUrl: null, title: `push to ${getBranch(cwd) || 'unknown'}`,
545
+ });
546
+ }
547
+ catch { /* git command failed */ }
548
+ }
549
+ return events;
550
+ }
551
+ // ---------- Env-var scrubbing ---------------------------------------------
552
+ /** Build a redaction map of value → [REDACTED:VAR_NAME] from .env files and process.env. */
553
+ function buildRedactionMap(cwd) {
554
+ const entries = [];
555
+ const seenValues = new Set();
556
+ const addEntry = (key, value) => {
557
+ if (!value || value.length < 16)
558
+ return;
559
+ if (seenValues.has(value))
560
+ return;
561
+ seenValues.add(value);
562
+ entries.push([value, `[REDACTED:${key}]`]);
563
+ };
564
+ // Walk up looking for .env files
565
+ let dir = path.resolve(cwd);
566
+ for (let depth = 0; depth < 8; depth++) {
567
+ const envPath = path.join(dir, '.env');
568
+ if (fs.existsSync(envPath)) {
569
+ try {
570
+ const raw = fs.readFileSync(envPath, 'utf-8');
571
+ for (const line of raw.split('\n')) {
572
+ const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.+?)\s*$/);
573
+ if (!m)
607
574
  continue;
608
- if ((block.name === 'Write' || block.name === 'Edit') && inp.file_path)
609
- files.add(inp.file_path);
610
- if (block.name === 'Bash' && inp.command)
611
- cmds.push(inp.description || inp.command.slice(0, 150));
575
+ let value = m[2].trim();
576
+ if ((value.startsWith('"') && value.endsWith('"')) ||
577
+ (value.startsWith("'") && value.endsWith("'"))) {
578
+ value = value.slice(1, -1);
579
+ }
580
+ addEntry(m[1], value);
612
581
  }
613
582
  }
583
+ catch { /* skip */ }
614
584
  }
585
+ const parent = path.dirname(dir);
586
+ if (parent === dir)
587
+ break;
588
+ dir = parent;
589
+ }
590
+ // Process env
591
+ for (const [key, value] of Object.entries(process.env)) {
592
+ if (typeof value === 'string')
593
+ addEntry(key, value);
594
+ }
595
+ // Sort by value length desc so longer values are replaced first
596
+ entries.sort((a, b) => b[0].length - a[0].length);
597
+ return entries;
598
+ }
599
+ function scrubString(text, map) {
600
+ if (!text)
601
+ return text;
602
+ let out = text;
603
+ for (const [value, replacement] of map) {
604
+ if (out.includes(value))
605
+ out = out.split(value).join(replacement);
606
+ }
607
+ return out;
608
+ }
609
+ function scrubObject(obj, map) {
610
+ if (typeof obj === 'string')
611
+ return scrubString(obj, map);
612
+ if (Array.isArray(obj))
613
+ return obj.map(o => scrubObject(o, map));
614
+ if (obj && typeof obj === 'object') {
615
+ const out = {};
616
+ for (const k of Object.keys(obj)) {
617
+ out[k] = scrubObject(obj[k], map);
618
+ }
619
+ return out;
615
620
  }
616
- // Deduplicate user requests, keep last 10
617
- const seen = new Set();
618
- const uniqueReqs = userReqs.map(r => r.split('\n')[0].slice(0, 150))
619
- .filter(r => { if (seen.has(r))
620
- return false; seen.add(r); return true; }).slice(-10);
621
- return {
622
- userReqs: uniqueReqs,
623
- files: Array.from(files).sort(),
624
- cmds: cmds.slice(-5),
625
- responses: Array.from(responses.values()).slice(-3).map(r => {
626
- const t = r.slice(0, 500);
627
- const dot = t.lastIndexOf('.');
628
- return dot > 200 ? t.slice(0, dot + 1) : t;
629
- }),
630
- };
621
+ return obj;
631
622
  }
632
- /** Extract raw session text from transcript — shared by summary + farewell. */
633
- function extractSessionText(input) {
634
- const transcriptPath = input.transcript_path || '';
635
- if (!transcriptPath)
636
- return null;
637
- const parsed = parseTranscript(transcriptPath);
638
- if (!parsed)
639
- return null;
640
- const cwd = input.cwd || process.cwd();
641
- const parts = [];
642
- if (parsed.userReqs.length)
643
- parts.push('User requests: ' + parsed.userReqs.join('; '));
644
- if (parsed.files.length)
645
- parts.push('Files modified: ' + parsed.files.slice(0, 20).join(', '));
646
- if (parsed.cmds.length)
647
- parts.push('Commands: ' + parsed.cmds.join('; '));
648
- if (parsed.responses.length)
649
- parts.push('Recent context:\n' + parsed.responses.join('\n'));
650
- const rawText = parts.join('\n\n');
651
- if (!rawText.trim())
652
- return null;
653
- return { rawText, cwd, project: path.basename(cwd) };
623
+ // ---------- Field-size cap ------------------------------------------------
624
+ function capField(text) {
625
+ if (!text || text.length <= MAX_FIELD_CHARS)
626
+ return text;
627
+ const truncated = text.slice(0, MAX_FIELD_CHARS);
628
+ const originalKb = (text.length / 1000).toFixed(1);
629
+ return `${truncated}\n\n[truncated: original ${originalKb}KB]`;
654
630
  }
655
- /** Store session text to API with compaction crystallization. */
656
- async function storeSessionSummary(input, rawText, cwd, project, label) {
631
+ // ---------- Store ---------------------------------------------------------
632
+ async function store(input) {
633
+ const cwd = input.cwd || process.cwd();
657
634
  const cfg = getConfig(cwd);
658
- if (cfg.enabled && cfg.apiKey) {
659
- await apiCall(`${cfg.apiUrl}/v1/memory/store`, cfg.apiKey, {
660
- profileId: project,
661
- sessionId: input.session_id || `hook-${Date.now()}`,
662
- userMessage: label,
663
- agentProse: rawText,
664
- crystallization: 'compaction',
665
- });
666
- }
667
- }
668
- /** PreCompact — extract raw session text, send to API for LLM summarization + storage. */
669
- async function summary(input) {
670
- const extracted = extractSessionText(input);
671
- if (!extracted)
672
- return;
673
- await storeSessionSummary(input, extracted.rawText, extracted.cwd, extracted.project, '[Session Summary]');
674
- }
675
- /** SessionEnd — store final session summary (fire-and-forget to survive teardown). */
676
- async function farewell(input) {
677
- const extracted = extractSessionText(input);
678
- if (!extracted)
679
- return;
680
- // Only store to API if content is substantial (skip tiny post-compaction tails)
681
- if (extracted.rawText.split(/\s+/).length < 30)
682
- return;
683
- const cfg = getConfig(extracted.cwd);
684
635
  if (!cfg.enabled || !cfg.apiKey)
685
636
  return;
686
- // Fire-and-forget: send request but don't await — survives session teardown
687
- fetch(`${cfg.apiUrl}/v1/memory/store`, {
688
- method: 'POST',
689
- headers: {
690
- 'Authorization': `Bearer ${cfg.apiKey}`,
691
- 'Content-Type': 'application/json',
692
- },
693
- body: JSON.stringify({
694
- profileId: extracted.project,
695
- sessionId: input.session_id || `hook-${Date.now()}`,
696
- userMessage: '[Session End]',
697
- agentProse: extracted.rawText,
698
- crystallization: 'compaction',
699
- }),
700
- }).catch(() => { });
701
- // Brief delay to let the HTTP request body flush to the socket
702
- await new Promise(resolve => setTimeout(resolve, 300));
703
- }
704
- function isOlderThanDays(isoDate, days) {
705
- return Date.now() - new Date(isoDate).getTime() > days * 24 * 60 * 60 * 1000;
637
+ const anchor = (0, project_anchor_1.readAnchor)(cwd);
638
+ const turn = parseLatestTurn(input.transcript_path);
639
+ // Flatten subagent tool_use blocks into the main turn so artifact detection
640
+ // catches commits/deploys/PRs a subagent fired. See adr/raw-turn-capture.md.
641
+ if (input.transcript_path && turn.userStartTime) {
642
+ const sub = parseSubagentCalls(input.transcript_path, turn.userStartTime);
643
+ if (sub.toolCalls.length > 0) {
644
+ turn.toolCalls.push(...sub.toolCalls);
645
+ const filesSet = new Set([...turn.filesTouched, ...sub.filesTouched]);
646
+ turn.filesTouched = Array.from(filesSet).sort();
647
+ }
648
+ }
649
+ // Empty turn — nothing to capture.
650
+ if (!turn.userPrompt && !turn.agentResponse && turn.toolCalls.length === 0)
651
+ return;
652
+ const branch = getBranch(cwd);
653
+ const shipEvents = detectArtifacts(turn.toolCalls, cwd);
654
+ // Env-var scrub
655
+ const redaction = buildRedactionMap(cwd);
656
+ const userPrompt = capField(scrubString(turn.userPrompt, redaction));
657
+ const agentResponse = capField(scrubString(turn.agentResponse, redaction));
658
+ // Strip internal-only fields (_output, _useId) before sending to API,
659
+ // then scrub. The internal fields fed detectArtifacts above.
660
+ const toolCallsForApi = turn.toolCalls.map(tc => ({
661
+ name: tc.name, target: tc.target, brief: tc.brief,
662
+ }));
663
+ const toolCalls = scrubObject(toolCallsForApi, redaction);
664
+ const artifactRefs = shipEvents.map(ev => `${ev.artifactType}:${ev.artifactId.slice(0, 12)}`);
665
+ await apiCall(`${cfg.apiUrl}/v1/memory/turn`, cfg.apiKey, {
666
+ projectId: anchor.projectId,
667
+ projectName: anchor.projectName,
668
+ branch,
669
+ workingDir: cwd,
670
+ sessionIdExternal: input.session_id || `hook-${Date.now()}`,
671
+ turnId: crypto.randomUUID(),
672
+ model: turn.model,
673
+ status: turn.status,
674
+ userPrompt,
675
+ agentResponse,
676
+ toolCalls,
677
+ filesTouched: turn.filesTouched,
678
+ artifacts: artifactRefs,
679
+ shipEvents,
680
+ });
706
681
  }
707
- /** Fetch latest session summary + profile summary from API and inject into stdout. */
708
- async function injectRecap(input, header, footer) {
682
+ /** SessionStart fetch recent episodic activity for this project and print
683
+ * to stdout. Claude Code injects the printed text as session context. Fires
684
+ * once per session. No LLM call. */
685
+ async function recap(input) {
709
686
  const cwd = input.cwd || process.cwd();
710
687
  const cfg = getConfig(cwd);
711
688
  if (!cfg.enabled || !cfg.apiKey)
712
689
  return;
713
- const profileId = path.basename(cwd);
714
- const result = await apiCall(`${cfg.apiUrl}/v1/memory/recap`, cfg.apiKey, { profileId });
715
- if (!result?.ok)
690
+ const anchor = (0, project_anchor_1.readAnchor)(cwd);
691
+ if (!anchor.projectId)
716
692
  return;
717
- const parts = [];
718
- // Profile summary block
719
- const pSummary = result.profileSummary;
720
- const pDetails = result.profileDetails || [];
721
- if (pSummary || pDetails.length > 0) {
722
- parts.push('--- PROFILE ---');
723
- if (pSummary)
724
- parts.push(pSummary);
725
- for (const d of pDetails)
726
- parts.push(`- ${d}`);
727
- }
728
- // Profile signal — request consolidation when stale, always accept details
729
- const stale = !pSummary
730
- || pDetails.length > 5
731
- || (result.profileSummaryUpdatedAt && isOlderThanDays(result.profileSummaryUpdatedAt, 7));
732
- const signals = [];
733
- if (stale)
734
- signals.push('<profile-summary>what this project is, what we work on, current state</profile-summary>');
735
- signals.push('<profile-detail>new fact about this project</profile-detail>');
736
- parts.push(`[greprag-profile: ${signals.join(' + ')}]`);
737
- // Session recap block
738
- if (result.found && result.enrichedSummary) {
739
- parts.push(header);
740
- parts.push(result.enrichedSummary);
741
- parts.push(footer);
742
- }
743
- if (parts.length > 0) {
744
- process.stdout.write(parts.join('\n') + '\n');
693
+ const now = new Date();
694
+ const todayStart = new Date(now);
695
+ todayStart.setUTCHours(0, 0, 0, 0);
696
+ const fromIso = new Date(now.getTime() - 7 * 86400_000).toISOString();
697
+ const toIso = now.toISOString();
698
+ const url = `${cfg.apiUrl}/v1/memory/by-period`
699
+ + `?projectId=${encodeURIComponent(anchor.projectId)}`
700
+ + `&from=${encodeURIComponent(fromIso)}`
701
+ + `&to=${encodeURIComponent(toIso)}`
702
+ + `&limit=200`;
703
+ let data = null;
704
+ try {
705
+ const res = await fetch(url, {
706
+ headers: { 'Authorization': `Bearer ${cfg.apiKey}` },
707
+ });
708
+ if (!res.ok)
709
+ return;
710
+ data = await res.json();
745
711
  }
746
- }
747
- /** SessionStart:startup — inject last session's summary for continuity. */
748
- async function recap(input) {
749
- await injectRecap(input, '--- LAST SESSION (auto-generated) ---', '--- END LAST SESSION ---');
750
- }
751
- /** SessionStart:compact recovery — inject cached session summary into context. */
752
- async function recover(input) {
753
- await injectRecap(input, '--- SESSION RECOVERY (auto-generated) ---', '--- END SESSION RECOVERY ---');
754
- }
755
- /** Manual handoff — store user-provided recap for next session (before /clear).
756
- * Fire-and-forget: sends the API request, prints confirmation immediately,
757
- * then exits after a short delay to let the TCP socket flush. */
758
- async function handoff(input) {
759
- const cwd = input.cwd || process.cwd();
760
- const cfg = getConfig(cwd);
761
- if (!cfg.enabled || !cfg.apiKey) {
762
- process.stderr.write('[greprag-hook] Memory hook not enabled or no API key\n');
712
+ catch {
763
713
  return;
764
714
  }
765
- const message = input.message || '';
766
- if (!message) {
767
- process.stderr.write('[greprag-hook] No handoff message provided\n');
715
+ const memories = data?.memories || [];
716
+ if (memories.length === 0)
768
717
  return;
718
+ // Bucket by crystallization
719
+ const dailies = memories.filter(m => m.crystallization === 'episodic-daily')
720
+ .sort((a, b) => (a.windowStart || '').localeCompare(b.windowStart || ''));
721
+ const hourlies = memories.filter(m => m.crystallization === 'episodic-hourly')
722
+ .sort((a, b) => (a.windowStart || '').localeCompare(b.windowStart || ''));
723
+ const shipEvents = memories.filter(m => m.crystallization === 'ship-event'
724
+ && m.artifactId
725
+ && m.artifactId !== 'pending').sort((a, b) => a.createdAt.localeCompare(b.createdAt));
726
+ // Most recent daily
727
+ const latestDaily = dailies[dailies.length - 1];
728
+ // Today's hourlies (window_start >= UTC midnight today)
729
+ const todaysHourlies = hourlies.filter(h => h.windowStart && new Date(h.windowStart).getTime() >= todayStart.getTime());
730
+ // Last 5 ship-events with real IDs
731
+ const recentShipEvents = shipEvents.slice(-5);
732
+ // Nothing useful to surface
733
+ if (!latestDaily && todaysHourlies.length === 0 && recentShipEvents.length === 0)
734
+ return;
735
+ const parts = [];
736
+ parts.push(`[Recent activity in ${anchor.projectName}]`);
737
+ if (latestDaily && latestDaily.windowStart) {
738
+ const dailyDate = latestDaily.windowStart.slice(0, 10);
739
+ parts.push('');
740
+ parts.push(`Last full day (${dailyDate}):`);
741
+ parts.push(latestDaily.content);
742
+ }
743
+ if (todaysHourlies.length > 0) {
744
+ parts.push('');
745
+ parts.push(`Today's activity:`);
746
+ for (const h of todaysHourlies) {
747
+ if (!h.windowStart || !h.windowEnd)
748
+ continue;
749
+ const startHHMM = h.windowStart.slice(11, 16);
750
+ const endHHMM = h.windowEnd.slice(11, 16);
751
+ // Pull the first numbered item from the hourly content as the gist
752
+ const m = h.content.match(/^\s*[0-9]+[.)]\s+([\s\S]+?)(?=\n\s*[0-9]+[.)]|\nOpen:|\nShipped:|$)/);
753
+ let gist = m ? m[1].trim() : h.content.trim();
754
+ gist = gist.replace(/\s+/g, ' ');
755
+ if (gist.length > 220)
756
+ gist = gist.slice(0, 217) + '…';
757
+ parts.push(`- ${startHHMM}–${endHHMM} UTC: ${gist}`);
758
+ }
769
759
  }
770
- const profileId = path.basename(cwd);
771
- const sessionId = input.session_id || `handoff-${Date.now()}`;
772
- // Fire-and-forget: send request but don't await the response
773
- fetch(`${cfg.apiUrl}/v1/memory/store`, {
774
- method: 'POST',
775
- headers: {
776
- 'Authorization': `Bearer ${cfg.apiKey}`,
777
- 'Content-Type': 'application/json',
778
- },
779
- body: JSON.stringify({
780
- profileId,
781
- sessionId,
782
- userMessage: '[Session End]',
783
- agentProse: message,
784
- crystallization: 'compaction',
785
- }),
786
- }).catch(() => { }); // swallow errors — already printed confirmation
787
- process.stdout.write('Handoff stored. Next session will pick it up via recap.\n');
788
- // Brief delay to let the HTTP request body flush to the socket
789
- await new Promise(resolve => setTimeout(resolve, 300));
760
+ if (recentShipEvents.length > 0) {
761
+ parts.push('');
762
+ parts.push(`Recently shipped:`);
763
+ for (const s of recentShipEvents) {
764
+ const id = (s.artifactId || '').slice(0, 12);
765
+ // s.content is "<artifact_type>: <title>" — strip the prefix
766
+ const title = s.content.replace(/^[^:]+:\s*/, '').slice(0, 120);
767
+ parts.push(`- ${s.artifactType} ${id}: ${title}`);
768
+ }
769
+ }
770
+ process.stdout.write(parts.join('\n') + '\n');
790
771
  }
791
- const SUBCOMMANDS = ['retrieve', 'store', 'compact', 'signal', 'summary', 'recover', 'farewell', 'recap', 'handoff'];
792
772
  async function main() {
793
773
  const subcommand = process.argv[2];
794
- if (!subcommand || !SUBCOMMANDS.includes(subcommand)) {
795
- process.stderr.write(`Usage: greprag-hook <${SUBCOMMANDS.join('|')}>\n`);
774
+ if (subcommand !== 'store' && subcommand !== 'recap') {
775
+ process.stderr.write(`Usage: greprag-hook <store|recap>\n`);
796
776
  process.exit(1);
797
777
  }
798
778
  let input = {};
@@ -808,36 +788,15 @@ async function main() {
808
788
  catch {
809
789
  process.exit(0);
810
790
  }
811
- if (subcommand === 'compact') {
812
- await compact(input);
813
- }
814
- else if (subcommand === 'retrieve') {
815
- await retrieve(input);
816
- }
817
- else if (subcommand === 'signal') {
818
- await signal(input);
819
- }
820
- else if (subcommand === 'summary') {
821
- await summary(input);
822
- }
823
- else if (subcommand === 'farewell') {
824
- await farewell(input);
825
- }
826
- else if (subcommand === 'recover') {
827
- await recover(input);
828
- }
829
- else if (subcommand === 'recap') {
791
+ if (subcommand === 'recap') {
830
792
  await recap(input);
831
793
  }
832
- else if (subcommand === 'handoff') {
833
- await handoff(input);
834
- }
835
794
  else {
836
795
  await store(input);
837
796
  }
838
797
  }
839
798
  main().catch(err => {
840
799
  process.stderr.write(`[greprag-hook] Fatal: ${err.message}\n`);
841
- process.exit(0); // never break a session
800
+ process.exit(0);
842
801
  });
843
802
  //# sourceMappingURL=hook.js.map