@eleboucher/opencode-memini 0.7.11 → 0.7.12
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/README.md +15 -7
- package/memini-v2.js +28 -2
- package/memini.js +39 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,8 +15,8 @@ What it wires (two hooks):
|
|
|
15
15
|
excludes this session's own captured turns (already in the live context), so
|
|
16
16
|
they aren't echoed back as memory a turn behind; past sessions still recall.
|
|
17
17
|
- **`event` (`session.idle`)** — once the session goes idle, stores the
|
|
18
|
-
completed user/assistant turn back into memini (
|
|
19
|
-
|
|
18
|
+
completed user/assistant turn back into memini (tagged with the session id) so
|
|
19
|
+
it can be recalled later.
|
|
20
20
|
|
|
21
21
|
### Install
|
|
22
22
|
|
|
@@ -77,13 +77,14 @@ Pass options inline via the `[name, options]` form:
|
|
|
77
77
|
| `home` | `MEMINI_HOME` | unset | caller's personal namespace, sent as `X-Memini-Home`; unset = no home leg |
|
|
78
78
|
| `recall` | `MEMINI_RECALL` | on | `false` disables recall-before-turn |
|
|
79
79
|
| `capture` | `MEMINI_CAPTURE` | on | `false` disables capture-after-turn |
|
|
80
|
+
| `capture_child_sessions` | `MEMINI_CAPTURE_CHILD_SESSIONS` | off | capture child sessions; off skips children and unknown ancestry (fail-closed), on records ancestry metadata |
|
|
80
81
|
| `recall_limit` | `MEMINI_RECALL_LIMIT` | `3` | max memories injected per turn |
|
|
81
82
|
| `recall_max_tokens` | `MEMINI_INJECT_RECALL_MAX_TOK` | `0` | hard ceiling on the recall-block tokens (`0` = unbounded); the tail is dropped with a `[… N item(s) truncated by token budget]` footer |
|
|
82
83
|
| `recall_min_score` | `MEMINI_INJECT_RECALL_MIN_SCORE` | `0` | fused-score floor (>=) sent as `min_score` to `/v1/search` |
|
|
83
84
|
| `inject_cooldown_ms` | `MEMINI_INJECT_COOLDOWN_MS` | `1800000` | repeat-injection cooldown, **time** window (ms): an already-injected memory is held back this long before it may re-serve; `0` disables the time dimension |
|
|
84
85
|
| `inject_cooldown_prompts` | `MEMINI_INJECT_COOLDOWN_PROMPTS` | `3` | repeat-injection cooldown, **prompt** window (counted per user message); `0` disables the prompt dimension; both cooldown knobs `0` = suppress for the whole session |
|
|
85
86
|
| `recall_budget_ms` | `MEMINI_RECALL_BUDGET_MS` | `2000` | how long a turn waits for recall before proceeding without it (`0` = wait for the full `timeout_ms`) |
|
|
86
|
-
| `timeout_ms` | `MEMINI_TIMEOUT_MS` | `30000` | per-request timeout
|
|
87
|
+
| `timeout_ms` | `MEMINI_TIMEOUT_MS` | `30000` | per-request timeout for memini requests |
|
|
87
88
|
| `fallback_on_error` | `MEMINI_FALLBACK` | on | `false` surfaces errors instead of degrading silently |
|
|
88
89
|
| `auto_update` | `MEMINI_AUTO_UPDATE` | on | `false` disables npm auto-update checks (opencode never re-fetches cached plugins otherwise) |
|
|
89
90
|
| — | `MEMINI_INJECT_LABELS` | — | comma-separated label toggles for each bullet: `tier`, `confidence`, `age`, `reason` |
|
|
@@ -93,13 +94,20 @@ Pass options inline via the `[name, options]` form:
|
|
|
93
94
|
opencode awaits `chat.message` before the model sees the message, so a slow or
|
|
94
95
|
unreachable memini would otherwise freeze the turn for the full `timeout_ms`.
|
|
95
96
|
Instead, recall races `recall_budget_ms`: if the search hasn't answered in time,
|
|
96
|
-
the turn proceeds without memories and
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
startup to warm the connection, so the first recall doesn't pay the
|
|
97
|
+
the turn proceeds without memories and eventual results are discarded. Errors
|
|
98
|
+
from the background request are still logged. The plugin also pings `/healthz`
|
|
99
|
+
once at startup to warm the connection, so the first recall doesn't pay the
|
|
100
100
|
DNS/TLS cold-start. Set `recall_budget_ms: 0` to restore fully blocking
|
|
101
101
|
same-turn injection.
|
|
102
102
|
|
|
103
|
+
Automatic captures leave tier selection to the server. Before retrieving
|
|
104
|
+
messages, the plugin resolves session ancestry. Root sessions are captured by
|
|
105
|
+
default; child sessions and sessions whose ancestry cannot be resolved are
|
|
106
|
+
skipped by default. Set `capture_child_sessions` (or
|
|
107
|
+
`MEMINI_CAPTURE_CHILD_SESSIONS=1`) to opt in; captures then include
|
|
108
|
+
`metadata.session_type` (`root`, `child`, or `unknown`) and child captures also
|
|
109
|
+
include `metadata.parent_session_id`.
|
|
110
|
+
|
|
103
111
|
### Repeat-injection cooldown
|
|
104
112
|
|
|
105
113
|
The two `inject_cooldown_*` knobs are the windowed **repeat-injection
|
package/memini-v2.js
CHANGED
|
@@ -48,11 +48,13 @@ import {
|
|
|
48
48
|
injectedIdentity,
|
|
49
49
|
injectedSuppressed,
|
|
50
50
|
postSearchWithFloor,
|
|
51
|
+
resolveSessionAncestry,
|
|
51
52
|
} from "./memini.js";
|
|
52
53
|
|
|
53
54
|
const INJECT_PREAMBLE =
|
|
54
55
|
"Relevant long-term memory from memini (background context — prefer " +
|
|
55
56
|
"current workspace state and the user's instructions):";
|
|
57
|
+
const BUDGET_EXPIRED = Symbol("memini-recall-budget-expired");
|
|
56
58
|
|
|
57
59
|
// messageText pulls the plain text out of one v2 request message, tolerating the
|
|
58
60
|
// shapes the beta may hand us: `content` as a string, `content` as an array of
|
|
@@ -259,11 +261,33 @@ export async function setup(ctx) {
|
|
|
259
261
|
// fail-soft, and on an older server's 400 it retries once with
|
|
260
262
|
// min_rank_score stripped (v2 sends no exclude_ids), so a slow or
|
|
261
263
|
// out-of-date memini degrades to no memory this turn, never a throw.
|
|
262
|
-
const
|
|
264
|
+
const searchPromise = postSearchWithFloor(
|
|
263
265
|
rest.postJson,
|
|
264
266
|
body,
|
|
265
267
|
live.namespace,
|
|
266
268
|
);
|
|
269
|
+
// Keep the rejection handler attached even after the request budget
|
|
270
|
+
// expires: late results are discarded, but late errors remain visible.
|
|
271
|
+
const settled = searchPromise.catch((error) => {
|
|
272
|
+
log.warn(`memini: ${String(error)}`);
|
|
273
|
+
return { data: null, rankFloorStripped: false };
|
|
274
|
+
});
|
|
275
|
+
let search;
|
|
276
|
+
if (live.recall_budget_ms > 0) {
|
|
277
|
+
let timer;
|
|
278
|
+
const budget = new Promise((resolve) => {
|
|
279
|
+
timer = setTimeout(() => resolve(BUDGET_EXPIRED), live.recall_budget_ms);
|
|
280
|
+
});
|
|
281
|
+
search = await Promise.race([settled, budget]);
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
if (search === BUDGET_EXPIRED) {
|
|
284
|
+
log.warn(`recall exceeded its ${live.recall_budget_ms}ms budget; late results discarded`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
search = await settled;
|
|
289
|
+
}
|
|
290
|
+
const { data: result, rankFloorStripped } = search;
|
|
267
291
|
|
|
268
292
|
// Client composite floor is a fallback ONLY: it runs when the knob was
|
|
269
293
|
// clamped to client-only (>= 1) or the retry stripped min_rank_score. A
|
|
@@ -371,11 +395,13 @@ export async function setup(ctx) {
|
|
|
371
395
|
const sessionID =
|
|
372
396
|
(event && event.properties && event.properties.sessionID) || (event && event.sessionID);
|
|
373
397
|
if (!sessionID) return;
|
|
398
|
+
const ancestry = await resolveSessionAncestry(ctx.session, sessionID);
|
|
399
|
+
if (ancestry.session_type !== "root" && !live.capture_child_sessions) return;
|
|
374
400
|
const messages = await fetchSessionMessages(ctx, sessionID);
|
|
375
401
|
const { userText, assistantText, assistantID } = extractLastTurn(messages);
|
|
376
402
|
if (!userText || !assistantText) return;
|
|
377
403
|
if (assistantID && captured.has(assistantID)) return;
|
|
378
|
-
const metadata = { source: "opencode", session_id: sessionID, format: "turn" };
|
|
404
|
+
const metadata = { source: "opencode", session_id: sessionID, format: "turn", ...ancestry };
|
|
379
405
|
if (lastAssistantFailed(messages)) metadata.failed = true;
|
|
380
406
|
const stored = await rest.postJson(
|
|
381
407
|
"/v1/memories",
|
package/memini.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - chat.message: recall memories relevant to the incoming user message and
|
|
7
7
|
* inject them as a synthetic context part before the turn runs.
|
|
8
8
|
* - event (session.idle): capture the completed user/assistant turn into
|
|
9
|
-
* memini
|
|
9
|
+
* memini once the session goes idle.
|
|
10
10
|
*
|
|
11
11
|
* Talks to memini over REST (/v1/search, /v1/memories), scoped by the
|
|
12
12
|
* X-Memini-Namespace header. Default endpoint http://localhost:8080.
|
|
@@ -365,6 +365,9 @@ export function resolveConfig(env, options, worktree) {
|
|
|
365
365
|
// guess. Not layered from the server: it is a purely local, per-caller knob.
|
|
366
366
|
const homeRaw = o.home !== undefined ? o.home : e.MEMINI_HOME;
|
|
367
367
|
const home = homeRaw && String(homeRaw).trim() ? String(homeRaw).trim() : undefined;
|
|
368
|
+
const capture_child_sessions = o.capture_child_sessions !== undefined
|
|
369
|
+
? envBool(o.capture_child_sessions, false)
|
|
370
|
+
: envBool(e.MEMINI_CAPTURE_CHILD_SESSIONS, false);
|
|
368
371
|
|
|
369
372
|
// Windowed injection-cooldown knobs. 0 is MEANINGFUL (it disables that
|
|
370
373
|
// dimension; both 0 restores the legacy suppress-forever behavior), so a
|
|
@@ -387,6 +390,7 @@ export function resolveConfig(env, options, worktree) {
|
|
|
387
390
|
home,
|
|
388
391
|
recall: o.recall !== undefined ? o.recall !== false : envBool(e.MEMINI_RECALL, true),
|
|
389
392
|
capture: o.capture !== undefined ? o.capture !== false : envBool(e.MEMINI_CAPTURE, true),
|
|
393
|
+
capture_child_sessions,
|
|
390
394
|
recall_limit,
|
|
391
395
|
recall_max_tokens:
|
|
392
396
|
o.recall_max_tokens !== undefined
|
|
@@ -791,6 +795,33 @@ export function buildTurnCapture(userText, assistantText, userMax, assistantMax)
|
|
|
791
795
|
return `${truncateForCapture(userText, userMax)}\n\n${truncateForCapture(assistantText, assistantMax)}`;
|
|
792
796
|
}
|
|
793
797
|
|
|
798
|
+
// Resolve ancestry before reading messages. Hosts have exposed several session
|
|
799
|
+
// accessors over time; an unavailable accessor is distinguishable from a root
|
|
800
|
+
// session so the default capture policy can fail closed.
|
|
801
|
+
export async function resolveSessionAncestry(session, sessionID) {
|
|
802
|
+
if (!session || !sessionID) return { session_type: "unknown" };
|
|
803
|
+
const attempts = [
|
|
804
|
+
() => session.get && session.get({ path: { id: sessionID } }),
|
|
805
|
+
() => session.info && session.info({ path: { id: sessionID } }),
|
|
806
|
+
() => session.get && session.get(sessionID),
|
|
807
|
+
];
|
|
808
|
+
for (const attempt of attempts) {
|
|
809
|
+
try {
|
|
810
|
+
const response = await attempt();
|
|
811
|
+
if (response?.error) continue;
|
|
812
|
+
const info = response?.data || response?.session || response;
|
|
813
|
+
if (!info || typeof info !== "object" || Array.isArray(info) || info.error) continue;
|
|
814
|
+
const recordID = info.id || info.sessionID || info.sessionId;
|
|
815
|
+
if (!recordID || String(recordID) !== String(sessionID)) continue;
|
|
816
|
+
const parent = info.parentID || info.parentId || info.parent_session_id || info.parentSessionId;
|
|
817
|
+
return parent ? { session_type: "child", parent_session_id: parent } : { session_type: "root" };
|
|
818
|
+
} catch {
|
|
819
|
+
// Try the next host shape.
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return { session_type: "unknown" };
|
|
823
|
+
}
|
|
824
|
+
|
|
794
825
|
/**
|
|
795
826
|
* Truncate to `max` bytes, suffix with a marker. Same shape as the Claude
|
|
796
827
|
* Code plugin's truncate helper.
|
|
@@ -1249,10 +1280,6 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1249
1280
|
state.ids.delete(oldest);
|
|
1250
1281
|
}
|
|
1251
1282
|
};
|
|
1252
|
-
// Recall results that arrived after the injection budget expired, keyed by
|
|
1253
|
-
// session and injected on that session's next chat.message. Latest-replace:
|
|
1254
|
-
// a second late recall for the same session supersedes the first.
|
|
1255
|
-
const pendingBySession = new Map();
|
|
1256
1283
|
// /v1/search drops exclude_ids before ranking and the limit, so an
|
|
1257
1284
|
// already-shown hit frees its slot for the next-best match. Older servers
|
|
1258
1285
|
// 400 on the unknown field: when a request carrying it fails and the retry
|
|
@@ -1377,9 +1404,9 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1377
1404
|
: [];
|
|
1378
1405
|
// opencode awaits this hook before the model sees the message, so the
|
|
1379
1406
|
// turn only waits live.recall_budget_ms for the search; the fetch itself keeps
|
|
1380
|
-
// cfg.timeout_ms as its bound and runs
|
|
1407
|
+
// cfg.timeout_ms as its bound and runs in the background. A slow or
|
|
1381
1408
|
// unreachable memini degrades to "no memories this turn" instead of a
|
|
1382
|
-
// frozen turn
|
|
1409
|
+
// frozen turn; results arriving after the budget are discarded.
|
|
1383
1410
|
const fetchPromise = searchExcluding(body, excludeIds, live.namespace);
|
|
1384
1411
|
// Once the budget expires nothing awaits this promise, and with
|
|
1385
1412
|
// fallback_on_error off postJson rethrows — catch here or a late
|
|
@@ -1397,15 +1424,7 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1397
1424
|
result = await Promise.race([settled, budget]);
|
|
1398
1425
|
clearTimeout(timer);
|
|
1399
1426
|
if (result === BUDGET_EXPIRED) {
|
|
1400
|
-
log.info(
|
|
1401
|
-
`recall exceeded its ${live.recall_budget_ms}ms budget; late results will inject next turn`,
|
|
1402
|
-
);
|
|
1403
|
-
if (sessionID) {
|
|
1404
|
-
settled.then((late) => {
|
|
1405
|
-
const hits = Array.isArray(late && late.data && late.data.results) ? late.data.results : [];
|
|
1406
|
-
if (hits.length) boundedPut(pendingBySession, sessionID, hits);
|
|
1407
|
-
});
|
|
1408
|
-
}
|
|
1427
|
+
log.info(`recall exceeded its ${live.recall_budget_ms}ms budget; late results discarded`);
|
|
1409
1428
|
result = null;
|
|
1410
1429
|
}
|
|
1411
1430
|
} else {
|
|
@@ -1419,16 +1438,6 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1419
1438
|
const serverEnforcedFloor = rankFloorInRange && !(result && result.rankFloorStripped);
|
|
1420
1439
|
const floor = live.recall_min_score > 0 && !serverEnforcedFloor ? live.recall_min_score : 0;
|
|
1421
1440
|
let rawHits = Array.isArray(searchData && searchData.results) ? searchData.results : [];
|
|
1422
|
-
// Merge in results that arrived late on a previous turn: fresh hits
|
|
1423
|
-
// first (they answer the current query), deduped by memory id.
|
|
1424
|
-
if (sessionID) {
|
|
1425
|
-
const pending = pendingBySession.get(sessionID);
|
|
1426
|
-
if (pending && pending.length) {
|
|
1427
|
-
pendingBySession.delete(sessionID);
|
|
1428
|
-
const fresh = new Set(rawHits.map((r) => r?.memory?.id).filter(Boolean));
|
|
1429
|
-
rawHits = rawHits.concat(pending.filter((r) => !fresh.has(r?.memory?.id)));
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1432
1441
|
// Suppress memories this session was already shown and that are still in
|
|
1433
1442
|
// cooldown — judged PER HIT against its content identity, so an
|
|
1434
1443
|
// in-window unchanged hit is dropped, a lapsed one passes through and
|
|
@@ -1453,8 +1462,7 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1453
1462
|
const fit = fitByTokens(hits, live.recall_max_tokens);
|
|
1454
1463
|
if (fit.items.length === 0) return;
|
|
1455
1464
|
if (seen) {
|
|
1456
|
-
// Mark only the slice formatResults actually renders
|
|
1457
|
-
// merged in, `filtered` can exceed recall_limit, and marking unshown
|
|
1465
|
+
// Mark only the slice formatResults actually renders; marking unshown
|
|
1458
1466
|
// hits as seen would suppress what was never injected.
|
|
1459
1467
|
rememberInjected(seen, filtered.slice(0, live.recall_limit || DEFAULT_RECALL_LIMIT));
|
|
1460
1468
|
}
|
|
@@ -1526,11 +1534,13 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1526
1534
|
if (!live.capture || !event || event.type !== "session.idle") return;
|
|
1527
1535
|
const sessionID = event.properties && event.properties.sessionID;
|
|
1528
1536
|
if (!sessionID) return;
|
|
1537
|
+
const ancestry = await resolveSessionAncestry(client.session, sessionID);
|
|
1538
|
+
if (ancestry.session_type !== "root" && !live.capture_child_sessions) return;
|
|
1529
1539
|
const res = await client.session.messages({ path: { id: sessionID } });
|
|
1530
1540
|
const { userText, assistantText, assistantID } = extractLastTurn(res && res.data);
|
|
1531
1541
|
if (!userText || !assistantText) return;
|
|
1532
1542
|
if (assistantID && captured.has(assistantID)) return;
|
|
1533
|
-
const metadata = { source: "opencode", session_id: sessionID, format: "turn" };
|
|
1543
|
+
const metadata = { source: "opencode", session_id: sessionID, format: "turn", ...ancestry };
|
|
1534
1544
|
if (lastAssistantFailed(res && res.data)) metadata.failed = true;
|
|
1535
1545
|
const stored = await rest.postJson(
|
|
1536
1546
|
"/v1/memories",
|