greprag 0.4.0 → 0.5.1
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/commands/init.js +6 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/hook.d.ts +5 -11
- package/dist/hook.js +638 -412
- package/dist/hook.js.map +1 -1
- package/dist/project-anchor.d.ts +27 -0
- package/dist/project-anchor.js +142 -0
- package/dist/project-anchor.js.map +1 -0
- package/package.json +1 -1
package/dist/hook.js
CHANGED
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
|
-
/** GrepRAG Hook —
|
|
3
|
+
/** GrepRAG Hook — Stop-hook turn capture for episodic memory.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
* Subcommands: store, signal, summary, recover, farewell, recap, handoff
|
|
10
|
-
*
|
|
11
|
-
* Lifecycle:
|
|
12
|
-
* recap(SessionStart:startup|clear) → [agent turn] → store(Stop) → farewell(SessionEnd)
|
|
13
|
-
* summary(PreCompact) → recover(SessionStart:compact)
|
|
14
|
-
* 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
|
+
*/
|
|
15
9
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
16
10
|
if (k2 === undefined) k2 = k;
|
|
17
11
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
@@ -48,16 +42,12 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
48
42
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
43
|
const path = __importStar(require("path"));
|
|
50
44
|
const fs = __importStar(require("fs"));
|
|
51
|
-
const
|
|
45
|
+
const crypto = __importStar(require("crypto"));
|
|
46
|
+
const child_process_1 = require("child_process");
|
|
47
|
+
const project_anchor_1 = require("./project-anchor");
|
|
52
48
|
const API_URL_DEFAULT = 'https://api.greprag.com';
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return {
|
|
56
|
-
apiUrl: process.env.GREPRAG_API_URL || API_URL_DEFAULT,
|
|
57
|
-
apiKey: process.env.GREPRAG_API_KEY || '',
|
|
58
|
-
enabled: process.env.MEMORY_HOOK_ENABLED === 'true',
|
|
59
|
-
};
|
|
60
|
-
}
|
|
49
|
+
const MAX_FIELD_CHARS = 500_000; // safety cap per text field
|
|
50
|
+
// ---------- Env + config ---------------------------------------------------
|
|
61
51
|
function loadEnvFile(filePath) {
|
|
62
52
|
try {
|
|
63
53
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
@@ -74,14 +64,11 @@ function loadEnvFile(filePath) {
|
|
|
74
64
|
(value.startsWith("'") && value.endsWith("'"))) {
|
|
75
65
|
value = value.slice(1, -1);
|
|
76
66
|
}
|
|
77
|
-
if (!process.env[key])
|
|
67
|
+
if (!process.env[key])
|
|
78
68
|
process.env[key] = value;
|
|
79
|
-
}
|
|
80
69
|
}
|
|
81
70
|
}
|
|
82
|
-
catch {
|
|
83
|
-
// File doesn't exist — fine
|
|
84
|
-
}
|
|
71
|
+
catch { /* file missing — fine */ }
|
|
85
72
|
}
|
|
86
73
|
function ensureEnv(cwd) {
|
|
87
74
|
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
@@ -90,6 +77,14 @@ function ensureEnv(cwd) {
|
|
|
90
77
|
if (!process.env.GREPRAG_API_KEY)
|
|
91
78
|
loadEnvFile(path.join(cwd, '.env'));
|
|
92
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
|
+
}
|
|
93
88
|
async function apiCall(url, apiKey, body) {
|
|
94
89
|
const res = await fetch(url, {
|
|
95
90
|
method: 'POST',
|
|
@@ -115,445 +110,691 @@ function extractText(content) {
|
|
|
115
110
|
.map((b) => b.text || '')
|
|
116
111
|
.join('\n').trim();
|
|
117
112
|
}
|
|
118
|
-
/**
|
|
119
|
-
*
|
|
120
|
-
function
|
|
121
|
-
if (
|
|
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))
|
|
122
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;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return tc;
|
|
180
|
+
}
|
|
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');
|
|
193
|
+
}
|
|
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;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
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
|
+
}
|
|
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
|
+
};
|
|
305
|
+
}
|
|
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 = [];
|
|
123
327
|
try {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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)
|
|
129
351
|
continue;
|
|
130
352
|
let entry;
|
|
131
353
|
try {
|
|
132
|
-
entry = JSON.parse(
|
|
354
|
+
entry = JSON.parse(trimmed);
|
|
133
355
|
}
|
|
134
356
|
catch {
|
|
135
357
|
continue;
|
|
136
358
|
}
|
|
359
|
+
const ts = typeof entry.timestamp === 'string' ? new Date(entry.timestamp).getTime() : NaN;
|
|
360
|
+
if (!Number.isFinite(ts) || ts < sinceMs)
|
|
361
|
+
continue;
|
|
137
362
|
const msg = (entry.message || entry);
|
|
138
363
|
const role = (msg.role || entry.type || '');
|
|
139
|
-
if (role !== '
|
|
364
|
+
if (role !== 'assistant' || !Array.isArray(msg.content))
|
|
140
365
|
continue;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
.some(b => b?.type === 'tool_result');
|
|
144
|
-
if (isToolResult)
|
|
366
|
+
for (const b of msg.content) {
|
|
367
|
+
if (b?.type !== 'tool_use')
|
|
145
368
|
continue;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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);
|
|
375
|
+
}
|
|
376
|
+
else if (tc.name === 'NotebookEdit' && input.notebook_path) {
|
|
377
|
+
filesTouched.add(input.notebook_path);
|
|
378
|
+
}
|
|
150
379
|
}
|
|
151
380
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
/** Detect action in the last assistant turn — reads transcript for tool_use blocks.
|
|
157
|
-
* A single turn spans multiple requestIds (text → tool_use → tool_result → text → ...).
|
|
158
|
-
* Walk backwards through the transcript; stop at a real user message (non-tool_result). */
|
|
159
|
-
function detectAction(transcriptPath, agentProse) {
|
|
160
|
-
if (/```/.test(agentProse))
|
|
161
|
-
return true;
|
|
162
|
-
if (!transcriptPath)
|
|
163
|
-
return false;
|
|
164
|
-
try {
|
|
165
|
-
const raw = fs.readFileSync(transcriptPath, 'utf-8');
|
|
166
|
-
const lines = raw.split('\n');
|
|
167
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
168
|
-
const line = lines[i].trim();
|
|
169
|
-
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)
|
|
170
385
|
continue;
|
|
171
386
|
let entry;
|
|
172
387
|
try {
|
|
173
|
-
entry = JSON.parse(
|
|
388
|
+
entry = JSON.parse(trimmed);
|
|
174
389
|
}
|
|
175
390
|
catch {
|
|
176
391
|
continue;
|
|
177
392
|
}
|
|
178
393
|
const msg = (entry.message || entry);
|
|
179
394
|
const role = (msg.role || entry.type || '');
|
|
180
|
-
if (role
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (b?.type === 'tool_use')
|
|
192
|
-
return true;
|
|
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;
|
|
193
406
|
}
|
|
194
407
|
}
|
|
195
408
|
}
|
|
409
|
+
merged.push(...localCalls);
|
|
196
410
|
}
|
|
197
|
-
|
|
198
|
-
return false;
|
|
199
|
-
}
|
|
200
|
-
/** Parse <profile-summary> tag from agent response. Returns null if not found. */
|
|
201
|
-
function parseProfileSummary(text) {
|
|
202
|
-
const match = text.match(/<profile-summary>([\s\S]*?)<\/profile-summary>/);
|
|
203
|
-
return match && match[1].trim() ? match[1].trim() : null;
|
|
204
|
-
}
|
|
205
|
-
/** Parse <profile-detail> tags from agent response. Returns array of details. */
|
|
206
|
-
function parseProfileDetails(text) {
|
|
207
|
-
const results = [];
|
|
208
|
-
const regex = /<profile-detail>([\s\S]*?)<\/profile-detail>/g;
|
|
209
|
-
let match;
|
|
210
|
-
while ((match = regex.exec(text)) !== null) {
|
|
211
|
-
const detail = match[1].trim();
|
|
212
|
-
if (detail)
|
|
213
|
-
results.push(detail);
|
|
214
|
-
}
|
|
215
|
-
return results;
|
|
216
|
-
}
|
|
217
|
-
/** Stop — store the turn. Reads user message from transcript (no prior caching). */
|
|
218
|
-
async function store(input) {
|
|
219
|
-
const cwd = input.cwd || process.cwd();
|
|
220
|
-
const cfg = getConfig(cwd);
|
|
221
|
-
if (!cfg.enabled || !cfg.apiKey)
|
|
222
|
-
return;
|
|
223
|
-
const agentProse = input.last_assistant_message || '';
|
|
224
|
-
if (!agentProse)
|
|
225
|
-
return;
|
|
226
|
-
const userMessage = findLastUserMessage(input.transcript_path);
|
|
227
|
-
const sessionId = input.session_id || `hook-${Date.now()}`;
|
|
228
|
-
const profileId = path.basename(cwd);
|
|
229
|
-
const hasAction = detectAction(input.transcript_path, agentProse);
|
|
230
|
-
const storePromise = apiCall(`${cfg.apiUrl}/v1/memory/store`, cfg.apiKey, {
|
|
231
|
-
profileId,
|
|
232
|
-
sessionId,
|
|
233
|
-
userMessage,
|
|
234
|
-
agentProse,
|
|
235
|
-
hasAction,
|
|
236
|
-
crystallization: 'turn',
|
|
237
|
-
});
|
|
238
|
-
let profilePromise = null;
|
|
239
|
-
const profSummary = parseProfileSummary(agentProse);
|
|
240
|
-
const profDetails = parseProfileDetails(agentProse);
|
|
241
|
-
if (profSummary || profDetails.length > 0) {
|
|
242
|
-
profilePromise = apiCall(`${cfg.apiUrl}/v1/memory/profile/summary`, cfg.apiKey, {
|
|
243
|
-
profileId,
|
|
244
|
-
summary: profSummary || undefined,
|
|
245
|
-
details: profDetails.length > 0 && !profSummary ? profDetails : undefined,
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
await Promise.all([storePromise, profilePromise].filter(Boolean));
|
|
411
|
+
return { toolCalls: merged, filesTouched: Array.from(filesTouched).sort() };
|
|
249
412
|
}
|
|
250
|
-
|
|
251
|
-
function
|
|
252
|
-
const plansDir = path.join(os.homedir(), '.claude', 'plans');
|
|
413
|
+
// ---------- Git branch + artifact detection -------------------------------
|
|
414
|
+
function getBranch(cwd) {
|
|
253
415
|
try {
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
.map(f => {
|
|
257
|
-
const full = path.join(plansDir, f);
|
|
258
|
-
return { path: full, mtime: fs.statSync(full).mtimeMs };
|
|
259
|
-
})
|
|
260
|
-
.sort((a, b) => b.mtime - a.mtime);
|
|
261
|
-
if (!files.length)
|
|
262
|
-
return null;
|
|
263
|
-
if (Date.now() - files[0].mtime > 60_000)
|
|
264
|
-
return null;
|
|
265
|
-
return fs.readFileSync(files[0].path, 'utf-8');
|
|
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;
|
|
266
418
|
}
|
|
267
419
|
catch {
|
|
268
420
|
return null;
|
|
269
421
|
}
|
|
270
422
|
}
|
|
271
|
-
/**
|
|
272
|
-
function
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
const question = q.question || '';
|
|
280
|
-
const answer = answers?.[question] || '';
|
|
281
|
-
if (question) {
|
|
282
|
-
lines.push(`Q: ${question}`);
|
|
283
|
-
lines.push(`A: ${answer || '(no answer)'}`);
|
|
284
|
-
lines.push('');
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
return lines.join('\n').trim();
|
|
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)
|
|
427
|
+
return null;
|
|
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 };
|
|
288
431
|
}
|
|
289
|
-
/**
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const sessionId = input.session_id || `hook-${Date.now()}`;
|
|
297
|
-
const profileId = path.basename(cwd);
|
|
298
|
-
let userMessage = '';
|
|
299
|
-
let agentProse = '';
|
|
300
|
-
let crystallization = '';
|
|
301
|
-
if (toolName === 'ExitPlanMode') {
|
|
302
|
-
const planContent = readNewestPlanFile();
|
|
303
|
-
if (!planContent)
|
|
304
|
-
return;
|
|
305
|
-
userMessage = '[Plan Approved]';
|
|
306
|
-
const words = planContent.split(/\s+/);
|
|
307
|
-
agentProse = words.length > 2000
|
|
308
|
-
? words.slice(0, 2000).join(' ') + '\n\n[truncated]'
|
|
309
|
-
: planContent;
|
|
310
|
-
crystallization = 'plan';
|
|
311
|
-
}
|
|
312
|
-
else if (toolName === 'AskUserQuestion') {
|
|
313
|
-
const toolInput = input.tool_input || {};
|
|
314
|
-
const toolResponse = input.tool_response || {};
|
|
315
|
-
const formatted = formatQuestionAnswers(toolInput, toolResponse);
|
|
316
|
-
if (!formatted)
|
|
317
|
-
return;
|
|
318
|
-
userMessage = formatted;
|
|
319
|
-
agentProse = '';
|
|
320
|
-
crystallization = 'decision';
|
|
321
|
-
}
|
|
322
|
-
else {
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
await apiCall(`${cfg.apiUrl}/v1/memory/store`, cfg.apiKey, {
|
|
326
|
-
profileId,
|
|
327
|
-
sessionId,
|
|
328
|
-
userMessage,
|
|
329
|
-
agentProse,
|
|
330
|
-
crystallization,
|
|
331
|
-
});
|
|
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] };
|
|
332
439
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
const responses = new Map();
|
|
338
|
-
let raw;
|
|
339
|
-
try {
|
|
340
|
-
raw = fs.readFileSync(transcriptPath, 'utf-8');
|
|
341
|
-
}
|
|
342
|
-
catch {
|
|
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)
|
|
343
444
|
return null;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
|
|
445
|
+
return { id: m[1], url: null, title: m[1] };
|
|
446
|
+
}
|
|
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)
|
|
452
|
+
return null;
|
|
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')
|
|
347
465
|
continue;
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
+
});
|
|
351
480
|
}
|
|
352
|
-
|
|
353
|
-
|
|
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
|
+
});
|
|
354
489
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
+
});
|
|
363
498
|
}
|
|
364
|
-
if (
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
+
});
|
|
381
532
|
}
|
|
382
533
|
}
|
|
534
|
+
catch { /* git log failed */ }
|
|
383
535
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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}]`]);
|
|
397
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)
|
|
574
|
+
continue;
|
|
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);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
catch { /* skip */ }
|
|
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;
|
|
398
598
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const parts = [];
|
|
409
|
-
if (parsed.userReqs.length)
|
|
410
|
-
parts.push('User requests: ' + parsed.userReqs.join('; '));
|
|
411
|
-
if (parsed.files.length)
|
|
412
|
-
parts.push('Files modified: ' + parsed.files.slice(0, 20).join(', '));
|
|
413
|
-
if (parsed.cmds.length)
|
|
414
|
-
parts.push('Commands: ' + parsed.cmds.join('; '));
|
|
415
|
-
if (parsed.responses.length)
|
|
416
|
-
parts.push('Recent context:\n' + parsed.responses.join('\n'));
|
|
417
|
-
const rawText = parts.join('\n\n');
|
|
418
|
-
if (!rawText.trim())
|
|
419
|
-
return null;
|
|
420
|
-
return { rawText, cwd, project: path.basename(cwd) };
|
|
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;
|
|
421
608
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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;
|
|
433
620
|
}
|
|
621
|
+
return obj;
|
|
434
622
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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]`;
|
|
441
630
|
}
|
|
442
|
-
|
|
443
|
-
async function
|
|
444
|
-
const extracted = extractSessionText(input);
|
|
445
|
-
if (!extracted)
|
|
446
|
-
return;
|
|
447
|
-
if (extracted.rawText.split(/\s+/).length < 30)
|
|
448
|
-
return;
|
|
449
|
-
const cfg = getConfig(extracted.cwd);
|
|
450
|
-
if (!cfg.enabled || !cfg.apiKey)
|
|
451
|
-
return;
|
|
452
|
-
fetch(`${cfg.apiUrl}/v1/memory/store`, {
|
|
453
|
-
method: 'POST',
|
|
454
|
-
headers: {
|
|
455
|
-
'Authorization': `Bearer ${cfg.apiKey}`,
|
|
456
|
-
'Content-Type': 'application/json',
|
|
457
|
-
},
|
|
458
|
-
body: JSON.stringify({
|
|
459
|
-
profileId: extracted.project,
|
|
460
|
-
sessionId: input.session_id || `hook-${Date.now()}`,
|
|
461
|
-
userMessage: '[Session End]',
|
|
462
|
-
agentProse: extracted.rawText,
|
|
463
|
-
crystallization: 'compaction',
|
|
464
|
-
}),
|
|
465
|
-
}).catch(() => { });
|
|
466
|
-
await new Promise(resolve => setTimeout(resolve, 300));
|
|
467
|
-
}
|
|
468
|
-
function isOlderThanDays(isoDate, days) {
|
|
469
|
-
return Date.now() - new Date(isoDate).getTime() > days * 24 * 60 * 60 * 1000;
|
|
470
|
-
}
|
|
471
|
-
/** Fetch profile + session recap, inject into stdout with memory-tools reminder. */
|
|
472
|
-
async function injectRecap(input, header, footer) {
|
|
631
|
+
// ---------- Store ---------------------------------------------------------
|
|
632
|
+
async function store(input) {
|
|
473
633
|
const cwd = input.cwd || process.cwd();
|
|
474
634
|
const cfg = getConfig(cwd);
|
|
475
635
|
if (!cfg.enabled || !cfg.apiKey)
|
|
476
636
|
return;
|
|
477
|
-
const
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
if (pSummary)
|
|
489
|
-
parts.push(pSummary);
|
|
490
|
-
for (const d of pDetails)
|
|
491
|
-
parts.push(`- ${d}`);
|
|
492
|
-
}
|
|
493
|
-
const stale = !pSummary
|
|
494
|
-
|| pDetails.length > 5
|
|
495
|
-
|| (result.profileSummaryUpdatedAt && isOlderThanDays(result.profileSummaryUpdatedAt, 7));
|
|
496
|
-
const signals = [];
|
|
497
|
-
if (stale)
|
|
498
|
-
signals.push('<profile-summary>what this project is, what we work on, current state</profile-summary>');
|
|
499
|
-
signals.push('<profile-detail>new fact about this project</profile-detail>');
|
|
500
|
-
parts.push(`[greprag-profile: ${signals.join(' + ')}]`);
|
|
501
|
-
if (result.found && result.enrichedSummary) {
|
|
502
|
-
parts.push(header);
|
|
503
|
-
parts.push(result.enrichedSummary);
|
|
504
|
-
parts.push(footer);
|
|
505
|
-
}
|
|
506
|
-
if (parts.length > 0) {
|
|
507
|
-
process.stdout.write(parts.join('\n') + '\n');
|
|
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
|
+
}
|
|
508
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
|
+
});
|
|
509
681
|
}
|
|
510
|
-
/** SessionStart
|
|
511
|
-
|
|
512
|
-
|
|
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
|
+
// ---------- Local-time formatting for recap display -----------------------
|
|
686
|
+
// Storage stays UTC (Cloudflare crons can only fire on UTC). The recap runs
|
|
687
|
+
// on the user's machine so it converts to local time for display, avoiding
|
|
688
|
+
// the UTC-vs-local-day confusion when a UTC daily window straddles two
|
|
689
|
+
// local calendar days.
|
|
690
|
+
function localTzShort() {
|
|
691
|
+
const parts = new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' })
|
|
692
|
+
.formatToParts(new Date());
|
|
693
|
+
return parts.find(p => p.type === 'timeZoneName')?.value || '';
|
|
694
|
+
}
|
|
695
|
+
function fmtLocalDayHour(iso) {
|
|
696
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
697
|
+
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true,
|
|
698
|
+
}).format(new Date(iso));
|
|
513
699
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
700
|
+
function fmtLocalHour(iso) {
|
|
701
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
702
|
+
hour: 'numeric', minute: '2-digit', hour12: true,
|
|
703
|
+
}).format(new Date(iso));
|
|
517
704
|
}
|
|
518
|
-
|
|
519
|
-
* Fire-and-forget: sends the API request, prints confirmation immediately,
|
|
520
|
-
* then exits after a short delay to let the TCP socket flush. */
|
|
521
|
-
async function handoff(input) {
|
|
705
|
+
async function recap(input) {
|
|
522
706
|
const cwd = input.cwd || process.cwd();
|
|
523
707
|
const cfg = getConfig(cwd);
|
|
524
|
-
if (!cfg.enabled || !cfg.apiKey)
|
|
525
|
-
|
|
708
|
+
if (!cfg.enabled || !cfg.apiKey)
|
|
709
|
+
return;
|
|
710
|
+
const anchor = (0, project_anchor_1.readAnchor)(cwd);
|
|
711
|
+
if (!anchor.projectId)
|
|
526
712
|
return;
|
|
713
|
+
const now = new Date();
|
|
714
|
+
// Local midnight today — boundary for "today's hourlies" in the user's frame
|
|
715
|
+
const localMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
716
|
+
const fromIso = new Date(now.getTime() - 7 * 86400_000).toISOString();
|
|
717
|
+
const toIso = now.toISOString();
|
|
718
|
+
const tz = localTzShort();
|
|
719
|
+
const url = `${cfg.apiUrl}/v1/memory/by-period`
|
|
720
|
+
+ `?projectId=${encodeURIComponent(anchor.projectId)}`
|
|
721
|
+
+ `&from=${encodeURIComponent(fromIso)}`
|
|
722
|
+
+ `&to=${encodeURIComponent(toIso)}`
|
|
723
|
+
+ `&limit=200`;
|
|
724
|
+
let data = null;
|
|
725
|
+
try {
|
|
726
|
+
const res = await fetch(url, {
|
|
727
|
+
headers: { 'Authorization': `Bearer ${cfg.apiKey}` },
|
|
728
|
+
});
|
|
729
|
+
if (!res.ok)
|
|
730
|
+
return;
|
|
731
|
+
data = await res.json();
|
|
527
732
|
}
|
|
528
|
-
|
|
529
|
-
if (!message) {
|
|
530
|
-
process.stderr.write('[greprag-hook] No handoff message provided\n');
|
|
733
|
+
catch {
|
|
531
734
|
return;
|
|
532
735
|
}
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
736
|
+
const memories = data?.memories || [];
|
|
737
|
+
if (memories.length === 0)
|
|
738
|
+
return;
|
|
739
|
+
// Bucket by crystallization
|
|
740
|
+
const dailies = memories.filter(m => m.crystallization === 'episodic-daily')
|
|
741
|
+
.sort((a, b) => (a.windowStart || '').localeCompare(b.windowStart || ''));
|
|
742
|
+
const hourlies = memories.filter(m => m.crystallization === 'episodic-hourly')
|
|
743
|
+
.sort((a, b) => (a.windowStart || '').localeCompare(b.windowStart || ''));
|
|
744
|
+
const shipEvents = memories.filter(m => m.crystallization === 'ship-event'
|
|
745
|
+
&& m.artifactId
|
|
746
|
+
&& m.artifactId !== 'pending').sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
747
|
+
// Most recent daily
|
|
748
|
+
const latestDaily = dailies[dailies.length - 1];
|
|
749
|
+
// Hourlies since LOCAL midnight today (the user's frame, not UTC's)
|
|
750
|
+
const todaysHourlies = hourlies.filter(h => h.windowStart && new Date(h.windowStart).getTime() >= localMidnight.getTime());
|
|
751
|
+
// Last 5 ship-events with real IDs
|
|
752
|
+
const recentShipEvents = shipEvents.slice(-5);
|
|
753
|
+
// Nothing useful to surface
|
|
754
|
+
if (!latestDaily && todaysHourlies.length === 0 && recentShipEvents.length === 0)
|
|
755
|
+
return;
|
|
756
|
+
const parts = [];
|
|
757
|
+
parts.push(`[Recent activity in ${anchor.projectName}] — times shown in ${tz}`);
|
|
758
|
+
if (latestDaily && latestDaily.windowStart && latestDaily.windowEnd) {
|
|
759
|
+
const range = `${fmtLocalDayHour(latestDaily.windowStart)} – ${fmtLocalDayHour(latestDaily.windowEnd)}`;
|
|
760
|
+
parts.push('');
|
|
761
|
+
parts.push(`Most recent daily (${range}):`);
|
|
762
|
+
parts.push(latestDaily.content);
|
|
763
|
+
}
|
|
764
|
+
if (todaysHourlies.length > 0) {
|
|
765
|
+
parts.push('');
|
|
766
|
+
parts.push(`Since midnight today (${fmtLocalDayHour(localMidnight.toISOString())}):`);
|
|
767
|
+
for (const h of todaysHourlies) {
|
|
768
|
+
if (!h.windowStart || !h.windowEnd)
|
|
769
|
+
continue;
|
|
770
|
+
const startHr = fmtLocalHour(h.windowStart);
|
|
771
|
+
const endHr = fmtLocalHour(h.windowEnd);
|
|
772
|
+
// Pull the first numbered item from the hourly content as the gist
|
|
773
|
+
const m = h.content.match(/^\s*[0-9]+[.)]\s+([\s\S]+?)(?=\n\s*[0-9]+[.)]|\nOpen:|\nShipped:|$)/);
|
|
774
|
+
let gist = m ? m[1].trim() : h.content.trim();
|
|
775
|
+
gist = gist.replace(/\s+/g, ' ');
|
|
776
|
+
if (gist.length > 220)
|
|
777
|
+
gist = gist.slice(0, 217) + '…';
|
|
778
|
+
parts.push(`- ${startHr} – ${endHr}: ${gist}`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (recentShipEvents.length > 0) {
|
|
782
|
+
parts.push('');
|
|
783
|
+
parts.push(`Recently shipped:`);
|
|
784
|
+
for (const s of recentShipEvents) {
|
|
785
|
+
const id = (s.artifactId || '').slice(0, 12);
|
|
786
|
+
// s.content is "<artifact_type>: <title>" — strip the prefix
|
|
787
|
+
const title = s.content.replace(/^[^:]+:\s*/, '').slice(0, 120);
|
|
788
|
+
const when = fmtLocalDayHour(s.createdAt);
|
|
789
|
+
parts.push(`- ${s.artifactType} ${id} (${when}): ${title}`);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
process.stdout.write(parts.join('\n') + '\n');
|
|
551
793
|
}
|
|
552
|
-
const SUBCOMMANDS = ['store', 'signal', 'summary', 'recover', 'farewell', 'recap', 'handoff'];
|
|
553
794
|
async function main() {
|
|
554
795
|
const subcommand = process.argv[2];
|
|
555
|
-
if (
|
|
556
|
-
process.stderr.write(`Usage: greprag-hook
|
|
796
|
+
if (subcommand !== 'store' && subcommand !== 'recap') {
|
|
797
|
+
process.stderr.write(`Usage: greprag-hook <store|recap>\n`);
|
|
557
798
|
process.exit(1);
|
|
558
799
|
}
|
|
559
800
|
let input = {};
|
|
@@ -569,24 +810,9 @@ async function main() {
|
|
|
569
810
|
catch {
|
|
570
811
|
process.exit(0);
|
|
571
812
|
}
|
|
572
|
-
if (subcommand === '
|
|
573
|
-
await signal(input);
|
|
574
|
-
}
|
|
575
|
-
else if (subcommand === 'summary') {
|
|
576
|
-
await summary(input);
|
|
577
|
-
}
|
|
578
|
-
else if (subcommand === 'farewell') {
|
|
579
|
-
await farewell(input);
|
|
580
|
-
}
|
|
581
|
-
else if (subcommand === 'recover') {
|
|
582
|
-
await recover(input);
|
|
583
|
-
}
|
|
584
|
-
else if (subcommand === 'recap') {
|
|
813
|
+
if (subcommand === 'recap') {
|
|
585
814
|
await recap(input);
|
|
586
815
|
}
|
|
587
|
-
else if (subcommand === 'handoff') {
|
|
588
|
-
await handoff(input);
|
|
589
|
-
}
|
|
590
816
|
else {
|
|
591
817
|
await store(input);
|
|
592
818
|
}
|