@furongjun1999/dsh-memory 0.7.1 → 0.7.3

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 (125) hide show
  1. package/README.md +58 -48
  2. package/codebuddy/CODEBUDDY.md +9 -9
  3. package/docs/README.md +148 -144
  4. package/docs/discipline/harnesses.yaml +16 -8
  5. package/docs/discipline/templates/zcode-user.md.tmpl +49 -0
  6. package/docs/eval/AGI/344/270/203/347/273/264/350/257/204/345/210/206/346/212/245/345/221/212_/345/205/255/345/256/266/350/256/260/345/277/206/347/263/273/347/273/237/345/220/214/345/260/272/345/256/236/346/265/213_v3.1.md +340 -0
  7. package/docs/eval/N225_/347/264/242/345/274/225/346/227/245/345/277/227/351/235/236/345/257/271/350/261/241/350/243/205/350/275/275/351/235/242/347/261/273/345/236/213/351/227/270_v1.0.md +2 -2
  8. package/docs/eval/cons200_/345/206/262/347/252/201/346/243/200/346/265/213/351/200/211/351/235/242_/345/256/236/346/226/275/350/256/260/345/275/225_v1.0.md +1 -1
  9. package/docs/eval/cons200_/345/206/262/347/252/201/346/243/200/346/265/213/351/200/211/351/235/242_/345/256/236/346/226/275/350/256/260/345/275/225_v1.1.md +1 -1
  10. package/docs/eval/issue43_/351/273/230/350/256/244/347/255/226/347/225/245/344/270/216/351/224/256/347/261/273/345/236/213/351/227/270_v1.1.md +1 -1
  11. package/docs/eval/issue43_/351/273/230/350/256/244/347/255/226/347/225/245/345/212/240/350/275/275/344/270/216/345/207/255/346/215/256/346/230/216/346/226/207/351/230/237/345/210/227_v1.0.md +2 -2
  12. package/docs/eval/issue50_/345/205/203/346/225/260/346/215/256/351/200/217/344/274/240/344/270/216/345/205/234/345/272/225_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +5 -5
  13. package/docs/eval/issue50_/345/215/212/351/207/215/345/244/215/345/276/205/345/256/232/345/244/215/346/240/270_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +9 -9
  14. package/docs/eval/issue50_/345/276/205/345/256/232/345/244/215/346/240/270/345/205/245/351/230/237_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +4 -4
  15. package/docs/eval/issue50_/351/207/215/350/246/201/345/272/246/345/220/214/346/272/220/344/270/216/344/277/235/346/212/244/350/257/255/344/271/211_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +1 -1
  16. package/docs/eval/issue51_/344/270/200/351/224/256/345/256/211/350/243/205/345/244/261/350/264/245_/345/275/222/345/261/236/345/210/244/345/256/232_v1.0.md +1 -1
  17. package/docs/eval/issue52_/346/235/241/344/273/266/345/205/210/350/241/214/344/270/216/346/210/252/346/226/255/345/217/257/350/247/202/346/265/213_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +3 -3
  18. package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/242/345/217/230/346/233/264/345/215/225/344/270/216/345/233/236/346/273/232/345/216/237/350/257/255_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +3 -3
  19. package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/243/345/207/206/345/205/245/350/257/273/346/225/260_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +7 -7
  20. package/docs/eval//344/274/230/345/214/226/347/254/254/344/270/200/346/211/271_/346/216/245/347/272/277/344/270/216/347/255/211/344/273/267/345/217/230/346/215/242_v1.0.md +2 -2
  21. package/docs/eval//344/274/230/345/214/226/347/254/254/344/270/211/346/211/271_/351/227/250/347/246/201/350/275/254/346/255/243/344/270/216/350/260/203/345/272/246/346/255/242/350/241/200/344/270/216/351/227/250/346/216/247/346/224/266/345/217/243_v1.0.md +1 -1
  22. package/docs/eval//344/274/230/345/214/226/347/254/254/344/272/214/346/211/271_/344/276/235/350/265/226/351/200/217/344/274/240/344/270/216/345/257/271/346/213/215/345/217/243/345/276/204/344/270/216/350/264/237/347/274/223/345/255/230_v1.0.md +7 -7
  23. package/docs/eval//345/207/272/350/264/247/351/235/242/345/206/222/347/203/237_/350/277/233/350/264/247/351/227/250/347/246/201_v1.0.md +10 -10
  24. package/docs/eval//345/217/221/345/270/20307_/345/244/226/351/203/250/346/212/245/345/221/212/345/233/233/346/211/271/344/277/256/345/244/215/344/270/216/346/217/222/344/273/266/351/235/242/345/212/240/345/233/272_v1.0.md +4 -0
  25. package/docs/eval//345/217/221/345/270/20308_/350/207/252/350/277/255/344/273/243/344/270/216/347/235/241/347/234/240_/345/233/276/346/243/200/347/264/242/350/267/257/344/270/216/346/235/203/351/207/215_v1.0.md +1 -1
  26. package/docs/eval//345/217/221/345/270/20309_/346/243/200/347/264/242/351/235/242/344/270/211/346/211/271/346/224/266/345/217/243_v1.0.md +1 -1
  27. package/docs/eval//345/217/221/345/270/20310_/350/257/273/351/235/242/346/215/237/345/235/217UTF8/345/256/266/346/227/217/346/224/266/345/217/243_v1.0.md +71 -0
  28. package/docs/eval//345/217/221/345/270/20311_/345/244/226/346/212/245/345/205/255/350/277/236/344/277/256/344/270/216/344/270/226/347/225/214/346/250/241/345/236/213/345/272/225/345/272/247_v1.0.md +91 -0
  29. package/docs/eval//345/275/222/344/270/200/345/261/202/347/274/272/347/234/201/347/277/273/345/205/263_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +2 -2
  30. package/docs/eval//347/224/250/346/210/267/350/243/201/345/206/263/345/244/204/347/275/256_/347/254/254/344/270/200/346/211/271_v1.0.md +195 -0
  31. package/docs/eval//347/224/250/346/210/267/350/243/201/345/206/263/345/244/204/347/275/256_/347/254/254/344/272/214/346/211/271B6_/346/234/254/346/234/272/350/267/257/345/276/204/347/233/270/345/257/271/345/214/226_v1.0.md +78 -0
  32. package/docs/eval//347/253/257/345/210/260/347/253/257LoCoMoQA/345/220/214/345/217/243/345/276/204/345/257/271/347/205/247_v1.0.md +1 -1
  33. package/docs/eval//347/253/257/345/210/260/347/253/257/345/271/262/346/211/260/346/261/240/350/257/204/346/265/213_/347/241/256/345/256/232/346/200/247/350/243/201/345/206/263vsLLM_judge_v1.1.md +3 -3
  34. package/docs/eval//347/254/2543/345/261/202stg/347/273/223/346/236/204/347/264/242/345/274/225_/345/256/236/346/226/275/350/256/260/345/275/225_v1.0.md +1 -1
  35. package/docs/eval//347/274/226/347/240/201/351/235/242/345/211/215/347/275/256_/345/205/245/345/217/243/350/207/252/344/277/235/350/257/201UTF8/344/270/216/346/226/207/346/234/254open/345/256/210/345/215/253_v1.0.md +1 -1
  36. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v26.md +196 -0
  37. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v27.md +259 -0
  38. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v28.md +217 -0
  39. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v29.md +270 -0
  40. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v30.md +231 -0
  41. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v31.md +281 -0
  42. package/docs/hive//345/256/211/345/205/250/345/256/241/350/256/241/345/256/236/351/224/232_v0.1.md +1 -1
  43. package/docs/hive//350/234/202/345/267/242M6_ingest/345/256/236/346/226/275/350/256/241/345/210/222_v0.1.md +1 -1
  44. package/docs/hive//350/234/202/345/267/242/345/217/214/345/256/236/344/276/213/344/272/222/351/252/214_/350/256/276/350/256/241/345/256/232/347/250/277.md +1 -1
  45. package/docs/hive//350/234/202/345/267/242/350/256/276/350/256/241_/347/220/206/350/256/272/345/257/271/351/275/220_v0.1.md +17 -17
  46. package/docs/hive//350/234/202/345/267/242/350/277/255/344/273/243_/345/256/217/350/247/202/344/270/216/347/276/244/344/275/223/350/260/203/345/272/246_v0.1.md +1 -1
  47. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +2 -2
  48. package/docs/mdcg//345/212/237/350/203/275/350/260/203/347/224/250/346/230/240/345/260/204/350/241/250_v0.1.md +42 -41
  49. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +29 -8
  50. package/docs/mdcg//347/235/241/347/234/240/345/221/250/346/234/237_/350/277/220/347/273/264/345/211/215/346/217/220/344/270/216/347/273/264/346/212/244/346/214/207/345/215/227_v1.0.md +71 -1
  51. package/docs/plans//345/205/250/344/270/255/346/226/207/347/274/226/347/240/201/344/270/216/350/234/202/345/267/242/344/273/273/345/212/241/346/240/207/350/257/206/345/245/221/347/272/246_v2.0.md +1 -1
  52. package/docs/plans//345/244/232/344/270/273/344/275/223/344/270/226/347/225/214/346/250/241/345/236/213_/345/257/271/351/275/220/350/257/204/344/274/260/344/270/216/350/220/275/345/234/260/350/256/276/350/256/241_v0.1.md +141 -0
  53. package/docs/plans//347/235/241/347/234/240/344/270/216/350/207/252/350/277/255/344/273/243_/345/212/237/350/203/275/344/274/230/345/214/226/350/256/276/350/256/241_v0.4.md +2 -2
  54. package/docs/plans//350/257/255/344/271/211/346/227/266/347/251/272/345/233/276/350/241/245/345/205/250_/344/270/226/347/225/214/346/250/241/345/236/213/345/212/237/350/203/275/347/253/257_/350/256/276/350/256/241_v0.1.md +107 -0
  55. package/docs//345/267/245/344/275/234/347/272/252/345/276/213_/350/256/244/347/237/245/345/233/276/346/235/241/347/233/256_v1.1.json +9 -8
  56. package/docs//347/247/244_/350/256/260/345/277/206/347/263/273/347/273/237/350/257/204/346/265/213/350/247/204/350/214/203_v2.1.md +225 -0
  57. package/dsh/cordis-patch-profile-web.example.yml +2 -2
  58. package/lib/bridge.js +9 -0
  59. package/lib/hooks.d.ts +8 -1
  60. package/lib/hooks.js +94 -32
  61. package/lib/lib/hook_audit.d.ts +55 -0
  62. package/lib/lib/hook_audit.js +208 -0
  63. package/lib/lib/mutual.js +5 -1
  64. package/lib/lib/roleplay_web.js +17 -2
  65. package/md_cg/auditview.py +248 -0
  66. package/md_cg/bench6_arms.py +10 -6
  67. package/md_cg/bench_e2e_judge.py +4 -2
  68. package/md_cg/branches.py +29 -3
  69. package/md_cg/census.py +2 -1
  70. package/md_cg/consistency.py +38 -13
  71. package/md_cg/datapath.py +38 -11
  72. package/md_cg/forgetting.py +15 -2
  73. package/md_cg/freshness.py +32 -11
  74. package/md_cg/ghostref.py +130 -0
  75. package/md_cg/judgment_manifest.py +7 -0
  76. package/md_cg/mcp_server.py +58 -1
  77. package/md_cg/mdcg.py +4923 -4764
  78. package/md_cg/mdcos.py +40 -15
  79. package/md_cg/mreview/locate.py +1 -1
  80. package/md_cg/nodefile.py +77 -13
  81. package/md_cg/readcache.py +29 -4
  82. package/md_cg/rollback.py +12 -3
  83. package/md_cg/routing.py +10 -1
  84. package/md_cg/selfreport.py +12 -3
  85. package/md_cg/sleep.py +129 -13
  86. package/md_cg/sources.py +1 -1
  87. package/md_cg/state_events.py +131 -0
  88. package/md_cg/sustain.py +1436 -1436
  89. package/md_cg/test_auditview.py +277 -0
  90. package/md_cg/test_autonomy_modes.py +2 -1
  91. package/md_cg/test_boot_openblas_guard.py +197 -0
  92. package/md_cg/test_ccg_form_parity.py +95 -4
  93. package/md_cg/test_cons200_scan_selection.py +4 -2
  94. package/md_cg/test_corrupt_utf8_read_surfaces.py +451 -0
  95. package/md_cg/test_datapath_device_name.py +62 -3
  96. package/md_cg/test_generation_guard.py +20 -11
  97. package/md_cg/test_ghostref.py +182 -0
  98. package/md_cg/test_health_corrupt_utf8.py +37 -0
  99. package/md_cg/test_index_crossprocess_reload.py +39 -24
  100. package/md_cg/test_interop.py +1 -1
  101. package/md_cg/test_issue52_scan_condition_first.py +1 -1
  102. package/md_cg/test_issue53_cipher_health_hash.py +385 -0
  103. package/md_cg/test_lock.py +16 -7
  104. package/md_cg/test_m3_h9_semantic_guard.py +1 -1
  105. package/md_cg/test_n230_dirty_replay.py +375 -375
  106. package/md_cg/test_n238_body_text_parity.py +156 -0
  107. package/md_cg/test_n276_probe_hoist.py +199 -0
  108. package/md_cg/test_p4_freshness.py +95 -1
  109. package/md_cg/test_policy_required_ccg.py +3 -1
  110. package/md_cg/test_readcache_prodpath.py +66 -0
  111. package/md_cg/test_sleep.py +58 -1
  112. package/md_cg/test_sleep_p1.py +218 -2
  113. package/md_cg/test_state_events.py +178 -0
  114. package/md_cg/test_time_core_lint.py +8 -1
  115. package/md_cg/test_writepipe.py +8 -5
  116. package/md_cg/tokens.py +11 -2
  117. package/md_cg/writepipe.py +76 -0
  118. package/package.json +2 -2
  119. package/skills/plugin.json +1 -1
  120. package/src/bridge.ts +9 -0
  121. package/src/hooks.ts +576 -517
  122. package/src/lib/hook_audit.ts +243 -0
  123. package/src/lib/mutual.ts +5 -1
  124. package/src/lib/roleplay_web.ts +17 -2
  125. package/zcode/AGENTS.md +9 -9
