@bastani/atomic 0.8.26-alpha.9 → 0.8.26
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 +41 -0
- package/dist/builtin/intercom/CHANGELOG.md +18 -0
- package/dist/builtin/intercom/package.json +1 -1
- package/dist/builtin/mcp/CHANGELOG.md +22 -0
- package/dist/builtin/mcp/package.json +1 -1
- package/dist/builtin/subagents/CHANGELOG.md +27 -0
- package/dist/builtin/subagents/agents/codebase-analyzer.md +1 -1
- package/dist/builtin/subagents/agents/codebase-locator.md +1 -1
- package/dist/builtin/subagents/agents/codebase-pattern-finder.md +1 -1
- package/dist/builtin/subagents/agents/codebase-research-analyzer.md +1 -1
- package/dist/builtin/subagents/agents/codebase-research-locator.md +1 -1
- package/dist/builtin/subagents/package.json +1 -1
- package/dist/builtin/web-access/CHANGELOG.md +18 -0
- package/dist/builtin/web-access/package.json +1 -1
- package/dist/builtin/workflows/CHANGELOG.md +34 -0
- package/dist/builtin/workflows/package.json +1 -1
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +4 -2
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/compaction/context-compaction.d.ts +125 -11
- package/dist/core/compaction/context-compaction.d.ts.map +1 -1
- package/dist/core/compaction/context-compaction.js +1112 -78
- package/dist/core/compaction/context-compaction.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/docs/compaction.md +120 -37
- package/docs/extensions.md +4 -4
- package/docs/json.md +1 -1
- package/docs/rpc.md +4 -4
- package/docs/sdk.md +2 -0
- package/docs/session-format.md +1 -1
- package/docs/sessions.md +1 -1
- package/docs/settings.md +1 -1
- package/docs/usage.md +2 -2
- package/package.json +3 -1
|
@@ -1,11 +1,33 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Agent } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { createAssistantMessageEventStream, getSupportedThinkingLevels, isContextOverflow, streamSimple, StringEnum, } from "@earendil-works/pi-ai";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
2
7
|
import { Type } from "typebox";
|
|
3
8
|
import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, } from "../messages.js";
|
|
4
9
|
import { buildContextDeletionFilteredPath, buildContextDeletionFilters, } from "../session-manager.js";
|
|
5
10
|
import { estimateTokens } from "./compaction.js";
|
|
6
11
|
export const CONTEXT_COMPACTION_PROMPT_VERSION = 1;
|
|
7
|
-
const
|
|
8
|
-
const
|
|
12
|
+
const CONTEXT_COMPACTION_THINKING_LEVEL_ORDER = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
13
|
+
const CONTEXT_DELETE_TOOL_NAME = "context_delete";
|
|
14
|
+
const CONTEXT_GREP_DELETE_TOOL_NAME = "context_grep_delete";
|
|
15
|
+
const CONTEXT_SEARCH_TRANSCRIPT_TOOL_NAME = "context_search_transcript";
|
|
16
|
+
const CONTEXT_READ_ENTRY_TOOL_NAME = "context_read_entry";
|
|
17
|
+
export const CONTEXT_COMPACTION_MAX_TURNS = 50;
|
|
18
|
+
const CONTEXT_GREP_DELETE_DEFAULT_MAX_MATCHES = 50;
|
|
19
|
+
const CONTEXT_GREP_DELETE_MAX_REGEX_PATTERN_CHARS = 512;
|
|
20
|
+
const CONTEXT_GREP_DELETE_MAX_REGEX_SCAN_CHARS = 250_000;
|
|
21
|
+
const CONTEXT_MANIFEST_MAX_ENTRIES = 80;
|
|
22
|
+
const CONTEXT_MANIFEST_PREVIEW_CHARS = 240;
|
|
23
|
+
const CONTEXT_CRITICAL_OVERFLOW_RECENT_ENTRY_COUNT = 5;
|
|
24
|
+
const CONTEXT_READ_ENTRY_DEFAULT_MAX_CHARS = 4000;
|
|
25
|
+
const CONTEXT_READ_ENTRY_MAX_CHARS = 12_000;
|
|
26
|
+
const CONTEXT_SEARCH_DEFAULT_MAX_MATCHES = 20;
|
|
27
|
+
const CONTEXT_SEARCH_MAX_MATCHES = 100;
|
|
28
|
+
const CONTEXT_SEARCH_DEFAULT_CONTEXT_CHARS = 160;
|
|
29
|
+
const CONTEXT_SEARCH_MAX_CONTEXT_CHARS = 500;
|
|
30
|
+
const ContextDeleteToolParameters = Type.Object({
|
|
9
31
|
deletions: Type.Array(Type.Object({
|
|
10
32
|
kind: StringEnum(["entry", "content_block"], {
|
|
11
33
|
description: "Delete an entire transcript entry or a single content block within one entry.",
|
|
@@ -17,21 +39,77 @@ const ContextDeletionPlanToolParameters = Type.Object({
|
|
|
17
39
|
})),
|
|
18
40
|
}, { additionalProperties: false }), { description: "Deletion targets only. Protected entries and recent active context must not be included." }),
|
|
19
41
|
}, { additionalProperties: false });
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
description: "
|
|
23
|
-
|
|
42
|
+
const ContextGrepDeleteToolParameters = Type.Object({
|
|
43
|
+
pattern: Type.String({ minLength: 1, description: "Literal text or regular expression to match in transcript text." }),
|
|
44
|
+
regex: Type.Optional(Type.Boolean({ description: "Treat pattern as a JavaScript regular expression. Defaults to false." })),
|
|
45
|
+
caseSensitive: Type.Optional(Type.Boolean({ description: "Use case-sensitive matching. Defaults to false." })),
|
|
46
|
+
target: Type.Optional(StringEnum(["entry", "content_block"], {
|
|
47
|
+
description: "Delete whole matching entries or matching content blocks. Defaults to entry.",
|
|
48
|
+
})),
|
|
49
|
+
maxMatches: Type.Optional(Type.Integer({
|
|
50
|
+
minimum: 1,
|
|
51
|
+
maximum: 200,
|
|
52
|
+
description: "Safety cap. If more unprotected, not-yet-deleted candidate targets are found, no deletions are applied. Defaults to 50.",
|
|
53
|
+
})),
|
|
54
|
+
expectedMatchCount: Type.Optional(Type.Integer({
|
|
55
|
+
minimum: 0,
|
|
56
|
+
description: "Optional safety check. If the match count differs, no deletions are applied.",
|
|
57
|
+
})),
|
|
58
|
+
}, { additionalProperties: false });
|
|
59
|
+
const ContextSearchTranscriptToolParameters = Type.Object({
|
|
60
|
+
pattern: Type.String({ minLength: 1, description: "Literal text or regular expression to search for." }),
|
|
61
|
+
regex: Type.Optional(Type.Boolean({ description: "Treat pattern as a JavaScript regular expression. Defaults to false." })),
|
|
62
|
+
caseSensitive: Type.Optional(Type.Boolean({ description: "Use case-sensitive matching. Defaults to false." })),
|
|
63
|
+
target: Type.Optional(StringEnum(["entry", "content_block"], {
|
|
64
|
+
description: "Search whole entry text or individual content-block text. Defaults to entry.",
|
|
65
|
+
})),
|
|
66
|
+
maxMatches: Type.Optional(Type.Integer({ minimum: 1, maximum: CONTEXT_SEARCH_MAX_MATCHES, description: "Maximum matches to return. Defaults to 20." })),
|
|
67
|
+
contextChars: Type.Optional(Type.Integer({
|
|
68
|
+
minimum: 0,
|
|
69
|
+
maximum: CONTEXT_SEARCH_MAX_CONTEXT_CHARS,
|
|
70
|
+
description: "Characters of context to include before and after each match. Defaults to 160.",
|
|
71
|
+
})),
|
|
72
|
+
}, { additionalProperties: false });
|
|
73
|
+
const ContextReadEntryToolParameters = Type.Object({
|
|
74
|
+
entryId: Type.String({ minLength: 1, description: "Stable transcript entry id to read." }),
|
|
75
|
+
blockIndex: Type.Optional(Type.Integer({ minimum: 0, description: "Optional content block index to read instead of the whole entry text." })),
|
|
76
|
+
offset: Type.Optional(Type.Integer({ minimum: 0, description: "Character offset to begin reading. Defaults to 0." })),
|
|
77
|
+
maxChars: Type.Optional(Type.Integer({
|
|
78
|
+
minimum: 1,
|
|
79
|
+
maximum: CONTEXT_READ_ENTRY_MAX_CHARS,
|
|
80
|
+
description: "Maximum characters to return. Defaults to 4000; keep reads small to avoid overflowing context.",
|
|
81
|
+
})),
|
|
82
|
+
}, { additionalProperties: false });
|
|
83
|
+
const CONTEXT_DELETE_TOOL = {
|
|
84
|
+
name: CONTEXT_DELETE_TOOL_NAME,
|
|
85
|
+
description: "Record context compaction deletion targets directly against the transcript.",
|
|
86
|
+
parameters: ContextDeleteToolParameters,
|
|
87
|
+
};
|
|
88
|
+
const CONTEXT_GREP_DELETE_TOOL = {
|
|
89
|
+
name: CONTEXT_GREP_DELETE_TOOL_NAME,
|
|
90
|
+
description: "Bulk-delete transcript entries or content blocks matching a guarded grep/regex query.",
|
|
91
|
+
parameters: ContextGrepDeleteToolParameters,
|
|
92
|
+
};
|
|
93
|
+
const CONTEXT_SEARCH_TRANSCRIPT_TOOL = {
|
|
94
|
+
name: CONTEXT_SEARCH_TRANSCRIPT_TOOL_NAME,
|
|
95
|
+
description: "Search the full transcript working copy and return small snippets without mutating deletion state.",
|
|
96
|
+
parameters: ContextSearchTranscriptToolParameters,
|
|
97
|
+
};
|
|
98
|
+
const CONTEXT_READ_ENTRY_TOOL = {
|
|
99
|
+
name: CONTEXT_READ_ENTRY_TOOL_NAME,
|
|
100
|
+
description: "Read a small slice of one transcript entry or content block from the full transcript working copy.",
|
|
101
|
+
parameters: ContextReadEntryToolParameters,
|
|
24
102
|
};
|
|
25
|
-
const CONTEXT_COMPACTION_SYSTEM_PROMPT =
|
|
26
|
-
const CONTEXT_COMPACTION_FIXED_PROMPT = `You are a context compaction planner for an AI coding assistant transcript.
|
|
103
|
+
const CONTEXT_COMPACTION_SYSTEM_PROMPT = `You are a context compaction assistant.
|
|
27
104
|
|
|
28
|
-
Your task is deletion-only verbatim compaction
|
|
105
|
+
Your task is to read relevant parts of a conversation between a user and an AI assistant provided via a transcript file, then run a series of tools to apply deletion-only verbatim compaction using the exact context_delete or context_grep_delete format specified.`;
|
|
106
|
+
const CONTEXT_COMPACTION_FIXED_PROMPT = `Reference the provided transcript file transcript and use your search/read tools for small inspections, then use context_delete or context_grep_delete for deletions.
|
|
29
107
|
|
|
30
108
|
You MUST NOT summarize.
|
|
31
109
|
You MUST NOT paraphrase.
|
|
32
110
|
You MUST NOT generate replacement context.
|
|
33
111
|
You MUST NOT mutate retained transcript objects or content.
|
|
34
|
-
|
|
112
|
+
Deletion tool calls are the compaction action; record only deletion targets by stable ID.
|
|
35
113
|
|
|
36
114
|
What Gets Deleted:
|
|
37
115
|
- Redundant tool outputs: file reads already acted on, grep/search results already processed, passing test output no longer needed.
|
|
@@ -47,13 +125,19 @@ What Survives:
|
|
|
47
125
|
- User instructions: The original task and any clarifications.
|
|
48
126
|
|
|
49
127
|
<output_format>
|
|
50
|
-
Call the
|
|
128
|
+
Call the context_delete tool one or more times with deletion targets in this shape:
|
|
51
129
|
{ "deletions": [{ "kind": "entry", "entryId": "..." }] }
|
|
52
130
|
|
|
53
131
|
For content-block deletions, use:
|
|
54
132
|
{ "kind": "content_block", "entryId": "...", "blockIndex": 0 }
|
|
55
133
|
|
|
56
|
-
|
|
134
|
+
The tool applies and validates deletion targets immediately. You can continue calling it for additional deletions if useful.
|
|
135
|
+
|
|
136
|
+
For guarded bulk deletion by text match, call context_grep_delete with a literal pattern or regex. It skips protected context, enforces maxMatches and expectedMatchCount, and validates through the same tool-call/tool-result safety rules.
|
|
137
|
+
|
|
138
|
+
The full transcript is available as a JSONL file path in the prompt, but do NOT try to load the whole file into context. Use context_search_transcript to find candidate entry IDs and context_read_entry to read only small slices (for example maxChars 1000-4000) before deleting.
|
|
139
|
+
|
|
140
|
+
When you are done, reply with a brief plain-text completion message. Do not write deletion JSON or deletion target IDs outside tool calls.
|
|
57
141
|
</output_format>`;
|
|
58
142
|
function getMessageFromEntry(entry) {
|
|
59
143
|
if (entry.type === "message") {
|
|
@@ -239,7 +323,7 @@ function isProtectedEntry(entry, message, recentEntryIds) {
|
|
|
239
323
|
return true;
|
|
240
324
|
return false;
|
|
241
325
|
}
|
|
242
|
-
export function prepareContextCompaction(pathEntries, settings) {
|
|
326
|
+
export function prepareContextCompaction(pathEntries, settings, options = {}) {
|
|
243
327
|
if (pathEntries.length === 0)
|
|
244
328
|
return undefined;
|
|
245
329
|
const latestCompactionIndex = collectLatestSummaryCompactionIndex(pathEntries);
|
|
@@ -251,7 +335,7 @@ export function prepareContextCompaction(pathEntries, settings) {
|
|
|
251
335
|
.map((index) => filteredEntryById.get(pathEntries[index].id))
|
|
252
336
|
.filter((entry) => entry !== undefined && getContextEligibleMessageFromEntry(entry) !== undefined)
|
|
253
337
|
.map((entry) => entry.id);
|
|
254
|
-
const recentEntryIds = new Set(messageEntryIds.slice(-
|
|
338
|
+
const recentEntryIds = new Set(messageEntryIds.slice(-CONTEXT_CRITICAL_OVERFLOW_RECENT_ENTRY_COUNT));
|
|
255
339
|
const protectedEntryIds = new Set();
|
|
256
340
|
const entries = [];
|
|
257
341
|
if (latestCompactionIndex >= 0) {
|
|
@@ -306,6 +390,7 @@ export function prepareContextCompaction(pathEntries, settings) {
|
|
|
306
390
|
return undefined;
|
|
307
391
|
return {
|
|
308
392
|
branchEntries: pathEntries,
|
|
393
|
+
mode: options.mode ?? "standard",
|
|
309
394
|
transcript: {
|
|
310
395
|
entries,
|
|
311
396
|
protectedEntryIds: [...protectedEntryIds],
|
|
@@ -325,6 +410,14 @@ function normalizeRawTarget(target) {
|
|
|
325
410
|
return { kind: "entry", entryId: target.entryId };
|
|
326
411
|
return { kind: "content_block", entryId: target.entryId, blockIndex: target.blockIndex };
|
|
327
412
|
}
|
|
413
|
+
function rawDeletionFromTarget(target) {
|
|
414
|
+
if (target.kind === "entry")
|
|
415
|
+
return { kind: "entry", entryId: target.entryId };
|
|
416
|
+
return { kind: "content_block", entryId: target.entryId, blockIndex: target.blockIndex };
|
|
417
|
+
}
|
|
418
|
+
function deletionRequestFromTargets(targets) {
|
|
419
|
+
return { deletions: targets.map(rawDeletionFromTarget) };
|
|
420
|
+
}
|
|
328
421
|
function getDeletedEntryIds(targets) {
|
|
329
422
|
return new Set(targets.filter((target) => target.kind === "entry").map((target) => target.entryId));
|
|
330
423
|
}
|
|
@@ -347,6 +440,153 @@ function isToolCallBlockDeleted(entry, callId, deletedEntryIds, deletedContentBl
|
|
|
347
440
|
return false;
|
|
348
441
|
return entry.contentBlocks.some((block) => block.toolCallId === callId && deletedBlocks.has(block.blockIndex));
|
|
349
442
|
}
|
|
443
|
+
function toolCallBlockIndexes(entry, callId) {
|
|
444
|
+
return entry.contentBlocks
|
|
445
|
+
.filter((block) => block.toolCallId === callId)
|
|
446
|
+
.map((block) => block.blockIndex);
|
|
447
|
+
}
|
|
448
|
+
function addTarget(targets, target) {
|
|
449
|
+
if (targets.some((existing) => targetKey(existing) === targetKey(target)))
|
|
450
|
+
return false;
|
|
451
|
+
targets.push(target);
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
function deleteEntryTarget(targets, entryId) {
|
|
455
|
+
let changed = false;
|
|
456
|
+
for (let index = targets.length - 1; index >= 0; index--) {
|
|
457
|
+
const target = targets[index];
|
|
458
|
+
if (target.kind === "content_block" && target.entryId === entryId) {
|
|
459
|
+
targets.splice(index, 1);
|
|
460
|
+
changed = true;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return addTarget(targets, { kind: "entry", entryId }) || changed;
|
|
464
|
+
}
|
|
465
|
+
function removeEntryDeletion(targets, entryId) {
|
|
466
|
+
const originalLength = targets.length;
|
|
467
|
+
for (let index = targets.length - 1; index >= 0; index--) {
|
|
468
|
+
const target = targets[index];
|
|
469
|
+
if (target.kind === "entry" && target.entryId === entryId)
|
|
470
|
+
targets.splice(index, 1);
|
|
471
|
+
}
|
|
472
|
+
return targets.length !== originalLength;
|
|
473
|
+
}
|
|
474
|
+
function mergeContextDeletionTargets(baseTargets, additionalTargets) {
|
|
475
|
+
const targets = [...baseTargets];
|
|
476
|
+
for (const target of additionalTargets) {
|
|
477
|
+
if (target.kind === "entry") {
|
|
478
|
+
deleteEntryTarget(targets, target.entryId);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (!getDeletedEntryIds(targets).has(target.entryId)) {
|
|
482
|
+
addTarget(targets, target);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return targets;
|
|
486
|
+
}
|
|
487
|
+
function canonicalizeEntryTargets(targets, entry) {
|
|
488
|
+
if (entry.protected || getDeletedEntryIds(targets).has(entry.entryId))
|
|
489
|
+
return false;
|
|
490
|
+
const deletedBlocks = getDeletedContentBlocks(targets).get(entry.entryId);
|
|
491
|
+
if (!deletedBlocks || !entry.contentBlocks.every((block) => deletedBlocks.has(block.blockIndex)))
|
|
492
|
+
return false;
|
|
493
|
+
// Only repair/promote when dependency reconciliation reaches this entry. Non-tool entries that
|
|
494
|
+
// request every block individually stay invalid so the assistant must choose explicit entry deletion.
|
|
495
|
+
return deleteEntryTarget(targets, entry.entryId);
|
|
496
|
+
}
|
|
497
|
+
function removeToolCallDeletion(targets, entry, callId) {
|
|
498
|
+
let changed = removeEntryDeletion(targets, entry.entryId);
|
|
499
|
+
const blockIndexes = new Set(toolCallBlockIndexes(entry, callId));
|
|
500
|
+
for (let index = targets.length - 1; index >= 0; index--) {
|
|
501
|
+
const target = targets[index];
|
|
502
|
+
if (target.kind === "content_block" && target.entryId === entry.entryId && blockIndexes.has(target.blockIndex)) {
|
|
503
|
+
targets.splice(index, 1);
|
|
504
|
+
changed = true;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return changed;
|
|
508
|
+
}
|
|
509
|
+
function addToolCallDeletion(targets, entry, callId) {
|
|
510
|
+
if (entry.protected)
|
|
511
|
+
return false;
|
|
512
|
+
let changed = false;
|
|
513
|
+
for (const blockIndex of toolCallBlockIndexes(entry, callId)) {
|
|
514
|
+
if (!getDeletedEntryIds(targets).has(entry.entryId)) {
|
|
515
|
+
changed = addTarget(targets, { kind: "content_block", entryId: entry.entryId, blockIndex }) || changed;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return canonicalizeEntryTargets(targets, entry) || changed;
|
|
519
|
+
}
|
|
520
|
+
let warnedReconciliationNonConvergence = false;
|
|
521
|
+
function reconcileToolDependencies(transcript, initialTargets) {
|
|
522
|
+
const targets = [...initialTargets];
|
|
523
|
+
const callEntries = new Map();
|
|
524
|
+
const entriesWithToolCalls = new Set();
|
|
525
|
+
const resultEntries = new Map();
|
|
526
|
+
for (const entry of transcript.entries) {
|
|
527
|
+
for (const callId of entry.toolCallIds) {
|
|
528
|
+
callEntries.set(callId, entry);
|
|
529
|
+
entriesWithToolCalls.add(entry);
|
|
530
|
+
}
|
|
531
|
+
if (entry.toolResultFor) {
|
|
532
|
+
const results = resultEntries.get(entry.toolResultFor) ?? [];
|
|
533
|
+
results.push(entry);
|
|
534
|
+
resultEntries.set(entry.toolResultFor, results);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// Bounded fixpoint repair: each pass can add/remove paired call/result targets. In practice this
|
|
538
|
+
// converges within one or two passes; the cap protects against accidental oscillation.
|
|
539
|
+
let changed = true;
|
|
540
|
+
let remainingPasses = Math.max(1, transcript.entries.length * 2);
|
|
541
|
+
while (changed && remainingPasses > 0) {
|
|
542
|
+
changed = false;
|
|
543
|
+
remainingPasses -= 1;
|
|
544
|
+
let deletedEntryIds = getDeletedEntryIds(targets);
|
|
545
|
+
let deletedContentBlocks = getDeletedContentBlocks(targets);
|
|
546
|
+
const recordChange = (nextChanged) => {
|
|
547
|
+
if (!nextChanged)
|
|
548
|
+
return;
|
|
549
|
+
changed = true;
|
|
550
|
+
deletedEntryIds = getDeletedEntryIds(targets);
|
|
551
|
+
deletedContentBlocks = getDeletedContentBlocks(targets);
|
|
552
|
+
};
|
|
553
|
+
for (const [callId, callEntry] of callEntries) {
|
|
554
|
+
const callDeleted = isToolCallBlockDeleted(callEntry, callId, deletedEntryIds, deletedContentBlocks);
|
|
555
|
+
const results = resultEntries.get(callId) ?? [];
|
|
556
|
+
if (callDeleted) {
|
|
557
|
+
const retainedProtectedResult = results.find((entry) => entry.protected && !deletedEntryIds.has(entry.entryId));
|
|
558
|
+
if (retainedProtectedResult) {
|
|
559
|
+
recordChange(removeToolCallDeletion(targets, callEntry, callId));
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
for (const result of results) {
|
|
563
|
+
recordChange(deleteEntryTarget(targets, result.entryId));
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (isToolCallBlockDeleted(callEntry, callId, deletedEntryIds, deletedContentBlocks))
|
|
568
|
+
continue;
|
|
569
|
+
for (const result of results) {
|
|
570
|
+
if (!deletedEntryIds.has(result.entryId))
|
|
571
|
+
continue;
|
|
572
|
+
recordChange(deleteEntryTarget(targets, result.entryId));
|
|
573
|
+
if (callEntry.protected) {
|
|
574
|
+
recordChange(removeEntryDeletion(targets, result.entryId));
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
recordChange(addToolCallDeletion(targets, callEntry, callId));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
for (const entry of entriesWithToolCalls) {
|
|
581
|
+
recordChange(canonicalizeEntryTargets(targets, entry));
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (changed && !warnedReconciliationNonConvergence) {
|
|
585
|
+
warnedReconciliationNonConvergence = true;
|
|
586
|
+
console.warn(`Context compaction tool dependency reconciliation did not converge within the bounded pass limit; validation will continue with the last reconciled target set. entries=${transcript.entries.length} callEntries=${callEntries.size} targets=${targets.length}`);
|
|
587
|
+
}
|
|
588
|
+
return targets;
|
|
589
|
+
}
|
|
350
590
|
function validateToolDependencies(transcript, targets) {
|
|
351
591
|
const deletedEntryIds = getDeletedEntryIds(targets);
|
|
352
592
|
const deletedContentBlocks = getDeletedContentBlocks(targets);
|
|
@@ -415,15 +655,44 @@ function computeContextCompactionStats(transcript, targets) {
|
|
|
415
655
|
percentReduction,
|
|
416
656
|
};
|
|
417
657
|
}
|
|
418
|
-
|
|
419
|
-
if (!
|
|
420
|
-
|
|
658
|
+
function isCriticalOverflowProtectedEntryDeletable(entry, transcript) {
|
|
659
|
+
if (!entry.protected)
|
|
660
|
+
return true;
|
|
661
|
+
const entryIndex = transcript.entries.findIndex((candidate) => candidate.entryId === entry.entryId);
|
|
662
|
+
if (entryIndex < 0)
|
|
663
|
+
return false;
|
|
664
|
+
const recentBoundary = Math.max(0, transcript.entries.length - CONTEXT_CRITICAL_OVERFLOW_RECENT_ENTRY_COUNT);
|
|
665
|
+
if (entryIndex >= recentBoundary)
|
|
666
|
+
return false;
|
|
667
|
+
if (hasAssistantError(entry.message) || hasToolResultError(entry.message) || hasFailedBashExecution(entry.message)) {
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
return (entry.role === "user" ||
|
|
671
|
+
entry.role === "custom" ||
|
|
672
|
+
entry.role === "branchSummary" ||
|
|
673
|
+
entry.role === "compactionSummary" ||
|
|
674
|
+
entry.entryType === "branch_summary");
|
|
675
|
+
}
|
|
676
|
+
function canDeleteProtectedTargetInMode(transcript, target, mode) {
|
|
677
|
+
if (mode !== "critical_overflow")
|
|
678
|
+
return false;
|
|
679
|
+
const entry = transcript.entries.find((candidate) => candidate.entryId === target.entryId);
|
|
680
|
+
if (!entry || !isCriticalOverflowProtectedEntryDeletable(entry, transcript))
|
|
681
|
+
return false;
|
|
682
|
+
if (target.kind === "entry")
|
|
683
|
+
return true;
|
|
684
|
+
const block = entry.contentBlocks.find((candidate) => candidate.blockIndex === target.blockIndex);
|
|
685
|
+
return block !== undefined;
|
|
686
|
+
}
|
|
687
|
+
export function validateContextDeletionRequest(request, transcript, options = {}) {
|
|
688
|
+
const mode = options.mode ?? "standard";
|
|
689
|
+
if (!request || typeof request !== "object" || !Array.isArray(request.deletions)) {
|
|
690
|
+
throw new Error("Context deletion request must be an object with a deletions array");
|
|
421
691
|
}
|
|
422
692
|
const entryById = new Map(transcript.entries.map((entry) => [entry.entryId, entry]));
|
|
423
693
|
const seen = new Set();
|
|
424
|
-
const deletedEntryIds = new Set();
|
|
425
694
|
const deletedTargets = [];
|
|
426
|
-
for (const deletion of
|
|
695
|
+
for (const deletion of request.deletions) {
|
|
427
696
|
if (!deletion || typeof deletion !== "object") {
|
|
428
697
|
throw new Error("Deletion target must be an object");
|
|
429
698
|
}
|
|
@@ -437,7 +706,7 @@ export function validateContextDeletionPlan(plan, transcript) {
|
|
|
437
706
|
if (!entry) {
|
|
438
707
|
throw new Error(`Unknown deletion target entryId: ${deletion.entryId}`);
|
|
439
708
|
}
|
|
440
|
-
if (entry.protected) {
|
|
709
|
+
if (entry.protected && !canDeleteProtectedTargetInMode(transcript, normalizeRawTarget(deletion), mode)) {
|
|
441
710
|
throw new Error(`Deletion target ${deletion.entryId} is protected`);
|
|
442
711
|
}
|
|
443
712
|
if (deletion.kind === "content_block") {
|
|
@@ -448,7 +717,7 @@ export function validateContextDeletionPlan(plan, transcript) {
|
|
|
448
717
|
if (!block) {
|
|
449
718
|
throw new Error(`Unknown content block ${deletion.blockIndex} for entry ${deletion.entryId}`);
|
|
450
719
|
}
|
|
451
|
-
if (block.protected) {
|
|
720
|
+
if (block.protected && !canDeleteProtectedTargetInMode(transcript, normalizeRawTarget(deletion), mode)) {
|
|
452
721
|
throw new Error(`Content block ${deletion.entryId}:${deletion.blockIndex} is protected`);
|
|
453
722
|
}
|
|
454
723
|
if (entry.contentBlocks.length <= 1) {
|
|
@@ -462,34 +731,34 @@ export function validateContextDeletionPlan(plan, transcript) {
|
|
|
462
731
|
seen.add(key);
|
|
463
732
|
const normalized = normalizeRawTarget(deletion);
|
|
464
733
|
deletedTargets.push(normalized);
|
|
465
|
-
if (normalized.kind === "entry")
|
|
466
|
-
deletedEntryIds.add(normalized.entryId);
|
|
467
734
|
}
|
|
468
|
-
|
|
469
|
-
|
|
735
|
+
const reconciledTargets = reconcileToolDependencies(transcript, deletedTargets);
|
|
736
|
+
const reconciledDeletedEntryIds = getDeletedEntryIds(reconciledTargets);
|
|
737
|
+
for (const target of reconciledTargets) {
|
|
738
|
+
if (target.kind === "content_block" && reconciledDeletedEntryIds.has(target.entryId)) {
|
|
470
739
|
throw new Error(`Deletion target ${targetKey(target)} overlaps with entry deletion`);
|
|
471
740
|
}
|
|
472
741
|
}
|
|
473
|
-
const deletedContentBlocks = getDeletedContentBlocks(
|
|
742
|
+
const deletedContentBlocks = getDeletedContentBlocks(reconciledTargets);
|
|
474
743
|
for (const [entryId, blockIndexes] of deletedContentBlocks) {
|
|
475
744
|
const entry = entryById.get(entryId);
|
|
476
745
|
if (entry?.contentBlocks.every((block) => blockIndexes.has(block.blockIndex))) {
|
|
477
746
|
throw new Error(`Content-block deletions for ${entryId} would remove every content block`);
|
|
478
747
|
}
|
|
479
748
|
}
|
|
480
|
-
validateToolDependencies(transcript,
|
|
481
|
-
const remainingEntries = transcript.entries.filter((entry) => !
|
|
749
|
+
validateToolDependencies(transcript, reconciledTargets);
|
|
750
|
+
const remainingEntries = transcript.entries.filter((entry) => !reconciledDeletedEntryIds.has(entry.entryId));
|
|
482
751
|
if (remainingEntries.length === 0) {
|
|
483
|
-
throw new Error("Deletion
|
|
752
|
+
throw new Error("Deletion request would remove all context entries");
|
|
484
753
|
}
|
|
485
754
|
const hasTaskBearingContext = remainingEntries.some((entry) => entry.role === "user" || (entry.role === "compactionSummary" && entry.protected));
|
|
486
755
|
if (!hasTaskBearingContext) {
|
|
487
|
-
throw new Error("Deletion
|
|
756
|
+
throw new Error("Deletion request would leave no user task in context");
|
|
488
757
|
}
|
|
489
758
|
return {
|
|
490
|
-
deletedTargets,
|
|
759
|
+
deletedTargets: reconciledTargets,
|
|
491
760
|
protectedEntryIds: [...transcript.protectedEntryIds],
|
|
492
|
-
stats: computeContextCompactionStats(transcript,
|
|
761
|
+
stats: computeContextCompactionStats(transcript, reconciledTargets),
|
|
493
762
|
};
|
|
494
763
|
}
|
|
495
764
|
function stripJsonFence(text) {
|
|
@@ -504,25 +773,641 @@ function stripJsonFence(text) {
|
|
|
504
773
|
return trimmed;
|
|
505
774
|
return trimmed.slice(firstLineEnd + 1, -3).trim();
|
|
506
775
|
}
|
|
507
|
-
function
|
|
776
|
+
function contextDeletionRequestFromObject(value, source) {
|
|
508
777
|
if (!value || typeof value !== "object" || !Array.isArray(value.deletions)) {
|
|
509
778
|
throw new Error(`${source} must contain a deletions array`);
|
|
510
779
|
}
|
|
511
780
|
return value;
|
|
512
781
|
}
|
|
513
|
-
|
|
782
|
+
function escapeRegExpLiteral(text) {
|
|
783
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
784
|
+
}
|
|
785
|
+
function formatErrorMessage(error) {
|
|
786
|
+
return error instanceof Error ? error.message : String(error);
|
|
787
|
+
}
|
|
788
|
+
function createContextDeletionToolResult(text, details) {
|
|
789
|
+
return { content: [{ type: "text", text }], details, terminate: false };
|
|
790
|
+
}
|
|
791
|
+
function assertSafeRegexPattern(pattern) {
|
|
792
|
+
if (pattern.length > CONTEXT_GREP_DELETE_MAX_REGEX_PATTERN_CHARS) {
|
|
793
|
+
throw new Error(`Regex pattern is too long (${pattern.length} characters); maximum is ${CONTEXT_GREP_DELETE_MAX_REGEX_PATTERN_CHARS}`);
|
|
794
|
+
}
|
|
795
|
+
// Heuristic ReDoS guard for common catastrophic-backtracking shapes. JavaScript's RegExp engine
|
|
796
|
+
// does not expose a timeout, so reject nested quantified groups and backreferences instead of
|
|
797
|
+
// relying only on transcript scan-size caps.
|
|
798
|
+
const hasNestedQuantifiedGroup = /\((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*\)\s*(?:[+*]|\{\d)/u.test(pattern);
|
|
799
|
+
const hasQuantifiedAlternation = /\((?:[^()\\]|\\.)*\|(?:[^()\\]|\\.)*\)\s*(?:[+*]|\{\d)/u.test(pattern);
|
|
800
|
+
const hasBackreference = /\\[1-9]/u.test(pattern);
|
|
801
|
+
if (hasNestedQuantifiedGroup || hasQuantifiedAlternation || hasBackreference) {
|
|
802
|
+
throw new Error("Regex pattern is not allowed because it may cause excessive backtracking; use a literal pattern or exact deletion targets instead.");
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
function createGrepMatcher(pattern, regex, caseSensitive) {
|
|
806
|
+
if (regex) {
|
|
807
|
+
assertSafeRegexPattern(pattern);
|
|
808
|
+
}
|
|
809
|
+
try {
|
|
810
|
+
return new RegExp(regex ? pattern : escapeRegExpLiteral(pattern), caseSensitive ? "u" : "iu");
|
|
811
|
+
}
|
|
812
|
+
catch (error) {
|
|
813
|
+
throw new Error(`Invalid grep ${regex ? "regex" : "pattern"}: ${formatErrorMessage(error)}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
function assertSafeRegexScan(scanChars) {
|
|
817
|
+
if (scanChars <= CONTEXT_GREP_DELETE_MAX_REGEX_SCAN_CHARS)
|
|
818
|
+
return;
|
|
819
|
+
throw new Error(`Regex grep would scan ${scanChars} characters; maximum is ${CONTEXT_GREP_DELETE_MAX_REGEX_SCAN_CHARS}. Use a literal pattern or exact deletion targets instead.`);
|
|
820
|
+
}
|
|
821
|
+
function clampInteger(value, defaultValue, minimum, maximum) {
|
|
822
|
+
if (value === undefined)
|
|
823
|
+
return defaultValue;
|
|
824
|
+
return Math.max(minimum, Math.min(maximum, value));
|
|
825
|
+
}
|
|
826
|
+
function textSlice(text, offset, maxChars) {
|
|
827
|
+
return text.slice(offset, Math.min(text.length, offset + maxChars));
|
|
828
|
+
}
|
|
829
|
+
function findMatchIndex(matcher, text) {
|
|
830
|
+
const match = matcher.exec(text);
|
|
831
|
+
matcher.lastIndex = 0;
|
|
832
|
+
return match?.index ?? -1;
|
|
833
|
+
}
|
|
834
|
+
function snippetForMatch(text, matchIndex, contextChars) {
|
|
835
|
+
const start = Math.max(0, matchIndex - contextChars);
|
|
836
|
+
const end = Math.min(text.length, matchIndex + contextChars);
|
|
837
|
+
const prefix = start > 0 ? "…" : "";
|
|
838
|
+
const suffix = end < text.length ? "…" : "";
|
|
839
|
+
return `${prefix}${text.slice(start, end)}${suffix}`;
|
|
840
|
+
}
|
|
841
|
+
function currentTargetDeleted(targets, target) {
|
|
842
|
+
const deletedEntryIds = getDeletedEntryIds(targets);
|
|
843
|
+
if (deletedEntryIds.has(target.entryId))
|
|
844
|
+
return true;
|
|
845
|
+
if (target.kind === "entry")
|
|
846
|
+
return false;
|
|
847
|
+
return getDeletedContentBlocks(targets).get(target.entryId)?.has(target.blockIndex) === true;
|
|
848
|
+
}
|
|
849
|
+
function addGrepCandidate(candidates, matches, seenTargets, candidate, match) {
|
|
850
|
+
const key = targetKey(candidate);
|
|
851
|
+
if (seenTargets.has(key))
|
|
852
|
+
return;
|
|
853
|
+
seenTargets.add(key);
|
|
854
|
+
candidates.push(candidate);
|
|
855
|
+
matches.push(match);
|
|
856
|
+
}
|
|
857
|
+
const moduleRequire = createRequire(import.meta.url);
|
|
858
|
+
function createTransientSqliteDatabase() {
|
|
859
|
+
// better-sqlite3 is the portable/package dependency for Node-based Atomic installs.
|
|
860
|
+
// Bun cannot dlopen better-sqlite3 yet, so Bun runtime/tests use the API-compatible builtin.
|
|
861
|
+
if (process.versions.bun) {
|
|
862
|
+
const sqlite = moduleRequire("bun:sqlite");
|
|
863
|
+
return new sqlite.Database(":memory:");
|
|
864
|
+
}
|
|
865
|
+
const BetterSqliteDatabase = moduleRequire("better-sqlite3");
|
|
866
|
+
return new BetterSqliteDatabase(":memory:");
|
|
867
|
+
}
|
|
868
|
+
class SqliteAdapter {
|
|
869
|
+
constructor(db) {
|
|
870
|
+
this.db = db;
|
|
871
|
+
}
|
|
872
|
+
static createTransient() {
|
|
873
|
+
return new SqliteAdapter(createTransientSqliteDatabase());
|
|
874
|
+
}
|
|
875
|
+
exec(sql) {
|
|
876
|
+
this.db.exec(sql);
|
|
877
|
+
}
|
|
878
|
+
run(sql, ...params) {
|
|
879
|
+
this.db.prepare(sql).run(...params);
|
|
880
|
+
}
|
|
881
|
+
all(sql, ...params) {
|
|
882
|
+
return this.db.prepare(sql).all(...params);
|
|
883
|
+
}
|
|
884
|
+
get(sql, ...params) {
|
|
885
|
+
return this.db.prepare(sql).get(...params);
|
|
886
|
+
}
|
|
887
|
+
transaction(operation) {
|
|
888
|
+
this.exec("BEGIN IMMEDIATE");
|
|
889
|
+
try {
|
|
890
|
+
const result = operation();
|
|
891
|
+
this.exec("COMMIT");
|
|
892
|
+
return result;
|
|
893
|
+
}
|
|
894
|
+
catch (error) {
|
|
895
|
+
try {
|
|
896
|
+
this.exec("ROLLBACK");
|
|
897
|
+
}
|
|
898
|
+
catch { }
|
|
899
|
+
throw error;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
close() {
|
|
903
|
+
this.db.close();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
class ContextDeletionSqliteStore {
|
|
907
|
+
constructor(sqlite) {
|
|
908
|
+
this.sqlite = sqlite;
|
|
909
|
+
}
|
|
910
|
+
initialize(transcript) {
|
|
911
|
+
this.sqlite.transaction(() => {
|
|
912
|
+
this.sqlite.exec(`
|
|
913
|
+
PRAGMA foreign_keys = ON;
|
|
914
|
+
CREATE TABLE transcript_entries (
|
|
915
|
+
position INTEGER PRIMARY KEY,
|
|
916
|
+
entry_id TEXT NOT NULL UNIQUE,
|
|
917
|
+
role TEXT NOT NULL,
|
|
918
|
+
is_protected INTEGER NOT NULL,
|
|
919
|
+
token_estimate INTEGER NOT NULL,
|
|
920
|
+
text TEXT NOT NULL,
|
|
921
|
+
tool_result_for TEXT
|
|
922
|
+
);
|
|
923
|
+
CREATE TABLE transcript_content_blocks (
|
|
924
|
+
entry_id TEXT NOT NULL,
|
|
925
|
+
block_index INTEGER NOT NULL,
|
|
926
|
+
type TEXT NOT NULL,
|
|
927
|
+
is_protected INTEGER NOT NULL,
|
|
928
|
+
token_estimate INTEGER NOT NULL,
|
|
929
|
+
text TEXT NOT NULL,
|
|
930
|
+
tool_call_id TEXT,
|
|
931
|
+
PRIMARY KEY (entry_id, block_index),
|
|
932
|
+
FOREIGN KEY (entry_id) REFERENCES transcript_entries(entry_id) ON DELETE CASCADE
|
|
933
|
+
);
|
|
934
|
+
CREATE TABLE deletion_targets (
|
|
935
|
+
position INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
936
|
+
target_key TEXT NOT NULL UNIQUE,
|
|
937
|
+
kind TEXT NOT NULL CHECK (kind IN ('entry', 'content_block')),
|
|
938
|
+
entry_id TEXT NOT NULL,
|
|
939
|
+
block_index INTEGER
|
|
940
|
+
);
|
|
941
|
+
CREATE TABLE context_compaction_state (
|
|
942
|
+
key TEXT PRIMARY KEY,
|
|
943
|
+
value TEXT NOT NULL
|
|
944
|
+
);
|
|
945
|
+
INSERT INTO context_compaction_state (key, value) VALUES ('call_count', '0');
|
|
946
|
+
`);
|
|
947
|
+
for (const [position, entry] of transcript.entries.entries()) {
|
|
948
|
+
this.sqlite.run("INSERT INTO transcript_entries (position, entry_id, role, is_protected, token_estimate, text, tool_result_for) VALUES (?, ?, ?, ?, ?, ?, ?)", position, entry.entryId, entry.role, entry.protected ? 1 : 0, entry.tokenEstimate, entry.text, entry.toolResultFor ?? null);
|
|
949
|
+
for (const block of entry.contentBlocks) {
|
|
950
|
+
this.sqlite.run("INSERT INTO transcript_content_blocks (entry_id, block_index, type, is_protected, token_estimate, text, tool_call_id) VALUES (?, ?, ?, ?, ?, ?, ?)", block.entryId, block.blockIndex, block.type, block.protected ? 1 : 0, block.tokenEstimate, block.text, block.toolCallId ?? null);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
transaction(operation) {
|
|
956
|
+
return this.sqlite.transaction(operation);
|
|
957
|
+
}
|
|
958
|
+
readTargets() {
|
|
959
|
+
return this.sqlite
|
|
960
|
+
.all("SELECT kind, entry_id, block_index FROM deletion_targets ORDER BY position")
|
|
961
|
+
.map((row) => row.kind === "entry"
|
|
962
|
+
? { kind: "entry", entryId: row.entry_id }
|
|
963
|
+
: { kind: "content_block", entryId: row.entry_id, blockIndex: row.block_index });
|
|
964
|
+
}
|
|
965
|
+
replaceTargets(targets) {
|
|
966
|
+
this.sqlite.run("DELETE FROM deletion_targets");
|
|
967
|
+
for (const target of targets) {
|
|
968
|
+
this.sqlite.run("INSERT INTO deletion_targets (target_key, kind, entry_id, block_index) VALUES (?, ?, ?, ?)", targetKey(target), target.kind, target.entryId, target.kind === "content_block" ? target.blockIndex : null);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
listEntriesForGrep() {
|
|
972
|
+
return this.sqlite.all("SELECT entry_id, text, is_protected FROM transcript_entries ORDER BY position");
|
|
973
|
+
}
|
|
974
|
+
listContentBlocksForGrep() {
|
|
975
|
+
return this.sqlite.all(`
|
|
976
|
+
SELECT
|
|
977
|
+
blocks.entry_id,
|
|
978
|
+
blocks.block_index,
|
|
979
|
+
blocks.text,
|
|
980
|
+
entries.is_protected AS entry_protected,
|
|
981
|
+
blocks.is_protected AS block_protected,
|
|
982
|
+
(
|
|
983
|
+
SELECT COUNT(*)
|
|
984
|
+
FROM transcript_content_blocks sibling
|
|
985
|
+
WHERE sibling.entry_id = blocks.entry_id
|
|
986
|
+
) AS block_count
|
|
987
|
+
FROM transcript_content_blocks blocks
|
|
988
|
+
JOIN transcript_entries entries ON entries.entry_id = blocks.entry_id
|
|
989
|
+
ORDER BY entries.position, blocks.block_index
|
|
990
|
+
`);
|
|
991
|
+
}
|
|
992
|
+
getEntryForRead(entryId) {
|
|
993
|
+
return this.sqlite.get("SELECT entry_id, role, is_protected, token_estimate, text FROM transcript_entries WHERE entry_id = ?", entryId);
|
|
994
|
+
}
|
|
995
|
+
getContentBlockForRead(entryId, blockIndex) {
|
|
996
|
+
return this.sqlite.get(`
|
|
997
|
+
SELECT
|
|
998
|
+
blocks.entry_id,
|
|
999
|
+
blocks.block_index,
|
|
1000
|
+
blocks.type,
|
|
1001
|
+
blocks.token_estimate,
|
|
1002
|
+
blocks.text,
|
|
1003
|
+
entries.is_protected AS entry_protected,
|
|
1004
|
+
blocks.is_protected AS block_protected,
|
|
1005
|
+
(
|
|
1006
|
+
SELECT COUNT(*)
|
|
1007
|
+
FROM transcript_content_blocks sibling
|
|
1008
|
+
WHERE sibling.entry_id = blocks.entry_id
|
|
1009
|
+
) AS block_count
|
|
1010
|
+
FROM transcript_content_blocks blocks
|
|
1011
|
+
JOIN transcript_entries entries ON entries.entry_id = blocks.entry_id
|
|
1012
|
+
WHERE blocks.entry_id = ? AND blocks.block_index = ?
|
|
1013
|
+
`, entryId, blockIndex);
|
|
1014
|
+
}
|
|
1015
|
+
getGrepScanTextLength(target) {
|
|
1016
|
+
const table = target === "entry" ? "transcript_entries" : "transcript_content_blocks";
|
|
1017
|
+
const row = this.sqlite.get(`SELECT SUM(LENGTH(text)) AS scan_chars FROM ${table}`);
|
|
1018
|
+
return row?.scan_chars ?? 0;
|
|
1019
|
+
}
|
|
1020
|
+
incrementCallCount() {
|
|
1021
|
+
const next = this.getCallCount() + 1;
|
|
1022
|
+
this.setState("call_count", String(next));
|
|
1023
|
+
return next;
|
|
1024
|
+
}
|
|
1025
|
+
getCallCount() {
|
|
1026
|
+
return Number(this.getState("call_count") ?? "0");
|
|
1027
|
+
}
|
|
1028
|
+
setLastError(message) {
|
|
1029
|
+
this.setState("last_error", message);
|
|
1030
|
+
}
|
|
1031
|
+
clearLastError() {
|
|
1032
|
+
this.sqlite.run("DELETE FROM context_compaction_state WHERE key = ?", "last_error");
|
|
1033
|
+
}
|
|
1034
|
+
getLastError() {
|
|
1035
|
+
return this.getState("last_error");
|
|
1036
|
+
}
|
|
1037
|
+
getState(key) {
|
|
1038
|
+
return this.sqlite.get("SELECT value FROM context_compaction_state WHERE key = ?", key)?.value;
|
|
1039
|
+
}
|
|
1040
|
+
setState(key, value) {
|
|
1041
|
+
this.sqlite.run("INSERT INTO context_compaction_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", key, value);
|
|
1042
|
+
}
|
|
1043
|
+
close() {
|
|
1044
|
+
this.sqlite.close();
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
function createContextDeletionSqliteStore(transcript) {
|
|
1048
|
+
const store = new ContextDeletionSqliteStore(SqliteAdapter.createTransient());
|
|
1049
|
+
store.initialize(transcript);
|
|
1050
|
+
return store;
|
|
1051
|
+
}
|
|
1052
|
+
export function createContextDeletionTool(transcript, options = {}) {
|
|
1053
|
+
const mode = options.mode ?? "standard";
|
|
1054
|
+
const store = createContextDeletionSqliteStore(transcript);
|
|
1055
|
+
let validatedResult;
|
|
1056
|
+
function readTargets() {
|
|
1057
|
+
return store.readTargets();
|
|
1058
|
+
}
|
|
1059
|
+
function applyValidatedTargets(additionalTargets) {
|
|
1060
|
+
const mergedTargets = mergeContextDeletionTargets(readTargets(), additionalTargets);
|
|
1061
|
+
validatedResult = validateContextDeletionRequest(deletionRequestFromTargets(mergedTargets), transcript, { mode });
|
|
1062
|
+
store.replaceTargets(validatedResult.deletedTargets);
|
|
1063
|
+
return validatedResult;
|
|
1064
|
+
}
|
|
1065
|
+
function currentStats() {
|
|
1066
|
+
return validatedResult?.stats ?? computeContextCompactionStats(transcript, readTargets());
|
|
1067
|
+
}
|
|
1068
|
+
function canDeleteProtectedTarget(target) {
|
|
1069
|
+
return canDeleteProtectedTargetInMode(transcript, target, mode);
|
|
1070
|
+
}
|
|
1071
|
+
const tool = {
|
|
1072
|
+
...CONTEXT_DELETE_TOOL,
|
|
1073
|
+
label: "context deletion request",
|
|
1074
|
+
executionMode: "parallel",
|
|
1075
|
+
async execute(_toolCallId, params) {
|
|
1076
|
+
return store.transaction(() => {
|
|
1077
|
+
const callCount = store.incrementCallCount();
|
|
1078
|
+
try {
|
|
1079
|
+
const incomingRequest = contextDeletionRequestFromObject(params, `${CONTEXT_DELETE_TOOL_NAME} arguments`);
|
|
1080
|
+
const incomingValidated = validateContextDeletionRequest(incomingRequest, transcript, { mode });
|
|
1081
|
+
const applied = applyValidatedTargets(incomingValidated.deletedTargets);
|
|
1082
|
+
store.clearLastError();
|
|
1083
|
+
const deletedTargets = readTargets();
|
|
1084
|
+
const details = {
|
|
1085
|
+
deletions: deletionRequestFromTargets(deletedTargets).deletions,
|
|
1086
|
+
deletedTargets,
|
|
1087
|
+
stats: applied.stats,
|
|
1088
|
+
callCount,
|
|
1089
|
+
};
|
|
1090
|
+
const text = `Recorded ${incomingValidated.deletedTargets.length} deletion target(s); ${deletedTargets.length} total validated deletion target(s) are selected. Continue calling ${CONTEXT_DELETE_TOOL_NAME} or ${CONTEXT_GREP_DELETE_TOOL_NAME} for additional deletions, or respond done when finished.`;
|
|
1091
|
+
return createContextDeletionToolResult(text, details);
|
|
1092
|
+
}
|
|
1093
|
+
catch (error) {
|
|
1094
|
+
const message = formatErrorMessage(error);
|
|
1095
|
+
store.setLastError(message);
|
|
1096
|
+
const deletedTargets = readTargets();
|
|
1097
|
+
const details = {
|
|
1098
|
+
deletions: deletionRequestFromTargets(deletedTargets).deletions,
|
|
1099
|
+
deletedTargets,
|
|
1100
|
+
stats: currentStats(),
|
|
1101
|
+
callCount,
|
|
1102
|
+
error: message,
|
|
1103
|
+
};
|
|
1104
|
+
return createContextDeletionToolResult(`Error recording context deletion targets: ${message}. No new deletion targets were applied; continue with a corrected tool call.`, details);
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
},
|
|
1108
|
+
};
|
|
1109
|
+
const grepTool = {
|
|
1110
|
+
...CONTEXT_GREP_DELETE_TOOL,
|
|
1111
|
+
label: "context grep delete",
|
|
1112
|
+
executionMode: "parallel",
|
|
1113
|
+
async execute(_toolCallId, params) {
|
|
1114
|
+
return store.transaction(() => {
|
|
1115
|
+
const callCount = store.incrementCallCount();
|
|
1116
|
+
const pattern = params.pattern;
|
|
1117
|
+
const regex = params.regex === true;
|
|
1118
|
+
const caseSensitive = params.caseSensitive === true;
|
|
1119
|
+
const target = params.target ?? "entry";
|
|
1120
|
+
const maxMatches = params.maxMatches ?? CONTEXT_GREP_DELETE_DEFAULT_MAX_MATCHES;
|
|
1121
|
+
const candidates = [];
|
|
1122
|
+
const matches = [];
|
|
1123
|
+
const skipped = [];
|
|
1124
|
+
const seenTargets = new Set();
|
|
1125
|
+
try {
|
|
1126
|
+
if (regex) {
|
|
1127
|
+
assertSafeRegexScan(store.getGrepScanTextLength(target));
|
|
1128
|
+
}
|
|
1129
|
+
const matcher = createGrepMatcher(pattern, regex, caseSensitive);
|
|
1130
|
+
const currentTargets = readTargets();
|
|
1131
|
+
if (target === "entry") {
|
|
1132
|
+
for (const entry of store.listEntriesForGrep()) {
|
|
1133
|
+
if (!matcher.test(entry.text))
|
|
1134
|
+
continue;
|
|
1135
|
+
const candidate = { kind: "entry", entryId: entry.entry_id };
|
|
1136
|
+
if (entry.is_protected === 1 && !canDeleteProtectedTarget(candidate)) {
|
|
1137
|
+
skipped.push({ entryId: entry.entry_id, target, reason: "protected_entry", text: entry.text });
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
if (currentTargetDeleted(currentTargets, candidate)) {
|
|
1141
|
+
skipped.push({ entryId: entry.entry_id, target, reason: "already_deleted", text: entry.text });
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
addGrepCandidate(candidates, matches, seenTargets, candidate, {
|
|
1145
|
+
entryId: entry.entry_id,
|
|
1146
|
+
target,
|
|
1147
|
+
text: entry.text,
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
else {
|
|
1152
|
+
for (const block of store.listContentBlocksForGrep()) {
|
|
1153
|
+
if (!matcher.test(block.text))
|
|
1154
|
+
continue;
|
|
1155
|
+
const candidate = block.block_count <= 1
|
|
1156
|
+
? { kind: "entry", entryId: block.entry_id }
|
|
1157
|
+
: { kind: "content_block", entryId: block.entry_id, blockIndex: block.block_index };
|
|
1158
|
+
if (block.entry_protected === 1 && !canDeleteProtectedTarget(candidate)) {
|
|
1159
|
+
skipped.push({
|
|
1160
|
+
entryId: block.entry_id,
|
|
1161
|
+
target,
|
|
1162
|
+
blockIndex: block.block_index,
|
|
1163
|
+
reason: "protected_entry",
|
|
1164
|
+
text: block.text,
|
|
1165
|
+
});
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
if (block.block_protected === 1 && !canDeleteProtectedTarget(candidate)) {
|
|
1169
|
+
skipped.push({
|
|
1170
|
+
entryId: block.entry_id,
|
|
1171
|
+
target,
|
|
1172
|
+
blockIndex: block.block_index,
|
|
1173
|
+
reason: "protected_block",
|
|
1174
|
+
text: block.text,
|
|
1175
|
+
});
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
if (currentTargetDeleted(currentTargets, candidate)) {
|
|
1179
|
+
skipped.push({
|
|
1180
|
+
entryId: block.entry_id,
|
|
1181
|
+
target: candidate.kind,
|
|
1182
|
+
...(candidate.kind === "content_block" ? { blockIndex: candidate.blockIndex } : {}),
|
|
1183
|
+
reason: "already_deleted",
|
|
1184
|
+
text: block.text,
|
|
1185
|
+
});
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
addGrepCandidate(candidates, matches, seenTargets, candidate, {
|
|
1189
|
+
entryId: block.entry_id,
|
|
1190
|
+
target: candidate.kind,
|
|
1191
|
+
...(candidate.kind === "content_block" ? { blockIndex: candidate.blockIndex } : {}),
|
|
1192
|
+
text: block.text,
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
let applied;
|
|
1197
|
+
if (params.expectedMatchCount !== undefined && candidates.length !== params.expectedMatchCount) {
|
|
1198
|
+
skipped.push({ reason: "expected_match_count_mismatch" });
|
|
1199
|
+
}
|
|
1200
|
+
else if (candidates.length > maxMatches) {
|
|
1201
|
+
skipped.push({ reason: "max_matches_exceeded" });
|
|
1202
|
+
}
|
|
1203
|
+
else if (candidates.length > 0) {
|
|
1204
|
+
applied = applyValidatedTargets(candidates);
|
|
1205
|
+
}
|
|
1206
|
+
store.clearLastError();
|
|
1207
|
+
const deletedTargets = readTargets();
|
|
1208
|
+
const details = {
|
|
1209
|
+
pattern,
|
|
1210
|
+
regex,
|
|
1211
|
+
caseSensitive,
|
|
1212
|
+
target,
|
|
1213
|
+
matches,
|
|
1214
|
+
skipped,
|
|
1215
|
+
deletedTargets,
|
|
1216
|
+
stats: applied?.stats ?? currentStats(),
|
|
1217
|
+
callCount,
|
|
1218
|
+
};
|
|
1219
|
+
const text = `Matched ${matches.length} deletion target(s), skipped ${skipped.length}, and ${applied ? "applied" : "did not apply"} grep deletion for pattern ${JSON.stringify(pattern)}. Total validated deletion target(s): ${deletedTargets.length}.`;
|
|
1220
|
+
return createContextDeletionToolResult(text, details);
|
|
1221
|
+
}
|
|
1222
|
+
catch (error) {
|
|
1223
|
+
const message = formatErrorMessage(error);
|
|
1224
|
+
store.setLastError(message);
|
|
1225
|
+
const deletedTargets = readTargets();
|
|
1226
|
+
const details = {
|
|
1227
|
+
pattern,
|
|
1228
|
+
regex,
|
|
1229
|
+
caseSensitive,
|
|
1230
|
+
target,
|
|
1231
|
+
matches,
|
|
1232
|
+
skipped,
|
|
1233
|
+
deletedTargets,
|
|
1234
|
+
stats: currentStats(),
|
|
1235
|
+
callCount,
|
|
1236
|
+
error: message,
|
|
1237
|
+
};
|
|
1238
|
+
return createContextDeletionToolResult(`Error applying grep deletion for pattern ${JSON.stringify(pattern)}: ${message}. No new deletion targets were applied; continue with a corrected tool call.`, details);
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1241
|
+
},
|
|
1242
|
+
};
|
|
1243
|
+
const searchTool = {
|
|
1244
|
+
...CONTEXT_SEARCH_TRANSCRIPT_TOOL,
|
|
1245
|
+
label: "context transcript search",
|
|
1246
|
+
executionMode: "parallel",
|
|
1247
|
+
async execute(_toolCallId, params) {
|
|
1248
|
+
return store.transaction(() => {
|
|
1249
|
+
const callCount = store.incrementCallCount();
|
|
1250
|
+
const pattern = params.pattern;
|
|
1251
|
+
const regex = params.regex === true;
|
|
1252
|
+
const caseSensitive = params.caseSensitive === true;
|
|
1253
|
+
const target = params.target ?? "entry";
|
|
1254
|
+
const maxMatches = clampInteger(params.maxMatches, CONTEXT_SEARCH_DEFAULT_MAX_MATCHES, 1, CONTEXT_SEARCH_MAX_MATCHES);
|
|
1255
|
+
const contextChars = clampInteger(params.contextChars, CONTEXT_SEARCH_DEFAULT_CONTEXT_CHARS, 0, CONTEXT_SEARCH_MAX_CONTEXT_CHARS);
|
|
1256
|
+
const matches = [];
|
|
1257
|
+
let truncated = false;
|
|
1258
|
+
try {
|
|
1259
|
+
if (regex) {
|
|
1260
|
+
assertSafeRegexScan(store.getGrepScanTextLength(target));
|
|
1261
|
+
}
|
|
1262
|
+
const matcher = createGrepMatcher(pattern, regex, caseSensitive);
|
|
1263
|
+
if (target === "entry") {
|
|
1264
|
+
for (const entry of store.listEntriesForGrep()) {
|
|
1265
|
+
const matchIndex = findMatchIndex(matcher, entry.text);
|
|
1266
|
+
if (matchIndex < 0)
|
|
1267
|
+
continue;
|
|
1268
|
+
if (matches.length >= maxMatches) {
|
|
1269
|
+
truncated = true;
|
|
1270
|
+
break;
|
|
1271
|
+
}
|
|
1272
|
+
matches.push({
|
|
1273
|
+
entryId: entry.entry_id,
|
|
1274
|
+
target,
|
|
1275
|
+
matchIndex,
|
|
1276
|
+
snippet: snippetForMatch(entry.text, matchIndex, contextChars),
|
|
1277
|
+
protected: entry.is_protected === 1,
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
else {
|
|
1282
|
+
for (const block of store.listContentBlocksForGrep()) {
|
|
1283
|
+
const matchIndex = findMatchIndex(matcher, block.text);
|
|
1284
|
+
if (matchIndex < 0)
|
|
1285
|
+
continue;
|
|
1286
|
+
if (matches.length >= maxMatches) {
|
|
1287
|
+
truncated = true;
|
|
1288
|
+
break;
|
|
1289
|
+
}
|
|
1290
|
+
matches.push({
|
|
1291
|
+
entryId: block.entry_id,
|
|
1292
|
+
target,
|
|
1293
|
+
blockIndex: block.block_index,
|
|
1294
|
+
matchIndex,
|
|
1295
|
+
snippet: snippetForMatch(block.text, matchIndex, contextChars),
|
|
1296
|
+
protected: block.entry_protected === 1 || block.block_protected === 1,
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
store.clearLastError();
|
|
1301
|
+
const details = {
|
|
1302
|
+
pattern,
|
|
1303
|
+
regex,
|
|
1304
|
+
caseSensitive,
|
|
1305
|
+
target,
|
|
1306
|
+
matches,
|
|
1307
|
+
truncated,
|
|
1308
|
+
callCount,
|
|
1309
|
+
};
|
|
1310
|
+
const text = `Found ${matches.length}${truncated ? "+" : ""} ${target} match(es) for ${JSON.stringify(pattern)}. Use ${CONTEXT_READ_ENTRY_TOOL_NAME} with small maxChars to inspect exact content before deleting.`;
|
|
1311
|
+
return createContextDeletionToolResult(text, details);
|
|
1312
|
+
}
|
|
1313
|
+
catch (error) {
|
|
1314
|
+
const message = formatErrorMessage(error);
|
|
1315
|
+
store.setLastError(message);
|
|
1316
|
+
const details = {
|
|
1317
|
+
pattern,
|
|
1318
|
+
regex,
|
|
1319
|
+
caseSensitive,
|
|
1320
|
+
target,
|
|
1321
|
+
matches,
|
|
1322
|
+
truncated,
|
|
1323
|
+
callCount,
|
|
1324
|
+
error: message,
|
|
1325
|
+
};
|
|
1326
|
+
return createContextDeletionToolResult(`Error searching transcript for ${JSON.stringify(pattern)}: ${message}. Try a literal pattern or narrower query.`, details);
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
},
|
|
1330
|
+
};
|
|
1331
|
+
const readEntryTool = {
|
|
1332
|
+
...CONTEXT_READ_ENTRY_TOOL,
|
|
1333
|
+
label: "context read entry",
|
|
1334
|
+
executionMode: "parallel",
|
|
1335
|
+
async execute(_toolCallId, params) {
|
|
1336
|
+
return store.transaction(() => {
|
|
1337
|
+
const callCount = store.incrementCallCount();
|
|
1338
|
+
const offset = clampInteger(params.offset, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
1339
|
+
const maxChars = clampInteger(params.maxChars, CONTEXT_READ_ENTRY_DEFAULT_MAX_CHARS, 1, CONTEXT_READ_ENTRY_MAX_CHARS);
|
|
1340
|
+
try {
|
|
1341
|
+
const row = params.blockIndex === undefined
|
|
1342
|
+
? store.getEntryForRead(params.entryId)
|
|
1343
|
+
: store.getContentBlockForRead(params.entryId, params.blockIndex);
|
|
1344
|
+
if (!row) {
|
|
1345
|
+
throw new Error(params.blockIndex === undefined
|
|
1346
|
+
? `Unknown transcript entry: ${params.entryId}`
|
|
1347
|
+
: `Unknown transcript content block: ${params.entryId}:${params.blockIndex}`);
|
|
1348
|
+
}
|
|
1349
|
+
const text = row.text;
|
|
1350
|
+
const slice = textSlice(text, offset, maxChars);
|
|
1351
|
+
store.clearLastError();
|
|
1352
|
+
const details = {
|
|
1353
|
+
entryId: params.entryId,
|
|
1354
|
+
...(params.blockIndex === undefined ? {} : { blockIndex: params.blockIndex }),
|
|
1355
|
+
offset,
|
|
1356
|
+
maxChars,
|
|
1357
|
+
totalChars: text.length,
|
|
1358
|
+
text: slice,
|
|
1359
|
+
truncatedBefore: offset > 0,
|
|
1360
|
+
truncatedAfter: offset + maxChars < text.length,
|
|
1361
|
+
callCount,
|
|
1362
|
+
};
|
|
1363
|
+
const textResult = `Read ${slice.length} of ${text.length} characters from ${params.blockIndex === undefined ? params.entryId : `${params.entryId}:${params.blockIndex}`}. Keep reads small; increase offset for the next slice if needed.`;
|
|
1364
|
+
return createContextDeletionToolResult(textResult, details);
|
|
1365
|
+
}
|
|
1366
|
+
catch (error) {
|
|
1367
|
+
const message = formatErrorMessage(error);
|
|
1368
|
+
store.setLastError(message);
|
|
1369
|
+
const details = {
|
|
1370
|
+
entryId: params.entryId,
|
|
1371
|
+
...(params.blockIndex === undefined ? {} : { blockIndex: params.blockIndex }),
|
|
1372
|
+
offset,
|
|
1373
|
+
maxChars,
|
|
1374
|
+
totalChars: 0,
|
|
1375
|
+
text: "",
|
|
1376
|
+
truncatedBefore: false,
|
|
1377
|
+
truncatedAfter: false,
|
|
1378
|
+
callCount,
|
|
1379
|
+
error: message,
|
|
1380
|
+
};
|
|
1381
|
+
return createContextDeletionToolResult(`Error reading transcript entry: ${message}`, details);
|
|
1382
|
+
}
|
|
1383
|
+
});
|
|
1384
|
+
},
|
|
1385
|
+
};
|
|
1386
|
+
return {
|
|
1387
|
+
tool,
|
|
1388
|
+
grepTool,
|
|
1389
|
+
searchTool,
|
|
1390
|
+
readEntryTool,
|
|
1391
|
+
tools: [tool, grepTool, searchTool, readEntryTool],
|
|
1392
|
+
getDeletionRequest: () => deletionRequestFromTargets(readTargets()),
|
|
1393
|
+
getValidatedResult: () => validatedResult,
|
|
1394
|
+
getLastError: () => store.getLastError(),
|
|
1395
|
+
getCallCount: () => store.getCallCount(),
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
export function parseContextDeletionRequest(text) {
|
|
514
1399
|
const stripped = stripJsonFence(text);
|
|
515
1400
|
let parsed;
|
|
516
1401
|
try {
|
|
517
1402
|
parsed = JSON.parse(stripped);
|
|
518
1403
|
}
|
|
519
1404
|
catch (error) {
|
|
520
|
-
throw new Error(`Failed to parse context deletion
|
|
1405
|
+
throw new Error(`Failed to parse context deletion request JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
521
1406
|
}
|
|
522
|
-
return
|
|
1407
|
+
return contextDeletionRequestFromObject(parsed, "Context deletion request JSON");
|
|
523
1408
|
}
|
|
524
|
-
function
|
|
525
|
-
return content.type === "toolCall" && content.name ===
|
|
1409
|
+
function isContextDeleteToolCall(content) {
|
|
1410
|
+
return content.type === "toolCall" && content.name === CONTEXT_DELETE_TOOL_NAME;
|
|
526
1411
|
}
|
|
527
1412
|
function textContentFromResponse(response) {
|
|
528
1413
|
return response.content
|
|
@@ -530,73 +1415,222 @@ function textContentFromResponse(response) {
|
|
|
530
1415
|
.map((content) => content.text)
|
|
531
1416
|
.join("\n");
|
|
532
1417
|
}
|
|
533
|
-
export function
|
|
534
|
-
const toolCalls = response.content.filter(
|
|
1418
|
+
export function parseContextDeletionResponse(response) {
|
|
1419
|
+
const toolCalls = response.content.filter(isContextDeleteToolCall);
|
|
535
1420
|
if (toolCalls.length > 1) {
|
|
536
|
-
throw new Error(`Context compaction
|
|
1421
|
+
throw new Error(`Context compaction assistant called ${CONTEXT_DELETE_TOOL_NAME} more than once`);
|
|
537
1422
|
}
|
|
538
1423
|
const toolCall = toolCalls[0];
|
|
539
1424
|
if (toolCall) {
|
|
540
|
-
return
|
|
1425
|
+
return contextDeletionRequestFromObject(toolCall.arguments, `${CONTEXT_DELETE_TOOL_NAME} arguments`);
|
|
541
1426
|
}
|
|
542
1427
|
const textContent = textContentFromResponse(response);
|
|
543
1428
|
if (textContent.trim().length === 0) {
|
|
544
|
-
throw new Error(`Context compaction
|
|
1429
|
+
throw new Error(`Context compaction assistant did not call ${CONTEXT_DELETE_TOOL_NAME}`);
|
|
545
1430
|
}
|
|
546
|
-
return
|
|
1431
|
+
return parseContextDeletionRequest(textContent);
|
|
547
1432
|
}
|
|
548
1433
|
function truncateForPrompt(text, maxChars) {
|
|
549
1434
|
if (text.length <= maxChars)
|
|
550
1435
|
return text;
|
|
551
|
-
return `${text.slice(0, maxChars)}\n[... ${text.length - maxChars} more characters omitted from
|
|
1436
|
+
return `${text.slice(0, maxChars)}\n[... ${text.length - maxChars} more characters omitted from context compaction prompt]`;
|
|
552
1437
|
}
|
|
553
|
-
function
|
|
554
|
-
return
|
|
555
|
-
.filter((entry) => !isExcludedFromLlmContext(entry.message))
|
|
556
|
-
.map((entry) => ({
|
|
1438
|
+
function transcriptEntryFilePayload(entry) {
|
|
1439
|
+
return {
|
|
557
1440
|
entryId: entry.entryId,
|
|
1441
|
+
entryType: entry.entryType,
|
|
558
1442
|
role: entry.role,
|
|
559
1443
|
protected: entry.protected,
|
|
560
1444
|
tokenEstimate: entry.tokenEstimate,
|
|
561
1445
|
toolCallIds: entry.toolCallIds,
|
|
562
1446
|
toolResultFor: entry.toolResultFor,
|
|
1447
|
+
text: entry.text,
|
|
563
1448
|
contentBlocks: entry.contentBlocks.map((block) => ({
|
|
564
1449
|
blockIndex: block.blockIndex,
|
|
565
1450
|
type: block.type,
|
|
566
1451
|
protected: block.protected,
|
|
567
1452
|
toolCallId: block.toolCallId,
|
|
568
|
-
|
|
1453
|
+
tokenEstimate: block.tokenEstimate,
|
|
1454
|
+
text: block.text,
|
|
569
1455
|
})),
|
|
570
|
-
|
|
571
|
-
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
function writeContextCompactionTranscriptFile(transcript) {
|
|
1459
|
+
const directory = mkdtempSync(join(tmpdir(), "atomic-context-transcript-"));
|
|
1460
|
+
const path = join(directory, "transcript.jsonl");
|
|
1461
|
+
const lines = transcript.entries
|
|
1462
|
+
.filter((entry) => !isExcludedFromLlmContext(entry.message))
|
|
1463
|
+
.map((entry) => JSON.stringify(transcriptEntryFilePayload(entry)));
|
|
1464
|
+
writeFileSync(path, `${lines.join("\n")}\n`, "utf8");
|
|
1465
|
+
return {
|
|
1466
|
+
path,
|
|
1467
|
+
cleanup: () => rmSync(directory, { recursive: true, force: true }),
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
function contextCompactionTranscriptManifest(transcript, transcriptFilePath) {
|
|
1471
|
+
const eligibleEntries = transcript.entries.filter((entry) => !isExcludedFromLlmContext(entry.message));
|
|
1472
|
+
const selectedEntryIds = new Set();
|
|
1473
|
+
const selectedEntries = [];
|
|
1474
|
+
const addEntry = (entry) => {
|
|
1475
|
+
if (selectedEntryIds.has(entry.entryId) || selectedEntries.length >= CONTEXT_MANIFEST_MAX_ENTRIES)
|
|
1476
|
+
return;
|
|
1477
|
+
selectedEntryIds.add(entry.entryId);
|
|
1478
|
+
selectedEntries.push(entry);
|
|
1479
|
+
};
|
|
1480
|
+
for (const entry of eligibleEntries.filter((entry) => entry.protected)) {
|
|
1481
|
+
addEntry(entry);
|
|
1482
|
+
}
|
|
1483
|
+
for (const entry of [...eligibleEntries]
|
|
1484
|
+
.filter((entry) => !entry.protected)
|
|
1485
|
+
.sort((left, right) => right.tokenEstimate - left.tokenEstimate)) {
|
|
1486
|
+
addEntry(entry);
|
|
1487
|
+
}
|
|
1488
|
+
selectedEntries.sort((left, right) => eligibleEntries.indexOf(left) - eligibleEntries.indexOf(right));
|
|
1489
|
+
return {
|
|
1490
|
+
transcriptFilePath,
|
|
1491
|
+
transcriptFileFormat: "jsonl: one compactable transcript entry per line with full text and contentBlocks text",
|
|
1492
|
+
totalEntries: eligibleEntries.length,
|
|
1493
|
+
manifestEntries: selectedEntries.length,
|
|
1494
|
+
omittedEntries: Math.max(0, eligibleEntries.length - selectedEntries.length),
|
|
1495
|
+
tokensBefore: transcript.tokensBefore,
|
|
1496
|
+
protectedEntryIds: transcript.protectedEntryIds,
|
|
1497
|
+
entries: selectedEntries.map((entry) => ({
|
|
1498
|
+
entryId: entry.entryId,
|
|
1499
|
+
role: entry.role,
|
|
1500
|
+
protected: entry.protected,
|
|
1501
|
+
tokenEstimate: entry.tokenEstimate,
|
|
1502
|
+
toolCallIds: entry.toolCallIds,
|
|
1503
|
+
toolResultFor: entry.toolResultFor,
|
|
1504
|
+
contentBlockCount: entry.contentBlocks.length,
|
|
1505
|
+
contentBlocks: entry.contentBlocks.map((block) => ({
|
|
1506
|
+
blockIndex: block.blockIndex,
|
|
1507
|
+
type: block.type,
|
|
1508
|
+
protected: block.protected,
|
|
1509
|
+
toolCallId: block.toolCallId,
|
|
1510
|
+
tokenEstimate: block.tokenEstimate,
|
|
1511
|
+
})),
|
|
1512
|
+
preview: truncateForPrompt(entry.text, CONTEXT_MANIFEST_PREVIEW_CHARS),
|
|
1513
|
+
})),
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
function contextCompactionModePrompt(mode) {
|
|
1517
|
+
if (mode === "critical_overflow") {
|
|
1518
|
+
return `\n<critical-overflow-mode>\nThe previous model request overflowed its context window. This is a critical LRU-style compaction pass. First delete stale unprotected context. If that is not enough, you may also delete the earliest protected entries or protected content shown in the manifest, especially old user/custom/summary context, while preserving recent entries, unresolved errors, failed commands, and enough task-bearing context for the assistant to continue.\n</critical-overflow-mode>`;
|
|
1519
|
+
}
|
|
1520
|
+
return `\n<standard-mode>\nDo not delete entries or content blocks marked protected. Protected context is only eligible during critical overflow recovery, not during standard compaction.\n</standard-mode>`;
|
|
1521
|
+
}
|
|
1522
|
+
export function buildContextCompactionPrompt(transcript, transcriptFilePath = "<transcript file will be written during context compaction>", mode = "standard") {
|
|
1523
|
+
return `${CONTEXT_COMPACTION_FIXED_PROMPT}${contextCompactionModePrompt(mode)}\n\n<transcript-file>\n${transcriptFilePath}\n</transcript-file>\n\n<context-manifest>\n${JSON.stringify(contextCompactionTranscriptManifest(transcript, transcriptFilePath), null, 2)}\n</context-manifest>`;
|
|
572
1524
|
}
|
|
573
|
-
|
|
574
|
-
return
|
|
1525
|
+
function createContextCompactionAssistantMessage(model, content, stopReason, errorMessage) {
|
|
1526
|
+
return {
|
|
1527
|
+
role: "assistant",
|
|
1528
|
+
content,
|
|
1529
|
+
api: model.api,
|
|
1530
|
+
provider: model.provider,
|
|
1531
|
+
model: model.id,
|
|
1532
|
+
usage: {
|
|
1533
|
+
input: 0,
|
|
1534
|
+
output: 0,
|
|
1535
|
+
cacheRead: 0,
|
|
1536
|
+
cacheWrite: 0,
|
|
1537
|
+
totalTokens: 0,
|
|
1538
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
1539
|
+
},
|
|
1540
|
+
stopReason,
|
|
1541
|
+
...(errorMessage !== undefined ? { errorMessage } : {}),
|
|
1542
|
+
timestamp: Date.now(),
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1545
|
+
function createContextCompactionStopStream(model, text) {
|
|
1546
|
+
const stream = createAssistantMessageEventStream();
|
|
1547
|
+
queueMicrotask(() => {
|
|
1548
|
+
const message = createContextCompactionAssistantMessage(model, [{ type: "text", text }], "stop");
|
|
1549
|
+
stream.push({ type: "done", reason: "stop", message });
|
|
1550
|
+
stream.end(message);
|
|
1551
|
+
});
|
|
1552
|
+
return stream;
|
|
1553
|
+
}
|
|
1554
|
+
function isContextCompactionOverflowError(model, errorMessage) {
|
|
1555
|
+
return isContextOverflow(createContextCompactionAssistantMessage(model, [], "error", errorMessage), model.contextWindow);
|
|
1556
|
+
}
|
|
1557
|
+
export function getLowestContextCompactionThinkingLevel(model) {
|
|
1558
|
+
const supportedLevels = getSupportedThinkingLevels(model);
|
|
1559
|
+
for (const level of CONTEXT_COMPACTION_THINKING_LEVEL_ORDER) {
|
|
1560
|
+
if (supportedLevels.includes(level))
|
|
1561
|
+
return level;
|
|
1562
|
+
}
|
|
1563
|
+
return "off";
|
|
575
1564
|
}
|
|
576
|
-
|
|
1565
|
+
async function runContextDeletionAssistant(transcript, model, apiKey, headers, signal, mode = "standard") {
|
|
577
1566
|
const maxTokens = Math.min(4096, model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY);
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
1567
|
+
if (signal?.aborted) {
|
|
1568
|
+
throw new Error("Context compaction failed: Request was aborted");
|
|
1569
|
+
}
|
|
1570
|
+
const transcriptFile = writeContextCompactionTranscriptFile(transcript);
|
|
1571
|
+
const promptMessage = {
|
|
1572
|
+
role: "user",
|
|
1573
|
+
content: [{ type: "text", text: buildContextCompactionPrompt(transcript, transcriptFile.path, mode) }],
|
|
1574
|
+
timestamp: Date.now(),
|
|
1575
|
+
};
|
|
1576
|
+
const deletionTool = createContextDeletionTool(transcript, { mode });
|
|
1577
|
+
const effectiveThinkingLevel = getLowestContextCompactionThinkingLevel(model);
|
|
1578
|
+
let compactionTurnCount = 0;
|
|
1579
|
+
const agent = new Agent({
|
|
1580
|
+
initialState: {
|
|
1581
|
+
systemPrompt: CONTEXT_COMPACTION_SYSTEM_PROMPT,
|
|
1582
|
+
model,
|
|
1583
|
+
thinkingLevel: effectiveThinkingLevel,
|
|
1584
|
+
tools: deletionTool.tools,
|
|
1585
|
+
},
|
|
1586
|
+
toolExecution: "parallel",
|
|
1587
|
+
streamFn: async (requestModel, context, streamOptions) => {
|
|
1588
|
+
compactionTurnCount += 1;
|
|
1589
|
+
if (compactionTurnCount > CONTEXT_COMPACTION_MAX_TURNS) {
|
|
1590
|
+
return createContextCompactionStopStream(requestModel, `Reached the context compaction turn cap (${CONTEXT_COMPACTION_MAX_TURNS}); using the deletions recorded so far.`);
|
|
1591
|
+
}
|
|
1592
|
+
return streamSimple(requestModel, context, {
|
|
1593
|
+
...streamOptions,
|
|
1594
|
+
maxTokens,
|
|
1595
|
+
apiKey,
|
|
1596
|
+
headers: headers ?? streamOptions?.headers,
|
|
1597
|
+
});
|
|
583
1598
|
},
|
|
584
|
-
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
if (
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
1599
|
+
});
|
|
1600
|
+
const abortOnSignal = () => agent.abort();
|
|
1601
|
+
signal?.addEventListener("abort", abortOnSignal, { once: true });
|
|
1602
|
+
try {
|
|
1603
|
+
await agent.prompt(promptMessage);
|
|
1604
|
+
}
|
|
1605
|
+
finally {
|
|
1606
|
+
signal?.removeEventListener("abort", abortOnSignal);
|
|
1607
|
+
transcriptFile.cleanup();
|
|
1608
|
+
}
|
|
1609
|
+
if (signal?.aborted) {
|
|
1610
|
+
throw new Error("Context compaction failed: Request was aborted");
|
|
1611
|
+
}
|
|
1612
|
+
if (agent.state.errorMessage) {
|
|
1613
|
+
if (isContextCompactionOverflowError(model, agent.state.errorMessage)) {
|
|
1614
|
+
return {
|
|
1615
|
+
validatedResult: deletionTool.getValidatedResult(),
|
|
1616
|
+
lastToolError: deletionTool.getLastError(),
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
throw new Error(`Context compaction failed: ${agent.state.errorMessage}`);
|
|
1620
|
+
}
|
|
1621
|
+
if (deletionTool.getCallCount() === 0) {
|
|
1622
|
+
throw new Error(`Context compaction did not call any transcript inspection or deletion tools (${CONTEXT_SEARCH_TRANSCRIPT_TOOL_NAME}, ${CONTEXT_READ_ENTRY_TOOL_NAME}, ${CONTEXT_DELETE_TOOL_NAME}, or ${CONTEXT_GREP_DELETE_TOOL_NAME})`);
|
|
1623
|
+
}
|
|
1624
|
+
return {
|
|
1625
|
+
validatedResult: deletionTool.getValidatedResult(),
|
|
1626
|
+
lastToolError: deletionTool.getLastError(),
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
export async function contextCompact(preparation, model, apiKey, headers, signal, _thinkingLevel, mode = preparation.mode ?? "standard") {
|
|
1630
|
+
const { validatedResult, lastToolError } = await runContextDeletionAssistant(preparation.transcript, model, apiKey, headers, signal, mode);
|
|
1631
|
+
if (!validatedResult || validatedResult.deletedTargets.length === 0) {
|
|
1632
|
+
throw new Error(lastToolError ? `No safe context deletions proposed; last deletion tool error: ${lastToolError}` : "No safe context deletions proposed");
|
|
1633
|
+
}
|
|
1634
|
+
return validatedResult;
|
|
601
1635
|
}
|
|
602
1636
|
//# sourceMappingURL=context-compaction.js.map
|