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/.codex-plugin/plugin.json +15 -0
- package/.mcp.json +9 -0
- package/README.md +55 -0
- package/dist/bootstrap.js +949 -0
- package/dist/capture.js +960 -117
- package/dist/cli.js +440 -63
- package/dist/codex-config.js +321 -0
- package/dist/dialogue-codex.js +63 -0
- package/dist/dialogue.js +139 -0
- package/dist/harnesses.js +57 -22
- package/dist/hook.js +324 -144
- package/dist/login.js +312 -0
- package/dist/project.js +43 -0
- package/hooks/hooks.json +63 -0
- package/package.json +2 -2
- package/skills/waku/SKILL.md +23 -0
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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// 2. Content goes to the ingest endpoint and nowhere else
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
|
26
|
-
// the
|
|
27
|
-
//
|
|
28
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
export function
|
|
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 >=
|
|
172
|
+
if (offset >= buf.length)
|
|
113
173
|
return null; // nothing new since last watermark (also covers a shrunk/rotated file)
|
|
114
|
-
|
|
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
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
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: /
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
+
let delta = readDelta(event.transcript_path, watermarkPath, profile.formatDelta);
|
|
219
345
|
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.`);
|
|
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
|
-
//
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
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)}".`);
|