@kira905/dsh-memory-toolkit 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/LICENSE +661 -0
- package/README.en.md +110 -0
- package/README.md +102 -2
- package/dsh-memory-local/FORK-BASE.json +24 -0
- package/dsh-memory-local/README.md +113 -0
- package/dsh-memory-local/build.mjs +101 -0
- package/dsh-memory-local/cordis.patch.yml +20 -0
- package/dsh-memory-local/deploy.mjs +306 -0
- package/dsh-memory-local/entity-status.mjs +75 -0
- package/dsh-memory-local/lib/bridge.mjs +108 -0
- package/dsh-memory-local/lib/host-adapter.js +1171 -0
- package/dsh-memory-local/lib/index.d.ts +128 -0
- package/dsh-memory-local/lib/index.js +944 -0
- package/dsh-memory-local/lib/injector.mjs +400 -0
- package/dsh-memory-local/lib/memory-read.mjs +116 -0
- package/dsh-memory-local/lib/module-gate.mjs +68 -0
- package/dsh-memory-local/lib/pointer.mjs +152 -0
- package/dsh-memory-local/lib/prod-gate.mjs +121 -0
- package/dsh-memory-local/lib/recall-log.mjs +118 -0
- package/dsh-memory-local/lib/recall.mjs +192 -0
- package/dsh-memory-local/lib/schema-migrate.mjs +365 -0
- package/dsh-memory-local/lib/user-message.mjs +32 -0
- package/dsh-memory-local/lib/vectors-read.mjs +110 -0
- package/dsh-memory-local/package.json +51 -0
- package/dsh-memory-local/restore.mjs +259 -0
- package/dsh-memory-local/rollback-injector-fix.mjs +166 -0
- package/dsh-memory-local/src/bridge.mjs +108 -0
- package/dsh-memory-local/src/host-adapter.js +1171 -0
- package/dsh-memory-local/src/index.d.ts +128 -0
- package/dsh-memory-local/src/index.js +944 -0
- package/dsh-memory-local/src/injector.mjs +400 -0
- package/dsh-memory-local/src/memory-read.mjs +116 -0
- package/dsh-memory-local/src/module-gate.mjs +68 -0
- package/dsh-memory-local/src/pointer.mjs +152 -0
- package/dsh-memory-local/src/recall-log.mjs +118 -0
- package/dsh-memory-local/src/recall.mjs +192 -0
- package/dsh-memory-local/src/schema-migrate.mjs +365 -0
- package/dsh-memory-local/src/user-message.mjs +32 -0
- package/dsh-memory-local/src/vectors-read.mjs +110 -0
- package/dsh-memory-local/test/fixtures-s2b.mjs +216 -0
- package/dsh-memory-local/test/probe-entity.mjs +170 -0
- package/dsh-memory-local/test/regression.mjs +270 -0
- package/dsh-memory-local/test/s2b.test.mjs +568 -0
- package/dsh-memory-local/test/schema-migrate.test.mjs +334 -0
- package/dsh-memory-local/test/test-adapter-converge.mjs +127 -0
- package/dsh-memory-local/test/visible-injections.test.mjs +239 -0
- package/dsh-memory-local/verify-after-restart.mjs +181 -0
- package/lib/deploy-guard.mjs +391 -0
- package/lib/guard-http.mjs +93 -0
- package/lib/pitfall-form-map.mjs +130 -0
- package/lib/pitfall-score.mjs +185 -0
- package/lib/session-file-name.mjs +160 -0
- package/lib/zstd-frames.mjs +78 -0
- package/memory-db-tools/README.md +68 -0
- package/memory-db-tools/_test/test-memory-scope.mjs +302 -0
- package/memory-db-tools/audit-analyze.mjs +352 -0
- package/memory-db-tools/audit-dump-ids.mjs +245 -0
- package/memory-db-tools/audit-export.mjs +270 -0
- package/memory-db-tools/audit-hit-probe.mjs +227 -0
- package/memory-db-tools/audit-ledger-probe.mjs +236 -0
- package/memory-db-tools/audit-persona-overlap.mjs +277 -0
- package/memory-db-tools/audit-struct-probe.mjs +213 -0
- package/memory-db-tools/audit-verify-unchanged.mjs +276 -0
- package/memory-db-tools/check-memory-db.mjs +8 -0
- package/memory-db-tools/dump-memory-db.mjs +12 -0
- package/memory-db-tools/lib/memory-scope.mjs +446 -0
- package/memory-db-tools/list-memory-summary.mjs +477 -0
- package/memory-db-tools/memory-identity-check.mjs +101 -0
- package/memory-db-tools/memory-schema-migrate.mjs +290 -0
- package/memory-db-tools/memory-scope-fixture.mjs +197 -0
- package/memory-db-tools/read-memories.mjs +26 -0
- package/memory-db-tools/read-memory-db.mjs +20 -0
- package/memory-db-tools/refine-memories.mjs +247 -0
- package/memory-db-tools/show-mem-times.mjs +22 -0
- package/memory-engine/README.md +70 -0
- package/memory-engine/bench/bench.mjs +221 -0
- package/memory-engine/cli/distill-rollback.mjs +86 -0
- package/memory-engine/cli/distill.mjs +156 -0
- package/memory-engine/cli/doc-index-rebuild.mjs +155 -0
- package/memory-engine/cli/doc-index.mjs +48 -0
- package/memory-engine/cli/domain-tags.mjs +251 -0
- package/memory-engine/cli/pitfall-hook.mjs +91 -0
- package/memory-engine/cli/recall.mjs +132 -0
- package/memory-engine/cli/selfcheck.mjs +57 -0
- package/memory-engine/cli/serve-start.mjs +247 -0
- package/memory-engine/cli/sweep.mjs +58 -0
- package/memory-engine/cli/sync.mjs +69 -0
- package/memory-engine/lib/cli-util.mjs +197 -0
- package/memory-engine/lib/config.mjs +135 -0
- package/memory-engine/lib/dirty-ledger.mjs +174 -0
- package/memory-engine/lib/distill-rollback.mjs +178 -0
- package/memory-engine/lib/distill.mjs +898 -0
- package/memory-engine/lib/doc-index-store.mjs +325 -0
- package/memory-engine/lib/doc-index.mjs +529 -0
- package/memory-engine/lib/doc-scan.mjs +271 -0
- package/memory-engine/lib/doc-summarize.mjs +305 -0
- package/memory-engine/lib/domain-tags.mjs +429 -0
- package/memory-engine/lib/embedder.mjs +242 -0
- package/memory-engine/lib/hashes.mjs +12 -0
- package/memory-engine/lib/memory-source.mjs +127 -0
- package/memory-engine/lib/pitfall-source.mjs +170 -0
- package/memory-engine/lib/policy-source.mjs +371 -0
- package/memory-engine/lib/recall.mjs +331 -0
- package/memory-engine/lib/selfcheck.mjs +131 -0
- package/memory-engine/lib/sweep.mjs +159 -0
- package/memory-engine/lib/sync.mjs +180 -0
- package/memory-engine/lib/tick-hook.mjs +218 -0
- package/memory-engine/lib/vectors-store.mjs +292 -0
- package/memory-engine/package-lock.json +1086 -0
- package/memory-engine/package.json +22 -0
- package/memory-engine/serve.mjs +171 -0
- package/memory-engine/test/01-store.test.mjs +127 -0
- package/memory-engine/test/02-sync.test.mjs +196 -0
- package/memory-engine/test/03-sweep.test.mjs +145 -0
- package/memory-engine/test/04-recall.test.mjs +214 -0
- package/memory-engine/test/05-selfcheck.test.mjs +175 -0
- package/memory-engine/test/06-doc-index.test.mjs +928 -0
- package/memory-engine/test/07-domain-tags.test.mjs +298 -0
- package/memory-engine/test/08-distill.test.mjs +686 -0
- package/memory-engine/test/09-pitfall-source.test.mjs +365 -0
- package/memory-engine/test/10-tick-hook.test.mjs +252 -0
- package/memory-engine/test/11-policy-source.test.mjs +224 -0
- package/memory-engine/test/_tickhook-worker.mjs +56 -0
- package/memory-engine/test/fixtures.mjs +205 -0
- package/memory-engine/test/harness.mjs +73 -0
- package/memory-engine/test/run.mjs +35 -0
- package/memory-engine/tools/bridge-smoke.mjs +80 -0
- package/memory-engine/tools/fetch-model.mjs +128 -0
- package/memory-engine/tools/smoke-recall.mjs +76 -0
- package/package.json +53 -4
- package/persona-memory-audit.mjs +217 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// 记忆引擎 v2 · 离线层 —— policy 来源(方案 §4 B 点)单测
|
|
2
|
+
//
|
|
3
|
+
// 覆盖:① persona 块标量抽取;② 段化规则(【…】/`- 【…】`/`##`、≤400 字硬上限、超长按句切);
|
|
4
|
+
// ③ 段身份锚(refId 稳定 / 唯一 / 与正文无关 ⇒ 改正文是"同一条换 text_hash");
|
|
5
|
+
// ④ 与 memory-source 同形状(sync/sweep 会按方法名硬调用);⑤ `sync --source policy` 端到端 + 幂等;
|
|
6
|
+
// ⑥ **默认行为不变**(不传 --source 仍走 memory)。
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { test, assert, assertEq, section } from './harness.mjs';
|
|
10
|
+
import { makeTempRoot, makeMemoryDb, sampleRows } from './fixtures.mjs';
|
|
11
|
+
import {
|
|
12
|
+
POLICY_MAX_CHARS,
|
|
13
|
+
extractPersonaText,
|
|
14
|
+
segmentText,
|
|
15
|
+
scanPolicySources,
|
|
16
|
+
openPolicySource,
|
|
17
|
+
policyRefId,
|
|
18
|
+
normalizeDocPath,
|
|
19
|
+
} from '../lib/policy-source.mjs';
|
|
20
|
+
import { openDataSource, KNOWN_SOURCES } from '../lib/cli-util.mjs';
|
|
21
|
+
import { openVectorsStore, countVectors, getMetaFor, META_KEYS } from '../lib/vectors-store.mjs';
|
|
22
|
+
import { runIncrementalSync } from '../lib/sync.mjs';
|
|
23
|
+
import { createStubEmbedder } from '../lib/embedder.mjs';
|
|
24
|
+
import { openMemorySource } from '../lib/memory-source.mjs';
|
|
25
|
+
import { openPitfallSource } from '../lib/pitfall-source.mjs';
|
|
26
|
+
|
|
27
|
+
export const __section = 'B 点 · policy 段源(抽取 / 段化 / 契约 / sync 端到端)';
|
|
28
|
+
|
|
29
|
+
const TMP = makeTempRoot('memv2-policy-');
|
|
30
|
+
|
|
31
|
+
/** 造一棵隔离语料树(persona + skills),返回 { personaDir, skillsRoot } */
|
|
32
|
+
function makeCorpus(name) {
|
|
33
|
+
const root = path.join(TMP.root, name);
|
|
34
|
+
const personaDir = path.join(root, '.agent-presets', 'demo');
|
|
35
|
+
const skillsRoot = path.join(root, 'skills');
|
|
36
|
+
fs.mkdirSync(personaDir, { recursive: true });
|
|
37
|
+
fs.mkdirSync(path.join(skillsRoot, 'demo-skill'), { recursive: true });
|
|
38
|
+
fs.writeFileSync(
|
|
39
|
+
path.join(personaDir, 'agent.cordis.yml'),
|
|
40
|
+
[
|
|
41
|
+
'# 注释行(不进 persona)',
|
|
42
|
+
'- id: persona',
|
|
43
|
+
' config:',
|
|
44
|
+
' prefix: |-',
|
|
45
|
+
' 你是本鱼。',
|
|
46
|
+
'',
|
|
47
|
+
' 【身份】软萌鲸鱼娘。',
|
|
48
|
+
'',
|
|
49
|
+
' 【协作偏好】',
|
|
50
|
+
' - 【规矩甲】甲甲甲。',
|
|
51
|
+
' - 【规矩乙】乙乙乙。',
|
|
52
|
+
'',
|
|
53
|
+
' 【能力】能干活。',
|
|
54
|
+
' suffix: |-',
|
|
55
|
+
' 不该被抽进来。',
|
|
56
|
+
].join('\n'),
|
|
57
|
+
'utf8',
|
|
58
|
+
);
|
|
59
|
+
fs.writeFileSync(
|
|
60
|
+
path.join(skillsRoot, 'demo-skill', 'SKILL.md'),
|
|
61
|
+
['---', 'name: demo-skill', '---', '# demo', '', '## 一、第一段', '内容内容。', '', '## 二、第二段', '更多内容。'].join('\n'),
|
|
62
|
+
'utf8',
|
|
63
|
+
);
|
|
64
|
+
return { root, personaDir, skillsRoot };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
section('① persona 块标量抽取(extractPersonaText)');
|
|
68
|
+
test('抽 prefix: |- 的块内容,遇到去缩进行即停(suffix 不进)', () => {
|
|
69
|
+
const { personaDir } = makeCorpus('c1');
|
|
70
|
+
const raw = fs.readFileSync(path.join(personaDir, 'agent.cordis.yml'), 'utf8');
|
|
71
|
+
const t = extractPersonaText(raw);
|
|
72
|
+
assert(t.includes('你是本鱼。'), '应含首行');
|
|
73
|
+
assert(t.includes('【身份】'), '应含 【身份】');
|
|
74
|
+
assert(t.includes('【规矩甲】'), '应含列表式 【规矩甲】');
|
|
75
|
+
assert(!t.includes('不该被抽进来'), 'suffix 块不得混入');
|
|
76
|
+
assert(!t.includes('# 注释行'), '块外注释不得混入');
|
|
77
|
+
});
|
|
78
|
+
test('抽不到块标量 ⇒ 退回整份文本(不抛错、不断链)', () => {
|
|
79
|
+
const t = extractPersonaText('随便一段文本\n没有块标量');
|
|
80
|
+
assertEq(t, '随便一段文本\n没有块标量');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
section('② 段化规则');
|
|
84
|
+
test('persona:行首 【…】 与 `- 【…】` 都算段起点', () => {
|
|
85
|
+
const text = '你是本鱼。\n\n【身份】甲。\n\n【偏好】\n- 【规矩甲】甲甲。\n- 【规矩乙】乙乙。\n';
|
|
86
|
+
const segs = segmentText(text, { kind: 'persona' });
|
|
87
|
+
const labels = segs.map((s) => s.section);
|
|
88
|
+
assert(labels.some((x) => x.includes('【身份】')), `应有 【身份】段:${labels.join(' | ')}`);
|
|
89
|
+
assert(labels.some((x) => x.includes('【规矩甲】')), `应有 【规矩甲】段:${labels.join(' | ')}`);
|
|
90
|
+
assert(labels.some((x) => x.includes('【规矩乙】')), `应有 【规矩乙】段:${labels.join(' | ')}`);
|
|
91
|
+
});
|
|
92
|
+
test('markdown:按 ## / ### 切小节', () => {
|
|
93
|
+
const segs = segmentText('前言\n\n## 甲\n甲甲\n\n### 乙\n乙乙\n', { kind: 'markdown' });
|
|
94
|
+
assert(segs.some((s) => s.section.startsWith('## 甲')), '应有 ## 甲');
|
|
95
|
+
assert(segs.some((s) => s.section.startsWith('### 乙')), '应有 ### 乙');
|
|
96
|
+
});
|
|
97
|
+
test('≤400 字硬上限:长段按句切,且无一段超上限', () => {
|
|
98
|
+
const long = `## 长段\n${'这是一句很长的话。'.repeat(80)}`;
|
|
99
|
+
const segs = segmentText(long, { kind: 'markdown' });
|
|
100
|
+
assert(segs.length > 1, `应被切成多段,实际 ${segs.length}`);
|
|
101
|
+
for (const s of segs) assert(s.text.length <= POLICY_MAX_CHARS, `段超上限:${s.text.length}`);
|
|
102
|
+
});
|
|
103
|
+
test('超长无标点块:硬切到上限内', () => {
|
|
104
|
+
const segs = segmentText('## 硬切\n' + 'x'.repeat(1000), { kind: 'markdown' });
|
|
105
|
+
assert(segs.length >= 3, `应硬切多段,实际 ${segs.length}`);
|
|
106
|
+
for (const s of segs) assert(s.text.length <= POLICY_MAX_CHARS, `段超上限:${s.text.length}`);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
section('③ 段身份锚(refId)');
|
|
110
|
+
test('refId 稳定:同输入两次扫描逐条相同', () => {
|
|
111
|
+
const { personaDir, skillsRoot } = makeCorpus('c3');
|
|
112
|
+
const a = scanPolicySources({ personaDir, skillsRoot });
|
|
113
|
+
const b = scanPolicySources({ personaDir, skillsRoot });
|
|
114
|
+
assertEq(a.map((x) => x.refId), b.map((x) => x.refId), 'refId 序列应逐条相同');
|
|
115
|
+
assert(a.length > 0, '应有段');
|
|
116
|
+
});
|
|
117
|
+
test('refId 唯一(同文件同名小节重复出现也不撞)', () => {
|
|
118
|
+
const { personaDir, skillsRoot } = makeCorpus('c3b');
|
|
119
|
+
const segs = scanPolicySources({ personaDir, skillsRoot });
|
|
120
|
+
const set = new Set(segs.map((s) => s.refId));
|
|
121
|
+
assertEq(set.size, segs.length, `refId 应唯一:${segs.length} 段 / ${set.size} 唯一`);
|
|
122
|
+
});
|
|
123
|
+
test('refId 只由「文件+段锚+分片号」决定 ⇒ 改正文不动 refId(增量重编码而非产生孤儿)', () => {
|
|
124
|
+
const id1 = policyRefId('/x/a.md', '## 甲#1', 1);
|
|
125
|
+
const id2 = policyRefId('/x/a.md', '## 甲#1', 1);
|
|
126
|
+
const id3 = policyRefId('/x/a.md', '## 甲#1', 2);
|
|
127
|
+
assertEq(id1, id2, '同参应同 id');
|
|
128
|
+
assert(id1 !== id3, '不同分片号应不同 id');
|
|
129
|
+
assertEq(id1.length, 16, '应为 16 位 hex');
|
|
130
|
+
assert(/^[0-9a-f]{16}$/.test(id1), '应为 hex');
|
|
131
|
+
});
|
|
132
|
+
test('normalizeDocPath:反斜杠转正斜杠 + 盘符小写', () => {
|
|
133
|
+
const winPath = path.win32.join('Q:', 'A', 'b', 'C.MD');
|
|
134
|
+
const expected = path.win32.join('q:', 'A', 'b', 'C.MD').replace(/\\/g, '/');
|
|
135
|
+
assertEq(normalizeDocPath(winPath), expected);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
section('④ 契约:与 memory-source / pitfall-source 同形状');
|
|
139
|
+
test('policy 源具备 sync/sweep/recall 硬调用的全部方法', () => {
|
|
140
|
+
const { personaDir, skillsRoot } = makeCorpus('c4');
|
|
141
|
+
const src = openPolicySource({ segments: scanPolicySources({ personaDir, skillsRoot }) });
|
|
142
|
+
for (const m of ['count', 'maxUpdatedAt', 'idRange', 'pinnedIds', 'incrementalPage', 'segment', 'getById', 'allTexts', 'allTags', 'ftsSearch', 'close']) {
|
|
143
|
+
assert(typeof src[m] === 'function', `缺方法 ${m}`);
|
|
144
|
+
}
|
|
145
|
+
assertEq(src.sourceName, 'policy');
|
|
146
|
+
assertEq(src.pinnedIds(), []);
|
|
147
|
+
assertEq(src.ftsSearch(), { error: 'policy 段源无 FTS5(policy source 无 FTS 补位路)' });
|
|
148
|
+
const r = src.idRange();
|
|
149
|
+
assert(r.n === src.count(), 'idRange.n 应等于 count()');
|
|
150
|
+
});
|
|
151
|
+
test('segment / incrementalPage 返回行必含 id/text/updated_at(缺 updated_at 会写 undefined 进向量)', () => {
|
|
152
|
+
const { personaDir, skillsRoot } = makeCorpus('c4b');
|
|
153
|
+
const src = openPolicySource({ segments: scanPolicySources({ personaDir, skillsRoot }) });
|
|
154
|
+
const rng = src.idRange();
|
|
155
|
+
const rows = src.segment(rng.lo, Math.min(rng.hi, rng.lo + 1)).concat(src.incrementalPage(0, null, 3));
|
|
156
|
+
assert(rows.length > 0, '应有行');
|
|
157
|
+
for (const r of rows) {
|
|
158
|
+
assert(r.id !== undefined && r.text !== undefined && r.updated_at !== undefined, `行字段缺失:${JSON.stringify(Object.keys(r))}`);
|
|
159
|
+
assert(typeof r.updated_at === 'number' && Number.isFinite(r.updated_at), 'updated_at 应为有限数字');
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
test('KNOWN_SOURCES 含 policy;openDataSource("policy") 可开、未知来源仍抛错', () => {
|
|
163
|
+
assert(KNOWN_SOURCES.includes('policy'), 'KNOWN_SOURCES 应含 policy');
|
|
164
|
+
const { personaDir, skillsRoot } = makeCorpus('c4c');
|
|
165
|
+
const src = openDataSource('policy', { policyPersonaDir: personaDir, policySkillsRoot: skillsRoot });
|
|
166
|
+
assert(src.sourceName === 'policy' && src.count() > 0, '应开出非空 policy 源');
|
|
167
|
+
let threw = false;
|
|
168
|
+
try { openDataSource('nope', {}); } catch { threw = true; }
|
|
169
|
+
assert(threw, '未知来源应抛错');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
section('⑤ sync --source policy 端到端 + 幂等 + 不串味');
|
|
173
|
+
test('sync policy:写入 source=policy,条数 = 段数;重跑同哈希跳过', async () => {
|
|
174
|
+
const { personaDir, skillsRoot } = makeCorpus('c5');
|
|
175
|
+
const segs = scanPolicySources({ personaDir, skillsRoot });
|
|
176
|
+
const store = openVectorsStore(path.join(TMP.root, 'c5', 'v.db'), { create: true });
|
|
177
|
+
const embedder = createStubEmbedder({ dim: 64, modelId: 'stub' });
|
|
178
|
+
const src = openPolicySource({ segments: segs });
|
|
179
|
+
const r1 = await runIncrementalSync({ source: src, store, embedder, modelId: 'stub', dim: 64, sourceName: 'policy' });
|
|
180
|
+
assertEq(r1.encoded, segs.length, '首次应全量编码');
|
|
181
|
+
assertEq(countVectors(store.db, 'policy'), segs.length, '应写入同条数向量');
|
|
182
|
+
assertEq(countVectors(store.db, 'memory'), 0, '不得写到 memory 源');
|
|
183
|
+
const r2 = await runIncrementalSync({ source: openPolicySource({ segments: segs }), store, embedder, modelId: 'stub', dim: 64, sourceName: 'policy' });
|
|
184
|
+
assertEq(r2.encoded, 0, '重跑应同哈希跳过(幂等,零重编码)');
|
|
185
|
+
assertEq(r2.vectorsAfter, segs.length, '向量条数应保持不变(无新增/重复)');
|
|
186
|
+
assertEq(r2.skippedSameHash, r2.scanned, '本轮扫到的应全部同哈希跳过');
|
|
187
|
+
// ⚠️ 语义说明(实测口径,别写成"每轮都全表重扫"):水位 = `max(updated_at)`,而 policy 段的
|
|
188
|
+
// `updated_at` = **文件 mtime** ⇒ 同文件的多段共享同一时间戳,水位一推进就把整个文件带过去了
|
|
189
|
+
// ⇒ 第二轮只重扫「mtime 最新的那一批」(可能少于全量)。这是**设计内**行为(与 memory 源同款
|
|
190
|
+
// watermark 语义),不变量是「不重编码、条数不变」;漏改由 `sweep --source policy` 按 text_hash 兜底。
|
|
191
|
+
assert(r2.scanned <= segs.length, `本轮扫描数不应超过全量:${r2.scanned} vs ${segs.length}`);
|
|
192
|
+
store.db.close();
|
|
193
|
+
});
|
|
194
|
+
test('跨源不串味:跑完 policy 后 memory 侧四类水位键不受影响', async () => {
|
|
195
|
+
const { personaDir, skillsRoot } = makeCorpus('c5b');
|
|
196
|
+
const vpath = path.join(TMP.root, 'c5b', 'v.db');
|
|
197
|
+
const mdb = makeMemoryDb(path.join(TMP.root, 'c5b', 'memory.db'), sampleRows(6));
|
|
198
|
+
const store = openVectorsStore(vpath, { create: true });
|
|
199
|
+
const embedder = createStubEmbedder({ dim: 32, modelId: 'stub' });
|
|
200
|
+
const mem = openMemorySource(mdb);
|
|
201
|
+
await runIncrementalSync({ source: mem, store, embedder, modelId: 'stub', dim: 32, sourceName: 'memory' });
|
|
202
|
+
const before = ['modelId', 'dim', 'lastIncrementalSyncAt', 'hashSweepCursor'].map((k) => getMetaFor(store.db, META_KEYS[k], 'memory'));
|
|
203
|
+
await runIncrementalSync({ source: openPolicySource({ segments: scanPolicySources({ personaDir, skillsRoot }) }), store, embedder, modelId: 'stub', dim: 32, sourceName: 'policy' });
|
|
204
|
+
const after = ['modelId', 'dim', 'lastIncrementalSyncAt', 'hashSweepCursor'].map((k) => getMetaFor(store.db, META_KEYS[k], 'memory'));
|
|
205
|
+
assertEq(after, before, '记忆侧四类水位键应逐字节不变');
|
|
206
|
+
assert(countVectors(store.db, 'policy') > 0, 'policy 应有向量');
|
|
207
|
+
store.db.close();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
section('⑥ 默认行为不变(不传 --source 仍走 memory)');
|
|
211
|
+
test('openDataSource("memory") 仍是记忆源、pitfall 仍是坑源', () => {
|
|
212
|
+
const mdb = makeMemoryDb(path.join(TMP.root, 'c6', 'memory.db'), sampleRows(4));
|
|
213
|
+
const msrc = openDataSource('memory', { memoryDb: mdb });
|
|
214
|
+
assertEq(msrc.sourceName, undefined, 'memory-source 无 sourceName 字段(旧形状不变)');
|
|
215
|
+
assertEq(msrc.count(), 4);
|
|
216
|
+
const pjson = path.join(TMP.root, 'c6', 'pitfalls.json');
|
|
217
|
+
fs.mkdirSync(path.dirname(pjson), { recursive: true });
|
|
218
|
+
fs.writeFileSync(pjson, JSON.stringify({ schemaVersion: 1, pitfalls: [{ id: 'a'.repeat(16), normText: 'x', lastSeen: 1 }] }), 'utf8');
|
|
219
|
+
const psrc = openDataSource('pitfall', { pitfallsFile: pjson });
|
|
220
|
+
assertEq(psrc.sourceName, 'pitfall');
|
|
221
|
+
assertEq(psrc.count(), 1);
|
|
222
|
+
const okP = openPitfallSource(pjson);
|
|
223
|
+
assertEq(typeof okP.count, 'function');
|
|
224
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// tick-hook 并发压测的「子进程工人」(只被 10-tick-hook.test.mjs 拉起)
|
|
2
|
+
// 用法:node _tickhook-worker.mjs <mark|consume> <markerPath> <durationMs>
|
|
3
|
+
// mark —— 循环置标记(每次写自己的 updatedAt),结束打印事件数与 errors
|
|
4
|
+
// consume —— 循环清标记(每次取 consumeStart),结束打印每次的 cleared/consumeStart/consumedUpdatedAt
|
|
5
|
+
// 设计:**捕异常计数上报**(不吞),事件列表截断到 2000 条上限(防超大 stdout)。
|
|
6
|
+
import { markDirty, clearMarkerIfStale } from '../lib/tick-hook.mjs';
|
|
7
|
+
|
|
8
|
+
const [mode, markerPath, durationRaw] = process.argv.slice(2);
|
|
9
|
+
const durationMs = Number(durationRaw) || 1500;
|
|
10
|
+
const deadline = Date.now() + durationMs;
|
|
11
|
+
const events = [];
|
|
12
|
+
const errors = [];
|
|
13
|
+
let n = 0;
|
|
14
|
+
let violations = 0;
|
|
15
|
+
let minAt = null;
|
|
16
|
+
let maxAt = null;
|
|
17
|
+
|
|
18
|
+
function note(at) {
|
|
19
|
+
if (minAt === null || at < minAt) minAt = at;
|
|
20
|
+
if (maxAt === null || at > maxAt) maxAt = at;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
if (mode === 'mark') {
|
|
25
|
+
while (Date.now() < deadline) {
|
|
26
|
+
const at = Date.now();
|
|
27
|
+
try {
|
|
28
|
+
markDirty(markerPath, { at, reason: `loadtest-${n}` });
|
|
29
|
+
n += 1;
|
|
30
|
+
note(at);
|
|
31
|
+
if (events.length < 2000) events.push(at);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
errors.push(`${e && e.code ? e.code : 'ERR'}:${String(e && e.message).slice(0, 80)}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
while (Date.now() < deadline) {
|
|
38
|
+
const consumeStart = Date.now();
|
|
39
|
+
try {
|
|
40
|
+
const r = clearMarkerIfStale(markerPath, consumeStart);
|
|
41
|
+
if (r.cleared && !(Number(r.consumedUpdatedAt) < consumeStart)) violations += 1;
|
|
42
|
+
if (r.cleared) note(Number(r.consumedUpdatedAt));
|
|
43
|
+
n += 1;
|
|
44
|
+
if (events.length < 2000) {
|
|
45
|
+
events.push({ cleared: r.cleared, reason: r.reason, consumeStart, consumedUpdatedAt: r.consumedUpdatedAt ?? null });
|
|
46
|
+
}
|
|
47
|
+
} catch (e) {
|
|
48
|
+
errors.push(`${e && e.code ? e.code : 'ERR'}:${String(e && e.message).slice(0, 80)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch (e) {
|
|
53
|
+
errors.push(`fatal:${String(e && e.stack).slice(0, 200)}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
process.stdout.write(`${JSON.stringify({ mode, n, violations, errors, minAt, maxAt, events })}\n`);
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// 记忆引擎 v2 · 离线层 —— 测试夹具
|
|
2
|
+
//
|
|
3
|
+
// 铁律:测试输出路径一律落 os.tmpdir() 下的临时目录(可注入),绝不写生产路径。
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
+
|
|
9
|
+
/** 建独立临时根;返回 { root, cleanup } */
|
|
10
|
+
export function makeTempRoot(prefix = 'dsh-mem-engine-test-') {
|
|
11
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
12
|
+
return {
|
|
13
|
+
root,
|
|
14
|
+
paths: {
|
|
15
|
+
memoryDb: path.join(root, 'memory', 'memory.db'),
|
|
16
|
+
vectorsDb: path.join(root, 'memory-vectors', 'vectors.db'),
|
|
17
|
+
stateFile: path.join(root, 'butler-memory-vectors-state.json'),
|
|
18
|
+
pitfallsFile: path.join(root, 'butler-pitfalls.json'),
|
|
19
|
+
pitfallDirtyFile: path.join(root, 'butler-pitfall-vector-dirty.json'),
|
|
20
|
+
modelDir: path.join(root, 'models'),
|
|
21
|
+
},
|
|
22
|
+
cleanup() {
|
|
23
|
+
try {
|
|
24
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
25
|
+
} catch {
|
|
26
|
+
/* ignore */
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DDL = `
|
|
33
|
+
CREATE TABLE memories (
|
|
34
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
35
|
+
text TEXT NOT NULL,
|
|
36
|
+
tags TEXT NOT NULL DEFAULT '',
|
|
37
|
+
pinned INTEGER NOT NULL DEFAULT 0,
|
|
38
|
+
created_at INTEGER NOT NULL,
|
|
39
|
+
updated_at INTEGER NOT NULL
|
|
40
|
+
);
|
|
41
|
+
CREATE VIRTUAL TABLE memories_fts USING fts5(text, tags, content='memories', content_rowid='id');
|
|
42
|
+
CREATE TRIGGER memories_ai AFTER INSERT ON memories BEGIN
|
|
43
|
+
INSERT INTO memories_fts(rowid, text, tags) VALUES (new.id, new.text, new.tags);
|
|
44
|
+
END;
|
|
45
|
+
CREATE TRIGGER memories_ad AFTER DELETE ON memories BEGIN
|
|
46
|
+
INSERT INTO memories_fts(memories_fts, rowid, text, tags) VALUES ('delete', old.id, old.text, old.tags);
|
|
47
|
+
END;
|
|
48
|
+
CREATE TRIGGER memories_au AFTER UPDATE ON memories BEGIN
|
|
49
|
+
INSERT INTO memories_fts(memories_fts, rowid, text, tags) VALUES ('delete', old.id, old.text, old.tags);
|
|
50
|
+
INSERT INTO memories_fts(rowid, text, tags) VALUES (new.id, new.text, new.tags);
|
|
51
|
+
END;
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 造夹具记忆库(与生产同构:memories + external content FTS5 + 3 触发器)。
|
|
56
|
+
* @param {string} dbPath
|
|
57
|
+
* @param {Array<{id?:number,text:string,tags?:string,pinned?:number,updated_at:number,created_at?:number}>} rows
|
|
58
|
+
*/
|
|
59
|
+
export function makeMemoryDb(dbPath, rows) {
|
|
60
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
61
|
+
if (fs.existsSync(dbPath)) fs.rmSync(dbPath);
|
|
62
|
+
const db = new DatabaseSync(dbPath);
|
|
63
|
+
db.exec(DDL);
|
|
64
|
+
const ins = db.prepare(
|
|
65
|
+
'INSERT INTO memories (id, text, tags, pinned, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
|
66
|
+
);
|
|
67
|
+
for (const r of rows) {
|
|
68
|
+
ins.run(
|
|
69
|
+
r.id ?? null,
|
|
70
|
+
r.text,
|
|
71
|
+
r.tags ?? '',
|
|
72
|
+
r.pinned ?? 0,
|
|
73
|
+
r.created_at ?? r.updated_at,
|
|
74
|
+
r.updated_at,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
db.close();
|
|
78
|
+
return dbPath;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 直接改 text 而**不动 updated_at**(复刻 A7 的 apply-pinned-refine.mjs 行为) */
|
|
82
|
+
export function silentTextUpdate(dbPath, id, newText) {
|
|
83
|
+
const db = new DatabaseSync(dbPath);
|
|
84
|
+
db.prepare('UPDATE memories SET text = ? WHERE id = ?').run(newText, Number(id));
|
|
85
|
+
db.close();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 直接改 pinned(测试排除 pinned 用) */
|
|
89
|
+
export function setPinned(dbPath, id, pinned) {
|
|
90
|
+
const db = new DatabaseSync(dbPath);
|
|
91
|
+
db.prepare('UPDATE memories SET pinned = ? WHERE id = ?').run(pinned ? 1 : 0, Number(id));
|
|
92
|
+
db.close();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 读取向量库里某条的 text_hash */
|
|
96
|
+
export function readVectorHash(db, source, refId) {
|
|
97
|
+
const r = db.prepare('SELECT text_hash FROM vectors WHERE source = ? AND ref_id = ?').get(source, String(refId));
|
|
98
|
+
return r ? r.text_hash : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─────────────────────────── 坑库夹具(2026-10-10 · 补坑库联动A) ───────────────────────────
|
|
102
|
+
|
|
103
|
+
/** 16 位 hex 稳定 id(与生产同形:sigKey sha1 前 16 位) */
|
|
104
|
+
export function pitfallId(i) {
|
|
105
|
+
return (i + 1).toString(16).padStart(16, '0');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 造 `butler-pitfalls.json` 夹具(与生产同构:`{schemaVersion,updatedAt,device,counters,pitfalls[],seedSources}`)。
|
|
110
|
+
* @param {Array<object>} rows 见 samplePitfalls
|
|
111
|
+
*/
|
|
112
|
+
export function makePitfallJson(jsonPath, rows) {
|
|
113
|
+
fs.mkdirSync(path.dirname(jsonPath), { recursive: true });
|
|
114
|
+
const doc = {
|
|
115
|
+
schemaVersion: 1,
|
|
116
|
+
updatedAt: Math.max(...rows.map((r) => r.lastSeen), 0),
|
|
117
|
+
device: 'TEST-HOST',
|
|
118
|
+
counters: { scans: 0, findings: rows.length, excluded: 0 },
|
|
119
|
+
pitfalls: rows,
|
|
120
|
+
seedSources: { keng: 0, persona: 0, memory: 0 },
|
|
121
|
+
};
|
|
122
|
+
fs.writeFileSync(jsonPath, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
|
|
123
|
+
return jsonPath;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 生成 N 条坑(含**同一 lastSeen 的平局条**,用来验稳定游标 = lastSeen 升序 + id 字典序)。
|
|
128
|
+
* 前 2 条按 `source:'seed'`(无 tools)造,验 seed 文本前缀分支。
|
|
129
|
+
*/
|
|
130
|
+
export function samplePitfalls(n, baseTs = 1_760_000_000_000) {
|
|
131
|
+
const rows = [];
|
|
132
|
+
for (let i = 0; i < n; i += 1) {
|
|
133
|
+
// 每 3 条共用同一 lastSeen(制造平局);其余递增
|
|
134
|
+
const lastSeen = baseTs + Math.floor(i / 3) * 1000;
|
|
135
|
+
const seed = i < 2;
|
|
136
|
+
rows.push({
|
|
137
|
+
id: pitfallId(i),
|
|
138
|
+
sigKey: `Error|NO_CODE|夹具坑 ${i}`,
|
|
139
|
+
name: seed ? '' : `Error${i}`,
|
|
140
|
+
code: seed ? '' : `E${i}`,
|
|
141
|
+
normText: `夹具坑 ${i}:${i % 2 ? '跨机同步挡推口径' : '看板卡验收双判据'}`,
|
|
142
|
+
category: i % 2 ? '同步' : '看板',
|
|
143
|
+
tools: seed ? {} : { edit: i + 1, bash: 1 },
|
|
144
|
+
count: i + 1,
|
|
145
|
+
firstSeen: lastSeen - 500,
|
|
146
|
+
lastSeen,
|
|
147
|
+
sessions: [],
|
|
148
|
+
devices: [],
|
|
149
|
+
samples: [],
|
|
150
|
+
source: seed ? 'seed' : 'scan',
|
|
151
|
+
excluded: 0,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return rows;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 造一个"第 N 次 encodeBatch 必炸"的编码器(测失败保留进度) */
|
|
158
|
+
export function makeFlakyEmbedder(base, { failOnBatchCall = 2 } = {}) {
|
|
159
|
+
let calls = 0;
|
|
160
|
+
return {
|
|
161
|
+
kind: 'flaky',
|
|
162
|
+
get modelId() {
|
|
163
|
+
return base.modelId;
|
|
164
|
+
},
|
|
165
|
+
get dim() {
|
|
166
|
+
return base.dim;
|
|
167
|
+
},
|
|
168
|
+
describe: () => ({ kind: 'flaky', wrapped: base.describe() }),
|
|
169
|
+
health: () => base.health(),
|
|
170
|
+
encode: (t) => base.encode(t),
|
|
171
|
+
async encodeBatch(texts) {
|
|
172
|
+
calls += 1;
|
|
173
|
+
if (calls === failOnBatchCall) throw new Error(`模拟编码崩溃(第 ${calls} 次 encodeBatch)`);
|
|
174
|
+
return base.encodeBatch(texts);
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 生成 N 条中文记忆(表意稳定、便于断言) */
|
|
180
|
+
export function sampleRows(n, baseTs = 1_760_000_000_000) {
|
|
181
|
+
const topics = [
|
|
182
|
+
'跨机同步的挡推裁决口径与三仓推送顺序',
|
|
183
|
+
'守护鱼崩溃链路与分身唤醒条件',
|
|
184
|
+
'看板卡的验收双判据与执行会话绑定',
|
|
185
|
+
'生图角色一致性参考图与提示词模板',
|
|
186
|
+
'记忆库的 FTS5 分词实测结论与召回策略',
|
|
187
|
+
'任务看板 ledger 并回与版本门',
|
|
188
|
+
'权限默认值的部署 override 注入路径',
|
|
189
|
+
'会话归档与 zstd 多帧修复流程',
|
|
190
|
+
'代理自控脚本的直连优先判断',
|
|
191
|
+
'本地 ComfyUI 栈的启动与模型切换',
|
|
192
|
+
];
|
|
193
|
+
const rows = [];
|
|
194
|
+
for (let i = 0; i < n; i += 1) {
|
|
195
|
+
rows.push({
|
|
196
|
+
id: i + 1,
|
|
197
|
+
text: `#${i + 1} ${topics[i % topics.length]}(第 ${i + 1} 条样本,含 task-board 与 3080 关键词)`,
|
|
198
|
+
tags: i % 3 === 0 ? 'task-board, 同步' : '技术',
|
|
199
|
+
pinned: 0,
|
|
200
|
+
created_at: baseTs + i * 1000,
|
|
201
|
+
updated_at: baseTs + i * 1000,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return rows;
|
|
205
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// 记忆引擎 v2 · 离线层 —— 零依赖测试 runner
|
|
2
|
+
const cases = [];
|
|
3
|
+
let passed = 0;
|
|
4
|
+
let failed = 0;
|
|
5
|
+
const failures = [];
|
|
6
|
+
|
|
7
|
+
export function test(name, fn) {
|
|
8
|
+
cases.push({ name, fn });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function skip(name, why = '') {
|
|
12
|
+
cases.push({ name, fn: null, why });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function runAll() {
|
|
16
|
+
for (const c of cases) {
|
|
17
|
+
if (!c.fn) {
|
|
18
|
+
console.log(` ⏭️ ${c.name}${c.why ? `(跳过:${c.why})` : ''}`);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const t0 = Date.now();
|
|
22
|
+
try {
|
|
23
|
+
await c.fn();
|
|
24
|
+
passed += 1;
|
|
25
|
+
console.log(` ✅ ${c.name} (${Date.now() - t0}ms)`);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
failed += 1;
|
|
28
|
+
const msg = String(e && e.stack ? e.stack : e);
|
|
29
|
+
failures.push({ name: c.name, msg });
|
|
30
|
+
console.log(` ❌ ${c.name}`);
|
|
31
|
+
console.log(` ${msg.split('\n').slice(0, 6).join('\n ')}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
console.log('');
|
|
35
|
+
console.log(`—— 合计 ${passed + failed} 项:通过 ${passed} / 失败 ${failed} ——`);
|
|
36
|
+
return { passed, failed, failures };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function assert(cond, msg = '断言失败') {
|
|
40
|
+
if (!cond) throw new Error(msg);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function assertEq(actual, expected, msg = '') {
|
|
44
|
+
// 数组/对象按 JSON 深度比较(否则 [0,1,0] !== [0,1,0] 会假红)
|
|
45
|
+
const deep = (actual !== null && typeof actual === 'object') || (expected !== null && typeof expected === 'object');
|
|
46
|
+
const same = deep ? JSON.stringify(actual) === JSON.stringify(expected) : actual === expected;
|
|
47
|
+
if (!same) {
|
|
48
|
+
throw new Error(`${msg ? msg + ':' : ''}期望 ${JSON.stringify(expected)},实际 ${JSON.stringify(actual)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function assertDeepEq(actual, expected, msg = '') {
|
|
53
|
+
const a = JSON.stringify(actual);
|
|
54
|
+
const b = JSON.stringify(expected);
|
|
55
|
+
if (a !== b) throw new Error(`${msg ? msg + ':' : ''}期望 ${b},实际 ${a}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function assertThrows(fn, msg = '期望抛错但未抛') {
|
|
59
|
+
let threw = null;
|
|
60
|
+
try {
|
|
61
|
+
await fn();
|
|
62
|
+
} catch (e) {
|
|
63
|
+
threw = e;
|
|
64
|
+
}
|
|
65
|
+
if (!threw) throw new Error(msg);
|
|
66
|
+
return threw;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function section(title) {
|
|
70
|
+
console.log(`\n▶ ${title}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export { passed, failed, failures };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// 记忆引擎 v2 · 离线层 —— 测试入口
|
|
2
|
+
// 用法:node test/run.mjs (全部)
|
|
3
|
+
// node test/run.mjs sync (只跑文件名含 sync 的)
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
7
|
+
import { runAll } from './harness.mjs';
|
|
8
|
+
|
|
9
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const filter = process.argv[2] || '';
|
|
11
|
+
const files = fs
|
|
12
|
+
.readdirSync(here)
|
|
13
|
+
.filter((f) => f.endsWith('.test.mjs'))
|
|
14
|
+
.filter((f) => !filter || f.includes(filter))
|
|
15
|
+
.sort();
|
|
16
|
+
|
|
17
|
+
console.log(`记忆引擎 v2 · 离线层 单测(夹具目录 = 系统临时区)`);
|
|
18
|
+
console.log(`node ${process.version} · ${process.platform} · ${osLabel()}`);
|
|
19
|
+
console.log(`用例文件:${files.join(', ') || '(无)'}`);
|
|
20
|
+
|
|
21
|
+
for (const f of files) {
|
|
22
|
+
const mod = await import(pathToFileURL(path.join(here, f)).href);
|
|
23
|
+
if (mod?.__section) console.log(`\n▶ ${mod.__section}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const res = await runAll();
|
|
27
|
+
process.exitCode = res.failed ? 1 : 0;
|
|
28
|
+
|
|
29
|
+
function osLabel() {
|
|
30
|
+
try {
|
|
31
|
+
return `${process.env.COMPUTERNAME || 'unknown-host'}`;
|
|
32
|
+
} catch {
|
|
33
|
+
return 'unknown-host';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// 记忆引擎 v2 · 离线层 —— 编码桥实测(方案 §3.1 编码通道契约)
|
|
2
|
+
//
|
|
3
|
+
// 同进程内起桥 → 打 /health 与 /encode → 校验端口顺延 → 关桥。
|
|
4
|
+
// 为什么不走 ssh 后台进程:SSH 会话结束会带走 detached 进程(跨机技能坑 3),
|
|
5
|
+
// 起与测必须同一条命令;本脚本把「起 + 测 + 关」放进同一个 node 进程,最省事也最可靠。
|
|
6
|
+
import { startBridge } from '../serve.mjs';
|
|
7
|
+
import { parseArgs, isMainEntry } from '../lib/cli-util.mjs';
|
|
8
|
+
|
|
9
|
+
async function waitReady(port, timeoutMs = 60000) {
|
|
10
|
+
const t0 = Date.now();
|
|
11
|
+
for (;;) {
|
|
12
|
+
try {
|
|
13
|
+
const h = await fetch(`http://127.0.0.1:${port}/health`).then((r) => r.json());
|
|
14
|
+
if (h.ready) return h;
|
|
15
|
+
if (h.lastError) throw new Error(`模型加载失败:${h.lastError}`);
|
|
16
|
+
} catch (e) {
|
|
17
|
+
if (Date.now() - t0 > timeoutMs) throw e;
|
|
18
|
+
}
|
|
19
|
+
if (Date.now() - t0 > timeoutMs) throw new Error('等待模型就绪超时');
|
|
20
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function main() {
|
|
25
|
+
const { flags } = parseArgs();
|
|
26
|
+
const modelDir = flags['model-dir'];
|
|
27
|
+
const wantPort = Number(flags.port || 8201);
|
|
28
|
+
const logs = [];
|
|
29
|
+
const log = (m) => {
|
|
30
|
+
logs.push(m);
|
|
31
|
+
console.log(m);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
console.log('=== 编码桥实测(仅回环) ===');
|
|
35
|
+
const b1 = await startBridge({ flags: { 'model-dir': modelDir, port: String(wantPort) }, log });
|
|
36
|
+
const b2 = await startBridge({ flags: { 'model-dir': modelDir, port: String(wantPort), warm: false }, log });
|
|
37
|
+
console.log(`端口顺延检查:首个=${b1.port},第二个=${b2.port}${b2.port === b1.port + 1 ? ' ✅' : ' ❌'}`);
|
|
38
|
+
|
|
39
|
+
const health0 = await fetch(`http://127.0.0.1:${b1.port}/health`).then((r) => r.json());
|
|
40
|
+
console.log(`/health(加载中):${JSON.stringify({ ready: health0.ready, modelLoading: health0.modelLoading, model: health0.model, dim: health0.dim })}`);
|
|
41
|
+
|
|
42
|
+
const ready = await waitReady(b1.port);
|
|
43
|
+
console.log(`/health(就绪):${JSON.stringify({ ready: ready.ready, model: ready.model, dim: ready.dim, port: ready.port })}`);
|
|
44
|
+
|
|
45
|
+
const t0 = performance.now();
|
|
46
|
+
const enc = await fetch(`http://127.0.0.1:${b1.port}/encode`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'content-type': 'application/json' },
|
|
49
|
+
body: JSON.stringify({ texts: ['跨机同步的挡推裁决', '整夜干活'] }),
|
|
50
|
+
}).then((r) => r.json());
|
|
51
|
+
const ms = performance.now() - t0;
|
|
52
|
+
console.log(`/encode 2 条:${ms.toFixed(1)}ms · model=${enc.model} dim=${enc.dim} · 首向量前 5 维 ${enc.vectors[0].slice(0, 5).map((x) => x.toFixed(4)).join(', ')}`);
|
|
53
|
+
const norm = Math.sqrt(enc.vectors[0].reduce((s, x) => s + x * x, 0));
|
|
54
|
+
console.log(`首向量 L2 范数:${norm.toFixed(6)}(契约要求归一化 ⇒ 应 ≈1)`);
|
|
55
|
+
|
|
56
|
+
const bad = await fetch(`http://127.0.0.1:${b1.port}/encode`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'content-type': 'application/json' },
|
|
59
|
+
body: JSON.stringify({ nope: 1 }),
|
|
60
|
+
}).then((r) => ({ status: r.status, body: r.json() }));
|
|
61
|
+
const badRes = await bad.body;
|
|
62
|
+
console.log(`坏请求处理:HTTP ${bad.status} ${JSON.stringify(badRes)}`);
|
|
63
|
+
|
|
64
|
+
const nonLoop = await (async () => {
|
|
65
|
+
try {
|
|
66
|
+
await startBridge({ flags: { 'model-dir': modelDir, bind: '0.0.0.0' }, log: () => {} });
|
|
67
|
+
return '未拦截 ❌';
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return `已拦截 ✅(${String(e.message).slice(0, 40)}…)`;
|
|
70
|
+
}
|
|
71
|
+
})();
|
|
72
|
+
console.log(`非回环绑定拦截:${nonLoop}`);
|
|
73
|
+
|
|
74
|
+
await b2.close();
|
|
75
|
+
await b1.close();
|
|
76
|
+
console.log('桥已关闭');
|
|
77
|
+
process.exitCode = 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (isMainEntry(import.meta.url)) await main();
|