dsh-prime-memory 0.26.5 → 0.26.9
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 +39 -0
- package/README.en.md +62 -1
- package/README.md +60 -2
- package/dist/client.js +45 -5
- package/dist/config.d.ts +40 -0
- package/dist/config.js +33 -3
- package/dist/contract.d.ts +14 -1
- package/dist/embedding-child.cjs +192 -0
- package/dist/embedding-worker.cjs +2 -0
- package/dist/graph/apply.d.ts +9 -0
- package/dist/graph/apply.js +12 -2
- package/dist/graph/search.d.ts +11 -2
- package/dist/graph/search.js +38 -10
- package/dist/hooks/recall.js +28 -1
- package/dist/index.js +14 -1
- package/dist/llm.d.ts +21 -2
- package/dist/llm.js +56 -25
- package/dist/pipeline/graph.d.ts +17 -1
- package/dist/pipeline/graph.js +45 -2
- package/dist/pipeline/l1.d.ts +15 -2
- package/dist/pipeline/l1.js +161 -47
- package/dist/pipeline/l2.js +72 -50
- package/dist/pipeline/md-sync.js +4 -2
- package/dist/pipeline/ruminate.js +2 -0
- package/dist/pipeline/runner.d.ts +32 -0
- package/dist/pipeline/runner.js +109 -11
- package/dist/pipeline/slot-migrate.d.ts +3 -2
- package/dist/pipeline/slot-migrate.js +3 -2
- package/dist/projection/recall.d.ts +10 -3
- package/dist/projection/recall.js +14 -0
- package/dist/settings.js +6 -0
- package/dist/stats.d.ts +1 -1
- package/dist/stats.js +35 -14
- package/dist/store/embedding-source.d.ts +4 -2
- package/dist/store/embedding-source.js +5 -2
- package/dist/store/file-versions.d.ts +2 -0
- package/dist/store/file-versions.js +2 -0
- package/dist/store/graph-store.d.ts +71 -1
- package/dist/store/graph-store.js +221 -8
- package/dist/store/l1.d.ts +5 -0
- package/dist/store/l1.js +18 -2
- package/dist/store/local-embedding.d.ts +9 -5
- package/dist/store/local-embedding.js +137 -17
- package/dist/store/memory-md-disable.d.ts +3 -2
- package/dist/store/memory-md.d.ts +3 -3
- package/dist/store/memory-md.js +6 -4
- package/dist/store/route-health.d.ts +38 -0
- package/dist/store/route-health.js +115 -0
- package/dist/store/runtime-installer.d.ts +13 -1
- package/dist/store/runtime-installer.js +40 -2
- package/dist/store/sqlite.d.ts +24 -12
- package/dist/store/sqlite.js +97 -31
- package/dist/store/supersede.d.ts +10 -2
- package/dist/store/supersede.js +10 -2
- package/dist/store/validity.d.ts +150 -0
- package/dist/store/validity.js +209 -0
- package/dist/tools/index.d.ts +15 -1
- package/dist/tools/index.js +393 -11
- package/dist/util/json-repair.d.ts +48 -0
- package/dist/util/json-repair.js +285 -0
- package/dist/validity-backfill.d.ts +78 -0
- package/dist/validity-backfill.js +160 -0
- package/dsh.plugin.json +2 -2
- package/package.json +3 -3
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地嵌入子进程(fix #11):transformers.js 的模型加载与推理在 fork 出的独立
|
|
3
|
+
* 子进程执行,主线程只投递文本、收回向量。取代 worker_threads 的原因:
|
|
4
|
+
* 插件 runtime 的 sharp/libvips 与宿主 Electron 的 sharp/libvips 在同一进程内
|
|
5
|
+
* 加载会撞 libvips-42.dll 名字(OS LoadLibrary 层冲突,同名不同路径行为未定义)
|
|
6
|
+
* ——worker_thread 与主线程同进程,撞名前提仍在;子进程自带独立 DLL 表,物理隔离。
|
|
7
|
+
*
|
|
8
|
+
* 推理队列语义与 embedding-worker.cjs 完全一致(单线程串行、priority 插队、
|
|
9
|
+
* 条间 setImmediate 让路),只有 IPC 传输层不同:
|
|
10
|
+
*
|
|
11
|
+
* 协议(与 src/store/local-embedding.ts 的 RealChildChannel 配对;自增 id):
|
|
12
|
+
* - 启动配置:env DSH_EMBED_CFG(JSON 字符串:{runtimeDir,modelDir,pooling,dtype,
|
|
13
|
+
* maxInputChars}——fork 的 env 只收标量,嵌套对象经序列化传递);
|
|
14
|
+
* - 入向:{id,type:'ping'} | {id,type:'warmup'} | {id,type:'embed',texts,priority}
|
|
15
|
+
* - 出向:{id,ok:true,type:'pong'|'ready'|'embedded',vectors?}(vectors 为
|
|
16
|
+
* number[][],fork IPC 默认 JSON 序列化不支持 TypedArray,主线程侧重建
|
|
17
|
+
* Float32Array;512d×32 条 ≈64KB,序列化 <5ms,瓶颈在推理不在传输)
|
|
18
|
+
* | {id,ok:false,stage:'load'|'infer',error} | {type:'fatal',error}
|
|
19
|
+
*
|
|
20
|
+
* 信任边界(沿 AGENTS.md 审查记录):runtime/ 目录在数据目录信任边界内,
|
|
21
|
+
* 本进程按其 package.json createRequire 加载 transformers——对数据目录有写
|
|
22
|
+
* 权限的进程理论上可植入恶意模块;模型文件逐文件 sha256 锁定,运行时代码
|
|
23
|
+
* 无同级防护,完整修复需安装指纹设计,暂以文档化假设为准。
|
|
24
|
+
*/
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
const { createRequire } = require('node:module');
|
|
29
|
+
|
|
30
|
+
// 必须由 fork 启动(需要 IPC channel);裸 node 执行时静默退出,不报 SyntaxError、不永挂。
|
|
31
|
+
if (typeof process.send !== 'function') {
|
|
32
|
+
console.error('[embedding-child] 未经 fork 启动,无 IPC 通道,退出');
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 错误消息归一化(Error 取 message,其余 String 化)。 */
|
|
37
|
+
function errMsg(err) {
|
|
38
|
+
return err && err.message ? err.message : String(err);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const cfg = JSON.parse(process.env.DSH_EMBED_CFG || '{}');
|
|
42
|
+
const RUNTIME_DIR = typeof cfg.runtimeDir === 'string' ? cfg.runtimeDir : '';
|
|
43
|
+
const MODEL_DIR = typeof cfg.modelDir === 'string' ? cfg.modelDir : '';
|
|
44
|
+
const POOLING = cfg.pooling === 'cls' ? 'cls' : 'mean';
|
|
45
|
+
const DTYPE = typeof cfg.dtype === 'string' ? cfg.dtype : 'q8';
|
|
46
|
+
const MAX_INPUT_CHARS = cfg.maxInputChars > 0 ? cfg.maxInputChars : 5000;
|
|
47
|
+
|
|
48
|
+
let extractor = null; // pipeline('feature-extraction', …)
|
|
49
|
+
let loadState = 'idle'; // idle | loading | ready | failed
|
|
50
|
+
let loadPromise = null;
|
|
51
|
+
let loadError = null;
|
|
52
|
+
|
|
53
|
+
// ── 模型懒加载(首请求触发;失败后经 warmup 可重试,embed 请求失败快返回) ──
|
|
54
|
+
function ensureLoaded() {
|
|
55
|
+
if (loadState === 'ready') return Promise.resolve();
|
|
56
|
+
if (loadState === 'loading' && loadPromise) return loadPromise;
|
|
57
|
+
loadState = 'loading';
|
|
58
|
+
loadError = null;
|
|
59
|
+
loadPromise = (async () => {
|
|
60
|
+
try {
|
|
61
|
+
if (!RUNTIME_DIR || !MODEL_DIR) throw new Error('DSH_EMBED_CFG 缺少 runtimeDir/modelDir');
|
|
62
|
+
const req = createRequire(path.join(RUNTIME_DIR, 'package.json'));
|
|
63
|
+
const mod = req('@huggingface/transformers');
|
|
64
|
+
if (mod.env) {
|
|
65
|
+
mod.env.allowRemoteModels = false;
|
|
66
|
+
if (mod.env.allowLocalModels !== undefined) mod.env.allowLocalModels = true;
|
|
67
|
+
}
|
|
68
|
+
extractor = await mod.pipeline('feature-extraction', MODEL_DIR, { dtype: DTYPE });
|
|
69
|
+
loadState = 'ready';
|
|
70
|
+
} catch (err) {
|
|
71
|
+
loadState = 'failed';
|
|
72
|
+
loadError = errMsg(err);
|
|
73
|
+
loadPromise = null; // 失败后下一次 warmup 重新走加载
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
})();
|
|
77
|
+
return loadPromise;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── 推理队列(逐条 job;priority 插队;条间让路) ──
|
|
81
|
+
const queue = []; // [{ request, index, text }]
|
|
82
|
+
let pumping = false;
|
|
83
|
+
|
|
84
|
+
function postReply(msg) {
|
|
85
|
+
// 通道已断(父进程退出/正在终止)时 send 抛错——静默即可,父进程侧有 exit 兜底
|
|
86
|
+
try {
|
|
87
|
+
process.send(msg);
|
|
88
|
+
} catch {
|
|
89
|
+
/* IPC 已关闭,回复无处可去 */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 请求全部条目完成 → 回执(Float32Array → number[] 序列化,IPC JSON 通道不支持 TypedArray)。 */
|
|
94
|
+
function settle(request) {
|
|
95
|
+
if (request.failed || request.remaining !== 0) return;
|
|
96
|
+
postReply({
|
|
97
|
+
id: request.id,
|
|
98
|
+
ok: true,
|
|
99
|
+
type: 'embedded',
|
|
100
|
+
vectors: request.vectors.map((v) => Array.from(v)),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 首个失败即回执错误,并移除该请求尚未开跑的条目(正在跑的至多 1 条,算完即弃)。 */
|
|
105
|
+
function failRequest(request, error, stage) {
|
|
106
|
+
if (request.failed) return;
|
|
107
|
+
request.failed = true;
|
|
108
|
+
for (let i = queue.length - 1; i >= 0; i--) {
|
|
109
|
+
if (queue[i].request === request) queue.splice(i, 1);
|
|
110
|
+
}
|
|
111
|
+
postReply({ id: request.id, ok: false, stage: stage, error: errMsg(error) });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function pump() {
|
|
115
|
+
if (pumping) return;
|
|
116
|
+
pumping = true;
|
|
117
|
+
try {
|
|
118
|
+
while (queue.length > 0) {
|
|
119
|
+
const job = queue.shift();
|
|
120
|
+
if (job.request.failed) continue;
|
|
121
|
+
try {
|
|
122
|
+
const result = await extractor([job.text.slice(0, MAX_INPUT_CHARS)], { pooling: POOLING, normalize: true });
|
|
123
|
+
const data = result[0] && result[0].data;
|
|
124
|
+
job.request.vectors[job.index] = data instanceof Float32Array ? data : new Float32Array(data);
|
|
125
|
+
job.request.remaining -= 1;
|
|
126
|
+
settle(job.request);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
failRequest(job.request, err, 'infer');
|
|
129
|
+
}
|
|
130
|
+
// 条间让路:交还本进程事件循环,新消息(含 priority 插队)可在批间被处理
|
|
131
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
132
|
+
}
|
|
133
|
+
} finally {
|
|
134
|
+
pumping = false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function handleEmbed(msg) {
|
|
139
|
+
const texts = Array.isArray(msg.texts) ? msg.texts : [];
|
|
140
|
+
if (texts.length === 0) {
|
|
141
|
+
postReply({ id: msg.id, ok: true, type: 'embedded', vectors: [] });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const request = { id: msg.id, vectors: new Array(texts.length), remaining: texts.length, failed: false };
|
|
145
|
+
// 单条请求(召回 query)插队到队首;批次追加到队尾(批内条目间仍会被后来的插队)
|
|
146
|
+
const priority = msg.priority === true && texts.length === 1;
|
|
147
|
+
for (let i = 0; i < texts.length; i++) {
|
|
148
|
+
const job = { request: request, index: i, text: String(texts[i] == null ? '' : texts[i]) };
|
|
149
|
+
if (priority) queue.unshift(job);
|
|
150
|
+
else queue.push(job);
|
|
151
|
+
}
|
|
152
|
+
void pump();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
process.on('message', (msg) => {
|
|
156
|
+
if (!msg || typeof msg.id !== 'number' || typeof msg.type !== 'string') return;
|
|
157
|
+
if (msg.type === 'ping') {
|
|
158
|
+
postReply({ id: msg.id, ok: true, type: 'pong' });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (msg.type === 'warmup') {
|
|
162
|
+
ensureLoaded().then(
|
|
163
|
+
() => postReply({ id: msg.id, ok: true, type: 'ready' }),
|
|
164
|
+
() => postReply({ id: msg.id, ok: false, stage: 'load', error: loadError ?? '模型加载失败' }),
|
|
165
|
+
);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (msg.type === 'embed') {
|
|
169
|
+
void (async () => {
|
|
170
|
+
try {
|
|
171
|
+
await ensureLoaded();
|
|
172
|
+
} catch {
|
|
173
|
+
postReply({ id: msg.id, ok: false, stage: 'load', error: loadError ?? '模型加载失败' });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
handleEmbed(msg);
|
|
177
|
+
})();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// 父进程disconnect(通道被主动关闭,如宿主优雅退出/RealChildChannel 前置断开):
|
|
183
|
+
// 模型与 onnxruntime 都只服务于这条通道,留着就是孤儿常驻——直接退出。
|
|
184
|
+
// taskkill /T /F 兜底覆盖"父进程被硬杀、无 disconnect"的路径。
|
|
185
|
+
process.on('disconnect', () => process.exit(0));
|
|
186
|
+
|
|
187
|
+
// 未捕获异常:尽力通知父进程后强制退出(父进程的 exit 处理会拒绝全部 pending
|
|
188
|
+
// 并转入 failed 态)。不保持半死状态——半死的子进程只会让调用方挂到超时。
|
|
189
|
+
process.on('uncaughtException', (err) => {
|
|
190
|
+
postReply({ type: 'fatal', error: errMsg(err) });
|
|
191
|
+
setTimeout(() => process.exit(1), 50);
|
|
192
|
+
});
|
package/dist/graph/apply.d.ts
CHANGED
|
@@ -31,7 +31,16 @@ export declare function normalizeEntityName(name: string): string;
|
|
|
31
31
|
* validTo → validFrom(时间增强列,新写入路径)→ metadata.activity_end_time →
|
|
32
32
|
* metadata.activity_start_time(列迁移前的存量数据)→ timestamps 最新 → createdAt。
|
|
33
33
|
* 全部无证据才用 fallback(now),绝不猜测。
|
|
34
|
+
*
|
|
35
|
+
* task_2.6(expiry-tiering):兜底 now 分支回传 `'fallback_now'` 证据等级——供图谱侧
|
|
36
|
+
* 同口径读时派生使用(落到 now 的 validFrom **不是**时间证据,与 activity_* 链的
|
|
37
|
+
* 'evidence' 必须可区分;否则假过期与真过期同形)。
|
|
34
38
|
*/
|
|
39
|
+
export declare function anchorTimeFromRecordsDetailed(records: readonly MemoryRecord[], fallbackIso: string): {
|
|
40
|
+
iso: string;
|
|
41
|
+
source: 'evidence' | 'fallback_now';
|
|
42
|
+
};
|
|
43
|
+
/** {@link anchorTimeFromRecordsDetailed} 的旧签名封装(调用方只要时刻时的零改动通道)。 */
|
|
35
44
|
export declare function anchorTimeFromRecords(records: readonly MemoryRecord[], fallbackIso: string): string;
|
|
36
45
|
/** 应用一次投影提案(硬校验 + 消歧 + supersede + 状态重建)。 */
|
|
37
46
|
export declare function applyGraphProjection(options: ApplyGraphProjectionOptions): ApplyGraphProjectionOutcome;
|
package/dist/graph/apply.js
CHANGED
|
@@ -66,8 +66,12 @@ function recordFamily(r) {
|
|
|
66
66
|
* validTo → validFrom(时间增强列,新写入路径)→ metadata.activity_end_time →
|
|
67
67
|
* metadata.activity_start_time(列迁移前的存量数据)→ timestamps 最新 → createdAt。
|
|
68
68
|
* 全部无证据才用 fallback(now),绝不猜测。
|
|
69
|
+
*
|
|
70
|
+
* task_2.6(expiry-tiering):兜底 now 分支回传 `'fallback_now'` 证据等级——供图谱侧
|
|
71
|
+
* 同口径读时派生使用(落到 now 的 validFrom **不是**时间证据,与 activity_* 链的
|
|
72
|
+
* 'evidence' 必须可区分;否则假过期与真过期同形)。
|
|
69
73
|
*/
|
|
70
|
-
export function
|
|
74
|
+
export function anchorTimeFromRecordsDetailed(records, fallbackIso) {
|
|
71
75
|
let latest = Number.NaN;
|
|
72
76
|
const consider = (raw) => {
|
|
73
77
|
const t = typeof raw === 'number' ? raw : typeof raw === 'string' ? Date.parse(raw) : Number.NaN;
|
|
@@ -85,7 +89,13 @@ export function anchorTimeFromRecords(records, fallbackIso) {
|
|
|
85
89
|
consider(t);
|
|
86
90
|
consider(r.createdAt);
|
|
87
91
|
}
|
|
88
|
-
return Number.isNaN(latest)
|
|
92
|
+
return Number.isNaN(latest)
|
|
93
|
+
? { iso: fallbackIso, source: 'fallback_now' }
|
|
94
|
+
: { iso: new Date(latest).toISOString(), source: 'evidence' };
|
|
95
|
+
}
|
|
96
|
+
/** {@link anchorTimeFromRecordsDetailed} 的旧签名封装(调用方只要时刻时的零改动通道)。 */
|
|
97
|
+
export function anchorTimeFromRecords(records, fallbackIso) {
|
|
98
|
+
return anchorTimeFromRecordsDetailed(records, fallbackIso).iso;
|
|
89
99
|
}
|
|
90
100
|
/** 来源集并集封顶(保最新:新引用追加在尾部,溢出丢头部最老引用)。 */
|
|
91
101
|
function unionSourcesCapped(old, add) {
|
package/dist/graph/search.d.ts
CHANGED
|
@@ -9,6 +9,15 @@ export declare const GRAPH_FIELD_WEIGHTS: {
|
|
|
9
9
|
readonly relations: 3;
|
|
10
10
|
readonly type: 2;
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* as-of 回放可选参数(expiry-tiering Phase 5:图谱读时派生,不加冻结列)。
|
|
14
|
+
* 默认(缺席)= 当前态检索,行为与改前逐字一致。
|
|
15
|
+
*/
|
|
16
|
+
export interface GraphAsOfOptions {
|
|
17
|
+
/** ISO 8601 时刻:把"当时成立"的 superseded fact/边也纳入检索与回链;
|
|
18
|
+
* archived 仍排除(那是删除传播的墓碑,来源已不在,回放也不该复活)。 */
|
|
19
|
+
asOf?: string;
|
|
20
|
+
}
|
|
12
21
|
/**
|
|
13
22
|
* 图谱命中 → L1 记录 id 序列(§D 第 3 路 RRF 的输入;纯函数,无 I/O)。
|
|
14
23
|
*
|
|
@@ -35,7 +44,7 @@ export declare const GRAPH_FIELD_WEIGHTS: {
|
|
|
35
44
|
* 见文件头演进阶梯)。族过滤不在本函数:邻居节点可跨族,兜底在 graphLane 尾部的
|
|
36
45
|
* 记录级 family/scope 双闸(宁可漏不可串落在记录上)。
|
|
37
46
|
*/
|
|
38
|
-
export declare function expandHitRecordIds(hits: readonly GraphNodeSearchResult[], nodes: readonly GraphNode[], edges: readonly GraphEdge[]): string[];
|
|
47
|
+
export declare function expandHitRecordIds(hits: readonly GraphNodeSearchResult[], nodes: readonly GraphNode[], edges: readonly GraphEdge[], opts?: GraphAsOfOptions): string[];
|
|
39
48
|
/**
|
|
40
49
|
* 直连段回链(无 hop)——保留 task_9 的原语义形状(既有测试继续钉住);
|
|
41
50
|
* 生产链路一律走 expandHitRecordIds(向量种子并集 + 1-hop)。
|
|
@@ -45,4 +54,4 @@ export declare function graphHitRecordIds(hits: readonly GraphNodeSearchResult[]
|
|
|
45
54
|
* 图谱节点检索:返回按 score 降序的命中(最多 limit 条;limit 钳制 1~20)。
|
|
46
55
|
* 候选节点 = active | disputed(superseded/archived 不进检索)。
|
|
47
56
|
*/
|
|
48
|
-
export declare function searchGraphNodes(nodes: readonly GraphNode[], edges: readonly GraphEdge[], query: string, limit: number): GraphNodeSearchResult[];
|
|
57
|
+
export declare function searchGraphNodes(nodes: readonly GraphNode[], edges: readonly GraphEdge[], query: string, limit: number, opts?: GraphAsOfOptions): GraphNodeSearchResult[];
|
package/dist/graph/search.js
CHANGED
|
@@ -60,6 +60,22 @@ const FIELD_ORDER = ['name', 'aliases', 'tags', 'currentState', 'facts', 'relati
|
|
|
60
60
|
function factValueText(value) {
|
|
61
61
|
return Array.isArray(value) ? value.join(' ') : value;
|
|
62
62
|
}
|
|
63
|
+
/** ISO 字符串区间覆盖判定(字典序=时间序;空串视作未闭合)。 */
|
|
64
|
+
function coversInterval(from, to, asOf) {
|
|
65
|
+
if (from && from > asOf)
|
|
66
|
+
return false;
|
|
67
|
+
if (to && asOf >= to)
|
|
68
|
+
return false;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
/** fact 在回放时刻是否成立(active 恒成立;superseded 看区间)。archived/disputed 不含。 */
|
|
72
|
+
function factValidAtAsOf(f, asOf) {
|
|
73
|
+
if (f.status === 'active')
|
|
74
|
+
return true;
|
|
75
|
+
if (f.status === 'superseded')
|
|
76
|
+
return coversInterval(f.validFrom, f.validTo, asOf);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
63
79
|
/** 节点新度序(updatedAt 新→旧,同值按 id 升序——与检索结果排序同式)。 */
|
|
64
80
|
function byNodeRecency(a, b) {
|
|
65
81
|
return a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : a.id < b.id ? -1 : 1;
|
|
@@ -90,7 +106,8 @@ function byNodeRecency(a, b) {
|
|
|
90
106
|
* 见文件头演进阶梯)。族过滤不在本函数:邻居节点可跨族,兜底在 graphLane 尾部的
|
|
91
107
|
* 记录级 family/scope 双闸(宁可漏不可串落在记录上)。
|
|
92
108
|
*/
|
|
93
|
-
export function expandHitRecordIds(hits, nodes, edges) {
|
|
109
|
+
export function expandHitRecordIds(hits, nodes, edges, opts) {
|
|
110
|
+
const asOf = typeof opts?.asOf === 'string' && opts.asOf.length > 0 ? opts.asOf : undefined;
|
|
94
111
|
const seen = new Set();
|
|
95
112
|
const out = [];
|
|
96
113
|
const take = (id) => {
|
|
@@ -99,12 +116,13 @@ export function expandHitRecordIds(hits, nodes, edges) {
|
|
|
99
116
|
seen.add(id);
|
|
100
117
|
out.push(id);
|
|
101
118
|
};
|
|
102
|
-
// 直连段(
|
|
119
|
+
// 直连段(节点身份来源在前,有效 facts 在后;task_5.2:asOf 下额外纳入当时成立的 superseded fact)
|
|
103
120
|
for (const h of hits) {
|
|
104
121
|
for (const id of h.node.sourceRecordIds)
|
|
105
122
|
take(id);
|
|
106
123
|
for (const f of h.node.facts) {
|
|
107
|
-
|
|
124
|
+
const ok = asOf === undefined ? f.status === 'active' : factValidAtAsOf(f, asOf);
|
|
125
|
+
if (!ok)
|
|
108
126
|
continue;
|
|
109
127
|
for (const id of f.sourceRecordIds ?? [])
|
|
110
128
|
take(id);
|
|
@@ -112,13 +130,17 @@ export function expandHitRecordIds(hits, nodes, edges) {
|
|
|
112
130
|
}
|
|
113
131
|
if (edges.length === 0)
|
|
114
132
|
return out;
|
|
115
|
-
// hop
|
|
133
|
+
// hop 段:可用边的双向邻居 ∉ 命中集,邻居去重,状态需 active|disputed
|
|
134
|
+
// (task_5.2:asOf 下"当时成立"的 superseded 边也可回链,邻居同理含当时成立的 superseded 节点)
|
|
116
135
|
const hitNodeIds = new Set(hits.map((h) => h.node.id));
|
|
117
136
|
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
|
137
|
+
const edgeUsable = (e) => e.status === 'active' || (asOf !== undefined && e.status === 'superseded' && coversInterval(e.validFrom, e.validTo, asOf));
|
|
138
|
+
const neighborUsable = (n) => n.status === 'active' || n.status === 'disputed'
|
|
139
|
+
|| (asOf !== undefined && n.status === 'superseded' && coversInterval(n.createdAt, n.updatedAt, asOf));
|
|
118
140
|
const seenNeighbor = new Set();
|
|
119
141
|
const neighbors = [];
|
|
120
142
|
for (const e of edges) {
|
|
121
|
-
if (e
|
|
143
|
+
if (!edgeUsable(e))
|
|
122
144
|
continue;
|
|
123
145
|
const dirs = [
|
|
124
146
|
[e.fromNodeId, e.toNodeId],
|
|
@@ -128,7 +150,7 @@ export function expandHitRecordIds(hits, nodes, edges) {
|
|
|
128
150
|
if (!hitNodeIds.has(self) || hitNodeIds.has(other) || seenNeighbor.has(other))
|
|
129
151
|
continue;
|
|
130
152
|
const n = nodeById.get(other);
|
|
131
|
-
if (!n || (n
|
|
153
|
+
if (!n || !neighborUsable(n))
|
|
132
154
|
continue;
|
|
133
155
|
seenNeighbor.add(other);
|
|
134
156
|
neighbors.push(n);
|
|
@@ -161,15 +183,18 @@ export function graphHitRecordIds(hits) {
|
|
|
161
183
|
* 图谱节点检索:返回按 score 降序的命中(最多 limit 条;limit 钳制 1~20)。
|
|
162
184
|
* 候选节点 = active | disputed(superseded/archived 不进检索)。
|
|
163
185
|
*/
|
|
164
|
-
export function searchGraphNodes(nodes, edges, query, limit) {
|
|
186
|
+
export function searchGraphNodes(nodes, edges, query, limit, opts) {
|
|
187
|
+
const asOf = typeof opts?.asOf === 'string' && opts.asOf.length > 0 ? opts.asOf : undefined;
|
|
165
188
|
const tokens = [...new Set(tokenize(query))];
|
|
166
189
|
const cappedLimit = Math.min(Math.max(Math.floor(limit) || 5, 1), GRAPH_SEARCH_LIMIT_MAX);
|
|
167
190
|
if (tokens.length === 0)
|
|
168
191
|
return [];
|
|
169
|
-
// nodeId →
|
|
192
|
+
// nodeId → 可用关系词 token 集(先建邻接表,防逐节点全量扫边;
|
|
193
|
+
// task_5.1:asOf 下纳入"当时成立"的 superseded 边)
|
|
170
194
|
const relationTokensByNode = new Map();
|
|
171
195
|
for (const edge of edges) {
|
|
172
|
-
if (edge.status !== 'active'
|
|
196
|
+
if (edge.status !== 'active'
|
|
197
|
+
&& !(asOf !== undefined && edge.status === 'superseded' && coversInterval(edge.validFrom, edge.validTo, asOf)))
|
|
173
198
|
continue;
|
|
174
199
|
let set = relationTokensByNode.get(edge.fromNodeId);
|
|
175
200
|
if (!set) {
|
|
@@ -188,6 +213,9 @@ export function searchGraphNodes(nodes, edges, query, limit) {
|
|
|
188
213
|
}
|
|
189
214
|
const results = [];
|
|
190
215
|
for (const node of nodes) {
|
|
216
|
+
// 候选状态闸保持 active|disputed:task_5.1 的放宽面在 **fact/边**("纳入 superseded 且
|
|
217
|
+
// 区间覆盖 T 的 fact/edge")——GraphNode 没有 valid 区间列,节点主体复活无判据;
|
|
218
|
+
// 历史实体经"当时成立边"的邻居回链(task_5.2)重新可达,不需要翻状态。
|
|
191
219
|
if (node.status !== 'active' && node.status !== 'disputed')
|
|
192
220
|
continue;
|
|
193
221
|
const fieldTokens = {
|
|
@@ -196,7 +224,7 @@ export function searchGraphNodes(nodes, edges, query, limit) {
|
|
|
196
224
|
tags: new Set((node.tags ?? []).flatMap((t) => tokenize(t))),
|
|
197
225
|
currentState: new Set(tokenize(node.currentState)),
|
|
198
226
|
facts: new Set(node.facts
|
|
199
|
-
.filter((f) => f.status === 'active')
|
|
227
|
+
.filter((f) => (asOf === undefined ? f.status === 'active' : factValidAtAsOf(f, asOf)))
|
|
200
228
|
.flatMap((f) => tokenize(`${f.key} ${factValueText(f.value)}`))),
|
|
201
229
|
relations: relationTokensByNode.get(node.id) ?? new Set(),
|
|
202
230
|
type: new Set(tokenize(node.type)),
|
package/dist/hooks/recall.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { trace } from '../store/trace.js';
|
|
4
|
+
import { filterInjectCandidates } from '../store/validity.js';
|
|
4
5
|
import { RecallDedupeStore } from '../store/recall-dedupe.js';
|
|
5
6
|
import { OccupancyStore } from '../store/occupancy.js';
|
|
6
7
|
import { expirePendingInjections, registerPendingInjection, setInjectionMarker, } from './recall-ack.js';
|
|
@@ -459,6 +460,29 @@ usage) {
|
|
|
459
460
|
scoped = kept;
|
|
460
461
|
}
|
|
461
462
|
}
|
|
463
|
+
// ── 门2 被动注入硬当前态(expiry-tiering task_3.3/3.4,灰度闸**默认关**):
|
|
464
|
+
// 开时按 classifyValidity 现算态剔除 `expired`(仍在索引可检,只是不再自动
|
|
465
|
+
// 浮现进模型上下文);`expired_suspect` 保留并在注入行带"疑似失效"标注;
|
|
466
|
+
// superseded/retired 由门1 索引层结构性进不到候选(不在本层重复过滤)。
|
|
467
|
+
// 判据单源 `stateOfRecord`,本层不再写一份 valid_to/marker 比较(计划 §4 约束)。
|
|
468
|
+
// 关时不进分支零 getByIds——注入逐字与改前一致(零漂移)。
|
|
469
|
+
let suspectIds = new Set();
|
|
470
|
+
const tieringOn = cfg.validity?.tieringEnabled === true || live.get().validityTiering === true;
|
|
471
|
+
if (tieringOn && scoped.length > 0) {
|
|
472
|
+
const nowV = Date.now();
|
|
473
|
+
const byIdV = new Map(stores.l1.getByIds(scoped.map((h) => h.id)).map((r) => [r.id, r]));
|
|
474
|
+
// 主体收敛在 validity.filterInjectCandidates(纯函数可单测,task_3.6)——
|
|
475
|
+
// 这里只做数据装配与日志,不内联第二份判据。
|
|
476
|
+
const filtered = filterInjectCandidates(scoped, (id) => byIdV.get(id), nowV);
|
|
477
|
+
suspectIds = filtered.suspectIds;
|
|
478
|
+
if (filtered.exited > 0) {
|
|
479
|
+
logger.warn(`[memory] 门2 防御性拦截退场态候选 ${filtered.exited} 条(门1 应已挡住,出现即泄漏信号)`);
|
|
480
|
+
}
|
|
481
|
+
if (filtered.kept.length !== scoped.length) {
|
|
482
|
+
logger.info(`[memory] 门2 时效过滤:剔除 expired ${scoped.length - filtered.kept.length - filtered.exited} 条(agent=${payload.agent.id})`);
|
|
483
|
+
}
|
|
484
|
+
scoped = filtered.kept;
|
|
485
|
+
}
|
|
462
486
|
// 召回去重:同会话已注入过的记录不再重复注入(模型上下文已持有,省 token)。
|
|
463
487
|
// 纯过滤——剩几条注几条,全量压制(0 条新鲜命中)是正确状态而非未命中。
|
|
464
488
|
// §E:压缩后增强轮跳过压制(压缩把已注入内容逐出了模型上下文)。
|
|
@@ -485,7 +509,10 @@ usage) {
|
|
|
485
509
|
usage?.recordAttempt(scoped.map((h) => h.id), []);
|
|
486
510
|
return decision;
|
|
487
511
|
}
|
|
488
|
-
const lines = applyRecallBudget(fresh.map((h) => `- [${h.scene_name ? `${h.type}|${h.scene_name}` : h.type}] ${h.content}`
|
|
512
|
+
const lines = applyRecallBudget(fresh.map((h) => `- [${h.scene_name ? `${h.type}|${h.scene_name}` : h.type}] ${h.content}` +
|
|
513
|
+
// 门2 task_3.4:疑而不隐——valid_to 来源不可信的行保留注入但带标注,
|
|
514
|
+
// 人/模型自行裁量。灰度关时 suspectIds 恒空,行形状逐字不变。
|
|
515
|
+
(suspectIds.has(h.id) ? '(疑似失效:有效期来源不可信)' : '')), { maxCharsPerMemory: cfg.recall.maxCharsPerMemory, maxTotalRecallChars: cfg.recall.maxTotalRecallChars });
|
|
489
516
|
if (lines.length === 0) {
|
|
490
517
|
// §F 追踪:预算清零轮(截断后无完整行)
|
|
491
518
|
trace({
|
package/dist/index.js
CHANGED
|
@@ -34,6 +34,7 @@ import { SceneStore } from './store/scenes.js';
|
|
|
34
34
|
import { SessionModeStore } from './store/session-modes.js';
|
|
35
35
|
import { StateStore } from './store/state.js';
|
|
36
36
|
import { SlotStore } from './store/slots.js';
|
|
37
|
+
import { RouteHealthStore, routeHealthPathFor } from './store/route-health.js';
|
|
37
38
|
import { registerMemoryTools } from './tools/index.js';
|
|
38
39
|
import { registerSlotTools } from './tools/slots.js';
|
|
39
40
|
import { registerSlotRecall } from './hooks/slot-recall.js';
|
|
@@ -207,6 +208,7 @@ export async function apply(ctx, config) {
|
|
|
207
208
|
};
|
|
208
209
|
const stores = {
|
|
209
210
|
usage: undefined, // 装配于下方(依赖 dataDir 就绪)
|
|
211
|
+
routeHealth: undefined, // R9 路由健康学习,装配于下方
|
|
210
212
|
// DRSC 客户端(T8.4 读切):蒸馏背景窗 + conversation_search 工具面的历史供给。
|
|
211
213
|
// 硬依赖无本地回退(l0 表退役);trustedHosts 惰性读取(webStartup 可能晚到)。
|
|
212
214
|
drsc: new DrscClient({ trustedHosts: () => readStartupTrustedHosts(ctx) }),
|
|
@@ -399,6 +401,8 @@ export async function apply(ctx, config) {
|
|
|
399
401
|
runner.startIdleTimer();
|
|
400
402
|
// 图谱周期补投影(cfg.graph.enabled 门控;30 分钟一次低速收敛)
|
|
401
403
|
runner.startGraphBackfillTimer();
|
|
404
|
+
// 时效标注存量回填(expiry-tiering task_6.3:tieringEnabled 门控,低频、让位蒸馏)
|
|
405
|
+
runner.startValidityBackfillTimer();
|
|
402
406
|
// 重建控制器(存储降级时不建——RPC 端点走 supported=false 分支)
|
|
403
407
|
const rebuild = storageOk && !db.isDegraded()
|
|
404
408
|
? new RebuildController(ctx, config, stores, db, runner, logger, live)
|
|
@@ -432,6 +436,10 @@ export async function apply(ctx, config) {
|
|
|
432
436
|
const recallUsage = new RecallUsageStore(dataDir, logger);
|
|
433
437
|
await recallUsage.init();
|
|
434
438
|
stores.usage = recallUsage;
|
|
439
|
+
// R9 路由健康学习:跨进程留存"退避后跑通的最小块长",供 runExtraction 收紧 perChunk。
|
|
440
|
+
const routeHealth = new RouteHealthStore(routeHealthPathFor(dataDir));
|
|
441
|
+
await routeHealth.load();
|
|
442
|
+
stores.routeHealth = routeHealth;
|
|
435
443
|
// 召回相关度反馈(人在召回卡上打 -1..5)与「已声明过时」待播报队列。
|
|
436
444
|
// 两个都是低频用户动作 → 立即落盘;sidecar 缺席/损坏只降级为只读,不阻断任何读路径。
|
|
437
445
|
const recallFeedback = new RecallFeedbackStore(dataDir, logger);
|
|
@@ -468,7 +476,12 @@ export async function apply(ctx, config) {
|
|
|
468
476
|
stores.l1.setFileSyncHook(fileSync);
|
|
469
477
|
const recall = registerRecall(ctx, config, stores, logger, live, modes, dataDir, recallUsage);
|
|
470
478
|
runner.setAfterRun(recall.invalidateProfile);
|
|
471
|
-
const toolSeals = registerMemoryTools(ctx, config, stores, logger, modes, live, ruminate, storageOk ? roomRegistry : undefined
|
|
479
|
+
const toolSeals = registerMemoryTools(ctx, config, stores, logger, modes, live, ruminate, storageOk ? roomRegistry : undefined, {
|
|
480
|
+
// 确定性写面钩子(graph-write-plane R2/R3):runner 公开方法直连,工具三路径与
|
|
481
|
+
// distill 分支共用同一个入队实现,不存在第二套优先级/去重逻辑。
|
|
482
|
+
enqueueGraph: (ids) => runner.enqueueGraphFor(ids),
|
|
483
|
+
reprojectGraph: (ids) => runner.reprojectGraph(ids),
|
|
484
|
+
});
|
|
472
485
|
// 激活槽位(active slot):工具面 + 常驻注入 + 服务端投影(均走 ctx.effect,可撤销)
|
|
473
486
|
registerSlotTools(ctx, config, stores.slots, logger, modes, live, stores.l1);
|
|
474
487
|
const slotRecall = registerSlotRecall(ctx, config, stores.slots, logger, live);
|
package/dist/llm.d.ts
CHANGED
|
@@ -71,11 +71,25 @@ export declare const HIGH_EFFORT_TIERS: readonly ["high", "xhigh", "max"];
|
|
|
71
71
|
* 预算致正文 0 字符)——effort 为 high/xhigh/max 时分层预算 ×4。
|
|
72
72
|
*/
|
|
73
73
|
export declare function layerMaxTokens(base: number, reasoningEffort: string): number;
|
|
74
|
+
/** 无可用蒸馏路由(显式一线/二线/链皆缺且 requireExplicitRoute=true):
|
|
75
|
+
* runner 据此 skip+顺延、不记退避——区别于"模型慢/超时"(那才退避)。R4。 */
|
|
76
|
+
export declare class NoDistillRouteError extends Error {
|
|
77
|
+
constructor(msg?: string);
|
|
78
|
+
}
|
|
74
79
|
/** 解析蒸馏用的 provider/model:配置优先,其次当前默认选择。 */
|
|
75
80
|
export declare function resolveModelRoute(ctx: Context, cfg: MemoryConfig): Promise<{
|
|
76
81
|
provider: string;
|
|
77
82
|
model: string;
|
|
78
83
|
}>;
|
|
84
|
+
/**
|
|
85
|
+
* 二线(大容量兜底)选路(R3):单次调用输入字符 ≥ triggerChars 且二线已配 → 返回二线路由,
|
|
86
|
+
* 否则 null(走一线/链)。二线未配(provider/model 任一空)→ 永不命中,不隐式改选任意模型。
|
|
87
|
+
*/
|
|
88
|
+
export declare function pickSecondary(cfg: MemoryConfig, inputChars: number): {
|
|
89
|
+
provider: string;
|
|
90
|
+
model: string;
|
|
91
|
+
reasoningEffort?: string;
|
|
92
|
+
} | null;
|
|
79
93
|
/** 回退链条目(配置形态;reasoningEffort 为该路由的档位覆盖,'' = 跟随全局)。 */
|
|
80
94
|
export interface FallbackRouteEntry {
|
|
81
95
|
provider: string;
|
|
@@ -156,7 +170,12 @@ export declare function planDistillEffort(ctx: Context, provider: string, model:
|
|
|
156
170
|
* 适配器异常地没有发 block-end 时兜底。两者都累计会把输出翻倍。
|
|
157
171
|
*/
|
|
158
172
|
export declare function callLLM(ctx: Context, cfg: MemoryConfig, opts: LlmCallOptions): Promise<string>;
|
|
159
|
-
/** 带诊断日志的 parseJson:解析失败时记录原始输出摘录(模型输出异常排查的关键信息)
|
|
173
|
+
/** 带诊断日志的 parseJson:解析失败时记录原始输出摘录(模型输出异常排查的关键信息);修复/救援命中时 warn 一行。 */
|
|
160
174
|
export declare function parseJsonLogged<T>(raw: string, what: string, logger?: MemoryLogger): T;
|
|
161
|
-
/**
|
|
175
|
+
/**
|
|
176
|
+
* 容错地解析 LLM 输出的 JSON。
|
|
177
|
+
*
|
|
178
|
+
* 旧版行为:剥 ```json``` 围栏 + 切到首个 `[`/`{` 到末尾 `]`/`}`,一次 JSON.parse。
|
|
179
|
+
* 新版同路径 + 三级容错(strict → repaired → salvaged),内部实现见 util/json-repair.ts。
|
|
180
|
+
*/
|
|
162
181
|
export declare function parseJson<T>(raw: string): T;
|