dsh-prime-memory 0.22.1 → 0.24.0

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 (121) hide show
  1. package/CHANGELOG.en.md +401 -0
  2. package/CHANGELOG.ja.md +401 -0
  3. package/CHANGELOG.ko.md +401 -0
  4. package/CHANGELOG.md +100 -0
  5. package/dist/client.js +1951 -1188
  6. package/dist/config.d.ts +176 -1240
  7. package/dist/config.js +113 -2
  8. package/dist/conflict-service.d.ts +12 -0
  9. package/dist/conflict-service.js +24 -0
  10. package/dist/contract.d.ts +327 -0
  11. package/dist/domain-gate.d.ts +19 -0
  12. package/dist/domain-gate.js +45 -0
  13. package/dist/graph/constraints.d.ts +4 -2
  14. package/dist/graph/constraints.js +4 -2
  15. package/dist/hooks/obsolete-notice.d.ts +26 -0
  16. package/dist/hooks/obsolete-notice.js +64 -0
  17. package/dist/hooks/recall.d.ts +41 -0
  18. package/dist/hooks/recall.js +127 -28
  19. package/dist/hooks/skill-recall.d.ts +25 -0
  20. package/dist/hooks/skill-recall.js +72 -0
  21. package/dist/index.d.ts +1 -1029
  22. package/dist/index.js +55 -2
  23. package/dist/memory-types.d.ts +69 -0
  24. package/dist/memory-types.js +165 -0
  25. package/dist/memport/format.d.ts +75 -0
  26. package/dist/memport/format.js +345 -0
  27. package/dist/memport/prefilter.d.ts +62 -0
  28. package/dist/memport/prefilter.js +132 -0
  29. package/dist/memport/scan.d.ts +13 -0
  30. package/dist/memport/scan.js +100 -0
  31. package/dist/metadata-validators.d.ts +22 -0
  32. package/dist/metadata-validators.js +45 -0
  33. package/dist/pipeline/l1.d.ts +44 -2
  34. package/dist/pipeline/l1.js +177 -9
  35. package/dist/pipeline/md-sync.d.ts +65 -0
  36. package/dist/pipeline/md-sync.js +456 -0
  37. package/dist/pipeline/ruminate.d.ts +46 -1
  38. package/dist/pipeline/ruminate.js +150 -1
  39. package/dist/pipeline/runner.d.ts +10 -0
  40. package/dist/pipeline/runner.js +7 -1
  41. package/dist/pipeline/slot-migrate.d.ts +45 -0
  42. package/dist/pipeline/slot-migrate.js +45 -0
  43. package/dist/pipeline/workspace-relink.d.ts +60 -0
  44. package/dist/pipeline/workspace-relink.js +73 -0
  45. package/dist/projection/recall.d.ts +112 -0
  46. package/dist/projection/recall.js +183 -0
  47. package/dist/projection/slots.d.ts +5 -0
  48. package/dist/prompts/examples.d.ts +73 -0
  49. package/dist/prompts/examples.js +178 -0
  50. package/dist/prompts/graph-projection.js +3 -0
  51. package/dist/prompts/l1-dedup.d.ts +9 -3
  52. package/dist/prompts/l1-dedup.js +34 -13
  53. package/dist/prompts/l1-extraction.d.ts +9 -4
  54. package/dist/prompts/l1-extraction.js +76 -8
  55. package/dist/settings.d.ts +8 -0
  56. package/dist/settings.js +15 -1
  57. package/dist/skills/crystallize.d.ts +41 -0
  58. package/dist/skills/crystallize.js +75 -0
  59. package/dist/stats.d.ts +30 -1
  60. package/dist/stats.js +504 -13
  61. package/dist/store/embedding-source.d.ts +8 -0
  62. package/dist/store/embedding-source.js +39 -2
  63. package/dist/store/file-versions.d.ts +4 -0
  64. package/dist/store/file-versions.js +4 -0
  65. package/dist/store/graph-store.d.ts +14 -0
  66. package/dist/store/graph-store.js +35 -0
  67. package/dist/store/l1-snapshot.d.ts +26 -1
  68. package/dist/store/l1-snapshot.js +53 -8
  69. package/dist/store/l1.d.ts +77 -3
  70. package/dist/store/l1.js +128 -3
  71. package/dist/store/memory-md-archive.d.ts +84 -0
  72. package/dist/store/memory-md-archive.js +155 -0
  73. package/dist/store/memory-md-disable.d.ts +23 -0
  74. package/dist/store/memory-md-disable.js +106 -0
  75. package/dist/store/memory-md-export.d.ts +50 -0
  76. package/dist/store/memory-md-export.js +200 -0
  77. package/dist/store/memory-md-ledger.d.ts +94 -0
  78. package/dist/store/memory-md-ledger.js +243 -0
  79. package/dist/store/memory-md-orphan.d.ts +27 -0
  80. package/dist/store/memory-md-orphan.js +153 -0
  81. package/dist/store/memory-md-parse.d.ts +35 -0
  82. package/dist/store/memory-md-parse.js +85 -0
  83. package/dist/store/memory-md-skeleton.d.ts +18 -0
  84. package/dist/store/memory-md-skeleton.js +61 -0
  85. package/dist/store/memory-md-types.d.ts +174 -0
  86. package/dist/store/memory-md-types.js +10 -0
  87. package/dist/store/memory-md.d.ts +108 -0
  88. package/dist/store/memory-md.js +219 -0
  89. package/dist/store/memory-sources.d.ts +59 -0
  90. package/dist/store/memory-sources.js +214 -0
  91. package/dist/store/obsolete-notices.d.ts +39 -0
  92. package/dist/store/obsolete-notices.js +153 -0
  93. package/dist/store/recall-feedback.d.ts +75 -0
  94. package/dist/store/recall-feedback.js +213 -0
  95. package/dist/store/recall-usage.d.ts +11 -0
  96. package/dist/store/recall-usage.js +29 -0
  97. package/dist/store/session-recalled.d.ts +60 -0
  98. package/dist/store/session-recalled.js +310 -0
  99. package/dist/store/skill-store.d.ts +99 -0
  100. package/dist/store/skill-store.js +330 -0
  101. package/dist/store/sqlite.d.ts +71 -0
  102. package/dist/store/sqlite.js +259 -15
  103. package/dist/store/supersede.d.ts +1 -1
  104. package/dist/store/supersede.js +1 -1
  105. package/dist/tools/index.d.ts +4 -13
  106. package/dist/tools/index.js +80 -12
  107. package/dist/tools/skills.d.ts +13 -0
  108. package/dist/tools/skills.js +130 -0
  109. package/dist/types.d.ts +30 -7
  110. package/dist/types.js +10 -2
  111. package/dist/util/maturity.d.ts +44 -0
  112. package/dist/util/maturity.js +43 -0
  113. package/dist/util/md-name.d.ts +129 -0
  114. package/dist/util/md-name.js +366 -0
  115. package/dist/util/md-normalize.d.ts +23 -0
  116. package/dist/util/md-normalize.js +131 -0
  117. package/dist/wing-backfill.js +7 -4
  118. package/dist/workspace-route.d.ts +22 -0
  119. package/dist/workspace-route.js +66 -0
  120. package/dsh.plugin.json +1 -1
  121. package/package.json +6 -5
