opencode-memoir 1.1.2 → 1.1.4

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,11 +1,3 @@
1
- // src/index.ts
2
- import { spawn } from "node:child_process";
3
- import { createHash } from "node:crypto";
4
- import { mkdir as mkdir2, readFile, rm as rm2, writeFile } from "node:fs/promises";
5
- import { join as join2 } from "node:path";
6
- import { homedir as homedir2 } from "node:os";
7
- import { tool } from "@opencode-ai/plugin";
8
-
9
1
  // src/store.ts
10
2
  import { execFile, execFileSync } from "node:child_process";
11
3
  import { access, mkdir, rm } from "node:fs/promises";
@@ -140,8 +132,8 @@ async function ensureStore(store) {
140
132
  await mkdir(tmpDir, { recursive: true });
141
133
  await execFileAsync("git", ["init", "-q", tmpDir], { timeout: 5e3 });
142
134
  const result = await runMemoir(["new", store, "--taxonomy-builtin"], { cwd: tmpDir });
143
- if (result.startsWith("Memoir command failed")) {
144
- throw new Error(result);
135
+ if (!result.ok) {
136
+ throw new Error(result.error);
145
137
  }
146
138
  ensuredStores.add(store);
147
139
  if (ensuredStores.size > ENSURED_STORES_MAX) ensuredStores.clear();
@@ -174,6 +166,8 @@ async function runMemoir(args, options = {}) {
174
166
  const winner = specs.find((s) => s.label === memoirResolved);
175
167
  if (winner) specs = reorderResolver(specs, winner);
176
168
  }
169
+ let lastError = "";
170
+ let lastResolver;
177
171
  for (const spec of specs) {
178
172
  try {
179
173
  const { stdout } = await execFileAsync(spec.command, spec.args, {
@@ -184,25 +178,15 @@ async function runMemoir(args, options = {}) {
184
178
  // prevent hang if memoir CLI stalls
185
179
  });
186
180
  memoirResolved = spec.label;
187
- return stdout.trim();
181
+ return { ok: true, stdout: stdout.trim() };
188
182
  } catch (e) {
189
- debugLog("runMemoir: fallback", spec.label, "failed:", errorMessage(e));
183
+ lastError = errorMessage(e);
184
+ lastResolver = spec.label;
185
+ debugLog("runMemoir: fallback", spec.label, "failed:", lastError);
190
186
  if (memoirResolved === spec.label) memoirResolved = null;
191
187
  }
192
188
  }
193
- try {
194
- const { stdout } = await execFileAsync("memoir", args, {
195
- cwd: options.cwd ?? process.cwd(),
196
- env: process.env,
197
- maxBuffer: MEMOIR_MAX_BUFFER,
198
- timeout: MEMOIR_TIMEOUT_MS
199
- });
200
- return stdout.trim();
201
- } catch (memoirError) {
202
- const err = memoirError;
203
- const detail = (err.stderr || err.stdout || err.message).trim();
204
- return `Memoir command failed${err.code ? ` (${err.code})` : ""}: ${detail}`;
205
- }
189
+ return { ok: false, error: `Memoir command failed: ${lastError}`, resolver: lastResolver };
206
190
  }
207
191
  function memoirSpawnSpecs(args) {
208
192
  return [
@@ -213,8 +197,10 @@ function memoirSpawnSpecs(args) {
213
197
  }
214
198
  async function getCurrentBranch(store) {
215
199
  try {
216
- const raw = await runMemoir(["--json", "-s", store, "status"], { cwd: store });
217
- const data = JSON.parse(raw);
200
+ const runFn = _storeTestOverrides.runMemoirFn ?? runMemoir;
201
+ const result = await runFn(["--json", "-s", store, "status"], { cwd: store });
202
+ if (!result.ok) return "unknown";
203
+ const data = JSON.parse(result.stdout);
218
204
  return data.branch || "unknown";
219
205
  } catch (e) {
220
206
  debugLog("getCurrentBranch: failed:", errorMessage(e));
@@ -223,7 +209,8 @@ async function getCurrentBranch(store) {
223
209
  }
224
210
  async function codeGitBranch() {
225
211
  try {
226
- const { stdout } = await execFileAsync("git", ["branch", "--show-current"], { encoding: "utf8", timeout: GIT_TIMEOUT_MS });
212
+ const execFn = _storeTestOverrides.execFileAsyncFn ?? execFileAsync;
213
+ const { stdout } = await execFn("git", ["branch", "--show-current"], { encoding: "utf8", timeout: GIT_TIMEOUT_MS });
227
214
  return stdout.trim();
228
215
  } catch (e) {
229
216
  debugLog("codeGitBranch: failed:", errorMessage(e));
@@ -233,8 +220,10 @@ async function codeGitBranch() {
233
220
  async function branchExistsInMemoir(store, name) {
234
221
  if (!name) return false;
235
222
  try {
236
- const raw = await runMemoir(["--json", "-s", store, "branch"], { cwd: store });
237
- const data = JSON.parse(raw);
223
+ const runFn = _storeTestOverrides.runMemoirFn ?? runMemoir;
224
+ const result = await runFn(["--json", "-s", store, "branch"], { cwd: store });
225
+ if (!result.ok) return false;
226
+ const data = JSON.parse(result.stdout);
238
227
  const branches = data?.branches ?? [];
239
228
  return branches.includes(name);
240
229
  } catch (e) {
@@ -243,6 +232,7 @@ async function branchExistsInMemoir(store, name) {
243
232
  }
244
233
  }
245
234
  async function autoMatchMemoirBranch(store) {
235
+ const runFn = _storeTestOverrides.runMemoirFn ?? runMemoir;
246
236
  const codeBranch = await codeGitBranch();
247
237
  if (!codeBranch) {
248
238
  return getCurrentBranch(store);
@@ -250,23 +240,25 @@ async function autoMatchMemoirBranch(store) {
250
240
  const current = await getCurrentBranch(store);
251
241
  if (current === codeBranch) return current;
252
242
  if (!await branchExistsInMemoir(store, codeBranch)) {
253
- const result2 = await runMemoir(["-s", store, "branch", codeBranch, "--from", "main"], { cwd: store });
254
- if (result2.startsWith("Memoir command failed")) {
255
- debugLog("autoMatchMemoirBranch: create branch failed:", result2);
243
+ const result2 = await runFn(["-s", store, "branch", codeBranch, "--from", "main"], { cwd: store });
244
+ if (!result2.ok) {
245
+ debugLog("autoMatchMemoirBranch: create branch failed:", result2.error);
256
246
  return getCurrentBranch(store);
257
247
  }
258
248
  }
259
- const result = await runMemoir(["-s", store, "checkout", codeBranch], { cwd: store });
260
- if (result.startsWith("Memoir command failed")) {
261
- debugLog("autoMatchMemoirBranch: checkout failed:", result);
249
+ const result = await runFn(["-s", store, "checkout", codeBranch], { cwd: store });
250
+ if (!result.ok) {
251
+ debugLog("autoMatchMemoirBranch: checkout failed:", result.error);
262
252
  return getCurrentBranch(store);
263
253
  }
264
254
  return codeBranch;
265
255
  }
266
256
  async function readMemoirValue(store, key, namespace = "default") {
267
257
  try {
268
- const raw = await runMemoir(["--json", "-s", store, "get", key, "-n", namespace], { cwd: store });
269
- const parsed = JSON.parse(raw);
258
+ const runFn = _storeTestOverrides.runMemoirFn ?? runMemoir;
259
+ const result = await runFn(["--json", "-s", store, "get", key, "-n", namespace], { cwd: store });
260
+ if (!result.ok) return "";
261
+ const parsed = JSON.parse(result.stdout);
270
262
  const items = parsed?.items ?? [];
271
263
  const value = items[0]?.value?.content;
272
264
  return typeof value === "string" ? value : "";
@@ -275,221 +267,17 @@ async function readMemoirValue(store, key, namespace = "default") {
275
267
  return "";
276
268
  }
277
269
  }
270
+ var _storeTestOverrides = {};
278
271
 
279
- // src/capture.ts
280
- var _cliOverrides = null;
281
- var EDIT_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch", "ApplyPatch", "MultiFileEdit"]);
282
- function evictOldest(map, maxSize) {
283
- if (map.size <= maxSize) return;
284
- const firstKey = map.keys().next().value;
285
- if (firstKey !== void 0) map.delete(firstKey);
286
- }
287
- var METRICS_CODE_MAX = 1e3;
288
- var pendingEditsBySession = /* @__PURE__ */ new Map();
289
- var toolMetricsBySession = /* @__PURE__ */ new Map();
290
- var cachedBranchBySession = /* @__PURE__ */ new Map();
291
- function getCachedBranch(sessionID) {
292
- return cachedBranchBySession.get(sessionID) ?? "unknown";
293
- }
294
- function setCachedBranch(sessionID, branch) {
295
- cachedBranchBySession.set(sessionID, branch);
296
- evictOldest(cachedBranchBySession, 200);
297
- }
298
- function recordEdit(sessionID, edit) {
299
- let edits = pendingEditsBySession.get(sessionID);
300
- if (!edits) {
301
- edits = [];
302
- pendingEditsBySession.set(sessionID, edits);
303
- evictOldest(pendingEditsBySession, 500);
304
- }
305
- edits.push(edit);
306
- }
307
- function recordToolMetrics(sessionID, tool2, metrics) {
308
- let sessionMetrics = toolMetricsBySession.get(sessionID);
309
- if (!sessionMetrics) {
310
- sessionMetrics = /* @__PURE__ */ new Map();
311
- toolMetricsBySession.set(sessionID, sessionMetrics);
312
- evictOldest(toolMetricsBySession, 500);
313
- }
314
- const prev = sessionMetrics.get(tool2) ?? { calls: 0, errors: 0 };
315
- sessionMetrics.set(tool2, {
316
- calls: prev.calls + metrics.calls,
317
- errors: prev.errors + metrics.errors
318
- });
319
- }
320
- function parseTurnMetrics(text) {
321
- const result = /* @__PURE__ */ new Map();
322
- if (!text) return result;
323
- const parts = text.split("|");
324
- for (const part of parts) {
325
- const trimmed = part.trim();
326
- if (!trimmed) continue;
327
- const [tool2, callsStr, errorsStr] = trimmed.split(":");
328
- if (!tool2) continue;
329
- result.set(tool2, {
330
- calls: parseInt(callsStr, 10) || 0,
331
- errors: parseInt(errorsStr, 10) || 0
332
- });
333
- }
334
- return result;
335
- }
336
- function serializeTurnMetrics(map) {
337
- return [...map.entries()].map(([tool2, m]) => `${tool2}:${m.calls}:${m.errors}`).join(" | ");
338
- }
339
- var flushQueues = /* @__PURE__ */ new Map();
340
- async function acquireFlushLock(store) {
341
- const prev = flushQueues.get(store) ?? Promise.resolve();
342
- let release;
343
- const next = new Promise((resolve2) => {
344
- release = resolve2;
345
- });
346
- flushQueues.set(store, next);
347
- await prev;
348
- return () => {
349
- release();
350
- if (flushQueues.get(store) === next) flushQueues.delete(store);
351
- };
352
- }
353
- async function flushCapture(store, branch, sessionID) {
354
- const targets = sessionID ? [sessionID] : [...pendingEditsBySession.keys()];
355
- if (targets.length === 0) return;
356
- try {
357
- store = store ?? deriveStorePath();
358
- if (!store) return;
359
- await ensureStore(store);
360
- const releaseLock = await acquireFlushLock(store);
361
- try {
362
- const perSession = [];
363
- const swappedSids = /* @__PURE__ */ new Map();
364
- for (const sid of targets) {
365
- const idx = perSession.length;
366
- const edits = pendingEditsBySession.get(sid) ?? [];
367
- pendingEditsBySession.set(sid, []);
368
- const metrics = toolMetricsBySession.get(sid) ?? /* @__PURE__ */ new Map();
369
- toolMetricsBySession.set(sid, /* @__PURE__ */ new Map());
370
- const sb = branch ?? cachedBranchBySession.get(sid);
371
- perSession.push({ edits, metrics, sessionBranch: sb ?? "unknown" });
372
- swappedSids.set(sid, idx);
373
- }
374
- if (!perSession.some((p) => p.edits.length > 0 || p.metrics.size > 0)) return;
375
- try {
376
- for (const p of perSession) {
377
- if (p.sessionBranch === "unknown" || !p.sessionBranch) {
378
- p.sessionBranch = await (_cliOverrides?.getCurrentBranch ?? getCurrentBranch)(store);
379
- }
380
- }
381
- for (const p of perSession) {
382
- const { edits, metrics, sessionBranch: branchKey } = p;
383
- if (edits.length === 0 && metrics.size === 0) continue;
384
- const [prevCodeRaw, prevTurnRaw] = await Promise.all([
385
- edits.length > 0 ? (_cliOverrides?.readMemoirValue ?? readMemoirValue)(store, `metrics.code.${branchKey}`) : Promise.resolve(""),
386
- metrics.size > 0 ? (_cliOverrides?.readMemoirValue ?? readMemoirValue)(store, `metrics.turn.${branchKey}`) : Promise.resolve("")
387
- ]);
388
- let codeWrite;
389
- if (edits.length > 0) {
390
- const files = [...new Set(edits.map((e) => e.filePath))];
391
- const entry = {
392
- timestamp: Date.now() / 1e3,
393
- summary: `Changed ${edits.length} block(s) across ${files.length} file(s): ${files.join(", ")}`,
394
- files
395
- };
396
- let acc = {
397
- schema_version: 2,
398
- entries: []
399
- };
400
- if (prevCodeRaw) {
401
- try {
402
- const parsed = JSON.parse(prevCodeRaw);
403
- if (parsed?.entries && Array.isArray(parsed.entries)) {
404
- acc = parsed;
405
- if (acc.schema_version < 2) acc.schema_version = 2;
406
- }
407
- } catch (e) {
408
- debugLog("flushCapture: failed to parse existing code metrics, starting fresh:", errorMessage(e));
409
- }
410
- }
411
- acc.entries.push(entry);
412
- if (acc.entries.length > METRICS_CODE_MAX) acc.entries = acc.entries.slice(-METRICS_CODE_MAX);
413
- codeWrite = (_cliOverrides?.runMemoir ?? runMemoir)(["-s", store, "remember", "--replace", JSON.stringify(acc), "-p", `metrics.code.${branchKey}`], { cwd: store });
414
- }
415
- let turnWrite;
416
- if (metrics.size > 0) {
417
- const existing = parseTurnMetrics(prevTurnRaw);
418
- for (const [tool2, current] of metrics) {
419
- const prev = existing.get(tool2) ?? { calls: 0, errors: 0 };
420
- existing.set(tool2, { calls: prev.calls + current.calls, errors: prev.errors + current.errors });
421
- }
422
- turnWrite = (_cliOverrides?.runMemoir ?? runMemoir)(["-s", store, "remember", "--replace", serializeTurnMetrics(existing), "-p", `metrics.turn.${branchKey}`], { cwd: store });
423
- }
424
- const results = await Promise.all([codeWrite, turnWrite].filter(Boolean));
425
- for (const result of results) {
426
- if (typeof result === "string" && result.startsWith("Memoir command failed")) {
427
- throw new Error(`Memoir write failed: ${result}`);
428
- }
429
- }
430
- }
431
- } catch (e) {
432
- for (const [sid, idx] of swappedSids) {
433
- const p = perSession[idx];
434
- const currentEdits = pendingEditsBySession.get(sid) ?? [];
435
- pendingEditsBySession.set(sid, [...p.edits, ...currentEdits]);
436
- const merged = toolMetricsBySession.get(sid) ?? /* @__PURE__ */ new Map();
437
- for (const [tool2, m] of p.metrics) {
438
- const prev = merged.get(tool2) ?? { calls: 0, errors: 0 };
439
- merged.set(tool2, { calls: prev.calls + m.calls, errors: prev.errors + m.errors });
440
- }
441
- toolMetricsBySession.set(sid, merged);
442
- }
443
- throw e;
444
- }
445
- } finally {
446
- releaseLock();
447
- }
448
- } catch (e) {
449
- debugLog("flushCapture: failed:", errorMessage(e));
450
- }
451
- }
452
-
453
- // src/recall-gate.ts
454
- function isSecretSanitizationEnabled() {
455
- return process.env.MEMOIR_SANITIZE_SECRETS !== "0";
456
- }
457
- var pendingRecall = /* @__PURE__ */ new Set();
458
- var ACK_PATTERN = /^(ok|thanks|thank you|sounds good|got it|👍|🙏|perfect|great|cool|nice|awesome|understood|makes sense|agree|right|sure|yes|no|done|nvm|never mind|lgtm|looks good|proceed|continue|good|fine)\b/i;
459
- var EXPLICIT_RECALL_PATTERN = /\b(memoir:recall|memoir:remember|memoir-recall|memoir-remember)\b|(\/recall|\/remember)\b/i;
460
- var RECALL_TRIGGER_PATTERNS = [
461
- // Action verbs and domain nouns
462
- /\b(add|build|implement|refactor|redesign|design|create|write|set( |-)up|wire( |-)up|integrate|migrate|rewrite|extract|extend|plumb|hook( |-)up|ship|scaffold|optimize|fix|debug|review|architect|model|schema|API|service|feature|module|system|pipeline|workflow|make|move|replace|convert|swap|remove|clean( |-)up|transform|investigate|explore|figure( |-)out|plan|decide|choose|pick|compare|walk( |-)?me( |-)?through|take( |-)?a( |-)?stab|help( |-)?me|harness|hook|prompt|test)\b/i,
463
- // Question starts (how/why/what/where/when/should/can/could/would/is it/are we/do I/does it)
464
- /^(how|why|what|where|when|should|can|could|would|is it|are we|do I|does it)\b.*\?/im,
465
- // Code blocks (triple backticks)
466
- /```/,
467
- // Code definitions
468
- /\b(def|function|class|import|export)\s+/,
469
- // Memoir/recall keywords
470
- /\b(memoir|recall|remember|memory)\b/i,
471
- // File extensions
472
- /\b\w+\.(py|js|ts|tsx|scala|java|go|rs|rb|md|json|yaml|yml|toml|sh|bash|css|html|kt|swift|c|cpp|h|hpp)\b/i,
473
- // File paths (slash-containing tokens)
474
- /\w+\/\w+/
475
- ];
476
- function isAcknowledgement(text) {
477
- const trimmed = text.trim().toLowerCase();
478
- const words = trimmed.split(/\s+/);
479
- return words.length <= 5 && ACK_PATTERN.test(trimmed);
480
- }
481
- function shouldTriggerRecall(text) {
482
- const trimmed = text.trim();
483
- if (!trimmed) return false;
484
- if (EXPLICIT_RECALL_PATTERN.test(trimmed)) return true;
485
- if (trimmed.length < 10) return false;
486
- if (isAcknowledgement(trimmed)) return false;
487
- if (trimmed.length >= 40 && RECALL_TRIGGER_PATTERNS.some((p) => p.test(trimmed))) return true;
488
- return false;
489
- }
490
- var DEFAULT_RECALL_NAMESPACES = ["default", "project:onboard", "codebase:onboard"];
272
+ // src/tools.ts
273
+ import { tool } from "@opencode-ai/plugin";
491
274
 
492
- // src/index.ts
275
+ // src/memoir-ops.ts
276
+ import { spawn } from "node:child_process";
277
+ import { createHash } from "node:crypto";
278
+ import { mkdir as mkdir2, readFile, rm as rm2, writeFile } from "node:fs/promises";
279
+ import { join as join2 } from "node:path";
280
+ import { homedir as homedir2 } from "node:os";
493
281
  var UI_URL_DEADLINE_MS = 5e3;
494
282
  var UI_URL_POLL_MS = 100;
495
283
  async function ensureStoreOrError(store) {
@@ -503,15 +291,16 @@ async function ensureStoreOrError(store) {
503
291
  async function statusJson(store) {
504
292
  const storeErr = await ensureStoreOrError(store);
505
293
  if (storeErr) return `Memoir command failed: ${storeErr}`;
506
- const raw = await runMemoir(["--json", "-s", store, "status"], { cwd: store });
294
+ const result = await runMemoir(["--json", "-s", store, "status"], { cwd: store });
295
+ if (!result.ok) return result.error;
507
296
  try {
508
- const data = JSON.parse(raw);
297
+ const data = JSON.parse(result.stdout);
509
298
  const branch = await codeGitBranch();
510
299
  data.opencode = { store, project_git_root: process.cwd(), project_git_branch: branch };
511
300
  return JSON.stringify(data, null, 2);
512
301
  } catch (e) {
513
302
  debugLog("statusJson: failed to parse JSON:", errorMessage(e));
514
- return raw;
303
+ return result.stdout;
515
304
  }
516
305
  }
517
306
  async function launchUi(store) {
@@ -580,86 +369,70 @@ ${output.trim()}`.trim();
580
369
  }
581
370
  return `Memoir UI failed to start: ${lastError || "no launcher succeeded"}`;
582
371
  }
583
- function pushText(output, text) {
584
- output.parts.length = 0;
585
- output.parts.push({ type: "text", text });
586
- }
587
- var initContext = null;
588
- var initContextFetched = false;
589
- var SESSION_CONTEXT_TTL_MS = 60 * 60 * 1e3;
590
- var sessionsWithContext = /* @__PURE__ */ new Map();
591
- function hasSessionContext(sessionID) {
592
- const ts = sessionsWithContext.get(sessionID);
593
- if (ts === void 0) return false;
594
- if (Date.now() - ts > SESSION_CONTEXT_TTL_MS) {
595
- sessionsWithContext.delete(sessionID);
596
- return false;
372
+ async function unmergedBranchesText(store) {
373
+ await ensureStore(store);
374
+ const branchResult = await runMemoir(["--json", "-s", store, "branch"], { cwd: store });
375
+ if (!branchResult.ok) {
376
+ return branchResult.error;
597
377
  }
598
- return true;
599
- }
600
- function markSessionContext(sessionID) {
601
- sessionsWithContext.set(sessionID, Date.now());
602
- }
603
- function pruneStaleSessions() {
604
- const now = Date.now();
605
- for (const [id, ts] of sessionsWithContext) {
606
- if (now - ts > SESSION_CONTEXT_TTL_MS) sessionsWithContext.delete(id);
378
+ const data = JSON.parse(branchResult.stdout);
379
+ const branches = data?.branches ?? [];
380
+ const unmerged = [];
381
+ for (const branch of branches) {
382
+ if (branch === "main") continue;
383
+ const diffResult = await runMemoir(["-s", store, "diff", branch, "main", "--stat"], { cwd: store });
384
+ if (!diffResult.ok) {
385
+ debugLog("command.execute.before: memoir diff failed", diffResult.error);
386
+ continue;
387
+ }
388
+ if (diffResult.stdout.trim()) {
389
+ unmerged.push(branch);
390
+ }
607
391
  }
392
+ return unmerged.length > 0 ? `Unmerged branches:
393
+ ${unmerged.join("\n")}` : "All branches are merged into main.";
608
394
  }
609
- function registerCommands(config) {
610
- config.command = config.command ?? {};
611
- config.command["memoir:status"] = {
612
- description: "Show Memoir status for the current OpenCode project",
613
- template: "Show Memoir status for this OpenCode project."
614
- };
615
- config.command["memoir:ui"] = {
616
- description: "Launch or reopen the Memoir web UI for this project store",
617
- template: "Launch or reopen the Memoir UI for this project."
618
- };
619
- config.command["memoir:remember"] = {
620
- description: "Save a durable fact, preference, rule, or decision to Memoir",
621
- template: `Use Memoir to save this durable memory now.
622
395
 
623
- USER REQUEST:
624
- $ARGUMENTS
625
-
626
- Extract the memory content, choose a semantic path if none is supplied, then call the memoir_remember tool. Never save secrets.`
627
- };
628
- config.command["memoir:recall"] = {
629
- description: "Recall relevant facts from Memoir before answering",
630
- template: `Recall relevant Memoir memories for this request.
631
-
632
- USER REQUEST:
633
- $ARGUMENTS
634
-
635
- 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.`
636
- };
637
- config.command["memoir:onboard"] = {
638
- description: "Populate or refresh Memoir onboarding for this project",
639
- template: `Populate or refresh Memoir onboarding for the CURRENT OpenCode project only.
640
-
641
- USER REQUEST:
642
- $ARGUMENTS
643
-
644
- Workflow:
645
- - Stay inside the current project/worktree. Do not inspect parent directories.
646
- - First obtain a project file tree to understand structure.
647
- - Start studying from project documentation.
648
- - Continue only based on what the tree and documentation show.
649
-
650
- Memory rules:
651
- - Record only verified facts from files/docs/code or explicit user statements.
652
- - Do not write inferred user thoughts, intentions, preferences, or opinions.
653
- - Do not use preferences.* paths unless the user explicitly stated a preference.
654
- - If a fact is your interpretation, do not save it; report it as uncertain instead.
655
-
656
- 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.`
657
- };
658
- config.command["memoir:unmerged"] = {
659
- description: "List memoir branches with changes not yet merged into main",
660
- template: "List memoir branches that have diverged from main."
661
- };
396
+ // src/recall-gate.ts
397
+ function isSecretSanitizationEnabled() {
398
+ return process.env.MEMOIR_SANITIZE_SECRETS !== "0";
662
399
  }
400
+ var pendingRecall = /* @__PURE__ */ new Set();
401
+ var ACK_PATTERN = /^(ok|thanks|thank you|sounds good|got it|👍|🙏|perfect|great|cool|nice|awesome|understood|makes sense|agree|right|sure|yes|no|done|nvm|never mind|lgtm|looks good|proceed|continue|good|fine)\b/i;
402
+ var EXPLICIT_RECALL_PATTERN = /\b(memoir:recall|memoir:remember|memoir-recall|memoir-remember)\b|(\/recall|\/remember)\b/i;
403
+ var RECALL_TRIGGER_PATTERNS = [
404
+ // Action verbs and domain nouns
405
+ /\b(add|build|implement|refactor|redesign|design|create|write|set( |-)up|wire( |-)up|integrate|migrate|rewrite|extract|extend|plumb|hook( |-)up|ship|scaffold|optimize|fix|debug|review|architect|model|schema|API|service|feature|module|system|pipeline|workflow|make|move|replace|convert|swap|remove|clean( |-)up|transform|investigate|explore|figure( |-)out|plan|decide|choose|pick|compare|walk( |-)?me( |-)?through|take( |-)?a( |-)?stab|help( |-)?me|harness|hook|prompt|test)\b/i,
406
+ // Question starts (how/why/what/where/when/should/can/could/would/is it/are we/do I/does it)
407
+ /^(how|why|what|where|when|should|can|could|would|is it|are we|do I|does it)\b.*\?/im,
408
+ // Code blocks (triple backticks)
409
+ /```/,
410
+ // Code definitions
411
+ /\b(def|function|class|import|export)\s+/,
412
+ // Memoir/recall keywords
413
+ /\b(memoir|recall|remember|memory)\b/i,
414
+ // File extensions
415
+ /\b\w+\.(py|js|ts|tsx|scala|java|go|rs|rb|md|json|yaml|yml|toml|sh|bash|css|html|kt|swift|c|cpp|h|hpp)\b/i,
416
+ // File paths (slash-containing tokens)
417
+ /\w+\/\w+/
418
+ ];
419
+ function isAcknowledgement(text) {
420
+ const trimmed = text.trim().toLowerCase();
421
+ const words = trimmed.split(/\s+/);
422
+ return words.length <= 5 && ACK_PATTERN.test(trimmed);
423
+ }
424
+ function shouldTriggerRecall(text) {
425
+ const trimmed = text.trim();
426
+ if (!trimmed) return false;
427
+ if (EXPLICIT_RECALL_PATTERN.test(trimmed)) return true;
428
+ if (trimmed.length < 10) return false;
429
+ if (isAcknowledgement(trimmed)) return false;
430
+ if (trimmed.length >= 40 && RECALL_TRIGGER_PATTERNS.some((p) => p.test(trimmed))) return true;
431
+ return false;
432
+ }
433
+ var DEFAULT_RECALL_NAMESPACES = ["default", "project:onboard", "codebase:onboard"];
434
+
435
+ // src/tools.ts
663
436
  var memoirStatus = tool({
664
437
  description: "Show Memoir status for the current OpenCode project store.",
665
438
  args: {},
@@ -690,7 +463,9 @@ var memoirRemember = tool({
690
463
  for (const p of paths) cliArgs.push("-p", p);
691
464
  cliArgs.push("-n", args.namespace ?? "default");
692
465
  if (args.replace) cliArgs.push("--replace");
693
- return tryPrettyJson(await runMemoir(cliArgs, { cwd: store }));
466
+ const rememberResult = await runMemoir(cliArgs, { cwd: store });
467
+ if (!rememberResult.ok) return rememberResult.error;
468
+ return tryPrettyJson(rememberResult.stdout);
694
469
  }
695
470
  });
696
471
  var memoirRecall = tool({
@@ -708,7 +483,9 @@ var memoirRecall = tool({
708
483
  const namespaces = args.namespace ? [args.namespace] : args.namespaces && args.namespaces.length > 0 ? args.namespaces : DEFAULT_RECALL_NAMESPACES;
709
484
  const sections = [];
710
485
  for (const namespace of namespaces) {
711
- const output = tryPrettyJson(await runMemoir(["--json", "-s", store, "summarize", "--depth", "3", "-n", namespace], { cwd: store }));
486
+ const summarizeResult = await runMemoir(["--json", "-s", store, "summarize", "--depth", "3", "-n", namespace], { cwd: store });
487
+ if (!summarizeResult.ok) return summarizeResult.error;
488
+ const output = tryPrettyJson(summarizeResult.stdout);
712
489
  sections.push(`## namespace: ${namespace}
713
490
  ${output}`);
714
491
  }
@@ -735,59 +512,407 @@ var memoirGet = tool({
735
512
  const store = deriveStorePath();
736
513
  const storeError = await ensureStoreOrError(store);
737
514
  if (storeError) return storeError;
738
- return tryPrettyJson(await runMemoir(["--json", "-s", store, "get", ...keys, "-n", args.namespace ?? "default"], { cwd: store }));
515
+ const getResult = await runMemoir(["--json", "-s", store, "get", ...keys, "-n", args.namespace ?? "default"], { cwd: store });
516
+ if (!getResult.ok) return getResult.error;
517
+ return tryPrettyJson(getResult.stdout);
739
518
  }
740
519
  });
520
+ var memoirTools = {
521
+ memoir_status: memoirStatus,
522
+ memoir_remember: memoirRemember,
523
+ memoir_recall: memoirRecall,
524
+ memoir_get: memoirGet
525
+ };
526
+
527
+ // src/commands.ts
528
+ function pushText(output, text) {
529
+ output.parts.length = 0;
530
+ output.parts.push({ type: "text", text });
531
+ }
532
+ function registerCommands(config) {
533
+ config.command = config.command ?? {};
534
+ config.command["memoir:status"] = {
535
+ description: "Show Memoir status for the current OpenCode project",
536
+ template: "Show Memoir status for this OpenCode project."
537
+ };
538
+ config.command["memoir:ui"] = {
539
+ description: "Launch or reopen the Memoir web UI for this project store",
540
+ template: "Launch or reopen the Memoir UI for this project."
541
+ };
542
+ config.command["memoir:remember"] = {
543
+ description: "Save a durable fact, preference, rule, or decision to Memoir",
544
+ template: `Use Memoir to save this durable memory now.
545
+
546
+ USER REQUEST:
547
+ $ARGUMENTS
548
+
549
+ Extract the memory content, choose a semantic path if none is supplied, then call the memoir_remember tool. Never save secrets.`
550
+ };
551
+ config.command["memoir:recall"] = {
552
+ description: "Recall relevant facts from Memoir before answering",
553
+ template: `Recall relevant Memoir memories for this request.
554
+
555
+ USER REQUEST:
556
+ $ARGUMENTS
557
+
558
+ 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.`
559
+ };
560
+ config.command["memoir:onboard"] = {
561
+ description: "Populate or refresh Memoir onboarding for this project",
562
+ template: `Populate or refresh Memoir onboarding for the CURRENT OpenCode project only.
563
+
564
+ USER REQUEST:
565
+ $ARGUMENTS
566
+
567
+ Workflow:
568
+ - Stay inside the current project/worktree. Do not inspect parent directories.
569
+ - First obtain a project file tree to understand structure.
570
+ - Start studying from project documentation.
571
+ - Continue only based on what the tree and documentation show.
572
+
573
+ Memory rules:
574
+ - Record only verified facts from files/docs/code or explicit user statements.
575
+ - Do not write inferred user thoughts, intentions, preferences, or opinions.
576
+ - Do not use preferences.* paths unless the user explicitly stated a preference.
577
+ - If a fact is your interpretation, do not save it; report it as uncertain instead.
578
+
579
+ 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.`
580
+ };
581
+ config.command["memoir:unmerged"] = {
582
+ description: "List memoir branches with changes not yet merged into main",
583
+ template: "List memoir branches that have diverged from main."
584
+ };
585
+ }
586
+ async function handleCommandExecuteBefore(storeRoot, input, output) {
587
+ try {
588
+ if (input.command === "memoir:status") {
589
+ pushText(output, await statusJson(storeRoot));
590
+ }
591
+ if (input.command === "memoir:ui") {
592
+ pushText(output, await launchUi(storeRoot));
593
+ }
594
+ if (input.command === "memoir:unmerged") {
595
+ pushText(output, await unmergedBranchesText(storeRoot));
596
+ }
597
+ } catch (error) {
598
+ pushText(output, `Memoir command failed: ${errorMessage(error)}`);
599
+ }
600
+ }
601
+
602
+ // src/capture.ts
603
+ var _cliOverrides = null;
604
+ var EDIT_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch", "ApplyPatch", "MultiFileEdit"]);
605
+ function evictOldest(map, maxSize) {
606
+ if (map.size <= maxSize) return;
607
+ const firstKey = map.keys().next().value;
608
+ if (firstKey !== void 0) map.delete(firstKey);
609
+ }
610
+ var METRICS_CODE_MAX = 1e3;
611
+ var pendingEditsBySession = /* @__PURE__ */ new Map();
612
+ var toolMetricsBySession = /* @__PURE__ */ new Map();
613
+ var cachedBranchBySession = /* @__PURE__ */ new Map();
614
+ function getCachedBranch(sessionID) {
615
+ return cachedBranchBySession.get(sessionID) ?? "unknown";
616
+ }
617
+ function setCachedBranch(sessionID, branch) {
618
+ cachedBranchBySession.set(sessionID, branch);
619
+ evictOldest(cachedBranchBySession, 200);
620
+ }
621
+ function recordEdit(sessionID, edit) {
622
+ let edits = pendingEditsBySession.get(sessionID);
623
+ if (!edits) {
624
+ edits = [];
625
+ pendingEditsBySession.set(sessionID, edits);
626
+ evictOldest(pendingEditsBySession, 500);
627
+ }
628
+ edits.push(edit);
629
+ }
630
+ function recordToolMetrics(sessionID, tool2, metrics) {
631
+ let sessionMetrics = toolMetricsBySession.get(sessionID);
632
+ if (!sessionMetrics) {
633
+ sessionMetrics = /* @__PURE__ */ new Map();
634
+ toolMetricsBySession.set(sessionID, sessionMetrics);
635
+ evictOldest(toolMetricsBySession, 500);
636
+ }
637
+ const prev = sessionMetrics.get(tool2) ?? { calls: 0, errors: 0 };
638
+ sessionMetrics.set(tool2, {
639
+ calls: prev.calls + metrics.calls,
640
+ errors: prev.errors + metrics.errors
641
+ });
642
+ }
643
+ function parseTurnMetrics(text) {
644
+ const result = /* @__PURE__ */ new Map();
645
+ if (!text) return result;
646
+ const parts = text.split("|");
647
+ for (const part of parts) {
648
+ const trimmed = part.trim();
649
+ if (!trimmed) continue;
650
+ const [tool2, callsStr, errorsStr] = trimmed.split(":");
651
+ if (!tool2) continue;
652
+ result.set(tool2, {
653
+ calls: parseInt(callsStr, 10) || 0,
654
+ errors: parseInt(errorsStr, 10) || 0
655
+ });
656
+ }
657
+ return result;
658
+ }
659
+ function serializeTurnMetrics(map) {
660
+ return [...map.entries()].map(([tool2, m]) => `${tool2}:${m.calls}:${m.errors}`).join(" | ");
661
+ }
662
+ var flushQueues = /* @__PURE__ */ new Map();
663
+ async function acquireFlushLock(store) {
664
+ const prev = flushQueues.get(store) ?? Promise.resolve();
665
+ let release;
666
+ const next = new Promise((resolve2) => {
667
+ release = resolve2;
668
+ });
669
+ flushQueues.set(store, next);
670
+ await prev;
671
+ return () => {
672
+ release();
673
+ if (flushQueues.get(store) === next) flushQueues.delete(store);
674
+ };
675
+ }
676
+ async function flushCapture(store, branch, sessionID) {
677
+ const targets = sessionID ? [sessionID] : [...pendingEditsBySession.keys()];
678
+ if (targets.length === 0) return;
679
+ try {
680
+ store = store ?? deriveStorePath();
681
+ if (!store) return;
682
+ await ensureStore(store);
683
+ const releaseLock = await acquireFlushLock(store);
684
+ try {
685
+ const perSession = [];
686
+ const swappedSids = /* @__PURE__ */ new Map();
687
+ for (const sid of targets) {
688
+ const idx = perSession.length;
689
+ const edits = pendingEditsBySession.get(sid) ?? [];
690
+ pendingEditsBySession.set(sid, []);
691
+ const metrics = toolMetricsBySession.get(sid) ?? /* @__PURE__ */ new Map();
692
+ toolMetricsBySession.set(sid, /* @__PURE__ */ new Map());
693
+ const sb = branch ?? cachedBranchBySession.get(sid);
694
+ perSession.push({ edits, metrics, sessionBranch: sb ?? "unknown" });
695
+ swappedSids.set(sid, idx);
696
+ }
697
+ if (!perSession.some((p) => p.edits.length > 0 || p.metrics.size > 0)) return;
698
+ try {
699
+ for (const p of perSession) {
700
+ if (p.sessionBranch === "unknown" || !p.sessionBranch) {
701
+ p.sessionBranch = await (_cliOverrides?.getCurrentBranch ?? getCurrentBranch)(store);
702
+ }
703
+ }
704
+ for (const p of perSession) {
705
+ const { edits, metrics, sessionBranch: branchKey } = p;
706
+ if (edits.length === 0 && metrics.size === 0) continue;
707
+ const [prevCodeRaw, prevTurnRaw] = await Promise.all([
708
+ edits.length > 0 ? (_cliOverrides?.readMemoirValue ?? readMemoirValue)(store, `metrics.code.${branchKey}`) : Promise.resolve(""),
709
+ metrics.size > 0 ? (_cliOverrides?.readMemoirValue ?? readMemoirValue)(store, `metrics.turn.${branchKey}`) : Promise.resolve("")
710
+ ]);
711
+ let codeWrite;
712
+ if (edits.length > 0) {
713
+ const files = [...new Set(edits.map((e) => e.filePath))];
714
+ const entry = {
715
+ timestamp: Date.now() / 1e3,
716
+ summary: `Changed ${edits.length} block(s) across ${files.length} file(s): ${files.join(", ")}`,
717
+ files
718
+ };
719
+ let acc = {
720
+ schema_version: 2,
721
+ entries: []
722
+ };
723
+ if (prevCodeRaw) {
724
+ try {
725
+ const parsed = JSON.parse(prevCodeRaw);
726
+ if (parsed?.entries && Array.isArray(parsed.entries)) {
727
+ acc = parsed;
728
+ if (acc.schema_version < 2) acc.schema_version = 2;
729
+ }
730
+ } catch (e) {
731
+ debugLog("flushCapture: failed to parse existing code metrics, starting fresh:", errorMessage(e));
732
+ }
733
+ }
734
+ acc.entries.push(entry);
735
+ if (acc.entries.length > METRICS_CODE_MAX) acc.entries = acc.entries.slice(-METRICS_CODE_MAX);
736
+ codeWrite = (_cliOverrides?.runMemoir ?? runMemoir)(["-s", store, "remember", "--replace", JSON.stringify(acc), "-p", `metrics.code.${branchKey}`], { cwd: store });
737
+ }
738
+ let turnWrite;
739
+ if (metrics.size > 0) {
740
+ const existing = parseTurnMetrics(prevTurnRaw);
741
+ for (const [tool2, current] of metrics) {
742
+ const prev = existing.get(tool2) ?? { calls: 0, errors: 0 };
743
+ existing.set(tool2, { calls: prev.calls + current.calls, errors: prev.errors + current.errors });
744
+ }
745
+ turnWrite = (_cliOverrides?.runMemoir ?? runMemoir)(["-s", store, "remember", "--replace", serializeTurnMetrics(existing), "-p", `metrics.turn.${branchKey}`], { cwd: store });
746
+ }
747
+ const writes = [];
748
+ if (codeWrite) writes.push(codeWrite);
749
+ if (turnWrite) writes.push(turnWrite);
750
+ const results = await Promise.all(writes);
751
+ for (const result of results) {
752
+ if (!result.ok) {
753
+ throw new Error(`Memoir write failed: ${result.error}`);
754
+ }
755
+ }
756
+ }
757
+ } catch (e) {
758
+ for (const [sid, idx] of swappedSids) {
759
+ const p = perSession[idx];
760
+ const currentEdits = pendingEditsBySession.get(sid) ?? [];
761
+ pendingEditsBySession.set(sid, [...p.edits, ...currentEdits]);
762
+ const merged = toolMetricsBySession.get(sid) ?? /* @__PURE__ */ new Map();
763
+ for (const [tool2, m] of p.metrics) {
764
+ const prev = merged.get(tool2) ?? { calls: 0, errors: 0 };
765
+ merged.set(tool2, { calls: prev.calls + m.calls, errors: prev.errors + m.errors });
766
+ }
767
+ toolMetricsBySession.set(sid, merged);
768
+ }
769
+ throw e;
770
+ }
771
+ } finally {
772
+ releaseLock();
773
+ }
774
+ } catch (e) {
775
+ debugLog("flushCapture: failed:", errorMessage(e));
776
+ }
777
+ }
778
+
779
+ // src/capture-hooks.ts
780
+ async function handleShellEnv(storeRoot, _input, output) {
781
+ try {
782
+ output.env.MEMOIR_STORE = storeRoot;
783
+ } catch (e) {
784
+ debugLog("shell.env: failed:", errorMessage(e));
785
+ }
786
+ }
787
+ async function handleToolExecuteAfter(input, output) {
788
+ try {
789
+ const typedInput = input;
790
+ const typedOutput = output;
791
+ const sid = typedInput?.sessionID ?? "default";
792
+ if (process.env.MEMOIR_NO_CAPTURE !== "1" && process.env.MEMOIR_NO_METRICS !== "1") {
793
+ const calls = 1;
794
+ const errors = typedOutput?.metadata?.error || typedOutput?.output?.startsWith("Error:") ? 1 : 0;
795
+ recordToolMetrics(sid, typedInput?.tool ?? "", { calls, errors });
796
+ }
797
+ if (process.env.MEMOIR_NO_CAPTURE !== "1" && process.env.MEMOIR_NO_CODE_SUMMARY !== "1" && EDIT_TOOLS.has(typedInput?.tool ?? "")) {
798
+ const args = typedInput?.args ?? {};
799
+ const filePath = typeof args.filePath === "string" ? args.filePath : typeof args.path === "string" ? args.path : "";
800
+ if (filePath) {
801
+ recordEdit(sid, { tool: typedInput?.tool ?? "", filePath, snippet: "", timestamp: Date.now() });
802
+ }
803
+ }
804
+ } catch (e) {
805
+ debugLog("tool.execute.after: failed:", errorMessage(e));
806
+ }
807
+ }
808
+ async function handleChatMessage(storeRoot, input, output) {
809
+ try {
810
+ const typedInput = input;
811
+ const typedOutput = output;
812
+ const sid = typedInput?.sessionID ?? "default";
813
+ const text = (typedOutput?.parts ?? []).filter((p) => p.type === "text").map((p) => p.text).join(" ");
814
+ if (shouldTriggerRecall(text)) {
815
+ pendingRecall.add(sid);
816
+ }
817
+ const _cachedBranch = getCachedBranch(sid);
818
+ const prevBranch = _cachedBranch === "unknown" ? void 0 : _cachedBranch;
819
+ setCachedBranch(sid, await autoMatchMemoirBranch(storeRoot));
820
+ if (process.env.MEMOIR_NO_CAPTURE !== "1") {
821
+ await flushCapture(storeRoot, prevBranch, sid);
822
+ }
823
+ } catch (e) {
824
+ debugLog("chat.message: failed:", errorMessage(e));
825
+ }
826
+ }
827
+ async function flushAllCapture(storeRoot) {
828
+ await flushCapture(storeRoot);
829
+ }
830
+
831
+ // src/session-context.ts
832
+ var initContext = null;
833
+ var initContextFetched = false;
834
+ var SESSION_CONTEXT_TTL_MS = 60 * 60 * 1e3;
835
+ var sessionsWithContext = /* @__PURE__ */ new Map();
836
+ function hasSessionContext(sessionID) {
837
+ const ts = sessionsWithContext.get(sessionID);
838
+ if (ts === void 0) return false;
839
+ if (Date.now() - ts > SESSION_CONTEXT_TTL_MS) {
840
+ sessionsWithContext.delete(sessionID);
841
+ return false;
842
+ }
843
+ return true;
844
+ }
845
+ function markSessionContext(sessionID) {
846
+ sessionsWithContext.set(sessionID, Date.now());
847
+ }
848
+ function pruneStaleSessions() {
849
+ const now = Date.now();
850
+ for (const [id, ts] of sessionsWithContext) {
851
+ if (now - ts > SESSION_CONTEXT_TTL_MS) sessionsWithContext.delete(id);
852
+ }
853
+ }
854
+ async function handleEvent(storeRoot, input) {
855
+ if (input.event.type === "session.created" && !initContextFetched) {
856
+ initContextFetched = true;
857
+ (async () => {
858
+ try {
859
+ const taxonomyResult = await runMemoir(
860
+ ["--json", "-s", storeRoot, "summarize", "--depth", "3", "-n", "default"],
861
+ { cwd: storeRoot }
862
+ );
863
+ if (!taxonomyResult.ok) {
864
+ throw new Error(taxonomyResult.error);
865
+ }
866
+ const pretty = tryPrettyJson(taxonomyResult.stdout);
867
+ initContext = `[memoir] Available taxonomy paths:
868
+ ${pretty}`;
869
+ } catch (e) {
870
+ debugLog("event: taxonomy fetch failed, will retry on next session:", errorMessage(e));
871
+ initContextFetched = false;
872
+ }
873
+ })().catch(() => {
874
+ initContextFetched = false;
875
+ });
876
+ }
877
+ }
878
+ async function handleSystemTransform(input, output) {
879
+ try {
880
+ const typedInput = input;
881
+ const typedOutput = output;
882
+ if (typedInput?.sessionID && !hasSessionContext(typedInput.sessionID)) {
883
+ markSessionContext(typedInput.sessionID);
884
+ if (initContext) {
885
+ typedOutput?.system?.unshift(initContext);
886
+ }
887
+ }
888
+ if (typedInput?.sessionID && pendingRecall.has(typedInput.sessionID)) {
889
+ pendingRecall.delete(typedInput.sessionID);
890
+ typedOutput?.system?.push(
891
+ "\n[memoir] The user may have relevant context in Memoir. Run memoir_recall to list available memories across default and onboard namespaces, then memoir_get with the matching namespace to fetch exact values."
892
+ );
893
+ }
894
+ } catch (e) {
895
+ debugLog("system.transform: failed:", errorMessage(e));
896
+ }
897
+ }
898
+ function clearSessionContext() {
899
+ pruneStaleSessions();
900
+ sessionsWithContext.clear();
901
+ }
902
+
903
+ // src/index.ts
741
904
  var MemoirOpenCode = async (_input, rawOptions) => {
742
905
  const opts = rawOptions ?? {};
743
906
  if (opts.store) setPluginStoreOverride(opts.store);
744
907
  const storeRoot = deriveStorePath();
745
908
  return {
746
909
  name: "memoir",
747
- tool: {
748
- memoir_status: memoirStatus,
749
- memoir_remember: memoirRemember,
750
- memoir_recall: memoirRecall,
751
- memoir_get: memoirGet
752
- },
910
+ tool: { ...memoirTools },
753
911
  config: async (opencodeConfig) => {
754
912
  registerCommands(opencodeConfig);
755
913
  },
756
914
  "command.execute.before": async (input, output) => {
757
- try {
758
- if (input.command === "memoir:status") {
759
- pushText(output, await statusJson(deriveStorePath()));
760
- }
761
- if (input.command === "memoir:ui") {
762
- pushText(output, await launchUi(deriveStorePath()));
763
- }
764
- if (input.command === "memoir:unmerged") {
765
- const store = deriveStorePath();
766
- await ensureStore(store);
767
- const raw = await runMemoir(["--json", "-s", store, "branch"], { cwd: store });
768
- const data = JSON.parse(raw);
769
- const branches = data?.branches ?? [];
770
- const unmerged = [];
771
- for (const branch of branches) {
772
- if (branch === "main") continue;
773
- const diffOut = await runMemoir(["-s", store, "diff", branch, "main", "--stat"], { cwd: store });
774
- if (diffOut.startsWith("Memoir command failed")) {
775
- debugLog("command.execute.before: memoir diff failed", diffOut);
776
- continue;
777
- }
778
- if (diffOut.trim()) {
779
- unmerged.push(branch);
780
- }
781
- }
782
- pushText(
783
- output,
784
- unmerged.length > 0 ? `Unmerged branches:
785
- ${unmerged.join("\n")}` : "All branches are merged into main."
786
- );
787
- }
788
- } catch (error) {
789
- pushText(output, `Memoir command failed: ${errorMessage(error)}`);
790
- }
915
+ await handleCommandExecuteBefore(storeRoot, input, output);
791
916
  },
792
917
  /**
793
918
  * Inject MEMOIR_STORE into every shell command's environment so any memoir
@@ -796,11 +921,7 @@ ${unmerged.join("\n")}` : "All branches are merged into main."
796
921
  * on every shell command).
797
922
  */
798
923
  "shell.env": async (_input2, output) => {
799
- try {
800
- output.env.MEMOIR_STORE = storeRoot;
801
- } catch (e) {
802
- debugLog("shell.env: failed:", errorMessage(e));
803
- }
924
+ await handleShellEnv(storeRoot, _input2, output);
804
925
  },
805
926
  /**
806
927
  * Observe every tool execution for metrics and code-change tracking.
@@ -808,22 +929,7 @@ ${unmerged.join("\n")}` : "All branches are merged into main."
808
929
  * Never modifies the tool output.
809
930
  */
810
931
  "tool.execute.after": async (input, output) => {
811
- try {
812
- const sid = input.sessionID ?? "default";
813
- if (process.env.MEMOIR_NO_CAPTURE !== "1" && process.env.MEMOIR_NO_METRICS !== "1") {
814
- const calls = 1;
815
- const errors = output.metadata?.error || output.output?.startsWith("Error:") ? 1 : 0;
816
- recordToolMetrics(sid, input.tool, { calls, errors });
817
- }
818
- if (process.env.MEMOIR_NO_CAPTURE !== "1" && process.env.MEMOIR_NO_CODE_SUMMARY !== "1" && EDIT_TOOLS.has(input.tool)) {
819
- const filePath = typeof input.args?.filePath === "string" ? input.args.filePath : typeof input.args?.path === "string" ? input.args.path : "";
820
- if (filePath) {
821
- recordEdit(sid, { tool: input.tool, filePath, snippet: "", timestamp: Date.now() });
822
- }
823
- }
824
- } catch (e) {
825
- debugLog("tool.execute.after: failed:", errorMessage(e));
826
- }
932
+ await handleToolExecuteAfter(input, output);
827
933
  },
828
934
  /**
829
935
  * Fires on every incoming user message.
@@ -837,49 +943,15 @@ ${unmerged.join("\n")}` : "All branches are merged into main."
837
943
  * Steps 1–2 are skipped when MEMOIR_NO_CAPTURE=1.
838
944
  */
839
945
  "chat.message": async (input, output) => {
840
- try {
841
- const sid = input.sessionID ?? "default";
842
- const text = output.parts.filter((p) => p.type === "text").map((p) => p.text).join(" ");
843
- if (shouldTriggerRecall(text)) {
844
- pendingRecall.add(sid);
845
- }
846
- const prevBranch = getCachedBranch(sid) === "unknown" ? void 0 : getCachedBranch(sid);
847
- setCachedBranch(sid, await autoMatchMemoirBranch(storeRoot));
848
- if (process.env.MEMOIR_NO_CAPTURE !== "1") {
849
- await flushCapture(storeRoot, prevBranch, sid);
850
- }
851
- } catch (e) {
852
- debugLog("chat.message: failed:", errorMessage(e));
853
- }
946
+ await handleChatMessage(storeRoot, input, output);
854
947
  },
855
948
  /**
856
949
  * Fires on SDK events. On session.created, kick off a background fetch of
857
950
  * the taxonomy overview so it's ready before the first LLM call.
858
951
  * Mirrors Claude Code's SessionStart context injection.
859
952
  */
860
- event: async ({ event: evt }) => {
861
- if (evt.type === "session.created" && !initContextFetched) {
862
- initContextFetched = true;
863
- (async () => {
864
- try {
865
- const taxonomy = await runMemoir(
866
- ["--json", "-s", storeRoot, "summarize", "--depth", "3", "-n", "default"],
867
- { cwd: storeRoot }
868
- );
869
- if (taxonomy.startsWith("Memoir command failed")) {
870
- throw new Error(taxonomy);
871
- }
872
- const pretty = tryPrettyJson(taxonomy);
873
- initContext = `[memoir] Available taxonomy paths:
874
- ${pretty}`;
875
- } catch (e) {
876
- debugLog("event: taxonomy fetch failed, will retry on next session:", errorMessage(e));
877
- initContextFetched = false;
878
- }
879
- })().catch(() => {
880
- initContextFetched = false;
881
- });
882
- }
953
+ event: async (input) => {
954
+ await handleEvent(storeRoot, input);
883
955
  },
884
956
  /**
885
957
  * Fires before every LLM call.
@@ -889,22 +961,7 @@ ${pretty}`;
889
961
  * telling the model to use memoir tools (one-shot per trigger).
890
962
  */
891
963
  "experimental.chat.system.transform": async (input, output) => {
892
- try {
893
- if (input.sessionID && !hasSessionContext(input.sessionID)) {
894
- markSessionContext(input.sessionID);
895
- if (initContext) {
896
- output.system.unshift(initContext);
897
- }
898
- }
899
- if (input.sessionID && pendingRecall.has(input.sessionID)) {
900
- pendingRecall.delete(input.sessionID);
901
- output.system.push(
902
- "\n[memoir] The user may have relevant context in Memoir. Run memoir_recall to list available memories across default and onboard namespaces, then memoir_get with the matching namespace to fetch exact values."
903
- );
904
- }
905
- } catch (e) {
906
- debugLog("system.transform: failed:", errorMessage(e));
907
- }
964
+ await handleSystemTransform(input, output);
908
965
  },
909
966
  /**
910
967
  * Fires when the plugin is shut down. Flushes any remaining code changes
@@ -912,11 +969,9 @@ ${pretty}`;
912
969
  */
913
970
  dispose: async () => {
914
971
  try {
915
- await flushCapture(storeRoot);
916
- pruneStaleSessions();
917
- sessionsWithContext.clear();
972
+ await flushAllCapture(storeRoot);
973
+ clearSessionContext();
918
974
  } catch (e) {
919
- debugLog("dispose: failed:", errorMessage(e));
920
975
  }
921
976
  }
922
977
  };