supipowers 1.2.6 → 1.3.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "supipowers",
3
- "version": "1.2.6",
3
+ "version": "1.3.0",
4
4
  "description": "Workflow extension for OMP coding agents.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -33,10 +33,6 @@ Instead use:
33
33
  - `ctx_execute(language: "shell", code: "find ...")` to run in sandbox
34
34
  - `ctx_batch_execute(commands, queries)` for multiple searches
35
35
 
36
- ### Read (full-file, no limit) — BLOCKED
37
- Reading an entire file without a `limit` parameter is blocked.
38
- - If you need to **Edit** the file → re-call Read with `limit` parameter (e.g., `limit: 200`)
39
- - If you need to **analyze or explore** → use `ctx_execute_file(path, language, code)` instead. Only your printed summary enters context.
40
36
 
41
37
  ## REDIRECTED tools — use sandbox equivalents
42
38
 
@@ -46,9 +42,9 @@ For everything else, use:
46
42
  - `ctx_batch_execute(commands, queries)` — run multiple commands + search in ONE call
47
43
  - `ctx_execute(language: "shell", code: "...")` — run in sandbox, only stdout enters context
48
44
 
49
- ### Read (for analysis)
50
- If you are reading a file to **Edit** it Read with `limit` is correct (Edit needs content in context).
51
- If you are reading to **analyze, explore, or summarize** → use `ctx_execute_file(path, language, code)` instead. Only your printed summary enters context. The raw file content stays in the sandbox.
45
+ ### Read (large files)
46
+ Reads are never blocked — they always go through OMP's native read tool so hashline anchors (`N#XX`) are preserved for the edit contract. Large file reads (>110 lines) are automatically compressed to head (80 lines) + tail (30 lines) with a `sel` hint for the omitted section.
47
+ For analysis-only reads where hashlines aren't needed, `ctx_execute_file(path, language, code)` remains more efficient — only your printed summary enters context.
52
48
 
53
49
  ## Tool selection hierarchy
54
50
 
package/src/bootstrap.ts CHANGED
@@ -17,6 +17,7 @@ import { registerModelCommand, handleModel } from "./commands/model.js";
17
17
  import { executeManagerAction } from "./mcp/manager-tool.js";
18
18
  import { registerFixPrCommand } from "./commands/fix-pr.js";
19
19
  import { registerContextCommand, handleContext } from "./commands/context.js";
20
+ import { registerOptimizeContextCommand, handleOptimizeContext } from "./commands/optimize-context.js";
20
21
  import { registerCommitCommand, handleCommit } from "./commands/commit.js";
21
22
  import { loadConfig } from "./config/loader.js";
22
23
  import { registerContextModeHooks } from "./context-mode/hooks.js";
@@ -37,6 +38,7 @@ const TUI_COMMANDS: Record<string, (platform: Platform, ctx: any, args?: string)
37
38
  "supi:mcp": (platform, ctx) => handleMcp(platform, ctx),
38
39
  "supi:model": (platform, ctx) => handleModel(platform, ctx),
39
40
  "supi:context": (platform, ctx) => handleContext(platform, ctx),
41
+ "supi:optimize-context": (platform, ctx) => handleOptimizeContext(platform, ctx),
40
42
  "supi:commit": (platform, ctx, args) => handleCommit(platform, ctx, args),
41
43
  "supi:release": (platform, ctx, args) => handleRelease(platform, ctx, args),
42
44
  };
