@kira905/dsh-memory-toolkit 0.0.0-stage → 0.1.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/CHANGELOG.md +32 -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 +294 -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,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FORK-EDIT(2026-09-21): 注入编排(方案 §3.4 注入层 / §6.1 指针注入链 / §7 fail-open)
|
|
3
|
+
*
|
|
4
|
+
* 契约:
|
|
5
|
+
* · 触发:仅 `step === 1` + user 文本非空(短暗号也算,方案 §3.3 Z16);纯符号/单字跳过
|
|
6
|
+
* · 召回:S1 编码桥(POST /encode)+ 向量为主 + FTS 补位 + RRF(k=60) + **排除 pinned**
|
|
7
|
+
* · 预算:条数 ≤8、总量 ≤800 字 / ≤600 token(方案 §8.2 绝对量硬判据)
|
|
8
|
+
* · 去重:**以 surface 可见性为准**(压缩是遮蔽 seq 区间、不是删消息)+ 保守退化
|
|
9
|
+
* 「每会话最多注入 N 次」(默认 3)+ 回合级一次性(step 多次只注入一次)
|
|
10
|
+
* · fail-open:任一环节异常/超时 ⇒ 不注入 + 写日志(绝不阻塞对话)
|
|
11
|
+
*/
|
|
12
|
+
import * as recallImpl from './recall.mjs';
|
|
13
|
+
import { renderPointer } from './pointer.mjs';
|
|
14
|
+
import { openMemoryReader } from './memory-read.mjs';
|
|
15
|
+
import { openVectorsReader } from './vectors-read.mjs';
|
|
16
|
+
import { createBridgeClient } from './bridge.mjs';
|
|
17
|
+
// FORK-EDIT(2026-09-22): U3 adapter 接入 —— 事件名收敛到适配层唯一源(值恒等,零语义变化)。
|
|
18
|
+
import { EV } from './host-adapter.js';
|
|
19
|
+
|
|
20
|
+
export const SOURCE_KIND = 'memory-recall';
|
|
21
|
+
export const SOURCE_VERSION = 1;
|
|
22
|
+
|
|
23
|
+
/** 从 claimed 批次里取本轮最后一条 user 文本(方案 §3.3 查询构造)。 */
|
|
24
|
+
export function lastUserText(messages) {
|
|
25
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
26
|
+
for (let i = list.length - 1; i >= 0; i -= 1) {
|
|
27
|
+
const m = list[i];
|
|
28
|
+
if (!m || m.role !== 'user') continue;
|
|
29
|
+
const content = Array.isArray(m.content) ? m.content : [];
|
|
30
|
+
const texts = content
|
|
31
|
+
.filter((c) => c && c.type === 'text' && typeof c.text === 'string')
|
|
32
|
+
.map((c) => c.text);
|
|
33
|
+
const joined = texts.join('\n').trim();
|
|
34
|
+
if (joined) return joined;
|
|
35
|
+
}
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* surface 可见性:数出**当前可见 surface** 里本插件注入过的 user 消息(方案 §3.4 Z12)。
|
|
41
|
+
* 压缩遮蔽的 seq 区间不出现在 surface.nodes 里 ⇒ 被遮蔽的历史注入不计入 ⇒ 允许重新注入。
|
|
42
|
+
*
|
|
43
|
+
* FORK-EDIT(2026-10-09 · 冲刺600-M1 卡《注入去重修复》):取事件面改为**新面优先、老线回落**。
|
|
44
|
+
* 背景(上卡 §6 实测坐实的升级遗留缺陷):主包 0.1.5-rc.2 的 `Session` 已删 `get events()`
|
|
45
|
+
* (只剩 `eventAt(seq)` / `snapshotEvents()`),老写法 `session.events[seq]` ⇒ `session.events`
|
|
46
|
+
* 恒 `undefined` ⇒ 本函数开头守卫直接 return `{count:0}` ⇒ `maxPerSession` 与
|
|
47
|
+
* `surface-same-payload` 两道去重**永不触发**(每个回合都注入一次)。
|
|
48
|
+
* 两面对照(静态取证,同构):0.1.5-rc.2 `eventAt(seq) { return this.log[seq] }`
|
|
49
|
+
* (`@deepseek-ai/dsh-session/lib/index.js:1096`);老面 `get events()` 返回的是**同一个**
|
|
50
|
+
* `this.log` 数组 ⇒ `events[seq]` 与 `eventAt(seq)` 取到同一个 `{seq, type, data}` 事件对象。
|
|
51
|
+
* `surface.nodes` 语义未变(0.1.5-rc.2 仍是 seq 数组:`lib/index.js:480` getter、
|
|
52
|
+
* `lib/types/surface.d.ts:94` `readonly nodes: readonly SessionSeq[]`)。
|
|
53
|
+
* ⚠️ 本卡只改这一处:不碰注入阈值 / 预算 / 去重语义 / 其它文件。
|
|
54
|
+
* @returns {{count:number, lastText:string|null, ids:Set<string>}}
|
|
55
|
+
*/
|
|
56
|
+
export function visibleInjections(agent, kind = SOURCE_KIND) {
|
|
57
|
+
const out = { count: 0, lastText: null, ids: new Set() };
|
|
58
|
+
const session = agent && agent.session;
|
|
59
|
+
const nodes = session && session.surface && Array.isArray(session.surface.nodes) ? session.surface.nodes : null;
|
|
60
|
+
const hasEventAt = Boolean(session) && typeof session.eventAt === 'function';
|
|
61
|
+
const events = session ? session.events : undefined;
|
|
62
|
+
// 守卫:只有 nodes 不可用、或「两种取事件面都不可用」才早退
|
|
63
|
+
// (不得因 0.1.5 起 `session.events` 缺失而恒判 0)
|
|
64
|
+
if (!nodes || (!hasEventAt && !events)) return out;
|
|
65
|
+
for (const seq of nodes) {
|
|
66
|
+
const ev = hasEventAt ? session.eventAt(seq) : events[seq];
|
|
67
|
+
if (!ev || ev.type !== EV.USER_MESSAGE) continue;
|
|
68
|
+
const data = ev.data;
|
|
69
|
+
if (!data || !data.source || data.source.kind !== kind) continue;
|
|
70
|
+
out.count += 1;
|
|
71
|
+
const text = (Array.isArray(data.content) ? data.content : [])
|
|
72
|
+
.filter((c) => c && c.type === 'text' && typeof c.text === 'string')
|
|
73
|
+
.map((c) => c.text)
|
|
74
|
+
.join('\n');
|
|
75
|
+
if (text) out.lastText = text;
|
|
76
|
+
if (Array.isArray(data.source.items)) for (const id of data.source.items) out.ids.add(String(id));
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 硬超时包装(超时即放弃,不排队、不重试,方案 §3.4/§6.1 ③)。 */
|
|
82
|
+
export function withTimeout(promise, ms) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
let done = false;
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
if (!done) {
|
|
87
|
+
done = true;
|
|
88
|
+
resolve({ timedOut: true });
|
|
89
|
+
}
|
|
90
|
+
}, Math.max(1, Number(ms) || 1));
|
|
91
|
+
promise.then(
|
|
92
|
+
(v) => {
|
|
93
|
+
if (!done) {
|
|
94
|
+
done = true;
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
resolve(v);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
(e) => {
|
|
100
|
+
if (!done) {
|
|
101
|
+
done = true;
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
resolve({ error: String(e && e.message ? e.message : e) });
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {object} opts
|
|
112
|
+
* @param {object} opts.config 已解析参数(见 index.js 的 resolveRecallConfig)
|
|
113
|
+
* @param {object} opts.logger createRecallLogger() 产物(可注入内存实现)
|
|
114
|
+
* @param {object} [opts.moduleGate] createModuleGate() 产物
|
|
115
|
+
* @param {object} [opts.bridge] 覆盖桥实现(单测注入 stub)
|
|
116
|
+
* @param {object} [opts.deps] { recall, openMemoryReader, openVectorsReader, now }
|
|
117
|
+
*/
|
|
118
|
+
export function createRecallInjector({
|
|
119
|
+
config,
|
|
120
|
+
logger,
|
|
121
|
+
moduleGate = null,
|
|
122
|
+
bridge = null,
|
|
123
|
+
deps = {},
|
|
124
|
+
} = {}) {
|
|
125
|
+
const _recall = deps.recall || recallImpl;
|
|
126
|
+
const _openMemory = deps.openMemoryReader || openMemoryReader;
|
|
127
|
+
const _openVectors = deps.openVectorsReader || openVectorsReader;
|
|
128
|
+
const now = deps.now || (() => Date.now());
|
|
129
|
+
|
|
130
|
+
let memory = null;
|
|
131
|
+
let vectors = null;
|
|
132
|
+
let index = null;
|
|
133
|
+
let indexAt = 0;
|
|
134
|
+
const turnMark = new WeakMap(); // agent → 已注入的 turn(回合级一次性)
|
|
135
|
+
const stats = { decided: 0, injected: 0, skipped: 0, timeouts: 0, errors: 0 };
|
|
136
|
+
|
|
137
|
+
const br = bridge || (
|
|
138
|
+
config.enabled
|
|
139
|
+
? createBridgeClient({
|
|
140
|
+
host: config.bridgeHost,
|
|
141
|
+
port: config.bridgePort,
|
|
142
|
+
portScanMax: config.bridgePortScanMax,
|
|
143
|
+
encodeTimeoutMs: config.encodeTimeoutMs,
|
|
144
|
+
})
|
|
145
|
+
: null
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const openMemory = () => {
|
|
149
|
+
if (!memory) memory = _openMemory(config.memoryDb);
|
|
150
|
+
return memory;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const openVectors = () => {
|
|
154
|
+
if (!vectors) vectors = _openVectors(config.vectorsDb);
|
|
155
|
+
return vectors;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const getIndex = () => {
|
|
159
|
+
const ttl = config.indexTtlMs;
|
|
160
|
+
if (index && now() - indexAt < ttl) return index;
|
|
161
|
+
const v = openVectors();
|
|
162
|
+
index = _recall.buildVectorIndex
|
|
163
|
+
? _recall.buildVectorIndex(v.db, { source: 'memory' })
|
|
164
|
+
: null;
|
|
165
|
+
indexAt = now();
|
|
166
|
+
return index;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const close = () => {
|
|
170
|
+
try {
|
|
171
|
+
if (memory) memory.close();
|
|
172
|
+
} catch {
|
|
173
|
+
/* ignore */
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
if (vectors) vectors.close();
|
|
177
|
+
} catch {
|
|
178
|
+
/* ignore */
|
|
179
|
+
}
|
|
180
|
+
memory = null;
|
|
181
|
+
vectors = null;
|
|
182
|
+
index = null;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** 门与依赖状态(只读,诊断用)。 */
|
|
186
|
+
const status = () => {
|
|
187
|
+
const gate = moduleGate ? moduleGate.check() : { enabled: true, reason: 'no-gate' };
|
|
188
|
+
return {
|
|
189
|
+
enabled: Boolean(config.enabled),
|
|
190
|
+
module: gate,
|
|
191
|
+
bridge: br ? { host: br.host, port: br.port, stats: br.stats ? br.stats() : null } : null,
|
|
192
|
+
cache: { memory: Boolean(memory), vectors: Boolean(vectors), index: index ? index.count : 0 },
|
|
193
|
+
stats: { ...stats },
|
|
194
|
+
config: {
|
|
195
|
+
memoryDb: config.memoryDb,
|
|
196
|
+
vectorsDb: config.vectorsDb,
|
|
197
|
+
logPath: logger ? logger.path : null,
|
|
198
|
+
timeoutMs: config.timeoutMs,
|
|
199
|
+
encodeTimeoutMs: config.encodeTimeoutMs,
|
|
200
|
+
maxItems: config.maxItems,
|
|
201
|
+
maxChars: config.maxChars,
|
|
202
|
+
maxTokens: config.maxTokens,
|
|
203
|
+
maxPerSession: config.maxPerSession,
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 单轮决策。**除日志外无副作用**;所有异常都转成 skip。
|
|
210
|
+
* @returns {Promise<{action:'inject'|'skip', reason:string, text?:string, meta?:object}>}
|
|
211
|
+
*/
|
|
212
|
+
const decide = async ({ query, agent, turn, step, sessionId = null, __flag = null, budgetMs = null }) => {
|
|
213
|
+
stats.decided += 1;
|
|
214
|
+
const t0 = now();
|
|
215
|
+
const budget = Number.isFinite(Number(budgetMs)) && Number(budgetMs) > 0 ? Number(budgetMs) : config.timeoutMs;
|
|
216
|
+
const elapsed = () => now() - t0;
|
|
217
|
+
/** 预算复核(**不依赖 setTimeout**:长同步段会把定时器回调推到预算之后)。 */
|
|
218
|
+
const overBudget = () => budget > 0 && elapsed() > budget;
|
|
219
|
+
const base = { sessionId, turn: turn ?? null, step: step ?? null };
|
|
220
|
+
const skip = (reason, extra = {}) => {
|
|
221
|
+
stats.skipped += 1;
|
|
222
|
+
// 硬超时已放弃本轮 ⇒ 迟到结束的内层链**不再补写日志**(否则一次超时会留下两行,污染统计口径)
|
|
223
|
+
if (__flag && __flag.abandoned) return { action: 'skip', reason: 'abandoned-silent', meta: null };
|
|
224
|
+
const rec = { event: 'skip', reason, ms: elapsed(), ...base, ...extra };
|
|
225
|
+
if (logger) logger.log(rec);
|
|
226
|
+
return { action: 'skip', reason, meta: rec };
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
if (!config.enabled) return skip('plugin-disabled');
|
|
231
|
+
if (step !== 1) return skip('not-step-1');
|
|
232
|
+
if (moduleGate && !moduleGate.check().enabled) {
|
|
233
|
+
return skip(`module-drain:${moduleGate.check().reason}`);
|
|
234
|
+
}
|
|
235
|
+
const q = String(query ?? '');
|
|
236
|
+
const gate = _recall.shouldTrigger(q);
|
|
237
|
+
if (!gate.trigger) return skip(`query-${gate.reason}`, { chars: q.length });
|
|
238
|
+
if (agent && turn != null && turnMark.get(agent) === turn) return skip('turn-already-injected');
|
|
239
|
+
|
|
240
|
+
const seen = visibleInjections(agent);
|
|
241
|
+
if (seen.count >= config.maxPerSession) {
|
|
242
|
+
return skip('surface-visible-limit', { visible: seen.count, limit: config.maxPerSession });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const health = await br.probe();
|
|
246
|
+
if (!health || !health.ok) {
|
|
247
|
+
return skip('bridge-unavailable', { bridge: health ? health.error : 'no-client' });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let source;
|
|
251
|
+
let store;
|
|
252
|
+
try {
|
|
253
|
+
source = openMemory();
|
|
254
|
+
store = openVectors();
|
|
255
|
+
} catch (e) {
|
|
256
|
+
return skip('datastore-unavailable', { error: String(e && e.message ? e.message : e) });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const idx = getIndex();
|
|
260
|
+
const qtext = q.length > config.queryMaxChars ? q.slice(0, config.queryMaxChars) : q;
|
|
261
|
+
const r = await _recall.recall({
|
|
262
|
+
query: qtext,
|
|
263
|
+
store,
|
|
264
|
+
source,
|
|
265
|
+
embedder: br,
|
|
266
|
+
index: idx,
|
|
267
|
+
vectorTopK: config.vectorTopK,
|
|
268
|
+
ftsTopK: config.ftsTopK,
|
|
269
|
+
rrfK: config.rrfK,
|
|
270
|
+
resultTopK: config.resultTopK,
|
|
271
|
+
});
|
|
272
|
+
if (!r.triggered) return skip(`recall-${r.reason}`);
|
|
273
|
+
|
|
274
|
+
const ids = r.hits.map((h) => h.id);
|
|
275
|
+
const rows = source.rowsByIds(ids);
|
|
276
|
+
const byId = new Map(rows.map((row) => [Number(row.id), row]));
|
|
277
|
+
const ordered = ids.map((id) => byId.get(Number(id))).filter(Boolean);
|
|
278
|
+
const rendered = renderPointer(ordered, {
|
|
279
|
+
maxItems: config.maxItems,
|
|
280
|
+
maxChars: config.maxChars,
|
|
281
|
+
maxTokens: config.maxTokens,
|
|
282
|
+
summaryChars: config.summaryChars,
|
|
283
|
+
});
|
|
284
|
+
if (!rendered.text || !rendered.items.length) return skip('no-renderable-hits');
|
|
285
|
+
|
|
286
|
+
if (seen.lastText && seen.lastText === rendered.text) {
|
|
287
|
+
return skip('surface-same-payload', { visible: seen.count });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (agent && turn != null) turnMark.set(agent, turn);
|
|
291
|
+
stats.injected += 1;
|
|
292
|
+
const meta = {
|
|
293
|
+
event: 'inject',
|
|
294
|
+
reason: 'ok',
|
|
295
|
+
items: rendered.items.length,
|
|
296
|
+
ids: rendered.items.map((i) => i.id),
|
|
297
|
+
chars: rendered.chars,
|
|
298
|
+
estTokens: rendered.estTokens,
|
|
299
|
+
worstCaseTokens: rendered.worstCaseTokens,
|
|
300
|
+
dropped: rendered.dropped,
|
|
301
|
+
visibleBefore: seen.count,
|
|
302
|
+
hits: r.hits.length,
|
|
303
|
+
sources: r.hits.map((h) => h.sources.join('+')),
|
|
304
|
+
timings: r.timings,
|
|
305
|
+
indexSize: r.stats.indexSize,
|
|
306
|
+
bridgePort: health.port,
|
|
307
|
+
model: health.model,
|
|
308
|
+
ms: now() - t0,
|
|
309
|
+
...base,
|
|
310
|
+
};
|
|
311
|
+
// 硬超时已放弃 / 已超预算 ⇒ 本轮不注入(调用方拿到的是 skip),也不落 inject 行
|
|
312
|
+
if (__flag && __flag.abandoned) return { action: 'skip', reason: 'abandoned-silent', meta: null };
|
|
313
|
+
if (overBudget()) return skip('timeout', { lateTimer: true, budgetMs: budget });
|
|
314
|
+
if (logger) logger.log(meta);
|
|
315
|
+
return { action: 'inject', reason: 'ok', text: rendered.text, meta };
|
|
316
|
+
} catch (e) {
|
|
317
|
+
stats.errors += 1;
|
|
318
|
+
return skip('exception', { error: String(e && e.stack ? e.stack : e) });
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/** 带硬超时的决策(超时 ⇒ skip + 日志;被放弃的 promise 结果被丢弃,且**不再补写日志**)。
|
|
323
|
+
*
|
|
324
|
+
* ⚠️ 为什么还要「事后 elapsed 复核」(2026-09-21 真机实测修正):
|
|
325
|
+
* `setTimeout` 的计时器回调在**事件循环被长同步段(首次向量矩阵构建 + 余弦全扫)阻塞**时会被推迟,
|
|
326
|
+
* 于是 `withTimeout` 可能在预算之后才 settle,把一次 524ms 的决策当成成功返回 —— 实测踩到过一次。
|
|
327
|
+
* ⇒ 双判据:定时器先到 或 **返回时 elapsed > 预算** 都算超时,一律不注入(fail-open,宁可不注入)。
|
|
328
|
+
*/
|
|
329
|
+
const decideWithTimeout = async (args) => {
|
|
330
|
+
const t0 = now();
|
|
331
|
+
const flag = { abandoned: false };
|
|
332
|
+
const out = await withTimeout(decide({ ...args, __flag: flag, budgetMs: config.timeoutMs }), config.timeoutMs);
|
|
333
|
+
const elapsed = now() - t0;
|
|
334
|
+
const overBudget = !(out && (out.timedOut || out.error)) && elapsed > config.timeoutMs;
|
|
335
|
+
if ((out && out.timedOut) || overBudget) {
|
|
336
|
+
stats.timeouts += 1;
|
|
337
|
+
flag.abandoned = true; // 通知迟到完成的内层链:本轮已放弃,别写日志
|
|
338
|
+
const rec = {
|
|
339
|
+
event: 'skip',
|
|
340
|
+
reason: 'timeout',
|
|
341
|
+
ms: elapsed,
|
|
342
|
+
budgetMs: config.timeoutMs,
|
|
343
|
+
lateTimer: Boolean(overBudget),
|
|
344
|
+
sessionId: args ? args.sessionId : null,
|
|
345
|
+
turn: args ? args.turn : null,
|
|
346
|
+
step: args ? args.step : null,
|
|
347
|
+
};
|
|
348
|
+
if (logger) logger.log(rec);
|
|
349
|
+
return { action: 'skip', reason: 'timeout', meta: rec };
|
|
350
|
+
}
|
|
351
|
+
if (out && out.error) {
|
|
352
|
+
stats.errors += 1;
|
|
353
|
+
const rec = { event: 'skip', reason: 'exception', error: out.error, ms: now() - t0 };
|
|
354
|
+
if (logger) logger.log(rec);
|
|
355
|
+
return { action: 'skip', reason: 'exception', meta: rec };
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
/** `memory_get` 展开留痕(方案 §8.2 展开率)。只记元数据,不记正文。 */
|
|
361
|
+
const logExpand = ({ id, chars, truncated, sessionId = null, found = true }) => {
|
|
362
|
+
if (!logger) return;
|
|
363
|
+
logger.log({ event: 'expand', id: Number(id), chars: Number(chars) || 0, truncated: Boolean(truncated), found: Boolean(found), sessionId });
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
/** 只读取一条记忆全文(`memory_get` 用;复用召回侧的只读连接)。 */
|
|
367
|
+
const getById = (id) => openMemory().getById(id);
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* 预热:插件启用时在后台先把向量矩阵读进内存 + 探一次桥。
|
|
371
|
+
* 动机(2026-09-21 真机实测):首次注入要同步构建 622×512 矩阵(几十~上百 ms),
|
|
372
|
+
* 叠上探活与编码容易顶破 300ms 预算 ⇒ 提前预热,把首次注入拉到与稳态同档。
|
|
373
|
+
* 失败一律静默(预热失败不影响 fail-open 语义)。
|
|
374
|
+
*/
|
|
375
|
+
const warmup = async () => {
|
|
376
|
+
try {
|
|
377
|
+
if (!config.enabled) return { ok: false, reason: 'disabled' };
|
|
378
|
+
const health = await br.probe(true);
|
|
379
|
+
if (!health || !health.ok) return { ok: false, reason: 'bridge-unavailable' };
|
|
380
|
+
const idx = getIndex();
|
|
381
|
+
return { ok: true, count: idx ? idx.count : 0, bridgePort: health.port };
|
|
382
|
+
} catch (e) {
|
|
383
|
+
return { ok: false, reason: String(e && e.message ? e.message : e) };
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
decide,
|
|
389
|
+
decideWithTimeout,
|
|
390
|
+
status,
|
|
391
|
+
close,
|
|
392
|
+
logExpand,
|
|
393
|
+
getById,
|
|
394
|
+
warmup,
|
|
395
|
+
get bridge() {
|
|
396
|
+
return br;
|
|
397
|
+
},
|
|
398
|
+
_internals: { getIndex, openMemory, openVectors, visibleInjections },
|
|
399
|
+
};
|
|
400
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FORK-EDIT(2026-09-21): 记忆库只读数据源(召回路径) —— 从 S1 离线层
|
|
3
|
+
* `memory-engine/lib/memory-source.mjs` 移植(只取召回需要的读口)。
|
|
4
|
+
*
|
|
5
|
+
* 移植来源:本仓 `memory-engine/lib/memory-source.mjs`(记忆引擎 v2 · S1 交付)
|
|
6
|
+
* 铁律(方案 §3.1 / §7):对 memory.db **只读**,绝不写。只读连接在 DELETE journal 模式下
|
|
7
|
+
* 不阻塞写者,也不被写者阻塞(A6 实测:未提交写不阻塞读)。
|
|
8
|
+
* 与来源的差异:只保留 pinnedIds / ftsSearch / getById / rowsByIds / maxUpdatedAt,
|
|
9
|
+
* 去掉增量同步与全表扫描(那些是离线层 sync/sweep 的活)。
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
13
|
+
|
|
14
|
+
export function openMemoryReader(dbPath) {
|
|
15
|
+
if (!fs.existsSync(dbPath)) {
|
|
16
|
+
throw new Error(`记忆库不存在: ${dbPath}`);
|
|
17
|
+
}
|
|
18
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
19
|
+
db.exec('PRAGMA busy_timeout = 5000;');
|
|
20
|
+
|
|
21
|
+
const S = {
|
|
22
|
+
count: db.prepare('SELECT COUNT(*) AS n FROM memories'),
|
|
23
|
+
maxUpdated: db.prepare('SELECT MAX(updated_at) AS m FROM memories'),
|
|
24
|
+
pinned: db.prepare('SELECT id FROM memories WHERE pinned = 1 ORDER BY id'),
|
|
25
|
+
byId: db.prepare(
|
|
26
|
+
'SELECT id, text, tags, pinned, created_at, updated_at FROM memories WHERE id = ?',
|
|
27
|
+
),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
dbPath,
|
|
32
|
+
readOnly: true,
|
|
33
|
+
db,
|
|
34
|
+
|
|
35
|
+
count() {
|
|
36
|
+
return Number(S.count.get().n);
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
maxUpdatedAt() {
|
|
40
|
+
const v = S.maxUpdated.get().m;
|
|
41
|
+
return v == null ? 0 : Number(v);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
/** 召回用:pinned 集合(方案 §3.3:召回排除 pinned,交给 dsh-memory 注入) */
|
|
45
|
+
pinnedIds() {
|
|
46
|
+
return S.pinned.all().map((r) => Number(r.id));
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
getById(id) {
|
|
50
|
+
const r = S.byId.get(Number(id));
|
|
51
|
+
return r ? mapRow(r) : null;
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 批量取详情(指针渲染用;一条 SQL 取回,避免 N 次查询)。
|
|
56
|
+
* @param {number[]} ids
|
|
57
|
+
* @returns {Array<{id:number,text:string,tags:string,pinned:number,created_at:number,updated_at:number}>}
|
|
58
|
+
*/
|
|
59
|
+
rowsByIds(ids) {
|
|
60
|
+
const list = (ids || []).map(Number).filter((n) => Number.isFinite(n));
|
|
61
|
+
if (!list.length) return [];
|
|
62
|
+
const ph = list.map(() => '?').join(',');
|
|
63
|
+
return db
|
|
64
|
+
.prepare(
|
|
65
|
+
`SELECT id, text, tags, pinned, created_at, updated_at FROM memories WHERE id IN (${ph})`,
|
|
66
|
+
)
|
|
67
|
+
.all(...list)
|
|
68
|
+
.map((r) => ({ ...mapRow(r), created_at: Number(r.created_at) }));
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* FTS5 补位检索(方案 §3.4 A4:中文连续串几近不可用,只补标签/英文/数字)。
|
|
73
|
+
* @param {string} matchExpr 已构造好的 FTS5 MATCH 表达式
|
|
74
|
+
*/
|
|
75
|
+
ftsSearch(matchExpr, limit) {
|
|
76
|
+
try {
|
|
77
|
+
return db
|
|
78
|
+
.prepare(
|
|
79
|
+
`SELECT m.id AS id, m.pinned AS pinned, bm25(memories_fts) AS score
|
|
80
|
+
FROM memories_fts JOIN memories m ON m.id = memories_fts.rowid
|
|
81
|
+
WHERE memories_fts MATCH ? AND m.pinned = 0
|
|
82
|
+
ORDER BY score LIMIT ?`,
|
|
83
|
+
)
|
|
84
|
+
.all(String(matchExpr), Number(limit))
|
|
85
|
+
.map((r) => ({ id: Number(r.id), score: Number(r.score) }));
|
|
86
|
+
} catch (e) {
|
|
87
|
+
// 表达式非法/表缺失 → 补位路径空(方案 §3.3:FTS 缺失时不参与,不做零分占位)
|
|
88
|
+
return { error: String(e && e.message ? e.message : e) };
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
close() {
|
|
93
|
+
// 幂等:重复 close 不得抛错(调用方常在 finally 里再关一次)
|
|
94
|
+
if (this._closed) return;
|
|
95
|
+
this._closed = true;
|
|
96
|
+
try {
|
|
97
|
+
db.close();
|
|
98
|
+
} catch {
|
|
99
|
+
/* ignore */
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
get closed() {
|
|
103
|
+
return Boolean(this._closed);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function mapRow(r) {
|
|
109
|
+
return {
|
|
110
|
+
id: Number(r.id),
|
|
111
|
+
text: r.text,
|
|
112
|
+
tags: r.tags,
|
|
113
|
+
pinned: Number(r.pinned),
|
|
114
|
+
updated_at: Number(r.updated_at),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FORK-EDIT(2026-09-21): L2 模块门(自我 drain) —— 方案 §3.5-4 / §5.1(召回开关载体)
|
|
3
|
+
*
|
|
4
|
+
* 口径(S2a 已在 `tools\dsh-butler\lib\autonomy-modules.mjs` 的 MODULE_IDS 登记 `memory-recall`):
|
|
5
|
+
* · butler 无法热关 DSH 进程内的插件 ⇒ **插件自己每轮读该段并自我 drain**
|
|
6
|
+
* · `butler-autonomy.json` 的 `modules['memory-recall'].enabled === true` 才放行
|
|
7
|
+
* · **fail-safe:读不到配置 / 解析失败 / 段缺失 / 值非 true ⇒ 判关(drain)**
|
|
8
|
+
* 缓存:按文件 mtime + TTL(默认 5 s)缓存,避免每轮读盘;读失败也缓存(避免异常时每轮重读)。
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
export const MODULE_ID = 'memory-recall';
|
|
14
|
+
export const AUTONOMY_FILE = 'butler-autonomy.json';
|
|
15
|
+
|
|
16
|
+
export function createModuleGate({
|
|
17
|
+
home,
|
|
18
|
+
file = AUTONOMY_FILE,
|
|
19
|
+
moduleId = MODULE_ID,
|
|
20
|
+
ttlMs = 5000,
|
|
21
|
+
now = () => Date.now(),
|
|
22
|
+
fsImpl = fs,
|
|
23
|
+
} = {}) {
|
|
24
|
+
if (!home) throw new Error('module-gate: 必须给 home(DSH_HOME)');
|
|
25
|
+
const target = path.join(home, file);
|
|
26
|
+
let cache = null; // { enabled, reason, at, mtimeMs }
|
|
27
|
+
|
|
28
|
+
const read = (force) => {
|
|
29
|
+
const nowMs = now();
|
|
30
|
+
let mtimeMs = -1;
|
|
31
|
+
try {
|
|
32
|
+
mtimeMs = fsImpl.existsSync(target) ? fsImpl.statSync(target).mtimeMs : -1;
|
|
33
|
+
} catch {
|
|
34
|
+
mtimeMs = -1;
|
|
35
|
+
}
|
|
36
|
+
if (!force && cache && cache.mtimeMs === mtimeMs && nowMs - cache.at < ttlMs) return cache;
|
|
37
|
+
|
|
38
|
+
let enabled = false;
|
|
39
|
+
let reason = 'ok';
|
|
40
|
+
if (mtimeMs < 0) {
|
|
41
|
+
reason = 'config-missing'; // fail-safe:读不到配置判关
|
|
42
|
+
} else {
|
|
43
|
+
try {
|
|
44
|
+
const raw = JSON.parse(fsImpl.readFileSync(target, 'utf8'));
|
|
45
|
+
const mod = raw && raw.modules ? raw.modules[moduleId] : undefined;
|
|
46
|
+
if (!mod) reason = 'module-not-registered';
|
|
47
|
+
else if (mod.enabled !== true) reason = 'module-disabled';
|
|
48
|
+
else {
|
|
49
|
+
enabled = true;
|
|
50
|
+
reason = 'enabled';
|
|
51
|
+
}
|
|
52
|
+
} catch (e) {
|
|
53
|
+
reason = `config-unreadable:${String(e && e.message ? e.message : e)}`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
cache = { enabled, reason, at: nowMs, mtimeMs, file: target };
|
|
57
|
+
return cache;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
file: target,
|
|
62
|
+
check: (force = false) => {
|
|
63
|
+
const r = read(force);
|
|
64
|
+
return { enabled: r.enabled, reason: r.reason, file: r.file, at: r.at, mtimeMs: r.mtimeMs };
|
|
65
|
+
},
|
|
66
|
+
peek: () => cache,
|
|
67
|
+
};
|
|
68
|
+
}
|