opencode-memoir 1.1.3 → 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();
@@ -175,6 +167,7 @@ async function runMemoir(args, options = {}) {
175
167
  if (winner) specs = reorderResolver(specs, winner);
176
168
  }
177
169
  let lastError = "";
170
+ let lastResolver;
178
171
  for (const spec of specs) {
179
172
  try {
180
173
  const { stdout } = await execFileAsync(spec.command, spec.args, {
@@ -185,14 +178,15 @@ async function runMemoir(args, options = {}) {
185
178
  // prevent hang if memoir CLI stalls
186
179
  });
187
180
  memoirResolved = spec.label;
188
- return stdout.trim();
181
+ return { ok: true, stdout: stdout.trim() };
189
182
  } catch (e) {
190
183
  lastError = errorMessage(e);
184
+ lastResolver = spec.label;
191
185
  debugLog("runMemoir: fallback", spec.label, "failed:", lastError);
192
186
  if (memoirResolved === spec.label) memoirResolved = null;
193
187
  }
194
188
  }
195
- return `Memoir command failed: ${lastError}`;
189
+ return { ok: false, error: `Memoir command failed: ${lastError}`, resolver: lastResolver };
196
190
  }
197
191
  function memoirSpawnSpecs(args) {
198
192
  return [
@@ -203,8 +197,10 @@ function memoirSpawnSpecs(args) {
203
197
  }
204
198
  async function getCurrentBranch(store) {
205
199
  try {
206
- const raw = await runMemoir(["--json", "-s", store, "status"], { cwd: store });
207
- 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);
208
204
  return data.branch || "unknown";
209
205
  } catch (e) {
210
206
  debugLog("getCurrentBranch: failed:", errorMessage(e));
@@ -213,7 +209,8 @@ async function getCurrentBranch(store) {
213
209
  }
214
210
  async function codeGitBranch() {
215
211
  try {
216
- 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 });
217
214
  return stdout.trim();
218
215
  } catch (e) {
219
216
  debugLog("codeGitBranch: failed:", errorMessage(e));
@@ -223,8 +220,10 @@ async function codeGitBranch() {
223
220
  async function branchExistsInMemoir(store, name) {
224
221
  if (!name) return false;
225
222
  try {
226
- const raw = await runMemoir(["--json", "-s", store, "branch"], { cwd: store });
227
- 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);
228
227
  const branches = data?.branches ?? [];
229
228
  return branches.includes(name);
230
229
  } catch (e) {
@@ -233,6 +232,7 @@ async function branchExistsInMemoir(store, name) {
233
232
  }
234
233
  }
235
234
  async function autoMatchMemoirBranch(store) {
235
+ const runFn = _storeTestOverrides.runMemoirFn ?? runMemoir;
236
236
  const codeBranch = await codeGitBranch();
237
237
  if (!codeBranch) {
238
238
  return getCurrentBranch(store);
@@ -240,23 +240,25 @@ async function autoMatchMemoirBranch(store) {
240
240
  const current = await getCurrentBranch(store);
241
241
  if (current === codeBranch) return current;
242
242
  if (!await branchExistsInMemoir(store, codeBranch)) {
243
- const result2 = await runMemoir(["-s", store, "branch", codeBranch, "--from", "main"], { cwd: store });
244
- if (result2.startsWith("Memoir command failed")) {
245
- 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);
246
246
  return getCurrentBranch(store);
247
247
  }
248
248
  }
249
- const result = await runMemoir(["-s", store, "checkout", codeBranch], { cwd: store });
250
- if (result.startsWith("Memoir command failed")) {
251
- 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);
252
252
  return getCurrentBranch(store);
253
253
  }
254
254
  return codeBranch;
255
255
  }
256
256
  async function readMemoirValue(store, key, namespace = "default") {
257
257
  try {
258
- const raw = await runMemoir(["--json", "-s", store, "get", key, "-n", namespace], { cwd: store });
259
- 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);
260
262
  const items = parsed?.items ?? [];
261
263
  const value = items[0]?.value?.content;
262
264
  return typeof value === "string" ? value : "";
@@ -265,221 +267,17 @@ async function readMemoirValue(store, key, namespace = "default") {
265
267
  return "";
266
268
  }
267
269
  }
270
+ var _storeTestOverrides = {};
268
271
 
269
- // src/capture.ts
270
- var _cliOverrides = null;
271
- var EDIT_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch", "ApplyPatch", "MultiFileEdit"]);
272
- function evictOldest(map, maxSize) {
273
- if (map.size <= maxSize) return;
274
- const firstKey = map.keys().next().value;
275
- if (firstKey !== void 0) map.delete(firstKey);
276
- }
277
- var METRICS_CODE_MAX = 1e3;
278
- var pendingEditsBySession = /* @__PURE__ */ new Map();
279
- var toolMetricsBySession = /* @__PURE__ */ new Map();
280
- var cachedBranchBySession = /* @__PURE__ */ new Map();
281
- function getCachedBranch(sessionID) {
282
- return cachedBranchBySession.get(sessionID) ?? "unknown";
283
- }
284
- function setCachedBranch(sessionID, branch) {
285
- cachedBranchBySession.set(sessionID, branch);
286
- evictOldest(cachedBranchBySession, 200);
287
- }
288
- function recordEdit(sessionID, edit) {
289
- let edits = pendingEditsBySession.get(sessionID);
290
- if (!edits) {
291
- edits = [];
292
- pendingEditsBySession.set(sessionID, edits);
293
- evictOldest(pendingEditsBySession, 500);
294
- }
295
- edits.push(edit);
296
- }
297
- function recordToolMetrics(sessionID, tool2, metrics) {
298
- let sessionMetrics = toolMetricsBySession.get(sessionID);
299
- if (!sessionMetrics) {
300
- sessionMetrics = /* @__PURE__ */ new Map();
301
- toolMetricsBySession.set(sessionID, sessionMetrics);
302
- evictOldest(toolMetricsBySession, 500);
303
- }
304
- const prev = sessionMetrics.get(tool2) ?? { calls: 0, errors: 0 };
305
- sessionMetrics.set(tool2, {
306
- calls: prev.calls + metrics.calls,
307
- errors: prev.errors + metrics.errors
308
- });
309
- }
310
- function parseTurnMetrics(text) {
311
- const result = /* @__PURE__ */ new Map();
312
- if (!text) return result;
313
- const parts = text.split("|");
314
- for (const part of parts) {
315
- const trimmed = part.trim();
316
- if (!trimmed) continue;
317
- const [tool2, callsStr, errorsStr] = trimmed.split(":");
318
- if (!tool2) continue;
319
- result.set(tool2, {
320
- calls: parseInt(callsStr, 10) || 0,
321
- errors: parseInt(errorsStr, 10) || 0
322
- });
323
- }
324
- return result;
325
- }
326
- function serializeTurnMetrics(map) {
327
- return [...map.entries()].map(([tool2, m]) => `${tool2}:${m.calls}:${m.errors}`).join(" | ");
328
- }
329
- var flushQueues = /* @__PURE__ */ new Map();
330
- async function acquireFlushLock(store) {
331
- const prev = flushQueues.get(store) ?? Promise.resolve();
332
- let release;
333
- const next = new Promise((resolve2) => {
334
- release = resolve2;
335
- });
336
- flushQueues.set(store, next);
337
- await prev;
338
- return () => {
339
- release();
340
- if (flushQueues.get(store) === next) flushQueues.delete(store);
341
- };
342
- }
343
- async function flushCapture(store, branch, sessionID) {
344
- const targets = sessionID ? [sessionID] : [...pendingEditsBySession.keys()];
345
- if (targets.length === 0) return;
346
- try {
347
- store = store ?? deriveStorePath();
348
- if (!store) return;
349
- await ensureStore(store);
350
- const releaseLock = await acquireFlushLock(store);
351
- try {
352
- const perSession = [];
353
- const swappedSids = /* @__PURE__ */ new Map();
354
- for (const sid of targets) {
355
- const idx = perSession.length;
356
- const edits = pendingEditsBySession.get(sid) ?? [];
357
- pendingEditsBySession.set(sid, []);
358
- const metrics = toolMetricsBySession.get(sid) ?? /* @__PURE__ */ new Map();
359
- toolMetricsBySession.set(sid, /* @__PURE__ */ new Map());
360
- const sb = branch ?? cachedBranchBySession.get(sid);
361
- perSession.push({ edits, metrics, sessionBranch: sb ?? "unknown" });
362
- swappedSids.set(sid, idx);
363
- }
364
- if (!perSession.some((p) => p.edits.length > 0 || p.metrics.size > 0)) return;
365
- try {
366
- for (const p of perSession) {
367
- if (p.sessionBranch === "unknown" || !p.sessionBranch) {
368
- p.sessionBranch = await (_cliOverrides?.getCurrentBranch ?? getCurrentBranch)(store);
369
- }
370
- }
371
- for (const p of perSession) {
372
- const { edits, metrics, sessionBranch: branchKey } = p;
373
- if (edits.length === 0 && metrics.size === 0) continue;
374
- const [prevCodeRaw, prevTurnRaw] = await Promise.all([
375
- edits.length > 0 ? (_cliOverrides?.readMemoirValue ?? readMemoirValue)(store, `metrics.code.${branchKey}`) : Promise.resolve(""),
376
- metrics.size > 0 ? (_cliOverrides?.readMemoirValue ?? readMemoirValue)(store, `metrics.turn.${branchKey}`) : Promise.resolve("")
377
- ]);
378
- let codeWrite;
379
- if (edits.length > 0) {
380
- const files = [...new Set(edits.map((e) => e.filePath))];
381
- const entry = {
382
- timestamp: Date.now() / 1e3,
383
- summary: `Changed ${edits.length} block(s) across ${files.length} file(s): ${files.join(", ")}`,
384
- files
385
- };
386
- let acc = {
387
- schema_version: 2,
388
- entries: []
389
- };
390
- if (prevCodeRaw) {
391
- try {
392
- const parsed = JSON.parse(prevCodeRaw);
393
- if (parsed?.entries && Array.isArray(parsed.entries)) {
394
- acc = parsed;
395
- if (acc.schema_version < 2) acc.schema_version = 2;
396
- }
397
- } catch (e) {
398
- debugLog("flushCapture: failed to parse existing code metrics, starting fresh:", errorMessage(e));
399
- }
400
- }
401
- acc.entries.push(entry);
402
- if (acc.entries.length > METRICS_CODE_MAX) acc.entries = acc.entries.slice(-METRICS_CODE_MAX);
403
- codeWrite = (_cliOverrides?.runMemoir ?? runMemoir)(["-s", store, "remember", "--replace", JSON.stringify(acc), "-p", `metrics.code.${branchKey}`], { cwd: store });
404
- }
405
- let turnWrite;
406
- if (metrics.size > 0) {
407
- const existing = parseTurnMetrics(prevTurnRaw);
408
- for (const [tool2, current] of metrics) {
409
- const prev = existing.get(tool2) ?? { calls: 0, errors: 0 };
410
- existing.set(tool2, { calls: prev.calls + current.calls, errors: prev.errors + current.errors });
411
- }
412
- turnWrite = (_cliOverrides?.runMemoir ?? runMemoir)(["-s", store, "remember", "--replace", serializeTurnMetrics(existing), "-p", `metrics.turn.${branchKey}`], { cwd: store });
413
- }
414
- const results = await Promise.all([codeWrite, turnWrite].filter(Boolean));
415
- for (const result of results) {
416
- if (typeof result === "string" && result.startsWith("Memoir command failed")) {
417
- throw new Error(`Memoir write failed: ${result}`);
418
- }
419
- }
420
- }
421
- } catch (e) {
422
- for (const [sid, idx] of swappedSids) {
423
- const p = perSession[idx];
424
- const currentEdits = pendingEditsBySession.get(sid) ?? [];
425
- pendingEditsBySession.set(sid, [...p.edits, ...currentEdits]);
426
- const merged = toolMetricsBySession.get(sid) ?? /* @__PURE__ */ new Map();
427
- for (const [tool2, m] of p.metrics) {
428
- const prev = merged.get(tool2) ?? { calls: 0, errors: 0 };
429
- merged.set(tool2, { calls: prev.calls + m.calls, errors: prev.errors + m.errors });
430
- }
431
- toolMetricsBySession.set(sid, merged);
432
- }
433
- throw e;
434
- }
435
- } finally {
436
- releaseLock();
437
- }
438
- } catch (e) {
439
- debugLog("flushCapture: failed:", errorMessage(e));
440
- }
441
- }
442
-
443
- // src/recall-gate.ts
444
- function isSecretSanitizationEnabled() {
445
- return process.env.MEMOIR_SANITIZE_SECRETS !== "0";
446
- }
447
- var pendingRecall = /* @__PURE__ */ new Set();
448
- 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;
449
- var EXPLICIT_RECALL_PATTERN = /\b(memoir:recall|memoir:remember|memoir-recall|memoir-remember)\b|(\/recall|\/remember)\b/i;
450
- var RECALL_TRIGGER_PATTERNS = [
451
- // Action verbs and domain nouns
452
- /\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,
453
- // Question starts (how/why/what/where/when/should/can/could/would/is it/are we/do I/does it)
454
- /^(how|why|what|where|when|should|can|could|would|is it|are we|do I|does it)\b.*\?/im,
455
- // Code blocks (triple backticks)
456
- /```/,
457
- // Code definitions
458
- /\b(def|function|class|import|export)\s+/,
459
- // Memoir/recall keywords
460
- /\b(memoir|recall|remember|memory)\b/i,
461
- // File extensions
462
- /\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,
463
- // File paths (slash-containing tokens)
464
- /\w+\/\w+/
465
- ];
466
- function isAcknowledgement(text) {
467
- const trimmed = text.trim().toLowerCase();
468
- const words = trimmed.split(/\s+/);
469
- return words.length <= 5 && ACK_PATTERN.test(trimmed);
470
- }
471
- function shouldTriggerRecall(text) {
472
- const trimmed = text.trim();
473
- if (!trimmed) return false;
474
- if (EXPLICIT_RECALL_PATTERN.test(trimmed)) return true;
475
- if (trimmed.length < 10) return false;
476
- if (isAcknowledgement(trimmed)) return false;
477
- if (trimmed.length >= 40 && RECALL_TRIGGER_PATTERNS.some((p) => p.test(trimmed))) return true;
478
- return false;
479
- }
480
- var DEFAULT_RECALL_NAMESPACES = ["default", "project:onboard", "codebase:onboard"];
272
+ // src/tools.ts
273
+ import { tool } from "@opencode-ai/plugin";
481
274
 
482
- // 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";
483
281
  var UI_URL_DEADLINE_MS = 5e3;
484
282
  var UI_URL_POLL_MS = 100;
485
283
  async function ensureStoreOrError(store) {
@@ -493,15 +291,16 @@ async function ensureStoreOrError(store) {
493
291
  async function statusJson(store) {
494
292
  const storeErr = await ensureStoreOrError(store);
495
293
  if (storeErr) return `Memoir command failed: ${storeErr}`;
496
- 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;
497
296
  try {
498
- const data = JSON.parse(raw);
297
+ const data = JSON.parse(result.stdout);
499
298
  const branch = await codeGitBranch();
500
299
  data.opencode = { store, project_git_root: process.cwd(), project_git_branch: branch };
501
300
  return JSON.stringify(data, null, 2);
502
301
  } catch (e) {
503
302
  debugLog("statusJson: failed to parse JSON:", errorMessage(e));
504
- return raw;
303
+ return result.stdout;
505
304
  }
506
305
  }
507
306
  async function launchUi(store) {
@@ -570,86 +369,70 @@ ${output.trim()}`.trim();
570
369
  }
571
370
  return `Memoir UI failed to start: ${lastError || "no launcher succeeded"}`;
572
371
  }
573
- function pushText(output, text) {
574
- output.parts.length = 0;
575
- output.parts.push({ type: "text", text });
576
- }
577
- var initContext = null;
578
- var initContextFetched = false;
579
- var SESSION_CONTEXT_TTL_MS = 60 * 60 * 1e3;
580
- var sessionsWithContext = /* @__PURE__ */ new Map();
581
- function hasSessionContext(sessionID) {
582
- const ts = sessionsWithContext.get(sessionID);
583
- if (ts === void 0) return false;
584
- if (Date.now() - ts > SESSION_CONTEXT_TTL_MS) {
585
- sessionsWithContext.delete(sessionID);
586
- 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;
587
377
  }
588
- return true;
589
- }
590
- function markSessionContext(sessionID) {
591
- sessionsWithContext.set(sessionID, Date.now());
592
- }
593
- function pruneStaleSessions() {
594
- const now = Date.now();
595
- for (const [id, ts] of sessionsWithContext) {
596
- 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
+ }
597
391
  }
392
+ return unmerged.length > 0 ? `Unmerged branches:
393
+ ${unmerged.join("\n")}` : "All branches are merged into main.";
598
394
  }
599
- function registerCommands(config) {
600
- config.command = config.command ?? {};
601
- config.command["memoir:status"] = {
602
- description: "Show Memoir status for the current OpenCode project",
603
- template: "Show Memoir status for this OpenCode project."
604
- };
605
- config.command["memoir:ui"] = {
606
- description: "Launch or reopen the Memoir web UI for this project store",
607
- template: "Launch or reopen the Memoir UI for this project."
608
- };
609
- config.command["memoir:remember"] = {
610
- description: "Save a durable fact, preference, rule, or decision to Memoir",
611
- template: `Use Memoir to save this durable memory now.
612
-
613
- USER REQUEST:
614
- $ARGUMENTS
615
-
616
- Extract the memory content, choose a semantic path if none is supplied, then call the memoir_remember tool. Never save secrets.`
617
- };
618
- config.command["memoir:recall"] = {
619
- description: "Recall relevant facts from Memoir before answering",
620
- template: `Recall relevant Memoir memories for this request.
621
-
622
- USER REQUEST:
623
- $ARGUMENTS
624
-
625
- 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.`
626
- };
627
- config.command["memoir:onboard"] = {
628
- description: "Populate or refresh Memoir onboarding for this project",
629
- template: `Populate or refresh Memoir onboarding for the CURRENT OpenCode project only.
630
395
 
631
- USER REQUEST:
632
- $ARGUMENTS
633
-
634
- Workflow:
635
- - Stay inside the current project/worktree. Do not inspect parent directories.
636
- - First obtain a project file tree to understand structure.
637
- - Start studying from project documentation.
638
- - Continue only based on what the tree and documentation show.
639
-
640
- Memory rules:
641
- - Record only verified facts from files/docs/code or explicit user statements.
642
- - Do not write inferred user thoughts, intentions, preferences, or opinions.
643
- - Do not use preferences.* paths unless the user explicitly stated a preference.
644
- - If a fact is your interpretation, do not save it; report it as uncertain instead.
645
-
646
- 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.`
647
- };
648
- config.command["memoir:unmerged"] = {
649
- description: "List memoir branches with changes not yet merged into main",
650
- template: "List memoir branches that have diverged from main."
651
- };
396
+ // src/recall-gate.ts
397
+ function isSecretSanitizationEnabled() {
398
+ return process.env.MEMOIR_SANITIZE_SECRETS !== "0";
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;
652
432
  }
433
+ var DEFAULT_RECALL_NAMESPACES = ["default", "project:onboard", "codebase:onboard"];
434
+
435
+ // src/tools.ts
653
436
  var memoirStatus = tool({
654
437
  description: "Show Memoir status for the current OpenCode project store.",
655
438
  args: {},
@@ -680,7 +463,9 @@ var memoirRemember = tool({
680
463
  for (const p of paths) cliArgs.push("-p", p);
681
464
  cliArgs.push("-n", args.namespace ?? "default");
682
465
  if (args.replace) cliArgs.push("--replace");
683
- 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);
684
469
  }
685
470
  });
686
471
  var memoirRecall = tool({
@@ -698,7 +483,9 @@ var memoirRecall = tool({
698
483
  const namespaces = args.namespace ? [args.namespace] : args.namespaces && args.namespaces.length > 0 ? args.namespaces : DEFAULT_RECALL_NAMESPACES;
699
484
  const sections = [];
700
485
  for (const namespace of namespaces) {
701
- 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);
702
489
  sections.push(`## namespace: ${namespace}
703
490
  ${output}`);
704
491
  }
@@ -725,59 +512,407 @@ var memoirGet = tool({
725
512
  const store = deriveStorePath();
726
513
  const storeError = await ensureStoreOrError(store);
727
514
  if (storeError) return storeError;
728
- 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);
729
518
  }
730
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
731
904
  var MemoirOpenCode = async (_input, rawOptions) => {
732
905
  const opts = rawOptions ?? {};
733
906
  if (opts.store) setPluginStoreOverride(opts.store);
734
907
  const storeRoot = deriveStorePath();
735
908
  return {
736
909
  name: "memoir",
737
- tool: {
738
- memoir_status: memoirStatus,
739
- memoir_remember: memoirRemember,
740
- memoir_recall: memoirRecall,
741
- memoir_get: memoirGet
742
- },
910
+ tool: { ...memoirTools },
743
911
  config: async (opencodeConfig) => {
744
912
  registerCommands(opencodeConfig);
745
913
  },
746
914
  "command.execute.before": async (input, output) => {
747
- try {
748
- if (input.command === "memoir:status") {
749
- pushText(output, await statusJson(storeRoot));
750
- }
751
- if (input.command === "memoir:ui") {
752
- pushText(output, await launchUi(storeRoot));
753
- }
754
- if (input.command === "memoir:unmerged") {
755
- const store = storeRoot;
756
- await ensureStore(store);
757
- const raw = await runMemoir(["--json", "-s", store, "branch"], { cwd: store });
758
- const data = JSON.parse(raw);
759
- const branches = data?.branches ?? [];
760
- const unmerged = [];
761
- for (const branch of branches) {
762
- if (branch === "main") continue;
763
- const diffOut = await runMemoir(["-s", store, "diff", branch, "main", "--stat"], { cwd: store });
764
- if (diffOut.startsWith("Memoir command failed")) {
765
- debugLog("command.execute.before: memoir diff failed", diffOut);
766
- continue;
767
- }
768
- if (diffOut.trim()) {
769
- unmerged.push(branch);
770
- }
771
- }
772
- pushText(
773
- output,
774
- unmerged.length > 0 ? `Unmerged branches:
775
- ${unmerged.join("\n")}` : "All branches are merged into main."
776
- );
777
- }
778
- } catch (error) {
779
- pushText(output, `Memoir command failed: ${errorMessage(error)}`);
780
- }
915
+ await handleCommandExecuteBefore(storeRoot, input, output);
781
916
  },
782
917
  /**
783
918
  * Inject MEMOIR_STORE into every shell command's environment so any memoir
@@ -786,11 +921,7 @@ ${unmerged.join("\n")}` : "All branches are merged into main."
786
921
  * on every shell command).
787
922
  */
788
923
  "shell.env": async (_input2, output) => {
789
- try {
790
- output.env.MEMOIR_STORE = storeRoot;
791
- } catch (e) {
792
- debugLog("shell.env: failed:", errorMessage(e));
793
- }
924
+ await handleShellEnv(storeRoot, _input2, output);
794
925
  },
795
926
  /**
796
927
  * Observe every tool execution for metrics and code-change tracking.
@@ -798,22 +929,7 @@ ${unmerged.join("\n")}` : "All branches are merged into main."
798
929
  * Never modifies the tool output.
799
930
  */
800
931
  "tool.execute.after": async (input, output) => {
801
- try {
802
- const sid = input.sessionID ?? "default";
803
- if (process.env.MEMOIR_NO_CAPTURE !== "1" && process.env.MEMOIR_NO_METRICS !== "1") {
804
- const calls = 1;
805
- const errors = output.metadata?.error || output.output?.startsWith("Error:") ? 1 : 0;
806
- recordToolMetrics(sid, input.tool, { calls, errors });
807
- }
808
- if (process.env.MEMOIR_NO_CAPTURE !== "1" && process.env.MEMOIR_NO_CODE_SUMMARY !== "1" && EDIT_TOOLS.has(input.tool)) {
809
- const filePath = typeof input.args?.filePath === "string" ? input.args.filePath : typeof input.args?.path === "string" ? input.args.path : "";
810
- if (filePath) {
811
- recordEdit(sid, { tool: input.tool, filePath, snippet: "", timestamp: Date.now() });
812
- }
813
- }
814
- } catch (e) {
815
- debugLog("tool.execute.after: failed:", errorMessage(e));
816
- }
932
+ await handleToolExecuteAfter(input, output);
817
933
  },
818
934
  /**
819
935
  * Fires on every incoming user message.
@@ -827,50 +943,15 @@ ${unmerged.join("\n")}` : "All branches are merged into main."
827
943
  * Steps 1–2 are skipped when MEMOIR_NO_CAPTURE=1.
828
944
  */
829
945
  "chat.message": async (input, output) => {
830
- try {
831
- const sid = input.sessionID ?? "default";
832
- const text = output.parts.filter((p) => p.type === "text").map((p) => p.text).join(" ");
833
- if (shouldTriggerRecall(text)) {
834
- pendingRecall.add(sid);
835
- }
836
- const _cachedBranch = getCachedBranch(sid);
837
- const prevBranch = _cachedBranch === "unknown" ? void 0 : _cachedBranch;
838
- setCachedBranch(sid, await autoMatchMemoirBranch(storeRoot));
839
- if (process.env.MEMOIR_NO_CAPTURE !== "1") {
840
- await flushCapture(storeRoot, prevBranch, sid);
841
- }
842
- } catch (e) {
843
- debugLog("chat.message: failed:", errorMessage(e));
844
- }
946
+ await handleChatMessage(storeRoot, input, output);
845
947
  },
846
948
  /**
847
949
  * Fires on SDK events. On session.created, kick off a background fetch of
848
950
  * the taxonomy overview so it's ready before the first LLM call.
849
951
  * Mirrors Claude Code's SessionStart context injection.
850
952
  */
851
- event: async ({ event: evt }) => {
852
- if (evt.type === "session.created" && !initContextFetched) {
853
- initContextFetched = true;
854
- (async () => {
855
- try {
856
- const taxonomy = await runMemoir(
857
- ["--json", "-s", storeRoot, "summarize", "--depth", "3", "-n", "default"],
858
- { cwd: storeRoot }
859
- );
860
- if (taxonomy.startsWith("Memoir command failed")) {
861
- throw new Error(taxonomy);
862
- }
863
- const pretty = tryPrettyJson(taxonomy);
864
- initContext = `[memoir] Available taxonomy paths:
865
- ${pretty}`;
866
- } catch (e) {
867
- debugLog("event: taxonomy fetch failed, will retry on next session:", errorMessage(e));
868
- initContextFetched = false;
869
- }
870
- })().catch(() => {
871
- initContextFetched = false;
872
- });
873
- }
953
+ event: async (input) => {
954
+ await handleEvent(storeRoot, input);
874
955
  },
875
956
  /**
876
957
  * Fires before every LLM call.
@@ -880,22 +961,7 @@ ${pretty}`;
880
961
  * telling the model to use memoir tools (one-shot per trigger).
881
962
  */
882
963
  "experimental.chat.system.transform": async (input, output) => {
883
- try {
884
- if (input.sessionID && !hasSessionContext(input.sessionID)) {
885
- markSessionContext(input.sessionID);
886
- if (initContext) {
887
- output.system.unshift(initContext);
888
- }
889
- }
890
- if (input.sessionID && pendingRecall.has(input.sessionID)) {
891
- pendingRecall.delete(input.sessionID);
892
- output.system.push(
893
- "\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."
894
- );
895
- }
896
- } catch (e) {
897
- debugLog("system.transform: failed:", errorMessage(e));
898
- }
964
+ await handleSystemTransform(input, output);
899
965
  },
900
966
  /**
901
967
  * Fires when the plugin is shut down. Flushes any remaining code changes
@@ -903,11 +969,9 @@ ${pretty}`;
903
969
  */
904
970
  dispose: async () => {
905
971
  try {
906
- await flushCapture(storeRoot);
907
- pruneStaleSessions();
908
- sessionsWithContext.clear();
972
+ await flushAllCapture(storeRoot);
973
+ clearSessionContext();
909
974
  } catch (e) {
910
- debugLog("dispose: failed:", errorMessage(e));
911
975
  }
912
976
  }
913
977
  };