@modusensus/dsh-mneme 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +11 -3
  2. package/bin/dsh-mneme-choro.mjs +254 -0
  3. package/docs/CONFIGURATION.md +8 -1
  4. package/lib/api-standalone.js +17 -21
  5. package/lib/api.js +298 -12
  6. package/lib/bootstrap-http.js +105 -0
  7. package/lib/bootstrap.js +5 -1
  8. package/lib/choro-command.js +104 -0
  9. package/lib/client.js +1153 -106
  10. package/lib/config-keys.js +144 -0
  11. package/lib/config.js +28 -0
  12. package/lib/graph/anchoring.js +7 -2
  13. package/lib/index.js +50 -4
  14. package/lib/inject.js +15 -3
  15. package/lib/lang.js +54 -0
  16. package/lib/quality-filter.js +48 -12
  17. package/lib/service.js +72 -8
  18. package/lib/settings.js +8 -1
  19. package/lib/store.js +657 -0
  20. package/lib/tools.js +127 -1
  21. package/lib/wiki/inject.js +164 -0
  22. package/lib/wiki/partition.js +200 -0
  23. package/lib/wiki/plan.js +214 -0
  24. package/lib/wiki/queries.js +220 -0
  25. package/lib/wiki/render.js +187 -0
  26. package/lib/wiki/scan.js +371 -0
  27. package/lib/wiki/t0.js +463 -0
  28. package/lib/wiki/t1.js +496 -0
  29. package/lib/write-admission.js +5 -3
  30. package/package.json +4 -2
  31. package/src/api-standalone.js +17 -21
  32. package/src/api.js +298 -12
  33. package/src/bootstrap-http.js +105 -0
  34. package/src/bootstrap.js +5 -1
  35. package/src/choro-command.js +104 -0
  36. package/src/config-keys.js +144 -0
  37. package/src/config.js +28 -0
  38. package/src/graph/anchoring.js +7 -2
  39. package/src/index.js +50 -4
  40. package/src/inject.js +15 -3
  41. package/src/lang.js +54 -0
  42. package/src/quality-filter.js +48 -12
  43. package/src/service.js +72 -8
  44. package/src/settings.js +8 -1
  45. package/src/store.js +657 -0
  46. package/src/tools.js +127 -1
  47. package/src/wiki/inject.js +164 -0
  48. package/src/wiki/partition.js +200 -0
  49. package/src/wiki/plan.js +214 -0
  50. package/src/wiki/queries.js +220 -0
  51. package/src/wiki/render.js +187 -0
  52. package/src/wiki/scan.js +371 -0
  53. package/src/wiki/t0.js +463 -0
  54. package/src/wiki/t1.js +496 -0
  55. package/src/write-admission.js +5 -3
  56. package/test/api-routes-gaps.test.js +131 -1
  57. package/test/api.test.js +7 -1
  58. package/test/bootstrap.test.js +8 -0
  59. package/test/choro-command.test.js +218 -0
  60. package/test/client.test.js +820 -19
  61. package/test/config-keys-routes.test.js +233 -0
  62. package/test/contrast.test.js +14 -7
  63. package/test/graph-boosted-relations.test.js +189 -0
  64. package/test/graph-multipath.test.js +269 -0
  65. package/test/inject.test.js +51 -0
  66. package/test/llm-audit.test.js +29 -1
  67. package/test/quality-backfill.test.js +290 -0
  68. package/test/quality-filter.test.js +113 -4
  69. package/test/service.test.js +1 -1
  70. package/test/wiki-api.test.js +153 -0
  71. package/test/wiki-inject.test.js +265 -0
  72. package/test/wiki-partition.test.js +112 -0
  73. package/test/wiki-plan.test.js +80 -0
  74. package/test/wiki-queries.test.js +187 -0
  75. package/test/wiki-scan.test.js +208 -0
  76. package/test/wiki-store.test.js +190 -0
  77. package/test/wiki-t0.test.js +380 -0
  78. package/test/wiki-t1.test.js +235 -0
  79. package/test/wiki-tools.test.js +121 -0
