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/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
- return { kind: 'capture', action: subcommand, url: argv.includes('--url') ? url : DEFAULT_INGEST_URL };
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
- function realCaptureDeps(prompt) {
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
  });
@@ -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
+ }