@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.
- package/LICENSE +21 -0
- package/README.md +340 -0
- package/README.zh-CN.md +340 -0
- package/bin/pi-jev.js +13 -0
- package/dist/cli/main.js +223 -0
- package/dist/commands/completions.js +87 -0
- package/dist/commands/extension.js +58 -0
- package/dist/commands/menu.js +245 -0
- package/dist/commands/models.js +23 -0
- package/dist/compaction/convert.js +87 -0
- package/dist/compaction/decision.js +195 -0
- package/dist/compaction/extension.js +150 -0
- package/dist/compaction/jev.js +72 -0
- package/dist/compaction/summarize.js +68 -0
- package/dist/routing/decide.js +57 -0
- package/dist/routing/extension.js +81 -0
- package/dist/shared/config.js +157 -0
- package/dist/vendor/fast-jev-compaction/client.js +25 -0
- package/dist/vendor/fast-jev-compaction/compact.js +233 -0
- package/dist/vendor/fast-jev-compaction/index.js +7 -0
- package/dist/vendor/fast-jev-compaction/request.js +50 -0
- package/dist/vendor/fast-jev-compaction/state.js +255 -0
- package/dist/vendor/fast-jev-compaction/types.js +1 -0
- package/extensions/compaction.ts +1 -0
- package/extensions/jev.ts +1 -0
- package/extensions/routing.ts +1 -0
- package/media/banner.svg +198 -0
- package/package.json +55 -0
- package/src/cli/main.ts +241 -0
- package/src/commands/completions.ts +107 -0
- package/src/commands/extension.ts +61 -0
- package/src/commands/menu.ts +291 -0
- package/src/commands/models.ts +43 -0
- package/src/compaction/convert.ts +95 -0
- package/src/compaction/decision.ts +262 -0
- package/src/compaction/extension.ts +235 -0
- package/src/compaction/jev.ts +133 -0
- package/src/compaction/summarize.ts +80 -0
- package/src/routing/decide.ts +81 -0
- package/src/routing/extension.ts +92 -0
- package/src/shared/config.ts +280 -0
- package/src/vendor/fast-jev-compaction/LICENSE +21 -0
- package/src/vendor/fast-jev-compaction/client.ts +43 -0
- package/src/vendor/fast-jev-compaction/compact.ts +309 -0
- package/src/vendor/fast-jev-compaction/index.ts +7 -0
- package/src/vendor/fast-jev-compaction/request.ts +80 -0
- package/src/vendor/fast-jev-compaction/state.ts +304 -0
- 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
|
+
}
|