@modusensus/dsh-mneme 0.10.1 → 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.
- package/README.md +10 -3
- package/bin/dsh-mneme-choro.mjs +254 -0
- package/docs/CONFIGURATION.md +8 -1
- package/lib/api-standalone.js +17 -21
- package/lib/api.js +247 -11
- package/lib/bootstrap-http.js +105 -0
- package/lib/bootstrap.js +5 -1
- package/lib/choro-command.js +104 -0
- package/lib/client.js +1028 -102
- package/lib/config-keys.js +144 -0
- package/lib/config.js +28 -0
- package/lib/index.js +50 -4
- package/lib/inject.js +15 -3
- package/lib/lang.js +54 -0
- package/lib/quality-filter.js +48 -12
- package/lib/service.js +3 -0
- package/lib/settings.js +8 -1
- package/lib/store.js +502 -0
- package/lib/tools.js +127 -1
- package/lib/wiki/inject.js +164 -0
- package/lib/wiki/partition.js +200 -0
- package/lib/wiki/plan.js +214 -0
- package/lib/wiki/queries.js +220 -0
- package/lib/wiki/render.js +187 -0
- package/lib/wiki/scan.js +371 -0
- package/lib/wiki/t0.js +463 -0
- package/lib/wiki/t1.js +496 -0
- package/lib/write-admission.js +5 -3
- package/package.json +4 -2
- package/src/api-standalone.js +17 -21
- package/src/api.js +247 -11
- package/src/bootstrap-http.js +105 -0
- package/src/bootstrap.js +5 -1
- package/src/choro-command.js +104 -0
- package/src/config-keys.js +144 -0
- package/src/config.js +28 -0
- package/src/index.js +50 -4
- package/src/inject.js +15 -3
- package/src/lang.js +54 -0
- package/src/quality-filter.js +48 -12
- package/src/service.js +3 -0
- package/src/settings.js +8 -1
- package/src/store.js +502 -0
- package/src/tools.js +127 -1
- package/src/wiki/inject.js +164 -0
- package/src/wiki/partition.js +200 -0
- package/src/wiki/plan.js +214 -0
- package/src/wiki/queries.js +220 -0
- package/src/wiki/render.js +187 -0
- package/src/wiki/scan.js +371 -0
- package/src/wiki/t0.js +463 -0
- package/src/wiki/t1.js +496 -0
- package/src/write-admission.js +5 -3
- package/test/api-routes-gaps.test.js +131 -1
- package/test/api.test.js +7 -1
- package/test/bootstrap.test.js +8 -0
- package/test/choro-command.test.js +218 -0
- package/test/client.test.js +655 -19
- package/test/config-keys-routes.test.js +233 -0
- package/test/contrast.test.js +14 -7
- package/test/inject.test.js +51 -0
- package/test/llm-audit.test.js +29 -1
- package/test/quality-backfill.test.js +290 -0
- package/test/quality-filter.test.js +113 -4
- package/test/service.test.js +1 -1
- package/test/wiki-api.test.js +153 -0
- package/test/wiki-inject.test.js +265 -0
- package/test/wiki-partition.test.js +112 -0
- package/test/wiki-plan.test.js +80 -0
- package/test/wiki-queries.test.js +187 -0
- package/test/wiki-scan.test.js +208 -0
- package/test/wiki-store.test.js +190 -0
- package/test/wiki-t0.test.js +380 -0
- package/test/wiki-t1.test.js +235 -0
- package/test/wiki-tools.test.js +121 -0
package/src/tools.js
CHANGED
|
@@ -76,7 +76,10 @@ const MEMORY_PROVENANCE = (m) => {
|
|
|
76
76
|
return parts.length ? ` | ${parts.join(" | ")}` : "";
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
// choro(M1):chorography 只读查询对象(createChoroQueries 产物)。缺省 null =
|
|
80
|
+
// 装配层没接(headless/测试),三个 choro_* 工具走 hiddenTools 闸根本不注册。
|
|
81
|
+
// 它与 service 刻意分开传——chorography 的所有权模型与记忆域相反(spec §3)。
|
|
82
|
+
export function createTools(ctx, service, config, embedder, sessionGate = null, choro = null) {
|
|
80
83
|
const toolsRegistry = ctx.tools;
|
|
81
84
|
let registration = REGISTERED_TOOLS.get(toolsRegistry);
|
|
82
85
|
if (!registration) {
|
|
@@ -89,6 +92,16 @@ export function createTools(ctx, service, config, embedder, sessionGate = null)
|
|
|
89
92
|
// flag 关闭时恒返回 null——写入不标注,检索不加权,行为与 A1 前完全一致。
|
|
90
93
|
// logger 透传:registry 反查失败时 warnOnce 才有出口(否则静默降级无观测)。
|
|
91
94
|
const resolveSessionScope = createScopeResolver({ ctx, config, logger: ctx.logger });
|
|
95
|
+
// choro 工具共用的 repo 解析(M1):不带 repo 且只有一个登记仓时自动收敛;
|
|
96
|
+
// 多仓歧义时把可选清单带回给模型(render 层会把它转成人话)。三个 execute 里
|
|
97
|
+
// 都先走这里,错误形状统一 { error, ... },output.schema 容忍多余键由
|
|
98
|
+
// additionalProperties:false + render 兜底——错误分支直接透传给渲染层判。
|
|
99
|
+
const resolveChoroRepo = (ref) => {
|
|
100
|
+
const row = choro ? choro.resolveRepo(ref) : null;
|
|
101
|
+
if (!row) return { error: "no-repos", hint: "run `dsh-mneme-choro <repo> --apply` first (registers the repository)" };
|
|
102
|
+
if (row.ambiguous) return { error: "repo-ambiguous", repos: row.ambiguous, hint: "pass repo id or name" };
|
|
103
|
+
return row;
|
|
104
|
+
};
|
|
92
105
|
// #249 能力说明(injectGuidanceEnabled):开启时给「何时用」最有歧义的两个
|
|
93
106
|
// 工具补一句判断指引。工具描述常驻、不进每轮上下文,这个位子零注入成本;
|
|
94
107
|
// 关闭时描述逐字节不变,也不动其他工具的文案。
|
|
@@ -1032,6 +1045,111 @@ export function createTools(ctx, service, config, embedder, sessionGate = null)
|
|
|
1032
1045
|
...(report.reason ? { reason: report.reason } : {})
|
|
1033
1046
|
};
|
|
1034
1047
|
}
|
|
1048
|
+
}),
|
|
1049
|
+
|
|
1050
|
+
// --- chorography(仓库地志)只读工具(M1,spec §7)------------------
|
|
1051
|
+
// 三个全只读(红线 3:写操作一个不注册——生成只走 dsh-mneme-choro CLI,
|
|
1052
|
+
// 那是人按的按钮,不是模型的权利)。默认不在场:chorographyEnabled 关着时
|
|
1053
|
+
// 走下面的 hiddenTools 闸,模型连名字都看不到(与 memory_organize 同款反向
|
|
1054
|
+
// 语义)。执行器包 sessionGate 由注册循环统一做——仓库地志是文档不是会话
|
|
1055
|
+
// 派生内容,无痕会话里被一并关掉是保守取向,可接受。
|
|
1056
|
+
defineTool({
|
|
1057
|
+
name: "choro_module_map",
|
|
1058
|
+
description: "Show the chorography module map of a registered repository: module list (slug, title, file count, status) and cross-module dependency edges. Read-only. Use it to orient yourself in a codebase before reading pages; requires the user to have enabled chorography and generated the wiki (T0 skeleton).",
|
|
1059
|
+
parameters: {
|
|
1060
|
+
repo: { type: "string", description: "Repository id or display name; omit when exactly one repository is registered" }
|
|
1061
|
+
},
|
|
1062
|
+
output: {
|
|
1063
|
+
schema: {
|
|
1064
|
+
type: "object",
|
|
1065
|
+
additionalProperties: false,
|
|
1066
|
+
properties: {
|
|
1067
|
+
repo: { type: "string", required: true },
|
|
1068
|
+
modules: { type: "array", required: true, items: { type: "object", additionalProperties: true } },
|
|
1069
|
+
edges: { type: "array", required: true, items: { type: "object", additionalProperties: true } }
|
|
1070
|
+
}
|
|
1071
|
+
},
|
|
1072
|
+
render: (_args, value) => {
|
|
1073
|
+
const mods = value.modules ?? [];
|
|
1074
|
+
const edges = value.edges ?? [];
|
|
1075
|
+
const lines = mods.map((m) =>
|
|
1076
|
+
`- ${m.slug} — ${m.title}(${m.file_count} 文件${m.page_status ? `,页 ${m.page_status}` : ",无页"})`
|
|
1077
|
+
);
|
|
1078
|
+
for (const e of edges) lines.push(` ↳ ${e.from_slug} → ${e.to_slug} ×${e.weight}`);
|
|
1079
|
+
return TEXT_OUTPUT(`${mods.length} modules, ${edges.length} edges:\n${lines.join("\n")}`);
|
|
1080
|
+
}
|
|
1081
|
+
},
|
|
1082
|
+
async execute(args) {
|
|
1083
|
+
const repo = resolveChoroRepo(args?.repo);
|
|
1084
|
+
if (repo.error) return repo;
|
|
1085
|
+
const { modules, edges } = choro.moduleMap(repo.id);
|
|
1086
|
+
return { repo: repo.name, modules, edges };
|
|
1087
|
+
}
|
|
1088
|
+
}),
|
|
1089
|
+
|
|
1090
|
+
defineTool({
|
|
1091
|
+
name: "choro_read_page",
|
|
1092
|
+
description: "Read one chorography wiki page (module skeleton: file list, entries, internal/external dependencies) of a registered repository by slug. Use choro_module_map first to learn the slugs. Read-only.",
|
|
1093
|
+
parameters: {
|
|
1094
|
+
slug: { type: "string", description: "Page slug as listed by choro_module_map" },
|
|
1095
|
+
repo: { type: "string", description: "Repository id or display name; omit when exactly one repository is registered" }
|
|
1096
|
+
},
|
|
1097
|
+
output: {
|
|
1098
|
+
schema: {
|
|
1099
|
+
type: "object",
|
|
1100
|
+
additionalProperties: false,
|
|
1101
|
+
properties: {
|
|
1102
|
+
slug: { type: "string", required: true },
|
|
1103
|
+
content: { type: "string", required: true }
|
|
1104
|
+
}
|
|
1105
|
+
},
|
|
1106
|
+
render: (_args, value) => TEXT_OUTPUT(value.content)
|
|
1107
|
+
},
|
|
1108
|
+
async execute(args) {
|
|
1109
|
+
const repo = resolveChoroRepo(args?.repo);
|
|
1110
|
+
if (repo.error) return repo;
|
|
1111
|
+
const page = choro.readPage(repo.id, args?.slug);
|
|
1112
|
+
if (!page) return { error: "page-not-found", slug: String(args?.slug ?? ""), hint: "use choro_module_map to list slugs" };
|
|
1113
|
+
if (page.error) return page;
|
|
1114
|
+
return { slug: page.slug, content: page.content };
|
|
1115
|
+
}
|
|
1116
|
+
}),
|
|
1117
|
+
|
|
1118
|
+
defineTool({
|
|
1119
|
+
name: "choro_search",
|
|
1120
|
+
description: "Keyword search over a repository's chorography wiki pages. Returns matching sections with page slug, section title, line number and excerpt (case-insensitive substring; matches frontmatter too — sections let you tell metadata hits from body hits). Read-only.",
|
|
1121
|
+
parameters: {
|
|
1122
|
+
query: { type: "string", description: "Keyword substring to search for" },
|
|
1123
|
+
repo: { type: "string", description: "Repository id or display name; omit when exactly one repository is registered" },
|
|
1124
|
+
module: { type: "string", description: "Restrict to one module slug" },
|
|
1125
|
+
limit: { type: "integer", description: "Max hits (default 20, cap 100)" }
|
|
1126
|
+
},
|
|
1127
|
+
output: {
|
|
1128
|
+
schema: {
|
|
1129
|
+
type: "object",
|
|
1130
|
+
additionalProperties: false,
|
|
1131
|
+
properties: {
|
|
1132
|
+
hits: { type: "array", required: true, items: { type: "object", additionalProperties: true } }
|
|
1133
|
+
}
|
|
1134
|
+
},
|
|
1135
|
+
render: (_args, value) => {
|
|
1136
|
+
const hits = value.hits ?? [];
|
|
1137
|
+
if (hits.length === 0) return TEXT_OUTPUT("0 hits.");
|
|
1138
|
+
return TEXT_OUTPUT(hits
|
|
1139
|
+
.map((hit) => `${hit.slug} · ${hit.section} · L${hit.line}: ${hit.excerpt}`)
|
|
1140
|
+
.join("\n"));
|
|
1141
|
+
}
|
|
1142
|
+
},
|
|
1143
|
+
async execute(args) {
|
|
1144
|
+
const repo = resolveChoroRepo(args?.repo);
|
|
1145
|
+
if (repo.error) return repo;
|
|
1146
|
+
return {
|
|
1147
|
+
hits: choro.search(repo.id, args?.query, {
|
|
1148
|
+
moduleSlug: args?.module ?? null,
|
|
1149
|
+
limit: args?.limit
|
|
1150
|
+
})
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1035
1153
|
})
|
|
1036
1154
|
];
|
|
1037
1155
|
|
|
@@ -1046,6 +1164,14 @@ export function createTools(ctx, service, config, embedder, sessionGate = null)
|
|
|
1046
1164
|
// 注册。仍走同一个闸门而不是在数组里条件展开——模型看不到就不会调,这比在描述里
|
|
1047
1165
|
// 劝它别用可靠(上面那段 v0.8.5 的定性对两个方向都成立)。
|
|
1048
1166
|
if (config?.organizeEnabled !== true) hiddenTools.add("memory_organize");
|
|
1167
|
+
// chorography 三个只读工具(M1):默认不在场,chorographyEnabled 打开才注册。
|
|
1168
|
+
// 与 organizeEnabled 同款反向语义;queries 未装配(null)时同样藏——「开了开关
|
|
1169
|
+
// 但装配缺件」与「没开」对模型都是不可见,可区分性由面板路由的 409/503 负责。
|
|
1170
|
+
if (config?.chorographyEnabled !== true || !choro) {
|
|
1171
|
+
hiddenTools.add("choro_module_map");
|
|
1172
|
+
hiddenTools.add("choro_read_page");
|
|
1173
|
+
hiddenTools.add("choro_search");
|
|
1174
|
+
}
|
|
1049
1175
|
|
|
1050
1176
|
for (const tool of tools) {
|
|
1051
1177
|
if (hiddenTools.has(tool.name)) {
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// dsh-mneme/src/wiki/inject.js
|
|
2
|
+
// chorography 的注入面(M3):会话开局往 systemPrompt 里塞一小块「仓库地志提纲」。
|
|
3
|
+
//
|
|
4
|
+
// 三条边界(spec §7 / P24–P33):
|
|
5
|
+
// 1. **只注标题级**——模块名 + 文件数 + 页状态 + 页相对路径,不读页正文。正文要
|
|
6
|
+
// 模型自己用 choro_read_page 取,那不是「开局就该付的成本」。
|
|
7
|
+
// 2. **不上热路径的硬活**——不 fork git、不 stat 磁盘、不解析 markdown。这里只吃
|
|
8
|
+
// DB 里已有的行;过期判定留给 /choro(人主动触发,付得起一次 git rev-parse)。
|
|
9
|
+
// 3. **绝不抛**——本块折在既有的 memory context 里,一个附属信息块把整轮
|
|
10
|
+
// systemPrompt 搞崩是不成比例的失败。任何异常都退化成空串(= 本帧不注入,
|
|
11
|
+
// 宿主契约里空文本会被过滤,不占位)。
|
|
12
|
+
import { escapePromptVars } from "../user-settings.js";
|
|
13
|
+
import { STR } from "../lang.js";
|
|
14
|
+
|
|
15
|
+
/** 档位枚举(与 config.js 的 chorographyInject 同一份取值,配置闸门在 settings.js)。 */
|
|
16
|
+
export const INJECT_TIERS = Object.freeze(["off", "light", "full"]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* light 档最多列几个模块。
|
|
20
|
+
*
|
|
21
|
+
* 定 6 是照着 spec §7 的「≤8 行」倒推的:块头 1 行 + 模块行 6 行 + 「还有 N 个」
|
|
22
|
+
* 1 行 = 8 行。多给两行要挤掉「还有 N 个」那句,读者就不知道清单被截断了——
|
|
23
|
+
* 静默截断比明说「还有 30 个」更坏。
|
|
24
|
+
*/
|
|
25
|
+
export const LIGHT_MODULE_LIMIT = 6;
|
|
26
|
+
|
|
27
|
+
/** 各档字符硬上限。宿主侧没有任何体积预算(V3 实测),所以上限只能自己兜。 */
|
|
28
|
+
export const MAX_BLOCK_CHARS = Object.freeze({ light: 900, full: 6000 });
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 配置 → 档位。纯函数,无副作用,供注入侧与 /choro 共用同一份判据。
|
|
32
|
+
*
|
|
33
|
+
* 轻量档(lightMode)在这里被压成 off 而不是进 LIGHT_MODE_OFF:那张表对平铺键
|
|
34
|
+
* 一律赋 false,枚举键塞进去会得到与 schema 不符的 false,而 false !== "off"
|
|
35
|
+
* 时注入照样渲染——比不关更糟(P26)。
|
|
36
|
+
*/
|
|
37
|
+
export function resolveInjectTier(config) {
|
|
38
|
+
if (!config || config.chorographyEnabled !== true) return "off";
|
|
39
|
+
if (config.lightMode === true) return "off";
|
|
40
|
+
const raw = config.chorographyInject;
|
|
41
|
+
return INJECT_TIERS.includes(raw) ? raw : "light";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 页状态 → 标签。skeleton 不出标签(那是默认态,全标一遍等于没标)。 */
|
|
45
|
+
function statusLabel(status, language) {
|
|
46
|
+
if (status === "enriched") return STR.choroStatusEnriched[language];
|
|
47
|
+
if (status === "failed") return STR.choroStatusFailed[language];
|
|
48
|
+
if (status === "stale") return STR.choroStatusStale[language];
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* light 档取哪些模块。
|
|
54
|
+
*
|
|
55
|
+
* 按文件数降序、同数按 slug 升序——**不是**取字母序前 6 个:字母序会永远只显示
|
|
56
|
+
* 同一批(通常还是 src 这种大而空的目录页),而大模块是「被问到的概率」最高的
|
|
57
|
+
* 那批。排序里带 slug 是为了同一份库每次渲染字节一致(缓存与测试都靠它)。
|
|
58
|
+
*/
|
|
59
|
+
function pickModules(modules, tier) {
|
|
60
|
+
// 两档用同一份排序:light 与 full 若各按各的排,同一份地志在两个档位下会给出
|
|
61
|
+
// 不同次序,读起来像「刚才那份被重扫过了」。大模块在前对两档都成立——
|
|
62
|
+
// 被问到的概率最高的那批,本来就该排前面。
|
|
63
|
+
const ordered = [...modules].sort((a, b) => {
|
|
64
|
+
const d = (b.fileCount ?? 0) - (a.fileCount ?? 0);
|
|
65
|
+
if (d !== 0) return d;
|
|
66
|
+
return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
|
|
67
|
+
});
|
|
68
|
+
if (tier === "full") return { shown: ordered, rest: 0 };
|
|
69
|
+
return { shown: ordered.slice(0, LIGHT_MODULE_LIMIT), rest: Math.max(0, ordered.length - LIGHT_MODULE_LIMIT) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 生成时间只留日期部分:注入块里精确到毫秒没有信息量,只挤体积。 */
|
|
73
|
+
function dateOnly(iso) {
|
|
74
|
+
const match = /^(\d{4}-\d{2}-\d{2})/.exec(String(iso ?? ""));
|
|
75
|
+
return match ? match[1] : String(iso ?? "");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 渲染地志提纲块。纯函数(入参进、字符串出),注入侧与 /choro 的「看提纲」
|
|
80
|
+
* 共用这一份——两张皮在这里只剩档位与语言两个参数之差。
|
|
81
|
+
*
|
|
82
|
+
* @param {object|null} outline createChoroQueries().outline() 的产物
|
|
83
|
+
* @param {"light"|"full"} tier 已解析的档位(off 不该调到这里)
|
|
84
|
+
* @param {"zh"|"en"} language
|
|
85
|
+
* @returns {string} 空串 = 不注入
|
|
86
|
+
*/
|
|
87
|
+
export function renderChoroBlock(outline, tier, language = "zh") {
|
|
88
|
+
if (!outline || !Array.isArray(outline.modules) || outline.modules.length === 0) return "";
|
|
89
|
+
const lang = language === "en" ? "en" : "zh";
|
|
90
|
+
const cap = MAX_BLOCK_CHARS[tier] ?? MAX_BLOCK_CHARS.light;
|
|
91
|
+
const picked = pickModules(outline.modules, tier);
|
|
92
|
+
|
|
93
|
+
const lines = [
|
|
94
|
+
STR.choroBlockHeader[lang](outline.repo.name, outline.modules.length, dateOnly(outline.generatedAt))
|
|
95
|
+
];
|
|
96
|
+
for (const mod of picked.shown) {
|
|
97
|
+
lines.push(STR.choroBlockLine[lang](mod.title, mod.fileCount ?? 0, statusLabel(mod.status, lang), mod.relPath ?? ""));
|
|
98
|
+
}
|
|
99
|
+
if (picked.rest > 0) lines.push(STR.choroBlockMore[lang](picked.rest));
|
|
100
|
+
|
|
101
|
+
// 逐行累加而不是拼完再切:切在行中间会让最后一条模块行变成半句话,
|
|
102
|
+
// 模型读到的「路径」就可能是断的——宁可少一行,不可半行。
|
|
103
|
+
const kept = [];
|
|
104
|
+
let used = 0;
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
if (used + line.length + 1 > cap) break;
|
|
107
|
+
kept.push(line);
|
|
108
|
+
used += line.length + 1;
|
|
109
|
+
}
|
|
110
|
+
if (kept.length <= 1) return "";
|
|
111
|
+
return kept.join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 注入器(给 src/inject.js 用)。
|
|
116
|
+
*
|
|
117
|
+
* 缓存键用 **outline.generatedAt**(页级生成时间的最大值),不是 choro_repos.
|
|
118
|
+
* updated_at:T1 富化只推 choro_pages.generated_at,不动仓库行——若按仓库行做键,
|
|
119
|
+
* 用户刚在终端跑完 `--t1 --apply`,注入块还写着「骨架」,而 /choro(无缓存)已经
|
|
120
|
+
* 报「已富化」。两个面说同一件事却给出不同答案,比慢一点糟得多。
|
|
121
|
+
* 每轮仍查一次库取提纲(一次索引查询,几十微秒,不 fork git、不 stat 磁盘),
|
|
122
|
+
* 只是**渲染**走缓存——省的是拼字符串,不是正确性。
|
|
123
|
+
*
|
|
124
|
+
* @param {object} opts.choro createChoroQueries() 的产物(注入侧只读它)
|
|
125
|
+
* @param {object} opts.config 合并后的配置
|
|
126
|
+
* @param {object} [opts.logger]
|
|
127
|
+
* @param {() => string} [opts.language] 语言取值器(跟随配置,不在创建时定死)
|
|
128
|
+
* @returns {{ text: (cwd: string|null|undefined) => string, tier: string, dispose: () => void }}
|
|
129
|
+
*/
|
|
130
|
+
export function createChoroInject({ choro, config, logger = null, language = null } = {}) {
|
|
131
|
+
const langOf = typeof language === "function" ? language : () => "zh";
|
|
132
|
+
const tier = resolveInjectTier(config);
|
|
133
|
+
let cache = null; // { key, text }
|
|
134
|
+
|
|
135
|
+
function text(cwd) {
|
|
136
|
+
if (tier === "off" || !choro) return "";
|
|
137
|
+
try {
|
|
138
|
+
const repo = typeof choro.repoByCwd === "function" ? choro.repoByCwd(cwd) : null;
|
|
139
|
+
if (!repo) return "";
|
|
140
|
+
const lang = langOf() === "en" ? "en" : "zh";
|
|
141
|
+
const outline = typeof choro.outline === "function" ? choro.outline(repo.id) : null;
|
|
142
|
+
// 键取页级生成时间:T0 重算与 T1 富化都会推进它,两种「地志变了」都命中。
|
|
143
|
+
const key = tier + "|" + lang + "|" + repo.id + "|" + (outline?.generatedAt ?? repo.updated_at ?? "");
|
|
144
|
+
if (cache && cache.key === key) return cache.text;
|
|
145
|
+
// 转义只在这一层做:/choro 的 UI 回执也调 renderChoroBlock,但它不进宿主模板,
|
|
146
|
+
// 转义了反而会显示成一串反斜杠。这里的 config 与 inject.js 同一份开关。
|
|
147
|
+
const rendered = escapePromptVars(config, renderChoroBlock(outline, tier, lang));
|
|
148
|
+
cache = { key, text: rendered };
|
|
149
|
+
return rendered;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
// P31:注入永远不抛。日志留一次线索,但本帧就当没有地志。
|
|
152
|
+
logger?.warn?.("[dsh-mneme] chorography 注入块渲染失败,本帧跳过:" + String(error?.message ?? error));
|
|
153
|
+
return "";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
text,
|
|
159
|
+
tier,
|
|
160
|
+
dispose() {
|
|
161
|
+
cache = null;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// dsh-mneme/src/wiki/partition.js
|
|
2
|
+
// 模块划分(spec §6 步 4):目录为初稿,用图的内聚度做合并,超 max_files_per_page
|
|
3
|
+
// 再逐级下切。全流程确定性:同输入两次划分给出逐项相同的结果(spec §9 的可重建
|
|
4
|
+
// 不变量要求划分本身也稳定)。
|
|
5
|
+
//
|
|
6
|
+
// 启发式只有两条、都便宜到可解释(V2 要人判质量,判据必须能口述):
|
|
7
|
+
// 合并——同一父目录下的两个兄弟组,互相指向的内部边 ≥ MERGE_EDGE_MIN 且合并不超
|
|
8
|
+
// 上限时并成一组(典型收益:utils/ 与它唯一的消费者;典型损失:两个互不相干的
|
|
9
|
+
// 小目录被文件夹布局凑到一起,代价是页变大,不会错分语义)。
|
|
10
|
+
// 下切——超上限的组按下一级目录段切开;直属文件自成一组;叶子组仍超上限时按
|
|
11
|
+
// 文件名字典序切块(此时语义已无从谈起,保住「每页不超过上限」的契约)。
|
|
12
|
+
import { posix } from "node:path";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
const MERGE_EDGE_MIN = 3;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string[]} files 仓库相对路径(已排序)
|
|
19
|
+
* @param {{edges: {from:string,to:string}[], external: Map<string,Set<string>>}} graph scan 的产物
|
|
20
|
+
* @param {number} maxFilesPerPage plan.yaml 的上限
|
|
21
|
+
* @returns {Array<{slug,title,dir,fileCount,files,parentSlug,internalEdges,externalDeps,fingerprint}>}
|
|
22
|
+
*/
|
|
23
|
+
export function partitionModules({ files, graph, maxFilesPerPage, repoName }) {
|
|
24
|
+
const cap = Math.max(1, Math.floor(maxFilesPerPage));
|
|
25
|
+
|
|
26
|
+
// 步 1:目录初稿——前两级目录段;根直属文件一组。
|
|
27
|
+
const groups = new Map();
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
const key = groupKeyOf(file);
|
|
30
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
31
|
+
groups.get(key).push(file);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 步 2:兄弟组合并(单遍、按 key 字典序,确定性)。
|
|
35
|
+
mergeSiblingGroups(groups, graph.edges, cap);
|
|
36
|
+
|
|
37
|
+
// 步 3:超限下切。
|
|
38
|
+
const finalGroups = [];
|
|
39
|
+
for (const key of [...groups.keys()].sort()) {
|
|
40
|
+
splitOversized(key, groups.get(key), cap, finalGroups);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 步 4:slug / title / 父链 / 边聚合。
|
|
44
|
+
const modules = finalGroups
|
|
45
|
+
.map((g) => materialize(g, graph))
|
|
46
|
+
.sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : a.slug < b.slug ? -1 : 1));
|
|
47
|
+
|
|
48
|
+
assignSlugsAndParents(modules, repoName);
|
|
49
|
+
for (const mod of modules) mod.fingerprint = moduleFingerprint(mod);
|
|
50
|
+
return modules;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function groupKeyOf(file) {
|
|
54
|
+
const dir = posix.dirname(file);
|
|
55
|
+
if (dir === ".") return "";
|
|
56
|
+
const segments = dir.split("/");
|
|
57
|
+
return segments.slice(0, 2).join("/");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function mergeSiblingGroups(groups, edges, cap) {
|
|
61
|
+
// 文件级边先折算到组级(两次查找的 Map,组数 = 目录数,量级无所谓)。
|
|
62
|
+
const cross = new Map(); // "a|b" -> count(a、b 为组 key,字典序小的在前)
|
|
63
|
+
for (const e of edges) {
|
|
64
|
+
const ga = groupKeyOf(e.from);
|
|
65
|
+
const gb = groupKeyOf(e.to);
|
|
66
|
+
if (ga === gb || !groups.has(ga) || !groups.has(gb)) continue;
|
|
67
|
+
const [x, y] = ga < gb ? [ga, gb] : [gb, ga];
|
|
68
|
+
const k = `${x}|${y}`;
|
|
69
|
+
cross.set(k, (cross.get(k) ?? 0) + 1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const keys = [...groups.keys()].sort();
|
|
73
|
+
for (const key of keys) {
|
|
74
|
+
if (!groups.has(key)) continue; // 已被并走
|
|
75
|
+
const parent = posix.dirname(key || "·");
|
|
76
|
+
for (const [pairKey, count] of [...cross.entries()].sort()) {
|
|
77
|
+
if (count < MERGE_EDGE_MIN) continue;
|
|
78
|
+
const [a, b] = pairKey.split("|");
|
|
79
|
+
if (a !== key) continue;
|
|
80
|
+
if (!groups.has(a) || !groups.has(b)) continue;
|
|
81
|
+
if (posix.dirname(a || "·") !== parent || posix.dirname(b || "·") !== parent) continue;
|
|
82
|
+
if (groups.get(a).length + groups.get(b).length > cap) continue;
|
|
83
|
+
// 并入字典序小的组;被并组从账上消失(cross 里的旧键自然失效)。
|
|
84
|
+
groups.set(a, [...groups.get(a), ...groups.get(b)].sort());
|
|
85
|
+
groups.delete(b);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function splitOversized(key, files, cap, out) {
|
|
91
|
+
if (files.length <= cap) {
|
|
92
|
+
out.push({ dir: key, files });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// 按下一级目录段细分;直属文件(无更深层)留在本组(subKey = key)。key 为空
|
|
96
|
+
// (根直属组)时 dirname 是 ".",嵌套文件直接用完整相对目录做子键——根组没有
|
|
97
|
+
// 「前缀可剥」。
|
|
98
|
+
const sub = new Map();
|
|
99
|
+
for (const file of files) {
|
|
100
|
+
const dir = posix.dirname(file);
|
|
101
|
+
let subKey;
|
|
102
|
+
if (key === "") {
|
|
103
|
+
subKey = dir === "." ? key : dir;
|
|
104
|
+
} else {
|
|
105
|
+
const rest = dir.startsWith(key + "/") ? dir.slice(key.length + 1) : "";
|
|
106
|
+
subKey = rest === "" ? key : `${key}/${rest.split("/")[0]}`;
|
|
107
|
+
}
|
|
108
|
+
if (!sub.has(subKey)) sub.set(subKey, []);
|
|
109
|
+
sub.get(subKey).push(file);
|
|
110
|
+
}
|
|
111
|
+
if (sub.size === 1) {
|
|
112
|
+
const [onlyKey] = sub.keys();
|
|
113
|
+
if (onlyKey === key) {
|
|
114
|
+
// 已是最深层目录、没有可下的子级:按文件名切块,块名带序号(语义无可言,保契约)。
|
|
115
|
+
chunkFiles(key, files, cap, out);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// 整组都在一个更深的子目录里:继续往下切。
|
|
119
|
+
splitOversized(onlyKey, sub.get(onlyKey), cap, out);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
for (const subKey of [...sub.keys()].sort()) {
|
|
123
|
+
splitOversized(subKey, sub.get(subKey), cap, out);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function chunkFiles(key, files, cap, out) {
|
|
128
|
+
for (let i = 0; i < files.length; i += cap) {
|
|
129
|
+
const slice = files.slice(i, i + cap);
|
|
130
|
+
out.push({ dir: i === 0 ? key : `${key}#${Math.floor(i / cap) + 1}`, files: slice });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function materialize(group, graph) {
|
|
135
|
+
const fileSet = new Set(group.files);
|
|
136
|
+
const internalEdges = graph.edges.filter((e) => fileSet.has(e.from) && fileSet.has(e.to));
|
|
137
|
+
const extCounts = new Map();
|
|
138
|
+
for (const [pkg, users] of graph.external) {
|
|
139
|
+
const n = [...users].filter((f) => fileSet.has(f)).length;
|
|
140
|
+
if (n > 0) extCounts.set(pkg, n);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
dir: group.dir,
|
|
144
|
+
files: group.files,
|
|
145
|
+
fileCount: group.files.length,
|
|
146
|
+
internalEdges,
|
|
147
|
+
externalDeps: [...extCounts.entries()]
|
|
148
|
+
.map(([pkg, count]) => ({ pkg, count }))
|
|
149
|
+
.sort((a, b) => (a.pkg < b.pkg ? -1 : a.pkg > b.pkg ? 1 : b.count - a.count)),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function assignSlugsAndParents(modules, repoName) {
|
|
154
|
+
// slug:目录段连字化;重复加序号。空目录段(根直属)用 "root"。
|
|
155
|
+
const used = new Map();
|
|
156
|
+
for (const mod of modules) {
|
|
157
|
+
const base = mod.dir === "" ? "root" : slugify(mod.dir);
|
|
158
|
+
const n = used.get(base) ?? 0;
|
|
159
|
+
used.set(base, n + 1);
|
|
160
|
+
mod.slug = n === 0 ? base : `${base}-${n + 1}`;
|
|
161
|
+
mod.title = titleOf(mod, repoName);
|
|
162
|
+
}
|
|
163
|
+
// 父链:slug 集里目录包含关系最近的祖先(重名块 #2 算同层)。
|
|
164
|
+
for (const mod of modules) {
|
|
165
|
+
mod.parentSlug = null;
|
|
166
|
+
let best = null;
|
|
167
|
+
for (const other of modules) {
|
|
168
|
+
if (other === mod) continue;
|
|
169
|
+
if (other.dir === "") continue;
|
|
170
|
+
if (mod.dir === other.dir || mod.dir === "") continue;
|
|
171
|
+
if (!mod.dir.startsWith(other.dir + "/")) continue;
|
|
172
|
+
if (best === null || other.dir.length > best.dir.length) best = other;
|
|
173
|
+
}
|
|
174
|
+
if (best) mod.parentSlug = best.slug;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function slugify(dir) {
|
|
179
|
+
return dir
|
|
180
|
+
.toLowerCase()
|
|
181
|
+
.replace(/[^a-z0-9/]+/g, "-")
|
|
182
|
+
.replace(/\/+/g, "-")
|
|
183
|
+
.replace(/^-+|-+$/g, "") || "root";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function titleOf(mod, repoName) {
|
|
187
|
+
if (mod.dir === "") return repoName ? `${repoName}(仓库根)` : "仓库根";
|
|
188
|
+
const segments = mod.dir.split("/");
|
|
189
|
+
const last = segments[segments.length - 1];
|
|
190
|
+
// 目录名直接当标题(保持原文大小写);切块组带序号(dir 里的 # 是切块标记,
|
|
191
|
+
// slugify 之后 slug 里已无 #,所以判断要看 dir)。
|
|
192
|
+
return mod.dir.includes("#") ? `${last.replace(/#\d+$/, "")}(续 ${mod.dir.match(/#(\d+)$/)?.[1] ?? ""})` : last;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function moduleFingerprint(mod) {
|
|
196
|
+
const hash = createHash("sha256");
|
|
197
|
+
for (const f of mod.files) hash.update(`f:${f}\n`);
|
|
198
|
+
for (const e of mod.internalEdges) hash.update(`e:${e.from}>${e.to}\n`);
|
|
199
|
+
return hash.digest("hex");
|
|
200
|
+
}
|