@gmickel/gno 1.7.0 → 1.8.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.
@@ -4,8 +4,8 @@
4
4
  * @module src/core/file-ops
5
5
  */
6
6
 
7
- // node:fs/promises for rename/unlink (no Bun equivalent for structure ops)
8
- import { copyFile, mkdir, rename, unlink } from "node:fs/promises";
7
+ // node:fs/promises for rename/unlink/link (no Bun equivalent for structure ops or exclusive hard-link create)
8
+ import { copyFile, link, mkdir, rename, unlink } from "node:fs/promises";
9
9
  // node:os platform/homedir/tmpdir: no Bun equivalent
10
10
  import { homedir, platform as getPlatform, tmpdir } from "node:os";
11
11
  // node:path dirname/join/parse: no Bun equivalent
@@ -27,6 +27,25 @@ export async function atomicWrite(
27
27
  }
28
28
  }
29
29
 
30
+ export async function atomicCreate(
31
+ path: string,
32
+ content: string
33
+ ): Promise<void> {
34
+ const tempPath = `${path}.tmp.${crypto.randomUUID()}`;
35
+ await Bun.write(tempPath, content);
36
+ try {
37
+ await link(tempPath, path);
38
+ } catch (e) {
39
+ await unlink(tempPath).catch(() => {
40
+ /* ignore cleanup errors */
41
+ });
42
+ throw e;
43
+ }
44
+ await unlink(tempPath).catch(() => {
45
+ /* ignore cleanup errors */
46
+ });
47
+ }
48
+
30
49
  async function runCommand(cmd: string[]): Promise<void> {
31
50
  const proc = Bun.spawn({
32
51
  cmd,
@@ -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";
26
+ import { normalizeCollectionName } from "../../core/validation";
32
27
  import { defaultSyncService } from "../../ingestion";
33
- import { updateFrontmatterTags } from "../../ingestion/frontmatter";
34
- import { extractTitle } from "../../pipeline/contextual";
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,136 @@ 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],
178
+ [plan.relPath],
241
179
  { runUpdateCmd: false, gitPull: false }
242
180
  );
243
181
  const syncResult = results[0];
244
182
  if (!syncResult) {
245
- throw new Error("RUNTIME: Sync result missing");
183
+ return buildCaptureReceipt({
184
+ plan,
185
+ absPath,
186
+ docid: "",
187
+ sync: {
188
+ status: "failed",
189
+ error: "RUNTIME: Sync result missing",
190
+ },
191
+ overwritten: exists && args.overwrite === true,
192
+ serverInstanceId: ctx.serverInstanceId,
193
+ }) as McpCaptureResult;
246
194
  }
247
195
  if (syncResult.status === "error") {
248
- throw new Error(
249
- `INGEST_ERROR: ${syncResult.errorCode ?? "ERROR"} - ${
250
- syncResult.errorMessage ?? "Unknown error"
251
- }`
252
- );
196
+ return buildCaptureReceipt({
197
+ plan,
198
+ absPath,
199
+ docid: "",
200
+ sync: {
201
+ status: "failed",
202
+ error: `INGEST_ERROR: ${syncResult.errorCode ?? "ERROR"} - ${
203
+ syncResult.errorMessage ?? "Unknown error"
204
+ }`,
205
+ },
206
+ overwritten: exists && args.overwrite === true,
207
+ serverInstanceId: ctx.serverInstanceId,
208
+ }) as McpCaptureResult;
253
209
  }
254
210
 
255
211
  let docid = syncResult.docid;
256
212
  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;
213
+ const docResult = await ctx.store.getDocument(
214
+ collectionName,
215
+ plan.relPath
216
+ );
217
+ if (docResult.ok && docResult.value) {
218
+ docid = docid ?? docResult.value.docid;
269
219
  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
- }
220
+ }
221
+ if (!docid) {
222
+ return buildCaptureReceipt({
223
+ plan,
224
+ absPath,
225
+ docid: "",
226
+ sync: {
227
+ status: "failed",
228
+ error: "RUNTIME: Document missing after sync",
229
+ },
230
+ overwritten: exists && args.overwrite === true,
231
+ serverInstanceId: ctx.serverInstanceId,
232
+ }) as McpCaptureResult;
279
233
  }
280
234
 
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) {
235
+ const isMarkdown =
236
+ plan.relPath.endsWith(".md") || plan.relPath.endsWith(".markdown");
237
+ if (!isMarkdown && plan.tags.length > 0 && documentId) {
285
238
  const tagResult = await ctx.store.setDocTags(
286
239
  documentId,
287
- normalizedTags,
240
+ plan.tags,
288
241
  "user"
289
242
  );
290
- if (tagResult.ok) {
291
- tagsStored = true;
292
- } else {
293
- // Log warning - document created but tags not stored
243
+ if (!tagResult.ok) {
294
244
  console.error(
295
245
  `[MCP] Warning: Document created but tags not stored: ${tagResult.error.message}`
296
246
  );
297
247
  }
298
248
  }
299
249
 
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),
250
+ return buildCaptureReceipt({
251
+ plan,
307
252
  absPath,
308
- collection: collectionName,
309
- relPath,
310
- created: !exists,
311
- overwritten: exists,
253
+ docid,
254
+ sync: { status: "completed" },
255
+ overwritten: exists && args.overwrite === true,
312
256
  serverInstanceId: ctx.serverInstanceId,
313
- tags: confirmedTags,
314
- };
257
+ }) as McpCaptureResult;
315
258
  });
