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