@echomem/echo-memory-cloud-openclaw-plugin 0.1.0 → 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
@@ -1,46 +1,191 @@
1
- import { scanOpenClawMemoryDir } from "./openclaw-memory-scan.js";
2
- import { resolveStatePath, readLastSyncState, writeLastSyncState } from "./state.js";
3
-
4
- function chunk(items, size) {
5
- const batches = [];
6
- for (let index = 0; index < items.length; index += size) {
7
- batches.push(items.slice(index, index + size));
8
- }
9
- return batches;
10
- }
11
-
12
- function buildEmptySummary(fileCount = 0) {
13
- return {
14
- file_count: fileCount,
15
- skipped_count: 0,
16
- new_source_count: 0,
17
- new_memory_count: 0,
18
- duplicate_count: 0,
19
- failed_file_count: 0,
20
- };
21
- }
22
-
1
+ import { scanOpenClawMemoryDir } from "./openclaw-memory-scan.js";
2
+ import { resolveStatePath, readLastSyncState, writeLastSyncState } from "./state.js";
3
+
4
+ function resolveRuntimeStateDir(api, fallbackStateDir = null) {
5
+ const runtimeStateDir = api?.runtime?.state?.resolveStateDir?.();
6
+ if (runtimeStateDir) {
7
+ return runtimeStateDir;
8
+ }
9
+ return fallbackStateDir;
10
+ }
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
+
23
27
  function mergeSummary(target, next) {
24
- target.file_count += next.file_count ?? 0;
25
- target.skipped_count += next.skipped_count ?? 0;
26
- target.new_source_count += next.new_source_count ?? 0;
27
- target.new_memory_count += next.new_memory_count ?? 0;
28
- target.duplicate_count += next.duplicate_count ?? 0;
29
- 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;
30
34
  return target;
31
35
  }
