@jjh32456/dsh-longlive 1.6.7 → 1.8.0

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 (289) hide show
  1. package/CHANGELOG.md +70 -1508
  2. package/LICENSE +1 -1
  3. package/QUICKSTART.md +34 -13
  4. package/README.en.md +18 -7
  5. package/README.md +34 -9
  6. package/SPEC.md +5 -5
  7. package/cordis.patch.yml +23 -9
  8. package/docs/ANSWER-GUARD.md +92 -0
  9. package/docs/ARCHITECTURE.md +4 -3
  10. package/docs/AUTO-RECALL-WIRING.md +1 -1
  11. package/docs/AUTO-RECALL.md +15 -9
  12. package/docs/CONFIG.md +168 -35
  13. package/docs/ERRORS.md +17 -5
  14. package/docs/FAQ.md +7 -7
  15. package/docs/FEATURES.md +96 -28
  16. package/docs/MIGRATION-1.6.0.md +2 -2
  17. package/docs/OPERATIONS.md +6 -6
  18. package/docs/PANEL-INTEGRATION.md +6 -0
  19. package/docs/PRIVACY.md +88 -0
  20. package/docs/QUICKSTART.md +5 -5
  21. package/docs/README.en.md +4 -2
  22. package/docs/RESIDENT-WATCHER.md +1 -1
  23. package/docs/SESSION-WATCH.md +6 -6
  24. package/docs/TROUBLESHOOTING.md +4 -4
  25. package/package.json +47 -117
  26. package/runbook.md +2 -2
  27. package/scripts/backfill-auto-cards.mjs +98 -0
  28. package/scripts/install-surgery-task.ps1 +5 -2
  29. package/scripts/lib/session-watch-fixture.mjs +1 -1
  30. package/scripts/longlive-doctor.mjs +6 -0
  31. package/scripts/migrate-180.mjs +163 -0
  32. package/scripts/recall-index-build.mjs +128 -0
  33. package/scripts/session-watch-drill.mjs +1 -1
  34. package/scripts/surgery-run.cmd +1 -1
  35. package/scripts/unattended-surgery.ps1 +5 -2
  36. package/scripts/verify-card-anchors.mjs +138 -0
  37. package/src/actions/audit.js +125 -0
  38. package/src/actions/evidence.js +122 -0
  39. package/src/actions/fs.js +162 -0
  40. package/src/activity/index.js +760 -0
  41. package/src/activity/model.js +351 -0
  42. package/src/activity/store.js +644 -0
  43. package/src/activity/thinking.js +176 -0
  44. package/src/adapters/constants.js +11 -0
  45. package/src/adapters/engram/data/aliases.json +51 -0
  46. package/src/adapters/engram/data/policy.json +8 -0
  47. package/src/adapters/engram/data/routing-rules.json +546 -0
  48. package/src/adapters/engram/index.js +235 -0
  49. package/src/adapters/registry.js +315 -0
  50. package/src/answer/audit.js +193 -0
  51. package/src/answer/evidence.js +30 -0
  52. package/src/answer/guard.js +1370 -0
  53. package/src/answer/index.js +1067 -0
  54. package/src/answer/query.js +111 -0
  55. package/src/answer/second-hop.mjs +101 -0
  56. package/src/auto-truncate.js +1 -1
  57. package/src/config.js +171 -47
  58. package/src/deps.js +6 -4
  59. package/src/features.js +182 -6
  60. package/src/heartbeat.js +156 -23
  61. package/src/identity/handover.js +52 -1
  62. package/src/identity/index.js +13 -5
  63. package/src/identity/inject-position.js +2 -2
  64. package/src/index.js +97 -7
  65. package/src/lifecycle/gap-clocks.js +328 -0
  66. package/src/lifecycle/index.js +269 -0
  67. package/src/live.js +22 -2
  68. package/src/memory/activity-capture.js +229 -0
  69. package/src/memory/anchors.js +194 -0
  70. package/src/memory/audit-records.js +222 -0
  71. package/src/memory/auto-cards.js +401 -0
  72. package/src/memory/capture.js +442 -0
  73. package/src/memory/card-anchors.js +203 -0
  74. package/src/memory/conflicts.js +106 -0
  75. package/src/memory/decay.js +311 -0
  76. package/src/memory/distill.js +471 -0
  77. package/src/memory/event-index.js +917 -0
  78. package/src/memory/evidence-chain.js +295 -0
  79. package/src/memory/evidence.js +145 -0
  80. package/src/memory/filter-runner.js +1 -1
  81. package/src/memory/filter.js +5 -1
  82. package/src/memory/forget-v2.js +410 -0
  83. package/src/memory/forget.js +71 -0
  84. package/src/memory/frames-activation.js +247 -0
  85. package/src/memory/fts-query.js +174 -0
  86. package/src/memory/governance.js +604 -0
  87. package/src/memory/index.js +1 -0
  88. package/src/memory/l2-gate.js +169 -0
  89. package/src/memory/lock.js +121 -0
  90. package/src/memory/migrate.js +139 -0
  91. package/src/memory/recall-v2.js +223 -0
  92. package/src/memory/recall.js +65 -2
  93. package/src/memory/records.js +425 -0
  94. package/src/memory/retention.js +128 -0
  95. package/src/memory/review.js +124 -0
  96. package/src/memory/trust.js +88 -0
  97. package/src/memory/version-chain.js +264 -0
  98. package/src/memory/wal.js +137 -0
  99. package/src/memory/working-frame.js +516 -0
  100. package/src/metrics/classify.mjs +5 -3
  101. package/src/metrics/index.mjs +21 -1
  102. package/src/metrics/prestep-latency.mjs +300 -0
  103. package/src/metrics/store.mjs +25 -0
  104. package/src/policy-profile.js +18 -3
  105. package/src/recall/anaphora.mjs +87 -0
  106. package/src/recall/cards.mjs +7 -0
  107. package/src/recall/embedding.mjs +8 -8
  108. package/src/recall/entities.mjs +112 -0
  109. package/src/recall/index.js +235 -19
  110. package/src/recall/inject-order.mjs +10 -8
  111. package/src/recall/privacy.mjs +105 -16
  112. package/src/recall/recall-core.mjs +116 -10
  113. package/src/recall/shared-visibility.js +302 -0
  114. package/src/recall/time-normalizer.mjs +196 -0
  115. package/src/role/activation.js +191 -0
  116. package/src/role/attention.js +318 -0
  117. package/src/role/base.js +36 -0
  118. package/src/role/channels.js +423 -0
  119. package/src/role/index.js +425 -0
  120. package/src/role/interfaces.js +115 -0
  121. package/src/role/personality.js +370 -0
  122. package/src/role/readers.js +151 -0
  123. package/src/role/relationships.js +477 -0
  124. package/src/role/store.js +202 -0
  125. package/src/role/writeprotect.js +373 -0
  126. package/src/router/aliases.json +176 -25
  127. package/src/router/background/agent-runner.mjs +1 -1
  128. package/src/router/background/anchor.mjs +1 -1
  129. package/src/router/background/audit.mjs +2 -2
  130. package/src/router/background/budget-ledger.mjs +2 -2
  131. package/src/router/background/host-sim.mjs +1 -1
  132. package/src/router/background/memory.mjs +4 -4
  133. package/src/router/background/queue.mjs +1 -1
  134. package/src/router/background/retry.mjs +1 -1
  135. package/src/router/background/router.mjs +1 -1
  136. package/src/router/background/stream.mjs +1 -1
  137. package/src/router/background/tool-policy.mjs +1 -1
  138. package/src/router/design-headings.json +5 -5
  139. package/src/router/face/config.mjs +1 -1
  140. package/src/router/face/identity.mjs +3 -3
  141. package/src/router/judge.mjs +8 -7
  142. package/src/router/policies/generic.json +21 -4
  143. package/src/router/route.js +109 -22
  144. package/src/router/router.mjs +10 -6
  145. package/src/router/routing-rules.json +260 -445
  146. package/src/router/run-sim.mjs +8 -8
  147. package/src/router/runtime.js +23 -12
  148. package/src/router/service/board.mjs +95 -15
  149. package/src/router/service/config.mjs +5 -5
  150. package/src/router/service/context.mjs +1 -1
  151. package/src/router/service/hint.mjs +14 -0
  152. package/src/router/service/queue.mjs +1 -1
  153. package/src/router/service/service.mjs +27 -11
  154. package/src/router/shadow.mjs +4 -3
  155. package/src/surgery-recovery.js +2 -2
  156. package/src/tool-output.js +2 -2
  157. package/src/tools.js +39 -7
  158. package/src/truncate-visible.js +2 -2
  159. package/src/ui/actions.js +1312 -0
  160. package/src/ui/panel-api.js +367 -0
  161. package/src/ui/panel-auth.js +251 -0
  162. package/src/ui/panel-integrity.js +243 -0
  163. package/src/ui/routes-readonly.js +695 -0
  164. package/src/ui/routes.js +3 -0
  165. package/docs/DEV.md +0 -279
  166. package/docs/PRESENCE-BUS-WIRING.md +0 -491
  167. package/docs/PRESENCE-BUS.md +0 -694
  168. package/docs/SURGERY.md +0 -186
  169. package/scripts/mark-core-cards.mjs +0 -289
  170. package/scripts/pack.mjs +0 -392
  171. package/scripts/router-proposals.mjs +0 -386
  172. package/src/router/fixtures/M1.json +0 -16
  173. package/src/router/fixtures/M2.json +0 -16
  174. package/src/router/fixtures/M3.json +0 -20
  175. package/src/router/fixtures/M4.json +0 -20
  176. package/src/router/fixtures/M5.json +0 -45
  177. package/src/router/fixtures/M6.json +0 -53
  178. package/src/router/fixtures/M7.json +0 -46
  179. package/src/router/fixtures/S1.json +0 -20
  180. package/src/router/fixtures/S2.json +0 -23
  181. package/src/router/fixtures/S3.json +0 -20
  182. package/src/router/fixtures/S4.json +0 -34
  183. package/src/router/fixtures/S5.json +0 -43
  184. package/src/router/fixtures/S6.json +0 -47
  185. package/src/router/fixtures/T1.json +0 -23
  186. package/src/router/fixtures/T2.json +0 -16
  187. package/src/router/fixtures/T3.json +0 -19
  188. package/src/router/fixtures/T4.json +0 -16
  189. package/src/router/fixtures/X1.json +0 -17
  190. package/src/router/fixtures/X2.json +0 -20
  191. package/src/router/gen-design-headings.mjs +0 -107
  192. package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
  193. package/test/auto-recall.test.mjs +0 -474
  194. package/test/auto-truncate.test.mjs +0 -931
  195. package/test/autobind.test.mjs +0 -385
  196. package/test/bus.test.mjs +0 -1572
  197. package/test/cli-guard.test.mjs +0 -198
  198. package/test/conclusion.test.mjs +0 -408
  199. package/test/config-schema.test.mjs +0 -204
  200. package/test/d6-output.test.mjs +0 -194
  201. package/test/d6.test.mjs +0 -395
  202. package/test/deps.test.mjs +0 -342
  203. package/test/doctor-setup.test.mjs +0 -297
  204. package/test/embedding-recall.test.mjs +0 -347
  205. package/test/feature-flags.test.mjs +0 -568
  206. package/test/filter-shadow.test.mjs +0 -170
  207. package/test/fixtures/baseline-0.8.0-registration.json +0 -556
  208. package/test/fixtures/d6-header-tools.json +0 -1
  209. package/test/fixtures/lock-child.mjs +0 -67
  210. package/test/fixtures/pre-step-container.mjs +0 -100
  211. package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
  212. package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
  213. package/test/fixtures/profile-session-longlive-config.json +0 -20
  214. package/test/fixtures/proposal-cas-child.mjs +0 -66
  215. package/test/host-engine-status.test.mjs +0 -307
  216. package/test/hot-truncate.test.mjs +0 -1349
  217. package/test/identity-card-marker.test.mjs +0 -307
  218. package/test/identity-router/run.mjs +0 -721
  219. package/test/integration-pre-step.test.mjs +0 -553
  220. package/test/live.test.mjs +0 -1020
  221. package/test/maintenance-schedule.test.mjs +0 -538
  222. package/test/memory.test.mjs +0 -2828
  223. package/test/metrics.test.mjs +0 -478
  224. package/test/orchestrator.test.mjs +0 -266
  225. package/test/p1_probe.mjs +0 -97
  226. package/test/packaging.test.mjs +0 -182
  227. package/test/portability.test.mjs +0 -343
  228. package/test/rc4-regressions.test.mjs +0 -562
  229. package/test/rc5-regressions.test.mjs +0 -197
  230. package/test/rc6-chinese.test.mjs +0 -394
  231. package/test/router-1100/apply-off.test.mjs +0 -175
  232. package/test/router-1100/budget-reconcile.test.mjs +0 -215
  233. package/test/router-1100/config-context.test.mjs +0 -211
  234. package/test/router-1100/helpers.mjs +0 -129
  235. package/test/router-1100/hint-envelope.test.mjs +0 -150
  236. package/test/router-1100/lock-crossproc.test.mjs +0 -206
  237. package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
  238. package/test/router-1100/router-apply.test.mjs +0 -372
  239. package/test/router-1100/router-fixb.test.mjs +0 -602
  240. package/test/router-1100/router-proposals.test.mjs +0 -405
  241. package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
  242. package/test/router-1100/router-security-cr.test.mjs +0 -333
  243. package/test/router-1100/service-lifecycle.test.mjs +0 -220
  244. package/test/router-1100/tools-contract.test.mjs +0 -164
  245. package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
  246. package/test/router-1100/v6-contract.test.mjs +0 -158
  247. package/test/router-1100/v6b-contract.test.mjs +0 -293
  248. package/test/router-1100/v6c-contract.test.mjs +0 -249
  249. package/test/router-1100/wal-rotation.test.mjs +0 -375
  250. package/test/router-1100/wp1-config.test.mjs +0 -170
  251. package/test/router-1100/wp1-default-off.test.mjs +0 -77
  252. package/test/router-1100/wp1-model-status.test.mjs +0 -90
  253. package/test/router-1100/wp1-status.test.mjs +0 -220
  254. package/test/router-1100/wp1-tools.test.mjs +0 -146
  255. package/test/router-1100/wp5-cli.test.mjs +0 -240
  256. package/test/router-1100/wp5-model-auto.test.mjs +0 -418
  257. package/test/router-1100/wp6-merge.test.mjs +0 -386
  258. package/test/router-1100/wp6-wake.test.mjs +0 -502
  259. package/test/router-background/audit.test.mjs +0 -37
  260. package/test/router-background/budget-ledger.test.mjs +0 -330
  261. package/test/router-background/cross-process.test.mjs +0 -222
  262. package/test/router-background/fixtures/cross-child.mjs +0 -85
  263. package/test/router-background/fixtures/real-shapes.mjs +0 -239
  264. package/test/router-background/router.test.mjs +0 -1044
  265. package/test/router-background/stream-retry.test.mjs +0 -358
  266. package/test/router-background/tool-agent.test.mjs +0 -384
  267. package/test/router-face/bridge.test.mjs +0 -169
  268. package/test/router-face/face.test.mjs +0 -316
  269. package/test/router-failclosed.test.mjs +0 -466
  270. package/test/router-identity.test.mjs +0 -384
  271. package/test/router-l1-l2.contract.test.mjs +0 -1075
  272. package/test/router-memory-tools.test.mjs +0 -262
  273. package/test/router-policy.test.mjs +0 -225
  274. package/test/router-runtime.test.mjs +0 -438
  275. package/test/router-shadow.test.mjs +0 -287
  276. package/test/router.test.mjs +0 -903
  277. package/test/run-rc.mjs +0 -299
  278. package/test/run.mjs +0 -2469
  279. package/test/safety-defaults.test.mjs +0 -179
  280. package/test/seeded-load.test.mjs +0 -436
  281. package/test/session-original.test.mjs +0 -338
  282. package/test/surgery-recovery.test.mjs +0 -936
  283. package/test/t7_drive.mjs +0 -151
  284. package/test/tool-output.test.mjs +0 -393
  285. package/test/tool-schema.test.mjs +0 -177
  286. package/test/tools_contract.test.mjs +0 -716
  287. package/test/truncate-visible.test.mjs +0 -1150
  288. package/test/ui-flags.test.mjs +0 -477
  289. package/test/v4-hardening.test.mjs +0 -427
