dsh-prime-memory 0.22.1 → 0.22.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.
Files changed (62) hide show
  1. package/dist/client.js +1581 -1153
  2. package/dist/config.d.ts +149 -0
  3. package/dist/config.js +35 -0
  4. package/dist/conflict-service.d.ts +8 -0
  5. package/dist/conflict-service.js +19 -0
  6. package/dist/contract.d.ts +131 -0
  7. package/dist/domain-gate.d.ts +8 -0
  8. package/dist/domain-gate.js +30 -0
  9. package/dist/graph/constraints.d.ts +4 -2
  10. package/dist/graph/constraints.js +4 -2
  11. package/dist/hooks/recall.d.ts +2 -0
  12. package/dist/hooks/recall.js +20 -1
  13. package/dist/hooks/skill-recall.d.ts +25 -0
  14. package/dist/hooks/skill-recall.js +72 -0
  15. package/dist/index.d.ts +98 -0
  16. package/dist/index.js +26 -1
  17. package/dist/memport/format.d.ts +70 -0
  18. package/dist/memport/format.js +305 -0
  19. package/dist/memport/prefilter.d.ts +62 -0
  20. package/dist/memport/prefilter.js +132 -0
  21. package/dist/memport/scan.d.ts +13 -0
  22. package/dist/memport/scan.js +100 -0
  23. package/dist/metadata-validators.d.ts +22 -0
  24. package/dist/metadata-validators.js +45 -0
  25. package/dist/pipeline/l1.js +16 -2
  26. package/dist/pipeline/ruminate.d.ts +3 -0
  27. package/dist/pipeline/ruminate.js +50 -1
  28. package/dist/pipeline/slot-migrate.d.ts +45 -0
  29. package/dist/pipeline/slot-migrate.js +45 -0
  30. package/dist/projection/recall.d.ts +74 -0
  31. package/dist/projection/recall.js +148 -0
  32. package/dist/projection/slots.d.ts +5 -0
  33. package/dist/prompts/graph-projection.js +3 -0
  34. package/dist/prompts/l1-extraction.d.ts +4 -1
  35. package/dist/prompts/l1-extraction.js +27 -4
  36. package/dist/settings.js +3 -0
  37. package/dist/skills/crystallize.d.ts +41 -0
  38. package/dist/skills/crystallize.js +75 -0
  39. package/dist/stats.d.ts +18 -1
  40. package/dist/stats.js +184 -5
  41. package/dist/store/file-versions.d.ts +2 -0
  42. package/dist/store/file-versions.js +2 -0
  43. package/dist/store/l1.d.ts +2 -0
  44. package/dist/store/l1.js +4 -0
  45. package/dist/store/memory-sources.d.ts +59 -0
  46. package/dist/store/memory-sources.js +214 -0
  47. package/dist/store/session-recalled.d.ts +25 -0
  48. package/dist/store/session-recalled.js +163 -0
  49. package/dist/store/skill-store.d.ts +99 -0
  50. package/dist/store/skill-store.js +330 -0
  51. package/dist/store/sqlite.d.ts +5 -0
  52. package/dist/store/sqlite.js +30 -4
  53. package/dist/tools/index.d.ts +0 -13
  54. package/dist/tools/index.js +58 -7
  55. package/dist/tools/skills.d.ts +13 -0
  56. package/dist/tools/skills.js +130 -0
  57. package/dist/types.d.ts +6 -0
  58. package/dist/util/maturity.d.ts +27 -0
  59. package/dist/util/maturity.js +26 -0
  60. package/dist/wing-backfill.js +7 -4
  61. package/dsh.plugin.json +1 -1
  62. package/package.json +1 -1
@@ -21,6 +21,8 @@ export const SESSION_MODES_FILE_VERSION = 1;
21
21
  export const OCCUPANCY_FILE_VERSION = 1;
22
22
  /** `recall-dedupe.json`。 */
23
23
  export const RECALL_DEDUPE_FILE_VERSION = 1;
24
+ /** `session-recalled.json`(G-live 活召回集,持久化以令旧会话重开可读)。 */
25
+ export const SESSION_RECALLED_FILE_VERSION = 1;
24
26
  /** `pending.json`。 */
25
27
  export const PENDING_FILE_VERSION = 1;