32
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
+
33
175
  function buildProgressPayload({
34
176
  phase,
177
+ runId,
35
178
  trigger,
36
179
  startedAt,
37
180
  totalFiles,
38
181
  completedFiles,
39
- batchIndex = 0,
40
- batchCount = 0,
182
+ currentFileIndex = 0,
183
+ currentFilePath = null,
184
+ currentStage = null,
41
185
  currentFilePaths = [],
42
186
  completedFilePaths = [],
43
187
  failedFilePaths = [],
188
+ runResults = [],
44
189
  error = null,
45
190
  }) {
46
191
  const startedAtMs = new Date(startedAt).getTime();
@@ -50,69 +195,90 @@ function buildProgressPayload({
50
195
  completedFiles > 0 && remainingFiles > 0
51
196
  ? Math.round((elapsedMs / completedFiles) * remainingFiles)
52
197
  : null;
198
+ const counts = countStatuses(runResults);
199
+ const recentFileResult = runResults.length > 0 ? runResults[runResults.length - 1] : null;
53
200
 
54
201
  return {
55
202
  phase,
203
+ runId,
56
204
  trigger,
57
205
  startedAt,
58
206
  totalFiles,
59
207
  completedFiles,
60
208
  remainingFiles,
61
- batchIndex,
62
- batchCount,
209
+ currentFileIndex,
210
+ currentFilePath,
211
+ currentStage,
63
212
  currentFilePaths,
64
213
  completedFilePaths,
65
214
  failedFilePaths,
66
215
  elapsedMs,
67
216
  etaMs,
217
+ successCount: counts.successCount,
218
+ failedCount: counts.failedCount,
219
+ skippedCount: counts.skippedCount,
220
+ duplicateCount: counts.duplicateCount,
221
+ recentFileResult,
68
222
  error,
69
223
  };
70
224
  }
71
-
72
- export function formatStatusText(localState, remoteStatus = null) {
73
- const lines = [];
74
- lines.push("Echo Memory status:");
75
-
76
- if (localState) {
77
- lines.push(`- last_sync_at: ${localState.finished_at || "(unknown)"}`);
78
- lines.push(`- last_sync_mode: ${localState.trigger || "(unknown)"}`);
79
- lines.push(`- files_scanned: ${localState.summary?.file_count ?? 0}`);
80
- lines.push(`- skipped: ${localState.summary?.skipped_count ?? 0}`);
81
- lines.push(`- new_sources: ${localState.summary?.new_source_count ?? 0}`);
82
- lines.push(`- new_memories: ${localState.summary?.new_memory_count ?? 0}`);
83
- lines.push(`- duplicates: ${localState.summary?.duplicate_count ?? 0}`);
84
- lines.push(`- failed_files: ${localState.summary?.failed_file_count ?? 0}`);
85
- if (localState.error) {
86
- lines.push(`- last_error: ${localState.error}`);
87
- }
88
- } else {
89
- lines.push("- last_sync_at: (none)");
90
- }
91
-
92
- if (remoteStatus) {
93
- lines.push("");
94
- lines.push("Echo backend:");
95
- lines.push(`- total_sources: ${remoteStatus.total_source_versions ?? 0}`);
96
- lines.push(`- processed_sources: ${remoteStatus.processed_source_versions ?? 0}`);
97
- lines.push(`- recent_memories: ${remoteStatus.recent_memory_count ?? 0}`);
98
- lines.push(`- latest_imported_at: ${remoteStatus.latest_imported_at || "(none)"}`);
99
- }
100
-
101
- return lines.join("\n");
102
- }
103
-
104
- export function createSyncRunner({ api, cfg, client }) {
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
+
258
+ export function createSyncRunner({ api, cfg, client, fallbackStateDir = null }) {
105
259
  let intervalHandle = null;
106
260
  let statePath = null;
107
261
  let activeRun = null;
262
+ let activeRunInfo = null;
108
263
  const progressListeners = new Set();
109
-
110
- async function initialize(stateDir) {
111
- statePath = resolveStatePath(stateDir || api.runtime.state.resolveStateDir());
112
- }
113
-
264
+
265
+ async function initialize(stateDir) {
266
+ const resolvedStateDir = stateDir || resolveRuntimeStateDir(api, fallbackStateDir);
267
+ if (!resolvedStateDir) {
268
+ throw new Error("Echo memory state directory is unavailable");
269
+ }
270
+ statePath = resolveStatePath(resolvedStateDir);
271
+ }
272
+
114
273
  function getStatePath() {
115
- return statePath || resolveStatePath(api.runtime.state.resolveStateDir());
274
+ if (statePath) {
275
+ return statePath;
276
+ }
277
+ const resolvedStateDir = resolveRuntimeStateDir(api, fallbackStateDir);
278
+ if (!resolvedStateDir) {
279
+ throw new Error("Echo memory state directory is unavailable");
280
+ }
281
+ return resolveStatePath(resolvedStateDir);
116
282
  }
117
283
 
118
284
  function emitProgress(event) {
@@ -129,43 +295,71 @@ export function createSyncRunner({ api, cfg, client }) {
129
295
  progressListeners.add(listener);
130
296
  return () => progressListeners.delete(listener);
131
297
  }
132
-
133
- async function runSync(trigger = "manual", filterPaths = null) {
134
- if (activeRun) {
135
- return activeRun;
136
- }
137
-
298
+
299
+ async function runSync(trigger = "manual", filterPaths = null) {
300
+ if (activeRun) {
301
+ return activeRun;
302
+ }
303
+
138
304
  activeRun = (async () => {
305
+ const runId = buildRunId();
139
306
  const startedAt = new Date().toISOString();
307
+ activeRunInfo = { runId, trigger, startedAt };
308
+
140
309
  let totalFiles = 0;
141
- let batchCount = 0;
142
310
  let completedFiles = 0;
143
- 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
+
144
334
  try {
145
335
  if (!cfg.apiKey) {
336
+ const message = "Missing Echo API key";
146
337
  emitProgress(buildProgressPayload({
147
338
  phase: "failed",
339
+ runId,
148
340
  trigger,
149
341
  startedAt,
150
342
  totalFiles: 0,
151
343
  completedFiles: 0,
152
- error: "Missing Echo API key",
344
+ runResults,
345
+ error: message,
153
346
  }));
154
347
  const state = {
155
- trigger,
156
- started_at: startedAt,
157
- finished_at: new Date().toISOString(),
158
- error: "Missing Echo API key",
159
- summary: buildEmptySummary(0),
160
- results: [],
161
- };
162
- await writeLastSyncState(getStatePath(), state);
163
- return state;
164
- }
165
-
166
- await client.whoami();
167
-
168
- 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 = [];
169
363
  try {
170
364
  files = await scanOpenClawMemoryDir(cfg.memoryDir);
171
365
  } catch (error) {
@@ -174,182 +368,280 @@ export function createSyncRunner({ api, cfg, client }) {
174
368
  : String(error?.message ?? error);
175
369
  emitProgress(buildProgressPayload({
176
370
  phase: "failed",
371
+ runId,
177
372
  trigger,
178
373
  startedAt,
179
374
  totalFiles: 0,
180
375
  completedFiles: 0,
376
+ runResults,
181
377
  error: message,
182
378
  }));
183
379
  const state = {
184
- trigger,
185
- started_at: startedAt,
186
- finished_at: new Date().toISOString(),
187
- error: message,
188
- summary: buildEmptySummary(0),
189
- results: [],
190
- };
191
- await writeLastSyncState(getStatePath(), state);
192
- return state;
193
- }
194
-
195
- // If filterPaths is provided (Set of absolute paths), only sync those files
196
- if (filterPaths instanceof Set && filterPaths.size > 0) {
197
- files = files.filter(f => filterPaths.has(f.filePath));
198
- }
199
-
200
- 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) {
201
398
  emitProgress(buildProgressPayload({
202
399
  phase: "finished",
400
+ runId,
203
401
  trigger,
204
402
  startedAt,
205
403
  totalFiles: 0,
206
404
  completedFiles: 0,
405
+ runResults,
207
406
  }));
208
407
  const state = {
209
- trigger,
210
- started_at: startedAt,
211
- finished_at: new Date().toISOString(),
212
- summary: buildEmptySummary(0),
213
- results: [],
214
- };
215
- await writeLastSyncState(getStatePath(), state);
216
- return state;
217
- }
218
-
219
- const summary = buildEmptySummary();
220
- const batches = chunk(files, cfg.batchSize);
221
- totalFiles = files.length;
222
- 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
+ }
223
418
 
224
419
  emitProgress(buildProgressPayload({
225
420
  phase: "started",
421
+ runId,
226
422
  trigger,
227
423
  startedAt,
228
424
  totalFiles,
229
425
  completedFiles: 0,
230
- batchCount,
231
426
  currentFilePaths: files.map((file) => file.filePath),
427
+ runResults,
232
428
  }));
233
429
 
234
- for (const [index, batch] of batches.entries()) {
235
- currentBatchPaths = batch.map((file) => file.filePath);
236
- emitProgress(buildProgressPayload({
237
- phase: "batch-started",
238
- trigger,
239
- startedAt,
240
- totalFiles,
241
- completedFiles,
242
- batchIndex: index + 1,
243
- batchCount,
244
- currentFilePaths: currentBatchPaths,
245
- }));
246
- const response = await client.importMarkdown(batch);
247
- mergeSummary(summary, response.summary ?? {});
248
- 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;
249
435
  emitProgress(buildProgressPayload({
250
- phase: "batch-finished",
436
+ phase: "file-started",
437
+ runId,
251
438
  trigger,
252
439
  startedAt,
253
440
  totalFiles,
254
441
  completedFiles,
255
- batchIndex: index + 1,
256
- batchCount,
257
- completedFilePaths: currentBatchPaths,
442
+ currentFileIndex,
443
+ currentFilePath,
444
+ currentStage: "parse",
445
+ currentFilePaths: [file.filePath],
446
+ completedFilePaths: successfulPaths,
447
+ failedFilePaths: failedPaths,
448
+ runResults,
258
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
+ }
259
543
  }
260
-
261
- const newResults = files.map((f) => ({ filePath: f.filePath, contentHash: f.contentHash, status: "imported" }));
262
-
263
- // Merge with existing state — preserve previously synced files
264
- let mergedResults = newResults;
265
- if (filterPaths instanceof Set && filterPaths.size > 0) {
266
- const prevState = await readLastSyncState(getStatePath());
267
- const prevResults = Array.isArray(prevState?.results) ? prevState.results : [];
268
- // Keep previous results that weren't in this batch, add new ones
269
- const newPathSet = new Set(newResults.map(r => r.filePath));
270
- mergedResults = [
271
- ...prevResults.filter(r => !newPathSet.has(r.filePath || r.file_path)),
272
- ...newResults,
273
- ];
274
- }
275
-
544
+
545
+ const mergedResults = [...resultMap.values()];
276
546
  const state = {
277
- trigger,
278
- started_at: startedAt,
279
- finished_at: new Date().toISOString(),
280
- summary,
281
- results: mergedResults,
547
+ trigger,
548
+ started_at: startedAt,
549
+ finished_at: new Date().toISOString(),
550
+ summary,
551
+ results: mergedResults,
552
+ run_results: runResults,
282
553
  };
283
554
  await writeLastSyncState(getStatePath(), state);
555
+
284
556
  emitProgress(buildProgressPayload({
285
557
  phase: "finished",
558
+ runId,
286
559
  trigger,
287
560
  startedAt,
288
561
  totalFiles,
289
562
  completedFiles,
290
- batchIndex: batchCount,
291
- batchCount,
292
- completedFilePaths: files.map((file) => file.filePath),
563
+ currentFileIndex,
564
+ currentFilePath,
565
+ completedFilePaths: successfulPaths,
566
+ failedFilePaths: failedPaths,
567
+ runResults,
293
568
  }));
569
+
294
570
  api.logger?.info?.(
295
- `[echo-memory] sync complete: files=${summary.file_count} new_memories=${summary.new_memory_count} skipped=${summary.skipped_count} failed=${summary.failed_file_count}`,
296
- );
297
- 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;
298
574
  } catch (error) {
299
575
  const message = String(error?.message ?? error);
300
576
  emitProgress(buildProgressPayload({
301
577
  phase: "failed",
578
+ runId,
302
579
  trigger,
303
580
  startedAt,
304
581
  totalFiles,
305
582
  completedFiles,
306
- batchCount,
307
- currentFilePaths: currentBatchPaths,
308
- failedFilePaths: currentBatchPaths,
583
+ currentFileIndex,
584
+ currentFilePath,
585
+ currentFilePaths: currentFilePath ? [currentFilePath] : [],
586
+ failedFilePaths: currentFilePath ? [currentFilePath] : [],
587
+ runResults,
309
588
  error: message,
310
589
  }));
590
+
311
591
  const state = {
312
- trigger,
313
- started_at: startedAt,
314
- finished_at: new Date().toISOString(),
315
- error: message,
316
- summary: buildEmptySummary(0),
317
- results: [],
318
- };
319
- await writeLastSyncState(getStatePath(), state);
320
- return state;
321
- }
322
- })().finally(() => {
323
- activeRun = null;
324
- });
325
-
326
- return activeRun;
327
- }
328
-
329
- function startInterval() {
330
- stopInterval();
331
- const intervalMs = cfg.syncIntervalMinutes * 60 * 1000;
332
- intervalHandle = setInterval(() => {
333
- runSync("scheduled").catch((error) => {
334
- api.logger?.warn?.(`[echo-memory] scheduled sync failed: ${String(error?.message ?? error)}`);
335
- });
336
- }, intervalMs);
337
- intervalHandle.unref?.();
338
- }
339
-
340
- function stopInterval() {
341
- if (intervalHandle) {
342
- clearInterval(intervalHandle);
343
- intervalHandle = null;
344
- }
345
- }
346
-
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
+
347
637
  return {
348
638
  initialize,
349
639
  getStatePath,
350
640
  onProgress,
351
641
  runSync,
352
- startInterval,
353
- stopInterval,
354
- };
355
- }
642
+ startInterval,
643
+ stopInterval,
644
+ isRunning,
645
+ getActiveRunInfo,
646
+ };
647
+ }