@@ -0,0 +1,300 @@
1
+ // src/metrics/prestep-latency.mjs — B3 时延责任归因(pre-step 编排器 observability)
2
+ //
3
+ // 目的:把「思考/回忆时间(t_question → t_evidence_ready)」拆成可归因的阶段:
4
+ // boot(开机卡)/ shared(共享活动流)/ working(工作帧)/ recall(召回编排)/
5
+ // inject(消息合并入 messages)/ budget(召回预算裁剪)
6
+ // 另记 degradations[]:gate 跳过 / embedding 回退 / 零命中 / 预算裁剪 / 异常 fail-open。
7
+ //
8
+ // 契约:**纯计时、零行为变化**。所有入口都吞异常并返回 no-op 计时器;metrics 未启用时
9
+ // 完全不写盘、不创建目录(registerMetrics 在入口调用 configurePrestepLatency)。
10
+ // 落盘:<memoryRoot>/.longlive/metrics/prestep.jsonl,每轮一行(sessionId+turn 幂等)。
11
+ // 面板/基准读取:readPrestepRows();面板接线走 store.mjs#readPrestepSummary()。
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ export const PRESTEP_LATENCY_SCHEMA = 'prestep-latency/v1';
17
+ export const PRESTEP_SUMMARY_SCHEMA = 'prestep-summary/v1';
18
+ export const PRESTEP_FILE_NAME = 'prestep.jsonl';
19
+ export const PRESTEP_PHASES = Object.freeze(['boot', 'shared', 'working', 'recall', 'inject', 'budget', 'guard']);
20
+ export const PRESTEP_DEGRADES = Object.freeze([
21
+ 'gate-skip', 'embedding-fallback', 'zero-hit', 'budget-trim', 'error', 'factory-unavailable', 'frame-skip',
22
+ ]);
23
+
24
+ const state = {
25
+ enabled: false,
26
+ memoryRoot: '',
27
+ now: () => new Date(),
28
+ pending: new Map(), // key -> entry
29
+ finalized: new Map(), // key -> at (idempotency; bounded)
30
+ };
31
+ const MAX_ENTRIES = 2048;
32
+
33
+ function roundMs(value) {
34
+ return Number.isFinite(value) ? Math.round(value * 1000) / 1000 : null;
35
+ }
36
+ function nowMs() {
37
+ if (typeof process.hrtime?.bigint === 'function') return process.hrtime.bigint();
38
+ return BigInt(Date.now()) * 1_000_000n;
39
+ }
40
+ function elapsedMs(start) {
41
+ if (start === null) return null;
42
+ try {
43
+ return Number(nowMs() - start) / 1e6;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+ function safeSessionId(sessionId) {
49
+ return typeof sessionId === 'string' && sessionId.trim() !== '' ? sessionId.trim() : 'unknown';
50
+ }
51
+ function safeTurn(turn) {
52
+ return Number.isFinite(Number(turn)) ? Number(turn) : null;
53
+ }
54
+ function turnKey(sessionId, turn) {
55
+ return `${safeSessionId(sessionId)}:${safeTurn(turn) === null ? '?' : safeTurn(turn)}`;
56
+ }
57
+ function prestepFile(memoryRoot) {
58
+ const root = typeof memoryRoot === 'string' && memoryRoot.trim() !== '' ? path.resolve(memoryRoot.trim()) : '';
59
+ return root === '' ? '' : path.join(root, '.longlive', 'metrics', PRESTEP_FILE_NAME);
60
+ }
61
+ function trimMap(map) {
62
+ while (map.size > MAX_ENTRIES) {
63
+ const first = map.keys().next().value;
64
+ map.delete(first);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Called once by metrics registration. `enabled:false` makes every helper a no-op
70
+ * with zero disk IO; changing memoryRoot resets in-flight turn state.
71
+ */
72
+ export function configurePrestepLatency(options = {}) {
73
+ const memoryRoot = typeof options.memoryRoot === 'string' ? options.memoryRoot.trim() : '';
74
+ if (memoryRoot !== state.memoryRoot) {
75
+ state.pending.clear();
76
+ state.finalized.clear();
77
+ }
78
+ state.enabled = options.enabled === true && memoryRoot !== '';
79
+ state.memoryRoot = memoryRoot;
80
+ state.now = typeof options.now === 'function' ? options.now : () => new Date();
81
+ return { enabled: state.enabled, memoryRoot: state.memoryRoot };
82
+ }
83
+
84
+ export function prestepLatencyEnabled() {
85
+ return state.enabled === true;
86
+ }
87
+
88
+ export function resetPrestepLatencyForTests() {
89
+ state.pending.clear();
90
+ state.finalized.clear();
91
+ state.enabled = false;
92
+ state.memoryRoot = '';
93
+ }
94
+
95
+ function getEntry(sessionId, turn) {
96
+ const key = turnKey(sessionId, turn);
97
+ let entry = state.pending.get(key);
98
+ if (!entry) {
99
+ entry = {
100
+ key,
101
+ sessionId: safeSessionId(sessionId),
102
+ turn: safeTurn(turn),
103
+ startedAt: state.now().toISOString(),
104
+ phases: Object.fromEntries(PRESTEP_PHASES.map((name) => [name, null])),
105
+ degrades: [],
106
+ meta: {},
107
+ };
108
+ state.pending.set(key, entry);
109
+ trimMap(state.pending);
110
+ }
111
+ return entry;
112
+ }
113
+
114
+ /** Open a phase timer; always safe to call. */
115
+ export function startPrestepPhase(sessionId, turn, phase) {
116
+ if (!state.enabled || !PRESTEP_PHASES.includes(phase)) return { end() {} };
117
+ let start = null;
118
+ try {
119
+ const entry = getEntry(sessionId, turn);
120
+ start = nowMs();
121
+ return {
122
+ end(detail = null) {
123
+ if (start === null) return;
124
+ const ms = elapsedMs(start);
125
+ start = null;
126
+ if (!Number.isFinite(ms)) return;
127
+ try {
128
+ entry.phases[phase] = roundMs(ms);
129
+ if (detail !== null && typeof detail === 'object') {
130
+ for (const [name, value] of Object.entries(detail)) {
131
+ if (name === 'degrades' && Array.isArray(value)) continue;
132
+ entry.meta[name] = value;
133
+ }
134
+ }
135
+ } catch { /* timing must never break a turn */ }
136
+ },
137
+ };
138
+ } catch {
139
+ return { end() {} };
140
+ }
141
+ }
142
+
143
+ /** Record one degradation reason for this turn (unknown session/turn just creates the entry). */
144
+ export function notePrestepDegrade(sessionId, turn, input = {}) {
145
+ if (!state.enabled) return false;
146
+ try {
147
+ const entry = getEntry(sessionId, turn);
148
+ const kind = typeof input.kind === 'string' && input.kind !== '' ? input.kind : 'error';
149
+ entry.degrades.push({
150
+ kind,
151
+ phase: typeof input.phase === 'string' && PRESTEP_PHASES.includes(input.phase) ? input.phase : null,
152
+ detail: input.detail === undefined ? null : String(input.detail),
153
+ });
154
+ if (entry.degrades.length > 32) entry.degrades.splice(0, entry.degrades.length - 32);
155
+ return true;
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
160
+
161
+ /** Merge metadata into this turn's row (e.g. budget tokens, injected paths). */
162
+ export function setPrestepMeta(sessionId, turn, meta = {}) {
163
+ if (!state.enabled || meta === null || typeof meta !== 'object') return false;
164
+ try {
165
+ Object.assign(getEntry(sessionId, turn).meta, meta);
166
+ return true;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+
172
+ function dominantOf(phases) {
173
+ let name = null;
174
+ let ms = -1;
175
+ for (const key of PRESTEP_PHASES) {
176
+ const value = phases[key];
177
+ if (Number.isFinite(value) && value > ms) {
178
+ name = key;
179
+ ms = value;
180
+ }
181
+ }
182
+ return { dominant: name, dominantMs: ms >= 0 ? roundMs(ms) : null };
183
+ }
184
+
185
+ /**
186
+ * Write one row (idempotent per session:turn) and clear pending state.
187
+ * Safe to call from any pre-step handler / metrics turn-end fallback.
188
+ */
189
+ export function finalizePrestepTurn(sessionId, turn, options = {}) {
190
+ if (!state.enabled) return null;
191
+ const key = turnKey(sessionId, turn);
192
+ if (state.finalized.has(key)) return null;
193
+ let entry = state.pending.get(key);
194
+ if (!entry) entry = getEntry(sessionId, turn);
195
+ state.pending.delete(key);
196
+ state.finalized.set(key, state.now().toISOString());
197
+ trimMap(state.finalized);
198
+
199
+ let ms = 0;
200
+ let counted = 0;
201
+ for (const name of PRESTEP_PHASES) {
202
+ if (Number.isFinite(entry.phases[name])) {
203
+ ms += entry.phases[name];
204
+ counted += 1;
205
+ }
206
+ }
207
+ const { dominant, dominantMs } = dominantOf(entry.phases);
208
+ const row = {
209
+ schema: PRESTEP_LATENCY_SCHEMA,
210
+ at: state.now().toISOString(),
211
+ sessionId: entry.sessionId,
212
+ turn: entry.turn,
213
+ phases: { ...entry.phases },
214
+ dominant,
215
+ dominantMs,
216
+ ms: counted === 0 ? null : roundMs(ms),
217
+ countedPhases: counted,
218
+ degrades: entry.degrades,
219
+ meta: entry.meta,
220
+ };
221
+ const file = prestepFile(typeof options.memoryRoot === 'string' && options.memoryRoot.trim() !== '' ? options.memoryRoot : state.memoryRoot);
222
+ if (file === '') return null;
223
+ try {
224
+ fs.mkdirSync(path.dirname(file), { recursive: true });
225
+ fs.appendFileSync(file, `${JSON.stringify(row)}\n`, 'utf8');
226
+ } catch {
227
+ return null;
228
+ }
229
+ return row;
230
+ }
231
+
232
+ /** Read raw rows, newest last. Corrupt lines skipped; never throws. */
233
+ export function readPrestepRows(options = {}) {
234
+ const file = prestepFile(options.memoryRoot);
235
+ if (file === '') return { ok: false, code: 'E_MEMORY_ROOT_UNSET', path: '', rows: [], total: 0, skipped: 0 };
236
+ let text;
237
+ try {
238
+ text = fs.readFileSync(file, 'utf8');
239
+ } catch (error) {
240
+ if (error?.code === 'ENOENT') return { ok: true, path: file, rows: [], total: 0, skipped: 0, exists: false };
241
+ return { ok: false, code: 'E_PRESTEP_READ', path: file, rows: [], total: 0, skipped: 0, message: String(error?.message ?? error) };
242
+ }
243
+ const rows = [];
244
+ let skipped = 0;
245
+ for (const line of text.split('\n')) {
246
+ const trimmed = line.trim();
247
+ if (trimmed === '') continue;
248
+ try {
249
+ const row = JSON.parse(trimmed);
250
+ if (row && typeof row === 'object' && row.schema === PRESTEP_LATENCY_SCHEMA) rows.push(row);
251
+ else skipped += 1;
252
+ } catch {
253
+ skipped += 1;
254
+ }
255
+ }
256
+ const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 0;
257
+ const kept = limit > 0 ? rows.slice(-limit) : rows;
258
+ return { ok: true, path: file, exists: true, rows: kept, total: rows.length, skipped };
259
+ }
260
+
261
+ function avg(values) {
262
+ if (values.length === 0) return null;
263
+ return roundMs(values.reduce((sum, value) => sum + value, 0) / values.length);
264
+ }
265
+ function p50(values) {
266
+ if (values.length === 0) return null;
267
+ const sorted = [...values].sort((a, b) => a - b);
268
+ return roundMs(sorted[Math.floor((sorted.length - 1) / 2)]);
269
+ }
270
+
271
+ /** Deterministic summary for the panel (#9 write-health `prestep` field) / benchmarks. */
272
+ export function summarizePrestepRows(rows = []) {
273
+ const phases = {};
274
+ for (const name of PRESTEP_PHASES) {
275
+ const values = [];
276
+ for (const row of rows) {
277
+ const value = row?.phases?.[name];
278
+ if (Number.isFinite(value)) values.push(value);
279
+ }
280
+ phases[name] = { count: values.length, avgMs: avg(values), p50Ms: p50(values), maxMs: values.length > 0 ? roundMs(Math.max(...values)) : null };
281
+ }
282
+ const dominantCounts = {};
283
+ const degradeCounts = {};
284
+ let lastAt = null;
285
+ for (const row of rows) {
286
+ if (typeof row.dominant === 'string' && row.dominant !== '') dominantCounts[row.dominant] = (dominantCounts[row.dominant] ?? 0) + 1;
287
+ for (const item of Array.isArray(row.degrades) ? row.degrades : []) {
288
+ if (typeof item?.kind === 'string') degradeCounts[item.kind] = (degradeCounts[item.kind] ?? 0) + 1;
289
+ }
290
+ if (typeof row.at === 'string' && (lastAt === null || row.at > lastAt)) lastAt = row.at;
291
+ }
292
+ return {
293
+ schema: PRESTEP_SUMMARY_SCHEMA,
294
+ count: rows.length,
295
+ lastAt,
296
+ phases,
297
+ dominantCounts,
298
+ degradeCounts,
299
+ };
300
+ }
@@ -13,10 +13,12 @@
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { summarizeRows, clampFactor, DEFAULT_FACTOR, DEFAULT_KEEP_TURNS } from './measure.mjs';
16
+ import { PRESTEP_FILE_NAME, readPrestepRows, summarizePrestepRows } from './prestep-latency.mjs';
16
17
 
17
18
  export const METRICS_DIR_NAME = 'metrics';
18
19
  export const TURNS_FILE_NAME = 'turns.jsonl';
19
20
  export const STATE_FILE_NAME = 'state.json';
21
+ export const PRESTEP_FILE = PRESTEP_FILE_NAME;
20
22
  export const LEDGER_VERSION = 1;
21
23
 
22
24
  export function metricsDir(memoryRoot) {
@@ -34,6 +36,29 @@ export function calibrationPath(memoryRoot) {
34
36
  return dir === '' ? '' : path.join(dir, STATE_FILE_NAME);
35
37
  }
36
38
 
39
+ export function prestepLedgerPath(memoryRoot) {
40
+ const dir = metricsDir(memoryRoot);
41
+ return dir === '' ? '' : path.join(dir, PRESTEP_FILE_NAME);
42
+ }
43
+
44
+ /**
45
+ * B3: read the pre-step latency ledger (`.longlive/metrics/prestep.jsonl`).
46
+ * Best-effort, never throws; corrupt lines are counted in `skipped`.
47
+ */
48
+ export function readPrestepLedger(options = {}) {
49
+ return readPrestepRows({ memoryRoot: options.memoryRoot, limit: options.limit });
50
+ }
51
+
52
+ /**
53
+ * B3: deterministic pre-step latency summary for the panel.
54
+ * Activity-writer A6 wiring: add `prestep: readPrestepSummary({ memoryRoot })` to the
55
+ * #9 write-health reader response (see B3 handoff), no new route id needed.
56
+ */
57
+ export function readPrestepSummary(options = {}) {
58
+ const rows = readPrestepRows({ memoryRoot: options.memoryRoot, limit: options.limit }).rows;
59
+ return summarizePrestepRows(rows);
60
+ }
61
+
37
62
  /** Append one row. Never throws. */
38
63
  export function appendTurnRow(row, options = {}) {
39
64
  const memoryRoot = options.memoryRoot;
@@ -2,10 +2,11 @@
2
2
  // 这一层刻意放在 src/router 之外:判定层(route.js)可以 import 它,
3
3
  // 但产品代码(index.js)不需要 import 任何带 router 字样的模块。
4
4
  import { readFileSync } from 'node:fs';
5
+ import { applyAdapterPolicy } from './adapters/registry.js';
5
6
 
6
7
  export const POLICY_PROFILE_DEFAULT = 'generic';
7
8
  export const POLICY_PROFILE_BASE = 'generic';
8
- export const POLICY_PROFILE_SOURCE = 'J8-RULINGS-2026-11-03';
9
+ export const POLICY_PROFILE_SOURCE = 'example-policy';
9
10
  export const POLICY_PROFILE_DIR = new URL('./router/policies/', import.meta.url);
10
11
 
11
12
  export const E_ROUTE_PROFILE_UNKNOWN = 'E_ROUTE_PROFILE_UNKNOWN';
@@ -417,7 +418,14 @@ export function loadPolicyProfile(name = POLICY_PROFILE_DEFAULT, options = {}) {
417
418
  if (baseProblem) return baseProblem;
418
419
  const base = baseRead.value;
419
420
  if (name === POLICY_PROFILE_BASE) {
420
- return { ok: true, name, policy: JSON.parse(JSON.stringify(base)), sources: [`${name}.json`] };
421
+ // M2-E:adapter 策略片段(第三方写作用域等)在核心 profile 校验后并入;失败仅降级。
422
+ const patched = applyAdapterPolicy(base);
423
+ if (patched.degraded) {
424
+ return { ok: true, name, policy: patched.policy, sources: [`${name}.json`], adapterDegraded: patched.errors };
425
+ }
426
+ const patchedProblem = validateBase(patched.policy, name);
427
+ if (patchedProblem) return patchedProblem;
428
+ return { ok: true, name, policy: patched.policy, sources: [`${name}.json`] };
421
429
  }
422
430
  const overlayRead = readProfile(name, dir);
423
431
  if (overlayRead.error) return overlayRead.error;
@@ -426,7 +434,14 @@ export function loadPolicyProfile(name = POLICY_PROFILE_DEFAULT, options = {}) {
426
434
  const policy = mergePolicyProfile(base, overlayRead.value);
427
435
  const mergedProblem = validateBase(policy, name);
428
436
  if (mergedProblem) return mergedProblem;
429
- return { ok: true, name, policy, sources: [`${POLICY_PROFILE_BASE}.json`, `${name}.json`] };
437
+ // M2-E:adapter 策略片段并入(失败仅降级,不阻塞核心 profile)。
438
+ const patched = applyAdapterPolicy(policy);
439
+ if (patched.degraded) {
440
+ return { ok: true, name, policy: patched.policy, sources: [`${POLICY_PROFILE_BASE}.json`, `${name}.json`], adapterDegraded: patched.errors };
441
+ }
442
+ const patchedProblem = validateBase(patched.policy, name);
443
+ if (patchedProblem) return patchedProblem;
444
+ return { ok: true, name, policy: patched.policy, sources: [`${POLICY_PROFILE_BASE}.json`, `${name}.json`] };
430
445
  }
431
446
 
432
447
  export function configurePolicyProfile(name) {
@@ -0,0 +1,87 @@
1
+ // src/recall/anaphora.mjs — B2:指代链解析(它/这个/那个/他/她/上面/刚才)
2
+ //
3
+ // 只用上下文(焦点实体 + 最近用户文本),无候选时 resolved=null(不猜)。
4
+ // 输出可回填查询:把 resolved 实体追加进查询词,提升同实体卡片命中。
5
+
6
+ export const ANAPHORA_SCHEMA = 'anaphora/v1';
7
+
8
+ export const PRONOUN_PATTERNS = Object.freeze([
9
+ { pronoun: '它', regex: /它/gu },
10
+ { pronoun: '它们', regex: /它们/gu },
11
+ { pronoun: '这个', regex: /这个/gu },
12
+ { pronoun: '那个', regex: /那个/gu },
13
+ { pronoun: '这些', regex: /这些/gu },
14
+ { pronoun: '那些', regex: /那些/gu },
15
+ { pronoun: '上面', regex: /上面|上述|前面/u },
16
+ { pronoun: '刚才', regex: /刚才|刚刚/u },
17
+ { pronoun: '他', regex: /他(?![们])/gu },
18
+ { pronoun: '她', regex: /她(?![们])/gu },
19
+ { pronoun: '他们', regex: /他们/gu },
20
+ { pronoun: '她们', regex: /她们/gu },
21
+ ]);
22
+
23
+ const STOPWORDS = new Set([
24
+ '这个', '那个', '这些', '那些', '什么', '怎么', '可以', '我们', '你们', '他们', '她们', '自己',
25
+ '一个', '一下', '现在', '然后', '因为', '所以', '但是', '如果', '已经', '还是', '不是', '没有',
26
+ '没有', '就是', '这样', '那样', '这里', '那里', '时候', '东西', '事情', '问题', '继续', '开始',
27
+ ]);
28
+
29
+ function normalizeToken(token) {
30
+ return String(token ?? '').trim();
31
+ }
32
+
33
+ /** Extract plausible entity tokens from a text (CJK 2-12 chars / ASCII words), stopwords dropped. */
34
+ export function extractEntityTokens(text, { max = 12 } = {}) {
35
+ const value = typeof text === 'string' ? text : '';
36
+ const tokens = [];
37
+ const seen = new Set();
38
+ const cjk = value.match(/[\u3400-\u9fff]{2,12}/gu) ?? [];
39
+ const ascii = value.match(/[A-Za-z][A-Za-z0-9_.-]{1,30}/gu) ?? [];
40
+ for (const token of [...cjk, ...ascii]) {
41
+ if (STOPWORDS.has(token)) continue;
42
+ if (seen.has(token)) continue;
43
+ seen.add(token);
44
+ tokens.push(token);
45
+ if (tokens.length >= max) break;
46
+ }
47
+ return tokens;
48
+ }
49
+
50
+ /**
51
+ * Resolve pronouns in `text` against focus entities first, then recent user texts.
52
+ * @returns {{schema,references:{pronoun,resolved,candidates}[],terms:string[],unresolved:string[]}}
53
+ */
54
+ export function resolveAnaphora({ text = '', recentTexts = [], focusEntities = [] } = {}) {
55
+ const query = typeof text === 'string' ? text : '';
56
+ const focus = (Array.isArray(focusEntities) ? focusEntities : [])
57
+ .map(normalizeToken)
58
+ .filter((token) => token !== '' && !STOPWORDS.has(token));
59
+ const recentTokens = [];
60
+ for (const recent of Array.isArray(recentTexts) ? recentTexts.slice(-3) : []) {
61
+ for (const token of extractEntityTokens(recent)) {
62
+ if (!recentTokens.includes(token)) recentTokens.push(token);
63
+ }
64
+ }
65
+ const candidates = [...new Set([...focus, ...recentTokens])];
66
+ const references = [];
67
+ const terms = [];
68
+ const unresolved = [];
69
+ for (const { pronoun, regex } of PRONOUN_PATTERNS) {
70
+ regex.lastIndex = 0;
71
+ if (!regex.test(query)) continue;
72
+ // Prefer the most recent focus entity; never invent one when no candidate exists.
73
+ const resolved = candidates.length > 0 ? candidates[0] : null;
74
+ references.push({ pronoun, resolved, candidates: candidates.slice(0, 5) });
75
+ if (resolved !== null && !terms.includes(resolved)) terms.push(resolved);
76
+ if (resolved === null) unresolved.push(pronoun);
77
+ }
78
+ return { schema: ANAPHORA_SCHEMA, references, terms, unresolved };
79
+ }
80
+
81
+ /** Append resolved referents to the query (no-op when nothing resolved). */
82
+ export function expandQueryWithAnaphora(queryText, resolution) {
83
+ const terms = Array.isArray(resolution?.terms) ? resolution.terms : [];
84
+ const base = String(queryText ?? '');
85
+ const fresh = terms.filter((term) => !base.includes(term));
86
+ return { text: fresh.length === 0 ? base : `${base} ${fresh.join(' ')}`, changed: fresh.length > 0, terms: fresh };
87
+ }
@@ -138,6 +138,13 @@ export function parseCard(raw, relPath) {
138
138
  salience: typeof data.salience === 'number' ? data.salience : null,
139
139
  strength: typeof data.strength === 'number' ? data.strength : null,
140
140
  date: typeof data.date === 'string' ? data.date : null,
141
+ // A3 trust ranking hints (only when authored; absent => neutral weight)
142
+ ...(typeof data.source_tier === 'string' ? { sourceTier: data.source_tier } : {}),
143
+ ...(typeof data.sourceTier === 'string' ? { sourceTier: data.sourceTier } : {}),
144
+ ...(typeof data.trust_score === 'number' ? { trustScore: data.trust_score } : {}),
145
+ ...(typeof data.trustScore === 'number' ? { trustScore: data.trustScore } : {}),
146
+ ...(typeof data.verify_count === 'number' ? { verifyCount: data.verify_count } : {}),
147
+ ...(typeof data.last_used === 'string' ? { lastUsed: data.last_used } : {}),
141
148
  localOnly,
142
149
  hasFrontmatter,
143
150
  warnings,
@@ -28,7 +28,7 @@ import path from 'node:path';
28
28
  export const EMBEDDING_VERSION = 1;
29
29
 
30
30
  export const EMBEDDING_DEFAULTS = Object.freeze({
31
- enabled: false,
31
+ enabled: true,
32
32
  modelPath: '',
33
33
  maxSeqLen: 256,
34
34
  queryInstruction: '为这个句子生成表示以用于检索相关文章:',
@@ -67,7 +67,7 @@ function text(value) {
67
67
  */
68
68
  export function normalizeEmbeddingConfig(input, { memoryRoot = null } = {}) {
69
69
  const value = input && typeof input === 'object' ? input : {};
70
- const enabled = bool(value.enabled);
70
+ const enabled = value.enabled !== false;
71
71
  const modelPath = text(value.modelPath);
72
72
  const cacheDirRaw = text(value.cacheDir);
73
73
  const cacheDir = cacheDirRaw
@@ -625,7 +625,7 @@ export async function buildVectorIndex({ cards, config, embedder, cachePath }) {
625
625
  export function createEmbeddingRuntime(input, { memoryRoot = null, warn = null } = {}) {
626
626
  const config = normalizeEmbeddingConfig(input, { memoryRoot });
627
627
  const cachePath = config.cacheDir ? path.join(config.cacheDir, 'index.json') : '';
628
- let state = null; // {ok, embedder, vectors, stats} | {ok:false, code, message}
628
+ let state = null; // {ok, embedder, vectors, stats, signature} | {ok:false, code, message}
629
629
  let loading = null;
630
630
 
631
631
  const load = async (cards) => {
@@ -653,16 +653,16 @@ export function createEmbeddingRuntime(input, { memoryRoot = null, warn = null }
653
653
  get enabled() {
654
654
  return config.enabled;
655
655
  },
656
- /** Build/refresh once; later calls reuse the in-memory index. */
657
- async ensure(cards, { refresh = false } = {}) {
658
- if (state?.ok && !refresh) return state;
656
+ /** Build/refresh once; a changed card signature rebuilds the in-memory index. */
657
+ async ensure(cards, { refresh = false, signature = undefined } = {}) {
658
+ if (state?.ok && !refresh && (signature === undefined || signature === state.signature)) return state;
659
659
  if (loading) return loading;
660
660
  loading = load(cards).then((value) => {
661
- state = value;
661
+ state = { ...value, signature };
662
662
  if (!value.ok && typeof warn === 'function') {
663
663
  warn(`[embedding] ${value.code}: ${value.message} — 回退关键词召回`);
664
664
  }
665
- return value;
665
+ return state;
666
666
  }).finally(() => {
667
667
  loading = null;
668
668
  });
@@ -0,0 +1,112 @@
1
+ // src/recall/entities.mjs — B2:实体别名表(`.longlive/entities/entities.jsonl`)
2
+ //
3
+ // 字段:{schema:'entities/v1', canonical, aliases[], kind, at}
4
+ // 只读路径给召回查询扩展用;写入口只允许宿主传入(本模块不做权限门,由调用方保证)。
5
+
6
+ import crypto from 'node:crypto';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ export const ENTITIES_SCHEMA = 'entities/v1';
11
+
12
+ export function entitiesPathOf(memoryRoot) {
13
+ return path.join(path.resolve(String(memoryRoot ?? '')), '.longlive', 'entities', 'entities.jsonl');
14
+ }
15
+
16
+ function nowIso(now) {
17
+ const value = typeof now === 'function' ? now() : new Date();
18
+ return (value instanceof Date ? value : new Date(value)).toISOString();
19
+ }
20
+
21
+ /** Read alias registry (append-only; later rows add aliases). Missing file => empty map. */
22
+ export function readEntityAliases({ memoryRoot }) {
23
+ const file = entitiesPathOf(memoryRoot);
24
+ const byCanonical = new Map();
25
+ let text = '';
26
+ try {
27
+ text = fs.readFileSync(file, 'utf8');
28
+ } catch {
29
+ return { present: false, file, aliases: {}, byCanonical: {}, badLines: 0 };
30
+ }
31
+ let bad = 0;
32
+ for (const line of text.split('\n')) {
33
+ if (line.trim() === '') continue;
34
+ let row;
35
+ try {
36
+ row = JSON.parse(line);
37
+ } catch {
38
+ bad += 1;
39
+ continue;
40
+ }
41
+ const canonical = typeof row?.canonical === 'string' ? row.canonical.trim() : '';
42
+ if (canonical === '') continue;
43
+ const aliases = Array.isArray(row.aliases) ? row.aliases.map((a) => String(a).trim()).filter(Boolean) : [];
44
+ const prev = byCanonical.get(canonical) ?? { canonical, aliases: new Set([canonical]), kind: row.kind ?? null };
45
+ for (const alias of aliases) prev.aliases.add(alias);
46
+ byCanonical.set(canonical, prev);
47
+ }
48
+ const aliases = {};
49
+ for (const [canonical, entry] of byCanonical) aliases[canonical] = [...entry.aliases];
50
+ return { present: true, file, aliases, byCanonical, badLines: bad };
51
+ }
52
+
53
+ /** Append one entity alias row (host/panel write path). Idempotent per canonical+alias set. */
54
+ export function appendEntityAlias({ memoryRoot, canonical, aliases = [], kind = null, now = () => new Date() }) {
55
+ const name = typeof canonical === 'string' ? canonical.trim() : '';
56
+ if (name === '') {
57
+ const error = new Error('E_ENTITY_CANONICAL_REQUIRED: canonical 不能为空');
58
+ error.code = 'E_ENTITY_CANONICAL_REQUIRED';
59
+ throw error;
60
+ }
61
+ const list = [...new Set([name, ...(Array.isArray(aliases) ? aliases : [])].map((a) => String(a).trim()).filter(Boolean))];
62
+ const file = entitiesPathOf(memoryRoot);
63
+ fs.mkdirSync(path.dirname(file), { recursive: true });
64
+ const row = { schema: ENTITIES_SCHEMA, canonical: name, aliases: list, kind: kind ?? null, at: nowIso(now) };
65
+ fs.appendFileSync(file, `${JSON.stringify(row)}\n`, 'utf8');
66
+ return { ok: true, file, row };
67
+ }
68
+
69
+ /** Find canonical entities mentioned in `text` (longest alias first). */
70
+ export function resolveEntityAliases(text, aliasMap, { max = 20 } = {}) {
71
+ const value = typeof text === 'string' ? text : '';
72
+ const matches = [];
73
+ const entries = [];
74
+ for (const [canonical, aliases] of Object.entries(aliasMap ?? {})) {
75
+ for (const alias of new Set([canonical, ...(Array.isArray(aliases) ? aliases : [])])) {
76
+ if (typeof alias !== 'string' || alias.trim() === '') continue;
77
+ entries.push({ canonical, alias });
78
+ }
79
+ }
80
+ entries.sort((a, b) => b.alias.length - a.alias.length || a.alias.localeCompare(b.alias));
81
+ const usedRanges = [];
82
+ for (const entry of entries) {
83
+ let from = 0;
84
+ while (matches.length < max) {
85
+ const index = value.indexOf(entry.alias, from);
86
+ if (index === -1) break;
87
+ from = index + entry.alias.length;
88
+ const end = index + entry.alias.length;
89
+ if (usedRanges.some((range) => index < range.end && end > range.start)) continue;
90
+ usedRanges.push({ start: index, end });
91
+ matches.push({ canonical: entry.canonical, alias: entry.alias, index });
92
+ }
93
+ }
94
+ matches.sort((a, b) => a.index - b.index);
95
+ return {
96
+ terms: [...new Set(matches.map((match) => match.canonical))],
97
+ matches,
98
+ };
99
+ }
100
+
101
+ /** Deterministic expansion text: canonical names appended once. */
102
+ export function expandQueryWithEntities(queryText, resolution) {
103
+ const terms = Array.isArray(resolution?.terms) ? resolution.terms : [];
104
+ if (terms.length === 0) return { text: String(queryText ?? ''), changed: false, terms };
105
+ const base = String(queryText ?? '');
106
+ const tail = terms.filter((term) => !base.includes(term)).join(' ');
107
+ return { text: tail === '' ? base : `${base} ${tail}`, changed: tail !== '', terms };
108
+ }
109
+
110
+ export function entityAliasDigest(aliasMap) {
111
+ return crypto.createHash('sha1').update(JSON.stringify(aliasMap ?? {})).digest('hex').slice(0, 12);
112
+ }