26
28
  /** `reconcile-state.json`。 */
@@ -142,6 +142,8 @@ export declare class L1Store {
142
142
  };
143
143
  /** 按 id 精确取记录(去重决策的版本号查询用,避免全表扫描)。 */
144
144
  getByIds(ids: string[]): MemoryRecord[];
145
+ /** 外部导入幂等 pre-check(轨3-P0):ext_key 命中已存在记录则返回其 id,否则 undefined。 */
146
+ findIdByExtKey(extKey: string): string | undefined;
145
147
  /**
146
148
  * §B 决策凭证落盘(L1Store 的薄缝)。
147
149
  * 刻意放在 store 上:`runExtraction` 已经持有 L1Store,凭证写入因此无需新增
package/dist/store/l1.js CHANGED
@@ -229,6 +229,10 @@ export class L1Store {
229
229
  getByIds(ids) {
230
230
  return this.db.getL1ByIds(ids);
231
231
  }
232
+ /** 外部导入幂等 pre-check(轨3-P0):ext_key 命中已存在记录则返回其 id,否则 undefined。 */
233
+ findIdByExtKey(extKey) {
234
+ return this.db.findRecordIdByExtKey(extKey);
235
+ }
232
236
  /**
233
237
  * §B 决策凭证落盘(L1Store 的薄缝)。
234
238
  * 刻意放在 store 上:`runExtraction` 已经持有 L1Store,凭证写入因此无需新增
@@ -0,0 +1,59 @@
1
+ import type { MemoryLogger } from '../types.js';
2
+ /** sidecar 版本常量(file-versions 同款集中纪律)。 */
3
+ export declare const MEMORY_SOURCES_FILE_VERSION: 1;
4
+ /** 已知外部 agent 来源类别(与 memport foreign-agent.md 对齐);'file' = 任意路径。 */
5
+ export declare const SOURCE_KINDS: readonly ["trae", "qoder", "codex", "claude", "workbuddy", "golutra", "cursor", "file"];
6
+ export type SourceKind = (typeof SOURCE_KINDS)[number];
7
+ export interface MemorySourceEntry {
8
+ /** 稳定 id(小写字母数字连字符);扫描/导入按此归类、支持按来源撤销。 */
9
+ id: string;
10
+ kind: SourceKind;
11
+ /** 路径指针(绝对路径/目录),正文永不复制进来。 */
12
+ path: string;
13
+ label?: string;
14
+ /** 最近一次扫描时间(ISO);未扫为空。 */
15
+ lastScanAt: string;
16
+ /** 最近一次导入时间(ISO);未导入为空。 */
17
+ lastImportAt: string;
18
+ /** 用户临时停用(保留条目但不参与自动扫描;≠ 删除)。 */
19
+ disabled: boolean;
20
+ status: 'active' | 'retired';
21
+ createdAt: string;
22
+ updatedAt: string;
23
+ }
24
+ export type MemorySourceInput = Pick<MemorySourceEntry, 'id' | 'kind' | 'path'> & Partial<Pick<MemorySourceEntry, 'label'>>;
25
+ export declare class MemorySourceStore {
26
+ private readonly logger?;
27
+ private readonly file;
28
+ private entries;
29
+ private rev;
30
+ private loaded;
31
+ private degraded;
32
+ private degradedLogged;
33
+ private writeChain;
34
+ constructor(dataDir: string, logger?: MemoryLogger | undefined);
35
+ /** 载入(启动 await 一次)。缺失 = 合法空表;损坏 = 只读降级(回退"无来源")。 */
36
+ init(): Promise<void>;
37
+ private ensureLoaded;
38
+ /** 全量条目(含 retired;副本返回,活引用纪律)。 */
39
+ list(): MemorySourceEntry[];
40
+ /** active 且未停用的来源(扫描/导入遍历用)。 */
41
+ listEnabled(): MemorySourceEntry[];
42
+ byId(id: string): MemorySourceEntry | undefined;
43
+ /** 登记来源(幂等:同 id 已存在则原地补全 kind/path/label,不新建)。上限防刷。 */
44
+ register(input: MemorySourceInput): Promise<{
45
+ entry: MemorySourceEntry;
46
+ created: boolean;
47
+ }>;
48
+ /** 记一次扫描时间。 */
49
+ markScanned(id: string, at: string): Promise<boolean>;
50
+ /** 记一次导入时间。 */
51
+ markImported(id: string, at: string): Promise<boolean>;
52
+ /** 启用/停用(停用保留条目、只是不参与自动扫描)。 */
53
+ setDisabled(id: string, disabled: boolean): Promise<boolean>;
54
+ /** 退役/恢复(status 迁移)。 */
55
+ setStatus(id: string, status: 'active' | 'retired'): Promise<boolean>;
56
+ private patch;
57
+ /** 写穿持久化(串行链;失败降级内存态并告警一次,绝不抛出阻塞调用方)。 */
58
+ private persist;
59
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * 外部记忆来源注册表(轨3 Connect 路径的一等实体)。
3
+ *
4
+ * 动机:此前"其他 agent → 记忆库"的取内容路径只装配到 `memory_import` 这最后一寸,
5
+ * 前面十一寸(发现来源、记录来源、按来源管理)全外包给 skill 文本 + 用户手填路径。
6
+ * 本注册表把"来源"升为可持久、可枚举、可按来源撤销的一等实体。
7
+ *
8
+ * **路径指针不复制**(Connect 语义,援引 `slots.refs` 先例):每条只存 `path` + 摘要,
9
+ * 正文**永不落进插件目录**——既是"移动房子家具先消毒"的卫生边界,也让带 ext_key 的
10
+ * 外部记忆"来源仍在、可重放"(可重建性显式化)。
11
+ *
12
+ * 存储:rooms-registry 同款 sidecar(`memory-sources.json`),全量载入内存 + 写穿原子持久化,
13
+ * `rev` 乐观计数。文件缺失 = 合法空表;损坏/不可读 → 告警 + 只读降级(内存态照常、停止回写),
14
+ * **绝不阻塞扫描/导入/面板**(降级即回退"无注册来源",非破坏性)。上限防刷。
15
+ */
16
+ import * as path from 'node:path';
17
+ import { readJsonStrict, atomicWriteText, ensureDir } from '../util/io.js';
18
+ /** sidecar 版本常量(file-versions 同款集中纪律)。 */
19
+ export const MEMORY_SOURCES_FILE_VERSION = 1;
20
+ /** 已知外部 agent 来源类别(与 memport foreign-agent.md 对齐);'file' = 任意路径。 */
21
+ export const SOURCE_KINDS = ['trae', 'qoder', 'codex', 'claude', 'workbuddy', 'golutra', 'cursor', 'file'];
22
+ /** 来源上限(防刷;一个工作区通常 <20 个外部工具记忆落点)。 */
23
+ const SOURCES_CAP = 100;
24
+ const PATH_MAX = 500;
25
+ const LABEL_MAX = 80;
26
+ function isSourceId(s) {
27
+ return /^[a-z0-9][a-z0-9-]{0,63}$/.test(s);
28
+ }
29
+ function normKind(k) {
30
+ return SOURCE_KINDS.includes(String(k)) ? k : 'file';
31
+ }
32
+ export class MemorySourceStore {
33
+ logger;
34
+ file;
35
+ entries = [];
36
+ rev = 0;
37
+ loaded = false;
38
+ degraded;
39
+ degradedLogged = false;
40
+ writeChain = Promise.resolve();
41
+ constructor(dataDir, logger) {
42
+ this.logger = logger;
43
+ this.file = path.join(dataDir, 'memory-sources.json');
44
+ }
45
+ /** 载入(启动 await 一次)。缺失 = 合法空表;损坏 = 只读降级(回退"无来源")。 */
46
+ async init() {
47
+ await ensureDir(path.dirname(this.file));
48
+ const read = await readJsonStrict(this.file);
49
+ if (!read.ok) {
50
+ if (read.reason !== 'missing') {
51
+ this.degraded = `${read.reason}${'detail' in read && read.detail ? `: ${read.detail}` : ''}`;
52
+ this.logger?.warn(`[memory] 外部来源注册表只读降级: ${this.degraded}`);
53
+ }
54
+ this.loaded = true;
55
+ return;
56
+ }
57
+ if (read.value.version !== MEMORY_SOURCES_FILE_VERSION) {
58
+ this.logger?.warn(`[memory] 外部来源注册表版本 ${String(read.value.version)} 未知,按当前形状读取`);
59
+ }
60
+ const sources = Array.isArray(read.value.sources) ? read.value.sources : [];
61
+ for (const s of sources) {
62
+ if (!s || typeof s.id !== 'string' || !isSourceId(s.id))
63
+ continue; // 非法 id 丢弃
64
+ if (typeof s.path !== 'string' || !s.path)
65
+ continue; // 无路径指针的来源无意义,丢弃
66
+ this.entries.push({
67
+ id: s.id,
68
+ kind: normKind(s.kind),
69
+ path: s.path.slice(0, PATH_MAX),
70
+ label: typeof s.label === 'string' ? s.label : undefined,
71
+ lastScanAt: typeof s.lastScanAt === 'string' ? s.lastScanAt : '',
72
+ lastImportAt: typeof s.lastImportAt === 'string' ? s.lastImportAt : '',
73
+ disabled: s.disabled === true,
74
+ status: s.status === 'retired' ? 'retired' : 'active',
75
+ createdAt: typeof s.createdAt === 'string' ? s.createdAt : '',
76
+ updatedAt: typeof s.updatedAt === 'string' ? s.updatedAt : '',
77
+ });
78
+ }
79
+ this.rev = typeof read.value.rev === 'number' ? read.value.rev : 0;
80
+ this.loaded = true;
81
+ }
82
+ ensureLoaded() {
83
+ if (!this.loaded)
84
+ throw new Error('外部来源注册表未初始化(先 await init())');
85
+ }
86
+ /** 全量条目(含 retired;副本返回,活引用纪律)。 */
87
+ list() {
88
+ this.ensureLoaded();
89
+ return this.entries.map((e) => ({ ...e }));
90
+ }
91
+ /** active 且未停用的来源(扫描/导入遍历用)。 */
92
+ listEnabled() {
93
+ return this.list().filter((e) => e.status === 'active' && !e.disabled);
94
+ }
95
+ byId(id) {
96
+ this.ensureLoaded();
97
+ const found = this.entries.find((e) => e.id === id);
98
+ return found ? { ...found } : undefined;
99
+ }
100
+ /** 登记来源(幂等:同 id 已存在则原地补全 kind/path/label,不新建)。上限防刷。 */
101
+ async register(input) {
102
+ this.ensureLoaded();
103
+ const id = String(input.id ?? '').trim().toLowerCase();
104
+ if (!isSourceId(id))
105
+ throw new Error(`非法来源 id: ${String(input.id)}(小写字母数字连字符,≤64)`);
106
+ const p = String(input.path ?? '').trim();
107
+ if (!p)
108
+ throw new Error('来源 path 不能为空(路径指针不复制,但必须指向真实落点)');
109
+ if (p.length > PATH_MAX)
110
+ throw new Error(`来源 path 过长(>${PATH_MAX})`);
111
+ const found = this.entries.find((e) => e.id === id);
112
+ if (found) {
113
+ let changed = false;
114
+ const kind = normKind(input.kind);
115
+ if (found.kind !== kind) {
116
+ found.kind = kind;
117
+ changed = true;
118
+ }
119
+ if (found.path !== p) {
120
+ found.path = p;
121
+ changed = true;
122
+ }
123
+ if (typeof input.label === 'string' && input.label.trim() && found.label !== input.label.trim().slice(0, LABEL_MAX)) {
124
+ found.label = input.label.trim().slice(0, LABEL_MAX);
125
+ changed = true;
126
+ }
127
+ if (found.status === 'retired') {
128
+ found.status = 'active';
129
+ changed = true;
130
+ }
131
+ if (changed) {
132
+ found.updatedAt = new Date().toISOString();
133
+ this.rev++;
134
+ await this.persist();
135
+ }
136
+ return { entry: { ...found }, created: false };
137
+ }
138
+ if (this.entries.length >= SOURCES_CAP)
139
+ throw new Error(`外部来源已达上限(${SOURCES_CAP}),请先退役部分来源`);
140
+ const now = new Date().toISOString();
141
+ const entry = {
142
+ id,
143
+ kind: normKind(input.kind),
144
+ path: p.slice(0, PATH_MAX),
145
+ label: typeof input.label === 'string' && input.label.trim() ? input.label.trim().slice(0, LABEL_MAX) : undefined,
146
+ lastScanAt: '',
147
+ lastImportAt: '',
148
+ disabled: false,
149
+ status: 'active',
150
+ createdAt: now,
151
+ updatedAt: now,
152
+ };
153
+ this.entries.push(entry);
154
+ this.rev++;
155
+ await this.persist();
156
+ return { entry: { ...entry }, created: true };
157
+ }
158
+ /** 记一次扫描时间。 */
159
+ async markScanned(id, at) {
160
+ return this.patch(id, (e) => {
161
+ e.lastScanAt = at;
162
+ });
163
+ }
164
+ /** 记一次导入时间。 */
165
+ async markImported(id, at) {
166
+ return this.patch(id, (e) => {
167
+ e.lastImportAt = at;
168
+ });
169
+ }
170
+ /** 启用/停用(停用保留条目、只是不参与自动扫描)。 */
171
+ async setDisabled(id, disabled) {
172
+ return this.patch(id, (e) => {
173
+ e.disabled = disabled;
174
+ });
175
+ }
176
+ /** 退役/恢复(status 迁移)。 */
177
+ async setStatus(id, status) {
178
+ return this.patch(id, (e) => {
179
+ e.status = status;
180
+ });
181
+ }
182
+ async patch(id, mut) {
183
+ this.ensureLoaded();
184
+ const found = this.entries.find((e) => e.id === id);
185
+ if (!found)
186
+ return false;
187
+ mut(found);
188
+ found.updatedAt = new Date().toISOString();
189
+ this.rev++;
190
+ await this.persist();
191
+ return true;
192
+ }
193
+ /** 写穿持久化(串行链;失败降级内存态并告警一次,绝不抛出阻塞调用方)。 */
194
+ async persist() {
195
+ if (this.degraded)
196
+ return;
197
+ this.writeChain = this.writeChain.then(async () => {
198
+ const data = { version: MEMORY_SOURCES_FILE_VERSION, rev: this.rev + 1, sources: this.entries };
199
+ try {
200
+ await ensureDir(path.dirname(this.file));
201
+ await atomicWriteText(this.file, JSON.stringify(data, null, 2));
202
+ this.rev = data.rev;
203
+ }
204
+ catch (err) {
205
+ this.degraded = err instanceof Error ? err.message : String(err);
206
+ if (!this.degradedLogged) {
207
+ this.degradedLogged = true;
208
+ this.logger?.warn(`[memory] 外部来源注册表写入失败,只读降级: ${this.degraded}`);
209
+ }
210
+ }
211
+ });
212
+ await this.writeChain;
213
+ }
214
+ }
@@ -0,0 +1,25 @@
1
+ import type { MemoryLogger } from '../types.js';
2
+ export declare class SessionRecalledStore {
3
+ private readonly logger?;
4
+ private readonly file;
5
+ private readonly entries;
6
+ private degraded;
7
+ private degradedLogged;
8
+ private lastPersisted;
9
+ private writeChain;
10
+ /** 脏检序号:每次真实变更 +1,投影 apply 据此 O(1) 判断是否重建。 */
11
+ private rev;
12
+ constructor(dataDir: string, logger?: MemoryLogger | undefined);
13
+ revision(): number;
14
+ private init;
15
+ /** 记入本 sessionKey 本轮实际注入的记录 id(写穿;插入序淘汰最旧)。 */
16
+ add(sessionKey: string, recordIds: readonly string[]): void;
17
+ list(sessionKey: string): string[];
18
+ /** 压缩/清空:注入内容已离开上下文,清该会话活召回集(写穿)。 */
19
+ reset(sessionKey: string): void;
20
+ size(): number;
21
+ /** 等待在途持久化写完成(测试/停机)。 */
22
+ flush(): Promise<void>;
23
+ private persist;
24
+ private serialize;
25
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * 会话"活召回集"存储(G-live):sessionKey → 本会话已注入、尚未被压缩覆盖的 L1 记录 id。
3
+ *
4
+ * 语义(对齐"当前会话召回后对 session 有影响的条目",且**旧会话重开也有效**):
5
+ * - 每次召回**实际注入**(recall.ts 注入成功点)按 **sessionKey**(=agent.session?.id ?? agent.id,
6
+ * 与 pill 的 sessionId、投影 header.id 同源)记入;
7
+ * - 上下文压缩/清空(agent/created source=compact|clear)按 sessionKey **reset**;
8
+ * - **写穿持久化到 `session-recalled.json`**(独立 sidecar,与 recall-dedupe 同款:串行原子写 +
9
+ * 未知版本/损坏只读降级、失败绝不抛进召回热路径)→ 重启/旧会话重开时 `init()` 载回,卡不空。
10
+ *
11
+ * 供只读端点 `dsh-memory/session-recalled` 消费(client 浮层 SessionRecalledArea 展示)。
12
+ * 不经 memorySlots 投影:那条路会把召回渲染成常驻槽位注册表,与召回的非常驻语义错位。
13
+ */
14
+ import * as path from 'node:path';
15
+ import { errDetail } from '../util/filelog.js';
16
+ import { ensureDir, readJsonStrict, rmwJson } from '../util/io.js';
17
+ import { SESSION_RECALLED_FILE_VERSION } from './file-versions.js';
18
+ import { RECALL_DEDUPE_IDS_CAP, RECALL_DEDUPE_SESSION_CAP } from './recall-dedupe.js';
19
+ /** 条目过期清理(90 天,与 recall-dedupe 同量级)。 */
20
+ const PRUNE_MS = 90 * 24 * 3600_000;
21
+ export class SessionRecalledStore {
22
+ logger;
23
+ file;
24
+ entries = new Map();
25
+ degraded;
26
+ degradedLogged = false;
27
+ lastPersisted;
28
+ writeChain;
29
+ /** 脏检序号:每次真实变更 +1,投影 apply 据此 O(1) 判断是否重建。 */
30
+ rev = 0;
31
+ constructor(dataDir, logger) {
32
+ this.logger = logger;
33
+ this.file = path.join(dataDir, 'session-recalled.json');
34
+ this.writeChain = this.init();
35
+ }
36
+ revision() {
37
+ return this.rev;
38
+ }
39
+ async init() {
40
+ const r = await readJsonStrict(this.file, { expectedVersion: SESSION_RECALLED_FILE_VERSION });
41
+ let data;
42
+ if (r.ok) {
43
+ data = r.value;
44
+ }
45
+ else if (r.reason === 'missing') {
46
+ return;
47
+ }
48
+ else {
49
+ const lenient = await readJsonStrict(this.file);
50
+ if (!lenient.ok) {
51
+ this.degraded = lenient.reason;
52
+ this.logger?.warn(`[memory] 活召回集文件${lenient.reason === 'corrupt' ? '损坏' : '不可读'}(${this.file}):按空起步且不回写`);
53
+ return;
54
+ }
55
+ data = lenient.value;
56
+ this.degraded = `未知版本(${lenient.version ?? '无 version 字段'})`;
57
+ this.logger?.warn(`[memory] 活召回集文件版本未知(${lenient.version ?? '无'}):按当前形状读取并只读降级(不回写)`);
58
+ }
59
+ this.lastPersisted = JSON.stringify(data);
60
+ if (!data?.sessions || typeof data.sessions !== 'object')
61
+ return;
62
+ const now = Date.now();
63
+ for (const [sid, entry] of Object.entries(data.sessions)) {
64
+ if (!Array.isArray(entry?.recordIds))
65
+ continue;
66
+ if (now - (entry.updatedAt ?? 0) > PRUNE_MS)
67
+ continue;
68
+ this.entries.set(sid, { ids: new Set(entry.recordIds), updatedAt: entry.updatedAt ?? now });
69
+ }
70
+ }
71
+ /** 记入本 sessionKey 本轮实际注入的记录 id(写穿;插入序淘汰最旧)。 */
72
+ add(sessionKey, recordIds) {
73
+ if (!sessionKey || recordIds.length === 0)
74
+ return;
75
+ let entry = this.entries.get(sessionKey);
76
+ if (!entry) {
77
+ entry = { ids: new Set(), updatedAt: 0 };
78
+ this.entries.set(sessionKey, entry);
79
+ }
80
+ for (const id of recordIds) {
81
+ entry.ids.add(id);
82
+ while (entry.ids.size > RECALL_DEDUPE_IDS_CAP) {
83
+ const oldest = entry.ids.values().next().value;
84
+ if (oldest === undefined)
85
+ break;
86
+ entry.ids.delete(oldest);
87
+ }
88
+ }
89
+ entry.updatedAt = Date.now();
90
+ this.rev++;
91
+ this.writeChain = this.writeChain.then(() => this.persist());
92
+ }
93
+ list(sessionKey) {
94
+ const entry = this.entries.get(sessionKey);
95
+ return entry ? [...entry.ids] : [];
96
+ }
97
+ /** 压缩/清空:注入内容已离开上下文,清该会话活召回集(写穿)。 */
98
+ reset(sessionKey) {
99
+ if (!this.entries.has(sessionKey))
100
+ return;
101
+ this.entries.delete(sessionKey);
102
+ this.rev++;
103
+ this.writeChain = this.writeChain.then(() => this.persist());
104
+ }
105
+ size() {
106
+ return this.entries.size;
107
+ }
108
+ /** 等待在途持久化写完成(测试/停机)。 */
109
+ flush() {
110
+ return this.writeChain;
111
+ }
112
+ async persist() {
113
+ if (this.degraded !== undefined) {
114
+ if (!this.degradedLogged) {
115
+ this.degradedLogged = true;
116
+ this.logger?.warn(`[memory] 活召回集处于只读降级(${this.degraded}),已停止回写`);
117
+ }
118
+ return;
119
+ }
120
+ try {
121
+ await ensureDir(path.dirname(this.file));
122
+ await rmwJson(this.file, async (cur) => {
123
+ const diskRaw = cur.ok ? JSON.stringify(cur.value) : undefined;
124
+ if (this.lastPersisted !== undefined && diskRaw !== undefined && diskRaw !== this.lastPersisted) {
125
+ throw new Error('活召回集文件已被其它进程更新,本次写入取消以避免覆盖');
126
+ }
127
+ const next = this.serialize();
128
+ this.lastPersisted = JSON.stringify(next);
129
+ return { next, result: undefined };
130
+ }, { logger: this.logger, purpose: 'session-recalled-rmw' });
131
+ }
132
+ catch (err) {
133
+ this.logger?.warn(`[memory] 活召回集持久化失败(降级内存态): ${errDetail(err)}`);
134
+ }
135
+ }
136
+ serialize() {
137
+ const now = Date.now();
138
+ for (const [sid, e] of this.entries) {
139
+ if (now - e.updatedAt > PRUNE_MS && e.updatedAt > 0)
140
+ this.entries.delete(sid);
141
+ }
142
+ while (this.entries.size > RECALL_DEDUPE_SESSION_CAP) {
143
+ let oldestKey;
144
+ let oldestAt = Infinity;
145
+ for (const [k, e] of this.entries) {
146
+ if (e.updatedAt < oldestAt) {
147
+ oldestAt = e.updatedAt;
148
+ oldestKey = k;
149
+ }
150
+ }
151
+ if (oldestKey === undefined)
152
+ break;
153
+ this.entries.delete(oldestKey);
154
+ }
155
+ const sessions = {};
156
+ for (const [sid, e] of this.entries) {
157
+ if (e.ids.size === 0)
158
+ continue;
159
+ sessions[sid] = { recordIds: [...e.ids], updatedAt: e.updatedAt };
160
+ }
161
+ return { version: SESSION_RECALLED_FILE_VERSION, sessions };
162
+ }
163
+ }
@@ -0,0 +1,99 @@
1
+ import type { MemoryFamily, MemoryLogger } from '../types.js';
2
+ export declare const SKILL_STORE_FILE_VERSION: 1;
3
+ export type SkillStatus = 'candidate' | 'active' | 'archived';
4
+ export type InjectionTier = 'full' | 'excerpt' | 'hint';
5
+ export interface SkillTrigger {
6
+ /** 归属 wing/hall(粗分类);缺省 = 不参与按域过滤。 */
7
+ hall?: string;
8
+ sceneName?: string;
9
+ keywords: string[];
10
+ workspaceId?: string;
11
+ }
12
+ export interface Skill {
13
+ id: string;
14
+ title: string;
15
+ /** 人可编辑正文;进 preserve 白名单,重建不覆盖。 */
16
+ body: string;
17
+ /** 机器聚合的 checklist 步骤;重建投影可覆盖。 */
18
+ steps: string[];
19
+ trigger: SkillTrigger;
20
+ injectionTier: InjectionTier;
21
+ status: SkillStatus;
22
+ pinned: boolean;
23
+ confidence: number;
24
+ family: MemoryFamily;
25
+ scope: 'global' | 'workspace';
26
+ /** 支撑该技能的证据记录 id(指向 l1_records.record_id,仅作溯源,不强约束外键)。 */
27
+ evidenceRecordIds: string[];
28
+ createdAt: string;
29
+ updatedAt: string;
30
+ lastUsedAt: string;
31
+ archivedReason?: string;
32
+ }
33
+ export interface SkillEvidence {
34
+ skillId: string;
35
+ runId: string;
36
+ recordId: string;
37
+ sessionId: string;
38
+ at: string;
39
+ kind: string;
40
+ }
41
+ export interface NewSkillInput {
42
+ id?: string;
43
+ title: string;
44
+ body?: string;
45
+ steps?: string[];
46
+ trigger?: Partial<SkillTrigger>;
47
+ injectionTier?: InjectionTier;
48
+ status?: SkillStatus;
49
+ family?: MemoryFamily;
50
+ scope?: 'global' | 'workspace';
51
+ evidenceRecordIds?: string[];
52
+ }
53
+ export declare class SkillStore {
54
+ private readonly logger?;
55
+ private readonly file;
56
+ private skills;
57
+ private evidence;
58
+ private rev;
59
+ private loaded;
60
+ private degraded;
61
+ private degradedLogged;
62
+ private writeChain;
63
+ private seq;
64
+ constructor(dataDir: string, logger?: MemoryLogger | undefined);
65
+ init(): Promise<void>;
66
+ private ensureLoaded;
67
+ list(): Skill[];
68
+ /** active 技能(注入候选)。 */
69
+ listActive(): Skill[];
70
+ get(id: string): Skill | undefined;
71
+ private newId;
72
+ /** 新建技能(幂等:显式传 id 且已存在 → 走 updateBody 语义由调用方决定,这里仅拒绝重复 id)。 */
73
+ create(input: NewSkillInput): Promise<Skill>;
74
+ /** 编辑人可读正文(body/title/steps/injectionTier/pinned);只动提供的字段。 */
75
+ update(id: string, patch: Partial<Pick<Skill, 'title' | 'body' | 'steps' | 'injectionTier' | 'pinned'>>): Promise<boolean>;
76
+ /** 追加一条证据(只追加);返回该技能当前的 distinct-session 数。 */
77
+ appendEvidence(ev: SkillEvidence): Promise<number>;
78
+ /** 某技能的 distinct session 计数(晋升判据的唯一来源,与 LLM 判断解耦)。 */
79
+ sessionCount(skillId: string): number;
80
+ /**
81
+ * 晋升:candidate→active,仅当功能开且该技能 distinct-session ≥ minSessions。
82
+ * 返回是否发生晋升。不批量、不依赖 LLM(可解释性结构保证,同 conflictFreeze"不自动裁决"哲学)。
83
+ */
84
+ tryPromote(skillId: string, minSessions: number, opts: {
85
+ allowPromote: boolean;
86
+ }): Promise<boolean>;
87
+ /** 记一次使用(刷新 lastUsedAt,供闲置归档与"只用才强化")。 */
88
+ touchUsed(skillId: string, at: string): Promise<boolean>;
89
+ /** 归档(active/candidate→archived);记录原因。走本表独立生命周期,不触碰 l1_records 退场。 */
90
+ archive(skillId: string, reason: string): Promise<boolean>;
91
+ /**
92
+ * 闲置归档:active 且未 pinned 且(now - lastUsedAt)> idleDays 的技能自动归档。
93
+ * 机械比较、0 LLM、不引入 timer(对齐 slots.expireDue 范式)。返回归档条数。
94
+ */
95
+ archiveDue(now: number, idleDays: number): Promise<number>;
96
+ private patchOne;
97
+ /** 写穿持久化(串行链;失败降级内存态并告警一次,绝不抛出阻塞调用方)。 */
98
+ private persist;
99
+ }