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/lib/index.js CHANGED
@@ -12,7 +12,6 @@ import {
12
12
  setActiveCwd,
13
13
  embedQueryVector,
14
14
  } from "./store.js";
15
- import { clipToTokenBudget } from "./tokens.js";
16
15
  import { clip } from "./schema.js";
17
16
  import {
18
17
  clearPending,
@@ -25,7 +24,29 @@ import { readUiSettings, registerMemoryApi } from "./server.js";
25
24
  import { applyRecallMode, chipsEnabledOf, recallModeOf, sessionLayerEnabledOf } from "./settings.js";
26
25
  import { configureEmbedding } from "./embed.js";
27
26
  import { recordCheckpoint, ensureTail, syncForkLineage } from "./episode.js";
28
- import { buildPinInject } from "./pins.js";
27
+ import { pinCandidates } from "./pins.js";
28
+ import { startTrace } from "./recall-trace.js";
29
+ import { makeRevisionResolver } from "./revisions.js";
30
+ import { noteObservedSession } from "./provenance.js";
31
+ import {
32
+ MAP_TOKEN_BUDGET,
33
+ renderContextRecords,
34
+ selectContextRecords,
35
+ TURN_ADDITIONAL_BUDGET,
36
+ turnKeyOf,
37
+ } from "./context-budget.js";
38
+ import {
39
+ beginTurn,
40
+ clearAfterCompaction,
41
+ clearSession,
42
+ commit,
43
+ deliveredOf,
44
+ mapTextOf,
45
+ pendingCorrections,
46
+ remainingOf,
47
+ reserve,
48
+ rollback,
49
+ } from "./turn-ledger.js";
29
50
  import { syncSidecarOnOpen } from "./sidecar.js";
30
51
 
31
52
  export const name = "dsh-trivium";
@@ -74,10 +95,22 @@ const DEFAULTS = {
74
95
  const buffers = new Map();
75
96
  const idleTimers = new Map();
76
97
  const mapInjected = new Set();
98
+ /**
99
+ * The map text stays in the host context until compaction replaces it, so keep
100
+ * it per session and use it for cross-stage de-duplication in later turns.
101
+ */
102
+ const mapTextBySession = new Map();
77
103
  const status = {
78
104
  lastInjectTokens: 0,
79
105
  lastInjectAt: null,
80
106
  lastExtract: null,
107
+ lastContextPlan: null,
108
+ /*
109
+ * Invocation probe for the pre-step hook: it separates "the host never called
110
+ * us" from "we threw before planning", which is otherwise invisible — the
111
+ * per-step warning only reaches the host log.
112
+ */
113
+ preStep: { count: 0, at: null, stage: "", error: "" },
81
114
  };
82
115
 
83
116
  function liveOptions(config) {
@@ -201,6 +234,9 @@ async function runExtract(ctx, { cwd, turns, sessionId, session, agent, log, opt
201
234
 
202
235
  function onSessionEvent(session, event) {
203
236
  const buf = bufOf(session);
237
+ // Provenance can only report a session as live if this process actually saw
238
+ // it; nothing here is inferred from the stored text.
239
+ noteObservedSession(sessionKey(session) || buf.sessionId);
204
240
  switch (event.type) {
205
241
  case "user/message": {
206
242
  const msg = event.data;
@@ -297,13 +333,100 @@ async function ensureShortMap(agent, options) {
297
333
  const cwd = cwdOf(agent);
298
334
  setActiveCwd(cwd);
299
335
  const db = await openWorkspaceDb(cwd);
300
- return { db, cwd, map: injectShortMap(agent, db, options.mapTokenBudget || 400) };
336
+ const map = injectShortMap(agent, db, options.mapTokenBudget || MAP_TOKEN_BUDGET);
337
+ mapTextBySession.set(id, map.text);
338
+ return { db, cwd, map };
301
339
  } catch (err) {
302
340
  mapInjected.delete(id);
303
341
  throw err;
304
342
  }
305
343
  }
306
344
 
345
+ function proposedQuery(decision, messages) {
346
+ const proposed = [...(decision?.messages || []), ...(messages || [])];
347
+ const lastUser = [...proposed].reverse().find((m) => m?.source?.kind === "user");
348
+ return lastUser ? textFromContent(lastUser.content).trim() : "";
349
+ }
350
+
351
+ /** Search hits carry a rendered row; selection wants fields. */
352
+ function structuredHit(db, hit) {
353
+ const row = formatHit(db, hit);
354
+ return {
355
+ id: row.id,
356
+ type: row.type,
357
+ l0: row.l0,
358
+ until: row.until,
359
+ stale: row.stale,
360
+ path: row.path,
361
+ };
362
+ }
363
+
364
+ async function recallCandidates(db, mode, query, onStage) {
365
+ if (!query) return { hits: [], semantic: "not_run" };
366
+ if (mode === "anchor") {
367
+ return {
368
+ hits: namedRecallHits(db, query, { topK: 8, onStage })
369
+ .slice(0, 8)
370
+ .map((hit) => structuredHit(db, hit)),
371
+ // Anchor mode never consults the semantic channel.
372
+ semantic: "not_run",
373
+ };
374
+ }
375
+ const queryVector = await embedQueryVector(query);
376
+ const hits = searchNodes(db, query, {
377
+ topK: 3,
378
+ expandDepth: 1,
379
+ includeStale: false,
380
+ queryVector: queryVector || undefined,
381
+ onStage,
382
+ })
383
+ .slice(0, 3)
384
+ .map((hit) => structuredHit(db, hit));
385
+ return { hits, semantic: queryVector ? "ran" : "not_run" };
386
+ }
387
+
388
+ /** Trace-friendly node summaries for a list of ids. */
389
+ function stageNodes(db, ids) {
390
+ return (ids || []).map((raw) => {
391
+ const id = Number(raw);
392
+ const payload = db.get(id)?.payload || {};
393
+ return { id, type: payload.type || "unknown", l0: String(payload.text || payload.name || "").slice(0, 80) };
394
+ });
395
+ }
396
+
397
+ /** Map a selection drop reason onto a trace filter reason. */
398
+ const FILTER_BY_DROP = {
399
+ "over-budget": "budget",
400
+ "duplicate-node": "duplicate",
401
+ "duplicate-in-turn": "duplicate",
402
+ "already-in-map": "duplicate",
403
+ stale: "stale",
404
+ archived: "archived",
405
+ dirty: "hygiene",
406
+ "no-current": "revision",
407
+ empty: "hygiene",
408
+ };
409
+
410
+ function planSummary(plan) {
411
+ return {
412
+ budget: plan.budget,
413
+ tokens: plan.tokens,
414
+ estimatedTokens: plan.estimatedTokens,
415
+ at: plan.at,
416
+ records: plan.records.map((r) => ({
417
+ id: r.id,
418
+ channel: r.channel,
419
+ channels: r.channels,
420
+ type: r.type,
421
+ tokens: r.tokens || 0,
422
+ historical: r.historical === true,
423
+ matchedHistory: r.matchedHistory || null,
424
+ })),
425
+ dropped: plan.dropped,
426
+ deferred: plan.deferred,
427
+ };
428
+ }
429
+
307
430
  export function apply(ctx, config = {}) {
308
431
  const log = ctx.logger?.("dsh-trivium") ?? console;
309
432
  const optionsOf = () => liveOptions(config);
@@ -384,6 +507,17 @@ export function apply(ctx, config = {}) {
384
507
  if (event.type === "compaction/end" && !event.data?.error) {
385
508
  clearIdle(sessionKey(session));
386
509
  const buf = bufOf(session);
510
+ /*
511
+ * The host replaced the old messages with a summary, so the map and the
512
+ * per-turn ledger describe content that is no longer there. Clear both
513
+ * and let the next pre-step rebuild what is still needed.
514
+ */
515
+ const compactedId = sessionKey(session) || buf.sessionId;
516
+ if (compactedId) {
517
+ clearAfterCompaction(compactedId);
518
+ mapInjected.delete(compactedId);
519
+ mapTextBySession.delete(compactedId);
520
+ }
387
521
  runExtract(runtime, {
388
522
  cwd: buf.cwd || session.header?.cwd,
389
523
  turns: buf.turns,
@@ -472,77 +606,112 @@ export function apply(ctx, config = {}) {
472
606
 
473
607
  ctx.on("agent/pre-step", async ({ agent, messages }, next) => {
474
608
  const decision = await next();
609
+ status.preStep.count += 1;
610
+ status.preStep.at = new Date().toISOString();
611
+ status.preStep.stage = "decision";
612
+ status.preStep.error = "";
475
613
  try {
476
- if (!decision || decision.kind !== "enter") return decision;
614
+ if (!decision || decision.kind !== "enter") {
615
+ status.preStep.stage = "not-enter";
616
+ return decision;
617
+ }
618
+ const options = optionsOf();
477
619
  try {
478
- await ensureShortMap(agent, optionsOf());
620
+ await ensureShortMap(agent, options);
479
621
  } catch (err) {
480
622
  log.warn?.(`[dsh-trivium] pre-step map skipped: ${err.message}`);
481
623
  }
482
- const extra = [];
483
- const db = await openWorkspaceDb(cwdOf(agent));
624
+ const chipsOn = chipsEnabledOf(options);
625
+ const mode = recallModeOf(options);
626
+ // Default stays quiet: both channels off means no pin/recall planning at all.
627
+ if (!chipsOn && mode === "off") {
628
+ status.preStep.stage = "channels-off";
629
+ return decision;
630
+ }
631
+
484
632
  const sessionId = sessionKey(agent.session);
485
- const options = optionsOf();
486
- if (chipsEnabledOf(options)) {
487
- try {
488
- const pin = buildPinInject(db, sessionId, 300);
489
- if (pin.text) {
490
- extra.push(
491
- createUserMessage({
492
- content: [{ type: "text", text: pin.text }],
493
- // No `form`: "pins" was never in DSH's ContextForm union, so the
494
- // client already rendered it as undeclared (opaque) context.
495
- // Omitting the field is the documented default for that.
496
- source: producerSource(),
497
- }),
498
- );
499
- }
500
- } catch (err) {
501
- log.warn?.(`[dsh-trivium] pre-step pins skipped: ${err.message}`);
633
+ const turnKey = turnKeyOf([...(decision.messages || []), ...(messages || [])]);
634
+ beginTurn(sessionId, turnKey);
635
+ // 300 is the cumulative allowance for one user turn, not per pre-step:
636
+ // a tool loop that re-enters must not start a fresh 300 each time.
637
+ const remaining = remainingOf(sessionId, turnKey, TURN_ADDITIONAL_BUDGET);
638
+ if (remaining <= 0) return decision;
639
+
640
+ const db = await openWorkspaceDb(cwdOf(agent));
641
+ const cwd = cwdOf(agent);
642
+ const pins = chipsOn ? pinCandidates(db, sessionId, { resolveRevision: makeRevisionResolver(db) }) : [];
643
+ const query = mode === "off" ? "" : proposedQuery(decision, messages);
644
+ const trace =
645
+ mode === "off"
646
+ ? null
647
+ : startTrace({ cwd, sessionId, query, mode, semantic: "not_run", source: "pre-step" });
648
+ const recall = mode === "off" ? { hits: [], semantic: "not_run" } : await recallCandidates(db, mode, query, (stage, ids) => trace?.stage(stage, stageNodes(db, ids)));
649
+ const form = mode === "anchor" ? "anchorRecall" : "autoRecall";
650
+ const plan = selectContextRecords({
651
+ pins,
652
+ recall: recall.hits,
653
+ corrections: pendingCorrections(sessionId),
654
+ budget: remaining,
655
+ form,
656
+ delivered: deliveredOf(sessionId, turnKey),
657
+ mapText: mapTextOf(sessionId, turnKey) || mapTextBySession.get(sessionId) || "",
658
+ resolveCurrent: makeRevisionResolver(db),
659
+ });
660
+ if (trace) {
661
+ // Only nodes that were actually candidates get a filter reason; a node
662
+ // that never entered the pipeline is not "filtered by a rule".
663
+ for (const [dropReason, filterReason] of Object.entries(FILTER_BY_DROP)) {
664
+ const ids = plan.dropped.filter((d) => d.reason === dropReason).map((d) => d.id);
665
+ if (ids.length) trace.filter(filterReason, stageNodes(db, ids));
502
666
  }
667
+ trace.selected({
668
+ ids: plan.records.map((r) => r.id),
669
+ budget: plan.budget,
670
+ tokens: plan.tokens,
671
+ deferred: plan.deferred,
672
+ });
673
+ status.lastTrace = trace.finish({ semantic: recall.semantic });
503
674
  }
504
- const mode = recallModeOf(options);
505
- if (mode !== "off") {
506
- const proposed = [...(decision.messages || []), ...(messages || [])];
507
- const lastUser = [...proposed].reverse().find((m) => m?.source?.kind === "user");
508
- const query = lastUser ? textFromContent(lastUser.content).trim() : "";
509
- if (query) {
510
- let hits;
511
- if (mode === "anchor") {
512
- hits = namedRecallHits(db, query, { topK: 8 }).slice(0, 8);
513
- } else {
514
- const queryVector = await embedQueryVector(query);
515
- hits = searchNodes(db, query, {
516
- topK: 3,
517
- expandDepth: 1,
518
- queryVector: queryVector || undefined,
519
- }).slice(0, 3);
520
- }
521
- if (hits.length) {
522
- const lines = hits.map((hit) => {
523
- const row = formatHit(db, hit);
524
- return `- [${row.type}#${row.id}] ${row.l0} (${row.path.join(" | ") || "no path"})`;
525
- });
526
- const form = mode === "anchor" ? "anchorRecall" : "autoRecall";
527
- const text = clipToTokenBudget(
528
- `dsh-trivium ${form} (L0 only; use ctx_read for full text):\n${lines.join("\n")}`,
529
- 300,
530
- );
531
- extra.push(
532
- createUserMessage({
533
- content: [{ type: "text", text }],
534
- source: producerSource({ form: "recall" }),
535
- }),
536
- );
537
- }
675
+ status.lastContextPlan = planSummary(plan);
676
+ status.preStep.stage = "planned";
677
+ if (!plan.records.length) return decision;
678
+
679
+ const rendered = renderContextRecords(plan, { form });
680
+ // Reserve first, assemble second, commit last: anything that throws while
681
+ // building the messages rolls the reservation back, so the next pre-step
682
+ // retries the same content instead of believing it was delivered.
683
+ const reservation = reserve(sessionId, turnKey, plan.records);
684
+ try {
685
+ const extra = [];
686
+ if (rendered.pins.text) {
687
+ extra.push(
688
+ createUserMessage({
689
+ content: [{ type: "text", text: rendered.pins.text }],
690
+ // No `form`: "pins" was never in DSH's ContextForm union, so the
691
+ // client already rendered it as undeclared (opaque) context.
692
+ // Omitting the field is the documented default for that.
693
+ source: producerSource(),
694
+ }),
695
+ );
696
+ }
697
+ if (rendered.recall.text) {
698
+ extra.push(
699
+ createUserMessage({
700
+ content: [{ type: "text", text: rendered.recall.text }],
701
+ source: producerSource({ form: "recall" }),
702
+ }),
703
+ );
538
704
  }
705
+ const nextDecision = { kind: "enter", messages: [...extra, ...decision.messages] };
706
+ commit(reservation);
707
+ return nextDecision;
708
+ } catch (err) {
709
+ rollback(reservation);
710
+ throw err;
539
711
  }
540
- if (!extra.length) return decision;
541
- return {
542
- kind: "enter",
543
- messages: [...extra, ...decision.messages],
544
- };
545
712
  } catch (err) {
713
+ status.preStep.stage = "error";
714
+ status.preStep.error = String(err?.message || err);
546
715
  log.warn?.(`[dsh-trivium] pre-step recall skipped: ${err.message}`);
547
716
  return decision;
548
717
  }
@@ -557,6 +726,10 @@ export function apply(ctx, config = {}) {
557
726
  }
558
727
  buffers.delete(sessionKey(session));
559
728
  mapInjected.delete(sessionKey(session));
729
+ mapTextBySession.delete(sessionKey(session));
730
+ // A fork is a different session id, so it starts with an empty ledger and
731
+ // no inherited pins — nothing from the parent can leak through here.
732
+ clearSession(sessionKey(session));
560
733
  } catch (err) {
561
734
  log.warn?.(`[dsh-trivium] dispose skipped: ${err.message}`);
562
735
  }
package/lib/markdown.js CHANGED
@@ -19,7 +19,6 @@ import {
19
19
  applyCandidates,
20
20
  firstEntityName,
21
21
  } from "./extract.js";
22
- import { parseUntilFromText } from "./until.js";
23
22
  import { listOutgoingBusiness } from "./store.js";
24
23
 
25
24
  export const EXPORT_MARKER = "dsh-trivium-export";
@@ -192,7 +191,6 @@ function classifyBullet(text, sectionEntity) {
192
191
  type: "decision",
193
192
  name,
194
193
  text: fact,
195
- until: parseUntilFromText(body),
196
194
  linkName: name,
197
195
  linkLabel: "decided",
198
196
  quote: clip(body, 120),
package/lib/pins.js CHANGED
@@ -1,15 +1,15 @@
1
1
  /** Per-session memory chips. Pins inject into the next turn of the current session. */
2
2
 
3
3
  import { listEpisodeRecords } from "./episode.js";
4
- import { clipToTokenBudget, estimateTokens } from "./tokens.js";
4
+ import { renderContextRecords, selectContextRecords, TURN_ADDITIONAL_BUDGET } from "./context-budget.js";
5
5
  import { pinsOf, writePins } from "./settings.js";
6
6
  import { archiveNode, deleteNode, suggestedNeighborIds } from "./store.js";
7
7
  import { applyCandidates, DECISION_CUES, EXPLICIT_LINE_CHARS, firstEntityName } from "./extract.js";
8
8
  import { clip } from "./schema.js";
9
9
  import { payloadLooksDirty, sanitizeForWrite, writeGateMessage } from "./hygiene.js";
10
- import { parseUntilFromText } from "./until.js";
10
+ import { isStalePayload } from "./until.js";
11
11
 
12
- export const PIN_TOKEN_BUDGET = 300;
12
+ export const PIN_TOKEN_BUDGET = TURN_ADDITIONAL_BUDGET;
13
13
  export const CHIP_TYPES = Object.freeze(["preference", "decision", "entity"]);
14
14
 
15
15
  const CHIP_SET = new Set(CHIP_TYPES);
@@ -109,25 +109,56 @@ export function prunePins(db, sessionId) {
109
109
  return kept;
110
110
  }
111
111
 
112
- export function buildPinInject(db, sessionId, budget = PIN_TOKEN_BUDGET) {
112
+ /**
113
+ * Structured pin candidates, in user order.
114
+ *
115
+ * Selection owns filtering, de-duplication and rendering; handing over fields
116
+ * instead of a pre-clipped string is what lets chips and recall share one
117
+ * budget without the same node appearing twice.
118
+ *
119
+ * A pinned node that has since been superseded is kept as the user pinned it —
120
+ * it is marked historical, never silently rewritten to its successor.
121
+ */
122
+ export function pinCandidates(db, sessionId, { resolveRevision = null, now } = {}) {
113
123
  const ids = prunePins(db, sessionId);
114
- if (!ids.length) return { text: "", tokens: 0, clipped: false, ids: [] };
115
- const lines = ["dsh-trivium pins (L0 only; use ctx_read for full text):"];
116
- const used = [];
124
+ const out = [];
117
125
  for (const id of ids) {
118
126
  const payload = db.get(Number(id))?.payload;
119
- if (!payload || payloadLooksDirty(payload)) continue;
120
- lines.push(`- [${payload.type}#${id}] ${l0Of(payload)}`);
121
- used.push(Number(id));
127
+ if (!payload) continue;
128
+ const row = {
129
+ id: Number(id),
130
+ type: payload.type,
131
+ l0: l0Of(payload),
132
+ until: payload.until || payload.untilAt || "",
133
+ stale: isStalePayload(payload, now instanceof Date ? now : undefined),
134
+ archived: payload.status === "archived",
135
+ dirty: payloadLooksDirty(payload),
136
+ historical: false,
137
+ supersededBy: null,
138
+ };
139
+ if (typeof resolveRevision === "function") {
140
+ const revision = resolveRevision(Number(id));
141
+ const currentId = Number(revision?.currentId);
142
+ if (Number.isFinite(currentId) && currentId !== Number(id)) {
143
+ row.historical = true;
144
+ row.supersededBy = currentId;
145
+ }
146
+ }
147
+ out.push(row);
122
148
  }
123
- const full = lines.join("\n");
124
- const cap = Math.max(1, Number(budget) || PIN_TOKEN_BUDGET);
125
- const text = clipToTokenBudget(full, cap);
149
+ return out;
150
+ }
151
+
152
+ export function buildPinInject(db, sessionId, budget = PIN_TOKEN_BUDGET, { resolveRevision = null } = {}) {
153
+ const candidates = pinCandidates(db, sessionId, { resolveRevision });
154
+ if (!candidates.length) return { text: "", tokens: 0, clipped: false, ids: [] };
155
+ const plan = selectContextRecords({ pins: candidates, budget });
156
+ const rendered = renderContextRecords(plan).pins;
126
157
  return {
127
- text,
128
- tokens: estimateTokens(text),
129
- clipped: estimateTokens(full) > cap,
130
- ids: used,
158
+ text: rendered.text,
159
+ tokens: rendered.tokens,
160
+ clipped: plan.dropped.some((d) => d.reason === "over-budget"),
161
+ ids: plan.records.map((record) => record.id),
131
162
  };
132
163
  }
133
164
 
@@ -200,7 +231,6 @@ function candidateFromChipLine(body) {
200
231
  type: "decision",
201
232
  name,
202
233
  text: clip(text, EXPLICIT_LINE_CHARS),
203
- until: parseUntilFromText(text),
204
234
  linkName: name,
205
235
  linkLabel: "decided",
206
236
  quote: clip(text, 120),
@@ -258,10 +288,25 @@ export function batchChipAction(db, { action, ids, sessionId = "" } = {}) {
258
288
  const verb = action === "delete" ? "delete" : "archive";
259
289
  const list = [...new Set((ids || []).map(Number).filter((id) => Number.isFinite(id)))];
260
290
  let count = 0;
291
+ const blocked = [];
261
292
  for (const id of list) {
262
- const ok = verb === "delete" ? deleteNode(db, id) : archiveNode(db, id);
263
- if (ok) count += 1;
293
+ try {
294
+ const ok = verb === "delete" ? deleteNode(db, id) : archiveNode(db, id);
295
+ if (ok) count += 1;
296
+ } catch (err) {
297
+ // Deleting a node inside a revision chain would leave a dangling
298
+ // history; report it per id instead of failing the whole batch.
299
+ if (err?.code === "REVISION_LINKED") blocked.push(id);
300
+ else throw err;
301
+ }
264
302
  }
265
303
  if (sessionId) prunePins(db, sessionId);
266
- return { ok: count > 0 || list.length === 0, action: verb, count, ids: list };
304
+ return {
305
+ ok: count > 0 || list.length === 0,
306
+ action: verb,
307
+ count,
308
+ ids: list,
309
+ blocked,
310
+ code: blocked.length ? "REVISION_LINKED" : "",
311
+ };
267
312
  }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Provenance: where a memory came from.
3
+ *
4
+ * The plugin only ever has the fields it actually recorded. There is no host
5
+ * API that can turn a node id back into a message, so this module never
6
+ * reconstructs a session, an author or a quote — a record without `source`
7
+ * reports "not recorded" instead of guessing.
8
+ *
9
+ * Everything here is a projection for the management UI; none of it is injected
10
+ * into model context and none of it leaves the current workspace.
11
+ */
12
+
13
+ import { listEpisodeRecords } from "./episode.js";
14
+
15
+ /** Short preview cap for a stored quote, in code points. */
16
+ export const QUOTE_PREVIEW_MAX = 240;
17
+
18
+ export const SOURCE_KINDS = Object.freeze(["manual", "extract", "chip", "import", "tool"]);
19
+
20
+ /** Existing writes carried `via`; map them onto the newer `kind`. */
21
+ const KIND_BY_VIA = Object.freeze({
22
+ "chip-add": "chip",
23
+ "workbuddy-import": "import",
24
+ "external-import": "import",
25
+ extract: "extract",
26
+ tool: "tool",
27
+ manual: "manual",
28
+ });
29
+
30
+ const SESSION_SEEN = new Set();
31
+
32
+ /** Called on every session we observe, so a recent one can be reported as live. */
33
+ export function noteObservedSession(sessionId) {
34
+ const id = String(sessionId || "").trim();
35
+ if (id) SESSION_SEEN.add(id);
36
+ if (SESSION_SEEN.size > 200) {
37
+ const first = SESSION_SEEN.values().next().value;
38
+ SESSION_SEEN.delete(first);
39
+ }
40
+ }
41
+
42
+ export function resetObservedSessions() {
43
+ SESSION_SEEN.clear();
44
+ }
45
+
46
+ /**
47
+ * Plain text for display: control characters and bidi overrides removed.
48
+ *
49
+ * HTML/script markup is deliberately NOT stripped here — the client renders
50
+ * this as a text node, so it cannot execute, and losing it would hide what the
51
+ * user actually stored. Write-time hygiene still applies before anything is
52
+ * saved.
53
+ */
54
+ export function plainText(value, max = QUOTE_PREVIEW_MAX) {
55
+ const raw = String(value ?? "").replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, " ");
56
+ const flat = raw.replace(/\s+/g, " ").trim();
57
+ return flat.length > max ? `${flat.slice(0, Math.max(0, max - 1))}…` : flat;
58
+ }
59
+
60
+ function str(value, max = 200) {
61
+ const raw = plainText(value, max);
62
+ return raw;
63
+ }
64
+
65
+ /**
66
+ * Project a stored `source`. Optional fields may be missing; nothing is inferred.
67
+ *
68
+ * @returns `{ recorded: false }` when the payload carries no locatable source.
69
+ */
70
+ export function normalizeSource(source) {
71
+ if (!source || typeof source !== "object") return { recorded: false };
72
+ const via = str(source.via, 40);
73
+ const rawKind = str(source.kind, 24).toLowerCase();
74
+ const kind = SOURCE_KINDS.includes(rawKind) ? rawKind : KIND_BY_VIA[via] || "";
75
+ const sessionId = str(source.sessionId, 120);
76
+ const messageId = str(source.messageId ?? source.eventSeq ?? "", 120);
77
+ const quote = plainText(source.quote);
78
+ const capturedAt = str(source.capturedAt, 40);
79
+ const importPath = str(source.importPath, 400);
80
+ const recorded = Boolean(kind || sessionId || messageId || quote || capturedAt || importPath || via);
81
+ if (!recorded) return { recorded: false };
82
+ return {
83
+ recorded: true,
84
+ kind,
85
+ via,
86
+ sessionId,
87
+ messageId,
88
+ quote,
89
+ capturedAt,
90
+ importPath,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Is this session still reachable from this workspace?
96
+ *
97
+ * The plugin has two honest signals: an episode node recorded for that session
98
+ * in this workspace, or having observed the session in this process. Anything
99
+ * else is reported as unverifiable rather than assumed alive.
100
+ */
101
+ export function sessionStateOf(db, sessionId) {
102
+ const sid = String(sessionId || "");
103
+ if (!sid) return "unknown";
104
+ try {
105
+ if (listEpisodeRecords(db, sid).length) return "seen";
106
+ } catch {
107
+ // an unreadable episode index must not turn into a false positive
108
+ }
109
+ return SESSION_SEEN.has(sid) ? "seen" : "unknown";
110
+ }
111
+
112
+ /**
113
+ * `status`: `missing` (nothing recorded), `recorded` (locatable), or
114
+ * `unavailable` (fields exist but the session cannot be confirmed).
115
+ */
116
+ export function provenanceOf(db, id) {
117
+ const node = db.get(Number(id));
118
+ if (!node) return null;
119
+ const source = normalizeSource(node.payload?.source);
120
+ if (!source.recorded) {
121
+ return {
122
+ ok: true,
123
+ id: Number(id),
124
+ status: "missing",
125
+ message: "此条目没有可定位的来源。",
126
+ jump: false,
127
+ jumpReason: "",
128
+ source: null,
129
+ };
130
+ }
131
+ const state = source.sessionId ? sessionStateOf(db, source.sessionId) : "unknown";
132
+ const unavailable = Boolean(source.sessionId) && state !== "seen";
133
+ return {
134
+ ok: true,
135
+ id: Number(id),
136
+ status: unavailable ? "unavailable" : "recorded",
137
+ message: unavailable
138
+ ? "会话已不可用,保留现有摘录。"
139
+ : "宿主没有提供可用的会话定位接口,已保留摘录。",
140
+ sessionState: state,
141
+ // No deep link is invented: the host exposes no session locator here.
142
+ jump: false,
143
+ jumpReason: "host has no session locator API",
144
+ quotePreviewMax: QUOTE_PREVIEW_MAX,
145
+ source,
146
+ };
147
+ }