@echomem/echo-memory-cloud-openclaw-plugin 0.1.1 → 0.1.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.
package/lib/sync.js CHANGED
@@ -8,47 +8,184 @@ function resolveRuntimeStateDir(api, fallbackStateDir = null) {
8
8
  }
9
9
  return fallbackStateDir;
10
10
  }
11
-
12
- function chunk(items, size) {
13
- const batches = [];
14
- for (let index = 0; index < items.length; index += size) {
15
- batches.push(items.slice(index, index + size));
16
- }
17
- return batches;
18
- }
19
-
20
- function buildEmptySummary(fileCount = 0) {
21
- return {
22
- file_count: fileCount,
23
- skipped_count: 0,
24
- new_source_count: 0,
25
- new_memory_count: 0,
26
- duplicate_count: 0,
27
- failed_file_count: 0,
28
- };
29
- }
30
-
11
+
12
+ function buildRunId() {
13
+ return `echo-sync-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
14
+ }
15
+
16
+ function buildEmptySummary(fileCount = 0) {
17
+ return {
18
+ file_count: fileCount,
19
+ skipped_count: 0,
20
+ new_source_count: 0,
21
+ new_memory_count: 0,
22
+ duplicate_count: 0,
23
+ failed_file_count: 0,
24
+ };
25
+ }
26
+
31
27
  function mergeSummary(target, next) {
32
- target.file_count += next.file_count ?? 0;
33
- target.skipped_count += next.skipped_count ?? 0;
34
- target.new_source_count += next.new_source_count ?? 0;
35
- target.new_memory_count += next.new_memory_count ?? 0;
36
- target.duplicate_count += next.duplicate_count ?? 0;
37
- target.failed_file_count += next.failed_file_count ?? 0;
28
+ target.file_count += next.file_count ?? 0;
29
+ target.skipped_count += next.skipped_count ?? 0;
30
+ target.new_source_count += next.new_source_count ?? 0;
31
+ target.new_memory_count += next.new_memory_count ?? 0;
32
+ target.duplicate_count += next.duplicate_count ?? 0;
33
+ target.failed_file_count += next.failed_file_count ?? 0;
38
34
  return target;
39
35
  }
40
36
 
37
+ function countStatuses(results = []) {
38
+ const counts = {
39
+ successCount: 0,
40
+ failedCount: 0,
41
+ skippedCount: 0,
42
+ duplicateCount: 0,
43
+ };
44
+
45
+ for (const result of results) {
46
+ if (!result?.status) continue;
47
+ if (result.status === "failed") {
48
+ counts.failedCount += 1;
49
+ continue;
50
+ }
51
+ if (result.status === "skipped") {
52
+ counts.skippedCount += 1;
53
+ counts.successCount += 1;
54
+ continue;
55
+ }
56
+ if (result.status === "duplicate") {
57
+ counts.duplicateCount += 1;
58
+ counts.successCount += 1;
59
+ continue;
60
+ }
61
+ counts.successCount += 1;
62
+ }
63
+
64
+ return counts;
65
+ }
66
+
67
+ function applyManualFileSummary(summary, status) {
68
+ summary.file_count += 1;
69
+ if (status === "failed") {
70
+ summary.failed_file_count += 1;
71
+ } else if (status === "skipped") {
72
+ summary.skipped_count += 1;
73
+ } else if (status === "duplicate") {
74
+ summary.duplicate_count += 1;
75
+ } else {
76
+ summary.new_source_count += 1;
77
+ }
78
+ }
79
+
80
+ function normalizeStatus(rawStatus, fallback = "imported") {
81
+ const normalized = String(rawStatus || "").trim().toLowerCase();
82
+ if (!normalized) return fallback;
83
+ if (["failed", "error"].includes(normalized)) return "failed";
84
+ if (["skipped", "unchanged"].includes(normalized)) return "skipped";
85
+ if (["duplicate", "deduped"].includes(normalized)) return "duplicate";
86
+ if (["imported", "processed", "success", "saved", "synced"].includes(normalized)) return "imported";
87
+ return fallback;
88
+ }
89
+
90
+ function selectMatchingResult(response, filePath) {
91
+ const results = Array.isArray(response?.results)
92
+ ? response.results
93
+ : Array.isArray(response?.file_results)
94
+ ? response.file_results
95
+ : [];
96
+ if (results.length === 0) {
97
+ return null;
98
+ }
99
+
100
+ const exactMatch = results.find((result) => {
101
+ const candidate = result?.file_path || result?.filePath || result?.path;
102
+ return candidate === filePath;
103
+ });
104
+ if (exactMatch) {
105
+ return exactMatch;
106
+ }
107
+
108
+ return results.length === 1 ? results[0] : null;
109
+ }
110
+
111
+ function buildFileResult({
112
+ file,
113
+ response = null,
114
+ attemptAt,
115
+ previousResult = null,
116
+ error = null,
117
+ }) {
118
+ const matchedResult = response ? selectMatchingResult(response, file.filePath) : null;
119
+ const rawError =
120
+ error
121
+ ?? matchedResult?.error
122
+ ?? matchedResult?.error_message
123
+ ?? matchedResult?.reason
124
+ ?? response?.error
125
+ ?? response?.message
126
+ ?? null;
127
+ const responseSummary = response?.summary ?? null;
128
+
129
+ let status = matchedResult?.status ? normalizeStatus(matchedResult.status) : null;
130
+ if (!status) {
131
+ if ((responseSummary?.failed_file_count ?? 0) > 0) {
132
+ status = "failed";
133
+ } else if ((responseSummary?.duplicate_count ?? 0) > 0) {
134
+ status = "duplicate";
135
+ } else if ((responseSummary?.skipped_count ?? 0) > 0) {
136
+ status = "skipped";
137
+ } else {
138
+ status = error ? "failed" : "imported";
139
+ }
140
+ }
141
+
142
+ const lastSuccessAt =
143
+ status === "failed"
144
+ ? previousResult?.lastSuccessAt ?? previousResult?.last_success_at ?? null
145
+ : attemptAt;
146
+ const lastSuccessfulContentHash =
147
+ status === "failed"
148
+ ? previousResult?.lastSuccessfulContentHash
149
+ ?? previousResult?.last_successful_content_hash
150
+ ?? previousResult?.contentHash
151
+ ?? previousResult?.content_hash
152
+ ?? null
153
+ : file.contentHash;
154
+
155
+ return {
156
+ filePath: file.filePath,
157
+ contentHash: file.contentHash,
158
+ status,
159
+ lastAttemptAt: attemptAt,
160
+ lastSuccessAt,
161
+ lastSuccessfulContentHash,
162
+ lastError: status === "failed" ? String(rawError || "Unknown import failure") : null,
163
+ stage: matchedResult?.stage ?? matchedResult?.stage_reached ?? null,
164
+ summary: {
165
+ file_count: responseSummary?.file_count ?? 1,
166
+ skipped_count: responseSummary?.skipped_count ?? (status === "skipped" ? 1 : 0),
167
+ new_source_count: responseSummary?.new_source_count ?? (status === "imported" ? 1 : 0),
168
+ new_memory_count: responseSummary?.new_memory_count ?? 0,
169
+ duplicate_count: responseSummary?.duplicate_count ?? (status === "duplicate" ? 1 : 0),
170
+ failed_file_count: responseSummary?.failed_file_count ?? (status === "failed" ? 1 : 0),
171
+ },
172
+ };
173
+ }
174
+
41
175
  function buildProgressPayload({
42
176
  phase,
177
+ runId,
43
178
  trigger,
44
179
  startedAt,
45
180
  totalFiles,
46
181
  completedFiles,
47
- batchIndex = 0,
48
- batchCount = 0,
182
+ currentFileIndex = 0,
183
+ currentFilePath = null,
184
+ currentStage = null,
49
185
  currentFilePaths = [],
50
186
  completedFilePaths = [],
51
187
  failedFilePaths = [],
188
+ runResults = [],
52
189
  error = null,
53
190
  }) {
54
191
  const startedAtMs = new Date(startedAt).getTime();
@@ -58,63 +195,73 @@ function buildProgressPayload({
58
195
  completedFiles > 0 && remainingFiles > 0
59
196
  ? Math.round((elapsedMs / completedFiles) * remainingFiles)
60
197
  : null;
198
+ const counts = countStatuses(runResults);
199
+ const recentFileResult = runResults.length > 0 ? runResults[runResults.length - 1] : null;
61
200
 
62
201
  return {
63
202
  phase,
203
+ runId,
64
204
  trigger,
65
205
  startedAt,
66
206
  totalFiles,
67
207
  completedFiles,
68
208
  remainingFiles,
69
- batchIndex,
70
- batchCount,
209
+ currentFileIndex,
210
+ currentFilePath,
211
+ currentStage,
71
212
  currentFilePaths,
72
213
  completedFilePaths,
73
214
  failedFilePaths,
74
215
  elapsedMs,
75
216
  etaMs,
217
+ successCount: counts.successCount,
218
+ failedCount: counts.failedCount,
219
+ skippedCount: counts.skippedCount,
220
+ duplicateCount: counts.duplicateCount,
221
+ recentFileResult,
76
222
  error,
77
223
  };
78
224
  }
79
-
80
- export function formatStatusText(localState, remoteStatus = null) {
81
- const lines = [];
82
- lines.push("Echo Memory status:");
83
-
84
- if (localState) {
85
- lines.push(`- last_sync_at: ${localState.finished_at || "(unknown)"}`);
86
- lines.push(`- last_sync_mode: ${localState.trigger || "(unknown)"}`);
87
- lines.push(`- files_scanned: ${localState.summary?.file_count ?? 0}`);
88
- lines.push(`- skipped: ${localState.summary?.skipped_count ?? 0}`);
89
- lines.push(`- new_sources: ${localState.summary?.new_source_count ?? 0}`);
90
- lines.push(`- new_memories: ${localState.summary?.new_memory_count ?? 0}`);
91
- lines.push(`- duplicates: ${localState.summary?.duplicate_count ?? 0}`);
92
- lines.push(`- failed_files: ${localState.summary?.failed_file_count ?? 0}`);
93
- if (localState.error) {
94
- lines.push(`- last_error: ${localState.error}`);
95
- }
96
- } else {
97
- lines.push("- last_sync_at: (none)");
98
- }
99
-
100
- if (remoteStatus) {
101
- lines.push("");
102
- lines.push("Echo backend:");
103
- lines.push(`- total_sources: ${remoteStatus.total_source_versions ?? 0}`);
104
- lines.push(`- processed_sources: ${remoteStatus.processed_source_versions ?? 0}`);
105
- lines.push(`- recent_memories: ${remoteStatus.recent_memory_count ?? 0}`);
106
- lines.push(`- latest_imported_at: ${remoteStatus.latest_imported_at || "(none)"}`);
107
- }
108
-
109
- return lines.join("\n");
110
- }
111
-
225
+
226
+ export function formatStatusText(localState, remoteStatus = null) {
227
+ const lines = [];
228
+ lines.push("Echo Memory status:");
229
+
230
+ if (localState) {
231
+ lines.push(`- last_sync_at: ${localState.finished_at || "(unknown)"}`);
232
+ lines.push(`- last_sync_mode: ${localState.trigger || "(unknown)"}`);
233
+ lines.push(`- files_scanned: ${localState.summary?.file_count ?? 0}`);
234
+ lines.push(`- skipped: ${localState.summary?.skipped_count ?? 0}`);
235
+ lines.push(`- new_sources: ${localState.summary?.new_source_count ?? 0}`);
236
+ lines.push(`- new_memories: ${localState.summary?.new_memory_count ?? 0}`);
237
+ lines.push(`- duplicates: ${localState.summary?.duplicate_count ?? 0}`);
238
+ lines.push(`- failed_files: ${localState.summary?.failed_file_count ?? 0}`);
239
+ if (localState.error) {
240
+ lines.push(`- last_error: ${localState.error}`);
241
+ }
242
+ } else {
243
+ lines.push("- last_sync_at: (none)");
244
+ }
245
+
246
+ if (remoteStatus) {
247
+ lines.push("");
248
+ lines.push("Echo backend:");
249
+ lines.push(`- total_sources: ${remoteStatus.total_source_versions ?? 0}`);
250
+ lines.push(`- processed_sources: ${remoteStatus.processed_source_versions ?? 0}`);
251
+ lines.push(`- recent_memories: ${remoteStatus.recent_memory_count ?? 0}`);
252
+ lines.push(`- latest_imported_at: ${remoteStatus.latest_imported_at || "(none)"}`);
253
+ }
254
+
255
+ return lines.join("\n");
256
+ }
257
+
112
258
  export function createSyncRunner({ api, cfg, client, fallbackStateDir = null }) {
113
259
  let intervalHandle = null;
114
260
  let statePath = null;
115
261
  let activeRun = null;
262
+ let activeRunInfo = null;
116
263
  const progressListeners = new Set();
117
-
264
+
118
265
  async function initialize(stateDir) {
119
266
  const resolvedStateDir = stateDir || resolveRuntimeStateDir(api, fallbackStateDir);
120
267
  if (!resolvedStateDir) {
@@ -148,43 +295,71 @@ export function createSyncRunner({ api, cfg, client, fallbackStateDir = null })
148
295
  progressListeners.add(listener);
149
296
  return () => progressListeners.delete(listener);
150
297
  }
151
-
152
- async function runSync(trigger = "manual", filterPaths = null) {
153
- if (activeRun) {
154
- return activeRun;
155
- }
156
-
298
+
299
+ async function runSync(trigger = "manual", filterPaths = null) {
300
+ if (activeRun) {
301
+ return activeRun;
302
+ }
303
+
157
304
  activeRun = (async () => {
305
+ const runId = buildRunId();
158
306
  const startedAt = new Date().toISOString();
307
+ activeRunInfo = { runId, trigger, startedAt };
308
+
159
309
  let totalFiles = 0;
160
- let batchCount = 0;
161
310
  let completedFiles = 0;
162
- let currentBatchPaths = [];
311
+ let currentFilePath = null;
312
+ let currentFileIndex = 0;
313
+ const successfulPaths = [];
314
+ const failedPaths = [];
315
+ const runResults = [];
316
+
317
+ let prevState = null;
318
+ let prevResults = [];
319
+ const resultMap = new Map();
320
+
321
+ try {
322
+ prevState = await readLastSyncState(getStatePath());
323
+ prevResults = Array.isArray(prevState?.results) ? prevState.results : [];
324
+ for (const entry of prevResults) {
325
+ const key = entry?.filePath || entry?.file_path;
326
+ if (!key) continue;
327
+ resultMap.set(key, entry);
328
+ }
329
+ } catch {
330
+ prevState = null;
331
+ prevResults = [];
332
+ }
333
+
163
334
  try {
164
335
  if (!cfg.apiKey) {
336
+ const message = "Missing Echo API key";
165
337
  emitProgress(buildProgressPayload({
166
338
  phase: "failed",
339
+ runId,
167
340
  trigger,
168
341
  startedAt,
169
342
  totalFiles: 0,
170
343
  completedFiles: 0,
171
- error: "Missing Echo API key",
344
+ runResults,
345
+ error: message,
172
346
  }));
173
347
  const state = {
174
- trigger,
175
- started_at: startedAt,
176
- finished_at: new Date().toISOString(),
177
- error: "Missing Echo API key",
178
- summary: buildEmptySummary(0),
179
- results: [],
180
- };
181
- await writeLastSyncState(getStatePath(), state);
182
- return state;
183
- }
184
-
185
- await client.whoami();
186
-
187
- let files = [];
348
+ trigger,
349
+ started_at: startedAt,
350
+ finished_at: new Date().toISOString(),
351
+ error: message,
352
+ summary: buildEmptySummary(0),
353
+ results: prevResults,
354
+ run_results: [],
355
+ };
356
+ await writeLastSyncState(getStatePath(), state);
357
+ return state;
358
+ }
359
+
360
+ await client.whoami();
361
+
362
+ let files = [];
188
363
  try {
189
364
  files = await scanOpenClawMemoryDir(cfg.memoryDir);
190
365
  } catch (error) {
@@ -193,182 +368,280 @@ export function createSyncRunner({ api, cfg, client, fallbackStateDir = null })
193
368
  : String(error?.message ?? error);
194
369
  emitProgress(buildProgressPayload({
195
370
  phase: "failed",
371
+ runId,
196
372
  trigger,
197
373
  startedAt,
198
374
  totalFiles: 0,
199
375
  completedFiles: 0,
376
+ runResults,
200
377
  error: message,
201
378
  }));
202
379
  const state = {
203
- trigger,
204
- started_at: startedAt,
205
- finished_at: new Date().toISOString(),
206
- error: message,
207
- summary: buildEmptySummary(0),
208
- results: [],
209
- };
210
- await writeLastSyncState(getStatePath(), state);
211
- return state;
212
- }
213
-
214
- // If filterPaths is provided (Set of absolute paths), only sync those files
215
- if (filterPaths instanceof Set && filterPaths.size > 0) {
216
- files = files.filter(f => filterPaths.has(f.filePath));
217
- }
218
-
219
- if (files.length === 0) {
380
+ trigger,
381
+ started_at: startedAt,
382
+ finished_at: new Date().toISOString(),
383
+ error: message,
384
+ summary: buildEmptySummary(0),
385
+ results: prevResults,
386
+ run_results: [],
387
+ };
388
+ await writeLastSyncState(getStatePath(), state);
389
+ return state;
390
+ }
391
+
392
+ if (filterPaths instanceof Set && filterPaths.size > 0) {
393
+ files = files.filter((file) => filterPaths.has(file.filePath));
394
+ }
395
+
396
+ totalFiles = files.length;
397
+ if (totalFiles === 0) {
220
398
  emitProgress(buildProgressPayload({
221
399
  phase: "finished",
400
+ runId,
222
401
  trigger,
223
402
  startedAt,
224
403
  totalFiles: 0,
225
404
  completedFiles: 0,
405
+ runResults,
226
406
  }));
227
407
  const state = {
228
- trigger,
229
- started_at: startedAt,
230
- finished_at: new Date().toISOString(),
231
- summary: buildEmptySummary(0),
232
- results: [],
233
- };
234
- await writeLastSyncState(getStatePath(), state);
235
- return state;
236
- }
237
-
238
- const summary = buildEmptySummary();
239
- const batches = chunk(files, cfg.batchSize);
240
- totalFiles = files.length;
241
- batchCount = batches.length;
408
+ trigger,
409
+ started_at: startedAt,
410
+ finished_at: new Date().toISOString(),
411
+ summary: buildEmptySummary(0),
412
+ results: prevResults,
413
+ run_results: [],
414
+ };
415
+ await writeLastSyncState(getStatePath(), state);
416
+ return state;
417
+ }
242
418
 
243
419
  emitProgress(buildProgressPayload({
244
420
  phase: "started",
421
+ runId,
245
422
  trigger,
246
423
  startedAt,
247
424
  totalFiles,
248
425
  completedFiles: 0,
249
- batchCount,
250
426
  currentFilePaths: files.map((file) => file.filePath),
427
+ runResults,
251
428
  }));
252
429
 
253
- for (const [index, batch] of batches.entries()) {
254
- currentBatchPaths = batch.map((file) => file.filePath);
255
- emitProgress(buildProgressPayload({
256
- phase: "batch-started",
257
- trigger,
258
- startedAt,
259
- totalFiles,
260
- completedFiles,
261
- batchIndex: index + 1,
262
- batchCount,
263
- currentFilePaths: currentBatchPaths,
264
- }));
265
- const response = await client.importMarkdown(batch);
266
- mergeSummary(summary, response.summary ?? {});
267
- completedFiles += batch.length;
430
+ const summary = buildEmptySummary(0);
431
+
432
+ for (const [index, file] of files.entries()) {
433
+ currentFileIndex = index + 1;
434
+ currentFilePath = file.filePath;
268
435
  emitProgress(buildProgressPayload({
269
- phase: "batch-finished",
436
+ phase: "file-started",
437
+ runId,
270
438
  trigger,
271
439
  startedAt,
272
440
  totalFiles,
273
441
  completedFiles,
274
- batchIndex: index + 1,
275
- batchCount,
276
- completedFilePaths: currentBatchPaths,
442
+ currentFileIndex,
443
+ currentFilePath,
444
+ currentStage: "parse",
445
+ currentFilePaths: [file.filePath],
446
+ completedFilePaths: successfulPaths,
447
+ failedFilePaths: failedPaths,
448
+ runResults,
277
449
  }));
450
+
451
+ const attemptAt = new Date().toISOString();
452
+ const previousResult = resultMap.get(file.filePath) ?? null;
453
+
454
+ try {
455
+ const response = await client.importMarkdown([file], {
456
+ onStageEvent: (stageEvent) => {
457
+ emitProgress(buildProgressPayload({
458
+ phase: "file-stage",
459
+ runId,
460
+ trigger,
461
+ startedAt,
462
+ totalFiles,
463
+ completedFiles,
464
+ currentFileIndex,
465
+ currentFilePath,
466
+ currentStage: stageEvent?.stage || null,
467
+ currentFilePaths: [file.filePath],
468
+ completedFilePaths: successfulPaths,
469
+ failedFilePaths: failedPaths,
470
+ runResults,
471
+ }));
472
+ },
473
+ });
474
+ if (response?.summary) {
475
+ mergeSummary(summary, response.summary);
476
+ }
477
+
478
+ const fileResult = buildFileResult({
479
+ file,
480
+ response,
481
+ attemptAt,
482
+ previousResult,
483
+ });
484
+
485
+ if (!response?.summary) {
486
+ applyManualFileSummary(summary, fileResult.status);
487
+ }
488
+
489
+ runResults.push(fileResult);
490
+ resultMap.set(file.filePath, fileResult);
491
+ completedFiles += 1;
492
+
493
+ if (fileResult.status === "failed") {
494
+ failedPaths.push(file.filePath);
495
+ } else {
496
+ successfulPaths.push(file.filePath);
497
+ }
498
+
499
+ emitProgress(buildProgressPayload({
500
+ phase: "file-finished",
501
+ runId,
502
+ trigger,
503
+ startedAt,
504
+ totalFiles,
505
+ completedFiles,
506
+ currentFileIndex,
507
+ currentFilePath,
508
+ currentStage: fileResult.stage,
509
+ currentFilePaths: [file.filePath],
510
+ completedFilePaths: fileResult.status === "failed" ? [] : [file.filePath],
511
+ failedFilePaths: fileResult.status === "failed" ? [file.filePath] : [],
512
+ runResults,
513
+ }));
514
+ } catch (error) {
515
+ const fileResult = buildFileResult({
516
+ file,
517
+ attemptAt,
518
+ previousResult,
519
+ error: String(error?.message ?? error),
520
+ });
521
+
522
+ applyManualFileSummary(summary, fileResult.status);
523
+ runResults.push(fileResult);
524
+ resultMap.set(file.filePath, fileResult);
525
+ completedFiles += 1;
526
+ failedPaths.push(file.filePath);
527
+
528
+ emitProgress(buildProgressPayload({
529
+ phase: "file-finished",
530
+ runId,
531
+ trigger,
532
+ startedAt,
533
+ totalFiles,
534
+ completedFiles,
535
+ currentFileIndex,
536
+ currentFilePath,
537
+ currentStage: fileResult.stage,
538
+ currentFilePaths: [file.filePath],
539
+ failedFilePaths: [file.filePath],
540
+ runResults,
541
+ }));
542
+ }
278
543
  }
279
-
280
- const newResults = files.map((f) => ({ filePath: f.filePath, contentHash: f.contentHash, status: "imported" }));
281
-
282
- // Merge with existing state — preserve previously synced files
283
- let mergedResults = newResults;
284
- if (filterPaths instanceof Set && filterPaths.size > 0) {
285
- const prevState = await readLastSyncState(getStatePath());
286
- const prevResults = Array.isArray(prevState?.results) ? prevState.results : [];
287
- // Keep previous results that weren't in this batch, add new ones
288
- const newPathSet = new Set(newResults.map(r => r.filePath));
289
- mergedResults = [
290
- ...prevResults.filter(r => !newPathSet.has(r.filePath || r.file_path)),
291
- ...newResults,
292
- ];
293
- }
294
-
544
+
545
+ const mergedResults = [...resultMap.values()];
295
546
  const state = {
296
- trigger,
297
- started_at: startedAt,
298
- finished_at: new Date().toISOString(),
299
- summary,
300
- results: mergedResults,
547
+ trigger,
548
+ started_at: startedAt,
549
+ finished_at: new Date().toISOString(),
550
+ summary,
551
+ results: mergedResults,
552
+ run_results: runResults,
301
553
  };
302
554
  await writeLastSyncState(getStatePath(), state);
555
+
303
556
  emitProgress(buildProgressPayload({
304
557
  phase: "finished",
558
+ runId,
305
559
  trigger,
306
560
  startedAt,
307
561
  totalFiles,
308
562
  completedFiles,
309
- batchIndex: batchCount,
310
- batchCount,
311
- completedFilePaths: files.map((file) => file.filePath),
563
+ currentFileIndex,
564
+ currentFilePath,
565
+ completedFilePaths: successfulPaths,
566
+ failedFilePaths: failedPaths,
567
+ runResults,
312
568
  }));
569
+
313
570
  api.logger?.info?.(
314
- `[echo-memory] sync complete: files=${summary.file_count} new_memories=${summary.new_memory_count} skipped=${summary.skipped_count} failed=${summary.failed_file_count}`,
315
- );
316
- return state;
571
+ `[echo-memory] sync complete: files=${summary.file_count} new_memories=${summary.new_memory_count} skipped=${summary.skipped_count} failed=${summary.failed_file_count}`,
572
+ );
573
+ return state;
317
574
  } catch (error) {
318
575
  const message = String(error?.message ?? error);
319
576
  emitProgress(buildProgressPayload({
320
577
  phase: "failed",
578
+ runId,
321
579
  trigger,
322
580
  startedAt,
323
581
  totalFiles,
324
582
  completedFiles,
325
- batchCount,
326
- currentFilePaths: currentBatchPaths,
327
- failedFilePaths: currentBatchPaths,
583
+ currentFileIndex,
584
+ currentFilePath,
585
+ currentFilePaths: currentFilePath ? [currentFilePath] : [],
586
+ failedFilePaths: currentFilePath ? [currentFilePath] : [],
587
+ runResults,
328
588
  error: message,
329
589
  }));
590
+
330
591
  const state = {
331
- trigger,
332
- started_at: startedAt,
333
- finished_at: new Date().toISOString(),
334
- error: message,
335
- summary: buildEmptySummary(0),
336
- results: [],
337
- };
338
- await writeLastSyncState(getStatePath(), state);
339
- return state;
340
- }
341
- })().finally(() => {
342
- activeRun = null;
343
- });
344
-
345
- return activeRun;
346
- }
347
-
348
- function startInterval() {
349
- stopInterval();
350
- const intervalMs = cfg.syncIntervalMinutes * 60 * 1000;
351
- intervalHandle = setInterval(() => {
352
- runSync("scheduled").catch((error) => {
353
- api.logger?.warn?.(`[echo-memory] scheduled sync failed: ${String(error?.message ?? error)}`);
354
- });
355
- }, intervalMs);
356
- intervalHandle.unref?.();
357
- }
358
-
359
- function stopInterval() {
360
- if (intervalHandle) {
361
- clearInterval(intervalHandle);
362
- intervalHandle = null;
363
- }
364
- }
365
-
592
+ trigger,
593
+ started_at: startedAt,
594
+ finished_at: new Date().toISOString(),
595
+ error: message,
596
+ summary: buildEmptySummary(0),
597
+ results: [...resultMap.values()],
598
+ run_results: runResults,
599
+ };
600
+ await writeLastSyncState(getStatePath(), state);
601
+ return state;
602
+ }
603
+ })().finally(() => {
604
+ activeRun = null;
605
+ activeRunInfo = null;
606
+ });
607
+
608
+ return activeRun;
609
+ }
610
+
611
+ function startInterval() {
612
+ stopInterval();
613
+ const intervalMs = cfg.syncIntervalMinutes * 60 * 1000;
614
+ intervalHandle = setInterval(() => {
615
+ runSync("scheduled").catch((error) => {
616
+ api.logger?.warn?.(`[echo-memory] scheduled sync failed: ${String(error?.message ?? error)}`);
617
+ });
618
+ }, intervalMs);
619
+ intervalHandle.unref?.();
620
+ }
621
+
622
+ function stopInterval() {
623
+ if (intervalHandle) {
624
+ clearInterval(intervalHandle);
625
+ intervalHandle = null;
626
+ }
627
+ }
628
+
629
+ function isRunning() {
630
+ return Boolean(activeRun);
631
+ }
632
+
633
+ function getActiveRunInfo() {
634
+ return activeRunInfo;
635
+ }
636
+
366
637
  return {
367
638
  initialize,
368
639
  getStatePath,
369
640
  onProgress,
370
641
  runSync,
371
- startInterval,
372
- stopInterval,
373
- };
374
- }
642
+ startInterval,
643
+ stopInterval,
644
+ isRunning,
645
+ getActiveRunInfo,
646
+ };
647
+ }