ihow-memory 0.1.0-alpha.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 ADDED
@@ -0,0 +1,1084 @@
1
+ #!/usr/bin/env -S node --experimental-strip-types
2
+ import fs from 'node:fs/promises';
3
+ import crypto from 'node:crypto';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { openCore } from './core.js';
8
+ import { defaultRoot, ensureWorkspace, resolveWorkspace } from './workspace.js';
9
+ import { resolveEngineConfig } from './engine/retrieval.js';
10
+ import { sqliteRuntimeStatus } from './engine/fts.js';
11
+ import * as telemetry from './telemetry.js';
12
+ function parseArgs(argv) {
13
+ const [command = 'help', ...tail] = argv;
14
+ const options = {};
15
+ const rest = [];
16
+ for(let index = 0; index < tail.length; index += 1){
17
+ const arg = tail[index];
18
+ if (arg === '--space') options.space = tail[++index];
19
+ else if (arg === '--root') options.root = tail[++index];
20
+ else if (arg === '--memory-root') options.memoryRoot = tail[++index];
21
+ else if (arg === '--state-root') options.stateRoot = tail[++index];
22
+ else if (arg === '--cwd') options.cwd = tail[++index];
23
+ else if (arg === '--engine') options.engine = tail[++index];
24
+ else if (arg === '--vector-provider-command') options.vectorProviderCommand = tail[++index];
25
+ else if (arg === '--vector-model') options.vectorModel = tail[++index];
26
+ else if (arg === '--vector-timeout-ms') options.vectorTimeoutMs = Number(tail[++index]);
27
+ else if (arg === '--runtime') {
28
+ const runtime = tail[++index];
29
+ if (runtime === 'claude-code' || runtime === 'codex' || runtime === 'cursor') options.runtime = runtime;
30
+ else throw new Error('unsupported_runtime_use_claude-code_codex_or_cursor');
31
+ } else if (arg === '--share-diagnostics') options.shareDiagnostics = true;
32
+ else if (arg === '--json') options.json = true;
33
+ else if (arg === '--limit') options.limit = Number(tail[++index]);
34
+ else if (arg === '--dry-run') options.dryRun = true;
35
+ else if (arg === '--real-write') options.realWrite = true;
36
+ else if (arg === '--actor') options.actor = tail[++index];
37
+ else rest.push(arg);
38
+ }
39
+ return {
40
+ command,
41
+ options,
42
+ rest
43
+ };
44
+ }
45
+ function printJson(value) {
46
+ console.log(JSON.stringify(value, null, 2));
47
+ }
48
+ function workspaceMcpConfigSnippet(memoryRoot, stateRoot, runtimeDir) {
49
+ return {
50
+ mcpServers: {
51
+ 'ihow-memory': {
52
+ command: 'node',
53
+ args: [
54
+ 'mcp/server.js',
55
+ '--memory-root',
56
+ memoryRoot,
57
+ '--state-root',
58
+ stateRoot
59
+ ],
60
+ cwd: runtimeDir
61
+ }
62
+ }
63
+ };
64
+ }
65
+ function packageDir() {
66
+ return path.resolve(new URL('..', import.meta.url).pathname);
67
+ }
68
+ function runtimeLabel(runtime) {
69
+ if (runtime === 'claude-code') return 'Claude Code';
70
+ if (runtime === 'codex') return 'Codex';
71
+ if (runtime === 'cursor') return 'Cursor';
72
+ return 'generic MCP client';
73
+ }
74
+ function codexTomlSnippet(memoryRoot, stateRoot, runtimeDir) {
75
+ return `[mcp_servers.ihow-memory]
76
+ command = "node"
77
+ args = [
78
+ "mcp/server.js",
79
+ "--memory-root",
80
+ "${memoryRoot}",
81
+ "--state-root",
82
+ "${stateRoot}"
83
+ ]
84
+ cwd = "${runtimeDir}"`;
85
+ }
86
+ function runtimeConfigSnippet(workspace, runtime) {
87
+ const stateRoot = workspace.root;
88
+ const runtimeDir = path.join(workspace.spaceDir, '.runtime');
89
+ if (runtime === 'codex') return codexTomlSnippet(workspace.memoryDir, stateRoot, runtimeDir);
90
+ return workspaceMcpConfigSnippet(workspace.memoryDir, stateRoot, runtimeDir);
91
+ }
92
+ function printRuntimeSnippet(snippet, runtime) {
93
+ const label = runtimeLabel(runtime);
94
+ console.log(`\n${label} MCP config snippet:`);
95
+ if (typeof snippet === 'string') console.log(snippet);
96
+ else printJson(snippet);
97
+ }
98
+ function initBackupGuidance(runtime) {
99
+ if (runtime === 'codex') return 'Before editing Codex config, copy the existing config file or commit it first.';
100
+ if (runtime === 'claude-code') return 'Before editing Claude Code MCP settings, make a copy of the current settings file.';
101
+ if (runtime === 'cursor') return 'Before editing Cursor MCP settings, copy the current MCP/settings JSON.';
102
+ return 'Before writing this snippet into any runtime config, back up the existing config file.';
103
+ }
104
+ async function installRuntimeBundle(workspace) {
105
+ const source = path.join(packageDir(), 'dist');
106
+ const target = path.join(workspace.spaceDir, '.runtime');
107
+ try {
108
+ await fs.access(path.join(source, 'mcp', 'server.js'));
109
+ } catch {
110
+ throw new Error('runtime_bundle_missing_run_npm_build');
111
+ }
112
+ await fs.rm(target, {
113
+ recursive: true,
114
+ force: true
115
+ });
116
+ await fs.cp(source, target, {
117
+ recursive: true
118
+ });
119
+ await fs.writeFile(path.join(target, 'package.json'), `${JSON.stringify({
120
+ type: 'module'
121
+ }, null, 2)}\n`, 'utf8');
122
+ return target;
123
+ }
124
+ function commandExists(bin) {
125
+ const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', [
126
+ bin
127
+ ], {
128
+ encoding: 'utf8'
129
+ });
130
+ return probe.status === 0;
131
+ }
132
+ function mcpServerSpec(workspace) {
133
+ const serverEntry = path.join(workspace.spaceDir, '.runtime', 'mcp', 'server.js');
134
+ return {
135
+ command: 'node',
136
+ args: [
137
+ serverEntry,
138
+ '--memory-root',
139
+ workspace.memoryDir,
140
+ '--state-root',
141
+ workspace.root
142
+ ]
143
+ };
144
+ }
145
+ async function writeJsonMcpConfig(targetPath, runtime, spec, options) {
146
+ let config = {};
147
+ let existed = false;
148
+ let raw = null;
149
+ try {
150
+ raw = await fs.readFile(targetPath, 'utf8');
151
+ existed = true;
152
+ } catch (err) {
153
+ const code = err.code;
154
+ if (code === 'ENOENT') {
155
+ existed = false;
156
+ } else {
157
+ throw new Error(`connect_cannot_read_config: ${targetPath}: ${err.message}`);
158
+ }
159
+ }
160
+ if (raw !== null) {
161
+ try {
162
+ const parsed = JSON.parse(raw);
163
+ if (!parsed || typeof parsed !== 'object') throw new Error('config is not a JSON object');
164
+ config = parsed;
165
+ } catch (err) {
166
+ throw new Error(`connect_refuse_overwrite_unparseable_config: ${targetPath} exists but is not valid JSON (${err.message}). Aborting to avoid data loss — fix/remove the file or use the runtime's official CLI.`);
167
+ }
168
+ }
169
+ let backup = '';
170
+ if (existed && !options.dryRun) {
171
+ backup = `${targetPath}.ihow-bak-${Date.now()}`;
172
+ await fs.copyFile(targetPath, backup);
173
+ }
174
+ const servers = config.mcpServers && typeof config.mcpServers === 'object' ? config.mcpServers : {};
175
+ servers['ihow-memory'] = {
176
+ type: 'stdio',
177
+ command: spec.command,
178
+ args: spec.args
179
+ };
180
+ config.mcpServers = servers;
181
+ if (!options.dryRun) {
182
+ await fs.mkdir(path.dirname(targetPath), {
183
+ recursive: true
184
+ });
185
+ const tmp = `${targetPath}.ihow-tmp-${process.pid}`;
186
+ await fs.writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
187
+ await fs.rename(tmp, targetPath);
188
+ }
189
+ return {
190
+ ok: true,
191
+ runtime,
192
+ method: 'direct-json',
193
+ target: targetPath,
194
+ backup,
195
+ dryRun: !!options.dryRun,
196
+ existed
197
+ };
198
+ }
199
+ function connectViaClaudeCli(spec, options) {
200
+ if (!commandExists('claude')) return null;
201
+ const exists = spawnSync('claude', [
202
+ 'mcp',
203
+ 'get',
204
+ 'ihow-memory'
205
+ ], {
206
+ encoding: 'utf8'
207
+ }).status === 0;
208
+ if (options.dryRun) {
209
+ return {
210
+ ok: true,
211
+ runtime: 'claude-code',
212
+ method: 'official-cli:claude',
213
+ alreadyExists: exists,
214
+ dryRun: true
215
+ };
216
+ }
217
+ if (exists) spawnSync('claude', [
218
+ 'mcp',
219
+ 'remove',
220
+ 'ihow-memory',
221
+ '--scope',
222
+ 'user'
223
+ ], {
224
+ encoding: 'utf8'
225
+ });
226
+ const json = JSON.stringify({
227
+ type: 'stdio',
228
+ command: spec.command,
229
+ args: spec.args
230
+ });
231
+ const add = spawnSync('claude', [
232
+ 'mcp',
233
+ 'add-json',
234
+ '--scope',
235
+ 'user',
236
+ 'ihow-memory',
237
+ json
238
+ ], {
239
+ encoding: 'utf8'
240
+ });
241
+ if (add.status !== 0) {
242
+ throw new Error(`claude_mcp_add_failed: ${(add.stderr || add.stdout || '').slice(0, 300)}`);
243
+ }
244
+ return {
245
+ ok: true,
246
+ runtime: 'claude-code',
247
+ method: 'official-cli:claude',
248
+ target: '~/.claude.json (claude mcp add-json --scope user)',
249
+ replaced: exists
250
+ };
251
+ }
252
+ function connectViaCodexCli(spec, options) {
253
+ if (!commandExists('codex')) {
254
+ throw new Error('codex_cli_not_found: install the Codex CLI to connect codex (or run init for manual TOML).');
255
+ }
256
+ const exists = spawnSync('codex', [
257
+ 'mcp',
258
+ 'get',
259
+ 'ihow-memory'
260
+ ], {
261
+ encoding: 'utf8'
262
+ }).status === 0;
263
+ if (options.dryRun) {
264
+ return {
265
+ ok: true,
266
+ runtime: 'codex',
267
+ method: 'official-cli:codex',
268
+ alreadyExists: exists,
269
+ dryRun: true
270
+ };
271
+ }
272
+ if (exists) spawnSync('codex', [
273
+ 'mcp',
274
+ 'remove',
275
+ 'ihow-memory'
276
+ ], {
277
+ encoding: 'utf8'
278
+ });
279
+ const add = spawnSync('codex', [
280
+ 'mcp',
281
+ 'add',
282
+ 'ihow-memory',
283
+ '--',
284
+ spec.command,
285
+ ...spec.args
286
+ ], {
287
+ encoding: 'utf8'
288
+ });
289
+ if (add.status !== 0) {
290
+ throw new Error(`codex_mcp_add_failed: ${(add.stderr || add.stdout || '').slice(0, 300)}`);
291
+ }
292
+ return {
293
+ ok: true,
294
+ runtime: 'codex',
295
+ method: 'official-cli:codex',
296
+ target: '~/.codex/config.toml (codex mcp add)',
297
+ replaced: exists
298
+ };
299
+ }
300
+ async function connectRuntime(workspace, runtime, options) {
301
+ const home = os.homedir();
302
+ const spec = mcpServerSpec(workspace);
303
+ if (runtime === 'claude-code') {
304
+ const viaCli = connectViaClaudeCli(spec, options);
305
+ if (viaCli) return viaCli;
306
+ return writeJsonMcpConfig(path.join(home, '.claude.json'), runtime, spec, options);
307
+ }
308
+ if (runtime === 'codex') {
309
+ return connectViaCodexCli(spec, options);
310
+ }
311
+ if (runtime === 'cursor') {
312
+ return writeJsonMcpConfig(path.join(home, '.cursor', 'mcp.json'), runtime, spec, options);
313
+ }
314
+ throw new Error(`connect_unsupported_runtime: ${runtime}`);
315
+ }
316
+ function help() {
317
+ console.log(`iHow Memory Core A0.1
318
+
319
+ Usage:
320
+ ihow-memory init [--space name] [--root path] [--runtime claude-code|codex|cursor]
321
+ ihow-memory status [--space name] [--root path] [--memory-root path] [--state-root path] [--json]
322
+ ihow-memory doctor [--space name] [--root path] [--memory-root path] [--state-root path] [--runtime claude-code|codex|cursor] [--share-diagnostics] [--json]
323
+ ihow-memory proof [--root path] [--space name] [--engine fts|vector-gguf]
324
+ ihow-memory reindex [--memory-root path] [--state-root path] [--json]
325
+ ihow-memory search <query> [--limit n]
326
+ ihow-memory read <memory/path.md>
327
+ ihow-memory write-candidate <text> [--space name]
328
+ ihow-memory promote <candidate-path> [--scope name] [--title title]
329
+ ihow-memory durable-promote <candidate-path> (--dry-run | --real-write) [--scope name] [--title title] [--path path]
330
+ ihow-memory feedback [--runtime claude-code|codex|cursor]
331
+ ihow-memory reset --space name [--root path]
332
+ ihow-memory console [--port 8788] [--host 127.0.0.1] [--memory-root path] # read-only local web UI
333
+ ihow-memory connect --runtime claude-code|codex|cursor [--dry-run] [--json] # auto-config MCP (official CLI for claude/codex; safe backup+merge for cursor)
334
+ ihow-memory telemetry [on|off|status] # anonymous usage telemetry — OFF by default; only event/runtime/version, never memory content
335
+
336
+ Defaults:
337
+ root: ${defaultRoot()}
338
+ space: derived from cwd unless --space is provided
339
+ `);
340
+ }
341
+ async function isWritable(dir) {
342
+ try {
343
+ await fs.mkdir(dir, {
344
+ recursive: true
345
+ });
346
+ const probe = path.join(dir, `.write-test-${process.pid}-${Date.now()}`);
347
+ await fs.writeFile(probe, 'ok', 'utf8');
348
+ await fs.rm(probe, {
349
+ force: true
350
+ });
351
+ return true;
352
+ } catch {
353
+ return false;
354
+ }
355
+ }
356
+ async function latestAuditSummary(eventsDir) {
357
+ let entries;
358
+ try {
359
+ entries = await fs.readdir(eventsDir);
360
+ } catch {
361
+ return null;
362
+ }
363
+ const files = entries.filter((entry)=>entry.endsWith('.ndjson')).sort();
364
+ const latest = files.at(-1);
365
+ if (!latest) return null;
366
+ const eventPath = path.join(eventsDir, latest);
367
+ const lines = (await fs.readFile(eventPath, 'utf8')).trim().split('\n').filter(Boolean);
368
+ const last = lines.at(-1);
369
+ if (!last) return null;
370
+ return {
371
+ path: eventPath,
372
+ event: JSON.parse(last)
373
+ };
374
+ }
375
+ const SECRET_PATTERNS = [
376
+ [
377
+ /\b(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi,
378
+ '$1[redacted]'
379
+ ],
380
+ [
381
+ /\b(sk-[A-Za-z0-9_-]{8,})\b/g,
382
+ '[redacted]'
383
+ ],
384
+ [
385
+ /\b(ghp_[A-Za-z0-9_]{8,})\b/g,
386
+ '[redacted]'
387
+ ],
388
+ [
389
+ /\b(github_pat_[A-Za-z0-9_]{8,})\b/g,
390
+ '[redacted]'
391
+ ],
392
+ [
393
+ /\b(AKIA[0-9A-Z]{16})\b/g,
394
+ '[redacted]'
395
+ ],
396
+ [
397
+ /\b(token|password|passwd|secret|api[_-]?key|authorization|cookie)\b\s*[:=]\s*[^\s"',;]+/gi,
398
+ '$1=[redacted]'
399
+ ]
400
+ ];
401
+ function redactSecrets(value) {
402
+ return SECRET_PATTERNS.reduce((text, [pattern, replacement])=>text.replace(pattern, replacement), value);
403
+ }
404
+ function redactionHints(options = {}, status) {
405
+ const workspace = resolveWorkspace(options);
406
+ const statusWorkspace = status?.workspace || {};
407
+ const index = status?.index || {};
408
+ const hints = [
409
+ [
410
+ os.homedir(),
411
+ '<home>'
412
+ ],
413
+ [
414
+ process.cwd(),
415
+ '<cwd>'
416
+ ],
417
+ [
418
+ packageDir(),
419
+ '<package-dir>'
420
+ ],
421
+ [
422
+ workspace.root,
423
+ '<state-root>'
424
+ ],
425
+ [
426
+ workspace.spaceDir,
427
+ '<workspace>'
428
+ ],
429
+ [
430
+ workspace.memoryDir,
431
+ '<memory-root>'
432
+ ]
433
+ ];
434
+ for (const [key, label] of [
435
+ [
436
+ 'root',
437
+ '<state-root>'
438
+ ],
439
+ [
440
+ 'path',
441
+ '<workspace>'
442
+ ],
443
+ [
444
+ 'memoryRoot',
445
+ '<memory-root>'
446
+ ]
447
+ ]){
448
+ if (typeof statusWorkspace[key] === 'string') hints.push([
449
+ statusWorkspace[key],
450
+ label
451
+ ]);
452
+ }
453
+ for (const [key, label] of [
454
+ [
455
+ 'path',
456
+ '<index>'
457
+ ],
458
+ [
459
+ 'manifestPath',
460
+ '<index-manifest>'
461
+ ]
462
+ ]){
463
+ if (typeof index[key] === 'string') hints.push([
464
+ index[key],
465
+ label
466
+ ]);
467
+ }
468
+ return hints.filter(([absolute])=>path.isAbsolute(absolute)).sort((a, b)=>b[0].length - a[0].length);
469
+ }
470
+ function redactPaths(value, hints) {
471
+ let text = value;
472
+ for (const [absolute, label] of hints){
473
+ const normalized = absolute.replace(/\\/g, '/');
474
+ text = text.split(absolute).join(label);
475
+ text = text.split(normalized).join(label);
476
+ }
477
+ return text.replace(/(^|[\s"'`=([{:,])\/(?:[^\s"'`)\]}{,;]|\\ )+/g, (_match, prefix)=>`${prefix}<path>`);
478
+ }
479
+ function sanitizeString(value, hints) {
480
+ return redactPaths(redactSecrets(value), hints).slice(0, 1000);
481
+ }
482
+ function sanitizeValue(value, hints) {
483
+ if (typeof value === 'string') return sanitizeString(value, hints);
484
+ if (Array.isArray(value)) return value.map((entry)=>sanitizeValue(entry, hints));
485
+ if (value && typeof value === 'object') {
486
+ const output = {};
487
+ for (const [key, entry] of Object.entries(value)){
488
+ if (/token|password|secret|api[_-]?key|authorization|cookie/i.test(key)) {
489
+ output[key] = '[redacted]';
490
+ } else {
491
+ output[key] = sanitizeValue(entry, hints);
492
+ }
493
+ }
494
+ return output;
495
+ }
496
+ return value;
497
+ }
498
+ function sanitizeDoctorResult(result, options) {
499
+ const hints = redactionHints(options, result.status);
500
+ return sanitizeValue(result, hints);
501
+ }
502
+ function friendlyError(error) {
503
+ const raw = error instanceof Error ? error.message : String(error);
504
+ return sanitizeString(raw, redactionHints()).slice(0, 500);
505
+ }
506
+ function nodeVersionAtLeast(actual, expected) {
507
+ return actual.localeCompare(expected, undefined, {
508
+ numeric: true
509
+ }) >= 0;
510
+ }
511
+ async function doctor(options) {
512
+ const checks = [];
513
+ const workspace = resolveWorkspace(options);
514
+ const nodeOk = nodeVersionAtLeast(process.versions.node, '22.12.0');
515
+ const sqliteStatus = sqliteRuntimeStatus();
516
+ const writable = await isWritable(workspace.memoryDir);
517
+ let status;
518
+ checks.push({
519
+ name: 'node',
520
+ ok: nodeOk,
521
+ detail: `v${process.versions.node}`,
522
+ hint: nodeOk ? undefined : 'Install Node >= 22.12, then rerun: ihow-memory doctor. Example: nvm install 22 && nvm use 22.',
523
+ severity: nodeOk ? 'info' : 'error',
524
+ required: true
525
+ });
526
+ checks.push({
527
+ name: 'sqlite',
528
+ ok: sqliteStatus.ok,
529
+ detail: sqliteStatus.detail,
530
+ hint: sqliteStatus.ok ? undefined : 'Use a Node build with node:sqlite. The supported path is Node >= 22.12 from nodejs.org, nvm, fnm, or Volta.',
531
+ severity: sqliteStatus.ok ? 'info' : 'error',
532
+ required: true
533
+ });
534
+ checks.push({
535
+ name: 'memory-root',
536
+ ok: writable,
537
+ detail: workspace.memoryDir,
538
+ hint: writable ? undefined : 'Choose a writable location: ihow-memory init --root <writable-dir> or ihow-memory doctor --memory-root <writable-memory-dir> --state-root <writable-state-dir>.',
539
+ severity: writable ? 'info' : 'error',
540
+ required: true
541
+ });
542
+ checks.push({
543
+ name: 'runtime',
544
+ ok: Boolean(options.runtime),
545
+ detail: options.runtime ? `${runtimeLabel(options.runtime)} selected` : 'not selected',
546
+ hint: options.runtime ? `Run ihow-memory init --runtime ${options.runtime} and paste the snippet into ${runtimeLabel(options.runtime)} after backing up existing config.` : 'Run ihow-memory init --runtime claude-code, --runtime codex, or --runtime cursor to print a ready-to-paste MCP snippet.',
547
+ severity: options.runtime ? 'info' : 'warning',
548
+ required: false
549
+ });
550
+ if (nodeOk && sqliteStatus.ok && writable) {
551
+ try {
552
+ const core = await openCore(options);
553
+ status = await core.status();
554
+ } catch (error) {
555
+ checks.push({
556
+ name: 'core-status',
557
+ ok: false,
558
+ detail: friendlyError(error),
559
+ hint: 'Run ihow-memory doctor --share-diagnostics and include the redacted output in a feedback issue.',
560
+ severity: 'error',
561
+ required: true
562
+ });
563
+ }
564
+ }
565
+ const engineConfig = resolveEngineConfig(options);
566
+ if (status) {
567
+ const provider = status.provider;
568
+ const index = status.index;
569
+ const sync = status.sync;
570
+ const providerDetail = provider.fallback ? `active=fts fallbackFrom=${provider.fallbackFrom} lastError=${provider.lastError}` : `active=${provider.id} ready=${provider.ready}`;
571
+ checks.push({
572
+ name: 'engine',
573
+ ok: provider.ready === true,
574
+ detail: providerDetail,
575
+ hint: provider.ready ? undefined : 'FTS should be available locally; run ihow-memory reindex or check workspace paths.',
576
+ severity: provider.ready ? 'info' : 'error',
577
+ required: true
578
+ });
579
+ checks.push({
580
+ name: 'vector',
581
+ ok: true,
582
+ detail: engineConfig.vectorProviderCommand ? `configured requested=${engineConfig.requestedId}` : `not configured requested=${engineConfig.requestedId}`,
583
+ severity: 'info',
584
+ required: false
585
+ });
586
+ checks.push({
587
+ name: 'index-manifest',
588
+ ok: Boolean(index.manifestPath),
589
+ detail: index.lastError ? `${String(index.manifestPath)} lastError=${String(index.lastError)}` : String(index.manifestPath),
590
+ hint: index.manifestPath ? undefined : 'Run ihow-memory reindex to create the local index manifest.',
591
+ severity: index.manifestPath ? 'info' : 'error',
592
+ required: true
593
+ });
594
+ checks.push({
595
+ name: 'cloud',
596
+ ok: provider.cloud === false && sync.enabled === false,
597
+ detail: 'disabled / local only',
598
+ hint: provider.cloud ? 'Disable cloud provider for this local-first proof.' : undefined,
599
+ severity: provider.cloud === false && sync.enabled === false ? 'info' : 'error',
600
+ required: true
601
+ });
602
+ } else {
603
+ checks.push({
604
+ name: 'engine',
605
+ ok: false,
606
+ detail: 'skipped because node/sqlite/memory-root preflight did not pass',
607
+ hint: 'Fix the failed preflight checks above, then rerun ihow-memory doctor.',
608
+ severity: 'warning',
609
+ required: false
610
+ });
611
+ }
612
+ return {
613
+ ok: checks.every((check)=>check.ok || check.required === false),
614
+ checks,
615
+ status
616
+ };
617
+ }
618
+ async function packageInfo() {
619
+ try {
620
+ const raw = await fs.readFile(path.join(packageDir(), 'package.json'), 'utf8');
621
+ const parsed = JSON.parse(raw);
622
+ return {
623
+ name: parsed.name || 'ihow-memory-core',
624
+ version: parsed.version || 'unknown'
625
+ };
626
+ } catch {
627
+ return {
628
+ name: 'ihow-memory-core',
629
+ version: 'unknown'
630
+ };
631
+ }
632
+ }
633
+ async function diagnosticReport(result, options = {}) {
634
+ const sanitized = sanitizeDoctorResult(result, options);
635
+ const info = await packageInfo();
636
+ const provider = sanitized.status?.provider || {};
637
+ const sync = sanitized.status?.sync || {};
638
+ return {
639
+ schema: 'ihow-memory-diagnostics-v1',
640
+ diagnosticId: crypto.randomUUID(),
641
+ generatedAt: new Date().toISOString(),
642
+ package: info,
643
+ runtime: options.runtime || 'not-selected',
644
+ environment: {
645
+ node: process.versions.node,
646
+ platform: process.platform,
647
+ arch: process.arch
648
+ },
649
+ localOnly: {
650
+ cloud: provider.cloud === false ? 'disabled' : 'unknown',
651
+ sync: sync.enabled === false ? 'disabled' : 'unknown',
652
+ telemetry: await telemetry.isEnabled() ? 'opt-in (on)' : 'off (default)'
653
+ },
654
+ checks: sanitized.checks,
655
+ status: sanitized.status ? {
656
+ workspace: sanitized.status.workspace || {},
657
+ index: sanitized.status.index || {},
658
+ provider,
659
+ sync
660
+ } : undefined,
661
+ redaction: {
662
+ paths: 'redacted',
663
+ secrets: 'redacted',
664
+ fullMemoryContent: 'omitted'
665
+ }
666
+ };
667
+ }
668
+ function githubIssueUrl(body) {
669
+ const url = new URL('https://github.com/iHow1/ihow-memory-core/issues/new');
670
+ url.searchParams.set('title', '[Activation] ');
671
+ url.searchParams.set('body', body);
672
+ return url.toString();
673
+ }
674
+ async function feedbackTemplate(result, options = {}) {
675
+ const report = await diagnosticReport(result, options);
676
+ const body = `## What happened
677
+ [文案待 Commander]
678
+
679
+ ## What I expected
680
+ [文案待 Commander]
681
+
682
+ ## Steps to reproduce
683
+ 1. \`npx ihow-memory init\`
684
+ 2. \`ihow-memory doctor\`
685
+ 3. [文案待 Commander]
686
+
687
+ ## Runtime
688
+ - Runtime: ${options.runtime || 'not selected'}
689
+ - Node: ${process.versions.node}
690
+ - Package: ${report.package.name}@${report.package.version}
691
+
692
+ ## Redacted diagnostics
693
+ \`\`\`json
694
+ ${JSON.stringify(report, null, 2)}
695
+ \`\`\`
696
+ `;
697
+ return {
698
+ body,
699
+ url: githubIssueUrl(body)
700
+ };
701
+ }
702
+ async function resetSpace(options) {
703
+ if (!options.space) throw new Error('reset_requires_space');
704
+ if (options.memoryRoot) throw new Error('reset_managed_space_only_pass_root_and_space');
705
+ const workspace = resolveWorkspace(options);
706
+ await fs.rm(workspace.spaceDir, {
707
+ recursive: true,
708
+ force: true
709
+ });
710
+ return {
711
+ ok: true,
712
+ reset: {
713
+ space: workspace.space,
714
+ removed: workspace.spaceDir
715
+ }
716
+ };
717
+ }
718
+ async function runProof(options) {
719
+ const root = options.root ? path.resolve(options.root) : await fs.mkdtemp(path.join(os.tmpdir(), 'ihow-memory-proof-cli-'));
720
+ const space = options.space || 'proof-local';
721
+ const core = await openCore({
722
+ ...options,
723
+ root,
724
+ space
725
+ });
726
+ const marker = `blue-copper-river-${Date.now()}`;
727
+ const initialStatus = await core.status();
728
+ const candidate = await core.write_candidate({
729
+ title: 'agent-a-proof-memory',
730
+ text: `Agent A proof memory marker ${marker}. Local-only citation and audit demo.`,
731
+ sourceAgent: 'agent-a',
732
+ metadata: {
733
+ proof: 'ToC-1B',
734
+ cloud: false,
735
+ model: null
736
+ }
737
+ });
738
+ const promoted = await core.promote(candidate.path, {
739
+ scope: 'proof',
740
+ title: 'agent-a-proof-memory'
741
+ });
742
+ const agentB = await openCore({
743
+ ...options,
744
+ root,
745
+ space
746
+ });
747
+ const hits = await agentB.search(marker, {
748
+ limit: 5
749
+ });
750
+ if (hits.length === 0) throw new Error('proof_search_miss');
751
+ const read = await agentB.read(hits[0].path);
752
+ const finalStatus = await agentB.status();
753
+ const audit = await latestAuditSummary(agentB.workspace.eventsDir);
754
+ const result = {
755
+ ok: true,
756
+ cloud: 'disabled / local only',
757
+ workspace: {
758
+ root,
759
+ space,
760
+ path: agentB.workspace.spaceDir
761
+ },
762
+ initialStatus: {
763
+ provider: initialStatus.provider,
764
+ index: initialStatus.index
765
+ },
766
+ agentA: {
767
+ candidate,
768
+ promoted
769
+ },
770
+ agentB: {
771
+ query: marker,
772
+ hit: hits[0],
773
+ read: {
774
+ path: read.path,
775
+ citation: read.citation,
776
+ containsMarker: read.content.includes(marker)
777
+ }
778
+ },
779
+ audit,
780
+ finalStatus: {
781
+ provider: finalStatus.provider,
782
+ index: finalStatus.index
783
+ }
784
+ };
785
+ if (!options.root && process.env.IHOW_MEMORY_KEEP_PROOF !== '1') {
786
+ await fs.rm(root, {
787
+ recursive: true,
788
+ force: true
789
+ });
790
+ }
791
+ return result;
792
+ }
793
+ async function maybeAskTelemetry() {
794
+ if (await telemetry.hasAsked()) return;
795
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
796
+ console.log('(想匿名帮我们改进? 跑 `ihow-memory telemetry on` —— 只报使用、绝不含记忆内容)');
797
+ await telemetry.markAsked();
798
+ return;
799
+ }
800
+ const readline = await import('node:readline');
801
+ const rl = readline.createInterface({
802
+ input: process.stdin,
803
+ output: process.stdout
804
+ });
805
+ const answer = await new Promise((resolve)=>{
806
+ rl.question('\n帮我们改进?(可选)\n ✓ 只报: 何时用了 · 接哪个 agent · 版本 · 报错类型\n ✗ 绝不报: 你的记忆 / 文件 / 项目 — 一字不传\n 随时关: ihow-memory telemetry off\n 参与匿名上报? [y/N] › ', (a)=>resolve(a));
807
+ });
808
+ rl.close();
809
+ const yes = /^y(es)?$/i.test(answer.trim());
810
+ await telemetry.setEnabled(yes);
811
+ console.log(yes ? '✓ 已开启,谢谢!(随时 `ihow-memory telemetry off` 关闭)' : '已跳过,遥测保持关闭。');
812
+ }
813
+ async function main() {
814
+ const parsed = parseArgs(process.argv.slice(2));
815
+ const { command, options, rest } = parsed;
816
+ if (command === 'help' || command === '--help' || command === '-h') {
817
+ help();
818
+ return;
819
+ }
820
+ if (command === 'init') {
821
+ const workspace = await ensureWorkspace(resolveWorkspace(options));
822
+ const runtimeDir = await installRuntimeBundle(workspace);
823
+ const snippet = runtimeConfigSnippet(workspace, options.runtime);
824
+ const result = {
825
+ ok: true,
826
+ workspace: {
827
+ root: workspace.root,
828
+ space: workspace.space,
829
+ path: workspace.spaceDir,
830
+ mode: workspace.mode,
831
+ memoryRoot: workspace.memoryDir
832
+ },
833
+ runtime: options.runtime || 'generic',
834
+ runtimeDir,
835
+ backupBeforeWrite: initBackupGuidance(options.runtime),
836
+ mcpConfig: snippet
837
+ };
838
+ if (options.json) printJson(result);
839
+ else {
840
+ console.log('cloud: disabled / local only');
841
+ console.log(`initialized: ${workspace.spaceDir}`);
842
+ console.log(`mode: ${workspace.mode}`);
843
+ console.log(`memory root: ${workspace.memoryDir}`);
844
+ console.log(`runtime bundle: ${runtimeDir}`);
845
+ console.log(`backup first: ${result.backupBeforeWrite}`);
846
+ printRuntimeSnippet(snippet, options.runtime);
847
+ }
848
+ return;
849
+ }
850
+ if (command === 'connect') {
851
+ if (!options.runtime) {
852
+ console.error('connect requires --runtime claude-code|codex|cursor');
853
+ process.exitCode = 1;
854
+ return;
855
+ }
856
+ const workspace = await ensureWorkspace(resolveWorkspace(options));
857
+ if (!options.dryRun) await installRuntimeBundle(workspace);
858
+ const result = await connectRuntime(workspace, options.runtime, {
859
+ dryRun: options.dryRun
860
+ });
861
+ if (options.json) printJson(result);
862
+ else {
863
+ console.log('cloud: disabled / local only');
864
+ if (result.dryRun) {
865
+ const where = result.method === 'direct-json' ? String(result.target) : `${result.method} (already present: ${result.alreadyExists})`;
866
+ console.log(`[dry-run] would register mcpServers.ihow-memory via ${where}`);
867
+ } else {
868
+ console.log(`✓ connected ${runtimeLabel(options.runtime)} → iHow Memory`);
869
+ console.log(`method: ${result.method}`);
870
+ if (result.target) console.log(`target: ${result.target}`);
871
+ if (result.backup) console.log(`backup: ${result.backup}`);
872
+ if (result.replaced) console.log('(replaced an existing ihow-memory entry)');
873
+ console.log(`Restart ${runtimeLabel(options.runtime)} to load the memory tools.`);
874
+ }
875
+ }
876
+ if (!result.dryRun) {
877
+ await telemetry.track('connect', {
878
+ runtime: options.runtime
879
+ });
880
+ await maybeAskTelemetry();
881
+ }
882
+ return;
883
+ }
884
+ if (command === 'status') {
885
+ const core = await openCore(options);
886
+ const status = await core.status();
887
+ if (options.json) printJson(status);
888
+ else {
889
+ console.log(`workspace: ${status.workspace.path}`);
890
+ console.log(`space: ${status.workspace.space}`);
891
+ console.log(`mode: ${status.workspace.mode}`);
892
+ console.log(`memory root: ${status.workspace.memoryRoot}`);
893
+ console.log(`provider: ${status.provider.id} (ready=${status.provider.ready}, cloud=${status.provider.cloud}, model=${status.provider.model})`);
894
+ if (status.provider.fallback) {
895
+ console.log(`fallback: ${status.provider.fallbackFrom} -> fts (${status.provider.lastError})`);
896
+ }
897
+ console.log(`index: ${status.index.status}, documents=${status.index.documents}`);
898
+ console.log(`index path: ${status.index.path}`);
899
+ console.log(`sync: enabled=${status.sync.enabled}`);
900
+ }
901
+ return;
902
+ }
903
+ if (command === 'doctor') {
904
+ const result = await doctor(options);
905
+ const output = options.shareDiagnostics ? await diagnosticReport(result, options) : result;
906
+ if (options.json || options.shareDiagnostics) printJson(output);
907
+ else {
908
+ console.log(`doctor: ${result.ok ? 'ok' : 'failed'}`);
909
+ console.log('cloud: disabled / local only');
910
+ for (const check of result.checks){
911
+ const label = check.ok ? 'ok' : check.required === false ? 'action' : 'fail';
912
+ console.log(`- ${label} ${check.name}: ${check.detail}`);
913
+ if (check.hint) console.log(` hint: ${check.hint}`);
914
+ }
915
+ }
916
+ process.exitCode = result.ok ? 0 : 1;
917
+ return;
918
+ }
919
+ if (command === 'feedback') {
920
+ const result = await doctor(options);
921
+ const feedback = await feedbackTemplate(result, options);
922
+ if (options.json) printJson(feedback);
923
+ else {
924
+ console.log('No issue was submitted. Review the redacted template, then open the URL yourself.');
925
+ console.log('\nGitHub issue URL:');
926
+ console.log(feedback.url);
927
+ console.log('\nPrefilled issue body:');
928
+ console.log(feedback.body);
929
+ }
930
+ return;
931
+ }
932
+ if (command === 'telemetry') {
933
+ const sub = process.argv[3];
934
+ if (sub === 'on') {
935
+ await telemetry.setEnabled(true);
936
+ console.log('✓ 匿名遥测已开启(只报使用、绝不含记忆内容)。');
937
+ return;
938
+ }
939
+ if (sub === 'off') {
940
+ await telemetry.setEnabled(false);
941
+ console.log('✓ 匿名遥测已关闭。');
942
+ return;
943
+ }
944
+ const st = await telemetry.status();
945
+ if (options.json) printJson(st);
946
+ else {
947
+ console.log(`telemetry: ${st.enabled ? 'on' : 'off (default)'}`);
948
+ console.log(`collects: ${st.collects.join(' · ')}`);
949
+ console.log(`never collects: ${st.neverCollects.join(' · ')}`);
950
+ console.log(`endpoint: ${st.endpoint}`);
951
+ }
952
+ return;
953
+ }
954
+ if (command === 'reset') {
955
+ const result = await resetSpace(options);
956
+ if (options.json) printJson(result);
957
+ else {
958
+ console.log(`reset complete: ${result.reset.space}`);
959
+ console.log(`removed demo workspace: ${result.reset.removed}`);
960
+ }
961
+ return;
962
+ }
963
+ if (command === 'proof') {
964
+ const result = await runProof(options);
965
+ if (options.json) printJson(result);
966
+ else {
967
+ console.log('iHow Memory 10-second proof');
968
+ console.log('cloud: disabled / local only');
969
+ console.log(`workspace: ${result.workspace.path}`);
970
+ console.log(`agent A wrote candidate: ${result.agentA.candidate.path}`);
971
+ console.log(`agent A promoted: ${result.agentA.promoted.path}`);
972
+ const hit = result.agentB.hit;
973
+ const citation = hit.citation;
974
+ console.log(`agent B search hit: ${hit.path}`);
975
+ console.log(`citation: ${citation.path}`);
976
+ console.log(`source: ${hit.source}`);
977
+ if (hit.fallback) {
978
+ const fallback = hit.fallback;
979
+ console.log(`fallback: ${fallback.from} -> ${fallback.to} (${fallback.reason})`);
980
+ }
981
+ console.log(`read contains marker: ${result.agentB.read.containsMarker}`);
982
+ const audit = result.audit;
983
+ const event = audit?.event;
984
+ console.log(`audit event: ${event?.type || 'missing'} ${event?.id || ''}`);
985
+ console.log('PASS proof: A write -> promote -> B search/read with citation and audit');
986
+ }
987
+ return;
988
+ }
989
+ if (command === 'console') {
990
+ const { createConsoleServer } = await import('./http/console.js');
991
+ const argv = process.argv.slice(2);
992
+ const hostIdx = argv.indexOf('--host');
993
+ const portIdx = argv.indexOf('--port');
994
+ const host = hostIdx >= 0 && argv[hostIdx + 1] ? argv[hostIdx + 1] : '127.0.0.1';
995
+ const port = portIdx >= 0 && argv[portIdx + 1] ? Number(argv[portIdx + 1]) : 8788;
996
+ const server = await createConsoleServer(options);
997
+ server.listen(port, host, ()=>{
998
+ console.log('cloud: disabled / local only');
999
+ console.log(`iHow Memory console (read-only): http://${host}:${port}`);
1000
+ console.log('Open the URL in a browser. Ctrl+C to stop.');
1001
+ });
1002
+ return;
1003
+ }
1004
+ const core = await openCore(options);
1005
+ if (command === 'reindex') {
1006
+ const documents = await core.rebuild();
1007
+ const status = await core.status();
1008
+ const result = {
1009
+ ok: true,
1010
+ documents,
1011
+ index: status.index
1012
+ };
1013
+ if (options.json) printJson(result);
1014
+ else {
1015
+ console.log(`reindexed: documents=${documents}`);
1016
+ console.log(`index: ${status.index.path}`);
1017
+ }
1018
+ return;
1019
+ }
1020
+ if (command === 'search') {
1021
+ const query = rest.join(' ');
1022
+ printJson(await core.search(query, {
1023
+ limit: options.limit
1024
+ }));
1025
+ return;
1026
+ }
1027
+ if (command === 'read') {
1028
+ printJson(await core.read(rest[0]));
1029
+ return;
1030
+ }
1031
+ if (command === 'write-candidate') {
1032
+ printJson(await core.write_candidate({
1033
+ text: rest.join(' '),
1034
+ sourceAgent: 'cli'
1035
+ }));
1036
+ return;
1037
+ }
1038
+ if (command === 'promote') {
1039
+ const candidate = rest[0];
1040
+ const target = {};
1041
+ for(let index = 1; index < rest.length; index += 1){
1042
+ if (rest[index] === '--scope') target.scope = rest[++index];
1043
+ else if (rest[index] === '--title') target.title = rest[++index];
1044
+ }
1045
+ printJson(await core.promote(candidate, target));
1046
+ return;
1047
+ }
1048
+ if (command === 'durable-promote') {
1049
+ const candidate = rest[0];
1050
+ const target = {};
1051
+ for(let index = 1; index < rest.length; index += 1){
1052
+ if (rest[index] === '--scope') target.scope = rest[++index];
1053
+ else if (rest[index] === '--title') target.title = rest[++index];
1054
+ else if (rest[index] === '--path') target.path = rest[++index];
1055
+ }
1056
+ printJson(await core.durable_promote(candidate, {
1057
+ dryRun: options.dryRun,
1058
+ realWrite: options.realWrite,
1059
+ actor: options.actor || 'cli',
1060
+ target
1061
+ }));
1062
+ return;
1063
+ }
1064
+ help();
1065
+ process.exitCode = 1;
1066
+ }
1067
+ main().catch((error)=>{
1068
+ const message = error instanceof Error ? error.message : String(error);
1069
+ if (message === 'reset_requires_space') {
1070
+ console.error('reset requires an explicit demo space: ihow-memory reset --space <id> [--root <dir>]');
1071
+ } else if (message === 'reset_managed_space_only_pass_root_and_space') {
1072
+ console.error('reset only removes managed demo spaces. Use --root and --space; existing --memory-root data is never deleted.');
1073
+ } else if (message === 'unsupported_runtime_use_claude-code_codex_or_cursor') {
1074
+ console.error('unsupported runtime. Use --runtime claude-code, --runtime codex, or --runtime cursor.');
1075
+ } else if (message.startsWith('sqlite_unavailable:')) {
1076
+ console.error('SQLite is unavailable. Install Node >= 22.12 with node:sqlite support, then rerun ihow-memory doctor.');
1077
+ } else {
1078
+ console.error(friendlyError(error));
1079
+ }
1080
+ process.exitCode = 1;
1081
+ });
1082
+
1083
+
1084
+ //# sourceURL=cli.ts