@modusensus/dsh-mneme 0.10.1 → 0.12.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 +17 -6
- package/bin/dsh-mneme-choro.mjs +254 -0
- package/docs/CONFIGURATION.md +10 -1
- package/lib/api-standalone.js +17 -21
- package/lib/api.js +414 -12
- package/lib/bootstrap-http.js +105 -0
- package/lib/bootstrap.js +5 -1
- package/lib/choro-command.js +104 -0
- package/lib/client.js +1637 -110
- package/lib/config-keys.js +144 -0
- package/lib/config.js +60 -1
- package/lib/document.js +9 -3
- package/lib/dream/clustering.js +6 -1
- package/lib/index.js +87 -5
- package/lib/inject.js +18 -4
- package/lib/lang.js +82 -0
- package/lib/profile-facts.js +356 -0
- package/lib/quality-filter.js +48 -12
- package/lib/service.js +112 -4
- package/lib/settings.js +13 -1
- package/lib/store.js +978 -27
- package/lib/tools.js +244 -1
- package/lib/user-settings.js +63 -7
- 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 +360 -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/scripts/sync-test-badge.mjs +1 -1
- package/scripts/test-count-sync.mjs +9 -5
- package/src/api-standalone.js +17 -21
- package/src/api.js +414 -12
- 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 +60 -1
- package/src/document.js +9 -3
- package/src/dream/clustering.js +6 -1
- package/src/index.js +87 -5
- package/src/inject.js +18 -4
- package/src/lang.js +82 -0
- package/src/profile-facts.js +356 -0
- package/src/quality-filter.js +48 -12
- package/src/service.js +112 -4
- package/src/settings.js +13 -1
- package/src/store.js +978 -27
- package/src/tools.js +244 -1
- package/src/user-settings.js +63 -7
- 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 +360 -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 +9 -1
- package/test/bootstrap.test.js +8 -0
- package/test/choro-command.test.js +218 -0
- package/test/client.test.js +802 -21
- package/test/config-keys-routes.test.js +233 -0
- package/test/config.test.js +79 -0
- package/test/contrast.test.js +14 -7
- package/test/document.test.js +32 -0
- package/test/embedding-cache-f32.test.js +276 -0
- package/test/inject-pin-explicit.test.js +300 -0
- package/test/inject-pin-vector-crowd.test.js +225 -0
- package/test/inject.test.js +51 -0
- package/test/llm-audit.test.js +29 -1
- package/test/persona-settings.test.js +31 -0
- package/test/profile-facts.test.js +492 -0
- 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/session-gate.test.js +1 -1
- 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/wiki-api.test.js +273 -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 +270 -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 +176 -0
package/src/api.js
CHANGED
|
@@ -3,10 +3,12 @@ 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";
|
|
10
|
+
// op 形状谓词单一份(评审 P3:两份皮会让形状修洞漏一边)。
|
|
11
|
+
import { isValidFactOps } from "./profile-facts.js";
|
|
10
12
|
import { computeHeat } from "./heat.js";
|
|
11
13
|
import { EFFORT_REJECT_RE, describeStreamFailure, resolveRoute, withEffortFallback } from "./dream.js";
|
|
12
14
|
// Issue #521:面板要按归一化原因显示 skipped,归一化只有一份实现(住在
|
|
@@ -19,6 +21,14 @@ import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
|
|
|
19
21
|
import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
|
|
20
22
|
import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
|
|
21
23
|
import { parseReclaimQuery, parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
|
|
24
|
+
// Issue #386 第 6 条:从目录引导(bootstrap)在面板侧也要有入口。协议层口径只在
|
|
25
|
+
// bootstrap-http.js 里写一份,与 Bearer 数据面共用(#411 那条纪律:两张皮只许剩
|
|
26
|
+
// 鉴权与路径不同)。
|
|
27
|
+
import { parseBootstrapJson, readBootstrapDir, bootstrapReply, BOOTSTRAP_UNAVAILABLE } from "./bootstrap-http.js";
|
|
28
|
+
import { bootstrapFromDirectory } from "./bootstrap.js";
|
|
29
|
+
// Issue #386 第 7 条:面板能改的键 vs schema 里存在的键,两份清单的唯一算法在这里。
|
|
30
|
+
// 这里引的是同一份(不是照抄),谁改白名单都对得上。
|
|
31
|
+
import { describeConfigKeys, flagKeysFromSpec } from "./config-keys.js";
|
|
22
32
|
import { parseShareBody, shareReply, shareFilename, parseReceiveBody, receiveReply, parseUndoBody, undoReply } from "./share-http.js";
|
|
23
33
|
import { readPipelineDoc, loadPipelines, projectPipelineDir } from "./pipeline.js";
|
|
24
34
|
import { loadRunsById, runsDir, RUNS_SUFFIX } from "./pipeline-runs.js";
|
|
@@ -189,7 +199,10 @@ function parseBody(text) {
|
|
|
189
199
|
// maintenance 是第 9 个位置参数,默认 null:既有调用点(测试里的 14 处)全是位置传参、
|
|
190
200
|
// 不传它,必须继续工作——不传时 /maintenance/reclaim 回 503 maintenance-unavailable,
|
|
191
201
|
// 而不是把「没接线」伪装成崩溃(#386 第 4 条)。
|
|
192
|
-
|
|
202
|
+
// choro(M1):chorography 读侧查询对象(createChoroQueries 产物)。缺省 null =
|
|
203
|
+
// 宿主内 API 在 headless/测试环境没有接 queries,choro 路由回 503。它与 service
|
|
204
|
+
// 刻意分开传——chorography 的所有权模型与记忆域相反(spec §3),不塞进 service。
|
|
205
|
+
export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null, maintenance = null, profileExtract = null, choro = null, facts = null) {
|
|
193
206
|
const disposers = [];
|
|
194
207
|
|
|
195
208
|
// #182 注入状态探测:面板状态页的「极简模式下注入按宿主设计关闭」提示需要
|
|
@@ -231,14 +244,10 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
231
244
|
// **反推不出待重启**,必须另比一次冻结值——这是本字段存在的唯一理由。
|
|
232
245
|
// 只报「显式覆盖 ≠ 冻结值」的键:未被覆盖的键运行时值就是 cfg 值,不存在落差;
|
|
233
246
|
// config 缺席(旧调用方直连、未传 cfg)时无从比较,返回空数组而不编造。
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
...FEATURE_FLAG_SPEC.strings,
|
|
239
|
-
...FEATURE_FLAG_SPEC.urls,
|
|
240
|
-
...Object.keys(FEATURE_FLAG_SPEC.enums)
|
|
241
|
-
];
|
|
247
|
+
// 顺序见 config-keys.js 的 flagKeysFromSpec(booleans → ints → numbers → strings
|
|
248
|
+
// → urls → enums)。这张表曾经在这里手拼一遍,第 7 条把「面板能改哪些键」做成
|
|
249
|
+
// 对外清单之后,两处各算一份就会立刻分歧——所以搬进模块,这里只引。
|
|
250
|
+
const flagKeys = flagKeysFromSpec();
|
|
242
251
|
// 嵌套键在 kv 里按点号平铺("memoryQualityFilter.enabled"),运行时 cfg 里
|
|
243
252
|
// 是嵌套对象,effective 从对象子字段取值;其余键照旧从 cfg 顶层取。
|
|
244
253
|
// 这张表必须与 settings.js 白名单里的点号键一一对应:#254 加键时漏了这里,
|
|
@@ -589,7 +598,10 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
589
598
|
const preview = renderUserSettingsBlock({
|
|
590
599
|
settings,
|
|
591
600
|
config,
|
|
592
|
-
agentId: url.searchParams.get("agent")
|
|
601
|
+
agentId: url.searchParams.get("agent"),
|
|
602
|
+
// 事实段与注入共用同一渲染函数(user-settings.js 的同源纪律):装配了
|
|
603
|
+
// profile-facts 就传读口,开关与有无的判定在渲染函数内部(关=零读数)。
|
|
604
|
+
factsProvider: typeof facts?.factsProvider === "function" ? facts.factsProvider : null
|
|
593
605
|
});
|
|
594
606
|
sendJson(res, 200, { persona: settings.getPersona(), preview });
|
|
595
607
|
} catch {
|
|
@@ -598,6 +610,115 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
598
610
|
}
|
|
599
611
|
});
|
|
600
612
|
|
|
613
|
+
// --- 「Agent 眼中的你」自动事实(2026-11-06,对标 ChatGPT 记忆 / DS Bot 每 bot
|
|
614
|
+
// 的 MEMORY.md 事实清单)----------------------------------------------
|
|
615
|
+
// 三条路由都**不碰 user_settings**:手填画像神圣(#497 口径),事实住
|
|
616
|
+
// profile_facts 独立三表,模型只在 dream 轮里增改、人在面板上逐条修正。
|
|
617
|
+
// GET:卡内事实 + 账本尾 + 最近回执,一次请求喂饱面板那张卡。flag 关着也照读
|
|
618
|
+
// ——历史上开过又关掉的库里事实还在,面板要能展示它(与 /profile 的 GET 同理)。
|
|
619
|
+
register({
|
|
620
|
+
kind: "exact",
|
|
621
|
+
path: "/api/dsh-mneme/profile-facts",
|
|
622
|
+
policy: "token-write",
|
|
623
|
+
handler(req, res) {
|
|
624
|
+
try {
|
|
625
|
+
if (typeof facts?.listFacts !== "function") return sendJson(res, 503, { error: "unavailable" });
|
|
626
|
+
const url = new URL(req.url, "http://localhost");
|
|
627
|
+
const raw = url.searchParams.get("agent");
|
|
628
|
+
const agent = typeof raw === "string" ? raw.trim() : "";
|
|
629
|
+
sendJson(res, 200, {
|
|
630
|
+
cards: facts.cardKeys(),
|
|
631
|
+
agent,
|
|
632
|
+
facts: facts.listFacts(agent, { includeArchived: true }),
|
|
633
|
+
journal: facts.listJournal(agent, { limit: 12 }),
|
|
634
|
+
runs: facts.listRuns(agent, { limit: 5 })
|
|
635
|
+
});
|
|
636
|
+
} catch {
|
|
637
|
+
sendJson(res, 500, { error: "internal" });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
// POST /apply:面板的逐条人工修正(增/改/归档),actor=manual,与自动轮共用
|
|
643
|
+
// 同一份 applyOps 与同一本账。flag 关着也放行——修正存量事实不需要维护轮开着。
|
|
644
|
+
register({
|
|
645
|
+
kind: "exact",
|
|
646
|
+
path: "/api/dsh-mneme/profile-facts/apply",
|
|
647
|
+
policy: "token-write",
|
|
648
|
+
handler(req, res) {
|
|
649
|
+
if (req.method !== "POST") {
|
|
650
|
+
res.writeHead(405, { Allow: "POST" });
|
|
651
|
+
res.end("");
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
if (typeof facts?.applyManualOps !== "function") return sendJson(res, 503, { error: "unavailable" });
|
|
655
|
+
return readBody(req, res).then((text) => {
|
|
656
|
+
if (text === null) return undefined;
|
|
657
|
+
const body = parseBody(text);
|
|
658
|
+
const agent = typeof body.agent === "string" ? body.agent.trim() : "";
|
|
659
|
+
const ops = Array.isArray(body.ops) ? body.ops : [];
|
|
660
|
+
if (!ops.length) return sendJson(res, 400, { error: "ops required" });
|
|
661
|
+
// 形状谓词与解析层共用一份(isValidFactOps):update 与 add 同样必须带
|
|
662
|
+
// content——缺了会把「undefined」写进事实正文并从此每帧注入。
|
|
663
|
+
if (!isValidFactOps(ops)) return sendJson(res, 400, { error: "invalid ops" });
|
|
664
|
+
let result;
|
|
665
|
+
try {
|
|
666
|
+
result = facts.applyManualOps(agent, ops);
|
|
667
|
+
} catch (error) {
|
|
668
|
+
// /run 有完整的错误映射,这条写路径也不能把同步异常(SQLite I/O)裸抛
|
|
669
|
+
// 给宿主——500 + 一句 internal,与 GET 分支的 try/catch 同款。
|
|
670
|
+
ctx.logger?.warn?.(`dsh-mneme: profile-facts apply failed: ${String(error?.message ?? error)}`);
|
|
671
|
+
return sendJson(res, 500, { error: "internal" });
|
|
672
|
+
}
|
|
673
|
+
sendJson(res, 200, {
|
|
674
|
+
applied: result.applied,
|
|
675
|
+
failures: result.failures,
|
|
676
|
+
dupsSkipped: result.dupsSkipped,
|
|
677
|
+
facts: facts.listFacts(agent, { includeArchived: true })
|
|
678
|
+
});
|
|
679
|
+
return undefined;
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// POST /run:手动维护轮。policy `token` 与 /profile/extract 同款取舍:无读分支
|
|
685
|
+
// + 一次 LLM 调用,声明成 token-write 会让开放读清单按「GET 开放」把它算进去。
|
|
686
|
+
// run 本体经 service.enqueue 与 dream/sleep 串行,不与巩固轮抢模型。
|
|
687
|
+
register({
|
|
688
|
+
kind: "exact",
|
|
689
|
+
path: "/api/dsh-mneme/profile-facts/run",
|
|
690
|
+
policy: "token",
|
|
691
|
+
handler(req, res) {
|
|
692
|
+
if (req.method !== "POST") {
|
|
693
|
+
res.writeHead(405, { Allow: "POST" });
|
|
694
|
+
res.end("");
|
|
695
|
+
return undefined;
|
|
696
|
+
}
|
|
697
|
+
if (config?.profileFactsEnabled !== true) return sendJson(res, 409, { error: "disabled" });
|
|
698
|
+
if (typeof facts?.runFor !== "function") return sendJson(res, 503, { error: "unavailable" });
|
|
699
|
+
return readBody(req, res).then((text) => {
|
|
700
|
+
if (text === null) return undefined;
|
|
701
|
+
const body = parseBody(text);
|
|
702
|
+
const agent = typeof body.agent === "string" ? body.agent.trim() : "";
|
|
703
|
+
const queued = service?.enqueue
|
|
704
|
+
? service.enqueue(() => facts.runFor(agent, { trigger: "manual" }))
|
|
705
|
+
: facts.runFor(agent, { trigger: "manual" });
|
|
706
|
+
return queued.then(
|
|
707
|
+
(receipt) => sendJson(res, 200, receipt),
|
|
708
|
+
(error) => {
|
|
709
|
+
// 「没配模型」与「模型不配合」两码(profileExtract 同款映射):
|
|
710
|
+
// 前者重跑也一样,后者可重试。noop 是正常结局(没有新记忆),200。
|
|
711
|
+
const code = typeof error?.code === "string" ? error.code : "";
|
|
712
|
+
if (code === "no-route") return sendJson(res, 503, { error: code });
|
|
713
|
+
if (code === "unparseable" || code === "stream-failed") return sendJson(res, 502, { error: code });
|
|
714
|
+
ctx.logger?.warn?.(`dsh-mneme: profile facts run failed: ${String(error?.message ?? error)}`);
|
|
715
|
+
return sendJson(res, 500, { error: "internal" });
|
|
716
|
+
}
|
|
717
|
+
);
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
|
|
601
722
|
// --- dream/sleep 模型连通性 + 模型发现(settings 面板)----------------------
|
|
602
723
|
// Connectivity probe for a dream/sleep model route: runs one minimal LLM call
|
|
603
724
|
// through the same harness path the consolidation uses and reports success /
|
|
@@ -1308,6 +1429,27 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1308
1429
|
}
|
|
1309
1430
|
});
|
|
1310
1431
|
|
|
1432
|
+
// 沉淀曲线(面板「8 周沉淀」图):按天返回 added / merged / archived 三条序列。
|
|
1433
|
+
// 与 llm-audit/stats 并列但不同源——那边算的是「花了多少 token」,这边算的是
|
|
1434
|
+
// 「进来了多少条、真被巩固掉多少条、归档了多少条」。合并数与新增数背离时
|
|
1435
|
+
// (2026-10-25~10-31 那段:新增照旧、合并归零)这张图是唯一能一眼看出来的地方。
|
|
1436
|
+
// 窗口钳制 1-365 天(缺省 56,正好是热力图的 8 周),口径见 store.getConsolidationStats。
|
|
1437
|
+
register({
|
|
1438
|
+
kind: "exact",
|
|
1439
|
+
path: "/api/dsh-mneme/semantic/consolidation-stats",
|
|
1440
|
+
policy: "open",
|
|
1441
|
+
handler(req, res) {
|
|
1442
|
+
try {
|
|
1443
|
+
const url = new URL(req.url, "http://localhost");
|
|
1444
|
+
const days = Math.max(1, Math.min(365, Number(url.searchParams.get("days") ?? 56) || 56));
|
|
1445
|
+
const stats = service.getConsolidationStats?.({ days }) ?? null;
|
|
1446
|
+
sendJson(res, 200, stats ?? { error: "unavailable" });
|
|
1447
|
+
} catch {
|
|
1448
|
+
sendJson(res, 500, { error: "internal" });
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1311
1453
|
// --- 记忆级溯源读出口(#399 写、#421 读)-----------------------------------
|
|
1312
1454
|
// #399 把蒸馏窗口的原文落进 distill_records、把 memory → (session, seq 区间)
|
|
1313
1455
|
// 的归因写进 memory_provenance,但只做了写:库里有答案、service.provenanceFor
|
|
@@ -1548,7 +1690,26 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1548
1690
|
const url = new URL(req.url, "http://localhost");
|
|
1549
1691
|
const limit = Math.min(1000, Math.max(1, Number(url.searchParams.get("limit") ?? 500) || 500));
|
|
1550
1692
|
const entities = service.listEntities?.({ limit }) ?? [];
|
|
1551
|
-
|
|
1693
|
+
// total 是**全库**实体数,不是本次切片长度:目录带 limit,面板拿它当
|
|
1694
|
+
// 「一共抽出多少实体」,填切片长度会让 1.6 万实体永远显示成 500。
|
|
1695
|
+
// 老宿主(service 上没有 countEntities)回落到切片长度,不抛。计数本身失败也
|
|
1696
|
+
// 只回落、不冒泡:它在外层 try 里,一抛整条响应就变成 500,已经把切片读出来的
|
|
1697
|
+
// 那份列表也跟着丢掉——一个装饰性的总数不该决定目录能不能显示。
|
|
1698
|
+
let total = entities.length;
|
|
1699
|
+
// totalExact 是给面板看的:计数失败或老宿主没这个方法时,total 只能退回切片
|
|
1700
|
+
// 长度,那已经不是「全库有多少」而只是「这次取回多少」。不回这个标记,面板
|
|
1701
|
+
// 会把切片长度当成全库数照常显示——正是这条路由原来那个「永远 500」的错法。
|
|
1702
|
+
let totalExact = false;
|
|
1703
|
+
if (typeof service.countEntities === "function") {
|
|
1704
|
+
try {
|
|
1705
|
+
total = service.countEntities();
|
|
1706
|
+
totalExact = true;
|
|
1707
|
+
} catch {
|
|
1708
|
+
total = entities.length;
|
|
1709
|
+
totalExact = false;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
sendJson(res, 200, { entities, total, totalExact });
|
|
1552
1713
|
} catch {
|
|
1553
1714
|
sendJson(res, 500, { error: "internal" });
|
|
1554
1715
|
}
|
|
@@ -1808,6 +1969,85 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1808
1969
|
}
|
|
1809
1970
|
});
|
|
1810
1971
|
|
|
1972
|
+
// --- 从目录引导(bootstrap,#386 第 6 条)------------------------------------
|
|
1973
|
+
// 后端能力从 #220 起就在(src/bootstrap.js:六类确定性产物、零 LLM、吃
|
|
1974
|
+
// saveWithDedupe 的去重),但只有 Bearer 数据面一条入口,面板够不着——「用户照
|
|
1975
|
+
// 文档去找入口,发现产品界面里没有」。
|
|
1976
|
+
//
|
|
1977
|
+
// policy 取 token 而不是 token-write:它写记忆(虽然幂等),与 /import 同类,
|
|
1978
|
+
// 而 /import 就是整条要 token 的。GET 不给读分支:没有「预览一次引导会写什么」
|
|
1979
|
+
// 的形态,硬造一个 dryRun 等于把 bootstrap.js 拆成两半,收益不抵风险。
|
|
1980
|
+
//
|
|
1981
|
+
// 为什么不在服务端限制目录范围:插件没有「工作区根」概念(bootstrap.js 的注释
|
|
1982
|
+
// 写着不猜路径),而这条路由与 /export、/import 同级,本来就能读写整库;额外加
|
|
1983
|
+
// 一个路径白名单只会让人以为它比邻座更安全。
|
|
1984
|
+
register({
|
|
1985
|
+
kind: "exact",
|
|
1986
|
+
path: "/api/dsh-mneme/bootstrap",
|
|
1987
|
+
policy: "token",
|
|
1988
|
+
handler(req, res) {
|
|
1989
|
+
try {
|
|
1990
|
+
if (req.method !== "POST") {
|
|
1991
|
+
sendJson(res, 404, { error: "not-found" });
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
return readBody(req, res).then((text) => {
|
|
1995
|
+
if (text === null) return;
|
|
1996
|
+
const parsed = parseBootstrapJson(text);
|
|
1997
|
+
if (!parsed.ok) {
|
|
1998
|
+
sendJson(res, 400, { error: parsed.error });
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
// 顺序:先判 body 形状与 dir,再判能力是否接线。缺 dir 是调用方的问题,
|
|
2002
|
+
// 报「能力没接线」会把人引到错误的排查方向(reclaim 那边同一条纪律)。
|
|
2003
|
+
const dirResult = readBootstrapDir(parsed.body);
|
|
2004
|
+
if (!dirResult.ok) {
|
|
2005
|
+
sendJson(res, 400, { error: dirResult.error });
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
if (typeof service?.saveWithDedupe !== "function") {
|
|
2009
|
+
sendJson(res, 503, { error: BOOTSTRAP_UNAVAILABLE });
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
return bootstrapReply({
|
|
2013
|
+
bootstrapper: ({ dir }) => bootstrapFromDirectory({ service, dir, logger: ctx.logger }),
|
|
2014
|
+
dir: dirResult.dir,
|
|
2015
|
+
warn: (msg) => ctx.logger?.warn?.(msg)
|
|
2016
|
+
}).then(({ status, body }) => sendJson(res, status, body));
|
|
2017
|
+
});
|
|
2018
|
+
} catch {
|
|
2019
|
+
sendJson(res, 500, { error: "internal" });
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
});
|
|
2023
|
+
|
|
2024
|
+
// --- 配置键覆盖面(#386 第 7 条,只读)--------------------------------------
|
|
2025
|
+
// 面板能改的键(settings.js 白名单)与 schema 里存在的键(config.js)是两张表,
|
|
2026
|
+
// 从没被摆在一起对过。这条路由把它们交/差出来,回答「哪些键只能改配置文件」。
|
|
2027
|
+
//
|
|
2028
|
+
// 只回**键名与计数**,一个值都不回:apiToken 这类敏感键的名字出现在「仅配置
|
|
2029
|
+
// 文件」清单里正是这条 issue 想让人看见的东西(否则用户不知道它存在),但值
|
|
2030
|
+
// 出这道门就会成为一个新的泄漏面。
|
|
2031
|
+
//
|
|
2032
|
+
// policy 取 open:与 /inject-status、/features 的读分支同级——面板要渲染,且
|
|
2033
|
+
// 键名本身已经逐条写在 docs/CONFIGURATION.md 里(公开文档),不构成新秘密。
|
|
2034
|
+
register({
|
|
2035
|
+
kind: "exact",
|
|
2036
|
+
path: "/api/dsh-mneme/config-keys",
|
|
2037
|
+
policy: "open",
|
|
2038
|
+
handler(req, res) {
|
|
2039
|
+
try {
|
|
2040
|
+
if (req.method !== "GET") {
|
|
2041
|
+
sendJson(res, 404, { error: "not-found" });
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
sendJson(res, 200, describeConfigKeys());
|
|
2045
|
+
} catch {
|
|
2046
|
+
sendJson(res, 500, { error: "internal" });
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
});
|
|
2050
|
+
|
|
1811
2051
|
// --- 巩固状态(dream 面板)--------------------------------------------------
|
|
1812
2052
|
// 只读:最近巩固运行(最多 5 条,created_at DESC)+ 未解决冲突队列。
|
|
1813
2053
|
// pendingMemoryIds 从未解决冲突行提取涉及的记忆 id,去重后上限 200,避免积
|
|
@@ -2431,6 +2671,168 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
2431
2671
|
}
|
|
2432
2672
|
});
|
|
2433
2673
|
|
|
2674
|
+
// --- chorography(仓库地志)M1 读侧路由 -------------------------------
|
|
2675
|
+
// spec §7 消费面的面板数据源。四条全 policy "open":只读、宿主围栏统一鉴权
|
|
2676
|
+
// (与 /list、/version-check 同级);**chorographyEnabled 关闭时一律 409
|
|
2677
|
+
// disabled**(#497 先例:路由挂着、逻辑层挡——面板据此渲染「未启用」空态)。
|
|
2678
|
+
// 仓库/模块/页面全部只从 choro_* 表与仓库磁盘读(真源是盘上的 MD),没有
|
|
2679
|
+
// 任何写路径;生成入口在 M2 才有(T1 生成轮同批接)。
|
|
2680
|
+
// choro 参数缺省 null:headless/测试环境没有 queries 时这四条路由回 503
|
|
2681
|
+
// unavailable(语义分叉见 #497:503 = 环境缺件,409 = 用户没开)。
|
|
2682
|
+
function choroGate(req, res) {
|
|
2683
|
+
if (!choro) {
|
|
2684
|
+
sendJson(res, 503, { error: "unavailable", reason: "choro-queries-unavailable" });
|
|
2685
|
+
return false;
|
|
2686
|
+
}
|
|
2687
|
+
if (!config || config.chorographyEnabled !== true) {
|
|
2688
|
+
sendJson(res, 409, { error: "disabled", reason: "chorography-not-enabled" });
|
|
2689
|
+
return false;
|
|
2690
|
+
}
|
|
2691
|
+
return true;
|
|
2692
|
+
}
|
|
2693
|
+
function choroResolveOr4xx(req, res) {
|
|
2694
|
+
const repo = choro.resolveRepo(
|
|
2695
|
+
new URL(req.url, "http://localhost").searchParams.get("repo")
|
|
2696
|
+
);
|
|
2697
|
+
if (!repo || repo.ambiguous) {
|
|
2698
|
+
sendJson(res, repo && repo.ambiguous ? 400 : 404, {
|
|
2699
|
+
error: repo && repo.ambiguous ? "repo-ambiguous" : "repo-not-found",
|
|
2700
|
+
repos: repo && repo.ambiguous ? repo.ambiguous : undefined
|
|
2701
|
+
});
|
|
2702
|
+
return null;
|
|
2703
|
+
}
|
|
2704
|
+
return repo;
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
register({
|
|
2708
|
+
kind: "exact",
|
|
2709
|
+
path: "/api/dsh-mneme/choro/repos",
|
|
2710
|
+
policy: "open",
|
|
2711
|
+
handler(req, res) {
|
|
2712
|
+
if (!choroGate(req, res)) return;
|
|
2713
|
+
sendJson(res, 200, { repos: choro.listRepos() });
|
|
2714
|
+
}
|
|
2715
|
+
});
|
|
2716
|
+
|
|
2717
|
+
register({
|
|
2718
|
+
kind: "exact",
|
|
2719
|
+
path: "/api/dsh-mneme/choro/modules",
|
|
2720
|
+
policy: "open",
|
|
2721
|
+
handler(req, res) {
|
|
2722
|
+
if (!choroGate(req, res)) return;
|
|
2723
|
+
const repo = choroResolveOr4xx(req, res);
|
|
2724
|
+
if (!repo) return;
|
|
2725
|
+
const { modules, edges } = choro.moduleMap(repo.id);
|
|
2726
|
+
sendJson(res, 200, {
|
|
2727
|
+
repo: { id: repo.id, name: repo.name, gitBaseline: repo.git_baseline },
|
|
2728
|
+
modules,
|
|
2729
|
+
edges
|
|
2730
|
+
});
|
|
2731
|
+
}
|
|
2732
|
+
});
|
|
2733
|
+
|
|
2734
|
+
register({
|
|
2735
|
+
kind: "exact",
|
|
2736
|
+
path: "/api/dsh-mneme/choro/page",
|
|
2737
|
+
policy: "open",
|
|
2738
|
+
handler(req, res) {
|
|
2739
|
+
if (!choroGate(req, res)) return;
|
|
2740
|
+
const repo = choroResolveOr4xx(req, res);
|
|
2741
|
+
if (!repo) return;
|
|
2742
|
+
const slug = new URL(req.url, "http://localhost").searchParams.get("slug");
|
|
2743
|
+
if (!slug) {
|
|
2744
|
+
sendJson(res, 400, { error: "slug-required" });
|
|
2745
|
+
return;
|
|
2746
|
+
}
|
|
2747
|
+
const page = choro.readPage(repo.id, slug);
|
|
2748
|
+
if (!page) {
|
|
2749
|
+
sendJson(res, 404, { error: "page-not-found", slug });
|
|
2750
|
+
return;
|
|
2751
|
+
}
|
|
2752
|
+
if (page.error) {
|
|
2753
|
+
// 409 保留已登记的页与未知 slug 的区别,恢复方式取决于文件状态。
|
|
2754
|
+
sendJson(res, 409, {
|
|
2755
|
+
error: page.error,
|
|
2756
|
+
slug: page.slug,
|
|
2757
|
+
relPath: page.relPath,
|
|
2758
|
+
hint: page.error === "page-missing"
|
|
2759
|
+
? "run `dsh-mneme-choro <repo> --apply` to rebuild"
|
|
2760
|
+
: "check the local file and its permissions"
|
|
2761
|
+
});
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
sendJson(res, 200, { page });
|
|
2765
|
+
}
|
|
2766
|
+
});
|
|
2767
|
+
|
|
2768
|
+
register({
|
|
2769
|
+
kind: "exact",
|
|
2770
|
+
path: "/api/dsh-mneme/choro/search",
|
|
2771
|
+
policy: "open",
|
|
2772
|
+
handler(req, res) {
|
|
2773
|
+
if (!choroGate(req, res)) return;
|
|
2774
|
+
const repo = choroResolveOr4xx(req, res);
|
|
2775
|
+
if (!repo) return;
|
|
2776
|
+
const url = new URL(req.url, "http://localhost");
|
|
2777
|
+
const hits = choro.search(repo.id, url.searchParams.get("q"), {
|
|
2778
|
+
moduleSlug: url.searchParams.get("module"),
|
|
2779
|
+
limit: Number(url.searchParams.get("limit") ?? 20)
|
|
2780
|
+
});
|
|
2781
|
+
// 向量路(收尾档 P37/P39):embedder 可用时惰性物化 + 余弦 Top-N,独立成
|
|
2782
|
+
// semantic 数组——两路分数量纲不同,不混排序。embedder 缺席(CLI/测试装配)
|
|
2783
|
+
// → semantic: null,调用方据此退纯关键词。永不抛:向量路挂了不影响 hits。
|
|
2784
|
+
const embedder = choro.embedder ?? null;
|
|
2785
|
+
const finish = (semantic) => sendJson(res, 200, { q: url.searchParams.get("q"), hits, semantic });
|
|
2786
|
+
if (!embedder) { finish(null); return; }
|
|
2787
|
+
// 必须 return 整条链:invoke/测试是 await route.handler(...),不 return 就
|
|
2788
|
+
// 会在响应发出之前返回(实测:semantic 永远缺席、body 恒空)。
|
|
2789
|
+
// 顺序(P37/P38):先嵌查询拿 expectDim → 惰性物化(dim 失配全量重嵌)→
|
|
2790
|
+
// 本轮 semantic 用刚物化完的最新向量算。
|
|
2791
|
+
return Promise.resolve()
|
|
2792
|
+
.then(() => choro.embedQueryText(embedder, url.searchParams.get("q")))
|
|
2793
|
+
.then((qv) => choro.embedPageVectors(repo.id, embedder, { expectDim: qv ? qv.length : null }).then(() => qv))
|
|
2794
|
+
.then((qv) => choro.semanticTopN(repo.id, qv, { limit: Number(url.searchParams.get("limit") ?? 10) }))
|
|
2795
|
+
.then((semantic) => finish(semantic))
|
|
2796
|
+
.catch(() => finish(null));
|
|
2797
|
+
}
|
|
2798
|
+
});
|
|
2799
|
+
|
|
2800
|
+
// 面板重建入口(收尾档 P34):T0 apply 秒级同步回执;**T1 不上 HTTP**——单模块
|
|
2801
|
+
// 30–240s 起步的长任务不适合挂死面板请求,CLI(--t1)仍是 T1 唯一入口。
|
|
2802
|
+
// policy token-write:写的是仓库磁盘的 .chorography/**,属数据面写。
|
|
2803
|
+
register({
|
|
2804
|
+
kind: "exact",
|
|
2805
|
+
path: "/api/dsh-mneme/choro/run",
|
|
2806
|
+
policy: "token-write",
|
|
2807
|
+
handler(req, res) {
|
|
2808
|
+
if (req.method !== "POST") {
|
|
2809
|
+
sendJson(res, 405, { error: "method-not-allowed", allow: "POST" }, { Allow: "POST" });
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
if (!choroGate(req, res)) return;
|
|
2813
|
+
// return 链:同上,await route.handler 必须等到响应真的发出(nudge 同款)。
|
|
2814
|
+
return readBody(req, res).then((text) => {
|
|
2815
|
+
if (text === null) return; // readBody 已回 413
|
|
2816
|
+
const body = parseBody(text);
|
|
2817
|
+
const repoRef = body && typeof body === "object" ? body.repo : null;
|
|
2818
|
+
const repo = choro.resolveRepo(repoRef);
|
|
2819
|
+
if (!repo || repo.ambiguous) {
|
|
2820
|
+
sendJson(res, repo && repo.ambiguous ? 400 : 404, {
|
|
2821
|
+
error: repo && repo.ambiguous ? "repo-ambiguous" : "repo-not-found",
|
|
2822
|
+
repos: repo && repo.ambiguous ? repo.ambiguous : undefined
|
|
2823
|
+
});
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
try {
|
|
2827
|
+
const receipt = choro.runT0Apply({ repoRoot: repo.root_path });
|
|
2828
|
+
sendJson(res, 200, { ok: true, receipt });
|
|
2829
|
+
} catch (err) {
|
|
2830
|
+
sendJson(res, 500, { error: "t0-failed", detail: String((err && err.message) || err) });
|
|
2831
|
+
}
|
|
2832
|
+
});
|
|
2833
|
+
}
|
|
2834
|
+
});
|
|
2835
|
+
|
|
2434
2836
|
return {
|
|
2435
2837
|
routes: countRoutes(),
|
|
2436
2838
|
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);
|