@gmickel/gno 1.27.1 → 1.29.0

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 (195) hide show
  1. package/README.md +46 -20
  2. package/assets/skill/SKILL.md +59 -1
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip.sha256 +1 -0
  5. package/browser-extension/dist/chunk-b2zm0jjd.js +50 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/browser-extension/dist/preview.html +1 -1
  8. package/browser-extension/dist/service-worker.js +6 -6
  9. package/package.json +2 -2
  10. package/spec/cli.md +82 -5
  11. package/spec/db/schema.sql +31 -0
  12. package/spec/mcp.md +71 -0
  13. package/spec/output-schemas/ask.schema.json +156 -1
  14. package/spec/output-schemas/browser-clip-preview.schema.json +30 -0
  15. package/spec/output-schemas/collection-egress-check.schema.json +91 -0
  16. package/spec/output-schemas/collection-egress-policy-set.schema.json +56 -0
  17. package/spec/output-schemas/collection-egress-policy.schema.json +41 -0
  18. package/spec/output-schemas/context-capsule-v1.schema.json +183 -3
  19. package/spec/output-schemas/context-capsule-verification.schema.json +1 -1
  20. package/spec/output-schemas/egress-audit-management.schema.json +88 -0
  21. package/spec/output-schemas/get.schema.json +94 -0
  22. package/spec/output-schemas/mcp-http-error.schema.json +113 -2
  23. package/spec/output-schemas/mcp-job-status.schema.json +28 -0
  24. package/spec/output-schemas/multi-get.schema.json +128 -0
  25. package/spec/output-schemas/publish-artifact.schema.json +32 -0
  26. package/spec/output-schemas/record-import.schema.json +223 -0
  27. package/spec/output-schemas/retrieval-trace-common.schema.json +31 -0
  28. package/spec/output-schemas/retrieval-trace-export.schema.json +3 -0
  29. package/spec/output-schemas/retrieval-trace-qrels.schema.json +3 -0
  30. package/spec/output-schemas/retrieval-trace-summary.schema.json +4 -0
  31. package/spec/output-schemas/search-result.schema.json +94 -0
  32. package/spec/output-schemas/search-results.schema.json +155 -0
  33. package/spec/project-profile.schema.json +66 -0
  34. package/src/app/context-format.ts +1 -1
  35. package/src/app/context-runtime-contract.ts +16 -7
  36. package/src/cli/commands/ask.ts +7 -0
  37. package/src/cli/commands/collection/index.ts +5 -0
  38. package/src/cli/commands/collection/policy.ts +126 -0
  39. package/src/cli/commands/context-build.ts +5 -0
  40. package/src/cli/commands/daemon.ts +45 -3
  41. package/src/cli/commands/egress-audit.ts +68 -0
  42. package/src/cli/commands/embed.ts +2 -0
  43. package/src/cli/commands/get.ts +12 -2
  44. package/src/cli/commands/index-cmd.ts +13 -0
  45. package/src/cli/commands/multi-get.ts +9 -2
  46. package/src/cli/commands/query.ts +12 -0
  47. package/src/cli/commands/replay.ts +13 -3
  48. package/src/cli/commands/shared.ts +28 -0
  49. package/src/cli/commands/update.ts +5 -0
  50. package/src/cli/commands/vsearch.ts +3 -1
  51. package/src/cli/program.ts +127 -0
  52. package/src/config/index.ts +9 -0
  53. package/src/config/project-profile.ts +2 -0
  54. package/src/config/types.ts +66 -0
  55. package/src/converters/adapters/browser-export/adapter.ts +199 -0
  56. package/src/converters/adapters/browser-export/formats.ts +358 -0
  57. package/src/converters/adapters/email/adapter.ts +429 -0
  58. package/src/converters/adapters/email/html.ts +162 -0
  59. package/src/converters/adapters/email/mime.ts +454 -0
  60. package/src/converters/adapters/email/parameters.ts +77 -0
  61. package/src/converters/adapters/ical/adapter.ts +475 -0
  62. package/src/converters/adapters/ical/recurrence.ts +186 -0
  63. package/src/converters/adapters/jsonl/adapter.ts +238 -0
  64. package/src/converters/adapters/jsonl/config.ts +105 -0
  65. package/src/converters/adapters/shared/html-text.ts +165 -0
  66. package/src/converters/adapters/shared/record-utils.ts +79 -0
  67. package/src/converters/adapters/shared/utf8-lines.ts +141 -0
  68. package/src/converters/adapters/transcript/adapter.ts +295 -0
  69. package/src/converters/adapters/transcript/json.ts +184 -0
  70. package/src/converters/adapters/transcript/model.ts +171 -0
  71. package/src/converters/adapters/transcript/text.ts +58 -0
  72. package/src/converters/adapters/transcript/timed.ts +152 -0
  73. package/src/converters/index.ts +11 -1
  74. package/src/converters/mime.ts +8 -0
  75. package/src/converters/pipeline.ts +15 -1
  76. package/src/converters/registry.ts +37 -1
  77. package/src/converters/types.ts +136 -0
  78. package/src/core/browser-clip.ts +16 -1
  79. package/src/core/collection-egress-policy-projection.ts +28 -0
  80. package/src/core/collection-egress-policy-service.ts +443 -0
  81. package/src/core/collection-egress-policy-validation.ts +242 -0
  82. package/src/core/config-mutation.ts +31 -19
  83. package/src/core/context-capsule-schema.ts +198 -1
  84. package/src/core/context-capsule-validation.ts +4 -4
  85. package/src/core/context-capsule-verification.ts +5 -1
  86. package/src/core/context-capsule.ts +177 -112
  87. package/src/core/context-evidence.ts +93 -15
  88. package/src/core/destination-classifier.ts +402 -0
  89. package/src/core/document-capabilities.ts +12 -0
  90. package/src/core/egress-audit.ts +239 -0
  91. package/src/core/egress-authorization.ts +50 -0
  92. package/src/core/egress-enforcement.ts +264 -0
  93. package/src/core/egress-policy.ts +440 -0
  94. package/src/core/egress-provenance.ts +336 -0
  95. package/src/core/job-manager.ts +37 -0
  96. package/src/core/network-boundary-inventory.ts +261 -0
  97. package/src/core/project-profile-apply-state.ts +5 -0
  98. package/src/core/project-profile.ts +4 -0
  99. package/src/core/record-metadata.ts +49 -0
  100. package/src/core/retrieval-qrels.ts +6 -0
  101. package/src/core/retrieval-replay.ts +18 -4
  102. package/src/core/retrieval-trace-export.ts +23 -4
  103. package/src/core/retrieval-trace-management-helpers.ts +1 -0
  104. package/src/core/retrieval-trace-management-types.ts +3 -0
  105. package/src/core/retrieval-trace-management.ts +9 -1
  106. package/src/core/retrieval-trace-session.ts +94 -0
  107. package/src/core/retrieval-trace.ts +22 -0
  108. package/src/ingestion/record-adapter-canonical.ts +433 -0
  109. package/src/ingestion/record-adapter.ts +448 -0
  110. package/src/ingestion/record-container.ts +704 -0
  111. package/src/ingestion/record-path.ts +20 -0
  112. package/src/ingestion/record-sync.ts +70 -0
  113. package/src/ingestion/sync.ts +243 -36
  114. package/src/ingestion/types.ts +71 -1
  115. package/src/ingestion/walker.ts +31 -11
  116. package/src/llm/errors.ts +10 -0
  117. package/src/llm/http-inference.ts +175 -0
  118. package/src/llm/http-policy.ts +537 -0
  119. package/src/llm/httpEmbedding.ts +47 -28
  120. package/src/llm/httpGeneration.ts +31 -18
  121. package/src/llm/httpRerank.ts +30 -18
  122. package/src/llm/index.ts +1 -0
  123. package/src/llm/nodeLlamaCpp/adapter.ts +55 -20
  124. package/src/llm/pinned-http-connection.ts +177 -0
  125. package/src/mcp/context.ts +53 -1
  126. package/src/mcp/http-egress.ts +220 -0
  127. package/src/mcp/http-security.ts +32 -21
  128. package/src/mcp/http-session.ts +13 -2
  129. package/src/mcp/http-transport.ts +96 -6
  130. package/src/mcp/sync-egress.ts +24 -0
  131. package/src/mcp/tools/add-collection.ts +4 -0
  132. package/src/mcp/tools/ask.ts +1 -1
  133. package/src/mcp/tools/context.ts +9 -2
  134. package/src/mcp/tools/egress.ts +247 -0
  135. package/src/mcp/tools/embed.ts +1 -0
  136. package/src/mcp/tools/get.ts +10 -2
  137. package/src/mcp/tools/index-cmd.ts +7 -0
  138. package/src/mcp/tools/index.ts +98 -0
  139. package/src/mcp/tools/multi-get.ts +9 -2
  140. package/src/mcp/tools/query.ts +10 -0
  141. package/src/mcp/tools/remove-collection.ts +4 -0
  142. package/src/mcp/tools/sync.ts +6 -0
  143. package/src/mcp/tools/trace.ts +7 -1
  144. package/src/mcp/tools/vsearch.ts +1 -0
  145. package/src/mcp/tools/workspace-write.ts +2 -0
  146. package/src/pipeline/egress-lineage.ts +124 -0
  147. package/src/pipeline/filters.ts +1 -1
  148. package/src/pipeline/graph-retrieval.ts +7 -4
  149. package/src/pipeline/hybrid.ts +21 -4
  150. package/src/pipeline/result-context.ts +12 -4
  151. package/src/pipeline/search.ts +23 -4
  152. package/src/pipeline/types.ts +6 -0
  153. package/src/pipeline/vsearch.ts +67 -28
  154. package/src/publish/artifact.ts +31 -3
  155. package/src/publish/export-service.ts +25 -0
  156. package/src/sdk/client.ts +119 -2
  157. package/src/sdk/documents.ts +13 -5
  158. package/src/sdk/embed.ts +1 -0
  159. package/src/sdk/types.ts +33 -0
  160. package/src/serve/browse-tree.ts +4 -2
  161. package/src/serve/clipper-capture.ts +5 -1
  162. package/src/serve/closed-json.ts +61 -0
  163. package/src/serve/config-sync.ts +4 -1
  164. package/src/serve/context.ts +1 -0
  165. package/src/serve/doc-events.ts +110 -39
  166. package/src/serve/public/components/CollectionEgressCheckPanel.tsx +411 -0
  167. package/src/serve/public/components/CollectionEgressPolicyEditor.tsx +78 -0
  168. package/src/serve/public/components/CollectionModelDialog.tsx +93 -9
  169. package/src/serve/public/components/EgressAuditPanel.tsx +278 -0
  170. package/src/serve/public/globals.built.css +1 -1
  171. package/src/serve/public/pages/Collections.tsx +3 -0
  172. package/src/serve/resident-request.ts +80 -3
  173. package/src/serve/resident-runtime.ts +63 -1
  174. package/src/serve/routes/api.ts +237 -65
  175. package/src/serve/routes/clipper.ts +64 -1
  176. package/src/serve/routes/mcp.ts +6 -0
  177. package/src/serve/routes/traces.ts +27 -4
  178. package/src/serve/server.ts +118 -7
  179. package/src/store/migrations/022-record-export-lineage.ts +42 -0
  180. package/src/store/migrations/023-collection-egress-policy.ts +61 -0
  181. package/src/store/migrations/024-egress-derived-lineage.ts +121 -0
  182. package/src/store/migrations/025-collection-egress-policy-revision.ts +40 -0
  183. package/src/store/migrations/index.ts +8 -0
  184. package/src/store/retrieval-trace-codec.ts +15 -0
  185. package/src/store/sqlite/adapter.ts +284 -14
  186. package/src/store/sqlite/change-journal-store.ts +41 -2
  187. package/src/store/sqlite/egress-audit-store.ts +485 -0
  188. package/src/store/sqlite/retrieval-trace-management-store.ts +47 -6
  189. package/src/store/sqlite/retrieval-trace-retention.ts +2 -0
  190. package/src/store/sqlite/retrieval-trace-rows.ts +8 -1
  191. package/src/store/sqlite/retrieval-trace-store.ts +104 -5
  192. package/src/store/types.ts +166 -1
  193. package/browser-extension/artifacts/gno-browser-clipper-v1.27.1.zip +0 -0
  194. package/browser-extension/artifacts/gno-browser-clipper-v1.27.1.zip.sha256 +0 -1
  195. package/browser-extension/dist/chunk-vn5f663b.js +0 -50
