@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +7 -7
  3. package/scripts/format-prompts.ts +3 -3
  4. package/src/config/prompt-templates.ts +0 -5
  5. package/src/config/settings-schema.ts +38 -0
  6. package/src/eval/eval.lark +10 -31
  7. package/src/eval/index.ts +1 -0
  8. package/src/eval/parse.ts +156 -255
  9. package/src/eval/sniff.ts +28 -0
  10. package/src/export/html/template.css +38 -0
  11. package/src/export/html/template.generated.ts +1 -1
  12. package/src/export/html/template.js +209 -15
  13. package/src/hashline/constants.ts +20 -0
  14. package/src/hashline/grammar.lark +16 -23
  15. package/src/hashline/hash.ts +4 -34
  16. package/src/hashline/input.ts +16 -2
  17. package/src/hashline/parser.ts +12 -1
  18. package/src/internal-urls/agent-protocol.ts +1 -0
  19. package/src/internal-urls/artifact-protocol.ts +1 -0
  20. package/src/internal-urls/docs-index.generated.ts +2 -1
  21. package/src/internal-urls/jobs-protocol.ts +1 -0
  22. package/src/internal-urls/local-protocol.ts +1 -0
  23. package/src/internal-urls/mcp-protocol.ts +1 -0
  24. package/src/internal-urls/memory-protocol.ts +1 -0
  25. package/src/internal-urls/pi-protocol.ts +1 -0
  26. package/src/internal-urls/router.ts +2 -1
  27. package/src/internal-urls/rule-protocol.ts +1 -0
  28. package/src/internal-urls/skill-protocol.ts +1 -0
  29. package/src/internal-urls/types.ts +18 -2
  30. package/src/prompts/system/custom-system-prompt.md +0 -2
  31. package/src/prompts/system/now-prompt.md +7 -0
  32. package/src/prompts/system/project-prompt.md +2 -0
  33. package/src/prompts/system/subagent-system-prompt.md +18 -9
  34. package/src/prompts/system/subagent-user-prompt.md +1 -10
  35. package/src/prompts/system/system-prompt.md +154 -233
  36. package/src/prompts/tools/bash.md +0 -24
  37. package/src/prompts/tools/eval.md +26 -13
  38. package/src/session/agent-session.ts +49 -17
  39. package/src/system-prompt.ts +8 -9
  40. package/src/task/executor.ts +9 -5
  41. package/src/task/index.ts +38 -31
  42. package/src/tools/bash.ts +15 -41
  43. package/src/tools/eval.ts +13 -36
  44. package/src/tools/path-utils.ts +21 -1
  45. package/src/tools/read.ts +69 -27
  46. package/src/tools/search.ts +13 -1
  47. package/src/utils/file-display-mode.ts +11 -5
  48. package/src/task/template.ts +0 -47
  49. package/src/tools/bash-normalize.ts +0 -107
@@ -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
- return { path: rawPath.slice(0, colon), sel: candidate };
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
- function parseSel(sel: string | undefined): ParsedSelector {
470
- if (!sel || sel.length === 0) return { kind: "none" };
471
- if (sel.toLowerCase() === "raw") return { kind: "raw" };
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
- const rawStart = Number.parseInt(lineMatch[1]!, 10);
475
- if (rawStart < 1) {
476
- throw new ToolError("Line selector 0 is invalid; lines are 1-indexed. Use :1.");
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
- const sep = lineMatch[2];
479
- const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
480
- let rawEnd: number | undefined;
481
- if (sep === "+") {
482
- if (rhs === undefined || rhs < 1) {
483
- throw new ToolError(`Invalid range ${rawStart}+${rhs ?? 0}: count must be >= 1.`);
484
- }
485
- rawEnd = rawStart + rhs - 1;
486
- } else if (sep === "-") {
487
- if (rhs === undefined || rhs < rawStart) {
488
- throw new ToolError(`Invalid range ${rawStart}-${rhs ?? 0}: end must be >= start.`);
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
- return { kind: "lines", startLine: rawStart, endLine: rawEnd };
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.kind === "raw" },
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.kind !== "raw") {
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.kind === "raw";
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(url: string, offset?: number, limit?: number): Promise<AgentToolResult<ReadToolDetails>> {
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
 
@@ -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
- * and when the caller signals a `raw` read raw output should be returned as-is
26
- * without injecting hashline anchors or line numbers.
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(session: FileDisplayModeSession, options?: { raw?: boolean }): FileDisplayMode {
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 hashLines = !raw && hasEditTool && usesHashLineAnchors && settings.get("readHashLines") !== false;
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),
@@ -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
- }