sage-mem 0.7.2 → 0.8.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.
@@ -0,0 +1,70 @@
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
+
27
+ /**
28
+ * 会话门的扫描节流(10 分钟)。
29
+ *
30
+ * 为什么需要它:时间门过了、会话门没过时,状态里的 lastRunAt 不会前进,
31
+ * 于是每一次触发都会重新扫一遍整个 sessions 目录。
32
+ * 节流把「看着像没过」和「真的没过」区分开——这中间的成本差异全是白烧的盘 IO。
33
+ */
34
+ export const SESSION_SCAN_INTERVAL_MS = 10 * 60 * 1000
35
+
36
+ /** 自动模式的检查间隔。门控本身很便宜(有节流),所以这个频率是安全的。 */
37
+ export const SCHEDULE_INTERVAL_MS = 30 * 60 * 1000
38
+
39
+ /** 启动后第一次自动检查的延迟——避开 DSH 启动时的那阵忙碌。 */
40
+ export const STARTUP_CHECK_DELAY_MS = 90 * 1000
41
+
42
+ /** 锁的失效时间:超过这个时长还挂着的锁,认定是上次进程崩了留下的。 */
43
+ export const LOCK_STALE_MS = 2 * 60 * 60 * 1000
44
+
45
+ /** 失败后的退避:别让一个每次都失败的任务把门控刷成高频重试。 */
46
+ export const FAILURE_BACKOFF_MS = 60 * 60 * 1000
47
+
48
+ /** 报告目录名(相对 memory 根)。放在子目录里,所以不会被 sage-mem 自己扫成记忆。 */
49
+ export const REPORT_DIR = 'autodream'
50
+
51
+ /** 改名前的报告目录。只读兼容:老报告还能在面板里翻到,但不再往里写。 */
52
+ export const LEGACY_REPORT_DIR = 'dream'
53
+
54
+ /** 状态根目录名(相对 memory 根的上一层)。 */
55
+ export const STATE_DIR = '.sage-mem'
56
+
57
+ /** 本功能在状态根下的私有目录(运行记录、回滚点、锁、留痕)。 */
58
+ export const FEATURE_DIR = 'autodream'
59
+
60
+ /** 改名前的状态文件名(`<stateRoot>/dream.json`),只在迁移时读一次。 */
61
+ export const LEGACY_STATE_FILE = 'dream.json'
62
+
63
+ /** 改名前的快照根(`<stateRoot>/snapshots/`),只读兼容。 */
64
+ export const LEGACY_SNAPSHOT_DIR = 'snapshots'
65
+
66
+ /** 「回滚前保护快照」单独保留的份数——它不该被普通运行的快照轮转挤掉。 */
67
+ export const PRE_ROLLBACK_KEEP = 3
68
+
69
+ /** 一次 autodream 允许累计的输入 token 上限(软保护,超了就停)。 */
70
+ export const DEFAULT_TOKEN_BUDGET = 600000
@@ -0,0 +1,447 @@
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 { dirname, join } from 'node:path'
41
+
42
+ import * as gateMod from './gate.js'
43
+ import * as llmloopMod from './llm-loop.js'
44
+ import * as reportMod from './report.js'
45
+ import * as rollbackMod from './rollback.js'
46
+ import * as settingsMod from './settings.js'
47
+ import * as snapshotMod from './snapshot.js'
48
+
49
+ export class AutodreamEngine {
50
+ /**
51
+ * @param {object} ctx — 插件上下文(Cordis)
52
+ * @param {{memoryDir:string, sessionsRoot:string, version?:string}} opts
53
+ */
54
+ constructor(ctx, opts) {
55
+ this.ctx = ctx
56
+ this.memoryDir = opts.memoryDir
57
+ this.sessionsRoot = opts.sessionsRoot
58
+ this.version = opts.version
59
+ /** 使用者自己声明的受保护文件名(`<状态根>/reserved.json`)——注入 prompt,绝不写死。 */
60
+ this.reservedFiles = Array.isArray(opts.reservedFiles) ? opts.reservedFiles : []
61
+ this.stateRoot = join(dirname(this.memoryDir), STATE_DIR)
62
+ this.home = join(this.stateRoot, FEATURE_DIR)
63
+ this.configPath = join(this.stateRoot, 'autodream.json')
64
+ this.legacyConfigPath = join(this.stateRoot, LEGACY_STATE_FILE)
65
+ this.lockPath = join(this.home, 'autodream.lock')
66
+ /** 改名过渡期:旧版进程可能还攥着这把锁。两把互不相识的锁 = 两个进程同时改 memory。 */
67
+ this.legacyLockPath = join(this.stateRoot, 'dream.lock')
68
+ this.snapshotRoot = join(this.home, 'snapshots')
69
+ this.legacySnapshotRoot = join(this.stateRoot, LEGACY_SNAPSHOT_DIR)
70
+ this.runsRoot = join(this.home, 'runs')
71
+ this.rollbacksPath = join(this.home, 'rollbacks.json')
72
+
73
+ this.running = false
74
+ this.phase = ''
75
+ this.startedAt = 0
76
+ this.runId = ''
77
+ this.stepLog = []
78
+ this.lastScanAt = 0
79
+ this.state = { config: { ...DEFAULT_CONFIG }, version: CONFIG_VERSION, lastRunAt: 0, lastResult: null, retryAfter: 0 }
80
+ this.disposers = []
81
+ this._configLoaded = false
82
+ }
83
+
84
+ /** 服务解析:优先 ctx.get(),回落直接属性(不同 Cordis 版本两种都在用)。 */
85
+ svc(name) {
86
+ const viaGet = typeof this.ctx?.get === 'function' ? this.ctx.get(name) : undefined
87
+ return viaGet ?? this.ctx?.[name]
88
+ }
89
+
90
+ log(level, msg) {
91
+ const line = `sage-mem autodream: ${msg}`
92
+ const logger = this.ctx?.logger
93
+ if (level === 'error' && logger?.error) logger.error(line)
94
+ else if (logger?.warn) logger.warn(line)
95
+ else console.warn(line)
96
+ }
97
+
98
+ // ────────────────────────────── 配置与状态 ──────────────────────────────
99
+
100
+ // ── 配置与状态:实现在 lib/autodream/settings.js ──
101
+ async load() { return settingsMod.load.call(this) }
102
+ async persist() { return settingsMod.persist.call(this) }
103
+ async getConfig() { return settingsMod.getConfig.call(this) }
104
+ async setConfig(patch) { return settingsMod.setConfig.call(this, patch) }
105
+ defaultSelection() { return settingsMod.defaultSelection.call(this) }
106
+ resolveRoute(config) { return settingsMod.resolveRoute.call(this, config) }
107
+ async listModels() { return settingsMod.listModels.call(this) }
108
+ async checkRouteAvailable(provider, model) { return settingsMod.checkRouteAvailable.call(this, provider, model) }
109
+ async status() { return settingsMod.status.call(this) }
110
+
111
+ // ── 门控与锁:实现在 lib/autodream/gate.js ──
112
+ async gate() { return gateMod.gate.call(this) }
113
+ async acquireLock() { return gateMod.acquireLock.call(this) }
114
+ async releaseLock() { return gateMod.releaseLock.call(this) }
115
+
116
+ // ── 回滚点(快照):实现在 lib/autodream/snapshot.js ──
117
+ async snapshot(id, kind = 'run') { return snapshotMod.snapshot.call(this, id, kind) }
118
+ async pruneSnapshots() { return snapshotMod.pruneSnapshots.call(this) }
119
+ async resolveSnapshotDir(id) { return snapshotMod.resolveSnapshotDir.call(this, id) }
120
+ async listSnapshots() { return snapshotMod.listSnapshots.call(this) }
121
+
122
+ // ── LLM 循环:实现在 lib/autodream/llm-loop.js ──
123
+ async streamOnce(llm, req) { return llmloopMod.streamOnce.call(this, llm, req) }
124
+ async agentLoop(cfg, runOpts) { return llmloopMod.agentLoop.call(this, cfg, runOpts) }
125
+
126
+ // ── 整理声明与报告:实现在 lib/autodream/report.js ──
127
+ async writeDeclaration(payload) { return reportMod.writeDeclaration.call(this, payload) }
128
+ renderDeclaration(m, headingLevel = 1) { return reportMod.renderDeclaration.call(this, m, headingLevel) }
129
+ async listRuns(limit = 20) { return reportMod.listRuns.call(this, limit) }
130
+ async readDeclaration(runId) { return reportMod.readDeclaration.call(this, runId) }
131
+ async writeReport(payload) { return reportMod.writeReport.call(this, payload) }
132
+ async listReports(limit = 30) { return reportMod.listReports.call(this, limit) }
133
+ async readReport(name) { return reportMod.readReport.call(this, name) }
134
+
135
+ // ── 回滚:实现在 lib/autodream/rollback.js ──
136
+ async listRollbacks(limit = 20) { return rollbackMod.listRollbacks.call(this, limit) }
137
+ async appendRollback(entry) { return rollbackMod.appendRollback.call(this, entry) }
138
+ async markRunRolledBack(runId, at) { return rollbackMod.markRunRolledBack.call(this, runId, at) }
139
+ async rollback(opts = {}) { return rollbackMod.rollback.call(this, opts) }
140
+ /**
141
+ * 跑一次 autodream。
142
+ * @param {{reason?:string, force?:boolean}} opts — force 跳过门控(手动触发时用)
143
+ */
144
+ async run(opts = {}) {
145
+ const reason = opts.reason ?? '手动触发'
146
+ if (this.running) return { ok: false, error: '已有一趟 autodream 在跑' }
147
+
148
+ await this.load()
149
+ const cfg = this.state.config
150
+ const startedAt = Date.now()
151
+ const runId = newRunId(startedAt)
152
+
153
+ let hoursSince = null
154
+ let sessionCount = 0
155
+ if (opts.gateResult) {
156
+ // 自动触发路径:门控在 tick 里**已经跑过一遍**,这里绝不能再跑第二遍 ——
157
+ // `gate()` 内部有 10 分钟扫描节流,第二次调用必然被刚设下的节流挡回来
158
+ // (「扫描节流中(0s 前刚扫过)」),于是自动整理一次都跑不成;而 tick 若不查
159
+ // run 的返回值,连一行错误日志都没有。
160
+ hoursSince = Number.isFinite(opts.gateResult.hoursSince) ? opts.gateResult.hoursSince : null
161
+ sessionCount = opts.gateResult.sessionCount ?? 0
162
+ } else if (!opts.force) {
163
+ const g = await this.gate()
164
+ if (!g.ok) return { ok: false, error: `门控未通过:${g.reason}` }
165
+ hoursSince = Number.isFinite(g.hoursSince) ? g.hoursSince : null
166
+ sessionCount = g.sessionCount ?? 0
167
+ } else {
168
+ sessionCount = await countSessionsSince(this.sessionsRoot, this.state.lastRunAt || 0)
169
+ hoursSince = this.state.lastRunAt ? (startedAt - this.state.lastRunAt) / 3600000 : null
170
+ }
171
+
172
+ if (!(await this.acquireLock())) return { ok: false, error: '拿不到锁(另一趟 autodream 正在跑?)' }
173
+
174
+ this.running = true
175
+ this.startedAt = startedAt
176
+ this.runId = runId
177
+ this.stepLog = []
178
+ this.phase = '准备中'
179
+
180
+ const route = this.resolveRoute(cfg)
181
+ let snapshot = null
182
+ let auditBefore = null
183
+ /** 提到 try 外面:失败路径要拿它问「已经写进去哪几条」。 */
184
+ let runner = null
185
+ /**
186
+ * 还没开始任何模型调用 / 写入 —— 也就是「开工前的确定性失败」(路线不存在、配置半填、
187
+ * 快照不完整、审计跑不动)。
188
+ *
189
+ * 这类失败**不该吃失败退避**:退避是为了别让偶发故障把门控刷成高频重试,而配置错误
190
+ * 是人去改配置才能好的——用户改完却还要等一小时才生效,是最没道理的一种等待。
191
+ */
192
+ let preflight = true
193
+ try {
194
+ // 路线在开工前就判死:跑到一半才发现模型不存在,等于白烧一整轮上下文。
195
+ if (route.source === 'none') throw new Error(`模型路线不可用:${route.error}`)
196
+ if (route.source === 'config') {
197
+ const chk = await this.checkRouteAvailable(route.provider, route.model)
198
+ if (!chk.ok) {
199
+ throw new Error(
200
+ `配置里指定的模型路线不可用:${chk.reason}。` +
201
+ '请到「自动做梦 → 高级设置 → 模型」重新选一条,或清空两项改为跟随默认模型。',
202
+ )
203
+ }
204
+ if (!chk.checked) this.log('warn', `模型路线存在性未完全校验:${chk.reason}`)
205
+ }
206
+
207
+ if (cfg.apply) {
208
+ this.phase = '运行前快照(回滚点)'
209
+ snapshot = await this.snapshot(runId, 'run')
210
+ // 快照不完整就不许开工。残缺的回滚点比没有回滚点**更危险**——它让人以为
211
+ // 「退得回去」。在任何写操作之前中止,是这条路径上唯一诚实的做法。
212
+ if (snapshot.failed.length) {
213
+ const shown = snapshot.failed.slice(0, 3).join('、')
214
+ throw new Error(
215
+ `运行前快照有 ${snapshot.failed.length} 个文件没存下来(${shown}${snapshot.failed.length > 3 ? '…' : ''}),` +
216
+ '回滚点不完整,本次整理已中止(未改动任何文件)。请检查磁盘空间与文件占用后重试。',
217
+ )
218
+ }
219
+ }
220
+
221
+ this.phase = '运行前审计'
222
+ auditBefore = await auditMemoryDir(this.memoryDir)
223
+
224
+ const withSessions = cfg.source === 'memory+sessions'
225
+ runner = createToolRunner({
226
+ memoryDir: this.memoryDir,
227
+ sessionsRoot: this.sessionsRoot,
228
+ apply: !!cfg.apply,
229
+ withSessions,
230
+ })
231
+ const toolSchemas = autodreamToolSchemas({ apply: !!cfg.apply, withSessions })
232
+
233
+ // 从这里往下就会真的动模型和磁盘了 —— 之后的失败按「运行期故障」处理(吃退避)。
234
+ preflight = false
235
+ this.phase = '运行中'
236
+ const result = await this.agentLoop(cfg, {
237
+ provider: route.provider,
238
+ model: route.model,
239
+ toolSchemas,
240
+ runner,
241
+ maxSteps: cfg.maxSteps,
242
+ tokenBudget: DEFAULT_TOKEN_BUDGET,
243
+ hoursSince: hoursSince ?? 0,
244
+ sessionCount,
245
+ })
246
+
247
+ this.phase = '运行后审计'
248
+ const auditAfter = await auditMemoryDir(this.memoryDir)
249
+
250
+ const endedAt = Date.now()
251
+ this.phase = '整理声明'
252
+ const declaration = await this.writeDeclaration({
253
+ runId,
254
+ startedAt,
255
+ endedAt,
256
+ reason,
257
+ apply: !!cfg.apply,
258
+ source: cfg.source,
259
+ provider: route.provider,
260
+ model: route.model,
261
+ fromDefault: route.fromDefault,
262
+ hoursSince,
263
+ sessionCount,
264
+ tokensIn: result.tokensIn,
265
+ tokensOut: result.tokensOut,
266
+ snapshot,
267
+ changes: result.changes,
268
+ warnings: result.warnings,
269
+ notes: result.notes,
270
+ auditBefore,
271
+ auditAfter,
272
+ finalText: result.finalText,
273
+ })
274
+
275
+ const report = await this.writeReport({
276
+ runId,
277
+ startedAt,
278
+ endedAt,
279
+ reason,
280
+ apply: !!cfg.apply,
281
+ source: cfg.source,
282
+ provider: route.provider,
283
+ model: route.model,
284
+ fromDefault: route.fromDefault,
285
+ hoursSince,
286
+ sessionCount,
287
+ snapshot,
288
+ touched: result.touched,
289
+ auditBefore,
290
+ auditAfter,
291
+ finalText: result.finalText,
292
+ transcript: result.transcript,
293
+ tokensIn: result.tokensIn,
294
+ tokensOut: result.tokensOut,
295
+ declarationMarkdown: declaration.markdown,
296
+ })
297
+
298
+ this.state.lastRunAt = Date.now()
299
+ this.state.retryAfter = 0
300
+ this.state.lastResult = {
301
+ at: this.state.lastRunAt,
302
+ atHuman: stampHuman(this.state.lastRunAt),
303
+ ok: true,
304
+ runId,
305
+ apply: !!cfg.apply,
306
+ touched: result.touched,
307
+ changeCount: result.changes.length,
308
+ problemsBefore: auditBefore.problems,
309
+ problemsAfter: auditAfter.problems,
310
+ tokensIn: result.tokensIn,
311
+ tokensOut: result.tokensOut,
312
+ report: report.name,
313
+ snapshotId: snapshot?.id ?? null,
314
+ }
315
+ await this.persist()
316
+ this.phase = ''
317
+ return {
318
+ ok: true,
319
+ runId,
320
+ report: report.name,
321
+ touched: result.touched,
322
+ changeCount: result.changes.length,
323
+ snapshotId: snapshot?.id ?? null,
324
+ auditBefore,
325
+ auditAfter,
326
+ }
327
+ } catch (err) {
328
+ const message = err?.message ?? String(err)
329
+ this.log('warn', `运行失败:${message}`)
330
+
331
+ // **中途失败也要交代。** 已经落盘的改动不会自己消失,而「有改动、无声明」是最坏的
332
+ // 一种状态:人既不知道发生了什么,也不知道该不该回滚。回滚点此时仍然有效
333
+ // (它在任何写操作之前就建好了),声明把「中断前已经改了什么」钉住。
334
+ const partial = runner ? runner.changes() : []
335
+ if (partial.length) {
336
+ try {
337
+ await this.writeDeclaration({
338
+ runId,
339
+ startedAt,
340
+ endedAt: Date.now(),
341
+ reason: `${reason}(运行中断)`,
342
+ apply: !!cfg.apply,
343
+ source: cfg.source,
344
+ provider: route.provider,
345
+ model: route.model,
346
+ fromDefault: route.fromDefault,
347
+ hoursSince,
348
+ sessionCount,
349
+ tokensIn: 0,
350
+ tokensOut: 0,
351
+ snapshot,
352
+ changes: partial,
353
+ warnings: [...(runner?.warnings() ?? []), `运行在写完 ${partial.length} 条改动之后中断:${message}`],
354
+ notes: runner?.notes?.() ?? [],
355
+ auditBefore,
356
+ auditAfter: null,
357
+ finalText: `本次运行中断。以上是中断前**已经落盘**的改动;中断原因:${message}`,
358
+ })
359
+ } catch (e2) {
360
+ // 失败路径上的再失败不能盖住原始错误——只记一行,然后照原样把原始错误报出去。
361
+ this.log('warn', `中断声明写入失败:${e2?.message ?? e2}`)
362
+ }
363
+ }
364
+
365
+ this.state.retryAfter = preflight ? 0 : Date.now() + FAILURE_BACKOFF_MS
366
+ this.state.lastResult = {
367
+ at: Date.now(),
368
+ atHuman: stampHuman(Date.now()),
369
+ ok: false,
370
+ runId,
371
+ apply: !!cfg.apply,
372
+ error: message,
373
+ // preflight 失败会明确标出来:它和「模型跑到一半挂了」是两种病,退避策略也不同。
374
+ preflight,
375
+ changeCount: partial.length,
376
+ snapshotId: snapshot?.id ?? null,
377
+ tokensIn: 0,
378
+ tokensOut: 0,
379
+ }
380
+ await this.persist().catch(() => {})
381
+ this.phase = ''
382
+ return { ok: false, error: message }
383
+ } finally {
384
+ this.running = false
385
+ this.runId = ''
386
+ await this.releaseLock()
387
+ }
388
+ }
389
+
390
+ // ────────────────────────────── 调度 ──────────────────────────────
391
+
392
+ /**
393
+ * 挂上自动触发的定时器。
394
+ *
395
+ * 用 `ctx.timer`(Cordis 混合进 context 的定时器助手)而不是裸 setInterval:
396
+ * 它的 dispose 会跟着插件生命周期走,插件卸载时定时器一起消失。
397
+ * timer 服务拿不到时退回全局 setInterval,并在日志里说清楚。
398
+ */
399
+ start() {
400
+ const timer = this.svc('timer')
401
+ const tick = () => {
402
+ this.gate()
403
+ .then((g) => {
404
+ if (!g.ok) return undefined
405
+ this.log('warn', `门控通过,自动开始整理(${g.reason ?? ''} ${g.hoursSince?.toFixed?.(1) ?? '?'}h / ${g.sessionCount} 会话)`)
406
+ // 门控结果**带进去**,别让 run() 再跑一遍 gate()——gate() 有 10 分钟扫描节流,
407
+ // 第二遍必然被刚设下的节流挡回(详见 run() 里那段注释)。
408
+ return this.run({ reason: '自动(门控通过)', gateResult: g })
409
+ })
410
+ .then((r) => {
411
+ // run() 失败是**返回** `{ok:false}` 而不是抛;不看返回值就等于静默失败,
412
+ // 而「自动整理悄悄不工作」正是这个功能最难查的故障形态。
413
+ if (r && r.ok === false) this.log('warn', `自动整理没有跑成:${r.error ?? '未知原因'}`)
414
+ })
415
+ .catch((err) => this.log('warn', `自动触发失败:${err?.message ?? err}`))
416
+ }
417
+
418
+ if (timer?.interval) {
419
+ this.disposers.push(timer.interval(tick, SCHEDULE_INTERVAL_MS))
420
+ } else {
421
+ const id = setInterval(tick, SCHEDULE_INTERVAL_MS)
422
+ if (typeof id.unref === 'function') id.unref()
423
+ this.disposers.push(() => clearInterval(id))
424
+ }
425
+
426
+ if (timer?.timeout) {
427
+ this.disposers.push(timer.timeout(tick, STARTUP_CHECK_DELAY_MS))
428
+ } else {
429
+ const id = setTimeout(tick, STARTUP_CHECK_DELAY_MS)
430
+ if (typeof id.unref === 'function') id.unref()
431
+ this.disposers.push(() => clearTimeout(id))
432
+ }
433
+ }
434
+
435
+ /** 插件卸载:停掉所有定时器与锁。 */
436
+ dispose() {
437
+ for (const d of this.disposers) {
438
+ try {
439
+ d()
440
+ } catch {
441
+ /* 忽略:卸载路径上的异常不该再冒出来 */
442
+ }
443
+ }
444
+ this.disposers = []
445
+ this.releaseLock().catch(() => {})
446
+ }
447
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * autodream —— 门控(时间 / 会话 / 锁)与锁本身(引擎方法体)。
3
+ *
4
+ * 本文件里的函数都是 `AutodreamEngine` 的方法体:`engine.js` 里保留同名同签名的方法,
5
+ * 用 `xxxMod.name.call(this, …)` 转发进来 —— 所以函数体里的 `this` 就是引擎实例,
6
+ * 与拆分前完全一致,没有引入任何模块级可变状态。
7
+ */
8
+
9
+ import { LOCK_STALE_MS, SESSION_SCAN_INTERVAL_MS } from './config.js'
10
+ import { countSessionsSince } from './util.js'
11
+ import { mkdir, open, rm, stat } from 'node:fs/promises'
12
+
13
+ /**
14
+ * 三级门控(顺序即成本顺序)。
15
+ * @returns {Promise<{ok:boolean, reason?:string, hoursSince?:number, sessionCount?:number}>}
16
+ */
17
+ export async function gate() {
18
+ await this.load()
19
+ const cfg = this.state.config
20
+ if (!cfg.enabled) return { ok: false, reason: '未启用' }
21
+ if (cfg.trigger !== 'auto') return { ok: false, reason: '手动模式(不会自动触发)' }
22
+
23
+ const now = Date.now()
24
+ if (this.state.retryAfter && now < this.state.retryAfter) {
25
+ return { ok: false, reason: `上次失败后的退避中(还有 ${Math.ceil((this.state.retryAfter - now) / 60000)} 分钟)` }
26
+ }
27
+
28
+ // ① 时间门
29
+ const lastAt = this.state.lastRunAt
30
+ const hoursSince = lastAt ? (now - lastAt) / 3600000 : Number.POSITIVE_INFINITY
31
+ if (hoursSince < cfg.minHours) {
32
+ return { ok: false, reason: `距上次整理 ${hoursSince.toFixed(1)}h < ${cfg.minHours}h` }
33
+ }
34
+
35
+ // ② 扫描节流(在真正扫盘之前)
36
+ if (now - this.lastScanAt < SESSION_SCAN_INTERVAL_MS) {
37
+ return { ok: false, reason: `扫描节流中(${Math.round((now - this.lastScanAt) / 1000)}s 前刚扫过)` }
38
+ }
39
+ this.lastScanAt = now
40
+
41
+ // ③ 会话门
42
+ const sessionCount = await countSessionsSince(this.sessionsRoot, lastAt || 0)
43
+ if (sessionCount < cfg.minSessions) {
44
+ return { ok: false, reason: `期间只有 ${sessionCount} 个会话更新 < ${cfg.minSessions}` }
45
+ }
46
+ return { ok: true, hoursSince, sessionCount }
47
+ }
48
+
49
+ // ────────────────────────────── 锁 ──────────────────────────────
50
+
51
+ export async function acquireLock() {
52
+ await mkdir(this.home, { recursive: true })
53
+ // 改名过渡期:旧版进程的 `dream.lock` 本插件看不见,但它一样在改 memory。
54
+ // 新鲜(未过期)就先拒绝——正常升级流程会重启进程,这条只兜「新旧并行」那一小段窗口。
55
+ const legacy = await stat(this.legacyLockPath).catch(() => null)
56
+ if (legacy && Date.now() - legacy.mtimeMs <= LOCK_STALE_MS) return false
57
+ try {
58
+ const fh = await open(this.lockPath, 'wx')
59
+ await fh.write(JSON.stringify({ pid: process.pid, at: Date.now(), runId: this.runId }))
60
+ await fh.close()
61
+ return true
62
+ } catch (err) {
63
+ if (err?.code !== 'EEXIST') throw err
64
+ const info = await stat(this.lockPath).catch(() => null)
65
+ if (info && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
66
+ // 上次进程崩了留下的锁:清掉重来(不然 autodream 永远回不来)。
67
+ await rm(this.lockPath, { force: true })
68
+ return this.acquireLock()
69
+ }
70
+ return false
71
+ }
72
+ }
73
+
74
+ export async function releaseLock() {
75
+ await rm(this.lockPath, { force: true }).catch(() => {})
76
+ }
77
+
78
+ // ────────────────────────────── 回滚点(快照) ──────────────────────────────