@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.
- package/CHANGELOG.md +26 -0
- package/SKILL.md +34 -249
- package/api-client.ts +17 -9
- package/batch-gate.ts +42 -0
- package/billing-cache.ts +25 -20
- package/claims-helper.ts +7 -1
- package/config.ts +54 -12
- package/consolidation.ts +6 -13
- package/contradiction-sync.ts +19 -14
- package/credential-provider.ts +184 -0
- package/crypto.ts +27 -160
- package/dist/api-client.js +17 -9
- package/dist/batch-gate.js +40 -0
- package/dist/billing-cache.js +19 -21
- package/dist/claims-helper.js +7 -1
- package/dist/config.js +54 -12
- package/dist/consolidation.js +6 -15
- package/dist/contradiction-sync.js +15 -15
- package/dist/credential-provider.js +145 -0
- package/dist/crypto.js +17 -137
- package/dist/download-ux.js +11 -7
- package/dist/embedder-loader.js +266 -0
- package/dist/embedding.js +36 -3
- package/dist/entry.js +123 -0
- package/dist/extractor.js +134 -0
- package/dist/fs-helpers.js +116 -241
- package/dist/import-adapters/chatgpt-adapter.js +14 -0
- package/dist/import-adapters/claude-adapter.js +14 -0
- package/dist/import-adapters/gemini-adapter.js +43 -159
- package/dist/import-adapters/mcp-memory-adapter.js +14 -0
- package/dist/import-state-manager.js +100 -0
- package/dist/index.js +1130 -2520
- package/dist/llm-client.js +69 -1
- package/dist/memory-runtime.js +459 -0
- package/dist/native-memory.js +123 -0
- package/dist/onboarding-cli.js +3 -2
- package/dist/pair-cli.js +1 -1
- package/dist/pair-crypto.js +16 -358
- package/dist/pair-http.js +147 -4
- package/dist/relay.js +140 -0
- package/dist/reranker.js +13 -8
- package/dist/semantic-dedup.js +5 -7
- package/dist/skill-register.js +97 -0
- package/dist/subgraph-search.js +3 -1
- package/dist/subgraph-store.js +315 -282
- package/dist/tool-gating.js +39 -26
- package/dist/tools.js +367 -0
- package/dist/tr-cli-export-helper.js +103 -0
- package/dist/tr-cli.js +220 -127
- package/dist/trajectory-poller.js +155 -9
- package/dist/vault-crypto.js +551 -0
- package/download-ux.ts +12 -6
- package/embedder-loader.ts +293 -1
- package/embedding.ts +43 -3
- package/entry.ts +132 -0
- package/extractor.ts +167 -0
- package/fs-helpers.ts +166 -292
- package/import-adapters/chatgpt-adapter.ts +18 -0
- package/import-adapters/claude-adapter.ts +18 -0
- package/import-adapters/gemini-adapter.ts +56 -183
- package/import-adapters/mcp-memory-adapter.ts +18 -0
- package/import-adapters/types.ts +1 -1
- package/import-state-manager.ts +139 -0
- package/index.ts +1432 -3002
- package/llm-client.ts +74 -1
- package/memory-runtime.ts +723 -0
- package/native-memory.ts +196 -0
- package/onboarding-cli.ts +3 -2
- package/openclaw.plugin.json +5 -17
- package/package.json +7 -4
- package/pair-cli.ts +1 -1
- package/pair-crypto.ts +41 -483
- package/pair-http.ts +194 -5
- package/postinstall.mjs +138 -0
- package/relay.ts +172 -0
- package/reranker.ts +13 -8
- package/semantic-dedup.ts +5 -6
- package/skill-register.ts +146 -0
- package/skill.json +1 -1
- package/subgraph-search.ts +3 -1
- package/subgraph-store.ts +334 -299
- package/tool-gating.ts +39 -26
- package/tools.ts +499 -0
- package/tr-cli-export-helper.ts +138 -0
- package/tr-cli.ts +263 -133
- package/trajectory-poller.ts +162 -10
- package/vault-crypto.ts +705 -0
|
@@ -1,38 +1,19 @@
|
|
|
1
1
|
import { BaseImportAdapter } from './base-adapter.js';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
*/
|
|
17
|
-
function parseTimestamp(raw) {
|
|
18
|
-
const m = raw.match(/^(\d{1,2})\s+(\w{3})\s+(\d{4}),\s+(\d{2}):(\d{2}):(\d{2})\s+/);
|
|
19
|
-
if (!m || MONTHS[m[2]] === undefined)
|
|
20
|
-
return undefined;
|
|
21
|
-
const d = new Date(Date.UTC(+m[3], MONTHS[m[2]], +m[1], +m[4], +m[5], +m[6]));
|
|
22
|
-
return d.toISOString();
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
// All Gemini format parsing (MyActivity.json, legacy HTML, Saved-info paste)
|
|
6
|
+
// lives in the shared Rust core (`@totalreclaw/core` WASM) so the logic —
|
|
7
|
+
// including the locale-robust, lossless timestamp handling — is identical across
|
|
8
|
+
// every client (Python/Hermes via PyO3, TS via WASM). This adapter is a thin
|
|
9
|
+
// shim: it owns only file I/O + the size/RAM preflight, then delegates parsing.
|
|
10
|
+
const requireWasm = createRequire(import.meta.url);
|
|
11
|
+
let _wasm = null;
|
|
12
|
+
function getWasm() {
|
|
13
|
+
if (!_wasm)
|
|
14
|
+
_wasm = requireWasm('@totalreclaw/core');
|
|
15
|
+
return _wasm;
|
|
23
16
|
}
|
|
24
|
-
// ── HTML Helpers ─────────────────────────────────────────────────────────────
|
|
25
|
-
function decodeEntities(t) {
|
|
26
|
-
return t.replace(/'/g, "'").replace(/"/g, '"').replace(/&/g, '&')
|
|
27
|
-
.replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ');
|
|
28
|
-
}
|
|
29
|
-
function stripHTML(html) {
|
|
30
|
-
return html.replace(/<br\s*\/?>/gi, '\n').replace(/<\/p>/gi, '\n')
|
|
31
|
-
.replace(/<\/li>/gi, '\n').replace(/<\/h[1-6]>/gi, '\n')
|
|
32
|
-
.replace(/<hr\s*\/?>/gi, '\n---\n').replace(/<[^>]+>/g, '')
|
|
33
|
-
.replace(/\n{3,}/g, '\n\n').trim();
|
|
34
|
-
}
|
|
35
|
-
// ── Gemini Adapter ───────────────────────────────────────────────────────────
|
|
36
17
|
export class GeminiAdapter extends BaseImportAdapter {
|
|
37
18
|
source = 'gemini';
|
|
38
19
|
displayName = 'Google Gemini';
|
|
@@ -46,6 +27,20 @@ export class GeminiAdapter extends BaseImportAdapter {
|
|
|
46
27
|
else if (input.file_path) {
|
|
47
28
|
try {
|
|
48
29
|
const resolved = input.file_path.replace(/^~/, os.homedir());
|
|
30
|
+
const fileStat = fs.statSync(resolved);
|
|
31
|
+
const fileSizeMB = fileStat.size / (1024 * 1024);
|
|
32
|
+
if (fileSizeMB > 500) {
|
|
33
|
+
errors.push(`File is too large to import: ${fileSizeMB.toFixed(1)}MB exceeds the 500MB cap. ` +
|
|
34
|
+
'Split the file into smaller chunks and import each separately.');
|
|
35
|
+
return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
|
|
36
|
+
}
|
|
37
|
+
const freeMem = os.freemem();
|
|
38
|
+
if (freeMem < fileStat.size * 2) {
|
|
39
|
+
errors.push(`Not enough free memory: ${(freeMem / (1024 * 1024)).toFixed(0)}MB available, ` +
|
|
40
|
+
`~${Math.ceil(fileStat.size * 2 / (1024 * 1024))}MB needed (2× file size). ` +
|
|
41
|
+
'Close other applications or split the file.');
|
|
42
|
+
return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
|
|
43
|
+
}
|
|
49
44
|
content = fs.readFileSync(resolved, 'utf-8');
|
|
50
45
|
}
|
|
51
46
|
catch (e) {
|
|
@@ -55,147 +50,36 @@ export class GeminiAdapter extends BaseImportAdapter {
|
|
|
55
50
|
}
|
|
56
51
|
else {
|
|
57
52
|
errors.push('Gemini import requires either content or file_path. ' +
|
|
58
|
-
'Export from Google Takeout: takeout.google.com →
|
|
59
|
-
'Provide the "My Activity.html" file path.');
|
|
53
|
+
'Export from Google Takeout: takeout.google.com → "My Activity" → "Gemini Apps". ' +
|
|
54
|
+
'Provide the "My Activity.html" (or MyActivity.json) file path, or paste your Saved info.');
|
|
60
55
|
return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
|
|
61
56
|
}
|
|
62
57
|
if (onProgress) {
|
|
63
|
-
onProgress({ current: 0, total: 0, phase: 'parsing', message: 'Parsing Gemini
|
|
58
|
+
onProgress({ current: 0, total: 0, phase: 'parsing', message: 'Parsing Gemini export...' });
|
|
64
59
|
}
|
|
65
|
-
//
|
|
66
|
-
const
|
|
67
|
-
if (entries.length === 0) {
|
|
68
|
-
warnings.push('No conversation entries found in the HTML file.');
|
|
69
|
-
return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
|
|
70
|
-
}
|
|
71
|
-
// Group into pseudo-sessions by temporal proximity
|
|
72
|
-
const sessions = this.groupSessions(entries);
|
|
60
|
+
// Delegate ALL format parsing to the shared core.
|
|
61
|
+
const result = getWasm().parseGemini(content);
|
|
73
62
|
if (onProgress) {
|
|
74
63
|
onProgress({
|
|
75
|
-
current:
|
|
76
|
-
total:
|
|
64
|
+
current: result.chunks.length,
|
|
65
|
+
total: result.chunks.length,
|
|
77
66
|
phase: 'parsing',
|
|
78
|
-
message: `Parsed ${
|
|
67
|
+
message: `Parsed ${result.total_messages} messages into ${result.chunks.length} chunks`,
|
|
79
68
|
});
|
|
80
69
|
}
|
|
81
|
-
// Build conversation chunks from sessions
|
|
82
|
-
const chunks = [];
|
|
83
|
-
let totalMessages = 0;
|
|
84
|
-
for (const session of sessions) {
|
|
85
|
-
const messages = [];
|
|
86
|
-
for (const entry of session) {
|
|
87
|
-
if (entry.userPrompt)
|
|
88
|
-
messages.push({ role: 'user', text: entry.userPrompt });
|
|
89
|
-
if (entry.aiResponse)
|
|
90
|
-
messages.push({ role: 'assistant', text: entry.aiResponse });
|
|
91
|
-
}
|
|
92
|
-
if (messages.length === 0)
|
|
93
|
-
continue;
|
|
94
|
-
totalMessages += messages.length;
|
|
95
|
-
const timestamp = session[0].timestampISO;
|
|
96
|
-
// Sub-chunk large sessions
|
|
97
|
-
for (let i = 0; i < messages.length; i += CHUNK_SIZE) {
|
|
98
|
-
const batch = messages.slice(i, i + CHUNK_SIZE);
|
|
99
|
-
const chunkIdx = Math.floor(i / CHUNK_SIZE) + 1;
|
|
100
|
-
const totalChunks = Math.ceil(messages.length / CHUNK_SIZE);
|
|
101
|
-
const title = totalChunks > 1
|
|
102
|
-
? `Gemini session (part ${chunkIdx}/${totalChunks})`
|
|
103
|
-
: 'Gemini session';
|
|
104
|
-
chunks.push({ title, messages: batch, timestamp });
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
70
|
return {
|
|
108
71
|
facts: [],
|
|
109
|
-
chunks,
|
|
110
|
-
totalMessages,
|
|
111
|
-
warnings,
|
|
112
|
-
errors,
|
|
72
|
+
chunks: result.chunks,
|
|
73
|
+
totalMessages: result.total_messages,
|
|
74
|
+
warnings: [...warnings, ...result.warnings],
|
|
75
|
+
errors: [...errors, ...result.errors],
|
|
113
76
|
source_metadata: {
|
|
114
|
-
format:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
date_range: {
|
|
120
|
-
earliest: entries[0]?.timestampISO,
|
|
121
|
-
latest: entries[entries.length - 1]?.timestampISO,
|
|
122
|
-
},
|
|
77
|
+
format: result.format,
|
|
78
|
+
chunks_count: result.chunks.length,
|
|
79
|
+
total_messages: result.total_messages,
|
|
80
|
+
...(result.records_count ? { records_count: result.records_count } : {}),
|
|
81
|
+
...(result.skipped ? { skipped_non_gemini: result.skipped } : {}),
|
|
123
82
|
},
|
|
124
83
|
};
|
|
125
84
|
}
|
|
126
|
-
/**
|
|
127
|
-
* Parse Gemini Takeout HTML into structured entries.
|
|
128
|
-
*
|
|
129
|
-
* Each outer-cell div contains: "Prompted USER_TEXT<br>TIMESTAMP<br>RESPONSE_HTML"
|
|
130
|
-
* all within one content-cell.
|
|
131
|
-
*/
|
|
132
|
-
parseHTML(html) {
|
|
133
|
-
const entries = [];
|
|
134
|
-
const cellPattern = /<div class="outer-cell[^"]*">([\s\S]*?)(?=<div class="outer-cell|$)/g;
|
|
135
|
-
let match;
|
|
136
|
-
while ((match = cellPattern.exec(html)) !== null) {
|
|
137
|
-
const cell = match[1];
|
|
138
|
-
// Only process "Prompted" entries (skip canvas, feedback)
|
|
139
|
-
const promptedIdx = cell.indexOf('Prompted\u00a0');
|
|
140
|
-
if (promptedIdx === -1)
|
|
141
|
-
continue;
|
|
142
|
-
// Extract timestamp
|
|
143
|
-
const tsMatch = cell.match(/(\d{1,2}\s+\w{3}\s+\d{4},\s+\d{2}:\d{2}:\d{2}\s+\w+)/);
|
|
144
|
-
if (!tsMatch)
|
|
145
|
-
continue;
|
|
146
|
-
const timestampISO = parseTimestamp(tsMatch[1]);
|
|
147
|
-
if (!timestampISO)
|
|
148
|
-
continue;
|
|
149
|
-
// Split on timestamp to separate user prompt from AI response
|
|
150
|
-
const afterPrompted = cell.substring(promptedIdx + 'Prompted\u00a0'.length);
|
|
151
|
-
const tsPattern = /(\d{1,2}\s+\w{3}\s+\d{4},\s+\d{2}:\d{2}:\d{2}\s+\w+)/;
|
|
152
|
-
const tsIdx = afterPrompted.search(tsPattern);
|
|
153
|
-
let userPrompt = '';
|
|
154
|
-
let aiResponse = '';
|
|
155
|
-
if (tsIdx > 0) {
|
|
156
|
-
userPrompt = stripHTML(decodeEntities(afterPrompted.substring(0, tsIdx))).trim();
|
|
157
|
-
const tsInner = afterPrompted.match(tsPattern);
|
|
158
|
-
if (tsInner) {
|
|
159
|
-
const afterTs = afterPrompted.substring(tsIdx + tsInner[0].length)
|
|
160
|
-
.replace(/^\s*<br\s*\/?>\s*/i, '');
|
|
161
|
-
const endDiv = afterTs.search(/<\/div>\s*<div class="content-cell/);
|
|
162
|
-
const rawResp = endDiv !== -1 ? afterTs.substring(0, endDiv) : afterTs;
|
|
163
|
-
aiResponse = stripHTML(decodeEntities(rawResp)).trim();
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
if (userPrompt.length < 3 && aiResponse.length < 3)
|
|
167
|
-
continue;
|
|
168
|
-
entries.push({
|
|
169
|
-
userPrompt,
|
|
170
|
-
aiResponse,
|
|
171
|
-
timestampISO,
|
|
172
|
-
timestampUnix: Math.floor(new Date(timestampISO).getTime() / 1000),
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
// Sort chronologically (HTML is newest-first)
|
|
176
|
-
entries.sort((a, b) => a.timestampUnix - b.timestampUnix);
|
|
177
|
-
return entries;
|
|
178
|
-
}
|
|
179
|
-
/**
|
|
180
|
-
* Group entries into pseudo-sessions by temporal proximity.
|
|
181
|
-
*/
|
|
182
|
-
groupSessions(entries) {
|
|
183
|
-
if (entries.length === 0)
|
|
184
|
-
return [];
|
|
185
|
-
const sessions = [];
|
|
186
|
-
let current = [entries[0]];
|
|
187
|
-
for (let i = 1; i < entries.length; i++) {
|
|
188
|
-
const gap = entries[i].timestampUnix - entries[i - 1].timestampUnix;
|
|
189
|
-
if (gap > SESSION_GAP_MINUTES * 60) {
|
|
190
|
-
sessions.push(current);
|
|
191
|
-
current = [entries[i]];
|
|
192
|
-
}
|
|
193
|
-
else {
|
|
194
|
-
current.push(entries[i]);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
if (current.length > 0)
|
|
198
|
-
sessions.push(current);
|
|
199
|
-
return sessions;
|
|
200
|
-
}
|
|
201
85
|
}
|
|
@@ -29,6 +29,20 @@ export class MCPMemoryAdapter extends BaseImportAdapter {
|
|
|
29
29
|
else if (input.file_path) {
|
|
30
30
|
try {
|
|
31
31
|
const resolvedPath = input.file_path.replace(/^~/, os.homedir());
|
|
32
|
+
const fileStat = fs.statSync(resolvedPath);
|
|
33
|
+
const fileSizeMB = fileStat.size / (1024 * 1024);
|
|
34
|
+
if (fileSizeMB > 500) {
|
|
35
|
+
errors.push(`File is too large to import: ${fileSizeMB.toFixed(1)}MB exceeds the 500MB cap. ` +
|
|
36
|
+
'Split the file into smaller chunks and import each separately.');
|
|
37
|
+
return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
|
|
38
|
+
}
|
|
39
|
+
const freeMem = os.freemem();
|
|
40
|
+
if (freeMem < fileStat.size * 2) {
|
|
41
|
+
errors.push(`Not enough free memory: ${(freeMem / (1024 * 1024)).toFixed(0)}MB available, ` +
|
|
42
|
+
`~${Math.ceil(fileStat.size * 2 / (1024 * 1024))}MB needed (2× file size). ` +
|
|
43
|
+
'Close other applications or split the file.');
|
|
44
|
+
return { facts: [], chunks: [], totalMessages: 0, warnings, errors };
|
|
45
|
+
}
|
|
32
46
|
content = fs.readFileSync(resolvedPath, 'utf-8');
|
|
33
47
|
}
|
|
34
48
|
catch (e) {
|
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Paths
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
export let IMPORT_STATE_DIR = path.join(os.homedir(), '.totalreclaw', 'import-state');
|
|
15
|
+
const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
|
|
16
|
+
/** Only call from tests. Redirects state I/O to a temp directory. */
|
|
17
|
+
export function setImportStateDirForTests(dir) {
|
|
18
|
+
IMPORT_STATE_DIR = dir;
|
|
19
|
+
}
|
|
20
|
+
export function getImportStatePath(importId) {
|
|
21
|
+
return path.join(IMPORT_STATE_DIR, `${importId}.json`);
|
|
22
|
+
}
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Read / write
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
export function writeImportState(state) {
|
|
27
|
+
fs.mkdirSync(IMPORT_STATE_DIR, { recursive: true });
|
|
28
|
+
state.last_updated = new Date().toISOString();
|
|
29
|
+
fs.writeFileSync(getImportStatePath(state.import_id), JSON.stringify(state, null, 2), 'utf-8');
|
|
30
|
+
}
|
|
31
|
+
export function readImportState(importId) {
|
|
32
|
+
try {
|
|
33
|
+
const raw = fs.readFileSync(getImportStatePath(importId), 'utf-8');
|
|
34
|
+
return JSON.parse(raw);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Freshness
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
export function isImportStale(state) {
|
|
44
|
+
const lastUpdated = new Date(state.last_updated).getTime();
|
|
45
|
+
return Date.now() - lastUpdated > STALE_THRESHOLD_MS;
|
|
46
|
+
}
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Most-recent active import
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
/**
|
|
51
|
+
* Returns the most recently started import whose status is running/pending,
|
|
52
|
+
* or null if none found.
|
|
53
|
+
*/
|
|
54
|
+
export function readMostRecentActiveImport() {
|
|
55
|
+
try {
|
|
56
|
+
const files = fs.readdirSync(IMPORT_STATE_DIR).filter((f) => f.endsWith('.json'));
|
|
57
|
+
let mostRecent = null;
|
|
58
|
+
for (const file of files) {
|
|
59
|
+
try {
|
|
60
|
+
const raw = fs.readFileSync(path.join(IMPORT_STATE_DIR, file), 'utf-8');
|
|
61
|
+
const state = JSON.parse(raw);
|
|
62
|
+
if (state.status === 'running' || state.status === 'pending') {
|
|
63
|
+
if (!mostRecent || state.started_at > mostRecent.started_at) {
|
|
64
|
+
mostRecent = state;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// skip corrupted files
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return mostRecent;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Returns all import states sorted newest-first, regardless of status.
|
|
80
|
+
* Used for resume and audit.
|
|
81
|
+
*/
|
|
82
|
+
export function listAllImportStates() {
|
|
83
|
+
try {
|
|
84
|
+
const files = fs.readdirSync(IMPORT_STATE_DIR).filter((f) => f.endsWith('.json'));
|
|
85
|
+
const states = [];
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
try {
|
|
88
|
+
const raw = fs.readFileSync(path.join(IMPORT_STATE_DIR, file), 'utf-8');
|
|
89
|
+
states.push(JSON.parse(raw));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// skip corrupted
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return states.sort((a, b) => b.started_at.localeCompare(a.started_at));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
}
|