@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.
- package/README.md +6 -1
- package/assets/skill/SKILL.md +29 -1
- package/assets/skill/cli-reference.md +28 -2
- package/assets/skill/examples.md +16 -0
- package/assets/skill/mcp-reference.md +17 -3
- 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 +67 -1
- 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 +40 -1
- package/src/mcp/tools/query.ts +2 -0
- package/src/pipeline/hybrid.ts +1 -1
- package/src/pipeline/types.ts +3 -1
- 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 +191 -3
- package/src/serve/server.ts +12 -0
package/README.md
CHANGED
|
@@ -92,9 +92,12 @@ gno daemon --detach # headless continuous indexing (background; --status / --st
|
|
|
92
92
|
|
|
93
93
|
## What's New
|
|
94
94
|
|
|
95
|
-
> Latest release: [v1.
|
|
95
|
+
> Latest release: [v1.8.0](./CHANGELOG.md#180---2026-06-05)
|
|
96
96
|
> Full release history: [CHANGELOG.md](./CHANGELOG.md)
|
|
97
97
|
|
|
98
|
+
- **Second-brain capture**: `gno capture`, REST `/api/capture`, SDK
|
|
99
|
+
`client.capture()`, MCP `gno_capture`, and Web UI Quick Capture write
|
|
100
|
+
provenance-rich notes from text, stdin, or files
|
|
98
101
|
- **Publish to [gno.sh](https://gno.sh/publish)**: new `gno publish export` CLI and Web UI action produce a self-contained artifact you upload to the hosted reader — public, secret, invite-only, or locally encrypted before upload
|
|
99
102
|
- **Retrieval Quality Upgrade**: stronger BM25 lexical handling, code-aware chunking, terminal result hyperlinks, and per-collection model overrides
|
|
100
103
|
- **Code Embedding Benchmarks**: new benchmark workflow across canonical, real-GNO, and pinned OSS slices for comparing alternate embedding models
|
|
@@ -543,6 +546,8 @@ Open `http://localhost:3000` to:
|
|
|
543
546
|
- **Browse**: Cross-collection tree workspace with folder detail panes and per-tab browse context
|
|
544
547
|
- **Edit**: Create, edit, and delete documents with live preview
|
|
545
548
|
- **Create in place**: New notes in the current folder/collection with presets and command-palette flows
|
|
549
|
+
- **Capture with provenance**: `gno capture` and Web UI Quick Capture write quick notes to an editable collection with structured `source:` metadata and a receipt that separates write, sync, and embed state
|
|
550
|
+
- **Same capture contract everywhere**: CLI, MCP `gno_capture`, REST `/api/capture`, SDK `client.capture()`, and Web UI Quick Capture return the same provenance receipt shape
|
|
546
551
|
- **Ask**: AI-powered Q&A with citations
|
|
547
552
|
- **Manage Collections**: Add, remove, and re-index collections
|
|
548
553
|
- **Connect agents**: Install core Skill/MCP integrations from the app
|
package/assets/skill/SKILL.md
CHANGED
|
@@ -136,7 +136,7 @@ gno search "error handling" --json | jq -r '.results[].uri' | xargs gno multi-ge
|
|
|
136
136
|
When using GNO through MCP, prefer this retrieval order:
|
|
137
137
|
|
|
138
138
|
1. Check `gno_status` first when freshness, missing vectors, or stale results are plausible.
|
|
139
|
-
2. Use `gno_query` first for normal content questions. It returns snippets plus `uri`, `docid`, and often `line`;
|
|
139
|
+
2. Use `gno_query` first for normal content questions. It returns snippets plus `uri`, `docid`, and often `line`; pass `graph: true` only when linked context is worth the extra latency.
|
|
140
140
|
3. Use graph/link expansion for relationship context: `gno_graph_neighbors` for nearby documents, `gno_graph_path` for "how are X and Y connected?", `gno_links`/`gno_backlinks` for one-document link expansion, and `gno_similar` for semantic neighbors. Prefer `explicit` graph edges over `inferred`, `ambiguous`, or `similarity` edges when confidence matters.
|
|
141
141
|
4. Use `gno_get` with `fromLine`/`lineCount` for targeted reads, or `gno_multi_get` to batch top refs.
|
|
142
142
|
|
|
@@ -207,6 +207,34 @@ gno embed --collection travel
|
|
|
207
207
|
|
|
208
208
|
MCP `gno.sync` and `gno.capture` do NOT auto-embed. Use CLI for embedding.
|
|
209
209
|
|
|
210
|
+
## Capture Notes
|
|
211
|
+
|
|
212
|
+
Use `gno capture` for quick second-brain writes into an editable collection:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
gno capture "thought to remember"
|
|
216
|
+
gno capture --file ./clip.md --source-url https://example.com --source-kind web --json
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The JSON receipt reports write, sync, and embed status separately. Generated
|
|
220
|
+
captures land under `inbox/YYYY-MM-DD/capture-<body-hash>.md` unless `--path`,
|
|
221
|
+
`--folder`, or `--title` overrides the path. Capture does not imply embedding
|
|
222
|
+
unless `embed.status` is `completed`. Capture inputs must be text; binary-like
|
|
223
|
+
file/stdin content is rejected before writing. CLI, REST, SDK, and Web capture
|
|
224
|
+
writes fail instead of replacing late-arriving files; legacy `overwrite` is
|
|
225
|
+
MCP-only.
|
|
226
|
+
|
|
227
|
+
Programmatic capture uses the same receipt contract:
|
|
228
|
+
|
|
229
|
+
- MCP: `gno_capture` (requires `gno mcp --enable-write`)
|
|
230
|
+
- REST: `POST /api/capture`
|
|
231
|
+
- SDK: `client.capture({ collection, content, source, tags })`
|
|
232
|
+
|
|
233
|
+
MCP capture writes structured `source:` frontmatter, runs under the MCP write
|
|
234
|
+
lock, syncs the file for FTS, and preserves legacy MCP fields (`docid`,
|
|
235
|
+
`absPath`, `overwritten`, `serverInstanceId`) alongside the shared receipt. It
|
|
236
|
+
does not auto-embed.
|
|
237
|
+
|
|
210
238
|
## Collection-specific embedding models
|
|
211
239
|
|
|
212
240
|
Collections can override the global embedding model with `models.embed`.
|
|
@@ -111,6 +111,31 @@ Generate embeddings only.
|
|
|
111
111
|
gno embed [--force] [--model <uri>] [--batch-size <n>] [--dry-run]
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
+
## Capture
|
|
115
|
+
|
|
116
|
+
### gno capture
|
|
117
|
+
|
|
118
|
+
Capture a note into an editable collection with provenance.
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
gno capture "thought to remember"
|
|
122
|
+
gno capture --stdin --collection notes --preset source-summary --tags inbox,gno
|
|
123
|
+
gno capture --file ./clip.md --source-url https://example.com --source-kind web --json
|
|
124
|
+
gno capture "meeting note" --quiet
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Important behavior:
|
|
128
|
+
|
|
129
|
+
- Inline content, `--stdin`, and `--file` are mutually exclusive.
|
|
130
|
+
- Capture accepts text only; binary-like file/stdin content is rejected before
|
|
131
|
+
writing.
|
|
132
|
+
- Without `--path`, `--folder`, or `--title`, captures use
|
|
133
|
+
`inbox/YYYY-MM-DD/capture-<body-hash>.md` in UTC.
|
|
134
|
+
- Capture writes fail instead of replacing a late-arriving file.
|
|
135
|
+
- `--json` returns a capture receipt with separate write, sync, and embed status.
|
|
136
|
+
- Capture syncs the file into FTS but does not imply embedding unless
|
|
137
|
+
`embed.status` is `completed`.
|
|
138
|
+
|
|
114
139
|
## Search Commands
|
|
115
140
|
|
|
116
141
|
### gno search
|
|
@@ -154,7 +179,7 @@ gno query <query> [options]
|
|
|
154
179
|
|
|
155
180
|
| Flag | Time | Description |
|
|
156
181
|
| ------------ | ----- | ------------------------------ |
|
|
157
|
-
| `--fast` | ~0.7s | Skip expansion
|
|
182
|
+
| `--fast` | ~0.7s | Skip expansion and rerank |
|
|
158
183
|
| (default) | ~2-3s | Skip expansion, with reranking |
|
|
159
184
|
| `--thorough` | ~5-8s | Full pipeline with expansion |
|
|
160
185
|
|
|
@@ -164,7 +189,8 @@ Additional options:
|
|
|
164
189
|
| ------------- | --------------------------------- |
|
|
165
190
|
| `--no-expand` | Disable query expansion |
|
|
166
191
|
| `--no-rerank` | Disable reranking |
|
|
167
|
-
| `--
|
|
192
|
+
| `--graph` | Enable graph-neighbor candidates |
|
|
193
|
+
| `--no-graph` | Compatibility no-op by default |
|
|
168
194
|
| `--explain` | Print retrieval details to stderr |
|
|
169
195
|
|
|
170
196
|
### gno ask
|
package/assets/skill/examples.md
CHANGED
|
@@ -349,3 +349,19 @@ gno search "common term" --min-score 0.7
|
|
|
349
349
|
# Get full content
|
|
350
350
|
gno search "term" --full
|
|
351
351
|
```
|
|
352
|
+
|
|
353
|
+
## Capture Notes
|
|
354
|
+
|
|
355
|
+
### CLI capture with provenance
|
|
356
|
+
|
|
357
|
+
```bash
|
|
358
|
+
gno capture "thought to remember"
|
|
359
|
+
gno capture --file ./clip.md --source-url https://example.com --source-kind web --json
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### Web UI quick capture
|
|
363
|
+
|
|
364
|
+
Press **N** in `gno serve`, write the note, and open **Source** only when you
|
|
365
|
+
need provenance fields such as URL, author, observed date, or external id. The
|
|
366
|
+
success view reports the write result, FTS sync state, and embed state
|
|
367
|
+
separately.
|
|
@@ -56,9 +56,8 @@ gno mcp status
|
|
|
56
56
|
## Retrieval Order
|
|
57
57
|
|
|
58
58
|
For normal questions, start with `gno_query`, then read targeted snippets with
|
|
59
|
-
`gno_get` or batch refs with `gno_multi_get`. `
|
|
60
|
-
|
|
61
|
-
pure BM25/vector retrieval. Check `gno_status` first when freshness or
|
|
59
|
+
`gno_get` or batch refs with `gno_multi_get`. Pass `graph: true` only when
|
|
60
|
+
linked context is worth the extra latency. Check `gno_status` first when freshness or
|
|
62
61
|
embeddings may be stale.
|
|
63
62
|
|
|
64
63
|
Use graph tools for relationship context: `gno_graph` for corpus report/stats,
|
|
@@ -69,6 +68,21 @@ community summaries,
|
|
|
69
68
|
Graph edges include confidence/audit metadata; prefer `explicit` edges when
|
|
70
69
|
answers depend on link certainty.
|
|
71
70
|
|
|
71
|
+
## Capture
|
|
72
|
+
|
|
73
|
+
`gno_capture` is available only when MCP starts with `--enable-write` or
|
|
74
|
+
`GNO_MCP_ENABLE_WRITE=1`. It writes quick notes with structured `source:`
|
|
75
|
+
frontmatter and returns the same provenance receipt shape as CLI, REST, and SDK
|
|
76
|
+
capture, plus legacy MCP fields (`docid`, `absPath`, `overwritten`,
|
|
77
|
+
`serverInstanceId`).
|
|
78
|
+
|
|
79
|
+
Use `collisionPolicy: "open_existing"` to return an existing note without
|
|
80
|
+
rewriting, `create_with_suffix` to create the next available path, or legacy
|
|
81
|
+
`overwrite: true` to replace the target path. Capture content must be text, and
|
|
82
|
+
non-overwrite captures fail instead of replacing a late-arriving file. MCP
|
|
83
|
+
capture syncs the file for FTS but does not auto-embed; run `gno_embed` or
|
|
84
|
+
`gno_index` afterward when vector search should include it.
|
|
85
|
+
|
|
72
86
|
## Uninstall
|
|
73
87
|
|
|
74
88
|
```bash
|
package/package.json
CHANGED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gno capture command implementation.
|
|
3
|
+
*
|
|
4
|
+
* @module src/cli/commands/capture
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// node:fs/promises for mkdir (no Bun equivalent for recursive dir creation)
|
|
8
|
+
import { mkdir } from "node:fs/promises";
|
|
9
|
+
// node:path has no Bun path utilities
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
CapturePlan,
|
|
14
|
+
CaptureReceipt,
|
|
15
|
+
CaptureSourceKind,
|
|
16
|
+
} from "../../core/capture";
|
|
17
|
+
import type { NoteCollisionPolicy } from "../../core/note-creation";
|
|
18
|
+
import type { NotePresetId } from "../../core/note-presets";
|
|
19
|
+
|
|
20
|
+
import { getIndexDbPath } from "../../app/constants";
|
|
21
|
+
import {
|
|
22
|
+
buildCaptureReceipt,
|
|
23
|
+
listCaptureDiskRelPaths,
|
|
24
|
+
planCapture,
|
|
25
|
+
serializeCaptureReceipt,
|
|
26
|
+
} from "../../core/capture";
|
|
27
|
+
import { writeCapturePlanFile } from "../../core/capture-write";
|
|
28
|
+
import { withWriteLock } from "../../core/file-lock";
|
|
29
|
+
import { defaultSyncService } from "../../ingestion";
|
|
30
|
+
import { CliError } from "../errors";
|
|
31
|
+
import { initStore } from "./shared";
|
|
32
|
+
|
|
33
|
+
export interface CaptureCliOptions {
|
|
34
|
+
configPath?: string;
|
|
35
|
+
indexName?: string;
|
|
36
|
+
inlineContent?: string;
|
|
37
|
+
stdin?: boolean;
|
|
38
|
+
file?: string;
|
|
39
|
+
collection?: string;
|
|
40
|
+
title?: string;
|
|
41
|
+
path?: string;
|
|
42
|
+
folder?: string;
|
|
43
|
+
preset?: NotePresetId;
|
|
44
|
+
tags?: string;
|
|
45
|
+
collisionPolicy?: NoteCollisionPolicy;
|
|
46
|
+
sourceKind?: CaptureSourceKind;
|
|
47
|
+
sourceUrl?: string;
|
|
48
|
+
sourceTitle?: string;
|
|
49
|
+
sourceAuthor?: string;
|
|
50
|
+
sourceDate?: string;
|
|
51
|
+
sourceId?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const SOURCE_KINDS = new Set<CaptureSourceKind>([
|
|
55
|
+
"direct",
|
|
56
|
+
"web",
|
|
57
|
+
"email",
|
|
58
|
+
"meeting",
|
|
59
|
+
"chat",
|
|
60
|
+
"file",
|
|
61
|
+
"api",
|
|
62
|
+
"unknown",
|
|
63
|
+
]);
|
|
64
|
+
const COLLISION_POLICIES = new Set<NoteCollisionPolicy>([
|
|
65
|
+
"error",
|
|
66
|
+
"open_existing",
|
|
67
|
+
"create_with_suffix",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
function parseTags(raw: string | undefined): string[] {
|
|
71
|
+
return (
|
|
72
|
+
raw
|
|
73
|
+
?.split(",")
|
|
74
|
+
.map((tag) => tag.trim())
|
|
75
|
+
.filter((tag) => tag.length > 0) ?? []
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function readContent(
|
|
80
|
+
options: CaptureCliOptions
|
|
81
|
+
): Promise<string | undefined> {
|
|
82
|
+
const sources = [
|
|
83
|
+
options.inlineContent?.trim().length ? "inline" : null,
|
|
84
|
+
options.stdin ? "stdin" : null,
|
|
85
|
+
options.file ? "file" : null,
|
|
86
|
+
].filter((source) => source !== null);
|
|
87
|
+
if (sources.length > 1) {
|
|
88
|
+
throw new CliError(
|
|
89
|
+
"VALIDATION",
|
|
90
|
+
"Use only one content source: inline, --stdin, or --file."
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (options.stdin) {
|
|
95
|
+
return await Bun.stdin.text();
|
|
96
|
+
}
|
|
97
|
+
if (options.file) {
|
|
98
|
+
return await Bun.file(options.file).text();
|
|
99
|
+
}
|
|
100
|
+
return options.inlineContent;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildSource(options: CaptureCliOptions) {
|
|
104
|
+
if (options.sourceKind && !SOURCE_KINDS.has(options.sourceKind)) {
|
|
105
|
+
throw new CliError(
|
|
106
|
+
"VALIDATION",
|
|
107
|
+
"--source-kind must be one of: direct, web, email, meeting, chat, file, api, unknown"
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
kind: options.sourceKind,
|
|
112
|
+
url: options.sourceUrl,
|
|
113
|
+
title: options.sourceTitle,
|
|
114
|
+
author: options.sourceAuthor,
|
|
115
|
+
observedAt: options.sourceDate,
|
|
116
|
+
externalId: options.sourceId,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function validateCollisionPolicy(
|
|
121
|
+
policy: NoteCollisionPolicy | undefined
|
|
122
|
+
): void {
|
|
123
|
+
if (policy && !COLLISION_POLICIES.has(policy)) {
|
|
124
|
+
throw new CliError(
|
|
125
|
+
"VALIDATION",
|
|
126
|
+
"--collision-policy must be one of: error, open_existing, create_with_suffix"
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function capture(
|
|
132
|
+
options: CaptureCliOptions
|
|
133
|
+
): Promise<CaptureReceipt> {
|
|
134
|
+
const storeInit = await initStore({
|
|
135
|
+
configPath: options.configPath,
|
|
136
|
+
indexName: options.indexName,
|
|
137
|
+
syncConfig: true,
|
|
138
|
+
});
|
|
139
|
+
if (!storeInit.ok) {
|
|
140
|
+
throw new CliError("VALIDATION", storeInit.error);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const { store, collections } = storeInit;
|
|
144
|
+
try {
|
|
145
|
+
const collectionName = options.collection?.trim();
|
|
146
|
+
const collection = collectionName
|
|
147
|
+
? collections.find(
|
|
148
|
+
(candidate) =>
|
|
149
|
+
candidate.name.toLowerCase() === collectionName.toLowerCase()
|
|
150
|
+
)
|
|
151
|
+
: collections[0];
|
|
152
|
+
if (!collection) {
|
|
153
|
+
throw new CliError(
|
|
154
|
+
"VALIDATION",
|
|
155
|
+
collectionName
|
|
156
|
+
? `Collection not found: ${collectionName}`
|
|
157
|
+
: "No editable collection configured."
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
validateCollisionPolicy(options.collisionPolicy);
|
|
162
|
+
const content = await readContent(options);
|
|
163
|
+
const existingDocs = await store.listDocuments(collection.name);
|
|
164
|
+
if (!existingDocs.ok) {
|
|
165
|
+
throw new Error(existingDocs.error.message);
|
|
166
|
+
}
|
|
167
|
+
const diskRelPaths = await listCaptureDiskRelPaths(collection.path);
|
|
168
|
+
let plan: CapturePlan;
|
|
169
|
+
try {
|
|
170
|
+
plan = planCapture({
|
|
171
|
+
input: {
|
|
172
|
+
collection: collection.name,
|
|
173
|
+
content,
|
|
174
|
+
title: options.title,
|
|
175
|
+
relPath: options.path,
|
|
176
|
+
folderPath: options.folder,
|
|
177
|
+
collisionPolicy: options.collisionPolicy,
|
|
178
|
+
presetId: options.preset,
|
|
179
|
+
tags: parseTags(options.tags),
|
|
180
|
+
source: buildSource(options),
|
|
181
|
+
},
|
|
182
|
+
existingRelPaths: existingDocs.value.map((doc) => doc.relPath),
|
|
183
|
+
diskRelPaths,
|
|
184
|
+
});
|
|
185
|
+
} catch (error) {
|
|
186
|
+
throw new CliError(
|
|
187
|
+
"VALIDATION",
|
|
188
|
+
error instanceof Error ? error.message : String(error)
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const absPath = join(collection.path, plan.relPath);
|
|
192
|
+
|
|
193
|
+
const lockPath = join(
|
|
194
|
+
dirname(getIndexDbPath(options.indexName)),
|
|
195
|
+
".mcp-write.lock"
|
|
196
|
+
);
|
|
197
|
+
return await withWriteLock(lockPath, async () => {
|
|
198
|
+
if (plan.openedExisting) {
|
|
199
|
+
const existingDoc = await store.getDocument(
|
|
200
|
+
collection.name,
|
|
201
|
+
plan.relPath
|
|
202
|
+
);
|
|
203
|
+
if (!existingDoc.ok) {
|
|
204
|
+
throw new Error(existingDoc.error.message);
|
|
205
|
+
}
|
|
206
|
+
return buildCaptureReceipt({
|
|
207
|
+
plan,
|
|
208
|
+
absPath,
|
|
209
|
+
docid: existingDoc.value?.docid,
|
|
210
|
+
sync: existingDoc.value
|
|
211
|
+
? { status: "completed" }
|
|
212
|
+
: {
|
|
213
|
+
status: "skipped",
|
|
214
|
+
reason: "Existing file is not indexed yet.",
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
220
|
+
await writeCapturePlanFile(plan, absPath);
|
|
221
|
+
const syncResults = await defaultSyncService.syncFiles(
|
|
222
|
+
collection,
|
|
223
|
+
store,
|
|
224
|
+
[plan.relPath],
|
|
225
|
+
{
|
|
226
|
+
runUpdateCmd: false,
|
|
227
|
+
gitPull: false,
|
|
228
|
+
}
|
|
229
|
+
);
|
|
230
|
+
const syncResult = syncResults[0];
|
|
231
|
+
const docResult = await store.getDocument(collection.name, plan.relPath);
|
|
232
|
+
const docid = docResult.ok ? docResult.value?.docid : undefined;
|
|
233
|
+
return buildCaptureReceipt({
|
|
234
|
+
plan,
|
|
235
|
+
absPath,
|
|
236
|
+
docid: syncResult?.docid ?? docid,
|
|
237
|
+
sync:
|
|
238
|
+
syncResult?.status === "error"
|
|
239
|
+
? {
|
|
240
|
+
status: "failed",
|
|
241
|
+
error:
|
|
242
|
+
syncResult.errorMessage ??
|
|
243
|
+
syncResult.errorCode ??
|
|
244
|
+
"Unknown sync error",
|
|
245
|
+
}
|
|
246
|
+
: { status: "completed" },
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
} finally {
|
|
250
|
+
await store.close();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function formatCaptureReceipt(
|
|
255
|
+
receipt: CaptureReceipt,
|
|
256
|
+
options: { json?: boolean; quiet?: boolean } = {}
|
|
257
|
+
): string {
|
|
258
|
+
if (options.json) {
|
|
259
|
+
return serializeCaptureReceipt(receipt);
|
|
260
|
+
}
|
|
261
|
+
if (options.quiet) {
|
|
262
|
+
return receipt.uri;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const lines = [
|
|
266
|
+
receipt.openedExisting ? "Opened existing capture." : "Captured note.",
|
|
267
|
+
`URI: ${receipt.uri}`,
|
|
268
|
+
`Path: ${receipt.absPath ?? receipt.relPath}`,
|
|
269
|
+
`Sync: ${receipt.sync.status}`,
|
|
270
|
+
`Embed: ${receipt.embed.status}`,
|
|
271
|
+
];
|
|
272
|
+
if (receipt.tags.length > 0) {
|
|
273
|
+
lines.push(`Tags: ${receipt.tags.join(", ")}`);
|
|
274
|
+
}
|
|
275
|
+
if (receipt.source.url) {
|
|
276
|
+
lines.push(`Source: ${receipt.source.url}`);
|
|
277
|
+
}
|
|
278
|
+
return lines.join("\n");
|
|
279
|
+
}
|
package/src/cli/options.ts
CHANGED
|
@@ -38,6 +38,7 @@ export const CMD = {
|
|
|
38
38
|
backlinks: "backlinks",
|
|
39
39
|
similar: "similar",
|
|
40
40
|
graph: "graph",
|
|
41
|
+
capture: "capture",
|
|
41
42
|
} as const;
|
|
42
43
|
|
|
43
44
|
export type CommandId = (typeof CMD)[keyof typeof CMD];
|
|
@@ -63,6 +64,7 @@ const FORMAT_SUPPORT: Record<CommandId, OutputFormat[]> = {
|
|
|
63
64
|
[CMD.similar]: ["terminal", "json", "md"],
|
|
64
65
|
// graph uses custom --dot/--mermaid flags (not OutputFormat) and writes via terminal output
|
|
65
66
|
[CMD.graph]: ["json", "terminal"],
|
|
67
|
+
[CMD.capture]: ["terminal", "json"],
|
|
66
68
|
};
|
|
67
69
|
|
|
68
70
|
// ─────────────────────────────────────────────────────────────────────────────
|
package/src/cli/program.ts
CHANGED
|
@@ -247,6 +247,7 @@ export function createProgram(): Command {
|
|
|
247
247
|
// Wire command groups
|
|
248
248
|
wireSearchCommands(program);
|
|
249
249
|
wireOnboardingCommands(program);
|
|
250
|
+
wireCaptureCommand(program);
|
|
250
251
|
wireManagementCommands(program);
|
|
251
252
|
wirePublishCommand(program);
|
|
252
253
|
wireVecCommands(program);
|
|
@@ -539,7 +540,8 @@ function wireSearchCommands(program: Command): void {
|
|
|
539
540
|
)
|
|
540
541
|
.option("--no-expand", "disable query expansion")
|
|
541
542
|
.option("--no-rerank", "disable reranking")
|
|
542
|
-
.option("--
|
|
543
|
+
.option("--graph", "enable graph neighbor expansion")
|
|
544
|
+
.option("--no-graph", "compatibility no-op; graph is off by default")
|
|
543
545
|
.option(
|
|
544
546
|
"--query-mode <mode:text>",
|
|
545
547
|
"structured mode entry (repeatable): term:<text>, intent:<text>, or hyde:<text>",
|
|
@@ -656,6 +658,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
656
658
|
lineNumbers: Boolean(cmdOpts.lineNumbers),
|
|
657
659
|
noExpand: depthPolicy.noExpand,
|
|
658
660
|
noRerank: depthPolicy.noRerank,
|
|
661
|
+
graph: Boolean(cmdOpts.graph),
|
|
659
662
|
noGraph: Boolean(cmdOpts.fast) || cmdOpts.graph === false,
|
|
660
663
|
candidateLimit: depthPolicy.candidateLimit,
|
|
661
664
|
queryModes,
|
|
@@ -1007,6 +1010,69 @@ function wireOnboardingCommands(program: Command): void {
|
|
|
1007
1010
|
});
|
|
1008
1011
|
}
|
|
1009
1012
|
|
|
1013
|
+
function wireCaptureCommand(program: Command): void {
|
|
1014
|
+
program
|
|
1015
|
+
.command("capture [content...]")
|
|
1016
|
+
.description("Capture a note with provenance")
|
|
1017
|
+
.option("--stdin", "read capture content from stdin")
|
|
1018
|
+
.option("--file <path>", "read capture content from a text/markdown file")
|
|
1019
|
+
.option("-c, --collection <name>", "target collection")
|
|
1020
|
+
.option("--title <title>", "capture title")
|
|
1021
|
+
.option("--path <relPath>", "explicit relative path inside collection")
|
|
1022
|
+
.option("--folder <relPath>", "folder path inside collection")
|
|
1023
|
+
.option("--preset <id>", "note preset scaffold")
|
|
1024
|
+
.option("--tags <tags>", "comma-separated tags")
|
|
1025
|
+
.option(
|
|
1026
|
+
"--collision-policy <policy>",
|
|
1027
|
+
"error, open_existing, or create_with_suffix"
|
|
1028
|
+
)
|
|
1029
|
+
.option(
|
|
1030
|
+
"--source-kind <kind>",
|
|
1031
|
+
"direct, web, email, meeting, chat, file, api, or unknown"
|
|
1032
|
+
)
|
|
1033
|
+
.option("--source-url <url>", "source URL")
|
|
1034
|
+
.option("--source-title <title>", "source title")
|
|
1035
|
+
.option("--source-author <author>", "source author")
|
|
1036
|
+
.option("--source-date <date>", "source observed date/time")
|
|
1037
|
+
.option("--source-id <id>", "source external id")
|
|
1038
|
+
.option("--json", "JSON output")
|
|
1039
|
+
.action(
|
|
1040
|
+
async (contentParts: string[], cmdOpts: Record<string, unknown>) => {
|
|
1041
|
+
const format = getFormat(cmdOpts);
|
|
1042
|
+
assertFormatSupported(CMD.capture, format);
|
|
1043
|
+
const globals = getGlobals();
|
|
1044
|
+
const { capture, formatCaptureReceipt } =
|
|
1045
|
+
await import("./commands/capture");
|
|
1046
|
+
const receipt = await capture({
|
|
1047
|
+
configPath: globals.config,
|
|
1048
|
+
indexName: globals.index,
|
|
1049
|
+
inlineContent:
|
|
1050
|
+
contentParts.length > 0 ? contentParts.join(" ") : undefined,
|
|
1051
|
+
stdin: Boolean(cmdOpts.stdin),
|
|
1052
|
+
file: cmdOpts.file as string | undefined,
|
|
1053
|
+
collection: cmdOpts.collection as string | undefined,
|
|
1054
|
+
title: cmdOpts.title as string | undefined,
|
|
1055
|
+
path: cmdOpts.path as string | undefined,
|
|
1056
|
+
folder: cmdOpts.folder as string | undefined,
|
|
1057
|
+
preset: cmdOpts.preset as never,
|
|
1058
|
+
tags: cmdOpts.tags as string | undefined,
|
|
1059
|
+
collisionPolicy: cmdOpts.collisionPolicy as never,
|
|
1060
|
+
sourceKind: cmdOpts.sourceKind as never,
|
|
1061
|
+
sourceUrl: cmdOpts.sourceUrl as string | undefined,
|
|
1062
|
+
sourceTitle: cmdOpts.sourceTitle as string | undefined,
|
|
1063
|
+
sourceAuthor: cmdOpts.sourceAuthor as string | undefined,
|
|
1064
|
+
sourceDate: cmdOpts.sourceDate as string | undefined,
|
|
1065
|
+
sourceId: cmdOpts.sourceId as string | undefined,
|
|
1066
|
+
});
|
|
1067
|
+
const output = formatCaptureReceipt(receipt, {
|
|
1068
|
+
json: format === "json",
|
|
1069
|
+
quiet: globals.quiet,
|
|
1070
|
+
});
|
|
1071
|
+
await writeOutput(output, format);
|
|
1072
|
+
}
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1010
1076
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1011
1077
|
// Retrieval Commands (get, multi-get, ls)
|
|
1012
1078
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared capture write semantics.
|
|
3
|
+
*
|
|
4
|
+
* @module src/core/capture-write
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CapturePlan } from "./capture";
|
|
8
|
+
|
|
9
|
+
import { atomicCreate, atomicWrite } from "./file-ops";
|
|
10
|
+
|
|
11
|
+
function isFileExistsError(error: unknown): boolean {
|
|
12
|
+
return (
|
|
13
|
+
error instanceof Error &&
|
|
14
|
+
"code" in error &&
|
|
15
|
+
typeof error.code === "string" &&
|
|
16
|
+
error.code === "EEXIST"
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function writeCapturePlanFile(
|
|
21
|
+
plan: CapturePlan,
|
|
22
|
+
absPath: string
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
try {
|
|
25
|
+
if (plan.overwrite) {
|
|
26
|
+
await atomicWrite(absPath, plan.content);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
await atomicCreate(absPath, plan.content);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (isFileExistsError(error)) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
"File already exists. Use open_existing, create_with_suffix, or overwrite."
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|