@gmickel/gno 0.3.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 +256 -0
- package/assets/skill/SKILL.md +112 -0
- package/assets/skill/cli-reference.md +327 -0
- package/assets/skill/examples.md +234 -0
- package/assets/skill/mcp-reference.md +159 -0
- package/package.json +90 -0
- package/src/app/constants.ts +313 -0
- package/src/cli/colors.ts +65 -0
- package/src/cli/commands/ask.ts +545 -0
- package/src/cli/commands/cleanup.ts +105 -0
- package/src/cli/commands/collection/add.ts +120 -0
- package/src/cli/commands/collection/index.ts +10 -0
- package/src/cli/commands/collection/list.ts +108 -0
- package/src/cli/commands/collection/remove.ts +64 -0
- package/src/cli/commands/collection/rename.ts +95 -0
- package/src/cli/commands/context/add.ts +67 -0
- package/src/cli/commands/context/check.ts +153 -0
- package/src/cli/commands/context/index.ts +10 -0
- package/src/cli/commands/context/list.ts +109 -0
- package/src/cli/commands/context/rm.ts +52 -0
- package/src/cli/commands/doctor.ts +393 -0
- package/src/cli/commands/embed.ts +462 -0
- package/src/cli/commands/get.ts +356 -0
- package/src/cli/commands/index-cmd.ts +119 -0
- package/src/cli/commands/index.ts +102 -0
- package/src/cli/commands/init.ts +328 -0
- package/src/cli/commands/ls.ts +217 -0
- package/src/cli/commands/mcp/config.ts +300 -0
- package/src/cli/commands/mcp/index.ts +24 -0
- package/src/cli/commands/mcp/install.ts +203 -0
- package/src/cli/commands/mcp/paths.ts +470 -0
- package/src/cli/commands/mcp/status.ts +222 -0
- package/src/cli/commands/mcp/uninstall.ts +158 -0
- package/src/cli/commands/mcp.ts +20 -0
- package/src/cli/commands/models/clear.ts +103 -0
- package/src/cli/commands/models/index.ts +32 -0
- package/src/cli/commands/models/list.ts +214 -0
- package/src/cli/commands/models/path.ts +51 -0
- package/src/cli/commands/models/pull.ts +199 -0
- package/src/cli/commands/models/use.ts +85 -0
- package/src/cli/commands/multi-get.ts +400 -0
- package/src/cli/commands/query.ts +220 -0
- package/src/cli/commands/ref-parser.ts +108 -0
- package/src/cli/commands/reset.ts +191 -0
- package/src/cli/commands/search.ts +136 -0
- package/src/cli/commands/shared.ts +156 -0
- package/src/cli/commands/skill/index.ts +19 -0
- package/src/cli/commands/skill/install.ts +197 -0
- package/src/cli/commands/skill/paths-cmd.ts +81 -0
- package/src/cli/commands/skill/paths.ts +191 -0
- package/src/cli/commands/skill/show.ts +73 -0
- package/src/cli/commands/skill/uninstall.ts +141 -0
- package/src/cli/commands/status.ts +205 -0
- package/src/cli/commands/update.ts +68 -0
- package/src/cli/commands/vsearch.ts +188 -0
- package/src/cli/context.ts +64 -0
- package/src/cli/errors.ts +64 -0
- package/src/cli/format/search-results.ts +211 -0
- package/src/cli/options.ts +183 -0
- package/src/cli/program.ts +1330 -0
- package/src/cli/run.ts +213 -0
- package/src/cli/ui.ts +92 -0
- package/src/config/defaults.ts +20 -0
- package/src/config/index.ts +55 -0
- package/src/config/loader.ts +161 -0
- package/src/config/paths.ts +87 -0
- package/src/config/saver.ts +153 -0
- package/src/config/types.ts +280 -0
- package/src/converters/adapters/markitdownTs/adapter.ts +140 -0
- package/src/converters/adapters/officeparser/adapter.ts +126 -0
- package/src/converters/canonicalize.ts +89 -0
- package/src/converters/errors.ts +218 -0
- package/src/converters/index.ts +51 -0
- package/src/converters/mime.ts +163 -0
- package/src/converters/native/markdown.ts +115 -0
- package/src/converters/native/plaintext.ts +56 -0
- package/src/converters/path.ts +48 -0
- package/src/converters/pipeline.ts +159 -0
- package/src/converters/registry.ts +74 -0
- package/src/converters/types.ts +123 -0
- package/src/converters/versions.ts +24 -0
- package/src/index.ts +27 -0
- package/src/ingestion/chunker.ts +238 -0
- package/src/ingestion/index.ts +32 -0
- package/src/ingestion/language.ts +276 -0
- package/src/ingestion/sync.ts +671 -0
- package/src/ingestion/types.ts +219 -0
- package/src/ingestion/walker.ts +235 -0
- package/src/llm/cache.ts +467 -0
- package/src/llm/errors.ts +191 -0
- package/src/llm/index.ts +58 -0
- package/src/llm/nodeLlamaCpp/adapter.ts +133 -0
- package/src/llm/nodeLlamaCpp/embedding.ts +165 -0
- package/src/llm/nodeLlamaCpp/generation.ts +88 -0
- package/src/llm/nodeLlamaCpp/lifecycle.ts +317 -0
- package/src/llm/nodeLlamaCpp/rerank.ts +94 -0
- package/src/llm/registry.ts +86 -0
- package/src/llm/types.ts +129 -0
- package/src/mcp/resources/index.ts +151 -0
- package/src/mcp/server.ts +229 -0
- package/src/mcp/tools/get.ts +220 -0
- package/src/mcp/tools/index.ts +160 -0
- package/src/mcp/tools/multi-get.ts +263 -0
- package/src/mcp/tools/query.ts +226 -0
- package/src/mcp/tools/search.ts +119 -0
- package/src/mcp/tools/status.ts +81 -0
- package/src/mcp/tools/vsearch.ts +198 -0
- package/src/pipeline/chunk-lookup.ts +44 -0
- package/src/pipeline/expansion.ts +256 -0
- package/src/pipeline/explain.ts +115 -0
- package/src/pipeline/fusion.ts +185 -0
- package/src/pipeline/hybrid.ts +535 -0
- package/src/pipeline/index.ts +64 -0
- package/src/pipeline/query-language.ts +118 -0
- package/src/pipeline/rerank.ts +223 -0
- package/src/pipeline/search.ts +261 -0
- package/src/pipeline/types.ts +328 -0
- package/src/pipeline/vsearch.ts +348 -0
- package/src/store/index.ts +41 -0
- package/src/store/migrations/001-initial.ts +196 -0
- package/src/store/migrations/index.ts +20 -0
- package/src/store/migrations/runner.ts +187 -0
- package/src/store/sqlite/adapter.ts +1242 -0
- package/src/store/sqlite/index.ts +7 -0
- package/src/store/sqlite/setup.ts +129 -0
- package/src/store/sqlite/types.ts +28 -0
- package/src/store/types.ts +506 -0
- package/src/store/vector/index.ts +13 -0
- package/src/store/vector/sqlite-vec.ts +373 -0
- package/src/store/vector/stats.ts +152 -0
- package/src/store/vector/types.ts +115 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rerank port implementation using node-llama-cpp.
|
|
3
|
+
*
|
|
4
|
+
* @module src/llm/nodeLlamaCpp/rerank
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { inferenceFailedError } from '../errors';
|
|
8
|
+
import type { LlmResult, RerankPort, RerankScore } from '../types';
|
|
9
|
+
import type { ModelManager } from './lifecycle';
|
|
10
|
+
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
// Types
|
|
13
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
type LlamaModel = Awaited<
|
|
16
|
+
ReturnType<
|
|
17
|
+
Awaited<ReturnType<typeof import('node-llama-cpp').getLlama>>['loadModel']
|
|
18
|
+
>
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
// Implementation
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export class NodeLlamaCppRerank implements RerankPort {
|
|
26
|
+
private readonly manager: ModelManager;
|
|
27
|
+
readonly modelUri: string;
|
|
28
|
+
private readonly modelPath: string;
|
|
29
|
+
|
|
30
|
+
constructor(manager: ModelManager, modelUri: string, modelPath: string) {
|
|
31
|
+
this.manager = manager;
|
|
32
|
+
this.modelUri = modelUri;
|
|
33
|
+
this.modelPath = modelPath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async rerank(
|
|
37
|
+
query: string,
|
|
38
|
+
documents: string[]
|
|
39
|
+
): Promise<LlmResult<RerankScore[]>> {
|
|
40
|
+
if (documents.length === 0) {
|
|
41
|
+
return { ok: true, value: [] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const model = await this.manager.loadModel(
|
|
45
|
+
this.modelPath,
|
|
46
|
+
this.modelUri,
|
|
47
|
+
'rerank'
|
|
48
|
+
);
|
|
49
|
+
if (!model.ok) {
|
|
50
|
+
return model;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Build index map for O(1) lookups (handles duplicates correctly)
|
|
54
|
+
const indexMap = new Map<string, number[]>();
|
|
55
|
+
for (let i = 0; i < documents.length; i += 1) {
|
|
56
|
+
const doc = documents[i] as string; // Guaranteed by loop bounds
|
|
57
|
+
const indices = indexMap.get(doc) ?? [];
|
|
58
|
+
indices.push(i);
|
|
59
|
+
indexMap.set(doc, indices);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const llamaModel = model.value.model as LlamaModel;
|
|
63
|
+
const context = await llamaModel.createRankingContext();
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const ranked = await context.rankAndSort(query, documents);
|
|
67
|
+
|
|
68
|
+
// Convert to RerankScore format with O(1) index lookup
|
|
69
|
+
const scores: RerankScore[] = ranked.map((item, rank) => {
|
|
70
|
+
const indices = indexMap.get(item.document) ?? [];
|
|
71
|
+
// Shift to handle duplicates (each duplicate gets next index)
|
|
72
|
+
const index = indices.shift() ?? -1;
|
|
73
|
+
return {
|
|
74
|
+
index,
|
|
75
|
+
score: item.score,
|
|
76
|
+
rank: rank + 1,
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return { ok: true, value: scores };
|
|
81
|
+
} catch (e) {
|
|
82
|
+
return { ok: false, error: inferenceFailedError(this.modelUri, e) };
|
|
83
|
+
} finally {
|
|
84
|
+
await context.dispose().catch(() => {
|
|
85
|
+
// Ignore disposal errors
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async dispose(): Promise<void> {
|
|
91
|
+
// Rerank doesn't hold persistent context
|
|
92
|
+
// Model cleanup is handled by ModelManager
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model preset registry.
|
|
3
|
+
* Resolves active preset and model URIs from config.
|
|
4
|
+
*
|
|
5
|
+
* @module src/llm/registry
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Config, ModelConfig, ModelPreset } from '../config/types';
|
|
9
|
+
import { DEFAULT_MODEL_PRESETS } from '../config/types';
|
|
10
|
+
import type { ModelType } from './types';
|
|
11
|
+
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
// Registry Functions
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get model config with defaults.
|
|
18
|
+
*/
|
|
19
|
+
export function getModelConfig(config: Config): ModelConfig {
|
|
20
|
+
return {
|
|
21
|
+
activePreset: config.models?.activePreset ?? 'balanced',
|
|
22
|
+
presets: config.models?.presets ?? DEFAULT_MODEL_PRESETS,
|
|
23
|
+
loadTimeout: config.models?.loadTimeout ?? 60_000,
|
|
24
|
+
inferenceTimeout: config.models?.inferenceTimeout ?? 30_000,
|
|
25
|
+
warmModelTtl: config.models?.warmModelTtl ?? 300_000,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the active preset from config.
|
|
31
|
+
* Falls back to first preset if active not found.
|
|
32
|
+
*/
|
|
33
|
+
export function getActivePreset(config: Config): ModelPreset {
|
|
34
|
+
const modelConfig = getModelConfig(config);
|
|
35
|
+
const presetId = modelConfig.activePreset;
|
|
36
|
+
const preset = modelConfig.presets.find((p) => p.id === presetId);
|
|
37
|
+
|
|
38
|
+
if (preset) {
|
|
39
|
+
return preset;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Fallback to first preset
|
|
43
|
+
const fallback = modelConfig.presets[0];
|
|
44
|
+
if (fallback) {
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Return built-in default (guaranteed to exist)
|
|
49
|
+
const builtIn = DEFAULT_MODEL_PRESETS[0];
|
|
50
|
+
if (!builtIn) {
|
|
51
|
+
throw new Error('No default model presets configured');
|
|
52
|
+
}
|
|
53
|
+
return builtIn;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve a model URI for a given type.
|
|
58
|
+
* Uses override if provided, otherwise from active preset.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveModelUri(
|
|
61
|
+
config: Config,
|
|
62
|
+
type: ModelType,
|
|
63
|
+
override?: string
|
|
64
|
+
): string {
|
|
65
|
+
if (override) {
|
|
66
|
+
return override;
|
|
67
|
+
}
|
|
68
|
+
const preset = getActivePreset(config);
|
|
69
|
+
return preset[type];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* List all available presets.
|
|
74
|
+
*/
|
|
75
|
+
export function listPresets(config: Config): ModelPreset[] {
|
|
76
|
+
const modelConfig = getModelConfig(config);
|
|
77
|
+
return modelConfig.presets;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get a specific preset by ID.
|
|
82
|
+
*/
|
|
83
|
+
export function getPreset(config: Config, id: string): ModelPreset | undefined {
|
|
84
|
+
const modelConfig = getModelConfig(config);
|
|
85
|
+
return modelConfig.presets.find((p) => p.id === id);
|
|
86
|
+
}
|
package/src/llm/types.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM subsystem types.
|
|
3
|
+
* Port interfaces for embedding, generation, and reranking.
|
|
4
|
+
*
|
|
5
|
+
* @module src/llm/types
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { LlmError } from './errors';
|
|
9
|
+
|
|
10
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
// Result Type
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export type LlmResult<T> =
|
|
15
|
+
| { ok: true; value: T }
|
|
16
|
+
| { ok: false; error: LlmError };
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
// Model Types
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export type ModelType = 'embed' | 'rerank' | 'gen';
|
|
23
|
+
|
|
24
|
+
/** Model URI format: hf:org/repo/file.gguf or file:/path */
|
|
25
|
+
export type ModelUri = string;
|
|
26
|
+
|
|
27
|
+
// ModelPreset is defined in config/types.ts (source of truth)
|
|
28
|
+
// Re-exported from index.ts for convenience
|
|
29
|
+
|
|
30
|
+
export interface ModelCacheEntry {
|
|
31
|
+
uri: ModelUri;
|
|
32
|
+
type: ModelType;
|
|
33
|
+
path: string;
|
|
34
|
+
size: number;
|
|
35
|
+
checksum: string;
|
|
36
|
+
cachedAt: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ModelStatus {
|
|
40
|
+
uri: ModelUri;
|
|
41
|
+
cached: boolean;
|
|
42
|
+
path: string | null;
|
|
43
|
+
size?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
47
|
+
// Generation Parameters
|
|
48
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
export interface GenParams {
|
|
51
|
+
/** Temperature (0 = deterministic). Default: 0 */
|
|
52
|
+
temperature?: number;
|
|
53
|
+
/** Random seed for reproducibility. Default: 42 */
|
|
54
|
+
seed?: number;
|
|
55
|
+
/** Max tokens to generate. Default: 256 */
|
|
56
|
+
maxTokens?: number;
|
|
57
|
+
/** Stop sequences */
|
|
58
|
+
stop?: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
// Rerank Types
|
|
63
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export interface RerankScore {
|
|
66
|
+
/** Original index in input array */
|
|
67
|
+
index: number;
|
|
68
|
+
/** Relevance score (higher = more relevant) */
|
|
69
|
+
score: number;
|
|
70
|
+
/** Rank position (1 = best) */
|
|
71
|
+
rank: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
// Port Interfaces
|
|
76
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export interface EmbeddingPort {
|
|
79
|
+
readonly modelUri: string;
|
|
80
|
+
/** Initialize the embedding context (loads model). Call before dimensions(). */
|
|
81
|
+
init(): Promise<LlmResult<void>>;
|
|
82
|
+
embed(text: string): Promise<LlmResult<number[]>>;
|
|
83
|
+
embedBatch(texts: string[]): Promise<LlmResult<number[][]>>;
|
|
84
|
+
/** Returns embedding dimensions. Must call init() first. */
|
|
85
|
+
dimensions(): number;
|
|
86
|
+
dispose(): Promise<void>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface GenerationPort {
|
|
90
|
+
readonly modelUri: string;
|
|
91
|
+
generate(prompt: string, params?: GenParams): Promise<LlmResult<string>>;
|
|
92
|
+
dispose(): Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface RerankPort {
|
|
96
|
+
readonly modelUri: string;
|
|
97
|
+
rerank(query: string, documents: string[]): Promise<LlmResult<RerankScore[]>>;
|
|
98
|
+
dispose(): Promise<void>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
102
|
+
// Loaded Model (internal)
|
|
103
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
export interface LoadedModel {
|
|
106
|
+
uri: ModelUri;
|
|
107
|
+
type: ModelType;
|
|
108
|
+
model: unknown; // LlamaModel from node-llama-cpp
|
|
109
|
+
loadedAt: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
113
|
+
// Config Types
|
|
114
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
// ModelConfig is defined in config/types.ts (source of truth)
|
|
117
|
+
// Re-exported from index.ts for convenience
|
|
118
|
+
|
|
119
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
120
|
+
// Progress Callback
|
|
121
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
export interface DownloadProgress {
|
|
124
|
+
downloadedBytes: number;
|
|
125
|
+
totalBytes: number;
|
|
126
|
+
percent: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type ProgressCallback = (progress: DownloadProgress) => void;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP resource registration for gno:// URIs.
|
|
3
|
+
*
|
|
4
|
+
* @module src/mcp/resources
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { join as pathJoin } from 'node:path';
|
|
8
|
+
import {
|
|
9
|
+
type McpServer,
|
|
10
|
+
ResourceTemplate,
|
|
11
|
+
} from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
12
|
+
import { buildUri, parseUri, URI_PREFIX } from '../../app/constants';
|
|
13
|
+
import type { DocumentRow } from '../../store/types';
|
|
14
|
+
import type { ToolContext } from '../server';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Format document content with header comment and line numbers.
|
|
18
|
+
*/
|
|
19
|
+
function formatResourceContent(
|
|
20
|
+
doc: DocumentRow,
|
|
21
|
+
content: string,
|
|
22
|
+
ctx: ToolContext
|
|
23
|
+
): string {
|
|
24
|
+
// Find collection for absPath
|
|
25
|
+
const uriParsed = parseUri(doc.uri);
|
|
26
|
+
let absPath = doc.relPath;
|
|
27
|
+
if (uriParsed) {
|
|
28
|
+
const collection = ctx.collections.find(
|
|
29
|
+
(c) => c.name === uriParsed.collection
|
|
30
|
+
);
|
|
31
|
+
if (collection) {
|
|
32
|
+
absPath = pathJoin(collection.path, doc.relPath);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Header comment per spec (includes language if available)
|
|
37
|
+
const langLine = doc.languageHint
|
|
38
|
+
? `\n language: ${doc.languageHint}`
|
|
39
|
+
: '';
|
|
40
|
+
const header = `<!-- ${doc.uri}
|
|
41
|
+
docid: ${doc.docid}
|
|
42
|
+
source: ${absPath}
|
|
43
|
+
mime: ${doc.sourceMime}${langLine}
|
|
44
|
+
-->
|
|
45
|
+
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
// Line numbers per spec (default ON for agent friendliness)
|
|
49
|
+
const lines = content.split('\n');
|
|
50
|
+
const numbered = lines.map((line, i) => `${i + 1}: ${line}`).join('\n');
|
|
51
|
+
|
|
52
|
+
return header + numbered;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Register gno:// resources with the MCP server.
|
|
57
|
+
*/
|
|
58
|
+
export function registerResources(server: McpServer, ctx: ToolContext): void {
|
|
59
|
+
// Resource template for gno://{collection}/{path} URIs
|
|
60
|
+
const template = new ResourceTemplate(`${URI_PREFIX}{collection}/{+path}`, {
|
|
61
|
+
list: async () => {
|
|
62
|
+
// List all documents as resources
|
|
63
|
+
const listResult = await ctx.store.listDocuments();
|
|
64
|
+
if (!listResult.ok) {
|
|
65
|
+
return { resources: [] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
resources: listResult.value.map((doc) => ({
|
|
70
|
+
uri: doc.uri,
|
|
71
|
+
name: doc.relPath,
|
|
72
|
+
mimeType: doc.sourceMime || 'text/markdown',
|
|
73
|
+
description: doc.title ?? undefined,
|
|
74
|
+
})),
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Register the template-based resource handler
|
|
80
|
+
server.resource('gno-document', template, {}, async (uri, _variables) => {
|
|
81
|
+
// Check shutdown before acquiring mutex
|
|
82
|
+
if (ctx.isShuttingDown()) {
|
|
83
|
+
throw new Error('Server is shutting down');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Serialize resource reads same as tools (prevent concurrent DB access + shutdown race)
|
|
87
|
+
const release = await ctx.toolMutex.acquire();
|
|
88
|
+
try {
|
|
89
|
+
// Use parseUri for proper URL decoding (handles %20, etc.)
|
|
90
|
+
const parsed = parseUri(uri.href);
|
|
91
|
+
if (!parsed) {
|
|
92
|
+
throw new Error(`Invalid gno:// URI: ${uri.href}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { collection, path } = parsed;
|
|
96
|
+
|
|
97
|
+
// Validate collection exists
|
|
98
|
+
const collectionExists = ctx.collections.some(
|
|
99
|
+
(c) => c.name === collection
|
|
100
|
+
);
|
|
101
|
+
if (!collectionExists) {
|
|
102
|
+
throw new Error(`Collection not found: ${collection}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Look up document (path is properly decoded by parseUri)
|
|
106
|
+
const docResult = await ctx.store.getDocument(collection, path);
|
|
107
|
+
if (!docResult.ok) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Failed to lookup document: ${docResult.error.message}`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const doc = docResult.value;
|
|
114
|
+
if (!doc) {
|
|
115
|
+
throw new Error(`Document not found: ${uri.href}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Get content
|
|
119
|
+
if (!doc.mirrorHash) {
|
|
120
|
+
throw new Error(`Document has no indexed content: ${uri.href}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const contentResult = await ctx.store.getContent(doc.mirrorHash);
|
|
124
|
+
if (!contentResult.ok) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Failed to read content: ${contentResult.error.message}`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const content = contentResult.value ?? '';
|
|
131
|
+
|
|
132
|
+
// Format with header and line numbers
|
|
133
|
+
const formattedContent = formatResourceContent(doc, content, ctx);
|
|
134
|
+
|
|
135
|
+
// Build canonical URI
|
|
136
|
+
const canonicalUri = buildUri(collection, path);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
contents: [
|
|
140
|
+
{
|
|
141
|
+
uri: canonicalUri,
|
|
142
|
+
mimeType: 'text/markdown',
|
|
143
|
+
text: formattedContent,
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
} finally {
|
|
148
|
+
release();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Server implementation for GNO.
|
|
3
|
+
* Exposes search, retrieval, and status tools over stdio transport.
|
|
4
|
+
*
|
|
5
|
+
* @module src/mcp/server
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
9
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
10
|
+
import { MCP_SERVER_NAME, VERSION } from '../app/constants';
|
|
11
|
+
import type { Collection, Config } from '../config/types';
|
|
12
|
+
import type { SqliteAdapter } from '../store/sqlite/adapter';
|
|
13
|
+
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
// Simple Promise Mutex (avoids async-mutex dependency)
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
class Mutex {
|
|
19
|
+
#locked = false;
|
|
20
|
+
readonly #queue: Array<() => void> = [];
|
|
21
|
+
|
|
22
|
+
acquire(): Promise<() => void> {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const tryAcquire = () => {
|
|
25
|
+
if (this.#locked) {
|
|
26
|
+
this.#queue.push(tryAcquire);
|
|
27
|
+
} else {
|
|
28
|
+
this.#locked = true;
|
|
29
|
+
resolve(() => this.#release());
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
tryAcquire();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#release(): void {
|
|
37
|
+
this.#locked = false;
|
|
38
|
+
const next = this.#queue.shift();
|
|
39
|
+
if (next) {
|
|
40
|
+
next();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
// Tool Context
|
|
47
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export interface ToolContext {
|
|
50
|
+
store: SqliteAdapter;
|
|
51
|
+
config: Config;
|
|
52
|
+
collections: Collection[];
|
|
53
|
+
actualConfigPath: string;
|
|
54
|
+
toolMutex: Mutex;
|
|
55
|
+
isShuttingDown: () => boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
// Server Options
|
|
60
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export interface McpServerOptions {
|
|
63
|
+
indexName?: string;
|
|
64
|
+
configPath?: string;
|
|
65
|
+
verbose?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
// Server Lifecycle
|
|
70
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
export async function startMcpServer(options: McpServerOptions): Promise<void> {
|
|
73
|
+
// ========================================
|
|
74
|
+
// STDOUT PURITY GUARD (CRITICAL)
|
|
75
|
+
// ========================================
|
|
76
|
+
// Wrap stdout to catch accidental writes during init
|
|
77
|
+
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
78
|
+
let protocolMode = false;
|
|
79
|
+
|
|
80
|
+
// Stdout wrapper - redirect to stderr during init
|
|
81
|
+
// biome-ignore lint/suspicious/noExplicitAny: overloaded write signature
|
|
82
|
+
(process.stdout as any).write = (
|
|
83
|
+
chunk: string | Uint8Array,
|
|
84
|
+
encodingOrCb?: BufferEncoding | ((err?: Error | null) => void),
|
|
85
|
+
cb?: (err?: Error | null) => void
|
|
86
|
+
): boolean => {
|
|
87
|
+
if (!protocolMode) {
|
|
88
|
+
// During init, redirect to stderr
|
|
89
|
+
if (typeof encodingOrCb === 'function') {
|
|
90
|
+
return process.stderr.write(chunk, encodingOrCb);
|
|
91
|
+
}
|
|
92
|
+
return process.stderr.write(chunk, encodingOrCb, cb);
|
|
93
|
+
}
|
|
94
|
+
// After transport connected, allow JSON-RPC only
|
|
95
|
+
if (typeof encodingOrCb === 'function') {
|
|
96
|
+
return originalStdoutWrite(chunk, encodingOrCb);
|
|
97
|
+
}
|
|
98
|
+
return originalStdoutWrite(chunk, encodingOrCb, cb);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Lazy import to avoid pulling in all deps on --help
|
|
102
|
+
const { initStore } = await import('../cli/commands/shared.js');
|
|
103
|
+
|
|
104
|
+
// Open DB once with index/config threading
|
|
105
|
+
const init = await initStore({
|
|
106
|
+
indexName: options.indexName,
|
|
107
|
+
configPath: options.configPath,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (!init.ok) {
|
|
111
|
+
console.error('Failed to initialize:', init.error);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { store, config, collections, actualConfigPath } = init;
|
|
116
|
+
|
|
117
|
+
// Create MCP server
|
|
118
|
+
const server = new McpServer(
|
|
119
|
+
{
|
|
120
|
+
name: MCP_SERVER_NAME,
|
|
121
|
+
version: VERSION,
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
capabilities: {
|
|
125
|
+
tools: { listChanged: false },
|
|
126
|
+
resources: { subscribe: false, listChanged: false },
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Sequential execution mutex
|
|
132
|
+
const toolMutex = new Mutex();
|
|
133
|
+
|
|
134
|
+
// Shutdown state
|
|
135
|
+
let shuttingDown = false;
|
|
136
|
+
|
|
137
|
+
// Tool context (passed to all handlers)
|
|
138
|
+
const ctx: ToolContext = {
|
|
139
|
+
store,
|
|
140
|
+
config,
|
|
141
|
+
collections,
|
|
142
|
+
actualConfigPath,
|
|
143
|
+
toolMutex,
|
|
144
|
+
isShuttingDown: () => shuttingDown,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Register tools (T10.2)
|
|
148
|
+
const { registerTools } = await import('./tools/index.js');
|
|
149
|
+
registerTools(server, ctx);
|
|
150
|
+
|
|
151
|
+
// Register resources (T10.3)
|
|
152
|
+
const { registerResources } = await import('./resources/index.js');
|
|
153
|
+
registerResources(server, ctx);
|
|
154
|
+
|
|
155
|
+
if (options.verbose) {
|
|
156
|
+
console.error(
|
|
157
|
+
`[MCP] Loaded ${ctx.collections.length} collections from ${ctx.actualConfigPath}`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ========================================
|
|
162
|
+
// GRACEFUL SHUTDOWN (ordered)
|
|
163
|
+
// ========================================
|
|
164
|
+
const shutdown = async () => {
|
|
165
|
+
if (shuttingDown) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
shuttingDown = true;
|
|
169
|
+
|
|
170
|
+
console.error('[MCP] Shutting down...');
|
|
171
|
+
|
|
172
|
+
// 1. Wait for current handler (no timeout - correctness over speed)
|
|
173
|
+
// If we timeout and close DB while tool is running, we risk corruption
|
|
174
|
+
const release = await toolMutex.acquire();
|
|
175
|
+
release();
|
|
176
|
+
|
|
177
|
+
// 2. Close MCP server/transport (flush buffers, clean disconnect)
|
|
178
|
+
try {
|
|
179
|
+
await server.close();
|
|
180
|
+
} catch {
|
|
181
|
+
// Best-effort - server may already be closed
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 3. Close DB (safe now - no tool is running)
|
|
185
|
+
await store.close();
|
|
186
|
+
|
|
187
|
+
// 4. Exit
|
|
188
|
+
process.exit(0);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
process.on('SIGTERM', shutdown);
|
|
192
|
+
process.on('SIGINT', shutdown);
|
|
193
|
+
|
|
194
|
+
// ========================================
|
|
195
|
+
// CONSOLE REDIRECT (CRITICAL for stdout purity)
|
|
196
|
+
// ========================================
|
|
197
|
+
// Redirect console.log/info/debug/warn to stderr to prevent JSON-RPC corruption
|
|
198
|
+
// Save originals (prefixed with _ to indicate intentionally unused)
|
|
199
|
+
const _origLog = console.log;
|
|
200
|
+
const _origInfo = console.info;
|
|
201
|
+
const _origDebug = console.debug;
|
|
202
|
+
const _origWarn = console.warn;
|
|
203
|
+
console.log = (...args: unknown[]) => console.error('[log]', ...args);
|
|
204
|
+
console.info = (...args: unknown[]) => console.error('[info]', ...args);
|
|
205
|
+
console.debug = (...args: unknown[]) => console.error('[debug]', ...args);
|
|
206
|
+
console.warn = (...args: unknown[]) => console.error('[warn]', ...args);
|
|
207
|
+
|
|
208
|
+
// Connect transport
|
|
209
|
+
const transport = new StdioServerTransport();
|
|
210
|
+
protocolMode = true; // Enable stdout for JSON-RPC
|
|
211
|
+
|
|
212
|
+
await server.connect(transport);
|
|
213
|
+
|
|
214
|
+
console.error(`[MCP] ${MCP_SERVER_NAME} v${VERSION} ready on stdio`);
|
|
215
|
+
|
|
216
|
+
// Block forever until shutdown signal or stdin closes
|
|
217
|
+
// This prevents the CLI from exiting after startMcpServer() returns
|
|
218
|
+
await new Promise<void>((resolve) => {
|
|
219
|
+
process.stdin.on('end', () => {
|
|
220
|
+
console.error('[MCP] stdin ended');
|
|
221
|
+
resolve();
|
|
222
|
+
});
|
|
223
|
+
process.stdin.on('close', () => {
|
|
224
|
+
console.error('[MCP] stdin closed');
|
|
225
|
+
resolve();
|
|
226
|
+
});
|
|
227
|
+
// Also resolve on SIGTERM/SIGINT (already handled by shutdown())
|
|
228
|
+
});
|
|
229
|
+
}
|