@modusensus/dsh-mneme 0.9.3 → 0.9.5
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 +56 -10
- package/bin/cli.mjs +215 -2
- package/docs/CONFIGURATION.md +10 -2
- package/docs/DAEMON.md +9 -2
- package/docs/DISTILL.md +66 -4
- package/docs/SHARE.md +552 -0
- package/docs/SLEEP.md +1 -1
- package/docs/STORAGE.md +1 -1
- package/lib/api-standalone.js +286 -8
- package/lib/api.js +254 -3
- package/lib/client.js +1000 -50
- package/lib/config.js +50 -1
- package/lib/document.js +15 -2
- package/lib/dream/decisions.js +88 -3
- package/lib/dream/sleep.js +63 -13
- package/lib/dream.js +66 -10
- package/lib/index.js +6 -1
- package/lib/inject.js +10 -25
- package/lib/lang.js +48 -6
- package/lib/memory-source.js +9 -0
- package/lib/organize.js +9 -3
- package/lib/pipeline.js +62 -1
- package/lib/profile-extract.js +161 -0
- package/lib/provenance-http.js +126 -0
- package/lib/serve.js +13 -0
- package/lib/service.js +70 -6
- package/lib/settings.js +17 -13
- package/lib/share-http.js +281 -0
- package/lib/share-import.js +234 -0
- package/lib/share.js +335 -0
- package/lib/store.js +272 -11
- package/lib/summarize.js +118 -8
- package/lib/tools.js +240 -1
- package/lib/user-settings.js +78 -0
- package/package.json +1 -1
- package/src/api-standalone.js +286 -8
- package/src/api.js +254 -3
- package/src/config.js +50 -1
- package/src/document.js +15 -2
- package/src/dream/decisions.js +88 -3
- package/src/dream/sleep.js +63 -13
- package/src/dream.js +66 -10
- package/src/index.js +6 -1
- package/src/inject.js +10 -25
- package/src/lang.js +48 -6
- package/src/memory-source.js +9 -0
- package/src/organize.js +9 -3
- package/src/pipeline.js +62 -1
- package/src/profile-extract.js +161 -0
- package/src/provenance-http.js +126 -0
- package/src/serve.js +13 -0
- package/src/service.js +70 -6
- package/src/settings.js +17 -13
- package/src/share-http.js +281 -0
- package/src/share-import.js +234 -0
- package/src/share.js +335 -0
- package/src/store.js +272 -11
- package/src/summarize.js +118 -8
- package/src/tools.js +240 -1
- package/src/user-settings.js +78 -0
- package/test/api.test.js +384 -3
- package/test/audit.test.js +2 -2
- package/test/cli-receive.test.js +390 -0
- package/test/cli-share.test.js +437 -0
- package/test/client-share.test.js +225 -0
- package/test/client.test.js +215 -6
- package/test/dedupe-candidate-window.test.js +180 -0
- package/test/dream-candidate-hybrid.test.js +2 -2
- package/test/dream-fence.test.js +164 -0
- package/test/dream.test.js +87 -5
- package/test/helpers/dream-mock.js +6 -2
- package/test/lang.test.js +11 -0
- package/test/llm-message-source.test.js +1 -1
- package/test/mint-source.test.js +1 -1
- package/test/organize-tool.test.js +232 -0
- package/test/persona-addressing.test.js +83 -0
- package/test/persona-settings.test.js +30 -8
- package/test/pipeline-r1r2.test.js +129 -0
- package/test/policy-epoch.test.js +2 -2
- package/test/profile-extract.test.js +269 -0
- package/test/provenance-http.test.js +184 -0
- package/test/readme-claims.test.js +45 -0
- package/test/reasoning-effort.test.js +10 -10
- package/test/scope-candidates.test.js +7 -4
- package/test/scope-state.test.js +130 -1
- package/test/serve.test.js +62 -0
- package/test/share-foreign-rows.test.js +144 -0
- package/test/share-http.test.js +144 -0
- package/test/share-import.test.js +577 -0
- package/test/share-imported-guard.test.js +311 -0
- package/test/share-imported-identity.test.js +111 -0
- package/test/share-pack.test.js +511 -0
- package/test/share-receive-http.test.js +756 -0
- package/test/share-routes.test.js +337 -0
- package/test/share-store.test.js +133 -0
- package/test/share-validate.test.js +101 -0
- package/test/standalone-api.test.js +524 -5
- package/test/summarize-parse-diagnostics.test.js +75 -0
- package/test/summarize.test.js +240 -2
- package/test/tools-dto-schema-matrix.test.js +48 -5
- package/test/tools.test.js +6 -1
- package/test/user-settings-preview.test.js +127 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// 记忆级溯源读出口的 HTTP 口径 —— 唯一实现。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要单独一个模块:这张脸有两个入口——宿主内的面板路由(src/api.js 的
|
|
4
|
+
// /api/dsh-mneme/memory/provenance)与 Bearer 数据面(src/api-standalone.js 的
|
|
5
|
+
// /memory/provenance)。两者底下是同一份 service(provenanceFor + distillRecordsFor),
|
|
6
|
+
// 差别只在鉴权与路径。历史上(#509 第一版)它们是整段复制的,于是「改一边忘一边」
|
|
7
|
+
// 在结构上就可能发生——#411 已经为 reclaim / share 各踩过一次,本模块照那次的结论办。
|
|
8
|
+
//
|
|
9
|
+
// 口径(错误码、两种「空」的区分、窗口形状)现在只写在这里,两个入口都调它。
|
|
10
|
+
// 真要再写第二份,test/provenance-http.test.js 的静态锁会直接转红。
|
|
11
|
+
//
|
|
12
|
+
// 这里只做协议层的事:解析 id、判开关、组装窗口、区分「用户关了」「没接线」与
|
|
13
|
+
// 「没有来源」。一行数据都不改——读取在 src/store.js,透传在 src/service.js。
|
|
14
|
+
//
|
|
15
|
+
// 状态码阶梯(自前而后,先匹配的先回):
|
|
16
|
+
// 401 未鉴权 ——两个壳各自的门(api.js 的 register 包装 / standalone 的
|
|
17
|
+
// isAuthorized),在本模块之前,不在这里重复。
|
|
18
|
+
// 400 缺 id ——语法畸形,与服务端状态无关,所以排在功能开关之前;与
|
|
19
|
+
// reclaim 的 invalid-json 同档(reclaim-http.js:30-33 立的是
|
|
20
|
+
// 「503 要赢过**字段校验**的 400」,缺 id 不是字段校验而是请求
|
|
21
|
+
// 本身不成立,所以不适用那条)。判断在壳里
|
|
22
|
+
// (parseProvenanceQuery),走到本模块时 id 一定非空。
|
|
23
|
+
// ⚠ 将来要加**语义类** 400(如 limit 越界、id 格式不合法),
|
|
24
|
+
// 必须挪到接线检查之后——那条 reclaim 口径正是为它立的。
|
|
25
|
+
// 409 功能关了 ——distillRecords.enabled !== true。回的是「端点的属性」,
|
|
26
|
+
// 与 src/api.js:525 的 profileExtractEnabled 同构:用户关了
|
|
27
|
+
// 某功能 = 409,装配坏了 = 503,两者必须是可区分的答案
|
|
28
|
+
// (src/index.js:589-591 的设计原话)。
|
|
29
|
+
// 503 装配没接线 ——service 上没有 provenanceFor / distillRecordsFor。
|
|
30
|
+
// 读(200 / 500)
|
|
31
|
+
// 刻意不用 404:本端点永远不就「这条记忆是否存在」作断言(缺 id 是 400、未知 id
|
|
32
|
+
// 是 200 + 空 windows),404 在本仓另有稳定含义——未知路由与资源不存在
|
|
33
|
+
// (src/api-standalone.js:506/515、src/api.js:1367/1445)。把「功能关了」塞进
|
|
34
|
+
// 404,调用方会在状态码这个唯一被代理 / 监控 / 重试逻辑读取的通道上,把它读成
|
|
35
|
+
// 「这条记忆不存在」。
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 「溯源能力没接线」的错误码。headless / 旧装配下 service 上没有 provenanceFor 或
|
|
39
|
+
* distillRecordsFor,那是装配坏了,不是「这条记忆没有来源」——两种空必须分开读数,
|
|
40
|
+
* 否则调用方看到一个空 windows 会以为拿到了答案。
|
|
41
|
+
*/
|
|
42
|
+
export const PROVENANCE_UNAVAILABLE = "provenance-unavailable";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 「用户把蒸馏原文留存关掉了」的错误码。distillRecords.enabled 既是写侧的总闸
|
|
46
|
+
* (src/summarize.js:988)也是本读出口的总闸(#509 评审整改):关着时盘上可能
|
|
47
|
+
* 还有历史原文(保留期裁剪与写入同在 src/index.js:270 一道门里),但本端点不再
|
|
48
|
+
* 把它们读出去。与 api.js:525 的 "disabled" 分开:那个面只有一个功能开关,本仓
|
|
49
|
+
* 数据面上会有多个,特定码才让调用方分得清是哪一个被关。
|
|
50
|
+
*/
|
|
51
|
+
export const DISTILL_RECORDS_DISABLED = "distill-records-disabled";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 读侧唯一的开关判据,**与写侧 src/summarize.js:988 逐字同源**。
|
|
55
|
+
*
|
|
56
|
+
* 为什么不放在两个壳里各算一次:那句话已经是本仓第 3、第 4 份拷贝了
|
|
57
|
+
* (summarize.js:988、index.js:270),而 #411 的结论就是「两张皮整段复制必然
|
|
58
|
+
* 漂移」——读写两道门一旦漂移,就会出现「以为关了却还能读出去」或「开着却读不到」
|
|
59
|
+
* 这两种都更难排查的状态。壳只转发 config,判据只在这里出现一次。
|
|
60
|
+
*
|
|
61
|
+
* fail-closed:config 缺失(null / undefined)、distillRecords 组缺失、值不是
|
|
62
|
+
* 严格的 true("true" 字符串 / 1 / undefined)一律算关。这个端点返回的是蒸馏
|
|
63
|
+
* 窗口的逐字会话原文,判不出来时必须往「不读」那一侧倒。
|
|
64
|
+
*
|
|
65
|
+
* @param {object|null|undefined} config 启动期冻结的装配后 cfg(宿主 index.js:597
|
|
66
|
+
* 的 createApi 第 8 参 / index.js:621 的 createStandaloneApi config / daemon
|
|
67
|
+
* serve.js 的最小 cfg)。
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
export function isDistillRecordsEnabled(config) {
|
|
71
|
+
return config?.distillRecords?.enabled === true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 溯源读取失败的 warn 文案。两个入口共用,日志里不会出现两种拼法。 */
|
|
75
|
+
export function provenanceWarnMessage(error) {
|
|
76
|
+
return `[dsh-mneme] memory provenance read failed: ${String(error)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 解析 ?id。空 / 缺失 → 400;其余原样交给 service(id 不存在时返回空 windows,
|
|
81
|
+
* 那是有效答案,不是错误——读端点不替调用方判断记忆是否存在)。
|
|
82
|
+
*
|
|
83
|
+
* @returns {{ok: true, id: string} | {ok: false, status: 400, error: string}}
|
|
84
|
+
*/
|
|
85
|
+
export function parseProvenanceQuery(raw) {
|
|
86
|
+
const id = typeof raw === "string" ? raw.trim() : "";
|
|
87
|
+
if (!id) return { ok: false, status: 400, error: "missing-id" };
|
|
88
|
+
return { ok: true, id };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 组装答复:一个链接一个窗口(区间 + 区间内原文行,按 seq 升序)。
|
|
93
|
+
*
|
|
94
|
+
* 顺序敏感(阶梯全貌见文件头):409 功能关了 → 503 装配没接线 → 读。
|
|
95
|
+
* 503 要赢过空结果——装配没接线时报「这条记忆没有来源」是误导,与 reclaimReply 的
|
|
96
|
+
* maintenance-unavailable 同构;而 409 要赢过 503——「用户关了」比「装配坏了」更
|
|
97
|
+
* 具体,把前者报成后者会让人去查一个没坏的部署。
|
|
98
|
+
*
|
|
99
|
+
* @param {{service: object, id: string, config?: object|null, warn?: Function}} args
|
|
100
|
+
* config 缺省即关(fail-closed),见 isDistillRecordsEnabled。
|
|
101
|
+
* @returns {{status: number, body: object}}
|
|
102
|
+
*/
|
|
103
|
+
export function provenanceReply({ service, id, config = null, warn = null }) {
|
|
104
|
+
if (!isDistillRecordsEnabled(config)) {
|
|
105
|
+
return { status: 409, body: { error: DISTILL_RECORDS_DISABLED } };
|
|
106
|
+
}
|
|
107
|
+
if (typeof service?.provenanceFor !== "function" || typeof service?.distillRecordsFor !== "function") {
|
|
108
|
+
return { status: 503, body: { error: PROVENANCE_UNAVAILABLE } };
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const links = service.provenanceFor(id);
|
|
112
|
+
const windows = links.map((link) => ({
|
|
113
|
+
session_id: link.session_id,
|
|
114
|
+
seq_start: link.seq_start,
|
|
115
|
+
seq_end: link.seq_end,
|
|
116
|
+
created_at: link.created_at,
|
|
117
|
+
// 到期裁剪后区间可能变空:链接刻意保留并指向空区间(clearDistillRecords 的
|
|
118
|
+
// 语义),面板据此显示「原文已过期」而非「没有来源」。
|
|
119
|
+
records: service.distillRecordsFor(link.session_id, link.seq_start, link.seq_end)
|
|
120
|
+
}));
|
|
121
|
+
return { status: 200, body: { memoryId: id, windows } };
|
|
122
|
+
} catch (error) {
|
|
123
|
+
warn?.(provenanceWarnMessage(error));
|
|
124
|
+
return { status: 500, body: { error: "internal" } };
|
|
125
|
+
}
|
|
126
|
+
}
|
package/lib/serve.js
CHANGED
|
@@ -61,6 +61,19 @@ export async function createServeRuntime({
|
|
|
61
61
|
// documentMemoryEnabled 关掉 document 子系统(daemon 不建 documentIndex,service
|
|
62
62
|
// 内部 if-guard 安全,index.js:261 的 documentIndex 仅宿主装配)。
|
|
63
63
|
const cfg = { language: "zh", documentMemoryEnabled: false };
|
|
64
|
+
// #509 评审整改补的一格:GET /memory/provenance 的读门读 cfg.distillRecords.enabled
|
|
65
|
+
// (判据在 src/provenance-http.js,与写侧 src/summarize.js:988 同源)。daemon 与宿主
|
|
66
|
+
// 共用同一个 memory.db、同一份 feature_flags kv,所以这里把用户显式开关展开进 cfg
|
|
67
|
+
// ——index.js:217-233 同款展开,只取蒸馏这一组(daemon 其余键仍刻意不吃宿主 config
|
|
68
|
+
// schema)。不展开的后果:daemon 对这一面恒定回「功能关了」,哪怕用户在面板里开着、
|
|
69
|
+
// 库里明明有原文——对这个端点那是一句谎话,而「两种空必须分开读数」正是本仓纪律。
|
|
70
|
+
// 残留不对称(写进 docs/DAEMON.md 与 PR):只靠 bundle 配置打开、从没在面板点过的
|
|
71
|
+
// 用户,宿主认、daemon 不认——daemon 结构上看不到宿主的 bundle config,这是它看不见
|
|
72
|
+
// 的那一半,不是本处能补的。
|
|
73
|
+
const persistedFlags = settings.getFeatureFlags?.() ?? {};
|
|
74
|
+
if (typeof persistedFlags["distillRecords.enabled"] === "boolean") {
|
|
75
|
+
cfg.distillRecords = { ...(cfg.distillRecords ?? {}), enabled: persistedFlags["distillRecords.enabled"] };
|
|
76
|
+
}
|
|
64
77
|
const mirror = createMirror(dir, langOf(cfg));
|
|
65
78
|
const service = createService({ store, mirror, config: cfg, logger });
|
|
66
79
|
|
package/lib/service.js
CHANGED
|
@@ -7,9 +7,13 @@ import { STR, langOf } from "./lang.js";
|
|
|
7
7
|
import { computeHeat } from "./heat.js";
|
|
8
8
|
import { recallStats } from "./recall-stats.js";
|
|
9
9
|
import { evaluateMemoryQuality } from "./quality-filter.js";
|
|
10
|
+
// 外来行(source='imported',导入路径落的来源)的身份判定只许有一个定义处——本文件的
|
|
11
|
+
// 两处汇流口与巩固/降级那些读侧行池都调这同一个谓词,避免各处自己写字符串比较然后各自漂移。
|
|
12
|
+
import { isImportedRow } from "./memory-source.js";
|
|
10
13
|
import { applyDecisions } from "./dream/decisions.js";
|
|
11
14
|
import { createDocumentRegistrar } from "./document.js";
|
|
12
15
|
import { createOrganizer } from "./organize.js";
|
|
16
|
+
import { createShareImport } from "./share-import.js";
|
|
13
17
|
import { createBM25Index } from "./search/bm25.js";
|
|
14
18
|
import { adaptiveThreshold } from "./search/adaptive.js";
|
|
15
19
|
import { anchorSeeds, normalizePath, cascadeDepths, HOP_QUOTA } from "./graph/anchoring.js";
|
|
@@ -1368,7 +1372,31 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1368
1372
|
// dream 总览按 source 身份去重,而非本地化标题:memory.language 切换后
|
|
1369
1373
|
// 标题变化,若仍按 (type, title) 匹配会出现两个活跃的 summary 同时注入。
|
|
1370
1374
|
// 其余类型维持 (type, title) 匹配不变。
|
|
1371
|
-
|
|
1375
|
+
//
|
|
1376
|
+
// Issue #505:候选集此前是 store.list({ type, limit: 100 }) —— 那是「该类型
|
|
1377
|
+
// 最重要的 100 行」(order 默认 importance),与「找出同标题的那一行」无关。
|
|
1378
|
+
// 某类型里 importance 高于待写行的行数一旦超过 100,同标题行与待写行双双落在
|
|
1379
|
+
// 窗口外,下面的 find 恒不命中,于是静默走创建分支:不报错、不告警,合并语义
|
|
1380
|
+
// 随库增长而失效(实测 1047 行类型、importance=5 有 180 行时稳定复现)。
|
|
1381
|
+
// 改为按同标题定向取(store.activeByTypeAndTitle,无窗口)。
|
|
1382
|
+
//
|
|
1383
|
+
// spec §5 决策 2:本机巩固不许改写别人的原文。这里不是防呆——蒸馏路径的每一次
|
|
1384
|
+
// 同题命中都会 appendContent 覆盖外来行的正文与 updated_at,别人的记忆从此变成
|
|
1385
|
+
// 我们两家的混杂物。把外来行剔出候选集后只会落回既有的「新建一行」分支。
|
|
1386
|
+
// 谓词挂在**两个**候选集上:sourceScopedCandidates 在 dream/narrative 那两条分支
|
|
1387
|
+
// 里就是候选集本身,只挂 candidates 等于给外来行留一条后门。这一处过滤留在 JS 侧
|
|
1388
|
+
// 而不是下推 SQL(同 #515 的反面):那里下推是因为外来行会吃掉 LIMIT 窗口名额,
|
|
1389
|
+
// 这里 limit: null 本就无界,没有窗口可饿,而下推会多出第二份外来行判据。
|
|
1390
|
+
const candidates = store.activeByTypeAndTitle(memory.type, memory.title).filter((m) => !isImportedRow(m));
|
|
1391
|
+
// dream/narrative 两条分支按 source(及 tags[0])匹配,而不是按标题:它们的
|
|
1392
|
+
// 去重键本来就不含标题(语言切换后标题会变)。定向取同标题会把这两条路径
|
|
1393
|
+
// 打回「永远不命中」——所以这两类仍走「该类型活跃行」的候选集,只是窗口换成
|
|
1394
|
+
// 了无界且不带 importance 排序。类型内行数由 summary 的生产方(autoDream /
|
|
1395
|
+
// generateNarratives)决定,量级远小于普通记忆,且这两条分支本来就要求扫全
|
|
1396
|
+
// 类型才能找到同 source 的那一行——原来的 100 行窗口对它们同样是错的。
|
|
1397
|
+
const sourceScopedCandidates = (memory.type === "summary" && (memory.source === "dream" || memory.source === "narrative"))
|
|
1398
|
+
? store.list({ type: memory.type, limit: null }).filter((m) => !isImportedRow(m))
|
|
1399
|
+
: candidates;
|
|
1372
1400
|
// v0.8.0 A1(issue #17):去重键由 (type, title) 扩展为
|
|
1373
1401
|
// (type, title, agent_scope, workspace_scope, sensitivity)——不同作用域的同
|
|
1374
1402
|
// 标题记忆绝不互相物理合并(旧逻辑跨 agent/workspace 并行,事后无法拆分)。
|
|
@@ -1386,17 +1414,20 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1386
1414
|
// 下面这段并入逻辑(appendContent + content_history + 质量处置),不另写第二份
|
|
1387
1415
|
// 实现。目标已被并发删除时回落到常规匹配。显式目标同样过 scope 门:跨作用域
|
|
1388
1416
|
// 的语义相似不并入(防泄漏),回落为独立新行。
|
|
1389
|
-
|
|
1417
|
+
// 显式目标还过外来门(spec §5 决策 2):调用方指到别人导入的原文时,这个目标
|
|
1418
|
+
// 按「未命中」处理,同样落回新建一行——两条路都只有 merged/created 两种结局。
|
|
1419
|
+
const explicitCandidate = memory._mergeInto ? store.getById(memory._mergeInto) : undefined;
|
|
1420
|
+
const explicitTarget = explicitCandidate && !isImportedRow(explicitCandidate) ? explicitCandidate : undefined;
|
|
1390
1421
|
const existing = explicitTarget
|
|
1391
1422
|
? (scopeMatches(explicitTarget) ? explicitTarget : undefined)
|
|
1392
1423
|
: (memory.type === "summary" && memory.source === "dream")
|
|
1393
|
-
? (
|
|
1424
|
+
? (sourceScopedCandidates.find((m) => m.source === "dream" && scopeMatches(m))
|
|
1394
1425
|
?? candidates.find((m) => scopeMatches(m) && m.title.trim() === String(memory.title).trim()))
|
|
1395
1426
|
: (memory.type === "summary" && memory.source === "narrative")
|
|
1396
1427
|
// 叙述条(#164)按主题键去重而非本地化标题:标题随语言变
|
|
1397
1428
|
// (叙述:X / Narrative: X),title 匹配在语言切换后会残留同 tag 双行。
|
|
1398
1429
|
// tag 存于 tags[0](generateNarratives 落库),跨语言稳定 → 原地刷新。
|
|
1399
|
-
? (
|
|
1430
|
+
? (sourceScopedCandidates.find((m) => m.source === "narrative" && m.tags?.[0] === memory.tags?.[0] && scopeMatches(m))
|
|
1400
1431
|
?? candidates.find((m) => scopeMatches(m) && m.title.trim() === String(memory.title).trim()))
|
|
1401
1432
|
: candidates.find((m) => scopeMatches(m) && m.title.trim() === String(memory.title).trim());
|
|
1402
1433
|
if (existing) {
|
|
@@ -2564,18 +2595,34 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
2564
2595
|
}
|
|
2565
2596
|
});
|
|
2566
2597
|
|
|
2598
|
+
// 车道 C 收侧的入库与整包退出(spec §5):内聚块在 src/share-import.js,这里只做依赖
|
|
2599
|
+
// 注入 + barrel 出口。注入的是本文件的 transaction 而不是 store.runAtomically——嵌入
|
|
2600
|
+
// 队列由 transaction 的最外层 COMMIT 排空,换那个助手等于静默不嵌入(#372 修过两次);
|
|
2601
|
+
// 退出那侧用它则是为了 finally 里的 syncMirror + notifyWrite(删完得让其余读侧知道)。
|
|
2602
|
+
const shareImport = createShareImport({ store, transaction, scheduleEmbed });
|
|
2603
|
+
|
|
2567
2604
|
return {
|
|
2568
2605
|
saveWithDedupe,
|
|
2569
2606
|
registerDocument,
|
|
2570
2607
|
// agent 主动整理接口(#231):内聚块在 src/organize.js,这里只做依赖注入 +
|
|
2571
|
-
// barrel
|
|
2572
|
-
//
|
|
2608
|
+
// barrel 出口。本体不带配置键(第一批口径),暴露闸住在工具层(#231 第二批的
|
|
2609
|
+
// `organizeEnabled` → `tools.js` 的 `memory_organize`):service 侧不认识开关,
|
|
2610
|
+
// 所以直连 service 的调用方(测试、内部管线)不受面板那格影响。
|
|
2573
2611
|
organize,
|
|
2574
2612
|
recoverMirror,
|
|
2575
2613
|
getMirrorHealth,
|
|
2576
2614
|
getMirrorState: () => store.getMirrorState(),
|
|
2577
2615
|
injectCandidates,
|
|
2578
2616
|
mergeHumanEdits,
|
|
2617
|
+
// 车道 C 收侧:两张皮(面板路由 / standalone 数据面)各自的壳只认这一份实现——
|
|
2618
|
+
// createApi 手里没有 store,「api 层不直接摸 store」这条纪律(见下面 embeddedCount
|
|
2619
|
+
// 那组 passthrough 的注释)要求导入从 service 进,否则两皮各摸一遍 store、幂等
|
|
2620
|
+
// 口径随之变成两份。
|
|
2621
|
+
importSharePack: shareImport.importSharePack,
|
|
2622
|
+
// 整包退出(C4)与导入同住 src/share-import.js:收侧的「进与出」共用同一份 store
|
|
2623
|
+
// 注入与同一个事务口径,键名照计划里的契约留在 service 的返回表上,两张皮(C5/C6)
|
|
2624
|
+
// 只认 service 这一个入口——api 层不直接摸 store 那条纪律对退出同样成立。
|
|
2625
|
+
deleteSharePack: shareImport.deleteSharePack,
|
|
2579
2626
|
toApiList,
|
|
2580
2627
|
isVisibleInScope,
|
|
2581
2628
|
// issue #471 政策轴:按下/松开与读取照 store 那三条件透传(纯 bookkeeping,
|
|
@@ -2598,6 +2645,9 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
2598
2645
|
saveDistillRecords: (records) => store.saveDistillRecords(records),
|
|
2599
2646
|
saveMemoryProvenance: (link) => store.saveMemoryProvenance(link),
|
|
2600
2647
|
provenanceFor: (memoryId) => store.provenanceFor(memoryId),
|
|
2648
|
+
// #421 读出口:按 (session, seq 区间) 取蒸馏原文。与 provenanceFor 成对——
|
|
2649
|
+
// 一个给区间、一个给区间里的原文,合起来才是「这条记忆从哪几句蒸出来的」。
|
|
2650
|
+
distillRecordsFor: (sessionId, seqStart, seqEnd) => store.distillRecordsFor(sessionId, seqStart, seqEnd),
|
|
2601
2651
|
enqueue,
|
|
2602
2652
|
setDreamHook(fn) { dreamHook = fn; },
|
|
2603
2653
|
setSleepHook(fn) { sleepHook = fn; },
|
|
@@ -2669,6 +2719,20 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
2669
2719
|
// passthrough 形状与 embeddedCount 同款,api 层不直接摸 store。
|
|
2670
2720
|
embeddingModelBreakdown: () => store.embeddingModelBreakdown(),
|
|
2671
2721
|
embeddingDimensionBreakdown: () => store.embeddingDimensionBreakdown(),
|
|
2722
|
+
// #383:维度探针,供 /context 判「库里有没有一条活跃向量与查询同维」。注意
|
|
2723
|
+
// 它**不再是**「读一行的维度」(那个判据在混合维度库里两个方向都错,见 store.js
|
|
2724
|
+
// vectorDimensionState 的注释):换成存在性判据后成本是「一次全 id 扫描 + 按匹配
|
|
2725
|
+
// 短路的解析」,同维度健康库只解析 1 个向量,只有要报 degraded 时才走完全表。
|
|
2726
|
+
// 与上面那个全表扫的 breakdown 仍是两件事:一个是状态页的完整分布,一个是每请求
|
|
2727
|
+
// 用得起的判据。透传而非让 api 层摸 store,与同组其他条目一致;api 侧负责把探针
|
|
2728
|
+
// 异常隔离成 unknown,不在这里吞。
|
|
2729
|
+
vectorDimensionState: (dimension) => store.vectorDimensionState(dimension),
|
|
2730
|
+
// 整包透传:store.list 的每个口径(excludeTypes / excludeImported / includeArchived…)
|
|
2731
|
+
// 都从这里过去,service 侧不再枚举一遍——枚举就是第二份口径表,加一个键就要改两处,
|
|
2732
|
+
// 漏改的那处静默失效。政策轴一律走这里的查询参数,不许退回调用方的 .filter:
|
|
2733
|
+
// LIMIT 先生效的话,被丢掉的行照样吃掉窗口名额。前例是同文件 syncMirror 的那条注释
|
|
2734
|
+
// (src/service.js:2247,document 轴改由 excludeTypes 在 LIMIT 之前排除),
|
|
2735
|
+
// 同一取舍的另两次是 #230 与 #471。
|
|
2672
2736
|
list: (o) => store.list(o),
|
|
2673
2737
|
all: () => store.all(),
|
|
2674
2738
|
count: (type, opts) => store.count(type, opts),
|
package/lib/settings.js
CHANGED
|
@@ -185,6 +185,10 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
185
185
|
// 在慢/轻量模型上是多余往返,面板可关(默认关=行为不变)。
|
|
186
186
|
"disableMemorySearch",
|
|
187
187
|
"disableMemoryArchive",
|
|
188
|
+
// Issue #231 第二批:agent 主动整理(memory_organize 工具)的暴露闸。与上面两个
|
|
189
|
+
// 反向——那两个藏默认在场的工具,本键开默认不在场的能力(默认关=工具根本不存在,
|
|
190
|
+
// 行为逐字节不变)。面板可启停=线上回滚开关。
|
|
191
|
+
"organizeEnabled",
|
|
188
192
|
// 嵌套对象开关:config.js 里是 memoryQualityFilter / llmAudit 对象的 enabled
|
|
189
193
|
// 子字段。kv 按点号键平铺存("memoryQualityFilter.enabled": false),index.js
|
|
190
194
|
// 合并时展开回嵌套对象,api.js 的 effective 从对象子字段取值。
|
|
@@ -229,7 +233,13 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
229
233
|
// LIGHT_MODE_OFF——人格段是用户自写的一小段轻量常驻文本,不引入任何重型后台
|
|
230
234
|
// 路径,轻量档用户显式勾选仍然赢(同 actionPipelineEnabled 的定性)。顶层扁平
|
|
231
235
|
// 键,不需要 NESTED_FLAG_PATHS 条目。
|
|
232
|
-
"personaEnabled"
|
|
236
|
+
"personaEnabled",
|
|
237
|
+
// 人格寻址口径(上一条打开时才有意义):开 = 身份没建卡/卡被关时不注入人格,
|
|
238
|
+
// default 只兜解析不到身份的会话。默认关(历史行为,升级零变化)。
|
|
239
|
+
"personaStrictAddressing",
|
|
240
|
+
// Issue #497:画像提取草稿(POST /profile/extract)。默认关——每次调用一次 LLM 且
|
|
241
|
+
// 读上百行记忆;产物只回草稿,绝不写 user_settings。进 LIGHT_MODE_OFF(预设关)。
|
|
242
|
+
"profileExtractEnabled"
|
|
233
243
|
];
|
|
234
244
|
// 整数开关的闭区间,与 config.js 里 z.natural().min().max() 对齐。
|
|
235
245
|
const FEATURE_FLAG_INT_RANGES = {
|
|
@@ -247,6 +257,9 @@ const FEATURE_FLAG_INT_RANGES = {
|
|
|
247
257
|
// Issue #239:蒸馏零 LLM 预判(窗口最小字符数)与每会话 run 预算(0 = 零行为变化)。
|
|
248
258
|
summarizeMinWindowChars: [0, 100000],
|
|
249
259
|
summarizeMaxRunsPerSession: [0, 1000],
|
|
260
|
+
// Issue #493:连续解析失败几次后放弃当前窗口(0 = 关)。与 config.js 的
|
|
261
|
+
// z.natural().min(0).max(10) 成对。
|
|
262
|
+
summarizeParseFailureMaxRetries: [0, 10],
|
|
250
263
|
// Issue #239 第 4 项:高峰顺延上限(分钟,0 = 不设上限)。
|
|
251
264
|
summarizePeakMaxDeferMinutes: [0, 1440],
|
|
252
265
|
// Issue #239 第 4 项镜像到巩固:同一口径(分钟,0 = 不设上限)。
|
|
@@ -489,8 +502,9 @@ export function createSettings(db) {
|
|
|
489
502
|
/**
|
|
490
503
|
* Agent persona (name / personality / replyStyle + enabled gate), stored as
|
|
491
504
|
* one JSON key. v1.2 多卡:存储形 { default, byAgent },旧 v1 单对象读作
|
|
492
|
-
* default 兼容。getPersona
|
|
493
|
-
*
|
|
505
|
+
* default 兼容。getPersona 返回全量多卡形(面板与注入寻址都用它)。
|
|
506
|
+
* 寻址与兜底口径不在这里——住 src/user-settings.js 的 resolvePersonaCard,
|
|
507
|
+
* 注入与面板预览共用那一份;这里若再留一个 getPersonaFor,就是两张皮。
|
|
494
508
|
*/
|
|
495
509
|
getPersona() {
|
|
496
510
|
return parsePersonaStore(getSetting("persona") ?? "{}");
|
|
@@ -507,16 +521,6 @@ export function createSettings(db) {
|
|
|
507
521
|
current.default = parsePersona(JSON.stringify(input));
|
|
508
522
|
setSetting("persona", serializePersonaStore(current));
|
|
509
523
|
},
|
|
510
|
-
/** Per-agent lookup for the injector: id card → default fallback. */
|
|
511
|
-
getPersonaFor(agentId) {
|
|
512
|
-
const store = parsePersonaStore(getSetting("persona") ?? "{}");
|
|
513
|
-
const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : "";
|
|
514
|
-
if (id) {
|
|
515
|
-
const card = store.byAgent[id];
|
|
516
|
-
if (card && card.enabled === true) return card;
|
|
517
|
-
}
|
|
518
|
-
return store.default;
|
|
519
|
-
},
|
|
520
524
|
/** Replace one byAgent card wholesale (panel save); default 不可经此改。 */
|
|
521
525
|
setPersonaFor(agentId, card) {
|
|
522
526
|
const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : "";
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// 分享包在两张皮上共用的解析与答复(形状照 src/reclaim-http.js,#411)。
|
|
2
|
+
// 只返回 {status, body},绝不自己写 res —— 两侧的 sendJson 签名不同(api.js 有
|
|
3
|
+
// headers 第四参、api-standalone.js 没有),把写 res 留在各侧 handler 里是唯一
|
|
4
|
+
// 能让一份实现喂两张脸的做法。
|
|
5
|
+
//
|
|
6
|
+
// 哪些行进包、以什么原因不进,一条判据都不在这里重复:那是 src/share.js 的事。本模块
|
|
7
|
+
// 只做协议层的事——读 body、把结果包成答复、给一个能落盘的文件名。
|
|
8
|
+
import { buildSharePack } from "./share.js";
|
|
9
|
+
|
|
10
|
+
/** 拒发时的下一步动作。它是给调用方看的,出口只许有一处措辞(同 RECLAIM_CONFIRM_HINT)。 */
|
|
11
|
+
const SHARE_ERROR_HINT = "包体超过分享水位:用 --type 或 --tag 分批,别整库外发。";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 记日志这一手,只许经这里。
|
|
15
|
+
*
|
|
16
|
+
* 「这一层从不抛」是**层的契约**,不是每个调用面的运气(C5 评审裁定):warn 裸调时,宿主
|
|
17
|
+
* logger 自己坏掉就会从 catch 块里再抛出去,而两侧 handler 那个外层 try 在 .then 的回调里
|
|
18
|
+
* 早已退栈——现场就是「一个字节都没回」(#410)。守卫住在面上一处一个,等于每接一张新脸
|
|
19
|
+
* 都要记得再兜一次;住在层里,两侧、以及后面的 CLI(C7)拿到的都是同一个脾气。
|
|
20
|
+
*
|
|
21
|
+
* 三个构造器(shareReply / receiveReply / undoReply)都走这里,**对称本身就是要点**:
|
|
22
|
+
* 只给新加的两个上守卫、出包那侧留一处裸调,下一个读者就会照那处裸的抄。
|
|
23
|
+
* 日志环坏掉时答复照旧送出——对面拿到的仍是那一个状态码,只是少了一条日志迹。
|
|
24
|
+
*/
|
|
25
|
+
function warnSafely(warn, message) {
|
|
26
|
+
try {
|
|
27
|
+
warn?.(message);
|
|
28
|
+
} catch { /* 记日志是自己的事,坏了的日志不许把已经算好的答复一起带走 */ }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 解析 POST body(已由调用方读成文本)→ buildSharePack 的入参集合。
|
|
33
|
+
*
|
|
34
|
+
* 空 body 等价于 `{}`:分享的主用法就是「一个字节都不发,按整库口径打」。坏 JSON 与
|
|
35
|
+
* 发成数组/null 是两个不同的错码(前者是调用方发坏了,后者是发错了顶层形状),
|
|
36
|
+
* 与 reclaim 侧同款,出口要能分支。
|
|
37
|
+
*
|
|
38
|
+
* 只认这三个键,多余的键丢掉:面板与 CLI 各写各的选项时,静默接受一个拼错的键等于
|
|
39
|
+
* 让她以为筛过了。
|
|
40
|
+
*
|
|
41
|
+
* 这里**没有** dryRun:share 是纯读、不改库,spec §4 明确它不套 reclaim 的
|
|
42
|
+
* dry-run/confirm 纪律,干跑由 CLI 侧的 `--dry-run` 决定(它压根不发这个键)。上一版
|
|
43
|
+
* 把它「登记但不参与答复构造」留着,等于凭空多出一个没人消费的公开字段:今天发
|
|
44
|
+
* `dryRun: true` 的人拿到的是真包,而哪天要给它真语义、或把它删掉,两侧都是破坏性改动。
|
|
45
|
+
* 删掉后它和别的多余键走同一条规则——丢掉,不再是一个特例。
|
|
46
|
+
*
|
|
47
|
+
* @returns {{ok: true, options: {alias?: string, type?: unknown, tag?: unknown}}
|
|
48
|
+
* | {ok: false, error: string}}
|
|
49
|
+
*/
|
|
50
|
+
export function parseShareBody(text) {
|
|
51
|
+
if (text === undefined || text === null || String(text).trim() === "") return { ok: true, options: {} };
|
|
52
|
+
let body;
|
|
53
|
+
try {
|
|
54
|
+
body = JSON.parse(String(text));
|
|
55
|
+
} catch {
|
|
56
|
+
return { ok: false, error: "invalid-json" };
|
|
57
|
+
}
|
|
58
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: "invalid-body" };
|
|
59
|
+
const options = {};
|
|
60
|
+
if (typeof body.alias === "string") options.alias = body.alias;
|
|
61
|
+
// type / tag 在这里只判「填没填」,不判形状。收窄口径是 share.js 的 shareFilterSet,
|
|
62
|
+
// 它钉死的是「读不懂的形状只许收窄到 0 条,不许当没填」(错的方向是少发,当场看得见)。
|
|
63
|
+
// 本层若再校验一次形状、把裸字符串或混进非字符串条目的数组丢成「没填」,就等于在 HTTP
|
|
64
|
+
// 面上把那条洞反着挖开——她以为只发了一类,实际整库进包,而 report 看不出异样。
|
|
65
|
+
if (body.type !== undefined && body.type !== null) options.type = body.type;
|
|
66
|
+
if (body.tag !== undefined && body.tag !== null) options.tag = body.tag;
|
|
67
|
+
return { ok: true, options };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 包文件名:日期 + pack_id 前 8 位。Content-Disposition 与 CLI 落盘共用这一个拼法。 */
|
|
71
|
+
export function shareFilename(packId, now = () => new Date()) {
|
|
72
|
+
const stamp = now().toISOString().slice(0, 10).replace(/-/g, "");
|
|
73
|
+
return `dsh-mneme-share-${stamp}-${String(packId).slice(0, 8)}.json`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 组包并给出答复。`service.all()` 是「全库含归档/已遗忘」的现成通道(store.js 的 all()
|
|
78
|
+
* 不带 WHERE),口径判据全在 share.js 里判,本模块不重复任何一条。
|
|
79
|
+
*
|
|
80
|
+
* filters 两个键只在填了时才挂上去:buildSharePack 的默认值兜的是 `filters` 整个参数为
|
|
81
|
+
* undefined,构造一个 `filters: null` 会直接在对端解引用时抛——没填的键就让它不存在。
|
|
82
|
+
*
|
|
83
|
+
* 413 那条绝不回 pack:这条答复存在的原因恰恰是包太大,把 memories 序列化回去等于把
|
|
84
|
+
* 刚拒绝掉的那几 MB 再发一遍。report + 量到的字节 + 水位 + 提示够出口说清「大了多少、
|
|
85
|
+
* 怎么分批」(share.js 超限时也把完整 pack 交回,本层只用它的 report)。
|
|
86
|
+
*
|
|
87
|
+
* 抛错在这里兜住并回 500,不留给调用方的 try:面板侧 handler 的形状是
|
|
88
|
+
* `try { return readBody(...).then(...) } catch`,外层 try 在 .then 的回调里已经退栈,
|
|
89
|
+
* 从这里抛出去就是一个没人接的 rejection,客户端拿到的不是 500 而是「一个字节都没回」
|
|
90
|
+
* (#410,reclaim-http.js 里 reclaimReply 那段注释记的就是这一次)。共用层兜一次,两张
|
|
91
|
+
* 皮才不会因为各自接线不同而有一种会漏。warn 可选:数据面的 logger 未必接得上,少一个
|
|
92
|
+
* 回调也不许把异常漏出去。
|
|
93
|
+
*
|
|
94
|
+
* @param {{service: {all: () => object[]}, packageVersion: string, homeDir: string,
|
|
95
|
+
* now?: () => Date, options?: object, warn?: ((msg: string) => void)|null}} input
|
|
96
|
+
* @returns {{status: number, body: object}}
|
|
97
|
+
*/
|
|
98
|
+
export function shareReply({ service, packageVersion, homeDir, now = () => new Date(), options = {}, warn = null }) {
|
|
99
|
+
try {
|
|
100
|
+
const rows = typeof service?.all === "function" ? service.all() : [];
|
|
101
|
+
const filters = {};
|
|
102
|
+
if (options.type !== undefined) filters.type = options.type;
|
|
103
|
+
if (options.tag !== undefined) filters.tag = options.tag;
|
|
104
|
+
const out = buildSharePack({
|
|
105
|
+
rows, alias: options.alias, homeDir, packageVersion,
|
|
106
|
+
filters,
|
|
107
|
+
generatedAt: now().toISOString()
|
|
108
|
+
});
|
|
109
|
+
if (!out.ok) {
|
|
110
|
+
return {
|
|
111
|
+
status: 413,
|
|
112
|
+
body: { error: out.error, bytes: out.bytes, limitBytes: out.limitBytes, hint: SHARE_ERROR_HINT, report: out.pack.report }
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return { status: 200, body: out.pack };
|
|
116
|
+
} catch (error) {
|
|
117
|
+
// 日志里留住原始文本(现场最难下手的就是「只回了 internal」那一类),对外只有一个码。
|
|
118
|
+
warnSafely(warn, `[dsh-mneme] share pack failed: ${String(error)}`);
|
|
119
|
+
return { status: 500, body: { error: "internal" } };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- 接收侧(车道 C,spec §5)--------------------------------------------------
|
|
124
|
+
// 入库与整包退出同属「两张皮只许差在鉴权与路径」那条纪律(#411),所以这两组解析/答复
|
|
125
|
+
// 也住在本模块,与出包侧同一个形状:只回 {status, body},绝不写 res。
|
|
126
|
+
//
|
|
127
|
+
// 这里刻意**不**重跑 validateSharePack:整包闸门住在 share.js、由 service 的
|
|
128
|
+
// importSharePack 自己调(src/share-import.js)。本层再判一次就是第二套口径——两份实现
|
|
129
|
+
// 各自演进时最坏情况是「路由放行、service 拒」或反过来,而一个包有没有合格从此没有准信。
|
|
130
|
+
// 失败靠 err.code 鸭子类型映射成状态码:ShareImportError 是刻意不从 service 再导出的
|
|
131
|
+
// (类跨模块之后,instanceof 就退化成「只在同一个实例内成立」),码才是契约。
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 导入错误码 → HTTP 状态。一张表喂两张皮(数据面那半也从这儿取),码集由 share.js 的
|
|
135
|
+
* validateSharePack 定:闸门新增一格必须在这里登记。认不出的码走「500 + 日志」那一条,
|
|
136
|
+
* 绝不静默降级成 400 冒充「你的包不合格」——那会把存储故障报成对方的错。
|
|
137
|
+
*/
|
|
138
|
+
export const SHARE_IMPORT_ERRORS = Object.freeze({
|
|
139
|
+
"invalid-kind": 400,
|
|
140
|
+
"invalid-pack-id": 400,
|
|
141
|
+
"invalid-memories": 400,
|
|
142
|
+
"empty-pack": 400,
|
|
143
|
+
"invalid-type": 400,
|
|
144
|
+
"invalid-entry": 400,
|
|
145
|
+
"external-id-mismatch": 400,
|
|
146
|
+
"duplicate-external-id": 400
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 「收侧没接线」的码(headless 或旧装配里没有那两个方法)。与 RECLAIM_UNAVAILABLE 同一
|
|
151
|
+
* 处置:字面量只许住在本模块,两张皮都从这一个导出取,否则各写一种拼法就没法按码分支。
|
|
152
|
+
*/
|
|
153
|
+
export const SHARE_IMPORT_UNAVAILABLE = "share-import-unavailable";
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 缺省即干跑:没有 confirm 就是预览。脾气照 readReclaimFields 的 confirm(只认真布尔),
|
|
157
|
+
* 但这一面的方向更硬——写的是**收件人的库**,所以唯一的入口就是 confirm,`dryRun` 之类
|
|
158
|
+
* 的第二个旋钮一概不认(reclaim 的 body 形状里就带 dryRun,手熟的人会顺手发来,把它读成
|
|
159
|
+
* 「要写」等于替她点头)。
|
|
160
|
+
*/
|
|
161
|
+
function readDryRun(body) {
|
|
162
|
+
return body?.confirm !== true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 答复壳里唯一由本层加的一格。service 那一侧收的是 opts.dryRun、不认识 dry_run,对面读的
|
|
167
|
+
* 却是这一格,所以放在本层加:两张皮拿到的键才完全相同。
|
|
168
|
+
*
|
|
169
|
+
* `dryRun !== false` 而不是 `=== true`:认不出的值(字符串、对象、undefined)一律落回**干跑**,
|
|
170
|
+
* 与解析侧同一个失败方向;而传下去的那个布尔值与写进答复的那一格永远同一个数——「其实没写、
|
|
171
|
+
* 却说写了」和「其实写了、却说没写」都是说谎,这一格不许和实参分家。
|
|
172
|
+
*/
|
|
173
|
+
function withDryRun(reply, dryRun) {
|
|
174
|
+
return { ...reply, dry_run: dryRun !== false };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 解析 POST /receive 的 body(已读成文本)→ { pack, dryRun }。
|
|
179
|
+
*
|
|
180
|
+
* 三个码分开是有原因的:invalid-json(发坏了)、invalid-body(顶层就不是对象)、
|
|
181
|
+
* missing-pack(对象里没有包)——对面据此知道改哪一头,而它们全是 400。空 body 是
|
|
182
|
+
* **错**的,与出包侧相反:那边「一个字节都不发」是整库口径,这边压根没东西可写。
|
|
183
|
+
*
|
|
184
|
+
* pack 那一格只判「是不是个对象」,不判包:数组或 `{kind:"nope"}` 都往下交,由闸门整包拒
|
|
185
|
+
* (invalid-kind)。判据只许有一处。
|
|
186
|
+
*
|
|
187
|
+
* @returns {{ok: true, pack: object, dryRun: boolean} | {ok: false, error: string}}
|
|
188
|
+
*/
|
|
189
|
+
export function parseReceiveBody(text) {
|
|
190
|
+
if (text === undefined || text === null || String(text).trim() === "") return { ok: false, error: "invalid-body" };
|
|
191
|
+
let body;
|
|
192
|
+
try {
|
|
193
|
+
body = JSON.parse(String(text));
|
|
194
|
+
} catch {
|
|
195
|
+
return { ok: false, error: "invalid-json" };
|
|
196
|
+
}
|
|
197
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: "invalid-body" };
|
|
198
|
+
if (!body.pack || typeof body.pack !== "object") return { ok: false, error: "missing-pack" };
|
|
199
|
+
return { ok: true, pack: body.pack, dryRun: readDryRun(body) };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 解析 POST /receive/undo 的 body → { packId, dryRun }。顶层形状与「有没有 id」是两个错,
|
|
204
|
+
* 与 receive 侧同一口径(发坏了 / 发错了形状 / 没填)。
|
|
205
|
+
*
|
|
206
|
+
* id 在这里 trim,与 deleteSharePack 首行同一把尺;非字符串与空白一律不进删除那条路——
|
|
207
|
+
* 让空白去 service 那儿换回一个 `pack_id: ""` 的 0 删除,读数上看不出是「没填」还是
|
|
208
|
+
* 「那个包本来就没导入过」,而这两种在出口是完全不同的事。
|
|
209
|
+
*
|
|
210
|
+
* @returns {{ok: true, packId: string, dryRun: boolean} | {ok: false, error: string}}
|
|
211
|
+
*/
|
|
212
|
+
export function parseUndoBody(text) {
|
|
213
|
+
if (text === undefined || text === null || String(text).trim() === "") return { ok: false, error: "invalid-body" };
|
|
214
|
+
let body;
|
|
215
|
+
try {
|
|
216
|
+
body = JSON.parse(String(text));
|
|
217
|
+
} catch {
|
|
218
|
+
return { ok: false, error: "invalid-json" };
|
|
219
|
+
}
|
|
220
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: "invalid-body" };
|
|
221
|
+
const packId = typeof body.packId === "string" ? body.packId.trim() : "";
|
|
222
|
+
if (!packId) return { ok: false, error: "missing-pack-id" };
|
|
223
|
+
return { ok: true, packId, dryRun: readDryRun(body) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 入库的答复。`pack` 原样交给 service,校验在那儿跑(见本段开头那句「判据只许有一处」)。
|
|
228
|
+
*
|
|
229
|
+
* 抛错在这儿兜住并回 500,不留给调用方的 try:两侧 handler 的形状都是
|
|
230
|
+
* `try { return readBody(...).then(...) } catch`,外层 try 在 .then 的回调里已经退栈,
|
|
231
|
+
* 从这里抛出去就是一个没人接的 rejection(#410)。warn 可选——数据面的 logger 未必接得上,
|
|
232
|
+
* 少一个回调不许把异常漏出去。
|
|
233
|
+
*
|
|
234
|
+
* @param {{service: {importSharePack?: Function}, pack: object, dryRun?: boolean,
|
|
235
|
+
* warn?: ((msg: string) => void)|null}} input
|
|
236
|
+
* @returns {{status: number, body: object}}
|
|
237
|
+
*/
|
|
238
|
+
export function receiveReply({ service, pack, dryRun, warn = null }) {
|
|
239
|
+
if (typeof service?.importSharePack !== "function") {
|
|
240
|
+
return { status: 503, body: { error: SHARE_IMPORT_UNAVAILABLE } };
|
|
241
|
+
}
|
|
242
|
+
const dry = dryRun !== false;
|
|
243
|
+
try {
|
|
244
|
+
return { status: 200, body: withDryRun(service.importSharePack(pack, { dryRun: dry }), dry) };
|
|
245
|
+
} catch (error) {
|
|
246
|
+
const code = typeof error?.code === "string" ? error.code : null;
|
|
247
|
+
const status = code === null ? undefined : SHARE_IMPORT_ERRORS[code];
|
|
248
|
+
if (status) {
|
|
249
|
+
// detail 是闸门给的那一格(哪个字段、哪个坏类型),只转发不加工;没有就不造这一格。
|
|
250
|
+
const detail = typeof error.detail === "string" && error.detail ? { detail: error.detail } : {};
|
|
251
|
+
return { status, body: { error: code, ...detail } };
|
|
252
|
+
}
|
|
253
|
+
warnSafely(warn, `[dsh-mneme] share import failed: ${String(error)}`);
|
|
254
|
+
return { status: 500, body: { error: "internal" } };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 整包退出的答复。这一面**不查** SHARE_IMPORT_ERRORS:packId 的形状闸门在 parseUndoBody,
|
|
260
|
+
* service 那侧不抛带码的错(非字符串/空白它回一个 0 删除,不是异常),所以真抛出来就是
|
|
261
|
+
* 存储故障。别在这儿补第二张表——那张表只会成为 SHARE_IMPORT_ERRORS 的影子。
|
|
262
|
+
*
|
|
263
|
+
* 答复里的 pack_id 是 service 的读数而不是请求的回显(非字符串/空白那一支它回 ""),
|
|
264
|
+
* 本层不改写、不兜底;dry_run 由 withDryRun 加,与入库那侧同一格键。
|
|
265
|
+
*
|
|
266
|
+
* @param {{service: {deleteSharePack?: Function}, packId: string, dryRun?: boolean,
|
|
267
|
+
* warn?: ((msg: string) => void)|null}} input
|
|
268
|
+
* @returns {{status: number, body: object}}
|
|
269
|
+
*/
|
|
270
|
+
export function undoReply({ service, packId, dryRun, warn = null }) {
|
|
271
|
+
if (typeof service?.deleteSharePack !== "function") {
|
|
272
|
+
return { status: 503, body: { error: SHARE_IMPORT_UNAVAILABLE } };
|
|
273
|
+
}
|
|
274
|
+
const dry = dryRun !== false;
|
|
275
|
+
try {
|
|
276
|
+
return { status: 200, body: withDryRun(service.deleteSharePack(packId, { dryRun: dry }), dry) };
|
|
277
|
+
} catch (error) {
|
|
278
|
+
warnSafely(warn, `[dsh-mneme] share undo failed: ${String(error)}`);
|
|
279
|
+
return { status: 500, body: { error: "internal" } };
|
|
280
|
+
}
|
|
281
|
+
}
|