waku-memory 0.1.0 → 0.2.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/bootstrap.js +757 -0
- package/dist/capture.js +358 -63
- package/dist/cli.js +101 -11
- package/dist/dialogue.js +139 -0
- package/dist/hook.js +272 -121
- package/dist/project.js +43 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -13,12 +13,15 @@
|
|
|
13
13
|
// --help, and the found/wrote/error tri-state collapsing into one boolean
|
|
14
14
|
// -- are pinned by plain assertions on return values, not by mocking
|
|
15
15
|
// console.log.
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
16
17
|
import { readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
17
18
|
import { homedir } from 'node:os';
|
|
18
19
|
import { basename, dirname, join } from 'node:path';
|
|
19
20
|
import { createInterface } from 'node:readline';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { DEFAULT_WINDOW_DAYS } from "./bootstrap.js";
|
|
20
23
|
import { getHarnesses, getManualHarnesses, harnessConfigExists, mergeMcpConfig, } from "./harnesses.js";
|
|
21
|
-
import { handleHookEvent } from "./hook.js";
|
|
24
|
+
import { SHIM_VERSION, handleHookEvent } from "./hook.js";
|
|
22
25
|
import { KEYS_PAGE_URL, disable, enable } from "./capture.js";
|
|
23
26
|
export const DEFAULT_URL = 'https://api.waku.one/mcp';
|
|
24
27
|
export const DEFAULT_NAME = 'waku';
|
|
@@ -67,6 +70,9 @@ export function importNote() {
|
|
|
67
70
|
export function parseArgs(argv) {
|
|
68
71
|
let name = DEFAULT_NAME;
|
|
69
72
|
let url = DEFAULT_URL;
|
|
73
|
+
let since;
|
|
74
|
+
let all = false;
|
|
75
|
+
let noBootstrap = false;
|
|
70
76
|
const positional = [];
|
|
71
77
|
for (let i = 0; i < argv.length; i++) {
|
|
72
78
|
const arg = argv[i];
|
|
@@ -76,6 +82,15 @@ export function parseArgs(argv) {
|
|
|
76
82
|
else if (arg === '--url' && i + 1 < argv.length) {
|
|
77
83
|
url = argv[++i];
|
|
78
84
|
}
|
|
85
|
+
else if (arg === '--since' && i + 1 < argv.length) {
|
|
86
|
+
since = argv[++i];
|
|
87
|
+
}
|
|
88
|
+
else if (arg === '--all') {
|
|
89
|
+
all = true;
|
|
90
|
+
}
|
|
91
|
+
else if (arg === '--no-bootstrap') {
|
|
92
|
+
noBootstrap = true;
|
|
93
|
+
}
|
|
79
94
|
else if (!arg.startsWith('--')) {
|
|
80
95
|
positional.push(arg);
|
|
81
96
|
}
|
|
@@ -83,7 +98,7 @@ export function parseArgs(argv) {
|
|
|
83
98
|
// subcommand is only meaningful to the caller for "capture enable" /
|
|
84
99
|
// "capture disable" -- setup and hook both ignore it, same as they always
|
|
85
100
|
// ignored any positional beyond the first.
|
|
86
|
-
return { command: positional[0], subcommand: positional[1], name, url };
|
|
101
|
+
return { command: positional[0], subcommand: positional[1], name, url, since, all, noBootstrap };
|
|
87
102
|
}
|
|
88
103
|
export function printUsage() {
|
|
89
104
|
console.log('Usage: npx waku-memory setup [--name <name>] [--url <url>]');
|
|
@@ -97,7 +112,7 @@ export function printUsage() {
|
|
|
97
112
|
console.log(' the server named in ~/.waku-memory/config.json. Invoked by Claude Code');
|
|
98
113
|
console.log(' itself (see "capture enable") -- not meant to be run by hand.');
|
|
99
114
|
console.log('');
|
|
100
|
-
console.log('Usage: waku-memory capture enable [--url <ingest-base>]');
|
|
115
|
+
console.log('Usage: waku-memory capture enable [--url <ingest-base>] [--since <N>d] [--all] [--no-bootstrap]');
|
|
101
116
|
console.log('');
|
|
102
117
|
console.log(' Turns on automatic capture: every turn of your Claude Code sessions is');
|
|
103
118
|
console.log(' sent to our server and on to Anthropic for extraction. Shows that');
|
|
@@ -105,11 +120,36 @@ export function printUsage() {
|
|
|
105
120
|
console.log(` asks for an API key (mint one at ${KEYS_PAGE_URL})`);
|
|
106
121
|
console.log(' and installs the hook into ~/.claude/settings.json.');
|
|
107
122
|
console.log('');
|
|
123
|
+
console.log(' A third question then offers to import the memory and history Claude');
|
|
124
|
+
console.log(' Code already keeps on this machine -- its own memory files and session');
|
|
125
|
+
console.log(' transcripts -- asked only when it finds anything to offer.');
|
|
126
|
+
console.log('');
|
|
127
|
+
console.log(` --since <N>d only scan transcripts from the last N days (default: ${DEFAULT_WINDOW_DAYS}d)`);
|
|
128
|
+
console.log(' --all scan full history instead, ignoring --since');
|
|
129
|
+
console.log(' --no-bootstrap skip the scan and the third question entirely');
|
|
130
|
+
console.log('');
|
|
108
131
|
console.log('Usage: waku-memory capture disable');
|
|
109
132
|
console.log('');
|
|
110
133
|
console.log(' Removes exactly the hook entries "capture enable" added. Your saved key');
|
|
111
134
|
console.log(' is left in place -- revoke it on the keys page if you want it gone too.');
|
|
112
135
|
}
|
|
136
|
+
// The default third question: on, with the full DEFAULT_WINDOW_DAYS window
|
|
137
|
+
// (spec 011 §8/§9, task 14). Used both as dispatch()'s no-flags-given result
|
|
138
|
+
// and, for "capture disable", unconditionally -- disable() never reads
|
|
139
|
+
// deps.bootstrap (see its own comment), so there is nothing to gain and a
|
|
140
|
+
// real footgun to avoid in parsing --since/--all/--no-bootstrap for an
|
|
141
|
+
// action that would silently ignore them.
|
|
142
|
+
const DEFAULT_BOOTSTRAP = { windowDays: DEFAULT_WINDOW_DAYS, enabled: true };
|
|
143
|
+
// Accepts a bare integer ("90") or an integer with a trailing "d" ("90d") --
|
|
144
|
+
// the brief's two named shapes, spec 011 §9. Anything else (empty, signed,
|
|
145
|
+
// fractional, trailing garbage after the "d") is null, which dispatch()
|
|
146
|
+
// turns into a 'bad-flag' rather than a silent fallback to the default: a
|
|
147
|
+
// person who typed --since clearly meant to change the window, so getting
|
|
148
|
+
// no error and the default anyway would be the more confusing wrong answer.
|
|
149
|
+
export function parseSinceDays(raw) {
|
|
150
|
+
const match = /^(\d+)d?$/.exec(raw);
|
|
151
|
+
return match ? Number(match[1]) : null;
|
|
152
|
+
}
|
|
113
153
|
export function dispatch(argv) {
|
|
114
154
|
// Checked ahead of parseArgs deliberately: --help/-h are flags, so
|
|
115
155
|
// parseArgs (which only special-cases --name/--url) would otherwise drop
|
|
@@ -118,7 +158,7 @@ export function dispatch(argv) {
|
|
|
118
158
|
// existed to pin it. See cli.test.mjs's dispatch block.
|
|
119
159
|
if (argv.includes('--help') || argv.includes('-h'))
|
|
120
160
|
return { kind: 'help' };
|
|
121
|
-
const { command, subcommand, name, url } = parseArgs(argv);
|
|
161
|
+
const { command, subcommand, name, url, since, all, noBootstrap } = parseArgs(argv);
|
|
122
162
|
if (command === undefined)
|
|
123
163
|
return { kind: 'no-command' };
|
|
124
164
|
if (command === 'hook')
|
|
@@ -132,7 +172,30 @@ export function dispatch(argv) {
|
|
|
132
172
|
// cheapest way to tell "the user passed --url" apart from "parseArgs's
|
|
133
173
|
// unrelated default happened to apply" without giving parseArgs a
|
|
134
174
|
// second, command-dependent default of its own.
|
|
135
|
-
|
|
175
|
+
const ingestUrl = argv.includes('--url') ? url : DEFAULT_INGEST_URL;
|
|
176
|
+
if (subcommand === 'disable') {
|
|
177
|
+
return { kind: 'capture', action: 'disable', url: ingestUrl, bootstrap: DEFAULT_BOOTSTRAP };
|
|
178
|
+
}
|
|
179
|
+
// --since is validated unconditionally (even alongside --all) so a typo
|
|
180
|
+
// is never silently swallowed by an --all that happened to be present
|
|
181
|
+
// too; --all is then applied on top, since "scan everything" is the
|
|
182
|
+
// least surprising way to read the two stacked together (untested by
|
|
183
|
+
// the brief -- there is no one obviously-right answer here, but this at
|
|
184
|
+
// least never hides a bad --since value).
|
|
185
|
+
let windowDays = DEFAULT_WINDOW_DAYS;
|
|
186
|
+
if (since !== undefined) {
|
|
187
|
+
const parsed = parseSinceDays(since);
|
|
188
|
+
if (parsed === null) {
|
|
189
|
+
return {
|
|
190
|
+
kind: 'bad-flag',
|
|
191
|
+
message: `waku-memory capture enable: invalid --since value "${since}" -- expected a number of days, e.g. "90d" or "90".`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
windowDays = parsed;
|
|
195
|
+
}
|
|
196
|
+
if (all)
|
|
197
|
+
windowDays = null;
|
|
198
|
+
return { kind: 'capture', action: 'enable', url: ingestUrl, bootstrap: { windowDays, enabled: !noBootstrap } };
|
|
136
199
|
}
|
|
137
200
|
if (command !== 'setup')
|
|
138
201
|
return { kind: 'unknown', command };
|
|
@@ -383,14 +446,34 @@ async function runHookCommand() {
|
|
|
383
446
|
// JSON-shaped types meet. Safe in practice: atomicWriteJson only ever does
|
|
384
447
|
// JSON.stringify(data) -- it has no opinion about McpConfig vs
|
|
385
448
|
// ClaudeSettings beyond the type checker's.
|
|
386
|
-
|
|
449
|
+
//
|
|
450
|
+
// hookSourceDir is this module's own directory, not a fixed constant: as
|
|
451
|
+
// published, cli.ts compiles to dist/cli.js and runs from there, so
|
|
452
|
+
// import.meta.url resolves to dist -- exactly the directory installHookCopy
|
|
453
|
+
// needs (spec 011 §6, task 11). verifyHook actually runs the entry once
|
|
454
|
+
// with a throwaway event before enable() trusts it, so a corrupted copy or
|
|
455
|
+
// a Node too old for it is caught before settings.json is ever touched.
|
|
456
|
+
//
|
|
457
|
+
// fetchImpl is task 13's addition (spec 011 §8): the real global fetch, for
|
|
458
|
+
// runBootstrap's own network calls. bootstrap is now a parameter rather than
|
|
459
|
+
// a hardcoded default (task 14): dispatch() has already turned
|
|
460
|
+
// --since/--all/--no-bootstrap (or their absence) into this same shape, so
|
|
461
|
+
// this function's only job is to place it on the deps bag unchanged, the
|
|
462
|
+
// same way `prompt` already arrives built by its own call site below.
|
|
463
|
+
function realCaptureDeps(prompt, bootstrap) {
|
|
387
464
|
const claudeDir = join(homedir(), '.claude');
|
|
388
465
|
return {
|
|
389
466
|
claudeDir,
|
|
390
467
|
settingsPath: join(claudeDir, 'settings.json'),
|
|
391
468
|
configDir: join(homedir(), '.waku-memory'),
|
|
469
|
+
hookSourceDir: dirname(fileURLToPath(import.meta.url)),
|
|
470
|
+
version: SHIM_VERSION,
|
|
471
|
+
execPath: process.execPath,
|
|
472
|
+
verifyHook: (run) => spawnSync(run.command, run.args ?? [], { input: '{"hook_event_name":"Nope"}\n', timeout: 10_000 }).status === 0,
|
|
392
473
|
prompt,
|
|
393
474
|
writeSettingsJson: (path, data) => atomicWriteJson(path, data),
|
|
475
|
+
fetchImpl: fetch,
|
|
476
|
+
bootstrap,
|
|
394
477
|
};
|
|
395
478
|
}
|
|
396
479
|
// Not awaited by run() below, for the same structural reason runHookCommand
|
|
@@ -401,14 +484,16 @@ function realCaptureDeps(prompt) {
|
|
|
401
484
|
// safely touch) is the one outcome that should make a scripted caller
|
|
402
485
|
// notice. 'declined' and the disable no-op are both a normal, successful
|
|
403
486
|
// run that did exactly what was asked -- nothing.
|
|
404
|
-
async function runCaptureCommand(action, url) {
|
|
487
|
+
async function runCaptureCommand(action, url, bootstrap) {
|
|
405
488
|
if (action === 'disable') {
|
|
406
489
|
// disable() never prompts (see its own comment) -- no readline interface
|
|
407
490
|
// is created at all, and this stub is a fail-loud guard against that
|
|
408
|
-
// ever silently stopping being true.
|
|
491
|
+
// ever silently stopping being true. bootstrap is dispatch()'s
|
|
492
|
+
// DEFAULT_BOOTSTRAP here regardless of what was on argv -- disable()
|
|
493
|
+
// itself never reads deps.bootstrap either.
|
|
409
494
|
const deps = realCaptureDeps(() => {
|
|
410
495
|
throw new Error('waku-memory capture disable: unexpectedly tried to prompt.');
|
|
411
|
-
});
|
|
496
|
+
}, bootstrap);
|
|
412
497
|
const result = await disable(deps);
|
|
413
498
|
if (result === 'refused')
|
|
414
499
|
process.exitCode = 1;
|
|
@@ -446,7 +531,7 @@ async function runCaptureCommand(action, url) {
|
|
|
446
531
|
const { value, done } = await lines.next();
|
|
447
532
|
return done ? '' : value; // stdin closed before an answer arrived -- treated as "declined"
|
|
448
533
|
};
|
|
449
|
-
const result = await enable(url, realCaptureDeps(prompt));
|
|
534
|
+
const result = await enable(url, realCaptureDeps(prompt, bootstrap));
|
|
450
535
|
if (result === 'refused')
|
|
451
536
|
process.exitCode = 1;
|
|
452
537
|
}
|
|
@@ -470,6 +555,11 @@ export function run(argv) {
|
|
|
470
555
|
printUsage();
|
|
471
556
|
process.exitCode = 1;
|
|
472
557
|
return;
|
|
558
|
+
case 'bad-flag':
|
|
559
|
+
console.error(d.message);
|
|
560
|
+
printUsage();
|
|
561
|
+
process.exitCode = 1;
|
|
562
|
+
return;
|
|
473
563
|
case 'setup':
|
|
474
564
|
setup(d.name, d.url);
|
|
475
565
|
return;
|
|
@@ -488,7 +578,7 @@ export function run(argv) {
|
|
|
488
578
|
});
|
|
489
579
|
return;
|
|
490
580
|
case 'capture':
|
|
491
|
-
void runCaptureCommand(d.action, d.url).catch((err) => {
|
|
581
|
+
void runCaptureCommand(d.action, d.url, d.bootstrap).catch((err) => {
|
|
492
582
|
console.error(`waku-memory capture: unexpected failure -- ${errorMessage(err)}.`);
|
|
493
583
|
process.exitCode = 1;
|
|
494
584
|
});
|
package/dist/dialogue.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// cc-dialogue-v1 (spec 011 §1): the fixed rule that turns a transcript
|
|
2
|
+
// delta into the conversation before it leaves the machine, and the piece
|
|
3
|
+
// cut (§3) that keeps every request under the ingest cap. No judgement
|
|
4
|
+
// lives here -- record types and block types only -- and the rule is
|
|
5
|
+
// versioned: a change is a new CONTENT_FORMAT, not an edit to this one.
|
|
6
|
+
//
|
|
7
|
+
// The same rule exists in Python (waku_worker/dialogue.py) for raw jobs
|
|
8
|
+
// from 0.1.x shims; tests/fixtures/cc-dialogue-v1 pins both to one output.
|
|
9
|
+
export const CONTENT_FORMAT = 'cc-dialogue-v1';
|
|
10
|
+
export const MAX_PIECE_BYTES = 200 * 1024; // under the 256 KiB ingest cap, with room for one long record
|
|
11
|
+
// Text Claude Code itself writes into `user` records: a subagent's
|
|
12
|
+
// completion report, slash-command output, reminders. A text starting
|
|
13
|
+
// with one of these is the harness's, not the person's. The list is a
|
|
14
|
+
// census over Yang's machine on 2026-09-05 (task-notification 563,
|
|
15
|
+
// command-name 34, local-command-stdout 34, system-reminder 12) plus the
|
|
16
|
+
// caveat tag the harness documents for the same shape. Part of the
|
|
17
|
+
// format version: a new tag is cc-dialogue-v2, here and in dialogue.py.
|
|
18
|
+
const HARNESS_TAGS = [
|
|
19
|
+
'<task-notification>',
|
|
20
|
+
'<local-command-stdout>',
|
|
21
|
+
'<local-command-caveat>',
|
|
22
|
+
'<command-name>',
|
|
23
|
+
'<system-reminder>',
|
|
24
|
+
];
|
|
25
|
+
// Named so the Python twin (waku_worker/dialogue.py) strips the same set:
|
|
26
|
+
// trimStart() and str.lstrip() disagree on U+001C-U+001F and U+0085.
|
|
27
|
+
const LEADING_BLANKS = /^[ \t\r\n\f\v]+/;
|
|
28
|
+
function isTagged(text) {
|
|
29
|
+
const t = text.replace(LEADING_BLANKS, '');
|
|
30
|
+
return HARNESS_TAGS.some((tag) => t.startsWith(tag));
|
|
31
|
+
}
|
|
32
|
+
function textBlocks(content) {
|
|
33
|
+
if (typeof content === 'string')
|
|
34
|
+
return [content];
|
|
35
|
+
if (!Array.isArray(content))
|
|
36
|
+
return [];
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const b of content) {
|
|
39
|
+
if (b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string')
|
|
40
|
+
out.push(b.text);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
// One JSONL line -> the entries the rule keeps, or none when it drops
|
|
45
|
+
// the line.
|
|
46
|
+
export function formatLine(line) {
|
|
47
|
+
let rec;
|
|
48
|
+
try {
|
|
49
|
+
rec = JSON.parse(line);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
if (rec === null || typeof rec !== 'object')
|
|
55
|
+
return [];
|
|
56
|
+
const r = rec;
|
|
57
|
+
const content = r.message && typeof r.message === 'object' ? r.message.content : undefined;
|
|
58
|
+
if (r.type === 'user') {
|
|
59
|
+
if (r.isMeta === true || r.isCompactSummary === true)
|
|
60
|
+
return [];
|
|
61
|
+
return textBlocks(content)
|
|
62
|
+
.filter((t) => !isTagged(t))
|
|
63
|
+
.map((t) => ({ role: 'user', text: t }));
|
|
64
|
+
}
|
|
65
|
+
if (r.type === 'assistant') {
|
|
66
|
+
if (!Array.isArray(content))
|
|
67
|
+
return [];
|
|
68
|
+
const entries = [];
|
|
69
|
+
for (const b of content) {
|
|
70
|
+
if (!b || typeof b !== 'object')
|
|
71
|
+
continue;
|
|
72
|
+
if (b.type === 'text' && typeof b.text === 'string')
|
|
73
|
+
entries.push({ role: 'assistant', text: b.text });
|
|
74
|
+
else if (b.type === 'tool_use' && typeof b.name === 'string')
|
|
75
|
+
entries.push({ role: 'tool', text: b.name });
|
|
76
|
+
}
|
|
77
|
+
return entries;
|
|
78
|
+
}
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
export function renderEntry(e) {
|
|
82
|
+
if (e.role === 'user')
|
|
83
|
+
return `user: ${e.text}`;
|
|
84
|
+
if (e.role === 'assistant')
|
|
85
|
+
return `assistant: ${e.text}`;
|
|
86
|
+
return `assistant used tool: ${e.text}`;
|
|
87
|
+
}
|
|
88
|
+
// The wire text: entries joined by one blank line.
|
|
89
|
+
export function renderEntries(entries) {
|
|
90
|
+
return entries.map(renderEntry).join('\n\n');
|
|
91
|
+
}
|
|
92
|
+
// One line -> one string or null: the contract the fixture test and the
|
|
93
|
+
// Python twin (waku_worker/dialogue.py) share.
|
|
94
|
+
export function formatRecord(line) {
|
|
95
|
+
const entries = formatLine(line);
|
|
96
|
+
return entries.length > 0 ? renderEntries(entries) : null;
|
|
97
|
+
}
|
|
98
|
+
// Formats every complete line of `buf` and packs the surviving entries
|
|
99
|
+
// into pieces under `maxPieceBytes`, cutting only between lines (§3): a
|
|
100
|
+
// line's entries always travel together. A trailing partial line (no
|
|
101
|
+
// newline yet) is left for the next event. `end` is the offset past the
|
|
102
|
+
// last newline consumed, whether or not anything survived -- the
|
|
103
|
+
// caller's watermark moves to it either way.
|
|
104
|
+
export function formatDelta(buf, maxPieceBytes = MAX_PIECE_BYTES) {
|
|
105
|
+
const pieces = [];
|
|
106
|
+
let entries = [];
|
|
107
|
+
let entriesBytes = 0;
|
|
108
|
+
let pieceEnd = 0;
|
|
109
|
+
let lineStart = 0;
|
|
110
|
+
let end = 0;
|
|
111
|
+
const flush = () => {
|
|
112
|
+
if (entries.length > 0) {
|
|
113
|
+
pieces.push({ entries, text: renderEntries(entries), end: pieceEnd });
|
|
114
|
+
entries = [];
|
|
115
|
+
entriesBytes = 0;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
for (;;) {
|
|
119
|
+
const nl = buf.indexOf(0x0a, lineStart);
|
|
120
|
+
if (nl === -1)
|
|
121
|
+
break;
|
|
122
|
+
const lineEnd = nl + 1;
|
|
123
|
+
const lineEntries = formatLine(buf.subarray(lineStart, nl).toString('utf8'));
|
|
124
|
+
lineStart = lineEnd;
|
|
125
|
+
end = lineEnd;
|
|
126
|
+
if (lineEntries.length === 0) {
|
|
127
|
+
pieceEnd = lineEnd;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const bytes = Buffer.byteLength(renderEntries(lineEntries), 'utf8') + (entries.length > 0 ? 2 : 0);
|
|
131
|
+
if (entries.length > 0 && entriesBytes + bytes > maxPieceBytes)
|
|
132
|
+
flush();
|
|
133
|
+
entries.push(...lineEntries);
|
|
134
|
+
entriesBytes += bytes;
|
|
135
|
+
pieceEnd = lineEnd;
|
|
136
|
+
}
|
|
137
|
+
flush();
|
|
138
|
+
return { pieces, end };
|
|
139
|
+
}
|