dsh-prime-memory 0.22.2 → 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 (98) 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 +1180 -845
  6. package/dist/config.d.ts +128 -1341
  7. package/dist/config.js +80 -4
  8. package/dist/conflict-service.d.ts +4 -0
  9. package/dist/conflict-service.js +6 -1
  10. package/dist/contract.d.ts +196 -0
  11. package/dist/domain-gate.d.ts +11 -0
  12. package/dist/domain-gate.js +15 -0
  13. package/dist/hooks/obsolete-notice.d.ts +26 -0
  14. package/dist/hooks/obsolete-notice.js +64 -0
  15. package/dist/hooks/recall.d.ts +39 -0
  16. package/dist/hooks/recall.js +110 -30
  17. package/dist/index.d.ts +1 -1127
  18. package/dist/index.js +30 -2
  19. package/dist/memory-types.d.ts +69 -0
  20. package/dist/memory-types.js +165 -0
  21. package/dist/memport/format.d.ts +6 -1
  22. package/dist/memport/format.js +42 -2
  23. package/dist/pipeline/l1.d.ts +44 -2
  24. package/dist/pipeline/l1.js +163 -9
  25. package/dist/pipeline/md-sync.d.ts +65 -0
  26. package/dist/pipeline/md-sync.js +456 -0
  27. package/dist/pipeline/ruminate.d.ts +43 -1
  28. package/dist/pipeline/ruminate.js +100 -0
  29. package/dist/pipeline/runner.d.ts +10 -0
  30. package/dist/pipeline/runner.js +7 -1
  31. package/dist/pipeline/workspace-relink.d.ts +60 -0
  32. package/dist/pipeline/workspace-relink.js +73 -0
  33. package/dist/projection/recall.d.ts +51 -13
  34. package/dist/projection/recall.js +47 -12
  35. package/dist/prompts/examples.d.ts +73 -0
  36. package/dist/prompts/examples.js +178 -0
  37. package/dist/prompts/l1-dedup.d.ts +9 -3
  38. package/dist/prompts/l1-dedup.js +34 -13
  39. package/dist/prompts/l1-extraction.d.ts +5 -3
  40. package/dist/prompts/l1-extraction.js +49 -4
  41. package/dist/settings.d.ts +8 -0
  42. package/dist/settings.js +12 -1
  43. package/dist/stats.d.ts +12 -0
  44. package/dist/stats.js +327 -15
  45. package/dist/store/embedding-source.d.ts +8 -0
  46. package/dist/store/embedding-source.js +39 -2
  47. package/dist/store/file-versions.d.ts +2 -0
  48. package/dist/store/file-versions.js +2 -0
  49. package/dist/store/graph-store.d.ts +14 -0
  50. package/dist/store/graph-store.js +35 -0
  51. package/dist/store/l1-snapshot.d.ts +26 -1
  52. package/dist/store/l1-snapshot.js +53 -8
  53. package/dist/store/l1.d.ts +75 -3
  54. package/dist/store/l1.js +124 -3
  55. package/dist/store/memory-md-archive.d.ts +84 -0
  56. package/dist/store/memory-md-archive.js +155 -0
  57. package/dist/store/memory-md-disable.d.ts +23 -0
  58. package/dist/store/memory-md-disable.js +106 -0
  59. package/dist/store/memory-md-export.d.ts +50 -0
  60. package/dist/store/memory-md-export.js +200 -0
  61. package/dist/store/memory-md-ledger.d.ts +94 -0
  62. package/dist/store/memory-md-ledger.js +243 -0
  63. package/dist/store/memory-md-orphan.d.ts +27 -0
  64. package/dist/store/memory-md-orphan.js +153 -0
  65. package/dist/store/memory-md-parse.d.ts +35 -0
  66. package/dist/store/memory-md-parse.js +85 -0
  67. package/dist/store/memory-md-skeleton.d.ts +18 -0
  68. package/dist/store/memory-md-skeleton.js +61 -0
  69. package/dist/store/memory-md-types.d.ts +174 -0
  70. package/dist/store/memory-md-types.js +10 -0
  71. package/dist/store/memory-md.d.ts +108 -0
  72. package/dist/store/memory-md.js +219 -0
  73. package/dist/store/obsolete-notices.d.ts +39 -0
  74. package/dist/store/obsolete-notices.js +153 -0
  75. package/dist/store/recall-feedback.d.ts +75 -0
  76. package/dist/store/recall-feedback.js +213 -0
  77. package/dist/store/recall-usage.d.ts +11 -0
  78. package/dist/store/recall-usage.js +29 -0
  79. package/dist/store/session-recalled.d.ts +38 -3
  80. package/dist/store/session-recalled.js +156 -9
  81. package/dist/store/sqlite.d.ts +66 -0
  82. package/dist/store/sqlite.js +233 -15
  83. package/dist/store/supersede.d.ts +1 -1
  84. package/dist/store/supersede.js +1 -1
  85. package/dist/tools/index.d.ts +4 -0
  86. package/dist/tools/index.js +23 -6
  87. package/dist/types.d.ts +24 -7
  88. package/dist/types.js +10 -2
  89. package/dist/util/maturity.d.ts +20 -3
  90. package/dist/util/maturity.js +18 -1
  91. package/dist/util/md-name.d.ts +129 -0
  92. package/dist/util/md-name.js +366 -0
  93. package/dist/util/md-normalize.d.ts +23 -0
  94. package/dist/util/md-normalize.js +131 -0
  95. package/dist/workspace-route.d.ts +22 -0
  96. package/dist/workspace-route.js +66 -0
  97. package/dsh.plugin.json +1 -1
  98. package/package.json +6 -5
