@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,80 @@
|
|
|
1
|
+
import type { JevAnswer, JevQuestions, JevResponse, JevState } from './types.js';
|
|
2
|
+
|
|
3
|
+
export const SYSTEM_ONE_URL = 'https://api.typesafe.ai/v1/systemone';
|
|
4
|
+
export const DEFAULT_MODEL = 'jev-latest';
|
|
5
|
+
|
|
6
|
+
export interface JevRequest {
|
|
7
|
+
url: string;
|
|
8
|
+
method: 'POST';
|
|
9
|
+
headers: Record<string, string>;
|
|
10
|
+
body: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The HTTP request for one Jev call, for any fetch-like transport. */
|
|
14
|
+
export function buildJevRequest(
|
|
15
|
+
params: {
|
|
16
|
+
apiKey: string;
|
|
17
|
+
model?: string;
|
|
18
|
+
baseUrl?: string;
|
|
19
|
+
},
|
|
20
|
+
state: JevState,
|
|
21
|
+
questions: JevQuestions,
|
|
22
|
+
): JevRequest {
|
|
23
|
+
return {
|
|
24
|
+
url: params.baseUrl ?? SYSTEM_ONE_URL,
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: {
|
|
27
|
+
authorization: `Bearer ${params.apiKey}`,
|
|
28
|
+
'content-type': 'application/json',
|
|
29
|
+
},
|
|
30
|
+
body: JSON.stringify({
|
|
31
|
+
model: params.model ?? DEFAULT_MODEL,
|
|
32
|
+
state,
|
|
33
|
+
questions,
|
|
34
|
+
}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Validates a Jev response body; throws on anything but an `answers` object. */
|
|
39
|
+
export function parseJevResponse(
|
|
40
|
+
status: number,
|
|
41
|
+
ok: boolean,
|
|
42
|
+
text: string,
|
|
43
|
+
): JevResponse {
|
|
44
|
+
if (!ok) {
|
|
45
|
+
throw new Error(`Jev request failed (${status}): ${text.slice(0, 200)}`);
|
|
46
|
+
}
|
|
47
|
+
let parsed: unknown;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(text);
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error('Jev returned malformed JSON');
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
parsed === null ||
|
|
55
|
+
typeof parsed !== 'object' ||
|
|
56
|
+
!('answers' in parsed) ||
|
|
57
|
+
parsed.answers === null ||
|
|
58
|
+
typeof parsed.answers !== 'object'
|
|
59
|
+
) {
|
|
60
|
+
throw new Error('Jev response is missing answers');
|
|
61
|
+
}
|
|
62
|
+
return parsed as JevResponse;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The `noul` probability of one answer; throws when it is not there. */
|
|
66
|
+
export function noulAnswer(
|
|
67
|
+
answers: Record<string, JevAnswer>,
|
|
68
|
+
name: string,
|
|
69
|
+
): number {
|
|
70
|
+
const answer = answers[name];
|
|
71
|
+
if (
|
|
72
|
+
!answer ||
|
|
73
|
+
!('noul' in answer) ||
|
|
74
|
+
typeof answer.noul !== 'number' ||
|
|
75
|
+
!Number.isFinite(answer.noul)
|
|
76
|
+
) {
|
|
77
|
+
throw new Error(`Invalid Jev answer for ${name}`);
|
|
78
|
+
}
|
|
79
|
+
return answer.noul;
|
|
80
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CompactionState,
|
|
3
|
+
FittedState,
|
|
4
|
+
HistoryEntry,
|
|
5
|
+
Message,
|
|
6
|
+
ResolvedCompactOptions,
|
|
7
|
+
ToolCall,
|
|
8
|
+
ToolResult,
|
|
9
|
+
} from './types.js';
|
|
10
|
+
|
|
11
|
+
export const STATE_CONTEXT =
|
|
12
|
+
'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.';
|
|
13
|
+
|
|
14
|
+
/** Successive caps on the serialised tool input included per call. */
|
|
15
|
+
const INPUT_CHARS = [1000, 200, 60] as const;
|
|
16
|
+
const TEXT_HEAD = 400;
|
|
17
|
+
const TEXT_TAIL = 150;
|
|
18
|
+
|
|
19
|
+
const TOKEN_PIECES = /[A-Za-z]+|\d+|[^\sA-Za-z\d]/g;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Estimates tokens without a tokenizer: a word costs one token per six
|
|
23
|
+
* letters, a digit half a token, any other symbol nine tenths. Calibrated
|
|
24
|
+
* against the usage Jev reports for real transcripts, where it lands 2–18%
|
|
25
|
+
* above the true count; a plain characters-per-token ratio undercounts the
|
|
26
|
+
* JSON-heavy states by up to 40%.
|
|
27
|
+
*/
|
|
28
|
+
export function estimateTokens(text: string): number {
|
|
29
|
+
let tokens = 0;
|
|
30
|
+
for (const [piece] of text.matchAll(TOKEN_PIECES)) {
|
|
31
|
+
const first = piece.charCodeAt(0);
|
|
32
|
+
if (first >= 48 && first <= 57) tokens += piece.length / 2;
|
|
33
|
+
else if ((first >= 65 && first <= 90) || (first >= 97 && first <= 122)) {
|
|
34
|
+
tokens += 1 + Math.floor((piece.length - 1) / 6);
|
|
35
|
+
} else tokens += 0.9;
|
|
36
|
+
}
|
|
37
|
+
return Math.ceil(tokens);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function truncate(text: string, limit: number): string {
|
|
41
|
+
return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function abridge(text: string, head: number, tail: number): string {
|
|
45
|
+
if (text.length <= head + tail + 40) return text;
|
|
46
|
+
const omitted = text.length - head - tail;
|
|
47
|
+
return `${text.slice(0, head)}\n[… ${omitted} chars omitted …]\n${text.slice(-tail)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isPinned(
|
|
51
|
+
index: number,
|
|
52
|
+
total: number,
|
|
53
|
+
preserveRecentMessages: number,
|
|
54
|
+
): boolean {
|
|
55
|
+
return index === 0 || index >= total - preserveRecentMessages;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Pairs every tool_use with its tool_result by `tool_use_id`. Calls without a
|
|
60
|
+
* result are not candidates (there is nothing to drop yet).
|
|
61
|
+
*/
|
|
62
|
+
export function collectToolCalls(
|
|
63
|
+
messages: readonly Message[],
|
|
64
|
+
preserveRecentMessages: number,
|
|
65
|
+
): ToolCall[] {
|
|
66
|
+
const results = new Map<string, { index: number; result: ToolResult }>();
|
|
67
|
+
messages.forEach((message, index) => {
|
|
68
|
+
for (const result of message.toolResults ?? []) {
|
|
69
|
+
results.set(result.tool_use_id, { index, result });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
const calls: ToolCall[] = [];
|
|
73
|
+
messages.forEach((message, callIndex) => {
|
|
74
|
+
for (const tool of message.toolUses) {
|
|
75
|
+
const found = results.get(tool.tool_use_id);
|
|
76
|
+
if (!found) continue;
|
|
77
|
+
calls.push({
|
|
78
|
+
id: `t${calls.length + 1}`,
|
|
79
|
+
tool_use_id: tool.tool_use_id,
|
|
80
|
+
tool: tool.tool,
|
|
81
|
+
input: tool.input,
|
|
82
|
+
callIndex,
|
|
83
|
+
resultIndex: found.index,
|
|
84
|
+
resultChars: found.result.text.length,
|
|
85
|
+
isError: found.result.isError ?? false,
|
|
86
|
+
pinned:
|
|
87
|
+
isPinned(callIndex, messages.length, preserveRecentMessages) ||
|
|
88
|
+
isPinned(found.index, messages.length, preserveRecentMessages),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
return calls;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function inputText(input: Record<string, unknown>, limit: number): string {
|
|
96
|
+
let json = '';
|
|
97
|
+
try {
|
|
98
|
+
json = JSON.stringify(input);
|
|
99
|
+
} catch {
|
|
100
|
+
json = '[unserializable input]';
|
|
101
|
+
}
|
|
102
|
+
return truncate(json, limit);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resultNote(call: ToolCall): string {
|
|
106
|
+
return `${call.isError ? 'error' : 'ok'}, ${call.resultChars} chars (omitted)`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One call as a single line, for when the structured form is too costly. */
|
|
110
|
+
function compactCall(call: ToolCall): string {
|
|
111
|
+
const input = Object.entries(call.input)
|
|
112
|
+
.map(([key, value]) => {
|
|
113
|
+
const text = typeof value === 'string' ? value : inputText({ [key]: value }, 200);
|
|
114
|
+
return `${key}=${text.replace(/\s+/g, ' ')}`;
|
|
115
|
+
})
|
|
116
|
+
.join(' ');
|
|
117
|
+
return `${call.id} ${call.tool} ${truncate(input, INPUT_CHARS[2])} → ${
|
|
118
|
+
call.isError ? 'error' : 'ok'
|
|
119
|
+
} ${call.resultChars}ch`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Folds runs of adjacent call-only entries into one entry each, so the
|
|
124
|
+
* per-entry envelope is paid once per run; the call lines keep their ids.
|
|
125
|
+
*/
|
|
126
|
+
function mergeCallRuns(history: readonly HistoryEntry[], pinned: (e: HistoryEntry) => boolean): HistoryEntry[] {
|
|
127
|
+
const merged: HistoryEntry[] = [];
|
|
128
|
+
for (const entry of history) {
|
|
129
|
+
const previous = merged[merged.length - 1];
|
|
130
|
+
const foldable = (e: HistoryEntry): boolean =>
|
|
131
|
+
!pinned(e) && e.text.length === 0 && typeof e.tool_calls?.[0] === 'string';
|
|
132
|
+
if (previous && foldable(previous) && foldable(entry) && previous.role === entry.role) {
|
|
133
|
+
previous.tool_calls = [...(previous.tool_calls as string[]), ...(entry.tool_calls as string[])];
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
merged.push({ ...entry });
|
|
137
|
+
}
|
|
138
|
+
return merged;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function callsByMessage(calls: readonly ToolCall[]): Map<number, ToolCall[]> {
|
|
142
|
+
const byMessage = new Map<number, ToolCall[]>();
|
|
143
|
+
for (const call of calls) {
|
|
144
|
+
const list = byMessage.get(call.callIndex) ?? [];
|
|
145
|
+
list.push(call);
|
|
146
|
+
byMessage.set(call.callIndex, list);
|
|
147
|
+
}
|
|
148
|
+
return byMessage;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function historyEntries(
|
|
152
|
+
messages: readonly Message[],
|
|
153
|
+
calls: readonly ToolCall[],
|
|
154
|
+
inputChars: number,
|
|
155
|
+
): HistoryEntry[] {
|
|
156
|
+
const byMessage = callsByMessage(calls);
|
|
157
|
+
const entries: HistoryEntry[] = [];
|
|
158
|
+
messages.forEach((message, i) => {
|
|
159
|
+
const toolCalls = (byMessage.get(i) ?? []).map((call) => ({
|
|
160
|
+
id: call.id,
|
|
161
|
+
tool: call.tool,
|
|
162
|
+
input: inputText(call.input, inputChars),
|
|
163
|
+
result: resultNote(call),
|
|
164
|
+
}));
|
|
165
|
+
if (message.text.trim().length === 0 && toolCalls.length === 0) return;
|
|
166
|
+
const entry: HistoryEntry = { i, role: message.role, text: message.text };
|
|
167
|
+
if (toolCalls.length > 0) entry.tool_calls = toolCalls;
|
|
168
|
+
entries.push(entry);
|
|
169
|
+
});
|
|
170
|
+
return entries;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The last three user prompts, as the default `goal`. */
|
|
174
|
+
export function goalFromMessages(messages: readonly Message[]): string {
|
|
175
|
+
return messages
|
|
176
|
+
.filter(
|
|
177
|
+
(message) =>
|
|
178
|
+
message.role === 'user' &&
|
|
179
|
+
message.text.trim().length > 0 &&
|
|
180
|
+
(message.toolResults ?? []).length === 0,
|
|
181
|
+
)
|
|
182
|
+
.slice(-3)
|
|
183
|
+
.map((message) => truncate(message.text, 500))
|
|
184
|
+
.join('\n');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Builds the Jev state from the whole conversation and shrinks it in stages
|
|
189
|
+
* until it fits `maxStateTokens`: tool inputs are truncated, then long texts
|
|
190
|
+
* are abridged oldest-first (pinned messages last), then old messages collapse
|
|
191
|
+
* to a one-line note, then old tool calls shrink to one line each, then old
|
|
192
|
+
* messages that carry no call are left out, then runs of old call-only
|
|
193
|
+
* messages are folded into one entry. Throws when even that is too big.
|
|
194
|
+
*/
|
|
195
|
+
export function fitState(
|
|
196
|
+
messages: readonly Message[],
|
|
197
|
+
calls: readonly ToolCall[],
|
|
198
|
+
options: Pick<ResolvedCompactOptions, 'maxStateTokens' | 'preserveRecentMessages' | 'goal'>,
|
|
199
|
+
): FittedState {
|
|
200
|
+
const goal = options.goal || goalFromMessages(messages);
|
|
201
|
+
const stateOf = (history: HistoryEntry[]): CompactionState => ({
|
|
202
|
+
context: STATE_CONTEXT,
|
|
203
|
+
goal,
|
|
204
|
+
history,
|
|
205
|
+
});
|
|
206
|
+
const entryTokens = (entry: HistoryEntry): number => estimateTokens(JSON.stringify(entry)) + 1;
|
|
207
|
+
const baseTokens = estimateTokens(JSON.stringify(stateOf([])));
|
|
208
|
+
const fitted = (history: HistoryEntry[], tokens: number, stage: string): FittedState => ({
|
|
209
|
+
state: stateOf(history),
|
|
210
|
+
tokens,
|
|
211
|
+
stage,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
let history: HistoryEntry[] = [];
|
|
215
|
+
let perEntry: number[] = [];
|
|
216
|
+
let tokens = 0;
|
|
217
|
+
const rebuild = (inputChars: number): void => {
|
|
218
|
+
history = historyEntries(messages, calls, inputChars);
|
|
219
|
+
perEntry = history.map(entryTokens);
|
|
220
|
+
tokens = baseTokens + perEntry.reduce((sum, n) => sum + n, 0);
|
|
221
|
+
};
|
|
222
|
+
const fits = (): boolean => tokens <= options.maxStateTokens;
|
|
223
|
+
const shrink = (index: number, change: (entry: HistoryEntry) => void): void => {
|
|
224
|
+
const entry = history[index];
|
|
225
|
+
if (!entry) return;
|
|
226
|
+
change(entry);
|
|
227
|
+
const now = entryTokens(entry);
|
|
228
|
+
tokens += now - (perEntry[index] ?? 0);
|
|
229
|
+
perEntry[index] = now;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
rebuild(INPUT_CHARS[0]);
|
|
233
|
+
if (fits()) return fitted(history, tokens, 'full');
|
|
234
|
+
|
|
235
|
+
for (const limit of INPUT_CHARS.slice(1)) {
|
|
236
|
+
rebuild(limit);
|
|
237
|
+
if (fits()) return fitted(history, tokens, `inputs<=${limit}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const pinned = (entry: HistoryEntry): boolean =>
|
|
241
|
+
isPinned(entry.i, messages.length, options.preserveRecentMessages);
|
|
242
|
+
const indices = history.map((_, index) => index);
|
|
243
|
+
const order = [
|
|
244
|
+
...indices.filter((index) => !pinned(history[index]!)),
|
|
245
|
+
...indices.filter((index) => pinned(history[index]!)),
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
for (const index of order) {
|
|
249
|
+
const entry = history[index]!;
|
|
250
|
+
if (entry.text.length <= TEXT_HEAD + TEXT_TAIL + 40) continue;
|
|
251
|
+
shrink(index, (e) => {
|
|
252
|
+
e.text = abridge(e.text, TEXT_HEAD, TEXT_TAIL);
|
|
253
|
+
});
|
|
254
|
+
if (fits()) return fitted(history, tokens, 'texts abridged');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (const index of order) {
|
|
258
|
+
const entry = history[index]!;
|
|
259
|
+
if (pinned(entry) || entry.text.length === 0) continue;
|
|
260
|
+
const original = messages[entry.i]?.text.length ?? entry.text.length;
|
|
261
|
+
shrink(index, (e) => {
|
|
262
|
+
e.text = `[… ${original} chars omitted …]`;
|
|
263
|
+
});
|
|
264
|
+
if (fits()) return fitted(history, tokens, 'old messages collapsed');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const byMessage = callsByMessage(calls);
|
|
268
|
+
for (const index of order) {
|
|
269
|
+
const entry = history[index]!;
|
|
270
|
+
const own = byMessage.get(entry.i);
|
|
271
|
+
if (pinned(entry) || !own) continue;
|
|
272
|
+
shrink(index, (e) => {
|
|
273
|
+
e.tool_calls = own.map(compactCall);
|
|
274
|
+
});
|
|
275
|
+
if (fits()) return fitted(history, tokens, 'old calls compacted');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const left = new Set<number>();
|
|
279
|
+
for (const index of order) {
|
|
280
|
+
const entry = history[index]!;
|
|
281
|
+
if (pinned(entry) || entry.tool_calls) continue;
|
|
282
|
+
left.add(index);
|
|
283
|
+
tokens -= perEntry[index] ?? 0;
|
|
284
|
+
if (fits()) {
|
|
285
|
+
return fitted(
|
|
286
|
+
history.filter((_, i) => !left.has(i)),
|
|
287
|
+
tokens,
|
|
288
|
+
'old messages left out',
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
history = mergeCallRuns(
|
|
294
|
+
history.filter((_, i) => !left.has(i)),
|
|
295
|
+
pinned,
|
|
296
|
+
);
|
|
297
|
+
perEntry = history.map(entryTokens);
|
|
298
|
+
tokens = baseTokens + perEntry.reduce((sum, n) => sum + n, 0);
|
|
299
|
+
if (fits()) return fitted(history, tokens, 'old calls merged');
|
|
300
|
+
|
|
301
|
+
throw new Error(
|
|
302
|
+
`history too large for Jev (~${tokens} tokens after truncation, limit ${options.maxStateTokens})`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
export type Role = 'user' | 'assistant';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A tool_use block of an assistant message. `text` and `isError` mirror the
|
|
5
|
+
* outcome once the transcript holds it (Claude Code attaches them).
|
|
6
|
+
*/
|
|
7
|
+
export interface ToolUse {
|
|
8
|
+
tool_use_id: string;
|
|
9
|
+
tool: string;
|
|
10
|
+
input: Record<string, unknown>;
|
|
11
|
+
text?: string;
|
|
12
|
+
isError?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A tool_result block of a user message. */
|
|
16
|
+
export interface ToolResult {
|
|
17
|
+
tool_use_id: string;
|
|
18
|
+
text: string;
|
|
19
|
+
isError?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One transcript message. The shape is a subset of Claude Code's
|
|
24
|
+
* `SessionMessage`, so a session transcript can be passed in as is.
|
|
25
|
+
*/
|
|
26
|
+
export interface Message {
|
|
27
|
+
role: Role;
|
|
28
|
+
text: string;
|
|
29
|
+
toolUses: ToolUse[];
|
|
30
|
+
toolResults?: ToolResult[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A tool call paired with its result by `tool_use_id`. */
|
|
34
|
+
export interface ToolCall {
|
|
35
|
+
/** Short id used in the Jev state and question names (`t1`, `t2`, ...). */
|
|
36
|
+
id: string;
|
|
37
|
+
tool_use_id: string;
|
|
38
|
+
tool: string;
|
|
39
|
+
input: Record<string, unknown>;
|
|
40
|
+
/** Index of the message holding the tool_use block. */
|
|
41
|
+
callIndex: number;
|
|
42
|
+
/** Index of the message holding the tool_result block. */
|
|
43
|
+
resultIndex: number;
|
|
44
|
+
resultChars: number;
|
|
45
|
+
isError: boolean;
|
|
46
|
+
/** In the first or the newest preserved messages; never a candidate. */
|
|
47
|
+
pinned: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CallAnswer {
|
|
51
|
+
/** Jev's probability that the call itself still matters. */
|
|
52
|
+
keepCall: number;
|
|
53
|
+
/** Jev's probability that the full result still needs to stay verbatim. */
|
|
54
|
+
keepResult: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type CallAction = 'keep' | 'drop_result' | 'drop_call';
|
|
58
|
+
|
|
59
|
+
export interface CallDecision extends CallAnswer {
|
|
60
|
+
id: string;
|
|
61
|
+
tool: string;
|
|
62
|
+
action: CallAction;
|
|
63
|
+
reason: 'pinned' | 'kept' | 'result_dropped' | 'call_dropped';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface HistoryToolCall {
|
|
67
|
+
id: string;
|
|
68
|
+
tool: string;
|
|
69
|
+
input: string;
|
|
70
|
+
result: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface HistoryEntry {
|
|
74
|
+
i: number;
|
|
75
|
+
role: Role;
|
|
76
|
+
text: string;
|
|
77
|
+
/** Structured per call, or one compact line per call once the state has to shrink. */
|
|
78
|
+
tool_calls?: HistoryToolCall[] | string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The state sent with every Jev request: the whole history, results omitted. */
|
|
82
|
+
export interface CompactionState {
|
|
83
|
+
context: string;
|
|
84
|
+
goal: string;
|
|
85
|
+
history: HistoryEntry[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface FittedState {
|
|
89
|
+
state: CompactionState;
|
|
90
|
+
tokens: number;
|
|
91
|
+
/** Which fitting stage produced the state, for diagnostics. */
|
|
92
|
+
stage: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface CompactOptions {
|
|
96
|
+
/** Ongoing task description; defaults to the last few user prompts. */
|
|
97
|
+
goal?: string;
|
|
98
|
+
/** Minimum keep probability for a call or result to stay. Default 0.5. */
|
|
99
|
+
keepThreshold?: number;
|
|
100
|
+
/** Newest messages never touched (the first message is always kept). Default 6. */
|
|
101
|
+
preserveRecentMessages?: number;
|
|
102
|
+
/** Estimated token ceiling for the state. Default 25000. */
|
|
103
|
+
maxStateTokens?: number;
|
|
104
|
+
/** Estimated token ceiling for state plus one batch of questions. Default 30000. */
|
|
105
|
+
maxRequestTokens?: number;
|
|
106
|
+
/** Characters of a dropped tool result to retain. Default 300. */
|
|
107
|
+
truncateHeadChars?: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ResolvedCompactOptions {
|
|
111
|
+
goal: string;
|
|
112
|
+
keepThreshold: number;
|
|
113
|
+
preserveRecentMessages: number;
|
|
114
|
+
maxStateTokens: number;
|
|
115
|
+
maxRequestTokens: number;
|
|
116
|
+
truncateHeadChars: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface CompactResult {
|
|
120
|
+
/** The compacted transcript; untouched messages are the input objects. */
|
|
121
|
+
messages: Message[];
|
|
122
|
+
decisions: CallDecision[];
|
|
123
|
+
stats: {
|
|
124
|
+
messagesBefore: number;
|
|
125
|
+
messagesAfter: number;
|
|
126
|
+
charsBefore: number;
|
|
127
|
+
charsAfter: number;
|
|
128
|
+
calls: number;
|
|
129
|
+
kept: number;
|
|
130
|
+
resultsDropped: number;
|
|
131
|
+
callsDropped: number;
|
|
132
|
+
pinned: number;
|
|
133
|
+
stateTokens: number;
|
|
134
|
+
/** Which fitting stage the state needed, '' when no request was made. */
|
|
135
|
+
stateStage: string;
|
|
136
|
+
requests: number;
|
|
137
|
+
ms: number;
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The `state` of a Jev request: a string or any JSON-serialisable object. */
|
|
142
|
+
export type JevState = string | object;
|
|
143
|
+
|
|
144
|
+
export interface NoulQuestion {
|
|
145
|
+
type: 'noul';
|
|
146
|
+
instructions: string;
|
|
147
|
+
criteria?: {
|
|
148
|
+
true?: string;
|
|
149
|
+
false?: string;
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface ChoiceQuestion {
|
|
154
|
+
type: 'choice';
|
|
155
|
+
instructions: string;
|
|
156
|
+
criteria: Record<string, string | null>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface ScoreQuestion {
|
|
160
|
+
type: 'score';
|
|
161
|
+
instructions: string;
|
|
162
|
+
criteria: string[];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type JevQuestion = NoulQuestion | ChoiceQuestion | ScoreQuestion;
|
|
166
|
+
export type JevQuestions = Record<string, JevQuestion>;
|
|
167
|
+
|
|
168
|
+
export interface NoulAnswer {
|
|
169
|
+
type?: 'noul';
|
|
170
|
+
noul: number;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface ChoiceAnswer {
|
|
174
|
+
type?: 'choice';
|
|
175
|
+
choice: string;
|
|
176
|
+
confidence: number;
|
|
177
|
+
probabilities: Record<string, number>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface ScoreAnswer {
|
|
181
|
+
type?: 'score';
|
|
182
|
+
score: number;
|
|
183
|
+
confidence: number;
|
|
184
|
+
probabilities: Record<string, number>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type JevAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer;
|
|
188
|
+
|
|
189
|
+
export interface JevResponse {
|
|
190
|
+
model?: string;
|
|
191
|
+
answers: Record<string, JevAnswer>;
|
|
192
|
+
usage?: {
|
|
193
|
+
input_tokens?: number;
|
|
194
|
+
output_tokens?: number;
|
|
195
|
+
};
|
|
196
|
+
[key: string]: unknown;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Anything that can answer Jev questions: `JevClient`, or a host-provided adapter. */
|
|
200
|
+
export interface JevAsker {
|
|
201
|
+
ask(state: JevState, questions: JevQuestions): Promise<JevResponse>;
|
|
202
|
+
}
|