waku-memory 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/login.js ADDED
@@ -0,0 +1,312 @@
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 REGISTERED_REDIRECT_URI = 'http://127.0.0.1/callback';
37
+ // Carries which of the four network steps failed and the HTTP status that
38
+ // said so -- login()'s refusal branches read both off this and print
39
+ // neither a response body nor anything from the request (never the token,
40
+ // never the key): "Sign-in failed at <step> (HTTP <status>)." is the whole
41
+ // line.
42
+ class StepError extends Error {
43
+ step;
44
+ status;
45
+ constructor(step, status) {
46
+ super(`${step} step failed with HTTP ${status}`);
47
+ this.step = step;
48
+ this.status = status;
49
+ }
50
+ }
51
+ function stripSlash(url) {
52
+ return url.replace(/\/+$/, '');
53
+ }
54
+ // GET <apiBase>/.well-known/oauth-protected-resource for the resource's
55
+ // authorization server, then that server's own
56
+ // /.well-known/oauth-authorization-server for the three endpoints login
57
+ // needs. Throws StepError('discovery', status) on anything short of a
58
+ // clean pair of 2xx JSON responses carrying the fields this reads --
59
+ // login() is the only caller and turns that into the one printed line.
60
+ export async function discover(apiBase, fetchImpl) {
61
+ const base = stripSlash(apiBase);
62
+ const resourceRes = await fetchImpl(`${base}/.well-known/oauth-protected-resource`);
63
+ if (!resourceRes.ok)
64
+ throw new StepError('discovery', resourceRes.status);
65
+ const resourceBody = (await resourceRes.json());
66
+ const issuer = Array.isArray(resourceBody.authorization_servers) ? resourceBody.authorization_servers[0] : undefined;
67
+ if (typeof issuer !== 'string' || issuer === '')
68
+ throw new StepError('discovery', resourceRes.status);
69
+ const asRes = await fetchImpl(`${stripSlash(issuer)}/.well-known/oauth-authorization-server`);
70
+ if (!asRes.ok)
71
+ throw new StepError('discovery', asRes.status);
72
+ const asBody = (await asRes.json());
73
+ const { authorization_endpoint, token_endpoint, registration_endpoint } = asBody;
74
+ if (typeof authorization_endpoint !== 'string' ||
75
+ typeof token_endpoint !== 'string' ||
76
+ typeof registration_endpoint !== 'string') {
77
+ throw new StepError('discovery', asRes.status);
78
+ }
79
+ return {
80
+ issuer,
81
+ authorizationEndpoint: authorization_endpoint,
82
+ tokenEndpoint: token_endpoint,
83
+ registrationEndpoint: registration_endpoint,
84
+ };
85
+ }
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) {
92
+ const res = await fetchImpl(registrationEndpoint, {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/json' },
95
+ body: JSON.stringify({
96
+ client_name: `waku-memory ${version}`,
97
+ redirect_uris: [REGISTERED_REDIRECT_URI],
98
+ grant_types: ['authorization_code'],
99
+ response_types: ['code'],
100
+ token_endpoint_auth_method: 'none',
101
+ }),
102
+ });
103
+ if (!res.ok)
104
+ throw new StepError('registration', res.status);
105
+ const body = (await res.json());
106
+ if (typeof body.client_id !== 'string' || body.client_id === '')
107
+ throw new StepError('registration', res.status);
108
+ return body.client_id;
109
+ }
110
+ // PKCE (RFC 7636), S256 only: a 32-byte random verifier, base64url; the
111
+ // challenge is its SHA-256 digest, also base64url. Buffer's own
112
+ // 'base64url' encoding already omits padding, so neither value needs the
113
+ // usual base64 cleanup.
114
+ export function pkce() {
115
+ const verifier = randomBytes(32).toString('base64url');
116
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
117
+ return { verifier, challenge };
118
+ }
119
+ export function authorizeUrl(p) {
120
+ const url = new URL(p.authorizationEndpoint);
121
+ url.searchParams.set('response_type', 'code');
122
+ url.searchParams.set('client_id', p.clientId);
123
+ url.searchParams.set('redirect_uri', p.redirectUri);
124
+ url.searchParams.set('state', p.state);
125
+ url.searchParams.set('code_challenge', p.challenge);
126
+ url.searchParams.set('code_challenge_method', 'S256');
127
+ url.searchParams.set('scope', LOGIN_SCOPES);
128
+ url.searchParams.set('resource', p.resource);
129
+ return url.toString();
130
+ }
131
+ async function exchangeToken(tokenEndpoint, fetchImpl, p) {
132
+ const body = new URLSearchParams({
133
+ grant_type: 'authorization_code',
134
+ code: p.code,
135
+ redirect_uri: p.redirectUri,
136
+ client_id: p.clientId,
137
+ code_verifier: p.verifier,
138
+ resource: p.resource,
139
+ });
140
+ const res = await fetchImpl(tokenEndpoint, {
141
+ method: 'POST',
142
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
143
+ body: body.toString(),
144
+ });
145
+ if (!res.ok)
146
+ throw new StepError('token', res.status);
147
+ const json = (await res.json());
148
+ if (typeof json.access_token !== 'string' || json.access_token === '')
149
+ throw new StepError('token', res.status);
150
+ return { accessToken: json.access_token, idToken: typeof json.id_token === 'string' ? json.id_token : undefined };
151
+ }
152
+ // The email printed in the final "Signed in as <email>" line -- read from
153
+ // the token response's id_token, an unsigned decode of its middle segment
154
+ // (base64url JSON), never signature-checked: it arrived over TLS from the
155
+ // issuer discover() itself found, and the only use is one word in a
156
+ // stdout line, not an authorization decision. Anything short of a clean
157
+ // {email: string} there (no id_token, unparseable, wrong shape) falls back
158
+ // to the literal word "you" -- login() never blocks on this.
159
+ function emailFromIdToken(idToken) {
160
+ if (!idToken)
161
+ return null;
162
+ const parts = idToken.split('.');
163
+ if (parts.length < 2)
164
+ return null;
165
+ try {
166
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
167
+ return typeof payload.email === 'string' && payload.email !== '' ? payload.email : null;
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
173
+ // keys.py's CreateKeyRequest.label: free text, default "unnamed", capped
174
+ // at 200 chars server-side -- truncated here too so a very long hostname
175
+ // never trips the server's own validation into a refusal this file would
176
+ // then have to explain.
177
+ function keyLabel() {
178
+ const label = `waku-memory on ${hostname()}`;
179
+ return label.length > 200 ? label.slice(0, 200) : label;
180
+ }
181
+ function reportRefusal(deps, fallbackStep, err) {
182
+ const step = err instanceof StepError ? err.step : fallbackStep;
183
+ const status = err instanceof StepError ? err.status : 0;
184
+ deps.stdout(`Sign-in failed at ${step} (HTTP ${status}).`);
185
+ }
186
+ // The whole flow. Never prints the token or the key on any path -- the
187
+ // only things written to deps.stdout are the authorize URL, the final
188
+ // "Signed in as <email>" line, and (on refusal) the one-line step/status
189
+ // 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.
194
+ export async function login(deps) {
195
+ const apiBase = stripSlash(deps.apiBase);
196
+ const resource = `${apiBase}/mcp`;
197
+ const timeoutMs = deps.timeoutMs ?? LOGIN_TIMEOUT_MS;
198
+ let discovered;
199
+ try {
200
+ discovered = await discover(apiBase, deps.fetchImpl);
201
+ }
202
+ catch (err) {
203
+ reportRefusal(deps, 'discovery', err);
204
+ return 'refused';
205
+ }
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
+ const { verifier, challenge } = pkce();
215
+ const state = randomBytes(16).toString('hex');
216
+ return new Promise((resolveLogin) => {
217
+ let settled = false;
218
+ let port = 0;
219
+ let accepted = false;
220
+ const server = createServer((req, res) => {
221
+ const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
222
+ if (requestUrl.pathname !== '/callback') {
223
+ res.writeHead(404);
224
+ res.end();
225
+ return;
226
+ }
227
+ const code = requestUrl.searchParams.get('code');
228
+ const receivedState = requestUrl.searchParams.get('state');
229
+ if (receivedState !== state || !code) {
230
+ // Wrong or missing state: keep waiting -- a stray or forged hit on
231
+ // this port must not end the flow the real browser is still
232
+ // carrying.
233
+ res.writeHead(400);
234
+ res.end();
235
+ return;
236
+ }
237
+ // OAuth codes are single-use and minting a credential should be
238
+ // idempotent only by accident; the client must not rely on the
239
+ // authorization server to defend against replay. Guard here: flag
240
+ // the code accepted before any async work, so a concurrent request
241
+ // sees the flag and returns 200 without re-running the exchange.
242
+ if (accepted) {
243
+ res.writeHead(200, { 'Content-Type': 'text/html' });
244
+ res.end('Signed in. You can close this tab.');
245
+ return;
246
+ }
247
+ accepted = true;
248
+ res.writeHead(200, { 'Content-Type': 'text/html' });
249
+ res.end('Signed in. You can close this tab.');
250
+ void finishSignIn(code);
251
+ });
252
+ const finish = (result) => {
253
+ if (settled)
254
+ return;
255
+ settled = true;
256
+ clearTimeout(timer);
257
+ server.close();
258
+ resolveLogin(result);
259
+ };
260
+ async function finishSignIn(code) {
261
+ try {
262
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
263
+ const tokenResult = await exchangeToken(discovered.tokenEndpoint, deps.fetchImpl, {
264
+ code,
265
+ redirectUri,
266
+ clientId,
267
+ verifier,
268
+ resource,
269
+ });
270
+ const keysRes = await deps.fetchImpl(`${apiBase}/keys`, {
271
+ method: 'POST',
272
+ headers: {
273
+ 'Content-Type': 'application/json',
274
+ Authorization: `Bearer ${tokenResult.accessToken}`,
275
+ },
276
+ body: JSON.stringify({ label: keyLabel() }),
277
+ });
278
+ if (!keysRes.ok)
279
+ throw new StepError('keys', keysRes.status);
280
+ const keysBody = (await keysRes.json());
281
+ if (typeof keysBody.plaintext !== 'string' || keysBody.plaintext === '') {
282
+ throw new StepError('keys', keysRes.status);
283
+ }
284
+ const { key } = sanitizeKey(keysBody.plaintext);
285
+ writeConfigJson(deps.configDir, { url: apiBase, key });
286
+ const email = emailFromIdToken(tokenResult.idToken) ?? 'you';
287
+ deps.stdout(`Signed in as ${email}. Key stored in ${deps.configDir}/config.json.`);
288
+ finish('signed-in');
289
+ }
290
+ catch (err) {
291
+ reportRefusal(deps, 'token', err);
292
+ finish('refused');
293
+ }
294
+ }
295
+ const timer = setTimeout(() => finish('timeout'), timeoutMs);
296
+ server.listen(0, '127.0.0.1', () => {
297
+ 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,
307
+ });
308
+ deps.stdout(`Open this URL if your browser did not: ${url}`);
309
+ deps.openBrowser(url);
310
+ });
311
+ });
312
+ }
@@ -0,0 +1,43 @@
1
+ // Spec 011 §7: the project is the directory name, except that a git
2
+ // worktree names the repository it belongs to. A worktree's `.git` is a
3
+ // file holding `gitdir: <repo>/.git/worktrees/<name>`; a checkout's `.git`
4
+ // is a directory. Read with fs only -- no git binary.
5
+ import { readFileSync, statSync } from 'node:fs';
6
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
7
+ export function resolveProject(cwd, io = { statSync, readFileSync }) {
8
+ const start = resolve(cwd);
9
+ let dir = start;
10
+ for (;;) {
11
+ const dotGit = join(dir, '.git');
12
+ let stat;
13
+ try {
14
+ stat = io.statSync(dotGit);
15
+ }
16
+ catch {
17
+ stat = undefined;
18
+ }
19
+ if (stat?.isDirectory())
20
+ return basename(start);
21
+ if (stat?.isFile()) {
22
+ let content;
23
+ try {
24
+ content = io.readFileSync(dotGit, 'utf8');
25
+ }
26
+ catch {
27
+ return basename(start); // unreadable: the checkout's own name, never a throw (§7)
28
+ }
29
+ const m = /^gitdir:\s*(.+?)\s*$/m.exec(content);
30
+ if (m) {
31
+ const gitdir = isAbsolute(m[1]) ? m[1] : resolve(dir, m[1]);
32
+ const wt = /^(.*)[\\/]\.git[\\/]worktrees[\\/][^\\/]+$/.exec(gitdir);
33
+ if (wt)
34
+ return basename(wt[1]);
35
+ }
36
+ return basename(start);
37
+ }
38
+ const parent = dirname(dir);
39
+ if (parent === dir)
40
+ return basename(start);
41
+ dir = parent;
42
+ }
43
+ }
@@ -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.1.0",
3
+ "version": "0.3.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.