waku-memory 0.2.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.
package/dist/login.js ADDED
@@ -0,0 +1,471 @@
1
+ // `login` -- the browser OAuth flow that mints an API key without the
2
+ // person ever seeing one (spec 012 §7). Wired by cli.ts's `login` command
3
+ // and, since task 13, `capture enable`'s third path (a person who has no
4
+ // key yet is offered this instead of the paste prompt); this file only
5
+ // orchestrates the flow itself against an injected deps bag, the same
6
+ // split hook.ts/capture.ts already established, so login.test.mjs can
7
+ // drive it against a fake authorization server and a fake API on
8
+ // 127.0.0.1 and never touch a real browser or a real waku.one.
9
+ //
10
+ // The shape, in order: discover the authorization server from the API's
11
+ // protected-resource metadata, dynamically register a public client,
12
+ // listen on a loopback port, send the person to the authorization
13
+ // endpoint (PKCE S256, a random state, printed and opened), wait for the
14
+ // redirect back, exchange the code for a token, mint a key with it, and
15
+ // write that key exactly where a pasted one goes (writeConfigJson,
16
+ // sanitizeKey -- both capture.ts's, not reimplemented here).
17
+ //
18
+ // Zero new runtime dependency, per the shim's package.json: node:http,
19
+ // node:crypto, node:os and capture.ts (itself dependency-free) only.
20
+ import { createServer } from 'node:http';
21
+ import { randomBytes, createHash } from 'node:crypto';
22
+ import { hostname } from 'node:os';
23
+ import { sanitizeKey, writeConfigJson } from "./capture.js";
24
+ // §7: five minutes to complete a browser round trip is generous without
25
+ // being indefinite -- a person who wanders off leaves nothing running past
26
+ // that, and the loopback server closes either way (see login()'s finish).
27
+ export const LOGIN_TIMEOUT_MS = 300_000;
28
+ export const LOGIN_SCOPES = 'openid profile email offline_access';
29
+ // The `resource` parameter (RFC 8707) login sends in the authorize URL and
30
+ // the token request is always derived at runtime from deps.apiBase --
31
+ // `${apiBase}/mcp` with a trailing slash stripped first -- never this
32
+ // constant. LOGIN_RESOURCE exists only as the documented value for the
33
+ // default apiBase, so a reader (or a test asserting against the default)
34
+ // has something to compare the runtime value to without recomputing it.
35
+ export const LOGIN_RESOURCE = 'https://api.waku.one/mcp';
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
+ }
152
+ // Carries which of the four network steps failed and the HTTP status that
153
+ // said so -- login()'s refusal branches read both off this and print
154
+ // neither a response body nor anything from the request (never the token,
155
+ // never the key): "Sign-in failed at <step> (HTTP <status>)." is the whole
156
+ // line.
157
+ class StepError extends Error {
158
+ step;
159
+ status;
160
+ constructor(step, status) {
161
+ super(`${step} step failed with HTTP ${status}`);
162
+ this.step = step;
163
+ this.status = status;
164
+ }
165
+ }
166
+ function stripSlash(url) {
167
+ return url.replace(/\/+$/, '');
168
+ }
169
+ // GET <apiBase>/.well-known/oauth-protected-resource for the resource's
170
+ // authorization server, then that server's own
171
+ // /.well-known/oauth-authorization-server for the three endpoints login
172
+ // needs. Throws StepError('discovery', status) on anything short of a
173
+ // clean pair of 2xx JSON responses carrying the fields this reads --
174
+ // login() is the only caller and turns that into the one printed line.
175
+ export async function discover(apiBase, fetchImpl) {
176
+ const base = stripSlash(apiBase);
177
+ const resourceRes = await fetchImpl(`${base}/.well-known/oauth-protected-resource`);
178
+ if (!resourceRes.ok)
179
+ throw new StepError('discovery', resourceRes.status);
180
+ const resourceBody = (await resourceRes.json());
181
+ const issuer = Array.isArray(resourceBody.authorization_servers) ? resourceBody.authorization_servers[0] : undefined;
182
+ if (typeof issuer !== 'string' || issuer === '')
183
+ throw new StepError('discovery', resourceRes.status);
184
+ const asRes = await fetchImpl(`${stripSlash(issuer)}/.well-known/oauth-authorization-server`);
185
+ if (!asRes.ok)
186
+ throw new StepError('discovery', asRes.status);
187
+ const asBody = (await asRes.json());
188
+ const { authorization_endpoint, token_endpoint, registration_endpoint } = asBody;
189
+ if (typeof authorization_endpoint !== 'string' ||
190
+ typeof token_endpoint !== 'string' ||
191
+ typeof registration_endpoint !== 'string') {
192
+ throw new StepError('discovery', asRes.status);
193
+ }
194
+ return {
195
+ issuer,
196
+ authorizationEndpoint: authorization_endpoint,
197
+ tokenEndpoint: token_endpoint,
198
+ registrationEndpoint: registration_endpoint,
199
+ };
200
+ }
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) {
214
+ const res = await fetchImpl(registrationEndpoint, {
215
+ method: 'POST',
216
+ headers: { 'Content-Type': 'application/json' },
217
+ body: JSON.stringify({
218
+ client_name: `waku-memory ${version}`,
219
+ redirect_uris: [redirectUri],
220
+ grant_types: ['authorization_code'],
221
+ response_types: ['code'],
222
+ token_endpoint_auth_method: 'none',
223
+ }),
224
+ });
225
+ if (!res.ok)
226
+ throw new StepError('registration', res.status);
227
+ const body = (await res.json());
228
+ if (typeof body.client_id !== 'string' || body.client_id === '')
229
+ throw new StepError('registration', res.status);
230
+ return body.client_id;
231
+ }
232
+ // PKCE (RFC 7636), S256 only: a 32-byte random verifier, base64url; the
233
+ // challenge is its SHA-256 digest, also base64url. Buffer's own
234
+ // 'base64url' encoding already omits padding, so neither value needs the
235
+ // usual base64 cleanup.
236
+ export function pkce() {
237
+ const verifier = randomBytes(32).toString('base64url');
238
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
239
+ return { verifier, challenge };
240
+ }
241
+ export function authorizeUrl(p) {
242
+ const url = new URL(p.authorizationEndpoint);
243
+ url.searchParams.set('response_type', 'code');
244
+ url.searchParams.set('client_id', p.clientId);
245
+ url.searchParams.set('redirect_uri', p.redirectUri);
246
+ url.searchParams.set('state', p.state);
247
+ url.searchParams.set('code_challenge', p.challenge);
248
+ url.searchParams.set('code_challenge_method', 'S256');
249
+ url.searchParams.set('scope', LOGIN_SCOPES);
250
+ url.searchParams.set('resource', p.resource);
251
+ return url.toString();
252
+ }
253
+ async function exchangeToken(tokenEndpoint, fetchImpl, p) {
254
+ const body = new URLSearchParams({
255
+ grant_type: 'authorization_code',
256
+ code: p.code,
257
+ redirect_uri: p.redirectUri,
258
+ client_id: p.clientId,
259
+ code_verifier: p.verifier,
260
+ resource: p.resource,
261
+ });
262
+ const res = await fetchImpl(tokenEndpoint, {
263
+ method: 'POST',
264
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
265
+ body: body.toString(),
266
+ });
267
+ if (!res.ok)
268
+ throw new StepError('token', res.status);
269
+ const json = (await res.json());
270
+ if (typeof json.access_token !== 'string' || json.access_token === '')
271
+ throw new StepError('token', res.status);
272
+ return { accessToken: json.access_token, idToken: typeof json.id_token === 'string' ? json.id_token : undefined };
273
+ }
274
+ // The email printed in the final "Signed in as <email>" line -- read from
275
+ // the token response's id_token, an unsigned decode of its middle segment
276
+ // (base64url JSON), never signature-checked: it arrived over TLS from the
277
+ // issuer discover() itself found, and the only use is one word in a
278
+ // stdout line, not an authorization decision. Anything short of a clean
279
+ // {email: string} there (no id_token, unparseable, wrong shape) falls back
280
+ // to the literal word "you" -- login() never blocks on this.
281
+ function emailFromIdToken(idToken) {
282
+ if (!idToken)
283
+ return null;
284
+ const parts = idToken.split('.');
285
+ if (parts.length < 2)
286
+ return null;
287
+ try {
288
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
289
+ return typeof payload.email === 'string' && payload.email !== '' ? payload.email : null;
290
+ }
291
+ catch {
292
+ return null;
293
+ }
294
+ }
295
+ // keys.py's CreateKeyRequest.label: free text, default "unnamed", capped
296
+ // at 200 chars server-side -- truncated here too so a very long hostname
297
+ // never trips the server's own validation into a refusal this file would
298
+ // then have to explain.
299
+ function keyLabel() {
300
+ const label = `waku-memory on ${hostname()}`;
301
+ return label.length > 200 ? label.slice(0, 200) : label;
302
+ }
303
+ function reportRefusal(deps, fallbackStep, err) {
304
+ const step = err instanceof StepError ? err.step : fallbackStep;
305
+ const status = err instanceof StepError ? err.status : 0;
306
+ deps.stdout(`Sign-in failed at ${step} (HTTP ${status}).`);
307
+ }
308
+ // The whole flow. Never prints the token or the key on any path -- the
309
+ // only things written to deps.stdout are the authorize URL, the final
310
+ // "Signed in as <email>" line, and (on refusal) the one-line step/status
311
+ // report above. The loopback server, once opened, is closed on every path
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.
326
+ export async function login(deps) {
327
+ const apiBase = stripSlash(deps.apiBase);
328
+ const resource = `${apiBase}/mcp`;
329
+ const timeoutMs = deps.timeoutMs ?? LOGIN_TIMEOUT_MS;
330
+ let discovered;
331
+ try {
332
+ discovered = await discover(apiBase, deps.fetchImpl);
333
+ }
334
+ catch (err) {
335
+ reportRefusal(deps, 'discovery', err);
336
+ return 'refused';
337
+ }
338
+ const { verifier, challenge } = pkce();
339
+ const state = randomBytes(16).toString('hex');
340
+ return new Promise((resolveLogin) => {
341
+ let settled = false;
342
+ let accepted = false;
343
+ let clientId = '';
344
+ let redirectUri = '';
345
+ const server = createServer((req, res) => {
346
+ const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
347
+ if (requestUrl.pathname !== '/callback') {
348
+ res.writeHead(404, { 'Content-Type': 'text/html' });
349
+ res.end(renderCallbackPage('not-found'));
350
+ return;
351
+ }
352
+ const code = requestUrl.searchParams.get('code');
353
+ const receivedState = requestUrl.searchParams.get('state');
354
+ if (receivedState !== state || !code) {
355
+ // Wrong or missing state: keep waiting -- a stray or forged hit on
356
+ // this port must not end the flow the real browser is still
357
+ // carrying.
358
+ res.writeHead(400, { 'Content-Type': 'text/html' });
359
+ res.end(renderCallbackPage('could-not-complete'));
360
+ return;
361
+ }
362
+ // OAuth codes are single-use and minting a credential should be
363
+ // idempotent only by accident; the client must not rely on the
364
+ // authorization server to defend against replay. Guard here: flag
365
+ // the code accepted before any async work, so a concurrent request
366
+ // sees the flag and returns 200 without re-running the exchange.
367
+ if (accepted) {
368
+ res.writeHead(200, { 'Content-Type': 'text/html' });
369
+ res.end(renderCallbackPage('already-signed-in'));
370
+ return;
371
+ }
372
+ accepted = true;
373
+ res.writeHead(200, { 'Content-Type': 'text/html' });
374
+ res.end(renderCallbackPage('signed-in'));
375
+ void finishSignIn(code);
376
+ });
377
+ const finish = (result) => {
378
+ if (settled)
379
+ return;
380
+ settled = true;
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).
395
+ server.close();
396
+ server.closeAllConnections();
397
+ resolveLogin(result);
398
+ };
399
+ async function finishSignIn(code) {
400
+ try {
401
+ const tokenResult = await exchangeToken(discovered.tokenEndpoint, deps.fetchImpl, {
402
+ code,
403
+ redirectUri,
404
+ clientId,
405
+ verifier,
406
+ resource,
407
+ });
408
+ const keysRes = await deps.fetchImpl(`${apiBase}/keys`, {
409
+ method: 'POST',
410
+ headers: {
411
+ 'Content-Type': 'application/json',
412
+ Authorization: `Bearer ${tokenResult.accessToken}`,
413
+ },
414
+ body: JSON.stringify({ label: keyLabel() }),
415
+ });
416
+ if (!keysRes.ok)
417
+ throw new StepError('keys', keysRes.status);
418
+ const keysBody = (await keysRes.json());
419
+ if (typeof keysBody.plaintext !== 'string' || keysBody.plaintext === '') {
420
+ throw new StepError('keys', keysRes.status);
421
+ }
422
+ const { key } = sanitizeKey(keysBody.plaintext);
423
+ writeConfigJson(deps.configDir, { url: apiBase, key });
424
+ const email = emailFromIdToken(tokenResult.idToken) ?? 'you';
425
+ deps.stdout(`Signed in as ${email}. Key stored in ${deps.configDir}/config.json.`);
426
+ finish('signed-in');
427
+ }
428
+ catch (err) {
429
+ reportRefusal(deps, 'token', err);
430
+ finish('refused');
431
+ }
432
+ }
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
+ });
442
+ server.listen(0, '127.0.0.1', () => {
443
+ const address = server.address();
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');
468
+ });
469
+ });
470
+ });
471
+ }
@@ -0,0 +1,63 @@
1
+ {
2
+ "description": "Waku Memory: the session brief and turn capture through the waku MCP server.",
3
+ "hooks": {
4
+ "SessionStart": [
5
+ {
6
+ "matcher": "startup|resume|clear|compact",
7
+ "hooks": [
8
+ {
9
+ "type": "mcp_tool",
10
+ "server": "waku",
11
+ "tool": "session.hook",
12
+ "timeout": 10,
13
+ "statusMessage": "Waku memory",
14
+ "input": {
15
+ "event": "${hook_event_name}",
16
+ "session_id": "${session_id}",
17
+ "cwd": "${cwd}",
18
+ "source": "${source}"
19
+ }
20
+ }
21
+ ]
22
+ }
23
+ ],
24
+ "UserPromptSubmit": [
25
+ {
26
+ "hooks": [
27
+ {
28
+ "type": "mcp_tool",
29
+ "server": "waku",
30
+ "tool": "session.hook",
31
+ "timeout": 10,
32
+ "input": {
33
+ "event": "${hook_event_name}",
34
+ "session_id": "${session_id}",
35
+ "cwd": "${cwd}",
36
+ "turn_id": "${turn_id}",
37
+ "prompt": "${prompt}"
38
+ }
39
+ }
40
+ ]
41
+ }
42
+ ],
43
+ "Stop": [
44
+ {
45
+ "hooks": [
46
+ {
47
+ "type": "mcp_tool",
48
+ "server": "waku",
49
+ "tool": "session.hook",
50
+ "timeout": 10,
51
+ "input": {
52
+ "event": "${hook_event_name}",
53
+ "session_id": "${session_id}",
54
+ "cwd": "${cwd}",
55
+ "turn_id": "${turn_id}",
56
+ "last_assistant_message": "${last_assistant_message}"
57
+ }
58
+ }
59
+ ]
60
+ }
61
+ ]
62
+ }
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waku-memory",
3
- "version": "0.2.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",
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "type": "module",
16
16
  "bin": { "waku-memory": "./dist/index.js" },
17
- "files": ["dist"],
17
+ "files": ["dist", ".codex-plugin", ".mcp.json", "hooks", "skills"],
18
18
  "engines": { "node": ">=20" },
19
19
  "publishConfig": { "access": "public" },
20
20
  "scripts": {
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: waku
3
+ description: Durable memory for this person across sessions. Use memory.recall for what is known about a project and memory.remember for something worth keeping.
4
+ ---
5
+
6
+ Waku Memory keeps what a person tells their agents and what their agents
7
+ learn, and brings it back into later sessions.
8
+
9
+ - At the start of a session the plugin's hooks add a short brief of the
10
+ project's memories to your context. It arrives on the first or second
11
+ prompt; nothing needs to be called for it.
12
+ - Call `memory.recall` with `scope: "project:<name>"` when the person asks
13
+ what is known, or when a task depends on a preference or decision you do
14
+ not have. Call `memory.search` for one specific thing.
15
+ - Call `memory.remember` when the person states a preference, a decision,
16
+ a procedure or a fact they will want next time. Use their words. Do not
17
+ store secrets, credentials or file contents.
18
+ - What the person types and what you reply is sent to Waku after each turn
19
+ by the plugin's hooks; tool output is not.
20
+
21
+ One-time setup the person does: after installing, Codex asks them to
22
+ review and trust this plugin's hooks (`/hooks` in the CLI). Until then
23
+ the brief and the capture do not run.