dsh-lite-memory 0.11.1 → 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 +44 -18
- package/anchor.js +110 -13
- package/client.js +1078 -117
- 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 +579 -78
- package/learning.js +58 -32
- package/package.json +2 -2
- package/state.js +107 -8
- package/store.js +302 -29
- package/test/anchor.test.mjs +221 -0
- package/test/client.test.mjs +48 -6
- package/test/contract.test.mjs +214 -11
- package/test/control.test.mjs +278 -0
- package/test/evolution-read.test.mjs +63 -8
- package/test/learning.test.mjs +4 -2
- package/test/lexical.test.mjs +33 -0
- package/test/recall.test.mjs +11 -9
- package/test/state.test.mjs +21 -0
- package/test/store.test.mjs +25 -3
- package/view.js +16 -1
|
@@ -0,0 +1,278 @@
|
|
|
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
|
+
|
|
6
|
+
process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-control-'));
|
|
7
|
+
const { apply } = await import('../index.js');
|
|
8
|
+
const store = await import('../store.js');
|
|
9
|
+
const state = await import('../state.js');
|
|
10
|
+
|
|
11
|
+
const effect = (fn) => {
|
|
12
|
+
const dispose = fn();
|
|
13
|
+
return typeof dispose === 'function' ? dispose : () => {};
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function boot() {
|
|
17
|
+
const defs = new Map();
|
|
18
|
+
const handlers = new Map();
|
|
19
|
+
const promptContexts = [];
|
|
20
|
+
const ctx = {
|
|
21
|
+
tools: { register: (def) => { defs.set(def.name, def); return () => {}; } },
|
|
22
|
+
effect,
|
|
23
|
+
inject: () => () => {},
|
|
24
|
+
get: (name) => (name === 'systemPrompt'
|
|
25
|
+
? { context: (entry) => { promptContexts.push(entry); return () => {}; } }
|
|
26
|
+
: name === 'agents' ? { currentInitiator: () => null } : undefined),
|
|
27
|
+
on: (event, handler) => {
|
|
28
|
+
const list = handlers.get(event) || [];
|
|
29
|
+
list.push(handler);
|
|
30
|
+
handlers.set(event, list);
|
|
31
|
+
return () => {};
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
apply(ctx, { mode: 'adaptive' });
|
|
35
|
+
const provider = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
|
|
36
|
+
const exec = (name, args, agent) => defs.get(name).execute(args, { agent });
|
|
37
|
+
const preStep = (payload, decision) => handlers.get('agent/pre-step').at(-1)(payload, async () => decision);
|
|
38
|
+
return { exec, preStep, text: (agent, signal) => provider.text({ agent, signal }), debug: globalThis.__liteMemoryRecall };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let seq = 0;
|
|
42
|
+
const human = (body) => { seq += 1; return { role: 'user', id: `msg-${seq}`, source: { kind: 'user' }, content: [{ type: 'text', text: body }] }; };
|
|
43
|
+
const enter = (...messages) => ({ kind: 'enter', messages });
|
|
44
|
+
const mkdir = (label) => fs.mkdtempSync(path.join(os.tmpdir(), `lite-memory-control-${label}-`));
|
|
45
|
+
const makeAgent = (id, dir) => ({ id, cwd: dir, session: { id: `session-${id}`, header: { cwd: dir } } });
|
|
46
|
+
const save = (scope, fields) => store.upsert(scope, {
|
|
47
|
+
applicability: 'all_in_scope',
|
|
48
|
+
body: `正文:${fields.title}`,
|
|
49
|
+
resolution: 'distinct',
|
|
50
|
+
...fields,
|
|
51
|
+
summary: fields.summary ?? `${fields.title}的摘要`,
|
|
52
|
+
});
|
|
53
|
+
const seedRule = (scope, title) => {
|
|
54
|
+
const saved = save(scope, { title, tags: ['口播'] });
|
|
55
|
+
const id = saved.record?.id ?? saved.id;
|
|
56
|
+
state.transact(scope, {}, (draft) => {
|
|
57
|
+
const row = draft.entries.find((entry) => entry.id === id);
|
|
58
|
+
row.origin = 'project_evolution';
|
|
59
|
+
row.current.conditions = [{ key: 'seriesId', value: '口播' }];
|
|
60
|
+
row.maintenance.learning = { state: 'active' };
|
|
61
|
+
return { changed: true };
|
|
62
|
+
});
|
|
63
|
+
return id;
|
|
64
|
+
};
|
|
65
|
+
const scopeOf = (dir) => store.scopeOf(dir);
|
|
66
|
+
const toggle = (scope, enabled) => store.setMemoryEnabled(scope, enabled, { operationId: `op-${Math.random().toString(16).slice(2)}` });
|
|
67
|
+
|
|
68
|
+
/* C01:总开关关闭后,自动注入、全局召回、检索、读取、写入、学习全部停。 */
|
|
69
|
+
{
|
|
70
|
+
const dir = mkdir('off');
|
|
71
|
+
const scope = scopeOf(dir);
|
|
72
|
+
const globalSaved = save(store.globalScope(), { title: '全局写作偏好', tags: ['写作'] });
|
|
73
|
+
const globalId = globalSaved.record?.id ?? globalSaved.id;
|
|
74
|
+
save(scope, { title: '项目构建约定', tags: ['构建'] });
|
|
75
|
+
seedRule(scope, '口播开头');
|
|
76
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-on' }).ok, true);
|
|
77
|
+
assert.equal(toggle(scope, false).ok, true);
|
|
78
|
+
assert.equal(store.controlStatus(scope).enabled, false);
|
|
79
|
+
|
|
80
|
+
const runtime = boot();
|
|
81
|
+
const agent = makeAgent('a-off', dir);
|
|
82
|
+
const sig = new AbortController().signal;
|
|
83
|
+
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('按全局写作偏好来修构建脚本')));
|
|
84
|
+
assert.equal(runtime.text(agent, sig), '');
|
|
85
|
+
assert.equal(runtime.debug.preparedOf(agent), null);
|
|
86
|
+
|
|
87
|
+
const found = await runtime.exec('memory_search', { query: '写作 构建 口播开头' }, agent);
|
|
88
|
+
assert.equal(found.status, 'memory_disabled');
|
|
89
|
+
assert.equal(found.count, 0);
|
|
90
|
+
assert.ok(globalId);
|
|
91
|
+
assert.equal((await runtime.exec('memory_read', { id: globalId }, agent)).status, 'memory_disabled');
|
|
92
|
+
assert.equal((await runtime.exec('memory_forget', { id: globalId, mode: 'archive' }, agent)).mode, 'memory_disabled');
|
|
93
|
+
const suggested = await runtime.exec('memory_suggest', { title: '新偏好', summary: '一条偏好' }, agent);
|
|
94
|
+
assert.equal(suggested.asked, false);
|
|
95
|
+
const saved = await runtime.exec('memory_save', { title: '关闭后写入', summary: '应当拒绝', body: '不该落盘' }, agent);
|
|
96
|
+
assert.equal(saved.changed, false);
|
|
97
|
+
assert.equal(saved.reason, 'memory_disabled');
|
|
98
|
+
assert.equal(saved.id, '');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* C02:关闭期间所有写入入口都拒绝,自进化不能绕过总开关打开。 */
|
|
102
|
+
{
|
|
103
|
+
const dir = mkdir('write');
|
|
104
|
+
const scope = scopeOf(dir);
|
|
105
|
+
assert.equal(toggle(scope, false).ok, true);
|
|
106
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-blocked' }).ok, false);
|
|
107
|
+
const saved = store.saveEntry(scope, { title: '一条项目规范', summary: '普通摘要', body: '正文', kind: 'convention', scope: 'project', durability: 'durable' });
|
|
108
|
+
assert.equal(saved.reasonCode, 'memory_disabled');
|
|
109
|
+
const task = store.saveTaskState(scope, { version: 1, sessions: {}, episodes: {}, defaults: null, revision: 1 }, { operationId: 'task-blocked' });
|
|
110
|
+
assert.equal(task.ok, false);
|
|
111
|
+
const committed = store.commitLearning(scope, { title: '学习规则', summary: '摘要', body: '正文' }, {}, { operationId: 'learn-blocked' });
|
|
112
|
+
assert.equal(committed.ok, false);
|
|
113
|
+
const run = store.setEvolution(scope, false, { operationId: 'evo-off' });
|
|
114
|
+
assert.equal(run.ok, false);
|
|
115
|
+
assert.equal(run.code, 'memory_disabled');
|
|
116
|
+
assert.equal(store.controlStatus(scope).enabled, false);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* C03:关一个项目不影响另一个;重新打开记忆后自进化仍保持关闭。 */
|
|
120
|
+
{
|
|
121
|
+
const dirA = mkdir('iso-a');
|
|
122
|
+
const dirB = mkdir('iso-b');
|
|
123
|
+
const scopeA = scopeOf(dirA);
|
|
124
|
+
const scopeB = scopeOf(dirB);
|
|
125
|
+
save(scopeB, { title: 'B 项目构建约定', tags: ['构建', '脚本'] });
|
|
126
|
+
seedRule(scopeB, '口播开头');
|
|
127
|
+
assert.equal(store.setEvolution(scopeB, true, { operationId: 'evo-b' }).ok, true);
|
|
128
|
+
assert.equal(toggle(scopeA, false).ok, true);
|
|
129
|
+
assert.equal(store.evolutionStatus(scopeB).enabled, true);
|
|
130
|
+
assert.equal(store.controlStatus(scopeB).enabled, true);
|
|
131
|
+
|
|
132
|
+
const runtime = boot();
|
|
133
|
+
const agentB = makeAgent('b-live', dirB);
|
|
134
|
+
const sigB = new AbortController().signal;
|
|
135
|
+
await runtime.preStep({ agent: agentB, signal: sigB, turn: 't1' }, enter(human('按项目构建约定改脚本')));
|
|
136
|
+
assert.match(runtime.text(agentB, sigB), /B 项目构建约定/);
|
|
137
|
+
|
|
138
|
+
toggle(scopeB, false);
|
|
139
|
+
assert.equal(store.evolutionStatus(scopeB).enabled, false);
|
|
140
|
+
assert.equal(store.evolutionStatus(scopeB).configuredEnabled, false);
|
|
141
|
+
toggle(scopeB, true);
|
|
142
|
+
assert.equal(store.evolutionStatus(scopeB).enabled, false);
|
|
143
|
+
assert.equal(store.setEvolution(scopeB, true, { operationId: 'evo-b-again' }).ok, true);
|
|
144
|
+
assert.equal(store.evolutionStatus(scopeB).enabled, true);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* C04:切换总开关让旧任务上下文立即失效,不拿关闭前的绑定继续注入。 */
|
|
148
|
+
{
|
|
149
|
+
const dir = mkdir('stale');
|
|
150
|
+
const scope = scopeOf(dir);
|
|
151
|
+
seedRule(scope, '口播开头');
|
|
152
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-stale' }).ok, true);
|
|
153
|
+
const runtime = boot();
|
|
154
|
+
const agent = makeAgent('a-stale', dir);
|
|
155
|
+
const sig = new AbortController().signal;
|
|
156
|
+
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播开头怎么改')));
|
|
157
|
+
const held = runtime.debug.windowOf(agent).experiment;
|
|
158
|
+
assert.ok(held && held.contextId);
|
|
159
|
+
toggle(scope, false);
|
|
160
|
+
toggle(scope, true);
|
|
161
|
+
const stale = await runtime.exec('memory_search', { query: '口播开头', contextId: held.contextId }, agent);
|
|
162
|
+
assert.equal(stale.status !== 'ok', true);
|
|
163
|
+
assert.equal((await runtime.exec('memory_search', { query: '口播开头' }, agent)).status, 'ok');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* C05:关闭保留候选、试用、结果与整理回执;重开记忆后自进化仍是关闭。 */
|
|
167
|
+
{
|
|
168
|
+
const dir = mkdir('preserve');
|
|
169
|
+
const scope = scopeOf(dir);
|
|
170
|
+
const id = seedRule(scope, '口播开头');
|
|
171
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-preserve' }).ok, true);
|
|
172
|
+
const before = store.learningSnapshot(scope);
|
|
173
|
+
assert.equal(before.counts.active, 1);
|
|
174
|
+
assert.equal(toggle(scope, false).ok, true);
|
|
175
|
+
const closed = store.learningSnapshot(scope);
|
|
176
|
+
assert.equal(closed.enabled, false);
|
|
177
|
+
assert.equal(closed.rules.length, before.rules.length);
|
|
178
|
+
assert.equal(closed.counts.active, 1);
|
|
179
|
+
assert.equal(toggle(scope, true).ok, true);
|
|
180
|
+
const reopened = store.learningSnapshot(scope);
|
|
181
|
+
assert.equal(reopened.enabled, false);
|
|
182
|
+
assert.equal(reopened.counts.active, 1);
|
|
183
|
+
assert.equal(store.evolutionStatus(scope).configuredEnabled, false);
|
|
184
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-resume' }).ok, true);
|
|
185
|
+
assert.equal(store.learningSnapshot(scope).counts.active, 1);
|
|
186
|
+
assert.ok(id);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* C06:关闭期间管理面写操作一律拒绝,只读快照不写盘。 */
|
|
190
|
+
{
|
|
191
|
+
const dir = mkdir('managed-off');
|
|
192
|
+
const scope = scopeOf(dir);
|
|
193
|
+
const id = seedRule(scope, '口播开头');
|
|
194
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-managed' }).ok, true);
|
|
195
|
+
toggle(scope, false);
|
|
196
|
+
const file = state.stateFiles(scope).current;
|
|
197
|
+
const before = fs.readFileSync(file);
|
|
198
|
+
const snapshot = store.learningSnapshot(scope);
|
|
199
|
+
const rule = snapshot.rules[0];
|
|
200
|
+
assert.equal(rule.id, id);
|
|
201
|
+
assert.equal(Buffer.compare(before, fs.readFileSync(file)), 0);
|
|
202
|
+
for (const action of ['pin', 'pause', 'resume', 'delete', 'undo', 'adopt', 'edit']) {
|
|
203
|
+
let result;
|
|
204
|
+
if (action === 'pin') result = store.pinLearningRule(scope, id, { pinned: true, expectedRevision: rule.revision });
|
|
205
|
+
if (action === 'pause') result = store.pauseLearningRules(scope, [id], { operationId: 'pause-off' });
|
|
206
|
+
if (action === 'resume') result = store.resumeLearningRule(scope, id);
|
|
207
|
+
if (action === 'delete') result = store.removeLearningRule(scope, id, { expectedRevision: rule.revision });
|
|
208
|
+
if (action === 'undo') result = store.undoLearningChange(scope, id, { expectedRevision: rule.revision });
|
|
209
|
+
if (action === 'adopt') result = store.adoptLearningRule(scope, id, { expectedRevision: rule.revision });
|
|
210
|
+
if (action === 'edit') result = store.editLearningRule(scope, id, { expectedRevision: rule.revision, body: '改过的正文' });
|
|
211
|
+
assert.equal(result.ok, false, action);
|
|
212
|
+
assert.equal(result.code, 'memory_disabled', action);
|
|
213
|
+
}
|
|
214
|
+
assert.equal(Buffer.compare(before, fs.readFileSync(file)), 0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* C07:采纳把候选转为生效;编辑只保存并留上一版,不改状态以外的东西。 */
|
|
218
|
+
{
|
|
219
|
+
const dir = mkdir('adopt');
|
|
220
|
+
const scope = scopeOf(dir);
|
|
221
|
+
const id = seedRule(scope, '口播开头');
|
|
222
|
+
const first = store.learningSnapshot(scope).rules[0];
|
|
223
|
+
const edited = store.editLearningRule(scope, id, { expectedRevision: first.revision, body: '开头两句内给结论。', title: '口播结论前置' });
|
|
224
|
+
assert.equal(edited.ok, true);
|
|
225
|
+
const afterEdit = store.learningSnapshot(scope).rules[0];
|
|
226
|
+
assert.equal(afterEdit.state, 'trial');
|
|
227
|
+
assert.equal(afterEdit.body, '开头两句内给结论。');
|
|
228
|
+
assert.equal(afterEdit.title, '口播结论前置');
|
|
229
|
+
assert.equal(afterEdit.learning.versions.length, 1);
|
|
230
|
+
const adopted = store.adoptLearningRule(scope, id, { expectedRevision: afterEdit.revision });
|
|
231
|
+
assert.equal(adopted.ok, true);
|
|
232
|
+
const afterAdopt = store.learningSnapshot(scope).rules[0];
|
|
233
|
+
assert.equal(afterAdopt.state, 'active');
|
|
234
|
+
assert.equal(afterAdopt.body, '开头两句内给结论。');
|
|
235
|
+
assert.equal(afterAdopt.title, '口播结论前置');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* C08:关闭保留已消耗额度与默认绑定,重开后仍按原额度限额。 */
|
|
239
|
+
{
|
|
240
|
+
const dir = mkdir('preserve-quota');
|
|
241
|
+
const scope = scopeOf(dir);
|
|
242
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-quota' }).ok, true);
|
|
243
|
+
const defaults = { taskKind: 'video', seriesId: '口播' };
|
|
244
|
+
assert.equal(store.saveTaskState(scope, { version: 1, sessions: {}, episodes: {}, defaults, revision: 1 }, { operationId: 'defaults' }).ok, true);
|
|
245
|
+
assert.equal(store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a1' }).ok, true);
|
|
246
|
+
assert.equal(store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a2' }).ok, true);
|
|
247
|
+
assert.equal(store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a3' }).ok, false);
|
|
248
|
+
toggle(scope, false);
|
|
249
|
+
toggle(scope, true);
|
|
250
|
+
assert.deepEqual(store.taskState(scope).defaults, { ...defaults, confirmedBy: 'user', confirmedAt: '' });
|
|
251
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-quota-again' }).ok, true);
|
|
252
|
+
const limited = store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a4' });
|
|
253
|
+
assert.equal(limited.ok, false);
|
|
254
|
+
assert.equal(limited.reason, 'episode_limit');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* C09:同一项目两个会话都已准备内容,关闭后两边都立即失效。 */
|
|
258
|
+
{
|
|
259
|
+
const dir = mkdir('two-sessions');
|
|
260
|
+
const scope = scopeOf(dir);
|
|
261
|
+
seedRule(scope, '口播开头');
|
|
262
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-two' }).ok, true);
|
|
263
|
+
const runtime = boot();
|
|
264
|
+
const a1 = makeAgent('two-1', dir);
|
|
265
|
+
const a2 = makeAgent('two-2', dir);
|
|
266
|
+
const sig = new AbortController().signal;
|
|
267
|
+
await runtime.preStep({ agent: a1, signal: sig, turn: 't1' }, enter(human('这期口播开头怎么改')));
|
|
268
|
+
await runtime.preStep({ agent: a2, signal: sig, turn: 't1' }, enter(human('这期口播开头怎么改')));
|
|
269
|
+
assert.match(runtime.text(a1, sig), /口播开头/);
|
|
270
|
+
assert.match(runtime.text(a2, sig), /口播开头/);
|
|
271
|
+
toggle(scope, false);
|
|
272
|
+
assert.equal(runtime.text(a1, sig), '');
|
|
273
|
+
assert.equal(runtime.text(a2, sig), '');
|
|
274
|
+
assert.equal(runtime.debug.preparedOf(a1).text, '');
|
|
275
|
+
assert.equal(runtime.debug.preparedOf(a2).text, '');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
console.log('control.test.mjs: all passed');
|
|
@@ -102,7 +102,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
102
102
|
assert.equal(runtime.debug.windowOf(agent).experiment, null);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
/* L02
|
|
105
|
+
/* L02:开关打开但没有任何栏目,不注入任何领域问题,也不给空上下文。 */
|
|
106
106
|
{
|
|
107
107
|
const dir = mkdir('clarify');
|
|
108
108
|
const scope = scopeOf(dir);
|
|
@@ -112,30 +112,50 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
112
112
|
const sig = signal();
|
|
113
113
|
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期怎么开头')));
|
|
114
114
|
const text = runtime.text(agent, sig);
|
|
115
|
-
assert.
|
|
116
|
-
assert.
|
|
115
|
+
assert.equal(text, '');
|
|
116
|
+
assert.doesNotMatch(text, /栏目|Vlog|口播|视频/);
|
|
117
|
+
const experiment = runtime.debug.windowOf(agent).experiment;
|
|
118
|
+
assert.equal(experiment ? experiment.injectedIds.length : 0, 0);
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
/* L03:未绑定任务只给栏目索引,不超过三条;短规则不进正文。 */
|
|
120
122
|
{
|
|
121
123
|
const dir = mkdir('index');
|
|
122
124
|
const scope = scopeOf(dir);
|
|
123
|
-
seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '
|
|
124
|
-
seedRule(scope, { title: '口播收尾', body: '结尾只留一个行动。', conditions: [{ key: 'seriesId', value: '
|
|
125
|
+
seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播视频' }] });
|
|
126
|
+
seedRule(scope, { title: '口播收尾', body: '结尾只留一个行动。', conditions: [{ key: 'seriesId', value: '口播视频' }] });
|
|
125
127
|
seedRule(scope, { title: 'Vlog 节奏', body: '每段不超过二十秒。', conditions: [{ key: 'seriesId', value: 'Vlog' }] });
|
|
126
128
|
enable(scope);
|
|
127
129
|
const runtime = boot();
|
|
128
130
|
const agent = makeAgent('a-index', dir);
|
|
129
131
|
const sig = signal();
|
|
130
|
-
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('
|
|
132
|
+
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('视频开头怎么改')));
|
|
131
133
|
const text = runtime.text(agent, sig);
|
|
132
|
-
assert.match(text,
|
|
134
|
+
assert.match(text, /这些记忆范围可能适用/);
|
|
135
|
+
assert.doesNotMatch(text, /Vlog/);
|
|
133
136
|
assert.doesNotMatch(text, /开场三句内给结论/);
|
|
134
137
|
const listed = text.split('\n').filter((line) => line.startsWith('- '));
|
|
135
138
|
assert.equal(listed.length <= 3, true);
|
|
136
139
|
assert.equal(Buffer.byteLength(text) <= 600 + 160, true, 'index stays small');
|
|
137
140
|
}
|
|
138
141
|
|
|
142
|
+
/* L03b:项目有视频栏目,本轮是修代码:无关栏目与选择提示都不进上下文。 */
|
|
143
|
+
{
|
|
144
|
+
const dir = mkdir('index-unrelated');
|
|
145
|
+
const scope = scopeOf(dir);
|
|
146
|
+
seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
|
|
147
|
+
seedRule(scope, { title: 'Vlog 节奏', body: '每段不超过二十秒。', conditions: [{ key: 'seriesId', value: 'Vlog' }] });
|
|
148
|
+
enable(scope);
|
|
149
|
+
const runtime = boot();
|
|
150
|
+
const agent = makeAgent('a-index-unrelated', dir);
|
|
151
|
+
const sig = signal();
|
|
152
|
+
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('修一下 dsh-unlag 的重新载入问题')));
|
|
153
|
+
const injected = runtime.text(agent, sig);
|
|
154
|
+
assert.equal(injected, '');
|
|
155
|
+
const experiment = runtime.debug.windowOf(agent).experiment;
|
|
156
|
+
assert.equal(experiment ? experiment.injectedIds.length : 0, 0);
|
|
157
|
+
}
|
|
158
|
+
|
|
139
159
|
/* L04:项目默认绑定后给规则正文,带 id 与版本。 */
|
|
140
160
|
{
|
|
141
161
|
const dir = mkdir('default');
|
|
@@ -150,7 +170,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
150
170
|
assert.match(text, /项目实验规则/);
|
|
151
171
|
assert.match(text, /先给结论再解释/);
|
|
152
172
|
assert.ok(text.includes(id));
|
|
153
|
-
assert.
|
|
173
|
+
assert.doesNotMatch(text, /第 .* 版/);
|
|
154
174
|
}
|
|
155
175
|
|
|
156
176
|
/* L05:长规则只给用途与 memory_read 指针,不把正文塞进上下文。 */
|
|
@@ -247,6 +267,22 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
247
267
|
assert.ok(current, 'switch leaves an unbound episode');
|
|
248
268
|
}
|
|
249
269
|
|
|
270
|
+
/* L09b:明确改为排查数据库后,旧视频规则和栏目提示都不再注入。 */
|
|
271
|
+
{
|
|
272
|
+
const dir = mkdir('switch-db');
|
|
273
|
+
const scope = scopeOf(dir);
|
|
274
|
+
seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
|
|
275
|
+
enable(scope, { seriesId: '口播' });
|
|
276
|
+
const runtime = boot();
|
|
277
|
+
const agent = makeAgent('a-switch-db', dir);
|
|
278
|
+
await runtime.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('这期口播开头')));
|
|
279
|
+
const sig = signal();
|
|
280
|
+
await runtime.preStep({ agent, signal: sig, turn: 't2' }, enter(human('这期不做了,改为排查 PostgreSQL 慢查询')));
|
|
281
|
+
const text = runtime.text(agent, sig);
|
|
282
|
+
assert.equal(text.includes('开场三句内给结论'), false);
|
|
283
|
+
assert.equal(/栏目|点名/.test(text), false);
|
|
284
|
+
}
|
|
285
|
+
|
|
250
286
|
/* L10:没有本请求上下文时,普通检索看不到实验来源。 */
|
|
251
287
|
{
|
|
252
288
|
const dir = mkdir('nocx');
|
|
@@ -390,4 +426,23 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
390
426
|
assert.equal(text.includes('实验规则:发版前先备份'), false);
|
|
391
427
|
}
|
|
392
428
|
|
|
429
|
+
/* L18:工具绑定零命中/多命中时只回一句读取状态,不要求用户点名栏目。 */
|
|
430
|
+
{
|
|
431
|
+
const dir = mkdir('bind-none');
|
|
432
|
+
const scope = scopeOf(dir);
|
|
433
|
+
seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
|
|
434
|
+
seedRule(scope, { title: 'Vlog 节奏', body: '每段不超过二十秒。', conditions: [{ key: 'seriesId', value: 'Vlog' }] });
|
|
435
|
+
enable(scope);
|
|
436
|
+
const runtime = boot();
|
|
437
|
+
const agent = makeAgent('a-bind-none', dir);
|
|
438
|
+
await runtime.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('这期怎么开头')));
|
|
439
|
+
const held = runtime.debug.windowOf(agent).experiment;
|
|
440
|
+
const none = await runtime.exec('memory_search', { query: '修一下构建脚本', contextId: held.contextId, bindForRequest: true }, agent);
|
|
441
|
+
assert.equal(none.status, 'needs_context');
|
|
442
|
+
assert.doesNotMatch(none.text, /点名|先问|栏目/);
|
|
443
|
+
const many = await runtime.exec('memory_search', { query: '口播和 Vlog 都要', contextId: held.contextId, bindForRequest: true }, agent);
|
|
444
|
+
assert.equal(many.status, 'needs_context');
|
|
445
|
+
assert.doesNotMatch(many.text, /点名|先问|栏目/);
|
|
446
|
+
}
|
|
447
|
+
|
|
393
448
|
console.log('evolution-read.test.mjs: all passed');
|
package/test/learning.test.mjs
CHANGED
|
@@ -180,7 +180,9 @@ test('B01 调用输入不超过 8 KiB 且只有本地引用', () => {
|
|
|
180
180
|
ranges: { taskKinds: ['video'], series: ['口播'] },
|
|
181
181
|
});
|
|
182
182
|
assert.ok(input.bytes <= 8192, `bytes=${input.bytes}`);
|
|
183
|
-
assert.equal(input.rules.length,
|
|
183
|
+
assert.equal(input.rules.length >= 1, true);
|
|
184
|
+
assert.equal(input.rules.length <= 6, true);
|
|
185
|
+
assert.equal(input.prompt.includes(long), false);
|
|
184
186
|
assert.equal(input.refs.feedback.u0, 'm1');
|
|
185
187
|
assert.equal(input.refs.rules.r0, 'mem_0');
|
|
186
188
|
assert.equal(input.messages.length, 1);
|
|
@@ -470,7 +472,7 @@ test('P02 两个独立作品后升为已生效,单次只到试用', () => {
|
|
|
470
472
|
const version = ruleOf(scope, id).revision;
|
|
471
473
|
const rules = [{ ref: 'r0', id, revision: version, conditions: [{ key: 'seriesId', value: '口播' }] }];
|
|
472
474
|
const refs = { feedback: { u0: 'b1' }, rules: { r0: id } };
|
|
473
|
-
const check = parsed({ target: 'r0', feedbackType: 'spec_change', currentText:
|
|
475
|
+
const check = parsed({ target: 'r0', feedbackType: 'spec_change', currentText: NEW_CHANGE.currentText, evidence: ['u0'] }, { rules, refs });
|
|
474
476
|
const second = store.commitLearning(scope, {
|
|
475
477
|
operationId: 'learn_promote',
|
|
476
478
|
epoch: begin(scope, two).epoch,
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
|
|
6
|
+
process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-lexical-'));
|
|
7
|
+
const store = await import('../store.js');
|
|
8
|
+
|
|
9
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-lexical-scope-'));
|
|
10
|
+
const scope = store.scopeOf(dir);
|
|
11
|
+
const add = (title, extra = {}) => store.upsert(scope, {
|
|
12
|
+
applicability: 'all_in_scope',
|
|
13
|
+
body: `正文:${title}`,
|
|
14
|
+
summary: extra.summary || `${title}的摘要`,
|
|
15
|
+
tags: extra.tags || [],
|
|
16
|
+
resolution: 'distinct',
|
|
17
|
+
title,
|
|
18
|
+
});
|
|
19
|
+
add('digital signal 调试', { tags: ['音频'] });
|
|
20
|
+
add('怎么做发布检查', { tags: ['发布'] });
|
|
21
|
+
add('git 提交前跑检查', { tags: ['git'] });
|
|
22
|
+
|
|
23
|
+
/* 英文按整词:git 不能因为共享 gi/it 命中 digital。 */
|
|
24
|
+
assert.equal(store.search(scope, 'git', 5).some((row) => row.title.includes('digital')), false);
|
|
25
|
+
assert.equal(store.search(scope, 'digital', 5).some((row) => row.title.includes('digital')), true);
|
|
26
|
+
|
|
27
|
+
/* 只重合「怎么」这类高频词不算命中。 */
|
|
28
|
+
assert.equal(store.search(scope, '这个怎么弄', 5).length, 0);
|
|
29
|
+
|
|
30
|
+
/* 实词仍然命中。 */
|
|
31
|
+
assert.equal(store.search(scope, '发布检查', 5).some((row) => row.title.includes('怎么做发布检查')), true);
|
|
32
|
+
|
|
33
|
+
console.log('lexical.test.mjs: all passed');
|
package/test/recall.test.mjs
CHANGED
|
@@ -8,7 +8,7 @@ const { apply } = await import('../index.js');
|
|
|
8
8
|
const store = await import('../store.js');
|
|
9
9
|
|
|
10
10
|
const HEADER = '已有记忆可能相关';
|
|
11
|
-
const CARD_LINE = /^- 「.+?」.* · mem_[a-z0-9]+
|
|
11
|
+
const CARD_LINE = /^- \[[^\]]+\] 「.+?」.* · mem_[a-z0-9]+(?:|.*)?$/;
|
|
12
12
|
const CARD_LINE_M = new RegExp(CARD_LINE.source, 'm');
|
|
13
13
|
|
|
14
14
|
const effect = (fn) => {
|
|
@@ -122,7 +122,7 @@ for (let i = 0; i < 9; i += 1) {
|
|
|
122
122
|
summary: '读取预算测试',
|
|
123
123
|
tags: ['大正文'],
|
|
124
124
|
resolution: 'distinct',
|
|
125
|
-
body: `样本${i + 1}:${'正文内容'.repeat(
|
|
125
|
+
body: `样本${i + 1}:${'正文内容'.repeat(80)}`,
|
|
126
126
|
}));
|
|
127
127
|
}
|
|
128
128
|
|
|
@@ -142,7 +142,7 @@ assert.ok(Buffer.byteLength(preparedText) <= 1200, 'snapshot stays inside card b
|
|
|
142
142
|
const cards = bootA.provider.text({ agent: agentA, signal: signalA }).split('\n').slice(1);
|
|
143
143
|
assert.ok(cards.length <= 3);
|
|
144
144
|
for (const line of cards) assert.match(line, CARD_LINE);
|
|
145
|
-
assert.
|
|
145
|
+
assert.doesNotMatch(preparedText, /第 .* 版/);
|
|
146
146
|
assert.equal(bootA.provider.text({ agent: agentA, signal: signalA }), preparedText, 'frozen between calls');
|
|
147
147
|
|
|
148
148
|
// A03:provider 纯内存,不写盘、不动 hits。
|
|
@@ -265,7 +265,7 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
265
265
|
await bootA.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('大正文样本怎么用')));
|
|
266
266
|
const first = await bootA.exec('memory_read', { id: bigBodies[0].id }, agent);
|
|
267
267
|
assert.equal(first.status, 'ok');
|
|
268
|
-
assert.ok(first.body
|
|
268
|
+
assert.ok(Buffer.byteLength(first.body) > 900);
|
|
269
269
|
const repeat = await bootA.exec('memory_read', { id: bigBodies[0].id }, agent);
|
|
270
270
|
assert.equal(repeat.repeat, true);
|
|
271
271
|
assert.equal(repeat.body, '');
|
|
@@ -273,13 +273,15 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
273
273
|
const second = await bootA.exec('memory_read', { id: bigBodies[1].id }, agent);
|
|
274
274
|
assert.equal(second.status, 'ok');
|
|
275
275
|
const third = await bootA.exec('memory_read', { id: bigBodies[2].id }, agent);
|
|
276
|
-
assert.equal(third.status, '
|
|
277
|
-
|
|
278
|
-
assert.equal(
|
|
279
|
-
assert.equal(
|
|
276
|
+
assert.equal(third.status, 'ok');
|
|
277
|
+
const fourth = await bootA.exec('memory_read', { id: bigBodies[3].id }, agent);
|
|
278
|
+
assert.equal(fourth.status, 'budget_exhausted');
|
|
279
|
+
assert.equal(fourth.body || '', '');
|
|
280
|
+
assert.equal(fourth.id, '');
|
|
281
|
+
assert.equal(bootA.render('memory_read', {}, fourth), 'memory · 本请求的记忆额度已用完');
|
|
280
282
|
const win = bootA.debug.windowOf(agent);
|
|
281
283
|
assert.ok(win.bytes <= 8192);
|
|
282
|
-
assert.equal(win.bodies,
|
|
284
|
+
assert.equal(win.bodies, 3);
|
|
283
285
|
|
|
284
286
|
const agentReads = makeAgent('agent-reads', cwdA);
|
|
285
287
|
await bootA.preStep({ agent: agentReads, signal: signal(), turn: 't1' }, enter(human('大正文样本怎么用')));
|
package/test/state.test.mjs
CHANGED
|
@@ -210,6 +210,27 @@ test('S02 符号链接、非 Git 目录、嵌套仓库各归各的', () => {
|
|
|
210
210
|
assert.notEqual(scopeOf(nested).key, scopeOf(repo).key);
|
|
211
211
|
});
|
|
212
212
|
|
|
213
|
+
test('S03 配置的项目根只覆盖自己的子树,不吞别的目录', () => {
|
|
214
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-config-'));
|
|
215
|
+
dirs.push(root);
|
|
216
|
+
const sub = path.join(root, 'packages', 'app');
|
|
217
|
+
fs.mkdirSync(sub, { recursive: true });
|
|
218
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-outside-'));
|
|
219
|
+
dirs.push(outside);
|
|
220
|
+
const config = path.join(memoryRoot(), 'config.json');
|
|
221
|
+
fs.mkdirSync(path.dirname(config), { recursive: true });
|
|
222
|
+
fs.writeFileSync(config, JSON.stringify({ projectRoot: root }));
|
|
223
|
+
try {
|
|
224
|
+
assert.equal(resolveRoot(sub).root, fs.realpathSync(root));
|
|
225
|
+
assert.equal(resolveRoot(sub).source, 'configured');
|
|
226
|
+
assert.equal(scopeOf(sub).key, scopeOf(root).key);
|
|
227
|
+
assert.notEqual(resolveRoot(outside).source, 'configured');
|
|
228
|
+
assert.notEqual(scopeOf(outside).key, scopeOf(root).key);
|
|
229
|
+
} finally {
|
|
230
|
+
fs.rmSync(config, { force: true });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
213
234
|
test('P04 Git 查询失败不冒充已确认工程,不用 cwd 兜底学习', () => {
|
|
214
235
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-gitfail-'));
|
|
215
236
|
dirs.push(dir);
|
package/test/store.test.mjs
CHANGED
|
@@ -40,6 +40,26 @@ const rawState = (scope) => fs.readFileSync(path.join(scope.dir, 'state.json'),
|
|
|
40
40
|
const countOf = (scope) => loadIndex(scope).entries.length;
|
|
41
41
|
const suggestionsOf = (scope) => loadIndex(scope).suggestions;
|
|
42
42
|
|
|
43
|
+
test('over-specific body is rejected instead of becoming a constraint', () => {
|
|
44
|
+
const scope = fresh('specific');
|
|
45
|
+
assert.throws(
|
|
46
|
+
() => upsert(scope, {
|
|
47
|
+
title: '自绘菜单',
|
|
48
|
+
summary: '菜单用应用自绘,不用系统原生',
|
|
49
|
+
body: '菜单 z-index: 1100;宽度 min(264px, 100vw - 24px);字号 12px;内边距 10px;圆角 8px。',
|
|
50
|
+
}),
|
|
51
|
+
/正文太具体/,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('body over the distilled budget is rejected', () => {
|
|
56
|
+
const scope = fresh('toolong');
|
|
57
|
+
assert.throws(
|
|
58
|
+
() => upsert(scope, { title: '长正文', summary: '太长就不存', body: '记'.repeat(500) }),
|
|
59
|
+
/超过单条 1200 字节/,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
43
63
|
test('create then same title merges with full old body kept', () => {
|
|
44
64
|
const scope = fresh('create');
|
|
45
65
|
const first = upsert(scope, { title: '发布顺序', summary: '发版前要按顺序做的事', body: '第一步\n- 跑测试' });
|
|
@@ -185,7 +205,8 @@ test('search finds Chinese queries, when clauses and reports counts', () => {
|
|
|
185
205
|
assert.ok(when.some((item) => item.title === '文案检查'));
|
|
186
206
|
const rendered = renderIndex(hits);
|
|
187
207
|
assert.match(rendered.text, /^- mem_[a-z0-9]+ 发布顺序/);
|
|
188
|
-
assert.match(rendered.text,
|
|
208
|
+
assert.match(rendered.text, /mem_[a-z0-9]+ 发布顺序/);
|
|
209
|
+
assert.doesNotMatch(rendered.text, /第 .* 版/);
|
|
189
210
|
assert.ok(rendered.chars <= 400);
|
|
190
211
|
assert.deepEqual(search(scope, '完全无关的主题词'), []);
|
|
191
212
|
});
|
|
@@ -448,7 +469,7 @@ test('projects and the global layer stay separate', () => {
|
|
|
448
469
|
assert.equal(global.length, 1);
|
|
449
470
|
assert.equal(global[0].scope, 'global');
|
|
450
471
|
const rendered = renderIndex(global);
|
|
451
|
-
assert.match(rendered.text, /^- \[
|
|
472
|
+
assert.match(rendered.text, /^- \[global\] mem_/);
|
|
452
473
|
const removed = forget(b, 'mem_000000000000', { mode: 'archive' });
|
|
453
474
|
assert.equal(removed, null);
|
|
454
475
|
});
|
|
@@ -731,7 +752,8 @@ test('实验来源条目默认不进入检索、正文与关联,只有管理
|
|
|
731
752
|
});
|
|
732
753
|
assert.equal(search(scope, '实验记录', 4).some((row) => row.id === target.id), false);
|
|
733
754
|
assert.equal(readEntry(scope, target.id), null);
|
|
734
|
-
assert.equal(search(scope, '实验记录', 4, { allowExperimental: true }).some((row) => row.id === target.id),
|
|
755
|
+
assert.equal(search(scope, '实验记录', 4, { allowExperimental: true }).some((row) => row.id === target.id), false);
|
|
756
|
+
assert.equal(search(scope, '实验记录', 4, { allowExperimental: true, history: true }).some((row) => row.id === target.id), true);
|
|
735
757
|
assert.equal(readEntry(scope, target.id, { allowExperimental: true }).body, '实验正文');
|
|
736
758
|
assert.ok(readEntry(scope, normal.record.id));
|
|
737
759
|
});
|
package/view.js
CHANGED
|
@@ -222,6 +222,21 @@ export function canAutoRecall(record, context) {
|
|
|
222
222
|
return statusOf(record, context) === 'ok';
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/**
|
|
226
|
+
* 学习规则是否还在有效期内:paused/pending 和被时间淘汰的 trial 都不能当依据。
|
|
227
|
+
* 只对实验来源生效;普通记忆不看这一层。
|
|
228
|
+
*/
|
|
229
|
+
export function experimentalUsable(record, now = Date.now()) {
|
|
230
|
+
if (!record) return false;
|
|
231
|
+
const learning = record.learning && typeof record.learning === 'object' ? record.learning : null;
|
|
232
|
+
if (!learning && record.origin !== 'project_evolution') return true;
|
|
233
|
+
if (!learning) return false;
|
|
234
|
+
if (learning.state === 'active') return true;
|
|
235
|
+
if (learning.state !== 'trial') return false;
|
|
236
|
+
const until = Date.parse(String(learning.trialUntil || ''));
|
|
237
|
+
return !Number.isFinite(until) || until > Number(now);
|
|
238
|
+
}
|
|
239
|
+
|
|
225
240
|
function gramsOf(text) {
|
|
226
241
|
const plainText = plain(text, 400).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
227
242
|
const set = new Set();
|
|
@@ -511,7 +526,7 @@ export function newestUserQuery(messages) {
|
|
|
511
526
|
export function projectionText(view) {
|
|
512
527
|
if (!view) return '';
|
|
513
528
|
const rows = [];
|
|
514
|
-
rows.push(`${view.title || view.id}${view.when ? `(${view.when})` : ''} · ${view.id}
|
|
529
|
+
rows.push(`${view.title || view.id}${view.when ? `(${view.when})` : ''} · ${view.id}`);
|
|
515
530
|
if (view.conditions && view.conditions.length) rows.push(`条件:${view.conditions.map((item) => `${item.key}=${item.value}`).join(',')}`);
|
|
516
531
|
if (view.selection) rows.push(`仅对 ${view.selection} 生效`);
|
|
517
532
|
if (view.state === 'needs_context') rows.push('适用范围待确认');
|