@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.
- package/README.md +204 -0
- package/package.json +36 -0
- package/src/event-log.ts +246 -0
- package/src/extension.ts +1271 -0
- package/src/formatter.ts +127 -0
- package/src/index.ts +45 -0
- package/src/pressure.ts +61 -0
- package/src/search-tool.ts +230 -0
- package/src/snapshot.ts +240 -0
- package/src/store.ts +678 -0
- package/src/summarize.ts +206 -0
- package/src/tokenizer.ts +20 -0
- package/src/tracker.ts +159 -0
- package/src/types.ts +100 -0
package/src/formatter.ts
ADDED
|
@@ -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();
|
package/src/pressure.ts
ADDED
|
@@ -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
|
+
}
|
package/src/snapshot.ts
ADDED
|
@@ -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, "&")
|
|
236
|
+
.replace(/</g, "<")
|
|
237
|
+
.replace(/>/g, ">")
|
|
238
|
+
.replace(/"/g, """)
|
|
239
|
+
.replace(/'/g, "'");
|
|
240
|
+
}
|