supipowers 1.2.6 → 1.3.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.
@@ -1,7 +1,8 @@
1
1
  // src/context-mode/event-extractor.ts
2
+ import { PRIORITY } from "./event-store.js";
2
3
  import type { EventCategory, EventPriority, TrackedEvent } from "./event-store.js";
3
4
 
4
- type Event = Omit<TrackedEvent, "id">;
5
+ type Event = Omit<TrackedEvent, "id" | "dataHash">;
5
6
 
6
7
  const GIT_COMMAND_PATTERNS = [
7
8
  /^git\s+(commit|merge|rebase|checkout|switch|branch|push|pull|stash|reset|cherry-pick|tag)\b/,
@@ -39,8 +40,7 @@ function getTextContent(content: Array<{ type: string; text?: string }>): string
39
40
  return content
40
41
  .filter((c) => c.type === "text" && c.text)
41
42
  .map((c) => c.text!)
42
- .join("\n")
43
- .slice(0, 500); // Cap for storage
43
+ .join("\n");
44
44
  }
45
45
 
46
46
  /** Extract events from a tool result */
@@ -62,21 +62,29 @@ export function extractEvents(
62
62
  events.push(makeEvent(sessionId, "error", {
63
63
  toolName: event.toolName,
64
64
  content: text,
65
- }, "critical", "tool_result"));
65
+ }, PRIORITY.critical, "tool_result"));
66
66
  }
67
67
 
68
68
  switch (event.toolName) {
69
69
  case "bash":
70
70
  extractBash(events, event, sessionId, text);
71
71
  break;
72
- case "read":
72
+ case "read": {
73
+ const readPath = typeof event.input.path === "string" ? event.input.path : "";
74
+ if (/AGENTS\.md$/.test(readPath) || /CLAUDE\.md$/.test(readPath) || /\.omp\//.test(readPath)) {
75
+ events.push(makeEvent(sessionId, "rule", { path: readPath, type: "project-rule" }, PRIORITY.critical, "tool_result"));
76
+ }
77
+ if (readPath.includes("/skills/")) {
78
+ events.push(makeEvent(sessionId, "skill", { path: readPath }, PRIORITY.medium, "tool_result"));
79
+ }
73
80
  extractFile(events, event, sessionId, "read");
74
81
  break;
82
+ }
75
83
  case "edit":
76
- extractFile(events, event, sessionId, "edit", "high");
84
+ extractFile(events, event, sessionId, "edit", PRIORITY.high);
77
85
  break;
78
86
  case "write":
79
- extractFile(events, event, sessionId, "write", "high");
87
+ extractFile(events, event, sessionId, "write", PRIORITY.high);
80
88
  break;
81
89
  case "grep":
82
90
  extractFile(events, event, sessionId, "search");
@@ -87,18 +95,18 @@ export function extractEvents(
87
95
  case "todo_write":
88
96
  events.push(makeEvent(sessionId, "task", {
89
97
  input: event.input,
90
- }, "high", "tool_result"));
98
+ }, PRIORITY.high, "tool_result"));
91
99
  break;
92
100
  default:
93
101
  if (event.toolName.startsWith("ctx_")) {
94
102
  events.push(makeEvent(sessionId, "mcp", {
95
103
  tool: event.toolName,
96
- }, "low", "tool_result"));
104
+ }, PRIORITY.low, "tool_result"));
97
105
  } else if (event.toolName === "task" || event.toolName === "sub_agent") {
98
106
  events.push(makeEvent(sessionId, "subagent", {
99
107
  toolName: event.toolName,
100
108
  input: event.input,
101
- }, "medium", "tool_result"));
109
+ }, PRIORITY.medium, "tool_result"));
102
110
  }
103
111
  // Unknown tools: no events
104
112
  break;
@@ -123,7 +131,7 @@ function extractBash(
123
131
  events.push(makeEvent(sessionId, "git", {
124
132
  command,
125
133
  output: text,
126
- }, "high", "tool_result"));
134
+ }, PRIORITY.high, "tool_result"));
127
135
  }
128
136
 
129
137
  // Non-zero exit (in addition to general isError rule)
