@furongjun1999/dsh-memory 0.5.1 → 0.6.1

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 (187) hide show
  1. package/README.md +588 -552
  2. package/codebuddy/CODEBUDDY.md +10 -10
  3. package/data/policy.json +27 -0
  4. package/docs/discipline/harnesses.yaml +31 -0
  5. package/docs/discipline/templates/full.md.tmpl +1 -1
  6. package/docs/discipline/templates/rules.mdc.tmpl +1 -1
  7. package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225v2_/345/217/202/350/200/203dsh-TUI_v1.0.md +248 -0
  8. package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225/346/225/210/346/236/234/351/252/214/350/257/201_v1.0.md +209 -0
  9. package/docs/eval/DSH/347/253/257/347/274/272/351/231/267/344/270/223/351/241/271_v1.0.md +254 -0
  10. 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 +418 -0
  11. package/docs/eval/P1b2_/350/257/273/351/235/242/344/273/243/351/231/205/344/277/256/345/244/215_v1.0.md +124 -0
  12. 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 +441 -0
  13. 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 +398 -0
  14. 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 +498 -0
  15. 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 +669 -0
  16. 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 +683 -0
  17. package/docs/eval//345/217/221/345/270/20306_/344/270/200/351/224/256/351/205/215/347/275/256/344/270/216DSH0172_v1.0.md +171 -0
  18. 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 +205 -0
  19. package/docs/eval//346/200/247/350/203/275/344/270/223/351/241/271_/345/206/267/346/237/245/350/257/242/344/270/216/345/206/205/345/255/230_v1.0.md +218 -0
  20. package/docs/eval//346/225/205/351/232/234/346/263/250/345/205/245/345/256/236/346/265/213_v1.0.md +10 -0
  21. 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 +646 -0
  22. 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_v18.md +183 -0
  23. 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_v19.md +207 -0
  24. 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_v20.md +283 -0
  25. 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_v21.md +224 -0
  26. 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_v22.md +223 -0
  27. 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_v23.md +293 -0
  28. 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_v24.md +303 -0
  29. 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_v25.md +230 -0
  30. package/docs/hive//346/243/200/347/264/242/350/267/257/345/276/204/344/270/216/350/256/244/347/237/245/347/273/223/346/236/204/345/245/221/347/272/246_v0.1.md +20 -0
  31. 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
  32. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +12 -0
  33. 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 +40 -40
  34. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +21 -9
  35. package/docs/mdcg//347/201/265/346/236/242/350/256/260/345/277/206/345/212/250/350/257/215/345/215/217/350/256/256_v1.0-draft.md +7 -2
  36. package/docs/mdcg//350/256/244/347/237/245/345/233/276_/347/264/242/345/274/225/344/270/216/345/267/245/347/250/213/350/247/204/350/214/203/345/214/226_/350/256/241/345/210/222_v0.1.md +39 -3
  37. 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 +220 -0
  38. package/docs/plans//347/234/237/346/272/220/347/264/242/345/274/225_/351/200/232/347/224/250/346/234/272/345/210/266_v0.3.md +227 -0
  39. package/docs/plans//350/234/202/345/267/242/346/250/241/345/236/213/345/257/206/351/222/245/351/205/215/347/275/256/351/235/242_v1.0.md +385 -0
  40. 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 +460 -460
  41. package/dsh/cordis-patch-profile-web.example.yml +35 -0
  42. package/lib/bridge.js +24 -2
  43. package/lib/cli.d.ts +3 -0
  44. package/lib/cli.js +66 -0
  45. package/lib/hooks.d.ts +31 -2
  46. package/lib/hooks.js +219 -16
  47. package/lib/init.d.ts +88 -0
  48. package/lib/init.js +302 -0
  49. package/lib/lib/prompt_safety.d.ts +52 -1
  50. package/lib/lib/prompt_safety.js +76 -1
  51. package/md_cg/audit.py +652 -379
  52. package/md_cg/bench6_arms.py +1 -1
  53. package/md_cg/bench_p0.py +1 -1
  54. package/md_cg/branches.py +2 -2
  55. package/md_cg/ccgc.py +1071 -1006
  56. package/md_cg/chain.py +1 -1
  57. package/md_cg/consistency.py +97 -9
  58. package/md_cg/consolidate.py +34 -9
  59. package/md_cg/crypto.py +59 -26
  60. package/md_cg/docindex.py +16 -1
  61. package/md_cg/evidence.py +585 -582
  62. package/md_cg/evolution.py +17 -1
  63. package/md_cg/export.py +1 -1
  64. package/md_cg/forgetting.py +309 -13
  65. package/md_cg/fsutil.py +454 -8
  66. package/md_cg/identity.py +3 -3
  67. package/md_cg/insight.py +24 -3
  68. package/md_cg/linkref.py +1 -1
  69. package/md_cg/logref.py +327 -0
  70. package/md_cg/mcp_server.py +4152 -3818
  71. package/md_cg/mdcg.py +4208 -3462
  72. package/md_cg/mdcos.py +4491 -4137
  73. package/md_cg/mreview/pipeline.py +15 -1
  74. package/md_cg/nodefile.py +639 -575
  75. package/md_cg/protect.py +158 -11
  76. package/md_cg/protocol.py +41 -2
  77. package/md_cg/provenance.py +22 -4
  78. package/md_cg/reach.py +4 -4
  79. package/md_cg/readcache.py +76 -15
  80. package/md_cg/reconcile.py +1 -1
  81. package/md_cg/refindex.py +246 -37
  82. package/md_cg/refine.py +1 -1
  83. package/md_cg/review_cli.py +49 -4
  84. package/md_cg/routing.py +32 -5
  85. package/md_cg/run_tests.py +17 -0
  86. package/md_cg/scrub.py +30 -3
  87. package/md_cg/security.py +47 -1
  88. package/md_cg/self_state.py +5 -5
  89. package/md_cg/sources.py +23 -6
  90. package/md_cg/srcindex.py +352 -0
  91. package/md_cg/stg.py +47 -3
  92. package/md_cg/subgraph.py +2 -2
  93. package/md_cg/sustain.py +1299 -1168
  94. package/md_cg/tasks.py +469 -470
  95. package/md_cg/test_b1_auto_id_multiproc.py +277 -0
  96. package/md_cg/test_b1b2_write_face.py +417 -0
  97. package/md_cg/test_b2_sensitivity_landing.py +223 -0
  98. package/md_cg/test_b3_merge_keeps_content.py +576 -0
  99. package/md_cg/test_b4_shard_dir_selfheal.py +843 -0
  100. package/md_cg/test_b4_shard_dir_selfheal_guard.py +238 -0
  101. package/md_cg/test_c3_transient_read_negative.py +793 -0
  102. package/md_cg/test_c8_search_rrf_gates.py +502 -0
  103. package/md_cg/test_ccg_form_parity.py +188 -0
  104. package/md_cg/test_ccgc.py +28 -3
  105. package/md_cg/test_govern_directread.py +17 -4
  106. package/md_cg/test_h2_session_view_norm.py +233 -0
  107. package/md_cg/test_h4_sustain_snapshot.py +1377 -0
  108. package/md_cg/test_hive_ingest.py +285 -285
  109. package/md_cg/test_index_crossprocess_reload.py +301 -0
  110. package/md_cg/test_issue39_utf8_stdio.py +307 -16
  111. package/md_cg/test_issue43_default_policy.py +865 -0
  112. package/md_cg/test_legacy_p3_node_id_type.py +375 -0
  113. package/md_cg/test_linkref.py +10 -3
  114. package/md_cg/test_lock.py +2 -2
  115. package/md_cg/test_logref.py +1109 -0
  116. package/md_cg/test_m3_h9_bucket_health_protect_mark.py +301 -0
  117. package/md_cg/test_m3_h9_semantic_guard.py +525 -0
  118. package/md_cg/test_mr_m2.py +15 -3
  119. package/md_cg/test_n130_verify_falsified_protect.py +1 -1
  120. package/md_cg/test_n139_dek_provision_failclosed.py +185 -0
  121. package/md_cg/test_n176_link_trust.py +221 -0
  122. package/md_cg/test_n178_units_jobid_gate.py +212 -0
  123. package/md_cg/test_n184_keys_concurrent_provision.py +307 -0
  124. package/md_cg/test_n195_writepath_reload.py +283 -0
  125. package/md_cg/test_n196_stale_gate_skip.py +169 -0
  126. package/md_cg/test_n197_n208_write_face_gates.py +471 -0
  127. package/md_cg/test_n198_tokens_corrupt_failclosed.py +225 -0
  128. package/md_cg/test_n199_tokens_concurrent_write.py +378 -0
  129. package/md_cg/test_n201_proposal_visibility.py +382 -0
  130. package/md_cg/test_n202_session_notes_visibility.py +461 -0
  131. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +542 -0
  132. package/md_cg/test_n206_stdio_jsonrpc_type.py +403 -0
  133. package/md_cg/test_n209_verify_write_face_gates.py +461 -0
  134. package/md_cg/test_n212_n213_n224_generation_gates.py +513 -0
  135. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +563 -0
  136. package/md_cg/test_n225_nonobject_load.py +1110 -0
  137. package/md_cg/test_n62_tenant_bind_failclosed.py +200 -0
  138. package/md_cg/test_neg_condition_hits.py +333 -0
  139. package/md_cg/test_neg_tail_honesty.py +663 -0
  140. package/md_cg/test_none_id_write_guard.py +166 -0
  141. package/md_cg/test_opt_batch1_md_cg.py +451 -0
  142. package/md_cg/test_p1.py +5 -5
  143. package/md_cg/test_p11_consistency.py +161 -12
  144. package/md_cg/test_p26_refindex.py +2 -2
  145. package/md_cg/test_p27_docindex.py +777 -774
  146. package/md_cg/test_p28_refcheck.py +740 -13
  147. package/md_cg/test_p29_session_ingest_export.py +2 -2
  148. package/md_cg/test_p2_mcp.py +12 -1
  149. package/md_cg/test_p30_maintain.py +33 -2
  150. package/md_cg/test_p31_insight.py +1 -0
  151. package/md_cg/test_p9c_dedup_hints.py +276 -0
  152. package/md_cg/test_policy_required_ccg.py +426 -0
  153. package/md_cg/test_protocol.py +22 -0
  154. package/md_cg/test_rank_parity_score_mode.py +516 -0
  155. package/md_cg/test_read_face_input_gates.py +363 -0
  156. package/md_cg/test_read_face_semantics.py +489 -0
  157. package/md_cg/test_recall_face_guards.py +812 -0
  158. package/md_cg/test_rejected_credential_forms.py +188 -0
  159. package/md_cg/test_rejected_redact.py +146 -0
  160. package/md_cg/test_retr_s1b.py +2 -2
  161. package/md_cg/test_retr_s5.py +20 -7
  162. package/md_cg/test_review_cli_attribution.py +177 -0
  163. package/md_cg/test_review_cli_visibility.py +235 -0
  164. package/md_cg/test_review_conformance.py +21 -3
  165. package/md_cg/test_security_audit_v21.py +2 -2
  166. package/md_cg/test_server_version.py +67 -0
  167. package/md_cg/test_srcindex.py +171 -0
  168. package/md_cg/test_tenant_env_override_warn.py +63 -50
  169. package/md_cg/test_token_lowercase_form.py +315 -0
  170. package/md_cg/test_v21r1_package_version.py +310 -0
  171. package/md_cg/test_writepipe.py +10 -4
  172. package/md_cg/tokens.py +225 -95
  173. package/md_cg/tool_face.py +4 -4
  174. package/md_cg/trust.py +49 -5
  175. package/md_cg/units.py +204 -6
  176. package/md_cg/weights.py +4 -4
  177. package/md_cg/whitebox_kb/wisdom/knowledge_points.py +1 -1
  178. package/md_cg/writelimit.py +42 -8
  179. package/md_cg/writepipe.py +651 -554
  180. package/package.json +8 -2
  181. package/skills/plugin.json +1 -1
  182. package/src/bridge.ts +25 -2
  183. package/src/cli.ts +65 -0
  184. package/src/hooks.ts +229 -16
  185. package/src/init.ts +361 -0
  186. package/src/lib/prompt_safety.ts +88 -1
  187. package/zcode/AGENTS.md +10 -10
