dsh-trivium 0.4.21 → 0.5.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.
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Unified per-turn context budget.
3
+ *
4
+ * Chips (pins) and recall used to clip to 300 tokens each, so one turn could
5
+ * carry 600 and the same node could appear twice. This module owns a single
6
+ * decision: which structured records go into this turn, in what order, and
7
+ * what the exact text will be. It never re-parses a clipped string.
8
+ *
9
+ * Only `estimateTokens` is used for accounting — it is a CJK/ASCII heuristic,
10
+ * not a model tokenizer, so every surface must label its numbers as estimates.
11
+ */
12
+
13
+ import { estimateTokens, clipToTokenBudget } from "./tokens.js";
14
+
15
+ /** Session map: once per session, labels only. */
16
+ export const MAP_TOKEN_BUDGET = 400;
17
+
18
+ /** One user turn: pins + corrections + recall share this, headers included. */
19
+ export const TURN_ADDITIONAL_BUDGET = 300;
20
+
21
+ /** Why a candidate did not make it. Kept short so it can be shown as a tag. */
22
+ export const DROP_REASONS = Object.freeze({
23
+ invalid: "invalid",
24
+ archived: "archived",
25
+ dirty: "dirty",
26
+ stale: "stale",
27
+ empty: "empty",
28
+ duplicateNode: "duplicate-node",
29
+ duplicateTurn: "duplicate-in-turn",
30
+ alreadyInMap: "already-in-map",
31
+ noCurrent: "no-current",
32
+ overBudget: "over-budget",
33
+ });
34
+
35
+ const PIN_HEADER = "dsh-trivium pins (L0 only; use ctx_read for full text):";
36
+ const RECALL_HEADER = (form) =>
37
+ `dsh-trivium ${form || "recall"} (L0 only; use ctx_read for full text):`;
38
+
39
+ const MIN_MAP_MATCH_CHARS = 12;
40
+
41
+ let turnSeq = 0;
42
+ const objectTurnKeys = new WeakMap();
43
+
44
+ function usableTurnValue(value) {
45
+ if (value === undefined || value === null) return "";
46
+ const raw = String(value).trim();
47
+ return raw ? raw : "";
48
+ }
49
+
50
+ /**
51
+ * Stable identity for one user turn.
52
+ *
53
+ * Host ids only — never `Date.now()`, the query text, or a pre-step counter.
54
+ * A tool loop calls pre-step several times for one user message; that reuses
55
+ * the same message object, so the identity stays put. Two messages with
56
+ * identical text but different host ids are two turns.
57
+ *
58
+ * @param messages - messages that make up the upcoming turn.
59
+ * @returns `uid:<host id>`, `obj:<n>` for hosts that expose no id, or "".
60
+ */
61
+ export function turnKeyOf(messages = []) {
62
+ const users = (messages || []).filter((m) => m?.source?.kind === "user");
63
+ const last = users[users.length - 1];
64
+ if (!last) return "";
65
+ for (const candidate of [
66
+ last.id,
67
+ last.messageId,
68
+ last.seq,
69
+ last.source?.messageId,
70
+ last.source?.id,
71
+ last.source?.seq,
72
+ last.source?.turn,
73
+ last.turn,
74
+ ]) {
75
+ const value = usableTurnValue(candidate);
76
+ if (value) return `uid:${value}`;
77
+ }
78
+ let token = objectTurnKeys.get(last);
79
+ if (!token) {
80
+ token = `obj:${++turnSeq}`;
81
+ objectTurnKeys.set(last, token);
82
+ }
83
+ return token;
84
+ }
85
+
86
+ function normaliseText(value, max = 200) {
87
+ return String(value ?? "")
88
+ .replace(/\s+/g, " ")
89
+ .trim()
90
+ .slice(0, max);
91
+ }
92
+
93
+ function normalisePath(path) {
94
+ if (!Array.isArray(path)) return [];
95
+ return path.map((p) => normaliseText(p, 60)).filter(Boolean).slice(0, 2);
96
+ }
97
+
98
+ /**
99
+ * One structured candidate. Producers (pins.js, store.js) already know the
100
+ * payload; they must hand over fields, not a rendered string.
101
+ */
102
+ function shapeCandidate(raw, channel) {
103
+ if (!raw) return null;
104
+ const id = Number(raw.id);
105
+ if (!Number.isFinite(id)) return null;
106
+ return {
107
+ id,
108
+ channel,
109
+ type: normaliseText(raw.type, 24) || "unknown",
110
+ l0: normaliseText(raw.l0 ?? raw.text ?? raw.name, 200),
111
+ until: normaliseText(raw.until, 32),
112
+ stale: raw.stale === true,
113
+ archived: raw.archived === true || raw.status === "archived",
114
+ dirty: raw.dirty === true,
115
+ path: normalisePath(raw.path),
116
+ historical: raw.historical === true,
117
+ supersededBy: Number.isFinite(Number(raw.supersededBy)) ? Number(raw.supersededBy) : null,
118
+ matchedHistory: raw.matchedHistory || null,
119
+ note: normaliseText(raw.note, 120),
120
+ fingerprint: "",
121
+ };
122
+ }
123
+
124
+ function fingerprintOf(id, l0) {
125
+ // Cheap stable content fingerprint: node id plus its normalised L0. Enough to
126
+ // tell "same record delivered twice" from "record changed within the turn".
127
+ let hash = 5381;
128
+ const s = String(l0 || "");
129
+ for (let i = 0; i < s.length; i += 1) hash = ((hash * 33) ^ s.charCodeAt(i)) >>> 0;
130
+ return `${id}:${hash.toString(36)}`;
131
+ }
132
+
133
+ function lineFor(record) {
134
+ const flags = [];
135
+ if (record.stale) flags.push(record.until ? `stale until=${record.until}` : "stale");
136
+ if (record.historical) flags.push(record.supersededBy ? `superseded->#${record.supersededBy}` : "superseded");
137
+ if (record.matchedHistory) {
138
+ flags.push(`matched_history=#${record.matchedHistory.from} → current=#${record.matchedHistory.current}`);
139
+ }
140
+ if (record.note) flags.push(record.note);
141
+ const suffix = flags.length ? ` ${flags.join(" ")}` : "";
142
+ const body = record.channel === "pin" ? record.l0 : withPath(record);
143
+ return `- [${record.type}#${record.id}${suffix}] ${body}`;
144
+ }
145
+
146
+ function withPath(record) {
147
+ if (!record.path.length) return record.l0;
148
+ return `${record.l0} (${record.path.join(" | ")})`;
149
+ }
150
+
151
+ function blockTextOf(records, channel, form) {
152
+ if (!records.length) return "";
153
+ const header = channel === "pin" ? PIN_HEADER : RECALL_HEADER(form);
154
+ return `${header}\n${records.map((r) => r.line).join("\n")}`;
155
+ }
156
+
157
+ /**
158
+ * Render the current selection. Pins and recall stay separate messages (the
159
+ * client labels them differently) but they are measured together against the
160
+ * one shared budget.
161
+ */
162
+ export function renderContextRecords(plan, { form = "recall" } = {}) {
163
+ const pinRecords = plan.records.filter((r) => r.channel === "pin");
164
+ const recallRecords = plan.records.filter((r) => r.channel !== "pin");
165
+ const pinText = blockTextOf(pinRecords, "pin", form);
166
+ const recallText = blockTextOf(recallRecords, "recall", form);
167
+ const pinTokens = pinText ? estimateTokens(pinText) : 0;
168
+ const recallTokens = recallText ? estimateTokens(recallText) : 0;
169
+ return {
170
+ pins: { text: pinText, tokens: pinTokens },
171
+ recall: { text: recallText, tokens: recallTokens },
172
+ total: pinTokens + recallTokens,
173
+ estimatedTokens: pinTokens + recallTokens,
174
+ };
175
+ }
176
+
177
+ function measure(records, form) {
178
+ return renderContextRecords({ records }, { form }).total;
179
+ }
180
+
181
+ /**
182
+ * Turn one round of candidates into the exact records to inject.
183
+ *
184
+ * Order is pins → corrections → recall. Pins win the budget because the user
185
+ * chose them by hand; recall only gets what is left. A record that does not
186
+ * fit is dropped with `over-budget` instead of being clipped mid-record.
187
+ *
188
+ * @param pins - structured pin candidates, user order.
189
+ * @param recall - structured recall candidates, ranked.
190
+ * @param corrections - short staleness/supersession notices for this turn.
191
+ * @param resolveCurrent - optional `(id) => ({id, payload, archived, stale})`;
192
+ * M2 plugs the supersedes chain in here. Without it nothing is remapped.
193
+ * @param delivered - fingerprints already delivered in this turn.
194
+ * @param mapText - the short map text when it arrived in this same turn.
195
+ */
196
+ export function selectContextRecords({
197
+ pins = [],
198
+ recall = [],
199
+ corrections = [],
200
+ budget = TURN_ADDITIONAL_BUDGET,
201
+ form = "recall",
202
+ now,
203
+ resolveCurrent = null,
204
+ delivered = null,
205
+ mapText = "",
206
+ } = {}) {
207
+ const cap = Math.max(1, Number(budget) || TURN_ADDITIONAL_BUDGET);
208
+ const at = now instanceof Date ? now : new Date();
209
+ const deliveredSet = delivered instanceof Set ? delivered : new Set();
210
+ const mapBlob = String(mapText || "");
211
+ const dropped = [];
212
+ const deferred = [];
213
+ const byId = new Map();
214
+ const ordered = [];
215
+
216
+ const drop = (candidate, reason, channel) =>
217
+ dropped.push({ id: bestId(candidate), channel, reason });
218
+
219
+ const accept = (raw, channel) => {
220
+ const record = shapeCandidate(raw, channel);
221
+ if (!record) {
222
+ drop(raw, DROP_REASONS.invalid, channel);
223
+ return;
224
+ }
225
+ if (!record.l0) {
226
+ drop(record, DROP_REASONS.empty, channel);
227
+ return;
228
+ }
229
+ if (record.archived) {
230
+ drop(record, DROP_REASONS.archived, channel);
231
+ return;
232
+ }
233
+ if (record.dirty) {
234
+ drop(record, DROP_REASONS.dirty, channel);
235
+ return;
236
+ }
237
+
238
+ let targetId = record.id;
239
+ if (record.channel !== "pin" && typeof resolveCurrent === "function") {
240
+ let resolved = null;
241
+ try {
242
+ resolved = resolveCurrent(record.id);
243
+ } catch {
244
+ resolved = null;
245
+ }
246
+ if (resolved === null || resolved === undefined) {
247
+ drop(record, DROP_REASONS.noCurrent, channel);
248
+ return;
249
+ }
250
+ const nextId = Number(resolved.currentId ?? resolved.id ?? record.id);
251
+ if (!Number.isFinite(nextId)) {
252
+ drop(record, DROP_REASONS.noCurrent, channel);
253
+ return;
254
+ }
255
+ // The successor is gone, archived or expired: do not resurrect the old
256
+ // node, and do not inject the dead chain either.
257
+ if (nextId !== record.id) {
258
+ if (resolved.archived === true || resolved.stale === true) {
259
+ drop(record, resolved.archived === true ? DROP_REASONS.archived : DROP_REASONS.stale, channel);
260
+ return;
261
+ }
262
+ record.matchedHistory = { from: record.id, current: nextId };
263
+ targetId = nextId;
264
+ record.id = nextId;
265
+ }
266
+ } else if (record.channel !== "pin" && record.stale) {
267
+ // Recall never injects expired nodes; pinned chips may be stale (the user
268
+ // chose them) and carry a `stale until=` flag instead.
269
+ drop(record, DROP_REASONS.stale, channel);
270
+ return;
271
+ }
272
+
273
+ const existing = byId.get(targetId);
274
+ if (existing) {
275
+ if (!existing.channels.includes(record.channel)) existing.channels.push(record.channel);
276
+ // A node already present keeps its first (higher-priority) rendering.
277
+ drop(record, DROP_REASONS.duplicateNode, channel);
278
+ return;
279
+ }
280
+
281
+ record.channel = record.channel === "pin" ? "pin" : channel;
282
+ record.channels = [record.channel];
283
+ record.fingerprint = fingerprintOf(record.id, record.l0);
284
+ record.line = lineFor(record);
285
+ if (deliveredSet.has(record.fingerprint)) {
286
+ drop(record, DROP_REASONS.duplicateTurn, record.channel);
287
+ return;
288
+ }
289
+ if (mapBlob && mapCoversRecord(mapBlob, record.l0)) {
290
+ drop(record, DROP_REASONS.alreadyInMap, record.channel);
291
+ return;
292
+ }
293
+ byId.set(targetId, record);
294
+ ordered.push(record);
295
+ };
296
+
297
+ for (const pin of pins) accept(pin, "pin");
298
+ for (const correction of corrections) accept(correction, "recall");
299
+ for (const hit of recall) accept(hit, "recall");
300
+
301
+ // Greedy fill in priority order: a later, shorter record may still fit after
302
+ // a long one is rejected, so keep trying instead of stopping at the first miss.
303
+ const kept = [];
304
+ for (const record of ordered) {
305
+ const attempt = [...kept, record];
306
+ if (measure(attempt, form) <= cap) kept.push(record);
307
+ else if (record.channel === "pin") {
308
+ // An explicit pin that cannot fit is deferred, not silently mixed in.
309
+ drop(record, DROP_REASONS.overBudget, record.channel);
310
+ deferred.push({ id: record.id, channel: record.channel, reason: DROP_REASONS.overBudget });
311
+ } else {
312
+ drop(record, DROP_REASONS.overBudget, record.channel);
313
+ deferred.push({ id: record.id, channel: record.channel, reason: DROP_REASONS.overBudget });
314
+ }
315
+ }
316
+
317
+ const rendered = renderContextRecords({ records: kept }, { form });
318
+ return {
319
+ budget: cap,
320
+ tokens: rendered.total,
321
+ estimatedTokens: rendered.total,
322
+ records: kept,
323
+ dropped,
324
+ deferred,
325
+ at: at.toISOString(),
326
+ };
327
+ }
328
+
329
+ function bestId(candidate) {
330
+ const id = Number(candidate?.id);
331
+ return Number.isFinite(id) ? id : null;
332
+ }
333
+
334
+ /**
335
+ * Cross-stage duplicate: the map is labels only, so an identical long body is
336
+ * the same fact and must not be repeated. A short label match is not "full
337
+ * text already in context" and still gets its summary.
338
+ */
339
+ function mapCoversRecord(mapText, l0) {
340
+ const body = normaliseText(l0, 400);
341
+ if (body.length < MIN_MAP_MATCH_CHARS) return false;
342
+ return mapText.includes(body);
343
+ }
344
+
345
+ /** Clip one already-selected block; used by the debug/UI surface, not injection. */
346
+ export function clipBlock(text, budget = TURN_ADDITIONAL_BUDGET) {
347
+ return clipToTokenBudget(text, budget);
348
+ }
package/lib/extract.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  mergeInto,
10
10
  ensureLink,
