@remnic/core 1.1.14 → 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 (132) hide show
  1. package/dist/access-cli.js +34 -33
  2. package/dist/access-cli.js.map +1 -1
  3. package/dist/access-http.d.ts +2 -1
  4. package/dist/access-http.js +15 -14
  5. package/dist/access-mcp.d.ts +2 -1
  6. package/dist/access-mcp.js +14 -13
  7. package/dist/access-schema.d.ts +36 -5
  8. package/dist/access-schema.js +9 -5
  9. package/dist/{access-service-DcCDmNYC.d.ts → access-service-DZXc7qwR.d.ts} +31 -1
  10. package/dist/access-service.d.ts +2 -1
  11. package/dist/access-service.js +12 -11
  12. package/dist/briefing.js +4 -4
  13. package/dist/causal-consolidation.js +5 -5
  14. package/dist/chunk-2OZ6GP27.js +832 -0
  15. package/dist/chunk-2OZ6GP27.js.map +1 -0
  16. package/dist/{chunk-VNO6ZJ35.js → chunk-2PRLKQAH.js} +5 -5
  17. package/dist/{chunk-EFJ3MQ4V.js → chunk-65HQPW6O.js} +2 -2
  18. package/dist/{chunk-A2XUIMJ3.js → chunk-66H2DZYB.js} +18 -2
  19. package/dist/chunk-66H2DZYB.js.map +1 -0
  20. package/dist/{chunk-GA454ALV.js → chunk-AAX3SUM3.js} +39 -39
  21. package/dist/{chunk-QQUAB63I.js → chunk-BEB4GUU5.js} +2 -2
  22. package/dist/{chunk-KUJVMMZQ.js → chunk-C7DGCHJE.js} +2 -2
  23. package/dist/{chunk-PR5FBTFU.js → chunk-CYFQJMUV.js} +5 -5
  24. package/dist/{chunk-KLAO5DGL.js → chunk-G7JBLD65.js} +3 -3
  25. package/dist/{chunk-CHEL3SKB.js → chunk-HJILHQOR.js} +27 -27
  26. package/dist/{chunk-ME6ESPZU.js → chunk-IG5VGHYB.js} +2 -2
  27. package/dist/{chunk-7AAT6G4Q.js → chunk-IOAY54RF.js} +57 -5
  28. package/dist/chunk-IOAY54RF.js.map +1 -0
  29. package/dist/{chunk-XVZ7B3HG.js → chunk-JFEH2LZM.js} +2 -2
  30. package/dist/{chunk-JLFA7DQG.js → chunk-M3AA636B.js} +2 -2
  31. package/dist/{chunk-P4NEIHUT.js → chunk-MS3ULOZF.js} +2 -2
  32. package/dist/{chunk-CQZRLNMV.js → chunk-MTYLGYOQ.js} +53 -4
  33. package/dist/chunk-MTYLGYOQ.js.map +1 -0
  34. package/dist/{chunk-7IASACLB.js → chunk-NOHC2L57.js} +2 -2
  35. package/dist/{chunk-6RVI47ZR.js → chunk-NTUNYIF7.js} +5 -5
  36. package/dist/{chunk-CK5NTM2S.js → chunk-OGROP7ZN.js} +2 -2
  37. package/dist/{chunk-MT25YHYH.js → chunk-OJRKZLZ4.js} +5 -5
  38. package/dist/{chunk-2F2W355T.js → chunk-QA2ZAPBU.js} +4 -4
  39. package/dist/{chunk-MC26UJIM.js → chunk-QLKBF3TI.js} +2 -2
  40. package/dist/{chunk-YNJHCGDT.js → chunk-SH5S7XYD.js} +8 -5
  41. package/dist/chunk-SH5S7XYD.js.map +1 -0
  42. package/dist/{chunk-WZYKANL3.js → chunk-SK42SSAN.js} +4 -4
  43. package/dist/{chunk-VW676BEI.js → chunk-V7WH7DEM.js} +2 -2
  44. package/dist/{chunk-PU63GXWS.js → chunk-W7DK3CYM.js} +2 -2
  45. package/dist/{chunk-TFO23QT4.js → chunk-XKLD5OK4.js} +4 -4
  46. package/dist/{chunk-M23FSH32.js → chunk-Y2YBRCEF.js} +79 -6
  47. package/dist/chunk-Y2YBRCEF.js.map +1 -0
  48. package/dist/{chunk-I5V2VDIW.js → chunk-YCVWX2NF.js} +2 -2
  49. package/dist/{chunk-UXHQAFNA.js → chunk-ZPXYWTN5.js} +4 -4
  50. package/dist/{chunk-GGKRUQOO.js → chunk-ZYVPLJ4T.js} +4 -4
  51. package/dist/{cli-D3VpkVwB.d.ts → cli-kVwab1_L.d.ts} +1 -1
  52. package/dist/cli.d.ts +3 -2
  53. package/dist/cli.js +35 -34
  54. package/dist/compounding/engine.js +4 -4
  55. package/dist/connectors/codex-materialize-runner.js +4 -4
  56. package/dist/connectors/index.js +4 -4
  57. package/dist/conversation-index/backend.js +2 -2
  58. package/dist/entity-retrieval.js +4 -4
  59. package/dist/index.d.ts +4 -3
  60. package/dist/index.js +92 -58
  61. package/dist/index.js.map +1 -1
  62. package/dist/lcm/engine.js +2 -2
  63. package/dist/lcm/index.js +5 -5
  64. package/dist/maintenance/memory-governance.js +4 -4
  65. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +4 -4
  66. package/dist/maintenance/rebuild-memory-projection.js +5 -5
  67. package/dist/mcp-memory-inspector-app.d.ts +2 -1
  68. package/dist/namespaces/migrate.js +10 -10
  69. package/dist/namespaces/search.js +5 -5
  70. package/dist/namespaces/storage.js +4 -4
  71. package/dist/offline-sync.d.ts +145 -0
  72. package/dist/offline-sync.js +43 -0
  73. package/dist/offline-sync.js.map +1 -0
  74. package/dist/operator-toolkit.js +13 -13
  75. package/dist/orchestrator.js +24 -24
  76. package/dist/schemas.d.ts +22 -22
  77. package/dist/search/factory.js +4 -4
  78. package/dist/search/index.js +6 -6
  79. package/dist/secure-store/index.d.ts +1 -15
  80. package/dist/secure-store/index.js +2 -2
  81. package/dist/semantic-consolidation.js +5 -5
  82. package/dist/semantic-rule-promotion.js +4 -4
  83. package/dist/semantic-rule-verifier.js +4 -4
  84. package/dist/storage.d.ts +7 -0
  85. package/dist/storage.js +3 -3
  86. package/dist/transfer/backup.js +2 -2
  87. package/dist/transfer/capsule-export.js +4 -4
  88. package/dist/transfer/capsule-import.js +3 -3
  89. package/dist/transfer/import-sqlite.js +2 -2
  90. package/dist/transfer/types.d.ts +12 -12
  91. package/dist/verified-recall.js +4 -4
  92. package/package.json +1 -1
  93. package/src/access-http.test.ts +289 -0
  94. package/src/access-http.ts +69 -0
  95. package/src/access-schema.ts +30 -0
  96. package/src/access-service-namespace.test.ts +64 -1
  97. package/src/access-service.ts +120 -0
  98. package/src/index.ts +34 -0
  99. package/src/offline-sync.test.ts +646 -0
  100. package/src/offline-sync.ts +1087 -0
  101. package/src/secure-store/secure-fs.ts +14 -7
  102. package/src/storage.ts +59 -0
  103. package/dist/chunk-7AAT6G4Q.js.map +0 -1
  104. package/dist/chunk-A2XUIMJ3.js.map +0 -1
  105. package/dist/chunk-CQZRLNMV.js.map +0 -1
  106. package/dist/chunk-M23FSH32.js.map +0 -1
  107. package/dist/chunk-YNJHCGDT.js.map +0 -1
  108. /package/dist/{chunk-VNO6ZJ35.js.map → chunk-2PRLKQAH.js.map} +0 -0
  109. /package/dist/{chunk-EFJ3MQ4V.js.map → chunk-65HQPW6O.js.map} +0 -0
  110. /package/dist/{chunk-GA454ALV.js.map → chunk-AAX3SUM3.js.map} +0 -0
  111. /package/dist/{chunk-QQUAB63I.js.map → chunk-BEB4GUU5.js.map} +0 -0
  112. /package/dist/{chunk-KUJVMMZQ.js.map → chunk-C7DGCHJE.js.map} +0 -0
  113. /package/dist/{chunk-PR5FBTFU.js.map → chunk-CYFQJMUV.js.map} +0 -0
  114. /package/dist/{chunk-KLAO5DGL.js.map → chunk-G7JBLD65.js.map} +0 -0
  115. /package/dist/{chunk-CHEL3SKB.js.map → chunk-HJILHQOR.js.map} +0 -0
  116. /package/dist/{chunk-ME6ESPZU.js.map → chunk-IG5VGHYB.js.map} +0 -0
  117. /package/dist/{chunk-XVZ7B3HG.js.map → chunk-JFEH2LZM.js.map} +0 -0
  118. /package/dist/{chunk-JLFA7DQG.js.map → chunk-M3AA636B.js.map} +0 -0
  119. /package/dist/{chunk-P4NEIHUT.js.map → chunk-MS3ULOZF.js.map} +0 -0
  120. /package/dist/{chunk-7IASACLB.js.map → chunk-NOHC2L57.js.map} +0 -0
  121. /package/dist/{chunk-6RVI47ZR.js.map → chunk-NTUNYIF7.js.map} +0 -0
  122. /package/dist/{chunk-CK5NTM2S.js.map → chunk-OGROP7ZN.js.map} +0 -0
  123. /package/dist/{chunk-MT25YHYH.js.map → chunk-OJRKZLZ4.js.map} +0 -0
  124. /package/dist/{chunk-2F2W355T.js.map → chunk-QA2ZAPBU.js.map} +0 -0
  125. /package/dist/{chunk-MC26UJIM.js.map → chunk-QLKBF3TI.js.map} +0 -0
  126. /package/dist/{chunk-WZYKANL3.js.map → chunk-SK42SSAN.js.map} +0 -0
  127. /package/dist/{chunk-VW676BEI.js.map → chunk-V7WH7DEM.js.map} +0 -0
  128. /package/dist/{chunk-PU63GXWS.js.map → chunk-W7DK3CYM.js.map} +0 -0
  129. /package/dist/{chunk-TFO23QT4.js.map → chunk-XKLD5OK4.js.map} +0 -0
  130. /package/dist/{chunk-I5V2VDIW.js.map → chunk-YCVWX2NF.js.map} +0 -0
  131. /package/dist/{chunk-UXHQAFNA.js.map → chunk-ZPXYWTN5.js.map} +0 -0
  132. /package/dist/{chunk-GGKRUQOO.js.map → chunk-ZYVPLJ4T.js.map} +0 -0