@@ -44,6 +44,26 @@
44
44
  # 全部工具(含记忆/认知/白箱 wisdom_* 工具族),供 Agent 直接调用
45
45
  tools: all
46
46
  env:
47
+ # ── 会话 / 智能体 归因维度(2026-09-26 新增,可选项但推荐接线)──────────
48
+ # 三维身份:地点 = 会话所属工作区目录名(如 --D-4_ai--);
49
+ # 智能体 = harness(dsh / codebuddy / zcode);会话 = session-<uuid>。
50
+ # 为何要配:md_cg/mcp_server.py 的 _declared_session 规定「env 权威」——
51
+ # ① 设了 MDCG_SESSION(或 DSH_SESSION_ID)→ 本 MCP 连接的会话归属被钉死,
52
+ # 调用方在请求里声明的 session 一律忽略(客户端不得伪造归属);
53
+ # ② 没设 → 单进程多会话的载体(DSH web 一个 MCP 进程服务多个前端会话)
54
+ # 虽允许请求面声明,但须过 _normalize_session 的防编造校验:DSH 形态
55
+ # (session-<uuid4>)须真实存在于 MDCG_DSH_SESSIONS_ROOT/<workspace>/ 下,
56
+ # 否则降级 anonymous(记忆照样落盘,只是归属不可追溯)。
57
+ # 本机接线(2026-09-26 实测生效):由启动脚本按工作区解析当前会话目录名后注入
58
+ # DSH_HOME=D:\dsh-home
59
+ # set MDCG_DSH_SESSIONS_ROOT=D:\dsh-home\sessions
60
+ # set MDCG_HARNESS=dsh
61
+ # set MDCG_UNIT=agent
62
+ # set MDCG_SESSION=session-<当前会话 uuid>
63
+ # 参见 dsh/dsh-web-start.bat 与 <profile>/cordis.patch.yml 的 env 段。
64
+ MDCG_DSH_SESSIONS_ROOT: '<DSH_HOME>/sessions'
65
+ MDCG_HARNESS: 'dsh'
66
+ MDCG_UNIT: 'agent'
47
67
  BOCHA_API_KEY: !!js process.env.BOCHA_API_KEY
48
68
  AEIS_DESIGNER_KEY: !!js process.env.AEIS_DESIGNER_KEY
49
69
  # 角色扮演 LLM 续答密钥(插件桥 env 透传;优先从 .credentials.yaml 读取——
@@ -77,3 +97,18 @@
77
97
  mutual:
78
98
  enabled: false
