sage-mem 1.9.1 → 2.0.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.
@@ -29,8 +29,8 @@ export function buildAutodreamSystemPrompt(opts) {
29
29
  ? `- **不许改动的文件**(使用者指定的保护名单):${reserved.map((f) => `\`${f}\``).join('、')} —— 这些多半是手写单副本、人工维护的重要记忆,一个字都不要动`
30
30
  : '- 有些记忆是使用者手写单副本、人工维护的;本次没有额外指定保护名单,遇到明显「成篇手写、结构不像流水」的记忆请保守处理'
31
31
  const modeLine = apply
32
- ? '本次是**改写模式**:你有写工具,可以直接改记忆文件。运行前已经把整个 memory 目录快照了一份(这就是回滚点),改坏能整体退回去——但这只保证「可恢复」,不保证「改得对」。宁可少改,不要乱动。\n\n**每一次写入都请写清 `reason`。** 这一趟是无人值守跑的,人只能靠这份缘由判断你改得对不对。写清「为什么改这条、改成了什么」,不要写「整理优化」这种没有信息量的话。缺了**不会被拒绝**——改动照样生效——但整理声明里会把那条标成「未自述」,用宿主能追溯到的信息顶替。'
33
- : '本次是**只读模式**:你没有任何写工具。把所有发现写成建议清单,写进你最后的回复里——要具体到「哪个文件、改成什么」,不要泛泛而谈。'
32
+ ? '本次是**改写模式**:你有写工具,可以直接改记忆文件。运行前已经把整个 memory 目录快照了一份(这就是回滚点),改坏能整体退回去——但这只保证「可恢复」,不保证「改得对」。宁可少改,不要乱动。\n\n**每一次写入都请写清 `reason`。** 这一趟是无人值守跑的,人只能靠这份缘由判断你改得对不对。写清「为什么改这条、改成了什么」,不要写「整理优化」这种没有信息量的话。缺了**不会被拒绝**——改动照样生效——但整理声明里会把那条标成「未自述」,用宿主能追溯到的信息顶替。\n\n**遇到两条说法互斥的记忆(同一件事两种说法),用 `report_contradiction` 报出来,不要自己删改其中任何一条。** 裁决由人做:报出来之后这两条暂时都不进正文,人会在面板上选哪条作废。自己删改等于替人决定「哪条历史不该存在」。'
33
+ : '本次是**只读模式**:你没有任何写工具。把所有发现写成建议清单,写进你最后的回复里——要具体到「哪个文件、改成什么」,不要泛泛而谈。发现两条记忆互相矛盾时,单列出来说明哪两条、哪里互斥。'
34
34
 
35
35
  return `你是 sage-mem(一个文件式跨会话记忆系统)的整理进程。你的记忆不是数据库,是一个装着 markdown 文件的目录,靠 frontmatter 分类、靠双字组匹配检索。你要做的是一次反思性通读,把最近的经历沉淀成耐久、有条理的记忆。
36
36
 
