pi-jev-lens 0.2.1 → 0.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/README.md CHANGED
@@ -64,6 +64,8 @@ Three results shaped the design:
64
64
 
65
65
  ## Install
66
66
 
67
+ Use pi 0.84.3 or newer. The extension uses pi's built-in tool renderers for results that it does not compress.
68
+
67
69
  ```sh
68
70
  pi install npm:pi-jev-lens # from npm
69
71
  pi install git:github.com/dizk/pi-jev-lens # or from GitHub
@@ -73,12 +75,22 @@ jev needs a TypeSafe API key. You can get one at [console.typesafe.ai](https://c
73
75
  for the key in this order:
74
76
 
75
77
  1. `TYPESAFE_API_KEY` in the environment.
76
- 2. The key that you stored with `/jev-lens key` inside pi. The command prompts for the key, or you can give it as
77
- `/jev-lens key ts_...`. The key is stored in `~/.pi/agent/jev-lens.json`, readable only by you. jev is active from
78
- the next tool result. You do not have to restart pi.
79
- 3. A `.env` file next to the installed package. This is for development.
78
+ 2. A `.env` file next to the installed package. This is for development and supplies environment values that are not already set.
79
+ 3. The key that you stored with `/jev-lens key` inside pi. In terminal mode, the command opens a masked input field.
80
+ The key is stored in `~/.pi/agent/jev-lens.json`. New files are readable only by you.
81
+
82
+ Use `/jev-lens key` without an argument to keep the key out of command history. The field displays only `*` characters.
83
+ Type or paste the key, then press Enter to save. Press Esc to cancel without changing the stored key.
84
+ In RPC or noninteractive mode, set `TYPESAFE_API_KEY`. These modes do not fall back to a visible input field.
85
+ The key file still stores the key as plain text. Masking protects the terminal display, not the file.
86
+
87
+ You can still use `/jev-lens key ts_...`, but that exposes the key in the editor and can retain it in command history.
88
+ A new key takes effect without a restart, but the command does not validate it.
89
+ If `TYPESAFE_API_KEY` is set, that value takes priority again after reload.
90
+ If mock mode is forced or compression is disabled, storing a key does not change those settings.
80
91
 
81
- If no key is found, the extension shows a warning at startup and runs a mock classifier that compresses nothing.
92
+ If no key is found, the extension shows a warning at startup and uses a deterministic mock classifier.
93
+ The mock can compress results without API calls. It is not the jev model.
82
94
 
83
95
  For development, clone the repository and load it directly:
84
96
 
@@ -143,20 +155,35 @@ The footer shows the share of the session's input tokens that jev kept out of th
143
155
  jev-lens −38% of input (presend −12.3k · 5/8 · 1 recalls)
144
156
  ```
145
157
 
146
- The share is cut divided by sent plus cut. Sent is the provider's own count of input and cache-read tokens over all
147
- calls. Cut is what every compressed result saved on every call that it was part of.
158
+ The share is cut divided by sent plus cut. Sent counts input and cache-read tokens reported by the provider since the last load.
159
+ Cut estimates what compressed results saved on those calls, including results restored from the session.
160
+ The percentage counts repeated savings when the same result appears in later prompts. The `presend` total counts each result once.
161
+
162
+ After `/reload` or resume, the footer includes saved tokens from restored compressed results.
163
+ For example, `0/3 new · 5 restored` means no new compressions among three candidates, plus five restored compressed results.
164
+ New-result counts and recall counts start at zero after loading. `/jev-lens stats` shows new and restored savings separately.
148
165
 
149
166
  In the transcript, a compressed result shows a header like `⌁ jev-lens outline · 179 of 1524 tokens (−88 %)`. When
150
167
  you expand it with ctrl+e, you see exactly what the model saw. These commands are available:
151
168
 
152
- - `/jev-lens` shows the statistics and where the key comes from.
169
+ Type `/jev-lens ` and press Tab to complete subcommands. After `diff `, completion offers available result numbers.
170
+ Use `/reload` after installing the package in a running pi session.
171
+
172
+ - `/jev-lens` or `/jev-lens stats` shows the statistics, active configuration, and key source.
173
+ - `/jev-lens help` shows command usage.
174
+ - `/jev-lens decisions` shows post-send pruning decisions. Post-send pruning is off by default.
153
175
  - `/jev-lens list` lists the latest 200 compressed results with the tokens before and after.
154
176
  - `/jev-lens diff [n]` opens an overlay for the n-th latest result. It shows the original with the lines that the
155
177
  model did not get marked with `−`. Press `t` to see what was sent, and `Esc` to close.
156
178
  - `/jev-lens key` stores the API key.
157
179
 