79
99
  heartbeatMs: 600000
100
+
101
+ # ─── 会话归因补充说明(2026-09-26,DSH 日志索引 v2)──────────────────
102
+ # 经 dsh_log_index.py 摄取的日志节点(id 前缀 dsh-log-):
103
+ # · frontmatter.session = 派生标识 sha256(createdAt+首条用户消息)[:16]
104
+ # —— 16 位十六进制摘要而非 session-<uuid>。这是**刻意设计**(会话 uuid
105
+ # 每次重启必换,不可作身份锚;内容寻址标识跨重启稳定),勿当 bug 报。
106
+ # · frontmatter.dsh_session_uuid 保留原始 uuid 供溯源。
107
+ # · sensitivity=internal(日志不加密,使用者裁定)→ 跨会话共享档,
108
+ # 任何过密级校验的会话均可检索(跨会话回看是日志检索的用途)。
109
+ # · layer=knowledge → 不会进入 autoRecall 自动注入池(recall 默认
110
+ # contextual 层)——日志冷真源只对显式查询有效,这是预期行为。
111
+ # · MDCG_READ_CACHE:读缓存缺省已开(readcache.py "1" 缺省,=0 显式关闭)。
112
+ # DSH 端部署实测(2026-09-27):14135 节点库冷态首查全池装配 50–60s
113
+ # (每节点 ~3.3ms 读盘),装配完成后热查询 162ms。建议启动脚本在
114
+ # 起 dsh 前先跑一次预热查询,把全池装配挪到启动期(用户查询免首查卡顿)。
package/lib/bridge.js CHANGED
@@ -168,6 +168,19 @@ export class LingshuBridge {
168
168
  });
169
169
  this.proc = proc;
170
170
  this.rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
