pudu-ai 0.2.21 → 0.2.22
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 +2 -0
- package/dist/cli/{chunk-QGEXKMQ5.js → chunk-6NXOK4OD.js} +49 -8
- package/dist/cli/chunk-LL5FQT7I.js +78 -0
- package/dist/cli/{chunk-6C5EPKRI.js → chunk-PX7BJDD3.js} +1 -1
- package/dist/cli/index.js +8 -6
- package/dist/cli/{render-3UBPZNF4.js → render-SKZLGUOW.js} +220 -22
- package/dist/cli/{repo-cli-4GSG5MD3.js → repo-cli-TPYCTV5J.js} +85 -70
- package/package.json +1 -1
- package/python/pudu_agent/__pycache__/__init__.cpython-312.pyc +0 -0
- package/python/pudu_agent/__pycache__/__main__.cpython-312.pyc +0 -0
- package/python/pudu_agent/__pycache__/ast_search.cpython-312.pyc +0 -0
- package/python/pudu_agent/__pycache__/graph.cpython-312.pyc +0 -0
- package/python/pudu_agent/__pycache__/protocol.cpython-312.pyc +0 -0
- package/python/pudu_agent/__pycache__/search.cpython-312.pyc +0 -0
- package/python/pudu_agent/graph.py +398 -0
- package/python/pudu_agent/search.py +61 -15
package/README.md
CHANGED
|
@@ -148,6 +148,8 @@ Deterministic repository search. Optional Python 3.10+, `rg`, and `ast-grep`. No
|
|
|
148
148
|
```bash
|
|
149
149
|
npx pudu-ai repo search validate_user --repo . --json
|
|
150
150
|
npx pudu-ai repo search --structural 'def $FUNC($$$ARGS): $$$BODY'
|
|
151
|
+
npx pudu-ai repo graph --repo .
|
|
152
|
+
npx pudu-ai repo harness --task "fix validate_user" --json
|
|
151
153
|
npx pudu-ai repo search validate_user --lang es
|
|
152
154
|
```
|
|
153
155
|
|
|
@@ -30,16 +30,33 @@ var en = {
|
|
|
30
30
|
aboutRepoSearch: "Deterministic repository search (Pudu Agent Lab). Literal strings use rg; syntactic patterns use ast-grep. No LLM. Missing tools are reported, never faked.",
|
|
31
31
|
repoSearchTitle: "Pudu Agent Lab \u2014 repo search",
|
|
32
32
|
repoNeedQuery: "Pass a query or --structural PATTERN, e.g. npx pudu-ai repo search validate_user --json",
|
|
33
|
-
repoUnknownSubcommand: "Unknown repo subcommand: {sub}.
|
|
33
|
+
repoUnknownSubcommand: "Unknown repo subcommand: {sub}. Supported: search, graph, harness.",
|
|
34
34
|
repoPythonMissing: "python3 not found. Agent Lab search needs Python 3.10+ on PATH.",
|
|
35
35
|
repoToolMissing: "{tool} not found on PATH",
|
|
36
36
|
repoNoMatches: "No matches.",
|
|
37
|
-
|
|
37
|
+
repoNeedTask: 'Pass a task, e.g. npx pudu-ai repo harness --task "fix validate_user" --repo .',
|
|
38
|
+
repoGraphTitle: "AGENT LAB \u2014 IMPLEMENTATION GRAPH",
|
|
39
|
+
aboutRepoGraph: "Python stdlib AST graph (imports, defs, calls, inherits). Optional Graphify file if present. Edges are EXTRACTED or RESOLVED. Never invented.",
|
|
40
|
+
repoGraphBuilding: "Building graph of this folder\u2026",
|
|
41
|
+
repoGraphEmpty: "No Python files with extractable structure.",
|
|
42
|
+
repoGraphFiles: "Files",
|
|
43
|
+
repoGraphNodes: "Nodes",
|
|
44
|
+
repoGraphEdges: "Edges",
|
|
45
|
+
repoTokens: "Token estimate",
|
|
46
|
+
repoEffortTitle: "TASK EFFORT (DERIVED)",
|
|
47
|
+
repoHarnessPrompt: "HARNESS TASK",
|
|
48
|
+
repoHarnessPromptHint: "Type the coding task, then Enter",
|
|
49
|
+
repoHarnessEnter: "Enter = pick local model from effort Esc = back",
|
|
50
|
+
repoHarnessNoPrompt: "(empty prompt \u2014 effort is still from the graph)",
|
|
51
|
+
repoHarnessModel: "BEST LOCAL MODEL FOR THIS TASK",
|
|
52
|
+
repoHelp: `Pudu Agent Lab \u2014 repository search, graph, harness
|
|
38
53
|
|
|
39
54
|
Usage:
|
|
40
55
|
npx pudu-ai repo search QUERY
|
|
41
56
|
npx pudu-ai repo search QUERY --repo PATH --json
|
|
42
57
|
npx pudu-ai repo search --structural 'def $FUNC($$$ARGS): $$$BODY'
|
|
58
|
+
npx pudu-ai repo graph --repo PATH
|
|
59
|
+
npx pudu-ai repo harness --task "fix auth" --repo PATH --json
|
|
43
60
|
|
|
44
61
|
Flags:
|
|
45
62
|
--repo PATH Repository root (default: cwd)
|
|
@@ -47,7 +64,8 @@ Flags:
|
|
|
47
64
|
--intent INTENT TEXT | STRUCTURAL | RELATIONSHIP | IMPACT | SEMANTIC | UNKNOWN
|
|
48
65
|
--glob GLOB Repeatable include glob
|
|
49
66
|
--limit N Max matches (default 100)
|
|
50
|
-
--
|
|
67
|
+
--task TEXT Harness task prompt
|
|
68
|
+
--json Machine-readable JSON
|
|
51
69
|
`,
|
|
52
70
|
nav: "[S] Setup/Install [B] Benchmark [M] Models [R] Recommend [T] Tasks [H] Hardware [C] Compare [L] History [Q] Quit",
|
|
53
71
|
setupTitle: "SETUP \u2014 INSTALL MODELS & AGENTS",
|
|
@@ -197,6 +215,8 @@ Usage:
|
|
|
197
215
|
npx pudu-ai launch opencode --yes
|
|
198
216
|
npx pudu-ai repo search QUERY
|
|
199
217
|
npx pudu-ai repo search --structural 'def $FUNC($$$ARGS): $$$BODY'
|
|
218
|
+
npx pudu-ai repo graph
|
|
219
|
+
npx pudu-ai repo harness --task "fix auth"
|
|
200
220
|
|
|
201
221
|
Flags:
|
|
202
222
|
--json Machine-readable JSON (no TUI)
|
|
@@ -215,6 +235,7 @@ Flags:
|
|
|
215
235
|
--intent TEXT | STRUCTURAL | RELATIONSHIP | IMPACT | SEMANTIC | UNKNOWN
|
|
216
236
|
--glob Repeatable include glob for repo search
|
|
217
237
|
--limit Max repo search matches
|
|
238
|
+
--task Harness task prompt
|
|
218
239
|
|
|
219
240
|
Credits:
|
|
220
241
|
Measured values come from llama-bench and OS telemetry.
|
|
@@ -256,24 +277,42 @@ var es = {
|
|
|
256
277
|
aboutRepoSearch: "B\xFAsqueda determinista de repositorio (Pudu Agent Lab). Cadenas literales: rg; patrones sint\xE1cticos: ast-grep. Sin LLM. Si falta una herramienta se informa, no se inventa.",
|
|
257
278
|
repoSearchTitle: "Pudu Agent Lab \u2014 b\xFAsqueda de repo",
|
|
258
279
|
repoNeedQuery: "Pasa una consulta o --structural PATR\xD3N, p. ej. npx pudu-ai repo search validate_user --json",
|
|
259
|
-
repoUnknownSubcommand: "Subcomando de repo desconocido: {sub}.
|
|
280
|
+
repoUnknownSubcommand: "Subcomando de repo desconocido: {sub}. Soportados: search, graph, harness.",
|
|
260
281
|
repoPythonMissing: "No se encontr\xF3 python3. La b\xFAsqueda de Agent Lab necesita Python 3.10+ en PATH.",
|
|
261
282
|
repoToolMissing: "{tool} no est\xE1 en PATH",
|
|
262
283
|
repoNoMatches: "Sin coincidencias.",
|
|
263
|
-
|
|
284
|
+
repoNeedTask: 'Pasa una tarea, p. ej. npx pudu-ai repo harness --task "arreglar validate_user" --repo .',
|
|
285
|
+
repoGraphTitle: "AGENT LAB \u2014 GRAFO DE IMPLEMENTACI\xD3N",
|
|
286
|
+
aboutRepoGraph: "Grafo AST de Python (imports, defs, llamadas, herencias). Graphify opcional si existe el archivo. Aristas EXTRACTED o RESOLVED. Nunca inventadas.",
|
|
287
|
+
repoGraphBuilding: "Construyendo el grafo de esta carpeta\u2026",
|
|
288
|
+
repoGraphEmpty: "No hay Python con estructura extra\xEDble.",
|
|
289
|
+
repoGraphFiles: "Archivos",
|
|
290
|
+
repoGraphNodes: "Nodos",
|
|
291
|
+
repoGraphEdges: "Aristas",
|
|
292
|
+
repoTokens: "Estimaci\xF3n de tokens",
|
|
293
|
+
repoEffortTitle: "ESFUERZO DE TAREA (DERIVED)",
|
|
294
|
+
repoHarnessPrompt: "TAREA HARNESS",
|
|
295
|
+
repoHarnessPromptHint: "Escribe la tarea de c\xF3digo y pulsa Enter",
|
|
296
|
+
repoHarnessEnter: "Enter = elegir modelo local seg\xFAn esfuerzo Esc = atr\xE1s",
|
|
297
|
+
repoHarnessNoPrompt: "(prompt vac\xEDo \u2014 el esfuerzo sigue saliendo del grafo)",
|
|
298
|
+
repoHarnessModel: "MEJOR MODELO LOCAL PARA ESTA TAREA",
|
|
299
|
+
repoHelp: `Pudu Agent Lab \u2014 b\xFAsqueda, grafo y harness
|
|
264
300
|
|
|
265
301
|
Uso:
|
|
266
302
|
npx pudu-ai repo search CONSULTA
|
|
267
303
|
npx pudu-ai repo search CONSULTA --repo RUTA --json
|
|
268
304
|
npx pudu-ai repo search --structural 'def $FUNC($$$ARGS): $$$BODY'
|
|
305
|
+
npx pudu-ai repo graph --repo RUTA
|
|
306
|
+
npx pudu-ai repo harness --task "arreglar auth" --repo RUTA --json
|
|
269
307
|
|
|
270
308
|
Flags:
|
|
271
|
-
--repo PATH Ra\xEDz del repositorio (
|
|
272
|
-
--structural PAT Patr\xF3n
|
|
309
|
+
--repo PATH Ra\xEDz del repositorio (cwd)
|
|
310
|
+
--structural PAT Patr\xF3n ast-grep
|
|
273
311
|
--intent INTENT TEXT | STRUCTURAL | RELATIONSHIP | IMPACT | SEMANTIC | UNKNOWN
|
|
274
312
|
--glob GLOB Glob de inclusi\xF3n (repetible)
|
|
275
313
|
--limit N M\xE1ximo de coincidencias (100)
|
|
276
|
-
--
|
|
314
|
+
--task TEXT Prompt de la tarea harness
|
|
315
|
+
--json JSON
|
|
277
316
|
`,
|
|
278
317
|
nav: "[S] Setup/Instalar [B] Benchmark [M] Modelos [R] Recomendaciones [T] Tareas [H] Hardware [C] Comparar [L] Historial [Q] Salir",
|
|
279
318
|
setupTitle: "SETUP \u2014 INSTALAR MODELOS Y AGENTES",
|
|
@@ -423,6 +462,8 @@ Uso:
|
|
|
423
462
|
npx pudu-ai launch opencode --yes
|
|
424
463
|
npx pudu-ai repo search CONSULTA
|
|
425
464
|
npx pudu-ai repo search --structural 'def $FUNC($$$ARGS): $$$BODY'
|
|
465
|
+
npx pudu-ai repo graph
|
|
466
|
+
npx pudu-ai repo harness --task "arreglar auth"
|
|
426
467
|
|
|
427
468
|
Flags:
|
|
428
469
|
--json JSON legible por m\xE1quinas (sin TUI)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
commandExists,
|
|
3
|
+
runCommand
|
|
4
|
+
} from "./chunk-SS7OXXQQ.js";
|
|
5
|
+
|
|
6
|
+
// src/agent-lab/python-bridge.ts
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
function hasEngine(dir) {
|
|
11
|
+
return existsSync(path.join(dir, "pudu_agent", "__main__.py"));
|
|
12
|
+
}
|
|
13
|
+
function pythonRoot() {
|
|
14
|
+
const starts = [path.dirname(fileURLToPath(import.meta.url)), process.cwd()];
|
|
15
|
+
for (const start of starts) {
|
|
16
|
+
let dir = start;
|
|
17
|
+
for (let i = 0; i < 8; i++) {
|
|
18
|
+
const candidate = path.join(dir, "python");
|
|
19
|
+
if (hasEngine(candidate)) return candidate;
|
|
20
|
+
const parent = path.dirname(dir);
|
|
21
|
+
if (parent === dir) break;
|
|
22
|
+
dir = parent;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "python");
|
|
26
|
+
}
|
|
27
|
+
async function findPython() {
|
|
28
|
+
return await commandExists("python3") ?? await commandExists("python");
|
|
29
|
+
}
|
|
30
|
+
async function invokePuduAgent(request, timeout = 3e4) {
|
|
31
|
+
const python = await findPython();
|
|
32
|
+
if (!python) {
|
|
33
|
+
throw new Error("python3 not found on PATH");
|
|
34
|
+
}
|
|
35
|
+
const root = pythonRoot();
|
|
36
|
+
const payload = `${JSON.stringify(request)}
|
|
37
|
+
`;
|
|
38
|
+
const result = await runCommand(python, ["-m", "pudu_agent"], {
|
|
39
|
+
cwd: root,
|
|
40
|
+
env: { ...process.env, PYTHONPATH: root, PYTHONUNBUFFERED: "1" },
|
|
41
|
+
input: payload,
|
|
42
|
+
timeout
|
|
43
|
+
});
|
|
44
|
+
return { ...result, python };
|
|
45
|
+
}
|
|
46
|
+
function parseJsonStdout(stdout) {
|
|
47
|
+
const text = stdout.trim();
|
|
48
|
+
if (!text) {
|
|
49
|
+
throw new Error("Python engine returned empty stdout");
|
|
50
|
+
}
|
|
51
|
+
const start = text.indexOf("{");
|
|
52
|
+
const slice = start >= 0 ? text.slice(start) : text;
|
|
53
|
+
return JSON.parse(slice);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/agent-lab/graph.ts
|
|
57
|
+
async function buildRepoGraph(repo, timeoutMs = 6e4) {
|
|
58
|
+
const invoked = await invokePuduAgent({ op: "graph", repo }, timeoutMs);
|
|
59
|
+
if (invoked.timedOut) throw new Error("Python graph engine timed out");
|
|
60
|
+
const parsed = parseJsonStdout(invoked.stdout);
|
|
61
|
+
if (!parsed || parsed.ok === false) {
|
|
62
|
+
throw new Error(parsed.error ?? "graph failed");
|
|
63
|
+
}
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
async function runRepoHarness(repo, task, timeoutMs = 6e4) {
|
|
67
|
+
const invoked = await invokePuduAgent({ op: "harness", repo, task }, timeoutMs);
|
|
68
|
+
if (invoked.timedOut) throw new Error("Python harness engine timed out");
|
|
69
|
+
return parseJsonStdout(invoked.stdout);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export {
|
|
73
|
+
findPython,
|
|
74
|
+
invokePuduAgent,
|
|
75
|
+
parseJsonStdout,
|
|
76
|
+
buildRepoGraph,
|
|
77
|
+
runRepoHarness
|
|
78
|
+
};
|
package/dist/cli/index.js
CHANGED
|
@@ -32,12 +32,12 @@ import {
|
|
|
32
32
|
runBenchmark,
|
|
33
33
|
runtimesText,
|
|
34
34
|
tasksText
|
|
35
|
-
} from "./chunk-
|
|
35
|
+
} from "./chunk-PX7BJDD3.js";
|
|
36
36
|
import {
|
|
37
37
|
resolveLocale,
|
|
38
38
|
setLocale,
|
|
39
39
|
t
|
|
40
|
-
} from "./chunk-
|
|
40
|
+
} from "./chunk-6NXOK4OD.js";
|
|
41
41
|
import {
|
|
42
42
|
commandExists,
|
|
43
43
|
killAll,
|
|
@@ -57,7 +57,8 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
|
57
57
|
"--repo",
|
|
58
58
|
"--intent",
|
|
59
59
|
"--limit",
|
|
60
|
-
"--glob"
|
|
60
|
+
"--glob",
|
|
61
|
+
"--task"
|
|
61
62
|
]);
|
|
62
63
|
function flagValue(args2, name) {
|
|
63
64
|
const index = args2.findIndex((a) => a === name);
|
|
@@ -131,7 +132,8 @@ function parseArgs(argv) {
|
|
|
131
132
|
repo: flagValue(args2, "--repo"),
|
|
132
133
|
intent: flagValue(args2, "--intent"),
|
|
133
134
|
limit,
|
|
134
|
-
globs: flagValues(args2, "--glob")
|
|
135
|
+
globs: flagValues(args2, "--glob"),
|
|
136
|
+
task: flagValue(args2, "--task")
|
|
135
137
|
};
|
|
136
138
|
}
|
|
137
139
|
|
|
@@ -726,7 +728,7 @@ ${modelsText(session)}`)}
|
|
|
726
728
|
);
|
|
727
729
|
return;
|
|
728
730
|
}
|
|
729
|
-
const { renderDashboard } = await import("./render-
|
|
731
|
+
const { renderDashboard } = await import("./render-SKZLGUOW.js");
|
|
730
732
|
await renderDashboard(session, preset, start);
|
|
731
733
|
}
|
|
732
734
|
async function run(args2) {
|
|
@@ -739,7 +741,7 @@ async function run(args2) {
|
|
|
739
741
|
if (args2.verbose) setLogLevel("verbose");
|
|
740
742
|
if (!args2.color) process.env.NO_COLOR = "1";
|
|
741
743
|
if (args2.command === "repo") {
|
|
742
|
-
const { runRepoCommand } = await import("./repo-cli-
|
|
744
|
+
const { runRepoCommand } = await import("./repo-cli-TPYCTV5J.js");
|
|
743
745
|
return runRepoCommand(args2);
|
|
744
746
|
}
|
|
745
747
|
const cleanup = () => {
|
|
@@ -16,21 +16,24 @@ import {
|
|
|
16
16
|
resultText,
|
|
17
17
|
runBenchmark,
|
|
18
18
|
tasksText
|
|
19
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-PX7BJDD3.js";
|
|
20
|
+
import {
|
|
21
|
+
buildRepoGraph
|
|
22
|
+
} from "./chunk-LL5FQT7I.js";
|
|
20
23
|
import {
|
|
21
24
|
formatNumber,
|
|
22
25
|
formatPercent,
|
|
23
26
|
formatTokensPerSec,
|
|
24
27
|
t
|
|
25
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-6NXOK4OD.js";
|
|
26
29
|
import "./chunk-SS7OXXQQ.js";
|
|
27
30
|
|
|
28
31
|
// src/tui/render.tsx
|
|
29
32
|
import { render } from "ink";
|
|
30
33
|
|
|
31
34
|
// src/tui/App.tsx
|
|
32
|
-
import { useApp as useApp2, useInput as
|
|
33
|
-
import { useMemo as
|
|
35
|
+
import { useApp as useApp2, useInput as useInput5 } from "ink";
|
|
36
|
+
import { useMemo as useMemo3, useRef as useRef2, useState as useState5 } from "react";
|
|
34
37
|
|
|
35
38
|
// src/tui/views/Dashboard.tsx
|
|
36
39
|
import { Box as Box2, Text as Text2 } from "ink";
|
|
@@ -84,6 +87,7 @@ var NAV = [
|
|
|
84
87
|
{ key: "M", label: "Models", color: "cyan" },
|
|
85
88
|
{ key: "R", label: "Rec", color: "magenta" },
|
|
86
89
|
{ key: "T", label: "Tasks", color: "blue" },
|
|
90
|
+
{ key: "G", label: "Graph", color: "magenta" },
|
|
87
91
|
{ key: "H", label: "HW", color: "white" },
|
|
88
92
|
{ key: "C", label: "Cmp", color: "green" },
|
|
89
93
|
{ key: "L", label: "Log", color: "cyan" },
|
|
@@ -729,6 +733,194 @@ function TasksView({ session }) {
|
|
|
729
733
|
] });
|
|
730
734
|
}
|
|
731
735
|
|
|
736
|
+
// src/tui/views/Graph.tsx
|
|
737
|
+
import { Box as Box10, Text as Text11, useInput as useInput4 } from "ink";
|
|
738
|
+
import { useEffect as useEffect2, useMemo as useMemo2, useState as useState4 } from "react";
|
|
739
|
+
|
|
740
|
+
// src/agent-lab/harness.ts
|
|
741
|
+
var GRADE_RANK = { S: 0, A: 1, B: 2, C: 3, D: 4, F: 5 };
|
|
742
|
+
function isCodeish(useCase) {
|
|
743
|
+
const cases = useCase ?? ["chat", "code"];
|
|
744
|
+
return cases.some((item) => item.includes("code") || item.includes("chat"));
|
|
745
|
+
}
|
|
746
|
+
function pickHarnessModel(session, effort) {
|
|
747
|
+
const rows = session.rows.filter((row) => isCodeish(row.catalog?.useCase));
|
|
748
|
+
const pool = rows.length ? rows : session.rows;
|
|
749
|
+
if (!pool.length) {
|
|
750
|
+
return {
|
|
751
|
+
modelId: null,
|
|
752
|
+
modelName: null,
|
|
753
|
+
grade: null,
|
|
754
|
+
measuredTps: null,
|
|
755
|
+
reason: "No installed models. N/A",
|
|
756
|
+
origin: null
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
const scored = pool.map((row) => ({
|
|
760
|
+
row,
|
|
761
|
+
grade: row.compatibility?.grade ?? "C",
|
|
762
|
+
tps: row.lastBenchmark?.benchmark.generationTokensPerSecond ?? null,
|
|
763
|
+
params: row.catalog?.paramsBillions ?? null
|
|
764
|
+
}));
|
|
765
|
+
scored.sort((a, b) => {
|
|
766
|
+
if (effort.label === "LOW") {
|
|
767
|
+
if (a.tps != null && b.tps != null && a.tps !== b.tps) return b.tps - a.tps;
|
|
768
|
+
if (a.tps != null && b.tps == null) return -1;
|
|
769
|
+
if (a.tps == null && b.tps != null) return 1;
|
|
770
|
+
return (GRADE_RANK[a.grade] ?? 9) - (GRADE_RANK[b.grade] ?? 9);
|
|
771
|
+
}
|
|
772
|
+
if (effort.label === "HIGH" || effort.label === "VERY_HIGH") {
|
|
773
|
+
if (a.params != null && b.params != null && a.params !== b.params) return b.params - a.params;
|
|
774
|
+
if (a.params != null && b.params == null) return -1;
|
|
775
|
+
if (a.params == null && b.params != null) return 1;
|
|
776
|
+
return (GRADE_RANK[a.grade] ?? 9) - (GRADE_RANK[b.grade] ?? 9);
|
|
777
|
+
}
|
|
778
|
+
const grade = (GRADE_RANK[a.grade] ?? 9) - (GRADE_RANK[b.grade] ?? 9);
|
|
779
|
+
if (grade !== 0) return grade;
|
|
780
|
+
if (a.tps != null && b.tps != null) return b.tps - a.tps;
|
|
781
|
+
return 0;
|
|
782
|
+
});
|
|
783
|
+
const best = scored[0];
|
|
784
|
+
const reasons = {
|
|
785
|
+
LOW: "LOW effort: prefer fastest measured local coding model; else best grade.",
|
|
786
|
+
MEDIUM: "MEDIUM effort: prefer best hardware grade, then measured t/s.",
|
|
787
|
+
HIGH: "HIGH effort: prefer larger catalog params when known; else best grade.",
|
|
788
|
+
VERY_HIGH: "VERY_HIGH effort: prefer larger catalog params when known; else best grade."
|
|
789
|
+
};
|
|
790
|
+
return {
|
|
791
|
+
modelId: best.row.local.id,
|
|
792
|
+
modelName: best.row.local.name,
|
|
793
|
+
grade: best.grade,
|
|
794
|
+
measuredTps: best.tps,
|
|
795
|
+
reason: reasons[effort.label],
|
|
796
|
+
origin: "DERIVED"
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// src/tui/views/Graph.tsx
|
|
801
|
+
import { Fragment as Fragment6, jsx as jsx11, jsxs as jsxs11 } from "react/jsx-runtime";
|
|
802
|
+
function GraphView({ session }) {
|
|
803
|
+
const [phase, setPhase] = useState4("build");
|
|
804
|
+
const [graph, setGraph] = useState4();
|
|
805
|
+
const [error, setError] = useState4("");
|
|
806
|
+
const [prompt, setPrompt] = useState4("");
|
|
807
|
+
const repo = process.cwd();
|
|
808
|
+
useEffect2(() => {
|
|
809
|
+
let live = true;
|
|
810
|
+
void (async () => {
|
|
811
|
+
try {
|
|
812
|
+
const result = await buildRepoGraph(repo);
|
|
813
|
+
if (!live) return;
|
|
814
|
+
setGraph(result);
|
|
815
|
+
setPhase("prompt");
|
|
816
|
+
} catch (err) {
|
|
817
|
+
if (!live) return;
|
|
818
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
819
|
+
setPhase("error");
|
|
820
|
+
}
|
|
821
|
+
})();
|
|
822
|
+
return () => {
|
|
823
|
+
live = false;
|
|
824
|
+
};
|
|
825
|
+
}, [repo]);
|
|
826
|
+
useInput4((input, key) => {
|
|
827
|
+
if (phase !== "prompt") return;
|
|
828
|
+
if (key.return) {
|
|
829
|
+
setPhase("done");
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (key.backspace || key.delete) {
|
|
833
|
+
setPrompt((value) => value.slice(0, -1));
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (input && !key.ctrl && !key.meta && input !== " ") {
|
|
837
|
+
setPrompt((value) => `${value}${input}`.slice(0, 240));
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
const pick = useMemo2(() => graph ? pickHarnessModel(session, graph.effort) : void 0, [graph, session]);
|
|
841
|
+
if (phase === "build") {
|
|
842
|
+
return /* @__PURE__ */ jsxs11(Panel, { title: t("repoGraphTitle"), color: "magenta", children: [
|
|
843
|
+
/* @__PURE__ */ jsx11(Text11, { children: t("repoGraphBuilding") }),
|
|
844
|
+
/* @__PURE__ */ jsx11(Text11, { dimColor: true, children: repo })
|
|
845
|
+
] });
|
|
846
|
+
}
|
|
847
|
+
if (phase === "error") {
|
|
848
|
+
return /* @__PURE__ */ jsx11(Panel, { title: t("repoGraphTitle"), color: "magenta", children: /* @__PURE__ */ jsx11(Text11, { color: "red", children: error }) });
|
|
849
|
+
}
|
|
850
|
+
if (!graph) return /* @__PURE__ */ jsx11(Text11, { children: " " });
|
|
851
|
+
const degree = /* @__PURE__ */ new Map();
|
|
852
|
+
for (const edge of graph.edges) {
|
|
853
|
+
degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1);
|
|
854
|
+
}
|
|
855
|
+
const top = [...graph.nodes].filter((node) => node.type === "file" || node.type === "function" || node.type === "test").sort((a, b) => (degree.get(b.id) ?? 0) - (degree.get(a.id) ?? 0)).slice(0, 8);
|
|
856
|
+
return /* @__PURE__ */ jsxs11(Fragment6, { children: [
|
|
857
|
+
/* @__PURE__ */ jsxs11(Panel, { title: t("repoGraphTitle"), color: "magenta", children: [
|
|
858
|
+
/* @__PURE__ */ jsx11(Text11, { dimColor: true, children: fit(graph.repo, 80) }),
|
|
859
|
+
/* @__PURE__ */ jsxs11(Text11, { children: [
|
|
860
|
+
t("repoGraphFiles"),
|
|
861
|
+
" ",
|
|
862
|
+
graph.metrics.fileCount,
|
|
863
|
+
" ",
|
|
864
|
+
t("repoGraphNodes"),
|
|
865
|
+
" ",
|
|
866
|
+
graph.metrics.nodeCount,
|
|
867
|
+
" ",
|
|
868
|
+
t("repoGraphEdges"),
|
|
869
|
+
" ",
|
|
870
|
+
graph.metrics.edgeCount,
|
|
871
|
+
" (",
|
|
872
|
+
graph.metrics.origin,
|
|
873
|
+
")"
|
|
874
|
+
] }),
|
|
875
|
+
top.map((node) => /* @__PURE__ */ jsxs11(Box10, { children: [
|
|
876
|
+
/* @__PURE__ */ jsx11(Text11, { color: "cyan", children: fit(node.file ?? node.name, 36) }),
|
|
877
|
+
/* @__PURE__ */ jsxs11(Text11, { children: [
|
|
878
|
+
" ",
|
|
879
|
+
fit(node.name, 18),
|
|
880
|
+
" "
|
|
881
|
+
] }),
|
|
882
|
+
/* @__PURE__ */ jsx11(Text11, { dimColor: true, children: degree.get(node.id) ?? 0 })
|
|
883
|
+
] }, node.id)),
|
|
884
|
+
!top.length ? /* @__PURE__ */ jsx11(Text11, { dimColor: true, children: t("repoGraphEmpty") }) : null
|
|
885
|
+
] }),
|
|
886
|
+
/* @__PURE__ */ jsxs11(Panel, { title: t("repoEffortTitle"), color: "yellow", children: [
|
|
887
|
+
/* @__PURE__ */ jsxs11(Text11, { children: [
|
|
888
|
+
graph.effort.label,
|
|
889
|
+
" ",
|
|
890
|
+
graph.effort.score0to100,
|
|
891
|
+
"/100 (",
|
|
892
|
+
graph.effort.origin,
|
|
893
|
+
")"
|
|
894
|
+
] }),
|
|
895
|
+
/* @__PURE__ */ jsxs11(Text11, { dimColor: true, children: [
|
|
896
|
+
t("repoTokens"),
|
|
897
|
+
" ",
|
|
898
|
+
graph.metrics.tokenEstimate ?? "N/A",
|
|
899
|
+
" (",
|
|
900
|
+
graph.metrics.tokenOrigin ?? "N/A",
|
|
901
|
+
")"
|
|
902
|
+
] })
|
|
903
|
+
] }),
|
|
904
|
+
phase === "prompt" ? /* @__PURE__ */ jsxs11(Panel, { title: t("repoHarnessPrompt"), color: "blue", children: [
|
|
905
|
+
/* @__PURE__ */ jsxs11(Text11, { children: [
|
|
906
|
+
prompt || t("repoHarnessPromptHint"),
|
|
907
|
+
/* @__PURE__ */ jsx11(Text11, { color: "cyan", children: "\u258D" })
|
|
908
|
+
] }),
|
|
909
|
+
/* @__PURE__ */ jsx11(Text11, { dimColor: true, children: t("repoHarnessEnter") })
|
|
910
|
+
] }) : /* @__PURE__ */ jsxs11(Panel, { title: t("repoHarnessModel"), color: "green", children: [
|
|
911
|
+
/* @__PURE__ */ jsx11(Text11, { dimColor: true, children: prompt || t("repoHarnessNoPrompt") }),
|
|
912
|
+
pick?.modelName ? /* @__PURE__ */ jsxs11(Text11, { children: [
|
|
913
|
+
pick.modelName,
|
|
914
|
+
" ",
|
|
915
|
+
pick.grade ?? "\u2014",
|
|
916
|
+
" ",
|
|
917
|
+
pick.measuredTps != null ? `${pick.measuredTps} t/s` : t("notTested")
|
|
918
|
+
] }) : /* @__PURE__ */ jsx11(Text11, { color: "yellow", children: pick?.reason ?? "N/A" }),
|
|
919
|
+
/* @__PURE__ */ jsx11(Text11, { dimColor: true, children: pick?.reason })
|
|
920
|
+
] })
|
|
921
|
+
] });
|
|
922
|
+
}
|
|
923
|
+
|
|
732
924
|
// src/tui/keys.ts
|
|
733
925
|
function globalNav(letter) {
|
|
734
926
|
if (letter === "b") return { type: "screen", screen: "benchmark" };
|
|
@@ -739,11 +931,16 @@ function globalNav(letter) {
|
|
|
739
931
|
if (letter === "l") return { type: "screen", screen: "history" };
|
|
740
932
|
if (letter === "t") return { type: "screen", screen: "tasks" };
|
|
741
933
|
if (letter === "s") return { type: "screen", screen: "setup" };
|
|
934
|
+
if (letter === "g") return { type: "screen", screen: "graph" };
|
|
742
935
|
return void 0;
|
|
743
936
|
}
|
|
744
937
|
function handleAppKey(screen, input, key) {
|
|
745
938
|
const letter = input.toLowerCase();
|
|
746
939
|
if (screen === "benchmark") return { type: "ignore" };
|
|
940
|
+
if (screen === "graph") {
|
|
941
|
+
if (key.escape) return { type: "home" };
|
|
942
|
+
return { type: "ignore" };
|
|
943
|
+
}
|
|
747
944
|
if (letter === "q") return { type: "quit" };
|
|
748
945
|
if (key.escape) return screen === "home" ? { type: "quit" } : { type: "home" };
|
|
749
946
|
if (screen === "setup") {
|
|
@@ -757,34 +954,35 @@ function handleAppKey(screen, input, key) {
|
|
|
757
954
|
}
|
|
758
955
|
|
|
759
956
|
// src/tui/App.tsx
|
|
760
|
-
import { jsx as
|
|
957
|
+
import { jsx as jsx12, jsxs as jsxs12 } from "react/jsx-runtime";
|
|
761
958
|
function App(props) {
|
|
762
959
|
const { exit } = useApp2();
|
|
763
|
-
const [screen, setScreen] =
|
|
960
|
+
const [screen, setScreen] = useState5(props.start ?? "home");
|
|
764
961
|
const screenRef = useRef2(screen);
|
|
765
962
|
screenRef.current = screen;
|
|
766
|
-
const [selected, setSelected] =
|
|
767
|
-
const benchable =
|
|
963
|
+
const [selected, setSelected] = useState5(0);
|
|
964
|
+
const benchable = useMemo3(
|
|
768
965
|
() => props.session.models.filter((m) => Boolean(m.artifactPath)),
|
|
769
966
|
[props.session.models]
|
|
770
967
|
);
|
|
771
|
-
|
|
968
|
+
useInput5((input, key) => {
|
|
772
969
|
const result = handleAppKey(screenRef.current, input, key);
|
|
773
970
|
if (result.type === "quit") exit();
|
|
774
971
|
if (result.type === "home") setScreen("home");
|
|
775
972
|
if (result.type === "screen") setScreen(result.screen);
|
|
776
973
|
});
|
|
777
|
-
return /* @__PURE__ */
|
|
778
|
-
screen !== "benchmark" && /* @__PURE__ */
|
|
779
|
-
screen === "home" && /* @__PURE__ */
|
|
780
|
-
screen === "models" && /* @__PURE__ */
|
|
781
|
-
screen === "hardware" && /* @__PURE__ */
|
|
782
|
-
screen === "recommend" && /* @__PURE__ */
|
|
783
|
-
screen === "setup" && /* @__PURE__ */
|
|
784
|
-
screen === "compare" && /* @__PURE__ */
|
|
785
|
-
screen === "history" && /* @__PURE__ */
|
|
786
|
-
screen === "tasks" && /* @__PURE__ */
|
|
787
|
-
screen === "
|
|
974
|
+
return /* @__PURE__ */ jsxs12(Frame, { children: [
|
|
975
|
+
screen !== "benchmark" && /* @__PURE__ */ jsx12(Header, {}),
|
|
976
|
+
screen === "home" && /* @__PURE__ */ jsx12(Dashboard, { session: props.session }),
|
|
977
|
+
screen === "models" && /* @__PURE__ */ jsx12(ModelsView, { session: props.session }),
|
|
978
|
+
screen === "hardware" && /* @__PURE__ */ jsx12(HardwareView, { session: props.session }),
|
|
979
|
+
screen === "recommend" && /* @__PURE__ */ jsx12(RecommendView, { session: props.session }),
|
|
980
|
+
screen === "setup" && /* @__PURE__ */ jsx12(SetupView, { session: props.session }),
|
|
981
|
+
screen === "compare" && /* @__PURE__ */ jsx12(CompareView, { session: props.session }),
|
|
982
|
+
screen === "history" && /* @__PURE__ */ jsx12(HistoryView, { session: props.session }),
|
|
983
|
+
screen === "tasks" && /* @__PURE__ */ jsx12(TasksView, { session: props.session }),
|
|
984
|
+
screen === "graph" && /* @__PURE__ */ jsx12(GraphView, { session: props.session }),
|
|
985
|
+
screen === "benchmark" && /* @__PURE__ */ jsx12(
|
|
788
986
|
BenchmarkView,
|
|
789
987
|
{
|
|
790
988
|
session: props.session,
|
|
@@ -799,9 +997,9 @@ function App(props) {
|
|
|
799
997
|
}
|
|
800
998
|
|
|
801
999
|
// src/tui/render.tsx
|
|
802
|
-
import { jsx as
|
|
1000
|
+
import { jsx as jsx13 } from "react/jsx-runtime";
|
|
803
1001
|
async function renderDashboard(session, preset, start) {
|
|
804
|
-
const instance = render(/* @__PURE__ */
|
|
1002
|
+
const instance = render(/* @__PURE__ */ jsx13(App, { session, preset, start }), {
|
|
805
1003
|
exitOnCtrlC: true,
|
|
806
1004
|
patchConsole: true
|
|
807
1005
|
});
|
|
@@ -1,64 +1,18 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildRepoGraph,
|
|
3
|
+
findPython,
|
|
4
|
+
invokePuduAgent,
|
|
5
|
+
parseJsonStdout,
|
|
6
|
+
runRepoHarness
|
|
7
|
+
} from "./chunk-LL5FQT7I.js";
|
|
1
8
|
import {
|
|
2
9
|
na,
|
|
3
10
|
t
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import
|
|
6
|
-
commandExists,
|
|
7
|
-
runCommand
|
|
8
|
-
} from "./chunk-SS7OXXQQ.js";
|
|
11
|
+
} from "./chunk-6NXOK4OD.js";
|
|
12
|
+
import "./chunk-SS7OXXQQ.js";
|
|
9
13
|
|
|
10
14
|
// src/agent-lab/repo-cli.ts
|
|
11
|
-
import path2 from "node:path";
|
|
12
|
-
|
|
13
|
-
// src/agent-lab/python-bridge.ts
|
|
14
|
-
import { existsSync } from "node:fs";
|
|
15
15
|
import path from "node:path";
|
|
16
|
-
import { fileURLToPath } from "node:url";
|
|
17
|
-
function hasEngine(dir) {
|
|
18
|
-
return existsSync(path.join(dir, "pudu_agent", "__main__.py"));
|
|
19
|
-
}
|
|
20
|
-
function pythonRoot() {
|
|
21
|
-
const starts = [path.dirname(fileURLToPath(import.meta.url)), process.cwd()];
|
|
22
|
-
for (const start of starts) {
|
|
23
|
-
let dir = start;
|
|
24
|
-
for (let i = 0; i < 8; i++) {
|
|
25
|
-
const candidate = path.join(dir, "python");
|
|
26
|
-
if (hasEngine(candidate)) return candidate;
|
|
27
|
-
const parent = path.dirname(dir);
|
|
28
|
-
if (parent === dir) break;
|
|
29
|
-
dir = parent;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "python");
|
|
33
|
-
}
|
|
34
|
-
async function findPython() {
|
|
35
|
-
return await commandExists("python3") ?? await commandExists("python");
|
|
36
|
-
}
|
|
37
|
-
async function invokePuduAgent(request, timeout = 3e4) {
|
|
38
|
-
const python = await findPython();
|
|
39
|
-
if (!python) {
|
|
40
|
-
throw new Error("python3 not found on PATH");
|
|
41
|
-
}
|
|
42
|
-
const root = pythonRoot();
|
|
43
|
-
const payload = `${JSON.stringify(request)}
|
|
44
|
-
`;
|
|
45
|
-
const result = await runCommand(python, ["-m", "pudu_agent"], {
|
|
46
|
-
cwd: root,
|
|
47
|
-
env: { ...process.env, PYTHONPATH: root, PYTHONUNBUFFERED: "1" },
|
|
48
|
-
input: payload,
|
|
49
|
-
timeout
|
|
50
|
-
});
|
|
51
|
-
return { ...result, python };
|
|
52
|
-
}
|
|
53
|
-
function parseJsonStdout(stdout) {
|
|
54
|
-
const text = stdout.trim();
|
|
55
|
-
if (!text) {
|
|
56
|
-
throw new Error("Python engine returned empty stdout");
|
|
57
|
-
}
|
|
58
|
-
const start = text.indexOf("{");
|
|
59
|
-
const slice = start >= 0 ? text.slice(start) : text;
|
|
60
|
-
return JSON.parse(slice);
|
|
61
|
-
}
|
|
62
16
|
|
|
63
17
|
// src/agent-lab/schema.ts
|
|
64
18
|
import { z } from "zod";
|
|
@@ -83,7 +37,7 @@ var searchMatchSchema = z.object({
|
|
|
83
37
|
endLine: z.number().nullable(),
|
|
84
38
|
endColumn: z.number().nullable(),
|
|
85
39
|
text: z.string(),
|
|
86
|
-
strategy: z.enum(["rg", "ast-grep"]),
|
|
40
|
+
strategy: z.enum(["rg", "ast-grep", "graph"]),
|
|
87
41
|
language: z.string().nullable(),
|
|
88
42
|
metavariables: z.record(z.string())
|
|
89
43
|
});
|
|
@@ -205,16 +159,41 @@ async function runRepoCommand(args) {
|
|
|
205
159
|
print(t("repoHelp"), args.json);
|
|
206
160
|
return 0;
|
|
207
161
|
}
|
|
162
|
+
const python = await findPython();
|
|
163
|
+
if (!python) {
|
|
164
|
+
process.stderr.write(`${t("repoPythonMissing")}
|
|
165
|
+
`);
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
const repo = path.resolve(args.repo ?? process.cwd());
|
|
169
|
+
if (sub === "graph") {
|
|
170
|
+
try {
|
|
171
|
+
const graph = await buildRepoGraph(repo);
|
|
172
|
+
print(args.json ? graph : formatGraphText(graph), args.json);
|
|
173
|
+
return graph.ok ? 0 : 1;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return fail(error, args.json);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (sub === "harness") {
|
|
179
|
+
const task = args.task ?? args.positional.slice(1).join(" ").trim() ?? "";
|
|
180
|
+
if (!task) {
|
|
181
|
+
process.stderr.write(`${t("repoNeedTask")}
|
|
182
|
+
`);
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const harness = await runRepoHarness(repo, task);
|
|
187
|
+
print(args.json ? harness : formatGraphText(harness.graph, task), args.json);
|
|
188
|
+
return harness.ok ? 0 : 1;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
return fail(error, args.json);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
208
193
|
if (sub !== "search") {
|
|
209
194
|
process.stderr.write(`${t("repoUnknownSubcommand", { sub })}
|
|
210
195
|
`);
|
|
211
196
|
process.stderr.write(`${t("repoHelp")}
|
|
212
|
-
`);
|
|
213
|
-
return 1;
|
|
214
|
-
}
|
|
215
|
-
const python = await findPython();
|
|
216
|
-
if (!python) {
|
|
217
|
-
process.stderr.write(`${t("repoPythonMissing")}
|
|
218
197
|
`);
|
|
219
198
|
return 1;
|
|
220
199
|
}
|
|
@@ -226,7 +205,6 @@ async function runRepoCommand(args) {
|
|
|
226
205
|
return 1;
|
|
227
206
|
}
|
|
228
207
|
const intent = asIntent(args.intent);
|
|
229
|
-
const repo = path2.resolve(args.repo ?? process.cwd());
|
|
230
208
|
try {
|
|
231
209
|
const result = await searchRepo({
|
|
232
210
|
repo,
|
|
@@ -239,17 +217,54 @@ async function runRepoCommand(args) {
|
|
|
239
217
|
print(args.json ? result : formatSearchText(result), args.json);
|
|
240
218
|
return result.ok ? 0 : 1;
|
|
241
219
|
} catch (error) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
220
|
+
return fail(error, args.json);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function fail(error, json) {
|
|
224
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
225
|
+
if (json) print({ ok: false, error: message }, true);
|
|
226
|
+
else process.stderr.write(`${message}
|
|
247
227
|
`);
|
|
248
|
-
|
|
249
|
-
|
|
228
|
+
return 1;
|
|
229
|
+
}
|
|
230
|
+
function formatGraphText(graph, task) {
|
|
231
|
+
const e = graph.effort;
|
|
232
|
+
const m = graph.metrics;
|
|
233
|
+
const lines = [
|
|
234
|
+
t("repoGraphTitle"),
|
|
235
|
+
"",
|
|
236
|
+
t("aboutRepoGraph"),
|
|
237
|
+
"",
|
|
238
|
+
`Repo ${graph.repo}`,
|
|
239
|
+
`Backend ${graph.backend}`,
|
|
240
|
+
`Files ${m.fileCount} (${m.origin})`,
|
|
241
|
+
`Nodes ${m.nodeCount} (${m.origin})`,
|
|
242
|
+
`Edges ${m.edgeCount} (${m.origin})`,
|
|
243
|
+
`Tokens ${m.tokenEstimate ?? "N/A"} (${m.tokenOrigin ?? "N/A"})`,
|
|
244
|
+
`Effort ${e.label} ${e.score0to100}/100 (${e.origin})`
|
|
245
|
+
];
|
|
246
|
+
if (task) lines.push(`Task ${task}`);
|
|
247
|
+
if (graph.writtenTo) lines.push(`Wrote ${graph.writtenTo}`);
|
|
248
|
+
for (const err of graph.errors) {
|
|
249
|
+
lines.push(`Error ${err.tool}: ${err.message} (${err.origin})`);
|
|
250
|
+
}
|
|
251
|
+
lines.push("");
|
|
252
|
+
const degree = /* @__PURE__ */ new Map();
|
|
253
|
+
for (const edge of graph.edges) {
|
|
254
|
+
degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1);
|
|
255
|
+
degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1);
|
|
250
256
|
}
|
|
257
|
+
const top = [...graph.nodes].filter((n) => n.type === "file" || n.type === "function" || n.type === "test").sort((a, b) => (degree.get(b.id) ?? 0) - (degree.get(a.id) ?? 0)).slice(0, 12);
|
|
258
|
+
for (const node of top) {
|
|
259
|
+
const related = graph.edges.filter((edge) => edge.source === node.id).slice(0, 3).map((edge) => `${edge.type}\u2192${edge.symbol ?? edge.target} [${edge.confidence}]`);
|
|
260
|
+
lines.push(`${node.file ?? node.name} ${node.name} (${degree.get(node.id) ?? 0})`);
|
|
261
|
+
for (const rel of related) lines.push(` ${rel}`);
|
|
262
|
+
}
|
|
263
|
+
if (!top.length) lines.push(t("repoGraphEmpty"));
|
|
264
|
+
return lines.join("\n");
|
|
251
265
|
}
|
|
252
266
|
export {
|
|
267
|
+
formatGraphText,
|
|
253
268
|
formatSearchText,
|
|
254
269
|
runRepoCommand
|
|
255
270
|
};
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
SKIP_DIRS = {
|
|
10
|
+
".git",
|
|
11
|
+
"node_modules",
|
|
12
|
+
".venv",
|
|
13
|
+
"venv",
|
|
14
|
+
"__pycache__",
|
|
15
|
+
".pudu-ai",
|
|
16
|
+
"dist",
|
|
17
|
+
"build",
|
|
18
|
+
".tox",
|
|
19
|
+
".mypy_cache",
|
|
20
|
+
}
|
|
21
|
+
MAX_FILES = 500
|
|
22
|
+
MAX_EDGES = 3000
|
|
23
|
+
MAX_BYTES = 400_000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def graph_tool_info() -> dict[str, Any]:
|
|
27
|
+
return {"available": True, "path": "python-ast", "version": "stdlib"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _rel(repo: Path, path: Path) -> str:
|
|
31
|
+
try:
|
|
32
|
+
return str(path.relative_to(repo)).replace("\\", "/")
|
|
33
|
+
except ValueError:
|
|
34
|
+
return str(path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _iter_py_files(repo: Path) -> list[Path]:
|
|
38
|
+
files: list[Path] = []
|
|
39
|
+
for path in repo.rglob("*.py"):
|
|
40
|
+
if any(part in SKIP_DIRS for part in path.parts):
|
|
41
|
+
continue
|
|
42
|
+
if not path.is_file():
|
|
43
|
+
continue
|
|
44
|
+
files.append(path)
|
|
45
|
+
if len(files) >= MAX_FILES:
|
|
46
|
+
break
|
|
47
|
+
return files
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _module_name(rel: str) -> str:
|
|
51
|
+
body = rel[:-3] if rel.endswith(".py") else rel
|
|
52
|
+
if body.endswith("/__init__"):
|
|
53
|
+
body = body[: -len("/__init__")]
|
|
54
|
+
return body.replace("/", ".")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_test(rel: str) -> bool:
|
|
58
|
+
name = Path(rel).name
|
|
59
|
+
return name.startswith("test_") or name.endswith("_test.py") or "/tests/" in f"/{rel}/"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _load_graphify(repo: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
|
63
|
+
path = repo / "graphify-out" / "graph.json"
|
|
64
|
+
if not path.is_file():
|
|
65
|
+
return [], [], []
|
|
66
|
+
try:
|
|
67
|
+
raw = json.loads(path.read_text(encoding="utf8"))
|
|
68
|
+
except (OSError, ValueError) as exc:
|
|
69
|
+
return [], [], [{"tool": "graphify", "message": str(exc), "origin": "MEASURED"}]
|
|
70
|
+
nodes_in = raw.get("nodes") if isinstance(raw, dict) else None
|
|
71
|
+
edges_in = raw.get("edges") if isinstance(raw, dict) else None
|
|
72
|
+
if not isinstance(nodes_in, list) or not isinstance(edges_in, list):
|
|
73
|
+
return [], [], [{"tool": "graphify", "message": "graphify-out/graph.json missing nodes/edges arrays", "origin": "MEASURED"}]
|
|
74
|
+
nodes: list[dict[str, Any]] = []
|
|
75
|
+
for item in nodes_in:
|
|
76
|
+
if not isinstance(item, dict):
|
|
77
|
+
continue
|
|
78
|
+
ident = item.get("id") or item.get("name")
|
|
79
|
+
if not isinstance(ident, str):
|
|
80
|
+
continue
|
|
81
|
+
kind = item.get("type") or item.get("kind") or "file"
|
|
82
|
+
nodes.append(
|
|
83
|
+
{
|
|
84
|
+
"id": ident,
|
|
85
|
+
"type": kind if isinstance(kind, str) else "file",
|
|
86
|
+
"name": item.get("name") if isinstance(item.get("name"), str) else ident,
|
|
87
|
+
"file": item.get("file") if isinstance(item.get("file"), str) else None,
|
|
88
|
+
"line": item.get("line") if isinstance(item.get("line"), int) else None,
|
|
89
|
+
"backend": "graphify",
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
edges: list[dict[str, Any]] = []
|
|
93
|
+
allowed = {"imports", "calls", "inherits", "references", "definitions"}
|
|
94
|
+
for item in edges_in:
|
|
95
|
+
if not isinstance(item, dict):
|
|
96
|
+
continue
|
|
97
|
+
source = item.get("source") or item.get("from")
|
|
98
|
+
target = item.get("target") or item.get("to")
|
|
99
|
+
kind = item.get("type") or item.get("kind") or item.get("relation")
|
|
100
|
+
if not isinstance(source, str) or not isinstance(target, str) or not isinstance(kind, str):
|
|
101
|
+
continue
|
|
102
|
+
if kind not in allowed:
|
|
103
|
+
continue
|
|
104
|
+
edges.append(
|
|
105
|
+
{
|
|
106
|
+
"source": source,
|
|
107
|
+
"target": target,
|
|
108
|
+
"type": kind,
|
|
109
|
+
"confidence": "EXTRACTED",
|
|
110
|
+
"file": item.get("file") if isinstance(item.get("file"), str) else None,
|
|
111
|
+
"line": item.get("line") if isinstance(item.get("line"), int) else None,
|
|
112
|
+
"symbol": item.get("symbol") if isinstance(item.get("symbol"), str) else None,
|
|
113
|
+
"backend": "graphify",
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
return nodes, edges, []
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_python_graph(repo: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]], int, list[dict[str, Any]]]:
|
|
120
|
+
nodes: list[dict[str, Any]] = []
|
|
121
|
+
edges: list[dict[str, Any]] = []
|
|
122
|
+
errors: list[dict[str, Any]] = []
|
|
123
|
+
chars = 0
|
|
124
|
+
seen_nodes: set[str] = set()
|
|
125
|
+
|
|
126
|
+
def add_node(node: dict[str, Any]) -> None:
|
|
127
|
+
ident = node["id"]
|
|
128
|
+
if ident in seen_nodes:
|
|
129
|
+
return
|
|
130
|
+
seen_nodes.add(ident)
|
|
131
|
+
nodes.append(node)
|
|
132
|
+
|
|
133
|
+
for path in _iter_py_files(repo):
|
|
134
|
+
rel = _rel(repo, path)
|
|
135
|
+
try:
|
|
136
|
+
source = path.read_text(encoding="utf8", errors="replace")
|
|
137
|
+
except OSError as exc:
|
|
138
|
+
errors.append({"tool": "python-ast", "message": f"{rel}: {exc}", "origin": "MEASURED"})
|
|
139
|
+
continue
|
|
140
|
+
if len(source) > MAX_BYTES:
|
|
141
|
+
errors.append({"tool": "python-ast", "message": f"{rel}: skipped, file larger than {MAX_BYTES} bytes", "origin": "MEASURED"})
|
|
142
|
+
continue
|
|
143
|
+
chars += len(source)
|
|
144
|
+
try:
|
|
145
|
+
tree = ast.parse(source, filename=rel)
|
|
146
|
+
except SyntaxError as exc:
|
|
147
|
+
errors.append({"tool": "python-ast", "message": f"{rel}: {exc.msg}", "origin": "MEASURED"})
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
file_id = f"file:{rel}"
|
|
151
|
+
mod_id = f"module:{_module_name(rel)}"
|
|
152
|
+
add_node({"id": file_id, "type": "test" if _is_test(rel) else "file", "name": rel, "file": rel, "line": 1, "backend": "python-ast"})
|
|
153
|
+
add_node({"id": mod_id, "type": "module", "name": _module_name(rel), "file": rel, "line": 1, "backend": "python-ast"})
|
|
154
|
+
edges.append(
|
|
155
|
+
{
|
|
156
|
+
"source": file_id,
|
|
157
|
+
"target": mod_id,
|
|
158
|
+
"type": "definitions",
|
|
159
|
+
"confidence": "EXTRACTED",
|
|
160
|
+
"file": rel,
|
|
161
|
+
"line": 1,
|
|
162
|
+
"symbol": _module_name(rel),
|
|
163
|
+
"backend": "python-ast",
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
defs: dict[str, str] = {}
|
|
168
|
+
current_fn: str | None = None
|
|
169
|
+
|
|
170
|
+
class Visitor(ast.NodeVisitor):
|
|
171
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
172
|
+
nonlocal current_fn
|
|
173
|
+
ident = f"def:{rel}:{node.name}"
|
|
174
|
+
add_node({"id": ident, "type": "function", "name": node.name, "file": rel, "line": node.lineno, "backend": "python-ast"})
|
|
175
|
+
defs[node.name] = ident
|
|
176
|
+
edges.append(
|
|
177
|
+
{
|
|
178
|
+
"source": file_id,
|
|
179
|
+
"target": ident,
|
|
180
|
+
"type": "definitions",
|
|
181
|
+
"confidence": "EXTRACTED",
|
|
182
|
+
"file": rel,
|
|
183
|
+
"line": node.lineno,
|
|
184
|
+
"symbol": node.name,
|
|
185
|
+
"backend": "python-ast",
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
prev = current_fn
|
|
189
|
+
current_fn = ident
|
|
190
|
+
self.generic_visit(node)
|
|
191
|
+
current_fn = prev
|
|
192
|
+
|
|
193
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
194
|
+
self.visit_FunctionDef(node) # type: ignore[arg-type]
|
|
195
|
+
|
|
196
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
197
|
+
ident = f"class:{rel}:{node.name}"
|
|
198
|
+
add_node({"id": ident, "type": "class", "name": node.name, "file": rel, "line": node.lineno, "backend": "python-ast"})
|
|
199
|
+
defs[node.name] = ident
|
|
200
|
+
edges.append(
|
|
201
|
+
{
|
|
202
|
+
"source": file_id,
|
|
203
|
+
"target": ident,
|
|
204
|
+
"type": "definitions",
|
|
205
|
+
"confidence": "EXTRACTED",
|
|
206
|
+
"file": rel,
|
|
207
|
+
"line": node.lineno,
|
|
208
|
+
"symbol": node.name,
|
|
209
|
+
"backend": "python-ast",
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
for base in node.bases:
|
|
213
|
+
name = base.id if isinstance(base, ast.Name) else None
|
|
214
|
+
if not name:
|
|
215
|
+
continue
|
|
216
|
+
target = defs.get(name, f"name:{name}")
|
|
217
|
+
if target.startswith("name:"):
|
|
218
|
+
add_node({"id": target, "type": "name", "name": name, "file": None, "line": None, "backend": "python-ast"})
|
|
219
|
+
edges.append(
|
|
220
|
+
{
|
|
221
|
+
"source": ident,
|
|
222
|
+
"target": target,
|
|
223
|
+
"type": "inherits",
|
|
224
|
+
"confidence": "EXTRACTED" if target in defs.values() else "EXTRACTED",
|
|
225
|
+
"file": rel,
|
|
226
|
+
"line": node.lineno,
|
|
227
|
+
"symbol": name,
|
|
228
|
+
"backend": "python-ast",
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
self.generic_visit(node)
|
|
232
|
+
|
|
233
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
234
|
+
for alias in node.names:
|
|
235
|
+
target = f"module:{alias.name}"
|
|
236
|
+
add_node({"id": target, "type": "module", "name": alias.name, "file": None, "line": None, "backend": "python-ast"})
|
|
237
|
+
edges.append(
|
|
238
|
+
{
|
|
239
|
+
"source": file_id,
|
|
240
|
+
"target": target,
|
|
241
|
+
"type": "imports",
|
|
242
|
+
"confidence": "EXTRACTED",
|
|
243
|
+
"file": rel,
|
|
244
|
+
"line": node.lineno,
|
|
245
|
+
"symbol": alias.name,
|
|
246
|
+
"backend": "python-ast",
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
251
|
+
if not node.module:
|
|
252
|
+
return
|
|
253
|
+
target = f"module:{node.module}"
|
|
254
|
+
add_node({"id": target, "type": "module", "name": node.module, "file": None, "line": None, "backend": "python-ast"})
|
|
255
|
+
edges.append(
|
|
256
|
+
{
|
|
257
|
+
"source": file_id,
|
|
258
|
+
"target": target,
|
|
259
|
+
"type": "imports",
|
|
260
|
+
"confidence": "EXTRACTED",
|
|
261
|
+
"file": rel,
|
|
262
|
+
"line": node.lineno,
|
|
263
|
+
"symbol": node.module,
|
|
264
|
+
"backend": "python-ast",
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
269
|
+
name: str | None = None
|
|
270
|
+
if isinstance(node.func, ast.Name):
|
|
271
|
+
name = node.func.id
|
|
272
|
+
elif isinstance(node.func, ast.Attribute):
|
|
273
|
+
name = node.func.attr
|
|
274
|
+
if name and current_fn:
|
|
275
|
+
target = defs.get(name, f"name:{name}")
|
|
276
|
+
confidence = "RESOLVED" if name in defs else "EXTRACTED"
|
|
277
|
+
if target.startswith("name:"):
|
|
278
|
+
add_node({"id": target, "type": "name", "name": name, "file": None, "line": None, "backend": "python-ast"})
|
|
279
|
+
edges.append(
|
|
280
|
+
{
|
|
281
|
+
"source": current_fn,
|
|
282
|
+
"target": target,
|
|
283
|
+
"type": "calls",
|
|
284
|
+
"confidence": confidence,
|
|
285
|
+
"file": rel,
|
|
286
|
+
"line": getattr(node, "lineno", None),
|
|
287
|
+
"symbol": name,
|
|
288
|
+
"backend": "python-ast",
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
self.generic_visit(node)
|
|
292
|
+
|
|
293
|
+
Visitor().visit(tree)
|
|
294
|
+
if len(edges) >= MAX_EDGES:
|
|
295
|
+
errors.append({"tool": "python-ast", "message": f"edge cap {MAX_EDGES} reached", "origin": "MEASURED"})
|
|
296
|
+
break
|
|
297
|
+
|
|
298
|
+
return nodes, edges[:MAX_EDGES], chars, errors
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def effort_from_counts(file_count: int, edge_count: int, token_estimate: int) -> dict[str, Any]:
|
|
302
|
+
score = min(100, int(round(file_count * 1.2 + edge_count * 0.15 + token_estimate / 800)))
|
|
303
|
+
if score < 20:
|
|
304
|
+
label = "LOW"
|
|
305
|
+
elif score < 45:
|
|
306
|
+
label = "MEDIUM"
|
|
307
|
+
elif score < 75:
|
|
308
|
+
label = "HIGH"
|
|
309
|
+
else:
|
|
310
|
+
label = "VERY_HIGH"
|
|
311
|
+
return {
|
|
312
|
+
"label": label,
|
|
313
|
+
"score0to100": score,
|
|
314
|
+
"origin": "DERIVED",
|
|
315
|
+
"humanBaselineMinutes": None,
|
|
316
|
+
"agentRuntimeMinutes": None,
|
|
317
|
+
"humanInterventionMinutes": None,
|
|
318
|
+
"potentialTimeReductionPct": None,
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def build_graph(repo: Path, persist: bool = True) -> dict[str, Any]:
|
|
323
|
+
started = time.perf_counter()
|
|
324
|
+
py_nodes, py_edges, chars, errors = build_python_graph(repo)
|
|
325
|
+
gy_nodes, gy_edges, gy_errors = _load_graphify(repo)
|
|
326
|
+
errors.extend(gy_errors)
|
|
327
|
+
nodes = py_nodes + gy_nodes
|
|
328
|
+
edges = py_edges + gy_edges
|
|
329
|
+
backend = "hybrid" if gy_nodes or gy_edges else "python-ast"
|
|
330
|
+
token_estimate = max(0, chars // 4)
|
|
331
|
+
file_count = sum(1 for n in py_nodes if n["type"] in {"file", "test"})
|
|
332
|
+
duration_ms = int((time.perf_counter() - started) * 1000)
|
|
333
|
+
payload = {
|
|
334
|
+
"schemaVersion": 1,
|
|
335
|
+
"ok": True,
|
|
336
|
+
"op": "graph",
|
|
337
|
+
"repo": str(repo),
|
|
338
|
+
"backend": backend,
|
|
339
|
+
"writtenTo": None,
|
|
340
|
+
"nodes": nodes,
|
|
341
|
+
"edges": edges,
|
|
342
|
+
"metrics": {
|
|
343
|
+
"fileCount": file_count,
|
|
344
|
+
"nodeCount": len(nodes),
|
|
345
|
+
"edgeCount": len(edges),
|
|
346
|
+
"tokenEstimate": token_estimate,
|
|
347
|
+
"tokenOrigin": "ESTIMATED",
|
|
348
|
+
"durationMs": duration_ms,
|
|
349
|
+
"origin": "MEASURED",
|
|
350
|
+
},
|
|
351
|
+
"effort": effort_from_counts(file_count, len(edges), token_estimate),
|
|
352
|
+
"errors": errors,
|
|
353
|
+
"tools": {"graph": graph_tool_info()},
|
|
354
|
+
}
|
|
355
|
+
if persist:
|
|
356
|
+
out_dir = repo / ".pudu-ai"
|
|
357
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
358
|
+
out = out_dir / "code-graph.json"
|
|
359
|
+
out.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf8")
|
|
360
|
+
payload["writtenTo"] = str(out)
|
|
361
|
+
return payload
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def query_graph(graph: dict[str, Any], query: str | None, limit: int) -> list[dict[str, Any]]:
|
|
365
|
+
if not query:
|
|
366
|
+
return []
|
|
367
|
+
needle = query.strip()
|
|
368
|
+
for prefix in ("who calls ", "callers of ", "impact of ", "dependencies of ", "imports of "):
|
|
369
|
+
if needle.lower().startswith(prefix):
|
|
370
|
+
needle = needle[len(prefix) :].strip()
|
|
371
|
+
break
|
|
372
|
+
needle = needle.split()[0] if needle.split() else needle
|
|
373
|
+
if not needle:
|
|
374
|
+
return []
|
|
375
|
+
matches: list[dict[str, Any]] = []
|
|
376
|
+
for edge in graph.get("edges") or []:
|
|
377
|
+
if not isinstance(edge, dict):
|
|
378
|
+
continue
|
|
379
|
+
symbol = edge.get("symbol") if isinstance(edge.get("symbol"), str) else ""
|
|
380
|
+
target = edge.get("target") if isinstance(edge.get("target"), str) else ""
|
|
381
|
+
if needle not in symbol and needle not in target:
|
|
382
|
+
continue
|
|
383
|
+
matches.append(
|
|
384
|
+
{
|
|
385
|
+
"file": edge.get("file") or "",
|
|
386
|
+
"line": edge.get("line") if isinstance(edge.get("line"), int) else None,
|
|
387
|
+
"column": None,
|
|
388
|
+
"endLine": edge.get("line") if isinstance(edge.get("line"), int) else None,
|
|
389
|
+
"endColumn": None,
|
|
390
|
+
"text": f"{edge.get('type')} {edge.get('source')} -> {edge.get('target')} [{edge.get('confidence')}]",
|
|
391
|
+
"strategy": "graph",
|
|
392
|
+
"language": "python",
|
|
393
|
+
"metavariables": {"symbol": symbol or needle},
|
|
394
|
+
}
|
|
395
|
+
)
|
|
396
|
+
if len(matches) >= limit:
|
|
397
|
+
break
|
|
398
|
+
return matches
|
|
@@ -8,6 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
10
|
from pudu_agent.ast_search import run_ast_grep, tool_info as ast_tool_info
|
|
11
|
+
from pudu_agent.graph import build_graph, graph_tool_info, query_graph
|
|
11
12
|
from pudu_agent.protocol import (
|
|
12
13
|
PROTOCOL_VERSION,
|
|
13
14
|
classify_intent,
|
|
@@ -48,10 +49,6 @@ def rg_tool_info() -> dict[str, Any]:
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
|
|
51
|
-
def graph_tool_info() -> dict[str, Any]:
|
|
52
|
-
return {"available": False, "path": None, "version": None}
|
|
53
|
-
|
|
54
|
-
|
|
55
52
|
def parse_rg_json_line(line: str) -> dict[str, Any] | None:
|
|
56
53
|
line = line.strip()
|
|
57
54
|
if not line:
|
|
@@ -222,16 +219,8 @@ def handle_search(req: dict[str, Any]) -> dict[str, Any]:
|
|
|
222
219
|
result["ok"] = False
|
|
223
220
|
return result
|
|
224
221
|
|
|
225
|
-
if intent
|
|
222
|
+
if intent == "SEMANTIC" or not planned:
|
|
226
223
|
duration_ms = int((time.perf_counter() - started) * 1000)
|
|
227
|
-
unavailable = ["graph"] if intent in {"RELATIONSHIP", "IMPACT"} else (["semantic"] if intent == "SEMANTIC" else planned)
|
|
228
|
-
errors = [
|
|
229
|
-
{
|
|
230
|
-
"tool": "graph" if intent != "SEMANTIC" else "semantic",
|
|
231
|
-
"message": "not implemented in Agent Lab iteration 1",
|
|
232
|
-
"origin": "MEASURED",
|
|
233
|
-
}
|
|
234
|
-
]
|
|
235
224
|
return empty_search(
|
|
236
225
|
str(repo),
|
|
237
226
|
query,
|
|
@@ -239,11 +228,39 @@ def handle_search(req: dict[str, Any]) -> dict[str, Any]:
|
|
|
239
228
|
intent,
|
|
240
229
|
strategy if planned else "hybrid",
|
|
241
230
|
tools,
|
|
242
|
-
|
|
243
|
-
|
|
231
|
+
[{"tool": "semantic", "message": "semantic search is not implemented; no LLM router", "origin": "MEASURED"}],
|
|
232
|
+
["semantic"],
|
|
244
233
|
duration_ms,
|
|
245
234
|
)
|
|
246
235
|
|
|
236
|
+
if intent in {"RELATIONSHIP", "IMPACT"} or planned == ["graph"]:
|
|
237
|
+
graph = build_graph(repo, persist=True)
|
|
238
|
+
duration_ms = int((time.perf_counter() - started) * 1000)
|
|
239
|
+
matches = query_graph(graph, query, limit)
|
|
240
|
+
errors = list(graph.get("errors") or [])
|
|
241
|
+
return {
|
|
242
|
+
"schemaVersion": PROTOCOL_VERSION,
|
|
243
|
+
"ok": True,
|
|
244
|
+
"op": "search",
|
|
245
|
+
"repo": str(repo),
|
|
246
|
+
"query": query,
|
|
247
|
+
"structuralPattern": structural,
|
|
248
|
+
"intent": intent,
|
|
249
|
+
"strategy": "graph",
|
|
250
|
+
"matches": matches,
|
|
251
|
+
"tools": tools,
|
|
252
|
+
"errors": errors,
|
|
253
|
+
"metrics": {
|
|
254
|
+
"durationMs": duration_ms,
|
|
255
|
+
"matchCount": len(matches),
|
|
256
|
+
"rgQueries": 0,
|
|
257
|
+
"astQueries": 0,
|
|
258
|
+
"graphQueries": 1,
|
|
259
|
+
"origin": "MEASURED",
|
|
260
|
+
},
|
|
261
|
+
"unavailable": [],
|
|
262
|
+
}
|
|
263
|
+
|
|
247
264
|
matches: list[dict[str, Any]] = []
|
|
248
265
|
errors: list[dict[str, Any]] = []
|
|
249
266
|
unavailable: list[str] = []
|
|
@@ -330,6 +347,35 @@ def main() -> int:
|
|
|
330
347
|
}
|
|
331
348
|
)
|
|
332
349
|
return 0
|
|
350
|
+
if op == "graph":
|
|
351
|
+
repo_raw = req.get("repo") or "."
|
|
352
|
+
repo = Path(repo_raw).expanduser().resolve()
|
|
353
|
+
if not repo.is_dir():
|
|
354
|
+
write_response(error_response("repo is not a directory", op="graph"))
|
|
355
|
+
return 1
|
|
356
|
+
result = build_graph(repo, persist=True)
|
|
357
|
+
write_response(result)
|
|
358
|
+
return 0
|
|
359
|
+
if op == "harness":
|
|
360
|
+
repo_raw = req.get("repo") or "."
|
|
361
|
+
repo = Path(repo_raw).expanduser().resolve()
|
|
362
|
+
task = req.get("task") if isinstance(req.get("task"), str) else None
|
|
363
|
+
if not repo.is_dir():
|
|
364
|
+
write_response(error_response("repo is not a directory", op="harness"))
|
|
365
|
+
return 1
|
|
366
|
+
graph = build_graph(repo, persist=True)
|
|
367
|
+
write_response(
|
|
368
|
+
{
|
|
369
|
+
"schemaVersion": PROTOCOL_VERSION,
|
|
370
|
+
"ok": True,
|
|
371
|
+
"op": "harness",
|
|
372
|
+
"repo": str(repo),
|
|
373
|
+
"task": task,
|
|
374
|
+
"graph": graph,
|
|
375
|
+
"effort": graph.get("effort"),
|
|
376
|
+
}
|
|
377
|
+
)
|
|
378
|
+
return 0
|
|
333
379
|
if op != "search":
|
|
334
380
|
write_response(error_response(f"unknown op: {op}"))
|
|
335
381
|
return 1
|