dsh-layered-memory 0.8.4 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +43 -13
- package/README.md +28 -13
- package/assets/img/ui-dark.jpg +0 -0
- package/assets/img/ui-light.jpg +0 -0
- package/assets/readme/bench-dialog.svg +49 -55
- package/dist/bench-control.d.ts +35 -0
- package/dist/bench-control.js +16 -0
- package/dist/client.js +173 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +4 -0
- package/dist/embedding-worker.cjs +176 -0
- package/dist/hooks/recall.d.ts +28 -1
- package/dist/hooks/recall.js +72 -13
- package/dist/index.d.ts +6 -0
- package/dist/index.js +18 -3
- package/dist/llm-usage.d.ts +27 -0
- package/dist/llm-usage.js +39 -0
- package/dist/llm.d.ts +3 -0
- package/dist/llm.js +6 -0
- package/dist/pipeline/l1.js +4 -2
- package/dist/pipeline/l2.js +1 -0
- package/dist/pipeline/l3.js +1 -0
- package/dist/pipeline/runner.d.ts +21 -0
- package/dist/pipeline/runner.js +83 -4
- package/dist/pipeline/trigger.d.ts +2 -0
- package/dist/pipeline/trigger.js +11 -0
- package/dist/prompts/l1-extraction.d.ts +7 -1
- package/dist/prompts/l1-extraction.js +12 -3
- package/dist/stats.d.ts +27 -1
- package/dist/stats.js +37 -2
- package/dist/store/embedding-source.d.ts +2 -1
- package/dist/store/embedding-source.js +7 -2
- package/dist/store/embedding.d.ts +2 -1
- package/dist/store/l0.d.ts +2 -0
- package/dist/store/l0.js +14 -4
- package/dist/store/l1.d.ts +11 -1
- package/dist/store/l1.js +26 -6
- package/dist/store/local-embedding.d.ts +69 -46
- package/dist/store/local-embedding.js +179 -75
- package/dist/store/recall-dedupe.d.ts +26 -0
- package/dist/store/recall-dedupe.js +138 -0
- package/dist/store/runtime-installer.d.ts +0 -2
- package/dist/store/runtime-installer.js +0 -6
- package/dist/store/search-utils.d.ts +17 -0
- package/dist/store/search-utils.js +29 -0
- package/dist/store/sqlite.d.ts +2 -0
- package/dist/store/sqlite.js +37 -9
- package/dist/types.d.ts +8 -0
- package/dist/types.js +8 -0
- package/dist/util/recall-budget.d.ts +2 -2
- package/dist/util/recall-budget.js +2 -2
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -244,6 +244,23 @@ window.__ModuleLoader__.load({
|
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
/** 相对时间(悬浮卡摘要行用):刚刚 / N 分钟前 / N 小时前 / N 天前;无效返回 null。 */
|
|
248
|
+
function fmtAgo(iso) {
|
|
249
|
+
if (!iso) return null;
|
|
250
|
+
try {
|
|
251
|
+
var t = new Date(iso).getTime();
|
|
252
|
+
if (!t) return null;
|
|
253
|
+
var s = Math.floor((Date.now() - t) / 1000);
|
|
254
|
+
if (s < 0) s = 0;
|
|
255
|
+
if (s < 45) return "刚刚";
|
|
256
|
+
if (s < 3600) return Math.floor(s / 60) + " 分钟前";
|
|
257
|
+
if (s < 86400) return Math.floor(s / 3600) + " 小时前";
|
|
258
|
+
return Math.floor(s / 86400) + " 天前";
|
|
259
|
+
} catch (e) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
247
264
|
// ── Switch 组件 ──
|
|
248
265
|
function Switch(props) {
|
|
249
266
|
var on = !!props.checked;
|
|
@@ -673,6 +690,151 @@ window.__ModuleLoader__.load({
|
|
|
673
690
|
props.error
|
|
674
691
|
? react.createElement("div", { style: { fontSize: 11, color: "var(--dsh-mem-danger)", marginTop: 10, whiteSpace: "nowrap" } }, props.error)
|
|
675
692
|
: null,
|
|
693
|
+
// 会话信息区(分隔线 + 2×2 指标 + 状态行):session-stats 热路径端点,
|
|
694
|
+
// 宿主不支持 / 数据缺失时整体不渲染(best-effort 增强,不占位)
|
|
695
|
+
props.rpc && props.sessionId
|
|
696
|
+
? react.createElement(SessionInfoArea, { rpc: props.rpc, sessionId: props.sessionId })
|
|
697
|
+
: null,
|
|
698
|
+
),
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ── 会话信息区:悬浮卡下半部(召回命中 / 攒批进度 / 会话产出 / 会话消息) ──
|
|
703
|
+
// 数据通道 dsh-memory/session-stats(宿主侧零文件 I/O:内存注册表 + 索引 COUNT,
|
|
704
|
+
// 见 slider-spec 数据策略节);自适应轮询:忙时(攒批/挂起/全局待蒸馏)2s,静默 5s,
|
|
705
|
+
// 浮层卸载即停(ModeSlider 只在 pill 展开期间挂载)。
|
|
706
|
+
function sinfoCell(val, label, title) {
|
|
707
|
+
return react.createElement(
|
|
708
|
+
"div",
|
|
709
|
+
{ title: title || null },
|
|
710
|
+
react.createElement("div", { className: "dsh-mem-sinfo-val" }, val),
|
|
711
|
+
react.createElement("div", { className: "dsh-mem-sinfo-label" }, label),
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function SessionInfoArea(props) {
|
|
716
|
+
var rpc = props.rpc;
|
|
717
|
+
var sessionId = props.sessionId;
|
|
718
|
+
// undefined=首帧加载中;null=宿主不支持(整体隐藏);对象=最新快照
|
|
719
|
+
var stState = react.useState(undefined);
|
|
720
|
+
var stats = stState[0];
|
|
721
|
+
var setStats = stState[1];
|
|
722
|
+
var busyRef = react.useRef(false);
|
|
723
|
+
|
|
724
|
+
react.useEffect(function () {
|
|
725
|
+
if (!rpc || !sessionId) return undefined;
|
|
726
|
+
var alive = true;
|
|
727
|
+
var timer = null;
|
|
728
|
+
var seq = 0;
|
|
729
|
+
var tick = function () {
|
|
730
|
+
var token = ++seq;
|
|
731
|
+
rpc("dsh-memory/session-stats", { sessionId: sessionId })
|
|
732
|
+
.then(function (r) {
|
|
733
|
+
if (!alive || token !== seq) return;
|
|
734
|
+
if (r && r.ok && r.value && r.value.supported === false) {
|
|
735
|
+
setStats(null); // 宿主无数据源:整体隐藏
|
|
736
|
+
} else if (r && r.ok && r.value) {
|
|
737
|
+
setStats(r.value);
|
|
738
|
+
var d = r.value.distill || {};
|
|
739
|
+
var g = r.value.global || {};
|
|
740
|
+
busyRef.current = (d.pendingSlice || 0) > 0 || (d.parkedSlices || 0) > 0 || (g.pendingTotal || 0) > 0;
|
|
741
|
+
}
|
|
742
|
+
// RPC 失败:保持旧快照(信息区不因瞬时错误闪没)
|
|
743
|
+
})
|
|
744
|
+
.catch(function () {})
|
|
745
|
+
.then(function () {
|
|
746
|
+
if (alive) timer = setTimeout(tick, busyRef.current ? 2000 : 5000);
|
|
747
|
+
});
|
|
748
|
+
};
|
|
749
|
+
tick();
|
|
750
|
+
return function () {
|
|
751
|
+
alive = false;
|
|
752
|
+
if (timer) clearTimeout(timer);
|
|
753
|
+
};
|
|
754
|
+
}, [rpc, sessionId]);
|
|
755
|
+
|
|
756
|
+
// 首帧占位骨架(防内容跳变);宿主不支持则整体隐藏
|
|
757
|
+
if (stats === null) return null;
|
|
758
|
+
if (stats === undefined) {
|
|
759
|
+
return react.createElement(
|
|
760
|
+
"div",
|
|
761
|
+
{ className: "dsh-mem-sinfo" },
|
|
762
|
+
react.createElement(
|
|
763
|
+
"div",
|
|
764
|
+
{ className: "dsh-mem-sinfo-grid" },
|
|
765
|
+
sinfoCell("…", "召回命中"),
|
|
766
|
+
sinfoCell("…", "攒批进度"),
|
|
767
|
+
sinfoCell("…", "本会话记忆"),
|
|
768
|
+
sinfoCell("…", "会话消息"),
|
|
769
|
+
),
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
var rc = stats.recall || {};
|
|
774
|
+
var di = stats.distill || {};
|
|
775
|
+
var gl = stats.global || {};
|
|
776
|
+
var isOff = stats.mode === "off";
|
|
777
|
+
|
|
778
|
+
// 召回命中:口径是"注入统计"(命中轮次/检索轮次),停用时显示状态而非误导性 0/0
|
|
779
|
+
var rcVal;
|
|
780
|
+
var rcLabel;
|
|
781
|
+
var rcTitle;
|
|
782
|
+
if (rc.enabled === false) {
|
|
783
|
+
rcVal = "停用";
|
|
784
|
+
rcLabel = "召回命中";
|
|
785
|
+
rcTitle = "召回已停用(开关关闭 / 档位关闭 / 部署未启用)";
|
|
786
|
+
} else {
|
|
787
|
+
rcVal = (rc.hitTurns || 0) + "/" + (rc.injectedTurns || 0);
|
|
788
|
+
rcLabel = "召回命中 · " + (rc.totalHits || 0) + " 条";
|
|
789
|
+
rcTitle =
|
|
790
|
+
"最近一轮命中 " + (rc.lastHits || 0) + " 条,耗时 " + (rc.lastDurationMs || 0) + "ms" +
|
|
791
|
+
((rc.timeouts || 0) > 0 ? ",超时跳过 " + rc.timeouts + " 次" : "");
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// 攒批进度(x/生效阈值,含 warmup 爬坡);off 档显示挂起切片数
|
|
795
|
+
var dVal;
|
|
796
|
+
var dLabel;
|
|
797
|
+
var dTitle;
|
|
798
|
+
if (isOff) {
|
|
799
|
+
dVal = String(di.parkedSlices || 0);
|
|
800
|
+
dLabel = "挂起切片";
|
|
801
|
+
dTitle = "档位关闭:未蒸馏切片挂起,切回档位后继续";
|
|
802
|
+
} else {
|
|
803
|
+
dVal = (di.pendingSlice || 0) + "/" + (di.threshold != null ? di.threshold : "-");
|
|
804
|
+
dLabel = (di.parkedSlices || 0) > 0 ? "攒批 · 挂起 " + di.parkedSlices : "攒批进度";
|
|
805
|
+
dTitle = "达到阈值后自动蒸馏(阈值随使用渐进爬坡到稳态)";
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
var pTitle = di.lastDistillAt ? "最近蒸馏 " + fmtTime(di.lastDistillAt) : "本会话尚未蒸馏";
|
|
809
|
+
var warn = gl.degraded ? "⚠ 存储不可用,记忆功能已停用" : null;
|
|
810
|
+
var note = null;
|
|
811
|
+
if (!gl.degraded) {
|
|
812
|
+
if (stats.retrieval === "keyword" && !isOff) note = "检索降级:纯关键词(向量不可用)";
|
|
813
|
+
else if (stats.retrieval === "none") note = "检索不可用(FTS 与向量均失效)";
|
|
814
|
+
}
|
|
815
|
+
var ago = fmtAgo(gl.lastExtractAt);
|
|
816
|
+
|
|
817
|
+
return react.createElement(
|
|
818
|
+
"div",
|
|
819
|
+
{ className: "dsh-mem-sinfo" },
|
|
820
|
+
warn
|
|
821
|
+
? react.createElement("div", { className: "dsh-mem-sinfo-warn" }, warn)
|
|
822
|
+
: null,
|
|
823
|
+
react.createElement(
|
|
824
|
+
"div",
|
|
825
|
+
{ className: "dsh-mem-sinfo-grid" },
|
|
826
|
+
sinfoCell(rcVal, rcLabel, rcTitle),
|
|
827
|
+
sinfoCell(dVal, dLabel, dTitle),
|
|
828
|
+
sinfoCell(String(di.producedRecords || 0), "本会话记忆", pTitle),
|
|
829
|
+
sinfoCell(stats.l0Count != null ? String(stats.l0Count) : "…", "会话消息"),
|
|
830
|
+
),
|
|
831
|
+
note
|
|
832
|
+
? react.createElement("div", { className: "dsh-mem-sinfo-note" }, note)
|
|
833
|
+
: null,
|
|
834
|
+
react.createElement(
|
|
835
|
+
"div",
|
|
836
|
+
{ className: "dsh-mem-sinfo-sum" },
|
|
837
|
+
"待蒸馏 " + (gl.pendingTotal || 0) + " · 上次蒸馏 " + (ago || "尚未蒸馏"),
|
|
676
838
|
),
|
|
677
839
|
);
|
|
678
840
|
}
|
|
@@ -930,6 +1092,15 @@ window.__ModuleLoader__.load({
|
|
|
930
1092
|
" box-shadow: 0 16px 48px rgba(0,0,0,0.24); }",
|
|
931
1093
|
"body[data-ds-dark-theme] .dsh-mem-rb-modal { box-shadow: 0 16px 48px rgba(0,0,0,0.6); }",
|
|
932
1094
|
".dsh-mem-rb-muted { font-size: 12px; color: var(--dsh-mem-text-3); }",
|
|
1095
|
+
// ── 会话信息区(悬浮卡下半部):分隔线 + 2×2 指标 + 状态行;纯静态 DOM,
|
|
1096
|
+
// 不进粒子层 rAF 循环,轮询数据到达才触发本组件小树 re-render ──
|
|
1097
|
+
".dsh-mem-sinfo { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--dsh-mem-border); }",
|
|
1098
|
+
".dsh-mem-sinfo-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 10px; }",
|
|
1099
|
+
".dsh-mem-sinfo-val { font-size: 13px; font-weight: 600; color: var(--dsh-mem-text-1); line-height: 18px; font-variant-numeric: tabular-nums; }",
|
|
1100
|
+
".dsh-mem-sinfo-label { font-size: 11px; color: var(--dsh-mem-text-3); line-height: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }",
|
|
1101
|
+
".dsh-mem-sinfo-warn { font-size: 11px; color: var(--dsh-mem-danger); line-height: 16px; margin-bottom: 6px; }",
|
|
1102
|
+
".dsh-mem-sinfo-note { font-size: 11px; color: var(--dsh-mem-text-3); line-height: 16px; margin-top: 8px; }",
|
|
1103
|
+
".dsh-mem-sinfo-sum { font-size: 11px; color: var(--dsh-mem-text-3); line-height: 16px; margin-top: 8px; }",
|
|
933
1104
|
// reduced-motion 兜底放样式表末尾:同特异性下后置声明才能压过上面的组件类
|
|
934
1105
|
"@media (prefers-reduced-motion: reduce) {",
|
|
935
1106
|
" .dsh-mem-root, .dsh-mem-root *, .dsh-mem-btn, .dsh-mem-input, .dsh-mem-select, .dsh-mem-rb-fill, .dsh-mem-scene-chev, .dsh-mem-sel-chev { transition: none; }",
|
|
@@ -1066,6 +1237,8 @@ window.__ModuleLoader__.load({
|
|
|
1066
1237
|
mode: mode || "auto",
|
|
1067
1238
|
onCommit: commit,
|
|
1068
1239
|
error: error,
|
|
1240
|
+
rpc: rpc,
|
|
1241
|
+
sessionId: sessionId,
|
|
1069
1242
|
})
|
|
1070
1243
|
: null,
|
|
1071
1244
|
);
|
package/dist/config.d.ts
CHANGED
|
@@ -66,6 +66,9 @@ export interface MemoryConfig {
|
|
|
66
66
|
strategy: 'keyword' | 'embedding' | 'hybrid';
|
|
67
67
|
/** 召回路径分数阈值(0~1,低于该分不注入;工具路径不过滤)。 */
|
|
68
68
|
scoreThreshold: number;
|
|
69
|
+
/** 时效衰减半衰期(天,0=关):召回排序的 freshness 加权——score × max(0.5, 0.5^(Δ天/半衰期)),
|
|
70
|
+
* 只影响相关度相近候选间的名次(老记忆最多损失一半排序分,不淘汰)。 */
|
|
71
|
+
decayHalfLifeDays: number;
|
|
69
72
|
};
|
|
70
73
|
embedding: {
|
|
71
74
|
/** 向量检索总开关;关闭时纯 FTS 运行(官方 provider="none" 同款)。 */
|
|
@@ -113,6 +116,9 @@ export interface MemoryConfig {
|
|
|
113
116
|
};
|
|
114
117
|
/** 是否注册模型可调用的记忆工具。 */
|
|
115
118
|
tools: boolean;
|
|
119
|
+
/** 注册 bench 控制服务(dsh-memory-bench,进程内 rebuild 触发面)。
|
|
120
|
+
* 仅供基准/调试部署(bench profile 的 lifecycle 赛道),默认关——生产零表面积。 */
|
|
121
|
+
benchControl: boolean;
|
|
116
122
|
}
|
|
117
123
|
export declare const memorySchema: Schema<Schemastery.ObjectS<{
|
|
118
124
|
dataDir: Schema<string, string>;
|
|
@@ -167,6 +173,7 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
|
|
|
167
173
|
includeSceneNav: Schema<boolean, boolean>;
|
|
168
174
|
strategy: Schema<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
|
|
169
175
|
scoreThreshold: Schema<number, number>;
|
|
176
|
+
decayHalfLifeDays: Schema<number, number>;
|
|
170
177
|
}>, Schemastery.ObjectT<{
|
|
171
178
|
enabled: Schema<boolean, boolean>;
|
|
172
179
|
maxResults: Schema<number, number>;
|
|
@@ -177,6 +184,7 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
|
|
|
177
184
|
includeSceneNav: Schema<boolean, boolean>;
|
|
178
185
|
strategy: Schema<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
|
|
179
186
|
scoreThreshold: Schema<number, number>;
|
|
187
|
+
decayHalfLifeDays: Schema<number, number>;
|
|
180
188
|
}>>;
|
|
181
189
|
embedding: Schema<Schemastery.ObjectS<{
|
|
182
190
|
enabled: Schema<boolean, boolean>;
|
|
@@ -219,6 +227,7 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
|
|
|
219
227
|
timeoutMs: Schema<number, number>;
|
|
220
228
|
}>>;
|
|
221
229
|
tools: Schema<boolean, boolean>;
|
|
230
|
+
benchControl: Schema<boolean, boolean>;
|
|
222
231
|
}>, Schemastery.ObjectT<{
|
|
223
232
|
dataDir: Schema<string, string>;
|
|
224
233
|
family: Schema<"chat" | "work" | "auto", "chat" | "work" | "auto">;
|
|
@@ -272,6 +281,7 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
|
|
|
272
281
|
includeSceneNav: Schema<boolean, boolean>;
|
|
273
282
|
strategy: Schema<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
|
|
274
283
|
scoreThreshold: Schema<number, number>;
|
|
284
|
+
decayHalfLifeDays: Schema<number, number>;
|
|
275
285
|
}>, Schemastery.ObjectT<{
|
|
276
286
|
enabled: Schema<boolean, boolean>;
|
|
277
287
|
maxResults: Schema<number, number>;
|
|
@@ -282,6 +292,7 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
|
|
|
282
292
|
includeSceneNav: Schema<boolean, boolean>;
|
|
283
293
|
strategy: Schema<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
|
|
284
294
|
scoreThreshold: Schema<number, number>;
|
|
295
|
+
decayHalfLifeDays: Schema<number, number>;
|
|
285
296
|
}>>;
|
|
286
297
|
embedding: Schema<Schemastery.ObjectS<{
|
|
287
298
|
enabled: Schema<boolean, boolean>;
|
|
@@ -324,5 +335,6 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
|
|
|
324
335
|
timeoutMs: Schema<number, number>;
|
|
325
336
|
}>>;
|
|
326
337
|
tools: Schema<boolean, boolean>;
|
|
338
|
+
benchControl: Schema<boolean, boolean>;
|
|
327
339
|
}>>;
|
|
328
340
|
export declare function resolveDataDir(cfg: MemoryConfig): string;
|
package/dist/config.js
CHANGED
|
@@ -48,6 +48,9 @@ export const memorySchema = Schema.object({
|
|
|
48
48
|
includeSceneNav: Schema.boolean().default(true),
|
|
49
49
|
strategy: Schema.union(['keyword', 'embedding', 'hybrid']).default('hybrid'),
|
|
50
50
|
scoreThreshold: Schema.number().min(0).max(1).default(0.3),
|
|
51
|
+
// 时效衰减(#29):乘法软加权 + 地板 0.5(老记忆最多损失一半排序分),只轮转
|
|
52
|
+
// 相关度相近候选的名次;0=关(bench 基线可比性可 pin 0)
|
|
53
|
+
decayHalfLifeDays: Schema.number().min(0).max(3650).default(30),
|
|
51
54
|
}),
|
|
52
55
|
embedding: Schema.object({
|
|
53
56
|
enabled: Schema.boolean().default(false),
|
|
@@ -78,6 +81,7 @@ export const memorySchema = Schema.object({
|
|
|
78
81
|
timeoutMs: Schema.number().min(1000).max(600_000).default(120_000),
|
|
79
82
|
}),
|
|
80
83
|
tools: Schema.boolean().default(true),
|
|
84
|
+
benchControl: Schema.boolean().default(false),
|
|
81
85
|
});
|
|
82
86
|
export function resolveDataDir(cfg) {
|
|
83
87
|
if (cfg.dataDir)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地嵌入 worker:transformers.js 的模型加载与推理在本线程执行,主线程只
|
|
3
|
+
* 投递文本、收回向量。onnxruntime-node(v1.24.3)的 run/loadModel 是
|
|
4
|
+
* setImmediate 回调里的同步调用(Promise 包装不卸载计算),留在主线程会
|
|
5
|
+
* 冻结宿主事件循环——dsh 页面一切交互无响应数秒的根因。
|
|
6
|
+
*
|
|
7
|
+
* 协议(与 src/store/local-embedding.ts 的 RealWorkerChannel 配对;自增 id):
|
|
8
|
+
* - 入向:{id,type:'ping'} | {id,type:'warmup'} | {id,type:'embed',texts,priority}
|
|
9
|
+
* - 出向:{id,ok:true,type:'pong'|'ready'|'embedded',vectors?}(向量经
|
|
10
|
+
* transfer 列表零拷贝回传)| {id,ok:false,stage:'load'|'infer',error}
|
|
11
|
+
* | {type:'fatal',error}
|
|
12
|
+
*
|
|
13
|
+
* 调度:单线程串行。embedBatch 拆成逐条 job、条间 setImmediate 让路——
|
|
14
|
+
* 单条请求(priority,即召回 query)unshift 插队,最坏只等"正在算的那一条",
|
|
15
|
+
* 不会被 reindex 批次(16/32 条 ≈5-10s)堵在队尾。
|
|
16
|
+
*
|
|
17
|
+
* 信任边界(沿 AGENTS.md 0.8.2 审查记录):runtime/ 目录在数据目录信任边界内,
|
|
18
|
+
* 本线程按其 package.json createRequire 加载 transformers——对数据目录有写
|
|
19
|
+
* 权限的进程理论上可植入恶意模块;模型文件逐文件 sha256 锁定,运行时代码
|
|
20
|
+
* 无同级防护,完整修复需安装指纹设计,暂以文档化假设为准。
|
|
21
|
+
*/
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const { parentPort, workerData } = require('node:worker_threads');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const { createRequire } = require('node:module');
|
|
27
|
+
|
|
28
|
+
/** 错误消息归一化(Error 取 message,其余 String 化)。 */
|
|
29
|
+
function errMsg(err) {
|
|
30
|
+
return err && err.message ? err.message : String(err);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const cfg = workerData || {};
|
|
34
|
+
const RUNTIME_DIR = typeof cfg.runtimeDir === 'string' ? cfg.runtimeDir : '';
|
|
35
|
+
const MODEL_DIR = typeof cfg.modelDir === 'string' ? cfg.modelDir : '';
|
|
36
|
+
const POOLING = cfg.pooling === 'cls' ? 'cls' : 'mean';
|
|
37
|
+
const DTYPE = typeof cfg.dtype === 'string' ? cfg.dtype : 'q8';
|
|
38
|
+
const MAX_INPUT_CHARS = cfg.maxInputChars > 0 ? cfg.maxInputChars : 5000;
|
|
39
|
+
|
|
40
|
+
let extractor = null; // pipeline('feature-extraction', …)
|
|
41
|
+
let loadState = 'idle'; // idle | loading | ready | failed
|
|
42
|
+
let loadPromise = null;
|
|
43
|
+
let loadError = null;
|
|
44
|
+
|
|
45
|
+
// ── 模型懒加载(首请求触发;失败后经 warmup 可重试,embed 请求失败快返回) ──
|
|
46
|
+
function ensureLoaded() {
|
|
47
|
+
if (loadState === 'ready') return Promise.resolve();
|
|
48
|
+
if (loadState === 'loading' && loadPromise) return loadPromise;
|
|
49
|
+
loadState = 'loading';
|
|
50
|
+
loadError = null;
|
|
51
|
+
loadPromise = (async () => {
|
|
52
|
+
try {
|
|
53
|
+
if (!RUNTIME_DIR || !MODEL_DIR) throw new Error('workerData 缺少 runtimeDir/modelDir');
|
|
54
|
+
const req = createRequire(path.join(RUNTIME_DIR, 'package.json'));
|
|
55
|
+
const mod = req('@huggingface/transformers');
|
|
56
|
+
if (mod.env) {
|
|
57
|
+
mod.env.allowRemoteModels = false;
|
|
58
|
+
if (mod.env.allowLocalModels !== undefined) mod.env.allowLocalModels = true;
|
|
59
|
+
}
|
|
60
|
+
extractor = await mod.pipeline('feature-extraction', MODEL_DIR, { dtype: DTYPE });
|
|
61
|
+
loadState = 'ready';
|
|
62
|
+
} catch (err) {
|
|
63
|
+
loadState = 'failed';
|
|
64
|
+
loadError = errMsg(err);
|
|
65
|
+
loadPromise = null; // 失败后下一次 warmup 重新走加载
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
})();
|
|
69
|
+
return loadPromise;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── 推理队列(逐条 job;priority 插队;条间让路) ──
|
|
73
|
+
const queue = []; // [{ request, index, text }]
|
|
74
|
+
let pumping = false;
|
|
75
|
+
|
|
76
|
+
function postReply(msg, transfer) {
|
|
77
|
+
parentPort.postMessage(msg, transfer || []);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 请求全部条目完成 → 回执(transfer 向量缓冲零拷贝)。 */
|
|
81
|
+
function settle(request) {
|
|
82
|
+
if (request.failed || request.remaining !== 0) return;
|
|
83
|
+
postReply(
|
|
84
|
+
{ id: request.id, ok: true, type: 'embedded', vectors: request.vectors },
|
|
85
|
+
request.vectors.map((v) => v.buffer),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 首个失败即回执错误,并移除该请求尚未开跑的条目(正在跑的至多 1 条,算完即弃)。 */
|
|
90
|
+
function failRequest(request, error, stage) {
|
|
91
|
+
if (request.failed) return;
|
|
92
|
+
request.failed = true;
|
|
93
|
+
for (let i = queue.length - 1; i >= 0; i--) {
|
|
94
|
+
if (queue[i].request === request) queue.splice(i, 1);
|
|
95
|
+
}
|
|
96
|
+
postReply({ id: request.id, ok: false, stage: stage, error: errMsg(error) });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function pump() {
|
|
100
|
+
if (pumping) return;
|
|
101
|
+
pumping = true;
|
|
102
|
+
try {
|
|
103
|
+
while (queue.length > 0) {
|
|
104
|
+
const job = queue.shift();
|
|
105
|
+
if (job.request.failed) continue;
|
|
106
|
+
try {
|
|
107
|
+
const result = await extractor([job.text.slice(0, MAX_INPUT_CHARS)], { pooling: POOLING, normalize: true });
|
|
108
|
+
const data = result[0] && result[0].data;
|
|
109
|
+
job.request.vectors[job.index] = data instanceof Float32Array ? data : new Float32Array(data);
|
|
110
|
+
job.request.remaining -= 1;
|
|
111
|
+
settle(job.request);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
failRequest(job.request, err, 'infer');
|
|
114
|
+
}
|
|
115
|
+
// 条间让路:交还本线程事件循环,新消息(含 priority 插队)可在批间被处理
|
|
116
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
pumping = false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function handleEmbed(msg) {
|
|
124
|
+
const texts = Array.isArray(msg.texts) ? msg.texts : [];
|
|
125
|
+
if (texts.length === 0) {
|
|
126
|
+
postReply({ id: msg.id, ok: true, type: 'embedded', vectors: [] });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const request = { id: msg.id, vectors: new Array(texts.length), remaining: texts.length, failed: false };
|
|
130
|
+
// 单条请求(召回 query)插队到队首;批次追加到队尾(批内条目间仍会被后来的插队)
|
|
131
|
+
const priority = msg.priority === true && texts.length === 1;
|
|
132
|
+
for (let i = 0; i < texts.length; i++) {
|
|
133
|
+
const job = { request: request, index: i, text: String(texts[i] == null ? '' : texts[i]) };
|
|
134
|
+
if (priority) queue.unshift(job);
|
|
135
|
+
else queue.push(job);
|
|
136
|
+
}
|
|
137
|
+
void pump();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
parentPort.on('message', (msg) => {
|
|
141
|
+
if (!msg || typeof msg.id !== 'number' || typeof msg.type !== 'string') return;
|
|
142
|
+
if (msg.type === 'ping') {
|
|
143
|
+
postReply({ id: msg.id, ok: true, type: 'pong' });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (msg.type === 'warmup') {
|
|
147
|
+
ensureLoaded().then(
|
|
148
|
+
() => postReply({ id: msg.id, ok: true, type: 'ready' }),
|
|
149
|
+
() => postReply({ id: msg.id, ok: false, stage: 'load', error: loadError ?? '模型加载失败' }),
|
|
150
|
+
);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (msg.type === 'embed') {
|
|
154
|
+
void (async () => {
|
|
155
|
+
try {
|
|
156
|
+
await ensureLoaded();
|
|
157
|
+
} catch {
|
|
158
|
+
postReply({ id: msg.id, ok: false, stage: 'load', error: loadError ?? '模型加载失败' });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
handleEmbed(msg);
|
|
162
|
+
})();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// 未捕获异常:尽力通知主线程后强制退出(主线程的 exit 处理会拒绝全部 pending
|
|
168
|
+
// 并转入 failed 态)。不保持半死状态——半死的 worker 只会让调用方挂到超时。
|
|
169
|
+
process.on('uncaughtException', (err) => {
|
|
170
|
+
try {
|
|
171
|
+
postReply({ type: 'fatal', error: errMsg(err) });
|
|
172
|
+
} catch {
|
|
173
|
+
/* 端口已坏,exit 处理兜底 */
|
|
174
|
+
}
|
|
175
|
+
setTimeout(() => process.exit(1), 50);
|
|
176
|
+
});
|
package/dist/hooks/recall.d.ts
CHANGED
|
@@ -30,12 +30,39 @@ import type { MemoryLogger } from '../types.js';
|
|
|
30
30
|
export declare function buildRecallQuery(messages: Array<{
|
|
31
31
|
content: unknown;
|
|
32
32
|
}>, tailMessages?: number, maxChars?: number): string;
|
|
33
|
+
/** 单会话召回统计(悬浮卡信息区数据源;每轮 O(1) 记账,agent/disposed 清理)。
|
|
34
|
+
* 口径声明:这是"注入统计"而非 bench 的离线 recall@k——运行时没有 ground truth,
|
|
35
|
+
* 命中率 = hitTurns / injectedTurns。去重语义(0.8.6):全量压制轮计入 hitTurns
|
|
36
|
+
* (相关记忆已在模型上下文里,本质是命中而非未命中),injectedTurns 仍计全部
|
|
37
|
+
* 发生过检索的轮次(保住分母语义与悬浮卡口径连续性)。 */
|
|
38
|
+
export interface RecallSessionStats {
|
|
39
|
+
/** 发生过召回检索的轮次数(含零命中、全量压制与超时)。 */
|
|
40
|
+
injectedTurns: number;
|
|
41
|
+
/** 命中(≥1 条实际注入,或有命中但被去重全量压制)轮次数。 */
|
|
42
|
+
hitTurns: number;
|
|
43
|
+
/** 累计注入条数(去重过滤后、预算截断前)。 */
|
|
44
|
+
totalHits: number;
|
|
45
|
+
/** 总预算超时跳过次数。 */
|
|
46
|
+
timeouts: number;
|
|
47
|
+
/** 累计被去重压制的命中条数(同会话已注入过,不重复注入)。 */
|
|
48
|
+
suppressedRecalls: number;
|
|
49
|
+
/** 最近一轮实际注入条数(0 = 零命中/超时/全量压制;工具指南门控沿用此信号)。 */
|
|
50
|
+
lastHits: number;
|
|
51
|
+
/** 最近一轮检索耗时 ms。 */
|
|
52
|
+
lastDurationMs: number;
|
|
53
|
+
/** 最近一次记账时间(LRU 清理依据)。 */
|
|
54
|
+
updatedAt: number;
|
|
55
|
+
}
|
|
56
|
+
/** 新建零值统计(首次出现的会话)。 */
|
|
57
|
+
export declare function emptyRecallStats(now?: number): RecallSessionStats;
|
|
33
58
|
export interface RecallHooks {
|
|
34
59
|
/** 管线更新后调用:画像/场景缓存立即失效并异步刷新。 */
|
|
35
60
|
invalidateProfile(): void;
|
|
61
|
+
/** 会话召回统计只读视图(未发生过检索的会话返回 undefined)。 */
|
|
62
|
+
stats(sessionId: string): RecallSessionStats | undefined;
|
|
36
63
|
}
|
|
37
64
|
export declare function registerRecall(ctx: Context, cfg: MemoryConfig, stores: {
|
|
38
65
|
l1: L1Store;
|
|
39
66
|
scenes: Record<'chat' | 'work', SceneStore>;
|
|
40
67
|
persona: Record<'chat' | 'work', PersonaStore>;
|
|
41
|
-
}, logger: MemoryLogger, live: LiveSettingsHandle, modes: SessionModeStore): RecallHooks;
|
|
68
|
+
}, logger: MemoryLogger, live: LiveSettingsHandle, modes: SessionModeStore, dataDir: string): RecallHooks;
|
package/dist/hooks/recall.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
2
|
+
import { RecallDedupeStore } from '../store/recall-dedupe.js';
|
|
2
3
|
import { applyRecallBudget, raceRecallTimeout, RECALL_EMBED_CAP_MS } from '../util/recall-budget.js';
|
|
3
4
|
import { errDetail } from '../util/filelog.js';
|
|
4
5
|
import { blocksToText } from '../util/text.js';
|
|
@@ -35,9 +36,32 @@ const MEMORY_TOOLS_GUIDE = `<memory-tools-guide>
|
|
|
35
36
|
注:若当前环境限制直接调用工具(如仅允许代码执行入口),请经由该环境的工具调用机制
|
|
36
37
|
(如 run_code 程序内)使用以上记忆工具。
|
|
37
38
|
</memory-tools-guide>`;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
/** 新建零值统计(首次出现的会话)。 */
|
|
40
|
+
export function emptyRecallStats(now = Date.now()) {
|
|
41
|
+
return {
|
|
42
|
+
injectedTurns: 0,
|
|
43
|
+
hitTurns: 0,
|
|
44
|
+
totalHits: 0,
|
|
45
|
+
timeouts: 0,
|
|
46
|
+
suppressedRecalls: 0,
|
|
47
|
+
lastHits: 0,
|
|
48
|
+
lastDurationMs: 0,
|
|
49
|
+
updatedAt: now,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
|
|
53
|
+
/** 召回去重存储(同会话已注入的记忆不再重复注入;写穿持久化,重启不丢)。 */
|
|
54
|
+
const dedupe = new RecallDedupeStore(dataDir, logger);
|
|
55
|
+
/** 每 agent 召回统计(工具指南门控读 lastHits;悬浮卡信息区读全量计数)。 */
|
|
56
|
+
const recallStats = new Map();
|
|
57
|
+
const statFor = (id) => {
|
|
58
|
+
let s = recallStats.get(id);
|
|
59
|
+
if (!s) {
|
|
60
|
+
s = emptyRecallStats();
|
|
61
|
+
recallStats.set(id, s);
|
|
62
|
+
}
|
|
63
|
+
return s;
|
|
64
|
+
};
|
|
41
65
|
// 画像/场景导航按族缓存(分族隔离:注入时按会话档位选族)
|
|
42
66
|
const profileCache = {
|
|
43
67
|
chat: { persona: '', nav: '' },
|
|
@@ -65,9 +89,17 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes) {
|
|
|
65
89
|
const invalidateProfile = () => {
|
|
66
90
|
void refreshProfile();
|
|
67
91
|
};
|
|
68
|
-
// agent
|
|
92
|
+
// agent 销毁时清掉召回统计槽(去重记录不随 agent 清——持久化语义:会话恢复后继续压制)
|
|
69
93
|
ctx.on('agent/disposed', (payload) => {
|
|
70
|
-
|
|
94
|
+
recallStats.delete(payload.agent.id);
|
|
95
|
+
});
|
|
96
|
+
// 上下文压缩/清空 → 已注入内容从模型上下文丢失,重置该会话的去重压制
|
|
97
|
+
// (resume/startup 不重置:历史仍在,已注入的记忆模型还持有)。
|
|
98
|
+
ctx.on('agent/session-start', (payload) => {
|
|
99
|
+
if (payload.source === 'compact' || payload.source === 'clear') {
|
|
100
|
+
dedupe.reset(payload.agent.id);
|
|
101
|
+
logger.info(`[memory] 召回去重重置(agent=${payload.agent.id},source=${payload.source})`);
|
|
102
|
+
}
|
|
71
103
|
});
|
|
72
104
|
// ── 1. pre-step 消息侧注入:记忆先行于每一条新的用户输入(ADR-0001) ──
|
|
73
105
|
// prepend 注册 + 先 next() 再改写:不劫持其他监听器(dsh-time-context 官方范式)。
|
|
@@ -86,25 +118,52 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes) {
|
|
|
86
118
|
if (!hasNewUserMessage)
|
|
87
119
|
return decision;
|
|
88
120
|
const query = buildRecallQuery(payload.messages);
|
|
89
|
-
|
|
90
|
-
if (!query)
|
|
121
|
+
// 空查询是退化轮(无用户文本),重置命中信号但不计入统计
|
|
122
|
+
if (!query) {
|
|
123
|
+
const degenerate = statFor(payload.agent.id);
|
|
124
|
+
degenerate.lastHits = 0;
|
|
91
125
|
return decision;
|
|
126
|
+
}
|
|
127
|
+
const st = statFor(payload.agent.id);
|
|
128
|
+
st.injectedTurns++;
|
|
129
|
+
st.lastHits = 0;
|
|
130
|
+
st.updatedAt = Date.now();
|
|
131
|
+
const searchStart = Date.now();
|
|
92
132
|
const hits = await raceRecallTimeout(stores.l1.search(query, cfg.recall.maxResults, {
|
|
93
133
|
scoreThreshold: cfg.recall.scoreThreshold,
|
|
94
134
|
family: mode === 'auto' ? undefined : mode,
|
|
95
|
-
//
|
|
135
|
+
// 嵌入内层钳制:给 FTS 降级留出总预算内的时间(远程限 HTTP fetch;本地经 worker 代理 race 放弃)
|
|
96
136
|
embeddingTimeoutMs: RECALL_EMBED_CAP_MS,
|
|
97
137
|
}), cfg.recall.timeoutMs);
|
|
138
|
+
st.updatedAt = Date.now();
|
|
98
139
|
if (hits === undefined) {
|
|
140
|
+
st.timeouts++;
|
|
99
141
|
logger.warn('[memory] 召回超时,跳过本轮注入(不阻塞对话)');
|
|
100
142
|
return decision;
|
|
101
143
|
}
|
|
102
|
-
|
|
103
|
-
|
|
144
|
+
st.lastDurationMs = Date.now() - searchStart;
|
|
145
|
+
// 召回去重:同会话已注入过的记录不再重复注入(模型上下文已持有,省 token)。
|
|
146
|
+
// 纯过滤——剩几条注几条,全量压制(0 条新鲜命中)是正确状态而非未命中。
|
|
147
|
+
const seen = dedupe.seen(payload.agent.id);
|
|
148
|
+
const fresh = hits.filter((h) => !seen.has(h.id));
|
|
149
|
+
const suppressed = hits.length - fresh.length;
|
|
150
|
+
st.suppressedRecalls += suppressed;
|
|
151
|
+
if (suppressed > 0) {
|
|
152
|
+
logger.debug?.(`[memory] 召回去重:压制 ${suppressed} 条已注入记忆(agent=${payload.agent.id},余 ${fresh.length} 条新鲜命中)`);
|
|
153
|
+
}
|
|
154
|
+
if (hits.length > 0) {
|
|
155
|
+
// 全量压制轮也计入命中:相关记忆已在模型上下文里,本质是命中
|
|
156
|
+
st.hitTurns++;
|
|
157
|
+
st.totalHits += fresh.length;
|
|
158
|
+
}
|
|
159
|
+
if (fresh.length === 0)
|
|
104
160
|
return decision;
|
|
105
|
-
const lines = applyRecallBudget(
|
|
161
|
+
const lines = applyRecallBudget(fresh.map((h) => `- [${h.scene_name ? `${h.type}|${h.scene_name}` : h.type}] ${h.content}`), { maxCharsPerMemory: cfg.recall.maxCharsPerMemory, maxTotalRecallChars: cfg.recall.maxTotalRecallChars });
|
|
106
162
|
if (lines.length === 0)
|
|
107
163
|
return decision;
|
|
164
|
+
// 预算截断只丢尾部(前缀保留):实际注入 = fresh 的前 lines.length 条——只标记模型真实看到的
|
|
165
|
+
dedupe.mark(payload.agent.id, fresh.slice(0, lines.length).map((h) => h.id));
|
|
166
|
+
st.lastHits = lines.length;
|
|
108
167
|
const text = [
|
|
109
168
|
'<relevant-memories>',
|
|
110
169
|
'以下是当前对话召回的相关记忆,不代表当前任务进程,仅作为参考:',
|
|
@@ -156,7 +215,7 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes) {
|
|
|
156
215
|
const body = mode === 'auto'
|
|
157
216
|
? formatProfileAuto(profileCache.chat, profileCache.work)
|
|
158
217
|
: formatProfileSingle(profileCache[mode]);
|
|
159
|
-
const hasRecallHit = (
|
|
218
|
+
const hasRecallHit = (recallStats.get(agent.id)?.lastHits ?? 0) > 0;
|
|
160
219
|
// 指南三条件门控:工具已注册(cfg.tools)&&(稳定内容 ∥ 本轮召回命中)——
|
|
161
220
|
// 空库用户与关闭工具的用户不付这份固定 token(原版 auto-recall 同款语义)
|
|
162
221
|
if (!cfg.tools)
|
|
@@ -189,7 +248,7 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes) {
|
|
|
189
248
|
}
|
|
190
249
|
}
|
|
191
250
|
});
|
|
192
|
-
return { invalidateProfile };
|
|
251
|
+
return { invalidateProfile, stats: (id) => recallStats.get(id) };
|
|
193
252
|
}
|
|
194
253
|
/** auto 档 <user-persona> 内的域说明:让模型理解分块结构与两域的独立性。 */
|
|
195
254
|
const DOMAIN_HINT = '以下内容按记忆域分块:chat=用户个人画像(User Narrative Profile),work=团队工作准则(Team Operating Doctrine)。' +
|