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
6 changes: 6 additions & 0 deletions packages/lifeboard/e2e/offline.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ await page.waitForTimeout(200);
ok("an unconnected console says so instead of swallowing the message",
/no assistant is connected/i.test((await page.locator(".lb-line--error").last().textContent()) ?? ""));

// Connections must work when nothing else does: it is the only screen that can fix "no assistant".
await page.getByRole("button", { name: "Connect" }).click();
await page.waitForTimeout(300);
ok("Connections opens offline and offers a way in", await page.locator(".lb-add-connection").isVisible());
ok("Connections says plainly that nothing is connected", /nothing connected yet/i.test((await page.locator("#board").textContent()) ?? ""));

ok(`no console errors (saw ${errors.length}${errors.length ? `: ${errors[0]}` : ""})`, errors.length === 0);

await browser.close();
Expand Down
90 changes: 90 additions & 0 deletions packages/lifeboard/src/agent/connections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Which assistant is connected, and how. Connections live in device preferences rather than in the
// fact log for one blunt reason: they hold secrets, and the log is append-only — anything written
// there can be corrected but never forgotten. A key you rotate must actually disappear.

import type { Prefs } from "../prefs.js";
import { anthropicProvider } from "./providers/anthropic.js";
import { googleProvider } from "./providers/google.js";
import { openaiProvider } from "./providers/openai.js";
import { openrouterProvider } from "./providers/openrouter.js";
import type { Fetch, Provider } from "./providers/types.js";

export type ProviderId = "openrouter" | "anthropic" | "openai" | "google";

export interface Connection {
id: string;
provider: ProviderId;
label: string;
model: string;
key: string;
}

const LIST_KEY = "lifeboard.connections";
const ACTIVE_KEY = "lifeboard.activeConnection";

export const DEFAULT_MODELS: Record<ProviderId, string> = {
openrouter: "anthropic/claude-opus-5",
anthropic: "claude-opus-5",
openai: "gpt-5",
google: "gemini-2.5-pro",
};

export const PROVIDER_LABELS: Record<ProviderId, string> = {
openrouter: "OpenRouter",
anthropic: "Claude",
openai: "OpenAI",
google: "Gemini",
};

export function listConnections(prefs: Prefs): Connection[] {
try {
const v = JSON.parse(prefs.get(LIST_KEY) ?? "[]");
return Array.isArray(v) ? (v as Connection[]).filter(isConnection) : [];
} catch {
// A corrupt preference must not stop the app booting; it degrades to "no assistant connected".
return [];
}
}

export function saveConnection(prefs: Prefs, c: Connection): void {
const next = [...listConnections(prefs).filter((x) => x.id !== c.id), c];
prefs.set(LIST_KEY, JSON.stringify(next));
if (!prefs.get(ACTIVE_KEY)) prefs.set(ACTIVE_KEY, c.id);
}

export function removeConnection(prefs: Prefs, id: string): void {
prefs.set(LIST_KEY, JSON.stringify(listConnections(prefs).filter((c) => c.id !== id)));
if (prefs.get(ACTIVE_KEY) === id) prefs.remove(ACTIVE_KEY);
}

export function setActiveConnection(prefs: Prefs, id: string): void {
prefs.set(ACTIVE_KEY, id);
}

/** The connection to use, or null when none is set up — the app must work in that state. */
export function activeConnection(prefs: Prefs): Connection | null {
const all = listConnections(prefs);
if (all.length === 0) return null;
const id = prefs.get(ACTIVE_KEY);
return all.find((c) => c.id === id) ?? all[0];
}

export function makeProvider(c: Connection, f?: Fetch): Provider {
switch (c.provider) {
case "anthropic": return anthropicProvider(c.key, c.model, f);
case "openai": return openaiProvider(c.key, c.model, f);
case "google": return googleProvider(c.key, c.model, f);
case "openrouter": return openrouterProvider(c.key, c.model, f);
}
}

export function activeProvider(prefs: Prefs, f?: Fetch): Provider | null {
const c = activeConnection(prefs);
return c ? makeProvider(c, f) : null;
}

const isConnection = (v: unknown): v is Connection =>
typeof v === "object" && v !== null &&
typeof (v as Connection).id === "string" &&
typeof (v as Connection).key === "string" &&
["openrouter", "anthropic", "openai", "google"].includes((v as Connection).provider);
136 changes: 136 additions & 0 deletions packages/lifeboard/src/agent/envelope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// The envelope is the entire contract with the model: one JSON object carrying what to say, what to
// change, and what to draw. Everything else in the turn is validation around it.
//
// Two rules shape this file. First, parse defensively — models wrap JSON in prose and fences, and a
// turn that fails because of a stray "Here you go:" is a turn wasted. Second, reject precisely: the
// error text is fed straight back as the repair prompt, so "mutations[1].collection" beats "invalid".

import { KNOWN_PUSH_TYPES, type KnownPushType } from "@ivanmkc/termchart-core";
import type { CollectionName, Mutation } from "../types.js";

export interface BoardSpec {
type: KnownPushType;
content: string;
title?: string;
space?: string;
}

export interface Envelope {
reply: string;
mutations?: Mutation[];
board?: BoardSpec;
}

