| 1 | import Database from 'better-sqlite3'; |
| 2 | import { ulid } from 'ulid'; |
| 3 | import fs from 'fs'; |
| 4 | import path from 'path'; |
| 5 | |
| 6 | const DATA_DIR = process.env.ZPM_DATA_DIR || path.join(process.cwd(), 'data'); |
| 7 | fs.mkdirSync(DATA_DIR, { recursive: true }); |
| 8 | const DB_PATH = path.join(DATA_DIR, 'zionpm.db'); |
| 9 | |
| 10 | export const db = new Database(DB_PATH); |
| 11 | db.pragma('journal_mode = WAL'); |
| 12 | |
| 13 | db.exec(` |
| 14 | CREATE TABLE IF NOT EXISTS channels( |
| 15 | id TEXT PRIMARY KEY, |
| 16 | name TEXT NOT NULL UNIQUE, |
| 17 | default_model TEXT NOT NULL, |
| 18 | created_at INTEGER NOT NULL |
| 19 | ); |
| 20 | CREATE TABLE IF NOT EXISTS messages( |
| 21 | id TEXT PRIMARY KEY, |
| 22 | channel_id TEXT NOT NULL REFERENCES channels(id), |
| 23 | role TEXT NOT NULL, |
| 24 | author_model TEXT, |
| 25 | body TEXT NOT NULL, |
| 26 | parent_id TEXT REFERENCES messages(id), |
| 27 | created_at INTEGER NOT NULL |
| 28 | ); |
| 29 | CREATE TABLE IF NOT EXISTS tasks( |
| 30 | id TEXT PRIMARY KEY, |
| 31 | title TEXT NOT NULL, |
| 32 | status TEXT NOT NULL DEFAULT 'todo', |
| 33 | assigned_model TEXT, |
| 34 | channel_id TEXT REFERENCES channels(id), |
| 35 | created_at INTEGER NOT NULL, |
| 36 | updated_at INTEGER NOT NULL |
| 37 | ); |
| 38 | CREATE TABLE IF NOT EXISTS outbox( |
| 39 | id TEXT PRIMARY KEY, |
| 40 | action_type TEXT NOT NULL, |
| 41 | payload_json TEXT NOT NULL, |
| 42 | status TEXT NOT NULL DEFAULT 'draft', |
| 43 | created_at INTEGER NOT NULL, |
| 44 | approved_at INTEGER |
| 45 | ); |
| 46 | CREATE TABLE IF NOT EXISTS cap_state( |
| 47 | model TEXT PRIMARY KEY, |
| 48 | available INTEGER NOT NULL, |
| 49 | reason TEXT, |
| 50 | last_checked INTEGER NOT NULL |
| 51 | ); |
| 52 | `); |
| 53 | |
| 54 | export const now = () => Date.now(); |
| 55 | export { ulid }; |
| 56 | |
| 57 | function seed() { |
| 58 | const chanCount = (db.prepare('SELECT COUNT(*) c FROM channels').get() as { c: number }).c; |
| 59 | if (chanCount === 0) { |
| 60 | const ins = db.prepare( |
| 61 | 'INSERT INTO channels(id,name,default_model,created_at) VALUES(?,?,?,?)' |
| 62 | ); |
| 63 | const seedCh: [string, string][] = [ |
| 64 | ['pm', 'glm'], |
| 65 | ['claude', 'claude'], |
| 66 | ['codex', 'codex'], |
| 67 | ['ollama', 'ollama'], |
| 68 | ]; |
| 69 | const t = now(); |
| 70 | for (const [name, model] of seedCh) ins.run(ulid(), name, model, t); |
| 71 | } |
| 72 | const capIns = db.prepare( |
| 73 | `INSERT INTO cap_state(model,available,reason,last_checked) |
| 74 | VALUES(?,?,?,?) ON CONFLICT(model) DO NOTHING` |
| 75 | ); |
| 76 | const t = now(); |
| 77 | for (const m of ['claude', 'codex', 'ollama']) capIns.run(m, 1, 'seeded', t); |
| 78 | } |
| 79 | seed(); |
| 80 | |
| 81 | export interface Channel { id: string; name: string; default_model: string; created_at: number; } |
| 82 | export interface Message { id: string; channel_id: string; role: string; author_model: string | null; body: string; parent_id: string | null; created_at: number; } |
| 83 | export interface Task { id: string; title: string; status: string; assigned_model: string | null; channel_id: string | null; created_at: number; updated_at: number; } |
| 84 | export interface Outbox { id: string; action_type: string; payload_json: string; status: string; created_at: number; approved_at: number | null; } |
| 85 | export interface CapState { model: string; available: number; reason: string | null; last_checked: number; } |
| 86 | |
| 87 | export const listChannels = () => db.prepare('SELECT * FROM channels ORDER BY created_at').all() as Channel[]; |
| 88 | export const getChannel = (id: string) => db.prepare('SELECT * FROM channels WHERE id=?').get(id) as Channel | undefined; |
| 89 | export const getChannelByName = (name: string) => db.prepare('SELECT * FROM channels WHERE name=?').get(name) as Channel | undefined; |
| 90 | export function createChannel(name: string, defaultModel: string): Channel { |
| 91 | const id = ulid(); |
| 92 | db.prepare('INSERT INTO channels(id,name,default_model,created_at) VALUES(?,?,?,?)').run(id, name, defaultModel, now()); |
| 93 | return getChannel(id)!; |
| 94 | } |
| 95 | |
| 96 | export const listMessages = (channelId: string) => |
| 97 | db.prepare('SELECT * FROM messages WHERE channel_id=? ORDER BY created_at').all(channelId) as Message[]; |
| 98 | export function addMessage(channelId: string, role: string, body: string, authorModel: string | null = null, parentId: string | null = null): Message { |
| 99 | const id = ulid(); |
| 100 | db.prepare('INSERT INTO messages(id,channel_id,role,author_model,body,parent_id,created_at) VALUES(?,?,?,?,?,?,?)') |
| 101 | .run(id, channelId, role, authorModel, body, parentId, now()); |
| 102 | return db.prepare('SELECT * FROM messages WHERE id=?').get(id) as Message; |
| 103 | } |
| 104 | |
| 105 | export const listTasks = () => db.prepare('SELECT * FROM tasks ORDER BY created_at DESC').all() as Task[]; |
| 106 | export function createTask(title: string, opts: { status?: string; assignedModel?: string | null; channelId?: string | null } = {}): Task { |
| 107 | const id = ulid(); |
| 108 | const t = now(); |
| 109 | db.prepare('INSERT INTO tasks(id,title,status,assigned_model,channel_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)') |
| 110 | .run(id, title, opts.status || 'todo', opts.assignedModel ?? null, opts.channelId ?? null, t, t); |
| 111 | return db.prepare('SELECT * FROM tasks WHERE id=?').get(id) as Task; |
| 112 | } |
| 113 | export function updateTask(id: string, patch: { status?: string; assignedModel?: string | null; title?: string }): Task | undefined { |
| 114 | const cur = db.prepare('SELECT * FROM tasks WHERE id=?').get(id) as Task | undefined; |
| 115 | if (!cur) return undefined; |
| 116 | db.prepare('UPDATE tasks SET status=?, assigned_model=?, title=?, updated_at=? WHERE id=?') |
| 117 | .run(patch.status ?? cur.status, patch.assignedModel ?? cur.assigned_model, patch.title ?? cur.title, now(), id); |
| 118 | return db.prepare('SELECT * FROM tasks WHERE id=?').get(id) as Task; |
| 119 | } |
| 120 | |
| 121 | export const listOutbox = () => db.prepare('SELECT * FROM outbox ORDER BY created_at DESC').all() as Outbox[]; |
| 122 | export function draftOutbox(actionType: string, payload: unknown): Outbox { |
| 123 | const id = ulid(); |
| 124 | db.prepare('INSERT INTO outbox(id,action_type,payload_json,status,created_at) VALUES(?,?,?,?,?)') |
| 125 | .run(id, actionType, JSON.stringify(payload), 'draft', now()); |
| 126 | return db.prepare('SELECT * FROM outbox WHERE id=?').get(id) as Outbox; |
| 127 | } |
| 128 | export function setOutboxStatus(id: string, status: 'approved' | 'rejected' | 'sent'): Outbox | undefined { |
| 129 | const approvedAt = status === 'approved' ? now() : null; |
| 130 | db.prepare('UPDATE outbox SET status=?, approved_at=COALESCE(?,approved_at) WHERE id=?').run(status, approvedAt, id); |
| 131 | return db.prepare('SELECT * FROM outbox WHERE id=?').get(id) as Outbox | undefined; |
| 132 | } |
| 133 | |
| 134 | export const listCaps = () => db.prepare('SELECT * FROM cap_state ORDER BY model').all() as CapState[]; |
| 135 | export const getCap = (model: string) => db.prepare('SELECT * FROM cap_state WHERE model=?').get(model) as CapState | undefined; |
| 136 | export function setCap(model: string, available: number, reason: string) { |
| 137 | db.prepare(`INSERT INTO cap_state(model,available,reason,last_checked) VALUES(?,?,?,?) |
| 138 | ON CONFLICT(model) DO UPDATE SET available=excluded.available, reason=excluded.reason, last_checked=excluded.last_checked`) |
| 139 | .run(model, available, reason, now()); |
| 140 | } |
| 141 | export const touchCap = (model: string) => db.prepare('UPDATE cap_state SET last_checked=? WHERE model=?').run(now(), model); |