@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
@@ -46,6 +46,7 @@ export interface JobRecord {
46
46
  result?: SyncResult;
47
47
  typedResult?: JobResult;
48
48
  error?: string;
49
+ progress?: { current: number; total: number; currentFile?: string };
49
50
  serverInstanceId: string;
50
51
  }
51
52
 
@@ -148,6 +149,24 @@ export class JobManager {
148
149
  return this.#jobs.get(jobId);
149
150
  }
150
151
 
152
+ getActiveJob(): JobRecord | null {
153
+ if (!this.#activeJobId) return null;
154
+ return this.#jobs.get(this.#activeJobId) ?? null;
155
+ }
156
+
157
+ updateJobProgress(
158
+ jobId: string,
159
+ progress: { current: number; total: number; currentFile?: string }
160
+ ): void {
161
+ const job = this.#jobs.get(jobId);
162
+ if (job) job.progress = progress;
163
+ }
164
+
165
+ clear(): void {
166
+ this.#jobs.clear();
167
+ this.#activeJobId = null;
168
+ }
169
+
151
170
  listJobs(limit: number = 10): { active: JobRecord[]; recent: JobRecord[] } {
152
171
  this.#cleanupExpiredJobs();
153
172
 
@@ -0,0 +1,33 @@
1
+ /** Shared mutation detection for resident content and vector generations. */
2
+
3
+ interface SyncMutationCounts {
4
+ filesAdded?: number;
5
+ filesUpdated?: number;
6
+ filesMarkedInactive?: number;
7
+ totalFilesAdded?: number;
8
+ totalFilesUpdated?: number;
9
+ collections?: readonly SyncMutationCounts[];
10
+ }
11
+
12
+ export function hasContentMutation(result: SyncMutationCounts): boolean {
13
+ return (
14
+ (result.filesAdded ?? result.totalFilesAdded ?? 0) > 0 ||
15
+ (result.filesUpdated ?? result.totalFilesUpdated ?? 0) > 0 ||
16
+ (result.filesMarkedInactive ?? 0) > 0 ||
17
+ result.collections?.some(hasContentMutation) === true
18
+ );
19
+ }
20
+
21
+ export function recordContentMutation(
22
+ result: SyncMutationCounts,
23
+ markMutation: (() => void) | undefined
24
+ ): void {
25
+ if (hasContentMutation(result)) markMutation?.();
26
+ }
27
+
28
+ export function recordIndexMutation(
29
+ embedded: number,
30
+ markMutation: (() => void) | undefined
31
+ ): void {
32
+ if (embedded > 0) markMutation?.();
33
+ }
package/src/llm/cache.ts CHANGED
@@ -46,6 +46,7 @@ const HF_PATH_PATTERN = /^([^/]+)\/([^/]+)\/(.+\.gguf)$/;
46
46
  const GGUF_MAGIC = new Uint8Array([0x47, 0x47, 0x55, 0x46]);
47
47
 
48
48
  type ModelFileOwner = "cache" | "user";
49
+ type ResolveModelFile = typeof import("node-llama-cpp").resolveModelFile;
49
50
 
50
51
  type ValidatedCachedPath =
51
52
  | { ok: true; path: string }
@@ -268,11 +269,16 @@ const MANIFEST_VERSION = "1.0" as const;
268
269
  export class ModelCache {
269
270
  readonly dir: string;
270
271
  private readonly manifestPath: string;
272
+ private readonly resolveModelFileFn?: ResolveModelFile;
271
273
  private manifest: Manifest | null = null;
272
274
 
273
- constructor(cacheDir?: string) {
275
+ constructor(
276
+ cacheDir?: string,
277
+ deps?: { resolveModelFile?: ResolveModelFile }
278
+ ) {
274
279
  this.dir = cacheDir ?? getModelsCachePath();
275
280
  this.manifestPath = join(this.dir, "manifest.json");
281
+ this.resolveModelFileFn = deps?.resolveModelFile;
276
282
  }
277
283
 
278
284
  /**
@@ -324,7 +330,8 @@ export class ModelCache {
324
330
  uri: string,
325
331
  type: ModelType,
326
332
  onProgress?: ProgressCallback,
327
- force?: boolean
333
+ force?: boolean,
334
+ signal?: AbortSignal
328
335
  ): Promise<LlmResult<string>> {
329
336
  const parsed = parseModelUri(uri);
330
337
  if (!parsed.ok) {
@@ -364,7 +371,9 @@ export class ModelCache {
364
371
  }
365
372
 
366
373
  try {
367
- const { resolveModelFile } = await import("node-llama-cpp");
374
+ const resolveModelFile =
375
+ this.resolveModelFileFn ??
376
+ (await import("node-llama-cpp")).resolveModelFile;
368
377
 
369
378
  // Convert to node-llama-cpp format (handles quantization shorthand)
370
379
  // node-llama-cpp needs hf: prefix to identify HuggingFace models
@@ -399,6 +408,7 @@ export class ModelCache {
399
408
  }
400
409
  }
401
410
  : undefined,
411
+ signal,
402
412
  });
403
413
 
404
414
  const validation = await validateGgufFile(resolvedPath, uri, "cache");
@@ -27,7 +27,11 @@ import {
27
27
  } from "../registry";
28
28
  import { NodeLlamaCppEmbedding } from "./embedding";
29
29
  import { NodeLlamaCppGeneration } from "./generation";
30
- import { getModelManager, type ModelManager } from "./lifecycle";
30
+ import {
31
+ getModelManager,
32
+ type ModelLease,
33
+ type ModelManager,
34
+ } from "./lifecycle";
31
35
  import { NodeLlamaCppRerank } from "./rerank";
32
36
 
33
37
  // ─────────────────────────────────────────────────────────────────────────────
@@ -218,6 +222,11 @@ export class LlmAdapter {
218
222
  return this.manager;
219
223
  }
220
224
 
225
+ /** Acquire an idempotent request lease without transferring manager ownership. */
226
+ acquireModelLease(): ModelLease {
227
+ return this.manager.acquireLease();
228
+ }
229
+
221
230
  /**
222
231
  * Dispose all resources.
223
232
  */
@@ -35,6 +35,21 @@ interface CachedModel {
35
35
  loadedAt: number;
36
36
  }
37
37
 
38
+ export interface ModelLease {
39
+ release(): void;
40
+ }
41
+
42
+ export interface ModelLifecycleStats {
43
+ activeLeases: number;
44
+ leaseAcquisitions: number;
45
+ leaseReleases: number;
46
+ loadedModels: number;
47
+ loadAttempts: number;
48
+ loadSuccesses: number;
49
+ loadFailures: number;
50
+ inflightLoads: number;
51
+ }
52
+
38
53
  let invalidGpuModeWarned = false;
39
54
  let invalidBuildModeWarned = false;
40
55
  let gpuFallbackWarned = false;
@@ -134,7 +149,14 @@ export class ModelManager {
134
149
  new Map();
135
150
  private readonly inflightLoads: Map<string, Promise<LlmResult<LoadedModel>>> =
136
151
  new Map();
152
+ private readonly leaseDrainWaiters = new Set<() => void>();
137
153
  private readonly config: ModelConfig;
154
+ private activeLeases = 0;
155
+ private leaseAcquisitions = 0;
156
+ private leaseReleases = 0;
157
+ private loadAttempts = 0;
158
+ private loadSuccesses = 0;
159
+ private loadFailures = 0;
138
160
 
139
161
  constructor(config: ModelConfig) {
140
162
  this.config = config;
@@ -263,6 +285,7 @@ export class ModelManager {
263
285
  uri: string,
264
286
  type: ModelType
265
287
  ): Promise<LlmResult<LoadedModel>> {
288
+ this.loadAttempts += 1;
266
289
  const timeoutMs = this.config.loadTimeout;
267
290
  let timeoutId: ReturnType<typeof setTimeout> | null = null;
268
291
  let timedOut = false;
@@ -299,6 +322,7 @@ export class ModelManager {
299
322
 
300
323
  this.models.set(uri, cachedModel);
301
324
  this.setDisposalTimer(uri);
325
+ this.loadSuccesses += 1;
302
326
 
303
327
  return {
304
328
  ok: true,
@@ -310,6 +334,7 @@ export class ModelManager {
310
334
  },
311
335
  };
312
336
  } catch (e) {
337
+ this.loadFailures += 1;
313
338
  // Clear timeout on error
314
339
  if (timeoutId) {
315
340
  clearTimeout(timeoutId);
@@ -356,6 +381,42 @@ export class ModelManager {
356
381
  return model;
357
382
  }
358
383
 
384
+ /** Keep warm models alive while one request owns model-backed ports. */
385
+ acquireLease(): ModelLease {
386
+ this.activeLeases += 1;
387
+ this.leaseAcquisitions += 1;
388
+ for (const timer of this.disposalTimers.values()) clearTimeout(timer);
389
+ this.disposalTimers.clear();
390
+
391
+ let released = false;
392
+ return {
393
+ release: () => {
394
+ if (released) return;
395
+ released = true;
396
+ this.activeLeases = Math.max(0, this.activeLeases - 1);
397
+ this.leaseReleases += 1;
398
+ if (this.activeLeases === 0) {
399
+ for (const resolve of this.leaseDrainWaiters) resolve();
400
+ this.leaseDrainWaiters.clear();
401
+ for (const uri of this.models.keys()) this.setDisposalTimer(uri);
402
+ }
403
+ },
404
+ };
405
+ }
406
+
407
+ getLifecycleStats(): ModelLifecycleStats {
408
+ return {
409
+ activeLeases: this.activeLeases,
410
+ leaseAcquisitions: this.leaseAcquisitions,
411
+ leaseReleases: this.leaseReleases,
412
+ loadedModels: this.models.size,
413
+ loadAttempts: this.loadAttempts,
414
+ loadSuccesses: this.loadSuccesses,
415
+ loadFailures: this.loadFailures,
416
+ inflightLoads: this.inflightLoads.size,
417
+ };
418
+ }
419
+
359
420
  /**
360
421
  * Check if a model is loaded.
361
422
  */
@@ -367,6 +428,10 @@ export class ModelManager {
367
428
  * Dispose a specific model.
368
429
  */
369
430
  async dispose(uri: string): Promise<void> {
431
+ if (this.activeLeases > 0) {
432
+ this.resetDisposalTimer(uri);
433
+ return;
434
+ }
370
435
  const cached = this.models.get(uri);
371
436
  if (!cached) {
372
437
  return;
@@ -393,6 +458,11 @@ export class ModelManager {
393
458
  * Dispose all loaded models.
394
459
  */
395
460
  async disposeAll(): Promise<void> {
461
+ if (this.activeLeases > 0) {
462
+ await new Promise<void>((resolve) => this.leaseDrainWaiters.add(resolve));
463
+ }
464
+ await Promise.allSettled(this.inflightLoads.values());
465
+
396
466
  // Clear all timers
397
467
  for (const timer of this.disposalTimers.values()) {
398
468
  clearTimeout(timer);
@@ -429,6 +499,7 @@ export class ModelManager {
429
499
  // ───────────────────────────────────────────────────────────────────────────
430
500
 
431
501
  private setDisposalTimer(uri: string): void {
502
+ if (this.activeLeases > 0) return;
432
503
  const timer = setTimeout(() => {
433
504
  this.dispose(uri).catch(() => {
434
505
  // Ignore disposal errors in timer callback
@@ -0,0 +1,161 @@
1
+ /** Shared MCP surface and request-scoped runtime context. */
2
+
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ // node:async_hooks provides async-local request context; Bun has no separate native equivalent.
5
+ import { AsyncLocalStorage } from "node:async_hooks";
6
+
7
+ import type { Collection, Config } from "../config/types";
8
+ import type { JobManager } from "../core/job-manager";
9
+ import type { ModelLease } from "../llm/nodeLlamaCpp/lifecycle";
10
+ import type { ResidentStatus } from "../serve/status-model";
11
+ import type { SqliteAdapter } from "../store/sqlite/adapter";
12
+
13
+ import { MCP_SERVER_NAME, VERSION } from "../app/constants";
14
+ import { createStandaloneResidentStatus } from "../serve/resident-status";
15
+ import { registerResources } from "./resources/index";
16
+ import { registerTools } from "./tools/index";
17
+
18
+ export interface AsyncMutex {
19
+ acquire(): Promise<() => void>;
20
+ }
21
+
22
+ export class Mutex implements AsyncMutex {
23
+ #locked = false;
24
+ readonly #queue: Array<() => void> = [];
25
+
26
+ acquire(): Promise<() => void> {
27
+ return new Promise((resolve) => {
28
+ const tryAcquire = (): void => {
29
+ if (this.#locked) {
30
+ this.#queue.push(tryAcquire);
31
+ return;
32
+ }
33
+ this.#locked = true;
34
+ resolve(() => this.#release());
35
+ };
36
+ tryAcquire();
37
+ });
38
+ }
39
+
40
+ #release(): void {
41
+ this.#locked = false;
42
+ this.#queue.shift()?.();
43
+ }
44
+ }
45
+
46
+ export interface ToolContextSnapshot {
47
+ config: Config;
48
+ collections: Collection[];
49
+ }
50
+
51
+ export interface ToolContext {
52
+ store: SqliteAdapter;
53
+ config: Config;
54
+ collections: Collection[];
55
+ actualConfigPath: string;
56
+ indexName: string;
57
+ toolMutex: AsyncMutex;
58
+ jobManager: JobManager;
59
+ serverInstanceId: string;
60
+ writeLockPath: string;
61
+ enableWrite: boolean;
62
+ isShuttingDown: () => boolean;
63
+ getResidentStatus?: () => ResidentStatus;
64
+ acquireModelLease?: () => ModelLease;
65
+ markContentMutation?: () => void;
66
+ markIndexMutation?: () => void;
67
+ runWithSnapshot?<T>(operation: () => Promise<T>): Promise<T>;
68
+ }
69
+
70
+ export interface CreateToolContextOptions {
71
+ store: SqliteAdapter;
72
+ getConfig: () => Config;
73
+ setConfig?: (config: Config) => void;
74
+ actualConfigPath: string;
75
+ indexName: string;
76
+ toolMutex: AsyncMutex;
77
+ jobManager: JobManager;
78
+ serverInstanceId: string;
79
+ writeLockPath: string;
80
+ enableWrite: boolean;
81
+ isShuttingDown: () => boolean;
82
+ getResidentStatus?: () => ResidentStatus;
83
+ acquireModelLease?: () => ModelLease;
84
+ markContentMutation?: () => void;
85
+ markIndexMutation?: () => void;
86
+ }
87
+
88
+ /**
89
+ * Create a transport-neutral MCP context.
90
+ *
91
+ * Config and collection getters resolve from one snapshot captured at the
92
+ * request boundary, so a hot reload cannot mix old and new values mid-call.
93
+ */
94
+ export function createToolContext(
95
+ options: CreateToolContextOptions
96
+ ): ToolContext {
97
+ const requestSnapshot = new AsyncLocalStorage<ToolContextSnapshot>();
98
+ const currentSnapshot = (): ToolContextSnapshot =>
99
+ requestSnapshot.getStore() ??
100
+ (() => {
101
+ const config = options.getConfig();
102
+ return { config, collections: config.collections };
103
+ })();
104
+
105
+ return {
106
+ store: options.store,
107
+ get config() {
108
+ return currentSnapshot().config;
109
+ },
110
+ set config(config: Config) {
111
+ options.setConfig?.(config);
112
+ },
113
+ get collections() {
114
+ return currentSnapshot().collections;
115
+ },
116
+ set collections(_collections: Collection[]) {
117
+ // Collections are derived from config. Existing write handlers assign
118
+ // both for backwards compatibility; the config setter is authoritative.
119
+ },
120
+ actualConfigPath: options.actualConfigPath,
121
+ indexName: options.indexName,
122
+ toolMutex: options.toolMutex,
123
+ jobManager: options.jobManager,
124
+ serverInstanceId: options.serverInstanceId,
125
+ writeLockPath: options.writeLockPath,
126
+ enableWrite: options.enableWrite,
127
+ isShuttingDown: options.isShuttingDown,
128
+ getResidentStatus:
129
+ options.getResidentStatus ??
130
+ (() => createStandaloneResidentStatus("stdio")),
131
+ acquireModelLease: options.acquireModelLease,
132
+ markContentMutation: options.markContentMutation,
133
+ markIndexMutation: options.markIndexMutation,
134
+ runWithSnapshot<T>(operation: () => Promise<T>): Promise<T> {
135
+ const config = options.getConfig();
136
+ return requestSnapshot.run(
137
+ { config, collections: config.collections },
138
+ operation
139
+ );
140
+ },
141
+ };
142
+ }
143
+
144
+ /** Build the contract-identical MCP tool/resource surface for any transport. */
145
+ export function createMcpServerSurface(
146
+ context: ToolContext,
147
+ identity: { name: string; version: string } = {
148
+ name: MCP_SERVER_NAME,
149
+ version: VERSION,
150
+ }
151
+ ): McpServer {
152
+ const server = new McpServer(identity, {
153
+ capabilities: {
154
+ tools: { listChanged: false },
155
+ resources: { subscribe: false, listChanged: false },
156
+ },
157
+ });
158
+ registerTools(server, context);
159
+ registerResources(server, context);
160
+ return server;
161
+ }