@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,280 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { DEFAULT_MODEL, SYSTEM_ONE_URL } from '../vendor/fast-jev-compaction/index.js';
5
+
6
+ /**
7
+ * Shared configuration for the pi-jev extension suite. Values resolve from
8
+ * layered sources, highest first: environment variables (prefix `JEVC_`), a
9
+ * project file (`.pi/jev.json`), a global file (`~/.pi/agent/jev.json`), then
10
+ * defaults. Each extension in `extensions/` reads the parts it needs;
11
+ * enabling/disabling an extension itself is done with `pi config`, not env
12
+ * flags.
13
+ *
14
+ * The `pi-jev config` CLI writes those files; API keys stay env-only.
15
+ *
16
+ * Jev can be reached through two transports with identical request/response
17
+ * shapes (`{ model, state, questions }` → `{ answers }`):
18
+ *
19
+ * - `typesafe` — the TypeSafe System One endpoint (`TYPESAFE_API_KEY`)
20
+ * - `openrouter` — OpenRouter's Decisions API (`OPENROUTER_API_KEY`), which
21
+ * forwards to TypeSafe directly
22
+ */
23
+
24
+ export type JevProvider = 'typesafe' | 'openrouter';
25
+
26
+ /** OpenRouter Decisions API (alpha). */
27
+ export const OPENROUTER_DECISIONS_URL = 'https://openrouter.ai/api/alpha/decisions';
28
+ /** Default Jev model slug on OpenRouter (versioned; override with `JEVC_MODEL`). */
29
+ export const OPENROUTER_JEV_MODEL = 'typesafe/jev-1.13';
30
+
31
+ /** Compaction feature options (consumed by src/compaction/*). */
32
+ export interface JevCompactionConfig {
33
+ /** Transport selected via `JEVC_PROVIDER` or auto-detected from keys. */
34
+ provider: JevProvider;
35
+ /** API key for the selected transport (from `JEVC_API_KEY` or the transport's own variable). */
36
+ apiKey: string;
37
+ /** Resolved Jev model slug (already provider-specific). */
38
+ model: string;
39
+ /** Resolved endpoint URL. */
40
+ baseUrl: string;
41
+ /** Minimum keep probability for a call or result to stay verbatim. */
42
+ keepThreshold: number;
43
+ /** Band under `keepThreshold` where a confident low-staleness score still keeps a result. */
44
+ borderline: number;
45
+ /** Newest messages within the summarized span that are never touched. */
46
+ preserveRecentMessages: number;
47
+ /** Characters of a dropped tool result retained before its note. */
48
+ truncateHeadChars: number;
49
+ /** Minimum estimated span reduction, or the feature falls back to default compaction. */
50
+ minReduction: number;
51
+ /** Estimated token ceiling for the Jev state. */
52
+ maxStateTokens: number;
53
+ /** Estimated ceiling for state plus one batch of questions. */
54
+ maxRequestTokens: number;
55
+ /** Set via `JEVC_DISABLED` to bypass the hooks entirely (all features). */
56
+ disabled: boolean;
57
+ }
58
+
59
+ /** Model-routing feature options (consumed by src/routing/*). */
60
+ export interface RoutingConfig {
61
+ /** Easy-request target, `"provider/model-id"`. Routing is enabled when cheap or strong is set. */
62
+ cheap: string | undefined;
63
+ /** Hard-request target, `"provider/model-id"`; optional. */
64
+ strong: string | undefined;
65
+ /** Difficulty level (0..2) at or below which the cheap model is used. */
66
+ easyMax: number;
67
+ /** Difficulty level (0..2) at or above which the strong model is used. */
68
+ hardMin: number;
69
+ /** Minimum Jev confidence to act on a decision. */
70
+ minConfidence: number;
71
+ }
72
+
73
+ export interface JevConfig extends JevCompactionConfig {
74
+ routing: RoutingConfig;
75
+ }
76
+
77
+ const DEFAULTS = {
78
+ keepThreshold: 0.5,
79
+ borderline: 0.1,
80
+ /** Small: pi already excludes the newest ~20k tokens from the span. */
81
+ preserveRecentMessages: 3,
82
+ truncateHeadChars: 300,
83
+ minReduction: 0.15,
84
+ maxStateTokens: 25_000,
85
+ maxRequestTokens: 30_000,
86
+ easyMax: 0.5,
87
+ hardMin: 1.5,
88
+ minConfidence: 0.6,
89
+ };
90
+
91
+ function number(env: Record<string, string | undefined>, key: string, fallback: number): number {
92
+ const raw = env[key];
93
+ if (raw === undefined || raw.trim() === '') return fallback;
94
+ const value = Number(raw);
95
+ return Number.isFinite(value) ? value : fallback;
96
+ }
97
+
98
+ function flag(env: Record<string, string | undefined>, key: string): boolean {
99
+ return /^(1|true|yes)$/i.test(env[key] ?? '');
100
+ }
101
+
102
+ /** Boolean with source precedence: an explicitly set env value (even `0`) wins over the file. */
103
+ function layeredFlag(env: Record<string, string | undefined>, key: string, fileValue: boolean | undefined): boolean {
104
+ const raw = env[key]?.trim();
105
+ if (raw) return /^(1|true|yes)$/i.test(raw);
106
+ return fileValue ?? false;
107
+ }
108
+
109
+ /**
110
+ * Picks the transport: explicit `JEVC_PROVIDER` wins; otherwise TypeSafe when
111
+ * its key is present, OpenRouter when only `OPENROUTER_API_KEY` is present,
112
+ * and `typesafe` otherwise. `JEVC_API_KEY` alone does not influence the
113
+ * choice (it could belong to either transport); set `JEVC_PROVIDER` to use it
114
+ * with OpenRouter.
115
+ */
116
+ function resolveProvider(env: Record<string, string | undefined>): JevProvider {
117
+ const explicit = env.JEVC_PROVIDER?.trim().toLowerCase();
118
+ if (explicit === 'typesafe' || explicit === 'openrouter') return explicit;
119
+ if ((env.TYPESAFE_API_KEY ?? '').trim()) return 'typesafe';
120
+ if ((env.OPENROUTER_API_KEY ?? '').trim()) return 'openrouter';
121
+ return 'typesafe';
122
+ }
123
+
124
+ function keyFor(provider: JevProvider, env: Record<string, string | undefined>): string {
125
+ const own = provider === 'openrouter' ? env.OPENROUTER_API_KEY : env.TYPESAFE_API_KEY;
126
+ return env.JEVC_API_KEY?.trim() || own?.trim() || '';
127
+ }
128
+
129
+ /** File-based configuration layer; every field is optional. API keys never live here. */
130
+ export interface JevFileConfig {
131
+ provider?: JevProvider;
132
+ model?: string;
133
+ baseUrl?: string;
134
+ disabled?: boolean;
135
+ routing?: {
136
+ cheap?: string;
137
+ strong?: string;
138
+ easyMax?: number;
139
+ hardMin?: number;
140
+ minConfidence?: number;
141
+ };
142
+ compaction?: {
143
+ keepThreshold?: number;
144
+ borderline?: number;
145
+ preserveRecentMessages?: number;
146
+ truncateHeadChars?: number;
147
+ minReduction?: number;
148
+ maxStateTokens?: number;
149
+ maxRequestTokens?: number;
150
+ };
151
+ }
152
+
153
+ /** Metadata for one configurable key; drives the `pi-jev config` CLI. */
154
+ export interface ConfigKeyMeta {
155
+ path: string;
156
+ type: 'string' | 'number' | 'boolean';
157
+ env?: string;
158
+ default?: string | number | boolean;
159
+ description: string;
160
+ }
161
+
162
+ /**
163
+ * All user-facing keys. `apiKey` is deliberately absent (env-only), and the
164
+ * routing thresholds (easyMax/hardMin/minConfidence) stay internal defaults —
165
+ * still tunable through `JEVC_ROUTE_*` env vars, but not exposed here.
166
+ */
167
+ export const CONFIG_KEYS: readonly ConfigKeyMeta[] = [
168
+ { path: 'provider', type: 'string', env: 'JEVC_PROVIDER', description: 'Jev transport: typesafe or openrouter' },
169
+ { path: 'model', type: 'string', env: 'JEVC_MODEL', description: 'Jev model slug (e.g. typesafe/jev-1.13 on OpenRouter)' },
170
+ { path: 'baseUrl', type: 'string', env: 'JEVC_BASE_URL', description: 'Jev endpoint URL' },
171
+ { path: 'disabled', type: 'boolean', env: 'JEVC_DISABLED', default: false, description: 'Bypass all pi-jev hooks' },
172
+ { path: 'routing.cheap', type: 'string', env: 'JEVC_ROUTE_CHEAP', description: '"provider/model-id" for easy requests (enables routing)' },
173
+ { path: 'routing.strong', type: 'string', env: 'JEVC_ROUTE_STRONG', description: '"provider/model-id" for hard requests (optional)' },
174
+ { path: 'compaction.keepThreshold', type: 'number', env: 'JEVC_KEEP_THRESHOLD', default: DEFAULTS.keepThreshold, description: 'Minimum keep probability for verbatim retention' },
175
+ { path: 'compaction.borderline', type: 'number', env: 'JEVC_BORDERLINE', default: DEFAULTS.borderline, description: 'Band where a confident low-staleness score still keeps a result' },
176
+ { path: 'compaction.preserveRecentMessages', type: 'number', env: 'JEVC_PRESERVE_RECENT', default: DEFAULTS.preserveRecentMessages, description: 'Newest messages within the summarized span that are never touched' },
177
+ { path: 'compaction.truncateHeadChars', type: 'number', env: 'JEVC_TRUNCATE_HEAD', default: DEFAULTS.truncateHeadChars, description: 'Characters of a dropped tool result retained before its note' },
178
+ { path: 'compaction.minReduction', type: 'number', env: 'JEVC_MIN_REDUCTION', default: DEFAULTS.minReduction, description: 'Minimum estimated span reduction, else default compaction' },
179
+ { path: 'compaction.maxStateTokens', type: 'number', env: 'JEVC_MAX_STATE_TOKENS', default: DEFAULTS.maxStateTokens, description: 'Estimated token ceiling for the Jev state' },
180
+ { path: 'compaction.maxRequestTokens', type: 'number', env: 'JEVC_MAX_REQUEST_TOKENS', default: DEFAULTS.maxRequestTokens, description: 'Estimated ceiling for state plus one batch of questions' },
181
+ ];
182
+
183
+ /** Default config file locations, mirroring pi's own settings layout. */
184
+ export function defaultConfigPaths(): { globalPath: string; projectPath: string } {
185
+ return {
186
+ globalPath: join(homedir(), '.pi', 'agent', 'jev.json'),
187
+ projectPath: join(process.cwd(), '.pi', 'jev.json'),
188
+ };
189
+ }
190
+
191
+ /** Reads and parses one config file. Throws on missing or malformed JSON. */
192
+ export function readConfigFile(path: string): JevFileConfig {
193
+ const raw = readFileSync(path, 'utf8');
194
+ const parsed: unknown = JSON.parse(raw);
195
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
196
+ throw new Error(`config at ${path} must be a JSON object`);
197
+ }
198
+ return parsed as JevFileConfig;
199
+ }
200
+
201
+ function readTolerant(path: string, scope: string, warn: (message: string) => void): JevFileConfig | undefined {
202
+ try {
203
+ return readConfigFile(path);
204
+ } catch (error) {
205
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
206
+ warn(`pi-jev: ignoring ${scope} config at ${path} (${(error as Error).message})`);
207
+ return undefined;
208
+ }
209
+ }
210
+
211
+ export function mergeConfigFiles(global?: JevFileConfig, project?: JevFileConfig): JevFileConfig | undefined {
212
+ if (!global) return project;
213
+ if (!project) return global;
214
+ return {
215
+ ...global,
216
+ ...project,
217
+ routing: { ...global.routing, ...project.routing },
218
+ compaction: { ...global.compaction, ...project.compaction },
219
+ };
220
+ }
221
+
222
+ /** Resolves the full config: env > project file > global file > defaults. */
223
+ export function configFromEnv(
224
+ env: Record<string, string | undefined> = process.env,
225
+ file?: JevFileConfig,
226
+ ): JevConfig {
227
+ const explicit = env.JEVC_PROVIDER?.trim().toLowerCase();
228
+ const provider: JevProvider = explicit === 'typesafe' || explicit === 'openrouter'
229
+ ? explicit
230
+ : file?.provider === 'typesafe' || file?.provider === 'openrouter'
231
+ ? file.provider
232
+ : resolveProvider(env);
233
+ return {
234
+ provider,
235
+ apiKey: keyFor(provider, env),
236
+ model: env.JEVC_MODEL?.trim() || file?.model?.trim()
237
+ || (provider === 'openrouter' ? OPENROUTER_JEV_MODEL : DEFAULT_MODEL),
238
+ baseUrl: env.JEVC_BASE_URL?.trim() || file?.baseUrl?.trim()
239
+ || (provider === 'openrouter' ? OPENROUTER_DECISIONS_URL : SYSTEM_ONE_URL),
240
+ keepThreshold: number(env, 'JEVC_KEEP_THRESHOLD', file?.compaction?.keepThreshold ?? DEFAULTS.keepThreshold),
241
+ borderline: number(env, 'JEVC_BORDERLINE', file?.compaction?.borderline ?? DEFAULTS.borderline),
242
+ preserveRecentMessages: Math.max(
243
+ 0,
244
+ Math.floor(number(env, 'JEVC_PRESERVE_RECENT', file?.compaction?.preserveRecentMessages ?? DEFAULTS.preserveRecentMessages)),
245
+ ),
246
+ truncateHeadChars: Math.max(
247
+ 0,
248
+ Math.floor(number(env, 'JEVC_TRUNCATE_HEAD', file?.compaction?.truncateHeadChars ?? DEFAULTS.truncateHeadChars)),
249
+ ),
250
+ minReduction: number(env, 'JEVC_MIN_REDUCTION', file?.compaction?.minReduction ?? DEFAULTS.minReduction),
251
+ maxStateTokens: Math.max(1, number(env, 'JEVC_MAX_STATE_TOKENS', file?.compaction?.maxStateTokens ?? DEFAULTS.maxStateTokens)),
252
+ maxRequestTokens: Math.max(1, number(env, 'JEVC_MAX_REQUEST_TOKENS', file?.compaction?.maxRequestTokens ?? DEFAULTS.maxRequestTokens)),
253
+ disabled: layeredFlag(env, 'JEVC_DISABLED', file?.disabled),
254
+ routing: {
255
+ cheap: env.JEVC_ROUTE_CHEAP?.trim() || file?.routing?.cheap?.trim() || undefined,
256
+ strong: env.JEVC_ROUTE_STRONG?.trim() || file?.routing?.strong?.trim() || undefined,
257
+ easyMax: number(env, 'JEVC_ROUTE_EASY_MAX', file?.routing?.easyMax ?? DEFAULTS.easyMax),
258
+ hardMin: number(env, 'JEVC_ROUTE_HARD_MIN', file?.routing?.hardMin ?? DEFAULTS.hardMin),
259
+ minConfidence: number(env, 'JEVC_ROUTE_MIN_CONFIDENCE', file?.routing?.minConfidence ?? DEFAULTS.minConfidence),
260
+ },
261
+ };
262
+ }
263
+
264
+ /** Loads config files and env into a full `JevConfig`. Malformed files warn and are skipped. */
265
+ export function loadConfig(
266
+ opts: {
267
+ env?: Record<string, string | undefined>;
268
+ globalPath?: string;
269
+ projectPath?: string;
270
+ warn?: (message: string) => void;
271
+ } = {},
272
+ ): JevConfig {
273
+ const paths = { ...defaultConfigPaths(), ...opts };
274
+ const warn = opts.warn ?? ((message: string) => console.warn(message));
275
+ const file = mergeConfigFiles(
276
+ readTolerant(paths.globalPath, 'global', warn),
277
+ readTolerant(paths.projectPath, 'project', warn),
278
+ );
279
+ return configFromEnv(opts.env ?? process.env, file);
280
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,43 @@
1
+ import { buildJevRequest, parseJevResponse } from './request.js';
2
+ import type { JevAsker, JevQuestions, JevResponse, JevState } from './types.js';
3
+
4
+ export interface JevClientOptions {
5
+ /** Defaults to `process.env.TYPESAFE_API_KEY`. */
6
+ apiKey?: string;
7
+ /** Defaults to `jev-latest`. */
8
+ model?: string;
9
+ /** Defaults to the System One endpoint. */
10
+ baseUrl?: string;
11
+ /** Defaults to the global `fetch`. */
12
+ fetch?: typeof fetch;
13
+ }
14
+
15
+ /** Asks Jev over HTTP with the global `fetch` (or an injected one). */
16
+ export class JevClient implements JevAsker {
17
+ private readonly apiKey: string;
18
+ private readonly model: string | undefined;
19
+ private readonly baseUrl: string | undefined;
20
+ private readonly fetcher: typeof fetch;
21
+
22
+ constructor(options: JevClientOptions = {}) {
23
+ this.apiKey = options.apiKey ?? process.env.TYPESAFE_API_KEY ?? '';
24
+ this.model = options.model;
25
+ this.baseUrl = options.baseUrl;
26
+ this.fetcher = options.fetch ?? fetch;
27
+ }
28
+
29
+ async ask(state: JevState, questions: JevQuestions): Promise<JevResponse> {
30
+ if (!this.apiKey) throw new Error('TYPESAFE_API_KEY is not configured');
31
+ const request = buildJevRequest(
32
+ { apiKey: this.apiKey, model: this.model, baseUrl: this.baseUrl },
33
+ state,
34
+ questions,
35
+ );
36
+ const response = await this.fetcher(request.url, {
37
+ method: request.method,
38
+ headers: request.headers,
39
+ body: request.body,
40
+ });
41
+ return parseJevResponse(response.status, response.ok, await response.text());
42
+ }
43
+ }
@@ -0,0 +1,309 @@
1
+ import { noulAnswer } from './request.js';
2
+ import { collectToolCalls, estimateTokens, fitState } from './state.js';
3
+ import type {
4
+ CallAnswer,
5
+ CallDecision,
6
+ CompactOptions,
7
+ CompactResult,
8
+ CompactionState,
9
+ JevAsker,
10
+ JevQuestions,
11
+ Message,
12
+ ResolvedCompactOptions,
13
+ ToolCall,
14
+ ToolUse,
15
+ } from './types.js';
16
+
17
+ export const DEFAULT_OPTIONS: ResolvedCompactOptions = {
18
+ goal: '',
19
+ keepThreshold: 0.5,
20
+ preserveRecentMessages: 6,
21
+ maxStateTokens: 25_000,
22
+ maxRequestTokens: 30_000,
23
+ truncateHeadChars: 300,
24
+ };
25
+
26
+ /** Tokens the request envelope (`model`, key names) adds around state and questions. */
27
+ const REQUEST_OVERHEAD_TOKENS = 20;
28
+
29
+ function finite(value: number | undefined, fallback: number): number {
30
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
31
+ }
32
+
33
+ export function resolveOptions(options: CompactOptions = {}): ResolvedCompactOptions {
34
+ return {
35
+ goal: options.goal ?? DEFAULT_OPTIONS.goal,
36
+ keepThreshold: finite(options.keepThreshold, DEFAULT_OPTIONS.keepThreshold),
37
+ preserveRecentMessages: Math.max(
38
+ 0,
39
+ Math.floor(
40
+ finite(options.preserveRecentMessages, DEFAULT_OPTIONS.preserveRecentMessages),
41
+ ),
42
+ ),
43
+ maxStateTokens: Math.max(1, finite(options.maxStateTokens, DEFAULT_OPTIONS.maxStateTokens)),
44
+ maxRequestTokens: Math.max(
45
+ 1,
46
+ finite(options.maxRequestTokens, DEFAULT_OPTIONS.maxRequestTokens),
47
+ ),
48
+ truncateHeadChars: Math.max(
49
+ 0,
50
+ Math.floor(finite(options.truncateHeadChars, DEFAULT_OPTIONS.truncateHeadChars)),
51
+ ),
52
+ };
53
+ }
54
+
55
+ /** The two `noul` questions asked about one call: keep the call, keep its result. */
56
+ export function questionsFor(call: ToolCall): JevQuestions {
57
+ return {
58
+ [`call_${call.id}`]: {
59
+ type: 'noul',
60
+ 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`,
61
+ },
62
+ [`result_${call.id}`]: {
63
+ type: 'noul',
64
+ 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`,
65
+ },
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Splits the candidate calls into batches whose questions, together with the
71
+ * (always complete) state, fit one request.
72
+ */
73
+ export function batchCalls(
74
+ calls: readonly ToolCall[],
75
+ stateTokens: number,
76
+ options: Pick<ResolvedCompactOptions, 'maxRequestTokens'>,
77
+ ): ToolCall[][] {
78
+ const budget = options.maxRequestTokens - stateTokens - REQUEST_OVERHEAD_TOKENS;
79
+ const batches: ToolCall[][] = [];
80
+ let current: ToolCall[] = [];
81
+ let currentTokens = 0;
82
+ for (const call of calls) {
83
+ const tokens = estimateTokens(JSON.stringify(questionsFor(call)));
84
+ if (current.length > 0 && currentTokens + tokens > budget) {
85
+ batches.push(current);
86
+ current = [];
87
+ currentTokens = 0;
88
+ }
89
+ if (current.length === 0 && tokens > budget) {
90
+ throw new Error(
91
+ `state leaves no room for questions (~${stateTokens} of ${options.maxRequestTokens} tokens)`,
92
+ );
93
+ }
94
+ current.push(call);
95
+ currentTokens += tokens;
96
+ }
97
+ if (current.length > 0) batches.push(current);
98
+ return batches;
99
+ }
100
+
101
+ export function decideCall(
102
+ call: Pick<ToolCall, 'id' | 'tool' | 'pinned'>,
103
+ answer: CallAnswer,
104
+ options: Pick<ResolvedCompactOptions, 'keepThreshold'>,
105
+ ): CallDecision {
106
+ const base = { id: call.id, tool: call.tool, ...answer };
107
+ if (call.pinned) return { ...base, action: 'keep', reason: 'pinned' };
108
+ if (answer.keepResult >= options.keepThreshold) {
109
+ return { ...base, action: 'keep', reason: 'kept' };
110
+ }
111
+ if (answer.keepCall >= options.keepThreshold) {
112
+ return { ...base, action: 'drop_result', reason: 'result_dropped' };
113
+ }
114
+ return { ...base, action: 'drop_call', reason: 'call_dropped' };
115
+ }
116
+
117
+ async function askBatch(
118
+ asker: JevAsker,
119
+ state: CompactionState,
120
+ batch: readonly ToolCall[],
121
+ ): Promise<Map<string, CallAnswer>> {
122
+ const questions: JevQuestions = Object.assign({}, ...batch.map(questionsFor));
123
+ const { answers } = await asker.ask(state, questions);
124
+ return new Map(
125
+ batch.map((call) => [
126
+ call.id,
127
+ {
128
+ keepCall: noulAnswer(answers, `call_${call.id}`),
129
+ keepResult: noulAnswer(answers, `result_${call.id}`),
130
+ },
131
+ ]),
132
+ );
133
+ }
134
+
135
+ function truncatedResultText(text: string, isError: boolean, headChars: number): string {
136
+ if (text.length <= headChars + 120) return text;
137
+ const head = headChars > 0 ? `${text.slice(0, headChars)}\n` : '';
138
+ return `${head}[fast-jev-compaction truncated ${text.length - headChars} chars of this tool result${
139
+ isError ? ' (error)' : ''
140
+ }; re-run the tool if needed]`;
141
+ }
142
+
143
+ /**
144
+ * Rebuilds the conversation from the decisions. A dropped call disappears
145
+ * together with its result; a dropped result keeps a bounded head and note.
146
+ * Messages that lose all their content are removed; untouched messages are
147
+ * returned as the same objects they came in as.
148
+ */
149
+ export function applyDecisions(
150
+ messages: readonly Message[],
151
+ decisions: readonly CallDecision[],
152
+ calls: readonly ToolCall[],
153
+ headChars: number,
154
+ ): Message[] {
155
+ const byId = new Map(calls.map((call) => [call.id, call]));
156
+ const actions = new Map<string, CallDecision['action']>();
157
+ for (const decision of decisions) {
158
+ const call = byId.get(decision.id);
159
+ if (call && decision.action !== 'keep') actions.set(call.tool_use_id, decision.action);
160
+ }
161
+ const kept: Message[] = [];
162
+ for (const message of messages) {
163
+ const touched =
164
+ message.toolUses.some((tool) => actions.has(tool.tool_use_id)) ||
165
+ (message.toolResults ?? []).some((result) => actions.has(result.tool_use_id));
166
+ if (!touched) {
167
+ kept.push(message);
168
+ continue;
169
+ }
170
+ const toolUses = message.toolUses
171
+ .filter((tool) => actions.get(tool.tool_use_id) !== 'drop_call')
172
+ .map((tool) => {
173
+ if (actions.get(tool.tool_use_id) !== 'drop_result') return tool;
174
+ const text = truncatedResultText(
175
+ tool.text ?? '',
176
+ tool.isError ?? false,
177
+ headChars,
178
+ );
179
+ if ((tool.text ?? '') === text) return tool;
180
+ const copy: ToolUse = {
181
+ tool_use_id: tool.tool_use_id,
182
+ tool: tool.tool,
183
+ input: tool.input,
184
+ text,
185
+ };
186
+ if (tool.isError) copy.isError = true;
187
+ return copy;
188
+ });
189
+ const toolResults = (message.toolResults ?? [])
190
+ .filter((result) => actions.get(result.tool_use_id) !== 'drop_call')
191
+ .map((result) => {
192
+ if (actions.get(result.tool_use_id) !== 'drop_result') return result;
193
+ const text = truncatedResultText(result.text, result.isError ?? false, headChars);
194
+ return text === result.text
195
+ ? result
196
+ : {
197
+ tool_use_id: result.tool_use_id,
198
+ text,
199
+ isError: result.isError,
200
+ };
201
+ });
202
+ if (
203
+ !message.toolUses.some(
204
+ (tool) => actions.get(tool.tool_use_id) === 'drop_call',
205
+ ) &&
206
+ !(message.toolResults ?? []).some(
207
+ (result) => actions.get(result.tool_use_id) === 'drop_call',
208
+ ) &&
209
+ toolUses.every((tool, index) => tool === message.toolUses[index]) &&
210
+ toolResults.every(
211
+ (result, index) => result === message.toolResults?.[index],
212
+ )
213
+ ) {
214
+ kept.push(message);
215
+ continue;
216
+ }
217
+ if (message.text.trim().length === 0 && toolUses.length === 0 && toolResults.length === 0) {
218
+ continue;
219
+ }
220
+ const rebuilt: Message = { role: message.role, text: message.text, toolUses };
221
+ if (toolResults.length > 0) rebuilt.toolResults = toolResults;
222
+ kept.push(rebuilt);
223
+ }
224
+ return kept;
225
+ }
226
+
227
+ /** Characters of text, tool input and tool output a message holds. */
228
+ export function messageChars(message: Message): number {
229
+ let total = message.text.length;
230
+ for (const tool of message.toolUses) {
231
+ try {
232
+ total += JSON.stringify(tool.input).length;
233
+ } catch {
234
+ total += 20;
235
+ }
236
+ }
237
+ for (const result of message.toolResults ?? []) total += result.text.length;
238
+ return total;
239
+ }
240
+
241
+ export function reductionRatio(result: Pick<CompactResult, 'stats'>): number {
242
+ const { charsBefore, charsAfter } = result.stats;
243
+ return charsBefore === 0 ? 0 : (charsBefore - charsAfter) / charsBefore;
244
+ }
245
+
246
+ function count(decisions: readonly CallDecision[], reason: CallDecision['reason']): number {
247
+ return decisions.filter((decision) => decision.reason === reason).length;
248
+ }
249
+
250
+ /**
251
+ * Compacts a transcript by asking Jev, for every tool call outside the pinned
252
+ * first and newest messages, whether the call and whether its result must
253
+ * stay. The whole history (results omitted, fitted into `maxStateTokens`) is
254
+ * sent as state with every batch of questions. Throws when Jev fails or the
255
+ * history cannot be fitted; the caller decides whether to fall back.
256
+ */
257
+ export async function compact(
258
+ messages: readonly Message[],
259
+ asker: JevAsker,
260
+ options: CompactOptions = {},
261
+ ): Promise<CompactResult> {
262
+ const started = Date.now();
263
+ const resolved = resolveOptions(options);
264
+ const calls = collectToolCalls(messages, resolved.preserveRecentMessages);
265
+ const candidates = calls.filter((call) => !call.pinned);
266
+ const charsBefore = messages.reduce((sum, message) => sum + messageChars(message), 0);
267
+
268
+ let fitted: { tokens: number; stage: string } = { tokens: 0, stage: '' };
269
+ let batches: ToolCall[][] = [];
270
+ const answers = new Map<string, CallAnswer>();
271
+ if (candidates.length > 0) {
272
+ const state = fitState(messages, calls, resolved);
273
+ fitted = state;
274
+ batches = batchCalls(candidates, state.tokens, resolved);
275
+ const answered = await Promise.all(
276
+ batches.map((batch) => askBatch(asker, state.state, batch)),
277
+ );
278
+ for (const map of answered) for (const [id, answer] of map) answers.set(id, answer);
279
+ }
280
+
281
+ const decisions = calls.map((call) =>
282
+ decideCall(call, answers.get(call.id) ?? { keepCall: 1, keepResult: 1 }, resolved),
283
+ );
284
+ const kept = applyDecisions(
285
+ messages,
286
+ decisions,
287
+ calls,
288
+ resolved.truncateHeadChars,
289
+ );
290
+ return {
291
+ messages: kept,
292
+ decisions,
293
+ stats: {
294
+ messagesBefore: messages.length,
295
+ messagesAfter: kept.length,
296
+ charsBefore,
297
+ charsAfter: kept.reduce((sum, message) => sum + messageChars(message), 0),
298
+ calls: calls.length,
299
+ kept: count(decisions, 'kept'),
300
+ resultsDropped: count(decisions, 'result_dropped'),
301
+ callsDropped: count(decisions, 'call_dropped'),
302
+ pinned: count(decisions, 'pinned'),
303
+ stateTokens: fitted.tokens,
304
+ stateStage: fitted.stage,
305
+ requests: batches.length,
306
+ ms: Date.now() - started,
307
+ },
308
+ };
309
+ }
@@ -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';