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.
- package/README.md +49 -6
- package/README.zh-CN.md +49 -6
- package/lib/client.js +948 -9
- package/lib/context-budget.js +348 -0
- package/lib/extract.js +25 -11
- package/lib/index.js +236 -63
- package/lib/markdown.js +0 -2
- package/lib/pins.js +66 -21
- package/lib/provenance.js +147 -0
- package/lib/recall-preview.js +128 -0
- package/lib/recall-trace.js +285 -0
- package/lib/revision-graph.js +103 -0
- package/lib/revisions.js +451 -0
- package/lib/schema.js +13 -1
- package/lib/server.js +204 -4
- package/lib/sidecar.js +122 -15
- package/lib/store.js +480 -62
- package/lib/tool-defs.js +93 -0
- package/lib/tool-output.js +88 -0
- package/lib/tools.js +69 -43
- package/lib/turn-ledger.js +184 -0
- package/lib/until.js +1 -13
- package/package.json +10 -4
|
@@ -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 {
|
|
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":"","
|
|
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
|
-
|
|
414
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|