@@ -68,6 +70,7 @@ export function bootstrap(platform: Platform): void {
68
70
  registerMcpCommand(platform);
69
71
  registerModelCommand(platform);
70
72
  registerContextCommand(platform);
73
+ registerOptimizeContextCommand(platform);
71
74
  registerCommitCommand(platform);
72
75
 
73
76
 
@@ -0,0 +1,202 @@
1
+ import type { Platform, PlatformContext } from "../platform/types.js";
2
+ import {
3
+ parseSystemPrompt,
4
+ parseIndividualSkills,
5
+ } from "../context/analyzer.js";
6
+ import {
7
+ detectTechStack,
8
+ buildContextReport,
9
+ } from "../context/optimizer.js";
10
+ import type { ContextReport } from "../context/optimizer.js";
11
+
12
+ function formatTokens(n: number): string {
13
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n);
14
+ }
15
+
16
+ export function handleOptimizeContext(platform: Platform, ctx: PlatformContext): void {
17
+ void (async () => {
18
+ if (!ctx.hasUI) return;
19
+
20
+ // 1. Detect tech stack
21
+ const techStack = await detectTechStack(platform, ctx.cwd);
22
+
23
+ // 2. Get system prompt
24
+ let systemPrompt = "";
25
+ try {
26
+ systemPrompt = (ctx as any).getSystemPrompt?.() ?? "";
27
+ } catch {
28
+ // getSystemPrompt not available
29
+ }
30
+
31
+ if (!systemPrompt) {
32
+ ctx.ui.notify("System prompt unavailable", "warning");
33
+ return;
34
+ }
35
+
36
+ // 3. Parse
37
+ const sections = parseSystemPrompt(systemPrompt);
38
+ const skills = parseIndividualSkills(systemPrompt);
39
+
40
+ // 4. Build raw report
41
+ const report = buildContextReport(sections, skills, techStack);
42
+
43
+ // 5. Show TUI
44
+ await showReport(platform, ctx, report);
45
+ })().catch((err) => {
46
+ ctx.ui.notify(`Optimize error: ${(err as Error).message}`, "error");
47
+ });
48
+ }
49
+
50
+ export function registerOptimizeContextCommand(platform: Platform): void {
51
+ platform.registerCommand("supi:optimize-context", {
52
+ description: "Analyze context usage and suggest token optimizations",
53
+ async handler(_args: string | undefined, ctx: any) {
54
+ handleOptimizeContext(platform, ctx);
55
+ },
56
+ });
57
+ }
58
+
59
+ // ── Internal ──────────────────────────────────────────────
60
+
61
+ async function showReport(
62
+ platform: Platform,
63
+ ctx: PlatformContext,
64
+ report: ContextReport,
65
+ ): Promise<void> {
66
+ const techList = [
67
+ ...report.techStack.languages,
68
+ ...(report.techStack.runtime ? [report.techStack.runtime] : []),
69
+ ...report.techStack.frameworks,
70
+ ...report.techStack.tools,
71
+ ].join(", ");
72
+
73
+ const lines: string[] = [
74
+ `Tech: ${techList || "unknown"}`,
75
+ `Current: ~${formatTokens(report.totalTokens)} tokens | Target: ~8.0K tokens`,
76
+ "",
77
+ ];
78
+
79
+ // Skills breakdown
80
+ if (report.skills.length === 0) {
81
+ lines.push("No skills detected in system prompt.");
82
+ } else {
83
+ const totalSkillTokens = report.skills.reduce((sum, s) => sum + s.tokens, 0);
84
+ lines.push(`Skills (${report.skills.length} loaded, ~${formatTokens(totalSkillTokens)} tok total):`);
85
+ lines.push("");
86
+
87
+ const sorted = [...report.skills].sort((a, b) => b.tokens - a.tokens);
88
+ for (const s of sorted) {
89
+ lines.push(` ${s.name.padEnd(32)} ~${formatTokens(s.tokens)} tok`);
90
+ }
91
+ }
92
+
93
+ // Non-skill sections
94
+ if (report.sections.length > 0) {
95
+ lines.push("");
96
+ lines.push("Other sections:");
97
+ lines.push("");
98
+
99
+ for (const sec of report.sections) {
100
+ const tok = formatTokens(sec.tokens);
101
+ const note = sec.note ? ` (${sec.note})` : "";
102
+ lines.push(` ${sec.label.padEnd(28)} ~${tok} tok${note}`);
103
+ }
104
+ }
105
+
106
+ const message = lines.join("\n");
107
+
108
+ // confirm() may not be available on all platforms
109
+ const shouldOptimize = ctx.ui.confirm
110
+ ? await ctx.ui.confirm("Context Optimization", message)
111
+ : (await ctx.ui.select("Context Optimization", [message, "▶ Optimize with AI", "Close"]))?.includes("Optimize");
112
+
113
+ if (!shouldOptimize) return;
114
+
115
+ platform.sendMessage(
116
+ {
117
+ customType: "optimize-context",
118
+ content: [{ type: "text", text: buildOptimizationPrompt(report) }],
119
+ display: "none",
120
+ },
121
+ { deliverAs: "steer", triggerTurn: true },
122
+ );
123
+ }
124
+
125
+ function buildOptimizationPrompt(report: ContextReport): string {
126
+ const lines: string[] = [];
127
+
128
+ const techList = [
129
+ ...report.techStack.languages,
130
+ ...(report.techStack.runtime ? [report.techStack.runtime] : []),
131
+ ...report.techStack.frameworks,
132
+ ...report.techStack.tools,
133
+ ].join(", ");
134
+
135
+ lines.push("# Context Optimization Request");
136
+ lines.push("");
137
+ lines.push(`Current system prompt is **~${formatTokens(report.totalTokens)} tokens**. Target is **< 8K tokens**.`);
138
+ lines.push(`Project tech stack: **${techList || "unknown"}**`);
139
+ lines.push("");
140
+
141
+ // Skill inventory
142
+ if (report.skills.length > 0) {
143
+ lines.push("## Skills currently loaded");
144
+ lines.push("");
145
+ lines.push("| Skill | Tokens |");
146
+ lines.push("|-------|--------|");
147
+ const sorted = [...report.skills].sort((a, b) => b.tokens - a.tokens);
148
+ for (const s of sorted) {
149
+ lines.push(`| ${s.name} | ~${formatTokens(s.tokens)} |`);
150
+ }
151
+ lines.push("");
152
+ }
153
+
154
+ // Section inventory
155
+ if (report.sections.length > 0) {
156
+ lines.push("## Other prompt sections");
157
+ lines.push("");
158
+ for (const sec of report.sections) {
159
+ const note = sec.note ? ` — ${sec.note}` : "";
160
+ lines.push(`- **${sec.label}**: ~${formatTokens(sec.tokens)} tok${note}`);
161
+ }
162
+ lines.push("");
163
+ }
164
+
165
+ lines.push("## Your task");
166
+ lines.push("");
167
+ lines.push("Classify each loaded skill into one of these actions:");
168
+ lines.push("");
169
+ lines.push("| Action | Prompt cost | When to use |");
170
+ lines.push("|--------|-------------|-------------|");
171
+ lines.push("| **Keep as skill** | Full content every turn | Essential for this project, needed constantly |");
172
+ lines.push("| **Convert to rulebook rule** | Name + description only | Relevant but only needed on-demand (load via `rule://`) |");
173
+ lines.push("| **Convert to TTSR rule** | Zero | Behavioral enforcement — triggered by regex pattern in output stream |");
174
+ lines.push("| **Convert to slash command** | Zero | Interactive workflow the user invokes explicitly |");
175
+ lines.push("| **Disable** | Zero | Irrelevant to this project's tech stack |");
176
+ lines.push("");
177
+ lines.push("For each skill, consider:");
178
+ lines.push("1. Is it relevant to the detected tech stack? If not → **disable**.");
179
+ lines.push("2. Does it enforce a behavior pattern (debugging, TDD, verification)? → **TTSR** with a condition regex.");
180
+ lines.push("3. Is it reference material loaded for occasional lookups? → **rulebook** with a short description.");
181
+ lines.push("4. Is it an interactive workflow the user triggers explicitly? → **slash command**.");
182
+ lines.push("5. Is it essential context needed on every turn for this project? → **keep**.");
183
+ lines.push("");
184
+ lines.push("## Implementation");
185
+ lines.push("");
186
+ lines.push("After classifying, implement the changes:");
187
+ lines.push("");
188
+ lines.push("- **Rulebook**: Create `.omp/rules/<skill-name>.md` with YAML frontmatter `description: \"...\"` and condensed key content");
189
+ lines.push("- **TTSR**: Create `.omp/rules/<skill-name>.md` with YAML frontmatter `condition: \"regex_pattern\"`");
190
+ lines.push("- **Disable**: Note which skills to remove from the session configuration");
191
+ lines.push("- **Command**: Note which skills could become slash commands (but don't create them now)");
192
+ lines.push("");
193
+ lines.push("## Warnings");
194
+ lines.push("");
195
+ lines.push("- Do **NOT** delete files from `~/.omp/skills/` — only create project-local `.omp/rules/` files");
196
+ lines.push("- Rulebook and TTSR files go in `.omp/rules/` at the project root");
197
+ lines.push("- Preserve the original skill content's intent when condensing for rulebook rules");
198
+ lines.push("");
199
+ lines.push("Present your classification table and implementation plan first, then ask before executing.");
200
+
201
+ return lines.join("\n");
202
+ }
@@ -20,6 +20,7 @@ export function handleSupi(platform: Platform, ctx: PlatformContext): void {
20
20
  "/supi:doctor — Run health checks",
21
21
  "/supi:update — Update to latest version",
22
22
  "/supi:context — Show context breakdown",
23
+ "/supi:optimize-context — Optimize context to save tokens",
23
24
  ];
