opencode-memoir 2.1.1 → 3.0.1

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,218 +1,864 @@
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;
19
81
  }
20
- function pruneAll() {
21
- sessionMsgCount.clear();
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("/"));
90
+ }
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");
32
152
  }
33
- function deriveStorePath(cwd = process.cwd()) {
34
- if (pluginStoreOverride) return pluginStoreOverride;
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
+ }
235
+ }
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);
47
321
  }
48
- const slug = projectDir.replace(/[/.]/g, "-");
49
- return join(homedir(), ".memoir", slug);
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));
50
333
  }
51
- var pluginStoreOverride;
52
- function setPluginStoreOverride(store) {
53
- pluginStoreOverride = store;
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
+ const port = this.serverPort ?? await pickFreeHighPort();
398
+ this.serverPort = port;
399
+ const url = new URL(`http://127.0.0.1:${port}/mcp`);
400
+ const args = [
401
+ ...this.command.slice(1),
402
+ "--http",
403
+ "--host",
404
+ "127.0.0.1",
405
+ "--port",
406
+ String(port)
407
+ ];
408
+ const proc = spawn(this.command[0], args, {
409
+ // Merge with the parent env so PATH (and other essentials) survive —
410
+ // passing only `env` would wipe PATH and break command resolution.
411
+ env: { ...process.env, ...this.env },
412
+ // Run in the real (symlink-resolved) project dir so memoir-mcp's
413
+ // portable store slug is identical whether the repo is entered via a
414
+ // symlink or its real path.
415
+ cwd: this.cwd ? safeRealpath(this.cwd) : process.cwd(),
416
+ stdio: ["ignore", "ignore", "pipe"]
417
+ });
418
+ this.serverProc = proc;
419
+ if (proc.stderr) {
420
+ proc.stderr.on("data", (chunk) => {
421
+ log("memoir-mcp:", chunk.toString().trimEnd());
422
+ });
423
+ }
424
+ proc.on("exit", (code) => {
425
+ log("memoir-mcp HTTP server exited with code", code);
426
+ if (this.serverProc !== proc) return;
427
+ this.serverProc = null;
428
+ this.serverUrl = null;
429
+ this.startingServer = null;
430
+ this.cleanupClient();
431
+ });
432
+ await new Promise((resolve2, reject) => {
433
+ proc.once("spawn", resolve2);
434
+ proc.once("error", reject);
435
+ }).catch((e) => {
436
+ throw new Error(`memoir: failed to spawn ${this.command[0]}: ${errorMessage(e)}`);
437
+ });
438
+ await waitForPort(port);
439
+ this.serverUrl = url;
440
+ log("memoir-mcp HTTP server up at", url.toString());
441
+ return url;
442
+ })();
443
+ this.startingServer = start;
444
+ try {
445
+ return await start;
446
+ } catch (e) {
447
+ this.startingServer = null;
448
+ this.serverUrl = null;
449
+ if (this.serverProc) this.serverProc.kill();
450
+ this.serverProc = null;
451
+ throw e;
452
+ }
453
+ }
454
+ /** Get the lazily-connected internal client for this plugin instance. */
455
+ async connect() {
456
+ if (this.state) return this.state.client;
457
+ if (!this.connecting) {
458
+ this.connecting = (async () => {
459
+ const url = await this.start();
460
+ const { client, transport } = this.dependencies.createClientConnection?.(url) ?? {
461
+ client: new Client({ name: "opencode-memoir", version: "1.0.0" }, { capabilities: {} }),
462
+ transport: new StreamableHTTPClientTransport(url)
463
+ };
464
+ transport.onclose = () => {
465
+ log("mcp-client: transport closed, resetting state");
466
+ this.cleanupClient();
467
+ };
468
+ try {
469
+ await client.connect(transport);
470
+ } catch (e) {
471
+ this.cleanupClient();
472
+ throw e;
473
+ }
474
+ this.state = { client };
475
+ return client;
476
+ })();
477
+ }
478
+ return this.connecting;
479
+ }
480
+ /** Discover the live tool catalog once per connection for the small-model prompt. */
481
+ async listTools(client) {
482
+ if (this.tools) return this.tools;
483
+ try {
484
+ const result = await client.listTools();
485
+ this.tools = (result.tools ?? []).map((tool) => ({
486
+ // OpenCode exposes remote MCP tools as <server>_<raw-name>. The server
487
+ // is registered as "memoir", so the prompt must use the same names the
488
+ // subagent actually sees (for example memoir_memoir_remember).
489
+ name: `memoir_${tool.name}`,
490
+ description: typeof tool.description === "string" ? tool.description : ""
491
+ }));
492
+ return this.tools;
493
+ } catch (e) {
494
+ log("MemoirRuntime.listTools failed", e);
495
+ return [];
496
+ }
497
+ }
498
+ /** Close the MCP client and stop this instance's HTTP server. */
499
+ async close() {
500
+ if (this.state) {
501
+ try {
502
+ await this.state.client.close();
503
+ } catch (e) {
504
+ log("closeMemoirClient: close failed", e);
505
+ }
506
+ this.cleanupClient();
507
+ }
508
+ if (this.serverProc) {
509
+ const proc = this.serverProc;
510
+ this.serverProc = null;
511
+ try {
512
+ await new Promise((resolve2) => {
513
+ if (proc.exitCode !== null) {
514
+ resolve2();
515
+ return;
516
+ }
517
+ const timer = setTimeout(resolve2, 1e3);
518
+ timer.unref();
519
+ proc.once("exit", () => {
520
+ clearTimeout(timer);
521
+ resolve2();
522
+ });
523
+ proc.once("error", () => {
524
+ clearTimeout(timer);
525
+ resolve2();
526
+ });
527
+ proc.kill();
528
+ });
529
+ } catch (e) {
530
+ log("closeMemoirClient: kill failed", e);
531
+ }
532
+ }
533
+ this.serverUrl = null;
534
+ this.startingServer = null;
535
+ this.connecting = null;
536
+ this.tools = null;
537
+ }
538
+ };
539
+ async function callMemoirTool(client, name, args) {
540
+ try {
541
+ const result = await client.callTool({
542
+ name,
543
+ arguments: args
544
+ });
545
+ if (result.isError) {
546
+ log(`callMemoirTool: ${name} returned error`);
547
+ return null;
548
+ }
549
+ for (const block of result.content) {
550
+ if (block.type === "text" && block.text != null) {
551
+ return block.text;
552
+ }
553
+ }
554
+ return null;
555
+ } catch (e) {
556
+ log(`callMemoirTool: ${name} failed`, e);
557
+ return null;
558
+ }
559
+ }
560
+
561
+ // src/store.ts
562
+ import { execFileSync as execFileSync2 } from "node:child_process";
563
+ function deriveStorePath2(cwd, override) {
564
+ return deriveStorePath(cwd, override);
54
565
  }
