@nxuss/lemma 1.4.0 → 1.6.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.
Files changed (59) hide show
  1. package/README.md +75 -7
  2. package/bin/init.js +1 -1
  3. package/dist/cjs/cli/lemma-proxy.d.ts.map +1 -1
  4. package/dist/cjs/cli/lemma-proxy.js +50 -6
  5. package/dist/cjs/cli/lemma-proxy.js.map +1 -1
  6. package/dist/cjs/mcp/index.js +23 -0
  7. package/dist/cjs/mcp/index.js.map +1 -1
  8. package/dist/cjs/mcp/prompts.d.ts +2 -2
  9. package/dist/cjs/mcp/prompts.d.ts.map +1 -1
  10. package/dist/cjs/mcp/prompts.js +10 -5
  11. package/dist/cjs/mcp/prompts.js.map +1 -1
  12. package/dist/cjs/mcp/tools.d.ts +2 -0
  13. package/dist/cjs/mcp/tools.d.ts.map +1 -1
  14. package/dist/cjs/mcp/tools.js +111 -5
  15. package/dist/cjs/mcp/tools.js.map +1 -1
  16. package/dist/cjs/utils/CommandOutputDistiller.d.ts +75 -0
  17. package/dist/cjs/utils/CommandOutputDistiller.d.ts.map +1 -0
  18. package/dist/cjs/utils/CommandOutputDistiller.js +598 -0
  19. package/dist/cjs/utils/CommandOutputDistiller.js.map +1 -0
  20. package/dist/cjs/utils/ContextSqueezer.d.ts.map +1 -1
  21. package/dist/cjs/utils/ContextSqueezer.js +5 -3
  22. package/dist/cjs/utils/ContextSqueezer.js.map +1 -1
  23. package/dist/cjs/utils/SavingsLedger.d.ts +38 -1
  24. package/dist/cjs/utils/SavingsLedger.d.ts.map +1 -1
  25. package/dist/cjs/utils/SavingsLedger.js +89 -13
  26. package/dist/cjs/utils/SavingsLedger.js.map +1 -1
  27. package/dist/cjs/utils/reportSavings.d.ts +15 -0
  28. package/dist/cjs/utils/reportSavings.d.ts.map +1 -1
  29. package/dist/cjs/utils/reportSavings.js +13 -1
  30. package/dist/cjs/utils/reportSavings.js.map +1 -1
  31. package/dist/esm/cli/lemma-proxy.d.ts.map +1 -1
  32. package/dist/esm/cli/lemma-proxy.js +50 -6
  33. package/dist/esm/cli/lemma-proxy.js.map +1 -1
  34. package/dist/esm/mcp/index.js +24 -1
  35. package/dist/esm/mcp/index.js.map +1 -1
  36. package/dist/esm/mcp/prompts.d.ts +2 -2
  37. package/dist/esm/mcp/prompts.d.ts.map +1 -1
  38. package/dist/esm/mcp/prompts.js +10 -5
  39. package/dist/esm/mcp/prompts.js.map +1 -1
  40. package/dist/esm/mcp/tools.d.ts +2 -0
  41. package/dist/esm/mcp/tools.d.ts.map +1 -1
  42. package/dist/esm/mcp/tools.js +112 -7
  43. package/dist/esm/mcp/tools.js.map +1 -1
  44. package/dist/esm/utils/CommandOutputDistiller.d.ts +75 -0
  45. package/dist/esm/utils/CommandOutputDistiller.d.ts.map +1 -0
  46. package/dist/esm/utils/CommandOutputDistiller.js +588 -0
  47. package/dist/esm/utils/CommandOutputDistiller.js.map +1 -0
  48. package/dist/esm/utils/ContextSqueezer.d.ts.map +1 -1
  49. package/dist/esm/utils/ContextSqueezer.js +5 -3
  50. package/dist/esm/utils/ContextSqueezer.js.map +1 -1
  51. package/dist/esm/utils/SavingsLedger.d.ts +38 -1
  52. package/dist/esm/utils/SavingsLedger.d.ts.map +1 -1
  53. package/dist/esm/utils/SavingsLedger.js +89 -13
  54. package/dist/esm/utils/SavingsLedger.js.map +1 -1
  55. package/dist/esm/utils/reportSavings.d.ts +15 -0
  56. package/dist/esm/utils/reportSavings.d.ts.map +1 -1
  57. package/dist/esm/utils/reportSavings.js +12 -1
  58. package/dist/esm/utils/reportSavings.js.map +1 -1
  59. package/package.json +1 -1
