greprag 0.4.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/commands/init.js +6 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/hook.d.ts +5 -11
- package/dist/hook.js +617 -413
- 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,669 @@ 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;
|
|
411
|
+
return { toolCalls: merged, filesTouched: Array.from(filesTouched).sort() };
|
|
199
412
|
}
|
|
200
|
-
|
|
201
|
-
function
|
|
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));
|
|
249
|
-
}
|
|
250
|
-
/** Find the most recently modified plan file in ~/.claude/plans/ */
|
|
251
|
-
function readNewestPlanFile() {
|
|
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
|
-
|
|
441
|
-
}
|
|
442
|
-
/** SessionEnd — store final session summary (fire-and-forget to survive teardown). */
|
|
443
|
-
async function farewell(input) {
|
|
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;
|
|
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]`;
|
|
470
630
|
}
|
|
471
|
-
|
|
472
|
-
async function
|
|
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
|
|
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. */
|
|
511
685
|
async function recap(input) {
|
|
512
|
-
await injectRecap(input, '--- LAST SESSION (auto-generated) ---', '--- END LAST SESSION ---');
|
|
513
|
-
}
|
|
514
|
-
/** SessionStart:compact recovery — reinject cached session summary + memory reminder. */
|
|
515
|
-
async function recover(input) {
|
|
516
|
-
await injectRecap(input, '--- SESSION RECOVERY (auto-generated) ---', '--- END SESSION RECOVERY ---');
|
|
517
|
-
}
|
|
518
|
-
/** Manual handoff — store user-provided recap for next session (before /clear).
|
|
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) {
|
|
522
686
|
const cwd = input.cwd || process.cwd();
|
|
523
687
|
const cfg = getConfig(cwd);
|
|
524
|
-
if (!cfg.enabled || !cfg.apiKey)
|
|
525
|
-
|
|
688
|
+
if (!cfg.enabled || !cfg.apiKey)
|
|
689
|
+
return;
|
|
690
|
+
const anchor = (0, project_anchor_1.readAnchor)(cwd);
|
|
691
|
+
if (!anchor.projectId)
|
|
526
692
|
return;
|
|
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();
|
|
527
711
|
}
|
|
528
|
-
|
|
529
|
-
if (!message) {
|
|
530
|
-
process.stderr.write('[greprag-hook] No handoff message provided\n');
|
|
712
|
+
catch {
|
|
531
713
|
return;
|
|
532
714
|
}
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
715
|
+
const memories = data?.memories || [];
|
|
716
|
+
if (memories.length === 0)
|
|
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
|
+
}
|
|
759
|
+
}
|
|
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');
|
|
551
771
|
}
|
|
552
|
-
const SUBCOMMANDS = ['store', 'signal', 'summary', 'recover', 'farewell', 'recap', 'handoff'];
|
|
553
772
|
async function main() {
|
|
554
773
|
const subcommand = process.argv[2];
|
|
555
|
-
if (
|
|
556
|
-
process.stderr.write(`Usage: greprag-hook
|
|
774
|
+
if (subcommand !== 'store' && subcommand !== 'recap') {
|
|
775
|
+
process.stderr.write(`Usage: greprag-hook <store|recap>\n`);
|
|
557
776
|
process.exit(1);
|
|
558
777
|
}
|
|
559
778
|
let input = {};
|
|
@@ -569,24 +788,9 @@ async function main() {
|
|
|
569
788
|
catch {
|
|
570
789
|
process.exit(0);
|
|
571
790
|
}
|
|
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') {
|
|
791
|
+
if (subcommand === 'recap') {
|
|
585
792
|
await recap(input);
|
|
586
793
|
}
|
|
587
|
-
else if (subcommand === 'handoff') {
|
|
588
|
-
await handoff(input);
|
|
589
|
-
}
|
|
590
794
|
else {
|
|
591
795
|
await store(input);
|
|
592
796
|
}
|