@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,291 @@
1
+ import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
2
+ import { defineMenu, runMenu } from '@narumitw/pi-tui-kit';
3
+ import {
4
+ CONFIG_KEYS,
5
+ configFromEnv,
6
+ mergeConfigFiles,
7
+ readConfigFile,
8
+ type ConfigKeyMeta,
9
+ type JevFileConfig,
10
+ } from '../shared/config.js';
11
+ import { coerce, keyMeta, readOrInit, saveFile, setPath, valueAt } from '../cli/main.js';
12
+ import { VALUE_OPTIONS } from './completions.js';
13
+ import { THINKING_LEVELS, type ModelOption } from './models.js';
14
+
15
+ export type MenuScreenId = 'main' | 'general' | 'compaction' | 'routing' | 'chooseValue' | 'chooseModel' | 'chooseThinking' | 'inputValue';
16
+ export type MenuActionId =
17
+ | 'toggleScope'
18
+ | 'editKey'
19
+ | 'setEnum'
20
+ | 'setValue'
21
+ | 'pickModel'
22
+ | 'pickThinking'
23
+ | 'showResolved';
24
+
25
+ export interface EditorState {
26
+ scope: 'global' | 'project';
27
+ editingKey: string;
28
+ /** Model picked in chooseModel, awaiting an optional thinking level. */
29
+ selectedModelRef?: string;
30
+ /** Whether the selected model supports thinking. */
31
+ selectedModelReasoning?: boolean;
32
+ }
33
+
34
+ export interface MenuPaths {
35
+ globalPath: string;
36
+ projectPath: string;
37
+ }
38
+
39
+ function readFileTolerant(path: string): JevFileConfig | undefined {
40
+ try {
41
+ return readConfigFile(path);
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ function merged(paths: MenuPaths): JevFileConfig | undefined {
48
+ return mergeConfigFiles(readFileTolerant(paths.globalPath), readFileTolerant(paths.projectPath));
49
+ }
50
+
51
+ /** Effective value of a key, tagged with where it comes from. */
52
+ function displayValue(meta: ConfigKeyMeta, paths: MenuPaths): string {
53
+ const config = configFromEnv(process.env, merged(paths));
54
+ const raw = valueAt(config, meta.path);
55
+ if (raw === undefined) return 'unset';
56
+ const fromEnv = meta.env ? (process.env[meta.env] ?? '').trim() : '';
57
+ return `${String(raw)}${fromEnv ? ' [env]' : ''}`;
58
+ }
59
+
60
+ function groupKeys(prefix: string): ConfigKeyMeta[] {
61
+ return CONFIG_KEYS.filter((meta) =>
62
+ prefix === '' ? !meta.path.includes('.') : meta.path.startsWith(`${prefix}.`),
63
+ );
64
+ }
65
+
66
+ function groupScreen(
67
+ screenId: 'general' | 'compaction' | 'routing',
68
+ title: string,
69
+ paths: MenuPaths,
70
+ ): { kind: 'settings'; title: string; items: { id: string; label: string; description?: string; currentValue: string; action: MenuActionId }[] } {
71
+ const prefix = screenId === 'general' ? '' : screenId;
72
+ const action: MenuActionId = 'editKey';
73
+ return {
74
+ kind: 'settings',
75
+ title,
76
+ items: groupKeys(prefix).map((meta) => ({
77
+ id: meta.path,
78
+ label: meta.path,
79
+ description: meta.description,
80
+ currentValue: displayValue(meta, paths),
81
+ action,
82
+ })),
83
+ };
84
+ }
85
+
86
+ function writeKey(paths: MenuPaths, scope: 'global' | 'project', keyPath: string, value: string): void {
87
+ const meta = keyMeta(keyPath);
88
+ if (!meta) throw new Error(`unknown key "${keyPath}"`);
89
+ const targetPath = scope === 'project' ? paths.projectPath : paths.globalPath;
90
+ const file = readOrInit(targetPath);
91
+ setPath(file, meta.path, coerce(meta, value));
92
+ saveFile(targetPath, file);
93
+ }
94
+
95
+ /**
96
+ * Builds the interactive `/jev` settings menu on top of `@narumitw/pi-tui-kit`.
97
+ * `state` is mutated by actions (scope toggle, key being edited).
98
+ */
99
+ export function buildJevMenu(
100
+ paths: MenuPaths,
101
+ state: EditorState,
102
+ io: { out(text: string): void },
103
+ models: readonly ModelOption[] = [],
104
+ ) {
105
+ return defineMenu<EditorState, MenuScreenId, MenuActionId, ExtensionCommandContext>({
106
+ start: 'main',
107
+ screens: {
108
+ main: () => {
109
+ const config = configFromEnv(process.env, merged(paths));
110
+ const scopePath = state.scope === 'project' ? paths.projectPath : paths.globalPath;
111
+ return {
112
+ kind: 'actions',
113
+ title: 'pi-jev',
114
+ lines: [`Scope: ${state.scope} (${scopePath}) — 'toggle scope' switches it`],
115
+ items: [
116
+ {
117
+ id: 'toggleScope',
118
+ label: `Toggle scope (currently ${state.scope})`,
119
+ action: 'toggleScope',
120
+ },
121
+ {
122
+ id: 'general',
123
+ label: `General (provider ${config.provider})`,
124
+ to: 'general',
125
+ },
126
+ {
127
+ id: 'compaction',
128
+ label: 'Compaction',
129
+ to: 'compaction',
130
+ },
131
+ {
132
+ id: 'routing',
133
+ label: `Routing (${config.routing.cheap || config.routing.strong ? 'enabled' : 'disabled'})`,
134
+ to: 'routing',
135
+ },
136
+ { id: 'showResolved', label: 'Show resolved config', action: 'showResolved' },
137
+ ],
138
+ hint: 'close',
139
+ };
140
+ },
141
+ general: () => groupScreen('general', 'pi-jev — General', paths),
142
+ compaction: () => groupScreen('compaction', 'pi-jev — Compaction', paths),
143
+ routing: () => groupScreen('routing', 'pi-jev — Routing', paths),
144
+ chooseModel: () => {
145
+ const current = displayValue(keyMeta(state.editingKey)!, paths);
146
+ const items = [
147
+ ...(current !== 'unset' ? [{ id: '__unset__', label: '(unset)', description: 'clear this key' }] : []),
148
+ ...models.map((model) => ({
149
+ id: model.ref,
150
+ label: model.name,
151
+ description: `${model.provider}${model.image ? ' · images' : ''}${model.reasoning ? ' · thinking' : ''}${model.contextWindow ? ` · ${Math.round(model.contextWindow / 1000)}k ctx` : ''}`,
152
+ })),
153
+ ];
154
+ return {
155
+ kind: 'choice',
156
+ title: `pi-jev — ${state.editingKey}`,
157
+ lines: models.length === 0 ? ['No models found in pi (check provider auth).'] : [],
158
+ items,
159
+ action: 'pickModel',
160
+ enableSearch: true,
161
+ hint: 'back',
162
+ };
163
+ },
164
+ chooseThinking: () => {
165
+ const model = models.find((entry) => entry.modelRef === state.selectedModelRef);
166
+ return {
167
+ kind: 'choice',
168
+ title: `pi-jev — thinking level`,
169
+ lines: [`${state.selectedModelRef ?? ''} (thinking optional — default keeps the model's own)`],
170
+ items: [
171
+ { id: '__default__', label: 'default', description: "no override (model default)" },
172
+ ...(model?.reasoning !== false && model ? THINKING_LEVELS.map((level) => ({ id: level, label: level })) : []),
173
+ ],
174
+ action: 'pickThinking',
175
+ initialItemId: '__default__',
176
+ hint: 'back',
177
+ };
178
+ },
179
+ chooseValue: () => {
180
+ const options = VALUE_OPTIONS[state.editingKey] ?? [];
181
+ return {
182
+ kind: 'choice',
183
+ title: `pi-jev — ${state.editingKey}`,
184
+ items: options.map((option) => ({
185
+ id: option.label,
186
+ label: option.label,
187
+ description: option.description,
188
+ })),
189
+ action: 'setEnum',
190
+ hint: 'back',
191
+ };
192
+ },
193
+ inputValue: () => {
194
+ const meta = keyMeta(state.editingKey);
195
+ return {
196
+ kind: 'input',
197
+ title: `pi-jev — set ${state.editingKey}`,
198
+ lines: meta ? [meta.description] : [],
199
+ placeholder: '(new value)',
200
+ action: 'setValue',
201
+ hint: 'back',
202
+ };
203
+ },
204
+ },
205
+ actions: {
206
+ toggleScope: () => {
207
+ state.scope = state.scope === 'global' ? 'project' : 'global';
208
+ return { kind: 'stay' };
209
+ },
210
+ editKey: ({ itemId }) => {
211
+ if (!keyMeta(itemId)) return { kind: 'rejected', error: new Error(`unknown key "${itemId}"`) };
212
+ state.editingKey = itemId;
213
+ if (itemId === 'routing.cheap' || itemId === 'routing.strong') return { kind: 'to', screen: 'chooseModel' };
214
+ return VALUE_OPTIONS[itemId] ? { kind: 'to', screen: 'chooseValue' } : { kind: 'to', screen: 'inputValue' };
215
+ },
216
+ setEnum: ({ itemId }) => {
217
+ writeKey(paths, state.scope, state.editingKey, itemId);
218
+ return { kind: 'back' };
219
+ },
220
+ pickModel: ({ itemId }) => {
221
+ if (itemId === '__unset__') {
222
+ const targetPath = state.scope === 'project' ? paths.projectPath : paths.globalPath;
223
+ const file = readOrInit(targetPath);
224
+ const [section, key] = state.editingKey.split('.') as [string, string];
225
+ delete ((file as Record<string, Record<string, unknown>>)[section] ??= {})[key];
226
+ if (Object.keys((file as Record<string, Record<string, unknown>>)[section]).length === 0) {
227
+ delete (file as Record<string, unknown>)[section];
228
+ }
229
+ saveFile(targetPath, file);
230
+ return { kind: 'back' };
231
+ }
232
+ const model = models.find((entry) => entry.ref === itemId);
233
+ state.selectedModelRef = itemId;
234
+ state.selectedModelReasoning = model?.reasoning ?? false;
235
+ if (state.selectedModelReasoning) return { kind: 'to', screen: 'chooseThinking' };
236
+ writeKey(paths, state.scope, state.editingKey, itemId);
237
+ return { kind: 'back' };
238
+ },
239
+ pickThinking: ({ itemId }) => {
240
+ const ref = itemId === '__default__' ? state.selectedModelRef : `${state.selectedModelRef}:${itemId}`;
241
+ writeKey(paths, state.scope, state.editingKey, ref ?? '');
242
+ return { kind: 'back' };
243
+ },
244
+ setValue: ({ value }) => {
245
+ if (value === undefined || !value.trim()) return { kind: 'rejected', error: new Error('empty value') };
246
+ try {
247
+ writeKey(paths, state.scope, state.editingKey, value.trim());
248
+ } catch (error) {
249
+ return { kind: 'rejected', error };
250
+ }
251
+ return { kind: 'back' };
252
+ },
253
+ showResolved: () => {
254
+ const lines: string[] = [];
255
+ const global = readFileTolerant(paths.globalPath);
256
+ const project = readFileTolerant(paths.projectPath);
257
+ const config = configFromEnv(process.env, mergeConfigFiles(global, project));
258
+ for (const meta of CONFIG_KEYS) {
259
+ lines.push(`${meta.path} = ${valueAt(config, meta.path) ?? 'unset'}`);
260
+ }
261
+ io.out(lines.join('\n'));
262
+ return { kind: 'close' };
263
+ },
264
+ },
265
+ });
266
+ }
267
+
268
+ /** Opens the interactive menu; falls back to text output outside TUI mode. */
269
+ export async function openJevSettingsMenu(
270
+ ctx: ExtensionCommandContext,
271
+ paths: MenuPaths,
272
+ io: { out(text: string): void },
273
+ ): Promise<void> {
274
+ if (ctx.mode !== 'tui' || !ctx.hasUI) {
275
+ io.out('/jev menu requires an interactive pi UI; use `/jev set <key> <value>` instead');
276
+ return;
277
+ }
278
+ const controller = new AbortController();
279
+ const state: EditorState = { scope: 'global', editingKey: 'model' };
280
+ const { modelsFromContext } = await import('./models.js');
281
+ const menu = buildJevMenu(paths, state, io, modelsFromContext(ctx));
282
+ try {
283
+ await runMenu(ctx, menu, {
284
+ getState: () => state,
285
+ signal: controller.signal,
286
+ isCurrent: () => !controller.signal.aborted,
287
+ });
288
+ } finally {
289
+ controller.abort();
290
+ }
291
+ }
@@ -0,0 +1,43 @@
1
+ import type { Model } from '@earendil-works/pi-ai';
2
+
3
+ /** A model as shown in pickers and completions. */
4
+ export interface ModelOption {
5
+ /** pi-style reference: `provider/id`, optionally `:thinking` when pinned. */
6
+ ref: string;
7
+ /** Bare reference without a thinking suffix. */
8
+ modelRef: string;
9
+ name: string;
10
+ provider: string;
11
+ reasoning: boolean;
12
+ image: boolean;
13
+ contextWindow: number;
14
+ }
15
+
16
+ /** pi thinking levels, lowest to highest. */
17
+ export const THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const;
18
+
19
+ function fromModel(model: Model<never> | { id: string; name?: string; provider: string; reasoning?: boolean; input?: string[]; contextWindow?: number }): ModelOption {
20
+ const provider = String(model.provider);
21
+ const id = String(model.id);
22
+ return {
23
+ ref: `${provider}/${id}`,
24
+ modelRef: `${provider}/${id}`,
25
+ name: model.name ?? id,
26
+ provider,
27
+ reasoning: model.reasoning === true,
28
+ image: Array.isArray(model.input) ? model.input.includes('image') : false,
29
+ contextWindow: model.contextWindow ?? 0,
30
+ };
31
+ }
32
+
33
+ /** Models configured in pi: scoped models when set, otherwise the full available catalogue. */
34
+ export function modelsFromContext(ctx: {
35
+ scopedModels?: readonly { model: unknown }[];
36
+ modelRegistry?: { getAvailable?: () => readonly unknown[] };
37
+ }): ModelOption[] {
38
+ const scoped = ctx.scopedModels ?? [];
39
+ const raw = scoped.length > 0 ? scoped.map((entry) => entry.model) : (ctx.modelRegistry?.getAvailable?.() ?? []);
40
+ return raw
41
+ .map((model) => fromModel(model as never))
42
+ .sort((a, b) => (a.provider === b.provider ? a.name.localeCompare(b.name) : a.provider.localeCompare(b.provider)));
43
+ }
@@ -0,0 +1,95 @@
1
+ import type { AgentMessage } from '@earendil-works/pi-agent-core';
2
+ import type { Message, ToolResult } from '../vendor/fast-jev-compaction/index.js';
3
+
4
+ type Blocks = Array<{ type: string; text?: string }>;
5
+
6
+ /** Extracts text from a string-or-content-blocks payload; images become a note. */
7
+ function textOf(content: string | Blocks): string {
8
+ if (typeof content === 'string') return content;
9
+ return content
10
+ .map(block => (block.type === 'text' && typeof block.text === 'string' ? block.text : '[image]'))
11
+ .join('\n');
12
+ }
13
+
14
+ /**
15
+ * Converts the span pi wants to summarize (any `AgentMessage`) into the
16
+ * Claude-Code-shaped `Message[]` fast-jev-compaction works with:
17
+ *
18
+ * - `toolResult` messages merge into synthetic user messages (consecutive
19
+ * ones share one message), keeping their call order;
20
+ * - `bashExecution` messages become an assistant tool call (`bash`) paired
21
+ * with a user tool result, so Jev can drop their outputs like any other;
22
+ * - assistant thinking is dropped (transient, never needed verbatim later);
23
+ * - `compactionSummary` / `branchSummary` are skipped: the hook receives the
24
+ * previous summary separately (`previousSummary`);
25
+ * - images cannot be represented and render as `[image]`.
26
+ */
27
+ export function convertMessages(messages: readonly AgentMessage[]): Message[] {
28
+ const out: Message[] = [];
29
+ let pendingResults: ToolResult[] = [];
30
+
31
+ const flushResults = (): void => {
32
+ if (pendingResults.length > 0) {
33
+ out.push({ role: 'user', text: '', toolUses: [], toolResults: pendingResults });
34
+ pendingResults = [];
35
+ }
36
+ };
37
+
38
+ for (const message of messages) {
39
+ switch (message.role) {
40
+ case 'user': {
41
+ flushResults();
42
+ out.push({ role: 'user', text: textOf(message.content), toolUses: [], toolResults: [] });
43
+ break;
44
+ }
45
+ case 'assistant': {
46
+ flushResults();
47
+ const text = message.content
48
+ .filter((block): block is { type: 'text'; text: string } => block.type === 'text')
49
+ .map(block => block.text)
50
+ .join('\n');
51
+ const toolUses = message.content
52
+ .filter((block): block is { type: 'toolCall'; id: string; name: string; arguments: Record<string, unknown> } =>
53
+ block.type === 'toolCall',
54
+ )
55
+ .map(block => ({ tool_use_id: block.id, tool: block.name, input: block.arguments }));
56
+ out.push({ role: 'assistant', text, toolUses });
57
+ break;
58
+ }
59
+ case 'toolResult': {
60
+ pendingResults.push({
61
+ tool_use_id: message.toolCallId,
62
+ text: textOf(message.content),
63
+ isError: message.isError,
64
+ });
65
+ break;
66
+ }
67
+ case 'bashExecution': {
68
+ flushResults();
69
+ if (message.excludeFromContext) break;
70
+ const id = `bashexec_${message.timestamp}`;
71
+ out.push({
72
+ role: 'assistant',
73
+ text: '',
74
+ toolUses: [{ tool_use_id: id, tool: 'bash', input: { command: message.command } }],
75
+ });
76
+ pendingResults.push({
77
+ tool_use_id: id,
78
+ text: message.output,
79
+ isError: message.cancelled || (message.exitCode !== undefined && message.exitCode !== 0),
80
+ });
81
+ break;
82
+ }
83
+ case 'custom': {
84
+ flushResults();
85
+ out.push({ role: 'user', text: textOf(message.content), toolUses: [], toolResults: [] });
86
+ break;
87
+ }
88
+ default:
89
+ // compactionSummary / branchSummary arrive via previousSummary instead
90
+ break;
91
+ }
92
+ }
93
+ flushResults();
94
+ return out;
95
+ }
@@ -0,0 +1,262 @@
1
+ import {
2
+ estimateTokens,
3
+ type CallAction,
4
+ type JevAnswer,
5
+ type JevQuestions,
6
+ type Message,
7
+ type ToolCall,
8
+ } from '../vendor/fast-jev-compaction/index.js';
9
+
10
+ /** Ordered staleness levels for the per-result `score` question. */
11
+ const STALENESS_LEVELS = ['needed', 'probably needed', 'uncertain', 'stale', 'very stale'] as const;
12
+
13
+ /** Tokens the request envelope (`model`, key names) adds around state and questions. */
14
+ const REQUEST_OVERHEAD_TOKENS = 20;
15
+
16
+ /**
17
+ * The three questions asked about one tool call: two `noul` questions (keep
18
+ * the call, keep its result verbatim) and one `score` question whose
19
+ * confident answer can rescue a borderline result.
20
+ */
21
+ export function questionsFor(call: ToolCall): JevQuestions {
22
+ return {
23
+ [`call_${call.id}`]: {
24
+ type: 'noul',
25
+ 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`,
26
+ },
27
+ [`result_${call.id}`]: {
28
+ type: 'noul',
29
+ 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`,
30
+ },
31
+ [`staleness_${call.id}`]: {
32
+ type: 'score',
33
+ instructions: `Rate how stale the output of tool call ${call.id} (${call.tool}) is for the ongoing work: level 0 means its contents are still needed verbatim, level ${STALENESS_LEVELS.length - 1} means clearly obsolete`,
34
+ criteria: [...STALENESS_LEVELS],
35
+ },
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Splits candidate calls into batches whose questions, together with the
41
+ * (always complete) state, fit one Jev request.
42
+ */
43
+ export function batchCalls(
44
+ calls: readonly ToolCall[],
45
+ stateTokens: number,
46
+ maxRequestTokens: number,
47
+ ): ToolCall[][] {
48
+ const budget = maxRequestTokens - stateTokens - REQUEST_OVERHEAD_TOKENS;
49
+ const batches: ToolCall[][] = [];
50
+ let current: ToolCall[] = [];
51
+ let currentTokens = 0;
52
+ for (const call of calls) {
53
+ const tokens = estimateTokens(JSON.stringify(questionsFor(call)));
54
+ if (current.length > 0 && currentTokens + tokens > budget) {
55
+ batches.push(current);
56
+ current = [];
57
+ currentTokens = 0;
58
+ }
59
+ if (current.length === 0 && tokens > budget) {
60
+ throw new Error(
61
+ `state leaves no room for questions (~${stateTokens} of ${maxRequestTokens} tokens)`,
62
+ );
63
+ }
64
+ current.push(call);
65
+ currentTokens += tokens;
66
+ }
67
+ if (current.length > 0) batches.push(current);
68
+ return batches;
69
+ }
70
+
71
+ export interface DecisionOutcome {
72
+ id: string;
73
+ tool: string;
74
+ keepCall: number;
75
+ keepResult: number;
76
+ action: CallAction;
77
+ reason: 'pinned' | 'kept' | 'result_dropped' | 'call_dropped';
78
+ pinned: boolean;
79
+ /** Kept only because of a confident low-staleness score. */
80
+ guarded: boolean;
81
+ /** Answers missing or malformed; kept conservatively. */
82
+ missing: boolean;
83
+ }
84
+
85
+ function noulOr(answers: Record<string, JevAnswer>, name: string): number | undefined {
86
+ const answer = answers[name];
87
+ if (
88
+ answer === null ||
89
+ typeof answer !== 'object' ||
90
+ !('noul' in answer) ||
91
+ typeof (answer as { noul?: unknown }).noul !== 'number' ||
92
+ !Number.isFinite((answer as { noul: number }).noul)
93
+ ) {
94
+ return undefined;
95
+ }
96
+ return (answer as { noul: number }).noul;
97
+ }
98
+
99
+ function scoreAnswer(
100
+ answers: Record<string, JevAnswer>,
101
+ name: string,
102
+ ): { score: number; confidence: number } | undefined {
103
+ const answer = answers[name];
104
+ if (
105
+ answer === null ||
106
+ typeof answer !== 'object' ||
107
+ !('score' in answer) ||
108
+ typeof (answer as { score?: unknown }).score !== 'number' ||
109
+ !Number.isFinite((answer as { score: number }).score)
110
+ ) {
111
+ return undefined;
112
+ }
113
+ const confidence =
114
+ 'confidence' in answer && typeof (answer as { confidence?: unknown }).confidence === 'number'
115
+ ? (answer as { confidence: number }).confidence
116
+ : 0;
117
+ return { score: (answer as { score: number }).score, confidence };
118
+ }
119
+
120
+ /** Jev scores arrive either in [0, 1] or spread across the criteria levels. */
121
+ function normalizedStaleness(score: number): number {
122
+ return score <= 1 ? score : score / (STALENESS_LEVELS.length - 1);
123
+ }
124
+
125
+ export interface DecideOptions {
126
+ keepThreshold: number;
127
+ borderline: number;
128
+ }
129
+
130
+ /**
131
+ * Decides one call from Jev's answers: keep, truncate the result, or drop the
132
+ * call. A result whose keep probability falls just under the threshold is
133
+ * rescued when the staleness score is confidently low. Missing or malformed
134
+ * answers keep the call (conservative).
135
+ */
136
+ export function decideCall(
137
+ call: Pick<ToolCall, 'id' | 'tool' | 'pinned'>,
138
+ answers: Record<string, JevAnswer>,
139
+ options: DecideOptions,
140
+ ): DecisionOutcome {
141
+ if (call.pinned) {
142
+ return {
143
+ id: call.id,
144
+ tool: call.tool,
145
+ keepCall: 1,
146
+ keepResult: 1,
147
+ action: 'keep',
148
+ reason: 'pinned',
149
+ pinned: true,
150
+ guarded: false,
151
+ missing: false,
152
+ };
153
+ }
154
+
155
+ const keepCall = noulOr(answers, `call_${call.id}`);
156
+ const keepResult = noulOr(answers, `result_${call.id}`);
157
+ if (keepCall === undefined || keepResult === undefined) {
158
+ return {
159
+ id: call.id,
160
+ tool: call.tool,
161
+ keepCall: keepCall ?? 1,
162
+ keepResult: keepResult ?? 1,
163
+ action: 'keep',
164
+ reason: 'kept',
165
+ pinned: false,
166
+ guarded: false,
167
+ missing: true,
168
+ };
169
+ }
170
+
171
+ const base = { id: call.id, tool: call.tool, keepCall, keepResult, pinned: false, missing: false };
172
+
173
+ if (keepResult >= options.keepThreshold) {
174
+ return { ...base, action: 'keep', reason: 'kept', guarded: false };
175
+ }
176
+
177
+ const staleness = scoreAnswer(answers, `staleness_${call.id}`);
178
+ if (
179
+ keepResult >= options.keepThreshold - options.borderline &&
180
+ staleness !== undefined &&
181
+ normalizedStaleness(staleness.score) <= 0.25 &&
182
+ staleness.confidence >= 0.5
183
+ ) {
184
+ return { ...base, action: 'keep', reason: 'kept', guarded: true };
185
+ }
186
+
187
+ if (keepCall >= options.keepThreshold) {
188
+ return { ...base, action: 'drop_result', reason: 'result_dropped', guarded: false };
189
+ }
190
+ return { ...base, action: 'drop_call', reason: 'call_dropped', guarded: false };
191
+ }
192
+
193
+ function truncatedResultText(text: string, isError: boolean, headChars: number): string {
194
+ if (text.length <= headChars + 120) return text;
195
+ const head = headChars > 0 ? `${text.slice(0, headChars)}\n` : '';
196
+ return `${head}[jev-compaction truncated ${text.length - headChars} chars of this tool result${
197
+ isError ? ' (error)' : ''
198
+ }; re-run the tool if needed]`;
199
+ }
200
+
201
+ /**
202
+ * Rebuilds the conversation from the decisions. A dropped call disappears
203
+ * together with its result; a dropped result keeps a bounded head and a note.
204
+ * Messages that lose all their content are removed; untouched messages are
205
+ * returned as the same objects they came in as.
206
+ */
207
+ export function applyJevDecisions(
208
+ messages: readonly Message[],
209
+ decisions: readonly DecisionOutcome[],
210
+ calls: readonly ToolCall[],
211
+ headChars: number,
212
+ ): Message[] {
213
+ const byId = new Map(calls.map(call => [call.id, call]));
214
+ const actionFor = new Map<string, CallAction>();
215
+ for (const decision of decisions) {
216
+ const call = byId.get(decision.id);
217
+ if (call && decision.action !== 'keep') actionFor.set(call.tool_use_id, decision.action);
218
+ }
219
+
220
+ const kept: Message[] = [];
221
+ for (const message of messages) {
222
+ const touched =
223
+ message.toolUses.some(tool => actionFor.has(tool.tool_use_id)) ||
224
+ (message.toolResults ?? []).some(result => actionFor.has(result.tool_use_id));
225
+ if (!touched) {
226
+ kept.push(message);
227
+ continue;
228
+ }
229
+
230
+ const toolUses = message.toolUses
231
+ .filter(tool => actionFor.get(tool.tool_use_id) !== 'drop_call')
232
+ .map(tool => {
233
+ if (actionFor.get(tool.tool_use_id) !== 'drop_result' || tool.text === undefined) return tool;
234
+ const text = truncatedResultText(tool.text, tool.isError ?? false, headChars);
235
+ return text === tool.text ? tool : { ...tool, text };
236
+ });
237
+ const toolResults = (message.toolResults ?? [])
238
+ .filter(result => actionFor.get(result.tool_use_id) !== 'drop_call')
239
+ .map(result => {
240
+ if (actionFor.get(result.tool_use_id) !== 'drop_result') return result;
241
+ const text = truncatedResultText(result.text, result.isError ?? false, headChars);
242
+ return text === result.text ? result : { ...result, text };
243
+ });
244
+
245
+ const unchanged =
246
+ toolUses.length === message.toolUses.length &&
247
+ toolUses.every((tool, index) => tool === message.toolUses[index]) &&
248
+ toolResults.length === (message.toolResults ?? []).length &&
249
+ toolResults.every((result, index) => result === message.toolResults?.[index]);
250
+ if (unchanged) {
251
+ kept.push(message);
252
+ continue;
253
+ }
254
+ if (message.text.trim().length === 0 && toolUses.length === 0 && toolResults.length === 0) {
255
+ continue;
256
+ }
257
+ const rebuilt: Message = { role: message.role, text: message.text, toolUses };
258
+ if (toolResults.length > 0) rebuilt.toolResults = toolResults;
259
+ kept.push(rebuilt);
260
+ }
261
+ return kept;
262
+ }