@remnic/core 1.1.15 → 1.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/access-cli.js +2 -2
  2. package/dist/access-http.d.ts +1 -1
  3. package/dist/access-http.js +5 -5
  4. package/dist/access-mcp.d.ts +1 -1
  5. package/dist/access-mcp.js +4 -4
  6. package/dist/access-schema.d.ts +17 -3
  7. package/dist/access-schema.js +3 -1
  8. package/dist/{access-service-BCMine1s.d.ts → access-service-DZXc7qwR.d.ts} +11 -1
  9. package/dist/access-service.d.ts +1 -1
  10. package/dist/access-service.js +2 -2
  11. package/dist/{chunk-GSP6ZKOY.js → chunk-2OZ6GP27.js} +81 -18
  12. package/dist/chunk-2OZ6GP27.js.map +1 -0
  13. package/dist/{chunk-VWFIQOTJ.js → chunk-66H2DZYB.js} +8 -1
  14. package/dist/chunk-66H2DZYB.js.map +1 -0
  15. package/dist/{chunk-ZYRMKWVW.js → chunk-HJILHQOR.js} +4 -4
  16. package/dist/{chunk-HJ2WMBFB.js → chunk-MTYLGYOQ.js} +15 -4
  17. package/dist/chunk-MTYLGYOQ.js.map +1 -0
  18. package/dist/{chunk-BNATB54A.js → chunk-SK42SSAN.js} +3 -3
  19. package/dist/{chunk-5D2G67ZQ.js → chunk-Y2YBRCEF.js} +29 -3
  20. package/dist/chunk-Y2YBRCEF.js.map +1 -0
  21. package/dist/{cli-B71zQ6XK.d.ts → cli-kVwab1_L.d.ts} +1 -1
  22. package/dist/cli.d.ts +2 -2
  23. package/dist/cli.js +6 -6
  24. package/dist/index.d.ts +4 -4
  25. package/dist/index.js +8 -6
  26. package/dist/index.js.map +1 -1
  27. package/dist/mcp-memory-inspector-app.d.ts +1 -1
  28. package/dist/offline-sync.d.ts +10 -1
  29. package/dist/offline-sync.js +3 -1
  30. package/dist/schemas.d.ts +22 -22
  31. package/dist/transfer/types.d.ts +12 -12
  32. package/package.json +1 -1
  33. package/src/access-http.test.ts +73 -0
  34. package/src/access-http.ts +15 -0
  35. package/src/access-schema.ts +12 -0
  36. package/src/access-service-namespace.test.ts +64 -1
  37. package/src/access-service.ts +44 -0
  38. package/src/index.ts +1 -0
  39. package/src/offline-sync.test.ts +125 -0
  40. package/src/offline-sync.ts +107 -18
  41. package/dist/chunk-5D2G67ZQ.js.map +0 -1
  42. package/dist/chunk-GSP6ZKOY.js.map +0 -1
  43. package/dist/chunk-HJ2WMBFB.js.map +0 -1
  44. package/dist/chunk-VWFIQOTJ.js.map +0 -1
  45. /package/dist/{chunk-ZYRMKWVW.js.map → chunk-HJILHQOR.js.map} +0 -0
  46. /package/dist/{chunk-BNATB54A.js.map → chunk-SK42SSAN.js.map} +0 -0
@@ -138,6 +138,7 @@ import {
138
138
  import {
139
139
  applyOfflineSyncChangeset,
140
140
  buildOfflineSyncSnapshot,
141
+ buildOfflineSyncSnapshotForPaths,
141
142
  type OfflineSyncApplyChangesetResult,
142
143
  type OfflineSyncSnapshot,
143
144
  } from "./offline-sync.js";
@@ -616,6 +617,13 @@ export interface EngramAccessOfflineSyncSnapshotRequest {
616
617
  includeContent?: boolean;
617
618
  }
618
619
 
620
+ export interface EngramAccessOfflineSyncFilesRequest {
621
+ namespace?: string;
622
+ principal?: string;
623
+ includeTranscripts?: boolean;
624
+ paths: string[];
625
+ }
626
+
619
627
  export interface EngramAccessOfflineSyncApplyRequest {
620
628
  namespace?: string;
621
629
  principal?: string;
@@ -626,6 +634,10 @@ export interface EngramAccessOfflineSyncSnapshotResponse extends OfflineSyncSnap
626
634
  namespace: string;
627
635
  }
628
636
 
637
+ export interface EngramAccessOfflineSyncFilesResponse extends OfflineSyncSnapshot {
638
+ namespace: string;
639
+ }
640
+
629
641
  export interface EngramAccessOfflineSyncApplyResponse extends OfflineSyncApplyChangesetResult {
630
642
  namespace: string;
631
643
  }
@@ -5568,6 +5580,38 @@ export class EngramAccessService {
5568
5580
  };
5569
5581
  }
