@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/skills.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill preload: maps the active thread to skill files and builds the block
|
|
3
|
+
* appended to the per-turn continuity message.
|
|
4
|
+
*
|
|
5
|
+
* Defaults: ~/.dsh/continuity/skills (override with DSH_SKILLS_DIR).
|
|
6
|
+
* index.json { skills: [{ name, file, aliases, threads }], byKeyword }
|
|
7
|
+
* <name>.md body with YAML frontmatter
|
|
8
|
+
*
|
|
9
|
+
* Matching is deterministic: a thread matches by thread id or tag alias.
|
|
10
|
+
* No model, no heuristics.
|
|
11
|
+
*/
|
|
12
|
+
import { readFile, stat as statFile } from 'node:fs/promises';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { continuityHome } from './threads-store.mjs';
|
|
15
|
+
|
|
16
|
+
export function skillsDir() {
|
|
17
|
+
const override = process.env.DSH_SKILLS_DIR;
|
|
18
|
+
return override !== undefined && override.length > 0
|
|
19
|
+
? override
|
|
20
|
+
: join(continuityHome(), 'skills');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let cache = { index: null, bodies: new Map() };
|
|
24
|
+
|
|
25
|
+
export async function readSkillsIndex() {
|
|
26
|
+
const file = join(skillsDir(), 'index.json');
|
|
27
|
+
try {
|
|
28
|
+
const raw = await readFile(file, 'utf8');
|
|
29
|
+
const parsed = JSON.parse(raw);
|
|
30
|
+
return {
|
|
31
|
+
skills: Array.isArray(parsed?.skills) ? parsed.skills : [],
|
|
32
|
+
byKeyword: parsed?.byKeyword ?? {},
|
|
33
|
+
};
|
|
34
|
+
} catch {
|
|
35
|
+
return { skills: [], byKeyword: {} };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Skills whose `threads` contain any of the given ids/aliases. */
|
|
40
|
+
export function skillsForThreads(index, ids) {
|
|
41
|
+
const set = new Set(ids.filter((x) => typeof x === 'string' && x.length > 0));
|
|
42
|
+
return index.skills.filter((s) => Array.isArray(s.threads) && s.threads.some((t) => set.has(t)));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Skills matched by keywords that appear in the given text (byKeyword: word -> file). */
|
|
46
|
+
export function skillsForKeywords(index, text) {
|
|
47
|
+
const hay = String(text ?? '');
|
|
48
|
+
if (hay.length === 0) return [];
|
|
49
|
+
const out = [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
for (const [word, file] of Object.entries(index.byKeyword ?? {})) {
|
|
52
|
+
if (!word || typeof file !== 'string') continue;
|
|
53
|
+
if (!hay.includes(word)) continue;
|
|
54
|
+
const entry = index.skills.find((candidate) => candidate.file === file || candidate.name === file);
|
|
55
|
+
if (!entry || seen.has(entry.name)) continue;
|
|
56
|
+
seen.add(entry.name);
|
|
57
|
+
out.push(entry);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function stripFrontmatter(raw) {
|
|
63
|
+
// 去 BOM:PowerShell 写文件可能带 BOM,会让 frontmatter 判不出来。
|
|
64
|
+
const text = String(raw ?? '').replace(/^\uFEFF/, '');
|
|
65
|
+
if (!text.startsWith('---')) return text;
|
|
66
|
+
const end = text.indexOf('\n---', 3);
|
|
67
|
+
if (end < 0) return text;
|
|
68
|
+
return text.slice(end + 4).replace(/^\s*/, '');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Read bodies (cached); truncate by a rough 4-chars-per-token budget. */
|
|
72
|
+
export async function buildSkillsBlock(entries, options = {}) {
|
|
73
|
+
const maxTokens = Number.isInteger(options.maxTokens) ? options.maxTokens : 3000;
|
|
74
|
+
if (entries.length === 0) return '';
|
|
75
|
+
const parts = [];
|
|
76
|
+
let budget = Math.max(200, maxTokens) * 4;
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
const file = join(skillsDir(), entry.file);
|
|
79
|
+
// 缓存带 mtime/size 校验:技能文件改了,下一轮就用新正文,不必重启。
|
|
80
|
+
let stat = null;
|
|
81
|
+
try { stat = await statFile(file); } catch { stat = null; }
|
|
82
|
+
let cached = cache.bodies.get(file);
|
|
83
|
+
if (cached !== undefined && stat !== null && (cached.mtimeMs !== stat.mtimeMs || cached.size !== stat.size)) {
|
|
84
|
+
cached = undefined;
|
|
85
|
+
cache.bodies.delete(file);
|
|
86
|
+
}
|
|
87
|
+
let body = cached?.body;
|
|
88
|
+
if (body === undefined) {
|
|
89
|
+
try {
|
|
90
|
+
body = stripFrontmatter(await readFile(file, 'utf8')).trim();
|
|
91
|
+
} catch {
|
|
92
|
+
body = '';
|
|
93
|
+
}
|
|
94
|
+
cache.bodies.set(file, { body, mtimeMs: stat?.mtimeMs ?? 0, size: stat?.size ?? 0 });
|
|
95
|
+
}
|
|
96
|
+
if (!body) continue;
|
|
97
|
+
if (budget - body.length < 0) {
|
|
98
|
+
parts.push('…(预算不足,余下技能未载入)');
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
parts.push(body);
|
|
102
|
+
budget -= body.length;
|
|
103
|
+
}
|
|
104
|
+
if (parts.length === 0) return '';
|
|
105
|
+
const header = '[技能正文] 本次按线头与关键词自动载入,切换线头或关键词变化后自动换下。';
|
|
106
|
+
return header + '\n\n' + parts.join('\n\n---\n\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function clearSkillsCache() {
|
|
110
|
+
cache = { index: null, bodies: new Map() };
|
|
111
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把“待保存区间”按轮标签切成话题段(纯函数,可单测)。
|
|
3
|
+
*
|
|
4
|
+
* 规则:
|
|
5
|
+
* 1. 每轮一个标签(T0 视为无归属);
|
|
6
|
+
* 2. 相邻同标签合并;T0 段并入前一段(没有前一段就并入后一段);
|
|
7
|
+
* 3. 小于 minTurns 的段并入相邻段(避免切出一堆碎段);
|
|
8
|
+
* 4. 只按 seq 区间切,不跨轮切。
|
|
9
|
+
*
|
|
10
|
+
* @returns {Array<{tag:string, fromSeq:number, toSeq:number, turns:number}>}
|
|
11
|
+
*/
|
|
12
|
+
export function planSplits({ turns = [], tagOf = new Map(), fromSeq = 0, toSeq = Infinity, minTurns = 2 } = {}) {
|
|
13
|
+
const items = [];
|
|
14
|
+
for (const info of Array.isArray(turns) ? turns : []) {
|
|
15
|
+
const seqs = (info?.seqs ?? []).filter((seq) => Number.isInteger(Number(seq)) && Number(seq) >= fromSeq && Number(seq) <= toSeq);
|
|
16
|
+
if (seqs.length === 0) continue;
|
|
17
|
+
const tag = String(tagOf.get(Number(info.turn)) ?? 'T0');
|
|
18
|
+
items.push({ turn: Number(info.turn), tag, from: Math.min(...seqs.map(Number)), to: Math.max(...seqs.map(Number)) });
|
|
19
|
+
}
|
|
20
|
+
items.sort((left, right) => left.from - right.from);
|
|
21
|
+
if (items.length === 0) return [];
|
|
22
|
+
|
|
23
|
+
// 1) 同标签相邻合并(T0 先不合并)
|
|
24
|
+
const runs = [];
|
|
25
|
+
for (const item of items) {
|
|
26
|
+
const last = runs.at(-1);
|
|
27
|
+
if (last !== undefined && last.tag === item.tag && item.tag !== 'T0') { last.to = Math.max(last.to, item.to); last.turns += 1; continue; }
|
|
28
|
+
runs.push({ tag: item.tag, from: item.from, to: item.to, turns: 1 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 2) T0 并入邻段
|
|
32
|
+
const merged = [];
|
|
33
|
+
for (let index = 0; index < runs.length; index += 1) {
|
|
34
|
+
const run = runs[index];
|
|
35
|
+
if (run.tag !== 'T0') { merged.push(run); continue; }
|
|
36
|
+
const prev = merged.at(-1);
|
|
37
|
+
if (prev !== undefined) { prev.to = Math.max(prev.to, run.to); prev.turns += run.turns; continue; }
|
|
38
|
+
const next = runs.slice(index + 1).find((row) => row.tag !== 'T0');
|
|
39
|
+
if (next === undefined) continue;
|
|
40
|
+
next.from = Math.min(next.from, run.from);
|
|
41
|
+
next.turns += run.turns;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 3) 小段并入邻段(优先前)
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const run of merged) {
|
|
47
|
+
const prev = out.at(-1);
|
|
48
|
+
if (prev !== undefined && run.turns < minTurns) { prev.to = Math.max(prev.to, run.to); prev.turns += run.turns; continue; }
|
|
49
|
+
out.push({ ...run });
|
|
50
|
+
}
|
|
51
|
+
if (out.length > 1 && out[0].turns < minTurns) {
|
|
52
|
+
out[1] = { ...out[1], from: Math.min(out[0].from, out[1].from), turns: out[0].turns + out[1].turns };
|
|
53
|
+
out.shift();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 4) 相邻同标签再合并一次
|
|
57
|
+
const final = [];
|
|
58
|
+
for (const run of out) {
|
|
59
|
+
const last = final.at(-1);
|
|
60
|
+
if (last !== undefined && last.tag === run.tag) { last.to = Math.max(last.to, run.to); last.turns += run.turns; continue; }
|
|
61
|
+
final.push(run);
|
|
62
|
+
}
|
|
63
|
+
return final.map((run) => ({ tag: run.tag, fromSeq: run.from, toSeq: run.to, turns: run.turns }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 把区间内的轮按标签切成连续块(不合并、不筛选)。
|
|
68
|
+
* 相邻同标签 = 一块;标签一变就断开。
|
|
69
|
+
*
|
|
70
|
+
* @returns {Array<{tag:string, fromSeq:number, toSeq:number, turns:number}>}
|
|
71
|
+
*/
|
|
72
|
+
export function blocksByTag({ events = [], tagOf = new Map(), fromSeq = 0, toSeq = Infinity } = {}) {
|
|
73
|
+
const byTurn = new Map();
|
|
74
|
+
for (const event of Array.isArray(events) ? events : []) {
|
|
75
|
+
if (event?.type !== 'user/message' && event?.type !== 'assistant/message') continue;
|
|
76
|
+
// user/message 没有 data.turn(结构性事实);调用方可以先用 attributeTurnSeqs 把轮号
|
|
77
|
+
// 填到 event.turn 上。只认 data.turn 会把用户原话排除在折叠边界之外——那正是
|
|
78
|
+
// “想找原话却折不进去”的根源(2026-10-30 实测:块起点落在 assistant 而不是 user)。
|
|
79
|
+
const turn = Number(event?.data?.turn ?? event?.turn);
|
|
80
|
+
const seq = Number(event?.seq);
|
|
81
|
+
if (!Number.isInteger(turn) || !Number.isInteger(seq)) continue;
|
|
82
|
+
if (seq < fromSeq || seq > toSeq) continue;
|
|
83
|
+
const entry = byTurn.get(turn) ?? { turn, seqs: [], tag: String(tagOf.get(turn) ?? 'T0') };
|
|
84
|
+
entry.seqs.push(seq);
|
|
85
|
+
byTurn.set(turn, entry);
|
|
86
|
+
}
|
|
87
|
+
const ordered = [...byTurn.values()].sort((left, right) => Math.min(...left.seqs) - Math.min(...right.seqs));
|
|
88
|
+
const blocks = [];
|
|
89
|
+
for (const entry of ordered) {
|
|
90
|
+
const last = blocks.at(-1);
|
|
91
|
+
if (last !== undefined && last.tag === entry.tag) last.turns.push(entry);
|
|
92
|
+
else blocks.push({ tag: entry.tag, turns: [entry] });
|
|
93
|
+
}
|
|
94
|
+
return blocks.map((block) => ({
|
|
95
|
+
tag: block.tag,
|
|
96
|
+
fromSeq: Math.min(...block.turns.flatMap((entry) => entry.seqs)),
|
|
97
|
+
toSeq: Math.max(...block.turns.flatMap((entry) => entry.seqs)),
|
|
98
|
+
turns: block.turns.length,
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* save 的机械分段:只取「属于 target 的连续轮块」。
|
|
104
|
+
* 相邻同标签的轮合成一块;标签一变就断开——所以不会跨过别的标签去折。
|
|
105
|
+
*
|
|
106
|
+
* 这是 save() 折叠范围的唯一依据:切到 T2 时,T4 的块被折、T2 的块原地留着。
|
|
107
|
+
*
|
|
108
|
+
* @returns {Array<{fromSeq:number, toSeq:number, turns:number}>}
|
|
109
|
+
*/
|
|
110
|
+
export function runsForThread({ events = [], tagOf = new Map(), fromSeq = 0, toSeq = Infinity, target = '' } = {}) {
|
|
111
|
+
if (String(target).length === 0) return [];
|
|
112
|
+
return blocksByTag({ events, tagOf, fromSeq, toSeq })
|
|
113
|
+
.filter((block) => block.tag === String(target))
|
|
114
|
+
.map((block) => ({ fromSeq: block.fromSeq, toSeq: block.toSeq, turns: block.turns }));
|
|
115
|
+
}
|
package/src/state.mjs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-local state shared by the Host panel row and the compaction engine.
|
|
3
|
+
*
|
|
4
|
+
* The panel's HTTP route and the engine are two rows of one installed package,
|
|
5
|
+
* so they resolve the same file URL and therefore the same module instance.
|
|
6
|
+
* This is a diagnostic cache only: the session log remains the source of truth
|
|
7
|
+
* for anything the model saw.
|
|
8
|
+
*/
|
|
9
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const continuityDir = () => join(process.env.USERPROFILE ?? process.env.HOME ?? '.', '.dsh', 'continuity');
|
|
13
|
+
|
|
14
|
+
/** Recompute the merged view from passes.jsonl plus the pre-pass folds sidecar.
|
|
15
|
+
* Each session gets its own sessions/<id>/dropped-ranges.json; sequence spaces
|
|
16
|
+
* from different sessions must never be merged together. */
|
|
17
|
+
export function rebuildDroppedRanges(sessionId = null) {
|
|
18
|
+
try {
|
|
19
|
+
const dir = continuityDir();
|
|
20
|
+
let passes = [];
|
|
21
|
+
try {
|
|
22
|
+
passes = readFileSync(join(dir, 'passes.jsonl'), 'utf8')
|
|
23
|
+
.split(/\r?\n/).filter(Boolean).map((line) => {
|
|
24
|
+
try { return JSON.parse(line.replace(/^\uFEFF/, '')); } catch { return null; }
|
|
25
|
+
}).filter(Boolean);
|
|
26
|
+
} catch { /* no new pass log yet; folds may still cover history */ }
|
|
27
|
+
let foldSessions = {};
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(readFileSync(join(dir, 'folds.json'), 'utf8').replace(/^\uFEFF/, ''));
|
|
30
|
+
foldSessions = parsed?.sessions ?? {};
|
|
31
|
+
} catch { /* old install may have no folds sidecar */ }
|
|
32
|
+
|
|
33
|
+
const passesBySession = new Map();
|
|
34
|
+
for (const row of passes) {
|
|
35
|
+
const id = row?.sessionId;
|
|
36
|
+
if (typeof id !== 'string' || id === '') continue;
|
|
37
|
+
if (!passesBySession.has(id)) passesBySession.set(id, []);
|
|
38
|
+
passesBySession.get(id).push(row);
|
|
39
|
+
}
|
|
40
|
+
const ids = sessionId
|
|
41
|
+
? [String(sessionId)]
|
|
42
|
+
: [...new Set([...passesBySession.keys(), ...Object.keys(foldSessions)])];
|
|
43
|
+
for (const id of ids) {
|
|
44
|
+
const sessionPasses = passesBySession.get(id) ?? [];
|
|
45
|
+
const sessionFolds = Array.isArray(foldSessions?.[id]?.folds) ? foldSessions[id].folds : [];
|
|
46
|
+
const entries = [
|
|
47
|
+
...sessionPasses.map((row) => ({ from: row.start, to: row.end, at: row.at, source: 'pass' })),
|
|
48
|
+
...sessionFolds.map((row) => ({ from: row.from, to: row.to, at: row.at, source: 'fold' })),
|
|
49
|
+
];
|
|
50
|
+
const ranges = entries
|
|
51
|
+
.map((row) => [Number(row.from), Number(row.to)])
|
|
52
|
+
.filter(([start, end]) => Number.isFinite(start) && Number.isFinite(end) && start <= end)
|
|
53
|
+
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
54
|
+
const merged = [];
|
|
55
|
+
for (const [start, end] of ranges) {
|
|
56
|
+
const last = merged.at(-1);
|
|
57
|
+
if (last && start <= last[1] + 1) last[1] = Math.max(last[1], end);
|
|
58
|
+
else merged.push([start, end]);
|
|
59
|
+
}
|
|
60
|
+
const totalSeqs = merged.reduce((sum, [start, end]) => sum + (end - start + 1), 0);
|
|
61
|
+
const times = entries.map((row) => row.at).filter((at) => typeof at === 'string' && at).sort();
|
|
62
|
+
const sessionDir = join(dir, 'sessions', id);
|
|
63
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
64
|
+
writeFileSync(join(sessionDir, 'dropped-ranges.json'), `${JSON.stringify({
|
|
65
|
+
updatedAt: new Date().toISOString(),
|
|
66
|
+
sessionId: id,
|
|
67
|
+
ranges: merged,
|
|
68
|
+
totalSeqs,
|
|
69
|
+
passes: sessionPasses.length,
|
|
70
|
+
folds: sessionFolds.length,
|
|
71
|
+
coverageFrom: times[0] ?? null,
|
|
72
|
+
coverageNote: 'passes.jsonl is complete after 4.9.80; earlier ranges come from folds.json with no token counts',
|
|
73
|
+
}, null, 2)}\n`, 'utf8');
|
|
74
|
+
}
|
|
75
|
+
} catch { /* diagnostics must never block compaction */ }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Append-only pass receipt for independent post-hoc verification. */
|
|
79
|
+
export function appendPassLog(pass) {
|
|
80
|
+
try {
|
|
81
|
+
const home = process.env.USERPROFILE ?? process.env.HOME ?? '.';
|
|
82
|
+
const dir = join(home, '.dsh', 'continuity');
|
|
83
|
+
mkdirSync(dir, { recursive: true });
|
|
84
|
+
const at = Number.isFinite(pass?.at) ? pass.at : Date.now();
|
|
85
|
+
appendFileSync(join(dir, 'passes.jsonl'), `${JSON.stringify({
|
|
86
|
+
...pass,
|
|
87
|
+
at: new Date(at).toISOString(),
|
|
88
|
+
atMs: at,
|
|
89
|
+
})}\n`, 'utf8');
|
|
90
|
+
rebuildDroppedRanges(pass?.sessionId ?? null);
|
|
91
|
+
} catch { /* diagnostics must never block compaction */ }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @typedef {{ at: number, mountedAt: number, policy: object, provenance: object, compactionBasic: object, base: object }} EngineState */
|
|
95
|
+
/** @typedef {{ at: number, sessionId?: string, start?: number, end?: number, count?: number, tokens?: number, budget?: number, reason?: string, estTokens?: number, windowTokens?: number, afterTokens?: number, afterSurfaceTokens?: number, afterBaselineTokens?: number, rebloatTokens?: number }} PassRecord */
|
|
96
|
+
/** @typedef {{ at: number, sessionId: string, estTokens: number, windowTokens: number, surfaceTokens: number, baselineTokens: number }} ContextRecord */
|
|
97
|
+
/** @typedef {{ at: number, sessionId?: string, count: number, tokens: number, chars: number, reason: string }} PreviewRecord */
|
|
98
|
+
|
|
99
|
+
export const continuityState = {
|
|
100
|
+
/** @type {EngineState | null} */
|
|
101
|
+
engine: null,
|
|
102
|
+
/** The session id the mounted engine has actually served. @type {string|null} */
|
|
103
|
+
engineSession: null,
|
|
104
|
+
/** @type {PassRecord[]} newest first */
|
|
105
|
+
passes: [],
|
|
106
|
+
/** @type {ContextRecord | null} */
|
|
107
|
+
context: null,
|
|
108
|
+
/** @type {PreviewRecord[]} newest first */
|
|
109
|
+
previews: [],
|
|
110
|
+
/** @type {{ at: number, message: string, key?: string } | null} */
|
|
111
|
+
lastError: null,
|
|
112
|
+
/** @type {{ at: number, name: string, scope: string } | null} */
|
|
113
|
+
sessionLogTool: null,
|
|
114
|
+
/** @type {{ at: number, name: string, scope: string } | null} */
|
|
115
|
+
threadsTool: null,
|
|
116
|
+
/** @type {{ at: number, active: string|null, count: number, rules: boolean } | null} */
|
|
117
|
+
threads: null,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Record that the preset-scoped `session_log` tool registered. */
|
|
121
|
+
export function markSessionLogTool({ name, scope }) {
|
|
122
|
+
continuityState.sessionLogTool = { at: Date.now(), name, scope };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Record that the preset-scoped `continuity` tool registered. */
|
|
126
|
+
export function markThreadsTool({ name, scope }) {
|
|
127
|
+
continuityState.threadsTool = { at: Date.now(), name, scope };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Remember which session the mounted Continuity engine is serving. */
|
|
131
|
+
export function markEngineSession(sessionId) {
|
|
132
|
+
const id = typeof sessionId === 'string' ? sessionId : '';
|
|
133
|
+
if (id.length > 0) continuityState.engineSession = id;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Record the thread injection snapshot for the panel (read-only diagnostic). */
|
|
137
|
+
export function markThreadsSnapshot({ activeId, count, rules }) {
|
|
138
|
+
continuityState.threads = {
|
|
139
|
+
at: Date.now(),
|
|
140
|
+
active: activeId ?? null,
|
|
141
|
+
count: Number.isFinite(count) ? count : 0,
|
|
142
|
+
rules: Boolean(rules),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Record that the engine row mounted, with the policy it resolved. */
|
|
147
|
+
export function markEngine({ policy, provenance, compactionBasic, base, instance }) {
|
|
148
|
+
continuityState.engine = {
|
|
149
|
+
at: Date.now(),
|
|
150
|
+
mountedAt: Date.now(),
|
|
151
|
+
policy,
|
|
152
|
+
provenance,
|
|
153
|
+
compactionBasic,
|
|
154
|
+
base: base ?? {},
|
|
155
|
+
};
|
|
156
|
+
// The panel metadata above is not callable; keep the real engine separately
|
|
157
|
+
// so index.apply can route agent/turn-stopping to onTurnStopping.
|
|
158
|
+
continuityState.engineInstance = instance ?? null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Remember one completed compaction pass for the panel. */
|
|
162
|
+
export function recordPass(pass) {
|
|
163
|
+
const record = { at: Date.now(), ...pass };
|
|
164
|
+
continuityState.passes.unshift(record);
|
|
165
|
+
if (continuityState.passes.length > 24) continuityState.passes.length = 24;
|
|
166
|
+
appendPassLog(record);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Remember the latest measured context shape (used by stats tail output). */
|
|
170
|
+
export function recordContext(context) {
|
|
171
|
+
continuityState.context = { at: Date.now(), ...context };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Remember one landed batch of old-tool-result previews. */
|
|
175
|
+
export function recordPreview(preview) {
|
|
176
|
+
continuityState.previews.unshift({ at: Date.now(), ...preview });
|
|
177
|
+
if (continuityState.previews.length > 24) continuityState.previews.length = 24;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Remember the last non-fatal measurement or compaction failure. */
|
|
181
|
+
export function recordError(message, key) {
|
|
182
|
+
continuityState.lastError = { at: Date.now(), message: String(message), ...(key === undefined ? {} : { key }) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function resetState() {
|
|
186
|
+
continuityState.engine = null;
|
|
187
|
+
continuityState.engineInstance = null;
|
|
188
|
+
continuityState.engineSession = null;
|
|
189
|
+
continuityState.passes = [];
|
|
190
|
+
continuityState.context = null;
|
|
191
|
+
continuityState.previews = [];
|
|
192
|
+
continuityState.lastError = null;
|
|
193
|
+
continuityState.sessionLogTool = null;
|
|
194
|
+
continuityState.threadsTool = null;
|
|
195
|
+
continuityState.threads = null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export default continuityState;
|
package/src/t0.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T0 闸的纯逻辑:算“即将折叠的区间里还有多少条未决”。
|
|
3
|
+
*
|
|
4
|
+
* 口径(与 T0-POLICY.md 一致):
|
|
5
|
+
* - 只看**可见文本**:用户全文 + 助手非 reasoning 部分(reasoning 是草稿,不算记忆);
|
|
6
|
+
* - 注入噪音([线]/[候选]/runtime…)不计;
|
|
7
|
+
* - 有标签 = 已定;`t0-decisions.jsonl` 里 `as:"t0"` = 显式判定的 T0;其余 = 未决。
|
|
8
|
+
*/
|
|
9
|
+
import { tagsAtSeq } from './tags.mjs';
|
|
10
|
+
|
|
11
|
+
export const NOISE_RE = /^(\[线\]|\[标签\]|\[习惯|\[技能|\[归位|\[回查|\[痕迹|\[接手|\[候选|\[新标签|\[归拢|\[学习|\[分叉|\[存\?|\[回正|\[放|\[暂停\]|\[打标|\[话题\?|\[基础规则|\[存活\]|<system-reminder>|<workspace|This is an automatically generated checkpoint|Current runtime context|Time sampled)/;
|
|
12
|
+
|
|
13
|
+
/** 可见文本:用户全文;助手只取非 reasoning 部分。 */
|
|
14
|
+
export function visibleText(event) {
|
|
15
|
+
const msg = event?.data?.message;
|
|
16
|
+
const parts = msg?.content;
|
|
17
|
+
if (Array.isArray(parts)) {
|
|
18
|
+
return parts
|
|
19
|
+
.filter((part) => part?.type !== 'reasoning')
|
|
20
|
+
.map((part) => part?.text ?? '')
|
|
21
|
+
.join('\n')
|
|
22
|
+
.trim();
|
|
23
|
+
}
|
|
24
|
+
const direct = event?.data?.content;
|
|
25
|
+
if (typeof direct === 'string') return direct.trim();
|
|
26
|
+
if (Array.isArray(direct)) {
|
|
27
|
+
return direct
|
|
28
|
+
.filter((part) => part?.type !== 'reasoning')
|
|
29
|
+
.map((part) => part?.text ?? '')
|
|
30
|
+
.join('\n')
|
|
31
|
+
.trim();
|
|
32
|
+
}
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @returns {{ content:number, tagged:number, t0Decided:number, undecided:Array<{seq:number,type:string,head:string}> }}
|
|
38
|
+
*/
|
|
39
|
+
export function contentUndecided({ events = [], tagsDoc = null, decisions = [], turnTagRows = [], fromSeq = 0, toSeq = Infinity } = {}) {
|
|
40
|
+
// 轮标签:调用方一直传 turnTagRows,但函数签名没接——于是在这里抛 ReferenceError,
|
|
41
|
+
// 又被 save 的 catch 吞掉,四道闸集体静默失效(2026-10-29 反向验收抓到:97 轮无标签也存了)。
|
|
42
|
+
const taggedTurns = new Set();
|
|
43
|
+
for (const row of Array.isArray(turnTagRows) ? turnTagRows : []) {
|
|
44
|
+
const turn = Number(row?.turn);
|
|
45
|
+
if (Number.isInteger(turn)) taggedTurns.add(turn);
|
|
46
|
+
}
|
|
47
|
+
const decided = new Set();
|
|
48
|
+
const explicitT0 = new Set();
|
|
49
|
+
for (const row of Array.isArray(decisions) ? decisions : []) {
|
|
50
|
+
const seqs = Array.isArray(row?.seqs) ? row.seqs : [row?.seq];
|
|
51
|
+
for (const raw of seqs) {
|
|
52
|
+
const seq = Number(raw);
|
|
53
|
+
if (!Number.isInteger(seq)) continue;
|
|
54
|
+
decided.add(seq);
|
|
55
|
+
if (row?.as === 't0') explicitT0.add(seq);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let content = 0;
|
|
60
|
+
let tagged = 0;
|
|
61
|
+
let t0Decided = 0;
|
|
62
|
+
let noTurn = 0;
|
|
63
|
+
const undecided = [];
|
|
64
|
+
for (const event of Array.isArray(events) ? events : []) {
|
|
65
|
+
if (event?.type !== 'user/message' && event?.type !== 'assistant/message') continue;
|
|
66
|
+
const seq = Number(event?.seq);
|
|
67
|
+
if (!Number.isInteger(seq) || seq < fromSeq || seq > toSeq) continue;
|
|
68
|
+
const text = visibleText(event);
|
|
69
|
+
if (text.length === 0) continue;
|
|
70
|
+
if (NOISE_RE.test(text)) continue;
|
|
71
|
+
const source = event?.data?.message?.source ?? event?.data?.source;
|
|
72
|
+
if (source?.peer === true) continue;
|
|
73
|
+
if (typeof source?.kind === 'string' && source.kind !== 'user') continue;
|
|
74
|
+
// Host 可能把 source 的额外字段洗掉,只留 kind:"user";投递前缀不参与内容计数。
|
|
75
|
+
if (/^\[(?:创|闸|peer|gate)(?:\]|·)/.test(text)) continue;
|
|
76
|
+
content += 1;
|
|
77
|
+
const tags = tagsDoc === null ? [] : [...tagsAtSeq(tagsDoc, seq)].filter((tag) => tag !== 'T0');
|
|
78
|
+
if (tags.length > 0) { tagged += 1; continue; }
|
|
79
|
+
if (explicitT0.has(seq)) { t0Decided += 1; continue; }
|
|
80
|
+
if (decided.has(seq)) { tagged += 1; continue; } // 已判给某标签(memberships 可能尚未写入)
|
|
81
|
+
const turnNumber = Number(event?.data?.turn);
|
|
82
|
+
if (Number.isInteger(turnNumber) && taggedTurns.has(turnNumber)) { tagged += 1; continue; } // 轮标签已判
|
|
83
|
+
// 没有轮号 ≠ 不用判:归属靠 run 区间([from,to,tagId]),run 不需要轮号。
|
|
84
|
+
// 之前这里写「无轮号 → 跳过」,等于把 37 条真实用户消息当成不用管(2026-10-30 用户纠正)。
|
|
85
|
+
if (!Number.isInteger(turnNumber)) noTurn += 1;
|
|
86
|
+
undecided.push({ seq, type: event.type, head: text.replace(/\s+/g, ' ').slice(0, 60) });
|
|
87
|
+
}
|
|
88
|
+
return { content, tagged, t0Decided, noTurn, undecided };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 这次折叠覆盖到、但还没有轮标签的轮。
|
|
94
|
+
* @returns {Array<{turn:number, seq:number}>}
|
|
95
|
+
*/
|
|
96
|
+
export function untaggedTurnList({ events = [], turnTagRows = [], fromSeq = 0, toSeq = Infinity } = {}) {
|
|
97
|
+
const tagged = new Set();
|
|
98
|
+
for (const row of Array.isArray(turnTagRows) ? turnTagRows : []) {
|
|
99
|
+
const turn = Number(row?.turn);
|
|
100
|
+
if (Number.isInteger(turn)) tagged.add(turn);
|
|
101
|
+
}
|
|
102
|
+
const out = new Map();
|
|
103
|
+
for (const event of Array.isArray(events) ? events : []) {
|
|
104
|
+
if (event?.type !== 'user/message' && event?.type !== 'assistant/message') continue;
|
|
105
|
+
const turn = Number(event?.data?.turn);
|
|
106
|
+
const seq = Number(event?.seq);
|
|
107
|
+
if (!Number.isInteger(turn) || !Number.isInteger(seq)) continue;
|
|
108
|
+
if (seq < fromSeq || seq > toSeq) continue;
|
|
109
|
+
if (tagged.has(turn)) continue;
|
|
110
|
+
if (!out.has(turn)) out.set(turn, { turn, seq });
|
|
111
|
+
}
|
|
112
|
+
return [...out.values()];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 全局轮标签缺口:已结束的轮里,有多少轮还没有标签。
|
|
118
|
+
* 这是“机制是否漏打”的判据——缺口大就不是没轮到,是漏了。
|
|
119
|
+
*
|
|
120
|
+
* @returns {{settled:number, tagged:number, missing:Array<{turn:number, hasUserText:boolean}>}}
|
|
121
|
+
*/
|
|
122
|
+
export function turnTagGap({ events = [], turnTagRows = [], boundaryTurn = Infinity } = {}) {
|
|
123
|
+
const tagged = new Set();
|
|
124
|
+
for (const row of Array.isArray(turnTagRows) ? turnTagRows : []) {
|
|
125
|
+
const turn = Number(row?.turn);
|
|
126
|
+
if (Number.isInteger(turn)) tagged.add(turn);
|
|
127
|
+
}
|
|
128
|
+
const withMessages = new Map();
|
|
129
|
+
let maxTurn = 0;
|
|
130
|
+
for (const event of Array.isArray(events) ? events : []) {
|
|
131
|
+
if (event?.type !== 'user/message' && event?.type !== 'assistant/message') continue;
|
|
132
|
+
const turn = Number(event?.data?.turn);
|
|
133
|
+
if (!Number.isInteger(turn)) continue;
|
|
134
|
+
if (turn > maxTurn) maxTurn = turn;
|
|
135
|
+
const entry = withMessages.get(turn) ?? { turn, hasUserText: false };
|
|
136
|
+
if (event.type === 'user/message') entry.hasUserText = true;
|
|
137
|
+
withMessages.set(turn, entry);
|
|
138
|
+
}
|
|
139
|
+
const settledBoundary = Math.min(Number.isFinite(boundaryTurn) ? boundaryTurn : Infinity, maxTurn - 1);
|
|
140
|
+
const missing = [];
|
|
141
|
+
let settled = 0;
|
|
142
|
+
for (const entry of withMessages.values()) {
|
|
143
|
+
if (entry.turn > settledBoundary) continue;
|
|
144
|
+
settled += 1;
|
|
145
|
+
if (!tagged.has(entry.turn)) missing.push({ turn: entry.turn, hasUserText: entry.hasUserText });
|
|
146
|
+
}
|
|
147
|
+
missing.sort((left, right) => left.turn - right.turn);
|
|
148
|
+
return { settled, tagged: settled - missing.length, missing };
|
|
149
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* New-tag policy for the strict `TAG: NEW:<word>` interface.
|
|
3
|
+
*
|
|
4
|
+
* An unknown word without NEW: is not a tag request; it is a typo or an
|
|
5
|
+
* attempt to let the model confirm itself. The gate must reject it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Split only on em/en dash; ASCII hyphen is part of F-35 / COVID-19. */
|
|
9
|
+
export function tagTokenFromDeclaration(declaration) {
|
|
10
|
+
return String(declaration ?? '')
|
|
11
|
+
.replace(/^TAG\s*[::]\s*/i, '')
|
|
12
|
+
.split(/[—–]/)[0]
|
|
13
|
+
.trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Return the explicit NEW: word (quotes stripped), or null. */
|
|
17
|
+
export function splitNewToken(token) {
|
|
18
|
+
const key = String(token ?? '').trim();
|
|
19
|
+
const hit = /^NEW\s*[::]\s*(.+)$/i.exec(key);
|
|
20
|
+
return hit === null ? null : tagWordFromToken(hit[1]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Strip wrapping quotes so evidence/near-label use the word itself. */
|
|
24
|
+
export function tagWordFromToken(token) {
|
|
25
|
+
const key = String(token ?? '').trim();
|
|
26
|
+
const wrap = /^["“「'‘](.*)["”」'’]$/.exec(key);
|
|
27
|
+
return wrap !== null ? wrap[1].trim() : key;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isTagLine(line) {
|
|
31
|
+
return /^\s*TAG\s*[::]/i.test(line);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The word must appear in the user's message or the assistant body, not only
|
|
36
|
+
* in the TAG line or in the gate question itself.
|
|
37
|
+
*/
|
|
38
|
+
export function hasWordEvidence({ userText = '', assistantText = '', word = '' } = {}) {
|
|
39
|
+
const needle = String(word ?? '').trim().toLowerCase();
|
|
40
|
+
if (needle.length === 0) return false;
|
|
41
|
+
const visible = (text) => String(text ?? '')
|
|
42
|
+
.split(/\r?\n/)
|
|
43
|
+
.filter((line) => !isTagLine(line) && !/^\s*\[(?:闸|gate|peer)/.test(line))
|
|
44
|
+
.join('\n')
|
|
45
|
+
.toLowerCase();
|
|
46
|
+
return visible(userText).includes(needle) || visible(assistantText).includes(needle);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Levenshtein distance, used only for near-duplicate label names. */
|
|
50
|
+
export function editDistance(a, b) {
|
|
51
|
+
const left = String(a ?? '');
|
|
52
|
+
const right = String(b ?? '');
|
|
53
|
+
if (left === right) return 0;
|
|
54
|
+
if (left.length === 0) return right.length;
|
|
55
|
+
if (right.length === 0) return left.length;
|
|
56
|
+
let prev = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
57
|
+
for (let i = 1; i <= left.length; i += 1) {
|
|
58
|
+
const next = [i];
|
|
59
|
+
for (let j = 1; j <= right.length; j += 1) {
|
|
60
|
+
const cost = left[i - 1] === right[j - 1] ? 0 : 1;
|
|
61
|
+
next[j] = Math.min(prev[j] + 1, next[j - 1] + 1, prev[j - 1] + cost);
|
|
62
|
+
}
|
|
63
|
+
prev = next;
|
|
64
|
+
}
|
|
65
|
+
return prev[right.length];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Return the first existing label within edit distance 1, or null. */
|
|
69
|
+
export function findNearLabel(labels, word) {
|
|
70
|
+
const target = String(word ?? '').trim();
|
|
71
|
+
if (target.length === 0) return null;
|
|
72
|
+
for (const item of Array.isArray(labels) ? labels : []) {
|
|
73
|
+
const label = String(item?.label ?? '').trim();
|
|
74
|
+
if (label.length === 0 || label === target) continue;
|
|
75
|
+
if (editDistance(target, label) <= 1) return item;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|