dsh-prime-memory 0.16.1-beta.1 → 0.16.2
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/assets/img/ui-light.jpg +0 -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/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.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/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/assets/img/ui-light.jpg
CHANGED
|
Binary file
|
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) {
|
|
@@ -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 {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
import { createHash } from 'node:crypto';
|
|
36
36
|
import { readdir } from 'node:fs/promises';
|
|
37
|
-
import { atomicWriteJson,
|
|
37
|
+
import { atomicWriteJson, readJsonStrict } from '../util/io.js';
|
|
38
38
|
export const SNAPSHOT_VERSION = 1;
|
|
39
39
|
/** 快照目录名:`l1-<时间戳>-<原因>`。**时间戳在前**,目录自然按时间排序。 */
|
|
40
40
|
export function snapshotDirName(createdAt, reason) {
|
|
@@ -102,7 +102,16 @@ export async function listSnapshots(dataDir, opts = {}) {
|
|
|
102
102
|
if (!isSnapshotName(name))
|
|
103
103
|
continue;
|
|
104
104
|
const dir = `${root}/${name}`;
|
|
105
|
-
|
|
105
|
+
let manifest;
|
|
106
|
+
try {
|
|
107
|
+
manifest = await readSnapshotManifest(dir);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
// 单个快照清单损坏不该让整个列表拿不到:跳过它,但**必须留诊断**
|
|
111
|
+
// (旧实现把损坏当"没有清单"静默略过,用户永远不知道坏了一份)
|
|
112
|
+
opts.logger?.warn(`[memory] 快照清单损坏,已从列表中排除: ${dir} — ${err instanceof Error ? err.message : String(err)}`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
106
115
|
if (manifest === undefined)
|
|
107
116
|
continue;
|
|
108
117
|
items.push({
|
|
@@ -222,17 +231,39 @@ export async function createL1Snapshot(db, dir, reason, now = new Date()) {
|
|
|
222
231
|
await atomicWriteJson(`${dir}/manifest.json`, manifest);
|
|
223
232
|
return { dir, name: snapshotNameOf(dir), manifest, records };
|
|
224
233
|
}
|
|
225
|
-
/**
|
|
234
|
+
/**
|
|
235
|
+
* 读快照清单。
|
|
236
|
+
*
|
|
237
|
+
* **读侧分类**(文件层加固 T2.7):
|
|
238
|
+
* - 缺失 / 版本不符 → `undefined`(沿用既有"这份快照不可用"的语义);
|
|
239
|
+
* - **损坏 / 不可读 → 抛错**:旧实现把损坏也压成 `undefined`,于是"清单坏了"和
|
|
240
|
+
* "没有清单"在调用方眼里一样——恢复时会选中一个半截快照却报 0 条成功。
|
|
241
|
+
*/
|
|
226
242
|
export async function readSnapshotManifest(dir) {
|
|
227
|
-
const
|
|
228
|
-
|
|
243
|
+
const r = await readJsonStrict(`${dir}/manifest.json`, {
|
|
244
|
+
expectedVersion: SNAPSHOT_VERSION,
|
|
245
|
+
});
|
|
246
|
+
if (r.ok)
|
|
247
|
+
return r.value;
|
|
248
|
+
if (r.reason === 'missing' || r.reason === 'unknown_version')
|
|
229
249
|
return undefined;
|
|
230
|
-
|
|
250
|
+
throw new Error(`快照清单${r.reason === 'corrupt' ? '已损坏' : '不可读'}(${dir})${r.detail ? ` — ${r.detail}` : ''};` +
|
|
251
|
+
`原文件未改动,请人工确认后再恢复`);
|
|
231
252
|
}
|
|
232
|
-
/**
|
|
253
|
+
/**
|
|
254
|
+
* 读回快照里的记录(与 `createL1Snapshot` 的写入格式必须成对)。
|
|
255
|
+
*
|
|
256
|
+
* **损坏不再返回空数组**:空数组会让"记录文件坏了"表现成"这份快照有 0 条记录",
|
|
257
|
+
* 恢复下去就是静默丢数据。缺文件同样抛错——建快照时正文与清单是成对写入的,
|
|
258
|
+
* 只缺其一说明这份快照本身不完整。
|
|
259
|
+
*/
|
|
233
260
|
export async function readSnapshotRecords(dir) {
|
|
234
|
-
const
|
|
235
|
-
|
|
261
|
+
const r = await readJsonStrict(`${dir}/l1-records.json`);
|
|
262
|
+
if (!r.ok) {
|
|
263
|
+
const why = r.reason === 'missing' ? '缺失' : r.reason === 'corrupt' ? '已损坏' : '不可读';
|
|
264
|
+
throw new Error(`快照记录${why}(${dir})${r.detail ? ` — ${r.detail}` : ''}`);
|
|
265
|
+
}
|
|
266
|
+
return Array.isArray(r.value) ? r.value : [];
|
|
236
267
|
}
|
|
237
268
|
/** 比对快照与当前库(**按内容哈希**,不是按行数)。 */
|
|
238
269
|
export async function verifySnapshot(db, dir) {
|
package/dist/store/l1.js
CHANGED
|
@@ -306,7 +306,7 @@ export class L1Store {
|
|
|
306
306
|
// ───────────────────────────────────────────────────────────────────────────
|
|
307
307
|
/** 可用快照列表(按时间倒序;面板/工具据此选一份来恢复)。 */
|
|
308
308
|
listSnapshots(opts = {}) {
|
|
309
|
-
return listSnapshotsIn(this.dataDir, opts);
|
|
309
|
+
return listSnapshotsIn(this.dataDir, { ...opts, logger: this.logger });
|
|
310
310
|
}
|
|
311
311
|
/**
|
|
312
312
|
* 名字 → 真实快照。
|
|
@@ -7,10 +7,20 @@ export declare class OccupancyStore {
|
|
|
7
7
|
private readonly file;
|
|
8
8
|
private readonly entries;
|
|
9
9
|
private persistFailed;
|
|
10
|
+
/** 只读降级原因(undefined = 正常):非空时内存账目照常有效,但停止回写。 */
|
|
11
|
+
private degraded;
|
|
12
|
+
private degradedLogged;
|
|
13
|
+
/** 本进程上次成功写入的磁盘内容(紧凑 JSON);并发冲突判据:磁盘与它不同 = 别人写过。 */
|
|
14
|
+
private lastPersisted;
|
|
10
15
|
/** 串行化持久化写(避免并发原子写撞临时文件名);init 链最前(先载入再落盘,防丢更新)。 */
|
|
11
16
|
private writeChain;
|
|
12
17
|
constructor(dataDir: string, logger?: MemoryLogger | undefined);
|
|
13
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* 载入持久化账目(合并进内存——构造与载入之间发生的 save 不丢);失败降级内存态。
|
|
20
|
+
*
|
|
21
|
+
* **读侧分类**(文件层加固 T2):缺失合法;损坏/不可读 → 告警 + 只读降级(禁写);
|
|
22
|
+
* 未知版本 → **仍按当前形状读取** + 只读降级(本 store 无迁移路径)。
|
|
23
|
+
*/
|
|
14
24
|
private init;
|
|
15
25
|
/**
|
|
16
26
|
* 该会话的持久化账目(热路径同步读;从未注入/已复位返回 null)。
|