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
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 {
|
|
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
|
-
|
|
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")
|
|
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,
|
|
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
|
|
483
|
-
const
|
|
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
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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 {
|
|
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 {
|
|
10
|
+
import { isStalePayload } from "./until.js";
|
|
11
11
|
|
|
12
|
-
export const PIN_TOKEN_BUDGET =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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:
|
|
129
|
-
clipped:
|
|
130
|
-
ids:
|
|
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
|
-
|
|
263
|
-
|
|
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 {
|
|
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
|
+
}
|