opencode-memoir 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,215 +1,866 @@
1
1
  // src/debug.ts
2
- function debugLog(...args) {
3
- if (process.env.MEMOIR_DEBUG !== "1") return;
4
- const ts = (/* @__PURE__ */ new Date()).toISOString();
5
- process.stderr.write(`[memoir ${ts}] ${args.map((a) => String(a)).join(" ")}
6
- `);
2
+ import { appendFileSync, mkdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ function resolveLogFile() {
6
+ const raw = process.env.MEMOIR_LOG;
7
+ if (raw === "stderr") return null;
8
+ if (raw) return raw;
9
+ const stateHome = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
10
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
11
+ return join(stateHome, "opencode", `memoir-plugin-${date}.log`);
12
+ }
13
+ function formatValue(value) {
14
+ if (value instanceof Error) {
15
+ if (process.env.MEMOIR_DEBUG === "1") {
16
+ return value.stack ?? `${value.name}: ${value.message}`;
17
+ }
18
+ return value.message;
19
+ }
20
+ return String(value);
21
+ }
22
+ function formatArgs(args) {
23
+ const [message, ...details] = args;
24
+ if (typeof message === "string" && details[0] instanceof Error) {
25
+ return `${message.replace(/:\s*$/, "")}: ${details.map(formatValue).join(" ")}`;
26
+ }
27
+ return args.map(formatValue).join(" ");
28
+ }
29
+ function writeLog(args) {
30
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").replace("Z", "");
31
+ const line = `[memoir ${ts}] ${formatArgs(args)}
32
+ `;
33
+ const logFilePath = resolveLogFile();
34
+ if (logFilePath) {
35
+ try {
36
+ mkdirSync(dirname(logFilePath), { recursive: true });
37
+ appendFileSync(logFilePath, line);
38
+ return;
39
+ } catch {
40
+ }
41
+ }
42
+ process.stderr.write(line);
43
+ }
44
+ function log(...args) {
45
+ writeLog(args);
7
46
  }
8
47
 
9
- // src/memory-saver.ts
10
- var sessionMsgCount = /* @__PURE__ */ new Map();
11
- var REMINDER_INTERVAL = Math.max(0, parseInt(process.env.MEMOIR_REMINDER_INTERVAL ?? "5", 10));
12
- function incrementMsgCount(sessionID) {
13
- const count = (sessionMsgCount.get(sessionID) ?? 0) + 1;
14
- sessionMsgCount.set(sessionID, count);
15
- return count;
48
+ // src/prompts.ts
49
+ import { readFileSync } from "node:fs";
50
+ import { fileURLToPath } from "node:url";
51
+ var cache = /* @__PURE__ */ new Map();
52
+ function loadPrompt(file) {
53
+ const cached = cache.get(file);
54
+ if (cached !== void 0) return cached;
55
+ const path = fileURLToPath(new URL(`./prompts/${file}`, import.meta.url));
56
+ const text = readFileSync(path, "utf8");
57
+ cache.set(file, text);
58
+ return text;
16
59
  }
17
- function shouldRemind(count) {
18
- return REMINDER_INTERVAL > 0 && count > 1 && count % REMINDER_INTERVAL === 0;
60
+
61
+ // src/subagent.ts
62
+ var MEMOIR_AGENT_NAME = "memoir";
63
+ var MEMOIR_CHECKOUT_TOOL = "memoir_memoir_checkout";
64
+ var MEMOIR_AGENT_PROMPT = loadPrompt("subagent-system.tmpl");
65
+ function buildMemoirAgent(model) {
66
+ const agent = {
67
+ mode: "subagent",
68
+ description: "Memoir memory agent \u2014 captures durable facts and recalls prior context via the memoir MCP server.",
69
+ prompt: MEMOIR_AGENT_PROMPT,
70
+ permission: {
71
+ "*": "deny",
72
+ doom_loop: "allow",
73
+ "memoir_*": "allow",
74
+ [MEMOIR_CHECKOUT_TOOL]: "deny"
75
+ },
76
+ temperature: 0,
77
+ steps: 8
78
+ };
79
+ if (model) agent.model = model;
80
+ return agent;
81
+ }
82
+ function resolveMemoirModel(opts) {
83
+ const candidates = [
84
+ process.env.MEMOIR_AGENT_MODEL?.trim(),
85
+ opts.summarizeModel,
86
+ opts.smallModel,
87
+ opts.model
88
+ ];
89
+ return candidates.find((candidate) => candidate?.includes("/"));
19
90
  }
20
- function pruneAll() {
21
- sessionMsgCount.clear();
91
+ async function runMemoirSubagent(client, parentSessionID, task) {
92
+ const api = client?.session;
93
+ if (!api?.promptAsync) {
94
+ throw new Error("client.session.promptAsync unavailable");
95
+ }
96
+ try {
97
+ await api.promptAsync({
98
+ path: { id: parentSessionID },
99
+ body: {
100
+ parts: [
101
+ {
102
+ type: "subtask",
103
+ agent: MEMOIR_AGENT_NAME,
104
+ description: "memoir capture",
105
+ prompt: task,
106
+ command: ""
107
+ }
108
+ ]
109
+ }
110
+ });
111
+ log("memoir subtask accepted (session", parentSessionID, ", task", task.length, "chars)");
112
+ } catch (e) {
113
+ log("runMemoirSubagent failed", e);
114
+ throw e;
115
+ }
22
116
  }
23
117
 
24
- // src/store.ts
25
- import { execFile, execFileSync } from "node:child_process";
26
- import { homedir } from "node:os";
27
- import { join, resolve } from "node:path";
28
- import { promisify } from "node:util";
29
- var execFileAsync = promisify(execFile);
30
- function errorMessage(e) {
31
- return e instanceof Error ? e.message : String(e);
118
+ // src/capture.ts
119
+ var DEFAULT_CAPTURE_MIN_CHARS = 16;
120
+ function captureMinChars() {
121
+ const raw = process.env.MEMOIR_CAPTURE_MIN_CHARS?.trim();
122
+ if (raw === void 0 || raw === "") return DEFAULT_CAPTURE_MIN_CHARS;
123
+ const n = Number.parseInt(raw, 10);
124
+ return Number.isFinite(n) ? n : DEFAULT_CAPTURE_MIN_CHARS;
125
+ }
126
+ function autoSaveEnabled() {
127
+ return process.env.MEMOIR_AUTO_SAVE !== "0";
128
+ }
129
+ function messageText(msg) {
130
+ const parts = msg.parts ?? [];
131
+ return parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("\n").trim();
132
+ }
133
+ function formatRole(role) {
134
+ switch (role) {
135
+ case "user":
136
+ return "USER";
137
+ case "assistant":
138
+ return "ASSISTANT";
139
+ case "system":
140
+ return "SYSTEM";
141
+ default:
142
+ return (role ?? "unknown").toUpperCase();
143
+ }
144
+ }
145
+ function formatSessionTranscript(messages) {
146
+ return messages.map((m) => {
147
+ const text = messageText(m);
148
+ if (!text) return null;
149
+ return `${formatRole(m.info?.role)}
150
+ ${text}`;
151
+ }).filter((line) => line !== null).join("\n\n");
152
+ }
153
+ function lastTurnTranscript(messages) {
154
+ let assistantIdx = -1;
155
+ for (let i = messages.length - 1; i >= 0; i--) {
156
+ if (messages[i].info?.role === "assistant" && messageText(messages[i])) {
157
+ assistantIdx = i;
158
+ break;
159
+ }
160
+ }
161
+ if (assistantIdx < 0) return null;
162
+ const userIdx = (() => {
163
+ for (let i = assistantIdx - 1; i >= 0; i--) {
164
+ if (messages[i].info?.role === "user") return i;
165
+ }
166
+ return -1;
167
+ })();
168
+ if (userIdx < 0) return null;
169
+ const turn = messages.slice(userIdx, assistantIdx + 1);
170
+ return formatSessionTranscript(turn);
171
+ }
172
+ function shouldCaptureTurn(transcript, minChars = captureMinChars()) {
173
+ if (minChars <= 0) return transcript !== null && transcript.length > 0;
174
+ return transcript !== null && transcript.length >= minChars;
175
+ }
176
+ var CAPTURE_TASK_TEMPLATE = loadPrompt("capture-task.tmpl");
177
+ function buildTurnCaptureTask(transcript, tools = []) {
178
+ const captureTools = tools.filter((tool) => tool.name !== MEMOIR_CHECKOUT_TOOL);
179
+ const toolsSection = captureTools.length ? `Available memory tools (name \u2014 what it does):
180
+ ${captureTools.map((tool) => `- ${tool.name} \u2014 ${tool.description}`).join("\n")}
181
+ ` : "";
182
+ return CAPTURE_TASK_TEMPLATE.replace("{{TOOLS_SECTION}}", toolsSection).replace(
183
+ "{{TRANSCRIPT}}",
184
+ transcript
185
+ );
186
+ }
187
+ function lastAssistantMessageId(messages) {
188
+ for (let i = messages.length - 1; i >= 0; i--) {
189
+ if (messages[i].info?.role === "assistant" && messageText(messages[i])) {
190
+ return messages[i].info?.id ?? null;
191
+ }
192
+ }
193
+ return null;
194
+ }
195
+ async function captureTurn(client, sessionID, _directory, lastCaptured, tools = []) {
196
+ if (!autoSaveEnabled()) {
197
+ log("captureTurn: MEMOIR_AUTO_SAVE=0, skipping");
198
+ return;
199
+ }
200
+ log("captureTurn: started for session", sessionID);
201
+ try {
202
+ const api = client?.session;
203
+ if (!api?.messages) {
204
+ log("captureTurn: client.session.messages unavailable");
205
+ log("captureTurn: skipped \u2014 session messages API unavailable");
206
+ return;
207
+ }
208
+ const res = await api.messages({
209
+ path: { id: sessionID },
210
+ query: { limit: 50 }
211
+ });
212
+ const raw = res.data;
213
+ const messages = Array.isArray(raw) ? raw : [];
214
+ if (messages.length === 0) return;
215
+ const turnId = lastAssistantMessageId(messages);
216
+ if (!turnId) return;
217
+ if (lastCaptured.get(sessionID) === turnId) {
218
+ log("captureTurn: skipped \u2014 already captured this turn");
219
+ return;
220
+ }
221
+ const transcript = lastTurnTranscript(messages);
222
+ if (!shouldCaptureTurn(transcript)) {
223
+ log("captureTurn: transcript below min-chars, skipping");
224
+ log("captureTurn: skipped \u2014 transcript below min-chars");
225
+ lastCaptured.set(sessionID, turnId);
226
+ return;
227
+ }
228
+ if (transcript === null) return;
229
+ log("captureTurn: submitting memoir subtask (transcript", transcript.length, "chars)");
230
+ await runMemoirSubagent(client, sessionID, buildTurnCaptureTask(transcript, tools));
231
+ lastCaptured.set(sessionID, turnId);
232
+ } catch (e) {
233
+ log("captureTurn failed", e);
234
+ }
32
235
  }
33
- function deriveStorePath(cwd = process.cwd()) {
34
- if (pluginStoreOverride) return pluginStoreOverride;
236
+
237
+ // src/capture-lifecycle.ts
238
+ var CaptureLifecycle = class {
239
+ active = /* @__PURE__ */ new Map();
240
+ callsByChild = /* @__PURE__ */ new Map();
241
+ begin(callID) {
242
+ if (this.active.has(callID)) return;
243
+ let resolve2;
244
+ const done = new Promise((doneResolve) => {
245
+ resolve2 = doneResolve;
246
+ });
247
+ this.active.set(callID, { done, resolve: resolve2 });
248
+ }
249
+ background(callID, childSessionID) {
250
+ const capture = this.active.get(callID);
251
+ if (!capture) return;
252
+ capture.childSessionID = childSessionID;
253
+ this.callsByChild.set(childSessionID, callID);
254
+ }
255
+ finishCall(callID) {
256
+ const capture = this.active.get(callID);
257
+ if (!capture) return;
258
+ if (capture.childSessionID) this.callsByChild.delete(capture.childSessionID);
259
+ this.active.delete(callID);
260
+ capture.resolve();
261
+ }
262
+ finishSession(sessionID) {
263
+ const callID = this.callsByChild.get(sessionID);
264
+ if (callID) this.finishCall(callID);
265
+ }
266
+ async drain(timeoutMs = 1e4) {
267
+ const pending = [...this.active.values()].map((capture) => capture.done);
268
+ if (pending.length === 0) return true;
269
+ let timer;
270
+ const timedOut = new Promise((resolve2) => {
271
+ timer = setTimeout(() => resolve2(false), timeoutMs);
272
+ });
273
+ const drained = Promise.all(pending).then(() => true);
274
+ const result = await Promise.race([drained, timedOut]);
275
+ if (timer) clearTimeout(timer);
276
+ if (!result) log("capture lifecycle: drain timed out; branch switch deferred");
277
+ return result;
278
+ }
279
+ clear() {
280
+ for (const callID of [...this.active.keys()]) this.finishCall(callID);
281
+ }
282
+ };
283
+
284
+ // src/mcp-client.ts
285
+ import { spawn } from "node:child_process";
286
+ import { createConnection, createServer } from "node:net";
287
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
288
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
289
+
290
+ // src/path.ts
291
+ import { execFileSync } from "node:child_process";
292
+ import { realpathSync } from "node:fs";
293
+ import { homedir as homedir2 } from "node:os";
294
+ import { join as join2, resolve } from "node:path";
295
+ function safeRealpath(p) {
296
+ try {
297
+ return realpathSync(p);
298
+ } catch {
299
+ return p;
300
+ }
301
+ }
302
+ function slugify(p) {
303
+ return p.replace(/[/.]/g, "-");
304
+ }
305
+ function deriveStorePath(cwd = process.cwd(), override) {
306
+ if (override) return override;
35
307
  const envStore = process.env.MEMOIR_STORE;
36
308
  if (envStore) return envStore;
309
+ const realCwd = safeRealpath(cwd);
37
310
  let projectDir;
38
311
  try {
39
312
  const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
40
- cwd,
313
+ cwd: realCwd,
41
314
  encoding: "utf8",
315
+ stdio: ["ignore", "pipe", "ignore"],
42
316
  timeout: 3e3
43
317
  }).trim();
44
- projectDir = gitRoot;
318
+ projectDir = safeRealpath(gitRoot);
45
319
  } catch {
46
- projectDir = resolve(cwd);
320
+ projectDir = resolve(realCwd);
321
+ }
322
+ return join2(homedir2(), ".memoir", slugify(projectDir));
323
+ }
324
+
325
+ // src/mcp-client.ts
326
+ function errorMessage(e) {
327
+ return e instanceof Error ? e.message : String(e);
328
+ }
329
+ var LOW_PORT = 49152;
330
+ var HIGH_PORT = 65535;
331
+ function randomHighPort() {
332
+ return LOW_PORT + Math.floor(Math.random() * (HIGH_PORT - LOW_PORT + 1));
333
+ }
334
+ function isPortFree(port) {
335
+ return new Promise((resolve2) => {
336
+ const srv = createServer();
337
+ srv.once("error", () => resolve2(false));
338
+ srv.once("listening", () => srv.close(() => resolve2(true)));
339
+ srv.listen(port, "127.0.0.1");
340
+ });
341
+ }
342
+ async function pickFreeHighPort(tries = 20) {
343
+ for (let i = 0; i < tries; i++) {
344
+ const port = randomHighPort();
345
+ if (await isPortFree(port)) return port;
346
+ }
347
+ throw new Error("memoir: could not find a free high port for the HTTP server");
348
+ }
349
+ function waitForPort(port, timeoutMs = 1e4) {
350
+ const deadline = Date.now() + timeoutMs;
351
+ return new Promise((resolve2, reject) => {
352
+ const attempt = () => {
353
+ const sock = createConnection({ port, host: "127.0.0.1" });
354
+ sock.once("connect", () => {
355
+ sock.destroy();
356
+ resolve2();
357
+ });
358
+ sock.once("error", () => {
359
+ sock.destroy();
360
+ if (Date.now() > deadline) {
361
+ reject(new Error("memoir: HTTP server did not start in time"));
362
+ } else {
363
+ setTimeout(attempt, 100);
364
+ }
365
+ });
366
+ };
367
+ attempt();
368
+ });
369
+ }
370
+ var MemoirRuntime = class {
371
+ constructor(command, env, cwd, dependencies = {}) {
372
+ this.command = command;
373
+ this.env = env;
374
+ this.cwd = cwd;
375
+ this.dependencies = dependencies;
376
+ }
377
+ command;
378
+ env;
379
+ cwd;
380
+ dependencies;
381
+ state = null;
382
+ connecting = null;
383
+ serverProc = null;
384
+ serverUrl = null;
385
+ serverPort = null;
386
+ startingServer = null;
387
+ tools = null;
388
+ cleanupClient() {
389
+ this.state = null;
390
+ this.connecting = null;
391
+ this.tools = null;
392
+ }
393
+ async start() {
394
+ if (this.serverUrl && this.serverProc) return this.serverUrl;
395
+ if (this.startingServer) return this.startingServer;
396
+ const start = (async () => {
397
+ if (process.env.NODE_ENV === "test") {
398
+ return new URL("http://127.0.0.1:9/mcp");
399
+ }
400
+ const port = this.serverPort ?? await pickFreeHighPort();
401
+ this.serverPort = port;
402
+ const url = new URL(`http://127.0.0.1:${port}/mcp`);
403
+ const args = [
404
+ ...this.command.slice(1),
405
+ "--http",
406
+ "--host",
407
+ "127.0.0.1",
408
+ "--port",
409
+ String(port)
410
+ ];
411
+ const proc = spawn(this.command[0], args, {
412
+ // Merge with the parent env so PATH (and other essentials) survive —
413
+ // passing only `env` would wipe PATH and break command resolution.
414
+ env: { ...process.env, ...this.env },
415
+ // Run in the real (symlink-resolved) project dir so memoir-mcp's
416
+ // portable store slug is identical whether the repo is entered via a
417
+ // symlink or its real path.
418
+ cwd: this.cwd ? safeRealpath(this.cwd) : process.cwd(),
419
+ stdio: ["ignore", "ignore", "pipe"]
420
+ });
421
+ this.serverProc = proc;
422
+ if (proc.stderr) {
423
+ proc.stderr.on("data", (chunk) => {
424
+ log("memoir-mcp:", chunk.toString().trimEnd());
425
+ });
426
+ }
427
+ proc.on("exit", (code) => {
428
+ log("memoir-mcp HTTP server exited with code", code);
429
+ if (this.serverProc !== proc) return;
430
+ this.serverProc = null;
431
+ this.serverUrl = null;
432
+ this.startingServer = null;
433
+ this.cleanupClient();
434
+ });
435
+ await new Promise((resolve2, reject) => {
436
+ proc.once("spawn", resolve2);
437
+ proc.once("error", reject);
438
+ }).catch((e) => {
439
+ throw new Error(`memoir: failed to spawn ${this.command[0]}: ${errorMessage(e)}`);
440
+ });
441
+ await waitForPort(port);
442
+ this.serverUrl = url;
443
+ log("memoir-mcp HTTP server up at", url.toString());
444
+ return url;
445
+ })();
446
+ this.startingServer = start;
447
+ try {
448
+ return await start;
449
+ } catch (e) {
450
+ this.startingServer = null;
451
+ this.serverUrl = null;
452
+ if (this.serverProc) this.serverProc.kill();
453
+ this.serverProc = null;
454
+ throw e;
455
+ }
456
+ }
457
+ /** Get the lazily-connected internal client for this plugin instance. */
458
+ async connect() {
459
+ if (this.state) return this.state.client;
460
+ if (!this.connecting) {
461
+ this.connecting = (async () => {
462
+ const url = await this.start();
463
+ const { client, transport } = this.dependencies.createClientConnection?.(url) ?? {
464
+ client: new Client({ name: "opencode-memoir", version: "1.0.0" }, { capabilities: {} }),
465
+ transport: new StreamableHTTPClientTransport(url)
466
+ };
467
+ transport.onclose = () => {
468
+ log("mcp-client: transport closed, resetting state");
469
+ this.cleanupClient();
470
+ };
471
+ try {
472
+ await client.connect(transport);
473
+ } catch (e) {
474
+ this.cleanupClient();
475
+ throw e;
476
+ }
477
+ this.state = { client };
478
+ return client;
479
+ })();
480
+ }
481
+ return this.connecting;
482
+ }
483
+ /** Discover the live tool catalog once per connection for the small-model prompt. */
484
+ async listTools(client) {
485
+ if (this.tools) return this.tools;
486
+ try {
487
+ const result = await client.listTools();
488
+ this.tools = (result.tools ?? []).map((tool) => ({
489
+ // OpenCode exposes remote MCP tools as <server>_<raw-name>. The server
490
+ // is registered as "memoir", so the prompt must use the same names the
491
+ // subagent actually sees (for example memoir_memoir_remember).
492
+ name: `memoir_${tool.name}`,
493
+ description: typeof tool.description === "string" ? tool.description : ""
494
+ }));
495
+ return this.tools;
496
+ } catch (e) {
497
+ log("MemoirRuntime.listTools failed", e);
498
+ return [];
499
+ }
500
+ }
501
+ /** Close the MCP client and stop this instance's HTTP server. */
502
+ async close() {
503
+ if (this.state) {
504
+ try {
505
+ await this.state.client.close();
506
+ } catch (e) {
507
+ log("closeMemoirClient: close failed", e);
508
+ }
509
+ this.cleanupClient();
510
+ }
511
+ if (this.serverProc) {
512
+ const proc = this.serverProc;
513
+ this.serverProc = null;
514
+ try {
515
+ await new Promise((resolve2) => {
516
+ if (proc.exitCode !== null) {
517
+ resolve2();
518
+ return;
519
+ }
520
+ const timer = setTimeout(resolve2, 1e3);
521
+ timer.unref();
522
+ proc.once("exit", () => {
523
+ clearTimeout(timer);
524
+ resolve2();
525
+ });
526
+ proc.once("error", () => {
527
+ clearTimeout(timer);
528
+ resolve2();
529
+ });
530
+ proc.kill();
531
+ });
532
+ } catch (e) {
533
+ log("closeMemoirClient: kill failed", e);
534
+ }
535
+ }
536
+ this.serverUrl = null;
537
+ this.startingServer = null;
538
+ this.connecting = null;
539
+ this.tools = null;
540
+ }
541
+ };
542
+ async function callMemoirTool(client, name, args) {
543
+ try {
544
+ const result = await client.callTool({
545
+ name,
546
+ arguments: args
547
+ });
548
+ if (result.isError) {
549
+ log(`callMemoirTool: ${name} returned error`);
550
+ return null;
551
+ }
552
+ for (const block of result.content) {
553
+ if (block.type === "text" && block.text != null) {
554
+ return block.text;
555
+ }
556
+ }
557
+ return null;
558
+ } catch (e) {
559
+ log(`callMemoirTool: ${name} failed`, e);
560
+ return null;
47
561
  }
48
- const slug = projectDir.replace(/[/.]/g, "-");
49
- return join(homedir(), ".memoir", slug);
50
562
  }
51
- var pluginStoreOverride;
52
- function setPluginStoreOverride(store) {
53
- pluginStoreOverride = store;
563
+
564
+ // src/store.ts
565
+ import { execFileSync as execFileSync2 } from "node:child_process";
566
+ function deriveStorePath2(cwd, override) {
567
+ return deriveStorePath(cwd, override);
54
568
  }
55
569
  function currentGitBranch(cwd = process.cwd()) {
56
570
  try {
57
- return execFileSync("git", ["branch", "--show-current"], {
58
- cwd,
571
+ return execFileSync2("git", ["branch", "--show-current"], {
572
+ cwd: safeRealpath(cwd),
59
573
  encoding: "utf8",
574
+ stdio: ["ignore", "pipe", "ignore"],
60
575
  timeout: 3e3
61
576
  }).trim();
62
577
  } catch {
63
578
  return "";
64
579
  }
65
580
  }
66
- var branchCache = /* @__PURE__ */ new Map();
67
- function getCachedBranch(sessionID) {
68
- return branchCache.get(sessionID) ?? "";
69
- }
70
- function setCachedBranch(sessionID, branch) {
71
- branchCache.set(sessionID, branch);
72
- }
73
- async function callMemoir(args, store) {
74
- try {
75
- const { stdout } = await execFileAsync("memoir", ["--store", store, ...args], {
76
- encoding: "utf8",
77
- timeout: 15e3,
78
- maxBuffer: 1024 * 1024
581
+ var MemoirBranchMatcher = class {
582
+ matching = Promise.resolve();
583
+ match(client, cwd, drain) {
584
+ const next = this.matching.then(() => this.matchNow(client, cwd, drain));
585
+ this.matching = next.catch(() => void 0);
586
+ return next;
587
+ }
588
+ async currentBranch(client) {
589
+ const raw = await callMemoirTool(client, "memoir_status");
590
+ if (!raw) return "";
591
+ try {
592
+ const status = JSON.parse(raw);
593
+ return typeof status.branch === "string" ? status.branch : "";
594
+ } catch {
595
+ return "";
596
+ }
597
+ }
598
+ async matchNow(client, cwd, drain) {
599
+ const codeBranch = currentGitBranch(cwd);
600
+ if (!codeBranch) return;
601
+ if (await this.currentBranch(client) === codeBranch) return;
602
+ if (drain && !await drain()) return;
603
+ if (await this.currentBranch(client) === codeBranch) return;
604
+ await callMemoirTool(client, "memoir_checkout", {
605
+ target: codeBranch,
606
+ create: true
79
607
  });
80
- return stdout.trim();
81
- } catch (e) {
82
- debugLog("callMemoir failed:", errorMessage(e));
83
- return null;
84
608
  }
85
- }
86
- async function autoMatchMemoirBranch(store, sessionID) {
87
- const codeBranch = currentGitBranch();
88
- if (!codeBranch) return;
89
- const cached = getCachedBranch(sessionID);
90
- if (cached === codeBranch) return;
91
- const branchResult = await callMemoir(["--json", "branch"], store);
92
- if (branchResult === null) return;
609
+ clear() {
610
+ this.matching = Promise.resolve();
611
+ }
612
+ };
613
+
614
+ // src/turn-status.ts
615
+ function buildTurnStatus(statusRaw) {
616
+ let branch = "";
617
+ let memoryCount = 0;
93
618
  try {
94
- const data = JSON.parse(branchResult);
95
- const branches = data?.branches ?? [];
96
- if (!branches.includes(codeBranch)) {
97
- await callMemoir(["branch", codeBranch], store);
98
- }
99
- await callMemoir(["checkout", codeBranch], store);
100
- setCachedBranch(sessionID, codeBranch);
101
- } catch (e) {
102
- debugLog("autoMatchMemoirBranch: parsing failed:", errorMessage(e));
619
+ const status = JSON.parse(statusRaw ?? "");
620
+ if (typeof status.branch === "string") branch = status.branch;
621
+ if (typeof status.memory_count === "number") memoryCount = status.memory_count;
622
+ } catch {
623
+ return "";
103
624
  }
104
- }
105
- function pruneBranchCache() {
106
- branchCache.clear();
625
+ if (!branch && memoryCount <= 0) return "";
626
+ if (!branch) return `[memoir] memory available (${memoryCount} memories)`;
627
+ return `[memoir] ${branch}${memoryCount > 0 ? ` \xB7 memory available (${memoryCount} memories)` : ""}`;
107
628
  }
108
629
 
109
630
  // src/index.ts
110
- var sessionsWithStartupHint = /* @__PURE__ */ new Set();
111
- function errorMessage2(e) {
112
- return e instanceof Error ? e.message : String(e);
631
+ function envFlag(name) {
632
+ const value = process.env[name]?.trim().toLowerCase();
633
+ if (value === void 0 || value === "") return void 0;
634
+ return value === "1" || value === "true";
635
+ }
636
+ function backgroundSubagentsEnabled() {
637
+ return envFlag("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS") ?? envFlag("OPENCODE_EXPERIMENTAL") ?? false;
113
638
  }
114
- var MemoirOpenCode = async (_input, rawOptions) => {
639
+ var MemoirOpenCode = async (input, rawOptions) => {
115
640
  const opts = rawOptions ?? {};
116
- if (opts.store) setPluginStoreOverride(opts.store);
117
- const storePath = deriveStorePath();
641
+ const pluginInput = input ?? {};
642
+ const sdkClient = pluginInput.client;
643
+ const directory = safeRealpath(pluginInput.directory ?? process.cwd());
644
+ const storePath = deriveStorePath2(directory, opts.store);
118
645
  const mcpCommand = ["memoir-mcp"];
119
646
  if (storePath) {
120
647
  mcpCommand.push("--store", storePath);
121
648
  }
649
+ const environment = storePath ? { MEMOIR_STORE: storePath } : void 0;
650
+ const runtime = new MemoirRuntime(mcpCommand, environment, directory);
651
+ const branchMatcher = new MemoirBranchMatcher();
652
+ const sessionsWithStartupHint = /* @__PURE__ */ new Set();
653
+ const parentSessions = /* @__PURE__ */ new Set();
654
+ const lastCaptured = /* @__PURE__ */ new Map();
655
+ const capturePending = /* @__PURE__ */ new Set();
656
+ const captureLifecycle = new CaptureLifecycle();
657
+ const messageCounts = /* @__PURE__ */ new Map();
658
+ const turnStatus = /* @__PURE__ */ new Map();
659
+ let mcpServer;
660
+ const connectClient = async () => {
661
+ if (process.env.NODE_ENV === "test") return null;
662
+ try {
663
+ return await runtime.connect();
664
+ } catch (e) {
665
+ log("plugin: failed to connect memoir client", e);
666
+ return null;
667
+ }
668
+ };
669
+ const discoverTools = async () => {
670
+ const client = await connectClient();
671
+ return client ? runtime.listTools(client) : [];
672
+ };
673
+ const dispatchCapture = async (sid) => {
674
+ if (!sdkClient || !parentSessions.has(sid) || capturePending.has(sid)) return;
675
+ capturePending.add(sid);
676
+ try {
677
+ const client = await connectClient();
678
+ if (client) await branchMatcher.match(client, directory, () => captureLifecycle.drain());
679
+ await captureTurn(sdkClient, sid, directory, lastCaptured, await discoverTools());
680
+ } catch (e) {
681
+ log("dispatchCapture failed", e);
682
+ } finally {
683
+ capturePending.delete(sid);
684
+ }
685
+ };
122
686
  const hooks = {
123
687
  name: "memoir",
124
688
  config: async (config) => {
125
- debugLog("config hook: mcpCommand =", JSON.stringify(mcpCommand));
126
- debugLog("config hook: mcpCommand length =", mcpCommand.length);
689
+ log("config hook: mcpCommand =", JSON.stringify(mcpCommand));
690
+ try {
691
+ const url = await runtime.start();
692
+ mcpServer = { type: "remote", url: url.toString(), enabled: true };
693
+ log("memoir MCP server registered at", url.toString(), "| store:", storePath);
694
+ } catch (e) {
695
+ log("config hook: failed to start memoir HTTP server", e);
696
+ mcpServer = void 0;
697
+ }
698
+ const mcp = config.mcp ?? {};
699
+ if (mcpServer) mcp.memoir = mcpServer;
700
+ config.mcp = mcp;
701
+ if (mcpServer) hooks.mcp = { memoir: mcpServer };
702
+ const agentModel = resolveMemoirModel({
703
+ summarizeModel: process.env.MEMOIR_AGENT_MODEL,
704
+ smallModel: config.small_model,
705
+ model: config.model
706
+ });
707
+ const agentField = config;
708
+ agentField.agent = {
709
+ ...agentField.agent ?? {},
710
+ [MEMOIR_AGENT_NAME]: buildMemoirAgent(agentModel)
711
+ };
712
+ log("memoir subagent registered | model:", agentModel ?? "(opencode default)");
713
+ log(
714
+ "memoir capture execution mode:",
715
+ backgroundSubagentsEnabled() ? "native background" : "foreground",
716
+ "| OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS:",
717
+ process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS ?? "(unset)",
718
+ "| OPENCODE_EXPERIMENTAL:",
719
+ process.env.OPENCODE_EXPERIMENTAL ?? "(unset)"
720
+ );
127
721
  config.command = config.command ?? {};
128
722
  config.command["memoir:onboard"] = {
129
723
  description: "Populate or refresh Memoir onboarding for this project",
130
- template: `Populate or refresh Memoir onboarding for the current project.
131
-
132
- Workflow:
133
- - First obtain a project file tree to understand structure.
134
- - Start studying from project documentation.
135
- - Continue only based on what the tree and documentation show.
136
-
137
- Memory rules:
138
- - Record only verified facts from files/docs/code or explicit user statements.
139
- - Do not write inferred user thoughts, intentions, preferences, or opinions.
140
- - Do not use preferences.* paths unless the user explicitly stated a preference.
141
- - If a fact is your interpretation, do not save it; report it as uncertain instead.
142
-
143
- Then call memoir_memoir_remember with replace=true for durable onboarding facts. Use namespace codebase:onboard in git repositories and project:onboard outside git.`
724
+ template: loadPrompt("onboard.tmpl")
144
725
  };
145
- const mcp = config.mcp ?? {};
146
- mcp.memoir = {
147
- type: "local",
148
- command: mcpCommand,
149
- environment: storePath ? { MEMOIR_STORE: storePath } : void 0,
150
- enabled: true
151
- };
152
- config.mcp = mcp;
153
- debugLog("config hook: registered mcp.memoir.command =", JSON.stringify(mcp.memoir?.command));
154
726
  },
155
- "shell.env": async (_input2, output) => {
727
+ "shell.env": async (_input, output) => {
156
728
  try {
157
729
  if (storePath) {
158
730
  output.env.MEMOIR_STORE = storePath;
159
731
  }
160
732
  } catch (e) {
161
- debugLog("shell.env: failed:", errorMessage2(e));
733
+ log("shell.env: failed", e);
162
734
  }
163
735
  },
164
- "chat.message": async (input, _output) => {
736
+ "chat.message": async (input2, output) => {
165
737
  try {
166
- const sid = input.sessionID ?? "default";
167
- incrementMsgCount(sid);
168
- await autoMatchMemoirBranch(storePath, sid);
738
+ const sid = input2.sessionID ?? "default";
739
+ const parts = output.parts ?? [];
740
+ const isMemoirSubtask = parts.some(
741
+ (part) => part.type === "subtask" && part.agent === MEMOIR_AGENT_NAME
742
+ );
743
+ const isSynthetic = parts.length > 0 && parts.every((part) => part.synthetic === true);
744
+ if (input2.agent === MEMOIR_AGENT_NAME || isMemoirSubtask || isSynthetic) return;
745
+ parentSessions.add(sid);
746
+ messageCounts.set(sid, (messageCounts.get(sid) ?? 0) + 1);
747
+ const client = await connectClient();
748
+ if (client) {
749
+ await branchMatcher.match(client, directory, () => captureLifecycle.drain());
750
+ const status = await callMemoirTool(client, "memoir_status", {});
751
+ const line = buildTurnStatus(status);
752
+ if (line) turnStatus.set(sid, line);
753
+ else turnStatus.delete(sid);
754
+ }
755
+ log("chat.message: submitting capture for previous completed turn", sid);
756
+ await dispatchCapture(sid);
169
757
  } catch (e) {
170
- debugLog("chat.message: failed:", errorMessage2(e));
758
+ log("chat.message: failed", e);
171
759
  }
172
760
  },
173
- "experimental.chat.system.transform": async (input, output) => {
761
+ "tool.execute.before": async (input2, output) => {
174
762
  try {
175
- const sid = input.sessionID ?? "default";
176
- if (input.sessionID && !sessionsWithStartupHint.has(sid)) {
177
- sessionsWithStartupHint.add(sid);
178
- output.system?.unshift(
179
- '[memoir] You have Memoir MCP tools (memoir_memoir_recall, memoir_memoir_remember, memoir_memoir_get, memoir_memoir_summarize, memoir_memoir_status, memoir_memoir_branches, memoir_memoir_checkout).\nSAVE (memoir_memoir_remember) after: task completion, user stating preferences/constraints, discovering project facts. RECALL (memoir_memoir_recall \u2192 memoir_memoir_get) at session start for prior context, when user asks about prior work.\nNamespaces: "default" (user context), "codebase:onboard" (project facts in git repos). Taxonomy paths: preferences.*, project.*, codebase.*, decisions.*, learning.*.'
763
+ if (input2.tool !== "task" || output.args?.subagent_type !== MEMOIR_AGENT_NAME) return;
764
+ captureLifecycle.begin(input2.callID);
765
+ if (!backgroundSubagentsEnabled()) {
766
+ log(
767
+ "memoir task remains foreground: OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS is disabled"
180
768
  );
769
+ return;
181
770
  }
182
- const count = sessionMsgCount.get(sid) ?? 0;
183
- if (shouldRemind(count)) {
184
- output.system?.push(
185
- "\n[memoir] Reminder: Use memoir_memoir_remember to save context, memoir_memoir_recall to retrieve prior context."
186
- );
771
+ output.args.background = true;
772
+ log("memoir capture task promoted to native background job");
773
+ } catch (e) {
774
+ log("tool.execute.before: failed", e);
775
+ }
776
+ },
777
+ "tool.execute.after": async (input2, output) => {
778
+ try {
779
+ if (input2.tool !== "task" || input2.args?.subagent_type !== MEMOIR_AGENT_NAME) return;
780
+ const childSessionID = output?.metadata?.sessionId;
781
+ if (output?.metadata?.background === true && childSessionID) {
782
+ captureLifecycle.background(input2.callID, childSessionID);
783
+ return;
784
+ }
785
+ captureLifecycle.finishCall(input2.callID);
786
+ } catch (e) {
787
+ log("tool.execute.after: failed", e);
788
+ captureLifecycle.finishCall(input2.callID);
789
+ }
790
+ },
791
+ event: async (input2) => {
792
+ try {
793
+ const event = input2.event;
794
+ if (event?.type !== "session.idle" && event?.type !== "session.error") return;
795
+ const sessionID = event.properties?.sessionID;
796
+ if (sessionID) captureLifecycle.finishSession(sessionID);
797
+ } catch (e) {
798
+ log("event: failed", e);
799
+ }
800
+ },
801
+ "experimental.chat.system.transform": async (input2, output) => {
802
+ try {
803
+ const sid = input2.sessionID ?? "default";
804
+ const status = turnStatus.get(sid);
805
+ if (status) output.system?.push(status);
806
+ if (input2.sessionID && parentSessions.has(sid) && !sessionsWithStartupHint.has(sid)) {
807
+ sessionsWithStartupHint.add(sid);
808
+ log("system.transform: startup hint injected for session", sid);
809
+ output.system?.unshift(loadPrompt("startup-hint.tmpl"));
810
+ const client = await connectClient();
811
+ if (client) {
812
+ if (process.env.MEMOIR_SUMMARIZE !== "0") {
813
+ const summary = await callMemoirTool(client, "memoir_summarize", {
814
+ depth: 1
815
+ });
816
+ if (summary) {
817
+ output.system?.unshift(
818
+ `[memoir] Prior context already stored (memoir):
819
+ ${summary}`
820
+ );
821
+ log("system.transform: proactive recall injected for session", sid);
822
+ }
823
+ } else {
824
+ log("system.transform: recall skipped (MEMOIR_SUMMARIZE=0)");
825
+ }
826
+ const status2 = await callMemoirTool(client, "memoir_status", {});
827
+ if (status2) {
828
+ output.system?.unshift(`[memoir] Memory store status:
829
+ ${status2}`);
830
+ log("system.transform: store status injected for session", sid);
831
+ }
832
+ }
187
833
  }
188
834
  } catch (e) {
189
- debugLog("system.transform: failed:", errorMessage2(e));
835
+ log("system.transform: failed", e);
190
836
  }
191
837
  },
192
838
  dispose: async () => {
193
839
  try {
194
840
  if (process.env.MEMOIR_AUTO_SAVE === "1") {
195
- for (const [sid, count] of sessionMsgCount) {
196
- await callMemoir(
197
- [
198
- "remember",
199
- `session.${sid}`,
200
- "--message",
201
- `Session ended \u2014 ${count} user messages exchanged`
202
- ],
203
- storePath
204
- );
841
+ const client = await connectClient();
842
+ if (client) {
843
+ for (const [sid, count] of messageCounts) {
844
+ await callMemoirTool(client, "memoir_remember", {
845
+ content: `Session ended \u2014 ${count} user messages exchanged`,
846
+ path: `session.${sid}`,
847
+ merge_policy: "replace"
848
+ });
849
+ }
205
850
  }
206
851
  }
207
852
  } catch (e) {
208
- debugLog("dispose: save failed:", errorMessage2(e));
853
+ log("dispose: save failed", e);
209
854
  }
210
855
  sessionsWithStartupHint.clear();
211
- pruneBranchCache();
212
- pruneAll();
856
+ parentSessions.clear();
857
+ capturePending.clear();
858
+ captureLifecycle.clear();
859
+ branchMatcher.clear();
860
+ messageCounts.clear();
861
+ turnStatus.clear();
862
+ lastCaptured.clear();
863
+ await runtime.close();
213
864
  }
214
865
  };
215
866
  return hooks;