@theseus-whale/dsh-continuity 0.0.0-stage → 4.12.2
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/INSTALL.md +25 -0
- package/PANEL.md +92 -0
- package/README.md +286 -2
- package/RELEASE-NOTES.md +732 -0
- package/bin/bc-acceptance-full.mjs +91 -0
- package/bin/bc-acceptance-tight.mjs +66 -0
- package/bin/bc-acceptance.mjs +22 -0
- package/bin/bc-add-keywords.mjs +21 -0
- package/bin/bc-demo.mjs +47 -0
- package/bin/bc-expand-hint.mjs +179 -0
- package/bin/bc-ingest-index.mjs +35 -0
- package/bin/bc-ingest.mjs +31 -0
- package/bin/bc-label-demo.mjs +18 -0
- package/bin/bc-relabel.mjs +150 -0
- package/bin/bc-sessionize.mjs +59 -0
- package/bin/coref_pipeline.py +47 -0
- package/bin/coref_service.py +57 -0
- package/bin/debug_ckip.py +16 -0
- package/bin/debug_ner_zh.py +18 -0
- package/bin/doctor.mjs +275 -0
- package/bin/en_ner_error_audit.py +33 -0
- package/bin/eval-label-rules.mjs +342 -0
- package/bin/export-turns.py +68 -0
- package/bin/extract-user-paragraph.mjs +14 -0
- package/bin/extraction-composite-smoke.mjs +28 -0
- package/bin/extraction-latency.mjs +58 -0
- package/bin/extraction-user-smoke.mjs +28 -0
- package/bin/extraction_service.py +164 -0
- package/bin/facet-active-experiment.mjs +110 -0
- package/bin/facet-covid.mjs +8 -0
- package/bin/facet-curve.mjs +27 -0
- package/bin/facet-embedding-train.mjs +20 -0
- package/bin/facet-embedding-validate.mjs +83 -0
- package/bin/facet-eval-roleplay.mjs +42 -0
- package/bin/facet-eval.mjs +38 -0
- package/bin/facet-fold.mjs +31 -0
- package/bin/facet-load.mjs +16 -0
- package/bin/facet-mlp-scan.mjs +45 -0
- package/bin/facet-mvp.mjs +34 -0
- package/bin/facet-online-experiments.mjs +78 -0
- package/bin/facet-online-race.mjs +99 -0
- package/bin/facet-online-validate.mjs +51 -0
- package/bin/facet-panel.mjs +22 -0
- package/bin/facet-passage.mjs +41 -0
- package/bin/facet-random-validate.mjs +48 -0
- package/bin/facet-roleplay.mjs +33 -0
- package/bin/facet-scan.mjs +56 -0
- package/bin/facet-segment.mjs +34 -0
- package/bin/facet-train.mjs +19 -0
- package/bin/facet-validate.mjs +103 -0
- package/bin/facet-zeroshot.mjs +14 -0
- package/bin/install-templates.mjs +72 -0
- package/bin/memory-bench.mjs +53 -0
- package/bin/memory-build-summary.mjs +43 -0
- package/bin/memory-build-vector.mjs +31 -0
- package/bin/memory-build.mjs +41 -0
- package/bin/memory-calibration.mjs +70 -0
- package/bin/memory-closed-loop.mjs +119 -0
- package/bin/memory-dry-run.mjs +70 -0
- package/bin/memory-eval.mjs +104 -0
- package/bin/memory-event-alias-test.mjs +89 -0
- package/bin/memory-extraction-bench.mjs +58 -0
- package/bin/memory-load-gate-replay.mjs +137 -0
- package/bin/memory-loop-test.mjs +90 -0
- package/bin/memory-pre-turn-harness.mjs +63 -0
- package/bin/memory-query.mjs +39 -0
- package/bin/memory-replay-hints.mjs +84 -0
- package/bin/memory-scale.mjs +58 -0
- package/bin/memory-scenario-test.mjs +146 -0
- package/bin/memory-user-loop.mjs +95 -0
- package/bin/ner_bench_tiny_zh.py +59 -0
- package/bin/ner_bench_zh.py +60 -0
- package/bin/ner_conll_en.py +139 -0
- package/bin/ner_direct_en.py +123 -0
- package/bin/ner_direct_zh.py +100 -0
- package/bin/ner_pipeline.py +208 -0
- package/bin/noise-selfcheck.mjs +50 -0
- package/bin/pack-integrity.mjs +47 -0
- package/bin/panel-check.mjs +57 -0
- package/bin/plugin-integrity.mjs +74 -0
- package/bin/preview-live.mjs +13 -0
- package/bin/public-install-check.mjs +222 -0
- package/bin/relabel-apply.mjs +272 -0
- package/bin/relabel-dry-run.mjs +381 -0
- package/bin/relik_pipeline.py +39 -0
- package/bin/relik_service.py +52 -0
- package/bin/srl_pipeline.py +55 -0
- package/bin/srl_service.py +68 -0
- package/bin/summary-index.mjs +151 -0
- package/bin/summary-query.mjs +79 -0
- package/bin/vec-check.mjs +27 -0
- package/bin/zh_ner_error_audit.py +36 -0
- package/bin/zh_ner_slot_eval.py +38 -0
- package/client.js +760 -0
- package/cordis.patch.yml +378 -0
- package/docs/API-NOTES.md +1743 -0
- package/docs/ARCHITECTURE.md +355 -0
- package/docs/CACHE.md +253 -0
- package/docs/CONDUCT.md +30 -0
- package/docs/CREATOR-HANDOFF.md +102 -0
- package/docs/EXPERIMENT.md +136 -0
- package/docs/FACET-MVP-REPORT.md +473 -0
- package/docs/GUIDE-0.4.66.md +195 -0
- package/docs/GUIDE.md +127 -0
- package/docs/MECHANISM.md +243 -0
- package/docs/NEW-INSTANCE-HANDOFF.md +219 -0
- package/docs/README.md +37 -0
- package/docs/SESSION-LAYOUT.md +151 -0
- package/docs/history-map.md +249 -0
- package/docs/relabel-migration-wiring.md +366 -0
- package/guides/00-READ-FIRST.md +206 -0
- package/guides/01-INSTALL.md +207 -0
- package/guides/02-TABLES.md +225 -0
- package/guides/03-AUDIT.md +201 -0
- package/guides/04-REBUILD.md +306 -0
- package/guides/05-FIRST-RUN.md +191 -0
- package/guides/06-EMBEDDING.md +146 -0
- package/guides/07-PTC-AND-ASSETS.md +221 -0
- package/icon.svg +7 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/models/README.md +13 -0
- package/models/Xenova/bge-small-zh-v1.5/config.json +31 -0
- package/models/Xenova/bge-small-zh-v1.5/onnx/model_quantized.onnx +0 -0
- package/models/Xenova/bge-small-zh-v1.5/tokenizer.json +21278 -0
- package/models/Xenova/bge-small-zh-v1.5/tokenizer_config.json +15 -0
- package/package.json +139 -4
- package/src/asset-tool.mjs +237 -0
- package/src/assets.mjs +244 -0
- package/src/backfill.mjs +74 -0
- package/src/backup.mjs +80 -0
- package/src/deadend.mjs +95 -0
- package/src/deleted-store.mjs +35 -0
- package/src/engine.mjs +3831 -0
- package/src/facets/classifier.mjs +146 -0
- package/src/facets/embedding-service.mjs +53 -0
- package/src/facets/embedding-worker.mjs +27 -0
- package/src/facets/embedding.mjs +163 -0
- package/src/facets/extract.mjs +80 -0
- package/src/facets/fold-plan.mjs +84 -0
- package/src/facets/hybrid.mjs +29 -0
- package/src/facets/index.mjs +113 -0
- package/src/facets/linear-probe.mjs +43 -0
- package/src/facets/load.mjs +37 -0
- package/src/facets/mlp-probe.mjs +54 -0
- package/src/facets/mlp-service.mjs +53 -0
- package/src/facets/mlp-worker.mjs +26 -0
- package/src/facets/online-knn.mjs +58 -0
- package/src/facets/online-models.mjs +227 -0
- package/src/facets/panel-state.mjs +23 -0
- package/src/facets/panel.mjs +73 -0
- package/src/facets/random.mjs +26 -0
- package/src/facets/scanner.mjs +35 -0
- package/src/facets/schema.mjs +75 -0
- package/src/facets/seed-examples.mjs +58 -0
- package/src/facets/sensitivity.mjs +98 -0
- package/src/facets/service.mjs +53 -0
- package/src/facets/store.mjs +39 -0
- package/src/facets/worker.mjs +23 -0
- package/src/folds-store.mjs +109 -0
- package/src/groups.mjs +167 -0
- package/src/handoff-tool.mjs +51 -0
- package/src/handoff.mjs +127 -0
- package/src/index.mjs +649 -0
- package/src/inject-policy.mjs +17 -0
- package/src/learning.mjs +88 -0
- package/src/memory/alias-proposal.mjs +40 -0
- package/src/memory/bc-audit.mjs +245 -0
- package/src/memory/bc-index-updater.mjs +404 -0
- package/src/memory/bc-index.mjs +48 -0
- package/src/memory/bc-label.mjs +220 -0
- package/src/memory/bc-panel-state.mjs +107 -0
- package/src/memory/bc-pre-step.mjs +463 -0
- package/src/memory/bc-recall.mjs +470 -0
- package/src/memory/bc-shift.mjs +123 -0
- package/src/memory/bc-store.mjs +41 -0
- package/src/memory/bm25.mjs +112 -0
- package/src/memory/calibration.mjs +54 -0
- package/src/memory/chatter.mjs +29 -0
- package/src/memory/cmap-file.mjs +107 -0
- package/src/memory/concepts-file.mjs +41 -0
- package/src/memory/coref-slots.mjs +40 -0
- package/src/memory/event-alias-ledger.mjs +87 -0
- package/src/memory/event-log.mjs +95 -0
- package/src/memory/event-model.mjs +132 -0
- package/src/memory/event-segmenter.mjs +36 -0
- package/src/memory/event-summary-tree.mjs +122 -0
- package/src/memory/extraction-adapter.mjs +76 -0
- package/src/memory/extraction-composite.mjs +75 -0
- package/src/memory/extraction-core.mjs +76 -0
- package/src/memory/extraction-pre-step.mjs +161 -0
- package/src/memory/extraction-provider.mjs +300 -0
- package/src/memory/extraction-score.mjs +36 -0
- package/src/memory/facet-stabilizer.mjs +59 -0
- package/src/memory/history-map.mjs +139 -0
- package/src/memory/language-router.mjs +30 -0
- package/src/memory/load-gate.mjs +161 -0
- package/src/memory/mechanical-note.mjs +104 -0
- package/src/memory/onboarding.mjs +163 -0
- package/src/memory/persona.mjs +78 -0
- package/src/memory/planner.mjs +249 -0
- package/src/memory/pre-turn.mjs +80 -0
- package/src/memory/query-aliases.mjs +39 -0
- package/src/memory/recall-cue.mjs +43 -0
- package/src/memory/recall.mjs +65 -0
- package/src/memory/referent-candidates.mjs +55 -0
- package/src/memory/referent-ledger.mjs +86 -0
- package/src/memory/relabel-migrate.mjs +376 -0
- package/src/memory/relik-adapter.mjs +25 -0
- package/src/memory/special-keywords.mjs +60 -0
- package/src/memory/structured-adapters.mjs +75 -0
- package/src/memory/summary-index.mjs +335 -0
- package/src/memory/summary-tree.mjs +110 -0
- package/src/memory/term-extractor.mjs +44 -0
- package/src/memory/tokenize.mjs +9 -0
- package/src/memory/vector-index.mjs +54 -0
- package/src/memory/window-summary.mjs +133 -0
- package/src/mood.mjs +133 -0
- package/src/peer-tool.mjs +63 -0
- package/src/peer.mjs +144 -0
- package/src/policy.mjs +170 -0
- package/src/preflight.mjs +113 -0
- package/src/provenance.mjs +94 -0
- package/src/range.mjs +75 -0
- package/src/recall-exact-tool.mjs +267 -0
- package/src/recall-tool.mjs +521 -0
- package/src/recheck.mjs +99 -0
- package/src/save-start.mjs +29 -0
- package/src/session-guard.mjs +140 -0
- package/src/session-log-tool.mjs +322 -0
- package/src/session-log.mjs +606 -0
- package/src/skills.mjs +111 -0
- package/src/split-range.mjs +115 -0
- package/src/state.mjs +198 -0
- package/src/t0.mjs +149 -0
- package/src/tag-confirm.mjs +78 -0
- package/src/tag-detect.mjs +42 -0
- package/src/tag-maintenance.mjs +285 -0
- package/src/tag-reject.mjs +73 -0
- package/src/tags-store.mjs +94 -0
- package/src/tags.mjs +692 -0
- package/src/threads-backend.mjs +26 -0
- package/src/threads-core.mjs +1887 -0
- package/src/threads-store.mjs +207 -0
- package/src/threads-tool.mjs +522 -0
- package/src/threads.mjs +929 -0
- package/src/tool-preview.mjs +427 -0
- package/src/topic.mjs +63 -0
- package/src/training.mjs +75 -0
- package/src/turn-tag.mjs +62 -0
- package/src/user-turn.mjs +39 -0
- package/templates/cmap.md +11 -0
- package/templates/concepts.md +12 -0
- package/templates/habits.md +40 -0
- package/templates/keywords.md +6 -0
- package/templates/rebuild.md +134 -0
- package/templates/rules-safe.md +184 -0
- package/templates/rules.md +177 -0
|
@@ -0,0 +1,1887 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Continuity threads Core + model-facing facade.
|
|
3
|
+
*
|
|
4
|
+
* The Core owns save/load/scan/find/tag/retag/tags/active/list. `makeApi()` is
|
|
5
|
+
* the forwarding layer the tool dispatcher calls; when a Core method is added,
|
|
6
|
+
* add it to the facade and exercise it through the tool, not just through a
|
|
7
|
+
* static check.
|
|
8
|
+
*/
|
|
9
|
+
import { recordError } from './state.mjs';
|
|
10
|
+
import { eventRole } from './session-log.mjs';
|
|
11
|
+
import {
|
|
12
|
+
continuityHome,
|
|
13
|
+
getSession,
|
|
14
|
+
readState,
|
|
15
|
+
updateSession,
|
|
16
|
+
} from './threads-store.mjs';
|
|
17
|
+
import { readTags, updateTags, writeTagsMd } from './tags-store.mjs';
|
|
18
|
+
import { writeDeletedBackup } from './deleted-store.mjs';
|
|
19
|
+
import { contentUndecided, untaggedTurnList, turnTagGap } from './t0.mjs';
|
|
20
|
+
import { attributeTurnSeqs } from './turn-tag.mjs';
|
|
21
|
+
import { detectDeadEnds, pathFromArguments } from './deadend.mjs';
|
|
22
|
+
import { buildPreflight, summarizeHealth } from './preflight.mjs';
|
|
23
|
+
import { planSplits, runsForThread, blocksByTag } from './split-range.mjs';
|
|
24
|
+
import { resolveSaveStart, detectSkippedRange } from './save-start.mjs';
|
|
25
|
+
import {
|
|
26
|
+
buildBackfillSegment,
|
|
27
|
+
rangeOverlaps,
|
|
28
|
+
selectBackfillEvents,
|
|
29
|
+
trimBackfillEvents,
|
|
30
|
+
} from './backfill.mjs';
|
|
31
|
+
import { appendFileSync, existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
import { foldForSeq, readFolds } from './folds-store.mjs';
|
|
34
|
+
import {
|
|
35
|
+
findResolvedRanges,
|
|
36
|
+
findTagSeqs,
|
|
37
|
+
findT0Ranges,
|
|
38
|
+
migrateAliasTags,
|
|
39
|
+
renderTagsMd,
|
|
40
|
+
retagTags,
|
|
41
|
+
selectionLoadText,
|
|
42
|
+
tagCoverageForRange,
|
|
43
|
+
tagSeqs,
|
|
44
|
+
tagsView,
|
|
45
|
+
TAG_ZERO,
|
|
46
|
+
} from './tags.mjs';
|
|
47
|
+
import {
|
|
48
|
+
activeView,
|
|
49
|
+
applyReasoningBudget,
|
|
50
|
+
estimateTextTokens,
|
|
51
|
+
formatTokenCount,
|
|
52
|
+
listView,
|
|
53
|
+
quoteEventText,
|
|
54
|
+
quoteSelected,
|
|
55
|
+
quoteSelectedWithReasoning,
|
|
56
|
+
REASONING_LOAD_BUDGET,
|
|
57
|
+
scanEvents,
|
|
58
|
+
selectLoadSegment,
|
|
59
|
+
threadLoadIndexOnly,
|
|
60
|
+
threadLoadText,
|
|
61
|
+
} from './threads.mjs';
|
|
62
|
+
const DEFAULT_LOAD_BUDGET = 8_000;
|
|
63
|
+
const MAX_LOAD_BUDGET = 20_000;
|
|
64
|
+
|
|
65
|
+
// 单测开关:假 session 没有 turn/事件数据,四道闸会把夹具误判成“缺口”。
|
|
66
|
+
// 只有测试进程会把它关掉;生产路径永远是 true。
|
|
67
|
+
let gatesEnabled = true;
|
|
68
|
+
export function setGatesEnabledForTests(value) { gatesEnabled = value === true; }
|
|
69
|
+
|
|
70
|
+
function requireString(value, name) {
|
|
71
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
72
|
+
throw new Error(`continuity.${name}: required non-empty string`);
|
|
73
|
+
}
|
|
74
|
+
return value.trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function optionalString(value) {
|
|
78
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function optionalInteger(value, name) {
|
|
82
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
83
|
+
const number = Number(value);
|
|
84
|
+
if (!Number.isInteger(number)) throw new Error(`continuity.${name}: must be an integer`);
|
|
85
|
+
return number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function integerList(value, name) {
|
|
89
|
+
if (value === undefined || value === null) return undefined;
|
|
90
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
91
|
+
throw new Error(`continuity.${name}: must be a non-empty array of integer seqs`);
|
|
92
|
+
}
|
|
93
|
+
const seqs = value.map((entry) => Number(entry));
|
|
94
|
+
if (seqs.some((seq) => !Number.isInteger(seq))) {
|
|
95
|
+
throw new Error(`continuity.${name}: every entry must be an integer seq`);
|
|
96
|
+
}
|
|
97
|
+
return [...new Set(seqs)];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function budgetOf(value) {
|
|
101
|
+
const number = Number(value);
|
|
102
|
+
if (!Number.isFinite(number) || number <= 0) return DEFAULT_LOAD_BUDGET;
|
|
103
|
+
return Math.min(MAX_LOAD_BUDGET, Math.max(1, Math.trunc(number)));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Keep the requested earliest/latest selection while enforcing the budget.
|
|
107
|
+
* Drops the largest visible event first; the result is sorted by seq. */
|
|
108
|
+
function trimSelectedToBudget(entries, budget) {
|
|
109
|
+
const keep = [...entries].sort((left, right) => Number(left.seq) - Number(right.seq));
|
|
110
|
+
let total = keep.reduce((sum, entry) => sum + (Number(entry.tokens) || 0), 0);
|
|
111
|
+
while (keep.length > 0 && total > budget) {
|
|
112
|
+
let largest = 0;
|
|
113
|
+
for (let index = 1; index < keep.length; index += 1) {
|
|
114
|
+
if (Number(keep[index].tokens) > Number(keep[largest].tokens)) largest = index;
|
|
115
|
+
}
|
|
116
|
+
total -= Number(keep[largest].tokens) || 0;
|
|
117
|
+
keep.splice(largest, 1);
|
|
118
|
+
}
|
|
119
|
+
return keep;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function readTagsWithWarning() {
|
|
123
|
+
const read = await readTags();
|
|
124
|
+
if (read.corrupt || read.missing) {
|
|
125
|
+
recordError(
|
|
126
|
+
`continuity: tags.json ${read.corrupt ? 'corrupt' : 'missing'}; using T0${read.error ? ` (${read.error})` : ''}`,
|
|
127
|
+
'tags-read',
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return read;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** alias is display only: resolve it to the canonical thread id. */
|
|
134
|
+
function canonicalTagId(id, threads = []) {
|
|
135
|
+
const text = String(id);
|
|
136
|
+
const thread = threads.find((entry) => String(entry.id) === text)
|
|
137
|
+
?? threads.find((entry) => String(entry.alias ?? '') === text);
|
|
138
|
+
return thread === undefined ? text : String(thread.id);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** id → existing tag id → tag/thread alias. null means unknown. */
|
|
142
|
+
function resolveTagIdentity(id, tagsState, threads = []) {
|
|
143
|
+
const text = String(id);
|
|
144
|
+
if (tagsState?.tags?.[text] !== undefined) return text;
|
|
145
|
+
const aliased = Object.entries(tagsState?.tags ?? {}).find(([, entry]) => entry?.alias === text);
|
|
146
|
+
if (aliased !== undefined) return aliased[0];
|
|
147
|
+
const thread = threads.find((entry) => String(entry.id) === text)
|
|
148
|
+
?? threads.find((entry) => String(entry.alias ?? '') === text);
|
|
149
|
+
return thread === undefined ? null : String(thread.id);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Read tags and merge any alias-named tag records into their canonical threads. */
|
|
153
|
+
async function readTagsMigrated(threads = []) {
|
|
154
|
+
const read = await readTagsWithWarning();
|
|
155
|
+
const migration = migrateAliasTags(read.state, threads);
|
|
156
|
+
if (!migration.changed) return { ...read, state: migration.state, migration };
|
|
157
|
+
const result = await updateTags(() => migration.state);
|
|
158
|
+
return { ...read, state: result.state, migration };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Split selected entries by fold state and collect the gist seqs. */
|
|
162
|
+
function foldedInfo(folds, entries) {
|
|
163
|
+
const folded = [];
|
|
164
|
+
const visible = [];
|
|
165
|
+
const gists = new Set();
|
|
166
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
167
|
+
const fold = foldForSeq(folds, entry.seq);
|
|
168
|
+
if (fold === null) {
|
|
169
|
+
visible.push(Number(entry.seq));
|
|
170
|
+
} else {
|
|
171
|
+
folded.push(Number(entry.seq));
|
|
172
|
+
if (Number.isInteger(fold.gistSeq)) gists.add(fold.gistSeq);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
folded,
|
|
177
|
+
visible,
|
|
178
|
+
foldedNote: gists.size > 0 ? [...gists].sort((left, right) => left - right).join(',') : null,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** One Continuity engine/session view over the durable thread store. */
|
|
183
|
+
export class ThreadsCore {
|
|
184
|
+
/**
|
|
185
|
+
* @param {object} options
|
|
186
|
+
* @param {object} options.agent
|
|
187
|
+
* @param {object} options.sessionQuery
|
|
188
|
+
* @param {object} [options.tokenMeter]
|
|
189
|
+
* @param {{ saveThread: Function }} options.backend lazily resolved engine seam
|
|
190
|
+
*/
|
|
191
|
+
constructor({ agent, sessionQuery, tokenMeter, backend }) {
|
|
192
|
+
this.agent = agent;
|
|
193
|
+
this.sessionQuery = sessionQuery;
|
|
194
|
+
this.tokenMeter = tokenMeter;
|
|
195
|
+
this.backend = backend;
|
|
196
|
+
this.sessionId = String(agent?.session?.id ?? agent?.id ?? '');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 按轮标签规划切段:返回 [{tag, label, threadId, fromSeq, toSeq}] */
|
|
200
|
+
async planSplit() {
|
|
201
|
+
const state = await getSession(this.sessionId);
|
|
202
|
+
const activeThread = (state.threads ?? []).find((thread) => String(thread.id) === String(state.activeId));
|
|
203
|
+
// 全局水位:所有线头的段最远覆盖到哪。按 active 自己的 lastTo 会重复保存别人已存的区间。
|
|
204
|
+
const globalLastTo = (state.threads ?? []).reduce((max, thread) => {
|
|
205
|
+
const to = Number(thread?.segments?.at(-1)?.toSeq ?? 0);
|
|
206
|
+
return Number.isFinite(to) && to > max ? to : max;
|
|
207
|
+
}, 0);
|
|
208
|
+
const lastTo = globalLastTo;
|
|
209
|
+
const snapshot = await this.sessionQuery.readSession(this.agent.session.id);
|
|
210
|
+
const events = Array.isArray(snapshot?.events) ? snapshot.events : [];
|
|
211
|
+
let maxTurn = -Infinity;
|
|
212
|
+
for (const event of events) {
|
|
213
|
+
const turn = Number(event?.data?.turn);
|
|
214
|
+
if (Number.isInteger(turn) && turn > maxTurn) maxTurn = turn;
|
|
215
|
+
}
|
|
216
|
+
let boundary = -Infinity;
|
|
217
|
+
for (const event of events) {
|
|
218
|
+
const turn = Number(event?.data?.turn);
|
|
219
|
+
if (!Number.isInteger(turn) || turn >= maxTurn) continue;
|
|
220
|
+
const seq = Number(event?.seq);
|
|
221
|
+
if (Number.isInteger(seq) && seq > boundary) boundary = seq;
|
|
222
|
+
}
|
|
223
|
+
if (!Number.isFinite(boundary)) return [];
|
|
224
|
+
const turnRows = (() => {
|
|
225
|
+
try {
|
|
226
|
+
return readFileSync(join(continuityHome(), 'turn-tags.jsonl'), 'utf8').split('\n').filter(Boolean)
|
|
227
|
+
.map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
|
|
228
|
+
} catch { return []; }
|
|
229
|
+
})();
|
|
230
|
+
const tagOf = new Map();
|
|
231
|
+
for (const row of turnRows) tagOf.set(Number(row.turn), String(row.tag));
|
|
232
|
+
const byTurn = new Map();
|
|
233
|
+
for (const event of events) {
|
|
234
|
+
if (event?.type !== 'user/message' && event?.type !== 'assistant/message') continue;
|
|
235
|
+
const turn = Number(event?.data?.turn);
|
|
236
|
+
const seq = Number(event?.seq);
|
|
237
|
+
if (!Number.isInteger(turn) || !Number.isInteger(seq)) continue;
|
|
238
|
+
const entry = byTurn.get(turn) ?? { turn, seqs: [] };
|
|
239
|
+
entry.seqs.push(seq);
|
|
240
|
+
byTurn.set(turn, entry);
|
|
241
|
+
}
|
|
242
|
+
const covered = [];
|
|
243
|
+
for (const thread of state.threads ?? []) {
|
|
244
|
+
for (const seg of thread.segments ?? []) covered.push([Number(seg.fromSeq), Number(seg.toSeq)]);
|
|
245
|
+
}
|
|
246
|
+
const isCovered = (seq) => covered.some(([a, b]) => Number.isFinite(a) && Number.isFinite(b) && seq >= a && seq <= b);
|
|
247
|
+
const uncoveredTurns = [...byTurn.values()].map((entry) => ({
|
|
248
|
+
...entry,
|
|
249
|
+
seqs: entry.seqs.filter((seq) => !isCovered(seq)),
|
|
250
|
+
})).filter((entry) => entry.seqs.length > 0);
|
|
251
|
+
const plan = planSplits({ turns: uncoveredTurns, tagOf, fromSeq: lastTo + 1, toSeq: boundary });
|
|
252
|
+
const threads = new Map((state.threads ?? []).map((thread) => [String(thread.id), thread]));
|
|
253
|
+
const fallback = activeThread?.id ?? null;
|
|
254
|
+
return plan.map((part) => {
|
|
255
|
+
const thread = threads.get(String(part.tag)) ?? (fallback === null ? null : threads.get(String(fallback)));
|
|
256
|
+
return {
|
|
257
|
+
tag: part.tag,
|
|
258
|
+
label: String(thread?.label ?? part.tag),
|
|
259
|
+
threadId: thread?.id ?? fallback,
|
|
260
|
+
fromSeq: part.fromSeq,
|
|
261
|
+
toSeq: part.toSeq,
|
|
262
|
+
};
|
|
263
|
+
}).filter((part) => typeof part.threadId === 'string' && part.threadId.length > 0);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async save(args = {}, signal) {
|
|
267
|
+
// home 必须是函数级:闸、override 日志、fold-empty 日志、preflight 诊断都在不同块里用它。
|
|
268
|
+
// 2026-10-30 实测:它原先声明在闸的 try 块里,出块即 ReferenceError,
|
|
269
|
+
// 于是 save 成功、preflight 却报 "home is not defined"。
|
|
270
|
+
const home = continuityHome();
|
|
271
|
+
// 预检要用的会话事件:闸在 try 块里读到,但出了块就没了,所以在这里留一份引用。
|
|
272
|
+
let saveEvents = null;
|
|
273
|
+
let saveState = null;
|
|
274
|
+
// 闸里算出来的折叠区间;机械分段要用(块外拿不到 try 里的 const)。
|
|
275
|
+
let saveRange = null;
|
|
276
|
+
// 机械分段的结果:要折成哪几段。在闸之前算好,闸门只检查这些块。
|
|
277
|
+
let planSegments = null;
|
|
278
|
+
const skippedTags = [];
|
|
279
|
+
// 同一条线在这一轮里被推迟的块数(交错时一次只折最早一块)。
|
|
280
|
+
let deferredBlocks = 0;
|
|
281
|
+
// 0.8.6:surface 起点之前的未折区间。只报告,不自动补。
|
|
282
|
+
let skippedRange = null;
|
|
283
|
+
// split:true —— 只出计划,不执行。
|
|
284
|
+
// 为什么不能在这里循环保存:pending 只有一个槽(threads-core:468),
|
|
285
|
+
// 同一个 step 里的多次 save 会互相覆盖,只有最后一段能提交。
|
|
286
|
+
// 正确做法:拿到计划后,逐段跨 step 调用 save(每段一个 tool 调用)。
|
|
287
|
+
if (args?.split === true && (args.fromSeq === undefined || args.fromSeq === null)) {
|
|
288
|
+
const plan = await this.planSplit();
|
|
289
|
+
if (plan.length === 0) throw new Error('continuity.save: split 没有可切的区间(可能已经没有未保存内容)');
|
|
290
|
+
return {
|
|
291
|
+
ok: true,
|
|
292
|
+
split: 'plan-only',
|
|
293
|
+
note: '这不是保存结果。计划共 ' + plan.length + ' 段;pending 只有一个槽,必须逐段跨 step 调用 save(fromSeq/toSeq/threadId)。',
|
|
294
|
+
plan,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const backfillRequested = args.backfill === true;
|
|
299
|
+
const label = backfillRequested ? (optionalString(args.label) ?? '') : requireString(args.label, 'save.label');
|
|
300
|
+
const why = backfillRequested
|
|
301
|
+
? (optionalString(args.why) ?? `历史回补 ${String(args.fromSeq ?? '?')}..${String(args.toSeq ?? '?')}`)
|
|
302
|
+
: requireString(args.why, 'save.why');
|
|
303
|
+
const next = backfillRequested
|
|
304
|
+
? (optionalString(args.next) ?? '检查这段原话')
|
|
305
|
+
: requireString(args.next, 'save.next');
|
|
306
|
+
const residue = optionalString(args.residue);
|
|
307
|
+
const threadId = optionalString(args.threadId);
|
|
308
|
+
// exclude:折「不属于这条线头」的所有块。load 切换前用它——等于"不折我要去的那条"。
|
|
309
|
+
const exclude = optionalString(args.exclude);
|
|
310
|
+
const alias = optionalString(args.alias);
|
|
311
|
+
const relabel = args.relabel === true;
|
|
312
|
+
const fromSeq = args.fromSeq === undefined || args.fromSeq === null ? undefined : Number(args.fromSeq);
|
|
313
|
+
const toSeq = args.toSeq === undefined || args.toSeq === null ? undefined : Number(args.toSeq);
|
|
314
|
+
if (fromSeq !== undefined && !Number.isInteger(fromSeq)) throw new Error('continuity.save: fromSeq must be an integer surface seq');
|
|
315
|
+
if (toSeq !== undefined && !Number.isInteger(toSeq)) throw new Error('continuity.save: toSeq must be an integer surface seq');
|
|
316
|
+
if ((fromSeq !== undefined && toSeq === undefined) || (fromSeq === undefined && toSeq !== undefined)) {
|
|
317
|
+
throw new Error('continuity.save: pass both fromSeq and toSeq, or neither');
|
|
318
|
+
}
|
|
319
|
+
if (backfillRequested) {
|
|
320
|
+
if (fromSeq === undefined || toSeq === undefined) {
|
|
321
|
+
throw new Error('continuity.save: backfill needs explicit fromSeq and toSeq');
|
|
322
|
+
}
|
|
323
|
+
return this.saveBackfill({ fromSeq, toSeq, threadId, label, why, next, residue });
|
|
324
|
+
}
|
|
325
|
+
if (this.backend === null || this.backend === undefined || typeof this.backend.prepareSave !== 'function') {
|
|
326
|
+
throw new Error('continuity.save: the Continuity compaction engine is not mounted');
|
|
327
|
+
}
|
|
328
|
+
const saveArgs = { label, why, next, residue, fromSeq, toSeq, threadId, alias, relabel };
|
|
329
|
+
// 单测用:假 session 没有 turn/事件,四道闸会把夹具判成“缺口”。
|
|
330
|
+
// 只允许测试进程关掉(不是环境变量,避免生产被误静默)。
|
|
331
|
+
if (gatesEnabled) {
|
|
332
|
+
// T0 闸:即将折叠的区间里若还有未决消息,拒绝保存(放 t0-override.json 可带债通过)。
|
|
333
|
+
try {
|
|
334
|
+
const overrideFile = join(home, 't0-override.json');
|
|
335
|
+
// override 必须写明“放行哪些闸 + 为什么”。空文件不再等于全部放行——
|
|
336
|
+
// 踩过:一个残留的空 t0-override.json 静默跳过了四道闸,52 轮没标签也存进去了。
|
|
337
|
+
let override = null;
|
|
338
|
+
try {
|
|
339
|
+
const doc = JSON.parse(readFileSync(overrideFile, 'utf8'));
|
|
340
|
+
const gates = Array.isArray(doc?.gates) ? doc.gates.map(String) : [];
|
|
341
|
+
const reason = String(doc?.reason ?? '').trim();
|
|
342
|
+
if (gates.length > 0 && reason.length >= 4) override = { gates: new Set(gates), reason };
|
|
343
|
+
} catch { override = null; }
|
|
344
|
+
const gatesOpen = (gate) => override !== null && (override.gates.has('all') || override.gates.has(gate));
|
|
345
|
+
const state = await getSession(this.sessionId);
|
|
346
|
+
saveState = state;
|
|
347
|
+
const activeThread = (state.threads ?? []).find((thread) => String(thread.id) === String(state.activeId));
|
|
348
|
+
// 折叠起点 = **当前活跃上下文里**还没被折过的第一条。
|
|
349
|
+
// 不是“活跃线头末段+1”(T2 停了一天,会从 20053 起把昨天全拉进来),
|
|
350
|
+
// 也不是“全局最大覆盖+1”(那会把已折叠之外的空洞一起卷进来)。
|
|
351
|
+
// 判据来源:session.surface.nodes 就是此刻在上下文里的那些 seq。
|
|
352
|
+
const lastToOf = (thread) => Number(thread?.segments?.at(-1)?.toSeq ?? 0);
|
|
353
|
+
const coveredTo = Math.max(0, ...(state.threads ?? []).map(lastToOf));
|
|
354
|
+
const surfaceSeqs = [...(this.agent?.session?.surface?.nodes ?? [])]
|
|
355
|
+
.map((seq) => Number(seq))
|
|
356
|
+
.filter((seq) => Number.isInteger(seq));
|
|
357
|
+
const surfaceTail = surfaceSeqs.filter((seq) => seq > coveredTo).sort((a, b) => a - b);
|
|
358
|
+
const lastTo = coveredTo > 0 ? coveredTo : lastToOf(activeThread);
|
|
359
|
+
// 0.8.6:起点仍是 surface[0];显式 fromSeq 低于 surface 时落到 surface[0],
|
|
360
|
+
// 不抛 “not a surface node”。真正的 surface 外补洞走 0.8.7 backfill。
|
|
361
|
+
const surfaceFirst = surfaceTail.length > 0 ? surfaceTail[0] : lastTo + 1;
|
|
362
|
+
const resolved = resolveSaveStart({ explicitFrom: fromSeq, surfaceFirst });
|
|
363
|
+
const startFrom = resolved.startFrom;
|
|
364
|
+
const snapshot = await this.sessionQuery.readSession(this.agent.session.id);
|
|
365
|
+
// readTags() 返回的是包装对象 { state, missing, corrupt },闸要的是 state 本体。
|
|
366
|
+
// 直接传包装对象等于传了一份空文档:overrides/runs 从来没进过闸(2026-10-30 抓到)。
|
|
367
|
+
const tagsRead = await readTags();
|
|
368
|
+
if (tagsRead.corrupt || tagsRead.missing) {
|
|
369
|
+
recordError(
|
|
370
|
+
`continuity: tags.json ${tagsRead.corrupt ? 'corrupt' : 'missing'}; 闸按“无标签”处理${tagsRead.error ? ` (${tagsRead.error})` : ''}`,
|
|
371
|
+
'tags-read',
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
const tagsDoc = tagsRead?.state ?? null;
|
|
375
|
+
let decisions = [];
|
|
376
|
+
try {
|
|
377
|
+
decisions = readFileSync(join(home, 't0-decisions.jsonl'), 'utf8')
|
|
378
|
+
.split('\n').filter(Boolean).map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
|
|
379
|
+
} catch { decisions = []; }
|
|
380
|
+
// 只算“已经结束的轮次”:当前这一轮还没到判定的时候,不能算未决。
|
|
381
|
+
const allEvents = Array.isArray(snapshot?.events) ? snapshot.events : [];
|
|
382
|
+
saveEvents = allEvents;
|
|
383
|
+
skippedRange = detectSkippedRange({ coveredTo, surfaceFirst, events: allEvents });
|
|
384
|
+
if (skippedRange !== null) {
|
|
385
|
+
recordError(
|
|
386
|
+
`continuity.save: 未折 ${skippedRange.from}..${skippedRange.to}(${skippedRange.events} 条内容事件,surface 起点 ${surfaceFirst})`,
|
|
387
|
+
'save-hole',
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
let maxTurn = -Infinity;
|
|
391
|
+
for (const event of allEvents) {
|
|
392
|
+
const turn = Number(event?.data?.turn);
|
|
393
|
+
if (Number.isInteger(turn) && turn > maxTurn) maxTurn = turn;
|
|
394
|
+
}
|
|
395
|
+
// 折叠右界:显式 toSeq 优先,否则取“最后一个已结束轮”的边界。
|
|
396
|
+
// 这里原写成 `let toSeq = Infinity`,把外层显式 toSeq 遮蔽了——
|
|
397
|
+
// 实测:明确给 42426..43040,却多折了 43444..43670(2026-10-30 定向验证抓到)。
|
|
398
|
+
let boundaryTo = Infinity;
|
|
399
|
+
if (Number.isFinite(maxTurn)) {
|
|
400
|
+
let boundary = -Infinity;
|
|
401
|
+
for (const event of allEvents) {
|
|
402
|
+
const turn = Number(event?.data?.turn);
|
|
403
|
+
if (!Number.isInteger(turn) || turn >= maxTurn) continue;
|
|
404
|
+
const seq = Number(event?.seq);
|
|
405
|
+
if (Number.isInteger(seq) && seq > boundary) boundary = seq;
|
|
406
|
+
}
|
|
407
|
+
if (Number.isFinite(boundary)) boundaryTo = boundary;
|
|
408
|
+
}
|
|
409
|
+
const cutTo = toSeq !== undefined ? toSeq : boundaryTo;
|
|
410
|
+
// 逐轮标签必须在使用前读进来:之前写成“先用后声明”,ReferenceError 被下面的
|
|
411
|
+
// catch 吞掉,四道闸全部静默失效(2026-10-29 反向验收抓到:90 轮无标签也存了)。
|
|
412
|
+
let turnTagRows = [];
|
|
413
|
+
try {
|
|
414
|
+
turnTagRows = readFileSync(join(home, 'turn-tags.jsonl'), 'utf8').split('\n').filter(Boolean)
|
|
415
|
+
.map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
|
|
416
|
+
} catch { turnTagRows = []; }
|
|
417
|
+
|
|
418
|
+
// ---- 先算“真正要折哪几块”,再跑闸 ----
|
|
419
|
+
// 以前闸门按“活跃线头末段+1 → 缓冲区末”检查,exclude 模式会把整条线拉进来,
|
|
420
|
+
// 于是几百条从没判过的历史把 save 拦死(实测:598 条未决)。闸门该看的是要折的块。
|
|
421
|
+
const tagOf = new Map();
|
|
422
|
+
for (const row of turnTagRows) tagOf.set(Number(row.turn), String(row.tag));
|
|
423
|
+
// user/message 没有 data.turn(结构性事实):先归轮,再切块——**两个分支都要用**。
|
|
424
|
+
// 不归轮的话块会从 assistant 开始,用户原话永远折不进去,下次 save 又看见它。
|
|
425
|
+
// (2026-10-30 踩过:把这段放在 exclude 分支里,else 分支直接 ReferenceError。)
|
|
426
|
+
const orderedForRuns = (Array.isArray(allEvents) ? allEvents : [])
|
|
427
|
+
.map((event) => ({
|
|
428
|
+
seq: Number(event?.seq),
|
|
429
|
+
type: event?.type,
|
|
430
|
+
turn: Number.isInteger(Number(event?.data?.turn)) ? Number(event.data.turn) : null,
|
|
431
|
+
}))
|
|
432
|
+
.filter((event) => Number.isInteger(event.seq));
|
|
433
|
+
const attributedTurns = attributeTurnSeqs(orderedForRuns);
|
|
434
|
+
const turnBySeq = new Map();
|
|
435
|
+
for (const [turn, seqs] of attributedTurns.turnSeqs) for (const seq of seqs) turnBySeq.set(Number(seq), turn);
|
|
436
|
+
const eventsForRuns = orderedForRuns.map((event) => ({ seq: event.seq, type: event.type, turn: event.turn ?? turnBySeq.get(event.seq) ?? null }));
|
|
437
|
+
if (exclude !== undefined) {
|
|
438
|
+
const known = new Map();
|
|
439
|
+
for (const thread of saveState?.threads ?? []) {
|
|
440
|
+
known.set(String(thread.id), String(thread.id));
|
|
441
|
+
if (typeof thread.alias === 'string' && thread.alias.length > 0) known.set(thread.alias, String(thread.id));
|
|
442
|
+
}
|
|
443
|
+
const excludeId = known.get(exclude) ?? exclude;
|
|
444
|
+
planSegments = [];
|
|
445
|
+
const seenThreads = new Set();
|
|
446
|
+
let deferred = 0;
|
|
447
|
+
for (const block of blocksByTag({ events: eventsForRuns, tagOf, fromSeq: startFrom, toSeq: cutTo })) {
|
|
448
|
+
if (block.tag === excludeId) continue;
|
|
449
|
+
const resolved = known.get(block.tag) ?? null;
|
|
450
|
+
if (resolved === null) { skippedTags.push(block.tag); continue; }
|
|
451
|
+
// 同一条线在一次 save 里只折最早的那一块:同时追加两段会让第二段的前驱算错。
|
|
452
|
+
// 交错块留给下一次 save(机制上仍成立,只是需要多存几次)。
|
|
453
|
+
if (seenThreads.has(resolved)) { deferred += 1; continue; }
|
|
454
|
+
seenThreads.add(resolved);
|
|
455
|
+
planSegments.push({ fromSeq: block.fromSeq, toSeq: block.toSeq, threadId: resolved });
|
|
456
|
+
}
|
|
457
|
+
if (deferred > 0) deferredBlocks = deferred;
|
|
458
|
+
} else {
|
|
459
|
+
const target = String(threadId ?? saveState?.activeId ?? '');
|
|
460
|
+
planSegments = runsForThread({ events: eventsForRuns, tagOf, fromSeq: startFrom, toSeq: cutTo, target })
|
|
461
|
+
.map((run) => ({ fromSeq: run.fromSeq, toSeq: run.toSeq, threadId: target }));
|
|
462
|
+
}
|
|
463
|
+
// 闸门区间 = 要折的块并集;没有块时才退回整段。
|
|
464
|
+
const gateFrom = planSegments.length > 0 ? Math.min(...planSegments.map((part) => part.fromSeq)) : startFrom;
|
|
465
|
+
const gateTo = planSegments.length > 0 ? Math.max(...planSegments.map((part) => part.toSeq)) : cutTo;
|
|
466
|
+
saveRange = { fromSeq: gateFrom, toSeq: gateTo };
|
|
467
|
+
|
|
468
|
+
const gateResult = contentUndecided({
|
|
469
|
+
events: allEvents,
|
|
470
|
+
tagsDoc,
|
|
471
|
+
decisions,
|
|
472
|
+
turnTagRows,
|
|
473
|
+
fromSeq: gateFrom,
|
|
474
|
+
toSeq: gateTo,
|
|
475
|
+
});
|
|
476
|
+
if (!gatesOpen('t0-undecided') && gateResult.undecided.length > 0) {
|
|
477
|
+
const sample = gateResult.undecided.slice(0, 5).map((row) => row.seq + ' ' + row.head).join(' | ');
|
|
478
|
+
const error = new Error('continuity.save: 拒绝保存——即将折叠的区间还有 ' + gateResult.undecided.length
|
|
479
|
+
+ ' 条未决(没人判过归属)。先跑 t0decide.mjs queue --from ' + startFrom + ' 逐条 decide;'
|
|
480
|
+
+ '要带债保存,放 ' + overrideFile + '。示例:' + sample);
|
|
481
|
+
error.code = 't0-undecided';
|
|
482
|
+
throw error;
|
|
483
|
+
}
|
|
484
|
+
// 全局轮标签闸:已结束的轮缺口过大 → 不是“还没轮到”,是机制漏打;此时折叠等于把缺口压成灰。
|
|
485
|
+
let maxTurnAll = 0;
|
|
486
|
+
for (const event of allEvents) {
|
|
487
|
+
const turn = Number(event?.data?.turn);
|
|
488
|
+
if (Number.isInteger(turn) && turn > maxTurnAll) maxTurnAll = turn;
|
|
489
|
+
}
|
|
490
|
+
const globalGap = turnTagGap({ events: allEvents, turnTagRows, boundaryTurn: maxTurnAll - 1 });
|
|
491
|
+
if (!gatesOpen('tags-gap') && globalGap.missing.length > 0) {
|
|
492
|
+
const sample = globalGap.missing.slice(0, 6).map((row) => 'turn ' + row.turn).join(', ');
|
|
493
|
+
const advice = globalGap.missing.length > 20 ? '缺口过大:node tag-gap.mjs 出清单 → 逐轮补判' : '逐轮补判:node turnmap.mjs set --turn <轮> --tag Tx(新事物用 --new)';
|
|
494
|
+
const error = new Error('continuity.save: 拒绝保存——已结束的轮里有 ' + globalGap.missing.length
|
|
495
|
+
+ ' 轮没有标签(' + globalGap.tagged + '/' + globalGap.settled + ' 已判)。示例:' + sample
|
|
496
|
+
+ '。' + advice + ';要带债保存,放 ' + overrideFile + '。');
|
|
497
|
+
error.code = 'tags-gap';
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
500
|
+
// 区间已被别的线头的段覆盖 → 不要重复存(会造出同名不同义的重复段)。
|
|
501
|
+
// 这通常发生在“active 线头的边界落后于全局水位”时。
|
|
502
|
+
const coveredAll = [];
|
|
503
|
+
for (const thread of state.threads ?? []) {
|
|
504
|
+
for (const seg of thread.segments ?? []) coveredAll.push([Number(seg.fromSeq), Number(seg.toSeq)]);
|
|
505
|
+
}
|
|
506
|
+
const uncoveredInRange = allEvents.filter((event) => {
|
|
507
|
+
if (event?.type !== 'user/message' && event?.type !== 'assistant/message') return false;
|
|
508
|
+
const seq = Number(event?.seq);
|
|
509
|
+
if (!Number.isInteger(seq) || seq < startFrom || seq > cutTo) return false;
|
|
510
|
+
return !coveredAll.some(([a, b]) => Number.isFinite(a) && Number.isFinite(b) && seq >= a && seq <= b);
|
|
511
|
+
}).length;
|
|
512
|
+
if (!gatesOpen('range-covered') && uncoveredInRange === 0) {
|
|
513
|
+
const error = new Error('continuity.save: 区间 ' + startFrom + '..' + cutTo
|
|
514
|
+
+ ' 已被其他线头的段覆盖,没有新内容可存(active 线头边界落后于全局水位)。用 save split:true 按话题切,或先 load 到正确的线头。');
|
|
515
|
+
error.code = 'range-already-covered';
|
|
516
|
+
throw error;
|
|
517
|
+
}
|
|
518
|
+
const untagged = untaggedTurnList({ events: allEvents, turnTagRows, fromSeq: startFrom, toSeq: cutTo });
|
|
519
|
+
if (!gatesOpen('turn-untagged') && untagged.length > 0) {
|
|
520
|
+
const sample = untagged.slice(0, 5).map((row) => 'turn ' + row.turn + ' (seq ' + row.seq + ')').join(', ');
|
|
521
|
+
const error = new Error('continuity.save: 拒绝保存——这次折叠覆盖的轮里有 ' + untagged.length
|
|
522
|
+
+ ' 轮没有标签。先用 turnmap.mjs set --turn N --tag Tx 补上;示例:' + sample
|
|
523
|
+
+ ';要带债保存,放 ' + overrideFile + '。');
|
|
524
|
+
error.code = 'turn-untagged';
|
|
525
|
+
throw error;
|
|
526
|
+
}
|
|
527
|
+
// C7 闸:即将折叠的区间里有“死胡同症状”(同处改多版 / 改完仍同错 / 改动无判据)→ 拒绝。
|
|
528
|
+
const c7Calls = [];
|
|
529
|
+
const c7Errors = [];
|
|
530
|
+
const c7Replies = [];
|
|
531
|
+
for (const event of allEvents) {
|
|
532
|
+
const seq = Number(event?.seq);
|
|
533
|
+
if (!Number.isInteger(seq) || seq < startFrom || seq > cutTo) continue;
|
|
534
|
+
const turn = Number(event?.data?.turn);
|
|
535
|
+
const turnValue = Number.isInteger(turn) ? turn : 0;
|
|
536
|
+
if (event?.type === 'tool/call') {
|
|
537
|
+
const path = pathFromArguments(event?.data?.name, event?.data?.arguments);
|
|
538
|
+
c7Calls.push({ turn: turnValue, path });
|
|
539
|
+
} else if (event?.type === 'tool/result') {
|
|
540
|
+
const text = JSON.stringify(event?.data ?? '');
|
|
541
|
+
if (/"(isError|error)"\s*:\s*true|Error:|失败|failed/i.test(text)) {
|
|
542
|
+
c7Errors.push({ turn: turnValue, sig: text.replace(/\s+/g, ' ').slice(0, 60) });
|
|
543
|
+
}
|
|
544
|
+
} else if (event?.type === 'assistant/message') {
|
|
545
|
+
let replyText = '';
|
|
546
|
+
try { replyText = eventText(event, { includeReasoning: false }).trim(); } catch { replyText = ''; }
|
|
547
|
+
if (replyText.length > 0) c7Replies.push({ turn: turnValue, text: replyText });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// 只看区间的“尾部 12 轮”:死胡同是“正在发生”的症状,不是历史统计。
|
|
551
|
+
let c7MaxTurn = 0;
|
|
552
|
+
for (const row of c7Calls) if (row.turn > c7MaxTurn) c7MaxTurn = row.turn;
|
|
553
|
+
for (const row of c7Errors) if (row.turn > c7MaxTurn) c7MaxTurn = row.turn;
|
|
554
|
+
const c7 = detectDeadEnds({
|
|
555
|
+
toolCalls: c7Calls.filter((row) => row.turn > c7MaxTurn - 12),
|
|
556
|
+
errors: c7Errors.filter((row) => row.turn > c7MaxTurn - 12),
|
|
557
|
+
replies: c7Replies.slice(-2),
|
|
558
|
+
windowTurns: 12,
|
|
559
|
+
limit: 3,
|
|
560
|
+
});
|
|
561
|
+
if (!gatesOpen('deadend-symptoms') && c7.symptoms.length > 0) {
|
|
562
|
+
const error = new Error('continuity.save: 拒绝保存——这次折叠的区间里有死胡同症状:' + c7.symptoms.join(';')
|
|
563
|
+
+ '。先补判据或换地方改(同处改三版就该停下来搜文献);要带债保存,放 ' + overrideFile + '。');
|
|
564
|
+
error.code = 'deadend-symptoms';
|
|
565
|
+
throw error;
|
|
566
|
+
}
|
|
567
|
+
if (override !== null) {
|
|
568
|
+
try {
|
|
569
|
+
appendFileSync(join(home, 'override-log.jsonl'), JSON.stringify({
|
|
570
|
+
at: new Date().toISOString(),
|
|
571
|
+
gates: [...override.gates],
|
|
572
|
+
reason: override.reason,
|
|
573
|
+
}) + '\n');
|
|
574
|
+
} catch { /* 日志失败不阻断保存 */ }
|
|
575
|
+
try { unlinkSync(overrideFile); } catch { /* 删不掉下次还会放行 */ }
|
|
576
|
+
}
|
|
577
|
+
} catch (error) {
|
|
578
|
+
if (error?.code === 't0-undecided' || error?.code === 'turn-untagged' || error?.code === 'deadend-symptoms' || error?.code === 'tags-gap' || error?.code === 'range-already-covered') throw error;
|
|
579
|
+
// 闸自身出错不阻断保存(宁可漏闸,不可卡死)——但必须留痕:
|
|
580
|
+
// 一个 ReferenceError 曾在这里被静默吞掉,四道闸一起失效。
|
|
581
|
+
try {
|
|
582
|
+
appendFileSync(join(continuityHome(), 'gate-errors.jsonl'), JSON.stringify({
|
|
583
|
+
at: new Date().toISOString(),
|
|
584
|
+
name: String(error?.name ?? 'Error'),
|
|
585
|
+
code: error?.code ?? null,
|
|
586
|
+
message: String(error?.message ?? error).slice(0, 300),
|
|
587
|
+
}) + '\n');
|
|
588
|
+
} catch { /* 日志都写不了就算了,但不能因此阻断 */ }
|
|
589
|
+
// 闸自身写错(ReferenceError/TypeError)时静默放行,等于四道闸一起失效——
|
|
590
|
+
// 今天已经这样漏过两次(20 轮、97 轮无标签被折叠)。改成响亮地中止。
|
|
591
|
+
if (error?.name === 'ReferenceError' || error?.name === 'TypeError') {
|
|
592
|
+
const fatal = new Error('continuity.save: 闸自身出错(' + error.name + ': ' + error.message
|
|
593
|
+
+ '),已中止保存——不静默放行。详情见 gate-errors.jsonl。');
|
|
594
|
+
fatal.code = 'gate-error';
|
|
595
|
+
throw fatal;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// 机械分段已在闸门之前算好(planSegments / skippedTags),这里只执行结果:
|
|
601
|
+
// exclude=<线头>:折所有**不属于它**的块(load 切换前用)
|
|
602
|
+
// 否则:只折属于目标线头的块
|
|
603
|
+
// planSegments === null 表示没走到闸(单测关闸、或拿不到 session)→ 走旧路径整段存。
|
|
604
|
+
const skippedNote = skippedRange === null
|
|
605
|
+
? ''
|
|
606
|
+
: `\n⚠ 未折 ${skippedRange.from}..${skippedRange.to}(${skippedRange.events} 条内容事件,surface 起点之前)——要补:save({fromSeq:${skippedRange.from},toSeq:${skippedRange.to},backfill:true})`;
|
|
607
|
+
if (planSegments !== null && planSegments.length === 0) {
|
|
608
|
+
// 不许静默:折 0 段也留一行痕迹,否则“该折没折”在屏幕上看不见(踩过:autoSave 空转)。
|
|
609
|
+
try {
|
|
610
|
+
appendFileSync(join(home, 'fold-empty.log'), JSON.stringify({
|
|
611
|
+
at: new Date().toISOString(),
|
|
612
|
+
fromSeq: saveRange?.fromSeq ?? null,
|
|
613
|
+
toSeq: saveRange?.toSeq ?? null,
|
|
614
|
+
threads: threadId ?? null,
|
|
615
|
+
}) + '\n');
|
|
616
|
+
} catch { /* 日志失败不阻断 */ }
|
|
617
|
+
return {
|
|
618
|
+
ok: true,
|
|
619
|
+
pending: false,
|
|
620
|
+
folded: 0,
|
|
621
|
+
skipped: skippedTags,
|
|
622
|
+
skippedRange,
|
|
623
|
+
receipt: `没有折叠:seq ${saveRange?.fromSeq ?? '?'}..${saveRange?.toSeq ?? '?'} 没有需要折的块${skippedNote}`,
|
|
624
|
+
hint: '要折当前线头本身就别传 exclude;或显式给 fromSeq/toSeq。',
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
if (planSegments !== null && planSegments.length > 1) {
|
|
628
|
+
const items = [];
|
|
629
|
+
for (const part of planSegments) {
|
|
630
|
+
const partArgs = { ...saveArgs, fromSeq: part.fromSeq, toSeq: part.toSeq, threadId: part.threadId };
|
|
631
|
+
const preparedPart = await this.backend.prepareSave(this.agent, partArgs);
|
|
632
|
+
items.push({
|
|
633
|
+
mode: preparedPart?.mode === 'append' ? 'append' : 'new',
|
|
634
|
+
args: partArgs,
|
|
635
|
+
prepared: preparedPart,
|
|
636
|
+
thread: preparedPart?.thread,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
await updateSession(this.sessionId, (current) => {
|
|
640
|
+
current.pending = { kind: 'save', items };
|
|
641
|
+
return current;
|
|
642
|
+
});
|
|
643
|
+
return {
|
|
644
|
+
ok: true,
|
|
645
|
+
pending: true,
|
|
646
|
+
split: items.length,
|
|
647
|
+
skipped: skippedTags,
|
|
648
|
+
skippedRange,
|
|
649
|
+
deferred: deferredBlocks,
|
|
650
|
+
receipt: `按标签折成 ${items.length} 段:` + items.map((item) => `${item.thread?.id ?? item.args?.threadId ?? '?'} seq ${item.prepared?.segment?.fromSeq ?? '?'}..${item.prepared?.segment?.toSeq ?? '?'}`).join(' · ')
|
|
651
|
+
+ (deferredBlocks > 0 ? `(另有 ${deferredBlocks} 块交错,留给下一次 save)` : '')
|
|
652
|
+
+ skippedNote,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
if (planSegments !== null && planSegments.length === 1) {
|
|
656
|
+
saveArgs.fromSeq = planSegments[0].fromSeq;
|
|
657
|
+
saveArgs.toSeq = planSegments[0].toSeq;
|
|
658
|
+
saveArgs.threadId = planSegments[0].threadId;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const prepared = await this.backend.prepareSave(this.agent, saveArgs);
|
|
662
|
+
const thread = prepared?.thread;
|
|
663
|
+
if (thread === undefined || thread === null) throw new Error('continuity.save: no thread range was prepared');
|
|
664
|
+
const mode = prepared.mode === 'append' ? 'append' : 'new';
|
|
665
|
+
const segments = Array.isArray(thread.segments) && thread.segments.length > 0
|
|
666
|
+
? thread.segments
|
|
667
|
+
: [thread];
|
|
668
|
+
const segment = prepared.segment ?? segments[segments.length - 1] ?? thread;
|
|
669
|
+
const segmentIndex = Number.isFinite(prepared.segmentIndex)
|
|
670
|
+
? prepared.segmentIndex
|
|
671
|
+
: Math.max(1, segments.length);
|
|
672
|
+
const tokenCount = Number(segment.tokens ?? thread.tokens) || 0;
|
|
673
|
+
let receipt = mode === 'append'
|
|
674
|
+
? `saved into ${thread.id} · segment ${segmentIndex} · seq ${segment.fromSeq}..${segment.toSeq} · ${formatTokenCount(tokenCount)} tokens`
|
|
675
|
+
: `saved as ${thread.id} · seq ${segment.fromSeq}..${segment.toSeq} · ${formatTokenCount(tokenCount)} tokens`;
|
|
676
|
+
receipt += skippedNote;
|
|
677
|
+
|
|
678
|
+
// The durable gist is committed at the next pre-step, after this tool
|
|
679
|
+
// result is appended. Appending it here would put a user message between
|
|
680
|
+
// the assistant tool-call and its result and break request pairing.
|
|
681
|
+
await updateSession(this.sessionId, (current) => {
|
|
682
|
+
if (mode === 'append') {
|
|
683
|
+
current.threads = current.threads.map((entry) => ({
|
|
684
|
+
...entry,
|
|
685
|
+
active: entry.id === thread.id,
|
|
686
|
+
}));
|
|
687
|
+
current.activeId = thread.id;
|
|
688
|
+
} else {
|
|
689
|
+
for (const entry of current.threads) entry.active = false;
|
|
690
|
+
current.activeId = null;
|
|
691
|
+
}
|
|
692
|
+
current.pending = { kind: 'save', mode, args: saveArgs, prepared, thread };
|
|
693
|
+
return current;
|
|
694
|
+
});
|
|
695
|
+
// 预检:把“每次 save 前要手查的几件事”一次算好,附在回执里。
|
|
696
|
+
let preflight = null;
|
|
697
|
+
let health = null;
|
|
698
|
+
let preflightError = null;
|
|
699
|
+
let tagRowsForDiag = null;
|
|
700
|
+
try {
|
|
701
|
+
const rows = (() => {
|
|
702
|
+
try {
|
|
703
|
+
return readFileSync(join(home, 'turn-tags.jsonl'), 'utf8').split('\n').filter(Boolean)
|
|
704
|
+
.map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
|
|
705
|
+
} catch { return []; }
|
|
706
|
+
})();
|
|
707
|
+
tagRowsForDiag = { home, rows: rows.length };
|
|
708
|
+
// 不带 fromSeq/toSeq 的 save 也要有区间:优先用刚落的这一段,
|
|
709
|
+
// 这样 preflight 计划与实际 segment 同源(0.8.6 R3)。
|
|
710
|
+
const rangeFrom = Number(prepared?.segment?.fromSeq ?? fromSeq ?? 0);
|
|
711
|
+
const rangeTo = Number(prepared?.segment?.toSeq ?? toSeq ?? 0);
|
|
712
|
+
preflight = buildPreflight({ events: saveEvents ?? [], turnTagRows: rows, fromSeq: rangeFrom, toSeq: rangeTo });
|
|
713
|
+
let projection = null;
|
|
714
|
+
try { projection = JSON.parse(readFileSync(join(home, 'projection-state.json'), 'utf8')); } catch { projection = null; }
|
|
715
|
+
const sessionThreads = Array.isArray(saveState?.threads) ? saveState.threads : [];
|
|
716
|
+
health = summarizeHealth({ events: saveEvents ?? [], threads: sessionThreads, projection });
|
|
717
|
+
} catch (error) {
|
|
718
|
+
// 不许吞:把错误本身带回回执,下一次 save 一眼能看到原因。
|
|
719
|
+
preflight = null;
|
|
720
|
+
health = null;
|
|
721
|
+
preflightError = String(error?.name ?? 'Error') + ': ' + String(error?.message ?? error);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const planFrom = Number(preflight?.plan?.[0]?.from);
|
|
725
|
+
const planNote = Number.isFinite(planFrom) && Number.isFinite(Number(segment.fromSeq))
|
|
726
|
+
&& planFrom !== Number(segment.fromSeq)
|
|
727
|
+
? `preflight plan[0].from=${planFrom},segment.fromSeq=${segment.fromSeq};计划只计有轮归属的内容事件`
|
|
728
|
+
: null;
|
|
729
|
+
if (planNote !== null) receipt += '\n(' + planNote + ')';
|
|
730
|
+
|
|
731
|
+
return {
|
|
732
|
+
ok: true,
|
|
733
|
+
pending: true,
|
|
734
|
+
mode,
|
|
735
|
+
receipt,
|
|
736
|
+
id: thread.id,
|
|
737
|
+
segment: segmentIndex,
|
|
738
|
+
segments: segments.length,
|
|
739
|
+
active: thread.active !== false,
|
|
740
|
+
span: `${segment.spanStart} → ${segment.spanEnd}`,
|
|
741
|
+
tokens: tokenCount,
|
|
742
|
+
seqStart: segment.fromSeq,
|
|
743
|
+
seqEnd: segment.toSeq,
|
|
744
|
+
count: segment.count,
|
|
745
|
+
adjusted: Boolean(prepared.adjusted),
|
|
746
|
+
skippedRange,
|
|
747
|
+
planNote,
|
|
748
|
+
preflight,
|
|
749
|
+
health,
|
|
750
|
+
preflightError,
|
|
751
|
+
// 诊断:preflight 说“无标签”时,用它一眼判断是文件没读到、还是标签真没有。
|
|
752
|
+
tagRows: tagRowsForDiag,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* 0.8.9 historical backfill: append an archival segment from raw snapshot
|
|
758
|
+
* events. It never calls selectSaveRange and never touches the live surface.
|
|
759
|
+
*/
|
|
760
|
+
async saveBackfill({ fromSeq, toSeq, threadId, why, next, residue }) {
|
|
761
|
+
const home = continuityHome();
|
|
762
|
+
const state = await getSession(this.sessionId);
|
|
763
|
+
let target = null;
|
|
764
|
+
if (threadId !== undefined) {
|
|
765
|
+
target = state.threads.find((thread) => String(thread.id) === String(threadId))
|
|
766
|
+
?? state.threads.find((thread) => String(thread.alias ?? '') === String(threadId))
|
|
767
|
+
?? null;
|
|
768
|
+
if (target === null) throw new Error(`continuity.save: unknown threadId ${threadId}`);
|
|
769
|
+
} else {
|
|
770
|
+
target = state.threads.find((thread) => String(thread.id) === String(state.activeId)) ?? null;
|
|
771
|
+
if (target === null) throw new Error('continuity.save: backfill needs an existing threadId or active thread');
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const allSegments = state.threads.flatMap((thread) => thread.segments ?? []);
|
|
775
|
+
if (rangeOverlaps(allSegments, fromSeq, toSeq)) {
|
|
776
|
+
const error = new Error(`continuity.save: range ${fromSeq}..${toSeq} overlaps an existing segment; backfill is append-only`);
|
|
777
|
+
error.code = 'range-already-covered';
|
|
778
|
+
throw error;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const snapshot = await this.sessionQuery.readSession(this.agent.session.id);
|
|
782
|
+
const allEvents = Array.isArray(snapshot?.events) ? snapshot.events : [];
|
|
783
|
+
const selected = trimBackfillEvents(selectBackfillEvents(allEvents, fromSeq, toSeq));
|
|
784
|
+
if (selected.length === 0) {
|
|
785
|
+
const error = new Error(`continuity.save: backfill ${fromSeq}..${toSeq} contains no content events`);
|
|
786
|
+
error.code = 'backfill-empty';
|
|
787
|
+
throw error;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
let turnTagRows = [];
|
|
791
|
+
try {
|
|
792
|
+
turnTagRows = readFileSync(join(home, 'turn-tags.jsonl'), 'utf8').split('\n').filter(Boolean)
|
|
793
|
+
.map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
|
|
794
|
+
} catch { turnTagRows = []; }
|
|
795
|
+
let decisions = [];
|
|
796
|
+
try {
|
|
797
|
+
decisions = readFileSync(join(home, 't0-decisions.jsonl'), 'utf8').split('\n').filter(Boolean)
|
|
798
|
+
.map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
|
|
799
|
+
} catch { decisions = []; }
|
|
800
|
+
let tagsDoc = null;
|
|
801
|
+
try {
|
|
802
|
+
const tagsRead = await readTags();
|
|
803
|
+
tagsDoc = tagsRead?.state ?? null;
|
|
804
|
+
} catch { tagsDoc = null; }
|
|
805
|
+
|
|
806
|
+
const undecided = contentUndecided({
|
|
807
|
+
events: allEvents,
|
|
808
|
+
tagsDoc,
|
|
809
|
+
decisions,
|
|
810
|
+
turnTagRows,
|
|
811
|
+
fromSeq,
|
|
812
|
+
toSeq,
|
|
813
|
+
});
|
|
814
|
+
if (undecided.undecided.length > 0) {
|
|
815
|
+
const sample = undecided.undecided.slice(0, 5).map((row) => row.seq + ' ' + row.head).join(' | ');
|
|
816
|
+
const error = new Error(`continuity.save: backfill rejected — ${undecided.undecided.length} undecided content events; decide them first. Sample: ${sample}`);
|
|
817
|
+
error.code = 't0-undecided';
|
|
818
|
+
throw error;
|
|
819
|
+
}
|
|
820
|
+
const untagged = untaggedTurnList({ events: allEvents, turnTagRows, fromSeq, toSeq });
|
|
821
|
+
if (untagged.length > 0) {
|
|
822
|
+
const sample = untagged.slice(0, 5).map((row) => `turn ${row.turn} (seq ${row.seq})`).join(', ');
|
|
823
|
+
const error = new Error(`continuity.save: backfill rejected — untagged turns in range: ${sample}`);
|
|
824
|
+
error.code = 'turn-untagged';
|
|
825
|
+
throw error;
|
|
826
|
+
}
|
|
827
|
+
let maxTurn = 0;
|
|
828
|
+
for (const event of allEvents) {
|
|
829
|
+
const turn = Number(event?.data?.turn);
|
|
830
|
+
if (Number.isInteger(turn) && turn > maxTurn) maxTurn = turn;
|
|
831
|
+
}
|
|
832
|
+
const gap = turnTagGap({ events: allEvents, turnTagRows, boundaryTurn: maxTurn - 1 });
|
|
833
|
+
if (gap.missing.length > 0) {
|
|
834
|
+
const sample = gap.missing.slice(0, 6).map((row) => 'turn ' + row.turn).join(', ');
|
|
835
|
+
const error = new Error(`continuity.save: backfill rejected — ${gap.missing.length} settled turns missing tags: ${sample}`);
|
|
836
|
+
error.code = 'tags-gap';
|
|
837
|
+
throw error;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const tCoverage = tagsDoc === null
|
|
841
|
+
? TAG_ZERO
|
|
842
|
+
: tagCoverageForRange(tagsDoc, Number(selected[0].seq), Number(selected[selected.length - 1].seq), { threads: state.threads }).label;
|
|
843
|
+
const segment = buildBackfillSegment(selected, {
|
|
844
|
+
sessionId: this.agent.session.id,
|
|
845
|
+
why: typeof why === 'string' && why.trim().length > 0 ? why.trim() : `历史回补 ${fromSeq}..${toSeq}`,
|
|
846
|
+
next: typeof next === 'string' && next.trim().length > 0 ? next.trim() : '检查这段原话',
|
|
847
|
+
residue,
|
|
848
|
+
tCoverage,
|
|
849
|
+
});
|
|
850
|
+
const mergedSegments = [...(target.segments ?? []), segment]
|
|
851
|
+
.sort((left, right) => Number(left.fromSeq) - Number(right.fromSeq));
|
|
852
|
+
const segmentIndex = mergedSegments.findIndex((entry) => entry === segment) + 1;
|
|
853
|
+
const latestContent = mergedSegments[mergedSegments.length - 1];
|
|
854
|
+
const contentUpdatedAt = latestContent?.spanEnd ?? latestContent?.at ?? new Date().toISOString();
|
|
855
|
+
await updateSession(this.sessionId, (current) => {
|
|
856
|
+
current.threads = current.threads.map((thread) => {
|
|
857
|
+
if (String(thread.id) !== String(target.id)) return { ...thread, active: false };
|
|
858
|
+
return { ...thread, segments: mergedSegments, active: true, updatedAt: contentUpdatedAt };
|
|
859
|
+
});
|
|
860
|
+
current.activeId = target.id;
|
|
861
|
+
return current;
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
return {
|
|
865
|
+
ok: true,
|
|
866
|
+
pending: false,
|
|
867
|
+
mode: 'backfill',
|
|
868
|
+
surfaceTouched: false,
|
|
869
|
+
adjusted: false,
|
|
870
|
+
receipt: `backfilled into ${target.id} · segment ${segmentIndex} · seq ${segment.fromSeq}..${segment.toSeq} · ${formatTokenCount(segment.tokens)} tokens(归档段,不是当轮折叠)`,
|
|
871
|
+
id: target.id,
|
|
872
|
+
segment: segmentIndex,
|
|
873
|
+
segments: mergedSegments.length,
|
|
874
|
+
active: true,
|
|
875
|
+
span: `${segment.spanStart} → ${segment.spanEnd}`,
|
|
876
|
+
tokens: segment.tokens,
|
|
877
|
+
seqStart: segment.fromSeq,
|
|
878
|
+
seqEnd: segment.toSeq,
|
|
879
|
+
count: segment.count,
|
|
880
|
+
requestedRange: { from: fromSeq, to: toSeq },
|
|
881
|
+
seqs: segment.seqs,
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async load(args = {}, signal) {
|
|
886
|
+
let id = optionalString(args.id);
|
|
887
|
+
const seqs = integerList(args.seqs, 'load.seqs');
|
|
888
|
+
if (id === undefined && seqs === undefined) {
|
|
889
|
+
throw new Error('continuity.load: id or seqs is required');
|
|
890
|
+
}
|
|
891
|
+
let threads = [];
|
|
892
|
+
if (id !== undefined) {
|
|
893
|
+
try {
|
|
894
|
+
threads = (await getSession(this.sessionId)).threads;
|
|
895
|
+
} catch {
|
|
896
|
+
threads = [];
|
|
897
|
+
}
|
|
898
|
+
id = canonicalTagId(id, threads);
|
|
899
|
+
const read = await readTagsMigrated(threads);
|
|
900
|
+
if (read.migration.changed) await this.writeTagsMarkdown(read.state);
|
|
901
|
+
}
|
|
902
|
+
// T0 是未分类桶,不是线头:没有关键词、没有主题、无法“拿起”。
|
|
903
|
+
// 看未分类内容请用 scan/find;要先归位到具体 T 或新建 T,再 load。
|
|
904
|
+
if (id === 'T0') {
|
|
905
|
+
return {
|
|
906
|
+
ok: false,
|
|
907
|
+
reason: 't0-is-bucket',
|
|
908
|
+
id,
|
|
909
|
+
injected: 0,
|
|
910
|
+
tokens: 0,
|
|
911
|
+
active: false,
|
|
912
|
+
hint: 'T0 是未分类桶,不能 load。用 scan/find 查看,或先 tag 归位到具体 T。',
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
const budget = budgetOf(args.budgetTokens);
|
|
916
|
+
const withReasoning = args.withReasoning === true;
|
|
917
|
+
const fromSeq = optionalInteger(args.fromSeq, 'load.fromSeq');
|
|
918
|
+
const toSeq = optionalInteger(args.toSeq, 'load.toSeq');
|
|
919
|
+
const select = integerList(args.select, 'load.select');
|
|
920
|
+
const first = optionalInteger(args.first, 'load.first');
|
|
921
|
+
const last = optionalInteger(args.last, 'load.last');
|
|
922
|
+
if ((first !== undefined && first < 0) || (last !== undefined && last < 0)) {
|
|
923
|
+
throw new Error('continuity.load: first/last must be >= 0');
|
|
924
|
+
}
|
|
925
|
+
if (first === 0 && last === 0) throw new Error('continuity.load: first and last cannot both be 0');
|
|
926
|
+
if (first !== undefined || last !== undefined) {
|
|
927
|
+
if (seqs !== undefined || fromSeq !== undefined || select !== undefined) {
|
|
928
|
+
throw new Error('continuity.load: first/last are only valid for a tag id');
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
if ((fromSeq === undefined) !== (toSeq === undefined)) {
|
|
932
|
+
throw new Error('continuity.load: pass both fromSeq and toSeq, or neither');
|
|
933
|
+
}
|
|
934
|
+
if (fromSeq !== undefined && fromSeq > toSeq) {
|
|
935
|
+
throw new Error('continuity.load: fromSeq must be <= toSeq');
|
|
936
|
+
}
|
|
937
|
+
if (seqs !== undefined) {
|
|
938
|
+
if (fromSeq !== undefined || select !== undefined) {
|
|
939
|
+
throw new Error('continuity.load: use seqs or fromSeq/toSeq/select, not both');
|
|
940
|
+
}
|
|
941
|
+
return this.loadSeqs({ id, seqs, budget, withReasoning }, signal);
|
|
942
|
+
}
|
|
943
|
+
const state = await getSession(this.sessionId);
|
|
944
|
+
const thread = state.threads.find((entry) => entry.id === id);
|
|
945
|
+
if (thread !== undefined) {
|
|
946
|
+
if (first !== undefined || last !== undefined) {
|
|
947
|
+
throw new Error('continuity.load: first/last are only valid for a tag id');
|
|
948
|
+
}
|
|
949
|
+
return this.loadThread({ id, thread, fromSeq, toSeq, select, budget, withReasoning }, signal);
|
|
950
|
+
}
|
|
951
|
+
if (fromSeq !== undefined || select !== undefined) {
|
|
952
|
+
throw new Error('continuity.load: fromSeq/toSeq/select are only valid for a thread id; use seqs for a tag');
|
|
953
|
+
}
|
|
954
|
+
return this.loadTag({ id, budget, first, last, withReasoning }, signal);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** Old thread load path: latest segment, range, or exact select; sets active. */
|
|
958
|
+
async loadThread({ id, thread, fromSeq, toSeq, select, budget, withReasoning = false }, signal) {
|
|
959
|
+
if (select !== undefined && fromSeq !== undefined) {
|
|
960
|
+
const outside = select.filter((seq) => seq < fromSeq || seq > toSeq);
|
|
961
|
+
if (outside.length > 0) {
|
|
962
|
+
return {
|
|
963
|
+
ok: false,
|
|
964
|
+
reason: 'select-outside-range',
|
|
965
|
+
id,
|
|
966
|
+
seqs: outside,
|
|
967
|
+
injected: 0,
|
|
968
|
+
tokens: 0,
|
|
969
|
+
active: false,
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
signal?.throwIfAborted?.();
|
|
974
|
+
|
|
975
|
+
let snapshot;
|
|
976
|
+
try {
|
|
977
|
+
snapshot = await this.sessionQuery.readSession(this.agent.session.id);
|
|
978
|
+
} catch (error) {
|
|
979
|
+
recordError(error?.message ?? String(error), 'thread-load');
|
|
980
|
+
snapshot = null;
|
|
981
|
+
}
|
|
982
|
+
const events = Array.isArray(snapshot?.events) ? snapshot.events : [];
|
|
983
|
+
const bySeq = new Map(events.map((event) => [Number(event.seq), event]));
|
|
984
|
+
const threadSegments = Array.isArray(thread.segments) && thread.segments.length > 0
|
|
985
|
+
? thread.segments
|
|
986
|
+
: [];
|
|
987
|
+
const latest = threadSegments[threadSegments.length - 1] ?? thread;
|
|
988
|
+
const segmentIndex = Math.max(1, threadSegments.length);
|
|
989
|
+
const segmentCount = Math.max(1, threadSegments.length);
|
|
990
|
+
const threadSeqs = new Set(threadSegments.flatMap((segment) => (
|
|
991
|
+
Array.isArray(segment.seqs) ? segment.seqs.map(Number) : []
|
|
992
|
+
)));
|
|
993
|
+
if (threadSeqs.size === 0) {
|
|
994
|
+
for (const seq of latest.seqs ?? []) threadSeqs.add(Number(seq));
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
let selected = [];
|
|
998
|
+
let range = null;
|
|
999
|
+
if (select !== undefined) {
|
|
1000
|
+
const notInThread = select.filter((seq) => !threadSeqs.has(seq));
|
|
1001
|
+
if (notInThread.length > 0) {
|
|
1002
|
+
return {
|
|
1003
|
+
ok: false,
|
|
1004
|
+
reason: 'seq-not-in-thread',
|
|
1005
|
+
id,
|
|
1006
|
+
seqs: notInThread,
|
|
1007
|
+
injected: 0,
|
|
1008
|
+
tokens: 0,
|
|
1009
|
+
active: false,
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
const missingSeq = select.filter((seq) => !bySeq.has(seq));
|
|
1013
|
+
if (missingSeq.length > 0) {
|
|
1014
|
+
return {
|
|
1015
|
+
ok: false,
|
|
1016
|
+
reason: 'unknown-seq',
|
|
1017
|
+
id,
|
|
1018
|
+
seqs: missingSeq,
|
|
1019
|
+
injected: 0,
|
|
1020
|
+
tokens: 0,
|
|
1021
|
+
active: false,
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
for (const seq of select) {
|
|
1025
|
+
const event = bySeq.get(seq);
|
|
1026
|
+
const text = quoteEventText(event);
|
|
1027
|
+
if (text.length === 0) continue;
|
|
1028
|
+
selected.push({ seq, event, role: eventRole(event), text, tokens: estimateTextTokens(text) });
|
|
1029
|
+
}
|
|
1030
|
+
if (fromSeq !== undefined) range = { fromSeq, toSeq };
|
|
1031
|
+
} else if (fromSeq !== undefined) {
|
|
1032
|
+
const rangeSeqs = [...threadSeqs]
|
|
1033
|
+
.filter((seq) => seq >= fromSeq && seq <= toSeq)
|
|
1034
|
+
.sort((left, right) => left - right);
|
|
1035
|
+
if (rangeSeqs.length === 0) {
|
|
1036
|
+
return {
|
|
1037
|
+
ok: false,
|
|
1038
|
+
reason: 'empty-range',
|
|
1039
|
+
id,
|
|
1040
|
+
injected: 0,
|
|
1041
|
+
tokens: 0,
|
|
1042
|
+
active: false,
|
|
1043
|
+
range: { fromSeq, toSeq },
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
const result = selectLoadSegment({
|
|
1047
|
+
seqs: rangeSeqs,
|
|
1048
|
+
bySeq,
|
|
1049
|
+
budget: withReasoning ? Number.MAX_SAFE_INTEGER : budget,
|
|
1050
|
+
});
|
|
1051
|
+
if (result.missing) {
|
|
1052
|
+
return {
|
|
1053
|
+
ok: false,
|
|
1054
|
+
reason: 'raw-missing',
|
|
1055
|
+
id,
|
|
1056
|
+
injected: 0,
|
|
1057
|
+
tokens: 0,
|
|
1058
|
+
active: false,
|
|
1059
|
+
range: { fromSeq, toSeq },
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
selected = result.selected;
|
|
1063
|
+
range = { fromSeq, toSeq };
|
|
1064
|
+
} else {
|
|
1065
|
+
const seqs = Array.isArray(latest.seqs) && latest.seqs.length > 0
|
|
1066
|
+
? latest.seqs
|
|
1067
|
+
: [latest.fromSeq ?? thread.seqStart, latest.toSeq ?? thread.seqEnd];
|
|
1068
|
+
const result = selectLoadSegment({
|
|
1069
|
+
seqs,
|
|
1070
|
+
bySeq,
|
|
1071
|
+
budget: withReasoning ? Number.MAX_SAFE_INTEGER : budget,
|
|
1072
|
+
});
|
|
1073
|
+
if (!result.missing) selected = result.selected;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
const picked = selected.length;
|
|
1077
|
+
let reasoned = null;
|
|
1078
|
+
if (withReasoning) {
|
|
1079
|
+
reasoned = applyReasoningBudget({
|
|
1080
|
+
selected,
|
|
1081
|
+
bySeq,
|
|
1082
|
+
budget: REASONING_LOAD_BUDGET,
|
|
1083
|
+
});
|
|
1084
|
+
selected = reasoned.selected;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const segs = selected.map((entry) => Number(entry.seq));
|
|
1088
|
+
const tokens = withReasoning
|
|
1089
|
+
? reasoned.tokens
|
|
1090
|
+
: selected.reduce((sum, entry) => sum + (Number(entry.tokens) || 0), 0);
|
|
1091
|
+
const missing = snapshot === null || picked === 0;
|
|
1092
|
+
const folds = (await readFolds(this.sessionId)).folds;
|
|
1093
|
+
const folded = foldedInfo(folds, selected);
|
|
1094
|
+
const note = select !== undefined
|
|
1095
|
+
? `${range === null ? '' : `范围 ${range.fromSeq}..${range.toSeq} · `}选择 ${select.length} 条`
|
|
1096
|
+
: range === null ? undefined : `范围 ${range.fromSeq}..${range.toSeq}`;
|
|
1097
|
+
const reasoningInfo = withReasoning
|
|
1098
|
+
? { reasoningInjected: reasoned.reasoningInjected, skipped: reasoned.skipped }
|
|
1099
|
+
: undefined;
|
|
1100
|
+
const text = missing
|
|
1101
|
+
? threadLoadIndexOnly(thread, segmentIndex, segmentCount, note, folded.foldedNote)
|
|
1102
|
+
: threadLoadText(
|
|
1103
|
+
thread,
|
|
1104
|
+
latest,
|
|
1105
|
+
withReasoning ? quoteSelectedWithReasoning(selected) : quoteSelected(selected),
|
|
1106
|
+
segmentIndex,
|
|
1107
|
+
segmentCount,
|
|
1108
|
+
note,
|
|
1109
|
+
folded.foldedNote,
|
|
1110
|
+
reasoningInfo,
|
|
1111
|
+
);
|
|
1112
|
+
const injected = missing ? 0 : selected.length;
|
|
1113
|
+
const outputTokens = missing ? 0 : tokens;
|
|
1114
|
+
const outputBudget = withReasoning ? REASONING_LOAD_BUDGET : budget;
|
|
1115
|
+
const reasoningFields = withReasoning
|
|
1116
|
+
? {
|
|
1117
|
+
reasoningInjected: reasoned.reasoningInjected,
|
|
1118
|
+
reasoningTokens: reasoned.reasoningTokens,
|
|
1119
|
+
skipped: reasoned.skipped,
|
|
1120
|
+
}
|
|
1121
|
+
: {};
|
|
1122
|
+
|
|
1123
|
+
await updateSession(this.sessionId, (current) => {
|
|
1124
|
+
current.threads = current.threads.map((entry) => ({
|
|
1125
|
+
...entry,
|
|
1126
|
+
active: entry.id === id,
|
|
1127
|
+
}));
|
|
1128
|
+
current.activeId = id;
|
|
1129
|
+
current.pending = {
|
|
1130
|
+
kind: 'load',
|
|
1131
|
+
id,
|
|
1132
|
+
text,
|
|
1133
|
+
injected,
|
|
1134
|
+
tokens: outputTokens,
|
|
1135
|
+
rawMissing: missing,
|
|
1136
|
+
segment: segmentIndex,
|
|
1137
|
+
segments: segmentCount,
|
|
1138
|
+
segs,
|
|
1139
|
+
folded: folded.folded,
|
|
1140
|
+
visible: folded.visible,
|
|
1141
|
+
...(range === null ? {} : { range }),
|
|
1142
|
+
...(select === undefined ? {} : { select }),
|
|
1143
|
+
mode: 'thread',
|
|
1144
|
+
...reasoningFields,
|
|
1145
|
+
};
|
|
1146
|
+
return current;
|
|
1147
|
+
});
|
|
1148
|
+
if (missing) {
|
|
1149
|
+
recordError(`continuity.load: raw missing for thread ${id}; index only`, 'thread-load-raw');
|
|
1150
|
+
return {
|
|
1151
|
+
ok: false,
|
|
1152
|
+
reason: 'raw-missing',
|
|
1153
|
+
id,
|
|
1154
|
+
mode: 'thread',
|
|
1155
|
+
injected: 0,
|
|
1156
|
+
tokens: 0,
|
|
1157
|
+
active: true,
|
|
1158
|
+
pending: true,
|
|
1159
|
+
budget: outputBudget,
|
|
1160
|
+
segment: segmentIndex,
|
|
1161
|
+
segments: segmentCount,
|
|
1162
|
+
segs: [],
|
|
1163
|
+
folded: [],
|
|
1164
|
+
visible: [],
|
|
1165
|
+
...(range === null ? {} : { range }),
|
|
1166
|
+
...(select === undefined ? {} : { select }),
|
|
1167
|
+
...reasoningFields,
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
return {
|
|
1171
|
+
ok: true,
|
|
1172
|
+
pending: true,
|
|
1173
|
+
id,
|
|
1174
|
+
mode: 'thread',
|
|
1175
|
+
injected,
|
|
1176
|
+
tokens: outputTokens,
|
|
1177
|
+
active: true,
|
|
1178
|
+
budget: outputBudget,
|
|
1179
|
+
segment: segmentIndex,
|
|
1180
|
+
segments: segmentCount,
|
|
1181
|
+
segs,
|
|
1182
|
+
folded: folded.folded,
|
|
1183
|
+
visible: folded.visible,
|
|
1184
|
+
...(range === null ? {} : { range }),
|
|
1185
|
+
...(select === undefined ? {} : { select }),
|
|
1186
|
+
...reasoningFields,
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** Tag load: find(id), first N + last M (default 3/5), trim only if over budget. */
|
|
1191
|
+
async loadTag({ id, budget, first, last, withReasoning = false }, signal) {
|
|
1192
|
+
const firstCount = first === undefined ? 3 : first;
|
|
1193
|
+
const lastCount = last === undefined ? 5 : last;
|
|
1194
|
+
if (firstCount < 0 || lastCount < 0) throw new Error('continuity.load: first/last must be >= 0');
|
|
1195
|
+
if (firstCount === 0 && lastCount === 0) throw new Error('continuity.load: first and last cannot both be 0');
|
|
1196
|
+
const read = await readTagsWithWarning();
|
|
1197
|
+
const tags = read.state;
|
|
1198
|
+
signal?.throwIfAborted?.();
|
|
1199
|
+
let snapshot;
|
|
1200
|
+
try {
|
|
1201
|
+
snapshot = await this.sessionQuery.readSession(this.agent.session.id);
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
recordError(error?.message ?? String(error), 'tag-load');
|
|
1204
|
+
snapshot = null;
|
|
1205
|
+
}
|
|
1206
|
+
const events = Array.isArray(snapshot?.events) ? snapshot.events : [];
|
|
1207
|
+
const found = findTagSeqs({ tags, id, events, limit: null });
|
|
1208
|
+
if (found.count === 0) {
|
|
1209
|
+
return { ok: false, reason: 'empty-tag', id, mode: 'tag', injected: 0, tokens: 0, active: false, segs: [] };
|
|
1210
|
+
}
|
|
1211
|
+
const bySeq = new Map(events.map((event) => [Number(event.seq), event]));
|
|
1212
|
+
const targetSeqs = [];
|
|
1213
|
+
if (firstCount > 0) targetSeqs.push(...found.seqs.slice(0, firstCount));
|
|
1214
|
+
if (lastCount > 0) targetSeqs.push(...found.seqs.slice(-lastCount));
|
|
1215
|
+
const target = [...new Set(targetSeqs)].sort((left, right) => left - right);
|
|
1216
|
+
const selected = [];
|
|
1217
|
+
for (const seq of target) {
|
|
1218
|
+
const event = bySeq.get(seq);
|
|
1219
|
+
if (event === undefined) continue;
|
|
1220
|
+
const text = quoteEventText(event);
|
|
1221
|
+
if (text.length === 0) continue;
|
|
1222
|
+
selected.push({ seq, event, role: eventRole(event), text, tokens: estimateTextTokens(text) });
|
|
1223
|
+
}
|
|
1224
|
+
const picked = selected.length;
|
|
1225
|
+
let reasoned = null;
|
|
1226
|
+
let fitted;
|
|
1227
|
+
if (withReasoning) {
|
|
1228
|
+
reasoned = applyReasoningBudget({
|
|
1229
|
+
selected,
|
|
1230
|
+
bySeq,
|
|
1231
|
+
budget: REASONING_LOAD_BUDGET,
|
|
1232
|
+
});
|
|
1233
|
+
fitted = reasoned.selected;
|
|
1234
|
+
} else {
|
|
1235
|
+
fitted = trimSelectedToBudget(selected, budget);
|
|
1236
|
+
}
|
|
1237
|
+
const segs = fitted.map((entry) => Number(entry.seq));
|
|
1238
|
+
const tokens = withReasoning
|
|
1239
|
+
? reasoned.tokens
|
|
1240
|
+
: fitted.reduce((sum, entry) => sum + (Number(entry.tokens) || 0), 0);
|
|
1241
|
+
const missing = snapshot === null || picked === 0;
|
|
1242
|
+
const folds = (await readFolds(this.sessionId)).folds;
|
|
1243
|
+
const folded = foldedInfo(folds, fitted);
|
|
1244
|
+
let threads = [];
|
|
1245
|
+
try {
|
|
1246
|
+
threads = (await getSession(this.sessionId)).threads;
|
|
1247
|
+
} catch {
|
|
1248
|
+
threads = [];
|
|
1249
|
+
}
|
|
1250
|
+
const tagRow = tagsView(tags, { threads }).tags.find((entry) => entry.id === id);
|
|
1251
|
+
const explicitLabel = tags.tags[id]?.label;
|
|
1252
|
+
const hasLabel = typeof explicitLabel === 'string' && explicitLabel.length > 0 && explicitLabel !== id;
|
|
1253
|
+
const display = tagRow?.display
|
|
1254
|
+
?? (hasLabel ? explicitLabel : (id.length > 8 ? id.slice(0, 8) : id));
|
|
1255
|
+
const reasoningFields = withReasoning
|
|
1256
|
+
? {
|
|
1257
|
+
reasoningInjected: reasoned.reasoningInjected,
|
|
1258
|
+
reasoningTokens: reasoned.reasoningTokens,
|
|
1259
|
+
skipped: reasoned.skipped,
|
|
1260
|
+
}
|
|
1261
|
+
: {};
|
|
1262
|
+
const text = selectionLoadText({
|
|
1263
|
+
id,
|
|
1264
|
+
label: hasLabel ? explicitLabel : null,
|
|
1265
|
+
display,
|
|
1266
|
+
count: segs.length,
|
|
1267
|
+
first: firstCount,
|
|
1268
|
+
last: lastCount,
|
|
1269
|
+
foldedNote: folded.foldedNote,
|
|
1270
|
+
withReasoning,
|
|
1271
|
+
reasoningInjected: withReasoning ? reasoned.reasoningInjected : 0,
|
|
1272
|
+
skipped: withReasoning ? reasoned.skipped : 0,
|
|
1273
|
+
quote: withReasoning ? quoteSelectedWithReasoning(fitted) : quoteSelected(fitted),
|
|
1274
|
+
indexOnly: missing,
|
|
1275
|
+
mode: 'tag',
|
|
1276
|
+
});
|
|
1277
|
+
const injected = missing ? 0 : fitted.length;
|
|
1278
|
+
const outputTokens = missing ? 0 : tokens;
|
|
1279
|
+
const outputBudget = withReasoning ? REASONING_LOAD_BUDGET : budget;
|
|
1280
|
+
|
|
1281
|
+
await updateSession(this.sessionId, (current) => {
|
|
1282
|
+
current.pending = {
|
|
1283
|
+
kind: 'load',
|
|
1284
|
+
id,
|
|
1285
|
+
text,
|
|
1286
|
+
injected,
|
|
1287
|
+
tokens: outputTokens,
|
|
1288
|
+
rawMissing: missing,
|
|
1289
|
+
segs,
|
|
1290
|
+
folded: folded.folded,
|
|
1291
|
+
visible: folded.visible,
|
|
1292
|
+
mode: 'tag',
|
|
1293
|
+
tagCount: found.count,
|
|
1294
|
+
...reasoningFields,
|
|
1295
|
+
};
|
|
1296
|
+
return current;
|
|
1297
|
+
});
|
|
1298
|
+
if (missing) {
|
|
1299
|
+
recordError(`continuity.load: raw missing for tag ${id}; index only`, 'tag-load-raw');
|
|
1300
|
+
return {
|
|
1301
|
+
ok: false,
|
|
1302
|
+
reason: 'raw-missing',
|
|
1303
|
+
id,
|
|
1304
|
+
mode: 'tag',
|
|
1305
|
+
injected: 0,
|
|
1306
|
+
tokens: 0,
|
|
1307
|
+
active: false,
|
|
1308
|
+
pending: true,
|
|
1309
|
+
budget: outputBudget,
|
|
1310
|
+
segs: [],
|
|
1311
|
+
folded: [],
|
|
1312
|
+
visible: [],
|
|
1313
|
+
tagCount: found.count,
|
|
1314
|
+
...reasoningFields,
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
return {
|
|
1318
|
+
ok: true,
|
|
1319
|
+
pending: true,
|
|
1320
|
+
id,
|
|
1321
|
+
mode: 'tag',
|
|
1322
|
+
injected,
|
|
1323
|
+
tokens: outputTokens,
|
|
1324
|
+
active: false,
|
|
1325
|
+
budget: outputBudget,
|
|
1326
|
+
segs,
|
|
1327
|
+
folded: folded.folded,
|
|
1328
|
+
visible: folded.visible,
|
|
1329
|
+
tagCount: found.count,
|
|
1330
|
+
...reasoningFields,
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/** Exact seqs load: only these, no budget tail truncation, no active change. */
|
|
1335
|
+
async loadSeqs({ id, seqs, budget, withReasoning = false }, signal) {
|
|
1336
|
+
signal?.throwIfAborted?.();
|
|
1337
|
+
let snapshot;
|
|
1338
|
+
try {
|
|
1339
|
+
snapshot = await this.sessionQuery.readSession(this.agent.session.id);
|
|
1340
|
+
} catch (error) {
|
|
1341
|
+
recordError(error?.message ?? String(error), 'seq-load');
|
|
1342
|
+
snapshot = null;
|
|
1343
|
+
}
|
|
1344
|
+
const events = Array.isArray(snapshot?.events) ? snapshot.events : [];
|
|
1345
|
+
const bySeq = new Map(events.map((event) => [Number(event.seq), event]));
|
|
1346
|
+
const missingSeq = seqs.filter((seq) => !bySeq.has(seq));
|
|
1347
|
+
if (missingSeq.length > 0) {
|
|
1348
|
+
return {
|
|
1349
|
+
ok: false,
|
|
1350
|
+
reason: 'unknown-seq',
|
|
1351
|
+
id: id ?? null,
|
|
1352
|
+
mode: 'seqs',
|
|
1353
|
+
seqs: missingSeq,
|
|
1354
|
+
injected: 0,
|
|
1355
|
+
tokens: 0,
|
|
1356
|
+
active: false,
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
const selected = [];
|
|
1360
|
+
for (const seq of seqs) {
|
|
1361
|
+
const event = bySeq.get(seq);
|
|
1362
|
+
const text = quoteEventText(event);
|
|
1363
|
+
if (text.length === 0) continue;
|
|
1364
|
+
selected.push({ seq, event, role: eventRole(event), text, tokens: estimateTextTokens(text) });
|
|
1365
|
+
}
|
|
1366
|
+
const picked = selected.length;
|
|
1367
|
+
let reasoned = null;
|
|
1368
|
+
if (withReasoning) {
|
|
1369
|
+
reasoned = applyReasoningBudget({
|
|
1370
|
+
selected,
|
|
1371
|
+
bySeq,
|
|
1372
|
+
budget: REASONING_LOAD_BUDGET,
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
const fitted = withReasoning ? reasoned.selected : selected;
|
|
1376
|
+
const segs = fitted.map((entry) => Number(entry.seq));
|
|
1377
|
+
const tokens = withReasoning
|
|
1378
|
+
? reasoned.tokens
|
|
1379
|
+
: fitted.reduce((sum, entry) => sum + (Number(entry.tokens) || 0), 0);
|
|
1380
|
+
const missing = snapshot === null || picked === 0;
|
|
1381
|
+
const folds = (await readFolds(this.sessionId)).folds;
|
|
1382
|
+
const folded = foldedInfo(folds, fitted);
|
|
1383
|
+
const reasoningFields = withReasoning
|
|
1384
|
+
? {
|
|
1385
|
+
reasoningInjected: reasoned.reasoningInjected,
|
|
1386
|
+
reasoningTokens: reasoned.reasoningTokens,
|
|
1387
|
+
skipped: reasoned.skipped,
|
|
1388
|
+
}
|
|
1389
|
+
: {};
|
|
1390
|
+
const text = selectionLoadText({
|
|
1391
|
+
id: id ?? null,
|
|
1392
|
+
label: id ?? null,
|
|
1393
|
+
count: segs.length,
|
|
1394
|
+
quote: withReasoning ? quoteSelectedWithReasoning(fitted) : quoteSelected(fitted),
|
|
1395
|
+
indexOnly: missing,
|
|
1396
|
+
mode: 'seqs',
|
|
1397
|
+
foldedNote: folded.foldedNote,
|
|
1398
|
+
withReasoning,
|
|
1399
|
+
reasoningInjected: withReasoning ? reasoned.reasoningInjected : 0,
|
|
1400
|
+
skipped: withReasoning ? reasoned.skipped : 0,
|
|
1401
|
+
});
|
|
1402
|
+
const injected = missing ? 0 : fitted.length;
|
|
1403
|
+
const outputTokens = missing ? 0 : tokens;
|
|
1404
|
+
const outputBudget = withReasoning ? REASONING_LOAD_BUDGET : budget;
|
|
1405
|
+
|
|
1406
|
+
await updateSession(this.sessionId, (current) => {
|
|
1407
|
+
current.pending = {
|
|
1408
|
+
kind: 'load',
|
|
1409
|
+
id: id ?? null,
|
|
1410
|
+
text,
|
|
1411
|
+
injected,
|
|
1412
|
+
tokens: outputTokens,
|
|
1413
|
+
rawMissing: missing,
|
|
1414
|
+
segs,
|
|
1415
|
+
folded: folded.folded,
|
|
1416
|
+
visible: folded.visible,
|
|
1417
|
+
mode: 'seqs',
|
|
1418
|
+
...reasoningFields,
|
|
1419
|
+
};
|
|
1420
|
+
return current;
|
|
1421
|
+
});
|
|
1422
|
+
if (missing) {
|
|
1423
|
+
return {
|
|
1424
|
+
ok: false,
|
|
1425
|
+
reason: 'raw-missing',
|
|
1426
|
+
id: id ?? null,
|
|
1427
|
+
mode: 'seqs',
|
|
1428
|
+
injected: 0,
|
|
1429
|
+
tokens: 0,
|
|
1430
|
+
active: false,
|
|
1431
|
+
pending: true,
|
|
1432
|
+
budget: outputBudget,
|
|
1433
|
+
segs: [],
|
|
1434
|
+
folded: [],
|
|
1435
|
+
visible: [],
|
|
1436
|
+
...reasoningFields,
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
return {
|
|
1440
|
+
ok: true,
|
|
1441
|
+
pending: true,
|
|
1442
|
+
id: id ?? null,
|
|
1443
|
+
mode: 'seqs',
|
|
1444
|
+
injected,
|
|
1445
|
+
tokens: outputTokens,
|
|
1446
|
+
active: false,
|
|
1447
|
+
budget: outputBudget,
|
|
1448
|
+
segs,
|
|
1449
|
+
folded: folded.folded,
|
|
1450
|
+
visible: folded.visible,
|
|
1451
|
+
...reasoningFields,
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/** Regenerate the tags.md view from tags.json; never read it back. */
|
|
1456
|
+
async writeTagsMarkdown(tagsState) {
|
|
1457
|
+
try {
|
|
1458
|
+
const sessionState = await getSession(this.sessionId);
|
|
1459
|
+
let maxSeq = 0;
|
|
1460
|
+
try {
|
|
1461
|
+
const snapshot = await this.sessionQuery.readSession(this.agent.session.id);
|
|
1462
|
+
for (const event of Array.isArray(snapshot?.events) ? snapshot.events : []) {
|
|
1463
|
+
const seq = Number(event?.seq);
|
|
1464
|
+
if (Number.isInteger(seq) && seq > maxSeq) maxSeq = seq;
|
|
1465
|
+
}
|
|
1466
|
+
} catch { /* tags view still useful without the tail */ }
|
|
1467
|
+
const markdown = renderTagsMd(tagsState, {
|
|
1468
|
+
threads: sessionState.threads,
|
|
1469
|
+
activeId: sessionState.activeId,
|
|
1470
|
+
maxSeq,
|
|
1471
|
+
});
|
|
1472
|
+
await writeTagsMd(markdown);
|
|
1473
|
+
} catch (error) {
|
|
1474
|
+
recordError(error?.message ?? String(error), 'tags-md');
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
async find(args = {}) {
|
|
1479
|
+
const requestedId = requireString(args.id, 'find.id');
|
|
1480
|
+
const after = optionalInteger(args.after, 'find.after');
|
|
1481
|
+
const before = optionalInteger(args.before, 'find.before');
|
|
1482
|
+
const limit = optionalInteger(args.limit, 'find.limit') ?? 20;
|
|
1483
|
+
let threads = [];
|
|
1484
|
+
try {
|
|
1485
|
+
threads = (await getSession(this.sessionId)).threads;
|
|
1486
|
+
} catch {
|
|
1487
|
+
threads = [];
|
|
1488
|
+
}
|
|
1489
|
+
const read = await readTagsMigrated(threads);
|
|
1490
|
+
if (read.migration.changed) await this.writeTagsMarkdown(read.state);
|
|
1491
|
+
const id = canonicalTagId(requestedId, threads);
|
|
1492
|
+
let snapshot;
|
|
1493
|
+
try {
|
|
1494
|
+
snapshot = await this.sessionQuery.readSession(this.agent.session.id);
|
|
1495
|
+
} catch (error) {
|
|
1496
|
+
recordError(error?.message ?? String(error), 'tag-find');
|
|
1497
|
+
snapshot = null;
|
|
1498
|
+
}
|
|
1499
|
+
const events = Array.isArray(snapshot?.events) ? snapshot.events : [];
|
|
1500
|
+
const folds = (await readFolds(this.sessionId)).folds;
|
|
1501
|
+
if (id === TAG_ZERO) {
|
|
1502
|
+
const t0 = findT0Ranges({ tags: read.state, events, after, before, limit });
|
|
1503
|
+
return { ...t0, visible: [], folded: [], covered: t0.count };
|
|
1504
|
+
}
|
|
1505
|
+
const found = findTagSeqs({ tags: read.state, id, events, after, before, limit });
|
|
1506
|
+
const coverage = findResolvedRanges({ tags: read.state, id, events, after, before, limit });
|
|
1507
|
+
const visible = [];
|
|
1508
|
+
const folded = [];
|
|
1509
|
+
for (const seq of found.seqs) {
|
|
1510
|
+
if (foldForSeq(folds, seq) === null) visible.push(seq);
|
|
1511
|
+
else folded.push(seq);
|
|
1512
|
+
}
|
|
1513
|
+
return {
|
|
1514
|
+
...found,
|
|
1515
|
+
count: coverage.count,
|
|
1516
|
+
visible,
|
|
1517
|
+
folded,
|
|
1518
|
+
ranges: coverage.ranges,
|
|
1519
|
+
covered: coverage.count,
|
|
1520
|
+
rangeFirst: coverage.first,
|
|
1521
|
+
rangeLast: coverage.last,
|
|
1522
|
+
rangeSpan: coverage.span,
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/** Apply a tag alias to the matching thread record, if there is one. */
|
|
1527
|
+
async applyTagThreadAlias(id, alias) {
|
|
1528
|
+
if (alias === undefined) return false;
|
|
1529
|
+
try {
|
|
1530
|
+
let changed = false;
|
|
1531
|
+
await updateSession(this.sessionId, (current) => {
|
|
1532
|
+
const thread = current.threads.find((entry) => entry.id === id);
|
|
1533
|
+
if (thread !== undefined) {
|
|
1534
|
+
thread.alias = alias;
|
|
1535
|
+
changed = true;
|
|
1536
|
+
}
|
|
1537
|
+
return current;
|
|
1538
|
+
});
|
|
1539
|
+
return changed;
|
|
1540
|
+
} catch {
|
|
1541
|
+
return false;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/** Updated tag row for a metadata-only tag call. */
|
|
1546
|
+
async tagRow(tagsState, id) {
|
|
1547
|
+
let threads = [];
|
|
1548
|
+
let activeId = null;
|
|
1549
|
+
try {
|
|
1550
|
+
const state = await getSession(this.sessionId);
|
|
1551
|
+
threads = state.threads;
|
|
1552
|
+
activeId = state.activeId;
|
|
1553
|
+
} catch {
|
|
1554
|
+
threads = [];
|
|
1555
|
+
activeId = null;
|
|
1556
|
+
}
|
|
1557
|
+
const row = tagsView(tagsState, { threads, activeId }).tags.find((entry) => entry.id === id);
|
|
1558
|
+
if (row === undefined) return { id };
|
|
1559
|
+
const th = threads.find((x) => String(x.id) === String(row.id)
|
|
1560
|
+
|| String(x.alias ?? '') === String(row.alias ?? '')
|
|
1561
|
+
|| String(x.id) === String(row.threadId ?? ''));
|
|
1562
|
+
return th === undefined
|
|
1563
|
+
? { ...row }
|
|
1564
|
+
: { ...row, label: th.label ?? row.label, keywords: Array.isArray(th.keywords) ? th.keywords : row.keywords };
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
async tag(args = {}) {
|
|
1568
|
+
let id = requireString(args.id, 'tag.id');
|
|
1569
|
+
const seqs = integerList(args.seqs, 'tag.seqs');
|
|
1570
|
+
const fromSeq = optionalInteger(args.fromSeq, 'tag.fromSeq');
|
|
1571
|
+
const toSeq = optionalInteger(args.toSeq, 'tag.toSeq');
|
|
1572
|
+
const label = optionalString(args.label);
|
|
1573
|
+
const alias = optionalString(args.alias);
|
|
1574
|
+
const keywords = Array.isArray(args.keywords)
|
|
1575
|
+
? [...new Set(args.keywords.map((item) => String(item).trim()).filter(Boolean))]
|
|
1576
|
+
: undefined;
|
|
1577
|
+
let threads = [];
|
|
1578
|
+
try {
|
|
1579
|
+
threads = (await getSession(this.sessionId)).threads;
|
|
1580
|
+
} catch {
|
|
1581
|
+
threads = [];
|
|
1582
|
+
}
|
|
1583
|
+
id = canonicalTagId(id, threads);
|
|
1584
|
+
const migrated = await readTagsMigrated(threads);
|
|
1585
|
+
if (migrated.migration.changed) await this.writeTagsMarkdown(migrated.state);
|
|
1586
|
+
const hasRange = fromSeq !== undefined || toSeq !== undefined;
|
|
1587
|
+
const hasMetadata = label !== undefined || alias !== undefined || keywords !== undefined;
|
|
1588
|
+
if (seqs !== undefined && hasRange) {
|
|
1589
|
+
throw new Error('continuity.tag: use seqs or fromSeq/toSeq, not both');
|
|
1590
|
+
}
|
|
1591
|
+
if (seqs === undefined && !hasRange && !hasMetadata) {
|
|
1592
|
+
throw new Error('continuity.tag: seqs, fromSeq/toSeq, or a metadata field is required');
|
|
1593
|
+
}
|
|
1594
|
+
const resolvedId = resolveTagIdentity(id, migrated.state, threads);
|
|
1595
|
+
if (resolvedId === null && seqs === undefined && !hasRange) {
|
|
1596
|
+
return { ok: false, reason: 'unknown-tag-id', id };
|
|
1597
|
+
}
|
|
1598
|
+
if (resolvedId !== null) id = resolvedId;
|
|
1599
|
+
if (hasRange) {
|
|
1600
|
+
if (fromSeq === undefined || toSeq === undefined) throw new Error('continuity.tag: pass both fromSeq and toSeq');
|
|
1601
|
+
if (fromSeq > toSeq) throw new Error('continuity.tag: fromSeq must be <= toSeq');
|
|
1602
|
+
await readTagsWithWarning();
|
|
1603
|
+
const result = await updateTags((tags) => {
|
|
1604
|
+
const existing = tags.tags[id];
|
|
1605
|
+
if (existing === undefined) tags.tags[id] = { label: label ?? id };
|
|
1606
|
+
else if (label !== undefined) existing.label = label;
|
|
1607
|
+
if (keywords !== undefined) tags.tags[id].keywords = keywords;
|
|
1608
|
+
if (alias !== undefined) tags.tags[id].alias = alias;
|
|
1609
|
+
tags.runs.push([fromSeq, toSeq, id]);
|
|
1610
|
+
tags.updatedAt = new Date().toISOString();
|
|
1611
|
+
return tags;
|
|
1612
|
+
});
|
|
1613
|
+
await this.applyTagThreadAlias(id, alias);
|
|
1614
|
+
await this.writeTagsMarkdown(result.state);
|
|
1615
|
+
return {
|
|
1616
|
+
ok: true,
|
|
1617
|
+
id,
|
|
1618
|
+
...(label === undefined ? {} : { label }),
|
|
1619
|
+
...(alias === undefined ? {} : { alias }),
|
|
1620
|
+
...(keywords === undefined ? {} : { keywords }),
|
|
1621
|
+
range: [fromSeq, toSeq],
|
|
1622
|
+
count: toSeq - fromSeq + 1,
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
if (seqs !== undefined) {
|
|
1626
|
+
await readTagsWithWarning();
|
|
1627
|
+
const result = await updateTags((tags) => tagSeqs(tags, { seqs, id, label, keywords, alias }).state);
|
|
1628
|
+
await this.applyTagThreadAlias(id, alias);
|
|
1629
|
+
await this.writeTagsMarkdown(result.state);
|
|
1630
|
+
return {
|
|
1631
|
+
ok: true,
|
|
1632
|
+
id,
|
|
1633
|
+
...(label === undefined ? {} : { label }),
|
|
1634
|
+
...(alias === undefined ? {} : { alias }),
|
|
1635
|
+
...(keywords === undefined ? {} : { keywords }),
|
|
1636
|
+
seqs,
|
|
1637
|
+
count: seqs.length,
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
// Metadata-only: no seq ownership changes, no run/override.
|
|
1641
|
+
await readTagsWithWarning();
|
|
1642
|
+
const result = await updateTags((tags) => {
|
|
1643
|
+
const existing = tags.tags[id];
|
|
1644
|
+
if (existing === undefined) tags.tags[id] = { label: label ?? id };
|
|
1645
|
+
else if (label !== undefined) existing.label = label;
|
|
1646
|
+
if (keywords !== undefined) tags.tags[id].keywords = keywords;
|
|
1647
|
+
if (alias !== undefined) tags.tags[id].alias = alias;
|
|
1648
|
+
tags.updatedAt = new Date().toISOString();
|
|
1649
|
+
return tags;
|
|
1650
|
+
});
|
|
1651
|
+
await this.applyTagThreadAlias(id, alias);
|
|
1652
|
+
await this.writeTagsMarkdown(result.state);
|
|
1653
|
+
return { ok: true, id, ...(await this.tagRow(result.state, id)) };
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
async untag(args = {}) {
|
|
1657
|
+
const fromSeq = optionalInteger(args.fromSeq, 'untag.fromSeq');
|
|
1658
|
+
const toSeq = optionalInteger(args.toSeq, 'untag.toSeq');
|
|
1659
|
+
if (fromSeq === undefined || toSeq === undefined) {
|
|
1660
|
+
throw new Error('continuity.untag: fromSeq and toSeq are required');
|
|
1661
|
+
}
|
|
1662
|
+
if (fromSeq > toSeq) throw new Error('continuity.untag: fromSeq must be <= toSeq');
|
|
1663
|
+
await readTagsWithWarning();
|
|
1664
|
+
let removedOverrides = 0;
|
|
1665
|
+
const result = await updateTags((tags) => {
|
|
1666
|
+
tags.runs.push([fromSeq, toSeq, TAG_ZERO]);
|
|
1667
|
+
for (const key of Object.keys(tags.overrides)) {
|
|
1668
|
+
const seq = Number(key);
|
|
1669
|
+
if (seq >= fromSeq && seq <= toSeq) {
|
|
1670
|
+
delete tags.overrides[key];
|
|
1671
|
+
removedOverrides += 1;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
tags.updatedAt = new Date().toISOString();
|
|
1675
|
+
return tags;
|
|
1676
|
+
});
|
|
1677
|
+
await this.writeTagsMarkdown(result.state);
|
|
1678
|
+
return {
|
|
1679
|
+
ok: true,
|
|
1680
|
+
range: [fromSeq, toSeq],
|
|
1681
|
+
count: toSeq - fromSeq + 1,
|
|
1682
|
+
removedOverrides,
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
async delete(args = {}) {
|
|
1687
|
+
const requested = requireString(args.id, 'delete.id');
|
|
1688
|
+
const force = args.force === true;
|
|
1689
|
+
const state = await getSession(this.sessionId);
|
|
1690
|
+
const thread = state.threads.find((entry) => entry.id === requested)
|
|
1691
|
+
?? state.threads.find((entry) => entry.alias === requested);
|
|
1692
|
+
if (thread === undefined) return { ok: false, reason: 'unknown-thread-id', id: requested };
|
|
1693
|
+
const id = thread.id;
|
|
1694
|
+
let rawThread = null;
|
|
1695
|
+
try {
|
|
1696
|
+
const raw = await readState();
|
|
1697
|
+
rawThread = (raw.sessions?.[this.sessionId]?.threads ?? [])
|
|
1698
|
+
.find((entry) => entry?.id === id) ?? null;
|
|
1699
|
+
} catch {
|
|
1700
|
+
rawThread = null;
|
|
1701
|
+
}
|
|
1702
|
+
const rawSegments = Array.isArray(rawThread?.segments) ? rawThread.segments : null;
|
|
1703
|
+
const segments = Array.isArray(thread.segments) ? thread.segments : [];
|
|
1704
|
+
const empty = (rawSegments !== null && rawSegments.length === 0)
|
|
1705
|
+
|| segments.length === 0
|
|
1706
|
+
|| segments.every((segment) => (Number(segment.tokens) || 0) === 0);
|
|
1707
|
+
if (!empty && !force) {
|
|
1708
|
+
return { ok: false, reason: 'not-empty', id };
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const tagsRead = await readTags();
|
|
1712
|
+
const tags = tagsRead.state;
|
|
1713
|
+
const alias = typeof thread.alias === 'string' && thread.alias.length > 0 ? thread.alias : null;
|
|
1714
|
+
const tagKeys = Object.keys(tags.tags).filter((key) => (
|
|
1715
|
+
key === id
|
|
1716
|
+
|| tags.tags[key]?.alias === id
|
|
1717
|
+
|| (alias !== null && tags.tags[key]?.alias === alias)
|
|
1718
|
+
));
|
|
1719
|
+
const tagEntries = Object.fromEntries(tagKeys.map((key) => [key, tags.tags[key]]));
|
|
1720
|
+
const tagRuns = tags.runs.filter((run) => run[2] === id || (alias !== null && run[2] === alias));
|
|
1721
|
+
const tagOverrides = Object.fromEntries(Object.entries(tags.overrides).filter(([, value]) => (
|
|
1722
|
+
value === id || (alias !== null && value === alias)
|
|
1723
|
+
)));
|
|
1724
|
+
const backup = await writeDeletedBackup(id, {
|
|
1725
|
+
version: 1,
|
|
1726
|
+
deletedAt: new Date().toISOString(),
|
|
1727
|
+
id,
|
|
1728
|
+
alias,
|
|
1729
|
+
thread: rawThread ?? thread,
|
|
1730
|
+
normalizedThread: thread,
|
|
1731
|
+
tags: { entries: tagEntries, runs: tagRuns, overrides: tagOverrides },
|
|
1732
|
+
});
|
|
1733
|
+
|
|
1734
|
+
await updateSession(this.sessionId, (current) => {
|
|
1735
|
+
current.threads = current.threads.filter((entry) => entry.id !== id);
|
|
1736
|
+
if (current.activeId === id) current.activeId = null;
|
|
1737
|
+
if (current.pending?.id === id) current.pending = null;
|
|
1738
|
+
return current;
|
|
1739
|
+
});
|
|
1740
|
+
if (tagKeys.length > 0 || tagRuns.length > 0 || Object.keys(tagOverrides).length > 0) {
|
|
1741
|
+
const result = await updateTags((current) => {
|
|
1742
|
+
for (const key of tagKeys) delete current.tags[key];
|
|
1743
|
+
current.runs = current.runs.filter((run) => run[2] !== id && (alias === null || run[2] !== alias));
|
|
1744
|
+
for (const key of Object.keys(current.overrides)) {
|
|
1745
|
+
if (current.overrides[key] === id || (alias !== null && current.overrides[key] === alias)) {
|
|
1746
|
+
delete current.overrides[key];
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
current.updatedAt = new Date().toISOString();
|
|
1750
|
+
return current;
|
|
1751
|
+
});
|
|
1752
|
+
await this.writeTagsMarkdown(result.state);
|
|
1753
|
+
} else {
|
|
1754
|
+
await this.writeTagsMarkdown(tags);
|
|
1755
|
+
}
|
|
1756
|
+
return { ok: true, id, backup };
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
async retag(args = {}) {
|
|
1760
|
+
const from = requireString(args.from, 'retag.from');
|
|
1761
|
+
const to = requireString(args.to, 'retag.to');
|
|
1762
|
+
let threads = [];
|
|
1763
|
+
let activeId = null;
|
|
1764
|
+
try {
|
|
1765
|
+
const state = await getSession(this.sessionId);
|
|
1766
|
+
threads = state.threads;
|
|
1767
|
+
activeId = state.activeId;
|
|
1768
|
+
} catch {
|
|
1769
|
+
threads = [];
|
|
1770
|
+
activeId = null;
|
|
1771
|
+
}
|
|
1772
|
+
const read = await readTagsMigrated(threads);
|
|
1773
|
+
const fromId = resolveTagIdentity(from, read.state, threads);
|
|
1774
|
+
if (fromId === null) return { ok: false, reason: 'unknown-tag-id', id: from };
|
|
1775
|
+
let toId = resolveTagIdentity(to, read.state, threads);
|
|
1776
|
+
if (toId === null) {
|
|
1777
|
+
const hasContent = read.state.runs.some((run) => run[2] === fromId)
|
|
1778
|
+
|| Object.values(read.state.overrides).includes(fromId);
|
|
1779
|
+
if (!hasContent) return { ok: false, reason: 'unknown-tag-id', id: to };
|
|
1780
|
+
toId = to;
|
|
1781
|
+
}
|
|
1782
|
+
if (fromId === toId) {
|
|
1783
|
+
return {
|
|
1784
|
+
ok: true,
|
|
1785
|
+
from: fromId,
|
|
1786
|
+
to: toId,
|
|
1787
|
+
tags: tagsView(read.state, { activeId, threads }).tags,
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
const result = await updateTags((tags) => retagTags(tags, { from: fromId, to: toId }).state);
|
|
1791
|
+
await this.writeTagsMarkdown(result.state);
|
|
1792
|
+
return {
|
|
1793
|
+
ok: true,
|
|
1794
|
+
from: fromId,
|
|
1795
|
+
to: toId,
|
|
1796
|
+
tags: tagsView(result.state, { activeId, threads }).tags,
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
async tags() {
|
|
1801
|
+
let activeId = null;
|
|
1802
|
+
let threads = [];
|
|
1803
|
+
let maxSeq = 0;
|
|
1804
|
+
try {
|
|
1805
|
+
const sessionState = await getSession(this.sessionId);
|
|
1806
|
+
activeId = sessionState.activeId;
|
|
1807
|
+
threads = sessionState.threads;
|
|
1808
|
+
const snapshot = await this.sessionQuery.readSession(this.agent.session.id);
|
|
1809
|
+
for (const event of Array.isArray(snapshot?.events) ? snapshot.events : []) {
|
|
1810
|
+
const seq = Number(event?.seq);
|
|
1811
|
+
if (Number.isInteger(seq) && seq > maxSeq) maxSeq = seq;
|
|
1812
|
+
}
|
|
1813
|
+
} catch {
|
|
1814
|
+
activeId = null;
|
|
1815
|
+
threads = [];
|
|
1816
|
+
}
|
|
1817
|
+
const read = await readTagsMigrated(threads);
|
|
1818
|
+
if (read.migration.changed) await this.writeTagsMarkdown(read.state);
|
|
1819
|
+
const view = tagsView(read.state, { activeId, threads, maxSeq });
|
|
1820
|
+
return {
|
|
1821
|
+
...view,
|
|
1822
|
+
...(read.corrupt || read.missing
|
|
1823
|
+
? { warning: read.corrupt ? 'tags.json corrupt; using T0' : 'tags.json missing; using T0' }
|
|
1824
|
+
: {}),
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
async scan(args = {}, signal) {
|
|
1829
|
+
const seqs = integerList(args.seqs, 'scan.seqs');
|
|
1830
|
+
const fromSeq = optionalInteger(args.fromSeq, 'scan.fromSeq');
|
|
1831
|
+
const toSeq = optionalInteger(args.toSeq, 'scan.toSeq');
|
|
1832
|
+
if (seqs === undefined && (fromSeq === undefined || toSeq === undefined)) {
|
|
1833
|
+
throw new Error('continuity.scan: pass seqs, or both fromSeq and toSeq');
|
|
1834
|
+
}
|
|
1835
|
+
if (fromSeq !== undefined && toSeq !== undefined && fromSeq > toSeq) {
|
|
1836
|
+
throw new Error('continuity.scan: fromSeq must be <= toSeq');
|
|
1837
|
+
}
|
|
1838
|
+
let snapshot;
|
|
1839
|
+
try {
|
|
1840
|
+
snapshot = await this.sessionQuery.readSession(this.agent.session.id);
|
|
1841
|
+
} catch (error) {
|
|
1842
|
+
throw new Error(`continuity.scan: cannot read session: ${error?.message ?? error}`);
|
|
1843
|
+
}
|
|
1844
|
+
signal?.throwIfAborted?.();
|
|
1845
|
+
const events = Array.isArray(snapshot?.events) ? snapshot.events : [];
|
|
1846
|
+
return scanEvents({
|
|
1847
|
+
events,
|
|
1848
|
+
fromSeq,
|
|
1849
|
+
toSeq,
|
|
1850
|
+
seqs,
|
|
1851
|
+
headChars: args.headChars,
|
|
1852
|
+
includeReasoning: args.includeReasoning === true,
|
|
1853
|
+
types: args.types,
|
|
1854
|
+
limit: args.limit,
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
async active() {
|
|
1859
|
+
const state = await getSession(this.sessionId);
|
|
1860
|
+
return activeView(state);
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
async list() {
|
|
1864
|
+
const state = await getSession(this.sessionId);
|
|
1865
|
+
return listView(state);
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
/**
|
|
1870
|
+
* Forwarding layer. Keep this list in sync with ThreadsCore; the tool test
|
|
1871
|
+
* calls every entry through the facade, so a missing method fails loudly.
|
|
1872
|
+
*/
|
|
1873
|
+
export function makeApi(core) {
|
|
1874
|
+
return {
|
|
1875
|
+
save: (args, signal) => core.save(args, signal),
|
|
1876
|
+
load: (args, signal) => core.load(args, signal),
|
|
1877
|
+
scan: (args, signal) => core.scan(args, signal),
|
|
1878
|
+
find: (args) => core.find(args),
|
|
1879
|
+
tag: (args) => core.tag(args),
|
|
1880
|
+
untag: (args) => core.untag(args),
|
|
1881
|
+
retag: (args) => core.retag(args),
|
|
1882
|
+
delete: (args) => core.delete(args),
|
|
1883
|
+
tags: () => core.tags(),
|
|
1884
|
+
active: () => core.active(),
|
|
1885
|
+
list: () => core.list(),
|
|
1886
|
+
};
|
|
1887
|
+
}
|