waku-memory 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/hook.js CHANGED
@@ -1,40 +1,71 @@
1
1
  // The `hook` subcommand's decision logic. Wired by cli.ts: run() reads one
2
- // Claude Code hook event as JSON off stdin (that part deliberately does not
3
- // live here -- see the note on handleHookEvent below) and calls
4
- // handleHookEvent with the parsed event and a real deps bag. Everything in
5
- // this file takes its inputs as plain values, so hook.test.mjs can call it
6
- // directly against temp files and an injected fetchImpl, the same way
7
- // cli.test.mjs drives applyToHarness without touching a real ~/.claude.json.
2
+ // hook event as JSON off stdin (that part deliberately does not live here --
3
+ // see the note on handleHookEvent below) and calls handleHookEvent with the
4
+ // parsed event and a real deps bag. Everything in this file takes its
5
+ // inputs as plain values, so hook.test.mjs can call it directly against temp
6
+ // files and an injected fetchImpl, the same way cli.test.mjs drives
7
+ // applyToHarness without touching a real ~/.claude.json.
8
+ //
9
+ // Claude Code and Codex both land here (spec 012 §4): the event shape and
10
+ // the stdin fields this file reads were measured identical across the two,
11
+ // so this module itself never asks which harness sent an event -- deps
12
+ // carries a HarnessId, handleHookEvent resolves it to one HarnessProfile
13
+ // (harnesses.ts's PROFILES) up front, and every harness-specific choice
14
+ // (content format, delta formatter, timing budgets, how the brief is
15
+ // printed) reads off that one value. A third harness is a third profile,
16
+ // never a new branch in this file.
8
17
  //
9
18
  // This is the one piece of the shim that runs unattended, on every turn, in
10
19
  // the middle of somebody else's tool -- see task 14's brief for the full
11
20
  // argument. Two consequences shape every decision below:
12
21
  //
13
22
  // 1. It must never throw and never leave a nonzero exit behind. A capture
14
- // bug must not read to the user as their actual Claude Code session
15
- // failing. handleHookEvent wraps its entire body in one try/catch for
16
- // exactly this reason, and every expected failure (missing config, no
17
- // new bytes, a non-2xx response, a rejected fetch) returns through its
18
- // own branch before ever reaching that catch -- the catch is the net
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. No transcript
21
- // byte, no delta, no response body is ever passed to console.log or
22
- // console.error -- only counts, status codes, and the event/session
23
- // names Claude Code itself already put on stdin.
23
+ // bug must not read to the user as their actual session failing.
24
+ // handleHookEvent wraps its entire body in one try/catch for exactly
25
+ // this reason, and every expected failure (missing config, no new
26
+ // bytes, a non-2xx response, a rejected fetch) returns through its own
27
+ // branch before ever reaching that catch -- the catch is the net under
28
+ // the net, for whatever this file's author did not think of.
29
+ // 2. Content goes to the ingest endpoint and nowhere else, with one
30
+ // deliberate exception: SessionStart's postBrief writes POST /brief's
31
+ // `text` (run through the harness profile's briefOutput -- identity
32
+ // for Claude Code, the hook JSON contract for Codex) to stdout, which
33
+ // the harness folds into the session's context -- that is the entire
34
+ // point of task 9. stdout carries the brief and nothing else, ever.
35
+ // Every other channel is stderr (console.error), and never carries a
36
+ // transcript byte, a delta, or any other response body -- only counts,
37
+ // status codes, and the event/session names already on stdin. The one
38
+ // further exception is ruling R4 (spec 012 §4.1): the Codex profile
39
+ // stays silent on stderr too when there is no usable credential, since
40
+ // Codex has no interactive onboarding to have warned the user first.
24
41
  //
25
- // Zero dependencies, per the brief: only node:fs and node:path here, plus
26
- // the global fetch (Node 18+, no import needed) that deps.fetchImpl
27
- // defaults to at the real call site in cli.ts.
28
- import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
42
+ // Zero dependencies, per the brief: only node:fs and node:path here (plus
43
+ // the sibling dialogue.ts and harnesses.ts modules -- local files, not npm
44
+ // packages), plus the global fetch (Node 18+, no import needed) that
45
+ // deps.fetchImpl defaults to at the real call site in cli.ts.
46
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
29
47
  import { basename, dirname, join } from 'node:path';
