dsh-prime-memory 0.16.1-beta.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/assets/img/ui-light.jpg +0 -0
- package/dist/client.js +41 -4
- package/dist/contract.d.ts +6 -0
- package/dist/index.js +13 -2
- package/dist/pipeline/reconcile-run.d.ts +13 -3
- package/dist/pipeline/reconcile-run.js +37 -9
- package/dist/pipeline/runner.d.ts +3 -0
- package/dist/pipeline/runner.js +15 -1
- package/dist/stats.js +6 -1
- package/dist/store/embedding-source.d.ts +26 -0
- package/dist/store/embedding-source.js +73 -22
- package/dist/store/file-versions.d.ts +27 -0
- package/dist/store/file-versions.js +27 -0
- package/dist/store/l1-snapshot.d.ts +17 -3
- package/dist/store/l1-snapshot.js +40 -9
- package/dist/store/l1.d.ts +2 -0
- package/dist/store/l1.js +1 -1
- package/dist/store/occupancy.d.ts +11 -1
- package/dist/store/occupancy.js +52 -5
- package/dist/store/pending.d.ts +7 -1
- package/dist/store/pending.js +21 -16
- package/dist/store/recall-dedupe.d.ts +11 -1
- package/dist/store/recall-dedupe.js +52 -5
- package/dist/store/session-modes.d.ts +12 -1
- package/dist/store/session-modes.js +53 -5
- package/dist/store/slots.d.ts +20 -2
- package/dist/store/slots.js +81 -8
- package/dist/store/sqlite.d.ts +9 -0
- package/dist/store/sqlite.js +8 -0
- package/dist/store/state.d.ts +15 -2
- package/dist/store/state.js +70 -8
- package/dist/util/io.d.ts +93 -6
- package/dist/util/io.js +217 -9
- package/dist/util/lock.d.ts +39 -0
- package/dist/util/lock.js +217 -0
- package/dist/util/path-guard.d.ts +13 -0
- package/dist/util/path-guard.js +71 -0
- package/dsh.plugin.json +2 -2
- package/package.json +11 -11
- package/assets/changelog/0.8.10/01-write-only-pill.png +0 -0
- package/assets/changelog/0.8.9/01-panel.png +0 -0
- package/assets/changelog/0.8.9/02-halo.png +0 -0
- package/assets/changelog/0.8.9/03-layer-segmented-panel.png +0 -0
- package/assets/changelog/0.8.9/04-layer-l1-panel.png +0 -0
- package/assets/img/ui-dark.jpg +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -58,6 +58,10 @@
|
|
|
58
58
|
|
|
59
59
|
## [0.16.1] — 2026-09-24
|
|
60
60
|
|
|
61
|
+
### 新增
|
|
62
|
+
|
|
63
|
+
- **记录面板「退场筛查」**:软删记录按设计不隐藏(可恢复),但与活跃记录混排不便分辨——现在列表工具行加了三态筛选「**全部 / 仅活跃 / 仅退场**」。后端 `listL1` 补同口径三态过滤(缺省全量,快照/重建不受影响;`retired:true` 与 `listRetiredL1` 同一判据,两份视图看到同一批行);契约 `ListRecordsRequest.retired?: boolean`。筛查仅浏览路径生效——关键词检索只覆盖检索面,已退场记录本就不在其中。「加载更多」与删除/恢复后的自动刷新都会保持当前筛查态。
|
|
64
|
+
|
|
61
65
|
### 修复
|
|
62
66
|
|
|
63
67
|
- **面板「删除记忆」确认后看似毫无反应——退场态没有透传给 UI。** 软删(retire)在服务端一直正常(`valid_to` 闭合 + 撤出检索面 + 可恢复),但按设计活动列表**保留**退场记录(`l1-retire` 测试钉死「面板浏览路径不隐藏退场记录」),而 `UiRecord` 契约里根本没有"是否已退场"字段、活动列表的记录行也没有任何视觉差异,"已退场"区又默认折叠——用户确认删除后看到的是**一条一模一样的记录**,自然以为"删除没生效"。
|
package/assets/img/ui-light.jpg
CHANGED
|
Binary file
|
package/dist/client.js
CHANGED
|
@@ -3226,6 +3226,11 @@ var __defProp = Object.defineProperty;
|
|
|
3226
3226
|
// client/src/tabs/RecordsTab.tsx
|
|
3227
3227
|
var import_react15 = require("react");
|
|
3228
3228
|
var import_jsx_runtime15 = require("react/jsx-runtime");
|
|
3229
|
+
function retiredSelOf(r) {
|
|
3230
|
+
if (r === "active") return false;
|
|
3231
|
+
if (r === "retired") return true;
|
|
3232
|
+
return void 0;
|
|
3233
|
+
}
|
|
3229
3234
|
var TYPE_CHOICES = [
|
|
3230
3235
|
"persona",
|
|
3231
3236
|
"episodic",
|
|
@@ -3255,9 +3260,10 @@ var __defProp = Object.defineProperty;
|
|
|
3255
3260
|
const [sceneFilter, setSceneFilter] = (0, import_react15.useState)("");
|
|
3256
3261
|
const [hallFilter, setHallFilter] = (0, import_react15.useState)([]);
|
|
3257
3262
|
const [tagFilter, setTagFilter] = (0, import_react15.useState)("");
|
|
3263
|
+
const [retiredFilter, setRetiredFilter] = (0, import_react15.useState)("");
|
|
3258
3264
|
const [rooms, setRooms] = (0, import_react15.useState)([]);
|
|
3259
3265
|
const [wingCatalog, setHallCatalog] = (0, import_react15.useState)(null);
|
|
3260
|
-
const [last, setLast] = (0, import_react15.useState)({ query: "", type: "", scene: "", halls: [], tag: "" });
|
|
3266
|
+
const [last, setLast] = (0, import_react15.useState)({ query: "", type: "", scene: "", halls: [], tag: "", retired: "" });
|
|
3261
3267
|
const seqRef = (0, import_react15.useRef)(0);
|
|
3262
3268
|
const fetchPage = (0, import_react15.useCallback)(
|
|
3263
3269
|
(conds, offset, append) => {
|
|
@@ -3270,6 +3276,8 @@ var __defProp = Object.defineProperty;
|
|
|
3270
3276
|
if (conds.scene) payload.scene = conds.scene;
|
|
3271
3277
|
if (conds.halls.length > 0) payload.halls = conds.halls;
|
|
3272
3278
|
if (conds.tag) payload.tag = conds.tag;
|
|
3279
|
+
const retiredSel = retiredSelOf(conds.retired);
|
|
3280
|
+
if (retiredSel !== void 0) payload.retired = retiredSel;
|
|
3273
3281
|
rpc("dsh-memory/list-records", payload).then((r) => {
|
|
3274
3282
|
if (token !== seqRef.current) return;
|
|
3275
3283
|
setLoading(false);
|
|
@@ -3294,7 +3302,13 @@ var __defProp = Object.defineProperty;
|
|
|
3294
3302
|
[rpc]
|
|
3295
3303
|
);
|
|
3296
3304
|
const search = () => {
|
|
3297
|
-
const conds = { query: query.trim(), type: typeFilter, scene: sceneFilter, halls: hallFilter, tag: tagFilter };
|
|
3305
|
+
const conds = { query: query.trim(), type: typeFilter, scene: sceneFilter, halls: hallFilter, tag: tagFilter, retired: retiredFilter };
|
|
3306
|
+
setLast(conds);
|
|
3307
|
+
fetchPage(conds, 0, false);
|
|
3308
|
+
};
|
|
3309
|
+
const applyRetiredFilter = (next) => {
|
|
3310
|
+
setRetiredFilter(next);
|
|
3311
|
+
const conds = { query: query.trim(), type: typeFilter, scene: sceneFilter, halls: hallFilter, tag: tagFilter, retired: next };
|
|
3298
3312
|
setLast(conds);
|
|
3299
3313
|
fetchPage(conds, 0, false);
|
|
3300
3314
|
};
|
|
@@ -3305,7 +3319,7 @@ var __defProp = Object.defineProperty;
|
|
|
3305
3319
|
});
|
|
3306
3320
|
}, [rpc]);
|
|
3307
3321
|
(0, import_react15.useEffect)(() => {
|
|
3308
|
-
fetchPage({ query: "", type: "", scene: "", halls: [], tag: "" }, 0, false);
|
|
3322
|
+
fetchPage({ query: "", type: "", scene: "", halls: [], tag: "", retired: "" }, 0, false);
|
|
3309
3323
|
loadRooms();
|
|
3310
3324
|
}, [fetchPage, loadRooms]);
|
|
3311
3325
|
const loadHiPriv = (0, import_react15.useCallback)(() => {
|
|
@@ -3486,7 +3500,7 @@ var __defProp = Object.defineProperty;
|
|
|
3486
3500
|
onClick: () => {
|
|
3487
3501
|
const next = on ? "" : r.room;
|
|
3488
3502
|
setTagFilter(next);
|
|
3489
|
-
const conds = { query: query.trim(), type: typeFilter, scene: sceneFilter, halls: hallFilter, tag: next };
|
|
3503
|
+
const conds = { query: query.trim(), type: typeFilter, scene: sceneFilter, halls: hallFilter, tag: next, retired: retiredFilter };
|
|
3490
3504
|
setLast(conds);
|
|
3491
3505
|
fetchPage(conds, 0, false);
|
|
3492
3506
|
},
|
|
@@ -3508,6 +3522,29 @@ var __defProp = Object.defineProperty;
|
|
|
3508
3522
|
] }) : /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { style: { ...S.muted, marginBottom: 10 }, children: "Room 分类:暂无(由反刍涌现的标签自动生成,无需手工建立)" }),
|
|
3509
3523
|
/* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { style: { ...S.flexRow, marginBottom: 10 }, children: [
|
|
3510
3524
|
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { style: S.muted, children: loading ? "加载中…" : countText }),
|
|
3525
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { style: S.muted, children: "退场筛查" }),
|
|
3526
|
+
[["", "全部"], ["active", "仅活跃"], ["retired", "仅退场"]].map(([val, label]) => {
|
|
3527
|
+
const on = retiredFilter === val;
|
|
3528
|
+
return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
|
|
3529
|
+
"button",
|
|
3530
|
+
{
|
|
3531
|
+
type: "button",
|
|
3532
|
+
title: val === "" ? "活跃与已退场混排(已退场的带徽标)" : val === "active" ? "只看未退场的记忆" : "只看已退场(软删)的记忆,可在此恢复",
|
|
3533
|
+
onClick: () => applyRetiredFilter(val),
|
|
3534
|
+
style: {
|
|
3535
|
+
cursor: "pointer",
|
|
3536
|
+
fontSize: 12,
|
|
3537
|
+
padding: "2px 10px",
|
|
3538
|
+
borderRadius: 999,
|
|
3539
|
+
border: on ? "1px solid var(--dsh-mem-accent)" : "1px solid var(--dsh-mem-border)",
|
|
3540
|
+
background: on ? "var(--dsh-mem-bg-inset)" : "transparent",
|
|
3541
|
+
color: on ? "var(--dsh-mem-accent)" : "var(--dsh-mem-text-2)"
|
|
3542
|
+
},
|
|
3543
|
+
children: label
|
|
3544
|
+
},
|
|
3545
|
+
val
|
|
3546
|
+
);
|
|
3547
|
+
}),
|
|
3511
3548
|
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
|
|
3512
3549
|
NButton,
|
|
3513
3550
|
{
|
package/dist/contract.d.ts
CHANGED
|
@@ -650,6 +650,12 @@ export interface ListRecordsRequest {
|
|
|
650
650
|
* Room 由 tags 派生(自生长),故这里传的是**具体 tag 名**而非枚举 id。
|
|
651
651
|
*/
|
|
652
652
|
tag?: string;
|
|
653
|
+
/**
|
|
654
|
+
* 退场(软删)筛查:三态。省略 = 全部(活跃+已退场混排,靠 `UiRecord.retired`
|
|
655
|
+
* 徽标区分);`false` = 仅活跃;`true` = 仅已退场。
|
|
656
|
+
* **仅浏览路径生效**:关键词检索只覆盖检索面,已退场记录本就不在其中。
|
|
657
|
+
*/
|
|
658
|
+
retired?: boolean;
|
|
653
659
|
/** 1~200,默认 50。 */
|
|
654
660
|
limit?: number;
|
|
655
661
|
/** 0~1_000_000。 */
|
package/dist/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import { NoopEmbeddingService } from './store/embedding.js';
|
|
|
22
22
|
import { EmbeddingManager, EmbeddingSourceStore, makeLocalServiceFactory, resolveInitialEmbedding, } from './store/embedding-source.js';
|
|
23
23
|
import { ModelDownloadQueue } from './store/download-queue.js';
|
|
24
24
|
import { PINNED_TRANSFORMERS_VERSION, RuntimeInstaller } from './store/runtime-installer.js';
|
|
25
|
-
import { ensureDir } from './util/io.js';
|
|
25
|
+
import { cleanupOrphanTmp, ensureDir } from './util/io.js';
|
|
26
26
|
import { L0Store } from './store/l0.js';
|
|
27
27
|
import { L1Store } from './store/l1.js';
|
|
28
28
|
import { PersonaStore } from './store/persona.js';
|
|
@@ -81,6 +81,17 @@ export async function apply(ctx, config) {
|
|
|
81
81
|
storageOk = false;
|
|
82
82
|
logger.error(`[memory] 数据目录不可写,记忆功能停用: ${dataDir} (${err instanceof Error ? err.message : String(err)})`);
|
|
83
83
|
}
|
|
84
|
+
// 孤儿 tmp 扫描:进程被 kill -9 / 断电时原子写会留下 tmp(写失败路径已 unlink,
|
|
85
|
+
// 只在硬中断下残留)。放在 store 载入之前——此刻还没有任何写者,清不掉"正在写"的文件。
|
|
86
|
+
// 清理失败绝不影响启动(残留 tmp 不参与任何读路径)。
|
|
87
|
+
if (storageOk) {
|
|
88
|
+
try {
|
|
89
|
+
await cleanupOrphanTmp(dataDir, logger);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
logger.warn(`[memory] 孤儿临时文件扫描失败(忽略): ${err instanceof Error ? err.message : String(err)}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
84
95
|
// ── 记忆模式运行时开关(官方 settings 服务,live 生效;缺失时恒开) ──
|
|
85
96
|
const live = registerLiveSettings(ctx, logger);
|
|
86
97
|
/** 插件停机标志:置位后不再发起后台 embeddings 调用(dispose 序最先设置)。 */
|
|
@@ -153,7 +164,7 @@ export async function apply(ctx, config) {
|
|
|
153
164
|
chat: new PersonaStore(dataDir, 'chat', logger),
|
|
154
165
|
work: new PersonaStore(dataDir, 'work', logger),
|
|
155
166
|
},
|
|
156
|
-
state: new StateStore(StateStore.pathFor(dataDir)),
|
|
167
|
+
state: new StateStore(StateStore.pathFor(dataDir), logger),
|
|
157
168
|
// 激活槽位(active slot):独立 slots.json,高频小改不拖累 checkpoint 原子写
|
|
158
169
|
slots: new SlotStore(SlotStore.pathFor(dataDir), logger, {
|
|
159
170
|
maxSlots: config.slots.maxSlots,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RECONCILE_STATE_FILE_VERSION } from '../store/file-versions.js';
|
|
1
2
|
import type { EvidenceSource } from '../store/evidence-source.js';
|
|
2
3
|
import type { MemoryLogger } from '../types.js';
|
|
3
4
|
import { type MemoryVerdict, type ReconcileDeps, type ReconcileInput, type ReconcileState } from './reconcile.js';
|
|
@@ -49,7 +50,7 @@ export interface ReconcilePlan {
|
|
|
49
50
|
}
|
|
50
51
|
/** 断点续跑状态文件。 */
|
|
51
52
|
export interface ReconcileRunState {
|
|
52
|
-
version:
|
|
53
|
+
version: typeof RECONCILE_STATE_FILE_VERSION;
|
|
53
54
|
runId: string;
|
|
54
55
|
startedAt: number;
|
|
55
56
|
updatedAt: number;
|
|
@@ -62,8 +63,17 @@ export declare function contentHash(text: string): string;
|
|
|
62
63
|
export declare function resumeKey(memory: Pick<ReconcileInput, 'id' | 'text'>): string;
|
|
63
64
|
/** 状态文件路径(与 `pendingPathFor` / `state.json` 同目录)。 */
|
|
64
65
|
export declare function reconcileStatePathFor(dataDir: string): string;
|
|
65
|
-
/**
|
|
66
|
-
|
|
66
|
+
/**
|
|
67
|
+
* 读状态。
|
|
68
|
+
*
|
|
69
|
+
* **读侧分类**(文件层加固 T2.10,审计 D2 点名补上):原注释写「文件损坏/版本不符
|
|
70
|
+
* 一律当作空状态」——损坏**不再静默**:`missing` 是首次运行(不告警),而
|
|
71
|
+
* `corrupt` / `unreadable` / `unknown_version` 一律记诊断后才按空状态继续。
|
|
72
|
+
* 「不覆盖」由 `atomicWriteJson` 的覆盖保护兜住(损坏文件写不进去,会显式报错)。
|
|
73
|
+
*
|
|
74
|
+
* 仍然**不抛**:续跑状态只是"跳过已判条目"的优化,坏了重跑一遍即可,不该阻止运行。
|
|
75
|
+
*/
|
|
76
|
+
export declare function loadRunState(file: string, logger?: MemoryLogger): Promise<ReconcileRunState | undefined>;
|
|
67
77
|
/**
|
|
68
78
|
* 预估并裁剪。
|
|
69
79
|
*
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { createHash } from 'node:crypto';
|
|
22
22
|
import { CHARS_PER_TOKEN } from '../util/context-occupancy.js';
|
|
23
|
-
import { atomicWriteJson,
|
|
23
|
+
import { atomicWriteJson, readJsonStrict } from '../util/io.js';
|
|
24
|
+
import { RECONCILE_STATE_FILE_VERSION } from '../store/file-versions.js';
|
|
24
25
|
import { renderReport, runReconcile, } from './reconcile.js';
|
|
25
26
|
export const DEFAULT_RECONCILE_BUDGET = {
|
|
26
27
|
maxRecords: 50,
|
|
@@ -40,13 +41,34 @@ export function resumeKey(memory) {
|
|
|
40
41
|
export function reconcileStatePathFor(dataDir) {
|
|
41
42
|
return `${dataDir.replace(/[\\/]+$/, '')}/reconcile-state.json`;
|
|
42
43
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
/**
|
|
45
|
+
* 读状态。
|
|
46
|
+
*
|
|
47
|
+
* **读侧分类**(文件层加固 T2.10,审计 D2 点名补上):原注释写「文件损坏/版本不符
|
|
48
|
+
* 一律当作空状态」——损坏**不再静默**:`missing` 是首次运行(不告警),而
|
|
49
|
+
* `corrupt` / `unreadable` / `unknown_version` 一律记诊断后才按空状态继续。
|
|
50
|
+
* 「不覆盖」由 `atomicWriteJson` 的覆盖保护兜住(损坏文件写不进去,会显式报错)。
|
|
51
|
+
*
|
|
52
|
+
* 仍然**不抛**:续跑状态只是"跳过已判条目"的优化,坏了重跑一遍即可,不该阻止运行。
|
|
53
|
+
*/
|
|
54
|
+
export async function loadRunState(file, logger) {
|
|
55
|
+
const r = await readJsonStrict(file, {
|
|
56
|
+
expectedVersion: RECONCILE_STATE_FILE_VERSION,
|
|
57
|
+
});
|
|
58
|
+
if (!r.ok) {
|
|
59
|
+
if (r.reason === 'missing')
|
|
60
|
+
return undefined;
|
|
61
|
+
const why = r.reason === 'corrupt' ? '损坏' : r.reason === 'unknown_version' ? '版本未知' : '不可读';
|
|
62
|
+
logger?.warn(`[memory] 冲突续跑状态${why}(本次全量重核,原文件不覆盖): ${file}${r.detail ? ` — ${r.detail}` : ''}`);
|
|
47
63
|
return undefined;
|
|
64
|
+
}
|
|
65
|
+
const raw = r.value;
|
|
66
|
+
if (!Array.isArray(raw.done)) {
|
|
67
|
+
logger?.warn(`[memory] 冲突续跑状态形状不符(缺 done 数组,本次全量重核): ${file}`);
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
48
70
|
return {
|
|
49
|
-
version:
|
|
71
|
+
version: RECONCILE_STATE_FILE_VERSION,
|
|
50
72
|
runId: typeof raw.runId === 'string' ? raw.runId : '',
|
|
51
73
|
startedAt: typeof raw.startedAt === 'number' ? raw.startedAt : 0,
|
|
52
74
|
updatedAt: typeof raw.updatedAt === 'number' ? raw.updatedAt : 0,
|
|
@@ -149,10 +171,10 @@ export function renderEstimate(estimate, budget) {
|
|
|
149
171
|
*/
|
|
150
172
|
export async function executeReconcile(deps, plan, opts = {}) {
|
|
151
173
|
const stateFile = opts.stateFile;
|
|
152
|
-
const previous = stateFile === undefined ? undefined : await loadRunState(stateFile);
|
|
174
|
+
const previous = stateFile === undefined ? undefined : await loadRunState(stateFile, opts.logger);
|
|
153
175
|
const done = new Set(previous?.done ?? []);
|
|
154
176
|
const state = previous ?? {
|
|
155
|
-
version:
|
|
177
|
+
version: RECONCILE_STATE_FILE_VERSION,
|
|
156
178
|
runId: createHash('sha1').update(String(Date.now())).digest('hex').slice(0, 12),
|
|
157
179
|
startedAt: Date.now(),
|
|
158
180
|
updatedAt: Date.now(),
|
|
@@ -198,7 +220,13 @@ export async function executeReconcile(deps, plan, opts = {}) {
|
|
|
198
220
|
}
|
|
199
221
|
/** 清空续跑状态(显式动作:下次运行会重核所有条目,**会重复计费**)。 */
|
|
200
222
|
export async function resetRunState(file) {
|
|
201
|
-
await atomicWriteJson(file, {
|
|
223
|
+
await atomicWriteJson(file, {
|
|
224
|
+
version: RECONCILE_STATE_FILE_VERSION,
|
|
225
|
+
runId: '',
|
|
226
|
+
startedAt: 0,
|
|
227
|
+
updatedAt: 0,
|
|
228
|
+
done: [],
|
|
229
|
+
});
|
|
202
230
|
}
|
|
203
231
|
/** 供装配层使用:把证据面与 judge 组装成 deps。 */
|
|
204
232
|
export function makeReconcileDeps(evidence, judge, logger) {
|
|
@@ -70,6 +70,9 @@ export declare class MemoryRunner {
|
|
|
70
70
|
/** 停止标志(dispose 序置位):不再取新任务;进行中任务自然收尾。 */
|
|
71
71
|
private stopped;
|
|
72
72
|
private pending;
|
|
73
|
+
/** pending.json 的只读降级原因(undefined = 正常):非空时不再回写缓冲。 */
|
|
74
|
+
private pendingDegraded;
|
|
75
|
+
private pendingDegradedLogged;
|
|
73
76
|
/** 各档位桶渐进阈值(1 起步翻倍至稳态毕业;随 pending.json 持久化)。 */
|
|
74
77
|
private warmup;
|
|
75
78
|
/** 每会话最后活动时间(闲置兜底判定用)。 */
|
package/dist/pipeline/runner.js
CHANGED
|
@@ -162,6 +162,9 @@ export class MemoryRunner {
|
|
|
162
162
|
/** 停止标志(dispose 序置位):不再取新任务;进行中任务自然收尾。 */
|
|
163
163
|
stopped = false;
|
|
164
164
|
pending = emptyPending();
|
|
165
|
+
/** pending.json 的只读降级原因(undefined = 正常):非空时不再回写缓冲。 */
|
|
166
|
+
pendingDegraded;
|
|
167
|
+
pendingDegradedLogged = false;
|
|
165
168
|
/** 各档位桶渐进阈值(1 起步翻倍至稳态毕业;随 pending.json 持久化)。 */
|
|
166
169
|
warmup = freshWarmup();
|
|
167
170
|
/** 每会话最后活动时间(闲置兜底判定用)。 */
|
|
@@ -199,7 +202,8 @@ export class MemoryRunner {
|
|
|
199
202
|
}
|
|
200
203
|
// 恢复未蒸馏缓冲(上次进程退出前未蒸馏的消息,含失败待重试与攒阈值中途的)
|
|
201
204
|
try {
|
|
202
|
-
const { buckets: loaded, warmup } = await loadPending(this.pendingFile, this.logger);
|
|
205
|
+
const { buckets: loaded, warmup, degraded } = await loadPending(this.pendingFile, this.logger);
|
|
206
|
+
this.pendingDegraded = degraded;
|
|
203
207
|
for (const key of PENDING_MODES) {
|
|
204
208
|
if (loaded[key].length > PENDING_BUCKET_CAP)
|
|
205
209
|
loaded[key] = loaded[key].slice(-PENDING_BUCKET_CAP);
|
|
@@ -486,6 +490,16 @@ export class MemoryRunner {
|
|
|
486
490
|
* 非重建轮持久化前按桶截断到上限:重建取消后的大桶不至于在后续每次
|
|
487
491
|
* 蒸馏尝试时反复整量序列化落盘(多 MB 级 IO);重建轮豁免维持。 */
|
|
488
492
|
async persistPending(noBufferCap = false) {
|
|
493
|
+
// 只读降级(pending.json 损坏或版本未知):缓冲不再落盘,但**蒸馏照常跑**——
|
|
494
|
+
// 缓冲只是"待蒸馏队列",丢了不回写最多是重启后重蒸馏一轮,
|
|
495
|
+
// 而按旧解释覆盖新格式会真的弄坏文件。
|
|
496
|
+
if (this.pendingDegraded !== undefined) {
|
|
497
|
+
if (!this.pendingDegradedLogged) {
|
|
498
|
+
this.pendingDegradedLogged = true;
|
|
499
|
+
this.logger.warn(`[memory] 未蒸馏缓冲处于只读降级(${this.pendingDegraded}),已停止回写(磁盘文件保持不变)`);
|
|
500
|
+
}
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
489
503
|
try {
|
|
490
504
|
if (!noBufferCap) {
|
|
491
505
|
for (const key of PENDING_MODES) {
|
package/dist/stats.js
CHANGED
|
@@ -746,6 +746,9 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
746
746
|
const p = (payload ?? {});
|
|
747
747
|
if (p.query !== undefined && p.query.length > 4096)
|
|
748
748
|
throw new Error('query 过长(≤4096 字符)');
|
|
749
|
+
// 退场筛查:三态(缺省=全部混排/false=仅活跃/true=仅已退场)。显式布尔才透传,
|
|
750
|
+
// 其余值(字符串/数字等)一律视为缺省——宁可宽看,不可静默筛掉一半。
|
|
751
|
+
const retiredSel = typeof p.retired === 'boolean' ? p.retired : undefined;
|
|
749
752
|
// Room 过滤:tag 是**自生长**的 slug(无枚举),只做形状与长度校验(SQL 侧参数化)
|
|
750
753
|
const tagSel = typeof p.tag === 'string' ? p.tag.trim().slice(0, 64) : '';
|
|
751
754
|
if (tagSel && !isTag(tagSel))
|
|
@@ -803,7 +806,9 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
803
806
|
};
|
|
804
807
|
return resp;
|
|
805
808
|
}
|
|
806
|
-
|
|
809
|
+
// 退场筛查仅浏览路径透传;检索路径不接——FTS/向量里本就没有已退场行,
|
|
810
|
+
// 接了只会让「仅退场+关键词」永远空结果,不如如实不筛。
|
|
811
|
+
const { items, total } = stores.l1.list({ type: p.type || undefined, scene: p.scene || undefined, hall: p.hall || undefined, halls: wingSel.length > 0 ? wingSel : undefined, tag: tagSel || undefined, retired: retiredSel, limit, offset });
|
|
807
812
|
const resp = {
|
|
808
813
|
items: items.map(hitToUiRecord),
|
|
809
814
|
hasMore: offset + items.length < total,
|
|
@@ -19,10 +19,36 @@ export declare class EmbeddingSourceStore {
|
|
|
19
19
|
private readonly file;
|
|
20
20
|
private writeQueue;
|
|
21
21
|
private readonly logger?;
|
|
22
|
+
/** 只读降级原因(undefined = 正常):文件损坏/不可读时置位,此后 `set()` 一律失败。 */
|
|
23
|
+
private degraded;
|
|
24
|
+
/** 本进程上次成功写入的磁盘内容(紧凑 JSON);并发冲突判据。 */
|
|
25
|
+
private lastPersisted;
|
|
22
26
|
constructor(dataDir: string, logger?: MemoryLogger);
|
|
23
27
|
get(): EmbeddingSourceState;
|
|
28
|
+
/**
|
|
29
|
+
* 读侧 strict 化(文件层加固 T2.11)。
|
|
30
|
+
*
|
|
31
|
+
* 旧实现是「裸 readFile + 外层 `catch {}`」:那个 catch 同时吞掉 ENOENT 与
|
|
32
|
+
* **JSON 解析错误**,于是「文件损坏」与「首次运行」长得一模一样——既无告警,
|
|
33
|
+
* 也会在随后被回写覆盖。现在三态分开:
|
|
34
|
+
* - `missing` → 默认 remote(历史行为,老用户无感),**不告警**;
|
|
35
|
+
* - `corrupt` / `unreadable` → 告警 + **只读降级**(后续 `set()` 抛错,不覆盖原文件);
|
|
36
|
+
* - 形状非法(解析成功但字段不对)→ 告警,同样按默认 remote 起步。
|
|
37
|
+
*/
|
|
24
38
|
init(): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* 改状态并写穿持久化。
|
|
41
|
+
*
|
|
42
|
+
* **失败必须对调用方可观测**:旧实现是 `writeQueue.then(persist).catch(() => {})`,
|
|
43
|
+
* 写失败后 `await` 照样 resolve——调用方以为已落盘,重启却回到旧源(G6 静默失败)。
|
|
44
|
+
* 现在本次 await 直接抛出;队列本身用 `.catch()` 兜住,免得一次失败把后续
|
|
45
|
+
* set 永久钉在 rejected 链上。
|
|
46
|
+
*/
|
|
25
47
|
set(next: EmbeddingSourceState): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* 落盘。走 `atomicWriteText` 而不是自研 tmp+rename:白拿文件级 fsync、
|
|
50
|
+
* 随机 tmp 名(旧实现的固定名 `*.tmp` 在多实例下会撞名)、以及失败路径清理。
|
|
51
|
+
*/
|
|
26
52
|
private persist;
|
|
27
53
|
}
|
|
28
54
|
export interface InitialEmbedding {
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* 重嵌取消/部分失败 → 已切换(物理表即新维度,meta 已同步),缺失向量由周期
|
|
14
14
|
* backfill 补齐——不回滚(回滚需要再 drop 一次表,得不偿失)。
|
|
15
15
|
*/
|
|
16
|
-
import { promises as fs } from 'node:fs';
|
|
17
16
|
import * as path from 'node:path';
|
|
17
|
+
import { readJsonStrict, rmwJson } from '../util/io.js';
|
|
18
18
|
import { NoopEmbeddingService, RemoteEmbeddingService } from './embedding.js';
|
|
19
19
|
import { catalogById, MODEL_CATALOG } from './model-catalog.js';
|
|
20
20
|
import { LocalEmbeddingService } from './local-embedding.js';
|
|
@@ -28,6 +28,10 @@ export class EmbeddingSourceStore {
|
|
|
28
28
|
file;
|
|
29
29
|
writeQueue = Promise.resolve();
|
|
30
30
|
logger;
|
|
31
|
+
/** 只读降级原因(undefined = 正常):文件损坏/不可读时置位,此后 `set()` 一律失败。 */
|
|
32
|
+
degraded;
|
|
33
|
+
/** 本进程上次成功写入的磁盘内容(紧凑 JSON);并发冲突判据。 */
|
|
34
|
+
lastPersisted;
|
|
31
35
|
constructor(dataDir, logger) {
|
|
32
36
|
this.file = path.join(dataDir, 'embedding-source.json');
|
|
33
37
|
this.logger = logger;
|
|
@@ -35,31 +39,70 @@ export class EmbeddingSourceStore {
|
|
|
35
39
|
get() {
|
|
36
40
|
return { ...this.state };
|
|
37
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* 读侧 strict 化(文件层加固 T2.11)。
|
|
44
|
+
*
|
|
45
|
+
* 旧实现是「裸 readFile + 外层 `catch {}`」:那个 catch 同时吞掉 ENOENT 与
|
|
46
|
+
* **JSON 解析错误**,于是「文件损坏」与「首次运行」长得一模一样——既无告警,
|
|
47
|
+
* 也会在随后被回写覆盖。现在三态分开:
|
|
48
|
+
* - `missing` → 默认 remote(历史行为,老用户无感),**不告警**;
|
|
49
|
+
* - `corrupt` / `unreadable` → 告警 + **只读降级**(后续 `set()` 抛错,不覆盖原文件);
|
|
50
|
+
* - 形状非法(解析成功但字段不对)→ 告警,同样按默认 remote 起步。
|
|
51
|
+
*/
|
|
38
52
|
async init() {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
const r = await readJsonStrict(this.file);
|
|
54
|
+
if (!r.ok) {
|
|
55
|
+
if (r.reason === 'missing')
|
|
56
|
+
return;
|
|
57
|
+
this.degraded = r.reason;
|
|
58
|
+
this.logger?.warn(`[memory] 嵌入源状态文件${r.reason === 'corrupt' ? '损坏' : '不可读'}(${this.file}): ` +
|
|
59
|
+
`按默认 remote 起步且**不回写**,原文件已保留${r.detail ? ` — ${r.detail}` : ''}`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const parsed = r.value;
|
|
63
|
+
if ((parsed.source === 'remote' || parsed.source === 'local' || parsed.source === 'off') &&
|
|
64
|
+
(parsed.activeModel === null || typeof parsed.activeModel === 'string')) {
|
|
65
|
+
this.state = { source: parsed.source, activeModel: parsed.activeModel };
|
|
66
|
+
this.lastPersisted = JSON.stringify(this.state);
|
|
49
67
|
}
|
|
50
|
-
|
|
51
|
-
|
|
68
|
+
else {
|
|
69
|
+
this.logger?.warn('[memory] 嵌入源状态文件形状非法,按默认 remote 起步');
|
|
52
70
|
}
|
|
53
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* 改状态并写穿持久化。
|
|
74
|
+
*
|
|
75
|
+
* **失败必须对调用方可观测**:旧实现是 `writeQueue.then(persist).catch(() => {})`,
|
|
76
|
+
* 写失败后 `await` 照样 resolve——调用方以为已落盘,重启却回到旧源(G6 静默失败)。
|
|
77
|
+
* 现在本次 await 直接抛出;队列本身用 `.catch()` 兜住,免得一次失败把后续
|
|
78
|
+
* set 永久钉在 rejected 链上。
|
|
79
|
+
*/
|
|
54
80
|
async set(next) {
|
|
81
|
+
if (this.degraded !== undefined) {
|
|
82
|
+
// 损坏文件绝不覆盖:重启会回到旧源,但用户至少能看到这条失败(不再"改了没生效")
|
|
83
|
+
throw new Error(`嵌入源状态文件${this.degraded === 'corrupt' ? '已损坏' : '不可读'},拒绝覆盖: ${this.file}`);
|
|
84
|
+
}
|
|
55
85
|
this.state = { source: next.source, activeModel: next.activeModel };
|
|
56
|
-
|
|
57
|
-
|
|
86
|
+
const write = this.writeQueue.then(() => this.persist());
|
|
87
|
+
this.writeQueue = write.catch(() => { });
|
|
88
|
+
await write;
|
|
58
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* 落盘。走 `atomicWriteText` 而不是自研 tmp+rename:白拿文件级 fsync、
|
|
92
|
+
* 随机 tmp 名(旧实现的固定名 `*.tmp` 在多实例下会撞名)、以及失败路径清理。
|
|
93
|
+
*/
|
|
59
94
|
async persist() {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
await
|
|
95
|
+
// 锁内 RMW(T4.11):嵌入源是"改一次落一次"的低频写,锁开销可忽略;
|
|
96
|
+
// 两个实例并发 set 时,后写的那个会看到磁盘与自己的上次写入不同 → 显式失败。
|
|
97
|
+
await rmwJson(this.file, async (cur) => {
|
|
98
|
+
const diskRaw = cur.ok ? JSON.stringify(cur.value) : undefined;
|
|
99
|
+
if (this.lastPersisted !== undefined && diskRaw !== undefined && diskRaw !== this.lastPersisted) {
|
|
100
|
+
throw new Error(`嵌入源状态已被其它进程更新,本次写入已取消以避免覆盖;请重试该操作`);
|
|
101
|
+
}
|
|
102
|
+
const next = { source: this.state.source, activeModel: this.state.activeModel };
|
|
103
|
+
this.lastPersisted = JSON.stringify(next);
|
|
104
|
+
return { next, result: undefined };
|
|
105
|
+
}, { logger: this.logger, purpose: 'embedding-source-rmw' });
|
|
63
106
|
}
|
|
64
107
|
}
|
|
65
108
|
/** 远程档部署上限:baseUrl + model + 维度 + enabled。apiKey 可选(本地免 key 自托管
|
|
@@ -403,13 +446,21 @@ export class EmbeddingManager {
|
|
|
403
446
|
if (result.error)
|
|
404
447
|
throw new Error(result.error);
|
|
405
448
|
}
|
|
406
|
-
|
|
449
|
+
// 落盘失败不等于切换失败:走到这里服务已换、物理表已按新维度重建——真相是
|
|
450
|
+
// "本次会话用新源、重启回旧源"。旧实现把失败吞掉(§6.2),UI 却显示完全成功,
|
|
451
|
+
// 于是用户以为已保存。这里如实报出来,不再把两种结果混成一个 'done'。
|
|
452
|
+
let persistNote = '';
|
|
453
|
+
try {
|
|
454
|
+
await this.sourceStore.set(next);
|
|
455
|
+
}
|
|
456
|
+
catch (err) {
|
|
457
|
+
persistNote = ';状态持久化失败,重启将回到旧嵌入源';
|
|
458
|
+
this.deps.logger.warn(`[memory] 嵌入源状态持久化失败(本次会话仍用新源): ${err instanceof Error ? err.message : String(err)}`);
|
|
459
|
+
}
|
|
407
460
|
this.activeNote = undefined;
|
|
408
461
|
this.applyPhase = 'done';
|
|
409
462
|
this.applyMessage =
|
|
410
|
-
next.source === 'off'
|
|
411
|
-
? '已切换为关键词检索'
|
|
412
|
-
: '切换完成' + pendingNote;
|
|
463
|
+
(next.source === 'off' ? '已切换为关键词检索' : '切换完成' + pendingNote) + persistNote;
|
|
413
464
|
}
|
|
414
465
|
catch (err) {
|
|
415
466
|
this.applyPhase = 'error';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 持久化文件的 schema 版本(单一事实源)。
|
|
3
|
+
*
|
|
4
|
+
* 以前这些字面量散落在各 store(`version: 1` × 15 / `version: 2` × 2),读侧
|
|
5
|
+
* 有的校验有的不校验(findings §3.2)。集中到这里后:
|
|
6
|
+
* - 写侧统一用常量,不会写出"自己都不认识"的版本;
|
|
7
|
+
* - 读侧统一拿常量做 `expectedVersion`,未知版本才有判据(fail-closed 的前提)。
|
|
8
|
+
*
|
|
9
|
+
* 注意:**只收持久化文件的 schema 版本**。契约版本 / 快照版本 / 协议版本等
|
|
10
|
+
* 非持久化用途的字面量不在此列(它们有各自的常量)。
|
|
11
|
+
*/
|
|
12
|
+
/** `state.json`:v1 = 平铺,v2 = 分族(chat/work)。 */
|
|
13
|
+
export declare const STATE_FILE_VERSION = 2;
|
|
14
|
+
/** v1 平铺格式的版本号(迁移判定用;`undefined` 亦按 v1 处理)。 */
|
|
15
|
+
export declare const STATE_FILE_VERSION_LEGACY = 1;
|
|
16
|
+
/** `slots.json`。 */
|
|
17
|
+
export declare const SLOTS_FILE_VERSION = 1;
|
|
18
|
+
/** `session-modes.json`。 */
|
|
19
|
+
export declare const SESSION_MODES_FILE_VERSION = 1;
|
|
20
|
+
/** `occupancy.json`。 */
|
|
21
|
+
export declare const OCCUPANCY_FILE_VERSION = 1;
|
|
22
|
+
/** `recall-dedupe.json`。 */
|
|
23
|
+
export declare const RECALL_DEDUPE_FILE_VERSION = 1;
|
|
24
|
+
/** `pending.json`。 */
|
|
25
|
+
export declare const PENDING_FILE_VERSION = 1;
|
|
26
|
+
/** `reconcile-state.json`。 */
|
|
27
|
+
export declare const RECONCILE_STATE_FILE_VERSION = 1;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 持久化文件的 schema 版本(单一事实源)。
|
|
3
|
+
*
|
|
4
|
+
* 以前这些字面量散落在各 store(`version: 1` × 15 / `version: 2` × 2),读侧
|
|
5
|
+
* 有的校验有的不校验(findings §3.2)。集中到这里后:
|
|
6
|
+
* - 写侧统一用常量,不会写出"自己都不认识"的版本;
|
|
7
|
+
* - 读侧统一拿常量做 `expectedVersion`,未知版本才有判据(fail-closed 的前提)。
|
|
8
|
+
*
|
|
9
|
+
* 注意:**只收持久化文件的 schema 版本**。契约版本 / 快照版本 / 协议版本等
|
|
10
|
+
* 非持久化用途的字面量不在此列(它们有各自的常量)。
|
|
11
|
+
*/
|
|
12
|
+
/** `state.json`:v1 = 平铺,v2 = 分族(chat/work)。 */
|
|
13
|
+
export const STATE_FILE_VERSION = 2;
|
|
14
|
+
/** v1 平铺格式的版本号(迁移判定用;`undefined` 亦按 v1 处理)。 */
|
|
15
|
+
export const STATE_FILE_VERSION_LEGACY = 1;
|
|
16
|
+
/** `slots.json`。 */
|
|
17
|
+
export const SLOTS_FILE_VERSION = 1;
|
|
18
|
+
/** `session-modes.json`。 */
|
|
19
|
+
export const SESSION_MODES_FILE_VERSION = 1;
|
|
20
|
+
/** `occupancy.json`。 */
|
|
21
|
+
export const OCCUPANCY_FILE_VERSION = 1;
|
|
22
|
+
/** `recall-dedupe.json`。 */
|
|
23
|
+
export const RECALL_DEDUPE_FILE_VERSION = 1;
|
|
24
|
+
/** `pending.json`。 */
|
|
25
|
+
export const PENDING_FILE_VERSION = 1;
|
|
26
|
+
/** `reconcile-state.json`。 */
|
|
27
|
+
export const RECONCILE_STATE_FILE_VERSION = 1;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MemoryRecord } from '../types.js';
|
|
1
|
+
import type { MemoryLogger, MemoryRecord } from '../types.js';
|
|
2
2
|
import type { ConflictPair } from './conflicts.js';
|
|
3
3
|
export declare const SNAPSHOT_VERSION = 1;
|
|
4
4
|
/** 快照里的一节:行数 + 内容哈希。哈希是"内容有没有变"的判据,行数看不出来。 */
|
|
@@ -63,6 +63,7 @@ export interface SnapshotSummary {
|
|
|
63
63
|
*/
|
|
64
64
|
export declare function listSnapshots(dataDir: string, opts?: {
|
|
65
65
|
limit?: number;
|
|
66
|
+
logger?: MemoryLogger;
|
|
66
67
|
}): Promise<{
|
|
67
68
|
items: SnapshotSummary[];
|
|
68
69
|
total: number;
|
|
@@ -128,9 +129,22 @@ export interface CreateSnapshotResult {
|
|
|
128
129
|
*/
|
|
129
130
|
export declare function projectConflictsForHash(rows: readonly ConflictPair[]): Array<Record<string, unknown>>;
|
|
130
131
|
export declare function createL1Snapshot(db: SnapshotDbLike, dir: string, reason: string, now?: Date): Promise<CreateSnapshotResult>;
|
|
131
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* 读快照清单。
|
|
134
|
+
*
|
|
135
|
+
* **读侧分类**(文件层加固 T2.7):
|
|
136
|
+
* - 缺失 / 版本不符 → `undefined`(沿用既有"这份快照不可用"的语义);
|
|
137
|
+
* - **损坏 / 不可读 → 抛错**:旧实现把损坏也压成 `undefined`,于是"清单坏了"和
|
|
138
|
+
* "没有清单"在调用方眼里一样——恢复时会选中一个半截快照却报 0 条成功。
|
|
139
|
+
*/
|
|
132
140
|
export declare function readSnapshotManifest(dir: string): Promise<L1SnapshotManifest | undefined>;
|
|
133
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* 读回快照里的记录(与 `createL1Snapshot` 的写入格式必须成对)。
|
|
143
|
+
*
|
|
144
|
+
* **损坏不再返回空数组**:空数组会让"记录文件坏了"表现成"这份快照有 0 条记录",
|
|
145
|
+
* 恢复下去就是静默丢数据。缺文件同样抛错——建快照时正文与清单是成对写入的,
|
|
146
|
+
* 只缺其一说明这份快照本身不完整。
|
|
147
|
+
*/
|
|
134
148
|
export declare function readSnapshotRecords(dir: string): Promise<MemoryRecord[]>;
|
|
135
149
|
/** 快照与当前库的差异。 */
|
|
136
150
|
export interface SnapshotVerification {
|