@dex-ai/context 0.7.16

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.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Format context usage for terminal display.
3
+ */
4
+
5
+ import type { ContextSnapshot } from "./types";
6
+
7
+ const RESET = "\x1b[0m";
8
+ const BOLD = "\x1b[1m";
9
+ const DIM = "\x1b[2m";
10
+
11
+ const BLOCK_USED = "\x1b[38;5;185m■" + RESET;
12
+ const BLOCK_FREE = "\x1b[38;5;240m□" + RESET;
13
+
14
+ const CAT_COLORS: Record<string, string> = {
15
+ "system-prompt": "\x1b[38;5;246m",
16
+ "system-tools": "\x1b[38;5;248m",
17
+ "tool-calls": "\x1b[38;5;179m",
18
+ "tool-results": "\x1b[38;5;185m",
19
+ messages: "\x1b[38;5;116m",
20
+ images: "\x1b[38;5;141m",
21
+ files: "\x1b[38;5;174m",
22
+ reasoning: "\x1b[38;5;110m",
23
+ };
24
+
25
+ const CAT_LABELS: Record<string, string> = {
26
+ "system-prompt": "System Prompt",
27
+ "system-tools": "System Tools",
28
+ "tool-calls": "Tool Calls",
29
+ "tool-results": "Tool Results",
30
+ messages: "Messages",
31
+ images: "Images",
32
+ files: "Files",
33
+ reasoning: "Reasoning",
34
+ };
35
+
36
+ export function formatContextUsage(snap: ContextSnapshot): string {
37
+ const lines: string[] = [];
38
+ lines.push(` ${BOLD}Context Usage${RESET}`);
39
+ lines.push("");
40
+
41
+ // Grid: 10×5 = 50 cells
42
+ const COLS = 10,
43
+ ROWS = 5,
44
+ TOTAL = COLS * ROWS;
45
+ const usedCells = Math.round((snap.usagePercent / 100) * TOTAL);
46
+ const grid: string[][] = [];
47
+ for (let r = 0; r < ROWS; r++) {
48
+ const row: string[] = [];
49
+ for (let c = 0; c < COLS; c++) {
50
+ row.push(r * COLS + c < usedCells ? BLOCK_USED : BLOCK_FREE);
51
+ }
52
+ grid.push(row);
53
+ }
54
+
55
+ // Right-side stats
56
+ const right: string[] = [];
57
+ right.push(
58
+ `${BOLD}Total Usage${RESET} ${fmtK(snap.totalTokens)} (${fmtPct(snap.usagePercent)})`,
59
+ );
60
+ right.push("");
61
+ for (const cat of snap.categories) {
62
+ const color = CAT_COLORS[cat.category] ?? "";
63
+ const label = CAT_LABELS[cat.category] ?? cat.category;
64
+ right.push(
65
+ `${color}■${RESET} ${color}${label}${RESET}${" ".repeat(Math.max(1, 15 - label.length))}${fmtK(cat.tokens).padStart(5)} (${fmtPct(cat.percent)})`,
66
+ );
67
+ }
68
+ right.push(
69
+ `${DIM}□${RESET} ${DIM}Available${RESET} ${fmtK(snap.availableTokens).padStart(5)} (${fmtPct(100 - snap.usagePercent)})`,
70
+ );
71
+
72
+ if (snap.tokensSaved > 0) {
73
+ right.push("");
74
+ right.push(
75
+ `${DIM}Saved: ~${fmtK(snap.tokensSaved)} (${snap.compressions} compression${snap.compressions !== 1 ? "s" : ""})${RESET}`,
76
+ );
77
+ }
78
+
79
+ // Merge
80
+ const maxRows = Math.max(grid.length, right.length);
81
+ for (let i = 0; i < maxRows; i++) {
82
+ const left = i < grid.length ? " " + grid[i].join(" ") : " ".repeat(21);
83
+ const r = right[i] ?? "";
84
+ lines.push(`${left} ${r}`);
85
+ }
86
+
87
+ return lines.join("\n");
88
+ }
89
+
90
+ export function formatContextUsagePlain(snap: ContextSnapshot): string {
91
+ const lines: string[] = [];
92
+ lines.push("Context Usage");
93
+ lines.push("─".repeat(45));
94
+ lines.push(
95
+ `Total: ${fmtK(snap.totalTokens)} / ${fmtK(snap.maxTokens)} (${fmtPct(snap.usagePercent)})`,
96
+ );
97
+ lines.push("");
98
+ for (const cat of snap.categories) {
99
+ const label = CAT_LABELS[cat.category] ?? cat.category;
100
+ const bar =
101
+ "█".repeat(Math.round(cat.percent / 5)) +
102
+ "░".repeat(20 - Math.round(cat.percent / 5));
103
+ lines.push(
104
+ ` ${label.padEnd(15)} ${bar} ${fmtK(cat.tokens).padStart(5)} (${fmtPct(cat.percent)})`,
105
+ );
106
+ }
107
+ lines.push(
108
+ ` ${"Available".padEnd(15)} ${"░".repeat(20)} ${fmtK(snap.availableTokens).padStart(5)} (${fmtPct(100 - snap.usagePercent)})`,
109
+ );
110
+ if (snap.tokensSaved > 0) {
111
+ lines.push("");
112
+ lines.push(
113
+ ` Saved: ~${fmtK(snap.tokensSaved)} (${snap.compressions} compression${snap.compressions !== 1 ? "s" : ""})`,
114
+ );
115
+ }
116
+ return lines.join("\n");
117
+ }
118
+
119
+ function fmtK(n: number): string {
120
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
121
+ if (n >= 1_000) return `${Math.round(n / 1_000)}k`;
122
+ return `${n}`;
123
+ }
124
+
125
+ function fmtPct(p: number): string {
126
+ return `${p.toFixed(1).padStart(5)}%`;
127
+ }
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @dex-ai/context — Index-and-pointer context management for Dex.
3
+ *
4
+ * Philosophy: NEVER LOSE INFORMATION.
5
+ *
6
+ * 1. INDEX — large tool results are stored in an FTS5 knowledge base
7
+ * 2. POINTER — context gets a compact reference, not the raw data
8
+ * 3. SEARCH — ctx_search retrieves specific chunks on demand
9
+ * 4. COMPRESS — graduated aging with lossless session resume
10
+ *
11
+ * The context window shrinks, but the knowledge base grows. The agent
12
+ * can always recover any previously-seen content via search.
13
+ */
14
+
15
+ export { contextExtension } from "./extension";
16
+ export type {
17
+ ContextExtensionOptions,
18
+ ContextSnapshot,
19
+ ContextCategory,
20
+ ContextBudget,
21
+ ContextEvent,
22
+ ContextEventType,
23
+ } from "./types";
24
+ export { estimateTokens } from "./tokenizer";
25
+ export { formatContextUsage, formatContextUsagePlain } from "./formatter";
26
+ export {
27
+ EventLog,
28
+ extractEvents,
29
+ type SessionEvent,
30
+ type ToolResultInput,
31
+ } from "./event-log";
32
+ export { buildSnapshot } from "./snapshot";
33
+ export { resolveBudget, getSendCap, getContextBudget } from "./pressure";
34
+ export { summarizeToolResults } from "./summarize";
35
+ export { ContentStore, sanitizeQuery } from "./store";
36
+ export type {
37
+ IndexResult,
38
+ SearchResult,
39
+ SourceInfo,
40
+ StoreStats,
41
+ } from "./store";
42
+ export { createSearchTool } from "./search-tool";
43
+
44
+ import { contextExtension as _ctx } from "./extension";
45
+ export default _ctx();
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Context Budget Resolution.
3
+ *
4
+ * Fixed-cap budgets. Percentages don't work for large-context models
5
+ * (Gemini 1M, etc.) where even 50% = 500k tokens — far beyond what's
6
+ * useful for quality, cost, and caching.
7
+ *
8
+ * Two limits:
9
+ * - contextBudget: maximum total tokens tracked (history + system + tools)
10
+ * - sendCap: maximum tokens actually sent to the model per request
11
+ *
12
+ * The budget determines when compression tiers activate.
13
+ * The sendCap ensures we never waste tokens on oversized requests.
14
+ */
15
+
16
+ import type { ContextBudget } from "./types";
17
+
18
+ /**
19
+ * Maximum total context budget (what we track and manage).
20
+ * Fixed at 200k regardless of model window size.
21
+ */
22
+ const CONTEXT_BUDGET = 200_000;
23
+
24
+ /**
25
+ * Maximum tokens to actually send to the model per request.
26
+ * Keeps prompt focused and cost-efficient.
27
+ */
28
+ const SEND_CAP = 80_000;
29
+
30
+ /**
31
+ * Resolve the effective token budget for context management.
32
+ *
33
+ * All modes use a fixed 200k cap. The budget tier controls how
34
+ * aggressively compression is applied (via message-count thresholds
35
+ * in the extension), not the total budget size.
36
+ *
37
+ * The modelWindowTokens parameter is kept for safety — if a model
38
+ * has a window smaller than our cap (rare), we respect it.
39
+ */
40
+ export function resolveBudget(
41
+ budget: ContextBudget,
42
+ modelWindowTokens: number,
43
+ ): number {
44
+ // Never exceed the model's actual window
45
+ return Math.min(CONTEXT_BUDGET, modelWindowTokens);
46
+ }
47
+
48
+ /**
49
+ * Get the send cap — maximum tokens to include in a single model request.
50
+ * This is independent of the budget tier.
51
+ */
52
+ export function getSendCap(): number {
53
+ return SEND_CAP;
54
+ }
55
+
56
+ /**
57
+ * Get the raw context budget constant.
58
+ */
59
+ export function getContextBudget(): number {
60
+ return CONTEXT_BUDGET;
61
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Context Search Tool — on-demand retrieval from the FTS5 knowledge base.
3
+ *
4
+ * This tool is registered by the context extension and allows the agent to
5
+ * retrieve specific chunks from previously-indexed content. When large tool
6
+ * results are auto-indexed (instead of filling context), this tool is the
7
+ * mechanism for the agent to pull back exactly what it needs.
8
+ *
9
+ * Design principles:
10
+ * - Batch queries in a single call (["query1", "query2", ...])
11
+ * - Return relevant chunks with titles + content
12
+ * - Progressive throttling to prevent flooding
13
+ * - Source filtering for scoped searches
14
+ */
15
+
16
+ import { z } from "zod";
17
+ import type { Tool } from "@dex-ai/sdk";
18
+ import type { ContentStore, SearchResult } from "./store";
19
+
20
+ /* ── Types ─────────────────────────────────────────────── */
21
+
22
+ interface SearchToolInput {
23
+ queries: string[];
24
+ limit?: number;
25
+ source?: string;
26
+ }
27
+
28
+ /* ── Throttle State ────────────────────────────────────── */
29
+
30
+ const THROTTLE_WINDOW_MS = 60_000;
31
+ const THROTTLE_WARN_AFTER = 5;
32
+ const THROTTLE_BLOCK_AFTER = 10;
33
+
34
+ let windowStart = Date.now();
35
+ let callsInWindow = 0;
36
+
37
+ function resetThrottleIfExpired(): void {
38
+ const now = Date.now();
39
+ if (now - windowStart > THROTTLE_WINDOW_MS) {
40
+ callsInWindow = 0;
41
+ windowStart = now;
42
+ }
43
+ }
44
+
45
+ /* ── Tool Factory ──────────────────────────────────────── */
46
+
47
+ /**
48
+ * Create the ctx_search tool bound to a ContentStore instance.
49
+ * The store reference is captured in a closure so it can be swapped/rebuilt.
50
+ */
51
+ export function createSearchTool(
52
+ getStore: () => ContentStore,
53
+ ): Tool<SearchToolInput, unknown> {
54
+ return {
55
+ name: "ctx_search",
56
+ displayName: "Context Search",
57
+ description:
58
+ "Search the session knowledge base. Content is auto-indexed from large tool results " +
59
+ "that were too big for the context window. Pass ALL questions as a queries array in ONE call.\n\n" +
60
+ "WHEN TO USE:\n" +
61
+ "- After seeing 'Indexed N sections from: <source>' messages\n" +
62
+ "- To recall file contents that were read earlier but compressed\n" +
63
+ "- To find specific code/text from large command outputs\n" +
64
+ "- When session_resume references work you need details on\n\n" +
65
+ "TIPS:\n" +
66
+ "- Use 2-4 specific terms per query (function names, error messages, file names)\n" +
67
+ "- Use 'source' to scope results (e.g. source: 'read(extension.ts)')\n" +
68
+ "- Batch queries: ctx_search(queries: ['query1', 'query2', 'query3'])",
69
+ parameters: z.object({
70
+ queries: z
71
+ .array(z.string())
72
+ .describe(
73
+ "Array of search queries. Batch ALL questions into one call.",
74
+ ),
75
+ limit: z
76
+ .number()
77
+ .optional()
78
+ .default(3)
79
+ .describe("Max results per query (default: 3)"),
80
+ source: z
81
+ .string()
82
+ .optional()
83
+ .describe("Filter to a specific source label (partial match)."),
84
+ }) as any,
85
+ access: "read",
86
+
87
+ async execute(input: SearchToolInput) {
88
+ const store = getStore();
89
+
90
+ // Check if store is empty
91
+ if (store.isEmpty()) {
92
+ return {
93
+ type: "text" as const,
94
+ value:
95
+ "Knowledge base is empty — no content has been indexed yet.\n\n" +
96
+ "Content is auto-indexed when tool results exceed the context threshold. " +
97
+ "Use tools normally and large outputs will be indexed automatically.",
98
+ };
99
+ }
100
+
101
+ // Throttle check
102
+ resetThrottleIfExpired();
103
+ callsInWindow++;
104
+
105
+ if (callsInWindow > THROTTLE_BLOCK_AFTER) {
106
+ return {
107
+ type: "error-text" as const,
108
+ value:
109
+ `BLOCKED: ${callsInWindow} search calls in ${Math.round((Date.now() - windowStart) / 1000)}s. ` +
110
+ "You're flooding context. Consolidate your searches into fewer calls with more specific queries.",
111
+ };
112
+ }
113
+
114
+ const { queries, limit = 3, source } = input;
115
+ const effectiveLimit =
116
+ callsInWindow > THROTTLE_WARN_AFTER ? 1 : Math.min(limit, 5);
117
+
118
+ const sections: string[] = [];
119
+ let totalSize = 0;
120
+ const MAX_TOTAL = 40_000; // 40KB total cap
121
+
122
+ for (const query of queries) {
123
+ if (totalSize > MAX_TOTAL) {
124
+ sections.push(
125
+ `## ${query}\n(output cap reached — refine your search)`,
126
+ );
127
+ continue;
128
+ }
129
+
130
+ const results = store.search(query, effectiveLimit, source);
131
+
132
+ if (results.length === 0) {
133
+ sections.push(`## ${query}\nNo results found.`);
134
+ continue;
135
+ }
136
+
137
+ const formatted = results
138
+ .map((r: SearchResult) => {
139
+ const header = `--- [${r.source}] ---`;
140
+ const heading = `### ${r.title}`;
141
+ const snippet = extractSnippet(r.content, query, 1500);
142
+ return `${header}\n${heading}\n\n${snippet}`;
143
+ })
144
+ .join("\n\n");
145
+
146
+ sections.push(`## ${query}\n\n${formatted}`);
147
+ totalSize += formatted.length;
148
+ }
149
+
150
+ let output = sections.join("\n\n---\n\n");
151
+
152
+ // Throttle warning
153
+ if (callsInWindow >= THROTTLE_WARN_AFTER) {
154
+ output +=
155
+ `\n\n⚠ Search call #${callsInWindow}/${THROTTLE_BLOCK_AFTER} in this window. ` +
156
+ `Results limited to ${effectiveLimit}/query. Batch your queries.`;
157
+ }
158
+
159
+ return { type: "text" as const, value: output };
160
+ },
161
+ };
162
+ }
163
+
164
+ /* ── Helpers ───────────────────────────────────────────── */
165
+
166
+ /**
167
+ * Extract a relevant snippet from content, centered around query term matches.
168
+ * Returns at most `maxChars` characters.
169
+ */
170
+ function extractSnippet(
171
+ content: string,
172
+ query: string,
173
+ maxChars: number,
174
+ ): string {
175
+ if (content.length <= maxChars) return content;
176
+
177
+ // Find the best match position
178
+ const terms = query
179
+ .toLowerCase()
180
+ .split(/\s+/)
181
+ .filter((t) => t.length > 2);
182
+ const lowerContent = content.toLowerCase();
183
+
184
+ let bestPos = 0;
185
+ let bestScore = 0;
186
+
187
+ for (const term of terms) {
188
+ const pos = lowerContent.indexOf(term);
189
+ if (pos >= 0) {
190
+ // Score by how many other terms are nearby
191
+ let score = 1;
192
+ for (const other of terms) {
193
+ if (other === term) continue;
194
+ const nearby = lowerContent.indexOf(other, Math.max(0, pos - 200));
195
+ if (nearby >= 0 && nearby < pos + 200) score++;
196
+ }
197
+ if (score > bestScore) {
198
+ bestScore = score;
199
+ bestPos = pos;
200
+ }
201
+ }
202
+ }
203
+
204
+ // Center snippet around best position
205
+ const halfWindow = Math.floor(maxChars / 2);
206
+ const start = Math.max(0, bestPos - halfWindow);
207
+ const end = Math.min(content.length, start + maxChars);
208
+
209
+ let snippet = content.slice(start, end);
210
+
211
+ // Clean up: don't start/end mid-line
212
+ if (start > 0) {
213
+ const firstNewline = snippet.indexOf("\n");
214
+ if (firstNewline > 0 && firstNewline < 100) {
215
+ snippet = snippet.slice(firstNewline + 1);
216
+ } else {
217
+ snippet = "…" + snippet;
218
+ }
219
+ }
220
+ if (end < content.length) {
221
+ const lastNewline = snippet.lastIndexOf("\n");
222
+ if (lastNewline > snippet.length - 100) {
223
+ snippet = snippet.slice(0, lastNewline);
224
+ } else {
225
+ snippet = snippet + "…";
226
+ }
227
+ }
228
+
229
+ return snippet;
230
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Session Snapshot — builds a compact XML resume from the event log.
3
+ *
4
+ * v5: Now includes RUNNABLE SEARCH QUERIES for each section so the agent
5
+ * can use ctx_search to recover full content from the knowledge base.
6
+ *
7
+ * The snapshot replaces old messages entirely — but because ALL content
8
+ * has been indexed into FTS5 before snapshot creation, this is truly
9
+ * lossless. The agent can always get the data back via search.
10
+ */
11
+
12
+ import type { SessionEvent } from "./event-log";
13
+
14
+ /* ── Types ─────────────────────────────────────────────── */
15
+
16
+ export interface SnapshotOptions {
17
+ /** Maximum events to include per section */
18
+ maxPerSection?: number;
19
+ /** Include ctx_search hints in each section */
20
+ includeSearchHints?: boolean;
21
+ }
22
+
23
+ /* ── Main Builder ──────────────────────────────────────── */
24
+
25
+ /**
26
+ * Build a session_resume XML snapshot from accumulated events.
27
+ * Each section includes a compact summary + ctx_search query for full details.
28
+ */
29
+ export function buildSnapshot(
30
+ events: ReadonlyArray<SessionEvent>,
31
+ turnCount: number,
32
+ opts: SnapshotOptions = {},
33
+ ): string {
34
+ const maxPerSection = opts.maxPerSection ?? 10;
35
+ const includeSearchHints = opts.includeSearchHints ?? false;
36
+ const now = new Date().toISOString();
37
+
38
+ const sections: string[] = [];
39
+
40
+ // Search instruction (always present when hints are enabled)
41
+ if (includeSearchHints) {
42
+ sections.push(` <how_to_recover>
43
+ All content from compressed turns is stored in the knowledge base.
44
+ Use ctx_search(queries: [...]) to retrieve FULL details for any section.
45
+ Do NOT ask the user to re-explain prior work. Search first.
46
+ Do NOT re-read files you already read. Search first.
47
+ </how_to_recover>`);
48
+ }
49
+
50
+ // Files section
51
+ const files = buildFilesSection(events, maxPerSection, includeSearchHints);
52
+ if (files) sections.push(files);
53
+
54
+ // Commands section
55
+ const commands = buildCommandsSection(events, maxPerSection, includeSearchHints);
56
+ if (commands) sections.push(commands);
57
+
58
+ // Errors section
59
+ const errors = buildErrorsSection(events, maxPerSection, includeSearchHints);
60
+ if (errors) sections.push(errors);
61
+
62
+ // Search section
63
+ const searches = buildSearchSection(events, maxPerSection);
64
+ if (searches) sections.push(searches);
65
+
66
+ if (sections.length === 0) {
67
+ return `<session_resume turns="${turnCount}" generated_at="${now}"/>\n`;
68
+ }
69
+
70
+ const body = sections.join("\n\n");
71
+ return [
72
+ `<session_resume turns="${turnCount}" generated_at="${now}">`,
73
+ body,
74
+ `</session_resume>`,
75
+ ].join("\n");
76
+ }
77
+
78
+ /* ── Section Builders ──────────────────────────────────── */
79
+
80
+ function buildFilesSection(
81
+ events: ReadonlyArray<SessionEvent>,
82
+ max: number,
83
+ includeSearchHints: boolean,
84
+ ): string {
85
+ const fileEvents = events.filter((e) => e.category === "file");
86
+ if (fileEvents.length === 0) return "";
87
+
88
+ // Group by path, count ops
89
+ const fileMap = new Map<string, Map<string, number>>();
90
+ for (const ev of fileEvents) {
91
+ let ops = fileMap.get(ev.data);
92
+ if (!ops) {
93
+ ops = new Map();
94
+ fileMap.set(ev.data, ops);
95
+ }
96
+ const op = ev.type.replace("file_", "");
97
+ ops.set(op, (ops.get(op) ?? 0) + 1);
98
+ }
99
+
100
+ // Take most recent files (last N)
101
+ const entries = [...fileMap.entries()].slice(-max);
102
+ const lines = entries.map(([path, ops]) => {
103
+ const opsStr = [...ops.entries()]
104
+ .map(([k, v]) => `${k}×${v}`)
105
+ .join(", ");
106
+ return ` ${basename(path)} (${opsStr})`;
107
+ });
108
+
109
+ // Build search queries from file paths
110
+ const searchHint = includeSearchHints
111
+ ? buildSearchHint(entries.slice(-4).map(([path]) => `read(${path})`))
112
+ : "";
113
+
114
+ return [
115
+ ` <files count="${fileMap.size}">`,
116
+ ...lines,
117
+ searchHint,
118
+ ` </files>`,
119
+ ].filter(Boolean).join("\n");
120
+ }
121
+
122
+ function buildCommandsSection(
123
+ events: ReadonlyArray<SessionEvent>,
124
+ max: number,
125
+ includeSearchHints: boolean,
126
+ ): string {
127
+ const cmdEvents = events.filter((e) => e.category === "command");
128
+ if (cmdEvents.length === 0) return "";
129
+
130
+ // Take last N commands
131
+ const recent = cmdEvents.slice(-max);
132
+ const lines = recent.map((ev) => ` ${ev.data}`);
133
+
134
+ // Build search queries from command names
135
+ const searchHint = includeSearchHints
136
+ ? buildSearchHint(recent.slice(-4).map((ev) => {
137
+ // Extract the core command for searchability
138
+ const cmd = ev.data.split("→")[0]!.trim().slice(0, 60);
139
+ return `bash(${cmd})`;
140
+ }))
141
+ : "";
142
+
143
+ return [
144
+ ` <commands count="${cmdEvents.length}">`,
145
+ ...lines,
146
+ searchHint,
147
+ ` </commands>`,
148
+ ].filter(Boolean).join("\n");
149
+ }
150
+
151
+ function buildErrorsSection(
152
+ events: ReadonlyArray<SessionEvent>,
153
+ max: number,
154
+ includeSearchHints: boolean,
155
+ ): string {
156
+ const errorEvents = events.filter((e) => e.category === "error");
157
+ if (errorEvents.length === 0) return "";
158
+
159
+ // Deduplicate errors
160
+ const seen = new Set<string>();
161
+ const unique: SessionEvent[] = [];
162
+ for (const ev of errorEvents) {
163
+ if (!seen.has(ev.data)) {
164
+ seen.add(ev.data);
165
+ unique.push(ev);
166
+ }
167
+ }
168
+
169
+ const recent = unique.slice(-max);
170
+ const lines = recent.map((ev) => ` ${ev.data}`);
171
+
172
+ // Error search hints use the error messages themselves
173
+ const searchHint = includeSearchHints
174
+ ? buildSearchHint(recent.slice(-3).map((ev) => {
175
+ // Extract key error terms
176
+ const parts = ev.data.split(":");
177
+ return parts.length > 1 ? parts.slice(1).join(":").trim().slice(0, 60) : ev.data.slice(0, 60);
178
+ }))
179
+ : "";
180
+
181
+ return [
182
+ ` <errors count="${unique.length}">`,
183
+ ...lines,
184
+ searchHint,
185
+ ` </errors>`,
186
+ ].filter(Boolean).join("\n");
187
+ }
188
+
189
+ function buildSearchSection(
190
+ events: ReadonlyArray<SessionEvent>,
191
+ max: number,
192
+ ): string {
193
+ const searchEvents = events.filter((e) => e.category === "search");
194
+ if (searchEvents.length === 0) return "";
195
+
196
+ // Deduplicate
197
+ const seen = new Set<string>();
198
+ const unique: SessionEvent[] = [];
199
+ for (const ev of searchEvents) {
200
+ if (!seen.has(ev.data)) {
201
+ seen.add(ev.data);
202
+ unique.push(ev);
203
+ }
204
+ }
205
+
206
+ const recent = unique.slice(-max);
207
+ const lines = recent.map((ev) => ` ${ev.data}`);
208
+
209
+ return [
210
+ ` <searches count="${unique.length}">`,
211
+ ...lines,
212
+ ` </searches>`,
213
+ ].join("\n");
214
+ }
215
+
216
+ /* ── Helpers ───────────────────────────────────────────── */
217
+
218
+ function basename(path: string): string {
219
+ const parts = path.split("/");
220
+ return parts[parts.length - 1] || path;
221
+ }
222
+
223
+ /**
224
+ * Build a ctx_search hint line for a section.
225
+ * Shows the agent exactly how to retrieve full details.
226
+ */
227
+ function buildSearchHint(queries: string[]): string {
228
+ if (queries.length === 0) return "";
229
+ const escaped = queries.map((q) => `"${escapeXML(q)}"`).join(", ");
230
+ return `\n For full details: ctx_search(queries: [${escaped}])`;
231
+ }
232
+
233
+ function escapeXML(str: string): string {
234
+ return str
235
+ .replace(/&/g, "&amp;")
236
+ .replace(/</g, "&lt;")
237
+ .replace(/>/g, "&gt;")
238
+ .replace(/"/g, "&quot;")
239
+ .replace(/'/g, "&apos;");
240
+ }