48
+ import { formatDelta, renderEntries } from "./dialogue.js";
49
+ import { profileFor } from "./harnesses.js";
50
+ import { resolveProject } from "./project.js";
51
+ // SESSION_END_HOOK_TIMEOUT_S/SESSION_END_TIMEOUT_MS are the Claude Code
52
+ // profile's numbers (harnesses.ts's PROFILES.claude_code) -- defined there,
53
+ // not here, to avoid an import cycle (this file imports profileFor from
54
+ // harnesses.ts; harnesses.ts must not import back). Re-exported unchanged so
55
+ // capture.ts's existing `import { ... } from './hook.ts'` keeps working. See
56
+ // harnesses.ts's own comment on them for the full derivation.
57
+ export { SESSION_END_HOOK_TIMEOUT_S, SESSION_END_TIMEOUT_MS } from "./harnesses.js";
30
58
  // Bumped by hand at release time alongside package.json's "version" -- the
31
59
  // brief's call: a build-time constant here is fine, and it keeps this file
32
60
  // free of any fs reach into its own package.json (which would also be
33
61
  // wrong at runtime, since dist/hook.js does not sit next to package.json
34
- // the way src/hook.ts does).
35
- const SHIM_VERSION = '0.1.0';
36
- const HARNESS = 'claude_code';
37
- const CONFIG_FILE_NAME = 'config.json';
62
+ // the way src/hook.ts does). Exported since postBrief's request body and
63
+ // hook.test.mjs's drift check (against package.json) both need it; task 14
64
+ // (spec 012 §9) bumps this, package.json and the plugin manifest
65
+ // (.codex-plugin/plugin.json) to 0.3.0 together.
66
+ export const SHIM_VERSION = '0.3.0';
67
+ // Exported for capture.ts, which writes the file this module reads.
68
+ export const CONFIG_FILE_NAME = 'config.json';
38
69
  // Same-directory temp file + rename, exactly atomicWriteJson's technique in
39
70
  // cli.ts (see its comment for the full why: rename is only atomic within
40
71
  // one filesystem, so the temp file has to be a sibling of the real one).
