@hzttt/multimodal-rag 0.3.0 → 0.3.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.
- package/README.md +8 -16
- package/index.ts +0 -1
- package/openclaw.plugin.json +8 -19
- package/package.json +1 -1
- package/src/notifier.ts +3 -303
- package/src/setup.ts +2 -7
- package/src/types.ts +6 -7
- package/src/watcher.ts +171 -9
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ OpenClaw 多模态 RAG 插件 — 使用本地 AI 模型对图像和音频进行
|
|
|
11
11
|
- **自动监听**:实时监听文件夹变化,自动索引新增文件
|
|
12
12
|
- **向量存储**:使用 LanceDB 高效存储和检索
|
|
13
13
|
- **智能去重**:基于文件 SHA256 哈希去重
|
|
14
|
-
-
|
|
14
|
+
- **索引通知**:批次聚合索引事件,仅通过唤醒 agent 生成并回复通知
|
|
15
15
|
|
|
16
16
|
## 前置条件
|
|
17
17
|
|
|
@@ -157,7 +157,7 @@ openclaw multimodal-rag setup -n \
|
|
|
157
157
|
- 非交互式执行时,`--notify-enabled` 仅负责“显式开启”,未传不会主动关闭通知。
|
|
158
158
|
- 当前没有 `--notify-disabled` 选项;要关闭通知请手动编辑 `~/.openclaw/openclaw.json`。
|
|
159
159
|
- `--notify-quiet-window` 和 `--notify-batch-timeout` 未传时会优先沿用已有配置,没有已有值才回退默认值。
|
|
160
|
-
- `notifications.
|
|
160
|
+
- `notifications.agentId` / `notifications.channel` / `notifications.to` / `notifications.targets` 目前通过配置文件手动设置(setup 命令暂未提供对应参数)。
|
|
161
161
|
- 未配置 `targets` 且未配置 `channel+to` 时,插件会自动从 session store 选取“最近活跃会话”的 `lastChannel + lastTo` 作为通知目标。
|
|
162
162
|
|
|
163
163
|
### 手动配置
|
|
@@ -184,7 +184,6 @@ openclaw multimodal-rag setup -n \
|
|
|
184
184
|
"indexExistingOnStart": true,
|
|
185
185
|
"notifications": {
|
|
186
186
|
"enabled": true,
|
|
187
|
-
"mode": "agent",
|
|
188
187
|
"agentId": "main",
|
|
189
188
|
"quietWindowMs": 30000,
|
|
190
189
|
"batchTimeoutMs": 600000,
|
|
@@ -213,13 +212,12 @@ openclaw multimodal-rag setup -n \
|
|
|
213
212
|
| `watchDebounceMs` | number | `1000` | 文件监听去抖延迟(毫秒) |
|
|
214
213
|
| `indexExistingOnStart` | boolean | `true` | 启动时是否索引已有文件 |
|
|
215
214
|
| `notifications.enabled` | boolean | `false` | 启用索引完成通知(默认唤醒 agent 回复) |
|
|
216
|
-
| `notifications.
|
|
217
|
-
| `notifications.agentId` | string | `"main"` | 通知触发使用的 agent ID(仅 `agent/auto`,会沿用该 agent 的性格/身份设定) |
|
|
215
|
+
| `notifications.agentId` | string | `"main"` | 通知触发使用的 agent ID(会沿用该 agent 的性格/身份设定) |
|
|
218
216
|
| `notifications.quietWindowMs` | number | `30000` | 静默窗口:最后一个文件处理完后等待多久再发送总结(毫秒) |
|
|
219
217
|
| `notifications.batchTimeoutMs` | number | `600000` | 批次最大超时:超过此时间强制发送总结(毫秒),防止大批量索引时等太久 |
|
|
220
|
-
| `notifications.channel` | string | `"last"` |
|
|
221
|
-
| `notifications.to` | string | - |
|
|
222
|
-
| `notifications.targets` | object[] | `[]` |
|
|
218
|
+
| `notifications.channel` | string | `"last"` | 通知渠道(未配置 `targets` 时使用) |
|
|
219
|
+
| `notifications.to` | string | - | 通知目标(未配置 `targets` 时使用) |
|
|
220
|
+
| `notifications.targets` | object[] | `[]` | 通知目标列表(用于 `agent --reply-channel/--reply-to`) |
|
|
223
221
|
|
|
224
222
|
|
|
225
223
|
配置完成后,重启 OpenClaw Gateway 使配置生效。
|
|
@@ -240,7 +238,7 @@ openclaw multimodal-rag setup -n \
|
|
|
240
238
|
|
|
241
239
|
**开始通知**:
|
|
242
240
|
```
|
|
243
|
-
[Multimodal RAG]
|
|
241
|
+
[Multimodal RAG] 已开始处理本轮新增媒体文件...
|
|
244
242
|
```
|
|
245
243
|
|
|
246
244
|
**完成通知**:
|
|
@@ -272,18 +270,12 @@ openclaw multimodal-rag setup -n \
|
|
|
272
270
|
|
|
273
271
|
```json
|
|
274
272
|
{
|
|
275
|
-
"hooks": {
|
|
276
|
-
"enabled": true,
|
|
277
|
-
"token": "replace-with-strong-random-token",
|
|
278
|
-
"path": "/hooks"
|
|
279
|
-
},
|
|
280
273
|
"plugins": {
|
|
281
274
|
"entries": {
|
|
282
275
|
"multimodal-rag": {
|
|
283
276
|
"config": {
|
|
284
277
|
"notifications": {
|
|
285
278
|
"enabled": true,
|
|
286
|
-
"mode": "agent",
|
|
287
279
|
"agentId": "main",
|
|
288
280
|
"quietWindowMs": 30000,
|
|
289
281
|
"batchTimeoutMs": 600000,
|
|
@@ -304,7 +296,7 @@ openclaw multimodal-rag setup -n \
|
|
|
304
296
|
}
|
|
305
297
|
}
|
|
306
298
|
```
|
|
307
|
-
如果未配置 `notifications.targets`,插件会先尝试 `channel+to
|
|
299
|
+
如果未配置 `notifications.targets`,插件会先尝试 `channel+to`;若仍未配置,则自动使用最新活跃会话目标。
|
|
308
300
|
|
|
309
301
|
### Agent 工具
|
|
310
302
|
|
package/index.ts
CHANGED
|
@@ -51,7 +51,6 @@ const multimodalRagPlugin = {
|
|
|
51
51
|
indexExistingOnStart: userConfig.indexExistingOnStart !== false,
|
|
52
52
|
notifications: {
|
|
53
53
|
enabled: userConfig.notifications?.enabled ?? false,
|
|
54
|
-
mode: userConfig.notifications?.mode || "agent",
|
|
55
54
|
agentId: userConfig.notifications?.agentId,
|
|
56
55
|
quietWindowMs: userConfig.notifications?.quietWindowMs ?? 30000,
|
|
57
56
|
batchTimeoutMs: userConfig.notifications?.batchTimeoutMs ?? 600000,
|
package/openclaw.plugin.json
CHANGED
|
@@ -91,15 +91,9 @@
|
|
|
91
91
|
"default": false,
|
|
92
92
|
"description": "启用索引通知(默认唤醒 agent 回复)"
|
|
93
93
|
},
|
|
94
|
-
"mode": {
|
|
95
|
-
"type": "string",
|
|
96
|
-
"enum": ["agent", "message-send", "auto"],
|
|
97
|
-
"default": "agent",
|
|
98
|
-
"description": "通知模式:agent(唤醒 agent 回复) / message-send(插件直发) / auto(先 agent 后直发)"
|
|
99
|
-
},
|
|
100
94
|
"agentId": {
|
|
101
95
|
"type": "string",
|
|
102
|
-
"description": "
|
|
96
|
+
"description": "用于触发通知回复的 agent ID(默认 main)"
|
|
103
97
|
},
|
|
104
98
|
"quietWindowMs": {
|
|
105
99
|
"type": "number",
|
|
@@ -114,15 +108,15 @@
|
|
|
114
108
|
"channel": {
|
|
115
109
|
"type": "string",
|
|
116
110
|
"default": "last",
|
|
117
|
-
"description": "
|
|
111
|
+
"description": "通知渠道(默认 last)"
|
|
118
112
|
},
|
|
119
113
|
"to": {
|
|
120
114
|
"type": "string",
|
|
121
|
-
"description": "
|
|
115
|
+
"description": "通知目标(可选)"
|
|
122
116
|
},
|
|
123
117
|
"targets": {
|
|
124
118
|
"type": "array",
|
|
125
|
-
"description": "
|
|
119
|
+
"description": "通知目标列表(用于 agent 的 reply-channel/reply-to)",
|
|
126
120
|
"items": {
|
|
127
121
|
"type": "object",
|
|
128
122
|
"properties": {
|
|
@@ -175,11 +169,6 @@
|
|
|
175
169
|
"label": "启用通知",
|
|
176
170
|
"help": "索引开始/完成时触发通知"
|
|
177
171
|
},
|
|
178
|
-
"notifications.mode": {
|
|
179
|
-
"label": "通知模式",
|
|
180
|
-
"advanced": true,
|
|
181
|
-
"help": "默认用 agent 生成并回复消息;可切到 message-send 直发"
|
|
182
|
-
},
|
|
183
172
|
"notifications.agentId": {
|
|
184
173
|
"label": "通知 Agent ID",
|
|
185
174
|
"advanced": true,
|
|
@@ -198,17 +187,17 @@
|
|
|
198
187
|
"notifications.channel": {
|
|
199
188
|
"label": "通知渠道",
|
|
200
189
|
"advanced": true,
|
|
201
|
-
"help": "
|
|
190
|
+
"help": "通知渠道(不填则自动取最近活跃会话)"
|
|
202
191
|
},
|
|
203
192
|
"notifications.to": {
|
|
204
193
|
"label": "通知目标",
|
|
205
194
|
"advanced": true,
|
|
206
|
-
"help": "
|
|
195
|
+
"help": "通知目标(不填则自动取最近活跃会话)"
|
|
207
196
|
},
|
|
208
197
|
"notifications.targets": {
|
|
209
|
-
"label": "
|
|
198
|
+
"label": "通知目标列表",
|
|
210
199
|
"advanced": true,
|
|
211
|
-
"help": "
|
|
200
|
+
"help": "用于通知回复的渠道/目标列表"
|
|
212
201
|
}
|
|
213
202
|
}
|
|
214
203
|
}
|
package/package.json
CHANGED
package/src/notifier.ts
CHANGED
|
@@ -9,9 +9,6 @@ type BatchFileStatus = "queued" | "indexed" | "skipped" | "failed";
|
|
|
9
9
|
type BatchFile = { status: BatchFileStatus; fileType?: MediaType; error?: string };
|
|
10
10
|
type MainSessionConfig = Parameters<PluginRuntime["system"]["resolveMainSessionKey"]>[0];
|
|
11
11
|
type RuntimeSystemCompat = {
|
|
12
|
-
enqueueSystemEvent?: PluginRuntime["system"]["enqueueSystemEvent"];
|
|
13
|
-
requestHeartbeatNow?: PluginRuntime["system"]["requestHeartbeatNow"];
|
|
14
|
-
resolveMainSessionKey?: PluginRuntime["system"]["resolveMainSessionKey"];
|
|
15
12
|
runCommandWithTimeout?: PluginRuntime["system"]["runCommandWithTimeout"];
|
|
16
13
|
};
|
|
17
14
|
type NotificationTargetResolved = {
|
|
@@ -19,32 +16,7 @@ type NotificationTargetResolved = {
|
|
|
19
16
|
to: string;
|
|
20
17
|
accountId?: string;
|
|
21
18
|
};
|
|
22
|
-
type NotificationDispatchMode = "agent" | "message-send" | "auto";
|
|
23
|
-
type HookAgentPayload = {
|
|
24
|
-
message: string;
|
|
25
|
-
name: string;
|
|
26
|
-
wakeMode: "now" | "next-heartbeat";
|
|
27
|
-
sessionKey: string;
|
|
28
|
-
deliver: boolean;
|
|
29
|
-
channel: string;
|
|
30
|
-
to?: string;
|
|
31
|
-
};
|
|
32
|
-
type HookAgentTarget = {
|
|
33
|
-
url: string;
|
|
34
|
-
token: string;
|
|
35
|
-
payload: HookAgentPayload;
|
|
36
|
-
};
|
|
37
|
-
type HooksConfigCompat = {
|
|
38
|
-
enabled?: boolean;
|
|
39
|
-
token?: string;
|
|
40
|
-
path?: string;
|
|
41
|
-
};
|
|
42
|
-
type GatewayConfigCompat = {
|
|
43
|
-
port?: number;
|
|
44
|
-
};
|
|
45
19
|
type RootConfigCompat = MainSessionConfig & {
|
|
46
|
-
hooks?: HooksConfigCompat;
|
|
47
|
-
gateway?: GatewayConfigCompat;
|
|
48
20
|
session?: {
|
|
49
21
|
store?: string;
|
|
50
22
|
};
|
|
@@ -64,7 +36,6 @@ export class IndexNotifier implements IndexEventCallbacks {
|
|
|
64
36
|
private batchStartTime = 0;
|
|
65
37
|
private quietTimer: NodeJS.Timeout | null = null;
|
|
66
38
|
private batchTimeoutTimer: NodeJS.Timeout | null = null;
|
|
67
|
-
private warnedMissingHookToken = false;
|
|
68
39
|
|
|
69
40
|
constructor(
|
|
70
41
|
private readonly config: NotificationConfig,
|
|
@@ -190,7 +161,7 @@ export class IndexNotifier implements IndexEventCallbacks {
|
|
|
190
161
|
}
|
|
191
162
|
|
|
192
163
|
/**
|
|
193
|
-
*
|
|
164
|
+
* 触发通知:仅保留 agent 主动回复链路。
|
|
194
165
|
*/
|
|
195
166
|
private triggerAgent(text: string): void {
|
|
196
167
|
void this.triggerAgentInternal(text);
|
|
@@ -200,71 +171,15 @@ export class IndexNotifier implements IndexEventCallbacks {
|
|
|
200
171
|
const system = this.runtime.system as unknown as RuntimeSystemCompat;
|
|
201
172
|
|
|
202
173
|
try {
|
|
203
|
-
|
|
204
|
-
if (mode === "agent") {
|
|
205
|
-
if (await this.dispatchViaAgentTurn(text, system)) {
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
if (await this.dispatchViaMessageSend(text, system)) {
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
} else if (mode === "message-send") {
|
|
212
|
-
if (await this.dispatchViaMessageSend(text, system)) {
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
} else {
|
|
216
|
-
if (await this.dispatchViaAgentTurn(text, system)) {
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
if (await this.dispatchViaMessageSend(text, system)) {
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (await this.dispatchViaHookAgent(text)) {
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
if (
|
|
229
|
-
typeof system.enqueueSystemEvent === "function" &&
|
|
230
|
-
typeof system.requestHeartbeatNow === "function"
|
|
231
|
-
) {
|
|
232
|
-
const sessionKey = this.resolveMainSessionKey(system);
|
|
233
|
-
system.enqueueSystemEvent(text, { sessionKey });
|
|
234
|
-
system.requestHeartbeatNow({ reason: "multimodal-rag:index-notify" });
|
|
235
|
-
this.logger.info?.(
|
|
236
|
-
`Notification enqueued and heartbeat requested (${sessionKey}): ${text.slice(0, 80)}...`,
|
|
237
|
-
);
|
|
174
|
+
if (await this.dispatchViaAgentTurn(text, system)) {
|
|
238
175
|
return;
|
|
239
176
|
}
|
|
240
|
-
|
|
241
|
-
if (typeof system.runCommandWithTimeout === "function") {
|
|
242
|
-
const argv = this.buildSystemEventCommand(text);
|
|
243
|
-
const result = await system.runCommandWithTimeout(argv, { timeoutMs: 15000 });
|
|
244
|
-
if (result.code === 0) {
|
|
245
|
-
this.logger.info?.(`Notification sent via CLI fallback: ${text.slice(0, 80)}...`);
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
const detail = (result.stderr || result.stdout || "no output").trim();
|
|
249
|
-
this.logger.warn?.(`Notification CLI fallback failed (code=${String(result.code)}): ${detail}`);
|
|
250
|
-
this.enqueueWithoutWake(system, text);
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
this.enqueueWithoutWake(system, text);
|
|
177
|
+
this.logger.warn?.(`Notification agent trigger failed for all targets: ${text.slice(0, 80)}...`);
|
|
255
178
|
} catch (err) {
|
|
256
179
|
this.logger.warn?.(`Failed to trigger notification: ${String(err)}`);
|
|
257
180
|
}
|
|
258
181
|
}
|
|
259
182
|
|
|
260
|
-
private resolveDispatchMode(): NotificationDispatchMode {
|
|
261
|
-
const rawMode = typeof this.config.mode === "string" ? this.config.mode.trim() : "";
|
|
262
|
-
if (rawMode === "agent" || rawMode === "message-send" || rawMode === "auto") {
|
|
263
|
-
return rawMode;
|
|
264
|
-
}
|
|
265
|
-
return "agent";
|
|
266
|
-
}
|
|
267
|
-
|
|
268
183
|
/**
|
|
269
184
|
* 通过 `openclaw agent --deliver` 触发 agent 主动回复用户。
|
|
270
185
|
* 这条路径会让最终消息由 agent 生成并投递,而不是插件直接发送文本。
|
|
@@ -310,141 +225,6 @@ export class IndexNotifier implements IndexEventCallbacks {
|
|
|
310
225
|
return false;
|
|
311
226
|
}
|
|
312
227
|
|
|
313
|
-
/**
|
|
314
|
-
* 直发路径:通过 `openclaw message send` 向显式配置的目标推送通知。
|
|
315
|
-
* 不依赖 heartbeat,也不依赖 HTTP hooks。
|
|
316
|
-
*/
|
|
317
|
-
private async dispatchViaMessageSend(
|
|
318
|
-
text: string,
|
|
319
|
-
system: RuntimeSystemCompat,
|
|
320
|
-
): Promise<boolean> {
|
|
321
|
-
if (typeof system.runCommandWithTimeout !== "function") {
|
|
322
|
-
return false;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
const targets = await this.resolveNotificationTargets();
|
|
326
|
-
if (targets.length === 0) {
|
|
327
|
-
return false;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
let successCount = 0;
|
|
331
|
-
for (const target of targets) {
|
|
332
|
-
const argv = this.buildMessageSendCommand(text, target);
|
|
333
|
-
try {
|
|
334
|
-
const result = await system.runCommandWithTimeout(argv, { timeoutMs: 20000 });
|
|
335
|
-
if (result.code === 0) {
|
|
336
|
-
successCount += 1;
|
|
337
|
-
continue;
|
|
338
|
-
}
|
|
339
|
-
const detail = (result.stderr || result.stdout || "no output").trim();
|
|
340
|
-
this.logger.warn?.(
|
|
341
|
-
`Notification direct send failed (${target.channel}:${target.to}, code=${String(result.code)}): ${detail}`,
|
|
342
|
-
);
|
|
343
|
-
} catch (err) {
|
|
344
|
-
this.logger.warn?.(
|
|
345
|
-
`Notification direct send error (${target.channel}:${target.to}): ${String(err)}`,
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
if (successCount > 0) {
|
|
351
|
-
this.logger.info?.(
|
|
352
|
-
`Notification sent via message send targets (${successCount}/${targets.length}): ${text.slice(0, 80)}...`,
|
|
353
|
-
);
|
|
354
|
-
return true;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
return false;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* 优先用 Gateway hooks 主动触发 isolated agent,避免依赖 heartbeat 文件状态。
|
|
362
|
-
*/
|
|
363
|
-
private async dispatchViaHookAgent(text: string): Promise<boolean> {
|
|
364
|
-
const target = this.resolveHookAgentTarget(text);
|
|
365
|
-
if (!target) {
|
|
366
|
-
return false;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
try {
|
|
370
|
-
const response = await fetch(target.url, {
|
|
371
|
-
method: "POST",
|
|
372
|
-
headers: {
|
|
373
|
-
Authorization: `Bearer ${target.token}`,
|
|
374
|
-
"Content-Type": "application/json",
|
|
375
|
-
},
|
|
376
|
-
body: JSON.stringify(target.payload),
|
|
377
|
-
});
|
|
378
|
-
if (response.status === 202) {
|
|
379
|
-
this.logger.info?.(`Notification sent via hooks/agent: ${text.slice(0, 80)}...`);
|
|
380
|
-
return true;
|
|
381
|
-
}
|
|
382
|
-
const detail = (await response.text()).trim();
|
|
383
|
-
this.logger.warn?.(
|
|
384
|
-
`Notification hooks/agent failed (status=${response.status}): ${detail || "no output"}`,
|
|
385
|
-
);
|
|
386
|
-
return false;
|
|
387
|
-
} catch (err) {
|
|
388
|
-
this.logger.warn?.(`Notification hooks/agent error: ${String(err)}`);
|
|
389
|
-
return false;
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
private resolveHookAgentTarget(text: string): HookAgentTarget | null {
|
|
394
|
-
const cfg = this.mainSessionConfig as RootConfigCompat | undefined;
|
|
395
|
-
const hooks = cfg?.hooks;
|
|
396
|
-
if (hooks?.enabled !== true) {
|
|
397
|
-
return null;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
const token = typeof hooks.token === "string" ? hooks.token.trim() : "";
|
|
401
|
-
if (!token) {
|
|
402
|
-
if (!this.warnedMissingHookToken) {
|
|
403
|
-
this.logger.warn?.(
|
|
404
|
-
"hooks.enabled=true but hooks.token is empty; skipping hooks/agent notification path",
|
|
405
|
-
);
|
|
406
|
-
this.warnedMissingHookToken = true;
|
|
407
|
-
}
|
|
408
|
-
return null;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
const rawPort = cfg?.gateway?.port;
|
|
412
|
-
const gatewayPort =
|
|
413
|
-
typeof rawPort === "number" && Number.isFinite(rawPort) && rawPort > 0
|
|
414
|
-
? Math.floor(rawPort)
|
|
415
|
-
: 18789;
|
|
416
|
-
const hooksPath = this.normalizeHooksPath(hooks.path);
|
|
417
|
-
const channel =
|
|
418
|
-
typeof this.config.channel === "string" && this.config.channel.trim()
|
|
419
|
-
? this.config.channel.trim()
|
|
420
|
-
: "last";
|
|
421
|
-
const to =
|
|
422
|
-
typeof this.config.to === "string" && this.config.to.trim() ? this.config.to.trim() : undefined;
|
|
423
|
-
|
|
424
|
-
return {
|
|
425
|
-
url: `http://127.0.0.1:${gatewayPort}${hooksPath}/agent`,
|
|
426
|
-
token,
|
|
427
|
-
payload: {
|
|
428
|
-
message: text,
|
|
429
|
-
name: "Multimodal RAG",
|
|
430
|
-
wakeMode: "now",
|
|
431
|
-
sessionKey: "hook:multimodal-rag:index-notify",
|
|
432
|
-
deliver: true,
|
|
433
|
-
channel,
|
|
434
|
-
to,
|
|
435
|
-
},
|
|
436
|
-
};
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
private normalizeHooksPath(rawPath: string | undefined): string {
|
|
440
|
-
const normalized = typeof rawPath === "string" ? rawPath.trim() : "";
|
|
441
|
-
if (!normalized) {
|
|
442
|
-
return "/hooks";
|
|
443
|
-
}
|
|
444
|
-
const withLeadingSlash = normalized.startsWith("/") ? normalized : `/${normalized}`;
|
|
445
|
-
const withoutTrailingSlash = withLeadingSlash.replace(/\/+$/, "");
|
|
446
|
-
return withoutTrailingSlash || "/hooks";
|
|
447
|
-
}
|
|
448
228
|
|
|
449
229
|
private async resolveNotificationTargets(): Promise<NotificationTargetResolved[]> {
|
|
450
230
|
const explicitTargets = Array.isArray(this.config.targets) ? this.config.targets : [];
|
|
@@ -531,47 +311,6 @@ export class IndexNotifier implements IndexEventCallbacks {
|
|
|
531
311
|
return null;
|
|
532
312
|
}
|
|
533
313
|
|
|
534
|
-
/**
|
|
535
|
-
* 旧版 runtime 缺少 resolveMainSessionKey 时的兼容解析。
|
|
536
|
-
*/
|
|
537
|
-
private resolveMainSessionKey(system: RuntimeSystemCompat): string {
|
|
538
|
-
if (typeof system.resolveMainSessionKey === "function") {
|
|
539
|
-
return system.resolveMainSessionKey(this.mainSessionConfig);
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
if (this.mainSessionConfig?.session?.scope === "global") {
|
|
543
|
-
return "global";
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
const configuredMainKey = this.mainSessionConfig?.session?.mainKey?.trim();
|
|
547
|
-
const mainKey = configuredMainKey && configuredMainKey.length > 0 ? configuredMainKey : "main";
|
|
548
|
-
|
|
549
|
-
const agents = this.mainSessionConfig?.agents?.list ?? [];
|
|
550
|
-
const defaultAgentId =
|
|
551
|
-
agents.find((agent) => agent.default && typeof agent.id === "string" && agent.id.trim())
|
|
552
|
-
?.id?.trim() ??
|
|
553
|
-
agents.find((agent) => typeof agent.id === "string" && agent.id.trim())?.id?.trim() ??
|
|
554
|
-
"main";
|
|
555
|
-
|
|
556
|
-
return `agent:${defaultAgentId}:${mainKey}`;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* 回退路径:至少入队系统事件,等待下一次 heartbeat。
|
|
561
|
-
*/
|
|
562
|
-
private enqueueWithoutWake(system: RuntimeSystemCompat, text: string): void {
|
|
563
|
-
if (typeof system.enqueueSystemEvent !== "function") {
|
|
564
|
-
this.logger.warn?.("Failed to trigger notification: runtime.system.enqueueSystemEvent missing");
|
|
565
|
-
return;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
const sessionKey = this.resolveMainSessionKey(system);
|
|
569
|
-
system.enqueueSystemEvent(text, { sessionKey });
|
|
570
|
-
this.logger.warn?.(
|
|
571
|
-
`Notification queued without wake (${sessionKey}); runtime lacks immediate heartbeat helper`,
|
|
572
|
-
);
|
|
573
|
-
}
|
|
574
|
-
|
|
575
314
|
private resolveNotificationAgentId(): string {
|
|
576
315
|
const configuredAgentId =
|
|
577
316
|
typeof this.config.agentId === "string" ? this.config.agentId.trim() : "";
|
|
@@ -644,45 +383,6 @@ export class IndexNotifier implements IndexEventCallbacks {
|
|
|
644
383
|
return `${target.channel}:${target.to}`;
|
|
645
384
|
}
|
|
646
385
|
|
|
647
|
-
/**
|
|
648
|
-
* CLI 回退:兼容缺少 requestHeartbeatNow 的旧版 runtime。
|
|
649
|
-
*/
|
|
650
|
-
private buildSystemEventCommand(text: string): string[] {
|
|
651
|
-
const nodeExec = process.execPath;
|
|
652
|
-
const cliEntry = process.argv[1];
|
|
653
|
-
|
|
654
|
-
if (typeof cliEntry === "string" && cliEntry.trim().length > 0) {
|
|
655
|
-
return [nodeExec, cliEntry, "system", "event", "--text", text, "--mode", "now"];
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
return ["openclaw", "system", "event", "--text", text, "--mode", "now"];
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
private buildMessageSendCommand(text: string, target: NotificationTargetResolved): string[] {
|
|
662
|
-
const nodeExec = process.execPath;
|
|
663
|
-
const cliEntry = process.argv[1];
|
|
664
|
-
const argvCore = [
|
|
665
|
-
"message",
|
|
666
|
-
"send",
|
|
667
|
-
"--channel",
|
|
668
|
-
target.channel,
|
|
669
|
-
"--target",
|
|
670
|
-
target.to,
|
|
671
|
-
"--message",
|
|
672
|
-
text,
|
|
673
|
-
];
|
|
674
|
-
|
|
675
|
-
if (typeof target.accountId === "string" && target.accountId.length > 0) {
|
|
676
|
-
argvCore.push("--account", target.accountId);
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
if (typeof cliEntry === "string" && cliEntry.trim().length > 0) {
|
|
680
|
-
return [nodeExec, cliEntry, ...argvCore];
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
return ["openclaw", ...argvCore];
|
|
684
|
-
}
|
|
685
|
-
|
|
686
386
|
/**
|
|
687
387
|
* 构建"开始索引"消息
|
|
688
388
|
*/
|
package/src/setup.ts
CHANGED
|
@@ -31,7 +31,6 @@ type PluginConfigPartial = {
|
|
|
31
31
|
indexExistingOnStart?: boolean;
|
|
32
32
|
notifications?: {
|
|
33
33
|
enabled?: boolean;
|
|
34
|
-
mode?: "agent" | "message-send" | "auto";
|
|
35
34
|
agentId?: string;
|
|
36
35
|
quietWindowMs?: number;
|
|
37
36
|
batchTimeoutMs?: number;
|
|
@@ -137,9 +136,7 @@ export async function runNonInteractiveSetup(opts: NonInteractiveSetupOpts): Pro
|
|
|
137
136
|
dbPath: opts.dbPath || existing.dbPath || "~/.openclaw/multimodal-rag.lance",
|
|
138
137
|
indexExistingOnStart: opts.noIndexOnStart ? false : (existing.indexExistingOnStart !== false),
|
|
139
138
|
notifications: {
|
|
140
|
-
...existing.notifications,
|
|
141
139
|
enabled: opts.notifyEnabled ?? existing.notifications?.enabled ?? false,
|
|
142
|
-
mode: existing.notifications?.mode ?? "agent",
|
|
143
140
|
agentId: existing.notifications?.agentId,
|
|
144
141
|
quietWindowMs: opts.notifyQuietWindowMs ?? existing.notifications?.quietWindowMs ?? 30000,
|
|
145
142
|
batchTimeoutMs: opts.notifyBatchTimeoutMs ?? existing.notifications?.batchTimeoutMs ?? 600000,
|
|
@@ -162,7 +159,6 @@ export async function runNonInteractiveSetup(opts: NonInteractiveSetupOpts): Pro
|
|
|
162
159
|
console.log(` 启动时索引: ${pluginConfig.indexExistingOnStart ? "是" : "否"}`);
|
|
163
160
|
console.log(` 索引通知: ${pluginConfig.notifications!.enabled ? "已启用" : "已禁用"}`);
|
|
164
161
|
if (pluginConfig.notifications!.enabled) {
|
|
165
|
-
console.log(` 通知模式: ${pluginConfig.notifications!.mode || "agent"}`);
|
|
166
162
|
if (pluginConfig.notifications!.agentId) {
|
|
167
163
|
console.log(` Agent ID: ${pluginConfig.notifications!.agentId}`);
|
|
168
164
|
}
|
|
@@ -173,7 +169,7 @@ export async function runNonInteractiveSetup(opts: NonInteractiveSetupOpts): Pro
|
|
|
173
169
|
console.log(` 通知目标: ${pluginConfig.notifications!.to}`);
|
|
174
170
|
}
|
|
175
171
|
if ((pluginConfig.notifications!.targets?.length ?? 0) > 0) {
|
|
176
|
-
console.log(`
|
|
172
|
+
console.log(` 通知目标: ${pluginConfig.notifications!.targets!.length} 个`);
|
|
177
173
|
}
|
|
178
174
|
}
|
|
179
175
|
console.log();
|
|
@@ -224,7 +220,6 @@ export async function runSetup(): Promise<void> {
|
|
|
224
220
|
indexExistingOnStart: true,
|
|
225
221
|
notifications: {
|
|
226
222
|
enabled: existing.notifications?.enabled ?? false,
|
|
227
|
-
mode: existing.notifications?.mode ?? "agent",
|
|
228
223
|
agentId: existing.notifications?.agentId,
|
|
229
224
|
quietWindowMs: existing.notifications?.quietWindowMs ?? 30000,
|
|
230
225
|
batchTimeoutMs: existing.notifications?.batchTimeoutMs ?? 600000,
|
|
@@ -306,7 +301,7 @@ export async function runSetup(): Promise<void> {
|
|
|
306
301
|
console.log(` 通知目标: ${pluginConfig.notifications!.to}`);
|
|
307
302
|
}
|
|
308
303
|
if ((pluginConfig.notifications!.targets?.length ?? 0) > 0) {
|
|
309
|
-
console.log(`
|
|
304
|
+
console.log(` 通知目标: ${pluginConfig.notifications!.targets!.length} 个`);
|
|
310
305
|
}
|
|
311
306
|
}
|
|
312
307
|
console.log();
|
package/src/types.ts
CHANGED
|
@@ -35,16 +35,15 @@ export type MediaSearchResult = {
|
|
|
35
35
|
*/
|
|
36
36
|
export type NotificationConfig = {
|
|
37
37
|
enabled: boolean; // 是否启用通知,默认 false
|
|
38
|
-
|
|
39
|
-
agentId?: string; // mode=agent/auto 时用于触发的 agent,默认 main(或配置中的默认 agent)
|
|
38
|
+
agentId?: string; // 用于触发通知回复的 agent,默认 main(或配置中的默认 agent)
|
|
40
39
|
quietWindowMs: number; // 静默窗口(毫秒),默认 30000
|
|
41
40
|
batchTimeoutMs: number; // 批次最大超时(毫秒),默认 600000
|
|
42
|
-
channel?: string; //
|
|
43
|
-
to?: string; //
|
|
41
|
+
channel?: string; // agent 回复投递渠道,默认 "last"
|
|
42
|
+
to?: string; // agent 回复投递目标(可选)
|
|
44
43
|
targets?: Array<{
|
|
45
|
-
channel: string; //
|
|
46
|
-
to: string; //
|
|
47
|
-
accountId?: string; //
|
|
44
|
+
channel: string; // 回复目标渠道(agent --reply-channel)
|
|
45
|
+
to: string; // 回复目标(agent --reply-to)
|
|
46
|
+
accountId?: string; // 可选账号(agent --reply-account)
|
|
48
47
|
}>;
|
|
49
48
|
};
|
|
50
49
|
|
package/src/watcher.ts
CHANGED
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import chokidar from "chokidar";
|
|
6
|
-
import { stat, readdir, realpath } from "node:fs/promises";
|
|
7
|
-
import { basename, extname, resolve, join } from "node:path";
|
|
6
|
+
import { stat, readdir, realpath, readFile, writeFile, mkdir } from "node:fs/promises";
|
|
7
|
+
import { basename, extname, resolve, join, dirname } from "node:path";
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
|
-
import { readFile } from "node:fs/promises";
|
|
10
9
|
import { homedir } from "node:os";
|
|
11
10
|
import type { MediaType, PluginConfig, IndexEventCallbacks } from "./types.js";
|
|
12
11
|
import type { MediaStorage } from "./storage.js";
|
|
@@ -14,6 +13,14 @@ import type { IEmbeddingProvider } from "./types.js";
|
|
|
14
13
|
import type { IMediaProcessor } from "./types.js";
|
|
15
14
|
|
|
16
15
|
const AUDIO_FAILURE_PATTERN = /^[((]\s*转录失败[::]/;
|
|
16
|
+
const BROKEN_FILES_STATE_SUFFIX = ".broken-files.json";
|
|
17
|
+
|
|
18
|
+
type BrokenFileRecord = {
|
|
19
|
+
mtimeMs: number;
|
|
20
|
+
size: number;
|
|
21
|
+
error: string;
|
|
22
|
+
markedAt: number;
|
|
23
|
+
};
|
|
17
24
|
|
|
18
25
|
/**
|
|
19
26
|
* 扩展路径中的 ~ 为用户主目录
|
|
@@ -38,6 +45,8 @@ export class MediaWatcher {
|
|
|
38
45
|
private processingFilePath: string | null = null;
|
|
39
46
|
private debounceTimers: Map<string, NodeJS.Timeout> = new Map();
|
|
40
47
|
private failedFiles: Map<string, { attempts: number; lastError: string }> = new Map();
|
|
48
|
+
private brokenFiles: Map<string, BrokenFileRecord> = new Map();
|
|
49
|
+
private readonly brokenFilesStatePath: string;
|
|
41
50
|
private ollamaHealthy = true;
|
|
42
51
|
private lastOllamaCheck = 0;
|
|
43
52
|
|
|
@@ -48,7 +57,10 @@ export class MediaWatcher {
|
|
|
48
57
|
private readonly processor: IMediaProcessor,
|
|
49
58
|
private readonly logger: { info?: (msg: string) => void; warn?: (msg: string) => void },
|
|
50
59
|
private readonly callbacks?: IndexEventCallbacks,
|
|
51
|
-
) {
|
|
60
|
+
) {
|
|
61
|
+
const resolvedDbPath = resolve(expandPath(this.config.dbPath));
|
|
62
|
+
this.brokenFilesStatePath = `${resolvedDbPath}${BROKEN_FILES_STATE_SUFFIX}`;
|
|
63
|
+
}
|
|
52
64
|
|
|
53
65
|
/**
|
|
54
66
|
* 启动监听
|
|
@@ -65,6 +77,8 @@ export class MediaWatcher {
|
|
|
65
77
|
return;
|
|
66
78
|
}
|
|
67
79
|
|
|
80
|
+
await this.loadBrokenFilesState();
|
|
81
|
+
|
|
68
82
|
// 扩展 ~ 为用户主目录
|
|
69
83
|
const expandedPaths = watchPaths.map(expandPath);
|
|
70
84
|
this.logger.info?.(`Expanded watch paths: ${expandedPaths.join(", ")}`);
|
|
@@ -96,7 +110,7 @@ export class MediaWatcher {
|
|
|
96
110
|
|
|
97
111
|
const timer = setTimeout(() => {
|
|
98
112
|
this.debounceTimers.delete(filePath);
|
|
99
|
-
this.
|
|
113
|
+
void this.enqueueFileWithBrokenFileGuard(filePath);
|
|
100
114
|
}, watchDebounceMs);
|
|
101
115
|
|
|
102
116
|
this.debounceTimers.set(filePath, timer);
|
|
@@ -116,7 +130,7 @@ export class MediaWatcher {
|
|
|
116
130
|
|
|
117
131
|
const timer = setTimeout(() => {
|
|
118
132
|
this.debounceTimers.delete(filePath);
|
|
119
|
-
this.
|
|
133
|
+
void this.enqueueFileWithBrokenFileGuard(filePath);
|
|
120
134
|
}, watchDebounceMs);
|
|
121
135
|
|
|
122
136
|
this.debounceTimers.set(filePath, timer);
|
|
@@ -166,6 +180,17 @@ export class MediaWatcher {
|
|
|
166
180
|
this.processNextFile();
|
|
167
181
|
}
|
|
168
182
|
|
|
183
|
+
private async enqueueFileWithBrokenFileGuard(filePath: string): Promise<void> {
|
|
184
|
+
if (await this.shouldSkipBrokenFile(filePath)) {
|
|
185
|
+
const ext = extname(filePath).toLowerCase();
|
|
186
|
+
const fileType: MediaType = this.config.fileTypes.image.includes(ext) ? "image" : "audio";
|
|
187
|
+
this.callbacks?.onFileSkipped?.(filePath, fileType, "broken");
|
|
188
|
+
this.logger.info?.(`Skipping previously broken file: ${filePath}`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
this.enqueueFile(filePath);
|
|
192
|
+
}
|
|
193
|
+
|
|
169
194
|
/**
|
|
170
195
|
* 处理队列中的下一个文件
|
|
171
196
|
*/
|
|
@@ -218,6 +243,12 @@ export class MediaWatcher {
|
|
|
218
243
|
return false; // 不支持的类型
|
|
219
244
|
}
|
|
220
245
|
|
|
246
|
+
if (await this.shouldSkipBrokenFile(filePath)) {
|
|
247
|
+
this.callbacks?.onFileSkipped?.(filePath, fileType, "broken");
|
|
248
|
+
this.logger.info?.(`Skipping unchanged broken file: ${fileName}`);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
221
252
|
// 检查 Ollama 健康状态
|
|
222
253
|
const ollamaHealthy = await this.checkOllamaHealth();
|
|
223
254
|
if (!ollamaHealthy) {
|
|
@@ -255,6 +286,7 @@ export class MediaWatcher {
|
|
|
255
286
|
this.logger.info?.(`Skipping duplicate: ${fileName}`);
|
|
256
287
|
// 已存在内容:标记为 skipped,不计入“本次新索引成功数”
|
|
257
288
|
this.failedFiles.delete(filePath);
|
|
289
|
+
await this.clearBrokenFileMark(filePath);
|
|
258
290
|
this.callbacks?.onFileSkipped?.(filePath, fileType, "duplicate");
|
|
259
291
|
return true;
|
|
260
292
|
}
|
|
@@ -291,6 +323,7 @@ export class MediaWatcher {
|
|
|
291
323
|
|
|
292
324
|
// 索引成功,清除失败记录
|
|
293
325
|
this.failedFiles.delete(filePath);
|
|
326
|
+
await this.clearBrokenFileMark(filePath);
|
|
294
327
|
|
|
295
328
|
// 通知回调:文件索引成功
|
|
296
329
|
this.callbacks?.onFileIndexed(filePath, fileType);
|
|
@@ -308,9 +341,7 @@ export class MediaWatcher {
|
|
|
308
341
|
// 如果是 Ollama 相关错误且未超过重试次数,稍后重试
|
|
309
342
|
if (
|
|
310
343
|
failedInfo.attempts < 3 &&
|
|
311
|
-
(errorMsg
|
|
312
|
-
errorMsg.includes("Ollama") ||
|
|
313
|
-
errorMsg.includes("ECONNREFUSED"))
|
|
344
|
+
this.isTransientIndexingError(errorMsg)
|
|
314
345
|
) {
|
|
315
346
|
this.logger.warn?.(
|
|
316
347
|
`Will retry ${fileName} in 60s (attempt ${failedInfo.attempts}/3)`,
|
|
@@ -319,6 +350,9 @@ export class MediaWatcher {
|
|
|
319
350
|
this.enqueueFile(filePath);
|
|
320
351
|
}, 60000);
|
|
321
352
|
} else {
|
|
353
|
+
if (this.shouldMarkFileAsBroken(errorMsg)) {
|
|
354
|
+
await this.markFileAsBroken(filePath, errorMsg);
|
|
355
|
+
}
|
|
322
356
|
// 达到最大重试次数或非临时错误,通知回调:文件索引失败
|
|
323
357
|
this.callbacks?.onFileFailed(filePath, errorMsg);
|
|
324
358
|
}
|
|
@@ -394,8 +428,13 @@ export class MediaWatcher {
|
|
|
394
428
|
|
|
395
429
|
// 找出缺失的文件
|
|
396
430
|
let missingFiles = 0;
|
|
431
|
+
let skippedBrokenFiles = 0;
|
|
397
432
|
for (const { filePath, comparablePath } of comparableAllFiles) {
|
|
398
433
|
if (!indexedPathsSet.has(filePath) && !indexedPathsSet.has(comparablePath)) {
|
|
434
|
+
if (await this.shouldSkipBrokenFile(filePath, comparablePath)) {
|
|
435
|
+
skippedBrokenFiles++;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
399
438
|
missingFiles++;
|
|
400
439
|
this.enqueueFile(filePath);
|
|
401
440
|
}
|
|
@@ -408,6 +447,12 @@ export class MediaWatcher {
|
|
|
408
447
|
} else {
|
|
409
448
|
this.logger.info?.(`All ${allFiles.length} files are already indexed`);
|
|
410
449
|
}
|
|
450
|
+
|
|
451
|
+
if (skippedBrokenFiles > 0) {
|
|
452
|
+
this.logger.info?.(
|
|
453
|
+
`Skipped ${skippedBrokenFiles} unchanged broken file(s) during startup scan`,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
411
456
|
}
|
|
412
457
|
|
|
413
458
|
private async normalizeComparablePath(filePath: string): Promise<string> {
|
|
@@ -509,4 +554,121 @@ export class MediaWatcher {
|
|
|
509
554
|
|
|
510
555
|
await this.scanAndIndexMissingFiles(expandedPaths, supportedExts);
|
|
511
556
|
}
|
|
557
|
+
|
|
558
|
+
private isTransientIndexingError(errorMsg: string): boolean {
|
|
559
|
+
const normalized = errorMsg.toLowerCase();
|
|
560
|
+
return (
|
|
561
|
+
normalized.includes("internal server error") ||
|
|
562
|
+
normalized.includes("ollama") ||
|
|
563
|
+
normalized.includes("econnrefused") ||
|
|
564
|
+
normalized.includes("econnreset") ||
|
|
565
|
+
normalized.includes("etimedout") ||
|
|
566
|
+
normalized.includes("fetch failed") ||
|
|
567
|
+
normalized.includes("timeout") ||
|
|
568
|
+
normalized.includes("aborted")
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private shouldMarkFileAsBroken(errorMsg: string): boolean {
|
|
573
|
+
return !this.isTransientIndexingError(errorMsg);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private async loadBrokenFilesState(): Promise<void> {
|
|
577
|
+
try {
|
|
578
|
+
const raw = await readFile(this.brokenFilesStatePath, "utf-8");
|
|
579
|
+
const parsed = JSON.parse(raw) as Record<string, Partial<BrokenFileRecord>>;
|
|
580
|
+
this.brokenFiles.clear();
|
|
581
|
+
|
|
582
|
+
for (const [filePath, value] of Object.entries(parsed || {})) {
|
|
583
|
+
if (!value || typeof value !== "object") {
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
const mtimeMs = Number(value.mtimeMs);
|
|
587
|
+
const size = Number(value.size);
|
|
588
|
+
const error = String(value.error ?? "");
|
|
589
|
+
const markedAt = Number(value.markedAt ?? Date.now());
|
|
590
|
+
if (!Number.isFinite(mtimeMs) || !Number.isFinite(size)) {
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
this.brokenFiles.set(filePath, { mtimeMs, size, error, markedAt });
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (this.brokenFiles.size > 0) {
|
|
597
|
+
this.logger.info?.(`Loaded ${this.brokenFiles.size} broken file marker(s)`);
|
|
598
|
+
}
|
|
599
|
+
} catch (error) {
|
|
600
|
+
const err = error as NodeJS.ErrnoException;
|
|
601
|
+
if (err?.code !== "ENOENT") {
|
|
602
|
+
this.logger.warn?.(`Failed to load broken file markers: ${String(error)}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private async saveBrokenFilesState(): Promise<void> {
|
|
608
|
+
const output: Record<string, BrokenFileRecord> = {};
|
|
609
|
+
for (const [filePath, info] of this.brokenFiles.entries()) {
|
|
610
|
+
output[filePath] = info;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
await mkdir(dirname(this.brokenFilesStatePath), { recursive: true });
|
|
614
|
+
await writeFile(
|
|
615
|
+
this.brokenFilesStatePath,
|
|
616
|
+
`${JSON.stringify(output, null, 2)}\n`,
|
|
617
|
+
"utf-8",
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private async markFileAsBroken(filePath: string, error: string): Promise<void> {
|
|
622
|
+
const key = await this.normalizeComparablePath(filePath);
|
|
623
|
+
let size = 0;
|
|
624
|
+
let mtimeMs = 0;
|
|
625
|
+
|
|
626
|
+
try {
|
|
627
|
+
const fileStat = await stat(filePath);
|
|
628
|
+
size = fileStat.size;
|
|
629
|
+
mtimeMs = fileStat.mtimeMs;
|
|
630
|
+
} catch {}
|
|
631
|
+
|
|
632
|
+
const existing = this.brokenFiles.get(key);
|
|
633
|
+
if (
|
|
634
|
+
existing &&
|
|
635
|
+
existing.size === size &&
|
|
636
|
+
existing.mtimeMs === mtimeMs &&
|
|
637
|
+
existing.error === error
|
|
638
|
+
) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
this.brokenFiles.set(key, { size, mtimeMs, error, markedAt: Date.now() });
|
|
643
|
+
await this.saveBrokenFilesState();
|
|
644
|
+
this.logger.warn?.(`Marked broken file to skip unchanged retries: ${filePath}`);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private async clearBrokenFileMark(filePath: string, normalizedPath?: string): Promise<void> {
|
|
648
|
+
const key = normalizedPath ?? (await this.normalizeComparablePath(filePath));
|
|
649
|
+
if (!this.brokenFiles.delete(key)) {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
await this.saveBrokenFilesState();
|
|
653
|
+
this.logger.info?.(`Removed broken file marker: ${filePath}`);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private async shouldSkipBrokenFile(filePath: string, normalizedPath?: string): Promise<boolean> {
|
|
657
|
+
const key = normalizedPath ?? (await this.normalizeComparablePath(filePath));
|
|
658
|
+
const marker = this.brokenFiles.get(key);
|
|
659
|
+
if (!marker) {
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
const fileStat = await stat(filePath);
|
|
665
|
+
const unchanged = fileStat.size === marker.size && fileStat.mtimeMs === marker.mtimeMs;
|
|
666
|
+
if (unchanged) {
|
|
667
|
+
return true;
|
|
668
|
+
}
|
|
669
|
+
} catch {}
|
|
670
|
+
|
|
671
|
+
await this.clearBrokenFileMark(filePath, key);
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
512
674
|
}
|