zob-harness 0.4.0 → 0.6.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/.pi/capabilities/zob-public-runtime-capabilities.json +54 -0
- package/.pi/context-discovery.json +36 -0
- package/.pi/extensions/zob-harness/src/domains/context/context-discovery.ts +317 -0
- package/.pi/extensions/zob-harness/src/domains/intent/intent-classifier.ts +418 -0
- package/.pi/extensions/zob-harness/src/runtime/commands.ts +384 -0
- package/.pi/extensions/zob-harness/src/runtime/events.ts +42 -5
- package/.pi/extensions/zob-harness/src/runtime/schemas.ts +10 -0
- package/.pi/extensions/zob-harness/src/runtime/state.ts +2 -10
- package/.pi/extensions/zob-harness/src/runtime/tools-context.ts +20 -0
- package/.pi/routing/intent-classifier.json +29 -0
- package/.pi/skills/zob-context-discovery/SKILL.md +54 -0
- package/.pi/skills/zob-harness/SKILL.md +4 -3
- package/AGENTS.md +1 -1
- package/CONTRIBUTING.md +3 -1
- package/README.md +80 -20
- package/package.json +20 -1
- package/scripts/README.md +7 -0
- package/scripts/context-discovery/doctor.mjs +32 -0
- package/scripts/context-discovery/init-colgrep.mjs +64 -0
- package/scripts/context-discovery/query.mjs +61 -0
- package/scripts/context-discovery/shared.mjs +249 -0
- package/scripts/context-discovery/smoke.mjs +36 -0
- package/scripts/intent-classifier/smoke.mjs +135 -0
- package/scripts/release/preview.mjs +195 -0
|
@@ -1010,6 +1010,28 @@
|
|
|
1010
1010
|
],
|
|
1011
1011
|
"noShipNotes": "Proposal only; no direct command execution."
|
|
1012
1012
|
},
|
|
1013
|
+
{
|
|
1014
|
+
"name": "zob_context_search",
|
|
1015
|
+
"family": "context",
|
|
1016
|
+
"modes": [
|
|
1017
|
+
"explore",
|
|
1018
|
+
"plan",
|
|
1019
|
+
"implement",
|
|
1020
|
+
"oracle",
|
|
1021
|
+
"factory",
|
|
1022
|
+
"orchestrator"
|
|
1023
|
+
],
|
|
1024
|
+
"skillRefs": [
|
|
1025
|
+
".pi/skills/zob-context-discovery/SKILL.md",
|
|
1026
|
+
".pi/skills/zob-harness/SKILL.md"
|
|
1027
|
+
],
|
|
1028
|
+
"docRefs": [
|
|
1029
|
+
"reports/context-discovery/design.md",
|
|
1030
|
+
"README.md",
|
|
1031
|
+
"scripts/README.md"
|
|
1032
|
+
],
|
|
1033
|
+
"noShipNotes": "Bounded repo-local context discovery only; prefer ColGREP when installed/ready, otherwise use grep/find/read fallback. Never auto-install ColGREP, run installer/network/package-manager commands, read forbidden/secret/session/vendor/build paths, persist raw secret/session bodies, inject stale/global/unbounded prompt context, or treat broad search hits as exact proof without grep/read/file-ref verification. Oracle/no-ship review must check freshness, citation coverage, and forbidden-source violations."
|
|
1034
|
+
},
|
|
1013
1035
|
{
|
|
1014
1036
|
"name": "zob_context_readiness",
|
|
1015
1037
|
"family": "context",
|
|
@@ -1431,6 +1453,38 @@
|
|
|
1431
1453
|
],
|
|
1432
1454
|
"noShipNotes": "Switches active harness mode only. orchestrator routes to adaptive-chief-vision plan_only defaults; vanilla restores Pi base-style unrestricted tool access outside ZOB governance and disables ZOB-specific bash mutation blocks. Child dispatch in governed modes requires parent-owned contract/preflight gates and writes remain blocked without sandbox/oracle/human approval."
|
|
1433
1455
|
},
|
|
1456
|
+
{
|
|
1457
|
+
"name": "intent-classifier",
|
|
1458
|
+
"family": "intent-routing",
|
|
1459
|
+
"modes": [
|
|
1460
|
+
"all"
|
|
1461
|
+
],
|
|
1462
|
+
"skillRefs": [
|
|
1463
|
+
".pi/skills/zob-harness/SKILL.md"
|
|
1464
|
+
],
|
|
1465
|
+
"docRefs": [
|
|
1466
|
+
"README.md",
|
|
1467
|
+
".pi/routing/intent-classifier.json",
|
|
1468
|
+
".pi/extensions/zob-harness/src/domains/intent/intent-classifier.ts",
|
|
1469
|
+
".pi/extensions/zob-harness/src/runtime/commands.ts"
|
|
1470
|
+
],
|
|
1471
|
+
"noShipNotes": "Slash UX for optional intent routing only: status|regex|model-strict [model]|model-fallback [model]|test. The owner-facing path selects current/available Pi models, similar to /model, without requiring endpoint/API-key flags. autoSwitchIntents controls direct mode switching and this project enables explore, plan, implement, oracle, factory, orchestrator, and vanilla by default. model-strict uses fallback=unknown and must not silently fall back to regex; model-fallback is explicit regex fallback. Advanced http-json endpoint metadata is hidden/optional for custom experiments only. Ledgers never store raw test text or API keys, and the classifier never approves secrets, destructive commands, commits, deploys, session reads, or no-ship state."
|
|
1472
|
+
},
|
|
1473
|
+
{
|
|
1474
|
+
"name": "intent",
|
|
1475
|
+
"family": "intent-routing",
|
|
1476
|
+
"modes": [
|
|
1477
|
+
"all"
|
|
1478
|
+
],
|
|
1479
|
+
"skillRefs": [
|
|
1480
|
+
".pi/skills/zob-harness/SKILL.md"
|
|
1481
|
+
],
|
|
1482
|
+
"docRefs": [
|
|
1483
|
+
"README.md",
|
|
1484
|
+
".pi/extensions/zob-harness/src/runtime/commands.ts"
|
|
1485
|
+
],
|
|
1486
|
+
"noShipNotes": "Alias for /intent-classifier with the same safety/privacy behavior."
|
|
1487
|
+
},
|
|
1434
1488
|
{
|
|
1435
1489
|
"name": "stop",
|
|
1436
1490
|
"family": "runtime-control",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"preferredProvider": "colgrep",
|
|
4
|
+
"fallbackProvider": "grep",
|
|
5
|
+
"includePaths": [
|
|
6
|
+
".pi/extensions",
|
|
7
|
+
".pi/skills",
|
|
8
|
+
".pi/capabilities",
|
|
9
|
+
"scripts",
|
|
10
|
+
"docs",
|
|
11
|
+
"README.md",
|
|
12
|
+
"AGENTS.md"
|
|
13
|
+
],
|
|
14
|
+
"excludePaths": [
|
|
15
|
+
".env",
|
|
16
|
+
"**/.env",
|
|
17
|
+
".env.*",
|
|
18
|
+
"**/*secret*",
|
|
19
|
+
"**/*key*",
|
|
20
|
+
"*.pem",
|
|
21
|
+
".pi/sessions",
|
|
22
|
+
".pi/agent-sessions",
|
|
23
|
+
"node_modules",
|
|
24
|
+
"dist",
|
|
25
|
+
"build"
|
|
26
|
+
],
|
|
27
|
+
"limits": {
|
|
28
|
+
"maxResults": 20,
|
|
29
|
+
"maxContextLines": 2,
|
|
30
|
+
"maxFileBytes": 1048576
|
|
31
|
+
},
|
|
32
|
+
"promptInjection": {
|
|
33
|
+
"enabled": true,
|
|
34
|
+
"includeInstallHint": true
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, extname, isAbsolute, join, normalize, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type ContextSearchMode = "auto" | "semantic" | "hybrid" | "regex" | "files";
|
|
6
|
+
|
|
7
|
+
export interface ContextDiscoveryConfig {
|
|
8
|
+
schemaVersion: number;
|
|
9
|
+
preferredProvider: string;
|
|
10
|
+
fallbackProvider: string;
|
|
11
|
+
includePaths: string[];
|
|
12
|
+
excludePaths: string[];
|
|
13
|
+
limits: {
|
|
14
|
+
maxResults: number;
|
|
15
|
+
maxContextLines: number;
|
|
16
|
+
maxFileBytes: number;
|
|
17
|
+
};
|
|
18
|
+
promptInjection: {
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
includeInstallHint: boolean;
|
|
21
|
+
};
|
|
22
|
+
loadedFrom: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ContextSearchParams {
|
|
26
|
+
query: string;
|
|
27
|
+
mode?: ContextSearchMode;
|
|
28
|
+
pattern?: string;
|
|
29
|
+
paths?: string[];
|
|
30
|
+
max_results?: number;
|
|
31
|
+
max_context_lines?: number;
|
|
32
|
+
json?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface NormalizedContextResult {
|
|
36
|
+
path: string;
|
|
37
|
+
line?: number;
|
|
38
|
+
ref: string;
|
|
39
|
+
preview: string;
|
|
40
|
+
context?: Array<{ line: number; text: string }>;
|
|
41
|
+
score?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const CONFIG_PATH = ".pi/context-discovery.json";
|
|
45
|
+
const DEFAULT_CONFIG: ContextDiscoveryConfig = {
|
|
46
|
+
schemaVersion: 1,
|
|
47
|
+
preferredProvider: "colgrep",
|
|
48
|
+
fallbackProvider: "grep",
|
|
49
|
+
includePaths: [".pi/extensions", ".pi/skills", ".pi/capabilities", "scripts", "docs", "README.md", "AGENTS.md"],
|
|
50
|
+
excludePaths: [".env", "**/.env", ".env.*", "**/*secret*", "**/*key*", "*.pem", ".pi/sessions", ".pi/agent-sessions", "node_modules", "dist", "build"],
|
|
51
|
+
limits: { maxResults: 20, maxContextLines: 2, maxFileBytes: 1024 * 1024 },
|
|
52
|
+
promptInjection: { enabled: true, includeInstallHint: true },
|
|
53
|
+
loadedFrom: "defaults",
|
|
54
|
+
};
|
|
55
|
+
const TEXT_EXTENSIONS = new Set(["", ".cjs", ".css", ".js", ".json", ".md", ".mjs", ".sh", ".ts", ".tsx", ".txt", ".yaml", ".yml"]);
|
|
56
|
+
|
|
57
|
+
function clampInteger(value: unknown, fallback: number, min: number, max: number): number {
|
|
58
|
+
const numberValue = typeof value === "number" ? value : Number(value);
|
|
59
|
+
if (!Number.isFinite(numberValue)) return fallback;
|
|
60
|
+
return Math.max(min, Math.min(max, Math.floor(numberValue)));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function shellQuote(value: string): string {
|
|
64
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function globToRegExp(pattern: string): RegExp {
|
|
68
|
+
const escaped = pattern.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/gu, "\\$&")).join(".*");
|
|
69
|
+
return new RegExp(`^${escaped}$`, "iu");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function normalizeRepoPath(raw: unknown): string | undefined {
|
|
73
|
+
if (typeof raw !== "string" || raw.trim().length === 0) return undefined;
|
|
74
|
+
const trimmed = raw.trim().replace(/^\.\//u, "");
|
|
75
|
+
if (trimmed.includes("\0") || trimmed.includes("\\") || isAbsolute(trimmed)) return undefined;
|
|
76
|
+
const normalized = normalize(trimmed);
|
|
77
|
+
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith(`..${sep}`)) return undefined;
|
|
78
|
+
return normalized.split(sep).join("/");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function pathIsExcluded(relPath: string, excludePaths: string[]): boolean {
|
|
82
|
+
const normalized = normalizeRepoPath(relPath);
|
|
83
|
+
if (!normalized) return true;
|
|
84
|
+
return excludePaths.some((pattern) => {
|
|
85
|
+
const clean = normalizeRepoPath(pattern) ?? pattern;
|
|
86
|
+
if (clean.includes("*")) {
|
|
87
|
+
return globToRegExp(clean).test(normalized) || globToRegExp(clean.replace(/^\*\*\//u, "")).test(basename(normalized));
|
|
88
|
+
}
|
|
89
|
+
return normalized === clean || normalized.startsWith(`${clean}/`) || basename(normalized) === clean;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function loadContextDiscoveryConfig(repoRoot: string): ContextDiscoveryConfig {
|
|
94
|
+
const path = join(repoRoot, CONFIG_PATH);
|
|
95
|
+
if (!existsSync(path)) return { ...DEFAULT_CONFIG };
|
|
96
|
+
try {
|
|
97
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<ContextDiscoveryConfig>;
|
|
98
|
+
return {
|
|
99
|
+
...DEFAULT_CONFIG,
|
|
100
|
+
...parsed,
|
|
101
|
+
includePaths: Array.isArray(parsed.includePaths) ? parsed.includePaths.filter((item): item is string => typeof item === "string") : DEFAULT_CONFIG.includePaths,
|
|
102
|
+
excludePaths: Array.isArray(parsed.excludePaths) ? parsed.excludePaths.filter((item): item is string => typeof item === "string") : DEFAULT_CONFIG.excludePaths,
|
|
103
|
+
limits: { ...DEFAULT_CONFIG.limits, ...(parsed.limits ?? {}) },
|
|
104
|
+
promptInjection: { ...DEFAULT_CONFIG.promptInjection, ...(parsed.promptInjection ?? {}) },
|
|
105
|
+
loadedFrom: CONFIG_PATH,
|
|
106
|
+
};
|
|
107
|
+
} catch {
|
|
108
|
+
return { ...DEFAULT_CONFIG, loadedFrom: `${CONFIG_PATH}:unreadable-fallback-defaults` };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function commandExists(repoRoot: string, command: string): boolean {
|
|
113
|
+
if (process.env.ZOB_CONTEXT_FORCE_FALLBACK === "1") return false;
|
|
114
|
+
const result = spawnSync("sh", ["-c", `command -v ${shellQuote(command)}`], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 2_000 });
|
|
115
|
+
return result.status === 0 && result.stdout.trim().length > 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function detectColgrep(repoRoot: string): { provider: "colgrep" | "grep-fallback"; installed: boolean; ready: boolean; statusCode?: number | null; guidance: string } {
|
|
119
|
+
if (!commandExists(repoRoot, "colgrep")) {
|
|
120
|
+
return { provider: "grep-fallback", installed: false, ready: false, guidance: "ColGREP is not on PATH. Optional setup: install ColGREP manually, then run npm run zob:context:init. Grep/find fallback is active." };
|
|
121
|
+
}
|
|
122
|
+
const status = spawnSync("colgrep", ["status"], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5_000 });
|
|
123
|
+
const ready = status.status === 0;
|
|
124
|
+
return {
|
|
125
|
+
provider: ready ? "colgrep" : "grep-fallback",
|
|
126
|
+
installed: true,
|
|
127
|
+
ready,
|
|
128
|
+
statusCode: status.status,
|
|
129
|
+
guidance: ready ? "ColGREP detected and ready." : "ColGREP is installed but not ready/indexed. Run npm run zob:context:init or inspect colgrep status. Grep/find fallback is active.",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function buildActiveSearchBackendPromptSnippet(repoRoot: string): string {
|
|
134
|
+
const config = loadContextDiscoveryConfig(repoRoot);
|
|
135
|
+
if (!config.promptInjection.enabled) return "";
|
|
136
|
+
const detection = detectColgrep(repoRoot);
|
|
137
|
+
const scope = `${config.loadedFrom}; roots=${config.includePaths.slice(0, 6).join(",")}; excludes=${config.excludePaths.length}`;
|
|
138
|
+
if (detection.ready) {
|
|
139
|
+
return `\n\nZOB ACTIVE SEARCH BACKEND\n- active search backend: colgrep\n- prompt injection: enabled by ${scope}; bounded per turn from current repo config, not a global/stale context pack.\n- Prefer zob_context_search for codebase discovery and broad/semantic search; use grep/read for exact proof and final citations.\n- Search output must stay bounded and avoid forbidden paths/secrets.`;
|
|
140
|
+
}
|
|
141
|
+
const installHint = config.promptInjection.includeInstallHint ? `\n- Optional ColGREP setup hint: ${detection.guidance}` : "";
|
|
142
|
+
return `\n\nZOB ACTIVE SEARCH BACKEND\n- active search backend: grep fallback\n- prompt injection: enabled by ${scope}; bounded per turn from current repo config, not a global/stale context pack.\n- Prefer zob_context_search, grep, find, and read for bounded repo-local discovery and exact proof.${installHint}\n- Missing ColGREP is not a blocker; do not auto-install it.`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function safeSearchRoots(repoRoot: string, config: ContextDiscoveryConfig, requestedPaths?: string[]): { roots: string[]; rejected: string[] } {
|
|
146
|
+
const source = requestedPaths && requestedPaths.length > 0 ? requestedPaths : config.includePaths;
|
|
147
|
+
const roots: string[] = [];
|
|
148
|
+
const rejected: string[] = [];
|
|
149
|
+
for (const raw of source) {
|
|
150
|
+
const relPath = normalizeRepoPath(raw);
|
|
151
|
+
if (!relPath || pathIsExcluded(relPath, config.excludePaths)) {
|
|
152
|
+
rejected.push(String(raw));
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (!existsSync(join(repoRoot, relPath))) {
|
|
156
|
+
rejected.push(String(raw));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
roots.push(relPath);
|
|
160
|
+
}
|
|
161
|
+
return { roots: [...new Set(roots)].sort(), rejected };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function looksTextFile(relPath: string): boolean {
|
|
165
|
+
return TEXT_EXTENSIONS.has(extname(relPath).toLowerCase());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function collectFiles(repoRoot: string, relPath: string, config: ContextDiscoveryConfig, out: string[]): void {
|
|
169
|
+
const safeRel = normalizeRepoPath(relPath);
|
|
170
|
+
if (!safeRel || pathIsExcluded(safeRel, config.excludePaths)) return;
|
|
171
|
+
const absolute = join(repoRoot, safeRel);
|
|
172
|
+
if (!existsSync(absolute)) return;
|
|
173
|
+
const stat = statSync(absolute);
|
|
174
|
+
if (stat.isFile()) {
|
|
175
|
+
if (stat.size <= config.limits.maxFileBytes && looksTextFile(safeRel)) out.push(safeRel);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (!stat.isDirectory()) return;
|
|
179
|
+
for (const entry of readdirSync(absolute, { withFileTypes: true })) collectFiles(repoRoot, join(safeRel, entry.name), config, out);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function normalizeLineResult(repoRoot: string, item: Record<string, unknown>, config: ContextDiscoveryConfig): NormalizedContextResult | undefined {
|
|
183
|
+
const rawPath = item.path ?? item.file ?? item.filename ?? item.source_path;
|
|
184
|
+
const path = normalizeRepoPath(rawPath);
|
|
185
|
+
if (!path || pathIsExcluded(path, config.excludePaths) || !existsSync(join(repoRoot, path))) return undefined;
|
|
186
|
+
const lineValue = item.line ?? item.lineNumber ?? item.line_number ?? item.start_line;
|
|
187
|
+
const line = typeof lineValue === "number" ? Math.max(1, Math.floor(lineValue)) : undefined;
|
|
188
|
+
const rawPreview = item.preview ?? item.text ?? item.lineText ?? item.content ?? item.match ?? "";
|
|
189
|
+
const preview = String(rawPreview).replace(/\s+/gu, " ").trim().slice(0, 240);
|
|
190
|
+
const score = typeof item.score === "number" ? item.score : undefined;
|
|
191
|
+
return { path, line, ref: line ? `${path}:${line}` : path, preview, score };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function extractJsonResults(repoRoot: string, stdout: string, config: ContextDiscoveryConfig, maxResults: number): NormalizedContextResult[] {
|
|
195
|
+
try {
|
|
196
|
+
const parsed = JSON.parse(stdout) as unknown;
|
|
197
|
+
const container = Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null ? parsed as Record<string, unknown> : {};
|
|
198
|
+
const candidates = Array.isArray(container) ? container : [container.results, container.matches, container.items].find(Array.isArray) ?? [];
|
|
199
|
+
return (candidates as unknown[]).filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null).map((item) => normalizeLineResult(repoRoot, item, config)).filter((item): item is NormalizedContextResult => Boolean(item)).slice(0, maxResults);
|
|
200
|
+
} catch {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function runColgrep(repoRoot: string, query: string, roots: string[], config: ContextDiscoveryConfig, maxResults: number, maxContextLines: number): { ok: boolean; results: NormalizedContextResult[]; error?: string } {
|
|
206
|
+
const args = ["--json", "-k", String(maxResults), "-n", String(maxContextLines), query, ...roots];
|
|
207
|
+
const result = spawnSync("colgrep", args, { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000, maxBuffer: 1024 * 1024 });
|
|
208
|
+
if (result.status !== 0) return { ok: false, results: [], error: result.stderr.trim().slice(0, 240) || `colgrep exited ${String(result.status)}` };
|
|
209
|
+
const results = extractJsonResults(repoRoot, result.stdout, config, maxResults);
|
|
210
|
+
return { ok: true, results };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function fallbackSearch(repoRoot: string, params: Required<Pick<ContextSearchParams, "query" | "mode">> & Pick<ContextSearchParams, "pattern" | "paths">, config: ContextDiscoveryConfig, maxResults: number, maxContextLines: number): { results: NormalizedContextResult[]; rejectedPaths: string[]; roots: string[] } {
|
|
214
|
+
const { roots, rejected } = safeSearchRoots(repoRoot, config, params.paths);
|
|
215
|
+
const files: string[] = [];
|
|
216
|
+
for (const root of roots) collectFiles(repoRoot, root, config, files);
|
|
217
|
+
const needle = params.mode === "regex" ? params.pattern ?? params.query : params.query;
|
|
218
|
+
let regex: RegExp | undefined;
|
|
219
|
+
if (params.mode === "regex") {
|
|
220
|
+
try { regex = new RegExp(needle, "iu"); } catch { regex = undefined; }
|
|
221
|
+
}
|
|
222
|
+
const results: NormalizedContextResult[] = [];
|
|
223
|
+
for (const path of [...new Set(files)].sort()) {
|
|
224
|
+
if (results.length >= maxResults) break;
|
|
225
|
+
if (params.mode === "files") {
|
|
226
|
+
if (!path.toLowerCase().includes(params.query.toLowerCase())) continue;
|
|
227
|
+
results.push({ path, ref: path, preview: path });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const lines = readFileSync(join(repoRoot, path), "utf8").split(/\r?\n/u);
|
|
231
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
232
|
+
const matched = regex ? regex.test(lines[index]) : lines[index].toLowerCase().includes(params.query.toLowerCase());
|
|
233
|
+
if (!matched) continue;
|
|
234
|
+
const start = Math.max(0, index - maxContextLines);
|
|
235
|
+
const end = Math.min(lines.length, index + maxContextLines + 1);
|
|
236
|
+
results.push({
|
|
237
|
+
path,
|
|
238
|
+
line: index + 1,
|
|
239
|
+
ref: `${path}:${index + 1}`,
|
|
240
|
+
preview: lines[index].trim().slice(0, 240),
|
|
241
|
+
context: lines.slice(start, end).map((text, offset) => ({ line: start + offset + 1, text: text.slice(0, 240) })),
|
|
242
|
+
});
|
|
243
|
+
if (results.length >= maxResults) break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return { results, rejectedPaths: rejected, roots };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function runContextSearch(repoRoot: string, input: ContextSearchParams): Record<string, unknown> {
|
|
250
|
+
const config = loadContextDiscoveryConfig(repoRoot);
|
|
251
|
+
const query = String(input.query ?? "").trim();
|
|
252
|
+
const mode = input.mode ?? "auto";
|
|
253
|
+
const maxResults = clampInteger(input.max_results, config.limits.maxResults, 1, 50);
|
|
254
|
+
const maxContextLines = clampInteger(input.max_context_lines, config.limits.maxContextLines, 0, 5);
|
|
255
|
+
const detection = detectColgrep(repoRoot);
|
|
256
|
+
const { roots, rejected } = safeSearchRoots(repoRoot, config, input.paths);
|
|
257
|
+
let provider = detection.ready ? "colgrep" : "grep-fallback";
|
|
258
|
+
let fallback = !detection.ready;
|
|
259
|
+
let fallbackReason = detection.ready ? undefined : detection.guidance;
|
|
260
|
+
let results: NormalizedContextResult[] = [];
|
|
261
|
+
if (detection.ready && mode !== "regex" && mode !== "files") {
|
|
262
|
+
const colgrep = runColgrep(repoRoot, query, roots, config, maxResults, maxContextLines);
|
|
263
|
+
if (colgrep.ok) results = colgrep.results;
|
|
264
|
+
else {
|
|
265
|
+
provider = "grep-fallback";
|
|
266
|
+
fallback = true;
|
|
267
|
+
fallbackReason = colgrep.error ?? "ColGREP query failed; grep fallback used.";
|
|
268
|
+
}
|
|
269
|
+
} else if (detection.ready) {
|
|
270
|
+
provider = "grep-fallback";
|
|
271
|
+
fallback = true;
|
|
272
|
+
fallbackReason = `${mode} mode uses exact grep/find fallback for deterministic results.`;
|
|
273
|
+
}
|
|
274
|
+
if (fallback || results.length === 0) {
|
|
275
|
+
const fallbackResult = fallbackSearch(repoRoot, { query, mode, pattern: input.pattern, paths: input.paths }, config, maxResults, maxContextLines);
|
|
276
|
+
results = fallbackResult.results;
|
|
277
|
+
}
|
|
278
|
+
const refs = results.map((item) => item.ref);
|
|
279
|
+
return {
|
|
280
|
+
schema: "zob.context-search-result.v1",
|
|
281
|
+
provider,
|
|
282
|
+
preferredProvider: config.preferredProvider,
|
|
283
|
+
fallback,
|
|
284
|
+
fallbackReason,
|
|
285
|
+
colgrepInstalled: detection.installed,
|
|
286
|
+
colgrepReady: detection.ready,
|
|
287
|
+
mode,
|
|
288
|
+
resultCount: results.length,
|
|
289
|
+
refs,
|
|
290
|
+
results,
|
|
291
|
+
searchedRoots: roots,
|
|
292
|
+
rejectedPaths: rejected,
|
|
293
|
+
limits: { maxResults, maxContextLines },
|
|
294
|
+
recommendedVerification: refs.length > 0
|
|
295
|
+
? [`grep -n ${shellQuote(query)} ${shellQuote(results[0]?.path ?? roots[0] ?? ".")}`, `read ${results[0]?.path ?? roots[0] ?? "."}`]
|
|
296
|
+
: [`grep -R -n ${shellQuote(query)} ${roots.map(shellQuote).join(" ") || "."}`, "find relevant safe paths, then read exact files"],
|
|
297
|
+
safety: { repoRelativeOnly: true, forbiddenPathsExcluded: config.excludePaths, rawPromptOrConversationPersisted: false, autoInstall: false },
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function formatContextSearchResult(result: Record<string, unknown>): string {
|
|
302
|
+
const provider = String(result.provider ?? "unknown");
|
|
303
|
+
const fallback = result.fallback === true ? "yes" : "no";
|
|
304
|
+
const count = typeof result.resultCount === "number" ? result.resultCount : 0;
|
|
305
|
+
const lines = [`zob_context_search: provider=${provider} fallback=${fallback} results=${count}`];
|
|
306
|
+
const reason = typeof result.fallbackReason === "string" ? result.fallbackReason : undefined;
|
|
307
|
+
if (reason) lines.push(`fallback_status: ${reason}`);
|
|
308
|
+
const results = Array.isArray(result.results) ? result.results.slice(0, 10) : [];
|
|
309
|
+
for (const item of results) {
|
|
310
|
+
if (typeof item !== "object" || item === null) continue;
|
|
311
|
+
const record = item as Record<string, unknown>;
|
|
312
|
+
lines.push(`- ${String(record.ref ?? record.path ?? "result")}: ${String(record.preview ?? "").slice(0, 240)}`);
|
|
313
|
+
}
|
|
314
|
+
const verification = Array.isArray(result.recommendedVerification) ? result.recommendedVerification.slice(0, 2).map(String) : [];
|
|
315
|
+
if (verification.length > 0) lines.push(`verify: ${verification.join(" ; ")}`);
|
|
316
|
+
return lines.join("\n");
|
|
317
|
+
}
|