@remnic/core 1.1.15 → 1.1.17

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 (44) 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-VWFIQOTJ.js → chunk-66H2DZYB.js} +8 -1
  12. package/dist/chunk-66H2DZYB.js.map +1 -0
  13. package/dist/{chunk-BNATB54A.js → chunk-BYACCC5C.js} +3 -3
  14. package/dist/{chunk-ZYRMKWVW.js → chunk-CDQNR7SV.js} +4 -4
  15. package/dist/{chunk-HJ2WMBFB.js → chunk-GCR4JFKK.js} +15 -4
  16. package/dist/chunk-GCR4JFKK.js.map +1 -0
  17. package/dist/{chunk-GSP6ZKOY.js → chunk-HMZYQPT5.js} +85 -19
  18. package/dist/chunk-HMZYQPT5.js.map +1 -0
  19. package/dist/{chunk-5D2G67ZQ.js → chunk-VLQWOGYM.js} +29 -3
  20. package/dist/chunk-VLQWOGYM.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/package.json +1 -1
  31. package/src/access-http.test.ts +73 -0
  32. package/src/access-http.ts +15 -0
  33. package/src/access-schema.ts +12 -0
  34. package/src/access-service-namespace.test.ts +64 -1
  35. package/src/access-service.ts +44 -0
  36. package/src/index.ts +1 -0
  37. package/src/offline-sync.test.ts +174 -0
  38. package/src/offline-sync.ts +110 -18
  39. package/dist/chunk-5D2G67ZQ.js.map +0 -1
  40. package/dist/chunk-GSP6ZKOY.js.map +0 -1
  41. package/dist/chunk-HJ2WMBFB.js.map +0 -1
  42. package/dist/chunk-VWFIQOTJ.js.map +0 -1
  43. /package/dist/{chunk-BNATB54A.js.map → chunk-BYACCC5C.js.map} +0 -0
  44. /package/dist/{chunk-ZYRMKWVW.js.map → chunk-CDQNR7SV.js.map} +0 -0
@@ -1,4 +1,4 @@
1
- import { a as EngramAccessRecallResponse } from './access-service-BCMine1s.js';
1
+ import { a as EngramAccessRecallResponse } from './access-service-DZXc7qwR.js';
2
2
  import { ActionConfidenceRequest } from './access-schema.js';
3
3
  import { RecallXraySnapshot } from './recall-xray.js';
4
4
  import { ActionConfidenceResult } from './action-confidence.js';
@@ -95,6 +95,15 @@ declare function buildOfflineSyncSnapshot(options: {
95
95
  now?: Date;
96
96
  readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
97
97
  }): Promise<OfflineSyncSnapshot>;
