@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
@@ -223,3 +223,292 @@ test("HTTP review show hides namespace denial as pair_not_found", async () => {
223
223
  await rm(dir, { recursive: true, force: true });
224
224
  }
225
225
  });
226
+
227
+ test("HTTP offline snapshot forwards namespace and transfer options", async () => {
228
+ const calls: Array<{
229
+ namespace: string | undefined;
230
+ principal: string | undefined;
231
+ includeTranscripts: boolean | undefined;
232
+ includeContent: boolean | undefined;
233
+ }> = [];
234
+ const service = {
235
+ offlineSyncSnapshot: async (options: {
236
+ namespace?: string;
237
+ principal?: string;
238
+ includeTranscripts?: boolean;
239
+ includeContent?: boolean;
240
+ }) => {
241
+ calls.push({
242
+ namespace: options.namespace,
243
+ principal: options.principal,
244
+ includeTranscripts: options.includeTranscripts,
245
+ includeContent: options.includeContent,
246
+ });
247
+ return {
248
+ namespace: options.namespace ?? "default",
249
+ format: "remnic.offline-sync.snapshot.v1",
250
+ schemaVersion: 1,
251
+ createdAt: new Date("2026-05-21T00:00:00Z").toISOString(),
252
+ sourceId: "remote:test",
253
+ includeTranscripts: options.includeTranscripts !== false,
254
+ files: [],
255
+ };
256
+ },
257
+ } as unknown as EngramAccessService;
258
+ const server = new EngramAccessHttpServer({
259
+ service,
260
+ port: 0,
261
+ authToken: "test-token",
262
+ principal: "reader",
263
+ adminConsoleEnabled: false,
264
+ });
265
+
266
+ const status = await server.start();
267
+ try {
268
+ const response = await fetch(
269
+ `http://127.0.0.1:${status.port}/remnic/v1/offline-sync/snapshot?namespace=team&include_transcripts=false&content=false`,
270
+ { headers: { authorization: "Bearer test-token" } },
271
+ );
272
+ const body = await response.json() as { namespace?: string; includeTranscripts?: boolean; files?: unknown[] };
273
+
274
+ assert.equal(response.status, 200);
275
+ assert.equal(body.namespace, "team");
276
+ assert.equal(body.includeTranscripts, false);
277
+ assert.deepEqual(body.files, []);
278
+ assert.deepEqual(calls, [{
279
+ namespace: "team",
280
+ principal: "reader",
281
+ includeTranscripts: false,
282
+ includeContent: false,
283
+ }]);
284
+ } finally {
285
+ await server.stop();
286
+ }
287
+ });
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
+
362
+ test("HTTP offline snapshot rejects invalid boolean query values", async () => {
363
+ let calls = 0;
364
+ const service = {
365
+ offlineSyncSnapshot: async () => {
366
+ calls += 1;
367
+ return {};
368
+ },
369
+ } as unknown as EngramAccessService;
370
+ const server = new EngramAccessHttpServer({
371
+ service,
372
+ port: 0,
373
+ authToken: "test-token",
374
+ principal: "reader",
375
+ adminConsoleEnabled: false,
376
+ });
377
+
378
+ const status = await server.start();
379
+ try {
380
+ const response = await fetch(
381
+ `http://127.0.0.1:${status.port}/engram/v1/offline-sync/snapshot?include_transcripts=maybe`,
382
+ { headers: { authorization: "Bearer test-token" } },
383
+ );
384
+ const body = await response.json() as { error?: string; code?: string };
385
+
386
+ assert.equal(response.status, 400);
387
+ assert.match(body.error ?? "", /include_transcripts must be one of: true, false/);
388
+ assert.equal(body.code, "input_error");
389
+ assert.equal(calls, 0);
390
+ } finally {
391
+ await server.stop();
392
+ }
393
+ });
394
+
395
+ test("HTTP offline apply validates and forwards changesets", async () => {
396
+ const calls: Array<{
397
+ namespace: string | undefined;
398
+ principal: string | undefined;
399
+ changeset: unknown;
400
+ }> = [];
401
+ const changeset = {
402
+ format: "remnic.offline-sync.changeset.v1",
403
+ schemaVersion: 1,
404
+ createdAt: new Date("2026-05-21T00:00:00Z").toISOString(),
405
+ sourceId: "laptop:test",
406
+ includeTranscripts: true,
407
+ changes: [],
408
+ };
409
+ const service = {
410
+ offlineSyncApply: async (options: {
411
+ namespace?: string;
412
+ principal?: string;
413
+ changeset: unknown;
414
+ }) => {
415
+ calls.push({
416
+ namespace: options.namespace,
417
+ principal: options.principal,
418
+ changeset: options.changeset,
419
+ });
420
+ return {
421
+ namespace: options.namespace ?? "default",
422
+ appliedUpserts: 0,
423
+ appliedDeletes: 0,
424
+ skipped: 0,
425
+ conflicts: [],
426
+ currentFiles: [],
427
+ };
428
+ },
429
+ } as unknown as EngramAccessService;
430
+ const server = new EngramAccessHttpServer({
431
+ service,
432
+ port: 0,
433
+ authToken: "test-token",
434
+ principal: "writer",
435
+ adminConsoleEnabled: false,
436
+ });
437
+
438
+ const status = await server.start();
439
+ try {
440
+ const response = await fetch(`http://127.0.0.1:${status.port}/remnic/v1/offline-sync/apply`, {
441
+ method: "POST",
442
+ headers: {
443
+ authorization: "Bearer test-token",
444
+ "content-type": "application/json",
445
+ },
446
+ body: JSON.stringify({ namespace: "team", changeset }),
447
+ });
448
+ const body = await response.json() as { namespace?: string; appliedUpserts?: number };
449
+
450
+ assert.equal(response.status, 200);
451
+ assert.equal(body.namespace, "team");
452
+ assert.equal(body.appliedUpserts, 0);
453
+ assert.deepEqual(calls, [{
454
+ namespace: "team",
455
+ principal: "writer",
456
+ changeset,
457
+ }]);
458
+ } finally {
459
+ await server.stop();
460
+ }
461
+ });
462
+
463
+ test("HTTP offline apply requires a changeset", async () => {
464
+ let calls = 0;
465
+ const service = {
466
+ offlineSyncApply: async () => {
467
+ calls += 1;
468
+ return {};
469
+ },
470
+ } as unknown as EngramAccessService;
471
+ const server = new EngramAccessHttpServer({
472
+ service,
473
+ port: 0,
474
+ authToken: "test-token",
475
+ principal: "writer",
476
+ adminConsoleEnabled: false,
477
+ });
478
+
479
+ const status = await server.start();
480
+ try {
481
+ const response = await fetch(`http://127.0.0.1:${status.port}/engram/v1/offline-sync/apply`, {
482
+ method: "POST",
483
+ headers: {
484
+ authorization: "Bearer test-token",
485
+ "content-type": "application/json",
486
+ },
487
+ body: JSON.stringify({ namespace: "team" }),
488
+ });
489
+ const body = await response.json() as { code?: string; details?: Array<{ field?: string; message?: string }> };
490
+
491
+ assert.equal(response.status, 400);
492
+ assert.equal(body.code, "validation_error");
493
+ assert.equal(body.details?.[0]?.field, "changeset");
494
+ assert.equal(calls, 0);
495
+
496
+ const nullResponse = await fetch(`http://127.0.0.1:${status.port}/engram/v1/offline-sync/apply`, {
497
+ method: "POST",
498
+ headers: {
499
+ authorization: "Bearer test-token",
500
+ "content-type": "application/json",
501
+ },
502
+ body: JSON.stringify({ namespace: "team", changeset: null }),
503
+ });
504
+ const nullBody = await nullResponse.json() as { code?: string; details?: Array<{ field?: string; message?: string }> };
505
+
506
+ assert.equal(nullResponse.status, 400);
507
+ assert.equal(nullBody.code, "validation_error");
508
+ assert.equal(nullBody.details?.[0]?.field, "changeset");
509
+ assert.equal(nullBody.details?.[0]?.message, "changeset is required");
510
+ assert.equal(calls, 0);
511
+ } finally {
512
+ await server.stop();
513
+ }
514
+ });
@@ -588,6 +588,75 @@ export class EngramAccessHttpServer {
588
588
  return;
589
589
  }
