@lienat/pi-jev-compaction 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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +215 -0
  3. package/README.md +171 -0
  4. package/assets/tamarajtran-jev-compaction.gif +0 -0
  5. package/assets/tamarajtran-jev-compaction.mp4 +0 -0
  6. package/extensions/fast-jev-compaction.ts +94 -0
  7. package/node_modules/fast-jev-compaction/LICENSE +21 -0
  8. package/node_modules/fast-jev-compaction/README.md +276 -0
  9. package/node_modules/fast-jev-compaction/cli/jev-find.mjs +103 -0
  10. package/node_modules/fast-jev-compaction/cli/jev-qa.mjs +159 -0
  11. package/node_modules/fast-jev-compaction/dist/client.d.ts +21 -0
  12. package/node_modules/fast-jev-compaction/dist/client.d.ts.map +1 -0
  13. package/node_modules/fast-jev-compaction/dist/client.js +26 -0
  14. package/node_modules/fast-jev-compaction/dist/client.js.map +1 -0
  15. package/node_modules/fast-jev-compaction/dist/compact.d.ts +30 -0
  16. package/node_modules/fast-jev-compaction/dist/compact.d.ts.map +1 -0
  17. package/node_modules/fast-jev-compaction/dist/compact.js +234 -0
  18. package/node_modules/fast-jev-compaction/dist/compact.js.map +1 -0
  19. package/node_modules/fast-jev-compaction/dist/index.d.ts +7 -0
  20. package/node_modules/fast-jev-compaction/dist/index.d.ts.map +1 -0
  21. package/node_modules/fast-jev-compaction/dist/index.js +7 -0
  22. package/node_modules/fast-jev-compaction/dist/index.js.map +1 -0
  23. package/node_modules/fast-jev-compaction/dist/messages.d.ts +6 -0
  24. package/node_modules/fast-jev-compaction/dist/messages.d.ts.map +1 -0
  25. package/node_modules/fast-jev-compaction/dist/messages.js +7 -0
  26. package/node_modules/fast-jev-compaction/dist/messages.js.map +1 -0
  27. package/node_modules/fast-jev-compaction/dist/request.d.ts +20 -0
  28. package/node_modules/fast-jev-compaction/dist/request.d.ts.map +1 -0
  29. package/node_modules/fast-jev-compaction/dist/request.js +51 -0
  30. package/node_modules/fast-jev-compaction/dist/request.js.map +1 -0
  31. package/node_modules/fast-jev-compaction/dist/state.d.ts +29 -0
  32. package/node_modules/fast-jev-compaction/dist/state.d.ts.map +1 -0
  33. package/node_modules/fast-jev-compaction/dist/state.js +256 -0
  34. package/node_modules/fast-jev-compaction/dist/state.js.map +1 -0
  35. package/node_modules/fast-jev-compaction/dist/types.d.ts +178 -0
  36. package/node_modules/fast-jev-compaction/dist/types.d.ts.map +1 -0
  37. package/node_modules/fast-jev-compaction/dist/types.js +2 -0
  38. package/node_modules/fast-jev-compaction/dist/types.js.map +1 -0
  39. package/node_modules/fast-jev-compaction/package.json +39 -0
  40. package/package.json +42 -0
  41. package/src/adapter.ts +373 -0
