@gotgenes/pi-colgrep 1.0.0 → 1.2.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/CHANGELOG.md +43 -0
- package/package.json +10 -4
- package/src/extension.ts +91 -2
- package/src/lib/args.ts +41 -0
- package/src/lib/availability.ts +44 -0
- package/src/lib/exec.ts +9 -0
- package/src/lib/format.ts +71 -0
- package/src/lib/reindex.ts +111 -0
- package/src/lib/search.ts +37 -0
- package/src/tool-result.ts +31 -0
- package/src/tools/colgrep.ts +211 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.2.0](https://github.com/gotgenes/pi-packages/compare/pi-colgrep-v1.1.0...pi-colgrep-v1.2.0) (2026-05-23)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add debounced reindex scheduling ([#91](https://github.com/gotgenes/pi-packages/issues/91)) ([f12bd7d](https://github.com/gotgenes/pi-packages/commit/f12bd7d2fb47b7dbe7edfbfa65705ebe727280cc))
|
|
9
|
+
* add reindexer shutdown ([#91](https://github.com/gotgenes/pi-packages/issues/91)) ([55db463](https://github.com/gotgenes/pi-packages/commit/55db4631386856e1bc791f6c01b3f03251f05c23))
|
|
10
|
+
* add reindexer with immediate execution ([#91](https://github.com/gotgenes/pi-packages/issues/91)) ([84356be](https://github.com/gotgenes/pi-packages/commit/84356befd9c146ca518919587a27361965671c5f))
|
|
11
|
+
* clean up reindexer on session shutdown ([#91](https://github.com/gotgenes/pi-packages/issues/91)) ([9a87869](https://github.com/gotgenes/pi-packages/commit/9a878697f352bf111ca6c1dc86ce99d6c3916eef))
|
|
12
|
+
* handle reindex errors gracefully ([#91](https://github.com/gotgenes/pi-packages/issues/91)) ([2ca14d5](https://github.com/gotgenes/pi-packages/commit/2ca14d544275cf16412435079206a0c9184bb266))
|
|
13
|
+
* queue reindex behind in-flight run ([#91](https://github.com/gotgenes/pi-packages/issues/91)) ([bb9ccbe](https://github.com/gotgenes/pi-packages/commit/bb9ccbe1eb56df768e547649a069ac3231835bfe))
|
|
14
|
+
* register /colgrep-reindex manual command ([#91](https://github.com/gotgenes/pi-packages/issues/91)) ([c591e80](https://github.com/gotgenes/pi-packages/commit/c591e8016ea9dac9db8d47e9fe51e01febc0c5b6))
|
|
15
|
+
* reindex on session start ([#91](https://github.com/gotgenes/pi-packages/issues/91)) ([d18cb8a](https://github.com/gotgenes/pi-packages/commit/d18cb8a1de49c7d92535a1b14d8d02cd6a7f93eb))
|
|
16
|
+
* schedule reindex on write/edit tool results ([#91](https://github.com/gotgenes/pi-packages/issues/91)) ([bb17ea0](https://github.com/gotgenes/pi-packages/commit/bb17ea0b6bb532f0d1a64e0e07a914f408482633))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
* plan auto-reindex on session start and file mutations ([#91](https://github.com/gotgenes/pi-packages/issues/91)) ([291d55f](https://github.com/gotgenes/pi-packages/commit/291d55f2a03674c3d84ab2e59d053b72c1b2d9b6))
|
|
22
|
+
|
|
23
|
+
## [1.1.0](https://github.com/gotgenes/pi-packages/compare/pi-colgrep-v1.0.0...pi-colgrep-v1.1.0) (2026-05-23)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Features
|
|
27
|
+
|
|
28
|
+
* add colgrep availability check ([#90](https://github.com/gotgenes/pi-packages/issues/90)) ([4bf9893](https://github.com/gotgenes/pi-packages/commit/4bf989340bfae97c1aa3bca7bd246768222a4fad))
|
|
29
|
+
* add colgrep CLI argument builder ([#90](https://github.com/gotgenes/pi-packages/issues/90)) ([80ba0db](https://github.com/gotgenes/pi-packages/commit/80ba0dba499b37116c98694ea0933b3a624cb85f))
|
|
30
|
+
* add colgrep result formatting ([#90](https://github.com/gotgenes/pi-packages/issues/90)) ([efad4e7](https://github.com/gotgenes/pi-packages/commit/efad4e778c06afadfeba0bb0e965bde9b0db247a))
|
|
31
|
+
* add colgrep search execution ([#90](https://github.com/gotgenes/pi-packages/issues/90)) ([d896766](https://github.com/gotgenes/pi-packages/commit/d8967660175c525e4234a2170d79fb853ecc6a1e))
|
|
32
|
+
* add exec type and tool-result helpers ([#90](https://github.com/gotgenes/pi-packages/issues/90)) ([3142a2b](https://github.com/gotgenes/pi-packages/commit/3142a2b2d0aa5577d3883f5d58bc7674711fe324))
|
|
33
|
+
* register colgrep search tool ([#90](https://github.com/gotgenes/pi-packages/issues/90)) ([0ad2bbd](https://github.com/gotgenes/pi-packages/commit/0ad2bbdc7a8926d461f5c8b37786e12940ae65f5))
|
|
34
|
+
* wire colgrep tool and availability check into extension ([#90](https://github.com/gotgenes/pi-packages/issues/90)) ([738bbdb](https://github.com/gotgenes/pi-packages/commit/738bbdb3208f6bc37e802725cea2093ed94f8e9c))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
### Documentation
|
|
38
|
+
|
|
39
|
+
* plan colgrep search tool registration ([#90](https://github.com/gotgenes/pi-packages/issues/90)) ([30a723d](https://github.com/gotgenes/pi-packages/commit/30a723df4b31d3ceb3b609fe89562d0ba4e43f05))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
### Miscellaneous Chores
|
|
43
|
+
|
|
44
|
+
* add vitest test infrastructure for pi-colgrep ([#90](https://github.com/gotgenes/pi-packages/issues/90)) ([eba526d](https://github.com/gotgenes/pi-packages/commit/eba526d7dc0f962ad06b9d733613fd0a3568465d))
|
|
45
|
+
|
|
3
46
|
## 1.0.0 (2026-05-23)
|
|
4
47
|
|
|
5
48
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotgenes/pi-colgrep",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Pi extension that integrates ColGrep semantic code search as an agent tool.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -43,18 +43,24 @@
|
|
|
43
43
|
]
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
|
-
"@earendil-works/pi-coding-agent": ">=0.75.0"
|
|
46
|
+
"@earendil-works/pi-coding-agent": ">=0.75.0",
|
|
47
|
+
"@earendil-works/pi-tui": ">=0.75.0"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
49
50
|
"@biomejs/biome": "^2.4.14",
|
|
50
51
|
"@earendil-works/pi-coding-agent": "0.75.4",
|
|
52
|
+
"@earendil-works/pi-tui": "0.75.4",
|
|
51
53
|
"@types/node": "^22.15.3",
|
|
52
54
|
"rumdl": "^0.1.93",
|
|
53
|
-
"
|
|
55
|
+
"typebox": "^1.1.38",
|
|
56
|
+
"typescript": "^6.0.3",
|
|
57
|
+
"vitest": "^4.1.5"
|
|
54
58
|
},
|
|
55
59
|
"scripts": {
|
|
56
60
|
"check": "tsc --noEmit",
|
|
57
61
|
"lint:md": "rumdl check *.md docs/**/*.md",
|
|
58
|
-
"lint": "biome check . && pnpm run lint:md"
|
|
62
|
+
"lint": "biome check . && pnpm run lint:md",
|
|
63
|
+
"test": "vitest run",
|
|
64
|
+
"test:watch": "vitest"
|
|
59
65
|
}
|
|
60
66
|
}
|
package/src/extension.ts
CHANGED
|
@@ -1,5 +1,94 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { createAvailabilityState } from "./lib/availability.js";
|
|
3
|
+
import { createReindexer, type Reindexer } from "./lib/reindex.js";
|
|
4
|
+
import { registerColGrep } from "./tools/colgrep.js";
|
|
2
5
|
|
|
3
|
-
|
|
4
|
-
|
|
6
|
+
const COLGREP_STATUS_KEY = "colgrep";
|
|
7
|
+
|
|
8
|
+
function setColGrepStatus(
|
|
9
|
+
ctx: { ui: { setStatus?: (key: string, text: string | undefined) => void } },
|
|
10
|
+
text: string | undefined,
|
|
11
|
+
): void {
|
|
12
|
+
if (typeof ctx.ui.setStatus === "function") {
|
|
13
|
+
ctx.ui.setStatus(COLGREP_STATUS_KEY, text);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function piColGrepExtension(pi: ExtensionAPI): void {
|
|
18
|
+
const availability = createAvailabilityState();
|
|
19
|
+
let reindexer: Reindexer | undefined;
|
|
20
|
+
|
|
21
|
+
registerColGrep(pi, {
|
|
22
|
+
exec: (cmd, args, opts) => pi.exec(cmd, args, opts),
|
|
23
|
+
availability,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
27
|
+
const exec = (
|
|
28
|
+
cmd: string,
|
|
29
|
+
args: string[],
|
|
30
|
+
opts?: { cwd?: string; timeout?: number; signal?: AbortSignal },
|
|
31
|
+
) => pi.exec(cmd, args, opts);
|
|
32
|
+
|
|
33
|
+
await availability.refresh(exec);
|
|
34
|
+
|
|
35
|
+
if (!availability.available) {
|
|
36
|
+
ctx.ui.notify(
|
|
37
|
+
"colgrep is not installed. Semantic code search will not be available.\n" +
|
|
38
|
+
"Install from: https://github.com/lightonai/next-plaid#installation",
|
|
39
|
+
"warning",
|
|
40
|
+
);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
reindexer = createReindexer({
|
|
45
|
+
exec,
|
|
46
|
+
cwd: ctx.cwd,
|
|
47
|
+
onStatus: (text) => setColGrepStatus(ctx, text),
|
|
48
|
+
});
|
|
49
|
+
await reindexer.runNow();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
pi.on("tool_result", async (event, _ctx) => {
|
|
53
|
+
if (event.isError) return;
|
|
54
|
+
if (event.toolName !== "write" && event.toolName !== "edit") return;
|
|
55
|
+
reindexer?.schedule();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
59
|
+
await reindexer?.shutdown();
|
|
60
|
+
reindexer = undefined;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
pi.registerCommand("colgrep-reindex", {
|
|
64
|
+
description: "Manually refresh the ColGrep semantic search index",
|
|
65
|
+
handler: async (_args, ctx) => {
|
|
66
|
+
if (!availability.available) {
|
|
67
|
+
ctx.ui.notify(
|
|
68
|
+
"colgrep is not installed. Install from: https://github.com/lightonai/next-plaid#installation",
|
|
69
|
+
"warning",
|
|
70
|
+
);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const exec = (
|
|
75
|
+
cmd: string,
|
|
76
|
+
args: string[],
|
|
77
|
+
opts?: { cwd?: string; timeout?: number; signal?: AbortSignal },
|
|
78
|
+
) => pi.exec(cmd, args, opts);
|
|
79
|
+
|
|
80
|
+
// Use the session reindexer if available; otherwise create a one-shot
|
|
81
|
+
// one (e.g., if the command is invoked before session_start has run).
|
|
82
|
+
const indexer =
|
|
83
|
+
reindexer ??
|
|
84
|
+
createReindexer({
|
|
85
|
+
exec,
|
|
86
|
+
cwd: ctx.cwd,
|
|
87
|
+
onStatus: (text) => setColGrepStatus(ctx, text),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await indexer.runNow();
|
|
91
|
+
ctx.ui.notify("ColGrep index updated.", "info");
|
|
92
|
+
},
|
|
93
|
+
});
|
|
5
94
|
}
|
package/src/lib/args.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface SearchParams {
|
|
2
|
+
query?: string;
|
|
3
|
+
regex?: string;
|
|
4
|
+
path?: string;
|
|
5
|
+
glob?: string;
|
|
6
|
+
limit?: number;
|
|
7
|
+
context?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build the CLI argument list for a colgrep search invocation.
|
|
12
|
+
*
|
|
13
|
+
* Always includes `--json`. Positional arguments (query, path) come last
|
|
14
|
+
* so flags are unambiguously parsed by the CLI.
|
|
15
|
+
*/
|
|
16
|
+
export function buildSearchArgs(params: SearchParams): string[] {
|
|
17
|
+
const args: string[] = ["--json"];
|
|
18
|
+
|
|
19
|
+
if (params.regex !== undefined) {
|
|
20
|
+
args.push("-e", params.regex);
|
|
21
|
+
}
|
|
22
|
+
if (params.glob !== undefined) {
|
|
23
|
+
args.push("--include", params.glob);
|
|
24
|
+
}
|
|
25
|
+
if (params.limit !== undefined) {
|
|
26
|
+
args.push("-k", String(params.limit));
|
|
27
|
+
}
|
|
28
|
+
if (params.context !== undefined) {
|
|
29
|
+
args.push("-n", String(params.context));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Positional: query comes before path
|
|
33
|
+
if (params.query !== undefined) {
|
|
34
|
+
args.push(params.query);
|
|
35
|
+
}
|
|
36
|
+
if (params.path !== undefined) {
|
|
37
|
+
args.push(params.path);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return args;
|
|
41
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Exec } from "./exec.js";
|
|
2
|
+
|
|
3
|
+
export interface AvailabilityResult {
|
|
4
|
+
available: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check whether the `colgrep` binary is present and responsive by running
|
|
9
|
+
* `colgrep --version`. Returns `{ available: false }` on any failure so the
|
|
10
|
+
* caller can degrade gracefully without catching.
|
|
11
|
+
*/
|
|
12
|
+
export async function checkAvailability(
|
|
13
|
+
exec: Exec,
|
|
14
|
+
): Promise<AvailabilityResult> {
|
|
15
|
+
try {
|
|
16
|
+
const result = await exec("colgrep", ["--version"], { timeout: 5000 });
|
|
17
|
+
return { available: result.code === 0 };
|
|
18
|
+
} catch {
|
|
19
|
+
return { available: false };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AvailabilityState {
|
|
24
|
+
/** `undefined` before the first `refresh()` call. */
|
|
25
|
+
available: boolean | undefined;
|
|
26
|
+
refresh(exec: Exec): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create a mutable availability state object that caches the result of the
|
|
31
|
+
* most recent `checkAvailability()` call.
|
|
32
|
+
*
|
|
33
|
+
* The `session_start` handler calls `refresh()` once per session and stores
|
|
34
|
+
* the result here. The tool's `execute()` reads `available` synchronously.
|
|
35
|
+
*/
|
|
36
|
+
export function createAvailabilityState(): AvailabilityState {
|
|
37
|
+
return {
|
|
38
|
+
available: undefined,
|
|
39
|
+
async refresh(exec: Exec): Promise<void> {
|
|
40
|
+
const result = await checkAvailability(exec);
|
|
41
|
+
this.available = result.available;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
package/src/lib/exec.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow exec interface matching `pi.exec()` — injected into library modules
|
|
3
|
+
* so they stay free of Pi SDK imports and remain directly testable.
|
|
4
|
+
*/
|
|
5
|
+
export type Exec = (
|
|
6
|
+
command: string,
|
|
7
|
+
args: string[],
|
|
8
|
+
options?: { cwd?: string; timeout?: number; signal?: AbortSignal },
|
|
9
|
+
) => Promise<{ stdout: string; stderr: string; code: number }>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { isAbsolute, relative } from "node:path";
|
|
2
|
+
|
|
3
|
+
export interface ColGrepJsonHit {
|
|
4
|
+
unit: {
|
|
5
|
+
file: string;
|
|
6
|
+
line: number;
|
|
7
|
+
end_line: number;
|
|
8
|
+
};
|
|
9
|
+
score: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Format a single colgrep JSON hit into the concise text representation:
|
|
14
|
+
* `relative/path.ts:startLine-endLine [score=0.xxx]`
|
|
15
|
+
*
|
|
16
|
+
* Paths are made relative to `searchDir`. If the file is outside
|
|
17
|
+
* `searchDir`, the absolute path is used unchanged.
|
|
18
|
+
*/
|
|
19
|
+
export function formatHit(hit: ColGrepJsonHit, searchDir: string): string {
|
|
20
|
+
const rel = relative(searchDir, hit.unit.file);
|
|
21
|
+
// relative() produces a path starting with ".." when outside searchDir.
|
|
22
|
+
// In that case keep the absolute path for clarity.
|
|
23
|
+
const displayPath =
|
|
24
|
+
isAbsolute(rel) || rel.startsWith("..") ? hit.unit.file : rel;
|
|
25
|
+
const score = hit.score.toFixed(3);
|
|
26
|
+
return `${displayPath}:${hit.unit.line}-${hit.unit.end_line} [score=${score}]`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse the colgrep `--json` stdout into agent-friendly text.
|
|
31
|
+
*
|
|
32
|
+
* Returns `"No matches found"` for an empty result set.
|
|
33
|
+
* Falls back to returning `rawOutput` unchanged when the output is not a
|
|
34
|
+
* valid JSON array — defensive against unexpected colgrep output.
|
|
35
|
+
* Skips individual hits that are missing required fields rather than
|
|
36
|
+
* throwing, so a single malformed entry doesn't suppress all results.
|
|
37
|
+
*/
|
|
38
|
+
export function formatResults(rawOutput: string, searchDir: string): string {
|
|
39
|
+
let parsed: unknown;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(rawOutput);
|
|
42
|
+
} catch {
|
|
43
|
+
return rawOutput;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!Array.isArray(parsed)) {
|
|
47
|
+
return rawOutput;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const lines: string[] = [];
|
|
51
|
+
for (const item of parsed) {
|
|
52
|
+
if (!isValidHit(item)) continue;
|
|
53
|
+
lines.push(formatHit(item, searchDir));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return lines.length === 0 ? "No matches found" : lines.join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isValidHit(item: unknown): item is ColGrepJsonHit {
|
|
60
|
+
if (typeof item !== "object" || item === null) return false;
|
|
61
|
+
const obj = item as Record<string, unknown>;
|
|
62
|
+
if (typeof obj.score !== "number") return false;
|
|
63
|
+
const unit = obj.unit;
|
|
64
|
+
if (typeof unit !== "object" || unit === null) return false;
|
|
65
|
+
const u = unit as Record<string, unknown>;
|
|
66
|
+
return (
|
|
67
|
+
typeof u.file === "string" &&
|
|
68
|
+
typeof u.line === "number" &&
|
|
69
|
+
typeof u.end_line === "number"
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Exec } from "./exec.js";
|
|
2
|
+
|
|
3
|
+
export type ReindexStatusCallback = (status: string | undefined) => void;
|
|
4
|
+
|
|
5
|
+
export interface ReindexerDeps {
|
|
6
|
+
exec: Exec;
|
|
7
|
+
cwd: string;
|
|
8
|
+
onStatus: ReindexStatusCallback;
|
|
9
|
+
/** Debounce quiet period before a scheduled reindex fires. Defaults to 4000 ms. */
|
|
10
|
+
debounceMs?: number;
|
|
11
|
+
/** Exec timeout for the reindex command. Defaults to 300 000 ms (5 min). */
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Reindexer {
|
|
16
|
+
/** Schedule a debounced reindex. Safe to call repeatedly. */
|
|
17
|
+
schedule(): void;
|
|
18
|
+
/** Run a reindex immediately, bypassing debounce. Resolves when complete. */
|
|
19
|
+
runNow(): Promise<void>;
|
|
20
|
+
/** Cancel pending timers and wait for any in-flight reindex to finish. */
|
|
21
|
+
shutdown(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_DEBOUNCE_MS = 4_000;
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
26
|
+
const INDEXING_STATUS = "colgrep: indexing\u2026";
|
|
27
|
+
const QUEUED_STATUS = "colgrep: indexing\u2026 (queued updates)";
|
|
28
|
+
const INDEXING_FAILED_STATUS = "colgrep: indexing failed";
|
|
29
|
+
|
|
30
|
+
export function createReindexer(deps: ReindexerDeps): Reindexer {
|
|
31
|
+
const { exec, cwd, onStatus } = deps;
|
|
32
|
+
const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
33
|
+
const debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
34
|
+
|
|
35
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
36
|
+
let inFlight = false;
|
|
37
|
+
let queued = false;
|
|
38
|
+
let isShutdown = false;
|
|
39
|
+
let inflightPromise: Promise<void> | undefined;
|
|
40
|
+
|
|
41
|
+
async function runReindex(): Promise<void> {
|
|
42
|
+
inFlight = true;
|
|
43
|
+
onStatus(INDEXING_STATUS);
|
|
44
|
+
let failed = false;
|
|
45
|
+
try {
|
|
46
|
+
const result = await exec("colgrep", ["init", "-y", "."], {
|
|
47
|
+
cwd,
|
|
48
|
+
timeout: timeoutMs,
|
|
49
|
+
});
|
|
50
|
+
if (result.code !== 0) {
|
|
51
|
+
failed = true;
|
|
52
|
+
const detail = result.stderr.trim();
|
|
53
|
+
console.error(
|
|
54
|
+
`colgrep reindex failed: ${detail || `exit code ${result.code}`}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
failed = true;
|
|
59
|
+
console.error("colgrep reindex failed:", err);
|
|
60
|
+
}
|
|
61
|
+
if (failed) {
|
|
62
|
+
onStatus(INDEXING_FAILED_STATUS);
|
|
63
|
+
}
|
|
64
|
+
onStatus(undefined);
|
|
65
|
+
inFlight = false;
|
|
66
|
+
inflightPromise = undefined;
|
|
67
|
+
|
|
68
|
+
// Drain: if a reindex was queued while we were running, start it now
|
|
69
|
+
// (unless shut down in the meantime).
|
|
70
|
+
if (queued && !isShutdown) {
|
|
71
|
+
queued = false;
|
|
72
|
+
inflightPromise = runReindex();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
async runNow(): Promise<void> {
|
|
78
|
+
await runReindex();
|
|
79
|
+
},
|
|
80
|
+
schedule(): void {
|
|
81
|
+
if (isShutdown) return;
|
|
82
|
+
|
|
83
|
+
// While a reindex is in flight, mark a queued follow-up instead of
|
|
84
|
+
// starting another debounce timer.
|
|
85
|
+
if (inFlight) {
|
|
86
|
+
if (!queued) {
|
|
87
|
+
queued = true;
|
|
88
|
+
onStatus(QUEUED_STATUS);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (debounceTimer !== undefined) {
|
|
93
|
+
clearTimeout(debounceTimer);
|
|
94
|
+
}
|
|
95
|
+
debounceTimer = setTimeout(() => {
|
|
96
|
+
debounceTimer = undefined;
|
|
97
|
+
inflightPromise = runReindex();
|
|
98
|
+
}, debounceMs);
|
|
99
|
+
},
|
|
100
|
+
async shutdown(): Promise<void> {
|
|
101
|
+
isShutdown = true;
|
|
102
|
+
if (debounceTimer !== undefined) {
|
|
103
|
+
clearTimeout(debounceTimer);
|
|
104
|
+
debounceTimer = undefined;
|
|
105
|
+
}
|
|
106
|
+
if (inflightPromise !== undefined) {
|
|
107
|
+
await inflightPromise;
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { buildSearchArgs, type SearchParams } from "./args.js";
|
|
2
|
+
import type { Exec } from "./exec.js";
|
|
3
|
+
import { formatResults } from "./format.js";
|
|
4
|
+
|
|
5
|
+
export interface SearchResult {
|
|
6
|
+
/** Formatted hit lines, present on success. */
|
|
7
|
+
output?: string;
|
|
8
|
+
/** Error message, present on failure. */
|
|
9
|
+
error?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Run a colgrep search and return formatted results.
|
|
14
|
+
*
|
|
15
|
+
* Paths in the output are relativized against `params.path` when provided,
|
|
16
|
+
* otherwise against `cwd`.
|
|
17
|
+
*/
|
|
18
|
+
export async function runSearch(
|
|
19
|
+
exec: Exec,
|
|
20
|
+
params: SearchParams,
|
|
21
|
+
cwd: string,
|
|
22
|
+
signal?: AbortSignal,
|
|
23
|
+
): Promise<SearchResult> {
|
|
24
|
+
const args = buildSearchArgs(params);
|
|
25
|
+
const result = await exec("colgrep", args, { cwd, signal });
|
|
26
|
+
|
|
27
|
+
if (result.code !== 0) {
|
|
28
|
+
const detail = result.stderr.trim();
|
|
29
|
+
const message = detail
|
|
30
|
+
? detail
|
|
31
|
+
: `colgrep exited with exit code ${result.code}`;
|
|
32
|
+
return { error: message };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const searchDir = params.path ?? cwd;
|
|
36
|
+
return { output: formatResults(result.stdout, searchDir) };
|
|
37
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers to create AgentToolResult-compatible objects.
|
|
3
|
+
*
|
|
4
|
+
* Pi's AgentToolResult expects `content` as an array of TextContent objects
|
|
5
|
+
* and a `details` field. These helpers wrap a plain string into that shape.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
interface ToolResult<TDetails = undefined> {
|
|
9
|
+
content: { type: "text"; text: string }[];
|
|
10
|
+
details: TDetails;
|
|
11
|
+
isError: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ok<TDetails = undefined>(
|
|
15
|
+
text: string,
|
|
16
|
+
details?: TDetails,
|
|
17
|
+
): ToolResult<TDetails> {
|
|
18
|
+
return {
|
|
19
|
+
content: [{ type: "text", text }],
|
|
20
|
+
details: details as TDetails,
|
|
21
|
+
isError: false,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function err(text: string): ToolResult<undefined> {
|
|
26
|
+
return {
|
|
27
|
+
content: [{ type: "text", text }],
|
|
28
|
+
details: undefined,
|
|
29
|
+
isError: true,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
ExtensionAPI,
|
|
6
|
+
ExtensionContext,
|
|
7
|
+
Theme,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_MAX_BYTES,
|
|
11
|
+
DEFAULT_MAX_LINES,
|
|
12
|
+
truncateHead,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
15
|
+
import { Type } from "typebox";
|
|
16
|
+
import type { SearchParams } from "../lib/args.js";
|
|
17
|
+
import type { AvailabilityState } from "../lib/availability.js";
|
|
18
|
+
import type { Exec } from "../lib/exec.js";
|
|
19
|
+
import { runSearch } from "../lib/search.js";
|
|
20
|
+
import { err, ok } from "../tool-result.js";
|
|
21
|
+
|
|
22
|
+
export interface ColGrepToolDetails {
|
|
23
|
+
hitCount: number;
|
|
24
|
+
truncated: boolean;
|
|
25
|
+
fullOutputPath?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ColGrepExecuteDeps {
|
|
29
|
+
exec: Exec;
|
|
30
|
+
availability: AvailabilityState;
|
|
31
|
+
/** Override default truncation line limit. Used in tests. */
|
|
32
|
+
maxLines?: number;
|
|
33
|
+
/** Override default truncation byte limit. Used in tests. */
|
|
34
|
+
maxBytes?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Core execute logic, extracted for testability.
|
|
39
|
+
* Called by the tool's `execute()` callback with `ctx.cwd` as cwd.
|
|
40
|
+
*/
|
|
41
|
+
export async function executeColGrepSearch(
|
|
42
|
+
params: SearchParams,
|
|
43
|
+
deps: ColGrepExecuteDeps,
|
|
44
|
+
cwd: string,
|
|
45
|
+
signal: AbortSignal | undefined,
|
|
46
|
+
) {
|
|
47
|
+
if (!deps.availability.available) {
|
|
48
|
+
return err(
|
|
49
|
+
"colgrep is not installed or not available.\n" +
|
|
50
|
+
"Install it from: https://github.com/lightonai/next-plaid#installation",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!params.query && !params.regex) {
|
|
55
|
+
return err("At least one of query or regex is required.");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const searchResult = await runSearch(deps.exec, params, cwd, signal);
|
|
59
|
+
if (searchResult.error) {
|
|
60
|
+
return err(searchResult.error);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const output = searchResult.output ?? "No matches found";
|
|
64
|
+
const maxLines = deps.maxLines ?? DEFAULT_MAX_LINES;
|
|
65
|
+
const maxBytes = deps.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
66
|
+
const truncation = truncateHead(output, { maxLines, maxBytes });
|
|
67
|
+
|
|
68
|
+
if (truncation.truncated) {
|
|
69
|
+
const tempPath = join(tmpdir(), `colgrep-${Date.now()}.txt`);
|
|
70
|
+
await writeFile(tempPath, output);
|
|
71
|
+
const hitCount = countHits(output);
|
|
72
|
+
return ok(`${truncation.content}\n[Truncated. Full output: ${tempPath}]`, {
|
|
73
|
+
hitCount,
|
|
74
|
+
truncated: true,
|
|
75
|
+
fullOutputPath: tempPath,
|
|
76
|
+
} satisfies ColGrepToolDetails);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const hitCount = countHits(output);
|
|
80
|
+
return ok(output, {
|
|
81
|
+
hitCount,
|
|
82
|
+
truncated: false,
|
|
83
|
+
} satisfies ColGrepToolDetails);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface RegisterColGrepDeps {
|
|
87
|
+
exec: Exec;
|
|
88
|
+
availability: AvailabilityState;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function registerColGrep(
|
|
92
|
+
pi: ExtensionAPI,
|
|
93
|
+
deps: RegisterColGrepDeps,
|
|
94
|
+
): void {
|
|
95
|
+
pi.registerTool({
|
|
96
|
+
name: "colgrep",
|
|
97
|
+
label: "ColGrep",
|
|
98
|
+
description:
|
|
99
|
+
"Search for code by meaning using semantic / hybrid search (ColBERT embeddings + tree-sitter). " +
|
|
100
|
+
"Complements the built-in grep: use colgrep for intent-based exploration, grep for exact pattern matching. " +
|
|
101
|
+
"At least one of query or regex is required.",
|
|
102
|
+
promptSnippet:
|
|
103
|
+
"colgrep: Semantic and hybrid code search — find code by intent, not just text.",
|
|
104
|
+
promptGuidelines: [
|
|
105
|
+
"Prefer colgrep for intent-based searches and exploration (e.g. 'error handling for database connections').",
|
|
106
|
+
"Use grep for exact pattern or symbol matching; use colgrep when keywords may not match exactly.",
|
|
107
|
+
"Increase limit (default 15) when exploring a large codebase — try limit=30 for broader coverage.",
|
|
108
|
+
],
|
|
109
|
+
parameters: Type.Object({
|
|
110
|
+
query: Type.Optional(
|
|
111
|
+
Type.String({
|
|
112
|
+
description: "Semantic search query (natural language intent)",
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
regex: Type.Optional(
|
|
116
|
+
Type.String({
|
|
117
|
+
description:
|
|
118
|
+
"Regex pre-filter applied before semantic ranking (-e flag)",
|
|
119
|
+
}),
|
|
120
|
+
),
|
|
121
|
+
path: Type.Optional(
|
|
122
|
+
Type.String({
|
|
123
|
+
description: "Directory or file to search (defaults to cwd)",
|
|
124
|
+
}),
|
|
125
|
+
),
|
|
126
|
+
glob: Type.Optional(
|
|
127
|
+
Type.String({ description: "Include glob pattern (--include flag)" }),
|
|
128
|
+
),
|
|
129
|
+
limit: Type.Optional(
|
|
130
|
+
Type.Number({ description: "Max results (-k flag, default: 15)" }),
|
|
131
|
+
),
|
|
132
|
+
context: Type.Optional(
|
|
133
|
+
Type.Number({ description: "Context lines (-n flag)" }),
|
|
134
|
+
),
|
|
135
|
+
}),
|
|
136
|
+
renderCall(args, theme, context) {
|
|
137
|
+
const text =
|
|
138
|
+
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
139
|
+
text.setText(formatCall(args, theme));
|
|
140
|
+
return text;
|
|
141
|
+
},
|
|
142
|
+
renderResult(result, options, theme, context) {
|
|
143
|
+
const text =
|
|
144
|
+
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
145
|
+
text.setText(formatResult(result, options, theme));
|
|
146
|
+
return text;
|
|
147
|
+
},
|
|
148
|
+
async execute(
|
|
149
|
+
_toolCallId,
|
|
150
|
+
params,
|
|
151
|
+
signal,
|
|
152
|
+
_onUpdate,
|
|
153
|
+
ctx: ExtensionContext,
|
|
154
|
+
) {
|
|
155
|
+
return executeColGrepSearch(params, deps, ctx.cwd, signal);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- rendering helpers ----
|
|
161
|
+
|
|
162
|
+
function formatCall(args: Record<string, unknown>, theme: Theme): string {
|
|
163
|
+
const query = typeof args.query === "string" ? args.query : undefined;
|
|
164
|
+
const regex = typeof args.regex === "string" ? args.regex : undefined;
|
|
165
|
+
const path = typeof args.path === "string" ? args.path : ".";
|
|
166
|
+
const limit = typeof args.limit === "number" ? args.limit : 15;
|
|
167
|
+
|
|
168
|
+
const parts: string[] = [theme.fg("toolTitle", theme.bold("colgrep"))];
|
|
169
|
+
if (query) parts.push(theme.fg("accent", `"${query}"`));
|
|
170
|
+
if (regex) parts.push(theme.fg("accent", `-e /${regex}/`));
|
|
171
|
+
parts.push(theme.fg("toolOutput", `in ${path} (k=${limit})`));
|
|
172
|
+
return parts.join(" ");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface AnyToolResult {
|
|
176
|
+
content: Array<{ type: string; text?: string }>;
|
|
177
|
+
details?: ColGrepToolDetails;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function formatResult(
|
|
181
|
+
result: unknown,
|
|
182
|
+
options: { expanded: boolean },
|
|
183
|
+
theme: Theme,
|
|
184
|
+
): string {
|
|
185
|
+
const r = result as AnyToolResult;
|
|
186
|
+
const details = r.details;
|
|
187
|
+
const hitCount = details?.hitCount ?? 0;
|
|
188
|
+
const outputText =
|
|
189
|
+
r.content[0]?.type === "text" ? (r.content[0].text ?? "") : "";
|
|
190
|
+
|
|
191
|
+
if (!options.expanded) {
|
|
192
|
+
const icon = theme.fg("success", "✓");
|
|
193
|
+
const count =
|
|
194
|
+
hitCount === 0
|
|
195
|
+
? theme.fg("muted", "no matches")
|
|
196
|
+
: theme.fg("muted", `${hitCount} hit${hitCount === 1 ? "" : "s"}`);
|
|
197
|
+
return `${icon} ${count}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const lines = outputText
|
|
201
|
+
.split("\n")
|
|
202
|
+
.map((l: string) => theme.fg("toolOutput", ` ${l}`));
|
|
203
|
+
return lines.join("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---- private helpers ----
|
|
207
|
+
|
|
208
|
+
function countHits(output: string): number {
|
|
209
|
+
if (output === "No matches found") return 0;
|
|
210
|
+
return output.split("\n").length;
|
|
211
|
+
}
|