dsh-prime-memory 0.22.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.en.md +401 -0
  2. package/CHANGELOG.ja.md +401 -0
  3. package/CHANGELOG.ko.md +401 -0
  4. package/CHANGELOG.md +100 -0
  5. package/dist/client.js +1951 -1188
  6. package/dist/config.d.ts +176 -1240
  7. package/dist/config.js +113 -2
  8. package/dist/conflict-service.d.ts +12 -0
  9. package/dist/conflict-service.js +24 -0
  10. package/dist/contract.d.ts +327 -0
  11. package/dist/domain-gate.d.ts +19 -0
  12. package/dist/domain-gate.js +45 -0
  13. package/dist/graph/constraints.d.ts +4 -2
  14. package/dist/graph/constraints.js +4 -2
  15. package/dist/hooks/obsolete-notice.d.ts +26 -0
  16. package/dist/hooks/obsolete-notice.js +64 -0
  17. package/dist/hooks/recall.d.ts +41 -0
  18. package/dist/hooks/recall.js +127 -28
  19. package/dist/hooks/skill-recall.d.ts +25 -0
  20. package/dist/hooks/skill-recall.js +72 -0
  21. package/dist/index.d.ts +1 -1029
  22. package/dist/index.js +55 -2
  23. package/dist/memory-types.d.ts +69 -0
  24. package/dist/memory-types.js +165 -0
  25. package/dist/memport/format.d.ts +75 -0
  26. package/dist/memport/format.js +345 -0
  27. package/dist/memport/prefilter.d.ts +62 -0
  28. package/dist/memport/prefilter.js +132 -0
  29. package/dist/memport/scan.d.ts +13 -0
  30. package/dist/memport/scan.js +100 -0
  31. package/dist/metadata-validators.d.ts +22 -0
  32. package/dist/metadata-validators.js +45 -0
  33. package/dist/pipeline/l1.d.ts +44 -2
  34. package/dist/pipeline/l1.js +177 -9
  35. package/dist/pipeline/md-sync.d.ts +65 -0
  36. package/dist/pipeline/md-sync.js +456 -0
  37. package/dist/pipeline/ruminate.d.ts +46 -1
  38. package/dist/pipeline/ruminate.js +150 -1
  39. package/dist/pipeline/runner.d.ts +10 -0
  40. package/dist/pipeline/runner.js +7 -1
  41. package/dist/pipeline/slot-migrate.d.ts +45 -0
  42. package/dist/pipeline/slot-migrate.js +45 -0
  43. package/dist/pipeline/workspace-relink.d.ts +60 -0
  44. package/dist/pipeline/workspace-relink.js +73 -0
  45. package/dist/projection/recall.d.ts +112 -0
  46. package/dist/projection/recall.js +183 -0
  47. package/dist/projection/slots.d.ts +5 -0
  48. package/dist/prompts/examples.d.ts +73 -0
  49. package/dist/prompts/examples.js +178 -0
  50. package/dist/prompts/graph-projection.js +3 -0
  51. package/dist/prompts/l1-dedup.d.ts +9 -3
  52. package/dist/prompts/l1-dedup.js +34 -13
  53. package/dist/prompts/l1-extraction.d.ts +9 -4
  54. package/dist/prompts/l1-extraction.js +76 -8
  55. package/dist/settings.d.ts +8 -0
  56. package/dist/settings.js +15 -1
  57. package/dist/skills/crystallize.d.ts +41 -0
  58. package/dist/skills/crystallize.js +75 -0
  59. package/dist/stats.d.ts +30 -1
  60. package/dist/stats.js +504 -13
  61. package/dist/store/embedding-source.d.ts +8 -0
  62. package/dist/store/embedding-source.js +39 -2
  63. package/dist/store/file-versions.d.ts +4 -0
  64. package/dist/store/file-versions.js +4 -0
  65. package/dist/store/graph-store.d.ts +14 -0
  66. package/dist/store/graph-store.js +35 -0
  67. package/dist/store/l1-snapshot.d.ts +26 -1
  68. package/dist/store/l1-snapshot.js +53 -8
  69. package/dist/store/l1.d.ts +77 -3
  70. package/dist/store/l1.js +128 -3
  71. package/dist/store/memory-md-archive.d.ts +84 -0
  72. package/dist/store/memory-md-archive.js +155 -0
  73. package/dist/store/memory-md-disable.d.ts +23 -0
  74. package/dist/store/memory-md-disable.js +106 -0
  75. package/dist/store/memory-md-export.d.ts +50 -0
  76. package/dist/store/memory-md-export.js +200 -0
  77. package/dist/store/memory-md-ledger.d.ts +94 -0
  78. package/dist/store/memory-md-ledger.js +243 -0
  79. package/dist/store/memory-md-orphan.d.ts +27 -0
  80. package/dist/store/memory-md-orphan.js +153 -0
  81. package/dist/store/memory-md-parse.d.ts +35 -0
  82. package/dist/store/memory-md-parse.js +85 -0
  83. package/dist/store/memory-md-skeleton.d.ts +18 -0
  84. package/dist/store/memory-md-skeleton.js +61 -0
  85. package/dist/store/memory-md-types.d.ts +174 -0
  86. package/dist/store/memory-md-types.js +10 -0
  87. package/dist/store/memory-md.d.ts +108 -0
  88. package/dist/store/memory-md.js +219 -0
  89. package/dist/store/memory-sources.d.ts +59 -0
  90. package/dist/store/memory-sources.js +214 -0
  91. package/dist/store/obsolete-notices.d.ts +39 -0
  92. package/dist/store/obsolete-notices.js +153 -0
  93. package/dist/store/recall-feedback.d.ts +75 -0
  94. package/dist/store/recall-feedback.js +213 -0
  95. package/dist/store/recall-usage.d.ts +11 -0
  96. package/dist/store/recall-usage.js +29 -0
  97. package/dist/store/session-recalled.d.ts +60 -0
  98. package/dist/store/session-recalled.js +310 -0
  99. package/dist/store/skill-store.d.ts +99 -0
  100. package/dist/store/skill-store.js +330 -0
  101. package/dist/store/sqlite.d.ts +71 -0
  102. package/dist/store/sqlite.js +259 -15
  103. package/dist/store/supersede.d.ts +1 -1
  104. package/dist/store/supersede.js +1 -1
  105. package/dist/tools/index.d.ts +4 -13
  106. package/dist/tools/index.js +80 -12
  107. package/dist/tools/skills.d.ts +13 -0
  108. package/dist/tools/skills.js +130 -0
  109. package/dist/types.d.ts +30 -7
  110. package/dist/types.js +10 -2
  111. package/dist/util/maturity.d.ts +44 -0
  112. package/dist/util/maturity.js +43 -0
  113. package/dist/util/md-name.d.ts +129 -0
  114. package/dist/util/md-name.js +366 -0
  115. package/dist/util/md-normalize.d.ts +23 -0
  116. package/dist/util/md-normalize.js +131 -0
  117. package/dist/wing-backfill.js +7 -4
  118. package/dist/workspace-route.d.ts +22 -0
  119. package/dist/workspace-route.js +66 -0
  120. package/dsh.plugin.json +1 -1
  121. package/package.json +6 -5
