@alexlikevibe/pi-jev 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +340 -0
  3. package/README.zh-CN.md +340 -0
  4. package/bin/pi-jev.js +13 -0
  5. package/dist/cli/main.js +223 -0
  6. package/dist/commands/completions.js +87 -0
  7. package/dist/commands/extension.js +58 -0
  8. package/dist/commands/menu.js +245 -0
  9. package/dist/commands/models.js +23 -0
  10. package/dist/compaction/convert.js +87 -0
  11. package/dist/compaction/decision.js +195 -0
  12. package/dist/compaction/extension.js +150 -0
  13. package/dist/compaction/jev.js +72 -0
  14. package/dist/compaction/summarize.js +68 -0
  15. package/dist/routing/decide.js +57 -0
  16. package/dist/routing/extension.js +81 -0
  17. package/dist/shared/config.js +157 -0
  18. package/dist/vendor/fast-jev-compaction/client.js +25 -0
  19. package/dist/vendor/fast-jev-compaction/compact.js +233 -0
  20. package/dist/vendor/fast-jev-compaction/index.js +7 -0
  21. package/dist/vendor/fast-jev-compaction/request.js +50 -0
  22. package/dist/vendor/fast-jev-compaction/state.js +255 -0
  23. package/dist/vendor/fast-jev-compaction/types.js +1 -0
  24. package/extensions/compaction.ts +1 -0
  25. package/extensions/jev.ts +1 -0
  26. package/extensions/routing.ts +1 -0
  27. package/media/banner.svg +198 -0
  28. package/package.json +55 -0
  29. package/src/cli/main.ts +241 -0
  30. package/src/commands/completions.ts +107 -0
  31. package/src/commands/extension.ts +61 -0
  32. package/src/commands/menu.ts +291 -0
  33. package/src/commands/models.ts +43 -0
  34. package/src/compaction/convert.ts +95 -0
  35. package/src/compaction/decision.ts +262 -0
  36. package/src/compaction/extension.ts +235 -0
  37. package/src/compaction/jev.ts +133 -0
  38. package/src/compaction/summarize.ts +80 -0
  39. package/src/routing/decide.ts +81 -0
  40. package/src/routing/extension.ts +92 -0
  41. package/src/shared/config.ts +280 -0
  42. package/src/vendor/fast-jev-compaction/LICENSE +21 -0
  43. package/src/vendor/fast-jev-compaction/client.ts +43 -0
  44. package/src/vendor/fast-jev-compaction/compact.ts +309 -0
  45. package/src/vendor/fast-jev-compaction/index.ts +7 -0
  46. package/src/vendor/fast-jev-compaction/request.ts +80 -0
  47. package/src/vendor/fast-jev-compaction/state.ts +304 -0
  48. package/src/vendor/fast-jev-compaction/types.ts +202 -0
