dsh-trivium 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/episode.js ADDED
@@ -0,0 +1,278 @@
1
+ /** Compaction checkpoints and fork lineage as episode nodes. Not recalled by find. */
2
+
3
+ import { EDGE_LABELS, nowIso } from "./schema.js";
4
+ import { ensureLink, findByType, findByUri, insertNode, updateNode } from "./store.js";
5
+
6
+ const FALLBACK_SUMMARY = "已压缩";
7
+ const TAIL_SUMMARY = "当前";
8
+ const FORK_SUMMARY = "分叉";
9
+
10
+ /** Auto titles. A hand-set name that is not one of these is the box title. */
11
+ export const GENERIC_EPISODE_NAMES = Object.freeze([TAIL_SUMMARY, FALLBACK_SUMMARY, FORK_SUMMARY]);
12
+
13
+ export function isGenericEpisodeName(name) {
14
+ const n = String(name || "").trim();
15
+ return !n || GENERIC_EPISODE_NAMES.includes(n);
16
+ }
17
+
18
+ function genericNameOf(kind) {
19
+ if (kind === "tail") return TAIL_SUMMARY;
20
+ if (kind === "fork") return FORK_SUMMARY;
21
+ return FALLBACK_SUMMARY;
22
+ }
23
+
24
+ export function episodeUri(sessionId, atSeq) {
25
+ return `ctx://episode/${sessionId}/${atSeq}`;
26
+ }
27
+
28
+ export function tailUri(sessionId) {
29
+ return `ctx://episode/${sessionId}/tail`;
30
+ }
31
+
32
+ function sessionOf(payload) {
33
+ return String(payload?.sessionId || "");
34
+ }
35
+
36
+ export function listEpisodeRecords(db, sessionId) {
37
+ const want = sessionId == null || sessionId === "" ? null : String(sessionId);
38
+ const rows = [];
39
+ const fromIndex = findByType(db, "episode");
40
+ const ids = fromIndex.length
41
+ ? fromIndex.map((n) => n.id)
42
+ : db.allNodeIds?.() || [];
43
+ const seen = new Set();
44
+ for (const raw of ids) {
45
+ const id = Number(raw?.id ?? raw);
46
+ if (!Number.isFinite(id) || seen.has(id)) continue;
47
+ seen.add(id);
48
+ const node = db.get(id);
49
+ const payload = node?.payload || {};
50
+ if (payload.type !== "episode" || payload.status === "archived") continue;
51
+ if (want && sessionOf(payload) !== want) continue;
52
+ rows.push(recordOf(id, payload, node?.edges || []));
53
+ }
54
+ return rows.sort((a, b) => (a.atSeq || 0) - (b.atSeq || 0) || a.id - b.id);
55
+ }
56
+
57
+ function recordOf(id, payload, edges) {
58
+ const summary = String(payload.summary || payload.text || "").trim();
59
+ return {
60
+ id: Number(id),
61
+ type: "episode",
62
+ sessionId: sessionOf(payload),
63
+ kind: payload.kind || "checkpoint",
64
+ atSeq: Number.isFinite(Number(payload.atSeq)) ? Number(payload.atSeq) : 0,
65
+ summary,
66
+ compactionId: payload.compactionId || "",
67
+ status: payload.status || "active",
68
+ createdAt: payload.createdAt || "",
69
+ updatedAt: payload.updatedAt || "",
70
+ uri: payload.uri || "",
71
+ name: payload.name || "",
72
+ text: payload.text || summary,
73
+ edges,
74
+ };
75
+ }
76
+
77
+ function upsertEpisode(db, payload) {
78
+ const uri = String(payload.uri || "").trim();
79
+ let id = uri ? findByUri(db, uri) : null;
80
+ if (id == null && payload.kind === "tail" && payload.sessionId) {
81
+ const existing = listEpisodeRecords(db, payload.sessionId).find((e) => e.kind === "tail");
82
+ if (existing) id = existing.id;
83
+ }
84
+ const body = {
85
+ type: "episode",
86
+ kind: payload.kind,
87
+ sessionId: String(payload.sessionId || ""),
88
+ atSeq: Number.isFinite(Number(payload.atSeq)) ? Number(payload.atSeq) : 0,
89
+ summary: String(payload.summary || payload.text || "").trim(),
90
+ compactionId: payload.compactionId || undefined,
91
+ uri,
92
+ name: payload.name || genericNameOf(payload.kind),
93
+ text: String(payload.summary || payload.text || "").trim(),
94
+ status: "active",
95
+ updatedAt: nowIso(),
96
+ };
97
+ if (id != null) {
98
+ const old = db.get(Number(id))?.payload || {};
99
+ const next = {
100
+ ...old,
101
+ ...body,
102
+ name: old.name || body.name,
103
+ kind: old.kind === "checkpoint" ? "checkpoint" : body.kind,
104
+ createdAt: old.createdAt || nowIso(),
105
+ };
106
+ if (payload.kind === "checkpoint") next.kind = "checkpoint";
107
+ db.updatePayload(Number(id), next);
108
+ db.flush();
109
+ return Number(id);
110
+ }
111
+ return insertNode(db, { ...body, createdAt: nowIso() });
112
+ }
113
+
114
+ function linkChain(db, sessionId) {
115
+ const rows = listEpisodeRecords(db, sessionId);
116
+ const checks = rows
117
+ .filter((e) => e.kind === "checkpoint" || e.kind === "fork")
118
+ .sort((a, b) => (a.atSeq || 0) - (b.atSeq || 0) || a.id - b.id);
119
+ const tail = rows.find((e) => e.kind === "tail");
120
+ for (let i = 0; i < checks.length - 1; i += 1) {
121
+ ensureLink(db, checks[i].id, checks[i + 1].id, EDGE_LABELS.continues);
122
+ }
123
+ if (tail && checks.length) {
124
+ ensureLink(db, checks[checks.length - 1].id, tail.id, EDGE_LABELS.continues);
125
+ }
126
+ }
127
+
128
+ /** At most one tail episode per session. Stable uri so compaction can reuse the node. */
129
+ export function ensureTail(db, sessionId, { atSeq, summary } = {}) {
130
+ const sid = String(sessionId || "");
131
+ if (!sid) return null;
132
+ const existing = listEpisodeRecords(db, sid).find((e) => e.kind === "tail");
133
+ let text;
134
+ if (summary === undefined) {
135
+ text = existing && !isGenericEpisodeName(existing.summary) ? existing.summary : "";
136
+ } else {
137
+ text = String(summary || "").trim();
138
+ }
139
+ const id = upsertEpisode(db, {
140
+ kind: "tail",
141
+ sessionId: sid,
142
+ atSeq: Number.isFinite(Number(atSeq)) ? Number(atSeq) : existing?.atSeq || 0,
143
+ summary: text,
144
+ uri: tailUri(sid),
145
+ name: existing?.name || TAIL_SUMMARY,
146
+ });
147
+ linkChain(db, sid);
148
+ return id;
149
+ }
150
+
151
+ export function recordCheckpoint(db, { sessionId, atSeq, compactionId, summary } = {}) {
152
+ const sid = String(sessionId || "");
153
+ const seq = Number(atSeq);
154
+ if (!sid || !Number.isFinite(seq)) return null;
155
+ const text = String(summary || "").trim() || FALLBACK_SUMMARY;
156
+ const id = upsertEpisode(db, {
157
+ kind: "checkpoint",
158
+ sessionId: sid,
159
+ atSeq: seq,
160
+ compactionId: compactionId ? String(compactionId) : undefined,
161
+ summary: text,
162
+ uri: episodeUri(sid, seq),
163
+ });
164
+ ensureTail(db, sid, { atSeq: seq, summary: "" });
165
+ return id;
166
+ }
167
+
168
+ function isCheckpointPayload(payload) {
169
+ return payload?.type === "episode" && payload.kind === "checkpoint";
170
+ }
171
+
172
+ function parentCutId(db, parent, seq) {
173
+ if (seq != null) {
174
+ const existing = findByUri(db, episodeUri(parent, seq));
175
+ if (existing != null && isCheckpointPayload(db.get(existing)?.payload)) return existing;
176
+ }
177
+ const parentRows = listEpisodeRecords(db, parent);
178
+ if (seq != null) {
179
+ const hit = [...parentRows]
180
+ .reverse()
181
+ .find((e) => e.kind === "checkpoint" && (e.atSeq || 0) <= seq);
182
+ if (hit) return hit.id;
183
+ }
184
+ const lastCheck = [...parentRows].reverse().find((e) => e.kind === "checkpoint");
185
+ return lastCheck?.id ?? parentRows.find((e) => e.kind === "tail")?.id ?? null;
186
+ }
187
+
188
+ /**
189
+ * Project a native or canvas fork onto the graph.
190
+ * Branch from an existing checkpoint or the parent's tail — never insert a dummy cut box.
191
+ */
192
+ export function syncForkLineage(db, { childSessionId, parentSessionId, atSeq } = {}) {
193
+ const child = String(childSessionId || "");
194
+ const parent = String(parentSessionId || "");
195
+ if (!child || !parent) return null;
196
+ const seq = Number.isFinite(Number(atSeq)) ? Number(atSeq) : null;
197
+ const cutId = parentCutId(db, parent, seq);
198
+ const childTail = ensureTail(db, child, { atSeq: seq ?? 0 });
199
+ if (cutId != null && childTail != null) {
200
+ ensureLink(db, childTail, cutId, EDGE_LABELS.forksFrom);
201
+ }
202
+ linkChain(db, parent);
203
+ return { cutId, childTail };
204
+ }
205
+
206
+ export function sessionMapSnapshot(db, sessionId) {
207
+ const sid = String(sessionId || "");
208
+ const all = listEpisodeRecords(db);
209
+ const mineIds = new Set(all.filter((n) => n.sessionId === sid).map((n) => n.id));
210
+ const edges = [];
211
+ for (const n of all) {
212
+ const node = db.get(n.id);
213
+ for (const e of node?.edges || []) {
214
+ const label = e.label || "";
215
+ if (label !== EDGE_LABELS.continues && label !== EDGE_LABELS.forksFrom) continue;
216
+ const to = Number(e.targetId ?? e.target_id);
217
+ if (!Number.isFinite(to)) continue;
218
+ edges.push({ from: n.id, to, label });
219
+ }
220
+ }
221
+ const related = new Set(mineIds);
222
+ let grew = true;
223
+ while (grew) {
224
+ grew = false;
225
+ for (const e of edges) {
226
+ if (related.has(e.from) && !related.has(e.to)) {
227
+ related.add(e.to);
228
+ grew = true;
229
+ }
230
+ if (related.has(e.to) && !related.has(e.from)) {
231
+ related.add(e.from);
232
+ grew = true;
233
+ }
234
+ }
235
+ }
236
+ const nodes = all
237
+ .filter((n) => related.has(n.id) || n.sessionId === sid)
238
+ .map((n) => ({
239
+ id: n.id,
240
+ sessionId: n.sessionId,
241
+ kind: n.kind,
242
+ atSeq: n.atSeq,
243
+ summary: n.summary,
244
+ compactionId: n.compactionId,
245
+ status: n.status,
246
+ createdAt: n.createdAt,
247
+ uri: n.uri,
248
+ name: n.name || "",
249
+ current: n.sessionId === sid && n.kind === "tail",
250
+ }));
251
+ return {
252
+ sessionId: sid,
253
+ nodes,
254
+ edges: edges.filter((e) => related.has(e.from) && related.has(e.to)),
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Hand-rename a box. Writes only `name`. Rejects non-episode ids.
260
+ * Callers must not pass text/summary into updateNode.
261
+ */
262
+ export function renameEpisode(db, id, name) {
263
+ const nid = Number(id);
264
+ if (!Number.isFinite(nid)) return { ok: false, status: 400, message: "bad id" };
265
+ const node = db.get(nid);
266
+ if (!node) return { ok: false, status: 404, message: "not found" };
267
+ const payload = node.payload || {};
268
+ if (payload.type !== "episode") {
269
+ return { ok: false, status: 400, message: "not an episode" };
270
+ }
271
+ const next = updateNode(db, nid, { name: String(name ?? "").trim() });
272
+ return {
273
+ ok: true,
274
+ id: nid,
275
+ name: next?.name || "",
276
+ summary: String(next?.summary || payload.summary || payload.text || ""),
277
+ };
278
+ }
package/lib/extract.js CHANGED
@@ -18,6 +18,11 @@ export const ONESHOT_CUES =
18
18
  export const CHITCHAT_CUES =
19
19
  /^(哈哈+|呵呵+|嗯+|哦+|好的|谢谢|天气|吃了吗|在吗)[\s!!.。??]*$/;
20
20
 
21
+ /** WorkBuddy-style per-batch write cap. Not a file-size limit. */
22
+ export const MAX_WRITE_CHARS = 3000;
23
+ export const MAX_LINE_CHARS = 240;
24
+ export const MAX_WRITE_ITEMS = 24;
25
+
21
26
  const NAME_RE =
22
27
  /`([^`]{2,64})`|\b([A-Z][a-z]+[A-Z][A-Za-z0-9]*)\b|\b([A-Z][A-Za-z0-9]*(?:[_-][A-Z][A-Za-z0-9]*)+)\b|\b([a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)+)\b|\b([A-Z]{3,}[A-Z0-9_-]{0,})\b/g;
23
28
 
@@ -170,6 +175,10 @@ function linkNameOf(span, turnText, uniqueTurn, { inSpanOnly = false } = {}) {
170
175
  );
171
176
  }
172
177
 
178
+ export function firstEntityName(text) {
179
+ return collectNames(text, 1)[0]?.name;
180
+ }
181
+
173
182
  function collectNames(text, min = 2) {
174
183
  const counts = new Map();
175
184
  const s = String(text || "");
@@ -298,10 +307,12 @@ export function ruleCandidates(turns) {
298
307
  return out;
299
308
  }
300
309
 
301
- export function filterCandidates(candidates) {
310
+ export function filterCandidates(candidates, { importFile = false } = {}) {
302
311
  const seen = new Set();
303
312
  const out = [];
304
- const caps = { entity: 8, preference: 8, decision: 6, experience: 4 };
313
+ const caps = importFile
314
+ ? { entity: 24, preference: 24, decision: 12, experience: 8 }
315
+ : { entity: 8, preference: 8, decision: 6, experience: 4 };
305
316
  const used = { entity: 0, preference: 0, decision: 0, experience: 0 };
306
317
  for (const raw of candidates || []) {
307
318
  if (!raw || !WRITE_TYPES.has(raw.type)) continue;
@@ -315,7 +326,7 @@ export function filterCandidates(candidates) {
315
326
  const blob = `${raw.text || ""} ${raw.quote || ""}`;
316
327
  if (CHITCHAT_CUES.test(String(raw.text || "").trim())) continue;
317
328
  if (ONESHOT_CUES.test(blob) && !PREFERENCE_CUES.test(blob)) continue;
318
- if (!PREFERENCE_CUES.test(blob)) continue;
329
+ if (!importFile && !PREFERENCE_CUES.test(blob)) continue;
319
330
  }
320
331
  if ((used[raw.type] || 0) >= (caps[raw.type] || 4)) continue;
321
332
  const key = `${raw.type}|${(raw.name || "").toLowerCase()}|${text.toLowerCase()}`;
@@ -325,7 +336,7 @@ export function filterCandidates(candidates) {
325
336
  out.push({
326
337
  type: raw.type,
327
338
  name: raw.name ? String(raw.name).slice(0, 80) : undefined,
328
- text: clip(text, 400),
339
+ text: clip(text, MAX_LINE_CHARS),
329
340
  linkName: raw.linkName ? String(raw.linkName).slice(0, 80) : undefined,
330
341
  linkLabel: raw.linkLabel || undefined,
331
342
  fail: raw.fail ? clip(raw.fail, 200) : undefined,
@@ -377,8 +388,29 @@ function resolveEntityId(db, name) {
377
388
  return findMergeTarget(db, { type: "entity", name, text: name });
378
389
  }
379
390
 
380
- export function applyCandidates(db, candidates, { sessionId } = {}) {
381
- const list = filterCandidates(candidates);
391
+ /** Drop the tail once the WorkBuddy-style char/item budget is spent. */
392
+ export function capWriteBatch(candidates, { maxChars = MAX_WRITE_CHARS, maxItems = MAX_WRITE_ITEMS } = {}) {
393
+ const kept = [];
394
+ let used = 0;
395
+ for (const cand of candidates || []) {
396
+ if (kept.length >= maxItems) break;
397
+ const n = String(cand.text || cand.name || "").length;
398
+ if (used + n > maxChars && kept.length) break;
399
+ if (n > maxChars && !kept.length) {
400
+ kept.push({ ...cand, text: clip(cand.text || cand.name || "", MAX_LINE_CHARS) });
401
+ used += Math.min(n, MAX_LINE_CHARS);
402
+ break;
403
+ }
404
+ kept.push(cand);
405
+ used += n;
406
+ }
407
+ return { kept, used, dropped: Math.max(0, (candidates || []).length - kept.length) };
408
+ }
409
+
410
+ export function applyCandidates(db, candidates, { sessionId, via } = {}) {
411
+ const { kept: list } = capWriteBatch(
412
+ filterCandidates(candidates, { importFile: via === "workbuddy-import" }),
413
+ );
382
414
  const ordered = [
383
415
  ...list.filter((c) => c.type === "entity"),
384
416
  ...list.filter((c) => c.type !== "entity"),
@@ -398,7 +430,7 @@ export function applyCandidates(db, candidates, { sessionId } = {}) {
398
430
  source: {
399
431
  sessionId: sessionId ? String(sessionId) : "",
400
432
  quote: cand.quote,
401
- via: "extract",
433
+ via: via || cand.via || "extract",
402
434
  },
403
435
  };
404
436
  const untilAt = cand.until ? resolveUntilAt(cand.until) : undefined;
package/lib/index.js CHANGED
@@ -22,6 +22,8 @@ import {
22
22
  import { readUiSettings, registerMemoryApi } from "./server.js";
23
23
  import { applyRecallMode, recallModeOf } from "./settings.js";
24
24
  import { configureEmbedding } from "./embed.js";
25
+ import { recordCheckpoint, ensureTail, syncForkLineage } from "./episode.js";
26
+ import { buildPinInject } from "./pins.js";
25
27
 
26
28
  export const name = "dsh-trivium";
27
29
  export const inject = ["tools"];
@@ -206,6 +208,15 @@ function onSessionEvent(session, event) {
206
208
  if (buf.dirty && buf.cwd && buf.turns.length) {
207
209
  savePending(buf.cwd, { sessionId: buf.sessionId, turns: buf.turns });
208
210
  }
211
+ buf.lastTurnEndSeq = event.seq;
212
+ break;
213
+ }
214
+ case "compaction/summary": {
215
+ buf.compactionSummary = {
216
+ id: event.data?.compactionId,
217
+ text: textFromContent(event.data?.summary),
218
+ end: event.data?.shadowedRange?.end,
219
+ };
209
220
  break;
210
221
  }
211
222
  default:
@@ -245,6 +256,25 @@ export function apply(ctx, config = {}) {
245
256
  },
246
257
  }),
247
258
  );
259
+ try {
260
+ const session = agent.session;
261
+ const sessionId = sessionKey(session);
262
+ if (sessionId) {
263
+ const seedLen = session?.header?.seedLength;
264
+ ensureTail(db, sessionId, {
265
+ atSeq: Number.isFinite(Number(seedLen)) && seedLen > 0 ? seedLen - 1 : 0,
266
+ });
267
+ if (session?.header?.parentSession) {
268
+ syncForkLineage(db, {
269
+ childSessionId: sessionId,
270
+ parentSessionId: session.header.parentSession,
271
+ atSeq: Number.isFinite(Number(seedLen)) && seedLen > 0 ? seedLen - 1 : undefined,
272
+ });
273
+ }
274
+ }
275
+ } catch (err) {
276
+ log.warn?.(`[dsh-trivium] episode tail skipped: ${err.message}`);
277
+ }
248
278
  const pending = loadPending(cwd);
249
279
  if (pending?.turns?.length && optionsOf().extractEnabled !== false) {
250
280
  runExtract(runtime, {
@@ -278,9 +308,45 @@ export function apply(ctx, config = {}) {
278
308
  options: optionsOf(),
279
309
  reason: "compaction/end",
280
310
  }).catch((err) => log.warn?.(`[dsh-trivium] compaction extract: ${err.message}`));
311
+ const cwd = buf.cwd || session.header?.cwd;
312
+ const sessionId = buf.sessionId || session.id;
313
+ if (cwd && sessionId) {
314
+ const summary =
315
+ buf.compactionSummary?.id === event.data?.compactionId && buf.compactionSummary.text
316
+ ? buf.compactionSummary.text
317
+ : "已压缩";
318
+ const atSeq = Number.isFinite(Number(event.seq))
319
+ ? Number(event.seq)
320
+ : Number.isFinite(Number(buf.compactionSummary?.end))
321
+ ? Number(buf.compactionSummary.end)
322
+ : Number(buf.lastTurnEndSeq) || 0;
323
+ openWorkspaceDb(cwd)
324
+ .then((db) => {
325
+ recordCheckpoint(db, {
326
+ sessionId,
327
+ atSeq,
328
+ compactionId: event.data?.compactionId,
329
+ summary,
330
+ });
331
+ })
332
+ .catch((err) => log.warn?.(`[dsh-trivium] episode checkpoint skipped: ${err.message}`));
333
+ }
281
334
  }
282
335
  if (event.type === "turn/end") {
283
336
  const id = sessionKey(session);
337
+ const bufEnd = bufOf(session);
338
+ const lastUser = [...(bufEnd.turns || [])].reverse().find((t) => t.role === "user");
339
+ const preview = String(lastUser?.text || "").trim();
340
+ if (bufEnd.cwd && bufEnd.sessionId && preview && !preview.startsWith("/")) {
341
+ openWorkspaceDb(bufEnd.cwd)
342
+ .then((db) => {
343
+ ensureTail(db, bufEnd.sessionId, {
344
+ atSeq: Number(event.seq) || bufEnd.lastTurnEndSeq || 0,
345
+ summary: preview.slice(0, 240),
346
+ });
347
+ })
348
+ .catch((err) => log.warn?.(`[dsh-trivium] episode tail preview skipped: ${err.message}`));
349
+ }
284
350
  clearIdle(id);
285
351
  const timer = setTimeout(() => {
286
352
  idleTimers.delete(id);
@@ -310,50 +376,72 @@ export function apply(ctx, config = {}) {
310
376
  ctx.on("agent/pre-step", async ({ agent, messages }, next) => {
311
377
  const decision = await next();
312
378
  try {
313
- const options = optionsOf();
314
- const mode = recallModeOf(options);
315
- if (mode === "off") return decision;
316
379
  if (!decision || decision.kind !== "enter") return decision;
317
- const proposed = [...(decision.messages || []), ...(messages || [])];
318
- const lastUser = [...proposed].reverse().find((m) => m?.source?.kind === "user");
319
- if (!lastUser) return decision;
320
- const query = textFromContent(lastUser.content).trim();
321
- if (!query) return decision;
380
+ const extra = [];
322
381
  const db = await openWorkspaceDb(cwdOf(agent));
323
- let hits;
324
- if (mode === "anchor") {
325
- hits = namedRecallHits(db, query, { topK: 8 }).slice(0, 8);
326
- } else {
327
- const queryVector = await embedQueryVector(query);
328
- hits = searchNodes(db, query, {
329
- topK: 3,
330
- expandDepth: 1,
331
- queryVector: queryVector || undefined,
332
- }).slice(0, 3);
382
+ const sessionId = sessionKey(agent.session);
383
+ try {
384
+ const pin = buildPinInject(db, sessionId, 300);
385
+ if (pin.text) {
386
+ extra.push(
387
+ createUserMessage({
388
+ content: [{ type: "text", text: pin.text }],
389
+ source: {
390
+ kind: "plugin",
391
+ plugin: "dsh-trivium",
392
+ form: "pins",
393
+ },
394
+ }),
395
+ );
396
+ }
397
+ } catch (err) {
398
+ log.warn?.(`[dsh-trivium] pre-step pins skipped: ${err.message}`);
333
399
  }
334
- if (!hits.length) return decision;
335
- const lines = hits.map((hit) => {
336
- const row = formatHit(db, hit);
337
- return `- [${row.type}#${row.id}] ${row.l0} (${row.path.join(" | ") || "no path"})`;
338
- });
339
- const form = mode === "anchor" ? "anchorRecall" : "autoRecall";
340
- const text = clipToTokenBudget(
341
- `dsh-trivium ${form} (L0 only; use ctx_read for full text):\n${lines.join("\n")}`,
342
- 300,
343
- );
400
+ const options = optionsOf();
401
+ const mode = recallModeOf(options);
402
+ if (mode !== "off") {
403
+ const proposed = [...(decision.messages || []), ...(messages || [])];
404
+ const lastUser = [...proposed].reverse().find((m) => m?.source?.kind === "user");
405
+ const query = lastUser ? textFromContent(lastUser.content).trim() : "";
406
+ if (query) {
407
+ let hits;
408
+ if (mode === "anchor") {
409
+ hits = namedRecallHits(db, query, { topK: 8 }).slice(0, 8);
410
+ } else {
411
+ const queryVector = await embedQueryVector(query);
412
+ hits = searchNodes(db, query, {
413
+ topK: 3,
414
+ expandDepth: 1,
415
+ queryVector: queryVector || undefined,
416
+ }).slice(0, 3);
417
+ }
418
+ if (hits.length) {
419
+ const lines = hits.map((hit) => {
420
+ const row = formatHit(db, hit);
421
+ return `- [${row.type}#${row.id}] ${row.l0} (${row.path.join(" | ") || "no path"})`;
422
+ });
423
+ const form = mode === "anchor" ? "anchorRecall" : "autoRecall";
424
+ const text = clipToTokenBudget(
425
+ `dsh-trivium ${form} (L0 only; use ctx_read for full text):\n${lines.join("\n")}`,
426
+ 300,
427
+ );
428
+ extra.push(
429
+ createUserMessage({
430
+ content: [{ type: "text", text }],
431
+ source: {
432
+ kind: "plugin",
433
+ plugin: "dsh-trivium",
434
+ form: "recall",
435
+ },
436
+ }),
437
+ );
438
+ }
439
+ }
440
+ }
441
+ if (!extra.length) return decision;
344
442
  return {
345
443
  kind: "enter",
346
- messages: [
347
- createUserMessage({
348
- content: [{ type: "text", text }],
349
- source: {
350
- kind: "plugin",
351
- plugin: "dsh-trivium",
352
- form: "recall",
353
- },
354
- }),
355
- ...decision.messages,
356
- ],
444
+ messages: [...extra, ...decision.messages],
357
445
  };
358
446
  } catch (err) {
359
447
  log.warn?.(`[dsh-trivium] pre-step recall skipped: ${err.message}`);