@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.
@@ -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
- // Bootstrap with a personal space
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
- const manifest: SpaceManifest = {
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
- export function saveManifest(manifest: SpaceManifest, baseDir?: string): void {
202
+ function saveManifestUnlocked(manifest: SpaceManifest, baseDir?: string): void {
160
203
  const manifestPath = getManifestPath(baseDir);
161
- fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
162
- fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
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 = memoryDirOverride
171
- ?? readEnvVar("REMNIC_MEMORY_DIR")
172
- ?? readEnvVar("ENGRAM_MEMORY_DIR")
173
- ?? (fs.existsSync(standalonePath) ? standalonePath
174
- : fs.existsSync(openclawPath) ? openclawPath
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 manifest = loadManifest(options.baseDir);
214
- const id = options.name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-");
215
-
216
- if (manifest.spaces.some((s) => s.id === id)) {
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: Space = {
235
- id,
236
- name: options.name,
237
- kind: options.kind,
238
- description: options.description,
239
- memoryDir,
240
- createdAt: now,
241
- updatedAt: now,
242
- owner: readEnvVar("USER"),
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
- // Ensure memory directory exists
247
- fs.mkdirSync(memoryDir, { recursive: true });
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
- manifest.spaces.push(space);
250
- manifest.updatedAt = now;
251
- saveManifest(manifest, options.baseDir);
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
- action: "space.create",
256
- sourceSpaceId: id,
257
- details: `Created ${options.kind} space "${options.name}"`,
258
- }, options.baseDir);
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
- const idx = manifest.spaces.findIndex((s) => s.id === spaceId);
271
- if (idx === -1) throw new Error(`Space "${spaceId}" not found`);
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
- // If deleting active space, switch to personal
274
- if (manifest.activeSpaceId === spaceId) {
275
- manifest.activeSpaceId = "personal";
276
- }
573
+ // If deleting active space, switch to personal
574
+ if (manifest.activeSpaceId === spaceId) {
575
+ manifest.activeSpaceId = "personal";
576
+ }
277
577
 
278
- // Clear parentSpaceId references from children
279
- for (const space of manifest.spaces) {
280
- if (space.parentSpaceId === spaceId) {
281
- space.parentSpaceId = undefined;
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
- manifest.spaces.splice(idx, 1);
286
- saveManifest(manifest, baseDir);
585
+ manifest.spaces.splice(idx, 1);
586
+ });
287
587
 
288
- appendAudit({
289
- action: "space.delete",
290
- sourceSpaceId: spaceId,
291
- details: `Deleted space "${spaceId}"`,
292
- }, baseDir);
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 manifest = loadManifest(baseDir);
299
- const space = manifest.spaces.find((s) => s.id === spaceId);
300
-
301
- if (!space) throw new Error(`Space "${spaceId}" not found`);
302
-
303
- const previousId = manifest.activeSpaceId;
304
- manifest.activeSpaceId = spaceId;
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
- action: "space.switch",
309
- sourceSpaceId: previousId,
310
- targetSpaceId: spaceId,
311
- details: `Switched from "${previousId}" to "${spaceId}"`,
312
- }, baseDir);
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 "${space.name}"`,
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
- action: "space.push",
344
- sourceSpaceId,
345
- targetSpaceId,
346
- details: `Pushed ${result.merged} memories, ${result.conflicts.length} conflicts`,
347
- }, options?.baseDir);
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
- action: "space.pull",
379
- sourceSpaceId,
380
- targetSpaceId,
381
- details: `Pulled ${result.merged} memories, ${result.conflicts.length} conflicts`,
382
- }, options?.baseDir);
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
- spaceId: string,
397
- members: string[],
398
- baseDir?: string,
399
- ): SpaceShareResult {
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
- space.members = [...new Set([...(space.members ?? []), ...members])];
407
- space.updatedAt = new Date().toISOString();
408
- saveManifest(manifest, baseDir);
712
+ space.members = [...new Set([...(space.members ?? []), ...members])];
713
+ space.updatedAt = new Date().toISOString();
714
+ return space.name;
715
+ });
409
716
 
410
- appendAudit({
411
- action: "space.share",
412
- sourceSpaceId: spaceId,
413
- details: `Shared with: ${members.join(", ")}`,
414
- }, baseDir);
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 "${space.name}" with ${members.length} member(s)`,
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
- action: "space.promote",
453
- sourceSpaceId,
454
- targetSpaceId,
455
- details: `Promoted ${result.merged} memories from "${source.name}" to "${target.name}"`,
456
- }, options?.baseDir);
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
- action: "space.merge",
489
- sourceSpaceId,
490
- targetSpaceId,
491
- details: `Merged: ${result.merged} merged, ${result.conflicts.length} conflicts, ${result.skipped} skipped`,
492
- }, options?.baseDir);
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) + "\n");
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[] = [];