@@ -0,0 +1,20 @@
1
+ const RECORD_VIRTUAL_ROOT = ".gno/records";
2
+
3
+ const sha256 = (value: string): string =>
4
+ new Bun.CryptoHasher("sha256").update(value).digest("hex");
5
+
6
+ /** Stable internal path for one logical record inside an export container. */
7
+ export const recordVirtualPath = (
8
+ sourcePath: string,
9
+ recordKey: string
10
+ ): string =>
11
+ `${RECORD_VIRTUAL_ROOT}/${sha256(sourcePath).slice(0, 16)}/${recordKey}.md`;
12
+
13
+ /** Physical files may never occupy GNO's virtual-record namespace. */
14
+ export const isRecordVirtualPath = (relativePath: string): boolean => {
15
+ const normalized = relativePath.replaceAll("\\", "/");
16
+ return (
17
+ normalized === RECORD_VIRTUAL_ROOT ||
18
+ normalized.startsWith(`${RECORD_VIRTUAL_ROOT}/`)
19
+ );
20
+ };
@@ -0,0 +1,70 @@
1
+ import type { StoredRecordState } from "../store/types";
2
+ import type { CanonicalRecord, RecordAdapterRunResult } from "./record-adapter";
3
+
4
+ export type RecordSyncAction =
5
+ | { type: "add"; record: CanonicalRecord }
6
+ | { type: "update"; previous: StoredRecordState; record: CanonicalRecord }
7
+ | {
8
+ type: "reactivate";
9
+ previous: StoredRecordState;
10
+ record: CanonicalRecord;
11
+ }
12
+ | { type: "unchanged"; previous: StoredRecordState; record?: CanonicalRecord }
13
+ | { type: "deactivate"; previous: StoredRecordState }
14
+ | { type: "preserve"; previous: StoredRecordState };
15
+
16
+ export interface RecordSyncPlan {
17
+ authoritative: boolean;
18
+ actions: RecordSyncAction[];
19
+ }
20
+
21
+ /**
22
+ * Pure snapshot reconciliation. Persistence/transactions are intentionally
23
+ * deferred until the virtual-record lineage schema exists.
24
+ */
25
+ export function reconcileRecordSnapshot(
26
+ priorRecords: readonly StoredRecordState[],
27
+ snapshot: RecordAdapterRunResult
28
+ ): RecordSyncPlan {
29
+ const priorByKey = new Map(
30
+ priorRecords.map((record) => [record.recordKey, record])
31
+ );
32
+ const seen = new Set<string>();
33
+ const failed = new Set(snapshot.failedRecordKeys);
34
+ const actions: RecordSyncAction[] = [];
35
+
36
+ for (const record of snapshot.records) {
37
+ seen.add(record.recordKey);
38
+ const previous = priorByKey.get(record.recordKey);
39
+ if (!previous) {
40
+ actions.push({ type: "add", record });
41
+ continue;
42
+ }
43
+ if (!previous.active) {
44
+ actions.push({ type: "reactivate", previous, record });
45
+ continue;
46
+ }
47
+ if (
48
+ previous.sourceHash === record.sourceHash &&
49
+ previous.adapterVersion === record.adapterVersion &&
50
+ previous.adapterFingerprint === record.adapterFingerprint
51
+ ) {
52
+ actions.push({ type: "unchanged", previous, record });
53
+ continue;
54
+ }
55
+ actions.push({ type: "update", previous, record });
56
+ }
57
+
58
+ for (const previous of priorRecords) {
59
+ if (seen.has(previous.recordKey)) continue;
60
+ if (failed.has(previous.recordKey) || !snapshot.authoritative) {
61
+ actions.push({ type: "preserve", previous });
62
+ } else if (previous.active) {
63
+ actions.push({ type: "deactivate", previous });
64
+ } else {
65
+ actions.push({ type: "unchanged", previous });
66
+ }
67
+ }
68
+
69
+ return { authoritative: snapshot.authoritative, actions };
70
+ }
@@ -5,10 +5,10 @@
5
5
  * @module src/ingestion/sync