@@ -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)。 */
@@ -326,6 +340,16 @@ export class MemoryDb {
326
340
  this.logger?.info(`${TAG} l1_records 补 ext_key 列(外部导入幂等键)`);
327
341
  }
328
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 <> ''");
329
353
  // L2 场景重聚类作业队列(治理 W3 T3.10;ruminate 空闲档消费,每轮至多 1 作业)。
330
354
  // source 区分触发来源('demote'|'repo-change'),便于诊断与重入队语义区分。
331
355
  this.db.exec(`
@@ -442,8 +466,8 @@ export class MemoryDb {
442
466
  record_id, content, type, priority, scene_name, session_id, version,
443
467
  timestamp_str, timestamp_start, timestamp_end, created_time, updated_time, metadata_json, family,
444
468
  valid_from, valid_to, persistence, scope, workspace_id,
445
- repo_key_name, repo_key_owner, applicability, ext_key
446
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
469
+ repo_key_name, repo_key_owner, applicability, ext_key, name
470
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
447
471
  ON CONFLICT(record_id) DO UPDATE SET
448
472
  content=excluded.content,
449
473
  type=excluded.type,
@@ -464,13 +488,14 @@ export class MemoryDb {
464
488
  repo_key_name=excluded.repo_key_name,
465
489
  repo_key_owner=excluded.repo_key_owner,
466
490
  applicability=excluded.applicability,
467
- ext_key=excluded.ext_key
491
+ ext_key=excluded.ext_key,
492
+ name=excluded.name
468
493
  `);
469
494
  this.stmtGetL1 = this.db.prepare(`
470
495
  SELECT record_id, content, type, priority, scene_name, version, timestamp_str,
471
496
  timestamp_start, timestamp_end, created_time, updated_time, metadata_json, family,
472
497
  valid_from, valid_to, persistence, scope, workspace_id,
473
- repo_key_name, repo_key_owner, applicability
498
+ repo_key_name, repo_key_owner, applicability, name
474
499
  FROM l1_records WHERE record_id = ?
475
500
  `);
476
501
  this.stmtL1Exists = this.db.prepare('SELECT 1 FROM l1_records WHERE record_id = ?');
