import type { OVConfig } from "./config.js"; // --- OV API Response Shapes --- // All OV responses wrap in: { status: "ok"|"error", result: T, error?: {...}, ... } // This client normalizes to { ok, result } internally. export interface OVSearchResult { uri: string; context_type: string; // "memory" | "resource" | "skill" score: number; abstract: string; overview: string | null; level: number; // 0=L0, 1=L1, 2=L2 category: string; match_reason: string; } export interface OVDirEntry { uri: string; name: string; isDir: boolean; size: number; mode: number; modTime: string; abstract: string; } export interface OVStatInfo { name: string; size: number; mode: number; modTime: string; isDir: boolean; isLocked: boolean; uri?: string; count?: number; // directories only } export interface OVSessionMeta { session_id: string; message_count: number; total_message_count?: number; commit_count: number; pending_tokens?: number; memories_extracted?: Record; last_commit_at?: string; } export interface OVSessionContext { latest_archive_overview: string | null; pre_archive_abstracts: any[]; messages: any[]; estimatedTokens: number; stats: { totalArchives: number; includedArchives: number; droppedArchives: number; failedArchives: number; activeTokens: number; archiveTokens: number; }; } export interface OVCommitResult { task_id?: string; archive_uri?: string; trace_id?: string; } export interface OVCommitResponse { result: OVCommitResult | null; traceId?: string; error?: any; status?: number; } export interface OVResponse { ok: boolean; result: T | null; error?: any; status?: number; traceId?: string; } export class OVClient { private baseUrl: string; private apiKey: string; private account: string; private user: string; private peerId: string; connected: boolean = false; private resolvedSpaces: Map = new Map(); private static RESERVED_USER = new Set(["memories"]); private static RESERVED_AGENT = new Set(["memories", "skills", "instructions", "workspaces"]); /** Read-only access to config (for value access across modules). */ readonly cfg: OVConfig; constructor(config: OVConfig) { this.cfg = config; this.baseUrl = config.endpoint.replace(/\/+$/, ""); this.apiKey = config.apiKey; this.account = config.account; this.user = config.user; this.peerId = config.peerId; } private headers(): Record { const h: Record = { "Content-Type": "application/json" }; if (this.apiKey) h["Authorization"] = `Bearer ${this.apiKey}`; if (this.account) h["X-OpenViking-Account"] = this.account; if (this.user) h["X-OpenViking-User"] = this.user; if (this.peerId) h["X-OpenViking-Actor-Peer"] = this.peerId; if (this.cfg.userAgent) h["User-Agent"] = this.cfg.userAgent; return h; } /** Core fetch wrapper. Returns { ok, result } after parsing OV's { status, result } envelope. */ async fetchJSON(path: string, init?: RequestInit, timeoutMs = 10000): Promise> { try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); const resp = await fetch(`${this.baseUrl}${path}`, { ...init, headers: { ...this.headers(), ...(init?.headers as Record || {}) }, signal: controller.signal, }); clearTimeout(timer); const body = await resp.json().catch(() => ({})); const traceId = body?.result?.trace_id || body?.error?.trace_id || body?.trace_id || undefined; if (!resp.ok || body.status === "error") { return { ok: false, result: null, status: resp.status, error: body.error || { message: `HTTP ${resp.status}` }, traceId, }; } return { ok: true, result: (body.result ?? body) as T, traceId }; } catch (err: any) { return { ok: false, result: null, status: 0, error: { message: err?.message || String(err) } }; } } // ========== Health ========== async health(): Promise { const res = await this.fetchJSON("/health", undefined, 5000); this.connected = res.ok; return res.ok; } // ========== Sessions ========== /** POST /api/v1/sessions — create or reuse session */ async createSession(sessionId: string): Promise { const res = await this.fetchJSON("/api/v1/sessions", { method: "POST", body: JSON.stringify({ session_id: sessionId }), }); return res.ok; } /** GET /api/v1/sessions/{id} — session metadata */ async getSession(sessionId: string, autoCreate = false): Promise { const q = autoCreate ? "?auto_create=true" : ""; const res = await this.fetchJSON( `/api/v1/sessions/${encodeURIComponent(sessionId)}${q}`, undefined, 5000, ); return res.ok ? res.result : null; } /** GET /api/v1/sessions/{id}/context — assembled context with archive overview */ async getSessionContext(sessionId: string, tokenBudget = 128000): Promise { const res = await this.fetchJSON( `/api/v1/sessions/${encodeURIComponent(sessionId)}/context?token_budget=${tokenBudget}`, undefined, 10000, ); return res.ok ? res.result : null; } /** POST /api/v1/sessions/{id}/messages — add a message (simple text mode) */ async addMessage(sessionId: string, role: string, content: string): Promise { const res = await this.fetchJSON( `/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`, { method: "POST", body: JSON.stringify({ role, content }) }, 10000, ); return res.ok; } /** POST /api/v1/sessions/{id}/messages — add a message with parts */ async addMessageParts(sessionId: string, role: string, parts: any[]): Promise { const res = await this.fetchJSON( `/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`, { method: "POST", body: JSON.stringify({ role, parts }) }, 10000, ); return res.ok; } async addMessagePayload(sessionId: string, payload: any): Promise { const res = await this.fetchJSON( `/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`, { method: "POST", body: JSON.stringify(payload) }, 10000, ); return res.ok; } /** POST /api/v1/sessions/{id}/commit — commit session for archiving + extraction */ async commitSessionResponse( sessionId: string, keepRecentCount = this.cfg.commitKeepRecentCount, ): Promise { const res = await this.fetchJSON( `/api/v1/sessions/${encodeURIComponent(sessionId)}/commit`, { method: "POST", body: JSON.stringify({ keep_recent_count: keepRecentCount }) }, 30000, ); if (res.ok && res.result && !res.result.trace_id && res.traceId) { res.result.trace_id = res.traceId; } return { result: res.ok ? res.result : null, traceId: res.traceId, error: res.error, status: res.status, }; } async commitSession( sessionId: string, keepRecentCount = this.cfg.commitKeepRecentCount, ): Promise { return (await this.commitSessionResponse(sessionId, keepRecentCount)).result; } /** DELETE /api/v1/sessions/{id} */ async deleteSession(sessionId: string): Promise { const res = await this.fetchJSON( `/api/v1/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" }, 10000, ); return res.ok; } // ========== Search ========== /** POST /api/v1/search/find — basic vector search */ async find( query: string, opts?: { targetUri?: string; topK?: number; scoreThreshold?: number }, ): Promise { const body: Record = { query }; if (opts?.targetUri) body.target_uri = opts.targetUri; if (opts?.topK) body.limit = opts.topK; if (opts?.scoreThreshold) body.score_threshold = opts.scoreThreshold; const res = await this.fetchJSON("/api/v1/search/find", { method: "POST", body: JSON.stringify(body), }, 10000); if (!res.ok || !res.result) return []; // OV returns { memories: [...], resources: [...], skills: [...], total } const all: OVSearchResult[] = []; for (const bucket of ["memories", "resources", "skills"]) { const items = res.result[bucket]; if (Array.isArray(items)) { for (const m of items) { all.push({ uri: m.uri ?? "", context_type: m.context_type ?? (bucket === "memories" ? "memory" : bucket === "skills" ? "skill" : "resource"), score: m.score ?? 0, abstract: m.abstract ?? "", overview: m.overview ?? null, level: m.level ?? 0, category: m.category ?? "", match_reason: m.match_reason ?? "", }); } } } return all; } // ========== Content ========== /** GET /api/v1/content/abstract — L0 summary */ async abstract(uri: string): Promise { const res = await this.fetchJSON( `/api/v1/content/abstract?uri=${encodeURIComponent(uri)}`, undefined, 10000, ); return res.ok ? res.result : null; } /** GET /api/v1/content/overview — L1 overview (directories only) */ async overview(uri: string): Promise { const res = await this.fetchJSON( `/api/v1/content/overview?uri=${encodeURIComponent(uri)}`, undefined, 10000, ); return res.ok ? res.result : null; } /** GET /api/v1/content/read — L2 full content (files only) */ async readContent(uri: string): Promise { const res = await this.fetchJSON( `/api/v1/content/read?uri=${encodeURIComponent(uri)}`, undefined, 10000, ); return res.ok ? res.result : null; } // ========== Filesystem ========== /** GET /api/v1/fs/ls — list directory */ async ls(uri: string): Promise { const res = await this.fetchJSON( `/api/v1/fs/ls?uri=${encodeURIComponent(uri)}`, undefined, 10000, ); if (!res.ok || !Array.isArray(res.result)) return []; return res.result.map(e => ({ uri: e.uri ?? "", name: e.name ?? uriBasename(e.uri ?? ""), isDir: e.isDir ?? false, size: e.size ?? 0, mode: e.mode ?? 0, modTime: e.modTime ?? "", abstract: e.abstract ?? "", })); } /** GET /api/v1/fs/stat — file/directory metadata */ async stat(uri: string): Promise { const res = await this.fetchJSON( `/api/v1/fs/stat?uri=${encodeURIComponent(uri)}`, undefined, 10000, ); return res.ok ? res.result : null; } /** DELETE /api/v1/fs — remove file or directory */ async delete(uri: string, recursive = false): Promise { const res = await this.fetchJSON( `/api/v1/fs?uri=${encodeURIComponent(uri)}&recursive=${recursive}`, { method: "DELETE" }, 10000, ); return res.ok; } // ========== Resources ========== /** POST /api/v1/resources — ingest a URL or file path */ async addResource( path: string, opts?: { to?: string }, ): Promise<{ root_uri: string } | null> { const body: Record = { path }; if (opts?.to) body.to = opts.to; const res = await this.fetchJSON<{ root_uri: string }>( "/api/v1/resources", { method: "POST", body: JSON.stringify(body) }, 30000, ); return res.ok ? res.result : null; } // ========== URI Space Resolution ========== async resolveScopeSpace(scope: "user" | "agent"): Promise { const cached = this.resolvedSpaces.get(scope); if (cached) return cached; // Probe system status for user identity fallback let fallbackSpace = "default"; const statusRes = await this.fetchJSON("/api/v1/system/status", undefined, 5000); if (statusRes.ok && typeof statusRes.result?.user === "string" && statusRes.result.user.trim()) { fallbackSpace = statusRes.result.user.trim(); } // List scope root for actual namespaces const reserved = scope === "user" ? OVClient.RESERVED_USER : OVClient.RESERVED_AGENT; const entries = await this.ls(`viking://${scope}/`); const spaces = entries .filter(e => e.isDir && !e.name.startsWith(".") && !reserved.has(e.name)) .map(e => e.name); if (spaces.length > 0) { // Prefer the fallback space if it exists, then "default", then first available let chosen = spaces[0]; if (spaces.includes(fallbackSpace)) chosen = fallbackSpace; else if (spaces.includes("default")) chosen = "default"; this.resolvedSpaces.set(scope, chosen); return chosen; } this.resolvedSpaces.set(scope, fallbackSpace); return fallbackSpace; } async resolveTargetUri(targetUri: string): Promise { const trimmed = targetUri.trim().replace(/\/+$/, ""); const m = trimmed.match(/^viking:\/\/(user|agent)(?:\/(.*))?$/); if (!m) return trimmed; const scope = m[1] as "user" | "agent"; const rawRest = (m[2] ?? "").trim(); if (!rawRest) return trimmed; const parts = rawRest.split("/").filter(Boolean); if (parts.length === 0) return trimmed; const reserved = scope === "user" ? OVClient.RESERVED_USER : OVClient.RESERVED_AGENT; if (!reserved.has(parts[0])) return trimmed; // already has space const space = await this.resolveScopeSpace(scope); return `viking://${scope}/${space}/${parts.join("/")}`; } } function uriBasename(uri: string): string { const cleaned = uri.replace(/\/+$/, ""); const last = cleaned.lastIndexOf("/"); return last >= 0 ? cleaned.slice(last + 1) : cleaned; }