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.
- package/.codex-plugin/plugin.json +15 -0
- package/.mcp.json +9 -0
- package/README.md +55 -0
- package/dist/bootstrap.js +256 -64
- package/dist/capture.js +660 -112
- package/dist/cli.js +351 -64
- package/dist/codex-config.js +321 -0
- package/dist/dialogue-codex.js +63 -0
- package/dist/dialogue.js +2 -2
- package/dist/harnesses.js +57 -22
- package/dist/hook.js +103 -74
- package/dist/login.js +312 -0
- package/hooks/hooks.json +63 -0
- package/package.json +2 -2
- package/skills/waku/SKILL.md +23 -0
|
@@ -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 =
|
|
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
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
}
|