@@ -9,6 +9,7 @@
9
9
  * 可解释性:每次召回把 8 个域权重写进日志(可读出),权重计算是纯函数、可回归。
10
10
  */
11
11
  import { WING_FALLBACK } from './types.js';
12
+ import { markSignal } from './util/maturity.js';
12
13
  /** 8 条域锚文本(单一事实源):一段描述该域典型内容的短文本,供向量化。
13
14
  * keywords 是嵌入关闭时的关键词降级匹配词表(命中任一 = 该域相关)。 */
14
15
  export const WING_ANCHORS = [
@@ -149,3 +150,47 @@ export function sortByDomainWeight(hits, wingOf, weights) {
149
150
  };
150
151
  return [...hits].sort((a, b) => weightOf(b.id) - weightOf(a.id) || b.score - a.score);
151
152
  }
153
+ /** 分词(小写、按非字母数字/非 CJK 切、去长度<2 的碎片)。供场景词重叠用。 */
154
+ function normTokens(s) {
155
+ return new Set(s.toLowerCase().split(/[^0-9a-z\u4e00-\u9fa5]+/).map((t) => t.trim()).filter((t) => t.length >= 2));
156
+ }
157
+ /**
158
+ * G1 使用场景细排(纯函数、**score 不改写**、默认关时不进此路径):
159
+ * 命中查询词的 usageScenario 记忆上浮;未命中/无场景的记录保持原序(fail-open)。
160
+ * 保守:只按“命中强度”重排,不引入新相关性判断;无查询词时原样返回。
161
+ */
162
+ export function sortByUsageScenario(hits, scenariosOf, query) {
163
+ const q = normTokens(query);
164
+ if (q.size === 0)
165
+ return [...hits];
166
+ const matchOf = (id) => {
167
+ let best = 0;
168
+ for (const s of scenariosOf(id)) {
169
+ let ov = 0;
170
+ for (const t of normTokens(s))
171
+ if (q.has(t))
172
+ ov++;
173
+ if (ov > best)
174
+ best = ov;
175
+ }
176
+ return best;
177
+ };
178
+ return hits
179
+ .map((h, i) => ({ h, i, m: matchOf(h.id) }))
180
+ .sort((a, b) => b.m - a.m || a.i - b.i)
181
+ .map((x) => x.h);
182
+ }
183
+ /**
184
+ * 人工打分参与排序(纯重排、**score 不改写**):4/5 上浮、0/1/2 下沉。
185
+ *
186
+ * 三条约束:
187
+ * - 同档内保持进入顺序(稳定排序),所以不会推翻 scopeFence / 场景细排的结果;
188
+ * - **未打分与 3 分同为中性**(fail-open),全部未打分时输出与输入逐字同序;
189
+ * - 判据是**最近一笔**而非均值——用户改主意的路径必须存在(先 5 后 0 应当下沉)。
190
+ */
191
+ export function sortByHumanMark(hits, markOf) {
192
+ return hits
193
+ .map((h, i) => ({ h, i, s: markSignal(markOf(h.id)) }))
194
+ .sort((a, b) => b.s - a.s || a.i - b.i)
195
+ .map((x) => x.h);
196
+ }
@@ -13,8 +13,10 @@ export declare const GRAPH_NODE_TYPE_SET: ReadonlySet<string>;
13
13
  /** 记录生命周期四态(节点/fact/边通用)。 */
14
14
  export declare const GRAPH_STATUSES: readonly GraphRecordStatus[];
15
15
  export declare const GRAPH_STATUS_SET: ReadonlySet<string>;
