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/.codex-plugin/plugin.json +15 -0
- package/.mcp.json +9 -0
- package/README.md +55 -0
- package/dist/bootstrap.js +256 -64
- package/dist/capture.js +660 -112
- package/dist/cli.js +409 -65
- package/dist/codex-config.js +321 -0
- package/dist/dialogue-codex.js +63 -0
- package/dist/dialogue.js +2 -2
- package/dist/harnesses.js +57 -22
- package/dist/hook.js +103 -74
- package/dist/login.js +471 -0
- package/hooks/hooks.json +63 -0
- package/package.json +2 -2
- package/skills/waku/SKILL.md +23 -0
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
|
+
}
|
package/hooks/hooks.json
ADDED
|
@@ -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.
|
|
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.
|