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