@nightpainters/dsh-ling 1.5.1
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/ACCESS-DESIGN.md +119 -0
- package/CHANGELOG.md +627 -0
- package/DESIGN.md +461 -0
- package/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +236 -0
- package/cordis.patch.yml +16 -0
- package/lib/audit/access-log.js +533 -0
- package/lib/audit/index.js +92 -0
- package/lib/audit/patch-spec.js +195 -0
- package/lib/client.js +5069 -0
- package/lib/host/api.js +2584 -0
- package/lib/host/archive.js +194 -0
- package/lib/host/autotree.js +131 -0
- package/lib/host/backfill.js +267 -0
- package/lib/host/classify.js +10 -0
- package/lib/host/clock-skill.js +141 -0
- package/lib/host/clock.js +132 -0
- package/lib/host/corpus-suggest.js +139 -0
- package/lib/host/deepsummary.js +338 -0
- package/lib/host/dsweb-summary.js +225 -0
- package/lib/host/feedback.js +119 -0
- package/lib/host/freeze.js +144 -0
- package/lib/host/genesis.js +176 -0
- package/lib/host/guard.js +184 -0
- package/lib/host/habit-gen.js +273 -0
- package/lib/host/import-file.js +291 -0
- package/lib/host/import-log.js +129 -0
- package/lib/host/inject-common.js +25 -0
- package/lib/host/inject.js +285 -0
- package/lib/host/l1.js +282 -0
- package/lib/host/lifecycle.js +367 -0
- package/lib/host/memory.js +2389 -0
- package/lib/host/mode.js +101 -0
- package/lib/host/persona.js +214 -0
- package/lib/host/retitle.js +214 -0
- package/lib/host/rules.js +195 -0
- package/lib/host/scan-dsweb.js +111 -0
- package/lib/host/seal.js +17 -0
- package/lib/host/settings-file.js +49 -0
- package/lib/host/snapshot.js +168 -0
- package/lib/host/suggestions.js +47 -0
- package/lib/host/summarizer.js +103 -0
- package/lib/host/tone-advice.js +112 -0
- package/lib/host/tools.js +1067 -0
- package/lib/host/tree-ops.js +330 -0
- package/lib/host/util.js +165 -0
- package/lib/host/zstd.js +63 -0
- package/lib/index.js +263 -0
- package/package.json +69 -0
- package/tools/apply-access-log-patch.mjs +295 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// dsh-ling host — 记忆树操作的**唯一实现**(Part G G1,2026-09-25)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有这个文件:
|
|
4
|
+
// Part G 任务书点出的洞 ——「记忆树的写操作,一条 agent 侧工具都没有。我得靠伪造成本机
|
|
5
|
+
// 进程的身份走 UI 的 HTTP 口,或者直接开库」。根因不是"缺工具",而是**逻辑焊在 HTTP
|
|
6
|
+
// handler 里**:`api.js` 的 26 处树端点各自内联校验 + 调 memory + 写留痕,没有任何可复用
|
|
7
|
+
// 的内部函数 ⇒ 工具通道无处落脚,只能重写一遍(那就成了两套会各自漂移的实现)。
|
|
8
|
+
//
|
|
9
|
+
// 本模块的契约:
|
|
10
|
+
// 1. **一个操作一个函数**,签名统一 `(memory, args) => result`,result 至少含 `ok`。
|
|
11
|
+
// 失败时含 `reason`(短英文码,给界面/模型分支用);成功时含该操作的自然结果字段。
|
|
12
|
+
// 2. **`result.status` 是权威 HTTP 状态码** —— 由这里决定,`api.js` 只负责照发。
|
|
13
|
+
// 这样"哪些失败算 400"这件事只有一处定义,不会出现两条通道判定不一致。
|
|
14
|
+
// 3. **留痕一律经 `memory.logBranch(..., { actor })`** —— `actor` 由调用方给
|
|
15
|
+
// (`'user'` 界面 / `'ling'` 器灵工具),G4 之前它恒为人,器灵改的树只能记在主人名下。
|
|
16
|
+
// 4. 本模块**不碰 HTTP**(不 import req/res/readBody),也**不碰模型** —— 纯逻辑,
|
|
17
|
+
// 便于单测,也便于工具通道直接调。
|
|
18
|
+
//
|
|
19
|
+
// 零行为变更:本文件的内容逐条搬自 `api.js` 原有 handler 的内联逻辑,搬动时**不改判定**,
|
|
20
|
+
// 只把 `sendJson(res, X, Y)` 换成 `return { ...Y, status: X }`。
|
|
21
|
+
|
|
22
|
+
import { TRUNK_ID } from './memory.js';
|
|
23
|
+
|
|
24
|
+
/** 枝列表 → id 索引 Map(原 `api.js:62` 的私有助手,搬来共用)。 */
|
|
25
|
+
export const mapBranchList = (list) => new Map((Array.isArray(list) ? list : []).map((b) => [String(b.id), b]));
|
|
26
|
+
|
|
27
|
+
/** 会话/条目存在性(审计 B#11):`conv_overview` 或 `session_meta` 里找得到才算真条目。
|
|
28
|
+
* ⚠️ `memory.sessionMeta()` 对未知会话会回一份**默认对象**(不是 null),不能当存在性判据
|
|
29
|
+
* —— 故 session_meta 侧走 `sessionBranchMap()`(真行才有键)。 */
|
|
30
|
+
export function convExistsIn(memory, source, convId) {
|
|
31
|
+
const src = String(source || '');
|
|
32
|
+
const cid = String(convId || '');
|
|
33
|
+
if (!src || !cid) return false;
|
|
34
|
+
try {
|
|
35
|
+
if (memory.overviewById(src, cid)) return true;
|
|
36
|
+
} catch { /* 查询失败按"找不到"处理 */ }
|
|
37
|
+
if (src === 'dsh') {
|
|
38
|
+
try {
|
|
39
|
+
return memory.sessionBranchMap().has(cid);
|
|
40
|
+
} catch { /* noop */ }
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 枝存在性检查(D-T02,1.5.1 红蓝对抗 · 红队 D):`link`/`unlink` 是这一族里**唯一**
|
|
46
|
+
* 没做存在性检查的两个(兄弟:`opBranchCreate` 查父、`opBranchAssign` 查枝、
|
|
47
|
+
* `opBranchRename`/`Reparent`/`Weight` 查自己)⇒ `link deadbeef ↔ cafebabe` 回 ok 且真落
|
|
48
|
+
* `vein_link` —— 悬空边要到渲染/对账时才炸,而制造它只要一次拼错的 id。
|
|
49
|
+
* 返回**找不到的那些 id**(回执要把"哪一端"念出来,否则调用方只能猜)。 */
|
|
50
|
+
export function missingBranchesIn(memory, ids) {
|
|
51
|
+
let known = new Set();
|
|
52
|
+
try { known = new Set((memory.listBranches() || []).map((b) => String(b.id))); } catch { /* 查询失败按全缺处理 */ }
|
|
53
|
+
return (ids || []).map((x) => String(x || '')).filter((x) => !known.has(x));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 统一失败构造(短码 + 权威状态码)。 */
|
|
57
|
+
const bad = (reason, status = 400, extra = {}) => ({ ok: false, reason, status, ...extra });
|
|
58
|
+
/** 统一成功构造(默认 200)。 */
|
|
59
|
+
const good = (extra = {}, status = 200) => ({ ok: true, status, ...extra });
|
|
60
|
+
|
|
61
|
+
/** 谁是这次改动的发起者 —— 只认 'ling',其余一律 'user'(与 `logBranch` 的归一化一致)。 */
|
|
62
|
+
const actorOf = (args) => (String(args?.actor || 'user').toLowerCase() === 'ling' ? 'ling' : 'user');
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------- 读
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 读整树:枝/主脉嵌套 + 横向连边 + 计数。
|
|
68
|
+
* @param deps.treeCounts 由调用方注入的计数函数(它依赖 api.js 侧的 memberCountOf 等,
|
|
69
|
+
* 搬动成本高于收益 —— 保持单一实现,由 api.js 传入)。缺省时退化为"无计数"。
|
|
70
|
+
*/
|
|
71
|
+
export function opTreeRead(memory, args = {}, deps = {}) {
|
|
72
|
+
const t = memory.branchTree();
|
|
73
|
+
const counts = typeof deps.treeCounts === 'function' ? deps.treeCounts() : {};
|
|
74
|
+
const { byBranch = {}, sessCount = {}, total = 0, memberCounts = null } = counts || {};
|
|
75
|
+
const treeNode = typeof deps.treeNode === 'function' ? deps.treeNode : ((n) => n);
|
|
76
|
+
return good({
|
|
77
|
+
trunk: TRUNK_ID,
|
|
78
|
+
total,
|
|
79
|
+
memories: Number(total || 0),
|
|
80
|
+
// A#6:告诉界面这次 sessions 用的是哪种口径(同一谓词 / 旧的 session_meta 计数降级)
|
|
81
|
+
sessionsBasis: memberCounts ? 'members' : 'session-meta',
|
|
82
|
+
roots: (t.roots || []).map((n) => treeNode(n, byBranch, sessCount)),
|
|
83
|
+
links: t.links || [],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 读某枝的会话级成员(R2 展开)。 */
|
|
88
|
+
export function opBranchMembers(memory, args = {}) {
|
|
89
|
+
const id = String(args.id || TRUNK_ID);
|
|
90
|
+
const limit = Number(args.limit || 300);
|
|
91
|
+
const members = memory.branchMembers(id, { limit });
|
|
92
|
+
return good({ id, count: members.length, members });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------- 建 / 删 / 挂
|
|
96
|
+
|
|
97
|
+
/** 建枝或主脉(kind='vein' 建主脉)。 */
|
|
98
|
+
export function opBranchCreate(memory, args = {}) {
|
|
99
|
+
const kind = String(args.kind || 'branch');
|
|
100
|
+
if (kind !== 'branch' && kind !== 'vein') return bad('bad-kind');
|
|
101
|
+
const parentId = String(args.parentId || TRUNK_ID);
|
|
102
|
+
if (!memory.listBranches().some((b) => b.id === parentId)) return bad('no-parent');
|
|
103
|
+
const name = String(args.name || '').trim().slice(0, 80);
|
|
104
|
+
// 审计 B#4:空名枝会成为树里一个点不开的幽灵节点;旧版**没有任何删枝端点**,建错了只能改库。
|
|
105
|
+
if (!name) return bad('empty-name');
|
|
106
|
+
const id = kind === 'vein'
|
|
107
|
+
? memory.createVein({ name, parentId })
|
|
108
|
+
: memory.createBranch({ name, kind: 'branch', parentId });
|
|
109
|
+
return good({ id, kind, name, parentId });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 删枝:trunk 不可删;非空枝(有子枝/归属/连边)默认拒绝并回报影响面,force 才动手。 */
|
|
113
|
+
export function opBranchDelete(memory, args = {}) {
|
|
114
|
+
const r = memory.deleteBranch(String(args.id || ''), { force: args.force === true });
|
|
115
|
+
if (r.ok) {
|
|
116
|
+
memory.logBranch(TRUNK_ID, 'delete', {
|
|
117
|
+
after: String(r.name || args.id || ''),
|
|
118
|
+
note: '删枝' + (args.force ? '(force)' : ''),
|
|
119
|
+
actor: actorOf(args),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return { ...r, status: r.ok ? 200 : 400 };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 并脉:把枝挂到主脉下(内容零变化)。新主脉可当场建(name 非空且 parentId 尚不存在时先建 vein)。 */
|
|
126
|
+
export function opBranchReparent(memory, args = {}) {
|
|
127
|
+
const id = String(args.id || '');
|
|
128
|
+
if (!id) return bad('bad-id');
|
|
129
|
+
let parentId = String(args.parentId || '');
|
|
130
|
+
if (parentId && !memory.listBranches().some((b) => b.id === parentId)) {
|
|
131
|
+
if (String(args.createVein || '') === '1' || args.name) {
|
|
132
|
+
parentId = memory.createVein({
|
|
133
|
+
name: String(args.name || '新主脉').slice(0, 80),
|
|
134
|
+
parentId: String(args.veinParent || TRUNK_ID),
|
|
135
|
+
});
|
|
136
|
+
} else {
|
|
137
|
+
return bad('no-parent');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// D-T06(1.5.1 红蓝对抗 · 红队 D):缺 parentId 时旧代码把 `''` 一路带下去 ——
|
|
141
|
+
// `memory.reparentBranch` 内部把 `''` 兜成主干(所以界面通道行为不变),但**留痕记的是 `''`**
|
|
142
|
+
// ⇒ digest 渲染成「挪到了「?」下」,注入面看不出挪去了哪里。
|
|
143
|
+
// 这里显式解析成真父:**留痕必须记实际发生的事**(工具通道另在 checkBranchEdit 里直接拒绝缺参)。
|
|
144
|
+
if (!parentId) parentId = TRUNK_ID;
|
|
145
|
+
const beforeRow = memory.listBranches().find((b) => b.id === id) || null;
|
|
146
|
+
// 审计 B#3:id 不存在时 UPDATE 命中 0 行仍回 ok:true ⇒ 这里先挡(不写留痕)
|
|
147
|
+
if (!beforeRow) return bad('no-match');
|
|
148
|
+
const r = memory.reparentBranch(id, parentId);
|
|
149
|
+
if (r.ok) {
|
|
150
|
+
memory.logBranch(id, 'reparent', {
|
|
151
|
+
before: (beforeRow && beforeRow.parentId) || '',
|
|
152
|
+
after: String(parentId || ''),
|
|
153
|
+
note: String(args.note || ''),
|
|
154
|
+
actor: actorOf(args),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return { ...r, status: r.ok ? 200 : 400 };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 改枝名(lock 默认 true ⇒ 起名即定名,机器不再覆盖)。 */
|
|
161
|
+
export function opBranchRename(memory, args = {}) {
|
|
162
|
+
const name = String(args.name || '').trim().slice(0, 80);
|
|
163
|
+
if (!name) return bad('empty-name');
|
|
164
|
+
const id = String(args.id || '');
|
|
165
|
+
const beforeRow = memory.listBranches().find((b) => b.id === id) || null;
|
|
166
|
+
if (!beforeRow) return bad('no-match'); // 审计 B#3
|
|
167
|
+
const r = memory.renameBranch(id, name, { lock: args.lock !== false });
|
|
168
|
+
if (r.ok) {
|
|
169
|
+
memory.logBranch(id, 'rename', {
|
|
170
|
+
before: (beforeRow && beforeRow.name) || '',
|
|
171
|
+
after: name,
|
|
172
|
+
note: String(args.note || ''),
|
|
173
|
+
actor: actorOf(args),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return { ...r, status: r.ok ? 200 : 400 };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 调枝系数(设计稿 §3.3:权重**仅复盘时**修改 —— 平时只读,由工具层的策略把关)。 */
|
|
180
|
+
export function opBranchWeight(memory, args = {}) {
|
|
181
|
+
const id = String(args.id || '');
|
|
182
|
+
const beforeRow = memory.listBranches().find((b) => b.id === id) || null;
|
|
183
|
+
if (!beforeRow) return bad('no-match'); // 审计 B#3
|
|
184
|
+
const r = memory.setBranchWeight(id, args.weightScale);
|
|
185
|
+
if (!r.ok) return { ...r, status: 400 };
|
|
186
|
+
// D-T03(1.5.1 红蓝对抗,加强):回执与留痕里的数字必须来自**库**,不能来自"我以为写了多少" ——
|
|
187
|
+
// `setBranchWeight` 的返回值是它**自己算出来的**夹取值(`UPDATE ... WHERE id=?` 命中 0 行它照样回 ok)。
|
|
188
|
+
// 这里写后读回一次,以后的每一处消费都以读回值为准;读不回来(行没了)时如实用 null 表示,
|
|
189
|
+
// 绝不拿"计算值"冒充"落库值"。 */
|
|
190
|
+
const afterRow = memory.listBranches().find((b) => b.id === id) || null;
|
|
191
|
+
const stored = afterRow ? Number(afterRow.weightScale) : null;
|
|
192
|
+
const wrote = Number.isFinite(stored) ? stored : r.weightScale;
|
|
193
|
+
memory.logBranch(id, 'weight', {
|
|
194
|
+
before: String((beforeRow && beforeRow.weightScale) ?? ''),
|
|
195
|
+
after: String(wrote),
|
|
196
|
+
note: String(args.note || ''),
|
|
197
|
+
actor: actorOf(args),
|
|
198
|
+
});
|
|
199
|
+
return { ...r, weightScale: wrote, stored, status: 200 };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------- 连边
|
|
203
|
+
|
|
204
|
+
/** 主脉间连边(横向关系,不影响归属)。 */
|
|
205
|
+
export function opVeinLink(memory, args = {}) {
|
|
206
|
+
const from = String(args.from || '');
|
|
207
|
+
const to = String(args.to || '');
|
|
208
|
+
// 缺参与"两端相同"是**参数问题**(bad-arg),与"id 不存在"(no-match)分开报 —— 两件事。
|
|
209
|
+
if (!from || !to) return bad('bad-arg', 400, { hint: '连边要给出两端 id(from / to)' });
|
|
210
|
+
if (from === to) return bad('bad-arg', 400, { hint: '两端相同 —— 一条边连不上自己' });
|
|
211
|
+
// D-T02:两端都必须是树里真有的枝 —— 这里是"悬空边"唯一的产生口,必须挡在写之前。
|
|
212
|
+
// (只挡 here,不改 memory.linkVeins 的签名:错要错得一致,但闸要立在写入侧。)
|
|
213
|
+
const missing = missingBranchesIn(memory, [from, to]);
|
|
214
|
+
if (missing.length) {
|
|
215
|
+
return bad('no-match', 400, {
|
|
216
|
+
missing, from, to, hint: '连边两端都必须是树里真实存在的枝:拒绝落悬空边',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const r = memory.linkVeins(from, to, {
|
|
220
|
+
kind: String(args.kind || 'related'),
|
|
221
|
+
note: String(args.note || '').slice(0, 300),
|
|
222
|
+
});
|
|
223
|
+
if (r.ok) {
|
|
224
|
+
memory.logBranch(from, 'link', {
|
|
225
|
+
before: '', after: to, note: String(args.kind || 'related'), actor: actorOf(args),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return { ...r, status: r.ok ? 200 : 400 };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 断边。 */
|
|
232
|
+
export function opVeinUnlink(memory, args = {}) {
|
|
233
|
+
const from = String(args.from || '');
|
|
234
|
+
const to = String(args.to || '');
|
|
235
|
+
if (!from || !to) return bad('bad-arg', 400, { hint: '断边要给出两端 id(from / to)' });
|
|
236
|
+
if (from === to) return bad('bad-arg', 400, { hint: '两端相同 —— 不存在这样的边' });
|
|
237
|
+
// D-T02(有意与 link 不同):**不用存在性拒绝**。库里可能本来就躺着悬空边(1.5.0 造的),
|
|
238
|
+
// 而 unlink 是清掉它**唯一**的通道 —— 若以"两端必须存在"为由拒绝,悬空边就永远删不掉了。
|
|
239
|
+
// 所以这里:① 边本身不存在 → no-match(原有闸,防假留痕);② 边在、但某端已不在树里 →
|
|
240
|
+
// 照删,把 `dangling` 如实回传(回执说"清掉的是一条悬空边",不再假装两端都在)。
|
|
241
|
+
const dangling = missingBranchesIn(memory, [from, to]);
|
|
242
|
+
const r = memory.unlinkVein(from, to);
|
|
243
|
+
// 审计 B#3:边不存在时 DELETE 命中 0 行 —— 旧版照样回 ok:true 并留痕「断开了边」(假留痕)
|
|
244
|
+
if (r.ok && !(Number(r.removed) > 0)) return bad('no-match', 400, { removed: 0, missing: dangling });
|
|
245
|
+
if (r.ok) {
|
|
246
|
+
memory.logBranch(from, 'unlink', {
|
|
247
|
+
before: to, after: '',
|
|
248
|
+
note: dangling.length ? '断开一条悬空边(一端已不在树里:' + dangling.join(',') + ')' : '',
|
|
249
|
+
actor: actorOf(args),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return { ...r, status: r.ok ? 200 : 400, dangling };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------- 归属
|
|
256
|
+
|
|
257
|
+
/** 把一条会话/条目挪到某枝(拖动,R2/R3)。 */
|
|
258
|
+
export function opAssignConv(memory, args = {}) {
|
|
259
|
+
// ⚠️ 审计 B#11:此前接受**任意** branchId / convId ⇒ "幽灵行"真落库(覆盖层里指向一条
|
|
260
|
+
// 不存在的枝 / 一个不存在的条目),表现为界面出现"名字 = 裸 UUID"的枝。
|
|
261
|
+
// 写入侧两侧都挡(读取侧另在 branchCounts 对账):
|
|
262
|
+
// ① 枝必须存在(trunk 恒在);② 条目必须能在 conv_overview 或 session_meta 里找到。
|
|
263
|
+
const bid = String(args.branchId || TRUNK_ID);
|
|
264
|
+
if (bid !== TRUNK_ID && !mapBranchList(memory.listBranches()).has(bid)) return bad('no-branch');
|
|
265
|
+
const src = String(args.source || '');
|
|
266
|
+
const cid = String(args.convId || '');
|
|
267
|
+
if (!src || !cid) return bad('bad-arg');
|
|
268
|
+
if (!convExistsIn(memory, src, cid)) {
|
|
269
|
+
return bad('no-conv', 400, { hint: '该条目不在 conv_overview / session_meta 里:拒绝落幽灵行' });
|
|
270
|
+
}
|
|
271
|
+
const r = memory.assignConv(src, cid, bid);
|
|
272
|
+
if (r.ok) {
|
|
273
|
+
memory.logBranch(bid, 'assign', {
|
|
274
|
+
before: String(r.prev || ''),
|
|
275
|
+
after: src + '/' + cid,
|
|
276
|
+
note: '把一条' + (src === 'dsh' ? '会话' : '历史条目') + '挪到本枝',
|
|
277
|
+
actor: actorOf(args),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return { ...r, status: 200 }; // 原 handler 成功与否都回 200(失败走上面的早返回)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------- 矛盾
|
|
284
|
+
|
|
285
|
+
/** 裁定一对矛盾(复盘环节:确认哪条作数 / 驳回)。**只改权重与状态,不改内容**。 */
|
|
286
|
+
export function opConflictResolve(memory, args = {}) {
|
|
287
|
+
const r = memory.resolveConflict(String(args.id || ''), {
|
|
288
|
+
winner: args.winner ?? null,
|
|
289
|
+
status: String(args.status || 'confirmed'),
|
|
290
|
+
});
|
|
291
|
+
return { ...r, status: r.ok ? 200 : 400 };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------- 快照
|
|
295
|
+
|
|
296
|
+
/** 存一份树快照(超上限时先裁掉最旧的一份**非自动**快照 —— 自动备份是撤回路径,不裁)。 */
|
|
297
|
+
export function opSnapshotTree(memory, args = {}, deps = {}) {
|
|
298
|
+
const max = Number(deps.maxSnapshots || 0);
|
|
299
|
+
if (max > 0) {
|
|
300
|
+
try {
|
|
301
|
+
const all = memory.listTreeSnapshots({ limit: 200 });
|
|
302
|
+
if (all.length >= max) {
|
|
303
|
+
// 列表按 id 倒序 → 从末位(最旧)往前找第一条可裁的
|
|
304
|
+
const victim = [...all].reverse().find((s) => s && s.id && String(s.kind || 'manual') !== 'auto');
|
|
305
|
+
// 传改过签名的 deleteTreeSnapshot(id, {force}) 一个参数是安全的;软链未落地时它读不到 force
|
|
306
|
+
// (undefined → 默认 false,保护默认开启)⇒ 绝不强删
|
|
307
|
+
if (victim) memory.deleteTreeSnapshot(victim.id);
|
|
308
|
+
}
|
|
309
|
+
} catch { /* 上限清理失败不该挡住保存 */ }
|
|
310
|
+
}
|
|
311
|
+
const r = memory.snapshotTree({ name: args.name, note: args.note });
|
|
312
|
+
if (r.ok) {
|
|
313
|
+
memory.logBranch(TRUNK_ID, 'snapshot', {
|
|
314
|
+
after: String(args.name || ('#' + r.id)), note: '保存树快照', actor: actorOf(args),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return { ...r, status: 200 }; // 原 handler 无条件 200
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** 恢复树结构;恢复前**自动存一份当前状态**(由 memory.restoreTree 内部完成,返回 backupId)。 */
|
|
321
|
+
export function opRestoreTree(memory, args = {}) {
|
|
322
|
+
const r = memory.restoreTree(args.id);
|
|
323
|
+
return { ...r, status: 200 };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** 删快照。 */
|
|
327
|
+
export function opDeleteSnapshot(memory, args = {}) {
|
|
328
|
+
const r = memory.deleteTreeSnapshot(args.id);
|
|
329
|
+
return { ...r, status: 200 };
|
|
330
|
+
}
|
package/lib/host/util.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// dsh-ling host utilities — platform-service access, paths, io helpers.
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { mkdir, writeFile, readFile } from 'node:fs/promises';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
export const PLUGIN_ID = 'dsh-ling';
|
|
9
|
+
export const DISPLAY_NAME = 'dsh-ling · 器灵';
|
|
10
|
+
|
|
11
|
+
/** Resolve a platform service by id without throwing (cordis ctx.get throws when absent). */
|
|
12
|
+
export function svc(ctx, name) {
|
|
13
|
+
try {
|
|
14
|
+
const v = ctx?.get?.(name);
|
|
15
|
+
return v !== undefined ? v : undefined;
|
|
16
|
+
} catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Try a nested chain of property gets (defensive against unknown payload shapes). */
|
|
22
|
+
export function pick(...chain) {
|
|
23
|
+
for (const c of chain) {
|
|
24
|
+
const v = c();
|
|
25
|
+
if (v !== undefined && v !== null) return v;
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** $DSH_HOME → env DSH_HOME, else ~/.dsh (platform convention; not hardcoded elsewhere). */
|
|
31
|
+
export function dshHome() {
|
|
32
|
+
return process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Plugin-owned data dir: $DSH_HOME/cache/dsh-ling (outside the plugin package, survives updates). */
|
|
36
|
+
export function defaultDataDir() {
|
|
37
|
+
return join(dshHome(), 'cache', PLUGIN_ID);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function ensureDir(dir) {
|
|
41
|
+
mkdirSync(dir, { recursive: true });
|
|
42
|
+
return dir;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readJsonSafe(file, fallback) {
|
|
46
|
+
try {
|
|
47
|
+
if (!existsSync(file)) return fallback;
|
|
48
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
49
|
+
} catch {
|
|
50
|
+
return fallback;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function writeJsonAtomic(file, value) {
|
|
55
|
+
const idx = Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\'));
|
|
56
|
+
const dir = idx > 0 ? file.slice(0, idx) : '.';
|
|
57
|
+
await mkdir(dir, { recursive: true });
|
|
58
|
+
const tmp = `${file}.tmp`;
|
|
59
|
+
await writeFile(tmp, JSON.stringify(value, null, 2), 'utf8');
|
|
60
|
+
await (await import('node:fs/promises')).rename(tmp, file).catch(async () => {
|
|
61
|
+
// Windows rename-over-existing can fail; fall back to direct write.
|
|
62
|
+
await writeFile(file, JSON.stringify(value, null, 2), 'utf8');
|
|
63
|
+
});
|
|
64
|
+
return file;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function sha256Text(text) {
|
|
68
|
+
return createHash('sha256').update(text, 'utf8').digest('hex');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function utcIso() {
|
|
72
|
+
return new Date().toISOString();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 宽松时间归一(2026-09-15 修 A):把各种历史遗留形态统一成 epoch 毫秒。
|
|
77
|
+
* 能吃:ISO 字符串 / 数字 / 数字字符串(含 `"1789006881011.0"` 这类浮点串)/ Date。
|
|
78
|
+
* 十位数按「秒」、十三位按「毫秒」;不可解析一律返回 null(绝不返回 NaN)。
|
|
79
|
+
* 背景:早期写入路径把毫秒时间戳当浮点存成了 text,导致 `Date.parse` 返回 NaN →
|
|
80
|
+
* 新近度恒为 1.0(永不衰减)、注入文本里出现 `(1789006881)`。
|
|
81
|
+
*/
|
|
82
|
+
export function toEpochMs(value) {
|
|
83
|
+
if (value === null || value === undefined || value === '') return null;
|
|
84
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.getTime();
|
|
85
|
+
if (typeof value === 'number') return epochOf(value);
|
|
86
|
+
const s = String(value).trim();
|
|
87
|
+
if (!s) return null;
|
|
88
|
+
if (/^-?\d+(?:\.\d+)?$/.test(s)) return epochOf(Number(s));
|
|
89
|
+
const t = Date.parse(s);
|
|
90
|
+
return Number.isNaN(t) ? null : t;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function epochOf(n) {
|
|
94
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
95
|
+
return Math.round(n < 1e11 ? n * 1000 : n); // 10 位=秒,13 位=毫秒
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 归一为 ISO 字符串(存库/展示统一用 ISO);不可解析返回 null。 */
|
|
99
|
+
export function toIso(value) {
|
|
100
|
+
const ms = toEpochMs(value);
|
|
101
|
+
return ms === null ? null : new Date(ms).toISOString();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 归一为 `YYYY-MM-DD`(日期展示用,UTC 口径,与原 `.slice(0,10)` 行为一致);不可解析返回 ''。 */
|
|
105
|
+
export function isoDate(value) {
|
|
106
|
+
const iso = toIso(value);
|
|
107
|
+
return iso ? iso.slice(0, 10) : '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Deep-merge plain objects (arrays replaced). */
|
|
111
|
+
export function mergeDeep(base, over) {
|
|
112
|
+
if (over === undefined || over === null) return base;
|
|
113
|
+
if (typeof base !== 'object' || base === null || Array.isArray(base)) return over;
|
|
114
|
+
if (typeof over !== 'object' || Array.isArray(over)) return over;
|
|
115
|
+
const out = { ...base };
|
|
116
|
+
for (const k of Object.keys(over)) out[k] = mergeDeep(base[k], over[k]);
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 会话种类(记忆分枝 D9-a,2026-09-18 用户定方向)。
|
|
121
|
+
* 背景:原 `isTopLevelSessionHeader` 把三类混成一个 false —— 子代理、委托、分叉**都不入库、不注入**。
|
|
122
|
+
* 但分叉会话是主人显式开的**并行工作线**(工程上"先放下别的模块,专注做完这一个再回来"),
|
|
123
|
+
* 它应当**有自己的记忆**(写入所属枝)且**能读祖先链**,而不是记忆真空。
|
|
124
|
+
* 区分:
|
|
125
|
+
* 'subagent' —— 子代理/委托:仍不入库、不注入(它们是实现细节,不是主人的工作线);
|
|
126
|
+
* 'fork' —— 分叉会话:归枝,入库到枝、注入血缘链(D9-a);
|
|
127
|
+
* 'top' —— 顶层会话:主干;
|
|
128
|
+
* 'unknown' —— 取不到 header:保守按旧行为(不入库)。
|
|
129
|
+
* 注意:`parentSession` 是平台的加速器,**不是判据** —— 即使 DSH 不再写这个字段,
|
|
130
|
+
* 也能靠主人显式标记建枝(见内部记忆树设计笔记 §7 与内部踩坑记录 B19)。 */
|
|
131
|
+
export function sessionKind(header) {
|
|
132
|
+
if (!header || typeof header !== 'object') return 'unknown';
|
|
133
|
+
if (header.origin === 'subagent' || header.delegationDepth) return 'subagent';
|
|
134
|
+
if (header.parentSession) return 'fork';
|
|
135
|
+
return 'top';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function isTopLevelSessionHeader(header) {
|
|
139
|
+
return sessionKind(header) === 'top';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 是否应当入库 / 被注入(D9-a):顶层与分叉会话都算,子代理不算。 */
|
|
143
|
+
export function isMemoryEligibleHeader(header) {
|
|
144
|
+
const k = sessionKind(header);
|
|
145
|
+
return k === 'top' || k === 'fork';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 中和文本里的 DSH 模板定界符(`{{…}}`),使注入提示面的任意文本都无法被当模板展开。
|
|
150
|
+
*
|
|
151
|
+
* 为什么必须做:宿主把 section 文本交给 `renderPrompt` 做变量插值,而调用点**没有 try/catch**
|
|
152
|
+
* (每次模型步渲染一次)。未注册的名字(如 `{{time}}`)直接抛异常 ⇒ 一条含 `{{…}}` 的
|
|
153
|
+
* 记忆就能让该会话**每一步**都失败,现象只是"器灵突然不说话了"。
|
|
154
|
+
*
|
|
155
|
+
* 为什么这样写:宿主模板只认**紧邻**的 `{{`,所以只需拆开相邻的两个 `{`(其后那个 `}` 无害)。
|
|
156
|
+
* 用 lookahead 逐位判定,而不是 `.replace(/\{\{/g, …)`:后者在 `{{{` 上会漏 ——
|
|
157
|
+
* 替换掉前两个 `{` 之后,残留字符又与原第三个 `{` 相邻。
|
|
158
|
+
* `{{time}}` → `{\u200b{time}}` `{{{` → `{\u200b{\u200b{` `{ {` → 原样
|
|
159
|
+
*
|
|
160
|
+
* 为什么用零宽空格:文本对模型与主人保持原样可读;且本函数**幂等**(中和过的文本再跑不变)。
|
|
161
|
+
*/
|
|
162
|
+
export function neutralizeMustache(text) {
|
|
163
|
+
if (typeof text !== 'string' || !text.includes('{{')) return text;
|
|
164
|
+
return text.replace(/\{(?=\{)/g, '{\u200b');
|
|
165
|
+
}
|
package/lib/host/zstd.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// dsh-ling host — zstd 拼接帧解码(纯 node;供 backfill/deepsummary/CLI 共用)
|
|
2
|
+
import { zstdDecompressSync } from 'node:zlib';
|
|
3
|
+
|
|
4
|
+
const MAGIC = [0x28, 0xb5, 0x2f, 0xfd];
|
|
5
|
+
|
|
6
|
+
/** 返回连续 zstd 帧的 [start,end) 偏移列表。 */
|
|
7
|
+
export function frameBounds(buf) {
|
|
8
|
+
const bounds = [];
|
|
9
|
+
let off = 0;
|
|
10
|
+
while (off + 4 <= buf.length) {
|
|
11
|
+
if (!(buf[off] === MAGIC[0] && buf[off + 1] === MAGIC[1] && buf[off + 2] === MAGIC[2] && buf[off + 3] === MAGIC[3])) break;
|
|
12
|
+
let p = off + 4;
|
|
13
|
+
if (p >= buf.length) break;
|
|
14
|
+
const fhd = buf[p++];
|
|
15
|
+
const dictFlag = fhd & 0x03;
|
|
16
|
+
const fcsFlag = (fhd >> 6) & 0x03;
|
|
17
|
+
const single = (fhd >> 5) & 0x01;
|
|
18
|
+
const checksum = (fhd >> 2) & 0x01;
|
|
19
|
+
if (!single) {
|
|
20
|
+
p += 1;
|
|
21
|
+
if (p > buf.length) break;
|
|
22
|
+
}
|
|
23
|
+
if (single) p += [1, 2, 4, 8][fcsFlag];
|
|
24
|
+
else if (fcsFlag !== 0) p += [0, 2, 4, 8][fcsFlag];
|
|
25
|
+
p += [0, 1, 2, 4][dictFlag];
|
|
26
|
+
if (p > buf.length) break;
|
|
27
|
+
let done = false;
|
|
28
|
+
let guard = 0;
|
|
29
|
+
while (!done && p + 3 <= buf.length && guard++ < 1e6) {
|
|
30
|
+
const b0 = buf[p];
|
|
31
|
+
const last = b0 & 1;
|
|
32
|
+
const type = (b0 >> 1) & 0x03;
|
|
33
|
+
const size = (b0 >> 3) | (buf[p + 1] << 5) | (buf[p + 2] << 13);
|
|
34
|
+
p += 3;
|
|
35
|
+
if (type === 3) break;
|
|
36
|
+
if (type === 1) p += 1;
|
|
37
|
+
else {
|
|
38
|
+
if (p + size > buf.length) break;
|
|
39
|
+
p += size;
|
|
40
|
+
}
|
|
41
|
+
if (last) done = true;
|
|
42
|
+
}
|
|
43
|
+
if (!done) break;
|
|
44
|
+
if (checksum) p += 4;
|
|
45
|
+
bounds.push([off, p]);
|
|
46
|
+
off = p;
|
|
47
|
+
}
|
|
48
|
+
return bounds;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 解码拼接帧并拼接为文本。 */
|
|
52
|
+
export function decodeAll(buf) {
|
|
53
|
+
const bounds = frameBounds(buf);
|
|
54
|
+
let out = '';
|
|
55
|
+
for (const [s, e] of bounds) {
|
|
56
|
+
try {
|
|
57
|
+
out += zstdDecompressSync(buf.subarray(s, e)).toString('utf8');
|
|
58
|
+
} catch {
|
|
59
|
+
// 单帧失败不影响其他帧
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|