@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.
@@ -75,6 +75,44 @@ function formatDuration(ms) {
75
75
  return `${hours}h ${minutes % 60}m`;
76
76
  }
77
77
 
78
+ function basenameFromPath(relativePath) {
79
+ if (!relativePath) return '';
80
+ const parts = String(relativePath).split(/[\\/]/);
81
+ return parts[parts.length - 1] || relativePath;
82
+ }
83
+
84
+ function formatStageLabel(stage) {
85
+ if (!stage) return null;
86
+ const normalized = String(stage).trim().toLowerCase();
87
+ if (!normalized) return null;
88
+ return normalized.replace(/[_-]+/g, ' ');
89
+ }
90
+
91
+ function buildSyncResultState(result) {
92
+ const summary = result?.summary || {};
93
+ const runResults = Array.isArray(result?.run_results) ? result.run_results : [];
94
+ const failed = runResults.filter((item) => item?.status === 'failed');
95
+ const parts = [];
96
+ if (summary.new_memory_count > 0) parts.push(`${summary.new_memory_count} new memories`);
97
+ if (summary.new_source_count > 0) parts.push(`${summary.new_source_count} files uploaded`);
98
+ if (summary.skipped_count > 0) parts.push(`${summary.skipped_count} already synced`);
99
+ if (summary.duplicate_count > 0) parts.push(`${summary.duplicate_count} duplicates`);
100
+ if (summary.failed_file_count > 0) parts.push(`${summary.failed_file_count} failed`);
101
+
102
+ let msg = parts.join(' | ') || 'Sync complete';
103
+ if (failed.length > 0 && failed.length === runResults.length && runResults.length > 0) {
104
+ msg = `All ${failed.length} selected file${failed.length === 1 ? '' : 's'} failed`;
105
+ } else if (failed.length > 0) {
106
+ msg = `Partial failure | ${msg}`;
107
+ }
108
+
109
+ return {
110
+ ok: failed.length === 0,
111
+ msg,
112
+ failed,
113
+ };
114
+ }
115
+
78
116
  export default function App() {
79
117
  const [files, setFiles] = useState([]);
80
118
  const [contentMap, setContentMap] = useState(null);
@@ -166,10 +204,10 @@ export default function App() {
166
204
  return;
167
205
  }
168
206
 
169
- if (progress.phase === 'batch-started') {
207
+ if (progress.phase === 'file-started') {
170
208
  setCardSyncState((prev) => {
171
209
  const next = { ...prev };
172
- for (const path of progress.currentRelativePaths || []) {
210
+ for (const path of progress.currentRelativePaths || (progress.currentRelativePath ? [progress.currentRelativePath] : [])) {
173
211
  next[path] = 'syncing';
174
212
  }
175
213
  return next;
@@ -177,11 +215,28 @@ export default function App() {
177
215
  return;
178
216
  }
179
217
 
180
- if (progress.phase === 'batch-finished') {
218
+ if (progress.phase === 'file-stage') {
181
219
  setCardSyncState((prev) => {
182
220
  const next = { ...prev };
183
- for (const path of progress.completedRelativePaths || []) {
184
- next[path] = 'done';
221
+ for (const path of progress.currentRelativePaths || (progress.currentRelativePath ? [progress.currentRelativePath] : [])) {
222
+ next[path] = 'syncing';
223
+ }
224
+ return next;
225
+ });
226
+ return;
227
+ }
228
+
229
+ if (progress.phase === 'file-finished') {
230
+ setCardSyncState((prev) => {
231
+ const next = { ...prev };
232
+ const recentStatus = progress.recentFileResult?.status;
233
+ const completedPaths = progress.completedRelativePaths || [];
234
+ const failedPaths = progress.failedRelativePaths || [];
235
+ for (const path of completedPaths) {
236
+ next[path] = recentStatus === 'failed' ? 'failed' : 'done';
237
+ }
238
+ for (const path of failedPaths) {
239
+ next[path] = 'failed';
185
240
  }
186
241
  return next;
187
242
  });
@@ -257,6 +312,14 @@ export default function App() {
257
312
  return computeSystemLayout(layout.systemFiles || [], vpWidth, contentMap);
258
313
  }, [view, layout.systemFiles, vpWidth, contentMap]);
259
314
 
315
+ const syncMetaByPath = useMemo(() => {
316
+ const next = {};
317
+ for (const status of syncStatus?.fileStatuses || []) {
318
+ next[status.relativePath] = status;
319
+ }
320
+ return next;
321
+ }, [syncStatus]);
322
+
260
323
  const syncMap = useMemo(() => {
261
324
  const next = {};
262
325
 
@@ -274,7 +337,7 @@ export default function App() {
274
337
  );
275
338
  for (const file of files) {
276
339
  const normalized = normalizePathKey(file.absolutePath || file.filePath);
277
- if (normalized && backendPaths.has(normalized)) {
340
+ if (normalized && backendPaths.has(normalized) && !next[file.relativePath]) {
278
341
  next[file.relativePath] = 'synced';
279
342
  }
280
343
  }
@@ -291,6 +354,16 @@ export default function App() {
291
354
  return next;
292
355
  }, [syncStatus, backendSources, files]);
293
356
 
357
+ const selectablePaths = useMemo(
358
+ () =>
359
+ new Set(
360
+ (syncStatus?.fileStatuses || [])
361
+ .filter((status) => status.syncEligible && ['new', 'modified', 'failed'].includes(status.status))
362
+ .map((status) => status.relativePath),
363
+ ),
364
+ [syncStatus],
365
+ );
366
+
294
367
  const stats = useMemo(() => {
295
368
  const t1 = filteredAnnotated.filter((file) => file._tier === 1).length;
296
369
  const t2 = filteredAnnotated.filter((file) => file._tier === 2).length;
@@ -302,7 +375,7 @@ export default function App() {
302
375
  const pendingCount = useMemo(() => {
303
376
  let count = 0;
304
377
  for (const status of Object.values(syncMap)) {
305
- if (status === 'new' || status === 'modified') count++;
378
+ if (status === 'new' || status === 'modified' || status === 'failed') count++;
306
379
  }
307
380
  return count;
308
381
  }, [syncMap]);
@@ -312,6 +385,14 @@ export default function App() {
312
385
  return Math.max(0, Math.min(100, Math.round((syncProgress.completedFiles / syncProgress.totalFiles) * 100)));
313
386
  }, [syncProgress]);
314
387
 
388
+ useEffect(() => {
389
+ setSyncSelection((prev) => {
390
+ const next = new Set([...prev].filter((path) => selectablePaths.has(path)));
391
+ if (next.size === prev.size) return prev;
392
+ return next;
393
+ });
394
+ }, [selectablePaths]);
395
+
315
396
  const handleSync = useCallback(async () => {
316
397
  setSyncing(true);
317
398
  setSyncResult(null);
@@ -325,31 +406,25 @@ export default function App() {
325
406
  } else {
326
407
  result = await triggerSync();
327
408
  }
328
- const summary = result?.summary || {};
329
- const parts = [];
330
- if (summary.new_memory_count > 0) parts.push(`${summary.new_memory_count} new memories`);
331
- if (summary.new_source_count > 0) parts.push(`${summary.new_source_count} files uploaded`);
332
- if (summary.skipped_count > 0) parts.push(`${summary.skipped_count} already synced`);
333
- if (summary.duplicate_count > 0) parts.push(`${summary.duplicate_count} duplicates`);
334
- if (summary.failed_file_count > 0) parts.push(`${summary.failed_file_count} failed`);
335
- setSyncResult({ ok: true, msg: parts.join(' | ') || 'Sync complete' });
409
+ setSyncResult(buildSyncResultState(result));
336
410
  loadSyncStatus();
337
411
  loadBackendSources();
338
- } catch {
339
- setSyncResult({ ok: false, msg: 'Sync failed' });
412
+ } catch (error) {
413
+ setSyncResult({ ok: false, msg: String(error?.message || 'Sync failed') });
340
414
  } finally {
341
415
  setSyncing(false);
342
416
  }
343
417
  }, [loadBackendSources, loadSyncStatus, selectMode, syncSelection]);
344
418
 
345
419
  const toggleFileSelection = useCallback((filePath) => {
420
+ if (!selectablePaths.has(filePath)) return;
346
421
  setSyncSelection((prev) => {
347
422
  const next = new Set(prev);
348
423
  if (next.has(filePath)) next.delete(filePath);
349
424
  else next.add(filePath);
350
425
  return next;
351
426
  });
352
- }, []);
427
+ }, [selectablePaths]);
353
428
 
354
429
  const handleSetupFieldChange = useCallback((key, value) => {
355
430
  setSetupDraft((prev) => ({ ...prev, [key]: value }));
@@ -513,14 +588,16 @@ export default function App() {
513
588
  sections={activeLayout.sections}
514
589
  bounds={activeLayout.bounds}
515
590
  syncStatus={syncMap}
591
+ syncMetaByPath={syncMetaByPath}
516
592
  transientStatusMap={cardSyncState}
517
593
  contentMap={contentMap}
518
594
  selectedPath={selectedPath}
519
595
  selectMode={selectMode}
520
596
  syncSelection={syncSelection}
597
+ selectablePaths={selectablePaths}
521
598
  onCardClick={(path) => {
522
599
  if (selectMode) {
523
- if (path) toggleFileSelection(path);
600
+ if (path && selectablePaths.has(path)) toggleFileSelection(path);
524
601
  return;
525
602
  }
526
603
  if (path === null) {
@@ -554,24 +631,45 @@ export default function App() {
554
631
  <div className="sync-progress-dock">
555
632
  <div className="sync-progress-top">
556
633
  <span className="sync-progress-title">
557
- {syncProgress.phase === 'failed' ? 'Sync failed' : syncProgress.phase === 'finished' ? 'Sync complete' : 'Sync in progress'}
634
+ {syncProgress.phase === 'failed'
635
+ ? 'Sync failed'
636
+ : syncProgress.phase === 'finished'
637
+ ? syncProgress.failedCount > 0
638
+ ? syncProgress.failedCount === syncProgress.totalFiles
639
+ ? 'All files failed'
640
+ : 'Sync finished with failures'
641
+ : 'Sync complete'
642
+ : 'Sync in progress'}
558
643
  </span>
559
644
  <span className="sync-progress-meta">
560
- {syncProgress.completedFiles} / {syncProgress.totalFiles} files
645
+ {Math.max(syncProgress.currentFileIndex || syncProgress.completedFiles, syncProgress.completedFiles)} / {syncProgress.totalFiles} files
561
646
  </span>
562
- {syncProgress.batchCount > 0 && (
647
+ {syncProgress.currentRelativePath && (
648
+ <span className="sync-progress-meta">
649
+ File {basenameFromPath(syncProgress.currentRelativePath)}
650
+ </span>
651
+ )}
652
+ {formatStageLabel(syncProgress.currentStage) && (
563
653
  <span className="sync-progress-meta">
564
- Batch {Math.max(1, syncProgress.batchIndex || (syncProgress.phase === 'finished' ? syncProgress.batchCount : 1))} of {syncProgress.batchCount}
654
+ Stage {formatStageLabel(syncProgress.currentStage)}
565
655
  </span>
566
656
  )}
567
657
  <span className="sync-progress-meta">Elapsed {formatDuration(syncProgress.elapsedMs)}</span>
568
658
  {syncProgress.etaMs && syncProgress.phase !== 'finished' && syncProgress.phase !== 'failed' && (
569
659
  <span className="sync-progress-meta">ETA {formatDuration(syncProgress.etaMs)}</span>
570
660
  )}
661
+ <span className="sync-progress-meta">
662
+ OK {syncProgress.successCount} | Failed {syncProgress.failedCount}
663
+ </span>
571
664
  </div>
572
665
  <div className="sync-progress-track">
573
666
  <div className="sync-progress-fill" style={{ width: `${syncProgressPercent}%` }} />
574
667
  </div>
668
+ {syncProgress.recentFileResult?.status === 'failed' && (
669
+ <div className="sync-progress-detail">
670
+ Failed {basenameFromPath(syncProgress.recentFileResult.relativePath || syncProgress.currentRelativePath)}: {syncProgress.recentFileResult.lastError || 'Unknown error'}
671
+ </div>
672
+ )}
575
673
  </div>
576
674
  )}
577
675
 
@@ -585,7 +683,7 @@ export default function App() {
585
683
  className="ftr-select-toggle"
586
684
  onClick={() => {
587
685
  const pending = (syncStatus?.fileStatuses || [])
588
- .filter((status) => status.status === 'new' || status.status === 'modified')
686
+ .filter((status) => status.syncEligible && ['new', 'modified', 'failed'].includes(status.status))
589
687
  .map((status) => status.relativePath);
590
688
  setSyncSelection(new Set(pending));
591
689
  }}
@@ -615,7 +713,17 @@ export default function App() {
615
713
  )}
616
714
  <span className="ftr-spacer" />
617
715
  {syncResult && (
618
- <span className={syncResult.ok ? 'sync-result' : 'sync-error'}>{syncResult.msg}</span>
716
+ <>
717
+ <span className={syncResult.ok ? 'sync-result' : 'sync-error'}>{syncResult.msg}</span>
718
+ {!syncResult.ok && syncResult.failed?.length > 0 && (
719
+ <span className="sync-error">
720
+ {syncResult.failed
721
+ .slice(0, 2)
722
+ .map((item) => `${basenameFromPath(item.filePath)}: ${item.lastError || 'Unknown error'}`)
723
+ .join(' | ')}
724
+ </span>
725
+ )}
726
+ </>
619
727
  )}
620
728
  {!selectMode && pendingCount > 0 && (
621
729
  <button className="ftr-select-toggle" onClick={() => setSelectMode(true)}>
@@ -19,7 +19,7 @@ const RENDER_MARGIN = 600; // px in screen space
19
19
  // Cards beyond this are not in DOM at all
20
20
  const PLACEHOLDER_MARGIN = 2000; // px in screen space
21
21
 
22
- export function Viewport({ cards, sections, bounds, syncStatus, transientStatusMap, contentMap, selectedPath, selectMode, syncSelection, onCardClick, onCardExpand }) {
22
+ export function Viewport({ cards, sections, bounds, syncStatus, syncMetaByPath, transientStatusMap, contentMap, selectedPath, selectMode, syncSelection, selectablePaths, onCardClick, onCardExpand }) {
23
23
  const vpRef = useRef(null);
24
24
  const canvasRef = useRef(null);
25
25
  const { viewState, ready, panMoved, focusCard, animateTo, handlers } =
@@ -179,15 +179,17 @@ export function Viewport({ cards, sections, bounds, syncStatus, transientStatusM
179
179
  key={card.key}
180
180
  card={card}
181
181
  syncStatus={syncStatus?.[card.key]}
182
+ syncMeta={syncMetaByPath?.[card.key]}
182
183
  transientStatus={transientStatusMap?.[card.key]}
183
184
  content={contentMap?.get(card.key) ?? ''}
184
- zoom={viewState.zoom}
185
- selected={selectedPath === card.key}
186
- dimmed={!!selectedPath && selectedPath !== card.key}
187
- selectMode={selectMode}
188
- checked={syncSelection?.has(card.key)}
189
- />
190
- ))}
185
+ zoom={viewState.zoom}
186
+ selected={selectedPath === card.key}
187
+ dimmed={!!selectedPath && selectedPath !== card.key}
188
+ selectMode={selectMode}
189
+ checked={syncSelection?.has(card.key)}
190
+ selectable={selectablePaths?.has(card.key)}
191
+ />
192
+ ))}
191
193
  </>
192
194
  )}
193
195
  </div>
@@ -177,6 +177,11 @@
177
177
  background: rgba(144, 144, 160, 0.06);
178
178
  opacity: 0.5;
179
179
  }
180
+ .stamp-local {
181
+ color: #8d7e67;
182
+ border-color: #8d7e67;
183
+ background: rgba(141, 126, 103, 0.08);
184
+ }
180
185
 
181
186
  .stamp-transient {
182
187
  opacity: 0.92;
@@ -205,6 +210,14 @@
205
210
  border-color: #e58a8a;
206
211
  background: rgba(229, 138, 138, 0.14);
207
212
  }
213
+
214
+ .card-unselectable {
215
+ opacity: 0.88;
216
+ }
217
+
218
+ .card-checkbox-disabled {
219
+ opacity: 0.35;
220
+ }
208
221
 
209
222
  /* Sealed: centered overlay stamp — red ink chop style */
210
223
  .stamp-overlay {
@@ -5,6 +5,8 @@ const STATUS_PALETTE = {
5
5
  sealed: { bg: '#fdf2f2', border: '#c9a0a0', text: '#8b4c4c', content: '#7a4040' },
6
6
  new: { bg: '#fefcf6', border: '#d4b882', text: '#7a6230', content: '#6b5228' },
7
7
  modified: { bg: '#fefcf6', border: '#d4b882', text: '#7a6230', content: '#6b5228' },
8
+ failed: { bg: '#fff5f5', border: '#d48b8b', text: '#9b4545', content: '#7c3d3d' },
9
+ local: { bg: '#f5f4f0', border: '#bbb1a2', text: '#756b5c', content: '#5f574d' },
8
10
  synced: { bg: '#f3f3f5', border: '#c0c0c8', text: '#888890', content: '#6e6e78' },
9
11
  none: { bg: '#eaeaed', border: '#b8b8c0', text: '#808088', content: '#606068' },
10
12
  };
@@ -16,6 +18,8 @@ function getPalette(syncStatus, tier) {
16
18
  if (syncStatus === 'sealed') return STATUS_PALETTE.sealed;
17
19
  if (syncStatus === 'new') return STATUS_PALETTE.new;
18
20
  if (syncStatus === 'modified') return STATUS_PALETTE.modified;
21
+ if (syncStatus === 'failed') return STATUS_PALETTE.failed;
22
+ if (syncStatus === 'local') return STATUS_PALETTE.local;
19
23
  if (syncStatus === 'synced') return STATUS_PALETTE.synced;
20
24
  return STATUS_PALETTE[TIER_DEFAULTS[tier] || 'none'];
21
25
  }
@@ -49,6 +53,8 @@ const STAMP_CONFIG = {
49
53
  sealed: { label: 'SENSITIVE', cls: 'stamp-sealed' },
50
54
  new: { label: 'NEW', cls: 'stamp-new' },
51
55
  modified: { label: 'MOD', cls: 'stamp-mod' },
56
+ failed: { label: 'FAILED', cls: 'stamp-failed' },
57
+ local: { label: 'LOCAL', cls: 'stamp-local' },
52
58
  synced: { label: 'OK', cls: 'stamp-synced' },
53
59
  };
54
60
 
@@ -77,6 +83,7 @@ function TransientStamp({ status }) {
77
83
  export const Card = React.memo(function Card({
78
84
  card,
79
85
  syncStatus,
86
+ syncMeta,
80
87
  transientStatus,
81
88
  content,
82
89
  zoom = 1,
@@ -84,6 +91,7 @@ export const Card = React.memo(function Card({
84
91
  dimmed,
85
92
  selectMode,
86
93
  checked,
94
+ selectable,
87
95
  }) {
88
96
  const { file, x, y, w, h } = card;
89
97
  const tier = file._tier || 3;
@@ -144,6 +152,7 @@ export const Card = React.memo(function Card({
144
152
  effectiveStatus === 'sealed' ? 'card-sealed' : '',
145
153
  selected ? 'card-selected' : '',
146
154
  dimmed ? 'card-dimmed' : '',
155
+ selectMode && selectable === false ? 'card-unselectable' : '',
147
156
  ]
148
157
  .filter(Boolean)
149
158
  .join(' ');
@@ -163,7 +172,7 @@ export const Card = React.memo(function Card({
163
172
  >
164
173
  <div className="card-header">
165
174
  {selectMode && (
166
- <span className={`card-checkbox ${checked ? 'card-checkbox-on' : ''}`} data-checkbox="true">
175
+ <span className={`card-checkbox ${checked ? 'card-checkbox-on' : ''} ${selectable === false ? 'card-checkbox-disabled' : ''}`} data-checkbox="true">
167
176
  {checked ? '[x]' : '[ ]'}
168
177
  </span>
169
178
  )}
@@ -445,6 +445,14 @@ body {
445
445
  transition: width 0.18s ease;
446
446
  }
447
447
 
448
+ .sync-progress-detail {
449
+ color: #f3b2b2;
450
+ font-size: 11px;
451
+ white-space: nowrap;
452
+ overflow: hidden;
453
+ text-overflow: ellipsis;
454
+ }
455
+
448
456
  .empty-state {
449
457
  flex: 1;
450
458
  display: flex;
@@ -52,11 +52,12 @@ export async function saveSetupConfig(payload) {
52
52
  return data;
53
53
  }
54
54
 
55
- export async function triggerSync() {
56
- const res = await fetch('/api/sync', { method: 'POST' });
57
- if (!res.ok) throw new Error(`Sync failed: HTTP ${res.status}`);
58
- return await res.json();
59
- }
55
+ export async function triggerSync() {
56
+ const res = await fetch('/api/sync', { method: 'POST' });
57
+ const data = await res.json().catch(() => ({}));
58
+ if (!res.ok) throw new Error(data?.details || data?.error || `Sync failed: HTTP ${res.status}`);
59
+ return data;
60
+ }
60
61
 
61
62
  export async function fetchBackendSources() {
62
63
  try {
@@ -68,15 +69,16 @@ export async function fetchBackendSources() {
68
69
  }
69
70
  }
70
71
 
71
- export async function triggerSyncSelected(paths) {
72
- const res = await fetch('/api/sync-selected', {
73
- method: 'POST',
74
- headers: { 'Content-Type': 'application/json' },
75
- body: JSON.stringify({ paths }),
76
- });
77
- if (!res.ok) throw new Error(`Sync failed: HTTP ${res.status}`);
78
- return await res.json();
79
- }
72
+ export async function triggerSyncSelected(paths) {
73
+ const res = await fetch('/api/sync-selected', {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/json' },
76
+ body: JSON.stringify({ paths }),
77
+ });
78
+ const data = await res.json().catch(() => ({}));
79
+ if (!res.ok) throw new Error(data?.details || data?.error || `Sync failed: HTTP ${res.status}`);
80
+ return data;
81
+ }
80
82
 
81
83
  /**
82
84
  * Fetch content of a single markdown file.