@gmickel/gno 0.10.4 → 0.11.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.
@@ -83,6 +83,18 @@ gno search "your query" # BM25 keyword search
83
83
  --no-pager Disable paging
84
84
  ```
85
85
 
86
+ ## Important: Embedding After Changes
87
+
88
+ If you edit/create files that should be searchable via vector search:
89
+
90
+ ```bash
91
+ gno index # Full re-index (sync + embed)
92
+ # or
93
+ gno embed # Embed only (if already synced)
94
+ ```
95
+
96
+ MCP `gno.sync` and `gno.capture` do NOT auto-embed. Use CLI for embedding.
97
+
86
98
  ## Reference Documentation
87
99
 
88
100
  | Topic | File |
@@ -1,8 +1,23 @@
1
- # GNO MCP Reference
1
+ # GNO MCP Installation
2
2
 
3
- GNO provides an MCP (Model Context Protocol) server for AI integration.
3
+ GNO provides an MCP (Model Context Protocol) server for AI client integration.
4
4
 
5
- ## Setup
5
+ > **Full reference**: See [gno.sh/docs/MCP](https://www.gno.sh/docs/MCP) for complete tool documentation.
6
+
7
+ ## Quick Install
8
+
9
+ ```bash
10
+ # Claude Desktop (default)
11
+ gno mcp install
12
+
13
+ # Claude Code
14
+ gno mcp install -t claude-code
15
+
16
+ # With write tools enabled
17
+ gno mcp install --enable-write
18
+ ```
19
+
20
+ ## Manual Setup
6
21
 
7
22
  ### Claude Desktop
8
23
 
@@ -19,202 +34,28 @@ Add to `claude_desktop_config.json`:
19
34
  }
20
35
  ```
21
36
 
22
- Config location:
37
+ Config locations:
23
38
 
24
39
  - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
25
40
  - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
26
41
  - Linux: `~/.config/Claude/claude_desktop_config.json`
27
42
 
28
- ### Start Server
43
+ ### Claude Code
29
44
 
30
45
  ```bash
31
- gno mcp
32
- ```
33
-
34
- Runs JSON-RPC 2.0 over stdio.
35
-
36
- ## Tools
37
-
38
- ### gno.search
39
-
40
- BM25 keyword search.
41
-
42
- ```json
43
- {
44
- "query": "search terms",
45
- "collection": "optional-collection",
46
- "limit": 5,
47
- "minScore": 0.5,
48
- "lang": "en",
49
- "tagsAny": ["project", "work"],
50
- "tagsAll": ["reviewed"]
51
- }
52
- ```
53
-
54
- | Parameter | Description |
55
- | ------------ | ----------------------------------- |
56
- | `query` | Search query (required) |
57
- | `collection` | Filter by collection |
58
- | `limit` | Max results (default: 5) |
59
- | `minScore` | Minimum score 0-1 |
60
- | `tagsAny` | Filter: has ANY of these tags (OR) |
61
- | `tagsAll` | Filter: has ALL of these tags (AND) |
62
-
63
- ### gno.vsearch
64
-
65
- Vector semantic search. Same parameters as `gno.search`.
66
-
67
- ### gno.query
68
-
69
- Hybrid search (best quality).
70
-
71
- ```json
72
- {
73
- "query": "search terms",
74
- "collection": "optional-collection",
75
- "limit": 5
76
- }
77
- ```
78
-
79
- **Search modes** (via parameters):
80
-
81
- | Mode | Parameters | Time |
82
- | -------- | ---------------- | ----- |
83
- | Fast | `fast: true` | ~0.7s |
84
- | Default | (none) | ~2-3s |
85
- | Thorough | `thorough: true` | ~5-8s |
86
-
87
- Default skips expansion, with reranking. Use `thorough: true` for best recall.
88
-
89
- **Agent retry strategy**: Use default mode first. If no relevant results:
90
-
91
- 1. Rephrase the query (free, often effective)
92
- 2. Then try `thorough: true` for better recall
93
-
94
- ### gno.get
95
-
96
- Retrieve document by reference.
97
-
98
- ```json
99
- {
100
- "ref": "gno://collection/path or #docid",
101
- "fromLine": 1,
102
- "lineCount": 100,
103
- "lineNumbers": true
104
- }
105
- ```
106
-
107
- ### gno.multi_get
108
-
109
- Retrieve multiple documents.
110
-
111
- ```json
112
- {
113
- "refs": ["gno://work/doc1.md", "#a1b2c3d4"],
114
- "maxBytes": 10240,
115
- "lineNumbers": true
116
- }
117
- ```
118
-
119
- Or by pattern:
120
-
121
- ```json
122
- {
123
- "pattern": "work/**/*.md",
124
- "maxBytes": 10240
125
- }
126
- ```
127
-
128
- ### gno.status
129
-
130
- Get index status.
131
-
132
- ```json
133
- {}
134
- ```
135
-
136
- ### gno.list_tags
137
-
138
- List tags with document counts.
139
-
140
- ```json
141
- {
142
- "collection": "optional-collection",
143
- "prefix": "project/"
144
- }
145
- ```
146
-
147
- | Parameter | Description |
148
- | ------------ | --------------------------------------- |
149
- | `collection` | Filter by collection |
150
- | `prefix` | Filter by tag prefix (e.g., `project/`) |
151
-
152
- ### gno.tag
153
-
154
- Add or remove tag from document.
155
-
156
- ```json
157
- {
158
- "ref": "gno://work/readme.md",
159
- "tag": "project/api",
160
- "action": "add"
161
- }
46
+ gno mcp install -t claude-code -s user # User scope
47
+ gno mcp install -t claude-code -s project # Project scope
162
48
  ```
