@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,150 @@
|
|
|
1
|
+
import { JevClient, estimateTokens, goalFromMessages, } from '../vendor/fast-jev-compaction/index.js';
|
|
2
|
+
import { loadConfig } from '../shared/config.js';
|
|
3
|
+
import { convertMessages } from './convert.js';
|
|
4
|
+
import { compactWithJev } from './jev.js';
|
|
5
|
+
import { renderSummary, renderTranscript } from './summarize.js';
|
|
6
|
+
function toUsage(jev) {
|
|
7
|
+
const total = jev.input + jev.output;
|
|
8
|
+
return {
|
|
9
|
+
input: jev.input,
|
|
10
|
+
output: jev.output,
|
|
11
|
+
cacheRead: 0,
|
|
12
|
+
cacheWrite: 0,
|
|
13
|
+
totalTokens: total,
|
|
14
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Core pipeline, separated from the extension hook so tests can drive it with
|
|
19
|
+
* a fake `JevAsker`. Converts the span, asks Jev which tool calls and results
|
|
20
|
+
* still matter, and renders the surviving transcript verbatim as the
|
|
21
|
+
* compaction summary. Returns `ok: false` when the span is empty or the
|
|
22
|
+
* estimated reduction is below `config.minReduction` (caller falls back to
|
|
23
|
+
* pi's default compaction). Throws when Jev fails; the caller decides.
|
|
24
|
+
*/
|
|
25
|
+
export async function runJevCompaction(input, asker, config) {
|
|
26
|
+
const span = [...input.messagesToSummarize, ...input.turnPrefixMessages];
|
|
27
|
+
const converted = convertMessages(span);
|
|
28
|
+
if (converted.length === 0) {
|
|
29
|
+
return { ok: false, reason: 'empty-span', reduction: 0 };
|
|
30
|
+
}
|
|
31
|
+
const goal = input.customInstructions?.trim() || goalFromMessages(converted);
|
|
32
|
+
const outcome = await compactWithJev(converted, asker, config, goal);
|
|
33
|
+
const summary = renderSummary(outcome.messages, {
|
|
34
|
+
goal,
|
|
35
|
+
previousSummary: input.previousSummary,
|
|
36
|
+
droppedCalls: outcome.stats.callsDropped,
|
|
37
|
+
truncatedResults: outcome.stats.resultsDropped,
|
|
38
|
+
});
|
|
39
|
+
// Size estimation in one consistent unit: render the original and the
|
|
40
|
+
// compacted span with the same renderer. The previous summary is a fixed
|
|
41
|
+
// cost on both sides, so the reduction ignores it.
|
|
42
|
+
const spanTokens = estimateTokens(renderTranscript(converted));
|
|
43
|
+
const keptTokens = estimateTokens(renderTranscript(outcome.messages));
|
|
44
|
+
const summaryTokens = estimateTokens(summary);
|
|
45
|
+
const reduction = spanTokens === 0 ? 0 : 1 - keptTokens / spanTokens;
|
|
46
|
+
if (reduction < config.minReduction) {
|
|
47
|
+
return { ok: false, reason: 'low-reduction', reduction };
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
reduction,
|
|
52
|
+
compaction: {
|
|
53
|
+
summary,
|
|
54
|
+
firstKeptEntryId: input.firstKeptEntryId,
|
|
55
|
+
tokensBefore: input.tokensBefore,
|
|
56
|
+
estimatedTokensAfter: Math.max(summaryTokens, input.tokensBefore - spanTokens + summaryTokens),
|
|
57
|
+
usage: outcome.stats.jevUsage ? toUsage(outcome.stats.jevUsage) : undefined,
|
|
58
|
+
details: {
|
|
59
|
+
engine: 'jev',
|
|
60
|
+
stats: outcome.stats,
|
|
61
|
+
decisions: outcome.decisions.map(decision => ({
|
|
62
|
+
id: decision.id,
|
|
63
|
+
tool: decision.tool,
|
|
64
|
+
action: decision.action,
|
|
65
|
+
reason: decision.reason,
|
|
66
|
+
keepCall: decision.keepCall,
|
|
67
|
+
keepResult: decision.keepResult,
|
|
68
|
+
})),
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function raceAbort(promise, signal) {
|
|
74
|
+
if (!signal)
|
|
75
|
+
return promise;
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const onAbort = () => reject(new Error('aborted'));
|
|
78
|
+
if (signal.aborted) {
|
|
79
|
+
onAbort();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
83
|
+
promise.then(value => {
|
|
84
|
+
signal.removeEventListener('abort', onAbort);
|
|
85
|
+
resolve(value);
|
|
86
|
+
}, error => {
|
|
87
|
+
signal.removeEventListener('abort', onAbort);
|
|
88
|
+
reject(error);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function errorMessage(error) {
|
|
93
|
+
return error instanceof Error ? error.message : String(error);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The extension: on `session_before_compact`, replace pi's LLM-generated
|
|
97
|
+
* summary with a Jev-compacted verbatim transcript. Any failure, abort, or
|
|
98
|
+
* insufficient reduction falls back to pi's default compaction.
|
|
99
|
+
*/
|
|
100
|
+
export default function (pi) {
|
|
101
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
102
|
+
const config = loadConfig();
|
|
103
|
+
if (config.disabled)
|
|
104
|
+
return;
|
|
105
|
+
ctx.ui.notify(config.apiKey
|
|
106
|
+
? `jev-compaction active (Jev via ${config.provider}): stale tool outputs are dropped, not summarized`
|
|
107
|
+
: 'jev-compaction: set TYPESAFE_API_KEY or OPENROUTER_API_KEY (or JEVC_API_KEY/JEVC_PROVIDER) to enable; using default compaction', config.apiKey ? 'info' : 'warning');
|
|
108
|
+
});
|
|
109
|
+
pi.on('session_before_compact', async (event, ctx) => {
|
|
110
|
+
// Loaded per event so `/jev set` applies without a restart.
|
|
111
|
+
const config = loadConfig();
|
|
112
|
+
if (config.disabled || !config.apiKey)
|
|
113
|
+
return;
|
|
114
|
+
if (event.signal?.aborted)
|
|
115
|
+
return;
|
|
116
|
+
const { preparation, customInstructions } = event;
|
|
117
|
+
const asker = new JevClient({
|
|
118
|
+
apiKey: config.apiKey,
|
|
119
|
+
model: config.model,
|
|
120
|
+
baseUrl: config.baseUrl,
|
|
121
|
+
});
|
|
122
|
+
try {
|
|
123
|
+
const run = await raceAbort(runJevCompaction({
|
|
124
|
+
messagesToSummarize: preparation.messagesToSummarize,
|
|
125
|
+
turnPrefixMessages: preparation.turnPrefixMessages,
|
|
126
|
+
previousSummary: preparation.previousSummary,
|
|
127
|
+
customInstructions,
|
|
128
|
+
firstKeptEntryId: preparation.firstKeptEntryId,
|
|
129
|
+
tokensBefore: preparation.tokensBefore,
|
|
130
|
+
}, asker, config), event.signal);
|
|
131
|
+
if (!run.ok) {
|
|
132
|
+
if (run.reason === 'low-reduction') {
|
|
133
|
+
ctx.ui.notify(`jev-compaction: only ${(run.reduction * 100).toFixed(0)}% reduction; using default compaction`, 'warning');
|
|
134
|
+
}
|
|
135
|
+
return; // undefined result -> pi runs its default compaction
|
|
136
|
+
}
|
|
137
|
+
const stats = run.compaction.details.stats;
|
|
138
|
+
ctx.ui.notify(`jev-compaction: ${stats.calls - stats.pinned} calls scored — kept ${stats.kept - stats.pinned}, ` +
|
|
139
|
+
`truncated ${stats.resultsDropped}, dropped ${stats.callsDropped} ` +
|
|
140
|
+
`(${stats.requests} Jev req, ${(run.reduction * 100).toFixed(0)}% smaller, ${stats.ms} ms)`, 'info');
|
|
141
|
+
return { compaction: run.compaction };
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (event.signal?.aborted)
|
|
145
|
+
return;
|
|
146
|
+
ctx.ui.notify(`jev-compaction failed (${errorMessage(error)}); using default compaction`, 'error');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { applyJevDecisions, batchCalls, decideCall, questionsFor, } from './decision.js';
|
|
2
|
+
import { collectToolCalls, fitState, goalFromMessages, messageChars, resolveOptions, } from '../vendor/fast-jev-compaction/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Compacts the converted span with Jev: every non-pinned tool call gets two
|
|
5
|
+
* `noul` questions (keep the call, keep its result verbatim) plus one `score`
|
|
6
|
+
* question (result staleness) whose confident answer can rescue a borderline
|
|
7
|
+
* result. Batches run concurrently; the same fitted state is sent with each.
|
|
8
|
+
*/
|
|
9
|
+
export async function compactWithJev(messages, asker, config, goal) {
|
|
10
|
+
const started = Date.now();
|
|
11
|
+
const resolvedGoal = goal?.trim() || goalFromMessages(messages);
|
|
12
|
+
const resolved = resolveOptions({
|
|
13
|
+
goal: resolvedGoal,
|
|
14
|
+
keepThreshold: config.keepThreshold,
|
|
15
|
+
preserveRecentMessages: config.preserveRecentMessages,
|
|
16
|
+
maxStateTokens: config.maxStateTokens,
|
|
17
|
+
maxRequestTokens: config.maxRequestTokens,
|
|
18
|
+
truncateHeadChars: config.truncateHeadChars,
|
|
19
|
+
});
|
|
20
|
+
const calls = collectToolCalls(messages, resolved.preserveRecentMessages);
|
|
21
|
+
const candidates = calls.filter(call => !call.pinned);
|
|
22
|
+
const answers = {};
|
|
23
|
+
let stateTokens = 0;
|
|
24
|
+
let stateStage = '';
|
|
25
|
+
let batches = [];
|
|
26
|
+
let jevUsage;
|
|
27
|
+
if (candidates.length > 0) {
|
|
28
|
+
const fitted = fitState(messages, calls, resolved);
|
|
29
|
+
stateTokens = fitted.tokens;
|
|
30
|
+
stateStage = fitted.stage;
|
|
31
|
+
batches = batchCalls(candidates, stateTokens, config.maxRequestTokens);
|
|
32
|
+
const responses = await Promise.all(batches.map(async (batch) => {
|
|
33
|
+
const questions = Object.assign({}, ...batch.map(questionsFor));
|
|
34
|
+
return asker.ask(fitted.state, questions);
|
|
35
|
+
}));
|
|
36
|
+
for (const response of responses) {
|
|
37
|
+
Object.assign(answers, response.answers);
|
|
38
|
+
if (response.usage) {
|
|
39
|
+
const input = response.usage.input_tokens ?? 0;
|
|
40
|
+
const output = response.usage.output_tokens ?? 0;
|
|
41
|
+
jevUsage = jevUsage
|
|
42
|
+
? { input: jevUsage.input + input, output: jevUsage.output + output }
|
|
43
|
+
: { input, output };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const decisions = calls.map(call => decideCall(call, answers, config));
|
|
48
|
+
const kept = applyJevDecisions(messages, decisions, calls, resolved.truncateHeadChars);
|
|
49
|
+
const count = (predicate) => decisions.filter(predicate).length;
|
|
50
|
+
return {
|
|
51
|
+
messages: kept,
|
|
52
|
+
decisions,
|
|
53
|
+
stats: {
|
|
54
|
+
messagesBefore: messages.length,
|
|
55
|
+
messagesAfter: kept.length,
|
|
56
|
+
charsBefore: messages.reduce((sum, message) => sum + messageChars(message), 0),
|
|
57
|
+
charsAfter: kept.reduce((sum, message) => sum + messageChars(message), 0),
|
|
58
|
+
calls: calls.length,
|
|
59
|
+
kept: count(decision => decision.action === 'keep' && !decision.pinned),
|
|
60
|
+
resultsDropped: count(decision => decision.action === 'drop_result'),
|
|
61
|
+
callsDropped: count(decision => decision.action === 'drop_call'),
|
|
62
|
+
pinned: count(decision => decision.pinned),
|
|
63
|
+
guarded: count(decision => decision.guarded),
|
|
64
|
+
missing: count(decision => decision.missing),
|
|
65
|
+
stateTokens,
|
|
66
|
+
stateStage,
|
|
67
|
+
requests: batches.length,
|
|
68
|
+
ms: Date.now() - started,
|
|
69
|
+
jevUsage,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const ARG_VALUE_LIMIT = 160;
|
|
2
|
+
function argValue(value) {
|
|
3
|
+
let text;
|
|
4
|
+
if (typeof value === 'string')
|
|
5
|
+
text = value;
|
|
6
|
+
else {
|
|
7
|
+
try {
|
|
8
|
+
text = JSON.stringify(value) ?? String(value);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
text = '[unserializable]';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
15
|
+
return flat.length <= ARG_VALUE_LIMIT ? flat : `${flat.slice(0, ARG_VALUE_LIMIT - 1)}…`;
|
|
16
|
+
}
|
|
17
|
+
function renderCall(name, input) {
|
|
18
|
+
const args = Object.entries(input)
|
|
19
|
+
.map(([key, value]) => `${key}=${argValue(value)}`)
|
|
20
|
+
.join(' ');
|
|
21
|
+
return args.length > 0 ? `${name}(${args})` : name;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Renders messages as a flat transcript in the same style pi uses for
|
|
25
|
+
* summarization, so the LLM reads a familiar format. Applied to both the
|
|
26
|
+
* original and the compacted span, it doubles as the size estimator.
|
|
27
|
+
*/
|
|
28
|
+
export function renderTranscript(messages) {
|
|
29
|
+
const lines = [];
|
|
30
|
+
for (const message of messages) {
|
|
31
|
+
const trimmed = message.text.trim();
|
|
32
|
+
if (trimmed.length > 0) {
|
|
33
|
+
lines.push(`[${message.role === 'user' ? 'User' : 'Assistant'}]: ${trimmed}`);
|
|
34
|
+
}
|
|
35
|
+
if (message.toolUses.length > 0) {
|
|
36
|
+
lines.push(`[Assistant tool calls]: ${message.toolUses.map(call => renderCall(call.tool, call.input)).join('; ')}`);
|
|
37
|
+
}
|
|
38
|
+
for (const result of message.toolResults ?? []) {
|
|
39
|
+
lines.push(`[Tool result]: ${result.text.trim()}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return lines.join('\n\n');
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Builds the compaction summary: the previous summary (kept verbatim, it is
|
|
46
|
+
* already compact) followed by the Jev-retained transcript wrapped in a note
|
|
47
|
+
* explaining what the truncation markers mean.
|
|
48
|
+
*/
|
|
49
|
+
export function renderSummary(messages, options = {}) {
|
|
50
|
+
const parts = [];
|
|
51
|
+
const previous = options.previousSummary?.trim();
|
|
52
|
+
if (previous) {
|
|
53
|
+
parts.push(`<summary-of-earlier-context>\n${previous}\n</summary-of-earlier-context>`);
|
|
54
|
+
}
|
|
55
|
+
const header = [
|
|
56
|
+
'Earlier conversation retained by Jev selective compaction.',
|
|
57
|
+
'User and assistant text is verbatim.',
|
|
58
|
+
];
|
|
59
|
+
if ((options.droppedCalls ?? 0) > 0)
|
|
60
|
+
header.push(`${options.droppedCalls} obsolete tool calls were removed.`);
|
|
61
|
+
if ((options.truncatedResults ?? 0) > 0) {
|
|
62
|
+
header.push(`${options.truncatedResults} tool results were truncated (marked); re-run a tool if its full output is needed again.`);
|
|
63
|
+
}
|
|
64
|
+
if (options.goal?.trim())
|
|
65
|
+
header.push(`Ongoing goal: ${options.goal.trim()}`);
|
|
66
|
+
parts.push(`<compacted-conversation>\n${header.join(' ')}\n\n${renderTranscript(messages)}\n</compacted-conversation>`);
|
|
67
|
+
return parts.join('\n\n');
|
|
68
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export const DIFFICULTY_LEVELS = ['trivial', 'moderate', 'complex'];
|
|
2
|
+
export const ROUTING_CONTEXT = '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.';
|
|
3
|
+
export function routingQuestions() {
|
|
4
|
+
return {
|
|
5
|
+
difficulty: {
|
|
6
|
+
type: 'score',
|
|
7
|
+
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)`,
|
|
8
|
+
criteria: [...DIFFICULTY_LEVELS],
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function scoreFrom(answers) {
|
|
13
|
+
const answer = answers.difficulty;
|
|
14
|
+
if (answer === null ||
|
|
15
|
+
typeof answer !== 'object' ||
|
|
16
|
+
!('score' in answer) ||
|
|
17
|
+
typeof answer.score !== 'number' ||
|
|
18
|
+
!Number.isFinite(answer.score)) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
const confidence = 'confidence' in answer && typeof answer.confidence === 'number'
|
|
22
|
+
? answer.confidence
|
|
23
|
+
: 0;
|
|
24
|
+
return { score: answer.score, confidence };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Maps a Jev score to the 0..N level space (N = levels - 1), clamped. Jev returns either a 0..1
|
|
28
|
+
* continuous score or a level index; `score <= 1` is read as normalized
|
|
29
|
+
* (a literal 1 therefore means "hardest", the conservative direction).
|
|
30
|
+
*/
|
|
31
|
+
export function toLevels(score) {
|
|
32
|
+
const span = DIFFICULTY_LEVELS.length - 1;
|
|
33
|
+
return Math.min(span, Math.max(0, (score <= 1 ? score : score / span) * span));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Pure decision: easy requests go to the cheap model, hard ones to the strong
|
|
37
|
+
* model, everything else (middle band, low confidence, missing or malformed
|
|
38
|
+
* answer) keeps the current model.
|
|
39
|
+
*/
|
|
40
|
+
export function decideRouting(answers, config) {
|
|
41
|
+
const answer = scoreFrom(answers);
|
|
42
|
+
if (!answer) {
|
|
43
|
+
return { target: null, score: 0, levels: 0, confidence: 0, reason: 'missing-answer' };
|
|
44
|
+
}
|
|
45
|
+
const levels = toLevels(answer.score);
|
|
46
|
+
const base = { score: answer.score, levels, confidence: answer.confidence };
|
|
47
|
+
if (answer.confidence < config.minConfidence) {
|
|
48
|
+
return { ...base, target: null, reason: 'low-confidence' };
|
|
49
|
+
}
|
|
50
|
+
if (config.cheap && levels <= config.easyMax) {
|
|
51
|
+
return { ...base, target: 'cheap', reason: 'easy' };
|
|
52
|
+
}
|
|
53
|
+
if (config.strong && levels >= config.hardMin) {
|
|
54
|
+
return { ...base, target: 'strong', reason: 'hard' };
|
|
55
|
+
}
|
|
56
|
+
return { ...base, target: null, reason: 'middle' };
|
|
57
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { JevClient } from '../vendor/fast-jev-compaction/index.js';
|
|
2
|
+
import { loadConfig } from '../shared/config.js';
|
|
3
|
+
import { decideRouting, DIFFICULTY_LEVELS, ROUTING_CONTEXT, routingQuestions } from './decide.js';
|
|
4
|
+
/** Asks Jev to rate the request difficulty and maps it to a routing target. */
|
|
5
|
+
export async function runRouting(prompt, asker, config) {
|
|
6
|
+
const response = await asker.ask({ context: ROUTING_CONTEXT, prompt }, routingQuestions());
|
|
7
|
+
return decideRouting(response.answers, config);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Parses a `"provider/model-id"` reference, optionally with a `:thinking`
|
|
11
|
+
* suffix (pi style, e.g. `deepseek/deepseek-flash:high`).
|
|
12
|
+
*/
|
|
13
|
+
export function parseModelRef(ref) {
|
|
14
|
+
const slash = ref.indexOf('/');
|
|
15
|
+
if (slash <= 0 || slash === ref.length - 1)
|
|
16
|
+
return undefined;
|
|
17
|
+
const rest = ref.slice(slash + 1);
|
|
18
|
+
const colon = rest.lastIndexOf(':');
|
|
19
|
+
if (colon > 0) {
|
|
20
|
+
const thinking = rest.slice(colon + 1);
|
|
21
|
+
if (thinking)
|
|
22
|
+
return { provider: ref.slice(0, slash), id: rest.slice(0, colon), thinking };
|
|
23
|
+
}
|
|
24
|
+
return { provider: ref.slice(0, slash), id: rest };
|
|
25
|
+
}
|
|
26
|
+
function errorMessage(error) {
|
|
27
|
+
return error instanceof Error ? error.message : String(error);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The routing extension: before each agent turn, Jev rates the request
|
|
31
|
+
* difficulty; confidently easy requests switch to `JEVC_ROUTE_CHEAP`,
|
|
32
|
+
* confidently hard ones to `JEVC_ROUTE_STRONG`. Every other outcome (middle
|
|
33
|
+
* band, low confidence, Jev failure, model not found, auth missing) keeps the
|
|
34
|
+
* current model. Prompts with images never downgrade to a text-only model.
|
|
35
|
+
*/
|
|
36
|
+
export default function (pi) {
|
|
37
|
+
pi.on('before_agent_start', async (event, ctx) => {
|
|
38
|
+
// Loaded per turn so `/jev set` applies without a restart.
|
|
39
|
+
const config = loadConfig();
|
|
40
|
+
const routing = config.routing;
|
|
41
|
+
if (config.disabled || !config.apiKey)
|
|
42
|
+
return;
|
|
43
|
+
if (!routing.cheap && !routing.strong)
|
|
44
|
+
return;
|
|
45
|
+
if (!event.prompt.trim())
|
|
46
|
+
return;
|
|
47
|
+
try {
|
|
48
|
+
const decision = await runRouting(event.prompt, new JevClient({ apiKey: config.apiKey, model: config.model, baseUrl: config.baseUrl }), routing);
|
|
49
|
+
if (!decision.target)
|
|
50
|
+
return;
|
|
51
|
+
const ref = parseModelRef(decision.target === 'cheap' ? routing.cheap : routing.strong);
|
|
52
|
+
if (!ref) {
|
|
53
|
+
ctx.ui.notify(`jev-routing: invalid JEVC_ROUTE_${decision.target.toUpperCase()} reference`, 'warning');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const model = ctx.modelRegistry.find(ref.provider, ref.id);
|
|
57
|
+
if (!model) {
|
|
58
|
+
ctx.ui.notify(`jev-routing: ${ref.provider}/${ref.id} not found; keeping current model`, 'warning');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (ctx.model && ctx.model.id === model.id && ctx.model.provider === model.provider)
|
|
62
|
+
return;
|
|
63
|
+
if (decision.target === 'cheap' && (event.images?.length ?? 0) > 0 && !model.input.includes('image')) {
|
|
64
|
+
return; // never route an image prompt to a text-only model
|
|
65
|
+
}
|
|
66
|
+
const switched = await pi.setModel(model);
|
|
67
|
+
if (!switched) {
|
|
68
|
+
ctx.ui.notify(`jev-routing: auth not configured for ${ref.provider}/${ref.id}; keeping current model`, 'warning');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (ref.thinking) {
|
|
72
|
+
pi.setThinkingLevel(ref.thinking);
|
|
73
|
+
}
|
|
74
|
+
ctx.ui.notify(`jev-routing: ${decision.reason} request (difficulty ${decision.levels.toFixed(1)}/${DIFFICULTY_LEVELS.length - 1}, ` +
|
|
75
|
+
`confidence ${(decision.confidence * 100).toFixed(0)}%) → ${ref.provider}/${ref.id}`, 'info');
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
ctx.ui.notify(`jev-routing failed (${errorMessage(error)}); keeping current model`, 'error');
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { DEFAULT_MODEL, SYSTEM_ONE_URL } from '../vendor/fast-jev-compaction/index.js';
|
|
5
|
+
/** OpenRouter Decisions API (alpha). */
|
|
6
|
+
export const OPENROUTER_DECISIONS_URL = 'https://openrouter.ai/api/alpha/decisions';
|
|
7
|
+
/** Default Jev model slug on OpenRouter (versioned; override with `JEVC_MODEL`). */
|
|
8
|
+
export const OPENROUTER_JEV_MODEL = 'typesafe/jev-1.13';
|
|
9
|
+
const DEFAULTS = {
|
|
10
|
+
keepThreshold: 0.5,
|
|
11
|
+
borderline: 0.1,
|
|
12
|
+
/** Small: pi already excludes the newest ~20k tokens from the span. */
|
|
13
|
+
preserveRecentMessages: 3,
|
|
14
|
+
truncateHeadChars: 300,
|
|
15
|
+
minReduction: 0.15,
|
|
16
|
+
maxStateTokens: 25_000,
|
|
17
|
+
maxRequestTokens: 30_000,
|
|
18
|
+
easyMax: 0.5,
|
|
19
|
+
hardMin: 1.5,
|
|
20
|
+
minConfidence: 0.6,
|
|
21
|
+
};
|
|
22
|
+
function number(env, key, fallback) {
|
|
23
|
+
const raw = env[key];
|
|
24
|
+
if (raw === undefined || raw.trim() === '')
|
|
25
|
+
return fallback;
|
|
26
|
+
const value = Number(raw);
|
|
27
|
+
return Number.isFinite(value) ? value : fallback;
|
|
28
|
+
}
|
|
29
|
+
function flag(env, key) {
|
|
30
|
+
return /^(1|true|yes)$/i.test(env[key] ?? '');
|
|
31
|
+
}
|
|
32
|
+
/** Boolean with source precedence: an explicitly set env value (even `0`) wins over the file. */
|
|
33
|
+
function layeredFlag(env, key, fileValue) {
|
|
34
|
+
const raw = env[key]?.trim();
|
|
35
|
+
if (raw)
|
|
36
|
+
return /^(1|true|yes)$/i.test(raw);
|
|
37
|
+
return fileValue ?? false;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Picks the transport: explicit `JEVC_PROVIDER` wins; otherwise TypeSafe when
|
|
41
|
+
* its key is present, OpenRouter when only `OPENROUTER_API_KEY` is present,
|
|
42
|
+
* and `typesafe` otherwise. `JEVC_API_KEY` alone does not influence the
|
|
43
|
+
* choice (it could belong to either transport); set `JEVC_PROVIDER` to use it
|
|
44
|
+
* with OpenRouter.
|
|
45
|
+
*/
|
|
46
|
+
function resolveProvider(env) {
|
|
47
|
+
const explicit = env.JEVC_PROVIDER?.trim().toLowerCase();
|
|
48
|
+
if (explicit === 'typesafe' || explicit === 'openrouter')
|
|
49
|
+
return explicit;
|
|
50
|
+
if ((env.TYPESAFE_API_KEY ?? '').trim())
|
|
51
|
+
return 'typesafe';
|
|
52
|
+
if ((env.OPENROUTER_API_KEY ?? '').trim())
|
|
53
|
+
return 'openrouter';
|
|
54
|
+
return 'typesafe';
|
|
55
|
+
}
|
|
56
|
+
function keyFor(provider, env) {
|
|
57
|
+
const own = provider === 'openrouter' ? env.OPENROUTER_API_KEY : env.TYPESAFE_API_KEY;
|
|
58
|
+
return env.JEVC_API_KEY?.trim() || own?.trim() || '';
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* All user-facing keys. `apiKey` is deliberately absent (env-only), and the
|
|
62
|
+
* routing thresholds (easyMax/hardMin/minConfidence) stay internal defaults —
|
|
63
|
+
* still tunable through `JEVC_ROUTE_*` env vars, but not exposed here.
|
|
64
|
+
*/
|
|
65
|
+
export const CONFIG_KEYS = [
|
|
66
|
+
{ path: 'provider', type: 'string', env: 'JEVC_PROVIDER', description: 'Jev transport: typesafe or openrouter' },
|
|
67
|
+
{ path: 'model', type: 'string', env: 'JEVC_MODEL', description: 'Jev model slug (e.g. typesafe/jev-1.13 on OpenRouter)' },
|
|
68
|
+
{ path: 'baseUrl', type: 'string', env: 'JEVC_BASE_URL', description: 'Jev endpoint URL' },
|
|
69
|
+
{ path: 'disabled', type: 'boolean', env: 'JEVC_DISABLED', default: false, description: 'Bypass all pi-jev hooks' },
|
|
70
|
+
{ path: 'routing.cheap', type: 'string', env: 'JEVC_ROUTE_CHEAP', description: '"provider/model-id" for easy requests (enables routing)' },
|
|
71
|
+
{ path: 'routing.strong', type: 'string', env: 'JEVC_ROUTE_STRONG', description: '"provider/model-id" for hard requests (optional)' },
|
|
72
|
+
{ path: 'compaction.keepThreshold', type: 'number', env: 'JEVC_KEEP_THRESHOLD', default: DEFAULTS.keepThreshold, description: 'Minimum keep probability for verbatim retention' },
|
|
73
|
+
{ path: 'compaction.borderline', type: 'number', env: 'JEVC_BORDERLINE', default: DEFAULTS.borderline, description: 'Band where a confident low-staleness score still keeps a result' },
|
|
74
|
+
{ path: 'compaction.preserveRecentMessages', type: 'number', env: 'JEVC_PRESERVE_RECENT', default: DEFAULTS.preserveRecentMessages, description: 'Newest messages within the summarized span that are never touched' },
|
|
75
|
+
{ path: 'compaction.truncateHeadChars', type: 'number', env: 'JEVC_TRUNCATE_HEAD', default: DEFAULTS.truncateHeadChars, description: 'Characters of a dropped tool result retained before its note' },
|
|
76
|
+
{ path: 'compaction.minReduction', type: 'number', env: 'JEVC_MIN_REDUCTION', default: DEFAULTS.minReduction, description: 'Minimum estimated span reduction, else default compaction' },
|
|
77
|
+
{ path: 'compaction.maxStateTokens', type: 'number', env: 'JEVC_MAX_STATE_TOKENS', default: DEFAULTS.maxStateTokens, description: 'Estimated token ceiling for the Jev state' },
|
|
78
|
+
{ path: 'compaction.maxRequestTokens', type: 'number', env: 'JEVC_MAX_REQUEST_TOKENS', default: DEFAULTS.maxRequestTokens, description: 'Estimated ceiling for state plus one batch of questions' },
|
|
79
|
+
];
|
|
80
|
+
/** Default config file locations, mirroring pi's own settings layout. */
|
|
81
|
+
export function defaultConfigPaths() {
|
|
82
|
+
return {
|
|
83
|
+
globalPath: join(homedir(), '.pi', 'agent', 'jev.json'),
|
|
84
|
+
projectPath: join(process.cwd(), '.pi', 'jev.json'),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Reads and parses one config file. Throws on missing or malformed JSON. */
|
|
88
|
+
export function readConfigFile(path) {
|
|
89
|
+
const raw = readFileSync(path, 'utf8');
|
|
90
|
+
const parsed = JSON.parse(raw);
|
|
91
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
92
|
+
throw new Error(`config at ${path} must be a JSON object`);
|
|
93
|
+
}
|
|
94
|
+
return parsed;
|
|
95
|
+
}
|
|
96
|
+
function readTolerant(path, scope, warn) {
|
|
97
|
+
try {
|
|
98
|
+
return readConfigFile(path);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error.code === 'ENOENT')
|
|
102
|
+
return undefined;
|
|
103
|
+
warn(`pi-jev: ignoring ${scope} config at ${path} (${error.message})`);
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export function mergeConfigFiles(global, project) {
|
|
108
|
+
if (!global)
|
|
109
|
+
return project;
|
|
110
|
+
if (!project)
|
|
111
|
+
return global;
|
|
112
|
+
return {
|
|
113
|
+
...global,
|
|
114
|
+
...project,
|
|
115
|
+
routing: { ...global.routing, ...project.routing },
|
|
116
|
+
compaction: { ...global.compaction, ...project.compaction },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Resolves the full config: env > project file > global file > defaults. */
|
|
120
|
+
export function configFromEnv(env = process.env, file) {
|
|
121
|
+
const explicit = env.JEVC_PROVIDER?.trim().toLowerCase();
|
|
122
|
+
const provider = explicit === 'typesafe' || explicit === 'openrouter'
|
|
123
|
+
? explicit
|
|
124
|
+
: file?.provider === 'typesafe' || file?.provider === 'openrouter'
|
|
125
|
+
? file.provider
|
|
126
|
+
: resolveProvider(env);
|
|
127
|
+
return {
|
|
128
|
+
provider,
|
|
129
|
+
apiKey: keyFor(provider, env),
|
|
130
|
+
model: env.JEVC_MODEL?.trim() || file?.model?.trim()
|
|
131
|
+
|| (provider === 'openrouter' ? OPENROUTER_JEV_MODEL : DEFAULT_MODEL),
|
|
132
|
+
baseUrl: env.JEVC_BASE_URL?.trim() || file?.baseUrl?.trim()
|
|
133
|
+
|| (provider === 'openrouter' ? OPENROUTER_DECISIONS_URL : SYSTEM_ONE_URL),
|
|
134
|
+
keepThreshold: number(env, 'JEVC_KEEP_THRESHOLD', file?.compaction?.keepThreshold ?? DEFAULTS.keepThreshold),
|
|
135
|
+
borderline: number(env, 'JEVC_BORDERLINE', file?.compaction?.borderline ?? DEFAULTS.borderline),
|
|
136
|
+
preserveRecentMessages: Math.max(0, Math.floor(number(env, 'JEVC_PRESERVE_RECENT', file?.compaction?.preserveRecentMessages ?? DEFAULTS.preserveRecentMessages))),
|
|
137
|
+
truncateHeadChars: Math.max(0, Math.floor(number(env, 'JEVC_TRUNCATE_HEAD', file?.compaction?.truncateHeadChars ?? DEFAULTS.truncateHeadChars))),
|
|
138
|
+
minReduction: number(env, 'JEVC_MIN_REDUCTION', file?.compaction?.minReduction ?? DEFAULTS.minReduction),
|
|
139
|
+
maxStateTokens: Math.max(1, number(env, 'JEVC_MAX_STATE_TOKENS', file?.compaction?.maxStateTokens ?? DEFAULTS.maxStateTokens)),
|
|
140
|
+
maxRequestTokens: Math.max(1, number(env, 'JEVC_MAX_REQUEST_TOKENS', file?.compaction?.maxRequestTokens ?? DEFAULTS.maxRequestTokens)),
|
|
141
|
+
disabled: layeredFlag(env, 'JEVC_DISABLED', file?.disabled),
|
|
142
|
+
routing: {
|
|
143
|
+
cheap: env.JEVC_ROUTE_CHEAP?.trim() || file?.routing?.cheap?.trim() || undefined,
|
|
144
|
+
strong: env.JEVC_ROUTE_STRONG?.trim() || file?.routing?.strong?.trim() || undefined,
|
|
145
|
+
easyMax: number(env, 'JEVC_ROUTE_EASY_MAX', file?.routing?.easyMax ?? DEFAULTS.easyMax),
|
|
146
|
+
hardMin: number(env, 'JEVC_ROUTE_HARD_MIN', file?.routing?.hardMin ?? DEFAULTS.hardMin),
|
|
147
|
+
minConfidence: number(env, 'JEVC_ROUTE_MIN_CONFIDENCE', file?.routing?.minConfidence ?? DEFAULTS.minConfidence),
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** Loads config files and env into a full `JevConfig`. Malformed files warn and are skipped. */
|
|
152
|
+
export function loadConfig(opts = {}) {
|
|
153
|
+
const paths = { ...defaultConfigPaths(), ...opts };
|
|
154
|
+
const warn = opts.warn ?? ((message) => console.warn(message));
|
|
155
|
+
const file = mergeConfigFiles(readTolerant(paths.globalPath, 'global', warn), readTolerant(paths.projectPath, 'project', warn));
|
|
156
|
+
return configFromEnv(opts.env ?? process.env, file);
|
|
157
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { buildJevRequest, parseJevResponse } from './request.js';
|
|
2
|
+
/** Asks Jev over HTTP with the global `fetch` (or an injected one). */
|
|
3
|
+
export class JevClient {
|
|
4
|
+
apiKey;
|
|
5
|
+
model;
|
|
6
|
+
baseUrl;
|
|
7
|
+
fetcher;
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
this.apiKey = options.apiKey ?? process.env.TYPESAFE_API_KEY ?? '';
|
|
10
|
+
this.model = options.model;
|
|
11
|
+
this.baseUrl = options.baseUrl;
|
|
12
|
+
this.fetcher = options.fetch ?? fetch;
|
|
13
|
+
}
|
|
14
|
+
async ask(state, questions) {
|
|
15
|
+
if (!this.apiKey)
|
|
16
|
+
throw new Error('TYPESAFE_API_KEY is not configured');
|
|
17
|
+
const request = buildJevRequest({ apiKey: this.apiKey, model: this.model, baseUrl: this.baseUrl }, state, questions);
|
|
18
|
+
const response = await this.fetcher(request.url, {
|
|
19
|
+
method: request.method,
|
|
20
|
+
headers: request.headers,
|
|
21
|
+
body: request.body,
|
|
22
|
+
});
|
|
23
|
+
return parseJevResponse(response.status, response.ok, await response.text());
|
|
24
|
+
}
|
|
25
|
+
}
|