@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
package/src/threads.mjs
ADDED
|
@@ -0,0 +1,929 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Continuity threads — pure formatting, range selection, and load selection.
|
|
3
|
+
*
|
|
4
|
+
* The tool layer owns session mutation and persistence; this module only
|
|
5
|
+
* decides what a save would cover, what a load would quote, and what the
|
|
6
|
+
* per-turn active line says. Keeping it pure makes the two dangerous pieces
|
|
7
|
+
* (tool-call pairing and the load budget) testable without a live agent.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { eventRole, eventText } from './session-log.mjs';
|
|
11
|
+
import { normalizeTags } from './tags.mjs';
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_LOAD_BUDGET = 8_000;
|
|
14
|
+
export const MAX_LOAD_BUDGET = 20_000;
|
|
15
|
+
export const REASONING_LOAD_BUDGET = 16_000;
|
|
16
|
+
export const REASONING_MAX_TOKENS = 800;
|
|
17
|
+
export const REASONING_HEAD_TOKENS = 150;
|
|
18
|
+
export const REASONING_TAIL_TOKENS = 650;
|
|
19
|
+
export const RULES_MAX_TOKENS = 400;
|
|
20
|
+
export const ACTIVE_LINE_MAX_TOKENS = 200;
|
|
21
|
+
export const RESIDUE_MAX_CHARS = 4_000;
|
|
22
|
+
export const MAX_SAVE_MESSAGES = 200;
|
|
23
|
+
|
|
24
|
+
/** Cheap conservative token estimate: CJK ≈ 1 token/char, ASCII ≈ 4 chars/token. */
|
|
25
|
+
export function estimateTextTokens(text) {
|
|
26
|
+
const value = String(text ?? '');
|
|
27
|
+
if (value.length === 0) return 0;
|
|
28
|
+
const cjk = (value.match(/[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/g) ?? []).length;
|
|
29
|
+
return Math.ceil(cjk + (value.length - cjk) / 4);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Truncate to an estimated token budget. The suffix says the text was cut.
|
|
34
|
+
* @param {string} text
|
|
35
|
+
* @param {number} maxTokens
|
|
36
|
+
* @param {(text: string) => number} [estimate]
|
|
37
|
+
*/
|
|
38
|
+
export function truncateToTokens(text, maxTokens, estimate = estimateTextTokens) {
|
|
39
|
+
const value = String(text ?? '');
|
|
40
|
+
if (!Number.isFinite(maxTokens) || maxTokens <= 0) return '';
|
|
41
|
+
if (estimate(value) <= maxTokens) return value;
|
|
42
|
+
const suffix = '\n… [truncated]';
|
|
43
|
+
const suffixTokens = estimate(suffix);
|
|
44
|
+
const budget = Math.max(0, maxTokens - suffixTokens);
|
|
45
|
+
let low = 0;
|
|
46
|
+
let high = value.length;
|
|
47
|
+
while (low < high) {
|
|
48
|
+
const middle = Math.ceil((low + high) / 2);
|
|
49
|
+
if (estimate(value.slice(0, middle)) <= budget) low = middle;
|
|
50
|
+
else high = middle - 1;
|
|
51
|
+
}
|
|
52
|
+
return `${value.slice(0, low).trimEnd()}${suffix}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One-line display text. */
|
|
56
|
+
export function oneLine(text, max = 120) {
|
|
57
|
+
const value = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
58
|
+
return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function minutesBetween(start, end) {
|
|
62
|
+
const from = Number.isFinite(Number(start)) ? Number(start) : null;
|
|
63
|
+
const to = Number.isFinite(Number(end)) ? Number(end) : null;
|
|
64
|
+
if (from === null || to === null || to < from) return 0;
|
|
65
|
+
return Math.max(0, Math.round(((to - from) / 60_000) * 10) / 10);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isoOf(value) {
|
|
69
|
+
const number = Number(value);
|
|
70
|
+
if (!Number.isFinite(number)) return null;
|
|
71
|
+
try {
|
|
72
|
+
return new Date(number).toISOString();
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Human span for a thread. */
|
|
79
|
+
export function spanLabel(thread) {
|
|
80
|
+
const start = thread.spanStart ?? '?';
|
|
81
|
+
const end = thread.spanEnd ?? '?';
|
|
82
|
+
const minutes = Number.isFinite(thread.minutes) ? `${thread.minutes}m` : '?m';
|
|
83
|
+
return `${start} → ${end} (${minutes})`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 12.3k / 980 for save receipts. */
|
|
87
|
+
export function formatTokenCount(value) {
|
|
88
|
+
const number = Number(value);
|
|
89
|
+
if (!Number.isFinite(number) || number < 0) return '?';
|
|
90
|
+
if (number >= 1000) return `${(number / 1000).toFixed(1).replace(/\.0$/, '')}k`;
|
|
91
|
+
return String(Math.round(number));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Local HH:mm for the list/active "最近" label. */
|
|
95
|
+
export function formatClock(value) {
|
|
96
|
+
const number = Date.parse(String(value ?? ''));
|
|
97
|
+
if (!Number.isFinite(number)) return '--:--';
|
|
98
|
+
const date = new Date(number);
|
|
99
|
+
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Normalize one segment; old single-range threads pass the thread as both. */
|
|
103
|
+
export function normalizeSegment(raw, thread = {}) {
|
|
104
|
+
const source = raw !== null && typeof raw === 'object' ? raw : {};
|
|
105
|
+
const fromSeq = source.fromSeq ?? source.seqStart ?? thread.seqStart ?? null;
|
|
106
|
+
const toSeq = source.toSeq ?? source.seqEnd ?? thread.seqEnd ?? null;
|
|
107
|
+
const seqs = Array.isArray(source.seqs)
|
|
108
|
+
? source.seqs
|
|
109
|
+
: Array.isArray(thread.seqs) ? thread.seqs : [fromSeq, toSeq].filter((seq) => seq !== null);
|
|
110
|
+
const count = Number.isFinite(source.count) ? source.count : Number.isFinite(thread.count) ? thread.count : seqs.length;
|
|
111
|
+
const tokens = Number.isFinite(source.tokens) ? source.tokens : Number.isFinite(thread.tokens) ? thread.tokens : 0;
|
|
112
|
+
const why = typeof source.why === 'string' ? source.why : (typeof thread.why === 'string' ? thread.why : '');
|
|
113
|
+
const next = typeof source.next === 'string' ? source.next : (typeof thread.next === 'string' ? thread.next : '');
|
|
114
|
+
const residue = typeof source.residue === 'string' ? source.residue : (typeof thread.residue === 'string' ? thread.residue : undefined);
|
|
115
|
+
const spanStart = source.spanStart ?? thread.spanStart ?? null;
|
|
116
|
+
const spanEnd = source.spanEnd ?? thread.spanEnd ?? null;
|
|
117
|
+
// Old backfill segments stored the write moment in `at`; their `why` starts
|
|
118
|
+
// with 历史回补. Normalize them to content time on read.
|
|
119
|
+
const isBackfill = source.archivedAt !== undefined || /^历史回补/.test(String(source.why ?? ''));
|
|
120
|
+
const at = isBackfill
|
|
121
|
+
? (spanEnd ?? source.at ?? thread.createdAt ?? thread.spanStart ?? new Date().toISOString())
|
|
122
|
+
: (source.at ?? thread.createdAt ?? thread.spanStart ?? new Date().toISOString());
|
|
123
|
+
// Preserve an old backfill's write moment instead of discarding it when `at`
|
|
124
|
+
// is normalized to content time.
|
|
125
|
+
const archivedAt = source.archivedAt
|
|
126
|
+
?? (isBackfill && source.at !== undefined && source.at !== spanEnd ? source.at : undefined);
|
|
127
|
+
const minutes = Number.isFinite(source.minutes) ? source.minutes : Number.isFinite(thread.minutes) ? thread.minutes : 0;
|
|
128
|
+
const archiveRef = source.archiveRef ?? `log:${thread.sessionId ?? 'session'}#seq=${fromSeq}-${toSeq}`;
|
|
129
|
+
const tCoverage = source.tCoverage ?? thread.tCoverage;
|
|
130
|
+
return {
|
|
131
|
+
fromSeq,
|
|
132
|
+
toSeq,
|
|
133
|
+
seqs,
|
|
134
|
+
count,
|
|
135
|
+
tokens,
|
|
136
|
+
why,
|
|
137
|
+
next,
|
|
138
|
+
...(residue === undefined ? {} : { residue }),
|
|
139
|
+
at,
|
|
140
|
+
...(archivedAt === undefined ? {} : { archivedAt }),
|
|
141
|
+
spanStart,
|
|
142
|
+
spanEnd,
|
|
143
|
+
minutes,
|
|
144
|
+
archiveRef,
|
|
145
|
+
...(tCoverage === undefined ? {} : { tCoverage }),
|
|
146
|
+
...(source.gistSeq === undefined && thread.gistSeq === undefined ? {} : { gistSeq: source.gistSeq ?? thread.gistSeq }),
|
|
147
|
+
...(source.compactionId === undefined && thread.compactionId === undefined
|
|
148
|
+
? {}
|
|
149
|
+
: { compactionId: source.compactionId ?? thread.compactionId }),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Read-time compatibility: old one-range threads become a one-segment thread.
|
|
154
|
+
* Write-time callers use the normalized shape returned here. */
|
|
155
|
+
export function normalizeThread(input) {
|
|
156
|
+
const source = input !== null && typeof input === 'object' ? { ...input } : {};
|
|
157
|
+
const segments = (Array.isArray(source.segments) && source.segments.length > 0
|
|
158
|
+
? source.segments.map((segment) => normalizeSegment(segment, source))
|
|
159
|
+
: [normalizeSegment(source, source)])
|
|
160
|
+
// Backfill may insert a historical segment; "last = newest" is an invariant
|
|
161
|
+
// for every reader, so normalize order by seq rather than trusting append order.
|
|
162
|
+
.sort((left, right) => Number(left.fromSeq) - Number(right.fromSeq));
|
|
163
|
+
const first = segments[0];
|
|
164
|
+
const latest = segments[segments.length - 1];
|
|
165
|
+
const tokens = segments.reduce((sum, segment) => sum + (Number(segment.tokens) || 0), 0);
|
|
166
|
+
const minutes = segments.reduce((sum, segment) => sum + (Number(segment.minutes) || 0), 0);
|
|
167
|
+
const normalized = {
|
|
168
|
+
...source,
|
|
169
|
+
segments,
|
|
170
|
+
seqStart: first.fromSeq,
|
|
171
|
+
seqEnd: latest.toSeq,
|
|
172
|
+
seqs: latest.seqs,
|
|
173
|
+
count: latest.count,
|
|
174
|
+
spanStart: first.spanStart,
|
|
175
|
+
spanEnd: latest.spanEnd,
|
|
176
|
+
minutes,
|
|
177
|
+
tokens,
|
|
178
|
+
why: latest.why,
|
|
179
|
+
next: latest.next,
|
|
180
|
+
archiveRef: latest.archiveRef,
|
|
181
|
+
...(latest.residue === undefined ? {} : { residue: latest.residue }),
|
|
182
|
+
...(latest.gistSeq === undefined ? {} : { gistSeq: latest.gistSeq }),
|
|
183
|
+
...(latest.compactionId === undefined ? {} : { compactionId: latest.compactionId }),
|
|
184
|
+
// Recency is content recency, not the write time of a backfill.
|
|
185
|
+
updatedAt: latest.spanEnd ?? latest.at,
|
|
186
|
+
};
|
|
187
|
+
if (latest.residue === undefined) delete normalized.residue;
|
|
188
|
+
return normalized;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** One thread's gist body; starts with [thread], then a retrievable [gist]. */
|
|
192
|
+
export function threadGistText(thread) {
|
|
193
|
+
const normalized = normalizeThread(thread);
|
|
194
|
+
const segments = normalized.segments;
|
|
195
|
+
const segment = segments[segments.length - 1];
|
|
196
|
+
const index = segments.length;
|
|
197
|
+
const total = segments.length;
|
|
198
|
+
const lines = [
|
|
199
|
+
`[thread] ${normalized.label} · segment ${index}/${total} · thread=${index} · T=${segment.tCoverage ?? 'T0'} · ${spanLabel(segment)} · 收起因为:${segment.why} · 下一步:${segment.next} · 存档:${segment.archiveRef} · seq ${segment.fromSeq}..${segment.toSeq}`,
|
|
200
|
+
];
|
|
201
|
+
if (typeof segment.residue === 'string' && segment.residue.trim().length > 0) {
|
|
202
|
+
lines.push(`残留:${segment.residue.trim()}`);
|
|
203
|
+
}
|
|
204
|
+
lines.push(
|
|
205
|
+
`[gist · log:${normalized.sessionId ?? 'session'}#seq=${segment.fromSeq}-${segment.toSeq}`
|
|
206
|
+
+ ` · ${segment.count ?? '?'} messages · original retrievable via session_log]`,
|
|
207
|
+
);
|
|
208
|
+
return lines.join('\n');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A surface user message carrying a registered runtime-context snapshot.
|
|
212
|
+
* `user` is accepted by v4 but renders as a human chat bubble; `runtime-context`
|
|
213
|
+
* is a producer-owned kind and renders as a context block, which is where the
|
|
214
|
+
* per-turn active line and rules belong. */
|
|
215
|
+
export function contextMessage(text) {
|
|
216
|
+
return {
|
|
217
|
+
id: randomUUID(),
|
|
218
|
+
role: 'user',
|
|
219
|
+
content: [{ type: 'text', text }],
|
|
220
|
+
source: {
|
|
221
|
+
kind: 'runtime-context',
|
|
222
|
+
form: 'snapshot',
|
|
223
|
+
sections: [{ name: 'continuity:threads', text }],
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A real user-role message. Every user/message event must carry source.kind;
|
|
229
|
+
* projection and agent.steer read it directly. */
|
|
230
|
+
export function userMessage(text) {
|
|
231
|
+
return {
|
|
232
|
+
id: randomUUID(),
|
|
233
|
+
role: 'user',
|
|
234
|
+
content: [{ type: 'text', text: String(text ?? '') }],
|
|
235
|
+
source: { kind: 'user' },
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A same-turn gate notice. `steer` only admits user-looking messages in some
|
|
240
|
+
* hosts; runtime-context is projected/filtered before it reaches the model.
|
|
241
|
+
* Use a user source so the notice is actually admitted; the [闸] marker makes
|
|
242
|
+
* clear it is not the person typing. */
|
|
243
|
+
export function gateMessage(text) {
|
|
244
|
+
return {
|
|
245
|
+
id: randomUUID(),
|
|
246
|
+
role: 'user',
|
|
247
|
+
content: [{ type: 'text', text: String(text ?? '') }],
|
|
248
|
+
source: { kind: 'user' },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Rules are must-read and editable; the constitution itself references them. */
|
|
253
|
+
export const RULES_ATTRIBUTION = '[规矩] 宪法要求你必须读当前 session 的 rules.md;它是可修改的规矩,不是不可改的宪法。';
|
|
254
|
+
|
|
255
|
+
export const RULES_PATH = '~/.dsh/continuity/rules.md';
|
|
256
|
+
export const HABITS_PATH = '~/.dsh/continuity/habits.md';
|
|
257
|
+
|
|
258
|
+
/** Active line only; the closed-label list moves to the compact [标签] line. */
|
|
259
|
+
export function activeLine(state, options = {}) {
|
|
260
|
+
const threads = Array.isArray(state?.threads) ? state.threads : [];
|
|
261
|
+
const pointerThread = threads.find((thread) => thread.id === state?.activeId) ?? null;
|
|
262
|
+
const current = threads.find((thread) => thread.active === true) ?? pointerThread;
|
|
263
|
+
const lines = [];
|
|
264
|
+
if (current !== null) {
|
|
265
|
+
const segments = Array.isArray(current.segments) ? current.segments : [];
|
|
266
|
+
const segmentCount = segments.length;
|
|
267
|
+
const tokens = segments.reduce((sum, segment) => sum + (Number(segment.tokens) || 0), 0);
|
|
268
|
+
const symbol = oneLine(current.alias ?? current.label ?? current.id, 40);
|
|
269
|
+
const activity = pointerThread !== null && pointerThread.id !== current.id
|
|
270
|
+
? `(活动:${oneLine(pointerThread.alias ?? pointerThread.label ?? pointerThread.id, 40)})`
|
|
271
|
+
: '';
|
|
272
|
+
lines.push(`[工作线] ${oneLine(current.label, 60)}(${segmentCount} 段 · ${tokens} tokens)${activity} · 下一步:${oneLine(current.next, 100)}`);
|
|
273
|
+
} else {
|
|
274
|
+
lines.push('[工作线] 无');
|
|
275
|
+
}
|
|
276
|
+
const text = lines.join('\n');
|
|
277
|
+
const maxTokens = Number.isFinite(options.maxTokens) ? options.maxTokens : ACTIVE_LINE_MAX_TOKENS;
|
|
278
|
+
return truncateToTokens(text, maxTokens);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** H2 headings as a one-line habit summary; never the full rules text. */
|
|
282
|
+
export function summarizeRules(text, maxChars = 220) {
|
|
283
|
+
const headings = String(text ?? '')
|
|
284
|
+
.split(/\r?\n/)
|
|
285
|
+
.map((line) => line.match(/^##\s+(.+?)\s*$/)?.[1])
|
|
286
|
+
.filter(Boolean)
|
|
287
|
+
.map((heading) => heading.replace(/[::]\s*$/, ''));
|
|
288
|
+
if (headings.length === 0) return '按自己的判断使用';
|
|
289
|
+
const summary = headings.join(' · ');
|
|
290
|
+
if (summary.length <= maxChars) return summary;
|
|
291
|
+
|
|
292
|
+
// Keep the truncation honest: say how many sections exist and how many fit.
|
|
293
|
+
for (let shown = headings.length - 1; shown > 0; shown -= 1) {
|
|
294
|
+
const suffix = `…(共 ${headings.length} 节,仅显示前 ${shown} 节)`;
|
|
295
|
+
const candidate = `${headings.slice(0, shown).join(' · ')}${suffix}`;
|
|
296
|
+
if (candidate.length <= maxChars) return candidate;
|
|
297
|
+
}
|
|
298
|
+
return oneLine(summary, maxChars);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Compact [标签] status line: active only, plus the count of other tags. */
|
|
302
|
+
export function tagStatusLine(state, tagRows) {
|
|
303
|
+
const rows = Array.isArray(tagRows) ? tagRows : [];
|
|
304
|
+
const threads = Array.isArray(state?.threads) ? state.threads : [];
|
|
305
|
+
const active = threads.find((thread) => thread.id === state?.activeId) ?? null;
|
|
306
|
+
const otherRows = rows.filter((row) => row.id !== 'T0');
|
|
307
|
+
if (active === null) {
|
|
308
|
+
return `[标签] T 未定 · 另有 ${otherRows.length} 条`;
|
|
309
|
+
}
|
|
310
|
+
const alias = typeof active.alias === 'string' && active.alias.trim().length > 0 ? active.alias.trim() : null;
|
|
311
|
+
const row = rows.find((entry) => entry.id === alias || entry.id === active.id)
|
|
312
|
+
?? rows.find((entry) => entry.threadId === active.id);
|
|
313
|
+
const display = row?.display ?? alias ?? oneLine(active.label, 40);
|
|
314
|
+
const others = rows.filter((entry) => entry.id !== row?.id && entry.id !== active.id && entry.id !== 'T0').length;
|
|
315
|
+
// 遗忘率 = T0 事件 / 全部已标记事件。它衡量"认知漏掉了多少",不是"桶有多大"。
|
|
316
|
+
const t0 = rows.find((entry) => entry.id === 'T0') ?? null;
|
|
317
|
+
const totalEvents = rows.reduce((sum, entry) => sum + (Number(entry.events) || 0), 0);
|
|
318
|
+
const t0Events = Number(t0?.events ?? 0) || 0;
|
|
319
|
+
const forget = totalEvents > 0
|
|
320
|
+
? ` · 未覆盖 ${t0Events}(${((t0Events / totalEvents) * 100).toFixed(1)}%,含结构事件)`
|
|
321
|
+
: '';
|
|
322
|
+
return `[标签] ${display} · 另有 ${others} 条${forget}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Compose the once-per-turn context block: line(s) + one-line habits summary. */
|
|
326
|
+
export function threadContextText(line, rulesSummary, habitsSummary = '', paths = {}) {
|
|
327
|
+
const parts = [];
|
|
328
|
+
if (typeof line === 'string' && line.trim().length > 0) parts.push(line.trim());
|
|
329
|
+
if (typeof rulesSummary === 'string' && rulesSummary.trim().length > 0) {
|
|
330
|
+
parts.push(`[规则] Continuity 自己的硬规则(AGENTS 要求必须读;只改这份,不改 AGENTS):${rulesSummary.trim()} · 全文 ${paths.rules ?? RULES_PATH}`);
|
|
331
|
+
}
|
|
332
|
+
if (typeof habitsSummary === 'string' && habitsSummary.trim().length > 0) {
|
|
333
|
+
parts.push(`[习惯] Continuity 自己的软偏好(与 rules 冲突时让位;只改这份):${habitsSummary.trim()} · 全文 ${paths.habits ?? HABITS_PATH}`);
|
|
334
|
+
}
|
|
335
|
+
return parts.join('\n');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Load header + quoted raw. Accepts the latest segment explicitly so a
|
|
339
|
+
* multi-segment thread says which segment supplied the backfill. */
|
|
340
|
+
export function threadLoadText(thread, segmentOrQuote, quoteValue, segmentIndex, segmentCount, note, foldedNote, reasoningInfo) {
|
|
341
|
+
const normalized = normalizeThread(thread);
|
|
342
|
+
const explicit = segmentOrQuote !== null && typeof segmentOrQuote === 'object'
|
|
343
|
+
&& (segmentOrQuote.fromSeq !== undefined || segmentOrQuote.toSeq !== undefined);
|
|
344
|
+
const segment = explicit
|
|
345
|
+
? normalizeSegment(segmentOrQuote, normalized)
|
|
346
|
+
: normalized.segments[normalized.segments.length - 1];
|
|
347
|
+
const quote = explicit ? quoteValue : segmentOrQuote;
|
|
348
|
+
const total = Number.isFinite(segmentCount) ? segmentCount : normalized.segments.length;
|
|
349
|
+
const index = Number.isFinite(segmentIndex) ? segmentIndex : normalized.segments.length;
|
|
350
|
+
const scope = typeof note === 'string' && note.trim().length > 0 ? note.trim() : `第${index}/${total}段`;
|
|
351
|
+
return [
|
|
352
|
+
`[放] ${normalized.label} · ${scope} · 现在算在「${normalized.id}」这条线上 · 下一步:${segment.next} · 上次收:${segment.why} · 存档:${segment.archiveRef}`,
|
|
353
|
+
...(foldedNote !== null && foldedNote !== undefined && String(foldedNote).length > 0
|
|
354
|
+
? [`[已折叠于 gist seq=${foldedNote}]`]
|
|
355
|
+
: []),
|
|
356
|
+
...(reasoningInfo !== null && reasoningInfo !== undefined
|
|
357
|
+
? [`[reasoning] 含 ${reasoningInfo.reasoningInjected} 条 · skipped ${reasoningInfo.skipped}`]
|
|
358
|
+
: []),
|
|
359
|
+
scope.startsWith('第') ? '—— 以下是从原文取回的最后一段 ——' : '—— 以下是从原文取回的所选内容 ——',
|
|
360
|
+
String(quote ?? ''),
|
|
361
|
+
].join('\n');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The index-only fallback used when the raw journal is missing. */
|
|
365
|
+
export function threadLoadIndexOnly(thread, segmentIndex, segmentCount, note, foldedNote) {
|
|
366
|
+
const normalized = normalizeThread(thread);
|
|
367
|
+
const segment = normalized.segments[normalized.segments.length - 1];
|
|
368
|
+
const total = Number.isFinite(segmentCount) ? segmentCount : normalized.segments.length;
|
|
369
|
+
const index = Number.isFinite(segmentIndex) ? segmentIndex : normalized.segments.length;
|
|
370
|
+
const scope = typeof note === 'string' && note.trim().length > 0 ? note.trim() : `第${index}/${total}段`;
|
|
371
|
+
return [
|
|
372
|
+
`[放] ${normalized.label} · ${scope} · 现在算在「${normalized.id}」这条线上 · 下一步:${segment.next} · 上次收:${segment.why} · 存档:${segment.archiveRef}`,
|
|
373
|
+
...(foldedNote !== null && foldedNote !== undefined && String(foldedNote).length > 0
|
|
374
|
+
? [`[已折叠于 gist seq=${foldedNote}]`]
|
|
375
|
+
: []),
|
|
376
|
+
'—— 原文不可用;只有索引 ——',
|
|
377
|
+
].join('\n');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Latest turn/start seq in this session, or null. */
|
|
381
|
+
export function latestTurnStartSeq(session) {
|
|
382
|
+
const end = Number.isInteger(session?.seq) ? session.seq : 0;
|
|
383
|
+
for (let seq = end - 1; seq >= 0; seq -= 1) {
|
|
384
|
+
const event = session.eventAt?.(seq);
|
|
385
|
+
if (event?.type === 'turn/start') return seq;
|
|
386
|
+
}
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function isGistNode(event) {
|
|
391
|
+
const text = eventText(event).trimStart();
|
|
392
|
+
return text.startsWith('[gist') || text.startsWith('[thread');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Select and validate the save range.
|
|
397
|
+
*
|
|
398
|
+
* Explicit from/to must be exact surface endpoints and both balanced; a cut is
|
|
399
|
+
* rejected with the offending seq. The default range starts after the latest
|
|
400
|
+
* gist and ends before the current open turn, then walks inward until both ends
|
|
401
|
+
* are pairing-balanced. The current turn is never folded.
|
|
402
|
+
*
|
|
403
|
+
* @param {object} input
|
|
404
|
+
* @param {object} input.session
|
|
405
|
+
* @param {number|string} [input.fromSeq]
|
|
406
|
+
* @param {number|string} [input.toSeq]
|
|
407
|
+
* @param {number|string} [input.minStartSeq] default save only: never start before this seq
|
|
408
|
+
* @param {{ before: (session: object, seq: number|string) => boolean, after: (session: object, seq: number|string) => boolean }} input.pairing
|
|
409
|
+
* @returns {{ startSeq: number|string, endSeq: number|string, startIdx: number, endIdx: number, shadowedSeqs: Array<number|string>, count: number, adjusted: boolean }}
|
|
410
|
+
*/
|
|
411
|
+
export function selectSaveRange({ session, fromSeq, toSeq, pairing, maxMessages = MAX_SAVE_MESSAGES, minStartSeq }) { const nodes = session?.surface?.nodes;
|
|
412
|
+
if (!Array.isArray(nodes) || nodes.length === 0) throw new Error('save: session has no surface');
|
|
413
|
+
if (typeof pairing?.before !== 'function' || typeof pairing?.after !== 'function') {
|
|
414
|
+
throw new Error('save: pairing predicates are unavailable');
|
|
415
|
+
}
|
|
416
|
+
const explicitStart = fromSeq !== undefined && fromSeq !== null && fromSeq !== '';
|
|
417
|
+
const explicitEnd = toSeq !== undefined && toSeq !== null && toSeq !== '';
|
|
418
|
+
if (explicitStart !== explicitEnd) throw new Error('save: pass both fromSeq and toSeq, or neither');
|
|
419
|
+
|
|
420
|
+
const turnStart = latestTurnStartSeq(session);
|
|
421
|
+
let startIdx;
|
|
422
|
+
let endIdx;
|
|
423
|
+
let adjusted = false;
|
|
424
|
+
|
|
425
|
+
if (explicitStart) {
|
|
426
|
+
startIdx = nodes.findIndex((seq) => Number(seq) === Number(fromSeq));
|
|
427
|
+
endIdx = nodes.findIndex((seq) => Number(seq) === Number(toSeq));
|
|
428
|
+
if (startIdx < 0) throw new Error(`save: fromSeq ${fromSeq} is not a surface node`);
|
|
429
|
+
if (endIdx < 0) throw new Error(`save: toSeq ${toSeq} is not a surface node`);
|
|
430
|
+
if (startIdx > endIdx) throw new Error('save: fromSeq is after toSeq on the surface');
|
|
431
|
+
if (turnStart !== null && Number(nodes[endIdx]) >= turnStart) {
|
|
432
|
+
throw new Error('save: the current open turn is not foldable; save a range that ends before this turn');
|
|
433
|
+
}
|
|
434
|
+
for (let index = startIdx; index <= endIdx; index += 1) {
|
|
435
|
+
if (isGistNode(session.eventAt(nodes[index]))) {
|
|
436
|
+
throw new Error(`save: surface seq ${nodes[index]} is a gist/thread node; start after the previous gist`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (!pairing.before(session, nodes[startIdx])) {
|
|
440
|
+
throw new Error(`save: fromSeq ${fromSeq} cuts a tool call/result pair (unbalanced before); choose an earlier boundary`);
|
|
441
|
+
}
|
|
442
|
+
if (!pairing.after(session, nodes[endIdx])) {
|
|
443
|
+
throw new Error(`save: toSeq ${toSeq} cuts a tool call/result pair (unbalanced after); choose a later boundary`);
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
endIdx = nodes.length - 1;
|
|
447
|
+
if (turnStart !== null) {
|
|
448
|
+
while (endIdx >= 0 && Number(nodes[endIdx]) >= turnStart) endIdx -= 1;
|
|
449
|
+
}
|
|
450
|
+
startIdx = 0;
|
|
451
|
+
if (nodes.length > 0 && session.eventAt(nodes[0])?.type === 'system/message') startIdx = 1;
|
|
452
|
+
for (let index = endIdx; index >= startIdx; index -= 1) {
|
|
453
|
+
if (isGistNode(session.eventAt(nodes[index]))) {
|
|
454
|
+
startIdx = index + 1;
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (startIdx > endIdx) throw new Error('save: no completed, non-gist range to save');
|
|
459
|
+
|
|
460
|
+
// Default save must not cross into an earlier T. The caller passes the
|
|
461
|
+
// start of the tag run containing the selected end; explicit from/to is
|
|
462
|
+
// intentionally not clamped.
|
|
463
|
+
if (minStartSeq !== undefined && minStartSeq !== null && minStartSeq !== '') {
|
|
464
|
+
const min = Number(minStartSeq);
|
|
465
|
+
if (!Number.isFinite(min)) throw new Error('save: minStartSeq must be a number');
|
|
466
|
+
const minIdx = nodes.findIndex((seq) => Number(seq) >= min);
|
|
467
|
+
if (minIdx < 0 || minIdx > endIdx) {
|
|
468
|
+
throw new Error('save: the current T run starts after the completed range');
|
|
469
|
+
}
|
|
470
|
+
if (minIdx > startIdx) {
|
|
471
|
+
startIdx = minIdx;
|
|
472
|
+
adjusted = true;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
while (startIdx < endIdx && !pairing.before(session, nodes[startIdx])) {
|
|
477
|
+
startIdx += 1;
|
|
478
|
+
adjusted = true;
|
|
479
|
+
}
|
|
480
|
+
while (endIdx > startIdx && !pairing.after(session, nodes[endIdx])) {
|
|
481
|
+
endIdx -= 1;
|
|
482
|
+
adjusted = true;
|
|
483
|
+
}
|
|
484
|
+
if (!pairing.before(session, nodes[startIdx])) {
|
|
485
|
+
throw new Error(`save: no balanced start after the previous gist (seq ${nodes[startIdx]})`);
|
|
486
|
+
}
|
|
487
|
+
if (!pairing.after(session, nodes[endIdx])) {
|
|
488
|
+
throw new Error(`save: no balanced end before the open turn (seq ${nodes[endIdx]})`);
|
|
489
|
+
}
|
|
490
|
+
for (let index = startIdx; index <= endIdx; index += 1) {
|
|
491
|
+
if (isGistNode(session.eventAt(nodes[index]))) {
|
|
492
|
+
throw new Error(`save: the selected range contains gist/thread node ${nodes[index]}; start after it`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (startIdx < 0 || endIdx < 0 || startIdx > endIdx) throw new Error('save: selected range is empty');
|
|
498
|
+
const countMessages = (from, to) => {
|
|
499
|
+
let count = 0;
|
|
500
|
+
for (let index = from; index <= to; index += 1) {
|
|
501
|
+
if (session.deriveEventMessage?.(session.eventAt(nodes[index])) !== null) count += 1;
|
|
502
|
+
}
|
|
503
|
+
return count;
|
|
504
|
+
};
|
|
505
|
+
if (!explicitStart && Number.isInteger(maxMessages) && maxMessages > 0) {
|
|
506
|
+
while (startIdx < endIdx && countMessages(startIdx, endIdx) > maxMessages) {
|
|
507
|
+
startIdx += 1;
|
|
508
|
+
adjusted = true;
|
|
509
|
+
}
|
|
510
|
+
while (startIdx < endIdx && !pairing.before(session, nodes[startIdx])) {
|
|
511
|
+
startIdx += 1;
|
|
512
|
+
adjusted = true;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1);
|
|
516
|
+
const count = countMessages(startIdx, endIdx);
|
|
517
|
+
if (count === 0) throw new Error('save: selected range contains no message-producing node');
|
|
518
|
+
return {
|
|
519
|
+
startSeq: nodes[startIdx],
|
|
520
|
+
endSeq: nodes[endIdx],
|
|
521
|
+
startIdx,
|
|
522
|
+
endIdx,
|
|
523
|
+
shadowedSeqs,
|
|
524
|
+
count,
|
|
525
|
+
adjusted,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Start of the T dominance block ending at seq. Own runs always join; other
|
|
530
|
+
* runs of <= islandMax events are override islands to cross; a larger other
|
|
531
|
+
* run is a real boundary. Returns null if seq has no containing run. */
|
|
532
|
+
export function dominanceStartSeq(tags, endSeq, { islandMax = 5 } = {}) {
|
|
533
|
+
const state = normalizeTags(tags);
|
|
534
|
+
const end = Number(endSeq);
|
|
535
|
+
if (!Number.isInteger(end)) return null;
|
|
536
|
+
let current = null;
|
|
537
|
+
for (let index = state.runs.length - 1; index >= 0; index -= 1) {
|
|
538
|
+
const run = state.runs[index];
|
|
539
|
+
if (end >= run[0] && end <= run[1]) { current = run; break; }
|
|
540
|
+
}
|
|
541
|
+
if (current === null) return null;
|
|
542
|
+
const currentId = current[2];
|
|
543
|
+
let start = current[0];
|
|
544
|
+
const earlier = state.runs
|
|
545
|
+
.filter((run) => run[0] < start)
|
|
546
|
+
.sort((left, right) => right[0] - left[0]);
|
|
547
|
+
for (const [from, to, id] of earlier) {
|
|
548
|
+
if (to >= start) {
|
|
549
|
+
if (id === currentId) start = Math.min(start, from);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
if (id === currentId) {
|
|
553
|
+
start = from;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
const length = to - from + 1;
|
|
557
|
+
if (length > islandMax) break;
|
|
558
|
+
}
|
|
559
|
+
return start;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Text carried by one raw event, with reasoning removed by default. */
|
|
563
|
+
export function quoteEventText(event) {
|
|
564
|
+
return eventText(event, { includeReasoning: false }).trim();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Coarse category used by `scan`; raw event types and roles are also accepted
|
|
568
|
+
* by the `types` filter. */
|
|
569
|
+
function scanType(event) {
|
|
570
|
+
switch (event?.type) {
|
|
571
|
+
case 'user/message':
|
|
572
|
+
case 'assistant/message':
|
|
573
|
+
case 'system/message':
|
|
574
|
+
case 'developer/message':
|
|
575
|
+
return 'message';
|
|
576
|
+
case 'tool/result':
|
|
577
|
+
return 'tool-result';
|
|
578
|
+
case 'tool/call':
|
|
579
|
+
return 'tool-call';
|
|
580
|
+
case 'compaction/summary':
|
|
581
|
+
return 'gist';
|
|
582
|
+
case 'compaction/start':
|
|
583
|
+
case 'compaction/end':
|
|
584
|
+
case 'compaction/prune':
|
|
585
|
+
return 'compaction';
|
|
586
|
+
case 'turn/start':
|
|
587
|
+
case 'turn/end':
|
|
588
|
+
return 'turn';
|
|
589
|
+
case 'step/start':
|
|
590
|
+
case 'step/end':
|
|
591
|
+
return 'step';
|
|
592
|
+
case 'request/header':
|
|
593
|
+
case 'request/context':
|
|
594
|
+
return 'request';
|
|
595
|
+
default:
|
|
596
|
+
return String(event?.type ?? 'event');
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function isSyntheticMessage(event, text) {
|
|
601
|
+
if (event?.type !== 'user/message') return false;
|
|
602
|
+
const kind = event?.data?.source?.kind;
|
|
603
|
+
if (kind === 'runtime-context' || kind === 'compact-checkpoint') return true;
|
|
604
|
+
return text.startsWith('This is an automatically generated checkpoint')
|
|
605
|
+
|| text.startsWith('[gist')
|
|
606
|
+
|| text.startsWith('[thread');
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Maximum estimated tokens for one scan payload. */
|
|
610
|
+
export const SCAN_LIST_MAX_TOKENS = 900;
|
|
611
|
+
export const SCAN_DEFAULT_HEAD_CHARS = 160;
|
|
612
|
+
export const SCAN_MAX_HEAD_CHARS = 200;
|
|
613
|
+
export const SCAN_DEFAULT_LIMIT = 20;
|
|
614
|
+
export const SCAN_MAX_LIMIT = 100;
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Cheap event index for one seq range. It never injects context and never
|
|
618
|
+
* summarizes: it returns seq/role/type/head/chars/tokens so the caller can pick
|
|
619
|
+
* exact seqs for a ranged load. Defaults: message events, no reasoning, 160-char
|
|
620
|
+
* heads, synthetic runtime-context/checkpoint messages skipped, and a payload
|
|
621
|
+
* budget below 1k estimated tokens.
|
|
622
|
+
*/
|
|
623
|
+
export function scanEvents({
|
|
624
|
+
events,
|
|
625
|
+
fromSeq,
|
|
626
|
+
toSeq,
|
|
627
|
+
seqs = null,
|
|
628
|
+
headChars = SCAN_DEFAULT_HEAD_CHARS,
|
|
629
|
+
includeReasoning = false,
|
|
630
|
+
types = null,
|
|
631
|
+
limit = SCAN_DEFAULT_LIMIT,
|
|
632
|
+
maxListTokens = SCAN_LIST_MAX_TOKENS,
|
|
633
|
+
estimate = estimateTextTokens,
|
|
634
|
+
}) {
|
|
635
|
+
const from = Number(fromSeq);
|
|
636
|
+
const to = Number(toSeq);
|
|
637
|
+
const explicitSeqs = Array.isArray(seqs)
|
|
638
|
+
? [...new Set(seqs.map((seq) => Number(seq)).filter((seq) => Number.isInteger(seq)))]
|
|
639
|
+
: null;
|
|
640
|
+
if (explicitSeqs === null) {
|
|
641
|
+
if (!Number.isInteger(from) || !Number.isInteger(to)) throw new Error('scan: fromSeq and toSeq must be integers');
|
|
642
|
+
if (from > to) throw new Error('scan: fromSeq must be <= toSeq');
|
|
643
|
+
} else if (explicitSeqs.length === 0) {
|
|
644
|
+
throw new Error('scan: seqs must contain at least one integer seq');
|
|
645
|
+
}
|
|
646
|
+
const maxHead = Math.min(SCAN_MAX_HEAD_CHARS, Math.max(20, Math.trunc(Number(headChars) || SCAN_DEFAULT_HEAD_CHARS)));
|
|
647
|
+
const maxItems = Math.min(SCAN_MAX_LIMIT, Math.max(1, Math.trunc(Number(limit) || SCAN_DEFAULT_LIMIT)));
|
|
648
|
+
const filter = Array.isArray(types) && types.length > 0 ? types.map(String) : ['message'];
|
|
649
|
+
const wanted = explicitSeqs === null ? null : new Set(explicitSeqs);
|
|
650
|
+
const ordered = (Array.isArray(events) ? events : [])
|
|
651
|
+
.filter((event) => {
|
|
652
|
+
const seq = Number(event?.seq);
|
|
653
|
+
if (!Number.isInteger(seq)) return false;
|
|
654
|
+
if (wanted !== null) return wanted.has(seq);
|
|
655
|
+
return seq >= from && seq <= to;
|
|
656
|
+
})
|
|
657
|
+
.sort((left, right) => Number(left.seq) - Number(right.seq));
|
|
658
|
+
|
|
659
|
+
const items = [];
|
|
660
|
+
let listTokens = 0;
|
|
661
|
+
for (const event of ordered) {
|
|
662
|
+
const type = scanType(event);
|
|
663
|
+
const role = eventRole(event);
|
|
664
|
+
if (!filter.some((entry) => entry === type || entry === event.type || entry === role)) continue;
|
|
665
|
+
const text = eventText(event, { includeReasoning }).trim();
|
|
666
|
+
if (text.length === 0) continue;
|
|
667
|
+
if (isSyntheticMessage(event, text)) continue;
|
|
668
|
+
const head = oneLine(text, maxHead);
|
|
669
|
+
const item = {
|
|
670
|
+
seq: Number(event.seq),
|
|
671
|
+
role,
|
|
672
|
+
type,
|
|
673
|
+
head,
|
|
674
|
+
chars: text.length,
|
|
675
|
+
tokens: estimate(text),
|
|
676
|
+
};
|
|
677
|
+
const cost = estimate(head) + 12;
|
|
678
|
+
if (items.length > 0 && listTokens + cost > maxListTokens) break;
|
|
679
|
+
items.push(item);
|
|
680
|
+
listTokens += cost;
|
|
681
|
+
if (items.length >= maxItems) break;
|
|
682
|
+
}
|
|
683
|
+
return items;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Select the newest segment of a thread's original events under a token budget.
|
|
688
|
+
* Reasoning-only events are skipped; the last non-empty event is included.
|
|
689
|
+
* @returns {{ selected: object[], tokens: number, missing: boolean }}
|
|
690
|
+
*/
|
|
691
|
+
export function selectLoadSegment({ seqs, bySeq, budget, estimate = estimateTextTokens }) {
|
|
692
|
+
const selected = [];
|
|
693
|
+
let tokens = 0;
|
|
694
|
+
if (!Array.isArray(seqs) || seqs.length === 0) return { selected, tokens, missing: true };
|
|
695
|
+
for (let index = seqs.length - 1; index >= 0; index -= 1) {
|
|
696
|
+
const seq = seqs[index];
|
|
697
|
+
const event = bySeq.get(Number(seq));
|
|
698
|
+
if (event === undefined) return { selected: [], tokens: 0, missing: true };
|
|
699
|
+
const text = quoteEventText(event);
|
|
700
|
+
if (text.length === 0) continue;
|
|
701
|
+
const eventTokens = estimate(text);
|
|
702
|
+
if (selected.length > 0 && tokens + eventTokens > budget) break;
|
|
703
|
+
selected.unshift({ seq, event, role: eventRole(event), text, tokens: eventTokens });
|
|
704
|
+
tokens += eventTokens;
|
|
705
|
+
if (tokens >= budget) break;
|
|
706
|
+
}
|
|
707
|
+
return { selected, tokens, missing: false };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Quote selected events without copying their node seqs. */
|
|
711
|
+
export function quoteSelected(selected) {
|
|
712
|
+
return selected.map((entry) => `[${entry.role}] ${entry.text}`).join('\n\n');
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function reasoningBlocksOf(event) {
|
|
716
|
+
const data = event?.data;
|
|
717
|
+
const blocks = Array.isArray(data?.message?.content)
|
|
718
|
+
? data.message.content
|
|
719
|
+
: Array.isArray(data?.content) ? data.content : Array.isArray(data) ? data : [];
|
|
720
|
+
return blocks
|
|
721
|
+
.filter((block) => block?.type === 'reasoning' && typeof block.text === 'string')
|
|
722
|
+
.map((block) => block.text.trim())
|
|
723
|
+
.filter(Boolean);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function reasoningTextOf(event) {
|
|
727
|
+
return reasoningBlocksOf(event).join('\n');
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function takeHeadTokens(text, maxTokens) {
|
|
731
|
+
if (estimateTextTokens(text) <= maxTokens) return text;
|
|
732
|
+
let low = 0;
|
|
733
|
+
let high = text.length;
|
|
734
|
+
while (low < high) {
|
|
735
|
+
const middle = Math.ceil((low + high) / 2);
|
|
736
|
+
if (estimateTextTokens(text.slice(0, middle)) <= maxTokens) low = middle;
|
|
737
|
+
else high = middle - 1;
|
|
738
|
+
}
|
|
739
|
+
return text.slice(0, low).trimEnd();
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function takeTailTokens(text, maxTokens) {
|
|
743
|
+
if (estimateTextTokens(text) <= maxTokens) return text;
|
|
744
|
+
let low = 0;
|
|
745
|
+
let high = text.length;
|
|
746
|
+
while (low < high) {
|
|
747
|
+
const middle = Math.ceil((low + high) / 2);
|
|
748
|
+
if (estimateTextTokens(text.slice(text.length - middle)) <= maxTokens) low = middle;
|
|
749
|
+
else high = middle - 1;
|
|
750
|
+
}
|
|
751
|
+
return text.slice(text.length - low).trimStart();
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** 150-head + 650-tail when one reasoning block exceeds 800 estimated tokens. */
|
|
755
|
+
export function truncateReasoning(text) {
|
|
756
|
+
const value = String(text ?? '').trim();
|
|
757
|
+
if (estimateTextTokens(value) <= REASONING_MAX_TOKENS) {
|
|
758
|
+
return { text: value, truncated: false };
|
|
759
|
+
}
|
|
760
|
+
const head = takeHeadTokens(value, REASONING_HEAD_TOKENS);
|
|
761
|
+
const tail = takeTailTokens(value, REASONING_TAIL_TOKENS);
|
|
762
|
+
return {
|
|
763
|
+
text: `${head}\n… [reasoning truncated] …\n${tail}`,
|
|
764
|
+
truncated: true,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Nearest reasoning inside the assistant message, or immediately before it in
|
|
769
|
+
* the same turn. A reasoning-only event is consumed once. */
|
|
770
|
+
function pairedReasoningFor(entry, bySeq, used) {
|
|
771
|
+
if (entry?.event?.type !== 'assistant/message') return null;
|
|
772
|
+
const own = reasoningTextOf(entry.event);
|
|
773
|
+
if (own.length > 0) return own;
|
|
774
|
+
for (let seq = Number(entry.seq) - 1; seq >= 0; seq -= 1) {
|
|
775
|
+
const previous = bySeq.get(seq);
|
|
776
|
+
if (previous === undefined) continue;
|
|
777
|
+
if (previous.type === 'turn/start' || previous.type === 'turn/end') break;
|
|
778
|
+
const body = quoteEventText(previous);
|
|
779
|
+
if (body.length > 0) break;
|
|
780
|
+
const reasoning = reasoningTextOf(previous);
|
|
781
|
+
if (reasoning.length === 0) continue;
|
|
782
|
+
if (used.has(seq)) return null;
|
|
783
|
+
used.add(seq);
|
|
784
|
+
return reasoning;
|
|
785
|
+
}
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function reasoningLine(entry) {
|
|
790
|
+
if (typeof entry.reasoningText !== 'string' || entry.reasoningText.length === 0) return '';
|
|
791
|
+
return `[reasoning${entry.reasoningTruncated ? ' · 截断' : ''}]\n${entry.reasoningText}`;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Chronological reasoning load under one body+reasoning budget. */
|
|
795
|
+
export function applyReasoningBudget({
|
|
796
|
+
selected,
|
|
797
|
+
bySeq,
|
|
798
|
+
budget = REASONING_LOAD_BUDGET,
|
|
799
|
+
} = {}) {
|
|
800
|
+
const ordered = [...(Array.isArray(selected) ? selected : [])]
|
|
801
|
+
.sort((left, right) => Number(left.seq) - Number(right.seq));
|
|
802
|
+
const used = new Set();
|
|
803
|
+
const kept = [];
|
|
804
|
+
let tokens = 0;
|
|
805
|
+
let reasoningInjected = 0;
|
|
806
|
+
let reasoningTokens = 0;
|
|
807
|
+
let skipped = 0;
|
|
808
|
+
for (let index = 0; index < ordered.length; index += 1) {
|
|
809
|
+
const entry = ordered[index];
|
|
810
|
+
const rawReasoning = pairedReasoningFor(entry, bySeq, used);
|
|
811
|
+
const reasoning = rawReasoning === null ? null : truncateReasoning(rawReasoning);
|
|
812
|
+
const line = reasoning === null ? '' : reasoningLine({
|
|
813
|
+
reasoningText: reasoning.text,
|
|
814
|
+
reasoningTruncated: reasoning.truncated,
|
|
815
|
+
});
|
|
816
|
+
const rendered = `[${entry.role}] ${entry.text}${line.length > 0 ? `\n\n${line}` : ''}`;
|
|
817
|
+
const cost = estimateTextTokens(rendered);
|
|
818
|
+
if (tokens + cost > budget) {
|
|
819
|
+
skipped = ordered.length - index;
|
|
820
|
+
break;
|
|
821
|
+
}
|
|
822
|
+
const enriched = reasoning === null
|
|
823
|
+
? { ...entry }
|
|
824
|
+
: {
|
|
825
|
+
...entry,
|
|
826
|
+
reasoningText: reasoning.text,
|
|
827
|
+
reasoningTruncated: reasoning.truncated,
|
|
828
|
+
reasoningTokens: estimateTextTokens(reasoning.text),
|
|
829
|
+
};
|
|
830
|
+
kept.push(enriched);
|
|
831
|
+
tokens += cost;
|
|
832
|
+
if (reasoning !== null) {
|
|
833
|
+
reasoningInjected += 1;
|
|
834
|
+
reasoningTokens += estimateTextTokens(reasoning.text);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return { selected: kept, tokens, reasoningInjected, reasoningTokens, skipped };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** Quote reasoning loads chronologically; reasoning stays separate from body. */
|
|
841
|
+
export function quoteSelectedWithReasoning(selected) {
|
|
842
|
+
return [...(Array.isArray(selected) ? selected : [])]
|
|
843
|
+
.sort((left, right) => Number(left.seq) - Number(right.seq))
|
|
844
|
+
.map((entry) => `[${entry.role}] ${entry.text}${reasoningLine(entry).length > 0 ? `\n\n${reasoningLine(entry)}` : ''}`)
|
|
845
|
+
.join('\n\n');
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** Surface seqs that must survive automatic compaction for the active thread. */
|
|
849
|
+
export function activePinSeqsFromState(state, surfaceNodes) {
|
|
850
|
+
const pins = new Set();
|
|
851
|
+
const active = Array.isArray(state?.threads)
|
|
852
|
+
? state.threads.find((thread) => thread.id === state.activeId)
|
|
853
|
+
: null;
|
|
854
|
+
if (active === null || active === undefined) return pins;
|
|
855
|
+
const candidates = [active.loadSeq, active.anchorSeq];
|
|
856
|
+
for (const seq of candidates) {
|
|
857
|
+
if (seq === undefined || seq === null || seq === '') continue;
|
|
858
|
+
if (surfaceNodes.some((node) => Number(node) === Number(seq))) pins.add(seq);
|
|
859
|
+
}
|
|
860
|
+
return pins;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function latestSegmentOf(thread) {
|
|
864
|
+
if (Array.isArray(thread?.segments) && thread.segments.length > 0) {
|
|
865
|
+
return thread.segments[thread.segments.length - 1];
|
|
866
|
+
}
|
|
867
|
+
return normalizeSegment(thread, thread);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function segmentCountOf(thread) {
|
|
871
|
+
return Array.isArray(thread?.segments) && thread.segments.length > 0
|
|
872
|
+
? thread.segments.length
|
|
873
|
+
: 1;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** `active` tool result. */
|
|
877
|
+
export function activeView(state) {
|
|
878
|
+
const threads = Array.isArray(state?.threads) ? state.threads : [];
|
|
879
|
+
const active = threads.find((thread) => thread.id === state?.activeId) ?? null;
|
|
880
|
+
if (active === null) return null;
|
|
881
|
+
const latest = latestSegmentOf(active);
|
|
882
|
+
const segments = segmentCountOf(active);
|
|
883
|
+
return {
|
|
884
|
+
id: active.id,
|
|
885
|
+
label: active.label,
|
|
886
|
+
next: latest.next,
|
|
887
|
+
closed: threads.filter((thread) => thread.id !== active.id).length,
|
|
888
|
+
segments,
|
|
889
|
+
lastAt: latest.at,
|
|
890
|
+
display: `${active.label} · ${segments} 段 · 最近 ${formatClock(latest.at)}`,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** `list` tool result. */
|
|
895
|
+
export function listView(state) {
|
|
896
|
+
const threads = Array.isArray(state?.threads) ? state.threads : [];
|
|
897
|
+
return threads.map((thread) => {
|
|
898
|
+
const latest = latestSegmentOf(thread);
|
|
899
|
+
const segments = segmentCountOf(thread);
|
|
900
|
+
return {
|
|
901
|
+
id: thread.id,
|
|
902
|
+
label: thread.label,
|
|
903
|
+
active: Boolean(thread.active),
|
|
904
|
+
span: `${thread.spanStart ?? '?'} → ${thread.spanEnd ?? '?'}`,
|
|
905
|
+
minutes: thread.minutes,
|
|
906
|
+
tokens: thread.tokens,
|
|
907
|
+
segments,
|
|
908
|
+
lastAt: latest.at,
|
|
909
|
+
display: `${thread.label} · ${segments} 段 · 最近 ${formatClock(latest.at)}`,
|
|
910
|
+
};
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** Thread summary returned to the model. */
|
|
915
|
+
export function threadView(thread) {
|
|
916
|
+
const latest = latestSegmentOf(thread);
|
|
917
|
+
return {
|
|
918
|
+
id: thread.id,
|
|
919
|
+
label: thread.label,
|
|
920
|
+
span: `${thread.spanStart ?? '?'} → ${thread.spanEnd ?? '?'}`,
|
|
921
|
+
minutes: thread.minutes,
|
|
922
|
+
tokens: thread.tokens,
|
|
923
|
+
seqStart: thread.seqStart,
|
|
924
|
+
seqEnd: thread.seqEnd,
|
|
925
|
+
active: Boolean(thread.active),
|
|
926
|
+
segments: segmentCountOf(thread),
|
|
927
|
+
lastAt: latest.at,
|
|
928
|
+
};
|
|
929
|
+
}
|