pi-jev-lens 0.3.0 → 0.4.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/README.md +8 -5
- package/index.ts +17 -25
- package/package.json +1 -1
- package/src/commands.ts +4 -18
- package/src/ui.ts +55 -76
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
|
|
168
|
-
|
|
169
|
-
|
|
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.
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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 |
|
|
479
|
-
getArgumentCompletions: (prefix) => commandCompletions(prefix
|
|
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
|
+
"version": "0.4.0",
|
|
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
|
|
16
|
-
"
|
|
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
|
-
|
|
21
|
-
|
|
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/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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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. */
|