@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.
- package/CHANGELOG.md +7 -0
- package/README.md +473 -0
- package/dist/types/agent-loop.d.ts +55 -0
- package/dist/types/agent.d.ts +331 -0
- package/dist/types/append-only-context.d.ts +113 -0
- package/dist/types/compaction/branch-summarization.d.ts +94 -0
- package/dist/types/compaction/compaction.d.ts +183 -0
- package/dist/types/compaction/entries.d.ts +103 -0
- package/dist/types/compaction/errors.d.ts +26 -0
- package/dist/types/compaction/index.d.ts +12 -0
- package/dist/types/compaction/messages.d.ts +61 -0
- package/dist/types/compaction/openai.d.ts +58 -0
- package/dist/types/compaction/pruning.d.ts +19 -0
- package/dist/types/compaction/shake.d.ts +82 -0
- package/dist/types/compaction/tool-protection.d.ts +17 -0
- package/dist/types/compaction/utils.d.ts +32 -0
- package/dist/types/compaction.d.ts +1 -0
- package/dist/types/harmony-leak.d.ts +118 -0
- package/dist/types/index.d.ts +11 -0
- package/dist/types/proxy.d.ts +84 -0
- package/dist/types/run-collector.d.ts +196 -0
- package/dist/types/telemetry.d.ts +588 -0
- package/dist/types/thinking.d.ts +17 -0
- package/dist/types/types.d.ts +443 -0
- package/dist/types/utils/yield.d.ts +52 -0
- package/package.json +75 -0
- package/src/agent-loop.ts +1418 -0
- package/src/agent.ts +1236 -0
- package/src/append-only-context.ts +297 -0
- package/src/compaction/branch-summarization.ts +339 -0
- package/src/compaction/compaction.ts +1155 -0
- package/src/compaction/entries.ts +133 -0
- package/src/compaction/errors.ts +31 -0
- package/src/compaction/index.ts +13 -0
- package/src/compaction/messages.ts +212 -0
- package/src/compaction/openai.ts +552 -0
- package/src/compaction/prompts/auto-handoff-threshold-focus.md +1 -0
- package/src/compaction/prompts/branch-summary-context.md +5 -0
- package/src/compaction/prompts/branch-summary-preamble.md +2 -0
- package/src/compaction/prompts/branch-summary.md +30 -0
- package/src/compaction/prompts/compaction-short-summary.md +9 -0
- package/src/compaction/prompts/compaction-summary-context.md +5 -0
- package/src/compaction/prompts/compaction-summary.md +38 -0
- package/src/compaction/prompts/compaction-turn-prefix.md +17 -0
- package/src/compaction/prompts/compaction-update-summary.md +45 -0
- package/src/compaction/prompts/file-operations.md +10 -0
- package/src/compaction/prompts/handoff-document.md +49 -0
- package/src/compaction/prompts/summarization-system.md +3 -0
- package/src/compaction/pruning.ts +99 -0
- package/src/compaction/shake.ts +406 -0
- package/src/compaction/tool-protection.ts +55 -0
- package/src/compaction/utils.ts +185 -0
- package/src/compaction.ts +1 -0
- package/src/harmony-leak.ts +456 -0
- package/src/index.ts +21 -0
- package/src/proxy.ts +326 -0
- package/src/run-collector.ts +631 -0
- package/src/telemetry.ts +2020 -0
- package/src/thinking.ts +19 -0
- package/src/types.ts +505 -0
- 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,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,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
|
+
}
|