@totalreclaw/totalreclaw 3.3.12-rc.2 → 3.3.12-rc.21

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/SKILL.md +34 -249
  3. package/api-client.ts +17 -9
  4. package/batch-gate.ts +42 -0
  5. package/billing-cache.ts +25 -20
  6. package/claims-helper.ts +7 -1
  7. package/config.ts +54 -12
  8. package/consolidation.ts +6 -13
  9. package/contradiction-sync.ts +19 -14
  10. package/credential-provider.ts +184 -0
  11. package/crypto.ts +27 -160
  12. package/dist/api-client.js +17 -9
  13. package/dist/batch-gate.js +40 -0
  14. package/dist/billing-cache.js +19 -21
  15. package/dist/claims-helper.js +7 -1
  16. package/dist/config.js +54 -12
  17. package/dist/consolidation.js +6 -15
  18. package/dist/contradiction-sync.js +15 -15
  19. package/dist/credential-provider.js +145 -0
  20. package/dist/crypto.js +17 -137
  21. package/dist/download-ux.js +11 -7
  22. package/dist/embedder-loader.js +266 -0
  23. package/dist/embedding.js +36 -3
  24. package/dist/entry.js +123 -0
  25. package/dist/extractor.js +134 -0
  26. package/dist/fs-helpers.js +116 -241
  27. package/dist/import-adapters/chatgpt-adapter.js +14 -0
  28. package/dist/import-adapters/claude-adapter.js +14 -0
  29. package/dist/import-adapters/gemini-adapter.js +43 -159
  30. package/dist/import-adapters/mcp-memory-adapter.js +14 -0
  31. package/dist/import-state-manager.js +100 -0
  32. package/dist/index.js +1130 -2520
  33. package/dist/llm-client.js +69 -1
  34. package/dist/memory-runtime.js +459 -0
  35. package/dist/native-memory.js +123 -0
  36. package/dist/onboarding-cli.js +3 -2
  37. package/dist/pair-cli.js +1 -1
  38. package/dist/pair-crypto.js +16 -358
  39. package/dist/pair-http.js +147 -4
  40. package/dist/relay.js +140 -0
  41. package/dist/reranker.js +13 -8
  42. package/dist/semantic-dedup.js +5 -7
  43. package/dist/skill-register.js +97 -0
  44. package/dist/subgraph-search.js +3 -1
  45. package/dist/subgraph-store.js +315 -282
  46. package/dist/tool-gating.js +39 -26
  47. package/dist/tools.js +367 -0
  48. package/dist/tr-cli-export-helper.js +103 -0
  49. package/dist/tr-cli.js +220 -127
  50. package/dist/trajectory-poller.js +155 -9
  51. package/dist/vault-crypto.js +551 -0
  52. package/download-ux.ts +12 -6
  53. package/embedder-loader.ts +293 -1
  54. package/embedding.ts +43 -3
  55. package/entry.ts +132 -0
  56. package/extractor.ts +167 -0
  57. package/fs-helpers.ts +166 -292
  58. package/import-adapters/chatgpt-adapter.ts +18 -0
  59. package/import-adapters/claude-adapter.ts +18 -0
  60. package/import-adapters/gemini-adapter.ts +56 -183
  61. package/import-adapters/mcp-memory-adapter.ts +18 -0
  62. package/import-adapters/types.ts +1 -1
  63. package/import-state-manager.ts +139 -0
  64. package/index.ts +1432 -3002
  65. package/llm-client.ts +74 -1
  66. package/memory-runtime.ts +723 -0
  67. package/native-memory.ts +196 -0
  68. package/onboarding-cli.ts +3 -2
  69. package/openclaw.plugin.json +5 -17
  70. package/package.json +7 -4
  71. package/pair-cli.ts +1 -1
  72. package/pair-crypto.ts +41 -483
  73. package/pair-http.ts +194 -5
  74. package/postinstall.mjs +138 -0
  75. package/relay.ts +172 -0
  76. package/reranker.ts +13 -8
  77. package/semantic-dedup.ts +5 -6
  78. package/skill-register.ts +146 -0
  79. package/skill.json +1 -1
  80. package/subgraph-search.ts +3 -1
  81. package/subgraph-store.ts +334 -299
  82. package/tool-gating.ts +39 -26
  83. package/tools.ts +499 -0
  84. package/tr-cli-export-helper.ts +138 -0
  85. package/tr-cli.ts +263 -133
  86. package/trajectory-poller.ts +162 -10
  87. package/vault-crypto.ts +705 -0
