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/dist/store/occupancy.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import * as path from 'node:path';
|
|
13
13
|
import { errDetail } from '../util/filelog.js';
|
|
14
|
-
import {
|
|
14
|
+
import { ensureDir, readJsonStrict, rmwJson } from '../util/io.js';
|
|
15
|
+
import { OCCUPANCY_FILE_VERSION } from './file-versions.js';
|
|
15
16
|
/** 会话条目上限(按 updatedAt 淘汰最旧;防文件无限增长)。 */
|
|
16
17
|
export const OCCUPANCY_SESSION_CAP = 200;
|
|
17
18
|
/** 条目过期清理(90 天未更新即丢弃,与 session-modes/dedupe 同款量级)。 */
|
|
@@ -21,6 +22,11 @@ export class OccupancyStore {
|
|
|
21
22
|
file;
|
|
22
23
|
entries = new Map();
|
|
23
24
|
persistFailed = false;
|
|
25
|
+
/** 只读降级原因(undefined = 正常):非空时内存账目照常有效,但停止回写。 */
|
|
26
|
+
degraded;
|
|
27
|
+
degradedLogged = false;
|
|
28
|
+
/** 本进程上次成功写入的磁盘内容(紧凑 JSON);并发冲突判据:磁盘与它不同 = 别人写过。 */
|
|
29
|
+
lastPersisted;
|
|
24
30
|
/** 串行化持久化写(避免并发原子写撞临时文件名);init 链最前(先载入再落盘,防丢更新)。 */
|
|
25
31
|
writeChain;
|
|
26
32
|
constructor(dataDir, logger) {
|
|
@@ -28,9 +34,34 @@ export class OccupancyStore {
|
|
|
28
34
|
this.file = path.join(dataDir, 'occupancy.json');
|
|
29
35
|
this.writeChain = this.init();
|
|
30
36
|
}
|
|
31
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* 载入持久化账目(合并进内存——构造与载入之间发生的 save 不丢);失败降级内存态。
|
|
39
|
+
*
|
|
40
|
+
* **读侧分类**(文件层加固 T2):缺失合法;损坏/不可读 → 告警 + 只读降级(禁写);
|
|
41
|
+
* 未知版本 → **仍按当前形状读取** + 只读降级(本 store 无迁移路径)。
|
|
42
|
+
*/
|
|
32
43
|
async init() {
|
|
33
|
-
const
|
|
44
|
+
const r = await readJsonStrict(this.file, { expectedVersion: OCCUPANCY_FILE_VERSION });
|
|
45
|
+
let data;
|
|
46
|
+
if (r.ok) {
|
|
47
|
+
data = r.value;
|
|
48
|
+
}
|
|
49
|
+
else if (r.reason === 'missing') {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
const lenient = await readJsonStrict(this.file);
|
|
54
|
+
if (!lenient.ok) {
|
|
55
|
+
this.degraded = lenient.reason;
|
|
56
|
+
this.logger?.warn(`[memory] 记忆占用文件${lenient.reason === 'corrupt' ? '损坏' : '不可读'}(${this.file}): ` +
|
|
57
|
+
`按空账目起步且**不回写**,原文件已保留${lenient.detail ? ` — ${lenient.detail}` : ''}`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
data = lenient.value;
|
|
61
|
+
this.degraded = `未知版本(${lenient.version ?? '无 version 字段'})`;
|
|
62
|
+
this.logger?.warn(`[memory] 记忆占用文件版本未知(${lenient.version ?? '无'}):按当前形状读取并进入只读降级(不回写)`);
|
|
63
|
+
}
|
|
64
|
+
this.lastPersisted = JSON.stringify(data);
|
|
34
65
|
if (!data?.sessions || typeof data.sessions !== 'object')
|
|
35
66
|
return;
|
|
36
67
|
const now = Date.now();
|
|
@@ -92,9 +123,25 @@ export class OccupancyStore {
|
|
|
92
123
|
return this.writeChain;
|
|
93
124
|
}
|
|
94
125
|
async persist() {
|
|
126
|
+
if (this.degraded !== undefined) {
|
|
127
|
+
// 只读降级:内存账目照常有效(指示器不看磁盘),但不落盘
|
|
128
|
+
if (!this.degradedLogged) {
|
|
129
|
+
this.degradedLogged = true;
|
|
130
|
+
this.logger?.warn(`[memory] 记忆占用处于只读降级(${this.degraded}),已停止回写(磁盘文件保持不变)`);
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
95
134
|
try {
|
|
96
135
|
await ensureDir(path.dirname(this.file));
|
|
97
|
-
await
|
|
136
|
+
await rmwJson(this.file, async (cur) => {
|
|
137
|
+
const diskRaw = cur.ok ? JSON.stringify(cur.value) : undefined;
|
|
138
|
+
if (this.lastPersisted !== undefined && diskRaw !== undefined && diskRaw !== this.lastPersisted) {
|
|
139
|
+
throw new Error(`记忆占用文件已被其它进程更新,本次写入已取消以避免覆盖;请重试该操作`);
|
|
140
|
+
}
|
|
141
|
+
const next = this.serialize();
|
|
142
|
+
this.lastPersisted = JSON.stringify(next);
|
|
143
|
+
return { next, result: undefined };
|
|
144
|
+
}, { logger: this.logger, purpose: 'occupancy-rmw' });
|
|
98
145
|
this.persistFailed = false;
|
|
99
146
|
}
|
|
100
147
|
catch (err) {
|
|
@@ -129,6 +176,6 @@ export class OccupancyStore {
|
|
|
129
176
|
continue; // 归零条目不落盘
|
|
130
177
|
sessions[sid] = { ...e };
|
|
131
178
|
}
|
|
132
|
-
return { version:
|
|
179
|
+
return { version: OCCUPANCY_FILE_VERSION, sessions };
|
|
133
180
|
}
|
|
134
181
|
}
|
package/dist/store/pending.d.ts
CHANGED
|
@@ -18,10 +18,16 @@ export type WarmupState = Record<ExtractMode, number>;
|
|
|
18
18
|
export declare function freshWarmup(): WarmupState;
|
|
19
19
|
export declare function emptyPending(): PendingBuckets;
|
|
20
20
|
/** 读取缓冲文件:文件缺失/损坏 → 空桶(不抛出——丢了缓冲 L0 事实源仍在)。
|
|
21
|
-
* 旧格式条目(无 sessionId)归 legacy 组;warmup 缺省 = 全新起步。
|
|
21
|
+
* 旧格式条目(无 sessionId)归 legacy 组;warmup 缺省 = 全新起步。
|
|
22
|
+
*
|
|
23
|
+
* **读侧分类**(文件层加固 T2):`missing` 与 `corrupt`/`unreadable` 区分(本函数是
|
|
24
|
+
* 全仓最早做到这点的一处,现在改用统一的 `readJsonStrict`)。
|
|
25
|
+
* **版本**(T3.5):`version` 非 1 时**允许读**(缓冲丢了只是少蒸馏一轮)但置
|
|
26
|
+
* `degraded`——调用方据此**禁止回写**,免得按旧解释覆盖新格式。 */
|
|
22
27
|
export declare function loadPending(file: string, logger?: MemoryLogger): Promise<{
|
|
23
28
|
buckets: PendingBuckets;
|
|
24
29
|
warmup: WarmupState;
|
|
30
|
+
degraded?: string;
|
|
25
31
|
}>;
|
|
26
32
|
/** 按会话分组(会话切片):组按首条时间排序、组内按时间稳定排序——
|
|
27
33
|
* 蒸馏的一切触发都以切片为单位,切片内永不跨会话混装(ADR-0003)。 */
|
package/dist/store/pending.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 与 state.json / session-modes.json 同款原子写;读取宽容(坏行丢弃、坏文件空桶起步)。
|
|
7
7
|
*/
|
|
8
|
-
import * as fs from 'node:fs/promises';
|
|
9
8
|
import * as path from 'node:path';
|
|
10
|
-
import { atomicWriteJson } from '../util/io.js';
|
|
9
|
+
import { atomicWriteJson, readJsonStrict } from '../util/io.js';
|
|
10
|
+
import { PENDING_FILE_VERSION } from './file-versions.js';
|
|
11
11
|
/** 旧格式(无 sessionId 字段)条目加载时归属的会话组。 */
|
|
12
12
|
export const LEGACY_SESSION = 'legacy';
|
|
13
13
|
/** 全新起步:三档都从 1 爬坡(首轮即触发抽取)。 */
|
|
@@ -24,24 +24,29 @@ function isMessage(m) {
|
|
|
24
24
|
return typeof r.id === 'string' && typeof r.content === 'string' && (r.role === 'user' || r.role === 'assistant');
|
|
25
25
|
}
|
|
26
26
|
/** 读取缓冲文件:文件缺失/损坏 → 空桶(不抛出——丢了缓冲 L0 事实源仍在)。
|
|
27
|
-
* 旧格式条目(无 sessionId)归 legacy 组;warmup 缺省 = 全新起步。
|
|
27
|
+
* 旧格式条目(无 sessionId)归 legacy 组;warmup 缺省 = 全新起步。
|
|
28
|
+
*
|
|
29
|
+
* **读侧分类**(文件层加固 T2):`missing` 与 `corrupt`/`unreadable` 区分(本函数是
|
|
30
|
+
* 全仓最早做到这点的一处,现在改用统一的 `readJsonStrict`)。
|
|
31
|
+
* **版本**(T3.5):`version` 非 1 时**允许读**(缓冲丢了只是少蒸馏一轮)但置
|
|
32
|
+
* `degraded`——调用方据此**禁止回写**,免得按旧解释覆盖新格式。 */
|
|
28
33
|
export async function loadPending(file, logger) {
|
|
29
34
|
const out = emptyPending();
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
const r = await readJsonStrict(file);
|
|
36
|
+
if (!r.ok) {
|
|
37
|
+
if (r.reason === 'missing')
|
|
38
|
+
return { buckets: out, warmup: freshWarmup() };
|
|
39
|
+
logger?.warn(`[memory] 未蒸馏缓冲${r.reason === 'corrupt' ? '文件损坏' : '不可读'}(按空桶起步): ${file}${r.detail ? ` — ${r.detail}` : ''}`);
|
|
40
|
+
return { buckets: out, warmup: freshWarmup(), degraded: r.reason };
|
|
35
41
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
return { buckets: out, warmup: freshWarmup() };
|
|
42
|
+
const raw = r.value;
|
|
43
|
+
const degraded = r.version !== undefined && r.version !== PENDING_FILE_VERSION ? `未知版本(${r.version})` : undefined;
|
|
44
|
+
if (degraded !== undefined) {
|
|
45
|
+
logger?.warn(`[memory] 未蒸馏缓冲版本未知(${r.version}):按当前形状读取并进入只读降级(不回写)`);
|
|
41
46
|
}
|
|
42
47
|
if (!raw || typeof raw !== 'object' || !raw.buckets || typeof raw.buckets !== 'object') {
|
|
43
48
|
logger?.warn(`[memory] 未蒸馏缓冲格式不符(缺 buckets,按空桶起步): ${file}`);
|
|
44
|
-
return { buckets: out, warmup: freshWarmup() };
|
|
49
|
+
return { buckets: out, warmup: freshWarmup(), ...(degraded ? { degraded } : {}) };
|
|
45
50
|
}
|
|
46
51
|
let dropped = 0;
|
|
47
52
|
let legacy = 0;
|
|
@@ -74,7 +79,7 @@ export async function loadPending(file, logger) {
|
|
|
74
79
|
if (typeof w === 'number' && Number.isFinite(w) && w >= 0)
|
|
75
80
|
warmup[key] = Math.floor(w);
|
|
76
81
|
}
|
|
77
|
-
return { buckets: out, warmup };
|
|
82
|
+
return { buckets: out, warmup, ...(degraded ? { degraded } : {}) };
|
|
78
83
|
}
|
|
79
84
|
/** 按会话分组(会话切片):组按首条时间排序、组内按时间稳定排序——
|
|
80
85
|
* 蒸馏的一切触发都以切片为单位,切片内永不跨会话混装(ADR-0003)。 */
|
|
@@ -93,7 +98,7 @@ export function groupPendingBySession(messages) {
|
|
|
93
98
|
}
|
|
94
99
|
/** 全量原子落盘(每次蒸馏尝试后调用;桶有上限,量级为百条级)。 */
|
|
95
100
|
export async function savePending(file, buckets, warmup) {
|
|
96
|
-
const payload = { version:
|
|
101
|
+
const payload = { version: PENDING_FILE_VERSION, buckets, ...(warmup ? { warmup } : {}) };
|
|
97
102
|
await atomicWriteJson(file, payload);
|
|
98
103
|
}
|
|
99
104
|
export function pendingPathFor(dataDir) {
|
|
@@ -8,10 +8,20 @@ export declare class RecallDedupeStore {
|
|
|
8
8
|
private readonly file;
|
|
9
9
|
private readonly entries;
|
|
10
10
|
private persistFailed;
|
|
11
|
+
/** 只读降级原因(undefined = 正常):非空时去重集合照常生效,但停止回写。 */
|
|
12
|
+
private degraded;
|
|
13
|
+
private degradedLogged;
|
|
14
|
+
/** 本进程上次成功写入的磁盘内容(紧凑 JSON);并发冲突判据:磁盘与它不同 = 别人写过。 */
|
|
15
|
+
private lastPersisted;
|
|
11
16
|
/** 串行化持久化写(避免并发原子写撞临时文件名);init 链最前(先载入再落盘,防丢更新)。 */
|
|
12
17
|
private writeChain;
|
|
13
18
|
constructor(dataDir: string, logger?: MemoryLogger | undefined);
|
|
14
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* 载入持久化映射(合并进内存——构造与载入之间发生的 mark 不丢);失败降级内存态。
|
|
21
|
+
*
|
|
22
|
+
* **读侧分类**(文件层加固 T2):缺失合法;损坏/不可读 → 告警 + 只读降级(禁写);
|
|
23
|
+
* 未知版本 → **仍按当前形状读取** + 只读降级(本 store 无迁移路径)。
|
|
24
|
+
*/
|
|
15
25
|
private init;
|
|
16
26
|
/** 该会话的已注入集合(热路径同步读;未出现过的会话返回空集合,惰性建条)。 */
|
|
17
27
|
seen(sessionId: string): Set<string>;
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import * as path from 'node:path';
|
|
12
12
|
import { errDetail } from '../util/filelog.js';
|
|
13
|
-
import {
|
|
13
|
+
import { ensureDir, readJsonStrict, rmwJson } from '../util/io.js';
|
|
14
|
+
import { RECALL_DEDUPE_FILE_VERSION } from './file-versions.js';
|
|
14
15
|
/** 会话条目上限(按 updatedAt 淘汰最旧;防文件无限增长)。 */
|
|
15
16
|
export const RECALL_DEDUPE_SESSION_CAP = 200;
|
|
16
17
|
/** 单会话记录 id 上限(按插入序淘汰最旧;Set 迭代序即插入序)。 */
|
|
@@ -22,6 +23,11 @@ export class RecallDedupeStore {
|
|
|
22
23
|
file;
|
|
23
24
|
entries = new Map();
|
|
24
25
|
persistFailed = false;
|
|
26
|
+
/** 只读降级原因(undefined = 正常):非空时去重集合照常生效,但停止回写。 */
|
|
27
|
+
degraded;
|
|
28
|
+
degradedLogged = false;
|
|
29
|
+
/** 本进程上次成功写入的磁盘内容(紧凑 JSON);并发冲突判据:磁盘与它不同 = 别人写过。 */
|
|
30
|
+
lastPersisted;
|
|
25
31
|
/** 串行化持久化写(避免并发原子写撞临时文件名);init 链最前(先载入再落盘,防丢更新)。 */
|
|
26
32
|
writeChain;
|
|
27
33
|
constructor(dataDir, logger) {
|
|
@@ -29,9 +35,34 @@ export class RecallDedupeStore {
|
|
|
29
35
|
this.file = path.join(dataDir, 'recall-dedupe.json');
|
|
30
36
|
this.writeChain = this.init();
|
|
31
37
|
}
|
|
32
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* 载入持久化映射(合并进内存——构造与载入之间发生的 mark 不丢);失败降级内存态。
|
|
40
|
+
*
|
|
41
|
+
* **读侧分类**(文件层加固 T2):缺失合法;损坏/不可读 → 告警 + 只读降级(禁写);
|
|
42
|
+
* 未知版本 → **仍按当前形状读取** + 只读降级(本 store 无迁移路径)。
|
|
43
|
+
*/
|
|
33
44
|
async init() {
|
|
34
|
-
const
|
|
45
|
+
const r = await readJsonStrict(this.file, { expectedVersion: RECALL_DEDUPE_FILE_VERSION });
|
|
46
|
+
let data;
|
|
47
|
+
if (r.ok) {
|
|
48
|
+
data = r.value;
|
|
49
|
+
}
|
|
50
|
+
else if (r.reason === 'missing') {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const lenient = await readJsonStrict(this.file);
|
|
55
|
+
if (!lenient.ok) {
|
|
56
|
+
this.degraded = lenient.reason;
|
|
57
|
+
this.logger?.warn(`[memory] 召回去重文件${lenient.reason === 'corrupt' ? '损坏' : '不可读'}(${this.file}): ` +
|
|
58
|
+
`按空集合起步且**不回写**,原文件已保留${lenient.detail ? ` — ${lenient.detail}` : ''}`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
data = lenient.value;
|
|
62
|
+
this.degraded = `未知版本(${lenient.version ?? '无 version 字段'})`;
|
|
63
|
+
this.logger?.warn(`[memory] 召回去重文件版本未知(${lenient.version ?? '无'}):按当前形状读取并进入只读降级(不回写)`);
|
|
64
|
+
}
|
|
65
|
+
this.lastPersisted = JSON.stringify(data);
|
|
35
66
|
if (!data?.sessions || typeof data.sessions !== 'object')
|
|
36
67
|
return;
|
|
37
68
|
const now = Date.now();
|
|
@@ -95,9 +126,25 @@ export class RecallDedupeStore {
|
|
|
95
126
|
return this.writeChain;
|
|
96
127
|
}
|
|
97
128
|
async persist() {
|
|
129
|
+
if (this.degraded !== undefined) {
|
|
130
|
+
// 只读降级:内存去重集合照常生效(重复注入照样被压),但不落盘
|
|
131
|
+
if (!this.degradedLogged) {
|
|
132
|
+
this.degradedLogged = true;
|
|
133
|
+
this.logger?.warn(`[memory] 召回去重处于只读降级(${this.degraded}),已停止回写(磁盘文件保持不变)`);
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
98
137
|
try {
|
|
99
138
|
await ensureDir(path.dirname(this.file));
|
|
100
|
-
await
|
|
139
|
+
await rmwJson(this.file, async (cur) => {
|
|
140
|
+
const diskRaw = cur.ok ? JSON.stringify(cur.value) : undefined;
|
|
141
|
+
if (this.lastPersisted !== undefined && diskRaw !== undefined && diskRaw !== this.lastPersisted) {
|
|
142
|
+
throw new Error(`召回去重文件已被其它进程更新,本次写入已取消以避免覆盖;请重试该操作`);
|
|
143
|
+
}
|
|
144
|
+
const next = this.serialize();
|
|
145
|
+
this.lastPersisted = JSON.stringify(next);
|
|
146
|
+
return { next, result: undefined };
|
|
147
|
+
}, { logger: this.logger, purpose: 'recall-dedupe-rmw' });
|
|
101
148
|
this.persistFailed = false;
|
|
102
149
|
}
|
|
103
150
|
catch (err) {
|
|
@@ -133,6 +180,6 @@ export class RecallDedupeStore {
|
|
|
133
180
|
continue; // 空集合不落盘
|
|
134
181
|
sessions[sid] = { recordIds: [...e.ids], updatedAt: e.updatedAt };
|
|
135
182
|
}
|
|
136
|
-
return { version:
|
|
183
|
+
return { version: RECALL_DEDUPE_FILE_VERSION, sessions };
|
|
137
184
|
}
|
|
138
185
|
}
|
|
@@ -9,12 +9,23 @@ export declare class SessionModeStore {
|
|
|
9
9
|
private readonly entries;
|
|
10
10
|
private readonly loaded;
|
|
11
11
|
private persistFailed;
|
|
12
|
+
/** 只读降级原因(undefined = 正常):非空时内存态照常生效,但停止回写。 */
|
|
13
|
+
private degraded;
|
|
14
|
+
private degradedLogged;
|
|
15
|
+
/** 本进程上次成功写入的磁盘内容(紧凑 JSON);并发冲突判据:磁盘与它不同 = 别人写过。 */
|
|
16
|
+
private lastPersisted;
|
|
12
17
|
/** 档位切换回调(index.ts 装配 runner 的同步动作:切片落袋/挂起,ADR-0003)。 */
|
|
13
18
|
private onModeChange?;
|
|
14
19
|
/** 串行化持久化写(避免并发原子写撞临时文件名)。 */
|
|
15
20
|
private writeChain;
|
|
16
21
|
constructor(dataDir: string, defaultMode: Extract<MemoryMode, 'auto' | 'chat' | 'work'>, logger?: MemoryLogger | undefined);
|
|
17
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* 载入持久化映射(index.ts 启动时 await;失败降级内存态)。
|
|
24
|
+
*
|
|
25
|
+
* **读侧分类**(文件层加固 T2):缺失合法;损坏/不可读 → 告警 + 只读降级;
|
|
26
|
+
* 未知版本 → **仍按当前形状读取**(本 store 无迁移路径,一律拒载会让档位在
|
|
27
|
+
* 版本回退后全部失效)+ 只读降级(禁写)。
|
|
28
|
+
*/
|
|
18
29
|
init(): Promise<void>;
|
|
19
30
|
get default(): MemoryMode;
|
|
20
31
|
/** 同步读取:未设置过的会话返回默认档。 */
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
import * as path from 'node:path';
|
|
8
8
|
import { WING_CATALOG } from '../types.js';
|
|
9
9
|
import { errDetail } from '../util/filelog.js';
|
|
10
|
-
import {
|
|
10
|
+
import { ensureDir, readJsonStrict, rmwJson } from '../util/io.js';
|
|
11
|
+
import { SESSION_MODES_FILE_VERSION } from './file-versions.js';
|
|
11
12
|
const MODES = ['auto', 'chat', 'work', 'off'];
|
|
12
13
|
const PRUNE_MS = 90 * 24 * 3600_000;
|
|
13
14
|
const MAX_ENTRIES = 500;
|
|
@@ -25,6 +26,11 @@ export class SessionModeStore {
|
|
|
25
26
|
entries = new Map();
|
|
26
27
|
loaded;
|
|
27
28
|
persistFailed = false;
|
|
29
|
+
/** 只读降级原因(undefined = 正常):非空时内存态照常生效,但停止回写。 */
|
|
30
|
+
degraded;
|
|
31
|
+
degradedLogged = false;
|
|
32
|
+
/** 本进程上次成功写入的磁盘内容(紧凑 JSON);并发冲突判据:磁盘与它不同 = 别人写过。 */
|
|
33
|
+
lastPersisted;
|
|
28
34
|
/** 档位切换回调(index.ts 装配 runner 的同步动作:切片落袋/挂起,ADR-0003)。 */
|
|
29
35
|
onModeChange;
|
|
30
36
|
/** 串行化持久化写(避免并发原子写撞临时文件名)。 */
|
|
@@ -35,9 +41,35 @@ export class SessionModeStore {
|
|
|
35
41
|
this.file = path.join(dataDir, 'session-modes.json');
|
|
36
42
|
this.loaded = defaultMode;
|
|
37
43
|
}
|
|
38
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* 载入持久化映射(index.ts 启动时 await;失败降级内存态)。
|
|
46
|
+
*
|
|
47
|
+
* **读侧分类**(文件层加固 T2):缺失合法;损坏/不可读 → 告警 + 只读降级;
|
|
48
|
+
* 未知版本 → **仍按当前形状读取**(本 store 无迁移路径,一律拒载会让档位在
|
|
49
|
+
* 版本回退后全部失效)+ 只读降级(禁写)。
|
|
50
|
+
*/
|
|
39
51
|
async init() {
|
|
40
|
-
const
|
|
52
|
+
const r = await readJsonStrict(this.file, { expectedVersion: SESSION_MODES_FILE_VERSION });
|
|
53
|
+
let data;
|
|
54
|
+
if (r.ok) {
|
|
55
|
+
data = r.value;
|
|
56
|
+
}
|
|
57
|
+
else if (r.reason === 'missing') {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const lenient = await readJsonStrict(this.file);
|
|
62
|
+
if (!lenient.ok) {
|
|
63
|
+
this.degraded = lenient.reason;
|
|
64
|
+
this.logger?.warn(`[memory] 会话档位文件${lenient.reason === 'corrupt' ? '损坏' : '不可读'}(${this.file}): ` +
|
|
65
|
+
`按默认档起步且**不回写**,原文件已保留${lenient.detail ? ` — ${lenient.detail}` : ''}`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
data = lenient.value;
|
|
69
|
+
this.degraded = `未知版本(${lenient.version ?? '无 version 字段'})`;
|
|
70
|
+
this.logger?.warn(`[memory] 会话档位文件版本未知(${lenient.version ?? '无'}):按当前形状读取并进入只读降级(不回写)`);
|
|
71
|
+
}
|
|
72
|
+
this.lastPersisted = JSON.stringify(data);
|
|
41
73
|
if (!data?.sessions || typeof data.sessions !== 'object')
|
|
42
74
|
return;
|
|
43
75
|
const now = Date.now();
|
|
@@ -179,9 +211,25 @@ export class SessionModeStore {
|
|
|
179
211
|
return this.writeChain;
|
|
180
212
|
}
|
|
181
213
|
async persist() {
|
|
214
|
+
if (this.degraded !== undefined) {
|
|
215
|
+
// 只读降级:内存态照常生效(切档在本次会话内有效),但不落盘
|
|
216
|
+
if (!this.degradedLogged) {
|
|
217
|
+
this.degradedLogged = true;
|
|
218
|
+
this.logger?.warn(`[memory] 会话档位处于只读降级(${this.degraded}),已停止回写(磁盘文件保持不变)`);
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
182
222
|
try {
|
|
183
223
|
await ensureDir(path.dirname(this.file));
|
|
184
|
-
await
|
|
224
|
+
await rmwJson(this.file, async (cur) => {
|
|
225
|
+
const diskRaw = cur.ok ? JSON.stringify(cur.value) : undefined;
|
|
226
|
+
if (this.lastPersisted !== undefined && diskRaw !== undefined && diskRaw !== this.lastPersisted) {
|
|
227
|
+
throw new Error(`会话档位文件已被其它进程更新,本次写入已取消以避免覆盖;请重试该操作`);
|
|
228
|
+
}
|
|
229
|
+
const next = this.serialize();
|
|
230
|
+
this.lastPersisted = JSON.stringify(next);
|
|
231
|
+
return { next, result: undefined };
|
|
232
|
+
}, { logger: this.logger, purpose: 'session-modes-rmw' });
|
|
185
233
|
this.persistFailed = false;
|
|
186
234
|
}
|
|
187
235
|
catch (err) {
|
|
@@ -214,6 +262,6 @@ export class SessionModeStore {
|
|
|
214
262
|
const sessions = {};
|
|
215
263
|
for (const [sid, e] of this.entries)
|
|
216
264
|
sessions[sid] = e;
|
|
217
|
-
return { version:
|
|
265
|
+
return { version: SESSION_MODES_FILE_VERSION, sessions };
|
|
218
266
|
}
|
|
219
267
|
}
|
package/dist/store/slots.d.ts
CHANGED
|
@@ -57,11 +57,22 @@ export declare class SlotStore {
|
|
|
57
57
|
/** 活引用:数组本身稳定,元素原地改;外部持有者不会因 mutate 拿到孤儿。 */
|
|
58
58
|
private slots;
|
|
59
59
|
private rev;
|
|
60
|
+
/** 只读降级原因(undefined = 正常):非空时内存照常更新,但停止回写。 */
|
|
61
|
+
private degraded;
|
|
62
|
+
/** 本进程已认可的磁盘 rev(并发冲突判据:磁盘比它新 = 别人写过)。 */
|
|
63
|
+
private baseRev;
|
|
64
|
+
private degradedLogged;
|
|
60
65
|
private readonly maxSlots;
|
|
61
66
|
private readonly maxBodyChars;
|
|
62
67
|
private readonly maxTitleChars;
|
|
63
68
|
constructor(file: string, logger: MemoryLogger, opts?: Partial<SlotStoreOptions>);
|
|
64
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* 读回持久态。**读侧分类**(文件层加固 T2):
|
|
71
|
+
* - 缺失 → 默认空态(首次运行,合法,不告警);
|
|
72
|
+
* - 损坏/不可读 → 默认空态 + **只读降级**(禁写,不覆盖原文件);
|
|
73
|
+
* - 未知版本 → **允许读**(按当前形状宽容解释)+ 只读降级(禁写)。
|
|
74
|
+
* 本 store 无迁移路径,一律拒载会让插件在版本回退时直接不可用。
|
|
75
|
+
*/
|
|
65
76
|
load(): Promise<void>;
|
|
66
77
|
/** 同步读内存,返回副本(不泄漏内部数组)。 */
|
|
67
78
|
list(): Slot[];
|
|
@@ -102,7 +113,14 @@ export declare class SlotStore {
|
|
|
102
113
|
openCount: number;
|
|
103
114
|
slots: SlotView[];
|
|
104
115
|
};
|
|
105
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* 锁内 RMW 写盘 + rev 单调递增(文件层加固 T4.7)。
|
|
118
|
+
*
|
|
119
|
+
* `rev` 在这里多担一个职责:**并发冲突判据**。锁保证"读-算-写"不交错,
|
|
120
|
+
* 但本进程的内存态仍可能是旧的(别人在我上次读之后写过)——比较磁盘 rev 与
|
|
121
|
+
* `baseRev` 能发现这件事,此时**拒绝写入**并让调用方看到失败,而不是把别人的
|
|
122
|
+
* 更新整块盖掉(丢更新)或假装成功。
|
|
123
|
+
*/
|
|
106
124
|
private persist;
|
|
107
125
|
static pathFor(dataDir: string): string;
|
|
108
126
|
}
|
package/dist/store/slots.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import * as path from 'node:path';
|
|
13
13
|
import { randomBytes } from 'node:crypto';
|
|
14
|
-
import {
|
|
14
|
+
import { readJsonStrict, rmwJson, nowIso } from '../util/io.js';
|
|
15
|
+
import { SLOTS_FILE_VERSION } from './file-versions.js';
|
|
15
16
|
/**
|
|
16
17
|
* 槽位类型全集。**单一所有者**:投影片注册的 schema 校验也从这里取枚举,
|
|
17
18
|
* 避免"store 加了新 kind、投影 schema 没跟上"导致 drive 期 parse 抛错。
|
|
@@ -89,6 +90,11 @@ export class SlotStore {
|
|
|
89
90
|
/** 活引用:数组本身稳定,元素原地改;外部持有者不会因 mutate 拿到孤儿。 */
|
|
90
91
|
slots = [];
|
|
91
92
|
rev = 0;
|
|
93
|
+
/** 只读降级原因(undefined = 正常):非空时内存照常更新,但停止回写。 */
|
|
94
|
+
degraded;
|
|
95
|
+
/** 本进程已认可的磁盘 rev(并发冲突判据:磁盘比它新 = 别人写过)。 */
|
|
96
|
+
baseRev = 0;
|
|
97
|
+
degradedLogged = false;
|
|
92
98
|
maxSlots;
|
|
93
99
|
maxBodyChars;
|
|
94
100
|
maxTitleChars;
|
|
@@ -99,14 +105,39 @@ export class SlotStore {
|
|
|
99
105
|
this.maxBodyChars = opts.maxBodyChars ?? 512;
|
|
100
106
|
this.maxTitleChars = opts.maxTitleChars ?? 60;
|
|
101
107
|
}
|
|
102
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* 读回持久态。**读侧分类**(文件层加固 T2):
|
|
110
|
+
* - 缺失 → 默认空态(首次运行,合法,不告警);
|
|
111
|
+
* - 损坏/不可读 → 默认空态 + **只读降级**(禁写,不覆盖原文件);
|
|
112
|
+
* - 未知版本 → **允许读**(按当前形状宽容解释)+ 只读降级(禁写)。
|
|
113
|
+
* 本 store 无迁移路径,一律拒载会让插件在版本回退时直接不可用。
|
|
114
|
+
*/
|
|
103
115
|
async load() {
|
|
104
|
-
const
|
|
105
|
-
|
|
116
|
+
const r = await readJsonStrict(this.file, { expectedVersion: SLOTS_FILE_VERSION });
|
|
117
|
+
let raw;
|
|
118
|
+
if (r.ok) {
|
|
119
|
+
raw = r.value;
|
|
120
|
+
}
|
|
121
|
+
else if (r.reason === 'missing') {
|
|
106
122
|
return;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
// unknown_version 仍要读出内容(可用性优先),只是禁止回写
|
|
126
|
+
const lenient = await readJsonStrict(this.file);
|
|
127
|
+
if (!lenient.ok) {
|
|
128
|
+
this.degraded = lenient.reason;
|
|
129
|
+
this.logger.warn(`[memory] 槽位状态文件${lenient.reason === 'corrupt' ? '损坏' : '不可读'}(${this.file}): ` +
|
|
130
|
+
`按空态起步且**不回写**,原文件已保留${lenient.detail ? ` — ${lenient.detail}` : ''}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
raw = lenient.value;
|
|
134
|
+
this.degraded = `未知版本(${lenient.version ?? '无 version 字段'})`;
|
|
135
|
+
this.logger.warn(`[memory] 槽位状态文件版本未知(${lenient.version ?? '无 version 字段'}):按当前形状读取并进入只读降级(不回写),避免用旧解释覆盖新文件`);
|
|
136
|
+
}
|
|
107
137
|
const { slots, rev } = fromFile(raw);
|
|
108
138
|
this.slots = slots;
|
|
109
139
|
this.rev = rev;
|
|
140
|
+
this.baseRev = rev;
|
|
110
141
|
}
|
|
111
142
|
/** 同步读内存,返回副本(不泄漏内部数组)。 */
|
|
112
143
|
list() {
|
|
@@ -235,12 +266,54 @@ export class SlotStore {
|
|
|
235
266
|
})),
|
|
236
267
|
};
|
|
237
268
|
}
|
|
238
|
-
/**
|
|
269
|
+
/**
|
|
270
|
+
* 锁内 RMW 写盘 + rev 单调递增(文件层加固 T4.7)。
|
|
271
|
+
*
|
|
272
|
+
* `rev` 在这里多担一个职责:**并发冲突判据**。锁保证"读-算-写"不交错,
|
|
273
|
+
* 但本进程的内存态仍可能是旧的(别人在我上次读之后写过)——比较磁盘 rev 与
|
|
274
|
+
* `baseRev` 能发现这件事,此时**拒绝写入**并让调用方看到失败,而不是把别人的
|
|
275
|
+
* 更新整块盖掉(丢更新)或假装成功。
|
|
276
|
+
*/
|
|
239
277
|
async persist() {
|
|
240
|
-
this.
|
|
241
|
-
|
|
278
|
+
if (this.degraded !== undefined) {
|
|
279
|
+
// 只读降级:内存态照常更新(工具语义不受影响),但绝不落盘——
|
|
280
|
+
// 此刻内存里是"按当前形状解释"的结果,写回去会把磁盘上的新格式洗成旧格式。
|
|
281
|
+
if (!this.degradedLogged) {
|
|
282
|
+
this.degradedLogged = true;
|
|
283
|
+
this.logger.warn(`[memory] 槽位处于只读降级(${this.degraded}),已停止回写(磁盘文件保持不变)`);
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
242
287
|
try {
|
|
243
|
-
await
|
|
288
|
+
await rmwJson(this.file, async (cur) => {
|
|
289
|
+
const disk = cur.ok ? cur.value : undefined;
|
|
290
|
+
const diskRev = disk && typeof disk.rev === 'number' && disk.rev >= 0 ? disk.rev : 0;
|
|
291
|
+
if (diskRev > this.baseRev) {
|
|
292
|
+
// 别人写过。槽位是**独立条目集合**(相互无引用、无计数),合并没有歧义:
|
|
293
|
+
// 以磁盘为底、本进程的同 id 条目覆盖之(内存态更新),于是两边的新增都留下。
|
|
294
|
+
// 直接拒写虽然"可观测",但会让并发场景下的新增静默消失——能合并就别丢。
|
|
295
|
+
const merged = new Map();
|
|
296
|
+
for (const s of (disk?.slots ?? []))
|
|
297
|
+
merged.set(s.id, s);
|
|
298
|
+
for (const s of this.slots)
|
|
299
|
+
merged.set(s.id, s); // 本进程优先
|
|
300
|
+
if (merged.size > this.maxSlots) {
|
|
301
|
+
this.logger.warn(`[memory] 槽位合并后超过上限(${merged.size} > ${this.maxSlots}),保留本进程优先的前 ${this.maxSlots} 条`);
|
|
302
|
+
const keep = new Set(this.slots.map((s) => s.id));
|
|
303
|
+
const trimmed = [...merged.values()].filter((s) => keep.has(s.id)).slice(0, this.maxSlots);
|
|
304
|
+
merged.clear();
|
|
305
|
+
for (const s of trimmed)
|
|
306
|
+
merged.set(s.id, s);
|
|
307
|
+
}
|
|
308
|
+
this.slots = [...merged.values()];
|
|
309
|
+
this.rev = diskRev + 1;
|
|
310
|
+
this.baseRev = this.rev;
|
|
311
|
+
return { next: { version: SLOTS_FILE_VERSION, rev: this.rev, slots: this.slots }, result: undefined };
|
|
312
|
+
}
|
|
313
|
+
this.rev = Math.max(this.rev, diskRev) + 1;
|
|
314
|
+
this.baseRev = this.rev;
|
|
315
|
+
return { next: { version: SLOTS_FILE_VERSION, rev: this.rev, slots: this.slots }, result: undefined };
|
|
316
|
+
}, { logger: this.logger, purpose: 'slots-rmw' });
|
|
244
317
|
}
|
|
245
318
|
catch (err) {
|
|
246
319
|
// 写盘失败只告警,不阻断调用方(降级:内存态已更新,下次写会重试)
|
package/dist/store/state.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MemoryFamily } from '../types.js';
|
|
1
|
+
import type { MemoryFamily, MemoryLogger } from '../types.js';
|
|
2
2
|
export interface MemoryState {
|
|
3
3
|
/** 上次 L1 抽取时间(epoch ms)。 */
|
|
4
4
|
lastExtractAt: number;
|
|
@@ -22,9 +22,22 @@ export interface MemoryState {
|
|
|
22
22
|
export declare function defaultState(): MemoryState;
|
|
23
23
|
export declare class StateStore {
|
|
24
24
|
private readonly file;
|
|
25
|
+
private readonly logger?;
|
|
25
26
|
private buckets;
|
|
26
27
|
private migrated;
|
|
27
|
-
|
|
28
|
+
/** 只读降级原因(非空 = 拒绝加载且禁止回写)。 */
|
|
29
|
+
private degradedReason;
|
|
30
|
+
private saveBlockedLogged;
|
|
31
|
+
/** 本进程上次成功写入的磁盘内容(紧凑 JSON);并发冲突判据。 */
|
|
32
|
+
private lastPersisted;
|
|
33
|
+
constructor(file: string, logger?: MemoryLogger | undefined);
|
|
34
|
+
/**
|
|
35
|
+
* 只读降级原因(undefined = 正常)。
|
|
36
|
+
*
|
|
37
|
+
* 降级时 `forFamily` 仍返回默认空态——记忆功能继续可用(只是丢 checkpoint),
|
|
38
|
+
* 但**绝不回写**,以免把用户磁盘上那份读不懂/已损坏的文件洗掉。
|
|
39
|
+
*/
|
|
40
|
+
get degraded(): string | undefined;
|
|
28
41
|
load(): Promise<void>;
|
|
29
42
|
/** v1 → v2 迁移发生时为 true(调用方记日志/落盘)。 */
|
|
30
43
|
get didMigrate(): boolean;
|