163
49
 
164
- | Parameter | Description |
165
- | --------- | ---------------------------------- |
166
- | `ref` | Document URI or docid (required) |
167
- | `tag` | Tag string (required) |
168
- | `action` | `add` or `remove` (default: `add`) |
50
+ ## Check Status
169
51
 
170
- ## Resources
171
-
172
- Documents accessible as MCP resources:
173
-
174
- ```
175
- gno://{collection}/{path}
176
- ```
177
-
178
- Examples:
179
-
180
- - `gno://work/contracts/nda.docx`
181
- - `gno://notes/2025/01/meeting.md`
182
-
183
- Returns Markdown content with line numbers.
184
-
185
- ## Response Format
186
-
187
- All tools return:
188
-
189
- ```json
190
- {
191
- "content": [
192
- { "type": "text", "text": "Human-readable summary" }
193
- ],
194
- "structuredContent": {
195
- "results": [...],
196
- "meta": { "query": "...", "mode": "hybrid" }
197
- }
198
- }
52
+ ```bash
53
+ gno mcp status
199
54
  ```
200
55
 
201
- ## Error Handling
56
+ ## Uninstall
202
57
 
203
- Errors return:
204
-
205
- ```json
206
- {
207
- "isError": true,
208
- "content": [
209
- { "type": "text", "text": "Error: Document not found" }
210
- ]
211
- }
58
+ ```bash
59
+ gno mcp uninstall
60
+ gno mcp uninstall -t claude-code
212
61
  ```
213
-
214
- ## Graceful Degradation
215
-
216
- `gno.query` degrades gracefully:
217
-
218
- - No vectors → BM25 only
219
- - No expansion model → skips expansion
220
- - No rerank model → skips reranking
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -161,13 +161,12 @@ async function processBatches(ctx: BatchContext): Promise<BatchResult> {
161
161
  continue;
162
162
  }
163
163
 
164
- // Store vectors
164
+ // Store vectors (embeddedAt set by DB)
165
165
  const vectors: VectorRow[] = batch.map((b, idx) => ({
166
166
  mirrorHash: b.mirrorHash,
167
167
  seq: b.seq,
168
168
  model: ctx.modelUri,
169
169
  embedding: new Float32Array(embeddings[idx] as number[]),
170
- embeddedAt: new Date().toISOString(),
171
170
  }));
172
171
 
173
172
  const storeResult = await ctx.vectorIndex.upsertVectors(vectors);
@@ -13,17 +13,38 @@ const JOB_EXPIRATION_MS = 60 * 60 * 1000;
13
13
  const JOB_MAX_RECENT = 100;
14
14
  const DEFAULT_LOCK_TIMEOUT_MS = 5000;
15
15
 
16
- export type JobType = "add" | "sync";
16
+ export type JobType = "add" | "sync" | "embed" | "index";
17
17
 
18
18
  export type JobStatus = "running" | "completed" | "failed";
19
19
 
