opencode-memoir 1.1.5 → 1.1.7

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/index.js CHANGED
@@ -1,10 +1,3 @@
1
- // src/store.ts
2
- import { execFile, execFileSync } from "node:child_process";
3
- import { access, mkdir, rm } from "node:fs/promises";
4
- import { join, resolve } from "node:path";
5
- import { homedir, tmpdir } from "node:os";
6
- import { promisify } from "node:util";
7
-
8
1
  // src/debug.ts
9
2
  function debugLog(...args) {
10
3
  if (process.env.MEMOIR_DEBUG !== "1") return;
@@ -13,6 +6,13 @@ function debugLog(...args) {
13
6
  `);
14
7
  }
15
8
 
9
+ // src/store.ts
10
+ import { execFile, execFileSync } from "node:child_process";
11
+ import { access, mkdir, rm } from "node:fs/promises";
12
+ import { homedir, tmpdir } from "node:os";
13
+ import { join, resolve } from "node:path";
14
+ import { promisify } from "node:util";
15
+
16
16
  // src/utils.ts
17
17
  function coercePaths(path) {
18
18
  if (!path) return [];
@@ -77,8 +77,16 @@ function _checkNoGit(cwd) {
77
77
  function _main_worktree_root(cwd) {
78
78
  if (_checkNoGit(cwd)) return "";
79
79
  try {
80
- const gitDir = execFileSync("git", ["rev-parse", "--git-dir"], { cwd, encoding: "utf8", timeout: GIT_TIMEOUT_MS }).trim();
81
- const gitCommonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], { cwd, encoding: "utf8", timeout: GIT_TIMEOUT_MS }).trim();
80
+ const gitDir = execFileSync("git", ["rev-parse", "--git-dir"], {
81
+ cwd,
82
+ encoding: "utf8",
83
+ timeout: GIT_TIMEOUT_MS
84
+ }).trim();
85
+ const gitCommonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
86
+ cwd,
87
+ encoding: "utf8",
88
+ timeout: GIT_TIMEOUT_MS
89
+ }).trim();
82
90
  const resolvePath = (path) => {
83
91
  if (path.startsWith("/")) return path;
84
92
  return join(cwd, path);
@@ -86,9 +94,17 @@ function _main_worktree_root(cwd) {
86
94
  const gitDirAbs = resolvePath(gitDir);
87
95
  const gitCommonDirAbs = resolvePath(gitCommonDir);
88
96
  if (gitDirAbs === gitCommonDirAbs) {
89
- return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", timeout: GIT_TIMEOUT_MS }).trim();
97
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
98
+ cwd,
99
+ encoding: "utf8",
100
+ timeout: GIT_TIMEOUT_MS
101
+ }).trim();
90
102
  }
91
- const worktreeList = execFileSync("git", ["worktree", "list", "--porcelain"], { cwd, encoding: "utf8", timeout: GIT_TIMEOUT_MS });
103
+ const worktreeList = execFileSync("git", ["worktree", "list", "--porcelain"], {
104
+ cwd,
105
+ encoding: "utf8",
106
+ timeout: GIT_TIMEOUT_MS
107
+ });
92
108
  const firstLine = worktreeList.split("\n")[0];
93
109
  if (firstLine.startsWith("worktree ")) {
94
110
  const mainWorktree = firstLine.substring("worktree ".length).trim();
@@ -96,7 +112,11 @@ function _main_worktree_root(cwd) {
96
112
  return mainWorktree;
97
113
  }
98
114
  }
99
- return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", timeout: GIT_TIMEOUT_MS }).trim();
115
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
116
+ cwd,
117
+ encoding: "utf8",
118
+ timeout: GIT_TIMEOUT_MS
119
+ }).trim();
100
120
  } catch (e) {
101
121
  debugLog("_main_worktree_root: not a git repo or git error:", errorMessage(e));
102
122
  sweepNoGitCache();
@@ -144,7 +164,9 @@ async function ensureStore(store) {
144
164
  if (ensuredStores.size > ENSURED_STORES_MAX) ensuredStores.clear();
145
165
  return;
146
166
  } catch {
147
- await mkdir(join(store, ".."), { recursive: true }).catch((e) => debugLog("ensureStore: mkdir failed:", errorMessage(e)));
167
+ await mkdir(join(store, ".."), { recursive: true }).catch(
168
+ (e) => debugLog("ensureStore: mkdir failed:", errorMessage(e))
169
+ );
148
170
  }
149
171
  const creationPromise = (async () => {
150
172
  const tmpDir = join(tmpdir(), `memoir-scratch-${Date.now()}`);
@@ -161,7 +183,9 @@ async function ensureStore(store) {
161
183
  debugLog("ensureStore: creation failed:", errorMessage(e));
162
184
  throw e;
163
185
  } finally {
164
- rm(tmpDir, { recursive: true, force: true }).catch((e) => debugLog("tmp cleanup failed:", errorMessage(e)));
186
+ rm(tmpDir, { recursive: true, force: true }).catch(
187
+ (e) => debugLog("tmp cleanup failed:", errorMessage(e))
188
+ );
165
189
  }
166
190
  })();
167
191
  storeCreations.set(store, creationPromise);
@@ -211,8 +235,16 @@ async function runMemoir(args, options = {}) {
211
235
  function memoirSpawnSpecs(args) {
212
236
  return [
213
237
  { command: "memoir", args, label: "memoir" },
214
- { command: "uvx", args: ["--from", `${MEMOIR_PACKAGE}==${MEMOIR_AI_PIN}`, "memoir", ...args], label: "uvx" },
215
- { command: "uv", args: ["tool", "run", "--from", `${MEMOIR_PACKAGE}==${MEMOIR_AI_PIN}`, "memoir", ...args], label: "uv tool run" }
238
+ {
239
+ command: "uvx",
240
+ args: ["--from", `${MEMOIR_PACKAGE}==${MEMOIR_AI_PIN}`, "memoir", ...args],
241
+ label: "uvx"
242
+ },
243
+ {
244
+ command: "uv",
245
+ args: ["tool", "run", "--from", `${MEMOIR_PACKAGE}==${MEMOIR_AI_PIN}`, "memoir", ...args],
246
+ label: "uv tool run"
247
+ }
216
248
  ];
217
249
  }
218
250
  async function getCurrentBranch(store) {
@@ -230,7 +262,10 @@ async function getCurrentBranch(store) {
230
262
  async function codeGitBranch() {
231
263
  try {
232
264
  const execFn = _storeTestOverrides.execFileAsyncFn ?? execFileAsync;
233
- const { stdout } = await execFn("git", ["branch", "--show-current"], { encoding: "utf8", timeout: GIT_TIMEOUT_MS });
265
+ const { stdout } = await execFn("git", ["branch", "--show-current"], {
266
+ encoding: "utf8",
267
+ timeout: GIT_TIMEOUT_MS
268
+ });
234
269
  return stdout.trim();
235
270
  } catch (e) {
236
271
  debugLog("codeGitBranch: failed:", errorMessage(e));
@@ -260,7 +295,9 @@ async function autoMatchMemoirBranch(store) {
260
295
  const current = await getCurrentBranch(store);
261
296
  if (current === codeBranch) return current;
262
297
  if (!await branchExistsInMemoir(store, codeBranch)) {
263
- const result2 = await runFn(["-s", store, "branch", codeBranch, "--from", "main"], { cwd: store });
298
+ const result2 = await runFn(["-s", store, "branch", codeBranch, "--from", "main"], {
299
+ cwd: store
300
+ });
264
301
  if (!result2.ok) {
265
302
  debugLog("autoMatchMemoirBranch: create branch failed:", result2.error);
266
303
  return getCurrentBranch(store);
@@ -276,7 +313,9 @@ async function autoMatchMemoirBranch(store) {
276
313
  async function readMemoirValue(store, key, namespace = "default") {
277
314
  try {
278
315
  const runFn = _storeTestOverrides.runMemoirFn ?? runMemoir;
279
- const result = await runFn(["--json", "-s", store, "get", key, "-n", namespace], { cwd: store });
316
+ const result = await runFn(["--json", "-s", store, "get", key, "-n", namespace], {
317
+ cwd: store
318
+ });
280
319
  if (!result.ok) return "";
281
320
  const parsed = JSON.parse(result.stdout);
282
321
  const items = parsed?.items ?? [];
@@ -289,131 +328,224 @@ async function readMemoirValue(store, key, namespace = "default") {
289
328
  }
290
329
  var _storeTestOverrides = {};
291
330
 
292
- // src/tools.ts
293
- import { tool } from "@opencode-ai/plugin";
294
-
295
- // src/memoir-ops.ts
296
- import { spawn } from "node:child_process";
297
- import { createHash } from "node:crypto";
298
- import { mkdir as mkdir2, readFile, rm as rm2, writeFile } from "node:fs/promises";
299
- import { join as join2 } from "node:path";
300
- import { homedir as homedir2 } from "node:os";
301
- var UI_URL_DEADLINE_MS = 5e3;
302
- var UI_URL_POLL_MS = 100;
303
- async function ensureStoreOrError(store) {
304
- try {
305
- await ensureStore(store);
306
- return null;
307
- } catch (error) {
308
- return errorMessage(error);
331
+ // src/capture.ts
332
+ var _cliOverrides = null;
333
+ var EDIT_TOOLS = /* @__PURE__ */ new Set([
334
+ "Edit",
335
+ "Write",
336
+ "MultiEdit",
337
+ "NotebookEdit",
338
+ "apply_patch",
339
+ "ApplyPatch",
340
+ "MultiFileEdit"
341
+ ]);
342
+ function evictOldest(map, maxSize) {
343
+ if (map.size <= maxSize) return;
344
+ const firstKey = map.keys().next().value;
345
+ if (firstKey !== void 0) map.delete(firstKey);
346
+ }
347
+ var METRICS_CODE_MAX = 1e3;
348
+ var pendingEditsBySession = /* @__PURE__ */ new Map();
349
+ var toolMetricsBySession = /* @__PURE__ */ new Map();
350
+ var cachedBranchBySession = /* @__PURE__ */ new Map();
351
+ function getCachedBranch(sessionID) {
352
+ return cachedBranchBySession.get(sessionID) ?? "unknown";
353
+ }
354
+ function setCachedBranch(sessionID, branch) {
355
+ cachedBranchBySession.set(sessionID, branch);
356
+ evictOldest(cachedBranchBySession, 200);
357
+ }
358
+ function recordEdit(sessionID, edit) {
359
+ let edits = pendingEditsBySession.get(sessionID);
360
+ if (!edits) {
361
+ edits = [];
362
+ pendingEditsBySession.set(sessionID, edits);
363
+ evictOldest(pendingEditsBySession, 500);
309
364
  }
365
+ edits.push(edit);
310
366
  }
311
- async function statusJson(store) {
312
- const storeErr = await ensureStoreOrError(store);
313
- if (storeErr) return `Memoir command failed: ${storeErr}`;
314
- const result = await runMemoir(["--json", "-s", store, "status"], { cwd: store });
315
- if (!result.ok) return result.error;
316
- try {
317
- const data = JSON.parse(result.stdout);
318
- const branch = await codeGitBranch();
319
- data.opencode = { store, project_git_root: process.cwd(), project_git_branch: branch };
320
- return JSON.stringify(data, null, 2);
321
- } catch (e) {
322
- debugLog("statusJson: failed to parse JSON:", errorMessage(e));
323
- return result.stdout;
367
+ function recordToolMetrics(sessionID, tool2, metrics) {
368
+ let sessionMetrics = toolMetricsBySession.get(sessionID);
369
+ if (!sessionMetrics) {
370
+ sessionMetrics = /* @__PURE__ */ new Map();
371
+ toolMetricsBySession.set(sessionID, sessionMetrics);
372
+ evictOldest(toolMetricsBySession, 500);
324
373
  }
374
+ const prev = sessionMetrics.get(tool2) ?? { calls: 0, errors: 0 };
375
+ sessionMetrics.set(tool2, {
376
+ calls: prev.calls + metrics.calls,
377
+ errors: prev.errors + metrics.errors
378
+ });
325
379
  }
326
- async function launchUi(store) {
327
- await ensureStore(store);
328
- const pidDir = join2(homedir2(), ".memoir", "ui-servers");
329
- await mkdir2(pidDir, { recursive: true });
330
- const hash = createHash("sha256").update(store).digest("hex").slice(0, 8);
331
- const pidfile = join2(pidDir, `${hash}.json`);
380
+ function parseTurnMetrics(text) {
381
+ const result = /* @__PURE__ */ new Map();
382
+ if (!text) return result;
383
+ const parts = text.split("|");
384
+ for (const part of parts) {
385
+ const trimmed = part.trim();
386
+ if (!trimmed) continue;
387
+ const [tool2, callsStr, errorsStr] = trimmed.split(":");
388
+ if (!tool2) continue;
389
+ result.set(tool2, {
390
+ calls: parseInt(callsStr, 10) || 0,
391
+ errors: parseInt(errorsStr, 10) || 0
392
+ });
393
+ }
394
+ return result;
395
+ }
396
+ function serializeTurnMetrics(map) {
397
+ return [...map.entries()].map(([tool2, m]) => `${tool2}:${m.calls}:${m.errors}`).join(" | ");
398
+ }
399
+ var flushQueues = /* @__PURE__ */ new Map();
400
+ async function acquireFlushLock(store) {
401
+ const prev = flushQueues.get(store) ?? Promise.resolve();
402
+ let release;
403
+ const next = new Promise((resolve2) => {
404
+ release = resolve2;
405
+ });
406
+ flushQueues.set(store, next);
407
+ await prev;
408
+ return () => {
409
+ release();
410
+ if (flushQueues.get(store) === next) flushQueues.delete(store);
411
+ };
412
+ }
413
+ async function flushCapture(store, branch, sessionID) {
414
+ const targets = sessionID ? [sessionID] : [...pendingEditsBySession.keys()];
415
+ if (targets.length === 0) return;
332
416
  try {
333
- const existing = JSON.parse(await readFile(pidfile, "utf8"));
334
- if (existing?.pid && existing?.url) {
417
+ store = store ?? deriveStorePath();
418
+ if (!store) return;
419
+ await ensureStore(store);
420
+ const releaseLock = await acquireFlushLock(store);
421
+ try {
422
+ const perSession = [];
423
+ const swappedSids = /* @__PURE__ */ new Map();
424
+ for (const sid of targets) {
425
+ const idx = perSession.length;
426
+ const edits = pendingEditsBySession.get(sid) ?? [];
427
+ pendingEditsBySession.set(sid, []);
428
+ const metrics = toolMetricsBySession.get(sid) ?? /* @__PURE__ */ new Map();
429
+ toolMetricsBySession.set(sid, /* @__PURE__ */ new Map());
430
+ const sb = branch ?? cachedBranchBySession.get(sid);
431
+ perSession.push({ edits, metrics, sessionBranch: sb ?? "unknown" });
432
+ swappedSids.set(sid, idx);
433
+ }
434
+ if (!perSession.some((p) => p.edits.length > 0 || p.metrics.size > 0)) return;
335
435
  try {
336
- process.kill(Number(existing.pid), 0);
337
- return JSON.stringify({ ...existing, reused: true }, null, 2);
436
+ for (const p of perSession) {
437
+ if (p.sessionBranch === "unknown" || !p.sessionBranch) {
438
+ p.sessionBranch = await (_cliOverrides?.getCurrentBranch ?? getCurrentBranch)(store);
439
+ }
440
+ }
441
+ for (const p of perSession) {
442
+ const { edits, metrics, sessionBranch: branchKey } = p;
443
+ if (edits.length === 0 && metrics.size === 0) continue;
444
+ const [prevCodeRaw, prevTurnRaw] = await Promise.all([
445
+ edits.length > 0 ? (_cliOverrides?.readMemoirValue ?? readMemoirValue)(
446
+ store,
447
+ `metrics.code.${branchKey}`
448
+ ) : Promise.resolve(""),
449
+ metrics.size > 0 ? (_cliOverrides?.readMemoirValue ?? readMemoirValue)(
450
+ store,
451
+ `metrics.turn.${branchKey}`
452
+ ) : Promise.resolve("")
453
+ ]);
454
+ let codeWrite;
455
+ if (edits.length > 0) {
456
+ const files = [...new Set(edits.map((e) => e.filePath))];
457
+ const entry = {
458
+ timestamp: Date.now() / 1e3,
459
+ summary: `Changed ${edits.length} block(s) across ${files.length} file(s): ${files.join(", ")}`,
460
+ files
461
+ };
462
+ let acc = {
463
+ schema_version: 2,
464
+ entries: []
465
+ };
466
+ if (prevCodeRaw) {
467
+ try {
468
+ const parsed = JSON.parse(prevCodeRaw);
469
+ if (parsed?.entries && Array.isArray(parsed.entries)) {
470
+ acc = parsed;
471
+ if (acc.schema_version < 2) acc.schema_version = 2;
472
+ }
473
+ } catch (e) {
474
+ debugLog(
475
+ "flushCapture: failed to parse existing code metrics, starting fresh:",
476
+ errorMessage(e)
477
+ );
478
+ }
479
+ }
480
+ acc.entries.push(entry);
481
+ if (acc.entries.length > METRICS_CODE_MAX)
482
+ acc.entries = acc.entries.slice(-METRICS_CODE_MAX);
483
+ codeWrite = (_cliOverrides?.runMemoir ?? runMemoir)(
484
+ [
485
+ "-s",
486
+ store,
487
+ "remember",
488
+ "--replace",
489
+ JSON.stringify(acc),
490
+ "-p",
491
+ `metrics.code.${branchKey}`
492
+ ],
493
+ { cwd: store }
494
+ );
495
+ }
496
+ let turnWrite;
497
+ if (metrics.size > 0) {
498
+ const existing = parseTurnMetrics(prevTurnRaw);
499
+ for (const [tool2, current] of metrics) {
500
+ const prev = existing.get(tool2) ?? { calls: 0, errors: 0 };
501
+ existing.set(tool2, {
502
+ calls: prev.calls + current.calls,
503
+ errors: prev.errors + current.errors
504
+ });
505
+ }
506
+ turnWrite = (_cliOverrides?.runMemoir ?? runMemoir)(
507
+ [
508
+ "-s",
509
+ store,
510
+ "remember",
511
+ "--replace",
512
+ serializeTurnMetrics(existing),
513
+ "-p",
514
+ `metrics.turn.${branchKey}`
515
+ ],
516
+ { cwd: store }
517
+ );
518
+ }
519
+ const writes = [];
520
+ if (codeWrite) writes.push(codeWrite);
521
+ if (turnWrite) writes.push(turnWrite);
522
+ const results = await Promise.all(writes);
523
+ for (const result of results) {
524
+ if (!result.ok) {
525
+ throw new Error(`Memoir write failed: ${result.error}`);
526
+ }
527
+ }
528
+ }
338
529
  } catch (e) {
339
- debugLog("launchUi: process dead, relaunching:", errorMessage(e));
530
+ for (const [sid, idx] of swappedSids) {
531
+ const p = perSession[idx];
532
+ const currentEdits = pendingEditsBySession.get(sid) ?? [];
533
+ pendingEditsBySession.set(sid, [...p.edits, ...currentEdits]);
534
+ const merged = toolMetricsBySession.get(sid) ?? /* @__PURE__ */ new Map();
535
+ for (const [tool2, m] of p.metrics) {
536
+ const prev = merged.get(tool2) ?? { calls: 0, errors: 0 };
537
+ merged.set(tool2, { calls: prev.calls + m.calls, errors: prev.errors + m.errors });
538
+ }
539
+ toolMetricsBySession.set(sid, merged);
540
+ }
541
+ throw e;
340
542
  }
543
+ } finally {
544
+ releaseLock();
341
545
  }
342
546
  } catch (e) {
343
- debugLog("launchUi: failed to read pidfile:", errorMessage(e));
344
- await rm2(pidfile, { force: true }).catch((e2) => debugLog("launchUi: failed to remove stale pidfile", errorMessage(e2)));
345
- }
346
- let lastError = "";
347
- let uiSpecs = memoirSpawnSpecs(["ui", store]);
348
- if (memoirResolved) {
349
- const winner = uiSpecs.find((s) => s.label === memoirResolved);
350
- if (winner) uiSpecs = reorderResolver(uiSpecs, winner);
547
+ debugLog("flushCapture: failed:", errorMessage(e));
351
548
  }
352
- for (const spec of uiSpecs) {
353
- const child = spawn(spec.command, spec.args, {
354
- detached: true,
355
- stdio: ["ignore", "pipe", "pipe"],
356
- // Scrub process.env — only forward whitelisted vars to avoid leaking secrets
357
- env: Object.fromEntries(
358
- Object.entries({ PATH: process.env.PATH, MEMOIR_STORE: process.env.MEMOIR_STORE, MEMOIR_DEBUG: process.env.MEMOIR_DEBUG }).filter(([, v]) => v != null)
359
- )
360
- });
361
- let output = "";
362
- child.stdout?.on("data", (chunk) => {
363
- output += String(chunk);
364
- });
365
- child.stderr?.on("data", (chunk) => {
366
- output += String(chunk);
367
- });
368
- const spawnFailed = new Promise((resolve2) => {
369
- child.once("error", (error2) => resolve2(errorMessage(error2)));
370
- child.once("spawn", () => resolve2(null));
371
- });
372
- const error = await spawnFailed;
373
- if (error) {
374
- lastError = `${spec.label}: ${error}`;
375
- continue;
376
- }
377
- child.unref();
378
- const urlPattern = /https?:\/\/(?:localhost|127\.0\.0\.1):\d+\S*/;
379
- const deadline = Date.now() + UI_URL_DEADLINE_MS;
380
- while (Date.now() < deadline) {
381
- const match = output.match(urlPattern);
382
- if (match) {
383
- const url = match[0];
384
- const data = { pid: child.pid, url, store, command: spec.label, started: (/* @__PURE__ */ new Date()).toISOString(), reused: false };
385
- await writeFile(pidfile, JSON.stringify(data, null, 2));
386
- return JSON.stringify(data, null, 2);
387
- }
388
- await new Promise((resolve2) => setTimeout(resolve2, UI_URL_POLL_MS));
389
- }
390
- return `Memoir UI started with ${spec.label} (pid ${child.pid ?? "unknown"}), but URL was not detected yet.
391
- ${output.trim()}`.trim();
392
- }
393
- return `Memoir UI failed to start: ${lastError || "no launcher succeeded"}`;
394
- }
395
- async function unmergedBranchesText(store) {
396
- await ensureStore(store);
397
- const branchResult = await runMemoir(["--json", "-s", store, "branch"], { cwd: store });
398
- if (!branchResult.ok) {
399
- return branchResult.error;
400
- }
401
- const data = JSON.parse(branchResult.stdout);
402
- const branches = data?.branches ?? [];
403
- const unmerged = [];
404
- for (const branch of branches) {
405
- if (branch === "main") continue;
406
- const diffResult = await runMemoir(["-s", store, "diff", branch, "main", "--stat"], { cwd: store });
407
- if (!diffResult.ok) {
408
- debugLog("command.execute.before: memoir diff failed", diffResult.error);
409
- continue;
410
- }
411
- if (diffResult.stdout.trim()) {
412
- unmerged.push(branch);
413
- }
414
- }
415
- return unmerged.length > 0 ? `Unmerged branches:
416
- ${unmerged.join("\n")}` : "All branches are merged into main.";
417
549
  }
418
550
 
419
551
  // src/recall-gate.ts
@@ -455,407 +587,295 @@ function shouldTriggerRecall(text) {
455
587
  }
456
588
  var DEFAULT_RECALL_NAMESPACES = ["default", "project:onboard", "codebase:onboard"];
457
589
 
458
- // src/tools.ts
459
- var memoirStatus = tool({
460
- description: "Show Memoir status for the current OpenCode project store.",
461
- args: {},
462
- execute: async () => statusJson(deriveStorePath())
463
- });
464
- var memoirRemember = tool({
465
- description: "Explicitly save a durable memory to Memoir at one or more semantic taxonomy paths.",
466
- args: {
467
- content: tool.schema.string().describe("Memory content to save. Do not include secrets."),
468
- path: tool.schema.string().optional().describe("Semantic taxonomy path, e.g. preferences.coding.style."),
469
- namespace: tool.schema.string().optional().describe("Memoir namespace. Defaults to default."),
470
- replace: tool.schema.boolean().optional().describe("Replace existing value at the path.")
471
- },
472
- execute: async (args) => {
473
- const content = args.content?.trim();
474
- if (!content) return "Memoir memory was not saved: content is empty.";
475
- if (isSecretSanitizationEnabled() && SECRET_PATTERN.test(content)) {
476
- return "Memoir memory was not saved: the content looks like a secret or credential. Save a redacted rule instead.";
477
- }
478
- const paths = coercePaths(args.path);
479
- if (paths.length === 0) {
480
- return "Memoir memory was not saved: provide a semantic path, e.g. preferences.coding.style.";
481
- }
482
- const store = deriveStorePath();
483
- const storeError = await ensureStoreOrError(store);
484
- if (storeError) return storeError;
485
- const cliArgs = ["--json", "-s", store, "remember", content];
486
- for (const p of paths) cliArgs.push("-p", p);
487
- cliArgs.push("-n", args.namespace ?? "default");
488
- if (args.replace) cliArgs.push("--replace");
489
- const rememberResult = await runMemoir(cliArgs, { cwd: store });
490
- if (!rememberResult.ok) return rememberResult.error;
491
- return tryPrettyJson(rememberResult.stdout);
590
+ // src/capture-hooks.ts
591
+ var branchMatchCache = /* @__PURE__ */ new Map();
592
+ var BRANCH_MATCH_TTL_MS = 5e3;
593
+ async function handleShellEnv(storeRoot, _input, output) {
594
+ try {
595
+ output.env.MEMOIR_STORE = storeRoot;
596
+ } catch (e) {
597
+ debugLog("shell.env: failed:", errorMessage(e));
492
598
  }
493
- });
494
- var memoirRecall = tool({
495
- description: "List Memoir memory keys across relevant namespaces for relevance picking. Never calls legacy memoir recall.",
496
- args: {
497
- query: tool.schema.string().optional().describe("User query or topic to recall for."),
498
- namespace: tool.schema.string().optional().describe("Single Memoir namespace to inspect. If omitted, checks default + onboard namespaces."),
499
- namespaces: tool.schema.array(tool.schema.string()).optional().describe("Namespaces to inspect. Defaults to default, project:onboard, codebase:onboard."),
500
- includeMetrics: tool.schema.boolean().optional().describe("Include metrics.* memories in the listing.")
501
- },
502
- execute: async (args) => {
503
- const store = deriveStorePath();
504
- const storeError = await ensureStoreOrError(store);
505
- if (storeError) return storeError;
506
- const namespaces = args.namespace ? [args.namespace] : args.namespaces && args.namespaces.length > 0 ? args.namespaces : DEFAULT_RECALL_NAMESPACES;
507
- const sections = [];
508
- for (const namespace of namespaces) {
509
- const summarizeResult = await runMemoir(["--json", "-s", store, "summarize", "--depth", "3", "-n", namespace], { cwd: store });
510
- if (!summarizeResult.ok) return summarizeResult.error;
511
- const output = tryPrettyJson(summarizeResult.stdout);
512
- sections.push(`## namespace: ${namespace}
513
- ${output}`);
599
+ }
600
+ async function handleToolExecuteAfter(input, output) {
601
+ try {
602
+ const typedInput = input;
603
+ const typedOutput = output;
604
+ const sid = typedInput?.sessionID ?? "default";
605
+ if (process.env.MEMOIR_NO_CAPTURE !== "1" && process.env.MEMOIR_NO_METRICS !== "1") {
606
+ const calls = 1;
607
+ const errors = typedOutput?.metadata?.error || typedOutput?.output?.startsWith("Error:") ? 1 : 0;
608
+ recordToolMetrics(sid, typedInput?.tool ?? "", { calls, errors });
514
609
  }
515
- const note = args.includeMetrics ? "Metrics were included by request." : "Ignore metrics.* and taxonomy:v1:* entries unless explicitly needed. If default is empty or only metrics, inspect project:onboard/codebase:onboard before concluding there is no memory.";
516
- const query = args.query ? `Query: ${args.query}
517
- ` : "";
518
- return `${query}${note}
519
- Pick at most 5-7 relevant exact keys across namespaces, then call memoir_get with the matching namespace if values are needed.
520
- ${sections.join("\n\n")}`;
521
- }
522
- });
523
- var memoirGet = tool({
524
- description: "Fetch exact Memoir memory keys after selecting them from memoir_recall output.",
525
- args: {
526
- keys: tool.schema.array(tool.schema.string()).describe("Exact memory keys to fetch."),
527
- namespace: tool.schema.string().optional().describe("Memoir namespace. Defaults to default.")
528
- },
529
- execute: async (args) => {
530
- const keys = args.keys?.map((key) => key.trim()).filter(Boolean) ?? [];
531
- if (keys.length === 0) return "No Memoir keys were provided.";
532
- if (keys.length > MEMOIR_GET_MAX_KEYS) {
533
- return `Error: Too many keys requested (max ${MEMOIR_GET_MAX_KEYS}, got ${keys.length}). Narrow your selection from memoir_recall output.`;
610
+ if (process.env.MEMOIR_NO_CAPTURE !== "1" && process.env.MEMOIR_NO_CODE_SUMMARY !== "1" && EDIT_TOOLS.has(typedInput?.tool ?? "")) {
611
+ const args = typedInput?.args ?? {};
612
+ const filePath = typeof args.filePath === "string" ? args.filePath : typeof args.path === "string" ? args.path : "";
613
+ if (filePath) {
614
+ recordEdit(sid, {
615
+ tool: typedInput?.tool ?? "",
616
+ filePath,
617
+ snippet: "",
618
+ timestamp: Date.now()
619
+ });
620
+ }
534
621
  }
535
- const store = deriveStorePath();
536
- const storeError = await ensureStoreOrError(store);
537
- if (storeError) return storeError;
538
- const getResult = await runMemoir(["--json", "-s", store, "get", ...keys, "-n", args.namespace ?? "default"], { cwd: store });
539
- if (!getResult.ok) return getResult.error;
540
- return tryPrettyJson(getResult.stdout);
622
+ } catch (e) {
623
+ debugLog("tool.execute.after: failed:", errorMessage(e));
541
624
  }
542
- });
543
- var memoirTools = {
544
- memoir_status: memoirStatus,
545
- memoir_remember: memoirRemember,
546
- memoir_recall: memoirRecall,
547
- memoir_get: memoirGet
548
- };
549
-
550
- // src/commands.ts
551
- function pushText(output, text) {
552
- output.parts.length = 0;
553
- output.parts.push({ type: "text", text });
554
- }
555
- function registerCommands(config) {
556
- config.command = config.command ?? {};
557
- config.command["memoir:status"] = {
558
- description: "Show Memoir status for the current OpenCode project",
559
- template: "Show Memoir status for this OpenCode project."
560
- };
561
- config.command["memoir:ui"] = {
562
- description: "Launch or reopen the Memoir web UI for this project store",
563
- template: "Launch or reopen the Memoir UI for this project."
564
- };
565
- config.command["memoir:remember"] = {
566
- description: "Save a durable fact, preference, rule, or decision to Memoir",
567
- template: `Use Memoir to save this durable memory now.
568
-
569
- USER REQUEST:
570
- $ARGUMENTS
571
-
572
- Extract the memory content, choose a semantic path if none is supplied, then call the memoir_remember tool. Never save secrets.`
573
- };
574
- config.command["memoir:recall"] = {
575
- description: "Recall relevant facts from Memoir before answering",
576
- template: `Recall relevant Memoir memories for this request.
577
-
578
- USER REQUEST:
579
- $ARGUMENTS
580
-
581
- Use memoir_recall first. It checks default plus onboard namespaces unless a namespace is specified. Then call memoir_get with the matching namespace for exact values before answering.`
582
- };
583
- config.command["memoir:onboard"] = {
584
- description: "Populate or refresh Memoir onboarding for this project",
585
- template: `Populate or refresh Memoir onboarding for the CURRENT OpenCode project only.
586
-
587
- USER REQUEST:
588
- $ARGUMENTS
589
-
590
- Workflow:
591
- - Stay inside the current project/worktree. Do not inspect parent directories.
592
- - First obtain a project file tree to understand structure.
593
- - Start studying from project documentation.
594
- - Continue only based on what the tree and documentation show.
595
-
596
- Memory rules:
597
- - Record only verified facts from files/docs/code or explicit user statements.
598
- - Do not write inferred user thoughts, intentions, preferences, or opinions.
599
- - Do not use preferences.* paths unless the user explicitly stated a preference.
600
- - If a fact is your interpretation, do not save it; report it as uncertain instead.
601
-
602
- Then call memoir_remember with replace=true for durable onboarding facts. Use namespace codebase:onboard in git repositories and project:onboard outside git. Do not install or invoke separate skills/scripts.`
603
- };
604
- config.command["memoir:unmerged"] = {
605
- description: "List memoir branches with changes not yet merged into main",
606
- template: "List memoir branches that have diverged from main."
607
- };
608
625
  }
609
- async function handleCommandExecuteBefore(storeRoot, input, output) {
626
+ async function handleChatMessage(storeRoot, input, output) {
610
627
  try {
611
- if (input.command === "memoir:status") {
612
- pushText(output, await statusJson(storeRoot));
613
- }
614
- if (input.command === "memoir:ui") {
615
- pushText(output, await launchUi(storeRoot));
628
+ const typedInput = input;
629
+ const typedOutput = output;
630
+ const sid = typedInput?.sessionID ?? "default";
631
+ const text = (typedOutput?.parts ?? []).filter((p) => p.type === "text").map((p) => p.text).join(" ");
632
+ if (shouldTriggerRecall(text)) {
633
+ pendingRecall.add(sid);
616
634
  }
617
- if (input.command === "memoir:remember") {
618
- const args = input.arguments;
619
- if (args && isSecretSanitizationEnabled() && SECRET_PATTERN.test(args)) {
620
- pushText(output, "Memoir: cannot remember content that matches secret patterns. Please remove sensitive data and try again.");
621
- }
635
+ const _cachedBranch = getCachedBranch(sid);
636
+ const prevBranch = _cachedBranch === "unknown" ? void 0 : _cachedBranch;
637
+ const now = Date.now();
638
+ const cached = branchMatchCache.get(sid);
639
+ if (cached && now - cached.ts < BRANCH_MATCH_TTL_MS) {
640
+ setCachedBranch(sid, cached.branch);
641
+ } else {
642
+ const branch = await autoMatchMemoirBranch(storeRoot);
643
+ branchMatchCache.set(sid, { branch, ts: now });
644
+ setCachedBranch(sid, branch);
622
645
  }
623
- if (input.command === "memoir:unmerged") {
624
- pushText(output, await unmergedBranchesText(storeRoot));
646
+ if (process.env.MEMOIR_NO_CAPTURE !== "1") {
647
+ await flushCapture(storeRoot, prevBranch, sid);
625
648
  }
626
- } catch (error) {
627
- pushText(output, `Memoir command failed: ${errorMessage(error)}`);
649
+ } catch (e) {
650
+ debugLog("chat.message: failed:", errorMessage(e));
628
651
  }
629
652
  }
630
-
631
- // src/capture.ts
632
- var _cliOverrides = null;
633
- var EDIT_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch", "ApplyPatch", "MultiFileEdit"]);
634
- function evictOldest(map, maxSize) {
635
- if (map.size <= maxSize) return;
636
- const firstKey = map.keys().next().value;
637
- if (firstKey !== void 0) map.delete(firstKey);
638
- }
639
- var METRICS_CODE_MAX = 1e3;
640
- var pendingEditsBySession = /* @__PURE__ */ new Map();
641
- var toolMetricsBySession = /* @__PURE__ */ new Map();
642
- var cachedBranchBySession = /* @__PURE__ */ new Map();
643
- function getCachedBranch(sessionID) {
644
- return cachedBranchBySession.get(sessionID) ?? "unknown";
645
- }
646
- function setCachedBranch(sessionID, branch) {
647
- cachedBranchBySession.set(sessionID, branch);
648
- evictOldest(cachedBranchBySession, 200);
649
- }
650
- function recordEdit(sessionID, edit) {
651
- let edits = pendingEditsBySession.get(sessionID);
652
- if (!edits) {
653
- edits = [];
654
- pendingEditsBySession.set(sessionID, edits);
655
- evictOldest(pendingEditsBySession, 500);
656
- }
657
- edits.push(edit);
653
+ async function flushAllCapture(storeRoot) {
654
+ await flushCapture(storeRoot);
658
655
  }
659
- function recordToolMetrics(sessionID, tool2, metrics) {
660
- let sessionMetrics = toolMetricsBySession.get(sessionID);
661
- if (!sessionMetrics) {
662
- sessionMetrics = /* @__PURE__ */ new Map();
663
- toolMetricsBySession.set(sessionID, sessionMetrics);
664
- evictOldest(toolMetricsBySession, 500);
656
+
657
+ // src/memoir-ops.ts
658
+ import { spawn } from "node:child_process";
659
+ import { createHash } from "node:crypto";
660
+ import { mkdir as mkdir2, readFile, rm as rm2, writeFile } from "node:fs/promises";
661
+ import { homedir as homedir2 } from "node:os";
662
+ import { join as join2 } from "node:path";
663
+ var UI_URL_DEADLINE_MS = 5e3;
664
+ var UI_URL_POLL_MS = 100;
665
+ async function ensureStoreOrError(store) {
666
+ try {
667
+ await ensureStore(store);
668
+ return null;
669
+ } catch (error) {
670
+ return errorMessage(error);
665
671
  }
666
- const prev = sessionMetrics.get(tool2) ?? { calls: 0, errors: 0 };
667
- sessionMetrics.set(tool2, {
668
- calls: prev.calls + metrics.calls,
669
- errors: prev.errors + metrics.errors
670
- });
671
672
  }
672
- function parseTurnMetrics(text) {
673
- const result = /* @__PURE__ */ new Map();
674
- if (!text) return result;
675
- const parts = text.split("|");
676
- for (const part of parts) {
677
- const trimmed = part.trim();
678
- if (!trimmed) continue;
679
- const [tool2, callsStr, errorsStr] = trimmed.split(":");
680
- if (!tool2) continue;
681
- result.set(tool2, {
682
- calls: parseInt(callsStr, 10) || 0,
683
- errors: parseInt(errorsStr, 10) || 0
684
- });
673
+ async function statusJson(store) {
674
+ const storeErr = await ensureStoreOrError(store);
675
+ if (storeErr) return `Memoir command failed: ${storeErr}`;
676
+ const result = await runMemoir(["--json", "-s", store, "status"], { cwd: store });
677
+ if (!result.ok) return result.error;
678
+ try {
679
+ const data = JSON.parse(result.stdout);
680
+ const branch = await codeGitBranch();
681
+ data.opencode = { store, project_git_root: process.cwd(), project_git_branch: branch };
682
+ return JSON.stringify(data, null, 2);
683
+ } catch (e) {
684
+ debugLog("statusJson: failed to parse JSON:", errorMessage(e));
685
+ return result.stdout;
685
686
  }
686
- return result;
687
- }
688
- function serializeTurnMetrics(map) {
689
- return [...map.entries()].map(([tool2, m]) => `${tool2}:${m.calls}:${m.errors}`).join(" | ");
690
- }
691
- var flushQueues = /* @__PURE__ */ new Map();
692
- async function acquireFlushLock(store) {
693
- const prev = flushQueues.get(store) ?? Promise.resolve();
694
- let release;
695
- const next = new Promise((resolve2) => {
696
- release = resolve2;
697
- });
698
- flushQueues.set(store, next);
699
- await prev;
700
- return () => {
701
- release();
702
- if (flushQueues.get(store) === next) flushQueues.delete(store);
703
- };
704
687
  }
705
- async function flushCapture(store, branch, sessionID) {
706
- const targets = sessionID ? [sessionID] : [...pendingEditsBySession.keys()];
707
- if (targets.length === 0) return;
688
+ async function launchUi(store) {
689
+ await ensureStore(store);
690
+ const pidDir = join2(homedir2(), ".memoir", "ui-servers");
691
+ await mkdir2(pidDir, { recursive: true });
692
+ const hash = createHash("sha256").update(store).digest("hex").slice(0, 8);
693
+ const pidfile = join2(pidDir, `${hash}.json`);
708
694
  try {
709
- store = store ?? deriveStorePath();
710
- if (!store) return;
711
- await ensureStore(store);
712
- const releaseLock = await acquireFlushLock(store);
713
- try {
714
- const perSession = [];
715
- const swappedSids = /* @__PURE__ */ new Map();
716
- for (const sid of targets) {
717
- const idx = perSession.length;
718
- const edits = pendingEditsBySession.get(sid) ?? [];
719
- pendingEditsBySession.set(sid, []);
720
- const metrics = toolMetricsBySession.get(sid) ?? /* @__PURE__ */ new Map();
721
- toolMetricsBySession.set(sid, /* @__PURE__ */ new Map());
722
- const sb = branch ?? cachedBranchBySession.get(sid);
723
- perSession.push({ edits, metrics, sessionBranch: sb ?? "unknown" });
724
- swappedSids.set(sid, idx);
725
- }
726
- if (!perSession.some((p) => p.edits.length > 0 || p.metrics.size > 0)) return;
695
+ const existing = JSON.parse(await readFile(pidfile, "utf8"));
696
+ if (existing?.pid && existing?.url) {
727
697
  try {
728
- for (const p of perSession) {
729
- if (p.sessionBranch === "unknown" || !p.sessionBranch) {
730
- p.sessionBranch = await (_cliOverrides?.getCurrentBranch ?? getCurrentBranch)(store);
731
- }
732
- }
733
- for (const p of perSession) {
734
- const { edits, metrics, sessionBranch: branchKey } = p;
735
- if (edits.length === 0 && metrics.size === 0) continue;
736
- const [prevCodeRaw, prevTurnRaw] = await Promise.all([
737
- edits.length > 0 ? (_cliOverrides?.readMemoirValue ?? readMemoirValue)(store, `metrics.code.${branchKey}`) : Promise.resolve(""),
738
- metrics.size > 0 ? (_cliOverrides?.readMemoirValue ?? readMemoirValue)(store, `metrics.turn.${branchKey}`) : Promise.resolve("")
739
- ]);
740
- let codeWrite;
741
- if (edits.length > 0) {
742
- const files = [...new Set(edits.map((e) => e.filePath))];
743
- const entry = {
744
- timestamp: Date.now() / 1e3,
745
- summary: `Changed ${edits.length} block(s) across ${files.length} file(s): ${files.join(", ")}`,
746
- files
747
- };
748
- let acc = {
749
- schema_version: 2,
750
- entries: []
751
- };
752
- if (prevCodeRaw) {
753
- try {
754
- const parsed = JSON.parse(prevCodeRaw);
755
- if (parsed?.entries && Array.isArray(parsed.entries)) {
756
- acc = parsed;
757
- if (acc.schema_version < 2) acc.schema_version = 2;
758
- }
759
- } catch (e) {
760
- debugLog("flushCapture: failed to parse existing code metrics, starting fresh:", errorMessage(e));
761
- }
762
- }
763
- acc.entries.push(entry);
764
- if (acc.entries.length > METRICS_CODE_MAX) acc.entries = acc.entries.slice(-METRICS_CODE_MAX);
765
- codeWrite = (_cliOverrides?.runMemoir ?? runMemoir)(["-s", store, "remember", "--replace", JSON.stringify(acc), "-p", `metrics.code.${branchKey}`], { cwd: store });
766
- }
767
- let turnWrite;
768
- if (metrics.size > 0) {
769
- const existing = parseTurnMetrics(prevTurnRaw);
770
- for (const [tool2, current] of metrics) {
771
- const prev = existing.get(tool2) ?? { calls: 0, errors: 0 };
772
- existing.set(tool2, { calls: prev.calls + current.calls, errors: prev.errors + current.errors });
773
- }
774
- turnWrite = (_cliOverrides?.runMemoir ?? runMemoir)(["-s", store, "remember", "--replace", serializeTurnMetrics(existing), "-p", `metrics.turn.${branchKey}`], { cwd: store });
775
- }
776
- const writes = [];
777
- if (codeWrite) writes.push(codeWrite);
778
- if (turnWrite) writes.push(turnWrite);
779
- const results = await Promise.all(writes);
780
- for (const result of results) {
781
- if (!result.ok) {
782
- throw new Error(`Memoir write failed: ${result.error}`);
783
- }
784
- }
785
- }
698
+ process.kill(Number(existing.pid), 0);
699
+ return JSON.stringify({ ...existing, reused: true }, null, 2);
786
700
  } catch (e) {
787
- for (const [sid, idx] of swappedSids) {
788
- const p = perSession[idx];
789
- const currentEdits = pendingEditsBySession.get(sid) ?? [];
790
- pendingEditsBySession.set(sid, [...p.edits, ...currentEdits]);
791
- const merged = toolMetricsBySession.get(sid) ?? /* @__PURE__ */ new Map();
792
- for (const [tool2, m] of p.metrics) {
793
- const prev = merged.get(tool2) ?? { calls: 0, errors: 0 };
794
- merged.set(tool2, { calls: prev.calls + m.calls, errors: prev.errors + m.errors });
795
- }
796
- toolMetricsBySession.set(sid, merged);
797
- }
798
- throw e;
701
+ debugLog("launchUi: process dead, relaunching:", errorMessage(e));
702
+ }
703
+ }
704
+ } catch (e) {
705
+ debugLog("launchUi: failed to read pidfile:", errorMessage(e));
706
+ await rm2(pidfile, { force: true }).catch(
707
+ (e2) => debugLog("launchUi: failed to remove stale pidfile", errorMessage(e2))
708
+ );
709
+ }
710
+ let lastError = "";
711
+ let uiSpecs = memoirSpawnSpecs(["ui", store]);
712
+ if (memoirResolved) {
713
+ const winner = uiSpecs.find((s) => s.label === memoirResolved);
714
+ if (winner) uiSpecs = reorderResolver(uiSpecs, winner);
715
+ }
716
+ for (const spec of uiSpecs) {
717
+ const child = spawn(spec.command, spec.args, {
718
+ detached: true,
719
+ stdio: ["ignore", "pipe", "pipe"],
720
+ // Scrub process.env — only forward whitelisted vars to avoid leaking secrets
721
+ env: Object.fromEntries(
722
+ Object.entries({
723
+ PATH: process.env.PATH,
724
+ MEMOIR_STORE: process.env.MEMOIR_STORE,
725
+ MEMOIR_DEBUG: process.env.MEMOIR_DEBUG
726
+ }).filter(([, v]) => v != null)
727
+ )
728
+ });
729
+ let output = "";
730
+ child.stdout?.on("data", (chunk) => {
731
+ output += String(chunk);
732
+ });
733
+ child.stderr?.on("data", (chunk) => {
734
+ output += String(chunk);
735
+ });
736
+ const spawnFailed = new Promise((resolve2) => {
737
+ child.once("error", (error2) => resolve2(errorMessage(error2)));
738
+ child.once("spawn", () => resolve2(null));
739
+ });
740
+ const error = await spawnFailed;
741
+ if (error) {
742
+ lastError = `${spec.label}: ${error}`;
743
+ continue;
744
+ }
745
+ child.unref();
746
+ const urlPattern = /https?:\/\/(?:localhost|127\.0\.0\.1):\d+\S*/;
747
+ const deadline = Date.now() + UI_URL_DEADLINE_MS;
748
+ while (Date.now() < deadline) {
749
+ const match = output.match(urlPattern);
750
+ if (match) {
751
+ const url = match[0];
752
+ const data = {
753
+ pid: child.pid,
754
+ url,
755
+ store,
756
+ command: spec.label,
757
+ started: (/* @__PURE__ */ new Date()).toISOString(),
758
+ reused: false
759
+ };
760
+ await writeFile(pidfile, JSON.stringify(data, null, 2));
761
+ return JSON.stringify(data, null, 2);
799
762
  }
800
- } finally {
801
- releaseLock();
763
+ await new Promise((resolve2) => setTimeout(resolve2, UI_URL_POLL_MS));
802
764
  }
803
- } catch (e) {
804
- debugLog("flushCapture: failed:", errorMessage(e));
765
+ return `Memoir UI started with ${spec.label} (pid ${child.pid ?? "unknown"}), but URL was not detected yet.
766
+ ${output.trim()}`.trim();
805
767
  }
768
+ return `Memoir UI failed to start: ${lastError || "no launcher succeeded"}`;
806
769
  }
807
-
808
- // src/capture-hooks.ts
809
- async function handleShellEnv(storeRoot, _input, output) {
810
- try {
811
- output.env.MEMOIR_STORE = storeRoot;
812
- } catch (e) {
813
- debugLog("shell.env: failed:", errorMessage(e));
770
+ async function unmergedBranchesText(store) {
771
+ await ensureStore(store);
772
+ const branchResult = await runMemoir(["--json", "-s", store, "branch"], { cwd: store });
773
+ if (!branchResult.ok) {
774
+ return branchResult.error;
814
775
  }
815
- }
816
- async function handleToolExecuteAfter(input, output) {
817
- try {
818
- const typedInput = input;
819
- const typedOutput = output;
820
- const sid = typedInput?.sessionID ?? "default";
821
- if (process.env.MEMOIR_NO_CAPTURE !== "1" && process.env.MEMOIR_NO_METRICS !== "1") {
822
- const calls = 1;
823
- const errors = typedOutput?.metadata?.error || typedOutput?.output?.startsWith("Error:") ? 1 : 0;
824
- recordToolMetrics(sid, typedInput?.tool ?? "", { calls, errors });
776
+ const data = JSON.parse(branchResult.stdout);
777
+ const branches = data?.branches ?? [];
778
+ const unmerged = [];
779
+ for (const branch of branches) {
780
+ if (branch === "main") continue;
781
+ const diffResult = await runMemoir(["-s", store, "diff", branch, "main", "--stat"], {
782
+ cwd: store
783
+ });
784
+ if (!diffResult.ok) {
785
+ debugLog("command.execute.before: memoir diff failed", diffResult.error);
786
+ continue;
825
787
  }
826
- if (process.env.MEMOIR_NO_CAPTURE !== "1" && process.env.MEMOIR_NO_CODE_SUMMARY !== "1" && EDIT_TOOLS.has(typedInput?.tool ?? "")) {
827
- const args = typedInput?.args ?? {};
828
- const filePath = typeof args.filePath === "string" ? args.filePath : typeof args.path === "string" ? args.path : "";
829
- if (filePath) {
830
- recordEdit(sid, { tool: typedInput?.tool ?? "", filePath, snippet: "", timestamp: Date.now() });
831
- }
788
+ if (diffResult.stdout.trim()) {
789
+ unmerged.push(branch);
832
790
  }
833
- } catch (e) {
834
- debugLog("tool.execute.after: failed:", errorMessage(e));
835
791
  }
792
+ return unmerged.length > 0 ? `Unmerged branches:
793
+ ${unmerged.join("\n")}` : "All branches are merged into main.";
836
794
  }
837
- async function handleChatMessage(storeRoot, input, output) {
795
+
796
+ // src/commands.ts
797
+ function pushText(output, text) {
798
+ output.parts.length = 0;
799
+ output.parts.push({ type: "text", text });
800
+ }
801
+ function registerCommands(config) {
802
+ config.command = config.command ?? {};
803
+ config.command["memoir:status"] = {
804
+ description: "Show Memoir status for the current OpenCode project",
805
+ template: "Show Memoir status for this OpenCode project."
806
+ };
807
+ config.command["memoir:ui"] = {
808
+ description: "Launch or reopen the Memoir web UI for this project store",
809
+ template: "Launch or reopen the Memoir UI for this project."
810
+ };
811
+ config.command["memoir:remember"] = {
812
+ description: "Save a durable fact, preference, rule, or decision to Memoir",
813
+ template: `Use Memoir to save this durable memory now.
814
+
815
+ USER REQUEST:
816
+ $ARGUMENTS
817
+
818
+ Extract the memory content, choose a semantic path if none is supplied, then call the memoir_remember tool. Never save secrets.`
819
+ };
820
+ config.command["memoir:recall"] = {
821
+ description: "Recall relevant facts from Memoir before answering",
822
+ template: `Recall relevant Memoir memories for this request.
823
+
824
+ USER REQUEST:
825
+ $ARGUMENTS
826
+
827
+ Use memoir_recall first. It checks default plus onboard namespaces unless a namespace is specified. Then call memoir_get with the matching namespace for exact values before answering.`
828
+ };
829
+ config.command["memoir:onboard"] = {
830
+ description: "Populate or refresh Memoir onboarding for this project",
831
+ template: `Populate or refresh Memoir onboarding for the CURRENT OpenCode project only.
832
+
833
+ USER REQUEST:
834
+ $ARGUMENTS
835
+
836
+ Workflow:
837
+ - Stay inside the current project/worktree. Do not inspect parent directories.
838
+ - First obtain a project file tree to understand structure.
839
+ - Start studying from project documentation.
840
+ - Continue only based on what the tree and documentation show.
841
+
842
+ Memory rules:
843
+ - Record only verified facts from files/docs/code or explicit user statements.
844
+ - Do not write inferred user thoughts, intentions, preferences, or opinions.
845
+ - Do not use preferences.* paths unless the user explicitly stated a preference.
846
+ - If a fact is your interpretation, do not save it; report it as uncertain instead.
847
+
848
+ Then call memoir_remember with replace=true for durable onboarding facts. Use namespace codebase:onboard in git repositories and project:onboard outside git. Do not install or invoke separate skills/scripts.`
849
+ };
850
+ config.command["memoir:unmerged"] = {
851
+ description: "List memoir branches with changes not yet merged into main",
852
+ template: "List memoir branches that have diverged from main."
853
+ };
854
+ }
855
+ async function handleCommandExecuteBefore(storeRoot, input, output) {
838
856
  try {
839
- const typedInput = input;
840
- const typedOutput = output;
841
- const sid = typedInput?.sessionID ?? "default";
842
- const text = (typedOutput?.parts ?? []).filter((p) => p.type === "text").map((p) => p.text).join(" ");
843
- if (shouldTriggerRecall(text)) {
844
- pendingRecall.add(sid);
857
+ if (input.command === "memoir:status") {
858
+ pushText(output, await statusJson(storeRoot));
845
859
  }
846
- const _cachedBranch = getCachedBranch(sid);
847
- const prevBranch = _cachedBranch === "unknown" ? void 0 : _cachedBranch;
848
- setCachedBranch(sid, await autoMatchMemoirBranch(storeRoot));
849
- if (process.env.MEMOIR_NO_CAPTURE !== "1") {
850
- await flushCapture(storeRoot, prevBranch, sid);
860
+ if (input.command === "memoir:ui") {
861
+ pushText(output, await launchUi(storeRoot));
851
862
  }
852
- } catch (e) {
853
- debugLog("chat.message: failed:", errorMessage(e));
863
+ if (input.command === "memoir:remember") {
864
+ const args = input.arguments;
865
+ if (args && isSecretSanitizationEnabled() && SECRET_PATTERN.test(args)) {
866
+ pushText(
867
+ output,
868
+ "Memoir: cannot remember content that matches secret patterns. Please remove sensitive data and try again."
869
+ );
870
+ }
871
+ }
872
+ if (input.command === "memoir:unmerged") {
873
+ pushText(output, await unmergedBranchesText(storeRoot));
874
+ }
875
+ } catch (error) {
876
+ pushText(output, `Memoir command failed: ${errorMessage(error)}`);
854
877
  }
855
878
  }
856
- async function flushAllCapture(storeRoot) {
857
- await flushCapture(storeRoot);
858
- }
859
879
 
860
880
  // src/session-context.ts
861
881
  var initContext = null;
@@ -934,6 +954,113 @@ function clearSessionContext() {
934
954
  sessionsWithContext.clear();
935
955
  }
936
956
 
957
+ // src/tools.ts
958
+ import { tool } from "@opencode-ai/plugin";
959
+ var memoirStatus = tool({
960
+ description: "Show Memoir status for the current OpenCode project store.",
961
+ args: {},
962
+ execute: async () => statusJson(deriveStorePath())
963
+ });
964
+ var memoirRemember = tool({
965
+ description: "Explicitly save a durable memory to Memoir at one or more semantic taxonomy paths.",
966
+ args: {
967
+ content: tool.schema.string().describe("Memory content to save. Do not include secrets."),
968
+ path: tool.schema.string().describe("Semantic taxonomy path, e.g. preferences.coding.style."),
969
+ namespace: tool.schema.string().optional().describe("Memoir namespace. Defaults to default."),
970
+ replace: tool.schema.boolean().optional().describe("Replace existing value at the path.")
971
+ },
972
+ execute: async (args) => {
973
+ const content = args.content?.trim();
974
+ if (!content) return "Memoir memory was not saved: content is empty.";
975
+ if (isSecretSanitizationEnabled() && SECRET_PATTERN.test(content)) {
976
+ return "Memoir memory was not saved: the content looks like a secret or credential. Save a redacted rule instead.";
977
+ }
978
+ const paths = coercePaths(args.path);
979
+ if (paths.length === 0) {
980
+ return "Memoir memory was not saved: provide a semantic path, e.g. preferences.coding.style.";
981
+ }
982
+ const store = deriveStorePath();
983
+ const storeError = await ensureStoreOrError(store);
984
+ if (storeError) return storeError;
985
+ const cliArgs = ["--json", "-s", store, "remember", content];
986
+ for (const p of paths) cliArgs.push("-p", p);
987
+ cliArgs.push("-n", args.namespace ?? "default");
988
+ if (args.replace) cliArgs.push("--replace");
989
+ const rememberResult = await runMemoir(cliArgs, { cwd: store });
990
+ if (!rememberResult.ok) return rememberResult.error;
991
+ return tryPrettyJson(rememberResult.stdout);
992
+ }
993
+ });
994
+ var memoirRecall = tool({
995
+ description: "List Memoir memory keys across relevant namespaces for relevance picking. Never calls legacy memoir recall.",
996
+ args: {
997
+ query: tool.schema.string().optional().describe("User query or topic to recall for."),
998
+ namespace: tool.schema.string().optional().describe(
999
+ "Single Memoir namespace to inspect. If omitted, checks default + onboard namespaces."
1000
+ ),
1001
+ namespaces: tool.schema.array(tool.schema.string()).optional().describe("Namespaces to inspect. Defaults to default, project:onboard, codebase:onboard."),
1002
+ includeMetrics: tool.schema.boolean().optional().describe("Include metrics.* memories in the listing.")
1003
+ },
1004
+ execute: async (args) => {
1005
+ const store = deriveStorePath();
1006
+ const storeError = await ensureStoreOrError(store);
1007
+ if (storeError) return storeError;
1008
+ const namespaces = args.namespace ? [args.namespace] : args.namespaces && args.namespaces.length > 0 ? args.namespaces : DEFAULT_RECALL_NAMESPACES;
1009
+ const results = await Promise.all(
1010
+ namespaces.map(async (namespace) => {
1011
+ const summarizeResult = await runMemoir(
1012
+ ["--json", "-s", store, "summarize", "--depth", "3", "-n", namespace],
1013
+ { cwd: store }
1014
+ );
1015
+ if (!summarizeResult.ok) return summarizeResult.error;
1016
+ const output = tryPrettyJson(summarizeResult.stdout);
1017
+ return `## namespace: ${namespace}
1018
+ ${output}`;
1019
+ })
1020
+ );
1021
+ const sections = results.filter((r) => typeof r === "string");
1022
+ const errorResult = results.find(
1023
+ (r) => typeof r === "string" && r.startsWith("Memoir command failed")
1024
+ );
1025
+ if (errorResult) return errorResult;
1026
+ const note = args.includeMetrics ? "Metrics were included by request." : "Ignore metrics.* and taxonomy:v1:* entries unless explicitly needed. If default is empty or only metrics, inspect project:onboard/codebase:onboard before concluding there is no memory.";
1027
+ const query = args.query ? `Query: ${args.query}
1028
+ ` : "";
1029
+ return `${query}${note}
1030
+ Pick at most 5-7 relevant exact keys across namespaces, then call memoir_get with the matching namespace if values are needed.
1031
+ ${sections.join("\n\n")}`;
1032
+ }
1033
+ });
1034
+ var memoirGet = tool({
1035
+ description: "Fetch exact Memoir memory keys after selecting them from memoir_recall output.",
1036
+ args: {
1037
+ keys: tool.schema.array(tool.schema.string()).describe("Exact memory keys to fetch."),
1038
+ namespace: tool.schema.string().optional().describe("Memoir namespace. Defaults to default.")
1039
+ },
1040
+ execute: async (args) => {
1041
+ const keys = args.keys?.map((key) => key.trim()).filter(Boolean) ?? [];
1042
+ if (keys.length === 0) return "No Memoir keys were provided.";
1043
+ if (keys.length > MEMOIR_GET_MAX_KEYS) {
1044
+ return `Error: Too many keys requested (max ${MEMOIR_GET_MAX_KEYS}, got ${keys.length}). Narrow your selection from memoir_recall output.`;
1045
+ }
1046
+ const store = deriveStorePath();
1047
+ const storeError = await ensureStoreOrError(store);
1048
+ if (storeError) return storeError;
1049
+ const getResult = await runMemoir(
1050
+ ["--json", "-s", store, "get", ...keys, "-n", args.namespace ?? "default"],
1051
+ { cwd: store }
1052
+ );
1053
+ if (!getResult.ok) return getResult.error;
1054
+ return tryPrettyJson(getResult.stdout);
1055
+ }
1056
+ });
1057
+ var memoirTools = {
1058
+ memoir_status: memoirStatus,
1059
+ memoir_remember: memoirRemember,
1060
+ memoir_recall: memoirRecall,
1061
+ memoir_get: memoirGet
1062
+ };
1063
+
937
1064
  // src/index.ts
938
1065
  var MemoirOpenCode = async (_input, rawOptions) => {
939
1066
  const opts = rawOptions ?? {};