botholomew 0.16.4 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +46 -41
  2. package/package.json +4 -9
  3. package/src/chat/agent.ts +37 -40
  4. package/src/chat/session.ts +10 -10
  5. package/src/cli.ts +0 -2
  6. package/src/commands/capabilities.ts +35 -33
  7. package/src/commands/context.ts +133 -221
  8. package/src/commands/init.ts +22 -1
  9. package/src/commands/mcpx.ts +21 -8
  10. package/src/commands/nuke.ts +52 -15
  11. package/src/commands/prepare.ts +16 -13
  12. package/src/config/loader.ts +1 -8
  13. package/src/config/schemas.ts +6 -0
  14. package/src/constants.ts +16 -32
  15. package/src/init/index.ts +52 -27
  16. package/src/mcpx/client.ts +21 -5
  17. package/src/mem/client.ts +33 -0
  18. package/src/{context → prompts}/capabilities.ts +11 -7
  19. package/src/schedules/store.ts +1 -1
  20. package/src/tasks/store.ts +1 -1
  21. package/src/threads/store.ts +1 -1
  22. package/src/tools/capabilities/refresh.ts +1 -1
  23. package/src/tools/membot/adapter.ts +111 -0
  24. package/src/tools/membot/copy.ts +59 -0
  25. package/src/tools/membot/count_lines.ts +53 -0
  26. package/src/tools/membot/edit.ts +72 -0
  27. package/src/tools/membot/exists.ts +54 -0
  28. package/src/tools/membot/index.ts +26 -0
  29. package/src/tools/{context → membot}/pipe.ts +34 -32
  30. package/src/tools/registry.ts +6 -37
  31. package/src/tools/tool.ts +6 -8
  32. package/src/tui/App.tsx +3 -4
  33. package/src/tui/components/ContextPanel.tsx +109 -226
  34. package/src/tui/components/HelpPanel.tsx +2 -2
  35. package/src/tui/components/StatusBar.tsx +0 -6
  36. package/src/tui/components/ThreadPanel.tsx +8 -7
  37. package/src/tui/wrapDetail.ts +11 -0
  38. package/src/worker/heartbeat.ts +0 -20
  39. package/src/worker/index.ts +13 -13
  40. package/src/worker/llm.ts +7 -9
  41. package/src/worker/prompt.ts +25 -13
  42. package/src/worker/spawn.ts +1 -1
  43. package/src/worker/tick.ts +10 -9
  44. package/src/commands/db.ts +0 -119
  45. package/src/commands/with-db.ts +0 -22
  46. package/src/context/chunker.ts +0 -275
  47. package/src/context/embedder-impl.ts +0 -100
  48. package/src/context/embedder.ts +0 -9
  49. package/src/context/fetcher-errors.ts +0 -8
  50. package/src/context/fetcher.ts +0 -515
  51. package/src/context/locks.ts +0 -146
  52. package/src/context/markdown-converter.ts +0 -186
  53. package/src/context/reindex.ts +0 -198
  54. package/src/context/store.ts +0 -841
  55. package/src/context/url-utils.ts +0 -25
  56. package/src/db/connection.ts +0 -255
  57. package/src/db/doctor.ts +0 -235
  58. package/src/db/embeddings.ts +0 -317
  59. package/src/db/query.ts +0 -56
  60. package/src/db/schema.ts +0 -93
  61. package/src/db/sql/1-core_tables.sql +0 -53
  62. package/src/db/sql/10-dedupe_context_items.sql +0 -26
  63. package/src/db/sql/11-rebuild_hnsw.sql +0 -8
  64. package/src/db/sql/12-workers.sql +0 -66
  65. package/src/db/sql/13-drive-paths.sql +0 -47
  66. package/src/db/sql/14-drop_hnsw_index.sql +0 -8
  67. package/src/db/sql/15-fts_index.sql +0 -8
  68. package/src/db/sql/16-source_url.sql +0 -7
  69. package/src/db/sql/17-worker_log_path.sql +0 -3
  70. package/src/db/sql/18-reset_embeddings_for_local.sql +0 -39
  71. package/src/db/sql/19-disk_backed_index.sql +0 -36
  72. package/src/db/sql/2-logging_tables.sql +0 -24
  73. package/src/db/sql/20-drop_db_tables_for_files.sql +0 -19
  74. package/src/db/sql/3-daemon_state.sql +0 -5
  75. package/src/db/sql/4-unique_context_path.sql +0 -1
  76. package/src/db/sql/5-reset_embeddings_for_openai.sql +0 -1
  77. package/src/db/sql/6-vss_index.sql +0 -7
  78. package/src/db/sql/7-drop_embeddings_fk.sql +0 -23
  79. package/src/db/sql/8-task_output.sql +0 -1
  80. package/src/db/sql/9-source-type.sql +0 -1
  81. package/src/tools/context/read-large-result.ts +0 -33
  82. package/src/tools/dir/create.ts +0 -47
  83. package/src/tools/dir/size.ts +0 -77
  84. package/src/tools/dir/tree.ts +0 -124
  85. package/src/tools/file/copy.ts +0 -73
  86. package/src/tools/file/count-lines.ts +0 -54
  87. package/src/tools/file/delete.ts +0 -83
  88. package/src/tools/file/edit.ts +0 -76
  89. package/src/tools/file/exists.ts +0 -33
  90. package/src/tools/file/info.ts +0 -66
  91. package/src/tools/file/move.ts +0 -66
  92. package/src/tools/file/read.ts +0 -67
  93. package/src/tools/file/write.ts +0 -58
  94. package/src/tools/search/fuse.ts +0 -96
  95. package/src/tools/search/index.ts +0 -127
  96. package/src/tools/search/regexp.ts +0 -82
  97. package/src/tools/search/semantic.ts +0 -167
  98. /package/src/{db → utils}/uuid.ts +0 -0
@@ -1,76 +0,0 @@
1
- import { z } from "zod";
2
- import {
3
- applyPatches,
4
- IsDirectoryError,
5
- MtimeConflictError,
6
- NotFoundError,
7
- readContextFile,
8
- } from "../../context/store.ts";
9
- import { LinePatchSchema } from "../../fs/patches.ts";
10
- import type { ToolDefinition } from "../tool.ts";
11
-
12
- const inputSchema = z.object({
13
- path: z.string().describe("Project-relative path under context/"),
14
- patches: z.array(LinePatchSchema).describe("Patches to apply"),
15
- });
16
-
17
- const outputSchema = z.object({
18
- applied: z.number(),
19
- content: z.string(),
20
- is_error: z.boolean(),
21
- error_type: z.string().optional(),
22
- message: z.string().optional(),
23
- next_action_hint: z.string().optional(),
24
- });
25
-
26
- export const contextEditTool = {
27
- name: "context_edit",
28
- description:
29
- "[[ bash equivalent command: patch ]] Apply line-range patches to a file under context/. Each patch specifies start_line/end_line/content. Edits that traverse a user symlink fail with PathEscapeError — delete the symlink first or copy the content to a real path.",
30
- group: "context",
31
- inputSchema,
32
- outputSchema,
33
- execute: async (input, ctx) => {
34
- try {
35
- const { applied } = await applyPatches(
36
- ctx.projectDir,
37
- input.path,
38
- input.patches,
39
- { holderId: ctx.workerId },
40
- );
41
- const content = await readContextFile(ctx.projectDir, input.path);
42
- return { applied, content, is_error: false };
43
- } catch (err) {
44
- if (err instanceof NotFoundError) {
45
- return {
46
- applied: 0,
47
- content: "",
48
- is_error: true,
49
- error_type: "not_found",
50
- message: `No file at context/${err.path}`,
51
- };
52
- }
53
- if (err instanceof IsDirectoryError) {
54
- return {
55
- applied: 0,
56
- content: "",
57
- is_error: true,
58
- error_type: "is_directory",
59
- message: `context/${err.path} is a directory`,
60
- };
61
- }
62
- if (err instanceof MtimeConflictError) {
63
- return {
64
- applied: 0,
65
- content: "",
66
- is_error: true,
67
- error_type: "mtime_conflict",
68
- message: `context/${input.path} was modified concurrently — another writer (or an external editor) changed it between read and write.`,
69
- next_action_hint:
70
- "Call context_read to fetch the current content, recompute your patches against the new line numbers, and retry.",
71
- };
72
- }
73
- throw err;
74
- }
75
- },
76
- } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,33 +0,0 @@
1
- import { z } from "zod";
2
- import { fileExists } from "../../context/store.ts";
3
- import { PathEscapeError } from "../../fs/sandbox.ts";
4
- import type { ToolDefinition } from "../tool.ts";
5
-
6
- const inputSchema = z.object({
7
- path: z.string().describe("Path under context/"),
8
- });
9
-
10
- const outputSchema = z.object({
11
- exists: z.boolean(),
12
- is_error: z.boolean(),
13
- });
14
-
15
- export const contextExistsTool = {
16
- name: "context_exists",
17
- description:
18
- "[[ bash equivalent command: test -e ]] Check whether a path exists under context/.",
19
- group: "context",
20
- inputSchema,
21
- outputSchema,
22
- execute: async (input, ctx) => {
23
- try {
24
- const exists = await fileExists(ctx.projectDir, input.path);
25
- return { exists, is_error: false };
26
- } catch (err) {
27
- if (err instanceof PathEscapeError) {
28
- return { exists: false, is_error: false };
29
- }
30
- throw err;
31
- }
32
- },
33
- } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,66 +0,0 @@
1
- import { z } from "zod";
2
- import { getInfo, readContextFile } from "../../context/store.ts";
3
- import type { ToolDefinition } from "../tool.ts";
4
-
5
- const inputSchema = z.object({
6
- path: z.string().describe("Path under context/"),
7
- });
8
-
9
- const fileSchema = z.object({
10
- path: z.string(),
11
- is_directory: z.boolean(),
12
- is_textual: z.boolean(),
13
- is_symlink: z.boolean(),
14
- mime_type: z.string(),
15
- size: z.number(),
16
- lines: z.number(),
17
- mtime: z.string(),
18
- content_hash: z.string().nullable(),
19
- });
20
-
21
- const outputSchema = z.object({
22
- file: fileSchema.optional(),
23
- is_error: z.boolean(),
24
- error_type: z.string().optional(),
25
- message: z.string().optional(),
26
- next_action_hint: z.string().optional(),
27
- });
28
-
29
- export const contextInfoTool = {
30
- name: "context_info",
31
- description:
32
- "[[ bash equivalent command: stat ]] Show metadata for a path under context/: size, MIME type, line count, mtime, content hash. `is_symlink` is true when the path is a user-placed symlink.",
33
- group: "context",
34
- inputSchema,
35
- outputSchema,
36
- execute: async (input, ctx) => {
37
- const info = await getInfo(ctx.projectDir, input.path);
38
- if (!info) {
39
- return {
40
- is_error: true,
41
- error_type: "not_found",
42
- message: `No path at context/${input.path}`,
43
- next_action_hint: "Call context_tree to browse.",
44
- };
45
- }
46
- let lines = 0;
47
- if (info.is_textual && !info.is_directory) {
48
- const content = await readContextFile(ctx.projectDir, input.path);
49
- lines = content === "" ? 0 : content.split("\n").length;
50
- }
51
- return {
52
- file: {
53
- path: info.path,
54
- is_directory: info.is_directory,
55
- is_textual: info.is_textual,
56
- is_symlink: info.is_symlink,
57
- mime_type: info.mime_type,
58
- size: info.size,
59
- lines,
60
- mtime: info.mtime.toISOString(),
61
- content_hash: info.content_hash,
62
- },
63
- is_error: false,
64
- };
65
- },
66
- } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,66 +0,0 @@
1
- import { z } from "zod";
2
- import {
3
- deleteContextPath,
4
- fileExists,
5
- moveContextPath,
6
- NotFoundError,
7
- PathConflictError,
8
- } from "../../context/store.ts";
9
- import type { ToolDefinition } from "../tool.ts";
10
-
11
- const inputSchema = z.object({
12
- src: z.string().describe("Source path under context/"),
13
- dst: z.string().describe("Destination path under context/"),
14
- overwrite: z.boolean().optional().describe("Overwrite if destination exists"),
15
- });
16
-
17
- const outputSchema = z.object({
18
- src: z.string(),
19
- dst: z.string(),
20
- is_error: z.boolean(),
21
- error_type: z.string().optional(),
22
- message: z.string().optional(),
23
- });
24
-
25
- export const contextMoveTool = {
26
- name: "context_move",
27
- description:
28
- "[[ bash equivalent command: mv ]] Move or rename a file/directory under context/. Source/destination paths that traverse a user symlink fail with PathEscapeError.",
29
- group: "context",
30
- inputSchema,
31
- outputSchema,
32
- execute: async (input, ctx) => {
33
- try {
34
- if (input.overwrite && (await fileExists(ctx.projectDir, input.dst))) {
35
- await deleteContextPath(ctx.projectDir, input.dst, {
36
- recursive: true,
37
- holderId: ctx.workerId,
38
- });
39
- }
40
- await moveContextPath(ctx.projectDir, input.src, input.dst, {
41
- holderId: ctx.workerId,
42
- });
43
- return { src: input.src, dst: input.dst, is_error: false };
44
- } catch (err) {
45
- if (err instanceof NotFoundError) {
46
- return {
47
- src: input.src,
48
- dst: input.dst,
49
- is_error: true,
50
- error_type: "not_found",
51
- message: `No file at context/${err.path}`,
52
- };
53
- }
54
- if (err instanceof PathConflictError) {
55
- return {
56
- src: input.src,
57
- dst: input.dst,
58
- is_error: true,
59
- error_type: "path_conflict",
60
- message: `Destination already exists at context/${err.path}; pass overwrite=true to replace.`,
61
- };
62
- }
63
- throw err;
64
- }
65
- },
66
- } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,67 +0,0 @@
1
- import { z } from "zod";
2
- import {
3
- IsDirectoryError,
4
- NotFoundError,
5
- readContextFile,
6
- } from "../../context/store.ts";
7
- import type { ToolDefinition } from "../tool.ts";
8
-
9
- const inputSchema = z.object({
10
- path: z
11
- .string()
12
- .describe(
13
- "Project-relative path under context/ (e.g. 'notes/foo.md'). Forward-slashes; never absolute.",
14
- ),
15
- offset: z
16
- .number()
17
- .optional()
18
- .describe("Line number to start reading from (1-based)"),
19
- limit: z.number().optional().describe("Maximum number of lines to return"),
20
- });
21
-
22
- const outputSchema = z.object({
23
- content: z.string().optional(),
24
- is_error: z.boolean(),
25
- error_type: z.string().optional(),
26
- message: z.string().optional(),
27
- next_action_hint: z.string().optional(),
28
- });
29
-
30
- export const contextReadTool = {
31
- name: "context_read",
32
- description: "[[ bash equivalent command: cat ]] Read a file under context/.",
33
- group: "context",
34
- inputSchema,
35
- outputSchema,
36
- execute: async (input, ctx) => {
37
- try {
38
- let content = await readContextFile(ctx.projectDir, input.path);
39
- if (input.offset || input.limit) {
40
- const lines = content.split("\n");
41
- const start = (input.offset ?? 1) - 1;
42
- const end = input.limit ? start + input.limit : lines.length;
43
- content = lines.slice(start, end).join("\n");
44
- }
45
- return { content, is_error: false };
46
- } catch (err) {
47
- if (err instanceof NotFoundError) {
48
- return {
49
- is_error: true,
50
- error_type: "not_found",
51
- message: `No file at context/${err.path}`,
52
- next_action_hint:
53
- "Call context_tree to browse, or context_exists to check first.",
54
- };
55
- }
56
- if (err instanceof IsDirectoryError) {
57
- return {
58
- is_error: true,
59
- error_type: "is_directory",
60
- message: `context/${err.path} is a directory`,
61
- next_action_hint: "Use context_tree to list its contents.",
62
- };
63
- }
64
- throw err;
65
- }
66
- },
67
- } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,58 +0,0 @@
1
- import { z } from "zod";
2
- import { PathConflictError, writeContextFile } from "../../context/store.ts";
3
- import type { ToolDefinition } from "../tool.ts";
4
-
5
- const inputSchema = z.object({
6
- path: z
7
- .string()
8
- .describe(
9
- "Project-relative path under context/ (e.g. 'notes/foo.md'). Created if its parent directory does not exist.",
10
- ),
11
- content: z.string().describe("Text content to write"),
12
- on_conflict: z
13
- .enum(["error", "overwrite"])
14
- .optional()
15
- .describe(
16
- "What to do if the file already exists. Defaults to 'error'. Pass 'overwrite' to replace.",
17
- ),
18
- });
19
-
20
- const outputSchema = z.object({
21
- path: z.string(),
22
- is_error: z.boolean(),
23
- error_type: z.string().optional(),
24
- message: z.string().optional(),
25
- next_action_hint: z.string().optional(),
26
- });
27
-
28
- export const contextWriteTool = {
29
- name: "context_write",
30
- description:
31
- "[[ bash equivalent command: tee ]] Write text content to a file under context/. Fails if the path already exists unless on_conflict='overwrite'. Writes that traverse a user symlink fail with PathEscapeError — delete the symlink first or write to a real path.",
32
- group: "context",
33
- inputSchema,
34
- outputSchema,
35
- execute: async (input, ctx) => {
36
- try {
37
- const entry = await writeContextFile(
38
- ctx.projectDir,
39
- input.path,
40
- input.content,
41
- { onConflict: input.on_conflict ?? "error", holderId: ctx.workerId },
42
- );
43
- return { path: entry.path, is_error: false };
44
- } catch (err) {
45
- if (err instanceof PathConflictError) {
46
- return {
47
- path: err.path,
48
- is_error: true,
49
- error_type: "path_conflict",
50
- message: `A file already exists at context/${err.path}.`,
51
- next_action_hint:
52
- "Call context_read to inspect, or retry with on_conflict='overwrite'.",
53
- };
54
- }
55
- throw err;
56
- }
57
- },
58
- } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,96 +0,0 @@
1
- import type { RegexpHit } from "./regexp.ts";
2
- import type { SemanticHit } from "./semantic.ts";
3
-
4
- export interface FusedMatch {
5
- path: string;
6
- line: number | null;
7
- content: string;
8
- context_lines: string[];
9
- match_type: "regexp" | "semantic" | "both";
10
- semantic_score: number | null;
11
- score: number;
12
- }
13
-
14
- const SNIPPET_MAX = 300;
15
-
16
- /**
17
- * Reciprocal rank fusion of regexp line hits and semantic file hits.
18
- *
19
- * Each regexp hit becomes its own row. If the same file also has a semantic
20
- * hit, the regexp row picks up that semantic side's RRF contribution and is
21
- * tagged `match_type: "both"` — exact-line + semantic agreement is the
22
- * strongest signal.
23
- *
24
- * Semantic hits emit their own rows only for paths with no regexp hit.
25
- */
26
- export function fuseRRF(
27
- regexpHits: RegexpHit[],
28
- semanticHits: SemanticHit[],
29
- options: { k?: number; limit: number },
30
- ): FusedMatch[] {
31
- const k = options.k ?? 60;
32
-
33
- const bestSemByPath = new Map<
34
- string,
35
- { rank: number; score: number; hit: SemanticHit }
36
- >();
37
- for (let i = 0; i < semanticHits.length; i++) {
38
- const hit = semanticHits[i];
39
- if (!hit) continue;
40
- const existing = bestSemByPath.get(hit.path);
41
- if (!existing || i < existing.rank) {
42
- bestSemByPath.set(hit.path, { rank: i, score: hit.score, hit });
43
- }
44
- }
45
-
46
- const regexpPaths = new Set<string>();
47
- for (const hit of regexpHits) regexpPaths.add(hit.path);
48
-
49
- const fused: FusedMatch[] = [];
50
-
51
- for (let i = 0; i < regexpHits.length; i++) {
52
- const rx = regexpHits[i];
53
- if (!rx) continue;
54
- const sem = bestSemByPath.get(rx.path);
55
- let score = 1 / (k + i + 1);
56
- let matchType: FusedMatch["match_type"] = "regexp";
57
- let semanticScore: number | null = null;
58
- if (sem) {
59
- score += 1 / (k + sem.rank + 1);
60
- matchType = "both";
61
- semanticScore = round(sem.score);
62
- }
63
- fused.push({
64
- path: rx.path,
65
- line: rx.line,
66
- content: rx.content,
67
- context_lines: rx.context_lines,
68
- match_type: matchType,
69
- semantic_score: semanticScore,
70
- score: round(score),
71
- });
72
- }
73
-
74
- for (let i = 0; i < semanticHits.length; i++) {
75
- const sem = semanticHits[i];
76
- if (!sem) continue;
77
- if (regexpPaths.has(sem.path)) continue;
78
- const score = 1 / (k + i + 1);
79
- fused.push({
80
- path: sem.path,
81
- line: null,
82
- content: sem.chunk_content.slice(0, SNIPPET_MAX),
83
- context_lines: [],
84
- match_type: "semantic",
85
- semantic_score: round(sem.score),
86
- score: round(score),
87
- });
88
- }
89
-
90
- fused.sort((a, b) => b.score - a.score);
91
- return fused.slice(0, options.limit);
92
- }
93
-
94
- function round(n: number): number {
95
- return Math.round(n * 10000) / 10000;
96
- }
@@ -1,127 +0,0 @@
1
- import { z } from "zod";
2
- import type { ToolDefinition } from "../tool.ts";
3
- import { fuseRRF } from "./fuse.ts";
4
- import { runRegexp } from "./regexp.ts";
5
- import { runSemantic } from "./semantic.ts";
6
-
7
- const MatchSchema = z.object({
8
- path: z.string(),
9
- line: z.number().nullable(),
10
- content: z.string(),
11
- context_lines: z.array(z.string()),
12
- match_type: z.enum(["regexp", "semantic", "both"]),
13
- semantic_score: z.number().nullable(),
14
- score: z.number(),
15
- });
16
-
17
- const inputSchema = z.object({
18
- query: z
19
- .string()
20
- .optional()
21
- .describe(
22
- "Natural-language query for semantic search. Provide alongside `pattern` for the strongest signal — files matched by both methods float to the top via reciprocal rank fusion.",
23
- ),
24
- pattern: z
25
- .string()
26
- .optional()
27
- .describe(
28
- "Regex pattern for exact text search across file contents under context/.",
29
- ),
30
- scope: z
31
- .string()
32
- .optional()
33
- .describe(
34
- "Restrict search to a sub-directory under context/ (e.g. 'notes' to only search context/notes/...).",
35
- ),
36
- glob: z
37
- .string()
38
- .optional()
39
- .describe("Filter results to files whose basename matches this glob."),
40
- ignore_case: z
41
- .boolean()
42
- .optional()
43
- .describe("Case-insensitive regex (only affects `pattern`)."),
44
- context: z
45
- .number()
46
- .optional()
47
- .describe(
48
- "Lines of surrounding context to include for each regex hit (only affects `pattern`).",
49
- ),
50
- max_results: z
51
- .number()
52
- .optional()
53
- .describe("Maximum number of fused results to return (default 20)."),
54
- });
55
-
56
- const outputSchema = z.object({
57
- matches: z.array(MatchSchema),
58
- is_error: z.boolean(),
59
- error_type: z.string().optional(),
60
- message: z.string().optional(),
61
- next_action_hint: z.string().optional(),
62
- });
63
-
64
- export const searchTool = {
65
- name: "search",
66
- description:
67
- "[[ bash equivalent command: grep -r ]] Hybrid search over files under context/. At least one of `query` (natural language → semantic) or `pattern` (regex over file contents) is required. Pass both for the strongest signal: results matched by both methods float to the top via reciprocal rank fusion. Scoping (`scope`, `glob`) applies to both sides. Note: while a persistent index sidecar is being rebuilt, semantic search re-embeds files on every call — keep result sets small.",
68
- group: "search",
69
- inputSchema,
70
- outputSchema,
71
- execute: async (input, ctx) => {
72
- if (!input.query && !input.pattern) {
73
- return {
74
- matches: [],
75
- is_error: true,
76
- error_type: "invalid_arguments",
77
- message:
78
- "Provide at least one of `query` (natural language) or `pattern` (regex). Pass both to fuse semantic and exact-match signals.",
79
- };
80
- }
81
-
82
- // Validate the regex up front so a malformed pattern returns a
83
- // structured error instead of bubbling SyntaxError. Match the shape
84
- // of search_threads' invalid_regex response so the agent can recover
85
- // identically across both tools.
86
- if (input.pattern) {
87
- try {
88
- new RegExp(input.pattern, input.ignore_case ? "i" : "");
89
- } catch (err) {
90
- return {
91
- matches: [],
92
- is_error: true,
93
- error_type: "invalid_regex",
94
- message: `Could not compile pattern: ${err instanceof Error ? err.message : String(err)}`,
95
- next_action_hint:
96
- "Double-check the regex; remember `.` is a metacharacter — escape it as `\\.` for a literal dot.",
97
- };
98
- }
99
- }
100
-
101
- const limit = input.max_results ?? 20;
102
-
103
- const regexpHits = input.pattern
104
- ? await runRegexp(ctx.projectDir, {
105
- pattern: input.pattern,
106
- scope: input.scope,
107
- glob: input.glob,
108
- ignore_case: input.ignore_case,
109
- context: input.context,
110
- max_results: 100,
111
- })
112
- : [];
113
-
114
- const semanticHits = input.query
115
- ? await runSemantic(ctx.projectDir, ctx.config, ctx.dbPath, {
116
- query: input.query,
117
- scope: input.scope,
118
- glob: input.glob,
119
- limit: 100,
120
- })
121
- : [];
122
-
123
- const matches = fuseRRF(regexpHits, semanticHits, { limit });
124
-
125
- return { matches, is_error: false };
126
- },
127
- } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,82 +0,0 @@
1
- import { listContextDir, readContextFile } from "../../context/store.ts";
2
-
3
- export interface RegexpHit {
4
- path: string;
5
- line: number;
6
- content: string;
7
- context_lines: string[];
8
- }
9
-
10
- export interface RegexpOptions {
11
- pattern: string;
12
- /** Optional path under context/ to scope the walk (default: whole tree). */
13
- scope?: string;
14
- glob?: string;
15
- ignore_case?: boolean;
16
- context?: number;
17
- max_results?: number;
18
- }
19
-
20
- /**
21
- * Walk every textual file under `context/` (or `context/<scope>/`) and run
22
- * `pattern` against each line. Cheap because tools opt into reading content
23
- * only for files whose names match an optional glob.
24
- */
25
- export async function runRegexp(
26
- projectDir: string,
27
- options: RegexpOptions,
28
- ): Promise<RegexpHit[]> {
29
- const flags = options.ignore_case ? "gi" : "g";
30
- const regex = new RegExp(options.pattern, flags);
31
- const globRegex = options.glob ? globToRegex(options.glob) : null;
32
- const contextLines = options.context ?? 0;
33
- const maxResults = options.max_results ?? 100;
34
-
35
- const entries = await listContextDir(projectDir, options.scope ?? "", {
36
- recursive: true,
37
- });
38
-
39
- const hits: RegexpHit[] = [];
40
- for (const entry of entries) {
41
- if (entry.is_directory) continue;
42
- if (!entry.is_textual) continue;
43
- if (globRegex) {
44
- const filename = entry.path.split("/").pop() ?? "";
45
- if (!globRegex.test(filename)) continue;
46
- }
47
-
48
- let content: string;
49
- try {
50
- content = await readContextFile(projectDir, entry.path);
51
- } catch {
52
- continue;
53
- }
54
- const lines = content.split("\n");
55
- for (let i = 0; i < lines.length; i++) {
56
- regex.lastIndex = 0;
57
- const line = lines[i];
58
- if (line === undefined) continue;
59
- if (regex.test(line)) {
60
- const start = Math.max(0, i - contextLines);
61
- const end = Math.min(lines.length, i + contextLines + 1);
62
- hits.push({
63
- path: entry.path,
64
- line: i + 1,
65
- content: line,
66
- context_lines: lines.slice(start, end),
67
- });
68
- if (hits.length >= maxResults) return hits;
69
- }
70
- }
71
- }
72
-
73
- return hits;
74
- }
75
-
76
- export function globToRegex(glob: string): RegExp {
77
- const escaped = glob
78
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
79
- .replace(/\*/g, ".*")
80
- .replace(/\?/g, ".");
81
- return new RegExp(`^${escaped}$`, "i");
82
- }