@gmickel/gno 1.17.0 → 1.18.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.
Files changed (51) hide show
  1. package/README.md +14 -2
  2. package/assets/skill/SKILL.md +17 -1
  3. package/assets/skill/mcp-reference.md +21 -0
  4. package/package.json +2 -2
  5. package/src/cli/commands/daemon.ts +69 -2
  6. package/src/cli/commands/models/pull.ts +13 -3
  7. package/src/cli/commands/status.ts +2 -0
  8. package/src/cli/detach.ts +37 -20
  9. package/src/cli/program.ts +74 -27
  10. package/src/config/index.ts +3 -0
  11. package/src/config/types.ts +37 -0
  12. package/src/core/job-manager.ts +19 -0
  13. package/src/core/mutation-generations.ts +33 -0
  14. package/src/llm/cache.ts +13 -3
  15. package/src/llm/nodeLlamaCpp/adapter.ts +10 -1
  16. package/src/llm/nodeLlamaCpp/lifecycle.ts +71 -0
  17. package/src/mcp/context.ts +161 -0
  18. package/src/mcp/http-security.ts +477 -0
  19. package/src/mcp/http-session.ts +272 -0
  20. package/src/mcp/http-transport.ts +370 -0
  21. package/src/mcp/resources/index.ts +141 -134
  22. package/src/mcp/server.ts +19 -79
  23. package/src/mcp/tools/add-collection.ts +3 -1
  24. package/src/mcp/tools/capture.ts +3 -0
  25. package/src/mcp/tools/clear-collection-embeddings.ts +2 -0
  26. package/src/mcp/tools/context.ts +9 -8
  27. package/src/mcp/tools/embed.ts +62 -52
  28. package/src/mcp/tools/index-cmd.ts +88 -74
  29. package/src/mcp/tools/index.ts +22 -2
  30. package/src/mcp/tools/remove-collection.ts +2 -0
  31. package/src/mcp/tools/status.ts +11 -0
  32. package/src/mcp/tools/sync.ts +16 -14
  33. package/src/mcp/tools/workspace-write.ts +7 -3
  34. package/src/serve/background-runtime.ts +12 -212
  35. package/src/serve/embed-scheduler.ts +74 -43
  36. package/src/serve/index.ts +9 -0
  37. package/src/serve/jobs.ts +78 -80
  38. package/src/serve/public/components/HealthCenter.tsx +74 -1
  39. package/src/serve/public/globals.built.css +1 -1
  40. package/src/serve/public/pages/Dashboard.tsx +1 -0
  41. package/src/serve/resident-admission.ts +159 -0
  42. package/src/serve/resident-background-work.ts +39 -0
  43. package/src/serve/resident-request.ts +55 -0
  44. package/src/serve/resident-runtime.ts +490 -0
  45. package/src/serve/resident-status.ts +96 -0
  46. package/src/serve/routes/api.ts +263 -167
  47. package/src/serve/routes/mcp.ts +69 -0
  48. package/src/serve/server.ts +191 -37
  49. package/src/serve/status-model.ts +51 -0
  50. package/src/serve/status.ts +5 -0
  51. package/src/store/sqlite/adapter.ts +26 -9
@@ -9,6 +9,7 @@ import type { ToolContext } from "../server";
9
9
  import { MCP_ERRORS } from "../../core/errors";
10
10
  import { acquireWriteLock, type WriteLockHandle } from "../../core/file-lock";
11
11
  import { JobError } from "../../core/job-manager";
12
+ import { recordIndexMutation } from "../../core/mutation-generations";
12
13
  import { embedBacklog } from "../../embed";
13
14
  import { LlmAdapter } from "../../llm/nodeLlamaCpp/adapter";
14
15
  import { resolveModelUri } from "../../llm/registry";