590
590
 
591
+ if (
592
+ req.method === "GET" &&
593
+ (pathname === "/engram/v1/offline-sync/snapshot" || pathname === "/remnic/v1/offline-sync/snapshot")
594
+ ) {
595
+ const includeTranscriptsRaw = parsed.searchParams.get("include_transcripts");
596
+ const includeContentRaw = parsed.searchParams.get("content");
597
+ if (
598
+ includeTranscriptsRaw !== null &&
599
+ includeTranscriptsRaw !== "true" &&
600
+ includeTranscriptsRaw !== "false"
601
+ ) {
602
+ throw new EngramAccessInputError(
603
+ `include_transcripts must be one of: true, false (got: ${includeTranscriptsRaw})`,
604
+ );
605
+ }
606
+ if (
607
+ includeContentRaw !== null &&
608
+ includeContentRaw !== "true" &&
609
+ includeContentRaw !== "false"
610
+ ) {
611
+ throw new EngramAccessInputError(
612
+ `content must be one of: true, false (got: ${includeContentRaw})`,
613
+ );
614
+ }
615
+ const namespaceParam = parsed.searchParams.get("namespace");
616
+ const result = await this.service.offlineSyncSnapshot({
617
+ namespace: this.resolveNamespace(
618
+ req,
619
+ namespaceParam && namespaceParam.length > 0 ? namespaceParam : undefined,
620
+ ),
621
+ principal: this.resolveRequestPrincipal(req),
622
+ includeTranscripts: includeTranscriptsRaw !== "false",
623
+ includeContent: includeContentRaw !== "false",
624
+ });
625
+ this.respondJson(res, 200, result);
626
+ return;
627
+ }
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
+
644
+ if (
645
+ req.method === "POST" &&
646
+ (pathname === "/engram/v1/offline-sync/apply" || pathname === "/remnic/v1/offline-sync/apply")
647
+ ) {
648
+ const body = await this.readValidatedBody(req, "offlineSyncApply");
649
+ this.ensureWriteRateLimitAvailable();
650
+ const result = await this.service.offlineSyncApply({
651
+ namespace: this.resolveNamespace(req, body.namespace),
652
+ principal: this.resolveRequestPrincipal(req),
653
+ changeset: body.changeset,
654
+ });
655
+ this.recordWriteRateLimitHit();
656
+ this.respondJson(res, 200, result);
657
+ return;
658
+ }
659
+
591
660
  if (req.method === "POST" && pathname === "/engram/v1/recall/explain") {
592
661
  const body = await this.readValidatedBody(req, "recallExplain");
593
662
  const response = await this.service.recallExplain({
@@ -364,6 +364,28 @@ export const capsuleListRequestSchema = z
364
364
  namespace: namespaceSchema,
365
365
  });
366
366
 
367
+ // ---------------------------------------------------------------------------
368
+ // Offline sync
369
+ // ---------------------------------------------------------------------------
370
+
371
+ export const offlineSyncApplyRequestSchema = z
372
+ .object({
373
+ namespace: namespaceSchema,
374
+ changeset: z.unknown(),
375
+ })
376
+ .refine((value) => value.changeset !== undefined && value.changeset !== null, {
377
+ message: "changeset is required",
378
+ path: ["changeset"],
379
+ });
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
+
367
389
  // ---------------------------------------------------------------------------
368
390
  // Action confidence
369
391
  // ---------------------------------------------------------------------------
@@ -429,6 +451,8 @@ export type DaySummaryRequest = z.infer<typeof daySummaryRequestSchema>;
429
451
  export type CapsuleExportRequest = z.infer<typeof capsuleExportRequestSchema>;
430
452
  export type CapsuleImportRequest = z.infer<typeof capsuleImportRequestSchema>;
431
453
  export type CapsuleListRequest = z.infer<typeof capsuleListRequestSchema>;
454
+ export type OfflineSyncApplyRequest = z.infer<typeof offlineSyncApplyRequestSchema>;
455
+ export type OfflineSyncFilesRequest = z.infer<typeof offlineSyncFilesRequestSchema>;
432
456
  export type ActionConfidenceRequest = z.infer<typeof actionConfidenceRequestSchema>;
433
457
 
434
458
  // ---------------------------------------------------------------------------
@@ -452,6 +476,8 @@ export type SchemaName =
452
476
  | "capsuleExport"
453
477
  | "capsuleImport"
454
478
  | "capsuleList"
479
+ | "offlineSyncFiles"
480
+ | "offlineSyncApply"
455
481
  | "actionConfidence";
456
482
 
457
483
  export type SchemaTypeFor<N extends SchemaName> =
@@ -471,6 +497,8 @@ export type SchemaTypeFor<N extends SchemaName> =
471
497
  : N extends "capsuleExport" ? CapsuleExportRequest
472
498
  : N extends "capsuleImport" ? CapsuleImportRequest
473
499
  : N extends "capsuleList" ? CapsuleListRequest
500
+ : N extends "offlineSyncFiles" ? OfflineSyncFilesRequest
501
+ : N extends "offlineSyncApply" ? OfflineSyncApplyRequest
474
502
  : N extends "actionConfidence" ? ActionConfidenceRequest
475
503
  : never;
476
504
 
@@ -491,6 +519,8 @@ const schemas: Record<SchemaName, z.ZodTypeAny> = {
491
519
  capsuleExport: capsuleExportRequestSchema,
492
520
  capsuleImport: capsuleImportRequestSchema,
493
521
  capsuleList: capsuleListRequestSchema,
522
+ offlineSyncFiles: offlineSyncFilesRequestSchema,
523
+ offlineSyncApply: offlineSyncApplyRequestSchema,
494
524
  actionConfidence: actionConfidenceRequestSchema,
495
525
  };
496
526
 
@@ -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
+ });
@@ -135,6 +135,13 @@ import {
135
135
  defaultCapsulesDir,
136
136
  type CapsuleListEntry,
137
137
  } from "./capsule-cli.js";
