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
@@ -30,6 +30,16 @@ export interface MemoryStores {
30
30
  state: StateStore;
31
31
  /** 图谱存储(可选:测试缝/未装配时图谱泵整体停用;GraphStore 自带降级 no-op)。 */
32
32
  graph?: GraphStore;
33
+ /**
34
+ * 派生数据搬运面(P0-d):merge 后把使用史与人工标注搬到新 id。
35
+ * 只声明用到的那个方法(不拉整个类入参)——未装配时不搬,行为与改动前一致。
36
+ */
37
+ usage?: {
38
+ rekey(oldId: string, newId: string): boolean;
39
+ };
40
+ feedback?: {
41
+ rekey(oldId: string, newId: string): boolean;
42
+ };
33
43
  }
34
44
  /** 管线任务(优先级调度:live > graph > rebuild)。 */
35
45
  export interface PipelineTask {
@@ -662,7 +662,13 @@ export class MemoryRunner {
662
662
  // §E 写入侧工作区:后台蒸馏手上只有 sessionId(没有 exec),经
663
663
  // `ctx.get('agents')` 宽容解析——与 §A 的多级父链解析同一招。
664
664
  // 拿不到 → undefined → 归属回落 global(pipeline 侧 `resolveRecordScope` 兜底)。
665
- sessionWorkspaceIdOf(this.ctx, sessionId), anchorMap);
665
+ sessionWorkspaceIdOf(this.ctx, sessionId), anchorMap,
666
+ // 派生数据跟着搬(P0-d):不搬等于每次合并把这条记忆的使用史与人工标注清零。
667
+ // 两个 sidecar 都是后装上的(`stores.usage` 在装配段才填),故在调用时点现取。
668
+ (oldId, newId) => {
669
+ this.stores.usage?.rekey(oldId, newId);
670
+ this.stores.feedback?.rekey(oldId, newId);
671
+ });
666
672
  if (!result.skipped) {
667
673
  this.pending[mode] = rest;
668
674
  // 重建轮(force)不是有机对话,不推进爬坡
@@ -0,0 +1,60 @@
1
+ /**
2
+ * 存量记忆的 workspace re-link(内容路由回填)。
3
+ *
4
+ * 为什么需要它:新写入的路由只覆盖此后产生的记忆,而库里已有的记录归属列全空
5
+ * (实测 1279 条活跃记录里 `workspace_id` 非空为 0)。不回填,「按项目看记忆」这件事
6
+ * 就永远只有增量、没有存量。
7
+ *
8
+ * 三条设计约束:
9
+ * 1. **只写 `workspace_id`,不动 `scope`、不动 `updated_time`**。前者保可见性零变化
10
+ * (`cfg.scope='global'` 下 `isScopeVisible` 直接放行),后者保快照哈希零失配
11
+ * (见 `store/sqlite.ts` 的 `setL1WorkspaceIds` 注释,三条硬约束都在那儿)。
12
+ * 2. **宁缺勿猜**:路由不命中的记录保持原样,绝不给一个"默认归属"。归错项目会让记忆
13
+ * 在错误的工作区可见/不可见,比不归难查得多。
14
+ * 3. **有界可重入**:照 `wing-backfill` 的双护栏 + 扫描阶段也查墙钟 + 剩余留待再次触发;
15
+ * 且因不写 `updated_time`,按更新时间倒序分页**不会边扫边漂**。
16
+ */
17
+ import type { MemoryRecord } from '../types.js';
18
+ import type { WorkspaceRoute } from '../workspace-route.js';
19
+ /** re-link 需要的存储面(结构化声明,便于单测注入)。 */
20
+ export interface RelinkStoreFace {
21
+ list(opts: {
22
+ limit: number;
23
+ offset: number;
24
+ }): {
25
+ items: MemoryRecord[];
26
+ total: number;
27
+ };
28
+ setWorkspaceIds(items: ReadonlyArray<{
29
+ id: string;
30
+ workspaceId: string;
31
+ }>): number;
32
+ }
33
+ export interface RelinkReport {
34
+ dryRun: boolean;
35
+ /** 扫过的记录数(含未命中)。 */
36
+ scanned: number;
37
+ /** 路由命中的记录数。 */
38
+ matched: number;
39
+ /** 归属已是目标值、无需写的记录数(幂等重跑时等于 matched)。 */
40
+ unchanged: number;
41
+ /** 干跑时“若真跑会写多少条”;真写时恒为 0(那一步看 written)。 */
42
+ toWrite: number;
43
+ /** 实际写入条数(dryRun 时恒为 0)。 */
44
+ written: number;
45
+ /** 因预算未处理、留待下次触发的条数。 */
46
+ remaining: number;
47
+ /** 命中记录按归属的分布(先看这个数就知道路由表合不合口味)。 */
48
+ byWorkspace: Record<string, number>;
49
+ }
50
+ export declare function relinkWorkspaces(deps: {
51
+ l1: RelinkStoreFace;
52
+ routes: readonly WorkspaceRoute[];
53
+ dryRun?: boolean;
54
+ maxRecords?: number;
55
+ timeBudgetMs?: number;
56
+ logger?: {
57
+ info: (m: string) => void;
58
+ warn: (m: string) => void;
59
+ };
60
+ }): Promise<RelinkReport>;
@@ -0,0 +1,73 @@
1
+ import { routeWorkspace } from '../workspace-route.js';
2
+ import { yieldLoop } from '../util/yield.js';
3
+ const PAGE = 200;
4
+ /** 攒够一批再写:一批一个事务,避免逐条开事务的 fsync 开销。 */
5
+ const FLUSH = 500;
6
+ export async function relinkWorkspaces(deps) {
7
+ const { l1, routes, logger } = deps;
8
+ const dryRun = deps.dryRun !== false; // 缺省干跑:先看报告,再决定真写
9
+ const maxRecords = Math.max(1, Math.floor(deps.maxRecords ?? 300));
10
+ const budgetMs = Math.max(1000, Math.floor(deps.timeBudgetMs ?? 120_000));
11
+ const started = Date.now();
12
+ const overBudget = () => Date.now() - started > budgetMs;
13
+ const report = {
14
+ dryRun, scanned: 0, matched: 0, unchanged: 0, toWrite: 0, written: 0, remaining: 0, byWorkspace: {},
15
+ };
16
+ if (routes.length === 0)
17
+ return report;
18
+ let offset = 0;
19
+ let batch = [];
20
+ // 计数在此结算:`flush()` 会清空 batch,调用点再拿 `batch.length` 必然归零。
21
+ const flush = () => {
22
+ if (batch.length === 0)
23
+ return;
24
+ if (dryRun)
25
+ report.toWrite += batch.length;
26
+ else
27
+ report.written += l1.setWorkspaceIds(batch);
28
+ batch = [];
29
+ };
30
+ for (;;) {
31
+ if (report.scanned >= maxRecords)
32
+ break;
33
+ // 扫描阶段本身也查墙钟:1279 条不慢,但同一个护栏要能扛住更大的库。
34
+ if (overBudget())
35
+ break;
36
+ const page = l1.list({ limit: PAGE, offset });
37
+ if (page.items.length === 0)
38
+ break;
39
+ for (const rec of page.items) {
40
+ // 上限检查必须在**循环顶部**:放在末尾时,未命中的记录会先 `continue` 掉,
41
+ // 于是 maxRecords=1 实际能扫 2 条(实测翻出)——有界性得对所有分支都成立。
42
+ if (report.scanned >= maxRecords)
43
+ break;
44
+ report.scanned++;
45
+ const routed = routeWorkspace(`${rec.content}\n${rec.scene_name}`, routes);
46
+ if (routed === undefined)
47
+ continue;
48
+ report.matched++;
49
+ report.byWorkspace[routed] = (report.byWorkspace[routed] ?? 0) + 1;
50
+ if (rec.workspaceId === routed) {
51
+ report.unchanged++;
52
+ continue;
53
+ }
54
+ batch.push({ id: rec.id, workspaceId: routed });
55
+ if (batch.length >= FLUSH)
56
+ flush();
57
+ }
58
+ offset += page.items.length;
59
+ if (offset >= page.total)
60
+ break;
61
+ await yieldLoop(); // 让出事件循环:面板 RPC / 状态查询优先
62
+ }
63
+ flush();
64
+ // 剩余 = 总数 - 实扫条数。**不能用 offset 算**:中途按 maxRecords/预算 break 时,
65
+ // offset 已推进整个页(一页 PAGE 条),会把“还剩 2 条没扫”算成 0,
66
+ // 于是报告谎称已扫完——下次也就不会再触发续扫了。
67
+ const totalRows = l1.list({ limit: 1, offset: 0 }).total;
68
+ report.remaining = Math.max(0, totalRows - report.scanned);
69
+ logger?.info(`[memory] workspace re-link${dryRun ? '(干跑)' : ''}:扫 ${report.scanned} 条,命中 ${report.matched} 条,`
70
+ + `待写 ${report.toWrite} 条,实写 ${report.written} 条,`
71
+ + `剩 ${report.remaining} 条未扫(下次触发续);分布 ${JSON.stringify(report.byWorkspace)}`);
72
+ return report;
73
+ }
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * 会话「活召回集」服务端投影(memoryRecall)。
3
3
  *
4
- * 语义:本会话**已注入、且目前仍在上下文里**的记忆(= 模型此刻真正看到的几条)。
5
- * 上下文被压缩/清空时注入内容离开模型,集合随之清空——数据源是
6
- * `SessionRecalledStore`(sessionKey 键、写穿持久化),所以**旧会话重开也读得到**。
4
+ * 语义:本会话**已注入**的记忆,以及其中「此刻仍在上下文里」的那部分。
5
+ * 数据源是 `SessionRecalledStore`(sessionKey 键、写穿持久化),所以**旧会话重开也读得到**。
6
+ *
7
+ * 压缩/清空对这张表的作用不同(2026-10-13 用户口径):
8
+ * - `clear`(主动清空)整表清,view 随之空;
9
+ * - `compact`(摘要替换)**不删条目**,只把当时在列表里的那批标成 `superseded`(正文已被
10
+ * 摘要取代、已不在上下文里)。读侧据此把旧召回与当前召回区分开,而不是继续谎报“还在”。
11
+ * 已退场(`retired`)的条目必须留在列表里(人的裁决凭证),取代标记与它正交。
7
12
  *
8
13
  * 与 memorySlots 的分工(两条轴,互不替代):
9
14
  * - `memorySlots` = 用户手动 pin 的**常驻约定**(每轮硬注入,注册表语义);
@@ -19,6 +24,7 @@
19
24
  */
20
25
  import type { Context } from '@deepseek-ai/cordis';
21
26
  import type { SessionRecalledStore } from '../store/session-recalled.js';
27
+ import type { MemoryRecord } from '../types.js';
22
28
  export declare const MEMORY_RECALL_KEY = "memoryRecall";
23
29
  /** view 最多透出条数(最近在上;超出部分只计入 count,防 wire 体量失控)。 */
24
30
  export declare const RECALL_VIEW_CAP = 20;
@@ -31,13 +37,44 @@ export interface RecallItemView {
31
37
  priority: number;
32
38
  /** 使用场景(metadata.dsh_usage_scenario);无则缺席(附加字段,旧帧没有也整条通过)。 */
33
39
  scenario?: string[];
40
+ /**
41
+ * 人给的最近一次打分(-1..5);未标注则缺席。
42
+ * 卡片靠它把已打的那一笔显示成选中态;均值等细粒度数据走 `session-recalled` 端点。
43
+ */
44
+ rating?: number;
45
+ /**
46
+ * 正文全文(截断至 BODY_MAX);消费方悬停泡泡直接读它,不再回查端点。
47
+ * 缺席 = 旧帧(读侧退化成只显示 title)。
48
+ */
49
+ body?: string;
50
+ /**
51
+ * 该记录已被判定过时(`isRetired`)。只写 true:未退场时字段缺席,
52
+ * 读侧按「缺席即活跃」处理,旧帧自然兼容。
53
+ */
54
+ retired?: true;
55
+ /**
56
+ * 该条目的正文**已被摘要取代**(压缩发生时时它就在列表里),即不在模型上下文里了。
57
+ * 只写 true(同 `retired` 的兼容手法);与 `retired` 正交,两者可共存。
58
+ * 读侧据此把「已被摘要取代的旧召回」与「此刻仍在上下文里的召回」区分开。
59
+ */
60
+ superseded?: true;
34
61
  }
35
62
  /** 持久态:本会话的活召回 id 集 + 判脏序号。 */
36
63
  export interface RecallProjectionState {
37
64
  rev: number;
65
+ /**
66
+ * 反馈 sidecar 的序号(打分也会让 view 变:条目要显示已打的那一笔)。
67
+ * 可选:旧 checkpoint 帧里没这个字段,读侧必须容忍。
68
+ */
69
+ fb?: number;
38
70
  sessionId: string;
39
71
  /** 注入序(旧→新),由 store 保证。 */
40
72
  ids: string[];
73
+ /**
74
+ * 已被摘要取代的 id(压缩水位之上的那批)。可选:旧 checkpoint 没有这个字段,
75
+ * 读侧必须容忍(缺席 = 无取代,与改动前逐字一致)。
76
+ */
77
+ superseded?: string[];
41
78
  }
42
79
  /** wire view:count 是总数,items 是**最近在上**的截断视图。 */
43
80
  export interface RecallView {
@@ -54,21 +91,22 @@ interface SchemaLike<T> {
54
91
  export declare const stateSchema: SchemaLike<RecallProjectionState>;
55
92
  /** view 校验(出网前调用;未知键容忍,同 slots 投影的语义边界)。 */
56
93
  export declare const viewSchema: SchemaLike<RecallView>;
57
- /** 投影需要的 L1 只读面(结构化声明,避免跨模块类型耦合)。 */
94
+ /** 投影需要的 L1 只读面(结构化声明,避免跨模块类型耦合)。
95
+ * 判退场要 `validTo`/`metadata`,故直接 Pick 自 MemoryRecord,不在投影里自写时间比较。 */
58
96
  export interface RecallL1Face {
59
- getByIds(ids: string[]): Array<{
60
- id: string;
61
- type: string;
62
- content: string;
63
- priority: number;
64
- metadata?: Record<string, unknown>;
65
- }>;
97
+ getByIds(ids: string[]): Array<Pick<MemoryRecord, 'id' | 'type' | 'content' | 'priority' | 'metadata' | 'validTo'>>;
98
+ }
99
+ /** 投影需要的反馈只读面(缺席 = view 不带 rating,旧行为)。 */
100
+ export interface RecallFeedbackFace {
101
+ revision(): number;
102
+ lastOf(id: string): number | undefined;
66
103
  }
67
104
  /**
68
105
  * 注册 memoryRecall 投影。
69
106
  *
70
107
  * 缺席降级同 slots 投影:`ctx.inject` 在服务未提供时不回调;形状不符静默不注册。
71
- * apply 是 O(1) 判脏(store.revision() 比对),任意已提交事件都会带进来看一眼。
108
+ * apply 是 O(1) 判脏(store.revision() 与反馈序号比对),任意已提交事件都会带进来看一眼。
109
+ * @param feedback - 相关度反馈只读面;缺席时 view 不带 rating(旧行为)。
72
110
  */
73
- export declare function registerRecallProjection(ctx: Context, store: SessionRecalledStore, l1?: RecallL1Face): void;
111
+ export declare function registerRecallProjection(ctx: Context, store: SessionRecalledStore, l1?: RecallL1Face, feedback?: RecallFeedbackFace): void;
74
112
  export {};
@@ -1,8 +1,11 @@
1
+ import { isRetired } from '../store/supersede.js';
1
2
  export const MEMORY_RECALL_KEY = 'memoryRecall';
2
3
  /** view 最多透出条数(最近在上;超出部分只计入 count,防 wire 体量失控)。 */
3
4
  export const RECALL_VIEW_CAP = 20;
4
5
  /** 单条标题(正文首行)最大长度。 */
5
6
  const TITLE_MAX = 160;
7
+ /** 单条全文最大长度(泡泡里显示的正文;超出截断并补省略号,防 wire 体量失控)。 */
8
+ const BODY_MAX = 700;
6
9
  function invalid(what, detail) {
7
10
  throw new Error(`${what}: ${detail}`);
8
11
  }
@@ -31,6 +34,11 @@ export const stateSchema = {
31
34
  if (!Array.isArray(root.ids))
32
35
  invalid(what, 'ids 必须是字符串数组');
33
36
  root.ids.forEach((id, i) => requireString(id, `${what}.ids[${i}]`, 'id'));
37
+ if (root.superseded !== undefined) {
38
+ if (!Array.isArray(root.superseded))
39
+ invalid(what, 'superseded 必须是字符串数组');
40
+ root.superseded.forEach((id, i) => requireString(id, `${what}.superseded[${i}]`, 'id'));
41
+ }
34
42
  return value;
35
43
  },
36
44
  };
@@ -56,6 +64,15 @@ export const viewSchema = {
56
64
  invalid(at, 'scenario 必须是字符串数组');
57
65
  item.scenario.forEach((s, j) => requireString(s, `${at}.scenario[${j}]`, 'scenario'));
58
66
  }
67
+ if (item.rating !== undefined && typeof item.rating !== 'number')
68
+ invalid(at, 'rating 必须是数字');
69
+ // 附加字段:旧帧没有 → 容忍;有 → 必须成形状。retired 只认 true,false 视为脏帧。
70
+ if (item.body !== undefined)
71
+ requireString(item.body, at, 'body');
72
+ if (item.retired !== undefined && item.retired !== true)
73
+ invalid(at, 'retired 只能是 true 或缺席');
74
+ if (item.superseded !== undefined && item.superseded !== true)
75
+ invalid(at, 'superseded 只能是 true 或缺席');
59
76
  });