11
11
  } from "./store.js";
12
- import { parseUntilFromText, resolveUntilAt } from "./until.js";
12
+ import { resolveUntilAt } from "./until.js";
13
13
 
14
14
  export const PREFERENCE_CUES =
15
15
  /记住|以后都|别再|不要再|从现在起|always do|from now on|never again|remember (?:this|that|to)\b/i;
@@ -114,10 +114,6 @@ export function textFromContent(content) {
114
114
  return parts.join("\n");
115
115
  }
116
116
 
117
- function untilOf(text) {
118
- return parseUntilFromText(text);
119
- }
120
-
121
117
  function isToolDriveTurn(text) {
122
118
  return TOOL_DRIVE_CUES.test(String(text || ""));
123
119
  }
@@ -256,7 +252,6 @@ export function ruleCandidates(turns) {
256
252
  type: "decision",
257
253
  name: object,
258
254
  text: clip(span, 240),
259
- until: untilOf(span),
260
255
  linkName: object,
261
256
  linkLabel: EDGE_LABELS.decided,
262
257
  quote: clip(span, 120),
@@ -377,9 +372,10 @@ export function buildExtractPrompt(turns) {
377
372
  const transcript = clip(turnsText(turns), 2400);
378
373
  return [
379
374
  "Extract durable memory candidates as JSON only.",
380
- 'Shape: {"candidates":[{"type":"preference|entity|decision|experience","name":"","text":"","linkName":"","linkLabel":"about|decided|broke|fixed","fail":"","fix":"","until":"","quote":""}]}',
375
+ 'Shape: {"candidates":[{"type":"preference|entity|decision|experience","name":"","text":"","linkName":"","linkLabel":"about|decided|broke|fixed","fail":"","fix":"","quote":""}]}',
381
376
  "Whitelist: 记住/以后都/别再 → preference; repeated proper names → entity; decision with an object (先别动/下周再/采用方案) → decision; tool fail then success in the same turn → experience.",
382
377
  "Do not write chitchat or one-off file edits as preference. Do not store secrets, tool dumps, or whole conversations.",
378
+ "Keep dates in the fact text. Do not infer expiry or emit until/untilAt; expiry is set explicitly through the memory tools or settings.",
383
379
  "If nothing matches, return {\"candidates\":[]}.",
384
380
  "",
385
381
  transcript,
@@ -410,8 +406,17 @@ export function capWriteBatch(candidates, { maxChars = MAX_WRITE_CHARS, maxItems
410
406
  return { kept, used, dropped: Math.max(0, (candidates || []).length - kept.length) };
411
407
  }
412
408
 
413
- export function applyCandidates(db, candidates, { sessionId, via } = {}) {
414
- const { kept: list } = capWriteBatch(
409
+ /** Legacy channel name → the stable provenance kind the UI reports. */
410
+ function sourceKindOfVia(via) {
411
+ const value = String(via || "");
412
+ if (value === "chip-add") return "chip";
413
+ if (value === "workbuddy-import" || value === "external-import") return "import";
414
+ if (value === "tool") return "tool";
415
+ if (value === "manual") return "manual";
416
+ return "extract";
417
+ }
418
+
419
+ export function applyCandidates(db, candidates, { sessionId, via } = {}) { const { kept: list } = capWriteBatch(
415
420
  filterCandidates(candidates, {
416
421
  importFile: via === "workbuddy-import" || via === "external-import",
417
422
  explicit: via === "chip-add",
@@ -434,9 +439,14 @@ export function applyCandidates(db, candidates, { sessionId, via } = {}) {
434
439
  fix: cand.fix,
435
440
  until: cand.until,
436
441
  source: {
442
+ // `via` is the legacy channel name; `kind` is the newer stable field the
443
+ // provenance UI reads. Only what this path actually knows is recorded —
444
+ // no message id, author or quote is invented.
445
+ kind: sourceKindOfVia(via || cand.via),
446
+ via: via || cand.via || "extract",
437
447
  sessionId: sessionId ? String(sessionId) : "",
438
448
  quote: cand.quote,
439
- via: via || cand.via || "extract",
449
+ capturedAt: nowIso(),
440
450
  },
441
451
  };
442
452
  const untilAt = cand.until ? resolveUntilAt(cand.until) : undefined;
@@ -481,7 +491,11 @@ export async function distill({ db, turns, sessionId, llmCall, log }) {
481
491
  if (typeof llmCall === "function") {
482
492
  try {
483
493
  const raw = await llmCall(buildExtractPrompt(usable));
484
- model = parseModelJson(raw);
494
+ // Automatic extraction cannot silently turn an event date into expiry,
495
+ // even when the model emits fields outside the requested schema.
496
+ model = parseModelJson(raw).map((candidate) => ({
497
+ ...candidate, until: undefined, untilAt: undefined,
498
+ }));
485
499
  } catch (err) {
486
500
  llmFailed = true;
487
501
  log?.warn?.(`[dsh-trivium] extract prompt failed: ${err.message}`);