@@ -0,0 +1,75 @@
1
+ /**
2
+ * CommandOutputDistiller — turns raw build/test/lint output into the few lines a model
3
+ * actually needs, without ever losing the rest.
4
+ *
5
+ * The problem it solves: a failing `npm test` dumps thousands of lines into the model's
6
+ * context, of which maybe thirty carry signal (which tests failed, why, and where). The
7
+ * other 99% is code frames the model can read from the file itself, passing-test noise,
8
+ * and node_modules stack frames.
9
+ *
10
+ * Two rules this module is built around:
11
+ *
12
+ * 1. Deterministic only. Every parser here is a grammar over a known reporter format —
13
+ * no LLM summarization. A summarizer that hallucinates a test name is worse than the
14
+ * raw dump it replaced.
15
+ * 2. Lossless on demand. The complete output is always written to disk first and the
16
+ * distilled view carries a handle back to it, so anything trimmed can be retrieved
17
+ * verbatim (see `readRegion`). Nothing is silently dropped.
18
+ */
19
+ export type OutputFormat = "jest" | "vitest" | "tsc" | "eslint" | "generic";
20
+ /** A named, retrievable slice of the raw output (1-indexed, inclusive). */
21
+ export interface OutputSection {
22
+ name: string;
23
+ startLine: number;
24
+ endLine: number;
25
+ }
26
+ export interface DistilledOutput {
27
+ format: OutputFormat;
28
+ /** The text to hand to the model. */
29
+ text: string;
30
+ /** Null when the output was small enough to pass through untouched. */
31
+ handle: string | null;
32
+ originalChars: number;
33
+ distilledChars: number;
34
+ originalLines: number;
35
+ sections: OutputSection[];
36
+ }
37
+ /**
38
+ * Persist the complete output and return its handle. Written before anything is trimmed,
39
+ * so a distilled view can never reference output that was never saved.
40
+ */
41
+ export declare function storeRawOutput(raw: string, command: string, format: OutputFormat, sections: OutputSection[]): string;
42
+ export interface RegionRequest {
43
+ handle: string;
44
+ /** Name of a section reported by the distiller (e.g. a failing test title). Prefix match, case-insensitive. */
45
+ section?: string;
46
+ startLine?: number;
47
+ endLine?: number;
48
+ /** Return the entire stored output. */
49
+ all?: boolean;
50
+ }
51
+ export interface RegionResult {
52
+ found: boolean;
53
+ command?: string;
54
+ totalLines?: number;
55
+ startLine?: number;
56
+ endLine?: number;
57
+ text?: string;
58
+ /** Section names available for this handle, so a failed lookup can be retried precisely. */
59
+ availableSections?: string[];
60
+ error?: string;
61
+ }
62
+ /** Retrieve raw output verbatim — the escape hatch that makes distillation lossless. */
63
+ export declare function readRegion(req: RegionRequest): RegionResult;
64
+ export declare function detectFormat(raw: string): OutputFormat;
65
+ export interface DistillOptions {
66
+ command: string;
67
+ /** Skip distillation entirely and return the raw output (still stored). */
68
+ raw?: boolean;
69
+ /** Outputs at or below this size pass through untouched. */
70
+ thresholdChars?: number;
71
+ }
72
+ export declare function distillCommandOutput(rawOutput: string, options: DistillOptions): DistilledOutput;
73
+ /** The footer that tells the model — and the user — that this view is not the whole output. */
74
+ export declare function buildDistillFooter(d: DistilledOutput): string;
75
+ //# sourceMappingURL=CommandOutputDistiller.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CommandOutputDistiller.d.ts","sourceRoot":"","sources":["../../../src/utils/CommandOutputDistiller.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAwBH,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,QAAQ,GAAG,KAAK,GAAG,QAAQ,GAAG,SAAS,CAAC;AAE5E,2EAA2E;AAC3E,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,YAAY,CAAC;IACrB,qCAAqC;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,uEAAuE;IACvE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,aAAa,EAAE,CAAC;CAC3B;AAoDD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,QAAQ,EAAE,aAAa,EAAE,GAAG,MAAM,CAepH;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,+GAA+G;IAC/G,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,uCAAuC;IACvC,GAAG,CAAC,EAAE,OAAO,CAAC;CACf;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,OAAO,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,4FAA4F;IAC5F,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,wFAAwF;AACxF,wBAAgB,UAAU,CAAC,GAAG,EAAE,aAAa,GAAG,YAAY,CAyD3D;AAWD,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,CAUtD;AAgYD,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,2EAA2E;IAC3E,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,4DAA4D;IAC5D,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,GAAG,eAAe,CAgEhG;AAED,+FAA+F;AAC/F,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,eAAe,GAAG,MAAM,CAc7D"}
@@ -0,0 +1,598 @@
1
+ "use strict";
2
+ /**
3
+ * CommandOutputDistiller — turns raw build/test/lint output into the few lines a model
4
+ * actually needs, without ever losing the rest.
5
+ *
6
+ * The problem it solves: a failing `npm test` dumps thousands of lines into the model's
7
+ * context, of which maybe thirty carry signal (which tests failed, why, and where). The
8
+ * other 99% is code frames the model can read from the file itself, passing-test noise,
9
+ * and node_modules stack frames.
10
+ *
11
+ * Two rules this module is built around:
12
+ *
13
+ * 1. Deterministic only. Every parser here is a grammar over a known reporter format —
14
+ * no LLM summarization. A summarizer that hallucinates a test name is worse than the
15
+ * raw dump it replaced.
16
+ * 2. Lossless on demand. The complete output is always written to disk first and the
17
+ * distilled view carries a handle back to it, so anything trimmed can be retrieved
18
+ * verbatim (see `readRegion`). Nothing is silently dropped.
19
+ */
20
+ var __importDefault = (this && this.__importDefault) || function (mod) {
21
+ return (mod && mod.__esModule) ? mod : { "default": mod };
22
+ };
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.storeRawOutput = storeRawOutput;
25
+ exports.readRegion = readRegion;
26
+ exports.detectFormat = detectFormat;
27
+ exports.distillCommandOutput = distillCommandOutput;
28
+ exports.buildDistillFooter = buildDistillFooter;
29
+ const fs_1 = __importDefault(require("fs"));
30
+ const path_1 = __importDefault(require("path"));
31
+ const os_1 = __importDefault(require("os"));
32
+ const crypto_1 = __importDefault(require("crypto"));
33
+ /** Outputs at or under this size are returned untouched — distilling them saves nothing. */
34
+ const DISTILL_THRESHOLD_CHARS = 3000;
35
+ /** Max failing-test blocks rendered inline before we point at the handle for the rest. */
36
+ const MAX_FAILURES_INLINE = 20;
37
+ /** Max lines kept from a single failure's reason/diff body. */
38
+ const MAX_REASON_LINES = 14;
39
+ /** Max tsc/eslint entries rendered per file. */
40
+ const MAX_ENTRIES_PER_FILE = 10;
41
+ /** Head/tail lines kept by the generic fallback. */
42
+ const GENERIC_EDGE_LINES = 25;
43
+ /** Max matched "interesting" lines the generic fallback keeps from the middle. */
44
+ const MAX_GENERIC_SIGNAL_LINES = 60;
45
+ /** How many stored outputs to keep on disk before pruning the oldest. */
46
+ const MAX_STORED_OUTPUTS = 30;
47
+ const STORE_DIR = path_1.default.join(os_1.default.tmpdir(), "lemma-command-output");
48
+ // ── Storage ────────────────────────────────────────────────────────────────────
49
+ function storePathFor(handle, ext) {
50
+ return path_1.default.join(STORE_DIR, `${handle}.${ext}`);
51
+ }
52
+ function pruneStore() {
53
+ let entries;
54
+ try {
55
+ entries = fs_1.default.readdirSync(STORE_DIR).filter((f) => f.endsWith(".txt"));
56
+ }
57
+ catch {
58
+ return;
59
+ }
60
+ if (entries.length <= MAX_STORED_OUTPUTS)
61
+ return;
62
+ const withTime = entries
63
+ .map((f) => {
64
+ const full = path_1.default.join(STORE_DIR, f);
65
+ try {
66
+ return { full, base: full.slice(0, -4), mtime: fs_1.default.statSync(full).mtimeMs };
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ })
72
+ .filter((e) => e !== null)
73
+ .sort((a, b) => a.mtime - b.mtime);
74
+ for (const stale of withTime.slice(0, withTime.length - MAX_STORED_OUTPUTS)) {
75
+ try {
76
+ fs_1.default.unlinkSync(stale.full);
77
+ }
78
+ catch {
79
+ /* already gone */
80
+ }
81
+ try {
82
+ fs_1.default.unlinkSync(`${stale.base}.json`);
83
+ }
84
+ catch {
85
+ /* no sidecar */
86
+ }
87
+ }
88
+ }
89
+ /**
90
+ * Persist the complete output and return its handle. Written before anything is trimmed,
91
+ * so a distilled view can never reference output that was never saved.
92
+ */
93
+ function storeRawOutput(raw, command, format, sections) {
94
+ fs_1.default.mkdirSync(STORE_DIR, { recursive: true });
95
+ const handle = crypto_1.default.randomBytes(6).toString("hex");
96
+ const meta = {
97
+ handle,
98
+ command,
99
+ createdAt: Date.now(),
100
+ totalLines: raw.split("\n").length,
101
+ format,
102
+ sections,
103
+ };
104
+ fs_1.default.writeFileSync(storePathFor(handle, "txt"), raw, "utf8");
105
+ fs_1.default.writeFileSync(storePathFor(handle, "json"), JSON.stringify(meta, null, 2), "utf8");
106
+ pruneStore();
107
+ return handle;
108
+ }
109
+ /** Retrieve raw output verbatim — the escape hatch that makes distillation lossless. */
110
+ function readRegion(req) {
111
+ const txtPath = storePathFor(req.handle, "txt");
112
+ const jsonPath = storePathFor(req.handle, "json");
113
+ if (!fs_1.default.existsSync(txtPath)) {
114
+ return {
115
+ found: false,
116
+ error: `No stored output for handle "${req.handle}". Handles live in the OS temp dir and are pruned after ${MAX_STORED_OUTPUTS} newer runs — re-run the command to get a fresh one.`,
117
+ };
118
+ }
119
+ const raw = fs_1.default.readFileSync(txtPath, "utf8");
120
+ const lines = raw.split("\n");
121
+ let meta = null;
122
+ try {
123
+ meta = JSON.parse(fs_1.default.readFileSync(jsonPath, "utf8"));
124
+ }
125
+ catch {
126
+ meta = null;
127
+ }
128
+ const sections = meta?.sections ?? [];
129
+ const available = sections.map((s) => s.name);
130
+ let start = 1;
131
+ let end = lines.length;
132
+ if (req.all) {
133
+ // full range
134
+ }
135
+ else if (req.section) {
136
+ const needle = req.section.toLowerCase();
137
+ const hit = sections.find((s) => s.name.toLowerCase() === needle) ??
138
+ sections.find((s) => s.name.toLowerCase().includes(needle));
139
+ if (!hit) {
140
+ return {
141
+ found: false,
142
+ error: `No section matching "${req.section}" in handle ${req.handle}.`,
143
+ availableSections: available,
144
+ command: meta?.command,
145
+ totalLines: lines.length,
146
+ };
147
+ }
148
+ start = hit.startLine;
149
+ end = hit.endLine;
150
+ }
151
+ else if (req.startLine !== undefined || req.endLine !== undefined) {
152
+ start = Math.max(1, req.startLine ?? 1);
153
+ end = Math.min(lines.length, req.endLine ?? lines.length);
154
+ if (end < start)
155
+ return { found: false, error: `endLine (${end}) is before startLine (${start}).` };
156
+ }
157
+ return {
158
+ found: true,
159
+ command: meta?.command,
160
+ totalLines: lines.length,
161
+ startLine: start,
162
+ endLine: end,
163
+ text: lines.slice(start - 1, end).join("\n"),
164
+ availableSections: available,
165
+ };
166
+ }
167
+ // ── Format detection ───────────────────────────────────────────────────────────
168
+ // All three carry the `m` flag so they work both per-line (`line.match(...)`) and as a
169
+ // format probe against the whole output (`re.test(raw)`) — without it, detection silently
170
+ // never matched anything but the first line.
171
+ const JEST_FAILURE_HEADER = /^\s{2}● (?!Console\b)(.+)$/m;
172
+ const TSC_ERROR = /^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+): (.*)$/m;
173
+ const ESLINT_LOCATION = /^\s+(\d+):(\d+)\s+(error|warning)\s+(.+?)\s{2,}([\w@/-]+)\s*$/m;
174
+ function detectFormat(raw) {
175
+ if (/^\s*(Test Suites|Tests):\s+\d/m.test(raw) || JEST_FAILURE_HEADER.test(raw))
176
+ return "jest";
177
+ // Vitest's default reporter labels its own summary block; checked after jest because a
178
+ // jest run never emits these markers.
179
+ if (/^\s*(Test Files|Tests)\s+\d+ (passed|failed)/m.test(raw) || /⎯+\s*Failed Tests?\s*\d*\s*⎯+/.test(raw)) {
180
+ return "vitest";
181
+ }
182
+ if (TSC_ERROR.test(raw))
183
+ return "tsc";
184
+ if (ESLINT_LOCATION.test(raw) && /^\/.*\.(ts|tsx|js|jsx|mjs|cjs)$/m.test(raw))
185
+ return "eslint";
186
+ return "generic";
187
+ }
188
+ // ── Helpers ────────────────────────────────────────────────────────────────────
189
+ function isProjectFrame(line) {
190
+ return !line.includes("node_modules") && !line.includes("internal/");
191
+ }
192
+ /**
193
+ * Strip jest/vitest code frames — numbered source lines the model can read from the file
194
+ * itself. Covers jest's `> 5 | expect(...)` and vitest's ` 4| expect(...)`, plus the
195
+ * caret pointer line under either.
196
+ */
197
+ function isCodeFrameLine(line) {
198
+ return /^\s*>?\s*\d+\s*\|/.test(line) || /^\s*\|\s*\^/.test(line);
199
+ }
200
+ function truncateBlock(lines, max) {
201
+ if (lines.length <= max)
202
+ return lines;
203
+ return [...lines.slice(0, max), ` … ${lines.length - max} more lines (use output_region)`];
204
+ }
205
+ function trimBlank(lines) {
206
+ let start = 0;
207
+ let end = lines.length;
208
+ while (start < end && lines[start].trim() === "")
209
+ start++;
210
+ while (end > start && lines[end - 1].trim() === "")
211
+ end--;
212
+ return lines.slice(start, end);
213
+ }
214
+ function collectJestFailures(lines) {
215
+ const blocks = [];
216
+ let current = null;
217
+ for (let i = 0; i < lines.length; i++) {
218
+ const m = lines[i].match(JEST_FAILURE_HEADER);
219
+ if (m) {
220
+ if (current) {
221
+ current.endLine = i; // previous block ends on the line before this header
222
+ blocks.push(current);
223
+ }
224
+ current = { title: m[1].trim(), startLine: i + 1, endLine: lines.length, body: [] };
225
+ continue;
226
+ }
227
+ // A summary line closes the last failure block.
228
+ if (current && /^(Test Suites|Tests|Snapshots|Time|Ran all test suites):/.test(lines[i])) {
229
+ current.endLine = i;
230
+ blocks.push(current);
231
+ current = null;
232
+ continue;
233
+ }
234
+ if (current)
235
+ current.body.push(lines[i]);
236
+ }
237
+ if (current)
238
+ blocks.push(current);
239
+ return blocks;
240
+ }
241
+ function distillJest(raw) {
242
+ const lines = raw.split("\n");
243
+ const failures = collectJestFailures(lines);
244
+ const sections = failures.map((f) => ({
245
+ name: f.title,
246
+ startLine: f.startLine,
247
+ endLine: f.endLine,
248
+ }));
249
+ const out = [];
250
+ const failedSuites = lines.filter((l) => /^\s*FAIL\s+/.test(l)).map((l) => l.trim());
251
+ if (failedSuites.length) {
252
+ out.push(`Failed suites (${failedSuites.length}):`);
253
+ for (const s of failedSuites.slice(0, 20))
254
+ out.push(` ${s}`);
255
+ if (failedSuites.length > 20)
256
+ out.push(` … ${failedSuites.length - 20} more`);
257
+ out.push("");
258
+ }
259
+ const shown = failures.slice(0, MAX_FAILURES_INLINE);
260
+ for (const f of shown) {
261
+ // Location: first project-local stack frame in the block.
262
+ const frame = f.body.find((l) => /^\s+at .*\(?[^\s]+:\d+:\d+\)?\s*$/.test(l) && isProjectFrame(l));
263
+ const loc = frame ? frame.trim().replace(/^at\s+/, "") : "";
264
+ // Reason: everything except code frames and stack frames.
265
+ const reason = trimBlank(f.body.filter((l) => {
266
+ if (isCodeFrameLine(l))
267
+ return false;
268
+ if (/^\s+at\s/.test(l))
269
+ return false;
270
+ return true;
271
+ }));
272
+ out.push(`✕ ${f.title}`);
273
+ if (loc)
274
+ out.push(` ${loc}`);
275
+ for (const l of truncateBlock(reason, MAX_REASON_LINES))
276
+ out.push(l.trim() ? ` ${l}` : "");
277
+ out.push("");
278
+ }
279
+ if (failures.length > shown.length) {
280
+ out.push(`… ${failures.length - shown.length} more failing tests. Retrieve any of them with:`);
281
+ for (const f of failures.slice(MAX_FAILURES_INLINE, MAX_FAILURES_INLINE + 10)) {
282
+ out.push(` output_region(section: "${f.title}")`);
283
+ }
284
+ out.push("");
285
+ }
286
+ const summary = lines.filter((l) => /^(Test Suites|Tests|Snapshots|Time|Test Files):/.test(l.trim()) || /^\s*(Tests|Test Files)\s+\d+ (passed|failed)/.test(l));
287
+ if (summary.length) {
288
+ out.push(...summary.map((l) => l.trim()));
289
+ }
290
+ if (!failures.length && !failedSuites.length) {
291
+ // A green run: the summary is the whole story.
292
+ const green = summary.length ? summary.map((l) => l.trim()) : ["Command produced no failures."];
293
+ return { text: green.join("\n"), sections };
294
+ }
295
+ return { text: trimBlank(out).join("\n"), sections };
296
+ }
297
+ // ── vitest ─────────────────────────────────────────────────────────────────────
298
+ //
299
+ // Vitest's default reporter is not jest's: failures are headed by
300
+ // ` FAIL file.test.ts > suite > test name`, the reason follows on the next line
301
+ // (`AssertionError: …`), the location is a ` ❯ file:line:col` line, and blocks are
302
+ // separated by `⎯⎯⎯[1/3]⎯` rules. Parsed separately rather than bent into the jest path.
303
+ const VITEST_FAILURE_HEADER = /^\s*FAIL\s+(.+?)\s*$/;
304
+ const VITEST_SEPARATOR = /^[⎯\-—_]{4,}(\[\d+\/\d+\])?[⎯\-—_]*\s*$/;
305
+ const VITEST_LOCATION = /^\s*❯\s+(.+)$/;
306
+ function distillVitest(raw) {
307
+ const lines = raw.split("\n");
308
+ const blocks = [];
309
+ let current = null;
310
+ const close = (endLine) => {
311
+ if (!current)
312
+ return;
313
+ current.endLine = endLine;
314
+ blocks.push(current);
315
+ current = null;
316
+ };
317
+ for (let i = 0; i < lines.length; i++) {
318
+ const line = lines[i];
319
+ const header = line.match(VITEST_FAILURE_HEADER);
320
+ // Only a header that names a test (`file > suite > test`) starts a block; the
321
+ // per-file `❯ fx.test.js (4 tests | 3 failed)` progress lines are not failures.
322
+ if (header && header[1].includes(">")) {
323
+ close(i);
324
+ current = { title: header[1].trim(), startLine: i + 1, endLine: lines.length, body: [] };
325
+ continue;
326
+ }
327
+ if (current && (VITEST_SEPARATOR.test(line) || /^\s*(Test Files|Tests|Duration|Start at)\s+\d/.test(line))) {
328
+ close(i);
329
+ continue;
330
+ }
331
+ if (current)
332
+ current.body.push(line);
333
+ }
334
+ close(lines.length);
335
+ const sections = blocks.map((b) => ({ name: b.title, startLine: b.startLine, endLine: b.endLine }));
336
+ const out = [];
337
+ const shown = blocks.slice(0, MAX_FAILURES_INLINE);
338
+ for (const b of shown) {
339
+ const locLine = b.body.find((l) => VITEST_LOCATION.test(l));
340
+ const loc = locLine ? locLine.match(VITEST_LOCATION)[1].trim() : "";
341
+ const reason = trimBlank(b.body.filter((l) => {
342
+ if (isCodeFrameLine(l))
343
+ return false;
344
+ if (VITEST_LOCATION.test(l))
345
+ return false;
346
+ if (VITEST_SEPARATOR.test(l))
347
+ return false;
348
+ return true;
349
+ }));
350
+ out.push(`✕ ${b.title}`);
351
+ if (loc)
352
+ out.push(` ${loc}`);
353
+ for (const l of truncateBlock(reason, MAX_REASON_LINES))
354
+ out.push(l.trim() ? ` ${l}` : "");
355
+ out.push("");
356
+ }
357
+ if (blocks.length > shown.length) {
358
+ out.push(`… ${blocks.length - shown.length} more failing tests. Retrieve any of them with:`);
359
+ for (const b of blocks.slice(MAX_FAILURES_INLINE, MAX_FAILURES_INLINE + 10)) {
360
+ out.push(` output_region(section: "${b.title}")`);
361
+ }
362
+ out.push("");
363
+ }
364
+ const summary = lines.filter((l) => /^\s*(Test Files|Tests|Duration)\s+\d/.test(l)).map((l) => l.trim());
365
+ if (summary.length)
366
+ out.push(...summary);
367
+ if (!blocks.length) {
368
+ return { text: (summary.length ? summary : ["Command produced no failures."]).join("\n"), sections };
369
+ }
370
+ return { text: trimBlank(out).join("\n"), sections };
371
+ }
372
+ // ── tsc ────────────────────────────────────────────────────────────────────────
373
+ /** Positions shown for one repeated (code, message) pair before the rest are summarized. */
374
+ const MAX_POSITIONS_PER_DIAGNOSTIC = 12;
375
+ function distillTsc(raw) {
376
+ const lines = raw.split("\n");
377
+ const byFile = new Map();
378
+ const codeCounts = new Map();
379
+ let total = 0;
380
+ lines.forEach((line, index) => {
381
+ const m = line.match(TSC_ERROR);
382
+ if (!m)
383
+ return;
384
+ total++;
385
+ const file = m[1];
386
+ codeCounts.set(m[5], (codeCounts.get(m[5]) ?? 0) + 1);
387
+ if (!byFile.has(file))
388
+ byFile.set(file, []);
389
+ byFile.get(file).push({
390
+ line: Number(m[2]),
391
+ col: Number(m[3]),
392
+ severity: m[4],
393
+ code: m[5],
394
+ message: m[6],
395
+ index: index + 1,
396
+ });
397
+ });
398
+ if (total === 0)
399
+ return { text: trimBlank(lines).join("\n"), sections: [] };
400
+ const sections = [];
401
+ const out = [
402
+ `${total} TypeScript diagnostic${total === 1 ? "" : "s"} across ${byFile.size} file${byFile.size === 1 ? "" : "s"}.`,
403
+ ];
404
+ const topCodes = [...codeCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
405
+ out.push(`By code: ${topCodes.map(([c, n]) => `${c}×${n}`).join(", ")}`);
406
+ out.push("");
407
+ for (const [file, entries] of byFile) {
408
+ sections.push({ name: file, startLine: entries[0].index, endLine: entries[entries.length - 1].index });
409
+ out.push(`${file} (${entries.length})`);
410
+ // The same diagnostic repeated across 40 lines is one fact plus 40 positions, not
411
+ // 40 facts — printing the message once is the bulk of the saving on a broken build.
412
+ const grouped = new Map();
413
+ for (const e of entries) {
414
+ const key = `${e.severity} ${e.code} ${e.message}`;
415
+ if (!grouped.has(key))
416
+ grouped.set(key, { severity: e.severity, code: e.code, message: e.message, at: [] });
417
+ grouped.get(key).at.push(`${e.line}:${e.col}`);
418
+ }
419
+ for (const g of grouped.values()) {
420
+ const shown = g.at.slice(0, MAX_POSITIONS_PER_DIAGNOSTIC).join(", ");
421
+ const extra = g.at.length > MAX_POSITIONS_PER_DIAGNOSTIC ? `, +${g.at.length - MAX_POSITIONS_PER_DIAGNOSTIC} more` : "";
422
+ const count = g.at.length > 1 ? ` ×${g.at.length}` : "";
423
+ out.push(` ${g.code}${count} ${g.severity}: ${g.message}`);
424
+ out.push(` at ${shown}${extra}`);
425
+ }
426
+ if (grouped.size > 1 || entries.length > MAX_POSITIONS_PER_DIAGNOSTIC) {
427
+ out.push(` (full list: output_region(section: "${file}"))`);
428
+ }
429
+ }
430
+ return { text: out.join("\n"), sections };
431
+ }
432
+ // ── eslint (stylish reporter) ──────────────────────────────────────────────────
433
+ function distillEslint(raw) {
434
+ const lines = raw.split("\n");
435
+ const sections = [];
436
+ const out = [];
437
+ const ruleCounts = new Map();
438
+ let currentFile = null;
439
+ let fileStart = 0;
440
+ let fileEntries = [];
441
+ let totalProblems = 0;
442
+ const flush = (endIndex) => {
443
+ if (!currentFile)
444
+ return;
445
+ sections.push({ name: currentFile, startLine: fileStart, endLine: endIndex });
446
+ out.push(`${currentFile} (${fileEntries.length})`);
447
+ for (const e of fileEntries.slice(0, MAX_ENTRIES_PER_FILE))
448
+ out.push(` ${e}`);
449
+ if (fileEntries.length > MAX_ENTRIES_PER_FILE) {
450
+ out.push(` … ${fileEntries.length - MAX_ENTRIES_PER_FILE} more — output_region(section: "${currentFile}")`);
451
+ }
452
+ currentFile = null;
453
+ fileEntries = [];
454
+ };
455
+ lines.forEach((line, index) => {
456
+ if (/^\S/.test(line) && line.trim() && !/^\s*\d+:\d+/.test(line) && !/problems?\s*\(/.test(line)) {
457
+ flush(index);
458
+ currentFile = line.trim();
459
+ fileStart = index + 1;
460
+ return;
461
+ }
462
+ const m = line.match(ESLINT_LOCATION);
463
+ if (m && currentFile) {
464
+ totalProblems++;
465
+ ruleCounts.set(m[5], (ruleCounts.get(m[5]) ?? 0) + 1);
466
+ fileEntries.push(`${m[1]}:${m[2]} ${m[3]} ${m[4]} ${m[5]}`);
467
+ }
468
+ });
469
+ flush(lines.length);
470
+ if (totalProblems === 0)
471
+ return { text: trimBlank(lines).join("\n"), sections: [] };
472
+ const topRules = [...ruleCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
473
+ const header = [
474
+ `${totalProblems} lint problem${totalProblems === 1 ? "" : "s"} across ${sections.length} file${sections.length === 1 ? "" : "s"}.`,
475
+ `Top rules: ${topRules.map(([r, n]) => `${r}×${n}`).join(", ")}`,
476
+ "",
477
+ ];
478
+ return { text: [...header, ...out].join("\n"), sections };
479
+ }
480
+ // ── generic fallback ───────────────────────────────────────────────────────────
481
+ const SIGNAL_LINE = /(^|\W)(error|errors|failed|failure|exception|panic|fatal|ERR!|Cannot find|not found|undefined is not|Traceback|SyntaxError|refused)(\W|$)/i;
482
+ function distillGeneric(raw) {
483
+ const lines = raw.split("\n");
484
+ const head = lines.slice(0, GENERIC_EDGE_LINES);
485
+ const tail = lines.slice(Math.max(GENERIC_EDGE_LINES, lines.length - GENERIC_EDGE_LINES));
486
+ const middleStart = GENERIC_EDGE_LINES;
487
+ const middleEnd = lines.length - GENERIC_EDGE_LINES;
488
+ const signal = [];
489
+ const seen = new Set();
490
+ for (let i = middleStart; i < middleEnd; i++) {
491
+ const line = lines[i];
492
+ if (!SIGNAL_LINE.test(line))
493
+ continue;
494
+ const key = line.trim();
495
+ if (!key || seen.has(key))
496
+ continue;
497
+ seen.add(key);
498
+ signal.push(`${i + 1}: ${line}`);
499
+ if (signal.length >= MAX_GENERIC_SIGNAL_LINES)
500
+ break;
501
+ }
502
+ const out = [];
503
+ out.push(...head);
504
+ const skipped = Math.max(0, middleEnd - middleStart);
505
+ if (skipped > 0) {
506
+ out.push("");
507
+ if (signal.length) {
508
+ out.push(`── ${skipped} middle lines withheld; lines matching error/failure below (with line numbers) ──`);
509
+ out.push(...signal);
510
+ if (signal.length >= MAX_GENERIC_SIGNAL_LINES)
511
+ out.push("… more matches withheld");
512
+ }
513
+ else {
514
+ out.push(`── ${skipped} middle lines withheld (no error/failure markers found) ──`);
515
+ }
516
+ out.push("");
517
+ }
518
+ out.push(...tail);
519
+ return { text: out.join("\n"), sections: [] };
520
+ }
521
+ function distillCommandOutput(rawOutput, options) {
522
+ const raw = rawOutput ?? "";
523
+ const originalChars = raw.length;
524
+ const originalLines = raw === "" ? 0 : raw.split("\n").length;
525
+ const threshold = options.thresholdChars ?? DISTILL_THRESHOLD_CHARS;
526
+ const format = detectFormat(raw);
527
+ // Small outputs: distilling costs more than it saves, and the raw text is already
528
+ // the most faithful thing we can hand back.
529
+ if (options.raw || originalChars <= threshold) {
530
+ return {
531
+ format,
532
+ text: raw,
533
+ handle: null,
534
+ originalChars,
535
+ distilledChars: originalChars,
536
+ originalLines,
537
+ sections: [],
538
+ };
539
+ }
540
+ let result;
541
+ switch (format) {
542
+ case "jest":
543
+ result = distillJest(raw);
544
+ break;
545
+ case "vitest":
546
+ result = distillVitest(raw);
547
+ break;
548
+ case "tsc":
549
+ result = distillTsc(raw);
550
+ break;
551
+ case "eslint":
552
+ result = distillEslint(raw);
553
+ break;
554
+ default:
555
+ result = distillGeneric(raw);
556
+ }
557
+ // If a parser somehow produced something no smaller than the original, the honest move
558
+ // is to hand back the original rather than a lossy view that saved nothing.
559
+ if (result.text.length >= originalChars) {
560
+ return {
561
+ format,
562
+ text: raw,
563
+ handle: null,
564
+ originalChars,
565
+ distilledChars: originalChars,
566
+ originalLines,
567
+ sections: result.sections,
568
+ };
569
+ }
570
+ const handle = storeRawOutput(raw, options.command, format, result.sections);
571
+ return {
572
+ format,
573
+ text: result.text,
574
+ handle,
575
+ originalChars,
576
+ distilledChars: result.text.length,
577
+ originalLines,
578
+ sections: result.sections,
579
+ };
580
+ }
581
+ /** The footer that tells the model — and the user — that this view is not the whole output. */
582
+ function buildDistillFooter(d) {
583
+ if (!d.handle)
584
+ return "";
585
+ const pct = Math.round((1 - d.distilledChars / d.originalChars) * 100);
586
+ const lines = [
587
+ "",
588
+ `── distilled (${d.format}) — showing ${d.distilledChars} of ${d.originalChars} chars (${pct}% withheld, ${d.originalLines} lines total) ──`,
589
+ `Full output stored: handle "${d.handle}". Retrieve any part with output_region:`,
590
+ ` output_region({ handle: "${d.handle}", all: true }) — the complete original output`,
591
+ ];
592
+ if (d.sections.length) {
593
+ lines.push(` output_region({ handle: "${d.handle}", section: "<name>" }) — one block verbatim`);
594
+ }
595
+ lines.push(` output_region({ handle: "${d.handle}", startLine: N, endLine: M }) — a line range`);
596
+ return lines.join("\n");
597
+ }
598
+ //# sourceMappingURL=CommandOutputDistiller.js.map