60
77
  return value;
61
78
  },
@@ -66,14 +83,22 @@ function firstLine(content) {
66
83
  const trimmed = line.trim();
67
84
  return trimmed.length > TITLE_MAX ? trimmed.slice(0, TITLE_MAX) : trimmed;
68
85
  }
86
+ /** 正文全文 → 泡泡用(去首尾空白,超长截断补省略号)。 */
87
+ function clampBody(content) {
88
+ const s = content.trim();
89
+ return s.length > BODY_MAX ? `${s.slice(0, BODY_MAX)}…` : s;
90
+ }
69
91
  /** 读 store 建态;ids 与旧态一致时返回旧引用(避免无谓 republish)。 */
70
- function buildState(sessionId, store, prev) {
92
+ function buildState(sessionId, store, feedbackRev, prev) {
71
93
  const ids = store.list(sessionId);
72
- if (prev !== undefined && prev.rev === store.revision() && sameIds(prev.ids, ids))
94
+ const superseded = [...store.supersededOf(sessionId)];
95
+ const sameCounters = prev !== undefined && prev.rev === store.revision() && (prev.fb ?? 0) === feedbackRev;
96
+ if (sameCounters && sameIds(prev.ids, ids) && sameIds(prev.superseded ?? [], superseded))
73
97
  return prev;
74
- const next = { rev: store.revision(), sessionId, ids };
75
- // rev 变了但本会话集合没变(别的会话写了)→ 仍复用旧引用,防跨会话 thrash。
76
- if (prev !== undefined && prev.sessionId === sessionId && sameIds(prev.ids, ids))
98
+ const next = { rev: store.revision(), fb: feedbackRev, sessionId, ids, superseded };
99
+ // rev 变了但本会话集合、取代集与反馈都没变(别的会话写了)→ 仍复用旧引用,防跨会话 thrash。
100
+ if (prev !== undefined && prev.sessionId === sessionId && sameIds(prev.ids, ids)
101
+ && sameIds(prev.superseded ?? [], superseded) && (prev.fb ?? 0) === feedbackRev)
77
102
  return prev;
78
103
  return next;
79
104
  }
@@ -87,13 +112,14 @@ function sameIds(a, b) {
87
112
  }
88
113
  /** view 引用稳定:同一 state 恒返回同一对象(registry 按 Object.is 判发布)。 */
89
114
  const viewMemo = new WeakMap();
90
- function makeView(state, l1) {
115
+ function makeView(state, l1, feedback) {
91
116
  const memoized = viewMemo.get(state);
92
117
  if (memoized !== undefined)
93
118
  return memoized;
94
119
  const recentFirst = [...state.ids].reverse();
95
120
  const picked = recentFirst.slice(0, RECALL_VIEW_CAP);
96
121
  const byId = new Map((l1?.getByIds(picked) ?? []).map((r) => [r.id, r]));
122
+ const superseded = new Set(state.superseded ?? []);
97
123
  const items = [];
98
124
  for (const id of picked) {
99
125
  const r = byId.get(id);
@@ -102,12 +128,19 @@ function makeView(state, l1) {
102
128
  const scenario = Array.isArray(r.metadata?.dsh_usage_scenario)
103
129
  ? r.metadata?.dsh_usage_scenario
104
130
  : undefined;
131
+ const rating = feedback?.lastOf(id);
105
132
  items.push({
106
133
  id: r.id,
107
134
  type: r.type,
108
135
  title: firstLine(r.content),
109
136
  priority: r.priority,
137
+ body: clampBody(r.content),
110
138
  ...(scenario !== undefined && scenario.length > 0 ? { scenario } : {}),
139
+ ...(typeof rating === 'number' ? { rating } : {}),
140
+ // 退场记录仍留在列表里(文本曾是上下文的一部分,且判过时是人的裁决凭证),只是该打上删除线。
141
+ ...(isRetired(r) ? { retired: true } : {}),
142
+ // 已被摘要取代:正文已不在上下文里。与 retired 正交,两者可同时在。
143
+ ...(superseded.has(id) ? { superseded: true } : {}),
111
144
  });
112
145
  }
113
146
  const next = { rev: state.rev, sessionId: state.sessionId, count: state.ids.length, items };
@@ -125,22 +158,24 @@ function sessionIdOf(header) {
125
158
  * 注册 memoryRecall 投影。
126
159
  *
127
160
  * 缺席降级同 slots 投影:`ctx.inject` 在服务未提供时不回调;形状不符静默不注册。
128
- * apply 是 O(1) 判脏(store.revision() 比对),任意已提交事件都会带进来看一眼。
161
+ * apply 是 O(1) 判脏(store.revision() 与反馈序号比对),任意已提交事件都会带进来看一眼。
162
+ * @param feedback - 相关度反馈只读面;缺席时 view 不带 rating(旧行为)。
129
163
  */
130
- export function registerRecallProjection(ctx, store, l1) {
164
+ export function registerRecallProjection(ctx, store, l1, feedback) {
131
165
  ctx.inject(['sessionProjections'], (injected) => {
132
166
  const registry = injected.sessionProjections;
133
167
  if (registry === undefined || typeof registry.register !== 'function')
134
168
  return;
169
+ const fbRev = () => feedback?.revision() ?? 0;
135
170
  // 双代平面(同 slots 投影):0.1.0-rc.8 的 registry 走平面 schema/view,0.1.1+ 走 wire。
136
171
  const definition = {
137
172
  key: MEMORY_RECALL_KEY,
138
173
  stateSchema,
139
- init: (header) => buildState(sessionIdOf(header), store),
140
- apply: (state) => buildState(state.sessionId, store, state),
141
- wire: { viewSchema, view: (state) => makeView(state, l1) },
174
+ init: (header) => buildState(sessionIdOf(header), store, fbRev()),
175
+ apply: (state) => buildState(state.sessionId, store, fbRev(), state),
176
+ wire: { viewSchema, view: (state) => makeView(state, l1, feedback) },
142
177
  schema: viewSchema,
143
- view: (state) => makeView(state, l1),
178
+ view: (state) => makeView(state, l1, feedback),
144
179
  stateVersion: 0,
145
180
  };
146
181
  registry.register(definition);
@@ -0,0 +1,73 @@
1
+ /** 示例集总长预算(字符)。超了就该减条数,而不是悄悄放宽。 */
2
+ export declare const FEW_SHOT_BUDGET = 1400;
3
+ /** 一条止例:会话片段 → 模型该产出的 title/content 对。 */
4
+ interface GoodExample {
5
+ /** 类型(与 G2 骨架同名,方便对照)。 */
6
+ type: string;
7
+ title: string;
8
+ content: string;
9
+ }
10
+ /**
11
+ * 止例集。每条的 content 都按 G2 骨架写成"前因后果",title 是它的名词短语摘要。
12
+ *
13
+ * 刻意覆盖到 title 的四种难头:含英文标识符(吃字符额度)、含数字(容易被当 id 残片)、
14
+ * 纯中文短语、以及"主语是团队/用户"的正当名词短语(这几个词作**短语**开头是合法的,
15
+ * 只有叙述引子才算话头)。
16
+ */
17
+ export declare const TITLE_GOOD_EXAMPLES: readonly GoodExample[];
18
+ /** 反例:模型最常犯的三种错,各给一条"不要这样"。 */
19
+ export declare const TITLE_BAD_EXAMPLES: ReadonlyArray<{
20
+ title: string;
21
+ why: string;
22
+ }>;
23
+ /** 供用例与提示词共用:把止例渲染成"输入 → 输出对象"的示范块。 */
24
+ export declare function renderGoodExample(g: GoodExample): string;
25
+ /**
26
+ * 追加到抽取系统提示尾部的示例段。
27
+ *
28
+ * 只在 `cfg.naming.fewShot` 为真时拼上(默认关):它每轮都收固定成本,值不值要靠
29
+ * A/B 的 fallback 率说话,不能靠"看起来更专业"。
30
+ */
31
+ export declare const EXTRACTION_FEW_SHOT: string;
32
+ /**
33
+ * 各类型的"前因后果"要素表(照 G2 `NARRATIVE_CLAUSE` 写,不另造一套定义)。
34
+ *
35
+ * 为什么需要它:可用性判据是用户定的——"content 没前因后果、写不全就不算可用"。
36
+ * 那句活若只留在提示词里就永远没法判定、也没法拿它定默认值,所以每个类型拆 3 个要素,
37
+ * 每个要素给一组词元(命中任一即算在)。
38
+ *
39
+ * **这是关键词启发式,不是语义判定**:要素换个说法就接不住;G2 自己也允许"信息不足则省略该节"。
40
+ * 因此它只能用于**同一输入下两臂相对比较**,不能当绝对质量分。
41
+ */
42
+ export declare const SKELETON_ELEMENTS: Readonly<Record<string, ReadonlyArray<{
43
+ name: string;
44
+ anyOf: readonly string[];
45
+ }>>>;
46
+ /** 骨架打分结果。 */
47
+ export interface SkeletonScore {
48
+ 类型: string;
49
+ 要素数: number;
50
+ 命中数: number;
51
+ 缺: string[];
52
+ /** 所有要素都命中(且正文非空)。 */
53
+ 齐备: boolean;
54
+ /** 该类型不在要素表里(分数仅供参考,不拿它下结论)。 */
55
+ 无表可依: boolean;
56
+ }
57
+ /**
58
+ * 按类型给 content 打骨架分(关键词启发式,局限见 {@link SKELETON_ELEMENTS})。
59
+ *
60
+ * @param type - 记忆类型。
61
+ * @param content - 模型产出的正文。
62
+ * @returns 命中情况;空正文算 0 命中而不是"齐备"。
63
+ */
64
+ export declare function scoreSkeleton(type: string, content: string): SkeletonScore;
65
+ /** 自检:示例本身是否合格(用例直接吃这个函数,不在用例里手抄判据)。 */
66
+ export declare function auditFewShot(): {
67
+ 超预算: boolean;
68
+ 长度: number;
69
+ 不合格title: string[];
70
+ 抄content开头: string[];
71
+ 止例骨架不全: string[];
72
+ };
73
+ export {};
@@ -0,0 +1,178 @@
1
+ /**
2
+ * 名称蒸馏(Phase 3)的标准格式示例集。
3
+ *
4
+ * 为什么必须有示例而不是只给定义:只写"6–14 字名词短语"这句散文,模型就是照着它自由
5
+ * 发挥——真机 `deriveName` 那批名字正是"看起来像定义、实际是句子残片"。示例是唯一能让
6
+ * 模型看清 **title 与 content 边界**的形状:两者必须在同一个例子里并列可见。
7
+ *
8
+ * 三条硬约束(都有对应用例钉着,不靠自觉):
9
+ * 1. **每个止例的 title 都必须过 `validateTitle`**——否则等于示范一个会被自己闸门拒掉的名字;
10
+ * 2. **每个止例的 content 都必须就是 `narrativeTemplates`(G2)那套骨架的产物**——否则同时开
11
+ * 两个开关时,模型在一份提示里看到两套互相矛盾的示范;
12
+ * 3. **总长有预算上限**:这份示例每轮蒸馏都要付,是固定成本,不是免费午餐。
13
+ *
14
+ * 反例同样必要:模型最容易犯的三类错(抄句首、塞路径、留话头)各给一条"不要这样"。
15
+ */
16
+ import { NAME_MAX, validateTitle } from '../util/md-name.js';
17
+ /** 示例集总长预算(字符)。超了就该减条数,而不是悄悄放宽。 */
18
+ export const FEW_SHOT_BUDGET = 1400;
19
+ /**
20
+ * 止例集。每条的 content 都按 G2 骨架写成"前因后果",title 是它的名词短语摘要。
21
+ *
22
+ * 刻意覆盖到 title 的四种难头:含英文标识符(吃字符额度)、含数字(容易被当 id 残片)、
23
+ * 纯中文短语、以及"主语是团队/用户"的正当名词短语(这几个词作**短语**开头是合法的,
24
+ * 只有叙述引子才算话头)。
25
+ */
26
+ export const TITLE_GOOD_EXAMPLES = [
27
+ {
28
+ type: 'work_fact',
29
+ title: 'npm发布必须显式指定registry',
30
+ content: '结论:npm publish 必须显式带 --registry=https://registry.npmjs.org。依据:默认 registry 指向镜像时发布打到镜像会失败或错投,本机 ~/.npmrc 实测如此。适用范围:所有对外发包;镜像有同步延迟,view 也必须钉源。',
31
+ },
32
+ {
33
+ type: 'work_method',
34
+ title: '记忆改名后的孤儿文件归档流程',
35
+ content: '目标:把改名后无人认领的投射文件收掉,避免活档案堆积。步骤:先按账本双指纹判定谁变了,回灌或重写使两侧一致,再扫描账本键与记录落点的差集,把孤儿搬进归档目录(不删,保留相对形状)。注意:来路不明的文件一律不动,那可能是人放进来等着同步的输入。',
36
+ },
37
+ {
38
+ type: 'work_task',
39
+ title: '六线宿主兼容冒烟任务状态',
40
+ content: '任务:为 dsh 插件做六线宿主冒烟。当前状态:0.2.0 与 0.1.7 两线已跑通,其余四线未跑。下一步/阻塞:需要保留场格子里的凭据刷新,负责人为仓库维护者,截止未定。',
41
+ },
42
+ {
43
+ type: 'work_artifact',
44
+ title: '宿主子系统解读文档集',
45
+ content: '交付物:宿主内部架构的 digest 分片文档集。位置:仓库 docs 目录下的模块化分片。用途/结论:给插件作者定位"要改哪一层",源码锚点侧车标注 file:line 与对应宿主版本。',
46
+ },
47
+ {
48
+ type: 'instruction',
49
+ title: '修改用户环境前须确认可逆性与影响范围',
50
+ content: '规则:任何改动用户环境(配置、依赖、profile、运行中实例)之前必须先说明可逆性与爆炸范围并获确认。触发时机:需要重启服务、改写配置、安装依赖、发布到 registry 时。反例/边界:只读取证与临时目录内的自检不受此限,不必为读操作索要许可。',
51
+ },
52
+ {
53
+ type: 'persona',
54
+ title: '偏好短命令先行后解释的沟通风格',
55
+ content: '偏好:回复要先给可执行的下一步,再给理由。表现:长铺垫会被打断,明确要求"别写前言和收尾"。强度:跨多个会话稳定成立,属硬性偏好。',
56
+ },
57
+ ];
58
+ /** 反例:模型最常犯的三种错,各给一条"不要这样"。 */
59
+ export const TITLE_BAD_EXAMPLES = [
60
+ { title: '用户要求以后所有 npm publish 必须显式带参数', why: '是个句子,且以叙述引子开头——title 回答"讲的是什么",不是"谁说了什么"' },
61
+ { title: '保存到 docs/report.md 的调研结论', why: '含路径分隔符,且"保存到…"是动作不是主题' },
62
+ { title: '会话-session-44332549-f05e', why: '残留 id 片段,换一批记录就换个名字,不能当身份' },
63
+ ];
64
+ /** 止例里最不该出现的一件事:title 直接抄 content 开头。挑一条做自检。 */
65
+ function copiesContentStart(g) {
66
+ const c = g.content.trim();
67
+ return c.startsWith(g.title) || g.title.length >= NAME_MAX;
68
+ }
69
+ /** 供用例与提示词共用:把止例渲染成"输入 → 输出对象"的示范块。 */
70
+ export function renderGoodExample(g) {
71
+ return `type=${g.type}\ntitle:${g.title}\ncontent:${g.content}`;
72
+ }
73
+ /**
74
+ * 追加到抽取系统提示尾部的示例段。
75
+ *
76
+ * 只在 `cfg.naming.fewShot` 为真时拼上(默认关):它每轮都收固定成本,值不值要靠
77
+ * A/B 的 fallback 率说话,不能靠"看起来更专业"。
78
+ */
79
+ export const EXTRACTION_FEW_SHOT = `
80
+
81
+ 【标准格式示例】下面每例的 title 与 content 是**并列的两个产物**:title 是名词短语(回答"这条讲的是什么"),content 是完整叙述正文。**不要**把 content 的开头抄成 title。
82
+
83
+ ${TITLE_GOOD_EXAMPLES.map(renderGoodExample).join('\n\n')}
84
+
85
+ 【不要这样】
86
+ ${TITLE_BAD_EXAMPLES.map((b) => `- ✗ ${b.title} → ${b.why}`).join('\n')}
87
+ `;
88
+ /**
89
+ * 各类型的"前因后果"要素表(照 G2 `NARRATIVE_CLAUSE` 写,不另造一套定义)。
90
+ *
91
+ * 为什么需要它:可用性判据是用户定的——"content 没前因后果、写不全就不算可用"。
92
+ * 那句活若只留在提示词里就永远没法判定、也没法拿它定默认值,所以每个类型拆 3 个要素,
93
+ * 每个要素给一组词元(命中任一即算在)。
94
+ *
95
+ * **这是关键词启发式,不是语义判定**:要素换个说法就接不住;G2 自己也允许"信息不足则省略该节"。
96
+ * 因此它只能用于**同一输入下两臂相对比较**,不能当绝对质量分。
97
+ */
98
+ export const SKELETON_ELEMENTS = {
99
+ work_method: [
100
+ { name: '目标', anyOf: ['目标', '目的', '为了', '旨在'] },
101
+ { name: '步骤', anyOf: ['步骤', '做法', '先', '再', '然后', '流程', '执行'] },
102
+ { name: '前置/注意', anyOf: ['前置', '注意', '前提', '坑', '风险', '必须', '不能'] },
103
+ ],
104
+ work_fact: [
105
+ { name: '结论/事实', anyOf: ['结论', '事实', '必须', '只能', '会', '导致', '默认'] },
106
+ { name: '依据', anyOf: ['因为', '由于', '实测', '验证', '确认', '依据', '来源', '试过'] },
107
+ { name: '适用范围/失效', anyOf: ['适用', '范围', '失效', '仅', '除非', '例外'] },
108
+ ],
109
+ work_task: [
110
+ { name: '任务', anyOf: ['任务', '需求', '要做', '目标', '工作'] },
111
+ { name: '当前状态', anyOf: ['当前', '状态', '进度', '已', '未', '完成', '跑通'] },
112
+ { name: '下一步/阻塞', anyOf: ['下一步', '阻塞', '待', '计划', '负责人', '截止', '尚需'] },
113
+ ],
114
+ work_artifact: [
115
+ { name: '交付物', anyOf: ['交付', '文档', '报告', '产物', '脚本', '清单', '模块'] },
116
+ { name: '位置', anyOf: ['位置', '路径', '目录', '链接', '存放', '落到', '位于'] },
117
+ { name: '用途/结论', anyOf: ['用途', '供', '用于', '结论', '便于', '可用'] },
118
+ ],
119
+ instruction: [
120
+ { name: '规则', anyOf: ['规则', '必须', '不得', '禁止', '要求', '一律'] },
121
+ { name: '触发时机', anyOf: ['触发', '时机', '当', '若', '之前', '之后'] },
122
+ { name: '反例/边界', anyOf: ['反例', '边界', '例外', '不适用', '除非', '不必', '豁免'] },
123
+ ],
124
+ episodic: [
125
+ { name: '事件', anyOf: ['发生', '做了', '完成', '处理', '排查', '提交'] },
126
+ { name: '时间/参与者', anyOf: ['时间', '于', '日', '当天', '用户', '团队', '会话'] },
127
+ { name: '结果', anyOf: ['结果', '产出', '导致', '得以'] },
128
+ ],
129
+ persona: [
130
+ { name: '偏好/习惯', anyOf: ['偏好', '习惯', '喜欢', '倾向', '风格'] },
131
+ { name: '表现/例子', anyOf: ['表现', '例子', '例如', '总是', '会'] },
132
+ { name: '强度', anyOf: ['一直', '从不', '总是', '硬性', '强烈', '优先', '稳定'] },
133
+ ],
134
+ };
135
+ /** 不在表里的类型(宿主那 29 类新词)共用一个兼容要素集,不拿未知类型假判完备。 */
136
+ const GENERIC_ELEMENTS = [
137
+ { name: '事实', anyOf: ['是', '为', '必须', '会', '有'] },
138
+ { name: '原因', anyOf: ['因为', '由于', '原因', '实测', '验证'] },
139
+ { name: '边界', anyOf: ['适用', '范围', '仅', '除非', '注意'] },
140
+ ];
141
+ /**
142
+ * 按类型给 content 打骨架分(关键词启发式,局限见 {@link SKELETON_ELEMENTS})。
143
+ *
144
+ * @param type - 记忆类型。
145
+ * @param content - 模型产出的正文。
146
+ * @returns 命中情况;空正文算 0 命中而不是"齐备"。
147
+ */
148
+ export function scoreSkeleton(type, content) {
149
+ const spec = SKELETON_ELEMENTS[type] ?? GENERIC_ELEMENTS;
150
+ const text = typeof content === 'string' ? content : '';
151
+ const 缺 = [];
152
+ for (const el of spec) {
153
+ if (!el.anyOf.some((k) => text.includes(k)))
154
+ 缺.push(el.name);
155
+ }
156
+ return {
157
+ 类型: type,
158
+ 要素数: spec.length,
159
+ 命中数: spec.length - 缺.length,
160
+ 缺,
161
+ 齐备: text.trim().length > 0 && 缺.length === 0,
162
+ 无表可依: SKELETON_ELEMENTS[type] === undefined,
163
+ };
164
+ }
165
+ /** 自检:示例本身是否合格(用例直接吃这个函数,不在用例里手抄判据)。 */
166
+ export function auditFewShot() {
167
+ const badTitles = TITLE_GOOD_EXAMPLES.filter((g) => !validateTitle(g.title).ok).map((g) => g.title);
168
+ // 止例自己必须骨架齐备:否则示例就是在示范"只写一句",而定义与判据全部白写。
169
+ const thin = TITLE_GOOD_EXAMPLES.filter((g) => !scoreSkeleton(g.type, g.content).齐备)
170
+ .map((g) => `${g.type}:${scoreSkeleton(g.type, g.content).缺.join('/')}`);
171
+ return {
172
+ 超预算: EXTRACTION_FEW_SHOT.length > FEW_SHOT_BUDGET,
173
+ 长度: EXTRACTION_FEW_SHOT.length,
174
+ 不合格title: badTitles,
175
+ 抄content开头: TITLE_GOOD_EXAMPLES.filter(copiesContentStart).map((g) => g.title),
176
+ 止例骨架不全: thin,
177
+ };
178
+ }