@furongjun1999/dsh-memory 0.5.0 → 0.5.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 (138) hide show
  1. package/README.md +552 -465
  2. package/docs/README.md +1 -0
  3. package/docs/eval/bench_lingshu_self/bench_self.py +140 -0
  4. package/docs/eval/bench_lingshu_self/self_bench_result.json +404 -0
  5. package/docs/eval/bench_lingshu_self//347/201/265/346/236/242/350/207/252/345/272/223/347/253/257/345/210/260/347/253/257/346/243/200/347/264/242/345/256/236/346/265/213_v1.0.md +38 -0
  6. package/docs/eval//344/270/215/345/217/257/351/235/240/346/200/247/350/220/275/345/234/260_P0_v1.0.md +185 -0
  7. package/docs/eval//346/225/205/351/232/234/346/263/250/345/205/245/345/256/236/346/265/213_v1.0.md +422 -0
  8. 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 +100 -0
  9. 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 +197 -0
  10. 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_v1.md +156 -0
  11. 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_v10.md +210 -0
  12. 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_v11.md +227 -0
  13. 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_v12.md +203 -0
  14. 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_v13.md +233 -0
  15. 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_v14.md +191 -0
  16. 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_v15.md +213 -0
  17. 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_v16.md +214 -0
  18. 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_v17.md +199 -0
  19. 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_v2.md +156 -0
  20. 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_v3.md +152 -0
  21. 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_v4.md +128 -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_v5.md +114 -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_v6.md +192 -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_v7.md +187 -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_v8.md +207 -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_v9.md +203 -0
  27. package/docs/hive//345/256/211/345/205/250/345/256/241/350/256/241/345/256/236/351/224/232_v0.1.md +202 -0
  28. 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 +19 -3
  29. package/docs/images/lingshu-moonlight-covenant-poster-preview.jpg +0 -0
  30. package/docs/images/lingshu-moonlight-covenant-poster.png +0 -0
  31. 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
  32. package/docs/theory//344/270/215/345/217/257/351/235/240/345/256/232/347/220/206/344/270/216/345/244/261/346/225/210/344/274/230/345/205/210/346/241/206/346/236/266_v0.3.md +365 -0
  33. package/docs//344/270/215/345/217/257/351/235/240/346/200/247/347/220/206/350/256/272_v0.1.md +343 -0
  34. package/lib/bridge.d.ts +9 -0
  35. package/lib/bridge.js +35 -0
  36. package/lib/index.js +7 -1
  37. package/lib/lib/roleplay_web.js +530 -443
  38. package/lib/lib/token_store.d.ts +7 -1
  39. package/lib/lib/token_store.js +12 -3
  40. package/md_cg/audit.py +12 -1
  41. package/md_cg/backfill.py +16 -15
  42. package/md_cg/backfill_bucket_zh.py +35 -0
  43. package/md_cg/bench_e2e_judge.py +532 -0
  44. package/md_cg/bench_e2e_locomo_qa.py +368 -0
  45. package/md_cg/bench_e2e_qa.py +256 -0
  46. package/md_cg/branches.py +18 -2
  47. package/md_cg/ccgc.py +3 -2
  48. package/md_cg/chain.py +19 -4
  49. package/md_cg/consolidate.py +7 -6
  50. package/md_cg/crosscheck.py +4 -3
  51. package/md_cg/crypto.py +439 -437
  52. package/md_cg/datapath.py +395 -335
  53. package/md_cg/evidence.py +582 -580
  54. package/md_cg/export.py +3 -1
  55. package/md_cg/forgetting.py +2 -2
  56. package/md_cg/fsutil.py +48 -0
  57. package/md_cg/hotcache.py +255 -238
  58. package/md_cg/interop.py +161 -22
  59. package/md_cg/judgment_manifest.py +177 -0
  60. package/md_cg/links.py +655 -622
  61. package/md_cg/mcp_server.py +280 -29
  62. package/md_cg/mdcg.py +616 -125
  63. package/md_cg/mdcos.py +430 -66
  64. package/md_cg/mreview/govern.py +5 -4
  65. package/md_cg/postings.py +4 -2
  66. package/md_cg/readcache.py +76 -18
  67. package/md_cg/reconcile.py +228 -0
  68. package/md_cg/review_cli.py +170 -0
  69. package/md_cg/routing.py +28 -0
  70. package/md_cg/run_tests.py +211 -0
  71. package/md_cg/scrub.py +862 -852
  72. package/md_cg/security.py +385 -275
  73. package/md_cg/selfreport.py +3 -2
  74. package/md_cg/signer.py +565 -562
  75. package/md_cg/sources.py +3 -2
  76. package/md_cg/stg.py +6 -0
  77. package/md_cg/sustain.py +1168 -1138
  78. package/md_cg/test_access_hints.py +147 -0
  79. package/md_cg/test_branch_discard_tombstone.py +136 -0
  80. package/md_cg/test_branches.py +259 -249
  81. package/md_cg/test_chain_read_isolate.py +168 -0
  82. package/md_cg/test_datapath_device_name.py +203 -0
  83. package/md_cg/test_emit_negtail_cache.py +156 -0
  84. package/md_cg/test_en_pipeline.py +186 -166
  85. package/md_cg/test_govern_directread.py +421 -0
  86. package/md_cg/test_i32_hotcache_env_key.py +218 -0
  87. package/md_cg/test_identity_attribution.py +228 -147
  88. package/md_cg/test_index_durability.py +238 -224
  89. package/md_cg/test_interop.py +4 -2
  90. package/md_cg/test_interop_judgment.py +228 -0
  91. package/md_cg/test_issue39_utf8_stdio.py +273 -0
  92. package/md_cg/test_links_concurrent_write.py +188 -0
  93. package/md_cg/test_merge_upsert.py +168 -0
  94. package/md_cg/test_n123_derive_expiry_chain.py +205 -0
  95. package/md_cg/test_n130_verify_falsified_protect.py +185 -0
  96. package/md_cg/test_n131_merge_gate.py +205 -0
  97. package/md_cg/test_p1x_ref_root.py +160 -0
  98. package/md_cg/test_p27_docindex.py +774 -765
  99. package/md_cg/test_p2_mcp.py +3 -0
  100. package/md_cg/test_p32_backfill.py +304 -298
  101. package/md_cg/test_p39_verify_flow.py +90 -50
  102. package/md_cg/test_p47_session_view.py +60 -25
  103. package/md_cg/test_propose_tail_index.py +157 -0
  104. package/md_cg/test_read_scope_b27.py +277 -0
  105. package/md_cg/test_readcache_default_on.py +168 -0
  106. package/md_cg/test_readcache_precise_inval.py +270 -0
  107. package/md_cg/test_readcache_prodpath.py +55 -7
  108. package/md_cg/test_reconcile_v0.py +294 -0
  109. package/md_cg/test_retr_s1.py +6 -2
  110. package/md_cg/test_retr_s1b.py +67 -0
  111. package/md_cg/test_retr_s7.py +8 -0
  112. package/md_cg/test_retr_s9_entity_ctx.py +181 -175
  113. package/md_cg/test_retr_score_once.py +208 -0
  114. package/md_cg/test_review_onepass.py +170 -0
  115. package/md_cg/test_rrf_graph_seed_cache.py +154 -0
  116. package/md_cg/test_security_audit.py +155 -0
  117. package/md_cg/test_security_audit_b26.py +161 -0
  118. package/md_cg/test_security_audit_v21.py +250 -0
  119. package/md_cg/test_semantic_canonical.py +255 -241
  120. package/md_cg/test_session_isolation.py +168 -0
  121. package/md_cg/test_snapshot_autoclose.py +187 -0
  122. package/md_cg/test_tail_watermark_race.py +208 -0
  123. package/md_cg/test_tenant_env_override_warn.py +139 -0
  124. package/md_cg/test_tenant_registry_corrupt_warn.py +151 -0
  125. package/md_cg/test_v14_fixes.py +415 -397
  126. package/md_cg/test_verify_dirty_reconcile.py +157 -0
  127. package/md_cg/theory.py +276 -273
  128. package/md_cg/tokens.py +734 -677
  129. package/md_cg/units.py +3 -2
  130. package/md_cg/vision_evidence.py +4 -3
  131. package/md_cg/whitebox_kb/wisdom/code_solidified.json +6195 -6195
  132. package/md_cg/whitebox_kb/wisdom/multilang_ir.py +128 -124
  133. package/md_cg/writepipe.py +554 -550
  134. package/package.json +6 -2
  135. package/src/bridge.ts +434 -401
  136. package/src/index.ts +526 -518
  137. package/src/lib/roleplay_web.ts +1019 -932
  138. package/src/lib/token_store.ts +202 -192