24
25
 
25
26
  const status = [
@@ -43,6 +43,63 @@ export function parseSystemPrompt(text: string): PromptSection[] {
43
43
  return sections;
44
44
  }
45
45
 
46
+ // ── Per-Skill Parser ──────────────────────────────────────
47
+
48
+ /** A single skill extracted from the system prompt */
49
+ export interface ParsedSkill {
50
+ name: string;
51
+ bytes: number;
52
+ tokens: number;
53
+ content: string;
54
+ }
55
+
56
+ /** Extract individual skills from system prompt text */
57
+ export function parseIndividualSkills(systemPrompt: string): ParsedSkill[] {
58
+ if (!systemPrompt) return [];
59
+
60
+ // OMP renders skills as markdown headings under "# Skills":
61
+ // ## skill-name
62
+ // Description text...
63
+ // Find the Skills section bounded by the next h1 heading or end of text.
64
+ const skillsSectionMatch = systemPrompt.match(
65
+ /^# Skills\n[\s\S]*?\n(?=##\s)/m,
66
+ );
67
+ if (!skillsSectionMatch) return [];
68
+
69
+ // Extract the region from first ## to the next # (h1) or end of text
70
+ const sectionStart = skillsSectionMatch.index! + skillsSectionMatch[0].length;
71
+ const afterSection = systemPrompt.slice(sectionStart);
72
+ const nextH1 = afterSection.search(/^# [^#]/m);
73
+ const skillsBody =
74
+ skillsSectionMatch[0].slice(skillsSectionMatch[0].indexOf("\n## ") + 1) +
75
+ (nextH1 === -1 ? afterSection : afterSection.slice(0, nextH1));
76
+
77
+ // Split on ## headings
78
+ const skills: ParsedSkill[] = [];
79
+ const headingRegex = /^## (.+)$/gm;
80
+ const headings: { name: string; index: number }[] = [];
81
+ let match;
82
+
83
+ while ((match = headingRegex.exec(skillsBody)) !== null) {
84
+ headings.push({ name: match[1].trim(), index: match.index });
85
+ }
86
+
87
+ for (let i = 0; i < headings.length; i++) {
88
+ const start = headings[i].index;
89
+ const end = i + 1 < headings.length ? headings[i + 1].index : skillsBody.length;
90
+ const content = skillsBody.slice(start, end).trimEnd();
91
+ const bytes = byteLength(content);
92
+ skills.push({
93
+ name: headings[i].name,
94
+ bytes,
95
+ tokens: estimateTokens(content),
96
+ content,
97
+ });
98
+ }
99
+
100
+ return skills;
101
+ }
102
+
46
103
  // ── Breakdown Builder ─────────────────────────────────────
47
104
 
48
105
  /** Context usage data from OMP runtime */
@@ -0,0 +1,199 @@
1
+ import type { Platform } from "../platform/types.js";
2
+ import type { ParsedSkill, PromptSection } from "./analyzer.js";
3
+ import { estimateTokens } from "./analyzer.js";
4
+
5
+ // ── Types ───────────────────────────────────────────────────
6
+
7
+ export interface TechStack {
8
+ languages: string[];
9
+ frameworks: string[];
10
+ tools: string[];
11
+ runtime: string | null;
12
+ }
13
+
14
+ /** A skill with its token cost — no classification judgment. */
15
+ export interface SkillEntry {
16
+ name: string;
17
+ tokens: number;
18
+ }
19
+
20
+ /** A non-skill section with token cost and optional note. */
21
+ export interface SectionEntry {
22
+ label: string;
23
+ tokens: number;
24
+ note: string;
25
+ }
26
+
27
+ /** Raw context report — data only, no classification. */
28
+ export interface ContextReport {
29
+ totalTokens: number;
30
+ techStack: TechStack;
31
+ skills: SkillEntry[];
32
+ sections: SectionEntry[];
33
+ }
34
+
35
+ // ── Framework / Tool Detection Maps ─────────────────────────
36
+
37
+ /** Maps package.json dependency names → detected framework or tool */
38
+ const DEP_TO_FRAMEWORK: Record<string, string> = {
39
+ react: "react",
40
+ "react-dom": "react",
41
+ next: "next",
42
+ vue: "vue",
43
+ svelte: "svelte",
44
+ "@sveltejs/kit": "svelte",
45
+ express: "express",
46
+ fastify: "fastify",
47
+ };
48
+
49
+ const DEP_TO_TOOL: Record<string, string> = {
50
+ tailwindcss: "tailwind",
51
+ "@playwright/test": "playwright",
52
+ prisma: "prisma",
53
+ "@prisma/client": "prisma",
54
+ "@shadcn/ui": "shadcn",
55
+ };
56
+
57
+ // ── Tech Stack Detection ────────────────────────────────────
58
+
59
+ /** Read a file via platform.exec; returns content or null on failure. */
60
+ async function tryRead(
61
+ platform: Platform,
62
+ cwd: string,
63
+ filename: string,
64
+ ): Promise<string | null> {
65
+ const r = await platform.exec("cat", [filename], { cwd });
66
+ return r.code === 0 ? r.stdout : null;
67
+ }
68
+
69
+ /** Check if a file exists at `cwd/filename` via exec. */
70
+ async function fileExists(
71
+ platform: Platform,
72
+ cwd: string,
73
+ filename: string,
74
+ ): Promise<boolean> {
75
+ const r = await platform.exec("test", ["-f", filename], { cwd });
76
+ return r.code === 0;
77
+ }
78
+
79
+ /**
80
+ * Deterministic tech-stack detection — no LLM.
81
+ * Inspects package.json, lockfiles, and config files.
82
+ */
83
+ export async function detectTechStack(
84
+ platform: Platform,
85
+ cwd: string,
86
+ ): Promise<TechStack> {
87
+ const languages = new Set<string>();
88
+ const frameworks = new Set<string>();
89
+ const tools = new Set<string>();
90
+ let runtime: string | null = null;
91
+
92
+ // 1. package.json — deps & devDeps
93
+ const pkgRaw = await tryRead(platform, cwd, "package.json");
94
+ if (pkgRaw) {
95
+ try {
96
+ const pkg = JSON.parse(pkgRaw);
97
+ const allDeps: Record<string, string> = {
98
+ ...pkg.dependencies,
99
+ ...pkg.devDependencies,
100
+ };
101
+ for (const dep of Object.keys(allDeps)) {
102
+ const fw = DEP_TO_FRAMEWORK[dep];
103
+ if (fw) frameworks.add(fw);
104
+ const tl = DEP_TO_TOOL[dep];
105
+ if (tl) tools.add(tl);
106
+ }
107
+ if (allDeps.typescript) languages.add("typescript");
108
+ } catch {
109
+ // malformed package.json — continue with file-based detection
110
+ }
111
+ }
112
+
113
+ // 2. Lockfiles → runtime (first match wins, most-specific first)
114
+ if (await fileExists(platform, cwd, "bun.lock")) {
115
+ runtime = "bun";
116
+ } else if (await fileExists(platform, cwd, "package-lock.json")) {
117
+ runtime = "node";
118
+ } else if (await fileExists(platform, cwd, "pnpm-lock.yaml")) {
119
+ runtime = "node";
120
+ } else if (await fileExists(platform, cwd, "yarn.lock")) {
121
+ runtime = "node";
122
+ }
123
+
124
+ // 3. Config files → languages
125
+ const configChecks: [string, string][] = [
126
+ ["tsconfig.json", "typescript"],
127
+ ["Cargo.toml", "rust"],
128
+ ["pyproject.toml", "python"],
129
+ ["requirements.txt", "python"],
130
+ ["go.mod", "go"],
131
+ ["Gemfile", "ruby"],
132
+ ];
133
+ const results = await Promise.all(
134
+ configChecks.map(([file, lang]) =>
135
+ fileExists(platform, cwd, file).then((exists) =>
136
+ exists ? lang : null,
137
+ ),
138
+ ),
139
+ );
140
+ for (const lang of results) {
141
+ if (lang) languages.add(lang);
142
+ }
143
+
144
+ return {
145
+ languages: [...languages],
146
+ frameworks: [...frameworks],
147
+ tools: [...tools],
148
+ runtime,
149
+ };
150
+ }
151
+
152
+ // ── Context Report ──────────────────────────────────────────
153
+
154
+ /**
155
+ * Build a raw context report from parsed prompt data.
156
+ * Gathers token costs per skill and per section, flags anomalies.
157
+ * Does NOT classify or recommend — that's the LLM's job.
158
+ */
159
+ export function buildContextReport(
160
+ sections: PromptSection[],
161
+ skills: ParsedSkill[],
162
+ techStack: TechStack,
163
+ ): ContextReport {
164
+ const totalTokens = sections.reduce(
165
+ (sum, s) => sum + estimateTokens(s.content),
166
+ 0,
167
+ );
168
+
169
+ const skillEntries: SkillEntry[] = skills.map((s) => ({
170
+ name: s.name,
171
+ tokens: s.tokens,
172
+ }));
173
+
174
+ // Annotate non-skill sections with notes for anomalies
175
+ const sectionEntries: SectionEntry[] = [];
176
+
177
+ const routingSections = sections.filter((s) =>
178
+ s.label.toLowerCase().includes("routing"),
179
+ );
180
+ const hasRoutingDupes = routingSections.length > 1;
181
+
182
+ for (const s of sections) {
183
+ // Skip the aggregate "Skills (N)" section — per-skill data is separate
184
+ if (s.label.toLowerCase().startsWith("skills")) continue;
185
+
186
+ const tokens = estimateTokens(s.content);
187
+ let note = "";
188
+
189
+ if (s.label.toLowerCase().includes("routing") && hasRoutingDupes) {
190
+ note = `Duplicate (${routingSections.length} found) — consolidate`;
191
+ } else if (s.label.toLowerCase().includes("memory") && tokens > 500) {
192
+ note = "Large — consider compressing or summarizing";
193
+ }
194
+
195
+ sectionEntries.push({ label: s.label, tokens, note });
196
+ }
197
+
198
+ return { totalTokens, techStack, skills: skillEntries, sections: sectionEntries };
199
+ }
@@ -14,7 +14,8 @@ interface ToolResultEventResult {
14
14
 
15
15
  const BASH_HEAD_LINES = 5;
16
16
  const BASH_TAIL_LINES = 10;
17
- const READ_PREVIEW_LINES = 10;
17
+ const READ_HEAD_LINES = 80;
18
+ const READ_TAIL_LINES = 30;
18
19
  const GREP_MAX_MATCHES = 10;
19
20
  const FIND_MAX_PATHS = 20;
20
21
 
@@ -68,23 +69,25 @@ function compressBash(text: string, details: unknown): string | undefined {
68
69
  ].join("\n");
69
70
  }
70
71
 
71
- /** Compress read tool output */
72
+ /** Compress read tool output — head+tail with hashline preservation */
72
73
  function compressRead(text: string, input: Record<string, unknown>): string | undefined {
73
- // Scoped reads (offset/limit) are already targeted — pass through
74
- if (input.offset !== undefined || input.limit !== undefined) return undefined;
74
+ // Scoped reads (offset/limit/sel) are already targeted — pass through
75
+ if (input.offset != null || input.limit != null || input.sel != null) return undefined;
75
76
 
76
77
  const lines = text.split("\n");
77
78
  const totalLines = lines.length;
78
- const path = typeof input.path === "string" ? input.path : "unknown";
79
79
 
80
- if (totalLines <= READ_PREVIEW_LINES) return undefined;
80
+ if (totalLines <= READ_HEAD_LINES + READ_TAIL_LINES) return undefined;
81
+
82
+ const head = lines.slice(0, READ_HEAD_LINES);
83
+ const tail = lines.slice(-READ_TAIL_LINES);
84
+ const omittedStart = READ_HEAD_LINES + 1;
85
+ const omittedEnd = totalLines - READ_TAIL_LINES;
81
86
 
82
- const preview = lines.slice(0, READ_PREVIEW_LINES);
83
87
  return [
84
- `File: ${path} (${totalLines} lines total)`,
85
- "",
86
- ...preview,
87
- `[...compressed: remaining ${totalLines - READ_PREVIEW_LINES} lines omitted...]`,
88
+ ...head,
89
+ `[...${omittedEnd - omittedStart + 1} lines omitted. Use read(path, sel="L${omittedStart}-L${omittedEnd}") to view...]`,
90
+ ...tail,
88
91
  ].join("\n");
89
92
  }
90
93