@gmickel/gno 1.27.0 → 1.28.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 (83) hide show
  1. package/README.md +2 -2
  2. package/assets/skill/SKILL.md +26 -1
  3. package/browser-extension/artifacts/{gno-browser-clipper-v1.27.0.zip → gno-browser-clipper-v1.28.0.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +2 -2
  7. package/spec/cli.md +41 -4
  8. package/spec/db/schema.sql +12 -0
  9. package/spec/mcp.md +5 -0
  10. package/spec/output-schemas/ask.schema.json +125 -0
  11. package/spec/output-schemas/context-capsule-v1.schema.json +94 -1
  12. package/spec/output-schemas/get.schema.json +94 -0
  13. package/spec/output-schemas/mcp-job-status.schema.json +28 -0
  14. package/spec/output-schemas/multi-get.schema.json +128 -0
  15. package/spec/output-schemas/record-import.schema.json +193 -0
  16. package/spec/output-schemas/search-result.schema.json +94 -0
  17. package/spec/output-schemas/search-results.schema.json +125 -0
  18. package/spec/project-profile.schema.json +66 -0
  19. package/src/app/context-format.ts +1 -1
  20. package/src/cli/commands/get.ts +12 -2
  21. package/src/cli/commands/index-cmd.ts +13 -0
  22. package/src/cli/commands/multi-get.ts +9 -2
  23. package/src/cli/commands/shared.ts +28 -0
  24. package/src/cli/commands/update.ts +5 -0
  25. package/src/cli/program.ts +4 -0
  26. package/src/config/project-profile.ts +2 -0
  27. package/src/config/types.ts +16 -0
  28. package/src/converters/adapters/browser-export/adapter.ts +199 -0
  29. package/src/converters/adapters/browser-export/formats.ts +358 -0
  30. package/src/converters/adapters/email/adapter.ts +429 -0
  31. package/src/converters/adapters/email/html.ts +162 -0
  32. package/src/converters/adapters/email/mime.ts +454 -0
  33. package/src/converters/adapters/email/parameters.ts +77 -0
  34. package/src/converters/adapters/ical/adapter.ts +475 -0
  35. package/src/converters/adapters/ical/recurrence.ts +186 -0
  36. package/src/converters/adapters/jsonl/adapter.ts +238 -0
  37. package/src/converters/adapters/jsonl/config.ts +105 -0
  38. package/src/converters/adapters/shared/html-text.ts +165 -0
  39. package/src/converters/adapters/shared/record-utils.ts +79 -0
  40. package/src/converters/adapters/shared/utf8-lines.ts +141 -0
  41. package/src/converters/adapters/transcript/adapter.ts +295 -0
  42. package/src/converters/adapters/transcript/json.ts +184 -0
  43. package/src/converters/adapters/transcript/model.ts +171 -0
  44. package/src/converters/adapters/transcript/text.ts +58 -0
  45. package/src/converters/adapters/transcript/timed.ts +152 -0
  46. package/src/converters/index.ts +11 -1
  47. package/src/converters/mime.ts +8 -0
  48. package/src/converters/pipeline.ts +15 -1
  49. package/src/converters/registry.ts +37 -1
  50. package/src/converters/types.ts +136 -0
  51. package/src/core/context-capsule-schema.ts +87 -0
  52. package/src/core/context-capsule.ts +20 -0
  53. package/src/core/context-evidence.ts +7 -1
  54. package/src/core/document-capabilities.ts +12 -0
  55. package/src/core/project-profile-apply-state.ts +5 -0
  56. package/src/core/project-profile.ts +4 -0
  57. package/src/core/record-metadata.ts +49 -0
  58. package/src/ingestion/record-adapter-canonical.ts +433 -0
  59. package/src/ingestion/record-adapter.ts +437 -0
  60. package/src/ingestion/record-container.ts +688 -0
  61. package/src/ingestion/record-path.ts +20 -0
  62. package/src/ingestion/record-sync.ts +70 -0
  63. package/src/ingestion/sync.ts +228 -36
  64. package/src/ingestion/types.ts +69 -1
  65. package/src/ingestion/walker.ts +31 -11
  66. package/src/mcp/tools/get.ts +10 -2
  67. package/src/mcp/tools/multi-get.ts +9 -2
  68. package/src/mcp/tools/workspace-write.ts +2 -0
  69. package/src/pipeline/filters.ts +1 -1
  70. package/src/pipeline/graph-retrieval.ts +7 -4
  71. package/src/pipeline/hybrid.ts +7 -4
  72. package/src/pipeline/result-context.ts +12 -4
  73. package/src/pipeline/search.ts +11 -4
  74. package/src/pipeline/types.ts +3 -0
  75. package/src/pipeline/vsearch.ts +26 -8
  76. package/src/sdk/documents.ts +13 -5
  77. package/src/serve/browse-tree.ts +4 -2
  78. package/src/serve/routes/api.ts +63 -65
  79. package/src/store/migrations/022-record-export-lineage.ts +42 -0
  80. package/src/store/migrations/index.ts +2 -0
  81. package/src/store/sqlite/adapter.ts +114 -7
  82. package/src/store/types.ts +40 -0
  83. package/browser-extension/artifacts/gno-browser-clipper-v1.27.0.zip.sha256 +0 -1
@@ -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,
@@ -64,6 +67,7 @@ import {
64
67
  stripFrontmatter,
65
68
  } from "./frontmatter";
66
69
  import { buildLineOffsets } from "./position";
70
+ import { processRecordContainer } from "./record-container";
67
71
  import { getExcludedRanges } from "./strip";
68
72
  import { collectionToWalkConfig, DEFAULT_CHUNK_PARAMS } from "./types";
69
73
  import { defaultWalker } from "./walker";
@@ -542,6 +546,44 @@ function mustOk<T>(
542
546
  return result.value;
543
547
  }
544
548
 
549
+ const preserveDocumentWithError = (
550
+ existing: DocumentRow,
551
+ code: string,
552
+ message: string
553
+ ): DocumentInput => ({
554
+ collection: existing.collection,
555
+ relPath: existing.relPath,
556
+ sourceHash: existing.sourceHash,
557
+ sourceMime: existing.sourceMime,
558
+ sourceExt: existing.sourceExt,
559
+ sourceSize: existing.sourceSize,
560
+ sourceMtime: existing.sourceMtime,
561
+ sourceCtime: existing.sourceCtime ?? existing.sourceMtime,
562
+ title: existing.title ?? undefined,
563
+ mirrorHash: existing.mirrorHash ?? undefined,
564
+ converterId: existing.converterId ?? undefined,
565
+ converterVersion: existing.converterVersion ?? undefined,
566
+ languageHint: existing.languageHint ?? undefined,
567
+ contentType: existing.contentType ?? undefined,
568
+ contentTypeSource: existing.contentTypeSource ?? undefined,
569
+ categories: existing.categories ?? undefined,
570
+ author: existing.author ?? undefined,
571
+ frontmatterDate: existing.frontmatterDate ?? undefined,
572
+ dateFields: existing.dateFields ?? undefined,
573
+ recordKey: existing.recordKey ?? undefined,
574
+ recordSourcePath: existing.recordSourcePath ?? undefined,
575
+ recordSourceLocator: existing.recordSourceLocator ?? undefined,
576
+ recordMetadata: existing.recordMetadata ?? undefined,
577
+ recordAnchors: existing.recordAnchors ?? undefined,
578
+ recordAdapterFingerprint: existing.recordAdapterFingerprint ?? undefined,
579
+ lastErrorCode: code,
580
+ lastErrorMessage: message,
581
+ ingestVersion: existing.ingestVersion ?? undefined,
582
+ contentTypeRulesFingerprint:
583
+ existing.contentTypeRulesFingerprint ?? undefined,
584
+ changeJournal: false,
585
+ });
586
+
545
587
  /**
546
588
  * Simple semaphore for bounded concurrency.
547
589
  */
@@ -594,6 +636,30 @@ export class SyncService {
594
636
  this.pipeline = pipeline ?? getDefaultPipeline();
595
637
  }
596
638
 
639
+ private async selectRecordAdapter(
640
+ collection: Collection,
641
+ mime: string,
642
+ ext: string
643
+ ): Promise<RecordAdapter | undefined> {
644
+ const transcriptConfig = collection.recordAdapters?.transcript;
645
+ if (transcriptConfig) {
646
+ const adapter = createTranscriptAdapter(transcriptConfig);
647
+ if (adapter.canHandle(mime, ext)) return adapter;
648
+ }
649
+ const jsonlConfig = collection.recordAdapters?.jsonl;
650
+ if (jsonlConfig) {
651
+ const adapter = createJsonlAdapter(jsonlConfig.fieldMapping);
652
+ if (adapter.canHandle(mime, ext)) return adapter;
653
+ }
654
+ const selectDefault = (
655
+ this.pipeline as ConversionPipeline & {
656
+ selectRecordAdapter?: ConversionPipeline["selectRecordAdapter"];
657
+ }
658
+ ).selectRecordAdapter;
659
+ if (!selectDefault) return undefined;
660
+ return selectDefault.call(this.pipeline, mime, ext);
661
+ }
662
+
597
663
  /**
598
664
  * Process a single file through the ingestion pipeline.
599
665
  * All store operations are checked and errors are propagated.
@@ -661,17 +727,57 @@ export class SyncService {
661
727
  };
662
728
  }
663
729
 
664
- // 2. Read file bytes
730
+ const extensionMime = this.mimeDetector.detect(
731
+ entry.relPath,
732
+ new Uint8Array()
733
+ );
734
+ const recordAdapter = await this.selectRecordAdapter(
735
+ collection,
736
+ extensionMime.mime,
737
+ extensionMime.ext
738
+ );
739
+ const contentTypeRules = options.contentTypeRules ?? [];
740
+ const contentTypeRulesFingerprint =
741
+ options.contentTypeRulesFingerprint ??
742
+ fingerprintContentTypeMetadataRules(contentTypeRules);
743
+ if (recordAdapter) {
744
+ return await processRecordContainer({
745
+ adapter: recordAdapter,
746
+ chunker: this.chunker,
747
+ collection,
748
+ contentTypeRules,
749
+ contentTypeRulesFingerprint,
750
+ entry,
751
+ ext: extensionMime.ext,
752
+ extractMetadata: extractDocumentMetadata,
753
+ ingestVersion: INGEST_VERSION,
754
+ mime: extensionMime.mime,
755
+ options,
756
+ sourceCtime,
757
+ sourceMtime,
758
+ sourceSize,
759
+ store,
760
+ });
761
+ }
762
+
763
+ const sniffBytes = new Uint8Array(
764
+ await Bun.file(entry.absPath).slice(0, 512).arrayBuffer()
765
+ );
766
+ const mime = this.mimeDetector.detect(entry.relPath, sniffBytes);
767
+
768
+ const priorRecordDocuments = mustOk(
769
+ await store.listRecordDocuments(collection.name, entry.relPath),
770
+ "listRecordDocuments",
771
+ { collection: collection.name, relPath: entry.relPath }
772
+ );
773
+
774
+ // 2. Read byte-oriented files only after record-adapter selection.
665
775
  const bytes = await Bun.file(entry.absPath).bytes();
666
776
 
667
777
  // 3. Compute sourceHash
668
778
  const hasher = new Bun.CryptoHasher("sha256");
669
779
  hasher.update(bytes);
670
780
  const sourceHash = hasher.digest("hex");
671
- const contentTypeRules = options.contentTypeRules ?? [];
672
- const contentTypeRulesFingerprint =
673
- options.contentTypeRulesFingerprint ??
674
- fingerprintContentTypeMetadataRules(contentTypeRules);
675
781
 
676
782
  // 4. Check existing doc for skip/repair decision
677
783
  const existingResult = await store.getDocument(
@@ -686,12 +792,20 @@ export class SyncService {
686
792
  );
687
793
 
688
794
  if (decision.kind === "skip") {
795
+ const activeRecordPaths = priorRecordDocuments
796
+ .filter((document) => document.active)
797
+ .map((document) => document.relPath);
798
+ if (activeRecordPaths.length > 0) {
799
+ mustOk(
800
+ await store.markInactive(collection.name, activeRecordPaths),
801
+ "markInactive",
802
+ { collection: collection.name, relPath: entry.relPath }
803
+ );
804
+ return { relPath: entry.relPath, status: "updated" };
805
+ }
689
806
  return { relPath: entry.relPath, status: "unchanged" };
690
807
  }
691
808
 
692
- // 5. Detect MIME (bytes is already Uint8Array from Bun.file().bytes())
693
- const mime = this.mimeDetector.detect(entry.absPath, bytes);
694
-
695
809
  // 6. Convert via pipeline
696
810
  const convertResult = await this.pipeline.convert({
697
811
  sourcePath: entry.absPath,
@@ -938,7 +1052,19 @@ export class SyncService {
938
1052
  linkCount: linkInputs.length,
939
1053
  });
940
1054
 
941
- const status = existing ? "updated" : "added";
1055
+ const activeRecordPaths = priorRecordDocuments
1056
+ .filter((document) => document.active)
1057
+ .map((document) => document.relPath);
1058
+ if (activeRecordPaths.length > 0) {
1059
+ mustOk(
1060
+ await store.markInactive(collection.name, activeRecordPaths),
1061
+ "markInactive",
1062
+ { collection: collection.name, relPath: entry.relPath }
1063
+ );
1064
+ }
1065
+
1066
+ const status =
1067
+ existing || priorRecordDocuments.length > 0 ? "updated" : "added";
942
1068
  return {
943
1069
  relPath: entry.relPath,
944
1070
  status,
@@ -982,22 +1108,10 @@ export class SyncService {
982
1108
  collection.name,
983
1109
  entry.relPath
984
1110
  );
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
- });
1111
+ if (existingResult.ok && existingResult.value?.active) {
1112
+ await store.upsertDocument(
1113
+ preserveDocumentWithError(existingResult.value, code, message)
1114
+ );
1001
1115
  }
1002
1116
  } catch {
1003
1117
  // Best-effort error recording
@@ -1065,6 +1179,20 @@ export class SyncService {
1065
1179
  let markedInactive = 0;
1066
1180
 
1067
1181
  for (const relPath of relPaths) {
1182
+ const recordDocumentsResult = await store.listRecordDocuments(
1183
+ collection.name,
1184
+ relPath
1185
+ );
1186
+ if (!recordDocumentsResult.ok) {
1187
+ results.push({
1188
+ relPath,
1189
+ status: "error",
1190
+ errorCode: recordDocumentsResult.error.code,
1191
+ errorMessage: recordDocumentsResult.error.message,
1192
+ });
1193
+ continue;
1194
+ }
1195
+ const recordDocuments = recordDocumentsResult.value;
1068
1196
  const existingResult = await store.getDocument(collection.name, relPath);
1069
1197
  const existingDoc = existingResult.ok ? existingResult.value : null;
1070
1198
  if (existingDoc) {
@@ -1074,6 +1202,13 @@ export class SyncService {
1074
1202
  projectionSourceIds
1075
1203
  );
1076
1204
  }
1205
+ for (const recordDocument of recordDocuments) {
1206
+ await this.collectProjectionSourceIds(
1207
+ store,
1208
+ recordDocument.id,
1209
+ projectionSourceIds
1210
+ );
1211
+ }
1077
1212
 
1078
1213
  const absPath = join(collection.path, relPath);
1079
1214
  let stats: Awaited<ReturnType<typeof stat>>;
@@ -1094,10 +1229,17 @@ export class SyncService {
1094
1229
  });
1095
1230
  continue;
1096
1231
  }
1097
- if (existingDoc?.active) {
1098
- const inactiveResult = await store.markInactive(collection.name, [
1099
- relPath,
1100
- ]);
1232
+ const activePaths = [
1233
+ ...(existingDoc?.active ? [relPath] : []),
1234
+ ...recordDocuments
1235
+ .filter((document) => document.active)
1236
+ .map((document) => document.relPath),
1237
+ ];
1238
+ if (activePaths.length > 0) {
1239
+ const inactiveResult = await store.markInactive(
1240
+ collection.name,
1241
+ activePaths
1242
+ );
1101
1243
  if (!inactiveResult.ok) {
1102
1244
  results.push({
1103
1245
  relPath,
@@ -1111,7 +1253,7 @@ export class SyncService {
1111
1253
  results.push({
1112
1254
  relPath,
1113
1255
  status: "updated",
1114
- docid: existingDoc.docid,
1256
+ docid: existingDoc?.docid,
1115
1257
  });
1116
1258
  continue;
1117
1259
  }
@@ -1133,8 +1275,39 @@ export class SyncService {
1133
1275
  continue;
1134
1276
  }
1135
1277
 
1278
+ let canonicalSourcePath: string;
1279
+ try {
1280
+ const [collectionRoot, sourcePath] = await Promise.all([
1281
+ realpath(collection.path),
1282
+ realpath(absPath),
1283
+ ]);
1284
+ const sourceRelative = relative(collectionRoot, sourcePath);
1285
+ if (
1286
+ sourceRelative === ".." ||
1287
+ sourceRelative.startsWith(`..${sep}`) ||
1288
+ isAbsolute(sourceRelative)
1289
+ ) {
1290
+ results.push({
1291
+ relPath,
1292
+ status: "error",
1293
+ errorCode: "PATH_OUTSIDE_COLLECTION",
1294
+ errorMessage: "Source path resolves outside the collection root.",
1295
+ });
1296
+ continue;
1297
+ }
1298
+ canonicalSourcePath = sourcePath;
1299
+ } catch {
1300
+ results.push({
1301
+ relPath,
1302
+ status: "error",
1303
+ errorCode: "PATH_UNRESOLVED",
1304
+ errorMessage: "Source path could not be resolved safely.",
1305
+ });
1306
+ continue;
1307
+ }
1308
+
1136
1309
  const entry: WalkEntry = {
1137
- absPath,
1310
+ absPath: canonicalSourcePath,
1138
1311
  relPath,
1139
1312
  size: stats.size,
1140
1313
  mtime: stats.mtime.toISOString(),
@@ -1157,6 +1330,21 @@ export class SyncService {
1157
1330
  projectionSourceIds
1158
1331
  );
1159
1332
  }
1333
+ const currentDocuments = await store.listRecordDocuments(
1334
+ collection.name,
1335
+ relPath
1336
+ );
1337
+ if (currentDocuments.ok) {
1338
+ for (const recordDocument of currentDocuments.value) {
1339
+ if (recordDocument.active) {
1340
+ await this.collectProjectionSourceIds(
1341
+ store,
1342
+ recordDocument.id,
1343
+ projectionSourceIds
1344
+ );
1345
+ }
1346
+ }
1347
+ }
1160
1348
  }
1161
1349
 
1162
1350
  const errors =
@@ -1600,7 +1788,11 @@ export class SyncService {
1600
1788
  const existingDocsResult = await store.listDocuments(collection.name);
1601
1789
  if (existingDocsResult.ok) {
1602
1790
  const missingPaths = existingDocsResult.value
1603
- .filter((d) => d.active && !seenPaths.has(d.relPath))
1791
+ .filter(
1792
+ (document) =>
1793
+ document.active &&
1794
+ !seenPaths.has(document.recordSourcePath ?? document.relPath)
1795
+ )
1604
1796
  .map((d) => d.relPath);
1605
1797
 
1606
1798
  if (missingPaths.length > 0) {
@@ -7,6 +7,10 @@
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";
10
14
 
11
15
  // ─────────────────────────────────────────────────────────────────────────────
12
16
  // Walker Types
@@ -32,8 +36,10 @@ export interface WalkConfig {
32
36
  root: string;
33
37
  /** Glob pattern (default: **\/*) */
34
38
  pattern: string;
35
- /** Extension allowlist (empty = all) */
39
+ /** Extension allowlist (empty = supported defaults) */
36
40
  include: string[];
41
+ /** Adapter-configured extensions added only to the supported defaults. */
42
+ additionalDefaultExtensions?: string[];
37
43
  /** Paths/patterns to exclude */
38
44
  exclude: string[];
39
45
  /** Max file size in bytes (files larger are skipped) */
@@ -147,6 +153,34 @@ export type ContentTypeSource =
147
153
  | "path-ext"
148
154
  | "fallback";
149
155
 
156
+ /** Maximum per-record actions retained in one sync receipt. */
157
+ export const MAX_RECORD_IMPORT_RECEIPT_ITEMS = 1_000;
158
+
159
+ export type RecordImportOutcome =
160
+ | "added"
161
+ | "updated"
162
+ | "reactivated"
163
+ | "unchanged"
164
+ | "deactivated"
165
+ | "preserved";
166
+
167
+ /** Bounded, privacy-safe identity and provenance for one reconciled record. */
168
+ export interface RecordImportItemReceipt {
169
+ outcome: RecordImportOutcome;
170
+ recordKey: string;
171
+ sourceLocator: string;
172
+ sourceHash: string;
173
+ mirrorHash?: string;
174
+ adapterFingerprint: string;
175
+ attachments: RecordAttachmentInventoryItem[];
176
+ }
177
+
178
+ export interface RecordImportWarning {
179
+ code: "PARTIAL_SNAPSHOT";
180
+ message: string;
181
+ retryable: boolean;
182
+ }
183
+
150
184
  /** Per-file sync status */
151
185
  export type FileSyncStatus =
152
186
  | "added"
@@ -165,6 +199,29 @@ export interface FileSyncResult {
165
199
  contentTypeSource?: ContentTypeSource;
166
200
  errorCode?: string;
167
201
  errorMessage?: string;
202
+ recordImport?: {
203
+ adapterId: string;
204
+ adapterVersion: string;
205
+ adapterFingerprint: string;
206
+ snapshotState: "complete" | "partial";
207
+ authoritative: boolean;
208
+ stoppedByCap: boolean;
209
+ sourceBytesRead: number;
210
+ records: {
211
+ accepted: number;
212
+ added: number;
213
+ updated: number;
214
+ reactivated: number;
215
+ unchanged: number;
216
+ deactivated: number;
217
+ preserved: number;
218
+ failed: number;
219
+ };
220
+ items: RecordImportItemReceipt[];
221
+ itemsTruncated: number;
222
+ warnings: RecordImportWarning[];
223
+ failures: RecordAdapterFailure[];
224
+ };
168
225
  }
169
226
 
170
227
  /** Collection sync summary */
@@ -221,6 +278,13 @@ export interface LanguageDetectorPort {
221
278
  // Helper to create WalkConfig from Collection
222
279
  // ─────────────────────────────────────────────────────────────────────────────
223
280
 
281
+ const TRANSCRIPT_EXTENSION_BY_FORMAT = {
282
+ json: ".json",
283
+ srt: ".srt",
284
+ text: ".txt",
285
+ vtt: ".vtt",
286
+ } as const;
287
+
224
288
  /**
225
289
  * Create WalkConfig from Collection with maxBytes override.
226
290
  */
@@ -228,10 +292,14 @@ export function collectionToWalkConfig(
228
292
  collection: Collection,
229
293
  maxBytes: number
230
294
  ): WalkConfig {
295
+ const transcriptFormat = collection.recordAdapters?.transcript?.format;
231
296
  return {
232
297
  root: collection.path,
233
298
  pattern: collection.pattern,
234
299
  include: collection.include,
300
+ additionalDefaultExtensions: transcriptFormat
301
+ ? [TRANSCRIPT_EXTENSION_BY_FORMAT[transcriptFormat]]
302
+ : [],
235
303
  exclude: collection.exclude,
236
304
  maxBytes,
237
305
  };
@@ -21,6 +21,7 @@ import type { SkippedEntry, WalkConfig, WalkEntry, WalkerPort } from "./types";
21
21
 
22
22
  import { SUPPORTED_EXTENSIONS } from "../converters/mime";
23
23
  import { matchesCollectionExclusion } from "../core/path-rules";
24
+ import { isRecordVirtualPath } from "./record-path";
24
25
 
25
26
  /**
26
27
  * Regex to detect dangerous patterns with parent directory traversal.
@@ -124,7 +125,7 @@ function scanPatterns(pattern: string): string[] {
124
125
  async function safeRelPath(
125
126
  rootReal: string,
126
127
  absPath: string
127
- ): Promise<string | null> {
128
+ ): Promise<{ absPath: string; relPath: string } | null> {
128
129
  try {
129
130
  const fileReal = await realpath(absPath);
130
131
  const rel = relative(rootReal, fileReal);
@@ -135,7 +136,7 @@ async function safeRelPath(
135
136
  return null;
136
137
  }
137
138
 
138
- return toPosixPath(rel);
139
+ return { absPath: fileReal, relPath: toPosixPath(rel) };
139
140
  } catch {
140
141
  // Can't resolve path (e.g., broken symlink)
141
142
  return null;
@@ -145,18 +146,25 @@ async function safeRelPath(
145
146
  /**
146
147
  * Check if a file extension matches the include list.
147
148
  * Include list contains extensions like ".md" or "md" (normalized).
148
- * When include is empty, falls back to SUPPORTED_EXTENSIONS to avoid
149
- * walking files that can't be converted.
149
+ * When include is empty, falls back to SUPPORTED_EXTENSIONS plus extensions
150
+ * made convertible by explicit adapter configuration.
150
151
  */
151
- function matchesInclude(relPath: string, include: string[]): boolean {
152
+ function matchesInclude(
153
+ relPath: string,
154
+ include: string[],
155
+ additionalDefaultExtensions: string[]
156
+ ): boolean {
152
157
  const ext = extname(relPath).toLowerCase();
153
158
  if (!ext) {
154
159
  return false;
155
160
  }
156
161
 
157
- // Fallback to supported extensions when no explicit include list
162
+ if (include.length === 0 && SUPPORTED_EXTENSIONS.includes(ext)) {
163
+ return true;
164
+ }
165
+
158
166
  const effectiveInclude =
159
- include.length === 0 ? SUPPORTED_EXTENSIONS : include;
167
+ include.length === 0 ? additionalDefaultExtensions : include;
160
168
 
161
169
  return effectiveInclude.some((inc) => {
162
170
  const normalizedInc = inc.startsWith(".")
@@ -211,13 +219,19 @@ export class FileWalker implements WalkerPort {
211
219
  }
212
220
  }
213
221
 
214
- for (const absPath of [...matches].sort()) {
222
+ for (const matchedPath of [...matches].sort()) {
215
223
  // Security: Compute safe relative path (validates file is within root)
216
- const relPath = await safeRelPath(rootReal, absPath);
217
- if (relPath === null) {
224
+ const safePath = await safeRelPath(rootReal, matchedPath);
225
+ if (safePath === null) {
218
226
  // File outside root or unresolvable - silently skip (security)
219
227
  continue;
220
228
  }
229
+ const { absPath, relPath } = safePath;
230
+
231
+ if (isRecordVirtualPath(relPath)) {
232
+ skipped.push({ absPath, relPath, reason: "EXCLUDED" });
233
+ continue;
234
+ }
221
235
 
222
236
  // Check exclude patterns
223
237
  if (matchesCollectionExclusion(relPath, config.exclude)) {
@@ -230,7 +244,13 @@ export class FileWalker implements WalkerPort {
230
244
  }
231
245
 
232
246
  // Check include extensions
233
- if (!matchesInclude(relPath, config.include)) {
247
+ if (
248
+ !matchesInclude(
249
+ relPath,
250
+ config.include,
251
+ config.additionalDefaultExtensions ?? []
252
+ )
253
+ ) {
234
254
  skipped.push({
235
255
  absPath,
236
256
  relPath,