import { GlobalStateStore } from './shellState'; function authHeaders(): HeadersInit { const token = GlobalStateStore.getState().session.authToken; return token ? { Authorization: `Bearer ${token}` } : {}; } type ResolveSpecResponse = { success: boolean; data: T; metadata?: unknown; error?: { code: string; message: string; detail?: string }; }; type ResolveSpecFilter = { column: string; operator: string; value?: unknown; }; function normalizeTags(value: unknown): string[] { if (Array.isArray(value)) { return value.map((tag) => String(tag).trim()).filter(Boolean); } if (typeof value !== 'string') { return []; } const trimmed = value.trim(); if (!trimmed) return []; // Handle Postgres text[] wire shape: "{tag1,tag2}". if (trimmed.startsWith('{') && trimmed.endsWith('}')) { return trimmed .slice(1, -1) .split(',') .map((tag) => tag.trim().replace(/^"(.*)"$/, '$1')) .filter(Boolean); } if (trimmed.includes(',')) { return trimmed .split(',') .map((tag) => tag.trim()) .filter(Boolean); } return [trimmed]; } async function get(path: string): Promise { const res = await fetch(path, { headers: authHeaders() }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json() as Promise; } async function post(path: string, body: unknown): Promise { const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify(body) }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); if (res.status === 204) return undefined as T; return res.json() as Promise; } async function del(path: string): Promise { const res = await fetch(path, { method: 'DELETE', headers: authHeaders() }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); } async function rsCall( path: string, operation: 'read' | 'create' | 'update' | 'delete', payload?: { data?: unknown; options?: unknown } ): Promise { const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ operation, ...(payload?.data !== undefined ? { data: payload.data } : {}), ...(payload?.options !== undefined ? { options: payload.options } : {}) }) }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); const body = (await res.json()) as ResolveSpecResponse; if (!body.success) { throw new Error(body.error?.message ?? 'ResolveSpec request failed'); } return body.data; } function rsReadMany( entity: string, options?: { filters?: ResolveSpecFilter[]; limit?: number; offset?: number; sort?: { column: string; direction: 'asc' | 'desc' }[] } ): Promise { return rsCall(`/api/rs/public/${entity}`, 'read', { options: { ...(options?.filters?.length ? { filters: options.filters } : {}), ...(options?.sort?.length ? { sort: options.sort } : {}), ...(options?.limit ? { limit: options.limit } : {}), ...(options?.offset ? { offset: options.offset } : {}) } }).then((rows) => (Array.isArray(rows) ? rows : [])); } export const api = { projects: { list: async (params?: { limit?: number; offset?: number; q?: string }) => { type ProjectRow = { guid: string; name: string; description: string | null; created_at: string; last_active_at: string; thought_count?: number; }; const filters: ResolveSpecFilter[] = []; if (params?.q) { filters.push({ column: 'name', operator: 'ilike', value: `%${params.q}%` }); } const projectRows = await rsCall('/api/rs/public/projects', 'read', { options: { columns: ['guid', 'name', 'description', 'created_at', 'last_active_at'], computedColumns: [ { name: 'thought_count', expression: 'COALESCE((SELECT COUNT(*) FROM public.thoughts t WHERE t.project_id = projects.guid), 0)' } ], ...(filters.length ? { filters } : {}), sort: [{ column: 'created_at', direction: 'desc' }], limit: params?.limit ?? 100, offset: params?.offset ?? 0 } }); const projects = Array.isArray(projectRows) ? projectRows : []; return projects.map((project) => ({ id: project.guid, name: project.name, description: project.description ?? '', created_at: project.created_at, last_active_at: project.last_active_at, thought_count: project.thought_count ?? 0 })); }, create: (name: string, description: string) => rsCall('/api/rs/public/projects', 'create', { data: { name, description } }) }, thoughts: { list: (params: { q?: string; project_id?: string; limit?: number; offset?: number; include_archived?: boolean }) => { const filters: ResolveSpecFilter[] = []; if (params.q) { filters.push({ column: 'content', operator: 'ilike', value: `%${params.q}%` }); } if (params.project_id) { filters.push({ column: 'project_id', operator: 'eq', value: params.project_id }); } if (!params.include_archived) { filters.push({ column: 'archived_at', operator: 'empty' }); } return rsReadMany('thoughts', { filters, limit: params.limit ?? 100, ...(params.offset !== undefined ? { offset: params.offset } : {}), sort: [{ column: 'created_at', direction: 'desc' }] }).then((rows) => rows.map((row) => ({ ...row, content: typeof row.content === 'string' ? row.content : '', metadata: { ...(row.metadata ?? {}), people: row.metadata?.people ?? [], action_items: row.metadata?.action_items ?? [], dates_mentioned: row.metadata?.dates_mentioned ?? [], topics: row.metadata?.topics ?? [], type: row.metadata?.type ?? '', source: row.metadata?.source ?? '', metadata_status: row.metadata?.metadata_status ?? '' } })) ); }, links: async (thoughtID: string) => { const numericID = Number.parseInt(thoughtID, 10); if (Number.isNaN(numericID)) return []; const [outbound, inbound] = await Promise.all([ rsReadMany('thought_links', { filters: [{ column: 'from_id', operator: 'eq', value: numericID }], limit: 200, sort: [{ column: 'created_at', direction: 'desc' }] }), rsReadMany('thought_links', { filters: [{ column: 'to_id', operator: 'eq', value: numericID }], limit: 200, sort: [{ column: 'created_at', direction: 'desc' }] }) ]); const byID = new Map(); for (const link of outbound) byID.set(link.id, link); for (const link of inbound) byID.set(link.id, link); return Array.from(byID.values()).sort( (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() ); }, get: (id: string) => rsCall(`/api/rs/public/thoughts/${id}`, 'read').then((row) => ({ ...row, content: typeof row.content === 'string' ? row.content : '', metadata: { ...(row.metadata ?? {}), people: row.metadata?.people ?? [], action_items: row.metadata?.action_items ?? [], dates_mentioned: row.metadata?.dates_mentioned ?? [], topics: row.metadata?.topics ?? [], type: row.metadata?.type ?? '', source: row.metadata?.source ?? '', metadata_status: row.metadata?.metadata_status ?? '' } })), delete: (id: string) => rsCall(`/api/rs/public/thoughts/${id}`, 'delete'), archive: (id: string) => rsCall(`/api/rs/public/thoughts/${id}`, 'update', { data: { archived_at: new Date().toISOString() } }) }, skills: { list: async (tag?: string) => { const rows = await rsReadMany & { tags?: unknown }>('agent_skills', { filters: tag ? [{ column: 'tags', operator: 'contains', value: tag }] : undefined, limit: 500, sort: [{ column: 'created_at', direction: 'desc' }] }); return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) })); }, delete: (id: string) => rsCall(`/api/rs/public/agent_skills/${id}`, 'delete') }, guardrails: { list: async (params?: { tag?: string; severity?: string }) => { const filters: ResolveSpecFilter[] = []; if (params?.tag) filters.push({ column: 'tags', operator: 'contains', value: params.tag }); if (params?.severity) filters.push({ column: 'severity', operator: 'eq', value: params.severity }); const rows = await rsReadMany & { tags?: unknown }>('agent_guardrails', { filters, limit: 500, sort: [{ column: 'created_at', direction: 'desc' }] }); return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) })); }, delete: (id: string) => rsCall(`/api/rs/public/agent_guardrails/${id}`, 'delete') }, files: { list: (params?: { project_id?: string; thought_id?: string; kind?: string }) => { const filters: ResolveSpecFilter[] = []; if (params?.project_id) filters.push({ column: 'project_id', operator: 'eq', value: params.project_id }); if (params?.thought_id) filters.push({ column: 'thought_id', operator: 'eq', value: params.thought_id }); if (params?.kind) filters.push({ column: 'kind', operator: 'eq', value: params.kind }); return rsReadMany('stored_files', { filters, limit: 500, sort: [{ column: 'created_at', direction: 'desc' }] }); } }, maintenance: { tasks: () => rsReadMany('maintenance_tasks', { limit: 200, sort: [{ column: 'next_due', direction: 'asc' }] }), logs: () => rsReadMany('maintenance_logs', { limit: 200, sort: [{ column: 'completed_at', direction: 'desc' }] }), runBackfill: (input?: { project?: string; limit?: number; include_archived?: boolean; older_than_days?: number; dry_run?: boolean; }) => post('/api/admin/actions/backfill', { limit: input?.limit ?? 100, project: input?.project, include_archived: input?.include_archived ?? false, older_than_days: input?.older_than_days ?? 0, dry_run: input?.dry_run ?? false }), runRetryMetadata: (input?: { project?: string; limit?: number; include_archived?: boolean; older_than_days?: number; dry_run?: boolean; }) => post('/api/admin/actions/retry-metadata', { limit: input?.limit ?? 100, project: input?.project, include_archived: input?.include_archived ?? false, older_than_days: input?.older_than_days ?? 1, dry_run: input?.dry_run ?? false }) }, stats: async () => { type StatsThoughtRow = { metadata?: { type?: string; topics?: string[]; people?: string[]; }; }; const thoughts = await rsReadMany('thoughts', { limit: 5000, sort: [{ column: 'created_at', direction: 'desc' }] }); const typeCounts: Record = {}; const topicCounts: Record = {}; const peopleCounts: Record = {}; for (const thought of thoughts) { const type = thought.metadata?.type?.trim() || 'unknown'; typeCounts[type] = (typeCounts[type] ?? 0) + 1; for (const topic of thought.metadata?.topics ?? []) { const key = topic.trim(); if (!key) continue; topicCounts[key] = (topicCounts[key] ?? 0) + 1; } for (const person of thought.metadata?.people ?? []) { const key = person.trim(); if (!key) continue; peopleCounts[key] = (peopleCounts[key] ?? 0) + 1; } } const topEntries = (map: Record) => Object.entries(map) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([key, count]) => ({ key, count })); const stats: import('./types').ThoughtStats = { total_count: thoughts.length, type_counts: typeCounts, top_topics: topEntries(topicCounts), top_people: topEntries(peopleCounts) }; return stats; } };