@remnic/core 9.3.599 → 9.3.601
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/dist/index.js +393 -106
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/spaces/index.test.ts +205 -18
- package/src/spaces/index.ts +453 -139
package/src/spaces/index.ts
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* through push/pull and promotion workflows.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import crypto from "node:crypto";
|
|
11
13
|
import fs from "node:fs";
|
|
12
14
|
import path from "node:path";
|
|
13
|
-
import crypto from "node:crypto";
|
|
14
15
|
import { readEnvVar, resolveHomeDir } from "../runtime/env.js";
|
|
15
16
|
|
|
16
17
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
@@ -123,6 +124,9 @@ export interface AuditEntry {
|
|
|
123
124
|
// ── Manifest management ─────────────────────────────────────────────────────
|
|
124
125
|
|
|
125
126
|
const MANIFEST_VERSION = 1;
|
|
127
|
+
const MANIFEST_LOCK_STALE_MS = 30_000;
|
|
128
|
+
const MANIFEST_LOCK_TIMEOUT_MS = MANIFEST_LOCK_STALE_MS + 10_000;
|
|
129
|
+
const MANIFEST_LOCK_SLEEP_MS = 20;
|
|
126
130
|
|
|
127
131
|
function normalizeSpaceMemoryDir(memoryDir: string): string {
|
|
128
132
|
return path.resolve(memoryDir);
|
|
@@ -138,28 +142,324 @@ export function getManifestPath(baseDir?: string): string {
|
|
|
138
142
|
}
|
|
139
143
|
|
|
140
144
|
export function loadManifest(baseDir?: string, memoryDirOverride?: string): SpaceManifest {
|
|
145
|
+
if (fs.existsSync(getManifestPath(baseDir))) {
|
|
146
|
+
try {
|
|
147
|
+
return readManifestUnlocked(baseDir, memoryDirOverride, { bootstrapIfMissing: false });
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return updateManifest(baseDir, (manifest) => manifest, memoryDirOverride);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function saveManifest(manifest: SpaceManifest, baseDir?: string): void {
|
|
159
|
+
withManifestLock(baseDir, () => {
|
|
160
|
+
saveManifestUnlocked(manifest, baseDir);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function updateManifest<T>(
|
|
165
|
+
baseDir: string | undefined,
|
|
166
|
+
updater: (manifest: SpaceManifest) => T,
|
|
167
|
+
memoryDirOverride?: string
|
|
168
|
+
): T {
|
|
169
|
+
return withManifestLock(baseDir, () => {
|
|
170
|
+
const manifest = readManifestUnlocked(baseDir, memoryDirOverride);
|
|
171
|
+
const result = updater(manifest);
|
|
172
|
+
saveManifestUnlocked(manifest, baseDir);
|
|
173
|
+
return result;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readManifestUnlocked(
|
|
178
|
+
baseDir?: string,
|
|
179
|
+
memoryDirOverride?: string,
|
|
180
|
+
options: { bootstrapIfMissing?: boolean } = {}
|
|
181
|
+
): SpaceManifest {
|
|
141
182
|
const manifestPath = getManifestPath(baseDir);
|
|
142
183
|
|
|
143
184
|
if (!fs.existsSync(manifestPath)) {
|
|
144
|
-
|
|
185
|
+
if (options.bootstrapIfMissing === false) {
|
|
186
|
+
const error = new Error(`Spaces manifest not found: ${manifestPath}`) as NodeJS.ErrnoException;
|
|
187
|
+
error.code = "ENOENT";
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
145
190
|
const personalSpace = createPersonalSpace(baseDir, memoryDirOverride);
|
|
146
|
-
|
|
191
|
+
return {
|
|
147
192
|
activeSpaceId: personalSpace.id,
|
|
148
193
|
spaces: [personalSpace],
|
|
149
194
|
version: MANIFEST_VERSION,
|
|
150
195
|
};
|
|
151
|
-
saveManifest(manifest, baseDir);
|
|
152
|
-
return manifest;
|
|
153
196
|
}
|
|
154
197
|
|
|
155
198
|
const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
156
199
|
return raw as SpaceManifest;
|
|
157
200
|
}
|
|
158
201
|
|
|
159
|
-
|
|
202
|
+
function saveManifestUnlocked(manifest: SpaceManifest, baseDir?: string): void {
|
|
160
203
|
const manifestPath = getManifestPath(baseDir);
|
|
161
|
-
|
|
162
|
-
fs.
|
|
204
|
+
const manifestDir = path.dirname(manifestPath);
|
|
205
|
+
fs.mkdirSync(manifestDir, { recursive: true });
|
|
206
|
+
const tempPath = path.join(manifestDir, `.manifest.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`);
|
|
207
|
+
try {
|
|
208
|
+
fs.writeFileSync(tempPath, `${JSON.stringify(manifest, null, 2)}\n`, { flag: "wx" });
|
|
209
|
+
fs.renameSync(tempPath, manifestPath);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
try {
|
|
212
|
+
fs.rmSync(tempPath, { force: true });
|
|
213
|
+
} catch {
|
|
214
|
+
// Ignore cleanup failures; surface the original write/rename error.
|
|
215
|
+
}
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function withManifestLock<T>(baseDir: string | undefined, operation: () => T): T {
|
|
221
|
+
const lockDir = `${getManifestPath(baseDir)}.lock`;
|
|
222
|
+
fs.mkdirSync(path.dirname(lockDir), { recursive: true });
|
|
223
|
+
const lockOwner = acquireManifestLock(lockDir);
|
|
224
|
+
try {
|
|
225
|
+
return operation();
|
|
226
|
+
} finally {
|
|
227
|
+
releaseManifestLock(lockDir, lockOwner);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function acquireManifestLock(lockDir: string): string {
|
|
232
|
+
const deadline = Date.now() + MANIFEST_LOCK_TIMEOUT_MS;
|
|
233
|
+
const owner = createManifestLockOwner();
|
|
234
|
+
const reclaimDir = getManifestLockReclaimDir(lockDir);
|
|
235
|
+
while (true) {
|
|
236
|
+
if (fs.existsSync(reclaimDir)) {
|
|
237
|
+
removeStaleManifestReclaimLock(reclaimDir);
|
|
238
|
+
}
|
|
239
|
+
if (fs.existsSync(reclaimDir)) {
|
|
240
|
+
if (Date.now() >= deadline) {
|
|
241
|
+
throw new Error(`Timed out waiting for spaces manifest reclaim lock: ${reclaimDir}`);
|
|
242
|
+
}
|
|
243
|
+
sleepSync(MANIFEST_LOCK_SLEEP_MS);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
fs.mkdirSync(lockDir, { recursive: false });
|
|
249
|
+
try {
|
|
250
|
+
fs.writeFileSync(path.join(lockDir, "owner"), `${owner}\n`, { flag: "wx" });
|
|
251
|
+
} catch (error) {
|
|
252
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
if (fs.existsSync(reclaimDir)) {
|
|
256
|
+
releaseManifestLock(lockDir, owner);
|
|
257
|
+
if (Date.now() >= deadline) {
|
|
258
|
+
throw new Error(`Timed out waiting for spaces manifest reclaim lock: ${reclaimDir}`);
|
|
259
|
+
}
|
|
260
|
+
sleepSync(MANIFEST_LOCK_SLEEP_MS);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
return owner;
|
|
264
|
+
} catch (error) {
|
|
265
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
266
|
+
if (code !== "EEXIST") {
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
removeStaleManifestLock(lockDir);
|
|
271
|
+
if (Date.now() >= deadline) {
|
|
272
|
+
throw new Error(`Timed out waiting for spaces manifest lock: ${lockDir}`);
|
|
273
|
+
}
|
|
274
|
+
sleepSync(MANIFEST_LOCK_SLEEP_MS);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function releaseManifestLock(lockDir: string, owner: string): void {
|
|
280
|
+
try {
|
|
281
|
+
const ownerPath = path.join(lockDir, "owner");
|
|
282
|
+
if (fs.readFileSync(ownerPath, "utf8").trim() === owner) {
|
|
283
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
284
|
+
}
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function removeStaleManifestLock(lockDir: string): void {
|
|
293
|
+
const reclaimDir = getManifestLockReclaimDir(lockDir);
|
|
294
|
+
const reclaimOwner = createManifestLockOwner();
|
|
295
|
+
try {
|
|
296
|
+
fs.mkdirSync(reclaimDir, { recursive: false });
|
|
297
|
+
try {
|
|
298
|
+
fs.writeFileSync(path.join(reclaimDir, "owner"), `${reclaimOwner}\n`, { flag: "wx" });
|
|
299
|
+
} catch (error) {
|
|
300
|
+
fs.rmSync(reclaimDir, { recursive: true, force: true });
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
} catch (error) {
|
|
304
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
305
|
+
if (code === "EEXIST") {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const snapshot = readManifestLockSnapshot(lockDir);
|
|
313
|
+
if (!snapshot || Date.now() - snapshot.mtimeMs <= MANIFEST_LOCK_STALE_MS) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (isManifestLockOwnerActive(snapshot.owner)) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const tombstoneDir = `${lockDir}.stale.${process.pid}.${crypto.randomUUID()}`;
|
|
322
|
+
try {
|
|
323
|
+
fs.renameSync(lockDir, tombstoneDir);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
fs.rmSync(tombstoneDir, { recursive: true, force: true });
|
|
331
|
+
} finally {
|
|
332
|
+
fs.rmSync(reclaimDir, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function getManifestLockReclaimDir(lockDir: string): string {
|
|
337
|
+
return `${lockDir}.reclaim`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function createManifestLockOwner(): string {
|
|
341
|
+
return JSON.stringify({
|
|
342
|
+
pid: process.pid,
|
|
343
|
+
startKey: readProcessStartKey(process.pid),
|
|
344
|
+
token: crypto.randomUUID(),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function removeStaleManifestReclaimLock(reclaimDir: string): void {
|
|
349
|
+
const snapshot = readManifestLockSnapshot(reclaimDir);
|
|
350
|
+
if (!snapshot || Date.now() - snapshot.mtimeMs <= MANIFEST_LOCK_STALE_MS) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (isManifestLockOwnerActive(snapshot.owner)) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const tombstoneDir = `${reclaimDir}.stale.${process.pid}.${crypto.randomUUID()}`;
|
|
359
|
+
try {
|
|
360
|
+
fs.renameSync(reclaimDir, tombstoneDir);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
fs.rmSync(tombstoneDir, { recursive: true, force: true });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function readManifestLockSnapshot(lockDir: string): { mtimeMs: number; owner?: string } | undefined {
|
|
371
|
+
let stat: fs.Stats;
|
|
372
|
+
try {
|
|
373
|
+
stat = fs.statSync(lockDir);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
throw error;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
const owner = fs.readFileSync(path.join(lockDir, "owner"), "utf8").trim();
|
|
383
|
+
return { mtimeMs: stat.mtimeMs, owner };
|
|
384
|
+
} catch (error) {
|
|
385
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
386
|
+
return { mtimeMs: stat.mtimeMs };
|
|
387
|
+
}
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function isManifestLockOwnerActive(owner: string | undefined): boolean {
|
|
393
|
+
if (!owner) {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const parsed = parseManifestLockOwner(owner);
|
|
398
|
+
if (!parsed) {
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const currentStartKey = readProcessStartKey(parsed.pid);
|
|
403
|
+
if (currentStartKey && parsed.startKey) {
|
|
404
|
+
return currentStartKey === parsed.startKey;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return isProcessAlive(parsed.pid);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function parseManifestLockOwner(owner: string): { pid: number; startKey?: string } | undefined {
|
|
411
|
+
try {
|
|
412
|
+
const parsed = JSON.parse(owner) as { pid?: unknown; startKey?: unknown };
|
|
413
|
+
const pid = typeof parsed.pid === "number" ? parsed.pid : Number.NaN;
|
|
414
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
415
|
+
return {
|
|
416
|
+
pid,
|
|
417
|
+
startKey: typeof parsed.startKey === "string" && parsed.startKey.length > 0 ? parsed.startKey : undefined,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
} catch {
|
|
421
|
+
// Fall through to legacy owner format.
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const legacyPid = Number(owner.split(":", 1)[0]);
|
|
425
|
+
return Number.isInteger(legacyPid) && legacyPid > 0 ? { pid: legacyPid } : undefined;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function readProcessStartKey(pid: number): string | undefined {
|
|
429
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const result = spawnSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
|
434
|
+
encoding: "utf8",
|
|
435
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
436
|
+
});
|
|
437
|
+
if (result.error || result.status !== 0 || typeof result.stdout !== "string") {
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
const startKey = result.stdout.trim().replace(/\s+/g, " ");
|
|
441
|
+
return startKey.length > 0 ? startKey : undefined;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function isProcessAlive(pid: number): boolean {
|
|
445
|
+
if (pid === process.pid) {
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
process.kill(pid, 0);
|
|
451
|
+
return true;
|
|
452
|
+
} catch (error) {
|
|
453
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
454
|
+
if (code === "ESRCH") {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function sleepSync(ms: number): void {
|
|
462
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
163
463
|
}
|
|
164
464
|
|
|
165
465
|
function createPersonalSpace(baseDir?: string, memoryDirOverride?: string): Space {
|
|
@@ -167,12 +467,11 @@ function createPersonalSpace(baseDir?: string, memoryDirOverride?: string): Spac
|
|
|
167
467
|
// Priority: override > REMNIC_MEMORY_DIR > ENGRAM_MEMORY_DIR > existing standalone dir > existing OpenClaw dir > new standalone dir
|
|
168
468
|
const standalonePath = path.join(homeDir, ".engram", "memory");
|
|
169
469
|
const openclawPath = path.join(homeDir, ".openclaw", "workspace", "memory", "local");
|
|
170
|
-
const memoryDir =
|
|
171
|
-
??
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
: standalonePath);
|
|
470
|
+
const memoryDir =
|
|
471
|
+
memoryDirOverride ??
|
|
472
|
+
readEnvVar("REMNIC_MEMORY_DIR") ??
|
|
473
|
+
readEnvVar("ENGRAM_MEMORY_DIR") ??
|
|
474
|
+
(fs.existsSync(standalonePath) ? standalonePath : fs.existsSync(openclawPath) ? openclawPath : standalonePath);
|
|
176
475
|
const normalizedMemoryDir = normalizeSpaceMemoryDir(memoryDir);
|
|
177
476
|
const now = new Date().toISOString();
|
|
178
477
|
|
|
@@ -210,111 +509,117 @@ export function createSpace(options: {
|
|
|
210
509
|
parentSpaceId?: string;
|
|
211
510
|
baseDir?: string;
|
|
212
511
|
}): Space {
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
throw new Error(`Space "${id}" already exists`);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// Validate parent space exists
|
|
221
|
-
if (options.parentSpaceId && !manifest.spaces.some((s) => s.id === options.parentSpaceId)) {
|
|
222
|
-
throw new Error(`Parent space "${options.parentSpaceId}" not found`);
|
|
223
|
-
}
|
|
224
|
-
|
|
512
|
+
const id = options.name
|
|
513
|
+
.toLowerCase()
|
|
514
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
515
|
+
.replace(/-+/g, "-");
|
|
225
516
|
const now = new Date().toISOString();
|
|
226
517
|
const memoryDir = normalizeSpaceMemoryDir(
|
|
227
|
-
options.memoryDir ?? path.join(
|
|
228
|
-
getSpacesDir(options.baseDir),
|
|
229
|
-
id,
|
|
230
|
-
"memory",
|
|
231
|
-
),
|
|
518
|
+
options.memoryDir ?? path.join(getSpacesDir(options.baseDir), id, "memory")
|
|
232
519
|
);
|
|
233
520
|
|
|
234
|
-
const space
|
|
235
|
-
id
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
parentSpaceId: options.parentSpaceId,
|
|
244
|
-
};
|
|
521
|
+
const space = updateManifest(options.baseDir, (manifest) => {
|
|
522
|
+
if (manifest.spaces.some((s) => s.id === id)) {
|
|
523
|
+
throw new Error(`Space "${id}" already exists`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Validate parent space exists
|
|
527
|
+
if (options.parentSpaceId && !manifest.spaces.some((s) => s.id === options.parentSpaceId)) {
|
|
528
|
+
throw new Error(`Parent space "${options.parentSpaceId}" not found`);
|
|
529
|
+
}
|
|
245
530
|
|
|
246
|
-
|
|
247
|
-
|
|
531
|
+
const created: Space = {
|
|
532
|
+
id,
|
|
533
|
+
name: options.name,
|
|
534
|
+
kind: options.kind,
|
|
535
|
+
description: options.description,
|
|
536
|
+
memoryDir,
|
|
537
|
+
createdAt: now,
|
|
538
|
+
updatedAt: now,
|
|
539
|
+
owner: readEnvVar("USER"),
|
|
540
|
+
parentSpaceId: options.parentSpaceId,
|
|
541
|
+
};
|
|
248
542
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
543
|
+
// Ensure memory directory exists before publishing the manifest entry.
|
|
544
|
+
fs.mkdirSync(memoryDir, { recursive: true });
|
|
545
|
+
|
|
546
|
+
manifest.spaces.push(created);
|
|
547
|
+
manifest.updatedAt = now;
|
|
548
|
+
return created;
|
|
549
|
+
});
|
|
252
550
|
|
|
253
551
|
// Audit
|
|
254
|
-
appendAudit(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
552
|
+
appendAudit(
|
|
553
|
+
{
|
|
554
|
+
action: "space.create",
|
|
555
|
+
sourceSpaceId: id,
|
|
556
|
+
details: `Created ${options.kind} space "${options.name}"`,
|
|
557
|
+
},
|
|
558
|
+
options.baseDir
|
|
559
|
+
);
|
|
259
560
|
|
|
260
561
|
return space;
|
|
261
562
|
}
|
|
262
563
|
|
|
263
564
|
export function deleteSpace(spaceId: string, baseDir?: string): void {
|
|
264
|
-
const manifest = loadManifest(baseDir);
|
|
265
|
-
|
|
266
565
|
if (spaceId === "personal") {
|
|
267
566
|
throw new Error("Cannot delete the personal space");
|
|
268
567
|
}
|
|
269
568
|
|
|
270
|
-
|
|
271
|
-
|
|
569
|
+
updateManifest(baseDir, (manifest) => {
|
|
570
|
+
const idx = manifest.spaces.findIndex((s) => s.id === spaceId);
|
|
571
|
+
if (idx === -1) throw new Error(`Space "${spaceId}" not found`);
|
|
272
572
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
573
|
+
// If deleting active space, switch to personal
|
|
574
|
+
if (manifest.activeSpaceId === spaceId) {
|
|
575
|
+
manifest.activeSpaceId = "personal";
|
|
576
|
+
}
|
|
277
577
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
578
|
+
// Clear parentSpaceId references from children
|
|
579
|
+
for (const space of manifest.spaces) {
|
|
580
|
+
if (space.parentSpaceId === spaceId) {
|
|
581
|
+
space.parentSpaceId = undefined;
|
|
582
|
+
}
|
|
282
583
|
}
|
|
283
|
-
}
|
|
284
584
|
|
|
285
|
-
|
|
286
|
-
|
|
585
|
+
manifest.spaces.splice(idx, 1);
|
|
586
|
+
});
|
|
287
587
|
|
|
288
|
-
appendAudit(
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
588
|
+
appendAudit(
|
|
589
|
+
{
|
|
590
|
+
action: "space.delete",
|
|
591
|
+
sourceSpaceId: spaceId,
|
|
592
|
+
details: `Deleted space "${spaceId}"`,
|
|
593
|
+
},
|
|
594
|
+
baseDir
|
|
595
|
+
);
|
|
293
596
|
}
|
|
294
597
|
|
|
295
598
|
// ── Switch ───────────────────────────────────────────────────────────────────
|
|
296
599
|
|
|
297
600
|
export function switchSpace(spaceId: string, baseDir?: string): SpaceSwitchResult {
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
saveManifest(manifest, baseDir);
|
|
601
|
+
const { previousId, spaceName } = updateManifest(baseDir, (manifest) => {
|
|
602
|
+
const space = manifest.spaces.find((s) => s.id === spaceId);
|
|
603
|
+
if (!space) throw new Error(`Space "${spaceId}" not found`);
|
|
604
|
+
const previousId = manifest.activeSpaceId;
|
|
605
|
+
manifest.activeSpaceId = spaceId;
|
|
606
|
+
return { previousId, spaceName: space.name };
|
|
607
|
+
});
|
|
306
608
|
|
|
307
|
-
appendAudit(
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
609
|
+
appendAudit(
|
|
610
|
+
{
|
|
611
|
+
action: "space.switch",
|
|
612
|
+
sourceSpaceId: previousId,
|
|
613
|
+
targetSpaceId: spaceId,
|
|
614
|
+
details: `Switched from "${previousId}" to "${spaceId}"`,
|
|
615
|
+
},
|
|
616
|
+
baseDir
|
|
617
|
+
);
|
|
313
618
|
|
|
314
619
|
return {
|
|
315
620
|
previousSpaceId: previousId,
|
|
316
621
|
currentSpaceId: spaceId,
|
|
317
|
-
message: `Switched to "${
|
|
622
|
+
message: `Switched to "${spaceName}"`,
|
|
318
623
|
};
|
|
319
624
|
}
|
|
320
625
|
|
|
@@ -323,7 +628,7 @@ export function switchSpace(spaceId: string, baseDir?: string): SpaceSwitchResul
|
|
|
323
628
|
export function pushToSpace(
|
|
324
629
|
sourceSpaceId: string,
|
|
325
630
|
targetSpaceId: string,
|
|
326
|
-
options?: { memoryIds?: string[]; force?: boolean; baseDir?: string }
|
|
631
|
+
options?: { memoryIds?: string[]; force?: boolean; baseDir?: string }
|
|
327
632
|
): SpacePushResult {
|
|
328
633
|
const startTime = Date.now();
|
|
329
634
|
const manifest = loadManifest(options?.baseDir);
|
|
@@ -339,12 +644,15 @@ export function pushToSpace(
|
|
|
339
644
|
force: options?.force,
|
|
340
645
|
});
|
|
341
646
|
|
|
342
|
-
appendAudit(
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
647
|
+
appendAudit(
|
|
648
|
+
{
|
|
649
|
+
action: "space.push",
|
|
650
|
+
sourceSpaceId,
|
|
651
|
+
targetSpaceId,
|
|
652
|
+
details: `Pushed ${result.merged} memories, ${result.conflicts.length} conflicts`,
|
|
653
|
+
},
|
|
654
|
+
options?.baseDir
|
|
655
|
+
);
|
|
348
656
|
|
|
349
657
|
return {
|
|
350
658
|
sourceSpaceId,
|
|
@@ -358,7 +666,7 @@ export function pushToSpace(
|
|
|
358
666
|
export function pullFromSpace(
|
|
359
667
|
sourceSpaceId: string,
|
|
360
668
|
targetSpaceId: string,
|
|
361
|
-
options?: { memoryIds?: string[]; force?: boolean; baseDir?: string }
|
|
669
|
+
options?: { memoryIds?: string[]; force?: boolean; baseDir?: string }
|
|
362
670
|
): SpacePullResult {
|
|
363
671
|
const startTime = Date.now();
|
|
364
672
|
const manifest = loadManifest(options?.baseDir);
|
|
@@ -374,12 +682,15 @@ export function pullFromSpace(
|
|
|
374
682
|
force: options?.force,
|
|
375
683
|
});
|
|
376
684
|
|
|
377
|
-
appendAudit(
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
685
|
+
appendAudit(
|
|
686
|
+
{
|
|
687
|
+
action: "space.pull",
|
|
688
|
+
sourceSpaceId,
|
|
689
|
+
targetSpaceId,
|
|
690
|
+
details: `Pulled ${result.merged} memories, ${result.conflicts.length} conflicts`,
|
|
691
|
+
},
|
|
692
|
+
options?.baseDir
|
|
693
|
+
);
|
|
383
694
|
|
|
384
695
|
return {
|
|
385
696
|
sourceSpaceId,
|
|
@@ -392,31 +703,30 @@ export function pullFromSpace(
|
|
|
392
703
|
|
|
393
704
|
// ── Share ────────────────────────────────────────────────────────────────────
|
|
394
705
|
|
|
395
|
-
export function shareSpace(
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
)
|
|
400
|
-
const manifest = loadManifest(baseDir);
|
|
401
|
-
const space = manifest.spaces.find((s) => s.id === spaceId);
|
|
402
|
-
|
|
403
|
-
if (!space) throw new Error(`Space "${spaceId}" not found`);
|
|
404
|
-
if (space.kind === "personal") throw new Error("Cannot share personal space");
|
|
706
|
+
export function shareSpace(spaceId: string, members: string[], baseDir?: string): SpaceShareResult {
|
|
707
|
+
const spaceName = updateManifest(baseDir, (manifest) => {
|
|
708
|
+
const space = manifest.spaces.find((s) => s.id === spaceId);
|
|
709
|
+
if (!space) throw new Error(`Space "${spaceId}" not found`);
|
|
710
|
+
if (space.kind === "personal") throw new Error("Cannot share personal space");
|
|
405
711
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
712
|
+
space.members = [...new Set([...(space.members ?? []), ...members])];
|
|
713
|
+
space.updatedAt = new Date().toISOString();
|
|
714
|
+
return space.name;
|
|
715
|
+
});
|
|
409
716
|
|
|
410
|
-
appendAudit(
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
717
|
+
appendAudit(
|
|
718
|
+
{
|
|
719
|
+
action: "space.share",
|
|
720
|
+
sourceSpaceId: spaceId,
|
|
721
|
+
details: `Shared with: ${members.join(", ")}`,
|
|
722
|
+
},
|
|
723
|
+
baseDir
|
|
724
|
+
);
|
|
415
725
|
|
|
416
726
|
return {
|
|
417
727
|
spaceId,
|
|
418
728
|
sharedWith: members,
|
|
419
|
-
message: `Shared "${
|
|
729
|
+
message: `Shared "${spaceName}" with ${members.length} member(s)`,
|
|
420
730
|
};
|
|
421
731
|
}
|
|
422
732
|
|
|
@@ -425,7 +735,7 @@ export function shareSpace(
|
|
|
425
735
|
export function promoteSpace(
|
|
426
736
|
sourceSpaceId: string,
|
|
427
737
|
targetSpaceId: string,
|
|
428
|
-
options?: { memoryIds?: string[]; force?: boolean; forceOverwrite?: boolean; baseDir?: string }
|
|
738
|
+
options?: { memoryIds?: string[]; force?: boolean; forceOverwrite?: boolean; baseDir?: string }
|
|
429
739
|
): SpacePromoteResult {
|
|
430
740
|
const startTime = Date.now();
|
|
431
741
|
const manifest = loadManifest(options?.baseDir);
|
|
@@ -448,12 +758,15 @@ export function promoteSpace(
|
|
|
448
758
|
force: options?.forceOverwrite !== undefined ? options.forceOverwrite : (options?.force ?? false),
|
|
449
759
|
});
|
|
450
760
|
|
|
451
|
-
appendAudit(
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
761
|
+
appendAudit(
|
|
762
|
+
{
|
|
763
|
+
action: "space.promote",
|
|
764
|
+
sourceSpaceId,
|
|
765
|
+
targetSpaceId,
|
|
766
|
+
details: `Promoted ${result.merged} memories from "${source.name}" to "${target.name}"`,
|
|
767
|
+
},
|
|
768
|
+
options?.baseDir
|
|
769
|
+
);
|
|
457
770
|
|
|
458
771
|
return {
|
|
459
772
|
sourceSpaceId,
|
|
@@ -469,7 +782,7 @@ export function promoteSpace(
|
|
|
469
782
|
export function mergeSpaces(
|
|
470
783
|
sourceSpaceId: string,
|
|
471
784
|
targetSpaceId: string,
|
|
472
|
-
options?: { force?: boolean; baseDir?: string }
|
|
785
|
+
options?: { force?: boolean; baseDir?: string }
|
|
473
786
|
): MergeResult {
|
|
474
787
|
const startTime = Date.now();
|
|
475
788
|
const manifest = loadManifest(options?.baseDir);
|
|
@@ -484,12 +797,15 @@ export function mergeSpaces(
|
|
|
484
797
|
force: options?.force,
|
|
485
798
|
});
|
|
486
799
|
|
|
487
|
-
appendAudit(
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
800
|
+
appendAudit(
|
|
801
|
+
{
|
|
802
|
+
action: "space.merge",
|
|
803
|
+
sourceSpaceId,
|
|
804
|
+
targetSpaceId,
|
|
805
|
+
details: `Merged: ${result.merged} merged, ${result.conflicts.length} conflicts, ${result.skipped} skipped`,
|
|
806
|
+
},
|
|
807
|
+
options?.baseDir
|
|
808
|
+
);
|
|
493
809
|
|
|
494
810
|
return {
|
|
495
811
|
...result,
|
|
@@ -504,9 +820,7 @@ export function getAuditLog(baseDir?: string): AuditEntry[] {
|
|
|
504
820
|
if (!fs.existsSync(auditPath)) return [];
|
|
505
821
|
|
|
506
822
|
const lines = fs.readFileSync(auditPath, "utf8").trim().split("\n");
|
|
507
|
-
return lines
|
|
508
|
-
.filter((l) => l.trim())
|
|
509
|
-
.map((l) => JSON.parse(l) as AuditEntry);
|
|
823
|
+
return lines.filter((l) => l.trim()).map((l) => JSON.parse(l) as AuditEntry);
|
|
510
824
|
}
|
|
511
825
|
|
|
512
826
|
function appendAudit(entry: Omit<AuditEntry, "id" | "timestamp">, baseDir?: string): void {
|
|
@@ -519,7 +833,7 @@ function appendAudit(entry: Omit<AuditEntry, "id" | "timestamp">, baseDir?: stri
|
|
|
519
833
|
...entry,
|
|
520
834
|
};
|
|
521
835
|
|
|
522
|
-
fs.appendFileSync(auditPath, JSON.stringify(full)
|
|
836
|
+
fs.appendFileSync(auditPath, `${JSON.stringify(full)}\n`);
|
|
523
837
|
}
|
|
524
838
|
|
|
525
839
|
// ── Internal helpers ─────────────────────────────────────────────────────────
|
|
@@ -532,7 +846,7 @@ interface CopyOptions {
|
|
|
532
846
|
function copyMemories(
|
|
533
847
|
sourceDir: string,
|
|
534
848
|
targetDir: string,
|
|
535
|
-
options?: CopyOptions
|
|
849
|
+
options?: CopyOptions
|
|
536
850
|
): { merged: number; conflicts: ConflictEntry[]; skipped: number } {
|
|
537
851
|
let merged = 0;
|
|
538
852
|
const conflicts: ConflictEntry[] = [];
|