@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
@@ -9,54 +9,39 @@ import { mkdir } from "node:fs/promises";
9
9
  // node:path for path utils (no Bun path utils)
10
10
  import { dirname, extname, join } from "node:path";
11
11
 
12
+ import type { NoteCollisionPolicy } from "../../core/note-creation";
13
+ import type { NotePresetId } from "../../core/note-presets";
12
14
  import type { ToolContext } from "../server";
13
15
 
14
- import { buildUri } from "../../app/constants";
16
+ import {
17
+ buildCaptureReceipt,
18
+ listCaptureDiskRelPaths,
19
+ planCapture,
20
+ type CaptureInput as SharedCaptureInput,
21
+ type CaptureReceipt,
22
+ } from "../../core/capture";
23
+ import { writeCapturePlanFile } from "../../core/capture-write";
15
24
  import { MCP_ERRORS } from "../../core/errors";
16
25
  import { withWriteLock } from "../../core/file-lock";
17
- import { atomicWrite } from "../../core/file-ops";
18
- import {
19
- resolveNoteCreatePlan,
20
- type NoteCollisionPolicy,
21
- } from "../../core/note-creation";
22
- import {
23
- getNotePreset,
24
- resolveNotePreset,
25
- type NotePresetId,
26
- } from "../../core/note-presets";
27
- import { normalizeTag, validateTag } from "../../core/tags";
28
- import {
29
- normalizeCollectionName,
30
- validateRelPath,
31
- } from "../../core/validation";
32
- import { defaultSyncService } from "../../ingestion";
33
- import { updateFrontmatterTags } from "../../ingestion/frontmatter";
34
- import { extractTitle } from "../../pipeline/contextual";
26
+ import { normalizeCollectionName } from "../../core/validation";
27
+ import { defaultSyncService, withContentTypeRules } from "../../ingestion";
35
28
  import { runTool, type ToolResult } from "./index";
36
29
 