20
+ // ─────────────────────────────────────────────────────────────────────────────
21
+ // Discriminated union for job results
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+
24
+ export interface EmbedJobResult {
25
+ embedded: number;
26
+ errors: number;
27
+ }
28
+
29
+ export interface IndexJobResult {
30
+ sync: SyncResult;
31
+ embed: EmbedJobResult;
32
+ }
33
+
34
+ export type JobResult =
35
+ | { kind: "sync"; value: SyncResult }
36
+ | { kind: "embed"; value: EmbedJobResult }
37
+ | { kind: "index"; value: IndexJobResult };
38
+
20
39
  export interface JobRecord {
21
40
  id: string;
22
41
  type: JobType;
23
42
  status: JobStatus;
24
43
  startedAt: number;
25
44
  completedAt?: number;
45
+ /** @deprecated Use typedResult for new job types */
26
46
  result?: SyncResult;
47
+ typedResult?: JobResult;
27
48
  error?: string;
28
49
  serverInstanceId: string;
29
50
  }
@@ -101,6 +122,27 @@ export class JobManager {
101
122
  return this.#startJobWithLock(type, fn, lock);
102
123
  }
103
124
 
125
+ /**
126
+ * Start a job with typed result (for embed/index jobs).
127
+ * Uses discriminated union for type-safe results.
128
+ */
129
+ async startTypedJobWithLock(
130
+ type: JobType,
131
+ lock: WriteLockHandle,
132
+ fn: () => Promise<JobResult>
133
+ ): Promise<string> {
134
+ this.#cleanupExpiredJobs();
135
+
136
+ if (this.#activeJobId) {
137
+ throw new JobError(
138
+ "JOB_CONFLICT",
139
+ `${MCP_ERRORS.JOB_CONFLICT.message} (${this.#activeJobId})`
140
+ );
141
+ }
142
+
143
+ return this.#startTypedJobWithLock(type, fn, lock);
144
+ }
145
+
104
146
  getJob(jobId: string): JobRecord | undefined {
105
147
  this.#cleanupExpiredJobs();
106
148
  return this.#jobs.get(jobId);
@@ -185,6 +227,57 @@ export class JobManager {
185
227
  return jobId;
186
228
  }
187
229
 
230
+ #startTypedJobWithLock(
231
+ type: JobType,
232
+ fn: () => Promise<JobResult>,
233
+ lock: WriteLockHandle
234
+ ): string {
235
+ const jobId = crypto.randomUUID();
236
+ const job: JobRecord = {
237
+ id: jobId,
238
+ type,
239
+ status: "running",
240
+ startedAt: Date.now(),
241
+ serverInstanceId: this.#serverInstanceId,
242
+ };
243
+
244
+ this.#jobs.set(jobId, job);
245
+ this.#activeJobId = jobId;
246
+
247
+ const jobPromise = this.#runTypedJob(job, fn, lock);
248
+ this.#track(jobPromise);
249
+
250
+ return jobId;
251
+ }
252
+
253
+ async #runTypedJob(
254
+ job: JobRecord,
255
+ fn: () => Promise<JobResult>,
256
+ lock: { release: () => Promise<void> }
257
+ ): Promise<void> {
258
+ try {
259
+ const release = await this.#toolMutex.acquire();
260
+ try {
261
+ const result = await fn();
262
+ job.status = "completed";
263
+ job.typedResult = result;
264
+ } catch (e) {
265
+ job.status = "failed";
266
+ job.error = e instanceof Error ? e.message : String(e);
267
+ } finally {
268
+ release();
269
+ }
270
+ } catch (e) {
271
+ job.status = "failed";
272
+ job.error = e instanceof Error ? e.message : String(e);
273
+ } finally {
274
+ job.completedAt = Date.now();
275
+ this.#activeJobId = null;
276
+ await lock.release().catch(() => undefined);
277
+ this.#cleanupExpiredJobs();
278
+ }
279
+ }
280
+
188
281
  #cleanupExpiredJobs(now: number = Date.now()): void {
