dsh-lite-memory 0.16.2 → 0.17.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 +9 -9
- package/TRIGGERS.md +14 -13
- package/anchor.js +259 -24
- package/client.js +1211 -206
- package/docs/CONVENTIONS.md +143 -0
- package/docs/RELEASE-0.17.0.md +8 -0
- package/index.js +337 -95
- package/learning.js +456 -48
- package/package.json +5 -2
- package/state.js +110 -5
- package/store.js +447 -61
- package/test/anchor.test.mjs +240 -0
- package/test/client.test.mjs +176 -6
- package/test/contract.test.mjs +9 -1
- package/test/learning.test.mjs +484 -2
- package/test/palace.test.mjs +102 -0
- package/test/runtime-context.test.mjs +186 -0
- package/test/state.test.mjs +121 -2
- package/test/store.test.mjs +454 -15
- package/view.js +60 -3
|
@@ -0,0 +1,186 @@
|
|
|
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-runtime-'));
|
|
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(mode = 'adaptive') {
|
|
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 });
|
|
35
|
+
const provider = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
|
|
36
|
+
assert.ok(provider, 'recall provider registered');
|
|
37
|
+
const exec = (name, args, agent) => defs.get(name).execute(args, { agent });
|
|
38
|
+
const preStep = (payload, decision) => handlers.get('agent/pre-step').at(-1)(payload, async () => decision);
|
|
39
|
+
return { defs, provider, exec, preStep, text: (agent, signal) => provider.text({ agent, signal }), debug: globalThis.__liteMemoryRecall };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const signal = () => new AbortController().signal;
|
|
43
|
+
let seq = 0;
|
|
44
|
+
const human = (body) => {
|
|
45
|
+
seq += 1;
|
|
46
|
+
return { role: 'user', id: `msg-${seq}`, source: { kind: 'user' }, content: [{ type: 'text', text: body }] };
|
|
47
|
+
};
|
|
48
|
+
const enter = (...messages) => ({ kind: 'enter', messages });
|
|
49
|
+
const mkdir = (label) => fs.mkdtempSync(path.join(os.tmpdir(), `lite-memory-runtime-${label}-`));
|
|
50
|
+
const makeAgent = (id, dir) => ({ id, cwd: dir, session: { id: `session-${id}`, header: { cwd: dir } } });
|
|
51
|
+
|
|
52
|
+
const save = (scope, fields) => {
|
|
53
|
+
const result = store.upsert(scope, {
|
|
54
|
+
applicability: fields.applicability ?? ((fields.conditions || []).length ? 'conditional' : 'all_in_scope'),
|
|
55
|
+
body: `正文:${fields.title}`,
|
|
56
|
+
resolution: 'distinct',
|
|
57
|
+
...fields,
|
|
58
|
+
summary: fields.summary ?? `${fields.title}的摘要`,
|
|
59
|
+
});
|
|
60
|
+
assert.equal(result.ok !== false, true, result.reason);
|
|
61
|
+
return { ...result, id: result.record?.id ?? result.id };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const enable = (scope, defaults) => {
|
|
65
|
+
const turned = store.setEvolution(scope, true, { operationId: `op-on-${Math.random().toString(16).slice(2)}` });
|
|
66
|
+
assert.equal(turned.ok, true, turned.reason);
|
|
67
|
+
if (defaults) {
|
|
68
|
+
const applied = store.setTaskDefault(scope, defaults);
|
|
69
|
+
assert.equal(applied.ok, true, applied.reason);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const seedRule = (scope, { title, body, summary, conditions = [], learning = { state: 'active' } }) => {
|
|
74
|
+
const saved = save(scope, { title, body, summary, tags: ['实验'] });
|
|
75
|
+
state.transact(scope, {}, (draft) => {
|
|
76
|
+
const row = draft.entries.find((entry) => entry.id === saved.id);
|
|
77
|
+
row.origin = 'project_evolution';
|
|
78
|
+
row.current.conditions = conditions;
|
|
79
|
+
row.maintenance.learning = { ...learning };
|
|
80
|
+
return { changed: true };
|
|
81
|
+
});
|
|
82
|
+
return saved.id;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/* R01:自进化关闭时,普通条件记忆照常按当前任务召回,且不会注入实验正文。 */
|
|
86
|
+
{
|
|
87
|
+
const dir = mkdir('normal-off');
|
|
88
|
+
const scope = store.scopeOf(dir);
|
|
89
|
+
save(scope, {
|
|
90
|
+
title: '数据库连接池排查顺序',
|
|
91
|
+
body: '先看连接数上限,再看慢查询和泄漏。',
|
|
92
|
+
conditions: [{ key: 'taskKind', value: 'code' }],
|
|
93
|
+
tags: ['数据库'],
|
|
94
|
+
});
|
|
95
|
+
const runtime = boot();
|
|
96
|
+
const agent = makeAgent('r01', dir);
|
|
97
|
+
const sig = signal();
|
|
98
|
+
await runtime.preStep({ agent, signal: sig, turn: 'r01' }, enter(human('排查 PostgreSQL 连接池报错')));
|
|
99
|
+
const text = runtime.text(agent, sig);
|
|
100
|
+
assert.match(text, /数据库连接池排查顺序/);
|
|
101
|
+
const win = runtime.debug.windowOf(agent);
|
|
102
|
+
assert.equal(win.experiment, null);
|
|
103
|
+
assert.ok(win.runtime && win.runtime.task, 'runtime context exists');
|
|
104
|
+
assert.equal(win.runtime.task.kind, 'code');
|
|
105
|
+
assert.equal(win.runtime.experimental, false);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* R02:明确撤销先于延续;换任务后旧栏目规则不进入本轮。 */
|
|
109
|
+
{
|
|
110
|
+
const dir = mkdir('revoke');
|
|
111
|
+
const scope = store.scopeOf(dir);
|
|
112
|
+
enable(scope, { seriesId: '口播', taskKind: 'content' });
|
|
113
|
+
seedRule(scope, {
|
|
114
|
+
title: '口播开头先给结论',
|
|
115
|
+
body: '开场三句内给结论。',
|
|
116
|
+
summary: '口播开头规则',
|
|
117
|
+
conditions: [{ key: 'seriesId', value: '口播' }],
|
|
118
|
+
});
|
|
119
|
+
const runtime = boot();
|
|
120
|
+
const agent = makeAgent('r02', dir);
|
|
121
|
+
const sig = signal();
|
|
122
|
+
await runtime.preStep({ agent, signal: sig, turn: 'r02' }, enter(human('先不做口播,排查 PostgreSQL 连接池故障')));
|
|
123
|
+
const text = runtime.text(agent, sig);
|
|
124
|
+
assert.doesNotMatch(text, /口播开头先给结论|口播开头规则/);
|
|
125
|
+
const win = runtime.debug.windowOf(agent);
|
|
126
|
+
assert.equal(win.runtime.task.kind, 'code');
|
|
127
|
+
assert.equal(win.runtime.task.seriesId, '');
|
|
128
|
+
assert.ok(win.experiment, 'experiment window is bound to the request');
|
|
129
|
+
assert.equal(win.experiment.decision.kind, 'rules');
|
|
130
|
+
assert.equal(win.experiment.experimentParts, null);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* R03:普通卡片与实验规则共用 3 条 / 1200 字节预算,先到先得、不截断。 */
|
|
134
|
+
{
|
|
135
|
+
const dir = mkdir('budget');
|
|
136
|
+
const scope = store.scopeOf(dir);
|
|
137
|
+
enable(scope, { seriesId: '口播', taskKind: 'content' });
|
|
138
|
+
for (let index = 0; index < 4; index += 1) {
|
|
139
|
+
save(scope, {
|
|
140
|
+
title: `口播节奏规范 ${index}`,
|
|
141
|
+
body: `第 ${index} 条正文,说明节奏和停顿。`,
|
|
142
|
+
conditions: [{ key: 'seriesId', value: '口播' }],
|
|
143
|
+
tags: ['口播'],
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
for (let index = 0; index < 4; index += 1) {
|
|
147
|
+
seedRule(scope, {
|
|
148
|
+
title: `口播实验规则 ${index}`,
|
|
149
|
+
body: `实验正文 ${index},开场结论。`,
|
|
150
|
+
summary: `实验规则 ${index}`,
|
|
151
|
+
conditions: [{ key: 'seriesId', value: '口播' }],
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const runtime = boot();
|
|
155
|
+
const agent = makeAgent('r03', dir);
|
|
156
|
+
const sig = signal();
|
|
157
|
+
await runtime.preStep({ agent, signal: sig, turn: 'r03' }, enter(human('这期口播开头怎么改')));
|
|
158
|
+
const text = runtime.text(agent, sig);
|
|
159
|
+
const win = runtime.debug.windowOf(agent);
|
|
160
|
+
const auto = win.autoBudget;
|
|
161
|
+
assert.ok(auto, 'shared budget exists');
|
|
162
|
+
assert.ok(auto.cards <= 3, `cards ${auto.cards} over cap`);
|
|
163
|
+
assert.ok(auto.cardBytes <= 1200, `bytes ${auto.cardBytes} over cap`);
|
|
164
|
+
assert.ok(auto.experimentBytes <= 1200, 'experiment bytes over cap');
|
|
165
|
+
assert.ok(auto.cardBytes <= 1200);
|
|
166
|
+
assert.ok(Buffer.byteLength(text) <= 1300, 'rendered text exceeds budget');
|
|
167
|
+
const heads = new Set(text.split('\n').filter((line) => /口播(节奏规范|实验规则)/.test(line)).map((line) => line.trim()));
|
|
168
|
+
assert.ok(heads.size <= 3, `too many cards: ${heads.size}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* R04:普通开关关闭时不再召回,但已有数据仍可被管理面读取。 */
|
|
172
|
+
{
|
|
173
|
+
const dir = mkdir('off');
|
|
174
|
+
const scope = store.scopeOf(dir);
|
|
175
|
+
const saved = save(scope, { title: '普通偏好', body: '回复用中文。', tags: ['偏好'] });
|
|
176
|
+
store.setMemoryEnabled(scope, false);
|
|
177
|
+
const runtime = boot();
|
|
178
|
+
const agent = makeAgent('r04', dir);
|
|
179
|
+
const sig = signal();
|
|
180
|
+
await runtime.preStep({ agent, signal: sig, turn: 'r04' }, enter(human('以后回复用什么语言')));
|
|
181
|
+
assert.equal(runtime.text(agent, sig), '');
|
|
182
|
+
const index = store.loadIndex(scope);
|
|
183
|
+
assert.ok(index.entries.some((entry) => entry.id === saved.id));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.log('runtime-context: all passed');
|
package/test/state.test.mjs
CHANGED
|
@@ -145,10 +145,10 @@ test('D02 响应丢了:同一 operationId 重放回执,不再写一遍', ()
|
|
|
145
145
|
assert.throws(() => transact(scope, { operationId: 'op-1', payloadDigest: 'digest-b' }, note), /不同内容/);
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
-
test('D08
|
|
148
|
+
test('D08 活进程占着锁:报忙,不猜也不抢(死 pid 的残留锁另行回收)', () => {
|
|
149
149
|
const scope = fresh('d08-lock');
|
|
150
150
|
fs.mkdirSync(scope.dir, { recursive: true });
|
|
151
|
-
fs.writeFileSync(path.join(scope.dir, '.lock'),
|
|
151
|
+
fs.writeFileSync(path.join(scope.dir, '.lock'), `${process.pid}:deadbeef`);
|
|
152
152
|
assert.throws(() => transact(scope, {}, addNote('一条', '正文')), (error) => error instanceof StateError && error.code === 'busy');
|
|
153
153
|
assert.equal(fs.existsSync(stateFile(scope)), false);
|
|
154
154
|
fs.rmSync(path.join(scope.dir, '.lock'));
|
|
@@ -481,5 +481,124 @@ test('写入重试不堆临时副本:固定临时名写前清理,成功后
|
|
|
481
481
|
assert.equal(JSON.parse(raw(scope)).payload.generation, next.state.generation);
|
|
482
482
|
});
|
|
483
483
|
|
|
484
|
+
|
|
485
|
+
/** 崩溃残留的 .lock:死 pid 能自动回收,活 pid 仍然 busy,迟到的新锁不能被误删。 */
|
|
486
|
+
const lockPath = (scope) => path.join(scope.dir, '.lock');
|
|
487
|
+
const writeLock = (scope, token) => {
|
|
488
|
+
fs.mkdirSync(scope.dir, { recursive: true });
|
|
489
|
+
fs.writeFileSync(lockPath(scope), token);
|
|
490
|
+
};
|
|
491
|
+
const deadPid = () => {
|
|
492
|
+
for (const pid of [999999, 999998, 999997, 888888]) {
|
|
493
|
+
try {
|
|
494
|
+
process.kill(pid, 0);
|
|
495
|
+
} catch (error) {
|
|
496
|
+
if (error.code === 'ESRCH') return pid;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return null;
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
test('崩溃残留的锁(死 pid)自动回收,不再卡 busy', () => {
|
|
503
|
+
const pid = deadPid();
|
|
504
|
+
if (pid === null) return;
|
|
505
|
+
const scope = fresh('stale-lock-dead');
|
|
506
|
+
writeLock(scope, `${pid}:deadbeef`);
|
|
507
|
+
const started = Date.now();
|
|
508
|
+
const result = withScopeLock(scope, () => 'locked');
|
|
509
|
+
const elapsed = Date.now() - started;
|
|
510
|
+
assert.equal(result, 'locked');
|
|
511
|
+
assert.ok(elapsed < 300, `回收要立刻重试,实际等了 ${elapsed}ms`);
|
|
512
|
+
assert.equal(fs.existsSync(lockPath(scope)), false, '退出时自己的锁要删掉');
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test('活 pid 的锁不回收:仍然报 busy,也不删别人的锁', () => {
|
|
516
|
+
const scope = fresh('stale-lock-live');
|
|
517
|
+
const token = `${process.pid}:other`;
|
|
518
|
+
writeLock(scope, token);
|
|
519
|
+
fs.utimesSync(lockPath(scope), new Date(0), new Date(0));
|
|
520
|
+
const started = Date.now();
|
|
521
|
+
assert.throws(() => withScopeLock(scope, () => 'never'), (error) => error.code === 'busy');
|
|
522
|
+
assert.ok(Date.now() - started >= LIMITS.lockWaitMs - 50, '活锁要等满等待窗口');
|
|
523
|
+
assert.equal(fs.readFileSync(lockPath(scope), 'utf8'), token, '活进程的锁一个字节都不能动');
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test('回收窗口里锁被换成活 token:不删别人的新锁,最终报 busy', () => {
|
|
527
|
+
const pid = deadPid();
|
|
528
|
+
if (pid === null) return;
|
|
529
|
+
const scope = fresh('stale-lock-race');
|
|
530
|
+
const stale = `${pid}:stale`;
|
|
531
|
+
const live = `${process.pid}:live`;
|
|
532
|
+
writeLock(scope, stale);
|
|
533
|
+
const originalRead = fs.readFileSync;
|
|
534
|
+
const originalWrite = fs.writeFileSync;
|
|
535
|
+
let swapped = false;
|
|
536
|
+
/**
|
|
537
|
+
* 真正注入竞态:回收函数第一次读到的还是死 pid 的 token,就在它读完之后、
|
|
538
|
+
* 二次校验之前,另一个进程把锁换成了活 token。回收必须放弃删除。
|
|
539
|
+
*/
|
|
540
|
+
fs.readFileSync = function (target, ...rest) {
|
|
541
|
+
const result = originalRead.call(fs, target, ...rest);
|
|
542
|
+
if (!swapped && String(target) === lockPath(scope)) {
|
|
543
|
+
swapped = true;
|
|
544
|
+
originalWrite.call(fs, lockPath(scope), live);
|
|
545
|
+
}
|
|
546
|
+
return result;
|
|
547
|
+
};
|
|
548
|
+
try {
|
|
549
|
+
assert.throws(() => withScopeLock(scope, () => 'never'), (error) => error.code === 'busy');
|
|
550
|
+
assert.equal(swapped, true, '竞态注入没生效,用例等于没测');
|
|
551
|
+
assert.equal(originalRead.call(fs, lockPath(scope), 'utf8'), live, '换过主人的锁不能被误删');
|
|
552
|
+
} finally {
|
|
553
|
+
fs.readFileSync = originalRead;
|
|
554
|
+
fs.rmSync(lockPath(scope), { force: true });
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test('P0-B 坏 state + 好 prev:从上一版恢复,坏字节留成副本,写入继续', () => {
|
|
559
|
+
const scope = fresh('p0b-recover');
|
|
560
|
+
transact(scope, { expectCreate: true }, addNote('第一条', '正文一'));
|
|
561
|
+
transact(scope, { expected: { generation: 1 } }, addNote('第二条', '正文二'));
|
|
562
|
+
const broken = '{"format":';
|
|
563
|
+
fs.writeFileSync(stateFile(scope), broken);
|
|
564
|
+
const first = load(scope);
|
|
565
|
+
assert.equal(first.recovered, true);
|
|
566
|
+
assert.equal(first.state.generation, 1, '采用上一版,不能凭空造新代数');
|
|
567
|
+
assert.equal(first.state.entries.length, 1);
|
|
568
|
+
assert.match(first.recovery.message, /已从上一版恢复/);
|
|
569
|
+
assert.equal(fs.readFileSync(first.recovery.corruptFile, 'utf8'), broken, '原始字节要原样保留');
|
|
570
|
+
assert.equal(fs.readFileSync(stateFile(scope), 'utf8'), broken, '读路径不许静默覆盖坏文件');
|
|
571
|
+
assert.equal(fs.readdirSync(scope.dir).filter((name) => name.startsWith('state.corrupt-')).length, 1);
|
|
572
|
+
load(scope);
|
|
573
|
+
assert.equal(fs.readdirSync(scope.dir).filter((name) => name.startsWith('state.corrupt-')).length, 1, '同一份坏字节不重复堆副本');
|
|
574
|
+
const written = transact(scope, { expected: { generation: 1 } }, addNote('第三条', '正文三'));
|
|
575
|
+
assert.equal(written.changed, true);
|
|
576
|
+
const after = load(scope);
|
|
577
|
+
assert.equal(after.recovered, false);
|
|
578
|
+
assert.equal(after.state.generation, 2);
|
|
579
|
+
assert.equal(after.state.entries.length, 2);
|
|
580
|
+
assert.doesNotThrow(() => JSON.parse(fs.readFileSync(path.join(scope.dir, 'state.prev.json'), 'utf8')), 'prev 不能被坏字节污染');
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test('P0-B 坏 state 且没有 prev:报错给出损坏副本与可执行的恢复路径', () => {
|
|
584
|
+
const scope = fresh('p0b-noprev');
|
|
585
|
+
fs.mkdirSync(scope.dir, { recursive: true });
|
|
586
|
+
const broken = '{"format":';
|
|
587
|
+
fs.writeFileSync(stateFile(scope), broken);
|
|
588
|
+
let error = null;
|
|
589
|
+
try {
|
|
590
|
+
load(scope);
|
|
591
|
+
} catch (caught) {
|
|
592
|
+
error = caught;
|
|
593
|
+
}
|
|
594
|
+
assert.ok(error, '坏 state 不能当空库');
|
|
595
|
+
assert.equal(error.code, 'corrupt');
|
|
596
|
+
assert.match(error.message, /state\.prev\.json/, '要给出上一版路径');
|
|
597
|
+
assert.match(error.message, /恢复步骤/);
|
|
598
|
+
assert.ok(error.recovery && error.recovery.corruptFile, '要指出损坏副本');
|
|
599
|
+
assert.equal(fs.readFileSync(error.recovery.corruptFile, 'utf8'), broken);
|
|
600
|
+
assert.equal(fs.readFileSync(stateFile(scope), 'utf8'), broken);
|
|
601
|
+
});
|
|
602
|
+
|
|
484
603
|
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
|
|
485
604
|
console.log(`state.test.mjs: ${passed} passed`);
|