37
- interface CaptureInput {
38
- collection: string;
39
- content?: string;
40
- title?: string;
30
+ interface CaptureInput extends Omit<
31
+ SharedCaptureInput,
32
+ "relPath" | "collisionPolicy" | "presetId"
33
+ > {
41
34
  path?: string;
42
- overwrite?: boolean;
43
- folderPath?: string;
44
35
  collisionPolicy?: NoteCollisionPolicy;
45
36
  presetId?: NotePresetId;
46
- tags?: string[];
47
37
  }
48
38
 
49
- interface CaptureResult {
39
+ type McpCaptureResult = CaptureReceipt & {
50
40
  docid: string;
51
- uri: string;
52
41
  absPath: string;
53
- collection: string;
54
- relPath: string;
55
- created: boolean;
56
42
  overwritten: boolean;
57
43
  serverInstanceId: string;
58
- tags?: string[];
59
- }
44
+ };
60
45
 
61
46
  const SENSITIVE_SUBPATHS = new Set([
62
47
  ".ssh",
@@ -67,51 +52,56 @@ const SENSITIVE_SUBPATHS = new Set([
67
52
  "node_modules",
68
53
  ]);
69
54
 
70
- function sanitizeFilename(title: string): string {
71
- return title
72
- .toLowerCase()
73
- .trim()
74
- .replaceAll(/[^\w\s-]/g, "")
75
- .replaceAll(/\s+/g, "-")
76
- .replaceAll(/-+/g, "-")
77
- .replace(/^-|-$/g, "");
78
- }
79
-
80
55
  function ensureMarkdownExtension(relPath: string): string {
81
56
  return extname(relPath) ? relPath : `${relPath}.md`;
82
57
  }
83
58
 
84
59
  function assertNotSensitive(relPath: string): void {
85
- const firstSegment = relPath.split(/[\\/]/)[0];
86
- if (firstSegment && SENSITIVE_SUBPATHS.has(firstSegment)) {
87
- throw new Error(
88
- `${MCP_ERRORS.INVALID_PATH.code}: Cannot write to sensitive directory: ${firstSegment}`
89
- );
60
+ for (const segment of relPath.split(/[\\/]/)) {
61
+ if (segment && SENSITIVE_SUBPATHS.has(segment)) {
62
+ throw new Error(
63
+ `${MCP_ERRORS.INVALID_PATH.code}: Cannot write to sensitive directory: ${segment}`
64
+ );
65
+ }
90
66
  }
91
67
  }
92
68
 
93
- function generateFilename(title: string | undefined, content: string): string {
94
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
95
- const fallback = `note-${timestamp}.md`;
96
- const baseTitle = title?.trim() || extractTitle(content, fallback);
97
- const slug = sanitizeFilename(baseTitle);
98
- const safeSlug = slug || `note-${timestamp}`;
99
- return `${safeSlug}.md`;
100
- }
101
-
102
- function formatCaptureResult(result: CaptureResult): string {
69
+ function formatCaptureResult(result: McpCaptureResult): string {
103
70
  const lines: string[] = [];
104
71
  lines.push(`Doc: ${result.docid}`);
105
72
  lines.push(`URI: ${result.uri}`);
106
73
  lines.push(`Path: ${result.absPath}`);
107
74
  lines.push(`Created: ${result.created ? "yes" : "no"}`);
75
+ lines.push(`Opened existing: ${result.openedExisting ? "yes" : "no"}`);
108
76
  lines.push(`Overwritten: ${result.overwritten ? "yes" : "no"}`);
109
- if (result.tags && result.tags.length > 0) {
77
+ lines.push(`Collision: ${result.collisionPolicyResult}`);
78
+ lines.push(`Sync: ${result.sync.status}`);
79
+ lines.push(`Embed: ${result.embed.status}`);
80
+ lines.push(`Content hash: ${result.contentHash}`);
81
+ if (result.tags.length > 0) {
110
82
  lines.push(`Tags: ${result.tags.join(", ")}`);
111
83
  }
112
84
  return lines.join("\n");
113
85
  }
114
86
 
87
+ function buildSharedInput(
88
+ args: CaptureInput,
89
+ collectionName: string
90
+ ): SharedCaptureInput {
91
+ return {
92
+ collection: collectionName,
93
+ content: args.content,
94
+ title: args.title,
95
+ relPath: args.path ? ensureMarkdownExtension(args.path) : undefined,
96
+ folderPath: args.folderPath,
97
+ collisionPolicy: args.collisionPolicy,
98
+ presetId: args.presetId,
99
+ tags: args.tags,
100
+ source: args.source,
101
+ overwrite: args.overwrite,
102
+ };
103
+ }
104
+
115
105
  export function handleCapture(
116
106
  args: CaptureInput,
117
107
  ctx: ToolContext
@@ -135,183 +125,139 @@ export function handleCapture(
135
125
  );
136
126
  }
137
127
 
138
- if (args.presetId && !getNotePreset(args.presetId)) {
139
- throw new Error(
140
- `${MCP_ERRORS.INVALID_INPUT.code}: Unknown presetId: ${args.presetId}`
141
- );
142
- }
143
-
144
128
  const existingDocs = await ctx.store.listDocuments(collectionName);
145
129
  if (!existingDocs.ok) {
146
130
  throw new Error(existingDocs.error.message);
147
131
  }
148
132
 
149
- let relPath: string;
133
+ let plan;
150
134
  try {
151
- const plan = resolveNoteCreatePlan(
152
- {
153
- collection: collection.name,
154
- relPath: args.path
155
- ? ensureMarkdownExtension(validateRelPath(args.path))
156
- : undefined,
157
- title:
158
- args.title?.trim() ||
159
- generateFilename(args.title, args.content ?? "").replace(
160
- /\.md$/u,
161
- ""
162
- ),
163
- folderPath: args.folderPath,
164
- collisionPolicy: args.collisionPolicy,
165
- },
166
- existingDocs.value.map((doc) => doc.relPath)
167
- );
168
- if (plan.openedExisting) {
169
- throw new Error(
170
- `${MCP_ERRORS.CONFLICT.code}: Existing note resolution is not supported through gno_capture yet`
171
- );
172
- }
173
- relPath = plan.relPath;
135
+ plan = planCapture({
136
+ input: buildSharedInput(args, collection.name),
137
+ existingRelPaths: existingDocs.value.map((doc) => doc.relPath),
138
+ diskRelPaths: await listCaptureDiskRelPaths(collection.path),
139
+ });
174
140
  } catch (error) {
175
141
  const message =
176
142
  error instanceof Error ? error.message : String(error);
177
- throw new Error(`${MCP_ERRORS.INVALID_PATH.code}: ${message}`);
143
+ throw new Error(`${MCP_ERRORS.INVALID_INPUT.code}: ${message}`);
178
144
  }
179
145
 
180
- assertNotSensitive(relPath);
146
+ assertNotSensitive(plan.relPath);
181
147
 
182
- // Validate and normalize tags
183
- let normalizedTags: string[] = [];
184
- if (args.tags && args.tags.length > 0) {
185
- for (const tag of args.tags) {
186
- const normalized = normalizeTag(tag);
187
- if (!validateTag(normalized)) {
188
- throw new Error(
189
- `${MCP_ERRORS.INVALID_INPUT.code}: Invalid tag "${tag}". Tags must be lowercase, alphanumeric with hyphens/dots/slashes.`
190
- );
191
- }
192
- normalizedTags.push(normalized);
193
- }
194
- // Dedupe
195
- normalizedTags = [...new Set(normalizedTags)];
196
- }
197
-
198
- const absPath = join(collection.path, relPath);
199
- const file = Bun.file(absPath);
200
- const exists = await file.exists();
201
- if (exists && !args.overwrite) {
202
- throw new Error(
203
- `${MCP_ERRORS.CONFLICT.code}: File exists: ${relPath}`
204
- );
205
- }
148
+ const absPath = join(collection.path, plan.relPath);
149
+ const existingFile = Bun.file(absPath);
150
+ const exists = await existingFile.exists();
206
151
 
207
- // Update frontmatter with tags for Markdown files
208
- const resolvedPreset = resolveNotePreset({
209
- presetId: args.presetId,
210
- title:
211
- args.title?.trim() ||
212
- relPath
213
- .split("/")
214
- .pop()
215
- ?.replace(/\.[^.]+$/u, "") ||
216
- "Untitled",
217
- tags: normalizedTags,
218
- body: args.content,
219
- });
220
- let contentToWrite =
221
- resolvedPreset?.content ??
222
- args.content ??
223
- `# ${args.title?.trim() || "Untitled"}\n`;
224
- const isMarkdown =
225
- relPath.endsWith(".md") || relPath.endsWith(".markdown");
226
- if (isMarkdown && normalizedTags.length > 0) {
227
- // Use shared utility that handles all frontmatter edge cases
228
- contentToWrite = updateFrontmatterTags(
229
- contentToWrite,
230
- normalizedTags
152
+ if (plan.openedExisting) {
153
+ const docResult = await ctx.store.getDocument(
154
+ collectionName,
155
+ plan.relPath
231
156
  );
157
+ const existingDoc = docResult.ok ? docResult.value : undefined;
158
+ return buildCaptureReceipt({
159
+ plan,
160
+ absPath,
161
+ docid: existingDoc?.docid ?? "",
162
+ sync: {
163
+ status: existingDoc ? "completed" : "skipped",
164
+ reason: existingDoc
165
+ ? "Existing capture already indexed."
166
+ : "Existing capture opened from disk but is not indexed yet.",
167
+ },
168
+ serverInstanceId: ctx.serverInstanceId,
169
+ }) as McpCaptureResult;
232
170
  }
233
171
 
234
172
  await mkdir(dirname(absPath), { recursive: true });
235
- await atomicWrite(absPath, contentToWrite);
173
+ await writeCapturePlanFile(plan, absPath);
236
174
 
237
175
  const results = await defaultSyncService.syncFiles(
238
176
  collection,
239
177
  ctx.store,
240
- [relPath],
241
- { runUpdateCmd: false, gitPull: false }
178
+ [plan.relPath],
179
+ withContentTypeRules(
180
+ { runUpdateCmd: false, gitPull: false },
181
+ ctx.config
182
+ )
242
183
  );
243
184
  const syncResult = results[0];
244
185
  if (!syncResult) {
245
- throw new Error("RUNTIME: Sync result missing");
186
+ return buildCaptureReceipt({
187
+ plan,
188
+ absPath,
189
+ docid: "",
190
+ sync: {
191
+ status: "failed",
192
+ error: "RUNTIME: Sync result missing",
193
+ },
194
+ overwritten: exists && args.overwrite === true,
195
+ serverInstanceId: ctx.serverInstanceId,
196
+ }) as McpCaptureResult;
246
197
  }
247
198
  if (syncResult.status === "error") {
248
- throw new Error(
249
- `INGEST_ERROR: ${syncResult.errorCode ?? "ERROR"} - ${
250
- syncResult.errorMessage ?? "Unknown error"
251
- }`
252
- );
199
+ return buildCaptureReceipt({
200
+ plan,
201
+ absPath,
202
+ docid: "",
203
+ sync: {
204
+ status: "failed",
205
+ error: `INGEST_ERROR: ${syncResult.errorCode ?? "ERROR"} - ${
206
+ syncResult.errorMessage ?? "Unknown error"
207
+ }`,
208
+ },
209
+ overwritten: exists && args.overwrite === true,
210
+ serverInstanceId: ctx.serverInstanceId,
211
+ }) as McpCaptureResult;
253
212
  }
254
213
 
255
214
  let docid = syncResult.docid;
256
215
  let documentId: number | undefined;
257
- if (!docid) {
258
- const docResult = await ctx.store.getDocument(
259
- collectionName,
260
- relPath
261
- );
262
- if (!docResult.ok) {
263
- throw new Error(docResult.error.message);
264
- }
265
- if (!docResult.value) {
266
- throw new Error("RUNTIME: Document missing after sync");
267
- }
268
- docid = docResult.value.docid;
216
+ const docResult = await ctx.store.getDocument(
217
+ collectionName,
218
+ plan.relPath
219
+ );
220
+ if (docResult.ok && docResult.value) {
221
+ docid = docid ?? docResult.value.docid;
269
222
  documentId = docResult.value.id;
270
- } else {
271
- // Get document id for tag storage
272
- const docResult = await ctx.store.getDocument(
273
- collectionName,
274
- relPath
275
- );
276
- if (docResult.ok && docResult.value) {
277
- documentId = docResult.value.id;
278
- }
223
+ }
224
+ if (!docid) {
225
+ return buildCaptureReceipt({
226
+ plan,
227
+ absPath,
228
+ docid: "",
229
+ sync: {
230
+ status: "failed",
231
+ error: "RUNTIME: Document missing after sync",
232
+ },
233
+ overwritten: exists && args.overwrite === true,
234
+ serverInstanceId: ctx.serverInstanceId,
235
+ }) as McpCaptureResult;
279
236
  }
280
237
 
281
- // For non-markdown files, store tags as user-source in DB
282
- // (Markdown files get tags from frontmatter during sync)
283
- let tagsStored = isMarkdown; // Markdown tags stored via frontmatter sync
284
- if (!isMarkdown && normalizedTags.length > 0 && documentId) {
238
+ const isMarkdown =
239
+ plan.relPath.endsWith(".md") || plan.relPath.endsWith(".markdown");
240
+ if (!isMarkdown && plan.tags.length > 0 && documentId) {
285
241
  const tagResult = await ctx.store.setDocTags(
286
242
  documentId,
287
- normalizedTags,
243
+ plan.tags,
288
244
  "user"
289
245
  );
290
- if (tagResult.ok) {
291
- tagsStored = true;
292
- } else {
293
- // Log warning - document created but tags not stored
246
+ if (!tagResult.ok) {
294
247
  console.error(
295
248
  `[MCP] Warning: Document created but tags not stored: ${tagResult.error.message}`
296
249
  );
297
250
  }
298
251
  }
299
252
 
300
- // Only include tags in response if confirmed stored
301
- const confirmedTags =
302
- tagsStored && normalizedTags.length > 0 ? normalizedTags : undefined;
303
-
304
- return {
305
- docid,
306
- uri: buildUri(collectionName, relPath),
253
+ return buildCaptureReceipt({
254
+ plan,
307
255
  absPath,
308
- collection: collectionName,
309
- relPath,
310
- created: !exists,
311
- overwritten: exists,
256
+ docid,
257
+ sync: { status: "completed" },
258
+ overwritten: exists && args.overwrite === true,
312
259
  serverInstanceId: ctx.serverInstanceId,
313
- tags: confirmedTags,
314
- };
260
+ }) as McpCaptureResult;
315
261
  });
316
262
  },
317
263
  formatCaptureResult
@@ -12,7 +12,7 @@ import { acquireWriteLock, type WriteLockHandle } from "../../core/file-lock";
12
12
  import { JobError } from "../../core/job-manager";
13
13
  import { normalizeCollectionName } from "../../core/validation";
14
14
  import { embedBacklog } from "../../embed";
15
- import { defaultSyncService } from "../../ingestion";
15
+ import { defaultSyncService, withContentTypeRules } from "../../ingestion";
16
16
  import { LlmAdapter } from "../../llm/nodeLlamaCpp/adapter";
17
17
  import { resolveModelUri } from "../../llm/registry";
18
18
  import {
@@ -95,11 +95,14 @@ export function handleIndex(
95
95
  ? [collection.name]
96
96
  : ctx.collections.map((entry) => entry.name);
97
97
 
98
- const options = {
99
- gitPull: args.gitPull ?? false,
100
- // Security: MCP never runs updateCmd by default
101
- runUpdateCmd: false,
102
- };
98
+ const options = withContentTypeRules(
99
+ {
100
+ gitPull: args.gitPull ?? false,
101
+ // Security: MCP never runs updateCmd by default
102
+ runUpdateCmd: false,
103
+ },
104
+ ctx.config
105
+ );
103
106
 
104
107
  const modelUri = resolveModelUri(
105
108
  ctx.config,
@@ -203,7 +206,10 @@ export function handleIndex(
203
206
  collections,
204
207
  status: "started",
205
208
  phases: ["sync", "embed"],
206
- options,
209
+ options: {
210
+ gitPull: options.gitPull ?? false,
211
+ runUpdateCmd: options.runUpdateCmd ?? false,
212
+ },
207
213
  };
208
214
 
209
215
  return result;
@@ -10,6 +10,8 @@ import { z } from "zod";
10
10
 
11
11
  import type { ToolContext } from "../server";
12
12
 
13
+ import { CAPTURE_MAX_TEXT_BYTES } from "../../core/capture";
14
+ import { NOTE_PRESETS, type NotePresetId } from "../../core/note-presets";
13
15
  import { normalizeTag } from "../../core/tags";
14
16
  import { handleAddCollection } from "./add-collection";
15
17
  import { handleCapture } from "./capture";
@@ -141,13 +143,19 @@ const searchInputSchema = z.object({
141
143
  .describe("Require ANY of these tags (OR filter)"),
142
144
  });
143
145
 
144
- const captureInputSchema = z.object({
146
+ const notePresetIds = NOTE_PRESETS.map((preset) => preset.id) as [
147
+ NotePresetId,
148
+ ...NotePresetId[],
149
+ ];
150
+
151
+ export const captureInputSchema = z.object({
145
152
  collection: z
146
153
  .string()
147
154
  .min(1, "Collection cannot be empty")
148
155
  .describe("Target collection name (must already exist)"),
149
156
  content: z
150
157
  .string()
158
+ .max(CAPTURE_MAX_TEXT_BYTES)
151
159
  .optional()
152
160
  .describe(
153
161
  "Document content (markdown or plain text). Optional when presetId provides a scaffold."
@@ -171,14 +179,7 @@ const captureInputSchema = z.object({
171
179
  .optional()
172
180
  .describe("How to handle name collisions"),
173
181
  presetId: z
174
- .enum([
175
- "blank",
176
- "project-note",
177
- "research-note",
178
- "decision-note",
179
- "prompt-pattern",
180
- "source-summary",
181
- ])
182
+ .enum(notePresetIds)
182
183
  .optional()
183
184
  .describe("Optional note preset scaffold"),
184
185
  overwrite: z
@@ -189,6 +190,39 @@ const captureInputSchema = z.object({
189
190
  .array(z.string())
190
191
  .optional()
191
192
  .describe("Tags to apply to the new document"),
193
+ source: z
194
+ .object({
195
+ kind: z
196
+ .enum([
197
+ "direct",
198
+ "web",
199
+ "email",
200
+ "meeting",
201
+ "chat",
202
+ "file",
203
+ "api",
204
+ "unknown",
205
+ ])
206
+ .optional()
207
+ .describe("Capture source kind"),
208
+ title: z.string().optional().describe("Human source title"),
209
+ url: z.string().optional().describe("Source URL"),
210
+ uri: z.string().optional().describe("Source URI"),
211
+ docid: z.string().optional().describe("Source GNO doc ID"),
212
+ mime: z.string().optional().describe("Source MIME type"),
213
+ ext: z.string().optional().describe("Source file extension"),
214
+ author: z.string().optional().describe("Source author"),
215
+ observedAt: z
216
+ .string()
217
+ .optional()
218
+ .describe("When the source was observed"),
219
+ capturedAt: z.string().optional().describe("Capture timestamp override"),
220
+ externalId: z.string().optional().describe("External system/source ID"),
221
+ })
222
+ .optional()
223
+ .describe(
224
+ "Structured provenance metadata written under source frontmatter"
225
+ ),
192
226
  });
193
227
 
194
228
  const addCollectionInputSchema = z.object({
@@ -12,7 +12,7 @@ import { MCP_ERRORS } from "../../core/errors";
12
12
  import { acquireWriteLock, type WriteLockHandle } from "../../core/file-lock";
13
13
  import { JobError } from "../../core/job-manager";
14
14
  import { normalizeCollectionName } from "../../core/validation";
15
- import { defaultSyncService } from "../../ingestion";
15
+ import { defaultSyncService, withContentTypeRules } from "../../ingestion";
16
16
  import { runTool, type ToolResult } from "./index";
17
17
 
18
18
  interface SyncInput {
@@ -101,10 +101,13 @@ export function handleSync(
101
101
  ? [collection.name]
102
102
  : ctx.collections.map((entry) => entry.name);
103
103
 
104
- const options = {
105
- gitPull: args.gitPull ?? false,
106
- runUpdateCmd: args.runUpdateCmd ?? false,
107
- };
104
+ const options = withContentTypeRules(
105
+ {
106
+ gitPull: args.gitPull ?? false,
107
+ runUpdateCmd: args.runUpdateCmd ?? false,
108
+ },
109
+ ctx.config
110
+ );
108
111
 
109
112
  const jobId = await ctx.jobManager.startJobWithLock(
110
113
  "sync",
@@ -133,7 +136,10 @@ export function handleSync(
133
136
  jobId,
134
137
  collections,
135
138
  status: "started",
136
- options,
139
+ options: {
140
+ gitPull: options.gitPull ?? false,
141
+ runUpdateCmd: options.runUpdateCmd ?? false,
142
+ },
137
143
  };
138
144
 
139
145
  return result;
@@ -27,7 +27,7 @@ import {
27
27
  planMoveRefactor,
28
28
  planRenameRefactor,
29
29
  } from "../../core/file-refactors";
30
- import { defaultSyncService } from "../../ingestion";
30
+ import { defaultSyncService, withContentTypeRules } from "../../ingestion";
31
31
  import { runTool, type ToolResult } from "./index";
32
32
 
33
33
  interface CreateFolderInput {
@@ -210,9 +210,11 @@ export function handleRenameNote(
210
210
  const currentPath = join(collection.path, doc.relPath);
211
211
  const nextPath = join(collection.path, plan.nextRelPath);
212
212
  await renameFilePath(currentPath, nextPath);
213
- await defaultSyncService.syncCollection(collection, ctx.store, {
214
- runUpdateCmd: false,
215
- });
213
+ await defaultSyncService.syncCollection(
214
+ collection,
215
+ ctx.store,
216
+ withContentTypeRules({ runUpdateCmd: false }, ctx.config)
217
+ );
216
218
  return {
217
219
  uri: plan.nextUri,
218
220
  relPath: plan.nextRelPath,
@@ -253,9 +255,11 @@ export function handleMoveNote(
253
255
  const nextPath = join(collection.path, plan.nextRelPath);
254
256
  await mkdir(dirname(nextPath), { recursive: true });
255
257
  await renameFilePath(currentPath, nextPath);
256
- await defaultSyncService.syncCollection(collection, ctx.store, {
257
- runUpdateCmd: false,
258
- });
258
+ await defaultSyncService.syncCollection(
259
+ collection,
260
+ ctx.store,
261
+ withContentTypeRules({ runUpdateCmd: false }, ctx.config)
262
+ );
259
263
  return {
260
264
  uri: plan.nextUri,
261
265
  relPath: plan.nextRelPath,
@@ -304,9 +308,11 @@ export function handleDuplicateNote(
304
308
  const nextPath = join(collection.path, plan.nextRelPath);
305
309
  await mkdir(dirname(nextPath), { recursive: true });
306
310
  await copyFilePath(currentPath, nextPath);
307
- await defaultSyncService.syncCollection(collection, ctx.store, {
308
- runUpdateCmd: false,
309
- });
311
+ await defaultSyncService.syncCollection(
312
+ collection,
313
+ ctx.store,
314
+ withContentTypeRules({ runUpdateCmd: false }, ctx.config)
315
+ );
310
316
  return {
311
317
  uri: plan.nextUri,
312
318
  relPath: plan.nextRelPath,
@@ -841,6 +841,8 @@ export async function searchHybrid(
841
841
  score: candidate.blendedScore,
842
842
  uri: doc.uri,
843
843
  title: doc.title ?? undefined,
844
+ contentType: doc.contentType ?? undefined,
845
+ categories: doc.categories ?? undefined,
844
846
  line: snippetChunk.startLine,
845
847
  snippet,
846
848
  snippetLanguage: chunk.language ?? undefined,
@@ -117,6 +117,8 @@ function buildSearchResult(ctx: BuildResultContext): SearchResult {
117
117
  score: fts.score, // Raw score, normalized later as batch
118
118
  uri: fts.uri ?? "",
119
119
  title: fts.title,
120
+ contentType: fts.contentType,
121
+ categories: fts.categories,
120
122
  line: chunk?.startLine,
121
123
  snippet,
122
124
  snippetLanguage: chunk?.language ?? undefined,
@@ -43,6 +43,8 @@ export interface SearchResult {
43
43
  score: number;
44
44
  uri: string;
45
45
  title?: string;
46
+ contentType?: string;
47
+ categories?: string[];
46
48
  /** Best source line for editor/agent anchors (1-indexed) */
47
49
  line?: number;
48
50
  snippet: string;