@pi-unipi/compactor 0.1.6 → 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.
@@ -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
- opencode: preset({
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
- for (const name of ["opencode", "balanced", "verbose", "minimal"] as const) {
62
- if (configsEqual(config, PRESET_CONFIGS[name])) return name;
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
- if (normalized === "opencode" || normalized === "balanced" || normalized === "verbose" || normalized === "minimal" || normalized === "custom") {
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
  }
@@ -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
- sessionDB = new SessionDB();
45
- await sessionDB.init();
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
- contentStore = new ContentStore();
49
- await contentStore.init();
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 will be registered inside session_start when deps are ready
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
- // Re-register commands with fresh deps now that sessionDB is ready
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
- config = loadConfig();
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
 
@@ -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: compactStats ? `~${compactStats.keptTokensEst}` : "0",
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: "0",
58
+ value: String(counters?.sandboxRuns ?? 0),
53
59
  detail: "Sandbox runs this session",
54
60
  },
55
61
  searchQueries: {
56
- value: "0",
62
+ value: String(counters?.searchQueries ?? 0),
57
63
  detail: "Search queries this session",
58
64
  },
59
65
  };
@@ -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
+ }