@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.
Files changed (131) hide show
  1. package/README.md +256 -0
  2. package/assets/skill/SKILL.md +112 -0
  3. package/assets/skill/cli-reference.md +327 -0
  4. package/assets/skill/examples.md +234 -0
  5. package/assets/skill/mcp-reference.md +159 -0
  6. package/package.json +90 -0
  7. package/src/app/constants.ts +313 -0
  8. package/src/cli/colors.ts +65 -0
  9. package/src/cli/commands/ask.ts +545 -0
  10. package/src/cli/commands/cleanup.ts +105 -0
  11. package/src/cli/commands/collection/add.ts +120 -0
  12. package/src/cli/commands/collection/index.ts +10 -0
  13. package/src/cli/commands/collection/list.ts +108 -0
  14. package/src/cli/commands/collection/remove.ts +64 -0
  15. package/src/cli/commands/collection/rename.ts +95 -0
  16. package/src/cli/commands/context/add.ts +67 -0
  17. package/src/cli/commands/context/check.ts +153 -0
  18. package/src/cli/commands/context/index.ts +10 -0
  19. package/src/cli/commands/context/list.ts +109 -0
  20. package/src/cli/commands/context/rm.ts +52 -0
  21. package/src/cli/commands/doctor.ts +393 -0
  22. package/src/cli/commands/embed.ts +462 -0
  23. package/src/cli/commands/get.ts +356 -0
  24. package/src/cli/commands/index-cmd.ts +119 -0
  25. package/src/cli/commands/index.ts +102 -0
  26. package/src/cli/commands/init.ts +328 -0
  27. package/src/cli/commands/ls.ts +217 -0
  28. package/src/cli/commands/mcp/config.ts +300 -0
  29. package/src/cli/commands/mcp/index.ts +24 -0
  30. package/src/cli/commands/mcp/install.ts +203 -0
  31. package/src/cli/commands/mcp/paths.ts +470 -0
  32. package/src/cli/commands/mcp/status.ts +222 -0
  33. package/src/cli/commands/mcp/uninstall.ts +158 -0
  34. package/src/cli/commands/mcp.ts +20 -0
  35. package/src/cli/commands/models/clear.ts +103 -0
  36. package/src/cli/commands/models/index.ts +32 -0
  37. package/src/cli/commands/models/list.ts +214 -0
  38. package/src/cli/commands/models/path.ts +51 -0
  39. package/src/cli/commands/models/pull.ts +199 -0
  40. package/src/cli/commands/models/use.ts +85 -0
  41. package/src/cli/commands/multi-get.ts +400 -0
  42. package/src/cli/commands/query.ts +220 -0
  43. package/src/cli/commands/ref-parser.ts +108 -0
  44. package/src/cli/commands/reset.ts +191 -0
  45. package/src/cli/commands/search.ts +136 -0
  46. package/src/cli/commands/shared.ts +156 -0
  47. package/src/cli/commands/skill/index.ts +19 -0
  48. package/src/cli/commands/skill/install.ts +197 -0
  49. package/src/cli/commands/skill/paths-cmd.ts +81 -0
  50. package/src/cli/commands/skill/paths.ts +191 -0
  51. package/src/cli/commands/skill/show.ts +73 -0
  52. package/src/cli/commands/skill/uninstall.ts +141 -0
  53. package/src/cli/commands/status.ts +205 -0
  54. package/src/cli/commands/update.ts +68 -0
  55. package/src/cli/commands/vsearch.ts +188 -0
  56. package/src/cli/context.ts +64 -0
  57. package/src/cli/errors.ts +64 -0
  58. package/src/cli/format/search-results.ts +211 -0
  59. package/src/cli/options.ts +183 -0
  60. package/src/cli/program.ts +1330 -0
  61. package/src/cli/run.ts +213 -0
  62. package/src/cli/ui.ts +92 -0
  63. package/src/config/defaults.ts +20 -0
  64. package/src/config/index.ts +55 -0
  65. package/src/config/loader.ts +161 -0
  66. package/src/config/paths.ts +87 -0
  67. package/src/config/saver.ts +153 -0
  68. package/src/config/types.ts +280 -0
  69. package/src/converters/adapters/markitdownTs/adapter.ts +140 -0
  70. package/src/converters/adapters/officeparser/adapter.ts +126 -0
  71. package/src/converters/canonicalize.ts +89 -0
  72. package/src/converters/errors.ts +218 -0
  73. package/src/converters/index.ts +51 -0
  74. package/src/converters/mime.ts +163 -0
  75. package/src/converters/native/markdown.ts +115 -0
  76. package/src/converters/native/plaintext.ts +56 -0
  77. package/src/converters/path.ts +48 -0
  78. package/src/converters/pipeline.ts +159 -0
  79. package/src/converters/registry.ts +74 -0
  80. package/src/converters/types.ts +123 -0
  81. package/src/converters/versions.ts +24 -0
  82. package/src/index.ts +27 -0
  83. package/src/ingestion/chunker.ts +238 -0
  84. package/src/ingestion/index.ts +32 -0
  85. package/src/ingestion/language.ts +276 -0
  86. package/src/ingestion/sync.ts +671 -0
  87. package/src/ingestion/types.ts +219 -0
  88. package/src/ingestion/walker.ts +235 -0
  89. package/src/llm/cache.ts +467 -0
  90. package/src/llm/errors.ts +191 -0
  91. package/src/llm/index.ts +58 -0
  92. package/src/llm/nodeLlamaCpp/adapter.ts +133 -0
  93. package/src/llm/nodeLlamaCpp/embedding.ts +165 -0
  94. package/src/llm/nodeLlamaCpp/generation.ts +88 -0
  95. package/src/llm/nodeLlamaCpp/lifecycle.ts +317 -0
  96. package/src/llm/nodeLlamaCpp/rerank.ts +94 -0
  97. package/src/llm/registry.ts +86 -0
  98. package/src/llm/types.ts +129 -0
  99. package/src/mcp/resources/index.ts +151 -0
  100. package/src/mcp/server.ts +229 -0
  101. package/src/mcp/tools/get.ts +220 -0
  102. package/src/mcp/tools/index.ts +160 -0
  103. package/src/mcp/tools/multi-get.ts +263 -0
  104. package/src/mcp/tools/query.ts +226 -0
  105. package/src/mcp/tools/search.ts +119 -0
  106. package/src/mcp/tools/status.ts +81 -0
  107. package/src/mcp/tools/vsearch.ts +198 -0
  108. package/src/pipeline/chunk-lookup.ts +44 -0
  109. package/src/pipeline/expansion.ts +256 -0
  110. package/src/pipeline/explain.ts +115 -0
  111. package/src/pipeline/fusion.ts +185 -0
  112. package/src/pipeline/hybrid.ts +535 -0
  113. package/src/pipeline/index.ts +64 -0
  114. package/src/pipeline/query-language.ts +118 -0
  115. package/src/pipeline/rerank.ts +223 -0
  116. package/src/pipeline/search.ts +261 -0
  117. package/src/pipeline/types.ts +328 -0
  118. package/src/pipeline/vsearch.ts +348 -0
  119. package/src/store/index.ts +41 -0
  120. package/src/store/migrations/001-initial.ts +196 -0
  121. package/src/store/migrations/index.ts +20 -0
  122. package/src/store/migrations/runner.ts +187 -0
  123. package/src/store/sqlite/adapter.ts +1242 -0
  124. package/src/store/sqlite/index.ts +7 -0
  125. package/src/store/sqlite/setup.ts +129 -0
  126. package/src/store/sqlite/types.ts +28 -0
  127. package/src/store/types.ts +506 -0
  128. package/src/store/vector/index.ts +13 -0
  129. package/src/store/vector/sqlite-vec.ts +373 -0
  130. package/src/store/vector/stats.ts +152 -0
  131. package/src/store/vector/types.ts +115 -0
