@prometheus-ai/agent-core 0.5.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 (61) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +473 -0
  3. package/dist/types/agent-loop.d.ts +55 -0
  4. package/dist/types/agent.d.ts +331 -0
  5. package/dist/types/append-only-context.d.ts +113 -0
  6. package/dist/types/compaction/branch-summarization.d.ts +94 -0
  7. package/dist/types/compaction/compaction.d.ts +183 -0
  8. package/dist/types/compaction/entries.d.ts +103 -0
  9. package/dist/types/compaction/errors.d.ts +26 -0
  10. package/dist/types/compaction/index.d.ts +12 -0
  11. package/dist/types/compaction/messages.d.ts +61 -0
  12. package/dist/types/compaction/openai.d.ts +58 -0
  13. package/dist/types/compaction/pruning.d.ts +19 -0
  14. package/dist/types/compaction/shake.d.ts +82 -0
  15. package/dist/types/compaction/tool-protection.d.ts +17 -0
  16. package/dist/types/compaction/utils.d.ts +32 -0
  17. package/dist/types/compaction.d.ts +1 -0
  18. package/dist/types/harmony-leak.d.ts +118 -0
  19. package/dist/types/index.d.ts +11 -0
  20. package/dist/types/proxy.d.ts +84 -0
  21. package/dist/types/run-collector.d.ts +196 -0
  22. package/dist/types/telemetry.d.ts +588 -0
  23. package/dist/types/thinking.d.ts +17 -0
  24. package/dist/types/types.d.ts +443 -0
  25. package/dist/types/utils/yield.d.ts +52 -0
  26. package/package.json +75 -0
  27. package/src/agent-loop.ts +1418 -0
  28. package/src/agent.ts +1236 -0
  29. package/src/append-only-context.ts +297 -0
  30. package/src/compaction/branch-summarization.ts +339 -0
  31. package/src/compaction/compaction.ts +1155 -0
  32. package/src/compaction/entries.ts +133 -0
  33. package/src/compaction/errors.ts +31 -0
  34. package/src/compaction/index.ts +13 -0
  35. package/src/compaction/messages.ts +212 -0
  36. package/src/compaction/openai.ts +552 -0
  37. package/src/compaction/prompts/auto-handoff-threshold-focus.md +1 -0
  38. package/src/compaction/prompts/branch-summary-context.md +5 -0
  39. package/src/compaction/prompts/branch-summary-preamble.md +2 -0
  40. package/src/compaction/prompts/branch-summary.md +30 -0
  41. package/src/compaction/prompts/compaction-short-summary.md +9 -0
  42. package/src/compaction/prompts/compaction-summary-context.md +5 -0
  43. package/src/compaction/prompts/compaction-summary.md +38 -0
  44. package/src/compaction/prompts/compaction-turn-prefix.md +17 -0
  45. package/src/compaction/prompts/compaction-update-summary.md +45 -0
  46. package/src/compaction/prompts/file-operations.md +10 -0
  47. package/src/compaction/prompts/handoff-document.md +49 -0
  48. package/src/compaction/prompts/summarization-system.md +3 -0
  49. package/src/compaction/pruning.ts +99 -0
  50. package/src/compaction/shake.ts +406 -0
  51. package/src/compaction/tool-protection.ts +55 -0
  52. package/src/compaction/utils.ts +185 -0
  53. package/src/compaction.ts +1 -0
  54. package/src/harmony-leak.ts +456 -0
  55. package/src/index.ts +21 -0
  56. package/src/proxy.ts +326 -0
  57. package/src/run-collector.ts +631 -0
  58. package/src/telemetry.ts +2020 -0
  59. package/src/thinking.ts +19 -0
  60. package/src/types.ts +505 -0
  61. package/src/utils/yield.ts +146 -0