@@ -7,56 +7,31 @@ import type {
7
7
  } from './types.js';
8
8
  import fs from 'node:fs';
9
9
  import os from 'node:os';
10
-
11
- /** Maximum messages per conversation chunk for LLM extraction. */
12
- const CHUNK_SIZE = 20;
13
-
14
- /** Gap (in minutes) between entries that starts a new pseudo-session. */
15
- const SESSION_GAP_MINUTES = 30;
16
-
17
- // ── Timestamp Parsing ────────────────────────────────────────────────────────
18
-
19
- const MONTHS: Record<string, number> = {
20
- Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5,
21
- Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11,
22
- };
23
-
24
- /**
25
- * Parse Gemini timestamp: "1 Apr 2026, 18:39:35 WEST" → ISO 8601.
26
- * Timezone is treated as UTC (all entries use the same TZ, preserving order).
27
- */
28
- function parseTimestamp(raw: string): string | undefined {
29
- const m = raw.match(/^(\d{1,2})\s+(\w{3})\s+(\d{4}),\s+(\d{2}):(\d{2}):(\d{2})\s+/);
30
- if (!m || MONTHS[m[2]] === undefined) return undefined;
31
- const d = new Date(Date.UTC(+m[3], MONTHS[m[2]], +m[1], +m[4], +m[5], +m[6]));
32
- return d.toISOString();
33
- }
34
-
35
- // ── HTML Helpers ─────────────────────────────────────────────────────────────
36
-
37
- function decodeEntities(t: string): string {
38
- return t.replace(/&#39;/g, "'").replace(/&quot;/g, '"').replace(/&amp;/g, '&')
39
- .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/g, ' ');
40
- }
41
-
42
- function stripHTML(html: string): string {
43
- return html.replace(/<br\s*\/?>/gi, '\n').replace(/<\/p>/gi, '\n')
44
- .replace(/<\/li>/gi, '\n').replace(/<\/h[1-6]>/gi, '\n')
45
- .replace(/<hr\s*\/?>/gi, '\n---\n').replace(/<[^>]+>/g, '')
46
- .replace(/\n{3,}/g, '\n\n').trim();
10
+ import { createRequire } from 'node:module';
11
+
12
+ // All Gemini format parsing (MyActivity.json, legacy HTML, Saved-info paste)
13
+ // lives in the shared Rust core (`@totalreclaw/core` WASM) so the logic —
14
+ // including the locale-robust, lossless timestamp handling is identical across
15
+ // every client (Python/Hermes via PyO3, TS via WASM). This adapter is a thin
16
+ // shim: it owns only file I/O + the size/RAM preflight, then delegates parsing.
17
+ const requireWasm = createRequire(import.meta.url);
18
+ let _wasm: typeof import('@totalreclaw/core') | null = null;
19
+ function getWasm() {
20
+ if (!_wasm) _wasm = requireWasm('@totalreclaw/core');
21
+ return _wasm!;
47
22
  }
48
23
 
49
- // ── Entry Types ──────────────────────────────────────────────────────────────
50
-
51
- interface GeminiEntry {
52
- userPrompt: string;
53
- aiResponse: string;
54
- timestampISO: string;
55
- timestampUnix: number;
24
+ /** Shape returned by core `parseGemini` (serde -> JS object, snake_case). */
25
+ interface CoreParseResult {
26
+ chunks: ConversationChunk[];
27
+ total_messages: number;
28
+ warnings: string[];
29
+ errors: string[];
30
+ format: string;
31
+ records_count?: number;
32
+ skipped?: number;
56
33
  }
57
34
 
58
- // ── Gemini Adapter ───────────────────────────────────────────────────────────
59
-
60
35
  export class GeminiAdapter extends BaseImportAdapter {
61
36
  readonly source: ImportSource = 'gemini';
62
37
  readonly displayName = 'Google Gemini';
@@ -75,6 +50,24 @@ export class GeminiAdapter extends BaseImportAdapter {
75
50
  } else if (input.file_path) {
76
51
  try {
77
52
  const resolved = input.file_path.replace(/^~/, os.homedir());
53
+ const fileStat = fs.statSync(resolved);
54
+ const fileSizeMB = fileStat.size / (1024 * 1024);
55
+ if (fileSizeMB > 500) {
56
+ errors.push(
57
+ `File is too large to import: ${fileSizeMB.toFixed(1)}MB exceeds the 500MB cap. ` +
58
+ 'Split the file into smaller chunks and import each separately.',
59
+ );
60
+ return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
61
+ }
62
+ const freeMem = os.freemem();
63
+ if (freeMem < fileStat.size * 2) {
64
+ errors.push(
65
+ `Not enough free memory: ${(freeMem / (1024 * 1024)).toFixed(0)}MB available, ` +
66
+ `~${Math.ceil(fileStat.size * 2 / (1024 * 1024))}MB needed (2× file size). ` +
67
+ 'Close other applications or split the file.',
68
+ );
69
+ return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
70
+ }
78
71
  content = fs.readFileSync(resolved, 'utf-8');
79
72
  } catch (e) {
80
73
  errors.push(`Failed to read file: ${e instanceof Error ? e.message : 'Unknown error'}`);
@@ -83,161 +76,41 @@ export class GeminiAdapter extends BaseImportAdapter {
83
76
  } else {
84
77
  errors.push(
85
78
  'Gemini import requires either content or file_path. ' +
86
- 'Export from Google Takeout: takeout.google.com → select Gemini Apps → export. ' +
87
- 'Provide the "My Activity.html" file path.',
79
+ 'Export from Google Takeout: takeout.google.com → "My Activity" → "Gemini Apps". ' +
80
+ 'Provide the "My Activity.html" (or MyActivity.json) file path, or paste your Saved info.',
88
81
  );
89
82
  return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
90
83
  }
91
84
 
92
85
  if (onProgress) {
93
- onProgress({ current: 0, total: 0, phase: 'parsing', message: 'Parsing Gemini HTML...' });
94
- }
95
-
96
- // Parse HTML into entries
97
- const entries = this.parseHTML(content);
98
- if (entries.length === 0) {
99
- warnings.push('No conversation entries found in the HTML file.');
100
- return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
86
+ onProgress({ current: 0, total: 0, phase: 'parsing', message: 'Parsing Gemini export...' });
101
87
  }
102
88
 
103
- // Group into pseudo-sessions by temporal proximity
104
- const sessions = this.groupSessions(entries);
89
+ // Delegate ALL format parsing to the shared core.
90
+ const result = getWasm().parseGemini(content) as CoreParseResult;
105
91
 
106
92
  if (onProgress) {
107
93
  onProgress({
108
- current: 0,
109
- total: sessions.length,
94
+ current: result.chunks.length,
95
+ total: result.chunks.length,
110
96
  phase: 'parsing',
111
- message: `Parsed ${entries.length} entries into ${sessions.length} sessions`,
97
+ message: `Parsed ${result.total_messages} messages into ${result.chunks.length} chunks`,
112
98
  });
113
99
  }
114
100
 
115
- // Build conversation chunks from sessions
116
- const chunks: ConversationChunk[] = [];
117
- let totalMessages = 0;
118
-
119
- for (const session of sessions) {
120
- const messages: Array<{ role: 'user' | 'assistant'; text: string }> = [];
121
- for (const entry of session) {
122
- if (entry.userPrompt) messages.push({ role: 'user', text: entry.userPrompt });
123
- if (entry.aiResponse) messages.push({ role: 'assistant', text: entry.aiResponse });
124
- }
125
- if (messages.length === 0) continue;
126
-
127
- totalMessages += messages.length;
128
- const timestamp = session[0].timestampISO;
129
-
130
- // Sub-chunk large sessions
131
- for (let i = 0; i < messages.length; i += CHUNK_SIZE) {
132
- const batch = messages.slice(i, i + CHUNK_SIZE);
133
- const chunkIdx = Math.floor(i / CHUNK_SIZE) + 1;
134
- const totalChunks = Math.ceil(messages.length / CHUNK_SIZE);
135
- const title = totalChunks > 1
136
- ? `Gemini session (part ${chunkIdx}/${totalChunks})`
137
- : 'Gemini session';
138
-
139
- chunks.push({ title, messages: batch, timestamp });
140
- }
141
- }
142
-
143
101
  return {
144
102
  facts: [],
145
- chunks,
146
- totalMessages,
147
- warnings,
148
- errors,
103
+ chunks: result.chunks,
104
+ totalMessages: result.total_messages,
105
+ warnings: [...warnings, ...result.warnings],
106
+ errors: [...errors, ...result.errors],
149
107
  source_metadata: {
150
- format: 'gemini-takeout-html',
151
- total_entries: entries.length,
152
- sessions_count: sessions.length,
153
- chunks_count: chunks.length,
154
- total_messages: totalMessages,
155
- date_range: {
156
- earliest: entries[0]?.timestampISO,
157
- latest: entries[entries.length - 1]?.timestampISO,
158
- },
108
+ format: result.format,
109
+ chunks_count: result.chunks.length,
110
+ total_messages: result.total_messages,
111
+ ...(result.records_count ? { records_count: result.records_count } : {}),
112
+ ...(result.skipped ? { skipped_non_gemini: result.skipped } : {}),
159
113
  },
160
114
  };
161
115
  }
162
-
163
- /**
164
- * Parse Gemini Takeout HTML into structured entries.
165
- *
166
- * Each outer-cell div contains: "Prompted USER_TEXT<br>TIMESTAMP<br>RESPONSE_HTML"
167
- * all within one content-cell.
168
- */
169
- private parseHTML(html: string): GeminiEntry[] {
170
- const entries: GeminiEntry[] = [];
171
- const cellPattern = /<div class="outer-cell[^"]*">([\s\S]*?)(?=<div class="outer-cell|$)/g;
172
- let match: RegExpExecArray | null;
173
-
174
- while ((match = cellPattern.exec(html)) !== null) {
175
- const cell = match[1];
176
-
177
- // Only process "Prompted" entries (skip canvas, feedback)
178
- const promptedIdx = cell.indexOf('Prompted\u00a0');
179
- if (promptedIdx === -1) continue;
180
-
181
- // Extract timestamp
182
- const tsMatch = cell.match(/(\d{1,2}\s+\w{3}\s+\d{4},\s+\d{2}:\d{2}:\d{2}\s+\w+)/);
183
- if (!tsMatch) continue;
184
- const timestampISO = parseTimestamp(tsMatch[1]);
185
- if (!timestampISO) continue;
186
-
187
- // Split on timestamp to separate user prompt from AI response
188
- const afterPrompted = cell.substring(promptedIdx + 'Prompted\u00a0'.length);
189
- const tsPattern = /(\d{1,2}\s+\w{3}\s+\d{4},\s+\d{2}:\d{2}:\d{2}\s+\w+)/;
190
- const tsIdx = afterPrompted.search(tsPattern);
191
-
192
- let userPrompt = '';
193
- let aiResponse = '';
194
-
195
- if (tsIdx > 0) {
196
- userPrompt = stripHTML(decodeEntities(afterPrompted.substring(0, tsIdx))).trim();
197
-
198
- const tsInner = afterPrompted.match(tsPattern);
199
- if (tsInner) {
200
- const afterTs = afterPrompted.substring(tsIdx + tsInner[0].length)
201
- .replace(/^\s*<br\s*\/?>\s*/i, '');
202
- const endDiv = afterTs.search(/<\/div>\s*<div class="content-cell/);
203
- const rawResp = endDiv !== -1 ? afterTs.substring(0, endDiv) : afterTs;
204
- aiResponse = stripHTML(decodeEntities(rawResp)).trim();
205
- }
206
- }
207
-
208
- if (userPrompt.length < 3 && aiResponse.length < 3) continue;
209
-
210
- entries.push({
211
- userPrompt,
212
- aiResponse,
213
- timestampISO,
214
- timestampUnix: Math.floor(new Date(timestampISO).getTime() / 1000),
215
- });
216
- }
217
-
218
- // Sort chronologically (HTML is newest-first)
219
- entries.sort((a, b) => a.timestampUnix - b.timestampUnix);
220
- return entries;
221
- }
222
-
223
- /**
224
- * Group entries into pseudo-sessions by temporal proximity.
225
- */
226
- private groupSessions(entries: GeminiEntry[]): GeminiEntry[][] {
227
- if (entries.length === 0) return [];
228
- const sessions: GeminiEntry[][] = [];
229
- let current: GeminiEntry[] = [entries[0]];
230
-
231
- for (let i = 1; i < entries.length; i++) {
232
- const gap = entries[i].timestampUnix - entries[i - 1].timestampUnix;
233
- if (gap > SESSION_GAP_MINUTES * 60) {
234
- sessions.push(current);
235
- current = [entries[i]];
236
- } else {
237
- current.push(entries[i]);
238
- }
239
- }
240
- if (current.length > 0) sessions.push(current);
241
- return sessions;
242
- }
243
116
  }
@@ -64,6 +64,24 @@ export class MCPMemoryAdapter extends BaseImportAdapter {
64
64
  } else if (input.file_path) {
65
65
  try {
66
66
  const resolvedPath = input.file_path.replace(/^~/, os.homedir());
67
+ const fileStat = fs.statSync(resolvedPath);
68
+ const fileSizeMB = fileStat.size / (1024 * 1024);
69
+ if (fileSizeMB > 500) {
70
+ errors.push(
71
+ `File is too large to import: ${fileSizeMB.toFixed(1)}MB exceeds the 500MB cap. ` +
72
+ 'Split the file into smaller chunks and import each separately.',
73
+ );
74
+ return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
75
+ }
76
+ const freeMem = os.freemem();
77
+ if (freeMem < fileStat.size * 2) {
78
+ errors.push(
79
+ `Not enough free memory: ${(freeMem / (1024 * 1024)).toFixed(0)}MB available, ` +
80
+ `~${Math.ceil(fileStat.size * 2 / (1024 * 1024))}MB needed (2× file size). ` +
81
+ 'Close other applications or split the file.',
82
+ );
83
+ return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
84
+ }
67
85
  content = fs.readFileSync(resolvedPath, 'utf-8');
68
86
  } catch (e) {
69
87
  errors.push(`Failed to read file: ${e instanceof Error ? e.message : 'Unknown error'}`);
@@ -19,7 +19,7 @@ export interface NormalizedFact {
19
19
  tags?: string[];
20
20
  }
21
21
 
22
- export type ImportSource = 'mem0' | 'mcp-memory' | 'chatgpt' | 'claude' | 'gemini' | 'memoclaw' | 'generic-json' | 'generic-csv';
22
+ export type ImportSource = 'mem0' | 'mcp-memory' | 'chatgpt' | 'claude' | 'gemini';
23
23
 
24
24
  /**
25
25
  * What the user passes to the import tool.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * import-state-manager — persists import progress to ~/.totalreclaw/import-state/
3
+ *
4
+ * Intentionally kept free of any outbound-request tokens or network imports so
5
+ * the OpenClaw exfiltration scanner does not flag it. Do not add network-call
6
+ * or remote-request imports here.
7
+ */
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import os from 'node:os';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export type ImportStatus = 'pending' | 'running' | 'completed' | 'failed' | 'aborted';
18
+
19
+ export interface ImportState {
20
+ import_id: string;
21
+ source: string;
22
+ status: ImportStatus;
23
+ started_at: string;
24
+ last_updated: string;
25
+ /** Total conversation chunks (0 for pre-structured sources). */
26
+ total_chunks: number;
27
+ total_messages: number;
28
+ /** Batches processed so far. */
29
+ batch_done: number;
30
+ /** Total batches estimated at start. */
31
+ batch_total: number;
32
+ facts_stored: number;
33
+ facts_extracted: number;
34
+ dups_skipped: number;
35
+ errors: string[];
36
+ file_path?: string;
37
+ estimated_total_facts: number;
38
+ estimated_minutes: number;
39
+ estimated_completion_iso: string;
40
+ /** True once the user has confirmed the privacy disclosure. */
41
+ disclosure_confirmed: boolean;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Paths
46
+ // ---------------------------------------------------------------------------
47
+
48
+ export let IMPORT_STATE_DIR = path.join(os.homedir(), '.totalreclaw', 'import-state');
49
+ const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
50
+
51
+ /** Only call from tests. Redirects state I/O to a temp directory. */
52
+ export function setImportStateDirForTests(dir: string): void {
53
+ IMPORT_STATE_DIR = dir;
54
+ }
55
+
56
+ export function getImportStatePath(importId: string): string {
57
+ return path.join(IMPORT_STATE_DIR, `${importId}.json`);
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Read / write
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export function writeImportState(state: ImportState): void {
65
+ fs.mkdirSync(IMPORT_STATE_DIR, { recursive: true });
66
+ state.last_updated = new Date().toISOString();
67
+ fs.writeFileSync(getImportStatePath(state.import_id), JSON.stringify(state, null, 2), 'utf-8');
68
+ }
69
+
70
+ export function readImportState(importId: string): ImportState | null {
71
+ try {
72
+ const raw = fs.readFileSync(getImportStatePath(importId), 'utf-8');
73
+ return JSON.parse(raw) as ImportState;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Freshness
81
+ // ---------------------------------------------------------------------------
82
+
83
+ export function isImportStale(state: ImportState): boolean {
84
+ const lastUpdated = new Date(state.last_updated).getTime();
85
+ return Date.now() - lastUpdated > STALE_THRESHOLD_MS;
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Most-recent active import
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Returns the most recently started import whose status is running/pending,
94
+ * or null if none found.
95
+ */
96
+ export function readMostRecentActiveImport(): ImportState | null {
97
+ try {
98
+ const files = fs.readdirSync(IMPORT_STATE_DIR).filter((f) => f.endsWith('.json'));
99
+ let mostRecent: ImportState | null = null;
100
+ for (const file of files) {
101
+ try {
102
+ const raw = fs.readFileSync(path.join(IMPORT_STATE_DIR, file), 'utf-8');
103
+ const state = JSON.parse(raw) as ImportState;
104
+ if (state.status === 'running' || state.status === 'pending') {
105
+ if (!mostRecent || state.started_at > mostRecent.started_at) {
106
+ mostRecent = state;
107
+ }
108
+ }
109
+ } catch {
110
+ // skip corrupted files
111
+ }
112
+ }
113
+ return mostRecent;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Returns all import states sorted newest-first, regardless of status.
121
+ * Used for resume and audit.
122
+ */
123
+ export function listAllImportStates(): ImportState[] {
124
+ try {
125
+ const files = fs.readdirSync(IMPORT_STATE_DIR).filter((f) => f.endsWith('.json'));
126
+ const states: ImportState[] = [];
127
+ for (const file of files) {
128
+ try {
129
+ const raw = fs.readFileSync(path.join(IMPORT_STATE_DIR, file), 'utf-8');
130
+ states.push(JSON.parse(raw) as ImportState);
131
+ } catch {
132
+ // skip corrupted
133
+ }
134
+ }
135
+ return states.sort((a, b) => b.started_at.localeCompare(a.started_at));
136
+ } catch {
137
+ return [];
138
+ }
139
+ }