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.
- package/package.json +1 -1
- package/skills/context-mode/SKILL.md +3 -7
- package/src/bootstrap.ts +3 -0
- package/src/commands/optimize-context.ts +202 -0
- package/src/commands/supi.ts +1 -0
- package/src/context/analyzer.ts +57 -0
- package/src/context/optimizer.ts +199 -0
- package/src/context-mode/compressor.ts +14 -11
- package/src/context-mode/event-extractor.ts +45 -16
- package/src/context-mode/event-store.ts +225 -16
- package/src/context-mode/hooks.ts +62 -7
- package/src/context-mode/routing.ts +9 -14
- package/src/context-mode/snapshot-builder.ts +243 -7
|
@@ -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
|
-
},
|
|
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",
|
|
84
|
+
extractFile(events, event, sessionId, "edit", PRIORITY.high);
|
|
77
85
|
break;
|
|
78
86
|
case "write":
|
|
79
|
-
extractFile(events, event, sessionId, "write",
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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 =
|
|
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 },
|
|
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 },
|
|
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
|
-
/**
|
|
17
|
-
export type EventPriority =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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") {
|