jeopi-agent-core 16.2.13

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 (66) hide show
  1. package/CHANGELOG.md +1016 -0
  2. package/README.md +473 -0
  3. package/dist/types/agent-loop.d.ts +66 -0
  4. package/dist/types/agent.d.ts +427 -0
  5. package/dist/types/append-only-context.d.ts +133 -0
  6. package/dist/types/compaction/branch-summarization.d.ts +101 -0
  7. package/dist/types/compaction/compaction-v2-streaming.d.ts +82 -0
  8. package/dist/types/compaction/compaction.d.ts +283 -0
  9. package/dist/types/compaction/entries.d.ts +110 -0
  10. package/dist/types/compaction/errors.d.ts +26 -0
  11. package/dist/types/compaction/index.d.ts +12 -0
  12. package/dist/types/compaction/messages.d.ts +77 -0
  13. package/dist/types/compaction/openai.d.ts +77 -0
  14. package/dist/types/compaction/pruning.d.ts +105 -0
  15. package/dist/types/compaction/shake.d.ts +92 -0
  16. package/dist/types/compaction/tool-protection.d.ts +17 -0
  17. package/dist/types/compaction/utils.d.ts +58 -0
  18. package/dist/types/compaction.d.ts +1 -0
  19. package/dist/types/index.d.ts +12 -0
  20. package/dist/types/proxy.d.ts +85 -0
  21. package/dist/types/replay-policy.d.ts +5 -0
  22. package/dist/types/run-collector.d.ts +196 -0
  23. package/dist/types/telemetry.d.ts +590 -0
  24. package/dist/types/thinking.d.ts +17 -0
  25. package/dist/types/tokenizer.d.ts +1 -0
  26. package/dist/types/types.d.ts +640 -0
  27. package/dist/types/utils/yield.d.ts +71 -0
  28. package/package.json +78 -0
  29. package/src/agent-loop.ts +2188 -0
  30. package/src/agent.ts +1457 -0
  31. package/src/append-only-context.ts +348 -0
  32. package/src/compaction/branch-summarization.ts +370 -0
  33. package/src/compaction/compaction-v2-streaming.ts +719 -0
  34. package/src/compaction/compaction.ts +1553 -0
  35. package/src/compaction/entries.ts +142 -0
  36. package/src/compaction/errors.ts +31 -0
  37. package/src/compaction/index.ts +13 -0
  38. package/src/compaction/messages.ts +237 -0
  39. package/src/compaction/openai.ts +581 -0
  40. package/src/compaction/prompts/auto-handoff-threshold-focus.md +1 -0
  41. package/src/compaction/prompts/branch-summary-context.md +5 -0
  42. package/src/compaction/prompts/branch-summary-preamble.md +2 -0
  43. package/src/compaction/prompts/branch-summary.md +30 -0
  44. package/src/compaction/prompts/compaction-short-summary.md +9 -0
  45. package/src/compaction/prompts/compaction-summary-context.md +5 -0
  46. package/src/compaction/prompts/compaction-summary.md +38 -0
  47. package/src/compaction/prompts/compaction-turn-prefix.md +17 -0
  48. package/src/compaction/prompts/compaction-update-summary.md +45 -0
  49. package/src/compaction/prompts/file-operations.md +5 -0
  50. package/src/compaction/prompts/handoff-document.md +49 -0
  51. package/src/compaction/prompts/snapcompact-archive-context.md +3 -0
  52. package/src/compaction/prompts/summarization-system.md +3 -0
  53. package/src/compaction/pruning.ts +424 -0
  54. package/src/compaction/shake.ts +429 -0
  55. package/src/compaction/tool-protection.ts +55 -0
  56. package/src/compaction/utils.ts +323 -0
  57. package/src/compaction.ts +1 -0
  58. package/src/index.ts +24 -0
  59. package/src/proxy.ts +376 -0
  60. package/src/replay-policy.ts +13 -0
  61. package/src/run-collector.ts +631 -0
  62. package/src/telemetry.ts +2034 -0
  63. package/src/thinking.ts +19 -0
  64. package/src/tokenizer.ts +17 -0
  65. package/src/types.ts +718 -0
  66. package/src/utils/yield.ts +183 -0
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Context-reducing surgical compaction ("shake").
3
+ *
4
+ * `shake` drops heavy content out of the live context mechanically: whole
5
+ * tool-call results and large fenced/XML blocks are replaced with short
6
+ * placeholders. This module is the pure layer — region detection and in-place
7
+ * mutation only. Artifact offload, persistence, and provider-session teardown
8
+ * are orchestrated by the caller (`AgentSession.shake`).
9
+ *
10
+ * Layering mirrors `pruning.ts`: no I/O here.
11
+ */
12
+
13
+ import type { TextContent, ToolResultMessage } from "jeopi-ai";
14
+ import { countTokens } from "../tokenizer";
15
+ import type { AgentMessage } from "../types";
16
+ import { estimateTokens } from "./compaction";
17
+ import type { CustomMessageEntry, SessionEntry, SessionMessageEntry } from "./entries";
18
+ import {
19
+ collectToolCallsById,
20
+ isProtectedToolResult,
21
+ isSkillReadToolResult,
22
+ type ProtectedToolMatcher,
23
+ } from "./tool-protection";
24
+
25
+ export interface ShakeConfig {
26
+ /** Keep the most recent context tokens (across all entries) intact. */
27
+ protectTokens: number;
28
+ /** Only shake when total estimated savings meets this threshold. */
29
+ minSavings: number;
30
+ /** Tool-result protection matchers. String entries protect every result from that tool; predicates may inspect the paired tool call. */
31
+ protectedTools: ProtectedToolMatcher[];
32
+ /** Minimum token size for a fenced/XML block to be eligible. */
33
+ fenceMinTokens: number;
34
+ /**
35
+ * Compaction boundary (`firstKeptEntryId` of the latest compaction). Entries
36
+ * before it are summarized away and never sent, so they are skipped — shaking
37
+ * them only churns persisted history. Undefined = no compaction (whole branch
38
+ * is sent). Note: shake still elides the warm cached prefix at/after the
39
+ * boundary — that is its job as a compaction-class reducer.
40
+ */
41
+ keepBoundaryId?: string;
42
+ }
43
+
44
+ /** Auto-shake config: protects the live tail, conservative thresholds. */
45
+ export const DEFAULT_SHAKE_CONFIG: ShakeConfig = {
46
+ protectTokens: 16_000,
47
+ minSavings: 4_000,
48
+ protectedTools: ["skill", isSkillReadToolResult],
49
+ fenceMinTokens: 400,
50
+ };
51
+
52
+ /** Manual `/shake`: aggressive — drops every eligible region across history. */
53
+ export const AGGRESSIVE_SHAKE_CONFIG: ShakeConfig = {
54
+ protectTokens: 0,
55
+ minSavings: 0,
56
+ protectedTools: ["skill", isSkillReadToolResult],
57
+ fenceMinTokens: 400,
58
+ };
59
+
60
+ /** Rough token cost of a placeholder line; used only for the savings gate. */
61
+ const PLACEHOLDER_TOKEN_ESTIMATE = 16;
62
+
63
+ /** A located eligible region. */
64
+ export interface ToolResultShakeRegion {
65
+ kind: "toolResult";
66
+ entry: SessionMessageEntry;
67
+ tokens: number;
68
+ originalText: string;
69
+ /** Human label for the offload doc (tool name). */
70
+ label: string;
71
+ }
72
+
73
+ export interface BlockShakeRegion {
74
+ kind: "block";
75
+ entry: SessionMessageEntry | CustomMessageEntry;
76
+ /** Index into the content array, or -1 for string-form content. */
77
+ blockIndex: number;
78
+ /** Character offsets into the target text (start inclusive, end exclusive). */
79
+ start: number;
80
+ end: number;
81
+ tokens: number;
82
+ originalText: string;
83
+ /** Human label for the offload doc (role / customType). */
84
+ label: string;
85
+ }
86
+
87
+ export type ShakeRegion = ToolResultShakeRegion | BlockShakeRegion;
88
+
89
+ // Mirror prompt.ts top-level XML detection. Lowercase tag names only —
90
+ // conservative by design (uppercase / mixed-case tags are ignored).
91
+ const OPENING_XML = /^<([a-z_-]+)(?:\s+[^>]*)?>$/;
92
+ const CLOSING_XML = /^<\/([a-z_-]+)>$/;
93
+
94
+ function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
95
+ if (entry.type !== "message") return undefined;
96
+ const message = entry.message as AgentMessage;
97
+ if (message.role !== "toolResult") return undefined;
98
+ return message as ToolResultMessage;
99
+ }
100
+
101
+ function toolResultText(message: ToolResultMessage): string {
102
+ return message.content
103
+ .filter((block): block is TextContent => block.type === "text")
104
+ .map(block => block.text)
105
+ .join("\n");
106
+ }
107
+
108
+ /** Estimate the token contribution of an entry for the protect-recent window. */
109
+ function entryTokens(entry: SessionEntry): number {
110
+ if (entry.type === "message") {
111
+ return estimateTokens(entry.message);
112
+ }
113
+ if (entry.type === "custom_message") {
114
+ const content = entry.content;
115
+ if (typeof content === "string") return content.length === 0 ? 0 : countTokens(content);
116
+ const fragments = content.filter((block): block is TextContent => block.type === "text").map(block => block.text);
117
+ return fragments.length === 0 ? 0 : countTokens(fragments);
118
+ }
119
+ return 0;
120
+ }
121
+
122
+ /**
123
+ * Locate fenced code blocks and top-level XML element spans inside `text`.
124
+ * Returns character ranges `[start, end)` covering the full block (including the
125
+ * opening and closing fence/tag lines, excluding the trailing newline).
126
+ *
127
+ * Conservative: unterminated fences/tags yield no range, and XML detection is
128
+ * suppressed inside fences. Mirrors the toggling logic in
129
+ * `jeopi-utils` `format()` so behavior stays aligned with prompt rendering.
130
+ */
131
+ function scanTextForBlockRanges(text: string): Array<{ start: number; end: number }> {
132
+ const ranges: Array<{ start: number; end: number }> = [];
133
+ let inFence = false;
134
+ let fenceStart = -1;
135
+ const tagStack: string[] = [];
136
+ let xmlStart = -1;
137
+
138
+ let lineStart = 0;
139
+ for (let i = 0; i <= text.length; i++) {
140
+ if (i !== text.length && text[i] !== "\n") continue;
141
+ const line = text.slice(lineStart, i);
142
+ const lineEnd = i; // offset of the newline (or end of text); excludes the "\n"
143
+ const trimmedStart = line.trimStart();
144
+
145
+ const isFenceLine = trimmedStart.startsWith("```") || trimmedStart.startsWith("~~~");
146
+ if (isFenceLine) {
147
+ if (!inFence) {
148
+ inFence = true;
149
+ fenceStart = lineStart;
150
+ } else {
151
+ inFence = false;
152
+ ranges.push({ start: fenceStart, end: lineEnd });
153
+ fenceStart = -1;
154
+ }
155
+ lineStart = i + 1;
156
+ continue;
157
+ }
158
+
159
+ if (!inFence) {
160
+ const isOpeningXml = line.length === trimmedStart.length && OPENING_XML.test(trimmedStart);
161
+ if (isOpeningXml) {
162
+ const match = OPENING_XML.exec(trimmedStart);
163
+ if (match) {
164
+ if (tagStack.length === 0) xmlStart = lineStart;
165
+ tagStack.push(match[1]);
166
+ }
167
+ } else {
168
+ const closingMatch = CLOSING_XML.exec(trimmedStart);
169
+ if (closingMatch && tagStack.length > 0 && tagStack[tagStack.length - 1] === closingMatch[1]) {
170
+ tagStack.pop();
171
+ if (tagStack.length === 0 && xmlStart >= 0) {
172
+ ranges.push({ start: xmlStart, end: lineEnd });
173
+ xmlStart = -1;
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ lineStart = i + 1;
180
+ }
181
+
182
+ return mergeRanges(ranges);
183
+ }
184
+
185
+ /**
186
+ * Sort ascending by start and drop any range that overlaps an already-kept
187
+ * range. Because fence/XML spans are always properly nested (XML detection is
188
+ * suppressed inside fences), overlap means containment — keeping the
189
+ * earlier-starting range keeps the outermost span.
190
+ */
191
+ function mergeRanges(ranges: Array<{ start: number; end: number }>): Array<{ start: number; end: number }> {
192
+ if (ranges.length <= 1) return ranges;
193
+ const sorted = [...ranges].sort((a, b) => a.start - b.start);
194
+ const kept: Array<{ start: number; end: number }> = [];
195
+ let lastEnd = -1;
196
+ for (const range of sorted) {
197
+ if (range.start < lastEnd) continue;
198
+ kept.push(range);
199
+ lastEnd = range.end;
200
+ }
201
+ return kept;
202
+ }
203
+
204
+ function pushBlockRegions(
205
+ entry: SessionMessageEntry | CustomMessageEntry,
206
+ blockIndex: number,
207
+ text: string,
208
+ config: ShakeConfig,
209
+ label: string,
210
+ out: ShakeRegion[],
211
+ ): void {
212
+ for (const range of scanTextForBlockRanges(text)) {
213
+ const slice = text.slice(range.start, range.end);
214
+ if (slice.length === 0) continue;
215
+ const tokens = countTokens(slice);
216
+ if (tokens < config.fenceMinTokens) continue;
217
+ out.push({
218
+ kind: "block",
219
+ entry,
220
+ blockIndex,
221
+ start: range.start,
222
+ end: range.end,
223
+ tokens,
224
+ originalText: slice,
225
+ label,
226
+ });
227
+ }
228
+ }
229
+
230
+ function collectBlockRegions(
231
+ entry: SessionMessageEntry | CustomMessageEntry,
232
+ config: ShakeConfig,
233
+ out: ShakeRegion[],
234
+ ): void {
235
+ if (entry.type === "message") {
236
+ const message = entry.message;
237
+ if (message.role === "assistant") {
238
+ for (let bi = 0; bi < message.content.length; bi++) {
239
+ const block = message.content[bi];
240
+ if (block.type === "text") pushBlockRegions(entry, bi, block.text, config, "assistant", out);
241
+ }
242
+ return;
243
+ }
244
+ if (message.role === "user" || message.role === "developer") {
245
+ scanContentBlocks(entry, message.content, config, message.role, out);
246
+ }
247
+ return;
248
+ }
249
+ // custom_message
250
+ scanContentBlocks(entry, entry.content, config, entry.customType, out);
251
+ }
252
+
253
+ function scanContentBlocks(
254
+ entry: SessionMessageEntry | CustomMessageEntry,
255
+ content: string | Array<{ type: string; text?: string }>,
256
+ config: ShakeConfig,
257
+ label: string,
258
+ out: ShakeRegion[],
259
+ ): void {
260
+ if (typeof content === "string") {
261
+ pushBlockRegions(entry, -1, content, config, label, out);
262
+ return;
263
+ }
264
+ for (let bi = 0; bi < content.length; bi++) {
265
+ const block = content[bi];
266
+ if (block.type === "text" && typeof block.text === "string") {
267
+ pushBlockRegions(entry, bi, block.text, config, label, out);
268
+ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Pure detection: locate every eligible shake region on a branch.
274
+ *
275
+ * Walks the protect-recent window (most recent `protectTokens` of context is
276
+ * kept intact), collects whole tool-result messages (honoring `protectedTools`
277
+ * and skipping already-pruned results) and large fenced/XML blocks inside
278
+ * user/developer/assistant/custom messages. Tool results flagged contextually
279
+ * useless by their tool bypass the protect window — there is nothing recent
280
+ * worth keeping in them. Returns regions in document order.
281
+ *
282
+ * `toolCall` blocks are never touched (tool-call/result pairing is preserved)
283
+ * and regions never span a message boundary. When the combined estimated
284
+ * savings is below `minSavings`, returns `[]` (no-op).
285
+ */
286
+ export function collectShakeRegions(entries: SessionEntry[], config: ShakeConfig): ShakeRegion[] {
287
+ const n = entries.length;
288
+ if (n === 0) return [];
289
+
290
+ // Tokens of all entries strictly more recent than index i.
291
+ const accumulatedAfter = new Array<number>(n);
292
+ let acc = 0;
293
+ for (let i = n - 1; i >= 0; i--) {
294
+ accumulatedAfter[i] = acc;
295
+ acc += entryTokens(entries[i]);
296
+ }
297
+
298
+ const toolCallsById = collectToolCallsById(entries);
299
+
300
+ // Entries before the compaction boundary are summarized away and never sent —
301
+ // shaking them only churns persisted history (no prompt/cache effect).
302
+ const boundaryIndex =
303
+ config.keepBoundaryId === undefined
304
+ ? 0
305
+ : Math.max(
306
+ 0,
307
+ entries.findIndex(entry => entry.id === config.keepBoundaryId),
308
+ );
309
+
310
+ const regions: ShakeRegion[] = [];
311
+ for (let i = 0; i < n; i++) {
312
+ const entry = entries[i];
313
+ if (i < boundaryIndex) continue;
314
+ const toolResult = getToolResultMessage(entry);
315
+ // Useless-flagged results carry no information once consumed; they are
316
+ // eligible even inside the protect-recent window.
317
+ const uselessResult = toolResult !== undefined && toolResult.useless === true && toolResult.isError !== true;
318
+ if (!uselessResult && accumulatedAfter[i] < config.protectTokens) continue;
319
+ if (toolResult) {
320
+ if (toolResult.prunedAt !== undefined) continue;
321
+ if (isProtectedToolResult(toolResult, toolCallsById.get(toolResult.toolCallId), config.protectedTools))
322
+ continue;
323
+ const text = toolResultText(toolResult);
324
+ if (text.length === 0) continue;
325
+ regions.push({
326
+ kind: "toolResult",
327
+ entry: entry as SessionMessageEntry,
328
+ tokens: estimateTokens(toolResult as AgentMessage),
329
+ originalText: text,
330
+ label: toolResult.toolName,
331
+ });
332
+ continue;
333
+ }
334
+
335
+ if (entry.type === "message" || entry.type === "custom_message") {
336
+ collectBlockRegions(entry as SessionMessageEntry | CustomMessageEntry, config, regions);
337
+ }
338
+ }
339
+
340
+ let savings = 0;
341
+ for (const region of regions) savings += Math.max(0, region.tokens - PLACEHOLDER_TOKEN_ESTIMATE);
342
+ if (savings < config.minSavings) return [];
343
+
344
+ return regions;
345
+ }
346
+
347
+ interface TextSlot {
348
+ read(): string;
349
+ write(value: string): void;
350
+ }
351
+
352
+ function getBlockTextSlot(entry: SessionMessageEntry | CustomMessageEntry, blockIndex: number): TextSlot | undefined {
353
+ if (entry.type === "message") {
354
+ const message = entry.message as { content: unknown };
355
+ if (blockIndex === -1) {
356
+ if (typeof message.content !== "string") return undefined;
357
+ return {
358
+ read: () => message.content as string,
359
+ write: value => {
360
+ message.content = value;
361
+ },
362
+ };
363
+ }
364
+ if (!Array.isArray(message.content)) return undefined;
365
+ const block = message.content[blockIndex] as TextContent | undefined;
366
+ if (block?.type !== "text") return undefined;
367
+ return {
368
+ read: () => block.text,
369
+ write: value => {
370
+ block.text = value;
371
+ },
372
+ };
373
+ }
374
+ // custom_message
375
+ if (blockIndex === -1) {
376
+ if (typeof entry.content !== "string") return undefined;
377
+ return {
378
+ read: () => entry.content as string,
379
+ write: value => {
380
+ entry.content = value;
381
+ },
382
+ };
383
+ }
384
+ if (!Array.isArray(entry.content)) return undefined;
385
+ const block = entry.content[blockIndex] as TextContent | undefined;
386
+ if (block?.type !== "text") return undefined;
387
+ return {
388
+ read: () => block.text,
389
+ write: value => {
390
+ block.text = value;
391
+ },
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Pure mutation: replace a single region's content in place.
397
+ *
398
+ * Tool-result: replaces the message content with the placeholder text and
399
+ * stamps `prunedAt`. Block: splices `replacement` over `[start, end)` of the
400
+ * target text block. When several block regions share one text block they MUST
401
+ * be applied highest-start-first so earlier offsets stay valid — use
402
+ * {@link applyShakeRegions}, which orders them correctly.
403
+ */
404
+ export function applyShakeRegion(region: ShakeRegion, replacement: string): void {
405
+ if (region.kind === "toolResult") {
406
+ const message = region.entry.message as ToolResultMessage;
407
+ message.content = [{ type: "text", text: replacement }];
408
+ message.prunedAt = Date.now();
409
+ return;
410
+ }
411
+ const slot = getBlockTextSlot(region.entry, region.blockIndex);
412
+ if (!slot) return;
413
+ const text = slot.read();
414
+ slot.write(text.slice(0, region.start) + replacement + text.slice(region.end));
415
+ }
416
+
417
+ /**
418
+ * Apply many regions at once. Block regions are applied highest-start-first so
419
+ * that splicing one region never shifts the offsets of another in the same text
420
+ * block; tool-result regions are independent.
421
+ */
422
+ export function applyShakeRegions(items: Array<{ region: ShakeRegion; replacement: string }>): void {
423
+ const ordered = [...items].sort((a, b) => {
424
+ const aStart = a.region.kind === "block" ? a.region.start : -1;
425
+ const bStart = b.region.kind === "block" ? b.region.start : -1;
426
+ return bStart - aStart;
427
+ });
428
+ for (const { region, replacement } of ordered) applyShakeRegion(region, replacement);
429
+ }
@@ -0,0 +1,55 @@
1
+ import type { ToolResultMessage } from "jeopi-ai";
2
+ import type { AgentToolCall } from "../types";
3
+ import type { SessionEntry } from "./entries";
4
+
5
+ export interface ProtectedToolContext {
6
+ readonly toolResult: ToolResultMessage;
7
+ readonly toolCall: AgentToolCall | undefined;
8
+ }
9
+
10
+ export type ProtectedToolMatcher = string | ((context: ProtectedToolContext) => boolean);
11
+
12
+ const SKILL_INTERNAL_URL_PREFIX = "skill://";
13
+
14
+ export function collectToolCallsById(entries: readonly SessionEntry[]): Map<string, AgentToolCall> {
15
+ const toolCalls = new Map<string, AgentToolCall>();
16
+ for (const entry of entries) {
17
+ if (entry.type !== "message") continue;
18
+ const message = entry.message;
19
+ if (message.role !== "assistant") continue;
20
+ for (const block of message.content) {
21
+ if (block.type === "toolCall") toolCalls.set(block.id, block);
22
+ }
23
+ }
24
+ return toolCalls;
25
+ }
26
+
27
+ /**
28
+ * Extract the `path` argument from a paired `read` tool call, when the result
29
+ * is a `read` result carrying a string path. Returns `undefined` otherwise.
30
+ * Shared primitive for read-targeted protection matchers (skills, plans, …).
31
+ */
32
+ export function getReadToolPath({ toolResult, toolCall }: ProtectedToolContext): string | undefined {
33
+ if (toolResult.toolName !== "read" || toolCall?.name !== "read") return undefined;
34
+ const path = (toolCall.arguments as Record<string, unknown>).path;
35
+ return typeof path === "string" ? path : undefined;
36
+ }
37
+
38
+ export function isSkillReadToolResult(context: ProtectedToolContext): boolean {
39
+ return getReadToolPath(context)?.startsWith(SKILL_INTERNAL_URL_PREFIX) ?? false;
40
+ }
41
+
42
+ export function isProtectedToolResult(
43
+ toolResult: ToolResultMessage,
44
+ toolCall: AgentToolCall | undefined,
45
+ matchers: readonly ProtectedToolMatcher[],
46
+ ): boolean {
47
+ for (const matcher of matchers) {
48
+ if (typeof matcher === "string") {
49
+ if (toolResult.toolName === matcher) return true;
50
+ continue;
51
+ }
52
+ if (matcher({ toolResult, toolCall })) return true;
53
+ }
54
+ return false;
55
+ }