package/src/hooks.ts CHANGED
@@ -1,517 +1,576 @@
1
- /**
2
- * 自动记忆钩子:把 DSH 的会话事件(经统一的 session/event 分发)沉淀进灵枢。
3
- *
4
- * 记忆真源 = **md_cg 认知图(md 文档)**(2026-09-10 统一):本钩子经
5
- * MdcgClient.remember() 调 MCP `mdcg_remember(gated=true)`(主动遗忘闸门:
6
- * ACCEPT 落盘 / MERGE 并入既有 = 去重强化 / DROP 低熵 / DEFER 待定),
7
- * 落层 contextual,role 取 user | assistant | tool-output —— 与 md_cg 对 DSH
8
- * 会话事件的约定一致(md_cg/sources.py 的 SESSION_LAYER / DSHSessionSource)。
9
- * ⚠️ 不用 `cg(op=write)`:那条路径先过 audit,未声明 content_kind 时永不落盘
10
- * (见 src/lib/mdcg_client.ts 文件头)。
11
- * 不再走 AEIS:AEIS 已降为能力库,不存记忆。
12
- *
13
- * ⚠️ 写入需凭据:md_cg fail-closed,无 MDCG_TOKEN / MDCG_LEGACY_ENV_AUTH=1
14
- * 时降级为只读 guest —— 本钩子的写入会失败(仅告警,不影响对话)。
15
- *
16
- * 与 DSH 的 session-persistence 插件(保存会话日志)不同,这里是"语义沉淀":
17
- * 带去重(闸门 MERGE)与重要性,写入前脱敏;agent 回复与工具结果可选开启。
18
- * 只记忆真实用户消息(source.kind === 'user'),过滤插件注入的噪音;其上再叠一层
19
- * **来源判定**(H1,2026-09-30):① **会话级**——子代理/委派子会话(SessionHeader 的
20
- * `origin` / `delegationDepth`)的自动记忆**整条会话拦掉**;② **消息级**——
21
- * `source.form === 'relay'`(「另一个 agent 发给本 agent 的消息」)不写。
22
- * 两条判据均为「**字段在场且取值匹配才拦**」:字段缺失一律退化为不过滤(默认放行),
23
- * 且宿主是否真写这些字段**未在真实会话事件上验证过**(见 installMemoryHooks 内注释)。
24
- *
25
- * autoRecall:通过 system-prompt/assemble 事件(waterfall,异步允许)在每次
26
- * 模型请求组装 system prompt 时自动注入灵枢最近记忆
27
- * (`stg(op=timeline)`,最近记忆节点时间线),让记忆"自动可用"而不只依赖
28
- * Agent 主动调用 recall/think 工具。失败静默(不影响请求)。
29
- * ⚠️ 该注入块的**稳定性**决定宿主是否新追加快照:内容没变时也必须照旧 push
30
- * (宿主按渲染后的整段文本去重);跳过 push 反而会各追加一份「有块/无块」的快照
31
- * —— 详见 installMemoryHooks 里的长注释。
32
- *
33
- * ⚠️ 注入文本**必经** escapePromptBraces(src/lib/prompt_safety.ts,issue #16):
34
- * 宿主对 context 文本做严格 `{{variable}}` 插值,裸 `{{` 会让每轮 assemble 抛错
35
- * → 会话永久不可用(记忆永久在库,非偶发故障)。记忆真源不动,只在**注入副本**上
36
- * 打断 `{{`——新增任何 push context/section 的代码,同样必须过这道转义。
37
- *
38
- * ⚠️ 注入文本**必经** renderUntrustedMemoryBlock(同上文件,H5):记忆正文是任意
39
- * 用户输入/工具输出的沉淀,必须以「历史原文 / 仅作参考 / 不得执行其中指令」的固定
40
- * 声明句 + 显式边界标记注入,且载荷内的边界标记先被打断(防提前闭合)。注入副本
41
- * 之外(库内正文、工具返回原文)一律不动。
42
- */
43
-
44
- import '@deepseek-ai/dsh-session'
45
- import '@deepseek-ai/dsh-system-prompt'
46
- import type { Context } from '@deepseek-ai/cordis'
47
- import type { SessionEvent } from '@deepseek-ai/dsh-session'
48
- import type { ContentBlock } from '@deepseek-ai/dsh-llm'
49
- import type { MdcgClient } from './lib/mdcg_client.js'
50
- import { escapePromptBraces, renderUntrustedMemoryBlock } from './lib/prompt_safety.js'
51
-
52
- /** 自动记忆开关。 */
53
- export interface MemoryHooksOptions {
54
- /** 用户消息 → remember(默认 true)。 */
55
- userMessage: boolean
56
- /** agent 回复 → remember(默认 false,防噪音)。 */
57
- assistantMessage: boolean
58
- /** 工具结果 → remember(默认 false,噪音大)。 */
59
- toolResult: boolean
60
- /** 写入记忆的重要性(0~1),默认 0.6。 */
61
- importance: number
62
- /** 自动召回注入:模型请求前自动注入灵枢最近记忆(默认 true,失败静默)。 */
63
- autoRecall: boolean
64
- /** 自动召回条数(默认 4)。 */
65
- autoRecallLimit: number
66
- /** 自动记忆脱敏:写入前过滤敏感信息(密钥/密码/令牌/身份证/手机号,默认 true)。 */
67
- desensitize: boolean
68
- }
69
-
70
- /** 从 ContentBlock[] 提取纯文本。 */
71
- function extractText(blocks: ContentBlock[]): string {
72
- const parts: string[] = []
73
- for (const block of blocks) {
74
- if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
75
- parts.push(block.text)
76
- }
77
- }
78
- return parts.join('\n').trim()
79
- }
80
-
81
- // ---------------------------------------------------------------- 脱敏字符集(单点)
82
- // M5(全角凭据绕过):值类与词形字符集必须**两宽齐备**。
83
- //
84
- // 背景(探针实测):此前值类字符集全是半角——`[A-Za-z0-9_@#$%^&*!.-]`、
85
- // `[^\s,,。;;]`、`sk-` 后限定 `[A-Za-z0-9_-]{8,}`——故
86
- // `密码:password123456` / `api_key=…` / `sk-abcd…`
87
- // 这类**全角写法全部漏检**(filtered=false)。全角形态是独立 Unicode 区段
88
- // (U+FF01-U+FF5E ↔ U+0021-U+007E 一一对应),`\w`/`\d`/`\b` 一律不认,必须显式列出。
89
- //
90
- // 为何**不**走「先全角→半角归一化再匹配」(两条路的取舍,依据如下):
91
- // ① NFKC 归一化会**改变长度**(`㍿`→`株式会社`、半角カナ `パ`→`パ`、`㈱`→`(株)`)
92
- // ⇒ 匹配片段无法映射回原文偏移,替换要么错位要么漏改——静默失真;
93
- // ② 若退一步「归一化后整段替换」,等于把正文里的全角标点(:=_())统统
94
- // 半角化——**改写记忆真源**,与「原文保真、只在注入边界改写」的既有纪律相悖;
95
- // ③ 归一化会抹平 `.`(U+FF0E,`.` 的全角形态) 与 `。`(U+3002,中文句号) 的区别,
96
- // 值类边界随之漂移——本题要求说明的误伤面正在这里。
97
- // 故取**显式两宽字符类**:替换只覆盖命中的凭据片段,正文其余字符逐字节零改写;
98
- // 且「每条规则的值类两宽齐备」成为可机械断言的性质
99
- // (守卫 test/fullwidth_redact.test.ts ①/②/④)。
100
- //
101
- // 边界(如实):两宽类刻意**不含**中文句读 ,;。!?、 —— 它们不在半角类里,
102
- // 引入会让值类跨句吞并(`.` 是 `.` 的全角形态、属值类,故收录)。
103
-
104
- /** 全角数字(U+FF10-U+FF19)。 */
105
- const FW_DIGITS = '0-9'
106
- /** 全角大写字母(U+FF21-U+FF3A)。 */
107
- const FW_UPPER = 'A-Z'
108
- /** 全角小写字母(U+FF41-U+FF5A)。 */
109
- const FW_LOWER = 'a-z'
110
-
111
- /** 两宽「词字符」类:半角 + 全角 字母/数字/下划线(`\w`/`\b` 的替代判据面)。
112
- * 导出供守卫核对字面量规则的同源性(test/fullwidth_redact.test.ts ⑦);
113
- * 对外仍属内部实现,不承诺稳定 ABI。 */
114
- export const WORD_CHARS = `A-Za-z0-9_${FW_DIGITS}${FW_UPPER}${FW_LOWER}_`
115
- /** 两宽数字类。 */
116
- const DIGIT_CHARS = `0-9${FW_DIGITS}`
117
- /** 两宽凭据值类:词字符 + `@ # $ % ^ & * ! . -` 的两宽形态。
118
- * ASCII 连字符一律转义(`\\-`)——`[_--]` 会被解析成 `_`→`-` 的**巨区间**。 */
119
- const CRED_VALUE_CHARS = `${WORD_CHARS}@@#$$%^&*!.\\--.`
120
- /** 两宽令牌值类(本项目令牌 id/secret 的字符集:词字符 + `-`)。
121
- * 导出供守卫核对字面量规则的同源性(同 ⑦),不承诺稳定 ABI。 */
122
- export const TOKEN_VALUE_CHARS = `${WORD_CHARS}\\--`
123
- /** 两宽 Bearer 值类(原半角集 `A-Za-z0-9._~+/=-` + 其全角形态)。 */
124
- const BEARER_VALUE_CHARS =
125
- `A-Za-z0-9._~+/=\\-${FW_DIGITS}${FW_UPPER}${FW_LOWER}_.~/+=-`
126
-
127
- /** ASCII 可见字符 → 全角等价(U+0021-U+007E ↔ U+FF01-U+FF5E);其余原样返回。 */
128
- function toFullwidth(ch: string): string {
129
- const code = ch.charCodeAt(0)
130
- return code >= 0x21 && code <= 0x7e ? String.fromCharCode(code + 0xfee0) : ch
131
- }
132
-
133
- /** 半角 ASCII 词 → 「半角|全角」等价类(M5 单点:关键词的两宽形态只此一处生成)。
134
- * 仅接受 `[A-Za-z0-9_-]`——含正则元字符的词会让等价类语法失真,故 fail-closed 抛错。 */
135
- function twoWidth(word: string): string {
136
- let out = ''
137
- for (const ch of word) {
138
- if (!/[A-Za-z0-9_-]/.test(ch)) {
139
- throw new Error(`twoWidth 只接受半角字母数字/下划线/连字符,收到:${word}`)
140
- }
141
- out += `[${ch}${toFullwidth(ch)}]`
142
- }
143
- return out
144
- }
145
-
146
- /**
147
- * 敏感信息模式(GPT 审查·自动记忆脱敏):写入认知图前过滤凭据/个人标识。
148
- * 命中 → 替换为 [已过滤:类别](保留对话主体);过滤后只剩占位符/空白 → 整条跳过。
149
- * 纯内容过滤,不涉及身份认证——开源场景下的隐私保护。
150
- *
151
- * M5:值类/词形两宽齐备(见上方字符集注释);`\b` 全部换成两宽 lookaround——
152
- * `\b` 只认半角 `\w`,`[sk−…]` 这类以全角起首的串在串首**根本取不到词边界**。
153
- */
154
- // 导出供守卫使用(test/token_redact_parity.test.ts 需要按「交换序」复跑同一条链,
155
- // 以证明顺序不再是安全性质);对外仍属内部实现,不承诺稳定 ABI。
156
- export const SENSITIVE_PATTERNS: Array<{ re: RegExp; label: string }> = [
157
- { re: new RegExp(`${twoWidth('sk')}[\\--][${TOKEN_VALUE_CHARS}]{8,}`, 'g'), label: 'API密钥' },
158
- { re: new RegExp(
159
- `(?<![${WORD_CHARS}])`
160
- + `(?:${twoWidth('api')}[__\\--]?${twoWidth('key')}|${twoWidth('apikey')}`
161
- + `|${twoWidth('access')}[__\\--]?${twoWidth('token')})`
162
- + `(?![${WORD_CHARS}])\\s*[:==:]\\s*[^\\s,,。;;]+`, 'gi'), label: 'API密钥' },
163
- { re: new RegExp(
164
- `(?<![${WORD_CHARS}])`
165
- + `(?:${twoWidth('password')}|${twoWidth('passwd')}|${twoWidth('pwd')})`
166
- + `(?![${WORD_CHARS}])\\s*[:==:]\\s*[^\\s,,。;;]+`, 'gi'), label: '密码' },
167
- { re: new RegExp(`${twoWidth('Bearer')}\\s+[${BEARER_VALUE_CHARS}]{8,}`, 'gi'), label: '令牌' },
168
- // 中文密码:值限定非中文连续串(凭据特征),避免误伤「密码是重要的安全概念」;
169
- // 分隔符补全角等号 `=`(半角 `=` 本就不在本规则的集合里,故只补全角形态)。
170
- { re: new RegExp(`密码\\s*[::是=]\\s*[${CRED_VALUE_CHARS}]{4,}`, 'g'), label: '密码' },
171
- // 两宽:全角数字形态同样要被认(`\b` 换成两宽 lookaround,见上)
172
- { re: new RegExp(`(?<![${WORD_CHARS}])[${DIGIT_CHARS}]{17}[${DIGIT_CHARS}XxXx](?![${WORD_CHARS}])`, 'g'), label: '身份证号' },
173
- { re: new RegExp(`(?<![${WORD_CHARS}])[11][3-93-9][${DIGIT_CHARS}]{9}(?![${WORD_CHARS}])`, 'g'), label: '手机号' },
174
- // 本项目自有令牌(issue #45):批次71 已把形态加进**写入闸门**的禁表,但自动
175
- // 记忆走的是 mdcg_remember(gated=true)、**不过 audit**,此处是这条路上唯一的
176
- // 防线——此前不认自家令牌,用户粘一次即明文落进共用记忆库。
177
- // 顺序要点:**完整令牌在前**。四段形态为 `mdcg1.<role>.<token_id>.<secret>`
178
- // (md_cg/tokens.py:make_token),若先匹配裸 token_id,secret 段会留成明文
179
- // (实测:`…designer.[已过滤:id].SECRET…`),故整条令牌必须整段吃掉。
180
- //
181
- // 2026-09-28 加固(PR#46 合并当批):**去 `\b` 词边界、role/secret 字符类放宽、
182
- // secret 下限 16→8**——原式有三处「整条规则失配 ⇒ id 规则独吃 id、secret 留明文」
183
- // 的触发面(实测复现):① 前导为词字符(`k_mdcg1.…`、`a mdcg1.…` 紧邻字母数字下划线
184
- // 时 `\b` 失效);② role 含非字母(如 `sub-agent1`——`parse_token` 只要求非空,
185
- // 不校验字符集);③ secret 短于 16 字符(`make_token` 不校验长度)。三者都让整条规则
186
- // 失配,而裸 id 规则照旧命中 ⇒ secret 明文落库(与顺序错配同一形态)。放宽后与禁表
187
- // 第 11 条 `mdcg1\.[A-Za-z0-9._\-]{20,}`(本就无 `\b`)同口径。
188
- // M5(两宽):四段令牌的**值类**(role/id/secret)与分隔点 `.,` 全部两宽;前缀写成
189
- // 「半角|全角」两种**拼写**的互斥分支(`(?:mdcg1|mdcg1)`)——两分支是不同字符,
190
- // 故不是冗余、也不是字符类。
191
- //
192
- // ⚠️ 这两条令牌规则**必须保持 `/…/g` 字面量、单行且 Python 兼容**:形态守卫
193
- // `md_cg/test_token_lowercase_form.py` 的 G7d~G7g 逐行抽取本文件的 `/…/g` 字面,
194
- // 再用 Python `re` 复跑(定宽后顾 `(?<!…)`、字符类里的全角字面量在 Python `re` 下同义)。
195
- // 改成 `new RegExp(...)` 拼装会让那条守卫抽不到规则(G7d 直接红,实测见本批报告),
196
- // 故此处**不**用 `twoWidth()` 组合;值类字面量须与共享字符集常量同源,
197
- // 由 test/fullwidth_redact.test.ts ⑦ 机械核对。
198
- { re: /(?:mdcg1|mdcg1)[..][A-Za-z0-9_0-9A-Za-z_\--]+[..][A-Za-z0-9_0-9A-Za-z_]+[..][A-Za-z0-9_0-9A-Za-z_\--]{8,}/g, label: '令牌' },
199
- // 裸令牌 id(`tk_` + 12 位 hex,1.15e14 空间不可猜——由 tokens.py 的
200
- // `secrets.token_hex(6)` 生成;id 本身即凭据,与禁表 `\btk_[0-9a-f]{8,}\b` 同形)。
201
- //
202
- // 加固:**把尾随的 `.secret` 段一并吃掉**(`(?:\.[A-Za-z0-9_-]{4,})?`)。不变量=
203
- // 「id 规则绝不能只吃 id、把 secret 留给下一条规则或留给用户」——顺序正确时那条尾巴
204
- // 由整条规则先吃;顺序被改、或整条规则因任何理由失配时,id 规则自己带上尾巴,
205
- // **顺序从此不再是安全性质**(防御纵深,由 test/token_redact_parity.test.ts ④ 钉住)。
206
- // 下限取 4 是为了不吃掉 `tk_…py` / `tk_…md` 这类短文件名尾巴。
207
- // M5(两宽):`\b` 只认半角 `\w`(`tk_…` 在串首取不到词边界 ⇒ 整条漏检),
208
- // 换成两宽后顾;hex 值类、尾巴分隔点 `.,` 一并两宽,前缀同「半角|全角互斥分支」。
209
- // 字面量/Python 兼容要求同上一条(形态守卫 G7d~G7g 抽取 `/…/g` 字面复跑)。
210
- { re: /(?<![A-Za-z0-9_0-9A-Za-z_])(?:tk_|tk_)[0-9a-f0-9a-f]{8,}(?:[..][A-Za-z0-9_0-9A-Za-z_\--]{4,})?/g, label: '令牌id' },
211
- ]
212
-
213
- /** 脱敏:替换敏感片段;返回 null 表示整条都是敏感内容(应跳过写入)。 */
214
- export function desensitize(text: string): string | null {
215
- let out = text
216
- for (const { re, label } of SENSITIVE_PATTERNS) {
217
- out = out.replace(re, `[已过滤:${label}]`)
218
- }
219
- // 过滤后只剩占位符/空白 → 纯凭据消息,不写(或全部被替换)
220
- const residue = out.replace(/\[已过滤:[^\]]+\]/g, '').trim()
221
- if (!residue) return null
222
- return out
223
- }
224
-
225
- /** 时间线载荷 → 注入文本。
226
- * `stg(op=timeline)` 返回 {count, limit, items:[{id, layer, start, end, preview}]}。
227
- * (保留原始实现;自动召回改用下面的分级递减渲染) */
228
- function formatTimeline(payload: unknown): string {
229
- const items = (payload && typeof payload === 'object'
230
- && Array.isArray((payload as { items?: unknown }).items))
231
- ? (payload as { items: Array<Record<string, unknown>> }).items
232
- : []
233
- return items
234
- .map((it) => {
235
- const preview = String(it['preview'] ?? '').replace(/\s+/g, ' ').trim()
236
- if (!preview) return ''
237
- const layer = it['layer'] ? `[${String(it['layer'])}] ` : ''
238
- return `- ${layer}${preview}`
239
- })
240
- .filter(Boolean)
241
- .join('\n')
242
- }
243
-
244
- // ---------------------------------------------------------------- 自动召回渲染
245
- // 常量写死在此处(而非 config schema)——未知键会被 schema 剥离。
246
- /** 第 1 档(最新 1 条)每条字符上限。 */
247
- const RECALL_BASE_CHARS = 160
248
- /** 每 N 条降一档。 */
249
- const RECALL_DECAY_EVERY = 1
250
- /** 每档缩放比例(−10%)。 */
251
- const RECALL_DECAY_RATIO = 0.9
252
- /** 最小保留字符。 */
253
- const RECALL_MIN_CHARS = 24
254
- /** 整块上限(与调用点 slice 对齐)。 */
255
- const RECALL_MAX_CHARS = 1400
256
- /** 永久层不自动注入(按需用 mdcg_recall / lingshu_stg 取)。 */
257
- const RECALL_SKIP_LAYERS = new Set(['anchor', 'self'])
258
-
259
- /** 分级递减渲染:按距当前的次序逐档收窄,早期条目信息量更大。
260
- *
261
- * 动机:注入块总长受限,而"最近 N 条"里越靠前的越可能被用到;线性等宽分配
262
- * 会让整块被最旧的一条挤掉。逐档递减后整块实测约 1133 字符(≈472 tok),
263
- * 9 条全部保留。(注意:整块仍远小于一条知识节点 500~800 tok,故本块定位是
264
- * 「存在性索引/提醒」,不承载知识本身——要知识请显式 mdcg_recall 并给足预算。) */
265
- function formatTimelineDecayed(payload: unknown): string {
266
- const items = (payload && typeof payload === 'object'
267
- && Array.isArray((payload as { items?: unknown }).items))
268
- ? (payload as { items: Array<Record<string, unknown>> }).items
269
- : []
270
- const out: string[] = []
271
- let rank = 0
272
- for (const it of items) {
273
- const layer = String(it['layer'] ?? '')
274
- if (RECALL_SKIP_LAYERS.has(layer)) continue
275
- const preview = String(it['preview'] ?? '').replace(/\s+/g, ' ').trim()
276
- if (!preview) continue
277
- const tier = Math.floor(rank / RECALL_DECAY_EVERY)
278
- const budget = Math.max(
279
- RECALL_MIN_CHARS,
280
- Math.round(RECALL_BASE_CHARS * Math.pow(RECALL_DECAY_RATIO, tier)),
281
- )
282
- out.push(`- [${layer}] ` + (preview.length > budget ? preview.slice(0, budget) + '…' : preview))
283
- rank += 1
284
- if (out.join('\n').length >= RECALL_MAX_CHARS) break
285
- }
286
- return out.join('\n').slice(0, RECALL_MAX_CHARS)
287
- }
288
-
289
- /** 取宿主会话标识(只用于**归因/隔离**,不参与任何权限判断)。
290
- *
291
- * 动机:记忆写入必须带会话身份才能区分不同会话;读取默认只看本会话(防串台),
292
- * 而「所有会话做了什么」用显式 session="*" 取。两侧都依赖这个标识。
293
- *
294
- * 字段名按 DSH 既有形态(`id` / `sessionId`)防御式读取,取不到就返回空串——
295
- * 空串在上游一律等同「不分会话」(退回旧行为),故宿主改字段名最坏只是失去
296
- * 隔离能力,不会注入错块、不会抛错。 */
297
- function sessionIdOf(raw: unknown): string {
298
- const s = raw as { id?: unknown; sessionId?: unknown } | null | undefined
299
- const v = s?.id ?? s?.sessionId
300
- return typeof v === 'string' ? v.trim() : ''
301
- }
302
-
303
- /** 会话归属未知时的**显式占位**(H2③,2026-09-30)。
304
- *
305
- * ⚠️ 不可退回「不传 session 键」:md_cg 的 `Principal.__init__` 在 session 为假值时
306
- * 生成**进程级随机** `sess_<hex>`(md_cg/security.py:117)——插件不传,等于让一个
307
- * 进程内所有「宿主未给标识」的会话共用一个**不可辨认**的随机桶:归属在审计上既
308
- * 读不出是谁、跨进程也对不上,是静默的归属丢失。
309
- * 本常量把这一态写成**显式值**:跨进程一致、可辨认、可审计,且不是伪造的宿主
310
- * 会话 id(非 DSH 形态,服务端 `_normalize_session` 原样采用、不会被改写成别的桶)。
311
- * 要读这个桶:`stg(op=timeline, session="unassigned")`。 */
312
- const UNASSIGNED_SESSION = 'unassigned'
313
-
314
- /** H1 **会话级**判据:这条 session 是否「子代理/委派子会话」。
315
- *
316
- * 字段来源(DSH 类型面,node_modules/@deepseek-ai/dsh-session/lib/types/types.d.ts):
317
- * · `header.origin?: 'subagent'`(:64「Coarse product classification for a
318
- * session created as a subagent child」);
319
- * · `header.delegationDepth?: number`(:70「absent (zero) for a top-level
320
- * session, parent depth + 1 for a subagent child」)。
321
- *
322
- * ⚠️ **未验证项(如实标注)**:本机未装 DSH harness,真实宿主是否真给子代理
323
- * 子会话写这两个字段,**只在类型面成立、未在真实会话事件上观测过**。故判据取
324
- * 「**字段在场且取值匹配才拦**」的形态:header 缺失 / 非对象 / 两个字段都取不到
325
- * 或不匹配 → 一律返回 false(**不拦**,安全退化为既有行为),绝不因字段缺失而
326
- * 报错,也不因此改变既有写入行为。
327
- *
328
- * 默认行为(显式声明):**子代理会话的自动记忆默认拦掉**(默认拦)——委派指令是
329
- * 「另一个 agent 发给本 agent 的指令」,不是用户的长期记忆,写进来会污染真人记忆;
330
- * 而判据不确定时**默认放行**(字段缺失即不拦)——宁可多记,不可因宿主字段缺失
331
- * 而静默丢掉真人记忆。 */
332
- function isSubagentSession(session: unknown): boolean {
333
- const header = (session as { header?: unknown } | null | undefined)?.header
334
- if (!header || typeof header !== 'object') return false
335
- const h = header as { origin?: unknown; delegationDepth?: unknown }
336
- if (h.origin === 'subagent') return true
337
- const depth = h.delegationDepth
338
- return typeof depth === 'number' && Number.isFinite(depth) && depth > 0
339
- }
340
-
341
- /** H1 **消息级**判据:这条消息是否是「另一个 agent 发给本 agent 的」(委派/中继)。
342
- *
343
- * 字段来源(DSH 类型面,node_modules/@deepseek-ai/dsh-llm/lib/types/message.d.ts):
344
- * `ContextForm` 的 `'relay'`(:52 注释原文「A message another agent addressed to
345
- * this one」),按类型只挂在 `kind: 'plugin'` 变体的 `form` 上(:98-101)。
346
- *
347
- * ⚠️ **未验证项(如实标注)**:真实宿主是否真给委派消息写 `form: 'relay'`
348
- * **未观测过**。故同样取「字段在场且取值匹配才拦」;source 缺失/非对象 → false。
349
- *
350
- * 与既有 `kind !== 'user'` 判据的关系:类型面下 `kind='plugin'` 的中继**本就被**
351
- * 那条拦掉;本判据放在它**之前**,是为了 ① 不把委派判定押在 `source.kind` 单点上、
352
- * ② 覆盖「生产者把中继标成 `kind='user'` 且带 form」这一类型面之外的形态——子会话
353
- * 的**首轮用户提示**就可能是这种:它与真人输入在 `kind` 上不可分,只有会话级判据
354
- * (或这里的 form)能拦。 */
355
- function isRelayedMessage(source: unknown): boolean {
356
- const s = source as { form?: unknown } | null | undefined
357
- return !!s && typeof s === 'object' && s.form === 'relay'
358
- }
359
-
360
- /** 安装自动记忆钩子(effect 作用域内,随插件卸载自动移除)。
361
- *
362
- * mdcg 为 null(config.mdcg.enabled=false)时自动记忆整体停用:记忆真源是
363
- * 认知图,没有它就没有可写的去处——**不会退回 AEIS**(AEIS 已不存记忆)。 */
364
- export function installMemoryHooks(ctx: Context, mdcg: MdcgClient | null, opts: MemoryHooksOptions): void {
365
- if (!mdcg) {
366
- ctx.logger.warn('dsh-memory: 认知图未启用(config.mdcg.enabled=false),自动记忆已停用')
367
- return
368
- }
369
- const graph = mdcg
370
-
371
- /** 最近一次观测到的宿主会话标识(见 sessionIdOf;空串 = 未知/无会话)。 */
372
- let lastSession = ''
373
-
374
- /** 记忆沉淀(fire-and-forget)。认知图未就绪则跳过并告警(不退回 AEIS)。 */
375
- const memorize = (label: string, run: (g: MdcgClient) => Promise<unknown>): void => {
376
- if (!graph.isReady()) {
377
- ctx.logger.warn(`dsh-memory: 认知图未就绪,跳过自动记忆(${label})`)
378
- return
379
- }
380
- void run(graph).catch((err: Error) =>
381
- ctx.logger.warn(`dsh-memory: 自动记忆 ${label} 失败: ${err.message}`))
382
- }
383
-
384
- // P1 完善(GPT 审查·自动记忆脱敏):写入前过滤敏感信息(默认开启)。
385
- // 命中敏感模式 → 替换为 [已过滤:类别];纯凭据消息 → 跳过写入(不落库)。
386
- const sanitize = (text: string): string | null => {
387
- if (!opts.desensitize) return text
388
- return desensitize(text)
389
- }
390
-
391
- // P1 完善(自动 recall 注入):每次模型请求组装 system prompt 时,注入灵枢最近记忆。
392
- // 用 system-prompt/assemble 事件(waterfall)而非 llm/stream——后者请求 deep-frozen 不可改写。
393
- //
394
- // ⚠️ 必须**每步都 push**,哪怕内容与上一步逐字节相同。原因在宿主侧(dsh-agent-loop 的
395
- // RuntimeContextProjection):assembly.contexts 会被渲染成一段「运行时上下文快照」,
396
- // 每个 step 拿渲染后的**整段文本**与上一份已提交的快照比对,**只有不同才**在会话里
397
- // append 一条新的 user/message(append 语义,旧的不会被替换或移除)。于是:
398
- // · 内容不变 + 照旧 push → 渲染文本不变 → 宿主不追加任何东西(零开销、零增长);
399
- // · 内容不变 + 跳过 push → 渲染文本**变了**(少了本块)→ 宿主追加一份「没有本块」的
400
- // 快照;下一步再 push 又把本块加回来 → **再**追加一份。跳过一次反而多花两份快照
401
- // (实测每份 ~250 tok),这正是 v0.4.8「每 8 步强制补一次」的自愈刷新会把长会话的
402
- // inject 推到 30k+ tok 的原因。
403
- // 因此本实现把「要不要补」交还给宿主:压缩归档后宿主会把 retained 置空并重新投影快照
404
- // (RuntimeContextProjection 的 retained === null 分支),本块自然跟着回来——
405
- // 不需要插件自己数步数做自愈。
406
- if (opts.autoRecall) {
407
- const recallLimit = Math.max(1, Math.min(10, opts.autoRecallLimit || 4))
408
- ctx.on('system-prompt/assemble', async (assembly, _ctx, next) => {
409
- try {
410
- // 异步取最近记忆节点(失败静默——不阻塞模型请求)
411
- if (graph.isReady()) {
412
- // 会话隔离(P45):自动召回只注入**本会话**的记忆,防多会话串台;
413
- // 取不到会话标识则退回旧行为(不加过滤),不做半吊子猜测。
414
- // ⚠️ 取值必须**每步稳定**:本块按 v0.4.8 契约每步都 push,内容一旦与上
415
- // 一步不同宿主就 append 一份新快照——会话标识若中途才出现,会让「无过滤
416
- // → 有过滤」翻转一次,白付两份快照。故优先取 ctx 上的会话(首步即在),
417
- // 退回「最近一次 session/event 的会话」。
418
- // 想读**所有**会话做了什么:别走自动召回(它会串台),显式调
419
- // `stg(op=timeline, session="*")`,返回项带 session 归属。
420
- const hostCtx = (_ctx as unknown) as { agent?: { session?: unknown } } | undefined
421
- const sid = sessionIdOf(hostCtx?.agent?.session) || lastSession
422
- const text = formatTimelineDecayed(
423
- await graph.timeline(recallLimit, sid ? { session: sid } : {}))
424
- if (text) {
425
- // 注入边界转义(issue #16):宿主 system-prompt 对 context 文本做严格
426
- // `{{variable}}` 插值,裸 `{{` 会 throw → 该轮请求整体失败。记忆原文
427
- // (含用户命令里的 `{{.X}}`)必须保真落库,故只在注入副本上打断 `{{`。
428
- //
429
- // H5(不可信内容边界):注入块**必经** renderUntrustedMemoryBlock——
430
- // 固定声明句(UNTRUSTED_MEMORY_NOTICE,常量单点)+ 显式边界标记 +
431
- // 载荷内边界标记的打断。记忆是任意用户输入/工具输出的沉淀,
432
- // 无边界时模型无从区分「数据」与「指令」(提示注入面)。
433
- // 三层顺序:边界渲染(含载荷打断)→ `{{` 转义;两者都只改注入副本。
434
- assembly.contexts.push({
435
- name: 'lingshu:auto-recall',
436
- text: escapePromptBraces(renderUntrustedMemoryBlock(
437
- `【灵枢最近记忆】\n${text.slice(0, RECALL_MAX_CHARS)}`)),
438
- })
439
- }
440
- }
441
- }
442
- catch { /* 静默:召回失败不影响请求 */ }
443
- return next()
444
- })
445
- }
446
-
447
- ctx.on('session/event', (session, event: SessionEvent) => {
448
- // H1(2026-09-30)**会话级**判据:子代理/委派子会话的自动记忆**整条会话**拦掉。
449
- // 位置在取 sid **之前**——子代理会话不得污染 lastSession,否则顶层会话的自动
450
- // 召回会拿子代理的 session 去读(读错会话)。字段缺失即不拦,见 isSubagentSession。
451
- if (isSubagentSession(session)) {
452
- ctx.logger.info('dsh-memory: 子代理会话的自动记忆被拦(H1:header.origin/delegationDepth)')
453
- return
454
- }
455
- // 会话归属(P45):记忆写入必须带会话身份,用来区分不同会话的记忆。
456
- // 空串 = 宿主未给出会话标识 → **显式标注 unassigned**(H2③:不落内核的进程级
457
- // 随机 sess_*,也不编造宿主会话 id——见 UNASSIGNED_SESSION 的注释)。
458
- const sid = sessionIdOf(session)
459
- if (sid) lastSession = sid
460
- const sessionTag = sid ? { session: sid } : { session: UNASSIGNED_SESSION }
461
- if (event.type === 'user/message' && opts.userMessage) {
462
- // H1 **消息级**判据:委派/中继消息(`form: 'relay'` 语义=「另一个 agent
463
- // 发给本 agent 的消息」)不写。先于 kind 判据,理由见 isRelayedMessage 注释。
464
- if (isRelayedMessage(event.data.source)) {
465
- ctx.logger.info('dsh-memory: 委派/中继消息的自动记忆被拦(H1:source.form=relay)')
466
- return
467
- }
468
- // 只记真实用户输入(kind='user'),跳过插件注入/系统上下文
469
- if (event.data.source?.kind !== 'user') {
470
- // T4 诊断(2026-08-30):dsh 端对话零写入排查——记录被滤事件的实际
471
- // source.kind(若 dsh 新版改了 kind 值,此处日志可定位)
472
- ctx.logger.info(`dsh-memory: user/message 事件被滤(source.kind=${event.data.source?.kind ?? 'undefined'})`)
473
- return
474
- }
475
- const text = extractText(event.data.content)
476
- if (!text) return
477
- const safe = sanitize(text) // 脱敏:纯凭据消息 → null → 跳过写入
478
- if (safe === null) return
479
- memorize('user', (g) => g.remember(safe, {
480
- role: 'user', tags: ['dsh', 'user'], importance: opts.importance,
481
- ...sessionTag,
482
- }))
483
- // T4:用用户消息做一次语义召回——md_cg 的读取会记 access log(复用观测,
484
- // 供 importance / scrub 陈旧度使用),同时预热检索路径。
485
- // (AEIS 侧的 `_note_reuse` 在 md_cg 中不存在,其等价物就是这次记访问。)
486
- //
487
- // H2③(2026-09-30):**显式带会话**。此前不传 session,靠服务端「cg 读路径
488
- // 丢弃请求 session」侥幸不串台;一旦读侧归一化在召回链路上生效,不传就等价于
489
- // 跨会话(全库)召回。此处走 `read(query, {k, ...sessionTag})`——与
490
- // `recall(query, k)` 是同一条 MCP 出口(`cg(op=read)`,见 mdcg_client.ts 的
491
- // recall → read),差别只在能带上会话槽;`recall` 没有 extra 形参,给它加槽要
492
- // 改 mdcg_client.ts(本件放行面之外),故在调用点改走等价出口。
493
- // 带上它不构成越权:cg 读路径的 session 是**归因/视图**维度,不参与任何授权
494
- // (issue #35 定稿「身份不可自报」,见 md_cg/mdcos.py 的 _candidates)。
495
- memorize('user-recall', (g) => g.read(safe.slice(0, 200), { k: 3, ...sessionTag }))
496
- } else if (event.type === 'assistant/message' && opts.assistantMessage) {
497
- const text = extractText(event.data.message.content)
498
- if (!text) return
499
- const safe = sanitize(text)
500
- if (safe === null) return
501
- memorize('assistant', (g) => g.remember(safe, {
502
- role: 'assistant', tags: ['dsh', 'assistant'], importance: opts.importance * 0.8,
503
- ...sessionTag,
504
- }))
505
- } else if (event.type === 'tool/result' && opts.toolResult) {
506
- if (event.data.error) return
507
- const text = extractText(event.data.message.content)
508
- if (!text) return
509
- const safe = sanitize(text)
510
- if (safe === null) return
511
- memorize('tool', (g) => g.remember(safe, {
512
- role: 'tool-output', tags: ['dsh', 'tool'], importance: opts.importance * 0.6,
513
- ...sessionTag,
514
- }))
515
- }
516
- })
517
- }
1
+ /**
2
+ * 自动记忆钩子:把 DSH 的会话事件(经统一的 session/event 分发)沉淀进灵枢。
3
+ *
4
+ * 记忆真源 = **md_cg 认知图(md 文档)**(2026-09-10 统一):本钩子经
5
+ * MdcgClient.remember() 调 MCP `mdcg_remember(gated=true)`(主动遗忘闸门:
6
+ * ACCEPT 落盘 / MERGE 并入既有 = 去重强化 / DROP 低熵 / DEFER 待定),
7
+ * 落层 contextual,role 取 user | assistant | tool-output —— 与 md_cg 对 DSH
8
+ * 会话事件的约定一致(md_cg/sources.py 的 SESSION_LAYER / DSHSessionSource)。
9
+ * ⚠️ 不用 `cg(op=write)`:那条路径先过 audit,未声明 content_kind 时永不落盘
10
+ * (见 src/lib/mdcg_client.ts 文件头)。
11
+ * 不再走 AEIS:AEIS 已降为能力库,不存记忆。
12
+ *
13
+ * ⚠️ 写入需凭据:md_cg fail-closed,无 MDCG_TOKEN / MDCG_LEGACY_ENV_AUTH=1
14
+ * 时降级为只读 guest —— 本钩子的写入会失败(仅告警,不影响对话)。
15
+ *
16
+ * 与 DSH 的 session-persistence 插件(保存会话日志)不同,这里是"语义沉淀":
17
+ * 带去重(闸门 MERGE)与重要性,写入前脱敏;agent 回复与工具结果可选开启。
18
+ * 只记忆真实用户消息(source.kind === 'user'),过滤插件注入的噪音;其上再叠一层
19
+ * **来源判定**(H1,2026-09-30):① **会话级**——子代理/委派子会话(SessionHeader 的
20
+ * `origin` / `delegationDepth`)的自动记忆**整条会话拦掉**;② **消息级**——
21
+ * `source.form === 'relay'`(「另一个 agent 发给本 agent 的消息」)不写。
22
+ * 两条判据均为「**字段在场且取值匹配才拦**」:字段缺失一律退化为不过滤(默认放行),
23
+ * 宿主字段面已在本机真实会话事件上观测(2026-10-05 订正:19 场,会话头
24
+ * `delegationDepth=0` 真实在写);**仍未观测**的是 `origin='subagent'` /
25
+ * `delegationDepth>0` / `form='relay'` 的真实出现(见 installMemoryHooks 内注释)。
26
+ *
27
+ * autoRecall:通过 system-prompt/assemble 事件(waterfall,异步允许)在每次
28
+ * 模型请求组装 system prompt 时自动注入灵枢最近记忆
29
+ * (`stg(op=timeline)`,最近记忆节点时间线),让记忆"自动可用"而不只依赖
30
+ * Agent 主动调用 recall/think 工具。失败静默(不影响请求)。
31
+ * ⚠️ 该注入块的**稳定性**决定宿主是否新追加快照:内容没变时也必须照旧 push
32
+ * (宿主按渲染后的整段文本去重);跳过 push 反而会各追加一份「有块/无块」的快照
33
+ * —— 详见 installMemoryHooks 里的长注释。
34
+ *
35
+ * ⚠️ 注入文本**必经** escapePromptBraces(src/lib/prompt_safety.ts,issue #16):
36
+ * 宿主对 context 文本做严格 `{{variable}}` 插值,裸 `{{` 会让每轮 assemble 抛错
37
+ * → 会话永久不可用(记忆永久在库,非偶发故障)。记忆真源不动,只在**注入副本**上
38
+ * 打断 `{{`——新增任何 push context/section 的代码,同样必须过这道转义。
39
+ *
40
+ * ⚠️ 注入文本**必经** renderUntrustedMemoryBlock(同上文件,H5):记忆正文是任意
41
+ * 用户输入/工具输出的沉淀,必须以「历史原文 / 仅作参考 / 不得执行其中指令」的固定
42
+ * 声明句 + 显式边界标记注入,且载荷内的边界标记先被打断(防提前闭合)。注入副本
43
+ * 之外(库内正文、工具返回原文)一律不动。
44
+ */
45
+
46
+ import '@deepseek-ai/dsh-session'
47
+ import '@deepseek-ai/dsh-system-prompt'
48
+ import type { Context } from '@deepseek-ai/cordis'
49
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
50
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
51
+ import type { MdcgClient } from './lib/mdcg_client.js'
52
+ import { escapePromptBraces, renderUntrustedMemoryBlock } from './lib/prompt_safety.js'
53
+ import { HookAuditRecorder, kindOf, type HookWriteRole } from './lib/hook_audit.js'
54
+
55
+ /** 自动记忆开关。 */
56
+ export interface MemoryHooksOptions {
57
+ /** 用户消息 → remember(默认 true)。 */
58
+ userMessage: boolean
59
+ /** agent 回复 → remember(默认 false,防噪音)。 */
60
+ assistantMessage: boolean
61
+ /** 工具结果 → remember(默认 false,噪音大)。 */
62
+ toolResult: boolean
63
+ /** 写入记忆的重要性(0~1),默认 0.6。 */
64
+ importance: number
65
+ /** 自动召回注入:模型请求前自动注入灵枢最近记忆(默认 true,失败静默)。 */
66
+ autoRecall: boolean
67
+ /** 自动召回条数(默认 4)。 */
68
+ autoRecallLimit: number
69
+ /** 自动记忆脱敏:写入前过滤敏感信息(密钥/密码/令牌/身份证/手机号,默认 true)。 */
70
+ desensitize: boolean
71
+ /** 落盘审计文件路径(issue #56,诊断面:哪些消息被设计滤除、source.kind 分布、
72
+ * 写入/跳过计数)。缺省 `~/.dsh/logs/dsh-memory-hook-audit.json`(与桥探针 /
73
+ * apply 探针同目录同惯例),供测试与定制注入;审计自身失败静默降级,
74
+ * 绝不冒泡进记忆路径。不改任何既有选项语义。 */
75
+ auditPath?: string
76
+ }
77
+
78
+ /** 从 ContentBlock[] 提取纯文本。 */
79
+ function extractText(blocks: ContentBlock[]): string {
80
+ const parts: string[] = []
81
+ for (const block of blocks) {
82
+ if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
83
+ parts.push(block.text)
84
+ }
85
+ }
86
+ return parts.join('\n').trim()
87
+ }
88
+
89
+ // ---------------------------------------------------------------- 脱敏字符集(单点)
90
+ // M5(全角凭据绕过):值类与词形字符集必须**两宽齐备**。
91
+ //
92
+ // 背景(探针实测):此前值类字符集全是半角——`[A-Za-z0-9_@#$%^&*!.-]`、
93
+ // `[^\s,,。;;]`、`sk-` 后限定 `[A-Za-z0-9_-]{8,}`——故
94
+ // `密码:password123456` / `api_key=…` / `sk-abcd…`
95
+ // 这类**全角写法全部漏检**(filtered=false)。全角形态是独立 Unicode 区段
96
+ // (U+FF01-U+FF5E ↔ U+0021-U+007E 一一对应),`\w`/`\d`/`\b` 一律不认,必须显式列出。
97
+ //
98
+ // 为何**不**走「先全角→半角归一化再匹配」(两条路的取舍,依据如下):
99
+ // ① NFKC 归一化会**改变长度**(`㍿`→`株式会社`、半角カナ `パ`→`パ`、`㈱`→`(株)`)
100
+ // ⇒ 匹配片段无法映射回原文偏移,替换要么错位要么漏改——静默失真;
101
+ // ② 若退一步「归一化后整段替换」,等于把正文里的全角标点(:=_())统统
102
+ // 半角化——**改写记忆真源**,与「原文保真、只在注入边界改写」的既有纪律相悖;
103
+ // ③ 归一化会抹平 `.`(U+FF0E,`.` 的全角形态) 与 `。`(U+3002,中文句号) 的区别,
104
+ // 值类边界随之漂移——本题要求说明的误伤面正在这里。
105
+ // 故取**显式两宽字符类**:替换只覆盖命中的凭据片段,正文其余字符逐字节零改写;
106
+ // 且「每条规则的值类两宽齐备」成为可机械断言的性质
107
+ // (守卫 test/fullwidth_redact.test.ts ①/②/④)。
108
+ //
109
+ // 边界(如实):两宽类刻意**不含**中文句读 ,;。!?、 —— 它们不在半角类里,
110
+ // 引入会让值类跨句吞并(`.` 是 `.` 的全角形态、属值类,故收录)。
111
+
112
+ /** 全角数字(U+FF10-U+FF19)。 */
113
+ const FW_DIGITS = '0-9'
114
+ /** 全角大写字母(U+FF21-U+FF3A)。 */
115
+ const FW_UPPER = 'A-Z'
116
+ /** 全角小写字母(U+FF41-U+FF5A)。 */
117
+ const FW_LOWER = 'a-z'
118
+
119
+ /** 两宽「词字符」类:半角 + 全角 字母/数字/下划线(`\w`/`\b` 的替代判据面)。
120
+ * 导出供守卫核对字面量规则的同源性(test/fullwidth_redact.test.ts ⑦);
121
+ * 对外仍属内部实现,不承诺稳定 ABI。 */
122
+ export const WORD_CHARS = `A-Za-z0-9_${FW_DIGITS}${FW_UPPER}${FW_LOWER}_`
123
+ /** 两宽数字类。 */
124
+ const DIGIT_CHARS = `0-9${FW_DIGITS}`
125
+ /** 两宽凭据值类:词字符 + `@ # $ % ^ & * ! . -` 的两宽形态。
126
+ * ASCII 连字符一律转义(`\\-`)——`[_--]` 会被解析成 `_`→`-` 的**巨区间**。
127
+ *
128
+ * N265(本轮补齐):半角 `&` 与全角 `#%^*` 此前缺位——其对照形态(全角 `&`、
129
+ * 半角 `# % ^ *`)早在类里,构成「同形不同过滤」:缺口字符落在值前 4 位内 ⇒ 整条漏检、
130
+ * 落在第 4 位之后 ⇒ 命中被截断在缺口处、尾部留明文。守卫
131
+ * test/fullwidth_redact.test.ts ⑧(形态一)与 ⑨(形态二)钉住这两只退化形态。
132
+ *
133
+ * ⚠️ 全角 `!` **不在**本类,且与半角 `!` 不对称(`!` 在类)——这是**有意保留**的现口径:
134
+ * 本文件「边界」段与同文件守卫 ④ 把全角叹号钉为句读边界(值在此收住)。
135
+ * 2026-10-05 使用者裁决:判 by-design——保留全角 ! 为句读边界,不得并入值类
136
+ * (并入必动守卫④),**勿在补字符集时顺手塞进来**——否则守卫 ④ 会红。 */
137
+ const CRED_VALUE_CHARS = `${WORD_CHARS}@@##$$%%^^&&**!.\\--.`
138
+ /** 两宽令牌值类(本项目令牌 id/secret 的字符集:词字符 + `-`)。
139
+ * 导出供守卫核对字面量规则的同源性(同 ⑦),不承诺稳定 ABI。 */
140
+ export const TOKEN_VALUE_CHARS = `${WORD_CHARS}\\--`
141
+ /** 两宽 Bearer 值类(原半角集 `A-Za-z0-9._~+/=-` + 其全角形态)。 */
142
+ const BEARER_VALUE_CHARS =
143
+ `A-Za-z0-9._~+/=\\-${FW_DIGITS}${FW_UPPER}${FW_LOWER}_.~/+=-`
144
+
145
+ /** ASCII 可见字符 → 全角等价(U+0021-U+007E ↔ U+FF01-U+FF5E);其余原样返回。 */
146
+ function toFullwidth(ch: string): string {
147
+ const code = ch.charCodeAt(0)
148
+ return code >= 0x21 && code <= 0x7e ? String.fromCharCode(code + 0xfee0) : ch
149
+ }
150
+
151
+ /** 半角 ASCII 词 → 「半角|全角」等价类(M5 单点:关键词的两宽形态只此一处生成)。
152
+ * 仅接受 `[A-Za-z0-9_-]`——含正则元字符的词会让等价类语法失真,故 fail-closed 抛错。 */
153
+ function twoWidth(word: string): string {
154
+ let out = ''
155
+ for (const ch of word) {
156
+ if (!/[A-Za-z0-9_-]/.test(ch)) {
157
+ throw new Error(`twoWidth 只接受半角字母数字/下划线/连字符,收到:${word}`)
158
+ }
159
+ out += `[${ch}${toFullwidth(ch)}]`
160
+ }
161
+ return out
162
+ }
163
+
164
+ /**
165
+ * 敏感信息模式(GPT 审查·自动记忆脱敏):写入认知图前过滤凭据/个人标识。
166
+ * 命中 → 替换为 [已过滤:类别](保留对话主体);过滤后只剩占位符/空白 → 整条跳过。
167
+ * 纯内容过滤,不涉及身份认证——开源场景下的隐私保护。
168
+ *
169
+ * M5:值类/词形两宽齐备(见上方字符集注释);`\b` 全部换成两宽 lookaround——
170
+ * `\b` 只认半角 `\w`,`[sk−…]` 这类以全角起首的串在串首**根本取不到词边界**。
171
+ */
172
+ // 导出供守卫使用(test/token_redact_parity.test.ts 需要按「交换序」复跑同一条链,
173
+ // 以证明顺序不再是安全性质);对外仍属内部实现,不承诺稳定 ABI。
174
+ export const SENSITIVE_PATTERNS: Array<{ re: RegExp; label: string }> = [
175
+ { re: new RegExp(`${twoWidth('sk')}[\\--][${TOKEN_VALUE_CHARS}]{8,}`, 'g'), label: 'API密钥' },
176
+ { re: new RegExp(
177
+ `(?<![${WORD_CHARS}])`
178
+ + `(?:${twoWidth('api')}[__\\--]?${twoWidth('key')}|${twoWidth('apikey')}`
179
+ + `|${twoWidth('access')}[__\\--]?${twoWidth('token')})`
180
+ + `(?![${WORD_CHARS}])\\s*[:==:]\\s*[^\\s,,。;;]+`, 'gi'), label: 'API密钥' },
181
+ { re: new RegExp(
182
+ `(?<![${WORD_CHARS}])`
183
+ + `(?:${twoWidth('password')}|${twoWidth('passwd')}|${twoWidth('pwd')})`
184
+ + `(?![${WORD_CHARS}])\\s*[:==:]\\s*[^\\s,,。;;]+`, 'gi'), label: '密码' },
185
+ { re: new RegExp(`${twoWidth('Bearer')}\\s+[${BEARER_VALUE_CHARS}]{8,}`, 'gi'), label: '令牌' },
186
+ // 中文/日文密码标签:值限定非中文连续串(凭据特征),避免误伤「密码是重要的安全概念」;
187
+ // 分隔符补全角等号 `=`(半角 `=` 本就不在本规则的集合里,故只补全角形态)。
188
+ // N269:词形补齐——繁体「密碼」、日文「パスワード」、中文「口令」与「密码」同表同口径
189
+ //(三者此前整条漏检:界面承诺 :66「密码…默认过滤」,却认不出这三种常见写法);
190
+ // 日文 `は` 一类助词**刻意不并入分隔符集合**(`パスワードは重要です` 是普通句子,
191
+ // 并进去即刻误伤,与「值类不含中文」同一取舍)。
192
+ { re: new RegExp(`(?:密码|密碼|口令|パスワード)\\s*[::是=]\\s*[${CRED_VALUE_CHARS}]{4,}`, 'g'), label: '密码' },
193
+ // 两宽:全角数字形态同样要被认(`\b` 换成两宽 lookaround,见上)
194
+ { re: new RegExp(`(?<![${WORD_CHARS}])[${DIGIT_CHARS}]{17}[${DIGIT_CHARS}XxXx](?![${WORD_CHARS}])`, 'g'), label: '身份证号' },
195
+ { re: new RegExp(`(?<![${WORD_CHARS}])[11][3-93-9][${DIGIT_CHARS}]{9}(?![${WORD_CHARS}])`, 'g'), label: '手机号' },
196
+ // N269:凭据标签词形的「标签: 值」形态——密钥 / token / secret / creds 此前整条漏检
197
+ //(界面承诺 :66「密钥/密码/令牌…默认过滤」,而裸 `token:`/`secret=`/`creds=` 一个都不认)。
198
+ // 值类取共享单点 CRED_VALUE_CHARS 并设 `{4,}` 下限(与中文密码规则同口径):比
199
+ // 「任意非空白续写」窄,避免吃掉 `token: 这句是说明文字` 一类普通文本;词形用
200
+ // twoWidth 单点生成(半角/全角拼写都认,`TOKEN:…` 同样命中);标签前后沿用
201
+ // 两宽 lookaround(`mytoken=`/`tokenize:` 不命中)。
202
+ // 位置约束:两条令牌规则必须留在数组**最后两位**(test/token_redact_parity.test.ts
203
+ // 的交换序按 n-2/n-1 取它们做对拍),故本规则插在其前。
204
+ { re: new RegExp(
205
+ `(?<![${WORD_CHARS}])`
206
+ + `(?:密钥|${twoWidth('token')}|${twoWidth('secret')}|${twoWidth('creds')})`
207
+ + `(?![${WORD_CHARS}])\\s*[:==:]\\s*[${CRED_VALUE_CHARS}]{4,}`, 'gi'), label: '密钥' },
208
+ // 本项目自有令牌(issue #45):批次71 已把形态加进**写入闸门**的禁表,但自动
209
+ // 记忆走的是 mdcg_remember(gated=true)、**不过 audit**,此处是这条路上唯一的
210
+ // 防线——此前不认自家令牌,用户粘一次即明文落进共用记忆库。
211
+ // 顺序要点:**完整令牌在前**。四段形态为 `mdcg1.<role>.<token_id>.<secret>`
212
+ // (md_cg/tokens.py:make_token),若先匹配裸 token_id,secret 段会留成明文
213
+ // (实测:`…designer.[已过滤:id].SECRET…`),故整条令牌必须整段吃掉。
214
+ //
215
+ // 2026-09-28 加固(PR#46 合并当批):**去 `\b` 词边界、role/secret 字符类放宽、
216
+ // secret 下限 16→8**——原式有三处「整条规则失配 ⇒ id 规则独吃 id、secret 留明文」
217
+ // 的触发面(实测复现):① 前导为词字符(`k_mdcg1.…`、`a mdcg1.…` 紧邻字母数字下划线
218
+ // 时 `\b` 失效);② role 含非字母(如 `sub-agent1`——`parse_token` 只要求非空,
219
+ // 不校验字符集);③ secret 短于 16 字符(`make_token` 不校验长度)。三者都让整条规则
220
+ // 失配,而裸 id 规则照旧命中 ⇒ secret 明文落库(与顺序错配同一形态)。放宽后与禁表
221
+ // 第 11 条 `mdcg1\.[A-Za-z0-9._\-]{20,}`(本就无 `\b`)同口径。
222
+ // M5(两宽):四段令牌的**值类**(role/id/secret)与分隔点 `.,` 全部两宽;前缀写成
223
+ // 「半角|全角」两种**拼写**的互斥分支(`(?:mdcg1|mdcg1)`)——两分支是不同字符,
224
+ // 故不是冗余、也不是字符类。
225
+ //
226
+ // ⚠️ 这两条令牌规则**必须保持 `/…/g` 字面量、单行且 Python 兼容**:形态守卫
227
+ // `md_cg/test_token_lowercase_form.py` 的 G7d~G7g 逐行抽取本文件的 `/…/g` 字面,
228
+ // 再用 Python `re` 复跑(定宽后顾 `(?<!…)`、字符类里的全角字面量在 Python `re` 下同义)。
229
+ // 改成 `new RegExp(...)` 拼装会让那条守卫抽不到规则(G7d 直接红,实测见本批报告),
230
+ // 故此处**不**用 `twoWidth()` 组合;值类字面量须与共享字符集常量同源,
231
+ // 由 test/fullwidth_redact.test.ts ⑦ 机械核对。
232
+ { re: /(?:mdcg1|mdcg1)[..][A-Za-z0-9_0-9A-Za-z_\--]+[..][A-Za-z0-9_0-9A-Za-z_]+[..][A-Za-z0-9_0-9A-Za-z_\--]{8,}/g, label: '令牌' },
233
+ // 裸令牌 id(`tk_` + 12 位 hex,1.15e14 空间不可猜——由 tokens.py 的
234
+ // `secrets.token_hex(6)` 生成;id 本身即凭据,与禁表 `\btk_[0-9a-f]{8,}\b` 同形)。
235
+ //
236
+ // 加固:**把尾随的 `.secret` 段一并吃掉**(`(?:\.[A-Za-z0-9_-]{4,})?`)。不变量=
237
+ // 「id 规则绝不能只吃 id、把 secret 留给下一条规则或留给用户」——顺序正确时那条尾巴
238
+ // 由整条规则先吃;顺序被改、或整条规则因任何理由失配时,id 规则自己带上尾巴,
239
+ // **顺序从此不再是安全性质**(防御纵深,由 test/token_redact_parity.test.ts ④ 钉住)。
240
+ // 下限取 4 是为了不吃掉 `tk_…py` / `tk_…md` 这类短文件名尾巴。
241
+ // M5(两宽):`\b` 只认半角 `\w`(`tk_…` 在串首取不到词边界 ⇒ 整条漏检),
242
+ // 换成两宽后顾;hex 值类、尾巴分隔点 `.,` 一并两宽,前缀同「半角|全角互斥分支」。
243
+ // 字面量/Python 兼容要求同上一条(形态守卫 G7d~G7g 抽取 `/…/g` 字面复跑)。
244
+ { re: /(?<![A-Za-z0-9_0-9A-Za-z_])(?:tk_|tk_)[0-9a-f0-9a-f]{8,}(?:[..][A-Za-z0-9_0-9A-Za-z_\--]{4,})?/g, label: '令牌id' },
245
+ ]
246
+
247
+ /** 脱敏:替换敏感片段;返回 null 表示整条都是敏感内容(应跳过写入)。 */
248
+ export function desensitize(text: string): string | null {
249
+ let out = text
250
+ for (const { re, label } of SENSITIVE_PATTERNS) {
251
+ out = out.replace(re, `[已过滤:${label}]`)
252
+ }
253
+ // 过滤后只剩占位符/空白 → 纯凭据消息,不写(或全部被替换)
254
+ const residue = out.replace(/\[已过滤:[^\]]+\]/g, '').trim()
255
+ if (!residue) return null
256
+ return out
257
+ }
258
+
259
+ /** 时间线载荷 → 注入文本。
260
+ * `stg(op=timeline)` 返回 {count, limit, items:[{id, layer, start, end, preview}]}。
261
+ * (保留原始实现;自动召回改用下面的分级递减渲染) */
262
+ function formatTimeline(payload: unknown): string {
263
+ const items = (payload && typeof payload === 'object'
264
+ && Array.isArray((payload as { items?: unknown }).items))
265
+ ? (payload as { items: Array<Record<string, unknown>> }).items
266
+ : []
267
+ return items
268
+ .map((it) => {
269
+ const preview = String(it['preview'] ?? '').replace(/\s+/g, ' ').trim()
270
+ if (!preview) return ''
271
+ const layer = it['layer'] ? `[${String(it['layer'])}] ` : ''
272
+ return `- ${layer}${preview}`
273
+ })
274
+ .filter(Boolean)
275
+ .join('\n')
276
+ }
277
+
278
+ // ---------------------------------------------------------------- 自动召回渲染
279
+ // 常量写死在此处(而非 config schema)——未知键会被 schema 剥离。
280
+ /** 第 1 档(最新 1 条)每条字符上限。 */
281
+ const RECALL_BASE_CHARS = 160
282
+ /** 每 N 条降一档。 */
283
+ const RECALL_DECAY_EVERY = 1
284
+ /** 每档缩放比例(−10%)。 */
285
+ const RECALL_DECAY_RATIO = 0.9
286
+ /** 最小保留字符。 */
287
+ const RECALL_MIN_CHARS = 24
288
+ /** 整块上限(与调用点 slice 对齐)。 */
289
+ const RECALL_MAX_CHARS = 1400
290
+ /** 永久层不自动注入(按需用 mdcg_recall / lingshu_stg 取)。 */
291
+ const RECALL_SKIP_LAYERS = new Set(['anchor', 'self'])
292
+
293
+ /** 分级递减渲染:按距当前的次序逐档收窄,早期条目信息量更大。
294
+ *
295
+ * 动机:注入块总长受限,而"最近 N 条"里越靠前的越可能被用到;线性等宽分配
296
+ * 会让整块被最旧的一条挤掉。逐档递减后整块实测约 1133 字符(≈472 tok),
297
+ * 9 条全部保留。(注意:整块仍远小于一条知识节点 500~800 tok,故本块定位是
298
+ * 「存在性索引/提醒」,不承载知识本身——要知识请显式 mdcg_recall 并给足预算。) */
299
+ function formatTimelineDecayed(payload: unknown): string {
300
+ const items = (payload && typeof payload === 'object'
301
+ && Array.isArray((payload as { items?: unknown }).items))
302
+ ? (payload as { items: Array<Record<string, unknown>> }).items
303
+ : []
304
+ const out: string[] = []
305
+ let rank = 0
306
+ for (const it of items) {
307
+ const layer = String(it['layer'] ?? '')
308
+ if (RECALL_SKIP_LAYERS.has(layer)) continue
309
+ const preview = String(it['preview'] ?? '').replace(/\s+/g, ' ').trim()
310
+ if (!preview) continue
311
+ const tier = Math.floor(rank / RECALL_DECAY_EVERY)
312
+ const budget = Math.max(
313
+ RECALL_MIN_CHARS,
314
+ Math.round(RECALL_BASE_CHARS * Math.pow(RECALL_DECAY_RATIO, tier)),
315
+ )
316
+ out.push(`- [${layer}] ` + (preview.length > budget ? preview.slice(0, budget) + '…' : preview))
317
+ rank += 1
318
+ if (out.join('\n').length >= RECALL_MAX_CHARS) break
319
+ }
320
+ return out.join('\n').slice(0, RECALL_MAX_CHARS)
321
+ }
322
+
323
+ /** 取宿主会话标识(只用于**归因/隔离**,不参与任何权限判断)。
324
+ *
325
+ * 动机:记忆写入必须带会话身份才能区分不同会话;读取默认只看本会话(防串台),
326
+ * 而「所有会话做了什么」用显式 session="*" 取。两侧都依赖这个标识。
327
+ *
328
+ * 字段名按 DSH 既有形态(`id` / `sessionId`)防御式读取,取不到就返回空串——
329
+ * 空串在上游一律等同「不分会话」(退回旧行为),故宿主改字段名最坏只是失去
330
+ * 隔离能力,不会注入错块、不会抛错。 */
331
+ function sessionIdOf(raw: unknown): string {
332
+ const s = raw as { id?: unknown; sessionId?: unknown } | null | undefined
333
+ const v = s?.id ?? s?.sessionId
334
+ return typeof v === 'string' ? v.trim() : ''
335
+ }
336
+
337
+ /** 会话归属未知时的**显式占位**(H2③,2026-09-30)。
338
+ *
339
+ * ⚠️ 不可退回「不传 session 键」:md_cg 的 `Principal.__init__` 在 session 为假值时
340
+ * 生成**进程级随机** `sess_<hex>`(md_cg/security.py:117)——插件不传,等于让一个
341
+ * 进程内所有「宿主未给标识」的会话共用一个**不可辨认**的随机桶:归属在审计上既
342
+ * 读不出是谁、跨进程也对不上,是静默的归属丢失。
343
+ * 本常量把这一态写成**显式值**:跨进程一致、可辨认、可审计,且不是伪造的宿主
344
+ * 会话 id(非 DSH 形态,服务端 `_normalize_session` 原样采用、不会被改写成别的桶)。
345
+ * 要读这个桶:`stg(op=timeline, session="unassigned")`。 */
346
+ const UNASSIGNED_SESSION = 'unassigned'
347
+
348
+ /** H1 **会话级**判据:这条 session 是否「子代理/委派子会话」。
349
+ *
350
+ * 字段来源(DSH 类型面,dsh-session/lib/types/types.d.ts):实装 DSH 2.0
351
+ * (`dsh-0.2.0-rc.2`):81 `header.origin?: 'subagent'`「Coarse product
352
+ * classification for a session created as a subagent child」;:87
353
+ * `header.delegationDepth?: number`「absent (zero) for a top-level session,
354
+ * parent depth + 1 for a subagent child」(本仓 devDeps `0.1.0-rc.8` 同字段
355
+ * 在 :64 / :70)。
356
+ *
357
+ * ⚠️ **观测面(2026-10-05 订正)**:本机**已装** DSH 2.0(`dsh-0.2.0-rc.2`);
358
+ * 19 场真实会话(`sessions/…/session.v4.jsonl.zstd`)观测到会话头
359
+ * `delegationDepth=0` 真实在写(19/19),`origin` 键从未出现。**仍未观测**:
360
+ * `origin='subagent'` / `delegationDepth>0` 的真实出现。故判据取
361
+ * 「**字段在场且取值匹配才拦**」的形态:header 缺失 / 非对象 / 两个字段都取不到
362
+ * 或不匹配 → 一律返回 false(**不拦**,安全退化为既有行为),绝不因字段缺失而
363
+ * 报错,也不因此改变既有写入行为。
364
+ *
365
+ * 默认行为(显式声明):**子代理会话的自动记忆默认拦掉**(默认拦)——委派指令是
366
+ * 「另一个 agent 发给本 agent 的指令」,不是用户的长期记忆,写进来会污染真人记忆;
367
+ * 而判据不确定时**默认放行**(字段缺失即不拦)——宁可多记,不可因宿主字段缺失
368
+ * 而静默丢掉真人记忆。 */
369
+ function isSubagentSession(session: unknown): boolean {
370
+ const header = (session as { header?: unknown } | null | undefined)?.header
371
+ if (!header || typeof header !== 'object') return false
372
+ const h = header as { origin?: unknown; delegationDepth?: unknown }
373
+ if (h.origin === 'subagent') return true
374
+ const depth = h.delegationDepth
375
+ return typeof depth === 'number' && Number.isFinite(depth) && depth > 0
376
+ }
377
+
378
+ /** H1 **消息级**判据:这条消息是否是「另一个 agent 发给本 agent 的」(委派/中继)。
379
+ *
380
+ * 字段来源(DSH 类型面,dsh-llm/lib/types/message.d.ts):实装 DSH 2.0 里 `'relay'`
381
+ * 在 `ContextFormed` 判别联合上(:90 `readonly form: 'relay';`;:55-56 注释原文
382
+ * 「A message another agent addressed to this one」),该联合由各生产者按需混入自己的
383
+ * source 类型——2.0 的 `MessageSourceMap` 注释明确**无共享 catch-all `plugin`
384
+ * 类别**,`kind` 由各生产者声明在自己的模块里(:94-100;本仓 devDeps
385
+ * `0.1.0-rc.8` 同段在 :52 / :86)。
386
+ *
387
+ * ⚠️ **观测面(2026-10-05 订正)**:本机**已装** DSH 2.0;19 场真实会话中
388
+ * `form='relay'` 从未出现(270 条 `user/message` 实测无 relay),即**真实出现
389
+ * 仍未观测**。故同样取「字段在场且取值匹配才拦」;source 缺失/非对象 → false。
390
+ *
391
+ * 与既有 `kind !== 'user'` 判据的关系:`kind` 非 `'user'` 的中继**本就被**那条拦掉
392
+ * (19 场实测的注入类 kind 均非 `'user'`);本判据放在它**之前**,是为了
393
+ * ① 不把委派判定押在 `source.kind` 单点上、② 覆盖「生产者把中继标成
394
+ * `kind='user'` 且带 form」这一类型面之外的形态——子会话的**首轮用户提示**就可能
395
+ * 是这种:它与真人输入在 `kind` 上不可分,只有会话级判据(或这里的 form)能拦。 */
396
+ function isRelayedMessage(source: unknown): boolean {
397
+ const s = source as { form?: unknown } | null | undefined
398
+ return !!s && typeof s === 'object' && s.form === 'relay'
399
+ }
400
+
401
+ /** 安装自动记忆钩子(effect 作用域内,随插件卸载自动移除)。
402
+ *
403
+ * mdcg 为 null(config.mdcg.enabled=false)时自动记忆整体停用:记忆真源是
404
+ * 认知图,没有它就没有可写的去处——**不会退回 AEIS**(AEIS 已不存记忆)。 */
405
+ export function installMemoryHooks(ctx: Context, mdcg: MdcgClient | null, opts: MemoryHooksOptions): void {
406
+ if (!mdcg) {
407
+ ctx.logger.warn('dsh-memory: 认知图未启用(config.mdcg.enabled=false),自动记忆已停用')
408
+ return
409
+ }
410
+ const graph = mdcg
411
+
412
+ /** 落盘审计(issue #56,诊断面):三处过滤分支与写入/跳过路径各留一道**盘面**
413
+ * 痕迹——宿主 logger 不落盘时,单看记忆侧无法区分「被设计滤除」与
414
+ * 「写入失败/漏记」。审计失败静默降级(见 src/lib/hook_audit.ts),
415
+ * 绝不冒泡进记忆路径、不改任何写入/过滤判定。 */
416
+ const audit = new HookAuditRecorder(opts.auditPath)
417
+
418
+ /** 最近一次观测到的宿主会话标识(见 sessionIdOf;空串 = 未知/无会话)。 */
419
+ let lastSession = ''
420
+
421
+ /** 记忆沉淀(fire-and-forget)。认知图未就绪则跳过并告警(不退回 AEIS)。
422
+ * `role`=null 表示**读预热**(user-recall)——审计只统计写入路径,
423
+ * 故读预热不参与 written/skipped 计数(它不写记忆)。 */
424
+ const memorize = (label: string, role: HookWriteRole | null, run: (g: MdcgClient) => Promise<unknown>): void => {
425
+ if (!graph.isReady()) {
426
+ if (role) audit.skipped('not_ready', role)
427
+ ctx.logger.warn(`dsh-memory: 认知图未就绪,跳过自动记忆(${label})`)
428
+ return
429
+ }
430
+ if (role) audit.written(role)
431
+ void run(graph).catch((err: Error) => {
432
+ if (role) audit.skipped('failed', role)
433
+ ctx.logger.warn(`dsh-memory: 自动记忆 ${label} 失败: ${err.message}`)
434
+ })
435
+ }
436
+
437
+ // P1 完善(GPT 审查·自动记忆脱敏):写入前过滤敏感信息(默认开启)。
438
+ // 命中敏感模式 → 替换为 [已过滤:类别];纯凭据消息 → 跳过写入(不落库)。
439
+ const sanitize = (text: string): string | null => {
440
+ if (!opts.desensitize) return text
441
+ return desensitize(text)
442
+ }
443
+
444
+ // P1 完善(自动 recall 注入):每次模型请求组装 system prompt 时,注入灵枢最近记忆。
445
+ // 用 system-prompt/assemble 事件(waterfall)而非 llm/stream——后者请求 deep-frozen 不可改写。
446
+ //
447
+ // ⚠️ 必须**每步都 push**,哪怕内容与上一步逐字节相同。原因在宿主侧(dsh-agent-loop 的
448
+ // RuntimeContextProjection):assembly.contexts 会被渲染成一段「运行时上下文快照」,
449
+ // 每个 step 拿渲染后的**整段文本**与上一份已提交的快照比对,**只有不同才**在会话里
450
+ // append 一条新的 user/message(append 语义,旧的不会被替换或移除)。于是:
451
+ // · 内容不变 + 照旧 push → 渲染文本不变 → 宿主不追加任何东西(零开销、零增长);
452
+ // · 内容不变 + 跳过 push → 渲染文本**变了**(少了本块)→ 宿主追加一份「没有本块」的
453
+ // 快照;下一步再 push 又把本块加回来 → **再**追加一份。跳过一次反而多花两份快照
454
+ // (实测每份 ~250 tok),这正是 v0.4.8「每 8 步强制补一次」的自愈刷新会把长会话的
455
+ // inject 推到 30k+ tok 的原因。
456
+ // 因此本实现把「要不要补」交还给宿主:压缩归档后宿主会把 retained 置空并重新投影快照
457
+ // (RuntimeContextProjection 的 retained === null 分支),本块自然跟着回来——
458
+ // 不需要插件自己数步数做自愈。
459
+ if (opts.autoRecall) {
460
+ const recallLimit = Math.max(1, Math.min(10, opts.autoRecallLimit || 4))
461
+ ctx.on('system-prompt/assemble', async (assembly, _ctx, next) => {
462
+ try {
463
+ // 异步取最近记忆节点(失败静默——不阻塞模型请求)
464
+ if (graph.isReady()) {
465
+ // 会话隔离(P45):自动召回只注入**本会话**的记忆,防多会话串台;
466
+ // 取不到会话标识则退回旧行为(不加过滤),不做半吊子猜测。
467
+ // ⚠️ 取值必须**每步稳定**:本块按 v0.4.8 契约每步都 push,内容一旦与上
468
+ // 一步不同宿主就 append 一份新快照——会话标识若中途才出现,会让「无过滤
469
+ // → 有过滤」翻转一次,白付两份快照。故优先取 ctx 上的会话(首步即在),
470
+ // 退回「最近一次 session/event 的会话」。
471
+ // 想读**所有**会话做了什么:别走自动召回(它会串台),显式调
472
+ // `stg(op=timeline, session="*")`,返回项带 session 归属。
473
+ const hostCtx = (_ctx as unknown) as { agent?: { session?: unknown } } | undefined
474
+ const sid = sessionIdOf(hostCtx?.agent?.session) || lastSession
475
+ const text = formatTimelineDecayed(
476
+ await graph.timeline(recallLimit, sid ? { session: sid } : {}))
477
+ if (text) {
478
+ // 注入边界转义(issue #16):宿主 system-prompt 对 context 文本做严格
479
+ // `{{variable}}` 插值,裸 `{{` 会 throw → 该轮请求整体失败。记忆原文
480
+ // (含用户命令里的 `{{.X}}`)必须保真落库,故只在注入副本上打断 `{{`。
481
+ //
482
+ // H5(不可信内容边界):注入块**必经** renderUntrustedMemoryBlock——
483
+ // 固定声明句(UNTRUSTED_MEMORY_NOTICE,常量单点)+ 显式边界标记 +
484
+ // 载荷内边界标记的打断。记忆是任意用户输入/工具输出的沉淀,
485
+ // 无边界时模型无从区分「数据」与「指令」(提示注入面)。
486
+ // 三层顺序:边界渲染(含载荷打断)→ `{{` 转义;两者都只改注入副本。
487
+ assembly.contexts.push({
488
+ name: 'lingshu:auto-recall',
489
+ text: escapePromptBraces(renderUntrustedMemoryBlock(
490
+ `【灵枢最近记忆】\n${text.slice(0, RECALL_MAX_CHARS)}`)),
491
+ })
492
+ }
493
+ }
494
+ }
495
+ catch { /* 静默:召回失败不影响请求 */ }
496
+ return next()
497
+ })
498
+ }
499
+
500
+ ctx.on('session/event', (session, event: SessionEvent) => {
501
+ // H1(2026-09-30)**会话级**判据:子代理/委派子会话的自动记忆**整条会话**拦掉。
502
+ // 位置在取 sid **之前**——子代理会话不得污染 lastSession,否则顶层会话的自动
503
+ // 召回会拿子代理的 session 去读(读错会话)。字段缺失即不拦,见 isSubagentSession。
504
+ if (isSubagentSession(session)) {
505
+ ctx.logger.info('dsh-memory: 子代理会话的自动记忆被拦(H1:header.origin/delegationDepth)')
506
+ audit.filtered('subagent')
507
+ return
508
+ }
509
+ // 会话归属(P45):记忆写入必须带会话身份,用来区分不同会话的记忆。
510
+ // 空串 = 宿主未给出会话标识 → **显式标注 unassigned**(H2③:不落内核的进程级
511
+ // 随机 sess_*,也不编造宿主会话 id——见 UNASSIGNED_SESSION 的注释)。
512
+ const sid = sessionIdOf(session)
513
+ if (sid) lastSession = sid
514
+ const sessionTag = sid ? { session: sid } : { session: UNASSIGNED_SESSION }
515
+ if (event.type === 'user/message' && opts.userMessage) {
516
+ // 落盘审计(issue #56):source.kind 分布——先于两级判据记录**完整**输入分布
517
+ // (含被滤与放行),「宿主到底给这条消息标了什么 kind」是排障第一问。
518
+ audit.observeKind(kindOf(event.data.source))
519
+ // H1 **消息级**判据:委派/中继消息(`form: 'relay'` 语义=「另一个 agent
520
+ // 发给本 agent 的消息」)不写。先于 kind 判据,理由见 isRelayedMessage 注释。
521
+ if (isRelayedMessage(event.data.source)) {
522
+ ctx.logger.info('dsh-memory: 委派/中继消息的自动记忆被拦(H1:source.form=relay)')
523
+ audit.filtered('relay', kindOf(event.data.source))
524
+ return
525
+ }
526
+ // 只记真实用户输入(kind='user'),跳过插件注入/系统上下文
527
+ if (event.data.source?.kind !== 'user') {
528
+ // T4 诊断(2026-08-30):dsh 端对话零写入排查——记录被滤事件的实际
529
+ // source.kind(若 dsh 新版改了 kind 值,此处日志可定位)
530
+ ctx.logger.info(`dsh-memory: user/message 事件被滤(source.kind=${event.data.source?.kind ?? 'undefined'})`)
531
+ audit.filtered('kind', kindOf(event.data.source))
532
+ return
533
+ }
534
+ const text = extractText(event.data.content)
535
+ if (!text) return
536
+ const safe = sanitize(text) // 脱敏:纯凭据消息 → null → 跳过写入
537
+ if (safe === null) { audit.skipped('sanitized', 'user'); return }
538
+ memorize('user', 'user', (g) => g.remember(safe, {
539
+ role: 'user', tags: ['dsh', 'user'], importance: opts.importance,
540
+ ...sessionTag,
541
+ }))
542
+ // T4:用用户消息做一次语义召回——md_cg 的读取会记 access log(复用观测,
543
+ // 供 importance / scrub 陈旧度使用),同时预热检索路径。
544
+ // (AEIS 侧的 `_note_reuse` 在 md_cg 中不存在,其等价物就是这次记访问。)
545
+ //
546
+ // H2③(2026-09-30):**显式带会话**。此前不传 session,靠服务端「cg 读路径
547
+ // 丢弃请求 session」侥幸不串台;一旦读侧归一化在召回链路上生效,不传就等价于
548
+ // 跨会话(全库)召回。此处走 `read(query, {k, ...sessionTag})`——与
549
+ // `recall(query, k)` 是同一条 MCP 出口(`cg(op=read)`,见 mdcg_client.ts 的
550
+ // recall → read),差别只在能带上会话槽;`recall` 没有 extra 形参,给它加槽要
551
+ // 改 mdcg_client.ts(本件放行面之外),故在调用点改走等价出口。
552
+ // 带上它不构成越权:cg 读路径的 session 是**归因/视图**维度,不参与任何授权
553
+ // (issue #35 定稿「身份不可自报」,见 md_cg/mdcos.py 的 _candidates)。
554
+ memorize('user-recall', null, (g) => g.read(safe.slice(0, 200), { k: 3, ...sessionTag }))
555
+ } else if (event.type === 'assistant/message' && opts.assistantMessage) {
556
+ const text = extractText(event.data.message.content)
557
+ if (!text) return
558
+ const safe = sanitize(text)
559
+ if (safe === null) { audit.skipped('sanitized', 'assistant'); return }
560
+ memorize('assistant', 'assistant', (g) => g.remember(safe, {
561
+ role: 'assistant', tags: ['dsh', 'assistant'], importance: opts.importance * 0.8,
562
+ ...sessionTag,
563
+ }))
564
+ } else if (event.type === 'tool/result' && opts.toolResult) {
565
+ if (event.data.error) return
566
+ const text = extractText(event.data.message.content)
567
+ if (!text) return
568
+ const safe = sanitize(text)
569
+ if (safe === null) { audit.skipped('sanitized', 'tool'); return }
570
+ memorize('tool', 'tool', (g) => g.remember(safe, {
571
+ role: 'tool-output', tags: ['dsh', 'tool'], importance: opts.importance * 0.6,
572
+ ...sessionTag,
573
+ }))
574
+ }
575
+ })
576
+ }