@modusensus/dsh-mneme 0.7.14 → 0.7.16
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.en.md +2 -2
- package/README.md +6 -4
- package/lib/api.js +338 -7
- package/lib/client.js +1238 -204
- package/lib/dream/sleep.js +17 -15
- package/lib/dream.js +94 -48
- package/lib/index.js +25 -2
- package/lib/mirror.js +93 -59
- package/lib/service.js +2 -0
- package/lib/settings.js +174 -0
- package/lib/store.js +77 -4
- package/package.json +12 -1
- package/src/api.js +338 -7
- package/src/dream/sleep.js +17 -15
- package/src/dream.js +94 -48
- package/src/index.js +25 -2
- package/src/mirror.js +93 -59
- package/src/service.js +2 -0
- package/src/settings.js +174 -0
- package/src/store.js +77 -4
- package/test/api.test.js +485 -2
- package/test/client.test.js +82 -52
- package/test/dream.test.js +1 -1
- package/test/helpers/peer-worker.mjs +17 -2
- package/test/llm-audit.test.js +34 -4
- package/test/peer-blockers.test.js +8 -2
- package/test/reasoning-effort.test.js +64 -0
- package/test/settings.test.js +136 -0
package/src/api.js
CHANGED
|
@@ -1,11 +1,35 @@
|
|
|
1
1
|
import { URL } from "node:url";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
4
|
+
import { FEATURE_FLAG_SPEC } from "./settings.js";
|
|
5
|
+
import { TYPE_FILE, renderMirrorText, parseHumanEdits } from "./mirror.js";
|
|
3
6
|
|
|
4
|
-
|
|
5
|
-
|
|
7
|
+
// headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
|
|
8
|
+
function sendJson(res, status, payload, headers = {}) {
|
|
9
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", ...headers });
|
|
6
10
|
res.end(JSON.stringify(payload));
|
|
7
11
|
}
|
|
8
12
|
|
|
13
|
+
// 附件下载响应(导出端点):Content-Type 与文件名(含日期后缀)由调用方给定。
|
|
14
|
+
function sendAttachment(res, status, contentType, filename, body) {
|
|
15
|
+
res.writeHead(status, {
|
|
16
|
+
"Content-Type": contentType,
|
|
17
|
+
"Content-Disposition": `attachment; filename="${filename}"`
|
|
18
|
+
});
|
|
19
|
+
res.end(body);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 导出 JSON 的 version 字段:读插件根的 package.json(src/ 与 lib/ 都在根下
|
|
23
|
+
// 一层,相对 import.meta.url 解析一致)。读取失败(打包/受限环境)降级为
|
|
24
|
+
// "unknown",导出本身仍然可用。
|
|
25
|
+
const PACKAGE_VERSION = (() => {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "unknown";
|
|
28
|
+
} catch {
|
|
29
|
+
return "unknown";
|
|
30
|
+
}
|
|
31
|
+
})();
|
|
32
|
+
|
|
9
33
|
// Defaults for the standalone external API — keep in step with the schema
|
|
10
34
|
// defaults in config.js (externalApiPort / externalApiHost).
|
|
11
35
|
const EXTERNAL_API_DEFAULTS = { enabled: false, port: 8790, host: "127.0.0.1" };
|
|
@@ -70,9 +94,43 @@ function parseBody(text) {
|
|
|
70
94
|
}
|
|
71
95
|
}
|
|
72
96
|
|
|
73
|
-
export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "") {
|
|
97
|
+
export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null) {
|
|
74
98
|
const disposers = [];
|
|
75
99
|
|
|
100
|
+
// feature flags 快照(GET/PUT 共用):overrides 是持久化的用户显式覆盖;
|
|
101
|
+
// effective 是启动配置在白名单键上被 overrides 覆盖后的最终值。config 缺席
|
|
102
|
+
// (旧调用方直连、未传 cfg)时只报被覆盖的键,不把不存在的默认值编造给前端。
|
|
103
|
+
const flagKeys = [
|
|
104
|
+
...FEATURE_FLAG_SPEC.booleans,
|
|
105
|
+
...Object.keys(FEATURE_FLAG_SPEC.ints),
|
|
106
|
+
...FEATURE_FLAG_SPEC.strings,
|
|
107
|
+
...FEATURE_FLAG_SPEC.urls,
|
|
108
|
+
...Object.keys(FEATURE_FLAG_SPEC.enums)
|
|
109
|
+
];
|
|
110
|
+
// 嵌套键在 kv 里按点号平铺("memoryQualityFilter.enabled"),运行时 cfg 里
|
|
111
|
+
// 是嵌套对象,effective 从对象子字段取值;其余键照旧从 cfg 顶层取。
|
|
112
|
+
const NESTED_FLAG_PATHS = {
|
|
113
|
+
"memoryQualityFilter.enabled": ["memoryQualityFilter", "enabled"],
|
|
114
|
+
"llmAudit.enabled": ["llmAudit", "enabled"]
|
|
115
|
+
};
|
|
116
|
+
function configFlagValue(key) {
|
|
117
|
+
const path = NESTED_FLAG_PATHS[key];
|
|
118
|
+
if (path) return config?.[path[0]]?.[path[1]];
|
|
119
|
+
return config?.[key];
|
|
120
|
+
}
|
|
121
|
+
function featureSnapshot() {
|
|
122
|
+
const overrides = settings.getFeatureFlags();
|
|
123
|
+
const effective = {};
|
|
124
|
+
for (const key of flagKeys) {
|
|
125
|
+
if (overrides[key] !== undefined) effective[key] = overrides[key];
|
|
126
|
+
else {
|
|
127
|
+
const value = configFlagValue(key);
|
|
128
|
+
if (value !== undefined) effective[key] = value;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { overrides, effective };
|
|
132
|
+
}
|
|
133
|
+
|
|
76
134
|
// Ensure the service has an embedder when the API layer was handed one
|
|
77
135
|
// (tests wire the embedder through the API instead of index.js). Without
|
|
78
136
|
// this, /api/dsh-mneme/search would silently degrade to keyword-only.
|
|
@@ -112,10 +170,26 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
112
170
|
? Number(minRaw)
|
|
113
171
|
: undefined;
|
|
114
172
|
const source = url.searchParams.get("source") || undefined;
|
|
115
|
-
|
|
173
|
+
// updatedFrom/updatedTo:updated_at 闭区间过滤(ISO 日期或完整时间戳)。
|
|
174
|
+
// 边界归一化在 store 的 updatedAtBounds(list/count 共用同一纯函数)
|
|
175
|
+
// 完成,非法值在那里被忽略——这里原样透传即可。
|
|
176
|
+
const updatedFrom = url.searchParams.get("updatedFrom") ?? undefined;
|
|
177
|
+
const updatedTo = url.searchParams.get("updatedTo") ?? undefined;
|
|
178
|
+
// archived=only:只看归档(状态页的归档列表用)。归档行不进默认列表,
|
|
179
|
+
// 所以这是独立的视图开关,而不是 includeArchived 的混看模式。
|
|
180
|
+
const onlyArchived = url.searchParams.get("archived") === "only";
|
|
181
|
+
const rows = service.list({ type, limit, offset, order, minImportance, source, updatedFrom, updatedTo, onlyArchived });
|
|
182
|
+
// 面板行在 wire DTO 之上补 archived/quality_score——模型工具的输出
|
|
183
|
+
// schema 严格复用 toApiList,扩展只发生在 HTTP 层。
|
|
184
|
+
const items = service.toApiList(rows).map((m, i) => ({
|
|
185
|
+
...m,
|
|
186
|
+
archived: rows[i].archived === true || rows[i].archived === 1,
|
|
187
|
+
quality_score: rows[i].quality_score ?? null
|
|
188
|
+
}));
|
|
116
189
|
// Total honors the same filters as the rows, or the pager's
|
|
117
|
-
// has-more math breaks whenever minImportance/source
|
|
118
|
-
|
|
190
|
+
// has-more math breaks whenever minImportance/source/updated-at
|
|
191
|
+
// bounds are active.
|
|
192
|
+
sendJson(res, 200, { items, total: service.count(type, { minImportance, source, updatedFrom, updatedTo, onlyArchived }) });
|
|
119
193
|
} catch {
|
|
120
194
|
sendJson(res, 500, { error: "internal" });
|
|
121
195
|
}
|
|
@@ -220,6 +294,90 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
220
294
|
}
|
|
221
295
|
});
|
|
222
296
|
|
|
297
|
+
// --- 编辑/归档一条记忆(面板写路径,与 /delete 对称命名)-------------------
|
|
298
|
+
// POST body {id, title?, content?, importance?, tags?, archived?}。字段校验
|
|
299
|
+
// 只做类型/范围检查:字段出现(!== undefined)就必须合法,宁 400 不静默纠正
|
|
300
|
+
// ——静默丢字段会让面板误以为保存成功。写入走 service 的正规更新路径:
|
|
301
|
+
// updated_at 由 store 的单调时钟推进;content 被改写时旧版本按 human_override
|
|
302
|
+
// 入档(与镜像人工编辑回灌 mergeHumanEdits 的语义对齐,FIFO 上限 20);
|
|
303
|
+
// archived 走 setArchived。两条路径都会触发镜像重渲染(afterSync)。
|
|
304
|
+
register({
|
|
305
|
+
kind: "exact",
|
|
306
|
+
path: "/api/dsh-mneme/update",
|
|
307
|
+
handler(req, res) {
|
|
308
|
+
try {
|
|
309
|
+
if (req.method !== "POST") {
|
|
310
|
+
sendJson(res, 404, { error: "not-found" });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
314
|
+
return readBody(req).then((text) => {
|
|
315
|
+
const body = parseBody(text);
|
|
316
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
317
|
+
if (!id) {
|
|
318
|
+
sendJson(res, 400, { error: "missing-id" });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const patch = {};
|
|
322
|
+
if (body.title !== undefined) {
|
|
323
|
+
if (typeof body.title !== "string" || !body.title.trim()) {
|
|
324
|
+
sendJson(res, 400, { error: "invalid-title" });
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
patch.title = body.title.trim();
|
|
328
|
+
}
|
|
329
|
+
if (body.content !== undefined) {
|
|
330
|
+
if (typeof body.content !== "string" || !body.content.trim()) {
|
|
331
|
+
sendJson(res, 400, { error: "invalid-content" });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
patch.content = body.content.trim();
|
|
335
|
+
}
|
|
336
|
+
if (body.importance !== undefined) {
|
|
337
|
+
if (!Number.isInteger(body.importance) || body.importance < 1 || body.importance > 5) {
|
|
338
|
+
sendJson(res, 400, { error: "invalid-importance" });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
patch.importance = body.importance;
|
|
342
|
+
}
|
|
343
|
+
if (body.tags !== undefined) {
|
|
344
|
+
if (!Array.isArray(body.tags) || !body.tags.every((t) => typeof t === "string")) {
|
|
345
|
+
sendJson(res, 400, { error: "invalid-tags" });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
patch.tags = body.tags;
|
|
349
|
+
}
|
|
350
|
+
if (body.archived !== undefined && typeof body.archived !== "boolean") {
|
|
351
|
+
sendJson(res, 400, { error: "invalid-archived" });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (Object.keys(patch).length === 0 && body.archived === undefined) {
|
|
355
|
+
sendJson(res, 400, { error: "no-fields" });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const existing = service.getById(id);
|
|
359
|
+
if (!existing) {
|
|
360
|
+
sendJson(res, 404, { error: "not-found" });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
// 内容被改写时旧版本先入档(human_override),人工修正不静默销毁旧值。
|
|
364
|
+
if (patch.content !== undefined && patch.content !== existing.content) {
|
|
365
|
+
const history = Array.isArray(existing.content_history) ? existing.content_history : [];
|
|
366
|
+
patch.content_history = [
|
|
367
|
+
{ content: existing.content ?? "", source: "human_override", updated_at: new Date().toISOString() },
|
|
368
|
+
...history
|
|
369
|
+
].slice(0, 20);
|
|
370
|
+
}
|
|
371
|
+
if (Object.keys(patch).length) service.update(id, patch);
|
|
372
|
+
if (body.archived !== undefined) service.setArchived(id, body.archived);
|
|
373
|
+
sendJson(res, 200, { memory: service.getById(id) });
|
|
374
|
+
});
|
|
375
|
+
} catch {
|
|
376
|
+
sendJson(res, 500, { error: "internal" });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
223
381
|
// --- rules ---
|
|
224
382
|
register({
|
|
225
383
|
kind: "exact",
|
|
@@ -501,6 +659,144 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
501
659
|
}
|
|
502
660
|
});
|
|
503
661
|
|
|
662
|
+
// --- 一条记忆的关联实体(记忆详情侧栏)-------------------------------------
|
|
663
|
+
// 只读:entity_attrs.memory_id 反查实体(store 一条 JOIN 完成,按提及去重)。
|
|
664
|
+
// 与目录 /entities 不同,这里以记忆为锚点,回答"这条记忆提到了谁"。记忆不
|
|
665
|
+
// 存在或无关联一律返回空数组——详情侧栏不需要区分这两种情况。
|
|
666
|
+
register({
|
|
667
|
+
kind: "exact",
|
|
668
|
+
path: "/api/dsh-mneme/memories/entities",
|
|
669
|
+
handler(req, res) {
|
|
670
|
+
try {
|
|
671
|
+
const url = new URL(req.url, "http://localhost");
|
|
672
|
+
const memoryId = (url.searchParams.get("memoryId") ?? "").trim();
|
|
673
|
+
if (!memoryId) {
|
|
674
|
+
sendJson(res, 400, { error: "missing-memory-id" });
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
sendJson(res, 200, { entities: service.entitiesForMemory?.(memoryId) ?? [] });
|
|
678
|
+
} catch {
|
|
679
|
+
sendJson(res, 500, { error: "internal" });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// --- 导出(面板备份/迁移)---------------------------------------------------
|
|
685
|
+
// 只读。json:全字段行(含 archived/forgotten,布尔化),updated_at DESC;
|
|
686
|
+
// markdown:按类型分节,块格式与磁盘镜像完全同构(renderMirrorText 与
|
|
687
|
+
// mirror.sync 共用同一条渲染路径),因此导出文本可以被 /import 原样吃回。
|
|
688
|
+
// 全量一次性取回(store.all 按 updated_at DESC)——导出是一次性备份动作,
|
|
689
|
+
// 不需要流式。
|
|
690
|
+
register({
|
|
691
|
+
kind: "exact",
|
|
692
|
+
path: "/api/dsh-mneme/export",
|
|
693
|
+
handler(req, res) {
|
|
694
|
+
try {
|
|
695
|
+
const url = new URL(req.url, "http://localhost");
|
|
696
|
+
const format = url.searchParams.get("format") ?? "json";
|
|
697
|
+
if (format !== "json" && format !== "markdown") {
|
|
698
|
+
sendJson(res, 400, { error: "invalid-format" });
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
const rows = service.all?.() ?? [];
|
|
702
|
+
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
|
703
|
+
if (format === "json") {
|
|
704
|
+
sendJson(res, 200, {
|
|
705
|
+
exported_at: new Date().toISOString(),
|
|
706
|
+
version: PACKAGE_VERSION,
|
|
707
|
+
count: rows.length,
|
|
708
|
+
memories: rows
|
|
709
|
+
}, { "Content-Disposition": `attachment; filename="dsh-mneme-export-${stamp}.json"` });
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const byType = {};
|
|
713
|
+
for (const row of rows) {
|
|
714
|
+
if (TYPE_FILE[row.type]) (byType[row.type] ??= []).push(row);
|
|
715
|
+
}
|
|
716
|
+
const sections = [];
|
|
717
|
+
for (const type of Object.keys(TYPE_FILE)) {
|
|
718
|
+
if (byType[type]?.length) sections.push(renderMirrorText(type, byType[type]));
|
|
719
|
+
}
|
|
720
|
+
sendAttachment(res, 200, "text/markdown; charset=utf-8", `dsh-mneme-export-${stamp}.md`, sections.join("\n"));
|
|
721
|
+
} catch {
|
|
722
|
+
sendJson(res, 500, { error: "internal" });
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// --- 导入(Markdown 镜像回填)----------------------------------------------
|
|
728
|
+
// 写路径(requireAuth)。body {type, markdown}:type 是镜像 TYPE_FILE 键
|
|
729
|
+
// (preference/project/decision/history/summary),markdown 是与镜像文件同构
|
|
730
|
+
// 的文本。解析复用 readHumanEdits 的纯函数核心 parseHumanEdits(同一实现,
|
|
731
|
+
// 行为一致是硬约束),合并走 mergeHumanEdits(只吃 title/content;digest 命
|
|
732
|
+
// 中或无差异的条目在 service 侧自动跳过)。解析出 0 条不算错误。
|
|
733
|
+
register({
|
|
734
|
+
kind: "exact",
|
|
735
|
+
path: "/api/dsh-mneme/import",
|
|
736
|
+
handler(req, res) {
|
|
737
|
+
try {
|
|
738
|
+
if (req.method !== "POST") {
|
|
739
|
+
sendJson(res, 404, { error: "not-found" });
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
743
|
+
return readBody(req).then((text) => {
|
|
744
|
+
const body = parseBody(text);
|
|
745
|
+
if (typeof body.type !== "string" || !Object.hasOwn(TYPE_FILE, body.type)) {
|
|
746
|
+
sendJson(res, 400, { error: "invalid-type" });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (typeof body.markdown !== "string" || !body.markdown.trim()) {
|
|
750
|
+
sendJson(res, 400, { error: "invalid-markdown" });
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const edits = parseHumanEdits(body.markdown);
|
|
754
|
+
service.mergeHumanEdits(body.type, edits);
|
|
755
|
+
sendJson(res, 200, { merged: edits.length, type: body.type });
|
|
756
|
+
});
|
|
757
|
+
} catch {
|
|
758
|
+
sendJson(res, 500, { error: "internal" });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
// --- 巩固状态(dream 面板)--------------------------------------------------
|
|
764
|
+
// 只读:最近巩固运行(最多 5 条,created_at DESC)+ 未解决冲突队列。
|
|
765
|
+
// pendingMemoryIds 从未解决冲突行提取涉及的记忆 id,去重后上限 200,避免积
|
|
766
|
+
// 压很大时响应失控。listDreamRuns/listConflictPending 走 service 现成的审计
|
|
767
|
+
// 只读通道(bookkeeping 语义,不触发写钩子)。
|
|
768
|
+
register({
|
|
769
|
+
kind: "exact",
|
|
770
|
+
path: "/api/dsh-mneme/dream-status",
|
|
771
|
+
handler(req, res) {
|
|
772
|
+
try {
|
|
773
|
+
const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map((r) => ({
|
|
774
|
+
created_at: r.created_at,
|
|
775
|
+
status: r.status,
|
|
776
|
+
provider: r.provider ?? null,
|
|
777
|
+
model: r.model ?? null,
|
|
778
|
+
error: r.error ?? null
|
|
779
|
+
}));
|
|
780
|
+
const pendingConflicts = service.countConflictPending?.() ?? 0;
|
|
781
|
+
const ids = new Set();
|
|
782
|
+
for (const row of service.listConflictPending?.({ limit: 200, includeResolved: false }) ?? []) {
|
|
783
|
+
if (ids.size >= 200) break;
|
|
784
|
+
if (row.memory_a) ids.add(row.memory_a);
|
|
785
|
+
if (ids.size >= 200) break;
|
|
786
|
+
if (row.memory_b) ids.add(row.memory_b);
|
|
787
|
+
}
|
|
788
|
+
sendJson(res, 200, {
|
|
789
|
+
lastRun: runs[0] ?? null,
|
|
790
|
+
runs,
|
|
791
|
+
pendingConflicts,
|
|
792
|
+
pendingMemoryIds: [...ids].slice(0, 200)
|
|
793
|
+
});
|
|
794
|
+
} catch {
|
|
795
|
+
sendJson(res, 500, { error: "internal" });
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
|
|
504
800
|
// --- health: mirror sync state (F-NEW-03 / v0.3.6) ---
|
|
505
801
|
// Auth-gated; only returns a sanitized error code (never raw last_error which
|
|
506
802
|
// may leak paths/token-like strings/internal hosts). On state read failure it
|
|
@@ -624,6 +920,41 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
624
920
|
}
|
|
625
921
|
});
|
|
626
922
|
|
|
923
|
+
// --- feature flags(功能开关:面板逐项开关后端能力)-------------------------
|
|
924
|
+
// 读路由保持开放(与 mode/list 一致),前端无需 token 即可渲染开关状态;PUT
|
|
925
|
+
// 与其他设置写一致走 requireAuth。空/非对象 patch 直接 400:它不携带任何
|
|
926
|
+
// 意图,静默成功只会掩盖前端 bug。校验失败(未知键/类型/越界)的 400 把键
|
|
927
|
+
// 名放进 error,前端能直接定位写坏的开关。生效节奏与 panel_mode 相同:
|
|
928
|
+
// 持久化后下次启动合并进 cfg,本次启动的运行时行为不变。
|
|
929
|
+
register({
|
|
930
|
+
kind: "exact",
|
|
931
|
+
path: "/api/dsh-mneme/features",
|
|
932
|
+
handler(req, res) {
|
|
933
|
+
try {
|
|
934
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
935
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
936
|
+
return readBody(req).then((text) => {
|
|
937
|
+
const body = parseBody(text);
|
|
938
|
+
if (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).length === 0) {
|
|
939
|
+
sendJson(res, 400, { error: "invalid-patch" });
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
try {
|
|
943
|
+
settings.setFeatureFlags(body);
|
|
944
|
+
} catch (error) {
|
|
945
|
+
sendJson(res, 400, { error: error.message });
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
sendJson(res, 200, featureSnapshot());
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
sendJson(res, 200, featureSnapshot());
|
|
952
|
+
} catch {
|
|
953
|
+
sendJson(res, 500, { error: "internal" });
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
|
|
627
958
|
// --- custom commands ---
|
|
628
959
|
register({
|
|
629
960
|
kind: "exact",
|
|
@@ -662,7 +993,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
662
993
|
});
|
|
663
994
|
|
|
664
995
|
return {
|
|
665
|
-
routes:
|
|
996
|
+
routes: 23,
|
|
666
997
|
dispose: () => {
|
|
667
998
|
for (const dispose of disposers) dispose();
|
|
668
999
|
}
|
package/src/dream/sleep.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { randomUUID, createHash } from "node:crypto";
|
|
18
18
|
import { validateDecisions, applyDecisions } from "./decisions.js";
|
|
19
19
|
import { findPotentialConflicts } from "./clustering.js";
|
|
20
|
-
import { buildReceipt } from "../dream.js";
|
|
20
|
+
import { buildReceipt, withEffortFallback } from "../dream.js";
|
|
21
21
|
|
|
22
22
|
const SUMMARY_MAX = 120;
|
|
23
23
|
// Conflict similarity threshold per strictness level (v0.4.0):
|
|
@@ -72,16 +72,18 @@ async function streamText(ctx, options) {
|
|
|
72
72
|
return text;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/** LLM route
|
|
76
|
-
*
|
|
77
|
-
* bulk passes without disturbing the dream
|
|
75
|
+
/** LLM route (Issue #25): explicit sleepProvider/Model wins, then the dream
|
|
76
|
+
* route as a shared explicit fallback, then the agent default model. Sleep
|
|
77
|
+
* can pin a cheaper model for its bulk passes without disturbing the dream
|
|
78
|
+
* route. Explicit config first — otherwise the config routes are dead code
|
|
79
|
+
* whenever agentDefaultModel resolves (see resolveRoute in dream.js). */
|
|
78
80
|
function resolveSleepRoute(ctx, config, logger) {
|
|
81
|
+
if (config.sleepProvider && config.sleepModel) return { provider: config.sleepProvider, model: config.sleepModel };
|
|
82
|
+
if (config.dreamProvider && config.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
|
|
79
83
|
try {
|
|
80
84
|
const sel = ctx?.agentDefaultModel?.currentSelection?.();
|
|
81
85
|
if (sel?.provider && sel?.model) return { provider: sel.provider, model: sel.model };
|
|
82
|
-
} catch { /* fall through to
|
|
83
|
-
if (config.sleepProvider && config.sleepModel) return { provider: config.sleepProvider, model: config.sleepModel };
|
|
84
|
-
if (config.dreamProvider && config.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
|
|
86
|
+
} catch { /* fall through to warn */ }
|
|
85
87
|
logger?.warn?.("dsh-mneme sleep: no llm route available");
|
|
86
88
|
return undefined;
|
|
87
89
|
}
|
|
@@ -182,19 +184,19 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
|
|
|
182
184
|
const listText = selected.map((p) =>
|
|
183
185
|
`候选冲突:\nid=${p.a.id} | type=${p.a.type} | title=${p.a.title}\n${p.a.content}\n---\nid=${p.b.id} | type=${p.b.type} | title=${p.b.title}\n${p.b.content}\n(相似度 ${p.similarity.toFixed(2)})`
|
|
184
186
|
).join("\n\n");
|
|
185
|
-
const
|
|
187
|
+
const sleepEffort = config.sleepReasoningEffort && config.sleepReasoningEffort !== "none" ? config.sleepReasoningEffort : null;
|
|
188
|
+
const runConflict = (withEffort) => streamText(ctx, {
|
|
186
189
|
provider: route.provider,
|
|
187
190
|
model: route.model,
|
|
188
191
|
purpose: "sleep-conflict",
|
|
189
192
|
maxTokens: 2048,
|
|
190
|
-
...(
|
|
191
|
-
? { reasoningEffort: config.sleepReasoningEffort }
|
|
192
|
-
: {}),
|
|
193
|
+
...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
|
|
193
194
|
messages: [
|
|
194
195
|
{ role: "system", content: [{ type: "text", text: CONFLICT_PROMPT }] },
|
|
195
196
|
{ role: "user", content: [{ type: "text", text: listText }] }
|
|
196
197
|
]
|
|
197
198
|
});
|
|
199
|
+
const text = await withEffortFallback(ctx, sleepEffort, () => runConflict(true), () => runConflict(false));
|
|
198
200
|
if (text === undefined) return { status: "failed", error: "llm failed" };
|
|
199
201
|
const decisions = parseJsonArray(text);
|
|
200
202
|
if (!decisions) return { status: "failed", error: "invalid decisions json" };
|
|
@@ -282,19 +284,19 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
|
|
|
282
284
|
.map((m) => `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}`)
|
|
283
285
|
.join("\n");
|
|
284
286
|
const maxPatterns = config.sleepMaxPatternPerRun ?? 3;
|
|
285
|
-
const
|
|
287
|
+
const sleepEffort = config.sleepReasoningEffort && config.sleepReasoningEffort !== "none" ? config.sleepReasoningEffort : null;
|
|
288
|
+
const runPattern = (withEffort) => streamText(ctx, {
|
|
286
289
|
provider: route.provider,
|
|
287
290
|
model: route.model,
|
|
288
291
|
purpose: "sleep-pattern",
|
|
289
292
|
maxTokens: 2048,
|
|
290
|
-
...(
|
|
291
|
-
? { reasoningEffort: config.sleepReasoningEffort }
|
|
292
|
-
: {}),
|
|
293
|
+
...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
|
|
293
294
|
messages: [
|
|
294
295
|
{ role: "system", content: [{ type: "text", text: PATTERN_PROMPT.replace("N", String(maxPatterns)) }] },
|
|
295
296
|
{ role: "user", content: [{ type: "text", text: listText }] }
|
|
296
297
|
]
|
|
297
298
|
});
|
|
299
|
+
const text = await withEffortFallback(ctx, sleepEffort, () => runPattern(true), () => runPattern(false));
|
|
298
300
|
if (text === undefined) return { status: "failed", error: "llm failed" };
|
|
299
301
|
const decisions = parseJsonArray(text);
|
|
300
302
|
if (!decisions || decisions.length === 0) return { status: "skipped", reason: "no patterns found" };
|