waku-memory 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Waku
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,231 @@
1
+ // `capture enable` / `capture disable` -- the one gesture that turns
2
+ // automatic memory capture on, and the disclosure gate it exists to carry.
3
+ // Wired by cli.ts, the same split hook.ts established: everything here
4
+ // takes its inputs as plain values (a deps bag, an already-decided action)
5
+ // so capture.test.mjs can drive it against temp directories and an injected
6
+ // prompt, never a real ~/.claude or ~/.waku-memory and never real stdin.
7
+ //
8
+ // This command is where status.md item 9's disclosure obligation is
9
+ // actually discharged: enabling capture means every turn of the user's
10
+ // Claude Code sessions is posted to our server and sent on to Anthropic for
11
+ // extraction. The gate below is deliberately not a formality -- see
12
+ // DISCLOSURE and enable()'s ordering, which prints it and requires a typed
13
+ // "y" before anything else happens, including asking for a key.
14
+ //
15
+ // Zero dependencies, per the shim's package.json: only node:fs, node:path
16
+ // and node:util (all built in) below.
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { isDeepStrictEqual } from 'node:util';
20
+ // The marker every hook command this tool writes contains, and the only
21
+ // thing removeHookSettings uses to tell "ours" from a foreign hook on the
22
+ // same event. Deliberately just the tail of the real command (not the full
23
+ // "npx -y waku-memory hook" string) so a hand-edited variant --
24
+ // different flags, a pinned version -- is still recognized as ours.
25
+ export const WAKU_HOOK_MARKER = 'waku-memory hook';
26
+ // No separate script file to install, version, or leave stale (spec 005):
27
+ // every event runs the shim package itself via npx. `-y` skips npm's
28
+ // install-confirmation prompt, which would otherwise hang a hook that has
29
+ // no terminal to answer it.
30
+ const WAKU_HOOK_COMMAND = `npx -y ${WAKU_HOOK_MARKER}`;
31
+ // Must match hook.ts's own (unexported) CONFIG_FILE_NAME -- the two files
32
+ // agree on this only by convention, not by import, because hook.ts is
33
+ // deliberately dependency-free of this file (it is the piece that runs
34
+ // unattended on every turn; this is the piece a human runs once).
35
+ const CONFIG_FILE_NAME = 'config.json';
36
+ // `Stop` is where captured content is actually sent -- "async": true so it
37
+ // runs in the background without blocking the turn (600s default budget,
38
+ // research doc §2). `SessionStart` posts the liveness heartbeat on the same
39
+ // fire-and-forget basis. `SessionEnd` is the flush: all SessionEnd hooks
40
+ // share a 1.5s-by-default exit budget (raisable only to 60s) and cannot
41
+ // block Claude Code's own exit -- hook.ts's SESSION_END_TIMEOUT_MS comment
42
+ // has the measured detail -- so it carries a `timeout` instead of `async`.
43
+ // `async` only affects whether a hook keeps running after Claude Code moves
44
+ // on; for an event that already cannot block exit, that distinction does
45
+ // not apply, so it is left off rather than set to a value the harness
46
+ // ignores anyway (pinned by the given test's `sessionEnd.async === undefined`).
47
+ const HOOK_EVENTS = {
48
+ Stop: { type: 'command', command: WAKU_HOOK_COMMAND, async: true },
49
+ SessionStart: { type: 'command', command: WAKU_HOOK_COMMAND, async: true },
50
+ SessionEnd: { type: 'command', command: WAKU_HOOK_COMMAND, timeout: 10 },
51
+ };
52
+ // A group is "ours" only if every hook inside it is ours -- mergeHookSettings
53
+ // only ever writes single-hook groups, so in practice this means exactly
54
+ // one hook whose command carries the marker, but the "every" check (rather
55
+ // than "some") is what makes removeHookSettings's contract exact: it must
56
+ // never delete a group that also carries someone else's hook.
57
+ function isOwnGroup(group) {
58
+ return group.hooks.length > 0 && group.hooks.every((h) => h.command.includes(WAKU_HOOK_MARKER));
59
+ }
60
+ // Appends one group per event in HOOK_EVENTS, skipping any event that
61
+ // already carries one of ours -- running `enable` twice must not duplicate
62
+ // entries (pinned by the brief's idempotence test and this file's "running
63
+ // twice" orchestration test). A foreign group already on the same event is
64
+ // never inspected beyond isOwnGroup's check and is never reordered or
65
+ // rewritten -- only ours is ever added.
66
+ export function mergeHookSettings(existing) {
67
+ const hooks = { ...(existing.hooks ?? {}) };
68
+ for (const [event, entry] of Object.entries(HOOK_EVENTS)) {
69
+ const groups = hooks[event] ?? [];
70
+ if (!groups.some(isOwnGroup)) {
71
+ hooks[event] = [...groups, { hooks: [entry] }];
72
+ }
73
+ }
74
+ return { ...existing, hooks };
75
+ }
76
+ // The exact inverse: drops every group that is entirely ours, and drops an
77
+ // event key altogether once nothing is left under it -- a stray
78
+ // `"SessionStart": []` would be exactly as misleading as leaving the hook in
79
+ // place, since both read as "something runs here". Not exported as
80
+ // disable()'s whole job: disable() also decides whether there was anything
81
+ // to remove at all (see its own comment).
82
+ export function removeHookSettings(existing) {
83
+ if (!existing.hooks)
84
+ return existing;
85
+ const hooks = {};
86
+ for (const [event, groups] of Object.entries(existing.hooks)) {
87
+ const kept = groups.filter((g) => !isOwnGroup(g));
88
+ if (kept.length > 0)
89
+ hooks[event] = kept;
90
+ }
91
+ if (Object.keys(hooks).length === 0) {
92
+ const { hooks: _drop, ...rest } = existing;
93
+ return rest;
94
+ }
95
+ return { ...existing, hooks };
96
+ }
97
+ // The exact two sentences spec 005's "Disclosure" section requires, shown
98
+ // at the moment that discharges status.md item 9's obligation for capture:
99
+ // "before anyone outside the three of us is invited, and no later than the
100
+ // day a worker is deployed, they need to be told the environment can lose
101
+ // their data and that importing [or capturing] sends its contents to
102
+ // Anthropic." Both facts, two sentences, nothing softened.
103
+ export const DISCLOSURE = 'Captured content is sent to our servers and to Anthropic for extraction. ' +
104
+ 'This is an alpha whose data can be lost.';
105
+ // Exported so cli.ts's usage text can point at the same URL without a
106
+ // second copy of it drifting out of sync.
107
+ //
108
+ // www.waku.one, not waku-mem.vercel.app, which this was until 2026-09-01 --
109
+ // changed before the first npm publish rather than after, because `npx`
110
+ // ships a default and an installed user keeps it until they update. That is
111
+ // exactly how the API address became a cutover instead of an edit (spec
112
+ // 008): a string that reaches strangers is expensive to move afterwards and
113
+ // free to move now. tech.md calls the Vercel host an earlier one, kept and
114
+ // not retired; this is the product's own address.
115
+ export const KEYS_PAGE_URL = 'https://www.waku.one/account/keys';
116
+ // Reads settings.json the same way applyToHarness (cli.ts) reads a harness
117
+ // config: missing or blank is a blank slate (undefined here would wrongly
118
+ // refuse the single most common first run -- no settings.json yet at all);
119
+ // anything non-blank that fails to parse, or does not parse to a plain
120
+ // object, is real corruption and returns undefined so the caller refuses
121
+ // rather than risks merging into -- and later overwriting -- a file it
122
+ // could not understand.
123
+ function readClaudeSettings(settingsPath) {
124
+ let raw;
125
+ try {
126
+ raw = readFileSync(settingsPath, 'utf8');
127
+ }
128
+ catch {
129
+ return {};
130
+ }
131
+ if (raw.trim() === '')
132
+ return {};
133
+ try {
134
+ const parsed = JSON.parse(raw);
135
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
136
+ return undefined;
137
+ return parsed;
138
+ }
139
+ catch {
140
+ return undefined;
141
+ }
142
+ }
143
+ // The one gesture that turns capture on. Order is deliberate and each step
144
+ // depends on the one before it having succeeded:
145
+ // 1. refuse if Claude Code itself is not on this machine -- the directory
146
+ // is the evidence, the shim's own edit-don't-create rule adapted from
147
+ // cli.ts's harness handling;
148
+ // 2. refuse if the existing settings.json cannot be safely read -- checked
149
+ // here, before the disclosure, so a machine this can never work on
150
+ // fails fast instead of making the user type through a consent flow
151
+ // first only to hit a wall at the end;
152
+ // 3. print the disclosure and demand a typed "y" -- before anything else,
153
+ // including asking for the key, so declining costs the user nothing
154
+ // and asks for nothing;
155
+ // 4. only then ask for the key;
156
+ // 5. write config.json, then merge and write settings.json. Everything
157
+ // before this point (refusing, declining) writes nothing at all --
158
+ // that guarantee is real. This step itself is not atomic across the
159
+ // two files, though: config.json is committed to disk first, so a
160
+ // failure on the settings.json write right after -- disk full, a
161
+ // permission error -- does leave a real credential saved with no
162
+ // hook actually installed, i.e. capture silently not active despite
163
+ // config.json existing. Accepted rather than guarded against (no
164
+ // rollback, no pre-write dry run): the failure still propagates out
165
+ // of enable() to cli.ts's runCaptureCommand, which reports it and
166
+ // sets a nonzero exit code, so it is not silent; it fails toward
167
+ // "capture not enabled" rather than a consent bypass; and re-running
168
+ // `enable` repairs it, since writing config.json again is a plain
169
+ // overwrite (capture.test.mjs's "running twice" test).
170
+ export async function enable(url, deps) {
171
+ if (!existsSync(deps.claudeDir)) {
172
+ console.error(`Claude Code not found at ${deps.claudeDir} -- install Claude Code first, then run ` +
173
+ `"npx waku-memory capture enable" again.`);
174
+ return 'refused';
175
+ }
176
+ const existingSettings = readClaudeSettings(deps.settingsPath);
177
+ if (existingSettings === undefined) {
178
+ console.error(`${deps.settingsPath}'s content is not something this tool can safely merge into -- ` +
179
+ `leaving it untouched. Nothing was written.`);
180
+ return 'refused';
181
+ }
182
+ console.log(DISCLOSURE);
183
+ console.log('');
184
+ const confirmation = await deps.prompt('Type "y" to continue, anything else to cancel: ');
185
+ if (confirmation.trim().toLowerCase() !== 'y') {
186
+ console.log('Capture not enabled -- nothing was written.');
187
+ return 'declined';
188
+ }
189
+ const key = (await deps.prompt(`Paste an API key from ${KEYS_PAGE_URL} (it is shown once, at mint time): `)).trim();
190
+ mkdirSync(deps.configDir, { recursive: true }); // first run: ~/.waku-memory may not exist yet
191
+ const configPath = join(deps.configDir, CONFIG_FILE_NAME);
192
+ // {mode: 0o600} is a request, not a guarantee: Windows has no POSIX
193
+ // owner-only bit for writeFileSync to set, so this line is best-effort
194
+ // there and fully effective on Unix. Correct to pass on every platform
195
+ // regardless -- harmless where it cannot apply, the right thing where it
196
+ // can. Not routed through atomicWriteJson: that writer has no mode
197
+ // parameter, and this file is small, freshly created, and not something
198
+ // Claude Code itself reads concurrently the way settings.json is -- the
199
+ // atomic temp-file-plus-rename technique earns its complexity there, not
200
+ // here.
201
+ writeFileSync(configPath, JSON.stringify({ url, key }, null, 2) + '\n', { mode: 0o600 });
202
+ const merged = mergeHookSettings(existingSettings);
203
+ deps.writeSettingsJson(deps.settingsPath, merged);
204
+ console.log('');
205
+ console.log(`Capture enabled -- wrote ${configPath} and merged hooks into ${deps.settingsPath}.`);
206
+ console.log('Start a new Claude Code session to pick up the hooks.');
207
+ return 'enabled';
208
+ }
209
+ // Removes exactly what enable() added and nothing else. Deliberately leaves
210
+ // config.json in place: the key is the user's to revoke on the keys page,
211
+ // not this tool's to delete -- disabling capture and revoking the
212
+ // credential are two different decisions, and conflating them would make
213
+ // "disable" destructive in a way its name does not promise.
214
+ export async function disable(deps) {
215
+ const existingSettings = readClaudeSettings(deps.settingsPath);
216
+ if (existingSettings === undefined) {
217
+ console.error(`${deps.settingsPath}'s content is not something this tool can safely modify -- leaving it untouched.`);
218
+ return 'refused';
219
+ }
220
+ const removed = removeHookSettings(existingSettings);
221
+ if (isDeepStrictEqual(removed, existingSettings)) {
222
+ console.log('Capture was not enabled here -- nothing to remove.');
223
+ return 'nothing-to-disable';
224
+ }
225
+ deps.writeSettingsJson(deps.settingsPath, removed);
226
+ const configPath = join(deps.configDir, CONFIG_FILE_NAME);
227
+ console.log(`Capture disabled -- removed the hook entries from ${deps.settingsPath}.`);
228
+ console.log(`Your API key is still saved at ${configPath}. Revoke it at ${KEYS_PAGE_URL} if you want to ` +
229
+ `fully remove access.`);
230
+ return 'disabled';
231
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,501 @@
1
+ // The argument dispatch and per-harness logic behind `npx waku-memory
2
+ // setup` (see index.ts for the shim's overall job and the credential
3
+ // story -- this file is everything downstream of "here is argv").
4
+ //
5
+ // Split out of index.ts on review: everything exported here is a plain
6
+ // function with no top-level side effects, so cli.test.mjs can import and
7
+ // call it directly against temp files. index.ts, by contrast, calls run()
8
+ // the instant it's loaded -- which is exactly why nothing imports
9
+ // index.ts itself in a test. Decision logic (dispatch, runSetup,
10
+ // summarize) is also kept separate from the imperative shell that prints
11
+ // and sets process.exitCode (run, setup), so the two things that were
12
+ // actually wrong during manual testing -- argument dispatch dropping
13
+ // --help, and the found/wrote/error tri-state collapsing into one boolean
14
+ // -- are pinned by plain assertions on return values, not by mocking
15
+ // console.log.
16
+ import { readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { basename, dirname, join } from 'node:path';
19
+ import { createInterface } from 'node:readline';
20
+ import { getHarnesses, getManualHarnesses, harnessConfigExists, mergeMcpConfig, } from "./harnesses.js";
21
+ import { handleHookEvent } from "./hook.js";
22
+ import { KEYS_PAGE_URL, disable, enable } from "./capture.js";
23
+ export const DEFAULT_URL = 'https://api.waku.one/mcp';
24
+ export const DEFAULT_NAME = 'waku';
25
+ // The ingest base capture.ts writes into config.json -- deliberately not
26
+ // DEFAULT_URL: that one carries setup's "/mcp" suffix, which is the wrong
27
+ // shape for hook.ts's endpointUrl() to append "/ingest/session" etc. onto.
28
+ export const DEFAULT_INGEST_URL = 'https://api.waku.one';
29
+ // The step after the server is configured, and the one the whole product
30
+ // claim rests on: nothing has been imported yet, so there is nothing in
31
+ // another harness to find.
32
+ //
33
+ // spec.md's "The shim" section said this shim "triggers the import". It
34
+ // never could: the same sentence says it obtains no credential, and
35
+ // memory.import is an authenticated MCP call, so triggering it would need
36
+ // exactly the credential ops-design §5 forbids this to hold. The spec is
37
+ // amended (2026-08-25); this is the achievable half of what it wanted --
38
+ // the user is told what to ask for, in words they can paste, by the one
39
+ // thing on this machine that already knows the server is now configured.
40
+ //
41
+ // Names the two files by convention rather than reading the directory:
42
+ // `npx waku-memory setup` is not necessarily run from a project root, and
43
+ // listing files that are not there would be worse than naming the ones
44
+ // people actually have.
45
+ //
46
+ // The last two lines are item 9's other half (spec 005 "Disclosure",
47
+ // corrected 2026-08-28 during that spec's execution): this function was
48
+ // once documented as already carrying that disclosure, and it never did --
49
+ // memory.import has been reachable since spec 004, so a person could send a
50
+ // file's contents to Anthropic having been told nothing. Matches
51
+ // capture.ts's DISCLOSURE in voice and content, worded for import rather
52
+ // than capture.
53
+ export function importNote() {
54
+ return (`Then, to put this project's conventions into Waku, ask the agent:\n` +
55
+ `\n` +
56
+ ` Run memory.import with this project's CLAUDE.md and AGENTS.md.\n` +
57
+ `\n` +
58
+ `The agent reads the files and makes the call -- this installer cannot,\n` +
59
+ `because it holds no credential, by design. Extraction is asynchronous,\n` +
60
+ `so the memories appear shortly rather than in that call. After that they\n` +
61
+ `are available in your other projects and other harnesses, which is the\n` +
62
+ `point of importing them at all.\n` +
63
+ `\n` +
64
+ `Imported content is sent to our servers and to Anthropic for extraction.\n` +
65
+ `This is an alpha whose data can be lost.`);
66
+ }
67
+ export function parseArgs(argv) {
68
+ let name = DEFAULT_NAME;
69
+ let url = DEFAULT_URL;
70
+ const positional = [];
71
+ for (let i = 0; i < argv.length; i++) {
72
+ const arg = argv[i];
73
+ if (arg === '--name' && i + 1 < argv.length) {
74
+ name = argv[++i];
75
+ }
76
+ else if (arg === '--url' && i + 1 < argv.length) {
77
+ url = argv[++i];
78
+ }
79
+ else if (!arg.startsWith('--')) {
80
+ positional.push(arg);
81
+ }
82
+ }
83
+ // subcommand is only meaningful to the caller for "capture enable" /
84
+ // "capture disable" -- setup and hook both ignore it, same as they always
85
+ // ignored any positional beyond the first.
86
+ return { command: positional[0], subcommand: positional[1], name, url };
87
+ }
88
+ export function printUsage() {
89
+ console.log('Usage: npx waku-memory setup [--name <name>] [--url <url>]');
90
+ console.log('');
91
+ console.log(` --name key to add under mcpServers (default: "${DEFAULT_NAME}")`);
92
+ console.log(` --url server URL to write (default: ${DEFAULT_URL})`);
93
+ console.log('');
94
+ console.log('Usage: waku-memory hook');
95
+ console.log('');
96
+ console.log(' Reads one Claude Code hook event as JSON on stdin and reports it to');
97
+ console.log(' the server named in ~/.waku-memory/config.json. Invoked by Claude Code');
98
+ console.log(' itself (see "capture enable") -- not meant to be run by hand.');
99
+ console.log('');
100
+ console.log('Usage: waku-memory capture enable [--url <ingest-base>]');
101
+ console.log('');
102
+ console.log(' Turns on automatic capture: every turn of your Claude Code sessions is');
103
+ console.log(' sent to our server and on to Anthropic for extraction. Shows that');
104
+ console.log(' disclosure and demands a typed "y" before asking for anything else, then');
105
+ console.log(` asks for an API key (mint one at ${KEYS_PAGE_URL})`);
106
+ console.log(' and installs the hook into ~/.claude/settings.json.');
107
+ console.log('');
108
+ console.log('Usage: waku-memory capture disable');
109
+ console.log('');
110
+ console.log(' Removes exactly the hook entries "capture enable" added. Your saved key');
111
+ console.log(' is left in place -- revoke it on the keys page if you want it gone too.');
112
+ }
113
+ export function dispatch(argv) {
114
+ // Checked ahead of parseArgs deliberately: --help/-h are flags, so
115
+ // parseArgs (which only special-cases --name/--url) would otherwise drop
116
+ // them silently and this would fall through to "no command" with the
117
+ // wrong exit code -- that was a real bug, caught by hand before any test
118
+ // existed to pin it. See cli.test.mjs's dispatch block.
119
+ if (argv.includes('--help') || argv.includes('-h'))
120
+ return { kind: 'help' };
121
+ const { command, subcommand, name, url } = parseArgs(argv);
122
+ if (command === undefined)
123
+ return { kind: 'no-command' };
124
+ if (command === 'hook')
125
+ return { kind: 'hook' };
126
+ if (command === 'capture') {
127
+ if (subcommand !== 'enable' && subcommand !== 'disable')
128
+ return { kind: 'unknown', command: 'capture' };
129
+ // parseArgs's `url` defaults to DEFAULT_URL (setup's "/mcp" endpoint)
130
+ // whenever --url is absent -- the wrong default here. Checking argv
131
+ // directly for the flag, rather than trusting that default, is the
132
+ // cheapest way to tell "the user passed --url" apart from "parseArgs's
133
+ // unrelated default happened to apply" without giving parseArgs a
134
+ // second, command-dependent default of its own.
135
+ return { kind: 'capture', action: subcommand, url: argv.includes('--url') ? url : DEFAULT_INGEST_URL };
136
+ }
137
+ if (command !== 'setup')
138
+ return { kind: 'unknown', command };
139
+ return { kind: 'setup', name, url };
140
+ }
141
+ // Same-directory temp file + rename: rename is only atomic within one
142
+ // filesystem, so the temp file has to live next to the real one -- a
143
+ // different directory (or /tmp on another device) would make the rename
144
+ // itself non-atomic, which defeats the point. Without this,
145
+ // writeFileSync(configPath, ...) truncates the real file before it writes
146
+ // a single byte back; a crash, a kill, or a full disk in that window
147
+ // leaves a real, in-use config -- ~78 KB and ~80 keys, by measurement --
148
+ // truncated or corrupted.
149
+ export const atomicWriteJson = (configPath, data) => {
150
+ const dir = dirname(configPath);
151
+ const tmpPath = join(dir, `.${basename(configPath)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`);
152
+ try {
153
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
154
+ renameSync(tmpPath, configPath);
155
+ }
156
+ catch (err) {
157
+ try {
158
+ unlinkSync(tmpPath);
159
+ }
160
+ catch {
161
+ // Best-effort cleanup only -- the error below is the one that matters,
162
+ // and a failed cleanup must not hide it. Also fires harmlessly
163
+ // (ENOENT, swallowed the same way) if writeFileSync itself never got
164
+ // far enough to create tmpPath at all.
165
+ }
166
+ throw err;
167
+ }
168
+ };
169
+ // Adds `name` -> `url` to one harness's config file. Never throws: every
170
+ // fallible step -- read, parse, merge, write -- is caught right here, so a
171
+ // bad harness cannot stop the others from being tried and cannot crash the
172
+ // process with a raw stack trace. Failure is the 'error' branch of the
173
+ // return value, for runSetup()/setup() to tally and report.
174
+ export function applyToHarness(configPath, harnessLabel, name, url, writeJson = atomicWriteJson) {
175
+ let existing;
176
+ try {
177
+ const raw = readFileSync(configPath, 'utf8');
178
+ if (raw.trim() === '') {
179
+ // An empty file is a blank slate, not corruption -- and it is the
180
+ // shipped initial state of a freshly installed harness. Measured
181
+ // 2026-08-26: ~/.cursor/mcp.json on this machine is zero bytes, with
182
+ // Cursor installed and never having had an MCP server added.
183
+ // JSON.parse('') throws, so without this branch the single most
184
+ // likely first-run state would be reported as "could not read or
185
+ // parse ... leaving it untouched" and setup would fail for exactly
186
+ // the people who had done nothing wrong -- the worst population to
187
+ // fail on, since they have no configuration to inspect for a clue.
188
+ // Deliberately narrow: only whitespace qualifies. Any non-blank
189
+ // content that fails to parse is real corruption and is still
190
+ // refused by the catch below, because overwriting a file whose
191
+ // contents we could not understand is the one thing this function
192
+ // must never do.
193
+ existing = {};
194
+ }
195
+ else {
196
+ const parsed = JSON.parse(raw);
197
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
198
+ // Valid JSON, but not a shape this tool can safely merge into.
199
+ // existing.mcpServers below would throw on a null base -- optional
200
+ // chaining only guards the property *lookup* (the "?.[name]" part),
201
+ // not "existing" itself being null -- reproduced against the
202
+ // compiled binary with a config file whose entire content is the
203
+ // JSON literal "null". Arrays and other primitives don't throw the
204
+ // same way, but spreading them into the merged object would
205
+ // silently corrupt the file instead, which is just as wrong.
206
+ // Refused for the same reason mergeMcpConfig refuses a same-name
207
+ // entry with a different URL: loud and legible beats a raw stack
208
+ // trace or silent corruption.
209
+ return {
210
+ status: 'error',
211
+ message: `${harnessLabel}: ${configPath}'s content is not a JSON object -- leaving it untouched.`,
212
+ };
213
+ }
214
+ existing = parsed;
215
+ }
216
+ }
217
+ catch (err) {
218
+ return {
219
+ status: 'error',
220
+ message: `${harnessLabel}: could not read or parse ${configPath} -- leaving it untouched.\n` +
221
+ ` ${err.message}`,
222
+ };
223
+ }
224
+ const alreadyPresent = existing.mcpServers?.[name] !== undefined;
225
+ let merged;
226
+ try {
227
+ merged = mergeMcpConfig(existing, name, url);
228
+ }
229
+ catch (err) {
230
+ // mergeMcpConfig's refusal: same name, different URL already there.
231
+ return { status: 'error', message: `${harnessLabel}: ${err.message}` };
232
+ }
233
+ if (alreadyPresent) {
234
+ return {
235
+ status: 'unchanged',
236
+ message: `${harnessLabel}: "${name}" already points at ${url} -- nothing to change.`,
237
+ };
238
+ }
239
+ try {
240
+ writeJson(configPath, merged);
241
+ }
242
+ catch (err) {
243
+ return {
244
+ status: 'error',
245
+ message: `${harnessLabel}: could not write ${configPath} -- ${err.message}`,
246
+ };
247
+ }
248
+ return { status: 'wrote', message: `${harnessLabel}: added "${name}" -> ${url} (${configPath}).` };
249
+ }
250
+ export function runSetup(harnesses, name, url, writeJson = atomicWriteJson) {
251
+ return harnesses.map((harness) => {
252
+ if (!harnessConfigExists(harness)) {
253
+ return {
254
+ harness: harness.name,
255
+ configPath: harness.configPath,
256
+ status: 'not-found',
257
+ message: `${harness.name}: not found at ${harness.configPath} -- skipping.`,
258
+ };
259
+ }
260
+ const { status, message } = applyToHarness(harness.configPath, harness.name, name, url, writeJson);
261
+ return { harness: harness.name, configPath: harness.configPath, status, message };
262
+ });
263
+ }
264
+ // Tracked as three separate facts on purpose: "found but already correct"
265
+ // and "not found at all" collapsed into one boolean in an earlier draft,
266
+ // which made a re-run on an already-configured machine print "no
267
+ // supported harness config was found" -- false, and actively misleading
268
+ // on what is probably the most common real invocation. See
269
+ // cli.test.mjs's runSetup + summarize block.
270
+ export function summarize(results) {
271
+ return {
272
+ foundAny: results.some((r) => r.status !== 'not-found'),
273
+ wroteAny: results.some((r) => r.status === 'wrote'),
274
+ hadError: results.some((r) => r.status === 'error'),
275
+ };
276
+ }
277
+ export function setup(name, url, harnesses = getHarnesses(), manualHarnesses = getManualHarnesses()) {
278
+ const results = runSetup(harnesses, name, url);
279
+ for (const r of results) {
280
+ if (r.status === 'error')
281
+ console.error(r.message);
282
+ else
283
+ console.log(r.message);
284
+ }
285
+ // Only the ones actually on this machine. A manual harness is reported
286
+ // as found -- it just cannot be written to -- so it counts toward
287
+ // "did we find anything at all" below, and specifically toward whether
288
+ // the import step is worth mentioning: someone who follows the
289
+ // instructions has a working server, same as an auto-configured one.
290
+ const manualPresent = manualHarnesses.filter(harnessConfigExists);
291
+ for (const h of manualPresent) {
292
+ console.log('');
293
+ console.log(h.note(name, url));
294
+ }
295
+ console.log('');
296
+ const { foundAny: autoFound, wroteAny, hadError } = summarize(results);
297
+ const foundAny = autoFound || manualPresent.length > 0;
298
+ if (wroteAny) {
299
+ console.log('Next: open the harness and use a waku tool. The first call will get a 401, ' +
300
+ 'the harness will open your browser to sign in, and the call will then go ' +
301
+ 'through on retry -- that browser popup is expected, not an error.');
302
+ }
303
+ else if (autoFound && !hadError) {
304
+ console.log('Already configured correctly -- nothing to change.');
305
+ }
306
+ else if (autoFound && hadError) {
307
+ console.log('Could not update every harness found -- see the messages above.');
308
+ }
309
+ else if (manualPresent.length > 0) {
310
+ // Deliberately its own branch rather than falling into "already
311
+ // configured": a manual harness being *present* is not the same as it
312
+ // being *configured*, and telling someone nothing needs changing when
313
+ // they still have a TOML block to paste is the more expensive of the
314
+ // two possible wrong messages.
315
+ console.log('Nothing was written: the only harness found here is one this shim does not ' +
316
+ 'configure automatically -- follow the instructions above to finish.');
317
+ }
318
+ else {
319
+ console.log('Nothing was added: no supported harness config was found on this machine.');
320
+ }
321
+ // Printed whenever a usable server exists on this machine -- which
322
+ // includes the already-configured re-run, since a configured server with
323
+ // nothing imported is exactly the state a re-runner is most likely to be
324
+ // in. Withheld only when nothing was found at all, where the next step is
325
+ // to install a harness, not to import.
326
+ if (foundAny) {
327
+ console.log('');
328
+ console.log(importNote());
329
+ }
330
+ if (hadError)
331
+ process.exitCode = 1;
332
+ }
333
+ function errorMessage(err) {
334
+ return err instanceof Error ? err.message : String(err);
335
+ }
336
+ // Reads all of stdin and joins it back into one string. A hook event is a
337
+ // single JSON object, normally delivered as one line -- readline is used
338
+ // for it anyway (rather than collecting raw 'data' chunks) because it is
339
+ // the built-in that already handles encoding and end-of-stream correctly,
340
+ // and it is on the shim's short allowed-built-ins list for exactly this.
341
+ function readStdin() {
342
+ return new Promise((resolve, reject) => {
343
+ const lines = [];
344
+ process.stdin.on('error', reject);
345
+ const rl = createInterface({ input: process.stdin, terminal: false });
346
+ rl.on('line', (line) => lines.push(line));
347
+ rl.on('close', () => resolve(lines.join('\n')));
348
+ });
349
+ }
350
+ // The imperative shell around handleHookEvent: read stdin, parse it, hand
351
+ // the result to the tested decision logic with the real deps (the user's
352
+ // actual config dir, the real global fetch). Deliberately never awaited by
353
+ // run() below -- see that call site's comment for why that is safe here
354
+ // specifically, which is not the same as it being safe in general.
355
+ async function runHookCommand() {
356
+ let raw;
357
+ try {
358
+ raw = await readStdin();
359
+ }
360
+ catch (err) {
361
+ console.error(`waku-memory hook: could not read stdin -- ${errorMessage(err)}.`);
362
+ return;
363
+ }
364
+ let event;
365
+ try {
366
+ event = JSON.parse(raw);
367
+ }
368
+ catch (err) {
369
+ // Malformed input is a no-op, not a crash: some future Claude Code
370
+ // hook event this shim was not written against, or a hand test of
371
+ // `waku-memory hook` with no stdin piped in at all.
372
+ console.error(`waku-memory hook: stdin was not valid JSON -- ${errorMessage(err)}.`);
373
+ return;
374
+ }
375
+ await handleHookEvent(event, { configDir: join(homedir(), '.waku-memory'), fetchImpl: fetch });
376
+ }
377
+ // The real deps enable()/disable() run against outside a test: real paths
378
+ // under the user's home directory, an injected prompt (built by the two
379
+ // call sites below), and cli.ts's own atomicWriteJson as the settings
380
+ // writer -- capture.ts never imports atomicWriteJson itself (that would
381
+ // import cli.ts from capture.ts, which already imports capture.ts the
382
+ // other way around), so the cast below is the one place the two files'
383
+ // JSON-shaped types meet. Safe in practice: atomicWriteJson only ever does
384
+ // JSON.stringify(data) -- it has no opinion about McpConfig vs
385
+ // ClaudeSettings beyond the type checker's.
386
+ function realCaptureDeps(prompt) {
387
+ const claudeDir = join(homedir(), '.claude');
388
+ return {
389
+ claudeDir,
390
+ settingsPath: join(claudeDir, 'settings.json'),
391
+ configDir: join(homedir(), '.waku-memory'),
392
+ prompt,
393
+ writeSettingsJson: (path, data) => atomicWriteJson(path, data),
394
+ };
395
+ }
396
+ // Not awaited by run() below, for the same structural reason runHookCommand
397
+ // isn't (see run()'s 'hook' case) -- but unlike hook, which must always
398
+ // exit 0 so a capture bug can never read as the user's own session failing,
399
+ // this command is interactive and its exit code is meant to mean something:
400
+ // 'refused' (Claude Code missing, or a settings.json this tool cannot
401
+ // safely touch) is the one outcome that should make a scripted caller
402
+ // notice. 'declined' and the disable no-op are both a normal, successful
403
+ // run that did exactly what was asked -- nothing.
404
+ async function runCaptureCommand(action, url) {
405
+ if (action === 'disable') {
406
+ // disable() never prompts (see its own comment) -- no readline interface
407
+ // is created at all, and this stub is a fail-loud guard against that
408
+ // ever silently stopping being true.
409
+ const deps = realCaptureDeps(() => {
410
+ throw new Error('waku-memory capture disable: unexpectedly tried to prompt.');
411
+ });
412
+ const result = await disable(deps);
413
+ if (result === 'refused')
414
+ process.exitCode = 1;
415
+ return;
416
+ }
417
+ // One node:readline interface for both of enable()'s questions, answered
418
+ // visibly on the real terminal (the frontend keys page already told the
419
+ // user their pasted key shows once, so echoing it back is not a new
420
+ // exposure). Deliberately NOT rl.question() called twice: readline's
421
+ // 'line' event fires as soon as a full line is parsed out of whatever is
422
+ // currently buffered, with no listener to catch it unless a .question()
423
+ // call happens to already be pending at that exact moment. Over a piped
424
+ // (non-TTY) stdin, both answers can arrive in the same chunk before the
425
+ // second .question() has even been called -- its one-time listener
426
+ // attaches too late, the line that already fired is gone, and the
427
+ // returned promise never resolves. An unresolved promise alone does not
428
+ // keep Node's event loop alive, so the process then exits 0 having
429
+ // written nothing, with no error and no hang to point at it. Proven by
430
+ // hand against the compiled binary with piped stdin (Node's own
431
+ // top-level-await detector reproduces the identical stuck-callback
432
+ // symptom against a two-line pipe), not by the unit tests, which inject a
433
+ // prompt function and never touch real readline.
434
+ //
435
+ // Iterating the interface as an async iterator instead -- pulling one
436
+ // line per question from the same queue -- does not have this race: the
437
+ // iterator protocol buffers whatever arrives until something actually
438
+ // asks for it, so no line can be dropped between one answer and the next
439
+ // question being asked. Confirmed against the same two-line pipe before
440
+ // landing here.
441
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
442
+ const lines = rl[Symbol.asyncIterator]();
443
+ try {
444
+ const prompt = async (question) => {
445
+ process.stdout.write(question);
446
+ const { value, done } = await lines.next();
447
+ return done ? '' : value; // stdin closed before an answer arrived -- treated as "declined"
448
+ };
449
+ const result = await enable(url, realCaptureDeps(prompt));
450
+ if (result === 'refused')
451
+ process.exitCode = 1;
452
+ }
453
+ finally {
454
+ rl.close();
455
+ }
456
+ }
457
+ export function run(argv) {
458
+ const d = dispatch(argv);
459
+ switch (d.kind) {
460
+ case 'help':
461
+ printUsage();
462
+ process.exitCode = 0;
463
+ return;
464
+ case 'no-command':
465
+ printUsage();
466
+ process.exitCode = 1;
467
+ return;
468
+ case 'unknown':
469
+ console.error(`Unknown command "${d.command}".`);
470
+ printUsage();
471
+ process.exitCode = 1;
472
+ return;
473
+ case 'setup':
474
+ setup(d.name, d.url);
475
+ return;
476
+ case 'hook':
477
+ // Exits 0 always, per the brief: set up front, not left to whatever
478
+ // the default happens to be, and never touched again on any failure
479
+ // path below this line. Not awaited -- index.ts does not await run()
480
+ // either, and a floating promise is only safe because
481
+ // runHookCommand() cannot reject: handleHookEvent already wraps its
482
+ // own body in a catch-all, and the try/catches above cover the stdin
483
+ // read and the JSON.parse that happen before it. The .catch() here is
484
+ // a second backstop, not the mechanism this relies on.
485
+ process.exitCode = 0;
486
+ void runHookCommand().catch((err) => {
487
+ console.error(`waku-memory hook: unexpected failure -- ${errorMessage(err)}.`);
488
+ });
489
+ return;
490
+ case 'capture':
491
+ void runCaptureCommand(d.action, d.url).catch((err) => {
492
+ console.error(`waku-memory capture: unexpected failure -- ${errorMessage(err)}.`);
493
+ process.exitCode = 1;
494
+ });
495
+ return;
496
+ default: {
497
+ const exhaustive = d;
498
+ throw new Error(`unhandled dispatch kind: ${JSON.stringify(exhaustive)}`);
499
+ }
500
+ }
501
+ }
@@ -0,0 +1,52 @@
1
+ // Where each harness keeps its MCP config, and how to add to it without
2
+ // destroying what is there. One file per harness would be tidier and is not
3
+ // worth it yet: the shapes are nearly identical, and the differences are
4
+ // worth seeing side by side while there are only two.
5
+ import { existsSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ export function mergeMcpConfig(existing, name, url) {
9
+ const servers = existing.mcpServers ?? {};
10
+ const current = servers[name];
11
+ if (current && current.url !== url) {
12
+ // Refuse rather than overwrite. Someone configured this by hand, with a
13
+ // different URL or with headers we cannot see the purpose of, and
14
+ // silently replacing it would undo a deliberate choice with no trace.
15
+ throw new Error(`"${name}" is already configured with a different URL (${current.url}). ` +
16
+ `Remove it or pass --name to use a different key.`);
17
+ }
18
+ return { ...existing, mcpServers: { ...servers, [name]: { type: 'http', url } } };
19
+ }
20
+ export function getHarnesses() {
21
+ return [{ name: 'Claude Code', configPath: join(homedir(), '.claude.json') }];
22
+ }
23
+ // Structurally typed rather than taking a Harness, so the same check serves
24
+ // both lists -- a manual harness needs exactly the same "is it here?"
25
+ // question answered, and duplicating existsSync for it would let the two
26
+ // drift.
27
+ export function harnessConfigExists(harness) {
28
+ return existsSync(harness.configPath);
29
+ }
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 }];
52
+ }
package/dist/hook.js ADDED
@@ -0,0 +1,329 @@
1
+ // The `hook` subcommand's decision logic. Wired by cli.ts: run() reads one
2
+ // Claude Code hook event as JSON off stdin (that part deliberately does not
3
+ // live here -- see the note on handleHookEvent below) and calls
4
+ // handleHookEvent with the parsed event and a real deps bag. Everything in
5
+ // this file takes its inputs as plain values, so hook.test.mjs can call it
6
+ // directly against temp files and an injected fetchImpl, the same way
7
+ // cli.test.mjs drives applyToHarness without touching a real ~/.claude.json.
8
+ //
9
+ // This is the one piece of the shim that runs unattended, on every turn, in
10
+ // the middle of somebody else's tool -- see task 14's brief for the full
11
+ // argument. Two consequences shape every decision below:
12
+ //
13
+ // 1. It must never throw and never leave a nonzero exit behind. A capture
14
+ // bug must not read to the user as their actual Claude Code session
15
+ // failing. handleHookEvent wraps its entire body in one try/catch for
16
+ // exactly this reason, and every expected failure (missing config, no
17
+ // new bytes, a non-2xx response, a rejected fetch) returns through its
18
+ // own branch before ever reaching that catch -- the catch is the net
19
+ // under the net, for whatever this file's author did not think of.
20
+ // 2. Content goes to the ingest endpoint and nowhere else. No transcript
21
+ // byte, no delta, no response body is ever passed to console.log or
22
+ // console.error -- only counts, status codes, and the event/session
23
+ // names Claude Code itself already put on stdin.
24
+ //
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
+ import { basename, dirname, join } from 'node:path';
30
+ // Bumped by hand at release time alongside package.json's "version" -- the
31
+ // brief's call: a build-time constant here is fine, and it keeps this file
32
+ // free of any fs reach into its own package.json (which would also be
33
+ // wrong at runtime, since dist/hook.js does not sit next to package.json
34
+ // the way src/hook.ts does).
35
+ const SHIM_VERSION = '0.1.0';
36
+ const HARNESS = 'claude_code';
37
+ const CONFIG_FILE_NAME = 'config.json';
38
+ // Same-directory temp file + rename, exactly atomicWriteJson's technique in
39
+ // cli.ts (see its comment for the full why: rename is only atomic within
40
+ // one filesystem, so the temp file has to be a sibling of the real one).
41
+ // Not the same function, because this file writes plain decimal text (the
42
+ // watermark), not the McpConfig-shaped JSON atomicWriteJson's type signature
43
+ // is fixed to -- reusing the pattern rather than reusing the export.
44
+ function atomicWriteText(path, text) {
45
+ const dir = dirname(path);
46
+ mkdirSync(dir, { recursive: true }); // first watermark for a session: state/ may not exist yet
47
+ const tmpPath = join(dir, `.${basename(path)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`);
48
+ try {
49
+ writeFileSync(tmpPath, text, 'utf8');
50
+ renameSync(tmpPath, path);
51
+ }
52
+ catch (err) {
53
+ try {
54
+ unlinkSync(tmpPath);
55
+ }
56
+ catch {
57
+ // Best-effort cleanup only -- the error below is the one that
58
+ // matters, and a failed cleanup must not hide it.
59
+ }
60
+ throw err;
61
+ }
62
+ }
63
+ // Reads the byte offset left by a previous run. Missing file (first event
64
+ // for this session) or blank/unparseable content are all the same thing to
65
+ // the caller: "trust nothing, start from the top" -- never a throw.
66
+ //
67
+ // The whole trimmed string must be digits: Number.parseInt("1200\x00junk",
68
+ // 10) === 1200, not NaN, so a bare parseInt would trust the numeric prefix
69
+ // of a partially-corrupted file. A trusted-but-wrong offset that lands past
70
+ // the real transcript size makes readDelta report "nothing new" while
71
+ // content that was never actually sent sits unsent -- silent data loss, not
72
+ // merely an extra resend, which is why this is stricter than it looks.
73
+ //
74
+ // Falling back to 0 after several prior *successful* sends resends a
75
+ // concatenation the server has never hashed before -- content_hash
76
+ // (job_repository.py) is a whole-body SHA-256, not overlap-aware, so the
77
+ // inbox dedupe does NOT catch this case the way it catches a byte-identical
78
+ // retry (see postDelta). That resend is re-enqueued and re-extracted:
79
+ // bounded cost (duplicate jobs/facts), not lost or corrupted data. A
80
+ // garbled watermark can in practice only come from something other than
81
+ // this module's own atomicWriteText writing it (that always writes a clean
82
+ // non-negative integer), so this is cheap insurance against a corrupted
83
+ // local file, not a path this module's own writes are expected to hit.
84
+ function readWatermark(watermarkPath) {
85
+ try {
86
+ const raw = readFileSync(watermarkPath, 'utf8').trim();
87
+ if (!/^\d+$/.test(raw))
88
+ return 0;
89
+ const n = Number.parseInt(raw, 10);
90
+ return Number.isFinite(n) && n >= 0 ? n : 0;
91
+ }
92
+ catch {
93
+ return 0;
94
+ }
95
+ }
96
+ // Pure read: computes the delta and the watermark it would advance to, but
97
+ // writes nothing. handleHookEvent only persists nextWatermark after the
98
+ // POST built from `content` actually succeeds -- see its own comment.
99
+ export function readDelta(transcriptPath, watermarkPath) {
100
+ let buf;
101
+ try {
102
+ buf = readFileSync(transcriptPath);
103
+ }
104
+ 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
+ return null;
109
+ }
110
+ const size = buf.length;
111
+ const offset = readWatermark(watermarkPath);
112
+ if (offset >= size)
113
+ return null; // nothing new since last watermark (also covers a shrunk/rotated file)
114
+ return { content: buf.subarray(offset).toString('utf8'), nextWatermark: size };
115
+ }
116
+ // Reads {url, key} from <configDir>/config.json. Anything short of a clean
117
+ // {url: string, key: string} object -- the file absent (task 15 has not run
118
+ // yet, or this is a harness other than Claude Code with capture never
119
+ // enabled), empty, malformed JSON, or missing/empty fields -- is reported
120
+ // as "no config" rather than distinguished further: the caller's response
121
+ // (log one line, send nothing) is identical either way, and the specific
122
+ // reason is exactly the kind of detail that is not worth a second log line
123
+ // per conventions.md §8's "every do-nothing branch logs why", not "logs
124
+ // why in detail".
125
+ function readConfig(configDir) {
126
+ try {
127
+ const raw = readFileSync(join(configDir, CONFIG_FILE_NAME), 'utf8');
128
+ const parsed = JSON.parse(raw);
129
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
130
+ return null;
131
+ const { url, key } = parsed;
132
+ if (typeof url !== 'string' || url === '' || typeof key !== 'string' || key === '')
133
+ return null;
134
+ return { url, key };
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ }
140
+ // config.url is the bare API origin (e.g. "https://api.example.test"); this
141
+ // is the one place that turns it into a full endpoint, so a trailing slash
142
+ // in a hand-edited config.json can't produce "https://host//ingest/...".
143
+ function endpointUrl(baseUrl, path) {
144
+ return baseUrl.replace(/\/+$/, '') + path;
145
+ }
146
+ function postJson(deps, config, path, body, signal) {
147
+ return deps.fetchImpl(endpointUrl(config.url, path), {
148
+ method: 'POST',
149
+ headers: {
150
+ 'Content-Type': 'application/json',
151
+ Authorization: `Bearer ${config.key}`,
152
+ },
153
+ body: JSON.stringify(body),
154
+ signal,
155
+ });
156
+ }
157
+ // SessionEnd hooks share a 1.5s budget by default (raisable only to 60s),
158
+ // cannot block Claude Code's own exit, and must be fire-and-forget --
159
+ // research doc §2. An unbounded await here risks the process being killed
160
+ // mid-request with no chance to exit cleanly or log why.
161
+ //
162
+ // 800ms is the network budget specifically, not the process's whole
163
+ // lifetime -- measured against a real build (dist/index.js hook, real
164
+ // fetch, a genuinely unresponsive local server), total process time from
165
+ // spawn to exit-0 on an abort was ~1.33s with a 1000ms network timeout,
166
+ // i.e. ~330ms of Node startup/stdin/fs overhead on this machine outside
167
+ // the network wait itself. 800ms leaves that overhead roughly 2x the
168
+ // margin (~370ms) under the *default* 1.5s budget rather than ~170ms, on
169
+ // the assumption other machines' startup cost varies and Claude Code's own
170
+ // pre-dispatch overhead, if any, is unknown and not ours to spend down.
171
+ // Stop is not bounded this way (600s budget, and can run "async": true),
172
+ // so its path through postDelta below does not use this.
173
+ const SESSION_END_TIMEOUT_MS = 800;
174
+ function errorMessage(err) {
175
+ return err instanceof Error ? err.message : String(err);
176
+ }
177
+ async function postHeartbeat(deps) {
178
+ const configPath = join(deps.configDir, CONFIG_FILE_NAME);
179
+ const config = readConfig(deps.configDir);
180
+ if (!config) {
181
+ console.error(`waku-memory hook: no usable config at ${configPath} -- skipping heartbeat.`);
182
+ return;
183
+ }
184
+ 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 });
193
+ if (!res.ok) {
194
+ console.error(`waku-memory hook: /ingest/heartbeat responded ${res.status}.`);
195
+ }
196
+ }
197
+ catch (err) {
198
+ console.error(`waku-memory hook: POST /ingest/heartbeat failed -- ${errorMessage(err)}.`);
199
+ }
200
+ }
201
+ async function postDelta(event, deps, source) {
202
+ if (!event.transcript_path) {
203
+ console.error(`waku-memory hook: no transcript_path on this ${event.hook_event_name} event -- skipping.`);
204
+ return;
205
+ }
206
+ const configPath = join(deps.configDir, CONFIG_FILE_NAME);
207
+ const config = readConfig(deps.configDir);
208
+ if (!config) {
209
+ console.error(`waku-memory hook: no usable config at ${configPath} -- skipping.`);
210
+ return;
211
+ }
212
+ // session_id arrives parsed from stdin and is used as a path segment
213
+ // below -- basename() strips any embedded separators (e.g. "../../x") so
214
+ // a malformed value cannot walk the watermark path outside
215
+ // configDir/state. The same-user trust boundary already limits the blast
216
+ // radius, but this removes the footgun rather than relying on that alone.
217
+ const watermarkPath = join(deps.configDir, 'state', basename(event.session_id));
218
+ const delta = readDelta(event.transcript_path, watermarkPath);
219
+ 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.`);
277
+ }
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));
282
+ }
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)}.`);
290
+ }
291
+ }
292
+ // Entry point cli.ts's run() calls after reading and JSON.parsing stdin --
293
+ // stdin reading deliberately does not live here (see the module comment and
294
+ // task 14's brief) so this function's whole surface is plain values a test
295
+ // can construct directly, no process.stdin involved.
296
+ //
297
+ // The one try/catch below is not "the error handling"; it is the backstop
298
+ // behind branches that already handle every failure mode the brief names
299
+ // (missing/malformed config, an empty delta, a non-2xx response, a rejected
300
+ // fetch) without throwing. Reaching this catch means something those
301
+ // branches did not anticipate happened -- possibly even `event` itself
302
+ // being null or some other non-object JSON.parse handed back for
303
+ // degenerate stdin (e.g. literal "null") -- and it still must not become an
304
+ // uncaught rejection in cli.ts's fire-and-forget call.
305
+ export async function handleHookEvent(event, deps) {
306
+ try {
307
+ if (event === null || typeof event !== 'object') {
308
+ console.error('waku-memory hook: received a malformed hook event on stdin -- skipping.');
309
+ return;
310
+ }
311
+ switch (event.hook_event_name) {
312
+ case 'SessionStart':
313
+ await postHeartbeat(deps);
314
+ return;
315
+ case 'Stop':
316
+ await postDelta(event, deps, 'stop');
317
+ return;
318
+ case 'SessionEnd':
319
+ await postDelta(event, deps, 'session-end');
320
+ return;
321
+ default:
322
+ console.error(`waku-memory hook: ignoring unhandled event "${String(event.hook_event_name)}".`);
323
+ return;
324
+ }
325
+ }
326
+ catch (err) {
327
+ console.error(`waku-memory hook: unexpected failure -- ${errorMessage(err)}.`);
328
+ }
329
+ }
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ // npx waku-memory setup -- writes the waku MCP server's URL into every
3
+ // harness config this shim knows how to edit safely, without disturbing
4
+ // anything already there (harnesses.ts:mergeMcpConfig).
5
+ //
6
+ // This obtains no credential and stores none. The harness itself hits the
7
+ // server, gets a 401 naming a protected-resource document, runs discovery,
8
+ // opens a browser, and holds the resulting token -- ops-design §5 and spec
9
+ // 003's real client both settled that a URL is all an install step needs to
10
+ // write. The import itself is not this shim's job either: the agent does
11
+ // that afterwards, through memory.import, because by then it already has
12
+ // both the file access and the token.
13
+ //
14
+ // The actual argument dispatch and per-harness logic lives in cli.ts,
15
+ // which has no top-level side effects on import -- cli.test.mjs imports it
16
+ // directly, without this file (or its shebang, or process.argv) coming
17
+ // along for the ride.
18
+ import { run } from "./cli.js";
19
+ run(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "waku-memory",
3
+ "version": "0.1.0",
4
+ "description": "Install Waku Memory into your agent harness, and turn on automatic session capture.",
5
+ "keywords": ["mcp", "memory", "claude-code", "agent", "waku"],
6
+ "homepage": "https://github.com/ShenSeanChen/waku-memory-backend/tree/spec-driven/shim#readme",
7
+ "bugs": "https://github.com/ShenSeanChen/waku-memory-backend/issues",
8
+ "license": "MIT",
9
+ "private": false,
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/ShenSeanChen/waku-memory-backend.git",
13
+ "directory": "shim"
14
+ },
15
+ "type": "module",
16
+ "bin": { "waku-memory": "./dist/index.js" },
17
+ "files": ["dist"],
18
+ "engines": { "node": ">=20" },
19
+ "publishConfig": { "access": "public" },
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "test": "node --test \"src/**/*.test.mjs\"",
23
+ "prepublishOnly": "npm run build && npm test"
24
+ },
25
+ "devDependencies": { "typescript": "^5.7.3", "@types/node": "^22.10.7" }
26
+ }