@modusensus/dsh-mneme 0.11.0 → 0.13.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 +54 -21
- package/docs/CONFIGURATION.md +45 -4
- package/docs/DIRECTORIES.md +114 -0
- package/docs/DISTILL.md +64 -0
- package/docs/DSH-GO.md +97 -0
- package/docs/ENTITIES.md +5 -3
- package/docs/FORGET.md +77 -0
- package/docs/KB.md +110 -0
- package/docs/SLEEP.md +28 -3
- package/docs/STORAGE.md +23 -0
- package/lib/api-standalone.js +4 -1
- package/lib/api.js +744 -26
- package/lib/attachment-text.js +122 -0
- package/lib/choro-command.js +9 -2
- package/lib/client.js +3752 -819
- package/lib/config.js +171 -2
- package/lib/continuity.js +7 -6
- package/lib/diary/auto.js +190 -0
- package/lib/diary/compose.js +440 -0
- package/lib/diary/dir.js +59 -0
- package/lib/diary/export.js +97 -0
- package/lib/diary/index.js +26 -0
- package/lib/diary/library.js +189 -0
- package/lib/diary/qq-inbox.js +142 -0
- package/lib/diary/rollover.js +61 -0
- package/lib/diary/session-provider.js +122 -0
- package/lib/document.js +9 -3
- package/lib/dream/clustering.js +6 -1
- package/lib/dream/decisions.js +56 -5
- package/lib/dream/sleep.js +616 -15
- package/lib/dream.js +45 -13
- package/lib/dsh-go/host.js +162 -0
- package/lib/dsh-go/panel.js +2091 -0
- package/lib/dsh-go/text.js +342 -0
- package/lib/dsh-go/views/common.js +68 -0
- package/lib/dsh-go/views/entity.js +48 -0
- package/lib/dsh-go/views/memory.js +198 -0
- package/lib/dsh-go/views/root.js +30 -0
- package/lib/dsh-go/views/settings.js +398 -0
- package/lib/dsh-go/views/status.js +51 -0
- package/lib/embedding.js +42 -8
- package/lib/entities/extractor.js +36 -7
- package/lib/hot-memory.js +8 -9
- package/lib/index.js +131 -5
- package/lib/inject.js +192 -19
- package/lib/kb/index.js +309 -0
- package/lib/kb/routes.js +220 -0
- package/lib/kb/tools.js +148 -0
- package/lib/lang.js +115 -4
- package/lib/peak-hours.js +57 -2
- package/lib/profile-facts.js +356 -0
- package/lib/semantic.js +347 -60
- package/lib/service.js +302 -70
- package/lib/settings.js +117 -14
- package/lib/store.js +830 -55
- package/lib/summarize.js +38 -11
- package/lib/tools.js +173 -25
- package/lib/user-settings.js +175 -23
- package/lib/vector-index.js +76 -6
- package/lib/wiki/cli-hint.js +90 -0
- package/lib/wiki/queries.js +264 -10
- package/lib/wiki/t1-jobs.js +232 -0
- package/lib/wiki/t1.js +7 -1
- package/package.json +25 -1
- package/scripts/sync-test-badge.mjs +1 -1
- package/scripts/test-count-sync.mjs +9 -5
- package/src/api-standalone.js +4 -1
- package/src/api.js +744 -26
- package/src/attachment-text.js +122 -0
- package/src/choro-command.js +9 -2
- package/src/config.js +171 -2
- package/src/continuity.js +7 -6
- package/src/diary/auto.js +190 -0
- package/src/diary/compose.js +440 -0
- package/src/diary/dir.js +59 -0
- package/src/diary/export.js +97 -0
- package/src/diary/index.js +26 -0
- package/src/diary/library.js +189 -0
- package/src/diary/qq-inbox.js +142 -0
- package/src/diary/rollover.js +61 -0
- package/src/diary/session-provider.js +122 -0
- package/src/document.js +9 -3
- package/src/dream/clustering.js +6 -1
- package/src/dream/decisions.js +56 -5
- package/src/dream/sleep.js +616 -15
- package/src/dream.js +45 -13
- package/src/dsh-go/host.js +162 -0
- package/src/dsh-go/panel.js +2091 -0
- package/src/dsh-go/text.js +342 -0
- package/src/dsh-go/views/common.js +68 -0
- package/src/dsh-go/views/entity.js +48 -0
- package/src/dsh-go/views/memory.js +198 -0
- package/src/dsh-go/views/root.js +30 -0
- package/src/dsh-go/views/settings.js +398 -0
- package/src/dsh-go/views/status.js +51 -0
- package/src/embedding.js +42 -8
- package/src/entities/extractor.js +36 -7
- package/src/hot-memory.js +8 -9
- package/src/index.js +131 -5
- package/src/inject.js +192 -19
- package/src/kb/index.js +309 -0
- package/src/kb/routes.js +220 -0
- package/src/kb/tools.js +148 -0
- package/src/lang.js +115 -4
- package/src/peak-hours.js +57 -2
- package/src/profile-facts.js +356 -0
- package/src/semantic.js +347 -60
- package/src/service.js +302 -70
- package/src/settings.js +117 -14
- package/src/store.js +830 -55
- package/src/summarize.js +38 -11
- package/src/tools.js +173 -25
- package/src/user-settings.js +175 -23
- package/src/vector-index.js +76 -6
- package/src/wiki/cli-hint.js +90 -0
- package/src/wiki/queries.js +264 -10
- package/src/wiki/t1-jobs.js +232 -0
- package/src/wiki/t1.js +7 -1
- package/test/api-routes-gaps.test.js +63 -0
- package/test/api.test.js +377 -4
- package/test/attachment-text.test.js +140 -0
- package/test/bm25-corpus-cache.test.js +145 -0
- package/test/capacity-warning.test.js +262 -0
- package/test/client.test.js +908 -138
- package/test/config.test.js +79 -0
- package/test/conflict-freeze.test.js +45 -1
- package/test/continuity.test.js +8 -1
- package/test/diary-library.test.js +187 -0
- package/test/diary.test.js +576 -0
- package/test/document.test.js +32 -0
- package/test/dream-maxtokens-fallback.test.js +3 -3
- package/test/dream.test.js +62 -1
- package/test/dsh-go-host.test.js +134 -0
- package/test/dsh-go-panel-actions.test.js +854 -0
- package/test/dsh-go-panel.test.js +251 -0
- package/test/embedding-cache-f32.test.js +276 -0
- package/test/embedding-ready.test.js +33 -3
- package/test/entities.test.js +73 -0
- package/test/forget-corrections.test.js +70 -0
- package/test/image-memory.test.js +171 -0
- package/test/inject-pin-explicit.test.js +300 -0
- package/test/inject-pin-vector-crowd.test.js +225 -0
- package/test/issue-617-step1-query-and-fusion.test.js +361 -0
- package/test/kb-api-wiring.test.js +128 -0
- package/test/kb-collections.test.js +272 -0
- package/test/kb-routes.test.js +379 -0
- package/test/mint-source.test.js +26 -0
- package/test/peak-hours-observability.test.js +70 -0
- package/test/persona-api.test.js +3 -3
- package/test/persona-by-session.test.js +568 -0
- package/test/persona-settings.test.js +38 -7
- package/test/profile-facts.test.js +492 -0
- package/test/reflection-update-gate.test.js +129 -0
- package/test/reindex-backfill.test.js +334 -0
- package/test/reindex-scheduler.test.js +369 -0
- package/test/scope-candidates.test.js +249 -0
- package/test/session-gate.test.js +1 -1
- package/test/sleep-action-set.test.js +14 -0
- package/test/sleep-heat.test.js +49 -0
- package/test/sleep.test.js +598 -0
- package/test/standalone-api.test.js +24 -0
- package/test/store.test.js +20 -0
- package/test/summarize.test.js +26 -0
- package/test/test-count-sync.test.js +14 -3
- package/test/tool-state-ignore.test.js +65 -0
- package/test/tools-dto-schema-matrix.test.js +23 -2
- package/test/tools.test.js +144 -7
- package/test/user-settings-segments.test.js +528 -0
- package/test/vector-index.test.js +120 -6
- package/test/wiki-api.test.js +476 -1
- package/test/wiki-cli-hint.test.js +118 -0
- package/test/wiki-queries.test.js +91 -1
- package/test/wiki-t1-jobs.test.js +125 -0
- package/test/wiki-tools.test.js +131 -76
package/src/wiki/queries.js
CHANGED
|
@@ -1,12 +1,49 @@
|
|
|
1
1
|
// dsh-mneme/src/wiki/queries.js
|
|
2
2
|
// chorography 的读侧(M1):仓库列表 / 模块图 / 页面正文 / 关键词检索。
|
|
3
|
-
//
|
|
3
|
+
// 收尾档追加:向量检索(P37–P39,惰性物化 + cosine)。
|
|
4
|
+
// 检索读侧零写路径的例外只有一处——向量物化写 choro_vectors(P37:检索时惰性
|
|
5
|
+
// 补嵌,host 侧才有 embedder;那是向量账本,不是页面真源)。
|
|
4
6
|
//
|
|
5
7
|
// 真源纪律(spec §4 / P12):页面正文永远读仓库磁盘的 MD(root_path + DB 里的
|
|
6
8
|
// rel_path),库里的表只是索引与状态账。slug 只从 DB 白名单解析,路径拼装零用户
|
|
7
9
|
// 输入——面板与工具传进来的 slug 查不到模块行就返回 null,绝不用它拼路径。
|
|
8
10
|
import { readFileSync, existsSync, realpathSync } from "node:fs";
|
|
9
11
|
import { join, resolve, sep } from "node:path";
|
|
12
|
+
// 过期判定复用既有三件真源(t0 的 gitHead、plan 的指纹、render 的模板版本),
|
|
13
|
+
// 面板侧不另造一套判据——两套判据迟早会对同一仓库给出两个答案。
|
|
14
|
+
import { gitHead } from "./t0.js";
|
|
15
|
+
import { loadPlanFromRepo } from "./plan.js";
|
|
16
|
+
import { TEMPLATE_VERSION } from "./render.js";
|
|
17
|
+
|
|
18
|
+
// 页正文去 frontmatter / CHORO 标记行后作为嵌入文本(P40:嵌页级全文)。
|
|
19
|
+
function pageEmbedText(content) {
|
|
20
|
+
const lines = String(content ?? "").split("\n");
|
|
21
|
+
let start = 0;
|
|
22
|
+
if (lines[0]?.trim() === "---") {
|
|
23
|
+
start = 1;
|
|
24
|
+
while (start < lines.length && lines[start].trim() !== "---") start++;
|
|
25
|
+
start++;
|
|
26
|
+
}
|
|
27
|
+
return lines
|
|
28
|
+
.slice(start)
|
|
29
|
+
.filter((l) => !l.startsWith("<!-- CHORO:T1:") && !l.startsWith("<!-- CHORO:HUMAN:"))
|
|
30
|
+
.join("\n")
|
|
31
|
+
.trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cosine(a, b) {
|
|
35
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || a.length === 0) return 0;
|
|
36
|
+
let dot = 0;
|
|
37
|
+
let na = 0;
|
|
38
|
+
let nb = 0;
|
|
39
|
+
for (let i = 0; i < a.length; i++) {
|
|
40
|
+
dot += a[i] * b[i];
|
|
41
|
+
na += a[i] * a[i];
|
|
42
|
+
nb += b[i] * b[i];
|
|
43
|
+
}
|
|
44
|
+
const denom = Math.sqrt(na) * Math.sqrt(nb);
|
|
45
|
+
return denom === 0 ? 0 : dot / denom;
|
|
46
|
+
}
|
|
10
47
|
|
|
11
48
|
export function createChoroQueries({ store }) {
|
|
12
49
|
const db = store.db;
|
|
@@ -17,12 +54,114 @@ export function createChoroQueries({ store }) {
|
|
|
17
54
|
`SELECT r.id, r.root_path, r.name, r.plan_fingerprint, r.git_baseline, r.updated_at,
|
|
18
55
|
(SELECT COUNT(*) FROM choro_modules m WHERE m.repo_id = r.id) AS module_count,
|
|
19
56
|
(SELECT COUNT(*) FROM choro_pages p JOIN choro_modules m2 ON p.module_id = m2.id
|
|
20
|
-
WHERE m2.repo_id = r.id) AS page_count
|
|
57
|
+
WHERE m2.repo_id = r.id) AS page_count,
|
|
58
|
+
(SELECT COUNT(*) FROM choro_claims c
|
|
59
|
+
JOIN choro_pages p2 ON c.page_id = p2.id
|
|
60
|
+
JOIN choro_modules m3 ON p2.module_id = m3.id
|
|
61
|
+
WHERE m3.repo_id = r.id) AS claim_count
|
|
21
62
|
FROM choro_repos r ORDER BY r.id`
|
|
22
63
|
).all();
|
|
23
64
|
return rows;
|
|
24
65
|
}
|
|
25
66
|
|
|
67
|
+
/**
|
|
68
|
+
* 面板仓库列表(契约 §3.2):在 listRepos 的行上补契约要求的字段。
|
|
69
|
+
*
|
|
70
|
+
* 为什么另开一个函数而不是改 listRepos 的返回形状:listRepos 的
|
|
71
|
+
* module_count / page_count 被工具层与查询层测试锁着(下划线命名),改名就是
|
|
72
|
+
* 破坏既有调用方;而契约要的是 modules / pages 这样的对外字段。两套名字并存
|
|
73
|
+
* 难看,但改名的连带面比难看贵得多——装饰层把它包一层,工具层照旧读原始行。
|
|
74
|
+
*
|
|
75
|
+
* 每个仓库都要 fork 一次 git 判过期:这是用户点开面板才走的路(不是每轮注入的
|
|
76
|
+
* 热路径),一次 rev-parse 几十毫秒在交互里不可感知。
|
|
77
|
+
*/
|
|
78
|
+
/**
|
|
79
|
+
* 契约字段的仓库对象(stale 由调用方决定是否重算)。
|
|
80
|
+
*
|
|
81
|
+
* 注意区分两个入口:repoFacts 每次都会 fork git 判过期;repoFromRow 是纯行投影,
|
|
82
|
+
* 给「已经知道过期原因、手上有判定结果」的调用方用(例如自动重跑之后要紧着按
|
|
83
|
+
* 新基线说话,不能再拿旧行算一遍)。混用会得到自相矛盾的回执。
|
|
84
|
+
*/
|
|
85
|
+
function repoFromRow(row) {
|
|
86
|
+
const rootPath = String(row.root_path ?? "");
|
|
87
|
+
return {
|
|
88
|
+
id: row.id,
|
|
89
|
+
name: row.name,
|
|
90
|
+
rootPath,
|
|
91
|
+
modules: Number(row.module_count ?? 0),
|
|
92
|
+
pages: Number(row.page_count ?? 0),
|
|
93
|
+
claims: Number(row.claim_count ?? 0),
|
|
94
|
+
// legacy = 这个仓库里有页面是用旧模板(或 M0 的 NULL)渲染的:重跑一次 T0 就
|
|
95
|
+
// 会把它们升级成当前模板,面板据此提示「建议重建」而不是让用户自己比对版本。
|
|
96
|
+
legacy: legacyPages(row.id) > 0,
|
|
97
|
+
lastRunAt: row.updated_at ?? null
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function legacyPages(repoId) {
|
|
102
|
+
const hit = db.prepare(
|
|
103
|
+
`SELECT COUNT(*) AS n FROM choro_pages p JOIN choro_modules m ON p.module_id = m.id
|
|
104
|
+
WHERE m.repo_id = ? AND (p.template_version IS NULL OR p.template_version <> ?)`
|
|
105
|
+
).get(repoId, TEMPLATE_VERSION);
|
|
106
|
+
return Number(hit?.n ?? 0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function repoFacts(row) {
|
|
110
|
+
const rootPath = String(row.root_path ?? "");
|
|
111
|
+
// git 优先:代码变了是更硬的「该重跑」信号,plan 变了才退而求其次——两者同时
|
|
112
|
+
// 成立时面板只说一句话,说「git」比说「plan」更贴近用户看到的现象(他刚改了码)。
|
|
113
|
+
const head = rootPath === "" ? null : gitHead(rootPath);
|
|
114
|
+
const baseline = String(row.git_baseline ?? "").trim();
|
|
115
|
+
let staleReason = null;
|
|
116
|
+
if (head && head !== baseline) {
|
|
117
|
+
staleReason = "git";
|
|
118
|
+
} else if (rootPath !== "") {
|
|
119
|
+
// 读不到 git(head 为 null)时不直接判「没过期」:plan.yaml 可能已经被改过,
|
|
120
|
+
// 那条判据不依赖 git,仍能给出答案。
|
|
121
|
+
let planFingerprintNow = null;
|
|
122
|
+
try { planFingerprintNow = loadPlanFromRepo(rootPath).fingerprint; } catch { planFingerprintNow = null; }
|
|
123
|
+
// plan.yaml 校验不过时 loadPlanFromRepo 会抛——那是「现在读不出结论」,不是
|
|
124
|
+
// 「过期了」,所以落 null 保持 stale=false,让重建按钮自己去报那条 YAML 错误。
|
|
125
|
+
if (planFingerprintNow && planFingerprintNow !== String(row.plan_fingerprint ?? "")) {
|
|
126
|
+
staleReason = "plan";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
...repoFromRow(row),
|
|
131
|
+
stale: staleReason !== null,
|
|
132
|
+
staleReason
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 单仓的原始 listRepos 行(自动重跑之后要拿推进过的基线,不能再用旧行)。 */
|
|
137
|
+
function listRepoRow(repoId) {
|
|
138
|
+
return listRepos().find((r) => Number(r.id) === Number(repoId)) ?? null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 契约形状的仓库列表:listRepos 的原始行 → 对外字段。 */
|
|
142
|
+
function listRepoFacts() {
|
|
143
|
+
return listRepos().map((row) => repoFacts(row));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 仓库的契约字段(按 id/name,给 /choro/page 这类已知单仓的读路径用)。 */
|
|
147
|
+
function repoFactsById(repoId) {
|
|
148
|
+
const row = listRepoRow(repoId);
|
|
149
|
+
return row ? repoFacts(row) : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 按绝对路径找已登记仓库(面板「选目录 → 生成」必需):T0 apply 落库时存的是
|
|
154
|
+
* realpath,所以这里也必须先 realpath 再比,否则 Windows 上盘符大小写/短名/符号
|
|
155
|
+
* 链接会让同一个目录被当成新仓库重复登记一次。
|
|
156
|
+
*/
|
|
157
|
+
function repoByRoot(rootPath) {
|
|
158
|
+
const raw = String(rootPath ?? "").trim();
|
|
159
|
+
if (raw === "") return null;
|
|
160
|
+
let normalized;
|
|
161
|
+
try { normalized = realpathSync(resolve(raw)); } catch { return null; }
|
|
162
|
+
return db.prepare("SELECT * FROM choro_repos WHERE root_path = ?").get(normalized) ?? null;
|
|
163
|
+
}
|
|
164
|
+
|
|
26
165
|
/** repo 解析:数字 id 或显示名(root_path 是本机私有路径,不作为对外键)。 */
|
|
27
166
|
function resolveRepo(ref) {
|
|
28
167
|
if (ref === undefined || ref === null || ref === "") {
|
|
@@ -50,12 +189,18 @@ export function createChoroQueries({ store }) {
|
|
|
50
189
|
`SELECT from_slug, to_slug, weight FROM choro_edges WHERE repo_id = ?
|
|
51
190
|
ORDER BY from_slug, to_slug`
|
|
52
191
|
).all(repoId);
|
|
192
|
+
// 纯加法补一个 camelCase 的 relPath:面板从地志页导入知识点要拼「根路径 + 页相对
|
|
193
|
+
// 路径」,而这里历史上直出 SQL 行的 snake_case(page_rel_path)。C 线按契约读的是
|
|
194
|
+
// mod.relPath,字段一缺就恒 undefined,用户选完模块点导入只会看到「请先选模块」。
|
|
195
|
+
// 原字段一律保留——src/tools.js 的 render 与 choro-command 都还在吃 file_count /
|
|
196
|
+
// page_status,改形状会波及它们;outline() 里的同名映射是这件事的正确写法。
|
|
197
|
+
for (const m of modules) m.relPath = m.page_rel_path ?? null;
|
|
53
198
|
return { modules, edges };
|
|
54
199
|
}
|
|
55
200
|
|
|
56
201
|
/**
|
|
57
|
-
* 读一页正文。slug 必须命中 DB 的模块行(P12
|
|
58
|
-
*
|
|
202
|
+
* 读一页正文。slug 必须命中 DB 的模块行(P12:路径零用户输入);索引仍在但
|
|
203
|
+
* 文件缺失或不可读时带回相对路径,让调用方给出对应的恢复提示。
|
|
59
204
|
*/
|
|
60
205
|
function readPage(repoId, slug) {
|
|
61
206
|
const row = db.prepare(
|
|
@@ -67,7 +212,13 @@ export function createChoroQueries({ store }) {
|
|
|
67
212
|
if (!row) return null;
|
|
68
213
|
const repo = db.prepare("SELECT root_path, name FROM choro_repos WHERE id = ?").get(repoId);
|
|
69
214
|
const abs = join(repo.root_path, row.rel_path);
|
|
70
|
-
|
|
215
|
+
let content;
|
|
216
|
+
try {
|
|
217
|
+
content = readFileSync(abs, "utf8");
|
|
218
|
+
} catch (cause) {
|
|
219
|
+
const error = cause.code === "ENOENT" || cause.code === "ENOTDIR" ? "page-missing" : "page-unreadable";
|
|
220
|
+
return { error, slug: row.slug, relPath: row.rel_path };
|
|
221
|
+
}
|
|
71
222
|
return {
|
|
72
223
|
slug: row.slug,
|
|
73
224
|
title: row.title,
|
|
@@ -79,16 +230,17 @@ export function createChoroQueries({ store }) {
|
|
|
79
230
|
humanBlockCount: row.human_block_count,
|
|
80
231
|
repoName: repo.name,
|
|
81
232
|
relPath: row.rel_path,
|
|
82
|
-
content
|
|
233
|
+
content,
|
|
83
234
|
};
|
|
84
235
|
}
|
|
85
236
|
|
|
86
237
|
/**
|
|
87
238
|
* 关键词检索(P10):按 `## ` 分节做大小写不敏感子串匹配,命中返回页 slug、
|
|
88
239
|
* 节标题、起始行号与摘录。只扫 DB 里登记过的页面文件(不是全目录 glob——
|
|
89
|
-
*
|
|
240
|
+
* 划分变更后未重算的孤儿文件不该被搜出来)。需要提示检索不完整的调用方可接收
|
|
241
|
+
* 不可读的页 slug。q 为空返回空数组不报错。
|
|
90
242
|
*/
|
|
91
|
-
function search(repoId, q, { moduleSlug = null, limit = 20 } = {}) {
|
|
243
|
+
function search(repoId, q, { moduleSlug = null, limit = 20, onUnreadablePage } = {}) {
|
|
92
244
|
const needle = String(q ?? "").trim().toLowerCase();
|
|
93
245
|
if (needle === "") return [];
|
|
94
246
|
const cap = Math.max(1, Math.min(100, Number(limit) || 20));
|
|
@@ -102,11 +254,11 @@ export function createChoroQueries({ store }) {
|
|
|
102
254
|
const hits = [];
|
|
103
255
|
for (const page of pages) {
|
|
104
256
|
const abs = join(repo.root_path, page.rel_path);
|
|
105
|
-
if (!existsSync(abs)) continue;
|
|
106
257
|
let text;
|
|
107
258
|
try {
|
|
108
259
|
text = readFileSync(abs, "utf8");
|
|
109
260
|
} catch {
|
|
261
|
+
onUnreadablePage?.(page.slug);
|
|
110
262
|
continue;
|
|
111
263
|
}
|
|
112
264
|
const lines = text.split("\n");
|
|
@@ -216,5 +368,107 @@ export function createChoroQueries({ store }) {
|
|
|
216
368
|
};
|
|
217
369
|
}
|
|
218
370
|
|
|
219
|
-
|
|
371
|
+
// --- chorography 向量(收尾档 P37–P39)---------------------------------
|
|
372
|
+
// 惰性物化(P37):只在 host 侧检索路径调用(embedder 由 index.js 注入);
|
|
373
|
+
// CLI 环境没有 embedder → 向量永不物化,检索退关键词。staleness 两维:
|
|
374
|
+
// 页 generated_at 变了(内容变了)、行 dim ≠ 本次查询向量 dim(换 embedder
|
|
375
|
+
// 模型了,P38)→ 重嵌。dim 失配是全表性的,遇到一次就把整仓重嵌(几十页)。
|
|
376
|
+
async function embedPageVectors(repoId, embedder, { limit = 200, expectDim = null } = {}) {
|
|
377
|
+
const pages = db.prepare(
|
|
378
|
+
`SELECT p.id, p.generated_at, m.slug, m.title, p.rel_path
|
|
379
|
+
FROM choro_pages p JOIN choro_modules m ON p.module_id = m.id
|
|
380
|
+
WHERE m.repo_id = ? ORDER BY m.slug`
|
|
381
|
+
).all(repoId);
|
|
382
|
+
const repo = db.prepare("SELECT root_path FROM choro_repos WHERE id = ?").get(repoId);
|
|
383
|
+
const embedOne = embedder.embedSingle ? (t) => embedder.embedSingle(t) : (t) => embedder.embed(t);
|
|
384
|
+
const rows = store.getChoroPageVectors(repoId);
|
|
385
|
+
const bySlug = new Map(rows.map((r) => [r.slug, r]));
|
|
386
|
+
const result = { embedded: 0, skipped: 0, stale: 0, failed: [], dimMismatch: false };
|
|
387
|
+
// dim 失配的全量重嵌(P38):expectDim 来自查询向量(调用方先嵌查询再物化,
|
|
388
|
+
// 维度免费拿到)。expectDim 与任何现存行失配 → 本轮 fresh 检查失效,全量重嵌;
|
|
389
|
+
// 没给 expectDim(无查询场景)退化为只按 generated_at 判 staleness。
|
|
390
|
+
const dimDrift = expectDim !== null && rows.some((r) => r.dim !== expectDim);
|
|
391
|
+
if (dimDrift) result.dimMismatch = true;
|
|
392
|
+
for (const page of pages) {
|
|
393
|
+
if (result.embedded >= limit) break;
|
|
394
|
+
const row = bySlug.get(page.slug);
|
|
395
|
+
const fresh = !dimDrift && row && row.source_generated_at === page.generated_at && row.vector;
|
|
396
|
+
if (fresh) { result.skipped++; continue; }
|
|
397
|
+
if (row && row.vector) result.stale++;
|
|
398
|
+
const abs = join(repo.root_path, page.rel_path);
|
|
399
|
+
if (!existsSync(abs)) continue;
|
|
400
|
+
let text;
|
|
401
|
+
try {
|
|
402
|
+
text = pageEmbedText(readFileSync(abs, "utf8"));
|
|
403
|
+
} catch {
|
|
404
|
+
result.failed.push({ slug: page.slug, reason: "page-unreadable" });
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (text.trim() === "") { result.skipped++; continue; }
|
|
408
|
+
let vector;
|
|
409
|
+
try {
|
|
410
|
+
vector = await embedOne(text);
|
|
411
|
+
} catch (err) {
|
|
412
|
+
result.failed.push({ slug: page.slug, reason: String(err?.message ?? err).slice(0, 120) });
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (!Array.isArray(vector) || vector.length === 0) {
|
|
416
|
+
result.failed.push({ slug: page.slug, reason: "embedder-returned-no-vector" });
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (row && row.dim !== vector.length) result.dimMismatch = true;
|
|
420
|
+
store.saveChoroPageVector(page.id, {
|
|
421
|
+
vector,
|
|
422
|
+
dim: vector.length,
|
|
423
|
+
model: String(embedder.model ?? "choro"),
|
|
424
|
+
sourceGeneratedAt: page.generated_at,
|
|
425
|
+
createdAt: new Date().toISOString(),
|
|
426
|
+
});
|
|
427
|
+
result.embedded++;
|
|
428
|
+
}
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** 嵌查询文本(宿主侧)。embedder 缺席 / 不可用 → null。 */
|
|
433
|
+
async function embedQueryText(embedder, q) {
|
|
434
|
+
if (!embedder) return null;
|
|
435
|
+
const embedOne = embedder.embedSingle ? (t) => embedder.embedSingle(t) : (t) => embedder.embed(t);
|
|
436
|
+
try {
|
|
437
|
+
const v = await embedOne(String(q ?? ""));
|
|
438
|
+
return Array.isArray(v) && v.length > 0 ? v : null;
|
|
439
|
+
} catch {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** 纯余弦 Top-N:向量已给定时零嵌入。行 dim 与查询失配的行过滤掉(P38)。 */
|
|
445
|
+
function semanticTopN(repoId, queryVector, { limit = 10 } = {}) {
|
|
446
|
+
if (!Array.isArray(queryVector) || queryVector.length === 0) return [];
|
|
447
|
+
const rows = store.getChoroPageVectors(repoId).filter((r) => r.vector && r.dim === queryVector.length);
|
|
448
|
+
return rows
|
|
449
|
+
.map((r) => ({
|
|
450
|
+
slug: r.slug,
|
|
451
|
+
title: r.title,
|
|
452
|
+
score: Number(cosine(queryVector, JSON.parse(r.vector)).toFixed(4)),
|
|
453
|
+
}))
|
|
454
|
+
.sort((a, b) => b.score - a.score)
|
|
455
|
+
.slice(0, Math.max(1, Math.min(50, Number(limit) || 10)));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** 便捷组合:嵌查询 + Top-N(P39:独立数组,不与 keyword hits 混排序)。
|
|
459
|
+
* 空查询 → [](空白查询的「语义」是噪声,面板逐键触发时尤其如此);
|
|
460
|
+
* embedder 缺席 → null(调用方退纯关键词)。 */
|
|
461
|
+
async function searchSemantic(repoId, embedder, q, { limit = 10 } = {}) {
|
|
462
|
+
if (!embedder) return null;
|
|
463
|
+
if (String(q ?? "").trim() === "") return [];
|
|
464
|
+
const queryVector = await embedQueryText(embedder, q);
|
|
465
|
+
if (!queryVector) return [];
|
|
466
|
+
return semanticTopN(repoId, queryVector, { limit });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
listRepos, listRepoFacts, repoFacts, repoFactsById, repoFromRow, listRepoRow, repoByRoot,
|
|
471
|
+
resolveRepo, moduleMap, readPage, search, repoByCwd, outline,
|
|
472
|
+
embedPageVectors, searchSemantic, embedQueryText, semanticTopN,
|
|
473
|
+
};
|
|
220
474
|
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// dsh-mneme/src/wiki/t1-jobs.js
|
|
2
|
+
// T1 面板后台任务的进程账与状态机(契约 §3.2)。
|
|
3
|
+
//
|
|
4
|
+
// 为什么住在这里而不是 api.js:T1 要 spawn 子进程,而 api.js 刻意只依赖少数 node
|
|
5
|
+
// 内置(不 import child_process)——HTTP 面只做参数解析与闸门分叉,进程生命周期与
|
|
6
|
+
// 状态账归 wiki 层。副作用是单测可以在 API 侧注入假 job 对象,永不真跑一次 T1
|
|
7
|
+
// (真跑要花模型额度,测试里花钱是不可接受的)。
|
|
8
|
+
//
|
|
9
|
+
// 为什么 spawn CLI 而不是宿主进程内直接 createT1():单模块 30–240s,挂在宿主事件
|
|
10
|
+
// 循环上会拖住整个插件;而 bin/dsh-mneme-choro.mjs --t1 已经是那条唯一收费入口,
|
|
11
|
+
// 面板再走一遍编排逻辑等于造第二条路径,两边迟早不一致(收据形状、降级语义都会漂)。
|
|
12
|
+
//
|
|
13
|
+
// stdout 契约(防一类回归):CLI 的 --json 用 JSON.stringify(receipt, null, 2) 输出
|
|
14
|
+
// ——**多行 JSON,不是 NDJSON**。所以 ring buffer 按行收(面板要的是人能看的日志尾巴),
|
|
15
|
+
// 回执解析另留一份可截断的全文,别指望从 200 行里拼出完整 JSON。
|
|
16
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
// lib/wiki/t1-jobs.js 与 src/wiki/t1-jobs.js 到 bin/ 都是上两级:sync 只换目录名,
|
|
22
|
+
// 深度不变,所以这里可以硬编码相对位置。
|
|
23
|
+
const DEFAULT_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "bin", "dsh-mneme-choro.mjs");
|
|
24
|
+
|
|
25
|
+
// 日志尾巴的上限取 200 行:面板是个窄弹层,再多也没人往下翻;而上限存在的真理由
|
|
26
|
+
// 是长任务会往 stdout 泼几万行,不封顶就是内存泄漏。
|
|
27
|
+
const DEFAULT_RING = 200;
|
|
28
|
+
// 已完成任务保留条数:status 要能被反复轮询(面板 2s 一次),但也不能无限留。
|
|
29
|
+
const DEFAULT_FINISHED = 20;
|
|
30
|
+
|
|
31
|
+
/** 逐行切分:chunk 边界可能落在一行中间,所以每路各留一个未完成余量。 */
|
|
32
|
+
function makeLineSplitter(onLine) {
|
|
33
|
+
let rest = "";
|
|
34
|
+
return {
|
|
35
|
+
push(chunk) {
|
|
36
|
+
rest += chunk;
|
|
37
|
+
const parts = rest.split(/\r?\n/);
|
|
38
|
+
rest = parts.pop() ?? "";
|
|
39
|
+
for (const line of parts) onLine(line);
|
|
40
|
+
},
|
|
41
|
+
flush() {
|
|
42
|
+
if (rest !== "") { onLine(rest); rest = ""; }
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {object} opts
|
|
49
|
+
* @param {object} opts.store createStore 产物(只需要它来定位 bin 之外的路径时用;目前不读库)
|
|
50
|
+
* @param {Function} [opts.spawn] 注入的 spawn(测试注假实现,绝不真起进程)
|
|
51
|
+
* @param {string} [opts.nodePath] 跑脚本的解释器,缺省 process.execPath
|
|
52
|
+
* @param {string} [opts.scriptPath] CLI 路径,缺省按 lib/src 同深度推导
|
|
53
|
+
* @param {string|null} [opts.dbPath] 透传 --db;不传则 CLI 用自己的缺省记忆库
|
|
54
|
+
* @param {() => string} [opts.now]
|
|
55
|
+
* @param {number} [opts.ringLimit]
|
|
56
|
+
* @param {number} [opts.maxFinished]
|
|
57
|
+
*/
|
|
58
|
+
export function createT1Jobs({
|
|
59
|
+
store = null,
|
|
60
|
+
spawn: spawnFn = nodeSpawn,
|
|
61
|
+
nodePath = process.execPath,
|
|
62
|
+
scriptPath = DEFAULT_SCRIPT,
|
|
63
|
+
dbPath = null,
|
|
64
|
+
now = () => new Date().toISOString(),
|
|
65
|
+
ringLimit = DEFAULT_RING,
|
|
66
|
+
maxFinished = DEFAULT_FINISHED,
|
|
67
|
+
env = process.env
|
|
68
|
+
} = {}) {
|
|
69
|
+
const jobs = new Map();
|
|
70
|
+
// 同一个仓库同时只许一个 T1:两个进程同时往同一批页面写,claims 与 status 会
|
|
71
|
+
// 交错成没人能解释的中间态(T1 的写路径没有跨进程锁,这是硬事实不是保守)。
|
|
72
|
+
const runningByRepo = new Map();
|
|
73
|
+
|
|
74
|
+
function pushLine(job, line) {
|
|
75
|
+
job.logTail.push(line);
|
|
76
|
+
if (job.logTail.length > ringLimit) job.logTail.splice(0, job.logTail.length - ringLimit);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 回执解析:只认 JSON.parsable 的完整 stdout;解析不出来就保持 0/0/0 不瞎猜。 */
|
|
80
|
+
function readCounts(job) {
|
|
81
|
+
const text = job.stdoutAll.trim();
|
|
82
|
+
if (text === "") return null;
|
|
83
|
+
let receipt;
|
|
84
|
+
try { receipt = JSON.parse(text); } catch { return null; }
|
|
85
|
+
if (!receipt || typeof receipt !== "object") return null;
|
|
86
|
+
if (Array.isArray(receipt.enriched) || Array.isArray(receipt.failed)) {
|
|
87
|
+
const done = Array.isArray(receipt.enriched) ? receipt.enriched.length : 0;
|
|
88
|
+
const failed = Array.isArray(receipt.failed) ? receipt.failed.length : 0;
|
|
89
|
+
return { total: done + failed, done, failed };
|
|
90
|
+
}
|
|
91
|
+
if (Array.isArray(receipt.candidates)) {
|
|
92
|
+
return { total: receipt.candidates.length, done: 0, failed: 0 };
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function trimFinished() {
|
|
98
|
+
const finished = [...jobs.values()].filter((j) => j.state !== "running");
|
|
99
|
+
if (finished.length <= maxFinished) return;
|
|
100
|
+
finished.sort((a, b) => String(a.finishedAt).localeCompare(String(b.finishedAt)));
|
|
101
|
+
for (const job of finished.slice(0, finished.length - maxFinished)) jobs.delete(job.jobId);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function finish(job, state, error) {
|
|
105
|
+
if (job.state !== "running") return; // close 与 error 可能都到,只认第一次结算
|
|
106
|
+
job.state = state;
|
|
107
|
+
job.finishedAt = now();
|
|
108
|
+
if (error) job.error = error;
|
|
109
|
+
const counts = readCounts(job);
|
|
110
|
+
if (counts) job.modules = counts;
|
|
111
|
+
const key = String(job.repoKey);
|
|
112
|
+
if (runningByRepo.get(key) === job.jobId) runningByRepo.delete(key);
|
|
113
|
+
trimFinished();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 起一个后台 T1。返回 {started:true, jobId} 或 {started:false, code, jobId?}——
|
|
118
|
+
* 路由据此分叉 200 / 409,本层不碰 HTTP。
|
|
119
|
+
*/
|
|
120
|
+
function start({ repo = null, module = null, retryFailed = false } = {}) {
|
|
121
|
+
const rootPath = typeof repo?.root_path === "string" ? repo.root_path : "";
|
|
122
|
+
if (rootPath === "") return { started: false, code: "repo-invalid" };
|
|
123
|
+
const repoKey = String(repo.id ?? rootPath);
|
|
124
|
+
const existing = runningByRepo.get(repoKey);
|
|
125
|
+
if (existing) {
|
|
126
|
+
const job = jobs.get(existing);
|
|
127
|
+
// runningByRepo 里还留着但 job 已经结算(理论不可达)时不该把它当占用,
|
|
128
|
+
// 否则这个仓库会被一条幽灵记录永久锁死。
|
|
129
|
+
if (job && job.state === "running") return { started: false, code: "job-running", jobId: existing };
|
|
130
|
+
runningByRepo.delete(repoKey);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const jobId = randomUUID();
|
|
134
|
+
const job = {
|
|
135
|
+
jobId,
|
|
136
|
+
repoKey,
|
|
137
|
+
repo: { id: repo.id ?? null, name: repo.name ?? null, rootPath },
|
|
138
|
+
state: "running",
|
|
139
|
+
startedAt: now(),
|
|
140
|
+
finishedAt: null,
|
|
141
|
+
modules: { total: 0, done: 0, failed: 0 },
|
|
142
|
+
logTail: [],
|
|
143
|
+
error: null,
|
|
144
|
+
stdoutAll: "",
|
|
145
|
+
stderrTail: []
|
|
146
|
+
};
|
|
147
|
+
jobs.set(jobId, job);
|
|
148
|
+
runningByRepo.set(repoKey, jobId);
|
|
149
|
+
|
|
150
|
+
const args = [scriptPath, rootPath, "--t1", "--apply", "--json"];
|
|
151
|
+
if (module) args.push("--module", String(module));
|
|
152
|
+
if (retryFailed === true) args.push("--retry-failed");
|
|
153
|
+
if (dbPath) args.push("--db", String(dbPath));
|
|
154
|
+
|
|
155
|
+
let child;
|
|
156
|
+
try {
|
|
157
|
+
child = spawnFn(nodePath, args, {
|
|
158
|
+
// Electron 宿主里 process.execPath 是 electron 本体;不设这个变量它会去开
|
|
159
|
+
// 窗口而不是跑 Node 脚本(t1.js 的真实 runner 用的是同一招)。
|
|
160
|
+
env: { ...env, ELECTRON_RUN_AS_NODE: "1" },
|
|
161
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
162
|
+
windowsHide: true
|
|
163
|
+
});
|
|
164
|
+
} catch (err) {
|
|
165
|
+
finish(job, "failed", String((err && err.message) || err));
|
|
166
|
+
return { started: true, jobId };
|
|
167
|
+
}
|
|
168
|
+
job.child = child;
|
|
169
|
+
|
|
170
|
+
const outLines = makeLineSplitter((line) => pushLine(job, line));
|
|
171
|
+
const errLines = makeLineSplitter((line) => {
|
|
172
|
+
job.stderrTail.push(line);
|
|
173
|
+
if (job.stderrTail.length > 20) job.stderrTail.shift();
|
|
174
|
+
});
|
|
175
|
+
child.stdout?.setEncoding?.("utf8");
|
|
176
|
+
child.stderr?.setEncoding?.("utf8");
|
|
177
|
+
child.stdout?.on?.("data", (chunk) => {
|
|
178
|
+
// 回执解析要完整 JSON,而 ring buffer 只留尾巴——所以另攒一份,封顶 256 KiB
|
|
179
|
+
// 防止一个跑飞的任务把宿主内存吃光。
|
|
180
|
+
if (job.stdoutAll.length < 256 * 1024) job.stdoutAll += chunk;
|
|
181
|
+
outLines.push(chunk);
|
|
182
|
+
});
|
|
183
|
+
child.stderr?.on?.("data", (chunk) => errLines.push(chunk));
|
|
184
|
+
child.on?.("error", (err) => {
|
|
185
|
+
outLines.flush();
|
|
186
|
+
errLines.flush();
|
|
187
|
+
finish(job, "failed", String((err && err.message) || err));
|
|
188
|
+
});
|
|
189
|
+
child.on?.("close", (code) => {
|
|
190
|
+
outLines.flush();
|
|
191
|
+
errLines.flush();
|
|
192
|
+
if (code === 0) {
|
|
193
|
+
finish(job, "done", null);
|
|
194
|
+
} else {
|
|
195
|
+
// CLI 的拒绝与异常都走 stderr + exit 1(bin:161-171):只报 exit code 等于
|
|
196
|
+
// 让面板显示「失败了,不知道为啥」,所以把 stderr 尾巴当 error 带出去。
|
|
197
|
+
const tail = job.stderrTail.length > 0 ? job.stderrTail[job.stderrTail.length - 1] : "";
|
|
198
|
+
finish(job, "failed", tail || `exit code ${code}`);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
return { started: true, jobId };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 状态快照(不含 child 等内部件)。未知 jobId → null。 */
|
|
205
|
+
function status(jobId) {
|
|
206
|
+
const job = jobs.get(String(jobId ?? ""));
|
|
207
|
+
if (!job) return null;
|
|
208
|
+
const view = {
|
|
209
|
+
jobId: job.jobId,
|
|
210
|
+
repo: job.repo,
|
|
211
|
+
state: job.state,
|
|
212
|
+
startedAt: job.startedAt,
|
|
213
|
+
modules: { ...job.modules },
|
|
214
|
+
logTail: job.logTail.slice()
|
|
215
|
+
};
|
|
216
|
+
if (job.finishedAt) view.finishedAt = job.finishedAt;
|
|
217
|
+
if (job.error) view.error = job.error;
|
|
218
|
+
return view;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function dispose() {
|
|
222
|
+
for (const job of jobs.values()) {
|
|
223
|
+
if (job.state === "running") {
|
|
224
|
+
try { job.child?.kill?.(); } catch { /* 进程可能已退出:dispose 不该抛 */ }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
jobs.clear();
|
|
228
|
+
runningByRepo.clear();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { start, status, dispose, scriptPath };
|
|
232
|
+
}
|
package/src/wiki/t1.js
CHANGED
|
@@ -24,6 +24,9 @@ import { join, dirname, sep, resolve } from "node:path";
|
|
|
24
24
|
import { tmpdir } from "node:os";
|
|
25
25
|
import { realpathSync } from "node:fs";
|
|
26
26
|
import { extractHumanBlocks, injectHumanBlocks, T1_SECTION_START, T1_SECTION_END } from "./render.js";
|
|
27
|
+
// issue #607:拒绝文案里的 CLI 命令要给绝对路径入口(profile 装的插件没有全局
|
|
28
|
+
// shim,光写 `dsh-mneme-choro` 用户照抄只会 command not found)。
|
|
29
|
+
import { choroCliCommandFrom } from "./cli-hint.js";
|
|
27
30
|
|
|
28
31
|
// 标记的单一来源在 render.js(页面格式的家);这里转出去,调用方与测试继续从
|
|
29
32
|
// t1.js 取,不必知道它住在哪。
|
|
@@ -31,6 +34,9 @@ export { T1_SECTION_START, T1_SECTION_END };
|
|
|
31
34
|
export const T1_FAIL_THRESHOLD = 2;
|
|
32
35
|
export const DEFAULT_T1_MODEL = "xdpool/deepseek-v4.1-flash";
|
|
33
36
|
|
|
37
|
+
// #607:拒绝文案里的命令串推导一次(本文件在 src/wiki/ 与 lib/wiki/ 同一层)。
|
|
38
|
+
const T1_CLI = choroCliCommandFrom(import.meta.url, "<repo>");
|
|
39
|
+
|
|
34
40
|
// 三重上限缺省(实现计划 P15):单模块预算——timeout 墙钟、工具调用次数、
|
|
35
41
|
// 累计输出字节。真实成本实测见 V1(10s/次任务),这里给的是上限不是预期。
|
|
36
42
|
export const DEFAULT_LIMITS = Object.freeze({
|
|
@@ -239,7 +245,7 @@ export function createT1({ store, runSession = null, limits = DEFAULT_LIMITS, no
|
|
|
239
245
|
const rootPath = realpathSync(resolve(repoRoot));
|
|
240
246
|
const repo = store.db.prepare("SELECT * FROM choro_repos WHERE root_path = ?").get(rootPath);
|
|
241
247
|
if (!repo) {
|
|
242
|
-
throw new T1Refusal("not-registered", `${rootPath} 还没有登记:先跑一次 T0
|
|
248
|
+
throw new T1Refusal("not-registered", `${rootPath} 还没有登记:先跑一次 T0(${T1_CLI ?? "dsh-mneme-choro <repo> --apply"})`);
|
|
243
249
|
}
|
|
244
250
|
|
|
245
251
|
// 选模块:skeleton 必跑;failed 只在 retryFailed 时;enriched 跳过(重富化留待显式 --module)。
|