16
- /** 规范中文关系词(提示词给枚举;校验不做白名单——关系是开放词表,只做长度钳制)。 */
17
- export declare const GRAPH_RELATION_WORDS: readonly ["使用", "属于", "创建", "参与", "贡献", "依赖", "位于", "相关"];
16
+ /** 规范中文关系词(提示词给枚举;校验不做白名单——关系是开放词表,只做长度钳制)。
17
+ * 后四个为因果边(导致/因为/修复了/取代)——让"前因后果"跨记录成链;仅当 graph.enabled 时
18
+ * 才参与投影(整块图谱默认关,不碰非图谱用户的蒸馏产物)。 */
19
+ export declare const GRAPH_RELATION_WORDS: readonly ["使用", "属于", "创建", "参与", "贡献", "依赖", "位于", "相关", "导致", "因为", "修复了", "取代"];
18
20
  /** 提案字段上限(校验侧截断/丢弃与提示词内插共用同一组数字)。 */
19
21
  export declare const GRAPH_CAP: {
20
22
  /** 提案内临时引用(ref)长度。 */
@@ -4,8 +4,10 @@ export const GRAPH_NODE_TYPE_SET = new Set(GRAPH_NODE_TYPES);
4
4
  /** 记录生命周期四态(节点/fact/边通用)。 */
5
5
  export const GRAPH_STATUSES = ['active', 'superseded', 'disputed', 'archived'];
6
6
  export const GRAPH_STATUS_SET = new Set(GRAPH_STATUSES);
7
- /** 规范中文关系词(提示词给枚举;校验不做白名单——关系是开放词表,只做长度钳制)。 */
8
- export const GRAPH_RELATION_WORDS = ['使用', '属于', '创建', '参与', '贡献', '依赖', '位于', '相关'];
7
+ /** 规范中文关系词(提示词给枚举;校验不做白名单——关系是开放词表,只做长度钳制)。
8
+ * 后四个为因果边(导致/因为/修复了/取代)——让"前因后果"跨记录成链;仅当 graph.enabled 时
9
+ * 才参与投影(整块图谱默认关,不碰非图谱用户的蒸馏产物)。 */
10
+ export const GRAPH_RELATION_WORDS = ['使用', '属于', '创建', '参与', '贡献', '依赖', '位于', '相关', '导致', '因为', '修复了', '取代'];
9
11
  /** 提案字段上限(校验侧截断/丢弃与提示词内插共用同一组数字)。 */
10
12
  export const GRAPH_CAP = {
11
13
  /** 提案内临时引用(ref)长度。 */
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 「记忆已声明过时」一次性播报 Hook。
3
+ *
4
+ * 用户在召回卡上打 `-1` 之后,记忆库那侧当场退场了,但模型此刻的上下文里那条记忆
5
+ * 还在——它必须被告知"这条已由用户判定过时,别再依据它作答",否则退场只发生在
6
+ * 用户看得见的地方,模型仍在按旧信息干活。
7
+ *
8
+ * 与槽位常驻注入(`slot-recall.ts`)的分工:那是**跨会话常驻**的约定,每轮都在;
9
+ * 本 Hook 是**一次性**的更正通知,`drain` 取走即离队,下一轮就没有了。
10
+ *
11
+ * 门控:`cfg.recallFeedback.enabled && cfg.recallFeedback.announce`。关时不进
12
+ * `agent/pre-step` 的注入分支(零出队、零新帧),既有会话逐字现状。
13
+ *
14
+ * 注入帧形状刻意与 recall/slot 同源(`source.kind:'plugin:memory'` + `form:'recall'`):
15
+ * 改这个形状会让宿主的"记忆召回份额"统计漏判(isOwnRecallSource),是踩过的红线。
16
+ * 正文按宿主 house style 转义闭合标签,防伪造帧边界。
17
+ */
18
+ import type { Context } from '@deepseek-ai/cordis';
19
+ import type { MemoryConfig } from '../config.js';
20
+ import type { ObsoleteNoticeStore } from '../store/obsolete-notices.js';
21
+ import type { MemoryLogger } from '../types.js';
22
+ export interface ObsoleteNoticeController {
23
+ /** 生成本轮播报文本(null = 无待播报);导出供单元测试。会**出队**。 */
24
+ buildInjection(sessionKey: string): string | null;
25
+ }
26
+ export declare function registerObsoleteNotice(ctx: Context, cfg: MemoryConfig, notices: ObsoleteNoticeStore, logger: MemoryLogger): ObsoleteNoticeController;
@@ -0,0 +1,64 @@
1
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
2
+ const SYSTEM_REMINDER_OPEN = '<system-reminder>';
3
+ const SYSTEM_REMINDER_CLOSE = '</system-reminder>';
4
+ const INTRO = '以下几条记忆刚被用户判定为**已过时**,并已从记忆库退场(后续不会再被召回)。' +
5
+ '请勿依据它们作答;若你此前的回答引用过,请主动更正并说明依据已失效。' +
6
+ '这是系统注入的元信息,不是用户发言——无需回应本节内容。';
7
+ /** 单轮最多播报条数(超出折叠;防一次批量误操作灌满上下文)。 */
8
+ const MAX_PER_TURN = 8;
9
+ function escapeFrameBody(body) {
10
+ return body.replaceAll(SYSTEM_REMINDER_CLOSE, '<\\/system-reminder>');
11
+ }
12
+ /** 注入键 = sessionKey(`agent.session?.id ?? agent.id`),与活召回集记账同源。 */
13
+ function sessionKeyOf(agent) {
14
+ const a = agent;
15
+ const sid = a?.session?.id;
16
+ if (typeof sid === 'string' && sid)
17
+ return sid;
18
+ return typeof a?.id === 'string' ? a.id : '';
19
+ }
20
+ export function registerObsoleteNotice(ctx, cfg, notices, logger) {
21
+ const on = () => cfg.recallFeedback?.enabled === true && cfg.recallFeedback?.announce !== false;
22
+ const buildInjection = (sessionKey) => {
23
+ if (!on())
24
+ return null;
25
+ if (!sessionKey)
26
+ return null;
27
+ const drained = notices.drain(sessionKey);
28
+ if (drained.length === 0)
29
+ return null;
30
+ const lines = drained.slice(0, MAX_PER_TURN).map((n) => `- ${n.title || n.id}`);
31
+ if (drained.length > MAX_PER_TURN) {
32
+ lines.push(`… 另有 ${drained.length - MAX_PER_TURN} 条同批声明过时的记忆未列出`);
33
+ }
34
+ return [
35
+ SYSTEM_REMINDER_OPEN,
36
+ INTRO,
37
+ escapeFrameBody(lines.join('\n')),
38
+ SYSTEM_REMINDER_CLOSE,
39
+ ].join('\n');
40
+ };
41
+ ctx.on('agent/pre-step', async (payload, next) => {
42
+ const decision = await next();
43
+ if (decision.kind === 'reject' || (payload.signal && payload.signal.aborted))
44
+ return decision;
45
+ if (!on())
46
+ return decision;
47
+ try {
48
+ const text = buildInjection(sessionKeyOf(payload.agent));
49
+ if (!text)
50
+ return decision;
51
+ const injection = createUserMessage({
52
+ content: [{ type: 'text', text }],
53
+ source: { kind: 'plugin:memory', form: 'recall' },
54
+ });
55
+ logger.info('[memory] 已向本会话播报记忆过时声明(一次性)');
56
+ return { kind: 'enter', messages: [injection, ...decision.messages] };
57
+ }
58
+ catch (err) {
59
+ logger.warn(`[memory] 过时播报注入失败(跳过本轮): ${err instanceof Error ? err.message : String(err)}`);
60
+ return decision;
61
+ }
62
+ }, { prepend: true });
63
+ return { buildInjection };
64
+ }
@@ -20,6 +20,8 @@ import type { MemoryConfig } from '../config.js';
20
20
  import type { LiveSettingsHandle } from '../settings.js';
21
21
  import type { L1Store } from '../store/l1.js';
22
22
  import type { PersonaStore } from '../store/persona.js';
23
+ import type { SessionRecalledStore } from '../store/session-recalled.js';
24
+ import type { RecallFeedbackFace } from '../projection/recall.js';
23
25
  import type { SceneStore } from '../store/scenes.js';
24
26
  import type { SessionModeStore } from '../store/session-modes.js';
25
27
  import type { RecallUsageStore } from '../store/recall-usage.js';
@@ -39,6 +41,43 @@ export declare function isOwnRecallSource(src: {
39
41
  plugin?: unknown;
40
42
  form?: unknown;
41
43
  } | undefined | null): boolean;
44
+ /** 会话事件里本插件可能关心的那几个字段(只读形状,不依赖宿主类型导出)。 */
45
+ export interface InjectionEventLike {
46
+ /** 存储兜底路的事件形状宽松,`type` 也可能缺;缺即不是注入(下面的不等判定已覆盖 undefined)。 */
47
+ type?: string;
48
+ /** 存储兜底路(`sessionPersistence.loadStored`)的事件**不带 seq**,所以它是可选的。 */
49
+ seq?: number;
50
+ data?: {
51
+ id?: unknown;
52
+ source?: Record<string, unknown>;
53
+ content?: ReadonlyArray<{
54
+ type?: string;
55
+ text?: string;
56
+ }>;
57
+ };
58
+ }
59
+ /** 一条本插件召回注入的事实。 */
60
+ export interface OwnInjectionFacts {
61
+ /** 注入消息 id——稳定锚点。**不能用 seq 做键**:压缩的 `surfaceOp:{op:'replace'}` 会重排它。 */
62
+ id: string;
63
+ /**
64
+ * 该消息此刻在日志里的 seq(拿去比 `surface.nodes`)。
65
+ *
66
+ * `undefined` = 这条来源根本没有 seq。**没有 seq 就不能声称“它可见”**——
67
+ * 宁可算“不在可见集里”,也不拿一个臆测的默认值去判(哨兵值会把误标藏起来)。
68
+ */
69
+ seq: number | undefined;
70
+ /** 注入正文字符数(token 估算用)。 */
71
+ chars: number;
72
+ }
73
+ /**
74
+ * 认出一条 `user/message` 事件是不是本插件的召回注入,并取出它的消息 id / seq / 正文字数。
75
+ *
76
+ * 这是**唯一的识别口径**:召份额估算(live 与存储两条路)与「正文已被摘要取代」对账都读它。
77
+ * 两处各写一套判定迟早漂移,而漂移的后果是静默的(份额归零或误标取代)。
78
+ * 署名判定走 `isOwnRecallSource`,同时吃 v4 `plugin:memory` 与 v3 旧行形状。
79
+ */
80
+ export declare function ownInjectionFromEvent(ev: InjectionEventLike): OwnInjectionFacts | undefined;
42
81
  export declare function buildRecallQuery(messages: Array<{
43
82
  content: unknown;
44
83
  }>, tailMessages?: number, maxChars?: number): string;
@@ -90,6 +129,8 @@ export declare function registerRecall(ctx: Context, cfg: MemoryConfig, stores:
90
129
  l1: L1Store;
91
130
  scenes: Record<'chat' | 'work', SceneStore>;
92
131
  persona: Record<'chat' | 'work', PersonaStore>;
132
+ recalled?: SessionRecalledStore;
133
+ feedback?: RecallFeedbackFace;
93
134
  }, logger: MemoryLogger, live: LiveSettingsHandle, modes: SessionModeStore, dataDir: string,
94
135
  /** 召回使用统计(可选;未装配 = 零成本旁路):进 topN(注入)才算 used。 */
95
136
  usage?: RecallUsageStore): RecallHooks;
@@ -11,7 +11,7 @@ import { applyRecallBudget, raceRecallTimeout, RECALL_EMBED_CAP_MS } from '../ut
11
11
  import { clearProfileShare, emptyOccupancyLedger, estimateInjectedMessageTokens, estimateStableSectionTokens, recordProfileShare, recordRecallInjection, resetForCompaction, } from '../util/context-occupancy.js';
12
12
  import { errDetail } from '../util/filelog.js';
13
13
  import { blocksToText } from '../util/text.js';
14
- import { domainGate, formatWeights, hardFilterByWingLock, sortByDomainWeight, WING_ANCHORS } from '../domain-gate.js';
14
+ import { domainGate, formatWeights, hardFilterByWingLock, sortByDomainWeight, sortByHumanMark, sortByUsageScenario, WING_ANCHORS } from '../domain-gate.js';
15
15
  const PROFILE_TTL = 60_000;
16
16
  /** 存储回填缓存上限(超过即按插入序淘汰最旧;查看会话数为百级,500 足够)。 */
17
17
  const STORED_ESTIMATE_CACHE_CAP = 500;
@@ -40,6 +40,30 @@ const RECALL_QUERY_MAX_CHARS = 2_000;
40
40
  export function isOwnRecallSource(src) {
41
41
  return !!src && src.form === 'recall' && (src.kind === 'plugin:memory' || (src.kind === 'plugin' && src.plugin === 'memory'));
42
42
  }
43
+ /**
44
+ * 认出一条 `user/message` 事件是不是本插件的召回注入,并取出它的消息 id / seq / 正文字数。
45
+ *
46
+ * 这是**唯一的识别口径**:召份额估算(live 与存储两条路)与「正文已被摘要取代」对账都读它。
47
+ * 两处各写一套判定迟早漂移,而漂移的后果是静默的(份额归零或误标取代)。
48
+ * 署名判定走 `isOwnRecallSource`,同时吃 v4 `plugin:memory` 与 v3 旧行形状。
49
+ */
50
+ export function ownInjectionFromEvent(ev) {
51
+ if (ev.type !== 'user/message')
52
+ return undefined;
53
+ const msg = ev.data;
54
+ if (msg === undefined || !isOwnRecallSource(msg.source))
55
+ return undefined;
56
+ let chars = 0;
57
+ for (const b of msg.content ?? []) {
58
+ if (b?.type === 'text' && typeof b.text === 'string')
59
+ chars += b.text.length;
60
+ }
61
+ return {
62
+ id: typeof msg.id === 'string' ? msg.id : '',
63
+ seq: typeof ev.seq === 'number' ? ev.seq : undefined,
64
+ chars,
65
+ };
66
+ }
43
67
  export function buildRecallQuery(messages, tailMessages = RECALL_QUERY_TAIL_MESSAGES, maxChars = RECALL_QUERY_MAX_CHARS) {
44
68
  const tail = messages.slice(-tailMessages);
45
69
  let text = tail.map((m) => blocksToText(m.content)).join(' ').trim();
@@ -215,9 +239,13 @@ usage) {
215
239
  ctx.on('session/event', (session, event) => {
216
240
  if (event.type !== 'compaction/end')
217
241
  return;
218
- if (postCompaction.onCompactionEnd(String(session.id ?? session))) {
219
- logger.info(`[memory] compaction/end:下一轮将做一次增强召回(session=${String(session.id ?? session)})`);
242
+ const sid = String(session.id ?? session);
243
+ if (postCompaction.onCompactionEnd(sid)) {
244
+ logger.info(`[memory] compaction/end:下一轮将做一次增强召回(session=${sid})`);
220
245
  }
246
+ // Phase 0B:压缩完成的**权威**信号。取代标记不再只靠 agent/created 的粗标,而是从
247
+ // surface.nodes 逐条对账;即使那条事件根本没发,这里也能把状态算对(并可撤销误标)。
248
+ void reconcileSupersededFromSession(sid);
221
249
  });
222
250
  // 上下文压缩/清空 → 已注入内容从模型上下文丢失,重置该会话的去重压制
223
251
  // (resume/startup 不重置:历史仍在,已注入的记忆模型还持有)。
@@ -225,6 +253,9 @@ usage) {
225
253
  ctx.on('agent/created', async (payload) => {
226
254
  if (payload.source === 'compact' || payload.source === 'clear') {
227
255
  dedupe.reset(payload.agent.id);
256
+ // 压缩与清空**不同对待**:摘要替换不得抹掉本会话的召回历史(尤其已判过时的裁决凭证),
257
+ // 只有用户主动清空才整表清。去重与占用账本仍按原样重置(那两者关心“能不能重新注入”)。
258
+ stores.recalled?.reset(String(payload.agent.session?.id ?? payload.agent.id), payload.source === 'compact' ? 'compact' : 'clear');
228
259
  const led = ledgerFor(payload.agent.id);
229
260
  resetForCompaction(led);
230
261
  occupancyStore.save(payload.agent.id, led); // stock 归零 ⇒ 流水条目删除
@@ -372,6 +403,48 @@ usage) {
372
403
  });
373
404
  logger.info(`[memory] repo 软围栏:currentRepo=${currentRepoKey || '(未识别)'} 跨仓乘子=${cfg.recall.scopeFence.crossRepoMultiplier} agent=${payload.agent.id}`);
374
405
  }
406
+ // ── G1 使用场景细排(默认关):命中 usageScenario 的记忆上浮,纯函数重排,score 不改写。
407
+ // 关闭时不进本分支(零 getByIds、零重排,召回逐字现状);无场景/未命中保持原序(fail-open)。
408
+ if (cfg.usageScenario?.enabled === true && scoped.length > 1) {
409
+ const scenarioById = new Map(stores.l1.getByIds(scoped.map((h) => h.id)).map((r) => [
410
+ r.id,
411
+ Array.isArray(r.metadata?.dsh_usage_scenario)
412
+ ? r.metadata.dsh_usage_scenario
413
+ : [],
414
+ ]));
415
+ const before = scoped.map((h) => h.id).join(',');
416
+ scoped = sortByUsageScenario(scoped, (id) => scenarioById.get(id) ?? [], query);
417
+ if (scoped.map((h) => h.id).join(',') !== before) {
418
+ logger.info(`[memory] 使用场景细排生效:agent=${payload.agent.id}`);
419
+ }
420
+ }
421
+ // ── 人工打分参与排序(默认开):4/5 上浮、0/1/2 下沉。纯重排、**score 不改写**、
422
+ // 同档内保持进入顺序(不推翻上面的围栏/场景细排)。这就是 0/1/2 的常规出口:
423
+ // 不相关就排到注不上,而不是从记忆里消失。未装配反馈面 / 全未打分 → 逐字同序。
424
+ if (cfg.recallFeedback?.enabled === true
425
+ && cfg.recallFeedback.affectRank !== false
426
+ && stores.feedback !== undefined
427
+ && scoped.length > 1) {
428
+ const fb = stores.feedback;
429
+ const before = scoped.map((h) => h.id).join(',');
430
+ scoped = sortByHumanMark(scoped, (id) => fb.lastOf(id));
431
+ if (scoped.map((h) => h.id).join(',') !== before) {
432
+ logger.info(`[memory] 人工打分排序生效(agent=${payload.agent.id})`);
433
+ }
434
+ }
435
+ // ── 可选硬门 excludeZero(**默认关**):最近一笔是 0 → 本轮不注入(不退场,
436
+ // 再点 1..5 即回来)。开着它=“误点一次就完全不再浮现”,所以默认只靠上面的沉底。
437
+ if (cfg.recallFeedback?.enabled === true
438
+ && cfg.recallFeedback.excludeZero === true
439
+ && stores.feedback !== undefined
440
+ && scoped.length > 0) {
441
+ const fb = stores.feedback;
442
+ const kept = scoped.filter((h) => fb.lastOf(h.id) !== 0);
443
+ if (kept.length !== scoped.length) {
444
+ logger.info(`[memory] 相关度 0 分剔除 ${scoped.length - kept.length} 条(agent=${payload.agent.id})`);
445
+ scoped = kept;
446
+ }
447
+ }
375
448
  // 召回去重:同会话已注入过的记录不再重复注入(模型上下文已持有,省 token)。
376
449
  // 纯过滤——剩几条注几条,全量压制(0 条新鲜命中)是正确状态而非未命中。
377
450
  // §E:压缩后增强轮跳过压制(压缩把已注入内容逐出了模型上下文)。
@@ -442,6 +515,9 @@ usage) {
442
515
  });
443
516
  // 使用统计:候选=scoped(尝试),进 topN(注入)=used
444
517
  usage?.recordAttempt(scoped.map((h) => h.id), fresh.slice(0, lines.length).map((h) => h.id));
518
+ // G-live:按 **sessionKey**(=agent.session?.id ?? agent.id,与 pill 的 sessionId / 投影 header.id 同源,
519
+ // 非 ownerId)记入本会话“活召回集”,供侧栏卡读取;压缩时同键 reset。
520
+ stores.recalled?.add(sessionKey, fresh.slice(0, lines.length).map((h) => h.id), injection.id);
445
521
  // 入账在成功构造注入消息之后、返回 enter 之前——任何前置抛错路径账目零扰动
446
522
  const led = ledgerFor(payload.agent.id);
447
523
  recordRecallInjection(led, text.length);
@@ -481,8 +557,46 @@ usage) {
481
557
  return '';
482
558
  return body ? `${body}\n\n${MEMORY_TOOLS_GUIDE}` : MEMORY_TOOLS_GUIDE;
483
559
  };
