waku-memory 0.1.0 → 0.2.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/bootstrap.js +757 -0
- package/dist/capture.js +358 -63
- package/dist/cli.js +101 -11
- package/dist/dialogue.js +139 -0
- package/dist/hook.js +272 -121
- package/dist/project.js +43 -0
- package/package.json +1 -1
package/dist/hook.js
CHANGED
|
@@ -17,24 +17,35 @@
|
|
|
17
17
|
// new bytes, a non-2xx response, a rejected fetch) returns through its
|
|
18
18
|
// own branch before ever reaching that catch -- the catch is the net
|
|
19
19
|
// under the net, for whatever this file's author did not think of.
|
|
20
|
-
// 2. Content goes to the ingest endpoint and nowhere else
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
20
|
+
// 2. Content goes to the ingest endpoint and nowhere else, with one
|
|
21
|
+
// deliberate exception: SessionStart's postBrief writes POST /brief's
|
|
22
|
+
// `text` to stdout, which Claude Code folds into the session's
|
|
23
|
+
// context -- that is the entire point of task 9. stdout carries the
|
|
24
|
+
// brief and nothing else, ever. Every other channel is stderr
|
|
25
|
+
// (console.error), and never carries a transcript byte, a delta, or
|
|
26
|
+
// any other response body -- only counts, status codes, and the
|
|
27
|
+
// event/session names Claude Code itself already put on stdin.
|
|
24
28
|
//
|
|
25
|
-
// Zero dependencies, per the brief: only node:fs and node:path here
|
|
26
|
-
// the
|
|
27
|
-
//
|
|
28
|
-
import
|
|
29
|
+
// Zero dependencies, per the brief: only node:fs and node:path here (plus
|
|
30
|
+
// the sibling dialogue.ts module for the cc-dialogue-v1 rule, spec 011
|
|
31
|
+
// §1 -- a local file, not an npm package), plus the global fetch (Node
|
|
32
|
+
// 18+, no import needed) that deps.fetchImpl defaults to at the real call
|
|
33
|
+
// site in cli.ts.
|
|
34
|
+
import { appendFileSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
29
35
|
import { basename, dirname, join } from 'node:path';
|
|
36
|
+
import { CONTENT_FORMAT, formatDelta, renderEntries } from "./dialogue.js";
|
|
37
|
+
import { resolveProject } from "./project.js";
|
|
30
38
|
// Bumped by hand at release time alongside package.json's "version" -- the
|
|
31
39
|
// brief's call: a build-time constant here is fine, and it keeps this file
|
|
32
40
|
// free of any fs reach into its own package.json (which would also be
|
|
33
41
|
// wrong at runtime, since dist/hook.js does not sit next to package.json
|
|
34
|
-
// the way src/hook.ts does).
|
|
35
|
-
|
|
42
|
+
// the way src/hook.ts does). Exported since postBrief's request body and
|
|
43
|
+
// hook.test.mjs's drift check (against package.json) both need it; task 14
|
|
44
|
+
// bumps this and package.json to 0.2.0 together.
|
|
45
|
+
export const SHIM_VERSION = '0.2.0';
|
|
36
46
|
const HARNESS = 'claude_code';
|
|
37
|
-
|
|
47
|
+
// Exported for capture.ts, which writes the file this module reads.
|
|
48
|
+
export const CONFIG_FILE_NAME = 'config.json';
|
|
38
49
|
// Same-directory temp file + rename, exactly atomicWriteJson's technique in
|
|
39
50
|
// cli.ts (see its comment for the full why: rename is only atomic within
|
|
40
51
|
// one filesystem, so the temp file has to be a sibling of the real one).
|
|
@@ -81,7 +92,9 @@ function atomicWriteText(path, text) {
|
|
|
81
92
|
// this module's own atomicWriteText writing it (that always writes a clean
|
|
82
93
|
// non-negative integer), so this is cheap insurance against a corrupted
|
|
83
94
|
// local file, not a path this module's own writes are expected to hit.
|
|
84
|
-
|
|
95
|
+
// Exported: a later run (spec 011 §8's bootstrap) reads watermarks with
|
|
96
|
+
// the hook's own rule rather than re-implementing it.
|
|
97
|
+
export function readWatermark(watermarkPath) {
|
|
85
98
|
try {
|
|
86
99
|
const raw = readFileSync(watermarkPath, 'utf8').trim();
|
|
87
100
|
if (!/^\d+$/.test(raw))
|
|
@@ -93,25 +106,48 @@ function readWatermark(watermarkPath) {
|
|
|
93
106
|
return 0;
|
|
94
107
|
}
|
|
95
108
|
}
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
109
|
+
// Exported so postDelta (and a later run -- spec 011 §8's bootstrap) can
|
|
110
|
+
// advance the watermark the same way after each piece that succeeds: one
|
|
111
|
+
// atomic write, never a partial one a crash could leave mid-byte.
|
|
112
|
+
export function writeWatermark(watermarkPath, offset) {
|
|
113
|
+
atomicWriteText(watermarkPath, String(offset));
|
|
114
|
+
}
|
|
115
|
+
// spec 011 §3: beside the watermark, not instead of it -- a session that
|
|
116
|
+
// drops one span is still capturing everything else, and this file is the
|
|
117
|
+
// record of where the holes are.
|
|
118
|
+
export function droppedMarkerPath(watermarkPath) {
|
|
119
|
+
return watermarkPath + '.dropped';
|
|
120
|
+
}
|
|
121
|
+
// Pure read: formats what the file holds past the watermark by the
|
|
122
|
+
// cc-dialogue-v1 rule and returns the surviving pieces with absolute byte
|
|
123
|
+
// offsets, but writes nothing -- handleHookEvent only persists a piece's
|
|
124
|
+
// `end` after the POST built from its `text` actually succeeds (see
|
|
125
|
+
// postDelta). null covers two different "nothing to send yet" cases that
|
|
126
|
+
// the caller treats the same way: no transcript to read at all (missing or
|
|
127
|
+
// unreadable file -- e.g. transcript_path's documented async-write lag,
|
|
128
|
+
// research doc §2, means the file may not exist yet on a very short
|
|
129
|
+
// session), or bytes past the watermark that do not yet contain one
|
|
130
|
+
// complete line (spec 011 §2 -- a read landing inside a write must not
|
|
131
|
+
// send half a JSON record; the next event's read starts from the same
|
|
132
|
+
// watermark and sees the completed line). A non-null result can still hold
|
|
133
|
+
// zero pieces -- every new line was dropped by the formatting rule -- and
|
|
134
|
+
// the caller still advances the watermark to `end` in that case, per
|
|
135
|
+
// formatDelta's own contract.
|
|
99
136
|
export function readDelta(transcriptPath, watermarkPath) {
|
|
100
137
|
let buf;
|
|
101
138
|
try {
|
|
102
139
|
buf = readFileSync(transcriptPath);
|
|
103
140
|
}
|
|
104
141
|
catch {
|
|
105
|
-
// Missing or unreadable transcript -- e.g. transcript_path's documented
|
|
106
|
-
// async-write lag (research doc §2) means the file may not exist yet at
|
|
107
|
-
// all on a very short session. Nothing to send is not an error.
|
|
108
142
|
return null;
|
|
109
143
|
}
|
|
110
|
-
const size = buf.length;
|
|
111
144
|
const offset = readWatermark(watermarkPath);
|
|
112
|
-
if (offset >=
|
|
145
|
+
if (offset >= buf.length)
|
|
113
146
|
return null; // nothing new since last watermark (also covers a shrunk/rotated file)
|
|
114
|
-
|
|
147
|
+
const { pieces, end } = formatDelta(buf.subarray(offset));
|
|
148
|
+
if (end === 0)
|
|
149
|
+
return null; // the only new bytes are an incomplete trailing line
|
|
150
|
+
return { pieces: pieces.map((p) => ({ ...p, end: offset + p.end })), end: offset + end, offset };
|
|
115
151
|
}
|
|
116
152
|
// Reads {url, key} from <configDir>/config.json. Anything short of a clean
|
|
117
153
|
// {url: string, key: string} object -- the file absent (task 15 has not run
|
|
@@ -154,50 +190,118 @@ function postJson(deps, config, path, body, signal) {
|
|
|
154
190
|
signal,
|
|
155
191
|
});
|
|
156
192
|
}
|
|
157
|
-
// SessionEnd
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
193
|
+
// The SessionEnd flush has to finish inside the hook entry capture.ts
|
|
194
|
+
// installs for it, or Claude Code kills the process mid-request with no
|
|
195
|
+
// chance to log why. That entry's `timeout` (seconds) is
|
|
196
|
+
// SESSION_END_HOOK_TIMEOUT_S, defined here so capture.ts imports it and the
|
|
197
|
+
// two numbers cannot drift apart again: until 2026-09-04 the network budget
|
|
198
|
+
// below was 800 ms, derived from Claude Code's 1.5 s *default* SessionEnd
|
|
199
|
+
// budget, while the installed entry had already raised that budget to 10 s
|
|
200
|
+
// -- and 800 ms is one cold round trip to api.waku.one from Yang's machine
|
|
201
|
+
// (three real POST /ingest/session calls measured 822, 644 and 1174 ms), so
|
|
202
|
+
// two of every three flushes were aborted by our own timer. That is the
|
|
203
|
+
// last turn of every session, and all of a single-turn one.
|
|
161
204
|
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
205
|
+
// The network budget is the process budget minus what happens before the
|
|
206
|
+
// request: `npx -y waku-memory hook` took 2.8 s to reach this code cold
|
|
207
|
+
// (2026-09-04), and Node startup, stdin and the transcript read are a few
|
|
208
|
+
// hundred milliseconds more. 5 s leaves about 2 s of the 10 s for a slower
|
|
209
|
+
// machine. hook.test.mjs pins both bounds. Stop is not bounded this way
|
|
210
|
+
// (600 s budget, and runs "async": true), so its path through postDelta
|
|
211
|
+
// below does not use this.
|
|
212
|
+
export const SESSION_END_HOOK_TIMEOUT_S = 10;
|
|
213
|
+
export const SESSION_END_TIMEOUT_MS = 5000;
|
|
214
|
+
// undici wraps every network-level failure as TypeError("fetch failed") and
|
|
215
|
+
// puts the reason on `cause` -- a DNS name, a refused connection, our own
|
|
216
|
+
// abort, or (2026-09-04) an Authorization header carrying a control
|
|
217
|
+
// character. The wrapper alone told an operator nothing. The cause is an
|
|
218
|
+
// error class, a code and a sentence, never the request that failed, so
|
|
219
|
+
// printing it keeps rule 2 of the module comment.
|
|
174
220
|
function errorMessage(err) {
|
|
175
|
-
|
|
221
|
+
if (!(err instanceof Error))
|
|
222
|
+
return String(err);
|
|
223
|
+
const cause = err.cause;
|
|
224
|
+
if (!(cause instanceof Error))
|
|
225
|
+
return err.message;
|
|
226
|
+
const code = cause.code;
|
|
227
|
+
const codePart = typeof code === 'string' ? ` ${code}` : '';
|
|
228
|
+
return `${err.message} (cause: ${cause.name}${codePart}: ${cause.message})`;
|
|
176
229
|
}
|
|
177
|
-
|
|
178
|
-
|
|
230
|
+
// §6: 0.16 s start-up + one round trip measured 0.6-1.2 s; a black hole
|
|
231
|
+
// (server up but never answering) would otherwise hold the session's first
|
|
232
|
+
// turn open indefinitely, since nothing before postBrief bounds this wait.
|
|
233
|
+
export const BRIEF_TIMEOUT_MS = 3000;
|
|
234
|
+
// The memories a session opens with (spec 011 §5, §6, A9): posts the
|
|
235
|
+
// harness/version/project triple to /brief and writes whatever `text` it
|
|
236
|
+
// answers with to stdout, which Claude Code folds straight into the
|
|
237
|
+
// session's context. Mirrors postHeartbeat's shape (same config read, same
|
|
238
|
+
// do-nothing-and-log branches) but now bounded by an AbortController --
|
|
239
|
+
// unlike a heartbeat nobody was waiting on, a hung /brief request sits in
|
|
240
|
+
// the middle of the user's SessionStart, so this cannot wait forever.
|
|
241
|
+
async function postBrief(event, deps) {
|
|
179
242
|
const config = readConfig(deps.configDir);
|
|
180
243
|
if (!config) {
|
|
181
|
-
console.error(`waku-memory hook: no usable config at ${
|
|
244
|
+
console.error(`waku-memory hook: no usable config at ${join(deps.configDir, CONFIG_FILE_NAME)} -- no brief.`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (typeof event.cwd !== 'string' || event.cwd === '') {
|
|
248
|
+
// No project to ask for. A do-nothing branch says why (conventions §8).
|
|
249
|
+
console.error('waku-memory hook: no cwd on this SessionStart event -- no brief.');
|
|
182
250
|
return;
|
|
183
251
|
}
|
|
252
|
+
const project = resolveProject(event.cwd);
|
|
253
|
+
const controller = new AbortController();
|
|
254
|
+
const timer = setTimeout(() => controller.abort(), deps.briefTimeoutMs ?? BRIEF_TIMEOUT_MS);
|
|
184
255
|
try {
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
// response is still worth a log line. hook_heartbeat.version exists so
|
|
189
|
-
// an operator can tell what is running on a machine that stopped
|
|
190
|
-
// reporting; silently treating a 401 (revoked key) or a 5xx the same
|
|
191
|
-
// as success would throw that signal away.
|
|
192
|
-
const res = await postJson(deps, config, '/ingest/heartbeat', { harness: HARNESS, version: SHIM_VERSION });
|
|
256
|
+
// POST, not GET: `project` is caller-supplied free text and a query
|
|
257
|
+
// string would put it in every access log (spec 011 §5).
|
|
258
|
+
const res = await postJson(deps, config, '/brief', { harness: HARNESS, version: SHIM_VERSION, project }, controller.signal);
|
|
193
259
|
if (!res.ok) {
|
|
194
|
-
console.error(`waku-memory hook: /
|
|
260
|
+
console.error(`waku-memory hook: /brief responded ${res.status} -- no brief.`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const body = (await res.json());
|
|
264
|
+
if (typeof body.text === 'string' && body.text !== '') {
|
|
265
|
+
(deps.stdout ?? defaultStdout)(body.text);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
// A 2xx with missing, non-string, or empty text is a failure like any other.
|
|
269
|
+
console.error('waku-memory hook: /brief responded with no usable text -- no brief.');
|
|
195
270
|
}
|
|
196
271
|
}
|
|
197
272
|
catch (err) {
|
|
198
|
-
|
|
273
|
+
// This catch also handles a 2xx response whose body is not valid JSON.
|
|
274
|
+
console.error(`waku-memory hook: POST /brief failed -- ${errorMessage(err)}.`);
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
clearTimeout(timer);
|
|
199
278
|
}
|
|
200
279
|
}
|
|
280
|
+
const defaultStdout = (text) => {
|
|
281
|
+
process.stdout.write(text + '\n');
|
|
282
|
+
};
|
|
283
|
+
const squash = (s) => s.replace(/\s+/g, ' ').trim();
|
|
284
|
+
// spec 011 §2: the text of every assistant: entry after the last user:
|
|
285
|
+
// entry, joined by one space -- what "Claude's final response" (the hooks
|
|
286
|
+
// reference's own words for last_assistant_message) means at record
|
|
287
|
+
// granularity. assistant used tool: entries (role 'tool') are names, not
|
|
288
|
+
// text, and are skipped rather than treated as a boundary. Scoping to the
|
|
289
|
+
// trailing run, instead of searching the whole delta, is deliberate: a
|
|
290
|
+
// short reply like "Done." that also occurred several turns earlier must
|
|
291
|
+
// not be mistaken for the current turn's reply just because the words
|
|
292
|
+
// match.
|
|
293
|
+
export function trailingAssistantText(entries) {
|
|
294
|
+
let lastUser = -1;
|
|
295
|
+
entries.forEach((e, i) => {
|
|
296
|
+
if (e.role === 'user')
|
|
297
|
+
lastUser = i;
|
|
298
|
+
});
|
|
299
|
+
return entries
|
|
300
|
+
.slice(lastUser + 1)
|
|
301
|
+
.filter((e) => e.role === 'assistant')
|
|
302
|
+
.map((e) => e.text)
|
|
303
|
+
.join(' ');
|
|
304
|
+
}
|
|
201
305
|
async function postDelta(event, deps, source) {
|
|
202
306
|
if (!event.transcript_path) {
|
|
203
307
|
console.error(`waku-memory hook: no transcript_path on this ${event.hook_event_name} event -- skipping.`);
|
|
@@ -215,79 +319,126 @@ async function postDelta(event, deps, source) {
|
|
|
215
319
|
// configDir/state. The same-user trust boundary already limits the blast
|
|
216
320
|
// radius, but this removes the footgun rather than relying on that alone.
|
|
217
321
|
const watermarkPath = join(deps.configDir, 'state', basename(event.session_id));
|
|
218
|
-
|
|
322
|
+
let delta = readDelta(event.transcript_path, watermarkPath);
|
|
219
323
|
if (delta === null) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
catch (err) {
|
|
241
|
-
// Network error, DNS failure, our own SessionEnd timeout firing -- all
|
|
242
|
-
// reject rather than resolving with a bad status. Leaving the
|
|
243
|
-
// watermark alone resends exactly this delta next time; the ingest
|
|
244
|
-
// inbox's (user_id, session_id, content_hash) dedupe recognizes a retry
|
|
245
|
-
// of byte-identical content as already-seen and no-ops it for free.
|
|
246
|
-
// That guarantee is specific to retrying *this* unchanged delta -- see
|
|
247
|
-
// readWatermark's comment for the resend it does NOT cover.
|
|
248
|
-
console.error(`waku-memory hook: POST /ingest/session failed -- ${errorMessage(err)}.`);
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
finally {
|
|
252
|
-
// Must run before any of the res.ok handling below, success or not --
|
|
253
|
-
// an uncleared timer holds the process open for the rest of
|
|
254
|
-
// SESSION_END_TIMEOUT_MS for no reason once the request has settled.
|
|
255
|
-
if (timer)
|
|
256
|
-
clearTimeout(timer);
|
|
257
|
-
}
|
|
258
|
-
if (!res.ok && res.status !== 413) {
|
|
259
|
-
// Covers everything except 2xx and 413 (handled below): 422 (should
|
|
260
|
-
// not happen -- body shape is fixed here), 5xx, 401/403. Leaving the
|
|
261
|
-
// watermark unchanged means the next event resends the same bytes; see
|
|
262
|
-
// the catch block above for exactly what the inbox dedupe does and
|
|
263
|
-
// does not guarantee about that resend.
|
|
264
|
-
console.error(`waku-memory hook: /ingest/session responded ${res.status} -- watermark left unchanged.`);
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
if (res.status === 413) {
|
|
268
|
-
// The endpoint has no server-side chunking (MAX_INGEST_CONTENT_BYTES in
|
|
269
|
-
// ingest.py, checked before any repository call) -- this exact span can
|
|
270
|
-
// never succeed as-is. Since the watermark only advances on success and
|
|
271
|
-
// the transcript only grows, leaving it unchanged here would 413 again
|
|
272
|
-
// next time with an even larger span, permanently wedging capture for
|
|
273
|
-
// the rest of this session. Advancing past it loses this one delta but
|
|
274
|
-
// keeps every later turn capturable -- the strictly better of the two.
|
|
275
|
-
console.error(`waku-memory hook: /ingest/session responded 413 for session ${event.session_id} -- ` +
|
|
276
|
-
`dropping this span (too large to ever fit) and advancing past it.`);
|
|
324
|
+
// spec 011 §2: the transcript can lag the turn past every complete
|
|
325
|
+
// line it holds. On Stop, a non-empty last_assistant_message is
|
|
326
|
+
// Claude's own record of what it just said (the hooks reference: "use
|
|
327
|
+
// this field rather than reading transcript_path" for exactly this
|
|
328
|
+
// reason) -- when the file has nothing new at all, that reply has not
|
|
329
|
+
// landed in it yet, and this is the only chance to send it before the
|
|
330
|
+
// next event finds it in the file for real and skips it (the
|
|
331
|
+
// trailing-run check below). The piece's `end` is the current
|
|
332
|
+
// watermark: nothing from the file is being consumed, only the
|
|
333
|
+
// out-of-band message.
|
|
334
|
+
if (source === 'stop' && typeof event.last_assistant_message === 'string' && event.last_assistant_message.trim() !== '') {
|
|
335
|
+
const current = readWatermark(watermarkPath);
|
|
336
|
+
const entry = { role: 'assistant', text: event.last_assistant_message };
|
|
337
|
+
delta = { pieces: [{ entries: [entry], text: renderEntries([entry]), end: current }], end: current, offset: current };
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
console.error(`waku-memory hook: nothing new in the transcript for session ${event.session_id} -- skipping.`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
277
343
|
}
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
344
|
+
// spec 011 §2: append last_assistant_message as one more assistant:
|
|
345
|
+
// entry unless the trailing run already reads it, on normalised
|
|
346
|
+
// whitespace -- so a final turn split across two text blocks (joined by
|
|
347
|
+
// the harness with a newline or a blank line) still matches. This runs
|
|
348
|
+
// on the structured entries, never on a piece's already-joined text,
|
|
349
|
+
// because a text can itself contain a blank line and the joined form
|
|
350
|
+
// cannot be split back to find the boundary between records.
|
|
351
|
+
if (source === 'stop' && typeof event.last_assistant_message === 'string' && event.last_assistant_message.trim() !== '') {
|
|
352
|
+
const msg = event.last_assistant_message;
|
|
353
|
+
const all = delta.pieces.flatMap((p) => p.entries);
|
|
354
|
+
const present = squash(trailingAssistantText(all)).includes(squash(msg));
|
|
355
|
+
if (!present) {
|
|
356
|
+
const entry = { role: 'assistant', text: msg };
|
|
357
|
+
const last = delta.pieces[delta.pieces.length - 1];
|
|
358
|
+
if (last) {
|
|
359
|
+
last.entries.push(entry);
|
|
360
|
+
last.text = renderEntries(last.entries);
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
delta.pieces.push({ entries: [entry], text: renderEntries([entry]), end: delta.end });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
282
366
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
367
|
+
// Only session-end is bounded -- see SESSION_END_TIMEOUT_MS's comment.
|
|
368
|
+
// One deadline for the whole flush, not one budget per piece: several
|
|
369
|
+
// pieces each getting the full SESSION_END_TIMEOUT_MS would let a
|
|
370
|
+
// many-piece session run far longer than the budget the installed hook
|
|
371
|
+
// entry actually gives the process. Stop's deadline stays undefined, so
|
|
372
|
+
// every piece's controller below is undefined too -- identical to not
|
|
373
|
+
// passing a signal at all.
|
|
374
|
+
const budgetMs = deps.sessionEndTimeoutMs ?? SESSION_END_TIMEOUT_MS;
|
|
375
|
+
const deadline = source === 'session-end' ? Date.now() + budgetMs : undefined;
|
|
376
|
+
let previousEnd = delta.offset;
|
|
377
|
+
for (const piece of delta.pieces) {
|
|
378
|
+
const controller = deadline !== undefined ? new AbortController() : undefined;
|
|
379
|
+
const timer = controller ? setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now())) : undefined;
|
|
380
|
+
let res;
|
|
381
|
+
try {
|
|
382
|
+
res = await postJson(deps, config, '/ingest/session', {
|
|
383
|
+
harness: HARNESS,
|
|
384
|
+
session_id: event.session_id,
|
|
385
|
+
source,
|
|
386
|
+
content: piece.text,
|
|
387
|
+
content_format: CONTENT_FORMAT,
|
|
388
|
+
...(event.cwd ? { project: resolveProject(event.cwd) } : {}),
|
|
389
|
+
}, controller?.signal);
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
// Network error, DNS failure, our own SessionEnd timeout firing --
|
|
393
|
+
// all reject rather than resolving with a bad status. The watermark
|
|
394
|
+
// stays at the last piece that succeeded; the ingest inbox's
|
|
395
|
+
// (user_id, session_id, content_hash) dedupe recognizes a retry of
|
|
396
|
+
// byte-identical content as already-seen and no-ops it for free.
|
|
397
|
+
// That guarantee is specific to retrying *this* unchanged piece --
|
|
398
|
+
// see readWatermark's comment for the resend it does NOT cover.
|
|
399
|
+
console.error(`waku-memory hook: POST /ingest/session failed -- ${errorMessage(err)}.`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
// Must run before any of the res.ok handling below, success or not --
|
|
404
|
+
// an uncleared timer holds the process open for no reason once the
|
|
405
|
+
// request has settled.
|
|
406
|
+
if (timer)
|
|
407
|
+
clearTimeout(timer);
|
|
408
|
+
}
|
|
409
|
+
if (res.status === 413) {
|
|
410
|
+
// The endpoint has no server-side chunking (MAX_INGEST_CONTENT_BYTES
|
|
411
|
+
// in ingest.py, checked before any repository call) -- a piece is
|
|
412
|
+
// already cut under MAX_PIECE_BYTES, so this is the backstop for the
|
|
413
|
+
// one record that is itself over the cap (spec 011 §3), which no
|
|
414
|
+
// dialogue record should ever be. One span per line, appended -- a
|
|
415
|
+
// second drop in the same session adds a line, never overwrites the
|
|
416
|
+
// first, so bootstrap (§8) can see every hole.
|
|
417
|
+
mkdirSync(dirname(watermarkPath), { recursive: true });
|
|
418
|
+
appendFileSync(droppedMarkerPath(watermarkPath), `${previousEnd}-${piece.end}\n`);
|
|
419
|
+
console.error(`waku-memory hook: /ingest/session responded 413 for session ${event.session_id} -- ` +
|
|
420
|
+
`dropping this piece and advancing past it.`);
|
|
421
|
+
}
|
|
422
|
+
else if (!res.ok) {
|
|
423
|
+
// 422 included (spec 011 §4, deployment order): an API that does not
|
|
424
|
+
// yet know content_format leaves the watermark where it is, same as
|
|
425
|
+
// any other non-2xx (5xx, 401/403) -- the next event resends the
|
|
426
|
+
// whole growing delta rather than losing this piece.
|
|
427
|
+
console.error(`waku-memory hook: /ingest/session responded ${res.status} -- watermark left unchanged.`);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
// Past this point: res.ok (202 queued / 200 deduped) or 413 (see
|
|
431
|
+
// above) both advance the watermark the same way, to this piece's end.
|
|
432
|
+
writeWatermark(watermarkPath, piece.end);
|
|
433
|
+
previousEnd = piece.end;
|
|
290
434
|
}
|
|
435
|
+
// spec 011 §3: a delta that produced zero pieces (every new line was
|
|
436
|
+
// dropped by the formatting rule) still consumes to `delta.end` --
|
|
437
|
+
// formatDelta's own contract -- so those lines are never re-read. The
|
|
438
|
+
// same catches a trailing run of dropped lines after the last real
|
|
439
|
+
// piece, when that piece's own `end` stops short of the full delta.
|
|
440
|
+
if (delta.pieces.length === 0 || delta.end > previousEnd)
|
|
441
|
+
writeWatermark(watermarkPath, delta.end);
|
|
291
442
|
}
|
|
292
443
|
// Entry point cli.ts's run() calls after reading and JSON.parsing stdin --
|
|
293
444
|
// stdin reading deliberately does not live here (see the module comment and
|
|
@@ -310,7 +461,7 @@ export async function handleHookEvent(event, deps) {
|
|
|
310
461
|
}
|
|
311
462
|
switch (event.hook_event_name) {
|
|
312
463
|
case 'SessionStart':
|
|
313
|
-
await
|
|
464
|
+
await postBrief(event, deps);
|
|
314
465
|
return;
|
|
315
466
|
case 'Stop':
|
|
316
467
|
await postDelta(event, deps, 'stop');
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Spec 011 §7: the project is the directory name, except that a git
|
|
2
|
+
// worktree names the repository it belongs to. A worktree's `.git` is a
|
|
3
|
+
// file holding `gitdir: <repo>/.git/worktrees/<name>`; a checkout's `.git`
|
|
4
|
+
// is a directory. Read with fs only -- no git binary.
|
|
5
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
6
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
7
|
+
export function resolveProject(cwd, io = { statSync, readFileSync }) {
|
|
8
|
+
const start = resolve(cwd);
|
|
9
|
+
let dir = start;
|
|
10
|
+
for (;;) {
|
|
11
|
+
const dotGit = join(dir, '.git');
|
|
12
|
+
let stat;
|
|
13
|
+
try {
|
|
14
|
+
stat = io.statSync(dotGit);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
stat = undefined;
|
|
18
|
+
}
|
|
19
|
+
if (stat?.isDirectory())
|
|
20
|
+
return basename(start);
|
|
21
|
+
if (stat?.isFile()) {
|
|
22
|
+
let content;
|
|
23
|
+
try {
|
|
24
|
+
content = io.readFileSync(dotGit, 'utf8');
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return basename(start); // unreadable: the checkout's own name, never a throw (§7)
|
|
28
|
+
}
|
|
29
|
+
const m = /^gitdir:\s*(.+?)\s*$/m.exec(content);
|
|
30
|
+
if (m) {
|
|
31
|
+
const gitdir = isAbsolute(m[1]) ? m[1] : resolve(dir, m[1]);
|
|
32
|
+
const wt = /^(.*)[\\/]\.git[\\/]worktrees[\\/][^\\/]+$/.exec(gitdir);
|
|
33
|
+
if (wt)
|
|
34
|
+
return basename(wt[1]);
|
|
35
|
+
}
|
|
36
|
+
return basename(start);
|
|
37
|
+
}
|
|
38
|
+
const parent = dirname(dir);
|
|
39
|
+
if (parent === dir)
|
|
40
|
+
return basename(start);
|
|
41
|
+
dir = parent;
|
|
42
|
+
}
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "waku-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Install Waku Memory into your agent harness, and turn on automatic session capture.",
|
|
5
5
|
"keywords": ["mcp", "memory", "claude-code", "agent", "waku"],
|
|
6
6
|
"homepage": "https://github.com/ShenSeanChen/waku-memory-backend/tree/spec-driven/shim#readme",
|