@gmickel/gno 1.7.1 → 1.9.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 (51) hide show
  1. package/README.md +10 -1
  2. package/assets/skill/SKILL.md +41 -0
  3. package/assets/skill/cli-reference.md +34 -0
  4. package/assets/skill/examples.md +37 -0
  5. package/assets/skill/mcp-reference.md +21 -0
  6. package/package.json +1 -1
  7. package/src/cli/commands/capture.ts +282 -0
  8. package/src/cli/commands/index-cmd.ts +17 -6
  9. package/src/cli/commands/shared.ts +17 -1
  10. package/src/cli/commands/update.ts +17 -6
  11. package/src/cli/options.ts +2 -0
  12. package/src/cli/program.ts +64 -0
  13. package/src/config/content-types.ts +140 -0
  14. package/src/config/defaults.ts +1 -0
  15. package/src/config/index.ts +14 -0
  16. package/src/config/loader.ts +11 -2
  17. package/src/config/types.ts +37 -1
  18. package/src/core/capture-write.ts +38 -0
  19. package/src/core/capture.ts +746 -0
  20. package/src/core/config-mutation.ts +14 -2
  21. package/src/core/file-ops.ts +21 -2
  22. package/src/core/note-presets.ts +61 -5
  23. package/src/ingestion/frontmatter.ts +77 -2
  24. package/src/ingestion/index.ts +2 -0
  25. package/src/ingestion/sync-options.ts +29 -0
  26. package/src/ingestion/sync.ts +104 -16
  27. package/src/ingestion/types.ts +14 -0
  28. package/src/mcp/tools/add-collection.ts +8 -5
  29. package/src/mcp/tools/capture.ts +137 -191
  30. package/src/mcp/tools/index-cmd.ts +13 -7
  31. package/src/mcp/tools/index.ts +43 -9
  32. package/src/mcp/tools/sync.ts +12 -6
  33. package/src/mcp/tools/workspace-write.ts +16 -10
  34. package/src/pipeline/hybrid.ts +2 -0
  35. package/src/pipeline/search.ts +2 -0
  36. package/src/pipeline/types.ts +2 -0
  37. package/src/pipeline/vsearch.ts +4 -0
  38. package/src/sdk/client.ts +156 -20
  39. package/src/sdk/index.ts +2 -0
  40. package/src/sdk/types.ts +6 -0
  41. package/src/serve/background-runtime.ts +18 -4
  42. package/src/serve/config-sync.ts +9 -2
  43. package/src/serve/public/components/CaptureModal.tsx +248 -10
  44. package/src/serve/public/globals.built.css +1 -1
  45. package/src/serve/routes/api.ts +238 -26
  46. package/src/serve/server.ts +12 -0
  47. package/src/serve/watch-service.ts +12 -2
  48. package/src/store/migrations/009-content-type-rule-fingerprint.ts +36 -0
  49. package/src/store/migrations/index.ts +12 -1
  50. package/src/store/sqlite/adapter.ts +29 -14
  51. package/src/store/types.ts +6 -0
@@ -5,10 +5,10 @@
5
5
  * @module src/serve/routes/api
6
6
  */
7
7
 
8
- // node:fs/promises structure walk has no Bun equivalent
9
- import { readdir } from "node:fs/promises";
8
+ // node:fs/promises structure ops have no Bun equivalent
9
+ import { mkdir, readdir } from "node:fs/promises";
10
10
  // node:path has no Bun equivalent
11
- import { posix as pathPosix } from "node:path";
11
+ import { dirname, join as pathJoin, posix as pathPosix } from "node:path";
12
12
 