484
- /** 本插件 recall 注入的署名判据(v4 `plugin:memory` 与 v3 旧行双形状;宿主迁移负责升格旧行)。 */
485
- const isOwnRecall = isOwnRecallSource;
560
+ /**
561
+ * Phase 0B:从宿主官方会话服务对账「正文已被摘要取代」。
562
+ *
563
+ * 为什么不再靠“收到一条压缩事件”:事件会漏(压缩不重建 agent 的宿主线、含 CCI compat 摘要
564
+ * 路径,根本不发 `agent/created{source:'compact'}`),漏一次就永久错且无法补算。这里每轮从
565
+ * `surface.nodes` 重算,所以既能标上也能撤销(误标与重新注入都会自愈)。
566
+ *
567
+ * @returns true = 会话在 live store 且已对账;false = 不在(调用方回退到粗标记)。
568
+ */
569
+ const reconcileSupersededFromSession = async (sessionId) => {
570
+ const recalled = stores.recalled;
571
+ if (recalled === undefined || sessionId.length === 0)
572
+ return false;
573
+ try {
574
+ // 可选服务一律走 ctx.get() 的宽容路径(属性访问对未 inject 的服务会抛)
575
+ const sessions = ctx.get?.('sessions');
576
+ const session = typeof sessions?.get === 'function' ? sessions.get(sessionId) : undefined;
577
+ if (session === undefined)
578
+ return false;
579
+ const visibleSeqs = new Set(session.surface.nodes);
580
+ const visibleIds = new Set();
581
+ const knownIds = new Set();
582
+ for (const ev of session.events) {
583
+ const own = ownInjectionFromEvent(ev);
584
+ if (own === undefined || own.id.length === 0)
585
+ continue;
586
+ knownIds.add(own.id);
587
+ // 只有“确实拿到了 seq 且 seq 在 surface.nodes 里”才算可见;
588
+ // seq 缺失时宁可不撤销(保持原状),也不拿臆测值去改动人的可见性判定。
589
+ if (own.seq !== undefined && visibleSeqs.has(own.seq))
590
+ visibleIds.add(own.id);
591
+ }
592
+ return recalled.reconcileSuperseded(sessionId, visibleIds, knownIds);
593
+ }
594
+ catch (err) {
595
+ // 对账是旁路:失败只能“本轮不动”,绝不能弄挂召回或面板。
596
+ logger.warn(`[memory] 取代对账失败(本轮跳过): ${errDetail(err)}`);
597
+ return false;
598
+ }
599
+ };
486
600
  async function estimateRecallFromStorage(sessionId) {
487
601
  if (storedEstimateCache.has(sessionId))
488
602
  return storedEstimateCache.get(sessionId) ?? null;
@@ -494,20 +608,13 @@ usage) {
494
608
  if (stored?.events) {
495
609
  tokens = 0;
496
610
  for (const ev of stored.events) {
611
+ // 存储路没有 surface,只能沿用“遇到压缩就归零”的启发式(粗近似,不伪装成精确判定)。
497
612
  if (typeof ev.type === 'string' && ev.type.startsWith('compaction'))
498
613
  tokens = 0;
499
- if (ev.type !== 'user/message')
500
- continue;
501
- const src = ev.data?.source;
502
- if (!isOwnRecall(src))
614
+ const own = ownInjectionFromEvent(ev);
615
+ if (own === undefined || own.chars === 0)
503
616
  continue;
504
- let chars = 0;
505
- for (const b of ev.data?.content ?? []) {
506
- if (b?.type === 'text' && typeof b.text === 'string')
507
- chars += b.text.length;
508
- }
509
- if (chars > 0)
510
- tokens += estimateInjectedMessageTokens(chars);
617
+ tokens += estimateInjectedMessageTokens(own.chars);
511
618
  }
512
619
  }
513
620
  }