98
+ declare function buildOfflineSyncSnapshotForPaths(options: {
99
+ root: string;
100
+ sourceId: string;
101
+ paths: readonly string[];
102
+ includeContent?: boolean;
103
+ includeTranscripts?: boolean;
104
+ now?: Date;
105
+ readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
106
+ }): Promise<OfflineSyncSnapshot>;
98
107
  declare function buildOfflineSyncChangeset(options: {
99
108
  root: string;
100
109
  sourceId: string;
@@ -133,4 +142,4 @@ declare function offlineSyncStateFromSnapshot(options: {
133
142
  declare function normalizeOfflineSyncState(input: unknown): OfflineSyncState;
134
143
  declare function fileStatesFromSnapshot(snapshot: OfflineSyncSnapshot): OfflineSyncFileState[];
135
144
 
136
- export { OFFLINE_SYNC_CHANGESET_FORMAT, OFFLINE_SYNC_SNAPSHOT_FORMAT, OFFLINE_SYNC_STATE_VERSION, type OfflineSyncApplyChangesetResult, type OfflineSyncApplySnapshotResult, type OfflineSyncChange, type OfflineSyncChangeset, type OfflineSyncChangesetSummary, type OfflineSyncConflict, type OfflineSyncFileRecord, type OfflineSyncFileState, type OfflineSyncFileTarget, type OfflineSyncFileWriteTarget, type OfflineSyncSnapshot, type OfflineSyncState, applyOfflineSyncChangeset, applyOfflineSyncSnapshot, buildOfflineSyncChangeset, buildOfflineSyncSnapshot, defaultOfflineSyncStatePath, fileStatesFromSnapshot, normalizeOfflineSyncChangeset, normalizeOfflineSyncSnapshot, normalizeOfflineSyncState, offlineSyncStateFromSnapshot, readOfflineSyncState, summarizeOfflineSyncChangeset, writeOfflineSyncState };
145
+ export { OFFLINE_SYNC_CHANGESET_FORMAT, OFFLINE_SYNC_SNAPSHOT_FORMAT, OFFLINE_SYNC_STATE_VERSION, type OfflineSyncApplyChangesetResult, type OfflineSyncApplySnapshotResult, type OfflineSyncChange, type OfflineSyncChangeset, type OfflineSyncChangesetSummary, type OfflineSyncConflict, type OfflineSyncFileRecord, type OfflineSyncFileState, type OfflineSyncFileTarget, type OfflineSyncFileWriteTarget, type OfflineSyncSnapshot, type OfflineSyncState, applyOfflineSyncChangeset, applyOfflineSyncSnapshot, buildOfflineSyncChangeset, buildOfflineSyncSnapshot, buildOfflineSyncSnapshotForPaths, defaultOfflineSyncStatePath, fileStatesFromSnapshot, normalizeOfflineSyncChangeset, normalizeOfflineSyncSnapshot, normalizeOfflineSyncState, offlineSyncStateFromSnapshot, readOfflineSyncState, summarizeOfflineSyncChangeset, writeOfflineSyncState };
@@ -6,6 +6,7 @@ import {
6
6
  applyOfflineSyncSnapshot,
7
7
  buildOfflineSyncChangeset,
8
8
  buildOfflineSyncSnapshot,
9
+ buildOfflineSyncSnapshotForPaths,
9
10
  defaultOfflineSyncStatePath,
10
11
  fileStatesFromSnapshot,
11
12
  normalizeOfflineSyncChangeset,
@@ -15,7 +16,7 @@ import {
15
16
  readOfflineSyncState,
16
17
  summarizeOfflineSyncChangeset,
17
18
  writeOfflineSyncState
18
- } from "./chunk-GSP6ZKOY.js";
19
+ } from "./chunk-HMZYQPT5.js";
19
20
  import "./chunk-P7FMDTKL.js";
20
21
  import "./chunk-I6K5FBRQ.js";
21
22
  import "./chunk-AGZQD76C.js";
@@ -28,6 +29,7 @@ export {
28
29
  applyOfflineSyncSnapshot,
29
30
  buildOfflineSyncChangeset,
30
31
  buildOfflineSyncSnapshot,
32
+ buildOfflineSyncSnapshotForPaths,
31
33
  defaultOfflineSyncStatePath,
32
34
  fileStatesFromSnapshot,
33
35
  normalizeOfflineSyncChangeset,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remnic/core",
3
- "version": "1.1.15",
3
+ "version": "1.1.17",
4
4
  "description": "Framework-agnostic Remnic memory engine — orchestrator, storage, extraction, search, trust zones",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -286,6 +286,79 @@ test("HTTP offline snapshot forwards namespace and transfer options", async () =
286
286
  }
287
287
  });
288
288
 
289
+ test("HTTP offline files forwards namespace and requested paths", async () => {
290
+ const calls: Array<{
291
+ namespace: string | undefined;
292
+ principal: string | undefined;
293
+ includeTranscripts: boolean | undefined;
294
+ paths: string[];
295
+ }> = [];
296
+ const service = {
297
+ offlineSyncFiles: async (options: {
298
+ namespace?: string;
299
+ principal?: string;
300
+ includeTranscripts?: boolean;
301
+ paths: string[];
302
+ }) => {
303
+ calls.push({
304
+ namespace: options.namespace,
305
+ principal: options.principal,
306
+ includeTranscripts: options.includeTranscripts,
307
+ paths: options.paths,
308
+ });
309
+ return {
310
+ namespace: options.namespace ?? "default",
311
+ format: "remnic.offline-sync.snapshot.v1",
312
+ schemaVersion: 1,
313
+ createdAt: new Date("2026-05-21T00:00:00Z").toISOString(),
314
+ sourceId: "remote:test",
315
+ includeTranscripts: options.includeTranscripts !== false,
316
+ files: [],
317
+ };
318
+ },
319
+ } as unknown as EngramAccessService;
320
+ const server = new EngramAccessHttpServer({
321
+ service,
322
+ port: 0,
323
+ authToken: "test-token",
324
+ principal: "reader",
325
+ adminConsoleEnabled: false,
326
+ });
327
+
328
+ const status = await server.start();
329
+ try {
330
+ const response = await fetch(
331
+ `http://127.0.0.1:${status.port}/remnic/v1/offline-sync/files`,
332
+ {
333
+ method: "POST",
334
+ headers: {
335
+ authorization: "Bearer test-token",
336
+ "content-type": "application/json",
337
+ },
338
+ body: JSON.stringify({
339
+ namespace: "team",
340
+ includeTranscripts: false,
341
+ paths: ["facts/a.md"],
342
+ }),
343
+ },
344
+ );
345
+ const body = await response.json() as { namespace?: string; includeTranscripts?: boolean; files?: unknown[] };
346
+
347
+ assert.equal(response.status, 200);
348
+ assert.equal(body.namespace, "team");
349
+ assert.equal(body.includeTranscripts, false);
350
+ assert.deepEqual(body.files, []);
351
+ assert.deepEqual(calls, [{
352
+ namespace: "team",
353
+ principal: "reader",
354
+ includeTranscripts: false,
355
+ paths: ["facts/a.md"],
356
+ }]);
357
+ } finally {
358
+ await server.stop();
359
+ }
360
+ });
361
+
289
362
  test("HTTP offline snapshot rejects invalid boolean query values", async () => {
290
363
  let calls = 0;
291
364
  const service = {
@@ -626,6 +626,21 @@ export class EngramAccessHttpServer {
626
626
  return;
627
627
  }
628
628
 
629
+ if (
630
+ req.method === "POST" &&
631
+ (pathname === "/engram/v1/offline-sync/files" || pathname === "/remnic/v1/offline-sync/files")
632
+ ) {
633
+ const body = await this.readValidatedBody(req, "offlineSyncFiles");
634
+ const result = await this.service.offlineSyncFiles({
635
+ namespace: this.resolveNamespace(req, body.namespace),
636
+ principal: this.resolveRequestPrincipal(req),
637
+ includeTranscripts: body.includeTranscripts,
638
+ paths: body.paths,
639
+ });
640
+ this.respondJson(res, 200, result);
641
+ return;
642
+ }
643
+
629
644
  if (
630
645
  req.method === "POST" &&
631
646
  (pathname === "/engram/v1/offline-sync/apply" || pathname === "/remnic/v1/offline-sync/apply")
@@ -378,6 +378,14 @@ export const offlineSyncApplyRequestSchema = z
378
378
  path: ["changeset"],
379
379
  });
380
380
 
381
+ export const offlineSyncFilesRequestSchema = z.object({
382
+ namespace: namespaceSchema,
383
+ includeTranscripts: z.boolean().optional(),
384
+ paths: z
385
+ .array(z.string().trim().min(1, "path must be non-empty").max(4096))
386
+ .max(5000, "paths must contain 5000 or fewer entries"),
387
+ });
388
+
381
389
  // ---------------------------------------------------------------------------
382
390
  // Action confidence
383
391
  // ---------------------------------------------------------------------------
@@ -444,6 +452,7 @@ export type CapsuleExportRequest = z.infer<typeof capsuleExportRequestSchema>;
444
452
  export type CapsuleImportRequest = z.infer<typeof capsuleImportRequestSchema>;
445
453
  export type CapsuleListRequest = z.infer<typeof capsuleListRequestSchema>;
446
454
  export type OfflineSyncApplyRequest = z.infer<typeof offlineSyncApplyRequestSchema>;
455
+ export type OfflineSyncFilesRequest = z.infer<typeof offlineSyncFilesRequestSchema>;
447
456
  export type ActionConfidenceRequest = z.infer<typeof actionConfidenceRequestSchema>;
448
457
 
449
458
  // ---------------------------------------------------------------------------
@@ -467,6 +476,7 @@ export type SchemaName =
467
476
  | "capsuleExport"
468
477
  | "capsuleImport"
469
478
  | "capsuleList"
479
+ | "offlineSyncFiles"
470
480
  | "offlineSyncApply"
471
481
  | "actionConfidence";
472
482
 
@@ -487,6 +497,7 @@ export type SchemaTypeFor<N extends SchemaName> =
487
497
  : N extends "capsuleExport" ? CapsuleExportRequest
488
498
  : N extends "capsuleImport" ? CapsuleImportRequest
489
499
  : N extends "capsuleList" ? CapsuleListRequest
500
+ : N extends "offlineSyncFiles" ? OfflineSyncFilesRequest
490
501
  : N extends "offlineSyncApply" ? OfflineSyncApplyRequest
491
502
  : N extends "actionConfidence" ? ActionConfidenceRequest
492
503
  : never;
@@ -508,6 +519,7 @@ const schemas: Record<SchemaName, z.ZodTypeAny> = {
508
519
  capsuleExport: capsuleExportRequestSchema,
509
520
  capsuleImport: capsuleImportRequestSchema,
510
521
  capsuleList: capsuleListRequestSchema,
522
+ offlineSyncFiles: offlineSyncFilesRequestSchema,
511
523
  offlineSyncApply: offlineSyncApplyRequestSchema,
512
524
  actionConfidence: actionConfidenceRequestSchema,
513
525
  };
@@ -1,7 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
+ import { mkdtemp, rm, symlink } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
2
5
  import test from "node:test";
3
6
 
4
- import { EngramAccessService } from "./access-service.js";
7
+ import { EngramAccessInputError, EngramAccessService } from "./access-service.js";
5
8
  import type { StorageManager } from "./storage.js";
6
9
  import type { PluginConfig } from "./types.js";
7
10
 
@@ -121,3 +124,63 @@ test("memoryBrowse resolves namespace storage for read principals", async () =>
121
124
  assert.equal(result.count, 0);
122
125
  assert.deepEqual(getStorageCalls, ["team"]);
123
126
  });
127
+
128
+ test("offlineSyncFiles reports invalid requested paths as input errors", async () => {
129
+ const { service } = makeService();
130
+ (service as unknown as {
131
+ orchestrator: {
132
+ config: PluginConfig;
133
+ getStorage(namespace: string): Promise<StorageManager>;
134
+ };
135
+ }).orchestrator.getStorage = async () => ({
136
+ dir: os.tmpdir(),
137
+ async readOfflineSyncFile() {
138
+ throw new Error("should not read invalid paths");
139
+ },
140
+ } as unknown as StorageManager);
141
+
142
+ await assert.rejects(
143
+ () =>
144
+ service.offlineSyncFiles({
145
+ namespace: "team",
146
+ principal: "reader",
147
+ paths: ["../escape"],
148
+ }),
149
+ (error: unknown) =>
150
+ error instanceof EngramAccessInputError &&
151
+ /paths\[\]: record path contains unsafe segments/.test(error.message),
152
+ );
153
+ });
154
+
155
+ test("offlineSyncFiles reports symlink requested paths as input errors", async () => {
156
+ const root = await mkdtemp(path.join(os.tmpdir(), "remnic-offline-files-symlink-"));
157
+ try {
158
+ await symlink("/tmp", path.join(root, "linked"));
159
+ const { service } = makeService();
160
+ (service as unknown as {
161
+ orchestrator: {
162
+ config: PluginConfig;
163
+ getStorage(namespace: string): Promise<StorageManager>;
164
+ };
165
+ }).orchestrator.getStorage = async () => ({
166
+ dir: root,
167
+ async readOfflineSyncFile() {
168
+ throw new Error("should not read symlink paths");
169
+ },
170
+ } as unknown as StorageManager);
171
+
172
+ await assert.rejects(
173
+ () =>
174
+ service.offlineSyncFiles({
175
+ namespace: "team",
176
+ principal: "reader",
177
+ paths: ["linked"],
178
+ }),
179
+ (error: unknown) =>
180
+ error instanceof EngramAccessInputError &&
181
+ /buildOfflineSyncSnapshotForPaths: record path targets a symlink/.test(error.message),
182
+ );
183
+ } finally {
184
+ await rm(root, { recursive: true, force: true });
185
+ }
186
+ });
@@ -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,
@@ -1,4 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
2
3
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
@@ -9,6 +10,7 @@ import {
9
10
  applyOfflineSyncSnapshot,
10
11
  buildOfflineSyncChangeset,
11
12
  buildOfflineSyncSnapshot,
13
+ buildOfflineSyncSnapshotForPaths,
12
14
  } from "./offline-sync.js";
13
15
  import { isEncryptedFile } from "./secure-store/secure-fs.js";
14
16
  import { StorageManager } from "./storage.js";
@@ -38,6 +40,9 @@ test("offline snapshot captures source-of-truth files and excludes private/inter
38
40
  await write(root, ".offline-sync/state/local.json", "state");
39
41
  await write(root, "state/fact-hashes.txt", "derived");
40
42
  await write(root, "state/fact-hashes.ready", "v1");
43
+ await write(root, "state/lcm.sqlite", "live db");
44
+ await write(root, "state/lcm.sqlite-shm", "live shm");
45
+ await write(root, "state/lcm.sqlite-wal", "live wal");
41
46
 
42
47
  const snapshot = await buildOfflineSyncSnapshot({
43
48
  root,
@@ -67,6 +72,51 @@ test("offline snapshot captures source-of-truth files and excludes private/inter
67
72
  }
68
73
  });
69
74
 
75
+ test("offline sync excludes live LCM sqlite artifacts without deleting existing local copies", async () => {
76
+ const root = await tempDir("remnic-offline-lcm-sqlite");
77
+ try {
78
+ await write(root, "facts/a.md", "alpha");
79
+ await write(root, "state/lcm.sqlite", "live db");
80
+ await write(root, "state/lcm.sqlite-shm", "live shm");
81
+ await write(root, "state/lcm.sqlite-wal", "live wal");
82
+
83
+ const snapshot = await buildOfflineSyncSnapshot({
84
+ root,
85
+ sourceId: "remote",
86
+ includeContent: true,
87
+ });
88
+
89
+ assert.deepEqual(snapshot.files.map((file) => file.path), ["facts/a.md"]);
90
+ await assert.rejects(
91
+ () =>
92
+ buildOfflineSyncSnapshotForPaths({
93
+ root,
94
+ sourceId: "remote",
95
+ paths: ["state/lcm.sqlite"],
96
+ includeContent: true,
97
+ }),
98
+ /offline sync snapshot path is excluded: state\/lcm\.sqlite/,
99
+ );
100
+
101
+ const oldDb = Buffer.from("old live db");
102
+ const pull = await applyOfflineSyncSnapshot({
103
+ root,
104
+ snapshot,
105
+ baseFiles: [{
106
+ path: "state/lcm.sqlite",
107
+ sha256: createHash("sha256").update(oldDb).digest("hex"),
108
+ bytes: oldDb.byteLength,
109
+ mtimeMs: 0,
110
+ }],
111
+ });
112
+
113
+ assert.equal(pull.deleted, 0);
114
+ assert.equal(await readUtf8(root, "state/lcm.sqlite"), "live db");
115
+ } finally {
116
+ await rm(root, { recursive: true, force: true });
117
+ }
118
+ });
119
+
70
120
  test("offline changeset pushes local edits when the remote is still at the shared base", async () => {
71
121
  const remote = await tempDir("remnic-offline-remote");
72
122
  const local = await tempDir("remnic-offline-local");
@@ -106,6 +156,130 @@ test("offline changeset pushes local edits when the remote is still at the share
106
156
  }
107
157
  });
108
158
 
159
+ test("offline changeset only carries content for changed local files", async () => {
160
+ const local = await tempDir("remnic-offline-changeset-content");
161
+ try {
162
+ await write(local, "facts/unchanged.md", "same");
163
+ await write(local, "facts/changed.md", "before");
164
+ const base = await buildOfflineSyncSnapshot({
165
+ root: local,
166
+ sourceId: "remote",
167
+ includeContent: false,
168
+ });
169
+
170
+ await write(local, "facts/changed.md", "after");
171
+ await write(local, "facts/empty.md", "");
172
+ const changeset = await buildOfflineSyncChangeset({
173
+ root: local,
174
+ sourceId: "laptop",
175
+ baseFiles: base.files,
176
+ });
177
+
178
+ assert.deepEqual(
179
+ changeset.changes.map((change) => change.path),
180
+ ["facts/changed.md", "facts/empty.md"],
181
+ );
182
+ const empty = changeset.changes.find((change) => change.path === "facts/empty.md");
183
+ assert.equal(empty?.type, "upsert");
184
+ if (empty?.type === "upsert") {
185
+ assert.equal(empty.file.contentBase64, "");
186
+ }
187
+ assert.equal(JSON.stringify(changeset).includes("same"), false);
188
+ } finally {
189
+ await rm(local, { recursive: true, force: true });
190
+ }
191
+ });
192
+
193
+ test("offline pull accepts metadata-only snapshots when files are unchanged", async () => {
194
+ const remote = await tempDir("remnic-offline-metadata-remote");
195
+ const local = await tempDir("remnic-offline-metadata-local");
196
+ try {
197
+ await write(remote, "facts/shared.md", "base");
198
+ const initial = await buildOfflineSyncSnapshot({
199
+ root: remote,
200
+ sourceId: "remote",
201
+ includeContent: true,
202
+ });
203
+ const firstPull = await applyOfflineSyncSnapshot({
204
+ root: local,
205
+ snapshot: initial,
206
+ });
207
+ const metadataOnly = await buildOfflineSyncSnapshot({
208
+ root: remote,
209
+ sourceId: "remote",
210
+ includeContent: false,
211
+ });
212
+
213
+ const secondPull = await applyOfflineSyncSnapshot({
214
+ root: local,
215
+ snapshot: metadataOnly,
216
+ baseFiles: firstPull.nextBaseFiles,
217
+ });
218
+
219
+ assert.equal(secondPull.conflicts.length, 0);
220
+ assert.equal(secondPull.upserted, 0);
221
+ assert.equal(secondPull.skipped, 1);
222
+ } finally {
223
+ await rm(remote, { recursive: true, force: true });
224
+ await rm(local, { recursive: true, force: true });
225
+ }
226
+ });
227
+
228
+ test("offline pull applies snapshots with content only for remote-changed files", async () => {
229
+ const remote = await tempDir("remnic-offline-partial-remote");
230
+ const local = await tempDir("remnic-offline-partial-local");
231
+ try {
232
+ await write(remote, "facts/shared.md", "base");
233
+ await write(remote, "facts/stable.md", "unchanged");
234
+ const initial = await buildOfflineSyncSnapshot({
235
+ root: remote,
236
+ sourceId: "remote",
237
+ includeContent: true,
238
+ });
239
+ const firstPull = await applyOfflineSyncSnapshot({
240
+ root: local,
241
+ snapshot: initial,
242
+ });
243
+
244
+ await write(remote, "facts/shared.md", "remote edit");
245
+ const metadataOnly = await buildOfflineSyncSnapshot({
246
+ root: remote,
247
+ sourceId: "remote",
248
+ includeContent: false,
249
+ });
250
+ const changedContent = await buildOfflineSyncSnapshotForPaths({
251
+ root: remote,
252
+ sourceId: "remote",
253
+ paths: ["facts/shared.md"],
254
+ includeContent: true,
255
+ });
256
+ const contentByPath = new Map(
257
+ changedContent.files.map((file) => [file.path, file.contentBase64]),
258
+ );
259
+ const hydrated = {
260
+ ...metadataOnly,
261
+ files: metadataOnly.files.map((file) => {
262
+ const contentBase64 = contentByPath.get(file.path);
263
+ return contentBase64 === undefined ? file : { ...file, contentBase64 };
264
+ }),
265
+ };
266
+
267
+ const secondPull = await applyOfflineSyncSnapshot({
268
+ root: local,
269
+ snapshot: hydrated,
270
+ baseFiles: firstPull.nextBaseFiles,
271
+ });
272
+
273
+ assert.equal(secondPull.upserted, 1);
274
+ assert.equal(secondPull.conflicts.length, 0);
275
+ assert.equal(await readUtf8(local, "facts/shared.md"), "remote edit");
276
+ assert.equal(await readUtf8(local, "facts/stable.md"), "unchanged");
277
+ } finally {
278
+ await rm(remote, { recursive: true, force: true });
279
+ await rm(local, { recursive: true, force: true });
280
+ }
281
+ });
282
+
109
283
  test("offline pull preserves local edits when both sides changed since the base", async () => {
110
284
  const remote = await tempDir("remnic-offline-conflict-remote");
111
285
  const local = await tempDir("remnic-offline-conflict-local");