supipowers 0.4.0 → 0.6.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 +3 -3
- package/skills/context-mode/SKILL.md +38 -0
- package/skills/qa-strategy/SKILL.md +103 -21
- package/src/commands/config.ts +23 -2
- package/src/commands/fix-pr.ts +1 -1
- package/src/commands/plan.ts +1 -1
- package/src/commands/qa.ts +232 -148
- package/src/commands/release.ts +1 -1
- package/src/commands/review.ts +1 -1
- package/src/commands/run.ts +9 -4
- package/src/commands/supi.ts +1 -1
- package/src/config/defaults.ts +11 -0
- package/src/config/schema.ts +11 -0
- package/src/context-mode/compressor.ts +200 -0
- package/src/context-mode/detector.ts +43 -0
- package/src/context-mode/event-extractor.ts +170 -0
- package/src/context-mode/event-store.ts +168 -0
- package/src/context-mode/hooks.ts +176 -0
- package/src/context-mode/installer.ts +71 -0
- package/src/context-mode/snapshot-builder.ts +127 -0
- package/src/discipline/debugging.ts +7 -7
- package/src/discipline/receiving-review.ts +5 -5
- package/src/discipline/tdd.ts +2 -2
- package/src/discipline/verification.ts +9 -9
- package/src/git/base-branch.ts +30 -0
- package/src/git/branch-finish.ts +12 -3
- package/src/git/sanitize.ts +19 -0
- package/src/git/worktree.ts +38 -11
- package/src/index.ts +8 -1
- package/src/orchestrator/agent-prompts.ts +15 -7
- package/src/orchestrator/conflict-resolver.ts +3 -2
- package/src/orchestrator/dispatcher.ts +76 -21
- package/src/orchestrator/prompts.ts +46 -6
- package/src/planning/plan-reviewer.ts +1 -1
- package/src/planning/plan-writer-prompt.ts +6 -9
- package/src/planning/prompt-builder.ts +17 -16
- package/src/planning/spec-reviewer.ts +2 -2
- package/src/qa/config.ts +43 -0
- package/src/qa/matrix.ts +84 -0
- package/src/qa/prompt-builder.ts +212 -0
- package/src/qa/scripts/detect-app-type.sh +68 -0
- package/src/qa/scripts/discover-routes.sh +143 -0
- package/src/qa/scripts/ensure-playwright.sh +38 -0
- package/src/qa/scripts/run-e2e-tests.sh +99 -0
- package/src/qa/scripts/start-dev-server.sh +46 -0
- package/src/qa/scripts/stop-dev-server.sh +36 -0
- package/src/qa/session.ts +39 -55
- package/src/qa/types.ts +97 -0
- package/src/storage/qa-sessions.ts +9 -9
- package/src/types.ts +22 -70
- package/src/qa/detector.ts +0 -61
- package/src/qa/phases/discovery.ts +0 -34
- package/src/qa/phases/execution.ts +0 -65
- package/src/qa/phases/matrix.ts +0 -41
- package/src/qa/phases/reporting.ts +0 -71
- package/src/qa/report.ts +0 -22
- package/src/qa/runner.ts +0 -46
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// src/context-mode/compressor.ts
|
|
2
|
+
|
|
3
|
+
interface ToolResultEventLike {
|
|
4
|
+
toolName: string;
|
|
5
|
+
input: Record<string, unknown>;
|
|
6
|
+
content: Array<{ type: string; text?: string }>;
|
|
7
|
+
isError: boolean;
|
|
8
|
+
details: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ToolResultEventResult {
|
|
12
|
+
content?: Array<{ type: string; text: string }>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const BASH_HEAD_LINES = 5;
|
|
16
|
+
const BASH_TAIL_LINES = 10;
|
|
17
|
+
const READ_PREVIEW_LINES = 10;
|
|
18
|
+
const GREP_MAX_MATCHES = 10;
|
|
19
|
+
const FIND_MAX_PATHS = 20;
|
|
20
|
+
|
|
21
|
+
/** Measure total byte length of text content entries */
|
|
22
|
+
function measureTextBytes(content: Array<{ type: string; text?: string }>): number {
|
|
23
|
+
let total = 0;
|
|
24
|
+
for (const entry of content) {
|
|
25
|
+
if (entry.type === "text" && entry.text) {
|
|
26
|
+
total += new TextEncoder().encode(entry.text).byteLength;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return total;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Check if content contains any non-text entries */
|
|
33
|
+
function hasNonTextContent(content: Array<{ type: string }>): boolean {
|
|
34
|
+
return content.some((entry) => entry.type !== "text");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Get combined text from all text content entries */
|
|
38
|
+
function getCombinedText(content: Array<{ type: string; text?: string }>): string {
|
|
39
|
+
return content
|
|
40
|
+
.filter((entry) => entry.type === "text" && entry.text)
|
|
41
|
+
.map((entry) => entry.text!)
|
|
42
|
+
.join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Compress bash tool output */
|
|
46
|
+
function compressBash(text: string, details: unknown): string | undefined {
|
|
47
|
+
const exitCode =
|
|
48
|
+
details && typeof details === "object" && "exitCode" in details
|
|
49
|
+
? (details as { exitCode: number }).exitCode
|
|
50
|
+
: 0;
|
|
51
|
+
|
|
52
|
+
// Non-zero exit: keep full output for debugging
|
|
53
|
+
if (exitCode !== 0) return undefined;
|
|
54
|
+
|
|
55
|
+
const lines = text.split("\n");
|
|
56
|
+
const totalLines = lines.length;
|
|
57
|
+
|
|
58
|
+
if (totalLines <= BASH_HEAD_LINES + BASH_TAIL_LINES) return undefined;
|
|
59
|
+
|
|
60
|
+
const head = lines.slice(0, BASH_HEAD_LINES);
|
|
61
|
+
const tail = lines.slice(-BASH_TAIL_LINES);
|
|
62
|
+
const omitted = totalLines - BASH_HEAD_LINES - BASH_TAIL_LINES;
|
|
63
|
+
|
|
64
|
+
return [
|
|
65
|
+
...head,
|
|
66
|
+
`[...compressed: ${omitted} lines omitted (${totalLines} lines total)...]`,
|
|
67
|
+
...tail,
|
|
68
|
+
].join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Compress read tool output */
|
|
72
|
+
function compressRead(text: string, input: Record<string, unknown>): string | undefined {
|
|
73
|
+
// Scoped reads (offset/limit) are already targeted — pass through
|
|
74
|
+
if (input.offset !== undefined || input.limit !== undefined) return undefined;
|
|
75
|
+
|
|
76
|
+
const lines = text.split("\n");
|
|
77
|
+
const totalLines = lines.length;
|
|
78
|
+
const path = typeof input.path === "string" ? input.path : "unknown";
|
|
79
|
+
|
|
80
|
+
if (totalLines <= READ_PREVIEW_LINES) return undefined;
|
|
81
|
+
|
|
82
|
+
const preview = lines.slice(0, READ_PREVIEW_LINES);
|
|
83
|
+
return [
|
|
84
|
+
`File: ${path} (${totalLines} lines total)`,
|
|
85
|
+
"",
|
|
86
|
+
...preview,
|
|
87
|
+
`[...compressed: remaining ${totalLines - READ_PREVIEW_LINES} lines omitted...]`,
|
|
88
|
+
].join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Compress grep tool output */
|
|
92
|
+
function compressGrep(text: string): string | undefined {
|
|
93
|
+
const lines = text.split("\n").filter((l) => l.length > 0);
|
|
94
|
+
const totalMatches = lines.length;
|
|
95
|
+
|
|
96
|
+
if (totalMatches <= GREP_MAX_MATCHES) return undefined;
|
|
97
|
+
|
|
98
|
+
const kept = lines.slice(0, GREP_MAX_MATCHES);
|
|
99
|
+
return [
|
|
100
|
+
`${totalMatches} matches total, showing first ${GREP_MAX_MATCHES}:`,
|
|
101
|
+
"",
|
|
102
|
+
...kept,
|
|
103
|
+
`[...compressed: ${totalMatches - GREP_MAX_MATCHES} more matches omitted...]`,
|
|
104
|
+
].join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Compress find tool output */
|
|
108
|
+
function compressFind(text: string): string | undefined {
|
|
109
|
+
const lines = text.split("\n").filter((l) => l.length > 0);
|
|
110
|
+
const totalFiles = lines.length;
|
|
111
|
+
|
|
112
|
+
if (totalFiles <= FIND_MAX_PATHS) return undefined;
|
|
113
|
+
|
|
114
|
+
const kept = lines.slice(0, FIND_MAX_PATHS);
|
|
115
|
+
return [
|
|
116
|
+
`${totalFiles} files found, showing first ${FIND_MAX_PATHS}:`,
|
|
117
|
+
"",
|
|
118
|
+
...kept,
|
|
119
|
+
`[...compressed: ${totalFiles - FIND_MAX_PATHS} more files omitted...]`,
|
|
120
|
+
].join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Compress a tool result if it exceeds the threshold */
|
|
124
|
+
export function compressToolResult(
|
|
125
|
+
event: ToolResultEventLike,
|
|
126
|
+
threshold: number,
|
|
127
|
+
): ToolResultEventResult | undefined {
|
|
128
|
+
// General rules: pass through errors, non-text content, and small outputs
|
|
129
|
+
if (event.isError) return undefined;
|
|
130
|
+
if (hasNonTextContent(event.content)) return undefined;
|
|
131
|
+
if (measureTextBytes(event.content) <= threshold) return undefined;
|
|
132
|
+
|
|
133
|
+
const text = getCombinedText(event.content);
|
|
134
|
+
let compressed: string | undefined;
|
|
135
|
+
|
|
136
|
+
switch (event.toolName) {
|
|
137
|
+
case "bash":
|
|
138
|
+
compressed = compressBash(text, event.details);
|
|
139
|
+
break;
|
|
140
|
+
case "read":
|
|
141
|
+
compressed = compressRead(text, event.input);
|
|
142
|
+
break;
|
|
143
|
+
case "grep":
|
|
144
|
+
compressed = compressGrep(text);
|
|
145
|
+
break;
|
|
146
|
+
case "find":
|
|
147
|
+
compressed = compressFind(text);
|
|
148
|
+
break;
|
|
149
|
+
default:
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!compressed) return undefined;
|
|
154
|
+
return { content: [{ type: "text", text: compressed }] };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Summarization prompt templates by tool type */
|
|
158
|
+
const SUMMARIZE_PROMPTS: Record<string, string> = {
|
|
159
|
+
bash: "Summarize this command output. Preserve: exit code, key findings, error messages, file paths mentioned. Be concise (under 200 words).",
|
|
160
|
+
read: "Summarize this file content. Preserve: file structure, key exports/functions, notable patterns. Be concise (under 200 words).",
|
|
161
|
+
grep: "Summarize these search results. Preserve: match count, most relevant matches, file distribution. Be concise (under 200 words).",
|
|
162
|
+
find: "Summarize these file paths. Preserve: directory structure, file count, key patterns. Be concise (under 200 words).",
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Compress with optional LLM summarization for very large outputs */
|
|
166
|
+
export async function compressToolResultWithLLM(
|
|
167
|
+
event: ToolResultEventLike,
|
|
168
|
+
threshold: number,
|
|
169
|
+
llmThreshold: number,
|
|
170
|
+
summarize: (text: string, toolName: string) => Promise<string>,
|
|
171
|
+
): Promise<ToolResultEventResult | undefined> {
|
|
172
|
+
// General rules
|
|
173
|
+
if (event.isError) return undefined;
|
|
174
|
+
if (hasNonTextContent(event.content)) return undefined;
|
|
175
|
+
const byteSize = measureTextBytes(event.content);
|
|
176
|
+
if (byteSize <= threshold) return undefined;
|
|
177
|
+
|
|
178
|
+
const text = getCombinedText(event.content);
|
|
179
|
+
|
|
180
|
+
// Below LLM threshold: use structural compression
|
|
181
|
+
if (byteSize < llmThreshold) {
|
|
182
|
+
return compressToolResult(event, threshold);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Above LLM threshold: try LLM summarization
|
|
186
|
+
try {
|
|
187
|
+
const prompt = SUMMARIZE_PROMPTS[event.toolName] ?? "Summarize this output concisely (under 200 words).";
|
|
188
|
+
const summary = await summarize(`${prompt}\n\n${text}`, event.toolName);
|
|
189
|
+
|
|
190
|
+
// Validate: non-empty and reasonably sized
|
|
191
|
+
if (summary && summary.length >= 50) {
|
|
192
|
+
return { content: [{ type: "text", text: summary }] };
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// Fall through to structural compression
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Fallback
|
|
199
|
+
return compressToolResult(event, threshold);
|
|
200
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// src/context-mode/detector.ts
|
|
2
|
+
|
|
3
|
+
/** Which context-mode MCP tools are available in the current session */
|
|
4
|
+
export interface ContextModeStatus {
|
|
5
|
+
available: boolean;
|
|
6
|
+
tools: {
|
|
7
|
+
ctxExecute: boolean;
|
|
8
|
+
ctxBatchExecute: boolean;
|
|
9
|
+
ctxExecuteFile: boolean;
|
|
10
|
+
ctxIndex: boolean;
|
|
11
|
+
ctxSearch: boolean;
|
|
12
|
+
ctxFetchAndIndex: boolean;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const TOOL_MAP: Record<string, keyof ContextModeStatus["tools"]> = {
|
|
17
|
+
ctx_execute: "ctxExecute",
|
|
18
|
+
ctx_batch_execute: "ctxBatchExecute",
|
|
19
|
+
ctx_execute_file: "ctxExecuteFile",
|
|
20
|
+
ctx_index: "ctxIndex",
|
|
21
|
+
ctx_search: "ctxSearch",
|
|
22
|
+
ctx_fetch_and_index: "ctxFetchAndIndex",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Detect context-mode MCP tool availability from the active tools list */
|
|
26
|
+
export function detectContextMode(activeTools: string[]): ContextModeStatus {
|
|
27
|
+
const tools: ContextModeStatus["tools"] = {
|
|
28
|
+
ctxExecute: false,
|
|
29
|
+
ctxBatchExecute: false,
|
|
30
|
+
ctxExecuteFile: false,
|
|
31
|
+
ctxIndex: false,
|
|
32
|
+
ctxSearch: false,
|
|
33
|
+
ctxFetchAndIndex: false,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
for (const tool of activeTools) {
|
|
37
|
+
const key = TOOL_MAP[tool];
|
|
38
|
+
if (key) tools[key] = true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const available = Object.values(tools).some(Boolean);
|
|
42
|
+
return { available, tools };
|
|
43
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// src/context-mode/event-extractor.ts
|
|
2
|
+
import type { EventCategory, EventPriority, TrackedEvent } from "./event-store.js";
|
|
3
|
+
|
|
4
|
+
type Event = Omit<TrackedEvent, "id">;
|
|
5
|
+
|
|
6
|
+
const GIT_COMMAND_PATTERNS = [
|
|
7
|
+
/^git\s+(commit|merge|rebase|checkout|switch|branch|push|pull|stash|reset|cherry-pick|tag)\b/,
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const DECISION_PATTERNS = [
|
|
11
|
+
/\blet'?s?\s+go\s+with\b/i,
|
|
12
|
+
/\buse\s+\S+\s+instead\s+of\b/i,
|
|
13
|
+
/\bi\s+want\b/i,
|
|
14
|
+
/\bgo\s+ahead\b/i,
|
|
15
|
+
/^(yes|no|yep|nope|sure|ok|okay)\b/i,
|
|
16
|
+
/\bdo\s+that\b/i,
|
|
17
|
+
/\blet'?s?\s+do\b/i,
|
|
18
|
+
/\bpick\s+(option|approach|choice)\b/i,
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function makeEvent(
|
|
22
|
+
sessionId: string,
|
|
23
|
+
category: EventCategory,
|
|
24
|
+
data: Record<string, unknown>,
|
|
25
|
+
priority: EventPriority,
|
|
26
|
+
source: string,
|
|
27
|
+
): Event {
|
|
28
|
+
return {
|
|
29
|
+
sessionId,
|
|
30
|
+
category,
|
|
31
|
+
data: JSON.stringify(data),
|
|
32
|
+
priority,
|
|
33
|
+
source,
|
|
34
|
+
timestamp: Date.now(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getTextContent(content: Array<{ type: string; text?: string }>): string {
|
|
39
|
+
return content
|
|
40
|
+
.filter((c) => c.type === "text" && c.text)
|
|
41
|
+
.map((c) => c.text!)
|
|
42
|
+
.join("\n")
|
|
43
|
+
.slice(0, 500); // Cap for storage
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Extract events from a tool result */
|
|
47
|
+
export function extractEvents(
|
|
48
|
+
event: {
|
|
49
|
+
toolName: string;
|
|
50
|
+
input: Record<string, unknown>;
|
|
51
|
+
content: Array<{ type: string; text?: string }>;
|
|
52
|
+
isError: boolean;
|
|
53
|
+
details: unknown;
|
|
54
|
+
},
|
|
55
|
+
sessionId: string,
|
|
56
|
+
): Event[] {
|
|
57
|
+
const events: Event[] = [];
|
|
58
|
+
const text = getTextContent(event.content);
|
|
59
|
+
|
|
60
|
+
// General rule: emit error event for any isError result
|
|
61
|
+
if (event.isError) {
|
|
62
|
+
events.push(makeEvent(sessionId, "error", {
|
|
63
|
+
toolName: event.toolName,
|
|
64
|
+
content: text,
|
|
65
|
+
}, "critical", "tool_result"));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
switch (event.toolName) {
|
|
69
|
+
case "bash":
|
|
70
|
+
extractBash(events, event, sessionId, text);
|
|
71
|
+
break;
|
|
72
|
+
case "read":
|
|
73
|
+
extractFile(events, event, sessionId, "read");
|
|
74
|
+
break;
|
|
75
|
+
case "edit":
|
|
76
|
+
extractFile(events, event, sessionId, "edit", "high");
|
|
77
|
+
break;
|
|
78
|
+
case "write":
|
|
79
|
+
extractFile(events, event, sessionId, "write", "high");
|
|
80
|
+
break;
|
|
81
|
+
case "grep":
|
|
82
|
+
extractFile(events, event, sessionId, "search");
|
|
83
|
+
break;
|
|
84
|
+
case "find":
|
|
85
|
+
extractFile(events, event, sessionId, "find");
|
|
86
|
+
break;
|
|
87
|
+
case "todo_write":
|
|
88
|
+
events.push(makeEvent(sessionId, "task", {
|
|
89
|
+
input: event.input,
|
|
90
|
+
}, "high", "tool_result"));
|
|
91
|
+
break;
|
|
92
|
+
default:
|
|
93
|
+
if (event.toolName.startsWith("ctx_")) {
|
|
94
|
+
events.push(makeEvent(sessionId, "mcp", {
|
|
95
|
+
tool: event.toolName,
|
|
96
|
+
}, "low", "tool_result"));
|
|
97
|
+
} else if (event.toolName === "task" || event.toolName === "sub_agent") {
|
|
98
|
+
events.push(makeEvent(sessionId, "subagent", {
|
|
99
|
+
toolName: event.toolName,
|
|
100
|
+
input: event.input,
|
|
101
|
+
}, "medium", "tool_result"));
|
|
102
|
+
}
|
|
103
|
+
// Unknown tools: no events
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return events;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function extractBash(
|
|
111
|
+
events: Event[],
|
|
112
|
+
event: { input: Record<string, unknown>; details: unknown },
|
|
113
|
+
sessionId: string,
|
|
114
|
+
text: string,
|
|
115
|
+
): void {
|
|
116
|
+
const command = typeof event.input.command === "string" ? event.input.command : "";
|
|
117
|
+
const exitCode = event.details && typeof event.details === "object" && "exitCode" in event.details
|
|
118
|
+
? (event.details as { exitCode: number }).exitCode
|
|
119
|
+
: 0;
|
|
120
|
+
|
|
121
|
+
// Git operations
|
|
122
|
+
if (GIT_COMMAND_PATTERNS.some((p) => p.test(command))) {
|
|
123
|
+
events.push(makeEvent(sessionId, "git", {
|
|
124
|
+
command,
|
|
125
|
+
output: text,
|
|
126
|
+
}, "high", "tool_result"));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Non-zero exit (in addition to general isError rule)
|
|
130
|
+
if (exitCode !== 0) {
|
|
131
|
+
events.push(makeEvent(sessionId, "error", {
|
|
132
|
+
command,
|
|
133
|
+
exitCode,
|
|
134
|
+
output: text,
|
|
135
|
+
}, "critical", "tool_result"));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Working directory change
|
|
139
|
+
if (/\bcd\s+/.test(command)) {
|
|
140
|
+
events.push(makeEvent(sessionId, "cwd", {
|
|
141
|
+
command,
|
|
142
|
+
}, "low", "tool_result"));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function extractFile(
|
|
147
|
+
events: Event[],
|
|
148
|
+
event: { input: Record<string, unknown> },
|
|
149
|
+
sessionId: string,
|
|
150
|
+
op: string,
|
|
151
|
+
priority: EventPriority = "medium",
|
|
152
|
+
): void {
|
|
153
|
+
const path = typeof event.input.path === "string" ? event.input.path : "unknown";
|
|
154
|
+
events.push(makeEvent(sessionId, "file", { op, path }, priority, "tool_result"));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Extract events from a user prompt (called from before_agent_start handler) */
|
|
158
|
+
export function extractPromptEvents(prompt: string, sessionId: string): Event[] {
|
|
159
|
+
const events: Event[] = [];
|
|
160
|
+
|
|
161
|
+
// Always capture the prompt
|
|
162
|
+
events.push(makeEvent(sessionId, "prompt", { prompt }, "high", "before_agent_start"));
|
|
163
|
+
|
|
164
|
+
// Check for decision patterns
|
|
165
|
+
if (DECISION_PATTERNS.some((p) => p.test(prompt))) {
|
|
166
|
+
events.push(makeEvent(sessionId, "decision", { prompt }, "high", "before_agent_start"));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return events;
|
|
170
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// src/context-mode/event-store.ts
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
|
|
4
|
+
/** Event categories extracted from tool results */
|
|
5
|
+
export type EventCategory =
|
|
6
|
+
| "file"
|
|
7
|
+
| "git"
|
|
8
|
+
| "error"
|
|
9
|
+
| "task"
|
|
10
|
+
| "cwd"
|
|
11
|
+
| "mcp"
|
|
12
|
+
| "subagent"
|
|
13
|
+
| "prompt"
|
|
14
|
+
| "decision";
|
|
15
|
+
|
|
16
|
+
/** Priority levels for resume snapshot ordering */
|
|
17
|
+
export type EventPriority = "critical" | "high" | "medium" | "low";
|
|
18
|
+
|
|
19
|
+
/** A tracked event */
|
|
20
|
+
export interface TrackedEvent {
|
|
21
|
+
id?: number;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
category: EventCategory;
|
|
24
|
+
data: string;
|
|
25
|
+
priority: EventPriority;
|
|
26
|
+
source: string;
|
|
27
|
+
timestamp: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SCHEMA = `
|
|
31
|
+
CREATE TABLE IF NOT EXISTS session_events (
|
|
32
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
33
|
+
session_id TEXT NOT NULL,
|
|
34
|
+
category TEXT NOT NULL,
|
|
35
|
+
data TEXT NOT NULL,
|
|
36
|
+
priority TEXT NOT NULL DEFAULT 'medium',
|
|
37
|
+
source TEXT NOT NULL,
|
|
38
|
+
timestamp INTEGER NOT NULL
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_events_session ON session_events(session_id, timestamp);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_events_category ON session_events(session_id, category);
|
|
43
|
+
|
|
44
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS session_events_fts USING fts5(
|
|
45
|
+
data,
|
|
46
|
+
content=session_events,
|
|
47
|
+
content_rowid=id
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE TRIGGER IF NOT EXISTS events_ai AFTER INSERT ON session_events BEGIN
|
|
51
|
+
INSERT INTO session_events_fts(rowid, data) VALUES (new.id, new.data);
|
|
52
|
+
END;
|
|
53
|
+
|
|
54
|
+
CREATE TRIGGER IF NOT EXISTS events_ad AFTER DELETE ON session_events BEGIN
|
|
55
|
+
INSERT INTO session_events_fts(session_events_fts, rowid, data) VALUES ('delete', old.id, old.data);
|
|
56
|
+
END;
|
|
57
|
+
|
|
58
|
+
CREATE TRIGGER IF NOT EXISTS events_au AFTER UPDATE ON session_events BEGIN
|
|
59
|
+
INSERT INTO session_events_fts(session_events_fts, rowid, data) VALUES ('delete', old.id, old.data);
|
|
60
|
+
INSERT INTO session_events_fts(rowid, data) VALUES (new.id, new.data);
|
|
61
|
+
END;
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
const ALL_CATEGORIES: EventCategory[] = [
|
|
65
|
+
"file", "git", "error", "task", "cwd", "mcp", "subagent", "prompt", "decision",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export class EventStore {
|
|
69
|
+
private db: Database;
|
|
70
|
+
|
|
71
|
+
constructor(dbPath: string) {
|
|
72
|
+
this.db = new Database(dbPath);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
init(): void {
|
|
76
|
+
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
77
|
+
this.db.exec(SCHEMA);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
writeEvent(event: Omit<TrackedEvent, "id">): void {
|
|
81
|
+
this.db.run(
|
|
82
|
+
"INSERT INTO session_events (session_id, category, data, priority, source, timestamp) VALUES (?, ?, ?, ?, ?, ?)",
|
|
83
|
+
[event.sessionId, event.category, event.data, event.priority, event.source, event.timestamp],
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
writeEvents(events: Omit<TrackedEvent, "id">[]): void {
|
|
88
|
+
const insert = this.db.prepare(
|
|
89
|
+
"INSERT INTO session_events (session_id, category, data, priority, source, timestamp) VALUES (?, ?, ?, ?, ?, ?)",
|
|
90
|
+
);
|
|
91
|
+
const tx = this.db.transaction(() => {
|
|
92
|
+
for (const event of events) {
|
|
93
|
+
insert.run(event.sessionId, event.category, event.data, event.priority, event.source, event.timestamp);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
tx();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
getEvents(
|
|
100
|
+
sessionId: string,
|
|
101
|
+
filters?: {
|
|
102
|
+
categories?: EventCategory[];
|
|
103
|
+
priority?: EventPriority;
|
|
104
|
+
since?: number;
|
|
105
|
+
limit?: number;
|
|
106
|
+
},
|
|
107
|
+
): TrackedEvent[] {
|
|
108
|
+
const conditions = ["session_id = ?"];
|
|
109
|
+
const params: (string | number)[] = [sessionId];
|
|
110
|
+
|
|
111
|
+
if (filters?.categories?.length) {
|
|
112
|
+
conditions.push(`category IN (${filters.categories.map(() => "?").join(",")})`);
|
|
113
|
+
params.push(...filters.categories);
|
|
114
|
+
}
|
|
115
|
+
if (filters?.priority) {
|
|
116
|
+
conditions.push("priority = ?");
|
|
117
|
+
params.push(filters.priority);
|
|
118
|
+
}
|
|
119
|
+
if (filters?.since) {
|
|
120
|
+
conditions.push("timestamp > ?");
|
|
121
|
+
params.push(filters.since);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let sql = `SELECT id, session_id AS sessionId, category, data, priority, source, timestamp FROM session_events WHERE ${conditions.join(" AND ")} ORDER BY timestamp DESC`;
|
|
125
|
+
|
|
126
|
+
if (filters?.limit) {
|
|
127
|
+
sql += " LIMIT ?";
|
|
128
|
+
params.push(filters.limit);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return this.db.prepare(sql).all(...params) as TrackedEvent[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
searchEvents(sessionId: string, query: string, limit = 20): TrackedEvent[] {
|
|
135
|
+
const sql = `
|
|
136
|
+
SELECT e.id, e.session_id AS sessionId, e.category, e.data, e.priority, e.source, e.timestamp
|
|
137
|
+
FROM session_events_fts fts
|
|
138
|
+
JOIN session_events e ON e.id = fts.rowid
|
|
139
|
+
WHERE fts.data MATCH ? AND e.session_id = ?
|
|
140
|
+
ORDER BY rank
|
|
141
|
+
LIMIT ?
|
|
142
|
+
`;
|
|
143
|
+
return this.db.prepare(sql).all(query, sessionId, limit) as TrackedEvent[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
getEventCounts(sessionId: string): Record<EventCategory, number> {
|
|
147
|
+
const rows = this.db.prepare(
|
|
148
|
+
"SELECT category, COUNT(*) AS count FROM session_events WHERE session_id = ? GROUP BY category",
|
|
149
|
+
).all(sessionId) as Array<{ category: EventCategory; count: number }>;
|
|
150
|
+
|
|
151
|
+
const counts = {} as Record<EventCategory, number>;
|
|
152
|
+
for (const cat of ALL_CATEGORIES) counts[cat] = 0;
|
|
153
|
+
for (const row of rows) counts[row.category] = row.count;
|
|
154
|
+
return counts;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
pruneEvents(olderThan: number): number {
|
|
158
|
+
const countRow = this.db.prepare("SELECT COUNT(*) AS count FROM session_events WHERE timestamp < ?").get(olderThan) as { count: number };
|
|
159
|
+
if (countRow.count > 0) {
|
|
160
|
+
this.db.prepare("DELETE FROM session_events WHERE timestamp < ?").run(olderThan);
|
|
161
|
+
}
|
|
162
|
+
return countRow.count;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
close(): void {
|
|
166
|
+
this.db.close();
|
|
167
|
+
}
|
|
168
|
+
}
|