@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,233 @@
1
+ import { noulAnswer } from './request.js';
2
+ import { collectToolCalls, estimateTokens, fitState } from './state.js';
3
+ export const DEFAULT_OPTIONS = {
4
+ goal: '',
5
+ keepThreshold: 0.5,
6
+ preserveRecentMessages: 6,
7
+ maxStateTokens: 25_000,
8
+ maxRequestTokens: 30_000,
9
+ truncateHeadChars: 300,
10
+ };
11
+ /** Tokens the request envelope (`model`, key names) adds around state and questions. */
12
+ const REQUEST_OVERHEAD_TOKENS = 20;
13
+ function finite(value, fallback) {
14
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
15
+ }
16
+ export function resolveOptions(options = {}) {
17
+ return {
18
+ goal: options.goal ?? DEFAULT_OPTIONS.goal,
19
+ keepThreshold: finite(options.keepThreshold, DEFAULT_OPTIONS.keepThreshold),
20
+ preserveRecentMessages: Math.max(0, Math.floor(finite(options.preserveRecentMessages, DEFAULT_OPTIONS.preserveRecentMessages))),
21
+ maxStateTokens: Math.max(1, finite(options.maxStateTokens, DEFAULT_OPTIONS.maxStateTokens)),
22
+ maxRequestTokens: Math.max(1, finite(options.maxRequestTokens, DEFAULT_OPTIONS.maxRequestTokens)),
23
+ truncateHeadChars: Math.max(0, Math.floor(finite(options.truncateHeadChars, DEFAULT_OPTIONS.truncateHeadChars))),
24
+ };
25
+ }
26
+ /** The two `noul` questions asked about one call: keep the call, keep its result. */
27
+ export function questionsFor(call) {
28
+ return {
29
+ [`call_${call.id}`]: {
30
+ type: 'noul',
31
+ instructions: `Tool call ${call.id} (${call.tool}) should stay in the history: knowing this call was made, with its input, still matters for what the assistant does next`,
32
+ },
33
+ [`result_${call.id}`]: {
34
+ type: 'noul',
35
+ instructions: `The full output of tool call ${call.id} (${call.tool}, ${call.resultChars} chars) should stay in the history verbatim: the assistant still needs its contents and re-running the tool would not do`,
36
+ },
37
+ };
38
+ }
39
+ /**
40
+ * Splits the candidate calls into batches whose questions, together with the
41
+ * (always complete) state, fit one request.
42
+ */
43
+ export function batchCalls(calls, stateTokens, options) {
44
+ const budget = options.maxRequestTokens - stateTokens - REQUEST_OVERHEAD_TOKENS;
45
+ const batches = [];
46
+ let current = [];
47
+ let currentTokens = 0;
48
+ for (const call of calls) {
49
+ const tokens = estimateTokens(JSON.stringify(questionsFor(call)));
50
+ if (current.length > 0 && currentTokens + tokens > budget) {
51
+ batches.push(current);
52
+ current = [];
53
+ currentTokens = 0;
54
+ }
55
+ if (current.length === 0 && tokens > budget) {
56
+ throw new Error(`state leaves no room for questions (~${stateTokens} of ${options.maxRequestTokens} tokens)`);
57
+ }
58
+ current.push(call);
59
+ currentTokens += tokens;
60
+ }
61
+ if (current.length > 0)
62
+ batches.push(current);
63
+ return batches;
64
+ }
65
+ export function decideCall(call, answer, options) {
66
+ const base = { id: call.id, tool: call.tool, ...answer };
67
+ if (call.pinned)
68
+ return { ...base, action: 'keep', reason: 'pinned' };
69
+ if (answer.keepResult >= options.keepThreshold) {
70
+ return { ...base, action: 'keep', reason: 'kept' };
71
+ }
72
+ if (answer.keepCall >= options.keepThreshold) {
73
+ return { ...base, action: 'drop_result', reason: 'result_dropped' };
74
+ }
75
+ return { ...base, action: 'drop_call', reason: 'call_dropped' };
76
+ }
77
+ async function askBatch(asker, state, batch) {
78
+ const questions = Object.assign({}, ...batch.map(questionsFor));
79
+ const { answers } = await asker.ask(state, questions);
80
+ return new Map(batch.map((call) => [
81
+ call.id,
82
+ {
83
+ keepCall: noulAnswer(answers, `call_${call.id}`),
84
+ keepResult: noulAnswer(answers, `result_${call.id}`),
85
+ },
86
+ ]));
87
+ }
88
+ function truncatedResultText(text, isError, headChars) {
89
+ if (text.length <= headChars + 120)
90
+ return text;
91
+ const head = headChars > 0 ? `${text.slice(0, headChars)}\n` : '';
92
+ return `${head}[fast-jev-compaction truncated ${text.length - headChars} chars of this tool result${isError ? ' (error)' : ''}; re-run the tool if needed]`;
93
+ }
94
+ /**
95
+ * Rebuilds the conversation from the decisions. A dropped call disappears
96
+ * together with its result; a dropped result keeps a bounded head and note.
97
+ * Messages that lose all their content are removed; untouched messages are
98
+ * returned as the same objects they came in as.
99
+ */
100
+ export function applyDecisions(messages, decisions, calls, headChars) {
101
+ const byId = new Map(calls.map((call) => [call.id, call]));
102
+ const actions = new Map();
103
+ for (const decision of decisions) {
104
+ const call = byId.get(decision.id);
105
+ if (call && decision.action !== 'keep')
106
+ actions.set(call.tool_use_id, decision.action);
107
+ }
108
+ const kept = [];
109
+ for (const message of messages) {
110
+ const touched = message.toolUses.some((tool) => actions.has(tool.tool_use_id)) ||
111
+ (message.toolResults ?? []).some((result) => actions.has(result.tool_use_id));
112
+ if (!touched) {
113
+ kept.push(message);
114
+ continue;
115
+ }
116
+ const toolUses = message.toolUses
117
+ .filter((tool) => actions.get(tool.tool_use_id) !== 'drop_call')
118
+ .map((tool) => {
119
+ if (actions.get(tool.tool_use_id) !== 'drop_result')
120
+ return tool;
121
+ const text = truncatedResultText(tool.text ?? '', tool.isError ?? false, headChars);
122
+ if ((tool.text ?? '') === text)
123
+ return tool;
124
+ const copy = {
125
+ tool_use_id: tool.tool_use_id,
126
+ tool: tool.tool,
127
+ input: tool.input,
128
+ text,
129
+ };
130
+ if (tool.isError)
131
+ copy.isError = true;
132
+ return copy;
133
+ });
134
+ const toolResults = (message.toolResults ?? [])
135
+ .filter((result) => actions.get(result.tool_use_id) !== 'drop_call')
136
+ .map((result) => {
137
+ if (actions.get(result.tool_use_id) !== 'drop_result')
138
+ return result;
139
+ const text = truncatedResultText(result.text, result.isError ?? false, headChars);
140
+ return text === result.text
141
+ ? result
142
+ : {
143
+ tool_use_id: result.tool_use_id,
144
+ text,
145
+ isError: result.isError,
146
+ };
147
+ });
148
+ if (!message.toolUses.some((tool) => actions.get(tool.tool_use_id) === 'drop_call') &&
149
+ !(message.toolResults ?? []).some((result) => actions.get(result.tool_use_id) === 'drop_call') &&
150
+ toolUses.every((tool, index) => tool === message.toolUses[index]) &&
151
+ toolResults.every((result, index) => result === message.toolResults?.[index])) {
152
+ kept.push(message);
153
+ continue;
154
+ }
155
+ if (message.text.trim().length === 0 && toolUses.length === 0 && toolResults.length === 0) {
156
+ continue;
157
+ }
158
+ const rebuilt = { role: message.role, text: message.text, toolUses };
159
+ if (toolResults.length > 0)
160
+ rebuilt.toolResults = toolResults;
161
+ kept.push(rebuilt);
162
+ }
163
+ return kept;
164
+ }
165
+ /** Characters of text, tool input and tool output a message holds. */
166
+ export function messageChars(message) {
167
+ let total = message.text.length;
168
+ for (const tool of message.toolUses) {
169
+ try {
170
+ total += JSON.stringify(tool.input).length;
171
+ }
172
+ catch {
173
+ total += 20;
174
+ }
175
+ }
176
+ for (const result of message.toolResults ?? [])
177
+ total += result.text.length;
178
+ return total;
179
+ }
180
+ export function reductionRatio(result) {
181
+ const { charsBefore, charsAfter } = result.stats;
182
+ return charsBefore === 0 ? 0 : (charsBefore - charsAfter) / charsBefore;
183
+ }
184
+ function count(decisions, reason) {
185
+ return decisions.filter((decision) => decision.reason === reason).length;
186
+ }
187
+ /**
188
+ * Compacts a transcript by asking Jev, for every tool call outside the pinned
189
+ * first and newest messages, whether the call and whether its result must
190
+ * stay. The whole history (results omitted, fitted into `maxStateTokens`) is
191
+ * sent as state with every batch of questions. Throws when Jev fails or the
192
+ * history cannot be fitted; the caller decides whether to fall back.
193
+ */
194
+ export async function compact(messages, asker, options = {}) {
195
+ const started = Date.now();
196
+ const resolved = resolveOptions(options);
197
+ const calls = collectToolCalls(messages, resolved.preserveRecentMessages);
198
+ const candidates = calls.filter((call) => !call.pinned);
199
+ const charsBefore = messages.reduce((sum, message) => sum + messageChars(message), 0);
200
+ let fitted = { tokens: 0, stage: '' };
201
+ let batches = [];
202
+ const answers = new Map();
203
+ if (candidates.length > 0) {
204
+ const state = fitState(messages, calls, resolved);
205
+ fitted = state;
206
+ batches = batchCalls(candidates, state.tokens, resolved);
207
+ const answered = await Promise.all(batches.map((batch) => askBatch(asker, state.state, batch)));
208
+ for (const map of answered)
209
+ for (const [id, answer] of map)
210
+ answers.set(id, answer);
211
+ }
212
+ const decisions = calls.map((call) => decideCall(call, answers.get(call.id) ?? { keepCall: 1, keepResult: 1 }, resolved));
213
+ const kept = applyDecisions(messages, decisions, calls, resolved.truncateHeadChars);
214
+ return {
215
+ messages: kept,
216
+ decisions,
217
+ stats: {
218
+ messagesBefore: messages.length,
219
+ messagesAfter: kept.length,
220
+ charsBefore,
221
+ charsAfter: kept.reduce((sum, message) => sum + messageChars(message), 0),
222
+ calls: calls.length,
223
+ kept: count(decisions, 'kept'),
224
+ resultsDropped: count(decisions, 'result_dropped'),
225
+ callsDropped: count(decisions, 'call_dropped'),
226
+ pinned: count(decisions, 'pinned'),
227
+ stateTokens: fitted.tokens,
228
+ stateStage: fitted.stage,
229
+ requests: batches.length,
230
+ ms: Date.now() - started,
231
+ },
232
+ };
233
+ }
@@ -0,0 +1,7 @@
1
+ // Vendored subset of https://github.com/tamaratran/fast-jev-compaction (MIT).
2
+ // See LICENSE in this directory. Kept verbatim; upgrade by re-copying from upstream.
3
+ export * from './types.js';
4
+ export * from './request.js';
5
+ export * from './client.js';
6
+ export * from './state.js';
7
+ export * from './compact.js';
@@ -0,0 +1,50 @@
1
+ export const SYSTEM_ONE_URL = 'https://api.typesafe.ai/v1/systemone';
2
+ export const DEFAULT_MODEL = 'jev-latest';
3
+ /** The HTTP request for one Jev call, for any fetch-like transport. */
4
+ export function buildJevRequest(params, state, questions) {
5
+ return {
6
+ url: params.baseUrl ?? SYSTEM_ONE_URL,
7
+ method: 'POST',
8
+ headers: {
9
+ authorization: `Bearer ${params.apiKey}`,
10
+ 'content-type': 'application/json',
11
+ },
12
+ body: JSON.stringify({
13
+ model: params.model ?? DEFAULT_MODEL,
14
+ state,
15
+ questions,
16
+ }),
17
+ };
18
+ }
19
+ /** Validates a Jev response body; throws on anything but an `answers` object. */
20
+ export function parseJevResponse(status, ok, text) {
21
+ if (!ok) {
22
+ throw new Error(`Jev request failed (${status}): ${text.slice(0, 200)}`);
23
+ }
24
+ let parsed;
25
+ try {
26
+ parsed = JSON.parse(text);
27
+ }
28
+ catch {
29
+ throw new Error('Jev returned malformed JSON');
30
+ }
31
+ if (parsed === null ||
32
+ typeof parsed !== 'object' ||
33
+ !('answers' in parsed) ||
34
+ parsed.answers === null ||
35
+ typeof parsed.answers !== 'object') {
36
+ throw new Error('Jev response is missing answers');
37
+ }
38
+ return parsed;
39
+ }
40
+ /** The `noul` probability of one answer; throws when it is not there. */
41
+ export function noulAnswer(answers, name) {
42
+ const answer = answers[name];
43
+ if (!answer ||
44
+ !('noul' in answer) ||
45
+ typeof answer.noul !== 'number' ||
46
+ !Number.isFinite(answer.noul)) {
47
+ throw new Error(`Invalid Jev answer for ${name}`);
48
+ }
49
+ return answer.noul;
50
+ }
@@ -0,0 +1,255 @@
1
+ export const STATE_CONTEXT = 'A coding assistant conversation is being compacted to free context. `history` is the whole conversation so far, oldest first; tool outputs are replaced by a short `result` note and long texts may be abridged. Each question asks whether one tool call, or the full output of that call, still needs to stay in the history verbatim. Whatever is not kept is deleted permanently, but the assistant can always re-run a tool or re-read a file.';
2
+ /** Successive caps on the serialised tool input included per call. */
3
+ const INPUT_CHARS = [1000, 200, 60];
4
+ const TEXT_HEAD = 400;
5
+ const TEXT_TAIL = 150;
6
+ const TOKEN_PIECES = /[A-Za-z]+|\d+|[^\sA-Za-z\d]/g;
7
+ /**
8
+ * Estimates tokens without a tokenizer: a word costs one token per six
9
+ * letters, a digit half a token, any other symbol nine tenths. Calibrated
10
+ * against the usage Jev reports for real transcripts, where it lands 2–18%
11
+ * above the true count; a plain characters-per-token ratio undercounts the
12
+ * JSON-heavy states by up to 40%.
13
+ */
14
+ export function estimateTokens(text) {
15
+ let tokens = 0;
16
+ for (const [piece] of text.matchAll(TOKEN_PIECES)) {
17
+ const first = piece.charCodeAt(0);
18
+ if (first >= 48 && first <= 57)
19
+ tokens += piece.length / 2;
20
+ else if ((first >= 65 && first <= 90) || (first >= 97 && first <= 122)) {
21
+ tokens += 1 + Math.floor((piece.length - 1) / 6);
22
+ }
23
+ else
24
+ tokens += 0.9;
25
+ }
26
+ return Math.ceil(tokens);
27
+ }
28
+ export function truncate(text, limit) {
29
+ return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`;
30
+ }
31
+ function abridge(text, head, tail) {
32
+ if (text.length <= head + tail + 40)
33
+ return text;
34
+ const omitted = text.length - head - tail;
35
+ return `${text.slice(0, head)}\n[… ${omitted} chars omitted …]\n${text.slice(-tail)}`;
36
+ }
37
+ export function isPinned(index, total, preserveRecentMessages) {
38
+ return index === 0 || index >= total - preserveRecentMessages;
39
+ }
40
+ /**
41
+ * Pairs every tool_use with its tool_result by `tool_use_id`. Calls without a
42
+ * result are not candidates (there is nothing to drop yet).
43
+ */
44
+ export function collectToolCalls(messages, preserveRecentMessages) {
45
+ const results = new Map();
46
+ messages.forEach((message, index) => {
47
+ for (const result of message.toolResults ?? []) {
48
+ results.set(result.tool_use_id, { index, result });
49
+ }
50
+ });
51
+ const calls = [];
52
+ messages.forEach((message, callIndex) => {
53
+ for (const tool of message.toolUses) {
54
+ const found = results.get(tool.tool_use_id);
55
+ if (!found)
56
+ continue;
57
+ calls.push({
58
+ id: `t${calls.length + 1}`,
59
+ tool_use_id: tool.tool_use_id,
60
+ tool: tool.tool,
61
+ input: tool.input,
62
+ callIndex,
63
+ resultIndex: found.index,
64
+ resultChars: found.result.text.length,
65
+ isError: found.result.isError ?? false,
66
+ pinned: isPinned(callIndex, messages.length, preserveRecentMessages) ||
67
+ isPinned(found.index, messages.length, preserveRecentMessages),
68
+ });
69
+ }
70
+ });
71
+ return calls;
72
+ }
73
+ function inputText(input, limit) {
74
+ let json = '';
75
+ try {
76
+ json = JSON.stringify(input);
77
+ }
78
+ catch {
79
+ json = '[unserializable input]';
80
+ }
81
+ return truncate(json, limit);
82
+ }
83
+ function resultNote(call) {
84
+ return `${call.isError ? 'error' : 'ok'}, ${call.resultChars} chars (omitted)`;
85
+ }
86
+ /** One call as a single line, for when the structured form is too costly. */
87
+ function compactCall(call) {
88
+ const input = Object.entries(call.input)
89
+ .map(([key, value]) => {
90
+ const text = typeof value === 'string' ? value : inputText({ [key]: value }, 200);
91
+ return `${key}=${text.replace(/\s+/g, ' ')}`;
92
+ })
93
+ .join(' ');
94
+ return `${call.id} ${call.tool} ${truncate(input, INPUT_CHARS[2])} → ${call.isError ? 'error' : 'ok'} ${call.resultChars}ch`;
95
+ }
96
+ /**
97
+ * Folds runs of adjacent call-only entries into one entry each, so the
98
+ * per-entry envelope is paid once per run; the call lines keep their ids.
99
+ */
100
+ function mergeCallRuns(history, pinned) {
101
+ const merged = [];
102
+ for (const entry of history) {
103
+ const previous = merged[merged.length - 1];
104
+ const foldable = (e) => !pinned(e) && e.text.length === 0 && typeof e.tool_calls?.[0] === 'string';
105
+ if (previous && foldable(previous) && foldable(entry) && previous.role === entry.role) {
106
+ previous.tool_calls = [...previous.tool_calls, ...entry.tool_calls];
107
+ continue;
108
+ }
109
+ merged.push({ ...entry });
110
+ }
111
+ return merged;
112
+ }
113
+ function callsByMessage(calls) {
114
+ const byMessage = new Map();
115
+ for (const call of calls) {
116
+ const list = byMessage.get(call.callIndex) ?? [];
117
+ list.push(call);
118
+ byMessage.set(call.callIndex, list);
119
+ }
120
+ return byMessage;
121
+ }
122
+ function historyEntries(messages, calls, inputChars) {
123
+ const byMessage = callsByMessage(calls);
124
+ const entries = [];
125
+ messages.forEach((message, i) => {
126
+ const toolCalls = (byMessage.get(i) ?? []).map((call) => ({
127
+ id: call.id,
128
+ tool: call.tool,
129
+ input: inputText(call.input, inputChars),
130
+ result: resultNote(call),
131
+ }));
132
+ if (message.text.trim().length === 0 && toolCalls.length === 0)
133
+ return;
134
+ const entry = { i, role: message.role, text: message.text };
135
+ if (toolCalls.length > 0)
136
+ entry.tool_calls = toolCalls;
137
+ entries.push(entry);
138
+ });
139
+ return entries;
140
+ }
141
+ /** The last three user prompts, as the default `goal`. */
142
+ export function goalFromMessages(messages) {
143
+ return messages
144
+ .filter((message) => message.role === 'user' &&
145
+ message.text.trim().length > 0 &&
146
+ (message.toolResults ?? []).length === 0)
147
+ .slice(-3)
148
+ .map((message) => truncate(message.text, 500))
149
+ .join('\n');
150
+ }
151
+ /**
152
+ * Builds the Jev state from the whole conversation and shrinks it in stages
153
+ * until it fits `maxStateTokens`: tool inputs are truncated, then long texts
154
+ * are abridged oldest-first (pinned messages last), then old messages collapse
155
+ * to a one-line note, then old tool calls shrink to one line each, then old
156
+ * messages that carry no call are left out, then runs of old call-only
157
+ * messages are folded into one entry. Throws when even that is too big.
158
+ */
159
+ export function fitState(messages, calls, options) {
160
+ const goal = options.goal || goalFromMessages(messages);
161
+ const stateOf = (history) => ({
162
+ context: STATE_CONTEXT,
163
+ goal,
164
+ history,
165
+ });
166
+ const entryTokens = (entry) => estimateTokens(JSON.stringify(entry)) + 1;
167
+ const baseTokens = estimateTokens(JSON.stringify(stateOf([])));
168
+ const fitted = (history, tokens, stage) => ({
169
+ state: stateOf(history),
170
+ tokens,
171
+ stage,
172
+ });
173
+ let history = [];
174
+ let perEntry = [];
175
+ let tokens = 0;
176
+ const rebuild = (inputChars) => {
177
+ history = historyEntries(messages, calls, inputChars);
178
+ perEntry = history.map(entryTokens);
179
+ tokens = baseTokens + perEntry.reduce((sum, n) => sum + n, 0);
180
+ };
181
+ const fits = () => tokens <= options.maxStateTokens;
182
+ const shrink = (index, change) => {
183
+ const entry = history[index];
184
+ if (!entry)
185
+ return;
186
+ change(entry);
187
+ const now = entryTokens(entry);
188
+ tokens += now - (perEntry[index] ?? 0);
189
+ perEntry[index] = now;
190
+ };
191
+ rebuild(INPUT_CHARS[0]);
192
+ if (fits())
193
+ return fitted(history, tokens, 'full');
194
+ for (const limit of INPUT_CHARS.slice(1)) {
195
+ rebuild(limit);
196
+ if (fits())
197
+ return fitted(history, tokens, `inputs<=${limit}`);
198
+ }
199
+ const pinned = (entry) => isPinned(entry.i, messages.length, options.preserveRecentMessages);
200
+ const indices = history.map((_, index) => index);
201
+ const order = [
202
+ ...indices.filter((index) => !pinned(history[index])),
203
+ ...indices.filter((index) => pinned(history[index])),
204
+ ];
205
+ for (const index of order) {
206
+ const entry = history[index];
207
+ if (entry.text.length <= TEXT_HEAD + TEXT_TAIL + 40)
208
+ continue;
209
+ shrink(index, (e) => {
210
+ e.text = abridge(e.text, TEXT_HEAD, TEXT_TAIL);
211
+ });
212
+ if (fits())
213
+ return fitted(history, tokens, 'texts abridged');
214
+ }
215
+ for (const index of order) {
216
+ const entry = history[index];
217
+ if (pinned(entry) || entry.text.length === 0)
218
+ continue;
219
+ const original = messages[entry.i]?.text.length ?? entry.text.length;
220
+ shrink(index, (e) => {
221
+ e.text = `[… ${original} chars omitted …]`;
222
+ });
223
+ if (fits())
224
+ return fitted(history, tokens, 'old messages collapsed');
225
+ }
226
+ const byMessage = callsByMessage(calls);
227
+ for (const index of order) {
228
+ const entry = history[index];
229
+ const own = byMessage.get(entry.i);
230
+ if (pinned(entry) || !own)
231
+ continue;
232
+ shrink(index, (e) => {
233
+ e.tool_calls = own.map(compactCall);
234
+ });
235
+ if (fits())
236
+ return fitted(history, tokens, 'old calls compacted');
237
+ }
238
+ const left = new Set();
239
+ for (const index of order) {
240
+ const entry = history[index];
241
+ if (pinned(entry) || entry.tool_calls)
242
+ continue;
243
+ left.add(index);
244
+ tokens -= perEntry[index] ?? 0;
245
+ if (fits()) {
246
+ return fitted(history.filter((_, i) => !left.has(i)), tokens, 'old messages left out');
247
+ }
248
+ }
249
+ history = mergeCallRuns(history.filter((_, i) => !left.has(i)), pinned);
250
+ perEntry = history.map(entryTokens);
251
+ tokens = baseTokens + perEntry.reduce((sum, n) => sum + n, 0);
252
+ if (fits())
253
+ return fitted(history, tokens, 'old calls merged');
254
+ throw new Error(`history too large for Jev (~${tokens} tokens after truncation, limit ${options.maxStateTokens})`);
255
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export { default } from '../src/compaction/extension.js';
@@ -0,0 +1 @@
1
+ export { default } from '../src/commands/extension.js';
@@ -0,0 +1 @@
1
+ export { default } from '../src/routing/extension.js';