@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/api.js
CHANGED
|
@@ -3,7 +3,7 @@ import { isAbsolute } from "node:path";
|
|
|
3
3
|
import { existsSync, readdirSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
6
|
-
|
|
6
|
+
|
|
7
7
|
import { TYPES } from "./store.js";
|
|
8
8
|
import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_READONLY_TYPES } from "./mirror.js";
|
|
9
9
|
import { langOf } from "./lang.js";
|
|
@@ -19,6 +19,14 @@ import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
|
|
|
19
19
|
import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
|
|
20
20
|
import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
|
|
21
21
|
import { parseReclaimQuery, parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
|
|
22
|
+
// Issue #386 第 6 条:从目录引导(bootstrap)在面板侧也要有入口。协议层口径只在
|
|
23
|
+
// bootstrap-http.js 里写一份,与 Bearer 数据面共用(#411 那条纪律:两张皮只许剩
|
|
24
|
+
// 鉴权与路径不同)。
|
|
25
|
+
import { parseBootstrapJson, readBootstrapDir, bootstrapReply, BOOTSTRAP_UNAVAILABLE } from "./bootstrap-http.js";
|
|
26
|
+
import { bootstrapFromDirectory } from "./bootstrap.js";
|
|
27
|
+
// Issue #386 第 7 条:面板能改的键 vs schema 里存在的键,两份清单的唯一算法在这里。
|
|
28
|
+
// 这里引的是同一份(不是照抄),谁改白名单都对得上。
|
|
29
|
+
import { describeConfigKeys, flagKeysFromSpec } from "./config-keys.js";
|
|
22
30
|
import { parseShareBody, shareReply, shareFilename, parseReceiveBody, receiveReply, parseUndoBody, undoReply } from "./share-http.js";
|
|
23
31
|
import { readPipelineDoc, loadPipelines, projectPipelineDir } from "./pipeline.js";
|
|
24
32
|
import { loadRunsById, runsDir, RUNS_SUFFIX } from "./pipeline-runs.js";
|
|
@@ -189,7 +197,10 @@ function parseBody(text) {
|
|
|
189
197
|
// maintenance 是第 9 个位置参数,默认 null:既有调用点(测试里的 14 处)全是位置传参、
|
|
190
198
|
// 不传它,必须继续工作——不传时 /maintenance/reclaim 回 503 maintenance-unavailable,
|
|
191
199
|
// 而不是把「没接线」伪装成崩溃(#386 第 4 条)。
|
|
192
|
-
|
|
200
|
+
// choro(M1):chorography 读侧查询对象(createChoroQueries 产物)。缺省 null =
|
|
201
|
+
// 宿主内 API 在 headless/测试环境没有接 queries,choro 路由回 503。它与 service
|
|
202
|
+
// 刻意分开传——chorography 的所有权模型与记忆域相反(spec §3),不塞进 service。
|
|
203
|
+
export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null, maintenance = null, profileExtract = null, choro = null) {
|
|
193
204
|
const disposers = [];
|
|
194
205
|
|
|
195
206
|
// #182 注入状态探测:面板状态页的「极简模式下注入按宿主设计关闭」提示需要
|
|
@@ -231,14 +242,10 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
231
242
|
// **反推不出待重启**,必须另比一次冻结值——这是本字段存在的唯一理由。
|
|
232
243
|
// 只报「显式覆盖 ≠ 冻结值」的键:未被覆盖的键运行时值就是 cfg 值,不存在落差;
|
|
233
244
|
// config 缺席(旧调用方直连、未传 cfg)时无从比较,返回空数组而不编造。
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
...FEATURE_FLAG_SPEC.strings,
|
|
239
|
-
...FEATURE_FLAG_SPEC.urls,
|
|
240
|
-
...Object.keys(FEATURE_FLAG_SPEC.enums)
|
|
241
|
-
];
|
|
245
|
+
// 顺序见 config-keys.js 的 flagKeysFromSpec(booleans → ints → numbers → strings
|
|
246
|
+
// → urls → enums)。这张表曾经在这里手拼一遍,第 7 条把「面板能改哪些键」做成
|
|
247
|
+
// 对外清单之后,两处各算一份就会立刻分歧——所以搬进模块,这里只引。
|
|
248
|
+
const flagKeys = flagKeysFromSpec();
|
|
242
249
|
// 嵌套键在 kv 里按点号平铺("memoryQualityFilter.enabled"),运行时 cfg 里
|
|
243
250
|
// 是嵌套对象,effective 从对象子字段取值;其余键照旧从 cfg 顶层取。
|
|
244
251
|
// 这张表必须与 settings.js 白名单里的点号键一一对应:#254 加键时漏了这里,
|
|
@@ -1308,6 +1315,27 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1308
1315
|
}
|
|
1309
1316
|
});
|
|
1310
1317
|
|
|
1318
|
+
// 沉淀曲线(面板「8 周沉淀」图):按天返回 added / merged / archived 三条序列。
|
|
1319
|
+
// 与 llm-audit/stats 并列但不同源——那边算的是「花了多少 token」,这边算的是
|
|
1320
|
+
// 「进来了多少条、真被巩固掉多少条、归档了多少条」。合并数与新增数背离时
|
|
1321
|
+
// (2026-10-25~10-31 那段:新增照旧、合并归零)这张图是唯一能一眼看出来的地方。
|
|
1322
|
+
// 窗口钳制 1-365 天(缺省 56,正好是热力图的 8 周),口径见 store.getConsolidationStats。
|
|
1323
|
+
register({
|
|
1324
|
+
kind: "exact",
|
|
1325
|
+
path: "/api/dsh-mneme/semantic/consolidation-stats",
|
|
1326
|
+
policy: "open",
|
|
1327
|
+
handler(req, res) {
|
|
1328
|
+
try {
|
|
1329
|
+
const url = new URL(req.url, "http://localhost");
|
|
1330
|
+
const days = Math.max(1, Math.min(365, Number(url.searchParams.get("days") ?? 56) || 56));
|
|
1331
|
+
const stats = service.getConsolidationStats?.({ days }) ?? null;
|
|
1332
|
+
sendJson(res, 200, stats ?? { error: "unavailable" });
|
|
1333
|
+
} catch {
|
|
1334
|
+
sendJson(res, 500, { error: "internal" });
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1311
1339
|
// --- 记忆级溯源读出口(#399 写、#421 读)-----------------------------------
|
|
1312
1340
|
// #399 把蒸馏窗口的原文落进 distill_records、把 memory → (session, seq 区间)
|
|
1313
1341
|
// 的归因写进 memory_provenance,但只做了写:库里有答案、service.provenanceFor
|
|
@@ -1548,7 +1576,26 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1548
1576
|
const url = new URL(req.url, "http://localhost");
|
|
1549
1577
|
const limit = Math.min(1000, Math.max(1, Number(url.searchParams.get("limit") ?? 500) || 500));
|
|
1550
1578
|
const entities = service.listEntities?.({ limit }) ?? [];
|
|
1551
|
-
|
|
1579
|
+
// total 是**全库**实体数,不是本次切片长度:目录带 limit,面板拿它当
|
|
1580
|
+
// 「一共抽出多少实体」,填切片长度会让 1.6 万实体永远显示成 500。
|
|
1581
|
+
// 老宿主(service 上没有 countEntities)回落到切片长度,不抛。计数本身失败也
|
|
1582
|
+
// 只回落、不冒泡:它在外层 try 里,一抛整条响应就变成 500,已经把切片读出来的
|
|
1583
|
+
// 那份列表也跟着丢掉——一个装饰性的总数不该决定目录能不能显示。
|
|
1584
|
+
let total = entities.length;
|
|
1585
|
+
// totalExact 是给面板看的:计数失败或老宿主没这个方法时,total 只能退回切片
|
|
1586
|
+
// 长度,那已经不是「全库有多少」而只是「这次取回多少」。不回这个标记,面板
|
|
1587
|
+
// 会把切片长度当成全库数照常显示——正是这条路由原来那个「永远 500」的错法。
|
|
1588
|
+
let totalExact = false;
|
|
1589
|
+
if (typeof service.countEntities === "function") {
|
|
1590
|
+
try {
|
|
1591
|
+
total = service.countEntities();
|
|
1592
|
+
totalExact = true;
|
|
1593
|
+
} catch {
|
|
1594
|
+
total = entities.length;
|
|
1595
|
+
totalExact = false;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
sendJson(res, 200, { entities, total, totalExact });
|
|
1552
1599
|
} catch {
|
|
1553
1600
|
sendJson(res, 500, { error: "internal" });
|
|
1554
1601
|
}
|
|
@@ -1808,6 +1855,85 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1808
1855
|
}
|
|
1809
1856
|
});
|
|
1810
1857
|
|
|
1858
|
+
// --- 从目录引导(bootstrap,#386 第 6 条)------------------------------------
|
|
1859
|
+
// 后端能力从 #220 起就在(src/bootstrap.js:六类确定性产物、零 LLM、吃
|
|
1860
|
+
// saveWithDedupe 的去重),但只有 Bearer 数据面一条入口,面板够不着——「用户照
|
|
1861
|
+
// 文档去找入口,发现产品界面里没有」。
|
|
1862
|
+
//
|
|
1863
|
+
// policy 取 token 而不是 token-write:它写记忆(虽然幂等),与 /import 同类,
|
|
1864
|
+
// 而 /import 就是整条要 token 的。GET 不给读分支:没有「预览一次引导会写什么」
|
|
1865
|
+
// 的形态,硬造一个 dryRun 等于把 bootstrap.js 拆成两半,收益不抵风险。
|
|
1866
|
+
//
|
|
1867
|
+
// 为什么不在服务端限制目录范围:插件没有「工作区根」概念(bootstrap.js 的注释
|
|
1868
|
+
// 写着不猜路径),而这条路由与 /export、/import 同级,本来就能读写整库;额外加
|
|
1869
|
+
// 一个路径白名单只会让人以为它比邻座更安全。
|
|
1870
|
+
register({
|
|
1871
|
+
kind: "exact",
|
|
1872
|
+
path: "/api/dsh-mneme/bootstrap",
|
|
1873
|
+
policy: "token",
|
|
1874
|
+
handler(req, res) {
|
|
1875
|
+
try {
|
|
1876
|
+
if (req.method !== "POST") {
|
|
1877
|
+
sendJson(res, 404, { error: "not-found" });
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
return readBody(req, res).then((text) => {
|
|
1881
|
+
if (text === null) return;
|
|
1882
|
+
const parsed = parseBootstrapJson(text);
|
|
1883
|
+
if (!parsed.ok) {
|
|
1884
|
+
sendJson(res, 400, { error: parsed.error });
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
// 顺序:先判 body 形状与 dir,再判能力是否接线。缺 dir 是调用方的问题,
|
|
1888
|
+
// 报「能力没接线」会把人引到错误的排查方向(reclaim 那边同一条纪律)。
|
|
1889
|
+
const dirResult = readBootstrapDir(parsed.body);
|
|
1890
|
+
if (!dirResult.ok) {
|
|
1891
|
+
sendJson(res, 400, { error: dirResult.error });
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
if (typeof service?.saveWithDedupe !== "function") {
|
|
1895
|
+
sendJson(res, 503, { error: BOOTSTRAP_UNAVAILABLE });
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
return bootstrapReply({
|
|
1899
|
+
bootstrapper: ({ dir }) => bootstrapFromDirectory({ service, dir, logger: ctx.logger }),
|
|
1900
|
+
dir: dirResult.dir,
|
|
1901
|
+
warn: (msg) => ctx.logger?.warn?.(msg)
|
|
1902
|
+
}).then(({ status, body }) => sendJson(res, status, body));
|
|
1903
|
+
});
|
|
1904
|
+
} catch {
|
|
1905
|
+
sendJson(res, 500, { error: "internal" });
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
// --- 配置键覆盖面(#386 第 7 条,只读)--------------------------------------
|
|
1911
|
+
// 面板能改的键(settings.js 白名单)与 schema 里存在的键(config.js)是两张表,
|
|
1912
|
+
// 从没被摆在一起对过。这条路由把它们交/差出来,回答「哪些键只能改配置文件」。
|
|
1913
|
+
//
|
|
1914
|
+
// 只回**键名与计数**,一个值都不回:apiToken 这类敏感键的名字出现在「仅配置
|
|
1915
|
+
// 文件」清单里正是这条 issue 想让人看见的东西(否则用户不知道它存在),但值
|
|
1916
|
+
// 出这道门就会成为一个新的泄漏面。
|
|
1917
|
+
//
|
|
1918
|
+
// policy 取 open:与 /inject-status、/features 的读分支同级——面板要渲染,且
|
|
1919
|
+
// 键名本身已经逐条写在 docs/CONFIGURATION.md 里(公开文档),不构成新秘密。
|
|
1920
|
+
register({
|
|
1921
|
+
kind: "exact",
|
|
1922
|
+
path: "/api/dsh-mneme/config-keys",
|
|
1923
|
+
policy: "open",
|
|
1924
|
+
handler(req, res) {
|
|
1925
|
+
try {
|
|
1926
|
+
if (req.method !== "GET") {
|
|
1927
|
+
sendJson(res, 404, { error: "not-found" });
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
sendJson(res, 200, describeConfigKeys());
|
|
1931
|
+
} catch {
|
|
1932
|
+
sendJson(res, 500, { error: "internal" });
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1811
1937
|
// --- 巩固状态(dream 面板)--------------------------------------------------
|
|
1812
1938
|
// 只读:最近巩固运行(最多 5 条,created_at DESC)+ 未解决冲突队列。
|
|
1813
1939
|
// pendingMemoryIds 从未解决冲突行提取涉及的记忆 id,去重后上限 200,避免积
|
|
@@ -2431,6 +2557,116 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
2431
2557
|
}
|
|
2432
2558
|
});
|
|
2433
2559
|
|
|
2560
|
+
// --- chorography(仓库地志)M1 读侧路由 -------------------------------
|
|
2561
|
+
// spec §7 消费面的面板数据源。四条全 policy "open":只读、宿主围栏统一鉴权
|
|
2562
|
+
// (与 /list、/version-check 同级);**chorographyEnabled 关闭时一律 409
|
|
2563
|
+
// disabled**(#497 先例:路由挂着、逻辑层挡——面板据此渲染「未启用」空态)。
|
|
2564
|
+
// 仓库/模块/页面全部只从 choro_* 表与仓库磁盘读(真源是盘上的 MD),没有
|
|
2565
|
+
// 任何写路径;生成入口在 M2 才有(T1 生成轮同批接)。
|
|
2566
|
+
// choro 参数缺省 null:headless/测试环境没有 queries 时这四条路由回 503
|
|
2567
|
+
// unavailable(语义分叉见 #497:503 = 环境缺件,409 = 用户没开)。
|
|
2568
|
+
function choroGate(req, res) {
|
|
2569
|
+
if (!choro) {
|
|
2570
|
+
sendJson(res, 503, { error: "unavailable", reason: "choro-queries-unavailable" });
|
|
2571
|
+
return false;
|
|
2572
|
+
}
|
|
2573
|
+
if (!config || config.chorographyEnabled !== true) {
|
|
2574
|
+
sendJson(res, 409, { error: "disabled", reason: "chorography-not-enabled" });
|
|
2575
|
+
return false;
|
|
2576
|
+
}
|
|
2577
|
+
return true;
|
|
2578
|
+
}
|
|
2579
|
+
function choroResolveOr4xx(req, res) {
|
|
2580
|
+
const repo = choro.resolveRepo(
|
|
2581
|
+
new URL(req.url, "http://localhost").searchParams.get("repo")
|
|
2582
|
+
);
|
|
2583
|
+
if (!repo || repo.ambiguous) {
|
|
2584
|
+
sendJson(res, repo && repo.ambiguous ? 400 : 404, {
|
|
2585
|
+
error: repo && repo.ambiguous ? "repo-ambiguous" : "repo-not-found",
|
|
2586
|
+
repos: repo && repo.ambiguous ? repo.ambiguous : undefined
|
|
2587
|
+
});
|
|
2588
|
+
return null;
|
|
2589
|
+
}
|
|
2590
|
+
return repo;
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
register({
|
|
2594
|
+
kind: "exact",
|
|
2595
|
+
path: "/api/dsh-mneme/choro/repos",
|
|
2596
|
+
policy: "open",
|
|
2597
|
+
handler(req, res) {
|
|
2598
|
+
if (!choroGate(req, res)) return;
|
|
2599
|
+
sendJson(res, 200, { repos: choro.listRepos() });
|
|
2600
|
+
}
|
|
2601
|
+
});
|
|
2602
|
+
|
|
2603
|
+
register({
|
|
2604
|
+
kind: "exact",
|
|
2605
|
+
path: "/api/dsh-mneme/choro/modules",
|
|
2606
|
+
policy: "open",
|
|
2607
|
+
handler(req, res) {
|
|
2608
|
+
if (!choroGate(req, res)) return;
|
|
2609
|
+
const repo = choroResolveOr4xx(req, res);
|
|
2610
|
+
if (!repo) return;
|
|
2611
|
+
const { modules, edges } = choro.moduleMap(repo.id);
|
|
2612
|
+
sendJson(res, 200, {
|
|
2613
|
+
repo: { id: repo.id, name: repo.name, gitBaseline: repo.git_baseline },
|
|
2614
|
+
modules,
|
|
2615
|
+
edges
|
|
2616
|
+
});
|
|
2617
|
+
}
|
|
2618
|
+
});
|
|
2619
|
+
|
|
2620
|
+
register({
|
|
2621
|
+
kind: "exact",
|
|
2622
|
+
path: "/api/dsh-mneme/choro/page",
|
|
2623
|
+
policy: "open",
|
|
2624
|
+
handler(req, res) {
|
|
2625
|
+
if (!choroGate(req, res)) return;
|
|
2626
|
+
const repo = choroResolveOr4xx(req, res);
|
|
2627
|
+
if (!repo) return;
|
|
2628
|
+
const slug = new URL(req.url, "http://localhost").searchParams.get("slug");
|
|
2629
|
+
if (!slug) {
|
|
2630
|
+
sendJson(res, 400, { error: "slug-required" });
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
const page = choro.readPage(repo.id, slug);
|
|
2634
|
+
if (!page) {
|
|
2635
|
+
sendJson(res, 404, { error: "page-not-found", slug });
|
|
2636
|
+
return;
|
|
2637
|
+
}
|
|
2638
|
+
if (page.error) {
|
|
2639
|
+
// 索引还在、盘上没了:409 让面板知道「重跑一次 T0 就能好」,不是 404 那种
|
|
2640
|
+
// 「这条路本来就不存在」。
|
|
2641
|
+
sendJson(res, 409, {
|
|
2642
|
+
error: page.error,
|
|
2643
|
+
slug: page.slug,
|
|
2644
|
+
relPath: page.relPath,
|
|
2645
|
+
hint: "run `dsh-mneme-choro <repo> --apply` to rebuild"
|
|
2646
|
+
});
|
|
2647
|
+
return;
|
|
2648
|
+
}
|
|
2649
|
+
sendJson(res, 200, { page });
|
|
2650
|
+
}
|
|
2651
|
+
});
|
|
2652
|
+
|
|
2653
|
+
register({
|
|
2654
|
+
kind: "exact",
|
|
2655
|
+
path: "/api/dsh-mneme/choro/search",
|
|
2656
|
+
policy: "open",
|
|
2657
|
+
handler(req, res) {
|
|
2658
|
+
if (!choroGate(req, res)) return;
|
|
2659
|
+
const repo = choroResolveOr4xx(req, res);
|
|
2660
|
+
if (!repo) return;
|
|
2661
|
+
const url = new URL(req.url, "http://localhost");
|
|
2662
|
+
const hits = choro.search(repo.id, url.searchParams.get("q"), {
|
|
2663
|
+
moduleSlug: url.searchParams.get("module"),
|
|
2664
|
+
limit: Number(url.searchParams.get("limit") ?? 20)
|
|
2665
|
+
});
|
|
2666
|
+
sendJson(res, 200, { q: url.searchParams.get("q"), hits });
|
|
2667
|
+
}
|
|
2668
|
+
});
|
|
2669
|
+
|
|
2434
2670
|
return {
|
|
2435
2671
|
routes: countRoutes(),
|
|
2436
2672
|
dispose: () => {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// 从目录引导(bootstrap)的 HTTP 口径 —— 唯一实现。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要单独一个模块:这张脸原本只有一个入口(Bearer 数据面 src/api-standalone.js
|
|
4
|
+
// 的 POST /bootstrap),#386 第 6 条要给面板补入口,于是就有了第二个。历史教训写在
|
|
5
|
+
// reclaim-http.js 的开头:#410 修好面板侧之后数据面还停在旧形态(#411),整段复制
|
|
6
|
+
// 必然漂移。所以解析、校验、答复形状从第一份就落在这里,两个入口只留路径与鉴权不同。
|
|
7
|
+
//
|
|
8
|
+
// 这里只做协议层的事:解析、校验、把 bootstrapFromDirectory 的结果包成答复。真正的
|
|
9
|
+
// 解析在 src/bootstrap.js——它读目录、抽六类确定性产物,零 LLM。
|
|
10
|
+
//
|
|
11
|
+
// 与 reclaim-http.js 的一处刻意不同:这里**不做 confirm 两步确认**。bootstrap 是
|
|
12
|
+
// 幂等的(吃 saveWithDedupe 的 (type,title,scope) 去重,`_overwrite` 只刷新内容),
|
|
13
|
+
// 重复跑不会翻倍,也不会删任何东西,所以不需要「先看报告再执行」那道门。
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 「引导能力没接线」的错误码。两个入口各有一次顺序敏感的显式检查(503 要赢过 400:
|
|
17
|
+
* 端点整个没接线时报「dir 不合法」是误导),所以这条不能只藏在 bootstrapReply 里;
|
|
18
|
+
* 但也必须是同一份字符串。
|
|
19
|
+
*/
|
|
20
|
+
export const BOOTSTRAP_UNAVAILABLE = "bootstrap-unavailable";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 记日志这一手,只许经这里。
|
|
24
|
+
*
|
|
25
|
+
* 照 `share-http.js` 的同一份理由抄(C5 评审裁定):warn 裸调时,宿主 logger 自己坏掉就会
|
|
26
|
+
* 从 catch 块里再抛出去,而两侧 handler 那个外层 try 在 .then 的回调里早已退栈——现场
|
|
27
|
+
* 就是「一个字节都没回」(#410)。守卫住在层里,两张脸(in-process 与 Bearer 数据面)
|
|
28
|
+
* 拿到的就是同一个脾气,不必每张脸各记一次。
|
|
29
|
+
*/
|
|
30
|
+
function warnSafely(warn, message) {
|
|
31
|
+
try {
|
|
32
|
+
warn?.(message);
|
|
33
|
+
} catch { /* 记日志是自己的事,坏了的日志不许把已经算好的答复一起带走 */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 引导失败的 warn 文案。两条路径共用,日志里不会出现两种拼法。 */
|
|
37
|
+
function bootstrapWarnMessage(error) {
|
|
38
|
+
return `[dsh-mneme] bootstrap failed: ${String(error)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 解析 POST body(已读成文本)的结构。只判 JSON 与顶层形状,dir 在 readBootstrapDir
|
|
43
|
+
* 里读——两步分开是为了保住「invalid-json → invalid-body → 能力是否接线 → dir」这个
|
|
44
|
+
* 顺序,这个顺序是既有对外行为。
|
|
45
|
+
*
|
|
46
|
+
* 不用 api.js 的 parseBody:它把坏 JSON 吞成 {},而 invalid-json 与 invalid-body 是
|
|
47
|
+
* 两个不同的错(前者是调用方发坏了,后者是发成了数组/null)。
|
|
48
|
+
*
|
|
49
|
+
* @returns {{ok: true, body: object} | {ok: false, error: string}}
|
|
50
|
+
*/
|
|
51
|
+
export function parseBootstrapJson(text) {
|
|
52
|
+
let body;
|
|
53
|
+
try {
|
|
54
|
+
body = JSON.parse(text || "{}");
|
|
55
|
+
} catch {
|
|
56
|
+
return { ok: false, error: "invalid-json" };
|
|
57
|
+
}
|
|
58
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
59
|
+
return { ok: false, error: "invalid-body" };
|
|
60
|
+
}
|
|
61
|
+
return { ok: true, body };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 读 dir 并归一化。
|
|
66
|
+
*
|
|
67
|
+
* 只收非空字符串:插件没有「工作区根目录」的概念,绝不猜路径(猜错就会去读一个
|
|
68
|
+
* 与用户意图无关的目录,然后把里面的东西写进记忆库)。`trim()` 之后再判空,
|
|
69
|
+
* 但**回传原串**而不是 trim 后的——两端空白在 Windows 路径里不合法,交给 fs 报
|
|
70
|
+
* dir-not-found 比在这里静默改写更诚实。
|
|
71
|
+
*
|
|
72
|
+
* @returns {{ok: true, dir: string} | {ok: false, error: string}}
|
|
73
|
+
*/
|
|
74
|
+
export function readBootstrapDir(body) {
|
|
75
|
+
if (typeof body.dir !== "string" || !body.dir.trim()) {
|
|
76
|
+
return { ok: false, error: "missing-dir" };
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, dir: body.dir };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 把一次引导请求变成 { status, body }。调用方只管 sendJson,不自己拼答复。
|
|
83
|
+
*
|
|
84
|
+
* 报告一律不加包装层(就是 bootstrapFromDirectory 的原样返回):面板与数据面都直接
|
|
85
|
+
* 读 created / merged / items 这几格。
|
|
86
|
+
*
|
|
87
|
+
* `BootstrapError` 带 `code`(missing-dir / dir-not-found / not-a-directory),它们
|
|
88
|
+
* 是**调用方的输入问题**,回 400 并把 code 当错误码;其余异常是内部问题,回 500,
|
|
89
|
+
* 且不能让 warn 把异常本身吞掉。
|
|
90
|
+
*
|
|
91
|
+
* @param {{bootstrapper: ((input: {dir: string}) => Promise<object>)|null,
|
|
92
|
+
* dir: string, warn?: ((msg: string) => void)|null}} input
|
|
93
|
+
* @returns {Promise<{status: number, body: object}>}
|
|
94
|
+
*/
|
|
95
|
+
export function bootstrapReply({ bootstrapper, dir, warn = null }) {
|
|
96
|
+
if (!bootstrapper) return Promise.resolve({ status: 503, body: { error: BOOTSTRAP_UNAVAILABLE } });
|
|
97
|
+
return Promise.resolve()
|
|
98
|
+
.then(() => bootstrapper({ dir }))
|
|
99
|
+
.then((summary) => ({ status: 200, body: summary }))
|
|
100
|
+
.catch((error) => {
|
|
101
|
+
if (error?.code) return { status: 400, body: { error: error.code } };
|
|
102
|
+
warnSafely(warn, bootstrapWarnMessage(error));
|
|
103
|
+
return { status: 500, body: { error: "internal" } };
|
|
104
|
+
});
|
|
105
|
+
}
|
package/src/bootstrap.js
CHANGED
|
@@ -177,7 +177,11 @@ async function collectCandidates(root) {
|
|
|
177
177
|
*/
|
|
178
178
|
export async function bootstrapFromDirectory({ service, dir, logger } = {}) {
|
|
179
179
|
if (typeof dir !== "string" || !dir.trim()) throw new BootstrapError("missing-dir");
|
|
180
|
-
|
|
180
|
+
// 只 trim 判空,**不**把 trim 结果当路径(#386 第 6 条评审):协议层 readBootstrapDir
|
|
181
|
+
// 刻意回传原串,理由写着「两端空白交给 fs 报 dir-not-found 比静默改写更诚实」;这里
|
|
182
|
+
// 若再 trim 一次,那个承诺就变成了空话——用户填 " /repo " 会被悄悄换成 "/repo",
|
|
183
|
+
// 读的是另一个目录,而他一无所知。路径不合法就让 statSync 去报。
|
|
184
|
+
const root = dir;
|
|
181
185
|
let st;
|
|
182
186
|
try {
|
|
183
187
|
st = statSync(root);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// dsh-mneme/src/choro-command.js
|
|
2
|
+
// /choro:仓库地志的只读回执(M3,spec §10)。
|
|
3
|
+
//
|
|
4
|
+
// 与 pipeline-command.js 的区别:那边持有状态(开窗 / 结账),这边**什么都不写**。
|
|
5
|
+
// spec §2 D6 定的是「只手动触发,过期只提示」——重建、富化、切档位全都不在这条
|
|
6
|
+
// 命令里,它只回答三个问题:当前仓库登记过地志吗?基线还一致吗?提纲长什么样?
|
|
7
|
+
import { gitHead } from "./wiki/t0.js";
|
|
8
|
+
import { renderChoroBlock } from "./wiki/inject.js";
|
|
9
|
+
import { STR } from "./lang.js";
|
|
10
|
+
|
|
11
|
+
export const CHORO_COMMAND = "choro";
|
|
12
|
+
|
|
13
|
+
/** 短 sha:回执里只用于人眼比对,给全 40 位纯属噪音。 */
|
|
14
|
+
function shortSha(sha) {
|
|
15
|
+
const text = String(sha ?? "").trim();
|
|
16
|
+
return text.length > 7 ? text.slice(0, 7) : text;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 生成时间只留日期(与注入块同口径:分钟级精度在这块文本里没有信息量)。 */
|
|
20
|
+
function dateOnly(iso) {
|
|
21
|
+
const m = /^(\d{4}-\d{2}-\d{2})/.exec(String(iso ?? ""));
|
|
22
|
+
return m ? m[1] : String(iso ?? "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 过期判定:地志落库时的 git 基线 vs 当前 HEAD。
|
|
27
|
+
*
|
|
28
|
+
* **只在这里做**——注入块是每轮都要渲染的热路径,fork 一次 git 在 Windows 上
|
|
29
|
+
* 几十毫秒起步(P29)。这里是用户主动敲的,付得起一次 rev-parse。
|
|
30
|
+
* 三种结局分开报:一致 / 不一致 / 读不到 git——不能把「读不到」说成「没过期」。
|
|
31
|
+
*/
|
|
32
|
+
function staleness(repo) {
|
|
33
|
+
const baseline = String(repo.git_baseline ?? "").trim();
|
|
34
|
+
const head = gitHead(repo.root_path);
|
|
35
|
+
if (!head) return { state: "no-git", baseline, head: null };
|
|
36
|
+
if (baseline && baseline === head) return { state: "fresh", baseline, head };
|
|
37
|
+
return { state: "stale", baseline, head };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 处理函数。返回 CommandResult 形状 { kind, text }——**只作 UI 回执,不进模型
|
|
42
|
+
* 上下文**(同 pipeline-command.js 的契约注释);要模型看提纲,走开局注入那条路。
|
|
43
|
+
*
|
|
44
|
+
* @param {object} opts.choro createChoroQueries() 的产物
|
|
45
|
+
* @param {object} opts.config 合并后的配置
|
|
46
|
+
* @param {() => string} [opts.language]
|
|
47
|
+
*/
|
|
48
|
+
export function createChoroHandler({ choro, config, language = () => "zh" } = {}) {
|
|
49
|
+
return (invocation) => {
|
|
50
|
+
const lang = language() === "en" ? "en" : "zh";
|
|
51
|
+
const S = STR.choroCommandUsage[lang];
|
|
52
|
+
try {
|
|
53
|
+
// 闸只认 chorographyEnabled,不认注入档位:resolveInjectTier 在轻量档下也返回
|
|
54
|
+
// "off",用它当闸会让 lightMode 的用户敲 /choro 时听到「没开,去设置里打开」
|
|
55
|
+
// ——而设置里明明是开着的(档位只管注入,不管这条命令能不能看提纲)。
|
|
56
|
+
if (!config || config.chorographyEnabled !== true) return { kind: "error", text: S.off };
|
|
57
|
+
const raw = invocation?.agent?.session?.header?.cwd ?? null;
|
|
58
|
+
const cwd = typeof raw === "string" ? raw.trim() : "";
|
|
59
|
+
if (!cwd) return { kind: "error", text: S.noCwd };
|
|
60
|
+
const repo = choro ? choro.repoByCwd(cwd) : null;
|
|
61
|
+
if (!repo) return { kind: "error", text: S.noRepo(cwd) };
|
|
62
|
+
const outline = choro.outline(repo.id);
|
|
63
|
+
if (!outline || outline.modules.length === 0) {
|
|
64
|
+
return { kind: "success", text: S.title(repo.name, 0, dateOnly(repo.updated_at)) + "\n" + S.empty };
|
|
65
|
+
}
|
|
66
|
+
const lines = [S.title(repo.name, outline.modules.length, dateOnly(outline.generatedAt))];
|
|
67
|
+
// 命令里的清单不设 light 的 6 行上限:人主动敲了命令就是要看全表。
|
|
68
|
+
// 但 full 档的字符上限仍由 renderChoroBlock 自己兜——回执是 UI 里一个文本框,
|
|
69
|
+
// 贴爆它同样是失败(上限不在这里判,两处各判一次迟早对不上)。
|
|
70
|
+
const block = renderChoroBlock(outline, "full", lang);
|
|
71
|
+
if (block) lines.push(block.split("\n").slice(1).join("\n"));
|
|
72
|
+
// 截断语要说「还剩几个」,不是「总共几个」:总表格行数减去真列出来的行数才是
|
|
73
|
+
// 读者没看到的量。传总数会让人以为「另有 38 个」而实际只漏了 3 个。
|
|
74
|
+
const listed = block ? block.split("\n").filter((line) => line.startsWith("- ")).length : 0;
|
|
75
|
+
const rest = Math.max(0, outline.modules.length - listed);
|
|
76
|
+
if (rest > 0) lines.push(S.truncated(rest));
|
|
77
|
+
const head = staleness(repo);
|
|
78
|
+
if (head.state === "fresh") lines.push(S.fresh(shortSha(head.head)));
|
|
79
|
+
else if (head.state === "stale") lines.push(S.stale(shortSha(head.baseline), shortSha(head.head)));
|
|
80
|
+
else lines.push(S.noGit);
|
|
81
|
+
lines.push(S.hint);
|
|
82
|
+
return { kind: "success", text: lines.filter(Boolean).join("\n") };
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// 回执层永不抛进宿主(同 pipeline-command.js):把原因写进文本就够了。
|
|
85
|
+
return { kind: "error", text: String(error?.message ?? error) };
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 注册。返回 disposer 或 null(宿主没有命令注册表时静默返回 null,不抛)。
|
|
92
|
+
* 形状与 registerMoRunCommand 一致——两条内置命令只有「做什么」不同。
|
|
93
|
+
*/
|
|
94
|
+
export function registerChoroCommand({ ctx, choro, config, enabled, language = () => "zh" }) {
|
|
95
|
+
if (enabled !== true) return null;
|
|
96
|
+
if (typeof ctx?.commands?.register !== "function") return null;
|
|
97
|
+
const lang = language() === "en" ? "en" : "zh";
|
|
98
|
+
return ctx.commands.register({
|
|
99
|
+
name: CHORO_COMMAND,
|
|
100
|
+
description: STR.choroCommandDescription[lang],
|
|
101
|
+
input: { hint: STR.choroCommandInputHint[lang] },
|
|
102
|
+
handler: createChoroHandler({ choro, config, language })
|
|
103
|
+
});
|
|
104
|
+
}
|