Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373// The page only ever talks to its own origin: `/api` is forwarded to the// daemon with its token by `albedo web` (or by Vite in development).
import type { components } from './api.gen';
export type Phase = components['schemas']['SessionStatus']['phase'];
export type Session = components['schemas']['SessionSummary'];export type PreferencePatch = components['schemas']['PreferencePatch'];
interface Page<T> { items: T[]; next: string | null;}
export class ApiError extends Error { constructor( readonly status: number, readonly code: string, message: string, ) { super(message); }}
async function request<T>(method: string, path: string, init: { signal?: AbortSignal; body?: unknown; headers?: Record<string, string> } = {}): Promise<T> { const response = await fetch(`/api${path}`, { method, signal: init.signal, headers: { accept: 'application/json', ...(init.body === undefined ? {} : { 'content-type': 'application/json' }), ...init.headers }, body: init.body === undefined ? undefined : JSON.stringify(init.body), }); if (!response.ok) { // The daemon answers problems as RFC 9457 bodies; `albedo web` uses {error}. const body = await response.json().catch(() => null); const problem = body?.error ?? body ?? {}; throw new ApiError(response.status, problem.code ?? 'http_error', problem.detail ?? problem.title ?? response.statusText); } return response.status === 204 ? (undefined as T) : response.json();}
function get<T>(path: string, signal?: AbortSignal): Promise<T> { return request<T>('GET', path, { signal });}
// Older sessions may lack either time; activity falls back to creation.export function listSessions(signal?: AbortSignal): Promise<Session[]> { return get<Page<Session>>('/sessions?scope=roots&limit=200', signal).then((page) => page.items.map((session) => ({ ...session, activity_at: session.activity_at ?? session.created_at })), );}
export type EntryKind = HistoryEntry['kind'];
export type ContentPart = HistoryEntry['content'][number];
export type HistoryEntry = components['schemas']['HistoryEntry'];
export type HistoryPage = components['schemas']['HistoryPage'];
/** A page of a session's transcript, oldest first: the newest page, or the one before or after a position. */export function readHistory(sessionId: string, at: { before?: number; after?: number; limit?: number } = {}, signal?: AbortSignal): Promise<HistoryPage> { const query = new URLSearchParams({ limit: String(at.limit ?? 100) }); if (at.before !== undefined) query.set('before', String(at.before)); if (at.after !== undefined) query.set('after', String(at.after)); return get(`/sessions/${sessionId}/history?${query}`, signal);}
export type RequestRecord = components['schemas']['ProviderRequestRecord'];
/** * A page of a session's provider requests, oldest first: every model call * with its timing and tokens. `after` is a request's sequence. */export function readRequests(sessionId: string, after = 0, limit = 200, signal?: AbortSignal, since?: number): Promise<{ items: RequestRecord[]; next: string | null }> { const query = new URLSearchParams({ view: 'requests', after: String(after), limit: String(limit) }); if (since !== undefined) query.set('since_ms', String(since)); return get(`/sessions/${sessionId}/context?${query}`, signal);}
/** One session as the daemon has it now, without its history. */export function getSession(sessionId: string, signal?: AbortSignal): Promise<Session> { return get(`/sessions/${sessionId}?tail=0`, signal);}
export type ContextWindow = components['schemas']['ContextWindow'];
/** The window the session compacts against, which the context meter measures its share by. */export function getContextWindow(sessionId: string, signal?: AbortSignal): Promise<ContextWindow> { return get(`/sessions/${sessionId}/context-window`, signal);}
export type ContextSummary = components['schemas']['ContextSummary'];
/** The prepared request's section summary and compaction threshold, without preparing a request. */export function getContextSummary(sessionId: string, signal?: AbortSignal): Promise<ContextSummary> { return get(`/sessions/${sessionId}/context?view=summary`, signal);}
/** The sessions a session started: its child agents, with their own status. */export function listChildren(sessionId: string, signal?: AbortSignal): Promise<Session[]> { return get<Page<Session>>(`/sessions?parent_id=${encodeURIComponent(sessionId)}&limit=200`, signal).then((page) => page.items);}
/** A whole family: the root session and every agent under it, however deep, with their own status. */export function listFamily(rootId: string, signal?: AbortSignal): Promise<Session[]> { return get<Page<Session>>(`/sessions?family_id=${encodeURIComponent(rootId)}&limit=200`, signal).then((page) => page.items);}
type ContentPage = components['schemas']['EntryContentPage'];
/** * Every stored field of one entry in full, for content a history page only * referenced. `url` is the reference's own, which may name another entry. */export async function readEntryContent(url: string): Promise<Record<string, string>> { const fields: Record<string, string> = {}; let next: string | null = null; do { const query: string = next ? `?next=${encodeURIComponent(next)}` : ''; const page: ContentPage = await get(`${url}${query}`); for (const part of page.parts) fields[part.field] = (fields[part.field] ?? '') + part.text; next = page.next; } while (next); return fields;}
/** * Several entries' stored fields in full, in one walk: one request per byte * budget rather than per entry. `ids` are at most 200 entry ids. */export async function readContentBatch(sessionId: string, ids: string[], signal?: AbortSignal): Promise<Map<string, Record<string, string>>> { const fields = new Map<string, Record<string, string>>(); let next: string | null = null; do { const query = new URLSearchParams({ entries: ids.join(',') }); if (next) query.set('next', next); const page: components['schemas']['EntryContentBatch'] = await get(`/sessions/${sessionId}/history/content?${query}`, signal); for (const part of page.parts) { const entry = fields.get(part.entry_id) ?? {}; entry[part.field] = (entry[part.field] ?? '') + part.text; fields.set(part.entry_id, entry); } next = page.next; } while (next); return fields;}
/** * Puts text on the clipboard. Over plain http from another device the page * is not a secure context and has no Clipboard API, so it falls back to the * older copy command. */export async function copyText(text: string): Promise<void> { if (navigator.clipboard) return navigator.clipboard.writeText(text); const field = Object.assign(document.createElement('textarea'), { value: text }); field.style.cssText = 'position:fixed;opacity:0'; document.body.append(field); field.select(); const copied = document.execCommand('copy'); field.remove(); if (!copied) throw new Error('copying is not available here');}
/** A UUIDv7: the daemon keys inputs by one, so a retried send is the same send. */export function uuidv7(): string { const bytes = crypto.getRandomValues(new Uint8Array(16)); let time = Date.now(); for (let i = 5; i >= 0; i--) { bytes[i] = time % 256; time = Math.floor(time / 256); } bytes[6] = (bytes[6] & 0x0f) | 0x70; bytes[8] = (bytes[8] & 0x3f) | 0x80; const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join(''); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;}
/** * Stops the session's running turn and any inputs waiting behind it. The run * and input order are read fresh, so a stop never reaches a later run. */export async function interruptSession(sessionId: string): Promise<void> { const session = await get<components['schemas']['Session']>(`/sessions/${sessionId}`); await request('POST', `/sessions/${sessionId}/interrupt`, { body: { run_id: session.status.run_id, through_input_order: session.input_order } });}
/** One background job as the kernel's job book keeps it; times are wall-clock milliseconds. */export type Job = components['schemas']['JobRecord'];
/** A page of a job's output: the bytes from `offset` to `next_offset`, cut on characters. */export type JobPage = components['schemas']['JobOutputPage'];
/** The session's jobs, newest first, finished ones too while the kernel keeps their output. */export function listJobs(sessionId: string, signal?: AbortSignal): Promise<Job[]> { return get<components['schemas']['JobRecordCollection']>(`/extensions/run/sessions/${sessionId}/jobs/recent`, signal).then((page) => page.items);}
/** A page of a job's output: from `offset`, or the last page when there is none. */export function readJobOutput(sessionId: string, jobId: string, offset?: number, signal?: AbortSignal): Promise<JobPage> { const query = new URLSearchParams({ limit: '65536' }); if (offset !== undefined) query.set('offset', String(offset)); return get(`/extensions/run/sessions/${sessionId}/jobs/${jobId}/output?${query}`, signal);}
/** Stops the job's process group; the kernel answers once it is gone. */export async function stopJob(sessionId: string, jobId: string): Promise<void> { await request('POST', `/extensions/run/sessions/${sessionId}/jobs/${jobId}/stop`);}
/** A folder sessions have worked in, most used first. */export type RecentWorkspace = components['schemas']['RecentWorkspace'];
export type Directory = components['schemas']['WorkspaceDirectory'];
export function recentWorkspaces(): Promise<RecentWorkspace[]> { return get<Page<RecentWorkspace>>('/workspaces?limit=200').then((page) => page.items);}
export function browse(location: string): Promise<Directory> { return get<Directory>(`/workspaces?location=${encodeURIComponent(location)}&limit=200`);}
/** A provider profile: an account and endpoint, with the model and effort a new session starts on. */export type Profile = Pick<components['schemas']['ProviderProfile'], 'extension' | 'model' | 'effort'> & { id: string };
export async function profiles(): Promise<{ fallback: string | null; profiles: Profile[] }> { const providers = await get<components['schemas']['ProviderSettings']>('/settings?group=providers'); const list = Object.entries(providers.profiles).map(([id, profile]) => ({ ...profile, id })); return { fallback: providers.default_profile, profiles: list.sort((a, b) => a.id.localeCompare(b.id)) };}
export type Model = components['schemas']['Model'];
/** A profile's whole catalogue, every page of it. */export async function models(profile: string): Promise<Model[]> { const items: Model[] = []; let next: string | null = null; do { const query: string = next ? `&next=${encodeURIComponent(next)}` : ''; const page: Page<Model> = await get(`/models?provider_profile=${encodeURIComponent(profile)}${query}`); items.push(...page.items); next = page.next; } while (next); return items;}
export type Choice = Pick<components['schemas']['SessionConfiguration'], 'provider_profile' | 'model' | 'effort'>;export type SessionConfiguration = components['schemas']['SessionConfiguration'];
/** Starts a session in a folder; null choices take the defaults. */export function createSession(workspace: string, choice: Choice): Promise<Session> { // If-None-Match makes the PUT a create: it never replaces a session. return request<Session>('PUT', `/sessions/${uuidv7()}`, { body: { kind: 'new', workspace, ...choice }, headers: { 'if-none-match': '*' } });}
/** Changes a session's profile, model, effort or pin and archive marks against a fresh read; a 412 from a write in between is retried once. */export async function configureSession(sessionId: string, change: Partial<Choice> | { preferences: PreferencePatch }): Promise<SessionConfiguration | undefined> { try { return await patchConfiguration(sessionId, change); } catch (error) { if (!(error instanceof ApiError && error.status === 412)) throw error; return await patchConfiguration(sessionId, change); }}
async function patchConfiguration(sessionId: string, change: Partial<Choice> | { preferences: PreferencePatch }): Promise<SessionConfiguration | undefined> { const url = `/sessions/${sessionId}?view=configuration`; const response = await fetch(`/api${url}`, { headers: { accept: 'application/json' } }); if (!response.ok) throw new ApiError(response.status, 'http_error', response.statusText); const etag = response.headers.get('etag') ?? ''; const resource = await request<{ value?: SessionConfiguration }>('PATCH', url, { body: change, headers: { 'content-type': 'application/merge-patch+json', 'if-match': etag }, }); return resource?.value;}
/** A session's current choice of profile, model and effort. */export function sessionChoice(sessionId: string): Promise<Choice> { return get<Choice>(`/sessions/${sessionId}?view=configuration`);}
/** An MCP server and its OAuth sign-in, as the mcp settings group reports them; tokens never reach the page. */export type McpServer = components['schemas']['MCPDefinition'] & { name: string };export type McpAuth = components['schemas']['MCPAuth'];export type Login = components['schemas']['Login'];
export async function mcpServers(): Promise<McpServer[]> { const group = await get<components['schemas']['MCPSettings']>('/settings?group=mcp'); return Object.entries(group.definitions) .map(([name, server]) => ({ ...server, name })) .sort((a, b) => a.name.localeCompare(b.name));}
/** Starts the daemon's browser sign-in for one MCP server; the login carries the authorize URL. */export function signInToMcp(server: string): Promise<Login> { return request('PUT', `/auth/logins/${uuidv7()}`, { body: { provider: `mcp:${server}`, flow: 'browser', values: {} } });}
export function readLogin(id: string, signal?: AbortSignal): Promise<Login> { return get(`/auth/logins/${id}`, signal);}
export function cancelLogin(id: string): Promise<void> { return request('DELETE', `/auth/logins/${id}`);}
/** Drops an MCP server's OAuth tokens against a fresh read of the group; the server stays saved. */export async function signOutOfMcp(server: string): Promise<void> { const response = await fetch('/api/settings?group=mcp', { headers: { accept: 'application/json' } }); if (!response.ok) throw new ApiError(response.status, 'http_error', response.statusText); await request('PATCH', '/settings?group=mcp', { body: { definitions: { [server]: { secrets: { oauth: null } } }, validate_connection: false }, headers: { 'content-type': 'application/merge-patch+json', 'if-match': response.headers.get('etag') ?? '' }, });}
export type ImageUpload = components['schemas']['ImageUpload'];
/** Sends a slash command or skill as an input, with its arguments as a form. */export function sendCommand(sessionId: string, inputId: string, commandId: string, fields: Record<string, unknown>): Promise<unknown> { return request('PUT', `/sessions/${sessionId}/inputs/${inputId}`, { body: { kind: 'command', command_id: commandId, arguments: fields } });}
export function sendMessage(sessionId: string, inputId: string, text: string, images: ImageUpload[] = []): Promise<unknown> { const body = images.length ? { kind: 'message', text, images } : { kind: 'message', text }; return request('PUT', `/sessions/${sessionId}/inputs/${inputId}`, { body });}
export type SkillLoad = components['schemas']['SkillLoad'];export type SkillLoadSummary = components['schemas']['SkillLoadSummary'];
/** The skills a session loaded, by its model or its person, first loaded first. */export function listSkillLoads(sessionId: string, signal?: AbortSignal): Promise<SkillLoadSummary[]> { return get<components['schemas']['SkillLoadList']>(`/extensions/skills/loads?session_id=${encodeURIComponent(sessionId)}`, signal).then((page) => page.items);}
export type Artifact = components['schemas']['Artifact'];export type ArtifactDetail = components['schemas']['ArtifactDetail'];export type ArtifactVersion = components['schemas']['ArtifactVersion'];
/** Every artifact, newest change first; with `session`, only that session's. */export function listArtifacts(options: { session?: string; limit?: number; signal?: AbortSignal } = {}): Promise<Artifact[]> { const query = new URLSearchParams({ limit: String(options.limit ?? 200) }); if (options.session) query.set('session', options.session); return get<{ items: Artifact[] }>(`/extensions/artifacts/items?${query}`, options.signal).then((page) => page.items);}
/** One artifact with every version, newest first. */export function getArtifact(id: string, signal?: AbortSignal): Promise<ArtifactDetail> { return get(`/extensions/artifacts/items/${id}`, signal);}
/** Deletes an artifact and all its versions, from the gallery and for good. */export function deleteArtifact(id: string): Promise<void> { return request('DELETE', `/extensions/artifacts/items/${id}`);}
/** * Where one version's page is served: `albedo web`'s sandboxed frame route, * whose key is the capability (robot-docs/artifacts.md). */export function frameUrl(artifact: Pick<Artifact, 'id' | 'key'>, version: number, entry: string): string { const path = entry.split('/').map(encodeURIComponent).join('/'); return `/frame/${artifact.id}.${artifact.key}/${version}/${path}`;}
/** The page's address that follows the newest version, reloading as it moves. */export function latestFrameUrl(artifact: Pick<Artifact, 'id' | 'key'>): string { return `/frame/${artifact.id}.${artifact.key}/latest/`;}