158
- jev-lens logs every decision to `<project>/.pi/jev-lens.log` as JSON lines. Set `JEV_LENS_UI=0` to keep pi's own
159
- tool rendering.
180
+ If compression fails, jev-lens keeps the full output and shows a warning. A failed post-send classification leaves that result unchanged.
181
+ Warnings appear at most once per stage per session. The footer shows `degraded` until a later attempt in that stage succeeds.
182
+ Use `/jev-lens stats` to see failure counts and recovery status. Cancellation does not count as a failure.
183
+
184
+ Uncompressed results, errors, and streaming updates use pi's built-in tool renderers.
185
+ jev-lens logs every decision to `<project>/.pi/jev-lens.log` as JSON lines. Set `JEV_LENS_UI=0` to disable the
186
+ custom savings headers and tool overrides.
160
187
 
161
188
  ### Configuration (environment)
162
189
 
package/index.ts CHANGED
@@ -11,11 +11,11 @@
11
11
  */
12
12
  import { appendFileSync, mkdirSync } from "node:fs";
13
13
  import { join } from "node:path";
14
- import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
14
+ import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
15
15
  import type { AgentMessage } from "./src/pi-types.ts";
16
16
  import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
17
17
  import { Type } from "typebox";
18
- import { createBashTool, createFindTool, createGrepTool, createLsTool, createReadTool } from "@earendil-works/pi-coding-agent";
18
+ import { createBashToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition } from "@earendil-works/pi-coding-agent";
19
19
  import { Text } from "@earendil-works/pi-tui";
20
20
  import { DiffOverlay, listLines, savingsLine, type CompressedRecord } from "./src/ui.ts";
21
21
  import { TypeSafeClient } from "@typesafe-ai/sdk";
@@ -23,6 +23,9 @@ import { buildItemState, JevClassifier, MockClassifier, type Classifier } from "
23
23
  import { buildPresendState, decideView, DEFAULT_PROMPTS, expandRelevantBlocks, JevPresend, MockPresend, type PresendClassifier, type PromptVariant } from "./src/presend.ts";
24
24
  import { buildCandidatesAsync, extractTerms, footer } from "./src/views.ts";
25
25
  import { keyFilePath, loadConfigWithVariant, storeKey, type Config } from "./src/config.ts";
26
+ import { Health } from "./src/health.ts";
27
+ import { SecretInput } from "./src/secret-input.ts";
28
+ import { commandCompletions, commandHelp } from "./src/commands.ts";
26
29
  import { ENTRY_TYPE, rebuildLedger } from "./src/ledger.ts";
27
30
  import { applyLedger, decideBucket, pendingPrunable, shouldApplyPending } from "./src/policy.ts";
28
31
  import { contentText, describeToolCall, estimateTokensOfText, toolCallsOf, truncate } from "./src/text.ts";
