waku-memory 0.3.0 → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waku",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Waku Memory: durable memory for your Codex sessions. memory.recall brings what is known about a project into a session; memory.remember keeps what matters; the plugin's hooks open each session with a brief and send each turn to be remembered.",
5
5
  "skills": "./skills/",
6
6
  "hooks": "./hooks/hooks.json",
package/dist/cli.js CHANGED
@@ -133,6 +133,10 @@ export function printUsage() {
133
133
  console.log(` --name key to add under mcpServers (default: "${DEFAULT_NAME}")`);
134
134
  console.log(` --url server URL to write (default: ${DEFAULT_URL})`);
135
135
  console.log('');
136
+ console.log(' If a harness was found, setup then asks whether to also turn on automatic');
137
+ console.log(' capture -- the same flow as "capture enable", asked so you do not have to');
138
+ console.log(' type a second command. Default is no; press Enter to skip it.');
139
+ console.log('');
136
140
  console.log('Usage: waku-memory hook [--harness <claude_code|codex>]');
137
141
  console.log('');
138
142
  console.log(' Reads one hook event as JSON on stdin and reports it to the server named');
@@ -464,13 +468,49 @@ export function summarize(results) {
464
468
  hadError: results.some((r) => r.status === 'error'),
465
469
  };
466
470
  }
