dsh-layered-memory 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/dist/client.js +221 -2
- package/dist/config.js +1 -1
- package/dist/index.js +6 -1
- package/dist/pipeline/rebuild.d.ts +78 -0
- package/dist/pipeline/rebuild.js +300 -0
- package/dist/pipeline/runner.d.ts +36 -4
- package/dist/pipeline/runner.js +109 -20
- package/dist/stats.d.ts +2 -1
- package/dist/stats.js +27 -2
- package/dist/store/pending.d.ts +15 -0
- package/dist/store/pending.js +56 -0
- package/dist/store/sqlite.d.ts +15 -0
- package/dist/store/sqlite.js +76 -0
- package/dist/store/state.d.ts +6 -0
- package/dist/store/state.js +11 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,6 +27,16 @@ L0 捕获 → L1 原子记忆 → L2 场景整合 → L3 画像蒸馏,模型
|
|
|
27
27
|
管线原则:所有蒸馏调用复用 DSH 自身的 `ctx.llm`;任何阶段失败只记日志、绝不阻塞
|
|
28
28
|
Agent 循环;召回注入用标签包裹、捕获侧自动剥离,防止反馈循环。
|
|
29
29
|
|
|
30
|
+
**未蒸馏缓冲持久化**:抽取失败的待重试消息与攒触发阈值中途的消息都暂存在按档分桶的
|
|
31
|
+
缓冲里(`pending.json`,每次蒸馏尝试后原子落盘)——重启不丢,启动 20 秒后自动补跑一次,
|
|
32
|
+
失败则维持"等下一轮同档对话"的语义。
|
|
33
|
+
|
|
34
|
+
**重建**(设置页 → 记忆 → 概览 → 重建记忆):以 L0 原始对话为事实源重新推导全部派生层。
|
|
35
|
+
旧 `records/`、`scenes/`、`persona-*.md` 整体归档(改名 `*.bak.<时间戳>`,不删除),检索库
|
|
36
|
+
L1 清空、checkpoint 重置,随后按会话分块、统一 auto 档重蒸馏 L1→L2→L3(收尾强制跑一轮
|
|
37
|
+
L2 残余 + L3 冷启动)。重建分块走低优先级队列——期间正常对话的蒸馏优先进行;带确认弹窗
|
|
38
|
+
(会话数/消息数/预计调用数)、进度条与取消(已重建部分保留)。
|
|
39
|
+
|
|
30
40
|
### 会话级记忆档位
|
|
31
41
|
|
|
32
42
|
每个会话可独立选择记忆档位,**写入与召回同档**:
|
|
@@ -134,7 +144,7 @@ npx tsc src/smoke.ts --outDir dist-smoke --module nodenext --moduleResolution no
|
|
|
134
144
|
| `embedding.model` | 空 | embedding 模型名 |
|
|
135
145
|
| `embedding.dimensions` | `0` | 向量维度(启用时必填,须与模型输出一致) |
|
|
136
146
|
| `llm.provider/model` | 空 | 蒸馏模型覆盖(默认用当前默认选择) |
|
|
137
|
-
| `llm.maxTokens` | `
|
|
147
|
+
| `llm.maxTokens` | `256000` | 单次蒸馏输出 token 上限(全阶段统一;推理模型的 reasoning 与正文共享该预算,过低会被思考吃光导致正文 0 字符) |
|
|
138
148
|
| `llm.reasoningEffort` | `off` | 蒸馏思考档位(部署默认):`off` / `high` / `max`,空串不传(跟随模型默认)。蒸馏是结构化抽取任务,默认关思考——推理模型(如 v4-flash)默认 high 档的思考可把任意输出预算全部吃光导致正文 0 字符;非推理模型不认识 effort 时需设为空串。运行时可在设置页 → 记忆 → 概览临时切换(选"跟随配置"即回退本值) |
|
|
139
149
|
| `llm.temperature` | `0.3` | 蒸馏温度 |
|
|
140
150
|
| `llm.maxInputChars` | `700000` | 单次蒸馏输入字符预算(超限的 L1 输入自动分块抽取) |
|
|
@@ -165,7 +175,9 @@ memory/
|
|
|
165
175
|
├── persona-chat.md # L3 画像(chat 族:用户画像)
|
|
166
176
|
├── persona-work.md # L3 画像(work 族:Team Operating Doctrine)
|
|
167
177
|
├── state.json # 管线 checkpoint(v2 分族:families.chat / families.work)
|
|
178
|
+
├── pending.json # 未蒸馏缓冲(按档分桶;重启恢复 + 启动自动补跑)
|
|
168
179
|
├── session-modes.json # 会话档位映射(sessionId → 档位,>90 天自动清理)
|
|
180
|
+
├── records.bak.<ts>/ # 重建时归档的旧产物(scenes/persona 同款 *.bak.<ts>)
|
|
169
181
|
└── memory.log # 诊断日志(info+,超 2MB 轮转为 memory.log.1)
|
|
170
182
|
```
|
|
171
183
|
|
package/dist/client.js
CHANGED
|
@@ -492,8 +492,9 @@ window.__ModuleLoader__.load({
|
|
|
492
492
|
);
|
|
493
493
|
}
|
|
494
494
|
|
|
495
|
-
// ── auto 档边缘流光 +
|
|
496
|
-
//
|
|
495
|
+
// ── auto 档边缘流光 + 浮层玻璃材质 + 重建面板样式:inline style 放不了
|
|
496
|
+
// @keyframes/@property/媒体查询,惰性注入一次性样式表(id 防重复)。
|
|
497
|
+
// dsw 主题变量参考其前端令牌:暗色走 body[data-ds-dark-theme]。 ──
|
|
497
498
|
var FLOW_STYLE_ID = "dsh-mem-flow-style";
|
|
498
499
|
function ensureFlowStyle() {
|
|
499
500
|
if (document.getElementById(FLOW_STYLE_ID)) return;
|
|
@@ -548,6 +549,41 @@ window.__ModuleLoader__.load({
|
|
|
548
549
|
"@media (prefers-reduced-transparency: reduce) {",
|
|
549
550
|
" .dsh-mem-glass { background: var(--dsw-alias-bg-layer-2, #ffffff); backdrop-filter: none; -webkit-backdrop-filter: none; }",
|
|
550
551
|
"}",
|
|
552
|
+
// ── 重建面板(Light/Dark 双主题:基底用 dsw 真实令牌 bg-layer-2/border-l2,
|
|
553
|
+
// 暗色由 body[data-ds-dark-theme] 切换;强调红/进度蓝紫双主题各调一档) ──
|
|
554
|
+
".dsh-mem-rb-card {",
|
|
555
|
+
" border: 1px solid var(--dsw-alias-border-l2, #e5e7eb);",
|
|
556
|
+
" border-radius: 10px;",
|
|
557
|
+
" background: var(--dsw-alias-bg-layer-2, #ffffff);",
|
|
558
|
+
" padding: 12px 14px;",
|
|
559
|
+
" margin-bottom: 14px;",
|
|
560
|
+
" font-size: 13px;",
|
|
561
|
+
"}",
|
|
562
|
+
".dsh-mem-rb-btn {",
|
|
563
|
+
" padding: 5px 14px; font-size: 13px; border-radius: 6px; cursor: pointer;",
|
|
564
|
+
" border: 1px solid var(--dsw-alias-border-l2, #d0d7de);",
|
|
565
|
+
" background: var(--dsw-alias-bg-layer-2, #ffffff);",
|
|
566
|
+
" color: inherit;",
|
|
567
|
+
"}",
|
|
568
|
+
".dsh-mem-rb-btn:disabled { opacity: 0.45; cursor: not-allowed; }",
|
|
569
|
+
".dsh-mem-rb-danger { border-color: rgba(207,34,46,0.45); color: #cf222e; background: transparent; }",
|
|
570
|
+
"body[data-ds-dark-theme] .dsh-mem-rb-danger { color: #f4707b; border-color: rgba(244,112,123,0.5); }",
|
|
571
|
+
".dsh-mem-rb-primary { background: #cf222e; border-color: #cf222e; color: #fff; }",
|
|
572
|
+
"body[data-ds-dark-theme] .dsh-mem-rb-primary { background: #b62324; border-color: #b62324; }",
|
|
573
|
+
".dsh-mem-rb-bar { height: 8px; border-radius: 4px; overflow: hidden; flex: 1;",
|
|
574
|
+
" background: var(--dsw-alias-border-l2, #e8ebef); }",
|
|
575
|
+
"body[data-ds-dark-theme] .dsh-mem-rb-bar { background: rgba(255,255,255,0.12); }",
|
|
576
|
+
".dsh-mem-rb-fill { height: 100%; border-radius: 4px;",
|
|
577
|
+
" background: linear-gradient(90deg, #0969da, #8250df); transition: width .4s ease; }",
|
|
578
|
+
".dsh-mem-rb-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.35);",
|
|
579
|
+
" display: flex; align-items: center; justify-content: center; z-index: 2000; }",
|
|
580
|
+
".dsh-mem-rb-modal { width: 440px; max-width: calc(100vw - 48px); border-radius: 12px;",
|
|
581
|
+
" padding: 18px 20px; font-size: 13px; line-height: 1.6;",
|
|
582
|
+
" border: 1px solid var(--dsw-alias-border-l2, #e5e7eb);",
|
|
583
|
+
" background: var(--dsw-alias-bg-layer-2, #ffffff);",
|
|
584
|
+
" box-shadow: 0 16px 48px rgba(0,0,0,0.24); }",
|
|
585
|
+
"body[data-ds-dark-theme] .dsh-mem-rb-modal { box-shadow: 0 16px 48px rgba(0,0,0,0.6); }",
|
|
586
|
+
".dsh-mem-rb-muted { font-size: 12px; color: var(--dsw-alias-label-caption, #6e7781); }",
|
|
551
587
|
].join("\n");
|
|
552
588
|
document.head.appendChild(el);
|
|
553
589
|
}
|
|
@@ -665,6 +701,188 @@ window.__ModuleLoader__.load({
|
|
|
665
701
|
);
|
|
666
702
|
}
|
|
667
703
|
|
|
704
|
+
// ── 重建面板:从 L0 重导 L1/L2/L3(确认弹窗 + 进度 + 取消;Light/Dark 双主题走 class) ──
|
|
705
|
+
var RB_PHASE_LABEL = {
|
|
706
|
+
preparing: "准备中(归档旧数据 · 清空检索库)",
|
|
707
|
+
distilling: "分块蒸馏中",
|
|
708
|
+
finalizing: "收尾(强制 L2 场景 + L3 画像)",
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
function RebuildPanel(props) {
|
|
712
|
+
var rpc = props.rpc;
|
|
713
|
+
var rbState = react.useState(null);
|
|
714
|
+
var rb = rbState[0];
|
|
715
|
+
var setRb = rbState[1];
|
|
716
|
+
var confirmState = react.useState(false);
|
|
717
|
+
var confirmOpen = confirmState[0];
|
|
718
|
+
var setConfirmOpen = confirmState[1];
|
|
719
|
+
var busyState = react.useState(false);
|
|
720
|
+
var busy = busyState[0];
|
|
721
|
+
var setBusy = busyState[1];
|
|
722
|
+
var errState = react.useState(null);
|
|
723
|
+
var rbError = errState[0];
|
|
724
|
+
var setRbError = errState[1];
|
|
725
|
+
|
|
726
|
+
var refresh = react.useCallback(function () {
|
|
727
|
+
rpc("dsh-memory/rebuild-status", {})
|
|
728
|
+
.then(function (r) {
|
|
729
|
+
if (r && r.ok) setRb(r.value);
|
|
730
|
+
})
|
|
731
|
+
.catch(function () {});
|
|
732
|
+
}, [rpc]);
|
|
733
|
+
|
|
734
|
+
react.useEffect(function () { refresh(); }, [refresh]);
|
|
735
|
+
|
|
736
|
+
// 运行中 1.5s 高频轮询进度;空闲不轮询(重新打开 Tab 时挂载刷新一次)
|
|
737
|
+
var running = !!(rb && rb.running);
|
|
738
|
+
react.useEffect(function () {
|
|
739
|
+
if (!running) return;
|
|
740
|
+
var timer = setInterval(refresh, 1500);
|
|
741
|
+
return function () { clearInterval(timer); };
|
|
742
|
+
}, [running, refresh]);
|
|
743
|
+
|
|
744
|
+
if (!rb || rb.supported === false) return null;
|
|
745
|
+
ensureFlowStyle();
|
|
746
|
+
|
|
747
|
+
var start = function () {
|
|
748
|
+
setBusy(true);
|
|
749
|
+
setRbError(null);
|
|
750
|
+
rpc("dsh-memory/rebuild-start", {})
|
|
751
|
+
.then(function (r) {
|
|
752
|
+
setBusy(false);
|
|
753
|
+
if (r && r.ok) {
|
|
754
|
+
setConfirmOpen(false);
|
|
755
|
+
setRb(r.value);
|
|
756
|
+
} else {
|
|
757
|
+
setRbError(r && r.error ? r.error.message : "启动失败");
|
|
758
|
+
}
|
|
759
|
+
})
|
|
760
|
+
.catch(function (e) {
|
|
761
|
+
setBusy(false);
|
|
762
|
+
setRbError(String((e && e.message) || e));
|
|
763
|
+
});
|
|
764
|
+
};
|
|
765
|
+
var cancel = function () {
|
|
766
|
+
setBusy(true);
|
|
767
|
+
rpc("dsh-memory/rebuild-cancel", {})
|
|
768
|
+
.then(function (r) {
|
|
769
|
+
setBusy(false);
|
|
770
|
+
if (r && r.ok) setRb(r.value);
|
|
771
|
+
})
|
|
772
|
+
.catch(function () {
|
|
773
|
+
setBusy(false);
|
|
774
|
+
});
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
var empty = !running && (rb.messageCount === 0 || rb.estCalls === 0);
|
|
778
|
+
var pct = rb.total > 0 ? Math.round((rb.done / rb.total) * 100) : 0;
|
|
779
|
+
|
|
780
|
+
var lastNote = null;
|
|
781
|
+
if (!running && rb.phase === "done") {
|
|
782
|
+
lastNote =
|
|
783
|
+
"上次重建:完成(" + rb.done + "/" + rb.total + " 会话,产出 " + rb.recordsBuilt + " 条记录)" +
|
|
784
|
+
(rb.finishedAt ? " · " + fmtTime(new Date(rb.finishedAt).toISOString()) : "");
|
|
785
|
+
} else if (!running && rb.phase === "cancelled") {
|
|
786
|
+
lastNote = "上次重建:已取消(完成 " + rb.done + "/" + rb.total + " 会话,已重建部分保留)";
|
|
787
|
+
} else if (!running && rb.phase === "failed") {
|
|
788
|
+
lastNote = "上次重建:失败 — " + (rb.error || "未知错误");
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return react.createElement(
|
|
792
|
+
"div", { className: "dsh-mem-rb-card" },
|
|
793
|
+
react.createElement(
|
|
794
|
+
"div", { style: { display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" } },
|
|
795
|
+
react.createElement("div", { style: { fontWeight: 600, whiteSpace: "nowrap" } }, "重建记忆"),
|
|
796
|
+
react.createElement(
|
|
797
|
+
"div", { className: "dsh-mem-rb-muted", style: { flex: 1, minWidth: 180 } },
|
|
798
|
+
running
|
|
799
|
+
? (RB_PHASE_LABEL[rb.phase] || rb.phase) + " · " + rb.done + "/" + rb.total + " 会话(" + pct + "%)"
|
|
800
|
+
: "从 L0 原始对话重新蒸馏 L1/L2/L3;旧数据先归档(不删除)",
|
|
801
|
+
),
|
|
802
|
+
running
|
|
803
|
+
? react.createElement("button", {
|
|
804
|
+
type: "button",
|
|
805
|
+
className: "dsh-mem-rb-btn",
|
|
806
|
+
disabled: busy || rb.cancelRequested,
|
|
807
|
+
onClick: cancel,
|
|
808
|
+
}, rb.cancelRequested ? "取消中…" : "取消重建")
|
|
809
|
+
: react.createElement("button", {
|
|
810
|
+
type: "button",
|
|
811
|
+
className: "dsh-mem-rb-btn dsh-mem-rb-danger",
|
|
812
|
+
disabled: busy || empty,
|
|
813
|
+
title: empty ? "L0 无消息,无可重建内容" : "重新蒸馏全部记忆",
|
|
814
|
+
onClick: function () { setConfirmOpen(true); },
|
|
815
|
+
}, busy ? "…" : "开始重建"),
|
|
816
|
+
),
|
|
817
|
+
running
|
|
818
|
+
? react.createElement(
|
|
819
|
+
"div", { style: { display: "flex", alignItems: "center", gap: 10, marginTop: 10 } },
|
|
820
|
+
react.createElement(
|
|
821
|
+
"div", { className: "dsh-mem-rb-bar" },
|
|
822
|
+
react.createElement("div", { className: "dsh-mem-rb-fill", style: { width: pct + "%" } }),
|
|
823
|
+
),
|
|
824
|
+
react.createElement(
|
|
825
|
+
"span", { className: "dsh-mem-rb-muted", style: { whiteSpace: "nowrap" } },
|
|
826
|
+
"产出 " + rb.recordsBuilt + " 条",
|
|
827
|
+
),
|
|
828
|
+
)
|
|
829
|
+
: null,
|
|
830
|
+
lastNote
|
|
831
|
+
? react.createElement("div", { className: "dsh-mem-rb-muted", style: { marginTop: 8 } }, lastNote)
|
|
832
|
+
: null,
|
|
833
|
+
rbError
|
|
834
|
+
? react.createElement("div", { style: { marginTop: 8, fontSize: 12, color: "#cf222e" } }, rbError)
|
|
835
|
+
: null,
|
|
836
|
+
confirmOpen
|
|
837
|
+
? react.createElement(
|
|
838
|
+
"div", {
|
|
839
|
+
className: "dsh-mem-rb-overlay",
|
|
840
|
+
onClick: function (e) {
|
|
841
|
+
if (e.target === e.currentTarget) setConfirmOpen(false);
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
react.createElement(
|
|
845
|
+
"div", { className: "dsh-mem-rb-modal" },
|
|
846
|
+
react.createElement(
|
|
847
|
+
"div", { style: { fontSize: 15, fontWeight: 600, marginBottom: 10 } },
|
|
848
|
+
"确认重建全部记忆?",
|
|
849
|
+
),
|
|
850
|
+
react.createElement(
|
|
851
|
+
"div", null,
|
|
852
|
+
"将以 L0 原始对话为事实源重新蒸馏:",
|
|
853
|
+
react.createElement("b", null, rb.sessionCount + " 个会话 · " + rb.messageCount + " 条消息"),
|
|
854
|
+
",预计 ≥" + rb.estCalls + " 次蒸馏调用。",
|
|
855
|
+
),
|
|
856
|
+
react.createElement(
|
|
857
|
+
"div", { style: { marginTop: 8 } },
|
|
858
|
+
"现有 L1 记忆 / L2 场景 / L3 画像会整体归档(*.bak.时间戳,可手工找回),随后清空重建;重建期间可正常对话,新对话的蒸馏优先进行;中途可取消,已重建部分保留。",
|
|
859
|
+
),
|
|
860
|
+
react.createElement(
|
|
861
|
+
"div", { style: { display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 16 } },
|
|
862
|
+
react.createElement(
|
|
863
|
+
"button", {
|
|
864
|
+
type: "button",
|
|
865
|
+
className: "dsh-mem-rb-btn",
|
|
866
|
+
onClick: function () { setConfirmOpen(false); },
|
|
867
|
+
},
|
|
868
|
+
"取消",
|
|
869
|
+
),
|
|
870
|
+
react.createElement(
|
|
871
|
+
"button", {
|
|
872
|
+
type: "button",
|
|
873
|
+
className: "dsh-mem-rb-btn dsh-mem-rb-primary",
|
|
874
|
+
disabled: busy,
|
|
875
|
+
onClick: start,
|
|
876
|
+
},
|
|
877
|
+
busy ? "启动中…" : "开始重建",
|
|
878
|
+
),
|
|
879
|
+
),
|
|
880
|
+
),
|
|
881
|
+
)
|
|
882
|
+
: null,
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
|
|
668
886
|
// ── Tab:概览(开关面板 + 计数) ──
|
|
669
887
|
function OverviewTab(props) {
|
|
670
888
|
var rpc = props.rpc;
|
|
@@ -812,6 +1030,7 @@ window.__ModuleLoader__.load({
|
|
|
812
1030
|
: null,
|
|
813
1031
|
)
|
|
814
1032
|
: null,
|
|
1033
|
+
react.createElement(RebuildPanel, { rpc: rpc }),
|
|
815
1034
|
error
|
|
816
1035
|
? react.createElement("div", { style: S.error }, "获取状态失败:" + error)
|
|
817
1036
|
: !stats
|
package/dist/config.js
CHANGED
|
@@ -50,7 +50,7 @@ export const memorySchema = Schema.object({
|
|
|
50
50
|
provider: Schema.string().default(''),
|
|
51
51
|
model: Schema.string().default(''),
|
|
52
52
|
// 推理模型(如 v4-flash)的 reasoning 计入输出预算:预算不足会被思考吃光导致正文 0 字符
|
|
53
|
-
maxTokens: Schema.number().default(
|
|
53
|
+
maxTokens: Schema.number().default(256_000),
|
|
54
54
|
// 蒸馏是结构化抽取任务,默认关思考(off):v4-flash 默认 high 档的思考可把任意 maxTokens
|
|
55
55
|
// 预算全部吃光导致正文 0 字符;非推理模型不认识 effort 时会报 UNSUPPORTED_REASONING_EFFORT,设空串跳过
|
|
56
56
|
reasoningEffort: Schema.union(['', 'off', 'high', 'max']).default('off'),
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { memorySchema, resolveDataDir } from './config.js';
|
|
|
21
21
|
import { registerCapture } from './hooks/capture.js';
|
|
22
22
|
import { registerRecall } from './hooks/recall.js';
|
|
23
23
|
import { MemoryRunner } from './pipeline/runner.js';
|
|
24
|
+
import { RebuildController } from './pipeline/rebuild.js';
|
|
24
25
|
import { registerMemoryRpc, PLUGIN_VERSION } from './stats.js';
|
|
25
26
|
import { registerLiveSettings } from './settings.js';
|
|
26
27
|
import { NoopEmbeddingService, RemoteEmbeddingService, } from './store/embedding.js';
|
|
@@ -220,6 +221,10 @@ export async function apply(ctx, config) {
|
|
|
220
221
|
}
|
|
221
222
|
const runner = new MemoryRunner(ctx, config, stores, logger, live);
|
|
222
223
|
await runner.init();
|
|
224
|
+
// 重建控制器(存储降级时不建——RPC 端点走 supported=false 分支)
|
|
225
|
+
const rebuild = storageOk && !db.isDegraded()
|
|
226
|
+
? new RebuildController(ctx, config, stores, db, runner, logger, live)
|
|
227
|
+
: undefined;
|
|
223
228
|
if (storageOk) {
|
|
224
229
|
registerCapture(ctx, config, runner, stores.l0, logger, live, modes);
|
|
225
230
|
}
|
|
@@ -229,6 +234,6 @@ export async function apply(ctx, config) {
|
|
|
229
234
|
registerMemoryRpc(ctx, config, stores, logger, {
|
|
230
235
|
degraded: () => !storageOk || db.isDegraded(),
|
|
231
236
|
pending: () => runner.pendingCount,
|
|
232
|
-
}, live, modes, dataDir);
|
|
237
|
+
}, live, modes, dataDir, rebuild);
|
|
233
238
|
logger.info(`[memory] L0~L3 分层蒸馏记忆插件就绪(L1 记忆 ${storageOk ? stores.l1.size : 0} 条 | 捕获=${storageOk && config.capture.enabled} | 蒸馏=${storageOk && config.extract.enabled} | 召回=${config.recall.enabled})`);
|
|
234
239
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import { type MemoryConfig } from '../config.js';
|
|
3
|
+
import type { L1Store } from '../store/l1.js';
|
|
4
|
+
import type { PersonaStore } from '../store/persona.js';
|
|
5
|
+
import type { SceneStore } from '../store/scenes.js';
|
|
6
|
+
import type { StateStore } from '../store/state.js';
|
|
7
|
+
import type { MemoryDb } from '../store/sqlite.js';
|
|
8
|
+
import type { LiveSettingsHandle } from '../settings.js';
|
|
9
|
+
import type { ConversationMessage, L0MessageRecord, MemoryFamily, MemoryLogger } from '../types.js';
|
|
10
|
+
import { type MemoryRunner } from './runner.js';
|
|
11
|
+
/** 重建所需的存储子集(l0 不需要——快照直接走 db)。 */
|
|
12
|
+
export interface RebuildStores {
|
|
13
|
+
l1: L1Store;
|
|
14
|
+
scenes: Record<MemoryFamily, SceneStore>;
|
|
15
|
+
persona: Record<MemoryFamily, PersonaStore>;
|
|
16
|
+
state: StateStore;
|
|
17
|
+
}
|
|
18
|
+
export type RebuildPhase = 'idle' | 'preparing' | 'distilling' | 'finalizing' | 'done' | 'cancelled' | 'failed';
|
|
19
|
+
export interface RebuildStatus {
|
|
20
|
+
running: boolean;
|
|
21
|
+
phase: RebuildPhase;
|
|
22
|
+
/** 已完成的会话块数 / 总块数。 */
|
|
23
|
+
done: number;
|
|
24
|
+
total: number;
|
|
25
|
+
/** L0 体量(idle 时为实时预估,运行中为快照值)。 */
|
|
26
|
+
sessionCount: number;
|
|
27
|
+
messageCount: number;
|
|
28
|
+
/** 预计 LLM 抽取调用次数(下界估算:块数与字符预算取大)。 */
|
|
29
|
+
estCalls: number;
|
|
30
|
+
/** 重建产出的 L1 记录累计条数。 */
|
|
31
|
+
recordsBuilt: number;
|
|
32
|
+
cancelRequested: boolean;
|
|
33
|
+
startedAt: number | null;
|
|
34
|
+
finishedAt: number | null;
|
|
35
|
+
error: string | null;
|
|
36
|
+
/** 归档产物名(提示用户可手工找回)。 */
|
|
37
|
+
archiveNote: string | null;
|
|
38
|
+
}
|
|
39
|
+
export interface RebuildChunk {
|
|
40
|
+
sessionId: string;
|
|
41
|
+
messages: ConversationMessage[];
|
|
42
|
+
}
|
|
43
|
+
export declare function groupL0Sessions(records: L0MessageRecord[]): RebuildChunk[];
|
|
44
|
+
/** 抽取调用数下界估算(与 l1.ts 的 perChunk 同式;会话数与字符预算取大)。 */
|
|
45
|
+
export declare function estimateCalls(sessions: number, messages: number, chars: number, maxInputChars: number): number;
|
|
46
|
+
export declare class RebuildController {
|
|
47
|
+
private readonly ctx;
|
|
48
|
+
private readonly cfg;
|
|
49
|
+
private readonly stores;
|
|
50
|
+
private readonly db;
|
|
51
|
+
private readonly runner;
|
|
52
|
+
private readonly logger;
|
|
53
|
+
private readonly live;
|
|
54
|
+
private status;
|
|
55
|
+
private chunks;
|
|
56
|
+
private cancelRequested;
|
|
57
|
+
/** 快照时刻(收尾时按它区分重建产物与重建后新对话的记录)。 */
|
|
58
|
+
private rebuildStartMs;
|
|
59
|
+
constructor(ctx: Context, cfg: MemoryConfig, stores: RebuildStores, db: MemoryDb, runner: Pick<MemoryRunner, 'enqueueRebuildTask' | 'runRebuildTurn' | 'states'>, logger: MemoryLogger, live: LiveSettingsHandle);
|
|
60
|
+
/** 状态快照(idle 时附带实时 L0 预估,供确认弹窗显示成本)。 */
|
|
61
|
+
getStatus(): RebuildStatus;
|
|
62
|
+
/** 启动重建(校验后入队准备任务;真正的清库/归档在管线队列里串行执行,避开并发竞态)。 */
|
|
63
|
+
start(): RebuildStatus;
|
|
64
|
+
/** 请求取消:当前块完成后停止,已重建部分保留并照常收尾 L2/L3。 */
|
|
65
|
+
requestCancel(): RebuildStatus;
|
|
66
|
+
private prepare;
|
|
67
|
+
/** 分块链:一次只挂一个重建块,跑完再挂下一块——正常轮次可随时插队。 */
|
|
68
|
+
private scheduleChunk;
|
|
69
|
+
private finalize;
|
|
70
|
+
/**
|
|
71
|
+
* 收集重建窗口内某族的记录:重建产物全部是新插入(updated==created),
|
|
72
|
+
* 按 updated_time 倒序翻页、越过 rebuildStartMs 即停。
|
|
73
|
+
*/
|
|
74
|
+
private collectRebuildRecords;
|
|
75
|
+
private finish;
|
|
76
|
+
/** 归档旧派生层:records/ scenes/ persona-*.md 改名 .bak.<ts>。不存在则跳过。 */
|
|
77
|
+
private archiveDerived;
|
|
78
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 重建控制器:从 L0 事实源重新推导 L1/L2/L3(用户主动动作,设置页按钮触发)。
|
|
3
|
+
*
|
|
4
|
+
* 语义(CONTEXT.md「重建」):
|
|
5
|
+
* - L0 永不改动;旧派生层归档保留(records/ scenes/ persona-*.md → *.bak.<ts>,不硬删);
|
|
6
|
+
* - 检索库 L1 三表清空、checkpoint 原地重置;
|
|
7
|
+
* - 统一按 auto 档、按会话分块重蒸馏;分块经 runner 的低优先级队列让位于正常轮次;
|
|
8
|
+
* - 收尾强制一轮 L2(各族残余记录)+ L3(重建后 hasPersona=false → 冷启动触发)。
|
|
9
|
+
*
|
|
10
|
+
* 失败语义:准备/归档任一步失败 → phase=failed,绝不拖垮宿主;
|
|
11
|
+
* 单块蒸馏失败继续下一块(消息留在未蒸馏缓冲,下轮对话/重启补跑自愈)。
|
|
12
|
+
*/
|
|
13
|
+
import { promises as fs } from 'node:fs';
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
import { resolveDataDir } from '../config.js';
|
|
16
|
+
import { errDetail } from '../util/filelog.js';
|
|
17
|
+
import { runSceneConsolidation } from './l2.js';
|
|
18
|
+
import { runPersona } from './l3.js';
|
|
19
|
+
import { effectiveCfg } from './runner.js';
|
|
20
|
+
export function groupL0Sessions(records) {
|
|
21
|
+
const bySession = new Map();
|
|
22
|
+
for (const r of records) {
|
|
23
|
+
if (!r || typeof r.id !== 'string' || typeof r.content !== 'string')
|
|
24
|
+
continue;
|
|
25
|
+
if (r.role !== 'user' && r.role !== 'assistant')
|
|
26
|
+
continue;
|
|
27
|
+
if (!r.content.trim())
|
|
28
|
+
continue;
|
|
29
|
+
const key = r.sessionId || 'default';
|
|
30
|
+
const arr = bySession.get(key) ?? [];
|
|
31
|
+
arr.push({ id: r.id, role: r.role, content: r.content, timestamp: r.timestamp ?? 0 });
|
|
32
|
+
bySession.set(key, arr);
|
|
33
|
+
}
|
|
34
|
+
const chunks = [];
|
|
35
|
+
for (const [sessionId, messages] of bySession) {
|
|
36
|
+
messages.sort((a, b) => a.timestamp - b.timestamp);
|
|
37
|
+
chunks.push({ sessionId, messages });
|
|
38
|
+
}
|
|
39
|
+
// 会话按首条消息时间升序:情境链按时间顺序衔接(与原始发生顺序一致)
|
|
40
|
+
chunks.sort((a, b) => a.messages[0].timestamp - b.messages[0].timestamp);
|
|
41
|
+
return chunks;
|
|
42
|
+
}
|
|
43
|
+
/** 抽取调用数下界估算(与 l1.ts 的 perChunk 同式;会话数与字符预算取大)。 */
|
|
44
|
+
export function estimateCalls(sessions, messages, chars, maxInputChars) {
|
|
45
|
+
if (messages === 0)
|
|
46
|
+
return 0;
|
|
47
|
+
const perChunk = Math.max(20_000, maxInputChars - 42_000);
|
|
48
|
+
return Math.max(sessions, Math.ceil((chars + 64 * messages) / perChunk));
|
|
49
|
+
}
|
|
50
|
+
function idleStatus() {
|
|
51
|
+
return {
|
|
52
|
+
running: false,
|
|
53
|
+
phase: 'idle',
|
|
54
|
+
done: 0,
|
|
55
|
+
total: 0,
|
|
56
|
+
sessionCount: 0,
|
|
57
|
+
messageCount: 0,
|
|
58
|
+
estCalls: 0,
|
|
59
|
+
recordsBuilt: 0,
|
|
60
|
+
cancelRequested: false,
|
|
61
|
+
startedAt: null,
|
|
62
|
+
finishedAt: null,
|
|
63
|
+
error: null,
|
|
64
|
+
archiveNote: null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** 时间戳后缀(归档命名,秒级防撞)。 */
|
|
68
|
+
function stamp(now = new Date()) {
|
|
69
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
70
|
+
return `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
|
|
71
|
+
}
|
|
72
|
+
export class RebuildController {
|
|
73
|
+
ctx;
|
|
74
|
+
cfg;
|
|
75
|
+
stores;
|
|
76
|
+
db;
|
|
77
|
+
runner;
|
|
78
|
+
logger;
|
|
79
|
+
live;
|
|
80
|
+
status = idleStatus();
|
|
81
|
+
chunks = [];
|
|
82
|
+
cancelRequested = false;
|
|
83
|
+
/** 快照时刻(收尾时按它区分重建产物与重建后新对话的记录)。 */
|
|
84
|
+
rebuildStartMs = 0;
|
|
85
|
+
constructor(ctx, cfg, stores, db, runner, logger, live) {
|
|
86
|
+
this.ctx = ctx;
|
|
87
|
+
this.cfg = cfg;
|
|
88
|
+
this.stores = stores;
|
|
89
|
+
this.db = db;
|
|
90
|
+
this.runner = runner;
|
|
91
|
+
this.logger = logger;
|
|
92
|
+
this.live = live;
|
|
93
|
+
}
|
|
94
|
+
/** 状态快照(idle 时附带实时 L0 预估,供确认弹窗显示成本)。 */
|
|
95
|
+
getStatus() {
|
|
96
|
+
if (this.status.phase === 'idle') {
|
|
97
|
+
const est = this.db.l0RebuildEstimate();
|
|
98
|
+
return {
|
|
99
|
+
...this.status,
|
|
100
|
+
sessionCount: est.sessions,
|
|
101
|
+
messageCount: est.messages,
|
|
102
|
+
estCalls: estimateCalls(est.sessions, est.messages, est.chars, this.cfg.llm.maxInputChars),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return { ...this.status };
|
|
106
|
+
}
|
|
107
|
+
/** 启动重建(校验后入队准备任务;真正的清库/归档在管线队列里串行执行,避开并发竞态)。 */
|
|
108
|
+
start() {
|
|
109
|
+
if (this.status.running)
|
|
110
|
+
throw new Error('重建已在进行中');
|
|
111
|
+
const est = this.db.l0RebuildEstimate();
|
|
112
|
+
if (est.messages === 0)
|
|
113
|
+
throw new Error('L0 无任何消息,无需重建');
|
|
114
|
+
this.cancelRequested = false;
|
|
115
|
+
this.chunks = [];
|
|
116
|
+
this.status = {
|
|
117
|
+
...idleStatus(),
|
|
118
|
+
running: true,
|
|
119
|
+
phase: 'preparing',
|
|
120
|
+
sessionCount: est.sessions,
|
|
121
|
+
messageCount: est.messages,
|
|
122
|
+
estCalls: estimateCalls(est.sessions, est.messages, est.chars, this.cfg.llm.maxInputChars),
|
|
123
|
+
startedAt: Date.now(),
|
|
124
|
+
};
|
|
125
|
+
this.runner.enqueueRebuildTask(() => this.prepare());
|
|
126
|
+
this.logger.info(`[memory] 重建开始:${est.sessions} 个会话 / ${est.messages} 条 L0 消息(预计 ≥${this.status.estCalls} 次抽取调用)`);
|
|
127
|
+
return { ...this.status };
|
|
128
|
+
}
|
|
129
|
+
/** 请求取消:当前块完成后停止,已重建部分保留并照常收尾 L2/L3。 */
|
|
130
|
+
requestCancel() {
|
|
131
|
+
if (!this.status.running)
|
|
132
|
+
return this.getStatus();
|
|
133
|
+
this.cancelRequested = true;
|
|
134
|
+
this.status.cancelRequested = true;
|
|
135
|
+
this.logger.info('[memory] 重建取消已请求(当前块完成后停止)');
|
|
136
|
+
return { ...this.status };
|
|
137
|
+
}
|
|
138
|
+
async prepare() {
|
|
139
|
+
try {
|
|
140
|
+
// 快照:从检索库读全量 L0(事务一致;重建期间新捕获的消息走正常轮次,天然不重不漏)
|
|
141
|
+
this.rebuildStartMs = Date.now();
|
|
142
|
+
this.chunks = groupL0Sessions(this.db.listL0All());
|
|
143
|
+
if (this.chunks.length === 0) {
|
|
144
|
+
this.finish('failed', 'L0 快照为空');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.status.total = this.chunks.length;
|
|
148
|
+
// 归档旧派生层(改名不硬删;任一失败即终止——半清半留会破坏"全量重导"语义)
|
|
149
|
+
const archiveNote = await this.archiveDerived();
|
|
150
|
+
this.status.archiveNote = archiveNote ?? null;
|
|
151
|
+
// 清检索库 + 重置 checkpoint;归档后重建空目录(records/ 由 appendNew 自动重建)
|
|
152
|
+
if (!this.db.clearL1())
|
|
153
|
+
throw new Error('L1 检索库清空失败');
|
|
154
|
+
this.stores.state.reset();
|
|
155
|
+
await this.stores.state.save();
|
|
156
|
+
await Promise.all([
|
|
157
|
+
this.stores.scenes.chat.init(),
|
|
158
|
+
this.stores.scenes.work.init(),
|
|
159
|
+
this.stores.persona.chat.init(),
|
|
160
|
+
this.stores.persona.work.init(),
|
|
161
|
+
]);
|
|
162
|
+
this.status.phase = 'distilling';
|
|
163
|
+
this.logger.info(`[memory] 重建准备完成(归档:${archiveNote ?? '无旧产物'},${this.chunks.length} 个会话块)`);
|
|
164
|
+
this.scheduleChunk(0);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
this.finish('failed', `准备阶段失败: ${errDetail(err)}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** 分块链:一次只挂一个重建块,跑完再挂下一块——正常轮次可随时插队。 */
|
|
171
|
+
scheduleChunk(i) {
|
|
172
|
+
if (this.cancelRequested || i >= this.chunks.length) {
|
|
173
|
+
this.runner.enqueueRebuildTask(() => this.finalize());
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const chunk = this.chunks[i];
|
|
177
|
+
this.runner.enqueueRebuildTask(async () => {
|
|
178
|
+
// 入队后开跑前可能已收到取消(等待插队的正常轮次期间),直接跳到收尾
|
|
179
|
+
if (this.cancelRequested) {
|
|
180
|
+
this.runner.enqueueRebuildTask(() => this.finalize());
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const n = await this.runner.runRebuildTurn(chunk.sessionId, chunk.messages);
|
|
185
|
+
this.status.recordsBuilt += n;
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
this.logger.warn(`[memory] 重建块失败(session=${chunk.sessionId},跳过继续): ${errDetail(err)}`);
|
|
189
|
+
}
|
|
190
|
+
this.status.done = i + 1;
|
|
191
|
+
this.scheduleChunk(i + 1);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
async finalize() {
|
|
195
|
+
try {
|
|
196
|
+
this.status.phase = 'finalizing';
|
|
197
|
+
const cfg = effectiveCfg(this.cfg, this.live);
|
|
198
|
+
const liveNow = this.live.get();
|
|
199
|
+
const distillOn = liveNow.enabled && liveNow.distill;
|
|
200
|
+
// 强制 L2:把重建窗口内该族尚未整合的残余记录补一轮(正常轮次语义里差几条
|
|
201
|
+
// 不触发是常态,但"重建"应把已有记录全部落进场景)
|
|
202
|
+
if (cfg.l2.enabled && distillOn) {
|
|
203
|
+
for (const family of ['chat', 'work']) {
|
|
204
|
+
const fstate = this.runner.states[family];
|
|
205
|
+
if (fstate.newMemoriesSinceL2 <= 0)
|
|
206
|
+
continue;
|
|
207
|
+
const leftovers = this.collectRebuildRecords(family);
|
|
208
|
+
if (leftovers.length === 0)
|
|
209
|
+
continue;
|
|
210
|
+
try {
|
|
211
|
+
const t = Date.now();
|
|
212
|
+
const result = await runSceneConsolidation(this.ctx, cfg, this.stores.scenes[family], leftovers, this.logger, family);
|
|
213
|
+
fstate.lastL2At = Date.now();
|
|
214
|
+
fstate.newMemoriesSinceL2 = 0;
|
|
215
|
+
if (result.personaRequestedReason)
|
|
216
|
+
fstate.personaRequestedReason = result.personaRequestedReason;
|
|
217
|
+
this.logger.info(`[memory] 重建收尾 L2 完成(family=${family},${Date.now() - t}ms,${leftovers.length} 条残余记录)`);
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
this.logger.warn(`[memory] 重建收尾 L2 失败(family=${family}): ${errDetail(err)}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// 强制 L3:checkpoint 已重置(hasPersona=false)→ 冷启动触发;无场景的族跳过
|
|
225
|
+
if (cfg.l3.enabled && distillOn) {
|
|
226
|
+
for (const family of ['chat', 'work']) {
|
|
227
|
+
try {
|
|
228
|
+
const scenes = await this.stores.scenes[family].list();
|
|
229
|
+
if (scenes.length === 0)
|
|
230
|
+
continue;
|
|
231
|
+
await runPersona(this.ctx, cfg, this.stores.scenes[family], this.stores.persona[family], this.runner.states[family], this.logger, family);
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
this.logger.warn(`[memory] 重建收尾 L3 失败(family=${family}): ${errDetail(err)}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
await this.stores.state.save();
|
|
239
|
+
this.finish(this.cancelRequested ? 'cancelled' : 'done', null);
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
this.finish('failed', `收尾失败: ${errDetail(err)}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* 收集重建窗口内某族的记录:重建产物全部是新插入(updated==created),
|
|
247
|
+
* 按 updated_time 倒序翻页、越过 rebuildStartMs 即停。
|
|
248
|
+
*/
|
|
249
|
+
collectRebuildRecords(family) {
|
|
250
|
+
const out = [];
|
|
251
|
+
const PAGE = 200;
|
|
252
|
+
for (let offset = 0;; offset += PAGE) {
|
|
253
|
+
const { items } = this.stores.l1.list({ family, limit: PAGE, offset });
|
|
254
|
+
if (items.length === 0)
|
|
255
|
+
break;
|
|
256
|
+
let beyond = 0;
|
|
257
|
+
for (const r of items) {
|
|
258
|
+
if (r.createdAt >= this.rebuildStartMs)
|
|
259
|
+
out.push(r);
|
|
260
|
+
else
|
|
261
|
+
beyond++;
|
|
262
|
+
}
|
|
263
|
+
if (beyond > 0 || items.length < PAGE)
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
finish(phase, error) {
|
|
269
|
+
this.status.running = false;
|
|
270
|
+
this.status.phase = phase;
|
|
271
|
+
this.status.error = error;
|
|
272
|
+
this.status.finishedAt = Date.now();
|
|
273
|
+
const cost = this.status.finishedAt - (this.status.startedAt ?? this.status.finishedAt);
|
|
274
|
+
this.logger.info(`[memory] 重建结束(${phase}):${this.status.done}/${this.status.total} 会话,产出 ${this.status.recordsBuilt} 条记录,耗时 ${Math.round(cost / 1000)}s` +
|
|
275
|
+
(error ? `,错误:${error}` : ''));
|
|
276
|
+
}
|
|
277
|
+
/** 归档旧派生层:records/ scenes/ persona-*.md 改名 .bak.<ts>。不存在则跳过。 */
|
|
278
|
+
async archiveDerived() {
|
|
279
|
+
const dataDir = resolveDataDir(this.cfg);
|
|
280
|
+
const ts = stamp();
|
|
281
|
+
const items = [
|
|
282
|
+
[path.join(dataDir, 'records'), path.join(dataDir, `records.bak.${ts}`)],
|
|
283
|
+
[path.join(dataDir, 'scenes'), path.join(dataDir, `scenes.bak.${ts}`)],
|
|
284
|
+
[path.join(dataDir, 'persona-chat.md'), path.join(dataDir, `persona-chat.md.bak.${ts}`)],
|
|
285
|
+
[path.join(dataDir, 'persona-work.md'), path.join(dataDir, `persona-work.md.bak.${ts}`)],
|
|
286
|
+
];
|
|
287
|
+
const archived = [];
|
|
288
|
+
for (const [from, to] of items) {
|
|
289
|
+
try {
|
|
290
|
+
await fs.access(from);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
await fs.rename(from, to);
|
|
296
|
+
archived.push(path.basename(to));
|
|
297
|
+
}
|
|
298
|
+
return archived.length > 0 ? archived.join(', ') : undefined;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -4,9 +4,15 @@
|
|
|
4
4
|
*
|
|
5
5
|
* 会话档位:enqueue 带 mode(off 在捕获侧已被拦截);L1 待重试缓冲按档分桶;
|
|
6
6
|
* L2/L3 按记录族各自跑各自的场景/画像存储与阈值计数(分族隔离不变量)。
|
|
7
|
+
*
|
|
8
|
+
* 调度:内部是带优先级的任务列表——正常对话轮次(live)优先于重建分块(rebuild),
|
|
9
|
+
* 重建期间用户照常聊天,新轮次的蒸馏最多等一个重建块。任务串行,同一时刻至多一个在跑。
|
|
10
|
+
*
|
|
11
|
+
* 未蒸馏缓冲:pending 三桶持久化在 pending.json,进程重启不丢;init 恢复后延迟补跑一次
|
|
12
|
+
* (受 live 开关与 minMessages 阈值约束,失败维持"等下一轮同档对话"的现状语义)。
|
|
7
13
|
*/
|
|
8
14
|
import type { Context } from '@deepseek-ai/cordis';
|
|
9
|
-
import type
|
|
15
|
+
import { type MemoryConfig } from '../config.js';
|
|
10
16
|
import type { LiveSettingsHandle } from '../settings.js';
|
|
11
17
|
import type { L0Store } from '../store/l0.js';
|
|
12
18
|
import type { L1Store } from '../store/l1.js';
|
|
@@ -14,6 +20,7 @@ import type { PersonaStore } from '../store/persona.js';
|
|
|
14
20
|
import type { SceneStore } from '../store/scenes.js';
|
|
15
21
|
import type { StateStore } from '../store/state.js';
|
|
16
22
|
import type { ConversationMessage, ExtractMode, MemoryFamily, MemoryLogger } from '../types.js';
|
|
23
|
+
import type { FamilyStates } from './l1.js';
|
|
17
24
|
export interface MemoryStores {
|
|
18
25
|
l0: L0Store;
|
|
19
26
|
l1: L1Store;
|
|
@@ -21,24 +28,49 @@ export interface MemoryStores {
|
|
|
21
28
|
persona: Record<MemoryFamily, PersonaStore>;
|
|
22
29
|
state: StateStore;
|
|
23
30
|
}
|
|
31
|
+
/** 管线任务(优先级调度:live 优先于 rebuild)。 */
|
|
32
|
+
export interface PipelineTask {
|
|
33
|
+
kind: 'live' | 'rebuild';
|
|
34
|
+
run: () => Promise<unknown>;
|
|
35
|
+
}
|
|
36
|
+
/** 选取下一个要执行的任务下标:最早的 live 优先,否则队首(rebuild 分块让位)。 */
|
|
37
|
+
export declare function pickNextTaskIndex(tasks: PipelineTask[]): number;
|
|
38
|
+
/**
|
|
39
|
+
* 运行时调参视图:UI 选择器可临时覆盖蒸馏思考档位(空串回退静态 config 默认)。
|
|
40
|
+
* 浅拷贝只覆盖 llm 一层,其余键与原 cfg 共享只读引用;pipeline 全链继续收 cfg,无需感知。
|
|
41
|
+
*/
|
|
42
|
+
export declare function effectiveCfg(cfg: MemoryConfig, live: LiveSettingsHandle): MemoryConfig;
|
|
24
43
|
export declare class MemoryRunner {
|
|
25
44
|
private readonly ctx;
|
|
26
45
|
private readonly cfg;
|
|
27
46
|
private readonly stores;
|
|
28
47
|
private readonly logger;
|
|
29
48
|
private readonly live;
|
|
30
|
-
private
|
|
49
|
+
private tasks;
|
|
50
|
+
private draining;
|
|
31
51
|
private pending;
|
|
52
|
+
private readonly pendingFile;
|
|
32
53
|
private background;
|
|
33
|
-
|
|
54
|
+
/** 分族 checkpoint(init 后可用;重建收尾也从这里读活引用)。 */
|
|
55
|
+
states: FamilyStates;
|
|
34
56
|
private afterRun;
|
|
35
57
|
constructor(ctx: Context, cfg: MemoryConfig, stores: MemoryStores, logger: MemoryLogger, live: LiveSettingsHandle);
|
|
36
58
|
init(): Promise<void>;
|
|
59
|
+
/** 启动补跑:对每个非空桶入队一次蒸馏尝试(受 live 开关与阈值约束,失败不无限重试)。 */
|
|
60
|
+
private scheduleStartupRetry;
|
|
37
61
|
/** L1 抽取待重试的消息条数(状态面板用)。 */
|
|
38
62
|
get pendingCount(): number;
|
|
39
63
|
/** 管线跑完一轮后的回调(用于召回缓存失效)。 */
|
|
40
64
|
setAfterRun(fn: () => void): void;
|
|
41
|
-
/** 一轮对话结束后入队(L0
|
|
65
|
+
/** 一轮对话结束后入队(L0 落盘由 capture 在 turn/end 即时完成,不排蒸馏队列)。 */
|
|
42
66
|
enqueue(sessionId: string, messages: ConversationMessage[], mode: ExtractMode): void;
|
|
67
|
+
/** 重建任务入队(低优先级:让位于正常轮次;由 RebuildController 分块驱动)。 */
|
|
68
|
+
enqueueRebuildTask(run: () => Promise<unknown>): void;
|
|
69
|
+
/** 重建蒸馏轮:统一 auto 档,不受缓冲 200 上限(历史会话全量入桶,由 char 预算分块)。 */
|
|
70
|
+
runRebuildTurn(sessionId: string, messages: ConversationMessage[]): Promise<number>;
|
|
71
|
+
private pushTask;
|
|
72
|
+
private drain;
|
|
73
|
+
/** 缓冲落盘(每次蒸馏尝试后调用;失败只告警不阻断管线)。 */
|
|
74
|
+
private persistPending;
|
|
43
75
|
private runTurn;
|
|
44
76
|
}
|
package/dist/pipeline/runner.js
CHANGED
|
@@ -1,16 +1,41 @@
|
|
|
1
|
+
import { resolveDataDir } from '../config.js';
|
|
2
|
+
import { emptyPending, loadPending, PENDING_MODES, pendingPathFor, savePending } from '../store/pending.js';
|
|
3
|
+
import { errDetail } from '../util/filelog.js';
|
|
1
4
|
import { runExtraction } from './l1.js';
|
|
2
5
|
import { runSceneConsolidation } from './l2.js';
|
|
3
6
|
import { runPersona } from './l3.js';
|
|
4
|
-
|
|
7
|
+
/** 选取下一个要执行的任务下标:最早的 live 优先,否则队首(rebuild 分块让位)。 */
|
|
8
|
+
export function pickNextTaskIndex(tasks) {
|
|
9
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
10
|
+
if (tasks[i].kind === 'live')
|
|
11
|
+
return i;
|
|
12
|
+
}
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 运行时调参视图:UI 选择器可临时覆盖蒸馏思考档位(空串回退静态 config 默认)。
|
|
17
|
+
* 浅拷贝只覆盖 llm 一层,其余键与原 cfg 共享只读引用;pipeline 全链继续收 cfg,无需感知。
|
|
18
|
+
*/
|
|
19
|
+
export function effectiveCfg(cfg, live) {
|
|
20
|
+
const eff = live.get().reasoningEffort;
|
|
21
|
+
return eff ? { ...cfg, llm: { ...cfg.llm, reasoningEffort: eff } } : cfg;
|
|
22
|
+
}
|
|
23
|
+
/** 单桶堆积上限(防无限堆积;重建分块不受限——历史会话需全量入桶蒸馏)。 */
|
|
24
|
+
const PENDING_BUCKET_CAP = 200;
|
|
25
|
+
/** 启动补跑延迟:避开宿主启动期忙乱。 */
|
|
26
|
+
const STARTUP_RETRY_DELAY_MS = 20_000;
|
|
5
27
|
export class MemoryRunner {
|
|
6
28
|
ctx;
|
|
7
29
|
cfg;
|
|
8
30
|
stores;
|
|
9
31
|
logger;
|
|
10
32
|
live;
|
|
11
|
-
|
|
12
|
-
|
|
33
|
+
tasks = [];
|
|
34
|
+
draining = false;
|
|
35
|
+
pending = emptyPending();
|
|
36
|
+
pendingFile;
|
|
13
37
|
background = [];
|
|
38
|
+
/** 分族 checkpoint(init 后可用;重建收尾也从这里读活引用)。 */
|
|
14
39
|
states;
|
|
15
40
|
afterRun;
|
|
16
41
|
constructor(ctx, cfg, stores, logger, live) {
|
|
@@ -19,6 +44,7 @@ export class MemoryRunner {
|
|
|
19
44
|
this.stores = stores;
|
|
20
45
|
this.logger = logger;
|
|
21
46
|
this.live = live;
|
|
47
|
+
this.pendingFile = pendingPathFor(resolveDataDir(cfg));
|
|
22
48
|
}
|
|
23
49
|
async init() {
|
|
24
50
|
await this.stores.state.load();
|
|
@@ -30,6 +56,33 @@ export class MemoryRunner {
|
|
|
30
56
|
this.logger.info('[memory] state.json 已迁移为 v2 分族格式(旧数据归 chat 桶)');
|
|
31
57
|
await this.stores.state.save();
|
|
32
58
|
}
|
|
59
|
+
// 恢复未蒸馏缓冲(上次进程退出前未蒸馏的消息,含失败待重试与攒阈值中途的)
|
|
60
|
+
try {
|
|
61
|
+
const loaded = await loadPending(this.pendingFile, this.logger);
|
|
62
|
+
for (const key of PENDING_MODES) {
|
|
63
|
+
if (loaded[key].length > PENDING_BUCKET_CAP)
|
|
64
|
+
loaded[key] = loaded[key].slice(-PENDING_BUCKET_CAP);
|
|
65
|
+
}
|
|
66
|
+
this.pending = loaded;
|
|
67
|
+
if (this.pendingCount > 0) {
|
|
68
|
+
this.logger.info(`[memory] 未蒸馏缓冲已恢复 ${this.pendingCount} 条(auto=${this.pending.auto.length}/chat=${this.pending.chat.length}/work=${this.pending.work.length}),${STARTUP_RETRY_DELAY_MS / 1000}s 后自动补跑`);
|
|
69
|
+
this.scheduleStartupRetry();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
this.logger.warn(`[memory] 未蒸馏缓冲恢复失败(空桶起步): ${errDetail(err)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** 启动补跑:对每个非空桶入队一次蒸馏尝试(受 live 开关与阈值约束,失败不无限重试)。 */
|
|
77
|
+
scheduleStartupRetry() {
|
|
78
|
+
const modes = PENDING_MODES.filter((m) => this.pending[m].length > 0);
|
|
79
|
+
this.ctx.effect(() => {
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
for (const mode of modes)
|
|
82
|
+
this.enqueue('startup-retry', [], mode);
|
|
83
|
+
}, STARTUP_RETRY_DELAY_MS);
|
|
84
|
+
return () => clearTimeout(timer);
|
|
85
|
+
});
|
|
33
86
|
}
|
|
34
87
|
/** L1 抽取待重试的消息条数(状态面板用)。 */
|
|
35
88
|
get pendingCount() {
|
|
@@ -39,32 +92,65 @@ export class MemoryRunner {
|
|
|
39
92
|
setAfterRun(fn) {
|
|
40
93
|
this.afterRun = fn;
|
|
41
94
|
}
|
|
42
|
-
/** 一轮对话结束后入队(L0
|
|
95
|
+
/** 一轮对话结束后入队(L0 落盘由 capture 在 turn/end 即时完成,不排蒸馏队列)。 */
|
|
43
96
|
enqueue(sessionId, messages, mode) {
|
|
44
|
-
this.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
});
|
|
97
|
+
this.pushTask({ kind: 'live', run: () => this.runTurn(sessionId, messages, mode) });
|
|
98
|
+
}
|
|
99
|
+
/** 重建任务入队(低优先级:让位于正常轮次;由 RebuildController 分块驱动)。 */
|
|
100
|
+
enqueueRebuildTask(run) {
|
|
101
|
+
this.pushTask({ kind: 'rebuild', run });
|
|
102
|
+
}
|
|
103
|
+
/** 重建蒸馏轮:统一 auto 档,不受缓冲 200 上限(历史会话全量入桶,由 char 预算分块)。 */
|
|
104
|
+
runRebuildTurn(sessionId, messages) {
|
|
105
|
+
return this.runTurn(sessionId, messages, 'auto', { noBufferCap: true });
|
|
106
|
+
}
|
|
107
|
+
pushTask(task) {
|
|
108
|
+
this.tasks.push(task);
|
|
109
|
+
void this.drain();
|
|
49
110
|
}
|
|
50
|
-
async
|
|
111
|
+
async drain() {
|
|
112
|
+
if (this.draining)
|
|
113
|
+
return;
|
|
114
|
+
this.draining = true;
|
|
115
|
+
try {
|
|
116
|
+
while (this.tasks.length > 0) {
|
|
117
|
+
const [task] = this.tasks.splice(pickNextTaskIndex(this.tasks), 1);
|
|
118
|
+
try {
|
|
119
|
+
await task.run();
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
this.logger.warn(`[memory] 管线失败(已兜底): ${errDetail(err)}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
this.draining = false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** 缓冲落盘(每次蒸馏尝试后调用;失败只告警不阻断管线)。 */
|
|
131
|
+
async persistPending() {
|
|
132
|
+
try {
|
|
133
|
+
await savePending(this.pendingFile, this.pending);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
this.logger.warn(`[memory] 未蒸馏缓冲落盘失败: ${errDetail(err)}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async runTurn(sessionId, messages, mode, opts) {
|
|
51
140
|
const turnStart = Date.now();
|
|
52
141
|
this.logger.info(`[memory] 蒸馏管线开始(session=${sessionId},mode=${mode},本轮 ${messages.length} 条消息,待重试 ${this.pendingCount} 条)`);
|
|
53
142
|
// ── L0:原始对话已由 capture 在 turn/end 即时落盘(不排蒸馏队列,防慢 LLM 阻塞/退出丢消息) ──
|
|
54
|
-
|
|
55
|
-
// 浅拷贝只覆盖 llm 一层,其余键与 this.cfg 共享只读引用;pipeline 全链继续收 cfg,无需感知。 ──
|
|
56
|
-
const liveNow = this.live.get();
|
|
57
|
-
const cfg = liveNow.reasoningEffort
|
|
58
|
-
? { ...this.cfg, llm: { ...this.cfg.llm, reasoningEffort: liveNow.reasoningEffort } }
|
|
59
|
-
: this.cfg;
|
|
143
|
+
const cfg = effectiveCfg(this.cfg, this.live);
|
|
60
144
|
// ── L1:抽取 + 去重(按档分桶,失败按桶保留待重试) ──
|
|
61
145
|
let newRecords = [];
|
|
146
|
+
const liveNow = this.live.get();
|
|
62
147
|
const distillOn = liveNow.enabled && liveNow.distill;
|
|
63
148
|
if (cfg.extract.enabled && distillOn) {
|
|
64
149
|
const bucket = this.pending[mode];
|
|
65
150
|
bucket.push(...messages);
|
|
66
|
-
if (bucket.length >
|
|
67
|
-
bucket.splice(0, bucket.length -
|
|
151
|
+
if (!opts?.noBufferCap && bucket.length > PENDING_BUCKET_CAP) {
|
|
152
|
+
bucket.splice(0, bucket.length - PENDING_BUCKET_CAP);
|
|
153
|
+
}
|
|
68
154
|
try {
|
|
69
155
|
const t = Date.now();
|
|
70
156
|
const result = await runExtraction(this.ctx, cfg, this.stores.l1, this.states, bucket, this.background, this.logger, mode);
|
|
@@ -75,11 +161,13 @@ export class MemoryRunner {
|
|
|
75
161
|
this.logger.info(`[memory] L1 阶段完成(${Date.now() - t}ms)`);
|
|
76
162
|
}
|
|
77
163
|
catch (err) {
|
|
78
|
-
// 保留 pending
|
|
79
|
-
if (this.pending[mode].length >
|
|
164
|
+
// 保留 pending,下次重试;但防止无限堆积(重建轮不限,量被会话规模天然约束)
|
|
165
|
+
if (!opts?.noBufferCap && this.pending[mode].length > PENDING_BUCKET_CAP)
|
|
80
166
|
this.pending[mode] = [];
|
|
81
167
|
this.logger.warn(`[memory] L1 抽取失败(mode=${mode},pending=${this.pending[mode].length}): ${errDetail(err)}`);
|
|
82
168
|
}
|
|
169
|
+
// 缓冲每次尝试后立即落盘:进程中途退出不丢待重试/攒阈值状态
|
|
170
|
+
await this.persistPending();
|
|
83
171
|
// L1 计数推进后立即落盘:L2/L3 失败或进程中途退出不得回滚阈值进度
|
|
84
172
|
// (记录已入库但计数丢失会让该族 L2 永远差一截,state 与 DB 脱节)
|
|
85
173
|
try {
|
|
@@ -134,5 +222,6 @@ export class MemoryRunner {
|
|
|
134
222
|
}
|
|
135
223
|
this.logger.info(`[memory] 蒸馏管线结束(本轮新增 ${newRecords.length} 条,总耗时 ${Date.now() - turnStart}ms)`);
|
|
136
224
|
this.afterRun?.();
|
|
225
|
+
return newRecords.length;
|
|
137
226
|
}
|
|
138
227
|
}
|
package/dist/stats.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
2
|
import { type MemoryConfig } from './config.js';
|
|
3
|
+
import type { RebuildController } from './pipeline/rebuild.js';
|
|
3
4
|
import type { LiveSettingsHandle } from './settings.js';
|
|
4
5
|
import type { L0Store } from './store/l0.js';
|
|
5
6
|
import type { L1Store } from './store/l1.js';
|
|
@@ -43,4 +44,4 @@ export declare function registerMemoryRpc(ctx: Context, cfg: MemoryConfig, store
|
|
|
43
44
|
scenes: Record<MemoryFamily, SceneStore>;
|
|
44
45
|
persona: Record<MemoryFamily, PersonaStore>;
|
|
45
46
|
state: StateStore;
|
|
46
|
-
}, logger: MemoryLogger, status?: MemoryStatusSource, live?: LiveSettingsHandle, modes?: SessionModeStore, dataDir?: string): void;
|
|
47
|
+
}, logger: MemoryLogger, status?: MemoryStatusSource, live?: LiveSettingsHandle, modes?: SessionModeStore, dataDir?: string, rebuild?: RebuildController): void;
|
package/dist/stats.js
CHANGED
|
@@ -12,7 +12,7 @@ import { resolveDataDir } from './config.js';
|
|
|
12
12
|
const require = createRequire(import.meta.url);
|
|
13
13
|
export const PLUGIN_VERSION = require('../package.json').version;
|
|
14
14
|
/** 注册状态 RPC(web 侧 connection 服务可选,缺失时跳过,不影响插件主体)。 */
|
|
15
|
-
export function registerMemoryRpc(ctx, cfg, stores, logger, status, live, modes, dataDir) {
|
|
15
|
+
export function registerMemoryRpc(ctx, cfg, stores, logger, status, live, modes, dataDir, rebuild) {
|
|
16
16
|
/** 当前是否持有一段有效注册(dispose 完成后清空,允许服务重上线时重注册)。 */
|
|
17
17
|
let holding = false;
|
|
18
18
|
const tryRegister = () => {
|
|
@@ -34,6 +34,7 @@ export function registerMemoryRpc(ctx, cfg, stores, logger, status, live, modes,
|
|
|
34
34
|
modes,
|
|
35
35
|
dataDir: dataDir ?? resolveDataDir(cfg),
|
|
36
36
|
logger,
|
|
37
|
+
rebuild,
|
|
37
38
|
});
|
|
38
39
|
return { ok: true, value };
|
|
39
40
|
}
|
|
@@ -104,7 +105,7 @@ async function buildStats(cfg, stores, status) {
|
|
|
104
105
|
};
|
|
105
106
|
}
|
|
106
107
|
async function handleEndpoint(endpoint, payload, deps) {
|
|
107
|
-
const { cfg, stores, status, live, modes, dataDir } = deps;
|
|
108
|
+
const { cfg, stores, status, live, modes, dataDir, rebuild } = deps;
|
|
108
109
|
switch (endpoint) {
|
|
109
110
|
case 'dsh-memory/stats':
|
|
110
111
|
return buildStats(cfg, stores, status);
|
|
@@ -215,6 +216,30 @@ async function handleEndpoint(endpoint, payload, deps) {
|
|
|
215
216
|
const p = (payload ?? {});
|
|
216
217
|
return { lines: readLogTail(join(dataDir, 'memory.log'), Math.min(Math.max(Number(p.lines) || 200, 1), 1000)) };
|
|
217
218
|
}
|
|
219
|
+
case 'dsh-memory/rebuild-status': {
|
|
220
|
+
if (!rebuild)
|
|
221
|
+
return { supported: false, running: false, phase: 'idle' };
|
|
222
|
+
return rebuild.getStatus();
|
|
223
|
+
}
|
|
224
|
+
case 'dsh-memory/rebuild-start': {
|
|
225
|
+
if (!rebuild)
|
|
226
|
+
throw new Error('重建控制器未初始化(存储不可用)');
|
|
227
|
+
if (status?.degraded())
|
|
228
|
+
throw new Error('存储处于降级状态,无法重建');
|
|
229
|
+
const s = live?.get();
|
|
230
|
+
if (s && (!s.enabled || !s.distill))
|
|
231
|
+
throw new Error('蒸馏开关已关闭,请先开启蒸馏再重建');
|
|
232
|
+
if (!cfg.extract.enabled)
|
|
233
|
+
throw new Error('部署配置已停用蒸馏(extract.enabled=false),无法重建');
|
|
234
|
+
const result = rebuild.start();
|
|
235
|
+
deps.logger.info('[memory] 收到重建指令(设置页按钮)');
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
case 'dsh-memory/rebuild-cancel': {
|
|
239
|
+
if (!rebuild)
|
|
240
|
+
throw new Error('重建控制器未初始化');
|
|
241
|
+
return rebuild.requestCancel();
|
|
242
|
+
}
|
|
218
243
|
default:
|
|
219
244
|
throw new Error(`unknown endpoint: ${endpoint}`);
|
|
220
245
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ConversationMessage, ExtractMode, MemoryLogger } from '../types.js';
|
|
2
|
+
/** 三档蒸馏缓冲桶(off 在捕获侧已被拦截,永远不到这里)。 */
|
|
3
|
+
export interface PendingBuckets {
|
|
4
|
+
auto: ConversationMessage[];
|
|
5
|
+
chat: ConversationMessage[];
|
|
6
|
+
work: ConversationMessage[];
|
|
7
|
+
}
|
|
8
|
+
export declare function emptyPending(): PendingBuckets;
|
|
9
|
+
/** 读取缓冲文件:文件缺失/损坏 → 空桶(不抛出——丢了缓冲 L0 事实源仍在)。 */
|
|
10
|
+
export declare function loadPending(file: string, logger?: MemoryLogger): Promise<PendingBuckets>;
|
|
11
|
+
/** 全量原子落盘(每次蒸馏尝试后调用;桶有上限,量级为百条级)。 */
|
|
12
|
+
export declare function savePending(file: string, buckets: PendingBuckets): Promise<void>;
|
|
13
|
+
export declare function pendingPathFor(dataDir: string): string;
|
|
14
|
+
/** 三档 key(调度/遍历用)。 */
|
|
15
|
+
export declare const PENDING_MODES: readonly ExtractMode[];
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 未蒸馏缓冲持久化(pending.json):
|
|
3
|
+
* 按档分桶的"已捕获但尚未成功蒸馏"消息——既包括抽取失败的待重试消息,
|
|
4
|
+
* 也包括攒够触发阈值之前的消息。跨重启不丢(CONTEXT.md「未蒸馏缓冲」)。
|
|
5
|
+
*
|
|
6
|
+
* 与 state.json / session-modes.json 同款原子写;读取宽容(坏行丢弃、坏文件空桶起步)。
|
|
7
|
+
*/
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import { atomicWriteJson, readJsonIfExists } from './io.js';
|
|
10
|
+
export function emptyPending() {
|
|
11
|
+
return { auto: [], chat: [], work: [] };
|
|
12
|
+
}
|
|
13
|
+
function isMessage(m) {
|
|
14
|
+
if (!m || typeof m !== 'object')
|
|
15
|
+
return false;
|
|
16
|
+
const r = m;
|
|
17
|
+
return typeof r.id === 'string' && typeof r.content === 'string' && (r.role === 'user' || r.role === 'assistant');
|
|
18
|
+
}
|
|
19
|
+
/** 读取缓冲文件:文件缺失/损坏 → 空桶(不抛出——丢了缓冲 L0 事实源仍在)。 */
|
|
20
|
+
export async function loadPending(file, logger) {
|
|
21
|
+
const out = emptyPending();
|
|
22
|
+
let raw;
|
|
23
|
+
try {
|
|
24
|
+
raw = await readJsonIfExists(file);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
raw = undefined;
|
|
28
|
+
}
|
|
29
|
+
if (!raw || typeof raw !== 'object' || !raw.buckets || typeof raw.buckets !== 'object')
|
|
30
|
+
return out;
|
|
31
|
+
let dropped = 0;
|
|
32
|
+
for (const key of ['auto', 'chat', 'work']) {
|
|
33
|
+
const arr = raw.buckets[key];
|
|
34
|
+
if (!Array.isArray(arr))
|
|
35
|
+
continue;
|
|
36
|
+
for (const m of arr) {
|
|
37
|
+
if (isMessage(m))
|
|
38
|
+
out[key].push(m);
|
|
39
|
+
else
|
|
40
|
+
dropped++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (dropped > 0)
|
|
44
|
+
logger?.warn(`[memory] 未蒸馏缓冲文件含 ${dropped} 条坏记录,已丢弃`);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/** 全量原子落盘(每次蒸馏尝试后调用;桶有上限,量级为百条级)。 */
|
|
48
|
+
export async function savePending(file, buckets) {
|
|
49
|
+
const payload = { version: 1, buckets };
|
|
50
|
+
await atomicWriteJson(file, payload);
|
|
51
|
+
}
|
|
52
|
+
export function pendingPathFor(dataDir) {
|
|
53
|
+
return path.join(dataDir, 'pending.json');
|
|
54
|
+
}
|
|
55
|
+
/** 三档 key(调度/遍历用)。 */
|
|
56
|
+
export const PENDING_MODES = ['auto', 'chat', 'work'];
|
package/dist/store/sqlite.d.ts
CHANGED
|
@@ -75,6 +75,13 @@ export declare class MemoryDb {
|
|
|
75
75
|
upsertL1(record: MemoryRecord, embedding?: Float32Array): boolean;
|
|
76
76
|
/** 批量删除 L1(元数据 + 向量 + FTS),返回删除条数。 */
|
|
77
77
|
deleteL1Batch(ids: string[]): number;
|
|
78
|
+
/**
|
|
79
|
+
* 清空 L1 检索库全部数据(重建用)。records/FTS 直接 DELETE;
|
|
80
|
+
* 向量表走 DROP + 重建(vec0 的全表 DELETE 语义不可靠,dropVectorTables
|
|
81
|
+
* 会连 l0_vec 一起删——L0 向量必须保留——故此处单独处理 l1_vec)。
|
|
82
|
+
* L0 表与 embedding_meta 不动:backfill 的行数比对天然重新一致。
|
|
83
|
+
*/
|
|
84
|
+
clearL1(): boolean;
|
|
78
85
|
countL1(): number;
|
|
79
86
|
/** 全量读取(调试/迁移/重嵌入用;检索请走 FTS/向量)。 */
|
|
80
87
|
getAllL1(): MemoryRecord[];
|
|
@@ -101,6 +108,14 @@ export declare class MemoryDb {
|
|
|
101
108
|
countL0(): number;
|
|
102
109
|
/** 统计 recorded_at >= iso 的消息数(状态面板"今日捕获"用)。 */
|
|
103
110
|
countL0Since(iso: string): number;
|
|
111
|
+
/** L0 全量列举(重建快照用;按时间升序,事务一致性避开 JSONL 追加竞态)。 */
|
|
112
|
+
listL0All(): L0MessageRecord[];
|
|
113
|
+
/** 重建成本预估(一次全表聚合:会话数 / 消息数 / 字符量)。 */
|
|
114
|
+
l0RebuildEstimate(): {
|
|
115
|
+
sessions: number;
|
|
116
|
+
messages: number;
|
|
117
|
+
chars: number;
|
|
118
|
+
};
|
|
104
119
|
/** 向量表行数(backfill 判据:与元数据行数的差值即缺失向量数;不可用时返回 -1)。 */
|
|
105
120
|
countL1Vec(): number;
|
|
106
121
|
countL0Vec(): number;
|
package/dist/store/sqlite.js
CHANGED
|
@@ -518,6 +518,46 @@ export class MemoryDb {
|
|
|
518
518
|
return 0;
|
|
519
519
|
}
|
|
520
520
|
}
|
|
521
|
+
/**
|
|
522
|
+
* 清空 L1 检索库全部数据(重建用)。records/FTS 直接 DELETE;
|
|
523
|
+
* 向量表走 DROP + 重建(vec0 的全表 DELETE 语义不可靠,dropVectorTables
|
|
524
|
+
* 会连 l0_vec 一起删——L0 向量必须保留——故此处单独处理 l1_vec)。
|
|
525
|
+
* L0 表与 embedding_meta 不动:backfill 的行数比对天然重新一致。
|
|
526
|
+
*/
|
|
527
|
+
clearL1() {
|
|
528
|
+
if (this.degraded)
|
|
529
|
+
return false;
|
|
530
|
+
try {
|
|
531
|
+
this.db.exec('BEGIN');
|
|
532
|
+
try {
|
|
533
|
+
this.db.exec('DELETE FROM l1_records');
|
|
534
|
+
if (this.ftsAvailable)
|
|
535
|
+
this.db.exec('DELETE FROM l1_fts');
|
|
536
|
+
this.db.exec('COMMIT');
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
try {
|
|
540
|
+
this.db.exec('ROLLBACK');
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
/* ignore */
|
|
544
|
+
}
|
|
545
|
+
throw err;
|
|
546
|
+
}
|
|
547
|
+
// vec0 全表 DELETE 语义不可靠 → DROP + 重建空表;放事务外(vtab DDL 事务性弱)。
|
|
548
|
+
// 中间态:向量行短暂孤儿——检索侧对缺 meta 的向量本就跳过(searchL1Vector 回查过滤)。
|
|
549
|
+
if (this.stmtDeleteL1Vec) {
|
|
550
|
+
this.db.exec('DROP TABLE IF EXISTS l1_vec');
|
|
551
|
+
this.prepareL1VecStatements();
|
|
552
|
+
}
|
|
553
|
+
this.logger?.info(`${TAG} L1 检索库已清空(重建)`);
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
this.logger?.warn(`${TAG} L1 清空失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
521
561
|
countL1() {
|
|
522
562
|
if (this.degraded)
|
|
523
563
|
return 0;
|
|
@@ -724,6 +764,42 @@ export class MemoryDb {
|
|
|
724
764
|
return 0;
|
|
725
765
|
}
|
|
726
766
|
}
|
|
767
|
+
/** L0 全量列举(重建快照用;按时间升序,事务一致性避开 JSONL 追加竞态)。 */
|
|
768
|
+
listL0All() {
|
|
769
|
+
if (this.degraded)
|
|
770
|
+
return [];
|
|
771
|
+
try {
|
|
772
|
+
const rows = this.db
|
|
773
|
+
.prepare('SELECT record_id, session_id, role, message_text, recorded_at, timestamp FROM l0_conversations ORDER BY timestamp ASC')
|
|
774
|
+
.all();
|
|
775
|
+
return rows.map((r) => ({
|
|
776
|
+
sessionId: r.session_id,
|
|
777
|
+
recordedAt: r.recorded_at,
|
|
778
|
+
id: r.record_id,
|
|
779
|
+
role: r.role,
|
|
780
|
+
content: r.message_text,
|
|
781
|
+
timestamp: r.timestamp ?? 0,
|
|
782
|
+
}));
|
|
783
|
+
}
|
|
784
|
+
catch (err) {
|
|
785
|
+
this.logger?.warn(`${TAG} L0 全量列举失败(返回空): ${err instanceof Error ? err.message : String(err)}`);
|
|
786
|
+
return [];
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
/** 重建成本预估(一次全表聚合:会话数 / 消息数 / 字符量)。 */
|
|
790
|
+
l0RebuildEstimate() {
|
|
791
|
+
if (this.degraded)
|
|
792
|
+
return { sessions: 0, messages: 0, chars: 0 };
|
|
793
|
+
try {
|
|
794
|
+
const row = this.db
|
|
795
|
+
.prepare('SELECT COUNT(DISTINCT session_id) AS s, COUNT(*) AS n, COALESCE(SUM(LENGTH(message_text)), 0) AS c FROM l0_conversations')
|
|
796
|
+
.get();
|
|
797
|
+
return { sessions: row?.s ?? 0, messages: row?.n ?? 0, chars: row?.c ?? 0 };
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
return { sessions: 0, messages: 0, chars: 0 };
|
|
801
|
+
}
|
|
802
|
+
}
|
|
727
803
|
/** 向量表行数(backfill 判据:与元数据行数的差值即缺失向量数;不可用时返回 -1)。 */
|
|
728
804
|
countL1Vec() {
|
|
729
805
|
if (this.degraded || !this.stmtSearchL1Vec)
|
package/dist/store/state.d.ts
CHANGED
|
@@ -30,6 +30,12 @@ export declare class StateStore {
|
|
|
30
30
|
get didMigrate(): boolean;
|
|
31
31
|
/** 取某族的 checkpoint(活引用——改字段后 save 生效)。 */
|
|
32
32
|
forFamily(family: MemoryFamily): MemoryState;
|
|
33
|
+
/**
|
|
34
|
+
* 重建用:两族 checkpoint 重置为默认值。
|
|
35
|
+
* 必须原地突变(Object.assign)——runner.states 等处持有桶对象的活引用,
|
|
36
|
+
* 换新对象会让引用指向已废弃的桶,后续计数写到内存孤儿上。
|
|
37
|
+
*/
|
|
38
|
+
reset(): void;
|
|
33
39
|
save(): Promise<void>;
|
|
34
40
|
static pathFor(dataDir: string): string;
|
|
35
41
|
}
|
package/dist/store/state.js
CHANGED
|
@@ -52,6 +52,17 @@ export class StateStore {
|
|
|
52
52
|
forFamily(family) {
|
|
53
53
|
return this.buckets[family];
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* 重建用:两族 checkpoint 重置为默认值。
|
|
57
|
+
* 必须原地突变(Object.assign)——runner.states 等处持有桶对象的活引用,
|
|
58
|
+
* 换新对象会让引用指向已废弃的桶,后续计数写到内存孤儿上。
|
|
59
|
+
*/
|
|
60
|
+
reset() {
|
|
61
|
+
for (const family of ['chat', 'work']) {
|
|
62
|
+
Object.assign(this.buckets[family], defaultState());
|
|
63
|
+
this.buckets[family].personaRequestedReason = undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
55
66
|
async save() {
|
|
56
67
|
const file = { version: 2, families: this.buckets };
|
|
57
68
|
await atomicWriteJson(this.file, file);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-layered-memory",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "L0~L3 分层蒸馏记忆插件 for DeepSeek Harness:自动捕获对话(L0)、抽取原子记忆(L1)、整合场景块(L2)、蒸馏核心画像/团队方法论(L3),并在模型步骤前自动召回注入。移植自 MemoryCore (TencentDB Agent Memory) 的管线设计。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|