@@ -37,6 +40,7 @@ export default function (pi: ExtensionAPI) {
37
40
  const { cfg, variant } = loadConfigWithVariant();
38
41
  const prompts: PromptVariant = { ...DEFAULT_PROMPTS, ...((variant.prompts ?? {}) as Partial<PromptVariant>), viewDescriptions: { ...DEFAULT_PROMPTS.viewDescriptions, ...(((variant.prompts ?? {}) as Partial<PromptVariant>).viewDescriptions ?? {}) } };
39
42
  const viewParams = variant.views ?? {};
43
+ let keySource = process.env.TYPESAFE_API_KEY === cfg.apiKey && cfg.apiKey ? "env" : cfg.apiKey ? keyFilePath() : "none";
40
44
  let usingMock = cfg.forceMock || !cfg.apiKey;
41
45
  let classifier: Classifier = usingMock ? new MockClassifier() : new JevClassifier(cfg);
42
46
  let presend: PresendClassifier = usingMock ? new MockPresend() : new JevPresend(new TypeSafeClient({ apiKey: cfg.apiKey }), cfg.model, prompts);
@@ -54,7 +58,9 @@ export default function (pi: ExtensionAPI) {
54
58
  const recordById = new Map<string, CompressedRecord>();
55
59
  const remember = (r: CompressedRecord) => { records.push(r); recordById.set(r.id, r); if (records.length > 200) { const old = records.shift(); if (old) recordById.delete(old.id); } };
56
60
  let lastAssistantText = "";
61
+ let health = new Health();
57
62
  let presendTotals = { considered: 0, compressed: 0, tokensSaved: 0, recalls: 0 };
63
+ let restored = { compressed: 0, tokensSaved: 0 };
58
64
 
59
65
  let ledger = new Map<string, Decision>();
60
66
  /** Classifications launched but not yet resolved, keyed by toolCallId. */
@@ -96,14 +102,18 @@ export default function (pi: ExtensionAPI) {
96
102
  return sent > 0 ? Math.round((100 * kept) / (sent + kept)) : undefined;
97
103
  };
98
104
  const statusText = () => {
99
- const tag = usingMock ? "jev-lens(mock)" : "jev-lens";
105
+ const tag = !cfg.enabled ? "jev-lens(disabled)" : usingMock ? "jev-lens(mock)" : "jev-lens";
100
106
  const pct = cutShare();
101
- const lead = pct === undefined ? tag : `${tag} −${pct}% of input`;
107
+ const label = health.failing ? `${tag}(degraded)` : tag;
108
+ const lead = pct === undefined ? label : `${label} −${pct}% of input`;
102
109
  const pruned = cfg.mode === "off" ? "" : `, pruned −${(totals.pruned / 1000).toFixed(1)}k · ${totals.applied}`;
103
- return `${lead} (presend −${(presendTotals.tokensSaved / 1000).toFixed(1)}k · ${presendTotals.compressed}/${presendTotals.considered} · ${presendTotals.recalls} recalls${pruned})`;
110
+ const saved = presendTotals.tokensSaved + restored.tokensSaved;
111
+ const counts = `${presendTotals.compressed}/${presendTotals.considered}${restored.compressed ? ` new · ${restored.compressed} restored` : ""}`;
112
+ return `${lead} (presend −${(saved / 1000).toFixed(1)}k · ${counts} · ${presendTotals.recalls} recalls${pruned})`;
104
113
  };
105
114
  const status = (ctx: ExtensionContext) => {
106
115
  if (!ctx.hasUI) return;
116
+ for (const warning of health.warnings()) ctx.ui.notify(warning, "warning");
107
117
  ctx.ui.setStatus("jev-lens", statusText());
108
118
  };
109
119
 
@@ -116,6 +126,7 @@ export default function (pi: ExtensionAPI) {
116
126
  sessionAbort.abort();
117
127
  sessionAbort = new AbortController();
118
128
  lastAssistantText = "";
129
+ health = new Health();
119
130
  ledger = rebuildLedger(ctx.sessionManager.getEntries());
120
131
  buffer = [];
121
132
  inflight.clear();
@@ -130,6 +141,7 @@ export default function (pi: ExtensionAPI) {
130
141
  records.length = 0;
131
142
  recordById.clear();
132
143
  presendTotals = { considered: 0, compressed: 0, tokensSaved: 0, recalls: 0 };
144
+ restored = { compressed: 0, tokensSaved: 0 };
133
145
  try {
134
146
  mkdirSync(join(ctx.cwd, CONFIG_DIR_NAME), { recursive: true });
135
147
  logPath = join(ctx.cwd, CONFIG_DIR_NAME, "jev-lens.log");
@@ -147,6 +159,8 @@ export default function (pi: ExtensionAPI) {
147
159
  if (d?.full) {
148
160
  fullOutputs.set(entry.message.toolCallId, { text: d.full, toolName: entry.message.toolName, args: d.args, view: d.view ?? "?" });
149
161
  const sent = contentText(entry.message.content).replace(/\n\n\[jev-lens:[\s\S]*$/, "");
162
+ restored.compressed++;
163
+ restored.tokensSaved += Math.max(0, estimateTokensOfText(d.full) - estimateTokensOfText(sent));
150
164
  remember({ id: entry.message.toolCallId, toolName: entry.message.toolName, args: d.args, kind: d.kind ?? "?", view: d.view ?? "?", tokensBefore: estimateTokensOfText(d.full), tokensAfter: estimateTokensOfText(sent), full: d.full, sent, included: d.included ?? [], needsFull: d.needsFull, pFull: d.p?.full, recalls: 0, at: entry.message.timestamp });
151
165
  }
152
166
  }
@@ -206,20 +220,19 @@ export default function (pi: ExtensionAPI) {
206
220
  }
207
221
  });
208
222
 
209
- pi.on("agent_end", async () => {
210
- const epoch = generation;
223
+ pi.on("agent_end", async (_event, ctx) => {
211
224
  // No further assistant reaction is coming for the last results; classify with what we have.
212
225
  const toClassify = buffer;
213
226
  buffer = [];
214
- for (const item of toClassify) launchClassification(item, "", [], undefined);
227
+ for (const item of toClassify) launchClassification(item, "", [], ctx);
215
228
  await waitForWork([...inflight.values()]);
216
- void epoch;
217
229
  });
218
230
 
219
231
  function launchClassification(item: PendingResult, afterText: string, afterCalls: { name: string; arguments: unknown }[], ctx?: ExtensionContext) {
220
232
  const m = item.message;
221
233
  if (cfg.mode === "off" || sessionAbort.signal.aborted || m.content.some((c) => c.type !== "text")) return;
222
234
  const epoch = generation;
235
+ const signal = workSignal(ctx?.signal);
223
236
  if (ledger.has(m.toolCallId) || inflight.has(m.toolCallId)) return;
224
237
  const output = contentText(m.content);
225
238
  const tokens = estimateTokensOfText(output);
@@ -238,9 +251,11 @@ export default function (pi: ExtensionAPI) {
238
251
  const summary = describeToolCall(m.toolName, item.args, output.length, lines);
239
252
  const started = Date.now();
240
253
  const p = classifier
241
- .classifyToolResult(state, workSignal(ctx?.signal))
254
+ .classifyToolResult(state, signal)
242
255
  .then((probs) => {
243
- if (epoch !== generation) return;
256
+ if (epoch !== generation || signal.aborted) return;
257
+ health.success("postsend");
258
+ if (ctx) status(ctx);
244
259
  const decision: Decision = {
245
260
  id: m.toolCallId,
246
261
  toolName: m.toolName,
@@ -256,7 +271,10 @@ export default function (pi: ExtensionAPI) {
256
271
  log({ event: "decision", id: decision.id, tool: m.toolName, bucket: decision.bucket, p: probs, tokens, ms: Date.now() - started, summary });
257
272
  })
258
273
  .catch((err) => {
259
- if (epoch === generation) log({ event: "classify_error", id: m.toolCallId, error: String(err?.message ?? err) });
274
+ if (epoch !== generation || signal.aborted) return;
275
+ health.failure("postsend", err);
276
+ log({ event: "classify_error", id: m.toolCallId, error: health.lines()[1] });
277
+ if (ctx) status(ctx);
260
278
  })
261
279
  .finally(() => { if (epoch === generation) inflight.delete(m.toolCallId); });
262
280
  inflight.set(m.toolCallId, p);
@@ -348,16 +366,16 @@ export default function (pi: ExtensionAPI) {
348
366
  if (event.content.some((c) => c.type === "image")) return;
349
367
  presendTotals.considered++;
350
368
  const started = Date.now();
351
- const terms = extractTerms(latestUser, lastAssistantText, JSON.stringify(event.input ?? {}));
352
- const cands = await buildCandidatesAsync(event.toolName, event.input, text, terms, viewParams);
353
- if (epoch !== generation || signal.aborted) return;
354
- if (cands.views.length < 2) {
355
- log({ event: "presend", id: event.toolCallId, tool: event.toolName, tokens, view: "full", reason: "no-candidates" });
356
- return;
357
- }
358
- const totalLines = text.split("\n").length;
359
- const state = buildPresendState(cfg, { firstUser, latestUser, agentText: lastAssistantText, toolName: event.toolName, args: event.input, isError: event.isError, cands, totalLines, totalChars: text.length });
360
369
  try {
370
+ const terms = extractTerms(latestUser, lastAssistantText, JSON.stringify(event.input ?? {}));
371
+ const cands = await buildCandidatesAsync(event.toolName, event.input, text, terms, viewParams);
372
+ if (epoch !== generation || signal.aborted) return;
373
+ if (cands.views.length < 2) {
374
+ log({ event: "presend", id: event.toolCallId, tool: event.toolName, tokens, view: "full", reason: "no-candidates" });
375
+ return;
376
+ }
377
+ const totalLines = text.split("\n").length;
378
+ const state = buildPresendState(cfg, { firstUser, latestUser, agentText: lastAssistantText, toolName: event.toolName, args: event.input, isError: event.isError, cands, totalLines, totalChars: text.length });
361
379
  const answer = await presend.choose(state, cands.views.map((v) => v.kind), signal);
362
380
  if (epoch !== generation || signal.aborted) return;
363
381
  let view = decideView(answer, cands, cfg);
@@ -368,6 +386,8 @@ export default function (pi: ExtensionAPI) {
368
386
  if (epoch !== generation || signal.aborted) return;
369
387
  if (ex) { view = ex.view; expanded = ex.probs.map((p, i) => (p > above ? i : -1)).filter((i) => i >= 0); }
370
388
  }
389
+ health.success("presend");
390
+ status(ctx);
371
391
  log({ event: "presend", id: event.toolCallId, tool: event.toolName, kind: cands.kind, tokens, view: view.kind, viewTokens: estimateTokensOfText(view.text), chosen: answer.choice, needsFull: answer.needsFull, p: answer.probabilities, confidence: answer.confidence, expanded, candidates: cands.views.map((v) => `${v.kind}:${v.chars}`), ms: Date.now() - started });
372
392
  if (view.kind === "full") return;
373
393
  presendTotals.compressed++;
@@ -378,7 +398,10 @@ export default function (pi: ExtensionAPI) {
378
398
  status(ctx);
379
399
  return { content: [{ type: "text", text: view.text + footer(view, event.toolCallId, totalLines) }], details };
380
400
  } catch (err) {
381
- if (epoch === generation) log({ event: "presend_error", id: event.toolCallId, error: String((err as Error)?.message ?? err) });
401
+ if (epoch !== generation || signal.aborted) return;
402
+ health.failure("presend", err);
403
+ log({ event: "presend_error", id: event.toolCallId, error: health.lines()[0] });
404
+ status(ctx);
382
405
  return;
383
406
  }
384
407
  });
@@ -427,71 +450,69 @@ export default function (pi: ExtensionAPI) {
427
450
 
428
451
  if (process.env.JEV_LENS_UI !== "0") {
429
452
  const cwd = process.cwd();
430
- const originals: Record<string, ReturnType<typeof createReadTool>> = {
431
- read: createReadTool(cwd) as ReturnType<typeof createReadTool>,
432
- bash: createBashTool(cwd) as unknown as ReturnType<typeof createReadTool>,
433
- grep: createGrepTool(cwd) as unknown as ReturnType<typeof createReadTool>,
434
- find: createFindTool(cwd) as unknown as ReturnType<typeof createReadTool>,
435
- ls: createLsTool(cwd) as unknown as ReturnType<typeof createReadTool>,
436
- };
437
- for (const [name, original] of Object.entries(originals)) {
438
- const o = original as unknown as { description: string; parameters: never; execute: (...a: unknown[]) => Promise<unknown>; renderCall?: (...a: unknown[]) => unknown; renderResult?: (...a: unknown[]) => unknown; promptSnippet?: string; promptGuidelines?: string[] };
453
+ // Tool definitions include pi's renderers; create*Tool() strips them.
454
+ const originals: ToolDefinition<any, any>[] = [
455
+ createReadToolDefinition(cwd), createBashToolDefinition(cwd),
456
+ createGrepToolDefinition(cwd), createFindToolDefinition(cwd), createLsToolDefinition(cwd),
457
+ ];
458
+ for (const original of originals) {
439
459
  pi.registerTool({
440
- name,
441
- label: name,
442
- description: o.description,
443
- parameters: o.parameters,
444
- promptSnippet: o.promptSnippet,
445
- promptGuidelines: o.promptGuidelines,
446
- async execute(toolCallId: string, params: unknown, signal: AbortSignal | undefined, onUpdate: unknown, ctx: unknown) {
447
- return (o.execute as (id: string, p: unknown, s: unknown, u: unknown, c: unknown) => Promise<never>)(toolCallId, params, signal, onUpdate, ctx);
448
- },
449
- renderCall(args: unknown, theme: Theme, context: unknown) {
450
- if (o.renderCall) return (o.renderCall as (a: unknown, t: unknown, c: unknown) => never)(args, theme, context);
451
- const a = args as Record<string, unknown>;
452
- const what = typeof a.path === "string" ? a.path : typeof a.command === "string" ? a.command : typeof a.pattern === "string" ? a.pattern : "";
453
- return new Text(theme.fg("toolTitle", theme.bold(`${name} `)) + theme.fg("accent", String(what)), 0, 0);
454
- },
455
- renderResult(result: { content: unknown }, options: { expanded: boolean }, theme: Theme, context: { toolCallId: string }) {
460
+ ...original,
461
+ renderResult(result, options, theme, context) {
456
462
  const rec = recordById.get(context.toolCallId);
457
- if (!rec) {
458
- if (o.renderResult) return (o.renderResult as (r: unknown, op: unknown, t: unknown, c: unknown) => never)(result, options, theme, context);
459
- const text = contentText(result.content);
460
- const lines = text.split("\n");
461
- let out = theme.fg("success", `${lines.length} lines`);
462
- if (options.expanded) out += "\n" + lines.slice(0, 200).join("\n");
463
- else out += theme.fg("dim", " " + lines[0]?.slice(0, 80));
464
- return new Text(out, 0, 0);
463
+ if (!rec || options.isPartial || context.isError) {
464
+ return original.renderResult!(result, options, theme, context);
465
465
  }
466
466
  let out = savingsLine(rec, theme);
467
467
  if (options.expanded) out += "\n" + rec.sent;
468
468
  else out += "\n" + theme.fg("dim", rec.sent.split("\n").slice(0, 3).join("\n"));
469
469
  return new Text(out, 0, 0);
470
470
  },
471
- } as never);
471
+ });
472
472
  }
473
473
  }
474
474
 
475
475
  // ---- commands ----------------------------------------------------------------------
476
476
 
477
477
  pi.registerCommand("jev-lens", {
478
- description: "jev-lens: stats | list (compressed results) | diff [n] (original vs sent, overlay) | decisions | key [api-key] (store your TypeSafe key)",
478
+ description: "Inspect compression and setup: stats | list | diff [n] | decisions | key | help",
479
+ getArgumentCompletions: (prefix) => commandCompletions(prefix, records),
479
480
  handler: async (args, ctx) => {
480
481
  const sub = (args ?? "").trim();
481
- if (sub === "key" || sub.startsWith("key ")) {
482
+ if (sub === "help" || sub === "--help" || sub === "-h") {
483
+ ctx.ui.notify(commandHelp, "info");
484
+ return;
485
+ }
486
+ if (/^key(?:\s|$)/.test(sub)) {
482
487
  let key = sub.slice(3).trim();
483
- if (!key) key = ((await ctx.ui.input("TypeSafe API key (from console.typesafe.ai):", "ts_...")) ?? "").trim();
484
- if (!key) { ctx.ui.notify("no key entered", "info"); return; }
485
- const where = storeKey(key);
488
+ if (!key && (!ctx.hasUI || ctx.mode !== "tui")) { ctx.ui.notify("Masked key input requires terminal mode. Set TYPESAFE_API_KEY or run /jev-lens key in interactive pi.", "warning"); return; }
489
+ if (!key) key = ((await ctx.ui.custom<string | undefined>((tui, theme, keys, done) =>
490
+ new SecretInput(theme, keys, done, () => tui.requestRender()),
491
+ )) ?? "").trim();
492
+ if (!key) { ctx.ui.notify("Key setup cancelled. The current key is unchanged.", "info"); return; }
493
+ let where: string;
494
+ try { where = storeKey(key); }
495
+ catch {
496
+ ctx.ui.notify(`Could not store the key in ${keyFilePath()}. Check directory permissions or set TYPESAFE_API_KEY.`, "error");
497
+ return;
498
+ }
486
499
  useKey(key);
487
- ctx.ui.notify(`jev-lens: key stored in ${where}; jev is active from the next tool result`, "info");
500
+ keySource = where;
501
+ const next = cfg.forceMock ? "Mock mode remains active. Unset JEV_LENS_CLASSIFIER and reload pi to use jev." : !cfg.enabled || !cfg.presend ? "Pre-send compression is disabled. See /jev-lens stats." : "jev will use this key from the next tool result. The key has not been validated.";
502
+ ctx.ui.notify(`jev-lens: key stored in ${where}. ${next}${process.env.TYPESAFE_API_KEY ? " TYPESAFE_API_KEY takes priority again after reload." : ""}`, "info");
488
503
  status(ctx);
489
504
  return;
490
505
  }
491
- if (sub.startsWith("diff")) {
492
- const n = Number(sub.slice(4).trim() || "1");
493
- const rec = records[records.length - (Number.isFinite(n) && n >= 1 ? n : 1)];
494
- if (!rec) { ctx.ui.notify("no compressed tool result to show yet", "info"); return; }
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; }
495
516
  if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify(listLines([rec], { fg: (_c, t) => t, bold: (t) => t }).join("\n"), "info"); return; }
496
517
  await ctx.ui.custom<void>((tui, theme, _kb, done) => {
497
518
  const height = Math.max(12, Math.floor(((tui as { terminalHeight?: number }).terminalHeight ?? process.stdout.rows ?? 40) * 0.85));
@@ -506,17 +527,23 @@ export default function (pi: ExtensionAPI) {
506
527
  }
507
528
  if (sub === "decisions") {
508
529
  const rows = [...ledger.values()].map((d) => `${d.status === "applied" ? "●" : "○"} ${d.bucket.padEnd(6)} n=${d.p.needed.toFixed(2)} o=${d.p.outcomeOnly.toFixed(2)} ${d.tokensBefore}t ${d.summary}`);
509
- ctx.ui.notify(rows.join("\n") || "(no decisions yet)", "info");
530
+ ctx.ui.notify(rows.join("\n") || (cfg.mode === "off" ? "Post-send pruning is off (the default). Pre-send compression is separate: see /jev-lens stats." : "No post-send decisions yet."), "info");
531
+ return;
532
+ }
533
+ if (sub && sub !== "stats") {
534
+ ctx.ui.notify("Unknown subcommand or extra arguments. Run /jev-lens help for usage.", "warning");
510
535
  return;
511
536
  }
512
537
  const hit = totals.input + totals.cacheRead > 0 ? Math.round((100 * totals.cacheRead) / (totals.input + totals.cacheRead)) : 0;
513
538
  ctx.ui.notify(
514
539
  [
515
- `mode=${cfg.mode} enabled=${cfg.enabled} classifier=${usingMock ? "mock (no key: /jev-lens key)" : cfg.model} key=${process.env.TYPESAFE_API_KEY ? "env" : cfg.apiKey ? keyFilePath() : "none"}`,
516
- `presend: ${presendTotals.compressed}/${presendTotals.considered} large results compressed, ≈${presendTotals.tokensSaved} tokens saved, ${presendTotals.recalls} recalls`,
540
+ `mode=${cfg.mode} enabled=${cfg.enabled} presend=${cfg.presend} classifier=${usingMock ? cfg.forceMock ? "mock (forced by JEV_LENS_CLASSIFIER)" : "mock (no key: /jev-lens key)" : cfg.model} key=${keySource}`,
541
+ `presend since load: ${presendTotals.compressed}/${presendTotals.considered} large results compressed, ≈${presendTotals.tokensSaved} tokens saved, ${presendTotals.recalls} recalls`,
542
+ `restored from session: ${restored.compressed} compressed results, ≈${restored.tokensSaved} tokens saved (included in footer savings)`,
517
543
  `post-send: calls=${totals.calls} decisions=${ledger.size} applied=${totals.applied} pruned≈${totals.pruned} tokens`,
544
+ ...health.lines(),
518
545
  `cache: read=${totals.cacheRead} uncached=${totals.input} hit=${hit}%`,
519
- `input cut: ${cutShare() ?? 0}% of the session's input tokens (≈${cut.presend + cut.pruned} of ${totals.input + totals.cacheRead + cut.presend + cut.pruned}: presend ${cut.presend}, pruned ${cut.pruned}, summed over ${totals.calls} calls)`,
546
+ `input cut: ${cutShare() ?? 0}% of input tokens counted since load (≈${cut.presend + cut.pruned} of ${totals.input + totals.cacheRead + cut.presend + cut.pruned}: presend ${cut.presend}, pruned ${cut.pruned}, summed over ${totals.calls} calls)`,
520
547
  ].join("\n"),
521
548
  "info",
522
549
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jev-lens",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",
@@ -51,7 +51,7 @@
51
51
  "web-tree-sitter": "^0.27.0"
52
52
  },
53
53
  "peerDependencies": {
54
- "@earendil-works/pi-coding-agent": "*",
54
+ "@earendil-works/pi-coding-agent": ">=0.84.3",
55
55
  "@earendil-works/pi-tui": "*",
56
56
  "typebox": "*"
57
57
  },
@@ -0,0 +1,35 @@
1
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
2
+ import type { CompressedRecord } from "./ui.ts";
3
+
4
+ const commands = [
5
+ { value: "stats", label: "stats", description: "Show statistics and active configuration" },
6
+ { 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
+ { value: "decisions", label: "decisions", description: "Show post-send pruning decisions" },
9
+ { value: "key", label: "key", description: "Store a TypeSafe API key" },
10
+ { value: "help", label: "help", description: "Show commands and usage" },
11
+ ];
12
+
13
+ export const commandHelp = [
14
+ "/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.",
17
+ "Press Tab after /jev-lens to complete a subcommand.",
18
+ ].join("\n");
19
+
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
+ }
34
+ return items.length ? items : null;
35
+ }
package/src/health.ts ADDED
@@ -0,0 +1,38 @@
1
+ export type Stage = "presend" | "postsend";
2
+
3
+ type StageHealth = { failures: number; failing: boolean; reason: string; notified: boolean };
4
+
5
+ /** Session-local failure counters. Never expose provider error messages or credentials. */
6
+ export class Health {
7
+ private stages: Record<Stage, StageHealth> = {
8
+ presend: { failures: 0, failing: false, reason: "", notified: false },
9
+ postsend: { failures: 0, failing: false, reason: "", notified: false },
10
+ };
11
+
12
+ 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.";
17
+ Object.assign(this.stages[stage], { failures: this.stages[stage].failures + 1, failing: true, reason });
18
+ }
19
+
20
+ success(stage: Stage): void { this.stages[stage].failing = false; }
21
+
22
+ get failing(): boolean { return Object.values(this.stages).some((s) => s.failing); }
23
+
24
+ /** At most one warning per stage per session, including work completed without a UI context. */
25
+ warnings(): string[] {
26
+ return (Object.entries(this.stages) as [Stage, StageHealth][]).flatMap(([stage, state]) => {
27
+ if (!state.failures || state.notified) return [];
28
+ state.notified = true;
29
+ const effect = stage === "presend" ? "Full output was kept." : "The affected result was not pruned.";
30
+ return [`jev-lens: ${stage === "presend" ? "Pre-send compression" : "Post-send classification"} failed. ${effect} ${state.reason} See /jev-lens stats. Further failures appear there without repeated warnings.`];
31
+ });
32
+ }
33
+
34
+ lines(): string[] {
35
+ return (Object.entries(this.stages) as [Stage, StageHealth][]).map(([stage, state]) =>
36
+ `${stage} failures: ${state.failures}${state.failures ? state.failing ? ` (last attempt failed). ${state.reason}` : " (a later attempt succeeded)" : ""}`);
37
+ }
38
+ }
@@ -0,0 +1,124 @@
1
+ import { CURSOR_MARKER, decodeKittyPrintable, truncateToWidth, type Component, type Focusable, type KeybindingsManager } from "@earendil-works/pi-tui";
2
+ import type { ThemeLike } from "./ui.ts";
3
+
4
+ const PASTE_START = "\x1b[200~", PASTE_END = "\x1b[201~";
5
+ const MAX_LENGTH = 4096;
6
+
7
+ /** A secret-only editor: no plaintext rendering, history, clipboard, undo, or reveal action. */
8
+ export class SecretInput implements Component, Focusable {
9
+ focused = false;
10
+ private value: string[] = [];
11
+ private cursor = 0;
12
+ private paste: string | undefined;
13
+ private pasteTooLong = false;
14
+ private error = "";
15
+ private closed = false;
16
+
17
+ constructor(
18
+ private theme: ThemeLike,
19
+ private keys: Pick<KeybindingsManager, "matches" | "getKeys">,
20
+ private done: (value: string | undefined) => void,
21
+ private requestRender: () => void,
22
+ ) {}
23
+
24
+ private insert(text: string): void {
25
+ const chars = Array.from(text);
26
+ if (/[\x00-\x1f\x7f-\x9f]/.test(text)) {
27
+ this.error = "Paste a single-line API key. Control characters are not allowed.";
28
+ } else if (this.value.length + chars.length > MAX_LENGTH) {
29
+ this.error = `The key is too long. Maximum: ${MAX_LENGTH} characters.`;
30
+ } else {
31
+ this.value.splice(this.cursor, 0, ...chars);
32
+ this.cursor += chars.length;
33
+ this.error = "";
34
+ }
35
+ }
36
+
37
+ private finish(value?: string): void {
38
+ this.dispose();
39
+ this.done(value);
40
+ }
41
+
42
+ /** Drop references on submit, cancel, or external teardown. JS cannot guarantee memory erasure. */
43
+ dispose(): void {
44
+ this.value.fill("");
45
+ this.value = [];
46
+ this.cursor = 0;
47
+ this.paste = undefined;
48
+ this.error = "";
49
+ this.closed = true;
50
+ }
51
+
52
+ handleInput(data: string): void {
53
+ if (this.closed) return;
54
+ if (this.paste === undefined && data.startsWith(PASTE_START)) {
55
+ this.paste = "";
56
+ this.pasteTooLong = false;
57
+ data = data.slice(PASTE_START.length);
58
+ }
59
+ if (this.paste !== undefined) {
60
+ this.paste += data;
61
+ const end = this.paste.indexOf(PASTE_END);
62
+ if (end >= 0) {
63
+ const text = this.paste.slice(0, end);
64
+ const remaining = this.paste.slice(end + PASTE_END.length);
65
+ this.paste = undefined;
66
+ if (this.pasteTooLong) this.error = "The pasted key is too long. Paste only the API key.";
67
+ else this.insert(text.trim());
68
+ if (remaining) this.handleInput(remaining);
69
+ } else if (this.paste.length > MAX_LENGTH + PASTE_END.length) {
70
+ this.pasteTooLong = true;
71
+ this.paste = this.paste.slice(-PASTE_END.length); // retain a possible split end marker
72
+ }
73
+ } else if (this.keys.matches(data, "tui.select.cancel")) {
74
+ this.finish();
75
+ } else if (this.keys.matches(data, "tui.input.submit")) {
76
+ if (!this.error) this.finish(this.value.join("").trim() || undefined);
77
+ } else if (this.keys.matches(data, "tui.editor.cursorLeft")) {
78
+ this.cursor = Math.max(0, this.cursor - 1);
79
+ } else if (this.keys.matches(data, "tui.editor.cursorRight")) {
80
+ this.cursor = Math.min(this.value.length, this.cursor + 1);
81
+ } else if (this.keys.matches(data, "tui.editor.cursorLineStart")) {
82
+ this.cursor = 0;
83
+ } else if (this.keys.matches(data, "tui.editor.cursorLineEnd")) {
84
+ this.cursor = this.value.length;
85
+ } else if (this.keys.matches(data, "tui.editor.deleteCharBackward")) {
86
+ if (this.cursor) this.value.splice(--this.cursor, 1);
87
+ this.error = "";
88
+ } else if (this.keys.matches(data, "tui.editor.deleteCharForward")) {
89
+ this.value.splice(this.cursor, 1);
90
+ this.error = "";
91
+ } else if (this.keys.matches(data, "tui.editor.deleteToLineStart")) {
92
+ this.value.splice(0, this.cursor);
93
+ this.cursor = 0;
94
+ this.error = "";
95
+ } else if (this.keys.matches(data, "tui.editor.deleteToLineEnd")) {
96
+ this.value.splice(this.cursor);
97
+ this.error = "";
98
+ } else {
99
+ const text = decodeKittyPrintable(data) ?? data;
100
+ if (!/[\x00-\x1f\x7f-\x9f]/.test(text)) this.insert(text);
101
+ }
102
+ this.requestRender();
103
+ }
104
+
105
+ invalidate(): void {}
106
+
107
+ render(width: number): string[] {
108
+ if (width < 1) return [""];
109
+ const room = Math.max(1, width - 2);
110
+ const start = Math.max(0, this.cursor - room + 1);
111
+ const before = "*".repeat(this.cursor - start);
112
+ const after = "*".repeat(Math.min(this.value.length - this.cursor, room - before.length - 1));
113
+ const cursor = this.cursor < this.value.length ? "*" : " ";
114
+ const marker = this.focused ? CURSOR_MARKER : "";
115
+ const field = `${width > 2 ? "> " : ""}${before}${marker}${this.focused ? `\x1b[7m${cursor}\x1b[27m` : cursor}${after}`;
116
+ return [
117
+ this.theme.fg("accent", "TypeSafe API key (masked)"),
118
+ this.theme.fg("dim", "Get a key at console.typesafe.ai. Type or paste it below."),
119
+ field,
120
+ this.theme.fg("dim", `${this.keys.getKeys("tui.input.submit").join("/")}: save · ${this.keys.getKeys("tui.select.cancel").join("/")}: cancel`),
121
+ ...(this.error ? [this.theme.fg("warning", this.error)] : []),
122
+ ].map((line) => truncateToWidth(line, width));
123
+ }
124
+ }