@@ -0,0 +1,235 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import type { AgentMessage } from '@earendil-works/pi-agent-core';
3
+ import type { Usage } from '@earendil-works/pi-ai';
4
+ import {
5
+ JevClient,
6
+ estimateTokens,
7
+ goalFromMessages,
8
+ type JevAsker,
9
+ } from '../vendor/fast-jev-compaction/index.js';
10
+ import { loadConfig, type JevCompactionConfig } from '../shared/config.js';
11
+ import { convertMessages } from './convert.js';
12
+ import { compactWithJev, type JevCompactionStats } from './jev.js';
13
+ import { renderSummary, renderTranscript } from './summarize.js';
14
+
15
+ /** The slice of `SessionBeforeCompactEvent` the compaction core needs. */
16
+ export interface CompactionSpanInput {
17
+ messagesToSummarize: readonly AgentMessage[];
18
+ turnPrefixMessages: readonly AgentMessage[];
19
+ previousSummary?: string;
20
+ customInstructions?: string;
21
+ firstKeptEntryId: string;
22
+ tokensBefore: number;
23
+ }
24
+
25
+ export interface JevDetails {
26
+ engine: 'jev';
27
+ stats: JevCompactionStats;
28
+ decisions: Array<{
29
+ id: string;
30
+ tool: string;
31
+ action: string;
32
+ reason: string;
33
+ keepCall: number;
34
+ keepResult: number;
35
+ }>;
36
+ }
37
+
38
+ export type JevCompactionRun =
39
+ | {
40
+ ok: true;
41
+ compaction: {
42
+ summary: string;
43
+ firstKeptEntryId: string;
44
+ tokensBefore: number;
45
+ estimatedTokensAfter: number;
46
+ usage?: Usage;
47
+ details: JevDetails;
48
+ };
49
+ reduction: number;
50
+ }
51
+ | {
52
+ ok: false;
53
+ reason: 'empty-span' | 'low-reduction';
54
+ reduction: number;
55
+ };
56
+
57
+ function toUsage(jev: { input: number; output: number }): Usage {
58
+ const total = jev.input + jev.output;
59
+ return {
60
+ input: jev.input,
61
+ output: jev.output,
62
+ cacheRead: 0,
63
+ cacheWrite: 0,
64
+ totalTokens: total,
65
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Core pipeline, separated from the extension hook so tests can drive it with
71
+ * a fake `JevAsker`. Converts the span, asks Jev which tool calls and results
72
+ * still matter, and renders the surviving transcript verbatim as the
73
+ * compaction summary. Returns `ok: false` when the span is empty or the
74
+ * estimated reduction is below `config.minReduction` (caller falls back to
75
+ * pi's default compaction). Throws when Jev fails; the caller decides.
76
+ */
77
+ export async function runJevCompaction(
78
+ input: CompactionSpanInput,
79
+ asker: JevAsker,
80
+ config: JevCompactionConfig,
81
+ ): Promise<JevCompactionRun> {
82
+ const span = [...input.messagesToSummarize, ...input.turnPrefixMessages];
83
+ const converted = convertMessages(span);
84
+ if (converted.length === 0) {
85
+ return { ok: false, reason: 'empty-span', reduction: 0 };
86
+ }
87
+
88
+ const goal = input.customInstructions?.trim() || goalFromMessages(converted);
89
+ const outcome = await compactWithJev(converted, asker, config, goal);
90
+
91
+ const summary = renderSummary(outcome.messages, {
92
+ goal,
93
+ previousSummary: input.previousSummary,
94
+ droppedCalls: outcome.stats.callsDropped,
95
+ truncatedResults: outcome.stats.resultsDropped,
96
+ });
97
+
98
+ // Size estimation in one consistent unit: render the original and the
99
+ // compacted span with the same renderer. The previous summary is a fixed
100
+ // cost on both sides, so the reduction ignores it.
101
+ const spanTokens = estimateTokens(renderTranscript(converted));
102
+ const keptTokens = estimateTokens(renderTranscript(outcome.messages));
103
+ const summaryTokens = estimateTokens(summary);
104
+ const reduction = spanTokens === 0 ? 0 : 1 - keptTokens / spanTokens;
105
+
106
+ if (reduction < config.minReduction) {
107
+ return { ok: false, reason: 'low-reduction', reduction };
108
+ }
109
+
110
+ return {
111
+ ok: true,
112
+ reduction,
113
+ compaction: {
114
+ summary,
115
+ firstKeptEntryId: input.firstKeptEntryId,
116
+ tokensBefore: input.tokensBefore,
117
+ estimatedTokensAfter: Math.max(summaryTokens, input.tokensBefore - spanTokens + summaryTokens),
118
+ usage: outcome.stats.jevUsage ? toUsage(outcome.stats.jevUsage) : undefined,
119
+ details: {
120
+ engine: 'jev',
121
+ stats: outcome.stats,
122
+ decisions: outcome.decisions.map(decision => ({
123
+ id: decision.id,
124
+ tool: decision.tool,
125
+ action: decision.action,
126
+ reason: decision.reason,
127
+ keepCall: decision.keepCall,
128
+ keepResult: decision.keepResult,
129
+ })),
130
+ },
131
+ },
132
+ };
133
+ }
134
+
135
+ function raceAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
136
+ if (!signal) return promise;
137
+ return new Promise<T>((resolve, reject) => {
138
+ const onAbort = (): void => reject(new Error('aborted'));
139
+ if (signal.aborted) {
140
+ onAbort();
141
+ return;
142
+ }
143
+ signal.addEventListener('abort', onAbort, { once: true });
144
+ promise.then(
145
+ value => {
146
+ signal.removeEventListener('abort', onAbort);
147
+ resolve(value);
148
+ },
149
+ error => {
150
+ signal.removeEventListener('abort', onAbort);
151
+ reject(error);
152
+ },
153
+ );
154
+ });
155
+ }
156
+
157
+ function errorMessage(error: unknown): string {
158
+ return error instanceof Error ? error.message : String(error);
159
+ }
160
+
161
+ /**
162
+ * The extension: on `session_before_compact`, replace pi's LLM-generated
163
+ * summary with a Jev-compacted verbatim transcript. Any failure, abort, or
164
+ * insufficient reduction falls back to pi's default compaction.
165
+ */
166
+ export default function (pi: ExtensionAPI): void {
167
+ pi.on('session_start', async (_event, ctx) => {
168
+ const config = loadConfig();
169
+ if (config.disabled) return;
170
+ ctx.ui.notify(
171
+ config.apiKey
172
+ ? `jev-compaction active (Jev via ${config.provider}): stale tool outputs are dropped, not summarized`
173
+ : 'jev-compaction: set TYPESAFE_API_KEY or OPENROUTER_API_KEY (or JEVC_API_KEY/JEVC_PROVIDER) to enable; using default compaction',
174
+ config.apiKey ? 'info' : 'warning',
175
+ );
176
+ });
177
+
178
+ pi.on('session_before_compact', async (event, ctx) => {
179
+ // Loaded per event so `/jev set` applies without a restart.
180
+ const config = loadConfig();
181
+ if (config.disabled || !config.apiKey) return;
182
+ if (event.signal?.aborted) return;
183
+
184
+ const { preparation, customInstructions } = event;
185
+ const asker = new JevClient({
186
+ apiKey: config.apiKey,
187
+ model: config.model,
188
+ baseUrl: config.baseUrl,
189
+ });
190
+
191
+ try {
192
+ const run = await raceAbort(
193
+ runJevCompaction(
194
+ {
195
+ messagesToSummarize: preparation.messagesToSummarize,
196
+ turnPrefixMessages: preparation.turnPrefixMessages,
197
+ previousSummary: preparation.previousSummary,
198
+ customInstructions,
199
+ firstKeptEntryId: preparation.firstKeptEntryId,
200
+ tokensBefore: preparation.tokensBefore,
201
+ },
202
+ asker,
203
+ config,
204
+ ),
205
+ event.signal,
206
+ );
207
+
208
+ if (!run.ok) {
209
+ if (run.reason === 'low-reduction') {
210
+ ctx.ui.notify(
211
+ `jev-compaction: only ${(run.reduction * 100).toFixed(0)}% reduction; using default compaction`,
212
+ 'warning',
213
+ );
214
+ }
215
+ return; // undefined result -> pi runs its default compaction
216
+ }
217
+
218
+ const stats = run.compaction.details.stats;
219
+ ctx.ui.notify(
220
+ `jev-compaction: ${stats.calls - stats.pinned} calls scored — kept ${stats.kept - stats.pinned}, ` +
221
+ `truncated ${stats.resultsDropped}, dropped ${stats.callsDropped} ` +
222
+ `(${stats.requests} Jev req, ${(run.reduction * 100).toFixed(0)}% smaller, ${stats.ms} ms)`,
223
+ 'info',
224
+ );
225
+ return { compaction: run.compaction };
226
+ } catch (error) {
227
+ if (event.signal?.aborted) return;
228
+ ctx.ui.notify(
229
+ `jev-compaction failed (${errorMessage(error)}); using default compaction`,
230
+ 'error',
231
+ );
232
+ return;
233
+ }
234
+ });
235
+ }
@@ -0,0 +1,133 @@
1
+ import {
2
+ applyJevDecisions,
3
+ batchCalls,
4
+ decideCall,
5
+ questionsFor,
6
+ type DecisionOutcome,
7
+ } from './decision.js';
8
+ import {
9
+ collectToolCalls,
10
+ fitState,
11
+ goalFromMessages,
12
+ messageChars,
13
+ resolveOptions,
14
+ type JevAnswer,
15
+ type JevAsker,
16
+ type JevQuestions,
17
+ type Message,
18
+ type ToolCall,
19
+ } from '../vendor/fast-jev-compaction/index.js';
20
+ import type { JevCompactionConfig } from '../shared/config.js';
21
+
22
+ export interface JevCompactionStats {
23
+ messagesBefore: number;
24
+ messagesAfter: number;
25
+ charsBefore: number;
26
+ charsAfter: number;
27
+ calls: number;
28
+ kept: number;
29
+ resultsDropped: number;
30
+ callsDropped: number;
31
+ pinned: number;
32
+ /** Borderline results kept because of a confident low-staleness score. */
33
+ guarded: number;
34
+ /** Calls whose answers were missing/malformed; kept conservatively. */
35
+ missing: number;
36
+ stateTokens: number;
37
+ stateStage: string;
38
+ requests: number;
39
+ ms: number;
40
+ jevUsage?: { input: number; output: number };
41
+ }
42
+
43
+ export interface JevCompactionOutcome {
44
+ /** Compacted transcript; untouched messages are the input objects. */
45
+ messages: Message[];
46
+ decisions: DecisionOutcome[];
47
+ stats: JevCompactionStats;
48
+ }
49
+
50
+ /**
51
+ * Compacts the converted span with Jev: every non-pinned tool call gets two
52
+ * `noul` questions (keep the call, keep its result verbatim) plus one `score`
53
+ * question (result staleness) whose confident answer can rescue a borderline
54
+ * result. Batches run concurrently; the same fitted state is sent with each.
55
+ */
56
+ export async function compactWithJev(
57
+ messages: readonly Message[],
58
+ asker: JevAsker,
59
+ config: JevCompactionConfig,
60
+ goal?: string,
61
+ ): Promise<JevCompactionOutcome> {
62
+ const started = Date.now();
63
+ const resolvedGoal = goal?.trim() || goalFromMessages(messages);
64
+ const resolved = resolveOptions({
65
+ goal: resolvedGoal,
66
+ keepThreshold: config.keepThreshold,
67
+ preserveRecentMessages: config.preserveRecentMessages,
68
+ maxStateTokens: config.maxStateTokens,
69
+ maxRequestTokens: config.maxRequestTokens,
70
+ truncateHeadChars: config.truncateHeadChars,
71
+ });
72
+
73
+ const calls = collectToolCalls(messages, resolved.preserveRecentMessages);
74
+ const candidates = calls.filter(call => !call.pinned);
75
+
76
+ const answers: Record<string, JevAnswer> = {};
77
+ let stateTokens = 0;
78
+ let stateStage = '';
79
+ let batches: ToolCall[][] = [];
80
+ let jevUsage: { input: number; output: number } | undefined;
81
+
82
+ if (candidates.length > 0) {
83
+ const fitted = fitState(messages, calls, resolved);
84
+ stateTokens = fitted.tokens;
85
+ stateStage = fitted.stage;
86
+ batches = batchCalls(candidates, stateTokens, config.maxRequestTokens);
87
+ const responses = await Promise.all(
88
+ batches.map(async batch => {
89
+ const questions: JevQuestions = Object.assign({}, ...batch.map(questionsFor));
90
+ return asker.ask(fitted.state, questions);
91
+ }),
92
+ );
93
+ for (const response of responses) {
94
+ Object.assign(answers, response.answers);
95
+ if (response.usage) {
96
+ const input = response.usage.input_tokens ?? 0;
97
+ const output = response.usage.output_tokens ?? 0;
98
+ jevUsage = jevUsage
99
+ ? { input: jevUsage.input + input, output: jevUsage.output + output }
100
+ : { input, output };
101
+ }
102
+ }
103
+ }
104
+
105
+ const decisions = calls.map(call => decideCall(call, answers, config));
106
+ const kept = applyJevDecisions(messages, decisions, calls, resolved.truncateHeadChars);
107
+
108
+ const count = (predicate: (decision: ReturnType<typeof decideCall>) => boolean): number =>
109
+ decisions.filter(predicate).length;
110
+
111
+ return {
112
+ messages: kept,
113
+ decisions,
114
+ stats: {
115
+ messagesBefore: messages.length,
116
+ messagesAfter: kept.length,
117
+ charsBefore: messages.reduce((sum, message) => sum + messageChars(message), 0),
118
+ charsAfter: kept.reduce((sum, message) => sum + messageChars(message), 0),
119
+ calls: calls.length,
120
+ kept: count(decision => decision.action === 'keep' && !decision.pinned),
121
+ resultsDropped: count(decision => decision.action === 'drop_result'),
122
+ callsDropped: count(decision => decision.action === 'drop_call'),
123
+ pinned: count(decision => decision.pinned),
124
+ guarded: count(decision => decision.guarded),
125
+ missing: count(decision => decision.missing),
126
+ stateTokens,
127
+ stateStage,
128
+ requests: batches.length,
129
+ ms: Date.now() - started,
130
+ jevUsage,
131
+ },
132
+ };
133
+ }
@@ -0,0 +1,80 @@
1
+ import type { Message } from '../vendor/fast-jev-compaction/index.js';
2
+
3
+ const ARG_VALUE_LIMIT = 160;
4
+
5
+ function argValue(value: unknown): string {
6
+ let text: string;
7
+ if (typeof value === 'string') text = value;
8
+ else {
9
+ try {
10
+ text = JSON.stringify(value) ?? String(value);
11
+ } catch {
12
+ text = '[unserializable]';
13
+ }
14
+ }
15
+ const flat = text.replace(/\s+/g, ' ').trim();
16
+ return flat.length <= ARG_VALUE_LIMIT ? flat : `${flat.slice(0, ARG_VALUE_LIMIT - 1)}…`;
17
+ }
18
+
19
+ function renderCall(name: string, input: Record<string, unknown>): string {
20
+ const args = Object.entries(input)
21
+ .map(([key, value]) => `${key}=${argValue(value)}`)
22
+ .join(' ');
23
+ return args.length > 0 ? `${name}(${args})` : name;
24
+ }
25
+
26
+ /**
27
+ * Renders messages as a flat transcript in the same style pi uses for
28
+ * summarization, so the LLM reads a familiar format. Applied to both the
29
+ * original and the compacted span, it doubles as the size estimator.
30
+ */
31
+ export function renderTranscript(messages: readonly Message[]): string {
32
+ const lines: string[] = [];
33
+ for (const message of messages) {
34
+ const trimmed = message.text.trim();
35
+ if (trimmed.length > 0) {
36
+ lines.push(`[${message.role === 'user' ? 'User' : 'Assistant'}]: ${trimmed}`);
37
+ }
38
+ if (message.toolUses.length > 0) {
39
+ lines.push(`[Assistant tool calls]: ${message.toolUses.map(call => renderCall(call.tool, call.input)).join('; ')}`);
40
+ }
41
+ for (const result of message.toolResults ?? []) {
42
+ lines.push(`[Tool result]: ${result.text.trim()}`);
43
+ }
44
+ }
45
+ return lines.join('\n\n');
46
+ }
47
+
48
+ export interface SummaryOptions {
49
+ goal?: string;
50
+ previousSummary?: string;
51
+ droppedCalls?: number;
52
+ truncatedResults?: number;
53
+ }
54
+
55
+ /**
56
+ * Builds the compaction summary: the previous summary (kept verbatim, it is
57
+ * already compact) followed by the Jev-retained transcript wrapped in a note
58
+ * explaining what the truncation markers mean.
59
+ */
60
+ export function renderSummary(messages: readonly Message[], options: SummaryOptions = {}): string {
61
+ const parts: string[] = [];
62
+
63
+ const previous = options.previousSummary?.trim();
64
+ if (previous) {
65
+ parts.push(`<summary-of-earlier-context>\n${previous}\n</summary-of-earlier-context>`);
66
+ }
67
+
68
+ const header = [
69
+ 'Earlier conversation retained by Jev selective compaction.',
70
+ 'User and assistant text is verbatim.',
71
+ ];
72
+ if ((options.droppedCalls ?? 0) > 0) header.push(`${options.droppedCalls} obsolete tool calls were removed.`);
73
+ if ((options.truncatedResults ?? 0) > 0) {
74
+ header.push(`${options.truncatedResults} tool results were truncated (marked); re-run a tool if its full output is needed again.`);
75
+ }
76
+ if (options.goal?.trim()) header.push(`Ongoing goal: ${options.goal.trim()}`);
77
+
78
+ parts.push(`<compacted-conversation>\n${header.join(' ')}\n\n${renderTranscript(messages)}\n</compacted-conversation>`);
79
+ return parts.join('\n\n');
80
+ }
@@ -0,0 +1,81 @@
1
+ import type { JevAnswer, JevQuestions } from '../vendor/fast-jev-compaction/index.js';
2
+ import type { RoutingConfig } from '../shared/config.js';
3
+
4
+ export const DIFFICULTY_LEVELS = ['trivial', 'moderate', 'complex'] as const;
5
+
6
+ export const ROUTING_CONTEXT =
7
+ 'A coding assistant is about to start a turn. `prompt` is the user request starting it. The question rates how demanding the request is for the assistant, so that easy requests can go to a cheaper model and hard ones to a stronger model.';
8
+
9
+ export function routingQuestions(): JevQuestions {
10
+ return {
11
+ difficulty: {
12
+ type: 'score',
13
+ instructions: `Rate how demanding this coding request is: level 0 is trivial (greetings, quick questions, simple lookups, formatting, single-file mechanical edits), level ${DIFFICULTY_LEVELS.length - 1} is complex (multi-file refactors, subtle debugging, architecture decisions)`,
14
+ criteria: [...DIFFICULTY_LEVELS],
15
+ },
16
+ };
17
+ }
18
+
19
+ export type RoutingTarget = 'cheap' | 'strong' | null;
20
+
21
+ export interface RoutingDecision {
22
+ target: RoutingTarget;
23
+ /** Raw Jev score, as returned. */
24
+ score: number;
25
+ /** Score mapped to the 0..4 level space (see `decideRouting`). */
26
+ levels: number;
27
+ confidence: number;
28
+ reason: 'easy' | 'hard' | 'middle' | 'low-confidence' | 'missing-answer';
29
+ }
30
+
31
+ function scoreFrom(answers: Record<string, JevAnswer>): { score: number; confidence: number } | undefined {
32
+ const answer = answers.difficulty;
33
+ if (
34
+ answer === null ||
35
+ typeof answer !== 'object' ||
36
+ !('score' in answer) ||
37
+ typeof (answer as { score?: unknown }).score !== 'number' ||
38
+ !Number.isFinite((answer as { score: number }).score)
39
+ ) {
40
+ return undefined;
41
+ }
42
+ const confidence =
43
+ 'confidence' in answer && typeof (answer as { confidence?: unknown }).confidence === 'number'
44
+ ? (answer as { confidence: number }).confidence
45
+ : 0;
46
+ return { score: (answer as { score: number }).score, confidence };
47
+ }
48
+
49
+ /**
50
+ * Maps a Jev score to the 0..N level space (N = levels - 1), clamped. Jev returns either a 0..1
51
+ * continuous score or a level index; `score <= 1` is read as normalized
52
+ * (a literal 1 therefore means "hardest", the conservative direction).
53
+ */
54
+ export function toLevels(score: number): number {
55
+ const span = DIFFICULTY_LEVELS.length - 1;
56
+ return Math.min(span, Math.max(0, (score <= 1 ? score : score / span) * span));
57
+ }
58
+ /**
59
+ * Pure decision: easy requests go to the cheap model, hard ones to the strong
60
+ * model, everything else (middle band, low confidence, missing or malformed
61
+ * answer) keeps the current model.
62
+ */
63
+ export function decideRouting(answers: Record<string, JevAnswer>, config: RoutingConfig): RoutingDecision {
64
+ const answer = scoreFrom(answers);
65
+ if (!answer) {
66
+ return { target: null, score: 0, levels: 0, confidence: 0, reason: 'missing-answer' };
67
+ }
68
+ const levels = toLevels(answer.score);
69
+ const base = { score: answer.score, levels, confidence: answer.confidence };
70
+
71
+ if (answer.confidence < config.minConfidence) {
72
+ return { ...base, target: null, reason: 'low-confidence' };
73
+ }
74
+ if (config.cheap && levels <= config.easyMax) {
75
+ return { ...base, target: 'cheap', reason: 'easy' };
76
+ }
77
+ if (config.strong && levels >= config.hardMin) {
78
+ return { ...base, target: 'strong', reason: 'hard' };
79
+ }
80
+ return { ...base, target: null, reason: 'middle' };
81
+ }
@@ -0,0 +1,92 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { JevClient, type JevAsker } from '../vendor/fast-jev-compaction/index.js';
3
+ import { loadConfig, type RoutingConfig } from '../shared/config.js';
4
+ import { decideRouting, DIFFICULTY_LEVELS, ROUTING_CONTEXT, routingQuestions, type RoutingDecision } from './decide.js';
5
+
6
+ /** Asks Jev to rate the request difficulty and maps it to a routing target. */
7
+ export async function runRouting(
8
+ prompt: string,
9
+ asker: JevAsker,
10
+ config: RoutingConfig,
11
+ ): Promise<RoutingDecision> {
12
+ const response = await asker.ask({ context: ROUTING_CONTEXT, prompt }, routingQuestions());
13
+ return decideRouting(response.answers, config);
14
+ }
15
+
16
+ /**
17
+ * Parses a `"provider/model-id"` reference, optionally with a `:thinking`
18
+ * suffix (pi style, e.g. `deepseek/deepseek-flash:high`).
19
+ */
20
+ export function parseModelRef(ref: string): { provider: string; id: string; thinking?: string } | undefined {
21
+ const slash = ref.indexOf('/');
22
+ if (slash <= 0 || slash === ref.length - 1) return undefined;
23
+ const rest = ref.slice(slash + 1);
24
+ const colon = rest.lastIndexOf(':');
25
+ if (colon > 0) {
26
+ const thinking = rest.slice(colon + 1);
27
+ if (thinking) return { provider: ref.slice(0, slash), id: rest.slice(0, colon), thinking };
28
+ }
29
+ return { provider: ref.slice(0, slash), id: rest };
30
+ }
31
+
32
+ function errorMessage(error: unknown): string {
33
+ return error instanceof Error ? error.message : String(error);
34
+ }
35
+
36
+ /**
37
+ * The routing extension: before each agent turn, Jev rates the request
38
+ * difficulty; confidently easy requests switch to `JEVC_ROUTE_CHEAP`,
39
+ * confidently hard ones to `JEVC_ROUTE_STRONG`. Every other outcome (middle
40
+ * band, low confidence, Jev failure, model not found, auth missing) keeps the
41
+ * current model. Prompts with images never downgrade to a text-only model.
42
+ */
43
+ export default function (pi: ExtensionAPI): void {
44
+ pi.on('before_agent_start', async (event, ctx) => {
45
+ // Loaded per turn so `/jev set` applies without a restart.
46
+ const config = loadConfig();
47
+ const routing = config.routing;
48
+ if (config.disabled || !config.apiKey) return;
49
+ if (!routing.cheap && !routing.strong) return;
50
+ if (!event.prompt.trim()) return;
51
+
52
+ try {
53
+ const decision = await runRouting(
54
+ event.prompt,
55
+ new JevClient({ apiKey: config.apiKey, model: config.model, baseUrl: config.baseUrl }),
56
+ routing,
57
+ );
58
+ if (!decision.target) return;
59
+
60
+ const ref = parseModelRef(decision.target === 'cheap' ? routing.cheap! : routing.strong!);
61
+ if (!ref) {
62
+ ctx.ui.notify(`jev-routing: invalid JEVC_ROUTE_${decision.target.toUpperCase()} reference`, 'warning');
63
+ return;
64
+ }
65
+ const model = ctx.modelRegistry.find(ref.provider, ref.id);
66
+ if (!model) {
67
+ ctx.ui.notify(`jev-routing: ${ref.provider}/${ref.id} not found; keeping current model`, 'warning');
68
+ return;
69
+ }
70
+ if (ctx.model && ctx.model.id === model.id && ctx.model.provider === model.provider) return;
71
+ if (decision.target === 'cheap' && (event.images?.length ?? 0) > 0 && !model.input.includes('image')) {
72
+ return; // never route an image prompt to a text-only model
73
+ }
74
+
75
+ const switched = await pi.setModel(model);
76
+ if (!switched) {
77
+ ctx.ui.notify(`jev-routing: auth not configured for ${ref.provider}/${ref.id}; keeping current model`, 'warning');
78
+ return;
79
+ }
80
+ if (ref.thinking) {
81
+ pi.setThinkingLevel(ref.thinking as Parameters<typeof pi.setThinkingLevel>[0]);
82
+ }
83
+ ctx.ui.notify(
84
+ `jev-routing: ${decision.reason} request (difficulty ${decision.levels.toFixed(1)}/${DIFFICULTY_LEVELS.length - 1}, ` +
85
+ `confidence ${(decision.confidence * 100).toFixed(0)}%) → ${ref.provider}/${ref.id}`,
86
+ 'info',
87
+ );
88
+ } catch (error) {
89
+ ctx.ui.notify(`jev-routing failed (${errorMessage(error)}); keeping current model`, 'error');
90
+ }
91
+ });
92
+ }