pi-jev-lens 0.3.0 → 0.4.1

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/README.md CHANGED
@@ -164,17 +164,20 @@ For example, `0/3 new · 5 restored` means no new compressions among three candi
164
164
  New-result counts and recall counts start at zero after loading. `/jev-lens stats` shows new and restored savings separately.
165
165
 
166
166
  In the transcript, a compressed result shows a header like `⌁ jev-lens outline · 179 of 1524 tokens (−88 %)`. When
167
- you expand it with ctrl+e, you see exactly what the model saw. These commands are available:
168
-
169
- Type `/jev-lens ` and press Tab to complete subcommands. After `diff `, completion offers available result numbers.
167
+ you press ctrl+o to expand, you see full output on the left and compressed output on the right.
168
+ Retained lines align with their originals. A `−` marks each omitted line in the full output.
169
+ Long lines wrap. On narrow terminals, the two versions appear one below the other.
170
+ The collapsed result ends with a hint such as `… (48 lines pruned, 50 original, ctrl+o for diff)`.
171
+ The compressed version includes omission markers but excludes the recall footer. Press ctrl+o again to collapse.
172
+ This uses pi's tool expansion keybinding, including any custom binding.
173
+
174
+ Type `/jev-lens ` and press Tab to complete subcommands.
170
175
  Use `/reload` after installing the package in a running pi session.
171
176
 
172
177
  - `/jev-lens` or `/jev-lens stats` shows the statistics, active configuration, and key source.
173
178
  - `/jev-lens help` shows command usage.
174
179
  - `/jev-lens decisions` shows post-send pruning decisions. Post-send pruning is off by default.
175
180
  - `/jev-lens list` lists the latest 200 compressed results with the tokens before and after.
176
- - `/jev-lens diff [n]` opens an overlay for the n-th latest result. It shows the original with the lines that the
177
- model did not get marked with `−`. Press `t` to see what was sent, and `Esc` to close.
178
181
  - `/jev-lens key` stores the API key.
179
182
 
180
183
  If compression fails, jev-lens keeps the full output and shows a warning. A failed post-send classification leaves that result unchanged.