@@ -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
+ }
@@ -0,0 +1,330 @@
1
+ /**
2
+ * 技能结晶存储(轨2,程序性记忆)。**默认关**(config.skills.enabled)。
3
+ *
4
+ * 设计裁定(对齐计划轨2 + 三视角审计):
5
+ * - 技能**不是 memory `type`**,而是 L1 之上的**可重建派生投影**——因此完全不碰
6
+ * `ADD_TYPES` 白名单与蒸馏 prompt 词表,从根上规避 S1(白名单静默降级)/S2(词表产不出)/
7
+ * S3(family 前缀误归族) 三处静默腐化;证据来源 = 现有 `work_method` L1 记录。
8
+ * - 落 **JSON sidecar**(仿 memory-sources/rooms-registry),不塞进 2691 行的 `sqlite.ts`:
9
+ * 技能条目量小、distinct-session 计数内存聚合即可;缺失=合法空表、损坏=只读降级不阻塞。
10
+ * - **`body`(人编辑)与 `steps`(机器聚合)分列**:机器侧可被重建投影覆盖,
11
+ * 人编辑侧永久保留(对齐 rebuild-preserve 的"非派生内容不可被重建抹掉"不变量)。
12
+ * - **只计数不晋升**(本轮):`skill_evidence` 记录 distinct session,晋升需
13
+ * `count(distinct session) >= minSessions` 且功能开;新装库无历史即空转,符合预期。
14
+ * - 归档 `archiveDue` 基于 `lastUsedAt` 闲置天数(机械比较,0 LLM、不引入 timer)。
15
+ */
16
+ import * as path from 'node:path';
17
+ import { readJsonStrict, atomicWriteText, ensureDir } from '../util/io.js';
18
+ export const SKILL_STORE_FILE_VERSION = 1;
19
+ const SKILLS_CAP = 500;
20
+ const EVIDENCE_CAP = 20_000;
21
+ const TITLE_MAX = 120;
22
+ const BODY_MAX = 8000;
23
+ const STEP_MAX = 40;
24
+ const KEYWORDS_MAX = 16;
25
+ const TIER_SET = new Set(['full', 'excerpt', 'hint']);
26
+ const STATUS_SET = new Set(['candidate', 'active', 'archived']);
27
+ function str(v, def = '') {
28
+ return typeof v === 'string' ? v : def;
29
+ }
30
+ function normTier(v) {
31
+ return TIER_SET.has(v) ? v : 'excerpt';
32
+ }
33
+ function normStatus(v) {
34
+ return STATUS_SET.has(v) ? v : 'candidate';
35
+ }
36
+ /** 规整一条技能(读取侧 fail-soft:非法值归一到合法域,绝不因单条脏数据拖垮整表)。 */
37
+ function coerceSkill(raw) {
38
+ if (!raw || typeof raw !== 'object')
39
+ return null;
40
+ const r = raw;
41
+ const id = str(r.id);
42
+ const title = str(r.title).slice(0, TITLE_MAX);
43
+ if (!id || !title)
44
+ return null;
45
+ return {
46
+ id,
47
+ title,
48
+ body: str(r.body).slice(0, BODY_MAX),
49
+ steps: Array.isArray(r.steps) ? r.steps.filter((s) => typeof s === 'string').slice(0, STEP_MAX) : [],
50
+ trigger: {
51
+ hall: typeof r.trigger?.hall === 'string' ? str(r.trigger.hall) : undefined,
52
+ sceneName: typeof r.trigger?.sceneName === 'string' ? str(r.trigger.sceneName) : undefined,
53
+ keywords: Array.isArray(r.trigger?.keywords)
54
+ ? r.trigger.keywords.filter((k) => typeof k === 'string' && k !== '').slice(0, KEYWORDS_MAX)
55
+ : [],
56
+ workspaceId: typeof r.trigger?.workspaceId === 'string' ? str(r.trigger.workspaceId) : undefined,
57
+ },
58
+ injectionTier: normTier(r.injectionTier),
59
+ status: normStatus(r.status),
60
+ pinned: r.pinned === true,
61
+ confidence: typeof r.confidence === 'number' && Number.isFinite(r.confidence) ? Math.max(0, Math.min(1, r.confidence)) : 0,
62
+ family: r.family === 'work' ? 'work' : 'chat',
63
+ scope: r.scope === 'workspace' ? 'workspace' : 'global',
64
+ evidenceRecordIds: Array.isArray(r.evidenceRecordIds) ? r.evidenceRecordIds.filter((x) => typeof x === 'string').slice(0, 200) : [],
65
+ createdAt: str(r.createdAt),
66
+ updatedAt: str(r.updatedAt),
67
+ lastUsedAt: str(r.lastUsedAt),
68
+ archivedReason: typeof r.archivedReason === 'string' ? r.archivedReason : undefined,
69
+ };
70
+ }
71
+ export class SkillStore {
72
+ logger;
73
+ file;
74
+ skills = [];
75
+ evidence = [];
76
+ rev = 0;
77
+ loaded = false;
78
+ degraded;
79
+ degradedLogged = false;
80
+ writeChain = Promise.resolve();
81
+ seq = 0;
82
+ constructor(dataDir, logger) {
83
+ this.logger = logger;
84
+ this.file = path.join(dataDir, 'skills.json');
85
+ }
86
+ async init() {
87
+ await ensureDir(path.dirname(this.file));
88
+ const read = await readJsonStrict(this.file);
89
+ if (!read.ok) {
90
+ if (read.reason !== 'missing') {
91
+ this.degraded = `${read.reason}${'detail' in read && read.detail ? `: ${read.detail}` : ''}`;
92
+ this.logger?.warn(`[memory] 技能存储只读降级(内存态照常,停止回写): ${this.degraded}`);
93
+ }
94
+ this.loaded = true;
95
+ return;
96
+ }
97
+ if (read.value.version !== SKILL_STORE_FILE_VERSION) {
98
+ this.logger?.warn(`[memory] 技能存储版本 ${String(read.value.version)} 未知,按当前形状读取`);
99
+ }
100
+ if (Array.isArray(read.value.skills)) {
101
+ for (const raw of read.value.skills) {
102
+ const s = coerceSkill(raw);
103
+ if (s)
104
+ this.skills.push(s);
105
+ }
106
+ }
107
+ if (Array.isArray(read.value.evidence)) {
108
+ for (const e of read.value.evidence) {
109
+ if (e && typeof e.skillId === 'string' && typeof e.sessionId === 'string')
110
+ this.evidence.push(e);
111
+ }
112
+ }
113
+ this.rev = typeof read.value.rev === 'number' ? read.value.rev : 0;
114
+ // 序号续接:避免重启后铸出重复 skill id。
115
+ this.seq = this.skills.length;
116
+ this.loaded = true;
117
+ }
118
+ ensureLoaded() {
119
+ if (!this.loaded)
120
+ throw new Error('技能存储未初始化(先 await init())');
121
+ }
122
+ list() {
123
+ this.ensureLoaded();
124
+ return this.skills.map((s) => ({ ...s, steps: [...s.steps], trigger: { ...s.trigger, keywords: [...s.trigger.keywords] }, evidenceRecordIds: [...s.evidenceRecordIds] }));
125
+ }
126
+ /** active 技能(注入候选)。 */
127
+ listActive() {
128
+ return this.list().filter((s) => s.status === 'active');
129
+ }
130
+ get(id) {
131
+ this.ensureLoaded();
132
+ const s = this.skills.find((x) => x.id === id);
133
+ return s ? { ...s, steps: [...s.steps], trigger: { ...s.trigger, keywords: [...s.trigger.keywords] }, evidenceRecordIds: [...s.evidenceRecordIds] } : undefined;
134
+ }
135
+ newId() {
136
+ this.seq++;
137
+ return 'sk-' + Date.now().toString(36) + '-' + this.seq.toString(36);
138
+ }
139
+ /** 新建技能(幂等:显式传 id 且已存在 → 走 updateBody 语义由调用方决定,这里仅拒绝重复 id)。 */
140
+ async create(input) {
141
+ this.ensureLoaded();
142
+ if (this.skills.length >= SKILLS_CAP)
143
+ throw new Error(`技能数已达上限(${SKILLS_CAP}),请归档部分`);
144
+ const id = (input.id ?? '').trim() || this.newId();
145
+ if (this.skills.some((s) => s.id === id))
146
+ throw new Error(`技能 id 已存在: ${id}`);
147
+ const title = String(input.title ?? '').trim().slice(0, TITLE_MAX);
148
+ if (!title)
149
+ throw new Error('技能 title 不能为空');
150
+ const now = new Date().toISOString();
151
+ const skill = {
152
+ id,
153
+ title,
154
+ body: String(input.body ?? '').slice(0, BODY_MAX),
155
+ steps: (input.steps ?? []).filter((x) => typeof x === 'string').slice(0, STEP_MAX),
156
+ trigger: {
157
+ hall: input.trigger?.hall?.trim() || undefined,
158
+ sceneName: input.trigger?.sceneName?.trim() || undefined,
159
+ keywords: (input.trigger?.keywords ?? []).filter((k) => k).slice(0, KEYWORDS_MAX),
160
+ workspaceId: input.trigger?.workspaceId || undefined,
161
+ },
162
+ injectionTier: normTier(input.injectionTier),
163
+ status: normStatus(input.status),
164
+ pinned: false,
165
+ confidence: 0,
166
+ family: input.family === 'chat' ? 'chat' : 'work',
167
+ scope: input.scope === 'workspace' ? 'workspace' : 'global',
168
+ evidenceRecordIds: (input.evidenceRecordIds ?? []).slice(0, 200),
169
+ createdAt: now,
170
+ updatedAt: now,
171
+ lastUsedAt: '',
172
+ };
173
+ this.skills.push(skill);
174
+ this.rev++;
175
+ await this.persist();
176
+ return { ...skill };
177
+ }
178
+ /** 编辑人可读正文(body/title/steps/injectionTier/pinned);只动提供的字段。 */
179
+ async update(id, patch) {
180
+ this.ensureLoaded();
181
+ const s = this.skills.find((x) => x.id === id);
182
+ if (!s)
183
+ return false;
184
+ let changed = false;
185
+ if (typeof patch.title === 'string' && patch.title.trim()) {
186
+ s.title = patch.title.trim().slice(0, TITLE_MAX);
187
+ changed = true;
188
+ }
189
+ if (typeof patch.body === 'string') {
190
+ s.body = patch.body.slice(0, BODY_MAX);
191
+ changed = true;
192
+ }
193
+ if (Array.isArray(patch.steps)) {
194
+ s.steps = patch.steps.filter((x) => typeof x === 'string').slice(0, STEP_MAX);
195
+ changed = true;
196
+ }
197
+ if (patch.injectionTier !== undefined && TIER_SET.has(patch.injectionTier)) {
198
+ s.injectionTier = patch.injectionTier;
199
+ changed = true;
200
+ }
201
+ if (patch.pinned !== undefined && patch.pinned !== s.pinned) {
202
+ s.pinned = patch.pinned;
203
+ changed = true;
204
+ }
205
+ if (!changed)
206
+ return false;
207
+ s.updatedAt = new Date().toISOString();
208
+ this.rev++;
209
+ await this.persist();
210
+ return true;
211
+ }
212
+ /** 追加一条证据(只追加);返回该技能当前的 distinct-session 数。 */
213
+ async appendEvidence(ev) {
214
+ this.ensureLoaded();
215
+ if (!this.skills.some((s) => s.id === ev.skillId))
216
+ return 0;
217
+ this.evidence.push(ev);
218
+ if (this.evidence.length > EVIDENCE_CAP)
219
+ this.evidence.splice(0, this.evidence.length - EVIDENCE_CAP);
220
+ this.rev++;
221
+ await this.persist();
222
+ return this.sessionCount(ev.skillId);
223
+ }
224
+ /** 某技能的 distinct session 计数(晋升判据的唯一来源,与 LLM 判断解耦)。 */
225
+ sessionCount(skillId) {
226
+ this.ensureLoaded();
227
+ const s = new Set();
228
+ for (const e of this.evidence)
229
+ if (e.skillId === skillId && e.sessionId)
230
+ s.add(e.sessionId);
231
+ return s.size;
232
+ }
233
+ /**
234
+ * 晋升:candidate→active,仅当功能开且该技能 distinct-session ≥ minSessions。
235
+ * 返回是否发生晋升。不批量、不依赖 LLM(可解释性结构保证,同 conflictFreeze"不自动裁决"哲学)。
236
+ */
237
+ async tryPromote(skillId, minSessions, opts) {
238
+ this.ensureLoaded();
239
+ if (!opts.allowPromote)
240
+ return false;
241
+ const s = this.skills.find((x) => x.id === skillId);
242
+ if (!s || s.status !== 'candidate')
243
+ return false;
244
+ if (this.sessionCount(skillId) < minSessions)
245
+ return false;
246
+ s.status = 'active';
247
+ s.updatedAt = new Date().toISOString();
248
+ this.rev++;
249
+ await this.persist();
250
+ return true;
251
+ }
252
+ /** 记一次使用(刷新 lastUsedAt,供闲置归档与"只用才强化")。 */
253
+ async touchUsed(skillId, at) {
254
+ return this.patchOne(skillId, (s) => {
255
+ s.lastUsedAt = at;
256
+ });
257
+ }
258
+ /** 归档(active/candidate→archived);记录原因。走本表独立生命周期,不触碰 l1_records 退场。 */
259
+ async archive(skillId, reason) {
260
+ this.ensureLoaded();
261
+ const s = this.skills.find((x) => x.id === skillId);
262
+ if (!s || s.status === 'archived')
263
+ return false;
264
+ s.status = 'archived';
265
+ s.archivedReason = reason;
266
+ s.updatedAt = new Date().toISOString();
267
+ this.rev++;
268
+ await this.persist();
269
+ return true;
270
+ }
271
+ /**
272
+ * 闲置归档:active 且未 pinned 且(now - lastUsedAt)> idleDays 的技能自动归档。
273
+ * 机械比较、0 LLM、不引入 timer(对齐 slots.expireDue 范式)。返回归档条数。
274
+ */
275
+ async archiveDue(now, idleDays) {
276
+ this.ensureLoaded();
277
+ if (idleDays <= 0)
278
+ return 0;
279
+ const cutoff = now - idleDays * 86_400_000;
280
+ let n = 0;
281
+ for (const s of this.skills) {
282
+ if (s.status !== 'active' || s.pinned)
283
+ continue;
284
+ const last = s.lastUsedAt ? Date.parse(s.lastUsedAt) : Date.parse(s.updatedAt || s.createdAt);
285
+ if (Number.isFinite(last) && last < cutoff) {
286
+ s.status = 'archived';
287
+ s.archivedReason = `idle>${idleDays}d`;
288
+ s.updatedAt = new Date(now).toISOString();
289
+ n++;
290
+ }
291
+ }
292
+ if (n) {
293
+ this.rev++;
294
+ await this.persist();
295
+ }
296
+ return n;
297
+ }
298
+ async patchOne(id, mut) {
299
+ this.ensureLoaded();
300
+ const s = this.skills.find((x) => x.id === id);
301
+ if (!s)
302
+ return false;
303
+ mut(s);
304
+ s.updatedAt = new Date().toISOString();
305
+ this.rev++;
306
+ await this.persist();
307
+ return true;
308
+ }
309
+ /** 写穿持久化(串行链;失败降级内存态并告警一次,绝不抛出阻塞调用方)。 */
310
+ async persist() {
311
+ if (this.degraded)
312
+ return;
313
+ this.writeChain = this.writeChain.then(async () => {
314
+ const data = { version: SKILL_STORE_FILE_VERSION, rev: this.rev + 1, skills: this.skills, evidence: this.evidence };
315
+ try {
316
+ await ensureDir(path.dirname(this.file));
317
+ await atomicWriteText(this.file, JSON.stringify(data, null, 2));
318
+ this.rev = data.rev;
319
+ }
320
+ catch (err) {
321
+ this.degraded = err instanceof Error ? err.message : String(err);
322
+ if (!this.degradedLogged) {
323
+ this.degradedLogged = true;
324
+ this.logger?.warn(`[memory] 技能存储写入失败,只读降级: ${this.degraded}`);
325
+ }
326
+ }
327
+ });
328
+ await this.writeChain;
329
+ }
330
+ }
@@ -119,6 +119,21 @@ export declare class MemoryDb {
119
119
  * (三处列清单:DDL / insert 语句 / 本函数),故在此留下警示。
120
120
  */
121
121
  private backfillL1Fts;
122
+ /**
123
+ * 已退场 L1 行数。原本只给回灌留痕用,现在展示面也要它:
124
+ * `vectorCounts` 算“待补”时得分掉退场行(它们合法地无向量),否则那个数永远归不了零。
125
+ * 失败返回 -1——宁可日志写“未知”也不假装 0 条。
126
+ */
127
+ countL1Retired(): number;
128
+ /**
129
+ * 清除“已退场但仍待在检索面”的残留行(FTS + 向量)。幂等:第二次跑命中 0 行。
130
+ *
131
+ * 为什么必须存在于启动路径:口径修复只挡住**新增**的脏行,而存量脏行仍在给
132
+ * BM25/向量检索供货——对用户而言就是“标了失效却还能被召回”。
133
+ * 失败只 warn:这是个修复动作,无权把库拖进 degraded,更不能抛进启动
134
+ * 的 await 链(那里报错 = storageOk=false = 记忆功能整体停用)。
135
+ */
136
+ private purgeRetiredRetrievalRows;
122
137
  /** 重建后的 l0_fts 从 l0_conversations 全量回灌(仅 drop 重建时调用;iterate 流式)。 */
123
138
  private backfillL0Fts;
124
139
  private readEmbeddingMeta;
@@ -198,6 +213,11 @@ export declare class MemoryDb {
198
213
  */
199
214
  clearL1(): boolean;
200
215
  countL1(): number;
216
+ /**
217
+ * 按外部导入幂等键查已存在记录 id(轨3-P0 幂等 pre-check)。
218
+ * 空键/降级一律返回 undefined;未迁移旧库(无 ext_key 列)由 catch 兜底 undefined(fail-open)。
219
+ */
220
+ findRecordIdByExtKey(extKey: string): string | undefined;
201
221
  /**
202
222
  * 游标分批取 L1 元信息(**仅三列**):后台巡检专用。
203
223
  *
@@ -222,6 +242,57 @@ export declare class MemoryDb {
222
242
  repoKeyName?: string;
223
243
  repoKeyOwner?: string;
224
244
  }, expectRepoKeyName?: string): number;
245
+ /**
246
+ * 批量写 `workspace_id`(内容路由 / 存量 re-link 用)。单事务,**刻意不碰 `updated_time`**。
247
+ *
248
+ * 为什么不碰 `updated_time`(三条都是硬约束):
249
+ * ① 它在 `canonicalRecords` 的哈希字段表里——动了就是“静默改写”,会让改动前建的快照
250
+ * 永久 verify 不通过(`workspace_id` 本身不在表里,所以**只**写它才是快照安全的);
251
+ * ② 它是时效衰减(agingWeight)与列表排序的输入,改一下归属顺手把记忆“刷陈旧”是意外副作用;
252
+ * ③ re-link 按固定序分页扫,不写它才不会边扫边漂。
253
+ *
254
+ * 为什么连 `l1_fts` 一起改:FTS 行**自带** `workspace_id`(UNINDEXED 随行),
255
+ * 而检索过滤 `(? = '' OR scope='global' OR workspace_id = ?)` 读的就是 FTS 行那一列。
256
+ * 只改主表 = 一旦启用围栏,关键词路按旧归属过滤、向量路按新归属过滤,两路结果不一致。
257
+ * 退场行不在 FTS 里,那条 UPDATE 自然命中 0 行,不是错误。
258
+ */
259
+ setL1WorkspaceIds(items: ReadonlyArray<{
260
+ id: string;
261
+ workspaceId: string;
262
+ }>): number;
263
+ /**
264
+ * 改一条记录的文件身份名(N1)。三态返回,**不碰 `updated_time`**:它在快照哈希
265
+ * 字段表里、又是衰减与排序的输入——改名不是“内容变了”,不该顺带把这条记成刚更新过
266
+ * (与 `setL1WorkspaceIds` 同一个理由)。`name=''` 是合法值(= 没名字,部分索引不约束空串)。
267
+ *
268
+ * 唯一性由 `l1_records_name_uidx` 挡:撞名 ⇒ `'dup'`,由调用方决定报错还是让用户先改
269
+ * 另一条——**不自动加后缀**,那会静默造出语义重叠的第二条记忆。
270
+ * (另一个口径:机器给**本来没名字**的记录起名时必须消歧,见 `renameL1Batch` 的注释。)
271
+ */
272
+ renameL1(id: string, name: string): 'ok' | 'dup' | 'missing' | 'failed';
273
+ /**
274
+ * 批量写 `name`(单事务)。给“机器起名”用:存量记录没名字时一次补齐。
275
+ *
276
+ * 与 `renameL1` 的分工不是性能而是**语义**:那里是**人给的**名字,撞了就报错让用户
277
+ * 先改另一条,绝不静默加后缀;这里是机器给**本来没名字**的记录派生名字,同正文的
278
+ * 多条必然撞名(真机实测 42 条挤进 17 个路径),**不消歧就是每轮导出重刷 + 反向同步
279
+ * 把一份内容回灌给多条**。所以消歧必须在本层之上先把名字算唯一,本层只负责落库。
280
+ *
281
+ * 与 `renameL1` 同样**不碰 `updated_time`**(起名不是“内容变了”),也不动 FTS——
282
+ * `name` 不在检索面里(`l1_fts` 没这一列)。
283
+ *
284
+ * @param items - `{ id, name }`,name 已由调用方保证全库唯一。
285
+ * @returns 实际写入的 id 集(现值已等于目标值的跳过——幂等)与失败集。
286
+ */
287
+ renameL1Batch(items: ReadonlyArray<{
288
+ id: string;
289
+ name: string;
290
+ }>): {
291
+ done: string[];
292
+ failed: string[];
293
+ };
294
+ /** 按文件身份名反查 record_id(重名预检与 md 回灌用);没这条名 ⇒ undefined。*/
295
+ findRecordIdByName(name: string): string | undefined;
225
296
  /** 入队一个重聚类作业(demote-to-wiki / repo 归属变化触发;source 区分来源)。 */
226
297
  enqueueSceneRecluster(family: string, sceneNames: readonly string[], batchId: string, source?: string): string;
227
298
  /** 取一个待处理作业(ruminate 空闲消费;按创建序)。 */