import { GlobalStateStore } from './shellState'; import { currentTenantID, tenantScopeHeaders } from './tenantScope'; function authHeaders(): HeadersInit { const token = GlobalStateStore.getState().session.authToken; return { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...tenantScopeHeaders() }; } type ResolveSpecResponse = { success: boolean; data: T; metadata?: unknown; error?: { code: string; message: string; detail?: string }; }; type ResolveSpecFilter = { column: string; operator: string; value?: unknown; }; const TENANT_OWNED_RESOLVE_SPEC_ENTITIES = new Set([ 'projects', 'thoughts', 'learnings', 'plans', 'stored_files', 'agent_skills', 'agent_guardrails', 'agent_personas', 'agent_parts', 'agent_traits', 'character_arcs', 'chat_histories' ]); function resolveSpecEntity(path: string): string | null { const match = path.match(/^\/api\/rs\/public\/([^/?]+)/); return match?.[1] ?? null; } function tenantScopedResolveSpecPayload( path: string, operation: 'read' | 'create' | 'update' | 'delete', payload?: { data?: unknown; options?: unknown } ): { data?: unknown; options?: unknown } | undefined { const entity = resolveSpecEntity(path); const tenantID = currentTenantID(); if (!entity || !tenantID || !TENANT_OWNED_RESOLVE_SPEC_ENTITIES.has(entity)) return payload; if (operation === 'create') { const data = payload?.data; return { ...payload, // A selected tenant is authoritative; do not permit a form value to // accidentally create data in a different tenant. data: data && typeof data === 'object' && !Array.isArray(data) ? { ...data, tenant_id: tenantID } : data }; } const existingOptions = payload?.options && typeof payload.options === 'object' && !Array.isArray(payload.options) ? payload.options as Record : {}; const filters = Array.isArray(existingOptions.filters) ? existingOptions.filters.filter((filter): filter is ResolveSpecFilter => Boolean(filter) && typeof filter === 'object' && (filter as ResolveSpecFilter).column !== 'tenant_id') : []; return { ...payload, options: { ...existingOptions, filters: [...filters, { column: 'tenant_id', operator: 'eq', value: tenantID }] } }; } 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 patch(path: string, body: unknown): Promise { const res = await fetch(path, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify(body) }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json() as Promise; } async function rsCall( path: string, operation: 'read' | 'create' | 'update' | 'delete', payload?: { data?: unknown; options?: unknown } ): Promise { const scopedPayload = tenantScopedResolveSpecPayload(path, operation, payload); const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ operation, ...(scopedPayload?.data !== undefined ? { data: scopedPayload.data } : {}), ...(scopedPayload?.options !== undefined ? { options: scopedPayload.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 } }), update: (id: string, data: { name?: string; description?: string }) => rsCall(`/api/rs/public/projects/${id}`, 'update', { data }), delete: (id: string) => rsCall(`/api/rs/public/projects/${id}`, 'delete') }, 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() } }), create: (data: { content: string; project_id?: string }) => rsCall('/api/rs/public/thoughts', 'create', { data }), update: (id: string, data: { content?: string }) => rsCall(`/api/rs/public/thoughts/${id}`, 'update', { data }) }, 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) })); }, get: async (id: string) => { const row = await rsCall & { tags?: unknown }>( `/api/rs/public/agent_skills/${id}`, 'read' ); return { ...row, tags: normalizeTags(row.tags) }; }, create: (data: { name: string; description?: string; content: string; tags?: string[] }) => rsCall('/api/rs/public/agent_skills', 'create', { data }), delete: (id: string) => rsCall(`/api/rs/public/agent_skills/${id}`, 'delete'), update: (id: string, data: Partial) => rsCall(`/api/rs/public/agent_skills/${id}`, 'update', { data }) }, 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'), update: (id: string, data: Partial) => rsCall(`/api/rs/public/agent_guardrails/${id}`, 'update', { data }) }, 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 }) }, identity: { get: () => get('/api/admin/identity'), createTenant: (name: string) => post('/api/admin/identity/tenants', { name }), adoptLegacy: (id: string) => post(`/api/admin/identity/tenants/${id}/adopt-legacy`, {}), createUser: (data: { tenant_id: string; name: string; email: string }) => post('/api/admin/identity/users', data), createKey: (data: { tenant_id: string; user_id?: string; description: string }) => post<{ key: import('./types').IdentityKey; secret: string }>('/api/admin/identity/keys', data), updateKey: (id: string, data: { tenant_id: string; user_id?: string | null; description?: string; enabled?: boolean }) => patch(`/api/admin/identity/keys/${id}`, data) }, plans: { list: async (params?: { status?: string; priority?: string; project_id?: string; limit?: number }) => { const filters: ResolveSpecFilter[] = []; if (params?.status) filters.push({ column: 'status', operator: 'eq', value: params.status }); if (params?.priority) filters.push({ column: 'priority', operator: 'eq', value: params.priority }); if (params?.project_id) filters.push({ column: 'project_id', operator: 'eq', value: params.project_id }); const rows = await rsReadMany & { tags?: unknown }>('plans', { filters, limit: params?.limit ?? 500, sort: [{ column: 'updated_at', direction: 'desc' }] }); return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) })); }, get: async (id: string) => { const row = await rsCall & { tags?: unknown }>( `/api/rs/public/plans/${id}`, 'read' ); return { ...row, tags: normalizeTags(row.tags) }; }, create: (data: object) => rsCall('/api/rs/public/plans', 'create', { data }), update: (id: string, data: Partial) => rsCall(`/api/rs/public/plans/${id}`, 'update', { data }), delete: (id: string) => rsCall(`/api/rs/public/plans/${id}`, 'delete') }, learnings: { list: (params?: { limit?: number; offset?: number }) => rsReadMany('learnings', { limit: params?.limit ?? 500, ...(params?.offset !== undefined ? { offset: params.offset } : {}), sort: [{ column: 'created_at', direction: 'desc' }] }), get: (id: string) => rsCall(`/api/rs/public/learnings/${id}`, 'read'), create: (data: object) => rsCall('/api/rs/public/learnings', 'create', { data }), update: (id: string, data: Partial) => rsCall(`/api/rs/public/learnings/${id}`, 'update', { data }), delete: (id: string) => rsCall(`/api/rs/public/learnings/${id}`, 'delete') }, 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; } };