@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.
- package/assets/skill/SKILL.md +12 -0
- package/assets/skill/mcp-reference.md +29 -188
- package/package.json +1 -1
- package/src/cli/commands/embed.ts +1 -2
- package/src/core/job-manager.ts +94 -1
- package/src/embed/backlog.ts +126 -0
- package/src/embed/index.ts +11 -0
- package/src/mcp/tools/embed.ts +151 -0
- package/src/mcp/tools/index-cmd.ts +219 -0
- package/src/mcp/tools/index.ts +61 -0
- package/src/mcp/tools/job-status.ts +34 -4
- package/src/mcp/tools/list-jobs.ts +3 -2
- package/src/serve/embed-scheduler.ts +263 -0
- package/src/serve/public/pages/DocumentEditor.tsx +32 -7
- package/src/serve/routes/api.ts +84 -1
- package/src/serve/server.ts +33 -1
- package/src/store/vector/types.ts +1 -1
package/assets/skill/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
56
|
+
## Uninstall
|
|
202
57
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
@@ -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);
|
package/src/core/job-manager.ts
CHANGED
|
@@ -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,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
|
+
}
|