@pi-unipi/compactor 2.6.1 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/package.json +7 -7
- package/skills/compactor/SKILL.md +1 -1
- package/skills/compactor-detail/SKILL.md +3 -5
- package/skills/compactor-doctor/SKILL.md +1 -1
- package/skills/compactor-stats/SKILL.md +1 -1
- package/src/commands/index.ts +47 -78
- package/src/compaction/brief.ts +161 -90
- package/src/compaction/build-sections.ts +3 -4
- package/src/compaction/compact-args.ts +86 -0
- package/src/compaction/cut.ts +270 -28
- package/src/compaction/drill-down.ts +261 -0
- package/src/compaction/format-recall.ts +96 -0
- package/src/compaction/format.ts +8 -3
- package/src/compaction/hooks.ts +248 -72
- package/src/compaction/merge.ts +34 -4
- package/src/compaction/rank.ts +270 -0
- package/src/compaction/recall-scope.ts +28 -0
- package/src/compaction/search-entries.ts +333 -96
- package/src/compaction/skill-collapse.ts +35 -0
- package/src/compaction/summarize.ts +37 -6
- package/src/compaction/token-estimate.ts +104 -0
- package/src/compaction/touched-files.ts +35 -0
- package/src/config/manager.ts +2 -27
- package/src/config/presets.ts +0 -2
- package/src/config/schema.ts +2 -16
- package/src/executor/executor.ts +6 -15
- package/src/executor/runtime.ts +2 -12
- package/src/index.ts +12 -122
- package/src/info-screen.ts +3 -10
- package/src/security/evaluator.ts +0 -53
- package/src/security/policy.ts +7 -8
- package/src/session/db.ts +0 -6
- package/src/tools/ctx-execute-file.ts +0 -5
- package/src/tools/register.ts +27 -50
- package/src/tools/vcc-recall.ts +86 -48
- package/src/tui/settings-overlay.ts +20 -40
- package/src/types.ts +43 -100
- package/src/display/diff-renderer.ts +0 -281
- package/src/display/line-width-safety.ts +0 -28
- package/src/display/render-utils.ts +0 -52
- package/src/display/thinking-label.ts +0 -18
- package/src/display/tool-overrides.ts +0 -136
- package/src/tools/compact.ts +0 -20
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Touched-file aggregation (pi-vcc parity, mode:"touched") — files worked on
|
|
3
|
+
* with their entry indices, aggregated across the searched window.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { NormalizedBlock } from "../types.js";
|
|
7
|
+
import { extractPath } from "./extract/files.js";
|
|
8
|
+
|
|
9
|
+
/** A file touched in one block — used by mode:touched aggregation. */
|
|
10
|
+
export interface FileTouch {
|
|
11
|
+
index: number;
|
|
12
|
+
toolName: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Aggregated view of a file touched across multiple blocks. */
|
|
16
|
+
export interface TouchedFile {
|
|
17
|
+
path: string;
|
|
18
|
+
entries: FileTouch[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Aggregate file operations across tool_call blocks. */
|
|
22
|
+
export function getTouchedFiles(blocks: NormalizedBlock[]): TouchedFile[] {
|
|
23
|
+
const map = new Map<string, TouchedFile>();
|
|
24
|
+
for (const b of blocks) {
|
|
25
|
+
if (b.kind !== "tool_call") continue;
|
|
26
|
+
const path = extractPath(b.args);
|
|
27
|
+
if (!path) continue;
|
|
28
|
+
const index = b.sourceIndex ?? 0;
|
|
29
|
+
if (!map.has(path)) {
|
|
30
|
+
map.set(path, { path, entries: [] });
|
|
31
|
+
}
|
|
32
|
+
map.get(path)!.entries.push({ index, toolName: b.name });
|
|
33
|
+
}
|
|
34
|
+
return Array.from(map.values());
|
|
35
|
+
}
|
package/src/config/manager.ts
CHANGED
|
@@ -91,36 +91,11 @@ export function saveConfig(config: CompactorConfig, opts?: { perProject?: boolea
|
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
93
|
* Migrate partial config to full schema, filling missing keys from defaults.
|
|
94
|
+
* Uses deepMerge so nested strategy objects merge recursively.
|
|
94
95
|
*/
|
|
95
96
|
export function migrateConfig(partial: Partial<CompactorConfig>): CompactorConfig {
|
|
96
97
|
const defaults = structuredClone(DEFAULT_COMPACTOR_CONFIG);
|
|
97
|
-
|
|
98
|
-
function mergeStrategy<K extends keyof CompactorConfig>(
|
|
99
|
-
key: K,
|
|
100
|
-
defaultVal: CompactorConfig[K],
|
|
101
|
-
partialVal: CompactorConfig[K] | undefined,
|
|
102
|
-
): CompactorConfig[K] {
|
|
103
|
-
if (!partialVal || typeof partialVal !== "object") return defaultVal;
|
|
104
|
-
return { ...(defaultVal as any), ...(partialVal as any) };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return {
|
|
108
|
-
sessionGoals: mergeStrategy("sessionGoals", defaults.sessionGoals, partial.sessionGoals),
|
|
109
|
-
filesAndChanges: mergeStrategy("filesAndChanges", defaults.filesAndChanges, partial.filesAndChanges),
|
|
110
|
-
commits: mergeStrategy("commits", defaults.commits, partial.commits),
|
|
111
|
-
outstandingContext: mergeStrategy("outstandingContext", defaults.outstandingContext, partial.outstandingContext),
|
|
112
|
-
userPreferences: mergeStrategy("userPreferences", defaults.userPreferences, partial.userPreferences),
|
|
113
|
-
briefTranscript: mergeStrategy("briefTranscript", defaults.briefTranscript, partial.briefTranscript),
|
|
114
|
-
sessionContinuity: mergeStrategy("sessionContinuity", defaults.sessionContinuity, partial.sessionContinuity),
|
|
115
|
-
fts5Index: mergeStrategy("fts5Index", defaults.fts5Index, partial.fts5Index),
|
|
116
|
-
sandboxExecution: mergeStrategy("sandboxExecution", defaults.sandboxExecution, partial.sandboxExecution),
|
|
117
|
-
toolDisplay: mergeStrategy("toolDisplay", defaults.toolDisplay, partial.toolDisplay),
|
|
118
|
-
pipeline: mergeStrategy("pipeline", defaults.pipeline, (partial as any).pipeline) as any,
|
|
119
|
-
autoCompaction: mergeStrategy("autoCompaction", defaults.autoCompaction, partial.autoCompaction),
|
|
120
|
-
overrideDefaultCompaction: partial.overrideDefaultCompaction ?? defaults.overrideDefaultCompaction,
|
|
121
|
-
debug: partial.debug ?? defaults.debug,
|
|
122
|
-
showTruncationHints: partial.showTruncationHints ?? defaults.showTruncationHints,
|
|
123
|
-
};
|
|
98
|
+
return deepMerge(defaults, partial);
|
|
124
99
|
}
|
|
125
100
|
|
|
126
101
|
/**
|
package/src/config/presets.ts
CHANGED
|
@@ -34,8 +34,6 @@ const preset = (
|
|
|
34
34
|
// Legacy compatibility data only; presets never advertise or alter it.
|
|
35
35
|
fts5Index: { ...DEFAULT_COMPACTOR_CONFIG.fts5Index },
|
|
36
36
|
sandboxExecution: { ...DEFAULT_COMPACTOR_CONFIG.sandboxExecution, ...(overrides.sandboxExecution ?? {}) },
|
|
37
|
-
// Legacy compatibility data only; presets never advertise or alter it.
|
|
38
|
-
toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay },
|
|
39
37
|
pipeline: pipeline(overrides.pipeline),
|
|
40
38
|
autoCompaction: { ...DEFAULT_COMPACTOR_CONFIG.autoCompaction, ...(overrides.autoCompaction ?? {}) },
|
|
41
39
|
});
|
package/src/config/schema.ts
CHANGED
|
@@ -39,23 +39,8 @@ export const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = {
|
|
|
39
39
|
allowedLanguages: ["javascript", "typescript", "python", "shell"],
|
|
40
40
|
outputLimit: 100 * 1024 * 1024,
|
|
41
41
|
},
|
|
42
|
-
toolDisplay: {
|
|
43
|
-
...strategy(true, "opencode"),
|
|
44
|
-
mode: "opencode",
|
|
45
|
-
diffLayout: "auto",
|
|
46
|
-
diffIndicator: "bars",
|
|
47
|
-
showThinkingLabels: true,
|
|
48
|
-
showUserMessageBox: true,
|
|
49
|
-
showBashSpinner: true,
|
|
50
|
-
showPendingPreviews: true,
|
|
51
|
-
},
|
|
52
42
|
pipeline: {
|
|
53
|
-
ttlCache: false,
|
|
54
43
|
autoInjection: false,
|
|
55
|
-
proximityReranking: false,
|
|
56
|
-
timelineSort: false,
|
|
57
|
-
progressiveThrottling: false,
|
|
58
|
-
mmapPragma: false,
|
|
59
44
|
customNoisePatterns: [],
|
|
60
45
|
},
|
|
61
46
|
autoCompaction: {
|
|
@@ -66,6 +51,7 @@ export const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = {
|
|
|
66
51
|
notify: true,
|
|
67
52
|
},
|
|
68
53
|
overrideDefaultCompaction: true,
|
|
54
|
+
smartKeepTail: true,
|
|
55
|
+
continueAfterThresholdCompact: true,
|
|
69
56
|
debug: false,
|
|
70
|
-
showTruncationHints: true,
|
|
71
57
|
};
|
package/src/executor/executor.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* PolyglotExecutor — sandboxed code execution for 11 languages
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { spawn, execSync
|
|
5
|
+
import { spawn, execSync } from "node:child_process";
|
|
6
6
|
import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs";
|
|
7
7
|
import { join, resolve } from "node:path";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
@@ -11,19 +11,7 @@ import type { ExecResult } from "../types.js";
|
|
|
11
11
|
|
|
12
12
|
const isWin = process.platform === "win32";
|
|
13
13
|
|
|
14
|
-
const OS_TMPDIR = (()
|
|
15
|
-
if (isWin) return process.env.TEMP ?? process.env.TMP ?? tmpdir();
|
|
16
|
-
try {
|
|
17
|
-
const result = execFileSync(
|
|
18
|
-
process.platform === "darwin" ? "getconf" : "mktemp",
|
|
19
|
-
process.platform === "darwin" ? ["DARWIN_USER_TEMP_DIR"] : ["-u", "-d"],
|
|
20
|
-
{ env: { ...process.env, TMPDIR: undefined as unknown as string }, encoding: "utf-8" },
|
|
21
|
-
).trim();
|
|
22
|
-
const dir = process.platform === "darwin" ? result : resolve(result, "..");
|
|
23
|
-
if (dir && dir !== process.cwd()) return dir;
|
|
24
|
-
} catch { /* fall through */ }
|
|
25
|
-
return "/tmp";
|
|
26
|
-
})();
|
|
14
|
+
const OS_TMPDIR = isWin ? (process.env.TEMP ?? process.env.TMP ?? tmpdir()) : tmpdir();
|
|
27
15
|
|
|
28
16
|
function killTree(proc: ReturnType<typeof spawn>): void {
|
|
29
17
|
if (isWin && proc.pid) {
|
|
@@ -73,8 +61,11 @@ interface ExecuteOptions {
|
|
|
73
61
|
background?: boolean;
|
|
74
62
|
}
|
|
75
63
|
|
|
76
|
-
interface ExecuteFileOptions
|
|
64
|
+
interface ExecuteFileOptions {
|
|
65
|
+
language: Language;
|
|
77
66
|
path: string;
|
|
67
|
+
timeout?: number;
|
|
68
|
+
background?: boolean;
|
|
78
69
|
}
|
|
79
70
|
|
|
80
71
|
export class PolyglotExecutor {
|
package/src/executor/runtime.ts
CHANGED
|
@@ -2,18 +2,8 @@
|
|
|
2
2
|
* Runtime detection for sandbox executor
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
| "typescript"
|
|
8
|
-
| "python"
|
|
9
|
-
| "shell"
|
|
10
|
-
| "ruby"
|
|
11
|
-
| "go"
|
|
12
|
-
| "rust"
|
|
13
|
-
| "php"
|
|
14
|
-
| "perl"
|
|
15
|
-
| "r"
|
|
16
|
-
| "elixir";
|
|
5
|
+
import type { Language } from "../types.js";
|
|
6
|
+
export type { Language };
|
|
17
7
|
|
|
18
8
|
export interface RuntimeMap {
|
|
19
9
|
javascript: string;
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { MODULES, UNIPI_EVENTS, COMPACTOR_COMMANDS, COMPACTOR_TOOLS, COMPACTOR_INSTRUCTION, emitEvent } from "@pi-unipi/core";
|
|
6
|
+
import { MODULES, UNIPI_EVENTS, COMPACTOR_COMMANDS, COMPACTOR_TOOLS, COMPACTOR_INSTRUCTION, emitEvent, formatTokens } from "@pi-unipi/core";
|
|
7
7
|
import { scaffoldConfig, loadConfig } from "./config/manager.js";
|
|
8
8
|
import { registerCompactionHooks } from "./compaction/hooks.js";
|
|
9
9
|
import {
|
|
@@ -22,45 +22,13 @@ import { registerCompactorTools } from "./tools/register.js";
|
|
|
22
22
|
import { normalizeMessages } from "./compaction/normalize.js";
|
|
23
23
|
import { filterNoise } from "./compaction/filter-noise.js";
|
|
24
24
|
import { recallBlocksFromContext } from "./session/recall-blocks.js";
|
|
25
|
-
import type { NormalizedBlock, CompactorStrategyConfig, RuntimeCounters
|
|
25
|
+
import type { NormalizedBlock, CompactorStrategyConfig, RuntimeCounters } from "./types.js";
|
|
26
26
|
|
|
27
|
-
/** Debug logger — only logs when config.debug === true */
|
|
28
|
-
function createDebugLogger(getConfig: () => { debug: boolean }) {
|
|
29
|
-
return (_event: string, _data?: Record<string, unknown>) => {
|
|
30
|
-
// Debug logging disabled — was writing to stdout causing TUI rendering issues.
|
|
31
|
-
return;
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
27
|
|
|
35
|
-
const formatTokenCount = (n: number): string => {
|
|
36
|
-
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
37
|
-
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
38
|
-
return String(n);
|
|
39
|
-
};
|
|
40
28
|
|
|
41
29
|
/** Measure byte size of a tool_result event's response content. */
|
|
42
|
-
function measureResponseBytes(event: any): number {
|
|
43
|
-
try {
|
|
44
|
-
const content = event.content;
|
|
45
|
-
if (typeof content === "string") return Buffer.byteLength(content, "utf-8");
|
|
46
|
-
if (Array.isArray(content)) {
|
|
47
|
-
return content.reduce((sum: number, block: any) => {
|
|
48
|
-
if (typeof block?.text === "string") return sum + Buffer.byteLength(block.text, "utf-8");
|
|
49
|
-
if (typeof block === "string") return sum + Buffer.byteLength(block, "utf-8");
|
|
50
|
-
return sum;
|
|
51
|
-
}, 0);
|
|
52
|
-
}
|
|
53
|
-
if (event.output && typeof event.output === "string") return Buffer.byteLength(event.output, "utf-8");
|
|
54
|
-
} catch {
|
|
55
|
-
// Non-blocking: byte measurement errors silently skipped
|
|
56
|
-
}
|
|
57
|
-
return 0;
|
|
58
|
-
}
|
|
59
30
|
|
|
60
31
|
/** Check if a tool is a sandbox tool (output stays in sandbox, not context). */
|
|
61
|
-
function isSandboxTool(name: string): boolean {
|
|
62
|
-
return name === "bash" || name === "Bash";
|
|
63
|
-
}
|
|
64
32
|
|
|
65
33
|
export default function compactorExtension(pi: ExtensionAPI): void {
|
|
66
34
|
let sessionDB: SessionDB | null = null;
|
|
@@ -78,17 +46,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
78
46
|
};
|
|
79
47
|
const getCounters = () => counters;
|
|
80
48
|
|
|
81
|
-
const runtimeStats: RuntimeStats = {
|
|
82
|
-
bytesReturned: {},
|
|
83
|
-
bytesSandboxed: 0,
|
|
84
|
-
calls: {},
|
|
85
|
-
sessionStart: Date.now(),
|
|
86
|
-
cacheHits: 0,
|
|
87
|
-
cacheBytesSaved: 0,
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
const debug = createDebugLogger(() => config);
|
|
91
|
-
|
|
92
49
|
const init = async () => {
|
|
93
50
|
scaffoldConfig();
|
|
94
51
|
config = loadConfig();
|
|
@@ -134,8 +91,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
134
91
|
const fullSessionId = `${sessionId}${suffix}`;
|
|
135
92
|
currentSessionId = fullSessionId;
|
|
136
93
|
|
|
137
|
-
debug("session_start", { sessionId: fullSessionId, projectDir });
|
|
138
|
-
|
|
139
94
|
// Seed runtime counters from DB so they reflect prior usage
|
|
140
95
|
if (sessionDB) {
|
|
141
96
|
try {
|
|
@@ -148,12 +103,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
148
103
|
}
|
|
149
104
|
|
|
150
105
|
// Reset runtime stats for new session
|
|
151
|
-
runtimeStats.bytesReturned = {};
|
|
152
|
-
runtimeStats.bytesSandboxed = 0;
|
|
153
|
-
runtimeStats.calls = {};
|
|
154
|
-
runtimeStats.sessionStart = Date.now();
|
|
155
|
-
runtimeStats.cacheHits = 0;
|
|
156
|
-
runtimeStats.cacheBytesSaved = 0;
|
|
157
106
|
autoCompactionState = createAutoCompactionState();
|
|
158
107
|
|
|
159
108
|
sessionDB?.ensureSession(fullSessionId, projectDir);
|
|
@@ -226,8 +175,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
226
175
|
tools: Object.values(COMPACTOR_TOOLS),
|
|
227
176
|
});
|
|
228
177
|
|
|
229
|
-
debug("MODULE_READY", { commands: Object.values(COMPACTOR_COMMANDS), tools: Object.values(COMPACTOR_TOOLS) });
|
|
230
|
-
|
|
231
178
|
ctx.ui.notify("🗜️ Compactor ready", "info");
|
|
232
179
|
});
|
|
233
180
|
|
|
@@ -235,7 +182,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
235
182
|
const cwd = (ctx as any).cwd ?? process.cwd();
|
|
236
183
|
config = loadConfig(cwd);
|
|
237
184
|
currentSessionId = `${(ctx as any).sessionId ?? "default"}${getWorktreeSuffix()}`;
|
|
238
|
-
debug("before_agent_start", { sessionId: currentSessionId, configDebug: config.debug });
|
|
239
185
|
|
|
240
186
|
// Evaluate autoDetect conditions for strategies
|
|
241
187
|
try {
|
|
@@ -248,7 +194,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
248
194
|
if ((strat as any).autoDetect === "git") {
|
|
249
195
|
const gitDir = join(cwd, ".git");
|
|
250
196
|
if (!existsSync(gitDir)) {
|
|
251
|
-
debug("autoDetect_disable", { strategy: key, reason: "no .git dir" });
|
|
252
197
|
// Non-destructive: temporarily disable at runtime, don't modify config file
|
|
253
198
|
strat.enabled = false;
|
|
254
199
|
}
|
|
@@ -280,12 +225,18 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
280
225
|
const continuityEnabled = isSessionContinuityEnabled(config);
|
|
281
226
|
if (sessionDB && continuityEnabled) {
|
|
282
227
|
const resumeContext = await buildResumeContextMessage(sessionDB, currentSessionId);
|
|
283
|
-
debug("resume_snapshot", { injected: !!resumeContext });
|
|
284
228
|
return resumeContext;
|
|
285
229
|
}
|
|
286
230
|
});
|
|
287
231
|
|
|
288
|
-
|
|
232
|
+
// Trigger percentage auto-compaction at agent_end, NOT turn_end.
|
|
233
|
+
// Pi's ctx.compact() aborts the active agent operation first
|
|
234
|
+
// (AgentSession.compact() -> await this.abort()). turn_end fires between
|
|
235
|
+
// turns of a still-running agent loop, so compacting there kills the next
|
|
236
|
+
// in-flight provider request ("This operation was aborted" stopReason=error
|
|
237
|
+
// turns). agent_end fires only after the run has fully settled, matching
|
|
238
|
+
// Pi core's own native auto-compaction check point ("checked on agent_end").
|
|
239
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
289
240
|
const cwd = (ctx as any).cwd ?? process.cwd();
|
|
290
241
|
config = loadConfig(cwd);
|
|
291
242
|
|
|
@@ -297,23 +248,12 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
297
248
|
});
|
|
298
249
|
autoCompactionState = decision.state;
|
|
299
250
|
|
|
300
|
-
debug("auto_compaction_decision", {
|
|
301
|
-
enabled: config.autoCompaction.enabled,
|
|
302
|
-
reason: decision.reason,
|
|
303
|
-
shouldTrigger: decision.shouldTrigger,
|
|
304
|
-
percent: decision.usage?.percent,
|
|
305
|
-
tokens: decision.usage?.tokens,
|
|
306
|
-
thresholdPercent: decision.thresholdPercent,
|
|
307
|
-
cooldownRemainingMs: decision.cooldownRemainingMs,
|
|
308
|
-
tokenGrowth: decision.tokenGrowth,
|
|
309
|
-
});
|
|
310
|
-
|
|
311
251
|
if (!decision.shouldTrigger) return;
|
|
312
252
|
|
|
313
253
|
const notify = config.autoCompaction.notify;
|
|
314
254
|
if (notify && decision.usage) {
|
|
315
255
|
ctx.ui.notify(
|
|
316
|
-
`Auto-compacting at ${decision.usage.percent.toFixed(1)}% context (~${
|
|
256
|
+
`Auto-compacting at ${decision.usage.percent.toFixed(1)}% context (~${formatTokens(decision.usage.tokens)} tokens; threshold ${decision.thresholdPercent}%).`,
|
|
317
257
|
"info",
|
|
318
258
|
);
|
|
319
259
|
}
|
|
@@ -351,7 +291,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
351
291
|
const sessionId = currentSessionId;
|
|
352
292
|
const events = sessionDB.getEvents(sessionId, { limit: 1000 });
|
|
353
293
|
const stats = sessionDB.getSessionStats(sessionId);
|
|
354
|
-
debug("session_before_compact", { sessionId, eventCount: events.length, compactCount: stats?.compact_count ?? 0 });
|
|
355
294
|
const { buildResumeSnapshot } = await import("./session/snapshot.js");
|
|
356
295
|
const snapshot = buildResumeSnapshot(events, {
|
|
357
296
|
compactCount: stats?.compact_count ?? 1,
|
|
@@ -419,12 +358,10 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
419
358
|
tokensSaved,
|
|
420
359
|
compressionRatio: kept > 0 ? `${Math.round(totalEstimated / kept)}:1` : "0:1",
|
|
421
360
|
});
|
|
422
|
-
debug("session_compact", { sessionId, tokensBefore, hasCompactionEntry: !!compactionEntry });
|
|
423
361
|
}
|
|
424
362
|
});
|
|
425
363
|
|
|
426
364
|
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
427
|
-
debug("session_shutdown");
|
|
428
365
|
if (sessionDB) {
|
|
429
366
|
sessionDB.cleanupOldSessions(7);
|
|
430
367
|
}
|
|
@@ -435,7 +372,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
435
372
|
pi.on("input", async (event, _ctx) => {
|
|
436
373
|
const toolName = (event as any).toolName ?? "";
|
|
437
374
|
const args = (event as any).args ?? {};
|
|
438
|
-
debug("input", { toolName, args: JSON.stringify(args).slice(0, 200) });
|
|
439
375
|
|
|
440
376
|
// Existing network tool guard
|
|
441
377
|
if (toolName === "bash" || toolName === "Bash") {
|
|
@@ -447,7 +383,7 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
447
383
|
|
|
448
384
|
// Security scanner/evaluator wiring (fail-open pattern)
|
|
449
385
|
try {
|
|
450
|
-
const { evaluateCommand, evaluateFilePath
|
|
386
|
+
const { evaluateCommand, evaluateFilePath } = await import("./security/evaluator.js");
|
|
451
387
|
const { hasShellEscapes, scanForShellEscapes } = await import("./security/scanner.js");
|
|
452
388
|
const { readsOrCreatesPolicy } = await import("./security/policy.js");
|
|
453
389
|
|
|
@@ -461,7 +397,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
461
397
|
if (cmd) {
|
|
462
398
|
const decision = evaluateCommand(cmd, denyPolicy);
|
|
463
399
|
if (decision === "deny") {
|
|
464
|
-
debug("security_deny", { toolName, cmd: cmd.slice(0, 100) });
|
|
465
400
|
return {
|
|
466
401
|
content: [{ type: "text", text: `Command blocked by security policy: ${cmd.slice(0, 80)}` }],
|
|
467
402
|
isError: true,
|
|
@@ -478,7 +413,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
478
413
|
if (language && language !== "shell" && code) {
|
|
479
414
|
if (hasShellEscapes(code, language)) {
|
|
480
415
|
const findings = scanForShellEscapes(code, language);
|
|
481
|
-
debug("security_shell_escapes", { toolName, language, findings });
|
|
482
416
|
// Fail-open: log but don't block (the hooks system is enforcement)
|
|
483
417
|
}
|
|
484
418
|
}
|
|
@@ -491,14 +425,12 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
491
425
|
if (filePath) {
|
|
492
426
|
const decision = evaluateFilePath(filePath, denyPolicy, cwd);
|
|
493
427
|
if (decision === "deny") {
|
|
494
|
-
debug("security_deny_file", { toolName, filePath });
|
|
495
428
|
// Non-fatal: log warning but allow through (fail-open)
|
|
496
429
|
}
|
|
497
430
|
}
|
|
498
431
|
}
|
|
499
432
|
} catch (err) {
|
|
500
433
|
// Fail-open: security checks are advisory, never block on errors
|
|
501
|
-
debug("security_check_error", { error: String(err) });
|
|
502
434
|
}
|
|
503
435
|
|
|
504
436
|
return undefined;
|
|
@@ -511,8 +443,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
511
443
|
const toolNameRaw = (event as any).toolName ?? "";
|
|
512
444
|
const isError = (event as any).isError ?? false;
|
|
513
445
|
|
|
514
|
-
debug("tool_result", { toolName: toolNameRaw, isError, sessionId });
|
|
515
|
-
|
|
516
446
|
// Extract and store session events
|
|
517
447
|
const toolEvents = extractEventsFromToolResult({
|
|
518
448
|
toolName: (event as any).toolName ?? "",
|
|
@@ -523,22 +453,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
523
453
|
|
|
524
454
|
for (const ev of toolEvents) {
|
|
525
455
|
sessionDB.insertEvent(sessionId, ev, "PostToolUse");
|
|
526
|
-
debug("event_stored", { category: ev.category, type: ev.type });
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// Track byte consumption per tool for analytics
|
|
530
|
-
try {
|
|
531
|
-
const responseBytes = measureResponseBytes(event);
|
|
532
|
-
if (responseBytes > 0) {
|
|
533
|
-
const tName = (event as any).toolName ?? "unknown";
|
|
534
|
-
runtimeStats.calls[tName] = (runtimeStats.calls[tName] || 0) + 1;
|
|
535
|
-
runtimeStats.bytesReturned[tName] = (runtimeStats.bytesReturned[tName] || 0) + responseBytes;
|
|
536
|
-
if (isSandboxTool(tName)) {
|
|
537
|
-
runtimeStats.bytesSandboxed += responseBytes;
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
} catch {
|
|
541
|
-
// Non-blocking: byte tracking errors silently skipped
|
|
542
456
|
}
|
|
543
457
|
|
|
544
458
|
const toolName = (event as any).toolName ?? "";
|
|
@@ -558,36 +472,12 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
558
472
|
);
|
|
559
473
|
const clamped = clampDiffToWidth(details.diff);
|
|
560
474
|
if (clamped !== details.diff) {
|
|
561
|
-
debug("diff_width_clamped", { toolName });
|
|
562
475
|
return { details: { ...details, diff: clamped } } as any;
|
|
563
476
|
}
|
|
564
477
|
}
|
|
565
478
|
} catch (err) {
|
|
566
|
-
debug("diff_width_clamp_error", { error: String(err) });
|
|
567
479
|
}
|
|
568
480
|
}
|
|
569
481
|
});
|
|
570
482
|
|
|
571
|
-
pi.on("message_update", async (event, _ctx) => {
|
|
572
|
-
const msg = (event as any).message;
|
|
573
|
-
if (msg?.thinking) {
|
|
574
|
-
debug("message_update", { thinking: true, length: String(msg.thinking).length });
|
|
575
|
-
}
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
pi.on("message_end", async (_event, _ctx) => {
|
|
579
|
-
debug("message_end");
|
|
580
|
-
});
|
|
581
|
-
|
|
582
|
-
pi.on("context", async (event, _ctx) => {
|
|
583
|
-
const { sanitizeThinkingArtifacts } = await import("./display/thinking-label.js");
|
|
584
|
-
const ctxStr = (event as any).context;
|
|
585
|
-
if (typeof ctxStr === "string") {
|
|
586
|
-
const sanitized = sanitizeThinkingArtifacts(ctxStr);
|
|
587
|
-
if (sanitized !== ctxStr) {
|
|
588
|
-
debug("context", { sanitized: true, beforeLen: ctxStr.length, afterLen: sanitized.length });
|
|
589
|
-
}
|
|
590
|
-
(event as any).context = sanitized;
|
|
591
|
-
}
|
|
592
|
-
});
|
|
593
483
|
}
|
package/src/info-screen.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatTokens } from "@pi-unipi/core";
|
|
1
2
|
/**
|
|
2
3
|
* Info-screen integration for @pi-unipi/compactor
|
|
3
4
|
*
|
|
@@ -11,7 +12,7 @@
|
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import type { SessionDB } from "./session/db.js";
|
|
14
|
-
import { getLastCompactionStats } from "./compaction/hooks.js";
|
|
15
|
+
import { getLastCompactionStats, formatCompactionStats } from "./compaction/hooks.js";
|
|
15
16
|
import { parseUsageStatsAsync } from "@pi-unipi/info-screen/usage-parser.js";
|
|
16
17
|
import type { RuntimeCounters } from "./types.js";
|
|
17
18
|
|
|
@@ -25,14 +26,6 @@ export interface CompactorInfoData {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/** Format token count for display (e.g., "12.4k", "1.2M"). */
|
|
28
|
-
function formatTokens(n: number): string {
|
|
29
|
-
if (n < 1000) return String(n);
|
|
30
|
-
if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
|
|
31
|
-
if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
|
|
32
|
-
if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
33
|
-
return `${Math.round(n / 1_000_000)}M`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
29
|
/** Format cost for display (e.g., "$0.34", "<$0.01"). */
|
|
37
30
|
function formatCost(n: number): string {
|
|
38
31
|
if (n === 0) return "$0.00";
|
|
@@ -184,7 +177,7 @@ export async function getInfoScreenData(
|
|
|
184
177
|
compactions: {
|
|
185
178
|
value: String(compactionCount),
|
|
186
179
|
detail: compactStats
|
|
187
|
-
? `Last: ${
|
|
180
|
+
? `Last: ${formatCompactionStats(compactStats)}`
|
|
188
181
|
: compactionCount > 0
|
|
189
182
|
? `${compactionCount} compaction(s) across all sessions`
|
|
190
183
|
: "No compactions yet",
|
|
@@ -14,28 +14,6 @@ import { parseBashPattern, parseToolPattern, globToRegex, fileGlobToRegex } from
|
|
|
14
14
|
* Load permission patterns from .pi/settings.json in the given directory.
|
|
15
15
|
* Returns a SecurityPolicy merged with the provided policy.
|
|
16
16
|
*/
|
|
17
|
-
export function loadProjectPermissions(
|
|
18
|
-
cwd: string,
|
|
19
|
-
basePolicy: SecurityPolicy,
|
|
20
|
-
): SecurityPolicy {
|
|
21
|
-
const settingsPath = join(cwd, ".pi", "settings.json");
|
|
22
|
-
if (!existsSync(settingsPath)) return basePolicy;
|
|
23
|
-
|
|
24
|
-
try {
|
|
25
|
-
const raw = readFileSync(settingsPath, "utf-8");
|
|
26
|
-
const settings = JSON.parse(raw);
|
|
27
|
-
const permissions = settings.permissions ?? settings.security ?? {};
|
|
28
|
-
|
|
29
|
-
return {
|
|
30
|
-
deny: [...basePolicy.deny, ...(permissions.deny ?? [])],
|
|
31
|
-
ask: [...basePolicy.ask, ...(permissions.ask ?? [])],
|
|
32
|
-
allow: [...basePolicy.allow, ...(permissions.allow ?? [])],
|
|
33
|
-
};
|
|
34
|
-
} catch {
|
|
35
|
-
return basePolicy;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
17
|
export function evaluateCommand(
|
|
40
18
|
command: string,
|
|
41
19
|
policy: SecurityPolicy,
|
|
@@ -73,37 +51,6 @@ export function evaluateCommand(
|
|
|
73
51
|
return "allow";
|
|
74
52
|
}
|
|
75
53
|
|
|
76
|
-
export function splitChainedCommands(command: string): string[] {
|
|
77
|
-
const commands: string[] = [];
|
|
78
|
-
let current = "";
|
|
79
|
-
let inQuotes = false;
|
|
80
|
-
let quoteChar = "";
|
|
81
|
-
|
|
82
|
-
for (let i = 0; i < command.length; i++) {
|
|
83
|
-
const char = command[i];
|
|
84
|
-
|
|
85
|
-
if (!inQuotes && (char === '"' || char === "'" || char === "`")) {
|
|
86
|
-
inQuotes = true;
|
|
87
|
-
quoteChar = char;
|
|
88
|
-
current += char;
|
|
89
|
-
} else if (inQuotes && char === quoteChar) {
|
|
90
|
-
inQuotes = false;
|
|
91
|
-
quoteChar = "";
|
|
92
|
-
current += char;
|
|
93
|
-
} else if (!inQuotes && (char === "&" || char === "|" || char === ";")) {
|
|
94
|
-
if (current.trim()) commands.push(current.trim());
|
|
95
|
-
current = "";
|
|
96
|
-
// Skip the next char if it's part of && or ||
|
|
97
|
-
if ((char === "&" || char === "|") && command[i + 1] === char) i++;
|
|
98
|
-
} else {
|
|
99
|
-
current += char;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (current.trim()) commands.push(current.trim());
|
|
104
|
-
return commands;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
54
|
export function evaluateFilePath(
|
|
108
55
|
filePath: string,
|
|
109
56
|
policy: SecurityPolicy,
|
package/src/security/policy.ts
CHANGED
|
@@ -23,14 +23,13 @@ export function parseToolPattern(pattern: string): { tool: string; glob: string
|
|
|
23
23
|
return match ? { tool: match[1], glob: match[2] } : null;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function escapeRegex(str: string): string {
|
|
27
|
-
return str.replace(/[.*+?^${}()|[\]\\/\-]/g, "\\$&");
|
|
28
|
-
}
|
|
29
|
-
|
|
30
26
|
function convertGlobPart(glob: string): string {
|
|
27
|
+
// Replace wildcards FIRST, then escape the literal remainder — escaping first
|
|
28
|
+
// would turn `*` into `\*` and the wildcard replace would never fire.
|
|
31
29
|
return glob
|
|
32
|
-
.
|
|
33
|
-
.
|
|
30
|
+
.split("*")
|
|
31
|
+
.map((part) => RegExp.escape(part))
|
|
32
|
+
.join(".*");
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
export function globToRegex(glob: string, caseInsensitive: boolean = false): RegExp {
|
|
@@ -39,7 +38,7 @@ export function globToRegex(glob: string, caseInsensitive: boolean = false): Reg
|
|
|
39
38
|
if (colonIdx !== -1) {
|
|
40
39
|
const command = glob.slice(0, colonIdx);
|
|
41
40
|
const argsGlob = glob.slice(colonIdx + 1);
|
|
42
|
-
const escapedCmd =
|
|
41
|
+
const escapedCmd = RegExp.escape(command);
|
|
43
42
|
const argsRegex = convertGlobPart(argsGlob);
|
|
44
43
|
regexStr = `^${escapedCmd}(\\s${argsRegex})?$`;
|
|
45
44
|
} else {
|
|
@@ -68,7 +67,7 @@ export function fileGlobToRegex(glob: string, caseInsensitive: boolean = false):
|
|
|
68
67
|
regexStr += "[^/]";
|
|
69
68
|
i++;
|
|
70
69
|
} else {
|
|
71
|
-
regexStr +=
|
|
70
|
+
regexStr += RegExp.escape(glob[i]);
|
|
72
71
|
i++;
|
|
73
72
|
}
|
|
74
73
|
}
|
package/src/session/db.ts
CHANGED
|
@@ -191,7 +191,6 @@ export class SessionDB {
|
|
|
191
191
|
p("addCompactionStats", `UPDATE session_meta SET total_chars_before = total_chars_before + ?, total_chars_kept = total_chars_kept + ?, total_messages_summarized = total_messages_summarized + ? WHERE session_id = ?`);
|
|
192
192
|
p("getAllTimeStats", `SELECT COALESCE(SUM(total_chars_before), 0) AS all_chars_before, COALESCE(SUM(total_chars_kept), 0) AS all_chars_kept, COALESCE(SUM(total_messages_summarized), 0) AS all_messages_summarized, COALESCE(SUM(compact_count), 0) AS all_compactions, COALESCE(SUM(sandbox_runs), 0) AS all_sandbox_runs, COALESCE(SUM(search_queries), 0) AS all_search_queries FROM session_meta`);
|
|
193
193
|
p("incrementSandboxRuns", `UPDATE session_meta SET sandbox_runs = sandbox_runs + 1 WHERE session_id = ?`);
|
|
194
|
-
p("incrementSearchQueries", `UPDATE session_meta SET search_queries = search_queries + 1 WHERE session_id = ?`);
|
|
195
194
|
p("upsertResume", `INSERT INTO session_resume (session_id, snapshot, event_count) VALUES (?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET snapshot = excluded.snapshot, event_count = excluded.event_count, created_at = datetime('now'), consumed = 0`);
|
|
196
195
|
p("getResume", `SELECT snapshot, event_count, consumed FROM session_resume WHERE session_id = ?`);
|
|
197
196
|
p("markResumeConsumed", `UPDATE session_resume SET consumed = 1 WHERE session_id = ?`);
|
|
@@ -283,11 +282,6 @@ export class SessionDB {
|
|
|
283
282
|
this.stmt("incrementSandboxRuns").run(sessionId);
|
|
284
283
|
}
|
|
285
284
|
|
|
286
|
-
incrementSearchQueries(sessionId: string): void {
|
|
287
|
-
if (!this.stmts) return;
|
|
288
|
-
this.stmt("incrementSearchQueries").run(sessionId);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
285
|
upsertResume(sessionId: string, snapshot: string, eventCount?: number): void {
|
|
292
286
|
if (!this.stmts) return;
|
|
293
287
|
this.stmt("upsertResume").run(sessionId, snapshot, eventCount ?? 0);
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
import { PolyglotExecutor } from "../executor/executor.js";
|
|
6
6
|
import type { Language, ExecResult } from "../types.js";
|
|
7
|
-
import { readFileSync } from "node:fs";
|
|
8
7
|
|
|
9
8
|
export interface CtxExecuteFileInput {
|
|
10
9
|
language: Language;
|
|
@@ -16,13 +15,9 @@ export async function ctxExecuteFile(
|
|
|
16
15
|
input: CtxExecuteFileInput,
|
|
17
16
|
executor = new PolyglotExecutor(),
|
|
18
17
|
): Promise<ExecResult> {
|
|
19
|
-
const content = readFileSync(input.path, "utf-8");
|
|
20
|
-
const code = `const FILE_CONTENT = ${JSON.stringify(content)};\n// User script follows:\n`;
|
|
21
|
-
|
|
22
18
|
return executor.executeFile({
|
|
23
19
|
language: input.language,
|
|
24
20
|
path: input.path,
|
|
25
|
-
code,
|
|
26
21
|
timeout: input.timeout ?? 30000,
|
|
27
22
|
});
|
|
28
23
|
}
|