Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
901 changes: 901 additions & 0 deletions examples/pi-coding-agent-extension/DESIGN.md

Large diffs are not rendered by default.

251 changes: 251 additions & 0 deletions examples/pi-coding-agent-extension/README.md

Large diffs are not rendered by default.

375 changes: 375 additions & 0 deletions examples/pi-coding-agent-extension/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,375 @@
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<string, number>;
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 class OVClient {
private baseUrl: string;
private apiKey: string;
private account: string;
private user: string;
private agent: string;
connected: boolean = false;

private resolvedSpaces: Map<string, string> = 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.agent = config.agentId;
}

private headers(): Record<string, string> {
const h: Record<string, string> = { "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.agent) h["X-OpenViking-Agent"] = this.agent;
return h;
}

/** Core fetch wrapper. Returns { ok, result } after parsing OV's { status, result } envelope. */
private async fetchJSON<T>(path: string, init?: RequestInit, timeoutMs = 10000): Promise<{ ok: boolean; result: T | null; error?: any }> {
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<string, string> || {}) },
signal: controller.signal,
});
clearTimeout(timer);
const body = await resp.json().catch(() => ({}));
if (!resp.ok || body.status === "error") {
return { ok: false, result: null, error: body.error || { message: `HTTP ${resp.status}` } };
}
return { ok: true, result: (body.result ?? body) as T };
} catch (err: any) {
return { ok: false, result: null, error: { message: err?.message || String(err) } };
}
}

// ========== Health ==========

async health(): Promise<boolean> {
const res = await this.fetchJSON<any>("/health", undefined, 5000);
this.connected = res.ok;
return res.ok;
}

// ========== Sessions ==========

/** POST /api/v1/sessions — create or reuse session */
async createSession(sessionId: string): Promise<boolean> {
const res = await this.fetchJSON<any>("/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<OVSessionMeta | null> {
const q = autoCreate ? "?auto_create=true" : "";
const res = await this.fetchJSON<OVSessionMeta>(
`/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<OVSessionContext | null> {
const res = await this.fetchJSON<OVSessionContext>(
`/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<boolean> {
const res = await this.fetchJSON<any>(
`/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<boolean> {
const res = await this.fetchJSON<any>(
`/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`,
{ method: "POST", body: JSON.stringify({ role, parts }) },
10000,
);
return res.ok;
}

/** POST /api/v1/sessions/{id}/commit — commit session for archiving + extraction */
async commitSession(sessionId: string): Promise<{ task_id: string; archive_uri: string } | null> {
const res = await this.fetchJSON<{ task_id: string; archive_uri: string }>(
`/api/v1/sessions/${encodeURIComponent(sessionId)}/commit`,
{ method: "POST", body: JSON.stringify({}) },
30000,
);
return res.ok ? res.result : null;
}

/** DELETE /api/v1/sessions/{id} */
async deleteSession(sessionId: string): Promise<boolean> {
const res = await this.fetchJSON<any>(
`/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<OVSearchResult[]> {
const body: Record<string, unknown> = { 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<any>("/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<string | null> {
const res = await this.fetchJSON<string>(
`/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<string | null> {
const res = await this.fetchJSON<string>(
`/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<string | null> {
const res = await this.fetchJSON<string>(
`/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<OVDirEntry[]> {
const res = await this.fetchJSON<any[]>(
`/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<OVStatInfo | null> {
const res = await this.fetchJSON<OVStatInfo>(
`/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<boolean> {
const res = await this.fetchJSON<any>(
`/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<string, unknown> = { 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<string> {
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<any>("/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<string> {
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;
}
Loading