@@ -532,19 +639,11 @@ usage) {
532
639
  const visible = new Set(session.surface.nodes);
533
640
  let total = 0;
534
641
  for (const ev of session.events) {
535
- if (ev.type !== 'user/message' || !visible.has(ev.seq))
536
- continue;
537
- const msg = ev.data;
538
- const src = msg?.source;
539
- if (!isOwnRecall(src) || !msg)
642
+ const own = ownInjectionFromEvent(ev);
643
+ // 无 seq = 无法证明它可见,就不能算进份额(与原行为一致:原来它必须 visible.has(seq))。
644
+ if (own === undefined || own.chars === 0 || own.seq === undefined || !visible.has(own.seq))
540
645
  continue;
541
- let chars = 0;
542
- for (const b of msg.content ?? []) {
543
- if (b?.type === 'text' && typeof b.text === 'string')
544
- chars += b.text.length;
545
- }
546
- if (chars > 0)
547
- total += estimateInjectedMessageTokens(chars);
646
+ total += estimateInjectedMessageTokens(own.chars);
548
647
  }
549
648
  return total;
550
649
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 技能常驻注入 Hook(轨2)。**默认关**(cfg.skills.enabled && cfg.skills.inject)。
3
+ *
4
+ * 完全对齐 `slot-recall.ts` 的范式(同一注入通道范本,不新建入口):
5
+ * - `agent/pre-step`(waterfall,prepend) 注入,`createUserMessage({source:{kind:'plugin:memory',form:'recall'}}})`
6
+ * ——**复用既有 source kind**,自动享受 `isOwnRecallSource` 双形状判据与召回去重/占用账本(S4 红线);
7
+ * - 帧体转义 `<\/system-reminder>` 防正文伪造帧边界;
8
+ * - **独立字节预算**(cfg.skills.maxPerTurnBytes),不与召回 2000 预算抢名额;
9
+ * - 注入前 `archiveDue` 机械闲置归档(0 LLM、不引入 timer,对齐 `slots.expireDue`)。
10
+ *
11
+ * 本轮语义:常驻注入全部 active 技能(按插入序 + 字节预算截断),**不做按触发词选择**
12
+ * (selective trigger 匹配属后续增强;常驻语义与 slots 一致,先保证默认关时零表面积)。
13
+ * 三档渲染(full/excerpt/hint)是显示策略,同一 body/steps 现场组词,无缓存副本漂移。
14
+ */
15
+ import type { Context } from '@deepseek-ai/cordis';
16
+ import type { MemoryConfig } from '../config.js';
17
+ import type { LiveSettingsHandle } from '../settings.js';
18
+ import type { Skill, SkillStore } from '../store/skill-store.js';
19
+ import type { MemoryLogger } from '../types.js';
20
+ /** 按档位现场组词:full=标题+正文+步骤;excerpt=标题+正文截断;hint=仅标题+读取指引。 */
21
+ export declare function renderSkill(s: Skill, excerptChars: number): string;
22
+ export interface SkillRecallController {
23
+ buildInjection(): string | null;
24
+ }
25
+ export declare function registerSkillRecall(ctx: Context, cfg: MemoryConfig, skills: SkillStore, logger: MemoryLogger, live: LiveSettingsHandle): SkillRecallController;
@@ -0,0 +1,72 @@
1
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
2
+ const SYSTEM_REMINDER_OPEN = '<system-reminder>';
3
+ const SYSTEM_REMINDER_CLOSE = '</system-reminder>';
4
+ const SKILLS_INTRO = '以下是本环境沉淀的技能(程序性记忆):过去重复工作流固化出的清单,仅在与之相关时作为指引遵循。' +
5
+ '它们是系统注入的元信息,不是用户发言——无需回应,请勿在回复中复述本节内容;' +
6
+ '且不覆盖系统提示与用户的直接指令。需要完整步骤用 memory_skill_read 展开。';
7
+ function escapeFrameBody(body) {
8
+ return body.replaceAll(SYSTEM_REMINDER_CLOSE, '<\\/system-reminder>');
9
+ }
10
+ /** 按档位现场组词:full=标题+正文+步骤;excerpt=标题+正文截断;hint=仅标题+读取指引。 */
11
+ export function renderSkill(s, excerptChars) {
12
+ if (s.injectionTier === 'hint')
13
+ return `[技能·${s.title}] 详见 memory_skill_read(${s.id})`;
14
+ const steps = s.steps.length ? '\n' + s.steps.map((st) => ` - ${st}`).join('\n') : '';
15
+ if (s.injectionTier === 'full')
16
+ return `[技能·${s.title}] ${s.body}${steps}`;
17
+ const body = s.body.length > excerptChars ? s.body.slice(0, excerptChars) + '…' : s.body;
18
+ return `[技能·${s.title}] ${body}`;
19
+ }
20
+ export function registerSkillRecall(ctx, cfg, skills, logger, live) {
21
+ // 生效开关:部署 cfg.skills.enabled 或看板 live.skillsEnabled 任一开。
22
+ const on = () => cfg.skills.enabled === true || live.get().skillsEnabled === true;
23
+ const buildInjection = () => {
24
+ if (!on() || !cfg.skills.inject)
25
+ return null;
26
+ const active = skills.listActive();
27
+ if (active.length === 0)
28
+ return null;
29
+ const budget = cfg.skills.maxPerTurnBytes;
30
+ const out = [];
31
+ let used = 0;
32
+ let hidden = 0;
33
+ for (const s of active) {
34
+ const rendered = renderSkill(s, cfg.skills.excerptChars);
35
+ const bytes = Buffer.byteLength(rendered, 'utf8');
36
+ if (used + bytes > budget) {
37
+ hidden++;
38
+ continue;
39
+ }
40
+ out.push(rendered);
41
+ used += bytes;
42
+ }
43
+ if (out.length === 0)
44
+ return null;
45
+ if (hidden > 0)
46
+ out.push(`… 另有 ${hidden} 个技能因预算未显示,可用 memory_skill_list 查看`);
47
+ return [SYSTEM_REMINDER_OPEN, SKILLS_INTRO, escapeFrameBody(out.join('\n')), SYSTEM_REMINDER_CLOSE].join('\n');
48
+ };
49
+ ctx.on('agent/pre-step', async (payload, next) => {
50
+ const decision = await next();
51
+ if (decision.kind === 'reject' || (payload.signal && payload.signal.aborted))
52
+ return decision;
53
+ if (!on() || !cfg.skills.inject)
54
+ return decision;
55
+ try {
56
+ await skills.archiveDue(Date.now(), cfg.skills.idleArchiveDays);
57
+ const text = buildInjection();
58
+ if (!text)
59
+ return decision;
60
+ const injection = createUserMessage({
61
+ content: [{ type: 'text', text }],
62
+ source: { kind: 'plugin:memory', form: 'recall' },
63
+ });
64
+ return { kind: 'enter', messages: [injection, ...decision.messages] };
65
+ }
66
+ catch (err) {
67
+ logger.warn(`[memory] 技能注入失败(跳过本轮): ${err instanceof Error ? err.message : String(err)}`);
68
+ return decision;
69
+ }
70
+ });
71
+ return { buildInjection };
72
+ }