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
@@ -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
+ }
@@ -198,6 +198,11 @@ export declare class MemoryDb {
198
198
  */
199
199
  clearL1(): boolean;
200
200
  countL1(): number;
201
+ /**
202
+ * 按外部导入幂等键查已存在记录 id(轨3-P0 幂等 pre-check)。
203
+ * 空键/降级一律返回 undefined;未迁移旧库(无 ext_key 列)由 catch 兜底 undefined(fail-open)。
204
+ */
205
+ findRecordIdByExtKey(extKey: string): string | undefined;
201
206
  /**
202
207
  * 游标分批取 L1 元信息(**仅三列**):后台巡检专用。
203
208
  *
@@ -318,6 +318,14 @@ export class MemoryDb {
318
318
  this.db.exec("ALTER TABLE l1_records ADD COLUMN applicability TEXT NOT NULL DEFAULT ''");
319
319
  this.logger?.info(`${TAG} l1_records 补 repo 归属列(DEFAULT 即标注,存量零回填)`);
320
320
  }
321
+ // ── 外部导入幂等键(轨3-P0):ext_key='' 即内部记录(不参与去重),DEFAULT '' 存量零搬运。
322
+ // 部分唯一索引只约束非空值——多条内部记录 ext_key 同为 '' 互不冲突;仅当外部导入的
323
+ // 同一 (来源, 稳定身份) 重复导入才被结构性拒绝(写侧 pre-check + 索引双保险)。
324
+ if (!this.hasColumn('l1_records', 'ext_key')) {
325
+ this.db.exec("ALTER TABLE l1_records ADD COLUMN ext_key TEXT NOT NULL DEFAULT ''");
326
+ this.logger?.info(`${TAG} l1_records 补 ext_key 列(外部导入幂等键)`);
327
+ }
328
+ this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS l1_records_ext_key_uidx ON l1_records(ext_key) WHERE ext_key <> ''");
321
329
  // L2 场景重聚类作业队列(治理 W3 T3.10;ruminate 空闲档消费,每轮至多 1 作业)。
322
330
  // source 区分触发来源('demote'|'repo-change'),便于诊断与重入队语义区分。
323
331
  this.db.exec(`
@@ -434,8 +442,8 @@ export class MemoryDb {
434
442
  record_id, content, type, priority, scene_name, session_id, version,
435
443
  timestamp_str, timestamp_start, timestamp_end, created_time, updated_time, metadata_json, family,
436
444
  valid_from, valid_to, persistence, scope, workspace_id,
437
- repo_key_name, repo_key_owner, applicability
438
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
445
+ repo_key_name, repo_key_owner, applicability, ext_key
446
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
439
447
  ON CONFLICT(record_id) DO UPDATE SET
440
448
  content=excluded.content,
441
449
  type=excluded.type,
@@ -455,7 +463,8 @@ export class MemoryDb {
455
463
  workspace_id=excluded.workspace_id,
456
464
  repo_key_name=excluded.repo_key_name,
457
465
  repo_key_owner=excluded.repo_key_owner,
458
- applicability=excluded.applicability
466
+ applicability=excluded.applicability,
467
+ ext_key=excluded.ext_key
459
468
  `);
460
469
  this.stmtGetL1 = this.db.prepare(`
461
470
  SELECT record_id, content, type, priority, scene_name, version, timestamp_str,
@@ -907,7 +916,7 @@ export class MemoryDb {
907
916
  // 旧版导入都会经这里写回记录,若照常重建 FTS/向量行,一条带退场标记的记录
908
917
  // 会**悄悄回到检索结果里**(而检索侧刻意不看 valid_to,正是为了零漂移)。
909
918
  const retiredNow = record.validTo !== undefined || readSupersedeMarker(record.metadata) !== undefined;
910
- this.stmtUpsertL1.run(record.id, record.content, type, priority, sceneName, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, toIso(record.createdAt), toIso(record.updatedAt), JSON.stringify(record.metadata ?? {}), family, toIso(record.validFrom), toIso(record.validTo), normPersistence(record.persistence) ?? '', scope, workspaceId, record.repoKeyName ?? '', record.repoKeyOwner ?? '', normApplicability(record.applicability));
919
+ this.stmtUpsertL1.run(record.id, record.content, type, priority, sceneName, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, toIso(record.createdAt), toIso(record.updatedAt), JSON.stringify(record.metadata ?? {}), family, toIso(record.validFrom), toIso(record.validTo), normPersistence(record.persistence) ?? '', scope, workspaceId, record.repoKeyName ?? '', record.repoKeyOwner ?? '', normApplicability(record.applicability), record.extKey ?? '');
911
920
  // vec0 不支持 ON CONFLICT → 先删后插;零向量跳过(cosine 未定义)。
912
921
  // 已退场则**只删不插**(见上方 retiredNow 的说明)。
913
922
  if (this.stmtDeleteL1Vec && this.stmtInsertL1Vec) {
@@ -1134,6 +1143,23 @@ export class MemoryDb {
1134
1143
  return 0;
1135
1144
  }
1136
1145
  }
1146
+ /**
1147
+ * 按外部导入幂等键查已存在记录 id(轨3-P0 幂等 pre-check)。
1148
+ * 空键/降级一律返回 undefined;未迁移旧库(无 ext_key 列)由 catch 兜底 undefined(fail-open)。
1149
+ */
1150
+ findRecordIdByExtKey(extKey) {
1151
+ if (this.degraded || !extKey)
1152
+ return undefined;
1153
+ try {
1154
+ const row = this.db
1155
+ .prepare("SELECT record_id FROM l1_records WHERE ext_key = ? AND ext_key <> '' LIMIT 1")
1156
+ .get(extKey);
1157
+ return row?.record_id;
1158
+ }
1159
+ catch {
1160
+ return undefined;
1161
+ }
1162
+ }
1137
1163
  /**
1138
1164
  * 游标分批取 L1 元信息(**仅三列**):后台巡检专用。
1139
1165
  *
@@ -1,16 +1,3 @@
1
- /**
2
- * 模型可调用工具:memory_search(L1)、conversation_search(L0)、memory_read_scene(L2/L3)、
3
- * 高权限 memory_add / memory_delete。
4
- *
5
- * 会话档位联动:execute 的 exec.agent 即发起调用的 agent(agent.id === sessionId),
6
- * memory_search 按会话档位过滤族(auto 不过滤,纯档只查本族);off 档下读类工具统一
7
- * 返回提示(本会话已对记忆系统隐身)。conversation_search 检索范围保持全库。
8
- * 只写会话(注入覆盖=关)同款拒读,notice 区分文案——写入走捕获钩子不经工具,
9
- * 拒读不影响"只写"语义。
10
- *
11
- * 写删工具受 live.memoryMutate 高权限门控(默认 false):显式记忆指令语义——
12
- * 用户说"记得 X" → memory_add;说"忘了/删除该记忆" → memory_delete。
13
- */
14
1
  import type { Context } from '@deepseek-ai/cordis';
15
2
  import type { MemoryConfig } from '../config.js';
16
3
  import type { LiveSettingsHandle } from '../settings.js';
@@ -1,3 +1,17 @@
1
+ /**
2
+ * 模型可调用工具:memory_search(L1)、conversation_search(L0)、memory_read_scene(L2/L3)、
3
+ * 高权限 memory_add / memory_delete。
4
+ *
5
+ * 会话档位联动:execute 的 exec.agent 即发起调用的 agent(agent.id === sessionId),
6
+ * memory_search 按会话档位过滤族(auto 不过滤,纯档只查本族);off 档下读类工具统一
7
+ * 返回提示(本会话已对记忆系统隐身)。conversation_search 检索范围保持全库。
8
+ * 只写会话(注入覆盖=关)同款拒读,notice 区分文案——写入走捕获钩子不经工具,
9
+ * 拒读不影响"只写"语义。
10
+ *
11
+ * 写删工具受 live.memoryMutate 高权限门控(默认 false):显式记忆指令语义——
12
+ * 用户说"记得 X" → memory_add;说"忘了/删除该记忆" → memory_delete。
13
+ */
14
+ import { createHash } from 'node:crypto';
1
15
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
16
  import { RECEIPTS_QUERY_LIMIT_MAX, dimensionOf, toReceiptView } from '../store/receipts.js';
3
17
  import { listConflictPairs, renderConflictResolution, renderConflicts, resolveConflictPair } from '../conflict-service.js';
@@ -491,7 +505,14 @@ roomRegistry) {
491
505
  * 时间轴同时写顶层字段(时间增强列)与 metadata(列迁移前的兼容层,
492
506
  * 也是面板与图谱时间锚的读取点);`cf`/`rw` 落在 metadata.conflict/rewritten。
493
507
  */
494
- function buildRecord(item, sceneName, now, workspaceId) {
508
+ /** 外部导入幂等键:sha1(source_id \0 稳定身份)。身份取 dedupe_key,缺省用内容规范化。 */
509
+ const computeExtKey = (sourceId, item, content) => {
510
+ const identity = typeof item.dedupe_key === 'string' && item.dedupe_key.trim()
511
+ ? item.dedupe_key.trim()
512
+ : content.replace(/\s+/g, ' ').toLowerCase();
513
+ return createHash('sha1').update(`${sourceId}\u0000${identity}`, 'utf8').digest('hex');
514
+ };
515
+ function buildRecord(item, sceneName, now, workspaceId, sourceId) {
495
516
  const content = String(item.content ?? '').trim();
496
517
  const type = ADD_TYPES.includes(String(item.type ?? '')) ? String(item.type) : 'episodic';
497
518
  const family = type.startsWith('work') ? 'work' : 'chat';
@@ -536,6 +557,7 @@ roomRegistry) {
536
557
  ...(validFrom !== undefined ? { validFrom } : {}),
537
558
  ...(validTo !== undefined ? { validTo } : {}),
538
559
  ...(persistence !== undefined ? { persistence } : {}),
560
+ ...(sourceId && sourceId.trim() ? { extKey: computeExtKey(sourceId.trim(), item, content) } : {}),
539
561
  };
540
562
  }
541
563
  /** 时间轴/溯源字段的可选入参(两个写工具共用同一形状)。 */
@@ -618,6 +640,10 @@ roomRegistry) {
618
640
  },
