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/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. 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.
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, 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';
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
- const SHIM_VERSION = '0.1.0';
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
- const CONFIG_FILE_NAME = 'config.json';
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
- function readWatermark(watermarkPath) {
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
- // 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.
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 >= size)
145
+ if (offset >= buf.length)
113
146
  return null; // nothing new since last watermark (also covers a shrunk/rotated file)
114
- return { content: buf.subarray(offset).toString('utf8'), nextWatermark: size };
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 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.
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
- // 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;
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
- return err instanceof Error ? err.message : String(err);
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
- async function postHeartbeat(deps) {
178
- const configPath = join(deps.configDir, CONFIG_FILE_NAME);
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 ${configPath} -- skipping heartbeat.`);
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
- // 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 });
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: /ingest/heartbeat responded ${res.status}.`);
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
- console.error(`waku-memory hook: POST /ingest/heartbeat failed -- ${errorMessage(err)}.`);
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
- const delta = readDelta(event.transcript_path, watermarkPath);
322
+ let delta = readDelta(event.transcript_path, watermarkPath);
219
323
  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.`);
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
- // 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));
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
- 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)}.`);
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 postHeartbeat(deps);
464
+ await postBrief(event, deps);
314
465
  return;
315
466
  case 'Stop':
316
467
  await postDelta(event, deps, 'stop');
@@ -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.1.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",