Local-first knowledge management system with BYO API keys. Features: - 3-panel UI (Nodes | Focus | Helpers) - SQLite + sqlite-vec for vector search - Agent system (Easy/Hard mode orchestrators) - Content extraction (YouTube, PDF, web) - Integrate workflow for connection discovery - Dimension system with auto-assignment Tech stack: - Next.js 15 + TypeScript + Tailwind CSS - Anthropic (Claude) + OpenAI (GPT) via Vercel AI SDK Setup: npm install && npm rebuild better-sqlite3 scripts/dev/bootstrap-local.sh npm run dev MIT License
244 lines
8.2 KiB
TypeScript
244 lines
8.2 KiB
TypeScript
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
|
import { eventBroadcaster } from '@/services/events';
|
|
|
|
export type DelegationStatus = 'queued' | 'in_progress' | 'completed' | 'failed';
|
|
export type DelegationAgentType = 'mini' | 'wise-rah';
|
|
|
|
export interface AgentDelegation {
|
|
id: number;
|
|
sessionId: string;
|
|
task: string;
|
|
context: string[];
|
|
expectedOutcome?: string | null;
|
|
status: DelegationStatus;
|
|
summary?: string | null;
|
|
agentType: DelegationAgentType;
|
|
supabaseToken?: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
function rowToDelegation(row: any): AgentDelegation {
|
|
return {
|
|
id: row.id,
|
|
sessionId: row.session_id,
|
|
task: row.task,
|
|
context: (() => {
|
|
try {
|
|
return row.context ? JSON.parse(row.context) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
})(),
|
|
expectedOutcome: row.expected_outcome,
|
|
status: row.status as DelegationStatus,
|
|
summary: row.summary,
|
|
agentType: (row.agent_type || 'mini') as DelegationAgentType,
|
|
supabaseToken: row.supabase_token ?? null,
|
|
// SQLite CURRENT_TIMESTAMP is UTC, append 'Z' to parse correctly as UTC
|
|
createdAt: row.created_at ? row.created_at.replace(' ', 'T') + 'Z' : row.created_at,
|
|
updatedAt: row.updated_at ? row.updated_at.replace(' ', 'T') + 'Z' : row.updated_at,
|
|
};
|
|
}
|
|
|
|
function ensureTable() {
|
|
const db = getSQLiteClient();
|
|
db.query(`
|
|
CREATE TABLE IF NOT EXISTS agent_delegations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT UNIQUE NOT NULL,
|
|
task TEXT NOT NULL,
|
|
context TEXT,
|
|
expected_outcome TEXT,
|
|
status TEXT NOT NULL DEFAULT 'queued',
|
|
summary TEXT,
|
|
agent_type TEXT NOT NULL DEFAULT 'mini',
|
|
supabase_token TEXT,
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
|
|
// Add agent_type column if it doesn't exist (migration)
|
|
try {
|
|
const stmt = db.prepare('SELECT 1 FROM agent_delegations LIMIT 0');
|
|
const tableExists = stmt !== null;
|
|
|
|
if (tableExists) {
|
|
// Try to add the column, ignore if it already exists
|
|
try {
|
|
db.prepare(`ALTER TABLE agent_delegations ADD COLUMN agent_type TEXT NOT NULL DEFAULT 'mini'`).run();
|
|
console.log('✅ Added agent_type column to agent_delegations table');
|
|
} catch (alterError: any) {
|
|
// Column already exists, ignore
|
|
if (!alterError.message?.includes('duplicate column')) {
|
|
console.warn('Migration warning:', alterError.message);
|
|
}
|
|
}
|
|
|
|
try {
|
|
db.prepare(`ALTER TABLE agent_delegations ADD COLUMN supabase_token TEXT`).run();
|
|
console.log('✅ Added supabase_token column to agent_delegations table');
|
|
} catch (alterError: any) {
|
|
if (!alterError.message?.includes('duplicate column')) {
|
|
console.warn('Migration warning:', alterError.message);
|
|
}
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
// Table doesn't exist yet or other error - it will be created with the columns
|
|
console.log('Table creation will include agent_type and supabase_token columns');
|
|
}
|
|
}
|
|
|
|
export class AgentDelegationService {
|
|
static createDelegation(input: {
|
|
task: string;
|
|
context?: string[];
|
|
expectedOutcome?: string | null;
|
|
agentType?: DelegationAgentType;
|
|
supabaseToken?: string | null;
|
|
}): AgentDelegation {
|
|
ensureTable();
|
|
const db = getSQLiteClient();
|
|
const sessionId = `delegation_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
const contextJson = JSON.stringify(input.context ?? []);
|
|
const agentType = input.agentType || 'mini';
|
|
db.prepare(
|
|
`INSERT INTO agent_delegations (session_id, task, context, expected_outcome, status, agent_type, supabase_token)
|
|
VALUES (?, ?, ?, ?, 'queued', ?, ?)`
|
|
).run(
|
|
sessionId,
|
|
input.task,
|
|
contextJson,
|
|
input.expectedOutcome ?? null,
|
|
agentType,
|
|
input.supabaseToken ?? null
|
|
);
|
|
|
|
const row = db
|
|
.prepare('SELECT * FROM agent_delegations WHERE session_id = ?')
|
|
.get(sessionId);
|
|
|
|
const delegation = rowToDelegation(row);
|
|
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_CREATED', data: { delegation } });
|
|
return delegation;
|
|
}
|
|
|
|
static markInProgress(sessionId: string): AgentDelegation | null {
|
|
ensureTable();
|
|
const db = getSQLiteClient();
|
|
db.prepare(
|
|
`UPDATE agent_delegations
|
|
SET status = 'in_progress', updated_at = CURRENT_TIMESTAMP
|
|
WHERE session_id = ? AND status = 'queued'`
|
|
).run(sessionId);
|
|
const row = db.prepare('SELECT * FROM agent_delegations WHERE session_id = ?').get(sessionId);
|
|
if (!row) return null;
|
|
const delegation = rowToDelegation(row);
|
|
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_UPDATED', data: { delegation } });
|
|
return delegation;
|
|
}
|
|
|
|
static touchDelegation(sessionId: string): void {
|
|
// Update the timestamp to prevent cleanup from killing active delegations
|
|
ensureTable();
|
|
const db = getSQLiteClient();
|
|
db.prepare(
|
|
`UPDATE agent_delegations SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ? AND status = 'in_progress'`
|
|
).run(sessionId);
|
|
}
|
|
|
|
static completeDelegation(sessionId: string, summary: string, status: DelegationStatus = 'completed'): AgentDelegation | null {
|
|
ensureTable();
|
|
const db = getSQLiteClient();
|
|
db.prepare(
|
|
`UPDATE agent_delegations
|
|
SET status = ?, summary = ?, updated_at = CURRENT_TIMESTAMP, supabase_token = NULL
|
|
WHERE session_id = ?`
|
|
).run(status, summary, sessionId);
|
|
const row = db.prepare('SELECT * FROM agent_delegations WHERE session_id = ?').get(sessionId);
|
|
if (!row) return null;
|
|
const delegation = rowToDelegation(row);
|
|
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_UPDATED', data: { delegation } });
|
|
return delegation;
|
|
}
|
|
|
|
static getBySessionId(sessionId: string): AgentDelegation | null {
|
|
ensureTable();
|
|
const db = getSQLiteClient();
|
|
const row = db.prepare('SELECT * FROM agent_delegations WHERE session_id = ?').get(sessionId);
|
|
return row ? rowToDelegation(row) : null;
|
|
}
|
|
|
|
static getDelegation(sessionId: string): AgentDelegation | null {
|
|
return this.getBySessionId(sessionId);
|
|
}
|
|
|
|
static listActive({ includeCompleted = true, limit = 100 }: { includeCompleted?: boolean; limit?: number } = {}): AgentDelegation[] {
|
|
ensureTable();
|
|
const db = getSQLiteClient();
|
|
// Load all delegations - user closes them manually from UI
|
|
const rows = includeCompleted
|
|
? db.prepare(
|
|
`SELECT * FROM agent_delegations
|
|
ORDER BY updated_at DESC
|
|
LIMIT ?`
|
|
).all(limit)
|
|
: db.prepare(
|
|
`SELECT * FROM agent_delegations
|
|
WHERE status IN ('queued','in_progress')
|
|
ORDER BY updated_at DESC
|
|
LIMIT ?`
|
|
).all(limit);
|
|
return rows.map(rowToDelegation);
|
|
}
|
|
|
|
static listRecent(limit = 20): AgentDelegation[] {
|
|
ensureTable();
|
|
const db = getSQLiteClient();
|
|
const rows = db.prepare(
|
|
`SELECT * FROM agent_delegations
|
|
ORDER BY created_at DESC
|
|
LIMIT ?`
|
|
).all(limit);
|
|
return rows.map(rowToDelegation);
|
|
}
|
|
|
|
static deleteDelegation(sessionId: string): boolean {
|
|
ensureTable();
|
|
const db = getSQLiteClient();
|
|
const result = db.prepare('DELETE FROM agent_delegations WHERE session_id = ?').run(sessionId);
|
|
return result.changes > 0;
|
|
}
|
|
|
|
static cleanupStaleDelegations(timeoutMinutes = 15): number {
|
|
ensureTable();
|
|
const db = getSQLiteClient();
|
|
const cutoffTime = new Date(Date.now() - timeoutMinutes * 60 * 1000).toISOString();
|
|
|
|
const result = db.prepare(`
|
|
UPDATE agent_delegations
|
|
SET status = 'failed',
|
|
summary = 'Task timed out (exceeded ${timeoutMinutes} minutes)',
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE status = 'in_progress'
|
|
AND updated_at < ?
|
|
`).run(cutoffTime);
|
|
|
|
const affectedCount = result.changes || 0;
|
|
|
|
if (affectedCount > 0) {
|
|
const rows = db.prepare(
|
|
`SELECT * FROM agent_delegations WHERE status = 'failed' AND summary LIKE 'Task timed out%'`
|
|
).all();
|
|
rows.forEach(row => {
|
|
const delegation = rowToDelegation(row);
|
|
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_UPDATED', data: { delegation } });
|
|
});
|
|
}
|
|
|
|
return affectedCount;
|
|
}
|
|
}
|