@@ -82,7 +82,7 @@ export function buildAutodreamUserPrompt(opts) {
82
82
  - 每条一行 \`- [标题](文件.md) — 一句话\`
83
83
  - 移除指向已归档 / 已不存在文件的指针
84
84
  - 补上新写的重要记忆
85
- - 两条记忆互相矛盾时,把错的那条改对`
85
+ - 两条记忆互相矛盾时,用 \`report_contradiction\` 报出来(不要自己删改其中任何一条)`
86
86
  : `- 给出 \`MEMORY.md\` 该增删哪些行的**具体清单**(逐字给出要改成的那行文本)`
87
87
 
88
88
  return `# Autodream:一次记忆整理
@@ -57,6 +57,8 @@ export async function writeDeclaration(payload) {
57
57
  finalText: payload.finalText ?? '',
58
58
  // 1.0.1:输出撞上单次上限这件事要进机读记录 —— 残篇在界面上和「话说一半」长得一模一样。
59
59
  continuations: Number(payload.continuations) || 0,
60
+ // 1.9.2:零正文重试是另一本账 —— 「接着写过」与「根本没写出来」不能混成一个数字。
61
+ retries: Number(payload.retries) || 0,
60
62
  truncated: payload.truncated === true,
61
63
  rolledBackAt: 0,
62
64
  rolledBackAtHuman: '',
@@ -79,10 +81,14 @@ export function renderDeclaration(m, headingLevel = 1) {
79
81
  : '- 回滚点:未做(只出报告模式不写盘,无需回滚)',
80
82
  `- 审计:结构问题 ${m.audit.before.problems} → ${m.audit.after.problems}`,
81
83
  ...(m.truncated
82
- ? [`- ⚠️ 输出被单次长度上限截断:续写 ${m.continuations ?? 0} 轮后仍未写完,收尾说明是**残篇**`]
83
- : m.continuations
84
- ? [`- 输出曾撞上单次长度上限,已自动续写 ${m.continuations} 轮补齐`]
85
- : []),
84
+ ? [
85
+ `- ⚠️ 输出被单次长度上限截断:续写 ${m.continuations ?? 0} 轮${m.retries ? `、零正文重试 ${m.retries} 次` : ''}后仍未写完,收尾说明是**残篇**`,
86
+ ]
87
+ : m.retries
88
+ ? [`- 输出曾出现零正文截断(思考吃满额度),重试 ${m.retries} 次后补齐`]
89
+ : m.continuations
90
+ ? [`- 输出曾撞上单次长度上限,已自动续写 ${m.continuations} 轮补齐`]
91
+ : []),
86
92
  '',
87
93
  `${H}# 改了什么、为什么`,
88
94
  '',
@@ -213,12 +219,15 @@ export async function writeReport(payload) {
213
219
  */
214
220
  const truncLines = payload.truncated
215
221
  ? [
216
- `- ⚠️ **本次输出被单次长度上限截断**:自动续写 ${payload.continuations ?? 0} 轮后仍未写完 —— ` +
222
+ `- ⚠️ **本次输出被单次长度上限截断**:自动续写 ${payload.continuations ?? 0} 轮` +
223
+ `${payload.retries ? `、零正文重试 ${payload.retries} 次` : ''}后仍未写完 —— ` +
217
224
  '下面「模型的收尾说明」是**残篇**,后文缺失',
218
225
  ]
219
- : payload.continuations
220
- ? [`- 输出曾撞上单次长度上限,已自动续写 ${payload.continuations} 轮补齐(报告完整)`]
221
- : []
226
+ : payload.retries
227
+ ? [`- 输出曾出现零正文截断(思考吃满额度),重试 ${payload.retries} 次后补齐(报告完整)`]
228
+ : payload.continuations
229
+ ? [`- 输出曾撞上单次长度上限,已自动续写 ${payload.continuations} 轮补齐(报告完整)`]
230
+ : []
222
231
 
223
232
  const lines = [
224
233
  `# Autodream 报告 · ${stampHuman(payload.startedAt)}`,
@@ -257,8 +266,9 @@ export async function writeReport(payload) {
257
266
  '## 过程',
258
267
  '',
259
268
  ...payload.transcript.map((t) => {
260
- // 「续写」必须标出来:否则同一轮出现好几条,读起来像模型重复劳动。
261
- const mark = t.continuation ? '(截断续写)' : ''
269
+ // 「续写」与「零正文重试」都必须标出来:否则同一轮出现好几条,读起来像模型重复劳动。
270
+ // 两者分开标 —— 一个是「接着写」,一个是「上一段根本没写出来」,读者要看得出区别。
271
+ const mark = t.retry ? '(零正文重试)' : t.continuation ? '(截断续写)' : ''
262
272
  const kind = t.tools.length ? `:调用 ${t.tools.join('、')}` : ':结束'
263
273
  // ⚠️ **先压平空白再截断**。模型这一步的文字里常带换行与 `## 标题`,直接 slice(0,120)
264
274
  // 会把标题漏成真标题 —— 报告读起来像「内容重复了一遍」(2026-10-17 10:42 那次真踩到:
@@ -99,6 +99,12 @@ export async function setConfig(patch) {
99
99
  if (p.minHours !== undefined) c.minHours = clampNum(Number(p.minHours), 1, 720, DEFAULT_CONFIG.minHours)
100
100
  if (p.minSessions !== undefined) c.minSessions = Math.round(clampNum(Number(p.minSessions), 1, 200, DEFAULT_CONFIG.minSessions))
101
101
  if (p.maxSteps !== undefined) c.maxSteps = Math.round(clampNum(Number(p.maxSteps), 1, 80, DEFAULT_CONFIG.maxSteps))
102
+ /**
103
+ * 单次输出上限(1.9.2)。范围 1024–131072:下限够小、上限够到大多数模型的硬顶;
104
+ * 越界按统一口径**夹到合法范围**,不写一份坏配置。
105
+ */
106
+ if (p.maxOutputTokens !== undefined)
107
+ c.maxOutputTokens = Math.round(clampNum(Number(p.maxOutputTokens), 1024, 131072, DEFAULT_CONFIG.maxOutputTokens))
102
108
  if (p.maxSnapshotKeep !== undefined)
103
109
  c.maxSnapshotKeep = Math.round(clampNum(Number(p.maxSnapshotKeep), 1, 50, DEFAULT_CONFIG.maxSnapshotKeep))
104
110
  if (typeof p.provider === 'string') c.provider = p.provider.trim().slice(0, 120)
@@ -33,7 +33,7 @@ import { MAX_FILE_BYTES, STATE_ROOT } from '../memory/config.js'
33
33
  import { INDEX_FILE_KEY, SESSION_LOG_KEY } from '../shared/md-name.js'
34
34
  import { stampHuman as localStamp } from '../shared/stamp.js'
35
35
  import { writeFileAtomic, warnLog } from '../memory/util.js'
36
- import { buildConflict, listConflicts, writeConflict } from '../memory/conflicts.js'
36
+ import { buildConflict, listConflicts, readConflict, sameConflict, writeConflict } from '../memory/conflicts.js'
37
37
  import { memoryPathOf } from '../memory/paths.js'
38
38
  import { decideWriteIntent } from '../memory/write-gate.js'
39
39
  import { inspectMemoryEntry, reservedIdentitySet } from '../memory/scan.js'
@@ -396,6 +396,21 @@ export function autodreamToolSchemas(opts) {
396
396
  additionalProperties: false,
397
397
  },
398
398
  },
399
+ {
400
+ name: 'report_contradiction',
401
+ description:
402
+ '报告一对**说法互斥**的记忆(同一件事两种说法,例如「部署在 A」与「部署在 B」)。它只落一条待人工裁决的记录,**不修改任何记忆文件**。裁决之前这两条都不会被注入正文;不要自己删改其中任何一条来「消除矛盾」。',
403
+ parameters: {
404
+ type: 'object',
405
+ properties: {
406
+ old: str('旧说法 / 可能已作废的那条记忆的文件名'),
407
+ new: str('新说法 / 现行的那条记忆的文件名'),
408
+ note: str('一句话说明两条到底哪里互斥(人要靠它判断该保留哪条)'),
409
+ },
410
+ required: ['old', 'new', 'note'],
411
+ additionalProperties: false,
412
+ },
413
+ },
399
414
  )
400
415
  }
401
416
  return tools
@@ -646,24 +661,18 @@ export function createToolRunner(opts) {
646
661
  * (见 `buildConflict`),而「模型每一趟都试图覆盖一份它没读过的记忆」是**结构性**的
647
662
  * —— 每一趟都会生成新 id,于是 `conflicts/` 只增不减,而其中绝大多数是同一件事的副本。
648
663
  *
649
- * 判据是**逐字段相等**(kind / file / 两份正文 / 两个版本):任何一处不同都算另一件事,
664
+ * 判据是**逐字段相等**(kind / file / otherFile / 两份正文 / 两个版本):任何一处不同都算另一件事,
650
665
  * 仍然是两条独立记录 —— 去重只跳过重复,不做「合并」,更不做裁决。
666
+ * `otherFile` 必须进比较列表:否则「A 对 B」与「A 对 C」会被当成同一件事,
667
+ * 第二对矛盾就永远落不了盘。
651
668
  * 读队列失败一律当作「没有重复」:去重是省磁盘的优化,不能因为读不动就连该留的记录也不留。
652
669
  */
653
670
  async function hasSameOpenConflict(record) {
654
671
  try {
655
672
  const listed = await listConflicts({ stateRoot: STATE_ROOT })
656
673
  if (!listed.ok) return false
657
- return listed.items.some(
658
- (c) =>
659
- c.status === 'open' &&
660
- c.kind === record.kind &&
661
- c.file === record.file &&
662
- c.expectedVersion === record.expectedVersion &&
663
- c.actualVersion === record.actualVersion &&
664
- c.incoming === record.incoming &&
665
- c.onDisk === record.onDisk,
666
- )
674
+ // 判据本身在 `conflicts.js` 的 `sameConflict`(唯一一份):这里只负责「读队列 + 任一相等」。
675
+ return listed.items.some((c) => sameConflict(c, record))
667
676
  } catch {
668
677
  return false
669
678
  }
@@ -918,6 +927,62 @@ export function createToolRunner(opts) {
918
927
  }
919
928
  return `已归档 ${basename(full)}(原因:${reason})。`
920
929
  }
930
+ case 'report_contradiction': {
931
+ // 2.0.0:**只落待裁决记录,不动记忆文件**。检测只发生在整理这条已有路径上
932
+ // (不做常驻扫描、不加公开工具面,D29 Q5);裁决永远由人做。
933
+ if (!apply) return '错误:当前是「只出报告」模式,没有写权限。请把发现的矛盾写进你的最终回复。'
934
+ const oldName = safeMemoryPath(memoryDir, a.old)
935
+ const newName = safeMemoryPath(memoryDir, a.new)
936
+ if (!oldName || !newName) return '错误:文件名不合法(只能是 memory 目录顶层的 .md 文件名)。'
937
+ const oldFull = join(memoryDir, await resolveRealName(memoryDir, basename(oldName)))
938
+ const newFull = join(memoryDir, await resolveRealName(memoryDir, basename(newName)))
939
+ if (basename(oldFull) === basename(newFull)) return '错误:old 与 new 不能是同一个文件。'
940
+ const note = typeof a.note === 'string' ? a.note.trim() : ''
941
+ if (note.length < MIN_REASON_CHARS) {
942
+ return '错误:report_contradiction 必须给 note —— 一句话说明这两条到底哪里互斥。'
943
+ }
944
+ // 两份正文都存进记录:面板上人要对照着判,只留一边等于没得判。
945
+ const readBody = async (full) => {
946
+ const gate = await inspectMemoryEntry(full)
947
+ if (!gate.ok) return null
948
+ return await readFile(full, 'utf8').catch(() => null)
949
+ }
950
+ const onDisk = await readBody(oldFull)
951
+ const incoming = await readBody(newFull)
952
+ if (onDisk === null || incoming === null) {
953
+ return '错误:old / new 里有读不到的文件,先用 list_memory 确认真实文件名。'
954
+ }
955
+ const record = buildConflict({
956
+ kind: 'contradiction',
957
+ file: basename(oldFull),
958
+ otherFile: basename(newFull),
959
+ incoming,
960
+ onDisk,
961
+ note,
962
+ tool: 'report_contradiction',
963
+ at: new Date().toISOString(),
964
+ })
965
+ const pair = `${basename(oldFull)} ↔ ${basename(newFull)}`
966
+ if (await hasSameOpenConflict(record)) {
967
+ notes.push(`发现矛盾:${pair}(队列里已有同一对,未重复落盘)`)
968
+ return `已记录(此前已有同一对待裁决记录):${pair}。`
969
+ }
970
+ /**
971
+ * id 的时间戳只精确到秒:同一对在**同一秒**里「裁决完又重报」会撞上已裁决记录的 id,
972
+ * 盲写会把它拉回 open 并把裁决收据冲掉(状态机被这条路径绕过)。
973
+ * 撞上就换一个后缀,让这次报告成为**一条新记录** —— 旧判断留在原记录里,不该被覆盖。
974
+ */
975
+ let target = record
976
+ for (let n = 2; n <= 9; n++) {
977
+ const existing = await readConflict(target.id, { stateRoot: STATE_ROOT })
978
+ if (existing?.code === 'missing') break
979
+ target = { ...record, id: `${record.id}-${n}` }
980
+ }
981
+ const written = await writeConflict(target, { stateRoot: STATE_ROOT })
982
+ if (!written.ok) return `错误:矛盾记录没落下来(${written.code}):${written.error ?? ''}`
983
+ notes.push(`发现矛盾:${pair}(待人工裁决:${note})`)
984
+ return `已记录矛盾:${pair}。裁决之前这两条都不会被注入正文;请不要自行删改其中任何一条。`
985
+ }
921
986
  default:
922
987
  return `错误:没有这个工具:${name}`
923
988
  }