dsh-lite-memory 0.3.0 → 0.15.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 +73 -17
- package/TRIGGERS.md +18 -14
- package/anchor.js +518 -0
- package/bench/bench.mjs +145 -0
- package/bench/evolution.mjs +123 -0
- package/client.js +1519 -33
- package/docs/BENCH.md +74 -0
- package/docs/RELEASE-0.11.0.md +27 -0
- package/docs/RELEASE-0.11.1.md +11 -0
- package/docs/RELEASE-0.12.0.md +15 -0
- package/docs/RELEASE-0.12.1.md +17 -0
- package/docs/RELEASE-0.12.2.md +17 -0
- package/docs/RELEASE-0.13.0.md +7 -0
- package/docs/RELEASE-0.14.0.md +7 -0
- package/docs/RELEASE-0.15.0.md +8 -0
- package/index.js +2080 -76
- package/learning.js +585 -0
- package/package.json +10 -2
- package/state.js +1476 -0
- package/store.js +2967 -244
- package/test/anchor.test.mjs +436 -0
- package/test/client.test.mjs +114 -0
- package/test/contract.test.mjs +880 -0
- package/test/control.test.mjs +278 -0
- package/test/crossing.test.mjs +372 -0
- package/test/evolution-read.test.mjs +448 -0
- package/test/fixtures/legacy-v1/archive/mem_444444444444.json +18 -0
- package/test/fixtures/legacy-v1/entries/mem_111111111111.md +2 -0
- package/test/fixtures/legacy-v1/entries/mem_222222222222.md +1 -0
- package/test/fixtures/legacy-v1/entries/mem_333333333333.md +1 -0
- package/test/fixtures/legacy-v1/index.json +63 -0
- package/test/learning.test.mjs +772 -0
- package/test/lexical.test.mjs +33 -0
- package/test/recall.test.mjs +449 -0
- package/test/scenario.test.mjs +184 -0
- package/test/state.test.mjs +485 -0
- package/test/store.test.mjs +816 -115
- package/test/view.test.mjs +257 -0
- package/view.js +616 -0
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
|
|
7
|
+
process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-'));
|
|
8
|
+
const { apply } = await import('../index.js');
|
|
9
|
+
const store = await import('../store.js');
|
|
10
|
+
const state = await import('../state.js');
|
|
11
|
+
|
|
12
|
+
const ORIGIN = 'http://127.0.0.1:60402';
|
|
13
|
+
const defs = new Map();
|
|
14
|
+
let route = null;
|
|
15
|
+
|
|
16
|
+
const webServer = { getOrigin: () => ORIGIN, register: (entry) => { route = entry; return () => {}; } };
|
|
17
|
+
const effect = (fn) => { const dispose = fn(); return typeof dispose === 'function' ? dispose : () => {}; };
|
|
18
|
+
let currentAgent = null;
|
|
19
|
+
let llmImpl = null;
|
|
20
|
+
const sessionCwds = new Map();
|
|
21
|
+
const subscriptions = new Map();
|
|
22
|
+
const promptContexts = [];
|
|
23
|
+
const ctx = {
|
|
24
|
+
tools: { register: (def) => { defs.set(def.name, def); return () => {}; } },
|
|
25
|
+
effect,
|
|
26
|
+
inject: (deps, callback) => { if (deps.includes('webServer')) callback({ webServer, effect }); return () => {}; },
|
|
27
|
+
get: (name) => {
|
|
28
|
+
if (name === 'systemPrompt') return { context: (entry) => { promptContexts.push(entry); return () => {}; } };
|
|
29
|
+
if (name === 'agents') return { currentInitiator: () => currentAgent };
|
|
30
|
+
if (name === 'sessions') return { get: (id) => (sessionCwds.has(id) ? { header: { cwd: sessionCwds.get(id) } } : undefined) };
|
|
31
|
+
if (name === 'llm') return llmImpl || undefined;
|
|
32
|
+
return undefined;
|
|
33
|
+
},
|
|
34
|
+
on: (event, handler) => { subscriptions.set(event, handler); return () => {}; },
|
|
35
|
+
};
|
|
36
|
+
apply(ctx);
|
|
37
|
+
|
|
38
|
+
const cwd = path.join(os.tmpdir(), 'lite-memory-contract-project');
|
|
39
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
40
|
+
|
|
41
|
+
const exec = (name, args) => defs.get(name).execute(args, { agent: { cwd, session: { id: 'session-contract', header: { cwd } } } });
|
|
42
|
+
const render = (name, args, value) => defs.get(name).output.render(args, value)[0].text;
|
|
43
|
+
|
|
44
|
+
function call(pathname, payload, options = {}) {
|
|
45
|
+
const req = new EventEmitter();
|
|
46
|
+
req.method = options.method ?? 'POST';
|
|
47
|
+
req.url = pathname;
|
|
48
|
+
req.headers = { origin: options.origin ?? ORIGIN };
|
|
49
|
+
req.destroy = () => {};
|
|
50
|
+
const res = {
|
|
51
|
+
status: 0,
|
|
52
|
+
text: '',
|
|
53
|
+
writeHead(status) { this.status = status; },
|
|
54
|
+
end(body) { this.text = body; this.finish(); },
|
|
55
|
+
};
|
|
56
|
+
const done = new Promise((resolve) => { res.finish = () => resolve(res); });
|
|
57
|
+
route.handler(req, res);
|
|
58
|
+
if (req.method === 'POST') {
|
|
59
|
+
req.emit('data', Buffer.from(JSON.stringify(payload)));
|
|
60
|
+
req.emit('end');
|
|
61
|
+
}
|
|
62
|
+
return done.then((value) => ({ status: value.status, body: JSON.parse(value.text || '{}') }));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const json = (body) => JSON.parse(body);
|
|
66
|
+
|
|
67
|
+
// ── tool contract ────────────────────────────────────────────────────────────
|
|
68
|
+
const names = ['memory_save', 'memory_suggest', 'memory_search', 'memory_read', 'memory_forget'];
|
|
69
|
+
assert.deepEqual([...defs.keys()].sort(), [...names].sort());
|
|
70
|
+
for (const name of names) {
|
|
71
|
+
const def = defs.get(name);
|
|
72
|
+
assert.equal(def.parameters.type, 'object');
|
|
73
|
+
assert.equal(def.parameters.additionalProperties, false);
|
|
74
|
+
assert.equal(typeof def.output.render, 'function');
|
|
75
|
+
assert.equal(typeof def.output.schema, 'object');
|
|
76
|
+
assert.ok(def.parameters.required.length >= 1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const saveParams = defs.get('memory_save').parameters.properties;
|
|
80
|
+
/** 来源只能由服务端判定:参数里不存在可伪造成「用户明确要求」的字段。 */
|
|
81
|
+
for (const key of ['source', 'origin', 'confirmed', 'force', 'autoRecall']) {
|
|
82
|
+
assert.equal(key in saveParams, false, `memory_save 不该接受 ${key}`);
|
|
83
|
+
}
|
|
84
|
+
assert.equal(saveParams.mode.enum.includes('replace_current'), true);
|
|
85
|
+
assert.equal(JSON.stringify(saveParams.applicability.enum), JSON.stringify(['all_in_scope', 'conditional', 'unresolved']));
|
|
86
|
+
|
|
87
|
+
assert.deepEqual(saveParams.resolution.enum, ['distinct']);
|
|
88
|
+
/** 一次检索最多回 4 条:列表是给下一步取正文用的,不是索引。 */
|
|
89
|
+
assert.equal(defs.get('memory_search').parameters.properties.limit.maximum, 4);
|
|
90
|
+
assert.equal(saveParams.id.type, 'string');
|
|
91
|
+
assert.deepEqual(saveParams.scope.enum, ['auto', 'project', 'global']);
|
|
92
|
+
assert.equal(saveParams.memoryClass.type, 'string');
|
|
93
|
+
assert.deepEqual(saveParams.memoryClass.enum, ['personal_preference', 'project_fact', 'environment_fact', 'portable_procedure']);
|
|
94
|
+
assert.deepEqual(saveParams.durability.enum, ['durable', 'task']);
|
|
95
|
+
assert.deepEqual(saveParams.sameTopicAs.required, ['scope', 'id', 'revision']);
|
|
96
|
+
assert.equal(defs.get('memory_suggest').parameters.properties.scope, undefined);
|
|
97
|
+
assert.equal(defs.get('memory_suggest').parameters.properties.resolution, undefined);
|
|
98
|
+
|
|
99
|
+
// ── save → render, then update ───────────────────────────────────────────────
|
|
100
|
+
const saved = await exec('memory_save', { title: '发布顺序', summary: '发版前要按顺序做的事', body: '第一步' });
|
|
101
|
+
assert.equal(saved.needsResolution, false);
|
|
102
|
+
assert.equal(saved.merged, false);
|
|
103
|
+
assert.equal(saved.changed, true);
|
|
104
|
+
assert.equal(saved.scope, 'project');
|
|
105
|
+
// 「以后都用」这种措辞不带 scope 时落在项目层,不会自动升级成全局偏好
|
|
106
|
+
const phrased = await exec('memory_save', { title: '以后都用这个一次性脚本', summary: '这个脚本只跑一次', body: 'bash run.sh' });
|
|
107
|
+
assert.equal(phrased.scope, 'project');
|
|
108
|
+
assert.equal(store.loadIndex(store.globalScope()).entries.some((item) => item.title === '以后都用这个一次性脚本'), false);
|
|
109
|
+
|
|
110
|
+
assert.equal(typeof saved.id, 'string');
|
|
111
|
+
assert.match(render('memory_save', {}, saved), /^memory · saved \[[^\]]+\] "发布顺序" · mem_[a-z0-9]+$/);
|
|
112
|
+
|
|
113
|
+
const merged = await exec('memory_save', { title: '发布顺序', summary: '发版前要按顺序做的事', body: '第一步\n第二步' });
|
|
114
|
+
assert.equal(merged.merged, true);
|
|
115
|
+
assert.equal(merged.id, saved.id);
|
|
116
|
+
assert.equal(merged.revision, 2);
|
|
117
|
+
assert.match(render('memory_save', {}, merged), /^memory · merged \[[^\]]+\] "发布顺序" · mem_[a-z0-9]+$/);
|
|
118
|
+
|
|
119
|
+
const patch = await exec('memory_save', { id: saved.id, title: '发布顺序', summary: '发版前要按顺序做的事', when: '发版当天' });
|
|
120
|
+
assert.equal(patch.when, '发版当天');
|
|
121
|
+
assert.match(render('memory_save', {}, patch), /(发版当天)/);
|
|
122
|
+
|
|
123
|
+
/** 同样的字段再存一次不算修改:不改版次,也不该被读成「存过一次」。 */
|
|
124
|
+
const again = await exec('memory_save', { title: '发布顺序', summary: '发版前要按顺序做的事', when: '发版当天' });
|
|
125
|
+
assert.equal(again.changed, false);
|
|
126
|
+
assert.equal(again.id, saved.id);
|
|
127
|
+
assert.equal(again.revision, 3);
|
|
128
|
+
assert.match(render('memory_save', {}, again), /^memory · unchanged \[[^\]]+\] "发布顺序" · mem_[a-z0-9]+$/);
|
|
129
|
+
|
|
130
|
+
// ── similar title asks before overwriting ────────────────────────────────────
|
|
131
|
+
const probe = await exec('memory_save', { title: '发布顺序是啥', summary: '发版前要按顺序做的事', body: '另一条' });
|
|
132
|
+
assert.equal(probe.needsResolution, true);
|
|
133
|
+
assert.equal(probe.id, '');
|
|
134
|
+
assert.equal(probe.revision, 0);
|
|
135
|
+
assert.ok(probe.similarIds.length > 0);
|
|
136
|
+
const probeText = render('memory_save', {}, probe);
|
|
137
|
+
assert.match(probeText.split('\n')[0], /^memory · 需要确认:「发布顺序是啥」与已有条目相近$/);
|
|
138
|
+
assert.match(probeText, /要更新它,用 id 重存/);
|
|
139
|
+
assert.match(probeText, new RegExp(saved.id));
|
|
140
|
+
const distinct = await exec('memory_save', { title: '发布顺序是啥', summary: '发版前要按顺序做的事', body: '另一条', resolution: 'distinct' });
|
|
141
|
+
assert.equal(distinct.needsResolution, false);
|
|
142
|
+
assert.equal(distinct.merged, false);
|
|
143
|
+
assert.notEqual(distinct.id, saved.id);
|
|
144
|
+
|
|
145
|
+
// ── search / read / forget ───────────────────────────────────────────────────
|
|
146
|
+
const hits = await exec('memory_search', { query: '发布流程' });
|
|
147
|
+
assert.ok(hits.count >= 1);
|
|
148
|
+
assert.ok(hits.chars > 0);
|
|
149
|
+
const hitText = render('memory_search', {}, hits);
|
|
150
|
+
assert.match(hitText, new RegExp(`^memory · 命中 ${hits.count} 条 · 引用 ${hits.chars} 字\\n- \\[[^\\]]+\\] mem_`));
|
|
151
|
+
assert.doesNotMatch(hitText, /第 .* 版/);
|
|
152
|
+
/** 「发版当天」这类自由文本条件没有可信上下文,不能当成适用条目塞给模型。 */
|
|
153
|
+
const miss = await exec('memory_search', { query: '完全没有出现过的主题' });
|
|
154
|
+
assert.equal(miss.count, 0);
|
|
155
|
+
assert.equal(render('memory_search', {}, miss), 'memory · 没有命中');
|
|
156
|
+
|
|
157
|
+
const read = await exec('memory_read', { id: saved.id });
|
|
158
|
+
assert.equal(read.status, 'needs_context');
|
|
159
|
+
assert.equal(read.body, '');
|
|
160
|
+
const plain = await exec('memory_read', { id: distinct.id });
|
|
161
|
+
assert.equal(plain.body, '另一条');
|
|
162
|
+
assert.equal(plain.revision, 1);
|
|
163
|
+
assert.match(render('memory_read', {}, plain), new RegExp(`^memory · \\[[^\\]]+\\] 「发布顺序是啥」 · ${distinct.id}`));
|
|
164
|
+
|
|
165
|
+
// 没点明归档还是彻底忘掉时不动盘,先返回待确认
|
|
166
|
+
const undecided = await exec('memory_forget', { id: distinct.id });
|
|
167
|
+
assert.equal(undecided.mode, 'needs_resolution');
|
|
168
|
+
assert.match(render('memory_forget', {}, undecided), new RegExp(`^memory · 先确认是归档还是彻底忘掉 · ${distinct.id}$`));
|
|
169
|
+
assert.ok(store.locate(store.scopeOf(cwd), distinct.id));
|
|
170
|
+
const archived = await exec('memory_forget', { id: distinct.id, mode: 'archive' });
|
|
171
|
+
assert.equal(archived.id, distinct.id);
|
|
172
|
+
assert.match(render('memory_forget', {}, archived), new RegExp(`^memory · archived \\[[^\\]]+\\] "发布顺序是啥" · ${distinct.id},可找回$`));
|
|
173
|
+
assert.equal(await exec('memory_save', { title: '发布顺序是啥', summary: '发版前要按顺序做的事', body: '第三条' }).then((v) => v.merged), false);
|
|
174
|
+
|
|
175
|
+
// ── suggestion → keep → replay → reject ──────────────────────────────────────
|
|
176
|
+
const suggested = await exec('memory_suggest', { title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint', body: 'node ui-copy-lint.mjs', when: '发布前' });
|
|
177
|
+
assert.equal(suggested.asked, true);
|
|
178
|
+
assert.match(suggested.id, /^sug_[a-z0-9]+$/);
|
|
179
|
+
assert.match(render('memory_suggest', {}, suggested), /^memory · suggested "发布前跑文案检查" · sug_[a-z0-9]+$/);
|
|
180
|
+
|
|
181
|
+
const duplicate = await exec('memory_suggest', { title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint' });
|
|
182
|
+
assert.equal(duplicate.asked, false);
|
|
183
|
+
assert.match(render('memory_suggest', {}, duplicate), /^memory · skipped\n48 小时内已经建议过一条$/);
|
|
184
|
+
|
|
185
|
+
const kept = await call('/lite-memory/keep', { op: 'keep', candidateId: suggested.id, operationId: 'op_contract', cwd });
|
|
186
|
+
assert.equal(kept.status, 200);
|
|
187
|
+
assert.equal(kept.body.ok, true);
|
|
188
|
+
assert.equal(kept.body.mode, 'created');
|
|
189
|
+
const stored = store.search(store.scopeOf(cwd), '发布前跑文案检查')[0];
|
|
190
|
+
assert.equal(stored.when, '发布前');
|
|
191
|
+
assert.equal(store.readEntry(store.scopeOf(cwd), stored.id).body, 'node ui-copy-lint.mjs');
|
|
192
|
+
|
|
193
|
+
const replay = await call('/lite-memory/keep', { op: 'keep', candidateId: suggested.id, operationId: 'op_contract', cwd });
|
|
194
|
+
assert.equal(replay.status, 200);
|
|
195
|
+
assert.equal(replay.body.replay, true);
|
|
196
|
+
assert.equal(replay.body.id, kept.body.id);
|
|
197
|
+
const reused = await call('/lite-memory/keep', { op: 'keep', candidateId: suggested.id, operationId: 'op_contract_other', cwd });
|
|
198
|
+
assert.equal(reused.status, 404);
|
|
199
|
+
assert.equal(reused.body.ok, false);
|
|
200
|
+
|
|
201
|
+
const scope = store.scopeOf(cwd);
|
|
202
|
+
const reset = store.loadIndex(scope);
|
|
203
|
+
reset.lastSuggestedAt = '';
|
|
204
|
+
store.saveIndex(scope, reset);
|
|
205
|
+
const another = await exec('memory_suggest', { title: '另一条待确认建议', summary: '用来测试拒绝的候选' });
|
|
206
|
+
assert.equal(another.asked, true);
|
|
207
|
+
const rejected = await call('/lite-memory/reject', { op: 'reject', candidateId: another.id, cwd });
|
|
208
|
+
assert.equal(rejected.status, 200);
|
|
209
|
+
assert.equal(rejected.body.ok, true);
|
|
210
|
+
|
|
211
|
+
// ── undo by id; old title-based payloads are refused ─────────────────────────
|
|
212
|
+
const undoRevision = store.readEntry(store.scopeOf(cwd), kept.body.id).revision;
|
|
213
|
+
assert.equal((await call('/lite-memory/undo', { op: 'undo', id: kept.body.id, cwd })).status, 400);
|
|
214
|
+
const undone = await call('/lite-memory/undo', { op: 'undo', id: kept.body.id, revision: undoRevision, cwd });
|
|
215
|
+
assert.equal(undone.status, 200);
|
|
216
|
+
assert.equal(undone.body.mode, 'archived');
|
|
217
|
+
assert.equal(store.loadIndex(store.scopeOf(cwd)).entries.some((entry) => entry.id === kept.body.id), false);
|
|
218
|
+
const byTitle = await call('/lite-memory/undo', { op: 'undo', title: '发布顺序', cwd });
|
|
219
|
+
assert.equal(byTitle.status, 400);
|
|
220
|
+
const missing = await call('/lite-memory/undo', { op: 'undo', id: 'mem_000000000000', cwd });
|
|
221
|
+
assert.equal(missing.status, 404);
|
|
222
|
+
|
|
223
|
+
// ── route guards ─────────────────────────────────────────────────────────────
|
|
224
|
+
assert.equal((await call('/lite-memory/keep', { candidateId: 'sug_000000', cwd }, { origin: 'http://evil.example' })).status, 403);
|
|
225
|
+
assert.equal((await call('/lite-memory/keep', { candidateId: 'sug_000000' })).status, 400);
|
|
226
|
+
assert.equal((await call('/lite-memory/keep', { cwd })).status, 400);
|
|
227
|
+
assert.equal((await call('/lite-memory/undo', { cwd })).status, 400);
|
|
228
|
+
assert.equal((await call('/lite-memory/unknown', { cwd })).status, 404);
|
|
229
|
+
assert.equal((await call('/lite-memory/keep', {}, { method: 'GET' })).status, 405);
|
|
230
|
+
|
|
231
|
+
// ── global layer ─────────────────────────────────────────────────────────────
|
|
232
|
+
const global = await exec('memory_save', { title: '回复用中文', summary: '用户要求回复用中文', scope: 'global' });
|
|
233
|
+
assert.equal(global.scope, 'global');
|
|
234
|
+
assert.match(render('memory_save', {}, global), /^memory · saved \[global\] "回复用中文" · mem_[a-z0-9]+$/);
|
|
235
|
+
const globalSearch = await exec('memory_search', { query: '回复用中文' });
|
|
236
|
+
assert.equal(globalSearch.count, 1);
|
|
237
|
+
assert.match(render('memory_search', {}, globalSearch), /\[global\] mem_/);
|
|
238
|
+
assert.equal(typeof globalThis.__liteMemoryStats, 'function');
|
|
239
|
+
const stats = globalThis.__liteMemoryStats(cwd);
|
|
240
|
+
assert.ok(stats.count >= 1);
|
|
241
|
+
|
|
242
|
+
// ── 引用条:会话读过哪几条,点开能拿到正文 ───────────────────────────────────
|
|
243
|
+
const cited = await exec('memory_save', { title: '引用测试', summary: '底部引用条要能点开正文', body: '引用正文第一行' });
|
|
244
|
+
await exec('memory_read', { id: cited.id });
|
|
245
|
+
const cites = await call('/lite-memory/citations', { sessionId: 'session-contract' });
|
|
246
|
+
assert.equal(cites.status, 200);
|
|
247
|
+
assert.ok(cites.body.items.some((item) => item.id === cited.id && item.title === '引用测试'));
|
|
248
|
+
assert.equal(cites.body.items.some((item) => 'path' in item), false);
|
|
249
|
+
const opened = await call('/lite-memory/body', { sessionId: 'session-contract', id: cited.id });
|
|
250
|
+
assert.equal(opened.status, 200);
|
|
251
|
+
assert.match(opened.body.body, /引用正文第一行/);
|
|
252
|
+
assert.equal(opened.body.scopeLabel, path.basename(cwd));
|
|
253
|
+
const gone = await call('/lite-memory/body', { sessionId: 'session-contract', id: 'mem_999999999999' });
|
|
254
|
+
assert.equal(gone.status, 200);
|
|
255
|
+
assert.equal(gone.body.status, 'gone');
|
|
256
|
+
assert.equal('title' in gone.body, false);
|
|
257
|
+
/** 别的会话、别的工作目录都不能拿 id 探正文;本会话引用过或本目录这一层才行。 */
|
|
258
|
+
const foreignSession = await call('/lite-memory/body', { sessionId: 'session-contract-2', id: cited.id });
|
|
259
|
+
assert.equal(foreignSession.status, 200);
|
|
260
|
+
assert.equal(foreignSession.body.status, 'gone');
|
|
261
|
+
assert.equal('body' in foreignSession.body, false);
|
|
262
|
+
const viaCwd = await call('/lite-memory/body', { cwd, id: cited.id });
|
|
263
|
+
assert.equal(viaCwd.status, 200);
|
|
264
|
+
assert.match(viaCwd.body.body, /引用正文第一行/);
|
|
265
|
+
const foreignCwd = await call('/lite-memory/body', { cwd: path.join(os.tmpdir(), 'lite-memory-contract-foreign'), id: cited.id });
|
|
266
|
+
assert.equal(foreignCwd.body.status, 'gone');
|
|
267
|
+
assert.equal((await call('/lite-memory/body', { id: cited.id })).status, 400);
|
|
268
|
+
assert.equal((await call('/lite-memory/citations', {})).status, 400);
|
|
269
|
+
assert.equal((await call('/lite-memory/citations', {}, { origin: 'http://evil.example' })).status, 403);
|
|
270
|
+
assert.notEqual((await call('/lite-memory/citations', { sessionId: 'session-contract' }, { origin: 'dsh-app://app' })).status, 403);
|
|
271
|
+
assert.notEqual((await call('/lite-memory/keep', { candidateId: 'sug_000000', cwd }, { origin: 'dsh-app://app' })).status, 403);
|
|
272
|
+
assert.equal((await call('/lite-memory/citations', {}, { method: 'GET' })).status, 405);
|
|
273
|
+
|
|
274
|
+
// ── 项目实验开关:每个项目各自一份,默认关闭,切换不碰别的项目 ─────────────────
|
|
275
|
+
const evoCwd = path.join(os.tmpdir(), 'lite-memory-contract-evolution');
|
|
276
|
+
const evoOther = path.join(os.tmpdir(), 'lite-memory-contract-evolution-other');
|
|
277
|
+
fs.mkdirSync(evoCwd, { recursive: true });
|
|
278
|
+
fs.mkdirSync(evoOther, { recursive: true });
|
|
279
|
+
sessionCwds.set('session-evo', evoCwd);
|
|
280
|
+
sessionCwds.set('session-evo-other', evoOther);
|
|
281
|
+
const evoOff = await call('/lite-memory/evolution', { sessionId: 'session-evo' });
|
|
282
|
+
assert.equal(evoOff.status, 200);
|
|
283
|
+
assert.equal(evoOff.body.ok, true);
|
|
284
|
+
assert.equal(evoOff.body.enabled, false);
|
|
285
|
+
assert.equal(evoOff.body.compatible, true);
|
|
286
|
+
assert.equal(evoOff.body.project, path.basename(evoCwd));
|
|
287
|
+
const evoOn = await call('/lite-memory/evolution', { sessionId: 'session-evo', enabled: true, operationId: 'op_evo_on' });
|
|
288
|
+
assert.equal(evoOn.status, 200);
|
|
289
|
+
assert.equal(evoOn.body.enabled, true);
|
|
290
|
+
assert.equal(evoOn.body.changed, true);
|
|
291
|
+
assert.notEqual(evoOn.body.epoch, evoOff.body.epoch);
|
|
292
|
+
const evoStatus = await call('/lite-memory/evolution', { sessionId: 'session-evo' });
|
|
293
|
+
assert.equal(evoStatus.body.enabled, true);
|
|
294
|
+
const evoSnapshot = await call('/lite-memory/evolution', { sessionId: 'session-evo', learningAction: 'snapshot' });
|
|
295
|
+
assert.equal(evoSnapshot.status, 200);
|
|
296
|
+
assert.equal(Array.isArray(evoSnapshot.body.rules), true);
|
|
297
|
+
assert.equal(typeof evoSnapshot.body.counts.active, 'number');
|
|
298
|
+
assert.equal(evoSnapshot.body.payload.bytes, null);
|
|
299
|
+
assert.equal(evoSnapshot.body.economy.inputTokens, null);
|
|
300
|
+
const evoOtherStatus = await call('/lite-memory/evolution', { sessionId: 'session-evo-other' });
|
|
301
|
+
assert.equal(evoOtherStatus.body.enabled, false);
|
|
302
|
+
const evoStateFile = path.join(store.scopeOf(evoCwd).dir, 'state.json');
|
|
303
|
+
const evoDisk = JSON.parse(fs.readFileSync(evoStateFile, 'utf8'));
|
|
304
|
+
assert.equal(evoDisk.format, 'dsh-lite-memory/state@4');
|
|
305
|
+
assert.equal(evoDisk.payload.experimental.projectEvolution.enabled, true);
|
|
306
|
+
const evoOffAgain = await call('/lite-memory/evolution', { sessionId: 'session-evo', enabled: false });
|
|
307
|
+
assert.equal(evoOffAgain.body.enabled, false);
|
|
308
|
+
assert.equal(evoOffAgain.body.compatible, true);
|
|
309
|
+
assert.equal((await call('/lite-memory/evolution', {})).status, 400);
|
|
310
|
+
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-evo' }, { origin: 'http://evil.example' })).status, 403);
|
|
311
|
+
// 身份拿不准时拒绝写,也不拿进程 cwd 顶替
|
|
312
|
+
const evoUnknown = path.join(os.tmpdir(), 'lite-memory-contract-evolution-unknown');
|
|
313
|
+
fs.mkdirSync(evoUnknown, { recursive: true });
|
|
314
|
+
const savedPath = process.env.PATH;
|
|
315
|
+
process.env.PATH = '';
|
|
316
|
+
sessionCwds.set('session-evo-unknown', evoUnknown);
|
|
317
|
+
const evoUnconfirmed = await call('/lite-memory/evolution', { sessionId: 'session-evo-unknown', enabled: true });
|
|
318
|
+
process.env.PATH = savedPath;
|
|
319
|
+
assert.equal(evoUnconfirmed.status, 409);
|
|
320
|
+
assert.equal(evoUnconfirmed.body.confirmed, false);
|
|
321
|
+
assert.equal(fs.existsSync(path.join(store.scopeOf(evoUnknown).dir, 'state.json')), false);
|
|
322
|
+
|
|
323
|
+
// ── 自动召回:只在这句话跟已有记忆有关时出现,且不改命中计数 ─────────────────
|
|
324
|
+
const hinted = await exec('memory_save', { title: 'Chrome 弹钥匙串窗口要带 mock keychain', summary: '临时 profile 起 Chrome 会弹钥匙串窗口' });
|
|
325
|
+
// 只有一句 when、没有结构化条件的条目属于未解决,不自动注入
|
|
326
|
+
await exec('memory_save', { title: '发布窗口在周四晚上', summary: '只在周四晚上发版', when: '周四晚上' });
|
|
327
|
+
const recallAgent = { id: 'session-contract', cwd, session: { id: 'session-recall', header: { cwd } } };
|
|
328
|
+
currentAgent = recallAgent;
|
|
329
|
+
const preStep = subscriptions.get('agent/pre-step');
|
|
330
|
+
assert.equal(typeof preStep, 'function');
|
|
331
|
+
const pass = (text, id) => async () => ({
|
|
332
|
+
kind: 'enter',
|
|
333
|
+
messages: [{ role: 'user', id, source: { kind: 'user' }, content: [{ type: 'text', text }] }],
|
|
334
|
+
});
|
|
335
|
+
const recall = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
|
|
336
|
+
assert.ok(recall, 'recall context registered');
|
|
337
|
+
assert.equal(recall.order, 60);
|
|
338
|
+
assert.equal(recall.interpolate, false);
|
|
339
|
+
assert.equal(recall.required, false);
|
|
340
|
+
|
|
341
|
+
const sig1 = new AbortController().signal;
|
|
342
|
+
const hitsBefore = store.loadIndex(store.scopeOf(cwd)).entries.find((item) => item.id === hinted.id).hits;
|
|
343
|
+
await preStep({ agent: recallAgent, signal: sig1, turn: '2' }, pass('又弹钥匙串窗口了,怎么弄', 'h1'));
|
|
344
|
+
const hintText = recall.text({ agent: recallAgent, signal: sig1 });
|
|
345
|
+
assert.match(hintText, new RegExp(hinted.id));
|
|
346
|
+
assert.match(hintText, /需要细节用 memory_read/);
|
|
347
|
+
const hitsAfter = store.loadIndex(store.scopeOf(cwd)).entries.find((item) => item.id === hinted.id).hits;
|
|
348
|
+
assert.equal(hitsAfter, hitsBefore);
|
|
349
|
+
|
|
350
|
+
const sig2 = new AbortController().signal;
|
|
351
|
+
await preStep({ agent: recallAgent, signal: sig2, turn: '3' }, pass('周四晚上发版要注意什么', 'h2'));
|
|
352
|
+
assert.equal(recall.text({ agent: recallAgent, signal: sig2 }).includes('发布窗口在周四晚上'), false);
|
|
353
|
+
|
|
354
|
+
const sig3 = new AbortController().signal;
|
|
355
|
+
await preStep({ agent: recallAgent, signal: sig3, turn: '4' }, pass('把这个按钮的圆角改小一点', 'h3'));
|
|
356
|
+
assert.equal(recall.text({ agent: recallAgent, signal: sig3 }), '');
|
|
357
|
+
assert.equal(subscriptions.has('agent/disposed'), true);
|
|
358
|
+
|
|
359
|
+
// ── 项目实验:宿主事件顺序下由最终人类消息决定读取 ───────────────────────────
|
|
360
|
+
const evoReadCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-evo-read-'));
|
|
361
|
+
sessionCwds.set('session-evo-read', evoReadCwd);
|
|
362
|
+
const evoReadOn = await call('/lite-memory/evolution', { sessionId: 'session-evo-read', enabled: true });
|
|
363
|
+
assert.equal(evoReadOn.status, 200);
|
|
364
|
+
assert.equal(evoReadOn.body.enabled, true);
|
|
365
|
+
const evoReadScope = store.scopeOf(evoReadCwd);
|
|
366
|
+
const evoAgentSeed = { id: 'session-evo-read', cwd: evoReadCwd, session: { id: 'session-evo-read', header: { cwd: evoReadCwd } } };
|
|
367
|
+
const evoRule = await defs.get('memory_save').execute(
|
|
368
|
+
{ title: '口播开头', summary: '口播开头三句内给结论', body: '开场三句内给结论。' },
|
|
369
|
+
{ agent: evoAgentSeed },
|
|
370
|
+
);
|
|
371
|
+
state.transact(evoReadScope, {}, (draft) => {
|
|
372
|
+
const row = draft.entries.find((entry) => entry.id === evoRule.id);
|
|
373
|
+
row.origin = 'project_evolution';
|
|
374
|
+
row.current.conditions = [{ key: 'seriesId', value: '口播' }];
|
|
375
|
+
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'active', source: 'evolution' } };
|
|
376
|
+
return { changed: true };
|
|
377
|
+
});
|
|
378
|
+
await store.setTaskDefault(evoReadScope, { seriesId: '口播' });
|
|
379
|
+
|
|
380
|
+
const evoAgent = { id: 'session-evo-read', cwd: evoReadCwd, session: { id: 'session-evo-read', header: { cwd: evoReadCwd } } };
|
|
381
|
+
currentAgent = evoAgent;
|
|
382
|
+
const evoSig = new AbortController().signal;
|
|
383
|
+
const evoEnter = async () => ({
|
|
384
|
+
kind: 'enter',
|
|
385
|
+
messages: [
|
|
386
|
+
// 宿主可能先给出被拒/草稿消息,只有最终收下的 user 消息算新的人类输入
|
|
387
|
+
{ role: 'user', id: 'draft-1', source: { kind: 'synthetic' }, content: [{ type: 'text', text: '改做 README 文案' }] },
|
|
388
|
+
{ role: 'user', id: 'final-1', source: { kind: 'user' }, content: [{ type: 'text', text: '这期口播怎么开头' }] },
|
|
389
|
+
],
|
|
390
|
+
});
|
|
391
|
+
await preStep({ agent: evoAgent, signal: evoSig, turn: '21' }, evoEnter);
|
|
392
|
+
const evoRecall = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
|
|
393
|
+
assert.match(evoRecall.text({ agent: evoAgent, signal: evoSig }), /开场三句内给结论/);
|
|
394
|
+
assert.equal(evoRecall.text({ agent: evoAgent, signal: evoSig }).includes('README'), false);
|
|
395
|
+
|
|
396
|
+
// 工具续步没有新人类消息:沿用本批快照,交给 refreshContext 的是同一份内容
|
|
397
|
+
await preStep({ agent: evoAgent, signal: evoSig, turn: '21' }, async () => ({ kind: 'enter', messages: [] }));
|
|
398
|
+
assert.match(evoRecall.text({ agent: evoAgent, signal: evoSig }), /开场三句内给结论/);
|
|
399
|
+
|
|
400
|
+
// 事件确实触发:本回合停止后快照立即失效,下一批必须重新算
|
|
401
|
+
const stopping = subscriptions.get('agent/turn-stopping');
|
|
402
|
+
assert.equal(typeof stopping, 'function');
|
|
403
|
+
stopping({ turn: '21', signal: evoSig });
|
|
404
|
+
assert.equal(evoRecall.text({ agent: evoAgent, signal: evoSig }), '');
|
|
405
|
+
|
|
406
|
+
// 管理面:读当前项目的成果和上次载荷,删除按看到的版本
|
|
407
|
+
const evoManage = await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' });
|
|
408
|
+
assert.equal(evoManage.status, 200);
|
|
409
|
+
assert.equal(evoManage.body.rules.length, 1);
|
|
410
|
+
assert.equal(evoManage.body.rules[0].id, evoRule.id);
|
|
411
|
+
assert.ok(evoManage.body.payload.bytes > 0);
|
|
412
|
+
const evoDeleteStale = await call('/lite-memory/evolution', {
|
|
413
|
+
sessionId: 'session-evo-read', learningAction: 'delete', id: evoRule.id, expectedRevision: evoManage.body.rules[0].revision - 1,
|
|
414
|
+
});
|
|
415
|
+
assert.equal(evoDeleteStale.status, 409);
|
|
416
|
+
const evoDelete = await call('/lite-memory/evolution', {
|
|
417
|
+
sessionId: 'session-evo-read', learningAction: 'delete', id: evoRule.id, expectedRevision: evoManage.body.rules[0].revision,
|
|
418
|
+
});
|
|
419
|
+
assert.equal(evoDelete.status, 200);
|
|
420
|
+
assert.equal(evoDelete.body.ok, true);
|
|
421
|
+
|
|
422
|
+
// 管理面成功路径:候选可修改、采纳、暂停、恢复、放弃,每次返回后能继续操作
|
|
423
|
+
const manageSeed = await defs.get('memory_save').execute(
|
|
424
|
+
{ title: '口播收尾', summary: '口播收尾留一句行动', body: '结尾给一句具体行动。' },
|
|
425
|
+
{ agent: evoAgent },
|
|
426
|
+
);
|
|
427
|
+
state.transact(evoReadScope, {}, (draft) => {
|
|
428
|
+
const row = draft.entries.find((entry) => entry.id === manageSeed.id);
|
|
429
|
+
row.origin = 'project_evolution';
|
|
430
|
+
row.current.conditions = [{ key: 'seriesId', value: '口播' }];
|
|
431
|
+
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'pending', source: 'evolution' } };
|
|
432
|
+
return { changed: true };
|
|
433
|
+
});
|
|
434
|
+
const manageSnap = await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' });
|
|
435
|
+
const manageRule = manageSnap.body.rules.find((rule) => rule.id === manageSeed.id);
|
|
436
|
+
assert.ok(manageRule);
|
|
437
|
+
assert.equal(manageRule.state, 'pending');
|
|
438
|
+
const manageEdit = await call('/lite-memory/evolution', {
|
|
439
|
+
sessionId: 'session-evo-read', learningAction: 'edit', id: manageSeed.id,
|
|
440
|
+
expectedRevision: manageRule.revision, body: '结尾给一句具体动作。',
|
|
441
|
+
});
|
|
442
|
+
assert.equal(manageEdit.status, 200);
|
|
443
|
+
assert.equal(manageEdit.body.ok, true);
|
|
444
|
+
const afterManageEdit = (await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id === manageSeed.id);
|
|
445
|
+
assert.equal(afterManageEdit.state, 'pending');
|
|
446
|
+
assert.equal(afterManageEdit.body, '结尾给一句具体动作。');
|
|
447
|
+
const manageAdopt = await call('/lite-memory/evolution', {
|
|
448
|
+
sessionId: 'session-evo-read', learningAction: 'adopt', id: manageSeed.id, expectedRevision: afterManageEdit.revision,
|
|
449
|
+
});
|
|
450
|
+
assert.equal(manageAdopt.status, 200);
|
|
451
|
+
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id === manageSeed.id).state, 'active');
|
|
452
|
+
const managePause = await call('/lite-memory/evolution', {
|
|
453
|
+
sessionId: 'session-evo-read', learningAction: 'pause', ids: [manageSeed.id],
|
|
454
|
+
});
|
|
455
|
+
assert.equal(managePause.status, 200);
|
|
456
|
+
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id === manageSeed.id).state, 'paused');
|
|
457
|
+
const manageResume = await call('/lite-memory/evolution', {
|
|
458
|
+
sessionId: 'session-evo-read', learningAction: 'resume', id: manageSeed.id,
|
|
459
|
+
});
|
|
460
|
+
assert.equal(manageResume.status, 200);
|
|
461
|
+
const manageResumed = (await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id === manageSeed.id);
|
|
462
|
+
assert.equal(manageResumed.state, 'trial');
|
|
463
|
+
const manageDiscard = await call('/lite-memory/evolution', {
|
|
464
|
+
sessionId: 'session-evo-read', learningAction: 'discard', id: manageSeed.id, expectedRevision: manageResumed.revision,
|
|
465
|
+
});
|
|
466
|
+
assert.equal(manageDiscard.status, 200);
|
|
467
|
+
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.length, 0);
|
|
468
|
+
|
|
469
|
+
// ── 关闭项目记忆后:面板只读,管理面写操作全部拒绝,工具也不落盘 ─────────────
|
|
470
|
+
const closedCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-closed-'));
|
|
471
|
+
sessionCwds.set('session-closed', closedCwd);
|
|
472
|
+
const closedScope = store.scopeOf(closedCwd);
|
|
473
|
+
const closedAgent = { id: 'session-closed', cwd: closedCwd, session: { id: 'session-closed', header: { cwd: closedCwd } } };
|
|
474
|
+
const closedSeed = await defs.get('memory_save').execute(
|
|
475
|
+
{ title: '口播开头', summary: '口播开头三句内给结论', body: '开场三句内给结论。' },
|
|
476
|
+
{ agent: closedAgent },
|
|
477
|
+
);
|
|
478
|
+
state.transact(closedScope, {}, (draft) => {
|
|
479
|
+
const row = draft.entries.find((entry) => entry.id === closedSeed.id);
|
|
480
|
+
row.origin = 'project_evolution';
|
|
481
|
+
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'active' } };
|
|
482
|
+
return { changed: true };
|
|
483
|
+
});
|
|
484
|
+
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-closed', enabled: true })).status, 200);
|
|
485
|
+
const closedOff = await call('/lite-memory/control', { sessionId: 'session-closed', enabled: false, operationId: 'op_close_contract' });
|
|
486
|
+
assert.equal(closedOff.status, 200);
|
|
487
|
+
assert.equal(closedOff.body.enabled, false);
|
|
488
|
+
const closedFile = path.join(closedScope.dir, 'state.json');
|
|
489
|
+
const closedBefore = fs.readFileSync(closedFile);
|
|
490
|
+
const closedSnapshot = await call('/lite-memory/evolution', { sessionId: 'session-closed', learningAction: 'snapshot' });
|
|
491
|
+
assert.equal(closedSnapshot.status, 200);
|
|
492
|
+
assert.equal(closedSnapshot.body.enabled, false);
|
|
493
|
+
assert.equal(closedSnapshot.body.rules.length, 1);
|
|
494
|
+
assert.equal(Buffer.compare(closedBefore, fs.readFileSync(closedFile)), 0);
|
|
495
|
+
const closedRevision = closedSnapshot.body.rules[0].revision;
|
|
496
|
+
for (const action of ['adopt', 'edit', 'delete', 'pause', 'resume', 'pin', 'undo']) {
|
|
497
|
+
const payload = { sessionId: 'session-closed', learningAction: action, id: closedSeed.id, expectedRevision: closedRevision, body: '改过的正文' };
|
|
498
|
+
if (action === 'pin') payload.pinned = true;
|
|
499
|
+
if (action === 'pause') payload.ids = [closedSeed.id];
|
|
500
|
+
const result = await call('/lite-memory/evolution', payload);
|
|
501
|
+
assert.equal(result.status, 409, action);
|
|
502
|
+
assert.equal(result.body.code, 'memory_disabled', action);
|
|
503
|
+
}
|
|
504
|
+
const closedTool = await defs.get('memory_save').execute(
|
|
505
|
+
{ title: '关闭后写入', summary: '应当拒绝', body: '不该落盘' },
|
|
506
|
+
{ agent: closedAgent },
|
|
507
|
+
);
|
|
508
|
+
assert.equal(closedTool.changed, false);
|
|
509
|
+
assert.equal(closedTool.reason, 'memory_disabled');
|
|
510
|
+
assert.equal(closedTool.id, '');
|
|
511
|
+
assert.equal(Buffer.compare(closedBefore, fs.readFileSync(closedFile)), 0);
|
|
512
|
+
const reopened = await call('/lite-memory/control', { sessionId: 'session-closed', enabled: true, operationId: 'op_reopen_contract' });
|
|
513
|
+
assert.equal(reopened.status, 200);
|
|
514
|
+
const reopenedSnapshot = await call('/lite-memory/evolution', { sessionId: 'session-closed', learningAction: 'snapshot' });
|
|
515
|
+
assert.equal(reopenedSnapshot.body.enabled, false);
|
|
516
|
+
assert.equal(reopenedSnapshot.body.rules.length, 1);
|
|
517
|
+
|
|
518
|
+
// ── 界面只吃持久元数据,不猜文案 ─────────────────────────────────────────────
|
|
519
|
+
const metaOf = (name, args, value) => defs.get(name).output.presentationMeta(args, value);
|
|
520
|
+
const savedMeta = metaOf('memory_save', {}, saved);
|
|
521
|
+
assert.equal(savedMeta.kind, 'memory-save');
|
|
522
|
+
assert.equal(savedMeta.id, saved.id);
|
|
523
|
+
assert.equal(savedMeta.title, '发布顺序');
|
|
524
|
+
assert.equal(savedMeta.changed, true);
|
|
525
|
+
assert.ok(['created', 'updated'].includes(savedMeta.mode));
|
|
526
|
+
assert.equal('path' in savedMeta, false);
|
|
527
|
+
const searchMeta = metaOf('memory_search', {}, globalSearch);
|
|
528
|
+
assert.deepEqual(Object.keys(searchMeta.refs[0]).sort(), ['id', 'origin', 'revision', 'scope', 'status', 'title']);
|
|
529
|
+
assert.equal(searchMeta.refs[0].id, global.id);
|
|
530
|
+
const readValue = await exec('memory_read', { id: global.id });
|
|
531
|
+
const readMeta = metaOf('memory_read', {}, readValue);
|
|
532
|
+
assert.equal(readMeta.kind, 'memory-read');
|
|
533
|
+
assert.equal(readMeta.title, '回复用中文');
|
|
534
|
+
assert.equal(readMeta.revision, 1);
|
|
535
|
+
const suggestValue = await exec('memory_suggest', { title: '元数据建议', summary: '建议行只认元数据里的候选 id' });
|
|
536
|
+
const suggestMeta = metaOf('memory_suggest', {}, suggestValue);
|
|
537
|
+
assert.equal(suggestMeta.kind, 'memory-suggest');
|
|
538
|
+
assert.equal(typeof suggestMeta.asked, 'boolean');
|
|
539
|
+
assert.equal('path' in suggestMeta, false);
|
|
540
|
+
if (suggestMeta.asked === true) assert.match(suggestMeta.id, /^sug_[a-z0-9]+$/);
|
|
541
|
+
|
|
542
|
+
// ── 撤销带着当时的版本:过期就冲突,不覆盖后来的修改 ─────────────────────────
|
|
543
|
+
const guarded = await exec('memory_save', { title: '版本守卫', summary: '撤销要带当时版本' });
|
|
544
|
+
await exec('memory_save', { title: '版本守卫', summary: '撤销要带当时版本', body: '第二版正文' });
|
|
545
|
+
const stale = await call('/lite-memory/undo', { op: 'undo', id: guarded.id, revision: 1, cwd });
|
|
546
|
+
assert.equal(stale.status, 409);
|
|
547
|
+
const fresh = store.loadIndex(store.scopeOf(cwd)).entries.find((item) => item.id === guarded.id);
|
|
548
|
+
const accepted = await call('/lite-memory/undo', { op: 'undo', id: guarded.id, revision: fresh.revision, cwd });
|
|
549
|
+
assert.equal(accepted.status, 200);
|
|
550
|
+
|
|
551
|
+
// ── 第二组:确认前后语义一致、比较口径统一 ──────────────────────────────────
|
|
552
|
+
const cwd2 = path.join(os.tmpdir(), 'lite-memory-contract-project-2');
|
|
553
|
+
fs.mkdirSync(cwd2, { recursive: true });
|
|
554
|
+
const exec2 = (name, args) => defs.get(name).execute(args, { agent: { cwd: cwd2, session: { id: 'session-contract-2', header: { cwd: cwd2 } } } });
|
|
555
|
+
const projectScope = store.scopeOf(cwd2);
|
|
556
|
+
const projectEntries = () => store.loadIndex(projectScope).entries;
|
|
557
|
+
|
|
558
|
+
/** 建议时给的适用范围与条件,确认后必须原样落盘,不能因为过了一道待确认区就变成普遍适用。 */
|
|
559
|
+
const condSuggest = await exec2('memory_suggest', {
|
|
560
|
+
title: '只在发布机跑冒烟',
|
|
561
|
+
summary: '发布机上的冒烟检查命令',
|
|
562
|
+
body: 'bash smoke.sh',
|
|
563
|
+
when: '发布机上',
|
|
564
|
+
scope: 'project',
|
|
565
|
+
applicability: 'conditional',
|
|
566
|
+
conditions: [{ key: 'machine', value: 'release-box' }],
|
|
567
|
+
triggers: ['冒烟'],
|
|
568
|
+
});
|
|
569
|
+
assert.equal(condSuggest.asked, true, JSON.stringify(condSuggest));
|
|
570
|
+
const condKept = await call('/lite-memory/keep', { op: 'keep', candidateId: condSuggest.id, operationId: 'op_cond_keep', cwd: cwd2 });
|
|
571
|
+
assert.equal(condKept.status, 200);
|
|
572
|
+
const condRecord = projectEntries().find((item) => item.title === '只在发布机跑冒烟');
|
|
573
|
+
assert.ok(condRecord, '确认后应写入项目层');
|
|
574
|
+
assert.equal(condRecord.applicability, 'conditional');
|
|
575
|
+
assert.deepEqual(condRecord.conditions, [{ key: 'machine', value: 'release-box' }]);
|
|
576
|
+
assert.deepEqual(condRecord.triggers, ['冒烟']);
|
|
577
|
+
|
|
578
|
+
/** 只改摘要不该把原来有条件的规则放宽成普遍适用。 */
|
|
579
|
+
const narrow = await exec2('memory_save', {
|
|
580
|
+
title: 'windows 上跑构建',
|
|
581
|
+
summary: 'windows 构建命令',
|
|
582
|
+
body: 'pnpm build',
|
|
583
|
+
when: 'windows 上',
|
|
584
|
+
scope: 'project',
|
|
585
|
+
applicability: 'conditional',
|
|
586
|
+
conditions: [{ key: 'targetOs', value: 'windows' }],
|
|
587
|
+
});
|
|
588
|
+
assert.equal(narrow.changed, true);
|
|
589
|
+
const narrowed = await exec2('memory_save', { id: narrow.id, title: 'windows 上跑构建', summary: 'windows 构建用这条命令' });
|
|
590
|
+
assert.equal(narrowed.changed, true);
|
|
591
|
+
const narrowRecord = projectEntries().find((item) => item.id === narrow.id);
|
|
592
|
+
assert.equal(narrowRecord.applicability, 'conditional');
|
|
593
|
+
assert.deepEqual(narrowRecord.conditions, [{ key: 'targetOs', value: 'windows' }]);
|
|
594
|
+
|
|
595
|
+
/** 同名但机器条件不同是两条规则:新的一条不能覆盖旧的一条。 */
|
|
596
|
+
const boxA = await exec2('memory_save', {
|
|
597
|
+
title: '冒烟顺序',
|
|
598
|
+
summary: '冒烟按这个顺序跑',
|
|
599
|
+
body: '先登录再下单',
|
|
600
|
+
when: '冒烟时',
|
|
601
|
+
scope: 'project',
|
|
602
|
+
applicability: 'conditional',
|
|
603
|
+
conditions: [{ key: 'machine', value: 'box-a' }],
|
|
604
|
+
});
|
|
605
|
+
assert.equal(boxA.changed, true);
|
|
606
|
+
const boxB = await exec2('memory_save', {
|
|
607
|
+
title: '冒烟顺序',
|
|
608
|
+
summary: '冒烟按这个顺序跑',
|
|
609
|
+
body: '先建号再登录',
|
|
610
|
+
when: '冒烟时',
|
|
611
|
+
scope: 'project',
|
|
612
|
+
applicability: 'conditional',
|
|
613
|
+
conditions: [{ key: 'machine', value: 'box-b' }],
|
|
614
|
+
});
|
|
615
|
+
assert.equal(boxB.changed, true);
|
|
616
|
+
assert.notEqual(boxB.id, boxA.id);
|
|
617
|
+
const bothBoxes = projectEntries().filter((item) => item.title === '冒烟顺序' && item.status === 'active');
|
|
618
|
+
assert.equal(bothBoxes.length, 2);
|
|
619
|
+
assert.deepEqual(bothBoxes.map((item) => item.conditions[0].value).sort(), ['box-a', 'box-b']);
|
|
620
|
+
assert.equal(bothBoxes.find((item) => item.id === boxA.id).body, '先登录再下单');
|
|
621
|
+
|
|
622
|
+
/** 同一主题、条件一致但正文不同:读取要报冲突,不能悄悄挑一条当现行规范。 */
|
|
623
|
+
const slotA = await exec2('memory_save', {
|
|
624
|
+
title: '发版检查项',
|
|
625
|
+
summary: '发版前检查的固定清单',
|
|
626
|
+
body: '第一步:跑 lint',
|
|
627
|
+
scope: 'project',
|
|
628
|
+
applicability: 'all_in_scope',
|
|
629
|
+
topicKey: '发版检查',
|
|
630
|
+
resolution: 'distinct',
|
|
631
|
+
});
|
|
632
|
+
const slotB = await exec2('memory_save', {
|
|
633
|
+
title: '发版检查项',
|
|
634
|
+
summary: '发版前检查的固定清单',
|
|
635
|
+
body: '第一步:跑单测',
|
|
636
|
+
scope: 'project',
|
|
637
|
+
applicability: 'all_in_scope',
|
|
638
|
+
topicKey: '发版检查',
|
|
639
|
+
resolution: 'distinct',
|
|
640
|
+
});
|
|
641
|
+
assert.notEqual(slotA.id, slotB.id);
|
|
642
|
+
const resolvedSlots = store.visibleRules(projectScope, {}).resolved;
|
|
643
|
+
const slotRow = [...resolvedSlots.values()].find((row) => row.key === '发版检查');
|
|
644
|
+
assert.ok(slotRow);
|
|
645
|
+
assert.equal(slotRow.status, 'conflict');
|
|
646
|
+
|
|
647
|
+
/** 工具参数里的 links / selection.items / topicKey:null 是合法输入,不能被 schema 挡掉或被校验器拒绝。 */
|
|
648
|
+
assert.equal(saveParams.links.items.type, 'object');
|
|
649
|
+
assert.deepEqual(saveParams.links.items.required, ['id']);
|
|
650
|
+
assert.equal(saveParams.selection.properties.items.type, 'array');
|
|
651
|
+
assert.deepEqual(saveParams.topicKey.type, ['string', 'null']);
|
|
652
|
+
const linkedA = await exec2('memory_save', { title: '关联目标甲', summary: '用来当关联目标', body: '甲', scope: 'project' });
|
|
653
|
+
const linkedB = await exec2('memory_save', {
|
|
654
|
+
title: '关联目标乙',
|
|
655
|
+
summary: '带关联的那条',
|
|
656
|
+
body: '乙',
|
|
657
|
+
scope: 'project',
|
|
658
|
+
resolution: 'distinct',
|
|
659
|
+
links: [{ type: 'related', id: linkedA.id }],
|
|
660
|
+
});
|
|
661
|
+
assert.equal(linkedB.changed, true, JSON.stringify(linkedB));
|
|
662
|
+
const linkedRecord = projectEntries().find((item) => item.id === linkedB.id);
|
|
663
|
+
assert.equal(linkedRecord.links.length, 1);
|
|
664
|
+
assert.equal(linkedRecord.links[0].id, linkedA.id);
|
|
665
|
+
assert.equal(linkedRecord.links[0].type, 'related');
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
// ── 项目实验:正常回合结束后走一次短整理,只写后台结果 ─────────────────────────
|
|
669
|
+
const orgCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-organize-'));
|
|
670
|
+
sessionCwds.set('session-organize', orgCwd);
|
|
671
|
+
const orgOn = await call('/lite-memory/evolution', { sessionId: 'session-organize', enabled: true });
|
|
672
|
+
assert.equal(orgOn.status, 200);
|
|
673
|
+
const orgScope = store.scopeOf(orgCwd);
|
|
674
|
+
await store.setTaskDefault(orgScope, { taskKind: 'video', seriesId: '口播' });
|
|
675
|
+
let orgOptions = null;
|
|
676
|
+
let orgPrepare = null;
|
|
677
|
+
let orgMode = 'new';
|
|
678
|
+
llmImpl = {
|
|
679
|
+
prepareCall: async (config, signal) => {
|
|
680
|
+
orgPrepare = { config, signal };
|
|
681
|
+
return {
|
|
682
|
+
config: { provider: 'deepseek', model: 'chat' },
|
|
683
|
+
stream: async function* (options) {
|
|
684
|
+
orgOptions = options;
|
|
685
|
+
const prompt = options.messages[0].content[0].text;
|
|
686
|
+
const ref = (prompt.match(/"ref":"(r\d+)"/) || [])[1] || 'r0';
|
|
687
|
+
const change = orgMode === 'checkpoint'
|
|
688
|
+
? { target: ref, feedbackType: 'execution_miss', currentText: '交付前先核对开头是否先给结论。', evidence: ['u0'], persistence: 'project_candidate' }
|
|
689
|
+
: orgMode === 'sample'
|
|
690
|
+
? { target: ref, feedbackType: 'artifact_accept', evidence: ['u0'], persistence: 'project_candidate' }
|
|
691
|
+
: { target: 'new', feedbackType: 'spec_change', topicKey: '开头', currentText: '开头先给结论。', applicability: 'all_in_scope', conditions: [], evidence: ['u0'], persistence: 'project_candidate' };
|
|
692
|
+
yield { type: 'text-delta', text: JSON.stringify({ changes: [change] }) };
|
|
693
|
+
yield { type: 'usage', usage: { inputTokens: 12, outputTokens: 6 } };
|
|
694
|
+
yield { type: 'finish', reason: { kind: 'stop' } };
|
|
695
|
+
},
|
|
696
|
+
};
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
const orgAgent = { id: 'session-organize', cwd: orgCwd, session: { id: 'session-organize', header: { id: 'session-organize', cwd: orgCwd }, requestHeader: () => ({ config: { provider: 'deepseek', model: 'chat' } }) } };
|
|
700
|
+
currentAgent = orgAgent;
|
|
701
|
+
const orgSig = new AbortController().signal;
|
|
702
|
+
await preStep({ agent: orgAgent, signal: orgSig, turn: '31' }, async () => ({
|
|
703
|
+
kind: 'enter',
|
|
704
|
+
messages: [{ role: 'user', id: 'org-m1', source: { kind: 'user' }, content: [{ type: 'text', text: '以后开头先给结论,别再铺垫。' }] }],
|
|
705
|
+
}));
|
|
706
|
+
assert.equal(store.learningSnapshot(orgScope).rules.length, 0);
|
|
707
|
+
const orgTurnEnd = subscriptions.get('session/event');
|
|
708
|
+
assert.equal(typeof orgTurnEnd, 'function');
|
|
709
|
+
orgTurnEnd(orgAgent.session, { type: 'turn/end', data: { reason: { kind: 'completed' } } });
|
|
710
|
+
for (let i = 0; i < 100 && store.learningSnapshot(orgScope).rules.length === 0; i += 1) {
|
|
711
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
712
|
+
}
|
|
713
|
+
const orgRules = store.learningSnapshot(orgScope).rules;
|
|
714
|
+
assert.equal(orgRules.length, 1);
|
|
715
|
+
assert.equal(orgRules[0].body, '开头先给结论。');
|
|
716
|
+
assert.equal(orgRules[0].state, 'active');
|
|
717
|
+
assert.equal(orgPrepare.config.maxTokens, 768);
|
|
718
|
+
assert.equal(orgOptions.purpose, 'project-evolution');
|
|
719
|
+
assert.equal('tools' in orgOptions, false);
|
|
720
|
+
assert.equal(orgOptions.sessionId, 'session-organize');
|
|
721
|
+
assert.equal(orgRules[0].origin, 'project_evolution');
|
|
722
|
+
const orgEconomy = store.learningSnapshot(orgScope).economy;
|
|
723
|
+
assert.equal(orgEconomy.calls, 1);
|
|
724
|
+
assert.equal(orgEconomy.knownCalls, 1);
|
|
725
|
+
assert.equal(orgEconomy.inputTokens, 12);
|
|
726
|
+
|
|
727
|
+
/** 第二步:执行没做到,后台只补检查点;主持人新开一版也不改正文。 */
|
|
728
|
+
orgMode = 'checkpoint';
|
|
729
|
+
await preStep({ agent: orgAgent, signal: orgSig, turn: '32' }, async () => ({
|
|
730
|
+
kind: 'enter',
|
|
731
|
+
messages: [{ role: 'user', id: 'org-m2', source: { kind: 'user' }, content: [{ type: 'text', text: '之前说过开头先给结论,这次怎么又没做到。' }] }],
|
|
732
|
+
}));
|
|
733
|
+
orgTurnEnd(orgAgent.session, { type: 'turn/end', data: { reason: { kind: 'completed' } } });
|
|
734
|
+
const orgRuleId = store.learningSnapshot(orgScope).rules[0].id;
|
|
735
|
+
const orgFlat = () => store.loadIndex(orgScope).entries.find((row) => row.id === orgRuleId);
|
|
736
|
+
for (let i = 0; i < 100 && !(orgFlat()?.learning?.checkpoints || []).length; i += 1) {
|
|
737
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
738
|
+
}
|
|
739
|
+
assert.equal(orgFlat().body, '开头先给结论。');
|
|
740
|
+
assert.equal((orgFlat().learning.checkpoints || []).length, 1);
|
|
741
|
+
assert.match(orgFlat().learning.checkpoints[0].text, /核对/);
|
|
742
|
+
assert.equal(orgFlat().learning.state, 'active');
|
|
743
|
+
assert.equal(orgFlat().learning.evidence.length, 1);
|
|
744
|
+
|
|
745
|
+
/** 第三步:另一期作品认可这个开头,只记一条有版本、有维度的样例。 */
|
|
746
|
+
sessionCwds.set('session-organize-2', orgCwd);
|
|
747
|
+
const orgScope2 = orgScope;
|
|
748
|
+
orgMode = 'sample';
|
|
749
|
+
const orgAgent2 = { id: 'session-organize-2', cwd: orgCwd, session: { id: 'session-organize-2', header: { id: 'session-organize-2', cwd: orgCwd }, requestHeader: () => ({ config: { provider: 'deepseek', model: 'chat' } }) } };
|
|
750
|
+
currentAgent = orgAgent2;
|
|
751
|
+
await preStep({ agent: orgAgent2, signal: orgSig, turn: '41' }, async () => ({
|
|
752
|
+
kind: 'enter',
|
|
753
|
+
messages: [{ role: 'user', id: 'org-m3', source: { kind: 'user' }, content: [{ type: 'text', text: '这版可以,开头这样就行。' }] }],
|
|
754
|
+
}));
|
|
755
|
+
orgMode = 'sample';
|
|
756
|
+
orgTurnEnd(orgAgent2.session, { type: 'turn/end', data: { reason: { kind: 'completed' } } });
|
|
757
|
+
const orgRule2 = () => store.loadIndex(orgScope2).entries.find((row) => row.origin === 'project_evolution');
|
|
758
|
+
for (let i = 0; i < 100 && !(orgRule2()?.learning?.samples || []).length; i += 1) {
|
|
759
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
760
|
+
}
|
|
761
|
+
assert.equal(orgRule2().body, '开头先给结论。');
|
|
762
|
+
assert.equal((orgRule2().learning.samples || []).length, 1);
|
|
763
|
+
assert.equal(orgRule2().learning.samples[0].dimension, '开头');
|
|
764
|
+
assert.equal(orgRule2().learning.samples[0].verified, false);
|
|
765
|
+
assert.ok(orgRule2().learning.samples[0].artifactVersion);
|
|
766
|
+
const orgSnapshot2 = await call('/lite-memory/evolution', { sessionId: 'session-organize-2', learningAction: 'snapshot' });
|
|
767
|
+
assert.equal(orgSnapshot2.status, 200);
|
|
768
|
+
assert.equal(orgSnapshot2.body.rules.length, 1);
|
|
769
|
+
assert.equal(orgSnapshot2.body.rules[0].samples.length, 1);
|
|
770
|
+
assert.equal(orgSnapshot2.body.rules[0].checkpoints.length, 1);
|
|
771
|
+
const orgEconomy2 = store.learningSnapshot(orgScope2).economy;
|
|
772
|
+
assert.equal(orgEconomy2.calls, 3);
|
|
773
|
+
assert.equal(orgEconomy2.knownCalls, 3);
|
|
774
|
+
assert.equal(orgEconomy2.inputTokens, 36);
|
|
775
|
+
llmImpl = null;
|
|
776
|
+
|
|
777
|
+
// ── 记忆面板总览:只读枚举各项目层,正文按 scopeKey 单读,不泄露内部路径 ──────
|
|
778
|
+
const ovCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-overview-'));
|
|
779
|
+
const ovOtherCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-overview-other-'));
|
|
780
|
+
sessionCwds.set('session-overview', ovCwd);
|
|
781
|
+
sessionCwds.set('session-overview-other', ovOtherCwd);
|
|
782
|
+
const ovAgent = { id: 'session-overview', cwd: ovCwd, session: { id: 'session-overview', header: { cwd: ovCwd } } };
|
|
783
|
+
const ovFirst = await defs.get('memory_save').execute(
|
|
784
|
+
{ title: '开场先给结论', summary: '口播开头先给结论', body: '开场三句内给结论。', tags: ['口播', '开头'], links: [] },
|
|
785
|
+
{ agent: ovAgent },
|
|
786
|
+
);
|
|
787
|
+
const ovSecond = await defs.get('memory_save').execute(
|
|
788
|
+
{ title: '封面留白', summary: '封面别放满', body: '右侧留白。', tags: ['封面'] },
|
|
789
|
+
{ agent: ovAgent },
|
|
790
|
+
);
|
|
791
|
+
await defs.get('memory_save').execute(
|
|
792
|
+
{ title: '观察全局', summary: '跨项目通用说明', body: '全局正文。', scope: 'global' },
|
|
793
|
+
{ agent: ovAgent },
|
|
794
|
+
);
|
|
795
|
+
const ovOther = await defs.get('memory_save').execute(
|
|
796
|
+
{ title: '别的项目', summary: '只在别的项目里', body: '另一层的正文。', tags: ['别的'] },
|
|
797
|
+
{ agent: { id: 'session-overview-other', cwd: ovOtherCwd, session: { id: 'session-overview-other', header: { cwd: ovOtherCwd } } } },
|
|
798
|
+
);
|
|
799
|
+
const ov = await call('/lite-memory/overview', { sessionId: 'session-overview' });
|
|
800
|
+
assert.equal(ov.status, 200);
|
|
801
|
+
assert.equal(ov.body.ok, true);
|
|
802
|
+
assert.equal(ov.body.current.key, store.scopeOf(ovCwd).key);
|
|
803
|
+
assert.equal(ov.body.current.label, path.basename(ovCwd));
|
|
804
|
+
assert.equal(ov.body.current.evolution.enabled, false);
|
|
805
|
+
assert.equal(ov.body.selected.key, ov.body.current.key);
|
|
806
|
+
assert.equal(ov.body.selected.kind, 'project');
|
|
807
|
+
assert.equal(ov.body.selected.evolution.enabled, false);
|
|
808
|
+
assert.ok(Array.isArray(ov.body.selected.evolution.rules));
|
|
809
|
+
assert.ok(ov.body.selected.records.some((row) => row.id === ovFirst.id && row.title === '开场先给结论'));
|
|
810
|
+
assert.equal(ov.body.selected.records.some((row) => 'body' in row), false, '列表不带正文');
|
|
811
|
+
assert.equal(ov.body.selected.stats.total, 2);
|
|
812
|
+
assert.equal(ov.body.selected.stats.tags, 3);
|
|
813
|
+
assert.equal(JSON.stringify(ov.body).includes('"dir"'), false, '不暴露内部目录');
|
|
814
|
+
assert.equal(JSON.stringify(ov.body).includes(process.env.DSH_HOME), false);
|
|
815
|
+
const ovKeys = ov.body.scopes.map((row) => row.key);
|
|
816
|
+
assert.ok(ovKeys.includes(ov.body.current.key));
|
|
817
|
+
assert.ok(ovKeys.includes(store.scopeOf(ovOtherCwd).key));
|
|
818
|
+
assert.ok(ovKeys.includes('global'), '全局层总在列表里');
|
|
819
|
+
|
|
820
|
+
// 切到别的项目只是换一面看,不写那个项目
|
|
821
|
+
const ovOtherView = await call('/lite-memory/overview', { sessionId: 'session-overview', scopeKey: store.scopeOf(ovOtherCwd).key });
|
|
822
|
+
assert.equal(ovOtherView.status, 200);
|
|
823
|
+
assert.equal(ovOtherView.body.selected.key, store.scopeOf(ovOtherCwd).key);
|
|
824
|
+
assert.deepEqual(ovOtherView.body.selected.records.map((row) => row.title), ['别的项目']);
|
|
825
|
+
assert.equal(ovOtherView.body.selected.evolution.enabled, false, '别的项目也要给只读实验状态');
|
|
826
|
+
assert.equal(ovOtherView.body.current.key, ov.body.current.key);
|
|
827
|
+
const ovGlobal = await call('/lite-memory/overview', { sessionId: 'session-overview', scopeKey: 'global' });
|
|
828
|
+
assert.equal(ovGlobal.status, 200);
|
|
829
|
+
assert.equal(ovGlobal.body.selected.kind, 'global');
|
|
830
|
+
assert.ok(ovGlobal.body.selected.records.some((row) => row.title === '观察全局'));
|
|
831
|
+
|
|
832
|
+
// 详情按服务端枚举过的 scopeKey 取;内部路径、未知键一律不放行
|
|
833
|
+
const ovBody = await call('/lite-memory/body', { sessionId: 'session-overview', id: ovFirst.id, scopeKey: ov.body.current.key });
|
|
834
|
+
assert.equal(ovBody.status, 200);
|
|
835
|
+
assert.equal(ovBody.body.body, '开场三句内给结论。');
|
|
836
|
+
assert.equal(ovBody.body.scopeLabel, path.basename(ovCwd));
|
|
837
|
+
const ovOtherBody = await call('/lite-memory/body', { sessionId: 'session-overview', id: ovOther.id, scopeKey: store.scopeOf(ovOtherCwd).key });
|
|
838
|
+
assert.equal(ovOtherBody.status, 200);
|
|
839
|
+
assert.equal(ovOtherBody.body.scopeLabel, path.basename(ovOtherCwd));
|
|
840
|
+
assert.equal((await call('/lite-memory/body', { sessionId: 'session-overview', id: ovFirst.id, scopeKey: 'nope' })).status, 404);
|
|
841
|
+
assert.equal((await call('/lite-memory/body', { sessionId: 'session-overview', id: ovFirst.id, scopeKey: '../x' })).status, 404);
|
|
842
|
+
assert.equal((await call('/lite-memory/overview', {})).status, 400);
|
|
843
|
+
assert.equal((await call('/lite-memory/overview', { sessionId: 'session-overview' }, { origin: 'http://evil.example' })).status, 403);
|
|
844
|
+
const ovUnknownCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-overview-unknown-'));
|
|
845
|
+
sessionCwds.set('session-overview-unknown', ovUnknownCwd);
|
|
846
|
+
const ovUnconfirmedSaved = process.env.PATH;
|
|
847
|
+
process.env.PATH = '';
|
|
848
|
+
const ovUnconfirmed = await call('/lite-memory/overview', { sessionId: 'session-overview-unknown' });
|
|
849
|
+
process.env.PATH = ovUnconfirmedSaved;
|
|
850
|
+
assert.equal(ovUnconfirmed.status, 409);
|
|
851
|
+
assert.equal(fs.existsSync(path.join(store.scopeOf(ovUnknownCwd).dir, 'state.json')), false);
|
|
852
|
+
|
|
853
|
+
// 实验条目也画进图里,但只有面板能看见;普通读取面仍然拿不到
|
|
854
|
+
state.transact(store.scopeOf(ovCwd), {}, (draft) => {
|
|
855
|
+
const row = draft.entries.find((entry) => entry.id === ovSecond.id);
|
|
856
|
+
row.origin = 'project_evolution';
|
|
857
|
+
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'trial', source: 'evolution' } };
|
|
858
|
+
return { changed: true };
|
|
859
|
+
});
|
|
860
|
+
const ovExperimental = await call('/lite-memory/overview', { sessionId: 'session-overview' });
|
|
861
|
+
assert.equal(ovExperimental.body.selected.stats.experimental, 1);
|
|
862
|
+
assert.equal(ovExperimental.body.selected.records.find((row) => row.id === ovSecond.id).experimental, true);
|
|
863
|
+
assert.equal(store.loadIndex(store.scopeOf(ovCwd), { allowExperimental: true }).entries.some((row) => row.id === ovSecond.id), true);
|
|
864
|
+
|
|
865
|
+
// 归档不是死数字:总数诚实,列表单独取,正文还能按 id 打开
|
|
866
|
+
state.transact(store.scopeOf(ovCwd), {}, (draft) => {
|
|
867
|
+
const row = draft.entries.find((entry) => entry.id === ovSecond.id);
|
|
868
|
+
row.status = 'archived';
|
|
869
|
+
return { changed: true };
|
|
870
|
+
});
|
|
871
|
+
const ovArchived = await call('/lite-memory/overview', { sessionId: 'session-overview' });
|
|
872
|
+
assert.equal(ovArchived.body.selected.stats.archived, 1);
|
|
873
|
+
assert.equal(ovArchived.body.selected.records.some((row) => row.id === ovSecond.id), false);
|
|
874
|
+
assert.equal(ovArchived.body.selected.archived.some((row) => row.id === ovSecond.id), true);
|
|
875
|
+
const ovArchivedBody = await call('/lite-memory/body', { sessionId: 'session-overview', id: ovSecond.id, scopeKey: ovArchived.body.selected.key });
|
|
876
|
+
assert.equal(ovArchivedBody.status, 200);
|
|
877
|
+
assert.equal(ovArchivedBody.body.status, 'archived');
|
|
878
|
+
assert.equal(ovArchivedBody.body.body, '右侧留白。');
|
|
879
|
+
|
|
880
|
+
console.log('contract.test.mjs: all passed');
|