export type ParseResult = { ok: true; value: Envelope } | { ok: false; error: string };

/**
* What the model may write. Narrower than the store's collections on purpose: `profile` is the
* family's own description of itself, `message` is the transcript, and `derivation` is the record of
* how a fact came to be — a model that could rewrite those could rewrite its own history.
*/
export const MODEL_WRITABLE: ReadonlySet<CollectionName> = new Set<CollectionName>([
"item", "recipe", "event", "contact",
]);

const PUSH_TYPES = new Set<string>(KNOWN_PUSH_TYPES);

export function parseEnvelope(raw: string, writable: ReadonlySet<CollectionName> = MODEL_WRITABLE): ParseResult {
const json = extractObject(raw);
if (json === null) return { ok: false, error: "no JSON object found in the reply — respond with the envelope object and nothing else" };

let v: unknown;
try {
v = JSON.parse(json);
} catch (e) {
return { ok: false, error: `invalid JSON: ${(e as Error).message}` };
}
if (!isObj(v)) return { ok: false, error: "the envelope must be a JSON object" };
if (typeof v.reply !== "string" || v.reply.trim() === "")
return { ok: false, error: 'envelope.reply must be a non-empty string — say something to the person, even when the board carries the detail' };

const env: Envelope = { reply: v.reply.trim() };

if (v.mutations !== undefined) {
if (!Array.isArray(v.mutations)) return { ok: false, error: "envelope.mutations must be an array" };
const out: Mutation[] = [];
for (let i = 0; i < v.mutations.length; i++) {
const r = parseMutation(v.mutations[i], `mutations[${i}]`, writable);
if (!r.ok) return r;
out.push(r.value);
}
if (out.length > 0) env.mutations = out;
}

if (v.board !== undefined && v.board !== null) {
const r = parseBoard(v.board);
if (!r.ok) return r;
env.board = r.value;
}

return { ok: true, value: env };
}

function parseMutation(v: unknown, path: string, writable: ReadonlySet<CollectionName>): { ok: true; value: Mutation } | { ok: false; error: string } {
if (!isObj(v)) return { ok: false, error: `${path} must be an object` };
const collection = v.collection;
if (typeof collection !== "string" || !writable.has(collection as CollectionName))
return { ok: false, error: `${path}.collection must be one of: ${[...writable].join(", ")} (got ${JSON.stringify(collection)})` };
const c = collection as CollectionName;

if (v.op === "add") {
if (!isObj(v.value)) return { ok: false, error: `${path}.value must be an object` };
if (typeof v.value.space !== "string" || !v.value.space)
return { ok: false, error: `${path}.value.space is required — "shared" for the family, or a profile id` };
return { ok: true, value: { op: "add", collection: c, value: v.value as Mutation extends { value: infer V } ? V : never } };
}
if (v.op === "update") {
if (typeof v.id !== "string" || !v.id) return { ok: false, error: `${path}.id is required` };
if (!isObj(v.patch)) return { ok: false, error: `${path}.patch must be an object` };
return { ok: true, value: { op: "update", collection: c, id: v.id, patch: v.patch } };
}
if (v.op === "delete") {
if (typeof v.id !== "string" || !v.id) return { ok: false, error: `${path}.id is required` };
return { ok: true, value: { op: "delete", collection: c, id: v.id } };
}
return { ok: false, error: `${path}.op must be "add", "update" or "delete" (got ${JSON.stringify(v.op)})` };
}

function parseBoard(v: unknown): { ok: true; value: BoardSpec } | { ok: false; error: string } {
if (!isObj(v)) return { ok: false, error: "envelope.board must be an object" };
if (typeof v.type !== "string" || !PUSH_TYPES.has(v.type))
return { ok: false, error: `envelope.board.type must be one of: ${KNOWN_PUSH_TYPES.join(", ")} (got ${JSON.stringify(v.type)})` };
if (v.content === undefined || v.content === null) return { ok: false, error: "envelope.board.content is required" };
// Structured types are objects here and strings on the wire; accept either and normalise, because
// which one a model emits is a coin toss and both are unambiguous.
const content = typeof v.content === "string" ? v.content : JSON.stringify(v.content);
const board: BoardSpec = { type: v.type as KnownPushType, content };
if (typeof v.title === "string" && v.title) board.title = v.title;
if (typeof v.space === "string" && v.space) board.space = v.space;
return { ok: true, value: board };
}

/** The first balanced JSON object in the text, ignoring braces inside strings. Handles fenced code,
* leading prose, and trailing chatter in one pass. */
export function extractObject(raw: string): string | null {
const start = raw.indexOf("{");
if (start < 0) return null;
let depth = 0;
let inStr = false;
let esc = false;
for (let i = start; i < raw.length; i++) {
const ch = raw[i];
if (inStr) {
if (esc) esc = false;
else if (ch === "\\") esc = true;
else if (ch === '"') inStr = false;
continue;
}
if (ch === '"') inStr = true;
else if (ch === "{") depth++;
else if (ch === "}" && --depth === 0) return raw.slice(start, i + 1);
}
return null;
}

const isObj = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
Loading
Loading