@pi-unipi/compactor 0.1.7 → 0.2.1
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 +50 -24
- package/index.ts +7 -0
- package/package.json +2 -1
- package/skills/compactor/SKILL.md +21 -65
- package/skills/compactor-detail/SKILL.md +133 -0
- package/src/commands/index.ts +186 -109
- package/src/compaction/filter-noise.ts +4 -3
- package/src/compaction/hooks.ts +22 -1
- package/src/compaction/search-entries.ts +51 -4
- package/src/config/manager.ts +55 -6
- package/src/config/presets.ts +69 -5
- package/src/config/schema.ts +9 -0
- package/src/index.ts +183 -10
- package/src/info-screen.ts +10 -4
- package/src/security/policy.ts +23 -0
- package/src/session/auto-inject.ts +60 -0
- package/src/session/db.ts +65 -8
- package/src/session/resume-inject.ts +13 -1
- package/src/store/db-base.ts +11 -0
- package/src/store/index.ts +150 -4
- package/src/store/unified.ts +109 -0
- package/src/tools/context-budget.ts +50 -0
- package/src/tools/ctx-batch-execute.ts +2 -5
- package/src/tools/ctx-fetch-and-index.ts +3 -8
- package/src/tools/ctx-index.ts +3 -9
- package/src/tools/ctx-search.ts +3 -7
- package/src/tools/ctx-stats.ts +6 -4
- package/src/tools/register.ts +251 -216
- package/src/tui/settings-overlay.ts +359 -149
- package/src/types.ts +25 -1
- package/skills/compactor-ops/SKILL.md +0 -65
- package/skills/compactor-tools/SKILL.md +0 -120
package/src/config/presets.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Preset definitions + detection for compactor config
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
5
6
|
import type { CompactorConfig, CompactorPreset } from "../types.js";
|
|
6
7
|
import { DEFAULT_COMPACTOR_CONFIG } from "./schema.js";
|
|
7
8
|
|
|
@@ -22,15 +23,43 @@ const preset = (
|
|
|
22
23
|
toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, ...(overrides.toolDisplay as any) },
|
|
23
24
|
});
|
|
24
25
|
|
|
26
|
+
// Pipeline feature defaults per preset:
|
|
27
|
+
// precise: ttlCache+mmap on, rest off
|
|
28
|
+
// balanced: all on
|
|
29
|
+
// thorough: all on
|
|
30
|
+
// lean: all off
|
|
31
|
+
|
|
25
32
|
export const PRESET_CONFIGS: Record<CompactorPreset, CompactorConfig> = {
|
|
26
|
-
|
|
33
|
+
// New preset names
|
|
34
|
+
precise: preset({
|
|
27
35
|
toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, mode: "opencode" },
|
|
28
36
|
}),
|
|
37
|
+
thorough: preset({
|
|
38
|
+
briefTranscript: { ...DEFAULT_COMPACTOR_CONFIG.briefTranscript, mode: "full" },
|
|
39
|
+
toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, mode: "verbose" },
|
|
40
|
+
}),
|
|
41
|
+
lean: preset({
|
|
42
|
+
sessionGoals: { ...DEFAULT_COMPACTOR_CONFIG.sessionGoals, enabled: true, mode: "brief" },
|
|
43
|
+
filesAndChanges: { ...DEFAULT_COMPACTOR_CONFIG.filesAndChanges, enabled: true, mode: "modified-only" },
|
|
44
|
+
commits: { ...DEFAULT_COMPACTOR_CONFIG.commits, enabled: false, mode: "off" },
|
|
45
|
+
outstandingContext: { ...DEFAULT_COMPACTOR_CONFIG.outstandingContext, enabled: true, mode: "critical-only" },
|
|
46
|
+
userPreferences: { ...DEFAULT_COMPACTOR_CONFIG.userPreferences, enabled: false, mode: "off" },
|
|
47
|
+
briefTranscript: { ...DEFAULT_COMPACTOR_CONFIG.briefTranscript, enabled: true, mode: "minimal" },
|
|
48
|
+
sessionContinuity: { ...DEFAULT_COMPACTOR_CONFIG.sessionContinuity, enabled: false, mode: "off" },
|
|
49
|
+
fts5Index: { ...DEFAULT_COMPACTOR_CONFIG.fts5Index, enabled: false, mode: "off" },
|
|
50
|
+
sandboxExecution: { ...DEFAULT_COMPACTOR_CONFIG.sandboxExecution, enabled: false, mode: "off" },
|
|
51
|
+
toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, enabled: true, mode: "opencode" },
|
|
52
|
+
}),
|
|
29
53
|
balanced: preset({
|
|
30
54
|
briefTranscript: { ...DEFAULT_COMPACTOR_CONFIG.briefTranscript, mode: "compact" },
|
|
31
55
|
toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, mode: "balanced" },
|
|
32
56
|
fts5Index: { ...DEFAULT_COMPACTOR_CONFIG.fts5Index, mode: "auto" },
|
|
33
57
|
}),
|
|
58
|
+
|
|
59
|
+
// Backward-compat aliases — map old names to new
|
|
60
|
+
opencode: preset({
|
|
61
|
+
toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, mode: "opencode" },
|
|
62
|
+
}),
|
|
34
63
|
verbose: preset({
|
|
35
64
|
briefTranscript: { ...DEFAULT_COMPACTOR_CONFIG.briefTranscript, mode: "full" },
|
|
36
65
|
toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, mode: "verbose" },
|
|
@@ -50,7 +79,24 @@ export const PRESET_CONFIGS: Record<CompactorPreset, CompactorConfig> = {
|
|
|
50
79
|
custom: structuredClone(DEFAULT_COMPACTOR_CONFIG),
|
|
51
80
|
};
|
|
52
81
|
|
|
82
|
+
// Pre-computed identity hashes for fast preset detection
|
|
83
|
+
const presetHashes = new Map<string, string>();
|
|
84
|
+
|
|
85
|
+
function presetHash(config: CompactorConfig): string {
|
|
86
|
+
return createHash("sha256").update(JSON.stringify(config)).digest("hex");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Compute hashes once at module load
|
|
90
|
+
for (const name of ["precise", "balanced", "thorough", "lean"] as const) {
|
|
91
|
+
presetHashes.set(name, presetHash(PRESET_CONFIGS[name]));
|
|
92
|
+
}
|
|
93
|
+
|
|
53
94
|
function configsEqual(a: CompactorConfig, b: CompactorConfig): boolean {
|
|
95
|
+
// Fast path: hash comparison
|
|
96
|
+
const aHash = presetHash(a);
|
|
97
|
+
const bHash = presetHash(b);
|
|
98
|
+
if (aHash !== bHash) return false;
|
|
99
|
+
// Defensive: confirm with full comparison
|
|
54
100
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
55
101
|
}
|
|
56
102
|
|
|
@@ -58,8 +104,11 @@ function configsEqual(a: CompactorConfig, b: CompactorConfig): boolean {
|
|
|
58
104
|
* Detect which preset a config matches, or "custom".
|
|
59
105
|
*/
|
|
60
106
|
export function detectPreset(config: CompactorConfig): CompactorPreset {
|
|
61
|
-
|
|
62
|
-
|
|
107
|
+
const configHash = presetHash(config);
|
|
108
|
+
for (const name of ["precise", "balanced", "thorough", "lean"] as const) {
|
|
109
|
+
if (presetHashes.get(name) === configHash && configsEqual(config, PRESET_CONFIGS[name])) {
|
|
110
|
+
return name;
|
|
111
|
+
}
|
|
63
112
|
}
|
|
64
113
|
return "custom";
|
|
65
114
|
}
|
|
@@ -71,13 +120,28 @@ export function applyPreset(name: CompactorPreset): CompactorConfig {
|
|
|
71
120
|
return structuredClone(PRESET_CONFIGS[name]);
|
|
72
121
|
}
|
|
73
122
|
|
|
123
|
+
// Old → new preset name mapping for backward compatibility
|
|
124
|
+
const OLD_TO_NEW: Record<string, CompactorPreset> = {
|
|
125
|
+
opencode: "precise",
|
|
126
|
+
verbose: "thorough",
|
|
127
|
+
minimal: "lean",
|
|
128
|
+
};
|
|
129
|
+
|
|
74
130
|
/**
|
|
75
|
-
* Parse a preset name (case-insensitive).
|
|
131
|
+
* Parse a preset name (case-insensitive). Old names are mapped to new with deprecation.
|
|
76
132
|
*/
|
|
77
133
|
export function parsePreset(raw: string): CompactorPreset | undefined {
|
|
78
134
|
const normalized = raw.trim().toLowerCase();
|
|
79
|
-
|
|
135
|
+
|
|
136
|
+
// Check new names first
|
|
137
|
+
if (normalized === "precise" || normalized === "balanced" || normalized === "thorough" || normalized === "lean" || normalized === "custom") {
|
|
80
138
|
return normalized;
|
|
81
139
|
}
|
|
140
|
+
|
|
141
|
+
// Map old names to new (backward compat)
|
|
142
|
+
if (OLD_TO_NEW[normalized]) {
|
|
143
|
+
return OLD_TO_NEW[normalized];
|
|
144
|
+
}
|
|
145
|
+
|
|
82
146
|
return undefined;
|
|
83
147
|
}
|
package/src/config/schema.ts
CHANGED
|
@@ -49,6 +49,15 @@ export const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = {
|
|
|
49
49
|
showBashSpinner: true,
|
|
50
50
|
showPendingPreviews: true,
|
|
51
51
|
},
|
|
52
|
+
pipeline: {
|
|
53
|
+
ttlCache: false,
|
|
54
|
+
autoInjection: false,
|
|
55
|
+
proximityReranking: false,
|
|
56
|
+
timelineSort: false,
|
|
57
|
+
progressiveThrottling: false,
|
|
58
|
+
mmapPragma: false,
|
|
59
|
+
customNoisePatterns: [],
|
|
60
|
+
},
|
|
52
61
|
overrideDefaultCompaction: false,
|
|
53
62
|
debug: false,
|
|
54
63
|
showTruncationHints: true,
|
package/src/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { registerCommands } from "./commands/index.js";
|
|
|
15
15
|
import { registerCompactorTools } from "./tools/register.js";
|
|
16
16
|
import { normalizeMessages } from "./compaction/normalize.js";
|
|
17
17
|
import { filterNoise } from "./compaction/filter-noise.js";
|
|
18
|
-
import type { NormalizedBlock } from "./types.js";
|
|
18
|
+
import type { NormalizedBlock, CompactorStrategyConfig, RuntimeCounters } from "./types.js";
|
|
19
19
|
|
|
20
20
|
/** Debug logger — only logs when config.debug === true */
|
|
21
21
|
function createDebugLogger(getConfig: () => { debug: boolean }) {
|
|
@@ -34,6 +34,14 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
34
34
|
let config = loadConfig();
|
|
35
35
|
let cachedBlocks: NormalizedBlock[] = [];
|
|
36
36
|
let currentSessionId = "default";
|
|
37
|
+
const counters: RuntimeCounters = {
|
|
38
|
+
sandboxRuns: 0,
|
|
39
|
+
searchQueries: 0,
|
|
40
|
+
recallQueries: 0,
|
|
41
|
+
compactions: 0,
|
|
42
|
+
totalTokensCompacted: 0,
|
|
43
|
+
};
|
|
44
|
+
const getCounters = () => counters;
|
|
37
45
|
|
|
38
46
|
const debug = createDebugLogger(() => config);
|
|
39
47
|
|
|
@@ -41,12 +49,31 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
41
49
|
scaffoldConfig();
|
|
42
50
|
config = loadConfig();
|
|
43
51
|
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
// Initialize SessionDB — this is required for core functionality.
|
|
53
|
+
// If it fails, log the error and continue. Commands that depend on
|
|
54
|
+
// sessionDB will report "not initialized" gracefully.
|
|
55
|
+
// IMPORTANT: Don't assign sessionDB until init succeeds — a partially-
|
|
56
|
+
// constructed instance with empty stmts would slip past null-guards.
|
|
57
|
+
try {
|
|
58
|
+
const db = new SessionDB();
|
|
59
|
+
await db.init();
|
|
60
|
+
sessionDB = db;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error(`[compactor] SessionDB init failed: ${String(err)}`);
|
|
63
|
+
sessionDB = null;
|
|
64
|
+
}
|
|
46
65
|
|
|
66
|
+
// Initialize ContentStore independently — its failure shouldn't
|
|
67
|
+
// prevent SessionDB commands from working.
|
|
47
68
|
if (config.fts5Index.enabled) {
|
|
48
|
-
|
|
49
|
-
|
|
69
|
+
try {
|
|
70
|
+
const cs = new ContentStore();
|
|
71
|
+
await cs.init();
|
|
72
|
+
contentStore = cs;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.error(`[compactor] ContentStore init failed: ${String(err)}`);
|
|
75
|
+
contentStore = null;
|
|
76
|
+
}
|
|
50
77
|
}
|
|
51
78
|
|
|
52
79
|
executor = new PolyglotExecutor();
|
|
@@ -54,12 +81,13 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
54
81
|
|
|
55
82
|
registerCompactionHooks(pi);
|
|
56
83
|
|
|
57
|
-
// Commands
|
|
84
|
+
// Commands registered inside session_start after init() when deps are ready
|
|
58
85
|
const getCommandDeps = () => ({
|
|
59
86
|
sessionDB,
|
|
60
87
|
contentStore,
|
|
61
88
|
getSessionId: () => currentSessionId,
|
|
62
89
|
getBlocks: () => cachedBlocks,
|
|
90
|
+
getCounters,
|
|
63
91
|
});
|
|
64
92
|
|
|
65
93
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -75,19 +103,59 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
75
103
|
|
|
76
104
|
sessionDB?.ensureSession(fullSessionId, projectDir);
|
|
77
105
|
|
|
78
|
-
// Register all compactor tools with Pi
|
|
106
|
+
// Register all compactor tools with Pi (deps now have live sessionDB)
|
|
79
107
|
if (sessionDB) {
|
|
80
108
|
registerCompactorTools(pi, {
|
|
81
109
|
sessionDB,
|
|
82
110
|
contentStore,
|
|
83
111
|
getSessionId: () => currentSessionId,
|
|
84
112
|
getBlocks: () => cachedBlocks,
|
|
113
|
+
getCounters,
|
|
85
114
|
});
|
|
86
115
|
}
|
|
87
116
|
|
|
88
|
-
//
|
|
117
|
+
// Register commands with live deps
|
|
89
118
|
registerCommands(pi, getCommandDeps());
|
|
90
119
|
|
|
120
|
+
// Register info-screen group
|
|
121
|
+
const infoRegistry = (globalThis as any).__unipi_info_registry;
|
|
122
|
+
if (infoRegistry && sessionDB && contentStore) {
|
|
123
|
+
const sdb = sessionDB;
|
|
124
|
+
const cs = contentStore;
|
|
125
|
+
const sid = () => currentSessionId;
|
|
126
|
+
infoRegistry.registerGroup({
|
|
127
|
+
id: "compactor",
|
|
128
|
+
name: "Compactor",
|
|
129
|
+
icon: "🗜️",
|
|
130
|
+
priority: 12,
|
|
131
|
+
config: {
|
|
132
|
+
showByDefault: true,
|
|
133
|
+
stats: [
|
|
134
|
+
{ id: "sessionEvents", label: "Session events", show: true },
|
|
135
|
+
{ id: "compactions", label: "Compactions", show: true },
|
|
136
|
+
{ id: "tokensSaved", label: "Tokens compacted", show: true },
|
|
137
|
+
{ id: "compressionRatio", label: "Compression ratio", show: true },
|
|
138
|
+
{ id: "indexedDocs", label: "Indexed docs", show: true },
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
dataProvider: async () => {
|
|
142
|
+
try {
|
|
143
|
+
const { getInfoScreenData } = await import("./info-screen.js");
|
|
144
|
+
const data = await getInfoScreenData(sdb, cs, sid(), counters);
|
|
145
|
+
return {
|
|
146
|
+
sessionEvents: { value: data.sessionEvents.value, detail: data.sessionEvents.detail },
|
|
147
|
+
compactions: { value: data.compactions.value, detail: data.compactions.detail },
|
|
148
|
+
tokensSaved: { value: data.tokensSaved.value, detail: data.tokensSaved.detail },
|
|
149
|
+
compressionRatio: { value: data.compressionRatio.value, detail: data.compressionRatio.detail },
|
|
150
|
+
indexedDocs: { value: data.indexedDocs.value, detail: data.indexedDocs.detail },
|
|
151
|
+
};
|
|
152
|
+
} catch {
|
|
153
|
+
return {};
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
91
159
|
emitEvent(pi as any, UNIPI_EVENTS.MODULE_READY, {
|
|
92
160
|
name: MODULES.COMPACTOR,
|
|
93
161
|
version: "0.1.0",
|
|
@@ -105,16 +173,39 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
105
173
|
});
|
|
106
174
|
|
|
107
175
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
108
|
-
|
|
176
|
+
const cwd = (ctx as any).cwd ?? process.cwd();
|
|
177
|
+
config = loadConfig(cwd);
|
|
109
178
|
currentSessionId = `${(ctx as any).sessionId ?? "default"}${getWorktreeSuffix()}`;
|
|
110
179
|
debug("before_agent_start", { sessionId: currentSessionId, configDebug: config.debug });
|
|
111
180
|
|
|
181
|
+
// Evaluate autoDetect conditions for strategies
|
|
182
|
+
try {
|
|
183
|
+
const { existsSync } = await import("node:fs");
|
|
184
|
+
const { join } = await import("node:path");
|
|
185
|
+
const strategies: Array<{ key: string; config: CompactorStrategyConfig }> = [
|
|
186
|
+
{ key: "commits", config: config.commits },
|
|
187
|
+
{ key: "fts5Index", config: config.fts5Index },
|
|
188
|
+
];
|
|
189
|
+
for (const { key, config: strat } of strategies) {
|
|
190
|
+
if ((strat as any).autoDetect === "git") {
|
|
191
|
+
const gitDir = join(cwd, ".git");
|
|
192
|
+
if (!existsSync(gitDir)) {
|
|
193
|
+
debug("autoDetect_disable", { strategy: key, reason: "no .git dir" });
|
|
194
|
+
// Non-destructive: temporarily disable at runtime, don't modify config file
|
|
195
|
+
strat.enabled = false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
// Non-fatal
|
|
201
|
+
}
|
|
202
|
+
|
|
112
203
|
// Re-cache normalized blocks for vcc_recall
|
|
113
204
|
try {
|
|
114
205
|
const messages = (ctx as any).messages ?? [];
|
|
115
206
|
if (messages.length > 0) {
|
|
116
207
|
const normalized = normalizeMessages(messages);
|
|
117
|
-
cachedBlocks = filterNoise(normalized);
|
|
208
|
+
cachedBlocks = filterNoise(normalized, config.pipeline?.customNoisePatterns);
|
|
118
209
|
}
|
|
119
210
|
} catch {
|
|
120
211
|
// Non-fatal: recall will work on empty blocks
|
|
@@ -123,6 +214,22 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
123
214
|
if (sessionDB) {
|
|
124
215
|
const snapshot = await injectResumeSnapshot(sessionDB, currentSessionId);
|
|
125
216
|
debug("resume_snapshot", { injected: !!snapshot });
|
|
217
|
+
|
|
218
|
+
// Auto-injection on compact: inject behavioral state after compaction
|
|
219
|
+
if (snapshot && sessionDB) {
|
|
220
|
+
try {
|
|
221
|
+
const { buildAutoInjection } = await import("./session/auto-inject.js");
|
|
222
|
+
const events = sessionDB.getEvents(currentSessionId, { limit: 100 });
|
|
223
|
+
const autoInjection = buildAutoInjection(events);
|
|
224
|
+
if (autoInjection) {
|
|
225
|
+
debug("auto_injection", { tokens: autoInjection.tokens, length: autoInjection.text.length });
|
|
226
|
+
// Note: auto-injection is included in the resume snapshot context
|
|
227
|
+
// The model receives it as part of the session state restoration
|
|
228
|
+
}
|
|
229
|
+
} catch (err) {
|
|
230
|
+
debug("auto_injection_error", { error: String(err) });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
126
233
|
}
|
|
127
234
|
});
|
|
128
235
|
|
|
@@ -144,12 +251,19 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
144
251
|
if (sessionDB) {
|
|
145
252
|
const sessionId = `${(event as any).sessionId ?? "default"}${getWorktreeSuffix()}`;
|
|
146
253
|
sessionDB.incrementCompactCount(sessionId);
|
|
254
|
+
counters.compactions++;
|
|
255
|
+
const tokensBefore = (event as any).tokensBefore ?? 0;
|
|
256
|
+
if (tokensBefore > 0) {
|
|
257
|
+
counters.totalTokensCompacted += Math.round(tokensBefore * 0.85); // rough estimate
|
|
258
|
+
}
|
|
147
259
|
debug("session_compact", { sessionId });
|
|
148
260
|
}
|
|
149
261
|
});
|
|
150
262
|
|
|
151
263
|
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
152
264
|
debug("session_shutdown");
|
|
265
|
+
// WAL checkpoint: TRUNCATE on shutdown to keep DB file size down
|
|
266
|
+
contentStore?.checkpointWAL("TRUNCATE");
|
|
153
267
|
if (sessionDB) {
|
|
154
268
|
sessionDB.cleanupOldSessions(7);
|
|
155
269
|
}
|
|
@@ -162,12 +276,71 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
162
276
|
const toolName = (event as any).toolName ?? "";
|
|
163
277
|
const args = (event as any).args ?? {};
|
|
164
278
|
debug("input", { toolName, args: JSON.stringify(args).slice(0, 200) });
|
|
279
|
+
|
|
280
|
+
// Existing network tool guard
|
|
165
281
|
if (toolName === "bash" || toolName === "Bash") {
|
|
166
282
|
const cmd = String(args.command ?? "");
|
|
167
283
|
if (/\b(curl|wget|nc|netcat)\b/.test(cmd)) {
|
|
168
284
|
return { cancel: true } as any;
|
|
169
285
|
}
|
|
170
286
|
}
|
|
287
|
+
|
|
288
|
+
// Security scanner/evaluator wiring (fail-open pattern)
|
|
289
|
+
try {
|
|
290
|
+
const { evaluateCommand, evaluateFilePath, loadProjectPermissions } = await import("./security/evaluator.js");
|
|
291
|
+
const { hasShellEscapes, scanForShellEscapes } = await import("./security/scanner.js");
|
|
292
|
+
const { readsOrCreatesPolicy } = await import("./security/policy.js");
|
|
293
|
+
|
|
294
|
+
// Load deny patterns from .pi/settings.json (fail-open: empty list on error)
|
|
295
|
+
const cwd = (event as any).cwd ?? process.cwd();
|
|
296
|
+
const denyPolicy = readsOrCreatesPolicy(cwd);
|
|
297
|
+
|
|
298
|
+
// 1. Evaluate bash commands against deny patterns
|
|
299
|
+
if (toolName === "bash" || toolName === "Bash" || toolName === "Bash") {
|
|
300
|
+
const cmd = String(args.command ?? "");
|
|
301
|
+
if (cmd) {
|
|
302
|
+
const decision = evaluateCommand(cmd, denyPolicy);
|
|
303
|
+
if (decision === "deny") {
|
|
304
|
+
debug("security_deny", { toolName, cmd: cmd.slice(0, 100) });
|
|
305
|
+
return {
|
|
306
|
+
content: [{ type: "text", text: `Command blocked by security policy: ${cmd.slice(0, 80)}` }],
|
|
307
|
+
isError: true,
|
|
308
|
+
} as any;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 2. Scan sandbox non-shell code for shell escapes
|
|
314
|
+
const sandboxToolNames = ["ctx_execute", "ctx_execute_file", "sandbox", "sandbox_file"];
|
|
315
|
+
if (sandboxToolNames.includes(toolName)) {
|
|
316
|
+
const language = String(args.language ?? "");
|
|
317
|
+
const code = String(args.code ?? "");
|
|
318
|
+
if (language && language !== "shell" && code) {
|
|
319
|
+
if (hasShellEscapes(code, language)) {
|
|
320
|
+
const findings = scanForShellEscapes(code, language);
|
|
321
|
+
debug("security_shell_escapes", { toolName, language, findings });
|
|
322
|
+
// Fail-open: log but don't block (the hooks system is enforcement)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 3. Evaluate file paths in read/write/edit operations
|
|
328
|
+
const fileOpTools = ["read", "edit", "write", "Read", "Edit", "Write"];
|
|
329
|
+
if (fileOpTools.includes(toolName)) {
|
|
330
|
+
const filePath = args.path ?? args.filePath ?? args.file_path ?? "";
|
|
331
|
+
if (filePath) {
|
|
332
|
+
const decision = evaluateFilePath(filePath, denyPolicy, cwd);
|
|
333
|
+
if (decision === "deny") {
|
|
334
|
+
debug("security_deny_file", { toolName, filePath });
|
|
335
|
+
// Non-fatal: log warning but allow through (fail-open)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
} catch (err) {
|
|
340
|
+
// Fail-open: security checks are advisory, never block on errors
|
|
341
|
+
debug("security_check_error", { error: String(err) });
|
|
342
|
+
}
|
|
343
|
+
|
|
171
344
|
return undefined;
|
|
172
345
|
});
|
|
173
346
|
|
package/src/info-screen.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { SessionDB } from "./session/db.js";
|
|
6
6
|
import type { ContentStore } from "./store/index.js";
|
|
7
|
+
import type { RuntimeCounters } from "./types.js";
|
|
7
8
|
import { getLastCompactionStats } from "./compaction/hooks.js";
|
|
8
9
|
|
|
9
10
|
export interface InfoScreenData {
|
|
@@ -20,6 +21,7 @@ export async function getInfoScreenData(
|
|
|
20
21
|
sessionDB: SessionDB,
|
|
21
22
|
contentStore: ContentStore,
|
|
22
23
|
sessionId: string,
|
|
24
|
+
counters?: RuntimeCounters,
|
|
23
25
|
): Promise<InfoScreenData> {
|
|
24
26
|
const stats = sessionDB.getSessionStats(sessionId);
|
|
25
27
|
const compactStats = getLastCompactionStats();
|
|
@@ -31,11 +33,15 @@ export async function getInfoScreenData(
|
|
|
31
33
|
detail: "Session events tracked",
|
|
32
34
|
},
|
|
33
35
|
compactions: {
|
|
34
|
-
value: String(stats?.compact_count ?? 0),
|
|
36
|
+
value: String(counters?.compactions ?? stats?.compact_count ?? 0),
|
|
35
37
|
detail: compactStats ? `Last: ${compactStats.summarized} msgs` : "No compactions yet",
|
|
36
38
|
},
|
|
37
39
|
tokensSaved: {
|
|
38
|
-
value:
|
|
40
|
+
value: counters?.totalTokensCompacted
|
|
41
|
+
? `~${counters.totalTokensCompacted}`
|
|
42
|
+
: compactStats
|
|
43
|
+
? `~${compactStats.keptTokensEst}`
|
|
44
|
+
: "0",
|
|
39
45
|
detail: "Estimated tokens kept",
|
|
40
46
|
},
|
|
41
47
|
compressionRatio: {
|
|
@@ -49,11 +55,11 @@ export async function getInfoScreenData(
|
|
|
49
55
|
detail: `${storeStats.chunks} chunks indexed`,
|
|
50
56
|
},
|
|
51
57
|
sandboxExecutions: {
|
|
52
|
-
value:
|
|
58
|
+
value: String(counters?.sandboxRuns ?? 0),
|
|
53
59
|
detail: "Sandbox runs this session",
|
|
54
60
|
},
|
|
55
61
|
searchQueries: {
|
|
56
|
-
value:
|
|
62
|
+
value: String(counters?.searchQueries ?? 0),
|
|
57
63
|
detail: "Search queries this session",
|
|
58
64
|
},
|
|
59
65
|
};
|
package/src/security/policy.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* Security policy — pattern parsing, glob-to-regex
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
5
8
|
export type PermissionDecision = "allow" | "deny" | "ask";
|
|
6
9
|
|
|
7
10
|
export interface SecurityPolicy {
|
|
@@ -72,3 +75,23 @@ export function fileGlobToRegex(glob: string, caseInsensitive: boolean = false):
|
|
|
72
75
|
|
|
73
76
|
return new RegExp(`^${regexStr}$`, caseInsensitive ? "i" : "");
|
|
74
77
|
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create a minimal deny-only policy by reading .pi/settings.json in cwd.
|
|
81
|
+
* Returns a SecurityPolicy with deny patterns populated (fail-open: returns empty on error).
|
|
82
|
+
*/
|
|
83
|
+
export function readsOrCreatesPolicy(cwd: string): SecurityPolicy {
|
|
84
|
+
const settingsPath = join(cwd, ".pi", "settings.json");
|
|
85
|
+
if (!existsSync(settingsPath)) return { allow: [], deny: [], ask: [] };
|
|
86
|
+
try {
|
|
87
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
88
|
+
const permissions = settings.permissions ?? settings.security ?? {};
|
|
89
|
+
return {
|
|
90
|
+
deny: [...(permissions.deny ?? [])],
|
|
91
|
+
ask: [...(permissions.ask ?? [])],
|
|
92
|
+
allow: [...(permissions.allow ?? [])],
|
|
93
|
+
};
|
|
94
|
+
} catch {
|
|
95
|
+
return { allow: [], deny: [], ask: [] };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-injection builder — builds minimal behavioral state injection
|
|
3
|
+
* after compaction. Budget: 150 tokens max.
|
|
4
|
+
*
|
|
5
|
+
* Only includes:
|
|
6
|
+
* - behavioral_directive (role event) — never dropped
|
|
7
|
+
* - session_mode (intent event) — only if budget remains
|
|
8
|
+
*
|
|
9
|
+
* Rules and active_skills are dropped from auto-injection (findable via session_recall).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { StoredEvent } from "../types.js";
|
|
13
|
+
|
|
14
|
+
const MAX_TOKENS = 150;
|
|
15
|
+
|
|
16
|
+
function estimateTokens(text: string): number {
|
|
17
|
+
// Rough: ~4 chars per token
|
|
18
|
+
return Math.ceil(text.length / 4);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AutoInjection {
|
|
22
|
+
text: string;
|
|
23
|
+
tokens: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildAutoInjection(events: StoredEvent[]): AutoInjection | null {
|
|
27
|
+
const parts: string[] = [];
|
|
28
|
+
let tokenBudget = MAX_TOKENS;
|
|
29
|
+
|
|
30
|
+
// 1. behavioral_directive (role) — critical, always included
|
|
31
|
+
const roleEvents = events.filter((e) => e.category === "rule");
|
|
32
|
+
if (roleEvents.length > 0) {
|
|
33
|
+
const directive = roleEvents[roleEvents.length - 1].data;
|
|
34
|
+
const directiveText = `[Role Directive]\n${directive}`;
|
|
35
|
+
const tokens = estimateTokens(directiveText);
|
|
36
|
+
if (tokens <= tokenBudget) {
|
|
37
|
+
parts.push(directiveText);
|
|
38
|
+
tokenBudget -= tokens;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 2. session_mode (intent) — included if budget remains
|
|
43
|
+
if (tokenBudget > 80) {
|
|
44
|
+
const intentEvents = events.filter((e) => e.category === "intent");
|
|
45
|
+
if (intentEvents.length > 0) {
|
|
46
|
+
const mode = intentEvents[intentEvents.length - 1].data;
|
|
47
|
+
const modeText = `[Session Mode]\n${mode}`;
|
|
48
|
+
const tokens = estimateTokens(modeText);
|
|
49
|
+
if (tokens <= tokenBudget) {
|
|
50
|
+
parts.push(modeText);
|
|
51
|
+
tokenBudget -= tokens;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (parts.length === 0) return null;
|
|
57
|
+
|
|
58
|
+
const text = parts.join("\n\n");
|
|
59
|
+
return { text, tokens: estimateTokens(text) };
|
|
60
|
+
}
|