@@ -0,0 +1,220 @@
1
+ /**
2
+ * MCP gno_get tool - Retrieve single document.
3
+ *
4
+ * @module src/mcp/tools/get
5
+ */
6
+
7
+ import { join as pathJoin } from 'node:path';
8
+ import { parseUri } from '../../app/constants';
9
+ import { parseRef } from '../../cli/commands/ref-parser';
10
+ import type { DocumentRow, StorePort } from '../../store/types';
11
+ import type { ToolContext } from '../server';
12
+ import { runTool, type ToolResult } from './index';
13
+
14
+ interface GetInput {
15
+ ref: string;
16
+ fromLine?: number;
17
+ lineCount?: number;
18
+ lineNumbers?: boolean;
19
+ }
20
+
21
+ interface GetResponse {
22
+ docid: string;
23
+ uri: string;
24
+ title?: string;
25
+ content: string;
26
+ totalLines: number;
27
+ returnedLines?: { start: number; end: number };
28
+ language?: string;
29
+ source: {
30
+ absPath?: string;
31
+ relPath: string;
32
+ mime: string;
33
+ ext: string;
34
+ modifiedAt?: string;
35
+ sizeBytes?: number;
36
+ sourceHash?: string;
37
+ };
38
+ conversion?: {
39
+ converterId?: string;
40
+ converterVersion?: string;
41
+ mirrorHash?: string;
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Lookup document by parsed reference.
47
+ */
48
+ async function lookupDocument(
49
+ store: StorePort,
50
+ parsed: ReturnType<typeof parseRef>
51
+ ): Promise<DocumentRow | null> {
52
+ if ('error' in parsed) {
53
+ return null;
54
+ }
55
+
56
+ switch (parsed.type) {
57
+ case 'docid': {
58
+ const result = await store.getDocumentByDocid(parsed.value);
59
+ return result.ok ? result.value : null;
60
+ }
61
+ case 'uri': {
62
+ const result = await store.getDocumentByUri(parsed.value);
63
+ return result.ok ? result.value : null;
64
+ }
65
+ case 'collPath': {
66
+ if (!(parsed.collection && parsed.relPath)) {
67
+ return null;
68
+ }
69
+ const result = await store.getDocument(parsed.collection, parsed.relPath);
70
+ return result.ok ? result.value : null;
71
+ }
72
+ default:
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Format get response as text.
79
+ */
80
+ function formatGetResponse(data: GetResponse): string {
81
+ const lines: string[] = [];
82
+
83
+ lines.push(`Document: ${data.uri}`);
84
+ if (data.title) {
85
+ lines.push(`Title: ${data.title}`);
86
+ }
87
+ lines.push(`Lines: ${data.totalLines}`);
88
+ if (data.source.absPath) {
89
+ lines.push(`Path: ${data.source.absPath}`);
90
+ }
91
+ lines.push('');
92
+
93
+ if (data.returnedLines) {
94
+ lines.push(
95
+ `--- Content (lines ${data.returnedLines.start}-${data.returnedLines.end}) ---`
96
+ );
97
+ } else {
98
+ lines.push('--- Content ---');
99
+ }
100
+
101
+ lines.push(data.content);
102
+
103
+ return lines.join('\n');
104
+ }
105
+
106
+ /**
107
+ * Handle gno_get tool call.
108
+ */
109
+ export function handleGet(
110
+ args: GetInput,
111
+ ctx: ToolContext
112
+ ): Promise<ToolResult> {
113
+ return runTool(
114
+ ctx,
115
+ 'gno_get',
116
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: document retrieval with multiple ref formats and chunk handling
117
+ async () => {
118
+ // Parse reference
119
+ const parsed = parseRef(args.ref);
120
+ if ('error' in parsed) {
121
+ throw new Error(parsed.error);
122
+ }
123
+
124
+ // Lookup document
125
+ const doc = await lookupDocument(ctx.store, parsed);
126
+ if (!doc) {
127
+ throw new Error(`Document not found: ${args.ref}`);
128
+ }
129
+
130
+ // Get content
131
+ if (!doc.mirrorHash) {
132
+ throw new Error('Document has no indexed content');
133
+ }
134
+
135
+ const contentResult = await ctx.store.getContent(doc.mirrorHash);
136
+ if (!contentResult.ok) {
137
+ throw new Error(contentResult.error.message);
138
+ }
139
+
140
+ const fullContent = contentResult.value ?? '';
141
+ const contentLines = fullContent.split('\n');
142
+ const totalLines = contentLines.length;
143
+
144
+ // Apply line range if specified
145
+ let content = fullContent;
146
+ let returnedLines: { start: number; end: number } | undefined;
147
+
148
+ // lineNumbers defaults to true per spec
149
+ const showLineNumbers = args.lineNumbers !== false;
150
+
151
+ if (args.fromLine || args.lineCount) {
152
+ const startLine = args.fromLine ?? 1;
153
+ // Clamp startLine to valid range
154
+ if (startLine > totalLines) {
155
+ // Return empty content for out-of-range request
156
+ content = '';
157
+ returnedLines = undefined;
158
+ } else {
159
+ const count = args.lineCount ?? totalLines - startLine + 1;
160
+ const endLine = Math.min(startLine + count - 1, totalLines);
161
+
162
+ const slicedLines = contentLines.slice(startLine - 1, endLine);
163
+
164
+ if (showLineNumbers) {
165
+ content = slicedLines
166
+ .map((line, i) => `${startLine + i}: ${line}`)
167
+ .join('\n');
168
+ } else {
169
+ content = slicedLines.join('\n');
170
+ }
171
+
172
+ returnedLines = { start: startLine, end: endLine };
173
+ }
174
+ } else if (showLineNumbers) {
175
+ content = contentLines.map((line, i) => `${i + 1}: ${line}`).join('\n');
176
+ }
177
+
178
+ // Build absPath
179
+ const uriParsed = parseUri(doc.uri);
180
+ let absPath: string | undefined;
181
+ if (uriParsed) {
182
+ const collection = ctx.collections.find(
183
+ (c) => c.name === uriParsed.collection
184
+ );
185
+ if (collection) {
186
+ absPath = pathJoin(collection.path, doc.relPath);
187
+ }
188
+ }
189
+
190
+ const response: GetResponse = {
191
+ docid: doc.docid,
192
+ uri: doc.uri,
193
+ title: doc.title ?? undefined,
194
+ content,
195
+ totalLines,
196
+ returnedLines,
197
+ language: doc.languageHint ?? undefined,
198
+ source: {
199
+ absPath,
200
+ relPath: doc.relPath,
201
+ mime: doc.sourceMime,
202
+ ext: doc.sourceExt,
203
+ modifiedAt: doc.sourceMtime,
204
+ sizeBytes: doc.sourceSize,
205
+ sourceHash: doc.sourceHash,
206
+ },
207
+ conversion: doc.mirrorHash
208
+ ? {
209
+ converterId: doc.converterId ?? undefined,
210
+ converterVersion: doc.converterVersion ?? undefined,
211
+ mirrorHash: doc.mirrorHash,
212
+ }
213
+ : undefined,
214
+ };
215
+
216
+ return response;
217
+ },
218
+ formatGetResponse
219
+ );
220
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * MCP tool registration and shared utilities.
3
+ *
4
+ * @module src/mcp/tools
5
+ */
6
+
7
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
+ import { z } from 'zod';
9
+ import type { ToolContext } from '../server';
10
+ import { handleGet } from './get';
11
+ import { handleMultiGet } from './multi-get';
12
+ import { handleQuery } from './query';
13
+ import { handleSearch } from './search';
14
+ import { handleStatus } from './status';
15
+ import { handleVsearch } from './vsearch';
16
+
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+ // Shared Input Schemas
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+
21
+ const searchInputSchema = z.object({
22
+ query: z.string().min(1, 'Query cannot be empty'),
23
+ collection: z.string().optional(),
24
+ limit: z.number().int().min(1).max(100).default(5),
25
+ minScore: z.number().min(0).max(1).optional(),
26
+ lang: z.string().optional(),
27
+ });
28
+
29
+ const vsearchInputSchema = z.object({
30
+ query: z.string().min(1, 'Query cannot be empty'),
31
+ collection: z.string().optional(),
32
+ limit: z.number().int().min(1).max(100).default(5),
33
+ minScore: z.number().min(0).max(1).optional(),
34
+ lang: z.string().optional(),
35
+ });
36
+
37
+ const queryInputSchema = z.object({
38
+ query: z.string().min(1, 'Query cannot be empty'),
39
+ collection: z.string().optional(),
40
+ limit: z.number().int().min(1).max(100).default(5),
41
+ minScore: z.number().min(0).max(1).optional(),
42
+ lang: z.string().optional(),
43
+ expand: z.boolean().default(true),
44
+ rerank: z.boolean().default(true),
45
+ });
46
+
47
+ const getInputSchema = z.object({
48
+ ref: z.string().min(1, 'Reference cannot be empty'),
49
+ fromLine: z.number().int().min(1).optional(),
50
+ lineCount: z.number().int().min(1).optional(),
51
+ lineNumbers: z.boolean().default(true),
52
+ });
53
+
54
+ const multiGetInputSchema = z.object({
55
+ refs: z.array(z.string()).min(1).optional(),
56
+ pattern: z.string().optional(),
57
+ maxBytes: z.number().int().min(1).default(10_240),
58
+ lineNumbers: z.boolean().default(true),
59
+ });
60
+
61
+ const statusInputSchema = z.object({});
62
+
63
+ // ─────────────────────────────────────────────────────────────────────────────
64
+ // Tool Result Type
65
+ // ─────────────────────────────────────────────────────────────────────────────
66
+
67
+ export interface ToolResult {
68
+ [x: string]: unknown;
69
+ content: Array<{ type: 'text'; text: string }>;
70
+ structuredContent?: { [x: string]: unknown };
71
+ isError?: boolean;
72
+ }
73
+
74
+ // ─────────────────────────────────────────────────────────────────────────────
75
+ // DRY Helper: Exception Firewall + Mutex + Response Shaping
76
+ // ─────────────────────────────────────────────────────────────────────────────
77
+
78
+ export async function runTool<T>(
79
+ ctx: ToolContext,
80
+ name: string,
81
+ fn: () => Promise<T>,
82
+ formatText: (data: T) => string
83
+ ): Promise<ToolResult> {
84
+ // Check shutdown
85
+ if (ctx.isShuttingDown()) {
86
+ return {
87
+ isError: true,
88
+ content: [{ type: 'text', text: 'Error: Server is shutting down' }],
89
+ };
90
+ }
91
+
92
+ // Sequential execution via mutex
93
+ const release = await ctx.toolMutex.acquire();
94
+ try {
95
+ const data = await fn();
96
+ return {
97
+ content: [{ type: 'text', text: formatText(data) }],
98
+ structuredContent: data as { [x: string]: unknown },
99
+ };
100
+ } catch (e) {
101
+ // Exception firewall: never throw, always return isError
102
+ const message = e instanceof Error ? e.message : String(e);
103
+ console.error(`[MCP] ${name} error:`, message);
104
+ return {
105
+ isError: true,
106
+ content: [{ type: 'text', text: `Error: ${message}` }],
107
+ };
108
+ } finally {
109
+ release();
110
+ }
111
+ }
112
+
113
+ // ─────────────────────────────────────────────────────────────────────────────
114
+ // Tool Registration
115
+ // ─────────────────────────────────────────────────────────────────────────────
116
+
117
+ export function registerTools(server: McpServer, ctx: ToolContext): void {
118
+ // Tool IDs use underscores (MCP pattern: ^[a-zA-Z0-9_-]{1,64}$)
119
+ server.tool(
120
+ 'gno_search',
121
+ 'BM25 full-text search across indexed documents',
122
+ searchInputSchema.shape,
123
+ (args) => handleSearch(args, ctx)
124
+ );
125
+
126
+ server.tool(
127
+ 'gno_vsearch',
128
+ 'Vector/semantic similarity search',
129
+ vsearchInputSchema.shape,
130
+ (args) => handleVsearch(args, ctx)
131
+ );
132
+
133
+ server.tool(
134
+ 'gno_query',
135
+ 'Hybrid search with optional expansion and reranking',
136
+ queryInputSchema.shape,
137
+ (args) => handleQuery(args, ctx)
138
+ );
139
+
140
+ server.tool(
141
+ 'gno_get',
142
+ 'Retrieve a single document by URI, docid, or collection/path',
143
+ getInputSchema.shape,
144
+ (args) => handleGet(args, ctx)
145
+ );
146
+
147
+ server.tool(
148
+ 'gno_multi_get',
149
+ 'Retrieve multiple documents by refs or glob pattern',
150
+ multiGetInputSchema.shape,
151
+ (args) => handleMultiGet(args, ctx)
152
+ );
153
+
154
+ server.tool(
155
+ 'gno_status',
156
+ 'Get index status and health information',
157
+ statusInputSchema.shape,
158
+ (args) => handleStatus(args, ctx)
159
+ );
160
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * MCP gno_multi_get tool - Retrieve multiple documents.
3
+ *
4
+ * @module src/mcp/tools/multi-get
5
+ */
6
+
7
+ import { join as pathJoin } from 'node:path';
8
+ import { parseUri } from '../../app/constants';
9
+ import { parseRef } from '../../cli/commands/ref-parser';
10
+ import type { DocumentRow, StorePort } from '../../store/types';
11
+ import type { ToolContext } from '../server';
12
+ import { runTool, type ToolResult } from './index';
13
+
14
+ interface MultiGetInput {
15
+ refs?: string[];
16
+ pattern?: string;
17
+ maxBytes?: number;
18
+ lineNumbers?: boolean; // defaults to true per spec
19
+ }
20
+
21
+ interface DocumentResult {
22
+ docid: string;
23
+ uri: string;
24
+ title?: string;
25
+ content: string;
26
+ totalLines: number;
27
+ truncated: boolean;
28
+ source: {
29
+ absPath?: string;
30
+ relPath: string;
31
+ mime: string;
32
+ ext: string;
33
+ modifiedAt?: string;
34
+ sizeBytes?: number;
35
+ };
36
+ }
37
+
38
+ interface MultiGetResponse {
39
+ documents: DocumentResult[];
40
+ skipped: Array<{ ref: string; reason: string }>;
41
+ meta: {
42
+ requested: number;
43
+ returned: number;
44
+ skipped: number;
45
+ };
46
+ }
47
+
48
+ // Default per spec is 10240
49
+ const DEFAULT_MAX_BYTES = 10_240;
50
+
51
+ /**
52
+ * Lookup document by parsed reference.
53
+ */
54
+ async function lookupDocument(
55
+ store: StorePort,
56
+ parsed: ReturnType<typeof parseRef>
57
+ ): Promise<DocumentRow | null> {
58
+ if ('error' in parsed) {
59
+ return null;
60
+ }
61
+
62
+ switch (parsed.type) {
63
+ case 'docid': {
64
+ const result = await store.getDocumentByDocid(parsed.value);
65
+ return result.ok ? result.value : null;
66
+ }
67
+ case 'uri': {
68
+ const result = await store.getDocumentByUri(parsed.value);
69
+ return result.ok ? result.value : null;
70
+ }
71
+ case 'collPath': {
72
+ if (!(parsed.collection && parsed.relPath)) {
73
+ return null;
74
+ }
75
+ const result = await store.getDocument(parsed.collection, parsed.relPath);
76
+ return result.ok ? result.value : null;
77
+ }
78
+ default:
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Format multi-get response as text.
85
+ */
86
+ function formatMultiGetResponse(data: MultiGetResponse): string {
87
+ const lines: string[] = [];
88
+
89
+ lines.push(
90
+ `Retrieved ${data.meta.returned}/${data.meta.requested} documents`
91
+ );
92
+ if (data.meta.skipped > 0) {
93
+ lines.push(`Skipped: ${data.meta.skipped}`);
94
+ }
95
+ lines.push('');
96
+
97
+ for (const doc of data.documents) {
98
+ lines.push(`=== ${doc.uri} ===`);
99
+ if (doc.title) {
100
+ lines.push(`Title: ${doc.title}`);
101
+ }
102
+ lines.push(
103
+ `Lines: ${doc.totalLines}${doc.truncated ? ' (truncated)' : ''}`
104
+ );
105
+ lines.push('');
106
+ lines.push(doc.content);
107
+ lines.push('');
108
+ }
109
+
110
+ if (data.skipped.length > 0) {
111
+ lines.push('--- Skipped ---');
112
+ for (const s of data.skipped) {
113
+ lines.push(`${s.ref}: ${s.reason}`);
114
+ }
115
+ }
116
+
117
+ return lines.join('\n');
118
+ }
119
+
120
+ /**
121
+ * Handle gno_multi_get tool call.
122
+ */
123
+ export function handleMultiGet(
124
+ args: MultiGetInput,
125
+ ctx: ToolContext
126
+ ): Promise<ToolResult> {
127
+ return runTool(
128
+ ctx,
129
+ 'gno_multi_get',
130
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: multi-get with pattern expansion and batch retrieval
131
+ async () => {
132
+ // Validate input
133
+ if (!(args.refs || args.pattern)) {
134
+ throw new Error('Either refs or pattern must be provided');
135
+ }
136
+ if (args.refs && args.pattern) {
137
+ throw new Error('Cannot specify both refs and pattern');
138
+ }
139
+
140
+ const maxBytes = args.maxBytes ?? DEFAULT_MAX_BYTES;
141
+ const documents: DocumentResult[] = [];
142
+ const skipped: Array<{ ref: string; reason: string }> = [];
143
+
144
+ let refs: string[] = args.refs ?? [];
145
+
146
+ // Pattern-based lookup
147
+ if (args.pattern) {
148
+ // For pattern matching, list all documents and filter
149
+ const listResult = await ctx.store.listDocuments();
150
+ if (!listResult.ok) {
151
+ throw new Error(listResult.error.message);
152
+ }
153
+
154
+ // Safe glob-like pattern matching: escape regex metacharacters first
155
+ const pattern = args.pattern;
156
+ // Escape all regex metacharacters except * and ?
157
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
158
+ // Then convert glob wildcards to regex
159
+ const regexPattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
160
+ const regex = new RegExp(`^${regexPattern}$`);
161
+
162
+ refs = listResult.value
163
+ .filter((d) => regex.test(d.uri) || regex.test(d.relPath))
164
+ .map((d) => d.uri);
165
+ }
166
+
167
+ // Process each reference
168
+ for (const ref of refs) {
169
+ const parsed = parseRef(ref);
170
+ if ('error' in parsed) {
171
+ skipped.push({ ref, reason: parsed.error });
172
+ continue;
173
+ }
174
+
175
+ const doc = await lookupDocument(ctx.store, parsed);
176
+ if (!doc) {
177
+ skipped.push({ ref, reason: 'Not found' });
178
+ continue;
179
+ }
180
+
181
+ if (!doc.mirrorHash) {
182
+ skipped.push({ ref, reason: 'No indexed content' });
183
+ continue;
184
+ }
185
+
186
+ // Get content
187
+ const contentResult = await ctx.store.getContent(doc.mirrorHash);
188
+ if (!contentResult.ok) {
189
+ skipped.push({ ref, reason: contentResult.error.message });
190
+ continue;
191
+ }
192
+
193
+ let content = contentResult.value ?? '';
194
+ let truncated = false;
195
+
196
+ // Apply maxBytes truncation (actual UTF-8 bytes, not characters)
197
+ const contentBuffer = Buffer.from(content, 'utf8');
198
+ if (contentBuffer.length > maxBytes) {
199
+ // Truncate by bytes, then decode safely (may cut mid-codepoint)
200
+ const truncatedBuffer = contentBuffer.subarray(0, maxBytes);
201
+ // Decode with replacement char for incomplete sequences
202
+ content = truncatedBuffer.toString('utf8');
203
+ // Remove potential trailing replacement char from cut codepoint
204
+ if (content.endsWith('\uFFFD')) {
205
+ content = content.slice(0, -1);
206
+ }
207
+ truncated = true;
208
+ }
209
+
210
+ const contentLines = content.split('\n');
211
+
212
+ // Apply line numbers (defaults to true per spec)
213
+ if (args.lineNumbers !== false) {
214
+ content = contentLines
215
+ .map((line, i) => `${i + 1}: ${line}`)
216
+ .join('\n');
217
+ }
218
+
219
+ // Build absPath
220
+ const uriParsed = parseUri(doc.uri);
221
+ let absPath: string | undefined;
222
+ if (uriParsed) {
223
+ const collection = ctx.collections.find(
224
+ (c) => c.name === uriParsed.collection
225
+ );
226
+ if (collection) {
227
+ absPath = pathJoin(collection.path, doc.relPath);
228
+ }
229
+ }
230
+
231
+ documents.push({
232
+ docid: doc.docid,
233
+ uri: doc.uri,
234
+ title: doc.title ?? undefined,
235
+ content,
236
+ totalLines: (contentResult.value ?? '').split('\n').length,
237
+ truncated,
238
+ source: {
239
+ absPath,
240
+ relPath: doc.relPath,
241
+ mime: doc.sourceMime,
242
+ ext: doc.sourceExt,
243
+ modifiedAt: doc.sourceMtime,
244
+ sizeBytes: doc.sourceSize,
245
+ },
246
+ });
247
+ }
248
+
249
+ const response: MultiGetResponse = {
250
+ documents,
251
+ skipped,
252
+ meta: {
253
+ requested: refs.length,
254
+ returned: documents.length,
255
+ skipped: skipped.length,
256
+ },
257
+ };
258
+
259
+ return response;
260
+ },
261
+ formatMultiGetResponse
262
+ );
263
+ }