5570
5582
 
5583
+ async offlineSyncFiles(
5584
+ options: EngramAccessOfflineSyncFilesRequest,
5585
+ ): Promise<EngramAccessOfflineSyncFilesResponse> {
5586
+ const resolvedNamespace = this.resolveReadableNamespace(options.namespace, options.principal);
5587
+ const storage = await this.orchestrator.getStorage(resolvedNamespace);
5588
+ const storageHash = createHash("sha256").update(storage.dir).digest("hex").slice(0, 16);
5589
+ try {
5590
+ const snapshot = await buildOfflineSyncSnapshotForPaths({
5591
+ root: storage.dir,
5592
+ sourceId: `remnic:${resolvedNamespace}:${storageHash}`,
5593
+ paths: options.paths,
5594
+ includeContent: true,
5595
+ includeTranscripts: options.includeTranscripts !== false,
5596
+ readFile: async ({ filePath }) => storage.readOfflineSyncFile(filePath),
5597
+ });
5598
+ return {
5599
+ namespace: resolvedNamespace,
5600
+ ...snapshot,
5601
+ };
5602
+ } catch (error) {
5603
+ const message = error instanceof Error ? error.message : String(error);
5604
+ if (
5605
+ message.startsWith("paths[]:") ||
5606
+ message.startsWith("buildOfflineSyncSnapshotForPaths: record path ") ||
5607
+ message.startsWith("offline sync snapshot path is excluded:")
5608
+ ) {
5609
+ throw new EngramAccessInputError(message);
5610
+ }
5611
+ throw error;
5612
+ }
5613
+ }
5614
+
5571
5615
  async offlineSyncApply(
5572
5616
  options: EngramAccessOfflineSyncApplyRequest,
5573
5617
  ): Promise<EngramAccessOfflineSyncApplyResponse> {
package/src/index.ts CHANGED
@@ -686,6 +686,7 @@ export {
686
686
  applyOfflineSyncSnapshot,
687
687
  buildOfflineSyncChangeset,
688
688
  buildOfflineSyncSnapshot,
689
+ buildOfflineSyncSnapshotForPaths,
689
690
  defaultOfflineSyncStatePath,
690
691
  fileStatesFromSnapshot,
691
692
  normalizeOfflineSyncChangeset,
@@ -9,6 +9,7 @@ import {
9
9
  applyOfflineSyncSnapshot,
10
10
  buildOfflineSyncChangeset,
11
11
  buildOfflineSyncSnapshot,
12
+ buildOfflineSyncSnapshotForPaths,
12
13
  } from "./offline-sync.js";
13
14
  import { isEncryptedFile } from "./secure-store/secure-fs.js";
14
15
  import { StorageManager } from "./storage.js";
@@ -106,6 +107,130 @@ test("offline changeset pushes local edits when the remote is still at the share
106
107
  }
107
108
  });
108
109
 
