dsh-trivium 0.3.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/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}`);
package/lib/pins.js ADDED
@@ -0,0 +1,155 @@
1
+ /** Per-session memory chips. Pins inject into the next turn of the current session. */
2
+
3
+ import { listEpisodeRecords } from "./episode.js";
4
+ import { clipToTokenBudget, estimateTokens } from "./tokens.js";
5
+ import { pinsOf, writePins } from "./settings.js";
6
+ import { suggestedNeighborIds } from "./store.js";
7
+
8
+ export const PIN_TOKEN_BUDGET = 300;
9
+ export const CHIP_TYPES = Object.freeze(["preference", "decision", "entity"]);
10
+
11
+ const CHIP_SET = new Set(CHIP_TYPES);
12
+
13
+ function l0Of(payload) {
14
+ return String(payload?.text || payload?.name || "")
15
+ .replace(/\s+/g, " ")
16
+ .trim()
17
+ .slice(0, 200);
18
+ }
19
+
20
+ function chipMatches(row, q) {
21
+ const needle = String(q || "").trim().toLowerCase();
22
+ if (!needle) return true;
23
+ return [row.name, row.text, row.type, row.l0].some((s) => String(s || "").toLowerCase().includes(needle));
24
+ }
25
+
26
+ function episodeSuggestText(db, sessionId) {
27
+ const rows = listEpisodeRecords(db, sessionId);
28
+ const tail = rows.find((e) => e.kind === "tail");
29
+ const lastCheck = [...rows].reverse().find((e) => e.kind === "checkpoint");
30
+ const tailText = String(tail?.summary || tail?.text || "").trim();
31
+ if (tailText && tailText !== "当前" && tailText !== "分叉") return tailText;
32
+ return String(lastCheck?.summary || lastCheck?.text || tailText || "").trim();
33
+ }
34
+
35
+ function suggestedChipIdSet(db, sessionId) {
36
+ const sid = String(sessionId || "");
37
+ if (!sid) return new Set();
38
+ const text = episodeSuggestText(db, sid);
39
+ if (!text) return new Set();
40
+ const ids = new Set();
41
+ for (const nid of suggestedNeighborIds(db, text)) {
42
+ const payload = db.get(Number(nid))?.payload || {};
43
+ if (payload.status === "archived") continue;
44
+ if (!CHIP_SET.has(payload.type)) continue;
45
+ ids.add(Number(nid));
46
+ }
47
+ return ids;
48
+ }
49
+
50
+ function parentSessionOf(db, sessionId) {
51
+ const rows = listEpisodeRecords(db, sessionId);
52
+ const tail = rows.find((e) => e.kind === "tail");
53
+ for (const e of tail?.edges || []) {
54
+ if (e.label !== "forks_from") continue;
55
+ const payload = db.get(Number(e.targetId ?? e.target_id))?.payload;
56
+ if (payload?.sessionId) return String(payload.sessionId);
57
+ }
58
+ return "";
59
+ }
60
+
61
+ export function listChips(db, { q, sessionId } = {}) {
62
+ const suggested = suggestedChipIdSet(db, sessionId);
63
+ const rows = [];
64
+ for (const id of db.allNodeIds?.() || []) {
65
+ const payload = db.get(id)?.payload || {};
66
+ if (payload.status === "archived") continue;
67
+ if (!CHIP_SET.has(payload.type)) continue;
68
+ const row = {
69
+ id: Number(id),
70
+ type: payload.type,
71
+ name: payload.name || "",
72
+ text: payload.text || "",
73
+ l0: l0Of(payload),
74
+ updatedAt: payload.updatedAt || payload.createdAt || "",
75
+ suggested: suggested.has(Number(id)),
76
+ };
77
+ if (!chipMatches(row, q)) continue;
78
+ rows.push(row);
79
+ }
80
+ return rows.sort((a, b) => {
81
+ if (a.suggested !== b.suggested) return a.suggested ? -1 : 1;
82
+ return String(b.updatedAt).localeCompare(String(a.updatedAt));
83
+ });
84
+ }
85
+
86
+ export function livePinIds(db, sessionId) {
87
+ const ids = pinsOf(sessionId);
88
+ return ids.filter((id) => {
89
+ const payload = db.get(Number(id))?.payload;
90
+ return payload && payload.status !== "archived" && CHIP_SET.has(payload.type);
91
+ });
92
+ }
93
+
94
+ export function prunePins(db, sessionId) {
95
+ const ids = pinsOf(sessionId);
96
+ const kept = livePinIds(db, sessionId);
97
+ if (kept.length !== ids.length) writePins(sessionId, kept);
98
+ return kept;
99
+ }
100
+
101
+ export function buildPinInject(db, sessionId, budget = PIN_TOKEN_BUDGET) {
102
+ const ids = prunePins(db, sessionId);
103
+ if (!ids.length) return { text: "", tokens: 0, clipped: false, ids: [] };
104
+ const lines = ["dsh-trivium pins (L0 only; use ctx_read for full text):"];
105
+ const used = [];
106
+ for (const id of ids) {
107
+ const payload = db.get(Number(id))?.payload;
108
+ if (!payload) continue;
109
+ lines.push(`- [${payload.type}#${id}] ${l0Of(payload)}`);
110
+ used.push(Number(id));
111
+ }
112
+ const full = lines.join("\n");
113
+ const cap = Math.max(1, Number(budget) || PIN_TOKEN_BUDGET);
114
+ const text = clipToTokenBudget(full, cap);
115
+ return {
116
+ text,
117
+ tokens: estimateTokens(text),
118
+ clipped: estimateTokens(full) > cap,
119
+ ids: used,
120
+ };
121
+ }
122
+
123
+ export function setSessionPins(db, sessionId, ids) {
124
+ writePins(sessionId, ids);
125
+ return buildPinInject(db, sessionId);
126
+ }
127
+
128
+ export function readSessionPins(db, sessionId) {
129
+ return buildPinInject(db, sessionId);
130
+ }
131
+
132
+ /** Copy live (unarchived chip-type) pin ids. Child stays empty unless this is called. */
133
+ export function copyPins(db, fromSessionId, toSessionId) {
134
+ const ids = livePinIds(db, fromSessionId);
135
+ writePins(toSessionId, ids);
136
+ return ids;
137
+ }
138
+
139
+ /**
140
+ * POST /pins body. inherit defaults false — `{ sessionId, ids }` still replaces.
141
+ * inherit true copies live ids from inheritFrom (or graph parent), then merges ids if given.
142
+ */
143
+ export function applyPinsUpdate(db, body = {}) {
144
+ const sessionId = String(body.sessionId || "");
145
+ if (body.inherit === true) {
146
+ const from = String(body.inheritFrom || body.parentSessionId || parentSessionOf(db, sessionId) || "");
147
+ const copied = from && from !== sessionId ? copyPins(db, from, sessionId) : livePinIds(db, sessionId);
148
+ if (Array.isArray(body.ids)) {
149
+ const merged = [...new Set([...copied, ...body.ids.map(Number).filter((id) => Number.isFinite(id))])];
150
+ return setSessionPins(db, sessionId, merged);
151
+ }
152
+ return readSessionPins(db, sessionId);
153
+ }
154
+ return setSessionPins(db, sessionId, Array.isArray(body.ids) ? body.ids : []);
155
+ }
package/lib/schema.js CHANGED
@@ -8,6 +8,7 @@ export const NODE_TYPES = Object.freeze([
8
8
  "preference",
9
9
  "decision",
10
10
  "experience",
11
+ "episode",
11
12
  ]);
12
13
 
13
14
  export const EDGE_LABELS = Object.freeze({
@@ -18,6 +19,8 @@ export const EDGE_LABELS = Object.freeze({
18
19
  fixed: "fixed",
19
20
  sameAs: "same_as",
20
21
  fromSession: "from_session",
22
+ continues: "continues",
23
+ forksFrom: "forks_from",
21
24
  });
22
25
 
23
26
  export const BUSINESS_EDGES = Object.freeze(["about", "decided", "broke", "fixed"]);
@@ -31,6 +34,8 @@ export const EDGE_WEIGHTS = Object.freeze({
31
34
  in_workspace: 0.15,
32
35
  same_as: 0.5,
33
36
  from_session: 0.1,
37
+ continues: 0.4,
38
+ forks_from: 0.6,
34
39
  });
35
40
 
36
41
  export function weightOf(label) {