171
+ // N119(v13 留档 deferred → 本轮落地):stdin 在途冲刷失败必须有人接住。
172
+ // writeRaw 的大消息(超管道缓冲)会滞留在 Node 写缓冲区背压,此刻子进程
173
+ // 恰好死亡(崩溃 / kill / 换代 end()),滞留数据冲刷即以 'error' 事件发射
174
+ // (实测形态 'write EPIPE' / 'write EOF')——无监听时按 uncaughtException
175
+ // 上抛,直接打崩 DSH 宿主进程。下方 writeRaw 的 writable 守卫只挡调用瞬间,
176
+ // 挡不住在途冲刷失败;本监听 spawn 时挂一次即覆盖 writeRaw 全部写与
177
+ // dispose/killStaleProc 的 end()。吞错后无需额外状态迁移:子进程死亡由
178
+ // 既有 exit 分支收尾(rejectAll + 状态迁移 + 重试/冷却),stdin 写失败仅
179
+ // 留痕日志与探针。
180
+ proc.stdin?.on('error', (err) => {
181
+ console.error(`[lingshu-bridge] stdin 写入失败(子进程可能已退出): ${err.message}`);
182
+ probe(`stdin error: ${String(err)}`);
183
+ });
171
184
  proc.stderr?.on('data', (chunk) => {
172
185
  // stderr 透传日志(灵枢把日志写在 stderr,避免污染协议流)
173
186
  const text = chunk.toString('utf8').trim();
@@ -177,14 +190,23 @@ export class LingshuBridge {
177
190
  this.rl.on('line', (line) => {
178
191
  if (!line.trim())
179
192
  return;
180
- let msg;
193
+ // N219:parse 结果必须**校验类型**——JSON.parse 只挡「非 JSON」,
194
+ // 一行 `null`(崩溃残留 / 第三方写管道 / 新版打印)解析合法,随后读
195
+ // msg['id'] 即抛 TypeError;抛出点在事件回调内且无 try → 逃逸为进程级
196
+ // uncaughtException,宿主 DSH 进程整体死亡(每行都在监听、无需凭据)。
197
+ let parsed;
181
198
  try {
182
- msg = JSON.parse(line);
199
+ parsed = JSON.parse(line);
183
200
  }
184
201
  catch {
185
202
  console.error(`[lingshu-bridge] 非 JSON 输出: ${line.slice(0, 200)}`);
186
203
  return;
187
204
  }
205
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
206
+ console.error(`[lingshu-bridge] 非对象 JSON 输出: ${line.slice(0, 200)}`);
207
+ return;
208
+ }
209
+ const msg = parsed;
188
210
  if (typeof msg['id'] === 'number') {
189
211
  this.settle(msg['id'], msg);
190
212
  }
package/lib/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ /** 入口分发:argv 为去掉 node 与本脚本路径后的参数。仅导出供测试。 */
3
+ export declare function main(argv: string[]): Promise<number>;
package/lib/cli.js ADDED
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cli.ts —— `lingshu` 主命令入口(npx 兼容分发器)
4
+ *
5
+ * 为什么有三个 bin(package.json bin):
6
+ * · `lingshu` → 本文件:`lingshu init` 分发到 init.ts(未来其它子命令也挂这);
7
+ * · `lingshu-init` → lib/init.js:单命令直入(不想敲子命令的场景);
8
+ * · `dsh-memory` → 本文件:npx 的同名匹配规则——`npx @furongjun1999/dsh-memory init`
9
+ * 在包有多个 bin 时,只会自动运行**与包名末段同名**的 bin(npm 官方口径),
10
+ * 缺这个入口 npx 会因「多个 bin 且无同名」直接报错,一键配置命令即失效。
11
+ *
12
+ * 零运行时依赖:只用 Node 内置模块。
13
+ */
14
+ import { resolve } from 'node:path';
15
+ import { pathToFileURL } from 'node:url';
16
+ import { runInit } from './init.js';
17
+ const USAGE = [
18
+ '灵枢(Lingshu)命令行',
19
+ '',
20
+ '用法:lingshu <命令>',
21
+ '',
22
+ '命令:',
23
+ ' init [选项] 一键配置生成器(交互三问;参数齐则跳过问答)',
24
+ ' 选项:--end <dsh|claude|codex|generic> --root <dir> --python <interpreter>',
25
+ '',
26
+ '示例:',
27
+ ' npx @furongjun1999/dsh-memory init',
28
+ ' npx @furongjun1999/dsh-memory init -- --end claude --root D:/mem --python python3',
29
+ ].join('\n');
30
+ /** 入口分发:argv 为去掉 node 与本脚本路径后的参数。仅导出供测试。 */
31
+ export async function main(argv) {
32
+ const cmd = argv[0] ?? '';
33
+ if (cmd === 'init') {
34
+ await runInit(argv.slice(1));
35
+ return 0;
36
+ }
37
+ process.stdout.write(`${USAGE}\n`);
38
+ if (cmd === '--help' || cmd === '-h' || cmd === 'help')
39
+ return 0;
40
+ if (cmd !== '')
41
+ process.stderr.write(`未知命令「${cmd}」。\n`);
42
+ else
43
+ process.stderr.write('缺少命令。\n');
44
+ return 1;
45
+ }
46
+ function isMainEntry() {
47
+ const entry = process.argv[1];
48
+ if (!entry)
49
+ return false;
50
+ try {
51
+ const entryUrl = pathToFileURL(resolve(entry)).href;
52
+ return import.meta.url === entryUrl
53
+ || (process.platform === 'win32' && import.meta.url.toLowerCase() === entryUrl.toLowerCase());
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ if (isMainEntry()) {
60
+ main(process.argv.slice(2))
61
+ .then((code) => { process.exitCode = code; })
62
+ .catch((e) => {
63
+ process.stderr.write(`lingshu:${e instanceof Error ? e.message : String(e)}\n`);
64
+ process.exitCode = 1;
65
+ });
66
+ }
package/lib/hooks.d.ts CHANGED
@@ -15,7 +15,12 @@
15
15
  *
16
16
  * 与 DSH 的 session-persistence 插件(保存会话日志)不同,这里是"语义沉淀":
17
17
  * 带去重(闸门 MERGE)与重要性,写入前脱敏;agent 回复与工具结果可选开启。
18
- * 只记忆真实用户消息(source.kind === 'user'),过滤插件注入的噪音。
18
+ * 只记忆真实用户消息(source.kind === 'user'),过滤插件注入的噪音;其上再叠一层
19
+ * **来源判定**(H1,2026-09-30):① **会话级**——子代理/委派子会话(SessionHeader 的
20
+ * `origin` / `delegationDepth`)的自动记忆**整条会话拦掉**;② **消息级**——
21
+ * `source.form === 'relay'`(「另一个 agent 发给本 agent 的消息」)不写。
22
+ * 两条判据均为「**字段在场且取值匹配才拦**」:字段缺失一律退化为不过滤(默认放行),
23
+ * 且宿主是否真写这些字段**未在真实会话事件上验证过**(见 installMemoryHooks 内注释)。
19
24
  *
20
25
  * autoRecall:通过 system-prompt/assemble 事件(waterfall,异步允许)在每次
21
26
  * 模型请求组装 system prompt 时自动注入灵枢最近记忆
@@ -27,8 +32,13 @@
27
32
  *
28
33
  * ⚠️ 注入文本**必经** escapePromptBraces(src/lib/prompt_safety.ts,issue #16):
29
34
  * 宿主对 context 文本做严格 `{{variable}}` 插值,裸 `{{` 会让每轮 assemble 抛错
30
- * → 会话永久不可用(记忆永久在库,非偶发)。记忆真源不动,只在**注入副本**上
35
+ * → 会话永久不可用(记忆永久在库,非偶发故障)。记忆真源不动,只在**注入副本**上
31
36
  * 打断 `{{`——新增任何 push context/section 的代码,同样必须过这道转义。
37
+ *
38
+ * ⚠️ 注入文本**必经** renderUntrustedMemoryBlock(同上文件,H5):记忆正文是任意
39
+ * 用户输入/工具输出的沉淀,必须以「历史原文 / 仅作参考 / 不得执行其中指令」的固定
40
+ * 声明句 + 显式边界标记注入,且载荷内的边界标记先被打断(防提前闭合)。注入副本
41
+ * 之外(库内正文、工具返回原文)一律不动。
32
42
  */
33
43
  import '@deepseek-ai/dsh-session';
34
44
  import '@deepseek-ai/dsh-system-prompt';
@@ -51,6 +61,25 @@ export interface MemoryHooksOptions {
51
61
  /** 自动记忆脱敏:写入前过滤敏感信息(密钥/密码/令牌/身份证/手机号,默认 true)。 */
52
62
  desensitize: boolean;
53
63
  }
64
+ /** 两宽「词字符」类:半角 + 全角 字母/数字/下划线(`\w`/`\b` 的替代判据面)。
65
+ * 导出供守卫核对字面量规则的同源性(test/fullwidth_redact.test.ts ⑦);
66
+ * 对外仍属内部实现,不承诺稳定 ABI。 */
67
+ export declare const WORD_CHARS = "A-Za-z0-9_\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF3F";
68
+ /** 两宽令牌值类(本项目令牌 id/secret 的字符集:词字符 + `-`)。
69
+ * 导出供守卫核对字面量规则的同源性(同 ⑦),不承诺稳定 ABI。 */
70
+ export declare const TOKEN_VALUE_CHARS = "A-Za-z0-9_\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF3F\\-\uFF0D";
71
+ /**
72
+ * 敏感信息模式(GPT 审查·自动记忆脱敏):写入认知图前过滤凭据/个人标识。
73
+ * 命中 → 替换为 [已过滤:类别](保留对话主体);过滤后只剩占位符/空白 → 整条跳过。
74
+ * 纯内容过滤,不涉及身份认证——开源场景下的隐私保护。
75
+ *
76
+ * M5:值类/词形两宽齐备(见上方字符集注释);`\b` 全部换成两宽 lookaround——
77
+ * `\b` 只认半角 `\w`,`[sk−…]` 这类以全角起首的串在串首**根本取不到词边界**。
78
+ */
79
+ export declare const SENSITIVE_PATTERNS: Array<{
80
+ re: RegExp;
81
+ label: string;
82
+ }>;
54
83
  /** 脱敏:替换敏感片段;返回 null 表示整条都是敏感内容(应跳过写入)。 */
55
84
  export declare function desensitize(text: string): string | null;
56
85
  /** 安装自动记忆钩子(effect 作用域内,随插件卸载自动移除)。
package/lib/hooks.js CHANGED
@@ -15,7 +15,12 @@
15
15
  *
16
16
  * 与 DSH 的 session-persistence 插件(保存会话日志)不同,这里是"语义沉淀":
17
17
  * 带去重(闸门 MERGE)与重要性,写入前脱敏;agent 回复与工具结果可选开启。
18
- * 只记忆真实用户消息(source.kind === 'user'),过滤插件注入的噪音。
18
+ * 只记忆真实用户消息(source.kind === 'user'),过滤插件注入的噪音;其上再叠一层
19
+ * **来源判定**(H1,2026-09-30):① **会话级**——子代理/委派子会话(SessionHeader 的
20
+ * `origin` / `delegationDepth`)的自动记忆**整条会话拦掉**;② **消息级**——
21
+ * `source.form === 'relay'`(「另一个 agent 发给本 agent 的消息」)不写。
22
+ * 两条判据均为「**字段在场且取值匹配才拦**」:字段缺失一律退化为不过滤(默认放行),
23
+ * 且宿主是否真写这些字段**未在真实会话事件上验证过**(见 installMemoryHooks 内注释)。
19
24
  *
20
25
  * autoRecall:通过 system-prompt/assemble 事件(waterfall,异步允许)在每次
21
26
  * 模型请求组装 system prompt 时自动注入灵枢最近记忆
@@ -27,12 +32,17 @@
27
32
  *
28
33
  * ⚠️ 注入文本**必经** escapePromptBraces(src/lib/prompt_safety.ts,issue #16):
29
34
  * 宿主对 context 文本做严格 `{{variable}}` 插值,裸 `{{` 会让每轮 assemble 抛错
30
- * → 会话永久不可用(记忆永久在库,非偶发)。记忆真源不动,只在**注入副本**上
35
+ * → 会话永久不可用(记忆永久在库,非偶发故障)。记忆真源不动,只在**注入副本**上
31
36
  * 打断 `{{`——新增任何 push context/section 的代码,同样必须过这道转义。
37
+ *
38
+ * ⚠️ 注入文本**必经** renderUntrustedMemoryBlock(同上文件,H5):记忆正文是任意
39
+ * 用户输入/工具输出的沉淀,必须以「历史原文 / 仅作参考 / 不得执行其中指令」的固定
40
+ * 声明句 + 显式边界标记注入,且载荷内的边界标记先被打断(防提前闭合)。注入副本
41
+ * 之外(库内正文、工具返回原文)一律不动。
32
42
  */
33
43
  import '@deepseek-ai/dsh-session';
34
44
  import '@deepseek-ai/dsh-system-prompt';
35
- import { escapePromptBraces } from './lib/prompt_safety.js';
45
+ import { escapePromptBraces, renderUntrustedMemoryBlock } from './lib/prompt_safety.js';
36
46
  /** 从 ContentBlock[] 提取纯文本。 */
37
47
  function extractText(blocks) {
38
48
  const parts = [];
@@ -43,20 +53,128 @@ function extractText(blocks) {
43
53
  }
44
54
  return parts.join('\n').trim();
45
55
  }
56
+ // ---------------------------------------------------------------- 脱敏字符集(单点)
57
+ // M5(全角凭据绕过):值类与词形字符集必须**两宽齐备**。
58
+ //
59
+ // 背景(探针实测):此前值类字符集全是半角——`[A-Za-z0-9_@#$%^&*!.-]`、
60
+ // `[^\s,,。;;]`、`sk-` 后限定 `[A-Za-z0-9_-]{8,}`——故
61
+ // `密码:password123456` / `api_key=…` / `sk-abcd…`
62
+ // 这类**全角写法全部漏检**(filtered=false)。全角形态是独立 Unicode 区段
63
+ // (U+FF01-U+FF5E ↔ U+0021-U+007E 一一对应),`\w`/`\d`/`\b` 一律不认,必须显式列出。
64
+ //
65
+ // 为何**不**走「先全角→半角归一化再匹配」(两条路的取舍,依据如下):
66
+ // ① NFKC 归一化会**改变长度**(`㍿`→`株式会社`、半角カナ `パ`→`パ`、`㈱`→`(株)`)
67
+ // ⇒ 匹配片段无法映射回原文偏移,替换要么错位要么漏改——静默失真;
68
+ // ② 若退一步「归一化后整段替换」,等于把正文里的全角标点(:=_())统统
69
+ // 半角化——**改写记忆真源**,与「原文保真、只在注入边界改写」的既有纪律相悖;
70
+ // ③ 归一化会抹平 `.`(U+FF0E,`.` 的全角形态) 与 `。`(U+3002,中文句号) 的区别,
71
+ // 值类边界随之漂移——本题要求说明的误伤面正在这里。
72
+ // 故取**显式两宽字符类**:替换只覆盖命中的凭据片段,正文其余字符逐字节零改写;
73
+ // 且「每条规则的值类两宽齐备」成为可机械断言的性质
74
+ // (守卫 test/fullwidth_redact.test.ts ①/②/④)。
75
+ //
76
+ // 边界(如实):两宽类刻意**不含**中文句读 ,;。!?、 —— 它们不在半角类里,
77
+ // 引入会让值类跨句吞并(`.` 是 `.` 的全角形态、属值类,故收录)。
78
+ /** 全角数字(U+FF10-U+FF19)。 */
79
+ const FW_DIGITS = '0-9';
80
+ /** 全角大写字母(U+FF21-U+FF3A)。 */
81
+ const FW_UPPER = 'A-Z';
82
+ /** 全角小写字母(U+FF41-U+FF5A)。 */
83
+ const FW_LOWER = 'a-z';
84
+ /** 两宽「词字符」类:半角 + 全角 字母/数字/下划线(`\w`/`\b` 的替代判据面)。
85
+ * 导出供守卫核对字面量规则的同源性(test/fullwidth_redact.test.ts ⑦);
86
+ * 对外仍属内部实现,不承诺稳定 ABI。 */
87
+ export const WORD_CHARS = `A-Za-z0-9_${FW_DIGITS}${FW_UPPER}${FW_LOWER}_`;
88
+ /** 两宽数字类。 */
89
+ const DIGIT_CHARS = `0-9${FW_DIGITS}`;
90
+ /** 两宽凭据值类:词字符 + `@ # $ % ^ & * ! . -` 的两宽形态。
91
+ * ASCII 连字符一律转义(`\\-`)——`[_--]` 会被解析成 `_`→`-` 的**巨区间**。 */
92
+ const CRED_VALUE_CHARS = `${WORD_CHARS}@@#$$%^&*!.\\--.`;
93
+ /** 两宽令牌值类(本项目令牌 id/secret 的字符集:词字符 + `-`)。
94
+ * 导出供守卫核对字面量规则的同源性(同 ⑦),不承诺稳定 ABI。 */
95
+ export const TOKEN_VALUE_CHARS = `${WORD_CHARS}\\--`;
96
+ /** 两宽 Bearer 值类(原半角集 `A-Za-z0-9._~+/=-` + 其全角形态)。 */
97
+ const BEARER_VALUE_CHARS = `A-Za-z0-9._~+/=\\-${FW_DIGITS}${FW_UPPER}${FW_LOWER}_.~/+=-`;
98
+ /** ASCII 可见字符 → 全角等价(U+0021-U+007E ↔ U+FF01-U+FF5E);其余原样返回。 */
99
+ function toFullwidth(ch) {
100
+ const code = ch.charCodeAt(0);
101
+ return code >= 0x21 && code <= 0x7e ? String.fromCharCode(code + 0xfee0) : ch;
102
+ }
103
+ /** 半角 ASCII 词 → 「半角|全角」等价类(M5 单点:关键词的两宽形态只此一处生成)。
104
+ * 仅接受 `[A-Za-z0-9_-]`——含正则元字符的词会让等价类语法失真,故 fail-closed 抛错。 */
105
+ function twoWidth(word) {
106
+ let out = '';
107
+ for (const ch of word) {
108
+ if (!/[A-Za-z0-9_-]/.test(ch)) {
109
+ throw new Error(`twoWidth 只接受半角字母数字/下划线/连字符,收到:${word}`);
110
+ }
111
+ out += `[${ch}${toFullwidth(ch)}]`;
112
+ }
113
+ return out;
114
+ }
46
115
  /**
47
116
  * 敏感信息模式(GPT 审查·自动记忆脱敏):写入认知图前过滤凭据/个人标识。
48
117
  * 命中 → 替换为 [已过滤:类别](保留对话主体);过滤后只剩占位符/空白 → 整条跳过。
49
118
  * 纯内容过滤,不涉及身份认证——开源场景下的隐私保护。
119
+ *
120
+ * M5:值类/词形两宽齐备(见上方字符集注释);`\b` 全部换成两宽 lookaround——
121
+ * `\b` 只认半角 `\w`,`[sk−…]` 这类以全角起首的串在串首**根本取不到词边界**。
50
122
  */
51
- const SENSITIVE_PATTERNS = [
52
- { re: /sk-[A-Za-z0-9_-]{8,}/g, label: 'API密钥' },
53
- { re: /\b(?:api[_-]?key|apikey|access[_-]?token)\b\s*[:=]\s*[^\s,,。;;]+/gi, label: 'API密钥' },
54
- { re: /\b(?:password|passwd|pwd)\b\s*[:=]\s*[^\s,,。;;]+/gi, label: '密码' },
55
- { re: /Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, label: '令牌' },
56
- // 中文密码:值限定非中文连续串(凭据特征),避免误伤「密码是重要的安全概念」
57
- { re: /密码\s*[::是]\s*[A-Za-z0-9_@#$%^&*!.-]{4,}/g, label: '密码' },
58
- { re: /\b\d{17}[\dXx]\b/g, label: '身份证号' },
59
- { re: /\b1[3-9]\d{9}\b/g, label: '手机号' },
123
+ // 导出供守卫使用(test/token_redact_parity.test.ts 需要按「交换序」复跑同一条链,
124
+ // 以证明顺序不再是安全性质);对外仍属内部实现,不承诺稳定 ABI。
125
+ export const SENSITIVE_PATTERNS = [
126
+ { re: new RegExp(`${twoWidth('sk')}[\\--][${TOKEN_VALUE_CHARS}]{8,}`, 'g'), label: 'API密钥' },
127
+ { re: new RegExp(`(?<![${WORD_CHARS}])`
128
+ + `(?:${twoWidth('api')}[__\\--]?${twoWidth('key')}|${twoWidth('apikey')}`
129
+ + `|${twoWidth('access')}[__\\--]?${twoWidth('token')})`
130
+ + `(?![${WORD_CHARS}])\\s*[:==:]\\s*[^\\s,,。;;]+`, 'gi'), label: 'API密钥' },
131
+ { re: new RegExp(`(?<![${WORD_CHARS}])`
132
+ + `(?:${twoWidth('password')}|${twoWidth('passwd')}|${twoWidth('pwd')})`
133
+ + `(?![${WORD_CHARS}])\\s*[:==:]\\s*[^\\s,,。;;]+`, 'gi'), label: '密码' },
134
+ { re: new RegExp(`${twoWidth('Bearer')}\\s+[${BEARER_VALUE_CHARS}]{8,}`, 'gi'), label: '令牌' },
135
+ // 中文密码:值限定非中文连续串(凭据特征),避免误伤「密码是重要的安全概念」;
136
+ // 分隔符补全角等号 `=`(半角 `=` 本就不在本规则的集合里,故只补全角形态)。
137
+ { re: new RegExp(`密码\\s*[::是=]\\s*[${CRED_VALUE_CHARS}]{4,}`, 'g'), label: '密码' },
138
+ // 两宽:全角数字形态同样要被认(`\b` 换成两宽 lookaround,见上)
139
+ { re: new RegExp(`(?<![${WORD_CHARS}])[${DIGIT_CHARS}]{17}[${DIGIT_CHARS}XxXx](?![${WORD_CHARS}])`, 'g'), label: '身份证号' },
140
+ { re: new RegExp(`(?<![${WORD_CHARS}])[11][3-93-9][${DIGIT_CHARS}]{9}(?![${WORD_CHARS}])`, 'g'), label: '手机号' },
141
+ // 本项目自有令牌(issue #45):批次71 已把形态加进**写入闸门**的禁表,但自动
142
+ // 记忆走的是 mdcg_remember(gated=true)、**不过 audit**,此处是这条路上唯一的
143
+ // 防线——此前不认自家令牌,用户粘一次即明文落进共用记忆库。
144
+ // 顺序要点:**完整令牌在前**。四段形态为 `mdcg1.<role>.<token_id>.<secret>`
145
+ // (md_cg/tokens.py:make_token),若先匹配裸 token_id,secret 段会留成明文
146
+ // (实测:`…designer.[已过滤:id].SECRET…`),故整条令牌必须整段吃掉。
147
+ //
148
+ // 2026-09-28 加固(PR#46 合并当批):**去 `\b` 词边界、role/secret 字符类放宽、
149
+ // secret 下限 16→8**——原式有三处「整条规则失配 ⇒ id 规则独吃 id、secret 留明文」
150
+ // 的触发面(实测复现):① 前导为词字符(`k_mdcg1.…`、`a mdcg1.…` 紧邻字母数字下划线
151
+ // 时 `\b` 失效);② role 含非字母(如 `sub-agent1`——`parse_token` 只要求非空,
152
+ // 不校验字符集);③ secret 短于 16 字符(`make_token` 不校验长度)。三者都让整条规则
153
+ // 失配,而裸 id 规则照旧命中 ⇒ secret 明文落库(与顺序错配同一形态)。放宽后与禁表
154
+ // 第 11 条 `mdcg1\.[A-Za-z0-9._\-]{20,}`(本就无 `\b`)同口径。
155
+ // M5(两宽):四段令牌的**值类**(role/id/secret)与分隔点 `.,` 全部两宽;前缀写成
156
+ // 「半角|全角」两种**拼写**的互斥分支(`(?:mdcg1|mdcg1)`)——两分支是不同字符,
157
+ // 故不是冗余、也不是字符类。
158
+ //
159
+ // ⚠️ 这两条令牌规则**必须保持 `/…/g` 字面量、单行且 Python 兼容**:形态守卫
160
+ // `md_cg/test_token_lowercase_form.py` 的 G7d~G7g 逐行抽取本文件的 `/…/g` 字面,
161
+ // 再用 Python `re` 复跑(定宽后顾 `(?<!…)`、字符类里的全角字面量在 Python `re` 下同义)。
162
+ // 改成 `new RegExp(...)` 拼装会让那条守卫抽不到规则(G7d 直接红,实测见本批报告),
163
+ // 故此处**不**用 `twoWidth()` 组合;值类字面量须与共享字符集常量同源,
164
+ // 由 test/fullwidth_redact.test.ts ⑦ 机械核对。
165
+ { 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: '令牌' },
166
+ // 裸令牌 id(`tk_` + 12 位 hex,1.15e14 空间不可猜——由 tokens.py 的
167
+ // `secrets.token_hex(6)` 生成;id 本身即凭据,与禁表 `\btk_[0-9a-f]{8,}\b` 同形)。
168
+ //
169
+ // 加固:**把尾随的 `.secret` 段一并吃掉**(`(?:\.[A-Za-z0-9_-]{4,})?`)。不变量=
170
+ // 「id 规则绝不能只吃 id、把 secret 留给下一条规则或留给用户」——顺序正确时那条尾巴
171
+ // 由整条规则先吃;顺序被改、或整条规则因任何理由失配时,id 规则自己带上尾巴,
172
+ // **顺序从此不再是安全性质**(防御纵深,由 test/token_redact_parity.test.ts ④ 钉住)。
173
+ // 下限取 4 是为了不吃掉 `tk_…py` / `tk_…md` 这类短文件名尾巴。
174
+ // M5(两宽):`\b` 只认半角 `\w`(`tk_…` 在串首取不到词边界 ⇒ 整条漏检),
175
+ // 换成两宽后顾;hex 值类、尾巴分隔点 `.,` 一并两宽,前缀同「半角|全角互斥分支」。
176
+ // 字面量/Python 兼容要求同上一条(形态守卫 G7d~G7g 抽取 `/…/g` 字面复跑)。
177
+ { 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' },
60
178
  ];
61
179
  /** 脱敏:替换敏感片段;返回 null 表示整条都是敏感内容(应跳过写入)。 */
62
180
  export function desensitize(text) {
@@ -145,6 +263,62 @@ function sessionIdOf(raw) {
145
263
  const v = s?.id ?? s?.sessionId;
146
264
  return typeof v === 'string' ? v.trim() : '';
147
265
  }
266
+ /** 会话归属未知时的**显式占位**(H2③,2026-09-30)。
267
+ *
268
+ * ⚠️ 不可退回「不传 session 键」:md_cg 的 `Principal.__init__` 在 session 为假值时
269
+ * 生成**进程级随机** `sess_<hex>`(md_cg/security.py:117)——插件不传,等于让一个
270
+ * 进程内所有「宿主未给标识」的会话共用一个**不可辨认**的随机桶:归属在审计上既
271
+ * 读不出是谁、跨进程也对不上,是静默的归属丢失。
272
+ * 本常量把这一态写成**显式值**:跨进程一致、可辨认、可审计,且不是伪造的宿主
273
+ * 会话 id(非 DSH 形态,服务端 `_normalize_session` 原样采用、不会被改写成别的桶)。
274
+ * 要读这个桶:`stg(op=timeline, session="unassigned")`。 */
275
+ const UNASSIGNED_SESSION = 'unassigned';
276
+ /** H1 **会话级**判据:这条 session 是否「子代理/委派子会话」。
277
+ *
278
+ * 字段来源(DSH 类型面,node_modules/@deepseek-ai/dsh-session/lib/types/types.d.ts):
279
+ * · `header.origin?: 'subagent'`(:64「Coarse product classification for a
280
+ * session created as a subagent child」);
281
+ * · `header.delegationDepth?: number`(:70「absent (zero) for a top-level
282
+ * session, parent depth + 1 for a subagent child」)。
283
+ *
284
+ * ⚠️ **未验证项(如实标注)**:本机未装 DSH harness,真实宿主是否真给子代理
285
+ * 子会话写这两个字段,**只在类型面成立、未在真实会话事件上观测过**。故判据取
286
+ * 「**字段在场且取值匹配才拦**」的形态:header 缺失 / 非对象 / 两个字段都取不到
287
+ * 或不匹配 → 一律返回 false(**不拦**,安全退化为既有行为),绝不因字段缺失而
288
+ * 报错,也不因此改变既有写入行为。
289
+ *
290
+ * 默认行为(显式声明):**子代理会话的自动记忆默认拦掉**(默认拦)——委派指令是
291
+ * 「另一个 agent 发给本 agent 的指令」,不是用户的长期记忆,写进来会污染真人记忆;
292
+ * 而判据不确定时**默认放行**(字段缺失即不拦)——宁可多记,不可因宿主字段缺失
293
+ * 而静默丢掉真人记忆。 */
294
+ function isSubagentSession(session) {
295
+ const header = session?.header;
296
+ if (!header || typeof header !== 'object')
297
+ return false;
298
+ const h = header;
299
+ if (h.origin === 'subagent')
300
+ return true;
301
+ const depth = h.delegationDepth;
302
+ return typeof depth === 'number' && Number.isFinite(depth) && depth > 0;
303
+ }
304
+ /** H1 **消息级**判据:这条消息是否是「另一个 agent 发给本 agent 的」(委派/中继)。
305
+ *
306
+ * 字段来源(DSH 类型面,node_modules/@deepseek-ai/dsh-llm/lib/types/message.d.ts):
307
+ * `ContextForm` 的 `'relay'`(:52 注释原文「A message another agent addressed to
308
+ * this one」),按类型只挂在 `kind: 'plugin'` 变体的 `form` 上(:98-101)。
309
+ *
310
+ * ⚠️ **未验证项(如实标注)**:真实宿主是否真给委派消息写 `form: 'relay'`
311
+ * **未观测过**。故同样取「字段在场且取值匹配才拦」;source 缺失/非对象 → false。
312
+ *
313
+ * 与既有 `kind !== 'user'` 判据的关系:类型面下 `kind='plugin'` 的中继**本就被**
314
+ * 那条拦掉;本判据放在它**之前**,是为了 ① 不把委派判定押在 `source.kind` 单点上、
315
+ * ② 覆盖「生产者把中继标成 `kind='user'` 且带 form」这一类型面之外的形态——子会话
316
+ * 的**首轮用户提示**就可能是这种:它与真人输入在 `kind` 上不可分,只有会话级判据
317
+ * (或这里的 form)能拦。 */
318
+ function isRelayedMessage(source) {
319
+ const s = source;
320
+ return !!s && typeof s === 'object' && s.form === 'relay';
321
+ }
148
322
  /** 安装自动记忆钩子(effect 作用域内,随插件卸载自动移除)。
149
323
  *
150
324
  * mdcg 为 null(config.mdcg.enabled=false)时自动记忆整体停用:记忆真源是
@@ -208,9 +382,15 @@ export function installMemoryHooks(ctx, mdcg, opts) {
208
382
  // 注入边界转义(issue #16):宿主 system-prompt 对 context 文本做严格
209
383
  // `{{variable}}` 插值,裸 `{{` 会 throw → 该轮请求整体失败。记忆原文
210
384
  // (含用户命令里的 `{{.X}}`)必须保真落库,故只在注入副本上打断 `{{`。
385
+ //
386
+ // H5(不可信内容边界):注入块**必经** renderUntrustedMemoryBlock——
387
+ // 固定声明句(UNTRUSTED_MEMORY_NOTICE,常量单点)+ 显式边界标记 +
388
+ // 载荷内边界标记的打断。记忆是任意用户输入/工具输出的沉淀,
389
+ // 无边界时模型无从区分「数据」与「指令」(提示注入面)。
390
+ // 三层顺序:边界渲染(含载荷打断)→ `{{` 转义;两者都只改注入副本。
211
391
  assembly.contexts.push({
212
392
  name: 'lingshu:auto-recall',
213
- text: escapePromptBraces(`【灵枢最近记忆】\n${text.slice(0, RECALL_MAX_CHARS)}`),
393
+ text: escapePromptBraces(renderUntrustedMemoryBlock(`【灵枢最近记忆】\n${text.slice(0, RECALL_MAX_CHARS)}`)),
214
394
  });
215
395
  }
216
396
  }
@@ -220,13 +400,27 @@ export function installMemoryHooks(ctx, mdcg, opts) {
220
400
  });
221
401
  }
222
402
  ctx.on('session/event', (session, event) => {
403
+ // H1(2026-09-30)**会话级**判据:子代理/委派子会话的自动记忆**整条会话**拦掉。
404
+ // 位置在取 sid **之前**——子代理会话不得污染 lastSession,否则顶层会话的自动
405
+ // 召回会拿子代理的 session 去读(读错会话)。字段缺失即不拦,见 isSubagentSession。
406
+ if (isSubagentSession(session)) {
407
+ ctx.logger.info('dsh-memory: 子代理会话的自动记忆被拦(H1:header.origin/delegationDepth)');
408
+ return;
409
+ }
223
410
  // 会话归属(P45):记忆写入必须带会话身份,用来区分不同会话的记忆。
224
- // 空串 = 宿主未给出会话标识 → 不声明,交给内核回落到进程身份(不编造)。
411
+ // 空串 = 宿主未给出会话标识 → **显式标注 unassigned**(H2③:不落内核的进程级
412
+ // 随机 sess_*,也不编造宿主会话 id——见 UNASSIGNED_SESSION 的注释)。
225
413
  const sid = sessionIdOf(session);
226
414
  if (sid)
227
415
  lastSession = sid;
228
- const sessionTag = sid ? { session: sid } : {};
416
+ const sessionTag = sid ? { session: sid } : { session: UNASSIGNED_SESSION };
229
417
  if (event.type === 'user/message' && opts.userMessage) {
418
+ // H1 **消息级**判据:委派/中继消息(`form: 'relay'` 语义=「另一个 agent
419
+ // 发给本 agent 的消息」)不写。先于 kind 判据,理由见 isRelayedMessage 注释。
420
+ if (isRelayedMessage(event.data.source)) {
421
+ ctx.logger.info('dsh-memory: 委派/中继消息的自动记忆被拦(H1:source.form=relay)');
422
+ return;
423
+ }
230
424
  // 只记真实用户输入(kind='user'),跳过插件注入/系统上下文
231
425
  if (event.data.source?.kind !== 'user') {
232
426
  // T4 诊断(2026-08-30):dsh 端对话零写入排查——记录被滤事件的实际
@@ -247,7 +441,16 @@ export function installMemoryHooks(ctx, mdcg, opts) {
247
441
  // T4:用用户消息做一次语义召回——md_cg 的读取会记 access log(复用观测,
248
442
  // 供 importance / scrub 陈旧度使用),同时预热检索路径。
249
443
  // (AEIS 侧的 `_note_reuse` 在 md_cg 中不存在,其等价物就是这次记访问。)
250
- memorize('user-recall', (g) => g.recall(safe.slice(0, 200), 3));
444
+ //
445
+ // H2③(2026-09-30):**显式带会话**。此前不传 session,靠服务端「cg 读路径
446
+ // 丢弃请求 session」侥幸不串台;一旦读侧归一化在召回链路上生效,不传就等价于
447
+ // 跨会话(全库)召回。此处走 `read(query, {k, ...sessionTag})`——与
448
+ // `recall(query, k)` 是同一条 MCP 出口(`cg(op=read)`,见 mdcg_client.ts 的
449
+ // recall → read),差别只在能带上会话槽;`recall` 没有 extra 形参,给它加槽要
450
+ // 改 mdcg_client.ts(本件放行面之外),故在调用点改走等价出口。
451
+ // 带上它不构成越权:cg 读路径的 session 是**归因/视图**维度,不参与任何授权
452
+ // (issue #35 定稿「身份不可自报」,见 md_cg/mdcos.py 的 _candidates)。
453
+ memorize('user-recall', (g) => g.read(safe.slice(0, 200), { k: 3, ...sessionTag }));
251
454
  }
252
455
  else if (event.type === 'assistant/message' && opts.assistantMessage) {
253
456
  const text = extractText(event.data.message.content);
package/lib/init.d.ts ADDED
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * init.ts —— 灵枢一键配置生成器(独立 CLI 入口:`lingshu-init`)
4
+ *
5
+ * 用户痛点:部署灵枢要手工做多步——选记忆根目录、手写各端 mcp.json 的
6
+ * env(MDCG_ROOT / MDCG_PYTHON 等)、理解令牌签发。本命令把「生成配置」
7
+ * 这一步自动化:三问(接入端 / 记忆根目录 / Python 解释器)后打印所选端
8
+ * 的 mcp.json 配置片段、把片段落盘成可复制的文件、并给出各端后续步骤
9
+ * (片段放哪 / 令牌签发命令——后者指向 README「写入凭据」节)。
10
+ *
11
+ * 边界(刻意设计,勿越界):
12
+ * · init **只生成配置**——不创建记忆目录、不启动 md_cg 服务、不写库;
13
+ * 重跑(同参数)输出逐字节一致(无时间戳等易变内容),可放心重复执行。
14
+ * · 与插件运行时的数据根缺省(`~/.dsh/.dsh-memory/data/mdcg`,见
15
+ * `src/lib/datapath.ts`)不同,本命令的缺省记忆根是 `~/.lingshu/memory`
16
+ * ——它会被**显式写进**片段的 MDCG_ROOT,两侧不靠默契、无歧义。
17
+ *
18
+ * env 片段口径与 `mdcgChildEnv()`(src/lib/mdcg_client.ts)对齐:
19
+ * PYTHONIOENCODING/PYTHONUTF8 两条编码注入是硬约束(中文记忆场景,
20
+ * 详见该函数头注的 2026-09-20 读线程崩溃现场);MDCG_MCP_SURFACE=kernel
21
+ * 与各端 mcp.json 样例(claude/lingshu-memory/mcp.json.example 等)同口径。
22
+ * MDCG_PYTHON 对直挂端是文档性键(解释器由 command 决定),对 DSH 插件端
23
+ * 真实生效(`defaultPython()` 读它,src/lib/python_path.ts:42)。
24
+ *
25
+ * 零运行时依赖:只用 Node 内置模块(node:readline/promises / node:fs /
26
+ * node:path)——与全仓「CLI 不加第三方依赖」纪律一致。
27
+ */
28
+ import type { Readable, Writable } from 'node:stream';
29
+ /** 接入端:1=DSH 插件 2=Claude Code 3=Codex CLI 4=通用 MCP。 */
30
+ export type InitEnd = 'dsh' | 'claude' | 'codex' | 'generic';
31
+ interface EndSpec {
32
+ id: InitEnd;
33
+ /** 问答菜单里的展示名。 */
34
+ label: string;
35
+ /** MDCG_ACTOR(与既有各端样例/插件配置同口径;私有内容按 (tenant, actor) 派生 DEK)。 */
36
+ actor: string;
37
+ /** 片段该放到哪(打印给用户)。 */
38
+ configTarget: string;
39
+ /** 令牌签发指引里说明的角色语境(保持 README 口径,仅按端换 actor)。 */
40
+ tokenActor: string;
41
+ }
42
+ /** 缺省记忆根:<home>/.lingshu/memory(home 可注入,供测试)。 */
43
+ export declare function defaultMemoryRoot(home?: string): string;
44
+ /** 生成的 mcp.json 片段(键序固定 → 序列化稳定 → 幂等)。 */
45
+ export declare function buildSnippet(end: EndSpec, root: string, python: string): {
46
+ mcpServers: {
47
+ mdcg: {
48
+ command: string;
49
+ args: string[];
50
+ env: Record<string, string>;
51
+ };
52
+ };
53
+ };
54
+ /** runInit 可注入的 IO(测试注入;缺省 = 真实 stdin/stdout/cwd/home/平台)。 */
55
+ export interface InitOptions {
56
+ /** 交互模式的 stdin(注入 PassThrough 即可 mock)。 */
57
+ input?: Readable;
58
+ /** 输出收集处(缺省 process.stdout)。 */
59
+ output?: Writable;
60
+ /** 片段文件落盘目录(缺省 process.cwd())。 */
61
+ cwd?: string;
62
+ /** 缺省记忆根的 home(缺省 os.homedir())。 */
63
+ home?: string;
64
+ /** 平台缺省解释器判定(缺省 process.platform)。 */
65
+ platform?: NodeJS.Platform;
66
+ /** 覆盖 TTY 判定(缺省 process.stdin.isTTY):决定「无参数时」走交互还是
67
+ * 报错。测试注入 false 即可确定性触发非交互分支,不依赖测试宿主的 stdin
68
+ * 形态(管道/终端两态都会让测试结果漂移——显式注入消除这个自由度)。 */
69
+ tty?: boolean;
70
+ }
71
+ export interface InitResult {
72
+ end: InitEnd;
73
+ endLabel: string;
74
+ root: string;
75
+ python: string;
76
+ snippetPath: string;
77
+ snippet: ReturnType<typeof buildSnippet>;
78
+ /** 本次打印到 output 的完整文本(幂等断言用)。 */
79
+ output: string;
80
+ /** 片段文件字节(= JSON.stringify(snippet, null, 2) + 换行)。 */
81
+ snippetFile: string;
82
+ }
83
+ /**
84
+ * 一键配置主流程:非交互(参数齐)跳过问答;无参数走交互三问。
85
+ * 只生成配置(打印 + 片段落盘),不创建目录、不启动服务、不写库。
86
+ */
87
+ export declare function runInit(argv: string[], opts?: InitOptions): Promise<InitResult>;
88
+ export {};