@@ -81,7 +112,9 @@ function atomicWriteText(path, text) {
81
112
  // this module's own atomicWriteText writing it (that always writes a clean
82
113
  // non-negative integer), so this is cheap insurance against a corrupted
83
114
  // local file, not a path this module's own writes are expected to hit.
84
- function readWatermark(watermarkPath) {
115
+ // Exported: a later run (spec 011 §8's bootstrap) reads watermarks with
116
+ // the hook's own rule rather than re-implementing it.
117
+ export function readWatermark(watermarkPath) {
85
118
  try {
86
119
  const raw = readFileSync(watermarkPath, 'utf8').trim();
87
120
  if (!/^\d+$/.test(raw))
@@ -93,25 +126,55 @@ function readWatermark(watermarkPath) {
93
126
  return 0;
94
127
  }
95
128
  }
96
- // Pure read: computes the delta and the watermark it would advance to, but
97
- // writes nothing. handleHookEvent only persists nextWatermark after the
98
- // POST built from `content` actually succeeds -- see its own comment.
99
- export function readDelta(transcriptPath, watermarkPath) {
129
+ // Exported so postDelta (and a later run -- spec 011 §8's bootstrap) can
130
+ // advance the watermark the same way after each piece that succeeds: one
131
+ // atomic write, never a partial one a crash could leave mid-byte.
132
+ export function writeWatermark(watermarkPath, offset) {
133
+ atomicWriteText(watermarkPath, String(offset));
134
+ }
135
+ // spec 011 §3: beside the watermark, not instead of it -- a session that
136
+ // drops one span is still capturing everything else, and this file is the
137
+ // record of where the holes are.
138
+ export function droppedMarkerPath(watermarkPath) {
139
+ return watermarkPath + '.dropped';
140
+ }
141
+ // Pure read: formats what the file holds past the watermark by the
142
+ // cc-dialogue-v1 rule and returns the surviving pieces with absolute byte
143
+ // offsets, but writes nothing -- handleHookEvent only persists a piece's
144
+ // `end` after the POST built from its `text` actually succeeds (see
145
+ // postDelta). null covers two different "nothing to send yet" cases that
146
+ // the caller treats the same way: no transcript to read at all (missing or
147
+ // unreadable file -- e.g. transcript_path's documented async-write lag,
148
+ // research doc §2, means the file may not exist yet on a very short
149
+ // session), or bytes past the watermark that do not yet contain one
150
+ // complete line (spec 011 §2 -- a read landing inside a write must not
151
+ // send half a JSON record; the next event's read starts from the same
152
+ // watermark and sees the completed line). A non-null result can still hold
153
+ // zero pieces -- every new line was dropped by the formatting rule -- and
154
+ // the caller still advances the watermark to `end` in that case, per
155
+ // formatDelta's own contract.
156
+ export function readDelta(transcriptPath, watermarkPath,
157
+ // Which formatting rule to run past the watermark -- dialogue.ts's
158
+ // cc-dialogue-v1 by default (every caller before task 8, and every reader
159
+ // test above, wants exactly that); postDelta below passes the running
160
+ // event's profile.formatDelta instead, so the same watermark/piece
161
+ // machinery serves codex-dialogue-v1 too without a second copy of this
162
+ // function.
163
+ formatDeltaFn = formatDelta) {
100
164
  let buf;
101
165
  try {
102
166
  buf = readFileSync(transcriptPath);
103
167
  }
104
168
  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
169
  return null;
109
170
  }
110
- const size = buf.length;
111
171
  const offset = readWatermark(watermarkPath);
112
- if (offset >= size)
172
+ if (offset >= buf.length)
113
173
  return null; // nothing new since last watermark (also covers a shrunk/rotated file)
114
- return { content: buf.subarray(offset).toString('utf8'), nextWatermark: size };
174
+ const { pieces, end } = formatDeltaFn(buf.subarray(offset));
175
+ if (end === 0)
176
+ return null; // the only new bytes are an incomplete trailing line
177
+ return { pieces: pieces.map((p) => ({ ...p, end: offset + p.end })), end: offset + end, offset };
115
178
  }
116
179
  // Reads {url, key} from <configDir>/config.json. Anything short of a clean
117
180
  // {url: string, key: string} object -- the file absent (task 15 has not run
@@ -154,59 +217,122 @@ function postJson(deps, config, path, body, signal) {
154
217
  signal,
155
218
  });
156
219
  }
157
- // SessionEnd hooks share a 1.5s budget by default (raisable only to 60s),
158
- // cannot block Claude Code's own exit, and must be fire-and-forget --
159
- // research doc §2. An unbounded await here risks the process being killed
160
- // mid-request with no chance to exit cleanly or log why.
161
- //
162
- // 800ms is the network budget specifically, not the process's whole
163
- // lifetime -- measured against a real build (dist/index.js hook, real
164
- // fetch, a genuinely unresponsive local server), total process time from
165
- // spawn to exit-0 on an abort was ~1.33s with a 1000ms network timeout,
166
- // i.e. ~330ms of Node startup/stdin/fs overhead on this machine outside
167
- // the network wait itself. 800ms leaves that overhead roughly 2x the
168
- // margin (~370ms) under the *default* 1.5s budget rather than ~170ms, on
169
- // the assumption other machines' startup cost varies and Claude Code's own
170
- // pre-dispatch overhead, if any, is unknown and not ours to spend down.
171
- // Stop is not bounded this way (600s budget, and can run "async": true),
172
- // so its path through postDelta below does not use this.
173
- const SESSION_END_TIMEOUT_MS = 800;
220
+ // undici wraps every network-level failure as TypeError("fetch failed") and
221
+ // puts the reason on `cause` -- a DNS name, a refused connection, our own
222
+ // abort, or (2026-09-04) an Authorization header carrying a control
223
+ // character. The wrapper alone told an operator nothing. The cause is an
224
+ // error class, a code and a sentence, never the request that failed, so
225
+ // printing it keeps rule 2 of the module comment.
174
226
  function errorMessage(err) {
175
- return err instanceof Error ? err.message : String(err);
227
+ if (!(err instanceof Error))
228
+ return String(err);
229
+ const cause = err.cause;
230
+ if (!(cause instanceof Error))
231
+ return err.message;
232
+ const code = cause.code;
233
+ const codePart = typeof code === 'string' ? ` ${code}` : '';
234
+ return `${err.message} (cause: ${cause.name}${codePart}: ${cause.message})`;
176
235
  }
177
- async function postHeartbeat(deps) {
178
- const configPath = join(deps.configDir, CONFIG_FILE_NAME);
236
+ // §6: 0.16 s start-up + one round trip measured 0.6-1.2 s; a black hole
237
+ // (server up but never answering) would otherwise hold the session's first
238
+ // turn open indefinitely, since nothing before postBrief bounds this wait.
239
+ export const BRIEF_TIMEOUT_MS = 3000;
240
+ // The memories a session opens with (spec 011 §5, §6, A9): posts the
241
+ // harness/version/project triple to /brief and writes whatever `text` it
242
+ // answers with to stdout, which Claude Code folds straight into the
243
+ // session's context. Mirrors postHeartbeat's shape (same config read, same
244
+ // do-nothing-and-log branches) but now bounded by an AbortController --
245
+ // unlike a heartbeat nobody was waiting on, a hung /brief request sits in
246
+ // the middle of the user's SessionStart, so this cannot wait forever.
247
+ async function postBrief(event, deps, profile) {
179
248
  const config = readConfig(deps.configDir);
180
249
  if (!config) {
181
- console.error(`waku-memory hook: no usable config at ${configPath} -- skipping heartbeat.`);
250
+ // Ruling R4 (spec 012 §4.1): Codex-only, quiet without a credential --
251
+ // no stdout, no stderr, no fetch call. Claude Code keeps this line
252
+ // (spec 011 A9: its output is unchanged).
253
+ if (!profile.silentWithoutCredential) {
254
+ console.error(`waku-memory hook: no usable config at ${join(deps.configDir, CONFIG_FILE_NAME)} -- no brief.`);
255
+ }
182
256
  return;
183
257
  }
258
+ if (typeof event.cwd !== 'string' || event.cwd === '') {
259
+ // No project to ask for. A do-nothing branch says why (conventions §8).
260
+ console.error('waku-memory hook: no cwd on this SessionStart event -- no brief.');
261
+ return;
262
+ }
263
+ const project = resolveProject(event.cwd);
264
+ const controller = new AbortController();
265
+ const timer = setTimeout(() => controller.abort(), deps.briefTimeoutMs ?? BRIEF_TIMEOUT_MS);
184
266
  try {
185
- // No local state to advance on success and nothing useful to retry on
186
- // failure (the next SessionStart supersedes it), so unlike postDelta
187
- // there is no follow-up write gated on the response -- but the
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 });
267
+ // POST, not GET: `project` is caller-supplied free text and a query
268
+ // string would put it in every access log (spec 011 §5). session_id
269
+ // travels here too (spec 012 §4) -- every harness, not just Codex.
270
+ const res = await postJson(deps, config, '/brief', { harness: profile.id, version: SHIM_VERSION, project, session_id: event.session_id }, controller.signal);
193
271
  if (!res.ok) {
194
- console.error(`waku-memory hook: /ingest/heartbeat responded ${res.status}.`);
272
+ console.error(`waku-memory hook: /brief responded ${res.status} -- no brief.`);
273
+ return;
274
+ }
275
+ const body = (await res.json());
276
+ if (typeof body.text === 'string' && body.text !== '') {
277
+ // Identity for Claude Code (plain text); Codex wraps it in the hook
278
+ // JSON contract (spec 012 §4) -- the only place that knows which is
279
+ // profile.briefOutput itself, never a check here.
280
+ (deps.stdout ?? defaultStdout)(profile.briefOutput(body.text));
281
+ }
282
+ else {
283
+ // A 2xx with missing, non-string, or empty text is a failure like any other.
284
+ console.error('waku-memory hook: /brief responded with no usable text -- no brief.');
195
285
  }
196
286
  }
197
287
  catch (err) {
198
- console.error(`waku-memory hook: POST /ingest/heartbeat failed -- ${errorMessage(err)}.`);
288
+ // This catch also handles a 2xx response whose body is not valid JSON.
289
+ console.error(`waku-memory hook: POST /brief failed -- ${errorMessage(err)}.`);
199
290
  }
200
- }
201
- async function postDelta(event, deps, source) {
202
- if (!event.transcript_path) {
203
- console.error(`waku-memory hook: no transcript_path on this ${event.hook_event_name} event -- skipping.`);
204
- return;
291
+ finally {
292
+ clearTimeout(timer);
205
293
  }
294
+ }
295
+ const defaultStdout = (text) => {
296
+ process.stdout.write(text + '\n');
297
+ };
298
+ const squash = (s) => s.replace(/\s+/g, ' ').trim();
299
+ // spec 011 §2: the text of every assistant: entry after the last user:
300
+ // entry, joined by one space -- what "Claude's final response" (the hooks
301
+ // reference's own words for last_assistant_message) means at record
302
+ // granularity. assistant used tool: entries (role 'tool') are names, not
303
+ // text, and are skipped rather than treated as a boundary. Scoping to the
304
+ // trailing run, instead of searching the whole delta, is deliberate: a
305
+ // short reply like "Done." that also occurred several turns earlier must
306
+ // not be mistaken for the current turn's reply just because the words
307
+ // match.
308
+ export function trailingAssistantText(entries) {
309
+ let lastUser = -1;
310
+ entries.forEach((e, i) => {
311
+ if (e.role === 'user')
312
+ lastUser = i;
313
+ });
314
+ return entries
315
+ .slice(lastUser + 1)
316
+ .filter((e) => e.role === 'assistant')
317
+ .map((e) => e.text)
318
+ .join(' ');
319
+ }
320
+ async function postDelta(event, deps, source, profile) {
321
+ // Config is checked before transcript_path, deliberately: ruling R4 (spec
322
+ // 012 §4.1) says a Codex event with no usable config.json exits quietly
323
+ // regardless of what else about the event is missing or malformed --
324
+ // checking transcript_path first would let that branch's stderr line fire
325
+ // ahead of the credential check and break the "no stderr at all" promise.
206
326
  const configPath = join(deps.configDir, CONFIG_FILE_NAME);
207
327
  const config = readConfig(deps.configDir);
208
328
  if (!config) {
209
- console.error(`waku-memory hook: no usable config at ${configPath} -- skipping.`);
329
+ if (!profile.silentWithoutCredential) {
330
+ console.error(`waku-memory hook: no usable config at ${configPath} -- skipping.`);
331
+ }
332
+ return;
333
+ }
334
+ if (!event.transcript_path) {
335
+ console.error(`waku-memory hook: no transcript_path on this ${event.hook_event_name} event -- skipping.`);
210
336
  return;
211
337
  }
212
338
  // session_id arrives parsed from stdin and is used as a path segment
@@ -215,79 +341,127 @@ async function postDelta(event, deps, source) {
215
341
  // configDir/state. The same-user trust boundary already limits the blast
216
342
  // radius, but this removes the footgun rather than relying on that alone.
217
343
  const watermarkPath = join(deps.configDir, 'state', basename(event.session_id));
218
- const delta = readDelta(event.transcript_path, watermarkPath);
344
+ let delta = readDelta(event.transcript_path, watermarkPath, profile.formatDelta);
219
345
  if (delta === null) {
220
- console.error(`waku-memory hook: nothing new in the transcript for session ${event.session_id} -- skipping.`);
221
- return;
222
- }
223
- const body = {
224
- harness: HARNESS,
225
- session_id: event.session_id,
226
- source,
227
- content: delta.content,
228
- };
229
- if (event.cwd)
230
- body.project = basename(event.cwd);
231
- // Only session-end is bounded -- see SESSION_END_TIMEOUT_MS's comment.
232
- // Stop's controller stays undefined, so its postJson call below gets
233
- // `signal: undefined`, identical to not passing one at all.
234
- const controller = source === 'session-end' ? new AbortController() : undefined;
235
- const timer = controller ? setTimeout(() => controller.abort(), SESSION_END_TIMEOUT_MS) : undefined;
236
- let res;
237
- try {
238
- res = await postJson(deps, config, '/ingest/session', body, controller?.signal);
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.`);
346
+ // spec 011 §2: the transcript can lag the turn past every complete
347
+ // line it holds. On Stop, a non-empty last_assistant_message is
348
+ // Claude's own record of what it just said (the hooks reference: "use
349
+ // this field rather than reading transcript_path" for exactly this
350
+ // reason) -- when the file has nothing new at all, that reply has not
351
+ // landed in it yet, and this is the only chance to send it before the
352
+ // next event finds it in the file for real and skips it (the
353
+ // trailing-run check below). The piece's `end` is the current
354
+ // watermark: nothing from the file is being consumed, only the
355
+ // out-of-band message.
356
+ if (source === 'stop' && typeof event.last_assistant_message === 'string' && event.last_assistant_message.trim() !== '') {
357
+ const current = readWatermark(watermarkPath);
358
+ const entry = { role: 'assistant', text: event.last_assistant_message };
359
+ delta = { pieces: [{ entries: [entry], text: renderEntries([entry]), end: current }], end: current, offset: current };
360
+ }
361
+ else {
362
+ console.error(`waku-memory hook: nothing new in the transcript for session ${event.session_id} -- skipping.`);
363
+ return;
364
+ }
277
365
  }
278
- // Past this point: res.ok (202 queued / 200 deduped) or 413 (see above)
279
- // both advance the watermark the same way.
280
- try {
281
- atomicWriteText(watermarkPath, String(delta.nextWatermark));
366
+ // spec 011 §2: append last_assistant_message as one more assistant:
367
+ // entry unless the trailing run already reads it, on normalised
368
+ // whitespace -- so a final turn split across two text blocks (joined by
369
+ // the harness with a newline or a blank line) still matches. This runs
370
+ // on the structured entries, never on a piece's already-joined text,
371
+ // because a text can itself contain a blank line and the joined form
372
+ // cannot be split back to find the boundary between records.
373
+ if (source === 'stop' && typeof event.last_assistant_message === 'string' && event.last_assistant_message.trim() !== '') {
374
+ const msg = event.last_assistant_message;
375
+ const all = delta.pieces.flatMap((p) => p.entries);
376
+ const present = squash(trailingAssistantText(all)).includes(squash(msg));
377
+ if (!present) {
378
+ const entry = { role: 'assistant', text: msg };
379
+ const last = delta.pieces[delta.pieces.length - 1];
380
+ if (last) {
381
+ last.entries.push(entry);
382
+ last.text = renderEntries(last.entries);
383
+ }
384
+ else {
385
+ delta.pieces.push({ entries: [entry], text: renderEntries([entry]), end: delta.end });
386
+ }
387
+ }
282
388
  }
283
- catch (err) {
284
- // The POST outcome is already decided -- the local bookkeeping just
285
- // did not keep up. A 202/200 that fails to persist here resends the
286
- // same (now server-known) delta next time, same as any other write
287
- // failure; a 413 that fails to persist here will simply 413 again on
288
- // its own next attempt, same as if this write had never been tried.
289
- console.error(`waku-memory hook: could not persist the watermark for session ${event.session_id} -- ${errorMessage(err)}.`);
389
+ // Only session-end is bounded -- see harnesses.ts's SESSION_END_TIMEOUT_MS
390
+ // comment. One deadline for the whole flush, not one budget per piece:
391
+ // several pieces each getting the full budget would let a many-piece
392
+ // session run far longer than the budget the installed hook entry
393
+ // actually gives the process. Stop's deadline stays undefined, so every
394
+ // piece's controller below is undefined too -- identical to not passing a
395
+ // signal at all. profile.sessionEndTimeoutMs is Codex's 2000 ms or Claude
396
+ // Code's 5000 ms; deps.sessionEndTimeoutMs (test-only) overrides either.
397
+ const budgetMs = deps.sessionEndTimeoutMs ?? profile.sessionEndTimeoutMs;
398
+ const deadline = source === 'session-end' ? Date.now() + budgetMs : undefined;
399
+ let previousEnd = delta.offset;
400
+ for (const piece of delta.pieces) {
401
+ const controller = deadline !== undefined ? new AbortController() : undefined;
402
+ const timer = controller ? setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now())) : undefined;
403
+ let res;
404
+ try {
405
+ res = await postJson(deps, config, '/ingest/session', {
406
+ harness: profile.id,
407
+ session_id: event.session_id,
408
+ source,
409
+ content: piece.text,
410
+ content_format: profile.contentFormat,
411
+ ...(event.cwd ? { project: resolveProject(event.cwd) } : {}),
412
+ }, controller?.signal);
413
+ }
414
+ catch (err) {
415
+ // Network error, DNS failure, our own SessionEnd timeout firing --
416
+ // all reject rather than resolving with a bad status. The watermark
417
+ // stays at the last piece that succeeded; the ingest inbox's
418
+ // (user_id, session_id, content_hash) dedupe recognizes a retry of
419
+ // byte-identical content as already-seen and no-ops it for free.
420
+ // That guarantee is specific to retrying *this* unchanged piece --
421
+ // see readWatermark's comment for the resend it does NOT cover.
422
+ console.error(`waku-memory hook: POST /ingest/session failed -- ${errorMessage(err)}.`);
423
+ return;
424
+ }
425
+ finally {
426
+ // Must run before any of the res.ok handling below, success or not --
427
+ // an uncleared timer holds the process open for no reason once the
428
+ // request has settled.
429
+ if (timer)
430
+ clearTimeout(timer);
431
+ }
432
+ if (res.status === 413) {
433
+ // The endpoint has no server-side chunking (MAX_INGEST_CONTENT_BYTES
434
+ // in ingest.py, checked before any repository call) -- a piece is
435
+ // already cut under MAX_PIECE_BYTES, so this is the backstop for the
436
+ // one record that is itself over the cap (spec 011 §3), which no
437
+ // dialogue record should ever be. One span per line, appended -- a
438
+ // second drop in the same session adds a line, never overwrites the
439
+ // first, so bootstrap (§8) can see every hole.
440
+ mkdirSync(dirname(watermarkPath), { recursive: true });
441
+ appendFileSync(droppedMarkerPath(watermarkPath), `${previousEnd}-${piece.end}\n`);
442
+ console.error(`waku-memory hook: /ingest/session responded 413 for session ${event.session_id} -- ` +
443
+ `dropping this piece and advancing past it.`);
444
+ }
445
+ else if (!res.ok) {
446
+ // 422 included (spec 011 §4, deployment order): an API that does not
447
+ // yet know content_format leaves the watermark where it is, same as
448
+ // any other non-2xx (5xx, 401/403) -- the next event resends the
449
+ // whole growing delta rather than losing this piece.
450
+ console.error(`waku-memory hook: /ingest/session responded ${res.status} -- watermark left unchanged.`);
451
+ return;
452
+ }
453
+ // Past this point: res.ok (202 queued / 200 deduped) or 413 (see
454
+ // above) both advance the watermark the same way, to this piece's end.
455
+ writeWatermark(watermarkPath, piece.end);
456
+ previousEnd = piece.end;
290
457
  }
458
+ // spec 011 §3: a delta that produced zero pieces (every new line was
459
+ // dropped by the formatting rule) still consumes to `delta.end` --
460
+ // formatDelta's own contract -- so those lines are never re-read. The
461
+ // same catches a trailing run of dropped lines after the last real
462
+ // piece, when that piece's own `end` stops short of the full delta.
463
+ if (delta.pieces.length === 0 || delta.end > previousEnd)
464
+ writeWatermark(watermarkPath, delta.end);
291
465
  }
292
466
  // Entry point cli.ts's run() calls after reading and JSON.parsing stdin --
293
467
  // stdin reading deliberately does not live here (see the module comment and
@@ -308,15 +482,21 @@ export async function handleHookEvent(event, deps) {
308
482
  console.error('waku-memory hook: received a malformed hook event on stdin -- skipping.');
309
483
  return;
310
484
  }
485
+ // The only place that asks "which harness?" -- everything downstream
486
+ // (postBrief, postDelta, readDelta) takes the answer as a profile, never
487
+ // as a string to compare against. deps.harness is typed HarnessId, so
488
+ // profileFor always finds an entry in PROFILES; cli.ts's dispatch() is
489
+ // what rejects an unknown --harness value before this is ever reached.
490
+ const profile = profileFor(deps.harness ?? 'claude_code');
311
491
  switch (event.hook_event_name) {
312
492
  case 'SessionStart':
313
- await postHeartbeat(deps);
493
+ await postBrief(event, deps, profile);
314
494
  return;
315
495
  case 'Stop':
316
- await postDelta(event, deps, 'stop');
496
+ await postDelta(event, deps, 'stop', profile);
317
497
  return;
318
498
  case 'SessionEnd':
319
- await postDelta(event, deps, 'session-end');
499
+ await postDelta(event, deps, 'session-end', profile);
320
500
  return;
321
501
  default:
322
502
  console.error(`waku-memory hook: ignoring unhandled event "${String(event.hook_event_name)}".`);