@oh-my-pi/pi-coding-agent 14.9.2 → 14.9.3
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/CHANGELOG.md +45 -0
- package/package.json +7 -7
- package/scripts/format-prompts.ts +3 -3
- package/src/config/prompt-templates.ts +0 -5
- package/src/config/settings-schema.ts +38 -0
- package/src/eval/eval.lark +10 -31
- package/src/eval/index.ts +1 -0
- package/src/eval/parse.ts +156 -255
- package/src/eval/sniff.ts +28 -0
- package/src/export/html/template.css +38 -0
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +209 -15
- package/src/hashline/constants.ts +20 -0
- package/src/hashline/grammar.lark +16 -23
- package/src/hashline/hash.ts +4 -34
- package/src/hashline/input.ts +16 -2
- package/src/hashline/parser.ts +12 -1
- package/src/internal-urls/agent-protocol.ts +1 -0
- package/src/internal-urls/artifact-protocol.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +2 -1
- package/src/internal-urls/jobs-protocol.ts +1 -0
- package/src/internal-urls/local-protocol.ts +1 -0
- package/src/internal-urls/mcp-protocol.ts +1 -0
- package/src/internal-urls/memory-protocol.ts +1 -0
- package/src/internal-urls/pi-protocol.ts +1 -0
- package/src/internal-urls/router.ts +2 -1
- package/src/internal-urls/rule-protocol.ts +1 -0
- package/src/internal-urls/skill-protocol.ts +1 -0
- package/src/internal-urls/types.ts +18 -2
- package/src/prompts/system/custom-system-prompt.md +0 -2
- package/src/prompts/system/now-prompt.md +7 -0
- package/src/prompts/system/project-prompt.md +2 -0
- package/src/prompts/system/subagent-system-prompt.md +18 -9
- package/src/prompts/system/subagent-user-prompt.md +1 -10
- package/src/prompts/system/system-prompt.md +154 -233
- package/src/prompts/tools/bash.md +0 -24
- package/src/prompts/tools/eval.md +26 -13
- package/src/session/agent-session.ts +49 -17
- package/src/system-prompt.ts +8 -9
- package/src/task/executor.ts +9 -5
- package/src/task/index.ts +38 -31
- package/src/tools/bash.ts +15 -41
- package/src/tools/eval.ts +13 -36
- package/src/tools/path-utils.ts +21 -1
- package/src/tools/read.ts +69 -27
- package/src/tools/search.ts +13 -1
- package/src/utils/file-display-mode.ts +11 -5
- package/src/task/template.ts +0 -47
- package/src/tools/bash-normalize.ts +0 -107
package/src/tools/path-utils.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
|
6
6
|
|
|
7
7
|
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
8
8
|
const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+)?|raw)$/i;
|
|
9
|
+
const FILE_LINE_RANGE_ONLY_RE = /^L?\d+(?:[-+]L?\d+)?$/i;
|
|
10
|
+
const FILE_RAW_ONLY_RE = /^raw$/i;
|
|
9
11
|
const NARROW_NO_BREAK_SPACE = "\u202F";
|
|
10
12
|
const TOP_LEVEL_INTERNAL_URL_PREFIXES = [
|
|
11
13
|
"agent://",
|
|
@@ -110,7 +112,25 @@ export function splitPathAndSel(rawPath: string): { path: string; sel?: string }
|
|
|
110
112
|
const candidate = rawPath.slice(colon + 1);
|
|
111
113
|
if (!FILE_LINE_RANGE_RE.test(candidate)) return { path: rawPath };
|
|
112
114
|
|
|
113
|
-
|
|
115
|
+
let basePath = rawPath.slice(0, colon);
|
|
116
|
+
let sel = candidate;
|
|
117
|
+
|
|
118
|
+
// Allow a compound trailing selector: `path:1-50:raw` or `path:raw:1-50`.
|
|
119
|
+
// The two chunks must be one line-range plus one `raw`, in either order.
|
|
120
|
+
const innerColon = basePath.lastIndexOf(":");
|
|
121
|
+
if (innerColon > 0) {
|
|
122
|
+
const innerCandidate = basePath.slice(innerColon + 1);
|
|
123
|
+
const innerIsRaw = FILE_RAW_ONLY_RE.test(innerCandidate);
|
|
124
|
+
const outerIsRaw = FILE_RAW_ONLY_RE.test(candidate);
|
|
125
|
+
const innerIsRange = FILE_LINE_RANGE_ONLY_RE.test(innerCandidate);
|
|
126
|
+
const outerIsRange = FILE_LINE_RANGE_ONLY_RE.test(candidate);
|
|
127
|
+
if ((innerIsRaw && outerIsRange) || (innerIsRange && outerIsRaw)) {
|
|
128
|
+
sel = `${innerCandidate}:${candidate}`;
|
|
129
|
+
basePath = basePath.slice(0, innerColon);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { path: basePath, sel };
|
|
114
134
|
}
|
|
115
135
|
|
|
116
136
|
function assertNotInternalUrl(expanded: string, original: string): void {
|
package/src/tools/read.ts
CHANGED
|
@@ -462,34 +462,67 @@ type ReadParams = ReadToolInput;
|
|
|
462
462
|
type ParsedSelector =
|
|
463
463
|
| { kind: "none" }
|
|
464
464
|
| { kind: "raw" }
|
|
465
|
-
| { kind: "lines"; startLine: number; endLine: number | undefined };
|
|
465
|
+
| { kind: "lines"; startLine: number; endLine: number | undefined; raw?: boolean };
|
|
466
466
|
|
|
467
467
|
const LINE_RANGE_RE = /^L?(\d+)(?:([-+])L?(\d+))?$/i;
|
|
468
468
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
469
|
+
/** Returns true when the selector requested verbatim/raw output (alone or combined with a range). */
|
|
470
|
+
function isRawSelector(parsed: ParsedSelector): boolean {
|
|
471
|
+
return parsed.kind === "raw" || (parsed.kind === "lines" && parsed.raw === true);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function parseLineRangeChunk(sel: string): { startLine: number; endLine: number | undefined } | null {
|
|
472
475
|
const lineMatch = LINE_RANGE_RE.exec(sel);
|
|
473
|
-
if (lineMatch)
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
476
|
+
if (!lineMatch) return null;
|
|
477
|
+
const rawStart = Number.parseInt(lineMatch[1]!, 10);
|
|
478
|
+
if (rawStart < 1) {
|
|
479
|
+
throw new ToolError("Line selector 0 is invalid; lines are 1-indexed. Use :1.");
|
|
480
|
+
}
|
|
481
|
+
const sep = lineMatch[2];
|
|
482
|
+
const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
|
|
483
|
+
let rawEnd: number | undefined;
|
|
484
|
+
if (sep === "+") {
|
|
485
|
+
if (rhs === undefined || rhs < 1) {
|
|
486
|
+
throw new ToolError(`Invalid range ${rawStart}+${rhs ?? 0}: count must be >= 1.`);
|
|
477
487
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
488
|
+
rawEnd = rawStart + rhs - 1;
|
|
489
|
+
} else if (sep === "-") {
|
|
490
|
+
if (rhs === undefined || rhs < rawStart) {
|
|
491
|
+
throw new ToolError(`Invalid range ${rawStart}-${rhs ?? 0}: end must be >= start.`);
|
|
492
|
+
}
|
|
493
|
+
rawEnd = rhs;
|
|
494
|
+
}
|
|
495
|
+
return { startLine: rawStart, endLine: rawEnd };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function parseSel(sel: string | undefined): ParsedSelector {
|
|
499
|
+
if (!sel || sel.length === 0) return { kind: "none" };
|
|
500
|
+
|
|
501
|
+
// Compound selector: `1-50:raw` or `raw:1-50`. Split into chunks and accept
|
|
502
|
+
// any combination of one line range and the literal `raw`.
|
|
503
|
+
if (sel.includes(":")) {
|
|
504
|
+
const chunks = sel.split(":");
|
|
505
|
+
if (chunks.length === 2) {
|
|
506
|
+
const [a, b] = chunks as [string, string];
|
|
507
|
+
const aIsRaw = a.toLowerCase() === "raw";
|
|
508
|
+
const bIsRaw = b.toLowerCase() === "raw";
|
|
509
|
+
const rangeChunk = aIsRaw ? b : bIsRaw ? a : null;
|
|
510
|
+
const rawChunk = aIsRaw ? a : bIsRaw ? b : null;
|
|
511
|
+
if (rangeChunk !== null && rawChunk !== null) {
|
|
512
|
+
const range = parseLineRangeChunk(rangeChunk);
|
|
513
|
+
if (range) {
|
|
514
|
+
return { kind: "lines", startLine: range.startLine, endLine: range.endLine, raw: true };
|
|
515
|
+
}
|
|
489
516
|
}
|
|
490
|
-
rawEnd = rhs;
|
|
491
517
|
}
|
|
492
|
-
|
|
518
|
+
// Unrecognized compound — fall through (sqlite/archive/url consume their own colon syntax).
|
|
519
|
+
return { kind: "none" };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (sel.toLowerCase() === "raw") return { kind: "raw" };
|
|
523
|
+
const range = parseLineRangeChunk(sel);
|
|
524
|
+
if (range) {
|
|
525
|
+
return { kind: "lines", startLine: range.startLine, endLine: range.endLine };
|
|
493
526
|
}
|
|
494
527
|
// Unrecognized selectors fall through; sqlite/archive/url readers consume their own colon syntax.
|
|
495
528
|
return { kind: "none" };
|
|
@@ -653,9 +686,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
653
686
|
entityLabel: string;
|
|
654
687
|
ignoreResultLimits?: boolean;
|
|
655
688
|
raw?: boolean;
|
|
689
|
+
immutable?: boolean;
|
|
656
690
|
},
|
|
657
691
|
): AgentToolResult<ReadToolDetails> {
|
|
658
|
-
const displayMode = resolveFileDisplayMode(this.session, { raw: options.raw });
|
|
692
|
+
const displayMode = resolveFileDisplayMode(this.session, { raw: options.raw, immutable: options.immutable });
|
|
659
693
|
const details = options.details ?? {};
|
|
660
694
|
const allLines = text.split("\n");
|
|
661
695
|
const totalLines = allLines.length;
|
|
@@ -1139,6 +1173,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1139
1173
|
details: { ...cached.details },
|
|
1140
1174
|
sourceUrl: cached.details.finalUrl,
|
|
1141
1175
|
entityLabel: "URL output",
|
|
1176
|
+
immutable: true,
|
|
1142
1177
|
});
|
|
1143
1178
|
}
|
|
1144
1179
|
return executeReadUrl(this.session, { path: parsedUrlTarget.path, raw: parsedUrlTarget.raw }, signal);
|
|
@@ -1150,7 +1185,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1150
1185
|
if (internalRouter?.canHandle(internalTarget.path)) {
|
|
1151
1186
|
const parsed = parseSel(internalTarget.sel);
|
|
1152
1187
|
const { offset, limit } = selToOffsetLimit(parsed);
|
|
1153
|
-
return this.#handleInternalUrl(internalTarget.path, offset, limit);
|
|
1188
|
+
return this.#handleInternalUrl(internalTarget.path, offset, limit, { raw: isRawSelector(parsed) });
|
|
1154
1189
|
}
|
|
1155
1190
|
|
|
1156
1191
|
const archivePath = await this.#resolveArchiveReadPath(readPath, signal);
|
|
@@ -1164,7 +1199,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1164
1199
|
limit,
|
|
1165
1200
|
{ ...archivePath, archiveSubPath: archiveSubPath.path },
|
|
1166
1201
|
signal,
|
|
1167
|
-
{ raw: archiveParsed
|
|
1202
|
+
{ raw: isRawSelector(archiveParsed) },
|
|
1168
1203
|
);
|
|
1169
1204
|
}
|
|
1170
1205
|
|
|
@@ -1293,7 +1328,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1293
1328
|
throw error;
|
|
1294
1329
|
}
|
|
1295
1330
|
}
|
|
1296
|
-
} else if (isNotebookPath(absolutePath) && parsed
|
|
1331
|
+
} else if (isNotebookPath(absolutePath) && !isRawSelector(parsed)) {
|
|
1297
1332
|
const { offset, limit } = selToOffsetLimit(parsed);
|
|
1298
1333
|
return this.#buildInMemoryTextResult(
|
|
1299
1334
|
await readEditableNotebookText(absolutePath, localReadPath),
|
|
@@ -1421,7 +1456,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1421
1456
|
getFileReadCache(this.session).recordContiguous(absolutePath, startLineDisplay, collectedLines);
|
|
1422
1457
|
}
|
|
1423
1458
|
|
|
1424
|
-
const isRawMode = parsed
|
|
1459
|
+
const isRawMode = isRawSelector(parsed);
|
|
1425
1460
|
const shouldAddHashLines = !isRawMode && displayMode.hashLines;
|
|
1426
1461
|
const shouldAddLineNumbers = isRawMode ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
|
|
1427
1462
|
let capturedDisplayContent: { text: string; startLine: number } | undefined;
|
|
@@ -1510,7 +1545,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1510
1545
|
* Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://).
|
|
1511
1546
|
* Supports pagination via offset/limit but rejects them when query extraction is used.
|
|
1512
1547
|
*/
|
|
1513
|
-
async #handleInternalUrl(
|
|
1548
|
+
async #handleInternalUrl(
|
|
1549
|
+
url: string,
|
|
1550
|
+
offset?: number,
|
|
1551
|
+
limit?: number,
|
|
1552
|
+
options?: { raw?: boolean },
|
|
1553
|
+
): Promise<AgentToolResult<ReadToolDetails>> {
|
|
1514
1554
|
const internalRouter = this.session.internalRouter!;
|
|
1515
1555
|
|
|
1516
1556
|
// Check if URL has query extraction (agent:// only).
|
|
@@ -1550,6 +1590,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1550
1590
|
sourceInternal: url,
|
|
1551
1591
|
entityLabel: "resource",
|
|
1552
1592
|
ignoreResultLimits: scheme === "skill",
|
|
1593
|
+
immutable: resource.immutable,
|
|
1594
|
+
raw: options?.raw,
|
|
1553
1595
|
});
|
|
1554
1596
|
}
|
|
1555
1597
|
|
package/src/tools/search.ts
CHANGED
|
@@ -121,7 +121,6 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
121
121
|
const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
|
|
122
122
|
const effectiveMultiline = patternHasNewline;
|
|
123
123
|
|
|
124
|
-
const useHashLines = resolveFileDisplayMode(this.session).hashLines;
|
|
125
124
|
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
|
|
126
125
|
let searchPath: string;
|
|
127
126
|
let scopePath: string;
|
|
@@ -134,6 +133,10 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
134
133
|
}
|
|
135
134
|
const internalRouter = this.session.internalRouter;
|
|
136
135
|
const resolvedPathInputs: string[] = [];
|
|
136
|
+
// Absolute filesystem paths whose source is immutable (e.g. artifact://,
|
|
137
|
+
// pi://, skill://). Hashline anchors are suppressed for these on a
|
|
138
|
+
// per-file basis, leaving editable mixed-in files untouched.
|
|
139
|
+
const immutableSourcePaths = new Set<string>();
|
|
137
140
|
for (const rawPath of rawPaths) {
|
|
138
141
|
if (!internalRouter?.canHandle(rawPath)) {
|
|
139
142
|
resolvedPathInputs.push(rawPath);
|
|
@@ -146,8 +149,13 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
146
149
|
if (!resource.sourcePath) {
|
|
147
150
|
throw new ToolError(`Cannot search internal URL without a backing file: ${rawPath}`);
|
|
148
151
|
}
|
|
152
|
+
if (resource.immutable) {
|
|
153
|
+
immutableSourcePaths.add(path.resolve(resource.sourcePath));
|
|
154
|
+
}
|
|
149
155
|
resolvedPathInputs.push(resource.sourcePath);
|
|
150
156
|
}
|
|
157
|
+
const baseDisplayMode = resolveFileDisplayMode(this.session);
|
|
158
|
+
const immutableDisplayMode = resolveFileDisplayMode(this.session, { immutable: true });
|
|
151
159
|
// Tolerate missing entries in a multi-path call: skip ones whose base
|
|
152
160
|
// directory is gone, and only error if every entry is missing. Single
|
|
153
161
|
// missing path keeps the original ENOENT semantics.
|
|
@@ -336,6 +344,10 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
336
344
|
const modelOut: string[] = [];
|
|
337
345
|
const displayOut: string[] = [];
|
|
338
346
|
const fileMatches = matchesByFile.get(relativePath) ?? [];
|
|
347
|
+
const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
|
|
348
|
+
const useHashLines = immutableSourcePaths.has(absoluteFilePath)
|
|
349
|
+
? immutableDisplayMode.hashLines
|
|
350
|
+
: baseDisplayMode.hashLines;
|
|
339
351
|
const lineNumberWidth = fileMatches.reduce((width, match) => {
|
|
340
352
|
let nextWidth = Math.max(width, String(match.lineNumber).length);
|
|
341
353
|
for (const ctx of match.contextBefore ?? []) {
|
|
@@ -21,17 +21,23 @@ export interface FileDisplayModeSession {
|
|
|
21
21
|
/**
|
|
22
22
|
* Computes effective line display mode from session settings/env.
|
|
23
23
|
* Hashline mode takes precedence and implies line-addressed output everywhere.
|
|
24
|
-
* Hashlines are suppressed when the edit tool is not available (e.g. explore agents)
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* Hashlines are suppressed when the edit tool is not available (e.g. explore agents),
|
|
25
|
+
* when the caller signals a `raw` read, and when the source is `immutable`
|
|
26
|
+
* (e.g. internal URLs like artifact://, agent://, memory:// — there is no edit
|
|
27
|
+
* path that could consume the anchors). Raw output is returned as-is.
|
|
27
28
|
*/
|
|
28
|
-
export function resolveFileDisplayMode(
|
|
29
|
+
export function resolveFileDisplayMode(
|
|
30
|
+
session: FileDisplayModeSession,
|
|
31
|
+
options?: { raw?: boolean; immutable?: boolean },
|
|
32
|
+
): FileDisplayMode {
|
|
29
33
|
const { settings } = session;
|
|
30
34
|
const hasEditTool = session.hasEditTool ?? true;
|
|
31
35
|
const editMode = resolveEditMode(session);
|
|
32
36
|
const usesHashLineAnchors = editMode === "hashline";
|
|
33
37
|
const raw = options?.raw === true;
|
|
34
|
-
const
|
|
38
|
+
const immutable = options?.immutable === true;
|
|
39
|
+
const hashLines =
|
|
40
|
+
!raw && !immutable && hasEditTool && usesHashLineAnchors && settings.get("readHashLines") !== false;
|
|
35
41
|
return {
|
|
36
42
|
hashLines,
|
|
37
43
|
lineNumbers: !raw && (hashLines || settings.get("readLineNumbers") === true),
|
package/src/task/template.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { prompt } from "@oh-my-pi/pi-utils";
|
|
2
|
-
import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.md" with { type: "text" };
|
|
3
|
-
import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode";
|
|
4
|
-
import type { TaskItem } from "./types";
|
|
5
|
-
|
|
6
|
-
interface RenderResult {
|
|
7
|
-
/** Full task text sent to the subagent */
|
|
8
|
-
task: string;
|
|
9
|
-
/** Raw per-task assignment text, without prompt template boilerplate */
|
|
10
|
-
assignment: string;
|
|
11
|
-
id: string;
|
|
12
|
-
description: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Build the full task text from shared context and per-task assignment.
|
|
17
|
-
*
|
|
18
|
-
* If context is provided, it is prepended with a separator.
|
|
19
|
-
*/
|
|
20
|
-
export function renderTemplate(
|
|
21
|
-
context: string | undefined,
|
|
22
|
-
task: TaskItem,
|
|
23
|
-
simpleMode: TaskSimpleMode = "default",
|
|
24
|
-
): RenderResult {
|
|
25
|
-
let { id, description, assignment } = task;
|
|
26
|
-
assignment = assignment.trim();
|
|
27
|
-
const { contextEnabled } = getTaskSimpleModeCapabilities(simpleMode);
|
|
28
|
-
context = contextEnabled ? context?.trim() : undefined;
|
|
29
|
-
|
|
30
|
-
if (!context || !assignment) {
|
|
31
|
-
if (simpleMode === "independent" && assignment) {
|
|
32
|
-
return {
|
|
33
|
-
task: prompt.render(subagentUserPromptTemplate, { assignment, independentMode: true }),
|
|
34
|
-
assignment,
|
|
35
|
-
id,
|
|
36
|
-
description,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
return { task: assignment || context!, assignment: assignment || context!, id, description };
|
|
40
|
-
}
|
|
41
|
-
return {
|
|
42
|
-
task: prompt.render(subagentUserPromptTemplate, { context, assignment, independentMode: false }),
|
|
43
|
-
assignment,
|
|
44
|
-
id,
|
|
45
|
-
description,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bash command normalizer - extracts patterns that are better handled natively.
|
|
3
|
-
*
|
|
4
|
-
* Detects and extracts:
|
|
5
|
-
* - `| head -n N` / `| head -N` - extracted to headLines
|
|
6
|
-
* - `| tail -n N` / `| tail -N` - extracted to tailLines
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export interface NormalizedCommand {
|
|
10
|
-
/** Cleaned command with patterns stripped */
|
|
11
|
-
command: string;
|
|
12
|
-
/** Extracted head line count, if any */
|
|
13
|
-
headLines?: number;
|
|
14
|
-
/** Extracted tail line count, if any */
|
|
15
|
-
tailLines?: number;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Pattern to match trailing pipe to head/tail.
|
|
20
|
-
* Captures: full match, command (head/tail), line count
|
|
21
|
-
*
|
|
22
|
-
* Matches:
|
|
23
|
-
* - `| head -n 50`
|
|
24
|
-
* - `| head -50`
|
|
25
|
-
* - `| tail -n 100`
|
|
26
|
-
* - `| tail -100`
|
|
27
|
-
*
|
|
28
|
-
* Does NOT match head/tail with other flags or without line count.
|
|
29
|
-
*/
|
|
30
|
-
const TRAILING_HEAD_TAIL_PATTERN = /\|\s*(head|tail)\s+(?:-n\s*(\d+)|(-\d+))\s*$/;
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Normalize a bash command by stripping patterns better handled natively.
|
|
34
|
-
*
|
|
35
|
-
* Extracts `| head -n N` and `| tail -n N` suffixes into separate fields
|
|
36
|
-
* so they can be applied post-execution without breaking streaming.
|
|
37
|
-
*
|
|
38
|
-
* Strips `2>&1` since we already merge stdout/stderr.
|
|
39
|
-
*/
|
|
40
|
-
export function normalizeBashCommand(command: string): NormalizedCommand {
|
|
41
|
-
let normalized = command;
|
|
42
|
-
let headLines: number | undefined;
|
|
43
|
-
let tailLines: number | undefined;
|
|
44
|
-
|
|
45
|
-
// Extract trailing head/tail
|
|
46
|
-
const match = normalized.match(TRAILING_HEAD_TAIL_PATTERN);
|
|
47
|
-
if (match) {
|
|
48
|
-
const [fullMatch, cmd, nValue, dashValue] = match;
|
|
49
|
-
const lineCount = nValue ? Number.parseInt(nValue, 10) : Number.parseInt(dashValue.slice(1), 10);
|
|
50
|
-
|
|
51
|
-
if (cmd === "head") {
|
|
52
|
-
headLines = lineCount;
|
|
53
|
-
} else {
|
|
54
|
-
tailLines = lineCount;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
normalized = normalized.slice(0, -fullMatch.length);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Preserve internal whitespace (important for heredocs / indentation-sensitive scripts)
|
|
61
|
-
normalized = normalized.trim();
|
|
62
|
-
|
|
63
|
-
return {
|
|
64
|
-
command: normalized,
|
|
65
|
-
headLines,
|
|
66
|
-
tailLines,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Apply head/tail limits to output text.
|
|
72
|
-
*
|
|
73
|
-
* If both head and tail are specified, head is applied first (take first N lines),
|
|
74
|
-
* then tail is applied (take last M lines of that).
|
|
75
|
-
*/
|
|
76
|
-
export function applyHeadTail(
|
|
77
|
-
text: string,
|
|
78
|
-
headLines?: number,
|
|
79
|
-
tailLines?: number,
|
|
80
|
-
): { text: string; applied: boolean; headApplied?: number; tailApplied?: number } {
|
|
81
|
-
if (!headLines && !tailLines) {
|
|
82
|
-
return { text, applied: false };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
let lines = text.split("\n");
|
|
86
|
-
let headApplied: number | undefined;
|
|
87
|
-
let tailApplied: number | undefined;
|
|
88
|
-
|
|
89
|
-
// Apply head first (keep first N lines)
|
|
90
|
-
if (headLines !== undefined && headLines > 0 && lines.length > headLines) {
|
|
91
|
-
lines = lines.slice(0, headLines);
|
|
92
|
-
headApplied = headLines;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Then apply tail (keep last N lines)
|
|
96
|
-
if (tailLines !== undefined && tailLines > 0 && lines.length > tailLines) {
|
|
97
|
-
lines = lines.slice(-tailLines);
|
|
98
|
-
tailApplied = tailLines;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return {
|
|
102
|
-
text: lines.join("\n"),
|
|
103
|
-
applied: headApplied !== undefined || tailApplied !== undefined,
|
|
104
|
-
headApplied,
|
|
105
|
-
tailApplied,
|
|
106
|
-
};
|
|
107
|
-
}
|