467
- export function setup(name, url, harnesses = getHarnesses(),
471
+ // The one-shot readline prompt setup()'s own new question needs, real
472
+ // default for SetupPrompt below -- a plain rl.question(), not the
473
+ // async-iterator dance runCaptureCommand's own prompt uses for enable()'s
474
+ // two sequential questions (see that function's own comment): setup() only
475
+ // ever asks one question of its own before handing off to runCaptureEnable,
476
+ // which opens (and closes) its own readline in turn, so there is no shared
477
+ // interface for two overlapping .question() calls to race over.
478
+ function realSetupPrompt(question) {
479
+ return new Promise((resolve) => {
480
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
481
+ rl.question(question, (answer) => {
482
+ rl.close();
483
+ resolve(answer);
484
+ });
485
+ });
486
+ }
487
+ // Verbatim -- cli.test.mjs depends on this exact string, the same contract
488
+ // capture.ts's own SIGN_IN_QUESTION carries.
489
+ export const TURN_ON_CAPTURE_QUESTION = 'Also turn on automatic capture? It sends what you type and what the agent replies to Waku. [y/N]: ';
490
+ export async function setup(name, url, harnesses = getHarnesses(),
468
491
  // R20 (final review, spec 012): the real default, injected the same way
469
492
  // realCaptureDeps below builds it -- so cli.test.mjs can point this at a
470
493
  // temp path instead of the machine's actual ~/.codex/config.toml, the
471
494
  // same reason every other real path in this file is a parameter with a
472
495
  // real default rather than a bare homedir() call inline.
473
- codexConfigPath = join(homedir(), '.codex', 'config.toml')) {
496
+ codexConfigPath = join(homedir(), '.codex', 'config.toml'),
497
+ // The two additions that make setup() async (this task): the question
498
+ // itself (realSetupPrompt, above) and what a "y" answer actually runs.
499
+ // Both injectable, the same reason every real path/writer/fetch in this
500
+ // file is a parameter with a real default rather than a bare call inline
501
+ // -- so cli.test.mjs never opens real stdin and never runs a real
502
+ // capture enable. The real runCaptureEnable below is nothing but the
503
+ // existing "capture enable" command path (runCaptureCommand, same as
504
+ // dispatch()'s own 'capture'/'enable' case), called with no flags -- the
505
+ // Sign-in question, enable()'s own disclosure and typed "y" gate, the
506
+ // credential step, the hooks, and the bootstrap question all run exactly
507
+ // as they do from the command line. Nothing here may skip, weaken, or
508
+ // pre-answer any of that: the only thing this buys the person is not
509
+ // having to type "npx waku-memory capture enable" a second time.
510
+ prompt = realSetupPrompt, runCaptureEnable = () => {
511
+ const { ingestUrl, mcpUrl } = urlPair(url);
512
+ return runCaptureCommand('enable', ingestUrl, mcpUrl, DEFAULT_BOOTSTRAP, { kind: 'ask' });
513
+ }) {
474
514
  const results = runSetup(harnesses, name, url);
475
515
  for (const r of results) {
476
516
  if (r.status === 'error')
@@ -523,6 +563,20 @@ codexConfigPath = join(homedir(), '.codex', 'config.toml')) {
523
563
  }
524
564
  if (hadError)
525
565
  process.exitCode = 1;
566
+ // The new question this task adds: asked only once there is something to
567
+ // turn capture on for -- a machine with no harness found has nothing for
568
+ // "capture enable" to install into either, so asking would just repeat
569
+ // the "nothing was added" message above in question form. Default is no
570
+ // (an empty answer, from pressing Enter, keeps today's behaviour exactly)
571
+ // -- capture is a much bigger step than writing an MCP config, so unlike
572
+ // enable()'s own SIGN_IN_QUESTION (whose default path still requires
573
+ // choosing 1 or 2), this one must never be opted into by accident.
574
+ if (foundAny) {
575
+ const answer = (await prompt(TURN_ON_CAPTURE_QUESTION)).trim().toLowerCase();
576
+ if (answer === 'y') {
577
+ await runCaptureEnable();
578
+ }
579
+ }
526
580
  }
527
581
  function errorMessage(err) {
528
582
  return err instanceof Error ? err.message : String(err);
@@ -822,7 +876,10 @@ export function run(argv) {
822
876
  process.exitCode = 1;
823
877
  return;
824
878
  case 'setup':
825
- setup(d.name, d.url);
879
+ void setup(d.name, d.url).catch((err) => {
880
+ console.error(`waku-memory setup: unexpected failure -- ${errorMessage(err)}.`);
881
+ process.exitCode = 1;
882
+ });
826
883
  return;
827
884
  case 'login':
828
885
  // Ruling R15: the disclosure (and a blank line), no y/n gate -- unlike
package/dist/hook.js CHANGED
@@ -63,7 +63,7 @@ export { SESSION_END_HOOK_TIMEOUT_S, SESSION_END_TIMEOUT_MS } from "./harnesses.
63
63
  // hook.test.mjs's drift check (against package.json) both need it; task 14
64
64
  // (spec 012 §9) bumps this, package.json and the plugin manifest
65
65
  // (.codex-plugin/plugin.json) to 0.3.0 together.
66
- export const SHIM_VERSION = '0.3.0';
66
+ export const SHIM_VERSION = '0.4.0';
67
67
  // Exported for capture.ts, which writes the file this module reads.
68
68
  export const CONFIG_FILE_NAME = 'config.json';
69
69
  // Same-directory temp file + rename, exactly atomicWriteJson's technique in
package/dist/login.js CHANGED
@@ -33,7 +33,122 @@ export const LOGIN_SCOPES = 'openid profile email offline_access';
33
33
  // default apiBase, so a reader (or a test asserting against the default)
34
34
  // has something to compare the runtime value to without recomputing it.
35
35
  export const LOGIN_RESOURCE = 'https://api.waku.one/mcp';
36
- const REGISTERED_REDIRECT_URI = 'http://127.0.0.1/callback';
36
+ const CALLBACK_PAGE_COPY = {
37
+ // The real success. Only this one gets the ok/accent treatment -- the
38
+ // system's own message vocabulary (§14 of the design doc) marks a
39
+ // level with an uppercase mono word before the sentence, not with a
40
+ // filled surface or an icon.
41
+ 'signed-in': {
42
+ title: 'Signed in',
43
+ mark: 'OK',
44
+ markColor: 'var(--ok)',
45
+ heading: 'Signed in.',
46
+ body: 'This tab can be closed now.',
47
+ },
48
+ // The duplicate callback (`accepted` already true): the flow already
49
+ // finished, so this is not an error and carries no color.
50
+ 'already-signed-in': {
51
+ title: 'Already signed in',
52
+ mark: '·',
53
+ markColor: 'var(--text-faint)',
54
+ heading: 'Already signed in.',
55
+ body: 'Nothing else to do here.',
56
+ },
57
+ // The `state` mismatch or missing `code` (400). Neither an ok nor a
58
+ // plain neutral: a mismatched state can mean the link was tampered
59
+ // with, so this reads as the doc's `warn` level -- worth noticing,
60
+ // not a failure to panic over -- and says the one thing that is a
61
+ // different action from reloading this tab: go back to the terminal.
62
+ 'could-not-complete': {
63
+ title: 'Sign-in could not complete',
64
+ mark: 'WARN',
65
+ markColor: 'var(--warn)',
66
+ heading: 'Sign-in did not complete.',
67
+ body: 'The command is still running. Try again from the terminal.',
68
+ },
69
+ // Any path but /callback (404). Nothing to explain beyond the one fact.
70
+ 'not-found': {
71
+ title: 'Not found',
72
+ mark: '·',
73
+ markColor: 'var(--text-faint)',
74
+ heading: 'Not found.',
75
+ body: 'This address is not part of sign-in.',
76
+ },
77
+ };
78
+ function renderCallbackPage(state) {
79
+ const copy = CALLBACK_PAGE_COPY[state];
80
+ return `<!doctype html>
81
+ <html lang="en">
82
+ <head>
83
+ <meta charset="utf-8">
84
+ <meta name="viewport" content="width=device-width, initial-scale=1">
85
+ <title>${copy.title}</title>
86
+ <style>
87
+ :root {
88
+ --ground: #E1E1D9;
89
+ --ink: #161614;
90
+ --accent-ink: #161614;
91
+ --ok: #2B7754;
92
+ --warn: #8C5617;
93
+ --text-muted: color-mix(in srgb, var(--ink) 82%, var(--ground));
94
+ --text-faint: color-mix(in srgb, var(--ink) 62%, var(--ground));
95
+ }
96
+ @media (prefers-color-scheme: dark) {
97
+ :root {
98
+ --ground: #202020;
99
+ --ink: #C9CDD1;
100
+ --ok: #4FBF8B;
101
+ --warn: #E78B23;
102
+ }
103
+ }
104
+ * { box-sizing: border-box; }
105
+ html, body { height: 100%; margin: 0; }
106
+ body {
107
+ display: flex;
108
+ align-items: center;
109
+ justify-content: center;
110
+ padding: 24px;
111
+ background: var(--ground);
112
+ color: var(--ink);
113
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
114
+ font-weight: 400;
115
+ text-align: center;
116
+ }
117
+ main { max-width: 40ch; }
118
+ .mark {
119
+ display: block;
120
+ margin: 0 0 12px;
121
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
122
+ font-weight: 500;
123
+ font-size: 12px;
124
+ letter-spacing: .16em;
125
+ text-transform: uppercase;
126
+ color: ${copy.markColor};
127
+ }
128
+ h1 {
129
+ margin: 0 0 12px;
130
+ font-size: 20px;
131
+ font-weight: 500;
132
+ line-height: 1.3;
133
+ }
134
+ p {
135
+ margin: 0;
136
+ font-size: 15px;
137
+ line-height: 1.5;
138
+ color: var(--text-muted);
139
+ }
140
+ </style>
141
+ </head>
142
+ <body>
143
+ <main>
144
+ <span class="mark">${copy.mark}</span>
145
+ <h1>${copy.heading}</h1>
146
+ <p>${copy.body}</p>
147
+ </main>
148
+ </body>
149
+ </html>
150
+ `;
151
+ }
37
152
  // Carries which of the four network steps failed and the HTTP status that
38
153
  // said so -- login()'s refusal branches read both off this and print
39
154
  // neither a response body nor anything from the request (never the token,
@@ -83,18 +198,25 @@ export async function discover(apiBase, fetchImpl) {
83
198
  registrationEndpoint: registration_endpoint,
84
199
  };
85
200
  }
86
- // Dynamic client registration (RFC 7591): a public client, redirect fixed
87
- // at the bare loopback address (RFC 8252 §7.3 -- the *port* login actually
88
- // redirects to is chosen per run and never registered, which is the whole
89
- // point of the loopback exception). Returns client_id; throws
90
- // StepError('registration', status) otherwise.
91
- export async function registerClient(registrationEndpoint, fetchImpl, version) {
201
+ // Dynamic client registration (RFC 7591): a public client, registered with
202
+ // the exact redirect URI login() will use for the authorize request and the
203
+ // token exchange -- including the ephemeral port the loopback listener is
204
+ // actually on. RFC 8252 §7.3 lets a client register the bare loopback
205
+ // address and redirect to any port at request time, but our authorization
206
+ // server (Supabase) exact-matches redirect_uri against what was registered
207
+ // and does not implement that exception (measured against the deployed
208
+ // service, 2026-09-08: registering the bare address and then authorizing
209
+ // against the real port got back `{"error_code":"validation_failed","msg":
210
+ // "invalid redirect_uri"}`). So the caller must start the listener first and
211
+ // pass its real redirect URI in here -- see login()'s own ordering. Returns
212
+ // client_id; throws StepError('registration', status) otherwise.
213
+ export async function registerClient(registrationEndpoint, fetchImpl, version, redirectUri) {
92
214
  const res = await fetchImpl(registrationEndpoint, {
93
215
  method: 'POST',
94
216
  headers: { 'Content-Type': 'application/json' },
95
217
  body: JSON.stringify({
96
218
  client_name: `waku-memory ${version}`,
97
- redirect_uris: [REGISTERED_REDIRECT_URI],
219
+ redirect_uris: [redirectUri],
98
220
  grant_types: ['authorization_code'],
99
221
  response_types: ['code'],
100
222
  token_endpoint_auth_method: 'none',
@@ -187,10 +309,20 @@ function reportRefusal(deps, fallbackStep, err) {
187
309
  // only things written to deps.stdout are the authorize URL, the final
188
310
  // "Signed in as <email>" line, and (on refusal) the one-line step/status
189
311
  // report above. The loopback server, once opened, is closed on every path
190
- // out of the returned promise: 'signed-in', 'timeout', and 'refused' from
191
- // a bad token or keys response all go through the same finish().
192
- // 'refused' from discovery or registration returns before any server
193
- // exists, so there is nothing to close on those two paths.
312
+ // out of the returned promise: 'signed-in', 'timeout', and 'refused' from a
313
+ // listener failure, a bad registration, or a bad token/keys response all go
314
+ // through the same finish(). 'refused' from discovery returns before any
315
+ // server exists, so there is nothing to close on that one path.
316
+ //
317
+ // Ordering (fixed 2026-09-08, measured against the deployed service): the
318
+ // listener starts FIRST, before registration, so registerClient can be
319
+ // given the real `http://127.0.0.1:<port>/callback` -- the same string then
320
+ // used, unchanged, for the authorize URL and the token exchange. Registering
321
+ // the bare loopback address ahead of knowing the port (the previous order)
322
+ // is what RFC 8252 §7.3 expects a server to accept via its any-port
323
+ // exception, but Supabase exact-matches redirect_uri against the registered
324
+ // list and answered with `invalid redirect_uri`: see registerClient's own
325
+ // comment for the exact response.
194
326
  export async function login(deps) {
195
327
  const apiBase = stripSlash(deps.apiBase);
196
328
  const resource = `${apiBase}/mcp`;
@@ -203,25 +335,18 @@ export async function login(deps) {
203
335
  reportRefusal(deps, 'discovery', err);
204
336
  return 'refused';
205
337
  }
206
- let clientId;
207
- try {
208
- clientId = await registerClient(discovered.registrationEndpoint, deps.fetchImpl, deps.version);
209
- }
210
- catch (err) {
211
- reportRefusal(deps, 'registration', err);
212
- return 'refused';
213
- }
214
338
  const { verifier, challenge } = pkce();
215
339
  const state = randomBytes(16).toString('hex');
216
340
  return new Promise((resolveLogin) => {
217
341
  let settled = false;
218
- let port = 0;
219
342
  let accepted = false;
343
+ let clientId = '';
344
+ let redirectUri = '';
220
345
  const server = createServer((req, res) => {
221
346
  const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
222
347
  if (requestUrl.pathname !== '/callback') {
223
- res.writeHead(404);
224
- res.end();
348
+ res.writeHead(404, { 'Content-Type': 'text/html' });
349
+ res.end(renderCallbackPage('not-found'));
225
350
  return;
226
351
  }
227
352
  const code = requestUrl.searchParams.get('code');
@@ -230,8 +355,8 @@ export async function login(deps) {
230
355
  // Wrong or missing state: keep waiting -- a stray or forged hit on
231
356
  // this port must not end the flow the real browser is still
232
357
  // carrying.
233
- res.writeHead(400);
234
- res.end();
358
+ res.writeHead(400, { 'Content-Type': 'text/html' });
359
+ res.end(renderCallbackPage('could-not-complete'));
235
360
  return;
236
361
  }
237
362
  // OAuth codes are single-use and minting a credential should be
@@ -241,12 +366,12 @@ export async function login(deps) {
241
366
  // sees the flag and returns 200 without re-running the exchange.
242
367
  if (accepted) {
243
368
  res.writeHead(200, { 'Content-Type': 'text/html' });
244
- res.end('Signed in. You can close this tab.');
369
+ res.end(renderCallbackPage('already-signed-in'));
245
370
  return;
246
371
  }
247
372
  accepted = true;
248
373
  res.writeHead(200, { 'Content-Type': 'text/html' });
249
- res.end('Signed in. You can close this tab.');
374
+ res.end(renderCallbackPage('signed-in'));
250
375
  void finishSignIn(code);
251
376
  });
252
377
  const finish = (result) => {
@@ -254,12 +379,25 @@ export async function login(deps) {
254
379
  return;
255
380
  settled = true;
256
381
  clearTimeout(timer);
382
+ // server.close() alone only stops accepting *new* connections; it
383
+ // waits for existing ones to end on their own before the server (and
384
+ // its socket handles) actually goes away. A real browser holds the
385
+ // callback connection open with HTTP keep-alive -- it never ends the
386
+ // connection itself -- so that lone open socket kept the event loop
387
+ // alive and the process never exited (measured 2026-09-08 against
388
+ // the deployed service: a real sign-in completed, config.json was
389
+ // written, and the process was still running 92s later). Dropping
390
+ // every outstanding connection here, active or idle, is what lets
391
+ // the server -- and the process -- actually finish closing, on every
392
+ // path through finish(): signed-in, timeout, and refused alike.
393
+ // closeAllConnections() has been available since Node 18.2; this
394
+ // package requires Node >=20 (see package.json's engines).
257
395
  server.close();
396
+ server.closeAllConnections();
258
397
  resolveLogin(result);
259
398
  };
260
399
  async function finishSignIn(code) {
261
400
  try {
262
- const redirectUri = `http://127.0.0.1:${port}/callback`;
263
401
  const tokenResult = await exchangeToken(discovered.tokenEndpoint, deps.fetchImpl, {
264
402
  code,
265
403
  redirectUri,
@@ -293,20 +431,41 @@ export async function login(deps) {
293
431
  }
294
432
  }
295
433
  const timer = setTimeout(() => finish('timeout'), timeoutMs);
434
+ // A listener that never comes up (EADDRINUSE, permissions, ...) fires
435
+ // 'error' instead of the 'listening' callback below -- reported the same
436
+ // way every other step is, naming 'listener' rather than falling through
437
+ // to a confusing StepError from somewhere else.
438
+ server.on('error', (err) => {
439
+ reportRefusal(deps, 'listener', err);
440
+ finish('refused');
441
+ });
296
442
  server.listen(0, '127.0.0.1', () => {
297
443
  const address = server.address();
298
- port = typeof address === 'object' && address !== null ? address.port : 0;
299
- const redirectUri = `http://127.0.0.1:${port}/callback`;
300
- const url = authorizeUrl({
301
- authorizationEndpoint: discovered.authorizationEndpoint,
302
- clientId,
303
- redirectUri,
304
- state,
305
- challenge,
306
- resource,
444
+ const port = typeof address === 'object' && address !== null ? address.port : 0;
445
+ if (port === 0) {
446
+ reportRefusal(deps, 'listener', new Error('the loopback listener did not report a port'));
447
+ finish('refused');
448
+ return;
449
+ }
450
+ redirectUri = `http://127.0.0.1:${port}/callback`;
451
+ registerClient(discovered.registrationEndpoint, deps.fetchImpl, deps.version, redirectUri)
452
+ .then((id) => {
453
+ clientId = id;
454
+ const url = authorizeUrl({
455
+ authorizationEndpoint: discovered.authorizationEndpoint,
456
+ clientId,
457
+ redirectUri,
458
+ state,
459
+ challenge,
460
+ resource,
461
+ });
462
+ deps.stdout(`Open this URL if your browser did not: ${url}`);
463
+ deps.openBrowser(url);
464
+ })
465
+ .catch((err) => {
466
+ reportRefusal(deps, 'registration', err);
467
+ finish('refused');
307
468
  });
308
- deps.stdout(`Open this URL if your browser did not: ${url}`);
309
- deps.openBrowser(url);
310
469
  });
311
470
  });
312
471
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waku-memory",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Install Waku Memory into your agent harness, and turn on automatic session capture.",
5
5
  "keywords": ["mcp", "memory", "claude-code", "agent", "waku"],
6
6
  "homepage": "https://github.com/ShenSeanChen/waku-memory-backend/tree/spec-driven/shim#readme",