619
641
  ...writeFields,
620
642
  priority: { type: 'number', description: '优先级 0-100(缺省 80)' },
643
+ dedupe_key: {
644
+ type: 'string',
645
+ description: '可选稳定身份(外部包内该条的稳定 id);与顶层 source_id 组合算幂等键,缺省用内容规范化哈希',
646
+ },
621
647
  },
622
648
  },
623
649
  },
@@ -625,12 +651,17 @@ roomRegistry) {
625
651
  type: 'string',
626
652
  description: `归属场景名(缺省 ${DEFAULT_IMPORT_SCENE};建议用"${DEFAULT_IMPORT_SCENE}/<来源>")`,
627
653
  },
654
+ source_id: {
655
+ type: 'string',
656
+ description: '来源标识(如 "workbuddy"/"claude-code"/外部包名);非空时启用幂等——同一来源重复导入相同 dedupe_key/内容会被跳过并计入 deduped',
657
+ },
628
658
  },
629
659
  output: {
630
660
  schema: {
631
661
  type: 'object',
632
662
  properties: {
633
663
  written: { type: 'number' },
664
+ deduped: { type: 'number' },
634
665
  ids: { type: 'array', items: { type: 'string' } },
635
666
  skipped: {
636
667
  type: 'array',
@@ -653,15 +684,16 @@ roomRegistry) {
653
684
  },
654
685
  execute: async (args, exec) => {
655
686
  if (!live.get().memoryMutate) {
656
- return { written: 0, ids: [], skipped: [], notice: MUTATE_OFF_NOTICE };
687
+ return { written: 0, deduped: 0, ids: [], skipped: [], notice: MUTATE_OFF_NOTICE };
657
688
  }
658
689
  const raw = Array.isArray(args.records) ? args.records : [];
659
690
  if (raw.length === 0) {
660
- return { written: 0, ids: [], skipped: [], notice: 'records 为空,未写入' };
691
+ return { written: 0, deduped: 0, ids: [], skipped: [], notice: 'records 为空,未写入' };
661
692
  }
662
693
  if (raw.length > MAX_IMPORT) {
663
694
  return {
664
695
  written: 0,
696
+ deduped: 0,
665
697
  ids: [],
666
698
  skipped: [],
667
699
  notice: `单次上限 ${MAX_IMPORT} 条(收到 ${raw.length} 条),请拆批导入`,
@@ -670,10 +702,13 @@ roomRegistry) {
670
702
  const scene = typeof args.scene === 'string' && args.scene.trim()
671
703
  ? args.scene.trim().slice(0, 120)
672
704
  : DEFAULT_IMPORT_SCENE;
705
+ const sourceId = typeof args.source_id === 'string' ? args.source_id.trim() : '';
673
706
  const now = Date.now();
674
707
  const seen = new Set();
708
+ const seenExt = new Set();
675
709
  const records = [];
676
710
  const skipped = [];
711
+ let deduped = 0;
677
712
  raw.forEach((item, index) => {
678
713
  const content = String(item?.content ?? '').trim();
679
714
  if (!content) {
@@ -686,14 +721,30 @@ roomRegistry) {
686
721
  return;
687
722
  }
688
723
  seen.add(key);
689
- const record = buildRecord(item, scene, now, workspaceIdOf(exec));
724
+ // 幂等(轨3-P0):带 source_id 时按 ext_key 拦截重复——先查库内、再查本批,
725
+ // 命中即计入 deduped 并跳过(不写 JSONL、不写检索库)。无 source_id 保持既有逐字行为。
726
+ if (sourceId) {
727
+ const extKey = computeExtKey(sourceId, item, content);
728
+ if (seenExt.has(extKey)) {
729
+ deduped++;
730
+ skipped.push({ index, reason: '与本批前面的记录外部键重复(幂等跳过)' });
731
+ return;
732
+ }
733
+ if (stores.l1.findIdByExtKey(extKey)) {
734
+ deduped++;
735
+ skipped.push({ index, reason: '库内已存在同外部键记录(幂等跳过)' });
736
+ return;
737
+ }
738
+ seenExt.add(extKey);
739
+ }
740
+ const record = buildRecord(item, scene, now, workspaceIdOf(exec), sourceId);
690
741
  applyManualRedaction(record, cfg, logger);
691
742
  records.push(record);
692
743
  });
693
744
  if (records.length > 0)
694
745
  await stores.l1.appendNew(records);
695
- logger.info(`[memory] 批量导入 ${records.length} 条(跳过 ${skipped.length} 条,场景 ${scene})`);
696
- return { written: records.length, ids: records.map((r) => r.id), skipped };
746
+ logger.info(`[memory] 批量导入 ${records.length} 条(幂等跳过 ${deduped} 条,场景 ${scene}${sourceId ? `,来源 ${sourceId}` : ''})`);
747
+ return { written: records.length, deduped, ids: records.map((r) => r.id), skipped };
697
748
  },
698
749
  })));
699
750
  // memory_delete:显式"忘了 X"——按语义检索命中后删除(高权限门控)。
@@ -1222,7 +1273,7 @@ roomRegistry) {
1222
1273
  if (family === null) {
1223
1274
  return { enabled: false, total: 0, items: [], notice: blockNoticeOf(exec) };
1224
1275
  }
1225
- return listConflictPairs({ l1: stores.l1, conflictFreezeEnabled: live.get().conflictFreeze === true }, { limit: typeof args.limit === 'number' ? args.limit : undefined });
1276
+ return listConflictPairs({ l1: stores.l1, conflictFreezeEnabled: live.get().conflictFreeze === true, decayHalfLifeDays: cfg.recall?.decayHalfLifeDays }, { limit: typeof args.limit === 'number' ? args.limit : undefined });
1226
1277
  },
1227
1278
  })));
1228
1279
  // ── memory_conflicts_rejected: §C 丢弃留痕的读出口 ──
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 技能工具面(轨2)。**整块仅在 cfg.skills.enabled 时注册**——默认关时零表面积
3
+ * (对齐 benchControl"生产部署零表面积"先例,避免注册了却被门控的空壳工具污染模型工具列表)。
4
+ *
5
+ * 读工具 list/read;写工具 pin/archive 过 live.memoryMutate 高权限门(与 memory_add 同门)。
6
+ * 技能不是 memory `type`,这些工具只操作 SkillStore sidecar,不触碰 l1_records / ADD_TYPES / prompt 词表。
7
+ */
8
+ import type { Context } from '@deepseek-ai/cordis';
9
+ import type { MemoryConfig } from '../config.js';
10
+ import type { LiveSettingsHandle } from '../settings.js';
11
+ import type { SkillStore } from '../store/skill-store.js';
12
+ import type { MemoryLogger } from '../types.js';
13
+ export declare function registerSkillTools(ctx: Context, cfg: MemoryConfig, skills: SkillStore, logger: MemoryLogger, live: LiveSettingsHandle): void;