package/md_cg/datapath.py CHANGED
@@ -1,335 +1,395 @@
1
- # -*- coding: utf-8 -*-
2
- """datapath.py · 灵枢数据根解析(记忆写入路径可配置)
3
-
4
- 与 node 侧 `src/lib/datapath.ts` **同口径**——两侧读同一份
5
- `<用户级状态根>/paths.json`(旧 `<插件仓>/data/paths.json` 兼容读)。
6
-
7
- 解析优先级(高 → 低):
8
- 1. 环境变量 `MDCG_DATA_ROOT`(数据根)/ `MDCG_ROOT`(认知图根)
9
- 2. 用户可编辑的路径文件 `paths.json` 的 "data_root" / "root"
10
- (位置:`<用户级状态根>/paths.json`;旧 `<插件仓>/data/paths.json` 兼容读)
11
- 3. 默认:`<用户级状态根>/data`(**插件包目录之外**,与进程 cwd 解耦)
12
-
13
- 为什么默认**不**锚定「插件仓自身 data/」(issue #18 相邻问题,数据丢失级):
14
- DSH 插件按 hoisted 布局装在 `<profile>/node_modules/<pkg>`,`pnpm` 更新该包会
15
- **整个替换包目录**——运行时数据落在包内时,每次更新成功即连目录一起删掉
16
- (实机实证:`data/` 54 文件 → 0,46 条记忆节点靠人工备份回填);`paths.json`
17
- 同址,用户配置一并丢失。故默认数据根与配置文件一律落**用户级状态根**
18
- (见 state_root()),与包目录彻底解耦。
19
-
20
- 为什么默认不锚定 `data/mdcg` 相对路径:
21
- 相对路径随进程 cwd 漂移——node 侧插件与 python 侧脚本 cwd 不同
22
- (历史事故:DSH 进程 cwd 在私有库目录时,记忆真源落到 `[私有库]/data/mdcg`,
23
- 与插件仓内的 `data/` 分裂成两处)。
24
-
25
- 设计边界:
26
- - 本模块只决定「**新写入去哪**」。唯一的搬运是 `migrate_legacy_data()`:
27
- 默认数据根生效且旧包内位置仍有内容时,一次性**复制**(不删除、不覆盖)
28
- 到用户级位置——见该函数注释。
29
- - 纯标准库、无包内相对导入——可被 `sys.path` 以顶层模块方式加载,
30
- 绕开 `md_cg/__init__.py` 的重依赖。
31
-
32
- 用法:
33
- from datapath import data_root, mdcg_root, state_dir # scripts/ 内
34
- python md_cg/datapath.py # 打印当前解析结果
35
- python md_cg/datapath.py --set-root D:/x/data # 写入 paths.json(用户可改)
36
- python md_cg/datapath.py --migrate-legacy # 把旧包内数据复制到用户级根
37
- """
38
- from __future__ import annotations
39
-
40
- import json
41
- import os
42
- import shutil
43
-
44
- ENV_DATA_ROOT = "MDCG_DATA_ROOT"
45
- ENV_MDCG_ROOT = "MDCG_ROOT"
46
- ENV_STATE_ROOT = "MDCG_STATE_ROOT"
47
-
48
-
49
- # 生效条件:无入参,恒返回本文件 __file__ 绝对路径上溯两级得到的插件仓根目录。
50
- def plugin_root() -> str:
51
- """插件仓根目录(本文件位于 <root>/md_cg/datapath.py)。"""
52
- return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
53
-
54
-
55
- # 生效条件:无入参;ENV_STATE_ROOT 环境变量为非空真值时返回其 abspath,为空串/未设时若 DSH_HOME 为非空真值则返回其 abspath 下 ".dsh-memory",两者皆无则返回 ~/.dsh/.dsh-memory 的 abspath。
56
- def state_root() -> str:
57
- """用户级状态根(**插件包目录之外**):路径配置与默认数据根的落点。
58
-
59
- `DSH_HOME` **未必**由宿主注入(本仓 `dsh/dsh-web-start.bat` 自行 set),
60
- 故家目录兜底;`~/.dsh` 是本插件既有约定(node 侧调试日志同址)。
61
- """
62
- env = os.environ.get(ENV_STATE_ROOT)
63
- if env:
64
- return os.path.abspath(env)
65
- dsh_home = os.environ.get("DSH_HOME")
66
- if dsh_home:
67
- return os.path.join(os.path.abspath(dsh_home), ".dsh-memory")
68
- return os.path.join(os.path.expanduser("~"), ".dsh", ".dsh-memory")
69
-
70
-
71
- # 生效条件:无入参,恒返回 plugin_root() 下 "data" 的拼接路径(旧版落点,更新时被整目录替换,只作兼容读与迁移源)。
72
- def legacy_data_root() -> str:
73
- """旧版落点(插件包内 `data/`)——更新时会被整个替换。"""
74
- return os.path.join(plugin_root(), "data")
75
-
76
-
77
- # 生效条件:无入参,恒返回 legacy_data_root() 下 "paths.json" 的拼接路径。
78
- def legacy_paths_file() -> str:
79
- """旧版路径配置文件位置(包内,兼容读)。"""
80
- return os.path.join(legacy_data_root(), "paths.json")
81
-
82
-
83
- # 生效条件:两个路径参数经 normpath+normcase 规范化后相等时返回 True,否则 False(Windows 大小写不敏感)。
84
- def _same_path(a: str, b: str) -> bool:
85
- return (os.path.normcase(os.path.normpath(a)) ==
86
- os.path.normcase(os.path.normpath(b)))
87
-
88
-
89
- # 生效条件:无入参;<state_root()>/paths.json 存在(isfile)时返回之,不存在而 legacy_paths_file() 存在时返回旧件,两者皆不存在时返回新位置路径(首次写入时创建),不受 data_root() 取值影响。
90
- def paths_file() -> str:
91
- """用户可编辑的路径配置文件——用户级优先,旧包内位置兼容读。
92
-
93
- 新位置:`<用户级状态根>/paths.json`(随包更新不丢)。
94
- 旧位置:`<插件仓>/data/paths.json`(旧版约定,仅当新位置不存在时读它,
95
- 已存在新件则新件说了算——不做自动复制,避免旧件后续编辑静默失效)。
96
- """
97
- current = os.path.join(state_root(), "paths.json")
98
- if os.path.isfile(current):
99
- return current
100
- legacy = legacy_paths_file()
101
- return legacy if os.path.isfile(legacy) else current
102
-
103
-
104
- # 生效条件:无入参;paths_file() 的所在目录与 state_root() 同路径时返回 "user",否则返回 "legacy"(兼容读旧包内位置)。
105
- def paths_file_source() -> str:
106
- """生效中的 paths.json 位置来源:`user` / `legacy`。"""
107
- return "user" if _same_path(os.path.dirname(paths_file()),
108
- state_root()) else "legacy"
109
-
110
-
111
- # 生效条件:无入参;paths_file() 的 JSON 顶层为 dict 时返回该 dict,JSON 解析或读取抛任何异常、或顶层非 dict 时返回 {}。
112
- def _user_paths() -> dict:
113
- try:
114
- with open(paths_file(), encoding="utf-8") as f:
115
- d = json.load(f)
116
- return d if isinstance(d, dict) else {}
117
- except Exception:
118
- return {}
119
-
120
-
121
- # 生效条件:无入参,恒返回 state_root() 下 "data" 的拼接路径(插件包目录之外,更新不触碰)。
122
- def default_data_root() -> str:
123
- """默认数据根 = 用户级状态根下 data/。"""
124
- return os.path.join(state_root(), "data")
125
-
126
-
127
- # 生效条件:p 为目录且其下无任何条目时返回 True,p 不存在/是文件/OSError 时返回 False。
128
- def _is_empty_dir(p: str) -> bool:
129
- try:
130
- return os.path.isdir(p) and not os.listdir(p)
131
- except OSError:
132
- return False
133
-
134
-
135
- # 生效条件:无入参;data_root() 与 default_data_root() 不同路径时返回 {ran:False, reason:"数据根为显式配置(env/paths.json),不迁移", from, to, copied:0, failed:[]};相同则继续——legacy_data_root() 非目录时返回 reason="旧位置不存在(多为更新时已随包目录被替换)",其下无条目(除 paths.json 外)时返回 reason="旧位置无数据",否则把其余顶层条目逐个复制到 default_data_root() 下(目标条目已存在且非空目录则跳过;只复制不删除、不覆盖;单个条目异常记入 failed 不抛),返回 ran=(copied>0)、copied、failed 与失败时的 reason("目标不可写" 或 "新位置已有数据,未覆盖")。
136
- def migrate_legacy_data() -> dict:
137
- """一次性迁移:把旧版包内 `data/` 的数据面复制到用户级默认数据根。
138
-
139
- 触发条件(三条同时满足,缺一不动):
140
- ① 数据根未被显式配置(env `MDCG_DATA_ROOT` / paths.json 的 "data_root"
141
- 都未设,即 data_root() 恰为默认值)——显式配置是用户的决定;
142
- ② 旧位置存在;③ 目标顶层条目不存在或为空目录(不覆盖、不合并)。
143
-
144
- `paths.json` 不复制(配置走 paths_file() 兼容读,复制会制造两说)。
145
- 任何异常逐项吞掉并记入 `failed`——迁移是增益,不该成为启动失败源。
146
-
147
- 边界(如实):pnpm 更新是**先替换包目录再启动新代码**,旧数据在升级瞬间即
148
- 已消失,本函数只能接手「旧位置那时仍在」的情形;已在升级中丢掉的数据无法
149
- 由此恢复——发布说明须提示 0.4.8 及更早用户升级前备份包内 `data/`。
150
- """
151
- frm = legacy_data_root()
152
- to = default_data_root()
153
- out = {"ran": False, "reason": "", "from": frm, "to": to,
154
- "copied": 0, "failed": []}
155
- if not _same_path(data_root(), to):
156
- out["reason"] = "数据根为显式配置(env/paths.json),不迁移"
157
- return out
158
- if not os.path.isdir(frm):
159
- out["reason"] = "旧位置不存在(多为更新时已随包目录被替换)"
160
- return out
161
- try:
162
- names = [n for n in os.listdir(frm) if n != "paths.json"]
163
- except OSError:
164
- out["reason"] = "旧位置不可读"
165
- return out
166
- if not names:
167
- out["reason"] = "旧位置无数据"
168
- return out
169
- for name in names:
170
- src = os.path.join(frm, name)
171
- dst = os.path.join(to, name)
172
- # 新位置已有实质内容 → 跳过(宁可少搬,不可覆盖)
173
- if os.path.exists(dst) and not _is_empty_dir(dst):
174
- continue
175
- try:
176
- os.makedirs(to, exist_ok=True)
177
- if os.path.isdir(src):
178
- shutil.copytree(src, dst, dirs_exist_ok=True)
179
- else:
180
- shutil.copy2(src, dst)
181
- out["copied"] += 1
182
- except Exception: # noqa: BLE001 —— 迁移不得成为启动失败源
183
- out["failed"].append(name)
184
- out["ran"] = out["copied"] > 0
185
- if not out["ran"]:
186
- out["reason"] = ("目标不可写" if out["failed"]
187
- else "新位置已有数据,未覆盖")
188
- return out
189
-
190
-
191
- # 生效条件:无入参;ENV_DATA_ROOT 环境变量为非空真值时返回其 abspath,为空串/未设时若 paths.json 的 "data_root" 为真值则按其是否为绝对路径决定直接 abspath 还是拼 plugin_root() 后 abspath,该键缺失或为假值时回落 default_data_root()。
192
- def data_root() -> str:
193
- """数据根(记忆/账本/运行态的父目录)。"""
194
- env = os.environ.get(ENV_DATA_ROOT)
195
- if env:
196
- return os.path.abspath(env)
197
- cfg = _user_paths().get("data_root")
198
- if cfg:
199
- return os.path.abspath(cfg) if os.path.isabs(cfg) else \
200
- os.path.abspath(os.path.join(plugin_root(), cfg))
201
- return default_data_root()
202
-
203
-
204
- # 生效条件:无入参;ENV_MDCG_ROOT 环境变量为非空真值时返回其 abspath,为空串/未设时若 paths.json 的 "root" 为真值则按其是否为绝对路径决定直接 abspath 还是拼 plugin_root() 后 abspath,该键缺失或为假值时返回 data_root() 下 "mdcg" 的拼接路径。
205
- def mdcg_root() -> str:
206
- """认知图(记忆唯一真源)根目录。"""
207
- env = os.environ.get(ENV_MDCG_ROOT)
208
- if env:
209
- return os.path.abspath(env)
210
- cfg = _user_paths().get("root")
211
- if cfg:
212
- return os.path.abspath(cfg) if os.path.isabs(cfg) else \
213
- os.path.abspath(os.path.join(plugin_root(), cfg))
214
- return os.path.join(data_root(), "mdcg")
215
-
216
-
217
- # 生效条件:parts 非空时路径为 data_root() 与各 part 的 join,parts 为空时路径即 data_root();create 为真值(默认 True)时对该路径 makedirs(exist_ok=True),create 为假值时只返回路径不建目录。
218
- def state_dir(*parts: str, create: bool = True) -> str:
219
- """运行态子目录(日志/队列/草稿…),默认挂在数据根下。"""
220
- p = os.path.join(data_root(), *parts) if parts else data_root()
221
- if create:
222
- os.makedirs(p, exist_ok=True)
223
- return p
224
-
225
-
226
- # 生效条件:name 依次拼成 data_root()/name、plugin_root()/name、dirname(plugin_root())/[私有归档根]/_archive/ctp-aeis-data/name 三个候选,仅保留其中 os.path.isfile 为真的项并按此顺序返回列表。
227
- def archive_root() -> str | None:
228
- """私有侧归档根(公开仓不含私有库名):本机配置提供,未配置则没有该候选。
229
-
230
- 取值顺序:环境变量 MDCG_ARCHIVE_ROOT -> 插件仓同级的 .mdcg_archive_root 文件内容。
231
- """
232
- value = os.environ.get("MDCG_ARCHIVE_ROOT", "").strip()
233
- if value:
234
- return value
235
- marker = os.path.join(os.path.dirname(plugin_root()), ".mdcg_archive_root")
236
- if os.path.isfile(marker):
237
- try:
238
- with open(marker, "r", encoding="utf-8") as fh:
239
- text = fh.read().strip()
240
- except OSError:
241
- return None
242
- return text or None
243
- return None
244
-
245
-
246
- # 生效条件:给定 name 时按 data_root()/name、plugin_root()/name 顺序拼接候选,仅当 archive_root() 不为 None 才追加其下 _archive/ctp-aeis-data/name,最终只返回其中 os.path.isfile 为真的路径(其余被过滤掉),故全都不满足时返回空列表。
247
- def legacy_candidates(name: str) -> list:
248
- """历史位置候选(只读兼容:三仓分离前的校验缓存等)。
249
-
250
- 仅用于「读旧件」,顺序:数据根 → 插件仓根 → 归档区。
251
- """
252
- out = [os.path.join(data_root(), name),
253
- os.path.join(plugin_root(), name)]
254
- archive = archive_root()
255
- if archive is not None:
256
- out.append(os.path.join(archive, "_archive", "ctp-aeis-data", name))
257
- return [p for p in out if os.path.isfile(p)]
258
-
259
-
260
- # 生效条件:name 对应的 legacy_candidates(name) 列表非空时返回其首个元素,为空列表时返回 None。
261
- def find_existing(name: str) -> str | None:
262
- """在数据根/插件仓/归档区中找已存在的同名文件,找不到返回 None。"""
263
- hits = legacy_candidates(name)
264
- return hits[0] if hits else None
265
-
266
-
267
- # 生效条件:无入参;恒返回含 plugin_root/state_root/data_root/mdcg_root/default_data_root/is_default/source/paths_file/paths_file_source/legacy_data_root/legacy_data_exists/data_root_exists/mdcg_root_exists 的字典,其中 source 按 ENV_DATA_ROOT 非空取 "env:MDCG_DATA_ROOT" → 否则 ENV_MDCG_ROOT 非空取 "env:MDCG_MDCG_ROOT" → 否则 _user_paths() 为非空 dict 取 "paths.json(用户级)" 或 "paths.json(兼容读旧包内位置)"(按 paths_file_source())→ 否则取 "default(用户级状态根 data/)"。
268
- def describe() -> dict:
269
- """当前解析结果的完整快照(供心跳/日志留痕)。"""
270
- dr = data_root()
271
- mr = mdcg_root()
272
- pf_src = paths_file_source()
273
- return {
274
- "plugin_root": plugin_root(),
275
- "state_root": state_root(),
276
- "data_root": dr,
277
- "mdcg_root": mr,
278
- "default_data_root": default_data_root(),
279
- "is_default": _same_path(dr, default_data_root()),
280
- "source": ("env:" + ENV_DATA_ROOT if os.environ.get(ENV_DATA_ROOT)
281
- else ("env:" + ENV_MDCG_ROOT if os.environ.get(ENV_MDCG_ROOT)
282
- else (("paths.json(用户级)" if pf_src == "user"
283
- else "paths.json(兼容读旧包内位置)")
284
- if _user_paths()
285
- else "default(用户级状态根 data/)"))),
286
- "paths_file": paths_file(),
287
- "paths_file_source": pf_src,
288
- "legacy_data_root": legacy_data_root(),
289
- "legacy_data_exists": os.path.isdir(legacy_data_root()),
290
- "data_root_exists": os.path.isdir(dr),
291
- "mdcg_root_exists": os.path.isdir(mr),
292
- }
293
-
294
-
295
- # 生效条件:path 为传入字符串(写入前反斜杠替换为 "/"),key 默认 "data_root"(传入时写入该键名);先确保 <state_root()> 存在,读取 _user_paths()(可能来自旧包内位置的兼容读,异常时为 {},故旧配置在首次改写时被带到新位置并从此由新件说了算)并补 "_comment",再以该内容覆写 <state_root()>/paths.json 并返回该路径。
296
- def set_user_root(path: str, key: str = "data_root") -> str:
297
- """把用户选择的路径写入**用户级** paths.json(不存在则创建)。返回文件路径。
298
-
299
- 永远写新位置(不写旧包内那件)——旧位置会随包更新被删除,写进去等于
300
- 用户设置迟早丢失;旧件的既有取值经 _user_paths() 带入新件,不丢配置。
301
- """
302
- pf = os.path.join(state_root(), "paths.json")
303
- os.makedirs(os.path.dirname(pf), exist_ok=True)
304
- d = _user_paths()
305
- d.setdefault("_comment",
306
- "灵枢记忆写入路径(用户可改)。删掉本文件即回落到用户级默认数据根"
307
- "(~/.dsh/.dsh-memory/data)。")
308
- d[key] = path.replace("\\", "/")
309
- with open(pf, "w", encoding="utf-8") as f:
310
- json.dump(d, f, ensure_ascii=False, indent=2)
311
- return pf
312
-
313
-
314
- if __name__ == "__main__":
315
- import argparse
316
- import sys
317
-
318
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
319
- ap = argparse.ArgumentParser(description="灵枢数据根解析与设置")
320
- ap.add_argument("--set-root", metavar="PATH",
321
- help="把数据根写入 <用户级状态根>/paths.json(用户可改)")
322
- ap.add_argument("--set-mdcg-root", metavar="PATH",
323
- help="把认知图根写入 <用户级状态根>/paths.json")
324
- ap.add_argument("--migrate-legacy", action="store_true",
325
- help="把旧版包内 data/ 的数据面复制到用户级默认数据根"
326
- "(只复制不删除;默认数据根被显式配置时不动作)")
327
- a = ap.parse_args()
328
- if a.set_root:
329
- print("written:", set_user_root(a.set_root, "data_root"))
330
- if a.set_mdcg_root:
331
- print("written:", set_user_root(a.set_mdcg_root, "root"))
332
- if a.migrate_legacy:
333
- print("migrate:", json.dumps(migrate_legacy_data(),
334
- ensure_ascii=False))
335
- print(json.dumps(describe(), ensure_ascii=False, indent=2))
1
+ # -*- coding: utf-8 -*-
2
+ """datapath.py · 灵枢数据根解析(记忆写入路径可配置)
3
+
4
+ 与 node 侧 `src/lib/datapath.ts` **同口径**——两侧读同一份
5
+ `<用户级状态根>/paths.json`(旧 `<插件仓>/data/paths.json` 兼容读)。
6
+
7
+ 解析优先级(高 → 低):
8
+ 1. 环境变量 `MDCG_DATA_ROOT`(数据根)/ `MDCG_ROOT`(认知图根)
9
+ 2. 用户可编辑的路径文件 `paths.json` 的 "data_root" / "root"
10
+ (位置:`<用户级状态根>/paths.json`;旧 `<插件仓>/data/paths.json` 兼容读)
11
+ 3. 默认:`<用户级状态根>/data`(**插件包目录之外**,与进程 cwd 解耦)
12
+
13
+ 为什么默认**不**锚定「插件仓自身 data/」(issue #18 相邻问题,数据丢失级):
14
+ DSH 插件按 hoisted 布局装在 `<profile>/node_modules/<pkg>`,`pnpm` 更新该包会
15
+ **整个替换包目录**——运行时数据落在包内时,每次更新成功即连目录一起删掉
16
+ (实机实证:`data/` 54 文件 → 0,46 条记忆节点靠人工备份回填);`paths.json`
17
+ 同址,用户配置一并丢失。故默认数据根与配置文件一律落**用户级状态根**
18
+ (见 state_root()),与包目录彻底解耦。
19
+
20
+ 为什么默认不锚定 `data/mdcg` 相对路径:
21
+ 相对路径随进程 cwd 漂移——node 侧插件与 python 侧脚本 cwd 不同
22
+ (历史事故:DSH 进程 cwd 在私有库目录时,记忆真源落到 `[私有库]/data/mdcg`,
23
+ 与插件仓内的 `data/` 分裂成两处)。
24
+
25
+ 设计边界:
26
+ - 本模块只决定「**新写入去哪**」。唯一的搬运是 `migrate_legacy_data()`:
27
+ 默认数据根生效且旧包内位置仍有内容时,一次性**复制**(不删除、不覆盖)
28
+ 到用户级位置——见该函数注释。
29
+ - 纯标准库、无包内相对导入——可被 `sys.path` 以顶层模块方式加载,
30
+ 绕开 `md_cg/__init__.py` 的重依赖。
31
+
32
+ 用法:
33
+ from datapath import data_root, mdcg_root, state_dir # scripts/ 内
34
+ python md_cg/datapath.py # 打印当前解析结果
35
+ python md_cg/datapath.py --set-root D:/x/data # 写入 paths.json(用户可改)
36
+ python md_cg/datapath.py --migrate-legacy # 把旧包内数据复制到用户级根
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import os
42
+ import shutil
43
+
44
+ ENV_DATA_ROOT = "MDCG_DATA_ROOT"
45
+ ENV_MDCG_ROOT = "MDCG_ROOT"
46
+ ENV_STATE_ROOT = "MDCG_STATE_ROOT"
47
+ #: 辅助存储根(密钥/令牌/信任/理论/心跳)——与「记忆真源」**有意分离**:
48
+ #: 身份与信任面不随认知图迁移(换库不该换身份),故默认仍是历史的 `~/.mdcg`。
49
+ ENV_AUX_ROOT = "MDCG_AUX_ROOT"
50
+ DEFAULT_AUX_DIRNAME = ".mdcg"
51
+
52
+
53
+ # 生效条件:p 按仓库既有口径 expanduser+abspath 归一;平台为 Windows(os.name=="nt")
54
+ # 且归一结果以设备命名空间前缀("\\\\.\\")开头时抛 ValueError——保留设备名末段
55
+ # (aux/con/nul/prn/com1-9/lpt1-9 等)会被 GetFullPathNameW 吞成设备路径
56
+ # (例 D:\sandbox\aux → \\.\aux),目录语义静默丢失,密钥/令牌/数据覆盖键随之
57
+ # 静默失联;非 Windows 平台该形态是合法目录名字面量,不判定。归一是纯字符串
58
+ # 操作不触盘,路径无需存在即可复现。env_key 传覆盖键名(env 变量名或 paths.json
59
+ # 键),仅用于错误消息定位误配来源;为空时消息以「该路径」指代。
60
+ def _abs_host_path(p: str, env_key: str = "") -> str:
61
+ r = os.path.abspath(os.path.expanduser(p))
62
+ if os.name == "nt" and r.startswith("\\\\.\\"):
63
+ who = env_key or "该路径"
64
+ raise ValueError(
65
+ f"{who} 归一后解析为 Windows 设备命名空间路径 {r!r}:末段是 Windows "
66
+ "保留设备名(aux/con/nul/prn/com1-9/lpt1-9 等),GetFullPathNameW 会把"
67
+ "整个目录吞成设备路径,目录语义静默丢失(落在此处的密钥/令牌/数据会"
68
+ f"静默失联);请把 {who} 改指向末段不含保留设备名的普通目录。")
69
+ return r
70
+
71
+
72
+ # 生效条件:无入参,恒返回本文件 __file__ 绝对路径上溯两级得到的插件仓根目录。
73
+ def plugin_root() -> str:
74
+ """插件仓根目录(本文件位于 <root>/md_cg/datapath.py)。"""
75
+ return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
76
+
77
+
78
+ # 生效条件:无入参;ENV_STATE_ROOT 环境变量为非空真值时经 _abs_host_path 归一返回(Windows 上归一为设备命名空间形态——保留设备名末段——时抛 ValueError),为空串/未设时若 DSH_HOME 为非空真值则返回其 abspath 下 ".dsh-memory",两者皆无则返回 ~/.dsh/.dsh-memory 的 abspath。
79
+ def state_root() -> str:
80
+ """用户级状态根(**插件包目录之外**):路径配置与默认数据根的落点。
81
+
82
+ `DSH_HOME` **未必**由宿主注入(本仓 `dsh/dsh-web-start.bat` 自行 set),
83
+ 故家目录兜底;`~/.dsh` 是本插件既有约定(node 侧调试日志同址)。
84
+ """
85
+ env = os.environ.get(ENV_STATE_ROOT)
86
+ if env:
87
+ return _abs_host_path(env, ENV_STATE_ROOT)
88
+ dsh_home = os.environ.get("DSH_HOME")
89
+ if dsh_home:
90
+ return os.path.join(os.path.abspath(dsh_home), ".dsh-memory")
91
+ return os.path.join(os.path.expanduser("~"), ".dsh", ".dsh-memory")
92
+
93
+
94
+ # 生效条件:无入参,恒返回 plugin_root() 下 "data" 的拼接路径(旧版落点,更新时被整目录替换,只作兼容读与迁移源)。
95
+ def legacy_data_root() -> str:
96
+ """旧版落点(插件包内 `data/`)——更新时会被整个替换。"""
97
+ return os.path.join(plugin_root(), "data")
98
+
99
+
100
+ # 生效条件:无入参,恒返回 legacy_data_root() 下 "paths.json" 的拼接路径。
101
+ def legacy_paths_file() -> str:
102
+ """旧版路径配置文件位置(包内,兼容读)。"""
103
+ return os.path.join(legacy_data_root(), "paths.json")
104
+
105
+
106
+ # 生效条件:两个路径参数经 normpath+normcase 规范化后相等时返回 True,否则 False(Windows 大小写不敏感)。
107
+ def _same_path(a: str, b: str) -> bool:
108
+ return (os.path.normcase(os.path.normpath(a)) ==
109
+ os.path.normcase(os.path.normpath(b)))
110
+
111
+
112
+ # 生效条件:无入参;<state_root()>/paths.json 存在(isfile)时返回之,不存在而 legacy_paths_file() 存在时返回旧件,两者皆不存在时返回新位置路径(首次写入时创建),不受 data_root() 取值影响。
113
+ def paths_file() -> str:
114
+ """用户可编辑的路径配置文件——用户级优先,旧包内位置兼容读。
115
+
116
+ 新位置:`<用户级状态根>/paths.json`(随包更新不丢)。
117
+ 旧位置:`<插件仓>/data/paths.json`(旧版约定,仅当新位置不存在时读它,
118
+ 已存在新件则新件说了算——不做自动复制,避免旧件后续编辑静默失效)。
119
+ """
120
+ current = os.path.join(state_root(), "paths.json")
121
+ if os.path.isfile(current):
122
+ return current
123
+ legacy = legacy_paths_file()
124
+ return legacy if os.path.isfile(legacy) else current
125
+
126
+
127
+ # 生效条件:无入参;paths_file() 的所在目录与 state_root() 同路径时返回 "user",否则返回 "legacy"(兼容读旧包内位置)。
128
+ def paths_file_source() -> str:
129
+ """生效中的 paths.json 位置来源:`user` / `legacy`。"""
130
+ return "user" if _same_path(os.path.dirname(paths_file()),
131
+ state_root()) else "legacy"
132
+
133
+
134
+ # 生效条件:无入参;paths_file() 的 JSON 顶层为 dict 时返回该 dict,JSON 解析或读取抛任何异常、或顶层非 dict 时返回 {}。
135
+ def _user_paths() -> dict:
136
+ try:
137
+ with open(paths_file(), encoding="utf-8") as f:
138
+ d = json.load(f)
139
+ return d if isinstance(d, dict) else {}
140
+ except Exception:
141
+ return {}
142
+
143
+
144
+ # 生效条件:无入参,恒返回 state_root() 下 "data" 的拼接路径(插件包目录之外,更新不触碰)。
145
+ def default_data_root() -> str:
146
+ """默认数据根 = 用户级状态根下 data/。"""
147
+ return os.path.join(state_root(), "data")
148
+
149
+
150
+ # 生效条件:p 为目录且其下无任何条目时返回 True,p 不存在/是文件/OSError 时返回 False。
151
+ def _is_empty_dir(p: str) -> bool:
152
+ try:
153
+ return os.path.isdir(p) and not os.listdir(p)
154
+ except OSError:
155
+ return False
156
+
157
+
158
+ # 生效条件:无入参;data_root() 与 default_data_root() 不同路径时返回 {ran:False, reason:"数据根为显式配置(env/paths.json),不迁移", from, to, copied:0, failed:[]};相同则继续——legacy_data_root() 非目录时返回 reason="旧位置不存在(多为更新时已随包目录被替换)",其下无条目(除 paths.json 外)时返回 reason="旧位置无数据",否则把其余顶层条目逐个复制到 default_data_root() 下(目标条目已存在且非空目录则跳过;只复制不删除、不覆盖;单个条目异常记入 failed 不抛),返回 ran=(copied>0)、copied、failed 与失败时的 reason("目标不可写" 或 "新位置已有数据,未覆盖")。
159
+ def migrate_legacy_data() -> dict:
160
+ """一次性迁移:把旧版包内 `data/` 的数据面复制到用户级默认数据根。
161
+
162
+ 触发条件(三条同时满足,缺一不动):
163
+ ① 数据根未被显式配置(env `MDCG_DATA_ROOT` / paths.json 的 "data_root"
164
+ 都未设,即 data_root() 恰为默认值)——显式配置是用户的决定;
165
+ ② 旧位置存在;③ 目标顶层条目不存在或为空目录(不覆盖、不合并)。
166
+
167
+ `paths.json` 不复制(配置走 paths_file() 兼容读,复制会制造两说)。
168
+ 任何异常逐项吞掉并记入 `failed`——迁移是增益,不该成为启动失败源。
169
+
170
+ 边界(如实):pnpm 更新是**先替换包目录再启动新代码**,旧数据在升级瞬间即
171
+ 已消失,本函数只能接手「旧位置那时仍在」的情形;已在升级中丢掉的数据无法
172
+ 由此恢复——发布说明须提示 0.4.8 及更早用户升级前备份包内 `data/`。
173
+ """
174
+ frm = legacy_data_root()
175
+ to = default_data_root()
176
+ out = {"ran": False, "reason": "", "from": frm, "to": to,
177
+ "copied": 0, "failed": []}
178
+ if not _same_path(data_root(), to):
179
+ out["reason"] = "数据根为显式配置(env/paths.json),不迁移"
180
+ return out
181
+ if not os.path.isdir(frm):
182
+ out["reason"] = "旧位置不存在(多为更新时已随包目录被替换)"
183
+ return out
184
+ try:
185
+ names = [n for n in os.listdir(frm) if n != "paths.json"]
186
+ except OSError:
187
+ out["reason"] = "旧位置不可读"
188
+ return out
189
+ if not names:
190
+ out["reason"] = "旧位置无数据"
191
+ return out
192
+ for name in names:
193
+ src = os.path.join(frm, name)
194
+ dst = os.path.join(to, name)
195
+ # 新位置已有实质内容 → 跳过(宁可少搬,不可覆盖)
196
+ if os.path.exists(dst) and not _is_empty_dir(dst):
197
+ continue
198
+ try:
199
+ os.makedirs(to, exist_ok=True)
200
+ if os.path.isdir(src):
201
+ shutil.copytree(src, dst, dirs_exist_ok=True)
202
+ else:
203
+ shutil.copy2(src, dst)
204
+ out["copied"] += 1
205
+ except Exception: # noqa: BLE001 —— 迁移不得成为启动失败源
206
+ out["failed"].append(name)
207
+ out["ran"] = out["copied"] > 0
208
+ if not out["ran"]:
209
+ out["reason"] = ("目标不可写" if out["failed"]
210
+ else "新位置已有数据,未覆盖")
211
+ return out
212
+
213
+
214
+ # 生效条件:无入参;ENV_DATA_ROOT 环境变量为非空真值时经 _abs_host_path 归一返回(Windows 保留设备名末段抛 ValueError,下同),为空串/未设时若 paths.json 的 "data_root" 为真值则按其是否为绝对路径决定直接归一还是拼 plugin_root() 后归一,该键缺失或为假值时回落 default_data_root()。
215
+ def data_root() -> str:
216
+ """数据根(记忆/账本/运行态的父目录)。"""
217
+ env = os.environ.get(ENV_DATA_ROOT)
218
+ if env:
219
+ return _abs_host_path(env, ENV_DATA_ROOT)
220
+ cfg = _user_paths().get("data_root")
221
+ if cfg:
222
+ return _abs_host_path(cfg, "paths.json 的 data_root") \
223
+ if os.path.isabs(cfg) else \
224
+ _abs_host_path(os.path.join(plugin_root(), cfg),
225
+ "paths.json 的 data_root")
226
+ return default_data_root()
227
+
228
+
229
+ # 生效条件:无入参;ENV_MDCG_ROOT 环境变量为非空真值时经 _abs_host_path 归一返回(Windows 保留设备名末段抛 ValueError,下同),为空串/未设时若 paths.json 的 "root" 为真值则按其是否为绝对路径决定直接归一还是拼 plugin_root() 后归一,该键缺失或为假值时返回 data_root() 下 "mdcg" 的拼接路径。
230
+ def mdcg_root() -> str:
231
+ """认知图(记忆唯一真源)根目录。"""
232
+ env = os.environ.get(ENV_MDCG_ROOT)
233
+ if env:
234
+ return _abs_host_path(env, ENV_MDCG_ROOT)
235
+ cfg = _user_paths().get("root")
236
+ if cfg:
237
+ return _abs_host_path(cfg, "paths.json 的 root") \
238
+ if os.path.isabs(cfg) else \
239
+ _abs_host_path(os.path.join(plugin_root(), cfg),
240
+ "paths.json 的 root")
241
+ return os.path.join(data_root(), "mdcg")
242
+
243
+
244
+ # 生效条件:parts 非空时路径为 data_root() 与各 part 的 join,parts 为空时路径即 data_root();create 为真值(默认 True)时对该路径 makedirs(exist_ok=True),create 为假值时只返回路径不建目录。
245
+ def state_dir(*parts: str, create: bool = True) -> str:
246
+ """运行态子目录(日志/队列/草稿…),默认挂在数据根下。"""
247
+ p = os.path.join(data_root(), *parts) if parts else data_root()
248
+ if create:
249
+ os.makedirs(p, exist_ok=True)
250
+ return p
251
+
252
+
253
+ # 生效条件:无入参;ENV_AUX_ROOT 为非空真值时经 _abs_host_path 归一返回(Windows 保留设备名末段抛 ValueError),否则返回 ~/.mdcg 的拼接路径(历史默认,逐字不变)。
254
+ def aux_root() -> str:
255
+ """辅助存储根(密钥/令牌/信任/理论/心跳)所在目录。
256
+
257
+ 历史默认 `~/.mdcg`,**逐字保留**(改默认会让存量密钥/令牌失联:私有内容
258
+ 会解不开、已签令牌会失效)。设 `MDCG_AUX_ROOT` 可把这些面整体搬到别处
259
+ ——沙箱/多用户/与记忆真源同处的场景需要它。
260
+
261
+ 与 `mdcg_root()`(记忆真源)分离是**有意设计**:身份与信任不随认知图迁移。
262
+ 代价是两者可能分居两处,故 `mdcg.mcp_server` 启动时把两者一起打日志
263
+ (不许静默分裂);本函数即该分裂面的唯一解析入口。
264
+ """
265
+ env = (os.environ.get(ENV_AUX_ROOT) or "").strip()
266
+ if env:
267
+ return _abs_host_path(env, ENV_AUX_ROOT)
268
+ return os.path.join(os.path.expanduser("~"), DEFAULT_AUX_DIRNAME)
269
+
270
+
271
+ # 生效条件:parts 非空时返回 aux_root() 与各 part 的 join,parts 为空时即 aux_root();create 为真值时 makedirs(exist_ok=True),create 为假值时只返回路径。
272
+ def aux_path(*parts: str, create: bool = False) -> str:
273
+ """辅助存储根下的路径(可选建目录)。"""
274
+ p = os.path.join(aux_root(), *parts) if parts else aux_root()
275
+ if create:
276
+ os.makedirs(p, exist_ok=True)
277
+ return p
278
+
279
+
280
+ # 生效条件:name 依次拼成 data_root()/name、plugin_root()/name、dirname(plugin_root())/[私有归档根]/_archive/ctp-aeis-data/name 三个候选,仅保留其中 os.path.isfile 为真的项并按此顺序返回列表。
281
+ def archive_root() -> str | None:
282
+ """私有侧归档根(公开仓不含私有库名):本机配置提供,未配置则没有该候选。
283
+
284
+ 取值顺序:环境变量 MDCG_ARCHIVE_ROOT -> 插件仓同级的 .mdcg_archive_root 文件内容。
285
+ """
286
+ value = os.environ.get("MDCG_ARCHIVE_ROOT", "").strip()
287
+ if value:
288
+ return value
289
+ marker = os.path.join(os.path.dirname(plugin_root()), ".mdcg_archive_root")
290
+ if os.path.isfile(marker):
291
+ try:
292
+ with open(marker, "r", encoding="utf-8") as fh:
293
+ text = fh.read().strip()
294
+ except OSError:
295
+ return None
296
+ return text or None
297
+ return None
298
+
299
+
300
+ # 生效条件:给定 name 时按 data_root()/name、plugin_root()/name 顺序拼接候选,仅当 archive_root() 不为 None 才追加其下 _archive/ctp-aeis-data/name,最终只返回其中 os.path.isfile 为真的路径(其余被过滤掉),故全都不满足时返回空列表。
301
+ def legacy_candidates(name: str) -> list:
302
+ """历史位置候选(只读兼容:三仓分离前的校验缓存等)。
303
+
304
+ 仅用于「读旧件」,顺序:数据根 → 插件仓根 → 归档区。
305
+ """
306
+ out = [os.path.join(data_root(), name),
307
+ os.path.join(plugin_root(), name)]
308
+ archive = archive_root()
309
+ if archive is not None:
310
+ out.append(os.path.join(archive, "_archive", "ctp-aeis-data", name))
311
+ return [p for p in out if os.path.isfile(p)]
312
+
313
+
314
+ # 生效条件:name 对应的 legacy_candidates(name) 列表非空时返回其首个元素,为空列表时返回 None。
315
+ def find_existing(name: str) -> str | None:
316
+ """在数据根/插件仓/归档区中找已存在的同名文件,找不到返回 None。"""
317
+ hits = legacy_candidates(name)
318
+ return hits[0] if hits else None
319
+
320
+
321
+ # 生效条件:无入参;恒返回含 plugin_root/state_root/data_root/mdcg_root/default_data_root/is_default/source/paths_file/paths_file_source/legacy_data_root/legacy_data_exists/data_root_exists/mdcg_root_exists 的字典,其中 source 按 ENV_DATA_ROOT 非空取 "env:MDCG_DATA_ROOT" → 否则 ENV_MDCG_ROOT 非空取 "env:MDCG_MDCG_ROOT" → 否则 _user_paths() 为非空 dict 取 "paths.json(用户级)" 或 "paths.json(兼容读旧包内位置)"(按 paths_file_source())→ 否则取 "default(用户级状态根 data/)"。
322
+ def describe() -> dict:
323
+ """当前解析结果的完整快照(供心跳/日志留痕)。"""
324
+ dr = data_root()
325
+ mr = mdcg_root()
326
+ pf_src = paths_file_source()
327
+ return {
328
+ "plugin_root": plugin_root(),
329
+ "state_root": state_root(),
330
+ "data_root": dr,
331
+ "mdcg_root": mr,
332
+ "default_data_root": default_data_root(),
333
+ "is_default": _same_path(dr, default_data_root()),
334
+ "source": ("env:" + ENV_DATA_ROOT if os.environ.get(ENV_DATA_ROOT)
335
+ else ("env:" + ENV_MDCG_ROOT if os.environ.get(ENV_MDCG_ROOT)
336
+ else (("paths.json(用户级)" if pf_src == "user"
337
+ else "paths.json(兼容读旧包内位置)")
338
+ if _user_paths()
339
+ else "default(用户级状态根 data/)"))),
340
+ "paths_file": paths_file(),
341
+ "paths_file_source": pf_src,
342
+ "legacy_data_root": legacy_data_root(),
343
+ "legacy_data_exists": os.path.isdir(legacy_data_root()),
344
+ "data_root_exists": os.path.isdir(dr),
345
+ "mdcg_root_exists": os.path.isdir(mr),
346
+ }
347
+
348
+
349
+ # 生效条件:path 为传入字符串(写入前反斜杠替换为 "/"),key 默认 "data_root"(传入时写入该键名);先确保 <state_root()> 存在,读取 _user_paths()(可能来自旧包内位置的兼容读,异常时为 {},故旧配置在首次改写时被带到新位置并从此由新件说了算)并补 "_comment",再以该内容覆写 <state_root()>/paths.json 并返回该路径。
350
+ def set_user_root(path: str, key: str = "data_root") -> str:
351
+ """把用户选择的路径写入**用户级** paths.json(不存在则创建)。返回文件路径。
352
+
353
+ 永远写新位置(不写旧包内那件)——旧位置会随包更新被删除,写进去等于
354
+ 用户设置迟早丢失;旧件的既有取值经 _user_paths() 带入新件,不丢配置。
355
+ """
356
+ pf = os.path.join(state_root(), "paths.json")
357
+ os.makedirs(os.path.dirname(pf), exist_ok=True)
358
+ d = _user_paths()
359
+ d.setdefault("_comment",
360
+ "灵枢记忆写入路径(用户可改)。删掉本文件即回落到用户级默认数据根"
361
+ "(~/.dsh/.dsh-memory/data)。")
362
+ d[key] = path.replace("\\", "/")
363
+ with open(pf, "w", encoding="utf-8") as f:
364
+ json.dump(d, f, ensure_ascii=False, indent=2)
365
+ return pf
366
+
367
+
368
+ if __name__ == "__main__":
369
+ import argparse
370
+ import sys
371
+
372
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
373
+ ap = argparse.ArgumentParser(description="灵枢数据根解析与设置")
374
+ ap.add_argument("--set-root", metavar="PATH",
375
+ help="把数据根写入 <用户级状态根>/paths.json(用户可改)")
376
+ ap.add_argument("--set-mdcg-root", metavar="PATH",
377
+ help="把认知图根写入 <用户级状态根>/paths.json")
378
+ ap.add_argument("--migrate-legacy", action="store_true",
379
+ help="把旧版包内 data/ 的数据面复制到用户级默认数据根"
380
+ "(只复制不删除;默认数据根被显式配置时不动作)")
381
+ a = ap.parse_args()
382
+ # main 级入口承接受理:覆盖键末段为 Windows 保留设备名时 _abs_host_path 抛
383
+ # ValueError——CLI 面给一行清晰呈现(含原始消息),不留裸 traceback。
384
+ try:
385
+ if a.set_root:
386
+ print("written:", set_user_root(a.set_root, "data_root"))
387
+ if a.set_mdcg_root:
388
+ print("written:", set_user_root(a.set_mdcg_root, "root"))
389
+ if a.migrate_legacy:
390
+ print("migrate:", json.dumps(migrate_legacy_data(),
391
+ ensure_ascii=False))
392
+ print(json.dumps(describe(), ensure_ascii=False, indent=2))
393
+ except ValueError as ve:
394
+ sys.stderr.write(f"[datapath] {ve}\n拒绝输出:请先改正上述覆盖键/配置。\n")
395
+ sys.exit(2)