@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
@@ -0,0 +1,352 @@
1
+ # -*- coding: utf-8 -*-
2
+ """md_cg · 真源索引契约层(P0)——通用层 + 细化层,零解码 / 零模型 / 零第三方依赖。
3
+
4
+ 设计依据:`docs/plans/真源索引_通用机制_v0.3.md`(使用者裁定 2026-09-28)。
5
+ 本模块只做**契约与纯函数**:把任意文件变成「可寻址单位」,并提供按单位回读。
6
+ 落库(写认知图 ref 节点)在 P1 接 `refindex.add_items`,本模块不写任何状态。
7
+
8
+ 三层(v0.3):
9
+ ① **通用层**(任意文件):条目 = {path, name, type, size, mtime, file_hash},span=whole,
10
+ text_view = 文件名 + 路径 + 类型(+ 伴随文本)。类型只进检索面,**不决定能否索引**。
11
+ ② **细化层**(仅当真源本身可读文本):按章节切成 line 区间单位(每单位自带区间哈希),
12
+ 并产摘要/简介;带 `# 生效条件:` 一类 CCG 行的走既有 condition_space 同源机制。
13
+ ③ **伴随文本**(多模态的语义通道):同名 sidecar / 目录说明 —— 它们**本身即真源**,
14
+ 由调用方照常索引;本模块负责**发现并挂到通用层条目上**(`companions()`)。
15
+
16
+ 为什么不需要解码器与模型(v0.3 撤回项):通用层是**整文件粒度**,不做时间点/区域切片;
17
+ 语义面走伴随文本而非模型产出。二者降级为后续可选(方案 §12)。
18
+
19
+ 区间单位(`unit`)本版只有两种:`whole`(整文件)与 `line`(文本行区间)。`time` / `region` /
20
+ `byte` 三种 locator 属后续可选,**本模块显式不实现**(不静默假装支持)。
21
+
22
+ 生效条件:入参 path 指向常规文件(`os.path.isfile` 为真);`units()` 对不可读/无权限文件返回
23
+ 只含通用层单位的列表(不抛),`read_unit('whole')` 一律返回原始字节。
24
+ 不适用于:无文件实体的真源(须先物化);跨机指针(路径为绝对路径,跨机需另立锚)。
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import io
30
+ import os
31
+
32
+ CHUNK = 1 << 20 # 文件哈希分块(大文件不整读进内存)
33
+ SNIFF_BYTES = 4096 # 类型嗅探与「可读文本」判定取的前缀长度
34
+
35
+ # 本版实现的 unit 类型;time/region/byte 属后续可选(方案 §12),此处显式声明未实现
36
+ UNITS = ("whole", "line")
37
+
38
+ TEXT_SUFFIX = {
39
+ ".md": "text/markdown", ".markdown": "text/markdown", ".txt": "text/plain",
40
+ ".json": "application/json", ".jsonl": "application/x-ndjson",
41
+ ".yaml": "application/yaml", ".yml": "application/yaml",
42
+ ".csv": "text/csv", ".tsv": "text/tab-separated-values",
43
+ ".py": "text/x-python", ".rs": "text/x-rust", ".ts": "text/x-typescript",
44
+ ".js": "text/javascript", ".sh": "text/x-shellscript", ".toml": "application/toml",
45
+ ".ini": "text/plain", ".cfg": "text/plain", ".log": "text/plain",
46
+ ".html": "text/html", ".htm": "text/html", ".xml": "text/xml",
47
+ ".sql": "text/x-sql", ".c": "text/x-c", ".h": "text/x-c", ".cpp": "text/x-c++",
48
+ }
49
+ # 魔数 → 类型(只列常见;未命中即回落扩展名 / octet-stream,绝不因未知类型拒绝入索引)
50
+ MAGIC = (
51
+ (b"\x89PNG\r\n\x1a\n", "image/png"),
52
+ (b"\xff\xd8\xff", "image/jpeg"),
53
+ (b"GIF87a", "image/gif"), (b"GIF89a", "image/gif"),
54
+ (b"RIFF", "audio-or-video/riff"), # 细分再看子类型(WAVE / AVI)
55
+ (b"ID3", "audio/mpeg"), (b"\xff\xfb", "audio/mpeg"),
56
+ (b"OggS", "audio-or-video/ogg"),
57
+ (b"fLaC", "audio/flac"),
58
+ (b"\x00\x00\x00\x18ftyp", "video/mp4"),
59
+ (b"\x1aE\xdf\xa3", "video/webm-or-matroska"),
60
+ (b"PK\x03\x04", "application/zip"),
61
+ (b"%PDF-", "application/pdf"),
62
+ (b"BZh", "application/x-bzip2"),
63
+ (b"\x1f\x8b", "application/gzip"),
64
+ (b"\x7fELF", "application/x-elf"),
65
+ (b"MZ", "application/x-dosexec"),
66
+ )
67
+
68
+
69
+ # 生效条件:path 为字符串且可 os.stat;返回 {path, name, dir, suffix, size, mtime, file_hash},
70
+ # 路径取 abspath(真源身份要跨调用稳定);file_hash 为文件级 sha256(P0 的 staleness 判据)。
71
+ def src_id(path: str) -> dict:
72
+ """真源身份:**文件名 / 路径 / 大小 / 时间 / 文件哈希**(索引的通用层主键面)。"""
73
+ ap = os.path.abspath(path)
74
+ st = os.stat(ap)
75
+ return {
76
+ "path": ap,
77
+ "name": os.path.basename(ap),
78
+ "dir": os.path.dirname(ap),
79
+ "suffix": os.path.splitext(ap)[1].lower(),
80
+ "size": st.st_size,
81
+ "mtime": st.st_mtime,
82
+ "file_hash": file_hash(ap),
83
+ }
84
+
85
+
86
+ def file_hash(path: str) -> str:
87
+ h = hashlib.sha256()
88
+ with open(path, "rb") as f:
89
+ while True:
90
+ b = f.read(CHUNK)
91
+ if not b:
92
+ break
93
+ h.update(b)
94
+ return h.hexdigest()
95
+
96
+
97
+ # 生效条件:prefix 为 bytes 时按 MAGIC 表逐条 startswith 判定,首个命中即返回其类型;
98
+ # 未命中回落 suffix 表;仍未命中返回 "application/octet-stream"(未知类型**不是**拒绝理由)。
99
+ def sniff_type(path: str, prefix: bytes = None) -> str:
100
+ """类型判据=魔数优先、扩展名兜底(零依赖);只用于检索面与分组,不决定可索引性。"""
101
+ if prefix is None:
102
+ try:
103
+ with open(path, "rb") as f:
104
+ prefix = f.read(SNIFF_BYTES)
105
+ except OSError:
106
+ prefix = b""
107
+ for magic, t in MAGIC:
108
+ if prefix.startswith(magic):
109
+ if magic == b"RIFF":
110
+ if prefix[8:12] == b"WAVE":
111
+ return "audio/wav"
112
+ if prefix[8:12] == b"AVI ":
113
+ return "video/x-msvideo"
114
+ return t
115
+ return TEXT_SUFFIX.get(os.path.splitext(path)[1].lower(),
116
+ "application/octet-stream")
117
+
118
+
119
+ # 生效条件:prefix 含 NUL 字节即判不可读;否则尝试 utf-8 严格解码,成功即判可读文本。
120
+ # 语义边界:只读前缀,宁少判不多判(截断在多字节字符中间的假阴性可接受——细化层缺席不致命)。
121
+ def is_text_readable(prefix: bytes) -> bool:
122
+ if b"\x00" in prefix:
123
+ return False
124
+ try:
125
+ prefix.decode("utf-8")
126
+ return True
127
+ except UnicodeDecodeError:
128
+ return False
129
+
130
+
131
+ def _read_prefix(path: str) -> bytes:
132
+ try:
133
+ with open(path, "rb") as f:
134
+ return f.read(SNIFF_BYTES)
135
+ except OSError:
136
+ return b""
137
+
138
+
139
+ # 生效条件:text 与 1-based 行区间给定;返回该区间的哈希——**委托唯一实现**
140
+ # `codeindex.region_hash`(sha1 前 12 位)。其 docstring 明写「必须是唯一定义:索引侧与回读侧
141
+ # 共用同一个函数」,两侧各写一份就会让漂移检测悄悄失效(永远 hash_match=True)。
142
+ # 本模块 P0 初版在此自写了 sha256 第二份,被守卫 5b/5c 当场抓出——本函数即那次修正的产物。
143
+ def region_hash(text: str, lineno: int, end: int) -> str:
144
+ from . import codeindex
145
+ return codeindex.region_hash(text.split("\n"), lineno, end)
146
+
147
+
148
+ # 生效条件:path 为常规文件时返回单位列表——**至少一条**通用层单位(span=whole,任意文件);
149
+ # 若前缀可读文本则追加细化层 line 单位(章节来自 docindex.extract,无提取器后缀则不切)。
150
+ # 返回的每条单位形如 {src, span, span_hash, text_view, reader};不写任何状态。
151
+ def units(path: str, *, with_sections: bool = True) -> list[dict]:
152
+ """把任意文件变成可寻址单位:通用层恒在(含伴随文本挂载),细化层仅对可读文本追加。"""
153
+ sid = src_id(path)
154
+ prefix = _read_prefix(path)
155
+ typ = sniff_type(path, prefix)
156
+ sid["type"] = typ
157
+ out = [{
158
+ "src": sid,
159
+ "span": {"unit": "whole"},
160
+ "span_hash": sid["file_hash"],
161
+ "text_view": "文件 %s(%s)|路径 %s" % (sid["name"], typ, sid["path"]),
162
+ "reader": {"kind": "whole"},
163
+ }]
164
+ # 伴随文本属**通用层**:多模态文件的语义面就靠它(P0 初版误挂在「可读文本」分支里,
165
+ # 而媒体根本不进那个分支 ⇒ 恰好废掉方案的 §10.3 语义通道,被守卫 6b 抓出)
166
+ comps = companions(sid["path"])
167
+ if comps:
168
+ out[0]["text_view"] += "|伴随文本 " + "、".join(c["name"] for c in comps)
169
+ out[0]["companions"] = [c["path"] for c in comps]
170
+ out[0]["companion_kinds"] = sorted({c["kind"] for c in comps})
171
+ if not is_text_readable(prefix):
172
+ return out
173
+ try:
174
+ text = io.open(path, encoding="utf-8").read()
175
+ except (OSError, UnicodeDecodeError):
176
+ return out
177
+ if not with_sections:
178
+ return out
179
+ try:
180
+ from . import docindex
181
+ items = docindex.extract(text, path=sid["path"])
182
+ except Exception: # noqa: BLE001 —— 无提取器/解析失败:停在通用层,不抛
183
+ return out
184
+ lines = text.split("\n")
185
+ for it in items:
186
+ lineno = int(it.get("lineno") or 1)
187
+ end = int(it.get("end") or lineno)
188
+ head = it.get("heading") or it.get("name") or ""
189
+ body = "\n".join(lines[lineno - 1:end])
190
+ out.append({
191
+ "src": sid,
192
+ "span": {"unit": "line", "start": lineno, "end": end,
193
+ "anchor": it.get("anchor"), "level": it.get("level"),
194
+ "heading_path": it.get("heading_path")},
195
+ # 哈希单一实现:优先用 docindex 已算的(与 region_hash 同源),缺则现算
196
+ "span_hash": it.get("hash") or region_hash(text, lineno, end),
197
+ "text_view": ("%s | %s" % (sid["name"], head)).strip(),
198
+ "reader": {"kind": "line"},
199
+ # CCG 条件面:与正文同源(docindex.condition_space 解析 `# 生效条件:` 行)
200
+ "condition_space": (docindex.condition_space(it)
201
+ if hasattr(docindex, "condition_space") else None),
202
+ "summary": body[:200],
203
+ })
204
+ return out
205
+
206
+
207
+ # 生效条件:同目录下存在与媒体同主的可读文本→视为伴随文本;判定=同 stem 且后缀属
208
+ # 可读文本集合(.md/.txt/.json/.caption/.prompt),另加同目录 README/_index.md(目录说明)。
209
+ # 只发现与返回路径,不索引、不改动任何文件。
210
+ def companions(path: str) -> list[dict]:
211
+ """伴随文本发现(多模态的语义通道):同名 sidecar + 目录说明。"""
212
+ d = os.path.dirname(os.path.abspath(path))
213
+ stem = os.path.splitext(os.path.basename(path))[0]
214
+ found = []
215
+ for suffix in (".md", ".txt", ".json", ".caption", ".prompt"):
216
+ p = os.path.join(d, stem + suffix)
217
+ if os.path.isfile(p) and os.path.abspath(p) != os.path.abspath(path):
218
+ found.append({"path": os.path.abspath(p), "name": os.path.basename(p),
219
+ "kind": "sidecar"})
220
+ for name in ("README.md", "_index.md", "INDEX.md"):
221
+ p = os.path.join(d, name)
222
+ if os.path.isfile(p):
223
+ found.append({"path": os.path.abspath(p), "name": name, "kind": "dir_doc"})
224
+ break
225
+ return found
226
+
227
+
228
+ # 生效条件:unit 含 span;unit.span.unit == "whole" 时返回原文件字节;== "line" 时按
229
+ # src.path 读文本并返回 [start, end] 行;未知 unit 返回 ok=False 与 error(**不静默**)。
230
+ # with_hash=True 时附带 span_hash 复核结果(回读文本重算 vs unit 内记录)。
231
+ def read_unit(unit: dict, *, with_hash: bool = True) -> dict:
232
+ """按单位回读真源:通用层返回整文件字节,细化层返回行区间文本。"""
233
+ src = unit.get("src") or {}
234
+ path = src.get("path") or ""
235
+ span = unit.get("span") or {}
236
+ kind = span.get("unit")
237
+ if kind == "whole":
238
+ try:
239
+ with open(path, "rb") as f:
240
+ data = f.read()
241
+ except OSError as e:
242
+ return {"ok": False, "error": "读取失败:%s" % e}
243
+ out = {"ok": True, "bytes": len(data), "hash": hashlib.sha256(data).hexdigest()}
244
+ if with_hash:
245
+ out["hash_match"] = out["hash"] == unit.get("span_hash")
246
+ return out
247
+ if kind == "line":
248
+ try:
249
+ text = io.open(path, encoding="utf-8").read()
250
+ except (OSError, UnicodeDecodeError) as e:
251
+ return {"ok": False, "error": "读取失败:%s" % e}
252
+ a = int(span.get("start") or 1)
253
+ b = int(span.get("end") or 1)
254
+ seg = "\n".join(text.split("\n")[max(0, a - 1):max(max(0, a - 1), b)])
255
+ out = {"ok": True, "text": seg, "lines": b - a + 1, "hash": region_hash(text, a, b)}
256
+ if with_hash:
257
+ out["hash_match"] = out["hash"] == unit.get("span_hash")
258
+ return out
259
+ return {"ok": False,
260
+ "error": "未知 span.unit=%r(本版支持 %s;time/region/byte 属后续可选)"
261
+ % (kind, "/".join(UNITS))}
262
+
263
+
264
+ # ==========================================================================
265
+ # 日志真源适配器(P1):DSH 会话日志 → 真源身份 + 区间表
266
+ #
267
+ # 日志这一路与普通文件的差别只有一处(v0.3 §9 裁定①):**真源是 zstd JSONL,
268
+ # 可读区间却落在确定性转写上**。所以适配器把两件事分开交付——
269
+ # · 真源身份(`session_src_id`):日志本体的 7 键 + 会话身份 4 键;
270
+ # · 区间表(`log_units`):span 取自转写(docindex 切分),src 指向日志本体。
271
+ # 二者都只是**把既有单点拼起来**:身份仍由 `src_id` 定、区间仍由 `docindex.extract`
272
+ # 切、哈希仍由 `region_hash` 算。本模块不写任何状态、不 import scripts。
273
+ # ==========================================================================
274
+
275
+ #: `srcindex.session_src_id` 认的 path_mode——见函数 docstring 的暴露面说明
276
+ SRC_PATH_MODES = ("abs", "rel", "hash")
277
+
278
+
279
+ # 生效条件:log_path 为可 stat 的常规文件;path_mode 属 SRC_PATH_MODES(否则抛 ValueError);
280
+ # 返回 src_id 的 7 键 + {kind, session_uuid, workspace, sensitivity} 4 键,共 11 键。
281
+ def session_src_id(log_path: str, *, session_uuid: str = None,
282
+ workspace: str = None, sensitivity: str = "internal",
283
+ path_mode: str = "abs", base: str = None) -> dict:
284
+ """DSH 会话日志的真源身份:`src_id` 7 键 + 会话身份 4 键(只做加法)。
285
+
286
+ `file_hash` 是 staleness 的**唯一**判据(P0 裁定),故任何降级都不得动它。
287
+
288
+ `path_mode` 是**暴露面降级开关**:src 含真源绝对路径 + 会话 uuid,而节点是
289
+ internal 档(跨会话共享可见,dsh_log_index.py:16-21 的共享档语义)⇒ 任何能读
290
+ 该节点的会话都拿到本机日志绝对路径与会话 uuid。三档语义:
291
+ · `abs`(缺省)——绝对路径,探测能力完整;
292
+ · `rel`——相对 `base`(缺省取日志自身目录)且 **`dir` 一并置空**:留下绝对
293
+ 目录等于把 rel 刚省下的又落回库里;
294
+ · `hash`——**path 置空**。
295
+ rel 与 hash 两档都不支持探测(`logref.probe_src` 返回 unresolved,属「明确
296
+ 不探测」而非静默通过——不猜、不假装能核);探测能力完整只有 `abs`。
297
+ """
298
+ sid = src_id(log_path)
299
+ ap = sid["path"]
300
+ if path_mode == "rel":
301
+ b = os.path.abspath(base) if base else os.path.dirname(ap)
302
+ sid["path"] = os.path.relpath(ap, b).replace("\\", "/")
303
+ sid["dir"] = ""
304
+ elif path_mode == "hash":
305
+ sid["path"] = ""
306
+ sid["dir"] = ""
307
+ elif path_mode != "abs":
308
+ raise ValueError("未知 path_mode=%r(支持 %s)"
309
+ % (path_mode, "/".join(SRC_PATH_MODES)))
310
+ return {**sid, "kind": "dsh_session_log", "session_uuid": session_uuid,
311
+ "workspace": workspace, "sensitivity": sensitivity}
312
+
313
+
314
+ # 生效条件:transcript_path 为可读 md、src 为日志真源身份(session_src_id 的产物);
315
+ # 返回按 docindex 章节切分的区间表,每条 {src, span(unit=line), span_hash, text_view,
316
+ # reader, item};`item` 是**落库口径**的原始条目(path 已按入参 path 定)。
317
+ def log_units(transcript_path: str, src: dict, *, path: str = None) -> list[dict]:
318
+ """日志真源的区间表:span 落在**确定性转写**上,src 指向 zstd 日志本体。
319
+
320
+ `path` 是 docindex 的寻址键面(`path#heading_path` 里的 path,须与最终写进
321
+ `doc_ref.path` 的值同源),缺省回落转写的 basename——**缺省只用于单会话、
322
+ 单转写根的临时场景**:多工作区同 sid 时会撞 id(见 `md_cg/test_logref.py`
323
+ 的 path 口径守卫)。
324
+ """
325
+ from . import docindex # 局部 import:与 units() 同款,避免模块级循环依赖
326
+ text = io.open(transcript_path, encoding="utf-8").read()
327
+ p = path or os.path.basename(transcript_path)
328
+ out = []
329
+ for it in docindex.extract(text, path=p):
330
+ lineno = int(it.get("lineno") or 1)
331
+ end = int(it.get("end") or lineno)
332
+ out.append({
333
+ "src": src,
334
+ "span": {"unit": "line", "start": lineno, "end": end,
335
+ "anchor": it.get("anchor"), "level": it.get("level"),
336
+ "heading_path": it.get("heading_path")},
337
+ "span_hash": it.get("hash") or region_hash(text, lineno, end),
338
+ "text_view": "%s | %s" % (src.get("name") or "", it.get("heading") or ""),
339
+ "reader": {"kind": "line"},
340
+ "item": it,
341
+ })
342
+ return out
343
+
344
+
345
+ # 生效条件:无(诊断用);返回本模块对「通用层/细化层」的能力自陈,供文档与守卫比对。
346
+ def capability() -> dict:
347
+ return {"units": list(UNITS),
348
+ "universal_layer": "任意常规文件(span=whole)",
349
+ "detail_layer": "仅可读文本(line 区间 + 摘要 + CCG 条件面)",
350
+ "companions": ["sidecar", "dir_doc"],
351
+ "not_implemented": ["time", "region", "byte"],
352
+ "deps": [], "writes_state": False}
package/md_cg/stg.py CHANGED
@@ -210,7 +210,49 @@ def relation(cg, a_id, b_id, time_axis="observed"):
210
210
  "space_known": ba is not None and bb is not None}}
211
211
 
212
212
 
213
- # 生效条件:以 _scan(cg,layer=layer,max_scan=max_scan) 为范围,session 去空白后非空且不为 "*" 时仅保留 frontmatter.session 精确相等的节点,_interval(n["frontmatter"], time_axis) 为 None 的节点被跳过,其余按 (start,end) 以 reverse=bool(desc) 排序,返回 count=全部命中数、limit=传入 limit、session=生效的会话过滤值(跨会话时为 None)、items 为排序后前 limit 项(limit=0 时为空列表)且每项附 session 归属与 _preview(cg,id)(time_axis 缺省 observed,与旧行为逐位一致;非法轴抛 ValueError)。
213
+ # 生效条件:session 为 None 或 str(session).strip() 为空串时返回 ""(跨会话视图的内部表示,与旧实现 `"" if session is None else str(session).strip()` 逐位一致);去空白后恰为 "*" 时返回 "*";否则延迟导入 mcp_server._normalize_session 并返回其归一结果;该导入抛任何异常(ImportError 等)时返回去空白原值(fail-soft 退回旧行为)。
214
+ def _view_session(session):
215
+ """读侧会话视图值 → 过滤值:**与写侧同一把尺**(H2,2026-09-30)。
216
+
217
+ 动机(端到端实测,见 `md_cg/test_h2_session_view_norm.py`):写侧落盘值 =
218
+ `_normalize_session(请求声明值)`(`MdCGSecure._attribution` 取 `cg.session`,
219
+ 而 `cg.session` 由 `call_tool` → `_declared_session` 归一),读侧若拿
220
+ **未归一的原值**做等值比较,同一条记忆就「写进去查不出」——DSH 形态的
221
+ 会话 id 在会话根下不存在时,写侧落 `anonymous`、读侧按 `session-…`
222
+ 精确匹配 ⇒ `count=0`。
223
+
224
+ 由此本函数**只归一「具体会话值」这一态**,三态语义逐位不变:
225
+ · None / 空串 → 跨会话(缺省不过滤,向后兼容);
226
+ · `"*"` → 跨会话(显式意图;`"*"` 不是会话名,**不归一**);
227
+ · 其它值 → 过 `_normalize_session`(本会话视图与写侧同尺)。
228
+
229
+ 真源只有一份(`mcp_server._normalize_session`),此处**消费而不重写**:
230
+ 写侧读侧各写一份校验必然造出第三种不等值。延迟导入的副作用为零——正常
231
+ 形态下 stg 本就被 mcp_server 调用(模块早已加载);`stg` 被独立使用时导入
232
+ 失败即 fail-soft 返回原值(退回旧行为,不报错、不改变既有语义)。
233
+
234
+ 不适用条件:`cg` 侧读路径(search/recall/`cg(op=read)`)的请求 `session`
235
+ 走 `MdCGSecure._candidates` 的**身份判定**(issue #35 定稿:身份不可自报),
236
+ 不经本函数——那不是视图过滤,两处不得互相「对齐」。
237
+
238
+ 返回值口径:None 与空串一律映射为 `""`(旧实现 `"" if session is None
239
+ else str(session).strip()` 的内部表示,跨会话分支判据 `sid in ("", "*")`
240
+ 依赖它——返回 None 会让 `cross` 判假、把缺省视图变成「只看 session 为
241
+ 空的节点」,是一处会静默清空整块自动召回的坑)。
242
+ """
243
+ if session is None:
244
+ return ""
245
+ s = str(session).strip()
246
+ if s in ("", "*"):
247
+ return s
248
+ try:
249
+ from .mcp_server import _normalize_session
250
+ except Exception: # noqa: BLE001 fail-soft:保旧行为
251
+ return s
252
+ return _normalize_session(s)
253
+
254
+
255
+ # 生效条件:以 _scan(cg,layer=layer,max_scan=max_scan) 为范围,session 经 _view_session 归一后(None/空/"*"=跨会话不过滤,其它值=归一后的具体会话)非跨会话时仅保留 frontmatter.session 精确相等的节点,_interval(n["frontmatter"], time_axis) 为 None 的节点被跳过,其余按 (start,end) 以 reverse=bool(desc) 排序,返回 count=全部命中数、limit=传入 limit、session=生效的会话过滤值(跨会话时为 None)、items 为排序后前 limit 项(limit=0 时为空列表)且每项附 session 归属与 _preview(cg,id)(time_axis 缺省 observed,与旧行为逐位一致;非法轴抛 ValueError)。
214
256
  def timeline(cg, layer=None, limit=50, desc=True, max_scan=5000,
215
257
  time_axis="observed", session=None):
216
258
  """按时间排序的节点列表。`time_axis` 决定排序依据的时间区间(见 `_interval`)。
@@ -220,11 +262,13 @@ def timeline(cg, layer=None, limit=50, desc=True, max_scan=5000,
220
262
  · `"*"` → 同上语义,但把「我要看所有会话做了什么」写成**显式意图**,与
221
263
  「忘了传参」区分开,审计里也看得出这是一次跨会话读取;
222
264
  · 其它值 → 只取 `frontmatter.session` 精确相等的节点(本会话视图,
223
- 自动召回用它防串台)。
265
+ 自动召回用它防串台)。**该值先过 `_view_session` 归一**(H2)——
266
+ 写侧落盘时已过 `_normalize_session`,读侧不过同一把尺就会出现
267
+ 「写进去查不出」;返回体 `session` 回带的是**归一后**的生效值。
224
268
  `items` 一并回带 `session`:跨会话视图下「这条是哪个会话做的」必须可辨,
225
269
  否则「能读到所有会话做了什么」只剩内容、丢了归属。
226
270
  """
227
- sid = "" if session is None else str(session).strip()
271
+ sid = _view_session(session)
228
272
  cross = sid in ("", "*") # 跨会话:显式 "*" 与缺省同义
229
273
  items = []
230
274
  for n in _scan(cg, layer=layer, max_scan=max_scan):
package/md_cg/subgraph.py CHANGED
@@ -360,7 +360,7 @@ def separation_pairs(cg, layer=None, ids=None, max_nodes=SEP_MAX_NODES,
360
360
  """
361
361
  from . import consistency
362
362
  nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
363
- pool = [nid for nid, e in nodes.items()
363
+ pool = [nid for nid, e in list(nodes.items())
364
364
  if (not layer or e.get("layer") == layer)]
365
365
  if ids:
366
366
  want = {str(x) for x in ids}
@@ -622,7 +622,7 @@ def reconstruct_scene(cg, clues=None, ids=None, conditions=None, layer=None,
622
622
  if t in nodes and t not in want_ids:
623
623
  want_ids.append(t)
624
624
 
625
- pool = [nid for nid, e in nodes.items()
625
+ pool = [nid for nid, e in list(nodes.items())
626
626
  if (not layer or (e or {}).get("layer") == layer)]
627
627
  truncated = len(pool) > int(max_nodes)
628
628
  pool.sort()