@@ -279,4 +282,6 @@ Issues and pull requests are welcome at [github.com/dizk/pi-jev-lens](https://gi
279
282
  built or chosen must come with benchmark numbers. If the change touches code views, you must use the 500-trajectory
280
283
  slice.
281
284
 
285
+ For npm publication through GitHub Releases, see [Release to npm](docs/releasing.md).
286
+
282
287
  MIT, see LICENSE.
package/index.ts CHANGED
@@ -13,11 +13,11 @@ import { appendFileSync, mkdirSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
15
15
  import type { AgentMessage } from "./src/pi-types.ts";
16
- import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
16
+ import { CONFIG_DIR_NAME, keyText } from "@earendil-works/pi-coding-agent";
17
17
  import { Type } from "typebox";
18
18
  import { createBashToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition } from "@earendil-works/pi-coding-agent";
19
19
  import { Text } from "@earendil-works/pi-tui";
20
- import { DiffOverlay, listLines, savingsLine, type CompressedRecord } from "./src/ui.ts";
20
+ import { ComparisonResult, comparisonHint, listLines, savingsLine, type CompressedRecord } from "./src/ui.ts";
21
21
  import { TypeSafeClient } from "@typesafe-ai/sdk";
22
22
  import { buildItemState, JevClassifier, MockClassifier, type Classifier } from "./src/classifier.ts";
23
23
  import { buildPresendState, decideView, DEFAULT_PROMPTS, expandRelevantBlocks, JevPresend, MockPresend, type PresendClassifier, type PromptVariant } from "./src/presend.ts";
@@ -459,13 +459,23 @@ export default function (pi: ExtensionAPI) {
459
459
  pi.registerTool({
460
460
  ...original,
461
461
  renderResult(result, options, theme, context) {
462
- const rec = recordById.get(context.toolCallId);
462
+ let rec = recordById.get(context.toolCallId);
463
+ // Older rows can leave the recent-results index, but retain their comparison.
464
+ const d = result.details?.jevLens;
465
+ if (!rec && typeof d?.full === "string") {
466
+ const sent = contentText(result.content).replace(/\n\n\[jev-lens:[\s\S]*$/, "");
467
+ rec = { id: context.toolCallId, toolName: original.name, args: d.args,
468
+ kind: d.kind ?? "?", view: d.view ?? "?", full: d.full, sent,
469
+ tokensBefore: estimateTokensOfText(d.full), tokensAfter: estimateTokensOfText(sent),
470
+ included: d.included ?? [], recalls: 0, at: 0 };
471
+ }
463
472
  if (!rec || options.isPartial || context.isError) {
464
473
  return original.renderResult!(result, options, theme, context);
465
474
  }
475
+ if (options.expanded) return new ComparisonResult(rec, theme, `${keyText("app.tools.expand")} to collapse`);
466
476
  let out = savingsLine(rec, theme);
467
- if (options.expanded) out += "\n" + rec.sent;
468
- else out += "\n" + theme.fg("dim", rec.sent.split("\n").slice(0, 3).join("\n"));
477
+ out += "\n" + theme.fg("dim", rec.sent.split("\n").slice(0, 3).join("\n"));
478
+ out += "\n" + theme.fg("dim", comparisonHint(rec, keyText("app.tools.expand")));
469
479
  return new Text(out, 0, 0);
470
480
  },
471
481
  });
@@ -475,8 +485,8 @@ export default function (pi: ExtensionAPI) {
475
485
  // ---- commands ----------------------------------------------------------------------
476
486
 
477
487
  pi.registerCommand("jev-lens", {
478
- description: "Inspect compression and setup: stats | list | diff [n] | decisions | key | help",
479
- getArgumentCompletions: (prefix) => commandCompletions(prefix, records),
488
+ description: "Inspect compression and setup: stats | list | decisions | key | help",
489
+ getArgumentCompletions: (prefix) => commandCompletions(prefix),
480
490
  handler: async (args, ctx) => {
481
491
  const sub = (args ?? "").trim();
482
492
  if (sub === "help" || sub === "--help" || sub === "-h") {
@@ -503,24 +513,6 @@ export default function (pi: ExtensionAPI) {
503
513
  status(ctx);
504
514
  return;
505
515
  }
506
- if (/^diff(?:\s|$)/.test(sub)) {
507
- const arg = sub.slice(4).trim();
508
- const n = Number(arg || "1");
509
- if ((arg && !/^\d+$/.test(arg)) || !Number.isSafeInteger(n) || n < 1) {
510
- ctx.ui.notify("Usage: /jev-lens diff [n]. Use a positive whole number. 1 is the newest result.", "warning");
511
- return;
512
- }
513
- if (!records.length) { ctx.ui.notify("No compressed results yet. Use /jev-lens stats to inspect compression settings.", "info"); return; }
514
- const rec = records[records.length - n];
515
- if (!rec) { ctx.ui.notify(`Result ${n} is not available. Choose 1-${records.length} from /jev-lens list.`, "warning"); return; }
516
- if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify(listLines([rec], { fg: (_c, t) => t, bold: (t) => t }).join("\n"), "info"); return; }
517
- await ctx.ui.custom<void>((tui, theme, _kb, done) => {
518
- const height = Math.max(12, Math.floor(((tui as { terminalHeight?: number }).terminalHeight ?? process.stdout.rows ?? 40) * 0.85));
519
- const overlay = new DiffOverlay(rec, theme, height, () => done(), () => tui.requestRender());
520
- return { render: (w) => overlay.render(w), handleInput: (d) => overlay.handleInput(d), invalidate: () => overlay.invalidate() };
521
- }, { overlay: true, overlayOptions: { width: "92%", maxHeight: "90%", anchor: "center" } });
522
- return;
523
- }
524
516
  if (sub === "list") {
525
517
  ctx.ui.notify(listLines(records, { fg: (_c, t) => t, bold: (t) => t }).join("\n"), "info");
526
518
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jev-lens",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "pi extension that compresses large tool results before they reach the model: jev picks the view (outline, relevant blocks, sections, signals, testlog), full text stays recallable",
5
5
  "author": "Didrik Rognstad",
6
6
  "license": "MIT",
package/src/commands.ts CHANGED
@@ -1,10 +1,8 @@
1
1
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
2
- import type { CompressedRecord } from "./ui.ts";
3
2
 
4
3
  const commands = [
5
4
  { value: "stats", label: "stats", description: "Show statistics and active configuration" },
6
5
  { value: "list", label: "list", description: "List recent compressed results" },
7
- { value: "diff", label: "diff", description: "Compare original and sent output: diff [n], newest = 1" },
8
6
  { value: "decisions", label: "decisions", description: "Show post-send pruning decisions" },
9
7
  { value: "key", label: "key", description: "Store a TypeSafe API key" },
10
8
  { value: "help", label: "help", description: "Show commands and usage" },
@@ -12,24 +10,12 @@ const commands = [
12
10
 
13
11
  export const commandHelp = [
14
12
  "/jev-lens [stats] — Show statistics and active configuration.",
15
- ...commands.slice(1).map((c) => `/jev-lens ${c.value === "diff" ? "diff [n]" : c.value} — ${c.description}.`),
16
- "For diff, 1 is the newest result. Use /jev-lens list to find a number.",
13
+ ...commands.slice(1).map((c) => `/jev-lens ${c.value} — ${c.description}.`),
14
+ "ctrl+o to expand tool output and compare full and compressed output (default keybinding).",
17
15
  "Press Tab after /jev-lens to complete a subcommand.",
18
16
  ].join("\n");
19
17
 
20
- /** Pi replaces the entire argument prefix, so diff values include the subcommand. */
21
- export function commandCompletions(prefix: string, records: CompressedRecord[]): AutocompleteItem[] | null {
22
- const input = prefix.trimStart();
23
- const diff = /^diff\s+(\d*)$/.exec(input);
24
- let items: AutocompleteItem[];
25
- if (diff) {
26
- items = [...records].reverse().map((r, i) => ({
27
- value: `diff ${i + 1}`,
28
- label: `diff ${i + 1}`,
29
- description: `${r.toolName} · ${r.view} · ${r.tokensBefore} → ${r.tokensAfter} tokens`,
30
- })).filter((_, i) => String(i + 1).startsWith(diff[1]));
31
- } else {
32
- items = commands.filter((c) => c.value.startsWith(input));
33
- }
18
+ export function commandCompletions(prefix: string): AutocompleteItem[] | null {
19
+ const items = commands.filter((c) => c.value.startsWith(prefix.trimStart()));
34
20
  return items.length ? items : null;
35
21
  }
package/src/health.ts CHANGED
@@ -2,6 +2,29 @@ export type Stage = "presend" | "postsend";
2
2
 
3
3
  type StageHealth = { failures: number; failing: boolean; reason: string; notified: boolean };
4
4
 
5
+ /** Report only validated status codes and fixed advice, never raw provider data. */
6
+ function failureReason(error: unknown): string {
7
+ const e = error && typeof error === "object" ? error as { status?: unknown; name?: unknown } : {};
8
+ const status = typeof e.status === "number" && Number.isInteger(e.status) && e.status >= 100 && e.status <= 599 ? e.status : undefined;
9
+ if (status !== undefined) {
10
+ const advice = status === 402 ? "Payment required. Check your TypeSafe credits and billing at https://console.typesafe.ai."
11
+ : status === 401 ? "Authentication failed. Check your TypeSafe API key."
12
+ : status === 403 ? "Access denied. Check your TypeSafe API key and account permissions."
13
+ : status === 429 ? "TypeSafe rejected the request because of a usage limit. Check your rate limits and quota at https://console.typesafe.ai."
14
+ : status === 400 || status === 422 ? "TypeSafe rejected the request format. Check SDK compatibility and the model configuration."
15
+ : status === 404 ? "TypeSafe could not find the requested resource. Check the model and API endpoint."
16
+ : status === 408 || status === 504 ? "The API request timed out. Retry later."
17
+ : status >= 500 ? "TypeSafe reported a server error. Retry later and check service availability."
18
+ : "TypeSafe returned an unexpected HTTP response. Check service availability and account settings.";
19
+ return `HTTP ${status}: ${advice}`;
20
+ }
21
+ if (e.name === "APITimeoutError" || e.name === "TimeoutError") return "Request timed out (no HTTP status). Check your connection and TypeSafe service availability.";
22
+ if (e.name === "APIConnectionError") return "Connection failed (no HTTP status). Check your network, proxy, and TypeSafe service availability.";
23
+ if (e.name === "TypeSafeError") return "TypeSafe SDK error (no HTTP status). Check SDK compatibility and configuration.";
24
+ if (e.name === "TypeError" || e.name === "RangeError" || e.name === "SyntaxError") return `${e.name} during compression (no HTTP status). The cause is unknown. Report this as a jev-lens bug if it persists.`;
25
+ return "Unclassified error (no HTTP status). The cause is unknown. Report this as a jev-lens bug if it persists.";
26
+ }
27
+
5
28
  /** Session-local failure counters. Never expose provider error messages or credentials. */
6
29
  export class Health {
7
30
  private stages: Record<Stage, StageHealth> = {
@@ -10,10 +33,7 @@ export class Health {
10
33
  };
11
34
 
12
35
  failure(stage: Stage, error: unknown): void {
13
- const status = error && typeof error === "object" && "status" in error ? error.status : undefined;
14
- const reason = status === 401 || status === 403 ? "Check your TypeSafe API key."
15
- : status === 429 ? "TypeSafe rejected the request because of a usage limit."
16
- : "Check your connection and TypeSafe service availability.";
36
+ const reason = failureReason(error);
17
37
  Object.assign(this.stages[stage], { failures: this.stages[stage].failures + 1, failing: true, reason });
18
38
  }
19
39
 
package/src/ui.ts CHANGED
@@ -2,11 +2,9 @@
2
2
  * TUI integration: what the model got versus what the tool really returned.
3
3
  *
4
4
  * - Built-in tools (read, bash, grep, find, ls) are re-registered with renderers that show, for
5
- * a compressed result, a one-line savings header and (expanded) the exact text the model saw.
6
- * - `/jev-lens diff [n]` opens an overlay with the original output, omitted lines marked, and
7
- * `t` toggles to the sent view.
5
+ * a compressed result, a savings header and an inline comparison when expanded.
8
6
  */
9
- import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
7
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
10
8
 
11
9
  /** Everything needed to show one compressed result. Stored per session and in the tool result details. */
12
10
  export interface CompressedRecord {
@@ -39,11 +37,16 @@ export function savingsLine(r: CompressedRecord, theme: ThemeLike): string {
39
37
  theme.fg("accent", "⌁ jev-lens ") +
40
38
  theme.fg("toolTitle", theme.bold(r.view)) +
41
39
  theme.fg("muted", ` · ${r.tokensAfter} of ${r.tokensBefore} tokens (−${pct} %)`) +
42
- (r.recalls ? theme.fg("warning", ` · recalled ${r.recalls}×`) : "") +
43
- theme.fg("dim", " · /jev-lens diff")
40
+ (r.recalls ? theme.fg("warning", ` · recalled ${r.recalls}×`) : "")
44
41
  );
45
42
  }
46
43
 
44
+ export function comparisonHint(r: CompressedRecord, key: string): string {
45
+ const original = r.full.split("\n").length;
46
+ const pruned = original - new Set(r.included).size;
47
+ return `… (${pruned} ${pruned === 1 ? "line" : "lines"} pruned, ${original} original, ${key} for diff)`;
48
+ }
49
+
47
50
  function describeArgs(toolName: string, args: unknown): string {
48
51
  const a = (args ?? {}) as Record<string, unknown>;
49
52
  if (typeof a.path === "string") return a.path;
@@ -52,83 +55,59 @@ function describeArgs(toolName: string, args: unknown): string {
52
55
  return JSON.stringify(a).slice(0, 80);
53
56
  }
54
57
 
55
- /**
56
- * Overlay showing the original output with omitted lines marked (mode "annotated"), or exactly
57
- * what was sent (mode "sent"). Keys: ↑/↓ scroll, PgUp/PgDn, Home/End, t toggle, Esc/q close.
58
- */
59
- export class DiffOverlay {
60
- private offset = 0;
61
- private mode: "annotated" | "sent" = "annotated";
58
+ /** Inline comparison. Pi owns expansion and transcript scrolling; no extra key handler. */
59
+ export class ComparisonResult {
62
60
  private cache?: { width: number; lines: string[] };
63
- constructor(
64
- private record: CompressedRecord,
65
- private theme: ThemeLike,
66
- private height: number,
67
- private onClose: () => void,
68
- private onChange?: () => void,
69
- ) {}
61
+ constructor(private record: CompressedRecord, private theme: ThemeLike, private hint: string) {}
70
62
 
71
- private bodyLines(width: number): string[] {
63
+ render(width: number): string[] {
64
+ if (width < 1) return [];
65
+ if (this.cache?.width === width) return this.cache.lines;
72
66
  const r = this.record;
73
- const out: string[] = [];
74
- if (this.mode === "sent") {
75
- for (const l of r.sent.split("\n")) out.push(truncateToWidth(l, width));
76
- return out;
77
- }
78
- const inc = new Set(r.included);
79
- const lines = r.full.split("\n");
80
- const w = String(lines.length).length;
81
- for (let i = 0; i < lines.length; i++) {
82
- const n = String(i + 1).padStart(w);
83
- if (inc.has(i + 1)) out.push(truncateToWidth(this.theme.fg("dim", `${n} `) + this.theme.fg("text", "│ ") + lines[i], width));
84
- else out.push(truncateToWidth(this.theme.fg("toolDiffRemoved", `${n} − ${lines[i]}`), width));
67
+ const full = r.full.split("\n");
68
+ const included = new Set(r.included);
69
+ const digits = String(full.length).length;
70
+ const original = (i: number) => this.theme.fg(included.has(i + 1) ? "text" : "toolDiffRemoved",
71
+ `${String(i + 1).padStart(digits)} ${included.has(i + 1) ? "│" : "−"} ${full[i]}`);
72
+ const wrap = (text: string, columns: number) => wrapTextWithAnsi(text.replace(/\t/g, " "), columns)
73
+ .map((line) => truncateToWidth(line, columns));
74
+ const out = wrap(savingsLine(r, this.theme), width);
75
+ out.push(...wrap(this.theme.fg("dim", this.hint), width));
76
+ out.push(...wrap(this.theme.fg("dim", "− omitted from model input · compressed view excludes the recall footer"), width));
77
+ if (width < 100) {
78
+ out.push(...wrap(this.theme.bold("Full output"), width));
79
+ for (let i = 0; i < full.length; i++) out.push(...wrap(original(i), width));
80
+ out.push("", ...wrap(this.theme.bold("Compressed output"), width));
81
+ for (const line of r.sent.split("\n")) out.push(...wrap(line, width));
82
+ } else {
83
+ const leftWidth = Math.floor((width - 3) / 2);
84
+ const rightWidth = width - leftWidth - 3;
85
+ const row = (left: string, right: string) => {
86
+ const a = wrap(left, leftWidth), b = wrap(right, rightWidth);
87
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
88
+ const l = a[i] ?? "";
89
+ out.push(l + " ".repeat(Math.max(0, leftWidth - visibleWidth(l))) + this.theme.fg("dim", " │ ") + (b[i] ?? ""));
90
+ }
91
+ };
92
+ row(this.theme.bold("Full output"), this.theme.bold("Compressed output"));
93
+ let cursor = 0;
94
+ // Align numbered view lines with their originals. Keep markers and any other
95
+ // generated view text verbatim on separate rows, rather than reconstructing it.
96
+ for (const line of r.sent.split("\n")) {
97
+ const match = /^\s*(\d+)│ /.exec(line);
98
+ const n = match ? Number(match[1]) : 0;
99
+ if (n > cursor && n <= full.length && included.has(n)) {
100
+ while (cursor < n - 1) row(original(cursor++), "");
101
+ row(original(cursor++), line);
102
+ } else row("", line);
103
+ }
104
+ while (cursor < full.length) row(original(cursor++), "");
85
105
  }
106
+ this.cache = { width, lines: out };
86
107
  return out;
87
108
  }
88
109
 
89
- header(width: number): string[] {
90
- const r = this.record;
91
- const pct = r.tokensBefore ? Math.round((100 * (r.tokensBefore - r.tokensAfter)) / r.tokensBefore) : 0;
92
- const omitted = r.full.split("\n").length - r.included.length;
93
- return [
94
- truncateToWidth(this.theme.fg("accent", this.theme.bold(`jev-lens · ${r.toolName} ${describeArgs(r.toolName, r.args)}`)), width),
95
- truncateToWidth(this.theme.fg("muted", `${r.kind} → ${r.view} · ${r.tokensAfter} of ${r.tokensBefore} tokens (−${pct} %) · ${omitted} of ${r.full.split("\n").length} lines omitted` + (r.needsFull !== undefined ? ` · P(needs full)=${r.needsFull.toFixed(2)} P(full)=${(r.pFull ?? 0).toFixed(2)}` : "") + (r.recalls ? ` · recalled ${r.recalls}×` : "")), width),
96
- truncateToWidth(this.theme.fg("dim", this.mode === "annotated" ? "original output; − marks lines the model did not get · t: show what was sent · ↑↓ PgUp PgDn · Esc" : "exactly what the model got · t: show original with omissions · ↑↓ PgUp PgDn · Esc"), width),
97
- "",
98
- ];
99
- }
100
-
101
- render(width: number): string[] {
102
- if (this.cache && this.cache.width === width) return this.cache.lines;
103
- const head = this.header(width);
104
- const body = this.bodyLines(width);
105
- const room = Math.max(3, this.height - head.length - 1);
106
- const maxOffset = Math.max(0, body.length - room);
107
- if (this.offset > maxOffset) this.offset = maxOffset;
108
- const slice = body.slice(this.offset, this.offset + room);
109
- const footer = truncateToWidth(this.theme.fg("dim", `lines ${body.length ? this.offset + 1 : 0}-${Math.min(body.length, this.offset + room)} of ${body.length}`), width);
110
- this.cache = { width, lines: [...head, ...slice, footer] };
111
- return this.cache.lines;
112
- }
113
-
114
- handleInput(data: string): void {
115
- const page = Math.max(1, this.height - 6);
116
- if (matchesKey(data, "escape") || data === "q") { this.onClose(); return; }
117
- else if (matchesKey(data, "up")) this.offset = Math.max(0, this.offset - 1);
118
- else if (matchesKey(data, "down")) this.offset += 1;
119
- else if (matchesKey(data, "pageUp")) this.offset = Math.max(0, this.offset - page);
120
- else if (matchesKey(data, "pageDown")) this.offset += page;
121
- else if (matchesKey(data, "home")) this.offset = 0;
122
- else if (matchesKey(data, "end")) this.offset = Number.MAX_SAFE_INTEGER;
123
- else if (data === "t") { this.mode = this.mode === "annotated" ? "sent" : "annotated"; this.offset = 0; }
124
- else return;
125
- this.invalidate();
126
- this.onChange?.();
127
- }
128
-
129
- invalidate(): void {
130
- this.cache = undefined;
131
- }
110
+ invalidate(): void { this.cache = undefined; }
132
111
  }
133
112
 
134
113
  /** One row per compressed result, newest last. */