189
282
  for (const [id, job] of this.#jobs) {
190
283
  if (job.status === "running") {
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Shared embedding backlog processor.
3
+ * Used by CLI embed, Web scheduler, and MCP tools.
4
+ *
5
+ * @module src/embed/backlog
6
+ */
7
+
8
+ import type { EmbeddingPort } from "../llm/types";
9
+ import type { StoreResult } from "../store/types";
10
+ import type {
11
+ BacklogItem,
12
+ VectorIndexPort,
13
+ VectorRow,
14
+ VectorStatsPort,
15
+ } from "../store/vector";
16
+
17
+ import { formatDocForEmbedding } from "../pipeline/contextual";
18
+ import { err, ok } from "../store/types";
19
+
20
+ // ─────────────────────────────────────────────────────────────────────────────
21
+ // Types
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+
24
+ export interface EmbedBacklogDeps {
25
+ statsPort: VectorStatsPort;
26
+ embedPort: EmbeddingPort;
27
+ vectorIndex: VectorIndexPort;
28
+ modelUri: string;
29
+ batchSize?: number;
30
+ }
31
+
32
+ export interface EmbedBacklogResult {
33
+ embedded: number;
34
+ errors: number;
35
+ }
36
+
37
+ interface Cursor {
38
+ mirrorHash: string;
39
+ seq: number;
40
+ }
41
+
42
+ // ─────────────────────────────────────────────────────────────────────────────
43
+ // Main
44
+ // ─────────────────────────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Process embedding backlog in batches.
48
+ * Cursor-based pagination, batch embedding, vector storage.
49
+ */
50
+ export async function embedBacklog(
51
+ deps: EmbedBacklogDeps
52
+ ): Promise<StoreResult<EmbedBacklogResult>> {
53
+ const { statsPort, embedPort, vectorIndex, modelUri } = deps;
54
+ const batchSize = deps.batchSize ?? 32;
55
+
56
+ let embedded = 0;
57
+ let errors = 0;
58
+ let cursor: Cursor | undefined;
59
+
60
+ try {
61
+ while (true) {
62
+ // Get next batch using seek pagination
63
+ const batchResult = await statsPort.getBacklog(modelUri, {
64
+ limit: batchSize,
65
+ after: cursor,
66
+ });
67
+
68
+ if (!batchResult.ok) {
69
+ return err("QUERY_FAILED", batchResult.error.message);
70
+ }
71
+
72
+ const batch = batchResult.value;
73
+ if (batch.length === 0) {
74
+ break;
75
+ }
76
+
77
+ // Advance cursor (even on failure, to avoid infinite loops)
78
+ const lastItem = batch.at(-1);
79
+ if (lastItem) {
80
+ cursor = { mirrorHash: lastItem.mirrorHash, seq: lastItem.seq };
81
+ }
82
+
83
+ // Embed batch with contextual formatting (title prefix)
84
+ const embedResult = await embedPort.embedBatch(
85
+ batch.map((b: BacklogItem) =>
86
+ formatDocForEmbedding(b.text, b.title ?? undefined)
87
+ )
88
+ );
89
+
90
+ if (!embedResult.ok) {
91
+ errors += batch.length;
92
+ continue;
93
+ }
94
+
95
+ // Validate batch/embedding count match
96
+ const embeddings = embedResult.value;
97
+ if (embeddings.length !== batch.length) {
98
+ errors += batch.length;
99
+ continue;
100
+ }
101
+
102
+ // Store vectors (embeddedAt set by DB)
103
+ const vectors: VectorRow[] = batch.map((b: BacklogItem, idx: number) => ({
104
+ mirrorHash: b.mirrorHash,
105
+ seq: b.seq,
106
+ model: modelUri,
107
+ embedding: new Float32Array(embeddings[idx] as number[]),
108
+ }));
109
+
110
+ const storeResult = await vectorIndex.upsertVectors(vectors);
111
+ if (!storeResult.ok) {
112
+ errors += batch.length;
113
+ continue;
114
+ }
115
+
116
+ embedded += batch.length;
117
+ }
118
+
119
+ return ok({ embedded, errors });
120
+ } catch (e) {
121
+ return err(
122
+ "INTERNAL",
123
+ `Embedding failed: ${e instanceof Error ? e.message : String(e)}`
124
+ );
125
+ }
126
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Embedding module - shared embedding utilities.
3
+ *
4
+ * @module src/embed
5
+ */
6
+
7
+ export {
8
+ embedBacklog,
9
+ type EmbedBacklogDeps,
10
+ type EmbedBacklogResult,
11
+ } from "./backlog";
@@ -0,0 +1,151 @@
1
+ /**
2
+ * MCP gno_embed tool - embed unembedded chunks.
3
+ *
4
+ * @module src/mcp/tools/embed
5
+ */
6
+
7
+ import type { ToolContext } from "../server";
8
+
9
+ import { MCP_ERRORS } from "../../core/errors";
10
+ import { acquireWriteLock, type WriteLockHandle } from "../../core/file-lock";
11
+ import { JobError } from "../../core/job-manager";
12
+ import { embedBacklog } from "../../embed";
13
+ import { LlmAdapter } from "../../llm/nodeLlamaCpp/adapter";
14
+ import { getActivePreset } from "../../llm/registry";
15
+ import {
16
+ createVectorIndexPort,
17
+ createVectorStatsPort,
18
+ } from "../../store/vector";
19
+ import { runTool, type ToolResult } from "./index";
20
+
21
+ type EmbedInput = Record<string, never>;
22
+
23
+ interface EmbedResultOutput {
24
+ jobId: string;
25
+ status: "started";
26
+ model: string;
27
+ }
28
+
29
+ function formatEmbedResult(result: EmbedResultOutput): string {
30
+ const lines: string[] = [];
31
+ lines.push(`Job: ${result.jobId}`);
32
+ lines.push(`Status: ${result.status}`);
33
+ lines.push(`Model: ${result.model}`);
34
+ return lines.join("\n");
35
+ }
36
+
37
+ export function handleEmbed(
38
+ args: EmbedInput,
39
+ ctx: ToolContext
40
+ ): Promise<ToolResult> {
41
+ return runTool(
42
+ ctx,
43
+ "gno_embed",
44
+ async () => {
45
+ if (!ctx.enableWrite) {
46
+ throw new Error("Write tools disabled. Start MCP with --enable-write.");
47
+ }
48
+
49
+ let lock: WriteLockHandle | null = null;
50
+ let handedOff = false;
51
+
52
+ try {
53
+ lock = await acquireWriteLock(ctx.writeLockPath);
54
+ if (!lock) {
55
+ throw new Error(
56
+ `${MCP_ERRORS.LOCKED.code}: ${MCP_ERRORS.LOCKED.message}`
57
+ );
58
+ }
59
+
60
+ // Get model from active preset
61
+ const preset = getActivePreset(ctx.config);
62
+ const modelUri = preset.embed;
63
+
64
+ const jobId = await ctx.jobManager.startTypedJobWithLock(
65
+ "embed",
66
+ lock,
67
+ async () => {
68
+ // Create LLM adapter with offline policy (fail-fast, no download)
69
+ const llm = new LlmAdapter(ctx.config);
70
+ const embedResult = await llm.createEmbeddingPort(modelUri, {
71
+ policy: { offline: true, allowDownload: false },
72
+ });
73
+
74
+ if (!embedResult.ok) {
75
+ throw new Error(
76
+ `MODEL_NOT_FOUND: Embedding model not cached. ` +
77
+ `Model: ${modelUri}, Preset: ${preset.name}. ` +
78
+ `Run 'gno models pull embed' first.`
79
+ );
80
+ }
81
+
82
+ const embedPort = embedResult.value;
83
+
84
+ try {
85
+ // Initialize and get dimensions from port interface
86
+ const initResult = await embedPort.init();
87
+ if (!initResult.ok) {
88
+ throw new Error(initResult.error.message);
89
+ }
90
+ const dimensions = embedPort.dimensions();
91
+
92
+ // Create vector index port
93
+ const db = ctx.store.getRawDb();
94
+ const vectorResult = await createVectorIndexPort(db, {
95
+ model: modelUri,
96
+ dimensions,
97
+ });
98
+ if (!vectorResult.ok) {
99
+ throw new Error(vectorResult.error.message);
100
+ }
101
+ const vectorIndex = vectorResult.value;
102
+
103
+ // Create stats port for backlog
104
+ const statsPort = createVectorStatsPort(db);
105
+
106
+ // Run embedding
107
+ const result = await embedBacklog({
108
+ statsPort,
109
+ embedPort,
110
+ vectorIndex,
111
+ modelUri,
112
+ batchSize: 32,
113
+ });
114
+
115
+ if (!result.ok) {
116
+ throw new Error(result.error.message);
117
+ }
118
+
119
+ return {
120
+ kind: "embed" as const,
121
+ value: result.value,
122
+ };
123
+ } finally {
124
+ await embedPort.dispose();
125
+ }
126
+ }
127
+ );
128
+
129
+ handedOff = true;
130
+
131
+ const result: EmbedResultOutput = {
132
+ jobId,
133
+ status: "started",
134
+ model: modelUri,
135
+ };
136
+
137
+ return result;
138
+ } catch (error) {
139
+ if (error instanceof JobError) {
140
+ throw new Error(`${error.code}: ${error.message}`);
141
+ }
142
+ throw error;
143
+ } finally {
144
+ if (lock && !handedOff) {
145
+ await lock.release();
146
+ }
147
+ }
148
+ },
149
+ formatEmbedResult
150
+ );
151
+ }