pi-jev-compact 0.1.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/CONTRIBUTING.md +52 -0
- package/LICENSE +23 -0
- package/README.md +166 -0
- package/index.ts +8 -0
- package/package.json +61 -0
- package/src/adapter.ts +125 -0
- package/src/config.ts +136 -0
- package/src/index.ts +162 -0
- package/src/jev/client.ts +50 -0
- package/src/jev/compact.ts +298 -0
- package/src/jev/index.ts +6 -0
- package/src/jev/messages.ts +14 -0
- package/src/jev/request.ts +73 -0
- package/src/jev/state.ts +309 -0
- package/src/jev/types.ts +202 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describeStats, serializePruned, toLibraryMessages } from "./adapter.ts";
|
|
2
|
+
import { envConfig, type FastJevConfig, fileConfig, resolveFastJevConfig } from "./config.ts";
|
|
3
|
+
import { JevClient } from "./jev/client.ts";
|
|
4
|
+
import { compact, reductionRatio } from "./jev/compact.ts";
|
|
5
|
+
import type { CompactResult } from "./jev/types.ts";
|
|
6
|
+
|
|
7
|
+
export { describeStats, serializePruned, toLibraryMessages } from "./adapter.ts";
|
|
8
|
+
export { envConfig, fileConfig, resolveFastJevConfig } from "./config.ts";
|
|
9
|
+
export * from "./jev/index.ts";
|
|
10
|
+
|
|
11
|
+
/** The config plus key the compaction handler runs with. */
|
|
12
|
+
export type { FastJevConfig };
|
|
13
|
+
|
|
14
|
+
export default function (pi: import("@earendil-works/pi-coding-agent").ExtensionAPI) {
|
|
15
|
+
const WIDGET = "fast-jev";
|
|
16
|
+
|
|
17
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
18
|
+
ctx.ui.setWidget(WIDGET, []);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
pi.on("session_before_compact", async (event, ctx) => {
|
|
22
|
+
let config: FastJevConfig;
|
|
23
|
+
try {
|
|
24
|
+
// Project file only for trusted projects; project-local extensions are
|
|
25
|
+
// loaded after trust anyway, but global installs need the explicit check.
|
|
26
|
+
const trusted = typeof ctx.isProjectTrusted === "function" ? ctx.isProjectTrusted() : false;
|
|
27
|
+
config = resolveFastJevConfig(envConfig(), trusted ? fileConfig(ctx.cwd) : {});
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
if (!config.apiKey) return undefined;
|
|
32
|
+
|
|
33
|
+
const { preparation } = event;
|
|
34
|
+
const summarized = [...preparation.messagesToSummarize];
|
|
35
|
+
if (preparation.isSplitTurn && preparation.turnPrefixMessages.length > 0) {
|
|
36
|
+
summarized.push(...preparation.turnPrefixMessages);
|
|
37
|
+
}
|
|
38
|
+
const transcript = toLibraryMessages(summarized);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const result = await compact(
|
|
42
|
+
transcript,
|
|
43
|
+
new JevClient({
|
|
44
|
+
apiKey: config.apiKey,
|
|
45
|
+
...(config.model !== undefined && { model: config.model }),
|
|
46
|
+
}),
|
|
47
|
+
{
|
|
48
|
+
...(config.goal !== undefined && { goal: config.goal }),
|
|
49
|
+
...(config.keepThreshold !== undefined && { keepThreshold: config.keepThreshold }),
|
|
50
|
+
preserveRecentMessages: config.preserveRecentMessages,
|
|
51
|
+
...(config.maxStateTokens !== undefined && { maxStateTokens: config.maxStateTokens }),
|
|
52
|
+
...(config.maxRequestTokens !== undefined && {
|
|
53
|
+
maxRequestTokens: config.maxRequestTokens,
|
|
54
|
+
}),
|
|
55
|
+
...(config.truncateHeadChars !== undefined && {
|
|
56
|
+
truncateHeadChars: config.truncateHeadChars,
|
|
57
|
+
}),
|
|
58
|
+
},
|
|
59
|
+
event.signal,
|
|
60
|
+
);
|
|
61
|
+
const ratio = reductionRatio(result);
|
|
62
|
+
if (ratio < config.minReductionRatio) {
|
|
63
|
+
if (ctx.hasUI) {
|
|
64
|
+
ctx.ui.notify(
|
|
65
|
+
`fast-jev: fallback to built-in summary (below ${Math.round(
|
|
66
|
+
config.minReductionRatio * 100,
|
|
67
|
+
)}% minimum: ${describeStats(result.stats, ratio)})`,
|
|
68
|
+
"info",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const summary = serializePruned(preparation.previousSummary, result.messages);
|
|
75
|
+
if (ctx.hasUI) {
|
|
76
|
+
ctx.ui.notify(
|
|
77
|
+
`fast-jev: no summary, pruned history kept verbatim (${describeStats(result.stats, ratio)})`,
|
|
78
|
+
"info",
|
|
79
|
+
);
|
|
80
|
+
ctx.ui.setWidget(WIDGET, [
|
|
81
|
+
describeStats(result.stats, ratio),
|
|
82
|
+
...result.decisions
|
|
83
|
+
.filter((decision) => decision.reason !== "pinned" && decision.reason !== "kept")
|
|
84
|
+
.slice(0, 20)
|
|
85
|
+
.map(
|
|
86
|
+
(decision) =>
|
|
87
|
+
`${decision.id} ${decision.tool}: ${decision.action} (call=${decision.keepCall.toFixed(2)}, result=${decision.keepResult.toFixed(2)})`,
|
|
88
|
+
),
|
|
89
|
+
]);
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
compaction: {
|
|
93
|
+
summary,
|
|
94
|
+
firstKeptEntryId: preparation.firstKeptEntryId,
|
|
95
|
+
tokensBefore: preparation.tokensBefore,
|
|
96
|
+
details: { engine: "fast-jev", decisions: result.decisions, stats: result.stats },
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (ctx.hasUI) {
|
|
101
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
102
|
+
ctx.ui.notify(`fast-jev: fallback to built-in summary (${message})`, "error");
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// On-demand trigger: `/jev-compact [instructions]` runs pi's internal
|
|
109
|
+
// compaction flow, so the session_before_compact handler above decides
|
|
110
|
+
// between the fast-jev path and the built-in summary.
|
|
111
|
+
pi.registerCommand("jev-compact", {
|
|
112
|
+
description: "Trigger compaction through the fast-jev path (falls back to built-in summary)",
|
|
113
|
+
handler: async (args, ctx) => {
|
|
114
|
+
ctx.compact({
|
|
115
|
+
...(args && args.trim().length > 0 && { customInstructions: args.trim() }),
|
|
116
|
+
onError: (error) => {
|
|
117
|
+
ctx.ui.notify(`fast-jev: compaction failed (${error.message})`, "error");
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Outcome reporting: fires for every compaction (fast-jev or built-in),
|
|
124
|
+
// with the saved entry's details carrying our stats when we ran.
|
|
125
|
+
pi.on("session_compact", async (event, ctx) => {
|
|
126
|
+
const { compactionEntry, fromExtension, reason } = event;
|
|
127
|
+
const details =
|
|
128
|
+
compactionEntry.details && typeof compactionEntry.details === "object"
|
|
129
|
+
? (compactionEntry.details as Record<string, unknown>)
|
|
130
|
+
: undefined;
|
|
131
|
+
const engine = details?.["engine"] === "fast-jev" ? "fast-jev" : "built-in";
|
|
132
|
+
const stats =
|
|
133
|
+
details?.["stats"] && typeof details["stats"] === "object"
|
|
134
|
+
? (details["stats"] as CompactResult["stats"])
|
|
135
|
+
: undefined;
|
|
136
|
+
const lines = [
|
|
137
|
+
`fast-jev: compaction done (${engine}${fromExtension ? ", extension-provided" : ""}, ${reason})`,
|
|
138
|
+
];
|
|
139
|
+
if (engine === "fast-jev" && stats) {
|
|
140
|
+
lines.push(
|
|
141
|
+
`messages ${stats.messagesAfter}/${stats.messagesBefore}, calls ${stats.kept} kept / ${stats.resultsDropped} truncated / ${stats.callsDropped} dropped / ${stats.pinned} pinned`,
|
|
142
|
+
`state ~${stats.stateTokens} tokens (${stats.stateStage}), ${stats.requests} request(s), ${stats.ms}ms`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (ctx.hasUI) {
|
|
146
|
+
ctx.ui.notify(lines.join(" — "), "info");
|
|
147
|
+
if (engine === "fast-jev") ctx.ui.setWidget(WIDGET, lines);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
pi.on("session_compact_failed", async (event, ctx) => {
|
|
152
|
+
if (!ctx.hasUI) return;
|
|
153
|
+
if (event.aborted) {
|
|
154
|
+
ctx.ui.notify("fast-jev: compaction cancelled", "info");
|
|
155
|
+
} else {
|
|
156
|
+
ctx.ui.notify(
|
|
157
|
+
`fast-jev: compaction failed (${event.errorMessage ?? "unknown error"})`,
|
|
158
|
+
"error",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { buildJevRequest, parseJevResponse } from "./request.ts";
|
|
2
|
+
import type { JevAsker, JevQuestions, JevResponse, JevState } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
const ENV_KEY = "TYPESAFE_API_KEY";
|
|
5
|
+
|
|
6
|
+
export interface JevClientOptions {
|
|
7
|
+
/** Defaults to `process.env.TYPESAFE_API_KEY`. */
|
|
8
|
+
apiKey?: string;
|
|
9
|
+
/** Defaults to `jev-latest`. */
|
|
10
|
+
model?: string;
|
|
11
|
+
/** Defaults to the System One endpoint. */
|
|
12
|
+
baseUrl?: string;
|
|
13
|
+
/** Defaults to the global `fetch`. */
|
|
14
|
+
fetch?: typeof fetch;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Asks Jev over HTTP with the global `fetch` (or an injected one). */
|
|
18
|
+
export class JevClient implements JevAsker {
|
|
19
|
+
private readonly apiKey: string;
|
|
20
|
+
private readonly model: string | undefined;
|
|
21
|
+
private readonly baseUrl: string | undefined;
|
|
22
|
+
private readonly fetcher: typeof fetch;
|
|
23
|
+
|
|
24
|
+
constructor(options: JevClientOptions = {}) {
|
|
25
|
+
this.apiKey = options.apiKey ?? process.env[ENV_KEY] ?? "";
|
|
26
|
+
this.model = options.model;
|
|
27
|
+
this.baseUrl = options.baseUrl;
|
|
28
|
+
this.fetcher = options.fetch ?? fetch;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async ask(state: JevState, questions: JevQuestions, signal?: AbortSignal): Promise<JevResponse> {
|
|
32
|
+
if (!this.apiKey) throw new Error("TYPESAFE_API_KEY is not configured");
|
|
33
|
+
const request = buildJevRequest(
|
|
34
|
+
{
|
|
35
|
+
apiKey: this.apiKey,
|
|
36
|
+
...(this.model !== undefined && { model: this.model }),
|
|
37
|
+
...(this.baseUrl !== undefined && { baseUrl: this.baseUrl }),
|
|
38
|
+
},
|
|
39
|
+
state,
|
|
40
|
+
questions,
|
|
41
|
+
);
|
|
42
|
+
const response = await this.fetcher(request.url, {
|
|
43
|
+
method: request.method,
|
|
44
|
+
headers: request.headers,
|
|
45
|
+
body: request.body,
|
|
46
|
+
...(signal !== undefined && { signal }),
|
|
47
|
+
});
|
|
48
|
+
return parseJevResponse(response.status, response.ok, await response.text());
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { noulAnswer } from "./request.ts";
|
|
2
|
+
import { collectToolCalls, estimateTokens, fitState } from "./state.ts";
|
|
3
|
+
import type {
|
|
4
|
+
CallAnswer,
|
|
5
|
+
CallDecision,
|
|
6
|
+
CompactionState,
|
|
7
|
+
CompactOptions,
|
|
8
|
+
CompactResult,
|
|
9
|
+
JevAsker,
|
|
10
|
+
JevQuestions,
|
|
11
|
+
Message,
|
|
12
|
+
ResolvedCompactOptions,
|
|
13
|
+
ToolCall,
|
|
14
|
+
ToolResult,
|
|
15
|
+
ToolUse,
|
|
16
|
+
} from "./types.ts";
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_OPTIONS: ResolvedCompactOptions = {
|
|
19
|
+
goal: "",
|
|
20
|
+
keepThreshold: 0.5,
|
|
21
|
+
preserveRecentMessages: 6,
|
|
22
|
+
maxStateTokens: 25_000,
|
|
23
|
+
maxRequestTokens: 30_000,
|
|
24
|
+
truncateHeadChars: 300,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Tokens the request envelope (`model`, key names) adds around state and questions. */
|
|
28
|
+
const REQUEST_OVERHEAD_TOKENS = 20;
|
|
29
|
+
|
|
30
|
+
function finite(value: number | undefined, fallback: number): number {
|
|
31
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveOptions(options: CompactOptions = {}): ResolvedCompactOptions {
|
|
35
|
+
return {
|
|
36
|
+
goal: options.goal ?? DEFAULT_OPTIONS.goal,
|
|
37
|
+
keepThreshold: finite(options.keepThreshold, DEFAULT_OPTIONS.keepThreshold),
|
|
38
|
+
preserveRecentMessages: Math.max(
|
|
39
|
+
0,
|
|
40
|
+
Math.floor(finite(options.preserveRecentMessages, DEFAULT_OPTIONS.preserveRecentMessages)),
|
|
41
|
+
),
|
|
42
|
+
maxStateTokens: Math.max(1, finite(options.maxStateTokens, DEFAULT_OPTIONS.maxStateTokens)),
|
|
43
|
+
maxRequestTokens: Math.max(
|
|
44
|
+
1,
|
|
45
|
+
finite(options.maxRequestTokens, DEFAULT_OPTIONS.maxRequestTokens),
|
|
46
|
+
),
|
|
47
|
+
truncateHeadChars: Math.max(
|
|
48
|
+
0,
|
|
49
|
+
Math.floor(finite(options.truncateHeadChars, DEFAULT_OPTIONS.truncateHeadChars)),
|
|
50
|
+
),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The two `noul` questions asked about one call: keep the call, keep its result. */
|
|
55
|
+
export function questionsFor(call: ToolCall): JevQuestions {
|
|
56
|
+
return {
|
|
57
|
+
[`call_${call.id}`]: {
|
|
58
|
+
type: "noul",
|
|
59
|
+
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`,
|
|
60
|
+
},
|
|
61
|
+
[`result_${call.id}`]: {
|
|
62
|
+
type: "noul",
|
|
63
|
+
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`,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Splits the candidate calls into batches whose questions, together with the
|
|
70
|
+
* (always complete) state, fit one request.
|
|
71
|
+
*/
|
|
72
|
+
export function batchCalls(
|
|
73
|
+
calls: readonly ToolCall[],
|
|
74
|
+
stateTokens: number,
|
|
75
|
+
options: Pick<ResolvedCompactOptions, "maxRequestTokens">,
|
|
76
|
+
): ToolCall[][] {
|
|
77
|
+
const budget = options.maxRequestTokens - stateTokens - REQUEST_OVERHEAD_TOKENS;
|
|
78
|
+
const batches: ToolCall[][] = [];
|
|
79
|
+
let current: ToolCall[] = [];
|
|
80
|
+
let currentTokens = 0;
|
|
81
|
+
for (const call of calls) {
|
|
82
|
+
const tokens = estimateTokens(JSON.stringify(questionsFor(call)));
|
|
83
|
+
if (current.length > 0 && currentTokens + tokens > budget) {
|
|
84
|
+
batches.push(current);
|
|
85
|
+
current = [];
|
|
86
|
+
currentTokens = 0;
|
|
87
|
+
}
|
|
88
|
+
if (current.length === 0 && tokens > budget) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`state leaves no room for questions (~${stateTokens} of ${options.maxRequestTokens} tokens)`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
current.push(call);
|
|
94
|
+
currentTokens += tokens;
|
|
95
|
+
}
|
|
96
|
+
if (current.length > 0) batches.push(current);
|
|
97
|
+
return batches;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function decideCall(
|
|
101
|
+
call: Pick<ToolCall, "id" | "tool" | "pinned">,
|
|
102
|
+
answer: CallAnswer,
|
|
103
|
+
options: Pick<ResolvedCompactOptions, "keepThreshold">,
|
|
104
|
+
): CallDecision {
|
|
105
|
+
const base = { id: call.id, tool: call.tool, ...answer };
|
|
106
|
+
if (call.pinned) return { ...base, action: "keep", reason: "pinned" };
|
|
107
|
+
if (answer.keepResult >= options.keepThreshold) {
|
|
108
|
+
return { ...base, action: "keep", reason: "kept" };
|
|
109
|
+
}
|
|
110
|
+
if (answer.keepCall >= options.keepThreshold) {
|
|
111
|
+
return { ...base, action: "drop_result", reason: "result_dropped" };
|
|
112
|
+
}
|
|
113
|
+
return { ...base, action: "drop_call", reason: "call_dropped" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function askBatch(
|
|
117
|
+
asker: JevAsker,
|
|
118
|
+
state: CompactionState,
|
|
119
|
+
batch: readonly ToolCall[],
|
|
120
|
+
signal?: AbortSignal,
|
|
121
|
+
): Promise<Map<string, CallAnswer>> {
|
|
122
|
+
const questions: JevQuestions = Object.assign({}, ...batch.map(questionsFor));
|
|
123
|
+
const { answers } = await asker.ask(state, questions, signal);
|
|
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(tool.text ?? "", tool.isError ?? false, headChars);
|
|
175
|
+
if ((tool.text ?? "") === text) return tool;
|
|
176
|
+
const copy: ToolUse = {
|
|
177
|
+
tool_use_id: tool.tool_use_id,
|
|
178
|
+
tool: tool.tool,
|
|
179
|
+
input: tool.input,
|
|
180
|
+
text,
|
|
181
|
+
};
|
|
182
|
+
if (tool.isError) copy.isError = true;
|
|
183
|
+
return copy;
|
|
184
|
+
});
|
|
185
|
+
const toolResults = (message.toolResults ?? [])
|
|
186
|
+
.filter((result) => actions.get(result.tool_use_id) !== "drop_call")
|
|
187
|
+
.map((result) => {
|
|
188
|
+
if (actions.get(result.tool_use_id) !== "drop_result") return result;
|
|
189
|
+
const text = truncatedResultText(result.text, result.isError ?? false, headChars);
|
|
190
|
+
if (text === result.text) return result;
|
|
191
|
+
const copy: ToolResult = { tool_use_id: result.tool_use_id, text };
|
|
192
|
+
if (result.isError) copy.isError = result.isError;
|
|
193
|
+
return copy;
|
|
194
|
+
});
|
|
195
|
+
if (
|
|
196
|
+
!message.toolUses.some((tool) => actions.get(tool.tool_use_id) === "drop_call") &&
|
|
197
|
+
!(message.toolResults ?? []).some(
|
|
198
|
+
(result) => actions.get(result.tool_use_id) === "drop_call",
|
|
199
|
+
) &&
|
|
200
|
+
toolUses.every((tool, index) => tool === message.toolUses[index]) &&
|
|
201
|
+
toolResults.every((result, index) => result === message.toolResults?.[index])
|
|
202
|
+
) {
|
|
203
|
+
kept.push(message);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (message.text.trim().length === 0 && toolUses.length === 0 && toolResults.length === 0) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const rebuilt: Message = {
|
|
210
|
+
role: message.role,
|
|
211
|
+
text: message.text,
|
|
212
|
+
toolUses,
|
|
213
|
+
};
|
|
214
|
+
if (toolResults.length > 0) rebuilt.toolResults = toolResults;
|
|
215
|
+
kept.push(rebuilt);
|
|
216
|
+
}
|
|
217
|
+
return kept;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Characters of text, tool input and tool output a message holds. */
|
|
221
|
+
export function messageChars(message: Message): number {
|
|
222
|
+
let total = message.text.length;
|
|
223
|
+
for (const tool of message.toolUses) {
|
|
224
|
+
try {
|
|
225
|
+
total += JSON.stringify(tool.input).length;
|
|
226
|
+
} catch {
|
|
227
|
+
total += 20;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
for (const result of message.toolResults ?? []) total += result.text.length;
|
|
231
|
+
return total;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function reductionRatio(result: Pick<CompactResult, "stats">): number {
|
|
235
|
+
const { charsBefore, charsAfter } = result.stats;
|
|
236
|
+
return charsBefore === 0 ? 0 : (charsBefore - charsAfter) / charsBefore;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function count(decisions: readonly CallDecision[], reason: CallDecision["reason"]): number {
|
|
240
|
+
return decisions.filter((decision) => decision.reason === reason).length;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Compacts a transcript by asking Jev, for every tool call outside the pinned
|
|
245
|
+
* first and newest messages, whether the call and whether its result must
|
|
246
|
+
* stay. The whole history (results omitted, fitted into `maxStateTokens`) is
|
|
247
|
+
* sent as state with every batch of questions. Throws when Jev fails or the
|
|
248
|
+
* history cannot be fitted; the caller decides whether to fall back.
|
|
249
|
+
*/
|
|
250
|
+
export async function compact(
|
|
251
|
+
messages: readonly Message[],
|
|
252
|
+
asker: JevAsker,
|
|
253
|
+
options: CompactOptions = {},
|
|
254
|
+
signal?: AbortSignal,
|
|
255
|
+
): Promise<CompactResult> {
|
|
256
|
+
const started = Date.now();
|
|
257
|
+
const resolved = resolveOptions(options);
|
|
258
|
+
const calls = collectToolCalls(messages, resolved.preserveRecentMessages);
|
|
259
|
+
const candidates = calls.filter((call) => !call.pinned);
|
|
260
|
+
const charsBefore = messages.reduce((sum, message) => sum + messageChars(message), 0);
|
|
261
|
+
|
|
262
|
+
let fitted: { tokens: number; stage: string } = { tokens: 0, stage: "" };
|
|
263
|
+
let batches: ToolCall[][] = [];
|
|
264
|
+
const answers = new Map<string, CallAnswer>();
|
|
265
|
+
if (candidates.length > 0) {
|
|
266
|
+
const state = fitState(messages, calls, resolved);
|
|
267
|
+
fitted = state;
|
|
268
|
+
batches = batchCalls(candidates, state.tokens, resolved);
|
|
269
|
+
const answered = await Promise.all(
|
|
270
|
+
batches.map((batch) => askBatch(asker, state.state, batch, signal)),
|
|
271
|
+
);
|
|
272
|
+
for (const map of answered) for (const [id, answer] of map) answers.set(id, answer);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const decisions = calls.map((call) =>
|
|
276
|
+
decideCall(call, answers.get(call.id) ?? { keepCall: 1, keepResult: 1 }, resolved),
|
|
277
|
+
);
|
|
278
|
+
const kept = applyDecisions(messages, decisions, calls, resolved.truncateHeadChars);
|
|
279
|
+
return {
|
|
280
|
+
messages: kept,
|
|
281
|
+
decisions,
|
|
282
|
+
stats: {
|
|
283
|
+
messagesBefore: messages.length,
|
|
284
|
+
messagesAfter: kept.length,
|
|
285
|
+
charsBefore,
|
|
286
|
+
charsAfter: kept.reduce((sum, message) => sum + messageChars(message), 0),
|
|
287
|
+
calls: calls.length,
|
|
288
|
+
kept: count(decisions, "kept"),
|
|
289
|
+
resultsDropped: count(decisions, "result_dropped"),
|
|
290
|
+
callsDropped: count(decisions, "call_dropped"),
|
|
291
|
+
pinned: count(decisions, "pinned"),
|
|
292
|
+
stateTokens: fitted.tokens,
|
|
293
|
+
stateStage: fitted.stage,
|
|
294
|
+
requests: batches.length,
|
|
295
|
+
ms: Date.now() - started,
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
package/src/jev/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { JevClient, type JevClientOptions } from "./client.ts";
|
|
2
|
+
import { compact } from "./compact.ts";
|
|
3
|
+
import type { CompactOptions, CompactResult, Message } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export type CompactMessagesOptions = CompactOptions & JevClientOptions;
|
|
6
|
+
|
|
7
|
+
/** `compact` with a `JevClient` built from the options (key from `TYPESAFE_API_KEY` by default). */
|
|
8
|
+
export function compactMessages(
|
|
9
|
+
messages: readonly Message[],
|
|
10
|
+
options: CompactMessagesOptions = {},
|
|
11
|
+
signal?: AbortSignal,
|
|
12
|
+
): Promise<CompactResult> {
|
|
13
|
+
return compact(messages, new JevClient(options), options, signal);
|
|
14
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { JevAnswer, JevQuestions, JevResponse, JevState } from "./types.ts";
|
|
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(status: number, ok: boolean, text: string): JevResponse {
|
|
40
|
+
if (!ok) {
|
|
41
|
+
throw new Error(`Jev request failed (${status}): ${text.slice(0, 200)}`);
|
|
42
|
+
}
|
|
43
|
+
let parsed: unknown;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(text);
|
|
46
|
+
} catch {
|
|
47
|
+
throw new Error("Jev returned malformed JSON");
|
|
48
|
+
}
|
|
49
|
+
if (
|
|
50
|
+
parsed === null ||
|
|
51
|
+
typeof parsed !== "object" ||
|
|
52
|
+
!("answers" in parsed) ||
|
|
53
|
+
parsed.answers === null ||
|
|
54
|
+
typeof parsed.answers !== "object"
|
|
55
|
+
) {
|
|
56
|
+
throw new Error("Jev response is missing answers");
|
|
57
|
+
}
|
|
58
|
+
return parsed as JevResponse;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The `noul` probability of one answer; throws when it is not there. */
|
|
62
|
+
export function noulAnswer(answers: Record<string, JevAnswer>, name: string): number {
|
|
63
|
+
const answer = answers[name];
|
|
64
|
+
if (
|
|
65
|
+
!answer ||
|
|
66
|
+
!("noul" in answer) ||
|
|
67
|
+
typeof answer.noul !== "number" ||
|
|
68
|
+
!Number.isFinite(answer.noul)
|
|
69
|
+
) {
|
|
70
|
+
throw new Error(`Invalid Jev answer for ${name}`);
|
|
71
|
+
}
|
|
72
|
+
return answer.noul;
|
|
73
|
+
}
|