package/src/adapter.ts ADDED
@@ -0,0 +1,373 @@
1
+ import {
2
+ compact,
3
+ buildJevRequest,
4
+ parseJevResponse,
5
+ type CallDecision,
6
+ type CompactOptions,
7
+ type JevAsker,
8
+ type JevQuestions,
9
+ type JevResponse,
10
+ type JevState,
11
+ type Message as JevMessage,
12
+ } from "fast-jev-compaction";
13
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
14
+ import type { ImageContent, TextContent, ToolResultMessage } from "@earendil-works/pi-ai";
15
+
16
+ export const DEFAULT_TIMEOUT_MS = 15_000;
17
+
18
+ export interface TimerApi {
19
+ setTimeout(callback: () => void, milliseconds: number): ReturnType<typeof setTimeout>;
20
+ clearTimeout(handle: ReturnType<typeof setTimeout>): void;
21
+ }
22
+
23
+ export interface Deadline {
24
+ readonly signal: AbortSignal;
25
+ readonly timedOut: () => boolean;
26
+ dispose(): void;
27
+ }
28
+
29
+ export interface FilterOptions {
30
+ asker: JevAsker;
31
+ compactOptions?: CompactOptions;
32
+ signal?: AbortSignal;
33
+ }
34
+
35
+ export interface FilterResult {
36
+ messages: AgentMessage[];
37
+ changed: boolean;
38
+ /** Paired, text-only tool calls Jev was asked about. */
39
+ candidateCalls: number;
40
+ droppedCalls: number;
41
+ truncatedResults: number;
42
+ }
43
+
44
+ export interface PreparationFilterResult {
45
+ messagesToSummarize: AgentMessage[];
46
+ turnPrefixMessages: AgentMessage[];
47
+ changed: boolean;
48
+ candidateCalls: number;
49
+ droppedCalls: number;
50
+ truncatedResults: number;
51
+ }
52
+
53
+ type TextOrImage = TextContent | ImageContent;
54
+
55
+ type Pair = {
56
+ toolCallId: string;
57
+ callMessageIndex: number;
58
+ resultMessageIndex: number;
59
+ };
60
+
61
+ function abortError(): DOMException {
62
+ return new DOMException("The operation was aborted", "AbortError");
63
+ }
64
+
65
+ function rejectOnAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
66
+ if (!signal) return promise;
67
+ if (signal.aborted) return Promise.reject(abortError());
68
+
69
+ return new Promise<T>((resolve, reject) => {
70
+ const onAbort = () => reject(abortError());
71
+ signal.addEventListener("abort", onAbort, { once: true });
72
+ promise.then(
73
+ (value) => {
74
+ signal.removeEventListener("abort", onAbort);
75
+ resolve(value);
76
+ },
77
+ (error: unknown) => {
78
+ signal.removeEventListener("abort", onAbort);
79
+ reject(error);
80
+ },
81
+ );
82
+ });
83
+ }
84
+
85
+ export function createDeadline(
86
+ parentSignal: AbortSignal,
87
+ timeoutMs: number,
88
+ timers: TimerApi = globalThis,
89
+ ): Deadline {
90
+ const controller = new AbortController();
91
+ let timeoutFired = false;
92
+ const handle = timers.setTimeout(() => {
93
+ timeoutFired = true;
94
+ controller.abort();
95
+ }, timeoutMs);
96
+ const signal = AbortSignal.any([parentSignal, controller.signal]);
97
+
98
+ return {
99
+ signal,
100
+ timedOut: () => timeoutFired,
101
+ dispose: () => timers.clearTimeout(handle),
102
+ };
103
+ }
104
+
105
+ export function createJevAsker(
106
+ apiKey: string,
107
+ signal: AbortSignal,
108
+ fetcher: typeof fetch = globalThis.fetch,
109
+ ): JevAsker {
110
+ return {
111
+ async ask(state: JevState, questions: JevQuestions): Promise<JevResponse> {
112
+ const request = buildJevRequest({ apiKey }, state, questions);
113
+ const response = await rejectOnAbort(
114
+ fetcher(request.url, {
115
+ method: request.method,
116
+ headers: request.headers,
117
+ body: request.body,
118
+ signal,
119
+ }),
120
+ signal,
121
+ );
122
+ const body = await rejectOnAbort(response.text(), signal);
123
+ return parseJevResponse(response.status, response.ok, body);
124
+ },
125
+ };
126
+ }
127
+
128
+ function isTextOnly(content: readonly TextOrImage[]): content is readonly TextContent[] {
129
+ return content.every((block) => block.type === "text");
130
+ }
131
+
132
+ function contentText(content: readonly TextOrImage[]): string {
133
+ return content
134
+ .map((block) => (block.type === "text" ? block.text : "[image omitted from Jev decision]"))
135
+ .join("\n");
136
+ }
137
+
138
+ function messageText(message: AgentMessage): string {
139
+ switch (message.role) {
140
+ case "user":
141
+ return typeof message.content === "string" ? message.content : contentText(message.content);
142
+ case "toolResult":
143
+ return contentText(message.content);
144
+ case "assistant":
145
+ return message.content
146
+ .map((block) => {
147
+ if (block.type === "text") return block.text;
148
+ if (block.type === "thinking") return `[thinking]\n${block.thinking}`;
149
+ return "";
150
+ })
151
+ .filter(Boolean)
152
+ .join("\n");
153
+ case "bashExecution":
154
+ return `${message.command}\n${message.output}`;
155
+ case "custom":
156
+ return typeof message.content === "string" ? message.content : contentText(message.content);
157
+ case "branchSummary":
158
+ case "compactionSummary":
159
+ return message.summary;
160
+ default:
161
+ return "";
162
+ }
163
+ }
164
+
165
+ function collectPairs(messages: readonly AgentMessage[]): Pair[] {
166
+ const calls = new Map<string, number>();
167
+ const duplicateCalls = new Set<string>();
168
+ const results = new Map<string, number>();
169
+ const duplicateResults = new Set<string>();
170
+
171
+ messages.forEach((message, index) => {
172
+ if (message.role === "assistant") {
173
+ for (const block of message.content) {
174
+ if (block.type !== "toolCall") continue;
175
+ if (calls.has(block.id)) duplicateCalls.add(block.id);
176
+ else calls.set(block.id, index);
177
+ }
178
+ }
179
+ if (message.role === "toolResult") {
180
+ if (results.has(message.toolCallId)) duplicateResults.add(message.toolCallId);
181
+ else results.set(message.toolCallId, index);
182
+ }
183
+ });
184
+
185
+ const pairs: Pair[] = [];
186
+ for (const [toolCallId, callMessageIndex] of calls) {
187
+ const resultMessageIndex = results.get(toolCallId);
188
+ if (
189
+ resultMessageIndex === undefined ||
190
+ duplicateCalls.has(toolCallId) ||
191
+ duplicateResults.has(toolCallId)
192
+ ) {
193
+ continue;
194
+ }
195
+
196
+ const result = messages[resultMessageIndex];
197
+ // Never let Jev discard or rewrite an image-bearing tool result.
198
+ if (result.role !== "toolResult" || !isTextOnly(result.content)) continue;
199
+ pairs.push({ toolCallId, callMessageIndex, resultMessageIndex });
200
+ }
201
+ return pairs;
202
+ }
203
+
204
+ function toJevMessages(messages: readonly AgentMessage[], pairs: readonly Pair[]): JevMessage[] {
205
+ const eligible = new Set(pairs.map((pair) => pair.toolCallId));
206
+
207
+ return messages.map((message) => {
208
+ if (message.role === "assistant") {
209
+ return {
210
+ role: "assistant",
211
+ text: messageText(message),
212
+ toolUses: message.content.flatMap((block) =>
213
+ block.type === "toolCall" && eligible.has(block.id)
214
+ ? [{ tool_use_id: block.id, tool: block.name, input: block.arguments }]
215
+ : [],
216
+ ),
217
+ };
218
+ }
219
+
220
+ if (message.role === "toolResult" && eligible.has(message.toolCallId)) {
221
+ return {
222
+ role: "user",
223
+ text: "",
224
+ toolUses: [],
225
+ toolResults: [{
226
+ tool_use_id: message.toolCallId,
227
+ text: contentText(message.content),
228
+ isError: message.isError,
229
+ }],
230
+ };
231
+ }
232
+
233
+ return { role: "user", text: messageText(message), toolUses: [] };
234
+ });
235
+ }
236
+
237
+ function truncateTextContent(content: readonly TextContent[], headChars: number, isError: boolean): TextContent[] {
238
+ const fullText = content.map((block) => block.text).join("\n");
239
+ if (fullText.length <= headChars + 120) return [...content];
240
+
241
+ const kept: TextContent[] = [];
242
+ let remaining = headChars;
243
+ for (const block of content) {
244
+ if (remaining <= 0) break;
245
+ if (block.text.length <= remaining) {
246
+ kept.push(block);
247
+ remaining -= block.text.length;
248
+ } else {
249
+ kept.push({ type: "text", text: block.text.slice(0, remaining) });
250
+ remaining = 0;
251
+ }
252
+ }
253
+ const omitted = fullText.length - headChars;
254
+ kept.push({
255
+ type: "text",
256
+ text: `[fast-jev-compaction truncated ${omitted} chars of this tool result${isError ? " (error)" : ""}; re-run the tool if needed]`,
257
+ });
258
+ return kept;
259
+ }
260
+
261
+ function decisionActions(decisions: readonly CallDecision[], pairs: readonly Pair[]): Map<string, CallDecision["action"]> {
262
+ const byIndex = new Map<number, string>();
263
+ pairs.forEach((pair, index) => byIndex.set(index + 1, pair.toolCallId));
264
+ const actions = new Map<string, CallDecision["action"]>();
265
+
266
+ for (const decision of decisions) {
267
+ const index = Number.parseInt(decision.id.slice(1), 10);
268
+ const toolCallId = byIndex.get(index);
269
+ if (toolCallId && decision.action !== "keep") actions.set(toolCallId, decision.action);
270
+ }
271
+ return actions;
272
+ }
273
+
274
+ function applyActions(
275
+ messages: readonly AgentMessage[],
276
+ actions: ReadonlyMap<string, CallDecision["action"]>,
277
+ headChars: number,
278
+ candidateCalls: number,
279
+ ): FilterResult {
280
+ if (actions.size === 0) {
281
+ return { messages: [...messages], changed: false, candidateCalls, droppedCalls: 0, truncatedResults: 0 };
282
+ }
283
+
284
+ let changed = false;
285
+ let droppedCalls = 0;
286
+ let truncatedResults = 0;
287
+ const filtered: AgentMessage[] = [];
288
+
289
+ for (const message of messages) {
290
+ if (message.role === "assistant") {
291
+ const content = message.content.filter(
292
+ (block) => block.type !== "toolCall" || actions.get(block.id) !== "drop_call",
293
+ );
294
+ const removed = content.length !== message.content.length;
295
+ if (removed) droppedCalls += message.content.length - content.length;
296
+ if (content.length === 0) {
297
+ changed ||= removed;
298
+ continue;
299
+ }
300
+ if (removed) {
301
+ changed = true;
302
+ filtered.push({ ...message, content });
303
+ } else {
304
+ filtered.push(message);
305
+ }
306
+ continue;
307
+ }
308
+
309
+ if (message.role === "toolResult") {
310
+ const action = actions.get(message.toolCallId);
311
+ if (action === "drop_call") {
312
+ changed = true;
313
+ continue;
314
+ }
315
+ if (action === "drop_result" && isTextOnly(message.content)) {
316
+ const content = truncateTextContent(message.content, headChars, message.isError);
317
+ if (content.length !== message.content.length || content.some((block, index) => block !== message.content[index])) {
318
+ changed = true;
319
+ truncatedResults++;
320
+ filtered.push({ ...message, content } as ToolResultMessage);
321
+ } else {
322
+ filtered.push(message);
323
+ }
324
+ continue;
325
+ }
326
+ }
327
+
328
+ filtered.push(message);
329
+ }
330
+
331
+ return { messages: filtered, changed, candidateCalls, droppedCalls, truncatedResults };
332
+ }
333
+
334
+ /**
335
+ * Ask Jev for a single native Pi compaction region, then map its decisions back
336
+ * onto the original Pi messages. The simple upstream transcript is never used
337
+ * to reconstruct Pi output.
338
+ */
339
+ export async function filterMessages(
340
+ messages: readonly AgentMessage[],
341
+ options: FilterOptions,
342
+ ): Promise<FilterResult> {
343
+ const pairs = collectPairs(messages);
344
+ if (pairs.length === 0) {
345
+ return { messages: [...messages], changed: false, candidateCalls: 0, droppedCalls: 0, truncatedResults: 0 };
346
+ }
347
+
348
+ const compactOptions = options.compactOptions ?? {};
349
+ const result = await rejectOnAbort(
350
+ compact(toJevMessages(messages, pairs), options.asker, compactOptions),
351
+ options.signal,
352
+ );
353
+ const headChars = compactOptions.truncateHeadChars ?? 300;
354
+ return applyActions(messages, decisionActions(result.decisions, pairs), headChars, pairs.length);
355
+ }
356
+
357
+ /** Filter Pi's two native summary inputs independently; their boundaries stay intact. */
358
+ export async function filterPreparation(
359
+ messagesToSummarize: readonly AgentMessage[],
360
+ turnPrefixMessages: readonly AgentMessage[],
361
+ options: FilterOptions,
362
+ ): Promise<PreparationFilterResult> {
363
+ const history = await filterMessages(messagesToSummarize, options);
364
+ const prefix = await filterMessages(turnPrefixMessages, options);
365
+ return {
366
+ messagesToSummarize: history.messages,
367
+ turnPrefixMessages: prefix.messages,
368
+ changed: history.changed || prefix.changed,
369
+ candidateCalls: history.candidateCalls + prefix.candidateCalls,
370
+ droppedCalls: history.droppedCalls + prefix.droppedCalls,
371
+ truncatedResults: history.truncatedResults + prefix.truncatedResults,
372
+ };
373
+ }