@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.
Files changed (258) hide show
  1. package/INSTALL.md +25 -0
  2. package/PANEL.md +92 -0
  3. package/README.md +286 -2
  4. package/RELEASE-NOTES.md +732 -0
  5. package/bin/bc-acceptance-full.mjs +91 -0
  6. package/bin/bc-acceptance-tight.mjs +66 -0
  7. package/bin/bc-acceptance.mjs +22 -0
  8. package/bin/bc-add-keywords.mjs +21 -0
  9. package/bin/bc-demo.mjs +47 -0
  10. package/bin/bc-expand-hint.mjs +179 -0
  11. package/bin/bc-ingest-index.mjs +35 -0
  12. package/bin/bc-ingest.mjs +31 -0
  13. package/bin/bc-label-demo.mjs +18 -0
  14. package/bin/bc-relabel.mjs +150 -0
  15. package/bin/bc-sessionize.mjs +59 -0
  16. package/bin/coref_pipeline.py +47 -0
  17. package/bin/coref_service.py +57 -0
  18. package/bin/debug_ckip.py +16 -0
  19. package/bin/debug_ner_zh.py +18 -0
  20. package/bin/doctor.mjs +275 -0
  21. package/bin/en_ner_error_audit.py +33 -0
  22. package/bin/eval-label-rules.mjs +342 -0
  23. package/bin/export-turns.py +68 -0
  24. package/bin/extract-user-paragraph.mjs +14 -0
  25. package/bin/extraction-composite-smoke.mjs +28 -0
  26. package/bin/extraction-latency.mjs +58 -0
  27. package/bin/extraction-user-smoke.mjs +28 -0
  28. package/bin/extraction_service.py +164 -0
  29. package/bin/facet-active-experiment.mjs +110 -0
  30. package/bin/facet-covid.mjs +8 -0
  31. package/bin/facet-curve.mjs +27 -0
  32. package/bin/facet-embedding-train.mjs +20 -0
  33. package/bin/facet-embedding-validate.mjs +83 -0
  34. package/bin/facet-eval-roleplay.mjs +42 -0
  35. package/bin/facet-eval.mjs +38 -0
  36. package/bin/facet-fold.mjs +31 -0
  37. package/bin/facet-load.mjs +16 -0
  38. package/bin/facet-mlp-scan.mjs +45 -0
  39. package/bin/facet-mvp.mjs +34 -0
  40. package/bin/facet-online-experiments.mjs +78 -0
  41. package/bin/facet-online-race.mjs +99 -0
  42. package/bin/facet-online-validate.mjs +51 -0
  43. package/bin/facet-panel.mjs +22 -0
  44. package/bin/facet-passage.mjs +41 -0
  45. package/bin/facet-random-validate.mjs +48 -0
  46. package/bin/facet-roleplay.mjs +33 -0
  47. package/bin/facet-scan.mjs +56 -0
  48. package/bin/facet-segment.mjs +34 -0
  49. package/bin/facet-train.mjs +19 -0
  50. package/bin/facet-validate.mjs +103 -0
  51. package/bin/facet-zeroshot.mjs +14 -0
  52. package/bin/install-templates.mjs +72 -0
  53. package/bin/memory-bench.mjs +53 -0
  54. package/bin/memory-build-summary.mjs +43 -0
  55. package/bin/memory-build-vector.mjs +31 -0
  56. package/bin/memory-build.mjs +41 -0
  57. package/bin/memory-calibration.mjs +70 -0
  58. package/bin/memory-closed-loop.mjs +119 -0
  59. package/bin/memory-dry-run.mjs +70 -0
  60. package/bin/memory-eval.mjs +104 -0
  61. package/bin/memory-event-alias-test.mjs +89 -0
  62. package/bin/memory-extraction-bench.mjs +58 -0
  63. package/bin/memory-load-gate-replay.mjs +137 -0
  64. package/bin/memory-loop-test.mjs +90 -0
  65. package/bin/memory-pre-turn-harness.mjs +63 -0
  66. package/bin/memory-query.mjs +39 -0
  67. package/bin/memory-replay-hints.mjs +84 -0
  68. package/bin/memory-scale.mjs +58 -0
  69. package/bin/memory-scenario-test.mjs +146 -0
  70. package/bin/memory-user-loop.mjs +95 -0
  71. package/bin/ner_bench_tiny_zh.py +59 -0
  72. package/bin/ner_bench_zh.py +60 -0
  73. package/bin/ner_conll_en.py +139 -0
  74. package/bin/ner_direct_en.py +123 -0
  75. package/bin/ner_direct_zh.py +100 -0
  76. package/bin/ner_pipeline.py +208 -0
  77. package/bin/noise-selfcheck.mjs +50 -0
  78. package/bin/pack-integrity.mjs +47 -0
  79. package/bin/panel-check.mjs +57 -0
  80. package/bin/plugin-integrity.mjs +74 -0
  81. package/bin/preview-live.mjs +13 -0
  82. package/bin/public-install-check.mjs +222 -0
  83. package/bin/relabel-apply.mjs +272 -0
  84. package/bin/relabel-dry-run.mjs +381 -0
  85. package/bin/relik_pipeline.py +39 -0
  86. package/bin/relik_service.py +52 -0
  87. package/bin/srl_pipeline.py +55 -0
  88. package/bin/srl_service.py +68 -0
  89. package/bin/summary-index.mjs +151 -0
  90. package/bin/summary-query.mjs +79 -0
  91. package/bin/vec-check.mjs +27 -0
  92. package/bin/zh_ner_error_audit.py +36 -0
  93. package/bin/zh_ner_slot_eval.py +38 -0
  94. package/client.js +760 -0
  95. package/cordis.patch.yml +378 -0
  96. package/docs/API-NOTES.md +1743 -0
  97. package/docs/ARCHITECTURE.md +355 -0
  98. package/docs/CACHE.md +253 -0
  99. package/docs/CONDUCT.md +30 -0
  100. package/docs/CREATOR-HANDOFF.md +102 -0
  101. package/docs/EXPERIMENT.md +136 -0
  102. package/docs/FACET-MVP-REPORT.md +473 -0
  103. package/docs/GUIDE-0.4.66.md +195 -0
  104. package/docs/GUIDE.md +127 -0
  105. package/docs/MECHANISM.md +243 -0
  106. package/docs/NEW-INSTANCE-HANDOFF.md +219 -0
  107. package/docs/README.md +37 -0
  108. package/docs/SESSION-LAYOUT.md +151 -0
  109. package/docs/history-map.md +249 -0
  110. package/docs/relabel-migration-wiring.md +366 -0
  111. package/guides/00-READ-FIRST.md +206 -0
  112. package/guides/01-INSTALL.md +207 -0
  113. package/guides/02-TABLES.md +225 -0
  114. package/guides/03-AUDIT.md +201 -0
  115. package/guides/04-REBUILD.md +306 -0
  116. package/guides/05-FIRST-RUN.md +191 -0
  117. package/guides/06-EMBEDDING.md +146 -0
  118. package/guides/07-PTC-AND-ASSETS.md +221 -0
  119. package/icon.svg +7 -0
  120. package/locale/en.json +6 -0
  121. package/locale/zh.json +6 -0
  122. package/models/README.md +13 -0
  123. package/models/Xenova/bge-small-zh-v1.5/config.json +31 -0
  124. package/models/Xenova/bge-small-zh-v1.5/onnx/model_quantized.onnx +0 -0
  125. package/models/Xenova/bge-small-zh-v1.5/tokenizer.json +21278 -0
  126. package/models/Xenova/bge-small-zh-v1.5/tokenizer_config.json +15 -0
  127. package/package.json +139 -4
  128. package/src/asset-tool.mjs +237 -0
  129. package/src/assets.mjs +244 -0
  130. package/src/backfill.mjs +74 -0
  131. package/src/backup.mjs +80 -0
  132. package/src/deadend.mjs +95 -0
  133. package/src/deleted-store.mjs +35 -0
  134. package/src/engine.mjs +3831 -0
  135. package/src/facets/classifier.mjs +146 -0
  136. package/src/facets/embedding-service.mjs +53 -0
  137. package/src/facets/embedding-worker.mjs +27 -0
  138. package/src/facets/embedding.mjs +163 -0
  139. package/src/facets/extract.mjs +80 -0
  140. package/src/facets/fold-plan.mjs +84 -0
  141. package/src/facets/hybrid.mjs +29 -0
  142. package/src/facets/index.mjs +113 -0
  143. package/src/facets/linear-probe.mjs +43 -0
  144. package/src/facets/load.mjs +37 -0
  145. package/src/facets/mlp-probe.mjs +54 -0
  146. package/src/facets/mlp-service.mjs +53 -0
  147. package/src/facets/mlp-worker.mjs +26 -0
  148. package/src/facets/online-knn.mjs +58 -0
  149. package/src/facets/online-models.mjs +227 -0
  150. package/src/facets/panel-state.mjs +23 -0
  151. package/src/facets/panel.mjs +73 -0
  152. package/src/facets/random.mjs +26 -0
  153. package/src/facets/scanner.mjs +35 -0
  154. package/src/facets/schema.mjs +75 -0
  155. package/src/facets/seed-examples.mjs +58 -0
  156. package/src/facets/sensitivity.mjs +98 -0
  157. package/src/facets/service.mjs +53 -0
  158. package/src/facets/store.mjs +39 -0
  159. package/src/facets/worker.mjs +23 -0
  160. package/src/folds-store.mjs +109 -0
  161. package/src/groups.mjs +167 -0
  162. package/src/handoff-tool.mjs +51 -0
  163. package/src/handoff.mjs +127 -0
  164. package/src/index.mjs +649 -0
  165. package/src/inject-policy.mjs +17 -0
  166. package/src/learning.mjs +88 -0
  167. package/src/memory/alias-proposal.mjs +40 -0
  168. package/src/memory/bc-audit.mjs +245 -0
  169. package/src/memory/bc-index-updater.mjs +404 -0
  170. package/src/memory/bc-index.mjs +48 -0
  171. package/src/memory/bc-label.mjs +220 -0
  172. package/src/memory/bc-panel-state.mjs +107 -0
  173. package/src/memory/bc-pre-step.mjs +463 -0
  174. package/src/memory/bc-recall.mjs +470 -0
  175. package/src/memory/bc-shift.mjs +123 -0
  176. package/src/memory/bc-store.mjs +41 -0
  177. package/src/memory/bm25.mjs +112 -0
  178. package/src/memory/calibration.mjs +54 -0
  179. package/src/memory/chatter.mjs +29 -0
  180. package/src/memory/cmap-file.mjs +107 -0
  181. package/src/memory/concepts-file.mjs +41 -0
  182. package/src/memory/coref-slots.mjs +40 -0
  183. package/src/memory/event-alias-ledger.mjs +87 -0
  184. package/src/memory/event-log.mjs +95 -0
  185. package/src/memory/event-model.mjs +132 -0
  186. package/src/memory/event-segmenter.mjs +36 -0
  187. package/src/memory/event-summary-tree.mjs +122 -0
  188. package/src/memory/extraction-adapter.mjs +76 -0
  189. package/src/memory/extraction-composite.mjs +75 -0
  190. package/src/memory/extraction-core.mjs +76 -0
  191. package/src/memory/extraction-pre-step.mjs +161 -0
  192. package/src/memory/extraction-provider.mjs +300 -0
  193. package/src/memory/extraction-score.mjs +36 -0
  194. package/src/memory/facet-stabilizer.mjs +59 -0
  195. package/src/memory/history-map.mjs +139 -0
  196. package/src/memory/language-router.mjs +30 -0
  197. package/src/memory/load-gate.mjs +161 -0
  198. package/src/memory/mechanical-note.mjs +104 -0
  199. package/src/memory/onboarding.mjs +163 -0
  200. package/src/memory/persona.mjs +78 -0
  201. package/src/memory/planner.mjs +249 -0
  202. package/src/memory/pre-turn.mjs +80 -0
  203. package/src/memory/query-aliases.mjs +39 -0
  204. package/src/memory/recall-cue.mjs +43 -0
  205. package/src/memory/recall.mjs +65 -0
  206. package/src/memory/referent-candidates.mjs +55 -0
  207. package/src/memory/referent-ledger.mjs +86 -0
  208. package/src/memory/relabel-migrate.mjs +376 -0
  209. package/src/memory/relik-adapter.mjs +25 -0
  210. package/src/memory/special-keywords.mjs +60 -0
  211. package/src/memory/structured-adapters.mjs +75 -0
  212. package/src/memory/summary-index.mjs +335 -0
  213. package/src/memory/summary-tree.mjs +110 -0
  214. package/src/memory/term-extractor.mjs +44 -0
  215. package/src/memory/tokenize.mjs +9 -0
  216. package/src/memory/vector-index.mjs +54 -0
  217. package/src/memory/window-summary.mjs +133 -0
  218. package/src/mood.mjs +133 -0
  219. package/src/peer-tool.mjs +63 -0
  220. package/src/peer.mjs +144 -0
  221. package/src/policy.mjs +170 -0
  222. package/src/preflight.mjs +113 -0
  223. package/src/provenance.mjs +94 -0
  224. package/src/range.mjs +75 -0
  225. package/src/recall-exact-tool.mjs +267 -0
  226. package/src/recall-tool.mjs +521 -0
  227. package/src/recheck.mjs +99 -0
  228. package/src/save-start.mjs +29 -0
  229. package/src/session-guard.mjs +140 -0
  230. package/src/session-log-tool.mjs +322 -0
  231. package/src/session-log.mjs +606 -0
  232. package/src/skills.mjs +111 -0
  233. package/src/split-range.mjs +115 -0
  234. package/src/state.mjs +198 -0
  235. package/src/t0.mjs +149 -0
  236. package/src/tag-confirm.mjs +78 -0
  237. package/src/tag-detect.mjs +42 -0
  238. package/src/tag-maintenance.mjs +285 -0
  239. package/src/tag-reject.mjs +73 -0
  240. package/src/tags-store.mjs +94 -0
  241. package/src/tags.mjs +692 -0
  242. package/src/threads-backend.mjs +26 -0
  243. package/src/threads-core.mjs +1887 -0
  244. package/src/threads-store.mjs +207 -0
  245. package/src/threads-tool.mjs +522 -0
  246. package/src/threads.mjs +929 -0
  247. package/src/tool-preview.mjs +427 -0
  248. package/src/topic.mjs +63 -0
  249. package/src/training.mjs +75 -0
  250. package/src/turn-tag.mjs +62 -0
  251. package/src/user-turn.mjs +39 -0
  252. package/templates/cmap.md +11 -0
  253. package/templates/concepts.md +12 -0
  254. package/templates/habits.md +40 -0
  255. package/templates/keywords.md +6 -0
  256. package/templates/rebuild.md +134 -0
  257. package/templates/rules-safe.md +184 -0
  258. package/templates/rules.md +177 -0
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Find a TAG declaration in an assistant message.
3
+ *
4
+ * The gate accepts the first non-empty, non-fenced line OR the last five
5
+ * non-empty, non-fenced lines. Last-five wins when both contain a TAG, which
6
+ * keeps the old "末尾优先" behavior. Fenced examples are never declarations.
7
+ */
8
+ export function tagDeclarationCandidates(text, { tailLimit = 5 } = {}) {
9
+ const rawLines = String(text ?? '').split('\n');
10
+ const fenced = [];
11
+ let inFence = false;
12
+ for (const line of rawLines) {
13
+ if (/^\s*```/.test(line)) {
14
+ inFence = !inFence;
15
+ fenced.push(true);
16
+ continue;
17
+ }
18
+ fenced.push(inFence);
19
+ }
20
+
21
+ const candidates = [];
22
+ const tailIndexes = new Set();
23
+ for (let i = rawLines.length - 1; i >= 0; i -= 1) {
24
+ const line = rawLines[i].trim();
25
+ if (line.length === 0 || fenced[i]) continue;
26
+ candidates.push(line);
27
+ tailIndexes.add(i);
28
+ if (candidates.length >= tailLimit) break;
29
+ }
30
+
31
+ for (let i = 0; i < rawLines.length; i += 1) {
32
+ const line = rawLines[i].trim();
33
+ if (line.length === 0 || fenced[i]) continue;
34
+ if (!tailIndexes.has(i)) candidates.push(line);
35
+ break;
36
+ }
37
+ return candidates;
38
+ }
39
+
40
+ export function findTagDeclaration(text) {
41
+ return tagDeclarationCandidates(text).find((line) => /^TAG\s*[::]/i.test(line)) ?? null;
42
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Deterministic tag maintenance (0.8.57).
3
+ *
4
+ * No model calls. The only semantic-looking step is a conservative merge:
5
+ * it requires run containment, keyword/label overlap, a higher-use target and
6
+ * a clear margin. Everything else is data hygiene.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { copyFile, mkdir } from 'node:fs/promises';
11
+ import { continuityHome, readTags, updateTags, writeTagsMd } from './tags-store.mjs';
12
+ import { canonicalTagId, normalizeTags, renderTagsMd } from './tags.mjs';
13
+
14
+ function readTurnUse(home) {
15
+ const use = new Map();
16
+ try {
17
+ const text = fs.readFileSync(path.join(home, 'turn-tags.jsonl'), 'utf8');
18
+ for (const line of text.split(/\r?\n/)) {
19
+ if (!line.trim()) continue;
20
+ try {
21
+ const row = JSON.parse(line);
22
+ if (typeof row?.tag !== 'string') continue;
23
+ const current = use.get(row.tag) ?? { count: 0, lastTurn: 0 };
24
+ current.count += 1;
25
+ current.lastTurn = Math.max(current.lastTurn, Number(row.turn) || 0);
26
+ use.set(row.tag, current);
27
+ } catch { /* skip malformed row */ }
28
+ }
29
+ } catch { /* no turn tags yet */ }
30
+ return use;
31
+ }
32
+
33
+ function tagRuns(state) {
34
+ const runs = new Map();
35
+ for (const [start, end, id] of state.runs ?? []) {
36
+ if (typeof id !== 'string') continue;
37
+ if (!runs.has(id)) runs.set(id, []);
38
+ runs.get(id).push([start, end]);
39
+ }
40
+ return runs;
41
+ }
42
+
43
+ function hasAnySeq(state, id, runs) {
44
+ if ((runs.get(id) ?? []).length > 0) return true;
45
+ if (Object.values(state.overrides ?? {}).includes(id)) return true;
46
+ return Object.values(state.memberships ?? {}).some((ids) => Array.isArray(ids) && ids.includes(id));
47
+ }
48
+
49
+ function nextNumericAlias(state) {
50
+ let max = 0;
51
+ for (const entry of Object.values(state.tags ?? {})) {
52
+ const match = /^T(\d+)$/.exec(String(entry?.alias ?? ''));
53
+ if (match) max = Math.max(max, Number(match[1]));
54
+ }
55
+ return max + 1;
56
+ }
57
+
58
+ function runsInside(sourceRuns, targetRuns) {
59
+ if (sourceRuns.length === 0 || targetRuns.length === 0) return false;
60
+ const strict = sourceRuns.every(([s, e]) => targetRuns.some(([ts, te]) => s >= ts && e <= te));
61
+ if (strict) return true;
62
+ const start = Math.min(...targetRuns.map(([s]) => s));
63
+ const end = Math.max(...targetRuns.map(([, e]) => e));
64
+ return sourceRuns.every(([s, e]) => s >= start && e <= end);
65
+ }
66
+
67
+ function keywordScore(source, target) {
68
+ const keys = target.keywords.map((k) => k.toLowerCase());
69
+ let score = 0;
70
+ for (const raw of source.keywords.map((k) => k.toLowerCase())) {
71
+ if (keys.includes(raw)) score += 2;
72
+ else if (keys.some((k) => k.includes(raw) || raw.includes(k))) score += 1;
73
+ }
74
+ return score;
75
+ }
76
+
77
+ function score(source, target, runs) {
78
+ let value = 0;
79
+ const reasons = [];
80
+ if (runsInside(runs.get(source.id) ?? [], runs.get(target.id) ?? [])) {
81
+ value += 3;
82
+ reasons.push('run-contained');
83
+ }
84
+ const kw = keywordScore(source, target);
85
+ if (kw > 0) {
86
+ value += kw;
87
+ reasons.push(`kw:${kw}`);
88
+ }
89
+ const sl = source.label.toLowerCase();
90
+ const tl = target.label.toLowerCase();
91
+ if (sl.includes(tl) || tl.includes(sl)) {
92
+ value += 2;
93
+ reasons.push('label-contains');
94
+ }
95
+ return { value, reasons };
96
+ }
97
+
98
+ export function planMaintenance(stateInput, use, nowTurn = 0) {
99
+ const state = normalizeTags(stateInput);
100
+ const runs = tagRuns(state);
101
+ const rows = Object.entries(state.tags ?? {})
102
+ .filter(([id, entry]) => id !== 'T0' && entry?.lifecycle !== 'merged' && entry?.lifecycle !== 'archived')
103
+ .map(([id, entry]) => ({
104
+ id,
105
+ label: String(entry?.label ?? id),
106
+ alias: String(entry?.alias ?? ''),
107
+ keywords: Array.isArray(entry?.keywords) ? entry.keywords.map(String) : [],
108
+ use: use.get(id)?.count ?? 0,
109
+ lastTurn: use.get(id)?.lastTurn ?? 0,
110
+ lifecycle: entry?.lifecycle,
111
+ runs: runs.get(id) ?? [],
112
+ }));
113
+
114
+ const expired = nowTurn > 0
115
+ ? rows.filter((row) => row.lifecycle === 'provisional' && row.use <= 1 && nowTurn - row.lastTurn > 20)
116
+ : [];
117
+ const expiredIds = new Set(expired.map((row) => row.id));
118
+ const ghosts = rows.filter((row) => !expiredIds.has(row.id) && row.use === 0 && !hasAnySeq(state, row.id, runs));
119
+ const active = rows.filter((row) => !expiredIds.has(row.id) && (row.use > 0 || hasAnySeq(state, row.id, runs)));
120
+
121
+ active.sort((a, b) => b.use - a.use || a.id.localeCompare(b.id));
122
+ let alias = nextNumericAlias(state);
123
+ const aliasFixes = [];
124
+ for (const row of active) {
125
+ if (/^T\d+$/.test(row.alias)) continue;
126
+ row.newAlias = `T${alias}`;
127
+ alias += 1;
128
+ aliasFixes.push(row);
129
+ }
130
+
131
+ const byId = new Map(active.map((row) => [row.id, row]));
132
+ const merges = [];
133
+ const orphans = [];
134
+ for (const source of active.filter((row) => row.use <= 10 && row.runs.length <= 3)) {
135
+ const ranked = active
136
+ .filter((row) => row.id !== source.id && row.use > source.use)
137
+ .map((row) => ({ target: row, ...score(source, row, runs) }))
138
+ .sort((a, b) => b.value - a.value || b.target.use - a.target.use);
139
+ const top = ranked[0] ?? null;
140
+ const second = ranked[1] ?? null;
141
+ const margin = top ? top.value - (second?.value ?? 0) : 0;
142
+ if (top !== null && top.value >= 4 && margin >= 1) {
143
+ merges.push({ source, target: top.target, score: top.value, reasons: top.reasons });
144
+ } else {
145
+ orphans.push({ source, top, margin });
146
+ }
147
+ }
148
+
149
+ return { state, rows, ghosts, expired, active, aliasFixes, merges, orphans, byId };
150
+ }
151
+
152
+ export async function applyMaintenance(plan) {
153
+ const state = structuredClone(plan.state);
154
+ const runUse = new Map(plan.rows.map((row) => [row.id, row.use]));
155
+
156
+ for (const ghost of plan.ghosts) {
157
+ if (state.tags[ghost.id] === undefined) continue;
158
+ state.tags[ghost.id].lifecycle = 'archived';
159
+ state.tags[ghost.id].useCount = 0;
160
+ }
161
+ for (const expired of plan.expired) {
162
+ const entry = state.tags[expired.id];
163
+ if (entry === undefined) continue;
164
+ entry.lifecycle = 'archived';
165
+ entry.mergedInto = 'T0';
166
+ entry.useCount = runUse.get(expired.id) ?? 0;
167
+ }
168
+ for (const fix of plan.aliasFixes) {
169
+ const entry = state.tags[fix.id];
170
+ if (entry === undefined) continue;
171
+ const old = String(entry.alias ?? '').trim();
172
+ if (old.length > 0) entry.legacyAliases = [...new Set([...(entry.legacyAliases ?? []), old])];
173
+ entry.alias = fix.newAlias;
174
+ }
175
+ for (const merge of plan.merges) {
176
+ const source = state.tags[merge.source.id];
177
+ const target = state.tags[merge.target.id];
178
+ if (source === undefined || target === undefined) continue;
179
+ source.lifecycle = 'merged';
180
+ source.mergedInto = merge.target.id;
181
+ source.useCount = runUse.get(source.id) ?? source.useCount ?? 0;
182
+ target.mergedFrom = [...new Set([...(target.mergedFrom ?? []), source.id])];
183
+ target.useCount = (target.useCount ?? runUse.get(target.id) ?? 0) + (runUse.get(source.id) ?? 0);
184
+ }
185
+ const mergedInto = new Map([
186
+ ...plan.merges.map((merge) => [merge.source.id, merge.target.id]),
187
+ ...plan.expired.map((row) => [row.id, 'T0']),
188
+ ]);
189
+ state.runs = (state.runs ?? [])
190
+ .map(([start, end, id]) => [start, end, mergedInto.get(id) ?? id])
191
+ .map(([start, end, id]) => [`${start}-${end}-${id}`, [start, end, id]])
192
+ .filter(([key], index, list) => list.findIndex(([other]) => other === key) === index)
193
+ .map(([, run]) => run)
194
+ .sort((a, b) => a[0] - b[0] || a[1] - b[1]);
195
+ for (const [seq, id] of Object.entries(state.overrides ?? {})) {
196
+ state.overrides[seq] = mergedInto.get(id) ?? id;
197
+ }
198
+ for (const [seq, ids] of Object.entries(state.memberships ?? {})) {
199
+ state.memberships[seq] = [...new Set(ids.map((id) => mergedInto.get(id) ?? id))];
200
+ }
201
+ for (const row of plan.active) {
202
+ const entry = state.tags[row.id];
203
+ if (entry === undefined) continue;
204
+ entry.useCount = runUse.get(row.id) ?? 0;
205
+ if (entry.lifecycle === undefined) entry.lifecycle = 'active';
206
+ if (entry.lifecycle === 'provisional' && (entry.useCount ?? 0) >= 2) entry.lifecycle = 'active';
207
+ }
208
+ state.updatedAt = new Date().toISOString();
209
+ return normalizeTags(state);
210
+ }
211
+
212
+ function verify(plan, next) {
213
+ const problems = [];
214
+ const nextRuns = tagRuns(next);
215
+ for (const merge of plan.merges) {
216
+ const targetId = canonicalTagId(next, merge.target.id);
217
+ const covered = (merge.source.runs ?? []).every(([s, e]) => (nextRuns.get(targetId) ?? [])
218
+ .some(([ts, te]) => s >= ts && e <= te));
219
+ if (!covered) problems.push(`seq-not-covered:${merge.source.id}->${targetId}`);
220
+ }
221
+ for (const expired of plan.expired) {
222
+ const covered = (expired.runs ?? []).every(([s, e]) => (nextRuns.get('T0') ?? [])
223
+ .some(([ts, te]) => s >= ts && e <= te));
224
+ if (!covered) problems.push(`expired-seq-not-covered:${expired.id}`);
225
+ }
226
+ for (const row of plan.active) {
227
+ const entry = next.tags[row.id];
228
+ if (entry?.mergedInto !== undefined && next.tags[entry.mergedInto] === undefined) {
229
+ problems.push(`missing-merge-target:${row.id}->${entry.mergedInto}`);
230
+ }
231
+ }
232
+ return problems;
233
+ }
234
+
235
+ export async function runMaintenance({ home = continuityHome(), mode = 'manual', dryRun = false } = {}) {
236
+ const read = await readTags(home);
237
+ const use = readTurnUse(home);
238
+ const nowTurn = [...use.values()].reduce((max, item) => Math.max(max, item.lastTurn), 0);
239
+ const plan = planMaintenance(read.state, use, nowTurn);
240
+ const changed = plan.ghosts.length + plan.expired.length + plan.aliasFixes.length + plan.merges.length;
241
+ const result = {
242
+ mode,
243
+ dryRun,
244
+ total: plan.rows.length,
245
+ active: plan.active.length,
246
+ ghosts: plan.ghosts.length,
247
+ expired: plan.expired.length,
248
+ aliasFixes: plan.aliasFixes.length,
249
+ merged: plan.merges.length,
250
+ orphans: plan.orphans.length,
251
+ changed,
252
+ healthy: true,
253
+ problems: [],
254
+ merges: plan.merges.map((merge) => ({
255
+ source: merge.source.id,
256
+ target: merge.target.id,
257
+ score: merge.score,
258
+ reasons: merge.reasons,
259
+ })),
260
+ };
261
+ if (changed === 0) {
262
+ result.final = plan.rows.length;
263
+ return result;
264
+ }
265
+ if (dryRun) {
266
+ result.final = plan.rows.length - plan.ghosts.length - plan.expired.length - plan.merges.length;
267
+ return result;
268
+ }
269
+
270
+ const dir = home;
271
+ await mkdir(dir, { recursive: true });
272
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
273
+ const backup = path.join(dir, `tags.json.bak-maintenance-${stamp}`);
274
+ try { await copyFile(path.join(dir, 'tags.json'), backup); } catch { /* first run */ }
275
+
276
+ const next = await applyMaintenance(plan);
277
+ result.problems = verify(plan, next);
278
+ result.healthy = result.problems.length === 0;
279
+ await updateTags(() => next, home);
280
+ await writeTagsMd(renderTagsMd(next, { maxSeq: 0 }), home);
281
+ result.final = Object.entries(next.tags).filter(([id, entry]) => id !== 'T0'
282
+ && entry?.lifecycle !== 'merged' && entry?.lifecycle !== 'archived').length;
283
+ result.backup = backup;
284
+ return result;
285
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * One-shot display state for a rejected TAG.
3
+ *
4
+ * The first pre-step that sees a rejection renders it and writes `shown:true`;
5
+ * the next pre-step removes the file without rendering again. A second rejection
6
+ * for the same turn does not reset `shown`, so one turn cannot spam the header.
7
+ */
8
+ import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
9
+
10
+ export const TAG_REJECT_FILE = 'tag-reject.json';
11
+
12
+ function read(file) {
13
+ try {
14
+ const value = JSON.parse(readFileSync(file, 'utf8'));
15
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ /** Record the rejection, preserving an existing display for the same turn. */
22
+ export function recordTagReject({ file, turn, token, kind, reason, now = Date.now() }) {
23
+ const previous = read(file);
24
+ const sameTurnShown = previous !== null && Number(previous.turn) === Number(turn) && previous.shown === true;
25
+ const next = {
26
+ at: new Date(now).toISOString(),
27
+ turn,
28
+ token,
29
+ kind,
30
+ reason,
31
+ shown: sameTurnShown,
32
+ };
33
+ if (sameTurnShown) next.shownAt = previous.shownAt ?? previous.at ?? null;
34
+ writeFileSync(file, JSON.stringify(next, null, 2), 'utf8');
35
+ return next;
36
+ }
37
+
38
+ /** The exact header format the instance already reads. */
39
+ export function renderTagRejectHeader(reject) {
40
+ return '\n[打标·退回] 第 ' + String(reject.turn ?? '?') + ' 轮没有记下标签:你写的是「' + String(reject.token ?? '?') + '」——'
41
+ + String(reject.reason ?? reject.kind ?? '被拒') + '。'
42
+ + '\n 修法:node ~/.dsh/continuity/turnmap.mjs set --turn ' + String(reject.turn ?? '?') + ' --tag <Tx>'
43
+ + '(写别名,或写一个表里没有的新词;引号里的词按字面处理)';
44
+ }
45
+
46
+ /**
47
+ * Render at most once, then delete on the following consumption.
48
+ * If the rejected turn already has a tag, the notice is stale: delete without
49
+ * rendering. Returns `{action:'none'|'shown'|'deleted', text, reject?, reason?}`.
50
+ */
51
+ export function consumeTagReject({ file, isResolved = () => false, now = Date.now() }) {
52
+ const reject = read(file);
53
+ if (reject === null) {
54
+ try { unlinkSync(file); } catch { /* nothing to remove */ }
55
+ return { action: 'none', text: '' };
56
+ }
57
+ if (isResolved(Number(reject.turn))) {
58
+ try { unlinkSync(file); } catch { /* next pre-step retries */ }
59
+ return { action: 'deleted', text: '', reason: 'resolved' };
60
+ }
61
+ if (reject.shown === true) {
62
+ try { unlinkSync(file); } catch { /* next pre-step retries */ }
63
+ return { action: 'deleted', text: '' };
64
+ }
65
+ const text = renderTagRejectHeader(reject);
66
+ const next = {
67
+ ...reject,
68
+ shown: true,
69
+ shownAt: new Date(now).toISOString(),
70
+ };
71
+ try { writeFileSync(file, JSON.stringify(next, null, 2), 'utf8'); } catch { /* display still valid */ }
72
+ return { action: 'shown', text, reject: next };
73
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Durable Continuity tag sidecar (~/.dsh/continuity/tags.json).
3
+ *
4
+ * Missing is a valid empty state (all T0). Corrupt JSON also falls back to the
5
+ * empty state instead of throwing; callers record one warning. Writes are
6
+ * serialized and atomic. Raw session events are never touched.
7
+ */
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
11
+ import { emptyTags, normalizeTags } from './tags.mjs';
12
+
13
+ let writeQueue = Promise.resolve();
14
+
15
+ export function continuityHome() {
16
+ const override = process.env.DSH_CONTINUITY_HOME;
17
+ return override !== undefined && override.length > 0
18
+ ? override
19
+ : join(homedir(), '.dsh', 'continuity');
20
+ }
21
+
22
+ export function tagsFile(home = continuityHome()) {
23
+ return join(home, 'tags.json');
24
+ }
25
+
26
+ export function tagsMdFile(home = continuityHome()) {
27
+ return join(home, 'tags.md');
28
+ }
29
+
30
+ /** Write the generated tags.md view (never read back by the plugin). */
31
+ export async function writeTagsMd(text, home = continuityHome()) {
32
+ const dir = home;
33
+ await mkdir(dir, { recursive: true });
34
+ const file = tagsMdFile(home);
35
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
36
+ try {
37
+ await writeFile(temp, String(text ?? ''), 'utf8');
38
+ await rename(temp, file);
39
+ } catch (error) {
40
+ try { await unlink(temp); } catch { /* best effort */ }
41
+ throw new Error(`continuity: cannot write ${file}: ${error?.message ?? error}`);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * @returns {Promise<{state: object, missing: boolean, corrupt: boolean, error: string|null}>}
47
+ */
48
+ export async function readTags(home = continuityHome()) {
49
+ try {
50
+ const raw = await readFile(tagsFile(home), 'utf8');
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(raw);
54
+ } catch (error) {
55
+ return { state: emptyTags(), missing: false, corrupt: true, error: error?.message ?? String(error) };
56
+ }
57
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
58
+ return { state: emptyTags(), missing: false, corrupt: true, error: 'tags.json must contain an object' };
59
+ }
60
+ return { state: normalizeTags(parsed), missing: false, corrupt: false, error: null };
61
+ } catch (error) {
62
+ if (error?.code === 'ENOENT') return { state: emptyTags(), missing: true, corrupt: false, error: null };
63
+ return { state: emptyTags(), missing: false, corrupt: true, error: error?.message ?? String(error) };
64
+ }
65
+ }
66
+
67
+ async function writeTags(state, home = continuityHome()) {
68
+ const dir = home;
69
+ await mkdir(dir, { recursive: true });
70
+ const file = tagsFile(home);
71
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
72
+ try {
73
+ await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
74
+ await rename(temp, file);
75
+ } catch (error) {
76
+ try { await unlink(temp); } catch { /* best effort */ }
77
+ throw new Error(`continuity: cannot write ${file}: ${error?.message ?? error}`);
78
+ }
79
+ }
80
+
81
+ /** Serialized read-modify-write. The mutator receives a normalized copy. */
82
+ export async function updateTags(mutate, home = continuityHome()) {
83
+ const operation = writeQueue.then(async () => {
84
+ const read = await readTags(home);
85
+ const working = structuredClone(read.state);
86
+ const next = await mutate(working);
87
+ const value = next !== null && next !== undefined && typeof next === 'object' ? next : working;
88
+ const normalized = normalizeTags({ ...value, updatedAt: new Date().toISOString() });
89
+ await writeTags(normalized, home);
90
+ return { state: normalized, recovered: read.missing || read.corrupt, readError: read.error };
91
+ });
92
+ writeQueue = operation.catch(() => {});
93
+ return operation;
94
+ }