6
6
  */
7
7
 
8
- // node:fs/promises for stat (no Bun equivalent for file stats)
9
- import { stat } from "node:fs/promises";
8
+ // node:fs/promises for realpath/stat (no Bun equivalent for canonical paths or file stats)
9
+ import { realpath, stat } from "node:fs/promises";
10
10
  // node:path for join (no Bun path utils)
11
- import { join } from "node:path";
11
+ import { isAbsolute, join, relative, sep } from "node:path";
12
12
 
13
13
  import type { NormalizedContentTypeRule } from "../config";
14
14
  import type { Collection } from "../config/types";
@@ -16,6 +16,7 @@ import type {
16
16
  ChunkInput,
17
17
  DocEdgeInput,
18
18
  DocLinkInput,
19
+ DocumentInput,
19
20
  DocumentRow,
20
21
  IngestErrorInput,
21
22
  StorePort,
@@ -37,12 +38,14 @@ import {
37
38
  fingerprintContentTypeMetadataRules,
38
39
  resolveContentTypeRule,
39
40
  } from "../config";
41
+ import { createJsonlAdapter } from "../converters/adapters/jsonl/adapter";
42
+ import { createTranscriptAdapter } from "../converters/adapters/transcript/adapter";
40
43
  import { getDefaultMimeDetector, type MimeDetector } from "../converters/mime";
41
44
  import {
42
45
  type ConversionPipeline,
43
46
  getDefaultPipeline,
44
47
  } from "../converters/pipeline";
45
- import { DEFAULT_LIMITS } from "../converters/types";
48
+ import { DEFAULT_LIMITS, type RecordAdapter } from "../converters/types";
46
49
  import {
47
50
  diffDocumentStructure,
48
51
  extractDocumentStructure,
@@ -50,6 +53,7 @@ import {
50
53
  normalizeRelationEdgeType,
51
54
  normalizeRelationTarget,
52
55
  } from "../core/change-diff";
56
+ import { enforceCollectionEgress } from "../core/egress-enforcement";
53
57
  import {
54
58
  normalizeMarkdownPath,
55
59
  normalizeWikiName,
@@ -64,6 +68,7 @@ import {
64
68
  stripFrontmatter,
65
69
  } from "./frontmatter";
66
70
  import { buildLineOffsets } from "./position";
71
+ import { processRecordContainer } from "./record-container";
67
72
  import { getExcludedRanges } from "./strip";
68
73
  import { collectionToWalkConfig, DEFAULT_CHUNK_PARAMS } from "./types";
69
74
  import { defaultWalker } from "./walker";
@@ -542,6 +547,44 @@ function mustOk<T>(
542
547
  return result.value;
543
548
  }
544
549
 
550
+ const preserveDocumentWithError = (
551
+ existing: DocumentRow,
552
+ code: string,
553
+ message: string
554
+ ): DocumentInput => ({
555
+ collection: existing.collection,
556
+ relPath: existing.relPath,
557
+ sourceHash: existing.sourceHash,
558
+ sourceMime: existing.sourceMime,
559
+ sourceExt: existing.sourceExt,
560
+ sourceSize: existing.sourceSize,
561
+ sourceMtime: existing.sourceMtime,
562
+ sourceCtime: existing.sourceCtime ?? existing.sourceMtime,
563
+ title: existing.title ?? undefined,
564
+ mirrorHash: existing.mirrorHash ?? undefined,
565
+ converterId: existing.converterId ?? undefined,
566
+ converterVersion: existing.converterVersion ?? undefined,
567
+ languageHint: existing.languageHint ?? undefined,
568
+ contentType: existing.contentType ?? undefined,
569
+ contentTypeSource: existing.contentTypeSource ?? undefined,
570
+ categories: existing.categories ?? undefined,
571
+ author: existing.author ?? undefined,
572
+ frontmatterDate: existing.frontmatterDate ?? undefined,
573
+ dateFields: existing.dateFields ?? undefined,
574
+ recordKey: existing.recordKey ?? undefined,
575
+ recordSourcePath: existing.recordSourcePath ?? undefined,
576
+ recordSourceLocator: existing.recordSourceLocator ?? undefined,
577
+ recordMetadata: existing.recordMetadata ?? undefined,
578
+ recordAnchors: existing.recordAnchors ?? undefined,
579
+ recordAdapterFingerprint: existing.recordAdapterFingerprint ?? undefined,
580
+ lastErrorCode: code,
581
+ lastErrorMessage: message,
582
+ ingestVersion: existing.ingestVersion ?? undefined,
583
+ contentTypeRulesFingerprint:
584
+ existing.contentTypeRulesFingerprint ?? undefined,
585
+ changeJournal: false,
586
+ });
587
+
545
588
  /**
546
589
  * Simple semaphore for bounded concurrency.
547
590
  */
@@ -594,6 +637,30 @@ export class SyncService {
594
637
  this.pipeline = pipeline ?? getDefaultPipeline();
595
638
  }
596
639
 
640
+ private async selectRecordAdapter(
641
+ collection: Collection,
642
+ mime: string,
643
+ ext: string
644
+ ): Promise<RecordAdapter | undefined> {
645
+ const transcriptConfig = collection.recordAdapters?.transcript;
646
+ if (transcriptConfig) {
647
+ const adapter = createTranscriptAdapter(transcriptConfig);
648
+ if (adapter.canHandle(mime, ext)) return adapter;
649
+ }
650
+ const jsonlConfig = collection.recordAdapters?.jsonl;
651
+ if (jsonlConfig) {
652
+ const adapter = createJsonlAdapter(jsonlConfig.fieldMapping);
653
+ if (adapter.canHandle(mime, ext)) return adapter;
654
+ }
655
+ const selectDefault = (
656
+ this.pipeline as ConversionPipeline & {
657
+ selectRecordAdapter?: ConversionPipeline["selectRecordAdapter"];
658
+ }
659
+ ).selectRecordAdapter;
660
+ if (!selectDefault) return undefined;
661
+ return selectDefault.call(this.pipeline, mime, ext);
662
+ }
663
+
597
664
  /**
598
665
  * Process a single file through the ingestion pipeline.
599
666
  * All store operations are checked and errors are propagated.
@@ -661,17 +728,57 @@ export class SyncService {
661
728
  };
662
729
  }
663
730
 
664
- // 2. Read file bytes
731
+ const extensionMime = this.mimeDetector.detect(
732
+ entry.relPath,
733
+ new Uint8Array()
734
+ );
735
+ const recordAdapter = await this.selectRecordAdapter(
736
+ collection,
737
+ extensionMime.mime,
738
+ extensionMime.ext
739
+ );
740
+ const contentTypeRules = options.contentTypeRules ?? [];
741
+ const contentTypeRulesFingerprint =
742
+ options.contentTypeRulesFingerprint ??
743
+ fingerprintContentTypeMetadataRules(contentTypeRules);
744
+ if (recordAdapter) {
745
+ return await processRecordContainer({
746
+ adapter: recordAdapter,
747
+ chunker: this.chunker,
748
+ collection,
749
+ contentTypeRules,
750
+ contentTypeRulesFingerprint,
751
+ entry,
752
+ ext: extensionMime.ext,
753
+ extractMetadata: extractDocumentMetadata,
754
+ ingestVersion: INGEST_VERSION,
755
+ mime: extensionMime.mime,
756
+ options,
757
+ sourceCtime,
758
+ sourceMtime,
759
+ sourceSize,
760
+ store,
761
+ });
762
+ }
763
+
764
+ const sniffBytes = new Uint8Array(
765
+ await Bun.file(entry.absPath).slice(0, 512).arrayBuffer()
766
+ );
767
+ const mime = this.mimeDetector.detect(entry.relPath, sniffBytes);
768
+
769
+ const priorRecordDocuments = mustOk(
770
+ await store.listRecordDocuments(collection.name, entry.relPath),
771
+ "listRecordDocuments",
772
+ { collection: collection.name, relPath: entry.relPath }
773
+ );
774
+
775
+ // 2. Read byte-oriented files only after record-adapter selection.
665
776
  const bytes = await Bun.file(entry.absPath).bytes();
666
777
 
667
778
  // 3. Compute sourceHash
668
779
  const hasher = new Bun.CryptoHasher("sha256");
669
780
  hasher.update(bytes);
670
781
  const sourceHash = hasher.digest("hex");
671
- const contentTypeRules = options.contentTypeRules ?? [];
672
- const contentTypeRulesFingerprint =
673
- options.contentTypeRulesFingerprint ??
674
- fingerprintContentTypeMetadataRules(contentTypeRules);
675
782
 
676
783
  // 4. Check existing doc for skip/repair decision
677
784
  const existingResult = await store.getDocument(
@@ -686,12 +793,20 @@ export class SyncService {
686
793
  );
687
794
 
688
795
  if (decision.kind === "skip") {
796
+ const activeRecordPaths = priorRecordDocuments
797
+ .filter((document) => document.active)
798
+ .map((document) => document.relPath);
799
+ if (activeRecordPaths.length > 0) {
800
+ mustOk(
801
+ await store.markInactive(collection.name, activeRecordPaths),
802
+ "markInactive",
803
+ { collection: collection.name, relPath: entry.relPath }
804
+ );
805
+ return { relPath: entry.relPath, status: "updated" };
806
+ }
689
807
  return { relPath: entry.relPath, status: "unchanged" };
690
808
  }
691
809
 
692
- // 5. Detect MIME (bytes is already Uint8Array from Bun.file().bytes())
693
- const mime = this.mimeDetector.detect(entry.absPath, bytes);
694
-
695
810
  // 6. Convert via pipeline
696
811
  const convertResult = await this.pipeline.convert({
697
812
  sourcePath: entry.absPath,
@@ -938,7 +1053,19 @@ export class SyncService {
938
1053
  linkCount: linkInputs.length,
939
1054
  });
940
1055
 
941
- const status = existing ? "updated" : "added";
1056
+ const activeRecordPaths = priorRecordDocuments
1057
+ .filter((document) => document.active)
1058
+ .map((document) => document.relPath);
1059
+ if (activeRecordPaths.length > 0) {
1060
+ mustOk(
1061
+ await store.markInactive(collection.name, activeRecordPaths),
1062
+ "markInactive",
1063
+ { collection: collection.name, relPath: entry.relPath }
1064
+ );
1065
+ }
1066
+
1067
+ const status =
1068
+ existing || priorRecordDocuments.length > 0 ? "updated" : "added";
942
1069
  return {
943
1070
  relPath: entry.relPath,
944
1071
  status,
@@ -982,22 +1109,10 @@ export class SyncService {
982
1109
  collection.name,
983
1110
  entry.relPath
984
1111
  );
985
- if (existingResult.ok && existingResult.value) {
986
- await store.upsertDocument({
987
- collection: collection.name,
988
- relPath: entry.relPath,
989
- sourceHash: existingResult.value.sourceHash,
990
- sourceMime: existingResult.value.sourceMime,
991
- sourceExt: existingResult.value.sourceExt,
992
- sourceSize: existingResult.value.sourceSize,
993
- sourceMtime: existingResult.value.sourceMtime,
994
- sourceCtime:
995
- existingResult.value.sourceCtime ??
996
- existingResult.value.sourceMtime,
997
- lastErrorCode: code,
998
- lastErrorMessage: message,
999
- changeJournal: false,
1000
- });
1112
+ if (existingResult.ok && existingResult.value?.active) {
1113
+ await store.upsertDocument(
1114
+ preserveDocumentWithError(existingResult.value, code, message)
1115
+ );
1001
1116
  }
1002
1117
  } catch {
1003
1118
  // Best-effort error recording
@@ -1065,6 +1180,20 @@ export class SyncService {
1065
1180
  let markedInactive = 0;
1066
1181
 
1067
1182
  for (const relPath of relPaths) {
1183
+ const recordDocumentsResult = await store.listRecordDocuments(
1184
+ collection.name,
1185
+ relPath
1186
+ );
1187
+ if (!recordDocumentsResult.ok) {
1188
+ results.push({
1189
+ relPath,
1190
+ status: "error",
1191
+ errorCode: recordDocumentsResult.error.code,
1192
+ errorMessage: recordDocumentsResult.error.message,
1193
+ });
1194
+ continue;
1195
+ }
1196
+ const recordDocuments = recordDocumentsResult.value;
1068
1197
  const existingResult = await store.getDocument(collection.name, relPath);
1069
1198
  const existingDoc = existingResult.ok ? existingResult.value : null;
1070
1199
  if (existingDoc) {
@@ -1074,6 +1203,13 @@ export class SyncService {
1074
1203
  projectionSourceIds
1075
1204
  );
1076
1205
  }
1206
+ for (const recordDocument of recordDocuments) {
1207
+ await this.collectProjectionSourceIds(
1208
+ store,
1209
+ recordDocument.id,
1210
+ projectionSourceIds
1211
+ );
1212
+ }
1077
1213
 
1078
1214
  const absPath = join(collection.path, relPath);
1079
1215
  let stats: Awaited<ReturnType<typeof stat>>;
@@ -1094,10 +1230,17 @@ export class SyncService {
1094
1230
  });
1095
1231
  continue;
1096
1232
  }
1097
- if (existingDoc?.active) {
1098
- const inactiveResult = await store.markInactive(collection.name, [
1099
- relPath,
1100
- ]);
1233
+ const activePaths = [
1234
+ ...(existingDoc?.active ? [relPath] : []),
1235
+ ...recordDocuments
1236
+ .filter((document) => document.active)
1237
+ .map((document) => document.relPath),
1238
+ ];
1239
+ if (activePaths.length > 0) {
1240
+ const inactiveResult = await store.markInactive(
1241
+ collection.name,
1242
+ activePaths
1243
+ );
1101
1244
  if (!inactiveResult.ok) {
1102
1245
  results.push({
1103
1246
  relPath,
@@ -1111,7 +1254,7 @@ export class SyncService {
1111
1254
  results.push({
1112
1255
  relPath,
1113
1256
  status: "updated",
1114
- docid: existingDoc.docid,
1257
+ docid: existingDoc?.docid,
1115
1258
  });
1116
1259
  continue;
1117
1260
  }
@@ -1133,8 +1276,39 @@ export class SyncService {
1133
1276
  continue;
1134
1277
  }
1135
1278
 
1279
+ let canonicalSourcePath: string;
1280
+ try {
1281
+ const [collectionRoot, sourcePath] = await Promise.all([
1282
+ realpath(collection.path),
1283
+ realpath(absPath),
1284
+ ]);
1285
+ const sourceRelative = relative(collectionRoot, sourcePath);
1286
+ if (
1287
+ sourceRelative === ".." ||
1288
+ sourceRelative.startsWith(`..${sep}`) ||
1289
+ isAbsolute(sourceRelative)
1290
+ ) {
1291
+ results.push({
1292
+ relPath,
1293
+ status: "error",
1294
+ errorCode: "PATH_OUTSIDE_COLLECTION",
1295
+ errorMessage: "Source path resolves outside the collection root.",
1296
+ });
1297
+ continue;
1298
+ }
1299
+ canonicalSourcePath = sourcePath;
1300
+ } catch {
1301
+ results.push({
1302
+ relPath,
1303
+ status: "error",
1304
+ errorCode: "PATH_UNRESOLVED",
1305
+ errorMessage: "Source path could not be resolved safely.",
1306
+ });
1307
+ continue;
1308
+ }
1309
+
1136
1310
  const entry: WalkEntry = {
1137
- absPath,
1311
+ absPath: canonicalSourcePath,
1138
1312
  relPath,
1139
1313
  size: stats.size,
1140
1314
  mtime: stats.mtime.toISOString(),
@@ -1157,6 +1331,21 @@ export class SyncService {
1157
1331
  projectionSourceIds
1158
1332
  );
1159
1333
  }
1334
+ const currentDocuments = await store.listRecordDocuments(
1335
+ collection.name,
1336
+ relPath
1337
+ );
1338
+ if (currentDocuments.ok) {
1339
+ for (const recordDocument of currentDocuments.value) {
1340
+ if (recordDocument.active) {
1341
+ await this.collectProjectionSourceIds(
1342
+ store,
1343
+ recordDocument.id,
1344
+ projectionSourceIds
1345
+ );
1346
+ }
1347
+ }
1348
+ }
1160
1349
  }
1161
1350
 
1162
1351
  const errors =
@@ -1410,10 +1599,24 @@ export class SyncService {
1410
1599
 
1411
1600
  // 1. Run preflight commands
1412
1601
  if (options.runUpdateCmd !== false && collection.updateCmd) {
1602
+ enforceCollectionEgress({
1603
+ collections: [collection],
1604
+ action: "export",
1605
+ destinationZone: "remote",
1606
+ caller: { authenticated: true, operationAuthorized: true },
1607
+ contentClass: "source",
1608
+ });
1413
1609
  await runUpdateCmd(collection.path, collection.updateCmd);
1414
1610
  }
1415
1611
 
1416
1612
  if (options.gitPull && (await isGitRepo(collection.path))) {
1613
+ enforceCollectionEgress({
1614
+ collections: [collection],
1615
+ action: "export",
1616
+ destinationZone: "remote",
1617
+ caller: { authenticated: true, operationAuthorized: true },
1618
+ contentClass: "metadata",
1619
+ });
1417
1620
  await gitPull(collection.path);
1418
1621
  }
1419
1622
 
@@ -1600,7 +1803,11 @@ export class SyncService {
1600
1803
  const existingDocsResult = await store.listDocuments(collection.name);
1601
1804
  if (existingDocsResult.ok) {
1602
1805
  const missingPaths = existingDocsResult.value
1603
- .filter((d) => d.active && !seenPaths.has(d.relPath))
1806
+ .filter(
1807
+ (document) =>
1808
+ document.active &&
1809
+ !seenPaths.has(document.recordSourcePath ?? document.relPath)
1810
+ )
1604
1811
  .map((d) => d.relPath);
1605
1812
 
1606
1813
  if (missingPaths.length > 0) {
@@ -7,6 +7,11 @@
7
7
 
8
8
  import type { NormalizedContentTypeRule } from "../config";
9
9
  import type { Collection } from "../config/types";
10
+ import type {
11
+ RecordAdapterFailure,
12
+ RecordAttachmentInventoryItem,
13
+ } from "../converters/types";
14
+ import type { EgressLineage } from "../core/egress-provenance";
10
15
 
11
16
  // ─────────────────────────────────────────────────────────────────────────────
12
17
  // Walker Types
@@ -32,8 +37,10 @@ export interface WalkConfig {
32
37
  root: string;
33
38
  /** Glob pattern (default: **\/*) */
34
39
  pattern: string;
35
- /** Extension allowlist (empty = all) */
40
+ /** Extension allowlist (empty = supported defaults) */
36
41
  include: string[];
42
+ /** Adapter-configured extensions added only to the supported defaults. */
43
+ additionalDefaultExtensions?: string[];
37
44
  /** Paths/patterns to exclude */
38
45
  exclude: string[];
39
46
  /** Max file size in bytes (files larger are skipped) */
@@ -147,6 +154,34 @@ export type ContentTypeSource =
147
154
  | "path-ext"
148
155
  | "fallback";
149
156
 
157
+ /** Maximum per-record actions retained in one sync receipt. */
158
+ export const MAX_RECORD_IMPORT_RECEIPT_ITEMS = 1_000;
159
+
160
+ export type RecordImportOutcome =
161
+ | "added"
162
+ | "updated"
163
+ | "reactivated"
164
+ | "unchanged"
165
+ | "deactivated"
166
+ | "preserved";
167
+
168
+ /** Bounded, privacy-safe identity and provenance for one reconciled record. */
169
+ export interface RecordImportItemReceipt {
170
+ outcome: RecordImportOutcome;
171
+ recordKey: string;
172
+ sourceLocator: string;
173
+ sourceHash: string;
174
+ mirrorHash?: string;
175
+ adapterFingerprint: string;
176
+ attachments: RecordAttachmentInventoryItem[];
177
+ }
178
+
179
+ export interface RecordImportWarning {
180
+ code: "PARTIAL_SNAPSHOT";
181
+ message: string;
182
+ retryable: boolean;
183
+ }
184
+
150
185
  /** Per-file sync status */
151
186
  export type FileSyncStatus =
152
187
  | "added"
@@ -165,6 +200,30 @@ export interface FileSyncResult {
165
200
  contentTypeSource?: ContentTypeSource;
166
201
  errorCode?: string;
167
202
  errorMessage?: string;
203
+ recordImport?: {
204
+ adapterId: string;
205
+ adapterVersion: string;
206
+ adapterFingerprint: string;
207
+ egressLineage?: EgressLineage;
208
+ snapshotState: "complete" | "partial";
209
+ authoritative: boolean;
210
+ stoppedByCap: boolean;
211
+ sourceBytesRead: number;
212
+ records: {
213
+ accepted: number;
214
+ added: number;
215
+ updated: number;
216
+ reactivated: number;
217
+ unchanged: number;
218
+ deactivated: number;
219
+ preserved: number;
220
+ failed: number;
221
+ };
222
+ items: RecordImportItemReceipt[];
223
+ itemsTruncated: number;
224
+ warnings: RecordImportWarning[];
225
+ failures: RecordAdapterFailure[];
226
+ };
168
227
  }
169
228
 
170
229
  /** Collection sync summary */
@@ -221,6 +280,13 @@ export interface LanguageDetectorPort {
221
280
  // Helper to create WalkConfig from Collection
222
281
  // ─────────────────────────────────────────────────────────────────────────────
223
282
 
283
+ const TRANSCRIPT_EXTENSION_BY_FORMAT = {
284
+ json: ".json",
285
+ srt: ".srt",
286
+ text: ".txt",
287
+ vtt: ".vtt",
288
+ } as const;
289
+
224
290
  /**
225
291
  * Create WalkConfig from Collection with maxBytes override.
226
292
  */
@@ -228,10 +294,14 @@ export function collectionToWalkConfig(
228
294
  collection: Collection,
229
295
  maxBytes: number
230
296
  ): WalkConfig {
297
+ const transcriptFormat = collection.recordAdapters?.transcript?.format;
231
298
  return {
232
299
  root: collection.path,
233
300
  pattern: collection.pattern,
234
301
  include: collection.include,
302
+ additionalDefaultExtensions: transcriptFormat
303
+ ? [TRANSCRIPT_EXTENSION_BY_FORMAT[transcriptFormat]]
304
+ : [],
235
305
  exclude: collection.exclude,
236
306
  maxBytes,
237
307
  };