@@ -81,64 +82,73 @@ export function handleEmbed(
81
82
  "embed",
82
83
  lock,
83
84
  async () => {
84
- // Create LLM adapter with offline policy (fail-fast, no download)
85
- const llm = new LlmAdapter(ctx.config);
86
- const embedResult = await llm.createEmbeddingPort(modelUri, {
87
- policy: { offline: true, allowDownload: false },
88
- });
89
-
90
- if (!embedResult.ok) {
91
- throw new Error(
92
- `MODEL_NOT_FOUND: Embedding model not cached. ` +
93
- `Model: ${modelUri}. ` +
94
- `Run 'gno models pull embed' first.`
95
- );
96
- }
97
-
98
- const embedPort = embedResult.value;
99
-
85
+ const lease = ctx.acquireModelLease?.();
100
86
  try {
101
- // Initialize and get dimensions from port interface
102
- const initResult = await embedPort.init();
103
- if (!initResult.ok) {
104
- throw new Error(initResult.error.message);
105
- }
106
- const dimensions = embedPort.dimensions();
107
-
108
- // Create vector index port
109
- const db = ctx.store.getRawDb();
110
- const vectorResult = await createVectorIndexPort(db, {
111
- model: modelUri,
112
- dimensions,
113
- });
114
- if (!vectorResult.ok) {
115
- throw new Error(vectorResult.error.message);
116
- }
117
- const vectorIndex = vectorResult.value;
118
-
119
- // Create stats port for backlog
120
- const statsPort = createVectorStatsPort(db);
121
-
122
- // Run embedding
123
- const result = await embedBacklog({
124
- statsPort,
125
- embedPort,
126
- vectorIndex,
127
- collection: collection?.name,
128
- modelUri,
129
- batchSize: 32,
87
+ // Create LLM adapter with offline policy (fail-fast, no download)
88
+ const llm = new LlmAdapter(ctx.config);
89
+ const embedResult = await llm.createEmbeddingPort(modelUri, {
90
+ policy: { offline: true, allowDownload: false },
130
91
  });
131
92
 
132
- if (!result.ok) {
133
- throw new Error(result.error.message);
93
+ if (!embedResult.ok) {
94
+ throw new Error(
95
+ `MODEL_NOT_FOUND: Embedding model not cached. ` +
96
+ `Model: ${modelUri}. ` +
97
+ `Run 'gno models pull embed' first.`
98
+ );
134
99
  }
135
100
 
136
- return {
137
- kind: "embed" as const,
138
- value: result.value,
139
- };
101
+ const embedPort = embedResult.value;
102
+
103
+ try {
104
+ // Initialize and get dimensions from port interface
105
+ const initResult = await embedPort.init();
106
+ if (!initResult.ok) {
107
+ throw new Error(initResult.error.message);
108
+ }
109
+ const dimensions = embedPort.dimensions();
110
+
111
+ // Create vector index port
112
+ const db = ctx.store.getRawDb();
113
+ const vectorResult = await createVectorIndexPort(db, {
114
+ model: modelUri,
115
+ dimensions,
116
+ });
117
+ if (!vectorResult.ok) {
118
+ throw new Error(vectorResult.error.message);
119
+ }
120
+ const vectorIndex = vectorResult.value;
121
+
122
+ // Create stats port for backlog
123
+ const statsPort = createVectorStatsPort(db);
124
+
125
+ // Run embedding
126
+ const result = await embedBacklog({
127
+ statsPort,
128
+ embedPort,
129
+ vectorIndex,
130
+ collection: collection?.name,
131
+ modelUri,
132
+ batchSize: 32,
133
+ });
134
+
135
+ if (!result.ok) {
136
+ throw new Error(result.error.message);
137
+ }
138
+ recordIndexMutation(
139
+ result.value.embedded,
140
+ ctx.markIndexMutation
141
+ );
142
+
143
+ return {
144
+ kind: "embed" as const,
145
+ value: result.value,
146
+ };
147
+ } finally {
148
+ await embedPort.dispose();
149
+ }
140
150
  } finally {
141
- await embedPort.dispose();
151
+ lease?.release();
142
152
  }
143
153
  }
144
154
  );
@@ -10,6 +10,10 @@ import type { ToolContext } from "../server";
10
10
  import { MCP_ERRORS } from "../../core/errors";
11
11
  import { acquireWriteLock, type WriteLockHandle } from "../../core/file-lock";
12
12
  import { JobError } from "../../core/job-manager";
13
+ import {
14
+ recordContentMutation,
15
+ recordIndexMutation,
16
+ } from "../../core/mutation-generations";
13
17
  import { normalizeCollectionName } from "../../core/validation";
14
18
  import { embedBacklog } from "../../embed";
15
19
  import { defaultSyncService, withContentTypeRules } from "../../ingestion";
@@ -115,86 +119,96 @@ export function handleIndex(
115
119
  "index",
116
120
  lock,
117
121
  async () => {
118
- // Phase 1: Sync
119
- const syncResult = collection
120
- ? await defaultSyncService
121
- .syncCollection(collection, ctx.store, options)
122
- .then((r) => ({
123
- collections: [r],
124
- totalDurationMs: r.durationMs,
125
- totalFilesProcessed: r.filesProcessed,
126
- totalFilesAdded: r.filesAdded,
127
- totalFilesUpdated: r.filesUpdated,
128
- totalFilesErrored: r.filesErrored,
129
- totalFilesSkipped: r.filesSkipped,
130
- }))
131
- : await defaultSyncService.syncAll(
132
- ctx.collections,
133
- ctx.store,
134
- options
135
- );
136
-
137
- // Phase 2: Embed
138
- const llm = new LlmAdapter(ctx.config);
139
- const embedResult = await llm.createEmbeddingPort(modelUri, {
140
- policy: { offline: true, allowDownload: false },
141
- });
142
-
143
- if (!embedResult.ok) {
144
- throw new Error(
145
- `MODEL_NOT_FOUND: Embedding model not cached. ` +
146
- `Model: ${modelUri}. ` +
147
- `Run 'gno models pull embed' first.`
148
- );
149
- }
150
-
151
- const embedPort = embedResult.value;
152
-
122
+ const lease = ctx.acquireModelLease?.();
153
123
  try {
154
- // Initialize and get dimensions from port interface
155
- const initResult = await embedPort.init();
156
- if (!initResult.ok) {
157
- throw new Error(initResult.error.message);
158
- }
159
- const dimensions = embedPort.dimensions();
160
-
161
- // Create vector index port
162
- const db = ctx.store.getRawDb();
163
- const vectorResult = await createVectorIndexPort(db, {
164
- model: modelUri,
165
- dimensions,
166
- });
167
- if (!vectorResult.ok) {
168
- throw new Error(vectorResult.error.message);
169
- }
170
- const vectorIndex = vectorResult.value;
171
-
172
- // Create stats port for backlog
173
- const statsPort = createVectorStatsPort(db);
174
-
175
- // Run embedding
176
- const backlogResult = await embedBacklog({
177
- statsPort,
178
- embedPort,
179
- vectorIndex,
180
- collection: collection?.name,
181
- modelUri,
182
- batchSize: 32,
124
+ // Phase 1: Sync
125
+ const syncResult = collection
126
+ ? await defaultSyncService
127
+ .syncCollection(collection, ctx.store, options)
128
+ .then((r) => ({
129
+ collections: [r],
130
+ totalDurationMs: r.durationMs,
131
+ totalFilesProcessed: r.filesProcessed,
132
+ totalFilesAdded: r.filesAdded,
133
+ totalFilesUpdated: r.filesUpdated,
134
+ totalFilesErrored: r.filesErrored,
135
+ totalFilesSkipped: r.filesSkipped,
136
+ }))
137
+ : await defaultSyncService.syncAll(
138
+ ctx.collections,
139
+ ctx.store,
140
+ options
141
+ );
142
+ recordContentMutation(syncResult, ctx.markContentMutation);
143
+
144
+ // Phase 2: Embed
145
+ const llm = new LlmAdapter(ctx.config);
146
+ const embedResult = await llm.createEmbeddingPort(modelUri, {
147
+ policy: { offline: true, allowDownload: false },
183
148
  });
184
149
 
185
- if (!backlogResult.ok) {
186
- throw new Error(backlogResult.error.message);
150
+ if (!embedResult.ok) {
151
+ throw new Error(
152
+ `MODEL_NOT_FOUND: Embedding model not cached. ` +
153
+ `Model: ${modelUri}. ` +
154
+ `Run 'gno models pull embed' first.`
155
+ );
187
156
  }
188
157
 
189
- return {
190
- kind: "index" as const,
191
- value: {
192
- sync: syncResult,
193
- embed: backlogResult.value,
194
- },
195
- };
158
+ const embedPort = embedResult.value;
159
+
160
+ try {
161
+ // Initialize and get dimensions from port interface
162
+ const initResult = await embedPort.init();
163
+ if (!initResult.ok) {
164
+ throw new Error(initResult.error.message);
165
+ }
166
+ const dimensions = embedPort.dimensions();
167
+
168
+ // Create vector index port
169
+ const db = ctx.store.getRawDb();
170
+ const vectorResult = await createVectorIndexPort(db, {
171
+ model: modelUri,
172
+ dimensions,
173
+ });
174
+ if (!vectorResult.ok) {
175
+ throw new Error(vectorResult.error.message);
176
+ }
177
+ const vectorIndex = vectorResult.value;
178
+
179
+ // Create stats port for backlog
180
+ const statsPort = createVectorStatsPort(db);
181
+
182
+ // Run embedding
183
+ const backlogResult = await embedBacklog({
184
+ statsPort,
185
+ embedPort,
186
+ vectorIndex,
187
+ collection: collection?.name,
188
+ modelUri,
189
+ batchSize: 32,
190
+ });
191
+
192
+ if (!backlogResult.ok) {
193
+ throw new Error(backlogResult.error.message);
194
+ }
195
+ recordIndexMutation(
196
+ backlogResult.value.embedded,
197
+ ctx.markIndexMutation
198
+ );
199
+
200
+ return {
201
+ kind: "index" as const,
202
+ value: {
203
+ sync: syncResult,
204
+ embed: backlogResult.value,
205
+ },
206
+ };
207
+ } finally {
208
+ await embedPort.dispose();
209
+ }
196
210
  } finally {
197
- await embedPort.dispose();
211
+ lease?.release();
198
212
  }
199
213
  }
200
214
  );
@@ -83,6 +83,21 @@ export const MCP_TOOL_DESCRIPTIONS = {
83
83
  "Verify a saved Context Capsule without rebuilding or mutating it. Reports unchanged, stale, missing, reranked, and fingerprint drift states against the active index.",
84
84
  } as const;
85
85
 
86
+ /** Tool names whose execution mutates disk, config, or index state. */
87
+ export const MCP_WRITE_TOOL_NAMES = new Set([
88
+ "gno_capture",
89
+ "gno_add_collection",
90
+ "gno_sync",
91
+ "gno_embed",
92
+ "gno_index",
93
+ "gno_remove_collection",
94
+ "gno_clear_collection_embeddings",
95
+ "gno_create_folder",
96
+ "gno_rename_note",
97
+ "gno_move_note",
98
+ "gno_duplicate_note",
99
+ ]);
100
+
86
101
  // ─────────────────────────────────────────────────────────────────────────────
87
102
  // Shared Input Schemas
88
103
  // ─────────────────────────────────────────────────────────────────────────────
@@ -829,8 +844,9 @@ export async function runTool<T>(
829
844
 
830
845
  // Sequential execution via mutex
831
846
  const release = await ctx.toolMutex.acquire();
847
+ const modelLease = ctx.acquireModelLease?.();
832
848
  try {
833
- const data = await fn();
849
+ const data = await (ctx.runWithSnapshot?.(fn) ?? fn());
834
850
  return {
835
851
  content: [{ type: "text", text: formatText(data) }],
836
852
  structuredContent: data as { [x: string]: unknown },
@@ -846,6 +862,7 @@ export async function runTool<T>(
846
862
  structuredContent: parsedError,
847
863
  };
848
864
  } finally {
865
+ modelLease?.release();
849
866
  release();
850
867
  }
851
868
  }
@@ -869,8 +886,9 @@ export async function runToolNoMutex<T>(
869
886
  };
870
887
  }
871
888
 
889
+ const modelLease = ctx.acquireModelLease?.();
872
890
  try {
873
- const data = await fn();
891
+ const data = await (ctx.runWithSnapshot?.(fn) ?? fn());
874
892
  return {
875
893
  content: [{ type: "text", text: formatText(data) }],
876
894
  structuredContent: data as { [x: string]: unknown },
@@ -885,6 +903,8 @@ export async function runToolNoMutex<T>(
885
903
  content: [{ type: "text", text: `Error: ${message}` }],
886
904
  structuredContent: parsedError,
887
905
  };
906
+ } finally {
907
+ modelLease?.release();
888
908
  }
889
909
  }
890
910
 
@@ -89,6 +89,8 @@ export function handleRemoveCollection(
89
89
  if (!mutationResult.ok) {
90
90
  throw mapConfigError(mutationResult.code, mutationResult.error);
91
91
  }
92
+ ctx.markContentMutation?.();
93
+ ctx.markIndexMutation?.();
92
94
 
93
95
  const result: RemoveCollectionResult = {
94
96
  removed: true,
@@ -8,6 +8,7 @@ import type { IndexStatus } from "../../store/types";
8
8
  import type { ToolContext } from "../server";
9
9
 
10
10
  import { resolveModelUri } from "../../llm/registry";
11
+ import { createStandaloneResidentStatus } from "../../serve/resident-status";
11
12
  import { runTool, type ToolResult } from "./index";
12
13
 
13
14
  type StatusInput = Record<string, never>;
@@ -22,6 +23,14 @@ function formatStatus(status: IndexStatus): string {
22
23
  lines.push(`Config: ${status.configPath}`);
23
24
  lines.push(`Database: ${status.dbPath}`);
24
25
  lines.push(`Health: ${status.healthy ? "OK" : "DEGRADED"}`);
26
+ if ("resident" in status) {
27
+ const resident = status.resident as NonNullable<
28
+ ReturnType<NonNullable<ToolContext["getResidentStatus"]>>
29
+ >;
30
+ lines.push(
31
+ `Runtime: ${resident.mode}${resident.resident ? ` (${resident.transport.activeSessions} sessions)` : " (standalone)"}`
32
+ );
33
+ }
25
34
  lines.push("");
26
35
 
27
36
  if (status.collections.length === 0) {
@@ -78,6 +87,8 @@ export function handleStatus(
78
87
  return {
79
88
  ...result.value,
80
89
  configPath: ctx.actualConfigPath,
90
+ resident:
91
+ ctx.getResidentStatus?.() ?? createStandaloneResidentStatus("stdio"),
81
92
  };
82
93
  },
83
94
  formatStatus
@@ -11,6 +11,7 @@ import type { ToolContext } from "../server";
11
11
  import { MCP_ERRORS } from "../../core/errors";
12
12
  import { acquireWriteLock, type WriteLockHandle } from "../../core/file-lock";
13
13
  import { JobError } from "../../core/job-manager";
14
+ import { recordContentMutation } from "../../core/mutation-generations";
14
15
  import { normalizeCollectionName } from "../../core/validation";
15
16
  import { defaultSyncService, withContentTypeRules } from "../../ingestion";
16
17
  import { runTool, type ToolResult } from "./index";
@@ -113,20 +114,21 @@ export function handleSync(
113
114
  "sync",
114
115
  lock,
115
116
  async () => {
116
- if (collection) {
117
- const result = await defaultSyncService.syncCollection(
118
- collection,
119
- ctx.store,
120
- options
121
- );
122
- return wrapSingleSync(result);
123
- }
124
-
125
- return defaultSyncService.syncAll(
126
- ctx.collections,
127
- ctx.store,
128
- options
129
- );
117
+ const result = collection
118
+ ? wrapSingleSync(
119
+ await defaultSyncService.syncCollection(
120
+ collection,
121
+ ctx.store,
122
+ options
123
+ )
124
+ )
125
+ : await defaultSyncService.syncAll(
126
+ ctx.collections,
127
+ ctx.store,
128
+ options
129
+ );
130
+ recordContentMutation(result, ctx.markContentMutation);
131
+ return result;
130
132
  }
131
133
  );
132
134
 
@@ -27,6 +27,7 @@ import {
27
27
  planMoveRefactor,
28
28
  planRenameRefactor,
29
29
  } from "../../core/file-refactors";
30
+ import { recordContentMutation } from "../../core/mutation-generations";
30
31
  import { defaultSyncService, withContentTypeRules } from "../../ingestion";
31
32
  import { runTool, type ToolResult } from "./index";
32
33
 
@@ -210,11 +211,12 @@ export function handleRenameNote(
210
211
  const currentPath = join(collection.path, doc.relPath);
211
212
  const nextPath = join(collection.path, plan.nextRelPath);
212
213
  await renameFilePath(currentPath, nextPath);
213
- await defaultSyncService.syncCollection(
214
+ const syncResult = await defaultSyncService.syncCollection(
214
215
  collection,
215
216
  ctx.store,
216
217
  withContentTypeRules({ runUpdateCmd: false }, ctx.config)
217
218
  );
219
+ recordContentMutation(syncResult, ctx.markContentMutation);
218
220
  return {
219
221
  uri: plan.nextUri,
220
222
  relPath: plan.nextRelPath,
@@ -255,11 +257,12 @@ export function handleMoveNote(
255
257
  const nextPath = join(collection.path, plan.nextRelPath);
256
258
  await mkdir(dirname(nextPath), { recursive: true });
257
259
  await renameFilePath(currentPath, nextPath);
258
- await defaultSyncService.syncCollection(
260
+ const syncResult = await defaultSyncService.syncCollection(
259
261
  collection,
260
262
  ctx.store,
261
263
  withContentTypeRules({ runUpdateCmd: false }, ctx.config)
262
264
  );
265
+ recordContentMutation(syncResult, ctx.markContentMutation);
263
266
  return {
264
267
  uri: plan.nextUri,
265
268
  relPath: plan.nextRelPath,
@@ -308,11 +311,12 @@ export function handleDuplicateNote(
308
311
  const nextPath = join(collection.path, plan.nextRelPath);
309
312
  await mkdir(dirname(nextPath), { recursive: true });
310
313
  await copyFilePath(currentPath, nextPath);
311
- await defaultSyncService.syncCollection(
314
+ const syncResult = await defaultSyncService.syncCollection(
312
315
  collection,
313
316
  ctx.store,
314
317
  withContentTypeRules({ runUpdateCmd: false }, ctx.config)
315
318
  );
319
+ recordContentMutation(syncResult, ctx.markContentMutation);
316
320
  return {
317
321
  uri: plan.nextUri,
318
322
  relPath: plan.nextRelPath,