pi-jev-lens 0.1.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/LICENSE +21 -0
- package/README.md +238 -0
- package/STATUS.md +391 -0
- package/index.ts +591 -0
- package/package.json +67 -0
- package/src/classifier.ts +151 -0
- package/src/config.ts +188 -0
- package/src/ledger.ts +25 -0
- package/src/memory-file.ts +51 -0
- package/src/pi-types.ts +6 -0
- package/src/policy.ts +145 -0
- package/src/presend.ts +217 -0
- package/src/shell-display.ts +90 -0
- package/src/text.ts +68 -0
- package/src/treesitter.ts +220 -0
- package/src/types.ts +45 -0
- package/src/ui.ts +141 -0
- package/src/views.ts +611 -0
package/index.ts
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-jev-lens: cache-aware memory routing for pi.
|
|
3
|
+
*
|
|
4
|
+
* Every tool result is classified once by jev (TypeSafe System One) after the agent has
|
|
5
|
+
* seen it and acted on it. The decision (keep / trim / forget, plus durable yes/no) is
|
|
6
|
+
* persisted and, once applied to an outgoing prompt, never changes again, so the prompt
|
|
7
|
+
* prefix stays byte-identical across calls and the provider cache keeps hitting.
|
|
8
|
+
*
|
|
9
|
+
* Buckets:
|
|
10
|
+
* context (keep) – sent verbatim
|
|
11
|
+
* trim – head + tail only
|
|
12
|
+
* forget – replaced by a one-line stub (tool results are never removed:
|
|
13
|
+
* every function_call needs a matching output)
|
|
14
|
+
* file (durable) – appended to <project>/.pi/jev-lens.md, loaded at session start
|
|
15
|
+
*/
|
|
16
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import type { AgentMessage } from "./src/pi-types.ts";
|
|
20
|
+
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { Type } from "typebox";
|
|
22
|
+
import { createBashTool, createFindTool, createGrepTool, createLsTool, createReadTool } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
24
|
+
import { DiffOverlay, listLines, savingsLine, type CompressedRecord } from "./src/ui.ts";
|
|
25
|
+
import { TypeSafeClient } from "@typesafe-ai/sdk";
|
|
26
|
+
import { buildItemState, JevClassifier, MockClassifier, type Classifier } from "./src/classifier.ts";
|
|
27
|
+
import { buildPresendState, decideView, DEFAULT_PROMPTS, expandRelevantBlocks, JevPresend, MockPresend, type PresendClassifier, type PromptVariant } from "./src/presend.ts";
|
|
28
|
+
import { buildCandidatesAsync, extractTerms, footer } from "./src/views.ts";
|
|
29
|
+
import { keyFilePath, loadConfigWithVariant, storeKey, type Config } from "./src/config.ts";
|
|
30
|
+
import { ENTRY_TYPE, rebuildLedger } from "./src/ledger.ts";
|
|
31
|
+
import { appendNotes, memoryPromptSection, readMemoryFile } from "./src/memory-file.ts";
|
|
32
|
+
import { applyLedger, decideBucket, pendingPrunable, shouldApplyPending } from "./src/policy.ts";
|
|
33
|
+
import { contentText, describeToolCall, estimateTokensOfText, toolCallsOf, truncate } from "./src/text.ts";
|
|
34
|
+
import type { CallStats, Decision, DurableNote } from "./src/types.ts";
|
|
35
|
+
|
|
36
|
+
interface PendingResult {
|
|
37
|
+
message: AgentMessage & { role: "toolResult" };
|
|
38
|
+
args: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default function (pi: ExtensionAPI) {
|
|
42
|
+
const { cfg, variant } = loadConfigWithVariant();
|
|
43
|
+
const prompts: PromptVariant = { ...DEFAULT_PROMPTS, ...((variant.prompts ?? {}) as Partial<PromptVariant>), viewDescriptions: { ...DEFAULT_PROMPTS.viewDescriptions, ...(((variant.prompts ?? {}) as Partial<PromptVariant>).viewDescriptions ?? {}) } };
|
|
44
|
+
const viewParams = variant.views ?? {};
|
|
45
|
+
let usingMock = cfg.forceMock || !cfg.apiKey;
|
|
46
|
+
let classifier: Classifier = usingMock ? new MockClassifier() : new JevClassifier(cfg);
|
|
47
|
+
let presend: PresendClassifier = usingMock ? new MockPresend() : new JevPresend(new TypeSafeClient({ apiKey: cfg.apiKey }), cfg.model, prompts);
|
|
48
|
+
/** Switch from the mock to jev once a key is available (from `/jev-lens key`), without a restart. */
|
|
49
|
+
const useKey = (apiKey: string) => {
|
|
50
|
+
cfg.apiKey = apiKey;
|
|
51
|
+
usingMock = cfg.forceMock;
|
|
52
|
+
classifier = usingMock ? new MockClassifier() : new JevClassifier(cfg);
|
|
53
|
+
presend = usingMock ? new MockPresend() : new JevPresend(new TypeSafeClient({ apiKey }), cfg.model, prompts);
|
|
54
|
+
};
|
|
55
|
+
/** Full text of compressed tool results, by toolCallId, for the recall tool (also persisted in result details). */
|
|
56
|
+
const fullOutputs = new Map<string, { text: string; toolName: string; args: unknown; view: string }>();
|
|
57
|
+
/** Everything the UI needs per compressed result, newest last. */
|
|
58
|
+
const records: CompressedRecord[] = [];
|
|
59
|
+
const recordById = new Map<string, CompressedRecord>();
|
|
60
|
+
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); } };
|
|
61
|
+
let lastAssistantText = "";
|
|
62
|
+
let presendTotals = { considered: 0, compressed: 0, tokensSaved: 0, recalls: 0 };
|
|
63
|
+
|
|
64
|
+
let ledger = new Map<string, Decision>();
|
|
65
|
+
/** Classifications launched but not yet resolved, keyed by toolCallId. */
|
|
66
|
+
const inflight = new Map<string, Promise<void>>();
|
|
67
|
+
const textInflight = new Set<Promise<void>>();
|
|
68
|
+
let generation = 0;
|
|
69
|
+
let sessionAbort = new AbortController();
|
|
70
|
+
const workSignal = (signal?: AbortSignal) => signal ? AbortSignal.any([signal, sessionAbort.signal]) : sessionAbort.signal;
|
|
71
|
+
async function waitForWork(work: Promise<void>[]) {
|
|
72
|
+
if (!work.length) return;
|
|
73
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
74
|
+
try {
|
|
75
|
+
await Promise.race([Promise.allSettled(work), new Promise<void>((resolve) => { timer = setTimeout(resolve, Math.max(0, cfg.classifyWaitMs)); })]);
|
|
76
|
+
} finally { clearTimeout(timer); }
|
|
77
|
+
}
|
|
78
|
+
/** Tool results from the previous assistant turn, waiting for "what happened next". */
|
|
79
|
+
let buffer: PendingResult[] = [];
|
|
80
|
+
/** Tool call arguments by id, so results can be described. */
|
|
81
|
+
const argsById = new Map<string, unknown>();
|
|
82
|
+
let callIndex = 0;
|
|
83
|
+
let lastCallAt = 0;
|
|
84
|
+
let memorySnapshot = "";
|
|
85
|
+
let memoryPath = "";
|
|
86
|
+
let logPath = "";
|
|
87
|
+
let totals = { pruned: 0, applied: 0, notes: 0, calls: 0, cacheRead: 0, input: 0 };
|
|
88
|
+
/** Tokens kept out of the prompt, summed over every LLM call of the session (a compressed result saves on each later call too). */
|
|
89
|
+
let cut = { presend: 0, pruned: 0 };
|
|
90
|
+
let durableQueue: DurableNote[] = [];
|
|
91
|
+
let firstUser = "";
|
|
92
|
+
let latestUser = "";
|
|
93
|
+
|
|
94
|
+
const log = (record: Record<string, unknown>) => {
|
|
95
|
+
if (!cfg.logFile || !logPath) return;
|
|
96
|
+
try {
|
|
97
|
+
appendFileSync(logPath, `${JSON.stringify({ t: Date.now(), ...record })}\n`);
|
|
98
|
+
} catch {}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Share of all input tokens this session that jev kept out of the prompt: cut / (sent + cut), from the provider's own usage counts. */
|
|
102
|
+
const cutShare = () => {
|
|
103
|
+
const sent = totals.input + totals.cacheRead;
|
|
104
|
+
const kept = cut.presend + cut.pruned;
|
|
105
|
+
return sent > 0 ? Math.round((100 * kept) / (sent + kept)) : undefined;
|
|
106
|
+
};
|
|
107
|
+
const statusText = () => {
|
|
108
|
+
const tag = usingMock ? "jev-lens(mock)" : "jev-lens";
|
|
109
|
+
const pct = cutShare();
|
|
110
|
+
const lead = pct === undefined ? tag : `${tag} −${pct}% of input`;
|
|
111
|
+
return `${lead} (presend −${(presendTotals.tokensSaved / 1000).toFixed(1)}k · ${presendTotals.compressed}/${presendTotals.considered} · ${presendTotals.recalls} recalls, pruned −${(totals.pruned / 1000).toFixed(1)}k · ${totals.applied}, ${totals.notes} notes)`;
|
|
112
|
+
};
|
|
113
|
+
const status = (ctx: ExtensionContext) => {
|
|
114
|
+
if (!ctx.hasUI) return;
|
|
115
|
+
ctx.ui.setStatus("jev-lens", statusText());
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const persist = (d: Decision) => pi.appendEntry(ENTRY_TYPE, { kind: "decision", decision: { ...d } });
|
|
119
|
+
|
|
120
|
+
// ---- session lifecycle -------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
123
|
+
generation++;
|
|
124
|
+
sessionAbort.abort();
|
|
125
|
+
sessionAbort = new AbortController();
|
|
126
|
+
textInflight.clear();
|
|
127
|
+
durableQueue = [];
|
|
128
|
+
lastAssistantText = "";
|
|
129
|
+
ledger = rebuildLedger(ctx.sessionManager.getEntries());
|
|
130
|
+
buffer = [];
|
|
131
|
+
inflight.clear();
|
|
132
|
+
argsById.clear();
|
|
133
|
+
callIndex = 0;
|
|
134
|
+
lastCallAt = 0;
|
|
135
|
+
totals = { pruned: 0, applied: 0, notes: 0, calls: 0, cacheRead: 0, input: 0 };
|
|
136
|
+
cut = { presend: 0, pruned: 0 };
|
|
137
|
+
firstUser = "";
|
|
138
|
+
latestUser = "";
|
|
139
|
+
fullOutputs.clear();
|
|
140
|
+
records.length = 0;
|
|
141
|
+
recordById.clear();
|
|
142
|
+
presendTotals = { considered: 0, compressed: 0, tokensSaved: 0, recalls: 0 };
|
|
143
|
+
memoryPath = join(ctx.cwd, CONFIG_DIR_NAME, "jev-lens.md");
|
|
144
|
+
memorySnapshot = readMemoryFile(memoryPath);
|
|
145
|
+
try {
|
|
146
|
+
mkdirSync(join(ctx.cwd, CONFIG_DIR_NAME), { recursive: true });
|
|
147
|
+
logPath = join(ctx.cwd, CONFIG_DIR_NAME, "jev-lens.log");
|
|
148
|
+
} catch {
|
|
149
|
+
logPath = "";
|
|
150
|
+
}
|
|
151
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
152
|
+
if (entry.type !== "message") continue;
|
|
153
|
+
if (entry.message.role === "user") {
|
|
154
|
+
const t = contentText(entry.message.content);
|
|
155
|
+
if (!firstUser) firstUser = t;
|
|
156
|
+
latestUser = t;
|
|
157
|
+
} else if (entry.message.role === "toolResult") {
|
|
158
|
+
const d = (entry.message as { details?: { jevLens?: { full?: string; view?: string; args?: unknown; included?: number[]; kind?: string; needsFull?: number; p?: Record<string, number> } } }).details?.jevLens;
|
|
159
|
+
if (d?.full) {
|
|
160
|
+
fullOutputs.set(entry.message.toolCallId, { text: d.full, toolName: entry.message.toolName, args: d.args, view: d.view ?? "?" });
|
|
161
|
+
const sent = contentText(entry.message.content).replace(/\n\n\[jev-lens:[\s\S]*$/, "");
|
|
162
|
+
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 });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
log({ event: "session_start", mode: cfg.mode, enabled: cfg.enabled, mock: usingMock, ledger: ledger.size, variant: variant.name ?? null });
|
|
167
|
+
if (ctx.hasUI && usingMock && !cfg.forceMock) ctx.ui.notify("jev-lens: no TypeSafe API key. Run /jev-lens key (or set TYPESAFE_API_KEY). Using the mock classifier until then.", "warning");
|
|
168
|
+
status(ctx);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
pi.on("session_shutdown", async () => {
|
|
172
|
+
const epoch = generation;
|
|
173
|
+
await waitForWork([...inflight.values(), ...textInflight]);
|
|
174
|
+
if (epoch !== generation) return;
|
|
175
|
+
flushDurable();
|
|
176
|
+
if (inflight.size || textInflight.size) log({ event: "shutdown_timeout", pending: inflight.size + textInflight.size });
|
|
177
|
+
generation++;
|
|
178
|
+
sessionAbort.abort();
|
|
179
|
+
inflight.clear();
|
|
180
|
+
textInflight.clear();
|
|
181
|
+
durableQueue = [];
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ---- memory file → system prompt (snapshot taken at session start, stable within the session)
|
|
185
|
+
|
|
186
|
+
pi.on("before_agent_start", async (event) => {
|
|
187
|
+
if (!firstUser) firstUser = event.prompt;
|
|
188
|
+
latestUser = event.prompt;
|
|
189
|
+
const section = memoryPromptSection(memorySnapshot);
|
|
190
|
+
if (!section) return;
|
|
191
|
+
return { systemPrompt: event.systemPrompt + section };
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ---- classification ------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
pi.on("message_end", async (event, ctx) => {
|
|
197
|
+
const m = event.message;
|
|
198
|
+
if (m.role === "user") {
|
|
199
|
+
const text = contentText(m.content);
|
|
200
|
+
if (!firstUser) firstUser = text;
|
|
201
|
+
latestUser = text;
|
|
202
|
+
queueText("user", text, ctx);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (m.role === "toolResult") {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (m.role !== "assistant" || m.stopReason === "error" || m.stopReason === "aborted") return;
|
|
209
|
+
// The assistant has now reacted to the previous turn's tool results: classify them.
|
|
210
|
+
const afterText = contentText(m.content);
|
|
211
|
+
lastAssistantText = afterText;
|
|
212
|
+
const afterCalls = toolCallsOf(m);
|
|
213
|
+
for (const c of m.content) if (c.type === "toolCall") argsById.set(c.id, c.arguments);
|
|
214
|
+
const toClassify = buffer;
|
|
215
|
+
buffer = [];
|
|
216
|
+
for (const item of toClassify) launchClassification(item, afterText, afterCalls, ctx);
|
|
217
|
+
if (afterText.trim()) queueText("agent", afterText, ctx);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
pi.on("tool_execution_end", async (event) => {
|
|
221
|
+
// Collect the result message from the session once it lands; turn_end has the full list.
|
|
222
|
+
void event;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
pi.on("turn_end", async (event) => {
|
|
226
|
+
for (const r of event.toolResults) {
|
|
227
|
+
buffer.push({ message: r as PendingResult["message"], args: argsById.get(r.toolCallId) });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
pi.on("agent_end", async () => {
|
|
232
|
+
const epoch = generation;
|
|
233
|
+
// No further assistant reaction is coming for the last results; classify with what we have.
|
|
234
|
+
const toClassify = buffer;
|
|
235
|
+
buffer = [];
|
|
236
|
+
for (const item of toClassify) launchClassification(item, "", [], undefined);
|
|
237
|
+
await waitForWork([...inflight.values(), ...textInflight]);
|
|
238
|
+
if (epoch === generation) flushDurable();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
function launchClassification(item: PendingResult, afterText: string, afterCalls: { name: string; arguments: unknown }[], ctx?: ExtensionContext) {
|
|
242
|
+
const m = item.message;
|
|
243
|
+
if (sessionAbort.signal.aborted || m.content.some((c) => c.type !== "text")) return;
|
|
244
|
+
const epoch = generation;
|
|
245
|
+
if (ledger.has(m.toolCallId) || inflight.has(m.toolCallId)) return;
|
|
246
|
+
const output = contentText(m.content);
|
|
247
|
+
const tokens = estimateTokensOfText(output);
|
|
248
|
+
if (tokens < cfg.minTokens) return;
|
|
249
|
+
const state = buildItemState(cfg, {
|
|
250
|
+
firstUser,
|
|
251
|
+
latestUser,
|
|
252
|
+
toolName: m.toolName,
|
|
253
|
+
args: item.args,
|
|
254
|
+
isError: m.isError,
|
|
255
|
+
output,
|
|
256
|
+
afterText,
|
|
257
|
+
afterCalls,
|
|
258
|
+
});
|
|
259
|
+
const lines = output.split("\n").length;
|
|
260
|
+
const summary = describeToolCall(m.toolName, item.args, output.length, lines);
|
|
261
|
+
const started = Date.now();
|
|
262
|
+
const p = classifier
|
|
263
|
+
.classifyToolResult(state, workSignal(ctx?.signal))
|
|
264
|
+
.then((probs) => {
|
|
265
|
+
if (epoch !== generation) return;
|
|
266
|
+
const decision: Decision = {
|
|
267
|
+
id: m.toolCallId,
|
|
268
|
+
toolName: m.toolName,
|
|
269
|
+
bucket: cfg.enabled ? decideBucket(probs, cfg) : "keep",
|
|
270
|
+
durable: probs.durable > cfg.durableAbove,
|
|
271
|
+
p: probs,
|
|
272
|
+
summary,
|
|
273
|
+
tokensBefore: tokens,
|
|
274
|
+
decidedAt: Date.now(),
|
|
275
|
+
status: "pending",
|
|
276
|
+
};
|
|
277
|
+
ledger.set(decision.id, decision);
|
|
278
|
+
persist(decision);
|
|
279
|
+
log({ event: "decision", id: decision.id, tool: m.toolName, bucket: decision.bucket, p: probs, tokens, ms: Date.now() - started, summary });
|
|
280
|
+
// Tool output is rarely a durable fact by itself; only keep a pointer, and only when jev is very sure.
|
|
281
|
+
if (probs.durable > Math.max(cfg.durableAbove, 0.85)) {
|
|
282
|
+
durableQueue.push({ source: "tool", text: `${summary}${m.isError ? " failed" : " succeeded"}`, p: probs.durable, at: Date.now() });
|
|
283
|
+
flushDurable();
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
.catch((err) => {
|
|
287
|
+
if (epoch === generation) log({ event: "classify_error", id: m.toolCallId, error: String(err?.message ?? err) });
|
|
288
|
+
})
|
|
289
|
+
.finally(() => { if (epoch === generation) inflight.delete(m.toolCallId); });
|
|
290
|
+
inflight.set(m.toolCallId, p);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function queueText(role: "user" | "agent", text: string, ctx?: ExtensionContext) {
|
|
294
|
+
if (sessionAbort.signal.aborted || usingMock || text.length < 40 || text.length > 6000) return;
|
|
295
|
+
const epoch = generation;
|
|
296
|
+
const work = classifier
|
|
297
|
+
.classifyText({ task: { first_user_request: truncate(firstUser, 600) }, message: truncate(text, 3000), role }, workSignal(ctx?.signal))
|
|
298
|
+
.then((p) => {
|
|
299
|
+
if (epoch !== generation) return;
|
|
300
|
+
log({ event: "text", role, p, chars: text.length });
|
|
301
|
+
if (p > cfg.durableAbove) {
|
|
302
|
+
durableQueue.push({ source: role, text: truncate(text, 400), p, at: Date.now() });
|
|
303
|
+
flushDurable();
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
.catch((err) => { if (epoch === generation) log({ event: "classify_error", role, error: String(err?.message ?? err) }); })
|
|
307
|
+
.finally(() => { if (epoch === generation) textInflight.delete(work); });
|
|
308
|
+
textInflight.add(work);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function flushDurable() {
|
|
312
|
+
if (durableQueue.length === 0 || !memoryPath) return;
|
|
313
|
+
const notes = durableQueue;
|
|
314
|
+
durableQueue = [];
|
|
315
|
+
try {
|
|
316
|
+
const added = appendNotes(memoryPath, notes);
|
|
317
|
+
totals.notes += added;
|
|
318
|
+
log({ event: "memory_file", added, path: memoryPath });
|
|
319
|
+
} catch (err) {
|
|
320
|
+
log({ event: "memory_file_error", error: String((err as Error)?.message ?? err) });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---- the cache-aware cut: right before each LLM call --------------------------------
|
|
325
|
+
|
|
326
|
+
pi.on("context", async (event, ctx) => {
|
|
327
|
+
const epoch = generation;
|
|
328
|
+
callIndex++;
|
|
329
|
+
const now = Date.now();
|
|
330
|
+
const coldCache = lastCallAt > 0 && now - lastCallAt > cfg.cacheTtlMs;
|
|
331
|
+
lastCallAt = now;
|
|
332
|
+
|
|
333
|
+
// Give in-flight classifications a bounded chance to land, so decisions apply at the
|
|
334
|
+
// earliest call and freeze there instead of shifting the prefix one call later.
|
|
335
|
+
if (inflight.size > 0) {
|
|
336
|
+
await waitForWork([...inflight.values()]);
|
|
337
|
+
}
|
|
338
|
+
if (epoch !== generation || sessionAbort.signal.aborted) return;
|
|
339
|
+
|
|
340
|
+
const pending = pendingPrunable(event.messages, ledger, cfg);
|
|
341
|
+
const { apply: applyPending, reason } = shouldApplyPending(cfg.mode, cfg, coldCache, pending);
|
|
342
|
+
const result = applyLedger(event.messages, ledger, cfg, applyPending, callIndex, reason);
|
|
343
|
+
for (const d of result.appliedNow) persist(d);
|
|
344
|
+
totals.applied += result.appliedNow.length;
|
|
345
|
+
totals.pruned = 0;
|
|
346
|
+
for (const d of ledger.values()) if (d.status === "applied") totals.pruned += d.tokensBefore;
|
|
347
|
+
totals.calls++;
|
|
348
|
+
// What this call would have cost without jev: every compressed result still in the prompt, plus what was pruned.
|
|
349
|
+
let presentSaved = 0;
|
|
350
|
+
for (const m of result.messages) {
|
|
351
|
+
if (m.role !== "toolResult") continue;
|
|
352
|
+
const rec = recordById.get(m.toolCallId);
|
|
353
|
+
if (rec) presentSaved += Math.max(0, rec.tokensBefore - rec.tokensAfter);
|
|
354
|
+
}
|
|
355
|
+
cut.presend += presentSaved;
|
|
356
|
+
cut.pruned += Math.max(0, result.tokensOriginal - result.tokensSent);
|
|
357
|
+
|
|
358
|
+
const stats: CallStats = {
|
|
359
|
+
call: callIndex,
|
|
360
|
+
at: now,
|
|
361
|
+
messages: event.messages.length,
|
|
362
|
+
tokensOriginal: result.tokensOriginal,
|
|
363
|
+
tokensSent: result.tokensSent,
|
|
364
|
+
tokensPruned: result.tokensOriginal - result.tokensSent,
|
|
365
|
+
appliedNow: result.appliedNow.length,
|
|
366
|
+
frozen: result.frozen,
|
|
367
|
+
pendingHeld: result.pendingHeld,
|
|
368
|
+
coldCache,
|
|
369
|
+
};
|
|
370
|
+
log({ event: "context", ...stats, reason, pendingTokens: pending.tokens, tailTokens: pending.tailTokens, inflight: inflight.size, presendSavedInPrompt: presentSaved });
|
|
371
|
+
status(ctx);
|
|
372
|
+
return { messages: result.messages };
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// Cache accounting from the provider's own usage numbers.
|
|
376
|
+
pi.on("message_end", async (event, ctx) => {
|
|
377
|
+
const m = event.message;
|
|
378
|
+
if (m.role !== "assistant") return;
|
|
379
|
+
totals.cacheRead += m.usage?.cacheRead ?? 0;
|
|
380
|
+
totals.input += m.usage?.input ?? 0;
|
|
381
|
+
log({ event: "usage", call: callIndex, input: m.usage?.input, cacheRead: m.usage?.cacheRead, output: m.usage?.output, model: m.model });
|
|
382
|
+
if (ctx.hasUI) status(ctx);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// Compaction is a full prefix rewrite anyway: apply everything pending first.
|
|
386
|
+
pi.on("session_before_compact", async () => {
|
|
387
|
+
for (const d of ledger.values()) {
|
|
388
|
+
if (d.status === "pending") {
|
|
389
|
+
d.status = "applied";
|
|
390
|
+
d.appliedAtCall = callIndex;
|
|
391
|
+
d.appliedReason = "compaction";
|
|
392
|
+
persist(d);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
flushDurable();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// ---- pre-send compression: pick a view of a large tool result before it is ever sent ----
|
|
399
|
+
|
|
400
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
401
|
+
if (!cfg.presend || !cfg.enabled || sessionAbort.signal.aborted) return;
|
|
402
|
+
const epoch = generation;
|
|
403
|
+
const signal = workSignal(ctx.signal);
|
|
404
|
+
if (event.toolName === "recall") return;
|
|
405
|
+
const text = contentText(event.content);
|
|
406
|
+
const tokens = estimateTokensOfText(text);
|
|
407
|
+
if (tokens < cfg.presendMinTokens) return;
|
|
408
|
+
if (event.content.some((c) => c.type === "image")) return;
|
|
409
|
+
presendTotals.considered++;
|
|
410
|
+
const started = Date.now();
|
|
411
|
+
const terms = extractTerms(latestUser, lastAssistantText, JSON.stringify(event.input ?? {}));
|
|
412
|
+
const cands = await buildCandidatesAsync(event.toolName, event.input, text, terms, viewParams);
|
|
413
|
+
if (epoch !== generation || signal.aborted) return;
|
|
414
|
+
if (cands.views.length < 2) {
|
|
415
|
+
log({ event: "presend", id: event.toolCallId, tool: event.toolName, tokens, view: "full", reason: "no-candidates" });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const totalLines = text.split("\n").length;
|
|
419
|
+
const state = buildPresendState(cfg, { firstUser, latestUser, agentText: lastAssistantText, toolName: event.toolName, args: event.input, isError: event.isError, cands, totalLines, totalChars: text.length });
|
|
420
|
+
try {
|
|
421
|
+
const answer = await presend.choose(state, cands.views.map((v) => v.kind), signal);
|
|
422
|
+
if (epoch !== generation || signal.aborted) return;
|
|
423
|
+
let view = decideView(answer, cands, cfg);
|
|
424
|
+
let expanded: number[] | undefined;
|
|
425
|
+
if (view.kind !== "full") {
|
|
426
|
+
const above = cands.kind === "command" ? cfg.presendSectionExpandAbove : cfg.presendExpandAbove;
|
|
427
|
+
const ex = await expandRelevantBlocks(presend, state, text, cands, view, above, signal, cands.blocks, cfg.presendSectionFloor);
|
|
428
|
+
if (epoch !== generation || signal.aborted) return;
|
|
429
|
+
if (ex) { view = ex.view; expanded = ex.probs.map((p, i) => (p > above ? i : -1)).filter((i) => i >= 0); }
|
|
430
|
+
}
|
|
431
|
+
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 });
|
|
432
|
+
if (view.kind === "full") return;
|
|
433
|
+
presendTotals.compressed++;
|
|
434
|
+
presendTotals.tokensSaved += tokens - estimateTokensOfText(view.text);
|
|
435
|
+
fullOutputs.set(event.toolCallId, { text, toolName: event.toolName, args: event.input, view: view.kind });
|
|
436
|
+
remember({ id: event.toolCallId, toolName: event.toolName, args: event.input, kind: cands.kind, view: view.kind, tokensBefore: tokens, tokensAfter: estimateTokensOfText(view.text), full: text, sent: view.text, included: view.included, needsFull: answer.needsFull, pFull: answer.probabilities.full, recalls: 0, at: Date.now() });
|
|
437
|
+
const details = { ...((event.details as object) ?? {}), jevLens: { full: text, view: view.kind, kind: cands.kind, args: event.input, p: answer.probabilities, needsFull: answer.needsFull, included: view.included } };
|
|
438
|
+
status(ctx);
|
|
439
|
+
return { content: [{ type: "text", text: view.text + footer(view, event.toolCallId, totalLines) }], details };
|
|
440
|
+
} catch (err) {
|
|
441
|
+
if (epoch === generation) log({ event: "presend_error", id: event.toolCallId, error: String((err as Error)?.message ?? err) });
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
pi.registerTool({
|
|
447
|
+
name: "recall",
|
|
448
|
+
label: "Recall",
|
|
449
|
+
description: "Return the full output of an earlier tool call that jev-lens showed in a reduced view (or that was pruned). Pass the id from the [jev-lens: ...] note. Optionally restrict to a line range \"a-b\" or to lines matching a pattern (case-insensitive substring or /regex/).",
|
|
450
|
+
parameters: Type.Object({
|
|
451
|
+
id: Type.String({ description: "toolCallId from the jev-lens note" }),
|
|
452
|
+
lines: Type.Optional(Type.String({ description: "Line range like 120-180 (1-based, inclusive)" })),
|
|
453
|
+
pattern: Type.Optional(Type.String({ description: "Only lines matching this substring or /regex/, with 2 lines of context" })),
|
|
454
|
+
}),
|
|
455
|
+
async execute(_toolCallId, params) {
|
|
456
|
+
presendTotals.recalls++;
|
|
457
|
+
const rec = recordById.get(params.id);
|
|
458
|
+
if (rec) rec.recalls++;
|
|
459
|
+
const hit = fullOutputs.get(params.id);
|
|
460
|
+
log({ event: "recall", id: params.id, found: !!hit, lines: params.lines, pattern: params.pattern });
|
|
461
|
+
if (!hit) return { content: [{ type: "text", text: `No stored output for id ${params.id}. Re-run the original tool instead.` }], details: { id: params.id, lines: 0 } };
|
|
462
|
+
const all = hit.text.split("\n");
|
|
463
|
+
let idx = all.map((_, i) => i);
|
|
464
|
+
if (params.lines) {
|
|
465
|
+
const m = params.lines.match(/^(\d+)\s*-\s*(\d+)$/);
|
|
466
|
+
if (!m) return { content: [{ type: "text", text: "lines must look like 120-180" }], details: { id: params.id, lines: 0 } };
|
|
467
|
+
const a = Math.max(1, Number(m[1])), b = Math.min(all.length, Number(m[2]));
|
|
468
|
+
idx = idx.filter((i) => i + 1 >= a && i + 1 <= b);
|
|
469
|
+
}
|
|
470
|
+
if (params.pattern) {
|
|
471
|
+
let test: (l: string) => boolean;
|
|
472
|
+
const rx = params.pattern.match(/^\/(.*)\/([a-z]*)$/);
|
|
473
|
+
if (rx) { const re = new RegExp(rx[1], rx[2].includes("i") ? rx[2] : rx[2] + "i"); test = (l) => re.test(l); }
|
|
474
|
+
else { const needle = params.pattern.toLowerCase(); test = (l) => l.toLowerCase().includes(needle); }
|
|
475
|
+
const keep = new Set<number>();
|
|
476
|
+
for (const i of idx) if (test(all[i])) for (let j = Math.max(0, i - 2); j <= Math.min(all.length - 1, i + 2); j++) keep.add(j);
|
|
477
|
+
idx = idx.filter((i) => keep.has(i));
|
|
478
|
+
}
|
|
479
|
+
const width = String(all.length).length;
|
|
480
|
+
const body = idx.length === all.length ? hit.text : idx.map((i) => `${String(i + 1).padStart(width)}│ ${all[i]}`).join("\n");
|
|
481
|
+
const header = idx.length === all.length ? "" : `[${idx.length} of ${all.length} lines from ${hit.toolName} ${truncate(JSON.stringify(hit.args ?? {}), 80)}]\n`;
|
|
482
|
+
return { content: [{ type: "text", text: header + body }], details: { id: params.id, lines: idx.length } };
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// ---- TUI: built-in tools re-registered so compressed results show what was saved -----------
|
|
487
|
+
|
|
488
|
+
if (process.env.JEV_LENS_UI !== "0") {
|
|
489
|
+
const cwd = process.cwd();
|
|
490
|
+
const originals: Record<string, ReturnType<typeof createReadTool>> = {
|
|
491
|
+
read: createReadTool(cwd) as ReturnType<typeof createReadTool>,
|
|
492
|
+
bash: createBashTool(cwd) as unknown as ReturnType<typeof createReadTool>,
|
|
493
|
+
grep: createGrepTool(cwd) as unknown as ReturnType<typeof createReadTool>,
|
|
494
|
+
find: createFindTool(cwd) as unknown as ReturnType<typeof createReadTool>,
|
|
495
|
+
ls: createLsTool(cwd) as unknown as ReturnType<typeof createReadTool>,
|
|
496
|
+
};
|
|
497
|
+
for (const [name, original] of Object.entries(originals)) {
|
|
498
|
+
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[] };
|
|
499
|
+
pi.registerTool({
|
|
500
|
+
name,
|
|
501
|
+
label: name,
|
|
502
|
+
description: o.description,
|
|
503
|
+
parameters: o.parameters,
|
|
504
|
+
promptSnippet: o.promptSnippet,
|
|
505
|
+
promptGuidelines: o.promptGuidelines,
|
|
506
|
+
async execute(toolCallId: string, params: unknown, signal: AbortSignal | undefined, onUpdate: unknown, ctx: unknown) {
|
|
507
|
+
return (o.execute as (id: string, p: unknown, s: unknown, u: unknown, c: unknown) => Promise<never>)(toolCallId, params, signal, onUpdate, ctx);
|
|
508
|
+
},
|
|
509
|
+
renderCall(args: unknown, theme: Theme, context: unknown) {
|
|
510
|
+
if (o.renderCall) return (o.renderCall as (a: unknown, t: unknown, c: unknown) => never)(args, theme, context);
|
|
511
|
+
const a = args as Record<string, unknown>;
|
|
512
|
+
const what = typeof a.path === "string" ? a.path : typeof a.command === "string" ? a.command : typeof a.pattern === "string" ? a.pattern : "";
|
|
513
|
+
return new Text(theme.fg("toolTitle", theme.bold(`${name} `)) + theme.fg("accent", String(what)), 0, 0);
|
|
514
|
+
},
|
|
515
|
+
renderResult(result: { content: unknown }, options: { expanded: boolean }, theme: Theme, context: { toolCallId: string }) {
|
|
516
|
+
const rec = recordById.get(context.toolCallId);
|
|
517
|
+
if (!rec) {
|
|
518
|
+
if (o.renderResult) return (o.renderResult as (r: unknown, op: unknown, t: unknown, c: unknown) => never)(result, options, theme, context);
|
|
519
|
+
const text = contentText(result.content);
|
|
520
|
+
const lines = text.split("\n");
|
|
521
|
+
let out = theme.fg("success", `${lines.length} lines`);
|
|
522
|
+
if (options.expanded) out += "\n" + lines.slice(0, 200).join("\n");
|
|
523
|
+
else out += theme.fg("dim", " " + lines[0]?.slice(0, 80));
|
|
524
|
+
return new Text(out, 0, 0);
|
|
525
|
+
}
|
|
526
|
+
let out = savingsLine(rec, theme);
|
|
527
|
+
if (options.expanded) out += "\n" + rec.sent;
|
|
528
|
+
else out += "\n" + theme.fg("dim", rec.sent.split("\n").slice(0, 3).join("\n"));
|
|
529
|
+
return new Text(out, 0, 0);
|
|
530
|
+
},
|
|
531
|
+
} as never);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---- commands ----------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
pi.registerCommand("jev-lens", {
|
|
538
|
+
description: "jev-lens: stats | list (compressed results) | diff [n] (original vs sent, overlay) | decisions | file | key [api-key] (store your TypeSafe key)",
|
|
539
|
+
handler: async (args, ctx) => {
|
|
540
|
+
const sub = (args ?? "").trim();
|
|
541
|
+
if (sub === "key" || sub.startsWith("key ")) {
|
|
542
|
+
let key = sub.slice(3).trim();
|
|
543
|
+
if (!key) key = ((await ctx.ui.input("TypeSafe API key (from console.typesafe.ai):", "ts_...")) ?? "").trim();
|
|
544
|
+
if (!key) { ctx.ui.notify("no key entered", "info"); return; }
|
|
545
|
+
const where = storeKey(key);
|
|
546
|
+
useKey(key);
|
|
547
|
+
ctx.ui.notify(`jev-lens: key stored in ${where}; jev is active from the next tool result`, "info");
|
|
548
|
+
status(ctx);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (sub === "file") {
|
|
552
|
+
const text = readMemoryFile(memoryPath) || "(memory file is empty)";
|
|
553
|
+
ctx.ui.notify(text, "info");
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (sub.startsWith("diff")) {
|
|
557
|
+
const n = Number(sub.slice(4).trim() || "1");
|
|
558
|
+
const rec = records[records.length - (Number.isFinite(n) && n >= 1 ? n : 1)];
|
|
559
|
+
if (!rec) { ctx.ui.notify("no compressed tool result to show yet", "info"); return; }
|
|
560
|
+
if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify(listLines([rec], { fg: (_c, t) => t, bold: (t) => t }).join("\n"), "info"); return; }
|
|
561
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
562
|
+
const height = Math.max(12, Math.floor(((tui as { terminalHeight?: number }).terminalHeight ?? process.stdout.rows ?? 40) * 0.85));
|
|
563
|
+
const overlay = new DiffOverlay(rec, theme, height, () => done(), () => tui.requestRender());
|
|
564
|
+
return { render: (w) => overlay.render(w), handleInput: (d) => overlay.handleInput(d), invalidate: () => overlay.invalidate() };
|
|
565
|
+
}, { overlay: true, overlayOptions: { width: "92%", maxHeight: "90%", anchor: "center" } });
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (sub === "list") {
|
|
569
|
+
ctx.ui.notify(listLines(records, { fg: (_c, t) => t, bold: (t) => t }).join("\n"), "info");
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (sub === "decisions") {
|
|
573
|
+
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=${d.p.durable.toFixed(2)} ${d.tokensBefore}t ${d.summary}`);
|
|
574
|
+
ctx.ui.notify(rows.join("\n") || "(no decisions yet)", "info");
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const hit = totals.input + totals.cacheRead > 0 ? Math.round((100 * totals.cacheRead) / (totals.input + totals.cacheRead)) : 0;
|
|
578
|
+
ctx.ui.notify(
|
|
579
|
+
[
|
|
580
|
+
`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"}`,
|
|
581
|
+
`presend: ${presendTotals.compressed}/${presendTotals.considered} large results compressed, ≈${presendTotals.tokensSaved} tokens saved, ${presendTotals.recalls} recalls`,
|
|
582
|
+
`post-send: calls=${totals.calls} decisions=${ledger.size} applied=${totals.applied} pruned≈${totals.pruned} tokens`,
|
|
583
|
+
`cache: read=${totals.cacheRead} uncached=${totals.input} hit=${hit}%`,
|
|
584
|
+
`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)`,
|
|
585
|
+
`memory file: ${memoryPath} (+${totals.notes} notes this session)`,
|
|
586
|
+
].join("\n"),
|
|
587
|
+
"info",
|
|
588
|
+
);
|
|
589
|
+
},
|
|
590
|
+
});
|
|
591
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-jev-lens",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"author": "Didrik Rognstad",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/dizk/pi-jev-lens.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/dizk/pi-jev-lens#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/dizk/pi-jev-lens/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi-package",
|
|
17
|
+
"pi-extension",
|
|
18
|
+
"typesafe",
|
|
19
|
+
"jev",
|
|
20
|
+
"context",
|
|
21
|
+
"compression",
|
|
22
|
+
"prompt-cache",
|
|
23
|
+
"tool-output"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "index.ts",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"index.ts",
|
|
32
|
+
"src/",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE",
|
|
35
|
+
"STATUS.md"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"replay": "node --import tsx eval/replay.ts",
|
|
40
|
+
"typecheck": "tsc --noEmit"
|
|
41
|
+
},
|
|
42
|
+
"pi": {
|
|
43
|
+
"extensions": [
|
|
44
|
+
"./index.ts"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@binclusive/tree-sitter-kotlin-wasm": "^0.1.0",
|
|
49
|
+
"@typesafe-ai/sdk": "^0.6.0",
|
|
50
|
+
"@vscode/tree-sitter-wasm": "^0.3.1",
|
|
51
|
+
"web-tree-sitter": "^0.27.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
55
|
+
"@earendil-works/pi-tui": "*",
|
|
56
|
+
"typebox": "*"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/node": "^26.6.1",
|
|
60
|
+
"tsx": "^4.23.13",
|
|
61
|
+
"typescript": "^7.0.2",
|
|
62
|
+
"vitest": "^5.0.1",
|
|
63
|
+
"@earendil-works/pi-coding-agent": "^0.84.3",
|
|
64
|
+
"@earendil-works/pi-tui": "^0.84.3",
|
|
65
|
+
"typebox": "^1.3.33"
|
|
66
|
+
}
|
|
67
|
+
}
|