@pi-unipi/compactor 0.2.1 → 0.2.3
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 +13 -25
- package/package.json +3 -2
- package/src/commands/index.ts +2 -2
- package/src/compaction/hooks.ts +3 -5
- package/src/config/schema.ts +1 -1
- package/src/display/diff-presentation.ts +6 -1
- package/src/display/diff-renderer.ts +34 -8
- package/src/display/diff-width-safety.ts +83 -0
- package/src/display/line-width-safety.ts +14 -2
- package/src/index.ts +154 -27
- package/src/info-screen.ts +136 -51
- package/src/session/analytics.ts +205 -0
- package/src/session/db.ts +3 -0
- package/src/tools/ctx-stats.ts +26 -2
- package/src/tools/register.ts +2 -2
- package/src/types.ts +5 -6
package/README.md
CHANGED
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
# @pi-unipi/compactor
|
|
2
2
|
|
|
3
|
-
Context engine
|
|
3
|
+
Context engine that keeps sessions lean. Compacts conversations, indexes code, searches history, and runs sandboxed code — all without burning LLM tokens on compaction.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
- **Zero-LLM Compaction** — 6-stage pipeline (normalize → filter → build sections → brief → format → merge) achieves 95%+ token reduction with zero API cost
|
|
8
|
-
- **Session Continuity** — XML resume snapshots survive compaction, preserving context across session boundaries
|
|
9
|
-
- **Sandbox Execution** — 11 languages with process isolation, security hardening, and output capping
|
|
10
|
-
- **FTS5 Search** — Full-text search over indexed content with auto-chunking
|
|
11
|
-
- **Tool Display** — Mode-aware rendering for read, grep, find, ls, bash, edit, write tools
|
|
5
|
+
The zero-LLM pipeline compresses context through 6 stages (normalize, filter, build sections, brief, format, merge) to hit 95%+ token reduction at zero API cost. Session continuity preserves context across compaction boundaries with XML resume snapshots.
|
|
12
6
|
|
|
13
7
|
## Commands
|
|
14
8
|
|
|
@@ -25,6 +19,12 @@ Context engine for Pi coding agent. Fuses zero-LLM compaction, session continuit
|
|
|
25
19
|
| `/unipi:compact-preset <name>` | Apply quick preset |
|
|
26
20
|
| `/unipi:compact-help` | Show detailed documentation |
|
|
27
21
|
|
|
22
|
+
## Special Triggers
|
|
23
|
+
|
|
24
|
+
Compactor tools are available to the main agent when installed. All workflow skills can use compactor tools for context management.
|
|
25
|
+
|
|
26
|
+
Compactor registers with the info-screen dashboard, showing compaction count, tokens saved, compression ratio, and indexed documents. The footer subscribes to `COMPACTOR_STATSUPDATED` events to display compaction stats in the status bar.
|
|
27
|
+
|
|
28
28
|
## Agent Tools
|
|
29
29
|
|
|
30
30
|
| Tool | Family | Description |
|
|
@@ -34,19 +34,19 @@ Context engine for Pi coding agent. Fuses zero-LLM compaction, session continuit
|
|
|
34
34
|
| `sandbox` | sandbox | Run code in sandbox (11 languages) |
|
|
35
35
|
| `sandbox_file` | sandbox | Execute file via FILE_CONTENT |
|
|
36
36
|
| `sandbox_batch` | sandbox | Atomic batch of commands + searches |
|
|
37
|
-
| `content_index` | content | Chunk content
|
|
37
|
+
| `content_index` | content | Chunk content to FTS5 index |
|
|
38
38
|
| `content_search` | content | Query indexed content |
|
|
39
|
-
| `content_fetch` | content | Fetch URL
|
|
39
|
+
| `content_fetch` | content | Fetch URL and index |
|
|
40
40
|
| `compactor_stats` | compactor | Context savings dashboard |
|
|
41
41
|
| `compactor_doctor` | compactor | Diagnostics checklist |
|
|
42
42
|
| `context_budget` | compactor | Estimate remaining context window |
|
|
43
43
|
|
|
44
44
|
## Two-Tier Skills
|
|
45
45
|
|
|
46
|
-
- **Tier 1** (`compactor`): ~175 tokens, always loaded. Routing
|
|
46
|
+
- **Tier 1** (`compactor`): ~175 tokens, always loaded. Routing and critical rules.
|
|
47
47
|
- **Tier 2** (`compactor-detail`): On-demand. Full tool reference, anti-patterns, sandbox languages, FTS5 modes, workflows.
|
|
48
48
|
|
|
49
|
-
##
|
|
49
|
+
## Configurables
|
|
50
50
|
|
|
51
51
|
Config lives at `~/.unipi/config/compactor/config.json`. Per-project overrides at `<project>/.unipi/config/compactor.json`.
|
|
52
52
|
|
|
@@ -78,7 +78,7 @@ Tabbed settings interface (Presets / Strategies / Pipeline):
|
|
|
78
78
|
- `/` key opens search filter in Strategies tab
|
|
79
79
|
- Preset selection shows 3-line preview
|
|
80
80
|
- Per-project override checkbox (`o` key)
|
|
81
|
-
- Keyboard:
|
|
81
|
+
- Keyboard: left/right cycle modes, Space toggle, `s` save, Esc cancel
|
|
82
82
|
|
|
83
83
|
## Architecture
|
|
84
84
|
|
|
@@ -95,18 +95,6 @@ Tabbed settings interface (Presets / Strategies / Pipeline):
|
|
|
95
95
|
└─────────────────────┘
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
## Installation
|
|
99
|
-
|
|
100
|
-
Included in `@pi-unipi/unipi` metapackage. To use standalone:
|
|
101
|
-
|
|
102
|
-
```json
|
|
103
|
-
{
|
|
104
|
-
"pi": {
|
|
105
|
-
"extensions": ["node_modules/@pi-unipi/compactor/src/index.ts"]
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
```
|
|
109
|
-
|
|
110
98
|
## License
|
|
111
99
|
|
|
112
100
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/compactor",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Context engine for Pi — zero-LLM compaction, session continuity, sandbox execution, FTS5 search, and tool display optimization",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"access": "public"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@pi-unipi/core": "*"
|
|
41
|
+
"@pi-unipi/core": "*",
|
|
42
|
+
"@pi-unipi/info-screen": "*"
|
|
42
43
|
},
|
|
43
44
|
"peerDependencies": {
|
|
44
45
|
"@mariozechner/pi-coding-agent": "*",
|
package/src/commands/index.ts
CHANGED
|
@@ -34,8 +34,8 @@ export interface CommandDeps {
|
|
|
34
34
|
getCounters?: () => RuntimeCounters;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function deprecationLog(
|
|
38
|
-
|
|
37
|
+
function deprecationLog(_oldName: string, _newName: string): void {
|
|
38
|
+
// Deprecation logging disabled — was writing to stdout causing TUI rendering issues.
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
|
package/src/compaction/hooks.ts
CHANGED
|
@@ -21,11 +21,9 @@ const formatTokens = (n: number): string => {
|
|
|
21
21
|
return String(n);
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
const dbg = (
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const details = data ? " " + JSON.stringify(data) : "";
|
|
28
|
-
console.error(`[compactor:${ts}] ${event}${details}`);
|
|
24
|
+
const dbg = (_debug: boolean, _event: string, _data?: Record<string, unknown>) => {
|
|
25
|
+
// Debug logging disabled — was writing to stdout causing TUI rendering issues.
|
|
26
|
+
return;
|
|
29
27
|
};
|
|
30
28
|
|
|
31
29
|
const previewContent = (content: unknown): string => {
|
package/src/config/schema.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { DiffLayout } from "../types.js";
|
|
6
|
+
import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
|
|
6
7
|
|
|
7
8
|
export function selectDiffLayout(
|
|
8
9
|
terminalWidth: number,
|
|
@@ -12,9 +13,13 @@ export function selectDiffLayout(
|
|
|
12
13
|
return terminalWidth >= 100 ? "split" : "unified";
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/** Clamp text to maxWidth visible columns, ANSI-aware */
|
|
15
17
|
export function clampWidth(text: string, maxWidth: number): string {
|
|
16
18
|
return text
|
|
17
19
|
.split("\n")
|
|
18
|
-
.map((line) =>
|
|
20
|
+
.map((line) => {
|
|
21
|
+
if (visibleWidth(line) <= maxWidth) return line;
|
|
22
|
+
return truncateToWidth(line, maxWidth, "…");
|
|
23
|
+
})
|
|
19
24
|
.join("\n");
|
|
20
25
|
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* syntax highlighting, and Nerd Font detection
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
|
|
7
|
+
|
|
6
8
|
export type DiffLayout = "auto" | "split" | "unified";
|
|
7
9
|
export type DiffIndicator = "bars" | "classic" | "nerd" | "none";
|
|
8
10
|
|
|
@@ -150,16 +152,28 @@ function indicatorChar(type: DiffLine["type"], style: DiffIndicator): string {
|
|
|
150
152
|
return type === "add" ? "+ " : type === "remove" ? "- " : " ";
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
function renderUnified(
|
|
155
|
+
function renderUnified(
|
|
156
|
+
diff: DiffLine[],
|
|
157
|
+
indicator: DiffIndicator,
|
|
158
|
+
maxWidth?: number,
|
|
159
|
+
): string {
|
|
154
160
|
return diff.map((line) => {
|
|
155
161
|
const prefix = indicator === "bars"
|
|
156
162
|
? (line.type === "add" ? "│ " : line.type === "remove" ? "│ " : " ")
|
|
157
163
|
: indicatorChar(line.type, indicator);
|
|
158
|
-
|
|
164
|
+
const rendered = prefix + line.text;
|
|
165
|
+
if (maxWidth && visibleWidth(rendered) > maxWidth) {
|
|
166
|
+
return truncateToWidth(rendered, maxWidth, "…");
|
|
167
|
+
}
|
|
168
|
+
return rendered;
|
|
159
169
|
}).join("\n");
|
|
160
170
|
}
|
|
161
171
|
|
|
162
|
-
function renderSplit(
|
|
172
|
+
function renderSplit(
|
|
173
|
+
diff: DiffLine[],
|
|
174
|
+
indicator: DiffIndicator,
|
|
175
|
+
maxW?: number,
|
|
176
|
+
): string {
|
|
163
177
|
const left: string[] = [];
|
|
164
178
|
const right: string[] = [];
|
|
165
179
|
|
|
@@ -176,12 +190,24 @@ function renderSplit(diff: DiffLine[], indicator: DiffIndicator): string {
|
|
|
176
190
|
}
|
|
177
191
|
}
|
|
178
192
|
|
|
179
|
-
const
|
|
193
|
+
const halfW = maxW ? Math.floor(maxW / 2) - 2 : 40;
|
|
194
|
+
const colW = Math.max(
|
|
195
|
+
...left.map((l) => visibleWidth(l)),
|
|
196
|
+
...right.map((l) => visibleWidth(l)),
|
|
197
|
+
Math.min(halfW, 40),
|
|
198
|
+
);
|
|
180
199
|
const result: string[] = [];
|
|
181
200
|
for (let i = 0; i < left.length; i++) {
|
|
182
|
-
const
|
|
201
|
+
const lTrunc = visibleWidth(left[i]) > colW
|
|
202
|
+
? truncateToWidth(left[i], colW, "…")
|
|
203
|
+
: left[i].padEnd(colW);
|
|
183
204
|
const sep = left[i] && right[i] ? " │ " : " ";
|
|
184
|
-
|
|
205
|
+
let rLine = right[i];
|
|
206
|
+
if (maxW && visibleWidth(lTrunc + sep + rLine) > maxW) {
|
|
207
|
+
const rBudget = maxW - visibleWidth(lTrunc + sep);
|
|
208
|
+
rLine = truncateToWidth(rLine, Math.max(1, rBudget), "…");
|
|
209
|
+
}
|
|
210
|
+
result.push(lTrunc + sep + rLine);
|
|
185
211
|
}
|
|
186
212
|
|
|
187
213
|
return result.join("\n");
|
|
@@ -229,10 +255,10 @@ export function renderDiff(
|
|
|
229
255
|
}
|
|
230
256
|
|
|
231
257
|
if (effectiveLayout === "split") {
|
|
232
|
-
return renderSplit(highlightedDiff, effectiveIndicator);
|
|
258
|
+
return renderSplit(highlightedDiff, effectiveIndicator, maxWidth);
|
|
233
259
|
}
|
|
234
260
|
|
|
235
|
-
return renderUnified(highlightedDiff, effectiveIndicator);
|
|
261
|
+
return renderUnified(highlightedDiff, effectiveIndicator, maxWidth);
|
|
236
262
|
}
|
|
237
263
|
|
|
238
264
|
export function renderEditDiffResult(
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff width safety — truncate diff lines to terminal width
|
|
3
|
+
*
|
|
4
|
+
* Pi's renderDiff() in diff.js produces lines without width
|
|
5
|
+
* truncation. When a diff line's visible content exceeds the
|
|
6
|
+
* terminal width, the TUI crashes with:
|
|
7
|
+
* "Rendered line N exceeds terminal width (X > Y)"
|
|
8
|
+
*
|
|
9
|
+
* This module provides clampDiffToWidth() which truncates
|
|
10
|
+
* each diff line to a safe width, preventing TUI crashes.
|
|
11
|
+
*
|
|
12
|
+
* The diff format from pi's generateDiffString() is:
|
|
13
|
+
* [+/-/ ]LINE_NUM CONTENT
|
|
14
|
+
* e.g.: "+ 38 │ The root cause is a **compound failure**..."
|
|
15
|
+
*
|
|
16
|
+
* We detect the terminal width from process.stdout and clamp
|
|
17
|
+
* each line, accounting for the rendering overhead of the
|
|
18
|
+
* edit tool's Box nesting (approx 4-6 chars of padding).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
|
|
22
|
+
|
|
23
|
+
/** Rendering overhead from Box nesting in edit tool components */
|
|
24
|
+
const RENDER_OVERHEAD = 6;
|
|
25
|
+
|
|
26
|
+
/** Minimum useful line width (don't clamp below this) */
|
|
27
|
+
const MIN_LINE_WIDTH = 20;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the terminal width with fallback.
|
|
31
|
+
* Uses process.stdout.columns (updated on resize).
|
|
32
|
+
*/
|
|
33
|
+
function getTerminalWidth(): number {
|
|
34
|
+
return process.stdout?.columns ?? 80;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Clamp a diff string so every line fits within terminal width.
|
|
39
|
+
* Preserves the diff prefix (+/-/ ) and line number while
|
|
40
|
+
* truncating the content portion.
|
|
41
|
+
*
|
|
42
|
+
* @param diff - The diff string from generateDiffString()
|
|
43
|
+
* @param maxWidth - Override for terminal width (for testing)
|
|
44
|
+
* @returns The clamped diff string (may be same reference if no clamping needed)
|
|
45
|
+
*/
|
|
46
|
+
export function clampDiffToWidth(
|
|
47
|
+
diff: string,
|
|
48
|
+
maxWidth?: number,
|
|
49
|
+
): string {
|
|
50
|
+
const termW = maxWidth ?? getTerminalWidth();
|
|
51
|
+
const safeW = Math.max(MIN_LINE_WIDTH, termW - RENDER_OVERHEAD);
|
|
52
|
+
|
|
53
|
+
const lines = diff.split("\n");
|
|
54
|
+
let changed = false;
|
|
55
|
+
|
|
56
|
+
const result = lines.map((line) => {
|
|
57
|
+
const vw = visibleWidth(line);
|
|
58
|
+
if (vw <= safeW) return line;
|
|
59
|
+
|
|
60
|
+
changed = true;
|
|
61
|
+
|
|
62
|
+
// Try to preserve the diff prefix (+/-/ ) and line number
|
|
63
|
+
// Format: [+/-/ ]LINE_NUM CONTENT
|
|
64
|
+
// The prefix and line number are critical for readability
|
|
65
|
+
const prefixMatch = line.match(/^([+\- ])\s*(\d*)\s/);
|
|
66
|
+
if (prefixMatch) {
|
|
67
|
+
const prefixLen = prefixMatch[0].length;
|
|
68
|
+
// Calculate how much content we can keep
|
|
69
|
+
const contentBudget = safeW - prefixLen;
|
|
70
|
+
if (contentBudget >= MIN_LINE_WIDTH) {
|
|
71
|
+
const prefix = line.slice(0, prefixLen);
|
|
72
|
+
const content = line.slice(prefixLen);
|
|
73
|
+
const truncated = truncateToWidth(content, contentBudget, "…");
|
|
74
|
+
return prefix + truncated;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fallback: truncate the entire line
|
|
79
|
+
return truncateToWidth(line, safeW, "…");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return changed ? result.join("\n") : diff;
|
|
83
|
+
}
|
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Line width safety — width clamping with collapsed hints
|
|
3
|
+
*
|
|
4
|
+
* Uses ANSI-aware visibleWidth measurement from pi-tui to properly
|
|
5
|
+
* handle lines containing escape codes. Falls back to raw-length
|
|
6
|
+
* measurement when pi-tui is unavailable.
|
|
3
7
|
*/
|
|
4
8
|
|
|
9
|
+
import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Clamp each line to maxWidth visible columns.
|
|
13
|
+
* Uses pi-tui's visibleWidth() for ANSI-aware measurement and
|
|
14
|
+
* truncateToWidth() for ANSI-safe truncation.
|
|
15
|
+
*/
|
|
5
16
|
export function clampLineWidth(lines: string[], maxWidth: number): string[] {
|
|
6
17
|
return lines.map((line) => {
|
|
7
|
-
|
|
8
|
-
|
|
18
|
+
const vw = visibleWidth(line);
|
|
19
|
+
if (vw <= maxWidth) return line;
|
|
20
|
+
return truncateToWidth(line, maxWidth, "…");
|
|
9
21
|
});
|
|
10
22
|
}
|
|
11
23
|
|
package/src/index.ts
CHANGED
|
@@ -16,17 +16,45 @@ import { registerCompactorTools } from "./tools/register.js";
|
|
|
16
16
|
import { normalizeMessages } from "./compaction/normalize.js";
|
|
17
17
|
import { filterNoise } from "./compaction/filter-noise.js";
|
|
18
18
|
import type { NormalizedBlock, CompactorStrategyConfig, RuntimeCounters } from "./types.js";
|
|
19
|
+
import type { RuntimeStats } from "./session/analytics.js";
|
|
19
20
|
|
|
20
21
|
/** Debug logger — only logs when config.debug === true */
|
|
21
22
|
function createDebugLogger(getConfig: () => { debug: boolean }) {
|
|
22
|
-
return (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const details = data ? " " + JSON.stringify(data) : "";
|
|
26
|
-
console.error(`[compactor:${ts}] ${event}${details}`);
|
|
23
|
+
return (_event: string, _data?: Record<string, unknown>) => {
|
|
24
|
+
// Debug logging disabled — was writing to stdout causing TUI rendering issues.
|
|
25
|
+
return;
|
|
27
26
|
};
|
|
28
27
|
}
|
|
29
28
|
|
|
29
|
+
/** Measure byte size of a tool_result event's response content. */
|
|
30
|
+
function measureResponseBytes(event: any): number {
|
|
31
|
+
try {
|
|
32
|
+
const content = event.content;
|
|
33
|
+
if (typeof content === "string") return Buffer.byteLength(content, "utf-8");
|
|
34
|
+
if (Array.isArray(content)) {
|
|
35
|
+
return content.reduce((sum: number, block: any) => {
|
|
36
|
+
if (typeof block?.text === "string") return sum + Buffer.byteLength(block.text, "utf-8");
|
|
37
|
+
if (typeof block === "string") return sum + Buffer.byteLength(block, "utf-8");
|
|
38
|
+
return sum;
|
|
39
|
+
}, 0);
|
|
40
|
+
}
|
|
41
|
+
if (event.output && typeof event.output === "string") return Buffer.byteLength(event.output, "utf-8");
|
|
42
|
+
} catch {
|
|
43
|
+
// Non-blocking: byte measurement errors silently skipped
|
|
44
|
+
}
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Check if a tool is a sandbox tool (output stays in sandbox, not context). */
|
|
49
|
+
function isSandboxTool(name: string): boolean {
|
|
50
|
+
return name === "bash" || name === "Bash";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Check if a tool is an index tool (content goes to FTS5, not context). Future-proofing. */
|
|
54
|
+
function isIndexTool(_name: string): boolean {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
30
58
|
export default function compactorExtension(pi: ExtensionAPI): void {
|
|
31
59
|
let sessionDB: SessionDB | null = null;
|
|
32
60
|
let contentStore: ContentStore | null = null;
|
|
@@ -43,6 +71,16 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
43
71
|
};
|
|
44
72
|
const getCounters = () => counters;
|
|
45
73
|
|
|
74
|
+
const runtimeStats: RuntimeStats = {
|
|
75
|
+
bytesReturned: {},
|
|
76
|
+
bytesIndexed: 0,
|
|
77
|
+
bytesSandboxed: 0,
|
|
78
|
+
calls: {},
|
|
79
|
+
sessionStart: Date.now(),
|
|
80
|
+
cacheHits: 0,
|
|
81
|
+
cacheBytesSaved: 0,
|
|
82
|
+
};
|
|
83
|
+
|
|
46
84
|
const debug = createDebugLogger(() => config);
|
|
47
85
|
|
|
48
86
|
const init = async () => {
|
|
@@ -58,8 +96,8 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
58
96
|
const db = new SessionDB();
|
|
59
97
|
await db.init();
|
|
60
98
|
sessionDB = db;
|
|
61
|
-
} catch
|
|
62
|
-
|
|
99
|
+
} catch {
|
|
100
|
+
// Silently ignore — SessionDB init failure is handled gracefully.
|
|
63
101
|
sessionDB = null;
|
|
64
102
|
}
|
|
65
103
|
|
|
@@ -70,8 +108,8 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
70
108
|
const cs = new ContentStore();
|
|
71
109
|
await cs.init();
|
|
72
110
|
contentStore = cs;
|
|
73
|
-
} catch
|
|
74
|
-
|
|
111
|
+
} catch {
|
|
112
|
+
// Silently ignore — ContentStore init failure is handled gracefully.
|
|
75
113
|
contentStore = null;
|
|
76
114
|
}
|
|
77
115
|
}
|
|
@@ -79,7 +117,12 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
79
117
|
executor = new PolyglotExecutor();
|
|
80
118
|
};
|
|
81
119
|
|
|
82
|
-
|
|
120
|
+
// Register compaction hooks with lazy deps — sessionDB/sessionId may not be
|
|
121
|
+
// available at registration time, but will be by the time events fire.
|
|
122
|
+
registerCompactionHooks(pi, {
|
|
123
|
+
getSessionDB: () => sessionDB,
|
|
124
|
+
getSessionId: () => currentSessionId,
|
|
125
|
+
});
|
|
83
126
|
|
|
84
127
|
// Commands registered inside session_start after init() when deps are ready
|
|
85
128
|
const getCommandDeps = () => ({
|
|
@@ -101,6 +144,15 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
101
144
|
|
|
102
145
|
debug("session_start", { sessionId: fullSessionId, projectDir });
|
|
103
146
|
|
|
147
|
+
// Reset runtime stats for new session
|
|
148
|
+
runtimeStats.bytesReturned = {};
|
|
149
|
+
runtimeStats.bytesIndexed = 0;
|
|
150
|
+
runtimeStats.bytesSandboxed = 0;
|
|
151
|
+
runtimeStats.calls = {};
|
|
152
|
+
runtimeStats.sessionStart = Date.now();
|
|
153
|
+
runtimeStats.cacheHits = 0;
|
|
154
|
+
runtimeStats.cacheBytesSaved = 0;
|
|
155
|
+
|
|
104
156
|
sessionDB?.ensureSession(fullSessionId, projectDir);
|
|
105
157
|
|
|
106
158
|
// Register all compactor tools with Pi (deps now have live sessionDB)
|
|
@@ -119,9 +171,8 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
119
171
|
|
|
120
172
|
// Register info-screen group
|
|
121
173
|
const infoRegistry = (globalThis as any).__unipi_info_registry;
|
|
122
|
-
if (infoRegistry && sessionDB
|
|
174
|
+
if (infoRegistry && sessionDB) {
|
|
123
175
|
const sdb = sessionDB;
|
|
124
|
-
const cs = contentStore;
|
|
125
176
|
const sid = () => currentSessionId;
|
|
126
177
|
infoRegistry.registerGroup({
|
|
127
178
|
id: "compactor",
|
|
@@ -131,23 +182,25 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
131
182
|
config: {
|
|
132
183
|
showByDefault: true,
|
|
133
184
|
stats: [
|
|
134
|
-
{ id: "
|
|
185
|
+
{ id: "tokensSaved", label: "Tokens saved", show: true },
|
|
186
|
+
{ id: "costSaved", label: "Cost saved", show: true },
|
|
187
|
+
{ id: "pctReduction", label: "% Reduction", show: true },
|
|
188
|
+
{ id: "topTools", label: "Top tools", show: true },
|
|
135
189
|
{ id: "compactions", label: "Compactions", show: true },
|
|
136
|
-
{ id: "
|
|
137
|
-
{ id: "compressionRatio", label: "Compression ratio", show: true },
|
|
138
|
-
{ id: "indexedDocs", label: "Indexed docs", show: true },
|
|
190
|
+
{ id: "toolCalls", label: "Tool calls", show: true },
|
|
139
191
|
],
|
|
140
192
|
},
|
|
141
193
|
dataProvider: async () => {
|
|
142
194
|
try {
|
|
143
195
|
const { getInfoScreenData } = await import("./info-screen.js");
|
|
144
|
-
const data = await getInfoScreenData(sdb,
|
|
196
|
+
const data = await getInfoScreenData(sdb, sid(), runtimeStats);
|
|
145
197
|
return {
|
|
146
|
-
sessionEvents: { value: data.sessionEvents.value, detail: data.sessionEvents.detail },
|
|
147
|
-
compactions: { value: data.compactions.value, detail: data.compactions.detail },
|
|
148
198
|
tokensSaved: { value: data.tokensSaved.value, detail: data.tokensSaved.detail },
|
|
149
|
-
|
|
150
|
-
|
|
199
|
+
costSaved: { value: data.costSaved.value, detail: data.costSaved.detail },
|
|
200
|
+
pctReduction: { value: data.pctReduction.value, detail: data.pctReduction.detail },
|
|
201
|
+
topTools: { value: data.topTools.value, detail: data.topTools.detail },
|
|
202
|
+
compactions: { value: data.compactions.value, detail: data.compactions.detail },
|
|
203
|
+
toolCalls: { value: data.toolCalls.value, detail: data.toolCalls.detail },
|
|
151
204
|
};
|
|
152
205
|
} catch {
|
|
153
206
|
return {};
|
|
@@ -235,7 +288,9 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
235
288
|
|
|
236
289
|
pi.on("session_before_compact", async (event, _ctx) => {
|
|
237
290
|
if (sessionDB) {
|
|
238
|
-
|
|
291
|
+
// Use closure currentSessionId — Pi's session_before_compact event
|
|
292
|
+
// does not include sessionId at the top level.
|
|
293
|
+
const sessionId = currentSessionId;
|
|
239
294
|
const events = sessionDB.getEvents(sessionId, { limit: 1000 });
|
|
240
295
|
const stats = sessionDB.getSessionStats(sessionId);
|
|
241
296
|
debug("session_before_compact", { sessionId, eventCount: events.length, compactCount: stats?.compact_count ?? 0 });
|
|
@@ -249,14 +304,43 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
249
304
|
|
|
250
305
|
pi.on("session_compact", async (event, _ctx) => {
|
|
251
306
|
if (sessionDB) {
|
|
252
|
-
|
|
307
|
+
// Use closure currentSessionId — Pi's session_compact event does not
|
|
308
|
+
// include sessionId at the top level (it's inside compactionEntry).
|
|
309
|
+
const sessionId = currentSessionId;
|
|
253
310
|
sessionDB.incrementCompactCount(sessionId);
|
|
254
311
|
counters.compactions++;
|
|
255
|
-
|
|
312
|
+
|
|
313
|
+
// Pi's session_compact event structure: { compactionEntry, fromExtension }
|
|
314
|
+
// tokensBefore is inside compactionEntry, not at event root.
|
|
315
|
+
const compactionEntry = (event as any).compactionEntry;
|
|
316
|
+
const tokensBefore = compactionEntry?.tokensBefore ?? 0;
|
|
317
|
+
|
|
318
|
+
// Use actual runtimeStats for byte measurement instead of heuristic
|
|
319
|
+
const totalBytesReturned = Object.values(runtimeStats.bytesReturned).reduce((s, b) => s + b, 0);
|
|
320
|
+
const totalBytesProcessed = runtimeStats.bytesIndexed + runtimeStats.bytesSandboxed + totalBytesReturned;
|
|
321
|
+
|
|
256
322
|
if (tokensBefore > 0) {
|
|
257
|
-
|
|
323
|
+
// Use actual token count from Pi's compactionEntry
|
|
324
|
+
const charsBefore = tokensBefore * 4;
|
|
325
|
+
// Estimate kept chars: proportional to what remains after compaction
|
|
326
|
+
const tokensAfter = (event as any).tokensAfter ?? Math.round(tokensBefore * 0.15);
|
|
327
|
+
const charsKept = tokensAfter * 4;
|
|
328
|
+
const messagesSummarized = Math.max(1, Math.round(tokensBefore / 500));
|
|
329
|
+
counters.totalTokensCompacted += tokensBefore - tokensAfter;
|
|
330
|
+
sessionDB.addCompactionStats(sessionId, charsBefore, charsKept, messagesSummarized);
|
|
331
|
+
} else {
|
|
332
|
+
// Fallback: estimate from runtime byte stats when tokensBefore unavailable
|
|
333
|
+
if (totalBytesProcessed > 0) {
|
|
334
|
+
const charsBefore = totalBytesProcessed;
|
|
335
|
+
const charsKept = totalBytesReturned;
|
|
336
|
+
const messagesSummarized = Math.max(1, Math.round(totalBytesProcessed / 2000));
|
|
337
|
+
const estTokensBefore = Math.round(totalBytesProcessed / 4);
|
|
338
|
+
const estTokensAfter = Math.round(totalBytesReturned / 4);
|
|
339
|
+
counters.totalTokensCompacted += Math.max(0, estTokensBefore - estTokensAfter);
|
|
340
|
+
sessionDB.addCompactionStats(sessionId, charsBefore, charsKept, messagesSummarized);
|
|
341
|
+
}
|
|
258
342
|
}
|
|
259
|
-
debug("session_compact", { sessionId });
|
|
343
|
+
debug("session_compact", { sessionId, tokensBefore, totalBytesProcessed, hasCompactionEntry: !!compactionEntry });
|
|
260
344
|
}
|
|
261
345
|
});
|
|
262
346
|
|
|
@@ -346,7 +430,8 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
346
430
|
|
|
347
431
|
pi.on("tool_result", async (event, _ctx) => {
|
|
348
432
|
if (!sessionDB) return;
|
|
349
|
-
|
|
433
|
+
// Use closure currentSessionId — tool_result events use the same session
|
|
434
|
+
const sessionId = currentSessionId;
|
|
350
435
|
const toolNameRaw = (event as any).toolName ?? "";
|
|
351
436
|
const isError = (event as any).isError ?? false;
|
|
352
437
|
|
|
@@ -365,6 +450,24 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
365
450
|
debug("event_stored", { category: ev.category, type: ev.type });
|
|
366
451
|
}
|
|
367
452
|
|
|
453
|
+
// Track byte consumption per tool for analytics
|
|
454
|
+
try {
|
|
455
|
+
const responseBytes = measureResponseBytes(event);
|
|
456
|
+
if (responseBytes > 0) {
|
|
457
|
+
const tName = (event as any).toolName ?? "unknown";
|
|
458
|
+
runtimeStats.calls[tName] = (runtimeStats.calls[tName] || 0) + 1;
|
|
459
|
+
runtimeStats.bytesReturned[tName] = (runtimeStats.bytesReturned[tName] || 0) + responseBytes;
|
|
460
|
+
if (isSandboxTool(tName)) {
|
|
461
|
+
runtimeStats.bytesSandboxed += responseBytes;
|
|
462
|
+
}
|
|
463
|
+
if (isIndexTool(tName)) {
|
|
464
|
+
runtimeStats.bytesIndexed += responseBytes;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
} catch {
|
|
468
|
+
// Non-blocking: byte tracking errors silently skipped
|
|
469
|
+
}
|
|
470
|
+
|
|
368
471
|
// Apply display overrides for built-in tools
|
|
369
472
|
const toolName = (event as any).toolName ?? "";
|
|
370
473
|
const td = config.toolDisplay;
|
|
@@ -385,6 +488,30 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
385
488
|
} catch {
|
|
386
489
|
// Non-fatal: display override failed
|
|
387
490
|
}
|
|
491
|
+
|
|
492
|
+
// Width-safe diff truncation for edit/write tool results.
|
|
493
|
+
// Pi's renderDiff() does not truncate lines to terminal width,
|
|
494
|
+
// causing TUI crashes on narrow terminals. We truncate the
|
|
495
|
+
// diff string in details.diff before it reaches the TUI.
|
|
496
|
+
const diffToolNames = ["edit", "Edit", "write", "Write"];
|
|
497
|
+
if (diffToolNames.includes(toolName)) {
|
|
498
|
+
try {
|
|
499
|
+
const details = (event as any).details as
|
|
500
|
+
{ diff?: string } | undefined;
|
|
501
|
+
if (details?.diff) {
|
|
502
|
+
const { clampDiffToWidth } = await import(
|
|
503
|
+
"./display/diff-width-safety.js"
|
|
504
|
+
);
|
|
505
|
+
const clamped = clampDiffToWidth(details.diff);
|
|
506
|
+
if (clamped !== details.diff) {
|
|
507
|
+
debug("diff_width_clamped", { toolName });
|
|
508
|
+
return { details: { ...details, diff: clamped } } as any;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
} catch (err) {
|
|
512
|
+
debug("diff_width_clamp_error", { error: String(err) });
|
|
513
|
+
}
|
|
514
|
+
}
|
|
388
515
|
});
|
|
389
516
|
|
|
390
517
|
pi.on("message_update", async (event, _ctx) => {
|
package/src/info-screen.ts
CHANGED
|
@@ -1,66 +1,151 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Info-screen integration for @pi-unipi/compactor
|
|
3
|
+
*
|
|
4
|
+
* Budget-focused stats: tokensSaved, costSaved, pctReduction,
|
|
5
|
+
* topTools, compactions, toolCalls.
|
|
3
6
|
*/
|
|
4
7
|
|
|
5
8
|
import type { SessionDB } from "./session/db.js";
|
|
6
|
-
import type {
|
|
7
|
-
import
|
|
9
|
+
import type { RuntimeStats, FullReport } from "./session/analytics.js";
|
|
10
|
+
import { AnalyticsEngine, createMinimalDb } from "./session/analytics.js";
|
|
8
11
|
import { getLastCompactionStats } from "./compaction/hooks.js";
|
|
12
|
+
import { parseUsageStats } from "@pi-unipi/info-screen/usage-parser.js";
|
|
9
13
|
|
|
10
|
-
export interface
|
|
11
|
-
sessionEvents: { value: string; detail: string };
|
|
12
|
-
compactions: { value: string; detail: string };
|
|
14
|
+
export interface CompactorInfoData {
|
|
13
15
|
tokensSaved: { value: string; detail: string };
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
costSaved: { value: string; detail: string };
|
|
17
|
+
pctReduction: { value: string; detail: string };
|
|
18
|
+
topTools: { value: string; detail: string };
|
|
19
|
+
compactions: { value: string; detail: string };
|
|
20
|
+
toolCalls: { value: string; detail: string };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Format token count for display (e.g., "12.4k", "1.2M"). */
|
|
24
|
+
function formatTokens(n: number): string {
|
|
25
|
+
if (n < 1000) return String(n);
|
|
26
|
+
if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
|
|
27
|
+
if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
|
|
28
|
+
if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
29
|
+
return `${Math.round(n / 1_000_000)}M`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Format cost for display (e.g., "$0.34", "<$0.01"). */
|
|
33
|
+
function formatCost(n: number): string {
|
|
34
|
+
if (n === 0) return "$0.00";
|
|
35
|
+
if (n < 0.01) return "<$0.01";
|
|
36
|
+
if (n < 1) return `$${n.toFixed(2)}`;
|
|
37
|
+
return `$${n.toFixed(2)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Estimate cost per token for the most-used model in the current session. */
|
|
41
|
+
function estimateCostPerToken(): number | null {
|
|
42
|
+
try {
|
|
43
|
+
const usage = parseUsageStats();
|
|
44
|
+
// Use today's most-used model if available, otherwise all-time
|
|
45
|
+
const models = usage.byModelToday;
|
|
46
|
+
const todayKeys = Object.keys(models);
|
|
47
|
+
if (todayKeys.length > 0) {
|
|
48
|
+
// Pick the model with most tokens today
|
|
49
|
+
const topModel = todayKeys.reduce((a, b) => models[a].tokens > models[b].tokens ? a : b);
|
|
50
|
+
const entry = models[topModel];
|
|
51
|
+
if (entry.tokens > 0 && entry.cost > 0) {
|
|
52
|
+
return entry.cost / entry.tokens;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Fall back to all-time model data
|
|
56
|
+
const allKeys = Object.keys(usage.byModel);
|
|
57
|
+
if (allKeys.length > 0) {
|
|
58
|
+
const topModel = allKeys.reduce((a, b) => usage.byModel[a].tokens > usage.byModel[b].tokens ? a : b);
|
|
59
|
+
const entry = usage.byModel[topModel];
|
|
60
|
+
if (entry.tokens > 0 && entry.cost > 0) {
|
|
61
|
+
return entry.cost / entry.tokens;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
18
68
|
}
|
|
19
69
|
|
|
20
70
|
export async function getInfoScreenData(
|
|
21
71
|
sessionDB: SessionDB,
|
|
22
|
-
contentStore: ContentStore,
|
|
23
72
|
sessionId: string,
|
|
24
|
-
|
|
25
|
-
): Promise<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
73
|
+
runtimeStats: RuntimeStats,
|
|
74
|
+
): Promise<CompactorInfoData> {
|
|
75
|
+
try {
|
|
76
|
+
const db = sessionDB.getDb();
|
|
77
|
+
const adapter = db ?? createMinimalDb();
|
|
78
|
+
const engine = new AnalyticsEngine(adapter);
|
|
79
|
+
const report = engine.queryAll(runtimeStats);
|
|
80
|
+
const compactStats = getLastCompactionStats();
|
|
81
|
+
|
|
82
|
+
// Tokens saved: bytes kept out of context / 4
|
|
83
|
+
const tokensSaved = Math.round(report.savings.kept_out / 4);
|
|
84
|
+
|
|
85
|
+
// Per-tool breakdown table for tokensSaved detail
|
|
86
|
+
const toolsWithCalls = report.savings.by_tool
|
|
87
|
+
.filter(t => t.calls > 0)
|
|
88
|
+
.sort((a, b) => b.tokens - a.tokens);
|
|
89
|
+
const toolBreakdown = toolsWithCalls.length > 0
|
|
90
|
+
? toolsWithCalls.map(t =>
|
|
91
|
+
` ${t.tool.padEnd(20)} ${String(t.calls).padStart(4)} calls ${formatTokens(t.tokens).padStart(8)} tok`
|
|
92
|
+
).join("\n")
|
|
93
|
+
: "No tool calls yet";
|
|
94
|
+
|
|
95
|
+
// Cost saved: tokensSaved × cost per token
|
|
96
|
+
const costPerToken = estimateCostPerToken();
|
|
97
|
+
const costSaved = costPerToken !== null ? tokensSaved * costPerToken : null;
|
|
98
|
+
|
|
99
|
+
// Top consuming tool
|
|
100
|
+
const topTool = toolsWithCalls[0];
|
|
101
|
+
const top5Tools = toolsWithCalls.slice(0, 5);
|
|
102
|
+
const top5Detail = top5Tools.length > 0
|
|
103
|
+
? top5Tools.map(t =>
|
|
104
|
+
`${t.tool}: ${formatTokens(t.tokens)} (${t.calls} calls)`
|
|
105
|
+
).join("\n")
|
|
106
|
+
: "No tool calls yet";
|
|
29
107
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
:
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
108
|
+
return {
|
|
109
|
+
tokensSaved: {
|
|
110
|
+
value: formatTokens(tokensSaved),
|
|
111
|
+
detail: toolBreakdown,
|
|
112
|
+
},
|
|
113
|
+
costSaved: {
|
|
114
|
+
value: costSaved !== null ? formatCost(costSaved) : "N/A",
|
|
115
|
+
detail: costSaved !== null
|
|
116
|
+
? `~${formatTokens(tokensSaved)} tokens × $${(costPerToken! * 1_000_000).toFixed(2)}/M tokens`
|
|
117
|
+
: "Cost data unavailable for current model",
|
|
118
|
+
},
|
|
119
|
+
pctReduction: {
|
|
120
|
+
value: `${report.savings.pct}%`,
|
|
121
|
+
detail: `${formatTokens(Math.round(report.savings.processed_kb * 1024 / 4))} processed → ${formatTokens(Math.round(report.savings.entered_kb * 1024 / 4))} entered context`,
|
|
122
|
+
},
|
|
123
|
+
topTools: {
|
|
124
|
+
value: topTool ? `${topTool.tool}: ${formatTokens(topTool.tokens)}` : "N/A",
|
|
125
|
+
detail: top5Detail,
|
|
126
|
+
},
|
|
127
|
+
compactions: {
|
|
128
|
+
value: String(Math.max(report.continuity.compact_count, report.continuity.all_time_compact_count)),
|
|
129
|
+
detail: compactStats
|
|
130
|
+
? `Last: ${compactStats.summarized} msgs summarized, ${compactStats.kept} kept (~${formatTokens(compactStats.keptTokensEst)} tok)`
|
|
131
|
+
: report.continuity.all_time_compact_count > 0
|
|
132
|
+
? `${report.continuity.all_time_compact_count} compaction(s) across all sessions`
|
|
133
|
+
: "No compactions yet",
|
|
134
|
+
},
|
|
135
|
+
toolCalls: {
|
|
136
|
+
value: String(report.savings.total_calls),
|
|
137
|
+
detail: `${report.savings.total_calls} total tool calls across ${toolsWithCalls.length} tool${toolsWithCalls.length !== 1 ? "s" : ""}`,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
} catch {
|
|
141
|
+
// Never throw from dataProvider — return zeroed stats
|
|
142
|
+
return {
|
|
143
|
+
tokensSaved: { value: "0", detail: "No data" },
|
|
144
|
+
costSaved: { value: "N/A", detail: "No data" },
|
|
145
|
+
pctReduction: { value: "0%", detail: "No data" },
|
|
146
|
+
topTools: { value: "N/A", detail: "No data" },
|
|
147
|
+
compactions: { value: "0", detail: "No data" },
|
|
148
|
+
toolCalls: { value: "0", detail: "No data" },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
66
151
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnalyticsEngine — Runtime savings + session continuity reporting.
|
|
3
|
+
*
|
|
4
|
+
* Ported from context-mode's AnalyticsEngine, trimmed to budget-focused stats.
|
|
5
|
+
* Omits: formatReport(), categoryLabels, categoryHints, ThinkInCodeComparison,
|
|
6
|
+
* SandboxIO, dataBar(), visual formatting helpers.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const engine = new AnalyticsEngine(sessionDb);
|
|
10
|
+
* const report = engine.queryAll(runtimeStats);
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ─────────────────────────────────────────────────────────
|
|
14
|
+
// Types
|
|
15
|
+
// ─────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/** Database adapter — anything with a prepare() method (better-sqlite3, bun:sqlite, etc.) */
|
|
18
|
+
export interface DatabaseAdapter {
|
|
19
|
+
prepare(sql: string): {
|
|
20
|
+
run(...params: unknown[]): unknown;
|
|
21
|
+
get(...params: unknown[]): unknown;
|
|
22
|
+
all(...params: unknown[]): unknown[];
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Context savings result */
|
|
27
|
+
export interface ContextSavings {
|
|
28
|
+
rawBytes: number;
|
|
29
|
+
contextBytes: number;
|
|
30
|
+
savedBytes: number;
|
|
31
|
+
savedPercent: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Runtime stats tracked during a live session. */
|
|
35
|
+
export interface RuntimeStats {
|
|
36
|
+
bytesReturned: Record<string, number>;
|
|
37
|
+
bytesIndexed: number;
|
|
38
|
+
bytesSandboxed: number;
|
|
39
|
+
calls: Record<string, number>;
|
|
40
|
+
sessionStart: number;
|
|
41
|
+
cacheHits: number;
|
|
42
|
+
cacheBytesSaved: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Unified report combining runtime stats, DB analytics, and continuity data. */
|
|
46
|
+
export interface FullReport {
|
|
47
|
+
/** Runtime context savings */
|
|
48
|
+
savings: {
|
|
49
|
+
processed_kb: number;
|
|
50
|
+
entered_kb: number;
|
|
51
|
+
saved_kb: number;
|
|
52
|
+
pct: number;
|
|
53
|
+
savings_ratio: number;
|
|
54
|
+
by_tool: Array<{ tool: string; calls: number; context_kb: number; tokens: number }>;
|
|
55
|
+
total_calls: number;
|
|
56
|
+
total_bytes_returned: number;
|
|
57
|
+
kept_out: number;
|
|
58
|
+
total_processed: number;
|
|
59
|
+
};
|
|
60
|
+
/** Session metadata from SessionDB */
|
|
61
|
+
session: {
|
|
62
|
+
id: string;
|
|
63
|
+
uptime_min: string;
|
|
64
|
+
};
|
|
65
|
+
/** Session continuity data */
|
|
66
|
+
continuity: {
|
|
67
|
+
total_events: number;
|
|
68
|
+
compact_count: number;
|
|
69
|
+
all_time_compact_count: number;
|
|
70
|
+
resume_ready: boolean;
|
|
71
|
+
};
|
|
72
|
+
/** Persistent project memory — all events across all sessions */
|
|
73
|
+
projectMemory: {
|
|
74
|
+
total_events: number;
|
|
75
|
+
session_count: number;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─────────────────────────────────────────────────────────
|
|
80
|
+
// AnalyticsEngine
|
|
81
|
+
// ─────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
export class AnalyticsEngine {
|
|
84
|
+
private readonly db: DatabaseAdapter;
|
|
85
|
+
|
|
86
|
+
constructor(db: DatabaseAdapter) {
|
|
87
|
+
this.db = db;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build a FullReport by merging runtime stats (passed in)
|
|
92
|
+
* with continuity data from the DB.
|
|
93
|
+
*/
|
|
94
|
+
queryAll(runtimeStats: RuntimeStats): FullReport {
|
|
95
|
+
// ── Resolve latest session ID ──
|
|
96
|
+
const latestSession = this.db.prepare(
|
|
97
|
+
"SELECT session_id FROM session_meta ORDER BY started_at DESC LIMIT 1",
|
|
98
|
+
).get() as { session_id: string } | undefined;
|
|
99
|
+
const sid = latestSession?.session_id ?? "";
|
|
100
|
+
|
|
101
|
+
// ── Runtime savings ──
|
|
102
|
+
const totalBytesReturned = Object.values(runtimeStats.bytesReturned).reduce(
|
|
103
|
+
(sum, b) => sum + b, 0,
|
|
104
|
+
);
|
|
105
|
+
const totalCalls = Object.values(runtimeStats.calls).reduce(
|
|
106
|
+
(sum, c) => sum + c, 0,
|
|
107
|
+
);
|
|
108
|
+
const keptOut = runtimeStats.bytesIndexed + runtimeStats.bytesSandboxed;
|
|
109
|
+
const totalProcessed = keptOut + totalBytesReturned;
|
|
110
|
+
const savingsRatio = totalProcessed / Math.max(totalBytesReturned, 1);
|
|
111
|
+
const reductionPct = totalProcessed > 0
|
|
112
|
+
? Math.round((1 - totalBytesReturned / totalProcessed) * 100)
|
|
113
|
+
: 0;
|
|
114
|
+
|
|
115
|
+
const toolNames = new Set([
|
|
116
|
+
...Object.keys(runtimeStats.calls),
|
|
117
|
+
...Object.keys(runtimeStats.bytesReturned),
|
|
118
|
+
]);
|
|
119
|
+
const byTool = Array.from(toolNames).sort().map((tool) => ({
|
|
120
|
+
tool,
|
|
121
|
+
calls: runtimeStats.calls[tool] || 0,
|
|
122
|
+
context_kb: Math.round((runtimeStats.bytesReturned[tool] || 0) / 1024 * 10) / 10,
|
|
123
|
+
tokens: Math.round((runtimeStats.bytesReturned[tool] || 0) / 4),
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
const uptimeMs = Date.now() - runtimeStats.sessionStart;
|
|
127
|
+
const uptimeMin = (uptimeMs / 60_000).toFixed(1);
|
|
128
|
+
|
|
129
|
+
// ── Continuity data (scoped to current session) ──
|
|
130
|
+
const eventTotal = (this.db.prepare(
|
|
131
|
+
"SELECT COUNT(*) as cnt FROM session_events WHERE session_id = ?",
|
|
132
|
+
).get(sid) as { cnt: number }).cnt;
|
|
133
|
+
|
|
134
|
+
const meta = this.db.prepare(
|
|
135
|
+
"SELECT compact_count FROM session_meta WHERE session_id = ?",
|
|
136
|
+
).get(sid) as { compact_count: number } | undefined;
|
|
137
|
+
const compactCount = meta?.compact_count ?? 0;
|
|
138
|
+
|
|
139
|
+
// ── All-time compaction count across all sessions ──
|
|
140
|
+
const allTimeCompactions = (this.db.prepare(
|
|
141
|
+
"SELECT COALESCE(SUM(compact_count), 0) as total FROM session_meta",
|
|
142
|
+
).get() as { total: number }).total;
|
|
143
|
+
|
|
144
|
+
const resume = this.db.prepare(
|
|
145
|
+
"SELECT event_count, consumed FROM session_resume WHERE session_id = ? ORDER BY created_at DESC LIMIT 1",
|
|
146
|
+
).get(sid) as { event_count: number; consumed: number } | undefined;
|
|
147
|
+
const resumeReady = resume ? !resume.consumed : false;
|
|
148
|
+
|
|
149
|
+
// ── Project-wide persistent memory (all sessions, no session_id filter) ──
|
|
150
|
+
const projectTotals = this.db.prepare(
|
|
151
|
+
"SELECT COUNT(*) as cnt, COUNT(DISTINCT session_id) as sessions FROM session_events",
|
|
152
|
+
).get() as { cnt: number; sessions: number };
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
savings: {
|
|
156
|
+
processed_kb: Math.round(totalProcessed / 1024 * 10) / 10,
|
|
157
|
+
entered_kb: Math.round(totalBytesReturned / 1024 * 10) / 10,
|
|
158
|
+
saved_kb: Math.round(keptOut / 1024 * 10) / 10,
|
|
159
|
+
pct: reductionPct,
|
|
160
|
+
savings_ratio: Math.round(savingsRatio * 10) / 10,
|
|
161
|
+
by_tool: byTool,
|
|
162
|
+
total_calls: totalCalls,
|
|
163
|
+
total_bytes_returned: totalBytesReturned,
|
|
164
|
+
kept_out: keptOut,
|
|
165
|
+
total_processed: totalProcessed,
|
|
166
|
+
},
|
|
167
|
+
session: {
|
|
168
|
+
id: sid,
|
|
169
|
+
uptime_min: uptimeMin,
|
|
170
|
+
},
|
|
171
|
+
continuity: {
|
|
172
|
+
total_events: eventTotal,
|
|
173
|
+
compact_count: compactCount,
|
|
174
|
+
all_time_compact_count: allTimeCompactions,
|
|
175
|
+
resume_ready: resumeReady,
|
|
176
|
+
},
|
|
177
|
+
projectMemory: {
|
|
178
|
+
total_events: projectTotals.cnt,
|
|
179
|
+
session_count: projectTotals.sessions,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─────────────────────────────────────────────────────────
|
|
186
|
+
// createMinimalDb — in-memory SQLite fallback
|
|
187
|
+
// ─────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Create a minimal in-memory DatabaseAdapter for when SessionDB is unavailable.
|
|
191
|
+
* Returns zeroed/empty results for all queries.
|
|
192
|
+
*/
|
|
193
|
+
export function createMinimalDb(): DatabaseAdapter {
|
|
194
|
+
// Use an in-memory SQLite database with the expected schema
|
|
195
|
+
// so AnalyticsEngine queries don't fail.
|
|
196
|
+
const emptyStmt = {
|
|
197
|
+
run: (..._params: unknown[]) => {},
|
|
198
|
+
get: (..._params: unknown[]) => ({ cnt: 0, sessions: 0, compact_count: 0, total: 0, session_id: "", event_count: 0, consumed: 1 }),
|
|
199
|
+
all: (..._params: unknown[]) => [] as unknown[],
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
prepare: (_sql: string) => emptyStmt,
|
|
204
|
+
};
|
|
205
|
+
}
|
package/src/session/db.ts
CHANGED
|
@@ -293,6 +293,9 @@ export class SessionDB {
|
|
|
293
293
|
return oldSessions.length;
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
/** Expose the underlying db for AnalyticsEngine (read-only queries). Returns null if init failed. */
|
|
297
|
+
getDb(): any { return this.db ?? null; }
|
|
298
|
+
|
|
296
299
|
close(): void {
|
|
297
300
|
try { this.db.close(); } catch { /* ignore */ }
|
|
298
301
|
}
|
package/src/tools/ctx-stats.ts
CHANGED
|
@@ -26,10 +26,34 @@ export async function ctxStats(
|
|
|
26
26
|
const sessionStats = sessionDB.getSessionStats(sessionId);
|
|
27
27
|
const storeStats = await contentStore.getStats();
|
|
28
28
|
|
|
29
|
+
// Compute tokensSaved: prefer in-memory counters (current session),
|
|
30
|
+
// fall back to per-session DB stats, then all-time DB stats.
|
|
31
|
+
let tokensSaved = counters?.totalTokensCompacted ?? 0;
|
|
32
|
+
if (tokensSaved === 0 && sessionStats) {
|
|
33
|
+
const sessionCharsBefore = (sessionStats as any).total_chars_before ?? 0;
|
|
34
|
+
const sessionCharsKept = (sessionStats as any).total_chars_kept ?? 0;
|
|
35
|
+
tokensSaved = Math.round((sessionCharsBefore - sessionCharsKept) / 4);
|
|
36
|
+
}
|
|
37
|
+
if (tokensSaved === 0) {
|
|
38
|
+
const allTime = sessionDB.getAllTimeStats();
|
|
39
|
+
tokensSaved = Math.round((allTime.allCharsBefore - allTime.allCharsKept) / 4);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Compute compactions: prefer in-memory counter (current session),
|
|
43
|
+
// fall back to per-session DB, then all-time DB.
|
|
44
|
+
let compactions = counters?.compactions ?? 0;
|
|
45
|
+
if (compactions === 0) {
|
|
46
|
+
compactions = sessionStats?.compact_count ?? 0;
|
|
47
|
+
}
|
|
48
|
+
if (compactions === 0) {
|
|
49
|
+
const allTime = sessionDB.getAllTimeStats();
|
|
50
|
+
compactions = allTime.allCompactions;
|
|
51
|
+
}
|
|
52
|
+
|
|
29
53
|
return {
|
|
30
54
|
sessionEvents: sessionStats?.event_count ?? 0,
|
|
31
|
-
compactions
|
|
32
|
-
tokensSaved
|
|
55
|
+
compactions,
|
|
56
|
+
tokensSaved,
|
|
33
57
|
compressionRatio: "N/A",
|
|
34
58
|
indexedDocs: storeStats.sources,
|
|
35
59
|
indexedChunks: storeStats.chunks,
|
package/src/tools/register.ts
CHANGED
|
@@ -137,8 +137,8 @@ function jsonResult(data: unknown, label?: string): any {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
/** Log a deprecation warning when old tool names are used. */
|
|
140
|
-
function deprecationLog(
|
|
141
|
-
|
|
140
|
+
function deprecationLog(_oldName: string, _newName: string): void {
|
|
141
|
+
// Deprecation logging disabled — was writing to stdout causing TUI rendering issues.
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
// --- Old schema names for backward compat aliases ---
|
package/src/types.ts
CHANGED
|
@@ -283,11 +283,10 @@ export interface RuntimeCounters {
|
|
|
283
283
|
// ─────────────────────────────────────────────────────────
|
|
284
284
|
|
|
285
285
|
export interface CompactorInfoData {
|
|
286
|
-
sessionEvents: { value: string; detail: string };
|
|
287
|
-
compactions: { value: string; detail: string };
|
|
288
286
|
tokensSaved: { value: string; detail: string };
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
287
|
+
costSaved: { value: string; detail: string };
|
|
288
|
+
pctReduction: { value: string; detail: string };
|
|
289
|
+
topTools: { value: string; detail: string };
|
|
290
|
+
compactions: { value: string; detail: string };
|
|
291
|
+
toolCalls: { value: string; detail: string };
|
|
293
292
|
}
|