@gmickel/gno 1.8.0 → 1.10.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 (62) hide show
  1. package/README.md +6 -2
  2. package/assets/skill/SKILL.md +41 -16
  3. package/assets/skill/cli-reference.md +39 -0
  4. package/assets/skill/examples.md +41 -0
  5. package/assets/skill/mcp-reference.md +13 -1
  6. package/package.json +1 -1
  7. package/src/cli/commands/capture.ts +9 -6
  8. package/src/cli/commands/graph.ts +89 -2
  9. package/src/cli/commands/index-cmd.ts +17 -6
  10. package/src/cli/commands/links.ts +237 -54
  11. package/src/cli/commands/query.ts +212 -0
  12. package/src/cli/commands/ref-parser.ts +8 -103
  13. package/src/cli/commands/shared.ts +17 -1
  14. package/src/cli/commands/update.ts +17 -6
  15. package/src/cli/options.ts +4 -0
  16. package/src/cli/program.ts +176 -9
  17. package/src/config/content-types.ts +154 -0
  18. package/src/config/defaults.ts +1 -0
  19. package/src/config/index.ts +14 -0
  20. package/src/config/loader.ts +11 -2
  21. package/src/config/types.ts +37 -1
  22. package/src/core/config-mutation.ts +14 -2
  23. package/src/core/graph-query.ts +137 -0
  24. package/src/core/graph-resolver.ts +117 -0
  25. package/src/core/note-presets.ts +61 -5
  26. package/src/core/ref-parser.ts +145 -0
  27. package/src/ingestion/frontmatter.ts +170 -2
  28. package/src/ingestion/index.ts +2 -0
  29. package/src/ingestion/sync-options.ts +29 -0
  30. package/src/ingestion/sync.ts +385 -17
  31. package/src/ingestion/types.ts +14 -0
  32. package/src/mcp/tools/add-collection.ts +8 -5
  33. package/src/mcp/tools/capture.ts +5 -2
  34. package/src/mcp/tools/get.ts +1 -1
  35. package/src/mcp/tools/index-cmd.ts +13 -7
  36. package/src/mcp/tools/index.ts +97 -10
  37. package/src/mcp/tools/links.ts +83 -1
  38. package/src/mcp/tools/multi-get.ts +1 -1
  39. package/src/mcp/tools/query.ts +207 -0
  40. package/src/mcp/tools/sync.ts +12 -6
  41. package/src/mcp/tools/workspace-write.ts +16 -10
  42. package/src/pipeline/diagnose.ts +302 -0
  43. package/src/pipeline/filters.ts +119 -0
  44. package/src/pipeline/hybrid.ts +92 -17
  45. package/src/pipeline/search.ts +2 -0
  46. package/src/pipeline/types.ts +34 -0
  47. package/src/pipeline/vsearch.ts +4 -0
  48. package/src/publish/export-service.ts +1 -1
  49. package/src/sdk/client.ts +51 -24
  50. package/src/sdk/documents.ts +2 -2
  51. package/src/serve/background-runtime.ts +18 -4
  52. package/src/serve/config-sync.ts +9 -2
  53. package/src/serve/routes/api.ts +244 -24
  54. package/src/serve/routes/graph.ts +173 -1
  55. package/src/serve/server.ts +24 -1
  56. package/src/serve/watch-service.ts +12 -2
  57. package/src/store/migrations/009-content-type-rule-fingerprint.ts +36 -0
  58. package/src/store/migrations/010-typed-edges.ts +67 -0
  59. package/src/store/migrations/011-doc-edge-traversal-indexes.ts +30 -0
  60. package/src/store/migrations/index.ts +16 -1
  61. package/src/store/sqlite/adapter.ts +853 -114
  62. package/src/store/types.ts +147 -0
@@ -10,9 +10,11 @@ import { stat } from "node:fs/promises";
10
10
  // node:path for join (no Bun path utils)
11
11
  import { join } from "node:path";
12
12
 
