@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
package/src/mood.mjs ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * mood 层:只负责“找出带情绪的句子”和“决定注入哪几条”,不做强度自评。
3
+ *
4
+ * 设计约束(来自外部意见 + 自锁审查):
5
+ * 1. 不做模型自评强度:只记录“出现了哪些情绪词 + 原句”,强度不进来路不明的分数;
6
+ * 2. 调度用行为数据:最近没被注入过的优先(LRU),引用次数只用于“提升为判据层”,不回流权重;
7
+ * 3. 存储永不删:注入名额有限(≤2),记录只追加;
8
+ * 4. 冷却:同一条注入后 COOLDOWN_HOURS 内不再注入。
9
+ */
10
+
11
+ /**
12
+ * 情绪词:**只收多字词与少数强信号单字**。
13
+ * 工程语料里的“压缩/累积/崩溃/困境/沉默”都会撞上单字,所以单字只留极少数。
14
+ */
15
+ export const MARKER_GROUPS = {
16
+ agitation: ["烦死", "很烦", "太烦", "焦躁", "急躁", "烦躁", "受不了", "摆烂", "火大", "憋屈"],
17
+ fatigue: ["太累", "好累", "很累", "撑不住", "没劲", "疲惫", "困死了"],
18
+ heavy: ["难受", "低落", "沮丧", "心里堵", "闷得慌", "丧"],
19
+ fear: ["发怵", "慌", "不安", "害怕", "担心"],
20
+ bright: ["高兴", "开心", "舒服", "松了口气", "欣慰", "爽", "轻松"],
21
+ warmth: ["感动", "温暖", "暖", "温柔", "舍不得", "珍惜"],
22
+ anger: ["生气", "愤怒", "气死", "怒"],
23
+ };
24
+
25
+ const ALL_MARKERS = Object.values(MARKER_GROUPS).flat();
26
+
27
+ /** 第一人称:有“我”的句子才算自己的状态,不是转述别人。 */
28
+ const FIRST_PERSON = /(^|[^你他她])我|自己|心里|咱们/;
29
+
30
+ /**
31
+ * 质量闸:只收“像自己状态”的句子。
32
+ * - 命中 ≥2 个情绪标记;或
33
+ * - 命中 1 个且句子含第一人称。
34
+ * 返回 false 的句子不进库(宁可漏,不制造干扰)。
35
+ */
36
+ export function shouldRecord(hit) {
37
+ const markers = hit?.markers ?? [];
38
+ if (markers.length >= 2) return true;
39
+ if (markers.length === 1) return FIRST_PERSON.test(String(hit?.excerpt ?? ""));
40
+ return false;
41
+ }
42
+
43
+ const SENTENCE_SPLIT = /[。!?!?;;\n]/;
44
+ /** 讨论 mood 机制本身的句子不算情绪(否则自指会刷屏)。 */
45
+ const META_RE = /mood|情绪词|markers?|lexicon|心境|情绪改变|multi-char|singles?|technical|词表|命中率/i;
46
+
47
+ /**
48
+ * 从一段文本(reasoning 或正文)里找情绪标记。
49
+ * 返回 { markers, groups, excerpt };excerpt 取第一个命中所在句,≤80 字。
50
+ */
51
+ export function extractMood(text) {
52
+ const src = String(text ?? "");
53
+ if (src.length === 0) return { markers: [], groups: [], excerpt: "" };
54
+ const sentences = src.split(SENTENCE_SPLIT).map((s) => s.trim()).filter(Boolean);
55
+ const markers = [];
56
+ const groups = new Set();
57
+ let excerpt = "";
58
+ for (const sentence of sentences) {
59
+ if (META_RE.test(sentence)) continue;
60
+ const hit = ALL_MARKERS.filter((m) => sentence.includes(m));
61
+ if (hit.length === 0) continue;
62
+ for (const m of hit) if (!markers.includes(m)) markers.push(m);
63
+ for (const [group, list] of Object.entries(MARKER_GROUPS)) {
64
+ if (list.some((m) => sentence.includes(m))) groups.add(group);
65
+ }
66
+ if (excerpt.length === 0) excerpt = sentence.slice(0, 80);
67
+ }
68
+ return { markers, groups: [...groups], excerpt };
69
+ }
70
+
71
+ /**
72
+ * 解析 mood 事件日志(JSONL):
73
+ * {"type":"mood","seq":123,"at":"...","markers":["烦"],"excerpt":"..."}
74
+ * {"type":"injected","seq":123,"at":"..."}
75
+ * {"type":"cited","seq":123,"at":"..."}
76
+ * 返回 [{ seq, at, excerpt, cited, lastInjectedAt }]
77
+ */
78
+ export function parseMoodLog(lines) {
79
+ const entries = new Map();
80
+ for (const line of lines ?? []) {
81
+ if (!line || line.trim().length === 0) continue;
82
+ let row;
83
+ try { row = JSON.parse(line); } catch { continue; }
84
+ const seq = Number(row?.seq);
85
+ if (!Number.isInteger(seq)) continue;
86
+ if (row.type === "mood") {
87
+ const cur = entries.get(seq) ?? { seq, at: row.at, excerpt: row.excerpt ?? "", markers: row.markers ?? [], origin: row.origin ?? null, cited: false, lastInjectedAt: null };
88
+ entries.set(seq, cur);
89
+ } else if (row.type === "injected") {
90
+ const cur = entries.get(seq);
91
+ if (cur) cur.lastInjectedAt = row.at ?? null;
92
+ } else if (row.type === "cited") {
93
+ const cur = entries.get(seq);
94
+ if (cur) cur.cited = true;
95
+ }
96
+ }
97
+ return [...entries.values()];
98
+ }
99
+
100
+ /**
101
+ * 选出本次要注入的条目。
102
+ * 规则(全部可测,无自评):
103
+ * - 只选表达层(未被引用过)的条目;被引用过的条目已提升为“判据”,不再以“想起”的形式出现;
104
+ * - 冷却:距上次注入 < COOLDOWN_HOURS 的不选;
105
+ * - 过期:距上次被碰(创建/注入/引用)超过 MAX_AGE_DAYS 的,不再注入(记录保留);
106
+ * - 排序:从没注入过的优先,其次最久没注入的最先(LRU);同序按时间早的优先。
107
+ */
108
+ export function selectMood(entries, options = {}) {
109
+ const nowMs = options.nowMs ?? Date.now();
110
+ const quota = Number(options.quota ?? 2);
111
+ const cooldownHours = Number(options.cooldownHours ?? 6);
112
+ const maxAgeDays = Number(options.maxAgeDays ?? 7);
113
+ const hour = 3600 * 1000;
114
+ const day = 24 * hour;
115
+
116
+ const eligible = [];
117
+ for (const e of entries ?? []) {
118
+ if (e.cited) continue; // 已提升为判据,不走表达层注入
119
+ const created = Date.parse(e.at ?? "") || 0;
120
+ const injected = e.lastInjectedAt ? Date.parse(e.lastInjectedAt) || 0 : null;
121
+ const lastTouched = Math.max(created, injected ?? 0);
122
+ if (nowMs - lastTouched > maxAgeDays * day) continue; // 过期不再打扰
123
+ if (injected !== null && nowMs - injected < cooldownHours * hour) continue; // 冷却
124
+ eligible.push({ ...e, injected });
125
+ }
126
+ eligible.sort((a, b) => {
127
+ if (a.injected === null && b.injected !== null) return -1;
128
+ if (a.injected !== null && b.injected === null) return 1;
129
+ if (a.injected !== b.injected) return (a.injected ?? 0) - (b.injected ?? 0);
130
+ return (Date.parse(a.at ?? "") || 0) - (Date.parse(b.at ?? "") || 0);
131
+ });
132
+ return eligible.slice(0, Math.max(0, quota));
133
+ }
@@ -0,0 +1,63 @@
1
+ import { peerAgents, parsePeerPayload, peerMessage } from './peer.mjs';
2
+
3
+ /**
4
+ * Preset-scoped peer reply tool. It is the agent-facing wrapper around the same
5
+ * exact-target lookup and `agent.followup` used by the loopback route. It never
6
+ * falls back to the active session and never appends a raw session event.
7
+ */
8
+ export function createPeerTool({ agents = peerAgents } = {}) {
9
+ return {
10
+ name: 'peer_message',
11
+ description: [
12
+ 'Send one user message to one live DSH session by exact session id.',
13
+ 'Use it to reply to a peer or send a direct note; the target must already be live.',
14
+ 'It returns queued/next-turn with the delivery id, not the target\'s reply.',
15
+ ].join(' '),
16
+ parameters: {
17
+ type: 'object',
18
+ additionalProperties: false,
19
+ required: ['to', 'text'],
20
+ properties: {
21
+ to: {
22
+ type: 'string',
23
+ description: 'Exact live session id, e.g. session-<uuid>. Required; there is no active or newest fallback.',
24
+ },
25
+ text: {
26
+ type: 'string',
27
+ description: 'Message body. It is prefixed with [from] before delivery.',
28
+ },
29
+ from: {
30
+ type: 'string',
31
+ description: 'Optional display name used in the [from] prefix. Default: Continuity.',
32
+ },
33
+ },
34
+ },
35
+ output: {
36
+ schema: { type: 'string' },
37
+ render: (_args, value) => [{ type: 'text', text: String(value ?? '') }],
38
+ },
39
+ timeoutMs: 30_000,
40
+ isConcurrencySafe: () => false,
41
+ async execute(args) {
42
+ const input = args !== null && typeof args === 'object' ? args : {};
43
+ const parsed = parsePeerPayload({
44
+ ...input,
45
+ from: typeof input.from === 'string' && input.from.trim().length > 0 ? input.from : 'Continuity',
46
+ });
47
+ if (!parsed.ok) throw new Error(`peer_message: ${parsed.error}`);
48
+ const target = agents.get(parsed.to);
49
+ if (target === null || typeof target.followup !== 'function') {
50
+ throw new Error(`peer_message: target-not-live (${parsed.to})`);
51
+ }
52
+ const message = peerMessage(parsed);
53
+ await target.followup(message);
54
+ return JSON.stringify({
55
+ ok: true,
56
+ to: parsed.to,
57
+ from: parsed.from,
58
+ queued: 'next-turn',
59
+ id: message.id,
60
+ });
61
+ },
62
+ };
63
+ }
package/src/peer.mjs ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Target-addressed peer delivery on the Host's existing loopback web server.
3
+ *
4
+ * This is deliberately not the Coo pet bridge: that bridge owns one socket and
5
+ * routes to `agents.owner`, so switching the active session changes its target.
6
+ * Here `to` is a session id, lookup is exact, and a missing target is a refusal.
7
+ * Nothing in this module appends a session event directly; the only write path
8
+ * is the live agent's `followup`, which is validated by the Host.
9
+ */
10
+ import { userMessage } from './threads.mjs';
11
+
12
+ export const PEER_PATH = '/dsh-continuity/peer';
13
+ export const PEER_BODY_LIMIT = 16 * 1024;
14
+ export const PEER_TEXT_LIMIT = 8_000;
15
+ export const PEER_FROM_LIMIT = 32;
16
+
17
+ /** Live main agents, keyed by their session id. Subagents are never targets. */
18
+ export class LiveAgents {
19
+ #byId = new Map();
20
+
21
+ add(id, agent) {
22
+ const key = typeof id === 'string' ? id.trim() : '';
23
+ if (key.length === 0 || key.length > 200) return false;
24
+ if (!agent || typeof agent !== 'object' || typeof agent.followup !== 'function') return false;
25
+ if (agent.session?.parent) return false;
26
+ this.#byId.set(key, agent);
27
+ return true;
28
+ }
29
+
30
+ remove(id) {
31
+ const key = typeof id === 'string' ? id.trim() : '';
32
+ if (key.length === 0) return;
33
+ this.#byId.delete(key);
34
+ }
35
+
36
+ get(id) {
37
+ return typeof id === 'string' ? this.#byId.get(id) ?? null : null;
38
+ }
39
+
40
+ get size() {
41
+ return this.#byId.size;
42
+ }
43
+ }
44
+
45
+ /** The one registry shared by the Host route and the continuity-preset tool. */
46
+ export const peerAgents = new LiveAgents();
47
+
48
+ /** Validate the JSON body. `to` is mandatory; there is intentionally no default. */
49
+ export function parsePeerPayload(payload) {
50
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
51
+ return { ok: false, error: 'body-must-be-a-json-object' };
52
+ }
53
+ const to = typeof payload.to === 'string' ? payload.to.trim() : '';
54
+ if (to.length === 0) return { ok: false, error: 'to-required' };
55
+ if (to.length > 200 || !/^session-[A-Za-z0-9_-]+$/.test(to)) {
56
+ return { ok: false, error: 'to-must-be-a-session-id' };
57
+ }
58
+ const text = typeof payload.text === 'string' ? payload.text.trim() : '';
59
+ if (text.length === 0) return { ok: false, error: 'text-required' };
60
+ if (text.length > PEER_TEXT_LIMIT) return { ok: false, error: 'text-too-long' };
61
+ const rawFrom = typeof payload.from === 'string' ? payload.from : '';
62
+ const from = rawFrom.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, PEER_FROM_LIMIT) || '创';
63
+ return { ok: true, to, from, text };
64
+ }
65
+
66
+ /** A real user-role message with a fresh id and source.kind, not a raw append. */
67
+ export function peerMessage({ from, text }) {
68
+ const message = userMessage(`[${from}] ${text}`);
69
+ message.source = { ...message.source, peer: true, peerFrom: String(from ?? '') };
70
+ return message;
71
+ }
72
+
73
+ function isLoopback(address) {
74
+ const value = typeof address === 'string' ? address : '';
75
+ return /^(?:::1|::ffff:127\.|127\.)/i.test(value);
76
+ }
77
+
78
+ function sendJson(res, status, payload) {
79
+ const text = JSON.stringify(payload);
80
+ res.writeHead(status, {
81
+ 'content-type': 'application/json; charset=utf-8',
82
+ 'cache-control': 'no-store',
83
+ 'content-length': Buffer.byteLength(text),
84
+ });
85
+ res.end(text);
86
+ }
87
+
88
+ /** HTTP handler shared by the Host route and the tests. */
89
+ export async function handlePeerRequest(req, res, { agents, log = console } = {}) {
90
+ if (req.method !== 'POST') {
91
+ res.writeHead(405, { allow: 'POST' });
92
+ res.end();
93
+ return;
94
+ }
95
+ if (!isLoopback(req.socket?.remoteAddress)) {
96
+ sendJson(res, 403, { ok: false, error: 'loopback-only' });
97
+ return;
98
+ }
99
+ let raw = '';
100
+ try {
101
+ for await (const chunk of req) {
102
+ raw += chunk;
103
+ if (raw.length > PEER_BODY_LIMIT) {
104
+ sendJson(res, 413, { ok: false, error: 'body-too-large' });
105
+ return;
106
+ }
107
+ }
108
+ } catch {
109
+ sendJson(res, 400, { ok: false, error: 'body-read-failed' });
110
+ return;
111
+ }
112
+ let payload;
113
+ try {
114
+ payload = JSON.parse(raw);
115
+ } catch {
116
+ sendJson(res, 400, { ok: false, error: 'invalid-json' });
117
+ return;
118
+ }
119
+ const parsed = parsePeerPayload(payload);
120
+ if (!parsed.ok) {
121
+ sendJson(res, 400, { ok: false, error: parsed.error });
122
+ return;
123
+ }
124
+ const target = agents?.get?.(parsed.to) ?? null;
125
+ if (target === null || typeof target.followup !== 'function') {
126
+ sendJson(res, 404, { ok: false, error: 'target-not-live', to: parsed.to });
127
+ return;
128
+ }
129
+ const message = peerMessage(parsed);
130
+ try {
131
+ await target.followup(message);
132
+ } catch (error) {
133
+ log?.warn?.(`[dsh-continuity] peer followup failed: ${error?.message ?? error}`);
134
+ sendJson(res, 502, { ok: false, error: 'followup-failed', message: String(error?.message ?? error) });
135
+ return;
136
+ }
137
+ sendJson(res, 200, {
138
+ ok: true,
139
+ to: parsed.to,
140
+ from: parsed.from,
141
+ queued: 'next-turn',
142
+ id: message.id,
143
+ });
144
+ }
package/src/policy.mjs ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Pure band-controller policy. No DSH imports, no I/O, no side effects:
3
+ * the adapter and its tests both call these functions.
4
+ *
5
+ * The policy answers one question per turn: "how many tokens should this
6
+ * compaction pass remove?" It does not decide *what* to remove; the engine
7
+ * chooses the oldest balanced range (or a prune set) and does it.
8
+ */
9
+
10
+ /**
11
+ * Cache-first band: one pass at the high water mark moves the oldest contiguous
12
+ * range toward `low`, so the number of prefix rewrites is minimised. The pass is
13
+ * bounded by `maxPerBatch` and by the protected tail; with a fixed baseline the
14
+ * effective floor can be above `low` (protect + baseline). `setpoint === high`
15
+ * is deliberate: the proportional band would otherwise add smaller passes
16
+ * between them, and every pass invalidates the provider's cached suffix. The
17
+ * default hard cap is 515k of the 1,048k window: high 60% W starts the drain,
18
+ * low 40% W is the target, and emergency 65% W is the 515k last-resort trigger.
19
+ * The high→emergency gap is 5% W (39,628), deliberately above minAction 24k.
20
+ *
21
+ * Measured on the 53-pass Continuity session: 698,108 tokens were removed in
22
+ * total; at this band a pass is ~158k, so the same history is ~5 passes, not 53.
23
+ */
24
+ export const CACHE_FIRST_POLICY = Object.freeze({
25
+ /** `setpoint === high`: no proportional pass fires before the drain. */
26
+ setpoint: 0.60,
27
+ /** Above this fraction, start a pass whose budget targets `low` (upper bound). */
28
+ high: 0.60,
29
+ /** Target request when above `high`; effective floor is protect + baseline. */
30
+ low: 0.40,
31
+ /** Above this fraction, ignore `maxPerBatch` and drain to `emergencyTo`. */
32
+ emergency: 0.65,
33
+ /** Drain target; below `high` so one emergency does not chain into band-high. */
34
+ emergencyTo: 0.42,
35
+ /** Proportional gain (only used by the gradual variant). */
36
+ k: 0.4,
37
+ /** Hard cap on tokens folded in one pass: enough to drain high→low. */
38
+ maxPerBatch: 190000,
39
+ /** Do nothing below this many tokens: avoids rewriting the prefix for a sliver. */
40
+ minAction: 24000,
41
+ /** Never fold below this fraction of the window (recent verbatim tail). */
42
+ protect: 0.30,
43
+ });
44
+
45
+ /**
46
+ * Same water marks with a proportional zone between 50% and 60%. Smoother
47
+ * average context, but each proportional pass is another prefix invalidation;
48
+ * use only when cache hit rate is not the priority.
49
+ */
50
+ export const CACHE_GRADUAL_POLICY = Object.freeze({
51
+ ...CACHE_FIRST_POLICY,
52
+ setpoint: 0.50,
53
+ });
54
+
55
+ /** The engine default. Kept as a named alias for existing callers/tests. */
56
+ export const DEFAULT_POLICY = CACHE_FIRST_POLICY;
57
+
58
+ const NAMES = Object.keys(DEFAULT_POLICY);
59
+
60
+ /** Coerce partial config into a complete, validated policy. */
61
+ export function resolvePolicy(overrides = {}) {
62
+ if (overrides == null || typeof overrides !== 'object' || Array.isArray(overrides)) {
63
+ throw new TypeError('policy must be an object');
64
+ }
65
+ const unknown = Object.keys(overrides).filter((key) => !NAMES.includes(key));
66
+ if (unknown.length) throw new TypeError(`unknown policy key: ${unknown.join(', ')}`);
67
+ const policy = { ...DEFAULT_POLICY };
68
+ for (const [key, value] of Object.entries(overrides)) {
69
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
70
+ throw new TypeError(`policy.${key} must be a finite number`);
71
+ }
72
+ if (value < 0) throw new TypeError(`policy.${key} must be >= 0`);
73
+ policy[key] = value;
74
+ }
75
+ for (const key of ['setpoint', 'high', 'low', 'emergency', 'emergencyTo', 'protect']) {
76
+ if (policy[key] > 1) throw new TypeError(`policy.${key} must be <= 1`);
77
+ }
78
+ if (!(policy.low < policy.high && policy.high <= policy.emergency && policy.emergency <= 1)) {
79
+ throw new TypeError('policy must satisfy low < high <= emergency <= 1');
80
+ }
81
+ if (!(policy.setpoint > policy.low && policy.setpoint <= policy.high)) {
82
+ throw new TypeError('policy must satisfy low < setpoint <= high');
83
+ }
84
+ if (policy.emergencyTo >= policy.emergency) {
85
+ throw new TypeError('policy.emergencyTo must be below policy.emergency');
86
+ }
87
+ if (policy.maxPerBatch <= 0) throw new TypeError('policy.maxPerBatch must be > 0');
88
+ if (policy.minAction < 0) throw new TypeError('policy.minAction must be >= 0');
89
+ return Object.freeze(policy);
90
+ }
91
+
92
+ /**
93
+ * Calibrate `protect` once, at the first band-high trigger, from the
94
+ * triggering request's pre-fold baseline. The value is frozen for the session;
95
+ * later drift is reported, never re-applied.
96
+ */
97
+ export function calibrateProtect({
98
+ low,
99
+ windowTokens,
100
+ baselineTokens,
101
+ marginRatio = 0.01,
102
+ min = 0.10,
103
+ max = 0.20,
104
+ } = {}) {
105
+ const values = [low, windowTokens, baselineTokens, marginRatio, min, max];
106
+ if (values.some((value) => !Number.isFinite(value)) || windowTokens <= 0) return null;
107
+ const marginTokens = marginRatio * windowTokens;
108
+ const raw = (low * windowTokens - baselineTokens - marginTokens) / windowTokens;
109
+ const protect = Math.min(max, Math.max(min, raw));
110
+ return {
111
+ protect,
112
+ raw,
113
+ marginTokens,
114
+ baselineTokens,
115
+ clamped: protect !== raw,
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Plan one pass.
121
+ *
122
+ * @param {{ estTokens: number, windowTokens: number, policy?: object }} input
123
+ * `estTokens` is the measured context occupancy; `windowTokens` is the
124
+ * model's usable window after the output reserve.
125
+ * @returns {{ budget: number, reason: 'invalid'|'below-setpoint'|'deadband'|'setpoint'|'band-high'|'emergency'|'protected', target: number|null }}
126
+ */
127
+ export function planFold({ estTokens, windowTokens, policy = DEFAULT_POLICY }) {
128
+ const p = policy;
129
+ if (!Number.isFinite(estTokens) || !Number.isFinite(windowTokens) || windowTokens <= 0) {
130
+ return { budget: 0, reason: 'invalid', target: null };
131
+ }
132
+ if (estTokens <= p.setpoint * windowTokens) {
133
+ return { budget: 0, reason: 'below-setpoint', target: p.setpoint * windowTokens };
134
+ }
135
+
136
+ let budget;
137
+ let reason;
138
+ let target;
139
+ if (estTokens > p.emergency * windowTokens) {
140
+ target = p.emergencyTo * windowTokens;
141
+ budget = estTokens - target;
142
+ reason = 'emergency';
143
+ } else if (estTokens > p.high * windowTokens) {
144
+ target = p.low * windowTokens;
145
+ budget = estTokens - target;
146
+ reason = 'band-high';
147
+ } else {
148
+ target = p.setpoint * windowTokens;
149
+ budget = p.k * (estTokens - target);
150
+ reason = 'setpoint';
151
+ }
152
+
153
+ // Never fold into the protected recent window.
154
+ const foldable = Math.max(0, estTokens - p.protect * windowTokens);
155
+ budget = Math.min(budget, foldable);
156
+ if (reason !== 'emergency') budget = Math.min(budget, p.maxPerBatch);
157
+
158
+ if (budget <= 0) return { budget: 0, reason: 'protected', target };
159
+ if (reason !== 'emergency' && budget < p.minAction) {
160
+ return { budget: 0, reason: 'deadband', target };
161
+ }
162
+ return { budget, reason, target };
163
+ }
164
+
165
+ /** True when the band is above its setpoint but the deadband is withholding action. */
166
+ export function shouldWait({ estTokens, windowTokens, policy = DEFAULT_POLICY }) {
167
+ return planFold({ estTokens, windowTokens, policy }).reason === 'deadband';
168
+ }
169
+
170
+ export default DEFAULT_POLICY;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * save 预检(纯函数):把“我每次 save 前要手查的几件事”合成一次计算。
3
+ *
4
+ * 输入:会话事件 + 轮标签行 + 要折叠的 seq 区间
5
+ * 输出:
6
+ * turns 区间内出现过的轮
7
+ * untaggedTurns 区间内没有轮标签的轮
8
+ * tagCounts 每个标签覆盖多少条消息(用于判断“这一折是不是混了几件事”)
9
+ * plan 按标签切分后的建议段(只有 >1 个标签时才有意义)
10
+ * t0Candidates 区间内“没有标签且用户文本够长”的轮(新事物候选,最多 5 条)
11
+ */
12
+ import { visibleText } from './t0.mjs';
13
+
14
+ export function buildPreflight({ events = [], turnTagRows = [], fromSeq = 0, toSeq = 0, maxCandidates = 5 } = {}) {
15
+ // 没传区间时(save 不带 fromSeq/toSeq),不能把 toSeq 当 0 —— 那会把所有事件都过滤掉。
16
+ const effectiveTo = Number.isFinite(toSeq) && toSeq > 0 ? toSeq : Infinity;
17
+ const tagged = new Map();
18
+ for (const row of Array.isArray(turnTagRows) ? turnTagRows : []) {
19
+ const turn = Number(row?.turn);
20
+ if (Number.isInteger(turn)) tagged.set(turn, String(row?.tag ?? ''));
21
+ }
22
+
23
+ const turnSeqs = new Map();
24
+ const userText = new Map();
25
+ for (const event of Array.isArray(events) ? events : []) {
26
+ const seq = Number(event?.seq);
27
+ if (!Number.isInteger(seq) || seq < fromSeq || seq > effectiveTo) continue;
28
+ if (event?.type !== 'user/message' && event?.type !== 'assistant/message') continue;
29
+ const turn = Number(event?.data?.turn);
30
+ if (!Number.isInteger(turn)) continue;
31
+ const bucket = turnSeqs.get(turn) ?? [];
32
+ bucket.push(seq);
33
+ turnSeqs.set(turn, bucket);
34
+ if (event.type === 'user/message') {
35
+ const text = visibleText(event).replace(/\s+/g, ' ').trim();
36
+ if (text.length > 0) userText.set(turn, text);
37
+ }
38
+ }
39
+
40
+ const turns = [...turnSeqs.keys()].sort((a, b) => a - b);
41
+ const untaggedTurns = turns.filter((turn) => !tagged.has(turn) || tagged.get(turn) === '');
42
+
43
+ const tagCounts = new Map();
44
+ const planMap = new Map();
45
+ for (const turn of turns) {
46
+ const tag = tagged.get(turn) ?? '(无)';
47
+ const count = turnSeqs.get(turn).length;
48
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + count);
49
+ const seqs = turnSeqs.get(turn);
50
+ const entry = planMap.get(tag) ?? { tag, from: Math.min(...seqs), to: Math.max(...seqs), count: 0, turns: 0 };
51
+ entry.from = Math.min(entry.from, ...seqs);
52
+ entry.to = Math.max(entry.to, ...seqs);
53
+ entry.count += count;
54
+ entry.turns += 1;
55
+ planMap.set(tag, entry);
56
+ }
57
+
58
+ const plan = [...planMap.values()].sort((a, b) => a.from - b.from);
59
+ const t0Candidates = turns
60
+ .filter((turn) => untaggedTurns.includes(turn) && (userText.get(turn) ?? '').length >= 20)
61
+ .slice(0, maxCandidates)
62
+ .map((turn) => ({ turn, seq: Math.min(...turnSeqs.get(turn)), head: (userText.get(turn) ?? '').slice(0, 60) }));
63
+
64
+ return {
65
+ turns: turns.length,
66
+ untaggedTurns,
67
+ tagCounts: Object.fromEntries([...tagCounts.entries()].sort((a, b) => b[1] - a[1])),
68
+ splitSuggested: tagCounts.size > 1,
69
+ plan,
70
+ t0Candidates,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * 健康摘要(只读):覆盖率 + 投影状态。
76
+ *
77
+ * 这两项以前要手跑 selfcheck;现在跟着 save 回执一起给出,失败才需动作。
78
+ */
79
+ export function summarizeHealth({ events = [], threads = [], projection = null, now = Date.now() } = {}) {
80
+ const messageSeqs = [];
81
+ for (const event of Array.isArray(events) ? events : []) {
82
+ if (event?.type !== 'user/message' && event?.type !== 'assistant/message') continue;
83
+ const seq = Number(event?.seq);
84
+ if (Number.isInteger(seq)) messageSeqs.push(seq);
85
+ }
86
+ const ranges = [];
87
+ for (const thread of Array.isArray(threads) ? threads : []) {
88
+ for (const segment of thread?.segments ?? []) {
89
+ const from = Number(segment?.fromSeq);
90
+ const to = Number(segment?.toSeq);
91
+ if (Number.isInteger(from) && Number.isInteger(to)) ranges.push([from, to]);
92
+ }
93
+ }
94
+ let uncovered = 0;
95
+ for (const seq of messageSeqs) {
96
+ if (!ranges.some(([from, to]) => seq >= from && seq <= to)) uncovered += 1;
97
+ }
98
+ const coveragePct = messageSeqs.length === 0 ? 100 : Number(((messageSeqs.length - uncovered) / messageSeqs.length * 100).toFixed(2));
99
+
100
+ const ageMin = projection === null || typeof projection?.at !== 'string'
101
+ ? null
102
+ : Number(((now - Date.parse(projection.at)) / 60000).toFixed(1));
103
+
104
+ return {
105
+ coveragePct,
106
+ uncovered,
107
+ messages: messageSeqs.length,
108
+ projectionStage: projection?.stage ?? null,
109
+ projectionAgeMin: ageMin,
110
+ projected: Number(projection?.projected ?? 0),
111
+ skipped: projection?.skipped ?? {},
112
+ };
113
+ }