pi-agent-flow 1.2.3 → 1.2.5

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,74 @@
1
+ /**
2
+ * batch — rendering helpers for tool calls and results.
3
+ */
4
+
5
+ import * as os from "node:os";
6
+ import { Text, TruncatedText } from "@mariozechner/pi-tui";
7
+ import type { BatchTheme, OpResult } from "./constants.js";
8
+
9
+ function shortenPath(p: string): string {
10
+ const home = os.homedir();
11
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
12
+ }
13
+
14
+ function extractBatchOps(args: Record<string, unknown>): Array<{ o: string; p: string; e?: unknown[] }> {
15
+ let rawOps: unknown[];
16
+ if (Array.isArray(args.o)) rawOps = args.o;
17
+ else if (Array.isArray(args.op)) rawOps = args.op;
18
+ else if (Array.isArray(args.operations)) rawOps = args.operations;
19
+ else if (Array.isArray(args)) rawOps = args;
20
+ else rawOps = [];
21
+
22
+ return rawOps
23
+ .filter((op): op is Record<string, unknown> => !!op && typeof op === "object")
24
+ .map((op) => {
25
+ const opName = String(op.o ?? op.op ?? "?");
26
+ const opPath = String(op.p ?? op.path ?? "?");
27
+ const edits = Array.isArray(op.e) ? op.e : Array.isArray(op.edits) ? op.edits : undefined;
28
+ return { o: opName, p: opPath, e: edits };
29
+ });
30
+ }
31
+
32
+ function formatBatchCall(args: Record<string, unknown>): string {
33
+ const ops = extractBatchOps(args);
34
+ if (ops.length === 0) return "batch (empty)";
35
+
36
+ const parts: string[] = [];
37
+ for (const op of ops) {
38
+ const shortPath = shortenPath(op.p);
39
+ if (op.o === "edit" && op.e && op.e.length > 1) {
40
+ parts.push(`edit ${shortPath} (${op.e.length} blocks)`);
41
+ } else {
42
+ parts.push(`${op.o} ${shortPath}`);
43
+ }
44
+ }
45
+
46
+ if (parts.length <= 3) {
47
+ return parts.join(", ");
48
+ }
49
+ return `${parts.slice(0, 2).join(", ")} +${parts.length - 2} more`;
50
+ }
51
+
52
+ export function renderBatchCall(args: Record<string, unknown>, theme: BatchTheme): Text {
53
+ const summary = formatBatchCall(args);
54
+ return new Text(theme.fg("muted", "batch ") + theme.fg("accent", summary), 0, 0);
55
+ }
56
+
57
+ export function renderBatchResult(
58
+ result: { content?: Array<{ type: string; text?: string }> },
59
+ expanded: boolean,
60
+ _theme: BatchTheme,
61
+ _args?: Record<string, unknown>,
62
+ ): Text | TruncatedText {
63
+ const fullText = result.content?.find((c) => c.type === "text")?.text ?? "";
64
+ if (!expanded) {
65
+ const summary = fullText.split("\n")[0] ?? "";
66
+ return new TruncatedText(summary, 0, 0);
67
+ }
68
+ return new Text(fullText, 0, 0);
69
+ }
70
+
71
+ export function renderBatchReadCall(args: Record<string, unknown>, theme: BatchTheme): Text {
72
+ const summary = formatBatchCall(args);
73
+ return new Text(theme.fg("muted", "batch_read ") + theme.fg("accent", summary), 0, 0);
74
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * batch — symbol extraction and context-map building.
3
+ *
4
+ * Parses source files to produce a navigable symbol map for context-aware
5
+ * reading of large files.
6
+ */
7
+
8
+ import * as path from "node:path";
9
+ import {
10
+ type ContextLanguage,
11
+ type ContextMapEntry,
12
+ type FileContextMap,
13
+ MAX_CONTEXT_MAP_ENTRIES,
14
+ } from "./constants.js";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Symbol extraction
18
+ // ---------------------------------------------------------------------------
19
+
20
+ function countLeadingWhitespace(line: string): number {
21
+ return line.match(/^\s*/)?.[0].length ?? 0;
22
+ }
23
+
24
+ function findBraceBlockEnd(lines: string[], startIndex: number): number {
25
+ let depth = 0;
26
+ let sawOpenBrace = false;
27
+
28
+ for (let i = startIndex; i < lines.length; i++) {
29
+ for (const char of lines[i]) {
30
+ if (char === "{") {
31
+ depth++;
32
+ sawOpenBrace = true;
33
+ } else if (char === "}") {
34
+ depth--;
35
+ }
36
+ }
37
+ if (sawOpenBrace && depth <= 0) return i + 1;
38
+ }
39
+
40
+ return sawOpenBrace ? lines.length : startIndex + 1;
41
+ }
42
+
43
+ function findStatementEnd(lines: string[], startIndex: number): number {
44
+ for (let i = startIndex; i < lines.length; i++) {
45
+ if (lines[i].includes(";")) return i + 1;
46
+ }
47
+ return startIndex + 1;
48
+ }
49
+
50
+ function findIndentedBlockEnd(lines: string[], startIndex: number, indent: number): number {
51
+ for (let i = startIndex + 1; i < lines.length; i++) {
52
+ const trimmed = lines[i].trim();
53
+ if (!trimmed || trimmed.startsWith("#")) continue;
54
+ if (countLeadingWhitespace(lines[i]) <= indent) return i;
55
+ }
56
+ return lines.length;
57
+ }
58
+
59
+ function findYamlBlockEnd(lines: string[], startIndex: number, indent: number): number {
60
+ for (let i = startIndex + 1; i < lines.length; i++) {
61
+ const trimmed = lines[i].trim();
62
+ if (!trimmed || trimmed.startsWith("#")) continue;
63
+ if (trimmed === "---" || countLeadingWhitespace(lines[i]) <= indent) return i;
64
+ }
65
+ return lines.length;
66
+ }
67
+
68
+ function extractTsJsSymbols(lines: string[]): ContextMapEntry[] {
69
+ const entries: ContextMapEntry[] = [];
70
+ const classes: ContextMapEntry[] = [];
71
+
72
+ for (let i = 0; i < lines.length; i++) {
73
+ const line = lines[i];
74
+ let match = line.match(/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/);
75
+ if (match) {
76
+ entries.push({ kind: "function", name: match[1], startLine: i + 1, endLine: findBraceBlockEnd(lines, i) });
77
+ continue;
78
+ }
79
+
80
+ match = line.match(/^\s*(?:export\s+)?(?:default\s+)?class\s+([A-Za-z_$][\w$]*)\b/);
81
+ if (match) {
82
+ const entry = { kind: "class", name: match[1], startLine: i + 1, endLine: findBraceBlockEnd(lines, i) };
83
+ entries.push(entry);
84
+ classes.push(entry);
85
+ continue;
86
+ }
87
+
88
+ match = line.match(/^\s*(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)\b/);
89
+ if (match) {
90
+ entries.push({ kind: "interface", name: match[1], startLine: i + 1, endLine: findBraceBlockEnd(lines, i) });
91
+ continue;
92
+ }
93
+
94
+ match = line.match(/^\s*(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\b/);
95
+ if (match) {
96
+ entries.push({ kind: "type", name: match[1], startLine: i + 1, endLine: line.includes("{") ? findBraceBlockEnd(lines, i) : findStatementEnd(lines, i) });
97
+ continue;
98
+ }
99
+
100
+ match = line.match(/^\s*(?:export\s+)?enum\s+([A-Za-z_$][\w$]*)\b/);
101
+ if (match) {
102
+ entries.push({ kind: "enum", name: match[1], startLine: i + 1, endLine: findBraceBlockEnd(lines, i) });
103
+ continue;
104
+ }
105
+
106
+ match = line.match(/^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/);
107
+ if (match) {
108
+ entries.push({ kind: "function", name: match[1], startLine: i + 1, endLine: line.includes("{") ? findBraceBlockEnd(lines, i) : findStatementEnd(lines, i) });
109
+ }
110
+ }
111
+
112
+ const methodNameBlacklist = new Set(["if", "for", "while", "switch", "catch", "function"]);
113
+ for (const classEntry of classes) {
114
+ for (let i = classEntry.startLine; i < classEntry.endLine - 1 && i < lines.length; i++) {
115
+ const match = lines[i].match(/^\s*(?:(?:public|private|protected|static|async|override|readonly)\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]+>)?\s*\([^)]*\)\s*(?::\s*[^={]+)?\s*\{/);
116
+ if (!match || methodNameBlacklist.has(match[1])) continue;
117
+ entries.push({
118
+ kind: "method",
119
+ name: `${classEntry.name}.${match[1]}`,
120
+ parent: classEntry.name,
121
+ startLine: i + 1,
122
+ endLine: findBraceBlockEnd(lines, i),
123
+ });
124
+ }
125
+ }
126
+
127
+ return entries;
128
+ }
129
+
130
+ function extractPythonSymbols(lines: string[]): ContextMapEntry[] {
131
+ const entries: ContextMapEntry[] = [];
132
+ const classes: Array<ContextMapEntry & { indent: number }> = [];
133
+
134
+ for (let i = 0; i < lines.length; i++) {
135
+ const match = lines[i].match(/^(\s*)class\s+([A-Za-z_]\w*)\b/);
136
+ if (!match) continue;
137
+ const indent = match[1].length;
138
+ const entry = { kind: "class", name: match[2], startLine: i + 1, endLine: findIndentedBlockEnd(lines, i, indent), indent };
139
+ classes.push(entry);
140
+ entries.push(entry);
141
+ }
142
+
143
+ for (let i = 0; i < lines.length; i++) {
144
+ const match = lines[i].match(/^(\s*)(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/);
145
+ if (!match) continue;
146
+ const indent = match[1].length;
147
+ const parent = classes.find((cls) => i + 1 > cls.startLine && i + 1 < cls.endLine && indent > cls.indent);
148
+ entries.push({
149
+ kind: parent ? "method" : "function",
150
+ name: parent ? `${parent.name}.${match[2]}` : match[2],
151
+ parent: parent?.name,
152
+ startLine: i + 1,
153
+ endLine: findIndentedBlockEnd(lines, i, indent),
154
+ });
155
+ }
156
+
157
+ return entries;
158
+ }
159
+
160
+ function extractTerraformSymbols(lines: string[]): ContextMapEntry[] {
161
+ const entries: ContextMapEntry[] = [];
162
+
163
+ for (let i = 0; i < lines.length; i++) {
164
+ const line = lines[i];
165
+ let match = line.match(/^\s*(resource|data)\s+"([^"]+)"\s+"([^"]+)"\s*\{/);
166
+ if (match) {
167
+ entries.push({ kind: match[1], name: `${match[2]}.${match[3]}`, startLine: i + 1, endLine: findBraceBlockEnd(lines, i) });
168
+ continue;
169
+ }
170
+
171
+ match = line.match(/^\s*(module|variable|output|provider)\s+"([^"]+)"\s*\{/);
172
+ if (match) {
173
+ entries.push({ kind: match[1], name: match[2], startLine: i + 1, endLine: findBraceBlockEnd(lines, i) });
174
+ continue;
175
+ }
176
+
177
+ match = line.match(/^\s*(locals|terraform)\s*\{/);
178
+ if (match) {
179
+ entries.push({ kind: match[1], name: match[1], startLine: i + 1, endLine: findBraceBlockEnd(lines, i) });
180
+ continue;
181
+ }
182
+
183
+ match = line.match(/^([A-Za-z_][\w-]*)\s*=/);
184
+ if (match) {
185
+ entries.push({ kind: "assignment", name: match[1], startLine: i + 1, endLine: i + 1 });
186
+ }
187
+ }
188
+
189
+ return entries;
190
+ }
191
+
192
+ function extractYamlSymbols(lines: string[]): ContextMapEntry[] {
193
+ const entries: ContextMapEntry[] = [];
194
+
195
+ let docStart = 0;
196
+ for (let i = 0; i <= lines.length; i++) {
197
+ if (i < lines.length && lines[i].trim() !== "---") continue;
198
+ const docEnd = i === lines.length ? lines.length : i;
199
+ const docLines = lines.slice(docStart, docEnd);
200
+ const kindLine = docLines.findIndex((line) => /^kind:\s*\S+/.test(line));
201
+ const nameLine = docLines.findIndex((line) => /^\s{2}name:\s*\S+/.test(line));
202
+ if (kindLine >= 0 && nameLine >= 0) {
203
+ const kind = docLines[kindLine].replace(/^kind:\s*/, "").trim();
204
+ const name = docLines[nameLine].replace(/^\s{2}name:\s*/, "").trim();
205
+ entries.push({ kind, name, startLine: docStart + 1, endLine: Math.max(docStart + 1, docEnd) });
206
+ }
207
+ docStart = i + 1;
208
+ }
209
+
210
+ for (let i = 0; i < lines.length; i++) {
211
+ const topLevel = lines[i].match(/^([A-Za-z_][\w.-]*):\s*/);
212
+ if (topLevel) {
213
+ const key = topLevel[1];
214
+ entries.push({ kind: "section", name: key, startLine: i + 1, endLine: findYamlBlockEnd(lines, i, 0) });
215
+
216
+ if (["jobs", "services", "volumes", "networks"].includes(key)) {
217
+ for (let j = i + 1; j < lines.length; j++) {
218
+ const trimmed = lines[j].trim();
219
+ if (!trimmed || trimmed.startsWith("#")) continue;
220
+ const indent = countLeadingWhitespace(lines[j]);
221
+ if (indent <= 0) break;
222
+ const child = lines[j].match(/^\s{2}([A-Za-z0-9_.-]+):\s*/);
223
+ if (!child) continue;
224
+ const childKind = key === "jobs" ? "job" : key.slice(0, -1);
225
+ entries.push({ kind: childKind, name: child[1], startLine: j + 1, endLine: findYamlBlockEnd(lines, j, 2) });
226
+ }
227
+ }
228
+ }
229
+
230
+ const step = lines[i].match(/^\s*-\s+(?:name:\s*(.+)|uses:\s*(.+)|run:\s*(.+))/);
231
+ if (step) {
232
+ const name = (step[1] ?? step[2] ?? step[3] ?? "step").trim();
233
+ entries.push({ kind: "step", name: name.slice(0, 120), startLine: i + 1, endLine: i + 1 });
234
+ }
235
+ }
236
+
237
+ return entries;
238
+ }
239
+
240
+ function findDockerInstructionEnd(lines: string[], startIndex: number): number {
241
+ let endIndex = startIndex;
242
+ while (endIndex + 1 < lines.length && lines[endIndex].trimEnd().endsWith("\\")) {
243
+ endIndex++;
244
+ }
245
+ return endIndex + 1;
246
+ }
247
+
248
+ function extractDockerfileSymbols(lines: string[]): ContextMapEntry[] {
249
+ const entries: ContextMapEntry[] = [];
250
+ const fromLines: number[] = [];
251
+
252
+ for (let i = 0; i < lines.length; i++) {
253
+ if (/^\s*FROM\s+/i.test(lines[i])) fromLines.push(i);
254
+ }
255
+
256
+ for (let idx = 0; idx < fromLines.length; idx++) {
257
+ const i = fromLines[idx];
258
+ const nextFrom = fromLines[idx + 1] ?? lines.length;
259
+ const match = lines[i].match(/^\s*FROM\s+(\S+)(?:\s+AS\s+(\S+))?/i);
260
+ const image = match?.[1] ?? "unknown";
261
+ const alias = match?.[2];
262
+ entries.push({ kind: "stage", name: alias ? `${alias} FROM ${image}` : image, startLine: i + 1, endLine: nextFrom });
263
+ }
264
+
265
+ const important = /^(RUN|COPY|ADD|ENTRYPOINT|CMD|EXPOSE|ENV|ARG|WORKDIR)\b\s*(.*)/i;
266
+ for (let i = 0; i < lines.length; i++) {
267
+ const match = lines[i].trim().match(important);
268
+ if (!match) continue;
269
+ entries.push({ kind: match[1].toUpperCase(), name: (match[2] || match[1]).slice(0, 120), startLine: i + 1, endLine: findDockerInstructionEnd(lines, i) });
270
+ }
271
+
272
+ return entries;
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Language detection
277
+ // ---------------------------------------------------------------------------
278
+
279
+ function detectContextLanguage(filePath: string): ContextLanguage {
280
+ const base = path.basename(filePath);
281
+ const lowerBase = base.toLowerCase();
282
+ const ext = path.extname(lowerBase);
283
+
284
+ if (
285
+ lowerBase === "dockerfile" ||
286
+ lowerBase.startsWith("dockerfile.") ||
287
+ lowerBase === ".dockerfile" ||
288
+ lowerBase.endsWith(".dockerfile")
289
+ ) {
290
+ return "dockerfile";
291
+ }
292
+
293
+ if ([".ts", ".tsx", ".mts", ".cts"].includes(ext)) return "typescript";
294
+ if ([".js", ".jsx", ".mjs", ".cjs"].includes(ext)) return "javascript";
295
+ if ([".py", ".pyw"].includes(ext)) return "python";
296
+ if ([".tf", ".tfvars"].includes(ext)) return "terraform";
297
+ if (ext === ".hcl") return "hcl";
298
+ if ([".yml", ".yaml"].includes(ext)) return "yaml";
299
+ return "plain";
300
+ }
301
+
302
+ export function buildFileContextMap(filePath: string, lines: string[]): FileContextMap {
303
+ const language = detectContextLanguage(filePath);
304
+ let symbols: ContextMapEntry[] = [];
305
+
306
+ try {
307
+ switch (language) {
308
+ case "typescript":
309
+ case "javascript":
310
+ symbols = extractTsJsSymbols(lines);
311
+ break;
312
+ case "python":
313
+ symbols = extractPythonSymbols(lines);
314
+ break;
315
+ case "terraform":
316
+ case "hcl":
317
+ symbols = extractTerraformSymbols(lines);
318
+ break;
319
+ case "yaml":
320
+ symbols = extractYamlSymbols(lines);
321
+ break;
322
+ case "dockerfile":
323
+ symbols = extractDockerfileSymbols(lines);
324
+ break;
325
+ default:
326
+ symbols = [];
327
+ }
328
+ } catch {
329
+ symbols = [];
330
+ }
331
+
332
+ const sorted = symbols
333
+ .filter((entry) => entry.startLine > 0 && entry.endLine >= entry.startLine)
334
+ .sort((a, b) => a.startLine - b.startLine || a.endLine - b.endLine);
335
+
336
+ return {
337
+ language,
338
+ symbols: sorted.slice(0, MAX_CONTEXT_MAP_ENTRIES),
339
+ symbolsTruncated: sorted.length > MAX_CONTEXT_MAP_ENTRIES || undefined,
340
+ };
341
+ }