@agimon-ai/doompi-grep 0.0.1-alpha.48 → 0.0.1-alpha.49

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.
@@ -0,0 +1,325 @@
1
+ Object.defineProperties(exports, {
2
+ __esModule: { value: true },
3
+ [Symbol.toStringTag]: { value: "Module" }
4
+ });
5
+ let _agimon_ai_doompi_core_mcp_facet = require("@agimon-ai/doompi-core/mcp-facet");
6
+ let node_fs_promises = require("node:fs/promises");
7
+ let node_path = require("node:path");
8
+ let _agimon_ai_doompi_hashline = require("@agimon-ai/doompi-hashline");
9
+ let _agimon_ai_doompi_hashline_files = require("@agimon-ai/doompi-hashline/files");
10
+ let _earendil_works_pi_coding_agent = require("@earendil-works/pi-coding-agent");
11
+ let typebox = require("typebox");
12
+ let node_child_process = require("node:child_process");
13
+ //#region src/schemas/grepTool.ts
14
+ const GrepParamsSchema = typebox.Type.Object({
15
+ pattern: typebox.Type.String({ description: "Regular expression or literal text to find." }),
16
+ path: typebox.Type.Optional(typebox.Type.String({ description: "File or directory to search. Defaults to the working directory." })),
17
+ glob: typebox.Type.Optional(typebox.Type.String({ description: "Glob filter for files to search." })),
18
+ ignoreCase: typebox.Type.Optional(typebox.Type.Boolean({ description: "Match without case sensitivity." })),
19
+ literal: typebox.Type.Optional(typebox.Type.Boolean({ description: "Treat pattern as literal text." })),
20
+ context: typebox.Type.Optional(typebox.Type.Integer({
21
+ minimum: 0,
22
+ description: "Context lines around every match."
23
+ })),
24
+ limit: typebox.Type.Optional(typebox.Type.Integer({
25
+ minimum: 1,
26
+ description: "Maximum number of matches."
27
+ }))
28
+ });
29
+ //#endregion
30
+ //#region src/services/ripgrep/index.ts
31
+ /**
32
+ * Searching with ripgrep, with no host in it.
33
+ *
34
+ * The interactive host delegates to Pi's own grep and only re-tags the result,
35
+ * so today this has one caller. It still belongs here rather than beside that
36
+ * caller: spawning a process and parsing its output is the same work whichever
37
+ * host asked for it, and a routed file's job is the shape a host expects, not
38
+ * the search underneath it.
39
+ */
40
+ async function runRipgrep(request) {
41
+ const { params, cwd, signal } = request;
42
+ const output = await spawnRipgrep(argumentsFor(params, cwd), cwd, signal);
43
+ if (output.trim() === "") return { content: [{
44
+ type: "text",
45
+ text: "No matches found"
46
+ }] };
47
+ return { content: [{
48
+ type: "text",
49
+ text: limitMatches(output, params.limit)
50
+ }] };
51
+ }
52
+ /** The ripgrep argv one search needs, exposed so a test can assert it without spawning. */
53
+ function argumentsFor(params, cwd) {
54
+ const target = (0, _agimon_ai_doompi_hashline_files.resolveInputPath)(params.path ?? ".", cwd);
55
+ const targetArg = (0, node_path.relative)(cwd, target) || ".";
56
+ const args = [
57
+ "--color",
58
+ "never",
59
+ "--no-heading",
60
+ "--with-filename",
61
+ "--line-number"
62
+ ];
63
+ if (params.ignoreCase === true) args.push("--ignore-case");
64
+ if (params.literal === true) args.push("--fixed-strings");
65
+ if (params.context !== void 0) args.push("--context", String(params.context));
66
+ if (params.glob !== void 0) args.push("--glob", params.glob);
67
+ args.push("--", params.pattern, targetArg);
68
+ return args;
69
+ }
70
+ /**
71
+ * Keeps at most `limit` matching lines, and every context line around them.
72
+ *
73
+ * A context line carries no `:<line>:` marker, which is what separates it from
74
+ * a match, so trimming by line count alone would drop the context ripgrep was
75
+ * asked for.
76
+ */
77
+ function limitMatches(output, limit) {
78
+ if (limit === void 0) return output.trimEnd();
79
+ let matches = 0;
80
+ const selected = [];
81
+ for (const line of output.trimEnd().split("\n")) {
82
+ if (/:(\d+):/u.test(line)) {
83
+ matches++;
84
+ if (matches > limit) continue;
85
+ }
86
+ selected.push(line);
87
+ }
88
+ return selected.join("\n");
89
+ }
90
+ function spawnRipgrep(args, cwd, signal) {
91
+ return new Promise((resolveOutput, reject) => {
92
+ const child = (0, node_child_process.spawn)("rg", args, {
93
+ cwd,
94
+ stdio: [
95
+ "ignore",
96
+ "pipe",
97
+ "pipe"
98
+ ]
99
+ });
100
+ let stdout = "";
101
+ let stderr = "";
102
+ child.stdout.on("data", (chunk) => {
103
+ stdout += chunk.toString();
104
+ });
105
+ child.stderr.on("data", (chunk) => {
106
+ stderr += chunk.toString();
107
+ });
108
+ const abort = () => {
109
+ child.kill("SIGTERM");
110
+ reject(/* @__PURE__ */ new Error("Operation aborted"));
111
+ };
112
+ if (signal?.aborted) {
113
+ abort();
114
+ return;
115
+ }
116
+ signal?.addEventListener("abort", abort, { once: true });
117
+ child.once("error", (error) => {
118
+ signal?.removeEventListener("abort", abort);
119
+ reject(new Error(`Could not start ripgrep: ${error.message}`, { cause: error }));
120
+ });
121
+ child.once("close", (code) => {
122
+ signal?.removeEventListener("abort", abort);
123
+ if (code === 0 || code === 1) {
124
+ resolveOutput(stdout);
125
+ return;
126
+ }
127
+ reject(new Error(stderr.trim() || `ripgrep exited with code ${code ?? "unknown"}`));
128
+ });
129
+ });
130
+ }
131
+ //#endregion
132
+ //#region src/services/grepTool/index.ts
133
+ const MATCH_DELIMITER = /:(\d+): /gu;
134
+ const CONTEXT_DELIMITER = /-(\d+)- /gu;
135
+ const NO_MATCHES = "No matches found";
136
+ function createHeadlessGrepTool() {
137
+ return {
138
+ name: "grep",
139
+ label: "grep",
140
+ description: "Search file contents using ripgrep semantics, then add exact-byte file tags and stable line anchors for writable matches. Respects .gitignore.",
141
+ promptSnippet: "Search file contents and return editable hashline anchors",
142
+ parameters: GrepParamsSchema,
143
+ executionMode: "parallel",
144
+ execute: async (_toolCallId, params, signal, _onUpdate, context) => {
145
+ return tagGrepResult(await runRipgrep({
146
+ params,
147
+ cwd: context.cwd,
148
+ signal
149
+ }), params, context.cwd, signal);
150
+ }
151
+ };
152
+ }
153
+ /**
154
+ * The Node adapters both hosts hand to `tagGrepResult`.
155
+ *
156
+ * Neither is host specific, and both were previously declared twice, once
157
+ * beside each tool. A routed file composes platform-specific output; the
158
+ * service owns everything underneath it, including the plumbing.
159
+ */
160
+ const nodeGrepFileSystem = {
161
+ stat: (path) => (0, node_fs_promises.stat)(path),
162
+ readFile: (path) => (0, node_fs_promises.readFile)(path)
163
+ };
164
+ const defaultGrepOutput = {
165
+ maxBytes: _earendil_works_pi_coding_agent.DEFAULT_MAX_BYTES,
166
+ formatSize: _earendil_works_pi_coding_agent.formatSize,
167
+ truncateHead: _earendil_works_pi_coding_agent.truncateHead,
168
+ truncateLine: _earendil_works_pi_coding_agent.truncateLine
169
+ };
170
+ async function tagGrepResult(nativeResult, params, cwd, signal, writable = _agimon_ai_doompi_hashline_files.isWritableFile, filesystem = nodeGrepFileSystem, outputOperations = defaultGrepOutput) {
171
+ const textPart = nativeResult.content.find((part) => part.type === "text");
172
+ if (!textPart || textPart.text === NO_MATCHES) return nativeResult;
173
+ const noticeIndex = textPart.text.lastIndexOf("\n\n[");
174
+ const rowsText = noticeIndex === -1 ? textPart.text : textPart.text.slice(0, noticeIndex);
175
+ const notice = noticeIndex === -1 ? "" : textPart.text.slice(noticeIndex);
176
+ const searchPath = (0, _agimon_ai_doompi_hashline_files.resolveInputPath)(params.path ?? ".", cwd);
177
+ const searchStats = await filesystem.stat(searchPath);
178
+ assertNotAborted(signal);
179
+ const rows = await parseValidatedGrepRows(rowsText, searchPath, searchStats.isDirectory(), signal, filesystem);
180
+ if (rows.length === 0) return nativeResult;
181
+ const groups = groupGrepRows(rows, searchPath, searchStats.isDirectory(), cwd);
182
+ const blocks = [];
183
+ let taggedFiles = 0;
184
+ let linesTruncated = nativeResult.details?.linesTruncated ?? false;
185
+ for (const group of groups.values()) {
186
+ assertNotAborted(signal);
187
+ const canEdit = await writable(group.absolutePath);
188
+ assertNotAborted(signal);
189
+ if (!canEdit) {
190
+ blocks.push(group.nativeRows.join("\n"));
191
+ continue;
192
+ }
193
+ let bytes;
194
+ try {
195
+ bytes = await filesystem.readFile(group.absolutePath);
196
+ } catch {
197
+ blocks.push(group.nativeRows.join("\n"));
198
+ continue;
199
+ }
200
+ assertNotAborted(signal);
201
+ const lines = (0, _agimon_ai_doompi_hashline.splitLines)((0, _agimon_ai_doompi_hashline_files.decodeUtf8)(bytes, group.path));
202
+ const output = [(0, _agimon_ai_doompi_hashline.formatFileHeader)(group.path, (0, _agimon_ai_doompi_hashline_files.computeFileTag)(bytes))];
203
+ for (const [lineNumber, isMatch] of [...group.rows].sort(([left], [right]) => left - right)) {
204
+ const line = lines[lineNumber - 1];
205
+ if (line === void 0) continue;
206
+ const compact = outputOperations.truncateLine(line);
207
+ if (compact.wasTruncated) linesTruncated = true;
208
+ output.push((0, _agimon_ai_doompi_hashline.formatTaggedLine)(compact.text, lineNumber, isMatch ? ">> " : " ", line));
209
+ }
210
+ if (output.length > 1) {
211
+ blocks.push(output.join("\n"));
212
+ taggedFiles++;
213
+ } else blocks.push(group.nativeRows.join("\n"));
214
+ }
215
+ if (blocks.length === 0 || taggedFiles === 0) return nativeResult;
216
+ const truncation = outputOperations.truncateHead(blocks.join("\n"), { maxLines: Number.MAX_SAFE_INTEGER });
217
+ const extraNotice = truncation.truncated ? `\n\n[${outputOperations.formatSize(outputOperations.maxBytes)} tagged output limit reached]` : "";
218
+ const details = {
219
+ ...nativeResult.details,
220
+ ...truncation.truncated ? { truncation } : {},
221
+ ...linesTruncated ? { linesTruncated: true } : {}
222
+ };
223
+ assertNotAborted(signal);
224
+ return {
225
+ ...nativeResult,
226
+ content: [{
227
+ type: "text",
228
+ text: `${truncation.content}${notice}${extraNotice}`
229
+ }],
230
+ details: Object.keys(details).length > 0 ? details : void 0
231
+ };
232
+ }
233
+ function groupGrepRows(rows, searchPath, directory, cwd) {
234
+ const groups = /* @__PURE__ */ new Map();
235
+ for (const row of rows) {
236
+ const absolutePath = directory ? (0, node_path.resolve)(searchPath, row.path) : searchPath;
237
+ let group = groups.get(absolutePath);
238
+ if (!group) {
239
+ group = {
240
+ path: (0, _agimon_ai_doompi_hashline_files.displayPath)(absolutePath, cwd),
241
+ absolutePath,
242
+ nativeRows: [],
243
+ rows: /* @__PURE__ */ new Map()
244
+ };
245
+ groups.set(absolutePath, group);
246
+ }
247
+ if (row.native !== void 0) group.nativeRows.push(row.native);
248
+ group.rows.set(row.line, row.match || group.rows.get(row.line) === true);
249
+ }
250
+ return groups;
251
+ }
252
+ async function parseValidatedGrepRows(rowsText, searchPath, directory, signal, filesystem) {
253
+ const pathCache = /* @__PURE__ */ new Map();
254
+ const rows = [];
255
+ for (const value of rowsText.split("\n")) {
256
+ assertNotAborted(signal);
257
+ let resolvedCandidate;
258
+ for (const candidate of grepRowCandidates(value)) {
259
+ if (!directory) {
260
+ if (candidate.path === (0, node_path.basename)(searchPath)) rows.push({
261
+ ...candidate,
262
+ native: value
263
+ });
264
+ break;
265
+ }
266
+ const absolutePath = (0, node_path.resolve)(searchPath, candidate.path);
267
+ let exists = pathCache.get(absolutePath);
268
+ if (exists === void 0) {
269
+ exists = await filesystem.stat(absolutePath).then((value) => value.isFile()).catch(() => false);
270
+ assertNotAborted(signal);
271
+ pathCache.set(absolutePath, exists);
272
+ }
273
+ if (exists) resolvedCandidate = {
274
+ ...candidate,
275
+ native: value
276
+ };
277
+ }
278
+ if (resolvedCandidate) rows.push(resolvedCandidate);
279
+ }
280
+ return rows;
281
+ }
282
+ function grepRowCandidates(value) {
283
+ const candidates = [];
284
+ for (const match of value.matchAll(MATCH_DELIMITER)) candidates.push({
285
+ index: match.index,
286
+ row: {
287
+ path: value.slice(0, match.index),
288
+ line: Number.parseInt(match[1] ?? "", 10),
289
+ match: true
290
+ }
291
+ });
292
+ for (const match of value.matchAll(CONTEXT_DELIMITER)) candidates.push({
293
+ index: match.index,
294
+ row: {
295
+ path: value.slice(0, match.index),
296
+ line: Number.parseInt(match[1] ?? "", 10),
297
+ match: false
298
+ }
299
+ });
300
+ return candidates.sort((left, right) => left.index - right.index).map(({ row }) => row);
301
+ }
302
+ function assertNotAborted(signal) {
303
+ if (signal?.aborted) throw new Error("Operation aborted");
304
+ }
305
+ //#endregion
306
+ //#region src/extensions/workspaces/sessions/(backend)/tool/grep.mcp.ts
307
+ var grep_mcp_default = (0, _agimon_ai_doompi_core_mcp_facet.defineMcpTool)(createHeadlessGrepTool);
308
+ //#endregion
309
+ //#region generated/mcp.ts
310
+ const at = (value, context) => typeof value === "function" ? value(context) : value;
311
+ const via = (identity, value) => ({
312
+ ...identity,
313
+ ...value
314
+ });
315
+ const mcp = (0, _agimon_ai_doompi_core_mcp_facet.defineMcpPlugin)({
316
+ name: "@agimon-ai/doompi-grep",
317
+ session: (context) => ({ get tools() {
318
+ return [via({ name: "grep" }, at(grep_mcp_default, context))];
319
+ } })
320
+ });
321
+ //#endregion
322
+ exports.default = mcp;
323
+ exports.mcp = mcp;
324
+
325
+ //# sourceMappingURL=mcp.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp.cjs","names":["Type","resolveInputPath","relative","spawn","stat","readFile","DEFAULT_MAX_BYTES","isWritableFile","resolveInputPath","splitLines","decodeUtf8","formatFileHeader","computeFileTag","formatTaggedLine","resolve","displayPath","basename","defineMcpTool","defineMcpPlugin","toolGrep"],"sources":["../../src/schemas/grepTool.ts","../../src/services/ripgrep/index.ts","../../src/services/grepTool/index.ts","../../src/extensions/workspaces/sessions/(backend)/tool/grep.mcp.ts","../../generated/mcp.ts"],"sourcesContent":["import { type Static, Type } from 'typebox';\n\nexport const GrepParamsSchema = Type.Object({\n pattern: Type.String({ description: 'Regular expression or literal text to find.' }),\n path: Type.Optional(Type.String({ description: 'File or directory to search. Defaults to the working directory.' })),\n glob: Type.Optional(Type.String({ description: 'Glob filter for files to search.' })),\n ignoreCase: Type.Optional(Type.Boolean({ description: 'Match without case sensitivity.' })),\n literal: Type.Optional(Type.Boolean({ description: 'Treat pattern as literal text.' })),\n context: Type.Optional(Type.Integer({ minimum: 0, description: 'Context lines around every match.' })),\n limit: Type.Optional(Type.Integer({ minimum: 1, description: 'Maximum number of matches.' })),\n});\n\nexport type GrepParams = Static<typeof GrepParamsSchema>;\n","import { spawn } from 'node:child_process';\nimport { relative } from 'node:path';\n\nimport { resolveInputPath } from '@agimon-ai/doompi-hashline/files';\n\nimport type { GrepParams } from '../../schemas/grepTool';\nimport type { RipgrepRequest, RipgrepResult } from './type';\n\n/**\n * Searching with ripgrep, with no host in it.\n *\n * The interactive host delegates to Pi's own grep and only re-tags the result,\n * so today this has one caller. It still belongs here rather than beside that\n * caller: spawning a process and parsing its output is the same work whichever\n * host asked for it, and a routed file's job is the shape a host expects, not\n * the search underneath it.\n */\nexport async function runRipgrep(request: RipgrepRequest): Promise<RipgrepResult> {\n const { params, cwd, signal } = request;\n const output = await spawnRipgrep(argumentsFor(params, cwd), cwd, signal);\n if (output.trim() === '') return { content: [{ type: 'text', text: 'No matches found' }] };\n return { content: [{ type: 'text', text: limitMatches(output, params.limit) }] };\n}\n\n/** The ripgrep argv one search needs, exposed so a test can assert it without spawning. */\nexport function argumentsFor(params: GrepParams, cwd: string): string[] {\n const target = resolveInputPath(params.path ?? '.', cwd);\n const targetArg = relative(cwd, target) || '.';\n const args = ['--color', 'never', '--no-heading', '--with-filename', '--line-number'];\n if (params.ignoreCase === true) args.push('--ignore-case');\n if (params.literal === true) args.push('--fixed-strings');\n if (params.context !== undefined) args.push('--context', String(params.context));\n if (params.glob !== undefined) args.push('--glob', params.glob);\n args.push('--', params.pattern, targetArg);\n return args;\n}\n\n/**\n * Keeps at most `limit` matching lines, and every context line around them.\n *\n * A context line carries no `:<line>:` marker, which is what separates it from\n * a match, so trimming by line count alone would drop the context ripgrep was\n * asked for.\n */\nexport function limitMatches(output: string, limit: number | undefined): string {\n if (limit === undefined) return output.trimEnd();\n let matches = 0;\n const selected: string[] = [];\n for (const line of output.trimEnd().split('\\n')) {\n if (/:(\\d+):/u.test(line)) {\n matches++;\n if (matches > limit) continue;\n }\n selected.push(line);\n }\n return selected.join('\\n');\n}\n\nfunction spawnRipgrep(args: readonly string[], cwd: string, signal: AbortSignal | undefined): Promise<string> {\n return new Promise((resolveOutput, reject) => {\n const child = spawn('rg', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });\n let stdout = '';\n let stderr = '';\n child.stdout.on('data', (chunk: Buffer) => {\n stdout += chunk.toString();\n });\n child.stderr.on('data', (chunk: Buffer) => {\n stderr += chunk.toString();\n });\n const abort = (): void => {\n child.kill('SIGTERM');\n reject(new Error('Operation aborted'));\n };\n if (signal?.aborted) {\n abort();\n return;\n }\n signal?.addEventListener('abort', abort, { once: true });\n child.once('error', (error) => {\n signal?.removeEventListener('abort', abort);\n reject(new Error(`Could not start ripgrep: ${error.message}`, { cause: error }));\n });\n child.once('close', (code) => {\n signal?.removeEventListener('abort', abort);\n // 1 is ripgrep's \"no matches\", which is a result rather than a failure.\n if (code === 0 || code === 1) {\n resolveOutput(stdout);\n return;\n }\n reject(new Error(stderr.trim() || `ripgrep exited with code ${code ?? 'unknown'}`));\n });\n });\n}\n","import { readFile, stat } from 'node:fs/promises';\nimport { basename, resolve } from 'node:path';\n\nimport type {\n DoomHeadlessExecutionContext,\n DoomHeadlessTool,\n DoomHeadlessToolResult,\n} from '@agimon-ai/doompi-core/headless';\nimport { formatFileHeader, formatTaggedLine, splitLines } from '@agimon-ai/doompi-hashline';\nimport {\n computeFileTag,\n decodeUtf8,\n displayPath,\n isWritableFile,\n resolveInputPath,\n} from '@agimon-ai/doompi-hashline/files';\nimport { DEFAULT_MAX_BYTES, formatSize, truncateHead, truncateLine } from '@earendil-works/pi-coding-agent';\n\nimport { GrepParamsSchema, type GrepParams } from '../../schemas/grepTool';\nimport { runRipgrep } from '../ripgrep';\n\nconst MATCH_DELIMITER = /:(\\d+): /gu;\nconst CONTEXT_DELIMITER = /-(\\d+)- /gu;\nconst NO_MATCHES = 'No matches found';\n\nexport interface GrepRow {\n readonly path: string;\n readonly line: number;\n readonly match: boolean;\n readonly native?: string;\n}\n\nexport interface GrepGroup {\n readonly path: string;\n readonly absolutePath: string;\n readonly nativeRows: string[];\n readonly rows: Map<number, boolean>;\n}\n\nexport interface GrepResult {\n content: Array<{ type: 'text'; text: string }>;\n details?: {\n readonly linesTruncated?: boolean;\n readonly truncation?: GrepTruncation;\n readonly [key: string]: unknown;\n };\n}\n\nexport interface GrepFileStats {\n isDirectory(): boolean;\n isFile(): boolean;\n}\n\nexport interface GrepFileSystem {\n stat(path: string): Promise<GrepFileStats>;\n readFile(path: string): Promise<Uint8Array>;\n}\n\nexport interface GrepTruncation {\n readonly content: string;\n readonly truncated: boolean;\n readonly truncatedBy: 'lines' | 'bytes' | null;\n readonly outputLines: number;\n}\n\nexport interface GrepOutputOperations {\n readonly maxBytes: number;\n formatSize(bytes: number): string;\n truncateHead(content: string, options: { readonly maxLines?: number }): GrepTruncation;\n truncateLine(line: string): { readonly text: string; readonly wasTruncated: boolean };\n}\nexport function createHeadlessGrepTool(): DoomHeadlessTool<typeof GrepParamsSchema> {\n return {\n name: 'grep',\n label: 'grep',\n description:\n 'Search file contents using ripgrep semantics, then add exact-byte file tags and stable line anchors for writable matches. Respects .gitignore.',\n promptSnippet: 'Search file contents and return editable hashline anchors',\n parameters: GrepParamsSchema,\n executionMode: 'parallel',\n execute: async (\n _toolCallId: string,\n params: GrepParams,\n signal: AbortSignal | undefined,\n _onUpdate: ((result: DoomHeadlessToolResult) => void) | undefined,\n context: DoomHeadlessExecutionContext,\n ) => {\n const result = await runRipgrep({ params, cwd: context.cwd, signal });\n return tagGrepResult(result, params, context.cwd, signal);\n },\n };\n}\n\nexport function parseGrepRow(value: string): GrepRow[] {\n return grepRowCandidates(value)\n .slice(0, 1)\n .map(({ line, match, path }) => ({ line, match, path }));\n}\n\n/**\n * The Node adapters both hosts hand to `tagGrepResult`.\n *\n * Neither is host specific, and both were previously declared twice, once\n * beside each tool. A routed file composes platform-specific output; the\n * service owns everything underneath it, including the plumbing.\n */\nexport const nodeGrepFileSystem: GrepFileSystem = {\n stat: (path) => stat(path),\n readFile: (path) => readFile(path),\n};\n\nexport const defaultGrepOutput: GrepOutputOperations = {\n maxBytes: DEFAULT_MAX_BYTES,\n formatSize,\n truncateHead,\n truncateLine,\n};\n\nexport async function tagGrepResult(\n nativeResult: GrepResult,\n params: GrepParams,\n cwd: string,\n signal: AbortSignal | undefined,\n writable: (path: string) => Promise<boolean> = isWritableFile,\n filesystem: GrepFileSystem = nodeGrepFileSystem,\n outputOperations: GrepOutputOperations = defaultGrepOutput,\n): Promise<GrepResult> {\n const textPart = nativeResult.content.find((part) => part.type === 'text');\n if (!textPart || textPart.text === NO_MATCHES) return nativeResult;\n\n const noticeIndex = textPart.text.lastIndexOf('\\n\\n[');\n const rowsText = noticeIndex === -1 ? textPart.text : textPart.text.slice(0, noticeIndex);\n const notice = noticeIndex === -1 ? '' : textPart.text.slice(noticeIndex);\n const searchPath = resolveInputPath(params.path ?? '.', cwd);\n const searchStats = await filesystem.stat(searchPath);\n assertNotAborted(signal);\n const rows = await parseValidatedGrepRows(rowsText, searchPath, searchStats.isDirectory(), signal, filesystem);\n if (rows.length === 0) return nativeResult;\n\n const groups = groupGrepRows(rows, searchPath, searchStats.isDirectory(), cwd);\n const blocks: string[] = [];\n let taggedFiles = 0;\n let linesTruncated = nativeResult.details?.linesTruncated ?? false;\n\n for (const group of groups.values()) {\n assertNotAborted(signal);\n const canEdit = await writable(group.absolutePath);\n assertNotAborted(signal);\n if (!canEdit) {\n blocks.push(group.nativeRows.join('\\n'));\n continue;\n }\n let bytes: Uint8Array;\n try {\n bytes = await filesystem.readFile(group.absolutePath);\n } catch {\n blocks.push(group.nativeRows.join('\\n'));\n continue;\n }\n assertNotAborted(signal);\n const lines = splitLines(decodeUtf8(bytes, group.path));\n const output = [formatFileHeader(group.path, computeFileTag(bytes))];\n for (const [lineNumber, isMatch] of [...group.rows].sort(([left], [right]) => left - right)) {\n const line = lines[lineNumber - 1];\n if (line === undefined) continue;\n const compact = outputOperations.truncateLine(line);\n if (compact.wasTruncated) linesTruncated = true;\n output.push(formatTaggedLine(compact.text, lineNumber, isMatch ? '>> ' : ' ', line));\n }\n if (output.length > 1) {\n blocks.push(output.join('\\n'));\n taggedFiles++;\n } else {\n blocks.push(group.nativeRows.join('\\n'));\n }\n }\n\n if (blocks.length === 0 || taggedFiles === 0) return nativeResult;\n const truncation = outputOperations.truncateHead(blocks.join('\\n'), { maxLines: Number.MAX_SAFE_INTEGER });\n const extraNotice = truncation.truncated\n ? `\\n\\n[${outputOperations.formatSize(outputOperations.maxBytes)} tagged output limit reached]`\n : '';\n const details = {\n ...nativeResult.details,\n ...(truncation.truncated ? { truncation } : {}),\n ...(linesTruncated ? { linesTruncated: true } : {}),\n };\n assertNotAborted(signal);\n return {\n ...nativeResult,\n content: [{ type: 'text', text: `${truncation.content}${notice}${extraNotice}` }],\n details: Object.keys(details).length > 0 ? details : undefined,\n };\n}\n\nexport function groupGrepRows(\n rows: readonly GrepRow[],\n searchPath: string,\n directory: boolean,\n cwd: string,\n): Map<string, GrepGroup> {\n const groups = new Map<string, GrepGroup>();\n for (const row of rows) {\n const absolutePath = directory ? resolve(searchPath, row.path) : searchPath;\n let group = groups.get(absolutePath);\n if (!group) {\n group = { path: displayPath(absolutePath, cwd), absolutePath, nativeRows: [], rows: new Map() };\n groups.set(absolutePath, group);\n }\n if (row.native !== undefined) group.nativeRows.push(row.native);\n group.rows.set(row.line, row.match || group.rows.get(row.line) === true);\n }\n return groups;\n}\n\nasync function parseValidatedGrepRows(\n rowsText: string,\n searchPath: string,\n directory: boolean,\n signal: AbortSignal | undefined,\n filesystem: GrepFileSystem,\n): Promise<GrepRow[]> {\n const pathCache = new Map<string, boolean>();\n const rows: GrepRow[] = [];\n for (const value of rowsText.split('\\n')) {\n assertNotAborted(signal);\n let resolvedCandidate: GrepRow | undefined;\n for (const candidate of grepRowCandidates(value)) {\n if (!directory) {\n if (candidate.path === basename(searchPath)) rows.push({ ...candidate, native: value });\n break;\n }\n const absolutePath = resolve(searchPath, candidate.path);\n let exists = pathCache.get(absolutePath);\n if (exists === undefined) {\n exists = await filesystem\n .stat(absolutePath)\n .then((value) => value.isFile())\n .catch(() => false);\n assertNotAborted(signal);\n pathCache.set(absolutePath, exists);\n }\n if (exists) resolvedCandidate = { ...candidate, native: value };\n }\n if (resolvedCandidate) rows.push(resolvedCandidate);\n }\n return rows;\n}\n\nfunction grepRowCandidates(value: string): GrepRow[] {\n const candidates: Array<{ readonly index: number; readonly row: GrepRow }> = [];\n for (const match of value.matchAll(MATCH_DELIMITER)) {\n candidates.push({\n index: match.index,\n row: { path: value.slice(0, match.index), line: Number.parseInt(match[1] ?? '', 10), match: true },\n });\n }\n for (const match of value.matchAll(CONTEXT_DELIMITER)) {\n candidates.push({\n index: match.index,\n row: { path: value.slice(0, match.index), line: Number.parseInt(match[1] ?? '', 10), match: false },\n });\n }\n return candidates.sort((left, right) => left.index - right.index).map(({ row }) => row);\n}\n\nexport function assertNotAborted(signal: AbortSignal | undefined): void {\n if (signal?.aborted) throw new Error('Operation aborted');\n}\n","import { defineMcpTool } from '@agimon-ai/doompi-core/mcp-facet';\n\nimport { createHeadlessGrepTool } from '../../../../../services/grepTool';\n\nexport default defineMcpTool(createHeadlessGrepTool);\n","// Generated by @agimon-ai/doompi-build. Do not edit by hand.\nimport { defineMcpPlugin, type DoomMcpSessionPlugin } from '@agimon-ai/doompi-core/mcp-facet';\n\nimport toolGrep from '../src/extensions/workspaces/sessions/(backend)/tool/grep.mcp';\n\ntype Factory<T, C> = (context: C) => T;\nconst at = <T, C>(value: T | Factory<T, C>, context: C): T =>\n typeof value === 'function' ? (value as Factory<T, C>)(context) : value;\nconst via = <T>(identity: Record<string, unknown>, value: unknown): T => ({ ...identity, ...(value as object) }) as T;\n\nexport const mcp = defineMcpPlugin({\n name: '@agimon-ai/doompi-grep',\n session: (context) => ({\n get tools(): DoomMcpSessionPlugin['tools'] { return [via({ name: 'grep' }, at(toolGrep, context))]; },\n }) satisfies DoomMcpSessionPlugin,\n});\n\nexport default mcp;\n"],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ //#region generated/mcp.d.ts
2
+ export declare const mcp: import("@agimon-ai/doompi-core/mcp-facet").DoomMcpPluginDefinition;
3
+ //#endregion
4
+ export { mcp as default };
5
+ //# sourceMappingURL=mcp.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp.d.cts","names":[],"sources":["../../generated/mcp.ts"],"mappings":";qBAUa,gDAAG"}
@@ -0,0 +1,5 @@
1
+ //#region generated/mcp.d.ts
2
+ export declare const mcp: import("@agimon-ai/doompi-core/mcp-facet").DoomMcpPluginDefinition;
3
+ //#endregion
4
+ export { mcp as default };
5
+ //# sourceMappingURL=mcp.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp.d.mts","names":[],"sources":["../../generated/mcp.ts"],"mappings":";qBAUa,gDAAG"}
@@ -0,0 +1,320 @@
1
+ import { defineMcpPlugin, defineMcpTool } from "@agimon-ai/doompi-core/mcp-facet";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { basename, relative, resolve } from "node:path";
4
+ import { formatFileHeader, formatTaggedLine, splitLines } from "@agimon-ai/doompi-hashline";
5
+ import { computeFileTag, decodeUtf8, displayPath, isWritableFile, resolveInputPath } from "@agimon-ai/doompi-hashline/files";
6
+ import { DEFAULT_MAX_BYTES, formatSize, truncateHead, truncateLine } from "@earendil-works/pi-coding-agent";
7
+ import { Type } from "typebox";
8
+ import { spawn } from "node:child_process";
9
+ //#region src/schemas/grepTool.ts
10
+ const GrepParamsSchema = Type.Object({
11
+ pattern: Type.String({ description: "Regular expression or literal text to find." }),
12
+ path: Type.Optional(Type.String({ description: "File or directory to search. Defaults to the working directory." })),
13
+ glob: Type.Optional(Type.String({ description: "Glob filter for files to search." })),
14
+ ignoreCase: Type.Optional(Type.Boolean({ description: "Match without case sensitivity." })),
15
+ literal: Type.Optional(Type.Boolean({ description: "Treat pattern as literal text." })),
16
+ context: Type.Optional(Type.Integer({
17
+ minimum: 0,
18
+ description: "Context lines around every match."
19
+ })),
20
+ limit: Type.Optional(Type.Integer({
21
+ minimum: 1,
22
+ description: "Maximum number of matches."
23
+ }))
24
+ });
25
+ //#endregion
26
+ //#region src/services/ripgrep/index.ts
27
+ /**
28
+ * Searching with ripgrep, with no host in it.
29
+ *
30
+ * The interactive host delegates to Pi's own grep and only re-tags the result,
31
+ * so today this has one caller. It still belongs here rather than beside that
32
+ * caller: spawning a process and parsing its output is the same work whichever
33
+ * host asked for it, and a routed file's job is the shape a host expects, not
34
+ * the search underneath it.
35
+ */
36
+ async function runRipgrep(request) {
37
+ const { params, cwd, signal } = request;
38
+ const output = await spawnRipgrep(argumentsFor(params, cwd), cwd, signal);
39
+ if (output.trim() === "") return { content: [{
40
+ type: "text",
41
+ text: "No matches found"
42
+ }] };
43
+ return { content: [{
44
+ type: "text",
45
+ text: limitMatches(output, params.limit)
46
+ }] };
47
+ }
48
+ /** The ripgrep argv one search needs, exposed so a test can assert it without spawning. */
49
+ function argumentsFor(params, cwd) {
50
+ const target = resolveInputPath(params.path ?? ".", cwd);
51
+ const targetArg = relative(cwd, target) || ".";
52
+ const args = [
53
+ "--color",
54
+ "never",
55
+ "--no-heading",
56
+ "--with-filename",
57
+ "--line-number"
58
+ ];
59
+ if (params.ignoreCase === true) args.push("--ignore-case");
60
+ if (params.literal === true) args.push("--fixed-strings");
61
+ if (params.context !== void 0) args.push("--context", String(params.context));
62
+ if (params.glob !== void 0) args.push("--glob", params.glob);
63
+ args.push("--", params.pattern, targetArg);
64
+ return args;
65
+ }
66
+ /**
67
+ * Keeps at most `limit` matching lines, and every context line around them.
68
+ *
69
+ * A context line carries no `:<line>:` marker, which is what separates it from
70
+ * a match, so trimming by line count alone would drop the context ripgrep was
71
+ * asked for.
72
+ */
73
+ function limitMatches(output, limit) {
74
+ if (limit === void 0) return output.trimEnd();
75
+ let matches = 0;
76
+ const selected = [];
77
+ for (const line of output.trimEnd().split("\n")) {
78
+ if (/:(\d+):/u.test(line)) {
79
+ matches++;
80
+ if (matches > limit) continue;
81
+ }
82
+ selected.push(line);
83
+ }
84
+ return selected.join("\n");
85
+ }
86
+ function spawnRipgrep(args, cwd, signal) {
87
+ return new Promise((resolveOutput, reject) => {
88
+ const child = spawn("rg", args, {
89
+ cwd,
90
+ stdio: [
91
+ "ignore",
92
+ "pipe",
93
+ "pipe"
94
+ ]
95
+ });
96
+ let stdout = "";
97
+ let stderr = "";
98
+ child.stdout.on("data", (chunk) => {
99
+ stdout += chunk.toString();
100
+ });
101
+ child.stderr.on("data", (chunk) => {
102
+ stderr += chunk.toString();
103
+ });
104
+ const abort = () => {
105
+ child.kill("SIGTERM");
106
+ reject(/* @__PURE__ */ new Error("Operation aborted"));
107
+ };
108
+ if (signal?.aborted) {
109
+ abort();
110
+ return;
111
+ }
112
+ signal?.addEventListener("abort", abort, { once: true });
113
+ child.once("error", (error) => {
114
+ signal?.removeEventListener("abort", abort);
115
+ reject(new Error(`Could not start ripgrep: ${error.message}`, { cause: error }));
116
+ });
117
+ child.once("close", (code) => {
118
+ signal?.removeEventListener("abort", abort);
119
+ if (code === 0 || code === 1) {
120
+ resolveOutput(stdout);
121
+ return;
122
+ }
123
+ reject(new Error(stderr.trim() || `ripgrep exited with code ${code ?? "unknown"}`));
124
+ });
125
+ });
126
+ }
127
+ //#endregion
128
+ //#region src/services/grepTool/index.ts
129
+ const MATCH_DELIMITER = /:(\d+): /gu;
130
+ const CONTEXT_DELIMITER = /-(\d+)- /gu;
131
+ const NO_MATCHES = "No matches found";
132
+ function createHeadlessGrepTool() {
133
+ return {
134
+ name: "grep",
135
+ label: "grep",
136
+ description: "Search file contents using ripgrep semantics, then add exact-byte file tags and stable line anchors for writable matches. Respects .gitignore.",
137
+ promptSnippet: "Search file contents and return editable hashline anchors",
138
+ parameters: GrepParamsSchema,
139
+ executionMode: "parallel",
140
+ execute: async (_toolCallId, params, signal, _onUpdate, context) => {
141
+ return tagGrepResult(await runRipgrep({
142
+ params,
143
+ cwd: context.cwd,
144
+ signal
145
+ }), params, context.cwd, signal);
146
+ }
147
+ };
148
+ }
149
+ /**
150
+ * The Node adapters both hosts hand to `tagGrepResult`.
151
+ *
152
+ * Neither is host specific, and both were previously declared twice, once
153
+ * beside each tool. A routed file composes platform-specific output; the
154
+ * service owns everything underneath it, including the plumbing.
155
+ */
156
+ const nodeGrepFileSystem = {
157
+ stat: (path) => stat(path),
158
+ readFile: (path) => readFile(path)
159
+ };
160
+ const defaultGrepOutput = {
161
+ maxBytes: DEFAULT_MAX_BYTES,
162
+ formatSize,
163
+ truncateHead,
164
+ truncateLine
165
+ };
166
+ async function tagGrepResult(nativeResult, params, cwd, signal, writable = isWritableFile, filesystem = nodeGrepFileSystem, outputOperations = defaultGrepOutput) {
167
+ const textPart = nativeResult.content.find((part) => part.type === "text");
168
+ if (!textPart || textPart.text === NO_MATCHES) return nativeResult;
169
+ const noticeIndex = textPart.text.lastIndexOf("\n\n[");
170
+ const rowsText = noticeIndex === -1 ? textPart.text : textPart.text.slice(0, noticeIndex);
171
+ const notice = noticeIndex === -1 ? "" : textPart.text.slice(noticeIndex);
172
+ const searchPath = resolveInputPath(params.path ?? ".", cwd);
173
+ const searchStats = await filesystem.stat(searchPath);
174
+ assertNotAborted(signal);
175
+ const rows = await parseValidatedGrepRows(rowsText, searchPath, searchStats.isDirectory(), signal, filesystem);
176
+ if (rows.length === 0) return nativeResult;
177
+ const groups = groupGrepRows(rows, searchPath, searchStats.isDirectory(), cwd);
178
+ const blocks = [];
179
+ let taggedFiles = 0;
180
+ let linesTruncated = nativeResult.details?.linesTruncated ?? false;
181
+ for (const group of groups.values()) {
182
+ assertNotAborted(signal);
183
+ const canEdit = await writable(group.absolutePath);
184
+ assertNotAborted(signal);
185
+ if (!canEdit) {
186
+ blocks.push(group.nativeRows.join("\n"));
187
+ continue;
188
+ }
189
+ let bytes;
190
+ try {
191
+ bytes = await filesystem.readFile(group.absolutePath);
192
+ } catch {
193
+ blocks.push(group.nativeRows.join("\n"));
194
+ continue;
195
+ }
196
+ assertNotAborted(signal);
197
+ const lines = splitLines(decodeUtf8(bytes, group.path));
198
+ const output = [formatFileHeader(group.path, computeFileTag(bytes))];
199
+ for (const [lineNumber, isMatch] of [...group.rows].sort(([left], [right]) => left - right)) {
200
+ const line = lines[lineNumber - 1];
201
+ if (line === void 0) continue;
202
+ const compact = outputOperations.truncateLine(line);
203
+ if (compact.wasTruncated) linesTruncated = true;
204
+ output.push(formatTaggedLine(compact.text, lineNumber, isMatch ? ">> " : " ", line));
205
+ }
206
+ if (output.length > 1) {
207
+ blocks.push(output.join("\n"));
208
+ taggedFiles++;
209
+ } else blocks.push(group.nativeRows.join("\n"));
210
+ }
211
+ if (blocks.length === 0 || taggedFiles === 0) return nativeResult;
212
+ const truncation = outputOperations.truncateHead(blocks.join("\n"), { maxLines: Number.MAX_SAFE_INTEGER });
213
+ const extraNotice = truncation.truncated ? `\n\n[${outputOperations.formatSize(outputOperations.maxBytes)} tagged output limit reached]` : "";
214
+ const details = {
215
+ ...nativeResult.details,
216
+ ...truncation.truncated ? { truncation } : {},
217
+ ...linesTruncated ? { linesTruncated: true } : {}
218
+ };
219
+ assertNotAborted(signal);
220
+ return {
221
+ ...nativeResult,
222
+ content: [{
223
+ type: "text",
224
+ text: `${truncation.content}${notice}${extraNotice}`
225
+ }],
226
+ details: Object.keys(details).length > 0 ? details : void 0
227
+ };
228
+ }
229
+ function groupGrepRows(rows, searchPath, directory, cwd) {
230
+ const groups = /* @__PURE__ */ new Map();
231
+ for (const row of rows) {
232
+ const absolutePath = directory ? resolve(searchPath, row.path) : searchPath;
233
+ let group = groups.get(absolutePath);
234
+ if (!group) {
235
+ group = {
236
+ path: displayPath(absolutePath, cwd),
237
+ absolutePath,
238
+ nativeRows: [],
239
+ rows: /* @__PURE__ */ new Map()
240
+ };
241
+ groups.set(absolutePath, group);
242
+ }
243
+ if (row.native !== void 0) group.nativeRows.push(row.native);
244
+ group.rows.set(row.line, row.match || group.rows.get(row.line) === true);
245
+ }
246
+ return groups;
247
+ }
248
+ async function parseValidatedGrepRows(rowsText, searchPath, directory, signal, filesystem) {
249
+ const pathCache = /* @__PURE__ */ new Map();
250
+ const rows = [];
251
+ for (const value of rowsText.split("\n")) {
252
+ assertNotAborted(signal);
253
+ let resolvedCandidate;
254
+ for (const candidate of grepRowCandidates(value)) {
255
+ if (!directory) {
256
+ if (candidate.path === basename(searchPath)) rows.push({
257
+ ...candidate,
258
+ native: value
259
+ });
260
+ break;
261
+ }
262
+ const absolutePath = resolve(searchPath, candidate.path);
263
+ let exists = pathCache.get(absolutePath);
264
+ if (exists === void 0) {
265
+ exists = await filesystem.stat(absolutePath).then((value) => value.isFile()).catch(() => false);
266
+ assertNotAborted(signal);
267
+ pathCache.set(absolutePath, exists);
268
+ }
269
+ if (exists) resolvedCandidate = {
270
+ ...candidate,
271
+ native: value
272
+ };
273
+ }
274
+ if (resolvedCandidate) rows.push(resolvedCandidate);
275
+ }
276
+ return rows;
277
+ }
278
+ function grepRowCandidates(value) {
279
+ const candidates = [];
280
+ for (const match of value.matchAll(MATCH_DELIMITER)) candidates.push({
281
+ index: match.index,
282
+ row: {
283
+ path: value.slice(0, match.index),
284
+ line: Number.parseInt(match[1] ?? "", 10),
285
+ match: true
286
+ }
287
+ });
288
+ for (const match of value.matchAll(CONTEXT_DELIMITER)) candidates.push({
289
+ index: match.index,
290
+ row: {
291
+ path: value.slice(0, match.index),
292
+ line: Number.parseInt(match[1] ?? "", 10),
293
+ match: false
294
+ }
295
+ });
296
+ return candidates.sort((left, right) => left.index - right.index).map(({ row }) => row);
297
+ }
298
+ function assertNotAborted(signal) {
299
+ if (signal?.aborted) throw new Error("Operation aborted");
300
+ }
301
+ //#endregion
302
+ //#region src/extensions/workspaces/sessions/(backend)/tool/grep.mcp.ts
303
+ var grep_mcp_default = defineMcpTool(createHeadlessGrepTool);
304
+ //#endregion
305
+ //#region generated/mcp.ts
306
+ const at = (value, context) => typeof value === "function" ? value(context) : value;
307
+ const via = (identity, value) => ({
308
+ ...identity,
309
+ ...value
310
+ });
311
+ const mcp = defineMcpPlugin({
312
+ name: "@agimon-ai/doompi-grep",
313
+ session: (context) => ({ get tools() {
314
+ return [via({ name: "grep" }, at(grep_mcp_default, context))];
315
+ } })
316
+ });
317
+ //#endregion
318
+ export { mcp as default, mcp };
319
+
320
+ //# sourceMappingURL=mcp.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp.mjs","names":["toolGrep"],"sources":["../../src/schemas/grepTool.ts","../../src/services/ripgrep/index.ts","../../src/services/grepTool/index.ts","../../src/extensions/workspaces/sessions/(backend)/tool/grep.mcp.ts","../../generated/mcp.ts"],"sourcesContent":["import { type Static, Type } from 'typebox';\n\nexport const GrepParamsSchema = Type.Object({\n pattern: Type.String({ description: 'Regular expression or literal text to find.' }),\n path: Type.Optional(Type.String({ description: 'File or directory to search. Defaults to the working directory.' })),\n glob: Type.Optional(Type.String({ description: 'Glob filter for files to search.' })),\n ignoreCase: Type.Optional(Type.Boolean({ description: 'Match without case sensitivity.' })),\n literal: Type.Optional(Type.Boolean({ description: 'Treat pattern as literal text.' })),\n context: Type.Optional(Type.Integer({ minimum: 0, description: 'Context lines around every match.' })),\n limit: Type.Optional(Type.Integer({ minimum: 1, description: 'Maximum number of matches.' })),\n});\n\nexport type GrepParams = Static<typeof GrepParamsSchema>;\n","import { spawn } from 'node:child_process';\nimport { relative } from 'node:path';\n\nimport { resolveInputPath } from '@agimon-ai/doompi-hashline/files';\n\nimport type { GrepParams } from '../../schemas/grepTool';\nimport type { RipgrepRequest, RipgrepResult } from './type';\n\n/**\n * Searching with ripgrep, with no host in it.\n *\n * The interactive host delegates to Pi's own grep and only re-tags the result,\n * so today this has one caller. It still belongs here rather than beside that\n * caller: spawning a process and parsing its output is the same work whichever\n * host asked for it, and a routed file's job is the shape a host expects, not\n * the search underneath it.\n */\nexport async function runRipgrep(request: RipgrepRequest): Promise<RipgrepResult> {\n const { params, cwd, signal } = request;\n const output = await spawnRipgrep(argumentsFor(params, cwd), cwd, signal);\n if (output.trim() === '') return { content: [{ type: 'text', text: 'No matches found' }] };\n return { content: [{ type: 'text', text: limitMatches(output, params.limit) }] };\n}\n\n/** The ripgrep argv one search needs, exposed so a test can assert it without spawning. */\nexport function argumentsFor(params: GrepParams, cwd: string): string[] {\n const target = resolveInputPath(params.path ?? '.', cwd);\n const targetArg = relative(cwd, target) || '.';\n const args = ['--color', 'never', '--no-heading', '--with-filename', '--line-number'];\n if (params.ignoreCase === true) args.push('--ignore-case');\n if (params.literal === true) args.push('--fixed-strings');\n if (params.context !== undefined) args.push('--context', String(params.context));\n if (params.glob !== undefined) args.push('--glob', params.glob);\n args.push('--', params.pattern, targetArg);\n return args;\n}\n\n/**\n * Keeps at most `limit` matching lines, and every context line around them.\n *\n * A context line carries no `:<line>:` marker, which is what separates it from\n * a match, so trimming by line count alone would drop the context ripgrep was\n * asked for.\n */\nexport function limitMatches(output: string, limit: number | undefined): string {\n if (limit === undefined) return output.trimEnd();\n let matches = 0;\n const selected: string[] = [];\n for (const line of output.trimEnd().split('\\n')) {\n if (/:(\\d+):/u.test(line)) {\n matches++;\n if (matches > limit) continue;\n }\n selected.push(line);\n }\n return selected.join('\\n');\n}\n\nfunction spawnRipgrep(args: readonly string[], cwd: string, signal: AbortSignal | undefined): Promise<string> {\n return new Promise((resolveOutput, reject) => {\n const child = spawn('rg', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });\n let stdout = '';\n let stderr = '';\n child.stdout.on('data', (chunk: Buffer) => {\n stdout += chunk.toString();\n });\n child.stderr.on('data', (chunk: Buffer) => {\n stderr += chunk.toString();\n });\n const abort = (): void => {\n child.kill('SIGTERM');\n reject(new Error('Operation aborted'));\n };\n if (signal?.aborted) {\n abort();\n return;\n }\n signal?.addEventListener('abort', abort, { once: true });\n child.once('error', (error) => {\n signal?.removeEventListener('abort', abort);\n reject(new Error(`Could not start ripgrep: ${error.message}`, { cause: error }));\n });\n child.once('close', (code) => {\n signal?.removeEventListener('abort', abort);\n // 1 is ripgrep's \"no matches\", which is a result rather than a failure.\n if (code === 0 || code === 1) {\n resolveOutput(stdout);\n return;\n }\n reject(new Error(stderr.trim() || `ripgrep exited with code ${code ?? 'unknown'}`));\n });\n });\n}\n","import { readFile, stat } from 'node:fs/promises';\nimport { basename, resolve } from 'node:path';\n\nimport type {\n DoomHeadlessExecutionContext,\n DoomHeadlessTool,\n DoomHeadlessToolResult,\n} from '@agimon-ai/doompi-core/headless';\nimport { formatFileHeader, formatTaggedLine, splitLines } from '@agimon-ai/doompi-hashline';\nimport {\n computeFileTag,\n decodeUtf8,\n displayPath,\n isWritableFile,\n resolveInputPath,\n} from '@agimon-ai/doompi-hashline/files';\nimport { DEFAULT_MAX_BYTES, formatSize, truncateHead, truncateLine } from '@earendil-works/pi-coding-agent';\n\nimport { GrepParamsSchema, type GrepParams } from '../../schemas/grepTool';\nimport { runRipgrep } from '../ripgrep';\n\nconst MATCH_DELIMITER = /:(\\d+): /gu;\nconst CONTEXT_DELIMITER = /-(\\d+)- /gu;\nconst NO_MATCHES = 'No matches found';\n\nexport interface GrepRow {\n readonly path: string;\n readonly line: number;\n readonly match: boolean;\n readonly native?: string;\n}\n\nexport interface GrepGroup {\n readonly path: string;\n readonly absolutePath: string;\n readonly nativeRows: string[];\n readonly rows: Map<number, boolean>;\n}\n\nexport interface GrepResult {\n content: Array<{ type: 'text'; text: string }>;\n details?: {\n readonly linesTruncated?: boolean;\n readonly truncation?: GrepTruncation;\n readonly [key: string]: unknown;\n };\n}\n\nexport interface GrepFileStats {\n isDirectory(): boolean;\n isFile(): boolean;\n}\n\nexport interface GrepFileSystem {\n stat(path: string): Promise<GrepFileStats>;\n readFile(path: string): Promise<Uint8Array>;\n}\n\nexport interface GrepTruncation {\n readonly content: string;\n readonly truncated: boolean;\n readonly truncatedBy: 'lines' | 'bytes' | null;\n readonly outputLines: number;\n}\n\nexport interface GrepOutputOperations {\n readonly maxBytes: number;\n formatSize(bytes: number): string;\n truncateHead(content: string, options: { readonly maxLines?: number }): GrepTruncation;\n truncateLine(line: string): { readonly text: string; readonly wasTruncated: boolean };\n}\nexport function createHeadlessGrepTool(): DoomHeadlessTool<typeof GrepParamsSchema> {\n return {\n name: 'grep',\n label: 'grep',\n description:\n 'Search file contents using ripgrep semantics, then add exact-byte file tags and stable line anchors for writable matches. Respects .gitignore.',\n promptSnippet: 'Search file contents and return editable hashline anchors',\n parameters: GrepParamsSchema,\n executionMode: 'parallel',\n execute: async (\n _toolCallId: string,\n params: GrepParams,\n signal: AbortSignal | undefined,\n _onUpdate: ((result: DoomHeadlessToolResult) => void) | undefined,\n context: DoomHeadlessExecutionContext,\n ) => {\n const result = await runRipgrep({ params, cwd: context.cwd, signal });\n return tagGrepResult(result, params, context.cwd, signal);\n },\n };\n}\n\nexport function parseGrepRow(value: string): GrepRow[] {\n return grepRowCandidates(value)\n .slice(0, 1)\n .map(({ line, match, path }) => ({ line, match, path }));\n}\n\n/**\n * The Node adapters both hosts hand to `tagGrepResult`.\n *\n * Neither is host specific, and both were previously declared twice, once\n * beside each tool. A routed file composes platform-specific output; the\n * service owns everything underneath it, including the plumbing.\n */\nexport const nodeGrepFileSystem: GrepFileSystem = {\n stat: (path) => stat(path),\n readFile: (path) => readFile(path),\n};\n\nexport const defaultGrepOutput: GrepOutputOperations = {\n maxBytes: DEFAULT_MAX_BYTES,\n formatSize,\n truncateHead,\n truncateLine,\n};\n\nexport async function tagGrepResult(\n nativeResult: GrepResult,\n params: GrepParams,\n cwd: string,\n signal: AbortSignal | undefined,\n writable: (path: string) => Promise<boolean> = isWritableFile,\n filesystem: GrepFileSystem = nodeGrepFileSystem,\n outputOperations: GrepOutputOperations = defaultGrepOutput,\n): Promise<GrepResult> {\n const textPart = nativeResult.content.find((part) => part.type === 'text');\n if (!textPart || textPart.text === NO_MATCHES) return nativeResult;\n\n const noticeIndex = textPart.text.lastIndexOf('\\n\\n[');\n const rowsText = noticeIndex === -1 ? textPart.text : textPart.text.slice(0, noticeIndex);\n const notice = noticeIndex === -1 ? '' : textPart.text.slice(noticeIndex);\n const searchPath = resolveInputPath(params.path ?? '.', cwd);\n const searchStats = await filesystem.stat(searchPath);\n assertNotAborted(signal);\n const rows = await parseValidatedGrepRows(rowsText, searchPath, searchStats.isDirectory(), signal, filesystem);\n if (rows.length === 0) return nativeResult;\n\n const groups = groupGrepRows(rows, searchPath, searchStats.isDirectory(), cwd);\n const blocks: string[] = [];\n let taggedFiles = 0;\n let linesTruncated = nativeResult.details?.linesTruncated ?? false;\n\n for (const group of groups.values()) {\n assertNotAborted(signal);\n const canEdit = await writable(group.absolutePath);\n assertNotAborted(signal);\n if (!canEdit) {\n blocks.push(group.nativeRows.join('\\n'));\n continue;\n }\n let bytes: Uint8Array;\n try {\n bytes = await filesystem.readFile(group.absolutePath);\n } catch {\n blocks.push(group.nativeRows.join('\\n'));\n continue;\n }\n assertNotAborted(signal);\n const lines = splitLines(decodeUtf8(bytes, group.path));\n const output = [formatFileHeader(group.path, computeFileTag(bytes))];\n for (const [lineNumber, isMatch] of [...group.rows].sort(([left], [right]) => left - right)) {\n const line = lines[lineNumber - 1];\n if (line === undefined) continue;\n const compact = outputOperations.truncateLine(line);\n if (compact.wasTruncated) linesTruncated = true;\n output.push(formatTaggedLine(compact.text, lineNumber, isMatch ? '>> ' : ' ', line));\n }\n if (output.length > 1) {\n blocks.push(output.join('\\n'));\n taggedFiles++;\n } else {\n blocks.push(group.nativeRows.join('\\n'));\n }\n }\n\n if (blocks.length === 0 || taggedFiles === 0) return nativeResult;\n const truncation = outputOperations.truncateHead(blocks.join('\\n'), { maxLines: Number.MAX_SAFE_INTEGER });\n const extraNotice = truncation.truncated\n ? `\\n\\n[${outputOperations.formatSize(outputOperations.maxBytes)} tagged output limit reached]`\n : '';\n const details = {\n ...nativeResult.details,\n ...(truncation.truncated ? { truncation } : {}),\n ...(linesTruncated ? { linesTruncated: true } : {}),\n };\n assertNotAborted(signal);\n return {\n ...nativeResult,\n content: [{ type: 'text', text: `${truncation.content}${notice}${extraNotice}` }],\n details: Object.keys(details).length > 0 ? details : undefined,\n };\n}\n\nexport function groupGrepRows(\n rows: readonly GrepRow[],\n searchPath: string,\n directory: boolean,\n cwd: string,\n): Map<string, GrepGroup> {\n const groups = new Map<string, GrepGroup>();\n for (const row of rows) {\n const absolutePath = directory ? resolve(searchPath, row.path) : searchPath;\n let group = groups.get(absolutePath);\n if (!group) {\n group = { path: displayPath(absolutePath, cwd), absolutePath, nativeRows: [], rows: new Map() };\n groups.set(absolutePath, group);\n }\n if (row.native !== undefined) group.nativeRows.push(row.native);\n group.rows.set(row.line, row.match || group.rows.get(row.line) === true);\n }\n return groups;\n}\n\nasync function parseValidatedGrepRows(\n rowsText: string,\n searchPath: string,\n directory: boolean,\n signal: AbortSignal | undefined,\n filesystem: GrepFileSystem,\n): Promise<GrepRow[]> {\n const pathCache = new Map<string, boolean>();\n const rows: GrepRow[] = [];\n for (const value of rowsText.split('\\n')) {\n assertNotAborted(signal);\n let resolvedCandidate: GrepRow | undefined;\n for (const candidate of grepRowCandidates(value)) {\n if (!directory) {\n if (candidate.path === basename(searchPath)) rows.push({ ...candidate, native: value });\n break;\n }\n const absolutePath = resolve(searchPath, candidate.path);\n let exists = pathCache.get(absolutePath);\n if (exists === undefined) {\n exists = await filesystem\n .stat(absolutePath)\n .then((value) => value.isFile())\n .catch(() => false);\n assertNotAborted(signal);\n pathCache.set(absolutePath, exists);\n }\n if (exists) resolvedCandidate = { ...candidate, native: value };\n }\n if (resolvedCandidate) rows.push(resolvedCandidate);\n }\n return rows;\n}\n\nfunction grepRowCandidates(value: string): GrepRow[] {\n const candidates: Array<{ readonly index: number; readonly row: GrepRow }> = [];\n for (const match of value.matchAll(MATCH_DELIMITER)) {\n candidates.push({\n index: match.index,\n row: { path: value.slice(0, match.index), line: Number.parseInt(match[1] ?? '', 10), match: true },\n });\n }\n for (const match of value.matchAll(CONTEXT_DELIMITER)) {\n candidates.push({\n index: match.index,\n row: { path: value.slice(0, match.index), line: Number.parseInt(match[1] ?? '', 10), match: false },\n });\n }\n return candidates.sort((left, right) => left.index - right.index).map(({ row }) => row);\n}\n\nexport function assertNotAborted(signal: AbortSignal | undefined): void {\n if (signal?.aborted) throw new Error('Operation aborted');\n}\n","import { defineMcpTool } from '@agimon-ai/doompi-core/mcp-facet';\n\nimport { createHeadlessGrepTool } from '../../../../../services/grepTool';\n\nexport default defineMcpTool(createHeadlessGrepTool);\n","// Generated by @agimon-ai/doompi-build. Do not edit by hand.\nimport { defineMcpPlugin, type DoomMcpSessionPlugin } from '@agimon-ai/doompi-core/mcp-facet';\n\nimport toolGrep from '../src/extensions/workspaces/sessions/(backend)/tool/grep.mcp';\n\ntype Factory<T, C> = (context: C) => T;\nconst at = <T, C>(value: T | Factory<T, C>, context: C): T =>\n typeof value === 'function' ? (value as Factory<T, C>)(context) : value;\nconst via = <T>(identity: Record<string, unknown>, value: unknown): T => ({ ...identity, ...(value as object) }) as T;\n\nexport const mcp = defineMcpPlugin({\n name: '@agimon-ai/doompi-grep',\n session: (context) => ({\n get tools(): DoomMcpSessionPlugin['tools'] { return [via({ name: 'grep' }, at(toolGrep, context))]; },\n }) satisfies DoomMcpSessionPlugin,\n});\n\nexport default mcp;\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agimon-ai/doompi-grep",
3
- "version": "0.0.1-alpha.48",
3
+ "version": "0.0.1-alpha.49",
4
4
  "description": "Snapshot-bound hashline grep for Pi and DoomPi.",
