@gmickel/gno 1.7.1 → 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.
- package/README.md +6 -1
- package/assets/skill/SKILL.md +28 -0
- package/assets/skill/cli-reference.md +25 -0
- package/assets/skill/examples.md +16 -0
- package/assets/skill/mcp-reference.md +15 -0
- package/package.json +1 -1
- package/src/cli/commands/capture.ts +279 -0
- package/src/cli/options.ts +2 -0
- package/src/cli/program.ts +64 -0
- package/src/core/capture-write.ts +38 -0
- package/src/core/capture.ts +746 -0
- package/src/core/file-ops.ts +21 -2
- package/src/mcp/tools/capture.ts +132 -189
- package/src/mcp/tools/index.ts +35 -0
- package/src/sdk/client.ts +109 -0
- package/src/sdk/index.ts +2 -0
- package/src/sdk/types.ts +6 -0
- package/src/serve/public/components/CaptureModal.tsx +248 -10
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/routes/api.ts +189 -3
- package/src/serve/server.ts +12 -0
package/src/core/file-ops.ts
CHANGED
|
@@ -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,
|
package/src/mcp/tools/capture.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
133
|
+
let plan;
|
|
150
134
|
try {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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.
|
|
143
|
+
throw new Error(`${MCP_ERRORS.INVALID_INPUT.code}: ${message}`);
|
|
178
144
|
}
|
|
179
145
|
|
|
180
|
-
assertNotSensitive(relPath);
|
|
146
|
+
assertNotSensitive(plan.relPath);
|
|
181
147
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
overwritten: exists,
|
|
253
|
+
docid,
|
|
254
|
+
sync: { status: "completed" },
|
|
255
|
+
overwritten: exists && args.overwrite === true,
|
|
312
256
|
serverInstanceId: ctx.serverInstanceId,
|
|
313
|
-
|
|
314
|
-
};
|
|
257
|
+
}) as McpCaptureResult;
|
|
315
258
|
});
|
|
316
259
|
},
|
|
317
260
|
formatCaptureResult
|
package/src/mcp/tools/index.ts
CHANGED
|
@@ -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({
|
package/src/sdk/client.ts
CHANGED
|
@@ -15,6 +15,8 @@ import type { IndexStatus, StoreResult } from "../store/types";
|
|
|
15
15
|
import type { VectorIndexPort } from "../store/vector";
|
|
16
16
|
import type {
|
|
17
17
|
GnoAskOptions,
|
|
18
|
+
GnoCaptureOptions,
|
|
19
|
+
GnoCaptureResult,
|
|
18
20
|
GnoClient,
|
|
19
21
|
GnoCreateFolderOptions,
|
|
20
22
|
GnoCreateFolderResult,
|
|
@@ -43,6 +45,13 @@ import {
|
|
|
43
45
|
parseUri,
|
|
44
46
|
} from "../app/constants";
|
|
45
47
|
import { ConfigSchema, loadConfig } from "../config";
|
|
48
|
+
import {
|
|
49
|
+
buildCaptureReceipt,
|
|
50
|
+
type CapturePlan,
|
|
51
|
+
listCaptureDiskRelPaths,
|
|
52
|
+
planCapture,
|
|
53
|
+
} from "../core/capture";
|
|
54
|
+
import { writeCapturePlanFile } from "../core/capture-write";
|
|
46
55
|
import {
|
|
47
56
|
atomicWrite,
|
|
48
57
|
copyFilePath,
|
|
@@ -822,6 +831,106 @@ class GnoClientImpl implements GnoClient {
|
|
|
822
831
|
};
|
|
823
832
|
}
|
|
824
833
|
|
|
834
|
+
async capture(options: GnoCaptureOptions): Promise<GnoCaptureResult> {
|
|
835
|
+
this.assertOpen();
|
|
836
|
+
const collection = this.getCollections(options.collection)[0];
|
|
837
|
+
if (!collection) {
|
|
838
|
+
throw sdkError(
|
|
839
|
+
"VALIDATION",
|
|
840
|
+
`Collection not found: ${options.collection}`
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const existingList = await this.store.listDocuments(collection.name);
|
|
845
|
+
if (!existingList.ok) {
|
|
846
|
+
throw sdkError("STORE", existingList.error.message, {
|
|
847
|
+
cause: existingList.error.cause,
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
const { overwrite: _unsupportedOverwrite, ...captureOptions } =
|
|
851
|
+
options as GnoCaptureOptions & { overwrite?: unknown };
|
|
852
|
+
if (_unsupportedOverwrite !== undefined) {
|
|
853
|
+
throw sdkError(
|
|
854
|
+
"VALIDATION",
|
|
855
|
+
"overwrite is not supported by client.capture(); use collisionPolicy instead"
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
let plan: CapturePlan;
|
|
860
|
+
try {
|
|
861
|
+
plan = planCapture({
|
|
862
|
+
input: {
|
|
863
|
+
...captureOptions,
|
|
864
|
+
collection: collection.name,
|
|
865
|
+
},
|
|
866
|
+
existingRelPaths: existingList.value.map((doc) => doc.relPath),
|
|
867
|
+
diskRelPaths: await listCaptureDiskRelPaths(collection.path),
|
|
868
|
+
});
|
|
869
|
+
} catch (error) {
|
|
870
|
+
throw sdkError(
|
|
871
|
+
"VALIDATION",
|
|
872
|
+
error instanceof Error ? error.message : String(error)
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const fullPath = `${collection.path}/${plan.relPath}`;
|
|
877
|
+
if (plan.openedExisting) {
|
|
878
|
+
const existingDoc = await this.store.getDocument(
|
|
879
|
+
collection.name,
|
|
880
|
+
plan.relPath
|
|
881
|
+
);
|
|
882
|
+
if (!existingDoc.ok) {
|
|
883
|
+
throw sdkError("STORE", existingDoc.error.message, {
|
|
884
|
+
cause: existingDoc.error.cause,
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
return buildCaptureReceipt({
|
|
888
|
+
plan,
|
|
889
|
+
absPath: fullPath,
|
|
890
|
+
docid: existingDoc.value?.docid,
|
|
891
|
+
sync: existingDoc.value
|
|
892
|
+
? { status: "completed" }
|
|
893
|
+
: {
|
|
894
|
+
status: "skipped",
|
|
895
|
+
reason: "Existing file is not indexed yet.",
|
|
896
|
+
},
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
901
|
+
await writeCapturePlanFile(plan, fullPath);
|
|
902
|
+
const syncResults = await defaultSyncService.syncFiles(
|
|
903
|
+
collection,
|
|
904
|
+
this.store,
|
|
905
|
+
[plan.relPath],
|
|
906
|
+
{
|
|
907
|
+
runUpdateCmd: false,
|
|
908
|
+
gitPull: false,
|
|
909
|
+
}
|
|
910
|
+
);
|
|
911
|
+
const syncResult = syncResults[0];
|
|
912
|
+
const docResult = await this.store.getDocument(
|
|
913
|
+
collection.name,
|
|
914
|
+
plan.relPath
|
|
915
|
+
);
|
|
916
|
+
const docid = docResult.ok ? docResult.value?.docid : undefined;
|
|
917
|
+
return buildCaptureReceipt({
|
|
918
|
+
plan,
|
|
919
|
+
absPath: fullPath,
|
|
920
|
+
docid: syncResult?.docid ?? docid,
|
|
921
|
+
sync:
|
|
922
|
+
syncResult?.status === "error"
|
|
923
|
+
? {
|
|
924
|
+
status: "failed",
|
|
925
|
+
error:
|
|
926
|
+
syncResult.errorMessage ??
|
|
927
|
+
syncResult.errorCode ??
|
|
928
|
+
"Unknown sync error",
|
|
929
|
+
}
|
|
930
|
+
: { status: "completed" },
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
|
|
825
934
|
async createFolder(
|
|
826
935
|
options: GnoCreateFolderOptions
|
|
827
936
|
): Promise<GnoCreateFolderResult> {
|
package/src/sdk/index.ts
CHANGED
package/src/sdk/types.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Config } from "../config/types";
|
|
8
|
+
import type { CaptureInput, CaptureReceipt } from "../core/capture";
|
|
8
9
|
import type { NoteCollisionPolicy } from "../core/note-creation";
|
|
9
10
|
import type { NotePresetId } from "../core/note-presets";
|
|
10
11
|
import type { DocumentSection } from "../core/sections";
|
|
@@ -130,6 +131,10 @@ export interface GnoCreateNoteResult {
|
|
|
130
131
|
createdWithSuffix?: boolean;
|
|
131
132
|
}
|
|
132
133
|
|
|
134
|
+
export interface GnoCaptureOptions extends Omit<CaptureInput, "overwrite"> {}
|
|
135
|
+
|
|
136
|
+
export type GnoCaptureResult = CaptureReceipt;
|
|
137
|
+
|
|
133
138
|
export interface GnoCreateFolderOptions {
|
|
134
139
|
collection: string;
|
|
135
140
|
name: string;
|
|
@@ -194,6 +199,7 @@ export interface GnoClient {
|
|
|
194
199
|
update(options?: GnoUpdateOptions): Promise<SyncResult>;
|
|
195
200
|
embed(options?: GnoEmbedOptions): Promise<GnoEmbedResult>;
|
|
196
201
|
index(options?: GnoIndexOptions): Promise<GnoIndexResult>;
|
|
202
|
+
capture(options: GnoCaptureOptions): Promise<GnoCaptureResult>;
|
|
197
203
|
createNote(options: GnoCreateNoteOptions): Promise<GnoCreateNoteResult>;
|
|
198
204
|
createFolder(options: GnoCreateFolderOptions): Promise<GnoCreateFolderResult>;
|
|
199
205
|
renameNote(options: GnoRenameNoteOptions): Promise<GnoRefactorNoteResult>;
|