13
13
  import type {
14
14
  Collection,
@@ -35,6 +35,14 @@ import {
35
35
  removeCollection,
36
36
  updateCollection,
37
37
  } from "../../collection";
38
+ import {
39
+ buildCaptureReceipt,
40
+ listCaptureDiskRelPaths,
41
+ planCapture,
42
+ type CapturePlan,
43
+ type PublicCaptureInput,
44
+ } from "../../core/capture";
45
+ import { writeCapturePlanFile } from "../../core/capture-write";
38
46
  import {
39
47
  buildEditableCopyContent,
40
48
  deriveEditableCopyRelPath,
@@ -74,7 +82,11 @@ import {
74
82
  validateTag,
75
83
  } from "../../core/tags";
76
84
  import { validateRelPath } from "../../core/validation";
77
- import { defaultSyncService, type SyncResult } from "../../ingestion";
85
+ import {
86
+ defaultSyncService,
87
+ type SyncResult,
88
+ withContentTypeRules,
89
+ } from "../../ingestion";
78
90
  import { updateFrontmatterTags } from "../../ingestion/frontmatter";
79
91
  import {
80
92
  getCollectionEffectiveModels,
@@ -292,6 +304,8 @@ export interface CreateDocRequestBody {
292
304
  tags?: string[];
293
305
  }
294
306
 
307
+ export interface CreateCaptureRequestBody extends PublicCaptureInput {}
308
+
295
309
  export interface RenameDocRequestBody {
296
310
  name: string;
297
311
  uri?: string;
@@ -909,10 +923,17 @@ export async function handleCreateCollection(
909
923
  return errorResponse("RUNTIME", "Collection not found after add", 500);
910
924
  }
911
925
  const jobResult = startJob("add", async (): Promise<SyncResult> => {
912
- const result = await defaultSyncService.syncCollection(collection, store, {
913
- gitPull: body.gitPull,
914
- runUpdateCmd: true,
915
- });
926
+ const result = await defaultSyncService.syncCollection(
927
+ collection,
928
+ store,
929
+ withContentTypeRules(
930
+ {
931
+ gitPull: body.gitPull,
932
+ runUpdateCmd: true,
933
+ },
934
+ syncResult.config
935
+ )
936
+ );
916
937
  if (result.filesAdded > 0 || result.filesUpdated > 0) {
917
938
  if (ctxHolder.scheduler) {
918
939
  await ctxHolder.scheduler.triggerNow();
@@ -1204,10 +1225,17 @@ export async function handleSync(
1204
1225
 
1205
1226
  // Start background sync job
1206
1227
  const jobResult = startJob("sync", async (): Promise<SyncResult> => {
1207
- const result = await defaultSyncService.syncAll(collections, store, {
1208
- gitPull: body.gitPull,
1209
- runUpdateCmd: true,
1210
- });
1228
+ const result = await defaultSyncService.syncAll(
1229
+ collections,
1230
+ store,
1231
+ withContentTypeRules(
1232
+ {
1233
+ gitPull: body.gitPull,
1234
+ runUpdateCmd: true,
1235
+ },
1236
+ ctxHolder.config
1237
+ )
1238
+ );
1211
1239
  if (result.totalFilesAdded > 0 || result.totalFilesUpdated > 0) {
1212
1240
  if (ctxHolder.scheduler) {
1213
1241
  await ctxHolder.scheduler.triggerNow();
@@ -1833,9 +1861,11 @@ export async function handleRenameDoc(
1833
1861
  const nextUri = `gno://${collection.name}/${nextRelPath}`;
1834
1862
  let warning: string | undefined;
1835
1863
  try {
1836
- await syncCollection(collection, store, {
1837
- runUpdateCmd: false,
1838
- });
1864
+ await syncCollection(
1865
+ collection,
1866
+ store,
1867
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
1868
+ );
1839
1869
  } catch {
1840
1870
  warning =
1841
1871
  "File renamed on disk, but index refresh failed. Run Update All to reconcile the workspace.";
@@ -2053,9 +2083,11 @@ export async function handleTrashDoc(
2053
2083
  }
2054
2084
  let warning: string | undefined;
2055
2085
  try {
2056
- await syncCollection(collection, store, {
2057
- runUpdateCmd: false,
2058
- });
2086
+ await syncCollection(
2087
+ collection,
2088
+ store,
2089
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
2090
+ );
2059
2091
  } catch {
2060
2092
  warning =
2061
2093
  "File moved to Trash, but index refresh failed. Run Update All to reconcile the workspace.";
@@ -2174,9 +2206,11 @@ export async function handleMoveDoc(
2174
2206
  await renameFilePath(fullPath, nextFullPath);
2175
2207
  let warning: string | undefined;
2176
2208
  try {
2177
- await defaultSyncService.syncCollection(collection, store, {
2178
- runUpdateCmd: false,
2179
- });
2209
+ await defaultSyncService.syncCollection(
2210
+ collection,
2211
+ store,
2212
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
2213
+ );
2180
2214
  } catch {
2181
2215
  warning =
2182
2216
  "File moved on disk, but index refresh failed. Run Update All to reconcile the workspace.";
@@ -2295,9 +2329,11 @@ export async function handleDuplicateDoc(
2295
2329
  await copyFilePath(fullPath, nextFullPath);
2296
2330
  let warning: string | undefined;
2297
2331
  try {
2298
- await defaultSyncService.syncCollection(collection, store, {
2299
- runUpdateCmd: false,
2300
- });
2332
+ await defaultSyncService.syncCollection(
2333
+ collection,
2334
+ store,
2335
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
2336
+ );
2301
2337
  } catch {
2302
2338
  warning =
2303
2339
  "File duplicated on disk, but index refresh failed. Run Update All to reconcile the workspace.";
@@ -2626,7 +2662,7 @@ export async function handleUpdateDoc(
2626
2662
  const result = await defaultSyncService.syncCollection(
2627
2663
  collection,
2628
2664
  store,
2629
- { runUpdateCmd: false }
2665
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
2630
2666
  );
2631
2667
  // Notify scheduler after sync completes
2632
2668
  ctxHolder.scheduler?.notifySyncComplete([doc.docid]);
@@ -2795,6 +2831,157 @@ export async function handleCreateEditableCopy(
2795
2831
  return handleCreateDoc(ctxHolder, store, createReq);
2796
2832
  }
2797
2833
 
2834
+ /**
2835
+ * POST /api/capture
2836
+ * Capture a note with structured provenance.
2837
+ */
2838
+ export async function handleCreateCapture(
2839
+ ctxHolder: ContextHolder,
2840
+ store: SqliteAdapter,
2841
+ req: Request
2842
+ ): Promise<Response> {
2843
+ let body: CreateCaptureRequestBody;
2844
+ try {
2845
+ body = (await req.json()) as CreateCaptureRequestBody;
2846
+ } catch {
2847
+ return errorResponse("VALIDATION", "Invalid JSON body");
2848
+ }
2849
+
2850
+ if (!body.collection || typeof body.collection !== "string") {
2851
+ return errorResponse("VALIDATION", "Missing or invalid collection");
2852
+ }
2853
+ if (body.content !== undefined && typeof body.content !== "string") {
2854
+ return errorResponse("VALIDATION", "content must be a string");
2855
+ }
2856
+ if (body.title !== undefined && typeof body.title !== "string") {
2857
+ return errorResponse("VALIDATION", "title must be a string");
2858
+ }
2859
+ if (body.relPath !== undefined && typeof body.relPath !== "string") {
2860
+ return errorResponse("VALIDATION", "relPath must be a string");
2861
+ }
2862
+ if (body.folderPath !== undefined && typeof body.folderPath !== "string") {
2863
+ return errorResponse("VALIDATION", "folderPath must be a string");
2864
+ }
2865
+ if ("overwrite" in body) {
2866
+ return errorResponse(
2867
+ "VALIDATION",
2868
+ "overwrite is not supported by /api/capture; use collisionPolicy instead"
2869
+ );
2870
+ }
2871
+
2872
+ const collectionName = body.collection.toLowerCase();
2873
+ const collection = ctxHolder.config.collections.find(
2874
+ (candidate) => candidate.name.toLowerCase() === collectionName
2875
+ );
2876
+ if (!collection) {
2877
+ return errorResponse(
2878
+ "NOT_FOUND",
2879
+ `Collection not found: ${body.collection}`,
2880
+ 404
2881
+ );
2882
+ }
2883
+
2884
+ let plan: CapturePlan;
2885
+ try {
2886
+ plan = planCapture({
2887
+ input: {
2888
+ ...body,
2889
+ collection: collection.name,
2890
+ },
2891
+ existingRelPaths: await listCollectionRelPaths(store, collection.name),
2892
+ diskRelPaths: await listCaptureDiskRelPaths(collection.path),
2893
+ });
2894
+ } catch (error) {
2895
+ return errorResponse(
2896
+ "VALIDATION",
2897
+ error instanceof Error ? error.message : String(error),
2898
+ 409
2899
+ );
2900
+ }
2901
+
2902
+ const fullPath = pathJoin(collection.path, plan.relPath);
2903
+ if (plan.openedExisting) {
2904
+ const existingDoc = await store.getDocument(collection.name, plan.relPath);
2905
+ if (!existingDoc.ok) {
2906
+ return errorResponse("RUNTIME", existingDoc.error.message, 500);
2907
+ }
2908
+ return jsonResponse(
2909
+ buildCaptureReceipt({
2910
+ plan,
2911
+ absPath: fullPath,
2912
+ docid: existingDoc.value?.docid,
2913
+ sync: existingDoc.value
2914
+ ? { status: "completed" }
2915
+ : {
2916
+ status: "skipped",
2917
+ reason: "Existing file is not indexed yet.",
2918
+ },
2919
+ })
2920
+ );
2921
+ }
2922
+
2923
+ try {
2924
+ await mkdir(dirname(fullPath), { recursive: true });
2925
+ ctxHolder.watchService?.suppress(fullPath);
2926
+ await writeCapturePlanFile(plan, fullPath);
2927
+
2928
+ const gnoUri = `gno://${collection.name}/${plan.relPath}`;
2929
+ const jobResult = startJob("sync", async (): Promise<SyncResult> => {
2930
+ const result = await defaultSyncService.syncCollection(
2931
+ collection,
2932
+ store,
2933
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
2934
+ );
2935
+ ctxHolder.scheduler?.notifySyncComplete([plan.relPath]);
2936
+ ctxHolder.eventBus?.emit({
2937
+ type: "document-changed",
2938
+ uri: gnoUri,
2939
+ collection: collection.name,
2940
+ relPath: plan.relPath,
2941
+ origin: "create",
2942
+ changedAt: new Date().toISOString(),
2943
+ });
2944
+ return {
2945
+ collections: [result],
2946
+ totalDurationMs: result.durationMs,
2947
+ totalFilesProcessed: result.filesProcessed,
2948
+ totalFilesAdded: result.filesAdded,
2949
+ totalFilesUpdated: result.filesUpdated,
2950
+ totalFilesErrored: result.filesErrored,
2951
+ totalFilesSkipped: result.filesSkipped,
2952
+ };
2953
+ });
2954
+
2955
+ return jsonResponse(
2956
+ buildCaptureReceipt({
2957
+ plan,
2958
+ absPath: fullPath,
2959
+ sync: jobResult.ok
2960
+ ? {
2961
+ status: "pending",
2962
+ jobId: jobResult.jobId,
2963
+ reason: "Sync job started; poll /api/jobs/:id for status.",
2964
+ }
2965
+ : {
2966
+ status: "skipped",
2967
+ jobId: jobResult.activeJobId,
2968
+ reason: "Sync skipped because another job is running.",
2969
+ error: jobResult.error,
2970
+ },
2971
+ }),
2972
+ 202
2973
+ );
2974
+ } catch (error) {
2975
+ return errorResponse(
2976
+ "RUNTIME",
2977
+ `Failed to capture document: ${
2978
+ error instanceof Error ? error.message : String(error)
2979
+ }`,
2980
+ 500
2981
+ );
2982
+ }
2983
+ }
2984
+
2798
2985
  /**
2799
2986
  * POST /api/docs
2800
2987
  * Create a new document in a collection.
@@ -2979,7 +3166,7 @@ export async function handleCreateDoc(
2979
3166
  const result = await defaultSyncService.syncCollection(
2980
3167
  collection,
2981
3168
  store,
2982
- { runUpdateCmd: false }
3169
+ withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config)
2983
3170
  );
2984
3171
  // Notify scheduler after sync completes (use gnoUri as docid placeholder)
2985
3172
  // The sync will create a proper docid, but we don't have it here yet
@@ -3989,6 +4176,31 @@ export async function routeApi(
3989
4176
  return handlePublishExport(config, store, req);
3990
4177
  }
3991
4178
 
4179
+ if (path === "/api/capture" && req.method === "POST") {
4180
+ const ctxHolder: ContextHolder = {
4181
+ current: {
4182
+ config,
4183
+ store,
4184
+ vectorIndex: null,
4185
+ embedPort: null,
4186
+ expandPort: null,
4187
+ answerPort: null,
4188
+ rerankPort: null,
4189
+ capabilities: {
4190
+ bm25: true,
4191
+ vector: false,
4192
+ hybrid: false,
4193
+ answer: false,
4194
+ },
4195
+ },
4196
+ config,
4197
+ scheduler: null,
4198
+ eventBus: null,
4199
+ watchService: null,
4200
+ };
4201
+ return handleCreateCapture(ctxHolder, store, req);
4202
+ }
4203
+
3992
4204
  if (path === "/api/docs") {
3993
4205
  return handleDocs(store, url);
3994
4206
  }
@@ -23,6 +23,7 @@ import {
23
23
  handleCreateFolder,
24
24
  handleCreateCollection,
25
25
  handleCreateEditableCopy,
26
+ handleCreateCapture,
26
27
  handleCreateDoc,
27
28
  handleDeactivateDoc,
28
29
  handleDeleteCollection,
@@ -256,6 +257,17 @@ export async function startServer(
256
257
  );
257
258
  },
258
259
  },
260
+ "/api/capture": {
261
+ POST: async (req: Request) => {
262
+ if (!isRequestAllowed(req, port)) {
263
+ return withSecurityHeaders(forbiddenResponse(), isDev);
264
+ }
265
+ return withSecurityHeaders(
266
+ await handleCreateCapture(ctxHolder, store, req),
267
+ isDev
268
+ );
269
+ },
270
+ },
259
271
  "/api/docs": {
260
272
  GET: async (req: Request) => {
261
273
  const url = new URL(req.url);
@@ -2,7 +2,7 @@ import { watch, type FSWatcher } from "node:fs";
2
2
  import { join, normalize, sep } from "node:path";
3
3
 
4
4
  import type { Collection } from "../config/types";
5
- import type { CollectionSyncResult } from "../ingestion";
5
+ import type { CollectionSyncResult, SyncOptions } from "../ingestion";
6
6
  import type { SqliteAdapter } from "../store/sqlite/adapter";
7
7
  import type { DocumentEvent, DocumentEventBus } from "./doc-events";
8
8
  import type { EmbedScheduler } from "./embed-scheduler";
@@ -39,6 +39,7 @@ interface CollectionWatchServiceOptions {
39
39
  scheduler: EmbedScheduler | null;
40
40
  eventBus?: DocumentEventBus | null;
41
41
  callbacks?: CollectionWatchCallbacks;
42
+ syncOptions?: SyncOptions;
42
43
  watchFactory?: typeof watch;
43
44
  }
44
45
 
@@ -48,6 +49,7 @@ export class CollectionWatchService {
48
49
  readonly #scheduler: EmbedScheduler | null;
49
50
  readonly #eventBus: DocumentEventBus | null;
50
51
  readonly #callbacks: CollectionWatchCallbacks | null;
52
+ #syncOptions: SyncOptions;
51
53
  readonly #watchers = new Map<string, FSWatcher>();
52
54
  readonly #pendingByCollection = new Map<string, Set<string>>();
53
55
  readonly #timers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -64,6 +66,7 @@ export class CollectionWatchService {
64
66
  this.#scheduler = options.scheduler;
65
67
  this.#eventBus = options.eventBus ?? null;
66
68
  this.#callbacks = options.callbacks ?? null;
69
+ this.#syncOptions = options.syncOptions ?? {};
67
70
  this.#watchFactory = options.watchFactory ?? watch;
68
71
  }
69
72
 
@@ -71,8 +74,14 @@ export class CollectionWatchService {
71
74
  this.updateCollections(this.#collections);
72
75
  }
73
76
 
74
- updateCollections(collections: Collection[]): void {
77
+ updateCollections(
78
+ collections: Collection[],
79
+ syncOptions?: SyncOptions
80
+ ): void {
75
81
  this.#collections = collections;
82
+ if (syncOptions) {
83
+ this.#syncOptions = syncOptions;
84
+ }
76
85
  const nextNames = new Set(collections.map((collection) => collection.name));
77
86
 
78
87
  for (const [collectionName, watcher] of this.#watchers) {
@@ -204,6 +213,7 @@ export class CollectionWatchService {
204
213
  collection,
205
214
  this.#store,
206
215
  {
216
+ ...this.#syncOptions,
207
217
  runUpdateCmd: false,
208
218
  }
209
219
  );
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Migration: content type rule fingerprint.
3
+ *
4
+ * @module src/store/migrations/009-content-type-rule-fingerprint
5
+ */
6
+
7
+ import type { Migration } from "./runner";
8
+
9
+ export const migration: Migration = {
10
+ version: 9,
11
+ name: "content_type_rule_fingerprint",
12
+
13
+ up(db): void {
14
+ const columns = new Set(
15
+ db
16
+ .query<{ name: string }, []>("PRAGMA table_info(documents)")
17
+ .all()
18
+ .map((row) => row.name)
19
+ );
20
+ if (!columns.has("content_type_rules_fingerprint")) {
21
+ db.exec(
22
+ "ALTER TABLE documents ADD COLUMN content_type_rules_fingerprint TEXT"
23
+ );
24
+ }
25
+ db.exec(
26
+ "CREATE INDEX IF NOT EXISTS idx_documents_content_type_rules_fingerprint ON documents(content_type_rules_fingerprint)"
27
+ );
28
+ },
29
+
30
+ down(db): void {
31
+ db.exec(
32
+ "DROP INDEX IF EXISTS idx_documents_content_type_rules_fingerprint"
33
+ );
34
+ // SQLite cannot drop columns without a table rebuild; keep column on rollback.
35
+ },
36
+ };
@@ -22,6 +22,17 @@ import { migration as m005 } from "./005-graph-indexes";
22
22
  import { migration as m006 } from "./006-document-metadata";
23
23
  import { migration as m007 } from "./007-document-date-fields";
24
24
  import { migration as m008 } from "./008-vector-fingerprints";
25
+ import { migration as m009 } from "./009-content-type-rule-fingerprint";
25
26
 
26
27
  /** All migrations in order */
27
- export const migrations = [m001, m002, m003, m004, m005, m006, m007, m008];
28
+ export const migrations = [
29
+ m001,
30
+ m002,
31
+ m003,
32
+ m004,
33
+ m005,
34
+ m006,
35
+ m007,
36
+ m008,
37
+ m009,
38
+ ];
@@ -554,9 +554,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
554
554
  collection, rel_path, source_hash, source_mime, source_ext,
555
555
  source_size, source_mtime, source_ctime, docid, uri, title, mirror_hash,
556
556
  converter_id, converter_version, language_hint, content_type, categories,
557
- author, frontmatter_date, date_fields, active, indexed_at, last_error_code,
558
- last_error_message, last_error_at, ingest_version, updated_at
559
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now'), ?, ?, ?, ?, datetime('now'))
557
+ author, frontmatter_date, date_fields, content_type_rules_fingerprint,
558
+ active, indexed_at, last_error_code, last_error_message, last_error_at,
559
+ ingest_version, updated_at
560
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now'), ?, ?, ?, ?, datetime('now'))
560
561
  ON CONFLICT(collection, rel_path) DO UPDATE SET
561
562
  source_hash = excluded.source_hash,
562
563
  source_mime = excluded.source_mime,
@@ -576,6 +577,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
576
577
  author = excluded.author,
577
578
  frontmatter_date = excluded.frontmatter_date,
578
579
  date_fields = excluded.date_fields,
580
+ content_type_rules_fingerprint = excluded.content_type_rules_fingerprint,
579
581
  active = 1,
580
582
  indexed_at = datetime('now'),
581
583
  last_error_code = excluded.last_error_code,
@@ -605,6 +607,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
605
607
  doc.author ?? null,
606
608
  doc.frontmatterDate ?? null,
607
609
  doc.dateFields ? JSON.stringify(doc.dateFields) : null,
610
+ doc.contentTypeRulesFingerprint ?? null,
608
611
  doc.lastErrorCode ?? null,
609
612
  doc.lastErrorMessage ?? null,
610
613
  doc.lastErrorCode ? new Date().toISOString() : null,
@@ -1366,7 +1369,9 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1366
1369
  d.source_mtime,
1367
1370
  d.frontmatter_date,
1368
1371
  d.source_size,
1369
- d.source_hash
1372
+ d.source_hash,
1373
+ d.content_type,
1374
+ d.categories
1370
1375
  FROM fts_matches fm
1371
1376
  JOIN documents d ON d.id = fm.rowid AND d.active = 1
1372
1377
  WHERE 1 = 1
@@ -1392,6 +1397,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1392
1397
  frontmatter_date: string | null;
1393
1398
  source_size: number | null;
1394
1399
  source_hash: string | null;
1400
+ content_type: string | null;
1401
+ categories: string | null;
1395
1402
  }
1396
1403
 
1397
1404
  const queryParams = [builtQuery.query, ftsLimit, ...params];
@@ -1416,6 +1423,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1416
1423
  frontmatterDate: r.frontmatter_date ?? undefined,
1417
1424
  sourceSize: r.source_size ?? undefined,
1418
1425
  sourceHash: r.source_hash ?? undefined,
1426
+ contentType: r.content_type ?? undefined,
1427
+ categories: parseCategoriesJson(r.categories) ?? undefined,
1419
1428
  }))
1420
1429
  );
1421
1430
  } catch (cause) {
@@ -3522,6 +3531,7 @@ interface DbDocumentRow {
3522
3531
  author: string | null;
3523
3532
  frontmatter_date: string | null;
3524
3533
  date_fields: string | null;
3534
+ content_type_rules_fingerprint: string | null;
3525
3535
  indexed_at: string | null;
3526
3536
  active: number;
3527
3537
  ingest_version: number | null;
@@ -3580,19 +3590,23 @@ function mapContextRow(row: DbContextRow): ContextRow {
3580
3590
  };
3581
3591
  }
3582
3592
 
3583
- function mapDocumentRow(row: DbDocumentRow): DocumentRow {
3584
- let categories: string[] | null = null;
3585
- if (row.categories) {
3586
- try {
3587
- const parsed = JSON.parse(row.categories);
3588
- if (Array.isArray(parsed)) {
3589
- categories = parsed.filter((v): v is string => typeof v === "string");
3590
- }
3591
- } catch {
3592
- categories = null;
3593
+ function parseCategoriesJson(raw: string | null): string[] | null {
3594
+ if (!raw) {
3595
+ return null;
3596
+ }
3597
+ try {
3598
+ const parsed = JSON.parse(raw);
3599
+ if (Array.isArray(parsed)) {
3600
+ return parsed.filter((v): v is string => typeof v === "string");
3593
3601
  }
3602
+ } catch {
3603
+ return null;
3594
3604
  }
3605
+ return null;
3606
+ }
3595
3607
 
3608
+ function mapDocumentRow(row: DbDocumentRow): DocumentRow {
3609
+ const categories = parseCategoriesJson(row.categories);
3596
3610
  let dateFields: Record<string, string> | null = null;
3597
3611
  if (row.date_fields) {
3598
3612
  try {
@@ -3638,6 +3652,7 @@ function mapDocumentRow(row: DbDocumentRow): DocumentRow {
3638
3652
  indexedAt: row.indexed_at,
3639
3653
  active: row.active === 1,
3640
3654
  ingestVersion: row.ingest_version,
3655
+ contentTypeRulesFingerprint: row.content_type_rules_fingerprint,
3641
3656
  lastErrorCode: row.last_error_code,
3642
3657
  lastErrorMessage: row.last_error_message,
3643
3658
  lastErrorAt: row.last_error_at,
@@ -118,6 +118,8 @@ export interface DocumentRow {
118
118
  active: boolean;
119
119
  /** Ingest schema version for backfill detection */
120
120
  ingestVersion: number | null;
121
+ /** Fingerprint of normalized content type rules used for derived metadata */
122
+ contentTypeRulesFingerprint?: string | null;
121
123
 
122
124
  // Error tracking
123
125
  lastErrorCode: string | null;
@@ -264,6 +266,8 @@ export interface DocumentInput {
264
266
  lastErrorMessage?: string;
265
267
  /** Ingest schema version for backfill detection */
266
268
  ingestVersion?: number;
269
+ /** Fingerprint of normalized content type rules used for derived metadata */
270
+ contentTypeRulesFingerprint?: string;
267
271
  }
268
272
 
269
273
  /** Result of upserting a document */
@@ -345,6 +349,8 @@ export interface FtsResult {
345
349
  frontmatterDate?: string;
346
350
  sourceSize?: number;
347
351
  sourceHash?: string;
352
+ contentType?: string;
353
+ categories?: string[];
348
354
  }
349
355
 
350
356
  // ─────────────────────────────────────────────────────────────────────────────