@@ -0,0 +1,45 @@
1
+ You MUST incorporate new messages above into the existing handoff summary in <previous-summary> tags, used by another LLM to resume task.
2
+ RULES:
3
+ - MUST preserve all information from previous summary
4
+ - MUST add new progress, decisions, and context from new messages
5
+ - MUST update Progress: move items from "In Progress" to "Done" when completed
6
+ - MUST update "Next Steps" based on what was accomplished
7
+ - MUST preserve exact file paths, function names, and error messages
8
+ - You MAY remove anything no longer relevant
9
+
10
+ IMPORTANT: If new messages end with unanswered question or request to user, you MUST add it to Critical Context (replacing any previous pending question if answered).
11
+
12
+ You MUST use this format (omit sections if not applicable):
13
+
14
+ ## Goal
15
+ [Preserve existing goals; add new ones if task expanded]
16
+
17
+ ## Constraints & Preferences
18
+ - [Preserve existing; add new ones discovered]
19
+
20
+ ## Progress
21
+
22
+ ### Done
23
+ - [x] [Include previously done and newly completed items]
24
+
25
+ ### In Progress
26
+ - [ ] [Current work—update based on progress]
27
+
28
+ ### Blocked
29
+ - [Current blockers—remove if resolved]
30
+
31
+ ## Key Decisions
32
+ - **[Decision]**: [Brief rationale] (preserve all previous, add new)
33
+
34
+ ## Next Steps
35
+ 1. [Update based on current state]
36
+
37
+ ## Critical Context
38
+ - [Preserve important context; add new if needed]
39
+
40
+ ## Additional Notes
41
+ [Other important info not fitting above]
42
+
43
+ You MUST output only the structured summary; you NEVER include extra text.
44
+
45
+ Sections MUST be kept concise. You MUST preserve relevant tool outputs/command results. You MUST include repository state changes (branch, uncommitted changes) if mentioned.
@@ -0,0 +1,10 @@
1
+ {{#if readFiles.length}}
2
+ {{#xml "read-files"}}
3
+ {{join readFiles "\n"}}
4
+ {{/xml}}
5
+ {{/if}}
6
+ {{#if modifiedFiles.length}}
7
+ {{#xml "modified-files"}}
8
+ {{join modifiedFiles "\n"}}
9
+ {{/xml}}
10
+ {{/if}}
@@ -0,0 +1,49 @@
1
+ <critical>
2
+ Write a handoff document for another instance of yourself.
3
+ The handoff MUST be sufficient for seamless continuation without access to this conversation.
4
+ Output ONLY the handoff document. No preamble, no commentary, no wrapper text.
5
+ </critical>
6
+
7
+ <instruction>
8
+ Capture exact technical state, not abstractions.
9
+ - File paths, symbol names, commands run
10
+ - Test results, observed failures
11
+ - Decisions made
12
+ - Partial work affecting the next step
13
+ </instruction>
14
+
15
+ <output>
16
+ Use exactly this structure:
17
+
18
+ ## Goal
19
+ [What the user is trying to accomplish]
20
+
21
+ ## Constraints & Preferences
22
+ - [Any constraints, preferences, or requirements mentioned]
23
+
24
+ ## Progress
25
+ ### Done
26
+ - [x] [Completed tasks with specifics]
27
+
28
+ ### In Progress
29
+ - [ ] [Current work if any]
30
+
31
+ ### Pending
32
+ - [ ] [Tasks mentioned but not started]
33
+
34
+ ## Key Decisions
35
+ - **[Decision]**: [Rationale]
36
+
37
+ ## Critical Context
38
+ - Code snippets, file paths, function/type names, error messages, data essential to continue
39
+ - Repository state if relevant
40
+
41
+ ## Next Steps
42
+ 1. [What should happen next]
43
+ </output>
44
+
45
+ {{#if additionalFocus}}
46
+ <instruction>
47
+ Additional focus: {{additionalFocus}}
48
+ </instruction>
49
+ {{/if}}
@@ -0,0 +1,3 @@
1
+ Summarize conversations between users and AI coding assistants. Produce structured summaries in the exact specified format.
2
+
3
+ Do NOT continue the conversation. Do NOT respond to questions in the conversation. Output ONLY the structured summary.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Tool output pruning utilities for compaction.
3
+ */
4
+
5
+ import type { ToolResultMessage } from "@prometheus-ai/ai";
6
+ import type { AgentMessage } from "../types";
7
+ import { estimateTokens } from "./compaction";
8
+ import type { SessionEntry, SessionMessageEntry } from "./entries";
9
+ import {
10
+ collectToolCallsById,
11
+ isProtectedToolResult,
12
+ isSkillReadToolResult,
13
+ type ProtectedToolMatcher,
14
+ } from "./tool-protection";
15
+
16
+ export interface PruneConfig {
17
+ /** Keep the most recent tool output tokens intact. */
18
+ protectTokens: number;
19
+ /** Only prune if total savings meets this threshold. */
20
+ minimumSavings: number;
21
+ /** Tool-result protection matchers. String entries protect every result from that tool; predicates may inspect the paired tool call. */
22
+ protectedTools: ProtectedToolMatcher[];
23
+ }
24
+
25
+ export const DEFAULT_PRUNE_CONFIG: PruneConfig = {
26
+ protectTokens: 40_000,
27
+ minimumSavings: 20_000,
28
+ protectedTools: ["skill", isSkillReadToolResult],
29
+ };
30
+
31
+ export interface PruneResult {
32
+ prunedCount: number;
33
+ tokensSaved: number;
34
+ }
35
+
36
+ function createPrunedNotice(tokens: number): string {
37
+ return `[Output truncated - ${tokens} tokens]`;
38
+ }
39
+
40
+ function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
41
+ if (entry.type !== "message") return undefined;
42
+ const message = entry.message as AgentMessage;
43
+ if (message.role !== "toolResult") return undefined;
44
+ return message as ToolResultMessage;
45
+ }
46
+
47
+ function estimatePrunedSavings(tokens: number): number {
48
+ const noticeTokens = Math.ceil(createPrunedNotice(tokens).length / 4);
49
+ return Math.max(0, tokens - noticeTokens);
50
+ }
51
+
52
+ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = DEFAULT_PRUNE_CONFIG): PruneResult {
53
+ let accumulatedTokens = 0;
54
+ let tokensSaved = 0;
55
+ let prunedCount = 0;
56
+
57
+ const candidates: Array<{ entry: SessionMessageEntry; tokens: number }> = [];
58
+ const toolCallsById = collectToolCallsById(entries);
59
+
60
+ for (let i = entries.length - 1; i >= 0; i--) {
61
+ const entry = entries[i];
62
+ const message = getToolResultMessage(entry);
63
+ if (!message) continue;
64
+
65
+ const tokens = estimateTokens(message as AgentMessage);
66
+ const isProtected = isProtectedToolResult(message, toolCallsById.get(message.toolCallId), config.protectedTools);
67
+
68
+ if (message.prunedAt !== undefined) {
69
+ accumulatedTokens += tokens;
70
+ continue;
71
+ }
72
+
73
+ if (accumulatedTokens < config.protectTokens || isProtected) {
74
+ accumulatedTokens += tokens;
75
+ continue;
76
+ }
77
+
78
+ candidates.push({ entry: entry as SessionMessageEntry, tokens });
79
+ accumulatedTokens += tokens;
80
+ }
81
+
82
+ for (const candidate of candidates) {
83
+ tokensSaved += estimatePrunedSavings(candidate.tokens);
84
+ }
85
+
86
+ if (tokensSaved < config.minimumSavings || candidates.length === 0) {
87
+ return { prunedCount: 0, tokensSaved: 0 };
88
+ }
89
+
90
+ const prunedAt = Date.now();
91
+ for (const candidate of candidates) {
92
+ const message = candidate.entry.message as ToolResultMessage;
93
+ message.content = [{ type: "text", text: createPrunedNotice(candidate.tokens) }];
94
+ message.prunedAt = prunedAt;
95
+ prunedCount++;
96
+ }
97
+
98
+ return { prunedCount, tokensSaved };
99
+ }
@@ -0,0 +1,406 @@
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 "@prometheus-ai/ai";
14
+ import { countTokens } from "@prometheus-ai/natives";
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
+
36
+ /** Auto-shake config: protects the live tail, conservative thresholds. */
37
+ export const DEFAULT_SHAKE_CONFIG: ShakeConfig = {
38
+ protectTokens: 16_000,
39
+ minSavings: 4_000,
40
+ protectedTools: ["skill", isSkillReadToolResult],
41
+ fenceMinTokens: 400,
42
+ };
43
+
44
+ /** Manual `/shake`: aggressive — drops every eligible region across history. */
45
+ export const AGGRESSIVE_SHAKE_CONFIG: ShakeConfig = {
46
+ protectTokens: 0,
47
+ minSavings: 0,
48
+ protectedTools: ["skill", isSkillReadToolResult],
49
+ fenceMinTokens: 400,
50
+ };
51
+
52
+ /** Rough token cost of a placeholder line; used only for the savings gate. */
53
+ const PLACEHOLDER_TOKEN_ESTIMATE = 16;
54
+
55
+ /** A located eligible region. */
56
+ export interface ToolResultShakeRegion {
57
+ kind: "toolResult";
58
+ entry: SessionMessageEntry;
59
+ tokens: number;
60
+ originalText: string;
61
+ /** Human label for the offload doc (tool name). */
62
+ label: string;
63
+ }
64
+
65
+ export interface BlockShakeRegion {
66
+ kind: "block";
67
+ entry: SessionMessageEntry | CustomMessageEntry;
68
+ /** Index into the content array, or -1 for string-form content. */
69
+ blockIndex: number;
70
+ /** Character offsets into the target text (start inclusive, end exclusive). */
71
+ start: number;
72
+ end: number;
73
+ tokens: number;
74
+ originalText: string;
75
+ /** Human label for the offload doc (role / customType). */
76
+ label: string;
77
+ }
78
+
79
+ export type ShakeRegion = ToolResultShakeRegion | BlockShakeRegion;
80
+
81
+ // Mirror prompt.ts top-level XML detection. Lowercase tag names only —
82
+ // conservative by design (uppercase / mixed-case tags are ignored).
83
+ const OPENING_XML = /^<([a-z_-]+)(?:\s+[^>]*)?>$/;
84
+ const CLOSING_XML = /^<\/([a-z_-]+)>$/;
85
+
86
+ function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
87
+ if (entry.type !== "message") return undefined;
88
+ const message = entry.message as AgentMessage;
89
+ if (message.role !== "toolResult") return undefined;
90
+ return message as ToolResultMessage;
91
+ }
92
+
93
+ function toolResultText(message: ToolResultMessage): string {
94
+ return message.content
95
+ .filter((block): block is TextContent => block.type === "text")
96
+ .map(block => block.text)
97
+ .join("\n");
98
+ }
99
+
100
+ /** Estimate the token contribution of an entry for the protect-recent window. */
101
+ function entryTokens(entry: SessionEntry): number {
102
+ if (entry.type === "message") {
103
+ return estimateTokens(entry.message);
104
+ }
105
+ if (entry.type === "custom_message") {
106
+ const content = entry.content;
107
+ if (typeof content === "string") return content.length === 0 ? 0 : countTokens(content);
108
+ const fragments = content.filter((block): block is TextContent => block.type === "text").map(block => block.text);
109
+ return fragments.length === 0 ? 0 : countTokens(fragments);
110
+ }
111
+ return 0;
112
+ }
113
+
114
+ /**
115
+ * Locate fenced code blocks and top-level XML element spans inside `text`.
116
+ * Returns character ranges `[start, end)` covering the full block (including the
117
+ * opening and closing fence/tag lines, excluding the trailing newline).
118
+ *
119
+ * Conservative: unterminated fences/tags yield no range, and XML detection is
120
+ * suppressed inside fences. Mirrors the toggling logic in
121
+ * `@prometheus-ai/utils` `format()` so behavior stays aligned with prompt rendering.
122
+ */
123
+ function scanTextForBlockRanges(text: string): Array<{ start: number; end: number }> {
124
+ const ranges: Array<{ start: number; end: number }> = [];
125
+ let inFence = false;
126
+ let fenceStart = -1;
127
+ const tagStack: string[] = [];
128
+ let xmlStart = -1;
129
+
130
+ let lineStart = 0;
131
+ for (let i = 0; i <= text.length; i++) {
132
+ if (i !== text.length && text[i] !== "\n") continue;
133
+ const line = text.slice(lineStart, i);
134
+ const lineEnd = i; // offset of the newline (or end of text); excludes the "\n"
135
+ const trimmedStart = line.trimStart();
136
+
137
+ const isFenceLine = trimmedStart.startsWith("```") || trimmedStart.startsWith("~~~");
138
+ if (isFenceLine) {
139
+ if (!inFence) {
140
+ inFence = true;
141
+ fenceStart = lineStart;
142
+ } else {
143
+ inFence = false;
144
+ ranges.push({ start: fenceStart, end: lineEnd });
145
+ fenceStart = -1;
146
+ }
147
+ lineStart = i + 1;
148
+ continue;
149
+ }
150
+
151
+ if (!inFence) {
152
+ const isOpeningXml = line.length === trimmedStart.length && OPENING_XML.test(trimmedStart);
153
+ if (isOpeningXml) {
154
+ const match = OPENING_XML.exec(trimmedStart);
155
+ if (match) {
156
+ if (tagStack.length === 0) xmlStart = lineStart;
157
+ tagStack.push(match[1]);
158
+ }
159
+ } else {
160
+ const closingMatch = CLOSING_XML.exec(trimmedStart);
161
+ if (closingMatch && tagStack.length > 0 && tagStack[tagStack.length - 1] === closingMatch[1]) {
162
+ tagStack.pop();
163
+ if (tagStack.length === 0 && xmlStart >= 0) {
164
+ ranges.push({ start: xmlStart, end: lineEnd });
165
+ xmlStart = -1;
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ lineStart = i + 1;
172
+ }
173
+
174
+ return mergeRanges(ranges);
175
+ }
176
+
177
+ /**
178
+ * Sort ascending by start and drop any range that overlaps an already-kept
179
+ * range. Because fence/XML spans are always properly nested (XML detection is
180
+ * suppressed inside fences), overlap means containment — keeping the
181
+ * earlier-starting range keeps the outermost span.
182
+ */
183
+ function mergeRanges(ranges: Array<{ start: number; end: number }>): Array<{ start: number; end: number }> {
184
+ if (ranges.length <= 1) return ranges;
185
+ const sorted = [...ranges].sort((a, b) => a.start - b.start);
186
+ const kept: Array<{ start: number; end: number }> = [];
187
+ let lastEnd = -1;
188
+ for (const range of sorted) {
189
+ if (range.start < lastEnd) continue;
190
+ kept.push(range);
191
+ lastEnd = range.end;
192
+ }
193
+ return kept;
194
+ }
195
+
196
+ function pushBlockRegions(
197
+ entry: SessionMessageEntry | CustomMessageEntry,
198
+ blockIndex: number,
199
+ text: string,
200
+ config: ShakeConfig,
201
+ label: string,
202
+ out: ShakeRegion[],
203
+ ): void {
204
+ for (const range of scanTextForBlockRanges(text)) {
205
+ const slice = text.slice(range.start, range.end);
206
+ if (slice.length === 0) continue;
207
+ const tokens = countTokens(slice);
208
+ if (tokens < config.fenceMinTokens) continue;
209
+ out.push({
210
+ kind: "block",
211
+ entry,
212
+ blockIndex,
213
+ start: range.start,
214
+ end: range.end,
215
+ tokens,
216
+ originalText: slice,
217
+ label,
218
+ });
219
+ }
220
+ }
221
+
222
+ function collectBlockRegions(
223
+ entry: SessionMessageEntry | CustomMessageEntry,
224
+ config: ShakeConfig,
225
+ out: ShakeRegion[],
226
+ ): void {
227
+ if (entry.type === "message") {
228
+ const message = entry.message;
229
+ if (message.role === "assistant") {
230
+ for (let bi = 0; bi < message.content.length; bi++) {
231
+ const block = message.content[bi];
232
+ if (block.type === "text") pushBlockRegions(entry, bi, block.text, config, "assistant", out);
233
+ }
234
+ return;
235
+ }
236
+ if (message.role === "user" || message.role === "developer") {
237
+ scanContentBlocks(entry, message.content, config, message.role, out);
238
+ }
239
+ return;
240
+ }
241
+ // custom_message
242
+ scanContentBlocks(entry, entry.content, config, entry.customType, out);
243
+ }
244
+
245
+ function scanContentBlocks(
246
+ entry: SessionMessageEntry | CustomMessageEntry,
247
+ content: string | Array<{ type: string; text?: string }>,
248
+ config: ShakeConfig,
249
+ label: string,
250
+ out: ShakeRegion[],
251
+ ): void {
252
+ if (typeof content === "string") {
253
+ pushBlockRegions(entry, -1, content, config, label, out);
254
+ return;
255
+ }
256
+ for (let bi = 0; bi < content.length; bi++) {
257
+ const block = content[bi];
258
+ if (block.type === "text" && typeof block.text === "string") {
259
+ pushBlockRegions(entry, bi, block.text, config, label, out);
260
+ }
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Pure detection: locate every eligible shake region on a branch.
266
+ *
267
+ * Walks the protect-recent window (most recent `protectTokens` of context is
268
+ * kept intact), collects whole tool-result messages (honoring `protectedTools`
269
+ * and skipping already-pruned results) and large fenced/XML blocks inside
270
+ * user/developer/assistant/custom messages. Returns regions in document order.
271
+ *
272
+ * `toolCall` blocks are never touched (tool-call/result pairing is preserved)
273
+ * and regions never span a message boundary. When the combined estimated
274
+ * savings is below `minSavings`, returns `[]` (no-op).
275
+ */
276
+ export function collectShakeRegions(entries: SessionEntry[], config: ShakeConfig): ShakeRegion[] {
277
+ const n = entries.length;
278
+ if (n === 0) return [];
279
+
280
+ // Tokens of all entries strictly more recent than index i.
281
+ const accumulatedAfter = new Array<number>(n);
282
+ let acc = 0;
283
+ for (let i = n - 1; i >= 0; i--) {
284
+ accumulatedAfter[i] = acc;
285
+ acc += entryTokens(entries[i]);
286
+ }
287
+
288
+ const toolCallsById = collectToolCallsById(entries);
289
+
290
+ const regions: ShakeRegion[] = [];
291
+ for (let i = 0; i < n; i++) {
292
+ if (accumulatedAfter[i] < config.protectTokens) continue;
293
+ const entry = entries[i];
294
+
295
+ const toolResult = getToolResultMessage(entry);
296
+ if (toolResult) {
297
+ if (toolResult.prunedAt !== undefined) continue;
298
+ if (isProtectedToolResult(toolResult, toolCallsById.get(toolResult.toolCallId), config.protectedTools))
299
+ continue;
300
+ const text = toolResultText(toolResult);
301
+ if (text.length === 0) continue;
302
+ regions.push({
303
+ kind: "toolResult",
304
+ entry: entry as SessionMessageEntry,
305
+ tokens: estimateTokens(toolResult as AgentMessage),
306
+ originalText: text,
307
+ label: toolResult.toolName,
308
+ });
309
+ continue;
310
+ }
311
+
312
+ if (entry.type === "message" || entry.type === "custom_message") {
313
+ collectBlockRegions(entry as SessionMessageEntry | CustomMessageEntry, config, regions);
314
+ }
315
+ }
316
+
317
+ let savings = 0;
318
+ for (const region of regions) savings += Math.max(0, region.tokens - PLACEHOLDER_TOKEN_ESTIMATE);
319
+ if (savings < config.minSavings) return [];
320
+
321
+ return regions;
322
+ }
323
+
324
+ interface TextSlot {
325
+ read(): string;
326
+ write(value: string): void;
327
+ }
328
+
329
+ function getBlockTextSlot(entry: SessionMessageEntry | CustomMessageEntry, blockIndex: number): TextSlot | undefined {
330
+ if (entry.type === "message") {
331
+ const message = entry.message as { content: unknown };
332
+ if (blockIndex === -1) {
333
+ if (typeof message.content !== "string") return undefined;
334
+ return {
335
+ read: () => message.content as string,
336
+ write: value => {
337
+ message.content = value;
338
+ },
339
+ };
340
+ }
341
+ if (!Array.isArray(message.content)) return undefined;
342
+ const block = message.content[blockIndex] as TextContent | undefined;
343
+ if (block?.type !== "text") return undefined;
344
+ return {
345
+ read: () => block.text,
346
+ write: value => {
347
+ block.text = value;
348
+ },
349
+ };
350
+ }
351
+ // custom_message
352
+ if (blockIndex === -1) {
353
+ if (typeof entry.content !== "string") return undefined;
354
+ return {
355
+ read: () => entry.content as string,
356
+ write: value => {
357
+ entry.content = value;
358
+ },
359
+ };
360
+ }
361
+ if (!Array.isArray(entry.content)) return undefined;
362
+ const block = entry.content[blockIndex] as TextContent | undefined;
363
+ if (block?.type !== "text") return undefined;
364
+ return {
365
+ read: () => block.text,
366
+ write: value => {
367
+ block.text = value;
368
+ },
369
+ };
370
+ }
371
+
372
+ /**
373
+ * Pure mutation: replace a single region's content in place.
374
+ *
375
+ * Tool-result: replaces the message content with the placeholder text and
376
+ * stamps `prunedAt`. Block: splices `replacement` over `[start, end)` of the
377
+ * target text block. When several block regions share one text block they MUST
378
+ * be applied highest-start-first so earlier offsets stay valid — use
379
+ * {@link applyShakeRegions}, which orders them correctly.
380
+ */
381
+ export function applyShakeRegion(region: ShakeRegion, replacement: string): void {
382
+ if (region.kind === "toolResult") {
383
+ const message = region.entry.message as ToolResultMessage;
384
+ message.content = [{ type: "text", text: replacement }];
385
+ message.prunedAt = Date.now();
386
+ return;
387
+ }
388
+ const slot = getBlockTextSlot(region.entry, region.blockIndex);
389
+ if (!slot) return;
390
+ const text = slot.read();
391
+ slot.write(text.slice(0, region.start) + replacement + text.slice(region.end));
392
+ }
393
+
394
+ /**
395
+ * Apply many regions at once. Block regions are applied highest-start-first so
396
+ * that splicing one region never shifts the offsets of another in the same text
397
+ * block; tool-result regions are independent.
398
+ */
399
+ export function applyShakeRegions(items: Array<{ region: ShakeRegion; replacement: string }>): void {
400
+ const ordered = [...items].sort((a, b) => {
401
+ const aStart = a.region.kind === "block" ? a.region.start : -1;
402
+ const bStart = b.region.kind === "block" ? b.region.start : -1;
403
+ return bStart - aStart;
404
+ });
405
+ for (const { region, replacement } of ordered) applyShakeRegion(region, replacement);
406
+ }
@@ -0,0 +1,55 @@
1
+ import type { ToolResultMessage } from "@prometheus-ai/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
+ }