55
566
  function currentGitBranch(cwd = process.cwd()) {
56
567
  try {
57
- return execFileSync("git", ["branch", "--show-current"], {
58
- cwd,
568
+ return execFileSync2("git", ["branch", "--show-current"], {
569
+ cwd: safeRealpath(cwd),
59
570
  encoding: "utf8",
571
+ stdio: ["ignore", "pipe", "ignore"],
60
572
  timeout: 3e3
61
573
  }).trim();
62
574
  } catch {
63
575
  return "";
64
576
  }
65
577
  }
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
578
+ var MemoirBranchMatcher = class {
579
+ matching = Promise.resolve();
580
+ match(client, cwd, drain) {
581
+ const next = this.matching.then(() => this.matchNow(client, cwd, drain));
582
+ this.matching = next.catch(() => void 0);
583
+ return next;
584
+ }
585
+ async currentBranch(client) {
586
+ const raw = await callMemoirTool(client, "memoir_status");
587
+ if (!raw) return "";
588
+ try {
589
+ const status = JSON.parse(raw);
590
+ return typeof status.branch === "string" ? status.branch : "";
591
+ } catch {
592
+ return "";
593
+ }
594
+ }
595
+ async matchNow(client, cwd, drain) {
596
+ const codeBranch = currentGitBranch(cwd);
597
+ if (!codeBranch) return;
598
+ if (await this.currentBranch(client) === codeBranch) return;
599
+ if (drain && !await drain()) return;
600
+ if (await this.currentBranch(client) === codeBranch) return;
601
+ await callMemoirTool(client, "memoir_checkout", {
602
+ target: codeBranch,
603
+ create: true
79
604
  });
80
- return stdout.trim();
81
- } catch (e) {
82
- debugLog("callMemoir failed:", errorMessage(e));
83
- return null;
84
605
  }
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;
606
+ clear() {
607
+ this.matching = Promise.resolve();
608
+ }
609
+ };
610
+
611
+ // src/turn-status.ts
612
+ function buildTurnStatus(statusRaw) {
613
+ let branch = "";
614
+ let memoryCount = 0;
93
615
  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));
616
+ const status = JSON.parse(statusRaw ?? "");
617
+ if (typeof status.branch === "string") branch = status.branch;
618
+ if (typeof status.memory_count === "number") memoryCount = status.memory_count;
619
+ } catch {
620
+ return "";
103
621
  }