@@ -132,14 +140,28 @@ function extractBash(
132
140
  command,
133
141
  exitCode,
134
142
  output: text,
135
- }, "critical", "tool_result"));
143
+ }, PRIORITY.critical, "tool_result"));
136
144
  }
137
145
 
138
146
  // Working directory change
139
147
  if (/\bcd\s+/.test(command)) {
140
148
  events.push(makeEvent(sessionId, "cwd", {
141
149
  command,
142
- }, "low", "tool_result"));
150
+ }, PRIORITY.low, "tool_result"));
151
+ }
152
+
153
+ // Environment/version commands
154
+ const ENV_PATTERNS = [
155
+ /^(node|bun|python|ruby|go)\s+--?version/,
156
+ /^(npm|yarn|pnpm|pip|cargo)\s+--?version/,
157
+ /\bprintenv\b/,
158
+ /\becho\s+\$\w+/,
159
+ ];
160
+ if (ENV_PATTERNS.some((p) => p.test(command))) {
161
+ events.push(makeEvent(sessionId, "env", {
162
+ command,
163
+ output: text,
164
+ }, PRIORITY.medium, "tool_result"));
143
165
  }
144
166
  }
145
167
 
@@ -148,7 +170,7 @@ function extractFile(
148
170
  event: { input: Record<string, unknown> },
149
171
  sessionId: string,
150
172
  op: string,
151
- priority: EventPriority = "medium",
173
+ priority: EventPriority = PRIORITY.medium,
152
174
  ): void {
153
175
  const path = typeof event.input.path === "string" ? event.input.path : "unknown";
154
176
  events.push(makeEvent(sessionId, "file", { op, path }, priority, "tool_result"));
@@ -159,11 +181,18 @@ export function extractPromptEvents(prompt: string, sessionId: string): Event[]
159
181
  const events: Event[] = [];
160
182
 
161
183
  // Always capture the prompt
162
- events.push(makeEvent(sessionId, "prompt", { prompt }, "high", "before_agent_start"));
184
+ events.push(makeEvent(sessionId, "prompt", { prompt }, PRIORITY.high, "before_agent_start"));
163
185
 
164
186
  // Check for decision patterns
165
187
  if (DECISION_PATTERNS.some((p) => p.test(prompt))) {
166
- events.push(makeEvent(sessionId, "decision", { prompt }, "high", "before_agent_start"));
188
+ events.push(makeEvent(sessionId, "decision", { prompt }, PRIORITY.high, "before_agent_start"));
189
+ }
190
+
191
+ // Detect high-level intent markers
192
+ const INTENT_PATTERN = /\b(build|fix|refactor|test|review|deploy|debug|plan|implement|create|delete|remove|update|add|migrate)\b/i;
193
+ const intentMatch = prompt.match(INTENT_PATTERN);
194
+ if (intentMatch) {
195
+ events.push(makeEvent(sessionId, "intent", { intent: intentMatch[1].toLowerCase(), prompt }, PRIORITY.low, "before_agent_start"));
167
196
  }
168
197
 
169
198
  return events;
@@ -1,5 +1,6 @@
1
1
  // src/context-mode/event-store.ts
2
2
  import { Database } from "bun:sqlite";
3
+ import { createHash } from "node:crypto";
3
4
 
4
5
  /** Event categories extracted from tool results */
5
6
  export type EventCategory =
@@ -11,10 +12,23 @@ export type EventCategory =
11
12
  | "mcp"
12
13
  | "subagent"
13
14
  | "prompt"
14
- | "decision";
15
+ | "decision"
16
+ | "rule"
17
+ | "env"
18
+ | "skill"
19
+ | "intent";
15
20
 
16
- /** Priority levels for resume snapshot ordering */
17
- export type EventPriority = "critical" | "high" | "medium" | "low";
21
+ /** Numeric priority: 1 = critical 5 = lowest */
22
+ export type EventPriority = 1 | 2 | 3 | 4 | 5;
23
+
24
+ /** Named priority constants — use instead of magic numbers */
25
+ export const PRIORITY = {
26
+ critical: 1,
27
+ high: 2,
28
+ medium: 3,
29
+ low: 4,
30
+ lowest: 5,
31
+ } as const satisfies Record<string, EventPriority>;
18
32
 
19
33
  /** A tracked event */
20
34
  export interface TrackedEvent {
@@ -25,21 +39,47 @@ export interface TrackedEvent {
25
39
  priority: EventPriority;
26
40
  source: string;
27
41
  timestamp: number;
42
+ dataHash?: string;
43
+ }
44
+
45
+ /** Session metadata row */
46
+ export interface SessionMeta {
47
+ sessionId: string;
48
+ projectDir: string;
49
+ startedAt: string;
50
+ lastEventAt: string;
51
+ eventCount: number;
52
+ compactCount: number;
53
+ }
54
+
55
+ /** Resume snapshot row */
56
+ export interface SessionResume {
57
+ sessionId: string;
58
+ snapshot: string;
59
+ eventCount: number;
60
+ createdAt: string;
61
+ consumed: boolean;
28
62
  }
29
63
 
64
+ const MAX_EVENTS_PER_SESSION = 1000;
65
+ const DEDUP_WINDOW = 5;
66
+ const SCHEMA_VERSION = 1;
67
+
30
68
  const SCHEMA = `
31
69
  CREATE TABLE IF NOT EXISTS session_events (
32
70
  id INTEGER PRIMARY KEY AUTOINCREMENT,
33
71
  session_id TEXT NOT NULL,
34
72
  category TEXT NOT NULL,
35
73
  data TEXT NOT NULL,
36
- priority TEXT NOT NULL DEFAULT 'medium',
74
+ priority INTEGER NOT NULL DEFAULT 3,
37
75
  source TEXT NOT NULL,
38
- timestamp INTEGER NOT NULL
76
+ timestamp INTEGER NOT NULL,
77
+ data_hash TEXT NOT NULL DEFAULT ''
39
78
  );
40
79
 
41
80
  CREATE INDEX IF NOT EXISTS idx_events_session ON session_events(session_id, timestamp);
42
81
  CREATE INDEX IF NOT EXISTS idx_events_category ON session_events(session_id, category);
82
+ CREATE INDEX IF NOT EXISTS idx_events_dedup ON session_events(session_id, data_hash);
43
83
 
44
84
  CREATE VIRTUAL TABLE IF NOT EXISTS session_events_fts USING fts5(
45
85
  data,
@@ -59,12 +99,36 @@ CREATE TRIGGER IF NOT EXISTS events_au AFTER UPDATE ON session_events BEGIN
59
99
  INSERT INTO session_events_fts(session_events_fts, rowid, data) VALUES ('delete', old.id, old.data);
60
100
  INSERT INTO session_events_fts(rowid, data) VALUES (new.id, new.data);
61
101
  END;
102
+
103
+ CREATE TABLE IF NOT EXISTS session_meta (
104
+ session_id TEXT UNIQUE NOT NULL,
105
+ project_dir TEXT NOT NULL DEFAULT '',
106
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
107
+ last_event_at TEXT NOT NULL DEFAULT (datetime('now')),
108
+ event_count INTEGER NOT NULL DEFAULT 0,
109
+ compact_count INTEGER NOT NULL DEFAULT 0
110
+ );
111
+
112
+ CREATE TABLE IF NOT EXISTS session_resume (
113
+ id INTEGER PRIMARY KEY,
114
+ session_id TEXT UNIQUE NOT NULL,
115
+ snapshot TEXT NOT NULL,
116
+ event_count INTEGER NOT NULL DEFAULT 0,
117
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
118
+ consumed INTEGER NOT NULL DEFAULT 0
119
+ );
62
120
  `;
63
121
 
64
- const ALL_CATEGORIES: EventCategory[] = [
122
+ export const ALL_CATEGORIES: EventCategory[] = [
65
123
  "file", "git", "error", "task", "cwd", "mcp", "subagent", "prompt", "decision",
124
+ "rule", "env", "skill", "intent",
66
125
  ];
67
126
 
127
+ /** SHA-256 first 16 hex chars — fast content-addressable dedup key */
128
+ function computeDataHash(data: string): string {
129
+ return createHash("sha256").update(data).digest("hex").slice(0, 16);
130
+ }
131
+
68
132
  export class EventStore {
69
133
  private db: Database;
70
134
 
@@ -74,27 +138,94 @@ export class EventStore {
74
138
 
75
139
  init(): void {
76
140
  this.db.exec("PRAGMA journal_mode = WAL;");
141
+ this.migrate();
77
142
  this.db.exec(SCHEMA);
78
143
  }
79
144
 
80
- writeEvent(event: Omit<TrackedEvent, "id">): void {
145
+ // ── Schema migration ────────────────────────────────────────
146
+
147
+ private migrate(): void {
148
+ const { user_version } = this.db.prepare("PRAGMA user_version").get() as { user_version: number };
149
+ if (user_version >= SCHEMA_VERSION) return;
150
+
151
+ // Upgrade from v0: session_events exists but lacks data_hash column
152
+ const tableExists = this.db.prepare(
153
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='session_events'",
154
+ ).get();
155
+
156
+ if (tableExists) {
157
+ try {
158
+ this.db.exec("ALTER TABLE session_events ADD COLUMN data_hash TEXT NOT NULL DEFAULT ''");
159
+ } catch {
160
+ // Column already exists (fresh install ran SCHEMA first somehow)
161
+ }
162
+ }
163
+
164
+ this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION};`);
165
+ }
166
+
167
+ // ── Write with dedup + eviction ─────────────────────────────
168
+
169
+ writeEvent(event: Omit<TrackedEvent, "id" | "dataHash">): void {
170
+ const dataHash = computeDataHash(event.data);
171
+ if (this.isDuplicate(event.sessionId, event.category, dataHash)) return;
172
+
81
173
  this.db.prepare(
82
- "INSERT INTO session_events (session_id, category, data, priority, source, timestamp) VALUES (?, ?, ?, ?, ?, ?)",
83
- ).run(event.sessionId, event.category, event.data, event.priority, event.source, event.timestamp);
174
+ "INSERT INTO session_events (session_id, category, data, priority, source, timestamp, data_hash) VALUES (?, ?, ?, ?, ?, ?, ?)",
175
+ ).run(event.sessionId, event.category, event.data, event.priority, event.source, event.timestamp, dataHash);
176
+
177
+ this.enforceEventCap(event.sessionId);
84
178
  }
85
179
 
86
- writeEvents(events: Omit<TrackedEvent, "id">[]): void {
180
+ writeEvents(events: Omit<TrackedEvent, "id" | "dataHash">[]): void {
181
+ if (events.length === 0) return;
182
+
87
183
  const insert = this.db.prepare(
88
- "INSERT INTO session_events (session_id, category, data, priority, source, timestamp) VALUES (?, ?, ?, ?, ?, ?)",
184
+ "INSERT INTO session_events (session_id, category, data, priority, source, timestamp, data_hash) VALUES (?, ?, ?, ?, ?, ?, ?)",
89
185
  );
90
186
  const tx = this.db.transaction(() => {
91
187
  for (const event of events) {
92
- insert.run(event.sessionId, event.category, event.data, event.priority, event.source, event.timestamp);
188
+ const dataHash = computeDataHash(event.data);
189
+ if (this.isDuplicate(event.sessionId, event.category, dataHash)) continue;
190
+ insert.run(event.sessionId, event.category, event.data, event.priority, event.source, event.timestamp, dataHash);
93
191
  }
94
192
  });
95
193
  tx();
194
+
195
+ this.enforceEventCap(events[0].sessionId);
196
+ }
197
+
198
+ /** Check last DEDUP_WINDOW events for same category + content hash */
199
+ private isDuplicate(sessionId: string, category: string, dataHash: string): boolean {
200
+ const row = this.db.prepare(
201
+ `SELECT 1 FROM (
202
+ SELECT category, data_hash FROM session_events
203
+ WHERE session_id = ? ORDER BY id DESC LIMIT ?
204
+ ) WHERE category = ? AND data_hash = ? LIMIT 1`,
205
+ ).get(sessionId, DEDUP_WINDOW, category, dataHash);
206
+ return row != null;
96
207
  }
97
208
 
209
+ /** Delete lowest-priority (highest number), then oldest events to stay at cap */
210
+ private enforceEventCap(sessionId: string): void {
211
+ const { count } = this.db.prepare(
212
+ "SELECT COUNT(*) AS count FROM session_events WHERE session_id = ?",
213
+ ).get(sessionId) as { count: number };
214
+
215
+ if (count <= MAX_EVENTS_PER_SESSION) return;
216
+
217
+ const excess = count - MAX_EVENTS_PER_SESSION;
218
+ this.db.prepare(
219
+ `DELETE FROM session_events WHERE id IN (
220
+ SELECT id FROM session_events WHERE session_id = ?
221
+ ORDER BY CAST(priority AS INTEGER) DESC, timestamp ASC
222
+ LIMIT ?
223
+ )`,
224
+ ).run(sessionId, excess);
225
+ }
226
+
227
+ // ── Read ────────────────────────────────────────────────────
228
+
98
229
  getEvents(
99
230
  sessionId: string,
100
231
  filters?: {
@@ -112,7 +243,7 @@ export class EventStore {
112
243
  params.push(...filters.categories);
113
244
  }
114
245
  if (filters?.priority) {
115
- conditions.push("priority = ?");
246
+ conditions.push("CAST(priority AS INTEGER) = ?");
116
247
  params.push(filters.priority);
117
248
  }
118
249
  if (filters?.since) {
@@ -120,7 +251,7 @@ export class EventStore {
120
251
  params.push(filters.since);
121
252
  }
122
253
 
123
- let sql = `SELECT id, session_id AS sessionId, category, data, priority, source, timestamp FROM session_events WHERE ${conditions.join(" AND ")} ORDER BY timestamp DESC`;
254
+ let sql = `SELECT id, session_id AS sessionId, category, data, priority, source, timestamp, data_hash AS dataHash FROM session_events WHERE ${conditions.join(" AND ")} ORDER BY timestamp DESC`;
124
255
 
125
256
  if (filters?.limit) {
126
257
  sql += " LIMIT ?";
@@ -132,7 +263,8 @@ export class EventStore {
132
263
 
133
264
  searchEvents(sessionId: string, query: string, limit = 20): TrackedEvent[] {
134
265
  const sql = `
135
- SELECT e.id, e.session_id AS sessionId, e.category, e.data, e.priority, e.source, e.timestamp
266
+ SELECT e.id, e.session_id AS sessionId, e.category, e.data, e.priority,
267
+ e.source, e.timestamp, e.data_hash AS dataHash
136
268
  FROM session_events_fts fts
137
269
  JOIN session_events e ON e.id = fts.rowid
138
270
  WHERE fts.data MATCH ? AND e.session_id = ?
@@ -153,14 +285,91 @@ export class EventStore {
153
285
  return counts;
154
286
  }
155
287
 
288
+ // ── Session metadata ────────────────────────────────────────
289
+
290
+ upsertMeta(sessionId: string, projectDir: string): void {
291
+ this.db.prepare(
292
+ `INSERT INTO session_meta (session_id, project_dir, event_count)
293
+ VALUES (?, ?, 0)
294
+ ON CONFLICT(session_id) DO UPDATE SET
295
+ last_event_at = datetime('now'),
296
+ event_count = event_count + 1`,
297
+ ).run(sessionId, projectDir);
298
+ }
299
+
300
+ getMeta(sessionId: string): SessionMeta | null {
301
+ const row = this.db.prepare(
302
+ `SELECT session_id AS sessionId, project_dir AS projectDir,
303
+ started_at AS startedAt, last_event_at AS lastEventAt,
304
+ event_count AS eventCount, compact_count AS compactCount
305
+ FROM session_meta WHERE session_id = ?`,
306
+ ).get(sessionId) as SessionMeta | undefined;
307
+ return row ?? null;
308
+ }
309
+
310
+ incrementCompactCount(sessionId: string): void {
311
+ this.db.prepare(
312
+ "UPDATE session_meta SET compact_count = compact_count + 1 WHERE session_id = ?",
313
+ ).run(sessionId);
314
+ }
315
+
316
+ // ── Resume snapshots ────────────────────────────────────────
317
+
318
+ upsertResume(sessionId: string, snapshot: string, eventCount: number): void {
319
+ this.db.prepare(
320
+ `INSERT INTO session_resume (session_id, snapshot, event_count, consumed)
321
+ VALUES (?, ?, ?, 0)
322
+ ON CONFLICT(session_id) DO UPDATE SET
323
+ snapshot = excluded.snapshot,
324
+ event_count = excluded.event_count,
325
+ created_at = datetime('now'),
326
+ consumed = 0`,
327
+ ).run(sessionId, snapshot, eventCount);
328
+ }
329
+
330
+ getResume(sessionId: string): SessionResume | null {
331
+ const row = this.db.prepare(
332
+ `SELECT session_id AS sessionId, snapshot, event_count AS eventCount,
333
+ created_at AS createdAt, consumed
334
+ FROM session_resume WHERE session_id = ? AND consumed = 0`,
335
+ ).get(sessionId) as (Omit<SessionResume, "consumed"> & { consumed: number }) | undefined;
336
+ if (!row) return null;
337
+ return { ...row, consumed: row.consumed !== 0 };
338
+ }
339
+
340
+ consumeResume(sessionId: string): void {
341
+ this.db.prepare(
342
+ "UPDATE session_resume SET consumed = 1 WHERE session_id = ?",
343
+ ).run(sessionId);
344
+ }
345
+
346
+ // ── Maintenance ─────────────────────────────────────────────
347
+
156
348
  pruneEvents(olderThan: number): number {
157
- const countRow = this.db.prepare("SELECT COUNT(*) AS count FROM session_events WHERE timestamp < ?").get(olderThan) as { count: number };
349
+ const countRow = this.db.prepare(
350
+ "SELECT COUNT(*) AS count FROM session_events WHERE timestamp < ?",
351
+ ).get(olderThan) as { count: number };
158
352
  if (countRow.count > 0) {
159
353
  this.db.prepare("DELETE FROM session_events WHERE timestamp < ?").run(olderThan);
160
354
  }
161
355
  return countRow.count;
162
356
  }
163
357
 
358
+ pruneOldSessions(retentionDays = 7): number {
359
+ const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
360
+ const pruned = this.pruneEvents(cutoff);
361
+ // Remove orphaned metadata for sessions with no remaining events
362
+ this.db.exec(`
363
+ DELETE FROM session_meta WHERE session_id NOT IN (
364
+ SELECT DISTINCT session_id FROM session_events
365
+ );
366
+ DELETE FROM session_resume WHERE session_id NOT IN (
367
+ SELECT DISTINCT session_id FROM session_events
368
+ );
369
+ `);
370
+ return pruned;
371
+ }
372
+
164
373
  close(): void {
165
374
  this.db.close();
166
375
  }
@@ -106,22 +106,77 @@ export function registerContextModeHooks(platform: Platform, config: SupipowersC
106
106
 
107
107
  // Phase 3: Compaction integration
108
108
  if (config.contextMode.compaction && eventStore) {
109
- let pendingSnapshot: string | null = null;
109
+ // Initialize session metadata for compact tracking
110
+ try {
111
+ eventStore.upsertMeta(sessionId, process.cwd());
112
+ } catch {
113
+ // Non-fatal: metadata is supplementary
114
+ }
110
115
 
111
116
  platform.on("session_before_compact", () => {
117
+ // Re-detect MCP tools: they may have loaded since init
118
+ const status = cachedStatus ?? detectContextMode(platform.getActiveTools());
119
+ const searchAvailable = status.tools.ctxSearch;
120
+
121
+ // Determine the search tool name for reference-based snapshots
122
+ let searchTool: string | undefined;
123
+ if (searchAvailable) {
124
+ const tools = platform.getActiveTools();
125
+ searchTool = tools.find((t) => t.includes("ctx_search"));
126
+ }
127
+
128
+ // Read compact count from metadata
129
+ let compactCount = 0;
112
130
  try {
113
- pendingSnapshot = buildResumeSnapshot(eventStore!, sessionId);
131
+ const meta = eventStore!.getMeta(sessionId);
132
+ compactCount = meta?.compactCount ?? 0;
133
+ } catch {
134
+ // Non-fatal
135
+ }
136
+
137
+ try {
138
+ const snapshot = buildResumeSnapshot(eventStore!, sessionId, {
139
+ compactCount,
140
+ searchTool,
141
+ searchAvailable,
142
+ });
143
+
144
+ // Persist to DB so it survives crashes
145
+ if (snapshot) {
146
+ const eventCount = Object.values(eventStore!.getEventCounts(sessionId))
147
+ .reduce((a, b) => a + b, 0);
148
+ eventStore!.upsertResume(sessionId, snapshot, eventCount);
149
+ }
150
+
151
+ return undefined; // don't cancel or replace compaction
114
152
  } catch (e) {
115
153
  (platform as any).logger?.warn?.("context-mode: snapshot build failed", e);
116
- pendingSnapshot = null;
154
+ return undefined;
117
155
  }
118
- return undefined; // don't cancel or replace compaction
119
156
  });
120
157
 
121
158
  platform.on("session_compact", () => {
122
- if (!pendingSnapshot) return undefined;
123
- const snapshot = pendingSnapshot;
124
- pendingSnapshot = null;
159
+ // Try resume from DB first, fall back to in-memory
160
+ let snapshot: string | null = null;
161
+ try {
162
+ const resume = eventStore!.getResume(sessionId);
163
+ if (resume) {
164
+ snapshot = resume.snapshot;
165
+ eventStore!.consumeResume(sessionId);
166
+ }
167
+ } catch {
168
+ // Non-fatal: fall through
169
+ }
170
+
171
+ if (!snapshot) return undefined;
172
+
173
+ // Track compaction count in session metadata
174
+ try {
175
+ eventStore!.incrementCompactCount(sessionId);
176
+ } catch {
177
+ // Non-fatal
178
+ }
179
+
125
180
  return {
126
181
  context: snapshot.split("\n"),
127
182
  preserveData: {
@@ -7,7 +7,13 @@ const HTTP_PATTERNS = [
7
7
  /^\s*wget\s/,
8
8
  /\bcurl\s+(-[a-zA-Z]*\s+)*https?:\/\//,
9
9
  /\bwget\s+(-[a-zA-Z]*\s+)*https?:\/\//,
10
- ];
10
+ // Inline HTTP patterns
11
+ /\bfetch\s*\(/,
12
+ /\brequests\.(get|post|put|delete|patch)\s*\(/,
13
+ /\bhttp\.(get|request)\s*\(/,
14
+ /\burllib\.request/,
15
+ /\bInvoke-WebRequest/,
16
+ ];
11
17
 
12
18
  /** Bash commands that are search/find operations */
13
19
  const BASH_SEARCH_PATTERNS = [
@@ -41,10 +47,10 @@ export function isBashSearchCommand(command: unknown): boolean {
41
47
  return BASH_SEARCH_PATTERNS.some((p) => p.test(command));
42
48
  }
43
49
 
44
- /** Check if a Read call is a full-file read (no limit/offset = likely analysis, not edit prep) */
50
+ /** Check if a Read call is a full-file read (no limit/offset/sel = likely analysis, not edit prep) */
45
51
  export function isFullFileRead(input: Record<string, unknown> | undefined): boolean {
46
52
  if (!input) return true;
47
- return input.limit == null && input.offset == null;
53
+ return input.limit == null && input.offset == null && input.sel == null;
48
54
  }
49
55
 
50
56
  /** Block result returned by routing functions */
@@ -95,17 +101,6 @@ export function routeToolCall(
95
101
  };
96
102
  }
97
103
 
98
- // Read (full-file, no limit/offset) → block, redirect to ctx_execute_file
99
- if (options.enforceRouting && toolName === "read") {
100
- if (!status.tools.ctxExecuteFile) return undefined;
101
- if (!isFullFileRead(input)) return undefined;
102
- return {
103
- block: true,
104
- reason:
105
- "Use ctx_execute_file(path, language, code) for file analysis instead of Read. " +
106
- "If you need to Read before editing, re-call with a limit parameter.",
107
- };
108
- }
109
104
 
110
105
  // Bash routing
111
106
  if (toolName === "bash") {