waku-memory 0.2.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.
@@ -0,0 +1,321 @@
1
+ // The Codex config writers (spec 012 §4.2, §4.3, §4.6): everything `capture
2
+ // enable` needs to touch on a Codex machine, as pure functions over strings
3
+ // and objects. Task 10 wires the real paths (~/.codex/config.toml, ~/.codex/
4
+ // hooks.json, ~/.agents/plugins/marketplace.json) and does the atomic
5
+ // writes; this file only decides *what* the new content should be.
6
+ //
7
+ // The TOML table is added with a line scan, not a TOML library: this shim
8
+ // ships zero runtime dependencies (package.json), and `codex mcp add` was
9
+ // measured (harnesses.ts's own comment on Codex) to be a guess from OpenAI's
10
+ // docs rather than a verified integration -- pulling in a parser to
11
+ // reserialise a file we cannot fully round-trip is a worse bet than reading
12
+ // and writing back exactly the lines we understand, byte for byte, and
13
+ // leaving everything else on the page untouched.
14
+ //
15
+ // The hooks.json merge mirrors capture.ts's mergeHookSettings/removeHookSettings
16
+ // for Claude Code's settings.json -- same replace-ours-keep-foreign
17
+ // semantics, same non-string guard on entries we did not write ourselves.
18
+ import { existsSync, readdirSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { PROFILES } from "./harnesses.js";
21
+ // ---------------------------------------------------------------------------
22
+ // The [mcp_servers.waku] TOML table
23
+ // ---------------------------------------------------------------------------
24
+ export const CODEX_TOML_TABLE = '[mcp_servers.waku]';
25
+ // Matches the table header on its own line, allowing leading/trailing
26
+ // whitespace the way a hand-edited file might have -- but not a header that
27
+ // merely appears inside a comment or a longer table name.
28
+ const TABLE_HEADER_RE = /^\s*\[mcp_servers\.waku\]\s*$/;
29
+ // Any other table header -- marks the end of ours.
30
+ const NEXT_HEADER_RE = /^\s*\[/;
31
+ const URL_LINE_RE = /^\s*url\s*=\s*(.+?)\s*$/;
32
+ const DEFAULT_APPROVAL_LINE = 'default_tools_approval_mode = "approve"';
33
+ // Both `"..."` and `'...'` count as quoting (ruling): TOML allows either,
34
+ // and a value someone typed by hand is exactly the case this comparison
35
+ // exists for.
36
+ function unquoteTomlValue(raw) {
37
+ const trimmed = raw.trim();
38
+ if (trimmed.length >= 2) {
39
+ const first = trimmed[0];
40
+ const last = trimmed[trimmed.length - 1];
41
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
42
+ return trimmed.slice(1, -1);
43
+ }
44
+ }
45
+ return trimmed;
46
+ }
47
+ // Detect the file's line ending so the writer preserves it: Windows Codex
48
+ // machines use CRLF; Unix-like machines use LF. This ensures that writing to
49
+ // an existing file keeps every other byte as found -- a CRLF file stays
50
+ // uniformly CRLF, an LF file stays LF.
51
+ //
52
+ // Splitting on "\n" leaves a trailing "" element whenever the text ends with
53
+ // a newline -- an artifact of split, not a real blank line at the end of the
54
+ // file. Stripped here once so every line-scanning function below can treat
55
+ // `lines[i]` as an actual line without special-casing the last index; the
56
+ // trailing newline itself is tracked separately and restored on the way out.
57
+ // Any trailing \r on each line (left by CRLF->LF split) is also stripped,
58
+ // since regex `.trim()` and `\s` in the table header will match and tolerate
59
+ // it but our own appended lines should never have it.
60
+ function splitLines(text) {
61
+ const lineEnding = text.includes('\r\n') ? '\r\n' : '\n';
62
+ const hadTrailingNewline = text.endsWith('\n');
63
+ const body = hadTrailingNewline ? text.slice(0, -1) : text;
64
+ const lines = body.split('\n').map((line) => (lineEnding === '\r\n' && line.endsWith('\r') ? line.slice(0, -1) : line));
65
+ return { lines, hadTrailingNewline, lineEnding };
66
+ }
67
+ function joinLines(lines, hadTrailingNewline, lineEnding = '\n') {
68
+ return lines.join(lineEnding) + (hadTrailingNewline ? lineEnding : '');
69
+ }
70
+ // Finds our table's header line and the index where it ends -- either the
71
+ // next `[...]` header or the end of the file.
72
+ function findWakuTable(lines) {
73
+ const headerIdx = lines.findIndex((l) => TABLE_HEADER_RE.test(l));
74
+ if (headerIdx === -1)
75
+ return undefined;
76
+ let endIdx = lines.length;
77
+ for (let i = headerIdx + 1; i < lines.length; i++) {
78
+ if (NEXT_HEADER_RE.test(lines[i])) {
79
+ endIdx = i;
80
+ break;
81
+ }
82
+ }
83
+ return { headerIdx, endIdx };
84
+ }
85
+ // The first `url = ...` line inside [headerIdx, endIdx) -- the table body.
86
+ function findUrlLine(lines, headerIdx, endIdx) {
87
+ for (let i = headerIdx + 1; i < endIdx; i++) {
88
+ const m = URL_LINE_RE.exec(lines[i]);
89
+ if (m)
90
+ return { index: i, value: unquoteTomlValue(m[1]) };
91
+ }
92
+ return undefined;
93
+ }
94
+ function codexTomlBlock(url, lineEnding = '\n') {
95
+ return `${lineEnding}${CODEX_TOML_TABLE}${lineEnding}url = "${url}"${lineEnding}${DEFAULT_APPROVAL_LINE}${lineEnding}`;
96
+ }
97
+ // Appends `\n[mcp_servers.waku]\nurl = "<url>"\ndefault_tools_approval_mode =
98
+ // "approve"\n`, preceded by one blank line, when no such table exists yet.
99
+ // Refuses -- leaves `existing` byte-for-byte untouched -- when a table is
100
+ // already there with a different (or unreadable) url: someone configured
101
+ // this by hand, and silently replacing it would undo a deliberate choice
102
+ // with no trace (same reasoning as harnesses.ts's mergeMcpConfig refusal for
103
+ // Claude Code's config). Unchanged, also untouched, when the table already
104
+ // carries exactly our url.
105
+ export function mergeCodexToml(existing, url) {
106
+ const { lines, lineEnding } = splitLines(existing);
107
+ const table = findWakuTable(lines);
108
+ if (table) {
109
+ const found = findUrlLine(lines, table.headerIdx, table.endIdx);
110
+ if (found && found.value === url)
111
+ return { text: existing, result: 'unchanged' };
112
+ return { text: existing, result: 'refused' };
113
+ }
114
+ const hasTrailingNewline = existing.endsWith('\n');
115
+ const base = hasTrailingNewline ? existing : existing + lineEnding;
116
+ return { text: base + codexTomlBlock(url, lineEnding), result: 'wrote' };
117
+ }
118
+ // The exact inverse of the "wrote" branch above: removes the header, the
119
+ // `url` line and the `default_tools_approval_mode` line, and the one blank
120
+ // line that preceded the header if it is there -- and only when the table's
121
+ // url equals ours AND the table holds nothing else. A table a person added
122
+ // a line to (even a comment) is left alone entirely: "nothing" is the safe
123
+ // answer whenever removal cannot be proven to touch only what we wrote. The
124
+ // two lines must also be in the order codexTomlBlock wrote them (url, then
125
+ // the approval mode) -- `body[0]`/`body[1]` are checked positionally, not
126
+ // searched for, so a file where someone reordered them fails the same way
127
+ // an added line would and is left untouched.
128
+ //
129
+ // R22 (final review, spec 012): a `[mcp_servers.waku.<sub>]` header
130
+ // immediately after our two lines is a TOML sub-table of ours, not an
131
+ // unrelated table -- findWakuTable's endIdx (shared with mergeCodexToml)
132
+ // stops at it the same as at any other header, so the body-length check
133
+ // above would otherwise see exactly our two lines and remove them, leaving
134
+ // the sub-table behind. TOML then implicitly recreates mcp_servers.waku,
135
+ // empty and with no url, the moment that sub-table is parsed -- so this is
136
+ // refused before the body is even read.
137
+ export function removeCodexToml(existing, url) {
138
+ const { lines, hadTrailingNewline, lineEnding } = splitLines(existing);
139
+ const table = findWakuTable(lines);
140
+ if (!table)
141
+ return { text: existing, result: 'nothing' };
142
+ const { headerIdx, endIdx } = table;
143
+ if (endIdx < lines.length && /^\s*\[mcp_servers\.waku\./.test(lines[endIdx])) {
144
+ return { text: existing, result: 'nothing' };
145
+ }
146
+ const body = lines.slice(headerIdx + 1, endIdx);
147
+ const urlLine = body.length > 0 ? URL_LINE_RE.exec(body[0]) : null;
148
+ const urlMatches = urlLine !== null && unquoteTomlValue(urlLine[1]) === url;
149
+ const defaultLineMatches = body.length > 1 && body[1].trim() === DEFAULT_APPROVAL_LINE;
150
+ if (body.length !== 2 || !urlMatches || !defaultLineMatches) {
151
+ return { text: existing, result: 'nothing' };
152
+ }
153
+ const hasBlankBefore = headerIdx > 0 && lines[headerIdx - 1] === '';
154
+ const removeStart = hasBlankBefore ? headerIdx - 1 : headerIdx;
155
+ const newLines = [...lines.slice(0, removeStart), ...lines.slice(endIdx)];
156
+ return { text: joinLines(newLines, hadTrailingNewline, lineEnding), result: 'removed' };
157
+ }
158
+ // The marker that names an entry as ours: it can only appear in a command
159
+ // this shim generated, never in something a person or another tool wrote by
160
+ // hand (the leading space rules out an unrelated command that merely ends
161
+ // in a word containing "hook").
162
+ const OWN_CODEX_MARKER = ' hook --harness codex';
163
+ function codexHookCommand(indexPath) {
164
+ const posix = indexPath.replace(/\\/g, '/');
165
+ const windows = indexPath.replace(/\//g, '\\');
166
+ return {
167
+ command: `node "${posix}" hook --harness codex`,
168
+ command_windows: `node "${windows}" hook --harness codex`,
169
+ };
170
+ }
171
+ // The three events capture.ts's Claude Code profile installs, reshaped for
172
+ // Codex: same three-event split (SessionStart brief, Stop flush, SessionEnd
173
+ // flush-on-exit), different timing and output contract per PROFILES.codex
174
+ // (harnesses.ts) and spec 012 §4. `statusMessage` is Codex's own field for
175
+ // what SessionStart shows while the hook runs; Codex has no matching notion
176
+ // for Stop or SessionEnd, so it is left off there. The matcher omits "fork"
177
+ // -- unlike Claude Code's SESSION_START_MATCHER, Codex has no fork event to
178
+ // name (controller ruling).
179
+ export function codexHookEntries(indexPath) {
180
+ const { command, command_windows } = codexHookCommand(indexPath);
181
+ const base = { type: 'command', command, command_windows };
182
+ // Built as named variables, not inline literals, before landing in a
183
+ // `hooks: HookCommandEntry[]` position -- CodexHookEntry's extra fields
184
+ // (statusMessage, command_windows, timeout, a boolean async) are fine on a
185
+ // value structurally, but TypeScript's excess-property check would reject
186
+ // an *inline* object literal with fields HookCommandEntry does not name.
187
+ const sessionStartHook = { ...base, timeout: 5, statusMessage: 'Waku memory' };
188
+ const stopHook = { ...base, async: true, timeout: 600 };
189
+ const sessionEndHook = { ...base, timeout: PROFILES.codex.sessionEndHookTimeoutS };
190
+ return {
191
+ SessionStart: [{ matcher: 'startup|resume|clear|compact', hooks: [sessionStartHook] }],
192
+ Stop: [{ hooks: [stopHook] }],
193
+ SessionEnd: [{ hooks: [sessionEndHook] }],
194
+ };
195
+ }
196
+ // Ours in either field. `entry` is `unknown`, the same way isOwnEntry in
197
+ // capture.ts treats settings.json contents as untrusted: hooks.json is only
198
+ // validated as a top-level object, so anything nested under `hooks` could be
199
+ // whatever another tool wrote there. Never throws -- a malformed entry
200
+ // (a non-string `command`) is simply not ours.
201
+ export function isOwnCodexEntry(entry) {
202
+ if (typeof entry !== 'object' || entry === null)
203
+ return false;
204
+ const e = entry;
205
+ const inCommand = typeof e.command === 'string' && e.command.includes(OWN_CODEX_MARKER);
206
+ const inCommandWindows = typeof e.command_windows === 'string' && e.command_windows.includes(OWN_CODEX_MARKER);
207
+ return inCommand || inCommandWindows;
208
+ }
209
+ // A group is "ours" only if every hook inside it is ours -- see capture.ts's
210
+ // isOwnGroup for why "every" (not "some") is what makes the remove side
211
+ // exact.
212
+ function isOwnCodexGroup(group) {
213
+ return group.hooks.length > 0 && group.hooks.every(isOwnCodexEntry);
214
+ }
215
+ // Replaces any group of ours on each event with a freshly built one, keeps
216
+ // every foreign group untouched -- the same replace-not-skip semantics as
217
+ // capture.ts's mergeHookSettings, so re-running `capture enable` after an
218
+ // upgrade always lands the current entry rather than leaving a stale one
219
+ // beside it.
220
+ export function mergeCodexHooks(existing, indexPath) {
221
+ const hooks = { ...(existing.hooks ?? {}) };
222
+ for (const [event, groups] of Object.entries(codexHookEntries(indexPath))) {
223
+ hooks[event] = [...(hooks[event] ?? []).filter((g) => !isOwnCodexGroup(g)), ...groups];
224
+ }
225
+ return { ...existing, hooks };
226
+ }
227
+ // The exact inverse: drops every group that is entirely ours, and drops an
228
+ // event key altogether once nothing is left under it.
229
+ export function removeCodexHooks(existing) {
230
+ if (!existing.hooks)
231
+ return existing;
232
+ const hooks = {};
233
+ for (const [event, groups] of Object.entries(existing.hooks)) {
234
+ const kept = groups.filter((g) => !isOwnCodexGroup(g));
235
+ if (kept.length > 0)
236
+ hooks[event] = kept;
237
+ }
238
+ if (Object.keys(hooks).length === 0) {
239
+ const { hooks: _drop, ...rest } = existing;
240
+ return rest;
241
+ }
242
+ return { ...existing, hooks };
243
+ }
244
+ export const WAKU_MARKETPLACE_ENTRY = {
245
+ name: 'waku',
246
+ source: { source: 'npm', package: 'waku-memory' },
247
+ policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
248
+ category: 'Developer Tools',
249
+ };
250
+ // Writes our entry once. `existing` undefined (no marketplace.json yet)
251
+ // yields a fresh personal marketplace holding just ours; an existing file
252
+ // keeps its own name/interface/other keys and other plugins, with ours
253
+ // appended -- matched by `plugins[].name === 'waku'`, so a second run is a
254
+ // no-op rather than a duplicate entry.
255
+ export function mergeMarketplaceEntry(existing) {
256
+ if (existing?.plugins?.some((p) => p.name === 'waku')) {
257
+ return { data: existing, result: 'unchanged' };
258
+ }
259
+ const base = existing ?? { name: 'personal', interface: { displayName: 'Personal' } };
260
+ return { data: { ...base, plugins: [...(base.plugins ?? []), WAKU_MARKETPLACE_ENTRY] }, result: 'wrote' };
261
+ }
262
+ // ---------------------------------------------------------------------------
263
+ // Plugin detection
264
+ // ---------------------------------------------------------------------------
265
+ // Matches `[plugins."waku@<marketplace>"]` -- the table config.toml carries
266
+ // once the plugin is actually installed (as opposed to merely listed in a
267
+ // marketplace file, which only makes it available). Ruling R21: both quoting
268
+ // styles TOML allows for the key count, the same reasoning as
269
+ // unquoteTomlValue above -- a table someone hand-edited is exactly the case
270
+ // this needs to tolerate.
271
+ const PLUGIN_TABLE_HEADER_RE = /^\s*\[plugins\.(?:"waku@[^"]*"|'waku@[^']*')\]\s*$/;
272
+ const ENABLED_FALSE_RE = /^\s*enabled\s*=\s*false\b/;
273
+ // R21 (final review, spec 012): the config.toml table is authoritative when
274
+ // present -- returns true/false for "the table says so", or undefined when
275
+ // there is no such table at all (the cache probe's cue to run). A table
276
+ // with no `enabled` line at all means present (Codex's own default); one
277
+ // that sets `enabled = false` means absent, full stop, regardless of what
278
+ // the plugin cache still has lying around. Scans only up to the next table
279
+ // header, the same boundary findWakuTable uses above for
280
+ // [mcp_servers.waku].
281
+ function wakuPluginTableStatus(configToml) {
282
+ const { lines } = splitLines(configToml);
283
+ const headerIdx = lines.findIndex((l) => PLUGIN_TABLE_HEADER_RE.test(l));
284
+ if (headerIdx === -1)
285
+ return undefined;
286
+ let endIdx = lines.length;
287
+ for (let i = headerIdx + 1; i < lines.length; i++) {
288
+ if (NEXT_HEADER_RE.test(lines[i])) {
289
+ endIdx = i;
290
+ break;
291
+ }
292
+ }
293
+ const disabled = lines.slice(headerIdx + 1, endIdx).some((l) => ENABLED_FALSE_RE.test(l));
294
+ return !disabled;
295
+ }
296
+ // Detects whether the Waku plugin is already installed, by either route
297
+ // Codex uses: the config.toml table (source of truth once Codex has loaded
298
+ // it -- ruling R21, wakuPluginTableStatus above), or a `waku` directory
299
+ // under some marketplace's entry in Codex's plugin cache (on this machine,
300
+ // `~/.codex/plugins/cache/<marketplace>/<plugin>` -- present even before
301
+ // config.toml is regenerated). The cache probe runs only when no table
302
+ // exists at all: a lingering cache directory from an uninstalled plugin, or
303
+ // one from before a table with `enabled = false` was added, must not
304
+ // override what the table now says. `io` defaults to node:fs's real
305
+ // existsSync/readdirSync; tests inject a stand-in so this stays a pure
306
+ // function over its inputs otherwise. Any error reading the cache directory
307
+ // (ENOENT when it does not exist yet, or anything else) means "not present
308
+ // by that route".
309
+ export function codexPluginPresent(configToml, pluginsCacheDir, io = { existsSync, readdirSync }) {
310
+ const tableStatus = wakuPluginTableStatus(configToml);
311
+ if (tableStatus !== undefined)
312
+ return tableStatus;
313
+ let marketplaces;
314
+ try {
315
+ marketplaces = io.readdirSync(pluginsCacheDir);
316
+ }
317
+ catch {
318
+ return false;
319
+ }
320
+ return marketplaces.some((m) => io.existsSync(join(pluginsCacheDir, m, 'waku')));
321
+ }
@@ -0,0 +1,63 @@
1
+ // The codex-dialogue-v1 rule (spec 012 §5): the same three kinds of entry
2
+ // as cc-dialogue-v1, read out of Codex's rollout records.
3
+ import { formatDelta } from "./dialogue.js";
4
+ export const CODEX_CONTENT_FORMAT = 'codex-dialogue-v1';
5
+ export const CODEX_HARNESS_TAGS = ['<environment_context>', '<recommended_plugins>'];
6
+ const LEADING_BLANKS = /^[ \t\r\n\f\v]+/;
7
+ const TOOL_MENTION = /tools\.([A-Za-z0-9_]+)\(/g;
8
+ function isHarnessTag(text) {
9
+ const t = text.replace(LEADING_BLANKS, '');
10
+ return CODEX_HARNESS_TAGS.some((tag) => t.startsWith(tag));
11
+ }
12
+ function texts(content, partType) {
13
+ if (!Array.isArray(content))
14
+ return [];
15
+ const out = [];
16
+ for (const c of content) {
17
+ if (c && typeof c === 'object' && c.type === partType) {
18
+ const t = c.text;
19
+ if (typeof t === 'string' && t.length > 0)
20
+ out.push(t);
21
+ }
22
+ }
23
+ return out;
24
+ }
25
+ export function formatCodexLine(line) {
26
+ let record;
27
+ try {
28
+ record = JSON.parse(line);
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ if (!record || typeof record !== 'object')
34
+ return [];
35
+ const r = record;
36
+ if (r.type !== 'response_item' || !r.payload || typeof r.payload !== 'object')
37
+ return [];
38
+ const p = r.payload;
39
+ if (p.type === 'message') {
40
+ if (p.role === 'user')
41
+ return texts(p.content, 'input_text').filter((t) => !isHarnessTag(t)).map((text) => ({ role: 'user', text }));
42
+ if (p.role === 'assistant')
43
+ return texts(p.content, 'output_text').map((text) => ({ role: 'assistant', text }));
44
+ return [];
45
+ }
46
+ if (p.type === 'function_call')
47
+ return typeof p.name === 'string' ? [{ role: 'tool', text: p.name }] : [];
48
+ if (p.type === 'custom_tool_call') {
49
+ const names = [];
50
+ if (typeof p.input === 'string') {
51
+ for (const m of p.input.matchAll(TOOL_MENTION))
52
+ if (!names.includes(m[1]))
53
+ names.push(m[1]);
54
+ }
55
+ if (names.length === 0)
56
+ names.push(typeof p.name === 'string' ? p.name : 'exec');
57
+ return names.map((text) => ({ role: 'tool', text }));
58
+ }
59
+ return [];
60
+ }
61
+ export function formatCodexDelta(buf, maxPieceBytes) {
62
+ return formatDelta(buf, maxPieceBytes, formatCodexLine);
63
+ }
package/dist/dialogue.js CHANGED
@@ -101,7 +101,7 @@ export function formatRecord(line) {
101
101
  // newline yet) is left for the next event. `end` is the offset past the
102
102
  // last newline consumed, whether or not anything survived -- the
103
103
  // caller's watermark moves to it either way.
104
- export function formatDelta(buf, maxPieceBytes = MAX_PIECE_BYTES) {
104
+ export function formatDelta(buf, maxPieceBytes = MAX_PIECE_BYTES, lineFormatter = formatLine) {
105
105
  const pieces = [];
106
106
  let entries = [];
107
107
  let entriesBytes = 0;
@@ -120,7 +120,7 @@ export function formatDelta(buf, maxPieceBytes = MAX_PIECE_BYTES) {
120
120
  if (nl === -1)
121
121
  break;
122
122
  const lineEnd = nl + 1;
123
- const lineEntries = formatLine(buf.subarray(lineStart, nl).toString('utf8'));
123
+ const lineEntries = lineFormatter(buf.subarray(lineStart, nl).toString('utf8'));
124
124
  lineStart = lineEnd;
125
125
  end = lineEnd;
126
126
  if (lineEntries.length === 0) {
package/dist/harnesses.js CHANGED
@@ -2,9 +2,17 @@
2
2
  // destroying what is there. One file per harness would be tidier and is not
3
3
  // worth it yet: the shapes are nearly identical, and the differences are
4
4
  // worth seeing side by side while there are only two.
5
+ //
6
+ // This file also carries the *hook* side of "one harness, one profile"
7
+ // (spec 012 §4): everything hook.ts needs to know about the harness it is
8
+ // running under -- content format, delta formatter, timing budgets, and how
9
+ // the brief is printed -- lives on one HarnessProfile per HarnessId, so
10
+ // hook.ts itself never asks "which harness is this?" by string compare.
5
11
  import { existsSync } from 'node:fs';
6
12
  import { homedir } from 'node:os';
7
13
  import { join } from 'node:path';
14
+ import { CONTENT_FORMAT, formatDelta } from "./dialogue.js";
15
+ import { CODEX_CONTENT_FORMAT, formatCodexDelta } from "./dialogue-codex.js";
8
16
  export function mergeMcpConfig(existing, name, url) {
9
17
  const servers = existing.mcpServers ?? {};
10
18
  const current = servers[name];
@@ -27,26 +35,53 @@ export function getHarnesses() {
27
35
  export function harnessConfigExists(harness) {
28
36
  return existsSync(harness.configPath);
29
37
  }
30
- // Printed only when ~/.codex/config.toml actually exists. Before 2026-08-26
31
- // this went to everyone on every run, including the Claude-Code-only user
32
- // who has no Codex and no use for a TOML block -- noise that made the
33
- // output worse the more harnesses we knew about, which is the wrong
34
- // direction for that to scale.
35
- export function codexNote(name, url) {
36
- return (`Codex: found here, but not configured automatically -- this shim's\n` +
37
- `knowledge of Codex's format comes from OpenAI's docs\n` +
38
- `(learn.chatgpt.com/docs/extend/mcp), never from a real install, and\n` +
39
- `writing a guessed shape into a config that is really there is worse\n` +
40
- `than leaving it alone. Add this yourself, then run\n` +
41
- `"codex mcp login ${name}" (Codex does not sign in on first use the way\n` +
42
- `Claude Code does):\n` +
43
- ` [mcp_servers.${name}]\n` +
44
- ` url = "${url}"\n` +
45
- `Codex also has a "codex mcp" subcommand (add/list/login), which would\n` +
46
- `be the better route -- but its documented CLI syntax covers stdio\n` +
47
- `servers only (checked 2026-08-26), so the TOML above is the part that\n` +
48
- `can be stated exactly.`);
49
- }
50
- export function getManualHarnesses() {
51
- return [{ name: 'Codex', configPath: join(homedir(), '.codex', 'config.toml'), note: codexNote }];
38
+ // The SessionEnd flush has to finish inside the hook entry capture.ts
39
+ // installs for it, or Claude Code kills the process mid-request with no
40
+ // chance to log why. That entry's `timeout` (seconds) is
41
+ // SESSION_END_HOOK_TIMEOUT_S, defined here (not in hook.ts) so capture.ts's
42
+ // import of it and hook.ts's own use of the Claude Code profile cannot drift
43
+ // apart again: until 2026-09-04 the network budget below was 800 ms, derived
44
+ // from Claude Code's 1.5 s *default* SessionEnd budget, while the installed
45
+ // entry had already raised that budget to 10 s -- and 800 ms is one cold
46
+ // round trip to api.waku.one from Yang's machine (three real POST
47
+ // /ingest/session calls measured 822, 644 and 1174 ms), so two of every
48
+ // three flushes were aborted by our own timer. That is the last turn of
49
+ // every session, and all of a single-turn one.
50
+ //
51
+ // The network budget is the process budget minus what happens before the
52
+ // request: `npx -y waku-memory hook` took 2.8 s to reach this code cold
53
+ // (2026-09-04), and Node startup, stdin and the transcript read are a few
54
+ // hundred milliseconds more. 5 s leaves about 2 s of the 10 s for a slower
55
+ // machine. hook.test.mjs pins both bounds. Stop is not bounded this way
56
+ // (600 s budget, and runs "async": true), so its path through postDelta does
57
+ // not use this.
58
+ //
59
+ // Defined here rather than in hook.ts to avoid an import cycle: hook.ts
60
+ // needs profileFor() from this file, and capture.ts needs these two
61
+ // constants -- re-exported from hook.ts, unchanged, so capture.ts's existing
62
+ // import keeps working.
63
+ export const SESSION_END_HOOK_TIMEOUT_S = 10;
64
+ export const SESSION_END_TIMEOUT_MS = 5000;
65
+ export const PROFILES = {
66
+ claude_code: {
67
+ id: 'claude_code',
68
+ contentFormat: CONTENT_FORMAT,
69
+ formatDelta,
70
+ sessionEndTimeoutMs: SESSION_END_TIMEOUT_MS,
71
+ sessionEndHookTimeoutS: SESSION_END_HOOK_TIMEOUT_S,
72
+ briefOutput: (t) => t,
73
+ silentWithoutCredential: false,
74
+ },
75
+ codex: {
76
+ id: 'codex',
77
+ contentFormat: CODEX_CONTENT_FORMAT,
78
+ formatDelta: formatCodexDelta,
79
+ sessionEndTimeoutMs: 2000,
80
+ sessionEndHookTimeoutS: 3,
81
+ briefOutput: (t) => JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: t } }),
82
+ silentWithoutCredential: true,
83
+ },
84
+ };
85
+ export function profileFor(id) {
86
+ return PROFILES[id];
52
87
  }