mr-memory 3.1.0 → 3.2.0
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/index.ts +64 -2
- package/package.json +3 -2
- package/sync.ts +367 -0
package/index.ts
CHANGED
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
import { readFile, readdir, stat, lstat } from "node:fs/promises";
|
|
12
12
|
import { join, resolve, relative, isAbsolute, sep } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
13
14
|
import path from "node:path";
|
|
14
15
|
import { spawn } from "node:child_process";
|
|
15
16
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
17
|
+
import { syncWorkspaceFiles, runSyncCli, showManifestStatus } from "./sync.js";
|
|
16
18
|
|
|
17
19
|
const DEFAULT_ENDPOINT = "https://api.memoryrouter.ai";
|
|
18
20
|
|
|
@@ -1192,6 +1194,33 @@ const memoryRouterPlugin = {
|
|
|
1192
1194
|
console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1193
1195
|
}
|
|
1194
1196
|
});
|
|
1197
|
+
|
|
1198
|
+
// ── Workspace Sync ──
|
|
1199
|
+
mr.command("sync")
|
|
1200
|
+
.description("Sync workspace files to vault using source_hash tracking")
|
|
1201
|
+
.option("--workspace <dir>", "Workspace directory")
|
|
1202
|
+
.option("--status", "Show manifest status instead of syncing")
|
|
1203
|
+
.action(async (opts: { workspace?: string; status?: boolean }) => {
|
|
1204
|
+
if (opts.status) {
|
|
1205
|
+
await showManifestStatus();
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
if (!memoryKey) { console.error("Not configured. Run: openclaw mr <key>"); return; }
|
|
1209
|
+
const os = await import("node:os");
|
|
1210
|
+
const configWorkspace = (api.config as any).workspace || (api.config as any).agents?.defaults?.workspace;
|
|
1211
|
+
const workspaceDir = opts.workspace
|
|
1212
|
+
? path.resolve(opts.workspace)
|
|
1213
|
+
: configWorkspace
|
|
1214
|
+
? path.resolve(configWorkspace.replace(/^~/, os.homedir()))
|
|
1215
|
+
: path.join(os.homedir(), ".openclaw", "workspace");
|
|
1216
|
+
await runSyncCli({
|
|
1217
|
+
workspaceDir,
|
|
1218
|
+
endpoint,
|
|
1219
|
+
memoryKey,
|
|
1220
|
+
embeddings,
|
|
1221
|
+
logging: true,
|
|
1222
|
+
});
|
|
1223
|
+
});
|
|
1195
1224
|
},
|
|
1196
1225
|
{ commands: ["mr"] },
|
|
1197
1226
|
);
|
|
@@ -1202,8 +1231,41 @@ const memoryRouterPlugin = {
|
|
|
1202
1231
|
|
|
1203
1232
|
api.registerService({
|
|
1204
1233
|
id: "mr-memory",
|
|
1205
|
-
start: () => {
|
|
1206
|
-
if (memoryKey)
|
|
1234
|
+
start: async () => {
|
|
1235
|
+
if (memoryKey) {
|
|
1236
|
+
api.logger.info?.(`memoryrouter: active (key: ${memoryKey.slice(0, 6)}...)`);
|
|
1237
|
+
|
|
1238
|
+
// ── Auto-sync workspace files (non-blocking) ──
|
|
1239
|
+
// Resolve workspace directory
|
|
1240
|
+
const configWorkspace = (api.config as any).workspace || (api.config as any).agents?.defaults?.workspace;
|
|
1241
|
+
const workspaceDir = configWorkspace
|
|
1242
|
+
? path.resolve(configWorkspace.replace(/^~/, homedir()))
|
|
1243
|
+
: path.join(homedir(), ".openclaw", "workspace");
|
|
1244
|
+
|
|
1245
|
+
// Fire and forget — don't block service startup
|
|
1246
|
+
syncWorkspaceFiles({
|
|
1247
|
+
workspaceDir,
|
|
1248
|
+
endpoint,
|
|
1249
|
+
memoryKey,
|
|
1250
|
+
embeddings,
|
|
1251
|
+
logging,
|
|
1252
|
+
})
|
|
1253
|
+
.then((result) => {
|
|
1254
|
+
if (result.uploaded > 0 || result.deleted > 0) {
|
|
1255
|
+
api.logger.info?.(
|
|
1256
|
+
`memoryrouter: sync complete — ${result.uploaded} uploaded, ${result.deleted} deleted, ${result.unchanged} unchanged`,
|
|
1257
|
+
);
|
|
1258
|
+
} else {
|
|
1259
|
+
log(`memoryrouter: sync complete — ${result.unchanged} files unchanged`);
|
|
1260
|
+
}
|
|
1261
|
+
if (result.errors.length > 0) {
|
|
1262
|
+
api.logger.warn?.(`memoryrouter: sync had ${result.errors.length} errors`);
|
|
1263
|
+
}
|
|
1264
|
+
})
|
|
1265
|
+
.catch((err) => {
|
|
1266
|
+
api.logger.warn?.(`memoryrouter: sync error — ${err instanceof Error ? err.message : String(err)}`);
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1207
1269
|
},
|
|
1208
1270
|
stop: () => {
|
|
1209
1271
|
api.logger.info?.("memoryrouter: stopped");
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mr-memory",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "MemoryRouter persistent memory plugin for OpenClaw — your AI remembers every conversation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"README.md",
|
|
8
8
|
"index.ts",
|
|
9
9
|
"openclaw.plugin.json",
|
|
10
|
-
"upload.ts"
|
|
10
|
+
"upload.ts",
|
|
11
|
+
"sync.ts"
|
|
11
12
|
],
|
|
12
13
|
"keywords": [
|
|
13
14
|
"openclaw",
|
package/sync.ts
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryRouter Workspace Sync — source_hash-based file tracking.
|
|
3
|
+
*
|
|
4
|
+
* Tracks workspace files via SHA-256 content hashes. When files change,
|
|
5
|
+
* old chunks are deleted and new content is uploaded with the new hash.
|
|
6
|
+
* This enables clean replacement without stale chunk accumulation.
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT: source_hash is ONLY for workspace files, NEVER for sessions.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFile, writeFile, readdir, lstat, mkdir } from "node:fs/promises";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { join, relative, sep } from "node:path";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
|
|
16
|
+
// ── Types ──
|
|
17
|
+
|
|
18
|
+
export interface ManifestFileEntry {
|
|
19
|
+
source_hash: string;
|
|
20
|
+
size: number;
|
|
21
|
+
chunks: number;
|
|
22
|
+
uploaded_at: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Manifest {
|
|
26
|
+
version: number;
|
|
27
|
+
lastSync: number;
|
|
28
|
+
files: Record<string, ManifestFileEntry>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SyncResult {
|
|
32
|
+
uploaded: number;
|
|
33
|
+
deleted: number;
|
|
34
|
+
unchanged: number;
|
|
35
|
+
errors: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SyncOptions {
|
|
39
|
+
workspaceDir: string;
|
|
40
|
+
endpoint: string;
|
|
41
|
+
memoryKey: string;
|
|
42
|
+
embeddings?: string;
|
|
43
|
+
logging?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Constants ──
|
|
47
|
+
|
|
48
|
+
const MANIFEST_PATH = join(homedir(), ".openclaw", "mr-memory-manifest.json");
|
|
49
|
+
const MAX_FILE_SIZE = 1_000_000; // 1MB limit
|
|
50
|
+
const TEXT_EXTENSIONS = new Set(["md", "txt"]);
|
|
51
|
+
|
|
52
|
+
// ── Manifest I/O ──
|
|
53
|
+
|
|
54
|
+
export async function loadManifest(): Promise<Manifest> {
|
|
55
|
+
try {
|
|
56
|
+
const raw = await readFile(MANIFEST_PATH, "utf-8");
|
|
57
|
+
return JSON.parse(raw) as Manifest;
|
|
58
|
+
} catch {
|
|
59
|
+
// First run or corrupted — create empty manifest
|
|
60
|
+
return { version: 1, lastSync: 0, files: {} };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function saveManifest(manifest: Manifest): Promise<void> {
|
|
65
|
+
manifest.lastSync = Date.now();
|
|
66
|
+
// Ensure directory exists
|
|
67
|
+
const dir = join(homedir(), ".openclaw");
|
|
68
|
+
await mkdir(dir, { recursive: true });
|
|
69
|
+
await writeFile(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Hash Computation ──
|
|
73
|
+
|
|
74
|
+
export function computeSourceHash(content: string): string {
|
|
75
|
+
const hash = createHash("sha256").update(content).digest("hex");
|
|
76
|
+
return hash.substring(0, 16); // First 16 hex chars, matching MR API format
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── File Discovery ──
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Discover ALL workspace files to sync:
|
|
83
|
+
* - All .md and .txt files in workspace root
|
|
84
|
+
* - All files recursively under memory/ directory
|
|
85
|
+
* - Excludes: hidden files/dirs, node_modules/, files > 1MB, binary files
|
|
86
|
+
*/
|
|
87
|
+
export async function discoverWorkspaceFiles(workspaceDir: string): Promise<string[]> {
|
|
88
|
+
const files: string[] = [];
|
|
89
|
+
|
|
90
|
+
async function walk(dir: string) {
|
|
91
|
+
let entries;
|
|
92
|
+
try {
|
|
93
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
94
|
+
} catch {
|
|
95
|
+
return; // Directory doesn't exist or not readable
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
// Skip hidden dirs/files and node_modules
|
|
100
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
101
|
+
|
|
102
|
+
const fullPath = join(dir, entry.name);
|
|
103
|
+
|
|
104
|
+
if (entry.isDirectory()) {
|
|
105
|
+
await walk(fullPath);
|
|
106
|
+
} else if (entry.isFile()) {
|
|
107
|
+
// Only text files we want to index
|
|
108
|
+
const ext = entry.name.toLowerCase().split(".").pop() || "";
|
|
109
|
+
if (!TEXT_EXTENSIONS.has(ext)) continue;
|
|
110
|
+
|
|
111
|
+
// Check file size
|
|
112
|
+
try {
|
|
113
|
+
const stat = await lstat(fullPath);
|
|
114
|
+
if (stat.size > MAX_FILE_SIZE) continue;
|
|
115
|
+
if (stat.size === 0) continue; // Skip empty files
|
|
116
|
+
files.push(relative(workspaceDir, fullPath));
|
|
117
|
+
} catch {
|
|
118
|
+
continue; // Skip unreadable files
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
await walk(workspaceDir);
|
|
125
|
+
return files.sort();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── API Helpers ──
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Upload file content with source_hash using isolated chunking.
|
|
132
|
+
* Uses POST /v1/memory/upload-source endpoint.
|
|
133
|
+
*/
|
|
134
|
+
export async function uploadSourceFile(
|
|
135
|
+
endpoint: string,
|
|
136
|
+
memoryKey: string,
|
|
137
|
+
content: string,
|
|
138
|
+
sourceHash: string,
|
|
139
|
+
embeddings?: string,
|
|
140
|
+
): Promise<{ stored: number; chunks: number }> {
|
|
141
|
+
const res = await fetch(`${endpoint}/v1/memory/upload-source`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: {
|
|
144
|
+
Authorization: `Bearer ${memoryKey}`,
|
|
145
|
+
"Content-Type": "application/json",
|
|
146
|
+
...(embeddings && { "X-Embedding-Model": embeddings }),
|
|
147
|
+
},
|
|
148
|
+
body: JSON.stringify({ content, source_hash: sourceHash }),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (!res.ok) {
|
|
152
|
+
const errText = await res.text().catch(() => "");
|
|
153
|
+
throw new Error(`Upload failed: HTTP ${res.status} — ${errText.slice(0, 200)}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const data = (await res.json()) as { stored: number; chunks: number; source_hash: string };
|
|
157
|
+
return { stored: data.stored || 0, chunks: data.chunks || data.stored || 0 };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Delete all chunks with a given source_hash.
|
|
162
|
+
* Uses POST /v1/memory/source/delete (POST fallback for clients that can't send DELETE with body).
|
|
163
|
+
*/
|
|
164
|
+
export async function deleteBySourceHash(
|
|
165
|
+
endpoint: string,
|
|
166
|
+
memoryKey: string,
|
|
167
|
+
sourceHash: string,
|
|
168
|
+
): Promise<number> {
|
|
169
|
+
const res = await fetch(`${endpoint}/v1/memory/source/delete`, {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: {
|
|
172
|
+
Authorization: `Bearer ${memoryKey}`,
|
|
173
|
+
"Content-Type": "application/json",
|
|
174
|
+
},
|
|
175
|
+
body: JSON.stringify({ source_hash: sourceHash }),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
const errText = await res.text().catch(() => "");
|
|
180
|
+
throw new Error(`Delete failed: HTTP ${res.status} — ${errText.slice(0, 200)}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const data = (await res.json()) as { deleted: number; source_hash: string };
|
|
184
|
+
return data.deleted || 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Main Sync Flow ──
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Sync workspace files to MemoryRouter using source_hash tracking.
|
|
191
|
+
*
|
|
192
|
+
* Algorithm:
|
|
193
|
+
* 1. Load manifest (previous state)
|
|
194
|
+
* 2. Discover current workspace files, compute hashes
|
|
195
|
+
* 3. Diff current vs manifest:
|
|
196
|
+
* - NEW: upload with source_hash
|
|
197
|
+
* - CHANGED: delete old hash, upload new
|
|
198
|
+
* - DELETED: delete from MR
|
|
199
|
+
* - UNCHANGED: skip
|
|
200
|
+
* 4. Save updated manifest
|
|
201
|
+
*/
|
|
202
|
+
export async function syncWorkspaceFiles(options: SyncOptions): Promise<SyncResult> {
|
|
203
|
+
const { workspaceDir, endpoint, memoryKey, embeddings, logging } = options;
|
|
204
|
+
const log = (msg: string) => {
|
|
205
|
+
if (logging) console.log(msg);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const result: SyncResult = { uploaded: 0, deleted: 0, unchanged: 0, errors: [] };
|
|
209
|
+
|
|
210
|
+
// 1. Load manifest
|
|
211
|
+
const manifest = await loadManifest();
|
|
212
|
+
log(`memoryrouter: sync starting (${Object.keys(manifest.files).length} files in manifest)`);
|
|
213
|
+
|
|
214
|
+
// 2. Discover workspace files and compute hashes
|
|
215
|
+
const diskFiles = await discoverWorkspaceFiles(workspaceDir);
|
|
216
|
+
log(`memoryrouter: discovered ${diskFiles.length} workspace files`);
|
|
217
|
+
|
|
218
|
+
const diskState = new Map<string, { hash: string; size: number; content: string }>();
|
|
219
|
+
|
|
220
|
+
for (const relPath of diskFiles) {
|
|
221
|
+
const absPath = join(workspaceDir, relPath);
|
|
222
|
+
try {
|
|
223
|
+
const content = await readFile(absPath, "utf-8");
|
|
224
|
+
const trimmed = content.trim();
|
|
225
|
+
if (!trimmed) continue; // Skip empty files
|
|
226
|
+
|
|
227
|
+
const hash = computeSourceHash(content);
|
|
228
|
+
const size = Buffer.byteLength(content, "utf-8");
|
|
229
|
+
diskState.set(relPath, { hash, size, content });
|
|
230
|
+
} catch (err) {
|
|
231
|
+
result.errors.push(`Read error: ${relPath} — ${err instanceof Error ? err.message : String(err)}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 3a. Process disk files (new + changed)
|
|
236
|
+
for (const [relPath, { hash, size, content }] of diskState) {
|
|
237
|
+
const manifestEntry = manifest.files[relPath];
|
|
238
|
+
|
|
239
|
+
if (!manifestEntry) {
|
|
240
|
+
// NEW FILE — upload
|
|
241
|
+
try {
|
|
242
|
+
const { chunks } = await uploadSourceFile(endpoint, memoryKey, content, hash, embeddings);
|
|
243
|
+
manifest.files[relPath] = {
|
|
244
|
+
source_hash: hash,
|
|
245
|
+
size,
|
|
246
|
+
chunks,
|
|
247
|
+
uploaded_at: Date.now(),
|
|
248
|
+
};
|
|
249
|
+
result.uploaded++;
|
|
250
|
+
log(`memoryrouter: sync uploaded new file ${relPath} (${chunks} chunks)`);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
result.errors.push(`Upload error: ${relPath} — ${err instanceof Error ? err.message : String(err)}`);
|
|
253
|
+
}
|
|
254
|
+
} else if (manifestEntry.source_hash !== hash) {
|
|
255
|
+
// CHANGED FILE — delete old, upload new
|
|
256
|
+
try {
|
|
257
|
+
const deleted = await deleteBySourceHash(endpoint, memoryKey, manifestEntry.source_hash);
|
|
258
|
+
log(`memoryrouter: sync deleted old version of ${relPath} (${deleted} chunks)`);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
// Log but continue — old chunks may have been deleted manually
|
|
261
|
+
log(`memoryrouter: sync delete warning for ${relPath} — ${err instanceof Error ? err.message : String(err)}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const { chunks } = await uploadSourceFile(endpoint, memoryKey, content, hash, embeddings);
|
|
266
|
+
manifest.files[relPath] = {
|
|
267
|
+
source_hash: hash,
|
|
268
|
+
size,
|
|
269
|
+
chunks,
|
|
270
|
+
uploaded_at: Date.now(),
|
|
271
|
+
};
|
|
272
|
+
result.uploaded++;
|
|
273
|
+
log(`memoryrouter: sync updated file ${relPath} (${chunks} new chunks)`);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
result.errors.push(`Upload error: ${relPath} — ${err instanceof Error ? err.message : String(err)}`);
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
// UNCHANGED — skip
|
|
279
|
+
result.unchanged++;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 3b. Handle deleted files (in manifest but not on disk)
|
|
284
|
+
for (const relPath of Object.keys(manifest.files)) {
|
|
285
|
+
if (!diskState.has(relPath)) {
|
|
286
|
+
try {
|
|
287
|
+
const deleted = await deleteBySourceHash(endpoint, memoryKey, manifest.files[relPath].source_hash);
|
|
288
|
+
delete manifest.files[relPath];
|
|
289
|
+
result.deleted++;
|
|
290
|
+
log(`memoryrouter: sync deleted removed file ${relPath} (${deleted} chunks)`);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
result.errors.push(`Delete error: ${relPath} — ${err instanceof Error ? err.message : String(err)}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 4. Save updated manifest
|
|
298
|
+
await saveManifest(manifest);
|
|
299
|
+
|
|
300
|
+
log(
|
|
301
|
+
`memoryrouter: sync complete — ${result.uploaded} uploaded, ${result.deleted} deleted, ${result.unchanged} unchanged`,
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── CLI Helper ──
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Run sync from CLI with console output.
|
|
311
|
+
*/
|
|
312
|
+
export async function runSyncCli(options: SyncOptions): Promise<void> {
|
|
313
|
+
const { workspaceDir, embeddings } = options;
|
|
314
|
+
|
|
315
|
+
const modelLabel = embeddings ? `(model: ${embeddings})` : "(model: bge)";
|
|
316
|
+
console.log(`Syncing workspace to MemoryRouter ${modelLabel}...`);
|
|
317
|
+
console.log(` Workspace: ${workspaceDir}`);
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const result = await syncWorkspaceFiles({ ...options, logging: true });
|
|
321
|
+
|
|
322
|
+
console.log("\n────────────────────────────────");
|
|
323
|
+
console.log(`✅ Sync complete`);
|
|
324
|
+
console.log(` Uploaded: ${result.uploaded}`);
|
|
325
|
+
console.log(` Deleted: ${result.deleted}`);
|
|
326
|
+
console.log(` Unchanged: ${result.unchanged}`);
|
|
327
|
+
|
|
328
|
+
if (result.errors.length > 0) {
|
|
329
|
+
console.log(`\n⚠️ ${result.errors.length} errors:`);
|
|
330
|
+
for (const err of result.errors.slice(0, 5)) {
|
|
331
|
+
console.log(` • ${err}`);
|
|
332
|
+
}
|
|
333
|
+
if (result.errors.length > 5) {
|
|
334
|
+
console.log(` • ... and ${result.errors.length - 5} more`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
} catch (err) {
|
|
338
|
+
console.error(`\n❌ Sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Show manifest status from CLI.
|
|
344
|
+
*/
|
|
345
|
+
export async function showManifestStatus(): Promise<void> {
|
|
346
|
+
const manifest = await loadManifest();
|
|
347
|
+
const fileCount = Object.keys(manifest.files).length;
|
|
348
|
+
|
|
349
|
+
console.log("Workspace Sync Manifest");
|
|
350
|
+
console.log("───────────────────────────────");
|
|
351
|
+
console.log(` Path: ${MANIFEST_PATH}`);
|
|
352
|
+
console.log(` Version: ${manifest.version}`);
|
|
353
|
+
console.log(` Last sync: ${manifest.lastSync ? new Date(manifest.lastSync).toLocaleString() : "never"}`);
|
|
354
|
+
console.log(` Files: ${fileCount}`);
|
|
355
|
+
|
|
356
|
+
if (fileCount > 0) {
|
|
357
|
+
console.log("\n Tracked files:");
|
|
358
|
+
const files = Object.entries(manifest.files).sort((a, b) => a[0].localeCompare(b[0]));
|
|
359
|
+
for (const [path, entry] of files.slice(0, 20)) {
|
|
360
|
+
const date = new Date(entry.uploaded_at).toLocaleDateString();
|
|
361
|
+
console.log(` • ${path} (${entry.chunks} chunks, ${date})`);
|
|
362
|
+
}
|
|
363
|
+
if (files.length > 20) {
|
|
364
|
+
console.log(` ... and ${files.length - 20} more`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|