sage-mem 0.7.2 → 0.9.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.
package/cordis.patch.yml CHANGED
@@ -1,4 +1,4 @@
1
- # sage-mem bundle patch — injects the memory bridge plugin into the profile tree.
2
- - insert:
3
- - id: sage-mem
4
- name: 'sage-mem'
1
+ # sage-mem bundle patch — injects the memory bridge plugin into the profile tree.
2
+ - insert:
3
+ - id: sage-mem
4
+ name: 'sage-mem'
@@ -0,0 +1,83 @@
1
+ /**
2
+ * autodream —— 配置默认值与全部常量。
3
+ *
4
+ * 改名兼容(`dream` → `autodream`)用到的旧目录 / 旧文件名常量也在这儿,
5
+ * **一个都不能删**:它们是旧数据仍能被读到、被回滚的唯一依据。
6
+ */
7
+
8
+ /** 配置结构版本:将来加字段时用来做迁移。v2 = 改名 autodream + 回滚点 + 声明。 */
9
+ export const CONFIG_VERSION = 2
10
+
11
+ /** 默认配置。每一项都能从设置面板改;空 provider/model 表示跟聊天页当前默认模型。 */
12
+ export const DEFAULT_CONFIG = {
13
+ enabled: false,
14
+ trigger: 'manual',
15
+ apply: false,
16
+ source: 'memory',
17
+ minHours: 24,
18
+ minSessions: 5,
19
+ provider: '',
20
+ model: '',
21
+ maxSteps: 24,
22
+ maxSnapshotKeep: 5,
23
+ /** 回滚按钮的默认范围:files = 只恢复该次运行动过的文件;all = 整目录恢复到该时点。 */
24
+ rollbackScope: 'files',
25
+ /**
26
+ * 归档策略:off = 什么都不做;report = 只把候选写进报告(**默认,一个文件都不动**);
27
+ * auto = 运行末尾真的把候选移进 `archive/`(只移不删)。
28
+ * 默认最保守:归档是不可逆的退役动作,先让人看见清单,再决定要不要自动。
29
+ */
30
+ autoArchive: 'report',
31
+ /**
32
+ * 自动归档阈值(天,按类型差异化)。feedback 没有阈值 —— 它**永不进候选**。
33
+ * 保守默认:project 90 / reference 180 / user 365。
34
+ */
35
+ archiveAfterDaysProject: 90,
36
+ archiveAfterDaysReference: 180,
37
+ archiveAfterDaysUser: 365,
38
+ }
39
+
40
+ /**
41
+ * 会话门的扫描节流(10 分钟)。
42
+ *
43
+ * 为什么需要它:时间门过了、会话门没过时,状态里的 lastRunAt 不会前进,
44
+ * 于是每一次触发都会重新扫一遍整个 sessions 目录。
45
+ * 节流把「看着像没过」和「真的没过」区分开——这中间的成本差异全是白烧的盘 IO。
46
+ */
47
+ export const SESSION_SCAN_INTERVAL_MS = 10 * 60 * 1000
48
+
49
+ /** 自动模式的检查间隔。门控本身很便宜(有节流),所以这个频率是安全的。 */
50
+ export const SCHEDULE_INTERVAL_MS = 30 * 60 * 1000
51
+
52
+ /** 启动后第一次自动检查的延迟——避开 DSH 启动时的那阵忙碌。 */
53
+ export const STARTUP_CHECK_DELAY_MS = 90 * 1000
54
+
55
+ /** 锁的失效时间:超过这个时长还挂着的锁,认定是上次进程崩了留下的。 */
56
+ export const LOCK_STALE_MS = 2 * 60 * 60 * 1000
57
+
58
+ /** 失败后的退避:别让一个每次都失败的任务把门控刷成高频重试。 */
59
+ export const FAILURE_BACKOFF_MS = 60 * 60 * 1000
60
+
61
+ /** 报告目录名(相对 memory 根)。放在子目录里,所以不会被 sage-mem 自己扫成记忆。 */
62
+ export const REPORT_DIR = 'autodream'
63
+
64
+ /** 改名前的报告目录。只读兼容:老报告还能在面板里翻到,但不再往里写。 */
65
+ export const LEGACY_REPORT_DIR = 'dream'
66
+
67
+ /** 状态根目录名(相对 memory 根的上一层)。 */
68
+ export const STATE_DIR = '.sage-mem'
69
+
70
+ /** 本功能在状态根下的私有目录(运行记录、回滚点、锁、留痕)。 */
71
+ export const FEATURE_DIR = 'autodream'
72
+
73
+ /** 改名前的状态文件名(`<stateRoot>/dream.json`),只在迁移时读一次。 */
74
+ export const LEGACY_STATE_FILE = 'dream.json'
75
+
76
+ /** 改名前的快照根(`<stateRoot>/snapshots/`),只读兼容。 */
77
+ export const LEGACY_SNAPSHOT_DIR = 'snapshots'
78
+
79
+ /** 「回滚前保护快照」单独保留的份数——它不该被普通运行的快照轮转挤掉。 */
80
+ export const PRE_ROLLBACK_KEEP = 3
81
+
82
+ /** 一次 autodream 允许累计的输入 token 上限(软保护,超了就停)。 */
83
+ export const DEFAULT_TOKEN_BUDGET = 600000
@@ -0,0 +1,524 @@
1
+ /**
2
+ * sage-mem autodream —— 「自动做梦」:让记忆自己整理一遍。
3
+ *
4
+ * 一趟 autodream 做四件事:定位(看清已有什么)→ 收集(找新信号)→ 整合(合并、修正)
5
+ * → 修剪(索引与断链)。下面是几个非显然的设计决定:
6
+ *
7
+ * 1. **门控三级,最便宜的先查**:
8
+ * 时间门(一次读写状态)→ 会话门(一次目录扫描,且带 10 分钟节流)→ 锁。
9
+ * 任何一级不过,后面的都不做 —— 顺序就是成本顺序。
10
+ * 2. **执行不走真正的子代理**,直接 `ctx.llm.stream()` 手写一个极简 agent 循环。
11
+ * 换来的是:工具集由插件提供(安全边界在 autodream-tools.js)、步数有上限、
12
+ * 不需要 agent 生命周期与 provider 注册,**也不会把 autodream 写进用户的会话记录**。
13
+ * 3. **每一趟可回滚、可交代**。memory 目录不在任何版本控制下、删除没有回收站,
14
+ * 所以「可回滚」必须自己造:运行前全量快照 + 运行后逐文件变更清单(含缘由),
15
+ * 二者用同一个 runId 绑在一起,于是「某次整理」既能被读懂,
16
+ * 也能被退回去。审计则让每趟 autodream 自证没改坏。
17
+ * 4. **模型路线可单独指定**。整理是后台批量任务,跟聊天用的模型不必是同一个;
18
+ * 空配置 = 跟随当前默认模型,非空 = 用户显式指定,**指定了就绝不静默回落**。
19
+ *
20
+ * 目录约定(都在 memoryDir 的上一级 `.sage-mem/` 下):
21
+ * - 配置与状态:`.sage-mem/autodream.json`(旧版 `.sage-mem/dream.json` 只读迁移)
22
+ * - 运行记录与声明:`.sage-mem/autodream/runs/<runId>/{manifest.json,declaration.md}`
23
+ * - 回滚点:`.sage-mem/autodream/snapshots/<id>/`(旧版 `.sage-mem/snapshots/` 只读兼容)
24
+ * - 回滚留痕:`.sage-mem/autodream/rollbacks.json`
25
+ * - 人读报告:`memory/autodream/<runId>.md`(旧版 `memory/dream/` 只读兼容)
26
+ *
27
+ * 手改配置文件也方便——它是纯文本,且和人读的东西放在一起。
28
+ *
29
+ * ── 拆分说明(v0.8.0)─────────────────────────────────────────────
30
+ * 本类的方法体按关注点搬到了 `lib/autodream/` 下:配置与状态 → `settings.js`、
31
+ * 门控与锁 → `gate.js`、回滚点 → `snapshot.js`、LLM 循环 → `llm-loop.js`、
32
+ * 声明与报告 → `report.js`、回滚 → `rollback.js`、通用工具 → `util.js` / `config.js`。
33
+ * 这里保留**同名同签名**的方法,以 `.call(this, …)` 转发 —— 纯搬移、零行为变更。
34
+ */
35
+
36
+ import { auditMemoryDir } from '../autodream-audit.js'
37
+ import { autodreamToolSchemas, createToolRunner } from '../autodream-tools.js'
38
+ import { CONFIG_VERSION, DEFAULT_CONFIG, DEFAULT_TOKEN_BUDGET, FAILURE_BACKOFF_MS, FEATURE_DIR, LEGACY_SNAPSHOT_DIR, LEGACY_STATE_FILE, SCHEDULE_INTERVAL_MS, STARTUP_CHECK_DELAY_MS, STATE_DIR } from './config.js'
39
+ import { countSessionsSince, newRunId, stampHuman } from './util.js'
40
+ import { flushAccess, pickArchiveCandidates, readAccessLedger } from '../memory/access.js'
41
+ import { archiveMemory } from '../memory/archive.js'
42
+ import { scanMemoryFiles } from '../memory/scan.js'
43
+ import { dirname, join } from 'node:path'
44
+
45
+ import * as gateMod from './gate.js'
46
+ import * as llmloopMod from './llm-loop.js'
47
+ import * as reportMod from './report.js'
48
+ import * as rollbackMod from './rollback.js'
49
+ import * as settingsMod from './settings.js'
50
+ import * as snapshotMod from './snapshot.js'
51
+
52
+ export class AutodreamEngine {
53
+ /**
54
+ * @param {object} ctx — 插件上下文(Cordis)
55
+ * @param {{memoryDir:string, sessionsRoot:string, version?:string}} opts
56
+ */
57
+ constructor(ctx, opts) {
58
+ this.ctx = ctx
59
+ this.memoryDir = opts.memoryDir
60
+ this.sessionsRoot = opts.sessionsRoot
61
+ this.version = opts.version
62
+ /** 使用者自己声明的受保护文件名(`<状态根>/reserved.json`)——注入 prompt,绝不写死。 */
63
+ this.reservedFiles = Array.isArray(opts.reservedFiles) ? opts.reservedFiles : []
64
+ this.stateRoot = join(dirname(this.memoryDir), STATE_DIR)
65
+ this.home = join(this.stateRoot, FEATURE_DIR)
66
+ this.configPath = join(this.stateRoot, 'autodream.json')
67
+ this.legacyConfigPath = join(this.stateRoot, LEGACY_STATE_FILE)
68
+ this.lockPath = join(this.home, 'autodream.lock')
69
+ /** 改名过渡期:旧版进程可能还攥着这把锁。两把互不相识的锁 = 两个进程同时改 memory。 */
70
+ this.legacyLockPath = join(this.stateRoot, 'dream.lock')
71
+ this.snapshotRoot = join(this.home, 'snapshots')
72
+ this.legacySnapshotRoot = join(this.stateRoot, LEGACY_SNAPSHOT_DIR)
73
+ this.runsRoot = join(this.home, 'runs')
74
+ this.rollbacksPath = join(this.home, 'rollbacks.json')
75
+
76
+ this.running = false
77
+ this.phase = ''
78
+ this.startedAt = 0
79
+ this.runId = ''
80
+ this.stepLog = []
81
+ this.lastScanAt = 0
82
+ this.state = { config: { ...DEFAULT_CONFIG }, version: CONFIG_VERSION, lastRunAt: 0, lastResult: null, retryAfter: 0 }
83
+ this.disposers = []
84
+ this._configLoaded = false
85
+ }
86
+
87
+ /** 服务解析:优先 ctx.get(),回落直接属性(不同 Cordis 版本两种都在用)。 */
88
+ svc(name) {
89
+ const viaGet = typeof this.ctx?.get === 'function' ? this.ctx.get(name) : undefined
90
+ return viaGet ?? this.ctx?.[name]
91
+ }
92
+
93
+ log(level, msg) {
94
+ const line = `sage-mem autodream: ${msg}`
95
+ const logger = this.ctx?.logger
96
+ if (level === 'error' && logger?.error) logger.error(line)
97
+ else if (logger?.warn) logger.warn(line)
98
+ else console.warn(line)
99
+ }
100
+
101
+ // ────────────────────────────── 配置与状态 ──────────────────────────────
102
+
103
+ // ── 配置与状态:实现在 lib/autodream/settings.js ──
104
+ async load() { return settingsMod.load.call(this) }
105
+ async persist() { return settingsMod.persist.call(this) }
106
+ async getConfig() { return settingsMod.getConfig.call(this) }
107
+ async setConfig(patch) { return settingsMod.setConfig.call(this, patch) }
108
+ defaultSelection() { return settingsMod.defaultSelection.call(this) }
109
+ resolveRoute(config) { return settingsMod.resolveRoute.call(this, config) }
110
+ async listModels() { return settingsMod.listModels.call(this) }
111
+ async checkRouteAvailable(provider, model) { return settingsMod.checkRouteAvailable.call(this, provider, model) }
112
+ async status() { return settingsMod.status.call(this) }
113
+
114
+ // ── 门控与锁:实现在 lib/autodream/gate.js ──
115
+ async gate() { return gateMod.gate.call(this) }
116
+ async acquireLock() { return gateMod.acquireLock.call(this) }
117
+ async releaseLock() { return gateMod.releaseLock.call(this) }
118
+
119
+ // ── 回滚点(快照):实现在 lib/autodream/snapshot.js ──
120
+ async snapshot(id, kind = 'run') { return snapshotMod.snapshot.call(this, id, kind) }
121
+ async pruneSnapshots() { return snapshotMod.pruneSnapshots.call(this) }
122
+ async resolveSnapshotDir(id) { return snapshotMod.resolveSnapshotDir.call(this, id) }
123
+ async listSnapshots() { return snapshotMod.listSnapshots.call(this) }
124
+
125
+ // ── LLM 循环:实现在 lib/autodream/llm-loop.js ──
126
+ async streamOnce(llm, req) { return llmloopMod.streamOnce.call(this, llm, req) }
127
+ async agentLoop(cfg, runOpts) { return llmloopMod.agentLoop.call(this, cfg, runOpts) }
128
+
129
+ // ── 整理声明与报告:实现在 lib/autodream/report.js ──
130
+ async writeDeclaration(payload) { return reportMod.writeDeclaration.call(this, payload) }
131
+ renderDeclaration(m, headingLevel = 1) { return reportMod.renderDeclaration.call(this, m, headingLevel) }
132
+ async listRuns(limit = 20) { return reportMod.listRuns.call(this, limit) }
133
+ async readDeclaration(runId) { return reportMod.readDeclaration.call(this, runId) }
134
+ async writeReport(payload) { return reportMod.writeReport.call(this, payload) }
135
+ async listReports(limit = 30) { return reportMod.listReports.call(this, limit) }
136
+ async readReport(name) { return reportMod.readReport.call(this, name) }
137
+
138
+ // ── 回滚:实现在 lib/autodream/rollback.js ──
139
+ async listRollbacks(limit = 20) { return rollbackMod.listRollbacks.call(this, limit) }
140
+ async appendRollback(entry) { return rollbackMod.appendRollback.call(this, entry) }
141
+ async markRunRolledBack(runId, at) { return rollbackMod.markRunRolledBack.call(this, runId, at) }
142
+ async rollback(opts = {}) { return rollbackMod.rollback.call(this, opts) }
143
+ /**
144
+ * 跑一次 autodream。
145
+ * @param {{reason?:string, force?:boolean}} opts — force 跳过门控(手动触发时用)
146
+ */
147
+ async run(opts = {}) {
148
+ const reason = opts.reason ?? '手动触发'
149
+ if (this.running) return { ok: false, error: '已有一趟 autodream 在跑' }
150
+
151
+ await this.load()
152
+ const cfg = this.state.config
153
+ // 台账先落盘:这趟整理看到的「上次被注入」必须是新的,否则阈值按旧数据算。
154
+ // flushAccess 自己吞掉失败 —— 台账写不进去不该拦住一趟整理。
155
+ // 台账先落盘:这趟整理看到的「上次被注入」必须是新的,否则阈值按旧数据算。
156
+ // flushAccess 自己吞掉失败 —— 台账写不进去不该拦住一趟整理。
157
+ // ⚠️ 必须传 this.memoryDir:台账的键要跟**这个**记忆目录对表(引擎的目录可以是任意路径,
158
+ // 拿模块常量会把别的目录的键当成死的清掉)。
159
+ await flushAccess(this.memoryDir)
160
+ const startedAt = Date.now()
161
+ const runId = newRunId(startedAt)
162
+
163
+ let hoursSince = null
164
+ let sessionCount = 0
165
+ if (opts.gateResult) {
166
+ // 自动触发路径:门控在 tick 里**已经跑过一遍**,这里绝不能再跑第二遍 ——
167
+ // `gate()` 内部有 10 分钟扫描节流,第二次调用必然被刚设下的节流挡回来
168
+ // (「扫描节流中(0s 前刚扫过)」),于是自动整理一次都跑不成;而 tick 若不查
169
+ // run 的返回值,连一行错误日志都没有。
170
+ hoursSince = Number.isFinite(opts.gateResult.hoursSince) ? opts.gateResult.hoursSince : null
171
+ sessionCount = opts.gateResult.sessionCount ?? 0
172
+ } else if (!opts.force) {
173
+ const g = await this.gate()
174
+ if (!g.ok) return { ok: false, error: `门控未通过:${g.reason}` }
175
+ hoursSince = Number.isFinite(g.hoursSince) ? g.hoursSince : null
176
+ sessionCount = g.sessionCount ?? 0
177
+ } else {
178
+ sessionCount = await countSessionsSince(this.sessionsRoot, this.state.lastRunAt || 0)
179
+ hoursSince = this.state.lastRunAt ? (startedAt - this.state.lastRunAt) / 3600000 : null
180
+ }
181
+
182
+ if (!(await this.acquireLock())) return { ok: false, error: '拿不到锁(另一趟 autodream 正在跑?)' }
183
+
184
+ this.running = true
185
+ this.startedAt = startedAt
186
+ this.runId = runId
187
+ this.stepLog = []
188
+ this.phase = '准备中'
189
+
190
+ const route = this.resolveRoute(cfg)
191
+ let snapshot = null
192
+ let auditBefore = null
193
+ /** 提到 try 外面:失败路径要拿它问「已经写进去哪几条」。 */
194
+ let runner = null
195
+ /**
196
+ * 还没开始任何模型调用 / 写入 —— 也就是「开工前的确定性失败」(路线不存在、配置半填、
197
+ * 快照不完整、审计跑不动)。
198
+ *
199
+ * 这类失败**不该吃失败退避**:退避是为了别让偶发故障把门控刷成高频重试,而配置错误
200
+ * 是人去改配置才能好的——用户改完却还要等一小时才生效,是最没道理的一种等待。
201
+ */
202
+ let preflight = true
203
+ try {
204
+ // 路线在开工前就判死:跑到一半才发现模型不存在,等于白烧一整轮上下文。
205
+ if (route.source === 'none') throw new Error(`模型路线不可用:${route.error}`)
206
+ if (route.source === 'config') {
207
+ const chk = await this.checkRouteAvailable(route.provider, route.model)
208
+ if (!chk.ok) {
209
+ throw new Error(
210
+ `配置里指定的模型路线不可用:${chk.reason}。` +
211
+ '请到「自动做梦 → 高级设置 → 模型」重新选一条,或清空两项改为跟随默认模型。',
212
+ )
213
+ }
214
+ if (!chk.checked) this.log('warn', `模型路线存在性未完全校验:${chk.reason}`)
215
+ }
216
+
217
+ if (cfg.apply) {
218
+ this.phase = '运行前快照(回滚点)'
219
+ snapshot = await this.snapshot(runId, 'run')
220
+ // 快照不完整就不许开工。残缺的回滚点比没有回滚点**更危险**——它让人以为
221
+ // 「退得回去」。在任何写操作之前中止,是这条路径上唯一诚实的做法。
222
+ if (snapshot.failed.length) {
223
+ const shown = snapshot.failed.slice(0, 3).join('、')
224
+ throw new Error(
225
+ `运行前快照有 ${snapshot.failed.length} 个文件没存下来(${shown}${snapshot.failed.length > 3 ? '…' : ''}),` +
226
+ '回滚点不完整,本次整理已中止(未改动任何文件)。请检查磁盘空间与文件占用后重试。',
227
+ )
228
+ }
229
+ }
230
+
231
+ this.phase = '运行前审计'
232
+ auditBefore = await auditMemoryDir(this.memoryDir)
233
+
234
+ const withSessions = cfg.source === 'memory+sessions'
235
+ runner = createToolRunner({
236
+ memoryDir: this.memoryDir,
237
+ sessionsRoot: this.sessionsRoot,
238
+ apply: !!cfg.apply,
239
+ withSessions,
240
+ })
241
+ const toolSchemas = autodreamToolSchemas({ apply: !!cfg.apply, withSessions })
242
+
243
+ // 从这里往下就会真的动模型和磁盘了 —— 之后的失败按「运行期故障」处理(吃退避)。
244
+ preflight = false
245
+ this.phase = '运行中'
246
+ const result = await this.agentLoop(cfg, {
247
+ provider: route.provider,
248
+ model: route.model,
249
+ toolSchemas,
250
+ runner,
251
+ maxSteps: cfg.maxSteps,
252
+ tokenBudget: DEFAULT_TOKEN_BUDGET,
253
+ hoursSince: hoursSince ?? 0,
254
+ sessionCount,
255
+ })
256
+
257
+ // 归档策略:在运行末尾、**审计之前**做 —— 归档过的东西要出现在「运行后」的审计里
258
+ // (指向它的 [[链接]] 会从「断链」变成「指向已归档」的提示)。
259
+ this.phase = '归档候选'
260
+ const policy = await this.archivePolicyPass(cfg)
261
+
262
+ this.phase = '运行后审计'
263
+ const auditAfter = await auditMemoryDir(this.memoryDir)
264
+
265
+ const endedAt = Date.now()
266
+ this.phase = '整理声明'
267
+ const declaration = await this.writeDeclaration({
268
+ runId,
269
+ startedAt,
270
+ endedAt,
271
+ reason,
272
+ apply: !!cfg.apply,
273
+ source: cfg.source,
274
+ provider: route.provider,
275
+ model: route.model,
276
+ fromDefault: route.fromDefault,
277
+ hoursSince,
278
+ sessionCount,
279
+ tokensIn: result.tokensIn,
280
+ tokensOut: result.tokensOut,
281
+ snapshot,
282
+ changes: result.changes,
283
+ warnings: result.warnings,
284
+ notes: result.notes,
285
+ auditBefore,
286
+ auditAfter,
287
+ finalText: result.finalText,
288
+ archiveMode: policy.mode,
289
+ archiveRequested: policy.requested,
290
+ archiveCandidates: policy.candidates,
291
+ archivedByPolicy: policy.archived,
292
+ archiveDegraded: policy.degraded,
293
+ })
294
+
295
+ // 报告里的「改动的文件」要把策略归档也算上 —— 它们确实被移走了,漏掉就是报告在说谎。
296
+ const policyTouched = policy.archived.filter((a) => a.ok).map((a) => `${a.file} → archive/`)
297
+ const report = await this.writeReport({
298
+ runId,
299
+ startedAt,
300
+ endedAt,
301
+ reason,
302
+ apply: !!cfg.apply,
303
+ source: cfg.source,
304
+ provider: route.provider,
305
+ model: route.model,
306
+ fromDefault: route.fromDefault,
307
+ hoursSince,
308
+ sessionCount,
309
+ snapshot,
310
+ touched: [...result.touched, ...policyTouched],
311
+ auditBefore,
312
+ auditAfter,
313
+ finalText: result.finalText,
314
+ transcript: result.transcript,
315
+ tokensIn: result.tokensIn,
316
+ tokensOut: result.tokensOut,
317
+ declarationMarkdown: declaration.markdown,
318
+ archiveMode: policy.mode,
319
+ archiveRequested: policy.requested,
320
+ archiveCandidates: policy.candidates,
321
+ archivedByPolicy: policy.archived,
322
+ archiveDegraded: policy.degraded,
323
+ })
324
+
325
+ this.state.lastRunAt = Date.now()
326
+ this.state.retryAfter = 0
327
+ this.state.lastResult = {
328
+ at: this.state.lastRunAt,
329
+ atHuman: stampHuman(this.state.lastRunAt),
330
+ ok: true,
331
+ runId,
332
+ apply: !!cfg.apply,
333
+ touched: result.touched,
334
+ changeCount: result.changes.length,
335
+ problemsBefore: auditBefore.problems,
336
+ problemsAfter: auditAfter.problems,
337
+ tokensIn: result.tokensIn,
338
+ tokensOut: result.tokensOut,
339
+ report: report.name,
340
+ snapshotId: snapshot?.id ?? null,
341
+ }
342
+ await this.persist()
343
+ this.phase = ''
344
+ return {
345
+ ok: true,
346
+ runId,
347
+ report: report.name,
348
+ touched: result.touched,
349
+ changeCount: result.changes.length,
350
+ snapshotId: snapshot?.id ?? null,
351
+ auditBefore,
352
+ auditAfter,
353
+ }
354
+ } catch (err) {
355
+ const message = err?.message ?? String(err)
356
+ this.log('warn', `运行失败:${message}`)
357
+
358
+ // **中途失败也要交代。** 已经落盘的改动不会自己消失,而「有改动、无声明」是最坏的
359
+ // 一种状态:人既不知道发生了什么,也不知道该不该回滚。回滚点此时仍然有效
360
+ // (它在任何写操作之前就建好了),声明把「中断前已经改了什么」钉住。
361
+ const partial = runner ? runner.changes() : []
362
+ if (partial.length) {
363
+ try {
364
+ await this.writeDeclaration({
365
+ runId,
366
+ startedAt,
367
+ endedAt: Date.now(),
368
+ reason: `${reason}(运行中断)`,
369
+ apply: !!cfg.apply,
370
+ source: cfg.source,
371
+ provider: route.provider,
372
+ model: route.model,
373
+ fromDefault: route.fromDefault,
374
+ hoursSince,
375
+ sessionCount,
376
+ tokensIn: 0,
377
+ tokensOut: 0,
378
+ snapshot,
379
+ changes: partial,
380
+ warnings: [...(runner?.warnings() ?? []), `运行在写完 ${partial.length} 条改动之后中断:${message}`],
381
+ notes: runner?.notes?.() ?? [],
382
+ auditBefore,
383
+ auditAfter: null,
384
+ finalText: `本次运行中断。以上是中断前**已经落盘**的改动;中断原因:${message}`,
385
+ })
386
+ } catch (e2) {
387
+ // 失败路径上的再失败不能盖住原始错误——只记一行,然后照原样把原始错误报出去。
388
+ this.log('warn', `中断声明写入失败:${e2?.message ?? e2}`)
389
+ }
390
+ }
391
+
392
+ this.state.retryAfter = preflight ? 0 : Date.now() + FAILURE_BACKOFF_MS
393
+ this.state.lastResult = {
394
+ at: Date.now(),
395
+ atHuman: stampHuman(Date.now()),
396
+ ok: false,
397
+ runId,
398
+ apply: !!cfg.apply,
399
+ error: message,
400
+ // preflight 失败会明确标出来:它和「模型跑到一半挂了」是两种病,退避策略也不同。
401
+ preflight,
402
+ changeCount: partial.length,
403
+ snapshotId: snapshot?.id ?? null,
404
+ tokensIn: 0,
405
+ tokensOut: 0,
406
+ }
407
+ await this.persist().catch(() => {})
408
+ this.phase = ''
409
+ return { ok: false, error: message }
410
+ } finally {
411
+ this.running = false
412
+ this.runId = ''
413
+ await this.releaseLock()
414
+ }
415
+ }
416
+
417
+ // ────────────────────────────── 归档策略 ──────────────────────────────
418
+
419
+ /**
420
+ * 自动归档一趟(**确定性,不调模型**)。
421
+ *
422
+ * 三档:`off` 什么也不做;`report` 只把候选交回上层写进报告(一个文件都不动);
423
+ * `auto` 在运行末尾真的把候选移进 `archive/`(只移不删)。
424
+ *
425
+ * ⚠️ **`apply: false` 是用户可见的绝对承诺**(「只读模式……不可能改到任何文件」):
426
+ * `auto` 遇到只读一律**降级为报告**并在报告里明说,绝不绕过那条承诺。
427
+ *
428
+ * 归档动作调 `memory/archive.js` 的共享实现 —— 与手动 remote、`archive_memory` 工具
429
+ * 是同一份代码,三条入口不可能再漂移出两种行为。
430
+ */
431
+ async archivePolicyPass(cfg) {
432
+ const requested = cfg.autoArchive === 'auto' ? 'auto' : cfg.autoArchive === 'off' ? 'off' : 'report'
433
+ if (requested === 'off') return { mode: 'off', requested, candidates: [], archived: [], degraded: '' }
434
+ const [ledger, memoryFiles] = await Promise.all([readAccessLedger(), scanMemoryFiles(this.ctx, '')])
435
+ const candidates = pickArchiveCandidates(memoryFiles, ledger, {
436
+ thresholds: {
437
+ project: cfg.archiveAfterDaysProject,
438
+ reference: cfg.archiveAfterDaysReference,
439
+ user: cfg.archiveAfterDaysUser,
440
+ },
441
+ })
442
+ if (requested === 'auto' && !cfg.apply) {
443
+ return {
444
+ mode: 'report',
445
+ requested,
446
+ candidates,
447
+ archived: [],
448
+ degraded: 'autoArchive 已设为 auto,但自动做梦处于只读模式,本轮只出报告(一个文件都没动)',
449
+ }
450
+ }
451
+ if (requested !== 'auto' || candidates.length === 0) {
452
+ return { mode: requested, requested, candidates, archived: [], degraded: '' }
453
+ }
454
+ const archived = []
455
+ for (const c of candidates) {
456
+ const reason = `自动归档:${c.type} 类,超过 ${c.limit} 天未被注入`
457
+ const res = await archiveMemory(this.memoryDir, c.file, reason)
458
+ archived.push({
459
+ file: c.file,
460
+ ok: res.ok === true,
461
+ message: res.ok ? `已归档 ${res.file}(原因:${res.archivedReason})` : res.error,
462
+ })
463
+ }
464
+ return { mode: 'auto', requested, candidates, archived, degraded: '' }
465
+ }
466
+
467
+ // ────────────────────────────── 调度 ──────────────────────────────
468
+
469
+ /**
470
+ * 挂上自动触发的定时器。
471
+ *
472
+ * 用 `ctx.timer`(Cordis 混合进 context 的定时器助手)而不是裸 setInterval:
473
+ * 它的 dispose 会跟着插件生命周期走,插件卸载时定时器一起消失。
474
+ * timer 服务拿不到时退回全局 setInterval,并在日志里说清楚。
475
+ */
476
+ start() {
477
+ const timer = this.svc('timer')
478
+ const tick = () => {
479
+ this.gate()
480
+ .then((g) => {
481
+ if (!g.ok) return undefined
482
+ this.log('warn', `门控通过,自动开始整理(${g.reason ?? ''} ${g.hoursSince?.toFixed?.(1) ?? '?'}h / ${g.sessionCount} 会话)`)
483
+ // 门控结果**带进去**,别让 run() 再跑一遍 gate()——gate() 有 10 分钟扫描节流,
484
+ // 第二遍必然被刚设下的节流挡回(详见 run() 里那段注释)。
485
+ return this.run({ reason: '自动(门控通过)', gateResult: g })
486
+ })
487
+ .then((r) => {
488
+ // run() 失败是**返回** `{ok:false}` 而不是抛;不看返回值就等于静默失败,
489
+ // 而「自动整理悄悄不工作」正是这个功能最难查的故障形态。
490
+ if (r && r.ok === false) this.log('warn', `自动整理没有跑成:${r.error ?? '未知原因'}`)
491
+ })
492
+ .catch((err) => this.log('warn', `自动触发失败:${err?.message ?? err}`))
493
+ }
494
+
495
+ if (timer?.interval) {
496
+ this.disposers.push(timer.interval(tick, SCHEDULE_INTERVAL_MS))
497
+ } else {
498
+ const id = setInterval(tick, SCHEDULE_INTERVAL_MS)
499
+ if (typeof id.unref === 'function') id.unref()
500
+ this.disposers.push(() => clearInterval(id))
501
+ }
502
+
503
+ if (timer?.timeout) {
504
+ this.disposers.push(timer.timeout(tick, STARTUP_CHECK_DELAY_MS))
505
+ } else {
506
+ const id = setTimeout(tick, STARTUP_CHECK_DELAY_MS)
507
+ if (typeof id.unref === 'function') id.unref()
508
+ this.disposers.push(() => clearTimeout(id))
509
+ }
510
+ }
511
+
512
+ /** 插件卸载:停掉所有定时器与锁。 */
513
+ dispose() {
514
+ for (const d of this.disposers) {
515
+ try {
516
+ d()
517
+ } catch {
518
+ /* 忽略:卸载路径上的异常不该再冒出来 */
519
+ }
520
+ }
521
+ this.disposers = []
522
+ this.releaseLock().catch(() => {})
523
+ }
524
+ }