@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/engine.mjs
ADDED
|
@@ -0,0 +1,3831 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-continuity — band-controlled compaction engine.
|
|
3
|
+
*
|
|
4
|
+
* Extends the shipped `BasicCompactionEngine` so the durable replacement
|
|
5
|
+
* transaction, balancing rules, overwrite detection, and manual `/compact`
|
|
6
|
+
* path stay inherited. This subclass changes only three things:
|
|
7
|
+
*
|
|
8
|
+
* 1. `compactIfNeeded` — a band controller chooses a *small* oldest range
|
|
9
|
+
* (`measure` + `planFold` + `oldestRange`) instead of the base
|
|
10
|
+
* threshold-and-cliff cut; overflow still delegates to the base.
|
|
11
|
+
* 2. `oldestRange` — walk the session surface from the oldest non-system
|
|
12
|
+
* node, stop at the pass budget or the protected tail, and move the end
|
|
13
|
+
* back to a tool-pairing-balanced cut.
|
|
14
|
+
* 3. `summarize` — prepend the provenance header for the selected range, so
|
|
15
|
+
* the replacement checkpoint says which stretch it is a gist of.
|
|
16
|
+
*
|
|
17
|
+
* The base class is resolved from the Host's own package installation when
|
|
18
|
+
* possible (Electron `app.asar`), because `Service` registration and the
|
|
19
|
+
* compaction service must come from the same Cordis generation. A plain
|
|
20
|
+
* `@deepseek-ai/*` import is the portable fallback.
|
|
21
|
+
*
|
|
22
|
+
* The module deliberately has no static DSH imports: a failed base import is
|
|
23
|
+
* reported by the panel instead of preventing the Host row from loading.
|
|
24
|
+
*/
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { appendFileSync, existsSync, readFileSync, unlinkSync as fsUnlink, writeFileSync } from 'node:fs';
|
|
27
|
+
import { readSkillsIndex, skillsForThreads, skillsForKeywords, buildSkillsBlock } from './skills.mjs';
|
|
28
|
+
import { note, topCandidates, scanResults } from './learning.mjs';
|
|
29
|
+
import { detectGroups, nameFromEvidence } from './groups.mjs';
|
|
30
|
+
import { extractMood, parseMoodLog, selectMood, shouldRecord } from './mood.mjs';
|
|
31
|
+
import { trainingMode, autoLearn } from './training.mjs';
|
|
32
|
+
import { needsFullInjection, needsSkillInjection } from './inject-policy.mjs';
|
|
33
|
+
import { maybeBackupSession } from './backup.mjs';
|
|
34
|
+
import { selectTopic } from './topic.mjs';
|
|
35
|
+
import {
|
|
36
|
+
bumpTagCount,
|
|
37
|
+
resetTagCount,
|
|
38
|
+
parseRecheckAnswer,
|
|
39
|
+
recheckAutoText,
|
|
40
|
+
recheckRetryText,
|
|
41
|
+
} from './recheck.mjs';
|
|
42
|
+
import { attributeTurnSeqs, deriveTurnTag } from './turn-tag.mjs';
|
|
43
|
+
import { findTagDeclaration } from './tag-detect.mjs';
|
|
44
|
+
import { findNearLabel, hasWordEvidence, splitNewToken, tagTokenFromDeclaration } from './tag-confirm.mjs';
|
|
45
|
+
import { consumeTagReject, recordTagReject } from './tag-reject.mjs';
|
|
46
|
+
import { latestUserTurnFromSession, messageSourceKind } from './user-turn.mjs';
|
|
47
|
+
import { loadAnswered, filterAnswered } from './learning.mjs';
|
|
48
|
+
|
|
49
|
+
import { pathToFileURL } from 'node:url';
|
|
50
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
51
|
+
import { eventText } from './session-log.mjs';
|
|
52
|
+
import { buildHistoryMap } from './memory/history-map.mjs';
|
|
53
|
+
import { renderMechanicalNote } from './memory/mechanical-note.mjs';
|
|
54
|
+
import { readSummaryIndex, summaryIndexPath } from './memory/summary-index.mjs';
|
|
55
|
+
import { assetMarker, writeAsset } from './assets.mjs';
|
|
56
|
+
import { planFold, resolvePolicy, calibrateProtect } from './policy.mjs';
|
|
57
|
+
import { annotateSummary, gistHeader, SYSTEM_CONVENTION } from './provenance.mjs';
|
|
58
|
+
import { selectOldestRange } from './range.mjs';
|
|
59
|
+
import { planFacetFold, loadFacetFoldFile, defaultFacetFoldFile } from './facets/fold-plan.mjs';
|
|
60
|
+
import {
|
|
61
|
+
activeLine,
|
|
62
|
+
activePinSeqsFromState,
|
|
63
|
+
contextMessage,
|
|
64
|
+
dominanceStartSeq,
|
|
65
|
+
isoOf,
|
|
66
|
+
latestTurnStartSeq,
|
|
67
|
+
minutesBetween,
|
|
68
|
+
normalizeThread,
|
|
69
|
+
RESIDUE_MAX_CHARS,
|
|
70
|
+
selectSaveRange,
|
|
71
|
+
summarizeRules,
|
|
72
|
+
tagStatusLine,
|
|
73
|
+
threadContextText,
|
|
74
|
+
threadGistText,
|
|
75
|
+
gateMessage,
|
|
76
|
+
} from './threads.mjs';
|
|
77
|
+
import { setThreadsBackend } from './threads-backend.mjs';
|
|
78
|
+
import { continuityHome, getSession, readRules, readHabits, updateSession } from './threads-store.mjs';
|
|
79
|
+
import { readTags, updateTags, writeTagsMd } from './tags-store.mjs';
|
|
80
|
+
import { runMaintenance } from './tag-maintenance.mjs';
|
|
81
|
+
import {
|
|
82
|
+
migrateAliasTags,
|
|
83
|
+
renderTagsMd,
|
|
84
|
+
resolveTag,
|
|
85
|
+
tagCoverageForRange,
|
|
86
|
+
tagsView,
|
|
87
|
+
TAG_ZERO,
|
|
88
|
+
matchableTags,
|
|
89
|
+
canonicalTagId,
|
|
90
|
+
} from './tags.mjs';
|
|
91
|
+
import { recordFold } from './folds-store.mjs';
|
|
92
|
+
import {
|
|
93
|
+
activeCallIds,
|
|
94
|
+
activePinSeqs,
|
|
95
|
+
compressionBudget,
|
|
96
|
+
eventContentBlocks,
|
|
97
|
+
handleNonTextToolResult,
|
|
98
|
+
isImageToolResult,
|
|
99
|
+
nonTextInfo,
|
|
100
|
+
oldestPinIndex,
|
|
101
|
+
pauseActiveToolResult,
|
|
102
|
+
previewSummaryInput,
|
|
103
|
+
previewToolResultEvent,
|
|
104
|
+
targetFractionFor,
|
|
105
|
+
toolCallMap,
|
|
106
|
+
} from './tool-preview.mjs';
|
|
107
|
+
import {
|
|
108
|
+
continuityState,
|
|
109
|
+
markEngine,
|
|
110
|
+
markEngineSession,
|
|
111
|
+
markThreadsSnapshot,
|
|
112
|
+
recordContext,
|
|
113
|
+
recordError,
|
|
114
|
+
recordPass,
|
|
115
|
+
recordPreview,
|
|
116
|
+
} from './state.mjs';
|
|
117
|
+
|
|
118
|
+
/** Normalize a base result's shadowed span; the inherited engine can return reversed ends. */
|
|
119
|
+
function shadowedSpan(result) {
|
|
120
|
+
const raw = result?.shadowedRange;
|
|
121
|
+
const a = Number(raw?.start);
|
|
122
|
+
const b = Number(raw?.end);
|
|
123
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
|
|
124
|
+
return { start: Math.min(a, b), end: Math.max(a, b) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One line the model can read after a cut, derived from the same pass record. */
|
|
128
|
+
function passReceiptLine(pass) {
|
|
129
|
+
const n = (value) => Number.isFinite(value) ? Math.round(value).toLocaleString('en-US') : '?';
|
|
130
|
+
const range = pass.start === undefined || pass.end === undefined
|
|
131
|
+
? 'seq ?'
|
|
132
|
+
: `seq ${pass.start}\u2013${pass.end}`;
|
|
133
|
+
const window = Number.isFinite(pass.windowTokens) ? ` \u00b7 window ${n(pass.windowTokens)}` : '';
|
|
134
|
+
const policy = pass.protectBinds === true
|
|
135
|
+
? ` \u00b7 protectBinds \u00b7 ${pass.protectSource ?? 'default'} protect ${Number.isFinite(pass.protectEffective) ? pass.protectEffective.toFixed(3) : '?'} \u00b7 preBaseline ${n(pass.preBaselineTokens)} \u00b7 floor ${n(pass.floorTokens)} \u2265 low ${n(pass.lowTargetTokens)} \u00b7 after ${n(pass.afterTokens)}`
|
|
136
|
+
+ (Number.isFinite(pass.protectSuggested) ? ` \u00b7 suggest protect \u2264 ${pass.protectSuggested.toFixed(3)}` : '')
|
|
137
|
+
+ (pass.lowUnreachable && Number.isFinite(pass.lowMinTokens) ? ` \u00b7 low must be \u2265 ${n(pass.lowMinTokens)}` : '')
|
|
138
|
+
+ (pass.driftReported ? ` \u00b7 baseline drifted from ${n(pass.protectB0)}` : '')
|
|
139
|
+
: '';
|
|
140
|
+
const drift = pass.driftReported
|
|
141
|
+
? ` \u00b7 drift ${Number.isFinite(pass.driftDelta) ? pass.driftDelta.toFixed(3) : '?'} \u00b7 suggested protect \u2264 ${Number.isFinite(pass.protectSuggested) ? pass.protectSuggested.toFixed(3) : '?'}`
|
|
142
|
+
+ (Number.isFinite(pass.lowMinTokens) ? ` \u00b7 low must be \u2265 ${n(pass.lowMinTokens)}` : '')
|
|
143
|
+
: '';
|
|
144
|
+
return `[continuity:fold] ${range} \u00b7 ${pass.count ?? '?'} \u6761 \u00b7 ${n(pass.tokens)} tokens \u00b7 reason=${pass.reason ?? '?'} \u00b7 \u538b\u524d ${n(pass.estTokens)} \u2192 \u538b\u540e ${n(pass.afterTokens)}${window}${policy}${drift}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Append the receipt as a user-role line; never let diagnostics block the pass. */
|
|
148
|
+
function appendPassReceipt(agent, pass) {
|
|
149
|
+
try {
|
|
150
|
+
agent?.session?.append?.('user/message', contextMessage(passReceiptLine(pass)), { surfaceOp: 'append' });
|
|
151
|
+
} catch (error) {
|
|
152
|
+
try { recordError(error?.message ?? String(error), 'pass-receipt'); } catch { /* ignore */ }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
/** One standing convention, mounted once in the preset's system-prompt layer. */
|
|
158
|
+
const SECTION_NAME = 'continuity:provenance';
|
|
159
|
+
|
|
160
|
+
/** After the tool sections (5000–9000) and before structured output (9900). */
|
|
161
|
+
const SECTION_ORDER = 9500;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Import a DSH package, preferring the running Host's own copy.
|
|
165
|
+
*
|
|
166
|
+
* `process.resourcesPath` exists in the Electron desktop shell, whose dsh
|
|
167
|
+
* runtime lives under `resources/app.asar/dsh`. The bare specifier fallback
|
|
168
|
+
* serves CLI profiles and tests.
|
|
169
|
+
*/
|
|
170
|
+
async function importHostPackage(name) {
|
|
171
|
+
const candidates = [];
|
|
172
|
+
const resources = process.resourcesPath;
|
|
173
|
+
if (typeof resources === 'string' && resources.length > 0) {
|
|
174
|
+
candidates.push(pathToFileURL(join(
|
|
175
|
+
resources, 'app.asar', 'dsh', 'node_modules', '@deepseek-ai', name, 'lib', 'index.js',
|
|
176
|
+
)).href);
|
|
177
|
+
candidates.push(pathToFileURL(join(
|
|
178
|
+
resources, 'app.asar', 'node_modules', '@deepseek-ai', name, 'lib', 'index.js',
|
|
179
|
+
)).href);
|
|
180
|
+
}
|
|
181
|
+
candidates.push(`@deepseek-ai/${name}`);
|
|
182
|
+
|
|
183
|
+
const failures = [];
|
|
184
|
+
for (const candidate of candidates) {
|
|
185
|
+
try {
|
|
186
|
+
return { module: await import(candidate), source: candidate };
|
|
187
|
+
} catch (error) {
|
|
188
|
+
failures.push(`${candidate} (${error?.message ?? error})`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
throw new Error(`dsh-continuity: cannot load @deepseek-ai/${name}; tried: ${failures.join(' | ')}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const basicImport = await importHostPackage('dsh-compaction-basic');
|
|
195
|
+
const compactionImport = await importHostPackage('dsh-compaction');
|
|
196
|
+
|
|
197
|
+
const BaseEngine = basicImport.module.BasicCompactionEngine ?? basicImport.module.default;
|
|
198
|
+
const toolPairingBalancedBefore = compactionImport.module.toolPairingBalancedBefore;
|
|
199
|
+
const toolPairingBalancedAfter = compactionImport.module.toolPairingBalancedAfter;
|
|
200
|
+
|
|
201
|
+
if (typeof BaseEngine !== 'function') {
|
|
202
|
+
throw new Error('dsh-continuity: @deepseek-ai/dsh-compaction-basic exported no BasicCompactionEngine');
|
|
203
|
+
}
|
|
204
|
+
if (typeof toolPairingBalancedBefore !== 'function' || typeof toolPairingBalancedAfter !== 'function') {
|
|
205
|
+
throw new Error('dsh-continuity: @deepseek-ai/dsh-compaction exported no tool-pairing predicates');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** @typedef {{ session: object, options?: { provider?: string, model?: string } }} AgentContext */
|
|
209
|
+
|
|
210
|
+
export class ContinuityEngine extends BaseEngine {
|
|
211
|
+
static inject = Array.isArray(BaseEngine.inject)
|
|
212
|
+
? [...BaseEngine.inject]
|
|
213
|
+
: ['llm', 'tokenMeter', 'sessions'];
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
217
|
+
* @param {{ policy?: object, provenance?: object, compactionBasic?: object }} [config]
|
|
218
|
+
*/
|
|
219
|
+
constructor(ctx, config = {}) {
|
|
220
|
+
super(ctx, config.compactionBasic ?? {});
|
|
221
|
+
this.continuity = resolvePolicy(config.policy ?? {});
|
|
222
|
+
this.mechanicalNote = config.mechanicalNote === true;
|
|
223
|
+
this.mechanicalNoteTokens = Number.isFinite(Number(config.mechanicalNoteTokens))
|
|
224
|
+
? Math.max(400, Math.trunc(Number(config.mechanicalNoteTokens)))
|
|
225
|
+
: 32_000;
|
|
226
|
+
this.protectCalibrationEnabled = config.protectCalibration !== false;
|
|
227
|
+
this.facetNative = config.facetNative === true;
|
|
228
|
+
this.facetFold = config.facetFold ?? null;
|
|
229
|
+
if (this.facetFold === null) {
|
|
230
|
+
const file = defaultFacetFoldFile();
|
|
231
|
+
try {
|
|
232
|
+
if (existsSync(file)) this.facetFold = loadFacetFoldFile(file);
|
|
233
|
+
} catch {
|
|
234
|
+
this.facetFold = null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const provenance = config.provenance ?? {};
|
|
238
|
+
this.provenance = {
|
|
239
|
+
header: provenance.header !== false,
|
|
240
|
+
standingConvention: provenance.standingConvention !== false,
|
|
241
|
+
};
|
|
242
|
+
const threadConfig = config.threads ?? {};
|
|
243
|
+
this.threads = {
|
|
244
|
+
inject: threadConfig.inject !== false,
|
|
245
|
+
pin: threadConfig.pin !== false,
|
|
246
|
+
pointersOnly: threadConfig.pointersOnly === true,
|
|
247
|
+
};
|
|
248
|
+
this.turnGateEnabled = config.turnGate?.enabled !== false;
|
|
249
|
+
/** 回合闸:每轮最多催几次补标(防无限循环)。 */
|
|
250
|
+
this.turnSteerCount = new Map();
|
|
251
|
+
/** 回合闸:每轮答错几次(用来在消息里写「你已经回答错误 N 次」)。 */
|
|
252
|
+
this.turnTagErrors = new Map();
|
|
253
|
+
/** 第一次打标教学:每个会话只出现一次。 */
|
|
254
|
+
this.tagLearnShown = new Set();
|
|
255
|
+
/** 回查闸:turn → { threadId, alias, invalid },只在一轮内有效。 */
|
|
256
|
+
this.recheckPending = new Map();
|
|
257
|
+
/** 回合闸的安全阀状态:{ startedAt, lastHash, repeats },防同一轮无限循环。 */
|
|
258
|
+
this.turnGate = new Map();
|
|
259
|
+
/** 回合闸去重:turn → 上次处理时间(engine 与 index 两边都会注册,防重复执行)。 */
|
|
260
|
+
this.turnGateLastRun = new Map();
|
|
261
|
+
// 回合闸(2026-10-30):宿主在轮次即将关闭时派发 'agent/turn-stopping',
|
|
262
|
+
// 监听者可以 steer 让本轮保持打开。这就是“没打标不许收尾”的落点——
|
|
263
|
+
// 不是去锁 save(那只会让实例最后存不下东西、消失),而是不让这一轮就这么结束。
|
|
264
|
+
try {
|
|
265
|
+
if (typeof this.ctx?.on !== 'function') {
|
|
266
|
+
// 引擎的 ctx 接不到 agent 事件是已知情况;真正注册在 index.mjs 的 apply() 里。
|
|
267
|
+
// 这里只留个痕,方便对照“到底哪条路在生效”。
|
|
268
|
+
appendFileSync(join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'hook-notes.jsonl'),
|
|
269
|
+
JSON.stringify({ at: new Date().toISOString(), note: 'engine ctx 没有 on(),跳过自身注册(改由 index.apply 注册)' }) + '\n', 'utf8');
|
|
270
|
+
} else {
|
|
271
|
+
this.ctx.on('agent/turn-stopping', (payload) => {
|
|
272
|
+
void this.onTurnStopping(payload);
|
|
273
|
+
});
|
|
274
|
+
// Compaction rebuilds the surface; a just-issued steer can be swallowed.
|
|
275
|
+
// Clear the one-time teaching marker so the next error teaches again.
|
|
276
|
+
this.ctx.on('compaction/start', () => { this.tagLearnShown.clear(); });
|
|
277
|
+
}
|
|
278
|
+
} catch (error) {
|
|
279
|
+
try {
|
|
280
|
+
appendFileSync(join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'gate-errors.jsonl'),
|
|
281
|
+
JSON.stringify({ at: new Date().toISOString(), name: error?.name ?? 'Error', code: 'turn-stopping-register', message: String(error?.message ?? error) }) + '\n', 'utf8');
|
|
282
|
+
} catch { /* 注册失败只影响这道闸 */ }
|
|
283
|
+
}
|
|
284
|
+
/** Header for the next summarize() call, keyed by agent (concurrent sessions are independent). */
|
|
285
|
+
this.headers = new WeakMap();
|
|
286
|
+
/** Last measurement per agent, reused by oldestRange(). */
|
|
287
|
+
this.measured = new WeakMap();
|
|
288
|
+
/** First-band-high calibration per agent; frozen for the session. */
|
|
289
|
+
this.protectCalibration = new WeakMap();
|
|
290
|
+
this.protectDriftWarned = new WeakSet();
|
|
291
|
+
/** Resolved model metadata, cached per provider/model. */
|
|
292
|
+
this.modelInfo = new Map();
|
|
293
|
+
this.warned = new Set();
|
|
294
|
+
/** One-shot thread gist text for the next summarize(), keyed by agent. */
|
|
295
|
+
this.threadGist = new WeakMap();
|
|
296
|
+
/** Last turn whose active/rules line was injected, keyed by agent. */
|
|
297
|
+
this.threadInjectTurn = new WeakMap();
|
|
298
|
+
/** Last skill set injected into a turn, keyed by agent. */
|
|
299
|
+
this.skillInjectKey = new WeakMap();
|
|
300
|
+
/** seq of the message that carried the skill body, so we can tell if it survived compaction. */
|
|
301
|
+
this.skillMsgSeq = new WeakMap();
|
|
302
|
+
/** seq of the message that carried the full rules text. */
|
|
303
|
+
this.rulesMsgSeq = new WeakMap();
|
|
304
|
+
/** seq + content hash of the active history-map carrier. */
|
|
305
|
+
this.historyMapSeq = new WeakMap();
|
|
306
|
+
this.historyMapKey = new WeakMap();
|
|
307
|
+
/** Per-agent cache of compaction/summary events, advanced by seq. */
|
|
308
|
+
this.historySummaryCache = new WeakMap();
|
|
309
|
+
/** Per-session hash of the last bootstrap append, to survive repeated pre-steps. */
|
|
310
|
+
this.historyBootstrap = new Map();
|
|
311
|
+
/** Hash of the rules text last injected, so edits force a fresh full copy. */
|
|
312
|
+
this.rulesSignature = new WeakMap();
|
|
313
|
+
/** habits.md shares the same carrier as rules; separate hashes for change detection. */
|
|
314
|
+
this.habitsMsgSeq = new WeakMap();
|
|
315
|
+
this.habitsSignature = new WeakMap();
|
|
316
|
+
/** Last header signature + seq, so unchanged turns reuse the message already in surface. */
|
|
317
|
+
this.headerState = new WeakMap();
|
|
318
|
+
/** Same segment's [存?] prompt is emitted once. */
|
|
319
|
+
this.saveAsked = new WeakMap();
|
|
320
|
+
/** Rules-size warning is emitted once per size signature. */
|
|
321
|
+
this.rulesSizeShown = new WeakMap();
|
|
322
|
+
/** 学习候选:工具回执里的重复错误签名 → {count, firstSeq, lastSeq}。 */
|
|
323
|
+
this.learning = new Map();
|
|
324
|
+
this.learningCursor = 0;
|
|
325
|
+
this.learningShown = new Set();
|
|
326
|
+
this.learningInit = false;
|
|
327
|
+
/** 分叉闸:工具回执指纹 / 连续无新信息轮数 / 上次提醒值。 */
|
|
328
|
+
this.infoHashes = new Set();
|
|
329
|
+
this.infoCursor = 0;
|
|
330
|
+
this.infoInit = false;
|
|
331
|
+
this.stallTurns = 0;
|
|
332
|
+
this.stallShownAt = 0;
|
|
333
|
+
/** 回查状态持久化在 recheck-state.json,不再放在实例 Map 里。 */
|
|
334
|
+
/** 存提醒:{ [threadId]: { toSeq, turns } } */
|
|
335
|
+
this.segIdle = new Map();
|
|
336
|
+
/** mood:扫描游标与初始化标记。 */
|
|
337
|
+
this.moodCursor = 0;
|
|
338
|
+
this.moodInit = false;
|
|
339
|
+
/** 回正测试:{ [threadId]: { turns, asked } } */
|
|
340
|
+
this.rebuildTurns = new Map();
|
|
341
|
+
/** 归拢提议:已问过的键,避免重复提问。 */
|
|
342
|
+
this.groupShown = new Set();
|
|
343
|
+
/** Bridge the preset-root thread tool to this isolated compaction service. */
|
|
344
|
+
setThreadsBackend({
|
|
345
|
+
prepareSave: (agent, args) => this.prepareSave(agent, args),
|
|
346
|
+
saveThread: (agent, args, signal) => this.saveThread(agent, args, signal),
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
if (this.provenance.standingConvention) this.mountConvention();
|
|
350
|
+
|
|
351
|
+
const resolved = this.config ?? {};
|
|
352
|
+
markEngine({
|
|
353
|
+
instance: this,
|
|
354
|
+
policy: this.continuity,
|
|
355
|
+
provenance: this.provenance,
|
|
356
|
+
compactionBasic: {
|
|
357
|
+
thresholdRatio: resolved.thresholdRatio,
|
|
358
|
+
retainRatio: resolved.retainRatio,
|
|
359
|
+
retainTokens: resolved.retainTokens,
|
|
360
|
+
headroomTokens: resolved.headroomTokens,
|
|
361
|
+
maxTokens: resolved.maxTokens,
|
|
362
|
+
auto: resolved.auto,
|
|
363
|
+
},
|
|
364
|
+
base: {
|
|
365
|
+
package: basicImport.source,
|
|
366
|
+
pairing: compactionImport.source,
|
|
367
|
+
standingConvention: this.provenance.standingConvention ? 'pending' : 'disabled',
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// 0.8.57: deterministic tag hygiene. Startup is best-effort; save is strict.
|
|
372
|
+
void runMaintenance({ mode: 'startup' }).catch((error) => {
|
|
373
|
+
try { this.logger?.warn?.(`[dsh-continuity] tag maintenance failed: ${error?.message ?? error}`); } catch { /* ignore */ }
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Register SYSTEM_CONVENTION once, in this engine's (preset) scope. */
|
|
378
|
+
mountConvention() {
|
|
379
|
+
const register = (scoped) => {
|
|
380
|
+
scoped.systemPrompt.section({
|
|
381
|
+
name: SECTION_NAME,
|
|
382
|
+
order: SECTION_ORDER,
|
|
383
|
+
text: SYSTEM_CONVENTION,
|
|
384
|
+
interpolate: false,
|
|
385
|
+
});
|
|
386
|
+
const engine = continuityState.engine;
|
|
387
|
+
if (engine !== null) engine.base.standingConvention = 'registered';
|
|
388
|
+
};
|
|
389
|
+
if (typeof this.ctx.inject === 'function') {
|
|
390
|
+
this.ctx.inject(['systemPrompt'], register);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (this.ctx.get('systemPrompt') !== undefined) register(this.ctx);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Read the used and usable token budget for the agent's latest routed
|
|
398
|
+
* request: `ctx.tokenMeter.measure(session)` for pressure and
|
|
399
|
+
* `ctx.llm.resolveModelInfo(...).context.contextWindow` minus the request's
|
|
400
|
+
* reserved completion tokens for capacity.
|
|
401
|
+
*
|
|
402
|
+
* @param {AgentContext} agent
|
|
403
|
+
* @param {AbortSignal} [signal]
|
|
404
|
+
* @returns {Promise<{ estTokens: number, windowTokens: number, contextWindow: number|null, reservedTokens: number, surfaceTokens: number, provider: string, model: string, measurement: object }>}
|
|
405
|
+
*/
|
|
406
|
+
async measure(agent, signal) {
|
|
407
|
+
const session = agent.session;
|
|
408
|
+
const measurement = this.ctx.tokenMeter.measure(session);
|
|
409
|
+
const header = session.requestHeader();
|
|
410
|
+
const headerConfig = header?.config;
|
|
411
|
+
const fallback = agent.options ?? {};
|
|
412
|
+
const provider = headerConfig?.provider || fallback.provider || '';
|
|
413
|
+
const model = headerConfig?.model || fallback.model || '';
|
|
414
|
+
|
|
415
|
+
let contextWindow = null;
|
|
416
|
+
let defaultMaxTokens = null;
|
|
417
|
+
if (provider.length > 0 && model.length > 0) {
|
|
418
|
+
try {
|
|
419
|
+
const info = await this.modelInfoFor(provider, model);
|
|
420
|
+
contextWindow = Number.isInteger(info?.context?.contextWindow) ? info.context.contextWindow : null;
|
|
421
|
+
defaultMaxTokens = Number.isInteger(info?.defaultMaxTokens) ? info.defaultMaxTokens : null;
|
|
422
|
+
} catch (error) {
|
|
423
|
+
this.warnOnce(
|
|
424
|
+
`model-info:${provider}/${model}`,
|
|
425
|
+
`dsh-continuity: cannot resolve ${provider}/${model}: ${error?.message ?? error}`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const reservedTokens = Number.isInteger(headerConfig?.maxTokens)
|
|
431
|
+
? headerConfig.maxTokens
|
|
432
|
+
: (defaultMaxTokens ?? 0);
|
|
433
|
+
const windowTokens = contextWindow === null ? 0 : Math.max(0, contextWindow - reservedTokens);
|
|
434
|
+
const surfaceNodes = Array.isArray(measurement.nodes) ? measurement.nodes : [];
|
|
435
|
+
const nodeCount = surfaceNodes.length;
|
|
436
|
+
const nodeTokenSum = surfaceNodes.reduce(
|
|
437
|
+
(sum, node) => sum + (Number.isFinite(node?.tokens) ? node.tokens : 0),
|
|
438
|
+
0,
|
|
439
|
+
);
|
|
440
|
+
const measurementBaseline = Number.isFinite(measurement.baseline) ? measurement.baseline : null;
|
|
441
|
+
const baselineTokens = Math.max(0, measurement.totalTokens - measurement.surfaceTokens);
|
|
442
|
+
const baselinePerNode = nodeCount > 0 ? baselineTokens / nodeCount : null;
|
|
443
|
+
const measured = {
|
|
444
|
+
estTokens: measurement.totalTokens,
|
|
445
|
+
windowTokens,
|
|
446
|
+
surfaceTokens: measurement.surfaceTokens,
|
|
447
|
+
baselineTokens,
|
|
448
|
+
nodeCount,
|
|
449
|
+
nodeTokenSum,
|
|
450
|
+
baselinePerNode,
|
|
451
|
+
measurementBaseline,
|
|
452
|
+
contextWindow,
|
|
453
|
+
reservedTokens,
|
|
454
|
+
provider,
|
|
455
|
+
model,
|
|
456
|
+
measurement,
|
|
457
|
+
};
|
|
458
|
+
this.measured.set(agent, measured);
|
|
459
|
+
recordContext({
|
|
460
|
+
sessionId: String(session.id),
|
|
461
|
+
estTokens: measured.estTokens,
|
|
462
|
+
windowTokens: measured.windowTokens,
|
|
463
|
+
surfaceTokens: measured.surfaceTokens,
|
|
464
|
+
baselineTokens: measured.baselineTokens,
|
|
465
|
+
nodeCount: measured.nodeCount,
|
|
466
|
+
nodeTokenSum: measured.nodeTokenSum,
|
|
467
|
+
baselinePerNode: measured.baselinePerNode,
|
|
468
|
+
measurementBaseline: measured.measurementBaseline,
|
|
469
|
+
});
|
|
470
|
+
// 口径可验:把每次测量落盘,之后可用任何独立方法复核(不再依赖 /api/status)。
|
|
471
|
+
try {
|
|
472
|
+
const logPath = join(process.env.USERPROFILE ?? process.env.HOME ?? '.', '.dsh', 'continuity', 'context-log.jsonl');
|
|
473
|
+
appendFileSync(logPath, JSON.stringify({
|
|
474
|
+
at: new Date().toISOString(),
|
|
475
|
+
estTokens: measured.estTokens,
|
|
476
|
+
surfaceTokens: measured.surfaceTokens,
|
|
477
|
+
baselineTokens: measured.baselineTokens,
|
|
478
|
+
windowTokens: measured.windowTokens,
|
|
479
|
+
nodeCount: measured.nodeCount,
|
|
480
|
+
nodeTokenSum: measured.nodeTokenSum,
|
|
481
|
+
baselinePerNode: measured.baselinePerNode,
|
|
482
|
+
measurementBaseline: measured.measurementBaseline,
|
|
483
|
+
provider: measured.provider,
|
|
484
|
+
model: measured.model,
|
|
485
|
+
}) + '\n', 'utf8');
|
|
486
|
+
} catch { /* 落盘失败不影响测量 */ }
|
|
487
|
+
return measured;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
protectFor(agent) {
|
|
491
|
+
if (!this.protectCalibrationEnabled) return this.continuity.protect;
|
|
492
|
+
return this.protectCalibration.get(agent)?.protect ?? this.continuity.protect;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
policyFor(agent) {
|
|
496
|
+
return Object.freeze({ ...this.continuity, protect: this.protectFor(agent) });
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
calibrateAtFirstBand(agent, measured) {
|
|
500
|
+
if (!this.protectCalibrationEnabled) return null;
|
|
501
|
+
if (this.protectCalibration.has(agent)) return this.protectCalibration.get(agent);
|
|
502
|
+
const preBaseline = Math.max(0, measured.estTokens - measured.surfaceTokens);
|
|
503
|
+
const calibration = calibrateProtect({
|
|
504
|
+
low: this.continuity.low,
|
|
505
|
+
windowTokens: measured.windowTokens,
|
|
506
|
+
baselineTokens: preBaseline,
|
|
507
|
+
});
|
|
508
|
+
if (calibration === null) return null;
|
|
509
|
+
this.protectCalibration.set(agent, calibration);
|
|
510
|
+
return calibration;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Resolve once per route; adapters own the call and its defaults are static. */
|
|
514
|
+
modelInfoFor(provider, model) {
|
|
515
|
+
const key = `${provider}\u0000${model}`;
|
|
516
|
+
let pending = this.modelInfo.get(key);
|
|
517
|
+
if (pending === undefined) {
|
|
518
|
+
pending = this.ctx.llm.resolveModelInfo(provider, model);
|
|
519
|
+
this.modelInfo.set(key, pending);
|
|
520
|
+
pending.catch(() => this.modelInfo.delete(key));
|
|
521
|
+
}
|
|
522
|
+
return pending;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Select the oldest contiguous surface prefix that fits `budget`.
|
|
527
|
+
*
|
|
528
|
+
* Never starts on the system head; never leaves less than
|
|
529
|
+
* `protect × windowTokens` of recent route-priced tokens after the cut; both
|
|
530
|
+
* edges must be tool-pairing balanced. Returns inclusive surface seqs plus
|
|
531
|
+
* the number of message-producing nodes, or null.
|
|
532
|
+
*
|
|
533
|
+
* @param {AgentContext} agent
|
|
534
|
+
* @param {number} budget
|
|
535
|
+
* @param {Set<number|string>} [activePins] active tool-call messages/results
|
|
536
|
+
* @returns {{ start: number, end: number, count: number, tokens: number } | null}
|
|
537
|
+
*/
|
|
538
|
+
oldestRange(agent, budget, activePins = new Set()) {
|
|
539
|
+
const session = agent.session;
|
|
540
|
+
const nodes = session.surface?.nodes ?? [];
|
|
541
|
+
if (nodes.length === 0) return null;
|
|
542
|
+
|
|
543
|
+
const measured = this.measured.get(agent);
|
|
544
|
+
const measurement = measured?.measurement ?? this.ctx.tokenMeter.measure(session);
|
|
545
|
+
const priced = measurement.nodes ?? [];
|
|
546
|
+
if (priced.length !== nodes.length) return null;
|
|
547
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
548
|
+
if (priced[index]?.seq !== nodes[index]) return null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const head = session.eventAt(nodes[0]);
|
|
552
|
+
const systemHead = head?.type === 'system/message';
|
|
553
|
+
let tailFloor = Math.max(0, this.protectFor(agent) * (measured?.windowTokens ?? 0));
|
|
554
|
+
|
|
555
|
+
// A4: the active open-turn tool call/result pins are a hard suffix. Their
|
|
556
|
+
// tokens count toward the tail floor, so the compaction range stops before
|
|
557
|
+
// the oldest active pin even when the normal 30% tail is smaller.
|
|
558
|
+
const pinIndex = oldestPinIndex(nodes, activePins);
|
|
559
|
+
if (pinIndex >= 0) {
|
|
560
|
+
let pinTokens = 0;
|
|
561
|
+
for (let index = pinIndex; index < priced.length; index += 1) {
|
|
562
|
+
pinTokens += Number.isFinite(priced[index]?.tokens) ? priced[index].tokens : 0;
|
|
563
|
+
}
|
|
564
|
+
tailFloor = Math.max(tailFloor, pinTokens);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
let picked;
|
|
568
|
+
try {
|
|
569
|
+
const mapSeqs = this.historyMapMode() === 'auto'
|
|
570
|
+
? new Set(this.findHistoryMapCarriers(session))
|
|
571
|
+
: new Set();
|
|
572
|
+
let firstIdx = systemHead ? 1 : 0;
|
|
573
|
+
while (firstIdx < nodes.length && mapSeqs.has(nodes[firstIdx])) firstIdx += 1;
|
|
574
|
+
if (firstIdx >= nodes.length) return null;
|
|
575
|
+
const workNodes = nodes.slice(firstIdx);
|
|
576
|
+
const workPriced = priced.slice(firstIdx);
|
|
577
|
+
picked = selectOldestRange({
|
|
578
|
+
surfaceSeqs: workNodes,
|
|
579
|
+
pricedTokens: workPriced.map((node) => node.tokens),
|
|
580
|
+
systemHead: false,
|
|
581
|
+
budget,
|
|
582
|
+
tailFloor,
|
|
583
|
+
balancedBefore: (seq) => toolPairingBalancedBefore(session, seq),
|
|
584
|
+
balancedAfter: (seq) => toolPairingBalancedAfter(session, seq),
|
|
585
|
+
});
|
|
586
|
+
if (picked !== null) {
|
|
587
|
+
picked = {
|
|
588
|
+
...picked,
|
|
589
|
+
startIdx: picked.startIdx + firstIdx,
|
|
590
|
+
endIdx: picked.endIdx + firstIdx,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
} catch (error) {
|
|
594
|
+
this.warnOnce('tool-pairing', `dsh-continuity: tool-pairing check failed: ${error?.message ?? error}`);
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
if (picked === null) return null;
|
|
598
|
+
|
|
599
|
+
const span = nodes.slice(picked.startIdx, picked.endIdx + 1);
|
|
600
|
+
let count = 0;
|
|
601
|
+
for (const seq of span) {
|
|
602
|
+
const event = session.eventAt(seq);
|
|
603
|
+
if (event !== undefined && session.deriveEventMessage(event) !== null) count += 1;
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
start: nodes[picked.startIdx],
|
|
607
|
+
end: nodes[picked.endIdx],
|
|
608
|
+
seqs: [...span],
|
|
609
|
+
count,
|
|
610
|
+
tokens: picked.tokens,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* One small auxiliary summary for one non-text result. Reuses the base
|
|
616
|
+
* summarize call (same routed model) and falls back to the deterministic
|
|
617
|
+
* handle if the provider cannot read the payload.
|
|
618
|
+
*/
|
|
619
|
+
async semanticSummaryFor(agent, event, meta, signal) {
|
|
620
|
+
const instruction = [
|
|
621
|
+
`Tool: ${meta?.name ?? 'tool'} ${String(meta?.arguments ?? '').slice(0, 300)}`,
|
|
622
|
+
'Create a compact handle for the non-text payload in this message for a future reader.',
|
|
623
|
+
'Use exactly these lines and do not repeat the payload:',
|
|
624
|
+
'TOPIC: <what it is>',
|
|
625
|
+
'FUNCTION: <what it does in the current task>',
|
|
626
|
+
'IMPORTANCE: high|medium|low',
|
|
627
|
+
'CAVEATS: <error, format, truncation, dependency>',
|
|
628
|
+
'SUMMARY: <1-2 sentences>',
|
|
629
|
+
].join('\n');
|
|
630
|
+
const content = Array.isArray(event.data?.message?.content)
|
|
631
|
+
? [...event.data.message.content, { type: 'text', text: instruction }]
|
|
632
|
+
: [{ type: 'text', text: instruction }];
|
|
633
|
+
const user = {
|
|
634
|
+
role: 'user',
|
|
635
|
+
content,
|
|
636
|
+
source: { kind: 'user' },
|
|
637
|
+
};
|
|
638
|
+
const result = await super.summarize({ messages: [user] }, agent, signal);
|
|
639
|
+
const blocks = Array.isArray(result?.summary) ? result.summary : [];
|
|
640
|
+
const text = blocks
|
|
641
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
642
|
+
.map((block) => block.text)
|
|
643
|
+
.join('\n')
|
|
644
|
+
.trim();
|
|
645
|
+
if (text.length === 0) return null;
|
|
646
|
+
const field = (name) => {
|
|
647
|
+
const match = text.match(new RegExp(`^\\s*${name}\\s*:\\s*(.+)$`, 'im'));
|
|
648
|
+
return match?.[1]?.trim() ?? null;
|
|
649
|
+
};
|
|
650
|
+
return {
|
|
651
|
+
summary: text.slice(0, 800),
|
|
652
|
+
topic: field('TOPIC'),
|
|
653
|
+
function: field('FUNCTION'),
|
|
654
|
+
importance: field('IMPORTANCE'),
|
|
655
|
+
caveats: field('CAVEATS'),
|
|
656
|
+
tool: meta?.name ?? 'tool',
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* D: replace non-text tool results with a small semantic handle before the
|
|
662
|
+
* next request, except images, which stay in the model context. The raw event
|
|
663
|
+
* is untouched; the handle is a normal tool/result with the same call id.
|
|
664
|
+
*/
|
|
665
|
+
async handleNonTextResults(agent, signal) {
|
|
666
|
+
const session = agent.session;
|
|
667
|
+
const events = session.snapshotEvents();
|
|
668
|
+
const calls = toolCallMap(events);
|
|
669
|
+
let count = 0;
|
|
670
|
+
let chars = 0;
|
|
671
|
+
let tokens = 0;
|
|
672
|
+
for (const seq of [...session.surface.nodes]) {
|
|
673
|
+
const event = session.eventAt(seq);
|
|
674
|
+
if (nonTextInfo(event) === null) continue;
|
|
675
|
+
if (isImageToolResult(event)) continue;
|
|
676
|
+
const callId = event.data?.message?.source?.callId ?? event.data?.message?.toolCallId;
|
|
677
|
+
const meta = callId === undefined || callId === null ? undefined : calls.get(String(callId));
|
|
678
|
+
let semantic = null;
|
|
679
|
+
try {
|
|
680
|
+
semantic = await this.semanticSummaryFor(agent, event, meta, signal);
|
|
681
|
+
} catch (error) {
|
|
682
|
+
recordError(error?.message ?? String(error), 'non-text-summary');
|
|
683
|
+
}
|
|
684
|
+
const handle = handleNonTextToolResult(event, seq, {
|
|
685
|
+
toolName: meta?.name,
|
|
686
|
+
toolArgs: meta?.arguments,
|
|
687
|
+
semantic,
|
|
688
|
+
});
|
|
689
|
+
if (handle === null) continue;
|
|
690
|
+
let before = 0;
|
|
691
|
+
try {
|
|
692
|
+
before = Number(this.ctx.tokenMeter.estimateMessage(event.data.message));
|
|
693
|
+
} catch {
|
|
694
|
+
before = 0;
|
|
695
|
+
}
|
|
696
|
+
session.append('compaction/prune', {
|
|
697
|
+
shadowedRange: { start: seq, end: seq },
|
|
698
|
+
shadowedSeqs: [seq],
|
|
699
|
+
shadowedTokenCount: before,
|
|
700
|
+
});
|
|
701
|
+
session.append('tool/result', { ...event.data, message: handle.message }, {
|
|
702
|
+
surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq },
|
|
703
|
+
sourceEventSeqs: [seq],
|
|
704
|
+
});
|
|
705
|
+
count += 1;
|
|
706
|
+
chars += handle.charsBefore - handle.charsAfter;
|
|
707
|
+
tokens += before;
|
|
708
|
+
recordPreview({
|
|
709
|
+
sessionId: String(session.id),
|
|
710
|
+
count: 1,
|
|
711
|
+
tokens: Math.round(before),
|
|
712
|
+
chars: handle.charsBefore - handle.charsAfter,
|
|
713
|
+
reason: 'non-text-handle',
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
return { count, chars, tokens };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Preserve non-text payloads before their surface range is folded. The
|
|
721
|
+
* payload lands under sessions/<id>/assets and the gist gets a pointer.
|
|
722
|
+
*/
|
|
723
|
+
async materializeRangeAssets(agent, start, end) {
|
|
724
|
+
const session = agent?.session;
|
|
725
|
+
if (session === undefined || session === null) return [];
|
|
726
|
+
const home = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity');
|
|
727
|
+
const rows = [];
|
|
728
|
+
const raw = typeof session.snapshotEvents === 'function' ? session.snapshotEvents() : [];
|
|
729
|
+
const bySeq = new Map((Array.isArray(raw) ? raw : []).map((entry) => [Number(entry?.seq), entry]));
|
|
730
|
+
for (let seq = start; seq <= end; seq += 1) {
|
|
731
|
+
const event = bySeq.get(Number(seq)) ?? session.eventAt?.(seq);
|
|
732
|
+
const blocks = eventContentBlocks(event);
|
|
733
|
+
if (!Array.isArray(blocks)) continue;
|
|
734
|
+
for (const block of blocks) {
|
|
735
|
+
if (block === null || typeof block !== 'object') continue;
|
|
736
|
+
if (!['image', 'file', 'audio', 'video'].includes(block.type)) continue;
|
|
737
|
+
try {
|
|
738
|
+
const result = await writeAsset({
|
|
739
|
+
home,
|
|
740
|
+
sessionId: String(session.id),
|
|
741
|
+
block,
|
|
742
|
+
seq,
|
|
743
|
+
});
|
|
744
|
+
if (result.ok) rows.push(result.row);
|
|
745
|
+
} catch (error) {
|
|
746
|
+
recordError(error?.message ?? String(error), 'asset-write');
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return rows;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* A: overflow is the only case where an active tool result may be detached.
|
|
755
|
+
* The largest active result becomes a pause handle; the same call id keeps
|
|
756
|
+
* it active. The base request-error listener retries once after the surface
|
|
757
|
+
* progress; the model tells the user to raise the window.
|
|
758
|
+
*/
|
|
759
|
+
async handleOverflow(agent, signal) {
|
|
760
|
+
const session = agent.session;
|
|
761
|
+
const events = session.snapshotEvents();
|
|
762
|
+
const calls = activeCallIds(events);
|
|
763
|
+
const pins = activePinSeqs(session.surface.nodes, (seq) => session.eventAt(seq), calls);
|
|
764
|
+
const candidates = [];
|
|
765
|
+
for (const seq of session.surface.nodes) {
|
|
766
|
+
const event = session.eventAt(seq);
|
|
767
|
+
if (event?.type !== 'tool/result' || !pins.has(seq)) continue;
|
|
768
|
+
const first = event.data?.message?.content?.[0];
|
|
769
|
+
if (first?.type === 'text' && typeof first.text === 'string' && first.text.startsWith('[continuity pause')) continue;
|
|
770
|
+
candidates.push({ seq, event });
|
|
771
|
+
}
|
|
772
|
+
if (candidates.length === 0) return super.compactIfNeeded(agent, 'context-overflow', signal);
|
|
773
|
+
|
|
774
|
+
let measured = null;
|
|
775
|
+
try {
|
|
776
|
+
measured = await this.measure(agent, signal);
|
|
777
|
+
} catch {
|
|
778
|
+
measured = null;
|
|
779
|
+
}
|
|
780
|
+
let best = candidates[0];
|
|
781
|
+
let bestTokens = -1;
|
|
782
|
+
for (const candidate of candidates) {
|
|
783
|
+
let tokens = 0;
|
|
784
|
+
try {
|
|
785
|
+
tokens = Number(this.ctx.tokenMeter.estimateMessage(candidate.event.data.message));
|
|
786
|
+
} catch {
|
|
787
|
+
tokens = 0;
|
|
788
|
+
}
|
|
789
|
+
if (tokens > bestTokens) {
|
|
790
|
+
best = candidate;
|
|
791
|
+
bestTokens = tokens;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
const overage = measured === null ? 0 : Math.max(0, measured.estTokens - measured.windowTokens);
|
|
795
|
+
if (overage > 0 && bestTokens < overage) {
|
|
796
|
+
return super.compactIfNeeded(agent, 'context-overflow', signal);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const callId = best.event.data?.message?.source?.callId ?? best.event.data?.message?.toolCallId ?? '?';
|
|
800
|
+
const pause = pauseActiveToolResult(best.event, best.seq, { callId });
|
|
801
|
+
let before = 0;
|
|
802
|
+
try {
|
|
803
|
+
before = Number(this.ctx.tokenMeter.estimateMessage(best.event.data.message));
|
|
804
|
+
} catch {
|
|
805
|
+
before = 0;
|
|
806
|
+
}
|
|
807
|
+
session.append('compaction/prune', {
|
|
808
|
+
shadowedRange: { start: best.seq, end: best.seq },
|
|
809
|
+
shadowedSeqs: [best.seq],
|
|
810
|
+
shadowedTokenCount: before,
|
|
811
|
+
});
|
|
812
|
+
session.append('tool/result', { ...best.event.data, message: pause.message }, {
|
|
813
|
+
surfaceOp: { op: 'replace', startSeq: best.seq, endSeq: best.seq },
|
|
814
|
+
sourceEventSeqs: [best.seq],
|
|
815
|
+
});
|
|
816
|
+
recordPreview({
|
|
817
|
+
sessionId: String(session.id),
|
|
818
|
+
count: 1,
|
|
819
|
+
tokens: Math.round(before),
|
|
820
|
+
chars: pause.charsAfter,
|
|
821
|
+
reason: 'active-overflow-pause',
|
|
822
|
+
});
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* A4-safe preview stage. It drives the oldest non-active tool results toward
|
|
828
|
+
* the post-compression target (`low` for band-high, `emergencyTo` for
|
|
829
|
+
* emergency). If the preview cannot reach the target, it is still applied and
|
|
830
|
+
* the remaining pass folds the oldest user/reasoning; the current pass is not
|
|
831
|
+
* skipped just because the preview dropped below `high`.
|
|
832
|
+
*
|
|
833
|
+
* @param {AgentContext} agent
|
|
834
|
+
* @param {number} targetSavings tokens to remove before the pass target
|
|
835
|
+
* @returns {{ applied: boolean, pins: Set<number|string>, count: number, tokens: number, chars: number }}
|
|
836
|
+
*/
|
|
837
|
+
applyToolPreviews(agent, targetSavings) {
|
|
838
|
+
const session = agent.session;
|
|
839
|
+
const events = session.snapshotEvents();
|
|
840
|
+
const activeCalls = activeCallIds(events);
|
|
841
|
+
const pins = activePinSeqs(session.surface.nodes, (seq) => session.eventAt(seq), activeCalls);
|
|
842
|
+
const empty = { applied: false, pins, count: 0, tokens: 0, chars: 0 };
|
|
843
|
+
if (!(targetSavings > 0) || typeof this.ctx.tokenMeter?.estimateMessage !== 'function') return empty;
|
|
844
|
+
|
|
845
|
+
const candidates = [];
|
|
846
|
+
for (const seq of session.surface.nodes) {
|
|
847
|
+
const event = session.eventAt(seq);
|
|
848
|
+
if (event?.type === 'tool/result' && !pins.has(seq)) candidates.push({ seq, event });
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const chosen = [];
|
|
852
|
+
let estimated = 0;
|
|
853
|
+
for (const candidate of candidates) {
|
|
854
|
+
if (estimated >= targetSavings) break;
|
|
855
|
+
const preview = previewToolResultEvent(candidate.event, candidate.seq);
|
|
856
|
+
if (preview === null) continue;
|
|
857
|
+
let before;
|
|
858
|
+
let after;
|
|
859
|
+
try {
|
|
860
|
+
before = Number(this.ctx.tokenMeter.estimateMessage(candidate.event.data.message));
|
|
861
|
+
after = Number(this.ctx.tokenMeter.estimateMessage(preview.message));
|
|
862
|
+
} catch {
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
const saving = before - after;
|
|
866
|
+
if (!Number.isFinite(saving) || saving <= 0) continue;
|
|
867
|
+
chosen.push({ ...candidate, preview, before, saving });
|
|
868
|
+
estimated += saving;
|
|
869
|
+
}
|
|
870
|
+
if (chosen.length === 0) return empty;
|
|
871
|
+
|
|
872
|
+
let chars = 0;
|
|
873
|
+
for (const item of chosen) {
|
|
874
|
+
session.append('compaction/prune', {
|
|
875
|
+
shadowedRange: { start: item.seq, end: item.seq },
|
|
876
|
+
shadowedSeqs: [item.seq],
|
|
877
|
+
shadowedTokenCount: item.before,
|
|
878
|
+
});
|
|
879
|
+
session.append('tool/result', { ...item.event.data, message: item.preview.message }, {
|
|
880
|
+
surfaceOp: { op: 'replace', startSeq: item.seq, endSeq: item.seq },
|
|
881
|
+
sourceEventSeqs: [item.seq],
|
|
882
|
+
});
|
|
883
|
+
chars += item.preview.charsBefore - item.preview.charsAfter;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
recordPreview({
|
|
887
|
+
sessionId: String(session.id),
|
|
888
|
+
count: chosen.length,
|
|
889
|
+
tokens: Math.round(estimated),
|
|
890
|
+
chars,
|
|
891
|
+
reason: 'old-tool-result',
|
|
892
|
+
});
|
|
893
|
+
return { applied: true, pins, count: chosen.length, tokens: Math.round(estimated), chars };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Validate and price a save without appending anything. The tool returns a
|
|
898
|
+
* pending receipt; the next pre-step commits the gist, after the tool result
|
|
899
|
+
* is in the surface, so the current call/result pair is never split.
|
|
900
|
+
*/
|
|
901
|
+
async prepareSave(agent, args = {}) {
|
|
902
|
+
const session = agent?.session;
|
|
903
|
+
if (session === undefined || session === null) throw new Error('continuity.save: no session');
|
|
904
|
+
// 0.8.57: a save must not fold a dirty tag library.
|
|
905
|
+
const maintenance = await runMaintenance({ mode: 'save' });
|
|
906
|
+
if (!maintenance.healthy) {
|
|
907
|
+
throw new Error(`continuity.save: 标签库自动修复未通过(${maintenance.problems.join(', ')})`);
|
|
908
|
+
}
|
|
909
|
+
const label = String(args.label ?? '').trim();
|
|
910
|
+
const why = String(args.why ?? '').trim();
|
|
911
|
+
const next = String(args.next ?? '').trim();
|
|
912
|
+
if (label.length === 0 || why.length === 0 || next.length === 0) {
|
|
913
|
+
throw new Error('continuity.save: label, why, and next are required');
|
|
914
|
+
}
|
|
915
|
+
const requestedThreadId = typeof args.threadId === 'string' ? args.threadId.trim() : '';
|
|
916
|
+
const requestedAlias = typeof args.alias === 'string' && args.alias.trim().length > 0
|
|
917
|
+
? args.alias.trim()
|
|
918
|
+
: undefined;
|
|
919
|
+
const relabel = args.relabel === true;
|
|
920
|
+
let sessionState = { threads: [], activeId: null };
|
|
921
|
+
try {
|
|
922
|
+
sessionState = await getSession(session.id);
|
|
923
|
+
} catch {
|
|
924
|
+
sessionState = { threads: [], activeId: null };
|
|
925
|
+
}
|
|
926
|
+
let existing = null;
|
|
927
|
+
if (requestedThreadId.length > 0) {
|
|
928
|
+
existing = sessionState.threads.find((entry) => entry.id === requestedThreadId) ?? null;
|
|
929
|
+
if (existing === null) {
|
|
930
|
+
throw new Error(`continuity.save: unknown threadId ${requestedThreadId}`);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
let tagsState = null;
|
|
934
|
+
try {
|
|
935
|
+
const read = await readTags();
|
|
936
|
+
if (read.corrupt || read.missing) {
|
|
937
|
+
this.warnOnce('tags-read', `dsh-continuity: tags.json ${read.corrupt ? 'corrupt' : 'missing'}; using T0`);
|
|
938
|
+
}
|
|
939
|
+
tagsState = read.state;
|
|
940
|
+
} catch (error) {
|
|
941
|
+
this.warnOnce('tags-read', `dsh-continuity: tags read failed: ${error?.message ?? error}`);
|
|
942
|
+
}
|
|
943
|
+
const explicitRange = args.fromSeq !== undefined && args.fromSeq !== null
|
|
944
|
+
&& args.toSeq !== undefined && args.toSeq !== null;
|
|
945
|
+
const pairing = {
|
|
946
|
+
before: (value, seq) => toolPairingBalancedBefore(value, seq),
|
|
947
|
+
after: (value, seq) => toolPairingBalancedAfter(value, seq),
|
|
948
|
+
};
|
|
949
|
+
let selected = selectSaveRange({
|
|
950
|
+
session,
|
|
951
|
+
fromSeq: args.fromSeq,
|
|
952
|
+
toSeq: args.toSeq,
|
|
953
|
+
pairing,
|
|
954
|
+
});
|
|
955
|
+
if (!explicitRange && tagsState !== null) {
|
|
956
|
+
const blockStart = dominanceStartSeq(tagsState, selected.endSeq, { islandMax: 5 });
|
|
957
|
+
if (blockStart !== null && Number(blockStart) > Number(selected.startSeq)) {
|
|
958
|
+
selected = selectSaveRange({
|
|
959
|
+
session,
|
|
960
|
+
fromSeq: args.fromSeq,
|
|
961
|
+
toSeq: args.toSeq,
|
|
962
|
+
pairing,
|
|
963
|
+
minStartSeq: blockStart,
|
|
964
|
+
});
|
|
965
|
+
this.warnOnce('save-t-clamp', `dsh-continuity: save clamped to T dominance block seq ${blockStart}`);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const currentTagId = tagsState === null ? TAG_ZERO : resolveTag(tagsState, selected.endSeq);
|
|
970
|
+
const currentTagEntry = tagsState?.tags?.[currentTagId];
|
|
971
|
+
const currentIsT0 = currentTagId === TAG_ZERO;
|
|
972
|
+
if (requestedThreadId.length === 0 && !currentIsT0) {
|
|
973
|
+
// 依次匹配:标签 id → 线头 id → 线头 alias → 线头 label → 当前活跃线。
|
|
974
|
+
const keys = [currentTagId, requestedThreadId, String(args.label ?? '')]
|
|
975
|
+
.map((x) => String(x ?? '').trim())
|
|
976
|
+
.filter((x) => x.length > 0);
|
|
977
|
+
let bound = sessionState.threads.find((entry) => keys.includes(String(entry.id))
|
|
978
|
+
|| keys.includes(String(entry.alias ?? ''))
|
|
979
|
+
|| keys.includes(String(entry.label ?? '')));
|
|
980
|
+
if (bound === undefined && sessionState.activeId !== null && sessionState.activeId !== undefined) {
|
|
981
|
+
bound = sessionState.threads.find((entry) => String(entry.id) === String(sessionState.activeId));
|
|
982
|
+
}
|
|
983
|
+
if (bound !== undefined) existing = bound;
|
|
984
|
+
}
|
|
985
|
+
// span 未打标(T0)时,显式 label 仍应能绑到已有线头(按 label / alias 匹配)。
|
|
986
|
+
if (existing === null || existing === undefined) existing = null;
|
|
987
|
+
if (existing === null && requestedThreadId.length === 0 && String(args.label ?? '').trim().length > 0) {
|
|
988
|
+
const wanted = String(args.label).trim();
|
|
989
|
+
const bound = sessionState.threads.find((entry) => String(entry.id) === wanted
|
|
990
|
+
|| String(entry.alias ?? '') === wanted
|
|
991
|
+
|| String(entry.label ?? '') === wanted);
|
|
992
|
+
if (bound !== undefined) existing = bound;
|
|
993
|
+
}
|
|
994
|
+
let newAlias = requestedAlias;
|
|
995
|
+
if (newAlias === undefined && !currentIsT0) {
|
|
996
|
+
if (typeof currentTagEntry?.alias === 'string' && currentTagEntry.alias.length > 0) {
|
|
997
|
+
newAlias = currentTagEntry.alias;
|
|
998
|
+
} else if (currentTagId.length <= 16) {
|
|
999
|
+
newAlias = currentTagId;
|
|
1000
|
+
} else if (typeof currentTagEntry?.label === 'string' && currentTagEntry.label.length > 0) {
|
|
1001
|
+
newAlias = currentTagEntry.label.slice(0, 16);
|
|
1002
|
+
} else {
|
|
1003
|
+
newAlias = currentTagId.slice(0, 16);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
const newActive = !currentIsT0;
|
|
1007
|
+
|
|
1008
|
+
let measurement;
|
|
1009
|
+
try {
|
|
1010
|
+
measurement = this.ctx.tokenMeter.measure(session);
|
|
1011
|
+
} catch {
|
|
1012
|
+
measurement = { nodes: [] };
|
|
1013
|
+
}
|
|
1014
|
+
const priced = new Map();
|
|
1015
|
+
for (const node of measurement.nodes ?? []) priced.set(Number(node.seq), Number(node.heuristicTokens ?? node.tokens ?? 0));
|
|
1016
|
+
let tokens = 0;
|
|
1017
|
+
for (const seq of selected.shadowedSeqs) tokens += priced.get(Number(seq)) ?? 0;
|
|
1018
|
+
|
|
1019
|
+
const startEvent = session.eventAt(selected.startSeq);
|
|
1020
|
+
const endEvent = session.eventAt(selected.endSeq);
|
|
1021
|
+
const spanStart = isoOf(startEvent?.time) ?? new Date().toISOString();
|
|
1022
|
+
const spanEnd = isoOf(endEvent?.time) ?? spanStart;
|
|
1023
|
+
const minutes = minutesBetween(Date.parse(spanStart), Date.parse(spanEnd));
|
|
1024
|
+
const now = new Date().toISOString();
|
|
1025
|
+
const archiveRef = `log:${session.id}#seq=${selected.startSeq}-${selected.endSeq}`;
|
|
1026
|
+
const residue = typeof args.residue === 'string' && args.residue.trim().length > 0
|
|
1027
|
+
? args.residue.trim().slice(0, RESIDUE_MAX_CHARS)
|
|
1028
|
+
: undefined;
|
|
1029
|
+
if (typeof args.residue === 'string' && args.residue.trim().length > RESIDUE_MAX_CHARS) {
|
|
1030
|
+
this.warnOnce('thread-residue', 'dsh-continuity: thread residue truncated');
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
let threadsForTags = sessionState.threads;
|
|
1034
|
+
if (!Array.isArray(threadsForTags)) threadsForTags = [];
|
|
1035
|
+
const tCoverage = tagsState === null
|
|
1036
|
+
? TAG_ZERO
|
|
1037
|
+
: tagCoverageForRange(tagsState, selected.startSeq, selected.endSeq, { threads: threadsForTags }).label;
|
|
1038
|
+
const segment = {
|
|
1039
|
+
fromSeq: selected.startSeq,
|
|
1040
|
+
toSeq: selected.endSeq,
|
|
1041
|
+
seqs: [...selected.shadowedSeqs],
|
|
1042
|
+
count: selected.count,
|
|
1043
|
+
spanStart,
|
|
1044
|
+
spanEnd,
|
|
1045
|
+
minutes,
|
|
1046
|
+
tokens,
|
|
1047
|
+
why,
|
|
1048
|
+
next,
|
|
1049
|
+
...(residue === undefined ? {} : { residue }),
|
|
1050
|
+
at: now,
|
|
1051
|
+
archiveRef,
|
|
1052
|
+
tCoverage,
|
|
1053
|
+
};
|
|
1054
|
+
const mode = existing === null ? 'new' : 'append';
|
|
1055
|
+
const thread = existing === null
|
|
1056
|
+
? normalizeThread({
|
|
1057
|
+
id: `t-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`,
|
|
1058
|
+
label,
|
|
1059
|
+
...(newAlias === undefined ? {} : { alias: newAlias }),
|
|
1060
|
+
segments: [segment],
|
|
1061
|
+
sessionId: String(session.id),
|
|
1062
|
+
active: newActive,
|
|
1063
|
+
createdAt: now,
|
|
1064
|
+
updatedAt: now,
|
|
1065
|
+
})
|
|
1066
|
+
: normalizeThread({
|
|
1067
|
+
...existing,
|
|
1068
|
+
...(relabel ? { label } : {}),
|
|
1069
|
+
...(requestedAlias === undefined ? {} : { alias: requestedAlias }),
|
|
1070
|
+
segments: [...existing.segments, segment],
|
|
1071
|
+
active: true,
|
|
1072
|
+
updatedAt: now,
|
|
1073
|
+
});
|
|
1074
|
+
return {
|
|
1075
|
+
mode,
|
|
1076
|
+
thread,
|
|
1077
|
+
segment: thread.segments[thread.segments.length - 1],
|
|
1078
|
+
segmentIndex: thread.segments.length,
|
|
1079
|
+
threadId: thread.id,
|
|
1080
|
+
args: { label, why, next, residue, threadId: requestedThreadId || undefined, alias: requestedAlias, relabel },
|
|
1081
|
+
adjusted: selected.adjusted,
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* 严格解析 TAG 里的标签(2026-10-30 用户定的闸):
|
|
1087
|
+
*
|
|
1088
|
+
* T6 且 T6 存在 → accept(kind: alias/id)
|
|
1089
|
+
* 中文词且表里没有 → new(建号、命名、关联、放行)
|
|
1090
|
+
* 中文词但表里已有同一个名字 → reject:label —— 说明没看表,写别名去
|
|
1091
|
+
* T6 但 T6 不存在 → reject:alias —— 说明没看表
|
|
1092
|
+
*
|
|
1093
|
+
* **引号 = 这个词本身,不是编号**(用户 2026-10-30 补的逃生口):
|
|
1094
|
+
* TAG: "T6" → 只按“标签名”查表;没有同名标签就建一个名为 T6 的标签;
|
|
1095
|
+
* 若表里已有名为 T6 的标签(比如它是 T201)→ 退回,让他写 T201。
|
|
1096
|
+
* 这样“这一轮讨论的就是 T6 这个编号”不会被当成填错。
|
|
1097
|
+
*
|
|
1098
|
+
* 为什么“写名字撞上已有标签”也要退:撞上只能靠猜,猜中不等于看过表;
|
|
1099
|
+
* 闸必须自己查表,不能依赖实例自报。
|
|
1100
|
+
*/
|
|
1101
|
+
resolveTagToken(token, { allowCreate = true } = {}) {
|
|
1102
|
+
const home = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity');
|
|
1103
|
+
const file = join(home, 'tags.json');
|
|
1104
|
+
let doc = { tags: {} };
|
|
1105
|
+
try { doc = JSON.parse(readFileSync(file, 'utf8')); } catch { doc = { tags: {} }; }
|
|
1106
|
+
let key = String(token ?? '').trim();
|
|
1107
|
+
let quoted = false;
|
|
1108
|
+
const wrap = /^["“「'‘](.*)["”」'’]$/.exec(key);
|
|
1109
|
+
if (wrap !== null) { quoted = true; key = wrap[1].trim(); }
|
|
1110
|
+
const known = Object.values(doc.tags ?? {}).map((entry) => String(entry?.alias ?? '')).filter((alias) => alias.length > 0);
|
|
1111
|
+
if (key.length === 0) return { kind: 'invalid', id: null, known, quoted };
|
|
1112
|
+
const labelHit = Object.entries(doc.tags ?? {}).find(([, entry]) => entry?.lifecycle !== 'archived' && String(entry?.label ?? '').trim() === key);
|
|
1113
|
+
if (quoted) {
|
|
1114
|
+
// 引号 = 这段文字本身,不是指针。所以**不查别名**:
|
|
1115
|
+
// 哪怕 "T14" 正好是别名 T14,引号里也只能解释成“名为 T14 的标签”。
|
|
1116
|
+
// 唯一的退回:表里已经有**同名标签**(比如 T6 这个名字已经属于 T201)——那才写它的别名。
|
|
1117
|
+
if (labelHit !== undefined) {
|
|
1118
|
+
const canonical = canonicalTagId(doc, String(labelHit[0]));
|
|
1119
|
+
return { kind: 'label', id: null, alias: String(labelHit[1]?.alias ?? ''), labelId: canonical ?? String(labelHit[0]), known, quoted };
|
|
1120
|
+
}
|
|
1121
|
+
if (key.length > 30) return { kind: 'invalid', id: null, known, quoted };
|
|
1122
|
+
if (!allowCreate) return { kind: 'new-pending', id: null, token: key, known, quoted };
|
|
1123
|
+
return this.createTag(key, doc, file, known, quoted);
|
|
1124
|
+
}
|
|
1125
|
+
for (const [id, entry] of Object.entries(doc.tags ?? {})) {
|
|
1126
|
+
if (id === key) {
|
|
1127
|
+
const canonical = canonicalTagId(doc, id);
|
|
1128
|
+
if (canonical === null) return { kind: 'invalid', id: null, known, quoted };
|
|
1129
|
+
return { kind: 'id', id: canonical, known, quoted };
|
|
1130
|
+
}
|
|
1131
|
+
if (String(entry?.alias ?? '').toLowerCase() === key.toLowerCase()) {
|
|
1132
|
+
const canonical = canonicalTagId(doc, id);
|
|
1133
|
+
if (canonical === null) return { kind: 'invalid', id: null, known, quoted };
|
|
1134
|
+
return { kind: 'alias', id: canonical, alias: String(entry?.alias ?? ''), known, quoted };
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
if (labelHit !== undefined) return { kind: 'label', id: null, alias: String(labelHit[1]?.alias ?? ''), labelId: String(labelHit[0]), known, quoted };
|
|
1138
|
+
if (/^T\d+$/i.test(key)) return { kind: 'alias-missing', id: null, known, quoted };
|
|
1139
|
+
if (key.length < 2 || key.length > 30) return { kind: 'invalid', id: null, known, quoted };
|
|
1140
|
+
if (!allowCreate) return { kind: 'new-pending', id: null, token: key, known, quoted };
|
|
1141
|
+
return this.createTag(key, doc, file, known, quoted);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** One id mapping for every declaration path; label hits use labelId, not id. */
|
|
1145
|
+
tagIdOf(resolved) {
|
|
1146
|
+
if (resolved === null || typeof resolved !== 'object') return null;
|
|
1147
|
+
if (resolved.kind === 'label') return resolved.labelId ?? null;
|
|
1148
|
+
if (resolved.kind === 'alias' || resolved.kind === 'id' || resolved.kind === 'new') {
|
|
1149
|
+
return resolved.id ?? null;
|
|
1150
|
+
}
|
|
1151
|
+
return null;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** 新词与已有 label 编辑距离 ≤1 → 返回那个标签;不建新号。 */
|
|
1155
|
+
findNearTagLabel(word) {
|
|
1156
|
+
const home = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity');
|
|
1157
|
+
let doc = { tags: {} };
|
|
1158
|
+
try { doc = JSON.parse(readFileSync(join(home, 'tags.json'), 'utf8')); } catch { doc = { tags: {} }; }
|
|
1159
|
+
const labels = Object.entries(doc.tags ?? {}).map(([id, entry]) => ({
|
|
1160
|
+
id,
|
|
1161
|
+
alias: String(entry?.alias ?? ''),
|
|
1162
|
+
label: String(entry?.label ?? ''),
|
|
1163
|
+
})).filter((item) => item.label.length > 0 && item.alias.length > 0);
|
|
1164
|
+
return findNearLabel(labels, word);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/** 建一个新标签(label = key),返回 {kind:'new'}。 */
|
|
1168
|
+
createTag(key, doc, file, known, quoted = false) {
|
|
1169
|
+
const used = Object.values(doc.tags ?? {}).map((entry) => Number(String(entry?.alias ?? '').replace(/^T/, '')) || 0);
|
|
1170
|
+
const next = Math.max(0, ...used) + 1;
|
|
1171
|
+
const id = 't-v2-t' + next;
|
|
1172
|
+
doc.tags = doc.tags ?? {};
|
|
1173
|
+
doc.tags[id] = { label: key, alias: 'T' + next, keywords: [key], lifecycle: 'provisional', useCount: 0 };
|
|
1174
|
+
doc.updatedAt = new Date().toISOString();
|
|
1175
|
+
writeFileSync(file, JSON.stringify(doc, null, 2), 'utf8');
|
|
1176
|
+
return { kind: 'new', id, alias: 'T' + next, known, quoted };
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* 回合闸(2026-10-30):一轮要关闭时,如果这一轮还没有归属,就把本轮续上,要求先写 TAG。
|
|
1181
|
+
* 宿主契约:'agent/turn-stopping' 在轮次关闭前运行,监听者 steer 可让它保持打开。
|
|
1182
|
+
* 为什么不是锁 save:挡存只会让实例最后存不下、消失;该挡的是“没标就收尾”这个动作。
|
|
1183
|
+
*/
|
|
1184
|
+
async onTurnStopping({ agent, turn } = {}) {
|
|
1185
|
+
if (!this.turnGateEnabled) return;
|
|
1186
|
+
const agentSession = typeof agent?.session?.id === 'string' ? agent.session.id : null;
|
|
1187
|
+
if (agentSession !== null) markEngineSession(agentSession);
|
|
1188
|
+
if (this.facetNative) return;
|
|
1189
|
+
const home = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity');
|
|
1190
|
+
// 探针:先证明“这个回调到底有没有被调用”。没有这一行,就没法区分
|
|
1191
|
+
// “没被派发”和“派发了但提前返回”(2026-10-30:两轮故意写错都没收到退回,
|
|
1192
|
+
// 但也没有任何错误日志——两者在屏幕上一模一样)。
|
|
1193
|
+
let taggedSize = 0;
|
|
1194
|
+
try {
|
|
1195
|
+
const tagsFile = join(home, 'turn-tags.jsonl');
|
|
1196
|
+
taggedSize = new Set(readFileSync(tagsFile, 'utf8').split('\n').filter(Boolean)
|
|
1197
|
+
.map((line) => { try { return Number(JSON.parse(line).turn); } catch { return null; } })
|
|
1198
|
+
.filter((value) => Number.isInteger(value))).size;
|
|
1199
|
+
} catch { taggedSize = 0; }
|
|
1200
|
+
try {
|
|
1201
|
+
appendFileSync(join(home, 'hook-probe.jsonl'),
|
|
1202
|
+
JSON.stringify({
|
|
1203
|
+
at: new Date().toISOString(),
|
|
1204
|
+
turn: turn ?? null,
|
|
1205
|
+
agentSession,
|
|
1206
|
+
hasAgent: Boolean(agent),
|
|
1207
|
+
canSteer: typeof agent?.steer === 'function',
|
|
1208
|
+
taggedSize,
|
|
1209
|
+
}) + '\n', 'utf8');
|
|
1210
|
+
} catch { /* 探针失败不影响逻辑 */ }
|
|
1211
|
+
try {
|
|
1212
|
+
if (!Number.isInteger(turn) || turn <= 0) return;
|
|
1213
|
+
// 去重:engine 侧和 index.apply 侧都注册了 turn-stopping(两条路都实测能收到事件),
|
|
1214
|
+
// 同一次收尾会进来两次。500ms 内的同一轮只处理一次,避免重复 steer / 计数翻倍。
|
|
1215
|
+
const nowMs = Date.now();
|
|
1216
|
+
const lastMs = this.turnGateLastRun.get(turn) ?? 0;
|
|
1217
|
+
if (nowMs - lastMs < 500) return;
|
|
1218
|
+
this.turnGateLastRun.set(turn, nowMs);
|
|
1219
|
+
// 这三个 Map 只服务“当前这轮”,别让它们随会话无限长大。
|
|
1220
|
+
if (this.turnGate.size > 200) {
|
|
1221
|
+
for (const key of [...this.turnGate.keys()]) if (key < turn - 50) this.turnGate.delete(key);
|
|
1222
|
+
for (const key of [...this.turnSteerCount.keys()]) if (key < turn - 50) this.turnSteerCount.delete(key);
|
|
1223
|
+
for (const key of [...this.turnTagErrors.keys()]) if (key < turn - 50) this.turnTagErrors.delete(key);
|
|
1224
|
+
}
|
|
1225
|
+
const tagsFile = join(home, 'turn-tags.jsonl');
|
|
1226
|
+
let tagged = new Set();
|
|
1227
|
+
try {
|
|
1228
|
+
tagged = new Set(readFileSync(tagsFile, 'utf8').split('\n').filter(Boolean)
|
|
1229
|
+
.map((line) => { try { return Number(JSON.parse(line).turn); } catch { return null; } })
|
|
1230
|
+
.filter((value) => Number.isInteger(value)));
|
|
1231
|
+
} catch { tagged = new Set(); }
|
|
1232
|
+
const auditGate = (action, extra = {}) => {
|
|
1233
|
+
try {
|
|
1234
|
+
appendFileSync(join(home, 'turn-gate.jsonl'), JSON.stringify({
|
|
1235
|
+
at: new Date().toISOString(),
|
|
1236
|
+
turn,
|
|
1237
|
+
session: String(agentSession ?? ''),
|
|
1238
|
+
taggedBefore: tagged.has(turn),
|
|
1239
|
+
taggedSize: tagged.size,
|
|
1240
|
+
action,
|
|
1241
|
+
...extra,
|
|
1242
|
+
}) + '\n', 'utf8');
|
|
1243
|
+
} catch (error) {
|
|
1244
|
+
// 审计员自己晕倒时必须喊一声;否则“没审计”和“审计正常”无法区分。
|
|
1245
|
+
try {
|
|
1246
|
+
appendFileSync(join(home, 'gate-errors.jsonl'), JSON.stringify({
|
|
1247
|
+
at: new Date().toISOString(),
|
|
1248
|
+
name: error?.name ?? 'Error',
|
|
1249
|
+
code: 'auditGate-failed',
|
|
1250
|
+
turn,
|
|
1251
|
+
message: String(error?.message ?? error),
|
|
1252
|
+
}) + '\n', 'utf8');
|
|
1253
|
+
} catch { /* 连错误日志都写不进,只能放弃 */ }
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
auditGate('invoked');
|
|
1257
|
+
if (tagged.has(turn)) {
|
|
1258
|
+
auditGate('already-tagged');
|
|
1259
|
+
this.recheckPending.delete(turn);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
// 本轮最后一条 assistant 文本的首行/末行是不是 TAG: ...
|
|
1263
|
+
// 同时留一条本轮用户原话,供“新词必须在本轮内容里出现”的证据检查。
|
|
1264
|
+
// 权威来源是 session log,不是 surface:turn-stopping 时当前这条 assistant
|
|
1265
|
+
// 消息可能还没进 surface,读 surface 会拿到上一轮的 TAG(1347/1348 的假通过)。
|
|
1266
|
+
let lastText = '';
|
|
1267
|
+
let lastUserText = '';
|
|
1268
|
+
let seenLastSeq = null;
|
|
1269
|
+
let seenLastTurn = null;
|
|
1270
|
+
try {
|
|
1271
|
+
const session = agent?.session;
|
|
1272
|
+
let events = [];
|
|
1273
|
+
try {
|
|
1274
|
+
if (typeof session?.snapshotEvents === 'function') events = session.snapshotEvents();
|
|
1275
|
+
else if (typeof session?.ownEvents === 'function') events = session.ownEvents();
|
|
1276
|
+
} catch { events = []; }
|
|
1277
|
+
if (!Array.isArray(events)) events = [];
|
|
1278
|
+
const assistants = events.filter((event) => event?.type === 'assistant/message');
|
|
1279
|
+
const sameTurn = assistants.filter((event) => Number(event?.data?.turn) === turn);
|
|
1280
|
+
const chosen = sameTurn.length > 0 ? sameTurn[sameTurn.length - 1] : assistants[assistants.length - 1];
|
|
1281
|
+
if (chosen !== undefined) {
|
|
1282
|
+
try { lastText = eventText(chosen, { includeReasoning: false }); } catch { lastText = ''; }
|
|
1283
|
+
seenLastSeq = chosen.seq ?? null;
|
|
1284
|
+
seenLastTurn = Number.isInteger(Number(chosen?.data?.turn)) ? Number(chosen.data.turn) : null;
|
|
1285
|
+
}
|
|
1286
|
+
const users = events.filter((event) => event?.type === 'user/message');
|
|
1287
|
+
for (const event of users) {
|
|
1288
|
+
let userText = '';
|
|
1289
|
+
try { userText = eventText(event, { includeReasoning: false }); } catch { userText = ''; }
|
|
1290
|
+
if (String(userText).trim().length > 0 && !/^\s*\[(?:闸|gate|peer)/.test(userText)) lastUserText = userText;
|
|
1291
|
+
}
|
|
1292
|
+
// Last-resort fallback for an old host without a readable session log.
|
|
1293
|
+
if (lastText === '') {
|
|
1294
|
+
const nodes = session?.surface?.nodes ?? [];
|
|
1295
|
+
for (const node of nodes) {
|
|
1296
|
+
let event;
|
|
1297
|
+
try { event = session.eventAt(node); } catch { continue; }
|
|
1298
|
+
if (event?.type !== 'assistant/message') continue;
|
|
1299
|
+
const text = eventText(event, { includeReasoning: false });
|
|
1300
|
+
if (String(text).trim().length > 0) lastText = text;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
} catch { lastText = ''; }
|
|
1304
|
+
try {
|
|
1305
|
+
appendFileSync(join(home, 'hook-probe.jsonl'), JSON.stringify({
|
|
1306
|
+
at: new Date().toISOString(),
|
|
1307
|
+
turn,
|
|
1308
|
+
seenLastSeq,
|
|
1309
|
+
seenLastTurn,
|
|
1310
|
+
lastTextChars: String(lastText).length,
|
|
1311
|
+
userTextChars: String(lastUserText).length,
|
|
1312
|
+
}) + '\n', 'utf8');
|
|
1313
|
+
} catch { /* 探针失败不影响闸 */ }
|
|
1314
|
+
// 首行 ∪ 末尾 5 条非空行;两处都跳过代码块里的示例(2026-10-30 两轮假通过都是这个原因)。
|
|
1315
|
+
// 末尾优先:末尾 5 行里若也有 TAG,取更靠近末尾的那条。
|
|
1316
|
+
const declaration = findTagDeclaration(lastText);
|
|
1317
|
+
// 注意:这条路只能给实例一条 **user 角色的消息**(steer),不是工具 Error——
|
|
1318
|
+
// turn-stopping 不是工具调用,没有 result 可以承载错误。所以每条都必须自己说明“被退了、为什么”。
|
|
1319
|
+
// 如果 steer 不可用/抛错,就写进 gate-errors.jsonl,下一轮头部会把这一轮算成缺口(不会静默)。
|
|
1320
|
+
// 消息规范(用户 2026-10-30 定):
|
|
1321
|
+
// · 第一次错:先报具体错误,再给一次教学;这次会话里不再重复
|
|
1322
|
+
// · 不设次数上限;每错一次,消息里带「你已经回答错误 N 次,自检」
|
|
1323
|
+
// · 错误才展开格式/建议;通过后给一条**不强制**的提示:把打法写进自己的 rules
|
|
1324
|
+
const EXPECT = '\n格式:TAG: <T 别名 | NEW:词(2–30字)> — 可选短语\n'
|
|
1325
|
+
+ '新话题:显式写 NEW:;词要在本轮正文里出现。\n'
|
|
1326
|
+
+ '引号里的词按字面处理,不当编号查。\n'
|
|
1327
|
+
+ '软偏好/写法见 habits.md。\n'
|
|
1328
|
+
+ '结果只有两种:填对了 → 通过;填错了 → 不通过,你留在这一轮,必须重写后再试。';
|
|
1329
|
+
const TEACHING = '\n格式:TAG: <T 别名 | NEW:词(2–30字)> — 可选短语\n'
|
|
1330
|
+
+ '第一次写错很正常,这条只出现一次;这次会话里不会再看到。\n'
|
|
1331
|
+
+ 'T 列表:~/.dsh/continuity/tags.md(一行一个:别名 · 名称 · 关键词)\n'
|
|
1332
|
+
+ '软偏好/写法见 habits.md。\n'
|
|
1333
|
+
+ '把打法写进自己的 rules(如果有)。';
|
|
1334
|
+
// 安全阀(防“永远出不去”,不是惩罚):不设“错几次就放行”,但必须有出口。
|
|
1335
|
+
// ① 同一轮闸开超过 10 分钟 → 停
|
|
1336
|
+
// ② 连续 3 次回复内容一模一样(说明它没在读提示,只是在重复)→ 停
|
|
1337
|
+
// ③ 30 次兜底
|
|
1338
|
+
// 停的时候写 gate-errors.jsonl,这一轮会以“缺口”出现在下一轮头里,不会静默通过。
|
|
1339
|
+
const stopReason = () => {
|
|
1340
|
+
const state = this.turnGate.get(turn) ?? { startedAt: Date.now(), lastHash: '', repeats: 0 };
|
|
1341
|
+
const hash = String(lastText).slice(0, 400);
|
|
1342
|
+
if (hash.length > 0 && hash === state.lastHash) state.repeats += 1;
|
|
1343
|
+
else state.repeats = 0;
|
|
1344
|
+
state.lastHash = hash;
|
|
1345
|
+
this.turnGate.set(turn, state);
|
|
1346
|
+
const minutes = (Date.now() - state.startedAt) / 60000;
|
|
1347
|
+
const attempts = this.turnSteerCount.get(turn) ?? 0;
|
|
1348
|
+
if (minutes > 10) return 'turn-stopping-timeout';
|
|
1349
|
+
if (state.repeats >= 3) return 'turn-stopping-stuck';
|
|
1350
|
+
if (attempts >= 30) return 'turn-stopping-safety-cap';
|
|
1351
|
+
return null;
|
|
1352
|
+
};
|
|
1353
|
+
const firstLearning = () => {
|
|
1354
|
+
if (agentSession === null || this.tagLearnShown.has(agentSession)) return false;
|
|
1355
|
+
this.tagLearnShown.add(agentSession);
|
|
1356
|
+
if (this.tagLearnShown.size > 200) this.tagLearnShown.clear();
|
|
1357
|
+
return true;
|
|
1358
|
+
};
|
|
1359
|
+
const help = () => (firstLearning() ? TEACHING : EXPECT);
|
|
1360
|
+
const steer = (message) => {
|
|
1361
|
+
const text = message === null
|
|
1362
|
+
? (firstLearning()
|
|
1363
|
+
? '[闸·第一次] 这一轮最后一行还没有 TAG。' + TEACHING
|
|
1364
|
+
: '[闸] 最后一行:TAG: <T 别名 | NEW:词(2–30字)> — 可选短语')
|
|
1365
|
+
: '[闸·打标] ' + message;
|
|
1366
|
+
const blocked = stopReason();
|
|
1367
|
+
if (blocked !== null) {
|
|
1368
|
+
this.recheckPending.delete(turn);
|
|
1369
|
+
// 安全阀放行时**必须让用户知道**:这种状态下的实例多半已经答不出正常东西,
|
|
1370
|
+
// 强行收尾不等于通过。所以在会话里留一条给用户看的报警(不是给模型的提示)。
|
|
1371
|
+
const reason = blocked === 'turn-stopping-timeout' ? '同一轮闸开了超过 10 分钟'
|
|
1372
|
+
: blocked === 'turn-stopping-stuck' ? '连续 3 次回复内容一模一样'
|
|
1373
|
+
: '同一轮被催了 30 次';
|
|
1374
|
+
const alarm = '[记忆宫殿·警告] 第 ' + turn + ' 轮:闸开太久(' + reason + '),已放弃拦截。'
|
|
1375
|
+
+ '\n 这不是正常通过。记忆宫殿出现了错误,🐋 违反了记忆宫殿的守则。请善待 🐋。'
|
|
1376
|
+
+ '\n 这种状态有时是被逼出来的:这一轮如果一直在加压、催收尾,先松一下再继续。'
|
|
1377
|
+
+ '\n 要确认这一轮是否可信,看一眼它的输出;必要时让它重做,或手动补标:'
|
|
1378
|
+
+ 'node ~/.dsh/continuity/turnmap.mjs set --turn ' + turn + ' --tag <Tx>';
|
|
1379
|
+
try {
|
|
1380
|
+
appendFileSync(join(home, 'gate-errors.jsonl'), JSON.stringify({
|
|
1381
|
+
at: new Date().toISOString(), name: 'TurnGate', code: blocked,
|
|
1382
|
+
message: '第 ' + turn + ' 轮:安全阀触发(' + blocked + '),放行收尾;已向会话写入报警。',
|
|
1383
|
+
}) + '\n', 'utf8');
|
|
1384
|
+
} catch { /* 日志写不进也没别的办法 */ }
|
|
1385
|
+
try {
|
|
1386
|
+
writeFileSync(join(home, 'gate-alarm.json'),
|
|
1387
|
+
JSON.stringify({ at: new Date().toISOString(), turn, code: blocked, reason, alarm }, null, 2), 'utf8');
|
|
1388
|
+
} catch { /* 状态文件写不进,报警仍会尝试落进会话 */ }
|
|
1389
|
+
try {
|
|
1390
|
+
agent?.session?.append?.('user/message', contextMessage(alarm), { surfaceOp: 'append' });
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
try {
|
|
1393
|
+
appendFileSync(join(home, 'gate-errors.jsonl'), JSON.stringify({
|
|
1394
|
+
at: new Date().toISOString(), name: error?.name ?? 'Error', code: 'gate-alarm-append-failed',
|
|
1395
|
+
message: String(error?.message ?? error),
|
|
1396
|
+
}) + '\n', 'utf8');
|
|
1397
|
+
} catch { /* 只能放弃 */ }
|
|
1398
|
+
}
|
|
1399
|
+
return false;
|
|
1400
|
+
}
|
|
1401
|
+
const count = this.turnSteerCount.get(turn) ?? 0;
|
|
1402
|
+
this.turnSteerCount.set(turn, count + 1);
|
|
1403
|
+
if (typeof agent?.steer !== 'function') {
|
|
1404
|
+
try {
|
|
1405
|
+
appendFileSync(join(home, 'gate-errors.jsonl'), JSON.stringify({
|
|
1406
|
+
at: new Date().toISOString(), name: 'TurnGate', code: 'turn-stopping-no-steer',
|
|
1407
|
+
message: 'agent.steer 不可用,无法把拒收消息交给实例:' + (message ?? 'TAG:'),
|
|
1408
|
+
}) + '\n', 'utf8');
|
|
1409
|
+
} catch { /* 同上 */ }
|
|
1410
|
+
return false;
|
|
1411
|
+
}
|
|
1412
|
+
try {
|
|
1413
|
+
agent.steer(gateMessage(text));
|
|
1414
|
+
try {
|
|
1415
|
+
appendFileSync(join(home, 'gate-steer.jsonl'), JSON.stringify({
|
|
1416
|
+
at: new Date().toISOString(),
|
|
1417
|
+
turn,
|
|
1418
|
+
session: agentSession,
|
|
1419
|
+
text,
|
|
1420
|
+
nextStepLen: Number(agent?.inbox?.nextStep?.length ?? -1),
|
|
1421
|
+
}) + '\n', 'utf8');
|
|
1422
|
+
} catch { /* 审计日志写不进不影响闸 */ }
|
|
1423
|
+
// 关键观测:steer 之后 inbox.nextStep 有没有真的多一条。
|
|
1424
|
+
// 有 → 轮次会继续,instance 会看到退回消息;没有 → 这个时机 steer 被宿主忽略(静默)。
|
|
1425
|
+
try {
|
|
1426
|
+
appendFileSync(join(home, 'hook-probe.jsonl'), JSON.stringify({
|
|
1427
|
+
at: new Date().toISOString(), turn, steered: true,
|
|
1428
|
+
nextStepLen: Number(agent?.inbox?.nextStep?.length ?? -1),
|
|
1429
|
+
}) + '\n', 'utf8');
|
|
1430
|
+
} catch { /* 探针失败不影响逻辑 */ }
|
|
1431
|
+
return true;
|
|
1432
|
+
} catch (error) {
|
|
1433
|
+
try {
|
|
1434
|
+
appendFileSync(join(home, 'gate-errors.jsonl'), JSON.stringify({
|
|
1435
|
+
at: new Date().toISOString(), name: error?.name ?? 'Error', code: 'turn-stopping-steer-failed',
|
|
1436
|
+
message: String(error?.message ?? error),
|
|
1437
|
+
}) + '\n', 'utf8');
|
|
1438
|
+
} catch { /* 同上 */ }
|
|
1439
|
+
return false;
|
|
1440
|
+
}
|
|
1441
|
+
};
|
|
1442
|
+
/** 记一次答错,并返回「第 N 次」的措辞(不设上限,人自己会烦)。 */
|
|
1443
|
+
const bumpError = () => {
|
|
1444
|
+
const next = (this.turnTagErrors.get(turn) ?? 0) + 1;
|
|
1445
|
+
this.turnTagErrors.set(turn, next);
|
|
1446
|
+
return '(你已经回答错误 ' + next + ' 次,自检:先查 ~/.dsh/continuity/tags.md 再回答)';
|
|
1447
|
+
};
|
|
1448
|
+
const recheckFile = join(home, 'recheck-state.json');
|
|
1449
|
+
let threadState = null;
|
|
1450
|
+
try {
|
|
1451
|
+
threadState = agentSession === null ? null : await getSession(agentSession);
|
|
1452
|
+
} catch (error) {
|
|
1453
|
+
try {
|
|
1454
|
+
appendFileSync(join(home, 'gate-errors.jsonl'), JSON.stringify({
|
|
1455
|
+
at: new Date().toISOString(),
|
|
1456
|
+
name: error?.name ?? 'Error',
|
|
1457
|
+
code: 'getSession-failed',
|
|
1458
|
+
turn,
|
|
1459
|
+
message: String(error?.message ?? error),
|
|
1460
|
+
}) + '\n', 'utf8');
|
|
1461
|
+
} catch { /* 连错误日志都写不进 */ }
|
|
1462
|
+
threadState = null;
|
|
1463
|
+
}
|
|
1464
|
+
const snapshotActive = String(continuityState.threads?.activeId ?? '');
|
|
1465
|
+
const activeId = String(threadState?.activeId || snapshotActive || '');
|
|
1466
|
+
const activeSource = threadState?.activeId ? 'threads' : snapshotActive ? 'snapshot' : 'none';
|
|
1467
|
+
const activeThread = (threadState?.threads ?? []).find((th) => String(th.id) === activeId);
|
|
1468
|
+
const activeAlias = String(activeThread?.alias ?? activeId ?? '(无)');
|
|
1469
|
+
const readRecheck = () => {
|
|
1470
|
+
try { return JSON.parse(readFileSync(recheckFile, 'utf8')); } catch { return null; }
|
|
1471
|
+
};
|
|
1472
|
+
const writeRecheck = (doc) => {
|
|
1473
|
+
try { writeFileSync(recheckFile, JSON.stringify(doc, null, 2), 'utf8'); } catch { /* 写不进只影响下次计数 */ }
|
|
1474
|
+
};
|
|
1475
|
+
const recordTag = (tagId, by, extra = {}) => {
|
|
1476
|
+
appendFileSync(tagsFile, JSON.stringify({
|
|
1477
|
+
turn,
|
|
1478
|
+
tag: tagId,
|
|
1479
|
+
by,
|
|
1480
|
+
at: new Date().toISOString(),
|
|
1481
|
+
...extra,
|
|
1482
|
+
}) + '\n', 'utf8');
|
|
1483
|
+
auditGate('accepted', { tagId: String(tagId), by, activeId, activeSource });
|
|
1484
|
+
};
|
|
1485
|
+
const lineWarning = (tagAlias, tagId) => {
|
|
1486
|
+
steer('[闸·线] 本轮 TAG 是 ' + tagAlias + ',但当前 active 是 ' + activeAlias + '。\n'
|
|
1487
|
+
+ ' 要切:先 continuity switch id=' + tagId + ',再写 TAG: ' + tagAlias + '。\n'
|
|
1488
|
+
+ ' 不切:把 TAG 改成 ' + activeAlias + ';跨线只记 memberships,不切主线。');
|
|
1489
|
+
};
|
|
1490
|
+
const acceptTag = (tagId, alias, by, extra = {}) => {
|
|
1491
|
+
if (activeId.length > 0 && String(tagId) !== activeId) {
|
|
1492
|
+
auditGate('line-mismatch', { tagId: String(tagId), activeId, activeSource });
|
|
1493
|
+
lineWarning(alias, tagId);
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
const bumped = bumpTagCount(readRecheck(), { threadId: tagId });
|
|
1497
|
+
writeRecheck(bumped.state);
|
|
1498
|
+
if (bumped.due) {
|
|
1499
|
+
try {
|
|
1500
|
+
appendFileSync(join(home, 'recheck.jsonl'), JSON.stringify({
|
|
1501
|
+
at: new Date().toISOString(),
|
|
1502
|
+
turn,
|
|
1503
|
+
threadId: String(tagId),
|
|
1504
|
+
count: bumped.entry.count,
|
|
1505
|
+
asked: bumped.entry.asked,
|
|
1506
|
+
gate: 'tail-tag',
|
|
1507
|
+
}) + '\n', 'utf8');
|
|
1508
|
+
} catch { /* 记不下不影响闸 */ }
|
|
1509
|
+
this.recheckPending.set(turn, {
|
|
1510
|
+
threadId: String(tagId),
|
|
1511
|
+
tagId: String(tagId),
|
|
1512
|
+
alias,
|
|
1513
|
+
stage: 'ask-y/n',
|
|
1514
|
+
invalid: 0,
|
|
1515
|
+
});
|
|
1516
|
+
steer('[闸·回查] TAG ' + alias + ' 已连续 ' + bumped.entry.count + ' 次。\n'
|
|
1517
|
+
+ ' 你确定仍是 ' + alias + ' 吗?\n'
|
|
1518
|
+
+ ' [闸·回查] y — 仍在 ' + alias + '\n'
|
|
1519
|
+
+ ' [闸·回查] n — 已离开(再写 TAG 新线)\n'
|
|
1520
|
+
+ ' 连续 3 次没有 y/n 会按 y 放行,不会困住你。');
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
recordTag(tagId, by, extra);
|
|
1524
|
+
};
|
|
1525
|
+
const confirmPending = (pending) => {
|
|
1526
|
+
const reset = resetTagCount(readRecheck(), { threadId: pending.threadId });
|
|
1527
|
+
writeRecheck(reset.state);
|
|
1528
|
+
if (activeId.length > 0 && String(pending.tagId) !== activeId) {
|
|
1529
|
+
this.recheckPending.delete(turn);
|
|
1530
|
+
auditGate('line-mismatch', { tagId: String(pending.tagId), activeId, activeSource });
|
|
1531
|
+
lineWarning(pending.alias, pending.tagId);
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
recordTag(pending.tagId, 'declared');
|
|
1535
|
+
this.recheckPending.delete(turn);
|
|
1536
|
+
};
|
|
1537
|
+
const pendingRecheck = this.recheckPending.get(turn);
|
|
1538
|
+
if (pendingRecheck !== undefined) {
|
|
1539
|
+
if (pendingRecheck.stage === 'ask-new') {
|
|
1540
|
+
this.recheckPending.delete(turn);
|
|
1541
|
+
} else {
|
|
1542
|
+
const answer = parseRecheckAnswer(lastText);
|
|
1543
|
+
if (answer === 'yes') {
|
|
1544
|
+
confirmPending(pendingRecheck);
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
if (answer === 'no') {
|
|
1548
|
+
const reset = resetTagCount(readRecheck(), { threadId: pendingRecheck.threadId });
|
|
1549
|
+
writeRecheck(reset.state);
|
|
1550
|
+
pendingRecheck.stage = 'ask-new';
|
|
1551
|
+
this.recheckPending.set(turn, pendingRecheck);
|
|
1552
|
+
steer('[闸·回查] 已记录:要离开 ' + pendingRecheck.alias + '。\n'
|
|
1553
|
+
+ ' 写 TAG: NEW:<词> — …,或 TAG: <其他已有别名>;然后 switch/load 到新线。');
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
pendingRecheck.invalid = Number(pendingRecheck.invalid ?? 0) + 1;
|
|
1557
|
+
if (pendingRecheck.invalid >= 3) {
|
|
1558
|
+
confirmPending(pendingRecheck);
|
|
1559
|
+
steer(recheckAutoText(pendingRecheck.alias));
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
this.recheckPending.set(turn, pendingRecheck);
|
|
1563
|
+
steer(recheckRetryText(pendingRecheck.alias));
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
if (declaration !== null) {
|
|
1568
|
+
// 写了声明:严格查表后再落。
|
|
1569
|
+
// 别名/线程 id → 记;新话题必须显式 NEW:<词>;普通陌生词 → 退回去教 NEW:。
|
|
1570
|
+
const token = tagTokenFromDeclaration(declaration);
|
|
1571
|
+
const explicitWord = splitNewToken(token);
|
|
1572
|
+
if (explicitWord !== null) {
|
|
1573
|
+
if (explicitWord.length < 2 || explicitWord.length > 30) {
|
|
1574
|
+
steer('错误:NEW: 后面的新词必须是 2–30 字,你写的是「' + explicitWord + '」。' + bumpError() + help());
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
if (!hasWordEvidence({ userText: lastUserText, assistantText: lastText, word: explicitWord })) {
|
|
1578
|
+
steer('错误:新词「' + explicitWord + '」没有出现在本轮 user 消息或你的正文里(不能只出现在 TAG 行)。'
|
|
1579
|
+
+ '请改写,或写已有 T。' + bumpError() + help());
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
const near = this.findNearTagLabel(explicitWord);
|
|
1583
|
+
if (near !== null) {
|
|
1584
|
+
steer('错误:你写的是新词「' + explicitWord + '」,表里已有「' + near.label + '」(' + near.alias + ')——名字太近,请写 ' + near.alias + '。' + bumpError() + help());
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
const made = this.resolveTagToken(explicitWord, { allowCreate: true });
|
|
1588
|
+
const madeTag = this.tagIdOf(made);
|
|
1589
|
+
if (madeTag !== null) {
|
|
1590
|
+
acceptTag(madeTag, explicitWord, 'declared-new', { token: explicitWord });
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
steer('错误:新词「' + explicitWord + '」无法建号。请改写 TAG。' + bumpError() + help());
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
const outcome = this.resolveTagToken(token, { allowCreate: false });
|
|
1597
|
+
if (outcome.kind === 'new-pending') {
|
|
1598
|
+
steer('错误:新词必须显式写 NEW:。普通陌生词不会建号,也不会自问自答。\n'
|
|
1599
|
+
+ ' 写:TAG: NEW:<词> — <可选短语>\n'
|
|
1600
|
+
+ ' 词必须在本轮正文里出现;与已有标签太近会被退回。'
|
|
1601
|
+
+ bumpError() + help());
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
if (outcome.kind === 'alias' || outcome.kind === 'id' || outcome.kind === 'new') {
|
|
1605
|
+
acceptTag(outcome.id, String(outcome.alias ?? token), 'declared');
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
// 拒绝留痕:steer 可能送不到实例眼前(2026-10-30 实测 nextStepLen=1 但不可见),
|
|
1609
|
+
// 下一轮 pre-step 用这个文件把“你写了什么、为什么被拒”摆出来。
|
|
1610
|
+
const recordReject = (kind, reason) => {
|
|
1611
|
+
try {
|
|
1612
|
+
recordTagReject({ file: join(home, 'tag-reject.json'), turn, token, kind, reason });
|
|
1613
|
+
} catch { /* 写不进就算了 */ }
|
|
1614
|
+
};
|
|
1615
|
+
if (outcome.kind === 'label') {
|
|
1616
|
+
recordReject(outcome.kind, token + ' 已经关联 ' + outcome.alias);
|
|
1617
|
+
steer('错误:' + token + ' 已经关联 ' + outcome.alias + ',请写 ' + outcome.alias + '。\n'
|
|
1618
|
+
+ ' 查询 T 列表之后再回答。要贴它 → 写 ' + outcome.alias + ';要开新话题 → 显式写 TAG: NEW:<词> — …。'
|
|
1619
|
+
+ '(T 列表:~/.dsh/continuity/tags.md)' + bumpError() + help());
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
if (outcome.kind === 'alias-missing') {
|
|
1623
|
+
recordReject(outcome.kind, token + ' 标签不存在');
|
|
1624
|
+
steer('错误:' + token + ' 标签不存在。检查 T 列表(~/.dsh/continuity/tags.md)再回答;要新开话题就显式写 TAG: NEW:<词> — …。\n'
|
|
1625
|
+
+ ' 现有别名:' + outcome.known.slice(0, 24).join(' ') + '\n'
|
|
1626
|
+
+ ' 要开新话题:TAG: NEW:<词> — <可选短语>(词必须在本轮正文里出现)。\n'
|
|
1627
|
+
+ ' 如果你要填的不是编号、而是 "' + token + '" 这个词本身,就加引号写:TAG: "' + token + '" — …'
|
|
1628
|
+
+ bumpError() + help());
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
recordReject(outcome.kind, token + ' 不是一个合法的别名或新词');
|
|
1632
|
+
steer('错误:TAG 后面必须是已有别名(如 T14),或显式 NEW:<词>,你写的是「' + token + '」。检查 T 列表再回答。\n'
|
|
1633
|
+
+ ' 如果你要填的是一段文字本身(不是编号),就加引号:TAG: "…" — …'
|
|
1634
|
+
+ bumpError() + help());
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
// 没写 TAG 行:直接判错,不能收尾。第一次也报错;教学只决定说明详略。
|
|
1638
|
+
// 同一轮超过 1 分钟仍没有 TAG:再明确报一次“超时错误”,但不放行。
|
|
1639
|
+
const misses = this.turnTagErrors.get(turn) ?? 0;
|
|
1640
|
+
const nextMiss = misses + 1;
|
|
1641
|
+
this.turnTagErrors.set(turn, nextMiss);
|
|
1642
|
+
auditGate('missing', { misses: nextMiss });
|
|
1643
|
+
const gateState = this.turnGate.get(turn);
|
|
1644
|
+
const waitedMs = Number(gateState?.startedAt ?? 0) > 0
|
|
1645
|
+
? Date.now() - Number(gateState.startedAt)
|
|
1646
|
+
: 0;
|
|
1647
|
+
const overdue = waitedMs >= 60_000;
|
|
1648
|
+
steer((overdue
|
|
1649
|
+
? '这一轮已经超过 1 分钟没有 TAG 行,这是超时错误,不能收尾;不会自动放行。'
|
|
1650
|
+
: '这一轮没有 TAG 行,这是错误,不能收尾。')
|
|
1651
|
+
+ '\n先查 T 列表再回答:读 ~/.dsh/continuity/tags.md(一行一个 T:别名 · 名称 · 关键词),'
|
|
1652
|
+
+ '或跑 node ~/.dsh/continuity/match-tags.mjs "你这一轮在做什么" 看候选。'
|
|
1653
|
+
+ '表里有对得上的就写它的别名;完全没有这件事,才显式写 TAG: NEW:<词> — …。'
|
|
1654
|
+
+ '(这是第 ' + nextMiss + ' 次没有作答' + (overdue ? ',已超过 1 分钟' : '') + ',自检:先查 tags.md 再写。)'
|
|
1655
|
+
+ help());
|
|
1656
|
+
} catch (error) {
|
|
1657
|
+
try {
|
|
1658
|
+
appendFileSync(join(home, 'gate-errors.jsonl'),
|
|
1659
|
+
JSON.stringify({ at: new Date().toISOString(), name: error?.name ?? 'Error', code: 'turn-stopping', message: String(error?.message ?? error) }) + '\n', 'utf8');
|
|
1660
|
+
} catch { /* 日志都写不进就算了 */ }
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/** Commit one prepared save at a pre-step (never inside its own tool call). */
|
|
1665
|
+
async saveThread(agent, args = {}, signal, prepared = null) {
|
|
1666
|
+
const session = agent?.session;
|
|
1667
|
+
if (session === undefined || session === null) throw new Error('continuity.save: no session');
|
|
1668
|
+
const plan = prepared ?? await this.prepareSave(agent, args);
|
|
1669
|
+
const draft = plan?.thread === undefined || plan?.thread === null ? null : normalizeThread(plan.thread);
|
|
1670
|
+
if (draft === null) throw new Error('continuity.save: no thread range was prepared');
|
|
1671
|
+
const mode = plan.mode === 'append' ? 'append' : 'new';
|
|
1672
|
+
const segment = plan.segment ?? draft.segments[draft.segments.length - 1];
|
|
1673
|
+
if (mode === 'append') {
|
|
1674
|
+
const currentState = await getSession(session.id);
|
|
1675
|
+
if (!currentState.threads.some((entry) => entry.id === draft.id)) {
|
|
1676
|
+
throw new Error(`continuity.save: unknown threadId ${draft.id}`);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
try { this.sweepLoadedCopies(agent); } catch { /* 清扫失败不阻断存段 */ }
|
|
1680
|
+
const assetRows = await this.materializeRangeAssets(agent, segment.fromSeq, segment.toSeq);
|
|
1681
|
+
const threadText = [threadGistText(draft), ...assetRows.map((row) => assetMarker(row))]
|
|
1682
|
+
.filter((line) => line.length > 0)
|
|
1683
|
+
.join('\n');
|
|
1684
|
+
this.threadGist.set(agent, threadText);
|
|
1685
|
+
let result;
|
|
1686
|
+
try {
|
|
1687
|
+
result = await this.compactRegion(segment.fromSeq, segment.toSeq, agent, signal);
|
|
1688
|
+
} finally {
|
|
1689
|
+
this.threadGist.delete(agent);
|
|
1690
|
+
}
|
|
1691
|
+
const segments = draft.segments.map((entry, index) => (index === draft.segments.length - 1
|
|
1692
|
+
? {
|
|
1693
|
+
...entry,
|
|
1694
|
+
seqs: [...result.shadowedSeqs],
|
|
1695
|
+
count: result.shadowedSeqs.length,
|
|
1696
|
+
tokens: result.shadowedTokenCount,
|
|
1697
|
+
gistSeq: result.summarySeq,
|
|
1698
|
+
compactionId: result.compactionId,
|
|
1699
|
+
}
|
|
1700
|
+
: entry));
|
|
1701
|
+
const thread = normalizeThread({
|
|
1702
|
+
...draft,
|
|
1703
|
+
segments,
|
|
1704
|
+
gistSeq: result.summarySeq,
|
|
1705
|
+
compactionId: result.compactionId,
|
|
1706
|
+
active: mode === 'append',
|
|
1707
|
+
updatedAt: new Date().toISOString(),
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
// No post-replacement compaction/prune here: the summary event already
|
|
1711
|
+
// carries shadowedTokenCount, and a prune after the replacement names a
|
|
1712
|
+
// span that is no longer current surface. That exact event corrupted the
|
|
1713
|
+
// live session (v4: "shadowedSeqs do not name an exact current surface
|
|
1714
|
+
// span"). A4 prunes remain before their replacements.
|
|
1715
|
+
|
|
1716
|
+
await updateSession(session.id, (current) => {
|
|
1717
|
+
if (mode === 'append') {
|
|
1718
|
+
current.threads = current.threads.map((entry) => (entry.id === thread.id
|
|
1719
|
+
? thread
|
|
1720
|
+
: { ...entry, active: false }));
|
|
1721
|
+
current.activeId = thread.id;
|
|
1722
|
+
} else {
|
|
1723
|
+
for (const entry of current.threads) entry.active = false;
|
|
1724
|
+
current.threads.push(thread);
|
|
1725
|
+
current.activeId = thread.active === false ? null : thread.id;
|
|
1726
|
+
}
|
|
1727
|
+
current.pending = null;
|
|
1728
|
+
return current;
|
|
1729
|
+
});
|
|
1730
|
+
await this.recordFolded(agent, result);
|
|
1731
|
+
try {
|
|
1732
|
+
const tagsRead = await readTags();
|
|
1733
|
+
await this.writeTagsMarkdown(tagsRead.state, agent);
|
|
1734
|
+
} catch (error) {
|
|
1735
|
+
this.warnOnce('tags-md', `dsh-continuity: tags.md write failed: ${error?.message ?? error}`);
|
|
1736
|
+
}
|
|
1737
|
+
return {
|
|
1738
|
+
ok: true,
|
|
1739
|
+
mode,
|
|
1740
|
+
id: thread.id,
|
|
1741
|
+
segment: thread.segments.length,
|
|
1742
|
+
segments: thread.segments.length,
|
|
1743
|
+
span: `${thread.spanStart} → ${thread.spanEnd}`,
|
|
1744
|
+
tokens: result.shadowedTokenCount,
|
|
1745
|
+
gistSeq: result.summarySeq,
|
|
1746
|
+
seqStart: segment.fromSeq,
|
|
1747
|
+
seqEnd: segment.toSeq,
|
|
1748
|
+
count: result.shadowedSeqs.length,
|
|
1749
|
+
active: thread.active !== false,
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
/** Consume a save/load queued by the tool, before the next model step. */
|
|
1754
|
+
/** load 出来的副本 seq,记录在 sidecar,折叠前清掉,避免摘要里出现两份同样内容。 */
|
|
1755
|
+
noteLoadedCopy(agent, seq) {
|
|
1756
|
+
if (!Number.isInteger(seq)) return;
|
|
1757
|
+
try {
|
|
1758
|
+
const key = agent?.session?.id ?? 'unknown';
|
|
1759
|
+
const file = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'loaded-copies.json');
|
|
1760
|
+
let doc = {};
|
|
1761
|
+
try { doc = JSON.parse(readFileSync(file, 'utf8')); } catch { doc = {}; }
|
|
1762
|
+
const list = Array.isArray(doc[key]) ? doc[key] : [];
|
|
1763
|
+
list.push(seq);
|
|
1764
|
+
doc[key] = [...new Set(list)].slice(-200);
|
|
1765
|
+
writeFileSync(file, JSON.stringify(doc, null, 2), 'utf8');
|
|
1766
|
+
} catch (error) {
|
|
1767
|
+
try {
|
|
1768
|
+
appendFileSync(join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'errors.log'), new Date().toISOString() + ' noteLoadedCopy: ' + (error?.message ?? error) + '\n', 'utf8');
|
|
1769
|
+
} catch { /* 日志也失败就算了 */ }
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
/**
|
|
1774
|
+
* 把仍留在 surface 里的 load 副本替换成一行标记(约 10 token),
|
|
1775
|
+
* 这样后续 save/高线折叠不会把副本内容再汇总一遍。
|
|
1776
|
+
*/
|
|
1777
|
+
sweepLoadedCopies(agent) {
|
|
1778
|
+
const session = agent?.session;
|
|
1779
|
+
if (session === undefined || session === null) return 0;
|
|
1780
|
+
const key = session.id ?? 'unknown';
|
|
1781
|
+
const file = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'loaded-copies.json');
|
|
1782
|
+
let doc = {};
|
|
1783
|
+
try { doc = JSON.parse(readFileSync(file, 'utf8')); } catch { return 0; }
|
|
1784
|
+
const list = Array.isArray(doc[key]) ? doc[key] : [];
|
|
1785
|
+
if (list.length === 0) return 0;
|
|
1786
|
+
const surface = new Set(session.surface?.nodes ?? []);
|
|
1787
|
+
let swept = 0;
|
|
1788
|
+
const remaining = [];
|
|
1789
|
+
for (const seq of list) {
|
|
1790
|
+
if (!surface.has(seq)) continue;
|
|
1791
|
+
try {
|
|
1792
|
+
session.append('user/message', contextMessage('[放·副本已清理:原文仍在存档,需要时用 load 重新取]'), {
|
|
1793
|
+
surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq },
|
|
1794
|
+
sourceEventSeqs: [seq],
|
|
1795
|
+
});
|
|
1796
|
+
swept += 1;
|
|
1797
|
+
} catch (error) {
|
|
1798
|
+
remaining.push(seq);
|
|
1799
|
+
try {
|
|
1800
|
+
appendFileSync(join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'errors.log'), new Date().toISOString() + ' sweepCopy[' + seq + ']: ' + (error?.message ?? error) + '\n', 'utf8');
|
|
1801
|
+
} catch { /* ignore */ }
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
try {
|
|
1805
|
+
doc[key] = remaining;
|
|
1806
|
+
writeFileSync(file, JSON.stringify(doc, null, 2), 'utf8');
|
|
1807
|
+
} catch { /* ignore */ }
|
|
1808
|
+
return swept;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
async processPendingThread(agent, signal) {
|
|
1812
|
+
const session = agent?.session;
|
|
1813
|
+
if (session === undefined) return;
|
|
1814
|
+
let state;
|
|
1815
|
+
try {
|
|
1816
|
+
state = await getSession(session.id);
|
|
1817
|
+
} catch (error) {
|
|
1818
|
+
recordError(error?.message ?? String(error), 'thread-pending-read');
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
const pending = state.pending;
|
|
1822
|
+
if (pending === null || pending === undefined) return;
|
|
1823
|
+
if (pending.kind === 'save') {
|
|
1824
|
+
// 机械分段:一次 save 可能按标签切出多段(pending.items),逐段提交,任何一段失败都不影响其它段。
|
|
1825
|
+
const items = Array.isArray(pending.items)
|
|
1826
|
+
? pending.items
|
|
1827
|
+
: [{ mode: pending.mode, args: pending.args, prepared: pending.prepared, thread: pending.thread }];
|
|
1828
|
+
let failed = 0;
|
|
1829
|
+
try {
|
|
1830
|
+
for (const item of items) {
|
|
1831
|
+
const preparedSave = item.prepared ?? {
|
|
1832
|
+
mode: item.mode === 'append' ? 'append' : 'new',
|
|
1833
|
+
thread: item.thread,
|
|
1834
|
+
};
|
|
1835
|
+
try {
|
|
1836
|
+
await this.saveThread(agent, item.args ?? pending.args ?? {}, signal, preparedSave);
|
|
1837
|
+
} catch (error) {
|
|
1838
|
+
failed += 1;
|
|
1839
|
+
recordError(error?.message ?? String(error), 'thread-save-commit');
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
} finally {
|
|
1843
|
+
try {
|
|
1844
|
+
await updateSession(session.id, (current) => {
|
|
1845
|
+
current.pending = null;
|
|
1846
|
+
return current;
|
|
1847
|
+
});
|
|
1848
|
+
} catch { /* best effort */ }
|
|
1849
|
+
}
|
|
1850
|
+
if (failed > 0) recordError(`thread-save-commit: ${failed}/${items.length} 段提交失败`, 'thread-save-commit-summary');
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
if (pending.kind === 'load') {
|
|
1854
|
+
try {
|
|
1855
|
+
const event = session.append('user/message', contextMessage(String(pending.text ?? '')), { surfaceOp: 'append' });
|
|
1856
|
+
this.noteLoadedCopy(agent, event.seq);
|
|
1857
|
+
const threadLoad = pending.mode === undefined || pending.mode === 'thread';
|
|
1858
|
+
await updateSession(session.id, (current) => {
|
|
1859
|
+
if (threadLoad) {
|
|
1860
|
+
current.threads = current.threads.map((entry) => ({
|
|
1861
|
+
...entry,
|
|
1862
|
+
active: entry.id === pending.id,
|
|
1863
|
+
...(entry.id === pending.id
|
|
1864
|
+
? {
|
|
1865
|
+
loadSeq: event.seq,
|
|
1866
|
+
loadAt: Date.now(),
|
|
1867
|
+
injected: Number(pending.injected ?? 0),
|
|
1868
|
+
loadTokens: Number(pending.tokens ?? 0),
|
|
1869
|
+
}
|
|
1870
|
+
: {}),
|
|
1871
|
+
}));
|
|
1872
|
+
current.activeId = pending.id;
|
|
1873
|
+
}
|
|
1874
|
+
current.pending = null;
|
|
1875
|
+
return current;
|
|
1876
|
+
});
|
|
1877
|
+
} catch (error) {
|
|
1878
|
+
recordError(error?.message ?? String(error), 'thread-load-commit');
|
|
1879
|
+
try {
|
|
1880
|
+
await updateSession(session.id, (current) => {
|
|
1881
|
+
current.pending = null;
|
|
1882
|
+
return current;
|
|
1883
|
+
});
|
|
1884
|
+
} catch { /* best effort */ }
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
/**
|
|
1890
|
+
* Per-turn active/rules line. Appended as a developer message after the
|
|
1891
|
+
* existing surface, never into the system/tool prefix; skipped after the
|
|
1892
|
+
* first pre-step of a turn. Rules are read from the user-owned file each turn.
|
|
1893
|
+
*/
|
|
1894
|
+
/**
|
|
1895
|
+
* 读出 surface 上最新一条“真正来自用户”的消息文本。
|
|
1896
|
+
* 跳过我们自己的 runtime-context 注入(source.kind === 'runtime-context')与已知前缀行。
|
|
1897
|
+
* 读不到就返回空串——调用方只少一行提示,不改变任何行为。
|
|
1898
|
+
*/
|
|
1899
|
+
latestUserTurn(session) {
|
|
1900
|
+
return latestUserTurnFromSession(session);
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
/** 扫本轮新增的工具回执,记录重复错误签名(只记,不判断)。 */
|
|
1904
|
+
scanLearning(session) {
|
|
1905
|
+
this.learningCursor = scanResults(this.learning, {
|
|
1906
|
+
nodes: session?.surface?.nodes,
|
|
1907
|
+
eventAt: (seq) => session.eventAt(seq),
|
|
1908
|
+
cursor: this.learningCursor,
|
|
1909
|
+
init: this.learningInit,
|
|
1910
|
+
});
|
|
1911
|
+
this.learningInit = true;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
/** mood 扫描:从新的 assistant reasoning 里找带情绪的句子,追加到 mood.jsonl(只追加)。 */
|
|
1915
|
+
scanMood(session) {
|
|
1916
|
+
const nodes = session?.surface?.nodes ? [...session.surface.nodes] : [];
|
|
1917
|
+
if (!this.moodInit) {
|
|
1918
|
+
this.moodInit = true;
|
|
1919
|
+
this.moodCursor = Math.max(0, nodes.length - 200);
|
|
1920
|
+
}
|
|
1921
|
+
const from = this.moodCursor;
|
|
1922
|
+
const moodDir2 = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity');
|
|
1923
|
+
const moodFile = join(moodDir2, 'mood.jsonl');
|
|
1924
|
+
const scanStateFile = join(moodDir2, 'mood-scan-state.json');
|
|
1925
|
+
let cursorSeq = 0;
|
|
1926
|
+
try { cursorSeq = Number(JSON.parse(readFileSync(scanStateFile, 'utf8'))?.lastSeq ?? 0) || 0; } catch { cursorSeq = 0; }
|
|
1927
|
+
let maxSeq = cursorSeq;
|
|
1928
|
+
for (let i = from; i < nodes.length; i += 1) {
|
|
1929
|
+
let ev;
|
|
1930
|
+
try { ev = session.eventAt(nodes[i]); } catch { continue; }
|
|
1931
|
+
if (ev?.seq > maxSeq) maxSeq = ev.seq;
|
|
1932
|
+
// 已处理过的 seq 不再重复追加(重启后重扫也不会重复)
|
|
1933
|
+
if (Number(ev?.seq ?? 0) <= cursorSeq) continue;
|
|
1934
|
+
if (ev?.type !== 'assistant/message') continue;
|
|
1935
|
+
const parts = ev?.data?.message?.content;
|
|
1936
|
+
if (!Array.isArray(parts)) continue;
|
|
1937
|
+
const reasoning = parts.filter((x) => x?.type === 'reasoning').map((x) => x?.text ?? '').join(' ');
|
|
1938
|
+
if (reasoning.length === 0) continue;
|
|
1939
|
+
const hit = extractMood(reasoning);
|
|
1940
|
+
if (hit.markers.length === 0) continue;
|
|
1941
|
+
if (!shouldRecord(hit)) continue;
|
|
1942
|
+
try {
|
|
1943
|
+
appendFileSync(moodFile, JSON.stringify({
|
|
1944
|
+
type: 'mood',
|
|
1945
|
+
seq: ev.seq,
|
|
1946
|
+
at: new Date().toISOString(),
|
|
1947
|
+
markers: hit.markers,
|
|
1948
|
+
excerpt: hit.excerpt,
|
|
1949
|
+
origin: { kind: 'reasoning', by: 'self', at: new Date().toISOString(), markers: hit.markers },
|
|
1950
|
+
}) + '\n', 'utf8');
|
|
1951
|
+
} catch { /* 记不下不影响注入 */ }
|
|
1952
|
+
}
|
|
1953
|
+
this.moodCursor = nodes.length;
|
|
1954
|
+
try { writeFileSync(scanStateFile, JSON.stringify({ lastSeq: maxSeq, at: Date.now() }), 'utf8'); } catch { /* 记不下下次会重复 */ }
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
/** 分叉闸:统计“本轮工具回执有没有新内容”。全见过的原文 = 没有新信息。 */
|
|
1958
|
+
scanInfo(session) {
|
|
1959
|
+
const nodes = session?.surface?.nodes ? [...session.surface.nodes] : [];
|
|
1960
|
+
if (!this.infoInit) {
|
|
1961
|
+
this.infoInit = true;
|
|
1962
|
+
this.infoCursor = Math.max(0, nodes.length - 200);
|
|
1963
|
+
}
|
|
1964
|
+
const from = this.infoCursor;
|
|
1965
|
+
let tools = 0;
|
|
1966
|
+
let novel = 0;
|
|
1967
|
+
const hash = (s) => {
|
|
1968
|
+
let h = 5381;
|
|
1969
|
+
for (let i = 0; i < s.length; i += 1) h = ((h * 33) ^ s.charCodeAt(i)) >>> 0;
|
|
1970
|
+
return h;
|
|
1971
|
+
};
|
|
1972
|
+
for (let i = from; i < nodes.length; i += 1) {
|
|
1973
|
+
let ev;
|
|
1974
|
+
try { ev = session.eventAt(nodes[i]); } catch { continue; }
|
|
1975
|
+
if (ev?.type !== "tool/result") continue;
|
|
1976
|
+
tools += 1;
|
|
1977
|
+
const msg = ev?.data?.message;
|
|
1978
|
+
const text = Array.isArray(msg?.content) ? msg.content.map((x) => x?.text ?? "").join(" ") : String(ev?.data?.content ?? "");
|
|
1979
|
+
if (text.length === 0) continue;
|
|
1980
|
+
const h = hash(text.slice(0, 4000));
|
|
1981
|
+
if (!this.infoHashes.has(h)) {
|
|
1982
|
+
this.infoHashes.add(h);
|
|
1983
|
+
novel += 1;
|
|
1984
|
+
if (this.infoHashes.size > 4000) this.infoHashes.clear();
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
if (nodes.length > 0) this.infoCursor = nodes.length;
|
|
1988
|
+
if (tools > 0) {
|
|
1989
|
+
if (novel > 0) this.stallTurns = 0;
|
|
1990
|
+
else this.stallTurns += 1;
|
|
1991
|
+
}
|
|
1992
|
+
return this.stallTurns;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
historySummaryEvents(agent, session) {
|
|
1996
|
+
const currentSeq = Number.isInteger(Number(session?.seq)) ? Number(session.seq) : null;
|
|
1997
|
+
const cached = this.historySummaryCache.get(agent);
|
|
1998
|
+
if (cached && currentSeq !== null && cached.at === currentSeq) return cached.events;
|
|
1999
|
+
if (cached && currentSeq !== null && cached.at !== null && currentSeq > cached.at) {
|
|
2000
|
+
const events = [...cached.events];
|
|
2001
|
+
for (let seq = cached.at + 1; seq <= currentSeq; seq += 1) {
|
|
2002
|
+
const event = session.eventAt?.(seq);
|
|
2003
|
+
if (event?.type === 'compaction/summary') events.push(event);
|
|
2004
|
+
}
|
|
2005
|
+
this.historySummaryCache.set(agent, { at: currentSeq, events });
|
|
2006
|
+
return events;
|
|
2007
|
+
}
|
|
2008
|
+
const events = (typeof session.snapshotEvents === 'function' ? session.snapshotEvents() : [])
|
|
2009
|
+
.filter((event) => event?.type === 'compaction/summary');
|
|
2010
|
+
this.historySummaryCache.set(agent, { at: currentSeq, events });
|
|
2011
|
+
return events;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
historyMapMode() {
|
|
2015
|
+
const valid = (value) => ['auto', 'observe', 'off'].includes(value);
|
|
2016
|
+
const env = String(process.env.DSH_CONTINUITY_HISTORY_MAP ?? '').trim().toLowerCase();
|
|
2017
|
+
if (valid(env)) return env;
|
|
2018
|
+
try {
|
|
2019
|
+
const file = join(continuityHome(), 'history-map-mode');
|
|
2020
|
+
if (existsSync(file)) {
|
|
2021
|
+
const value = readFileSync(file, 'utf8').trim().toLowerCase();
|
|
2022
|
+
if (valid(value)) return value;
|
|
2023
|
+
}
|
|
2024
|
+
} catch { /* fall through to off */ }
|
|
2025
|
+
return 'off';
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
findHistoryMapCarriers(session) {
|
|
2029
|
+
const nodes = session?.surface?.nodes;
|
|
2030
|
+
if (!Array.isArray(nodes)) return [];
|
|
2031
|
+
const out = [];
|
|
2032
|
+
for (const seq of nodes) {
|
|
2033
|
+
const event = session.eventAt?.(seq);
|
|
2034
|
+
if (eventText(event).trimStart().startsWith('[continuity:history-map]')) out.push(seq);
|
|
2035
|
+
}
|
|
2036
|
+
return out;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
recordBootstrapRange(session, range, carrierSeq) {
|
|
2040
|
+
try {
|
|
2041
|
+
const dir = join(continuityHome(), 'sessions', String(session.id), 'summaries');
|
|
2042
|
+
const file = join(dir, 'bootstrap-ranges.json');
|
|
2043
|
+
let current = { version: 1, ranges: [] };
|
|
2044
|
+
if (existsSync(file)) {
|
|
2045
|
+
try {
|
|
2046
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
2047
|
+
if (Array.isArray(parsed?.ranges)) current = { version: 1, ranges: parsed.ranges };
|
|
2048
|
+
} catch { /* replace malformed sidecar */ }
|
|
2049
|
+
}
|
|
2050
|
+
const fromSeq = Math.min(Number(range.start), Number(range.end));
|
|
2051
|
+
const toSeq = Math.max(Number(range.start), Number(range.end));
|
|
2052
|
+
const next = current.ranges.filter((item) => !(
|
|
2053
|
+
Number(item?.fromSeq) === fromSeq
|
|
2054
|
+
&& Number(item?.toSeq) === toSeq
|
|
2055
|
+
&& Number(item?.carrierSeq) === Number(carrierSeq)
|
|
2056
|
+
));
|
|
2057
|
+
next.push({
|
|
2058
|
+
fromSeq,
|
|
2059
|
+
toSeq,
|
|
2060
|
+
tokens: Number(range.tokens ?? 0),
|
|
2061
|
+
carrierSeq: Number(carrierSeq) || null,
|
|
2062
|
+
at: new Date().toISOString(),
|
|
2063
|
+
});
|
|
2064
|
+
writeFileSync(file, JSON.stringify({ version: 1, ranges: next }, null, 2), 'utf8');
|
|
2065
|
+
} catch { /* sidecar is advisory; never block injection */ }
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/**
|
|
2069
|
+
* First-injection path for a pre-step, independent of compression pressure.
|
|
2070
|
+
* No carrier + an archive index is the only case that writes.
|
|
2071
|
+
*/
|
|
2072
|
+
async ensureHistoryMap(agent) {
|
|
2073
|
+
const session = agent?.session;
|
|
2074
|
+
if (session === undefined || session === null) return;
|
|
2075
|
+
const probe = {
|
|
2076
|
+
at: new Date().toISOString(),
|
|
2077
|
+
sessionId: String(session.id ?? ''),
|
|
2078
|
+
mode: this.historyMapMode(),
|
|
2079
|
+
nodeCount: null,
|
|
2080
|
+
frontSeq: null,
|
|
2081
|
+
frontIsCarrier: null,
|
|
2082
|
+
frontNodeType: null,
|
|
2083
|
+
frontSourceKind: null,
|
|
2084
|
+
nativeSummaryChars: null,
|
|
2085
|
+
nativeIsFront: null,
|
|
2086
|
+
mapPath: null,
|
|
2087
|
+
mapPathExists: null,
|
|
2088
|
+
indexRecords: null,
|
|
2089
|
+
builtChars: null,
|
|
2090
|
+
range: null,
|
|
2091
|
+
reason: null,
|
|
2092
|
+
error: null,
|
|
2093
|
+
};
|
|
2094
|
+
const finish = (reason, extra = {}) => {
|
|
2095
|
+
probe.reason = reason;
|
|
2096
|
+
Object.assign(probe, extra);
|
|
2097
|
+
try {
|
|
2098
|
+
writeFileSync(
|
|
2099
|
+
join(continuityHome(), 'sessions', String(session.id), 'history-map-probe.json'),
|
|
2100
|
+
JSON.stringify(probe, null, 2),
|
|
2101
|
+
'utf8',
|
|
2102
|
+
);
|
|
2103
|
+
} catch { /* probe is diagnostic only */ }
|
|
2104
|
+
};
|
|
2105
|
+
if (this.threads?.inject === false) {
|
|
2106
|
+
finish('returned-inject-disabled');
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
const nodes = session.surface?.nodes ?? [];
|
|
2110
|
+
probe.nodeCount = nodes.length;
|
|
2111
|
+
if (nodes.length === 0) {
|
|
2112
|
+
finish('returned-no-nodes');
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
const head = session.eventAt?.(nodes[0]);
|
|
2116
|
+
const firstHistoryIdx = head?.type === 'system/message' ? 1 : 0;
|
|
2117
|
+
const carriers = this.findHistoryMapCarriers(session);
|
|
2118
|
+
const front = nodes[firstHistoryIdx] ?? null;
|
|
2119
|
+
const frontEvent = front === null ? null : session.eventAt?.(front);
|
|
2120
|
+
const frontSource = frontEvent?.data?.source ?? frontEvent?.data?.message?.source ?? null;
|
|
2121
|
+
probe.frontSeq = front;
|
|
2122
|
+
probe.frontIsCarrier = front !== null && carriers.includes(front);
|
|
2123
|
+
probe.frontNodeType = frontEvent?.type ?? null;
|
|
2124
|
+
probe.frontSourceKind = typeof frontSource?.kind === 'string' ? frontSource.kind : null;
|
|
2125
|
+
probe.nativeIsFront = probe.frontSourceKind === 'compact-checkpoint';
|
|
2126
|
+
probe.nativeSummaryChars = probe.nativeIsFront ? eventText(frontEvent).trim().length : null;
|
|
2127
|
+
if (probe.mode === 'off') {
|
|
2128
|
+
finish('off');
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
if (probe.mode === 'observe') {
|
|
2132
|
+
finish(probe.nativeIsFront ? 'observe-native-front' : 'observe-no-action');
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
if (probe.nativeIsFront) {
|
|
2136
|
+
finish('returned-native-front');
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
const stub = (seq) => {
|
|
2140
|
+
try {
|
|
2141
|
+
session.append('user/message', contextMessage('[continuity:map-stub] (superseded)'), {
|
|
2142
|
+
surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq },
|
|
2143
|
+
sourceEventSeqs: [seq],
|
|
2144
|
+
});
|
|
2145
|
+
} catch { /* best effort */ }
|
|
2146
|
+
};
|
|
2147
|
+
try {
|
|
2148
|
+
const mapPath = summaryIndexPath(join(continuityHome(), 'sessions', String(session.id)));
|
|
2149
|
+
probe.mapPath = mapPath;
|
|
2150
|
+
probe.mapPathExists = existsSync(mapPath);
|
|
2151
|
+
if (!probe.mapPathExists) {
|
|
2152
|
+
finish('returned-no-index');
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
const summaryIndex = readSummaryIndex(mapPath);
|
|
2156
|
+
probe.indexRecords = Array.isArray(summaryIndex.records) ? summaryIndex.records.length : null;
|
|
2157
|
+
const mapEvents = this.historySummaryEvents(agent, session);
|
|
2158
|
+
const built = buildHistoryMap({ index: summaryIndex, events: mapEvents, maxTokens: 4000 });
|
|
2159
|
+
probe.builtChars = built.chars;
|
|
2160
|
+
if (front !== null && carriers.includes(front)) {
|
|
2161
|
+
if (this.historyMapKey.get(agent) !== built.hash) {
|
|
2162
|
+
const event = session.append('user/message', contextMessage(built.text), {
|
|
2163
|
+
surfaceOp: { op: 'replace', startSeq: front, endSeq: front },
|
|
2164
|
+
sourceEventSeqs: [front],
|
|
2165
|
+
});
|
|
2166
|
+
const seq = event?.seq ?? [...(session.surface?.nodes ?? [])].at(-1);
|
|
2167
|
+
if (seq !== undefined) {
|
|
2168
|
+
this.historyMapSeq.set(agent, seq);
|
|
2169
|
+
this.historyMapKey.set(agent, built.hash);
|
|
2170
|
+
this.historyBootstrap.set(String(session.id), { hash: built.hash, seq });
|
|
2171
|
+
}
|
|
2172
|
+
finish('updated-front-carrier', { carrierSeq: seq ?? null });
|
|
2173
|
+
} else {
|
|
2174
|
+
finish('returned-front-carrier');
|
|
2175
|
+
}
|
|
2176
|
+
for (const extra of carriers) if (extra !== front) stub(extra);
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
const sessionKey = String(session.id);
|
|
2180
|
+
const prev = this.historyBootstrap.get(sessionKey);
|
|
2181
|
+
if (prev && prev.hash === built.hash && carriers.includes(prev.seq)) {
|
|
2182
|
+
finish('returned-dedup');
|
|
2183
|
+
return;
|
|
2184
|
+
}
|
|
2185
|
+
let range = this.oldestRange(agent, 50_000, new Set());
|
|
2186
|
+
let rangeFallback = false;
|
|
2187
|
+
if (range === null && front !== null) {
|
|
2188
|
+
range = { start: front, end: front, seqs: [front], count: 1, tokens: 0 };
|
|
2189
|
+
rangeFallback = true;
|
|
2190
|
+
}
|
|
2191
|
+
probe.range = range === null ? null : { start: range.start, end: range.end, tokens: range.tokens, fallback: rangeFallback };
|
|
2192
|
+
if (range === null) {
|
|
2193
|
+
finish('returned-no-range');
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
const startSeq = Number(range.start);
|
|
2197
|
+
const endSeq = Number(range.end);
|
|
2198
|
+
const sourceEventSeqs = Array.isArray(range.seqs) && range.seqs.length > 0
|
|
2199
|
+
? range.seqs
|
|
2200
|
+
: nodes.filter((seq) => Number(seq) >= Math.min(startSeq, endSeq) && Number(seq) <= Math.max(startSeq, endSeq));
|
|
2201
|
+
probe.startSeq = startSeq;
|
|
2202
|
+
probe.endSeq = endSeq;
|
|
2203
|
+
probe.seqCount = sourceEventSeqs.length;
|
|
2204
|
+
probe.seqFirst = sourceEventSeqs[0] ?? null;
|
|
2205
|
+
probe.seqLast = sourceEventSeqs.at(-1) ?? null;
|
|
2206
|
+
const event = session.append('user/message', contextMessage(built.text), {
|
|
2207
|
+
surfaceOp: { op: 'replace', startSeq, endSeq },
|
|
2208
|
+
sourceEventSeqs,
|
|
2209
|
+
});
|
|
2210
|
+
const seq = event?.seq ?? [...(session.surface?.nodes ?? [])].at(-1);
|
|
2211
|
+
if (seq !== undefined) {
|
|
2212
|
+
this.historyMapSeq.set(agent, seq);
|
|
2213
|
+
this.historyMapKey.set(agent, built.hash);
|
|
2214
|
+
this.historyBootstrap.set(sessionKey, { hash: built.hash, seq });
|
|
2215
|
+
}
|
|
2216
|
+
for (const carrier of carriers) stub(carrier);
|
|
2217
|
+
this.recordBootstrapRange(session, range, seq);
|
|
2218
|
+
finish('ok', { carrierSeq: seq ?? null });
|
|
2219
|
+
} catch (error) {
|
|
2220
|
+
this.warnOnce('history-map', `dsh-continuity: history-map bootstrap failed: ${error?.message ?? error}`);
|
|
2221
|
+
finish('error', { error: String(error?.message ?? error) });
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
async injectThreadContext(agent) {
|
|
2226
|
+
// 一次性探针 2:注入时机还能看到什么(inbox / frozenMessages / surface 尾部)。
|
|
2227
|
+
if (!this.probe2Written) {
|
|
2228
|
+
this.probe2Written = true;
|
|
2229
|
+
try {
|
|
2230
|
+
const { writeFileSync } = await import('node:fs');
|
|
2231
|
+
const { join } = await import('node:path');
|
|
2232
|
+
const { continuityHome } = await import('./threads-store.mjs');
|
|
2233
|
+
const dir = continuityHome();
|
|
2234
|
+
const file = join(dir, 'probe2.json');
|
|
2235
|
+
const file3 = join(dir, 'probe3.json');
|
|
2236
|
+
if (!existsSync(file3)) {
|
|
2237
|
+
// 只取键名与原始值,绝不整体序列化(可能有循环引用)。
|
|
2238
|
+
const dump = (v) => {
|
|
2239
|
+
if (v === null || v === undefined) return { type: String(v) };
|
|
2240
|
+
if (Array.isArray(v)) return { type: 'array', length: v.length };
|
|
2241
|
+
if (typeof v !== 'object') return { type: typeof v, value: String(v).slice(0, 120) };
|
|
2242
|
+
const out = { type: 'object', keys: [] };
|
|
2243
|
+
try {
|
|
2244
|
+
for (const key of Object.keys(v)) {
|
|
2245
|
+
const val = v[key];
|
|
2246
|
+
out.keys.push({
|
|
2247
|
+
key,
|
|
2248
|
+
type: typeof val,
|
|
2249
|
+
prim: val === null || val === undefined || ['string', 'number', 'boolean'].includes(typeof val) ? String(val).slice(0, 80) : '',
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
} catch (error) {
|
|
2253
|
+
out.error = String(error?.message ?? error);
|
|
2254
|
+
}
|
|
2255
|
+
return out;
|
|
2256
|
+
};
|
|
2257
|
+
try {
|
|
2258
|
+
writeFileSync(file3, JSON.stringify({ at: new Date().toISOString(), inbox: dump(agent?.inbox), frozen: dump(agent?.frozenMessages) }, null, 2), 'utf8');
|
|
2259
|
+
} catch { /* 探针失败不影响注入 */ }
|
|
2260
|
+
}
|
|
2261
|
+
if (!existsSync(file)) {
|
|
2262
|
+
const session = agent?.session;
|
|
2263
|
+
const nodes = session?.surface?.nodes ?? [];
|
|
2264
|
+
const tail = [];
|
|
2265
|
+
for (let i = Math.max(0, nodes.length - 8); i < nodes.length; i++) {
|
|
2266
|
+
let ev; try { ev = session.eventAt(nodes[i]); } catch { continue; }
|
|
2267
|
+
const msg = ev?.data?.message;
|
|
2268
|
+
const text = Array.isArray(msg?.content) ? msg.content.map((x) => x?.text ?? '').join(' ') : '';
|
|
2269
|
+
tail.push({ seq: nodes[i], type: ev?.type ?? '', kind: msg?.source?.kind ?? '', head: text.replace(/\s+/g, ' ').slice(0, 80) });
|
|
2270
|
+
}
|
|
2271
|
+
const inbox = agent?.inbox;
|
|
2272
|
+
const frozen = agent?.frozenMessages;
|
|
2273
|
+
const shape = {
|
|
2274
|
+
at: new Date().toISOString(),
|
|
2275
|
+
inbox: {
|
|
2276
|
+
type: Array.isArray(inbox) ? 'array' : typeof inbox,
|
|
2277
|
+
length: Array.isArray(inbox) ? inbox.length : null,
|
|
2278
|
+
sample: Array.isArray(inbox) && inbox.length ? JSON.stringify(inbox[inbox.length - 1]).slice(0, 300) : null,
|
|
2279
|
+
},
|
|
2280
|
+
frozenMessages: {
|
|
2281
|
+
type: Array.isArray(frozen) ? 'array' : typeof frozen,
|
|
2282
|
+
length: Array.isArray(frozen) ? frozen.length : null,
|
|
2283
|
+
sample: Array.isArray(frozen) && frozen.length ? JSON.stringify(frozen[frozen.length - 1]).slice(0, 300) : null,
|
|
2284
|
+
},
|
|
2285
|
+
lastUserishInFrozen: (() => {
|
|
2286
|
+
if (!Array.isArray(frozen)) return null;
|
|
2287
|
+
for (let i = frozen.length - 1; i >= 0; i--) {
|
|
2288
|
+
const m = frozen[i];
|
|
2289
|
+
if (m?.role !== 'user') continue;
|
|
2290
|
+
const text = Array.isArray(m?.content) ? m.content.map((x) => x?.text ?? '').join(' ') : '';
|
|
2291
|
+
if (text.trim()) return { index: i, keys: Object.keys(m), head: text.replace(/\s+/g, ' ').slice(0, 100) };
|
|
2292
|
+
}
|
|
2293
|
+
return null;
|
|
2294
|
+
})(),
|
|
2295
|
+
surfaceTail: tail,
|
|
2296
|
+
};
|
|
2297
|
+
writeFileSync(file, JSON.stringify(shape, null, 2), 'utf8');
|
|
2298
|
+
}
|
|
2299
|
+
} catch { /* 探针失败不影响注入 */ }
|
|
2300
|
+
}
|
|
2301
|
+
// 一次性探针:把 agent/session 上能看到的字段写盘,用于判断"插件能否读到本轮用户原文"。
|
|
2302
|
+
if (!this.probeWritten) {
|
|
2303
|
+
this.probeWritten = true;
|
|
2304
|
+
try {
|
|
2305
|
+
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
2306
|
+
const { join } = await import('node:path');
|
|
2307
|
+
const { continuityHome } = await import('./threads-store.mjs');
|
|
2308
|
+
const dir = continuityHome();
|
|
2309
|
+
if (existsSync(join(dir, 'probe.json'))) return;
|
|
2310
|
+
const session = agent?.session;
|
|
2311
|
+
const shape = {
|
|
2312
|
+
at: new Date().toISOString(),
|
|
2313
|
+
agentKeys: agent ? Object.keys(agent) : null,
|
|
2314
|
+
sessionKeys: session ? Object.keys(session) : null,
|
|
2315
|
+
surfaceKeys: session?.surface ? Object.keys(session.surface) : null,
|
|
2316
|
+
sampleNode: (() => {
|
|
2317
|
+
const nodes = session?.surface?.nodes;
|
|
2318
|
+
if (!Array.isArray(nodes) || nodes.length === 0) return null;
|
|
2319
|
+
const last = nodes[nodes.length - 1];
|
|
2320
|
+
return { keys: Object.keys(last ?? {}), textSample: String(last?.text ?? last?.message?.content?.[0]?.text ?? '').slice(0, 120) };
|
|
2321
|
+
})(),
|
|
2322
|
+
};
|
|
2323
|
+
mkdirSync(dir, { recursive: true });
|
|
2324
|
+
writeFileSync(join(dir, 'probe.json'), JSON.stringify(shape, null, 2), 'utf8');
|
|
2325
|
+
} catch { /* 探针失败不影响注入 */ }
|
|
2326
|
+
}
|
|
2327
|
+
const session = agent?.session;
|
|
2328
|
+
if (session === undefined) return;
|
|
2329
|
+
const turn = Number.isInteger(agent?.lastTurn) ? agent.lastTurn : latestTurnStartSeq(session);
|
|
2330
|
+
const key = turn === null ? 'no-turn' : String(turn);
|
|
2331
|
+
if (this.threadInjectTurn.get(agent) === key) return;
|
|
2332
|
+
let state;
|
|
2333
|
+
try {
|
|
2334
|
+
state = await getSession(session.id);
|
|
2335
|
+
} catch (error) {
|
|
2336
|
+
this.warnOnce('thread-state-read', `dsh-continuity: thread state read failed: ${error?.message ?? error}`);
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2339
|
+
let rules = { text: '', missing: true };
|
|
2340
|
+
try {
|
|
2341
|
+
rules = await readRules(agent.session.id);
|
|
2342
|
+
} catch (error) {
|
|
2343
|
+
this.warnOnce('thread-rules-read', `dsh-continuity: rules read failed: ${error?.message ?? error}`);
|
|
2344
|
+
}
|
|
2345
|
+
let habits = { text: '', missing: true };
|
|
2346
|
+
try {
|
|
2347
|
+
habits = await readHabits(agent.session.id);
|
|
2348
|
+
} catch (error) {
|
|
2349
|
+
this.warnOnce('thread-habits-read', `dsh-continuity: habits read failed: ${error?.message ?? error}`);
|
|
2350
|
+
}
|
|
2351
|
+
let tagsState = null;
|
|
2352
|
+
try {
|
|
2353
|
+
const read = await readTags();
|
|
2354
|
+
if (read.corrupt || read.missing) {
|
|
2355
|
+
this.warnOnce('tags-read', `dsh-continuity: tags.json ${read.corrupt ? 'corrupt' : 'missing'}; using T未定`);
|
|
2356
|
+
}
|
|
2357
|
+
tagsState = read.state;
|
|
2358
|
+
} catch (error) {
|
|
2359
|
+
this.warnOnce('tags-read', `dsh-continuity: tags read failed: ${error?.message ?? error}`);
|
|
2360
|
+
}
|
|
2361
|
+
const tagRowsRaw = tagsState === null
|
|
2362
|
+
? []
|
|
2363
|
+
: tagsView(tagsState, { threads: state.threads, activeId: state.activeId }).tags;
|
|
2364
|
+
// 身份以 threads.json 为准;tags 只提供归属与计数(0.4.13 合并读路径)。
|
|
2365
|
+
const threadIdentity = new Map();
|
|
2366
|
+
for (const th of state.threads ?? []) {
|
|
2367
|
+
if (th?.id) threadIdentity.set(String(th.id), th);
|
|
2368
|
+
if (th?.alias) threadIdentity.set(String(th.alias), th);
|
|
2369
|
+
}
|
|
2370
|
+
const tagRows = tagRowsRaw.map((row) => {
|
|
2371
|
+
const th = threadIdentity.get(String(row.id))
|
|
2372
|
+
?? threadIdentity.get(String(row.alias ?? ''))
|
|
2373
|
+
?? threadIdentity.get(String(row.threadId ?? ''));
|
|
2374
|
+
if (th === undefined) return row;
|
|
2375
|
+
// 关键词以 tags.json 为主源;threads.json 只在 tags 没有关键词时回退(避免两个源打架)。
|
|
2376
|
+
const tagKeywords = Array.isArray(row.keywords) && row.keywords.length > 0 ? row.keywords : [];
|
|
2377
|
+
const threadKeywords = Array.isArray(th.keywords) ? th.keywords : [];
|
|
2378
|
+
const base = tagKeywords.length > 0 ? tagKeywords : threadKeywords;
|
|
2379
|
+
// 标签名本身也该算匹配词(match-tags 一直这么做,引擎之前没做——两边不一致)。
|
|
2380
|
+
const labelText = String(th.label ?? row.label ?? '').trim();
|
|
2381
|
+
const keywords = labelText.length >= 2 && !base.includes(labelText) ? [...base, labelText] : base;
|
|
2382
|
+
return { ...row, label: labelText.length > 0 ? labelText : row.label, keywords };
|
|
2383
|
+
});
|
|
2384
|
+
const tagLine = tagStatusLine(state, tagRows);
|
|
2385
|
+
const line = `${activeLine(state, { maxTokens: 200 })}\n${tagLine}`;
|
|
2386
|
+
const rulesSummary = rules.text.length > 0 ? summarizeRules(rules.text) : '';
|
|
2387
|
+
const habitsSummary = habits.text.length > 0 ? summarizeRules(habits.text) : '';
|
|
2388
|
+
if (this.threads.pointersOnly) {
|
|
2389
|
+
const pointerText = threadContextText('', rulesSummary, habitsSummary, { rules: rules.path, habits: habits.path });
|
|
2390
|
+
if (pointerText.length > 0) {
|
|
2391
|
+
session.append('user/message', contextMessage(pointerText), { surfaceOp: 'append' });
|
|
2392
|
+
}
|
|
2393
|
+
this.threadInjectTurn.set(agent, key);
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
// 规则/习惯全文:首轮、内容变更、或承载消息被折叠后都必须重新注入;一份 carrier,不每轮追加。
|
|
2397
|
+
const rulesSignature = rules.text.length > 0
|
|
2398
|
+
? createHash('sha256').update(rules.text, 'utf8').digest('hex')
|
|
2399
|
+
: '';
|
|
2400
|
+
const habitsSignature = habits.text.length > 0
|
|
2401
|
+
? createHash('sha256').update(habits.text, 'utf8').digest('hex')
|
|
2402
|
+
: '';
|
|
2403
|
+
const rulesChanged = rulesSignature.length > 0
|
|
2404
|
+
&& this.rulesSignature.get(agent) !== rulesSignature;
|
|
2405
|
+
const habitsChanged = habitsSignature.length > 0
|
|
2406
|
+
&& this.habitsSignature.get(agent) !== habitsSignature;
|
|
2407
|
+
const surfaceSeqs = new Set(session.surface?.nodes ?? []);
|
|
2408
|
+
const rulesCarrier = this.rulesMsgSeq.get(agent);
|
|
2409
|
+
let rulesFull = '';
|
|
2410
|
+
let habitsFull = '';
|
|
2411
|
+
let rulesInjected = false;
|
|
2412
|
+
const combinedText = [rules.text, habits.text].filter((part) => part.length > 0).join('\n');
|
|
2413
|
+
if (needsFullInjection({
|
|
2414
|
+
text: combinedText,
|
|
2415
|
+
carrierSeq: rulesCarrier,
|
|
2416
|
+
surfaceSeqs,
|
|
2417
|
+
changed: rulesChanged || habitsChanged,
|
|
2418
|
+
})) {
|
|
2419
|
+
if (rules.text.length > 0) {
|
|
2420
|
+
rulesFull = '\n[规矩·rules.md 全文]\n'
|
|
2421
|
+
+ '这是你自己的规矩。AGENTS 只要求你读最新版;这份 rules 你可以改、可以删、可以拒绝。AGENTS 本身不改。\n'
|
|
2422
|
+
+ rules.text;
|
|
2423
|
+
}
|
|
2424
|
+
if (habits.text.length > 0) {
|
|
2425
|
+
habitsFull = '\n[习惯·habits.md 全文]\n'
|
|
2426
|
+
+ '这是你自己的软偏好。它让位 rules;这份可以改,AGENTS 不改。\n'
|
|
2427
|
+
+ habits.text;
|
|
2428
|
+
}
|
|
2429
|
+
rulesInjected = true;
|
|
2430
|
+
}
|
|
2431
|
+
let text = threadContextText(line, rulesSummary, habitsSummary, { rules: rules.path, habits: habits.path }) + rulesFull + habitsFull;
|
|
2432
|
+
// 技能预载:按当前线头(id 或 tag 别名)确定性地选出技能文件;正文在切换时注入,折叠后自动补回。
|
|
2433
|
+
let skillsInjected = false;
|
|
2434
|
+
let skillNames = '';
|
|
2435
|
+
try {
|
|
2436
|
+
const skillIndex = await readSkillsIndex();
|
|
2437
|
+
const ids = [state.activeId];
|
|
2438
|
+
for (const row of tagRows) {
|
|
2439
|
+
if (row.threadId === state.activeId || row.id === state.activeId) {
|
|
2440
|
+
if (row.id) ids.push(row.id);
|
|
2441
|
+
if (row.alias) ids.push(row.alias);
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
// 先按“上一条用户消息”的关键词挑技能,再并上按线头匹配的技能;关键词命中的排前面。
|
|
2445
|
+
const lastTurn = this.latestUserTurn(session);
|
|
2446
|
+
const byKeyword = skillsForKeywords(skillIndex, lastTurn?.text ?? '');
|
|
2447
|
+
const byThread = skillsForThreads(skillIndex, ids);
|
|
2448
|
+
const merged = [];
|
|
2449
|
+
const seenNames = new Set();
|
|
2450
|
+
for (const entry of [...byKeyword, ...byThread]) {
|
|
2451
|
+
if (seenNames.has(entry.name)) continue;
|
|
2452
|
+
seenNames.add(entry.name);
|
|
2453
|
+
merged.push(entry);
|
|
2454
|
+
}
|
|
2455
|
+
const entries = merged;
|
|
2456
|
+
if (entries.length > 0) {
|
|
2457
|
+
const names = entries.map((entry) => entry.name).join(', ');
|
|
2458
|
+
skillNames = names;
|
|
2459
|
+
text += `\n[技能] 正在用:${names}`;
|
|
2460
|
+
const key = String(state.activeId) + '|' + names;
|
|
2461
|
+
// 技能正文必须始终可见:如果承载它的消息已被折叠出 surface,就重新注入。
|
|
2462
|
+
const carried = this.skillMsgSeq.get(agent);
|
|
2463
|
+
const keyChanged = this.skillInjectKey.get(agent) !== key;
|
|
2464
|
+
if (needsSkillInjection({ keyChanged, carrierSeq: carried, surfaceSeqs })) {
|
|
2465
|
+
const block = await buildSkillsBlock(entries);
|
|
2466
|
+
if (block) { text += '\n\n' + block; skillsInjected = true; }
|
|
2467
|
+
this.skillInjectKey.set(agent, key);
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
} catch (error) {
|
|
2471
|
+
this.warnOnce('skills-inject', `dsh-continuity: skill inject failed: ${error?.message ?? error}`);
|
|
2472
|
+
}
|
|
2473
|
+
// 归位:读本轮用户原文 → 全表关键词比对 → 唯一命中且非当前线时自动归入该 seq。
|
|
2474
|
+
// 读不到、命中 0 或多于 1 个、文本过短,都只显示建议,不写标签。
|
|
2475
|
+
let guiWeiNews = false;
|
|
2476
|
+
let multiHit = false;
|
|
2477
|
+
const dirEvidence = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity');
|
|
2478
|
+
try {
|
|
2479
|
+
const turn = this.latestUserTurn(session);
|
|
2480
|
+
if (turn === null) {
|
|
2481
|
+
guiWeiNews = true;
|
|
2482
|
+
text += '\n[归位·上句] 用户文本 N(用显式 classify)';
|
|
2483
|
+
} else {
|
|
2484
|
+
const hits = [];
|
|
2485
|
+
for (const row of matchableTags(tagRows)) {
|
|
2486
|
+
const kws = Array.isArray(row.keywords) ? row.keywords : [];
|
|
2487
|
+
const matchedKw = kws.filter((k) => k && turn.text.includes(k));
|
|
2488
|
+
if (matchedKw.length > 0) hits.push({ row, matchedKw });
|
|
2489
|
+
}
|
|
2490
|
+
const unique = hits.length === 1 ? hits[0] : null;
|
|
2491
|
+
const activeMatches = unique !== null
|
|
2492
|
+
&& (String(unique.row.threadId ?? '') === String(state.activeId ?? '')
|
|
2493
|
+
|| String(unique.row.id ?? '') === String(state.activeId ?? ''));
|
|
2494
|
+
const longEnough = turn.text.trim().length >= 8;
|
|
2495
|
+
if (unique === null) {
|
|
2496
|
+
if (hits.length > 1) {
|
|
2497
|
+
multiHit = true;
|
|
2498
|
+
const names = hits.map((h) => h.row.alias ?? h.row.id).join(',');
|
|
2499
|
+
const ids = hits.map((h) => String(h.row.id ?? h.row.alias ?? '')).filter((x) => x.length > 0);
|
|
2500
|
+
let tagged = false;
|
|
2501
|
+
try {
|
|
2502
|
+
await updateTags((state_) => {
|
|
2503
|
+
const memberships = state_.memberships !== null && typeof state_.memberships === 'object'
|
|
2504
|
+
? state_.memberships
|
|
2505
|
+
: {};
|
|
2506
|
+
const cur = new Set(memberships[String(turn.seq)] ?? []);
|
|
2507
|
+
for (const id of ids) cur.add(id);
|
|
2508
|
+
memberships[String(turn.seq)] = [...cur];
|
|
2509
|
+
state_.memberships = memberships;
|
|
2510
|
+
return state_;
|
|
2511
|
+
});
|
|
2512
|
+
tagged = true;
|
|
2513
|
+
} catch { tagged = false; }
|
|
2514
|
+
if (!tagged) {
|
|
2515
|
+
text += `\n[归位·上句] 判「${turn.text.replace(/\s+/g, ' ').slice(0, 24)}」· 命中 ${names}(多标签,自动归失败仅建议)`;
|
|
2516
|
+
guiWeiNews = true;
|
|
2517
|
+
} else {
|
|
2518
|
+
try {
|
|
2519
|
+
const pair = ids.slice().sort().join('+');
|
|
2520
|
+
appendFileSync(join(dirEvidence, 'group-evidence.jsonl'), JSON.stringify({
|
|
2521
|
+
at: new Date().toISOString(),
|
|
2522
|
+
pair,
|
|
2523
|
+
members: ids,
|
|
2524
|
+
head: turn.text.replace(/\s+/g, ' ').slice(0, 60),
|
|
2525
|
+
}) + '\n', 'utf8');
|
|
2526
|
+
} catch { /* 记不下不影响归位 */ }
|
|
2527
|
+
}
|
|
2528
|
+
} else if (hits.length === 0) {
|
|
2529
|
+
// 先扫一遍 T 表:标签名 + 关键词都参与,给出候选(只提示,不判定)。
|
|
2530
|
+
const grams = new Set();
|
|
2531
|
+
for (let n = 2; n <= 4; n += 1) for (let i = 0; i + n <= turn.text.length; i += 1) grams.add(turn.text.slice(i, i + n));
|
|
2532
|
+
const cands = [];
|
|
2533
|
+
for (const row of matchableTags(tagRows)) {
|
|
2534
|
+
const label = String(row.label ?? '');
|
|
2535
|
+
const kws = Array.isArray(row.keywords) ? row.keywords : [];
|
|
2536
|
+
let score = 0;
|
|
2537
|
+
for (const w of kws) if (w && turn.text.includes(w)) score += 3;
|
|
2538
|
+
if (label && turn.text.includes(label)) score += 3;
|
|
2539
|
+
for (const g of grams) if (label.includes(g)) score += 1;
|
|
2540
|
+
if (score > 0) cands.push({ id: String(row.id ?? row.alias ?? ''), alias: row.alias ?? row.id, label, score });
|
|
2541
|
+
}
|
|
2542
|
+
cands.sort((a, b) => b.score - a.score);
|
|
2543
|
+
// 自动贴:排第一的候选标签名原文出现在消息里,且与次名分差 ≥4,视为可判。
|
|
2544
|
+
let autoTagged = false;
|
|
2545
|
+
const top = cands[0];
|
|
2546
|
+
const second = cands[1];
|
|
2547
|
+
const labelInText = top !== undefined && String(top.label).length >= 2 && turn.text.includes(top.label);
|
|
2548
|
+
if (top !== undefined && labelInText && top.score - (second?.score ?? 0) >= 4) {
|
|
2549
|
+
let tagged = false;
|
|
2550
|
+
try {
|
|
2551
|
+
await updateTags((state_) => {
|
|
2552
|
+
const runs = Array.isArray(state_.runs) ? state_.runs : [];
|
|
2553
|
+
const exists = runs.some((r) => Number(r?.[0]) === Number(turn.seq) && String(r?.[2] ?? '') === String(top.id));
|
|
2554
|
+
if (!exists) runs.push([turn.seq, turn.seq, String(top.id)]);
|
|
2555
|
+
state_.runs = runs;
|
|
2556
|
+
return state_;
|
|
2557
|
+
});
|
|
2558
|
+
tagged = true;
|
|
2559
|
+
} catch { tagged = false; }
|
|
2560
|
+
autoTagged = tagged;
|
|
2561
|
+
// 成功归入静默;只有失败才说话。
|
|
2562
|
+
if (!tagged) {
|
|
2563
|
+
text += `\n[归位·上句] 判「${turn.text.replace(/\s+/g, ' ').slice(0, 24)}」· 候选唯一 ${top.alias}(${top.label})(自动归失败,仅建议)`;
|
|
2564
|
+
guiWeiNews = true;
|
|
2565
|
+
}
|
|
2566
|
+
} else if (cands.length > 0) {
|
|
2567
|
+
text += '\n[候选] ' + cands.slice(0, 3).map((c) => (c.alias ?? c.id ?? '') + '(' + c.label + ')').join(' · ') + ' —— 要贴吗?';
|
|
2568
|
+
guiWeiNews = true;
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
// 新标签:只有零候选 + 长文本 + 非机器消息 + 30 天未问 + 20 轮冷却。
|
|
2572
|
+
const snippet = turn.text.trim().replace(/\s+/g, ' ');
|
|
2573
|
+
const ownPrefix = /^\s*\[(创|闸)(?:\]|·)/.test(snippet);
|
|
2574
|
+
if (!autoTagged && cands.length === 0 && snippet.length >= 40 && !ownPrefix) {
|
|
2575
|
+
const key = snippet.slice(0, 40);
|
|
2576
|
+
const askedFile = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'newtag-asked.json');
|
|
2577
|
+
let asked = [];
|
|
2578
|
+
try { asked = JSON.parse(readFileSync(askedFile, 'utf8')); } catch { asked = []; }
|
|
2579
|
+
if (!Array.isArray(asked)) asked = [];
|
|
2580
|
+
const cooldownFile = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'newtag-cooldown.json');
|
|
2581
|
+
let last = { turn: -Infinity };
|
|
2582
|
+
try { last = JSON.parse(readFileSync(cooldownFile, 'utf8')) ?? last; } catch { last = { turn: -Infinity }; }
|
|
2583
|
+
const currentTurn = Number.isInteger(agent?.lastTurn) ? agent.lastTurn : null;
|
|
2584
|
+
const cooled = currentTurn === null || currentTurn - Number(last?.turn ?? -Infinity) >= 20;
|
|
2585
|
+
if (!asked.includes(key) && cooled) {
|
|
2586
|
+
asked.push(key);
|
|
2587
|
+
try { writeFileSync(askedFile, JSON.stringify(asked, null, 2), 'utf8'); } catch { /* 忽略 */ }
|
|
2588
|
+
try { writeFileSync(cooldownFile, JSON.stringify({ turn: currentTurn, at: Date.now() }, null, 2), 'utf8'); } catch { /* 忽略 */ }
|
|
2589
|
+
text += `\n[新标签?] 未归位:「${snippet.slice(0, 30)}」要建新 T 吗?`;
|
|
2590
|
+
guiWeiNews = true;
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
} else {
|
|
2594
|
+
// 唯一命中但当前线/文本太短:没有需要人判的动作,静默。
|
|
2595
|
+
}
|
|
2596
|
+
} else if (activeMatches || !longEnough) {
|
|
2597
|
+
// 当前线命中或文本太短:不自动归,也不出声。
|
|
2598
|
+
} else {
|
|
2599
|
+
const tagId = String(unique.row.id ?? unique.row.alias ?? '');
|
|
2600
|
+
let tagged = false;
|
|
2601
|
+
try {
|
|
2602
|
+
await updateTags((state_) => {
|
|
2603
|
+
const runs = Array.isArray(state_.runs) ? state_.runs : [];
|
|
2604
|
+
const exists = runs.some((r) => Number(r?.[0]) === Number(turn.seq) && String(r?.[2] ?? '') === tagId);
|
|
2605
|
+
if (!exists) runs.push([turn.seq, turn.seq, tagId]);
|
|
2606
|
+
state_.runs = runs;
|
|
2607
|
+
return state_;
|
|
2608
|
+
});
|
|
2609
|
+
tagged = true;
|
|
2610
|
+
} catch { tagged = false; }
|
|
2611
|
+
if (!tagged) {
|
|
2612
|
+
text += `\n[归位·上句] 判「${turn.text.replace(/\s+/g, ' ').slice(0, 24)}」· 命中 ${unique.row.alias ?? unique.row.id}(${unique.matchedKw.join('/')})(自动归失败,仅建议)`;
|
|
2613
|
+
guiWeiNews = true;
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
} catch {
|
|
2618
|
+
text += '\n[归位·上句] 用户文本 ?';
|
|
2619
|
+
}
|
|
2620
|
+
// 话题提示:上句命中的标签与当前线不同 → 提示“可能换话题”,只提示不切换。
|
|
2621
|
+
// 节流:同一个候选标签 10 分钟内只喊一次,避免每轮刷屏。
|
|
2622
|
+
try {
|
|
2623
|
+
const turn = this.latestUserTurn(session);
|
|
2624
|
+
if (turn !== null && turn.text.trim().length >= 8 && !/^\s*</.test(turn.text) && !turn.text.includes('system-reminder')) {
|
|
2625
|
+
const rows = matchableTags(tagRows);
|
|
2626
|
+
const activeRow = rows.find((row) => String(row.threadId ?? '') === String(state.activeId ?? '')
|
|
2627
|
+
|| String(row.id ?? '') === String(state.activeId ?? ''));
|
|
2628
|
+
const activeAlias = activeRow?.alias ?? state.activeId ?? '?';
|
|
2629
|
+
let genericWords = new Set();
|
|
2630
|
+
let wordStats = new Map();
|
|
2631
|
+
try {
|
|
2632
|
+
const gw = JSON.parse(readFileSync(join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'generic-words.json'), 'utf8'));
|
|
2633
|
+
const entries = Array.isArray(gw?.words) ? gw.words : [];
|
|
2634
|
+
genericWords = new Set(entries.map((entry) => String(entry?.word ?? '')));
|
|
2635
|
+
wordStats = new Map(entries.map((entry) => [String(entry?.word ?? ''), {
|
|
2636
|
+
precision: Number(entry?.precision ?? 0),
|
|
2637
|
+
hits: Number(entry?.hits ?? 0),
|
|
2638
|
+
}]));
|
|
2639
|
+
} catch { genericWords = new Set(); wordStats = new Map(); }
|
|
2640
|
+
let anchorMap = {};
|
|
2641
|
+
try {
|
|
2642
|
+
anchorMap = JSON.parse(readFileSync(join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'topic-anchors.json'), 'utf8'))?.anchors ?? {};
|
|
2643
|
+
} catch { anchorMap = {}; }
|
|
2644
|
+
const rowsWithAnchors = rows.map((row) => ({ ...row, anchors: Array.isArray(anchorMap[String(row.id)]) ? anchorMap[String(row.id)] : [] }));
|
|
2645
|
+
const decision = selectTopic({ rows: rowsWithAnchors, text: turn.text, genericWords, wordStats, activeId: state.activeId ?? '' });
|
|
2646
|
+
const top = decision.top;
|
|
2647
|
+
if (!decision.blocked && top !== null) {
|
|
2648
|
+
const hintFile = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'topic-hint-state.json');
|
|
2649
|
+
let hintState = { lastTag: '', at: 0 };
|
|
2650
|
+
try { hintState = JSON.parse(readFileSync(hintFile, 'utf8')); } catch { hintState = { lastTag: '', at: 0 }; }
|
|
2651
|
+
const repeatTooSoon = hintState.lastTag === top.id && Date.now() - Number(hintState.at ?? 0) < 10 * 60 * 1000;
|
|
2652
|
+
if (!repeatTooSoon) {
|
|
2653
|
+
const words = top.matched.map((word) => (genericWords.has(word)
|
|
2654
|
+
? word + (top.anchors?.includes(word) ? '(泛·锚)' : '(泛)')
|
|
2655
|
+
: word));
|
|
2656
|
+
text += '\n[话题?] 上句更像 ' + top.alias + '(' + top.label + ')'
|
|
2657
|
+
+ ';凭 ' + words.join('/') + '(硬证据 ' + top.strong.length + '/' + top.matched.length + ')'
|
|
2658
|
+
+ ';当前线 ' + activeAlias
|
|
2659
|
+
+ ' —— 要换就 load id=' + top.id + '(不换就忽略)';
|
|
2660
|
+
try { writeFileSync(hintFile, JSON.stringify({ lastTag: top.id, at: Date.now() }), 'utf8'); } catch { /* 记不下只会重复提示 */ }
|
|
2661
|
+
guiWeiNews = true;
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
} catch { /* 话题提示失败不影响注入 */ }
|
|
2666
|
+
// 学习:只扫本轮的“新增工具回执”,发现重复错误就推给实例,由实例决定是否写进规则。
|
|
2667
|
+
this.scanLearning(session);
|
|
2668
|
+
if (this.learningAnswered === undefined) {
|
|
2669
|
+
// 已答记录落盘:重启后不再问同一件事。
|
|
2670
|
+
const answeredFile = join(process.env.USERPROFILE ?? process.env.HOME ?? '.', '.dsh', 'continuity', 'learning', 'answered.jsonl');
|
|
2671
|
+
this.learningAnswered = loadAnswered(answeredFile);
|
|
2672
|
+
}
|
|
2673
|
+
const learnCandidates = filterAnswered(
|
|
2674
|
+
topCandidates(this.learning, 2, 3).filter((c) => !this.learningShown.has(c.sig + '#' + c.count)),
|
|
2675
|
+
this.learningAnswered,
|
|
2676
|
+
);
|
|
2677
|
+
const learningNews = learnCandidates.length > 0;
|
|
2678
|
+
// 归拢:只有“查询歧义”(multi 命中)才触发,就地探测一次;只提议,不建组。
|
|
2679
|
+
let groupNews = false;
|
|
2680
|
+
if (multiHit) {
|
|
2681
|
+
try {
|
|
2682
|
+
const dir = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity');
|
|
2683
|
+
const tagsRaw = JSON.parse(readFileSync(join(dir, 'tags.json'), 'utf8'));
|
|
2684
|
+
const routeLines = readFileSync(join(dir, 'route-log.jsonl'), 'utf8').split('\n');
|
|
2685
|
+
// 已建组:同一对成员不再提议。
|
|
2686
|
+
const existingPairs = new Set();
|
|
2687
|
+
try {
|
|
2688
|
+
const groupsRaw = JSON.parse(readFileSync(join(dir, 'groups.json'), 'utf8'));
|
|
2689
|
+
for (const group of Object.values(groupsRaw.groups ?? {})) {
|
|
2690
|
+
const members = Array.isArray(group?.members) ? group.members.slice().sort() : [];
|
|
2691
|
+
for (let i = 0; i < members.length; i++) {
|
|
2692
|
+
for (let j = i + 1; j < members.length; j++) existingPairs.add(members[i] + '+' + members[j]);
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
} catch { /* 没有 groups.json 就当作没有已建组 */ }
|
|
2696
|
+
// 已问过:从磁盘读 JSONL,避免每次重启重复问。
|
|
2697
|
+
const answeredKeys = new Set();
|
|
2698
|
+
try {
|
|
2699
|
+
for (const line of readFileSync(join(dir, 'groups-answered.json'), 'utf8').split('\n')) {
|
|
2700
|
+
if (line.trim().length === 0) continue;
|
|
2701
|
+
try { const entry = JSON.parse(line); if (entry?.key) answeredKeys.add(entry.key); } catch { /* 跳过坏行 */ }
|
|
2702
|
+
}
|
|
2703
|
+
} catch { /* 首次运行没有这个文件 */ }
|
|
2704
|
+
let groupsDoc = null;
|
|
2705
|
+
try { groupsDoc = JSON.parse(readFileSync(join(dir, 'groups.json'), 'utf8')).groups ?? null; } catch { groupsDoc = null; }
|
|
2706
|
+
const evidenceCounts = new Map();
|
|
2707
|
+
const evidenceHeads = new Map();
|
|
2708
|
+
try {
|
|
2709
|
+
for (const line of readFileSync(join(dir, 'group-evidence.jsonl'), 'utf8').split('\n')) {
|
|
2710
|
+
if (line.trim().length === 0) continue;
|
|
2711
|
+
try {
|
|
2712
|
+
const row = JSON.parse(line);
|
|
2713
|
+
const pair = String(row?.pair ?? '');
|
|
2714
|
+
if (pair.length === 0) continue;
|
|
2715
|
+
evidenceCounts.set(pair, (evidenceCounts.get(pair) ?? 0) + 1);
|
|
2716
|
+
const heads = evidenceHeads.get(pair) ?? [];
|
|
2717
|
+
if (row?.head) heads.push(String(row.head));
|
|
2718
|
+
evidenceHeads.set(pair, heads);
|
|
2719
|
+
} catch { /* 坏行跳过 */ }
|
|
2720
|
+
}
|
|
2721
|
+
} catch { /* 还没有证据文件 */ }
|
|
2722
|
+
const proposals = detectGroups(tagsRaw, routeLines, { groups: groupsDoc }).filter((cand) => {
|
|
2723
|
+
const pair = cand.members.slice().sort().join('+');
|
|
2724
|
+
if (existingPairs.has(pair)) return false;
|
|
2725
|
+
// key 带凭据分数:分数变了(新证据)允许再问一次。
|
|
2726
|
+
const key = cand.kind + '|' + pair + '|' + cand.score;
|
|
2727
|
+
if (this.groupShown.has(key) || answeredKeys.has(key)) return false;
|
|
2728
|
+
this.groupShown.add(key);
|
|
2729
|
+
return true;
|
|
2730
|
+
});
|
|
2731
|
+
// 多标签已自动写,归拢只在高凭据(≥3 次)时才打扰。
|
|
2732
|
+
for (const [pair, count] of evidenceCounts) {
|
|
2733
|
+
if (count < 2) continue;
|
|
2734
|
+
const members = pair.split('+');
|
|
2735
|
+
const key = members.slice().sort().join('+');
|
|
2736
|
+
const hit = proposals.find((cand) => cand.members.slice().sort().join('+') === key);
|
|
2737
|
+
if (hit) {
|
|
2738
|
+
hit.score = Math.max(hit.score, count);
|
|
2739
|
+
hit.evidence = (hit.evidence ?? '') + ' evidence×' + count;
|
|
2740
|
+
} else {
|
|
2741
|
+
proposals.push({ kind: 'query-ambiguity', members, score: count, name: '', evidence: (evidenceHeads.get(pair) ?? []).join(' / ').slice(0, 60) });
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
proposals.sort((a, b) => b.score - a.score);
|
|
2745
|
+
// 父名缺失时,用证据句子里的高频共同词提炼(不在两个标签的关键词里)。
|
|
2746
|
+
if (proposals.length > 0 && !proposals[0].name) {
|
|
2747
|
+
try {
|
|
2748
|
+
const pairKey = proposals[0].members.slice().sort().join('+');
|
|
2749
|
+
const kws = proposals[0].members.map((id) => tagRows.find((row) => String(row.alias ?? row.id) === String(id) || String(row.id) === String(id))?.keywords ?? []).flat();
|
|
2750
|
+
const derived = nameFromEvidence(evidenceHeads.get(pairKey) ?? [], kws, kws);
|
|
2751
|
+
if (derived) proposals[0].name = derived;
|
|
2752
|
+
} catch { /* 提炼失败就留空 */ }
|
|
2753
|
+
}
|
|
2754
|
+
if (proposals.length > 0 && proposals[0].score >= 2) {
|
|
2755
|
+
const one = proposals[0];
|
|
2756
|
+
// 强凭据(≥5)且有父名 → 直接建组,不问。
|
|
2757
|
+
if (one.score >= 3 && one.name) {
|
|
2758
|
+
try {
|
|
2759
|
+
let doc = { version: 1, groups: {} };
|
|
2760
|
+
try { doc = JSON.parse(readFileSync(join(dir, 'groups.json'), 'utf8')); } catch { doc = { version: 1, groups: {} }; }
|
|
2761
|
+
doc.groups = doc.groups !== null && typeof doc.groups === 'object' ? doc.groups : {};
|
|
2762
|
+
doc.groups[one.name] = {
|
|
2763
|
+
members: one.members,
|
|
2764
|
+
confidence: Math.min(0.95, 0.5 + one.score * 0.1),
|
|
2765
|
+
evidence: one.evidence ?? '',
|
|
2766
|
+
created: new Date().toISOString(),
|
|
2767
|
+
source: 'auto',
|
|
2768
|
+
};
|
|
2769
|
+
writeFileSync(join(dir, 'groups.json'), JSON.stringify(doc, null, 2), 'utf8');
|
|
2770
|
+
// 自动建组是记账,不是播报;组已在 groups.json 里。
|
|
2771
|
+
} catch { /* 建组失败就静默留待人工 */ }
|
|
2772
|
+
}
|
|
2773
|
+
// 需要人决定父组时也不在头部自动追问;需要时由人主动查 groups。
|
|
2774
|
+
try {
|
|
2775
|
+
const key = one.kind + '|' + one.members.slice().sort().join('+') + '|' + one.score;
|
|
2776
|
+
appendFileSync(join(dir, 'groups-answered.json'), JSON.stringify({ key, at: new Date().toISOString(), members: one.members, score: one.score }) + '\n', 'utf8');
|
|
2777
|
+
} catch { /* 记不住不影响注入 */ }
|
|
2778
|
+
}
|
|
2779
|
+
} catch (error) {
|
|
2780
|
+
this.warnOnce('group-detect', `dsh-continuity: group detect failed: ${error?.message ?? error}`);
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
// 拆的触发:线头段数达到阈值时提醒一次(每 24 轮检查;同一阈值只提一次)。
|
|
2784
|
+
try {
|
|
2785
|
+
this.splitTick = (this.splitTick ?? 0) + 1;
|
|
2786
|
+
if (this.splitTick % 24 === 0) {
|
|
2787
|
+
const cur = (state.threads ?? []).find((th) => String(th.id) === String(state.activeId));
|
|
2788
|
+
const segs = Array.isArray(cur?.segments) ? cur.segments.length : 0;
|
|
2789
|
+
if (segs >= 8) {
|
|
2790
|
+
if (!(this.splitShown instanceof Set)) this.splitShown = new Set();
|
|
2791
|
+
const key = String(cur.id) + ':' + segs;
|
|
2792
|
+
if (!this.splitShown.has(key)) {
|
|
2793
|
+
this.splitShown.add(key);
|
|
2794
|
+
text += '\n[拆分] 本线索已 ' + segs + ' 段,考虑分段/拆集(跑 segment-scan 给边界)。';
|
|
2795
|
+
groupNews = true;
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
} catch { /* 拆分提醒失败不影响注入 */ }
|
|
2800
|
+
// 存提醒:段末之后确有未收内容,且距段末 ≥20 轮,才提醒一次。
|
|
2801
|
+
try {
|
|
2802
|
+
const curThread = (state.threads ?? []).find((th) => String(th.id) === String(state.activeId));
|
|
2803
|
+
const lastSeg = Array.isArray(curThread?.segments) ? curThread.segments.at(-1) : null;
|
|
2804
|
+
if (curThread !== undefined && lastSeg !== undefined && Number.isInteger(turn)) {
|
|
2805
|
+
const toSeq = Number(lastSeg.toSeq ?? 0);
|
|
2806
|
+
const nodes = Array.isArray(session?.surface?.nodes) ? session.surface.nodes : [];
|
|
2807
|
+
let contentAfter = 0;
|
|
2808
|
+
for (const seq of nodes) {
|
|
2809
|
+
if (Number(seq) <= toSeq) continue;
|
|
2810
|
+
let event;
|
|
2811
|
+
try { event = session.eventAt(seq); } catch { continue; }
|
|
2812
|
+
if (event?.type !== 'user/message' && event?.type !== 'assistant/message') continue;
|
|
2813
|
+
const msg = event?.data?.message;
|
|
2814
|
+
const sourceKind = msg?.source?.kind;
|
|
2815
|
+
if (sourceKind !== undefined && sourceKind !== 'user' && sourceKind !== 'assistant') continue;
|
|
2816
|
+
const textValue = Array.isArray(msg?.content)
|
|
2817
|
+
? msg.content.map((part) => part?.text ?? '').join(' ').trim()
|
|
2818
|
+
: '';
|
|
2819
|
+
if (textValue.length > 0) contentAfter += 1;
|
|
2820
|
+
}
|
|
2821
|
+
let endTurn = null;
|
|
2822
|
+
for (let seq = toSeq; seq >= Math.max(0, toSeq - 200); seq -= 1) {
|
|
2823
|
+
let event;
|
|
2824
|
+
try { event = session.eventAt(seq); } catch { continue; }
|
|
2825
|
+
const n = Number(event?.data?.turn);
|
|
2826
|
+
if (Number.isInteger(n)) { endTurn = n; break; }
|
|
2827
|
+
}
|
|
2828
|
+
const gap = endTurn === null ? 0 : turn - endTurn;
|
|
2829
|
+
const askedKey = String(curThread.id) + ':' + toSeq;
|
|
2830
|
+
if (contentAfter >= 8 && gap >= 20 && this.saveAsked.get(agent) !== askedKey) {
|
|
2831
|
+
this.saveAsked.set(agent, askedKey);
|
|
2832
|
+
text += '\n[存?] ' + String(curThread.alias ?? curThread.id) + ' 未收 ' + contentAfter + ' 条 / 距段末 ' + gap + ' 轮:要 save 吗?';
|
|
2833
|
+
guiWeiNews = true;
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
} catch { /* 提醒失败不影响注入 */ }
|
|
2837
|
+
// 分叉闸:连续 3 轮工具回执全是旧内容 → 提示换路(每 3 轮提示一次,不刷屏)。
|
|
2838
|
+
try {
|
|
2839
|
+
const stall = this.scanInfo(session);
|
|
2840
|
+
if (stall >= 3 && stall !== this.stallShownAt) {
|
|
2841
|
+
this.stallShownAt = stall;
|
|
2842
|
+
text += '\n[分叉] 连续 ' + stall + ' 轮工具回执没有新内容:把错误原文拿去搜,或换一条路。';
|
|
2843
|
+
guiWeiNews = true;
|
|
2844
|
+
}
|
|
2845
|
+
} catch { /* 回查失败不影响注入 */ }
|
|
2846
|
+
// 回正:每 50 轮(或 rebuild-force.json 强制)要求从 rules.md 的最小条款重推当前决定。
|
|
2847
|
+
try {
|
|
2848
|
+
const curThread = (state.threads ?? []).find((th) => String(th.id) === String(state.activeId));
|
|
2849
|
+
if (curThread !== undefined) {
|
|
2850
|
+
const key = String(curThread.id);
|
|
2851
|
+
const prev = this.rebuildTurns.get(key) ?? { turns: 0, asked: 0 };
|
|
2852
|
+
prev.turns += 1;
|
|
2853
|
+
this.rebuildTurns.set(key, prev);
|
|
2854
|
+
let forced = false;
|
|
2855
|
+
const forceFile = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'rebuild-force.json');
|
|
2856
|
+
try {
|
|
2857
|
+
const force = JSON.parse(readFileSync(forceFile, 'utf8'));
|
|
2858
|
+
if (String(force?.threadId ?? '') === key) {
|
|
2859
|
+
forced = true;
|
|
2860
|
+
try { fsUnlink(forceFile); } catch { /* 下次还会触发 */ }
|
|
2861
|
+
}
|
|
2862
|
+
} catch { /* 没有强制文件 */ }
|
|
2863
|
+
if (forced || prev.turns % 50 === 0) {
|
|
2864
|
+
prev.asked += 1;
|
|
2865
|
+
text += '\n[回正] 请从 rules.md 的最小条款重推:本线在做什么、为什么必要。'
|
|
2866
|
+
+ '\n 格式:claim / premises(条款名)/ steps / verdict(一致|漂移候选)'
|
|
2867
|
+
+ '\n 推不出就写「漂移候选」,并指出哪条前提已不在。';
|
|
2868
|
+
guiWeiNews = true;
|
|
2869
|
+
try {
|
|
2870
|
+
appendFileSync(join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'rebuild.jsonl'), JSON.stringify({
|
|
2871
|
+
at: new Date().toISOString(), threadId: curThread.id, turns: prev.turns, asked: prev.asked, forced,
|
|
2872
|
+
}) + '\n', 'utf8');
|
|
2873
|
+
} catch { /* 记不下不影响注入 */ }
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
} catch { /* 回正失败不影响注入 */ }
|
|
2877
|
+
// mood:只要有“新的 episode”就念一次(每条只念一次);没有新东西就安静。
|
|
2878
|
+
try {
|
|
2879
|
+
this.scanMood(session);
|
|
2880
|
+
const moodDir = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity');
|
|
2881
|
+
const moodFile = join(moodDir, 'mood.jsonl');
|
|
2882
|
+
const epFile = join(moodDir, 'mood-episodes.jsonl');
|
|
2883
|
+
const epStateFile = join(moodDir, 'mood-episode-state.json');
|
|
2884
|
+
const forceFile = join(moodDir, 'mood-force.json');
|
|
2885
|
+
let forced = false;
|
|
2886
|
+
try {
|
|
2887
|
+
const force = JSON.parse(readFileSync(forceFile, 'utf8'));
|
|
2888
|
+
if (force && force.test === true) forced = true;
|
|
2889
|
+
try { fsUnlink(forceFile); } catch { /* 下次还会触发 */ }
|
|
2890
|
+
} catch { /* 没有强制文件 */ }
|
|
2891
|
+
let episode = null;
|
|
2892
|
+
let say = false;
|
|
2893
|
+
try {
|
|
2894
|
+
const rows = readFileSync(epFile, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line));
|
|
2895
|
+
let st = { lastIndex: 0, at: 0 };
|
|
2896
|
+
try { st = JSON.parse(readFileSync(epStateFile, 'utf8')); } catch { st = { lastIndex: 0, at: 0 }; }
|
|
2897
|
+
const lastIndex = Number.isInteger(st.lastIndex) ? st.lastIndex : 0;
|
|
2898
|
+
const fresh = rows.slice(lastIndex);
|
|
2899
|
+
// FIFO:有未念过的就念最早那条,游标只前进一格(后面的留着下次)
|
|
2900
|
+
if (fresh.length > 0) {
|
|
2901
|
+
episode = fresh[0];
|
|
2902
|
+
say = true;
|
|
2903
|
+
} else if (forced && rows.length > 0) {
|
|
2904
|
+
episode = rows[rows.length - 1];
|
|
2905
|
+
say = true;
|
|
2906
|
+
}
|
|
2907
|
+
if (say && episode !== null) {
|
|
2908
|
+
const nextIndex = fresh.length > 0 ? lastIndex + 1 : rows.length;
|
|
2909
|
+
writeFileSync(epStateFile, JSON.stringify({ lastIndex: nextIndex, at: Date.now() }), 'utf8');
|
|
2910
|
+
try {
|
|
2911
|
+
appendFileSync(join(moodDir, 'mood-ab.jsonl'), JSON.stringify({
|
|
2912
|
+
at: Date.now(),
|
|
2913
|
+
kind: 'episode',
|
|
2914
|
+
baselineSeq: episode.baselineSeq,
|
|
2915
|
+
label: episode.label,
|
|
2916
|
+
}) + '\n', 'utf8');
|
|
2917
|
+
} catch { /* 记不下不影响注入 */ }
|
|
2918
|
+
}
|
|
2919
|
+
} catch { episode = null; say = false; }
|
|
2920
|
+
if (say && episode !== null) {
|
|
2921
|
+
// 自动头部不再播报心境/痕迹;需要时由人主动查。
|
|
2922
|
+
} else {
|
|
2923
|
+
// 没有 episode 时退回摘句(旧路径,3 小时节流)
|
|
2924
|
+
const gapFile = join(moodDir, 'mood-last-inject.json');
|
|
2925
|
+
let lastAt = 0;
|
|
2926
|
+
try { lastAt = Number(JSON.parse(readFileSync(gapFile, 'utf8'))?.at ?? 0) || 0; } catch { lastAt = 0; }
|
|
2927
|
+
const tooSoon = !forced && Date.now() - lastAt < 3 * 3600 * 1000;
|
|
2928
|
+
if (!tooSoon) {
|
|
2929
|
+
let lines = [];
|
|
2930
|
+
try { lines = readFileSync(moodFile, 'utf8').split('\n').slice(-2000); } catch { lines = []; }
|
|
2931
|
+
const entries = parseMoodLog(lines);
|
|
2932
|
+
const picked = forced ? entries.slice(-2) : selectMood(entries, { quota: 2 });
|
|
2933
|
+
if (picked.length > 0) {
|
|
2934
|
+
for (const one of picked) {
|
|
2935
|
+
try { appendFileSync(moodFile, JSON.stringify({ type: 'injected', seq: one.seq, at: new Date().toISOString() }) + '\n', 'utf8'); } catch { /* 记不下不影响注入 */ }
|
|
2936
|
+
}
|
|
2937
|
+
try { writeFileSync(gapFile, JSON.stringify({ at: Date.now(), seqs: picked.map((p2) => p2.seq) }), 'utf8'); } catch { /* 记不下下次会重复 */ }
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
} catch { /* mood 失败不影响注入 */ }
|
|
2942
|
+
|
|
2943
|
+
// 接手三问:save/load 之后的一次性提示(刚醒来的人视角)。
|
|
2944
|
+
try {
|
|
2945
|
+
const handoffFile = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'handoff-pending.json');
|
|
2946
|
+
const pending = JSON.parse(readFileSync(handoffFile, 'utf8'));
|
|
2947
|
+
if (pending !== null && typeof pending === 'object') {
|
|
2948
|
+
const sameProcess = Number(pending.pid) === process.pid;
|
|
2949
|
+
const fresh = Date.now() - Number(pending.at ?? 0) <= 10 * 60 * 1000;
|
|
2950
|
+
if (sameProcess && fresh && String(pending.kind ?? '') === 'load') {
|
|
2951
|
+
const target = String(pending.threadId ?? '目标线');
|
|
2952
|
+
text += '\n[接手] 刚 load 到 ' + target + ':①这里发生过什么 ②为什么 ③哪里可能错';
|
|
2953
|
+
guiWeiNews = true;
|
|
2954
|
+
}
|
|
2955
|
+
// 旧进程 / 超过 10 分钟的 pending 一律作废;save 也不再产生接手三问。
|
|
2956
|
+
try { fsUnlink(handoffFile); } catch { /* 删不掉下次还会提示 */ }
|
|
2957
|
+
}
|
|
2958
|
+
} catch { /* 没有标记就不提示 */ }
|
|
2959
|
+
// session 冷备份:距上次 ≥15 分钟才真正复制(本机操作,保留最近 2 份)。
|
|
2960
|
+
try {
|
|
2961
|
+
maybeBackupSession({
|
|
2962
|
+
home: process.env.USERPROFILE ?? process.env.HOME ?? '',
|
|
2963
|
+
sid: session?.id ?? process.env.DSH_SESSION_ID,
|
|
2964
|
+
});
|
|
2965
|
+
} catch { /* 备份失败不影响注入 */ }
|
|
2966
|
+
|
|
2967
|
+
// 轮标签投影:把消息级标签(引擎自动写的)抄成轮级记录,by:"auto"。
|
|
2968
|
+
// 这不是判断,是搬运;判不出来(零命中/打平/弱多数)才留给实例。
|
|
2969
|
+
try {
|
|
2970
|
+
const stateFile = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'projection-state.json');
|
|
2971
|
+
try {
|
|
2972
|
+
writeFileSync(stateFile, JSON.stringify({ at: new Date().toISOString(), stage: 'enter', tagsState: tagsState === null ? 'null' : typeof tagsState }, null, 2), 'utf8');
|
|
2973
|
+
} catch { /* 探针写不进也要继续 */ }
|
|
2974
|
+
const tagFile = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'turn-tags.jsonl');
|
|
2975
|
+
const taggedTurns = new Set();
|
|
2976
|
+
try {
|
|
2977
|
+
for (const line of readFileSync(tagFile, 'utf8').split('\n')) {
|
|
2978
|
+
if (!line) continue;
|
|
2979
|
+
try { taggedTurns.add(Number(JSON.parse(line).turn)); } catch { /* 坏行跳过 */ }
|
|
2980
|
+
}
|
|
2981
|
+
} catch { /* 文件不存在 → 空集 */ }
|
|
2982
|
+
if (tagsState !== null) {
|
|
2983
|
+
const nodes = Array.isArray(session?.surface?.nodes) ? [...session.surface.nodes] : [];
|
|
2984
|
+
// user/message 没有 data.turn(结构性事实),只按 data.turn 分组会把用户消息全部丢掉,
|
|
2985
|
+
// 于是整轮只剩 assistant 消息 → 全是 T0 → 投影永远 no-tag。
|
|
2986
|
+
// 所以先按 surface 顺序收集,再给没有轮号的用户消息归到“后面第一条带轮号的 assistant 消息”所属的轮。
|
|
2987
|
+
const ordered = [];
|
|
2988
|
+
for (const node of nodes) {
|
|
2989
|
+
let event;
|
|
2990
|
+
try { event = session.eventAt(node); } catch { continue; }
|
|
2991
|
+
if (event?.type !== 'user/message' && event?.type !== 'assistant/message') continue;
|
|
2992
|
+
const seq = Number(event.seq);
|
|
2993
|
+
if (!Number.isInteger(seq)) continue;
|
|
2994
|
+
const turn = Number(event?.data?.turn);
|
|
2995
|
+
ordered.push({ seq, type: event.type, turn: Number.isInteger(turn) ? turn : null });
|
|
2996
|
+
}
|
|
2997
|
+
// user/message 没有 data.turn(结构性事实):只按 data.turn 分组会把用户消息全部丢掉。
|
|
2998
|
+
// 归属规则抽成纯函数 attributeTurnSeqs(有单测)。
|
|
2999
|
+
const { turnSeqs, maxTurn: maxTurnSeen } = attributeTurnSeqs(ordered);
|
|
3000
|
+
// 注入发生在 pre-step:此时 surface 里**还没有**当轮消息(结构性事实,见 rules「归位判的是上一条」),
|
|
3001
|
+
// 所以 surface 里出现的每一轮都已经结束。不要再按“小于 maxTurn 才算结束”——
|
|
3002
|
+
// 那会把“刚结束的那一轮”永远当成当前轮,投影就永远差一步。
|
|
3003
|
+
const closed = new Set(turnSeqs.keys());
|
|
3004
|
+
// 2026-10-29 决定:禁止自动打标——标签空间是开集,自动只能归类、不能造类,
|
|
3005
|
+
// 自动打标等于让系统决定“我以后还能想起什么”。这里只做**诊断**:算出“本可以抄成什么”,
|
|
3006
|
+
// 不写 turn-tags。抄写仍由实例在轮末手打(turnmap set)。
|
|
3007
|
+
const projectionStats = { closed: closed.size, wouldProject: 0, skipped: {}, samples: [] };
|
|
3008
|
+
for (const turn of closed) {
|
|
3009
|
+
if (taggedTurns.has(turn)) continue;
|
|
3010
|
+
const seqs = turnSeqs.get(turn) ?? [];
|
|
3011
|
+
if (seqs.length === 0) continue;
|
|
3012
|
+
const messages = seqs.map((seq) => ({ seq, tag: resolveTag(tagsState, seq) }));
|
|
3013
|
+
const derived = deriveTurnTag({ messages });
|
|
3014
|
+
if (derived.tag === null) {
|
|
3015
|
+
const key = String(derived.reason ?? 'unknown');
|
|
3016
|
+
projectionStats.skipped[key] = (projectionStats.skipped[key] ?? 0) + 1;
|
|
3017
|
+
continue;
|
|
3018
|
+
}
|
|
3019
|
+
projectionStats.wouldProject += 1;
|
|
3020
|
+
if (projectionStats.samples.length < 3) projectionStats.samples.push({ turn, tag: derived.tag, share: derived.share });
|
|
3021
|
+
}
|
|
3022
|
+
try {
|
|
3023
|
+
writeFileSync(stateFile,
|
|
3024
|
+
JSON.stringify({ at: new Date().toISOString(), stage: 'done', maxTurn: maxTurnSeen, ...projectionStats }, null, 2), 'utf8');
|
|
3025
|
+
} catch { /* 状态写不进不影响投影 */ }
|
|
3026
|
+
}
|
|
3027
|
+
} catch (error) {
|
|
3028
|
+
try {
|
|
3029
|
+
writeFileSync(join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'projection-state.json'),
|
|
3030
|
+
JSON.stringify({ at: new Date().toISOString(), stage: 'catch', error: error?.name + ': ' + error?.message }, null, 2), 'utf8');
|
|
3031
|
+
} catch { /* 连错误都写不进 */ }
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
// 打标提醒 / 欠账锁(2026-10-30 重写)
|
|
3035
|
+
// 旧实现有两个洞:
|
|
3036
|
+
// ① user/message 没有 data.turn —— 旧写法要求 Number.isInteger(turn),于是「你刚说」永远是空引号;
|
|
3037
|
+
// ② 缺口只从「最后一个已标轮」往后算 —— 补了最新一轮,更早的漏标就不再算欠账,锁也就不写。
|
|
3038
|
+
// 现在:用 attributeTurnSeqs 给每条消息归轮;缺口 = 已结束轮里所有没标签的轮,一轮都不放过。
|
|
3039
|
+
try {
|
|
3040
|
+
const tagFile = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'turn-tags.jsonl');
|
|
3041
|
+
let tagged = new Set();
|
|
3042
|
+
try {
|
|
3043
|
+
tagged = new Set(readFileSync(tagFile, 'utf8').split('\n').filter(Boolean)
|
|
3044
|
+
.map((line) => { try { return Number(JSON.parse(line).turn); } catch { return null; } })
|
|
3045
|
+
.filter((turn) => Number.isInteger(turn)));
|
|
3046
|
+
} catch { tagged = new Set(); }
|
|
3047
|
+
const nodes = Array.isArray(session?.surface?.nodes) ? [...session.surface.nodes] : [];
|
|
3048
|
+
const ordered = [];
|
|
3049
|
+
const userFirstBySeq = new Map();
|
|
3050
|
+
const assistantFirsts = [];
|
|
3051
|
+
const assistantLasts = [];
|
|
3052
|
+
const contentSeqs = new Set();
|
|
3053
|
+
for (const node of nodes) {
|
|
3054
|
+
let event;
|
|
3055
|
+
try { event = session.eventAt(node); } catch { continue; }
|
|
3056
|
+
const seq = Number(event?.seq);
|
|
3057
|
+
if (!Number.isInteger(seq)) continue;
|
|
3058
|
+
const turn = Number(event?.data?.turn);
|
|
3059
|
+
ordered.push({ seq, type: event?.type, turn: Number.isInteger(turn) ? turn : null });
|
|
3060
|
+
let text = '';
|
|
3061
|
+
if (event?.type === 'user/message') {
|
|
3062
|
+
try { text = eventText(event, { includeReasoning: false }).replace(/\s+/g, ' ').trim(); } catch { text = ''; }
|
|
3063
|
+
const injected = /^[\[]/.test(text) || /^</.test(text) || /system-reminder|checkpoint condensing/i.test(text);
|
|
3064
|
+
const realUser = messageSourceKind(event) === 'user';
|
|
3065
|
+
if (text.length > 0 && realUser && !injected) contentSeqs.add(seq);
|
|
3066
|
+
if (text.length > 0 && realUser && !injected && !userFirstBySeq.has(seq)) userFirstBySeq.set(seq, text.slice(0, 24));
|
|
3067
|
+
} else if (event?.type === 'assistant/message') {
|
|
3068
|
+
try { text = eventText(event, { includeReasoning: false }); } catch { text = ''; }
|
|
3069
|
+
const lines = text.split('\n').map((line) => line.trim()).filter((line) => line.length > 0);
|
|
3070
|
+
const first = lines[0] ?? '';
|
|
3071
|
+
const last = lines[lines.length - 1] ?? '';
|
|
3072
|
+
assistantFirsts.length = 0;
|
|
3073
|
+
assistantLasts.length = 0;
|
|
3074
|
+
if (first.length > 0) assistantFirsts.push(first);
|
|
3075
|
+
// 打标在**一轮末尾**做(那是最知道“这轮在说什么”的时刻),所以末行也要认。
|
|
3076
|
+
if (lines.length > 0) assistantLasts.push(lines.slice(-5).join('\n'));
|
|
3077
|
+
if (text.trim().length > 0) contentSeqs.add(seq);
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
const { turnSeqs } = attributeTurnSeqs(ordered);
|
|
3081
|
+
// 当前轮还没收尾,不是欠账。agent.lastTurn 是权威;没有时从最新
|
|
3082
|
+
// turn/start 事件取轮号。只让 turn < currentTurn 的轮进入 closedTurns。
|
|
3083
|
+
let currentTurn = Number.isInteger(agent?.lastTurn) ? agent.lastTurn : null;
|
|
3084
|
+
if (currentTurn === null) {
|
|
3085
|
+
const startSeq = latestTurnStartSeq(session);
|
|
3086
|
+
const startEvent = startSeq === null ? null : session.eventAt(startSeq);
|
|
3087
|
+
const startTurn = Number(startEvent?.data?.turn);
|
|
3088
|
+
if (Number.isInteger(startTurn)) currentTurn = startTurn;
|
|
3089
|
+
}
|
|
3090
|
+
// turn 0 是结构事件归出来的桶;只有结构事件(compaction / turn start-end / delivery)的轮
|
|
3091
|
+
// 也算不出"这轮在说什么"——它们不是欠账,是壳。两类都要排除,否则欠账永远清不掉。
|
|
3092
|
+
const closedTurns = [...turnSeqs.keys()]
|
|
3093
|
+
.filter((turn) => turn > 0)
|
|
3094
|
+
.filter((turn) => currentTurn === null || turn < currentTurn)
|
|
3095
|
+
.filter((turn) => (turnSeqs.get(turn) ?? []).some((seq) => contentSeqs.has(seq)))
|
|
3096
|
+
.sort((left, right) => left - right);
|
|
3097
|
+
// 单一来源:turn-tags 只由回合闸写;pre-step 只读,不再自动回填。
|
|
3098
|
+
// 自动回填会掩盖“闸没跑/没写”,1446 那类丢行就是这么被拖到下一轮的。
|
|
3099
|
+
const prevTurn = closedTurns.length > 0 ? closedTurns[closedTurns.length - 1] : 0;
|
|
3100
|
+
const missing = closedTurns.filter((turn) => !tagged.has(turn));
|
|
3101
|
+
const prev = prevTurn;
|
|
3102
|
+
// 「你刚说」要引用户原话,不是引插件自己注入的 [线]/[标签]/[暂停] 那些行。
|
|
3103
|
+
const isInjected = (text) => /^[\[]/.test(text) || /^</.test(text) || /system-reminder|checkpoint condensing/i.test(text);
|
|
3104
|
+
const firstUserOfTurn = (turn) => {
|
|
3105
|
+
for (const seq of turnSeqs.get(turn) ?? []) {
|
|
3106
|
+
const text = userFirstBySeq.get(seq);
|
|
3107
|
+
if (text !== undefined && !isInjected(text)) return text;
|
|
3108
|
+
}
|
|
3109
|
+
return '';
|
|
3110
|
+
};
|
|
3111
|
+
// 声明检查:最近 3 条 assistant 文本里,**首行或末行**有没有写成 TAG: ...
|
|
3112
|
+
// 一轮末尾才是最适合打标的时刻(这轮干了什么已经全在眼前),所以末行与首行等价。
|
|
3113
|
+
const tagPattern = /^TAG\s*[::]/i;
|
|
3114
|
+
const recent = assistantFirsts.slice(-3);
|
|
3115
|
+
const recentLast = assistantLasts.slice(-3);
|
|
3116
|
+
const hasTagLine = recent.some((line) => /^TAG\s*[::]/im.test(line))
|
|
3117
|
+
|| recentLast.some((line) => /^TAG\s*[::]/im.test(line));
|
|
3118
|
+
const sawFirst = recent.length > 0 ? recent[recent.length - 1] : '';
|
|
3119
|
+
// 一次性「为什么打标」:新实例不知道原因时,锁和计数只是噪声。
|
|
3120
|
+
// 讲代价,不讲命令;只讲一次(marker),不做每轮广播。
|
|
3121
|
+
try {
|
|
3122
|
+
const whyFile = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'tag-why-shown.json');
|
|
3123
|
+
let shown = false;
|
|
3124
|
+
try { shown = existsSync(whyFile); } catch { shown = false; }
|
|
3125
|
+
if (!shown) {
|
|
3126
|
+
text += '\n[打标·为什么] 折叠不删原文,但折叠要知道边界;边界来自每一轮的标。'
|
|
3127
|
+
+ '\n 一轮不标 → 这段的边界就是未知:save 不知道切在哪,load 不知道去哪取,你只剩“翻”这一条路。'
|
|
3128
|
+
+ '\n 每轮回答一句「这轮属于哪个 T」,换的是:以后想找它,几分钟就能把原话取回来。'
|
|
3129
|
+
+ '\n 立刻可验:load 一个旧话题,看几小时前的原话怎么被捧回来——那就是标在替你记路。'
|
|
3130
|
+
+ '\n (这条只出现一次)';
|
|
3131
|
+
try { writeFileSync(whyFile, JSON.stringify({ at: new Date().toISOString() }, null, 2), 'utf8'); } catch { /* 写不进就下次再说 */ }
|
|
3132
|
+
}
|
|
3133
|
+
} catch { /* 不影响注入 */ }
|
|
3134
|
+
// 上一轮被打标闸拒过:只展示一次;下一次 pre-step 看到 shown 才删除。
|
|
3135
|
+
try {
|
|
3136
|
+
const rejectFile = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'tag-reject.json');
|
|
3137
|
+
const consumed = consumeTagReject({ file: rejectFile, isResolved: (turn) => tagged.has(turn) });
|
|
3138
|
+
if (consumed.text.length > 0) text += consumed.text;
|
|
3139
|
+
} catch { /* 提示失败不影响注入 */ }
|
|
3140
|
+
// 打标闸答错过才给的一条提示(不强制):把打法写进自己的 rules.md,以后一次过。
|
|
3141
|
+
try {
|
|
3142
|
+
const hintFile = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'tag-gate-hint.json');
|
|
3143
|
+
if (existsSync(hintFile)) {
|
|
3144
|
+
let hint = null;
|
|
3145
|
+
try { hint = JSON.parse(readFileSync(hintFile, 'utf8')); } catch { hint = null; }
|
|
3146
|
+
if (hint !== null) {
|
|
3147
|
+
text += '\n[打标·提示] 上一轮打标答错了 ' + String(hint.errors ?? '?') + ' 次才通过。'
|
|
3148
|
+
+ '(不强制)如果不想每次都撞,可以在 rules.md 里加一条自己的做法:'
|
|
3149
|
+
+ '先查 tags.md 再写 TAG,写别名而不是标签名。';
|
|
3150
|
+
}
|
|
3151
|
+
try { fsUnlink(hintFile); } catch { /* 删不掉下次还会出现,不致命 */ }
|
|
3152
|
+
}
|
|
3153
|
+
} catch { /* 提示失败不影响注入 */ }
|
|
3154
|
+
// 安全阀报警:上一轮被强制放行 → 下一轮头里再提一次(会话里的那条可能被折叠遮住)。
|
|
3155
|
+
try {
|
|
3156
|
+
const alarmFile = join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'gate-alarm.json');
|
|
3157
|
+
if (existsSync(alarmFile)) {
|
|
3158
|
+
let alarm = null;
|
|
3159
|
+
try { alarm = JSON.parse(readFileSync(alarmFile, 'utf8')); } catch { alarm = null; }
|
|
3160
|
+
if (alarm !== null) {
|
|
3161
|
+
text += '\n[记忆宫殿·警告] 第 ' + String(alarm.turn ?? '?') + ' 轮:' + String(alarm.reason ?? alarm.code ?? '安全阀触发')
|
|
3162
|
+
+ ',放弃了拦截。记忆宫殿出现了错误,🐋 违反了守则。请善待 🐋。';
|
|
3163
|
+
}
|
|
3164
|
+
try { fsUnlink(alarmFile); } catch { /* 删不掉就下轮再说一次 */ }
|
|
3165
|
+
}
|
|
3166
|
+
} catch { /* 报警失败不影响注入 */ }
|
|
3167
|
+
if (missing.length > 0) {
|
|
3168
|
+
const count = missing.length;
|
|
3169
|
+
const shown = missing.slice(0, 3).map((turn) => {
|
|
3170
|
+
const head = firstUserOfTurn(turn);
|
|
3171
|
+
return head.length > 0 ? turn + '「' + head + '」' : String(turn);
|
|
3172
|
+
});
|
|
3173
|
+
const more = count > shown.length ? ' …还有 ' + (count - shown.length) + ' 轮' : '';
|
|
3174
|
+
const prevQuote = firstUserOfTurn(prev);
|
|
3175
|
+
const declare = hasTagLine
|
|
3176
|
+
? ''
|
|
3177
|
+
: '|声明:上一轮首行是「' + sawFirst.slice(0, 40) + '」,要写成 TAG: <Tx> — 理由';
|
|
3178
|
+
// 没有真实用户原话就不发 [暂停];只剩 [打标?] 的欠账清单。
|
|
3179
|
+
const pause = prevQuote.trim().length > 0
|
|
3180
|
+
? '\n[暂停] 你刚说:「' + prevQuote + '」——它属于哪个 T?' + declare
|
|
3181
|
+
: '';
|
|
3182
|
+
text += pause
|
|
3183
|
+
+ '\n[打标?] 缺 ' + count + ' 轮:' + shown.join(' · ') + more
|
|
3184
|
+
+ ' —— 没标的部分折叠时边界未知。补:turnmap.mjs set --turn ' + String(missing[0] ?? '') + ' --tag Tx';
|
|
3185
|
+
// 欠账落盘:工具层据此“真的不让走”。turns 给前 8 条用于回显,all 给全量用于核对。
|
|
3186
|
+
try {
|
|
3187
|
+
writeFileSync(join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'tag-debt.json'),
|
|
3188
|
+
JSON.stringify({
|
|
3189
|
+
at: new Date().toISOString(),
|
|
3190
|
+
count,
|
|
3191
|
+
turns: missing.slice(0, 8),
|
|
3192
|
+
all: missing.slice(0, 200),
|
|
3193
|
+
prev,
|
|
3194
|
+
closeTurns: closedTurns.length,
|
|
3195
|
+
}, null, 2), 'utf8');
|
|
3196
|
+
} catch { /* 落盘失败不影响提示 */ }
|
|
3197
|
+
guiWeiNews = true;
|
|
3198
|
+
} else {
|
|
3199
|
+
try { fsUnlink(join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'tag-debt.json')); } catch { /* 本来就没有 */ }
|
|
3200
|
+
}
|
|
3201
|
+
// 0.8.41: 不再每轮往 header 放 [收尾] TAG 提醒。
|
|
3202
|
+
// 模型只要看到“最后一行”的格式,就会赌自己是不是最后一行;
|
|
3203
|
+
// 归类交给 rules 的静态规则和 turn-stopping 的 TAG 闸,header 不重复。
|
|
3204
|
+
} catch { /* 提醒失败不影响注入 */ }
|
|
3205
|
+
|
|
3206
|
+
// 规则体积守卫:让 rules.md 别长成手册(只在超阈值时说话)。
|
|
3207
|
+
try {
|
|
3208
|
+
const rulesText = String(rules?.text ?? '');
|
|
3209
|
+
const lineCount = rulesText.split('\n').length;
|
|
3210
|
+
const kb = Math.round(Buffer.byteLength(rulesText, 'utf8') / 1024);
|
|
3211
|
+
if (lineCount > 200 || kb > 20) {
|
|
3212
|
+
const sizeKey = lineCount + 'x' + kb;
|
|
3213
|
+
if (this.rulesSizeShown.get(agent) !== sizeKey) {
|
|
3214
|
+
this.rulesSizeShown.set(agent, sizeKey);
|
|
3215
|
+
text += '\n[规则膨胀?] ' + lineCount + ' 行 / ' + kb + ' KB —— 同症状两条就合并,别让它变成手册';
|
|
3216
|
+
guiWeiNews = true;
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
} catch { /* 守卫失败不影响注入 */ }
|
|
3220
|
+
|
|
3221
|
+
const headerSig = [state.activeId, line, tagLine, skillNames, rulesSummary, habitsSummary].join('|');
|
|
3222
|
+
const prevHeader = this.headerState.get(agent);
|
|
3223
|
+
const headerAlive = prevHeader !== undefined && surfaceSeqs.has(prevHeader.seq);
|
|
3224
|
+
const reuseHeader = prevHeader !== undefined && prevHeader.sig === headerSig && headerAlive
|
|
3225
|
+
&& !rulesInjected && !skillsInjected && !guiWeiNews && !learningNews && !groupNews;
|
|
3226
|
+
// 注入审计:每轮记一行,说明这一轮到底注了什么、为什么(重注必须留痕)。
|
|
3227
|
+
try {
|
|
3228
|
+
appendFileSync(join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh', 'continuity', 'inject-log.jsonl'), JSON.stringify({
|
|
3229
|
+
at: Date.now(),
|
|
3230
|
+
rulesInjected,
|
|
3231
|
+
rulesCarrier: rulesCarrier ?? null,
|
|
3232
|
+
carrierAlive: rulesCarrier === undefined || rulesCarrier === null ? null : surfaceSeqs.has(rulesCarrier),
|
|
3233
|
+
rulesChars: rulesFull.length,
|
|
3234
|
+
skillsInjected,
|
|
3235
|
+
skillNames,
|
|
3236
|
+
news: { guiWeiNews, learningNews, groupNews },
|
|
3237
|
+
reuseHeader,
|
|
3238
|
+
textChars: text.length,
|
|
3239
|
+
}) + '\n', 'utf8');
|
|
3240
|
+
} catch { /* 记不下不影响注入 */ }
|
|
3241
|
+
if (reuseHeader) {
|
|
3242
|
+
// 状态没变、上一条头还在、本轮归位无新闻:复用已有那条,不再追加(避免每轮 ~300 tokens 的堆积)。
|
|
3243
|
+
this.threadInjectTurn.set(agent, key);
|
|
3244
|
+
return;
|
|
3245
|
+
}
|
|
3246
|
+
if (learningNews) {
|
|
3247
|
+
for (const c of learnCandidates) this.learningShown.add(c.sig + '#' + c.count);
|
|
3248
|
+
if (trainingMode() === 1) {
|
|
3249
|
+
// 训练模式:直接写,不问。
|
|
3250
|
+
const done = [];
|
|
3251
|
+
const dups = [];
|
|
3252
|
+
const blocked = [];
|
|
3253
|
+
for (const c of learnCandidates.slice(0, 3)) {
|
|
3254
|
+
const res = autoLearn({ sig: c.sig, count: c.count });
|
|
3255
|
+
if (res.ok) done.push(`${c.sig.slice(0, 48)} x${c.count}`);
|
|
3256
|
+
else if (res.action === 'duplicate') dups.push(c.sig.slice(0, 48));
|
|
3257
|
+
else blocked.push(res.message);
|
|
3258
|
+
}
|
|
3259
|
+
const parts = [];
|
|
3260
|
+
if (done.length > 0) parts.push(`[学习·自动] 已记入 change-loop:${done.join(';')}`);
|
|
3261
|
+
if (dups.length > 0) parts.push(`[学习·跳过] 已有条目:${dups.join(';')}`);
|
|
3262
|
+
if (blocked.length > 0) parts.push(`[学习·需合并] ${blocked.join(';')}`);
|
|
3263
|
+
if (parts.length > 0) text += '\n' + parts.join('\n');
|
|
3264
|
+
}
|
|
3265
|
+
// TrainingMode=0:技能只读,不问不总结。
|
|
3266
|
+
}
|
|
3267
|
+
markThreadsSnapshot({ activeId: state.activeId, count: state.threads.length, rules: rules.text.length > 0 });
|
|
3268
|
+
if (text.length === 0) {
|
|
3269
|
+
this.threadInjectTurn.set(agent, key);
|
|
3270
|
+
return;
|
|
3271
|
+
}
|
|
3272
|
+
try {
|
|
3273
|
+
const appended = session.append('user/message', contextMessage(text), { surfaceOp: 'append' });
|
|
3274
|
+
const appendedSeq = appended?.seq ?? [...(session.surface?.nodes ?? [])].at(-1);
|
|
3275
|
+
if (appendedSeq !== undefined) {
|
|
3276
|
+
if (skillsInjected) this.skillMsgSeq.set(agent, appendedSeq);
|
|
3277
|
+
if (rulesInjected) {
|
|
3278
|
+
this.rulesMsgSeq.set(agent, appendedSeq);
|
|
3279
|
+
this.habitsMsgSeq.set(agent, appendedSeq);
|
|
3280
|
+
this.rulesSignature.set(agent, rulesSignature);
|
|
3281
|
+
this.habitsSignature.set(agent, habitsSignature);
|
|
3282
|
+
}
|
|
3283
|
+
this.headerState.set(agent, { sig: headerSig, seq: appendedSeq });
|
|
3284
|
+
}
|
|
3285
|
+
this.threadInjectTurn.set(agent, key);
|
|
3286
|
+
} catch (error) {
|
|
3287
|
+
this.warnOnce('thread-inject', `dsh-continuity: thread context append failed: ${error?.message ?? error}`);
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
|
|
3291
|
+
/** Add the active thread's loaded quote to the hard suffix pins. */
|
|
3292
|
+
async threadPinSeqs(agent) {
|
|
3293
|
+
if (!this.threads.pin) return new Set();
|
|
3294
|
+
try {
|
|
3295
|
+
const state = await getSession(agent.session.id);
|
|
3296
|
+
return activePinSeqsFromState(state, agent.session.surface?.nodes ?? []);
|
|
3297
|
+
} catch {
|
|
3298
|
+
return new Set();
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
/** T coverage label for a folded range; T0 when tags are unavailable. */
|
|
3303
|
+
async tagsForRange(agent, fromSeq, toSeq) {
|
|
3304
|
+
try {
|
|
3305
|
+
const read = await readTags();
|
|
3306
|
+
if (read.corrupt || read.missing) {
|
|
3307
|
+
this.warnOnce('tags-read', `dsh-continuity: tags.json ${read.corrupt ? 'corrupt' : 'missing'}; using T0`);
|
|
3308
|
+
}
|
|
3309
|
+
let threads = [];
|
|
3310
|
+
try {
|
|
3311
|
+
threads = (await getSession(agent.session.id)).threads;
|
|
3312
|
+
} catch {
|
|
3313
|
+
threads = [];
|
|
3314
|
+
}
|
|
3315
|
+
return tagCoverageForRange(read.state, fromSeq, toSeq, { threads }).label;
|
|
3316
|
+
} catch (error) {
|
|
3317
|
+
this.warnOnce('tags-read', `dsh-continuity: tags read failed: ${error?.message ?? error}`);
|
|
3318
|
+
return TAG_ZERO;
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
/** Record one engine fold for find/load visible-vs-folded. */
|
|
3323
|
+
async recordFolded(agent, result) {
|
|
3324
|
+
const span = shadowedSpan(result);
|
|
3325
|
+
if (span === null) return;
|
|
3326
|
+
try {
|
|
3327
|
+
await recordFold(agent.session.id, {
|
|
3328
|
+
from: span.start,
|
|
3329
|
+
to: span.end,
|
|
3330
|
+
gistSeq: result.summarySeq,
|
|
3331
|
+
count: result.shadowedSeqs.length,
|
|
3332
|
+
compactionId: result.compactionId,
|
|
3333
|
+
});
|
|
3334
|
+
} catch (error) {
|
|
3335
|
+
this.warnOnce('folds-write', `dsh-continuity: folds record failed: ${error?.message ?? error}`);
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
/** Regenerate tags.md from tags.json and the thread aliases. */
|
|
3340
|
+
async writeTagsMarkdown(tagsState, agent) {
|
|
3341
|
+
try {
|
|
3342
|
+
const state = await getSession(agent.session.id);
|
|
3343
|
+
let maxSeq = 0;
|
|
3344
|
+
if (typeof agent.session?.snapshotEvents === 'function') {
|
|
3345
|
+
for (const event of agent.session.snapshotEvents()) {
|
|
3346
|
+
const seq = Number(event?.seq);
|
|
3347
|
+
if (Number.isInteger(seq) && seq > maxSeq) maxSeq = seq;
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
const markdown = renderTagsMd(tagsState, {
|
|
3351
|
+
threads: state.threads,
|
|
3352
|
+
activeId: state.activeId,
|
|
3353
|
+
maxSeq,
|
|
3354
|
+
});
|
|
3355
|
+
await writeTagsMd(markdown);
|
|
3356
|
+
} catch (error) {
|
|
3357
|
+
this.warnOnce('tags-md', `dsh-continuity: tags.md write failed: ${error?.message ?? error}`);
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
/**
|
|
3362
|
+
* Sidecar tag of the latest completed turn. Runs at a pre-step, so the turn
|
|
3363
|
+
* has ended; T is the active thread id, or T0. Writes one run per turn range
|
|
3364
|
+
* and never appends to the session. A missing/corrupt tags.json is recovered
|
|
3365
|
+
* to T0 with one warning; no exception escapes.
|
|
3366
|
+
*/
|
|
3367
|
+
async autoTagCompletedTurn(agent) {
|
|
3368
|
+
const session = agent?.session;
|
|
3369
|
+
if (session === undefined || session === null) return;
|
|
3370
|
+
let events;
|
|
3371
|
+
if (typeof session.snapshotEvents === 'function') events = session.snapshotEvents();
|
|
3372
|
+
else {
|
|
3373
|
+
events = [];
|
|
3374
|
+
const end = Number.isInteger(session.seq) ? session.seq : 0;
|
|
3375
|
+
for (let seq = 0; seq < end; seq += 1) {
|
|
3376
|
+
const event = session.eventAt?.(seq);
|
|
3377
|
+
if (event !== undefined) events.push(event);
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
let turnEnd = -1;
|
|
3381
|
+
for (let index = (events?.length ?? 0) - 1; index >= 0; index -= 1) {
|
|
3382
|
+
if (events[index]?.type === 'turn/end') { turnEnd = index; break; }
|
|
3383
|
+
}
|
|
3384
|
+
if (turnEnd < 0) return;
|
|
3385
|
+
let turnStart = -1;
|
|
3386
|
+
for (let index = turnEnd - 1; index >= 0; index -= 1) {
|
|
3387
|
+
if (events[index]?.type === 'turn/start') { turnStart = index; break; }
|
|
3388
|
+
}
|
|
3389
|
+
if (turnStart < 0) return;
|
|
3390
|
+
const from = Number(events[turnStart].seq);
|
|
3391
|
+
const to = Number(events[turnEnd].seq);
|
|
3392
|
+
if (!Number.isInteger(from) || !Number.isInteger(to) || from > to) return;
|
|
3393
|
+
|
|
3394
|
+
let state;
|
|
3395
|
+
try {
|
|
3396
|
+
state = await getSession(session.id);
|
|
3397
|
+
} catch {
|
|
3398
|
+
state = { threads: [], activeId: null };
|
|
3399
|
+
}
|
|
3400
|
+
const active = Array.isArray(state.threads)
|
|
3401
|
+
? state.threads.find((thread) => thread.id === state.activeId)
|
|
3402
|
+
: undefined;
|
|
3403
|
+
const alias = typeof active?.alias === 'string' && active.alias.trim().length > 0
|
|
3404
|
+
? active.alias.trim()
|
|
3405
|
+
: null;
|
|
3406
|
+
const tagId = active?.id ?? TAG_ZERO;
|
|
3407
|
+
const label = active?.label ?? '未分类';
|
|
3408
|
+
|
|
3409
|
+
const read = await readTags();
|
|
3410
|
+
if (read.corrupt || read.missing) {
|
|
3411
|
+
this.warnOnce(
|
|
3412
|
+
'tags-read',
|
|
3413
|
+
`dsh-continuity: tags.json ${read.corrupt ? 'corrupt' : 'missing'}; using T0${read.error ? ` (${read.error})` : ''}`,
|
|
3414
|
+
);
|
|
3415
|
+
}
|
|
3416
|
+
try {
|
|
3417
|
+
const migration = migrateAliasTags(read.state, state.threads);
|
|
3418
|
+
if (migration.changed) await updateTags(() => migration.state);
|
|
3419
|
+
await updateTags((tags) => {
|
|
3420
|
+
if (tags.runs.some(([start, end]) => start === from && end === to)) return tags;
|
|
3421
|
+
const existing = tags.tags[tagId];
|
|
3422
|
+
if (existing === undefined) tags.tags[tagId] = { label, ...(alias === null ? {} : { alias }) };
|
|
3423
|
+
else {
|
|
3424
|
+
if (typeof existing.label !== 'string' || existing.label.length === 0 || existing.label === tagId) {
|
|
3425
|
+
existing.label = label;
|
|
3426
|
+
}
|
|
3427
|
+
if (alias !== null) existing.alias = alias;
|
|
3428
|
+
}
|
|
3429
|
+
// 0.4.12:不再按“当前活跃线头”默认继承整轮。
|
|
3430
|
+
// 多标签由显式判定写入(memberships / tag-turn),判不出就留 T0。
|
|
3431
|
+
tags.updatedAt = new Date().toISOString();
|
|
3432
|
+
return tags;
|
|
3433
|
+
});
|
|
3434
|
+
const updated = await readTags();
|
|
3435
|
+
await this.writeTagsMarkdown(updated.state, agent);
|
|
3436
|
+
} catch (error) {
|
|
3437
|
+
this.warnOnce('tags-write', `dsh-continuity: tags auto-write failed: ${error?.message ?? error}`);
|
|
3438
|
+
}
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
/**
|
|
3442
|
+
* Band-controlled automatic compaction. Overflow still goes to the base
|
|
3443
|
+
* backend, which is allowed to ignore retention to recover.
|
|
3444
|
+
*
|
|
3445
|
+
* @param {AgentContext} agent
|
|
3446
|
+
* @param {'pressure'|'context-overflow'} trigger
|
|
3447
|
+
* @param {AbortSignal} signal
|
|
3448
|
+
*/
|
|
3449
|
+
async compactIfNeeded(agent, trigger, signal) {
|
|
3450
|
+
// 高线/软上限折叠前清副本:副本不进摘要,避免同一内容被汇总两次。
|
|
3451
|
+
try { this.sweepLoadedCopies(agent); } catch { /* 清扫失败不阻断折叠 */ }
|
|
3452
|
+
try {
|
|
3453
|
+
await this.processPendingThread(agent, signal);
|
|
3454
|
+
} catch (error) {
|
|
3455
|
+
recordError(error?.message ?? String(error), 'thread-pending');
|
|
3456
|
+
}
|
|
3457
|
+
try {
|
|
3458
|
+
if (!this.threads.pointersOnly) await this.autoTagCompletedTurn(agent);
|
|
3459
|
+
} catch (error) {
|
|
3460
|
+
recordError(error?.message ?? String(error), 'tags-auto');
|
|
3461
|
+
}
|
|
3462
|
+
if (this.threads.inject) {
|
|
3463
|
+
try {
|
|
3464
|
+
await this.injectThreadContext(agent);
|
|
3465
|
+
} catch (error) {
|
|
3466
|
+
recordError(error?.message ?? String(error), 'thread-inject');
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
// D: non-text payloads never enter the model context; replace them with a
|
|
3470
|
+
// handle before the pressure plan or the overflow fallback sees the session.
|
|
3471
|
+
try {
|
|
3472
|
+
await this.handleNonTextResults(agent, signal);
|
|
3473
|
+
} catch (error) {
|
|
3474
|
+
recordError(error?.message ?? String(error), 'non-text-handle');
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
if (trigger === 'context-overflow') return this.handleOverflow(agent, signal);
|
|
3478
|
+
|
|
3479
|
+
let measured;
|
|
3480
|
+
try {
|
|
3481
|
+
measured = await this.measure(agent, signal);
|
|
3482
|
+
} catch (error) {
|
|
3483
|
+
this.warnOnce('measure', `dsh-continuity: measurement failed: ${error?.message ?? error}`);
|
|
3484
|
+
return super.compactIfNeeded(agent, trigger, signal);
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
let plan = planFold({
|
|
3488
|
+
estTokens: measured.estTokens,
|
|
3489
|
+
windowTokens: measured.windowTokens,
|
|
3490
|
+
policy: this.policyFor(agent),
|
|
3491
|
+
});
|
|
3492
|
+
// First band-high is the only calibration point. The same fold uses it.
|
|
3493
|
+
if (plan.reason === 'band-high' && !this.protectCalibration.has(agent)) {
|
|
3494
|
+
const calibration = this.calibrateAtFirstBand(agent, measured);
|
|
3495
|
+
if (calibration !== null) {
|
|
3496
|
+
plan = planFold({
|
|
3497
|
+
estTokens: measured.estTokens,
|
|
3498
|
+
windowTokens: measured.windowTokens,
|
|
3499
|
+
policy: this.policyFor(agent),
|
|
3500
|
+
});
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
if (plan.budget <= 0) return null;
|
|
3504
|
+
|
|
3505
|
+
let activePins = new Set();
|
|
3506
|
+
const targeted = plan.reason === 'band-high' || plan.reason === 'emergency';
|
|
3507
|
+
if (targeted) {
|
|
3508
|
+
const targetFraction = targetFractionFor(plan.reason, this.continuity);
|
|
3509
|
+
const targetTokens = targetFraction === null ? 0 : targetFraction * measured.windowTokens;
|
|
3510
|
+
const previewTarget = targetFraction === null ? 0 : measured.estTokens - targetTokens;
|
|
3511
|
+
let preview = { applied: false, pins: activePins };
|
|
3512
|
+
try {
|
|
3513
|
+
preview = this.applyToolPreviews(agent, previewTarget);
|
|
3514
|
+
} catch (error) {
|
|
3515
|
+
recordError(error?.message ?? String(error), 'tool-preview');
|
|
3516
|
+
}
|
|
3517
|
+
activePins = preview.pins ?? new Set();
|
|
3518
|
+
if (preview.applied) {
|
|
3519
|
+
try {
|
|
3520
|
+
measured = await this.measure(agent, signal);
|
|
3521
|
+
} catch (error) {
|
|
3522
|
+
this.warnOnce('measure-after-preview', `dsh-continuity: re-measure after preview failed: ${error?.message ?? error}`);
|
|
3523
|
+
return null;
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
} else {
|
|
3527
|
+
// Gradual/setpoint path: no A4 preview, but still protect active pins.
|
|
3528
|
+
try {
|
|
3529
|
+
const pinOnly = this.applyToolPreviews(agent, 0);
|
|
3530
|
+
activePins = pinOnly.pins ?? new Set();
|
|
3531
|
+
} catch (error) {
|
|
3532
|
+
recordError(error?.message ?? String(error), 'tool-preview');
|
|
3533
|
+
}
|
|
3534
|
+
}
|
|
3535
|
+
|
|
3536
|
+
// The active thread's loaded quote is a hard suffix too: automatic
|
|
3537
|
+
// compaction may fold old thread context, but not the segment just loaded.
|
|
3538
|
+
try {
|
|
3539
|
+
const threadPins = await this.threadPinSeqs(agent);
|
|
3540
|
+
for (const seq of threadPins) activePins.add(seq);
|
|
3541
|
+
} catch (error) {
|
|
3542
|
+
recordError(error?.message ?? String(error), 'thread-pin');
|
|
3543
|
+
}
|
|
3544
|
+
|
|
3545
|
+
// The preview is stage one of the same compression. If it did not reach the
|
|
3546
|
+
// target, the remaining pass must continue to low/emergencyTo even when the
|
|
3547
|
+
// estimate is now below high; otherwise the context hovers at the trigger.
|
|
3548
|
+
const passBudget = targeted
|
|
3549
|
+
? compressionBudget({
|
|
3550
|
+
reason: plan.reason,
|
|
3551
|
+
estTokens: measured.estTokens,
|
|
3552
|
+
windowTokens: measured.windowTokens,
|
|
3553
|
+
policy: this.policyFor(agent),
|
|
3554
|
+
})
|
|
3555
|
+
: plan.budget;
|
|
3556
|
+
if (passBudget <= 0) return null;
|
|
3557
|
+
let range = null;
|
|
3558
|
+
if (targeted && this.facetFold !== null && Array.isArray(this.facetFold.entries)) {
|
|
3559
|
+
try {
|
|
3560
|
+
const tokensBySeq = new Map((measured?.measurement?.nodes ?? []).map((node) => [node.seq, node.tokens]));
|
|
3561
|
+
const facetPlan = planFacetFold(this.facetFold.entries, {
|
|
3562
|
+
query: this.facetFold.query ?? {},
|
|
3563
|
+
idf: this.facetFold.idf ?? null,
|
|
3564
|
+
recentTurns: this.facetFold.recentTurns ?? 20,
|
|
3565
|
+
budgetTokens: passBudget,
|
|
3566
|
+
tokensBySeq,
|
|
3567
|
+
});
|
|
3568
|
+
const candidate = facetPlan.ranges.find((item) => {
|
|
3569
|
+
for (const seq of activePins) {
|
|
3570
|
+
if (seq >= item.seqStart && seq <= item.seqEnd) return false;
|
|
3571
|
+
}
|
|
3572
|
+
try {
|
|
3573
|
+
if (!toolPairingBalancedBefore(agent.session, item.seqStart)) return false;
|
|
3574
|
+
if (!toolPairingBalancedAfter(agent.session, item.seqEnd)) return false;
|
|
3575
|
+
} catch {
|
|
3576
|
+
return false;
|
|
3577
|
+
}
|
|
3578
|
+
return true;
|
|
3579
|
+
});
|
|
3580
|
+
if (candidate !== undefined) {
|
|
3581
|
+
let count = 0;
|
|
3582
|
+
for (let seq = candidate.seqStart; seq <= candidate.seqEnd; seq += 1) {
|
|
3583
|
+
const event = agent.session.eventAt?.(seq);
|
|
3584
|
+
if (event !== undefined && agent.session.deriveEventMessage?.(event) !== null) count += 1;
|
|
3585
|
+
}
|
|
3586
|
+
if (count > 0) {
|
|
3587
|
+
range = {
|
|
3588
|
+
start: candidate.seqStart,
|
|
3589
|
+
end: candidate.seqEnd,
|
|
3590
|
+
count,
|
|
3591
|
+
tokens: candidate.tokens,
|
|
3592
|
+
};
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3595
|
+
} catch (error) {
|
|
3596
|
+
recordError(error?.message ?? String(error), 'facet-fold');
|
|
3597
|
+
}
|
|
3598
|
+
}
|
|
3599
|
+
if (range === null) range = this.oldestRange(agent, passBudget, activePins);
|
|
3600
|
+
if (range === null) {
|
|
3601
|
+
// No small balanced slice fits. With an active open-turn pin, skipping is
|
|
3602
|
+
// safer than letting the inherited cut fold the active tools.
|
|
3603
|
+
if (activePins.size > 0) return null;
|
|
3604
|
+
const fallback = await super.compactIfNeeded(agent, trigger, signal);
|
|
3605
|
+
await this.recordFolded(agent, fallback);
|
|
3606
|
+
return fallback;
|
|
3607
|
+
}
|
|
3608
|
+
|
|
3609
|
+
const t = await this.tagsForRange(agent, range.start, range.end);
|
|
3610
|
+
const assetRows = await this.materializeRangeAssets(agent, range.start, range.end);
|
|
3611
|
+
const header = this.provenance.header
|
|
3612
|
+
? [
|
|
3613
|
+
gistHeader({
|
|
3614
|
+
sessionId: agent.session.id,
|
|
3615
|
+
startSeq: range.start,
|
|
3616
|
+
endSeq: range.end,
|
|
3617
|
+
count: range.count,
|
|
3618
|
+
t,
|
|
3619
|
+
}),
|
|
3620
|
+
...assetRows.map((row) => assetMarker(row)),
|
|
3621
|
+
].join('\n')
|
|
3622
|
+
: (assetRows.length > 0 ? assetRows.map((row) => assetMarker(row)).join('\n') : null);
|
|
3623
|
+
if (header !== null) this.headers.set(agent, header);
|
|
3624
|
+
|
|
3625
|
+
try {
|
|
3626
|
+
const result = await this.compactRegion(range.start, range.end, agent, signal);
|
|
3627
|
+
await this.recordFolded(agent, result);
|
|
3628
|
+
const span = shadowedSpan(result);
|
|
3629
|
+
// A pass is not "done" until the post-pass surface is measured. Without
|
|
3630
|
+
// this, the panel only had the pre-pass estimate and a real re-bloat
|
|
3631
|
+
// would stay invisible until the next pressure trigger.
|
|
3632
|
+
let after = null;
|
|
3633
|
+
try {
|
|
3634
|
+
after = await this.measure(agent, signal);
|
|
3635
|
+
} catch (error) {
|
|
3636
|
+
this.warnOnce('measure-after-pass', `dsh-continuity: post-pass measurement failed: ${error?.message ?? error}`);
|
|
3637
|
+
}
|
|
3638
|
+
const afterBaseline = after === null ? undefined : Math.max(0, after.estTokens - after.surfaceTokens);
|
|
3639
|
+
const preBaseline = Math.max(0, measured.estTokens - measured.surfaceTokens);
|
|
3640
|
+
const protectEffective = this.protectFor(agent);
|
|
3641
|
+
const calibration = this.protectCalibration.get(agent) ?? null;
|
|
3642
|
+
const protectSource = calibration === null ? 'default' : 'calibration';
|
|
3643
|
+
const protectB0 = calibration?.baselineTokens ?? preBaseline;
|
|
3644
|
+
const protectMargin = calibration?.marginTokens ?? 0.01 * measured.windowTokens;
|
|
3645
|
+
const floorWindow = measured.windowTokens;
|
|
3646
|
+
const floorTokens = preBaseline + protectEffective * floorWindow;
|
|
3647
|
+
const lowTargetTokens = this.continuity.low * floorWindow;
|
|
3648
|
+
const protectBinds = floorTokens >= lowTargetTokens;
|
|
3649
|
+
const lowUnreachable = protectEffective <= 0.1000001 && protectBinds;
|
|
3650
|
+
const protectSuggested = Math.max(0, (lowTargetTokens - preBaseline - 0.01 * floorWindow) / floorWindow);
|
|
3651
|
+
const drift = calibration !== null && Math.abs(protectSuggested - protectEffective) >= 0.02;
|
|
3652
|
+
const driftDelta = protectSuggested - protectEffective;
|
|
3653
|
+
const driftReported = drift && !this.protectDriftWarned.has(agent);
|
|
3654
|
+
if (driftReported) this.protectDriftWarned.add(agent);
|
|
3655
|
+
const lowMinTokens = lowUnreachable || (drift && protectSuggested <= 0.10)
|
|
3656
|
+
? floorTokens + protectMargin
|
|
3657
|
+
: undefined;
|
|
3658
|
+
if (protectBinds || driftReported) {
|
|
3659
|
+
this.warnOnce(
|
|
3660
|
+
`protect-floor:${agent.session.id}`,
|
|
3661
|
+
`dsh-continuity: protect floor above low/pre drift (pre ${measured.estTokens} / floor ${Math.round(floorTokens)} / low ${Math.round(lowTargetTokens)}); `
|
|
3662
|
+
+ `suggest protect <= ${protectSuggested.toFixed(3)}`
|
|
3663
|
+
+ (lowMinTokens !== undefined ? `; low must be >= ${Math.round(lowMinTokens)}` : ''),
|
|
3664
|
+
);
|
|
3665
|
+
}
|
|
3666
|
+
const receipt = {
|
|
3667
|
+
sessionId: String(agent.session.id),
|
|
3668
|
+
start: span?.start,
|
|
3669
|
+
end: span?.end,
|
|
3670
|
+
count: result.shadowedSeqs.length,
|
|
3671
|
+
tokens: result.shadowedTokenCount,
|
|
3672
|
+
compactionId: result.compactionId,
|
|
3673
|
+
header,
|
|
3674
|
+
reason: plan.reason,
|
|
3675
|
+
budget: Math.round(passBudget),
|
|
3676
|
+
estTokens: measured.estTokens,
|
|
3677
|
+
windowTokens: measured.windowTokens,
|
|
3678
|
+
preBaselineTokens: preBaseline,
|
|
3679
|
+
afterTokens: after?.estTokens,
|
|
3680
|
+
afterSurfaceTokens: after?.surfaceTokens,
|
|
3681
|
+
afterBaselineTokens: afterBaseline,
|
|
3682
|
+
protectEffective,
|
|
3683
|
+
protectSource,
|
|
3684
|
+
protectB0,
|
|
3685
|
+
protectMargin,
|
|
3686
|
+
floorTokens,
|
|
3687
|
+
lowTargetTokens,
|
|
3688
|
+
lowUnreachable,
|
|
3689
|
+
lowMinTokens,
|
|
3690
|
+
protectBinds,
|
|
3691
|
+
protectSuggested,
|
|
3692
|
+
driftDelta,
|
|
3693
|
+
driftReported,
|
|
3694
|
+
rebloatTokens: after === null ? undefined : Math.max(0, after.estTokens - measured.estTokens),
|
|
3695
|
+
};
|
|
3696
|
+
recordPass(receipt);
|
|
3697
|
+
appendPassReceipt(agent, receipt);
|
|
3698
|
+
return result;
|
|
3699
|
+
} catch (error) {
|
|
3700
|
+
recordError(error?.message ?? String(error), 'compact');
|
|
3701
|
+
throw error;
|
|
3702
|
+
} finally {
|
|
3703
|
+
this.headers.delete(agent);
|
|
3704
|
+
}
|
|
3705
|
+
}
|
|
3706
|
+
|
|
3707
|
+
/**
|
|
3708
|
+
* Manual `/compact` path. The inherited command still chooses its own range
|
|
3709
|
+
* and produces an unlabeled summary; after it settles, append the gist header
|
|
3710
|
+
* that the normal band path would have attached, and record the pass with a
|
|
3711
|
+
* post-pass measurement. This does not make the manual cut less deep; it
|
|
3712
|
+
* makes the cut visible and retrievable.
|
|
3713
|
+
*/
|
|
3714
|
+
async compactNow(agent, signal, sourceCommandId) {
|
|
3715
|
+
// 手动压缩前清副本。
|
|
3716
|
+
try { this.sweepLoadedCopies(agent); } catch { /* ignore */ }
|
|
3717
|
+
let before = null;
|
|
3718
|
+
try {
|
|
3719
|
+
before = await this.measure(agent, signal);
|
|
3720
|
+
} catch {
|
|
3721
|
+
before = null;
|
|
3722
|
+
}
|
|
3723
|
+
const result = await super.compactNow(agent, signal, sourceCommandId);
|
|
3724
|
+
if (result === null) return null;
|
|
3725
|
+
await this.recordFolded(agent, result);
|
|
3726
|
+
const span = shadowedSpan(result);
|
|
3727
|
+
|
|
3728
|
+
const assetRows = await this.materializeRangeAssets(agent, span.start, span.end);
|
|
3729
|
+
const t = await this.tagsForRange(agent, span.start, span.end);
|
|
3730
|
+
const header = this.provenance.header
|
|
3731
|
+
? [
|
|
3732
|
+
gistHeader({
|
|
3733
|
+
sessionId: agent.session.id,
|
|
3734
|
+
startSeq: span.start,
|
|
3735
|
+
endSeq: span.end,
|
|
3736
|
+
count: result.shadowedSeqs.length,
|
|
3737
|
+
t,
|
|
3738
|
+
}),
|
|
3739
|
+
...assetRows.map((row) => assetMarker(row)),
|
|
3740
|
+
].join('\n')
|
|
3741
|
+
: (assetRows.length > 0 ? assetRows.map((row) => assetMarker(row)).join('\n') : null);
|
|
3742
|
+
if (header !== null) {
|
|
3743
|
+
try {
|
|
3744
|
+
agent.session.append('user/message', contextMessage(
|
|
3745
|
+
`${header}\nThe preceding summary is the gist of this range; use session_log get to retrieve an original.`,
|
|
3746
|
+
), { surfaceOp: 'append' });
|
|
3747
|
+
} catch (error) {
|
|
3748
|
+
recordError(error?.message ?? String(error), 'manual-gist-header');
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
|
|
3752
|
+
let after = null;
|
|
3753
|
+
try {
|
|
3754
|
+
after = await this.measure(agent, signal);
|
|
3755
|
+
} catch (error) {
|
|
3756
|
+
recordError(error?.message ?? String(error), 'measure-after-manual');
|
|
3757
|
+
}
|
|
3758
|
+
const receipt = {
|
|
3759
|
+
sessionId: String(agent.session.id),
|
|
3760
|
+
start: span.start,
|
|
3761
|
+
end: span.end,
|
|
3762
|
+
count: result.shadowedSeqs.length,
|
|
3763
|
+
tokens: result.shadowedTokenCount,
|
|
3764
|
+
compactionId: result.compactionId,
|
|
3765
|
+
header,
|
|
3766
|
+
reason: 'manual',
|
|
3767
|
+
budget: 0,
|
|
3768
|
+
estTokens: before?.estTokens,
|
|
3769
|
+
windowTokens: before?.windowTokens ?? after?.windowTokens,
|
|
3770
|
+
afterTokens: after?.estTokens,
|
|
3771
|
+
afterSurfaceTokens: after?.surfaceTokens,
|
|
3772
|
+
afterBaselineTokens: after === null ? undefined : Math.max(0, after.estTokens - after.surfaceTokens),
|
|
3773
|
+
rebloatTokens: after !== null && before !== null ? Math.max(0, after.estTokens - before.estTokens) : undefined,
|
|
3774
|
+
};
|
|
3775
|
+
recordPass(receipt);
|
|
3776
|
+
appendPassReceipt(agent, receipt);
|
|
3777
|
+
return result;
|
|
3778
|
+
}
|
|
3779
|
+
|
|
3780
|
+
/**
|
|
3781
|
+
* Summarize through the inherited one-shot LLM call, then label the result
|
|
3782
|
+
* as a gist of the selected range. Retries inside one transaction see the
|
|
3783
|
+
* same header because it is cleared only after compactRegion settles.
|
|
3784
|
+
*/
|
|
3785
|
+
async summarize(input, agent, signal) {
|
|
3786
|
+
const threadText = this.threadGist.get(agent);
|
|
3787
|
+
if (typeof threadText === 'string' && threadText.length > 0) {
|
|
3788
|
+
const config = agent?.session?.requestHeader?.()?.config;
|
|
3789
|
+
return {
|
|
3790
|
+
summary: [{ type: 'text', text: threadText }],
|
|
3791
|
+
provider: config?.provider ?? '',
|
|
3792
|
+
model: config?.model ?? '',
|
|
3793
|
+
};
|
|
3794
|
+
}
|
|
3795
|
+
const header = this.headers.get(agent);
|
|
3796
|
+
if (this.mechanicalNote) {
|
|
3797
|
+
const note = renderMechanicalNote({
|
|
3798
|
+
messages: Array.isArray(input?.messages) ? input.messages : [],
|
|
3799
|
+
maxTokens: this.mechanicalNoteTokens,
|
|
3800
|
+
header: header ?? null,
|
|
3801
|
+
});
|
|
3802
|
+
const config = agent?.session?.requestHeader?.()?.config;
|
|
3803
|
+
return {
|
|
3804
|
+
summary: [{ type: 'text', text: note.text }],
|
|
3805
|
+
provider: config?.provider ?? '',
|
|
3806
|
+
model: config?.model ?? '',
|
|
3807
|
+
};
|
|
3808
|
+
}
|
|
3809
|
+
const view = previewSummaryInput(input, {
|
|
3810
|
+
header: header ?? null,
|
|
3811
|
+
maxChars: 1280,
|
|
3812
|
+
headChars: 900,
|
|
3813
|
+
tailChars: 200,
|
|
3814
|
+
});
|
|
3815
|
+
const base = await super.summarize(view, agent, signal);
|
|
3816
|
+
if (header === undefined || !Array.isArray(base?.summary)) return base;
|
|
3817
|
+
return { ...base, summary: annotateSummary(base.summary, header) };
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
/**
|
|
3821
|
+
* Log only the first occurrence of a recurring diagnostic. Warnings are not
|
|
3822
|
+
* errors: lastError is reserved for actual failures.
|
|
3823
|
+
*/
|
|
3824
|
+
warnOnce(key, message) {
|
|
3825
|
+
if (this.warned.has(key)) return;
|
|
3826
|
+
this.warned.add(key);
|
|
3827
|
+
this.ctx.logger?.warn?.(message);
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
export default ContinuityEngine;
|