@lovenyberg/ove 0.2.2 → 0.4.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/.claude/skills/create-issue/SKILL.md +24 -0
- package/.claude/skills/review-pr/SKILL.md +37 -0
- package/.claude/skills/ship/SKILL.md +22 -0
- package/.env.example +5 -0
- package/CLAUDE.md +20 -0
- package/README.md +93 -17
- package/docs/examples.md +11 -52
- package/docs/index.html +40 -2
- package/docs/plans/2026-02-23-conversation-repo-memory.md +272 -0
- package/package.json +1 -1
- package/public/favicon.ico +0 -0
- package/public/index.html +424 -36
- package/public/logo.png +0 -0
- package/public/status.html +519 -0
- package/public/trace.html +973 -0
- package/src/adapters/cli.ts +16 -1
- package/src/adapters/debounce.test.ts +57 -0
- package/src/adapters/debounce.ts +42 -0
- package/src/adapters/discord.ts +18 -13
- package/src/adapters/github.ts +38 -1
- package/src/adapters/http.test.ts +7 -1
- package/src/adapters/http.ts +227 -47
- package/src/adapters/slack.ts +18 -13
- package/src/adapters/telegram.ts +22 -20
- package/src/adapters/types.ts +11 -0
- package/src/adapters/whatsapp.ts +40 -2
- package/src/config.ts +4 -1
- package/src/flows.test.ts +126 -0
- package/src/handlers.ts +571 -0
- package/src/index.ts +85 -649
- package/src/queue.ts +44 -11
- package/src/repo-registry.ts +23 -5
- package/src/router.ts +8 -2
- package/src/runners/claude.ts +27 -6
- package/src/runners/codex.ts +23 -2
- package/src/schedules.ts +13 -3
- package/src/sessions.ts +8 -2
- package/src/trace.ts +71 -0
- package/src/worker.ts +235 -0
package/src/queue.ts
CHANGED
|
@@ -19,6 +19,18 @@ export interface Task {
|
|
|
19
19
|
completedAt: string | null;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
interface TaskRow {
|
|
23
|
+
id: string;
|
|
24
|
+
user_id: string;
|
|
25
|
+
repo: string;
|
|
26
|
+
prompt: string;
|
|
27
|
+
status: string;
|
|
28
|
+
result: string | null;
|
|
29
|
+
task_type: string | null;
|
|
30
|
+
created_at: string;
|
|
31
|
+
completed_at: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
export class TaskQueue {
|
|
23
35
|
private db: Database;
|
|
24
36
|
|
|
@@ -38,10 +50,9 @@ export class TaskQueue {
|
|
|
38
50
|
)
|
|
39
51
|
`);
|
|
40
52
|
// Migration: add task_type column if missing (backward compat)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
// Column already exists
|
|
53
|
+
const columns = this.db.query("PRAGMA table_info(tasks)").all() as { name: string }[];
|
|
54
|
+
if (!columns.some(c => c.name === "task_type")) {
|
|
55
|
+
this.db.run("ALTER TABLE tasks ADD COLUMN task_type TEXT");
|
|
45
56
|
}
|
|
46
57
|
}
|
|
47
58
|
|
|
@@ -64,7 +75,7 @@ export class TaskQueue {
|
|
|
64
75
|
ORDER BY created_at ASC
|
|
65
76
|
LIMIT 1`
|
|
66
77
|
)
|
|
67
|
-
.get() as
|
|
78
|
+
.get() as TaskRow;
|
|
68
79
|
|
|
69
80
|
if (!row) return null;
|
|
70
81
|
|
|
@@ -88,7 +99,7 @@ export class TaskQueue {
|
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
get(id: string): Task | null {
|
|
91
|
-
const row = this.db.query(`SELECT * FROM tasks WHERE id = ?`).get(id) as
|
|
102
|
+
const row = this.db.query(`SELECT * FROM tasks WHERE id = ?`).get(id) as TaskRow;
|
|
92
103
|
return row ? this.rowToTask(row) : null;
|
|
93
104
|
}
|
|
94
105
|
|
|
@@ -97,7 +108,7 @@ export class TaskQueue {
|
|
|
97
108
|
.query(
|
|
98
109
|
`SELECT * FROM tasks WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`
|
|
99
110
|
)
|
|
100
|
-
.all(userId, limit) as
|
|
111
|
+
.all(userId, limit) as TaskRow[];
|
|
101
112
|
return rows.map((r) => this.rowToTask(r));
|
|
102
113
|
}
|
|
103
114
|
|
|
@@ -111,7 +122,7 @@ export class TaskQueue {
|
|
|
111
122
|
COUNT(*) FILTER (WHERE status = 'failed') as failed
|
|
112
123
|
FROM tasks`
|
|
113
124
|
)
|
|
114
|
-
.get() as
|
|
125
|
+
.get() as { pending: number; running: number; completed: number; failed: number };
|
|
115
126
|
return row;
|
|
116
127
|
}
|
|
117
128
|
|
|
@@ -120,7 +131,7 @@ export class TaskQueue {
|
|
|
120
131
|
.query(
|
|
121
132
|
`SELECT * FROM tasks WHERE status IN ('running', 'pending') ORDER BY created_at ASC LIMIT ?`
|
|
122
133
|
)
|
|
123
|
-
.all(limit) as
|
|
134
|
+
.all(limit) as TaskRow[];
|
|
124
135
|
return rows.map((r) => this.rowToTask(r));
|
|
125
136
|
}
|
|
126
137
|
|
|
@@ -132,6 +143,28 @@ export class TaskQueue {
|
|
|
132
143
|
return result.changes > 0;
|
|
133
144
|
}
|
|
134
145
|
|
|
146
|
+
listRecentFailed(limit: number = 20): Task[] {
|
|
147
|
+
const rows = this.db
|
|
148
|
+
.query(
|
|
149
|
+
`SELECT * FROM tasks WHERE status = 'failed' ORDER BY completed_at DESC LIMIT ?`
|
|
150
|
+
)
|
|
151
|
+
.all(limit) as TaskRow[];
|
|
152
|
+
return rows.map((r) => this.rowToTask(r));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
listRecent(limit: number = 20, status?: string): Task[] {
|
|
156
|
+
let sql = `SELECT * FROM tasks`;
|
|
157
|
+
const params: (string | number)[] = [];
|
|
158
|
+
if (status) {
|
|
159
|
+
sql += ` WHERE status = ?`;
|
|
160
|
+
params.push(status);
|
|
161
|
+
}
|
|
162
|
+
sql += ` ORDER BY created_at DESC LIMIT ?`;
|
|
163
|
+
params.push(limit);
|
|
164
|
+
const rows = this.db.query(sql).all(...params) as TaskRow[];
|
|
165
|
+
return rows.map((r) => this.rowToTask(r));
|
|
166
|
+
}
|
|
167
|
+
|
|
135
168
|
resetStale(): number {
|
|
136
169
|
const result = this.db.run(
|
|
137
170
|
`UPDATE tasks SET status = 'failed', result = 'Interrupted — process restarted', completed_at = ? WHERE status = 'running'`,
|
|
@@ -140,13 +173,13 @@ export class TaskQueue {
|
|
|
140
173
|
return result.changes;
|
|
141
174
|
}
|
|
142
175
|
|
|
143
|
-
private rowToTask(row:
|
|
176
|
+
private rowToTask(row: TaskRow): Task {
|
|
144
177
|
return {
|
|
145
178
|
id: row.id,
|
|
146
179
|
userId: row.user_id,
|
|
147
180
|
repo: row.repo,
|
|
148
181
|
prompt: row.prompt,
|
|
149
|
-
status: row.status,
|
|
182
|
+
status: row.status as "pending" | "running" | "completed" | "failed",
|
|
150
183
|
result: row.result,
|
|
151
184
|
taskType: row.task_type || null,
|
|
152
185
|
createdAt: row.created_at,
|
package/src/repo-registry.ts
CHANGED
|
@@ -11,6 +11,16 @@ export interface RepoRecord {
|
|
|
11
11
|
lastSyncedAt: string | null;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
interface RepoRow {
|
|
15
|
+
name: string;
|
|
16
|
+
url: string;
|
|
17
|
+
owner: string | null;
|
|
18
|
+
default_branch: string;
|
|
19
|
+
source: string;
|
|
20
|
+
excluded: number;
|
|
21
|
+
last_synced_at: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
export interface RepoUpsertInput {
|
|
15
25
|
name: string;
|
|
16
26
|
url: string;
|
|
@@ -62,17 +72,17 @@ export class RepoRegistry {
|
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
getByName(name: string): RepoRecord | null {
|
|
65
|
-
const row = this.db.query(`SELECT * FROM repos WHERE name = ?`).get(name) as
|
|
75
|
+
const row = this.db.query(`SELECT * FROM repos WHERE name = ?`).get(name) as RepoRow;
|
|
66
76
|
return row ? this.rowToRecord(row) : null;
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
getAll(): RepoRecord[] {
|
|
70
|
-
const rows = this.db.query(`SELECT * FROM repos WHERE excluded = 0 ORDER BY name`).all() as
|
|
80
|
+
const rows = this.db.query(`SELECT * FROM repos WHERE excluded = 0 ORDER BY name`).all() as RepoRow[];
|
|
71
81
|
return rows.map(r => this.rowToRecord(r));
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
getAllNames(): string[] {
|
|
75
|
-
const rows = this.db.query(`SELECT name FROM repos WHERE excluded = 0 ORDER BY name`).all() as
|
|
85
|
+
const rows = this.db.query(`SELECT name FROM repos WHERE excluded = 0 ORDER BY name`).all() as { name: string }[];
|
|
76
86
|
return rows.map(r => r.name);
|
|
77
87
|
}
|
|
78
88
|
|
|
@@ -94,7 +104,7 @@ export class RepoRegistry {
|
|
|
94
104
|
}
|
|
95
105
|
}
|
|
96
106
|
|
|
97
|
-
private rowToRecord(row:
|
|
107
|
+
private rowToRecord(row: RepoRow): RepoRecord {
|
|
98
108
|
return {
|
|
99
109
|
name: row.name,
|
|
100
110
|
url: row.url,
|
|
@@ -124,6 +134,13 @@ export function parseGhRepoLine(line: string): GhRepoParsed | null {
|
|
|
124
134
|
return { name, owner, fullName };
|
|
125
135
|
}
|
|
126
136
|
|
|
137
|
+
interface GhRepoResponse {
|
|
138
|
+
name: string;
|
|
139
|
+
owner?: { login: string };
|
|
140
|
+
defaultBranchRef?: { name: string };
|
|
141
|
+
isArchived: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
127
144
|
export async function syncGitHub(
|
|
128
145
|
registry: RepoRegistry,
|
|
129
146
|
orgs?: string[]
|
|
@@ -150,10 +167,11 @@ export async function syncGitHub(
|
|
|
150
167
|
continue;
|
|
151
168
|
}
|
|
152
169
|
|
|
153
|
-
let repos:
|
|
170
|
+
let repos: GhRepoResponse[];
|
|
154
171
|
try {
|
|
155
172
|
repos = JSON.parse(output);
|
|
156
173
|
} catch {
|
|
174
|
+
// JSON parsing failed — log and skip this org
|
|
157
175
|
logger.warn("gh repo list returned invalid JSON", { org });
|
|
158
176
|
continue;
|
|
159
177
|
}
|
package/src/router.ts
CHANGED
|
@@ -15,7 +15,8 @@ export type MessageType =
|
|
|
15
15
|
| "list-schedules"
|
|
16
16
|
| "remove-schedule"
|
|
17
17
|
| "list-tasks"
|
|
18
|
-
| "cancel-task"
|
|
18
|
+
| "cancel-task"
|
|
19
|
+
| "trace";
|
|
19
20
|
|
|
20
21
|
export interface ParsedMessage {
|
|
21
22
|
type: MessageType;
|
|
@@ -45,6 +46,9 @@ export function parseMessage(text: string): ParsedMessage {
|
|
|
45
46
|
const cancelMatch = trimmed.match(/^(?:\/)?cancel\s+(\S+)$/i);
|
|
46
47
|
if (cancelMatch) return { type: "cancel-task", args: { taskId: cancelMatch[1] }, rawText: trimmed };
|
|
47
48
|
|
|
49
|
+
const traceMatch = trimmed.match(/^(?:\/)?trace(?:\s+(\S+))?$/i);
|
|
50
|
+
if (traceMatch) return { type: "trace", args: { taskId: traceMatch[1] }, rawText: trimmed };
|
|
51
|
+
|
|
48
52
|
// Natural language status inquiries — short messages asking about progress
|
|
49
53
|
if (isStatusInquiry(lower)) return { type: "status", args: {}, rawText: trimmed };
|
|
50
54
|
|
|
@@ -142,6 +146,8 @@ export function buildCronPrompt(prompt: string): string {
|
|
|
142
146
|
return `This is an autonomous scheduled task. Do not ask questions — make your own decisions and proceed with the work. If there are multiple options, pick the best one and go.\n\n${prompt}`;
|
|
143
147
|
}
|
|
144
148
|
|
|
149
|
+
const CHAT_PIPELINE_HINT = "You are running in a chat pipeline — your text output is sent to the user via a messaging app. Do NOT use AskUserQuestion or other interactive CLI tools (they don't work here). If you need to ask the user something, include the question and numbered options directly in your text response — the user will reply in chat and you'll see their answer in the next message.";
|
|
150
|
+
|
|
145
151
|
export function buildContextualPrompt(
|
|
146
152
|
parsed: ParsedMessage,
|
|
147
153
|
history: { role: string; content: string }[],
|
|
@@ -152,7 +158,7 @@ export function buildContextualPrompt(
|
|
|
152
158
|
history.slice(0, -1).map((m) => `${m.role}: ${m.content}`).join("\n") +
|
|
153
159
|
"\n\nCurrent request:\n"
|
|
154
160
|
: "";
|
|
155
|
-
return persona + "\n\n" + contextPrefix + buildPrompt(parsed);
|
|
161
|
+
return persona + "\n\n" + CHAT_PIPELINE_HINT + "\n\n" + contextPrefix + buildPrompt(parsed);
|
|
156
162
|
}
|
|
157
163
|
|
|
158
164
|
export function buildPrompt(parsed: ParsedMessage): string {
|
package/src/runners/claude.ts
CHANGED
|
@@ -3,6 +3,20 @@ import { logger } from "../logger";
|
|
|
3
3
|
import { which } from "bun";
|
|
4
4
|
import { realpathSync } from "node:fs";
|
|
5
5
|
|
|
6
|
+
const TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
7
|
+
|
|
8
|
+
function withTimeout(proc: { exited: Promise<number>; kill(): void }): Promise<number | "timeout"> {
|
|
9
|
+
return Promise.race([
|
|
10
|
+
proc.exited,
|
|
11
|
+
new Promise<"timeout">((resolve) =>
|
|
12
|
+
setTimeout(() => {
|
|
13
|
+
proc.kill();
|
|
14
|
+
resolve("timeout");
|
|
15
|
+
}, TIMEOUT_MS)
|
|
16
|
+
),
|
|
17
|
+
]);
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
export function summarizeToolInput(name: string, input: any): string {
|
|
7
21
|
if (!input) return "";
|
|
8
22
|
switch (name) {
|
|
@@ -36,7 +50,7 @@ export class ClaudeRunner implements AgentRunner {
|
|
|
36
50
|
|
|
37
51
|
buildArgs(prompt: string, opts: RunOptions): string[] {
|
|
38
52
|
// stream-json requires --verbose in claude CLI
|
|
39
|
-
const args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--max-turns", String(opts.maxTurns), "--dangerously-skip-permissions"];
|
|
53
|
+
const args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--max-turns", String(opts.maxTurns), "--dangerously-skip-permissions", "--disallowed-tools", "AskUserQuestion"];
|
|
40
54
|
if (opts.mcpConfigPath) args.push("--mcp-config", opts.mcpConfigPath);
|
|
41
55
|
return args;
|
|
42
56
|
}
|
|
@@ -58,7 +72,7 @@ export class ClaudeRunner implements AgentRunner {
|
|
|
58
72
|
}
|
|
59
73
|
|
|
60
74
|
let resultText: string | null = null;
|
|
61
|
-
|
|
75
|
+
const textBlocks: string[] = [];
|
|
62
76
|
const decoder = new TextDecoder();
|
|
63
77
|
const reader = proc.stdout.getReader();
|
|
64
78
|
try {
|
|
@@ -76,7 +90,7 @@ export class ClaudeRunner implements AgentRunner {
|
|
|
76
90
|
if (msg.type === "assistant" && msg.message?.content) {
|
|
77
91
|
for (const block of msg.message.content) {
|
|
78
92
|
if (block.type === "text") {
|
|
79
|
-
|
|
93
|
+
textBlocks.push(block.text);
|
|
80
94
|
if (onStatus) onStatus({ kind: "text", text: block.text });
|
|
81
95
|
}
|
|
82
96
|
if (block.type === "tool_use" && onStatus) {
|
|
@@ -88,23 +102,30 @@ export class ClaudeRunner implements AgentRunner {
|
|
|
88
102
|
}
|
|
89
103
|
}
|
|
90
104
|
}
|
|
91
|
-
} catch {
|
|
105
|
+
} catch {
|
|
106
|
+
// Non-JSON line in stream output — skip
|
|
107
|
+
}
|
|
92
108
|
}
|
|
93
109
|
}
|
|
94
110
|
} finally {
|
|
95
111
|
reader.releaseLock();
|
|
96
112
|
}
|
|
97
113
|
|
|
98
|
-
const exitCode = await proc
|
|
114
|
+
const exitCode = await withTimeout(proc);
|
|
99
115
|
const durationMs = Date.now() - startTime;
|
|
100
116
|
|
|
117
|
+
if (exitCode === "timeout") {
|
|
118
|
+
logger.error("claude task timed out", { durationMs });
|
|
119
|
+
return { success: false, output: `Claude task timed out after ${TIMEOUT_MS / 60000} minutes`, durationMs };
|
|
120
|
+
}
|
|
121
|
+
|
|
101
122
|
if (exitCode !== 0) {
|
|
102
123
|
const stderr = await new Response(proc.stderr).text();
|
|
103
124
|
logger.error("claude task failed", { exitCode, stderr, durationMs });
|
|
104
125
|
return { success: false, output: stderr || "Claude task failed", durationMs };
|
|
105
126
|
}
|
|
106
127
|
|
|
107
|
-
const finalOutput = resultText ||
|
|
128
|
+
const finalOutput = resultText || textBlocks.join("\n\n") || "Task completed (no output)";
|
|
108
129
|
logger.info("claude task completed", { durationMs });
|
|
109
130
|
return { success: true, output: finalOutput, durationMs };
|
|
110
131
|
}
|
package/src/runners/codex.ts
CHANGED
|
@@ -8,6 +8,20 @@ import { logger } from "../logger";
|
|
|
8
8
|
import { which } from "bun";
|
|
9
9
|
import { realpathSync } from "node:fs";
|
|
10
10
|
|
|
11
|
+
const TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
12
|
+
|
|
13
|
+
function withTimeout(proc: { exited: Promise<number>; kill(): void }): Promise<number | "timeout"> {
|
|
14
|
+
return Promise.race([
|
|
15
|
+
proc.exited,
|
|
16
|
+
new Promise<"timeout">((resolve) =>
|
|
17
|
+
setTimeout(() => {
|
|
18
|
+
proc.kill();
|
|
19
|
+
resolve("timeout");
|
|
20
|
+
}, TIMEOUT_MS)
|
|
21
|
+
),
|
|
22
|
+
]);
|
|
23
|
+
}
|
|
24
|
+
|
|
11
25
|
export function summarizeCodexItem(
|
|
12
26
|
item: any
|
|
13
27
|
): { tool: string; input: string } | null {
|
|
@@ -116,16 +130,23 @@ export class CodexRunner implements AgentRunner {
|
|
|
116
130
|
if (event.type === "turn.failed") {
|
|
117
131
|
errorMessage = event.error?.message || "Turn failed";
|
|
118
132
|
}
|
|
119
|
-
} catch {
|
|
133
|
+
} catch {
|
|
134
|
+
// Non-JSON line in stream output — skip
|
|
135
|
+
}
|
|
120
136
|
}
|
|
121
137
|
}
|
|
122
138
|
} finally {
|
|
123
139
|
reader.releaseLock();
|
|
124
140
|
}
|
|
125
141
|
|
|
126
|
-
const exitCode = await proc
|
|
142
|
+
const exitCode = await withTimeout(proc);
|
|
127
143
|
const durationMs = Date.now() - startTime;
|
|
128
144
|
|
|
145
|
+
if (exitCode === "timeout") {
|
|
146
|
+
logger.error("codex task timed out", { durationMs });
|
|
147
|
+
return { success: false, output: `Codex task timed out after ${TIMEOUT_MS / 60000} minutes`, durationMs };
|
|
148
|
+
}
|
|
149
|
+
|
|
129
150
|
if (exitCode !== 0) {
|
|
130
151
|
const stderr = await new Response(proc.stderr).text();
|
|
131
152
|
const output = errorMessage || stderr || "Codex task failed";
|
package/src/schedules.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
|
|
3
|
+
interface ScheduleRow {
|
|
4
|
+
id: number;
|
|
5
|
+
user_id: string;
|
|
6
|
+
repo: string;
|
|
7
|
+
prompt: string;
|
|
8
|
+
schedule: string;
|
|
9
|
+
description: string | null;
|
|
10
|
+
created_at: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
export interface Schedule {
|
|
4
14
|
id: number;
|
|
5
15
|
userId: string;
|
|
@@ -45,7 +55,7 @@ export class ScheduleStore {
|
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
listByUser(userId: string): Schedule[] {
|
|
48
|
-
return (this.db.query(`SELECT * FROM schedules WHERE user_id = ? ORDER BY id`).all(userId) as
|
|
58
|
+
return (this.db.query(`SELECT * FROM schedules WHERE user_id = ? ORDER BY id`).all(userId) as ScheduleRow[])
|
|
49
59
|
.map(this.rowToSchedule);
|
|
50
60
|
}
|
|
51
61
|
|
|
@@ -55,11 +65,11 @@ export class ScheduleStore {
|
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
getAll(): Schedule[] {
|
|
58
|
-
return (this.db.query(`SELECT * FROM schedules ORDER BY id`).all() as
|
|
68
|
+
return (this.db.query(`SELECT * FROM schedules ORDER BY id`).all() as ScheduleRow[])
|
|
59
69
|
.map(this.rowToSchedule);
|
|
60
70
|
}
|
|
61
71
|
|
|
62
|
-
private rowToSchedule(row:
|
|
72
|
+
private rowToSchedule(row: ScheduleRow): Schedule {
|
|
63
73
|
return {
|
|
64
74
|
id: row.id,
|
|
65
75
|
userId: row.user_id,
|
package/src/sessions.ts
CHANGED
|
@@ -6,6 +6,12 @@ export interface ChatMessage {
|
|
|
6
6
|
timestamp: string;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
interface ChatMessageRow {
|
|
10
|
+
role: string;
|
|
11
|
+
content: string;
|
|
12
|
+
created_at: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
9
15
|
export class SessionStore {
|
|
10
16
|
private db: Database;
|
|
11
17
|
|
|
@@ -38,12 +44,12 @@ export class SessionStore {
|
|
|
38
44
|
ORDER BY id DESC
|
|
39
45
|
LIMIT ?`
|
|
40
46
|
)
|
|
41
|
-
.all(userId, limit) as
|
|
47
|
+
.all(userId, limit) as ChatMessageRow[];
|
|
42
48
|
|
|
43
49
|
return rows
|
|
44
50
|
.reverse()
|
|
45
51
|
.map((r) => ({
|
|
46
|
-
role: r.role,
|
|
52
|
+
role: r.role as "user" | "assistant",
|
|
47
53
|
content: r.content,
|
|
48
54
|
timestamp: r.created_at,
|
|
49
55
|
}));
|
package/src/trace.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
export interface TraceEvent {
|
|
4
|
+
id: number;
|
|
5
|
+
taskId: string;
|
|
6
|
+
ts: string;
|
|
7
|
+
kind: "status" | "tool" | "lifecycle" | "output" | "error";
|
|
8
|
+
summary: string;
|
|
9
|
+
detail: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface TraceRow {
|
|
13
|
+
id: number;
|
|
14
|
+
task_id: string;
|
|
15
|
+
ts: string;
|
|
16
|
+
kind: string;
|
|
17
|
+
summary: string;
|
|
18
|
+
detail: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class TraceStore {
|
|
22
|
+
private db: Database;
|
|
23
|
+
private enabled: boolean;
|
|
24
|
+
|
|
25
|
+
constructor(db: Database) {
|
|
26
|
+
this.db = db;
|
|
27
|
+
this.enabled = process.env.OVE_TRACE === "true";
|
|
28
|
+
this.db.run(`
|
|
29
|
+
CREATE TABLE IF NOT EXISTS task_traces (
|
|
30
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
31
|
+
task_id TEXT NOT NULL,
|
|
32
|
+
ts TEXT NOT NULL,
|
|
33
|
+
kind TEXT NOT NULL,
|
|
34
|
+
summary TEXT NOT NULL,
|
|
35
|
+
detail TEXT
|
|
36
|
+
)
|
|
37
|
+
`);
|
|
38
|
+
this.db.run(`CREATE INDEX IF NOT EXISTS idx_trace_task_id ON task_traces(task_id)`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
isEnabled(): boolean {
|
|
42
|
+
return this.enabled;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
append(taskId: string, kind: TraceEvent["kind"], summary: string, detail?: string) {
|
|
46
|
+
if (!this.enabled) return;
|
|
47
|
+
this.db.run(
|
|
48
|
+
`INSERT INTO task_traces (task_id, ts, kind, summary, detail) VALUES (?, ?, ?, ?, ?)`,
|
|
49
|
+
[taskId, new Date().toISOString(), kind, summary, detail ?? null]
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
getByTask(taskId: string, limit: number = 100): TraceEvent[] {
|
|
54
|
+
const rows = this.db
|
|
55
|
+
.query(`SELECT * FROM task_traces WHERE task_id = ? ORDER BY id ASC LIMIT ?`)
|
|
56
|
+
.all(taskId, limit) as TraceRow[];
|
|
57
|
+
return rows.map((r) => ({
|
|
58
|
+
id: r.id,
|
|
59
|
+
taskId: r.task_id,
|
|
60
|
+
ts: r.ts,
|
|
61
|
+
kind: r.kind as TraceEvent["kind"],
|
|
62
|
+
summary: r.summary,
|
|
63
|
+
detail: r.detail,
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
cleanup(olderThanDays: number = 7) {
|
|
68
|
+
const cutoff = new Date(Date.now() - olderThanDays * 86_400_000).toISOString();
|
|
69
|
+
this.db.run(`DELETE FROM task_traces WHERE ts < ?`, [cutoff]);
|
|
70
|
+
}
|
|
71
|
+
}
|