5
5
  "keywords": [
6
6
  "ai",
@@ -62,15 +62,15 @@
62
62
  "access": "public"
63
63
  },
64
64
  "dependencies": {
65
- "@agimon-ai/doompi-core": "0.0.1-alpha.76",
66
- "@agimon-ai/doompi-hashline": "0.0.1-alpha.45",
67
- "@agimon-ai/doompi-ui": "0.0.1-alpha.76",
68
- "@agimon-ai/doompi-web-components": "0.0.1-alpha.34",
65
+ "@agimon-ai/doompi-core": "0.0.1-alpha.77",
66
+ "@agimon-ai/doompi-hashline": "0.0.1-alpha.46",
67
+ "@agimon-ai/doompi-ui": "0.0.1-alpha.77",
68
+ "@agimon-ai/doompi-web-components": "0.0.1-alpha.35",
69
69
  "@deepseek-ai/cordis": "4.0.2",
70
70
  "typebox": "1.3.30"
71
71
  },
72
72
  "devDependencies": {
73
- "@agimon-ai/doompi-build": "0.0.1-alpha.5",
73
+ "@agimon-ai/doompi-build": "0.0.1-alpha.6",
74
74
  "@earendil-works/pi-coding-agent": "0.85.1",
75
75
  "@earendil-works/pi-tui": "0.85.1",
76
76
  "@tanstack/react-store": "0.11.1",
@@ -100,6 +100,13 @@
100
100
  "engines": {
101
101
  "node": ">=22.19.0"
102
102
  },
103
+ "doompiMcp": {
104
+ "entry": "./generated/mcp.ts",
105
+ "dist": "./dist/extensions/mcp.mjs",
106
+ "scopes": [
107
+ "session"
108
+ ]
109
+ },
103
110
  "doompiServer": {
104
111
  "entry": "./generated/server.ts",
105
112
  "dist": "./dist/extensions/server.mjs",
@@ -125,7 +132,8 @@
125
132
  ]
126
133
  },
127
134
  "scripts": {
128
- "build": "tsdown",
135
+ "build": "tsdown && tsdown --config tsdown.mcp.config.ts",
136
+ "build:mcp": "tsdown --config tsdown.mcp.config.ts",
129
137
  "test": "vitest --run",
130
138
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.web.json",
131
139
  "lint": "oxlint . && oxfmt . --check",