110
+ test("offline changeset only carries content for changed local files", async () => {
111
+ const local = await tempDir("remnic-offline-changeset-content");
112
+ try {
113
+ await write(local, "facts/unchanged.md", "same");
114
+ await write(local, "facts/changed.md", "before");
115
+ const base = await buildOfflineSyncSnapshot({
116
+ root: local,
117
+ sourceId: "remote",
118
+ includeContent: false,
119
+ });
120
+
121
+ await write(local, "facts/changed.md", "after");
122
+ await write(local, "facts/empty.md", "");
123
+ const changeset = await buildOfflineSyncChangeset({
124
+ root: local,
125
+ sourceId: "laptop",
126
+ baseFiles: base.files,
127
+ });
128
+
129
+ assert.deepEqual(
130
+ changeset.changes.map((change) => change.path),
131
+ ["facts/changed.md", "facts/empty.md"],
132
+ );
133
+ const empty = changeset.changes.find((change) => change.path === "facts/empty.md");
134
+ assert.equal(empty?.type, "upsert");
135
+ if (empty?.type === "upsert") {
136
+ assert.equal(empty.file.contentBase64, "");
137
+ }
138
+ assert.equal(JSON.stringify(changeset).includes("same"), false);
139
+ } finally {
140
+ await rm(local, { recursive: true, force: true });
141
+ }
142
+ });
143
+
144
+ test("offline pull accepts metadata-only snapshots when files are unchanged", async () => {
145
+ const remote = await tempDir("remnic-offline-metadata-remote");
146
+ const local = await tempDir("remnic-offline-metadata-local");
147
+ try {
148
+ await write(remote, "facts/shared.md", "base");
149
+ const initial = await buildOfflineSyncSnapshot({
150
+ root: remote,
151
+ sourceId: "remote",
152
+ includeContent: true,
153
+ });
154
+ const firstPull = await applyOfflineSyncSnapshot({
155
+ root: local,
156
+ snapshot: initial,
157
+ });
158
+ const metadataOnly = await buildOfflineSyncSnapshot({
159
+ root: remote,
160
+ sourceId: "remote",
161
+ includeContent: false,
162
+ });
163
+
164
+ const secondPull = await applyOfflineSyncSnapshot({
165
+ root: local,
166
+ snapshot: metadataOnly,
167
+ baseFiles: firstPull.nextBaseFiles,
168
+ });
169
+
170
+ assert.equal(secondPull.conflicts.length, 0);
171
+ assert.equal(secondPull.upserted, 0);
172
+ assert.equal(secondPull.skipped, 1);
173
+ } finally {
174
+ await rm(remote, { recursive: true, force: true });
175
+ await rm(local, { recursive: true, force: true });
176
+ }
177
+ });
178
+
179
+ test("offline pull applies snapshots with content only for remote-changed files", async () => {
180
+ const remote = await tempDir("remnic-offline-partial-remote");
181
+ const local = await tempDir("remnic-offline-partial-local");
182
+ try {
183
+ await write(remote, "facts/shared.md", "base");
184
+ await write(remote, "facts/stable.md", "unchanged");
185
+ const initial = await buildOfflineSyncSnapshot({
186
+ root: remote,
187
+ sourceId: "remote",
188
+ includeContent: true,
189
+ });
190
+ const firstPull = await applyOfflineSyncSnapshot({
191
+ root: local,
192
+ snapshot: initial,
193
+ });
194
+
195
+ await write(remote, "facts/shared.md", "remote edit");
196
+ const metadataOnly = await buildOfflineSyncSnapshot({
197
+ root: remote,
198
+ sourceId: "remote",
199
+ includeContent: false,
200
+ });
201
+ const changedContent = await buildOfflineSyncSnapshotForPaths({
202
+ root: remote,
203
+ sourceId: "remote",
204
+ paths: ["facts/shared.md"],
205
+ includeContent: true,
206
+ });
207
+ const contentByPath = new Map(
208
+ changedContent.files.map((file) => [file.path, file.contentBase64]),
209
+ );
210
+ const hydrated = {
211
+ ...metadataOnly,
212
+ files: metadataOnly.files.map((file) => {
213
+ const contentBase64 = contentByPath.get(file.path);
214
+ return contentBase64 === undefined ? file : { ...file, contentBase64 };
215
+ }),
216
+ };
217
+
218
+ const secondPull = await applyOfflineSyncSnapshot({
219
+ root: local,
220
+ snapshot: hydrated,
221
+ baseFiles: firstPull.nextBaseFiles,
222
+ });
223
+
224
+ assert.equal(secondPull.upserted, 1);
225
+ assert.equal(secondPull.conflicts.length, 0);
226
+ assert.equal(await readUtf8(local, "facts/shared.md"), "remote edit");
227
+ assert.equal(await readUtf8(local, "facts/stable.md"), "unchanged");
228
+ } finally {
229
+ await rm(remote, { recursive: true, force: true });
230
+ await rm(local, { recursive: true, force: true });
231
+ }
232
+ });
233
+
109
234
  test("offline pull preserves local edits when both sides changed since the base", async () => {
110
235
  const remote = await tempDir("remnic-offline-conflict-remote");
111
236
  const local = await tempDir("remnic-offline-conflict-local");
@@ -127,6 +127,14 @@ export interface OfflineSyncFileWriteTarget extends OfflineSyncFileTarget {
127
127
  content: Buffer;
128
128
  }
129
129
 
130
+ interface OfflineSyncFileRecordOptions {
131
+ root: SafeArchiveRoot;
132
+ relPath: string;
133
+ filePath: string;
134
+ includeContent: boolean;
135
+ readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
136
+ }
137
+
130
138
  const SYNC_INTERNAL_DIR = ".offline-sync";
131
139
  const EXCLUDED_FILE_NAMES = new Set([
132
140
  ".sync-state.json",
@@ -406,6 +414,24 @@ function filterBaseFilesForMode(
406
414
  return files.filter((file) => !shouldExcludeRelPath(file.path, includeTranscripts));
407
415
  }
408
416
 
417
+ async function readOfflineSyncFileRecord(
418
+ options: OfflineSyncFileRecordOptions,
419
+ ): Promise<OfflineSyncFileRecord> {
420
+ const relPath = validateArchiveRelativePath(options.relPath, "offlineSyncFile.path");
421
+ const bytes = options.readFile
422
+ ? await options.readFile({ root: options.root.abs, path: relPath, filePath: options.filePath })
423
+ : await readFile(options.filePath);
424
+ const digest = sha256Buffer(bytes);
425
+ const st = await stat(options.filePath);
426
+ return {
427
+ path: relPath,
428
+ sha256: digest.sha256,
429
+ bytes: digest.bytes,
430
+ mtimeMs: st.mtimeMs,
431
+ ...(options.includeContent ? { contentBase64: bytes.toString("base64") } : {}),
432
+ };
433
+ }
434
+
409
435
  export async function buildOfflineSyncSnapshot(options: {
410
436
  root: string;
411
437
  sourceId: string;
@@ -432,18 +458,13 @@ export async function buildOfflineSyncSnapshot(options: {
432
458
  continue;
433
459
  }
434
460
  if (!entry.isFile()) continue;
435
- const bytes = options.readFile
436
- ? await options.readFile({ root: root.abs, path: relPosix, filePath: abs })
437
- : await readFile(abs);
438
- const digest = sha256Buffer(bytes);
439
- const st = await stat(abs);
440
- files.push({
441
- path: validateArchiveRelativePath(relPosix, "buildOfflineSyncSnapshot"),
442
- sha256: digest.sha256,
443
- bytes: digest.bytes,
444
- mtimeMs: st.mtimeMs,
445
- ...(options.includeContent === true ? { contentBase64: bytes.toString("base64") } : {}),
446
- });
461
+ files.push(await readOfflineSyncFileRecord({
462
+ root,
463
+ relPath: relPosix,
464
+ filePath: abs,
465
+ includeContent: options.includeContent === true,
466
+ readFile: options.readFile,
467
+ }));
447
468
  }
448
469
  }
449
470
 
@@ -459,6 +480,53 @@ export async function buildOfflineSyncSnapshot(options: {
459
480
  };
460
481
  }
461
482
 
483
+ export async function buildOfflineSyncSnapshotForPaths(options: {
484
+ root: string;
485
+ sourceId: string;
486
+ paths: readonly string[];
487
+ includeContent?: boolean;
488
+ includeTranscripts?: boolean;
489
+ now?: Date;
490
+ readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
491
+ }): Promise<OfflineSyncSnapshot> {
492
+ const rootAbs = path.resolve(options.root);
493
+ const root = await prepareSafeArchiveRoot(rootAbs, "buildOfflineSyncSnapshotForPaths", "root");
494
+ const includeTranscripts = options.includeTranscripts !== false;
495
+ const files: OfflineSyncFileRecord[] = [];
496
+ const seen = new Set<string>();
497
+
498
+ for (const rawPath of options.paths) {
499
+ const relPath = normalizeRelativePath(rawPath, "paths[]");
500
+ if (seen.has(relPath)) continue;
501
+ seen.add(relPath);
502
+ if (shouldExcludeRelPath(relPath, includeTranscripts)) {
503
+ throw new Error(`offline sync snapshot path is excluded: ${relPath}`);
504
+ }
505
+ const filePath = await resolveSafeArchiveTarget(root, relPath);
506
+ const st = await lstat(filePath).catch((error: unknown) => {
507
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
508
+ throw error;
509
+ });
510
+ if (!st || st.isSymbolicLink() || !st.isFile()) continue;
511
+ files.push(await readOfflineSyncFileRecord({
512
+ root,
513
+ relPath,
514
+ filePath,
515
+ includeContent: options.includeContent === true,
516
+ readFile: options.readFile,
517
+ }));
518
+ }
519
+
520
+ return {
521
+ format: OFFLINE_SYNC_SNAPSHOT_FORMAT,
522
+ schemaVersion: 1,
523
+ createdAt: (options.now ?? new Date()).toISOString(),
524
+ sourceId: normalizeSourceId(options.sourceId, "sourceId"),
525
+ includeTranscripts,
526
+ files: files.sort(compareByPath),
527
+ };
528
+ }
529
+
462
530
  export async function buildOfflineSyncChangeset(options: {
463
531
  root: string;
464
532
  sourceId: string;
@@ -475,7 +543,7 @@ export async function buildOfflineSyncChangeset(options: {
475
543
  const current = await buildOfflineSyncSnapshot({
476
544
  root: options.root,
477
545
  sourceId: options.sourceId,
478
- includeContent: true,
546
+ includeContent: false,
479
547
  includeTranscripts,
480
548
  now: options.now,
481
549
  readFile: options.readFile,
@@ -487,11 +555,24 @@ export async function buildOfflineSyncChangeset(options: {
487
555
  const baseEntry = base.get(relPath);
488
556
  const currentEntry = currentMap.get(relPath);
489
557
  if (currentEntry && currentEntry.sha256 !== baseEntry?.sha256) {
558
+ const file = await buildOfflineSyncSnapshotForPaths({
559
+ root: options.root,
560
+ sourceId: options.sourceId,
561
+ paths: [relPath],
562
+ includeContent: true,
563
+ includeTranscripts,
564
+ now: options.now,
565
+ readFile: options.readFile,
566
+ });
567
+ const record = file.files[0];
568
+ if (!record || typeof record.contentBase64 !== "string" || record.sha256 !== currentEntry.sha256) {
569
+ throw new Error(`offline sync file changed while building changeset: ${relPath}`);
570
+ }
490
571
  changes.push({
491
572
  type: "upsert",
492
573
  path: relPath,
493
574
  ...(baseEntry ? { baseSha256: baseEntry.sha256 } : {}),
494
- file: currentEntry as OfflineSyncFileRecord & { contentBase64: string },
575
+ file: record as OfflineSyncFileRecord & { contentBase64: string },
495
576
  });
496
577
  continue;
497
578
  }
@@ -535,13 +616,15 @@ export async function applyOfflineSyncSnapshot(options: {
535
616
  writeFile?: (target: OfflineSyncFileWriteTarget) => Promise<void>;
536
617
  deleteFile?: (target: OfflineSyncFileTarget) => Promise<void>;
537
618
  }): Promise<OfflineSyncApplySnapshotResult> {
538
- const snapshot = normalizeOfflineSyncSnapshot(options.snapshot, { requireContent: true });
619
+ const snapshot = normalizeOfflineSyncSnapshot(options.snapshot);
539
620
  const baseMap = byPath(filterBaseFilesForMode(
540
621
  normalizeFileStates(options.baseFiles),
541
622
  snapshot.includeTranscripts,
542
623
  ));
543
624
  const incomingMap = byPath(snapshot.files);
544
- const incomingBuffers = verifyRecordContents(snapshot.files, "offline sync snapshot");
625
+ const incomingBuffers = verifyRecordContents(snapshot.files, "offline sync snapshot", {
626
+ requireContent: false,
627
+ });
545
628
  const root = await ensureSyncRoot(options.root, "applyOfflineSyncSnapshot");
546
629
  const current = await buildOfflineSyncSnapshot({
547
630
  root: root.abs,
@@ -582,7 +665,9 @@ export async function applyOfflineSyncSnapshot(options: {
582
665
  reason: "local_deleted_remote_modified",
583
666
  baseSha256: base.sha256,
584
667
  incomingSha256: incoming.sha256,
585
- incomingBuffer: incomingBuffers.get(relPath),
668
+ incomingBuffer: options.writeConflictCopies === false
669
+ ? incomingBuffers.get(relPath)
670
+ : requiredBuffer(incomingBuffers, relPath),
586
671
  writeConflictCopies: options.writeConflictCopies !== false,
587
672
  sourceId: snapshot.sourceId,
588
673
  writeFile: options.writeFile,
@@ -615,7 +700,9 @@ export async function applyOfflineSyncSnapshot(options: {
615
700
  baseSha256: base?.sha256,
616
701
  localSha256: currentEntry?.sha256,
617
702
  incomingSha256: incoming.sha256,
618
- incomingBuffer: incomingBuffers.get(relPath),
703
+ incomingBuffer: options.writeConflictCopies === false
704
+ ? incomingBuffers.get(relPath)
705
+ : requiredBuffer(incomingBuffers, relPath),
619
706
  writeConflictCopies: options.writeConflictCopies !== false,
620
707
  sourceId: snapshot.sourceId,
621
708
  writeFile: options.writeFile,
@@ -774,10 +861,12 @@ export async function applyOfflineSyncChangeset(options: {
774
861
  function verifyRecordContents(
775
862
  records: readonly OfflineSyncFileRecord[],
776
863
  context: string,
864
+ options: { requireContent?: boolean } = {},
777
865
  ): Map<string, Buffer> {
778
866
  const buffers = new Map<string, Buffer>();
779
867
  for (const record of records) {
780
868
  if (typeof record.contentBase64 !== "string") {
869
+ if (options.requireContent === false) continue;
781
870
  throw new Error(`${context}: contentBase64 is required for ${record.path}`);
782
871
  }
783
872
  const buffer = Buffer.from(record.contentBase64, "base64");