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/.codex-plugin/plugin.json +15 -0
- package/.mcp.json +9 -0
- package/README.md +55 -0
- package/dist/bootstrap.js +949 -0
- package/dist/capture.js +960 -117
- package/dist/cli.js +440 -63
- package/dist/codex-config.js +321 -0
- package/dist/dialogue-codex.js +63 -0
- package/dist/dialogue.js +139 -0
- package/dist/harnesses.js +57 -22
- package/dist/hook.js +324 -144
- package/dist/login.js +312 -0
- package/dist/project.js +43 -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,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
|
+
}
|
package/dist/project.js
ADDED
|
@@ -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
|
+
}
|
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.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.
|