@membank/cli 0.2.0 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +13 -5
  2. package/dist/index.mjs +282 -353
  3. package/package.json +4 -3
package/README.md CHANGED
@@ -33,7 +33,7 @@ Options:
33
33
  --json Machine-readable output
34
34
  ```
35
35
 
36
- Supported harnesses: `claude-code`, `copilot`, `codex`, `opencode`
36
+ Supported harnesses: `claude-code`, `copilot`, `codex`, `opencode` (see `membank setup` for harness-specific setup instructions)
37
37
 
38
38
  ## Commands
39
39
 
@@ -125,6 +125,16 @@ Output session context formatted for a harness. Called automatically by session
125
125
  membank inject --harness claude-code --scope <project-scope>
126
126
  ```
127
127
 
128
+ ### `membank dashboard`
129
+
130
+ Start the web dashboard for browsing and managing memories.
131
+
132
+ ```bash
133
+ membank dashboard
134
+ ```
135
+
136
+ Opens http://localhost:3847 by default. Features: full-text search, filtering by type/scope/pin status, edit memory metadata, view dedup reviews, and storage statistics.
137
+
128
138
  ## Global flags
129
139
 
130
140
  ```
@@ -143,11 +153,9 @@ Starts the stdio MCP server. This is what harnesses connect to — `setup` write
143
153
 
144
154
  ## Session hooks
145
155
 
146
- `setup` installs two hooks:
147
-
148
- **Session start** — calls `membank inject` to prepend pinned memories into the LLM context at the beginning of every session.
156
+ `setup` installs a hook for Claude Code that injects memories at the start of each session:
149
157
 
150
- **Session stop (Claude Code only)** — prompts the LLM to review the session and call `save_memory` for any notable corrections, preferences, or decisions.
158
+ **SessionStart** — calls `membank inject` to prepend pinned memories into the LLM context at the beginning of every session.
151
159
 
152
160
  ## Requirements
153
161
 
package/dist/index.mjs CHANGED
@@ -2,11 +2,12 @@
2
2
  import { DatabaseManager, EmbeddingService, MEMORY_TYPE_VALUES, MemoryRepository, QueryEngine, SessionContextBuilder, resolveScope } from "@membank/core";
3
3
  import { startServer } from "@membank/mcp";
4
4
  import { Command } from "commander";
5
+ import { startDashboard } from "@membank/dashboard";
5
6
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
6
7
  import { dirname, join } from "node:path";
7
- import { homedir, tmpdir } from "node:os";
8
8
  import * as readline from "node:readline";
9
9
  import { createInterface } from "node:readline";
10
+ import { homedir, tmpdir } from "node:os";
10
11
  import { execFile } from "node:child_process";
11
12
  import { promisify } from "node:util";
12
13
  import { EventEmitter } from "node:events";
@@ -30,6 +31,11 @@ async function addCommand(content, options, formatter, db, embeddingService) {
30
31
  }
31
32
  }
32
33
  //#endregion
34
+ //#region src/commands/dashboard.ts
35
+ async function dashboardCommand(opts) {
36
+ await startDashboard({ port: opts.port !== void 0 ? parseInt(opts.port, 10) : void 0 });
37
+ }
38
+ //#endregion
33
39
  //#region src/commands/delete.ts
34
40
  async function deleteCommand(id, db, formatter, prompt) {
35
41
  if (db.db.prepare(`SELECT id FROM memories WHERE id = ?`).get(id) === void 0) {
@@ -128,7 +134,7 @@ async function importCommand(filePath, db, formatter, prompt) {
128
134
  }
129
135
  //#endregion
130
136
  //#region src/commands/inject.ts
131
- const MEMORY_GUIDANCE = "[Memory Guidance]: query_memory before answering on topics where past preferences, corrections, or decisions may apply; save_memory when user corrects you, states a preference, makes a decision, or shares something worth retaining across sessions; update_memory to refine an existing memory (query first to find it) or to set pinned=true/false; delete_memory when a memory is wrong or no longer relevant; pin high-value memories that should always appear at session start";
137
+ const MEMORY_GUIDANCE = "[Memory Guidance]: Persistent memory is available via query_memory, save_memory, update_memory, delete_memory. Skipping save_memory when the user gives a correction or preference means they have to repeat themselves next session that is the failure mode to avoid. Skipping query_memory on topics that touch prior decisions means contradicting yourself. Default to saving (type: correction|preference|decision|learning|fact) when in doubt; rely on dedup to handle redundancy. Pin anything that should appear at every session start.";
132
138
  function formatContext(ctx) {
133
139
  const lines = [];
134
140
  const statParts = Object.entries(ctx.stats).filter(([, count]) => count > 0).map(([type, count]) => `${count} ${type}${count !== 1 ? "s" : ""}`);
@@ -140,20 +146,10 @@ function formatContext(ctx) {
140
146
  lines.push(MEMORY_GUIDANCE);
141
147
  return lines.join("\n");
142
148
  }
143
- async function injectCommand(opts) {
144
- const projectScope = opts.scope ?? await resolveScope();
145
- const db = DatabaseManager.open();
146
- let text;
147
- try {
148
- text = formatContext(new SessionContextBuilder(db).getSessionContext(projectScope));
149
- } finally {
150
- db.close();
151
- }
152
- if (!text) process.exit(0);
153
- const harness = opts.harness;
149
+ function outputAdditionalContext(text, harness, eventName) {
154
150
  if (harness === "claude-code") {
155
151
  process.stdout.write(JSON.stringify({ hookSpecificOutput: {
156
- hookEventName: "SessionStart",
152
+ hookEventName: eventName,
157
153
  additionalContext: text
158
154
  } }));
159
155
  return;
@@ -164,6 +160,23 @@ async function injectCommand(opts) {
164
160
  }
165
161
  process.stdout.write(`${text}\n`);
166
162
  }
163
+ async function handleSessionStart(opts) {
164
+ const projectScope = opts.scope ?? await resolveScope();
165
+ const db = DatabaseManager.open();
166
+ let text;
167
+ try {
168
+ text = formatContext(new SessionContextBuilder(db).getSessionContext(projectScope));
169
+ } finally {
170
+ db.close();
171
+ }
172
+ if (!text) process.exit(0);
173
+ const harness = opts.harness;
174
+ outputAdditionalContext(text, harness, "SessionStart");
175
+ }
176
+ async function injectCommand(opts) {
177
+ if (opts.event !== void 0 && opts.event !== "session-start") process.exit(0);
178
+ await handleSessionStart(opts);
179
+ }
167
180
  //#endregion
168
181
  //#region src/commands/list.ts
169
182
  async function listCommand(options, formatter) {
@@ -225,303 +238,6 @@ async function statsCommand(formatter) {
225
238
  }
226
239
  }
227
240
  //#endregion
228
- //#region src/setup/injection-hook-writer.ts
229
- const defaultPathResolver$1 = { home: () => {
230
- const h = process.env.HOME ?? process.env.USERPROFILE;
231
- if (!h) throw new Error("Cannot determine home directory");
232
- return h;
233
- } };
234
- function readJson$1(path) {
235
- try {
236
- return JSON.parse(readFileSync(path, "utf8"));
237
- } catch {
238
- return {};
239
- }
240
- }
241
- function writeJsonAtomic$1(path, data) {
242
- mkdirSync(dirname(path), { recursive: true });
243
- const tmp = join(mkdtempSync(join(tmpdir(), "membank-hook-")), "cfg.json");
244
- writeFileSync(tmp, JSON.stringify(data, null, 2));
245
- renameSync(tmp, path);
246
- }
247
- function getHooksArray(group) {
248
- if (typeof group !== "object" || group === null) return [];
249
- const h = group.hooks;
250
- return Array.isArray(h) ? h : [];
251
- }
252
- function findMembankHookCommand(hooks, pattern) {
253
- for (const h of hooks) {
254
- if (typeof h !== "object" || h === null) continue;
255
- if ("command" in h && typeof h.command === "string" && h.command.includes(pattern)) return h.command;
256
- if ("bash" in h && typeof h.bash === "string" && h.bash.includes(pattern)) return h.bash;
257
- }
258
- return "";
259
- }
260
- function containsMembankInject(hooks) {
261
- return findMembankHookCommand(hooks, "@membank/cli inject") !== "";
262
- }
263
- function extractInjectCommand(hooks) {
264
- return findMembankHookCommand(hooks, "@membank/cli inject");
265
- }
266
- const writers$1 = {
267
- "claude-code": {
268
- replacement: "npx @membank/cli inject --harness claude-code",
269
- write(resolver, overwrite = false) {
270
- const cfgPath = join(resolver.home(), ".claude", "settings.json");
271
- const cfg = readJson$1(cfgPath);
272
- const hooks = cfg.hooks;
273
- const existingGroups = Array.isArray(hooks?.SessionStart) ? hooks.SessionStart : [];
274
- const innerHooks = existingGroups.flatMap(getHooksArray);
275
- if (!overwrite && containsMembankInject(innerHooks)) return {
276
- status: "already-configured",
277
- existing: extractInjectCommand(innerHooks),
278
- replacement: this.replacement
279
- };
280
- const filteredGroups = overwrite ? existingGroups.filter((g) => !containsMembankInject(getHooksArray(g))) : existingGroups;
281
- writeJsonAtomic$1(cfgPath, {
282
- ...cfg,
283
- hooks: {
284
- ...hooks ?? {},
285
- SessionStart: [...filteredGroups, {
286
- matcher: "",
287
- hooks: [{
288
- type: "command",
289
- command: this.replacement
290
- }]
291
- }]
292
- }
293
- });
294
- return { status: "written" };
295
- }
296
- },
297
- "copilot-cli": {
298
- replacement: "npx @membank/cli inject --harness copilot-cli",
299
- write(resolver, overwrite = false) {
300
- const cfgPath = join(resolver.home(), ".copilot", "settings.json");
301
- const cfg = readJson$1(cfgPath);
302
- const hooks = cfg.hooks;
303
- const existingHooks = Array.isArray(hooks?.sessionStart) ? hooks.sessionStart : [];
304
- if (!overwrite && containsMembankInject(existingHooks)) return {
305
- status: "already-configured",
306
- existing: extractInjectCommand(existingHooks),
307
- replacement: this.replacement
308
- };
309
- const filteredHooks = overwrite ? existingHooks.filter((h) => !containsMembankInject([h])) : existingHooks;
310
- writeJsonAtomic$1(cfgPath, {
311
- version: cfg.version ?? 1,
312
- ...cfg,
313
- hooks: {
314
- ...hooks ?? {},
315
- sessionStart: [...filteredHooks, {
316
- type: "command",
317
- bash: this.replacement,
318
- timeoutSec: 30
319
- }]
320
- }
321
- });
322
- return { status: "written" };
323
- }
324
- },
325
- codex: {
326
- replacement: "npx @membank/cli inject --harness codex",
327
- write(resolver, overwrite = false) {
328
- const cfgPath = join(resolver.home(), ".codex", "hooks.json");
329
- const cfg = readJson$1(cfgPath);
330
- const hooks = cfg.hooks;
331
- const existingGroups = Array.isArray(hooks?.SessionStart) ? hooks.SessionStart : [];
332
- const innerHooks = existingGroups.flatMap(getHooksArray);
333
- if (!overwrite && containsMembankInject(innerHooks)) return {
334
- status: "already-configured",
335
- existing: extractInjectCommand(innerHooks),
336
- replacement: this.replacement
337
- };
338
- const filteredGroups = overwrite ? existingGroups.filter((g) => !containsMembankInject(getHooksArray(g))) : existingGroups;
339
- writeJsonAtomic$1(cfgPath, {
340
- ...cfg,
341
- hooks: {
342
- ...hooks ?? {},
343
- SessionStart: [...filteredGroups, {
344
- matcher: "",
345
- hooks: [{
346
- type: "command",
347
- command: this.replacement,
348
- timeout: 30
349
- }]
350
- }]
351
- }
352
- });
353
- return { status: "written" };
354
- }
355
- },
356
- opencode: {
357
- replacement: "npx @membank/cli inject",
358
- write(resolver, overwrite = false) {
359
- const pluginPath = join(resolver.home(), ".config", "opencode", "plugins", "membank.js");
360
- if (!overwrite && existsSync(pluginPath)) {
361
- const existing = readFileSync(pluginPath, "utf8");
362
- if (existing.includes("@membank/cli inject")) return {
363
- status: "already-configured",
364
- existing: existing.trim(),
365
- replacement: newOpencodePlugin()
366
- };
367
- }
368
- mkdirSync(dirname(pluginPath), { recursive: true });
369
- writeFileSync(pluginPath, `${newOpencodePlugin()}\n`, "utf8");
370
- return { status: "written" };
371
- }
372
- }
373
- };
374
- function newOpencodePlugin(includeIdle = false) {
375
- return [
376
- "export default {",
377
- " hooks: {",
378
- " \"session.start\": async ({ $ }) => {",
379
- " return await $`npx @membank/cli inject`.text();",
380
- " },",
381
- ...includeIdle ? [
382
- " \"session.idle\": async ({ $ }) => {",
383
- " return await $`npx @membank/cli stop-hook --harness opencode`.text();",
384
- " },"
385
- ] : [],
386
- " },",
387
- "};"
388
- ].join("\n");
389
- }
390
- const STOP_HOOK_PROMPT = "Review this session and consider whether any user preferences, corrections, decisions, or learnings are worth saving for future sessions. If so, use the save_memory MCP tool to store them. Be selective — only save what would genuinely help in a future conversation. Skip ephemeral task details.";
391
- function containsMembankStopHookCmd(hooks) {
392
- return findMembankHookCommand(hooks, "@membank/cli stop-hook") !== "";
393
- }
394
- function extractStopHookCmd(hooks) {
395
- return findMembankHookCommand(hooks, "@membank/cli stop-hook");
396
- }
397
- function containsMembankStopPrompt(stopGroups) {
398
- return stopGroups.some((g) => getHooksArray(g).some((h) => typeof h === "object" && h !== null && "type" in h && h.type === "prompt" && "prompt" in h && typeof h.prompt === "string" && h.prompt.includes("save_memory")));
399
- }
400
- function extractStopPrompt(stopGroups) {
401
- for (const g of stopGroups) for (const h of getHooksArray(g)) if (typeof h === "object" && h !== null && "type" in h && h.type === "prompt" && "prompt" in h && typeof h.prompt === "string" && h.prompt.includes("save_memory")) return h.prompt;
402
- return "";
403
- }
404
- const stopHookWriters = {
405
- "claude-code": { write(resolver, overwrite = false) {
406
- const cfgPath = join(resolver.home(), ".claude", "settings.json");
407
- const cfg = readJson$1(cfgPath);
408
- const hooks = cfg.hooks;
409
- const existingStop = Array.isArray(hooks?.Stop) ? hooks.Stop : [];
410
- if (!overwrite && containsMembankStopPrompt(existingStop)) return {
411
- status: "already-configured",
412
- existing: extractStopPrompt(existingStop),
413
- replacement: STOP_HOOK_PROMPT
414
- };
415
- const filteredStop = overwrite ? existingStop.filter((g) => !containsMembankStopPrompt([g])) : existingStop;
416
- writeJsonAtomic$1(cfgPath, {
417
- ...cfg,
418
- hooks: {
419
- ...hooks ?? {},
420
- Stop: [...filteredStop, { hooks: [{
421
- type: "prompt",
422
- prompt: STOP_HOOK_PROMPT
423
- }] }]
424
- }
425
- });
426
- return { status: "written" };
427
- } },
428
- "copilot-cli": { write(resolver, overwrite = false) {
429
- const cfgPath = join(resolver.home(), ".copilot", "settings.json");
430
- const cfg = readJson$1(cfgPath);
431
- const replacement = "npx @membank/cli stop-hook --harness copilot-cli";
432
- const hooks = cfg.hooks;
433
- const existingStop = Array.isArray(hooks?.stop) ? hooks.stop : [];
434
- if (!overwrite && containsMembankStopHookCmd(existingStop)) return {
435
- status: "already-configured",
436
- existing: extractStopHookCmd(existingStop),
437
- replacement
438
- };
439
- const filteredStop = overwrite ? existingStop.filter((h) => !containsMembankStopHookCmd([h])) : existingStop;
440
- writeJsonAtomic$1(cfgPath, {
441
- version: cfg.version ?? 1,
442
- ...cfg,
443
- hooks: {
444
- ...hooks ?? {},
445
- stop: [...filteredStop, {
446
- type: "command",
447
- bash: replacement,
448
- timeoutSec: 30
449
- }]
450
- }
451
- });
452
- return { status: "written" };
453
- } },
454
- codex: { write(resolver, overwrite = false) {
455
- const cfgPath = join(resolver.home(), ".codex", "hooks.json");
456
- const cfg = readJson$1(cfgPath);
457
- const replacement = "npx @membank/cli stop-hook --harness codex";
458
- const hooks = cfg.hooks;
459
- const existingGroups = Array.isArray(hooks?.Stop) ? hooks.Stop : [];
460
- const innerHooks = existingGroups.flatMap(getHooksArray);
461
- if (!overwrite && containsMembankStopHookCmd(innerHooks)) return {
462
- status: "already-configured",
463
- existing: extractStopHookCmd(innerHooks),
464
- replacement
465
- };
466
- const filteredGroups = overwrite ? existingGroups.filter((g) => !containsMembankStopHookCmd(getHooksArray(g))) : existingGroups;
467
- writeJsonAtomic$1(cfgPath, {
468
- ...cfg,
469
- hooks: {
470
- ...hooks ?? {},
471
- Stop: [...filteredGroups, {
472
- matcher: "",
473
- hooks: [{
474
- type: "command",
475
- command: replacement,
476
- timeout: 30
477
- }]
478
- }]
479
- }
480
- });
481
- return { status: "written" };
482
- } },
483
- opencode: { write(resolver, overwrite = false) {
484
- const pluginPath = join(resolver.home(), ".config", "opencode", "plugins", "membank.js");
485
- if (!overwrite && existsSync(pluginPath)) {
486
- const existing = readFileSync(pluginPath, "utf8");
487
- if (existing.includes("@membank/cli stop-hook")) return {
488
- status: "already-configured",
489
- existing: existing.trim(),
490
- replacement: newOpencodePlugin(true)
491
- };
492
- }
493
- mkdirSync(dirname(pluginPath), { recursive: true });
494
- writeFileSync(pluginPath, `${newOpencodePlugin(true)}\n`, "utf8");
495
- return { status: "written" };
496
- } }
497
- };
498
- var InjectionHookWriter = class {
499
- #resolver;
500
- constructor(resolver = defaultPathResolver$1) {
501
- this.#resolver = resolver;
502
- }
503
- write(harness, overwrite) {
504
- const writer = writers$1[harness];
505
- if (!writer) return { status: "not-supported" };
506
- return writer.write(this.#resolver, overwrite);
507
- }
508
- writeStopHook(harness, overwrite) {
509
- const writer = stopHookWriters[harness];
510
- if (!writer) return { status: "not-supported" };
511
- return writer.write(this.#resolver, overwrite);
512
- }
513
- };
514
- //#endregion
515
- //#region src/commands/stop-hook.ts
516
- function stopHookCommand(opts) {
517
- const { harness } = opts;
518
- if (harness === "copilot-cli" || harness === "codex") {
519
- process.stdout.write(JSON.stringify({ systemMessage: STOP_HOOK_PROMPT }));
520
- return;
521
- }
522
- process.stdout.write(`${STOP_HOOK_PROMPT}\n`);
523
- }
524
- //#endregion
525
241
  //#region src/commands/unpin.ts
526
242
  function unpinCommand(id, formatter, db) {
527
243
  const ownDb = db === void 0;
@@ -668,7 +384,7 @@ async function execFileNoThrow(cmd, args) {
668
384
  }
669
385
  //#endregion
670
386
  //#region src/setup/harness-config-writer.ts
671
- const defaultPathResolver = {
387
+ const defaultPathResolver$1 = {
672
388
  home: () => {
673
389
  const h = process.env.HOME ?? process.env.USERPROFILE;
674
390
  if (!h) throw new Error("Cannot determine home directory");
@@ -676,14 +392,14 @@ const defaultPathResolver = {
676
392
  },
677
393
  cwd: () => process.cwd()
678
394
  };
679
- function readJson(path) {
395
+ function readJson$1(path) {
680
396
  try {
681
397
  return JSON.parse(readFileSync(path, "utf8"));
682
398
  } catch {
683
399
  return {};
684
400
  }
685
401
  }
686
- function writeJsonAtomic(path, data) {
402
+ function writeJsonAtomic$1(path, data) {
687
403
  mkdirSync(dirname(path), { recursive: true });
688
404
  const tmp = join(mkdtempSync(join(tmpdir(), "membank-")), "cfg.json");
689
405
  writeFileSync(tmp, JSON.stringify(data, null, 2));
@@ -700,9 +416,9 @@ const MEMBANK_NPX_ARGS = [
700
416
  "@membank/cli@latest",
701
417
  "--mcp"
702
418
  ];
703
- const writers = {
419
+ const writers$1 = {
704
420
  "claude-code": { async write(resolver, run, { overwrite = false } = {}) {
705
- const configured = hasKey(readJson(join(resolver.home(), ".claude.json")).mcpServers, "membank");
421
+ const configured = hasKey(readJson$1(join(resolver.home(), ".claude.json")).mcpServers, "membank");
706
422
  if (configured && !overwrite) return { status: "already-configured" };
707
423
  if (configured) {
708
424
  const remove = await run("claude", [
@@ -730,9 +446,9 @@ const writers = {
730
446
  } },
731
447
  copilot: { async write(resolver, _run, { overwrite = false } = {}) {
732
448
  const cfgPath = join(resolver.home(), ".copilot", "mcp-config.json");
733
- const cfg = readJson(cfgPath);
449
+ const cfg = readJson$1(cfgPath);
734
450
  if (hasKey(cfg.mcpServers, "membank") && !overwrite) return { status: "already-configured" };
735
- writeJsonAtomic(cfgPath, {
451
+ writeJsonAtomic$1(cfgPath, {
736
452
  ...cfg,
737
453
  mcpServers: {
738
454
  ...cfg.mcpServers,
@@ -771,9 +487,9 @@ const writers = {
771
487
  } },
772
488
  opencode: { async write(resolver, _run, { overwrite = false } = {}) {
773
489
  const cfgPath = join(resolver.home(), ".config", "opencode", "opencode.json");
774
- const cfg = readJson(cfgPath);
490
+ const cfg = readJson$1(cfgPath);
775
491
  if (hasKey(cfg.mcp, "membank") && !overwrite) return { status: "already-configured" };
776
- writeJsonAtomic(cfgPath, {
492
+ writeJsonAtomic$1(cfgPath, {
777
493
  ...cfg,
778
494
  mcp: {
779
495
  ...cfg.mcp,
@@ -790,21 +506,232 @@ const writers = {
790
506
  return { status: "written" };
791
507
  } }
792
508
  };
793
- const SUPPORTED_HARNESSES = Object.keys(writers);
509
+ const SUPPORTED_HARNESSES = Object.keys(writers$1);
794
510
  var HarnessConfigWriter = class {
795
511
  #resolver;
796
512
  #run;
797
- constructor(resolver = defaultPathResolver, run = execFileNoThrow) {
513
+ constructor(resolver = defaultPathResolver$1, run = execFileNoThrow) {
798
514
  this.#resolver = resolver;
799
515
  this.#run = run;
800
516
  }
801
517
  async write(harness, { overwrite = false } = {}) {
802
- const writer = writers[harness];
518
+ const writer = writers$1[harness];
803
519
  if (!writer) throw new Error(`Unknown harness: ${harness}`);
804
520
  return writer.write(this.#resolver, this.#run, { overwrite });
805
521
  }
806
522
  };
807
523
  //#endregion
524
+ //#region src/setup/injection-hook-writer.ts
525
+ const defaultPathResolver = { home: () => {
526
+ const h = process.env.HOME ?? process.env.USERPROFILE;
527
+ if (!h) throw new Error("Cannot determine home directory");
528
+ return h;
529
+ } };
530
+ function readJson(path) {
531
+ try {
532
+ return JSON.parse(readFileSync(path, "utf8"));
533
+ } catch {
534
+ return {};
535
+ }
536
+ }
537
+ function writeJsonAtomic(path, data) {
538
+ mkdirSync(dirname(path), { recursive: true });
539
+ const tmp = join(mkdtempSync(join(tmpdir(), "membank-hook-")), "cfg.json");
540
+ writeFileSync(tmp, JSON.stringify(data, null, 2));
541
+ renameSync(tmp, path);
542
+ }
543
+ function getHooksArray(group) {
544
+ if (typeof group !== "object" || group === null) return [];
545
+ const h = group.hooks;
546
+ return Array.isArray(h) ? h : [];
547
+ }
548
+ function findMembankHookCommand(hooks, pattern) {
549
+ for (const h of hooks) {
550
+ if (typeof h !== "object" || h === null) continue;
551
+ if ("command" in h && typeof h.command === "string" && h.command.includes(pattern)) return h.command;
552
+ if ("bash" in h && typeof h.bash === "string" && h.bash.includes(pattern)) return h.bash;
553
+ }
554
+ return "";
555
+ }
556
+ function containsMembankInject(hooks) {
557
+ return findMembankHookCommand(hooks, "@membank/cli") !== "";
558
+ }
559
+ function extractInjectCommand(hooks) {
560
+ return findMembankHookCommand(hooks, "@membank/cli");
561
+ }
562
+ function filterOutMembank(groups) {
563
+ return groups.filter((g) => !containsMembankInject(getHooksArray(g)));
564
+ }
565
+ function filterOutMembankFlat(hooks) {
566
+ return hooks.filter((h) => !containsMembankInject([h]));
567
+ }
568
+ function pruneNestedEvent(hooks, eventKey) {
569
+ const existing = hooks[eventKey];
570
+ if (!Array.isArray(existing)) return;
571
+ const cleaned = filterOutMembank(existing);
572
+ if (cleaned.length === 0) delete hooks[eventKey];
573
+ else hooks[eventKey] = cleaned;
574
+ }
575
+ function pruneFlatEvent(hooks, eventKey) {
576
+ const existing = hooks[eventKey];
577
+ if (!Array.isArray(existing)) return;
578
+ const cleaned = filterOutMembankFlat(existing);
579
+ if (cleaned.length === 0) delete hooks[eventKey];
580
+ else hooks[eventKey] = cleaned;
581
+ }
582
+ const writers = {
583
+ "claude-code": {
584
+ inspect(resolver) {
585
+ const hooks = readJson(join(resolver.home(), ".claude", "settings.json")).hooks ?? {};
586
+ return {
587
+ status: "ready",
588
+ hooks: [{
589
+ event: "SessionStart",
590
+ command: "npx @membank/cli@latest inject --harness claude-code",
591
+ existingCommand: extractInjectCommand((Array.isArray(hooks.SessionStart) ? hooks.SessionStart : []).flatMap(getHooksArray)) || null
592
+ }]
593
+ };
594
+ },
595
+ write(resolver, events) {
596
+ const cfgPath = join(resolver.home(), ".claude", "settings.json");
597
+ const cfg = readJson(cfgPath);
598
+ const hooks = cfg.hooks ?? {};
599
+ const newHooks = { ...hooks };
600
+ pruneNestedEvent(newHooks, "UserPromptSubmit");
601
+ pruneNestedEvent(newHooks, "PostToolUseFailure");
602
+ if (events.includes("SessionStart")) newHooks.SessionStart = [...filterOutMembank(Array.isArray(hooks.SessionStart) ? hooks.SessionStart : []), {
603
+ matcher: "",
604
+ hooks: [{
605
+ type: "command",
606
+ command: "npx @membank/cli@latest inject --harness claude-code"
607
+ }]
608
+ }];
609
+ writeJsonAtomic(cfgPath, {
610
+ ...cfg,
611
+ hooks: newHooks
612
+ });
613
+ return { status: "written" };
614
+ }
615
+ },
616
+ "copilot-cli": {
617
+ inspect(resolver) {
618
+ const hooks = readJson(join(resolver.home(), ".copilot", "settings.json")).hooks ?? {};
619
+ return {
620
+ status: "ready",
621
+ hooks: [{
622
+ event: "sessionStart",
623
+ command: "npx @membank/cli@latest inject --harness copilot-cli",
624
+ existingCommand: extractInjectCommand(Array.isArray(hooks.sessionStart) ? hooks.sessionStart : []) || null
625
+ }]
626
+ };
627
+ },
628
+ write(resolver, events) {
629
+ const cfgPath = join(resolver.home(), ".copilot", "settings.json");
630
+ const cfg = readJson(cfgPath);
631
+ const hooks = cfg.hooks ?? {};
632
+ const newHooks = { ...hooks };
633
+ pruneFlatEvent(newHooks, "userPromptSubmitted");
634
+ pruneFlatEvent(newHooks, "postToolUseFailure");
635
+ if (events.includes("sessionStart")) newHooks.sessionStart = [...filterOutMembankFlat(Array.isArray(hooks.sessionStart) ? hooks.sessionStart : []), {
636
+ type: "command",
637
+ bash: "npx @membank/cli@latest inject --harness copilot-cli",
638
+ timeoutSec: 30
639
+ }];
640
+ writeJsonAtomic(cfgPath, {
641
+ version: cfg.version ?? 1,
642
+ ...cfg,
643
+ hooks: newHooks
644
+ });
645
+ return { status: "written" };
646
+ }
647
+ },
648
+ codex: {
649
+ inspect(resolver) {
650
+ const hooks = readJson(join(resolver.home(), ".codex", "hooks.json")).hooks ?? {};
651
+ return {
652
+ status: "ready",
653
+ hooks: [{
654
+ event: "SessionStart",
655
+ command: "npx @membank/cli@latest inject --harness codex",
656
+ existingCommand: extractInjectCommand((Array.isArray(hooks.SessionStart) ? hooks.SessionStart : []).flatMap(getHooksArray)) || null
657
+ }]
658
+ };
659
+ },
660
+ write(resolver, events) {
661
+ const cfgPath = join(resolver.home(), ".codex", "hooks.json");
662
+ const cfg = readJson(cfgPath);
663
+ const hooks = cfg.hooks ?? {};
664
+ const newHooks = { ...hooks };
665
+ pruneNestedEvent(newHooks, "UserPromptSubmit");
666
+ pruneNestedEvent(newHooks, "PostToolUse");
667
+ if (events.includes("SessionStart")) newHooks.SessionStart = [...filterOutMembank(Array.isArray(hooks.SessionStart) ? hooks.SessionStart : []), {
668
+ matcher: "",
669
+ hooks: [{
670
+ type: "command",
671
+ command: "npx @membank/cli@latest inject --harness codex",
672
+ timeout: 30
673
+ }]
674
+ }];
675
+ writeJsonAtomic(cfgPath, {
676
+ ...cfg,
677
+ hooks: newHooks
678
+ });
679
+ return { status: "written" };
680
+ }
681
+ },
682
+ opencode: {
683
+ inspect(resolver) {
684
+ const pluginPath = join(resolver.home(), ".config", "opencode", "plugins", "membank.js");
685
+ let existingCommand = null;
686
+ if (existsSync(pluginPath)) {
687
+ if (readFileSync(pluginPath, "utf8").includes("@membank/cli")) existingCommand = pluginPath;
688
+ }
689
+ return {
690
+ status: "ready",
691
+ hooks: [{
692
+ event: "plugin",
693
+ command: pluginPath,
694
+ existingCommand
695
+ }]
696
+ };
697
+ },
698
+ write(resolver, events) {
699
+ if (events.length === 0) return { status: "written" };
700
+ const pluginPath = join(resolver.home(), ".config", "opencode", "plugins", "membank.js");
701
+ mkdirSync(dirname(pluginPath), { recursive: true });
702
+ writeFileSync(pluginPath, `${newOpencodePlugin()}\n`, "utf8");
703
+ return { status: "written" };
704
+ }
705
+ }
706
+ };
707
+ function newOpencodePlugin() {
708
+ return [
709
+ "export default {",
710
+ " hooks: {",
711
+ " \"session.start\": async ({ $ }) => {",
712
+ " return await $`npx @membank/cli@latest inject`.text();",
713
+ " },",
714
+ " },",
715
+ "};"
716
+ ].join("\n");
717
+ }
718
+ var InjectionHookWriter = class {
719
+ #resolver;
720
+ constructor(resolver = defaultPathResolver) {
721
+ this.#resolver = resolver;
722
+ }
723
+ inspect(harness) {
724
+ const writer = writers[harness];
725
+ if (!writer) return { status: "not-supported" };
726
+ return writer.inspect(this.#resolver);
727
+ }
728
+ write(harness, events) {
729
+ const writer = writers[harness];
730
+ if (!writer) return { status: "not-supported" };
731
+ return writer.write(this.#resolver, events);
732
+ }
733
+ };
734
+ //#endregion
808
735
  //#region src/setup/model-downloader.ts
809
736
  const MODEL_NAME = "Xenova/bge-small-en-v1.5";
810
737
  var ModelDownloadError = class extends Error {
@@ -954,7 +881,6 @@ var SetupOrchestrator = class {
954
881
  detectedHarnesses: [],
955
882
  configuredHarnesses: [],
956
883
  injectionHooksConfigured: [],
957
- stopHooksConfigured: [],
958
884
  modelDownloaded: false
959
885
  }));
960
886
  return [];
@@ -968,10 +894,7 @@ var SetupOrchestrator = class {
968
894
  out("Planned changes (dry-run — no files written):");
969
895
  for (const h of detected) {
970
896
  out(` ⚠ ${h.name}: would write MCP config`);
971
- if (this.#hookWriter) {
972
- out(` ⚠ ${h.name}: would write injection hook config`);
973
- out(` ⚠ ${h.name}: would write stop hook config`);
974
- }
897
+ if (this.#hookWriter) out(` ⚠ ${h.name}: would write injection hook config`);
975
898
  }
976
899
  out("");
977
900
  out(" ⚠ Model download: skipped (dry-run)");
@@ -1038,11 +961,8 @@ var SetupOrchestrator = class {
1038
961
  }
1039
962
  out("");
1040
963
  const injectionHooksConfigured = [];
1041
- const stopHooksConfigured = [];
1042
964
  if (this.#hookWriter) {
1043
- const w = this.#hookWriter;
1044
- injectionHooksConfigured.push(...await this.#runHookLoop(detected, "injection hook", (h, ow) => w.write(h, ow), yes, out));
1045
- stopHooksConfigured.push(...await this.#runHookLoop(detected, "stop hook", (h, ow) => w.writeStopHook(h, ow), yes, out));
965
+ injectionHooksConfigured.push(...await this.#runHookSetup(detected, yes, out));
1046
966
  out("");
1047
967
  }
1048
968
  let modelDownloaded = false;
@@ -1056,7 +976,6 @@ var SetupOrchestrator = class {
1056
976
  detectedHarnesses: detected.map((h) => h.name),
1057
977
  configuredHarnesses: results.filter((r) => r.status === "written").map((r) => r.harness),
1058
978
  injectionHooksConfigured,
1059
- stopHooksConfigured,
1060
979
  modelDownloaded
1061
980
  };
1062
981
  this.#out(JSON.stringify(output));
@@ -1066,28 +985,33 @@ var SetupOrchestrator = class {
1066
985
  }
1067
986
  return results;
1068
987
  }
1069
- async #runHookLoop(detected, label, write, yes, out) {
988
+ async #runHookSetup(detected, yes, out) {
1070
989
  const configured = [];
990
+ const w = this.#hookWriter;
1071
991
  for (const h of detected) try {
1072
- const result = write(h.name);
1073
- if (result.status === "not-supported") continue;
1074
- if (result.status === "written") {
1075
- out(` ✓ ${h.name}: ${label} written`);
1076
- configured.push(h.name);
992
+ const inspected = w.inspect(h.name);
993
+ if (inspected.status === "not-supported") continue;
994
+ const toWrite = [];
995
+ for (const hook of inspected.hooks) if (hook.existingCommand === null) {
996
+ out(` ${h.name}: ${hook.event} injection hook`);
997
+ out(` Command: ${hook.command}`);
998
+ if (yes || await this.#prompter(` Configure ${hook.event} injection hook for ${h.name}?`)) toWrite.push(hook.event);
1077
999
  } else {
1078
- out(` ⚠ ${h.name}: ${label} already configured`);
1079
- out(` Current: ${result.existing}`);
1080
- out(` New: ${result.replacement}`);
1081
- if (yes || await this.#prompter(` Replace ${label} for ${h.name}?`)) {
1082
- if (write(h.name, true).status === "written") {
1083
- out(` ✓ ${h.name}: ${label} replaced`);
1084
- configured.push(h.name);
1085
- }
1086
- }
1000
+ out(` ⚠ ${h.name}: ${hook.event} injection hook already configured`);
1001
+ out(` Current: ${hook.existingCommand}`);
1002
+ if (hook.existingCommand !== hook.command) out(` New: ${hook.command}`);
1003
+ if (yes || await this.#prompter(` Replace ${hook.event} injection hook for ${h.name}?`)) toWrite.push(hook.event);
1004
+ }
1005
+ if (toWrite.length > 0) {
1006
+ w.write(h.name, toWrite);
1007
+ const skippedCount = inspected.hooks.length - toWrite.length;
1008
+ const label = skippedCount > 0 ? `${toWrite.length} injection hook(s) written, ${skippedCount} skipped` : `${toWrite.length} injection hook(s) written`;
1009
+ out(` ✓ ${h.name}: ${label}`);
1010
+ configured.push(h.name);
1087
1011
  }
1088
1012
  } catch (err) {
1089
1013
  const msg = err instanceof Error ? err.message : String(err);
1090
- out(` ✗ ${h.name} ${label}: ${msg}`);
1014
+ out(` ✗ ${h.name} injection hooks: ${msg}`);
1091
1015
  }
1092
1016
  return configured;
1093
1017
  }
@@ -1221,7 +1145,7 @@ program.command("import <file>").description("import memories from a JSON export
1221
1145
  db.close();
1222
1146
  }
1223
1147
  });
1224
- program.command("inject").description("output session context for harness injection (used by setup hooks)").option("--harness <name>", "format output for a specific harness (claude-code|copilot-cli|codex|opencode)").option("--scope <scope>", "project scope override (default: auto-detect from git remote)").action(async (cmdOptions) => {
1148
+ program.command("inject").description("output session context for harness injection (used by setup hooks)").option("--harness <name>", "format output for a specific harness (claude-code|copilot-cli|codex|opencode)").option("--scope <scope>", "project scope override (default: auto-detect from git remote)").option("--event <event>", "hook event type (only session-start is supported; other values no-op for legacy hook compatibility)", "session-start").action(async (cmdOptions) => {
1225
1149
  try {
1226
1150
  await injectCommand(cmdOptions);
1227
1151
  } catch (err) {
@@ -1229,9 +1153,6 @@ program.command("inject").description("output session context for harness inject
1229
1153
  process.exit(2);
1230
1154
  }
1231
1155
  });
1232
- program.command("stop-hook").description("output session-end prompt for harness stop hooks (called by hooks, not users)").option("--harness <name>", "harness name (copilot-cli, codex, opencode)").action((cmdOptions) => {
1233
- stopHookCommand(cmdOptions);
1234
- });
1235
1156
  program.command("setup").description("detect installed harnesses and write MCP config for each").option("--yes", "skip all confirmation prompts").option("--dry-run", "print planned changes without writing any file").option("--harness <name>", "target only the named harness (skip detection)").action(async (cmdOptions) => {
1236
1157
  const globalOpts = program.opts();
1237
1158
  const autoYes = cmdOptions.yes === true || globalOpts.yes === true;
@@ -1263,6 +1184,14 @@ program.command("setup").description("detect installed harnesses and write MCP c
1263
1184
  process.exit(2);
1264
1185
  }
1265
1186
  });
1187
+ program.command("dashboard").description("open the memory management dashboard in the browser").option("--port <port>", "port to listen on (default: 3847, fallback to random)").action(async (cmdOptions) => {
1188
+ try {
1189
+ await dashboardCommand(cmdOptions);
1190
+ } catch (err) {
1191
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
1192
+ process.exit(2);
1193
+ }
1194
+ });
1266
1195
  program.on("command:*", () => {
1267
1196
  program.outputHelp();
1268
1197
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@membank/cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,8 +17,9 @@
17
17
  "@huggingface/transformers": "^4.2.0",
18
18
  "commander": "^14.0.3",
19
19
  "ora": "^9.4.0",
20
- "@membank/core": "0.2.0",
21
- "@membank/mcp": "0.2.0"
20
+ "@membank/core": "0.4.0",
21
+ "@membank/dashboard": "0.2.0",
22
+ "@membank/mcp": "0.4.0"
22
23
  },
23
24
  "devDependencies": {
24
25
  "@types/node": "^25.6.0",