@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
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * dsh-auto-recall — 插件骨架(**未安装**,见 ../README.md 与 WIRING.md §9)。
3
3
  *
4
- * 与 WIRING.md §3 的差异(红队第三轮 mustFix 已修):
4
+ * 与 WIRING.md §3 的差异(评审第三轮 mustFix 已修):
5
5
  * - 注册用显式 `{ prepend: false }`,不跟 identity-router 抢外层;
6
6
  * identity-router 在 `next()` 之前把开机卡插到第一条真实 user 之后,auto 作为内层
7
7
  * 一定看得到它,最终顺序固定为 `[user, boot, runtime, auto]`(真实 Cordis
@@ -10,7 +10,7 @@
10
10
  * - firstUserText 只认真正的用户消息:`session/event` 里跳过 `【开机卡】`/`【自动想起】`
11
11
  * 注入(它们不再是 kind:'user',但历史消息仍可能带标记),否则开机卡会污染之后
12
12
  * 每一轮的查询词;第 1 轮内后来到达的真人消息覆盖先前候选,防外部插件用
13
- * kind:'user' 把文本插到真实提问之前(红队 ADV1);
13
+ * kind:'user' 把文本插到真实提问之前(评审 ADV1);
14
14
  * - 注入前过 privacyScan(privacy-gate 的 scanPrivacy);标题、摘要、路径都扫,
15
15
  * 路径命中整卡丢弃;
16
16
  * - identity-router 已注入的卡路径进 seenPaths(路径级排除),不再无条件排除
@@ -23,6 +23,13 @@
23
23
  */
24
24
 
25
25
  import { loadCards } from './cards.mjs';
26
+ import { sharedVisibility } from './shared-visibility.js';
27
+ import { extractTimeRefs } from './time-normalizer.mjs';
28
+ import { readEntityAliases, resolveEntityAliases, expandQueryWithEntities } from './entities.mjs';
29
+ import { resolveAnaphora, expandQueryWithAnaphora } from './anaphora.mjs';
30
+ import { appendAuditRecord } from '../memory/audit-records.js';
31
+ import { buildEvidencePackage, writeEvidencePackage } from '../memory/evidence.js';
32
+ import { fastAnswerFromRecallItems } from '../memory/recall-v2.js';
26
33
  import { buildQueryText, chooseRecall, decideTrigger, renderInjection } from './recall-core.mjs';
27
34
  import { createEmbeddingRuntime } from './embedding.mjs';
28
35
  import { privacyScan } from './privacy.mjs';
@@ -34,10 +41,13 @@ import {
34
41
  isRealUserMessage,
35
42
  mergeInjectionOrder,
36
43
  } from './inject-order.mjs';
37
- import { appendFileSync, mkdirSync } from 'node:fs';
44
+ import { appendFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
38
45
  import { dirname, join } from 'node:path';
39
46
  import { dshHome } from '../home.js';
40
47
  import { DEFAULT_MEMORY_DIRNAME } from '../memory/paths.js';
48
+ import { startPrestepPhase, notePrestepDegrade, setPrestepMeta } from '../metrics/prestep-latency.mjs';
49
+ import { readVerifiedAutoCards } from '../memory/auto-cards.js';
50
+ import { sampleContentHash } from '../memory/auto-cards.js';
41
51
 
42
52
  export const name = 'dsh-auto-recall';
43
53
  export const inject = [];
@@ -72,12 +82,13 @@ const DEFAULTS = Object.freeze({
72
82
  privacyPolicy: 'drop',
73
83
  skipSubagents: true,
74
84
  log: false,
75
- embedding: { enabled: false },
85
+ embedding: { enabled: true },
86
+ evidencePackage: true,
76
87
  });
77
88
 
78
89
  function normalize(config = {}) {
79
90
  const cfg = { ...DEFAULTS, ...config };
80
- // 红队 M1:空 / 未配置时走可移植默认链,绝不落到构建机的绝对路径。
91
+ // 评审 M1:空 / 未配置时走可移植默认链,绝不落到构建机的绝对路径。
81
92
  const root = typeof cfg.memoryRoot === 'string' ? cfg.memoryRoot.trim() : '';
82
93
  cfg.memoryRoot = root === '' ? defaultMemoryRoot() : root;
83
94
  // 注入硬上限 2K token/轮(A4);下限保留 200,避免误配把注入挤成一条。
@@ -90,7 +101,11 @@ function normalize(config = {}) {
90
101
  // 测试/嵌入时可注入消息工厂,避免依赖 @deepseek-ai/dsh-llm 的解析环境。
91
102
  cfg.createUserMessage = typeof config.createUserMessage === 'function' ? config.createUserMessage : null;
92
103
  // embedding 由 embedding.mjs 自己归一化(enabled 默认 false、modelPath 必填)。
93
- cfg.embedding = config.embedding && typeof config.embedding === 'object' ? { ...config.embedding } : { enabled: false };
104
+ cfg.embedding = config.embedding && typeof config.embedding === 'object' ? { ...config.embedding } : { enabled: true };
105
+ cfg.evidencePackage = cfg.evidencePackage !== false;
106
+ cfg.evidenceAuditKey = config.evidenceAuditKey && (Buffer.isBuffer(config.evidenceAuditKey) || typeof config.evidenceAuditKey === 'string')
107
+ ? config.evidenceAuditKey
108
+ : null;
94
109
  return cfg;
95
110
  }
96
111
 
@@ -168,19 +183,57 @@ export function apply(ctx, config = {}) {
168
183
  }
169
184
  };
170
185
 
186
+ // B2: entity alias registry is read lazily (missing file => no-op, no latency).
187
+ let entityAliasesCache = null;
188
+ const getEntityAliases = () => {
189
+ if (entityAliasesCache !== null) return entityAliasesCache;
190
+ try {
191
+ entityAliasesCache = readEntityAliases({ memoryRoot: cfg.memoryRoot });
192
+ } catch {
193
+ entityAliasesCache = { present: false, aliases: {} };
194
+ }
195
+ return entityAliasesCache;
196
+ };
197
+
171
198
  const states = new Map();
172
199
  const stateFor = (key) => {
173
200
  let st = states.get(key);
174
201
  if (!st) {
175
- st = { turn: 0, seen: new Set(), lastQuery: '', compacted: false, warned: false, firstUserText: '' };
202
+ st = { turn: 0, seen: new Set(), lastQuery: '', compacted: false, warned: false, firstUserText: '', sharedInjected: false };
176
203
  states.set(key, st);
177
204
  }
178
205
  return st;
179
206
  };
180
207
 
208
+ /** Cheap mtime/size signature so new or edited cards are visible next turn. */
209
+ const cardSignature = () => {
210
+ const parts = [];
211
+ const walk = (dir) => {
212
+ let entries = [];
213
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
214
+ for (const entry of entries) {
215
+ const full = join(dir, entry.name);
216
+ if (entry.isDirectory()) walk(full);
217
+ else if (entry.isFile() && /\.md$/i.test(entry.name)) {
218
+ try {
219
+ const st = statSync(full);
220
+ parts.push(`${full}:${st.mtimeMs}:${st.size}:${sampleContentHash(full, st.size)}`);
221
+ } catch { /* unreadable card is not part of the signature */ }
222
+ }
223
+ }
224
+ };
225
+ for (const dir of cfg.dirs) walk(join(cfg.memoryRoot, dir));
226
+ parts.sort();
227
+ return parts.join('|');
228
+ };
229
+
181
230
  let cards = null;
231
+ let cardsSignature = '';
182
232
  const getCards = () => {
183
- if (cards) return cards;
233
+ const signature = cardSignature();
234
+ if (cards && signature === cardsSignature) return cards;
235
+ cardsSignature = signature;
236
+ cards = null;
184
237
  try {
185
238
  cards = loadCards(cfg.memoryRoot, { dirs: cfg.dirs });
186
239
  } catch (error) {
@@ -239,13 +292,55 @@ export function apply(ctx, config = {}) {
239
292
  const st = stateFor(key);
240
293
  const messages = Array.isArray(decision.messages) ? decision.messages : [];
241
294
  const turn = Number.isFinite(Number(payload?.turn)) ? Number(payload.turn) : st.turn + 1;
242
-
295
+ // M2-C R1–R6: shared visibility block is injected on the FIRST turn only.
296
+ const rawSharedNow = payload?.at ?? payload?.eventTime ?? payload?.now ?? null;
297
+ const sharedNow = rawSharedNow === null || rawSharedNow === undefined
298
+ ? Date.now()
299
+ : (Number.isFinite(Number(rawSharedNow)) ? Number(rawSharedNow) : (Date.parse(String(rawSharedNow)) || Date.now()));
300
+ let sharedMessage = null;
301
+ let sharedVisibilityResult = null;
302
+ const attachShared = (value) => (sharedMessage === null ? value : { ...value, messages: mergeInjectionOrder(Array.isArray(value?.messages) ? value.messages : messages, sharedMessage) });
303
+
304
+ // B3: recall phase wraps the whole recall decision/injection (timing only).
305
+ const recallPhase = startPrestepPhase(key, turn, 'recall');
243
306
  try {
307
+ if (turn === 1 && st.sharedInjected !== true && cfg.memoryRoot) {
308
+ try {
309
+ const visibility = sharedVisibility({
310
+ memoryRoot: cfg.memoryRoot,
311
+ currentSessionId: payload?.agent?.session?.id ?? key,
312
+ now: sharedNow,
313
+ privacyScan,
314
+ });
315
+ if (visibility && visibility.block) {
316
+ const factory = await getCreateUserMessage();
317
+ if (factory) {
318
+ sharedMessage = assertInjectionMessage(factory({ content: [{ type: 'text', text: visibility.block }], source: injectionSource() }));
319
+ sharedVisibilityResult = { count: visibility.count, chars: visibility.block.length, sessions: visibility.sessions.map((session) => session.sessionId) };
320
+ st.sharedInjected = true;
321
+ }
322
+ }
323
+ } catch { /* shared visibility must never block the turn */ }
324
+ }
325
+ const recentTexts = userTexts(messages).slice(-2);
244
326
  const queryText = buildQueryText({
245
327
  firstUserText: st.firstUserText,
246
- recentTexts: userTexts(messages).slice(-2),
328
+ recentTexts,
247
329
  cwd: process.cwd(),
248
330
  });
331
+ // B2: entity aliases + anaphora expansion run before retrieval (read-only channels).
332
+ const aliases = getEntityAliases();
333
+ const aliasResolution = aliases.present ? resolveEntityAliases(queryText, aliases.aliases) : { terms: [], matches: [] };
334
+ const entityExpanded = expandQueryWithEntities(queryText, aliasResolution);
335
+ const anaphora = resolveAnaphora({ text: queryText, recentTexts });
336
+ const anaphoraExpanded = expandQueryWithAnaphora(entityExpanded.text, anaphora);
337
+ const expandedQuery = anaphoraExpanded.text;
338
+ // Event time only when the host/pre-step provides it; never Date.now() here.
339
+ const rawEventNow = payload?.at ?? payload?.eventTime ?? payload?.now ?? null;
340
+ const eventNow = rawEventNow === null || rawEventNow === undefined
341
+ ? null
342
+ : (Number.isFinite(Number(rawEventNow)) ? Number(rawEventNow) : (Date.parse(String(rawEventNow)) || null));
343
+ const queryTimeRefs = eventNow === null ? [] : extractTimeRefs(queryText, { now: eventNow });
249
344
 
250
345
  const gate = decideTrigger({
251
346
  step: Number(payload?.step),
@@ -255,12 +350,30 @@ export function apply(ctx, config = {}) {
255
350
  options: { everyNTurns: cfg.everyNTurns },
256
351
  });
257
352
  if (!gate.recall) {
353
+ notePrestepDegrade(key, turn, { kind: 'gate-skip', phase: 'recall', detail: gate.why });
258
354
  logRecall(key, turn, { items: [], rejections: [] });
259
355
  observe({ key, turn, why: gate.why, queryChars: queryText.length, tokens: 0, ms: Date.now() - started });
260
- return decision;
356
+ return attachShared(decision);
261
357
  }
262
358
 
263
359
  const loaded = getCards();
360
+ // 1.8.0 AutoCards: JSONL event→index cards are recall candidates too, so a
361
+ // matched card carries its origin anchor into the injected block.
362
+ let loadedForRecall = loaded;
363
+ if (cfg.memoryRoot) {
364
+ try {
365
+ const autoCards = readVerifiedAutoCards({ memoryRoot: cfg.memoryRoot }).map((card) => ({
366
+ path: `auto:${card.cardId}`,
367
+ title: card.title,
368
+ body: [card.title, Array.isArray(card.terms) ? card.terms.join(' ') : ''].filter(Boolean).join('\n'),
369
+ scopeIn: [],
370
+ aliases: Array.isArray(card.terms) ? card.terms : [],
371
+ cardId: card.cardId,
372
+ anchor: card.anchor,
373
+ }));
374
+ if (autoCards.length > 0) loadedForRecall = [...loaded, ...autoCards];
375
+ } catch { /* cards are additive; never block markdown recall */ }
376
+ }
264
377
  // 路径级排除:identity-router 本轮已经注入过的卡,不再注入第二遍。
265
378
  // 不再无条件排除首轮 handover/:没装 identity-router 时那张卡就是唯一开机卡。
266
379
  const seen = [...new Set([
@@ -274,25 +387,29 @@ export function apply(ctx, config = {}) {
274
387
  let vectors = null;
275
388
  if (embedding.enabled) {
276
389
  try {
277
- const ready = await embedding.ensure(loaded);
390
+ const ready = await embedding.ensure(loaded, { signature: cardsSignature });
278
391
  if (ready.ok) {
279
392
  queryVector = await embedding.embedQuery(queryText);
280
393
  vectors = ready.cardVectors;
281
394
  } else if (!embeddingWarned) {
282
395
  embeddingWarned = true;
396
+ notePrestepDegrade(key, turn, { kind: 'embedding-fallback', phase: 'recall', detail: ready.code ?? 'not-ready' });
283
397
  warn('EMBEDDING_FALLBACK', ready.code, ready.message);
284
398
  }
285
399
  } catch (error) {
286
400
  if (!embeddingWarned) {
287
401
  embeddingWarned = true;
402
+ notePrestepDegrade(key, turn, { kind: 'embedding-fallback', phase: 'recall', detail: String(error?.code ?? error?.message ?? error) });
288
403
  warn('EMBEDDING_FALLBACK', error?.code ?? error?.message ?? error);
289
404
  }
290
405
  }
291
406
  }
292
407
 
293
- const result = chooseRecall({
294
- queryText,
295
- cards: loaded,
408
+ // B3: budget phase = ranking + budget trimming inside chooseRecall.
409
+ const budgetPhase = startPrestepPhase(key, turn, 'budget');
410
+ const recallInput = {
411
+ queryText: expandedQuery,
412
+ cards: loadedForRecall,
296
413
  queryVector,
297
414
  vectors,
298
415
  budget: cfg.budget,
@@ -304,6 +421,9 @@ export function apply(ctx, config = {}) {
304
421
  privacyPolicy: cfg.privacyPolicy,
305
422
  privacyScan,
306
423
  trace: true,
424
+ // B2 channels: neutral when empty (legacy ordering unchanged).
425
+ queryTimeRefs,
426
+ entityAliases: aliases.present ? aliases.aliases : undefined,
307
427
  fusion: {
308
428
  keywordWeight: embedding.config.keywordWeight,
309
429
  vectorWeight: embedding.config.vectorWeight,
@@ -311,9 +431,97 @@ export function apply(ctx, config = {}) {
311
431
  minFusedScore: embedding.config.minFusedScore,
312
432
  },
313
433
  },
434
+ };
435
+ let result = chooseRecall(recallInput);
436
+ budgetPhase.end();
437
+ // 1.8.0 two-layer quota: at most 2 curated cards + 1 AutoCard per turn;
438
+ // curated hits always render first (tier weight 1.12 only affects order
439
+ // inside the curated layer, so existing output stays stable).
440
+ if (Array.isArray(result.items) && result.items.length > 0) {
441
+ const isAuto = (item) => String(item?.path ?? '').startsWith('auto:');
442
+ let curatedItems = result.items.filter((item) => !isAuto(item))
443
+ .sort((a, b) => (b.score * 1.12) - (a.score * 1.12));
444
+ const eventItems = result.items.filter(isAuto).slice(0, 1);
445
+ // Curated floor: event cards may never squeeze curated cards out.
446
+ if (curatedItems.length === 0 && loaded.length > 0) {
447
+ const curatedOnly = chooseRecall({ ...recallInput, cards: loaded });
448
+ if (curatedOnly.items.length > 0) {
449
+ result = curatedOnly;
450
+ curatedItems = curatedOnly.items.slice().sort((a, b) => (b.score * 1.12) - (a.score * 1.12));
451
+ }
452
+ }
453
+ result.items = [...curatedItems.slice(0, 2), ...eventItems].slice(0, cfg.maxItems);
454
+ }
455
+ // M2-C #6: every recall decision atomically publishes .longlive/evidence/latest.json.
456
+ let evidencePackage = null;
457
+ if (cfg.evidencePackage && cfg.memoryRoot) {
458
+ try {
459
+ const nowIso = new Date().toISOString();
460
+ const fast = fastAnswerFromRecallItems({ question: queryText, items: result.items });
461
+ const pkg = buildEvidencePackage({
462
+ sessionId: payload?.agent?.session?.id ?? key,
463
+ turn,
464
+ query: queryText,
465
+ timings: {
466
+ questionAt: eventNow === null ? null : new Date(eventNow).toISOString(),
467
+ queueMs: 0,
468
+ prefetchAt: new Date(started).toISOString(),
469
+ retrievedAt: nowIso,
470
+ evidenceReadyAt: nowIso,
471
+ },
472
+ anchors: result.items.map((item) => ({
473
+ path: item.path,
474
+ excerpt: `${item.title ?? ''} — ${item.excerpt ?? ''}`.trim(),
475
+ score: Number.isFinite(Number(item.score)) ? Number(item.score) : null,
476
+ l0Status: 'card',
477
+ })),
478
+ status: result.items.length > 0 ? 'ready' : 'no_evidence',
479
+ fastAnswer: fast.fastAnswer === null ? null : { text: fast.fastAnswer.text, valueType: fast.fastAnswer.valueType, anchor: fast.fastAnswer.anchor },
480
+ fallback: { used: false, reason: result.items.length > 0 ? null : 'zero-hit' },
481
+ });
482
+ const written = writeEvidencePackage({ memoryRoot: cfg.memoryRoot, pkg, auditKey: cfg.evidenceAuditKey });
483
+ evidencePackage = { queryId: pkg.queryId, status: pkg.status, anchors: pkg.anchors.length, fastAnswer: pkg.fastAnswer !== null, packageSha256: written.packageSha256 };
484
+ } catch (error) {
485
+ // Write failure must never block injection.
486
+ evidencePackage = { error: error?.code ?? 'E_EVIDENCE_WRITE' };
487
+ notePrestepDegrade(key, turn, { kind: 'evidence-write', phase: 'recall', detail: String(error?.code ?? error?.message ?? error) });
488
+ warn('EVIDENCE_WRITE_FAILED', error);
489
+ }
490
+ }
491
+ if (Number(result?.hits?.droppedByBudget ?? 0) > 0) {
492
+ notePrestepDegrade(key, turn, { kind: 'budget-trim', phase: 'budget', detail: `dropped=${result.hits.droppedByBudget}` });
493
+ }
494
+ setPrestepMeta(key, turn, {
495
+ budget: cfg.budget,
496
+ eligible: Number(result?.hits?.eligible ?? 0),
497
+ tokens: Number(result?.tokens ?? 0),
314
498
  });
315
499
 
500
+ // A5 unified audit: every real recall decision lands one .longlive/audit/records.jsonl row.
501
+ try {
502
+ appendAuditRecord({
503
+ memoryRoot: cfg.memoryRoot,
504
+ action: 'recall',
505
+ recordId: result.items.length > 0 ? result.items.map((item) => item.path).join(',') : null,
506
+ readerTier: 'host',
507
+ questionId: 'unknown',
508
+ expectedTurns: null,
509
+ answerText: null,
510
+ extra: {
511
+ turn,
512
+ queryChars: queryText.length,
513
+ expanded: expandedQuery !== queryText,
514
+ expansion: { entityTerms: aliasResolution.terms ?? [], anaphoraTerms: anaphora.terms ?? [], timeRefs: queryTimeRefs.length },
515
+ sharedVisibility: sharedVisibilityResult,
516
+ evidencePackage,
517
+ eligible: result.hits.eligible,
518
+ injected: result.items.map((item) => item.path),
519
+ tokens: result.tokens,
520
+ },
521
+ });
522
+ } catch { /* recall auditing must never block injection */ }
316
523
  if (result.items.length === 0) {
524
+ notePrestepDegrade(key, turn, { kind: 'zero-hit', phase: 'recall', detail: `eligible=${result.hits.eligible}` });
317
525
  st.lastQuery = queryText;
318
526
  st.compacted = false;
319
527
  logRecall(key, turn, result);
@@ -328,12 +536,16 @@ export function apply(ctx, config = {}) {
328
536
  privacy: result.hits.droppedByPrivacy,
329
537
  },
330
538
  });
331
- return decision; // 无命中:什么都不注入
539
+ return attachShared(decision); // 无命中:什么都不注入(共享可见块仍可注入)
332
540
  }
333
541
 
542
+ // B3: inject phase = render + message factory + merge into decision.messages.
543
+ const injectPhase = startPrestepPhase(key, turn, 'inject');
334
544
  const text = renderInjection(result, { privacyScan });
335
545
  const factory = await getCreateUserMessage();
336
546
  if (!factory) {
547
+ injectPhase.end();
548
+ notePrestepDegrade(key, turn, { kind: 'factory-unavailable', phase: 'inject' });
337
549
  logRecall(key, turn, { items: [], rejections: result.rejections });
338
550
  return decision;
339
551
  }
@@ -348,6 +560,7 @@ export function apply(ctx, config = {}) {
348
560
  for (const item of result.items) st.seen.add(item.path);
349
561
  st.lastQuery = queryText;
350
562
  st.compacted = false;
563
+ injectPhase.end();
351
564
 
352
565
  logRecall(key, turn, result);
353
566
  observe({
@@ -363,15 +576,18 @@ export function apply(ctx, config = {}) {
363
576
  },
364
577
  });
365
578
 
366
- return { ...decision, messages: mergeInjectionOrder(messages, message) };
579
+ return { ...decision, messages: mergeInjectionOrder(sharedMessage === null ? messages : mergeInjectionOrder(messages, sharedMessage), message) };
367
580
  } catch (error) {
368
581
  if (!st.warned) {
369
582
  st.warned = true;
370
583
  warn('RECALL_FAILED', error?.code ?? error?.message ?? error);
371
584
  }
585
+ notePrestepDegrade(key, turn, { kind: 'error', phase: 'recall', detail: String(error?.code ?? error?.message ?? error) });
372
586
  observe({ key, turn, why: 'error', error: String(error?.code ?? error?.message ?? error), tokens: 0 });
373
587
  logRecall(key, turn, { items: [], rejections: [] });
374
- return decision; // 插件失败不许拖垮本轮
588
+ return attachShared(decision); // 插件失败不许拖垮本轮
589
+ } finally {
590
+ try { recallPhase.end(); } catch { /* timing only */ }
375
591
  }
376
592
  }, { prepend: false });
377
593
 
@@ -381,7 +597,7 @@ export function apply(ctx, config = {}) {
381
597
  const st = stateFor(session?.id ?? 'unknown');
382
598
  if (event?.type === 'user/message' && isRealUserMessage(event?.data)) {
383
599
  const text = firstTextOf(event?.data);
384
- // 红队第三轮 mustFix #1:注入的【开机卡】/【自动想起】也是 user/message,
600
+ // 评审第三轮 mustFix #1:注入的【开机卡】/【自动想起】也是 user/message,
385
601
  // 旧代码只看 type 就把它们当成 firstUserText,导致之后每轮 query 都混进开机卡。
386
602
  //
387
603
  // v4 / ADV1:外部插件仍可能用 kind:'user' 把一段上下文插到真实提问**之前**。
@@ -12,12 +12,12 @@
12
12
  *
13
13
  * 开机卡插在**第一条真实 user 消息之后**,不再 unshift 到最前:宿主 TurnNavigator
14
14
  * 取该轮第一个可见 `kind:'user'` 节点做 tooltip 的 prompt(client.js:8373-8384),
15
- * boot 在最前会让第 1 轮显示【开机卡】而不是用户提问(红队第三轮 mustFix #2)。
15
+ * boot 在最前会让第 1 轮显示【开机卡】而不是用户提问(评审第三轮 mustFix #2)。
16
16
  *
17
17
  * auto 只负责把自己 append 到最后;不再把消息插到 boot 后面(那会得到
18
- * `[boot, auto, user, runtime]`,红队 S2 的顺序问题)。
18
+ * `[boot, auto, user, runtime]`,评审 S2 的顺序问题)。
19
19
  *
20
- * v4 / 红队第四轮 M1(2026-11-04):注入消息的 source.kind 从 'user' 改为
20
+ * v4 / 评审第四轮 M1(2026-11-04):注入消息的 source.kind 从 'user' 改为
21
21
  * 'session-reference',形状固定为
22
22
  *
23
23
  * { kind:'session-reference', form:'recall', version:1, references:[] }
@@ -35,9 +35,9 @@
35
35
  *
36
36
  * 识别契约:
37
37
  * - 本插件 / identity-router 的注入靠 kind 直接识别(不再依赖正文标记);
38
- * - 正文标记(【自动想起】/【开机卡】)继续保留,用于识别历史消息与外部 engram
38
+ * - 正文标记(【自动想起】/【开机卡】)继续保留,用于识别历史消息与外部记忆注入
39
39
  * 风格的注入,also keeps firstUserText from eating a legacy card;
40
- * - **外部插件**仍可能用 kind:'user' 把文本插到真实提问前(红队 ADV1)。识别不了
40
+ * - **外部插件**仍可能用 kind:'user' 把文本插到真实提问前(评审 ADV1)。识别不了
41
41
  * 任意纯文本,所以 firstUserText 的取值在 index.js 里按「第 1 轮最后一条真人
42
42
  * 消息」锁定,isRealUserMessage 只负责排掉已知注入形态。
43
43
  */
@@ -47,6 +47,8 @@
47
47
  * 注入只允许白名单内的 kind;坏 kind 会在 append 时被本插件拒绝,避免日志被
48
48
  * 毒化(append 接受任意非空字符串,但 adoptSessionEvent 会抛 invalid source)。
49
49
  */
50
+ import { isExternalMemoryKind } from '../adapters/constants.js';
51
+
50
52
  export const HOST_SOURCE_KINDS = Object.freeze([
51
53
  'user', 'model', 'tool', 'system-prompt', 'model-selection', 'runtime-context',
52
54
  'working-directory', 'user-approval', 'ptc-mode', 'tool-registry', 'user-rpc',
@@ -153,7 +155,7 @@ export function isAutoMessage(message) {
153
155
  * 且不是本插件 / identity-router / 已知外部注入形态。
154
156
  *
155
157
  * v4 起注入用 kind:'session-reference',kind 本身就能挡掉;这里再按正文特征
156
- * 排一层(红队 ADV1):带 `source.form` / `source.sections` 的、首行是已知标记的,
158
+ * 排一层(评审 ADV1):带 `source.form` / `source.sections` 的、首行是已知标记的,
157
159
  * 都不算真人消息。无法凭正文区分「任意外部插件写的纯文本 kind:user」——那类消息
158
160
  * 由 firstUserText 的「第 1 轮最后一条」规则处理(见 index.js)。
159
161
  * @param {object} message
@@ -181,7 +183,7 @@ export function textsOf(message) {
181
183
  }
182
184
 
183
185
  /**
184
- * 本轮已经注入的文本:identity 开机卡 + 旧 plugin:* / engram 消息。
186
+ * 本轮已经注入的文本:identity 开机卡 + 旧 plugin:* / 外部记忆注入消息。
185
187
  * 给 chooseRecall 做文本级去重;kind 合法化成 session-reference 后不能只靠 kind 判断。
186
188
  */
187
189
  export function injectedTexts(messages) {
@@ -192,7 +194,7 @@ export function injectedTexts(messages) {
192
194
  continue;
193
195
  }
194
196
  const kind = sourceKindOf(message);
195
- if (!kind.startsWith('plugin:') && !kind.includes('engram')) continue;
197
+ if (!kind.startsWith('plugin:') && !isExternalMemoryKind(kind)) continue;
196
198
  out.push(...textsOf(message));
197
199
  }
198
200
  return out;
@@ -8,11 +8,20 @@
8
8
  * 为什么用 createRequire 而不是静态 import:
9
9
  * - auto-recall 的核心要保持「零 import、可单测」;
10
10
  * - 隐私门是 ESM,但 Node ≥22 支持同步 require(ESM)(本机 v24.21.0 已验证);
11
- * - 这样门缺失时模块仍能加载,退到核心自带的保守 PII 兜底,绝不因「门没装」而放行。
11
+ * - 这样门缺失时模块仍能加载,退到核心自带的保守兜底,绝不因「门没装」而放行。
12
12
  *
13
13
  * 命中口径 = 隐私门的 verdict==='local_only' **或** 核心兜底正则命中(取并集,宁可多拦)。
14
- * 调用方:bin/eval.mjs、bin/eval-fresh.mjs、bin/recall-once.mjs、plugin/src/index.js
15
- * 以及 test/auto-recall.test.mjs(证明真门被调用)。
14
+ * 兜底分两类:
15
+ * - PII:手机号 / 邮箱 / 身份证(1.6.7 起就有);
16
+ * - 密钥/令牌(1.6.8 评审 S3 补):sk-*、Bearer …、*_API_KEY=*、token=/secret=/password=、
17
+ * 私钥头、AWS/GitHub/Slack 凭证前缀。活动板是跨会话共享的,
18
+ * 手机号只泄漏一个人的联系方式,密钥泄漏的是账号本身。
19
+ *
20
+ * 发布形态下(没有 privacy-gate 仓库)只靠兜底;DSH_PRIVACY_GATE 显式配置但不可达时
21
+ * 在加载期 `process.emitWarning` 一次,并强制走强化后的本地规则 —— 不允许静默放行。
22
+ *
23
+ * 调用方:bin/eval.mjs、bin/eval-fresh.mjs、bin/recall-once.mjs、plugin/src/index.js、
24
+ * src/activity/{store,index}.js 以及 test/auto-recall.test.mjs(证明真门被调用)。
16
25
  */
17
26
 
18
27
  import { createRequire } from 'node:module';
@@ -22,15 +31,20 @@ import { fileURLToPath } from 'node:url';
22
31
  const HERE = path.dirname(fileURLToPath(import.meta.url));
23
32
  const require = createRequire(import.meta.url);
24
33
 
34
+ const ENV_GATE = typeof process.env.DSH_PRIVACY_GATE === 'string' && process.env.DSH_PRIVACY_GATE.trim() !== ''
35
+ ? process.env.DSH_PRIVACY_GATE.trim()
36
+ : null;
37
+
25
38
  /** 环境变量优先;其次是作者树旁边的 privacy-gate;最后是绝对路径兜底。 */
26
39
  export const GATE_CANDIDATES = [
27
- process.env.DSH_PRIVACY_GATE,
40
+ ENV_GATE,
28
41
  path.resolve(HERE, '../../../privacy-gate/src/memory/privacy-gate.js'),
29
42
  ].filter((p) => typeof p === 'string' && p.trim() !== '');
30
43
 
31
44
  let realScan = null;
32
45
  let loadedFrom = null;
33
46
  let loadError = null;
47
+ const loadErrors = [];
34
48
  for (const candidate of GATE_CANDIDATES) {
35
49
  try {
36
50
  const mod = require(candidate);
@@ -39,44 +53,116 @@ for (const candidate of GATE_CANDIDATES) {
39
53
  loadedFrom = candidate;
40
54
  break;
41
55
  }
42
- loadError = `scanPrivacy not exported by ${candidate}`;
56
+ loadErrors.push(`scanPrivacy not exported by ${candidate}`);
43
57
  } catch (error) {
44
- loadError = `${candidate}: ${error?.code ?? error?.message ?? error}`;
58
+ loadErrors.push(`${candidate}: ${error?.code ?? error?.message ?? error}`);
59
+ }
60
+ }
61
+ loadError = loadErrors.length > 0 ? loadErrors[loadErrors.length - 1] : null;
62
+ // Diagnostics should name the explicitly configured gate when that is the one
63
+ // that failed — not the author-tree fallback that was tried after it.
64
+ if (ENV_GATE !== null) {
65
+ const envFailure = loadErrors.find((entry) => entry.startsWith(ENV_GATE));
66
+ if (envFailure) loadError = envFailure;
67
+ }
68
+
69
+ /** 显式配置了门却装不上:不静默放行,加载期告警一次,扫描强制走强化本地规则。 */
70
+ const gateConfigured = ENV_GATE !== null;
71
+ const forcedFallback = gateConfigured && realScan === null;
72
+ if (forcedFallback) {
73
+ try {
74
+ process.emitWarning(
75
+ `[longlive:privacy] DSH_PRIVACY_GATE 已配置但不可达(${loadError ?? 'unknown'})`
76
+ + ';已强制使用强化本地兜底规则(含密钥/令牌模式),不会静默放行。',
77
+ );
78
+ } catch {
79
+ /* warning is best effort; the scan below is what enforces the rule */
45
80
  }
46
81
  }
47
82
 
48
- /* 核心兜底:只认三类最硬的 PII,避免把普通数字(token 数、ID 号)误判。 */
83
+ /* 核心兜底 · 一类最硬的 PII:避免把普通数字(token 数、ID 号)误判。 */
49
84
  const PII_PATTERNS = [
50
85
  { kind: 'phone', re: /(?<!\d)1[3-9]\d{9}(?!\d)/g },
51
86
  { kind: 'email', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g },
52
87
  { kind: 'id-card', re: /(?<!\d)\d{17}[\dXx](?!\d)/g },
53
88
  ];
54
89
 
55
- /** 核心自带的保守扫描(不依赖任何外部文件)。 */
56
- export function fallbackScan(text) {
57
- const s = String(text ?? '');
58
- const spans = [];
59
- for (const { kind, re } of PII_PATTERNS) {
90
+ /*
91
+ * 核心兜底 · 密钥/令牌类。全部要求「像凭证」才拦,尽量不误伤普通文本:
92
+ * - sk- 前缀要求前面不是标识符字符(`task-id-1234567890` 里的 `sk-id-…` 不算);
93
+ * - `token=`/`api_key=` 这类赋值要求至少 6–8 位值,`token 数 12`、`--test` 不会命中;
94
+ * - 赋值形态允许 `[a-z0-9_]{0,32}` 前缀(RE-S3.1:`my_api_key: …`、`apiKey=…`、
95
+ * `secret token: …`),分隔符允许 `= : : ,` 与两侧空格;**纯空格不算分隔符**,
96
+ * 所以 `password reset flow` / `password 字段在 config.js 里` 不误伤;
97
+ * - PRIVATE KEY 头、AWS/GitHub/Slack 前缀是强特征,直接拦。
98
+ */
99
+ const ASSIGN_VALUE = String.raw`['"]?[A-Za-z0-9._\-/+!@#$%^&*]{6,}`;
100
+ const SECRET_PATTERNS = [
101
+ // RE-S3.7: sk-/SK- 前缀大小写不敏感(连续形式;空格/Unicode 变体是 P3 残余建议)。
102
+ { kind: 'api-key', re: /(?<![A-Za-z0-9_-])sk-[A-Za-z0-9][A-Za-z0-9_-]{8,}/gi },
103
+ { kind: 'api-key', re: /(?<![A-Za-z0-9_])[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*(?:_API_KEY|_APIKEY|_KEY|_TOKEN|_SECRET|_PASSWORD|_PASSWD)\s*[=:]\s*\S+/g },
104
+ // RE-S3.1: 小写/带前缀的赋值形态(大小写不敏感):my_api_key: "…"、apiKey=…。
105
+ {
106
+ kind: 'api-key',
107
+ re: new RegExp(
108
+ String.raw`(?<![A-Za-z0-9])[A-Za-z0-9_]{0,32}`
109
+ + String.raw`(?:api[_-]?key|access[_-]?key|secret[_-]?key|private[_-]?key|app[_-]?key|token|secret|password|passwd|pwd|credential)`
110
+ + String.raw`\s*[:=:,]\s*` + ASSIGN_VALUE,
111
+ 'gi',
112
+ ),
113
+ },
114
+ // RE-S3.1: 多词标签(允许空格/下划线):secret token: …、api key = …。
115
+ {
116
+ kind: 'token',
117
+ re: new RegExp(
118
+ String.raw`(?<![A-Za-z0-9])(?:api|access|secret|private|auth|refresh|session)[\s_-]+(?:key|token|secret|password)`
119
+ + String.raw`\s*[:=:,]\s*` + ASSIGN_VALUE,
120
+ 'gi',
121
+ ),
122
+ },
123
+ { kind: 'api-key', re: /(?<![A-Za-z0-9_])(?:api[_-]?key|access[_-]?key|secret[_-]?key|private[_-]?key|app[_-]?key)\s*[=:]\s*['"]?[A-Za-z0-9._\-/+]{8,}/gi },
124
+ { kind: 'token', re: /(?<![A-Za-z0-9_])(?:token|auth|access[_-]?token|refresh[_-]?token|session[_-]?token)\s*[=:]\s*['"]?[A-Za-z0-9._\-/+]{8,}/gi },
125
+ { kind: 'password', re: /(?<![A-Za-z0-9_])(?:password|passwd|pwd)\s*[=:]\s*['"]?[A-Za-z0-9._\-/+!@#$%^&*]{6,}/gi },
126
+ { kind: 'bearer', re: /\bBearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi },
127
+ { kind: 'private-key', re: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/g },
128
+ { kind: 'aws-key', re: /(?<![A-Z0-9])(?:AKIA|ASIA)[0-9A-Z]{16}(?![0-9A-Z])/g },
129
+ { kind: 'github-token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}/g },
130
+ { kind: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
131
+ ];
132
+
133
+ function scanWith(patterns, text, spans) {
134
+ for (const { kind, re } of patterns) {
60
135
  re.lastIndex = 0;
61
- let m = re.exec(s);
136
+ let m = re.exec(text);
62
137
  while (m !== null) {
63
138
  spans.push({ start: m.index, end: m.index + m[0].length, kind });
64
139
  if (m.index === re.lastIndex) re.lastIndex += 1;
65
- m = re.exec(s);
140
+ m = re.exec(text);
66
141
  }
67
142
  }
143
+ }
144
+
145
+ /** 核心自带的保守扫描(不依赖任何外部文件):PII + 凭证类,两类都算 local_only。 */
146
+ export function fallbackScan(text) {
147
+ const s = String(text ?? '');
148
+ const spans = [];
149
+ scanWith(PII_PATTERNS, s, spans);
150
+ scanWith(SECRET_PATTERNS, s, spans);
68
151
  spans.sort((a, b) => a.start - b.start);
69
- if (spans.length === 0) return { verdict: 'none', spans: [], reasons: [], source: 'core-fallback' };
152
+ if (spans.length === 0) {
153
+ return { verdict: 'none', spans: [], reasons: [], source: forcedFallback ? 'core-fallback:forced' : 'core-fallback' };
154
+ }
70
155
  return {
71
156
  verdict: 'local_only',
72
157
  spans,
73
158
  reasons: [...new Set(spans.map((x) => x.kind))],
74
- source: 'core-fallback',
159
+ source: forcedFallback ? 'core-fallback:forced' : 'core-fallback',
75
160
  };
76
161
  }
77
162
 
78
163
  /**
79
164
  * 注入前的隐私扫描(同步)。
165
+ * - 核心兜底(含凭证类)先扫,命中直接 local_only;
80
166
  * - 隐私门可用 → 调它;门抛错 → fail-closed;
81
167
  * - 门说 none 但兜底正则命中 → 仍按命中处理(并集)。
82
168
  * @param {string} text
@@ -113,6 +199,9 @@ export const privacyGateStatus = Object.freeze({
113
199
  loaded: realScan !== null,
114
200
  from: loadedFrom,
115
201
  error: realScan === null ? loadError : null,
202
+ configured: gateConfigured,
203
+ forcedFallback,
204
+ scope: 'pii+secret',
116
205
  });
117
206
 
118
207
  /** 给 chooseRecall 用的一次性参数:`options: { ...privacyDefaults }`。 */