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 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}. Iteration 1 supports search only.",
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
- repoHelp: `Pudu Agent Lab \u2014 repository search
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
- --json Machine-readable SearchResult
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}. La iteraci\xF3n 1 solo admite search.",
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
- repoHelp: `Pudu Agent Lab \u2014 b\xFAsqueda de repositorio
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 (por defecto: cwd)
272
- --structural PAT Patr\xF3n de ast-grep
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
- --json SearchResult legible por m\xE1quinas
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
+ };
@@ -3,7 +3,7 @@ import {
3
3
  formatTokensPerSec,
4
4
  na,
5
5
  t
6
- } from "./chunk-QGEXKMQ5.js";
6
+ } from "./chunk-6NXOK4OD.js";
7
7
  import {
8
8
  commandExists,
9
9
  runCommand,
package/dist/cli/index.js CHANGED
@@ -32,12 +32,12 @@ import {
32
32
  runBenchmark,
33
33
  runtimesText,
34
34
  tasksText
35
- } from "./chunk-6C5EPKRI.js";
35
+ } from "./chunk-PX7BJDD3.js";
36
36
  import {
37
37
  resolveLocale,
38
38
  setLocale,
39
39
  t
40
- } from "./chunk-QGEXKMQ5.js";
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-3UBPZNF4.js");
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-4GSG5MD3.js");
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-6C5EPKRI.js";
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-QGEXKMQ5.js";
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 useInput4 } from "ink";
33
- import { useMemo as useMemo2, useRef as useRef2, useState as useState4 } from "react";
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 jsx11, jsxs as jsxs11 } from "react/jsx-runtime";
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] = useState4(props.start ?? "home");
960
+ const [screen, setScreen] = useState5(props.start ?? "home");
764
961
  const screenRef = useRef2(screen);
765
962
  screenRef.current = screen;
766
- const [selected, setSelected] = useState4(0);
767
- const benchable = useMemo2(
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
- useInput4((input, key) => {
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__ */ jsxs11(Frame, { children: [
778
- screen !== "benchmark" && /* @__PURE__ */ jsx11(Header, {}),
779
- screen === "home" && /* @__PURE__ */ jsx11(Dashboard, { session: props.session }),
780
- screen === "models" && /* @__PURE__ */ jsx11(ModelsView, { session: props.session }),
781
- screen === "hardware" && /* @__PURE__ */ jsx11(HardwareView, { session: props.session }),
782
- screen === "recommend" && /* @__PURE__ */ jsx11(RecommendView, { session: props.session }),
783
- screen === "setup" && /* @__PURE__ */ jsx11(SetupView, { session: props.session }),
784
- screen === "compare" && /* @__PURE__ */ jsx11(CompareView, { session: props.session }),
785
- screen === "history" && /* @__PURE__ */ jsx11(HistoryView, { session: props.session }),
786
- screen === "tasks" && /* @__PURE__ */ jsx11(TasksView, { session: props.session }),
787
- screen === "benchmark" && /* @__PURE__ */ jsx11(
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 jsx12 } from "react/jsx-runtime";
1000
+ import { jsx as jsx13 } from "react/jsx-runtime";
803
1001
  async function renderDashboard(session, preset, start) {
804
- const instance = render(/* @__PURE__ */ jsx12(App, { session, preset, start }), {
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-QGEXKMQ5.js";
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
- const message = error instanceof Error ? error.message : String(error);
243
- if (args.json) {
244
- print({ ok: false, error: message }, true);
245
- } else {
246
- process.stderr.write(`${message}
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
- return 1;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pudu-ai",
3
- "version": "0.2.21",
3
+ "version": "0.2.22",
4
4
  "description": "Pudu-AI — local AI hardware & benchmark lab for the terminal",
5
5
  "keywords": [
6
6
  "ai",
@@ -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 in {"RELATIONSHIP", "IMPACT", "SEMANTIC"} or planned == ["graph"] or not planned:
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
- errors,
243
- unavailable,
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