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