316
259
  },
317
260
  formatCaptureResult
@@ -10,6 +10,7 @@ import { z } from "zod";
10
10
 
11
11
  import type { ToolContext } from "../server";
12
12
 
13
+ import { CAPTURE_MAX_TEXT_BYTES } from "../../core/capture";
13
14
  import { normalizeTag } from "../../core/tags";
14
15
  import { handleAddCollection } from "./add-collection";
15
16
  import { handleCapture } from "./capture";
@@ -148,6 +149,7 @@ const captureInputSchema = z.object({
148
149
  .describe("Target collection name (must already exist)"),
149
150
  content: z
150
151
  .string()
152
+ .max(CAPTURE_MAX_TEXT_BYTES)
151
153
  .optional()
152
154
  .describe(
153
155
  "Document content (markdown or plain text). Optional when presetId provides a scaffold."
@@ -189,6 +191,39 @@ const captureInputSchema = z.object({
189
191
  .array(z.string())
190
192
  .optional()
191
193
  .describe("Tags to apply to the new document"),
194
+ source: z
195
+ .object({
196
+ kind: z
197
+ .enum([
198
+ "direct",
199
+ "web",
200
+ "email",
201
+ "meeting",
202
+ "chat",
203
+ "file",
204
+ "api",
205
+ "unknown",
206
+ ])
207
+ .optional()
208
+ .describe("Capture source kind"),
209
+ title: z.string().optional().describe("Human source title"),
210
+ url: z.string().optional().describe("Source URL"),
211
+ uri: z.string().optional().describe("Source URI"),
212
+ docid: z.string().optional().describe("Source GNO doc ID"),
213
+ mime: z.string().optional().describe("Source MIME type"),
214
+ ext: z.string().optional().describe("Source file extension"),
215
+ author: z.string().optional().describe("Source author"),
216
+ observedAt: z
217
+ .string()
218
+ .optional()
219
+ .describe("When the source was observed"),
220
+ capturedAt: z.string().optional().describe("Capture timestamp override"),
221
+ externalId: z.string().optional().describe("External system/source ID"),
222
+ })
223
+ .optional()
224
+ .describe(
225
+ "Structured provenance metadata written under source frontmatter"
226
+ ),
192
227
  });
193
228
 
194
229
  const addCollectionInputSchema = z.object({
@@ -456,7 +491,11 @@ export const queryInputSchema = z.object({
456
491
  noGraph: z
457
492
  .boolean()
458
493
  .optional()
459
- .describe("Disable bounded one-hop graph neighbor expansion"),
494
+ .describe("Compatibility no-op unless graph is also true"),
495
+ graph: z
496
+ .boolean()
497
+ .optional()
498
+ .describe("Enable bounded one-hop graph neighbor expansion"),
460
499
  tagsAll: z.array(z.string()).optional().describe("Require ALL of these tags"),
461
500
  tagsAny: z.array(z.string()).optional().describe("Require ANY of these tags"),
462
501
  });
@@ -51,6 +51,7 @@ interface QueryInput {
51
51
  expand?: boolean;
52
52
  rerank?: boolean;
53
53
  noGraph?: boolean;
54
+ graph?: boolean;
54
55
  tagsAll?: string[];
55
56
  tagsAny?: string[];
56
57
  }
@@ -272,6 +273,7 @@ export function handleQuery(
272
273
  author: args.author,
273
274
  noExpand,
274
275
  noRerank,
276
+ graph: args.graph === true,
275
277
  noGraph: args.noGraph || args.fast,
276
278
  queryModes,
277
279
  tagsAll: normalizeTagFilters(args.tagsAll),
@@ -554,7 +554,7 @@ export async function searchHybrid(
554
554
  includeSimilar: vectorAvailable,
555
555
  limit,
556
556
  candidateLimit,
557
- disabled: options.noGraph,
557
+ disabled: !options.graph || options.noGraph,
558
558
  lang: options.lang,
559
559
  tagsAll: options.tagsAll,
560
560
  tagsAny: options.tagsAny,
@@ -174,7 +174,9 @@ export type HybridSearchOptions = SearchOptions & {
174
174
  candidateLimit?: number;
175
175
  /** Enable explain output */
176
176
  explain?: boolean;
177
- /** Disable bounded one-hop graph candidate expansion */
177
+ /** Enable bounded one-hop graph candidate expansion */
178
+ graph?: boolean;
179
+ /** Compatibility no-op unless graph is also true */
178
180
  noGraph?: boolean;
179
181
  /** Language hint for prompt selection (does NOT filter retrieval, only affects expansion prompts) */
180
182
  queryLanguageHint?: string;