@@ -0,0 +1,144 @@
1
+ // 配置键覆盖面的「唯一一份」描述(issue #386 第 7 条)。
2
+ //
3
+ // 为什么单独一个模块:面板上能改的键(settings.js 白名单)与 schema 里实际存在的
4
+ // 键(config.js 的 Config)是两张表,它们从来没被摆在同一个地方对过。于是
5
+ // 「用户能理解、但面板改不到」的键只能靠读源码 + 手数得到答案——issue #386 列了
6
+ // 七组、维护者另外复述了一遍,每次都要重新数。
7
+ //
8
+ // 这里只做「算」:把 schema 声明的叶子键与白名单取交/差,回三份清单。它不读
9
+ // 数据库、不碰 cfg 的实际值、**不返回任何键的值**——apiToken 这类敏感键的名字可以
10
+ // 出现在「仅配置文件」清单里(那正是这条 issue 想让用户看见的东西),值一个字节
11
+ // 都不出。判定口径住在 flagKeysFromSpec,api.js 的 featureSnapshot 用的也是它;
12
+ // 谁要是另抄一份白名单,test/config-keys-routes.test.js 的静态锁直接转红。
13
+
14
+ import { Config } from "./config.js";
15
+ import { FEATURE_FLAG_SPEC } from "./settings.js";
16
+
17
+ /**
18
+ * 白名单里的全部键(面板 PUT 能写、effective 会算的那些)。
19
+ *
20
+ * 顺序与 api.js 的 featureSnapshot 逐字对应:booleans → ints → numbers → strings
21
+ * → urls → enums。api.js 直接引这一份,不再自己拼一遍。
22
+ */
23
+ export function flagKeysFromSpec() {
24
+ return [
25
+ ...FEATURE_FLAG_SPEC.booleans,
26
+ ...Object.keys(FEATURE_FLAG_SPEC.ints),
27
+ ...Object.keys(FEATURE_FLAG_SPEC.numbers),
28
+ ...FEATURE_FLAG_SPEC.strings,
29
+ ...FEATURE_FLAG_SPEC.urls,
30
+ ...Object.keys(FEATURE_FLAG_SPEC.enums)
31
+ ];
32
+ }
33
+
34
+ /**
35
+ * 把「已解析的配置对象」摊平成叶子键清单。
36
+ *
37
+ * 这份清单的含义是**「有默认值因此会出现在 effective 里的键」**,不是「schema 声明
38
+ * 了哪些键」——两者不等。`Config({})` 解析后 `apiToken` 根本不存在(`z.string()`
39
+ * 没有 `.default()`),若拿它当 schema 清单,最该被用户看见的那个键反而消失了
40
+ * (第一版就是这么错的,被 `config-keys returns key names but never the apiToken
41
+ * value` 那条用例抓住)。
42
+ *
43
+ * 数组当叶子(`sessionGateSessions` 是 `z.array`,面板没有编辑器,整条替换不算
44
+ * 「改一个值」)。
45
+ */
46
+ export function defaultedLeafKeys(instance = Config({})) {
47
+ const out = [];
48
+ const walk = (node, prefix) => {
49
+ for (const key of Object.keys(node)) {
50
+ const value = node[key];
51
+ const path = prefix ? `${prefix}.${key}` : key;
52
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) walk(value, path);
53
+ else out.push(path);
54
+ }
55
+ };
56
+ walk(instance, "");
57
+ return out;
58
+ }
59
+
60
+ /**
61
+ * schema **声明**的全部叶子键(含没有默认值的那些,比如 apiToken)。
62
+ *
63
+ * 直接走 schema 的 ref 图而不是解析结果:`Config.dict` 里每一项都是
64
+ * `{ uid, refs }`,真实节点在 `refs[uid]`。三种形状要分开处理:
65
+ * - `object`:有 `dict`,递归进去;
66
+ * - `dict`:键是开放的(`heatTypeDecay` 就是),只能靠 `meta.default` 的对象键
67
+ * 把已知的那几个名字还原出来;没有默认值就整条当一个叶子;
68
+ * - 其它:叶子。
69
+ *
70
+ * 探针实测:声明叶子 196 个、解析结果 191 个、白名单 113 个。两边差的那五个是
71
+ * `dreamProvider` / `dreamModel` / `dreamReasoningEffort` / `apiToken` /
72
+ * `sleepReasoningEffort`——都只声明、没给默认值。
73
+ */
74
+ export function schemaLeafKeys(schema = Config) {
75
+ const out = [];
76
+ const resolve = (node) => (node && node.refs && node.uid in node.refs ? node.refs[node.uid] : node);
77
+ const walk = (node, prefix) => {
78
+ const real = resolve(node);
79
+ if (!real) return;
80
+ if (real.type === "object" && real.dict) {
81
+ for (const key of Object.keys(real.dict)) walk(real.dict[key], prefix ? `${prefix}.${key}` : key);
82
+ return;
83
+ }
84
+ if (real.type === "dict") {
85
+ const def = real.meta && real.meta.default;
86
+ if (def && typeof def === "object" && !Array.isArray(def)) {
87
+ for (const key of Object.keys(def)) out.push(`${prefix}.${key}`);
88
+ return;
89
+ }
90
+ }
91
+ out.push(prefix);
92
+ };
93
+ if (schema && schema.dict) {
94
+ for (const key of Object.keys(schema.dict)) walk(schema.dict[key], key);
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /**
100
+ * 三份清单 + 计数。#386 第 7 条要的答案全在这里。
101
+ *
102
+ * - `panelEditable`:schema 声明了、且白名单也登记了的键(面板能改)。
103
+ * - `fileOnly`:schema 声明了、面板改不到的键——issue 正文点名的那七组都在里面,
104
+ * 连同 `apiToken`、`memoryDir` 这类「知道它存在就够了」的键。
105
+ * - `undefaulted`:白名单登记了、schema 也有,**但没有 `.default()`**,所以
106
+ * `effective` 里根本不会出现它。**这是本轮读码发现的一处真实不对称**:
107
+ * `dreamProvider` / `dreamModel` 在 settings.js 的 STRINGS 表里,config.js 那边
108
+ * 只有 `z.string().description(...)` 而没有 `.default()`,于是面板有输入框、保存
109
+ * 也能存,但 `featureSnapshot()` 的 `configFlagValue` 取到 undefined 就不写进
110
+ * effective(api.js 的 `if (value !== undefined)`)。列出来而不擅自补默认值:
111
+ * 补默认值会改变「未配置时到底用哪个模型」的既有行为,那是另一个决定。
112
+ *
113
+ * 三份互不相交,并集 = 白名单 ∪(声明里不在白名单的那批)——探针实测白名单
114
+ * 113 个全部都在声明里,所以并集就是声明的 196 个,一个不漏也不重复。
115
+ */
116
+ export function describeConfigKeys() {
117
+ const declared = schemaLeafKeys();
118
+ const declaredSet = new Set(declared);
119
+ const defaulted = new Set(defaultedLeafKeys());
120
+ const flags = flagKeysFromSpec();
121
+ const flagSet = new Set(flags);
122
+
123
+ const panelEditable = [];
124
+ const undefaulted = [];
125
+ for (const key of flags) {
126
+ if (!declaredSet.has(key)) continue;
127
+ if (defaulted.has(key)) panelEditable.push(key);
128
+ else undefaulted.push(key);
129
+ }
130
+ const fileOnly = declared.filter((key) => !flagSet.has(key));
131
+
132
+ return {
133
+ counts: {
134
+ schemaLeaf: declared.length,
135
+ flag: flags.length,
136
+ panelEditable: panelEditable.length,
137
+ fileOnly: fileOnly.length,
138
+ undefaulted: undefaulted.length
139
+ },
140
+ panelEditable,
141
+ fileOnly,
142
+ undefaulted
143
+ };
144
+ }
package/lib/config.js CHANGED
@@ -696,6 +696,10 @@ export const Config = z.object({
696
696
  // injection sort re-ranks by importance * quality_score/100 (degraded)
697
697
  // score < 30 → archived + tagged low_quality (still explicitly searchable)
698
698
  // Meta-memory markers, near-duplicates and repetitive filler lose points.
699
+ // "Repetitive" is trigram repetition > 0.7 (this change). The shipped v0.7.12
700
+ // measure was unique-chars/total, which decays with length: it flagged 94% of
701
+ // 800-2000 char memories and 100% of longer ones, i.e. long Chinese prose was
702
+ // punished for being long. Threshold rationale lives in quality-filter.js.
699
703
  memoryQualityFilter: z.object({
700
704
  enabled: z.boolean().default(true),
701
705
  archiveThreshold: z.natural().min(1).max(100).default(30),
@@ -888,6 +892,30 @@ export const Config = z.object({
888
892
  // 「用户开关 > 轻量预设 > bundle」,同 continuityRescueEnabled / distillRecords.enabled)。
889
893
  organizeEnabled: z.boolean().default(false),
890
894
 
895
+ // chorography(仓库地志,spec: docs/specs/2026-11-04-chorography-design.md)的
896
+ // 暴露闸(opt-in,默认关):管模型侧三个只读工具(choro_search / read_page /
897
+ // module_map)、面板浏览页,以及 M3 的开局注入。工具描述常驻模型上下文 = 新增
898
+ // 成本,按仓库纪律 opt-in;默认关=工具不存在、面板页空态、注入块不渲染,行为与
899
+ // 0.10.1 逐字节一致。
900
+ //
901
+ // 本键仍**不进 LIGHT_MODE_OFF**,但理由收窄成一句:它是「是否启用这个子系统」,
902
+ // 不是「每轮付多少」。轻量档要付的是下面那条 chorographyInject(把档位压成
903
+ // off)——两层分开,用户才能在轻量档里保留工具、只关掉每轮那块文本。
904
+ chorographyEnabled: z.boolean().default(false),
905
+
906
+ // 开局注入档位(M3,spec §7):off / light / full,默认 light。
907
+ // light ≤8 行(模块名 + 文件数 + 页状态 + 页路径,按「还有 N 个」明说截断);
908
+ // full 列全部模块;off 不注入但工具照旧可用。
909
+ // 为什么是枚举而不是布尔:spec 原文就是三档,「开了但只给 6 行」与「全给」是两种
910
+ // 用法,布尔键表达不了;跟随 chorographyEnabled 隐式开又会丢掉「留工具、去文本」
911
+ // 这个组合。
912
+ // 为什么写作 z.union([z.const(...)]) 而不是 z.enum:与 summarizeDedupeMode 同款,
913
+ // 全仓枚举键统一这一种写法(z.enum 的报错文案与既有键不一致)。
914
+ // 轻量档**不进 LIGHT_MODE_OFF**:那张表对平铺键一律赋 false,枚举键进去会得到与
915
+ // schema 不符的 false,而 false !== "off" 时注入照样渲染——静默失效比不关更糟。
916
+ // 轻量档的压制改在消费侧(src/wiki/inject.js 的 resolveInjectTier 判 lightMode)。
917
+ chorographyInject: z.union([z.const("off"), z.const("light"), z.const("full")]).default("light"),
918
+
891
919
  // --- scope: v0.8.0 A1 存储层(issue #17)--------------------------------
892
920
  // 总开关默认关:关闭时写入不标注 scope、去重维持 (type, title) 现状,行为
893
921
  // 逐字节不变。开启后**两条写入路径都自动盖章**(*_scope_source='auto'):
@@ -46,7 +46,12 @@ export function minMax(scores) {
46
46
  /**
47
47
  * 把一条检测通路的结果归一化成 0..1 的打分表。
48
48
  * path 是离散通路名(exact/tag/...)→ 查 DISCRETE_LEVELS;是连续通路
49
- * (continuous=true)→ min-max。返回 { byId: Map<id, score>, order: [id...] }。
49
+ * (continuous=true)→ min-max。返回 { path, byId: Map<id, score>, order }。
50
+ *
51
+ * issue #24 块1 补充:返回值带上 `path`。anchorSeeds 用它记「这个种子被哪几路
52
+ * 激活」(`seeds[i].paths`),复核时据此判断一条边是被精确名、Tag 桥接还是向量
53
+ * 拉进来的。此前只有 { byId, order },`p.path ?? "?"` 把每一路都记成 "?",
54
+ * 通路身份在合并那一步就丢了。连续通路调用方传 `path: "vector"`(cos 相似度)。
50
55
  */
51
56
  export function normalizePath(hits, { path = "exact", continuous = false } = {}) {
52
57
  const ids = Array.isArray(hits) ? hits : [];
@@ -62,7 +67,7 @@ export function normalizePath(hits, { path = "exact", continuous = false } = {})
62
67
  const level = DISCRETE_LEVELS[path] ?? DISCRETE_LEVELS.substring;
63
68
  for (const id of ids) byId.set(id, level);
64
69
  }
65
- return { byId, order: [...byId.keys()] };
70
+ return { path, byId, order: [...byId.keys()] };
66
71
  }
67
72
 
68
73
  /**
package/lib/index.js CHANGED
@@ -21,6 +21,10 @@ import { createApi } from "./api.js";
21
21
  import { createStandaloneApi } from "./api-standalone.js";
22
22
  // #275 存储生命周期第一批:无损回收(手动入口,不挂启动路径)。
23
23
  import { createMaintenance } from "./maintenance.js";
24
+ import { createChoroQueries } from "./wiki/queries.js";
25
+ // chorography M3:开局注入块与 /choro 只读回执。两者都只吃 queries 读侧,不写任何东西。
26
+ import { createChoroInject } from "./wiki/inject.js";
27
+ import { registerChoroCommand } from "./choro-command.js";
24
28
  import { createSettings } from "./settings.js";
25
29
  import { createCommandManager } from "./commands.js";
26
30
  import { createExecutionRecorder } from "./pipeline-runner.js";
@@ -152,7 +156,10 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv
152
156
  return undefined;
153
157
  }
154
158
  }
155
- writeAudit("success", null);
159
+ // 第三参必须显式传进去:writeAudit 的形参默认值 [] 是给"没有 id 可归因"
160
+ // 留的,省略第三参等于宣称"这条成功与任何记忆无关"——实测活库 6256 条
161
+ // success 行 100% 空数组,正是这个省略造成的。#405 只修了 error 半边。
162
+ writeAudit("success", null, relatedMemoryIds);
156
163
  return text;
157
164
  })().catch((err) => {
158
165
  logger?.warn?.(`[dsh-mneme] entity extraction llm stream failed: ${String(err)}`);
@@ -560,8 +567,26 @@ export const apply = (ctx, config) => {
560
567
  // a torn-down context.
561
568
  disposers.push(() => semantic.dispose()); // #118 重试计时器 + boot 回填计时器(搬入 semantic.js 后由它自持)
562
569
 
570
+ // chorography M3:地志提纲折进 memory context(不新开 context——宿主侧同 order 无
571
+ // 兜底排序,多一块就多一份拼装抖动;spec §7 也要求「合并成一次拼装」)。档位由
572
+ // chorographyInject 决定,off / 未匹配到仓库 / 渲染失败一律返回空串,宿主会过滤
573
+ // 掉空文本,整块消失且不占位。
574
+ //
575
+ // 预算是 0:注入块只在 memory context 里多几行,本身不做检索、不 fork git——
576
+ // 真过期判定留给 /choro(P29)。所以这里不需要额外的「热路径」闸,chorographyEnabled
577
+ // 与 chorographyInject 两个开关在 resolveInjectTier 里已经判完。
578
+ const choroInject = createChoroInject({
579
+ choro: createChoroQueries({ store }),
580
+ config: cfg,
581
+ logger: ctx.logger,
582
+ language: () => langOf(cfg)
583
+ });
584
+ disposers.push(() => choroInject.dispose());
585
+
563
586
  ctx.inject(["systemPrompt"], (promptCtx) => {
564
- if (cfg.autoInject) disposers.push(createInjector(promptCtx, service, settings, cfg, preInjectGate, sessionGate));
587
+ if (cfg.autoInject) {
588
+ disposers.push(createInjector(promptCtx, service, settings, cfg, preInjectGate, sessionGate, choroInject));
589
+ }
565
590
  });
566
591
 
567
592
  // #249 N3:压缩边缘双落点。触发靠宿主自己落的压缩事件(订阅 + pre-step 追加),
@@ -572,9 +597,27 @@ export const apply = (ctx, config) => {
572
597
  }
573
598
 
574
599
  ctx.inject(["tools"], (toolsCtx) => {
575
- disposers.push(createTools(toolsCtx, service, cfg, embedder, sessionGate));
600
+ // choro(M1):只读查询对象,chorographyEnabled 关着时工具压根不注册(闸在
601
+ // createTools 的 hiddenTools,与 organizeEnabled 同款反向语义)。
602
+ disposers.push(createTools(toolsCtx, service, cfg, embedder, sessionGate, createChoroQueries({ store })));
576
603
  });
577
604
 
605
+ // chorography M3:/choro 只读回执。与 /mo-run 同形(ctx.commands.register),
606
+ // 但这里**不持有任何状态**、不写盘、不重建——spec §2 D6「只手动触发,过期只提示」
607
+ // 决定了这条命令只能看,不能改。位置必须在 disposers 声明之后(同 actionNudge 的
608
+ // TDZ 注释),否则插件加载时会直接抛 ReferenceError。
609
+ if (cfg.chorographyEnabled === true) {
610
+ const disposeChoro = registerChoroCommand({
611
+ ctx,
612
+ choro: createChoroQueries({ store }),
613
+ config: cfg,
614
+ enabled: true,
615
+ language: () => langOf(cfg)
616
+ });
617
+ if (disposeChoro) disposers.push(disposeChoro);
618
+ else ctx.logger?.info?.("[dsh-mneme] chorographyEnabled=true 但宿主没有命令注册表,/choro 未注册");
619
+ }
620
+
578
621
  const summarizer = createSummarizer(ctx, service, cfg, {}, sessionGate);
579
622
  disposers.push(summarizer.dispose);
580
623
 
@@ -599,11 +642,14 @@ export const apply = (ctx, config) => {
599
642
  // 本模块一次都不会被调用)。这样「没启用」与「没装配」在 HTTP 面上是两个可区分的
600
643
  // 答案(503 unavailable 只该出现在旧宿主/装配缺失上,不该被读成「用户没开」)。
601
644
  const extractProfileDraft = createProfileExtractor({ ctx, service, config: cfg, logger: ctx.logger });
645
+ // chorography 读侧(M1):与 service 刻意分开——所有权模型相反(spec §3)。
646
+ // 闸在路由/工具两层(关着 409 / 不注册),queries 本体装配与开关无关。
647
+ const choro = createChoroQueries({ store });
602
648
  const api = createApi(ctx, service, settings, commands ?? {
603
649
  add: () => { throw new Error("commands unavailable"); },
604
650
  remove: () => false,
605
651
  list: () => []
606
- }, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg, maintenance, extractProfileDraft);
652
+ }, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg, maintenance, extractProfileDraft, choro);
607
653
  disposers.push(api.dispose);
608
654
  }
609
655
 
package/lib/inject.js CHANGED
@@ -93,7 +93,7 @@ function extractRounds(ctx, maxRounds) {
93
93
  }
94
94
  }
95
95
 
96
- export function createInjector(ctx, service, settings, config, gate = null, sessionGate = null) {
96
+ export function createInjector(ctx, service, settings, config, gate = null, sessionGate = null, choroInject = null) {
97
97
  const language = langOf(config);
98
98
  const baseMaxItems = config.maxInjectedItems ?? 5;
99
99
  const threshold = config.importanceThreshold ?? 3;
@@ -405,9 +405,19 @@ export function createInjector(ctx, service, settings, config, gate = null, sess
405
405
  // #333(issue #34 恢复):时间前缀只在会话首轮出现(per-session 闩锁),
406
406
  // 排在整个记忆块的最前——模型先知道「今天几号」,再看热上下文与长期记忆。
407
407
  const timePrefix = renderTimePrefix(ctx);
408
- // 拼接次序:时间 → 热上下文 → 长期记忆(三者都可能为空,逐级判空拼接)。
408
+ // M3 仓库地志(chorography):只注标题级提纲,档位 off/light/full。
409
+ // cwd 用 scope.workspace_scope——那是全仓唯一的 cwd 口径(registry 反查,
410
+ // header.cwd 兜底),另读一份就是两张皮且重犯 #487 的 Proxy 陷阱(P27)。
411
+ // 取不到 cwd / 仓库没登记 / 渲染失败一律空串:本块在热路径上,不能抛。
412
+ const choroText = choroInject && typeof choroInject.text === "function"
413
+ ? choroInject.text(scope?.workspace_scope ?? null)
414
+ : "";
415
+ // 拼接次序:时间 → 地志 → 热上下文 → 长期记忆(四者都可能为空,逐级判空)。
416
+ // 地志排在热上下文之前:它是「这个仓库由哪些模块组成」的稳定背景,
417
+ // 而热上下文是「刚才聊到哪」,越靠近正文越晚——稳定的在前,易变的在后。
409
418
  const withHot = !hotText ? body : body ? `${hotText}\n\n${body}` : hotText;
410
- const finalBody = !timePrefix ? withHot : withHot ? `${timePrefix}\n\n${withHot}` : timePrefix;
419
+ const withChoro = !choroText ? withHot : withHot ? `${choroText}\n\n${withHot}` : choroText;
420
+ const finalBody = !timePrefix ? withChoro : withChoro ? `${timePrefix}\n\n${withChoro}` : timePrefix;
411
421
  // Issue #179:旁路缓存一帧——面板「注入预览」卡据此展示构成与体积。
412
422
  // chars 为条目内容的截断后近似值(与 render 同一函数计长),totalChars
413
423
  // 是本次实际返回块的精确长度。maxItems 反映自适应收缩后的生效值。
@@ -424,6 +434,8 @@ export function createInjector(ctx, service, settings, config, gate = null, sess
424
434
  // 「注入预览」卡据此展示判定是否生效与标记数。
425
435
  gate: gateFrame ? { state: gateFrame.state, nFlagged: gateFrame.flaggedIds ? gateFrame.flaggedIds.size : null, judged: gateFrame.judged ?? null } : null,
426
436
  hotChars: hotText.length,
437
+ // M3:地志块实际占的字符数(0 = 本帧没注入),面板据此区分「关」与「没匹配到仓库」。
438
+ choroChars: choroText.length,
427
439
  entries: candidates.map((m) => ({
428
440
  id: m.id,
429
441
  type: m.type,
package/lib/lang.js CHANGED
@@ -415,5 +415,59 @@ export const STR = {
415
415
  en: () => "# document index — dsh-mneme\n\n<!-- Machine-owned as a whole and rebuildable from the memory store at any time (which is why it carries no generation timestamp). Annotations belong in the memory store (memory_save). managed = the file sits inside documentDir. -->\n\n"
416
416
  },
417
417
 
418
+
419
+ // --- wiki/inject.js + choro-command.js:仓库地志(chorography M3) ---------------
420
+ // 注入块是三句话要说清的东西:这是哪个仓库、什么时候生成的、下面这几行怎么读。
421
+ // 生成时间必须出现——地志是快照不是真相,读者得知道它可能是旧的(spec §2 D6:
422
+ // 过期只提示、绝不自动重跑,所以「提示」的唯一落点就是这里)。
423
+ choroBlockHeader: {
424
+ zh: (repo, count, date) => `[仓库地志] ${repo}:${count} 个模块,生成于 ${date}。以下是标题级提纲(不含正文):`,
425
+ en: (repo, count, date) => `[Repo atlas] ${repo}: ${count} modules, generated ${date}. Title-level outline only (no page bodies):`
426
+ },
427
+ choroBlockLine: {
428
+ zh: (title, files, status, relPath) => `- ${title}(${files} 文件${status ? " · " + status : ""}) ${relPath}`,
429
+ en: (title, files, status, relPath) => `- ${title} (${files} files${status ? " · " + status : ""}) ${relPath}`
430
+ },
431
+ choroBlockMore: {
432
+ zh: (rest) => `- ……另有 ${rest} 个模块,需要时用 choro_module_map 看全表`,
433
+ en: (rest) => `- ...${rest} more modules; use choro_module_map for the full list`
434
+ },
435
+ choroStatusEnriched: { zh: "已有语义段", en: "enriched" },
436
+ choroStatusFailed: { zh: "生成失败", en: "failed" },
437
+ choroStatusStale: { zh: "可能已过期", en: "possibly stale" },
438
+
439
+ // /choro:只读回执。三条路径各自说清「看到了什么」——不写盘、不重建(P33)。
440
+ choroCommandDescription: {
441
+ zh: "仓库地志:看当前仓库的模块提纲、过期状态与用法",
442
+ en: "Repo atlas: show the module outline, staleness and usage hints for this repo"
443
+ },
444
+ choroCommandInputHint: { zh: "(无参数)", en: "(no arguments)" },
445
+ choroCommandUsage: {
446
+ zh: {
447
+ off: "chorography(仓库地志)没开。在设置的「仓库地志」里打开后重启 DSH;开之前只有空态,没有数据可看。",
448
+ noCwd: "拿不到这个会话的工作目录,无法判断你在哪个仓库里。",
449
+ noRepo: (cwd) => `当前工作目录没有登记过地志:${cwd}\n生成一份:dsh-mneme-choro <仓库路径> --apply(确定性骨架,零模型)`,
450
+ title: (repo, count, date) => `仓库地志 · ${repo}(${count} 个模块,生成于 ${date})`,
451
+ empty: "这个仓库还没扫出任何模块(可能全部被 plan.yaml 的 include/exclude 排掉了)。",
452
+ truncated: (rest) => `……另有 ${rest} 个模块。完整提纲用工具 choro_module_map 看。`,
453
+ fresh: (head) => `基线一致:当前 HEAD 就是 ${head},地志没过期。`,
454
+ stale: (baseline, head) => `⚠️ 可能已过期:地志基线 ${baseline},当前 HEAD ${head}。它只提示、不会自己重跑——要更新就手动跑 dsh-mneme-choro <仓库路径> --apply。`,
455
+ noGit: "读不到当前 HEAD(不是 git 仓库或 git 不可用),没法判断是否过期。",
456
+ hint: "正文按模块读:先 choro_search 找命中,再 choro_read_page 取整页。重建、富化都走 CLI,不在这条命令里。"
457
+ },
458
+ en: {
459
+ off: "Chorography (repo atlas) is off. Turn on the atlas switch in settings, then restart DSH; until then there is nothing to read.",
460
+ noCwd: "Cannot determine the working directory of this session, so the repo cannot be resolved.",
461
+ noRepo: (cwd) => `No atlas registered for this working directory: ${cwd}\nBuild one: dsh-mneme-choro <repo path> --apply (deterministic skeleton, no model)`,
462
+ title: (repo, count, date) => `Repo atlas · ${repo} (${count} modules, generated ${date})`,
463
+ empty: "No modules were produced for this repo (plan.yaml include/exclude may have filtered everything).",
464
+ truncated: (rest) => `...${rest} more modules. Use the choro_module_map tool for the full outline.`,
465
+ fresh: (head) => `Baseline matches: HEAD is ${head}, the atlas is not stale.`,
466
+ stale: (baseline, head) => `Possibly stale: atlas baseline ${baseline}, current HEAD ${head}. It only warns and never rebuilds itself — rerun dsh-mneme-choro <repo path> --apply to refresh.`,
467
+ noGit: "Cannot read HEAD (not a git repo, or git unavailable), so staleness is unknown.",
468
+ hint: "Read bodies per module: choro_search to locate, choro_read_page to load a whole page. Rebuilds and enrichment stay in the CLI, not in this command."
469
+ }
470
+ },
471
+
418
472
  prompts: PROMPTS
419
473
  };
@@ -15,7 +15,7 @@
15
15
  // memory system itself, not the user's world) −45
16
16
  // self_referential title/content mentions its own type label −15
17
17
  // short_content content shorter than minContentLength −80
18
- // repetitive dedup ratio (unique chars / total) < 0.3 −50
18
+ // repetitive trigram repetition ratio > 0.7 −50
19
19
  // duplicate bigram similarity to a recent memory > 0.85 −80
20
20
  //
21
21
  // The meta signal alone lands a well-formed memory in the degraded band
@@ -26,6 +26,20 @@
26
26
  export const META_MEMORY_RE =
27
27
  /记忆|mneme|recall|inject|上下文|token|prompt|系统指令|作为AI|作为助手|我需要记住|总结一下刚才/;
28
28
 
29
+ // The deduction table is exported because a SECOND reader needs the same numbers: the
30
+ // one-time backfill in store.js reconstructs a stored score from a row's surviving tags
31
+ // (it cannot re-score wholesale — that would also re-apply signals that were never on
32
+ // the row). Duplicating 45/15/80/50/80 there would make "what a tag costs" two facts
33
+ // that can drift apart, and the drift would be silent: the backfill would just write
34
+ // slightly wrong numbers. Keys are the tag names, not the reasons.
35
+ export const SIGNAL_DEDUCTIONS = Object.freeze({
36
+ meta: 45,
37
+ self_referential: 15,
38
+ short_content: 80,
39
+ repetitive: 50,
40
+ duplicate: 80
41
+ });
42
+
29
43
  // Own-type labels, used for self-reference detection (the English type value
30
44
  // the AI writers emit plus the Chinese equivalent a human would type).
31
45
  const TYPE_LABELS = {
@@ -56,11 +70,33 @@ export function textSimilarity(a, b) {
56
70
  return inter / Math.min(A.size, B.size);
57
71
  }
58
72
 
59
- /** Fraction of characters that are unique (dedup ratio in [0,1]). */
60
- export function dedupRatio(text) {
61
- const t = String(text);
62
- if (!t.length) return 0;
63
- return new Set(t).size / t.length;
73
+ // Repetition threshold. Calibrated 2026-11-04 against a real 12,827-row library:
74
+ // the highest trigram ratio among genuine memories was 0.591 (a note where the
75
+ // same install paths repeat), so 0.7 keeps every real memory clear while still
76
+ // catching degenerate fillers (a single char x20 = 0.94, a sentence x5 = 0.79).
77
+ // Do not lower this without re-measuring: 0.6 leaves only 0.009 of headroom.
78
+ export const REPETITION_THRESHOLD = 0.7;
79
+
80
+ /**
81
+ * Trigram repetition ratio in [0,1]: the share of 3-char windows that are not
82
+ * the first occurrence of their own trigram. 0 = every trigram distinct.
83
+ *
84
+ * Why this instead of unique-chars / total (the measure shipped in v0.7.12):
85
+ * that one is a function of LENGTH, not of repetition. Chinese prose draws from
86
+ * a few hundred common characters, so uniqueChars/total decays toward 0 as the
87
+ * text grows — measured on a real library it fell below the old 0.3 gate for
88
+ * 94% of 800-2000 char memories and 100% of those over 2000 chars. Long memories
89
+ * were being tagged "repetitive" for being long. Trigram repetition is
90
+ * length-stable: concatenating distinct real memories up to 30,000 chars only
91
+ * reaches 0.428, while genuinely duplicated text passes 0.6 by the second copy.
92
+ */
93
+ export function repetitionRatio(text) {
94
+ const t = String(text).replace(/\s+/g, "");
95
+ if (t.length < 3) return 0;
96
+ const total = t.length - 2;
97
+ const seen = new Set();
98
+ for (let i = 0; i < total; i++) seen.add(t.slice(i, i + 3));
99
+ return 1 - seen.size / total;
64
100
  }
65
101
 
66
102
  /**
@@ -85,26 +121,26 @@ export function evaluateMemoryQuality(memory, options = {}) {
85
121
  let score = 100;
86
122
 
87
123
  if (META_MEMORY_RE.test(text)) {
88
- score -= 45;
124
+ score -= SIGNAL_DEDUCTIONS.meta;
89
125
  tags.push("meta");
90
126
  reasons.push("meta-memory vocabulary");
91
127
  }
92
128
 
93
129
  const labels = TYPE_LABELS[memory?.type];
94
130
  if (labels && labels.some((l) => text.includes(l))) {
95
- score -= 15;
131
+ score -= SIGNAL_DEDUCTIONS.self_referential;
96
132
  tags.push("self_referential");
97
133
  reasons.push("mentions its own type");
98
134
  }
99
135
 
100
136
  if (minContentLength > 0 && trimmed.length < minContentLength) {
101
- score -= 80;
137
+ score -= SIGNAL_DEDUCTIONS.short_content;
102
138
  tags.push("short_content");
103
139
  reasons.push(`content shorter than ${minContentLength} chars`);
104
140
  }
105
141
 
106
- if (trimmed.length > 0 && dedupRatio(trimmed) < 0.3) {
107
- score -= 50;
142
+ if (trimmed.length > 0 && repetitionRatio(trimmed) > REPETITION_THRESHOLD) {
143
+ score -= SIGNAL_DEDUCTIONS.repetitive;
108
144
  tags.push("repetitive");
109
145
  reasons.push("repetitive content");
110
146
  }
@@ -112,7 +148,7 @@ export function evaluateMemoryQuality(memory, options = {}) {
112
148
  if (recentContents.length > 0 && trimmed.length > 0) {
113
149
  for (const other of recentContents) {
114
150
  if (textSimilarity(trimmed, other) > 0.85) {
115
- score -= 80;
151
+ score -= SIGNAL_DEDUCTIONS.duplicate;
116
152
  tags.push("duplicate");
117
153
  reasons.push("near-duplicate of a recent memory");
118
154
  break;