138
+ import {
139
+ applyOfflineSyncChangeset,
140
+ buildOfflineSyncSnapshot,
141
+ buildOfflineSyncSnapshotForPaths,
142
+ type OfflineSyncApplyChangesetResult,
143
+ type OfflineSyncSnapshot,
144
+ } from "./offline-sync.js";
138
145
  import {
139
146
  evaluateActionConfidence,
140
147
  type ActionConfidenceInput,
@@ -603,6 +610,38 @@ export interface EngramAccessCapsuleListResponse {
603
610
  capsules: CapsuleListEntry[];
604
611
  }
605
612
 
613
+ export interface EngramAccessOfflineSyncSnapshotRequest {
614
+ namespace?: string;
615
+ principal?: string;
616
+ includeTranscripts?: boolean;
617
+ includeContent?: boolean;
618
+ }
619
+
620
+ export interface EngramAccessOfflineSyncFilesRequest {
621
+ namespace?: string;
622
+ principal?: string;
623
+ includeTranscripts?: boolean;
624
+ paths: string[];
625
+ }
626
+
627
+ export interface EngramAccessOfflineSyncApplyRequest {
628
+ namespace?: string;
629
+ principal?: string;
630
+ changeset: unknown;
631
+ }
632
+
633
+ export interface EngramAccessOfflineSyncSnapshotResponse extends OfflineSyncSnapshot {
634
+ namespace: string;
635
+ }
636
+
637
+ export interface EngramAccessOfflineSyncFilesResponse extends OfflineSyncSnapshot {
638
+ namespace: string;
639
+ }
640
+
641
+ export interface EngramAccessOfflineSyncApplyResponse extends OfflineSyncApplyChangesetResult {
642
+ namespace: string;
643
+ }
644
+
606
645
  export type EngramAccessActionConfidenceRequest = ActionConfidenceInput;
607
646
  export type EngramAccessActionConfidenceResponse = ActionConfidenceResult;
608
647
 
@@ -5522,6 +5561,87 @@ export class EngramAccessService {
5522
5561
  return { namespace: resolvedNamespace, capsulesDir, capsules };
5523
5562
  }
5524
5563
 
5564
+ async offlineSyncSnapshot(
5565
+ options: EngramAccessOfflineSyncSnapshotRequest = {},
5566
+ ): Promise<EngramAccessOfflineSyncSnapshotResponse> {
5567
+ const resolvedNamespace = this.resolveReadableNamespace(options.namespace, options.principal);
5568
+ const storage = await this.orchestrator.getStorage(resolvedNamespace);
5569
+ const storageHash = createHash("sha256").update(storage.dir).digest("hex").slice(0, 16);
5570
+ const snapshot = await buildOfflineSyncSnapshot({
5571
+ root: storage.dir,
5572
+ sourceId: `remnic:${resolvedNamespace}:${storageHash}`,
5573
+ includeContent: options.includeContent !== false,
5574
+ includeTranscripts: options.includeTranscripts !== false,
5575
+ readFile: async ({ filePath }) => storage.readOfflineSyncFile(filePath),
5576
+ });
5577
+ return {
5578
+ namespace: resolvedNamespace,
5579
+ ...snapshot,
5580
+ };
5581
+ }
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
+
5615
+ async offlineSyncApply(
5616
+ options: EngramAccessOfflineSyncApplyRequest,
5617
+ ): Promise<EngramAccessOfflineSyncApplyResponse> {
5618
+ const resolvedNamespace = this.resolveWritableNamespace(
5619
+ options.namespace,
5620
+ undefined,
5621
+ options.principal,
5622
+ );
5623
+ const storage = await this.orchestrator.getStorage(resolvedNamespace);
5624
+ try {
5625
+ const result = await applyOfflineSyncChangeset({
5626
+ root: storage.dir,
5627
+ changeset: options.changeset,
5628
+ readFile: async ({ filePath }) => storage.readOfflineSyncFile(filePath),
5629
+ writeFile: async ({ filePath, content }) => storage.writeOfflineSyncFile(filePath, content),
5630
+ deleteFile: async ({ filePath }) => storage.deleteOfflineSyncFile(filePath),
5631
+ });
5632
+ return {
5633
+ namespace: resolvedNamespace,
5634
+ ...result,
5635
+ };
5636
+ } catch (error) {
5637
+ const message = error instanceof Error ? error.message : String(error);
5638
+ if (message.startsWith("offline sync")) {
5639
+ throw new EngramAccessInputError(message);
5640
+ }
5641
+ throw error;
5642
+ }
5643
+ }
5644
+
5525
5645
  // ── Dreams pipeline telemetry surfaces (issue #678 PR 3+4) ──────────────
5526
5646
 
5527
5647
  /**
package/src/index.ts CHANGED
@@ -674,6 +674,40 @@ export {
674
674
  type SyncState,
675
675
  } from "./sync/index.js";
676
676
 
677
+ // ---------------------------------------------------------------------------
678
+ // Offline Sync
679
+ // ---------------------------------------------------------------------------
680
+
681
+ export {
682
+ OFFLINE_SYNC_CHANGESET_FORMAT,
683
+ OFFLINE_SYNC_SNAPSHOT_FORMAT,
684
+ OFFLINE_SYNC_STATE_VERSION,
685
+ applyOfflineSyncChangeset,
686
+ applyOfflineSyncSnapshot,
687
+ buildOfflineSyncChangeset,
688
+ buildOfflineSyncSnapshot,
689
+ buildOfflineSyncSnapshotForPaths,
690
+ defaultOfflineSyncStatePath,
691
+ fileStatesFromSnapshot,
692
+ normalizeOfflineSyncChangeset,
693
+ normalizeOfflineSyncSnapshot,
694
+ offlineSyncStateFromSnapshot,
695
+ readOfflineSyncState,
696
+ summarizeOfflineSyncChangeset,
697
+ writeOfflineSyncState,
698
+ type OfflineSyncApplyChangesetResult,
699
+ type OfflineSyncApplySnapshotResult,
700
+ type OfflineSyncChange,
701
+ type OfflineSyncChangeset,
702
+ type OfflineSyncConflict,
703
+ type OfflineSyncFileRecord,
704
+ type OfflineSyncFileState,
705
+ type OfflineSyncFileTarget,
706
+ type OfflineSyncFileWriteTarget,
707
+ type OfflineSyncSnapshot,
708
+ type OfflineSyncState,
709
+ } from "./offline-sync.js";
710
+
677
711
  // ---------------------------------------------------------------------------
678
712
  // Memory Extension Host (#382)
679
713
  // ---------------------------------------------------------------------------