sage-mem 1.9.2 → 2.0.1

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.
@@ -56,8 +56,12 @@ export function buildCandidates({ picked, bestChunk, nowMs = Date.now() }) {
56
56
  *
57
57
  * 按**实际进正文的字节**记账,而不是 `delivery.totals` —— 后者含指针的额度,
58
58
  * 而指针没有真的进上下文。两本账要对得上,就不能把没花出去的钱记进去。
59
+ *
60
+ * 2.0.0 起多收两个集合(`conflicts` / `expired`):在此之前它们是硬编码空集,
61
+ * 而 `deliver.js` 里「未裁决冲突 / 已失效来源」两条规则早就写好了 —— 这一版只是
62
+ * 把真实数据接上去,**deliver.js 一行不改**(docs/2.0.0-plan.md §6.2)。
59
63
  */
60
- export function composeDelivery({ candidates, text, intent, contentBytes, nowMs = Date.now() }) {
64
+ export function composeDelivery({ candidates, text, intent, contentBytes, nowMs = Date.now(), conflicts, expired }) {
61
65
  const delivery = decideDelivery(candidates, {
62
66
  intent,
63
67
  // ⚠️ 这里**故意传空集**:按 revision 抑制重复对「同一份文件的不同段落」太粗 ——
@@ -66,8 +70,8 @@ export function composeDelivery({ candidates, text, intent, contentBytes, nowMs
66
70
  // 并且单独评测一次。这条记在 docs/decisions.md 的 D15,不是遗漏。
67
71
  visibleRevisions: new Set(),
68
72
  scopeDenied: new Set(),
69
- conflicts: new Set(),
70
- expired: new Set(),
73
+ conflicts: conflicts instanceof Set ? conflicts : new Set(),
74
+ expired: expired instanceof Set ? expired : new Set(),
71
75
  needle: text ? text.slice(0, 40) : '',
72
76
  }, { bytes: Math.max(0, CONTENT_BUDGET - contentBytes) })
73
77
 
@@ -103,7 +107,7 @@ export function composeDelivery({ candidates, text, intent, contentBytes, nowMs
103
107
  */
104
108
  export function composeStatus({
105
109
  files, selected, bodyRanked, bodySearched, text, parts, injectedFiles, skippedForBudget,
106
- deduped, st, toolsReady, checkpointLine, memoryDir, intentHistoryDependent,
110
+ deduped, st, toolsReady, checkpointLine, memoryDir, intentHistoryDependent, pendingConflicts,
107
111
  }) {
108
112
  // ── 缺口状态卡 ──
109
113
  // 它**替换**原来那行状态文字,不是额外追加:同一件事不说两遍。
@@ -118,8 +122,13 @@ export function composeStatus({
118
122
  // 后者是计划里点名的一条:没有历史依赖时不要插「你可能遗漏记忆」,否则提示
119
123
  // 每轮都出现,等真有缺口时它已经被当噪音忽略了。
120
124
  const gapExists = unread > 0 || skippedForBudget > 0
125
+ // 2.0.0:有矛盾待裁决时**必须**发卡 —— 抑制是「记忆悄悄不进正文」,
126
+ // 状态卡是用户不主动看面板时唯一会自己出现的解释(D27 教训,见 plan §6.4)。
127
+ const pending = Number.isFinite(Number(pendingConflicts)) && Number(pendingConflicts) > 0
128
+ ? Math.floor(Number(pendingConflicts))
129
+ : 0
121
130
 
122
- const wantCard = gapExists || intentHistoryDependent === true || !!checkpointLine
131
+ const wantCard = gapExists || intentHistoryDependent === true || !!checkpointLine || pending > 0
123
132
  // 签名没变时沿用上一步渲染好的那份缺口卡,而不是重渲染一张。
124
133
  // 两条理由:其一,那一步与这一步的检索面**逐字相同**,缺口也相同,重渲染只会把
125
134
  // 同一件事换个数字再说一遍 —— 而局部变量算出来的数字还是错的(上一步注了 N 条,
@@ -140,6 +149,7 @@ export function composeStatus({
140
149
  needle: text ? text.slice(0, 40) : null,
141
150
  toolsAvailable: toolsReady,
142
151
  memoryDir,
152
+ pendingConflicts: pending,
143
153
  })
144
154
  // 句柄与卡**共用卡池**:它是同一个通道里的另一句提示,不该另开一份额度。
145
155
  const cardText = [cardBody, checkpointLine].filter(Boolean).join('\n')
@@ -14,7 +14,9 @@
14
14
  * 1. 只取 `source.kind === 'user'` 的消息 —— DSH 0.1.5 起注入与工具结果也存成 role:'user';
15
15
  * 2. 代次处理(`state.begin`)必须在算正文与控制提示**之前**,否则压缩后会拿上一代的账去判重(复审 R03)。
16
16
  */
17
- import { MEMORY_DIR, RECALL_DISCIPLINE, STATE_ROOT } from '../memory/config.js'
17
+ import { MEMORY_DIR, RECALL_DISCIPLINE, STATE_ROOT, validityEnabled } from '../memory/config.js'
18
+ import { listConflictIndex } from '../memory/conflicts.js'
19
+ import { buildContradictionFileSet, buildExpirySet } from '../memory/validity.js'
18
20
  import { extractText } from '../memory/retrieval.js'
19
21
  import { scanMemoryFiles } from '../memory/scan.js'
20
22
  import { readCheckpoint } from '../memory/checkpoint.js'
@@ -24,6 +26,43 @@ import { buildCandidates, composeDelivery, composeStatus } from './compose.js'
24
26
  import { composeRecallText } from './text.js'
25
27
  import { debugEnabled, debugRecall, recordInjection } from './debug.js'
26
28
 
29
+ /**
30
+ * 2.0.1:调试账里「被有效性挡掉」的两类理由(2.0.0 新引入的抑制)。
31
+ *
32
+ * ⚠️ **只收这两个**:`below_floor` / `budget_omitted` 等老理由在 2.0.0 之前就有,
33
+ * 它们的解释已经在面板的「预算挡掉 N 条」与得分列表里;而失效与未裁决矛盾是
34
+ * 2.0.0 让记忆**从正文里消失**的唯一原因 —— 不单列出来,用户与维护侧都只能靠
35
+ * 猜「这条到底是没检索到,还是被新规则挡住了」。
36
+ */
37
+ const VALIDITY_SUPPRESS_REASONS = new Set(['expired_source', 'conflict_pending'])
38
+
39
+ /** reason → 面板上的一句人话(英文由 client 侧字典决定,这里只给稳定键)。 */
40
+ const VALIDITY_REASON_LABEL = Object.freeze({
41
+ expired_source: '已失效',
42
+ conflict_pending: '待裁决矛盾',
43
+ })
44
+
45
+ /**
46
+ * 从 `decideDelivery` 的逐条处置里挑出被有效性挡掉的,附上文件名、理由与(可选)冲突 id。
47
+ *
48
+ * 纯函数,给调试账用;**不影响注入结果** —— items 是决策的产物,这里只是把其中
49
+ * 两类 reason 摘出来。`conflictIds` 是「文件名 → 冲突记录 id」的映射:同一份文件被
50
+ * 多条记录牵扯时取第一条(按 `listConflictIndex` 的时间倒序),够定位就行。
51
+ */
52
+ function validitySuppressedItems(items, conflictIds) {
53
+ const out = []
54
+ for (const it of Array.isArray(items) ? items : []) {
55
+ if (!it || !VALIDITY_SUPPRESS_REASONS.has(it.reason)) continue
56
+ const row = { file: String(it.file ?? ''), reason: it.reason, label: VALIDITY_REASON_LABEL[it.reason] }
57
+ if (it.reason === 'conflict_pending' && conflictIds instanceof Map) {
58
+ const id = conflictIds.get(row.file)
59
+ if (id) row.conflictId = id
60
+ }
61
+ out.push(row)
62
+ }
63
+ return out
64
+ }
65
+
27
66
  /**
28
67
  * @param {object} deps
29
68
  * @param {object} deps.ctx — 宿主 ctx(`scanMemoryFiles` / `warnLog` 用)
@@ -85,6 +124,41 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
85
124
  // 统一的出口在下面 —— 只有**正文与控制提示都为空**才返回。
86
125
 
87
126
  const nowMs = Date.now()
127
+ // ── 2.0.0:有效性过滤与矛盾抑制的真实集合 ──────────────────────────
128
+ // 两件事分开算:失效看**记忆自己的 frontmatter**(事实来源),矛盾看冲突队列
129
+ // (待办 + 审计)。队列读不动时**不抑制**、只留日志 —— 抑制是「少给点内容」,
130
+ // 拿一个读失败去换「记忆消失」不划算(D27)。
131
+ //
132
+ // ⚠️ 只对 `contradiction` 进集合:写失败型记录里盘上那份仍是现行内容(§6.3)。
133
+ let conflictsForDelivery = new Set()
134
+ let pendingConflicts = 0
135
+ // 2.0.1:调试账用的「文件名 → 冲突记录 id」。只在开关打开时才读得到,但先按需
136
+ // 建一份空 Map(代价可忽略,比在 makeDebug 里为了 id 再扫一次队列便宜)。
137
+ const conflictIdByFile = new Map()
138
+ const validity = validityEnabled()
139
+ const expired = validity ? buildExpirySet(files, nowMs) : new Set()
140
+ if (validity) {
141
+ try {
142
+ // 用**摘要索引**(不带两份正文、进程内缓存 1 秒):100 条记录时 `listConflicts`
143
+ // 单次实测 p50 78 ms,而它每一步 assemble 都会被调一次(见 conflicts.js 的缓存注释)。
144
+ const listed = await listConflictIndex({ stateRoot: STATE_ROOT })
145
+ if (listed.ok) {
146
+ conflictsForDelivery = buildContradictionFileSet(listed.items)
147
+ for (const c of listed.items) {
148
+ if (!c || c.status !== 'open' || c.kind !== 'contradiction') continue
149
+ for (const key of ['file', 'otherFile']) {
150
+ const name = typeof c[key] === 'string' ? c[key].trim() : ''
151
+ if (name !== '' && !conflictIdByFile.has(name)) conflictIdByFile.set(name, c.id)
152
+ }
153
+ }
154
+ pendingConflicts = listed.items.filter((c) => c.status === 'open' && c.kind === 'contradiction').length
155
+ } else {
156
+ warnLog(ctx, `读冲突队列失败,本轮不做矛盾抑制:${listed.error ?? listed.code}`, sessionId)
157
+ }
158
+ } catch (err) {
159
+ warnLog(ctx, `读冲突队列异常,本轮不做矛盾抑制:${err?.message ?? err}`, sessionId)
160
+ }
161
+ }
88
162
  const candidates = buildCandidates({ picked, bestChunk, nowMs })
89
163
 
90
164
  // ── 单会话累计预算:**拆成两本账** ──
@@ -94,7 +168,15 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
94
168
  // 账记在 session id 上;picked 里 baseline 永远排在检索结果之前,所以预算不够时
95
169
  // 先被挤掉的是「更晚才想起来的」那些 —— baseline 有优先权。
96
170
  // 起点来自 `begin()`:它可能刚因压缩把两本账连同卡签名一起归零过。
97
- const delivered = composeDelivery({ candidates, text, intent: intentInfo.intent, contentBytes: st.contentBytes, nowMs })
171
+ const delivered = composeDelivery({
172
+ candidates,
173
+ text,
174
+ intent: intentInfo.intent,
175
+ contentBytes: st.contentBytes,
176
+ nowMs,
177
+ conflicts: conflictsForDelivery,
178
+ expired,
179
+ })
98
180
  const { parts, injectedFiles, skippedForBudget, contentBytes } = delivered
99
181
 
100
182
  // ── 任务检查点:压缩后的恢复入口 ──
@@ -113,6 +195,7 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
113
195
  files, selected, bodyRanked, bodySearched, text, parts, injectedFiles, skippedForBudget,
114
196
  deduped, st, toolsReady: tools.ready(), checkpointLine, memoryDir: MEMORY_DIR,
115
197
  intentHistoryDependent: intentInfo.historyDependent,
198
+ pendingConflicts,
116
199
  })
117
200
  const { cardBody, cardPart } = status
118
201
 
@@ -126,7 +209,10 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
126
209
  // - `sessionChars` = 本代累计正文字节(预算账);
127
210
  // - `cardChars` = 这一步发出去的状态卡字节(没发就是 0);
128
211
  // - `deduped` = 同题同命中集刚注过,这一步没重复追加 —— 面板上要解释成
129
- // 「不是坏了,是去重生效」,否则每轮多步里大部分账看着都是空的。
212
+ // 「不是坏了,是去重生效」,否则每轮多步里大部分账看着都是空的;
213
+ // - `validitySuppressed`(2.0.1)= 被 2.0.0 的有效性规则挡在正文外的条目,
214
+ // 带 `reason`(expired_source / conflict_pending)与 `label`;矛盾还带
215
+ // `conflictId`,便于去「冲突」页签定位。**这不是检索失败**,是规则生效。
130
216
  const debugOn = debugEnabled()
131
217
  const makeDebug = debugOn
132
218
  ? (outcome) => ({
@@ -146,6 +232,7 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
146
232
  sessionChars: contentBytes,
147
233
  cardChars: cardPart ? Buffer.byteLength(cardPart, 'utf8') : 0,
148
234
  skippedByBudget: skippedForBudget,
235
+ validitySuppressed: validitySuppressedItems(delivered.delivery.items, conflictIdByFile),
149
236
  card: cardPart ? 'sent' : (status.cardText ? 'deduped-or-over-budget' : 'none'),
150
237
  })
151
238
  : () => null
@@ -19,6 +19,8 @@
19
19
  import { DEFAULT_MAX_BYTES, MAX_MAX_BYTES, readSlice } from '../memory/reader.js'
20
20
  import { DEFAULT_LIMIT, MAX_LIMIT, retrieve } from '../memory/search.js'
21
21
  import { scanMemoryFiles } from '../memory/scan.js'
22
+ import { parseFrontmatter } from '../memory/frontmatter.js'
23
+ import { describeValidity } from '../memory/validity.js'
22
24
 
23
25
  /** 工具返回内容统一用 text block(宿主把 ContentBlock[] 交给模型)。 */
24
26
  const text = (s) => [{ type: 'text', text: String(s ?? '') }]
@@ -45,8 +47,15 @@ function renderSearchResult(args, value) {
45
47
  const lines = v.hits.map((h, i) => {
46
48
  const where = h.lines ? `${h.file}:${h.lines[0]}-${h.lines[1]}` : h.file
47
49
  const why = Array.isArray(h.matchedBy) ? h.matchedBy.join('+') : ''
50
+ // 2.0.0:失效记忆**仍然能被搜到**(问历史时旧口径是证据),但必须标出来 ——
51
+ // 不标的话模型会把旧口径当现状,那正是「标了失效却仍在误导」的更坏版本。
52
+ const validity = h.expired
53
+ ? `(已失效${h.invalidAt ? `:invalid_at=${h.invalidAt}` : ''},不是现行口径${h.supersededBy ? `;现行见 ${h.supersededBy}` : ''})`
54
+ : h.invalidAt || h.supersededBy
55
+ ? `(有失效标记但未到点${h.invalidAt ? `:invalid_at=${h.invalidAt}` : ''}${h.supersededBy ? `;现行见 ${h.supersededBy}` : ''})`
56
+ : ''
48
57
  const excerpt = h.excerpt ? `\n ${renderClip(h.excerpt.replace(/\n/g, ' '), 200)}` : ''
49
- return `${i + 1}. ${where}${why ? `(${why})` : ''}${excerpt}`
58
+ return `${i + 1}. ${where}${why ? `(${why})` : ''}${validity}${excerpt}`
50
59
  })
51
60
  const tail = v.nextCursor != null ? `\n\n还有下一页:用 cursor=${v.nextCursor} 继续。` : ''
52
61
  return text(`${head}\n${lines.join('\n')}${tail}`)
@@ -66,6 +75,10 @@ function renderReadResult(args, value) {
66
75
  const flags = [
67
76
  v.lineTooLong ? '这一行本身超过了一屏' : '',
68
77
  v.truncated ? '内容被截断' : '',
78
+ // 2.0.0:失效标记必须跟着正文一起给模型 —— 读得到 ≠ 仍然是现行口径。
79
+ v.expired ? '这条记忆已失效(invalid_at 已到点),下面是历史口径,不是现状' : '',
80
+ !v.expired && v.invalidAt ? `这条记忆带失效标记但未到点(invalid_at=${v.invalidAt})` : '',
81
+ v.supersededBy ? `现行口径见 ${v.supersededBy}` : '',
69
82
  ].filter(Boolean)
70
83
  const tail = v.nextStartLine != null
71
84
  ? `\n\n还有内容:startLine=${v.nextStartLine}${v.nextStartByte ? ` startByte=${v.nextStartByte}` : ''}`
@@ -86,7 +99,8 @@ export function createMemoryTools(ctx) {
86
99
  '在本地记忆库(markdown 文件)里检索历史记忆。先用一句话摘要匹配、再按段落查正文,返回候选与命中片段。' +
87
100
  '当你需要依据既往决定、用户偏好、或继续未完成的任务,而当前上下文里没有可定位的依据时用它。' +
88
101
  'scope 选 summary 只比摘要(最省)、body 只查正文段落、all 两者都查(默认)。' +
89
- '结果里带 file 与行号,可直接用 read_memory 读那一段。',
102
+ '结果里带 file 与行号,可直接用 read_memory 读那一段。' +
103
+ '已标失效(invalid_at)的记忆**仍然会被搜到**,结果里会带 invalidAt / supersededBy —— 那是历史口径,不是现状。',
90
104
  parameters: {
91
105
  type: 'object',
92
106
  properties: {
@@ -105,7 +119,24 @@ export function createMemoryTools(ctx) {
105
119
  if (!query) return { ok: false, error: 'query 不能为空' }
106
120
  try {
107
121
  const files = await scanMemoryFiles(ctx, exec?.agent?.id ?? '')
108
- return retrieve({ query, files, scope: args?.scope, limit: args?.limit, cursor: args?.cursor })
122
+ const result = retrieve({ query, files, scope: args?.scope, limit: args?.limit, cursor: args?.cursor })
123
+ // 2.0.0:把每条命中对应的失效信息带出去(D29 Q3)。检索本身**不过滤**失效记忆 ——
124
+ // 问历史时旧口径是证据;过滤掉才是丢信息。模型靠这里的标记判断「这是不是现状」。
125
+ if (result && result.ok !== false && Array.isArray(result.hits)) {
126
+ const byFile = new Map(files.map((f) => [f.file, f]))
127
+ const nowMs = Date.now()
128
+ result.hits = result.hits.map((h) => {
129
+ const file = byFile.get(h.file)
130
+ if (!file) return h
131
+ const v = describeValidity(file, nowMs)
132
+ const extra = {}
133
+ if (v.invalidAt) extra.invalidAt = v.invalidAt
134
+ if (v.supersededBy) extra.supersededBy = v.supersededBy
135
+ if (v.expired) extra.expired = true
136
+ return Object.keys(extra).length ? { ...h, ...extra } : h
137
+ })
138
+ }
139
+ return result
109
140
  } catch (err) {
110
141
  // 工具**绝不抛**:一次检索失败不该打断模型那一轮。
111
142
  return { ok: false, error: `检索出错:${err?.message ?? err}` }
@@ -118,6 +149,7 @@ export function createMemoryTools(ctx) {
118
149
  description:
119
150
  '按文件名读取一份本地记忆的正文,可按行分页。用 search_memory 拿到的 file 与行号来读。' +
120
151
  '返回内容带 revision;如果你上次读之后这份记忆被改过,会告诉你 revision 已变,需要重新确认。' +
152
+ '如果这份记忆已标失效(invalid_at 已到点),页头会明确写出来 —— 那是历史口径。' +
121
153
  `一次默认读 ${DEFAULT_MAX_BYTES} 字节,返回的 nextStartLine / nextStartByte 用于继续往下读。`,
122
154
  parameters: {
123
155
  type: 'object',
@@ -135,13 +167,24 @@ export function createMemoryTools(ctx) {
135
167
  isConcurrencySafe: () => true,
136
168
  async execute(args) {
137
169
  try {
138
- return await readSlice({
170
+ const result = await readSlice({
139
171
  name: args?.name,
140
172
  revision: args?.revision,
141
173
  startLine: args?.startLine,
142
174
  startByte: args?.startByte,
143
175
  maxBytes: args?.maxBytes,
144
176
  })
177
+ // 只在本页包含 frontmatter 时解析(分页读的后半段没有 frontmatter,解析出来是空 —— 不猜)。
178
+ if (result && result.ok !== false && typeof result.content === 'string') {
179
+ const meta = parseFrontmatter(result.content)
180
+ if (meta.invalidAt || meta.supersededBy) {
181
+ const v = describeValidity({ invalidAt: meta.invalidAt, supersededBy: meta.supersededBy }, Date.now())
182
+ result.invalidAt = v.invalidAt
183
+ result.supersededBy = v.supersededBy
184
+ result.expired = v.expired
185
+ }
186
+ }
187
+ return result
145
188
  } catch (err) {
146
189
  return { ok: false, code: 'io_error', error: `读取出错:${err?.message ?? err}` }
147
190
  }
@@ -178,6 +178,13 @@ const auditReportSchema = z.object({
178
178
  // 归档/恢复中途崩溃留下的私有名残留(`*.md.archiving-<hex>`)。它可能装着用户唯一
179
179
  // 一份内容,所以体检必须报出来 —— strict 不声明它就等于界面上永远看不见。
180
180
  'strays': z.array(auditItemSchema).readonly(),
181
+ // 2.0.0:有效性标注的**提示**(不进 problems —— problems 是唯一健康数字)。
182
+ // 不声明的话 strict 会把它剥掉,面板与设置页就永远看不到「某个字段格式坏了」。
183
+ 'validityHints': z.array(z.object({
184
+ 'file': z.string().readonly(),
185
+ 'field': z.string().readonly(),
186
+ 'reason': z.string().readonly(),
187
+ }).readonly()).readonly(),
181
188
  'problems': z.number().readonly(),
182
189
  }).readonly()
183
190
 
@@ -512,6 +519,90 @@ const autodreamOptsSchema = z.object({
512
519
  'scope': z.string().optional(),
513
520
  }).readonly()
514
521
 
522
+ // ── 2.0.0:冲突裁决面(conflict.*)────────────────────────────────
523
+ // 结果 schema 必须**同时容纳成功与失败两种形状**:这一面的约定是失败返回
524
+ // `{ok:false, code, error}` 而不是抛异常。strict 校验会按声明裁剪 —— 只声明成功字段,
525
+ // 失败时的 code / error 就会在传到界面前消失,面板只剩一个没有原因的 ok:false。
526
+ //
527
+ // 另一条边界:列表只回摘要(`invalidate` / `undo` 等动作的结果回完整记录)。
528
+ // 一份记录里两份正文各上限 48 KB,整批列表搬过去会把 remote 面撑爆。
529
+ const conflictIdSchema = z.string().min(1).max(64)
530
+ const conflictOptsSchema = z.object({
531
+ 'id': conflictIdSchema,
532
+ 'file': fileNameSchema.optional(),
533
+ 'note': z.string().max(4096).optional(),
534
+ }).readonly()
535
+ const conflictItemBase = z.object({
536
+ 'id': z.string().readonly(),
537
+ 'at': z.string().readonly(),
538
+ 'kind': z.string().readonly(),
539
+ 'status': z.string().readonly(),
540
+ 'file': z.string().readonly(),
541
+ 'otherFile': z.string().readonly(),
542
+ 'note': z.string().readonly(),
543
+ 'decision': z.string().readonly(),
544
+ 'decidedAt': z.string().readonly(),
545
+ 'appliedVersion': z.string().readonly(),
546
+ // 这次被标失效的是哪一方(`file` 或 `otherFile`)——`undo` 唯一可信的落点。
547
+ 'invalidatedFile': z.string().readonly(),
548
+ 'truncated': z.boolean().readonly(),
549
+ })
550
+ const conflictItemSchema = conflictItemBase.readonly()
551
+ const conflictRecordSchema = conflictItemBase.extend({
552
+ 'incoming': z.string().readonly(),
553
+ 'onDisk': z.string().readonly(),
554
+ }).readonly()
555
+ const conflictCountsSchema = z.object({
556
+ 'open': z.number().readonly(),
557
+ 'total': z.number().readonly(),
558
+ }).readonly()
559
+ const conflictWroteSchema = z.object({
560
+ 'file': z.string().readonly(),
561
+ 'invalidAt': z.string().readonly(),
562
+ 'version': z.string().readonly(),
563
+ }).readonly()
564
+ const conflictListSchema = z.object({
565
+ 'ok': z.boolean().readonly(),
566
+ 'items': z.array(conflictItemSchema).readonly().optional(),
567
+ 'broken': z.array(z.string()).readonly().optional(),
568
+ 'ignored': z.array(z.string()).readonly().optional(),
569
+ 'counts': conflictCountsSchema.optional(),
570
+ 'code': z.string().readonly().optional(),
571
+ 'error': z.string().readonly().optional(),
572
+ }).readonly()
573
+ const conflictReadResultSchema = z.object({
574
+ 'ok': z.boolean().readonly(),
575
+ 'record': conflictRecordSchema.optional(),
576
+ 'warnings': z.array(z.string()).readonly().optional(),
577
+ 'code': z.string().readonly().optional(),
578
+ 'error': z.string().readonly().optional(),
579
+ }).readonly()
580
+ const conflictInvalidateResultSchema = z.object({
581
+ 'ok': z.boolean().readonly(),
582
+ 'record': conflictRecordSchema.optional(),
583
+ 'wrote': conflictWroteSchema.optional(),
584
+ 'code': z.string().readonly().optional(),
585
+ 'error': z.string().readonly().optional(),
586
+ }).readonly()
587
+ const conflictDismissResultSchema = z.object({
588
+ 'ok': z.boolean().readonly(),
589
+ 'record': conflictRecordSchema.optional(),
590
+ 'code': z.string().readonly().optional(),
591
+ 'error': z.string().readonly().optional(),
592
+ }).readonly()
593
+ const conflictUndoResultSchema = z.object({
594
+ 'ok': z.boolean().readonly(),
595
+ 'record': conflictRecordSchema.optional(),
596
+ 'cleared': z.object({ 'file': z.string().readonly() }).readonly().optional(),
597
+ 'code': z.string().readonly().optional(),
598
+ 'error': z.string().readonly().optional(),
599
+ }).readonly()
600
+ const conflictRemoveResultSchema = z.object({
601
+ 'ok': z.boolean().readonly(),
602
+ 'code': z.string().readonly().optional(),
603
+ 'error': z.string().readonly().optional(),
604
+ }).readonly()
605
+
515
606
  export const TYPERT = {
516
607
  package: 'sage-mem',
517
608
  face: 'host',
@@ -910,6 +1001,78 @@ export const TYPERT = {
910
1001
  result: { mode: 'strict', typeSymbol: 'sage-mem/types#AutodreamModelList', schema: modelListSchema, create: () => modelListSchema },
911
1002
  sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
912
1003
  },
1004
+ // ── 2.0.0:冲突裁决面(sourceLocation 硬编码为 lib/index.js —— 见 D21)──
1005
+ {
1006
+ id: 'sage-mem#conflict/list',
1007
+ service: 'conflict',
1008
+ namespace: 'conflict',
1009
+ method: 'list',
1010
+ invocation: { kind: 'direct' },
1011
+ parameters: [],
1012
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#ConflictList', schema: conflictListSchema, create: () => conflictListSchema },
1013
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
1014
+ },
1015
+ {
1016
+ id: 'sage-mem#conflict/read',
1017
+ service: 'conflict',
1018
+ namespace: 'conflict',
1019
+ method: 'read',
1020
+ invocation: { kind: 'direct' },
1021
+ parameters: [
1022
+ { name: 'opts', wire: 'opts', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#ConflictOpts', schema: conflictOptsSchema, create: () => conflictOptsSchema } },
1023
+ ],
1024
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#ConflictReadResult', schema: conflictReadResultSchema, create: () => conflictReadResultSchema },
1025
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
1026
+ },
1027
+ {
1028
+ id: 'sage-mem#conflict/invalidate',
1029
+ service: 'conflict',
1030
+ namespace: 'conflict',
1031
+ method: 'invalidate',
1032
+ invocation: { kind: 'direct' },
1033
+ parameters: [
1034
+ { name: 'opts', wire: 'opts', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#ConflictOpts', schema: conflictOptsSchema, create: () => conflictOptsSchema } },
1035
+ ],
1036
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#ConflictInvalidateResult', schema: conflictInvalidateResultSchema, create: () => conflictInvalidateResultSchema },
1037
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
1038
+ },
1039
+ {
1040
+ id: 'sage-mem#conflict/dismiss',
1041
+ service: 'conflict',
1042
+ namespace: 'conflict',
1043
+ method: 'dismiss',
1044
+ invocation: { kind: 'direct' },
1045
+ parameters: [
1046
+ { name: 'opts', wire: 'opts', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#ConflictOpts', schema: conflictOptsSchema, create: () => conflictOptsSchema } },
1047
+ ],
1048
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#ConflictDismissResult', schema: conflictDismissResultSchema, create: () => conflictDismissResultSchema },
1049
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
1050
+ },
1051
+ {
1052
+ id: 'sage-mem#conflict/undo',
1053
+ service: 'conflict',
1054
+ namespace: 'conflict',
1055
+ method: 'undo',
1056
+ invocation: { kind: 'direct' },
1057
+ parameters: [
1058
+ { name: 'opts', wire: 'opts', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#ConflictOpts', schema: conflictOptsSchema, create: () => conflictOptsSchema } },
1059
+ ],
1060
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#ConflictUndoResult', schema: conflictUndoResultSchema, create: () => conflictUndoResultSchema },
1061
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
1062
+ },
1063
+ {
1064
+ // ⚠️ 不叫 `remove`:客户端 RemoteNamespaceService 的保留方法名,见 lib/index.js 的注释。
1065
+ id: 'sage-mem#conflict/removeRecord',
1066
+ service: 'conflict',
1067
+ namespace: 'conflict',
1068
+ method: 'removeRecord',
1069
+ invocation: { kind: 'direct' },
1070
+ parameters: [
1071
+ { name: 'opts', wire: 'opts', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#ConflictOpts', schema: conflictOptsSchema, create: () => conflictOptsSchema } },
1072
+ ],
1073
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#ConflictRemoveResult', schema: conflictRemoveResultSchema, create: () => conflictRemoveResultSchema },
1074
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
1075
+ },
913
1076
  ],
914
1077
  model: {
915
1078
  "services": [],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sage-mem",
3
3
  "description": "Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.",
4
- "version": "1.9.2",
4
+ "version": "2.0.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",
@@ -50,7 +50,8 @@
50
50
  "0.1.7-rc.2": "compatible",
51
51
  "0.2.0-rc.1": "compatible",
52
52
  "0.2.0-rc.2": "compatible",
53
- "0.2.1-rc.1": "compatible"
53
+ "0.2.1-rc.1": "compatible",
54
+ "0.2.1-rc.3": "compatible"
54
55
  }
55
56
  }
56
57
  },