13
+ import type { NormalizedContentTypeRule } from "../config";
13
14
  import type { Collection } from "../config/types";
14
15
  import type {
15
16
  ChunkInput,
17
+ DocEdgeInput,
16
18
  DocLinkInput,
17
19
  DocumentRow,
18
20
  IngestErrorInput,
@@ -22,6 +24,7 @@ import type {
22
24
  import type {
23
25
  ChunkerPort,
24
26
  CollectionSyncResult,
27
+ ContentTypeSource,
25
28
  FileSyncResult,
26
29
  ProcessDecision,
27
30
  SyncOptions,
@@ -30,6 +33,7 @@ import type {
30
33
  WalkerPort,
31
34
  } from "./types";
32
35
 
36
+ import { fingerprintContentTypeRules } from "../config";
33
37
  import { getDefaultMimeDetector, type MimeDetector } from "../converters/mime";
34
38
  import {
35
39
  type ConversionPipeline,
@@ -40,6 +44,7 @@ import {
40
44
  normalizeMarkdownPath,
41
45
  normalizeWikiName,
42
46
  parseLinks,
47
+ parseTargetParts,
43
48
  } from "../core/links";
44
49
  import { normalizeTag, validateTag } from "../core/tags";
45
50
  import { defaultChunker } from "./chunker";
@@ -67,7 +72,130 @@ const MAX_CONCURRENCY = 16;
67
72
  * Increment when ingestion adds new derived data (tags, metadata, etc.)
68
73
  * Documents with ingestVersion < INGEST_VERSION will be re-processed.
69
74
  */
70
- export const INGEST_VERSION = 5;
75
+ export const INGEST_VERSION = 6;
76
+ const EMPTY_CONTENT_TYPE_RULES_FINGERPRINT = fingerprintContentTypeRules([]);
77
+ const RELATION_EDGE_TYPE_PATTERN = /^[a-z][a-z0-9_]*$/;
78
+
79
+ type RelationMap = Record<string, string[]>;
80
+
81
+ function isRelationMap(value: unknown): value is RelationMap {
82
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
83
+ return false;
84
+ }
85
+
86
+ return Object.values(value).every(
87
+ (targets) =>
88
+ Array.isArray(targets) &&
89
+ targets.every((target) => typeof target === "string")
90
+ );
91
+ }
92
+
93
+ function normalizeRelationTarget(raw: string): string {
94
+ const trimmed = raw.trim();
95
+ if (trimmed.startsWith("[[") && trimmed.endsWith("]]")) {
96
+ return trimmed.slice(2, -2).split("|")[0]?.trim() ?? "";
97
+ }
98
+ return trimmed;
99
+ }
100
+
101
+ function normalizeRelationEdgeType(raw: string): string {
102
+ return raw
103
+ .trim()
104
+ .toLowerCase()
105
+ .replace(/[-\s]+/g, "_");
106
+ }
107
+
108
+ function findDocByWikiRef(
109
+ docs: DocumentRow[],
110
+ targetRef: string,
111
+ collection?: string
112
+ ): DocumentRow | undefined {
113
+ const normalized = normalizeWikiName(targetRef);
114
+ const candidates = collection
115
+ ? docs.filter((doc) => doc.collection === collection)
116
+ : docs;
117
+
118
+ return candidates.find((doc) => {
119
+ const title = doc.title ?? doc.relPath.split("/").pop() ?? doc.relPath;
120
+ const relStem = doc.relPath.replace(/\.[^/.]+$/, "");
121
+ return (
122
+ normalizeWikiName(title) === normalized ||
123
+ normalizeWikiName(doc.relPath) === normalized ||
124
+ normalizeWikiName(relStem) === normalized
125
+ );
126
+ });
127
+ }
128
+
129
+ function resolveRelationTarget(
130
+ docs: DocumentRow[],
131
+ sourceDoc: DocumentRow,
132
+ rawTarget: string
133
+ ): DocumentRow | undefined {
134
+ const target = normalizeRelationTarget(rawTarget);
135
+ if (!target) {
136
+ return undefined;
137
+ }
138
+
139
+ if (target.startsWith("#")) {
140
+ return docs.find((doc) => doc.docid === target);
141
+ }
142
+
143
+ if (target.startsWith("gno://")) {
144
+ return docs.find((doc) => doc.uri === target);
145
+ }
146
+
147
+ const parts = parseTargetParts(target);
148
+ const targetCollection = parts.collection;
149
+ const targetRef = parts.ref;
150
+ if (!targetRef) {
151
+ return undefined;
152
+ }
153
+
154
+ if (targetCollection) {
155
+ const exact = docs.find(
156
+ (doc) => doc.collection === targetCollection && doc.relPath === targetRef
157
+ );
158
+ return exact ?? findDocByWikiRef(docs, targetRef, targetCollection);
159
+ }
160
+
161
+ const sameCollectionPath = normalizeMarkdownPath(
162
+ targetRef,
163
+ sourceDoc.relPath
164
+ );
165
+ if (sameCollectionPath) {
166
+ const exact = docs.find(
167
+ (doc) =>
168
+ doc.collection === sourceDoc.collection &&
169
+ doc.relPath === sameCollectionPath
170
+ );
171
+ if (exact) {
172
+ return exact;
173
+ }
174
+ }
175
+
176
+ const explicitCollPath = docs.find(
177
+ (doc) => `${doc.collection}/${doc.relPath}` === targetRef
178
+ );
179
+ if (explicitCollPath) {
180
+ return explicitCollPath;
181
+ }
182
+
183
+ return (
184
+ findDocByWikiRef(docs, targetRef, sourceDoc.collection) ??
185
+ findDocByWikiRef(docs, targetRef)
186
+ );
187
+ }
188
+
189
+ function getPrimaryGraphHint(
190
+ contentType: string | null | undefined,
191
+ rules: NormalizedContentTypeRule[]
192
+ ): string | undefined {
193
+ if (!contentType) {
194
+ return undefined;
195
+ }
196
+ const rule = rules.find((candidate) => candidate.id === contentType);
197
+ return rule?.graphHints?.[0];
198
+ }
71
199
 
72
200
  /**
73
201
  * Decide whether to process a file or skip it.
@@ -76,7 +204,8 @@ export const INGEST_VERSION = 5;
76
204
  */
77
205
  function decideAction(
78
206
  existing: DocumentRow | null,
79
- sourceHash: string
207
+ sourceHash: string,
208
+ contentTypeRulesFingerprint: string
80
209
  ): ProcessDecision {
81
210
  // No existing doc - must process
82
211
  if (!existing) {
@@ -108,6 +237,16 @@ function decideAction(
108
237
  return { kind: "repair", reason: "ingest version outdated" };
109
238
  }
110
239
 
240
+ const hasLegacyEmptyRulesFingerprint =
241
+ existing.contentTypeRulesFingerprint === null &&
242
+ contentTypeRulesFingerprint === EMPTY_CONTENT_TYPE_RULES_FINGERPRINT;
243
+ if (
244
+ existing.contentTypeRulesFingerprint !== contentTypeRulesFingerprint &&
245
+ !hasLegacyEmptyRulesFingerprint
246
+ ) {
247
+ return { kind: "repair", reason: "content type rules changed" };
248
+ }
249
+
111
250
  // All good - skip
112
251
  return { kind: "skip", reason: "unchanged" };
113
252
  }
@@ -143,6 +282,7 @@ function extractTags(markdown: string): string[] {
143
282
 
144
283
  interface DocumentMetadata {
145
284
  contentType?: string;
285
+ contentTypeSource: ContentTypeSource;
146
286
  categories?: string[];
147
287
  author?: string;
148
288
  frontmatterDate?: string;
@@ -210,47 +350,95 @@ function normalizeDate(value: unknown): string | undefined {
210
350
  return parsed.toISOString();
211
351
  }
212
352
 
213
- function inferContentType(relPath: string, ext: string): string {
353
+ function inferPathContentType(
354
+ relPath: string,
355
+ ext: string
356
+ ): {
357
+ contentType: string;
358
+ source: ContentTypeSource;
359
+ } {
214
360
  const lowerPath = relPath.toLowerCase();
215
361
  if (CODE_EXTENSIONS.has(ext.toLowerCase())) {
216
- return "code";
362
+ return { contentType: "code", source: "path-ext" };
217
363
  }
218
364
  if (/(meeting|standup|retro|minutes)/.test(lowerPath)) {
219
- return "meeting";
365
+ return { contentType: "meeting", source: "path-ext" };
220
366
  }
221
367
  if (/(spec|rfc|adr|design)/.test(lowerPath)) {
222
- return "spec";
368
+ return { contentType: "spec", source: "path-ext" };
223
369
  }
224
370
  if (/(notes|journal|log)/.test(lowerPath)) {
225
- return "notes";
371
+ return { contentType: "notes", source: "path-ext" };
372
+ }
373
+ return { contentType: "prose", source: "fallback" };
374
+ }
375
+
376
+ function normalizeFrontmatterScalar(value: string): string {
377
+ const trimmed = value.trim();
378
+ if (trimmed.length < 2) {
379
+ return trimmed;
226
380
  }
227
- return "prose";
381
+ const first = trimmed[0];
382
+ const last = trimmed.at(-1);
383
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
384
+ return trimmed.slice(1, -1).trim();
385
+ }
386
+ return trimmed;
228
387
  }
229
388
 
230
389
  function parseCategories(input: unknown): string[] {
231
390
  if (Array.isArray(input)) {
232
391
  return input
233
392
  .filter((v): v is string => typeof v === "string")
234
- .map((v) => v.trim().toLowerCase())
393
+ .map((v) => normalizeFrontmatterScalar(v).toLowerCase())
235
394
  .filter((v) => v.length > 0);
236
395
  }
237
396
  if (typeof input === "string") {
238
397
  return input
239
398
  .split(",")
240
- .map((v) => v.trim().toLowerCase())
399
+ .map((v) => normalizeFrontmatterScalar(v).toLowerCase())
241
400
  .filter((v) => v.length > 0);
242
401
  }
243
402
  return [];
244
403
  }
245
404
 
246
- function extractDocumentMetadata(
405
+ function matchPrefixContentType(
406
+ relPath: string,
407
+ rules: NormalizedContentTypeRule[]
408
+ ): string | undefined {
409
+ for (const rule of rules) {
410
+ if (rule.prefixes.some((prefix) => relPath.startsWith(prefix))) {
411
+ return rule.id;
412
+ }
413
+ }
414
+ return undefined;
415
+ }
416
+
417
+ export function extractDocumentMetadata(
247
418
  markdown: string,
248
419
  relPath: string,
249
- ext: string
420
+ ext: string,
421
+ contentTypeRules: NormalizedContentTypeRule[] = []
250
422
  ): DocumentMetadata {
251
423
  const parsed = parseFrontmatter(markdown);
252
424
  const metadata = parsed.metadata;
253
- const contentType = inferContentType(relPath, ext);
425
+ const typedRules = new Map(contentTypeRules.map((rule) => [rule.id, rule]));
426
+ const rawFrontmatterType =
427
+ typeof metadata.type === "string"
428
+ ? normalizeFrontmatterScalar(metadata.type)
429
+ : "";
430
+ const frontmatterType = typedRules.get(rawFrontmatterType)?.id;
431
+ const prefixType =
432
+ frontmatterType === undefined
433
+ ? matchPrefixContentType(relPath, contentTypeRules)
434
+ : undefined;
435
+ const inferred = inferPathContentType(relPath, ext);
436
+ const contentType = frontmatterType ?? prefixType ?? inferred.contentType;
437
+ const contentTypeSource: ContentTypeSource = frontmatterType
438
+ ? "frontmatter-type"
439
+ : prefixType
440
+ ? "prefix"
441
+ : inferred.source;
254
442
  const categories = new Set<string>([contentType]);
255
443
 
256
444
  const fmCategories = parseCategories(
@@ -299,6 +487,7 @@ function extractDocumentMetadata(
299
487
 
300
488
  return {
301
489
  contentType,
490
+ contentTypeSource,
302
491
  categories: [...categories],
303
492
  author,
304
493
  frontmatterDate,
@@ -488,6 +677,10 @@ export class SyncService {
488
677
  const hasher = new Bun.CryptoHasher("sha256");
489
678
  hasher.update(bytes);
490
679
  const sourceHash = hasher.digest("hex");
680
+ const contentTypeRules = options.contentTypeRules ?? [];
681
+ const contentTypeRulesFingerprint =
682
+ options.contentTypeRulesFingerprint ??
683
+ fingerprintContentTypeRules(contentTypeRules);
491
684
 
492
685
  // 4. Check existing doc for skip/repair decision
493
686
  const existingResult = await store.getDocument(
@@ -495,7 +688,11 @@ export class SyncService {
495
688
  entry.relPath
496
689
  );
497
690
  const existing = existingResult.ok ? existingResult.value : null;
498
- const decision = decideAction(existing, sourceHash);
691
+ const decision = decideAction(
692
+ existing,
693
+ sourceHash,
694
+ contentTypeRulesFingerprint
695
+ );
499
696
 
500
697
  if (decision.kind === "skip") {
501
698
  return { relPath: entry.relPath, status: "unchanged" };
@@ -565,7 +762,8 @@ export class SyncService {
565
762
  const extractedMetadata = extractDocumentMetadata(
566
763
  artifact.markdown,
567
764
  entry.relPath,
568
- mime.ext
765
+ mime.ext,
766
+ contentTypeRules
569
767
  );
570
768
 
571
769
  // 7. Upsert document - EXPLICITLY clear error fields on success
@@ -584,10 +782,12 @@ export class SyncService {
584
782
  converterVersion: artifact.meta.converterVersion,
585
783
  languageHint: artifact.languageHint ?? collection.languageHint,
586
784
  contentType: extractedMetadata.contentType,
785
+ contentTypeSource: extractedMetadata.contentTypeSource,
587
786
  categories: extractedMetadata.categories,
588
787
  author: extractedMetadata.author,
589
788
  frontmatterDate: extractedMetadata.frontmatterDate,
590
789
  dateFields: extractedMetadata.dateFields,
790
+ contentTypeRulesFingerprint,
591
791
  // Clear error fields on success (requires store to handle undefined → null)
592
792
  lastErrorCode: undefined,
593
793
  lastErrorMessage: undefined,
@@ -711,6 +911,8 @@ export class SyncService {
711
911
  status,
712
912
  docid,
713
913
  mirrorHash: artifact.mirrorHash,
914
+ contentType: extractedMetadata.contentType,
915
+ contentTypeSource: extractedMetadata.contentTypeSource,
714
916
  };
715
917
  } catch (error) {
716
918
  const message = error instanceof Error ? error.message : "Unknown error";
@@ -815,9 +1017,160 @@ export class SyncService {
815
1017
  results.push(result);
816
1018
  }
817
1019
 
1020
+ await this.projectTypedEdges(collection, store, options);
1021
+
818
1022
  return results;
819
1023
  }
820
1024
 
1025
+ private async projectTypedEdges(
1026
+ collection: Collection,
1027
+ store: StorePort,
1028
+ options: SyncOptions
1029
+ ): Promise<Array<{ relPath: string; code: string; message: string }>> {
1030
+ const errors: Array<{ relPath: string; code: string; message: string }> =
1031
+ [];
1032
+
1033
+ const backfillResult = await store.backfillDocEdges();
1034
+ if (!backfillResult.ok) {
1035
+ return [
1036
+ {
1037
+ relPath: "(typed edge backfill)",
1038
+ code: backfillResult.error.code,
1039
+ message: backfillResult.error.message,
1040
+ },
1041
+ ];
1042
+ }
1043
+
1044
+ const docsResult = await store.listDocuments();
1045
+ if (!docsResult.ok) {
1046
+ return [
1047
+ {
1048
+ relPath: "(typed edge projection)",
1049
+ code: docsResult.error.code,
1050
+ message: docsResult.error.message,
1051
+ },
1052
+ ];
1053
+ }
1054
+
1055
+ const activeDocs = docsResult.value.filter((doc) => doc.active);
1056
+
1057
+ for (const doc of activeDocs) {
1058
+ if (!doc.mirrorHash) {
1059
+ continue;
1060
+ }
1061
+
1062
+ const contentResult = await store.getContent(doc.mirrorHash);
1063
+ if (!contentResult.ok || contentResult.value === null) {
1064
+ continue;
1065
+ }
1066
+
1067
+ const relationsValue = parseFrontmatter(contentResult.value).metadata
1068
+ .relations;
1069
+ const relationEdges: DocEdgeInput[] = [];
1070
+
1071
+ if (isRelationMap(relationsValue)) {
1072
+ for (const [rawEdgeType, targets] of Object.entries(relationsValue)) {
1073
+ const edgeType = normalizeRelationEdgeType(rawEdgeType);
1074
+ if (!RELATION_EDGE_TYPE_PATTERN.test(edgeType)) {
1075
+ continue;
1076
+ }
1077
+ for (const target of targets) {
1078
+ const targetDoc = resolveRelationTarget(activeDocs, doc, target);
1079
+ if (targetDoc) {
1080
+ relationEdges.push({
1081
+ targetDocId: targetDoc.id,
1082
+ edgeType,
1083
+ confidence: "manual",
1084
+ });
1085
+ }
1086
+ }
1087
+ }
1088
+ }
1089
+ const relationTargetIds = new Set(
1090
+ relationEdges.map((edge) => edge.targetDocId)
1091
+ );
1092
+
1093
+ const relationsResult = await store.setDocEdges(
1094
+ doc.id,
1095
+ relationEdges,
1096
+ "frontmatter-relation"
1097
+ );
1098
+ if (!relationsResult.ok) {
1099
+ errors.push({
1100
+ relPath: doc.relPath,
1101
+ code: relationsResult.error.code,
1102
+ message: relationsResult.error.message,
1103
+ });
1104
+ }
1105
+
1106
+ const primaryHint = getPrimaryGraphHint(
1107
+ doc.contentType,
1108
+ options.contentTypeRules ?? []
1109
+ );
1110
+ if (!primaryHint || !RELATION_EDGE_TYPE_PATTERN.test(primaryHint)) {
1111
+ continue;
1112
+ }
1113
+
1114
+ const linksResult = await store.getLinksForDoc(doc.id);
1115
+ if (!linksResult.ok) {
1116
+ errors.push({
1117
+ relPath: doc.relPath,
1118
+ code: linksResult.error.code,
1119
+ message: linksResult.error.message,
1120
+ });
1121
+ continue;
1122
+ }
1123
+
1124
+ const wikiEdges: DocEdgeInput[] = [];
1125
+ const markdownEdges: DocEdgeInput[] = [];
1126
+ for (const link of linksResult.value) {
1127
+ const targetRef =
1128
+ link.linkType === "markdown"
1129
+ ? `${doc.collection}/${link.targetRefNorm}`
1130
+ : link.targetCollection
1131
+ ? `${link.targetCollection}:${link.targetRef}`
1132
+ : link.targetRefNorm;
1133
+ const targetDoc = resolveRelationTarget(activeDocs, doc, targetRef);
1134
+ if (!targetDoc || relationTargetIds.has(targetDoc.id)) {
1135
+ continue;
1136
+ }
1137
+ const edge = {
1138
+ targetDocId: targetDoc.id,
1139
+ edgeType: primaryHint,
1140
+ confidence: "configured" as const,
1141
+ };
1142
+ if (link.linkType === "wiki") {
1143
+ wikiEdges.push(edge);
1144
+ } else {
1145
+ markdownEdges.push(edge);
1146
+ }
1147
+ }
1148
+
1149
+ const wikiResult = await store.setDocEdges(doc.id, wikiEdges, "wikilink");
1150
+ if (!wikiResult.ok) {
1151
+ errors.push({
1152
+ relPath: doc.relPath,
1153
+ code: wikiResult.error.code,
1154
+ message: wikiResult.error.message,
1155
+ });
1156
+ }
1157
+ const markdownResult = await store.setDocEdges(
1158
+ doc.id,
1159
+ markdownEdges,
1160
+ "markdown-link"
1161
+ );
1162
+ if (!markdownResult.ok) {
1163
+ errors.push({
1164
+ relPath: doc.relPath,
1165
+ code: markdownResult.error.code,
1166
+ message: markdownResult.error.message,
1167
+ });
1168
+ }
1169
+ }
1170
+
1171
+ return errors;
1172
+ }
1173
+
821
1174
  /**
822
1175
  * Sync a single collection.
823
1176
  */
@@ -828,6 +1181,13 @@ export class SyncService {
828
1181
  options: SyncOptions = {}
829
1182
  ): Promise<CollectionSyncResult> {
830
1183
  const startTime = Date.now();
1184
+ const syncOptions: SyncOptions = {
1185
+ ...options,
1186
+ contentTypeRules: options.contentTypeRules ?? [],
1187
+ contentTypeRulesFingerprint:
1188
+ options.contentTypeRulesFingerprint ??
1189
+ fingerprintContentTypeRules(options.contentTypeRules ?? []),
1190
+ };
831
1191
  const errors: Array<{ relPath: string; code: string; message: string }> =
832
1192
  [];
833
1193
 
@@ -892,6 +1252,7 @@ export class SyncService {
892
1252
  let unchanged = 0;
893
1253
  let errored = 0;
894
1254
  let dynamicSkipped = 0;
1255
+ const fileResults: FileSyncResult[] = [];
895
1256
 
896
1257
  if (concurrency === 1) {
897
1258
  // Sequential processing with batched transactions (Windows perf)
@@ -905,8 +1266,9 @@ export class SyncService {
905
1266
  collection,
906
1267
  entry,
907
1268
  store,
908
- options
1269
+ syncOptions
909
1270
  );
1271
+ fileResults.push(result);
910
1272
  switch (result.status) {
911
1273
  case "added":
912
1274
  added += 1;
@@ -970,8 +1332,9 @@ export class SyncService {
970
1332
  collection,
971
1333
  entry,
972
1334
  store,
973
- options
1335
+ syncOptions
974
1336
  );
1337
+ fileResults.push(result);
975
1338
  results.push(result);
976
1339
  } finally {
977
1340
  semaphore.release();
@@ -1034,6 +1397,10 @@ export class SyncService {
1034
1397
  }
1035
1398
  }
1036
1399
 
1400
+ errors.push(
1401
+ ...(await this.projectTypedEdges(collection, store, syncOptions))
1402
+ );
1403
+
1037
1404
  return {
1038
1405
  collection: collection.name,
1039
1406
  filesProcessed: entries.length,
@@ -1044,6 +1411,7 @@ export class SyncService {
1044
1411
  filesSkipped: skipped.length + dynamicSkipped,
1045
1412
  filesMarkedInactive: markedInactive,
1046
1413
  durationMs: Date.now() - startTime,
1414
+ files: fileResults,
1047
1415
  errors,
1048
1416
  };
1049
1417
  }
@@ -5,6 +5,7 @@
5
5
  * @module src/ingestion/types
6
6
  */
7
7
 
8
+ import type { NormalizedContentTypeRule } from "../config";
8
9
  import type { Collection } from "../config/types";
9
10
 
10
11
  // ─────────────────────────────────────────────────────────────────────────────
@@ -132,8 +133,18 @@ export interface SyncOptions {
132
133
  * SQLite operations are serialized regardless of this setting.
133
134
  */
134
135
  concurrency?: number;
136
+ /** Normalized content type rules from config.contentTypes. */
137
+ contentTypeRules?: NormalizedContentTypeRule[];
138
+ /** Stable hash of the normalized content type rules, used for re-derivation. */
139
+ contentTypeRulesFingerprint?: string;
135
140
  }
136
141
 
142
+ export type ContentTypeSource =
143
+ | "frontmatter-type"
144
+ | "prefix"
145
+ | "path-ext"
146
+ | "fallback";
147
+
137
148
  /** Per-file sync status */
138
149
  export type FileSyncStatus =
139
150
  | "added"
@@ -148,6 +159,8 @@ export interface FileSyncResult {
148
159
  status: FileSyncStatus;
149
160
  docid?: string;
150
161
  mirrorHash?: string;
162
+ contentType?: string;
163
+ contentTypeSource?: ContentTypeSource;
151
164
  errorCode?: string;
152
165
  errorMessage?: string;
153
166
  }
@@ -163,6 +176,7 @@ export interface CollectionSyncResult {
163
176
  filesSkipped: number;
164
177
  filesMarkedInactive: number;
165
178
  durationMs: number;
179
+ files?: FileSyncResult[];
166
180
  errors: Array<{
167
181
  relPath: string;
168
182
  code: string;
@@ -18,7 +18,7 @@ import {
18
18
  normalizeCollectionName,
19
19
  validateCollectionRoot,
20
20
  } from "../../core/validation";
21
- import { defaultSyncService } from "../../ingestion";
21
+ import { defaultSyncService, withContentTypeRules } from "../../ingestion";
22
22
  import { runTool, type ToolResult } from "./index";
23
23
 
24
24
  interface AddCollectionInput {
@@ -147,10 +147,13 @@ export function handleAddCollection(
147
147
  const result = await defaultSyncService.syncCollection(
148
148
  collection,
149
149
  ctx.store,
150
- {
151
- gitPull: args.gitPull ?? false,
152
- runUpdateCmd: false,
153
- }
150
+ withContentTypeRules(
151
+ {
152
+ gitPull: args.gitPull ?? false,
153
+ runUpdateCmd: false,
154
+ },
155
+ ctx.config
156
+ )
154
157
  );
155
158
 
156
159
  return {
@@ -24,7 +24,7 @@ import { writeCapturePlanFile } from "../../core/capture-write";
24
24
  import { MCP_ERRORS } from "../../core/errors";
25
25
  import { withWriteLock } from "../../core/file-lock";
26
26
  import { normalizeCollectionName } from "../../core/validation";
27
- import { defaultSyncService } from "../../ingestion";
27
+ import { defaultSyncService, withContentTypeRules } from "../../ingestion";
28
28
  import { runTool, type ToolResult } from "./index";
29
29
 
30
30
  interface CaptureInput extends Omit<
@@ -176,7 +176,10 @@ export function handleCapture(
176
176
  collection,
177
177
  ctx.store,
178
178
  [plan.relPath],
179
- { runUpdateCmd: false, gitPull: false }
179
+ withContentTypeRules(
180
+ { runUpdateCmd: false, gitPull: false },
181
+ ctx.config
182
+ )
180
183
  );
181
184
  const syncResult = results[0];
182
185
  if (!syncResult) {
@@ -10,11 +10,11 @@ import type { DocumentRow, StorePort } from "../../store/types";
10
10
  import type { ToolContext } from "../server";
11
11
 
12
12
  import { decorateUriForIndex, parseUri } from "../../app/constants";
13
- import { parseRef } from "../../cli/commands/ref-parser";
14
13
  import {
15
14
  getDocumentCapabilities,
16
15
  type DocumentCapabilities,
17
16
  } from "../../core/document-capabilities";
17
+ import { parseRef } from "../../core/ref-parser";
18
18
  import { runTool, type ToolResult } from "./index";
19
19
 
20
20
  interface GetInput {