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.
- package/CHANGELOG.en.md +401 -0
- package/CHANGELOG.ja.md +401 -0
- package/CHANGELOG.ko.md +401 -0
- package/CHANGELOG.md +100 -0
- package/dist/client.js +1951 -1188
- package/dist/config.d.ts +176 -1240
- package/dist/config.js +113 -2
- package/dist/conflict-service.d.ts +12 -0
- package/dist/conflict-service.js +24 -0
- package/dist/contract.d.ts +327 -0
- package/dist/domain-gate.d.ts +19 -0
- package/dist/domain-gate.js +45 -0
- package/dist/graph/constraints.d.ts +4 -2
- package/dist/graph/constraints.js +4 -2
- package/dist/hooks/obsolete-notice.d.ts +26 -0
- package/dist/hooks/obsolete-notice.js +64 -0
- package/dist/hooks/recall.d.ts +41 -0
- package/dist/hooks/recall.js +127 -28
- package/dist/hooks/skill-recall.d.ts +25 -0
- package/dist/hooks/skill-recall.js +72 -0
- package/dist/index.d.ts +1 -1029
- package/dist/index.js +55 -2
- package/dist/memory-types.d.ts +69 -0
- package/dist/memory-types.js +165 -0
- package/dist/memport/format.d.ts +75 -0
- package/dist/memport/format.js +345 -0
- package/dist/memport/prefilter.d.ts +62 -0
- package/dist/memport/prefilter.js +132 -0
- package/dist/memport/scan.d.ts +13 -0
- package/dist/memport/scan.js +100 -0
- package/dist/metadata-validators.d.ts +22 -0
- package/dist/metadata-validators.js +45 -0
- package/dist/pipeline/l1.d.ts +44 -2
- package/dist/pipeline/l1.js +177 -9
- package/dist/pipeline/md-sync.d.ts +65 -0
- package/dist/pipeline/md-sync.js +456 -0
- package/dist/pipeline/ruminate.d.ts +46 -1
- package/dist/pipeline/ruminate.js +150 -1
- package/dist/pipeline/runner.d.ts +10 -0
- package/dist/pipeline/runner.js +7 -1
- package/dist/pipeline/slot-migrate.d.ts +45 -0
- package/dist/pipeline/slot-migrate.js +45 -0
- package/dist/pipeline/workspace-relink.d.ts +60 -0
- package/dist/pipeline/workspace-relink.js +73 -0
- package/dist/projection/recall.d.ts +112 -0
- package/dist/projection/recall.js +183 -0
- package/dist/projection/slots.d.ts +5 -0
- package/dist/prompts/examples.d.ts +73 -0
- package/dist/prompts/examples.js +178 -0
- package/dist/prompts/graph-projection.js +3 -0
- package/dist/prompts/l1-dedup.d.ts +9 -3
- package/dist/prompts/l1-dedup.js +34 -13
- package/dist/prompts/l1-extraction.d.ts +9 -4
- package/dist/prompts/l1-extraction.js +76 -8
- package/dist/settings.d.ts +8 -0
- package/dist/settings.js +15 -1
- package/dist/skills/crystallize.d.ts +41 -0
- package/dist/skills/crystallize.js +75 -0
- package/dist/stats.d.ts +30 -1
- package/dist/stats.js +504 -13
- package/dist/store/embedding-source.d.ts +8 -0
- package/dist/store/embedding-source.js +39 -2
- package/dist/store/file-versions.d.ts +4 -0
- package/dist/store/file-versions.js +4 -0
- package/dist/store/graph-store.d.ts +14 -0
- package/dist/store/graph-store.js +35 -0
- package/dist/store/l1-snapshot.d.ts +26 -1
- package/dist/store/l1-snapshot.js +53 -8
- package/dist/store/l1.d.ts +77 -3
- package/dist/store/l1.js +128 -3
- package/dist/store/memory-md-archive.d.ts +84 -0
- package/dist/store/memory-md-archive.js +155 -0
- package/dist/store/memory-md-disable.d.ts +23 -0
- package/dist/store/memory-md-disable.js +106 -0
- package/dist/store/memory-md-export.d.ts +50 -0
- package/dist/store/memory-md-export.js +200 -0
- package/dist/store/memory-md-ledger.d.ts +94 -0
- package/dist/store/memory-md-ledger.js +243 -0
- package/dist/store/memory-md-orphan.d.ts +27 -0
- package/dist/store/memory-md-orphan.js +153 -0
- package/dist/store/memory-md-parse.d.ts +35 -0
- package/dist/store/memory-md-parse.js +85 -0
- package/dist/store/memory-md-skeleton.d.ts +18 -0
- package/dist/store/memory-md-skeleton.js +61 -0
- package/dist/store/memory-md-types.d.ts +174 -0
- package/dist/store/memory-md-types.js +10 -0
- package/dist/store/memory-md.d.ts +108 -0
- package/dist/store/memory-md.js +219 -0
- package/dist/store/memory-sources.d.ts +59 -0
- package/dist/store/memory-sources.js +214 -0
- package/dist/store/obsolete-notices.d.ts +39 -0
- package/dist/store/obsolete-notices.js +153 -0
- package/dist/store/recall-feedback.d.ts +75 -0
- package/dist/store/recall-feedback.js +213 -0
- package/dist/store/recall-usage.d.ts +11 -0
- package/dist/store/recall-usage.js +29 -0
- package/dist/store/session-recalled.d.ts +60 -0
- package/dist/store/session-recalled.js +310 -0
- package/dist/store/skill-store.d.ts +99 -0
- package/dist/store/skill-store.js +330 -0
- package/dist/store/sqlite.d.ts +71 -0
- package/dist/store/sqlite.js +259 -15
- package/dist/store/supersede.d.ts +1 -1
- package/dist/store/supersede.js +1 -1
- package/dist/tools/index.d.ts +4 -13
- package/dist/tools/index.js +80 -12
- package/dist/tools/skills.d.ts +13 -0
- package/dist/tools/skills.js +130 -0
- package/dist/types.d.ts +30 -7
- package/dist/types.js +10 -2
- package/dist/util/maturity.d.ts +44 -0
- package/dist/util/maturity.js +43 -0
- package/dist/util/md-name.d.ts +129 -0
- package/dist/util/md-name.js +366 -0
- package/dist/util/md-normalize.d.ts +23 -0
- package/dist/util/md-normalize.js +131 -0
- package/dist/wing-backfill.js +7 -4
- package/dist/workspace-route.d.ts +22 -0
- package/dist/workspace-route.js +66 -0
- package/dsh.plugin.json +1 -1
- package/package.json +6 -5
package/dist/store/sqlite.js
CHANGED
|
@@ -28,6 +28,20 @@ import { bm25RankToScore, buildFtsQuery, tokenizeForFts } from './search-utils.j
|
|
|
28
28
|
import { describeTokenizer, ensureTokenizer, tokenizerStamp } from '../util/tokenizer.js';
|
|
29
29
|
const require = createRequire(import.meta.url);
|
|
30
30
|
const TAG = '[memory][sqlite]';
|
|
31
|
+
/**
|
|
32
|
+
* 退场判据的 **SQL 侧同口径表达**(与 `isRetired` / `upsertL1InTx` 里的 `retiredNow` 一一对应):
|
|
33
|
+
* `valid_to` 非空 **或** `metadata_json` 带退场标记。
|
|
34
|
+
*
|
|
35
|
+
* 为什么仓内只能有一份:退场记录有**三条检索面写路径绕过 upsert 漏斗**——
|
|
36
|
+
* FTS 重建回灌、增量重嵌入取数、补齐判据计数。任一处漏了就是:
|
|
37
|
+
* ① 退场行被放回检索面(前两处;重嵌入是 30 分钟一轮,比 FTS 重建更易命中);
|
|
38
|
+
* ② 补齐判据永不收敛(第三处:缺向量的退场行永远算“还差”,却永远不该被补)。
|
|
39
|
+
* 判据分头写,三处迟早各自漂移 —— 故集中成这一个函数。
|
|
40
|
+
*/
|
|
41
|
+
function retiredL1Sql(alias) {
|
|
42
|
+
const p = alias ? `${alias}.` : '';
|
|
43
|
+
return `COALESCE(${p}valid_to, '') <> '' OR ${p}metadata_json LIKE '%"${SUPERSEDE_METADATA_KEY}"%'`;
|
|
44
|
+
}
|
|
31
45
|
// 成本账本(token_cost 表族)独立职责,此处 re-export 不断裂既有引用;
|
|
32
46
|
// MemoryDb 的四个公开方法为一行委托。
|
|
33
47
|
import { CostLedger } from './cost-ledger.js';
|
|
@@ -35,7 +49,7 @@ import { CostLedger } from './cost-ledger.js';
|
|
|
35
49
|
import { GraphStore } from './graph-store.js';
|
|
36
50
|
import { RECEIPTS_MAX_RUNS, RECEIPTS_QUERY_LIMIT_MAX } from './receipts.js';
|
|
37
51
|
import { DEFER_MAX, groupConflictPairsByClaim, normalizeClaimKey, normalizeConflictType } from './conflicts.js';
|
|
38
|
-
import { isRetired, readSupersedeMarker, stripSupersedeMarker, withSupersedeMarker } from './supersede.js';
|
|
52
|
+
import { isRetired, readSupersedeMarker, stripSupersedeMarker, withSupersedeMarker, SUPERSEDE_METADATA_KEY } from './supersede.js';
|
|
39
53
|
/** vec0 KNN 对遗留零向量的补偿缓冲。 */
|
|
40
54
|
const ZERO_VEC_BUFFER = 10;
|
|
41
55
|
/** IN 查询/删除的分块大小(保守避开 SQLite 变量数上限:现代构建 32766,老版 999)。 */
|
|
@@ -318,6 +332,24 @@ export class MemoryDb {
|
|
|
318
332
|
this.db.exec("ALTER TABLE l1_records ADD COLUMN applicability TEXT NOT NULL DEFAULT ''");
|
|
319
333
|
this.logger?.info(`${TAG} l1_records 补 repo 归属列(DEFAULT 即标注,存量零回填)`);
|
|
320
334
|
}
|
|
335
|
+
// ── 外部导入幂等键(轨3-P0):ext_key='' 即内部记录(不参与去重),DEFAULT '' 存量零搬运。
|
|
336
|
+
// 部分唯一索引只约束非空值——多条内部记录 ext_key 同为 '' 互不冲突;仅当外部导入的
|
|
337
|
+
// 同一 (来源, 稳定身份) 重复导入才被结构性拒绝(写侧 pre-check + 索引双保险)。
|
|
338
|
+
if (!this.hasColumn('l1_records', 'ext_key')) {
|
|
339
|
+
this.db.exec("ALTER TABLE l1_records ADD COLUMN ext_key TEXT NOT NULL DEFAULT ''");
|
|
340
|
+
this.logger?.info(`${TAG} l1_records 补 ext_key 列(外部导入幂等键)`);
|
|
341
|
+
}
|
|
342
|
+
this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS l1_records_ext_key_uidx ON l1_records(ext_key) WHERE ext_key <> ''");
|
|
343
|
+
// ── 文件身份键(md 投射相, N1/N8/N9):`name` 是人可读且可编辑的稳定键,用来当
|
|
344
|
+
// `<root>/<type>/<name>.memory.md` 的文件名。为何不用 id:id 每轮新铸、从不复用;
|
|
345
|
+
// 为何不用 scene_name:实测基数大于记录数,当不了唯一名。
|
|
346
|
+
// 存量 name 恒为 '' ⇒ 部分索引不约束空串 ⇒ 旧库首次建索引必成(不会因重复值而
|
|
347
|
+
// 整库 degraded)。该列**不进** canonicalRecords 哈希字段表 ⇒ 快照零影响。
|
|
348
|
+
if (!this.hasColumn('l1_records', 'name')) {
|
|
349
|
+
this.db.exec("ALTER TABLE l1_records ADD COLUMN name TEXT NOT NULL DEFAULT ''");
|
|
350
|
+
this.logger?.info(`${TAG} l1_records 补 name 列(文件身份键,存量全空)`);
|
|
351
|
+
}
|
|
352
|
+
this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS l1_records_name_uidx ON l1_records(name) WHERE name <> ''");
|
|
321
353
|
// L2 场景重聚类作业队列(治理 W3 T3.10;ruminate 空闲档消费,每轮至多 1 作业)。
|
|
322
354
|
// source 区分触发来源('demote'|'repo-change'),便于诊断与重入队语义区分。
|
|
323
355
|
this.db.exec(`
|
|
@@ -434,8 +466,8 @@ export class MemoryDb {
|
|
|
434
466
|
record_id, content, type, priority, scene_name, session_id, version,
|
|
435
467
|
timestamp_str, timestamp_start, timestamp_end, created_time, updated_time, metadata_json, family,
|
|
436
468
|
valid_from, valid_to, persistence, scope, workspace_id,
|
|
437
|
-
repo_key_name, repo_key_owner, applicability
|
|
438
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
469
|
+
repo_key_name, repo_key_owner, applicability, ext_key, name
|
|
470
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
439
471
|
ON CONFLICT(record_id) DO UPDATE SET
|
|
440
472
|
content=excluded.content,
|
|
441
473
|
type=excluded.type,
|
|
@@ -455,13 +487,15 @@ export class MemoryDb {
|
|
|
455
487
|
workspace_id=excluded.workspace_id,
|
|
456
488
|
repo_key_name=excluded.repo_key_name,
|
|
457
489
|
repo_key_owner=excluded.repo_key_owner,
|
|
458
|
-
applicability=excluded.applicability
|
|
490
|
+
applicability=excluded.applicability,
|
|
491
|
+
ext_key=excluded.ext_key,
|
|
492
|
+
name=excluded.name
|
|
459
493
|
`);
|
|
460
494
|
this.stmtGetL1 = this.db.prepare(`
|
|
461
495
|
SELECT record_id, content, type, priority, scene_name, version, timestamp_str,
|
|
462
496
|
timestamp_start, timestamp_end, created_time, updated_time, metadata_json, family,
|
|
463
497
|
valid_from, valid_to, persistence, scope, workspace_id,
|
|
464
|
-
repo_key_name, repo_key_owner, applicability
|
|
498
|
+
repo_key_name, repo_key_owner, applicability, name
|
|
465
499
|
FROM l1_records WHERE record_id = ?
|
|
466
500
|
`);
|
|
467
501
|
this.stmtL1Exists = this.db.prepare('SELECT 1 FROM l1_records WHERE record_id = ?');
|
|
@@ -602,6 +636,10 @@ export class MemoryDb {
|
|
|
602
636
|
`);
|
|
603
637
|
if (ftsRebuilt)
|
|
604
638
|
this.backfillL1Fts();
|
|
639
|
+
// 退场残留清理(幂等):历史上退场不变量只在写入漏斗上强制,FTS 重建回灌与
|
|
640
|
+
// 增量重嵌入两条旁路会把已退场记录留在检索面里(实测本机 725 行)。
|
|
641
|
+
// 上面的口径修复只挡新增,已有的要在这里收掉——否则那 725 条永远脏着。
|
|
642
|
+
this.purgeRetiredRetrievalRows();
|
|
605
643
|
this.stmtL0FtsInsert = this.db.prepare(`
|
|
606
644
|
INSERT INTO l0_fts (message_text, message_text_original, record_id, session_id, role, recorded_at, timestamp)
|
|
607
645
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
@@ -735,10 +773,14 @@ export class MemoryDb {
|
|
|
735
773
|
*/
|
|
736
774
|
backfillL1Fts() {
|
|
737
775
|
let count = 0;
|
|
776
|
+
// 退场行不回灌(理由见 `retiredL1Sql`)。跳过数单独数一次:重建是低频动作,
|
|
777
|
+
// 多一条 COUNT 换“运维看得见跳过了多少”比静默少灌划算。
|
|
778
|
+
const retiredSkipped = this.countL1Retired();
|
|
738
779
|
const stmt = this.db
|
|
739
780
|
.prepare(`SELECT record_id, content, type, priority, scene_name, session_id, version,
|
|
740
781
|
timestamp_str, timestamp_start, timestamp_end, metadata_json, family,
|
|
741
|
-
scope, workspace_id FROM l1_records
|
|
782
|
+
scope, workspace_id FROM l1_records
|
|
783
|
+
WHERE NOT (${retiredL1Sql()})`);
|
|
742
784
|
for (const r of stmt.iterate()) {
|
|
743
785
|
try {
|
|
744
786
|
this.stmtL1FtsInsert.run(tokenizeForFts(String(r.content ?? '')), String(r.content ?? ''), String(r.record_id ?? ''), String(r.type ?? ''), Number(r.priority ?? 50), String(r.scene_name ?? ''), String(r.session_id ?? 'default'), Number(r.version ?? 0), String(r.timestamp_str ?? ''), String(r.timestamp_start ?? ''), String(r.timestamp_end ?? ''), String(r.metadata_json ?? '{}'), String(r.family ?? 'chat'),
|
|
@@ -751,8 +793,52 @@ export class MemoryDb {
|
|
|
751
793
|
/* 单行失败跳过(吞+计数:count 与总量比对可发现大面积失败) */
|
|
752
794
|
}
|
|
753
795
|
}
|
|
754
|
-
if (count > 0)
|
|
755
|
-
this.logger?.info(`${TAG} l1_fts 回灌 ${count}
|
|
796
|
+
if (count > 0 || retiredSkipped !== 0) {
|
|
797
|
+
this.logger?.info(`${TAG} l1_fts 回灌 ${count} 行(跳过已退场 ${retiredSkipped < 0 ? '计数失败' : retiredSkipped} 行)`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* 已退场 L1 行数。原本只给回灌留痕用,现在展示面也要它:
|
|
802
|
+
* `vectorCounts` 算“待补”时得分掉退场行(它们合法地无向量),否则那个数永远归不了零。
|
|
803
|
+
* 失败返回 -1——宁可日志写“未知”也不假装 0 条。
|
|
804
|
+
*/
|
|
805
|
+
countL1Retired() {
|
|
806
|
+
try {
|
|
807
|
+
const row = this.db.prepare(`SELECT COUNT(*) AS n FROM l1_records WHERE ${retiredL1Sql()}`).all()[0];
|
|
808
|
+
return row?.n ?? 0;
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
return -1;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* 清除“已退场但仍待在检索面”的残留行(FTS + 向量)。幂等:第二次跑命中 0 行。
|
|
816
|
+
*
|
|
817
|
+
* 为什么必须存在于启动路径:口径修复只挡住**新增**的脏行,而存量脏行仍在给
|
|
818
|
+
* BM25/向量检索供货——对用户而言就是“标了失效却还能被召回”。
|
|
819
|
+
* 失败只 warn:这是个修复动作,无权把库拖进 degraded,更不能抛进启动
|
|
820
|
+
* 的 await 链(那里报错 = storageOk=false = 记忆功能整体停用)。
|
|
821
|
+
*/
|
|
822
|
+
purgeRetiredRetrievalRows() {
|
|
823
|
+
const sub = `SELECT record_id FROM l1_records WHERE ${retiredL1Sql()}`;
|
|
824
|
+
// 能力不在就别发 DELETE——每次启动一条无谓告警会把真问题埋掉。
|
|
825
|
+
const targets = [
|
|
826
|
+
{ table: 'l1_fts', ready: this.stmtL1FtsInsert !== undefined },
|
|
827
|
+
{ table: 'l1_vec', ready: this.stmtDeleteL1Vec !== undefined && this.tableExists('l1_vec') },
|
|
828
|
+
];
|
|
829
|
+
let purged = 0;
|
|
830
|
+
for (const t of targets) {
|
|
831
|
+
if (!t.ready)
|
|
832
|
+
continue;
|
|
833
|
+
try {
|
|
834
|
+
purged += Number(this.db.prepare(`DELETE FROM ${t.table} WHERE record_id IN (${sub})`).run().changes);
|
|
835
|
+
}
|
|
836
|
+
catch (err) {
|
|
837
|
+
this.logger?.warn(`${TAG} 退场残留清理失败(${t.table},本轮跳过): ${err instanceof Error ? err.message : String(err)}`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (purged > 0)
|
|
841
|
+
this.logger?.info(`${TAG} 清理退场残留检索行 ${purged} 条(FTS + 向量)`);
|
|
756
842
|
}
|
|
757
843
|
/** 重建后的 l0_fts 从 l0_conversations 全量回灌(仅 drop 重建时调用;iterate 流式)。 */
|
|
758
844
|
backfillL0Fts() {
|
|
@@ -907,7 +993,10 @@ export class MemoryDb {
|
|
|
907
993
|
// 旧版导入都会经这里写回记录,若照常重建 FTS/向量行,一条带退场标记的记录
|
|
908
994
|
// 会**悄悄回到检索结果里**(而检索侧刻意不看 valid_to,正是为了零漂移)。
|
|
909
995
|
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)
|
|
996
|
+
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 ?? '',
|
|
997
|
+
// 文件身份键。存什么由调用方定(`deriveName` 已按 `sanitizeFilename` 纪律截断/洗字符),
|
|
998
|
+
// 这里只做 undefined → '' 的绑定兜底,与同一行其他可选列同口径。
|
|
999
|
+
record.name ?? '');
|
|
911
1000
|
// vec0 不支持 ON CONFLICT → 先删后插;零向量跳过(cosine 未定义)。
|
|
912
1001
|
// 已退场则**只删不插**(见上方 retiredNow 的说明)。
|
|
913
1002
|
if (this.stmtDeleteL1Vec && this.stmtInsertL1Vec) {
|
|
@@ -1050,7 +1139,7 @@ export class MemoryDb {
|
|
|
1050
1139
|
.prepare("SELECT COUNT(*) AS n FROM l1_records WHERE COALESCE(valid_to, '') <> ''")
|
|
1051
1140
|
.get();
|
|
1052
1141
|
const rows = this.db
|
|
1053
|
-
.prepare(`SELECT record_id, content, type, priority, scene_name, version, timestamp_str, created_time, updated_time, metadata_json, family, valid_from, valid_to, persistence, scope, workspace_id, repo_key_name, repo_key_owner, applicability
|
|
1142
|
+
.prepare(`SELECT record_id, content, type, priority, scene_name, version, timestamp_str, created_time, updated_time, metadata_json, family, valid_from, valid_to, persistence, scope, workspace_id, repo_key_name, repo_key_owner, applicability, name
|
|
1054
1143
|
FROM l1_records WHERE COALESCE(valid_to, '') <> '' ORDER BY updated_time DESC LIMIT ? OFFSET ?`)
|
|
1055
1144
|
.all(opts.limit, opts.offset);
|
|
1056
1145
|
return { items: rows.map(rowToRecord), total: Number(totalRow?.n ?? 0) };
|
|
@@ -1076,10 +1165,14 @@ export class MemoryDb {
|
|
|
1076
1165
|
const repoCols = table === 'l1_records' && this.hasColumn('l1_records', 'repo_key_name')
|
|
1077
1166
|
? ', repo_key_name, repo_key_owner, applicability'
|
|
1078
1167
|
: '';
|
|
1168
|
+
// 文件身份键(md 投射相):同款形状探测。没这一列的旧库读侧归一 undefined,
|
|
1169
|
+
// 不报错——投射器把空 name 回落到 `mem-<id8>`,而不是把整条取记录路径弄挂。
|
|
1170
|
+
const nameCol = table === 'l1_records' && this.hasColumn('l1_records', 'name') ? ', name' : '';
|
|
1079
1171
|
const metaCols = 'record_id, content, type, priority, scene_name, version, timestamp_str, created_time, updated_time, metadata_json, family' +
|
|
1080
1172
|
temporal +
|
|
1081
1173
|
scopeCols +
|
|
1082
|
-
repoCols
|
|
1174
|
+
repoCols +
|
|
1175
|
+
nameCol;
|
|
1083
1176
|
stmt =
|
|
1084
1177
|
action === 'delete'
|
|
1085
1178
|
? this.db.prepare(`DELETE FROM ${table} WHERE record_id IN (${ph})`)
|
|
@@ -1134,6 +1227,23 @@ export class MemoryDb {
|
|
|
1134
1227
|
return 0;
|
|
1135
1228
|
}
|
|
1136
1229
|
}
|
|
1230
|
+
/**
|
|
1231
|
+
* 按外部导入幂等键查已存在记录 id(轨3-P0 幂等 pre-check)。
|
|
1232
|
+
* 空键/降级一律返回 undefined;未迁移旧库(无 ext_key 列)由 catch 兜底 undefined(fail-open)。
|
|
1233
|
+
*/
|
|
1234
|
+
findRecordIdByExtKey(extKey) {
|
|
1235
|
+
if (this.degraded || !extKey)
|
|
1236
|
+
return undefined;
|
|
1237
|
+
try {
|
|
1238
|
+
const row = this.db
|
|
1239
|
+
.prepare("SELECT record_id FROM l1_records WHERE ext_key = ? AND ext_key <> '' LIMIT 1")
|
|
1240
|
+
.get(extKey);
|
|
1241
|
+
return row?.record_id;
|
|
1242
|
+
}
|
|
1243
|
+
catch {
|
|
1244
|
+
return undefined;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1137
1247
|
/**
|
|
1138
1248
|
* 游标分批取 L1 元信息(**仅三列**):后台巡检专用。
|
|
1139
1249
|
*
|
|
@@ -1202,7 +1312,138 @@ export class MemoryDb {
|
|
|
1202
1312
|
return 0;
|
|
1203
1313
|
}
|
|
1204
1314
|
}
|
|
1315
|
+
/**
|
|
1316
|
+
* 批量写 `workspace_id`(内容路由 / 存量 re-link 用)。单事务,**刻意不碰 `updated_time`**。
|
|
1317
|
+
*
|
|
1318
|
+
* 为什么不碰 `updated_time`(三条都是硬约束):
|
|
1319
|
+
* ① 它在 `canonicalRecords` 的哈希字段表里——动了就是“静默改写”,会让改动前建的快照
|
|
1320
|
+
* 永久 verify 不通过(`workspace_id` 本身不在表里,所以**只**写它才是快照安全的);
|
|
1321
|
+
* ② 它是时效衰减(agingWeight)与列表排序的输入,改一下归属顺手把记忆“刷陈旧”是意外副作用;
|
|
1322
|
+
* ③ re-link 按固定序分页扫,不写它才不会边扫边漂。
|
|
1323
|
+
*
|
|
1324
|
+
* 为什么连 `l1_fts` 一起改:FTS 行**自带** `workspace_id`(UNINDEXED 随行),
|
|
1325
|
+
* 而检索过滤 `(? = '' OR scope='global' OR workspace_id = ?)` 读的就是 FTS 行那一列。
|
|
1326
|
+
* 只改主表 = 一旦启用围栏,关键词路按旧归属过滤、向量路按新归属过滤,两路结果不一致。
|
|
1327
|
+
* 退场行不在 FTS 里,那条 UPDATE 自然命中 0 行,不是错误。
|
|
1328
|
+
*/
|
|
1329
|
+
setL1WorkspaceIds(items) {
|
|
1330
|
+
if (this.degraded || items.length === 0)
|
|
1331
|
+
return 0;
|
|
1332
|
+
try {
|
|
1333
|
+
// 现值已等于目标值时不写:幂等(第二次跑零写入,不白刷 WAL)。
|
|
1334
|
+
const upd = this.db.prepare("UPDATE l1_records SET workspace_id = ? WHERE record_id = ? AND COALESCE(workspace_id, '') <> ?");
|
|
1335
|
+
const ftsReady = this.stmtL1FtsInsert !== undefined;
|
|
1336
|
+
const ftsUpd = ftsReady ? this.db.prepare('UPDATE l1_fts SET workspace_id = ? WHERE record_id = ?') : null;
|
|
1337
|
+
return this.withTransaction(() => {
|
|
1338
|
+
let changed = 0;
|
|
1339
|
+
for (const it of items) {
|
|
1340
|
+
if (!it.id || !it.workspaceId)
|
|
1341
|
+
continue;
|
|
1342
|
+
if (Number(upd.run(it.workspaceId, it.id, it.workspaceId).changes) === 0)
|
|
1343
|
+
continue;
|
|
1344
|
+
changed++;
|
|
1345
|
+
ftsUpd?.run(it.workspaceId, it.id);
|
|
1346
|
+
}
|
|
1347
|
+
return changed;
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
catch (err) {
|
|
1351
|
+
this.logger?.warn(`${TAG} 批量写 workspace_id 失败(${items.length} 条,本轮全量回退): ${err instanceof Error ? err.message : String(err)}`);
|
|
1352
|
+
return 0;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1205
1355
|
// ── L2 场景重聚类作业队列(治理 W3,T3.10)──────────────────────────────
|
|
1356
|
+
/**
|
|
1357
|
+
* 改一条记录的文件身份名(N1)。三态返回,**不碰 `updated_time`**:它在快照哈希
|
|
1358
|
+
* 字段表里、又是衰减与排序的输入——改名不是“内容变了”,不该顺带把这条记成刚更新过
|
|
1359
|
+
* (与 `setL1WorkspaceIds` 同一个理由)。`name=''` 是合法值(= 没名字,部分索引不约束空串)。
|
|
1360
|
+
*
|
|
1361
|
+
* 唯一性由 `l1_records_name_uidx` 挡:撞名 ⇒ `'dup'`,由调用方决定报错还是让用户先改
|
|
1362
|
+
* 另一条——**不自动加后缀**,那会静默造出语义重叠的第二条记忆。
|
|
1363
|
+
* (另一个口径:机器给**本来没名字**的记录起名时必须消歧,见 `renameL1Batch` 的注释。)
|
|
1364
|
+
*/
|
|
1365
|
+
renameL1(id, name) {
|
|
1366
|
+
if (this.degraded)
|
|
1367
|
+
return 'failed';
|
|
1368
|
+
try {
|
|
1369
|
+
if (this.stmtGetL1.get(id) === undefined)
|
|
1370
|
+
return 'missing';
|
|
1371
|
+
this.db.prepare('UPDATE l1_records SET name = ? WHERE record_id = ?').run(name, id);
|
|
1372
|
+
return 'ok';
|
|
1373
|
+
}
|
|
1374
|
+
catch (err) {
|
|
1375
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1376
|
+
if (msg.includes('UNIQUE constraint failed: l1_records.name'))
|
|
1377
|
+
return 'dup';
|
|
1378
|
+
this.logger?.warn(`${TAG} 改名失败(${id}): ${msg}`);
|
|
1379
|
+
return 'failed';
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* 批量写 `name`(单事务)。给“机器起名”用:存量记录没名字时一次补齐。
|
|
1384
|
+
*
|
|
1385
|
+
* 与 `renameL1` 的分工不是性能而是**语义**:那里是**人给的**名字,撞了就报错让用户
|
|
1386
|
+
* 先改另一条,绝不静默加后缀;这里是机器给**本来没名字**的记录派生名字,同正文的
|
|
1387
|
+
* 多条必然撞名(真机实测 42 条挤进 17 个路径),**不消歧就是每轮导出重刷 + 反向同步
|
|
1388
|
+
* 把一份内容回灌给多条**。所以消歧必须在本层之上先把名字算唯一,本层只负责落库。
|
|
1389
|
+
*
|
|
1390
|
+
* 与 `renameL1` 同样**不碰 `updated_time`**(起名不是“内容变了”),也不动 FTS——
|
|
1391
|
+
* `name` 不在检索面里(`l1_fts` 没这一列)。
|
|
1392
|
+
*
|
|
1393
|
+
* @param items - `{ id, name }`,name 已由调用方保证全库唯一。
|
|
1394
|
+
* @returns 实际写入的 id 集(现值已等于目标值的跳过——幂等)与失败集。
|
|
1395
|
+
*/
|
|
1396
|
+
renameL1Batch(items) {
|
|
1397
|
+
const done = [];
|
|
1398
|
+
const failed = [];
|
|
1399
|
+
if (this.degraded || items.length === 0)
|
|
1400
|
+
return { done, failed: items.map((it) => it.id) };
|
|
1401
|
+
const upd = this.db.prepare("UPDATE l1_records SET name = ? WHERE record_id = ? AND COALESCE(name, '') <> ?");
|
|
1402
|
+
try {
|
|
1403
|
+
this.withTransaction(() => {
|
|
1404
|
+
for (const it of items) {
|
|
1405
|
+
if (!it.id || it.name.length === 0) {
|
|
1406
|
+
failed.push(it.id);
|
|
1407
|
+
continue;
|
|
1408
|
+
}
|
|
1409
|
+
try {
|
|
1410
|
+
const ch = Number(upd.run(it.name, it.id, it.name).changes);
|
|
1411
|
+
// changes=0 两种可能:没这行,或现值已相等。前者要报出去,后者算幂等成功。
|
|
1412
|
+
if (ch === 0 && this.stmtGetL1.get(it.id) === undefined) {
|
|
1413
|
+
failed.push(it.id);
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
done.push(it.id);
|
|
1417
|
+
}
|
|
1418
|
+
catch (err) {
|
|
1419
|
+
// 单条撞唯一索引不影响事务里的其它行(ABORT 只回退当前语句)。
|
|
1420
|
+
this.logger?.warn(`${TAG} 批量起名单条失败(${it.id}): ${err instanceof Error ? err.message : String(err)}`);
|
|
1421
|
+
failed.push(it.id);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
catch (err) {
|
|
1427
|
+
this.logger?.warn(`${TAG} 批量起名事务失败(${items.length} 条): ${err instanceof Error ? err.message : String(err)}`);
|
|
1428
|
+
for (const it of items)
|
|
1429
|
+
if (!done.includes(it.id))
|
|
1430
|
+
failed.push(it.id);
|
|
1431
|
+
}
|
|
1432
|
+
return { done, failed };
|
|
1433
|
+
}
|
|
1434
|
+
/** 按文件身份名反查 record_id(重名预检与 md 回灌用);没这条名 ⇒ undefined。*/
|
|
1435
|
+
findRecordIdByName(name) {
|
|
1436
|
+
if (this.degraded || name.length === 0)
|
|
1437
|
+
return undefined;
|
|
1438
|
+
try {
|
|
1439
|
+
const row = this.db.prepare("SELECT record_id FROM l1_records WHERE name = ? AND name <> '' LIMIT 1").get(name);
|
|
1440
|
+
return row?.record_id;
|
|
1441
|
+
}
|
|
1442
|
+
catch (err) {
|
|
1443
|
+
this.logger?.warn(`${TAG} 按名反查失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
1444
|
+
return undefined;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1206
1447
|
/** 入队一个重聚类作业(demote-to-wiki / repo 归属变化触发;source 区分来源)。 */
|
|
1207
1448
|
enqueueSceneRecluster(family, sceneNames, batchId, source = 'demote') {
|
|
1208
1449
|
const jobId = `recl_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
|
|
@@ -1272,7 +1513,7 @@ export class MemoryDb {
|
|
|
1272
1513
|
if (this.degraded)
|
|
1273
1514
|
return [];
|
|
1274
1515
|
const rows = this.db
|
|
1275
|
-
.prepare('SELECT record_id, content, type, priority, scene_name, version, timestamp_str, created_time, updated_time, metadata_json, family, valid_from, valid_to, persistence, scope, workspace_id, repo_key_name, repo_key_owner, applicability FROM l1_records')
|
|
1516
|
+
.prepare('SELECT record_id, content, type, priority, scene_name, version, timestamp_str, created_time, updated_time, metadata_json, family, valid_from, valid_to, persistence, scope, workspace_id, repo_key_name, repo_key_owner, applicability, name FROM l1_records')
|
|
1276
1517
|
.all();
|
|
1277
1518
|
return rows.map(rowToRecord);
|
|
1278
1519
|
}
|
|
@@ -1617,7 +1858,7 @@ export class MemoryDb {
|
|
|
1617
1858
|
const whereSql = where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '';
|
|
1618
1859
|
const totalRow = this.db.prepare(`SELECT COUNT(*) AS n FROM l1_records${whereSql}`).get(...params);
|
|
1619
1860
|
const rows = this.db
|
|
1620
|
-
.prepare(`SELECT record_id, content, type, priority, scene_name, version, timestamp_str, created_time, updated_time, metadata_json, family, valid_from, valid_to, persistence, scope, workspace_id, repo_key_name, repo_key_owner, applicability FROM l1_records${whereSql} ORDER BY updated_time DESC LIMIT ? OFFSET ?`)
|
|
1861
|
+
.prepare(`SELECT record_id, content, type, priority, scene_name, version, timestamp_str, created_time, updated_time, metadata_json, family, valid_from, valid_to, persistence, scope, workspace_id, repo_key_name, repo_key_owner, applicability, name FROM l1_records${whereSql} ORDER BY updated_time DESC LIMIT ? OFFSET ?`)
|
|
1621
1862
|
.all(...params, opts.limit, opts.offset);
|
|
1622
1863
|
return { items: rows.map(rowToRecord), total: totalRow?.n ?? 0 };
|
|
1623
1864
|
}
|
|
@@ -2164,7 +2405,7 @@ export class MemoryDb {
|
|
|
2164
2405
|
const row = this.db
|
|
2165
2406
|
.prepare(`SELECT COUNT(*) AS n FROM ${srcTable} r
|
|
2166
2407
|
LEFT JOIN ${vecTable} v ON v.record_id = r.record_id
|
|
2167
|
-
WHERE v.record_id IS NULL${notInClause('r.record_id', exclude)}`)
|
|
2408
|
+
WHERE v.record_id IS NULL${kind === 'l1' ? ` AND NOT (${retiredL1Sql('r')})` : ''}${notInClause('r.record_id', exclude)}`)
|
|
2168
2409
|
.all(...notInParams(exclude))[0];
|
|
2169
2410
|
return row?.n ?? 0;
|
|
2170
2411
|
}
|
|
@@ -2186,7 +2427,7 @@ export class MemoryDb {
|
|
|
2186
2427
|
return this.db
|
|
2187
2428
|
.prepare(`SELECT r.record_id AS id, r.content FROM l1_records r
|
|
2188
2429
|
LEFT JOIN l1_vec v ON v.record_id = r.record_id
|
|
2189
|
-
WHERE v.record_id IS NULL${notInClause('r.record_id', exclude)}`)
|
|
2430
|
+
WHERE v.record_id IS NULL AND NOT (${retiredL1Sql('r')})${notInClause('r.record_id', exclude)}`)
|
|
2190
2431
|
.all(...notInParams(exclude));
|
|
2191
2432
|
}
|
|
2192
2433
|
catch (err) {
|
|
@@ -2398,6 +2639,9 @@ function rowToRecord(row) {
|
|
|
2398
2639
|
repoKeyName: row.repo_key_name ?? '',
|
|
2399
2640
|
repoKeyOwner: row.repo_key_owner ?? '',
|
|
2400
2641
|
applicability: normApplicability(row.applicability),
|
|
2642
|
+
// 文件身份键回读:空串归一 undefined(“还没名字”与“名字是空串”不是一种意思),
|
|
2643
|
+
// 缺列的旧库也是 undefined。读侧同样 fail-open。
|
|
2644
|
+
name: row.name || undefined,
|
|
2401
2645
|
};
|
|
2402
2646
|
}
|
|
2403
2647
|
/**
|
|
@@ -32,7 +32,7 @@ export declare const SUPERSEDE_METADATA_KEY = "dsh_superseded";
|
|
|
32
32
|
* 退场原因。**穷举**而非自由字符串:面板要按原因给不同文案与图标,
|
|
33
33
|
* 自由字符串会让"未识别的原因"静默退化成无徽标。
|
|
34
34
|
*/
|
|
35
|
-
export type RetireReason = 'conflict' | 'superseded' | 'manual';
|
|
35
|
+
export type RetireReason = 'conflict' | 'superseded' | 'manual' | 'obsolete';
|
|
36
36
|
/** 取代标记(写进 metadata 的负载)。 */
|
|
37
37
|
export interface SupersedeInfo {
|
|
38
38
|
/** 退场时刻(ISO 8601)。 */
|
package/dist/store/supersede.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 必须靠命名空间隔开,否则一次 LLM 幻觉输出 `superseded` 就能伪装成人工裁决。
|
|
7
7
|
*/
|
|
8
8
|
export const SUPERSEDE_METADATA_KEY = 'dsh_superseded';
|
|
9
|
-
const REASONS = ['conflict', 'superseded', 'manual'];
|
|
9
|
+
const REASONS = ['conflict', 'superseded', 'manual', 'obsolete'];
|
|
10
10
|
/**
|
|
11
11
|
* 写入取代标记(返回**新对象**)。
|
|
12
12
|
*
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -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';
|
|
@@ -30,6 +17,10 @@ export declare function registerMemoryTools(ctx: Context, cfg: MemoryConfig, sto
|
|
|
30
17
|
persona: Record<MemoryFamily, PersonaStore>;
|
|
31
18
|
/** 图谱存储(可选:未装配时图谱工具返回未启用提示)。 */
|
|
32
19
|
graph?: GraphStore;
|
|
20
|
+
/** 人工打分面(可选):冲突视图的成熟度旁注与面板同一口径。 */
|
|
21
|
+
feedback?: {
|
|
22
|
+
lastOf(id: string): number | undefined;
|
|
23
|
+
};
|
|
33
24
|
}, logger: MemoryLogger, modes: SessionModeStore, live: LiveSettingsHandle,
|
|
34
25
|
/** 反刍控制器(可选:未装配时 ruminate 工具返回未启用提示)。 */
|
|
35
26
|
ruminate?: RuminateController,
|
package/dist/tools/index.js
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
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';
|
|
16
|
+
import { DOWNGRADE_TARGET, familyOf, isKnownType, typeEnumSep } from '../memory-types.js';
|
|
2
17
|
import { RECEIPTS_QUERY_LIMIT_MAX, dimensionOf, toReceiptView } from '../store/receipts.js';
|
|
3
18
|
import { listConflictPairs, renderConflictResolution, renderConflicts, resolveConflictPair } from '../conflict-service.js';
|
|
4
19
|
import { WING_CATALOG, WING_FALLBACK, normPersistence, normScope, resolveRecordScope } from '../types.js';
|
|
@@ -167,7 +182,7 @@ roomRegistry) {
|
|
|
167
182
|
parameters: {
|
|
168
183
|
query: { type: 'string', required: true, description: '搜索查询文本(自然语言)' },
|
|
169
184
|
limit: { type: 'number', description: '最大返回条数(默认 5)' },
|
|
170
|
-
type: { type: 'string', description:
|
|
185
|
+
type: { type: 'string', description: `按记忆类型过滤(词表 ${typeEnumSep('/')})` },
|
|
171
186
|
},
|
|
172
187
|
output: {
|
|
173
188
|
schema: {
|
|
@@ -467,7 +482,12 @@ roomRegistry) {
|
|
|
467
482
|
}));
|
|
468
483
|
// ── 写删工具(高权限门控):memory_add / memory_delete ──
|
|
469
484
|
const MUTATE_OFF_NOTICE = '记忆写删未开放:请在记忆库面板开启「高权限模式」后,模型才能写入/删除记忆。';
|
|
470
|
-
|
|
485
|
+
/**
|
|
486
|
+
* 非词表 type 的降级台账(pm-name-distill task_1.4)。旧实现把外部分类**静默改写**成
|
|
487
|
+
* `episodic`,而 memport 文案却承诺“按原样透传”——两套口径对同一件事相反。
|
|
488
|
+
* 现在降级仍发生(写入面需要一个合法值),但**可观测**:每个不同值 warn 一次并计数。
|
|
489
|
+
*/
|
|
490
|
+
const addTypeDowngrades = new Set();
|
|
471
491
|
const newMemId = () => 'mem-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
|
472
492
|
// ── 工具刷写的公共装配(时间轴 + 溯源 + 冲突标记) ──
|
|
473
493
|
//
|
|
@@ -491,10 +511,28 @@ roomRegistry) {
|
|
|
491
511
|
* 时间轴同时写顶层字段(时间增强列)与 metadata(列迁移前的兼容层,
|
|
492
512
|
* 也是面板与图谱时间锚的读取点);`cf`/`rw` 落在 metadata.conflict/rewritten。
|
|
493
513
|
*/
|
|
494
|
-
|
|
514
|
+
/** 外部导入幂等键:sha1(source_id \0 稳定身份)。身份取 dedupe_key,缺省用内容规范化。 */
|
|
515
|
+
const computeExtKey = (sourceId, item, content) => {
|
|
516
|
+
const identity = typeof item.dedupe_key === 'string' && item.dedupe_key.trim()
|
|
517
|
+
? item.dedupe_key.trim()
|
|
518
|
+
: content.replace(/\s+/g, ' ').toLowerCase();
|
|
519
|
+
return createHash('sha1').update(`${sourceId}\u0000${identity}`, 'utf8').digest('hex');
|
|
520
|
+
};
|
|
521
|
+
function buildRecord(item, sceneName, now, workspaceId, sourceId) {
|
|
495
522
|
const content = String(item.content ?? '').trim();
|
|
496
|
-
const
|
|
497
|
-
|
|
523
|
+
const rawType = String(item.type ?? '').trim();
|
|
524
|
+
// 白名单判据来自词表单一真源;非词表值降级为 DOWNGRADE_TARGET 并留痕(不静默)。
|
|
525
|
+
let type = rawType;
|
|
526
|
+
if (!isKnownType(rawType)) {
|
|
527
|
+
type = DOWNGRADE_TARGET;
|
|
528
|
+
if (rawType.length > 0 && !addTypeDowngrades.has(rawType)) {
|
|
529
|
+
addTypeDowngrades.add(rawType);
|
|
530
|
+
logger.warn(`[memory] memory_add/import type '${rawType}' 不在词表,已降级为 '${DOWNGRADE_TARGET}'(同值不再重复提醒)`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// 族判定不再 `startsWith('work')` 内联猜——`project_*`/`development_*`/`*_experience`
|
|
534
|
+
// 都是 work 族却不带 work 前缀,猜错的族会静默改这批记录的共享面。
|
|
535
|
+
const family = familyOf(type);
|
|
498
536
|
const validFrom = parseTime(item.valid_from);
|
|
499
537
|
const validTo = parseTime(item.valid_to);
|
|
500
538
|
const persistence = normPersistence(item.persistence);
|
|
@@ -536,13 +574,14 @@ roomRegistry) {
|
|
|
536
574
|
...(validFrom !== undefined ? { validFrom } : {}),
|
|
537
575
|
...(validTo !== undefined ? { validTo } : {}),
|
|
538
576
|
...(persistence !== undefined ? { persistence } : {}),
|
|
577
|
+
...(sourceId && sourceId.trim() ? { extKey: computeExtKey(sourceId.trim(), item, content) } : {}),
|
|
539
578
|
};
|
|
540
579
|
}
|
|
541
580
|
/** 时间轴/溯源字段的可选入参(两个写工具共用同一形状)。 */
|
|
542
581
|
const writeFields = {
|
|
543
582
|
type: {
|
|
544
583
|
type: 'string',
|
|
545
|
-
description: '
|
|
584
|
+
description: `记忆类型(${typeEnumSep('/')};缺省 ${DOWNGRADE_TARGET})`,
|
|
546
585
|
},
|
|
547
586
|
hall: {
|
|
548
587
|
type: 'string',
|
|
@@ -618,6 +657,10 @@ roomRegistry) {
|
|
|
618
657
|
},
|
|
619
658
|
...writeFields,
|
|
620
659
|
priority: { type: 'number', description: '优先级 0-100(缺省 80)' },
|
|
660
|
+
dedupe_key: {
|
|
661
|
+
type: 'string',
|
|
662
|
+
description: '可选稳定身份(外部包内该条的稳定 id);与顶层 source_id 组合算幂等键,缺省用内容规范化哈希',
|
|
663
|
+
},
|
|
621
664
|
},
|
|
622
665
|
},
|
|
623
666
|
},
|
|
@@ -625,12 +668,17 @@ roomRegistry) {
|
|
|
625
668
|
type: 'string',
|
|
626
669
|
description: `归属场景名(缺省 ${DEFAULT_IMPORT_SCENE};建议用"${DEFAULT_IMPORT_SCENE}/<来源>")`,
|
|
627
670
|
},
|
|
671
|
+
source_id: {
|
|
672
|
+
type: 'string',
|
|
673
|
+
description: '来源标识(如 "workbuddy"/"claude-code"/外部包名);非空时启用幂等——同一来源重复导入相同 dedupe_key/内容会被跳过并计入 deduped',
|
|
674
|
+
},
|
|
628
675
|
},
|
|
629
676
|
output: {
|
|
630
677
|
schema: {
|
|
631
678
|
type: 'object',
|
|
632
679
|
properties: {
|
|
633
680
|
written: { type: 'number' },
|
|
681
|
+
deduped: { type: 'number' },
|
|
634
682
|
ids: { type: 'array', items: { type: 'string' } },
|
|
635
683
|
skipped: {
|
|
636
684
|
type: 'array',
|
|
@@ -653,15 +701,16 @@ roomRegistry) {
|
|
|
653
701
|
},
|
|
654
702
|
execute: async (args, exec) => {
|
|
655
703
|
if (!live.get().memoryMutate) {
|
|
656
|
-
return { written: 0, ids: [], skipped: [], notice: MUTATE_OFF_NOTICE };
|
|
704
|
+
return { written: 0, deduped: 0, ids: [], skipped: [], notice: MUTATE_OFF_NOTICE };
|
|
657
705
|
}
|
|
658
706
|
const raw = Array.isArray(args.records) ? args.records : [];
|
|
659
707
|
if (raw.length === 0) {
|
|
660
|
-
return { written: 0, ids: [], skipped: [], notice: 'records 为空,未写入' };
|
|
708
|
+
return { written: 0, deduped: 0, ids: [], skipped: [], notice: 'records 为空,未写入' };
|
|
661
709
|
}
|
|
662
710
|
if (raw.length > MAX_IMPORT) {
|
|
663
711
|
return {
|
|
664
712
|
written: 0,
|
|
713
|
+
deduped: 0,
|
|
665
714
|
ids: [],
|
|
666
715
|
skipped: [],
|
|
667
716
|
notice: `单次上限 ${MAX_IMPORT} 条(收到 ${raw.length} 条),请拆批导入`,
|
|
@@ -670,10 +719,13 @@ roomRegistry) {
|
|
|
670
719
|
const scene = typeof args.scene === 'string' && args.scene.trim()
|
|
671
720
|
? args.scene.trim().slice(0, 120)
|
|
672
721
|
: DEFAULT_IMPORT_SCENE;
|
|
722
|
+
const sourceId = typeof args.source_id === 'string' ? args.source_id.trim() : '';
|
|
673
723
|
const now = Date.now();
|
|
674
724
|
const seen = new Set();
|
|
725
|
+
const seenExt = new Set();
|
|
675
726
|
const records = [];
|
|
676
727
|
const skipped = [];
|
|
728
|
+
let deduped = 0;
|
|
677
729
|
raw.forEach((item, index) => {
|
|
678
730
|
const content = String(item?.content ?? '').trim();
|
|
679
731
|
if (!content) {
|
|
@@ -686,14 +738,30 @@ roomRegistry) {
|
|
|
686
738
|
return;
|
|
687
739
|
}
|
|
688
740
|
seen.add(key);
|
|
689
|
-
|
|
741
|
+
// 幂等(轨3-P0):带 source_id 时按 ext_key 拦截重复——先查库内、再查本批,
|
|
742
|
+
// 命中即计入 deduped 并跳过(不写 JSONL、不写检索库)。无 source_id 保持既有逐字行为。
|
|
743
|
+
if (sourceId) {
|
|
744
|
+
const extKey = computeExtKey(sourceId, item, content);
|
|
745
|
+
if (seenExt.has(extKey)) {
|
|
746
|
+
deduped++;
|
|
747
|
+
skipped.push({ index, reason: '与本批前面的记录外部键重复(幂等跳过)' });
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (stores.l1.findIdByExtKey(extKey)) {
|
|
751
|
+
deduped++;
|
|
752
|
+
skipped.push({ index, reason: '库内已存在同外部键记录(幂等跳过)' });
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
seenExt.add(extKey);
|
|
756
|
+
}
|
|
757
|
+
const record = buildRecord(item, scene, now, workspaceIdOf(exec), sourceId);
|
|
690
758
|
applyManualRedaction(record, cfg, logger);
|
|
691
759
|
records.push(record);
|
|
692
760
|
});
|
|
693
761
|
if (records.length > 0)
|
|
694
762
|
await stores.l1.appendNew(records);
|
|
695
|
-
logger.info(`[memory] 批量导入 ${records.length} 条(
|
|
696
|
-
return { written: records.length, ids: records.map((r) => r.id), skipped };
|
|
763
|
+
logger.info(`[memory] 批量导入 ${records.length} 条(幂等跳过 ${deduped} 条,场景 ${scene}${sourceId ? `,来源 ${sourceId}` : ''})`);
|
|
764
|
+
return { written: records.length, deduped, ids: records.map((r) => r.id), skipped };
|
|
697
765
|
},
|
|
698
766
|
})));
|
|
699
767
|
// memory_delete:显式"忘了 X"——按语义检索命中后删除(高权限门控)。
|
|
@@ -1222,7 +1290,7 @@ roomRegistry) {
|
|
|
1222
1290
|
if (family === null) {
|
|
1223
1291
|
return { enabled: false, total: 0, items: [], notice: blockNoticeOf(exec) };
|
|
1224
1292
|
}
|
|
1225
|
-
return listConflictPairs({ l1: stores.l1, conflictFreezeEnabled: live.get().conflictFreeze === true }, { limit: typeof args.limit === 'number' ? args.limit : undefined });
|
|
1293
|
+
return listConflictPairs({ l1: stores.l1, conflictFreezeEnabled: live.get().conflictFreeze === true, decayHalfLifeDays: cfg.recall?.decayHalfLifeDays, feedback: stores.feedback }, { limit: typeof args.limit === 'number' ? args.limit : undefined });
|
|
1226
1294
|
},
|
|
1227
1295
|
})));
|
|
1228
1296
|
// ── memory_conflicts_rejected: §C 丢弃留痕的读出口 ──
|