@@ -0,0 +1,646 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+
7
+ import {
8
+ applyOfflineSyncChangeset,
9
+ applyOfflineSyncSnapshot,
10
+ buildOfflineSyncChangeset,
11
+ buildOfflineSyncSnapshot,
12
+ buildOfflineSyncSnapshotForPaths,
13
+ } from "./offline-sync.js";
14
+ import { isEncryptedFile } from "./secure-store/secure-fs.js";
15
+ import { StorageManager } from "./storage.js";
16
+
17
+ async function tempDir(name: string): Promise<string> {
18
+ return mkdtemp(path.join(os.tmpdir(), `${name}-`));
19
+ }
20
+
21
+ async function write(root: string, relPath: string, content: string | Buffer): Promise<void> {
22
+ const filePath = path.join(root, relPath);
23
+ await mkdir(path.dirname(filePath), { recursive: true });
24
+ await writeFile(filePath, content);
25
+ }
26
+
27
+ async function readUtf8(root: string, relPath: string): Promise<string> {
28
+ return readFile(path.join(root, relPath), "utf-8");
29
+ }
30
+
31
+ test("offline snapshot captures source-of-truth files and excludes private/internal paths", async () => {
32
+ const root = await tempDir("remnic-offline-snapshot");
33
+ try {
34
+ await write(root, "facts/a.md", "alpha");
35
+ await write(root, "facts/fact-hashes.txt", "user-authored memory file");
36
+ await write(root, "transcripts/session.jsonl", "turn");
37
+ await write(root, "assets/blob.bin", Buffer.from([0, 1, 2, 255]));
38
+ await write(root, ".secure-store/header.json", "secret");
39
+ await write(root, ".offline-sync/state/local.json", "state");
40
+ await write(root, "state/fact-hashes.txt", "derived");
41
+ await write(root, "state/fact-hashes.ready", "v1");
42
+
43
+ const snapshot = await buildOfflineSyncSnapshot({
44
+ root,
45
+ sourceId: "remote",
46
+ includeContent: true,
47
+ });
48
+
49
+ assert.deepEqual(
50
+ snapshot.files.map((file) => file.path),
51
+ ["assets/blob.bin", "facts/a.md", "facts/fact-hashes.txt", "transcripts/session.jsonl"],
52
+ );
53
+ const binary = snapshot.files.find((file) => file.path === "assets/blob.bin");
54
+ assert.equal(Buffer.from(binary?.contentBase64 ?? "", "base64")[3], 255);
55
+
56
+ const withoutTranscripts = await buildOfflineSyncSnapshot({
57
+ root,
58
+ sourceId: "remote",
59
+ includeContent: false,
60
+ includeTranscripts: false,
61
+ });
62
+ assert.deepEqual(
63
+ withoutTranscripts.files.map((file) => file.path),
64
+ ["assets/blob.bin", "facts/a.md", "facts/fact-hashes.txt"],
65
+ );
66
+ } finally {
67
+ await rm(root, { recursive: true, force: true });
68
+ }
69
+ });
70
+
71
+ test("offline changeset pushes local edits when the remote is still at the shared base", async () => {
72
+ const remote = await tempDir("remnic-offline-remote");
73
+ const local = await tempDir("remnic-offline-local");
74
+ try {
75
+ await write(remote, "facts/base.md", "base");
76
+ const initial = await buildOfflineSyncSnapshot({
77
+ root: remote,
78
+ sourceId: "remote",
79
+ includeContent: true,
80
+ });
81
+ const pull = await applyOfflineSyncSnapshot({
82
+ root: local,
83
+ snapshot: initial,
84
+ });
85
+
86
+ await write(local, "facts/base.md", "base plus local");
87
+ await write(local, "facts/local-only.md", "new local fact");
88
+ const changeset = await buildOfflineSyncChangeset({
89
+ root: local,
90
+ sourceId: "laptop",
91
+ baseFiles: pull.nextBaseFiles,
92
+ });
93
+
94
+ assert.equal(changeset.changes.length, 2);
95
+ const push = await applyOfflineSyncChangeset({
96
+ root: remote,
97
+ changeset,
98
+ });
99
+
100
+ assert.equal(push.appliedUpserts, 2);
101
+ assert.equal(push.conflicts.length, 0);
102
+ assert.equal(await readUtf8(remote, "facts/base.md"), "base plus local");
103
+ assert.equal(await readUtf8(remote, "facts/local-only.md"), "new local fact");
104
+ } finally {
105
+ await rm(remote, { recursive: true, force: true });
106
+ await rm(local, { recursive: true, force: true });
107
+ }
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
+
234
+ test("offline pull preserves local edits when both sides changed since the base", async () => {
235
+ const remote = await tempDir("remnic-offline-conflict-remote");
236
+ const local = await tempDir("remnic-offline-conflict-local");
237
+ try {
238
+ await write(remote, "facts/shared.md", "base");
239
+ const initial = await buildOfflineSyncSnapshot({
240
+ root: remote,
241
+ sourceId: "remote",
242
+ includeContent: true,
243
+ });
244
+ const firstPull = await applyOfflineSyncSnapshot({
245
+ root: local,
246
+ snapshot: initial,
247
+ });
248
+
249
+ await write(local, "facts/shared.md", "local edit");
250
+ await write(remote, "facts/shared.md", "remote edit");
251
+ const remoteSnapshot = await buildOfflineSyncSnapshot({
252
+ root: remote,
253
+ sourceId: "remote",
254
+ includeContent: true,
255
+ });
256
+
257
+ const secondPull = await applyOfflineSyncSnapshot({
258
+ root: local,
259
+ snapshot: remoteSnapshot,
260
+ baseFiles: firstPull.nextBaseFiles,
261
+ });
262
+
263
+ assert.equal(secondPull.conflicts.length, 1);
264
+ assert.equal(secondPull.conflicts[0]?.reason, "both_modified");
265
+ assert.equal(await readUtf8(local, "facts/shared.md"), "local edit");
266
+ const conflictPath = secondPull.conflicts[0]?.conflictPath;
267
+ assert.ok(conflictPath);
268
+ assert.equal(await readUtf8(local, conflictPath), "remote edit");
269
+ } finally {
270
+ await rm(remote, { recursive: true, force: true });
271
+ await rm(local, { recursive: true, force: true });
272
+ }
273
+ });
274
+
275
+ test("offline push preserves remote edits when both sides changed since the base", async () => {
276
+ const remote = await tempDir("remnic-offline-push-conflict-remote");
277
+ const local = await tempDir("remnic-offline-push-conflict-local");
278
+ try {
279
+ await write(remote, "facts/shared.md", "base");
280
+ const initial = await buildOfflineSyncSnapshot({
281
+ root: remote,
282
+ sourceId: "remote",
283
+ includeContent: true,
284
+ });
285
+ const firstPull = await applyOfflineSyncSnapshot({
286
+ root: local,
287
+ snapshot: initial,
288
+ });
289
+
290
+ await write(local, "facts/shared.md", "local edit");
291
+ await write(remote, "facts/shared.md", "remote edit");
292
+ const changeset = await buildOfflineSyncChangeset({
293
+ root: local,
294
+ sourceId: "laptop",
295
+ baseFiles: firstPull.nextBaseFiles,
296
+ });
297
+
298
+ const push = await applyOfflineSyncChangeset({
299
+ root: remote,
300
+ changeset,
301
+ });
302
+
303
+ assert.equal(push.appliedUpserts, 0);
304
+ assert.equal(push.conflicts.length, 1);
305
+ assert.equal(push.conflicts[0]?.reason, "remote_changed_for_local_update");
306
+ assert.equal(await readUtf8(remote, "facts/shared.md"), "remote edit");
307
+ const conflictPath = push.conflicts[0]?.conflictPath;
308
+ assert.ok(conflictPath);
309
+ assert.equal(await readUtf8(remote, conflictPath), "local edit");
310
+ } finally {
311
+ await rm(remote, { recursive: true, force: true });
312
+ await rm(local, { recursive: true, force: true });
313
+ }
314
+ });
315
+
316
+ test("offline pull applies remote deletion when the local file is unchanged", async () => {
317
+ const remote = await tempDir("remnic-offline-delete-remote");
318
+ const local = await tempDir("remnic-offline-delete-local");
319
+ try {
320
+ await write(remote, "facts/deleted.md", "soon gone");
321
+ const initial = await buildOfflineSyncSnapshot({
322
+ root: remote,
323
+ sourceId: "remote",
324
+ includeContent: true,
325
+ });
326
+ const firstPull = await applyOfflineSyncSnapshot({
327
+ root: local,
328
+ snapshot: initial,
329
+ });
330
+
331
+ await rm(path.join(remote, "facts/deleted.md"), { force: true });
332
+ const remoteSnapshot = await buildOfflineSyncSnapshot({
333
+ root: remote,
334
+ sourceId: "remote",
335
+ includeContent: true,
336
+ });
337
+ const secondPull = await applyOfflineSyncSnapshot({
338
+ root: local,
339
+ snapshot: remoteSnapshot,
340
+ baseFiles: firstPull.nextBaseFiles,
341
+ });
342
+
343
+ assert.equal(secondPull.deleted, 1);
344
+ await assert.rejects(
345
+ () => readFile(path.join(local, "facts/deleted.md")),
346
+ /ENOENT/,
347
+ );
348
+ } finally {
349
+ await rm(remote, { recursive: true, force: true });
350
+ await rm(local, { recursive: true, force: true });
351
+ }
352
+ });
353
+
354
+ test("offline changeset does not delete transcript baselines when transcripts are excluded", async () => {
355
+ const root = await tempDir("remnic-offline-transcript-mode");
356
+ try {
357
+ await write(root, "facts/a.md", "alpha");
358
+ await write(root, "transcripts/session.jsonl", "turn");
359
+ const base = await buildOfflineSyncSnapshot({
360
+ root,
361
+ sourceId: "remote",
362
+ includeContent: true,
363
+ });
364
+
365
+ await rm(path.join(root, "transcripts"), { recursive: true, force: true });
366
+ const changeset = await buildOfflineSyncChangeset({
367
+ root,
368
+ sourceId: "laptop",
369
+ baseFiles: base.files,
370
+ includeTranscripts: false,
371
+ });
372
+
373
+ assert.deepEqual(changeset.changes, []);
374
+ } finally {
375
+ await rm(root, { recursive: true, force: true });
376
+ }
377
+ });
378
+
379
+ test("offline changeset rejects transcript changes when transcripts are excluded", async () => {
380
+ const root = await tempDir("remnic-offline-transcript-invalid");
381
+ try {
382
+ await write(root, "facts/a.md", "alpha");
383
+ const transcript = Buffer.from("turn");
384
+ await assert.rejects(
385
+ () =>
386
+ applyOfflineSyncChangeset({
387
+ root,
388
+ changeset: {
389
+ format: "remnic.offline-sync.changeset.v1",
390
+ schemaVersion: 1,
391
+ createdAt: new Date().toISOString(),
392
+ sourceId: "laptop",
393
+ includeTranscripts: false,
394
+ changes: [{
395
+ type: "upsert",
396
+ path: "transcripts/session.jsonl",
397
+ file: {
398
+ path: "transcripts/session.jsonl",
399
+ sha256: "0000000000000000000000000000000000000000000000000000000000000000",
400
+ bytes: transcript.byteLength,
401
+ mtimeMs: 0,
402
+ contentBase64: transcript.toString("base64"),
403
+ },
404
+ }],
405
+ },
406
+ }),
407
+ /offline sync changeset includeTranscripts is false but contains transcript path/,
408
+ );
409
+ } finally {
410
+ await rm(root, { recursive: true, force: true });
411
+ }
412
+ });
413
+
414
+ test("offline snapshot rejects transcript records when transcripts are excluded", async () => {
415
+ const root = await tempDir("remnic-offline-snapshot-transcript-invalid");
416
+ try {
417
+ const transcript = Buffer.from("turn");
418
+ await assert.rejects(
419
+ () =>
420
+ applyOfflineSyncSnapshot({
421
+ root,
422
+ snapshot: {
423
+ format: "remnic.offline-sync.snapshot.v1",
424
+ schemaVersion: 1,
425
+ createdAt: new Date().toISOString(),
426
+ sourceId: "remote",
427
+ includeTranscripts: false,
428
+ files: [{
429
+ path: "transcripts/session.jsonl",
430
+ sha256: "0000000000000000000000000000000000000000000000000000000000000000",
431
+ bytes: transcript.byteLength,
432
+ mtimeMs: 0,
433
+ contentBase64: transcript.toString("base64"),
434
+ }],
435
+ },
436
+ }),
437
+ /offline sync snapshot includeTranscripts is false but contains transcript path/,
438
+ );
439
+ } finally {
440
+ await rm(root, { recursive: true, force: true });
441
+ }
442
+ });
443
+
444
+ test("offline payloads require explicit includeTranscripts booleans", async () => {
445
+ const root = await tempDir("remnic-offline-include-transcripts-invalid");
446
+ try {
447
+ await assert.rejects(
448
+ () =>
449
+ applyOfflineSyncSnapshot({
450
+ root,
451
+ snapshot: {
452
+ format: "remnic.offline-sync.snapshot.v1",
453
+ schemaVersion: 1,
454
+ createdAt: new Date().toISOString(),
455
+ sourceId: "remote",
456
+ files: [],
457
+ },
458
+ }),
459
+ /includeTranscripts must be a boolean/,
460
+ );
461
+
462
+ await assert.rejects(
463
+ () =>
464
+ applyOfflineSyncChangeset({
465
+ root,
466
+ changeset: {
467
+ format: "remnic.offline-sync.changeset.v1",
468
+ schemaVersion: 1,
469
+ createdAt: new Date().toISOString(),
470
+ sourceId: "laptop",
471
+ includeTranscripts: "false",
472
+ changes: [],
473
+ },
474
+ }),
475
+ /includeTranscripts must be a boolean/,
476
+ );
477
+ } finally {
478
+ await rm(root, { recursive: true, force: true });
479
+ }
480
+ });
481
+
482
+ test("offline payloads reject excluded internal paths", async () => {
483
+ const root = await tempDir("remnic-offline-internal-path-invalid");
484
+ try {
485
+ const header = Buffer.from("secret");
486
+ await assert.rejects(
487
+ () =>
488
+ applyOfflineSyncSnapshot({
489
+ root,
490
+ snapshot: {
491
+ format: "remnic.offline-sync.snapshot.v1",
492
+ schemaVersion: 1,
493
+ createdAt: new Date().toISOString(),
494
+ sourceId: "remote",
495
+ includeTranscripts: true,
496
+ files: [{
497
+ path: ".secure-store/header.json",
498
+ sha256: "0000000000000000000000000000000000000000000000000000000000000000",
499
+ bytes: header.byteLength,
500
+ mtimeMs: 0,
501
+ contentBase64: header.toString("base64"),
502
+ }],
503
+ },
504
+ }),
505
+ /offline sync snapshot contains excluded path: \.secure-store\/header\.json/,
506
+ );
507
+
508
+ await assert.rejects(
509
+ () =>
510
+ applyOfflineSyncChangeset({
511
+ root,
512
+ changeset: {
513
+ format: "remnic.offline-sync.changeset.v1",
514
+ schemaVersion: 1,
515
+ createdAt: new Date().toISOString(),
516
+ sourceId: "laptop",
517
+ includeTranscripts: true,
518
+ changes: [{
519
+ type: "upsert",
520
+ path: ".secure-store/header.json",
521
+ file: {
522
+ path: ".secure-store/header.json",
523
+ sha256: "0000000000000000000000000000000000000000000000000000000000000000",
524
+ bytes: header.byteLength,
525
+ mtimeMs: 0,
526
+ contentBase64: header.toString("base64"),
527
+ },
528
+ }],
529
+ },
530
+ }),
531
+ /offline sync changeset contains excluded path: \.secure-store\/header\.json/,
532
+ );
533
+ } finally {
534
+ await rm(root, { recursive: true, force: true });
535
+ }
536
+ });
537
+
538
+ test("offline sync applies and snapshots through secure storage hooks", async () => {
539
+ const root = await tempDir("remnic-offline-secure-store");
540
+ const source = await tempDir("remnic-offline-secure-source");
541
+ try {
542
+ await write(source, "facts/secure.md", "secret fact");
543
+ const changeset = await buildOfflineSyncChangeset({
544
+ root: source,
545
+ sourceId: "laptop",
546
+ });
547
+ const storage = new StorageManager(root);
548
+ storage.setSecureStoreKey(Buffer.alloc(32, 7));
549
+ storage.setSecureStoreRequired(true);
550
+
551
+ const apply = await applyOfflineSyncChangeset({
552
+ root,
553
+ changeset,
554
+ readFile: async ({ filePath }) => storage.readOfflineSyncFile(filePath),
555
+ writeFile: async ({ filePath, content }) => storage.writeOfflineSyncFile(filePath, content),
556
+ deleteFile: async ({ filePath }) => storage.deleteOfflineSyncFile(filePath),
557
+ });
558
+
559
+ assert.equal(apply.appliedUpserts, 1);
560
+ const raw = await readFile(path.join(root, "facts", "secure.md"));
561
+ assert.equal(isEncryptedFile(raw), true);
562
+ assert.equal(
563
+ (await storage.readOfflineSyncFile(path.join(root, "facts", "secure.md"))).toString("utf8"),
564
+ "secret fact",
565
+ );
566
+
567
+ const snapshot = await buildOfflineSyncSnapshot({
568
+ root,
569
+ sourceId: "remote",
570
+ includeContent: true,
571
+ readFile: async ({ filePath }) => storage.readOfflineSyncFile(filePath),
572
+ });
573
+ assert.equal(
574
+ Buffer.from(snapshot.files[0]?.contentBase64 ?? "", "base64").toString("utf8"),
575
+ "secret fact",
576
+ );
577
+ assert.equal(snapshot.files[0]?.bytes, Buffer.byteLength("secret fact"));
578
+ } finally {
579
+ await rm(root, { recursive: true, force: true });
580
+ await rm(source, { recursive: true, force: true });
581
+ }
582
+ });
583
+
584
+ test("offline storage writes invalidate fact hash readiness for rebuild", async () => {
585
+ const root = await tempDir("remnic-offline-hash-index-local");
586
+ const source = await tempDir("remnic-offline-hash-index-source");
587
+ try {
588
+ const localStorage = new StorageManager(root);
589
+ await localStorage.writeMemory("fact", "alpha fact");
590
+ assert.equal(await localStorage.hasFactContentHash("alpha fact"), true);
591
+ assert.equal(await localStorage.hasFactContentHash("beta fact"), false);
592
+
593
+ const sourceStorage = new StorageManager(source);
594
+ await sourceStorage.writeMemory("fact", "beta fact");
595
+ const sourceChangeset = await buildOfflineSyncChangeset({
596
+ root: source,
597
+ sourceId: "remote",
598
+ });
599
+
600
+ assert.equal(
601
+ sourceChangeset.changes.some((change) => change.path.startsWith("state/fact-hashes")),
602
+ false,
603
+ );
604
+
605
+ const factChangeset = {
606
+ ...sourceChangeset,
607
+ changes: sourceChangeset.changes.filter((change) => change.path.startsWith("facts/")),
608
+ };
609
+ const apply = await applyOfflineSyncChangeset({
610
+ root,
611
+ changeset: factChangeset,
612
+ readFile: async ({ filePath }) => localStorage.readOfflineSyncFile(filePath),
613
+ writeFile: async ({ filePath, content }) => localStorage.writeOfflineSyncFile(filePath, content),
614
+ deleteFile: async ({ filePath }) => localStorage.deleteOfflineSyncFile(filePath),
615
+ });
616
+
617
+ assert.equal(apply.conflicts.length, 0);
618
+ assert.equal(await localStorage.hasFactContentHash("beta fact"), true);
619
+ } finally {
620
+ await rm(root, { recursive: true, force: true });
621
+ await rm(source, { recursive: true, force: true });
622
+ }
623
+ });
624
+
625
+ test("offline changeset validation reports client input errors with an offline sync prefix", async () => {
626
+ const root = await tempDir("remnic-offline-invalid-changeset");
627
+ try {
628
+ await assert.rejects(
629
+ () =>
630
+ applyOfflineSyncChangeset({
631
+ root,
632
+ changeset: {
633
+ format: "remnic.offline-sync.changeset.v1",
634
+ schemaVersion: 1,
635
+ createdAt: new Date().toISOString(),
636
+ sourceId: "laptop",
637
+ includeTranscripts: true,
638
+ changes: [{ type: "delete", path: "../escape", baseSha256: "nope" }],
639
+ },
640
+ }),
641
+ /offline sync changeset invalid:/,
642
+ );
643
+ } finally {
644
+ await rm(root, { recursive: true, force: true });
645
+ }
646
+ });