@@ -611,6 +636,10 @@ export class MemoryDb {
611
636
  `);
612
637
  if (ftsRebuilt)
613
638
  this.backfillL1Fts();
639
+ // 退场残留清理(幂等):历史上退场不变量只在写入漏斗上强制,FTS 重建回灌与
640
+ // 增量重嵌入两条旁路会把已退场记录留在检索面里(实测本机 725 行)。
641
+ // 上面的口径修复只挡新增,已有的要在这里收掉——否则那 725 条永远脏着。
642
+ this.purgeRetiredRetrievalRows();
614
643
  this.stmtL0FtsInsert = this.db.prepare(`
615
644
  INSERT INTO l0_fts (message_text, message_text_original, record_id, session_id, role, recorded_at, timestamp)
616
645
  VALUES (?, ?, ?, ?, ?, ?, ?)
@@ -744,10 +773,14 @@ export class MemoryDb {
744
773
  */
745
774
  backfillL1Fts() {
746
775
  let count = 0;
776
+ // 退场行不回灌(理由见 `retiredL1Sql`)。跳过数单独数一次:重建是低频动作,
777
+ // 多一条 COUNT 换“运维看得见跳过了多少”比静默少灌划算。
778
+ const retiredSkipped = this.countL1Retired();
747
779
  const stmt = this.db
748
780
  .prepare(`SELECT record_id, content, type, priority, scene_name, session_id, version,
749
781
  timestamp_str, timestamp_start, timestamp_end, metadata_json, family,
750
- scope, workspace_id FROM l1_records`);
782
+ scope, workspace_id FROM l1_records
783
+ WHERE NOT (${retiredL1Sql()})`);
751
784
  for (const r of stmt.iterate()) {
752
785
  try {
753
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'),
@@ -760,8 +793,52 @@ export class MemoryDb {
760
793
  /* 单行失败跳过(吞+计数:count 与总量比对可发现大面积失败) */
761
794
  }
762
795
  }
763
- if (count > 0)
764
- 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 + 向量)`);
765
842
  }
766
843
  /** 重建后的 l0_fts 从 l0_conversations 全量回灌(仅 drop 重建时调用;iterate 流式)。 */
767
844
  backfillL0Fts() {
@@ -916,7 +993,10 @@ export class MemoryDb {
916
993
  // 旧版导入都会经这里写回记录,若照常重建 FTS/向量行,一条带退场标记的记录
917
994
  // 会**悄悄回到检索结果里**(而检索侧刻意不看 valid_to,正是为了零漂移)。
918
995
  const retiredNow = record.validTo !== undefined || readSupersedeMarker(record.metadata) !== undefined;
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 ?? '');
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 ?? '');
920
1000
  // vec0 不支持 ON CONFLICT → 先删后插;零向量跳过(cosine 未定义)。
921
1001
  // 已退场则**只删不插**(见上方 retiredNow 的说明)。
922
1002
  if (this.stmtDeleteL1Vec && this.stmtInsertL1Vec) {
@@ -1059,7 +1139,7 @@ export class MemoryDb {
1059
1139
  .prepare("SELECT COUNT(*) AS n FROM l1_records WHERE COALESCE(valid_to, '') <> ''")
1060
1140
  .get();
1061
1141
  const rows = this.db
1062
- .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
1063
1143
  FROM l1_records WHERE COALESCE(valid_to, '') <> '' ORDER BY updated_time DESC LIMIT ? OFFSET ?`)
1064
1144
  .all(opts.limit, opts.offset);
1065
1145
  return { items: rows.map(rowToRecord), total: Number(totalRow?.n ?? 0) };
@@ -1085,10 +1165,14 @@ export class MemoryDb {
1085
1165
  const repoCols = table === 'l1_records' && this.hasColumn('l1_records', 'repo_key_name')
1086
1166
  ? ', repo_key_name, repo_key_owner, applicability'
1087
1167
  : '';
1168
+ // 文件身份键(md 投射相):同款形状探测。没这一列的旧库读侧归一 undefined,
1169
+ // 不报错——投射器把空 name 回落到 `mem-<id8>`,而不是把整条取记录路径弄挂。
1170
+ const nameCol = table === 'l1_records' && this.hasColumn('l1_records', 'name') ? ', name' : '';
1088
1171
  const metaCols = 'record_id, content, type, priority, scene_name, version, timestamp_str, created_time, updated_time, metadata_json, family' +
1089
1172
  temporal +
1090
1173
  scopeCols +
1091
- repoCols;
1174
+ repoCols +
1175
+ nameCol;
1092
1176
  stmt =
1093
1177
  action === 'delete'
1094
1178
  ? this.db.prepare(`DELETE FROM ${table} WHERE record_id IN (${ph})`)
@@ -1228,7 +1312,138 @@ export class MemoryDb {
1228
1312
  return 0;
1229
1313
  }
1230
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
+ }
1231
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
+ }
1232
1447
  /** 入队一个重聚类作业(demote-to-wiki / repo 归属变化触发;source 区分来源)。 */
1233
1448
  enqueueSceneRecluster(family, sceneNames, batchId, source = 'demote') {
1234
1449
  const jobId = `recl_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
@@ -1298,7 +1513,7 @@ export class MemoryDb {
1298
1513
  if (this.degraded)
1299
1514
  return [];
1300
1515
  const rows = this.db
1301
- .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')
1302
1517
  .all();
1303
1518
  return rows.map(rowToRecord);
1304
1519
  }
@@ -1643,7 +1858,7 @@ export class MemoryDb {
1643
1858
  const whereSql = where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '';
1644
1859
  const totalRow = this.db.prepare(`SELECT COUNT(*) AS n FROM l1_records${whereSql}`).get(...params);
1645
1860
  const rows = this.db
1646
- .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 ?`)
1647
1862
  .all(...params, opts.limit, opts.offset);
1648
1863
  return { items: rows.map(rowToRecord), total: totalRow?.n ?? 0 };
1649
1864
  }
@@ -2190,7 +2405,7 @@ export class MemoryDb {
2190
2405
  const row = this.db
2191
2406
  .prepare(`SELECT COUNT(*) AS n FROM ${srcTable} r
2192
2407
  LEFT JOIN ${vecTable} v ON v.record_id = r.record_id
2193
- 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)}`)
2194
2409
  .all(...notInParams(exclude))[0];
2195
2410
  return row?.n ?? 0;
2196
2411
  }
@@ -2212,7 +2427,7 @@ export class MemoryDb {
2212
2427
  return this.db
2213
2428
  .prepare(`SELECT r.record_id AS id, r.content FROM l1_records r
2214
2429
  LEFT JOIN l1_vec v ON v.record_id = r.record_id
2215
- 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)}`)
2216
2431
  .all(...notInParams(exclude));
2217
2432
  }
2218
2433
  catch (err) {
@@ -2424,6 +2639,9 @@ function rowToRecord(row) {
2424
2639
  repoKeyName: row.repo_key_name ?? '',
2425
2640
  repoKeyOwner: row.repo_key_owner ?? '',
2426
2641
  applicability: normApplicability(row.applicability),
2642
+ // 文件身份键回读:空串归一 undefined(“还没名字”与“名字是空串”不是一种意思),
2643
+ // 缺列的旧库也是 undefined。读侧同样 fail-open。
2644
+ name: row.name || undefined,
2427
2645
  };
2428
2646
  }
2429
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)。 */
@@ -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
  *
@@ -17,6 +17,10 @@ export declare function registerMemoryTools(ctx: Context, cfg: MemoryConfig, sto
17
17
  persona: Record<MemoryFamily, PersonaStore>;
18
18
  /** 图谱存储(可选:未装配时图谱工具返回未启用提示)。 */
19
19
  graph?: GraphStore;
20
+ /** 人工打分面(可选):冲突视图的成熟度旁注与面板同一口径。 */
21
+ feedback?: {
22
+ lastOf(id: string): number | undefined;
23
+ };
20
24
  }, logger: MemoryLogger, modes: SessionModeStore, live: LiveSettingsHandle,
21
25
  /** 反刍控制器(可选:未装配时 ruminate 工具返回未启用提示)。 */
22
26
  ruminate?: RuminateController,
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { createHash } from 'node:crypto';
15
15
  import { defineTool } from '@deepseek-ai/dsh-tools';
16
+ import { DOWNGRADE_TARGET, familyOf, isKnownType, typeEnumSep } from '../memory-types.js';
16
17
  import { RECEIPTS_QUERY_LIMIT_MAX, dimensionOf, toReceiptView } from '../store/receipts.js';
17
18
  import { listConflictPairs, renderConflictResolution, renderConflicts, resolveConflictPair } from '../conflict-service.js';
18
19
  import { WING_CATALOG, WING_FALLBACK, normPersistence, normScope, resolveRecordScope } from '../types.js';
@@ -181,7 +182,7 @@ roomRegistry) {
181
182
  parameters: {
182
183
  query: { type: 'string', required: true, description: '搜索查询文本(自然语言)' },
183
184
  limit: { type: 'number', description: '最大返回条数(默认 5)' },
184
- type: { type: 'string', description: '按记忆类型过滤(如 persona/episodic/instruction/work_fact/work_task/work_method/work_artifact)' },
185
+ type: { type: 'string', description: `按记忆类型过滤(词表 ${typeEnumSep('/')})` },
185
186
  },
186
187
  output: {
187
188
  schema: {
@@ -481,7 +482,12 @@ roomRegistry) {
481
482
  }));
482
483
  // ── 写删工具(高权限门控):memory_add / memory_delete ──
483
484
  const MUTATE_OFF_NOTICE = '记忆写删未开放:请在记忆库面板开启「高权限模式」后,模型才能写入/删除记忆。';
484
- const ADD_TYPES = ['persona', 'episodic', 'instruction', 'work_fact', 'work_task', 'work_method', 'work_artifact'];
485
+ /**
486
+ * 非词表 type 的降级台账(pm-name-distill task_1.4)。旧实现把外部分类**静默改写**成
487
+ * `episodic`,而 memport 文案却承诺“按原样透传”——两套口径对同一件事相反。
488
+ * 现在降级仍发生(写入面需要一个合法值),但**可观测**:每个不同值 warn 一次并计数。
489
+ */
490
+ const addTypeDowngrades = new Set();
485
491
  const newMemId = () => 'mem-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
486
492
  // ── 工具刷写的公共装配(时间轴 + 溯源 + 冲突标记) ──
487
493
  //
@@ -514,8 +520,19 @@ roomRegistry) {
514
520
  };
515
521
  function buildRecord(item, sceneName, now, workspaceId, sourceId) {
516
522
  const content = String(item.content ?? '').trim();
517
- const type = ADD_TYPES.includes(String(item.type ?? '')) ? String(item.type) : 'episodic';
518
- const family = type.startsWith('work') ? 'work' : 'chat';
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);
519
536
  const validFrom = parseTime(item.valid_from);
520
537
  const validTo = parseTime(item.valid_to);
521
538
  const persistence = normPersistence(item.persistence);
@@ -564,7 +581,7 @@ roomRegistry) {
564
581
  const writeFields = {
565
582
  type: {
566
583
  type: 'string',
567
- description: '记忆类型(persona/episodic/instruction/work_fact/work_task/work_method/work_artifact;缺省 episodic)',
584
+ description: `记忆类型(${typeEnumSep('/')};缺省 ${DOWNGRADE_TARGET})`,
568
585
  },
569
586
  hall: {
570
587
  type: 'string',
@@ -1273,7 +1290,7 @@ roomRegistry) {
1273
1290
  if (family === null) {
1274
1291
  return { enabled: false, total: 0, items: [], notice: blockNoticeOf(exec) };
1275
1292
  }
1276
- return listConflictPairs({ l1: stores.l1, conflictFreezeEnabled: live.get().conflictFreeze === true, decayHalfLifeDays: cfg.recall?.decayHalfLifeDays }, { 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 });
1277
1294
  },
1278
1295
  })));
1279
1296
  // ── memory_conflicts_rejected: §C 丢弃留痕的读出口 ──
package/dist/types.d.ts CHANGED
@@ -1,9 +1,3 @@
1
- /**
2
- * 领域类型与词汇表(净室重写)。
3
- *
4
- * 覆盖:记忆族/档位、Hall 目录、L0/L1 记录形状、抽取产出与族判定三级兜底、
5
- * L2 场景摘要与 L1 检索命中。字段名与取值是磁盘/管线两侧的既定契约,不可更名。
6
- */
7
1
  /** 蒸馏 Prompt 家族:chat = 个人记忆(persona/episodic/instruction + 用户画像),work = 工作记忆(work_fact/work_task/work_method/work_artifact + Team Operating Doctrine)。 */
8
2
  export type MemoryFamily = 'chat' | 'work';
9
3
  /** 会话记忆档位:auto = 双族自动判定 | chat/work = 单族 | off = 本会话对记忆系统隐身。 */
@@ -46,7 +40,14 @@ export interface RoomCount {
46
40
  /** 人类可读名(注册表条目才有;面板 chip 可用 label 展示)。 */
47
41
  label?: string;
48
42
  }
49
- /** 记录族标签推断:work_* 前缀 → work,其余(含 auto 档兜底)→ chat。 */
43
+ /**
44
+ * 记录族标签推断(pm-name-distill task_1.2):查 `TYPE_FAMILY_MAP`(词表单一真源)。
45
+ *
46
+ * 旧实现是 `type.startsWith('work')` 前缀猜族——新词表里 `project_*`/`development_*`/
47
+ * `*_experience` 都是 work 族却不带 work 前缀,前缀判定会把它们静默归进 chat,
48
+ * 而 family 决定召回/浏览/去重的共享面。未知值仍回落旧前缀规则并 warn 一次(可观测,
49
+ * 不静默)。
50
+ */
50
51
  export declare function familyForType(type: string): MemoryFamily;
51
52
  /**
52
53
  * 记忆的持续性(时间轴第三维,与 createdAt/updatedAt 正交):
@@ -118,6 +119,13 @@ export interface ExtractedMemory {
118
119
  metadata: Record<string, unknown>;
119
120
  /** 所属情境名(L1 抽取的情境切分结果)。 */
120
121
  scene_name: string;
122
+ /**
123
+ * 名称蒸馏(Phase 3):模型**当场**给的短标题(名词短语,回答“这条讲的是什么”),
124
+ * 与 `content`(完整叙述正文)分离。只在 `cfg.naming.enabled` 时被要求产出;
125
+ * 缺省或过不了 `validateTitle` 形状闸就**不写成名字**,仍由 `L1Store.assignMissingNames`
126
+ * 兜底——名字的唯一真源不因多一个来源就分两套判据。
127
+ */
128
+ title?: string;
121
129
  /** auto 档抽取输出的显式族判定(chat|work;纯档 Prompt 无此字段)。
122
130
  * 语境归族、形状不归族——避免"个人计划性事实被 work_* 形状吸走"的族错标。 */
123
131
  family?: string;
@@ -232,6 +240,15 @@ export interface MemoryRecord {
232
240
  * 与 `scene_name`/`metadata.origin` 不同:这是**存储层强制去重键**,不是展示用的溯源标签。
233
241
  */
234
242
  extKey?: string;
243
+ /**
244
+ * 文件身份键(md 投射相,N1):人可读、可编辑、全库唯一,直接当
245
+ * `<root>/<type>/<name>.memory.md` 的文件名。与 `extKey` 的分工:extKey 是
246
+ * **导入幂等键**(机器算的、来源内唯一),name 是**文件身份**(人写的、全库唯一)。
247
+ * 不用 id 当身份:id 每轮新铸、从不复用;不用 scene_name:它的基数大于记录数。
248
+ * undefined = 还没名字(存量记录),投射器则回落 `mem-<id8>`。
249
+ * 非空值受 `l1_records` 上的部分唯一索引约束;该列**不进**快照哈希投影。
250
+ */
251
+ name?: string;
235
252
  }
236
253
  /** L2 场景块摘要(META 解析结果)。 */
237
254
  export interface SceneSummary {
package/dist/types.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * 覆盖:记忆族/档位、Hall 目录、L0/L1 记录形状、抽取产出与族判定三级兜底、
5
5
  * L2 场景摘要与 L1 检索命中。字段名与取值是磁盘/管线两侧的既定契约,不可更名。
6
6
  */
7
+ import { familyOf } from './memory-types.js';
7
8
  export const WING_CATALOG = [
8
9
  { id: 'work', label: '工作' },
9
10
  { id: 'relationships', label: '人际' },
@@ -27,9 +28,16 @@ export function wingLabel(id) {
27
28
  const h = WING_CATALOG.find((x) => x.id === id);
28
29
  return h ? h.label : id;
29
30
  }
30
- /** 记录族标签推断:work_* 前缀 → work,其余(含 auto 档兜底)→ chat。 */
31
+ /**
32
+ * 记录族标签推断(pm-name-distill task_1.2):查 `TYPE_FAMILY_MAP`(词表单一真源)。
33
+ *
34
+ * 旧实现是 `type.startsWith('work')` 前缀猜族——新词表里 `project_*`/`development_*`/
35
+ * `*_experience` 都是 work 族却不带 work 前缀,前缀判定会把它们静默归进 chat,
36
+ * 而 family 决定召回/浏览/去重的共享面。未知值仍回落旧前缀规则并 warn 一次(可观测,
37
+ * 不静默)。
38
+ */
31
39
  export function familyForType(type) {
32
- return type.startsWith('work') ? 'work' : 'chat';
40
+ return familyOf(type);
33
41
  }
34
42
  export const PERSISTENCE_VALUES = ['p', 's', 'o', 't'];
35
43
  /** 持续性取值归一:只认 p/s/o/t,其余(缺省/非法)返回 undefined。 */
@@ -1,13 +1,19 @@
1
1
  /**
2
2
  * 成熟度(maturity)合成——G3,单一事实源的纯函数。
3
3
  *
4
- * 把三个**已存在**的信号合成一个可解释的置信档,供面板显示(后续可作召回排序/矛盾旁注):
4
+ * 把四个**已存在**的信号合成一个可解释的置信档,供面板显示与召回排序:
5
5
  * - `priority`(L1 真实列,0-100):写入时的重要性先验;
6
6
  * - `usage.used`(recall-usage,进 topN 被注入才计):被反复用到 = 越"确立";
7
- * - `agingWeight`(读路径衰减系数 0.5~1,null=关):新鲜度。
7
+ * - `agingWeight`(读路径衰减系数 0.5~1,null=关):新鲜度;
8
+ * - `mark`(人在召回卡上打的 -1..5,**只看最近一笔**):显式的人工裁决。
9
+ *
10
+ * 人工标记是**加性**项(±W_MARK),不是权重再分配:未打分时贡献恒为 0,
11
+ * 与三输入时代的分值**逐字相等**(旧回归断言不会因新增一项而漂)。
12
+ * 档位设计(用户口径:0/1/2 拉低、4/5 抬高):0/1/2 为负、3 中性、4/5 为正;
13
+ * `-1`(声明过时)**不在此计分**——它走退场,若再计一次罚就是同一件事罚两遍。
8
14
  *
9
15
  * 刻意**读时派生、不落库**(Agent B/C 结论):落库会在每轮 recordAttempt 触发双写 + 值随时间陈旧,
10
- * 且顶层列不进快照哈希=隐形改写。三输入即时可得,纯函数零 IO。
16
+ * 且顶层列不进快照哈希=隐形改写。各输入即时可得,纯函数零 IO。
11
17
  *
12
18
  * 权重/阈值集中为常量,便于回归与调参;缺 usage(未装配)按 0 强化处理(不虚构成熟度)。
13
19
  */
@@ -20,7 +26,18 @@ export interface MaturityInput {
20
26
  usage?: MaturityUsage | null;
21
27
  /** 读路径老化权重(0.5~1);null = 衰减关闭 → 视为 1(不惩罚)。 */
22
28
  agingWeight?: number | null;
29
+ /**
30
+ * 人给的最近一笔打分(-1..5);未打分 = undefined/null → 贡献 0(逐字旧行为)。
31
+ * `-1` 也按 0 处理:那条记录已经退场,不该在成熟度上再罚一次。
32
+ */
33
+ mark?: number | null;
23
34
  }
35
+ /**
36
+ * 打分 → 归一化人工信号(-1..1)。3 分与未打分同为中性。
37
+ * 正侧只分两档(4 半、5 满);负侧分三档渐次加重(2 轻罚 -0.4、1 中罚 -0.7、0 重罚 -1)——
38
+ * “不相关”是个需要区分程度的否定,而“相关”不必分那么细。
39
+ */
40
+ export declare function markSignal(mark: number | null | undefined): number;
24
41
  /** 合成分值(0~1),导出供测试与未来排序复用。 */
25
42
  export declare function maturityScore(i: MaturityInput): number;
26
43
  /** 三档成熟度:high ≥0.66、mid ≥0.40、否则 low。 */
@@ -1,10 +1,27 @@
1
1
  const W_PRIORITY = 0.45;
2
2
  const W_USAGE = 0.3;
3
3
  const W_AGING = 0.25;
4
+ /** 人工标记的加性幅度(±0.3):够让一档在强先验上被推翻,又不足以一票定生死。 */
5
+ const W_MARK = 0.3;
4
6
  /** used 达到该数即满强化(再高不加分)。 */
5
7
  const USAGE_SATURATE = 5;
6
8
  const HIGH_THRESHOLD = 0.66;
7
9
  const MID_THRESHOLD = 0.4;
10
+ /**
11
+ * 打分 → 归一化人工信号(-1..1)。3 分与未打分同为中性。
12
+ * 正侧只分两档(4 半、5 满);负侧分三档渐次加重(2 轻罚 -0.4、1 中罚 -0.7、0 重罚 -1)——
13
+ * “不相关”是个需要区分程度的否定,而“相关”不必分那么细。
14
+ */
15
+ export function markSignal(mark) {
16
+ switch (mark) {
17
+ case 5: return 1;
18
+ case 4: return 0.5;
19
+ case 2: return -0.4;
20
+ case 1: return -0.7;
21
+ case 0: return -1;
22
+ default: return 0; // 3 / -1 / 未打分 / 越界值
23
+ }
24
+ }
8
25
  function clamp01(x) {
9
26
  return Math.max(0, Math.min(1, x));
10
27
  }
@@ -13,7 +30,7 @@ export function maturityScore(i) {
13
30
  const p = clamp01(i.priority / 100);
14
31
  const u = i.usage ? clamp01(i.usage.used / USAGE_SATURATE) : 0;
15
32
  const a = i.agingWeight == null ? 1 : clamp01(i.agingWeight);
16
- return W_PRIORITY * p + W_USAGE * u + W_AGING * a;
33
+ return clamp01(W_PRIORITY * p + W_USAGE * u + W_AGING * a + W_MARK * markSignal(i.mark));
17
34
  }
18
35
  /** 三档成熟度:high ≥0.66、mid ≥0.40、否则 low。 */
19
36
  export function computeMaturity(i) {