pudu-ai 0.2.18 → 0.2.21

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.
@@ -0,0 +1,255 @@
1
+ import {
2
+ na,
3
+ t
4
+ } from "./chunk-QGEXKMQ5.js";
5
+ import {
6
+ commandExists,
7
+ runCommand
8
+ } from "./chunk-SS7OXXQQ.js";
9
+
10
+ // 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
+ 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
+
63
+ // src/agent-lab/schema.ts
64
+ import { z } from "zod";
65
+ var metricOriginSchema = z.enum(["MEASURED", "ESTIMATED", "DERIVED"]);
66
+ var searchIntentSchema = z.enum([
67
+ "TEXT",
68
+ "STRUCTURAL",
69
+ "RELATIONSHIP",
70
+ "IMPACT",
71
+ "SEMANTIC",
72
+ "UNKNOWN"
73
+ ]);
74
+ var toolAvailabilitySchema = z.object({
75
+ available: z.boolean(),
76
+ path: z.string().nullable(),
77
+ version: z.string().nullable()
78
+ });
79
+ var searchMatchSchema = z.object({
80
+ file: z.string(),
81
+ line: z.number().nullable(),
82
+ column: z.number().nullable(),
83
+ endLine: z.number().nullable(),
84
+ endColumn: z.number().nullable(),
85
+ text: z.string(),
86
+ strategy: z.enum(["rg", "ast-grep"]),
87
+ language: z.string().nullable(),
88
+ metavariables: z.record(z.string())
89
+ });
90
+ var searchResultSchema = z.object({
91
+ schemaVersion: z.literal(1),
92
+ ok: z.boolean(),
93
+ op: z.literal("search"),
94
+ repo: z.string(),
95
+ query: z.string().nullable(),
96
+ structuralPattern: z.string().nullable(),
97
+ intent: searchIntentSchema,
98
+ strategy: z.enum(["rg", "ast-grep", "graph", "hybrid"]),
99
+ matches: z.array(searchMatchSchema),
100
+ tools: z.object({
101
+ rg: toolAvailabilitySchema,
102
+ astGrep: toolAvailabilitySchema,
103
+ graph: toolAvailabilitySchema
104
+ }),
105
+ errors: z.array(
106
+ z.object({
107
+ tool: z.string(),
108
+ message: z.string(),
109
+ origin: metricOriginSchema
110
+ })
111
+ ),
112
+ metrics: z.object({
113
+ durationMs: z.number(),
114
+ matchCount: z.number(),
115
+ rgQueries: z.number(),
116
+ astQueries: z.number(),
117
+ graphQueries: z.number(),
118
+ origin: metricOriginSchema
119
+ }),
120
+ unavailable: z.array(z.string())
121
+ });
122
+ function parseSearchResult(value) {
123
+ return searchResultSchema.parse(value);
124
+ }
125
+
126
+ // src/agent-lab/search.ts
127
+ async function searchRepo(request) {
128
+ const invoked = await invokePuduAgent({
129
+ op: "search",
130
+ repo: request.repo,
131
+ query: request.query ?? null,
132
+ structuralPattern: request.structuralPattern ?? null,
133
+ intent: request.intent ?? null,
134
+ globs: request.globs ?? [],
135
+ limit: request.limit ?? 100,
136
+ timeoutMs: request.timeoutMs ?? 2e4
137
+ });
138
+ if (invoked.timedOut) {
139
+ throw new Error("Python search engine timed out");
140
+ }
141
+ let parsed;
142
+ try {
143
+ parsed = parseJsonStdout(invoked.stdout);
144
+ } catch {
145
+ const err = invoked.stderr.trim() || invoked.stdout.trim() || "invalid JSON from python -m pudu_agent";
146
+ throw new Error(err);
147
+ }
148
+ if (typeof parsed === "object" && parsed && "op" in parsed && parsed.op !== "search") {
149
+ throw new Error(parsed.error ?? "unexpected Python response");
150
+ }
151
+ return parseSearchResult(parsed);
152
+ }
153
+
154
+ // src/agent-lab/repo-cli.ts
155
+ var INTENTS = ["TEXT", "STRUCTURAL", "RELATIONSHIP", "IMPACT", "SEMANTIC", "UNKNOWN"];
156
+ function print(value, json) {
157
+ if (json) {
158
+ process.stdout.write(`${JSON.stringify(value, null, 2)}
159
+ `);
160
+ return;
161
+ }
162
+ process.stdout.write(`${typeof value === "string" ? value : JSON.stringify(value, null, 2)}
163
+ `);
164
+ }
165
+ function asIntent(raw) {
166
+ if (!raw) return void 0;
167
+ const value = raw.trim().toUpperCase();
168
+ return INTENTS.find((item) => item === value);
169
+ }
170
+ function formatSearchText(result) {
171
+ const lines = [
172
+ t("repoSearchTitle"),
173
+ "",
174
+ t("aboutRepoSearch"),
175
+ "",
176
+ `Intent ${result.intent}`,
177
+ `Strategy ${result.strategy}`,
178
+ `Repo ${result.repo}`,
179
+ `Matches ${result.metrics.matchCount} (${result.metrics.origin})`,
180
+ `Duration ${result.metrics.durationMs} ms (${result.metrics.origin})`,
181
+ `rg ${result.tools.rg.available ? na(result.tools.rg.version) : t("repoToolMissing", { tool: "rg" })}`,
182
+ `ast-grep ${result.tools.astGrep.available ? na(result.tools.astGrep.version) : t("repoToolMissing", { tool: "ast-grep" })}`
183
+ ];
184
+ if (result.unavailable.length) {
185
+ lines.push(`Unavailable ${result.unavailable.join(", ")}`);
186
+ }
187
+ for (const err of result.errors) {
188
+ lines.push(`Error ${err.tool}: ${err.message} (${err.origin})`);
189
+ }
190
+ lines.push("");
191
+ if (!result.matches.length) {
192
+ lines.push(t("repoNoMatches"));
193
+ return lines.join("\n");
194
+ }
195
+ for (const match of result.matches) {
196
+ const loc = `${match.file}:${na(match.line)}`;
197
+ const snippet = match.text.replace(/\s+/g, " ").slice(0, 160);
198
+ lines.push(`${loc} [${match.strategy}] ${snippet}`);
199
+ }
200
+ return lines.join("\n");
201
+ }
202
+ async function runRepoCommand(args) {
203
+ const sub = args.positional[0];
204
+ if (!sub || sub === "help") {
205
+ print(t("repoHelp"), args.json);
206
+ return 0;
207
+ }
208
+ if (sub !== "search") {
209
+ process.stderr.write(`${t("repoUnknownSubcommand", { sub })}
210
+ `);
211
+ 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
+ `);
219
+ return 1;
220
+ }
221
+ const queryParts = args.positional.slice(1);
222
+ const query = queryParts.join(" ").trim() || void 0;
223
+ if (!query && !args.structural) {
224
+ process.stderr.write(`${t("repoNeedQuery")}
225
+ `);
226
+ return 1;
227
+ }
228
+ const intent = asIntent(args.intent);
229
+ const repo = path2.resolve(args.repo ?? process.cwd());
230
+ try {
231
+ const result = await searchRepo({
232
+ repo,
233
+ query,
234
+ structuralPattern: args.structural,
235
+ intent,
236
+ globs: args.globs,
237
+ limit: args.limit
238
+ });
239
+ print(args.json ? result : formatSearchText(result), args.json);
240
+ return result.ok ? 0 : 1;
241
+ } 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}
247
+ `);
248
+ }
249
+ return 1;
250
+ }
251
+ }
252
+ export {
253
+ formatSearchText,
254
+ runRepoCommand
255
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pudu-ai",
3
- "version": "0.2.18",
3
+ "version": "0.2.21",
4
4
  "description": "Pudu-AI — local AI hardware & benchmark lab for the terminal",
5
5
  "keywords": [
6
6
  "ai",
@@ -32,6 +32,7 @@
32
32
  "files": [
33
33
  "dist",
34
34
  "bin",
35
+ "python",
35
36
  "README.md",
36
37
  "LICENSE"
37
38
  ],
@@ -39,7 +40,7 @@
39
40
  "node": ">=20"
40
41
  },
41
42
  "scripts": {
42
- "build": "npx --yes esbuild src/cli/index.ts --bundle --platform=node --format=esm --outfile=dist/cli/index.js --packages=external --jsx=automatic && node scripts/add-shebang.mjs",
43
+ "build": "rm -rf dist && npx --yes esbuild src/cli/index.ts --bundle --platform=node --format=esm --splitting --outdir=dist/cli --outbase=src/cli --packages=external --jsx=automatic && node scripts/add-shebang.mjs",
43
44
  "dev": "tsx src/cli/index.ts",
44
45
  "pudu-ai": "tsx src/cli/index.ts",
45
46
  "start": "node dist/cli/index.js",
@@ -0,0 +1 @@
1
+ PROTOCOL_VERSION = 1
@@ -0,0 +1,4 @@
1
+ from pudu_agent.search import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def _which() -> str | None:
11
+ return shutil.which("ast-grep") or shutil.which("sg")
12
+
13
+
14
+ def _version(binary: str) -> str | None:
15
+ try:
16
+ proc = subprocess.run(
17
+ [binary, "--version"],
18
+ capture_output=True,
19
+ text=True,
20
+ timeout=5,
21
+ check=False,
22
+ )
23
+ except (OSError, subprocess.TimeoutExpired):
24
+ return None
25
+ line = (proc.stdout or proc.stderr or "").strip().splitlines()
26
+ return line[0] if line else None
27
+
28
+
29
+ def tool_info() -> dict[str, Any]:
30
+ path = _which()
31
+ return {
32
+ "available": path is not None,
33
+ "path": path,
34
+ "version": _version(path) if path else None,
35
+ }
36
+
37
+
38
+ def _match_from_ast(obj: dict[str, Any]) -> dict[str, Any] | None:
39
+ if not isinstance(obj, dict):
40
+ return None
41
+ file_path = obj.get("file")
42
+ text = obj.get("text")
43
+ if not isinstance(file_path, str):
44
+ return None
45
+ if not isinstance(text, str):
46
+ text = obj.get("lines") if isinstance(obj.get("lines"), str) else ""
47
+ rng = obj.get("range") or {}
48
+ start = rng.get("start") or {}
49
+ end = rng.get("end") or {}
50
+ start_line = start.get("line")
51
+ start_col = start.get("column")
52
+ end_line = end.get("line")
53
+ end_col = end.get("column")
54
+ metavars: dict[str, str] = {}
55
+ meta = obj.get("metaVariables") or {}
56
+ single = meta.get("single") if isinstance(meta, dict) else {}
57
+ if isinstance(single, dict):
58
+ for key, value in single.items():
59
+ if isinstance(value, dict) and isinstance(value.get("text"), str):
60
+ metavars[str(key)] = value["text"]
61
+ language = obj.get("language")
62
+ return {
63
+ "file": file_path,
64
+ "line": start_line + 1 if isinstance(start_line, int) else None,
65
+ "column": start_col + 1 if isinstance(start_col, int) else None,
66
+ "endLine": end_line + 1 if isinstance(end_line, int) else None,
67
+ "endColumn": end_col + 1 if isinstance(end_col, int) else None,
68
+ "text": text.rstrip("\n") if isinstance(text, str) else "",
69
+ "strategy": "ast-grep",
70
+ "language": language if isinstance(language, str) else None,
71
+ "metavariables": metavars,
72
+ }
73
+
74
+
75
+ def parse_ast_grep_stdout(stdout: str) -> list[dict[str, Any]]:
76
+ text = (stdout or "").strip()
77
+ if not text:
78
+ return []
79
+ matches: list[dict[str, Any]] = []
80
+ try:
81
+ parsed = json.loads(text)
82
+ if isinstance(parsed, list):
83
+ for item in parsed:
84
+ match = _match_from_ast(item) if isinstance(item, dict) else None
85
+ if match:
86
+ matches.append(match)
87
+ return matches
88
+ if isinstance(parsed, dict):
89
+ match = _match_from_ast(parsed)
90
+ return [match] if match else []
91
+ except ValueError:
92
+ pass
93
+ for line in text.splitlines():
94
+ line = line.strip()
95
+ if not line:
96
+ continue
97
+ try:
98
+ obj = json.loads(line)
99
+ except ValueError:
100
+ continue
101
+ match = _match_from_ast(obj) if isinstance(obj, dict) else None
102
+ if match:
103
+ matches.append(match)
104
+ return matches
105
+
106
+
107
+ def run_ast_grep(
108
+ repo: Path,
109
+ pattern: str,
110
+ globs: list[str],
111
+ limit: int,
112
+ timeout_s: float,
113
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
114
+ binary = _which()
115
+ if not binary:
116
+ return [], [{"tool": "ast-grep", "message": "ast-grep not found on PATH", "origin": "MEASURED"}]
117
+ args = [binary, "run", "-p", pattern, "--json=stream", str(repo)]
118
+ for glob in globs:
119
+ args.extend(["--globs", glob])
120
+ try:
121
+ proc = subprocess.run(
122
+ args,
123
+ capture_output=True,
124
+ text=True,
125
+ timeout=timeout_s,
126
+ check=False,
127
+ )
128
+ except subprocess.TimeoutExpired:
129
+ return [], [{"tool": "ast-grep", "message": "ast-grep timed out", "origin": "MEASURED"}]
130
+ except OSError as exc:
131
+ return [], [{"tool": "ast-grep", "message": str(exc), "origin": "MEASURED"}]
132
+
133
+ stdout = proc.stdout or ""
134
+ if proc.returncode not in (0, 1) and not stdout.strip():
135
+ retry = [binary, "run", "-p", pattern, "--json", str(repo)]
136
+ try:
137
+ proc = subprocess.run(
138
+ retry,
139
+ capture_output=True,
140
+ text=True,
141
+ timeout=timeout_s,
142
+ check=False,
143
+ )
144
+ stdout = proc.stdout or ""
145
+ except (OSError, subprocess.TimeoutExpired) as exc:
146
+ return [], [{"tool": "ast-grep", "message": str(exc), "origin": "MEASURED"}]
147
+
148
+ matches = parse_ast_grep_stdout(stdout)[:limit]
149
+ errors: list[dict[str, Any]] = []
150
+ if proc.returncode not in (0, 1) and not matches:
151
+ err = (proc.stderr or "").strip() or f"ast-grep exit {proc.returncode}"
152
+ errors.append({"tool": "ast-grep", "message": err, "origin": "MEASURED"})
153
+ return matches, errors
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from typing import Any
6
+
7
+ PROTOCOL_VERSION = 1
8
+
9
+ INTENTS = ("TEXT", "STRUCTURAL", "RELATIONSHIP", "IMPACT", "SEMANTIC", "UNKNOWN")
10
+
11
+ IMPACT_HINTS = (
12
+ "impact of",
13
+ "what breaks",
14
+ "if i change",
15
+ "if i modify",
16
+ "affected tests",
17
+ "what can break",
18
+ )
19
+ RELATIONSHIP_HINTS = (
20
+ "who calls",
21
+ "callers of",
22
+ "depends on",
23
+ "dependencies of",
24
+ "path between",
25
+ "connected to",
26
+ "imports of",
27
+ "what depends",
28
+ )
29
+ SEMANTIC_HINTS = ("similar to", "like this code", "meaning of")
30
+
31
+
32
+ def read_request(raw: str | None = None) -> dict[str, Any]:
33
+ text = sys.stdin.read() if raw is None else raw
34
+ text = text.strip()
35
+ if not text:
36
+ raise ValueError("empty request")
37
+ data = json.loads(text)
38
+ if not isinstance(data, dict):
39
+ raise ValueError("request must be a JSON object")
40
+ return data
41
+
42
+
43
+ def write_response(payload: dict[str, Any]) -> None:
44
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False))
45
+ sys.stdout.write("\n")
46
+ sys.stdout.flush()
47
+
48
+
49
+ def error_response(message: str, **extra: Any) -> dict[str, Any]:
50
+ body: dict[str, Any] = {
51
+ "schemaVersion": PROTOCOL_VERSION,
52
+ "ok": False,
53
+ "error": message,
54
+ }
55
+ body.update(extra)
56
+ return body
57
+
58
+
59
+ def classify_intent(
60
+ query: str | None,
61
+ structural_pattern: str | None,
62
+ explicit: str | None,
63
+ ) -> str:
64
+ if explicit:
65
+ value = explicit.strip().upper()
66
+ if value in INTENTS and value != "UNKNOWN":
67
+ return value
68
+ if structural_pattern:
69
+ return "STRUCTURAL"
70
+ q = (query or "").strip()
71
+ if any(token in q for token in ("$$$", "$")):
72
+ return "STRUCTURAL"
73
+ low = q.lower()
74
+ if any(hint in low for hint in IMPACT_HINTS):
75
+ return "IMPACT"
76
+ if any(hint in low for hint in RELATIONSHIP_HINTS):
77
+ return "RELATIONSHIP"
78
+ if any(hint in low for hint in SEMANTIC_HINTS):
79
+ return "SEMANTIC"
80
+ if explicit and explicit.strip().upper() == "UNKNOWN":
81
+ return "UNKNOWN"
82
+ if q:
83
+ return "TEXT"
84
+ return "UNKNOWN"
85
+
86
+
87
+ def strategies_for(intent: str) -> list[str]:
88
+ if intent == "TEXT":
89
+ return ["rg"]
90
+ if intent == "STRUCTURAL":
91
+ return ["ast-grep"]
92
+ if intent in {"RELATIONSHIP", "IMPACT"}:
93
+ return ["graph"]
94
+ if intent == "SEMANTIC":
95
+ return []
96
+ return ["rg", "ast-grep"]