@miphamai/cli 0.31.1 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,449 @@
1
+ // apps/cli/src/daemon/database.ts
2
+ import { Database } from 'bun:sqlite'
3
+ import { readdirSync, readFileSync, existsSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import type {
6
+ DaemonSession,
7
+ DaemonAgent,
8
+ DaemonGoal,
9
+ DaemonSchedule,
10
+ MessageRecord,
11
+ CreateSessionInput,
12
+ SessionStatus,
13
+ } from './types'
14
+
15
+ export class DaemonDatabase {
16
+ private db: Database
17
+ private readonly path: string
18
+
19
+ constructor(dbPath: string) {
20
+ this.path = dbPath
21
+ this.db = new Database(dbPath)
22
+ this.db.exec('PRAGMA journal_mode=WAL')
23
+ this.db.exec('PRAGMA foreign_keys=ON')
24
+ }
25
+
26
+ init(): void {
27
+ this.db.exec(`
28
+ CREATE TABLE IF NOT EXISTS sessions (
29
+ id TEXT PRIMARY KEY,
30
+ name TEXT NOT NULL,
31
+ cwd TEXT NOT NULL,
32
+ provider TEXT NOT NULL,
33
+ model TEXT NOT NULL,
34
+ status TEXT DEFAULT 'active',
35
+ created_at TEXT NOT NULL,
36
+ updated_at TEXT NOT NULL,
37
+ closed_at TEXT,
38
+ turn_count INTEGER DEFAULT 0,
39
+ token_in INTEGER DEFAULT 0,
40
+ token_out INTEGER DEFAULT 0
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS messages (
44
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
45
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
46
+ role TEXT NOT NULL,
47
+ content TEXT NOT NULL,
48
+ created_at TEXT NOT NULL
49
+ );
50
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, id);
51
+
52
+ CREATE TABLE IF NOT EXISTS agents (
53
+ id TEXT PRIMARY KEY,
54
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
55
+ parent_id TEXT REFERENCES agents(id),
56
+ agent_type TEXT NOT NULL,
57
+ description TEXT NOT NULL,
58
+ status TEXT DEFAULT 'running',
59
+ kind TEXT DEFAULT 'interactive',
60
+ worktree TEXT,
61
+ branch TEXT,
62
+ pr_url TEXT,
63
+ created_at TEXT NOT NULL,
64
+ completed_at TEXT,
65
+ result TEXT,
66
+ error TEXT
67
+ );
68
+ CREATE INDEX IF NOT EXISTS idx_agents_session ON agents(session_id);
69
+
70
+ CREATE TABLE IF NOT EXISTS goals (
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
73
+ description TEXT NOT NULL,
74
+ status TEXT DEFAULT 'active',
75
+ progress TEXT,
76
+ created_at TEXT NOT NULL,
77
+ updated_at TEXT NOT NULL
78
+ );
79
+
80
+ CREATE TABLE IF NOT EXISTS schedules (
81
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
82
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
83
+ cron_expr TEXT NOT NULL,
84
+ prompt TEXT NOT NULL,
85
+ enabled INTEGER DEFAULT 1,
86
+ last_fired TEXT,
87
+ next_fire TEXT NOT NULL
88
+ );
89
+ `)
90
+ }
91
+
92
+ close(): void {
93
+ this.db.close()
94
+ }
95
+
96
+ // ── Sessions ──────────────────────────────────────────────
97
+
98
+ createSession(input: CreateSessionInput): DaemonSession {
99
+ const id = crypto.randomUUID()
100
+ const now = new Date().toISOString()
101
+ this.db.run(
102
+ `INSERT INTO sessions (id, name, cwd, provider, model, status, created_at, updated_at)
103
+ VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`,
104
+ [id, input.name, input.cwd, input.provider, input.model, now, now],
105
+ )
106
+ return {
107
+ id,
108
+ name: input.name,
109
+ cwd: input.cwd,
110
+ provider: input.provider,
111
+ model: input.model,
112
+ status: 'active',
113
+ createdAt: now,
114
+ updatedAt: now,
115
+ closedAt: null,
116
+ turnCount: 0,
117
+ tokenIn: 0,
118
+ tokenOut: 0,
119
+ }
120
+ }
121
+
122
+ getSession(id: string): DaemonSession | null {
123
+ const row = this.db.query('SELECT * FROM sessions WHERE id = ?').get(id) as Record<
124
+ string,
125
+ unknown
126
+ > | null
127
+ if (!row) return null
128
+ return this.rowToSession(row)
129
+ }
130
+
131
+ listSessions(status?: string): DaemonSession[] {
132
+ const sql = status
133
+ ? 'SELECT * FROM sessions WHERE status = ? ORDER BY updated_at DESC'
134
+ : 'SELECT * FROM sessions ORDER BY updated_at DESC'
135
+ const rows = status
136
+ ? (this.db.query(sql).all(status) as Record<string, unknown>[])
137
+ : (this.db.query(sql).all() as Record<string, unknown>[])
138
+ return rows.map((r) => this.rowToSession(r))
139
+ }
140
+
141
+ updateSessionStatus(id: string, status: SessionStatus): void {
142
+ const now = new Date().toISOString()
143
+ const closedAt = status === 'closed' ? now : null
144
+ this.db.run(`UPDATE sessions SET status = ?, updated_at = ?, closed_at = ? WHERE id = ?`, [
145
+ status,
146
+ now,
147
+ closedAt,
148
+ id,
149
+ ])
150
+ }
151
+
152
+ closeSession(id: string): void {
153
+ this.updateSessionStatus(id, 'closed')
154
+ }
155
+
156
+ incrementTurn(id: string, tokenIn: number, tokenOut: number): void {
157
+ const now = new Date().toISOString()
158
+ this.db.run(
159
+ `UPDATE sessions SET turn_count = turn_count + 1, token_in = token_in + ?, token_out = token_out + ?, updated_at = ? WHERE id = ?`,
160
+ [tokenIn, tokenOut, now, id],
161
+ )
162
+ }
163
+
164
+ // ── Messages ──────────────────────────────────────────────
165
+
166
+ saveMessage(sessionId: string, role: string, content: string): void {
167
+ const now = new Date().toISOString()
168
+ this.db.run(
169
+ `INSERT INTO messages (session_id, role, content, created_at) VALUES (?, ?, ?, ?)`,
170
+ [sessionId, role, content, now],
171
+ )
172
+ }
173
+
174
+ getMessages(sessionId: string, limit: number = 100): MessageRecord[] {
175
+ const rows = this.db
176
+ .query('SELECT * FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT ?')
177
+ .all(sessionId, limit) as Record<string, unknown>[]
178
+ return rows.reverse().map((r) => ({
179
+ id: r.id as number,
180
+ sessionId: r.session_id as string,
181
+ role: r.role as MessageRecord['role'],
182
+ content: r.content as string,
183
+ createdAt: r.created_at as string,
184
+ }))
185
+ }
186
+
187
+ // ── Agents ─────────────────────────────────────────────────
188
+
189
+ createAgent(agent: DaemonAgent): DaemonAgent {
190
+ this.db.run(
191
+ `INSERT INTO agents (id, session_id, parent_id, agent_type, description, status, kind, worktree, branch, pr_url, created_at, completed_at, result, error)
192
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
193
+ [
194
+ agent.id,
195
+ agent.sessionId,
196
+ agent.parentId,
197
+ agent.agentType,
198
+ agent.description,
199
+ agent.status,
200
+ agent.kind,
201
+ agent.worktree,
202
+ agent.branch,
203
+ agent.prUrl,
204
+ agent.createdAt,
205
+ agent.completedAt,
206
+ agent.result,
207
+ agent.error,
208
+ ],
209
+ )
210
+ return agent
211
+ }
212
+
213
+ getAgent(id: string): DaemonAgent | null {
214
+ const row = this.db.query('SELECT * FROM agents WHERE id = ?').get(id) as Record<
215
+ string,
216
+ unknown
217
+ > | null
218
+ if (!row) return null
219
+ return this.rowToAgent(row)
220
+ }
221
+
222
+ listAgents(sessionId?: string): DaemonAgent[] {
223
+ const sql = sessionId
224
+ ? 'SELECT * FROM agents WHERE session_id = ? ORDER BY created_at DESC'
225
+ : 'SELECT * FROM agents ORDER BY created_at DESC'
226
+ const rows = sessionId
227
+ ? (this.db.query(sql).all(sessionId) as Record<string, unknown>[])
228
+ : (this.db.query(sql).all() as Record<string, unknown>[])
229
+ return rows.map((r) => this.rowToAgent(r))
230
+ }
231
+
232
+ updateAgentStatus(id: string, status: string, result?: string, error?: string): void {
233
+ const completedAt =
234
+ status === 'completed' || status === 'failed' ? new Date().toISOString() : null
235
+ this.db.run(
236
+ `UPDATE agents SET status = ?, result = ?, error = ?, completed_at = ? WHERE id = ?`,
237
+ [status, result ?? null, error ?? null, completedAt, id],
238
+ )
239
+ }
240
+
241
+ // ── Goals ──────────────────────────────────────────────────
242
+
243
+ createGoal(goal: Omit<DaemonGoal, 'id'>): number {
244
+ const result = this.db.run(
245
+ `INSERT INTO goals (session_id, description, status, progress, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
246
+ [
247
+ goal.sessionId,
248
+ goal.description,
249
+ goal.status,
250
+ goal.progress ? JSON.stringify(goal.progress) : null,
251
+ goal.createdAt,
252
+ goal.updatedAt,
253
+ ],
254
+ )
255
+ return Number(result.lastInsertRowid)
256
+ }
257
+
258
+ getGoals(sessionId: string): DaemonGoal[] {
259
+ const rows = this.db
260
+ .query('SELECT * FROM goals WHERE session_id = ? ORDER BY id')
261
+ .all(sessionId) as Record<string, unknown>[]
262
+ return rows.map((r) => ({
263
+ id: r.id as number,
264
+ sessionId: r.session_id as string,
265
+ description: r.description as string,
266
+ status: r.status as DaemonGoal['status'],
267
+ progress: typeof r.progress === 'string' ? JSON.parse(r.progress as string) : null,
268
+ createdAt: r.created_at as string,
269
+ updatedAt: r.updated_at as string,
270
+ }))
271
+ }
272
+
273
+ updateGoal(id: number, updates: Partial<DaemonGoal>): void {
274
+ const now = new Date().toISOString()
275
+ const sets: string[] = ['updated_at = ?']
276
+ const vals: unknown[] = [now]
277
+
278
+ if (updates.status !== undefined) {
279
+ sets.push('status = ?')
280
+ vals.push(updates.status)
281
+ }
282
+ if (updates.description !== undefined) {
283
+ sets.push('description = ?')
284
+ vals.push(updates.description)
285
+ }
286
+ if (updates.progress !== undefined) {
287
+ sets.push('progress = ?')
288
+ vals.push(updates.progress ? JSON.stringify(updates.progress) : null)
289
+ }
290
+
291
+ vals.push(id)
292
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
293
+ this.db.run(`UPDATE goals SET ${sets.join(', ')} WHERE id = ?`, vals as any)
294
+ }
295
+
296
+ // ── Schedules ──────────────────────────────────────────────
297
+
298
+ createSchedule(schedule: Omit<DaemonSchedule, 'id'>): number {
299
+ const result = this.db.run(
300
+ `INSERT INTO schedules (session_id, cron_expr, prompt, enabled, last_fired, next_fire) VALUES (?, ?, ?, ?, ?, ?)`,
301
+ [
302
+ schedule.sessionId,
303
+ schedule.cronExpr,
304
+ schedule.prompt,
305
+ schedule.enabled ? 1 : 0,
306
+ schedule.lastFired,
307
+ schedule.nextFire,
308
+ ],
309
+ )
310
+ return Number(result.lastInsertRowid)
311
+ }
312
+
313
+ getSchedules(sessionId: string): DaemonSchedule[] {
314
+ const rows = this.db
315
+ .query('SELECT * FROM schedules WHERE session_id = ?')
316
+ .all(sessionId) as Record<string, unknown>[]
317
+ return rows.map((r) => this.rowToSchedule(r))
318
+ }
319
+
320
+ deleteSchedule(id: number): void {
321
+ this.db.run('DELETE FROM schedules WHERE id = ?', [id])
322
+ }
323
+
324
+ updateScheduleFireTimes(id: number, lastFired: string, nextFire: string): void {
325
+ this.db.run(`UPDATE schedules SET last_fired = ?, next_fire = ? WHERE id = ?`, [
326
+ lastFired,
327
+ nextFire,
328
+ id,
329
+ ])
330
+ }
331
+
332
+ getDueSchedules(): DaemonSchedule[] {
333
+ const now = new Date().toISOString()
334
+ const rows = this.db
335
+ .query('SELECT * FROM schedules WHERE enabled = 1 AND next_fire <= ?')
336
+ .all(now) as Record<string, unknown>[]
337
+ return rows.map((r) => this.rowToSchedule(r))
338
+ }
339
+
340
+ // ── Migration ──────────────────────────────────────────────
341
+
342
+ migrateFromJsonl(): number {
343
+ const sessionsDir = join(process.env.HOME || '/tmp', '.mipham', 'sessions')
344
+
345
+ if (!existsSync(sessionsDir)) return 0
346
+
347
+ const files = readdirSync(sessionsDir).filter((f: string) => f.endsWith('.jsonl'))
348
+ let count = 0
349
+
350
+ for (const file of files) {
351
+ try {
352
+ const raw = readFileSync(join(sessionsDir, file), 'utf-8')
353
+ const session = JSON.parse(raw)
354
+ if (!session.metadata || !Array.isArray(session.messages)) continue
355
+
356
+ const id = crypto.randomUUID()
357
+ const name = session.metadata.name || file.replace('.jsonl', '')
358
+ this.createSession({
359
+ name,
360
+ cwd: session.metadata.cwd || process.cwd(),
361
+ provider: session.metadata.provider || 'unknown',
362
+ model: session.metadata.model || 'unknown',
363
+ })
364
+
365
+ // Override the auto-generated ID with a stable one for migration
366
+ this.db.run('UPDATE sessions SET id = ? WHERE name = ?', [id, name])
367
+
368
+ for (const msg of session.messages) {
369
+ this.saveMessage(id, msg.role || 'user', JSON.stringify(msg))
370
+ }
371
+
372
+ count++
373
+ } catch {
374
+ // Skip corrupt files
375
+ }
376
+ }
377
+
378
+ return count
379
+ }
380
+
381
+ // ── Stats ──────────────────────────────────────────────────
382
+
383
+ getStats(): { activeSessions: number; totalSessions: number; activeAgents: number } {
384
+ const activeSessions = (
385
+ this.db.query("SELECT COUNT(*) as c FROM sessions WHERE status != 'closed'").get() as {
386
+ c: number
387
+ }
388
+ ).c
389
+ const totalSessions = (
390
+ this.db.query('SELECT COUNT(*) as c FROM sessions').get() as { c: number }
391
+ ).c
392
+ const activeAgents = (
393
+ this.db.query("SELECT COUNT(*) as c FROM agents WHERE status = 'running'").get() as {
394
+ c: number
395
+ }
396
+ ).c
397
+ return { activeSessions, totalSessions, activeAgents }
398
+ }
399
+
400
+ // ── Helpers ────────────────────────────────────────────────
401
+
402
+ private rowToSession(row: Record<string, unknown>): DaemonSession {
403
+ return {
404
+ id: row.id as string,
405
+ name: row.name as string,
406
+ cwd: row.cwd as string,
407
+ provider: row.provider as string,
408
+ model: row.model as string,
409
+ status: row.status as SessionStatus,
410
+ createdAt: row.created_at as string,
411
+ updatedAt: row.updated_at as string,
412
+ closedAt: row.closed_at as string | null,
413
+ turnCount: row.turn_count as number,
414
+ tokenIn: row.token_in as number,
415
+ tokenOut: row.token_out as number,
416
+ }
417
+ }
418
+
419
+ private rowToAgent(row: Record<string, unknown>): DaemonAgent {
420
+ return {
421
+ id: row.id as string,
422
+ sessionId: row.session_id as string,
423
+ parentId: row.parent_id as string | null,
424
+ agentType: row.agent_type as string,
425
+ description: row.description as string,
426
+ status: row.status as DaemonAgent['status'],
427
+ kind: row.kind as DaemonAgent['kind'],
428
+ worktree: row.worktree as string | null,
429
+ branch: row.branch as string | null,
430
+ prUrl: row.pr_url as string | null,
431
+ createdAt: row.created_at as string,
432
+ completedAt: row.completed_at as string | null,
433
+ result: row.result as string | null,
434
+ error: row.error as string | null,
435
+ }
436
+ }
437
+
438
+ private rowToSchedule(row: Record<string, unknown>): DaemonSchedule {
439
+ return {
440
+ id: row.id as number,
441
+ sessionId: row.session_id as string,
442
+ cronExpr: row.cron_expr as string,
443
+ prompt: row.prompt as string,
444
+ enabled: (row.enabled as number) === 1,
445
+ lastFired: row.last_fired as string | null,
446
+ nextFire: row.next_fire as string,
447
+ }
448
+ }
449
+ }
@@ -0,0 +1,53 @@
1
+ // apps/cli/src/daemon/goal-manager.ts — Phase 4: Goal service layer
2
+ // Thin delegation over DaemonDatabase goal methods.
3
+ // Pattern matches AgentManager (Phase 3) and SessionManager (Phase 1).
4
+
5
+ import type { DaemonDatabase } from './database'
6
+ import type { DaemonGoal } from './types'
7
+
8
+ export class GoalManager {
9
+ private db: DaemonDatabase
10
+
11
+ constructor(db: DaemonDatabase) {
12
+ this.db = db
13
+ }
14
+
15
+ createGoal(
16
+ sessionId: string,
17
+ description: string,
18
+ progress?: { current: number; total: number },
19
+ ): number {
20
+ const now = new Date().toISOString()
21
+ return this.db.createGoal({
22
+ sessionId,
23
+ description,
24
+ status: 'active',
25
+ progress: progress ?? null,
26
+ createdAt: now,
27
+ updatedAt: now,
28
+ })
29
+ }
30
+
31
+ getGoals(sessionId: string): DaemonGoal[] {
32
+ return this.db.getGoals(sessionId)
33
+ }
34
+
35
+ updateGoal(
36
+ id: number,
37
+ updates: Partial<Pick<DaemonGoal, 'status' | 'description' | 'progress'>>,
38
+ ): void {
39
+ this.db.updateGoal(id, updates)
40
+ }
41
+
42
+ completeGoal(id: number): void {
43
+ this.db.updateGoal(id, { status: 'completed' })
44
+ }
45
+
46
+ pauseGoal(id: number): void {
47
+ this.db.updateGoal(id, { status: 'paused' })
48
+ }
49
+
50
+ clearGoal(id: number): void {
51
+ this.db.updateGoal(id, { status: 'cleared' })
52
+ }
53
+ }