sage-mem 1.0.0 → 1.0.2

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.
@@ -290,6 +290,8 @@ export class AutodreamEngine {
290
290
  auditBefore,
291
291
  auditAfter,
292
292
  finalText: result.finalText,
293
+ continuations: result.continuations,
294
+ truncated: result.truncated,
293
295
  archiveMode: policy.mode,
294
296
  archiveRequested: policy.requested,
295
297
  archiveCandidates: policy.candidates,
@@ -316,6 +318,8 @@ export class AutodreamEngine {
316
318
  auditBefore,
317
319
  auditAfter,
318
320
  finalText: result.finalText,
321
+ continuations: result.continuations,
322
+ truncated: result.truncated,
319
323
  transcript: result.transcript,
320
324
  tokensIn: result.tokensIn,
321
325
  tokensOut: result.tokensOut,
@@ -341,6 +345,9 @@ export class AutodreamEngine {
341
345
  problemsAfter: auditAfter.problems,
342
346
  tokensIn: result.tokensIn,
343
347
  tokensOut: result.tokensOut,
348
+ // 残篇要能在界面上被认出来(面板读 lastResult):只报「成功」是不够的。
349
+ continuations: result.continuations,
350
+ truncated: result.truncated,
344
351
  report: report.name,
345
352
  snapshotId: snapshot?.id ?? null,
346
353
  }
@@ -353,6 +360,8 @@ export class AutodreamEngine {
353
360
  touched: result.touched,
354
361
  changeCount: result.changes.length,
355
362
  snapshotId: snapshot?.id ?? null,
363
+ continuations: result.continuations,
364
+ truncated: result.truncated,
356
365
  auditBefore,
357
366
  auditAfter,
358
367
  }
@@ -150,10 +150,35 @@ export async function streamOnce(llm, req) {
150
150
  return asm
151
151
  }
152
152
 
153
+ /**
154
+ * 一次模型调用的输出上限。
155
+ *
156
+ * ⚠️ **调大它解决不了「长报告被截断」**:报告长度没有上界,调大只是把墙往后挪一格
157
+ * (2026-10-17 实测:10-01 的报告 5532 字符、10-13 是 5957,都完整收尾;这次 8553 字符
158
+ * 就断在半个句子里)。真正的处理是撞上它时**接着写** —— 见下面的 MAX_CONTINUATIONS。
159
+ */
160
+ const MAX_TOKENS_PER_CALL = 4096
161
+ /** 撞上单次上限后最多续写几轮:够把最后一段写完,又不至于让一次跑飞掉。 */
162
+ const MAX_CONTINUATIONS = 3
163
+ /** 续写指令。三条禁令缺一不可 —— 重写整篇 / 重新开头 / 道歉寒暄,模型都干得出来。 */
164
+ const CONTINUE_PROMPT =
165
+ '(上一条回答被输出长度上限截断了。请**从中断处继续写**,不要重复已经写过的内容,不要重新开头,不要解释或道歉,直接接着写。)'
166
+
167
+ /** 取这一次调用里的全部文字块,拼成一段。 */
168
+ function textOf(asm) {
169
+ return asm
170
+ .blocks()
171
+ .filter((b) => b.type === 'text')
172
+ .map((b) => b.text)
173
+ .join('')
174
+ .trim()
175
+ }
176
+
153
177
  /**
154
178
  * 极简 agent 循环:模型 → 工具 → 结果回灌 → 再问,直到模型不再要工具或触顶。
155
179
  *
156
180
  * 每一步都把「这一轮要了什么工具」记进 stepLog,设置面板显示的就是这个。
181
+ * **一步可以包含多次模型调用**:撞上输出上限时接着写,而不是把半篇当成品交出去。
157
182
  */
158
183
  export async function agentLoop(cfg, runOpts) {
159
184
  const { provider, model, toolSchemas, runner, maxSteps, tokenBudget, signal } = runOpts
@@ -178,40 +203,90 @@ export async function agentLoop(cfg, runOpts) {
178
203
  let tokensIn = 0
179
204
  let tokensOut = 0
180
205
  let finalText = ''
206
+ // 截断与续写的账:`continuations` 是「确实接着写过几轮」,`truncated` 是「续写用尽还是没写完」。
207
+ // 两个都要进声明与报告 —— 「报告是残篇却报成功」正是这一版要修的那处静默。
208
+ let continuations = 0
209
+ let truncated = false
210
+ let lastStep = 0
181
211
  const allToolsUsed = []
212
+ const addUsage = (u) => {
213
+ if (!u) return
214
+ tokensIn += u.inputTokens ?? 0
215
+ tokensOut += u.outputTokens ?? 0
216
+ }
182
217
 
183
218
  for (let step = 1; step <= maxSteps; step++) {
184
219
  if (signal?.aborted) throw new Error('autodream 被取消')
185
220
  // 让工具执行器知道现在是第几轮:模型没自述缘由时,声明要靠这个追溯「哪一步干的」。
186
221
  if (typeof runner.setStep === 'function') runner.setStep(step)
187
222
  this.phase = `第 ${step} 轮:等待模型`
188
- const asm = await this.streamOnce(llm, {
223
+ lastStep = step
224
+
225
+ let asm = await this.streamOnce(llm, {
189
226
  provider,
190
227
  model,
191
228
  system,
192
229
  messages,
193
230
  tools: toolSchemas,
194
- maxTokens: 4096,
231
+ maxTokens: MAX_TOKENS_PER_CALL,
195
232
  signal,
196
233
  })
197
- const usage = asm.usage
198
- if (usage) {
199
- tokensIn += usage.inputTokens ?? 0
200
- tokensOut += usage.outputTokens ?? 0
234
+ addUsage(asm.usage)
235
+
236
+ // 一段 = 一次模型调用产出的文字。正常只有一段;撞上输出上限时接着写下一段。
237
+ let text = ''
238
+ let calls = []
239
+ let segment = 0
240
+ for (;;) {
241
+ const segText = textOf(asm)
242
+ const blocks = asm.blocks()
243
+ const segCalls = blocks.filter((b) => b.type === 'tool-call')
244
+ // 只有第一段可能带工具调用:被截断时 BlockAssembler 已经丢掉了参数残缺的 tool-call。
245
+ if (segment === 0) calls = segCalls
246
+ text += segText
247
+ transcript.push({
248
+ step,
249
+ text: segText,
250
+ tools: segCalls.map((c) => c.name),
251
+ ...(segment ? { continuation: true } : {}),
252
+ })
253
+ this.stepLog.push({
254
+ step,
255
+ tools: segCalls.map((c) => c.name),
256
+ chars: segText.length,
257
+ at: Date.now(),
258
+ ...(segment ? { continuation: true } : {}),
259
+ })
260
+ segment += 1
261
+ if (asm.finish.kind !== 'max-tokens') break
262
+ // 空段没法接着写;续写名额用尽也一样 —— 两种都算「这篇没写完」,要如实记下来。
263
+ if (!segText || continuations >= MAX_CONTINUATIONS) {
264
+ truncated = true
265
+ break
266
+ }
267
+ continuations += 1
268
+ messages.push(assistantMessage(blocks, provider, model))
269
+ messages.push(userMessage(CONTINUE_PROMPT))
270
+ this.phase = `第 ${step} 轮:输出撞上单次上限,续写 ${continuations}/${MAX_CONTINUATIONS}`
271
+ asm = await this.streamOnce(llm, {
272
+ provider,
273
+ model,
274
+ system,
275
+ messages,
276
+ tools: toolSchemas,
277
+ maxTokens: MAX_TOKENS_PER_CALL,
278
+ signal,
279
+ })
280
+ addUsage(asm.usage)
201
281
  }
202
- const blocks = asm.blocks()
203
- const text = blocks.filter((b) => b.type === 'text').map((b) => b.text).join('').trim()
204
- const calls = blocks.filter((b) => b.type === 'tool-call')
205
- if (text) finalText = text
206
- transcript.push({ step, text, tools: calls.map((c) => c.name) })
207
- this.stepLog.push({ step, tools: calls.map((c) => c.name), chars: text.length, at: Date.now() })
208
282
 
283
+ if (text) finalText = text
209
284
  if (asm.finish.kind === 'error') {
210
285
  throw new Error(`模型返回错误:${asm.finish.failure?.message ?? '未知'}`)
211
286
  }
212
287
  if (!calls.length) break
213
288
 
214
- messages.push(assistantMessage(blocks, provider, model))
289
+ messages.push(assistantMessage(asm.blocks(), provider, model))
215
290
  for (const call of calls) {
216
291
  let args = {}
217
292
  try {
@@ -231,11 +306,22 @@ export async function agentLoop(cfg, runOpts) {
231
306
  }
232
307
  }
233
308
 
309
+ if (truncated) {
310
+ // 过程清单要以「没写完」收尾:否则报告里那半句话看起来只是模型话说到一半。
311
+ transcript.push({
312
+ step: lastStep,
313
+ text: `(输出撞上单次长度上限,续写 ${continuations} 轮后仍未写完 —— 上面这段是残篇,后文缺失)`,
314
+ tools: [],
315
+ })
316
+ }
317
+
234
318
  return {
235
319
  transcript,
236
320
  finalText,
237
321
  tokensIn,
238
322
  tokensOut,
323
+ continuations,
324
+ truncated,
239
325
  toolsUsed: allToolsUsed,
240
326
  touched: runner.touchedFiles(),
241
327
  changes: runner.changes(),
@@ -55,6 +55,9 @@ export async function writeDeclaration(payload) {
55
55
  },
56
56
  report: payload.report ?? null,
57
57
  finalText: payload.finalText ?? '',
58
+ // 1.0.1:输出撞上单次上限这件事要进机读记录 —— 残篇在界面上和「话说一半」长得一模一样。
59
+ continuations: Number(payload.continuations) || 0,
60
+ truncated: payload.truncated === true,
58
61
  rolledBackAt: 0,
59
62
  rolledBackAtHuman: '',
60
63
  }
@@ -75,6 +78,11 @@ export function renderDeclaration(m, headingLevel = 1) {
75
78
  ? `- 回滚点:\`${m.snapshot.id}\`(${m.snapshot.files} 个文件)`
76
79
  : '- 回滚点:未做(只出报告模式不写盘,无需回滚)',
77
80
  `- 审计:结构问题 ${m.audit.before.problems} → ${m.audit.after.problems}`,
81
+ ...(m.truncated
82
+ ? [`- ⚠️ 输出被单次长度上限截断:续写 ${m.continuations ?? 0} 轮后仍未写完,收尾说明是**残篇**`]
83
+ : m.continuations
84
+ ? [`- 输出曾撞上单次长度上限,已自动续写 ${m.continuations} 轮补齐`]
85
+ : []),
78
86
  '',
79
87
  `${H}# 改了什么、为什么`,
80
88
  '',
@@ -198,6 +206,20 @@ export async function writeReport(payload) {
198
206
  const name = `${payload.runId}.md`
199
207
  const file = join(dir, name)
200
208
 
209
+ /**
210
+ * 输出撞上限这件事**必须在报告第一屏就说**。
211
+ * 为什么:残篇与「模型话说一半」在文件里长得一样,读者无从分辨;而这一版之前,
212
+ * 被截断的那份报告在界面上报的是「成功」。
213
+ */
214
+ const truncLines = payload.truncated
215
+ ? [
216
+ `- ⚠️ **本次输出被单次长度上限截断**:自动续写 ${payload.continuations ?? 0} 轮后仍未写完 —— ` +
217
+ '下面「模型的收尾说明」是**残篇**,后文缺失',
218
+ ]
219
+ : payload.continuations
220
+ ? [`- 输出曾撞上单次长度上限,已自动续写 ${payload.continuations} 轮补齐(报告完整)`]
221
+ : []
222
+
201
223
  const lines = [
202
224
  `# Autodream 报告 · ${stampHuman(payload.startedAt)}`,
203
225
  '',
@@ -208,6 +230,7 @@ export async function writeReport(payload) {
208
230
  `- 模型:\`${payload.provider}/${payload.model}\`${payload.fromDefault ? '(跟随当前会话默认模型)' : '(配置里指定)'}`,
209
231
  `- 门控:距上次 ${payload.hoursSince === null ? '(首次)' : payload.hoursSince.toFixed(1) + 'h'} · 期间 ${payload.sessionCount} 个会话更新`,
210
232
  `- 耗时:${((payload.endedAt - payload.startedAt) / 1000).toFixed(1)}s · token ${payload.tokensIn} in / ${payload.tokensOut} out`,
233
+ ...truncLines,
211
234
  payload.snapshot
212
235
  ? `- 回滚点:\`${payload.snapshot.id}\` · ${payload.snapshot.dir}(${payload.snapshot.count} 个文件)`
213
236
  : '- 回滚点:未做(只读模式不写盘)',
@@ -233,10 +256,19 @@ export async function writeReport(payload) {
233
256
  payload.declarationMarkdown,
234
257
  '## 过程',
235
258
  '',
236
- ...payload.transcript.map(
237
- (t) =>
238
- `- 第 ${t.step} 轮${t.tools.length ? `:调用 ${t.tools.join('、')}` : ':结束'}${t.text ? ` — ${t.text.slice(0, 120)}` : ''}`,
239
- ),
259
+ ...payload.transcript.map((t) => {
260
+ // 「续写」必须标出来:否则同一轮出现好几条,读起来像模型重复劳动。
261
+ const mark = t.continuation ? '(截断续写)' : ''
262
+ const kind = t.tools.length ? `:调用 ${t.tools.join('、')}` : ':结束'
263
+ // ⚠️ **先压平空白再截断**。模型这一步的文字里常带换行与 `## 标题`,直接 slice(0,120)
264
+ // 会把标题漏成真标题 —— 报告读起来像「内容重复了一遍」(2026-10-17 10:42 那次真踩到:
265
+ // 「过程」最后一条把下一节的 `## 一、必须修改的…` 顶了出来)。
266
+ const brief = String(t.text ?? '')
267
+ .replace(/\s+/g, ' ')
268
+ .trim()
269
+ .slice(0, 120)
270
+ return `- 第 ${t.step} 轮${mark}${kind}${brief ? ` — ${brief}` : ''}`
271
+ }),
240
272
  '',
241
273
  ]
242
274
  await writeFile(file, lines.join('\n'), 'utf8')
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.0.0",
4
+ "version": "1.0.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {