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.
- package/README.md +46 -41
- package/package.json +4 -9
- package/src/chat/agent.ts +37 -40
- package/src/chat/session.ts +10 -10
- package/src/cli.ts +0 -2
- package/src/commands/capabilities.ts +35 -33
- package/src/commands/context.ts +133 -221
- package/src/commands/init.ts +22 -1
- package/src/commands/mcpx.ts +21 -8
- package/src/commands/nuke.ts +52 -15
- package/src/commands/prepare.ts +16 -13
- package/src/config/loader.ts +1 -8
- package/src/config/schemas.ts +6 -0
- package/src/constants.ts +16 -32
- package/src/init/index.ts +52 -27
- package/src/mcpx/client.ts +21 -5
- package/src/mem/client.ts +33 -0
- package/src/{context → prompts}/capabilities.ts +11 -7
- package/src/schedules/store.ts +1 -1
- package/src/tasks/store.ts +1 -1
- package/src/threads/store.ts +1 -1
- package/src/tools/capabilities/refresh.ts +1 -1
- package/src/tools/membot/adapter.ts +111 -0
- package/src/tools/membot/copy.ts +59 -0
- package/src/tools/membot/count_lines.ts +53 -0
- package/src/tools/membot/edit.ts +72 -0
- package/src/tools/membot/exists.ts +54 -0
- package/src/tools/membot/index.ts +26 -0
- package/src/tools/{context → membot}/pipe.ts +34 -32
- package/src/tools/registry.ts +6 -37
- package/src/tools/tool.ts +6 -8
- package/src/tui/App.tsx +3 -4
- package/src/tui/components/ContextPanel.tsx +109 -226
- package/src/tui/components/HelpPanel.tsx +2 -2
- package/src/tui/components/StatusBar.tsx +0 -6
- package/src/tui/components/ThreadPanel.tsx +8 -7
- package/src/tui/wrapDetail.ts +11 -0
- package/src/worker/heartbeat.ts +0 -20
- package/src/worker/index.ts +13 -13
- package/src/worker/llm.ts +7 -9
- package/src/worker/prompt.ts +25 -13
- package/src/worker/spawn.ts +1 -1
- package/src/worker/tick.ts +10 -9
- package/src/commands/db.ts +0 -119
- package/src/commands/with-db.ts +0 -22
- package/src/context/chunker.ts +0 -275
- package/src/context/embedder-impl.ts +0 -100
- package/src/context/embedder.ts +0 -9
- package/src/context/fetcher-errors.ts +0 -8
- package/src/context/fetcher.ts +0 -515
- package/src/context/locks.ts +0 -146
- package/src/context/markdown-converter.ts +0 -186
- package/src/context/reindex.ts +0 -198
- package/src/context/store.ts +0 -841
- package/src/context/url-utils.ts +0 -25
- package/src/db/connection.ts +0 -255
- package/src/db/doctor.ts +0 -235
- package/src/db/embeddings.ts +0 -317
- package/src/db/query.ts +0 -56
- package/src/db/schema.ts +0 -93
- package/src/db/sql/1-core_tables.sql +0 -53
- package/src/db/sql/10-dedupe_context_items.sql +0 -26
- package/src/db/sql/11-rebuild_hnsw.sql +0 -8
- package/src/db/sql/12-workers.sql +0 -66
- package/src/db/sql/13-drive-paths.sql +0 -47
- package/src/db/sql/14-drop_hnsw_index.sql +0 -8
- package/src/db/sql/15-fts_index.sql +0 -8
- package/src/db/sql/16-source_url.sql +0 -7
- package/src/db/sql/17-worker_log_path.sql +0 -3
- package/src/db/sql/18-reset_embeddings_for_local.sql +0 -39
- package/src/db/sql/19-disk_backed_index.sql +0 -36
- package/src/db/sql/2-logging_tables.sql +0 -24
- package/src/db/sql/20-drop_db_tables_for_files.sql +0 -19
- package/src/db/sql/3-daemon_state.sql +0 -5
- package/src/db/sql/4-unique_context_path.sql +0 -1
- package/src/db/sql/5-reset_embeddings_for_openai.sql +0 -1
- package/src/db/sql/6-vss_index.sql +0 -7
- package/src/db/sql/7-drop_embeddings_fk.sql +0 -23
- package/src/db/sql/8-task_output.sql +0 -1
- package/src/db/sql/9-source-type.sql +0 -1
- package/src/tools/context/read-large-result.ts +0 -33
- package/src/tools/dir/create.ts +0 -47
- package/src/tools/dir/size.ts +0 -77
- package/src/tools/dir/tree.ts +0 -124
- package/src/tools/file/copy.ts +0 -73
- package/src/tools/file/count-lines.ts +0 -54
- package/src/tools/file/delete.ts +0 -83
- package/src/tools/file/edit.ts +0 -76
- package/src/tools/file/exists.ts +0 -33
- package/src/tools/file/info.ts +0 -66
- package/src/tools/file/move.ts +0 -66
- package/src/tools/file/read.ts +0 -67
- package/src/tools/file/write.ts +0 -58
- package/src/tools/search/fuse.ts +0 -96
- package/src/tools/search/index.ts +0 -127
- package/src/tools/search/regexp.ts +0 -82
- package/src/tools/search/semantic.ts +0 -167
- /package/src/{db → utils}/uuid.ts +0 -0
package/src/tools/file/edit.ts
DELETED
|
@@ -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>;
|
package/src/tools/file/exists.ts
DELETED
|
@@ -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>;
|
package/src/tools/file/info.ts
DELETED
|
@@ -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>;
|
package/src/tools/file/move.ts
DELETED
|
@@ -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>;
|
package/src/tools/file/read.ts
DELETED
|
@@ -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>;
|
package/src/tools/file/write.ts
DELETED
|
@@ -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>;
|
package/src/tools/search/fuse.ts
DELETED
|
@@ -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
|
-
}
|