104
- }
105
- function pruneBranchCache() {
106
- branchCache.clear();
622
+ if (!branch && memoryCount <= 0) return "";
623
+ if (!branch) return `[memoir] memory available (${memoryCount} memories)`;
624
+ return `[memoir] ${branch}${memoryCount > 0 ? ` \xB7 memory available (${memoryCount} memories)` : ""}`;
107
625
  }
108
626
 
109
627
  // src/index.ts
110
- var sessionsWithStartupHint = /* @__PURE__ */ new Set();
111
- function errorMessage2(e) {
112
- return e instanceof Error ? e.message : String(e);
628
+ function envFlag(name) {
629
+ const value = process.env[name]?.trim().toLowerCase();
630
+ if (value === void 0 || value === "") return void 0;
631
+ return value === "1" || value === "true";
632
+ }
633
+ function backgroundSubagentsEnabled() {
634
+ return envFlag("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS") ?? envFlag("OPENCODE_EXPERIMENTAL") ?? false;
113
635
  }
114
- var MemoirOpenCode = async (_input, rawOptions) => {
636
+ var MemoirOpenCode = async (input, rawOptions) => {
115
637
  const opts = rawOptions ?? {};
116
- if (opts.store) setPluginStoreOverride(opts.store);
117
- const storePath = deriveStorePath();
638
+ const pluginInput = input ?? {};
639
+ const sdkClient = pluginInput.client;
640
+ const directory = safeRealpath(pluginInput.directory ?? process.cwd());
641
+ const storePath = deriveStorePath2(directory, opts.store);
118
642
  const mcpCommand = ["memoir-mcp"];
119
643
  if (storePath) {
120
644
  mcpCommand.push("--store", storePath);
121
645
  }
122
- const mcpServer = {
123
- type: "local",
124
- command: mcpCommand,
125
- environment: storePath ? { MEMOIR_STORE: storePath } : void 0
646
+ const environment = storePath ? { MEMOIR_STORE: storePath } : void 0;
647
+ const runtime = new MemoirRuntime(mcpCommand, environment, directory);
648
+ const branchMatcher = new MemoirBranchMatcher();
649
+ const sessionsWithStartupHint = /* @__PURE__ */ new Set();
650
+ const parentSessions = /* @__PURE__ */ new Set();
651
+ const lastCaptured = /* @__PURE__ */ new Map();
652
+ const capturePending = /* @__PURE__ */ new Set();
653
+ const captureLifecycle = new CaptureLifecycle();
654
+ const messageCounts = /* @__PURE__ */ new Map();
655
+ const turnStatus = /* @__PURE__ */ new Map();
656
+ let mcpServer;
657
+ const connectClient = async () => {
658
+ try {
659
+ return await runtime.connect();
660
+ } catch (e) {
661
+ log("plugin: failed to connect memoir client", e);
662
+ return null;
663
+ }
664
+ };
665
+ const discoverTools = async () => {
666
+ const client = await connectClient();
667
+ return client ? runtime.listTools(client) : [];
668
+ };
669
+ const dispatchCapture = async (sid) => {
670
+ if (!sdkClient || !parentSessions.has(sid) || capturePending.has(sid)) return;
671
+ capturePending.add(sid);
672
+ try {
673
+ const client = await connectClient();
674
+ if (client) await branchMatcher.match(client, directory, () => captureLifecycle.drain());
675
+ await captureTurn(sdkClient, sid, directory, lastCaptured, await discoverTools());
676
+ } catch (e) {
677
+ log("dispatchCapture failed", e);
678
+ } finally {
679
+ capturePending.delete(sid);
680
+ }
126
681
  };
127
682
  const hooks = {
128
683
  name: "memoir",
129
684
  config: async (config) => {
130
- debugLog("config hook: mcpCommand =", JSON.stringify(mcpCommand));
131
- debugLog("config hook: mcpCommand length =", mcpCommand.length);
685
+ log("config hook: mcpCommand =", JSON.stringify(mcpCommand));
686
+ try {
687
+ const url = await runtime.start();
688
+ mcpServer = { type: "remote", url: url.toString(), enabled: true };
689
+ log("memoir MCP server registered at", url.toString(), "| store:", storePath);
690
+ } catch (e) {
691
+ log("config hook: failed to start memoir HTTP server", e);
692
+ mcpServer = void 0;
693
+ }
132
694
  const mcp = config.mcp ?? {};
133
- mcp.memoir = mcpServer;
695
+ if (mcpServer) mcp.memoir = mcpServer;
134
696
  config.mcp = mcp;
697
+ if (mcpServer) hooks.mcp = { memoir: mcpServer };
698
+ const agentModel = resolveMemoirModel({
699
+ summarizeModel: process.env.MEMOIR_AGENT_MODEL,
700
+ smallModel: config.small_model,
701
+ model: config.model
702
+ });
703
+ const agentField = config;
704
+ agentField.agent = {
705
+ ...agentField.agent ?? {},
706
+ [MEMOIR_AGENT_NAME]: buildMemoirAgent(agentModel)
707
+ };
708
+ log("memoir subagent registered | model:", agentModel ?? "(opencode default)");
709
+ log(
710
+ "memoir capture execution mode:",
711
+ backgroundSubagentsEnabled() ? "native background" : "foreground",
712
+ "| OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS:",
713
+ process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS ?? "(unset)",
714
+ "| OPENCODE_EXPERIMENTAL:",
715
+ process.env.OPENCODE_EXPERIMENTAL ?? "(unset)"
716
+ );
135
717
  config.command = config.command ?? {};
136
718
  config.command["memoir:onboard"] = {
137
719
  description: "Populate or refresh Memoir onboarding for this project",
138
- template: `Populate or refresh Memoir onboarding for the current project.
139
-
140
- Workflow:
141
- - First obtain a project file tree to understand structure.
142
- - Start studying from project documentation.
143
- - Continue only based on what the tree and documentation show.
144
-
145
- Memory rules:
146
- - Record only verified facts from files/docs/code or explicit user statements.
147
- - Do not write inferred user thoughts, intentions, preferences, or opinions.
148
- - Do not use preferences.* paths unless the user explicitly stated a preference.
149
- - If a fact is your interpretation, do not save it; report it as uncertain instead.
150
-
151
- Then call memoir_memoir_remember with replace=true for durable onboarding facts. Use namespace codebase:onboard in git repositories and project:onboard outside git.`
720
+ template: loadPrompt("onboard.tmpl")
152
721
  };
153
- debugLog("config hook: mcpCommand =", JSON.stringify(mcpCommand));
154
722
  },
155
- "shell.env": async (_input2, output) => {
723
+ "shell.env": async (_input, output) => {
156
724
  try {
157
725
  if (storePath) {
158
726
  output.env.MEMOIR_STORE = storePath;
159
727
  }
160
728
  } catch (e) {
161
- debugLog("shell.env: failed:", errorMessage2(e));
729
+ log("shell.env: failed", e);
162
730
  }
163
731
  },
164
- "chat.message": async (input, _output) => {
732
+ "chat.message": async (input2, output) => {
165
733
  try {
166
- const sid = input.sessionID ?? "default";
167
- incrementMsgCount(sid);
168
- await autoMatchMemoirBranch(storePath, sid);
734
+ const sid = input2.sessionID ?? "default";
735
+ const parts = output.parts ?? [];
736
+ const isMemoirSubtask = parts.some(
737
+ (part) => part.type === "subtask" && part.agent === MEMOIR_AGENT_NAME
738
+ );
739
+ const isSynthetic = parts.length > 0 && parts.every((part) => part.synthetic === true);
740
+ if (input2.agent === MEMOIR_AGENT_NAME || isMemoirSubtask || isSynthetic) return;
741
+ parentSessions.add(sid);
742
+ messageCounts.set(sid, (messageCounts.get(sid) ?? 0) + 1);
743
+ const client = await connectClient();
744
+ if (client) {
745
+ await branchMatcher.match(client, directory, () => captureLifecycle.drain());
746
+ const status = await callMemoirTool(client, "memoir_status", {});
747
+ const line = buildTurnStatus(status);
748
+ if (line) turnStatus.set(sid, line);
749
+ else turnStatus.delete(sid);
750
+ }
751
+ log("chat.message: submitting capture for previous completed turn", sid);
752
+ await dispatchCapture(sid);
169
753
  } catch (e) {
170
- debugLog("chat.message: failed:", errorMessage2(e));
754
+ log("chat.message: failed", e);
171
755
  }
172
756
  },
173
- "experimental.chat.system.transform": async (input, output) => {
757
+ "tool.execute.before": async (input2, output) => {
174
758
  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.*.'
759
+ if (input2.tool !== "task" || output.args?.subagent_type !== MEMOIR_AGENT_NAME) return;
760
+ captureLifecycle.begin(input2.callID);
761
+ if (!backgroundSubagentsEnabled()) {
762
+ log(
763
+ "memoir task remains foreground: OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS is disabled"
180
764
  );
765
+ return;
181
766
  }
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
- );
767
+ output.args.background = true;
768
+ log("memoir capture task promoted to native background job");
769
+ } catch (e) {
770
+ log("tool.execute.before: failed", e);
771
+ }
772
+ },
773
+ "tool.execute.after": async (input2, output) => {
774
+ try {
775
+ if (input2.tool !== "task" || input2.args?.subagent_type !== MEMOIR_AGENT_NAME) return;
776
+ const childSessionID = output?.metadata?.sessionId;
777
+ if (output?.metadata?.background === true && childSessionID) {
778
+ captureLifecycle.background(input2.callID, childSessionID);
779
+ return;
780
+ }
781
+ captureLifecycle.finishCall(input2.callID);
782
+ } catch (e) {
783
+ log("tool.execute.after: failed", e);
784
+ captureLifecycle.finishCall(input2.callID);
785
+ }
786
+ },
787
+ event: async (input2) => {
788
+ try {
789
+ const event = input2.event;
790
+ if (event?.type !== "session.idle" && event?.type !== "session.error") return;
791
+ const sessionID = event.properties?.sessionID;
792
+ if (sessionID) captureLifecycle.finishSession(sessionID);
793
+ } catch (e) {
794
+ log("event: failed", e);
795
+ }
796
+ },
797
+ "experimental.chat.system.transform": async (input2, output) => {
798
+ try {
799
+ const sid = input2.sessionID ?? "default";
800
+ const status = turnStatus.get(sid);
801
+ if (status) output.system?.push(status);
802
+ if (input2.sessionID && parentSessions.has(sid) && !sessionsWithStartupHint.has(sid)) {
803
+ sessionsWithStartupHint.add(sid);
804
+ log("system.transform: startup hint injected for session", sid);
805
+ output.system?.unshift(loadPrompt("startup-hint.tmpl"));
806
+ const client = await connectClient();
807
+ if (client) {
808
+ if (process.env.MEMOIR_SUMMARIZE !== "0") {
809
+ const summary = await callMemoirTool(client, "memoir_summarize", {
810
+ depth: 1
811
+ });
812
+ if (summary) {
813
+ output.system?.unshift(
814
+ `[memoir] Prior context already stored (memoir):
815
+ ${summary}`
816
+ );
817
+ log("system.transform: proactive recall injected for session", sid);
818
+ }
819
+ } else {
820
+ log("system.transform: recall skipped (MEMOIR_SUMMARIZE=0)");
821
+ }
822
+ const status2 = await callMemoirTool(client, "memoir_status", {});
823
+ if (status2) {
824
+ output.system?.unshift(`[memoir] Memory store status:
825
+ ${status2}`);
826
+ log("system.transform: store status injected for session", sid);
827
+ }
828
+ }
187
829
  }
188
830
  } catch (e) {
189
- debugLog("system.transform: failed:", errorMessage2(e));
831
+ log("system.transform: failed", e);
190
832
  }
191
833
  },
192
834
  dispose: async () => {
193
835
  try {
194
836
  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
- );
837
+ const client = await connectClient();
838
+ if (client) {
839
+ for (const [sid, count] of messageCounts) {
840
+ await callMemoirTool(client, "memoir_remember", {
841
+ content: `Session ended \u2014 ${count} user messages exchanged`,
842
+ path: `session.${sid}`,
843
+ merge_policy: "replace"
844
+ });
845
+ }
205
846
  }
206
847
  }
207
848
  } catch (e) {
208
- debugLog("dispose: save failed:", errorMessage2(e));
849
+ log("dispose: save failed", e);
209
850
  }
210
851
  sessionsWithStartupHint.clear();
211
- pruneBranchCache();
212
- pruneAll();
852
+ parentSessions.clear();
853
+ capturePending.clear();
854
+ captureLifecycle.clear();
855
+ branchMatcher.clear();
856
+ messageCounts.clear();
857
+ turnStatus.clear();
858
+ lastCaptured.clear();
859
+ await runtime.close();
213
860
  }
214
861
  };
215
- hooks.mcp = { memoir: mcpServer };
216
862
  return hooks;
217
863
  };
218
864
  var plugin = {