diff --git a/AMICODE-PATCHES.md b/AMICODE-PATCHES.md index e89ce2c289..0834bb663e 100644 --- a/AMICODE-PATCHES.md +++ b/AMICODE-PATCHES.md @@ -225,3 +225,23 @@ Rebuilt with the exact T3 recipe (`OPENCODE_VERSION=1.17.3 bun run script/build. - index.css: @font-face for both (JuliaMono full glyph set — Julia Unicode; Racing Sans One latin subset, font-display swap). logo.tsx + wordmark-v2.tsx: font-family 'Racing Sans One' first, weight 700→400. settings.tsx: monoDefault/monoFallback lead with JuliaMono. theme.css: --font-family-mono leads with JuliaMono. Terminal font DELIBERATELY unchanged (JetBrainsMono Nerd Font Mono via separate terminalFallback). - New assets (git-added — build breaks without them): public/assets/RacingSansOne-Regular.woff2 (21 KB) + JuliaMono-Regular.woff2 (946 KB). - Font build sha256: `2a15da111be516516fb1fbd1a4fb5ae02ad9bddd6373ede08cdf7b28c19d163a` (dist/opencode-local + vendored path, write-temp + mv -f swap; SUPERSEDES #13's a73d8583… — same code, fonts now committed). Verify (scratch port 14099): `GET /assets/RacingSansOne-Regular.woff2` → 200 font/woff2 21804 B; `GET /assets/JuliaMono-Regular.woff2` → 200 font/woff2 946516 B; "Racing Sans One" in built css + index/new-session chunks, "JuliaMono" in `index-Dwtxigfs.css`; `GET /amicode/problems` → 200; `GET /` → 200 `Amicode`; ui `bun test src` → 70 pass; typecheck green ui+app (no snapshots assert fonts, per Aaron — confirmed nothing went red). Bonus confirmation: KaTeX\_\* woff2 assets now in dist — the entity view's katex import (#13) pulls its font set into the embed. +9. (home CTA fallback) — amicode(home): "Open chat" works on a fresh profile. + - BUG: `startWithPrompt` (fork wiring for the Meet-Amico card, patch 5ef6b7e0e) dead-ended + silently when the persisted client-side project list was empty (fresh browser profile + against a bare `opencode serve`): the `!project` branch called `openNewSession()`, which + needs the SAME empty `newSessionProject()` and hits `if (!conn || !project) return`. + Primary home CTA did nothing, no error. Hit live 2026-07-08 (web UI on a scratch dir). + - FIX (packages/app/src/pages/home.tsx, `startWithPrompt` only): when no project is + tracked, fall back to the focused server's own working directory — + `focusedSync().data.path.directory` (synced from GET /path; "" until loaded, so the + falsy guard holds) — open+touch it as a project (self-heals the home page), then + `tabs.newDraft` with the prompt preserved. Deliberately NOT `sync.data.project`: + the server's "global" project record has worktree "/". + - Regression spec: packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts — + fresh profile (NO localStorage seed), mocked server, click the CTA (`exact: true` — + the whole card is also a button whose accessible name contains "Open chat"), expect + navigation to `/new-session?draftId=` + the cwd persisted as a tracked project. + Verified failing on the unfixed code, passing with the fix. Playwright note: config + reuses any server on port 3000 (`reuseExistingServer`) — run with `PLAYWRIGHT_PORT=` + if something else (e.g. the harmoniqs website dev server) holds 3000. + - Checks: `tsgo -b` clean; `bun run test:unit` 376 pass / 0 fail. diff --git a/packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts b/packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts new file mode 100644 index 0000000000..39ee2ca9cc --- /dev/null +++ b/packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from "@playwright/test" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" + +// Regression: on a fresh profile (no tracked projects in localStorage) the +// home "Open chat" CTA dead-ended silently — startWithPrompt fell through to +// openNewSession(), which needs the same newSessionProject() that just came +// back empty. It must instead fall back to the server's own working +// directory (GET /path → .directory) and start a draft there, tracking the +// directory as a project so the rest of the home page works from then on. +test("home 'Open chat' falls back to the server cwd on a fresh profile", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: [], + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + + // Deliberately NO localStorage seed — an empty tracked-project list is the + // regression condition (contrast: session-list-path-loading.spec.ts seeds it). + await page.goto("/") + // exact: true — the whole Meet-Amico card is also a button whose accessible + // name contains "Open chat"; we want the CTA inside it. + await page.getByRole("button", { name: "Open chat", exact: true }).click() + + // Navigates to a new-session draft instead of doing nothing. + await expect(page).toHaveURL(/\/new-session\?draftId=/) + + // And the server cwd is now a tracked project (the self-healing part). + const persisted = await page.evaluate(() => localStorage.getItem("opencode.global.dat:server") ?? "") + expect(persisted).toContain(fixture.directory) +}) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index aa0d177af6..3ca9e08e48 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -62,10 +62,10 @@ import { ServerHealthIndicator } from "@/components/server/server-row" import { type ServerHealth } from "@/utils/server-health" import { amicodeGet, amicodePost } from "@/utils/amicode-fetch" import { AmicodeRunGallery } from "@opencode-ai/ui/amicode-run-gallery" +import { AmicodeOnboardingWizard, shouldShowWizard } from "@opencode-ai/ui/amicode-onboarding-wizard" import { parseRunCardsResponse } from "@opencode-ai/ui/amicode-run-card" import { AmicodeHomeCards, parseProfileResponse, type HomeLiveRun } from "@opencode-ai/ui/amicode-home-cards" import { parseRunSeriesResponse } from "@opencode-ai/ui/amicode-run-window" -import { AMICODE_STARTERS } from "@opencode-ai/ui/amicode-getting-started" import { parseProblemsResponse } from "@opencode-ai/ui/amicode-problem-switcher" import { parseProblemResponse } from "@opencode-ai/ui/amicode-entity-view" import { Mark } from "@opencode-ai/ui/logo" @@ -339,13 +339,81 @@ function HomeDesign() { }) const [sessionsExpanded, setSessionsExpanded] = createSignal(false) + // Shared by the About-You card and the onboarding wizard: identity fields + // ride query params on the raw POST route; refetch renders the saved state. + async function saveProfileFields(fields: Record) { + const q = new URLSearchParams() + for (const [k, v] of Object.entries(fields)) if (v !== undefined) q.set(k, v) + await amicodePost(focusedServer(), `/amicode/profile?${q.toString()}`) + await refetchProfile() + } + + // Onboarding wizard (session zero): decided ONCE when the profile first + // resolves — the mid-wizard profile refetch must not unmount the preview + // step, and a dismiss is remembered per install (localStorage). + // Library (papers that make Amico smarter): count + latest for the card. + const [libraryRaw, { refetch: refetchLibrary }] = createResource( + () => state.selection.server, + () => amicodeGet(focusedServer(), "/amicode/library").catch(() => undefined), + ) + const libraryView = createMemo(() => { + const raw = libraryRaw() as { ok?: boolean; papers?: { name?: string; path?: string }[] } | undefined + if (!raw || raw.ok !== true || !Array.isArray(raw.papers)) return undefined + return { + count: raw.papers.length, + latestName: typeof raw.papers[0]?.name === "string" ? raw.papers[0].name : undefined, + latestPath: typeof raw.papers[0]?.path === "string" ? raw.papers[0].path : undefined, + } + }) + async function uploadPaper(filename: string, dataB64: string) { + const res = await amicodePost(focusedServer(), "/amicode/library", { filename, data_b64: dataB64 }) + if ((res as { ok?: boolean } | undefined)?.ok !== true) throw new Error("library save rejected") + await refetchLibrary() + } + + const WIZARD_DISMISS_KEY = "amicode-onboarding-dismissed" + const [wizardOpen, setWizardOpen] = createSignal(false) + let wizardDecided = false + createEffect(() => { + const view = profileView() + if (wizardDecided || view === undefined || !view.ok) return + wizardDecided = true + let dismissed = false + try { + dismissed = localStorage.getItem(WIZARD_DISMISS_KEY) === "1" + } catch { + /* storage unavailable → treat as not dismissed */ + } + setWizardOpen(shouldShowWizard(view.you, dismissed)) + }) + const dismissWizard = () => { + try { + localStorage.setItem(WIZARD_DISMISS_KEY, "1") + } catch { + /* best-effort */ + } + setWizardOpen(false) + } + function startWithPrompt(prompt: string) { const project = newSessionProject() - if (!project) { - openNewSession() + if (project) { + tabs.newDraft({ server: server.key, directory: project.worktree }, prompt) return } - tabs.newDraft({ server: server.key, directory: project.worktree }, prompt) + // No tracked projects (fresh profile against a bare `opencode serve`): + // openNewSession() would dead-end silently here — it needs the same + // newSessionProject() that just came back empty. Fall back to the server's + // own working directory (path.directory, synced from GET /path; "" until + // loaded) and start tracking it, so the home CTAs work on first visit. + // Deliberately NOT sync.data.project: its "global" record has worktree "/". + const conn = focusedServer() + const directory = focusedSync().data.path.directory + if (!conn || !directory) return + const ctx = global.createServerCtx(conn) + ctx.projects.open(directory) + ctx.projects.touch(directory) + tabs.newDraft({ server: ServerConnection.key(conn), directory }, prompt) } function setSelection(next: HomeProjectSelection) { @@ -630,17 +698,11 @@ function HomeDesign() {
startWithPrompt("update my profile — my name, affiliation, and what I work on")} - onSaveProfile={async (fields) => { - // In-place save (About-You card): identity fields ride query - // params on the raw POST route; refetch renders the saved state. - const q = new URLSearchParams() - for (const [k, v] of Object.entries(fields)) if (v !== undefined) q.set(k, v) - await amicodePost(focusedServer(), `/amicode/profile?${q.toString()}`) - await refetchProfile() - }} + onSaveProfile={saveProfileFields} resumeName={resumeProblem()?.name} resumeMeta={resumeMeta()} onResume={() => { @@ -657,6 +719,21 @@ function HomeDesign() { />
+ + { + const v = profileView() + return v?.ok ? v.you.name : "" + })()} + onComplete={saveProfileFields} + onUploadPaper={uploadPaper} + onDismiss={dismissWizard} + onOpenChat={() => { + dismissWizard() + startWithPrompt("") + }} + /> + { +export async function amicodePost( + conn: ServerConnection.Any | undefined, + route: string, + jsonBody?: unknown, +): Promise { if (!conn) throw new Error("no active server") const headers: Record = {} if (conn.http.password) @@ -29,7 +33,12 @@ export async function amicodePost(conn: ServerConnection.Any | undefined, route: username: conn.http.username, password: conn.http.password, })}` - const res = await fetch(new URL(route, conn.http.url), { method: "POST", headers }) + if (jsonBody !== undefined) headers["content-type"] = "application/json" + const res = await fetch(new URL(route, conn.http.url), { + method: "POST", + headers, + ...(jsonBody !== undefined ? { body: JSON.stringify(jsonBody) } : {}), + }) if (!res.ok) throw new Error(`HTTP ${res.status}`) return (await res.json()) as unknown } diff --git a/packages/opencode/src/server/amicode/library.ts b/packages/opencode/src/server/amicode/library.ts new file mode 100644 index 0000000000..96967f7c08 --- /dev/null +++ b/packages/opencode/src/server/amicode/library.ts @@ -0,0 +1,83 @@ +import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "fs" +import os from "os" +import path from "path" + +// AMICODE: the user's paper library — PDFs uploaded from the home page that +// make Amico smarter about THIS user's work. Files land in ~/.amico/library; +// the amicode extension grants the agent's file tools read access to that dir, +// so "read the paper I just added" works with zero further plumbing. Same +// never-reject discipline as problems.ts: every body is a JSON string. + +export function libraryRoot(): string { + const env = process.env.AMICODE_LIBRARY_DIR + if (env && env.trim() !== "") return env + return path.join(os.homedir(), ".amico", "library") +} + +export function synthesizeLibrary(code: string, detail: string): string { + return JSON.stringify({ ok: false, papers: [], error: `${code}: ${detail}` }) +} + +const MAX_BYTES = 30 * 1024 * 1024 // a 30MB PDF is a book; bigger is a mistake + +/** Basename-only, conservative charset, single .pdf suffix. */ +export function sanitizeFilename(raw: string): string | null { + const base = path.basename(raw).trim() + if (!/\.pdf$/i.test(base)) return null + const clean = base + .slice(0, -4) + .replace(/[^\w.\- ]+/g, "-") + .replace(/\s+/g, " ") + .trim() + .slice(0, 120) + return clean === "" ? null : `${clean}.pdf` +} + +export function libraryBody(root: string = libraryRoot()): string { + try { + if (!existsSync(root)) return JSON.stringify({ ok: true, papers: [], error: null }) + const papers = readdirSync(root) + .filter((f) => f.toLowerCase().endsWith(".pdf")) + .map((f) => { + const st = statSync(path.join(root, f)) + return { name: f, size: st.size, added_ms: Math.round(st.mtimeMs), path: path.join(root, f) } + }) + .sort((a, b) => b.added_ms - a.added_ms) + return JSON.stringify({ ok: true, papers, error: null }) + } catch (err) { + return synthesizeLibrary("bad_output", String(err)) + } +} + +/** Save one uploaded paper (JSON body: {filename, data_b64}). Returns the + * refreshed listing on success so the client renders in one round-trip. */ +export function saveLibraryFile(rawBody: string, root: string = libraryRoot()): string { + let parsed: { filename?: unknown; data_b64?: unknown } + try { + parsed = JSON.parse(rawBody) + } catch { + return synthesizeLibrary("bad_request", "body must be JSON {filename, data_b64}") + } + if (typeof parsed.filename !== "string" || typeof parsed.data_b64 !== "string") + return synthesizeLibrary("bad_request", "filename and data_b64 are required strings") + const name = sanitizeFilename(parsed.filename) + if (!name) return synthesizeLibrary("bad_filename", "PDFs only; name must survive sanitization") + let bytes: Buffer + try { + bytes = Buffer.from(parsed.data_b64, "base64") + } catch { + return synthesizeLibrary("bad_request", "data_b64 is not valid base64") + } + if (bytes.length === 0) return synthesizeLibrary("bad_request", "empty file") + if (bytes.length > MAX_BYTES) return synthesizeLibrary("too_large", `max ${MAX_BYTES} bytes`) + // magic check: every real PDF opens with %PDF- + if (!bytes.subarray(0, 5).equals(Buffer.from("%PDF-"))) + return synthesizeLibrary("bad_filetype", "not a PDF (missing %PDF- header)") + try { + mkdirSync(root, { recursive: true }) + writeFileSync(path.join(root, name), bytes) + } catch (err) { + return synthesizeLibrary("write_failed", String(err)) + } + return libraryBody(root) +} diff --git a/packages/opencode/src/server/amicode/traces.ts b/packages/opencode/src/server/amicode/traces.ts new file mode 100644 index 0000000000..f839ab6118 --- /dev/null +++ b/packages/opencode/src/server/amicode/traces.ts @@ -0,0 +1,102 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "fs" +import os from "os" +import path from "path" + +// AMICODE: reader for the local turn-level traces the amicode plugin writes +// (one JSONL of spans per session under ~/.amico/amicode/traces). +// +// MIRROR (change both in one change-set): the span envelope is defined by the +// WRITER — harmoniqs/amicode packages/extension/opencode-plugin/traces.ts: +// {v:1, ts, session, span:"model"|"tool"|"turn", id, name, +// dur_ms|null, attrs:{...}, error:string|null} +// Malformed lines are skipped (append-only stream discipline). Same +// never-reject contract as problems.ts: every body is a JSON string. + +export function tracesRoot(): string { + const env = process.env.AMICODE_TRACE_DIR + if (env && env.trim() !== "") return env + return path.join(os.homedir(), ".amico", "amicode", "traces") +} + +export function synthesizeTraces(code: string, detail: string): string { + return JSON.stringify({ ok: false, sessions: [], spans: [], error: `${code}: ${detail}` }) +} + +type Span = { + v: number + ts: string + session: string + span: string + id: string + name: string + dur_ms: number | null + attrs: Record + error: string | null +} + +function readSpans(file: string): Span[] { + let text = "" + try { + text = readFileSync(file, "utf8") + } catch { + return [] + } + const out: Span[] = [] + for (const line of text.split("\n")) { + if (line.trim() === "") continue + try { + const parsed = JSON.parse(line) + if (parsed && parsed.v === 1 && typeof parsed.span === "string") out.push(parsed as Span) + } catch { + /* torn/malformed line — skip */ + } + } + return out +} + +/** Index: one row per session — enough for a trace list UI and for spotting + * the failure patterns (error counts) and cost drivers (token totals). */ +export function tracesIndexBody(root: string = tracesRoot()): string { + try { + if (!existsSync(root)) return JSON.stringify({ ok: true, sessions: [], error: null }) + const sessions = readdirSync(root) + .filter((f) => f.endsWith(".jsonl")) + .map((f) => { + const spans = readSpans(path.join(root, f)) + const models = spans.filter((s) => s.span === "model") + const tokensIn = models.reduce((n, s) => n + (Number((s.attrs?.tokens as any)?.input) || 0), 0) + const cacheRead = models.reduce((n, s) => n + (Number((s.attrs?.tokens as any)?.cache?.read) || 0), 0) + return { + session: f.slice(0, -6), + spans: spans.length, + model_calls: models.length, + tool_calls: spans.filter((s) => s.span === "tool").length, + errors: spans.filter((s) => s.error !== null).length, + input_tokens: tokensIn, + cache_read_tokens: cacheRead, + model_ms: models.reduce((n, s) => n + (s.dur_ms ?? 0), 0), + first_ts: spans[0]?.ts ?? null, + last_ts: spans[spans.length - 1]?.ts ?? null, + mtime_ms: statSync(path.join(root, f)).mtimeMs, + } + }) + .sort((a, b) => b.mtime_ms - a.mtime_ms) + return JSON.stringify({ ok: true, sessions, error: null }) + } catch (err) { + return synthesizeTraces("bad_output", String(err)) + } +} + +/** One session's spans (newest last), bounded. */ +export function traceBody(session: string | undefined, limit = 500, root: string = tracesRoot()): string { + if (!session || !/^[\w.-]+$/.test(session)) return synthesizeTraces("bad_request", "session id required") + const file = path.join(root, `${session}.jsonl`) + if (!existsSync(file)) return synthesizeTraces(`not_found:${session}`, "no trace for that session") + try { + const spans = readSpans(file) + const bounded = spans.slice(Math.max(0, spans.length - Math.max(1, Math.min(limit, 5000)))) + return JSON.stringify({ ok: true, session, spans: bounded, total: spans.length, error: null }) + } catch (err) { + return synthesizeTraces("bad_output", String(err)) + } +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 9b6142bf68..a7b301a1f9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -60,6 +60,8 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors import { serveUIEffect } from "@/server/shared/ui" import * as AmicodeVaults from "@/server/amicode/vaults" import * as AmicodeProblems from "@/server/amicode/problems" +import * as AmicodeLibrary from "@/server/amicode/library" +import * as AmicodeTraces from "@/server/amicode/traces" import * as AmicodeProfile from "@/server/amicode/profile" import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" @@ -233,6 +235,24 @@ const amicodeProblemsRoute = HttpRouter.use((router) => // editable identity fields ride query params (small strings; keeps the // handler body-free like every other amicode route). Returns the fresh // profile JSON so the card can render the saved state without a second GET. + yield* router.add("GET", "/amicode/traces", (request) => + Effect.sync(() => { + const params = new URL(request.url, "http://localhost").searchParams + const session = params.get("session") ?? undefined + const limit = Number(params.get("limit") ?? "500") || 500 + const body = session ? AmicodeTraces.traceBody(session, limit) : AmicodeTraces.tracesIndexBody() + return HttpServerResponse.text(body, { contentType: "application/json" }) + }), + ) + yield* router.add("GET", "/amicode/library", () => + Effect.sync(() => HttpServerResponse.text(AmicodeLibrary.libraryBody(), { contentType: "application/json" })), + ) + yield* router.add("POST", "/amicode/library", (request) => + Effect.gen(function* () { + const body = yield* Effect.orDie(request.text) + return HttpServerResponse.text(AmicodeLibrary.saveLibraryFile(body), { contentType: "application/json" }) + }), + ) yield* router.add("POST", "/amicode/profile", (request) => Effect.sync(() => { const params = new URL(request.url, "http://localhost").searchParams diff --git a/packages/opencode/test/server/amicode-library.test.ts b/packages/opencode/test/server/amicode-library.test.ts new file mode 100644 index 0000000000..3da24cd42d --- /dev/null +++ b/packages/opencode/test/server/amicode-library.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import { mkdtempSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { libraryBody, saveLibraryFile, sanitizeFilename } from "@/server/amicode/library" + +const PDF = Buffer.concat([Buffer.from("%PDF-1.7\n"), Buffer.from("x".repeat(64))]) +const body = (filename: string, data: Buffer = PDF) => JSON.stringify({ filename, data_b64: data.toString("base64") }) + +describe("library", () => { + test("save → list roundtrip; newest first; path included for the agent prompt", () => { + const root = mkdtempSync(path.join(tmpdir(), "amicode-lib-")) + const saved = JSON.parse(saveLibraryFile(body("Krotov Methods (2024).pdf"), root)) + expect(saved.ok).toBe(true) + expect(saved.papers).toHaveLength(1) + expect(saved.papers[0].name).toBe("Krotov Methods -2024-.pdf") + const listed = JSON.parse(libraryBody(root)) + expect(listed.papers[0].path).toContain(root) + expect(listed.papers[0].size).toBe(PDF.length) + }) + test("rejects non-PDF content, wrong extension, oversize, garbage body", () => { + const root = mkdtempSync(path.join(tmpdir(), "amicode-lib-")) + expect(JSON.parse(saveLibraryFile(body("notes.txt"), root)).ok).toBe(false) + expect(JSON.parse(saveLibraryFile(body("fake.pdf", Buffer.from("hello")))).ok).toBe(false) + expect(JSON.parse(saveLibraryFile("not json", root)).ok).toBe(false) + expect(JSON.parse(saveLibraryFile(JSON.stringify({ filename: "a.pdf" }), root)).ok).toBe(false) + }) + test("sanitizeFilename: basename-only, pdf-only, traversal-proof", () => { + expect(sanitizeFilename("../../etc/passwd.pdf")).toBe("passwd.pdf") + expect(sanitizeFilename("paper.PDF")).toMatch(/\.pdf$/i) + expect(sanitizeFilename("nope.txt")).toBeNull() + expect(sanitizeFilename(".pdf")).toBeNull() + }) +}) diff --git a/packages/opencode/test/server/amicode-traces.test.ts b/packages/opencode/test/server/amicode-traces.test.ts new file mode 100644 index 0000000000..9d69a3188a --- /dev/null +++ b/packages/opencode/test/server/amicode-traces.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { tracesIndexBody, traceBody } from "@/server/amicode/traces" + +const SPAN = (over: Record = {}) => + JSON.stringify({ + v: 1, + ts: "2026-07-09T00:00:00Z", + session: "ses_a", + span: "model", + id: "m1", + name: "google/gemini-2.5-flash", + dur_ms: 4200, + attrs: { tokens: { input: 25054, output: 168, cache: { read: 0, write: 0 } } }, + error: null, + ...over, + }) + +describe("traces reader", () => { + test("index aggregates per session: counts, tokens, errors; skips malformed lines", () => { + const root = mkdtempSync(path.join(tmpdir(), "traces-")) + writeFileSync( + path.join(root, "ses_a.jsonl"), + [ + SPAN(), + SPAN({ id: "m2", error: '"APIError"' }), + "not json", + SPAN({ span: "tool", id: "c1", name: "amicode_solve" }), + ].join("\n") + "\n", + ) + const idx = JSON.parse(tracesIndexBody(root)) + expect(idx.ok).toBe(true) + expect(idx.sessions).toHaveLength(1) + expect(idx.sessions[0]).toMatchObject({ + session: "ses_a", + spans: 3, + model_calls: 2, + tool_calls: 1, + errors: 1, + input_tokens: 50108, + }) + }) + test("session read: bounded, newest-last, id validated", () => { + const root = mkdtempSync(path.join(tmpdir(), "traces-")) + writeFileSync( + path.join(root, "ses_b.jsonl"), + [SPAN({ id: "1" }), SPAN({ id: "2" }), SPAN({ id: "3" })].join("\n") + "\n", + ) + const two = JSON.parse(traceBody("ses_b", 2, root)) + expect(two.spans.map((s: any) => s.id)).toEqual(["2", "3"]) + expect(two.total).toBe(3) + expect(JSON.parse(traceBody("../etc", 5, root)).ok).toBe(false) + expect(JSON.parse(traceBody("nope", 5, root)).ok).toBe(false) + }) +}) diff --git a/packages/ui/src/amicode/home-cards.tsx b/packages/ui/src/amicode/home-cards.tsx index 0df447cc60..b9bd7b57fe 100644 --- a/packages/ui/src/amicode/home-cards.tsx +++ b/packages/ui/src/amicode/home-cards.tsx @@ -1,5 +1,12 @@ import { For, Show, createEffect, createMemo, on, type JSX, createSignal, onCleanup } from "solid-js" import { Mark } from "../components/logo" +import { + institutionLogoUrl, + suggestInstitutions, + resolveBrandLogo, + type InstitutionSuggestion, +} from "./institution-lookup" +import { fileToBase64 } from "./upload" // AMICODE: home-screen card strip (the "central screen" Aaron wanted the H-bot // and useful practitioner info on). Two identity heroes — MEET AMICO (who your @@ -397,14 +404,8 @@ function AboutYouCard(props: { }) setEditing(true) } - // Institution logos ride Google's favicon service — clearbit's logo CDN is - // sunset (autocomplete lives on for name+domain, which is all we need). - const institutionLogoUrl = (domain: string) => - `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${encodeURIComponent(domain)}&size=256` - - // LinkedIn-style institution lookup: Clearbit's free autocomplete (no key, - // CORS-open) → name + domain + logo. Picking a suggestion fills affiliation - // AND its logo; free text still saves as a plain affiliation. + // Institution lookup lives in institution-lookup.ts (shared with the + // onboarding wizard); this card owns debounce, sequencing, and signals. let searchTimer: ReturnType | undefined let searchSeq = 0 // out-of-order guard: a slow "har" response must not clobber "harvard"'s onCleanup(() => { @@ -419,55 +420,24 @@ function AboutYouCard(props: { } searchTimer = setTimeout(() => { const seq = ++searchSeq - fetch(`https://autocomplete.clearbit.com/v1/companies/suggest?query=${encodeURIComponent(q.trim())}`) - .then((r) => (r.ok ? r.json() : [])) - .then((rows: any) => { - if (seq === searchSeq) setSuggestions(Array.isArray(rows) ? rows.slice(0, 5) : []) - }) - .catch(() => { - if (seq === searchSeq) setSuggestions([]) - }) + void suggestInstitutions(q).then((rows) => { + if (seq === searchSeq) setSuggestions(rows) + }) }, 200) } // Counter, not boolean: pick A then B while A's Wikidata round-trip is in // flight — A's finally must not re-enable Save while B still resolves (the // boolean version re-introduced the save-races-logo bug it claimed to fix). const [resolvingLogo, setResolvingLogo] = createSignal(0) - const wikiJson = (url: string) => - fetch(url) - .then((r) => (r.ok ? r.json() : undefined)) - .catch(() => undefined) - const pickInstitution = async (sug: { name: string; domain: string; logo: string }) => { - // Instant favicon mark, then resolve the real BRAND logo: Wikidata P154 - // ("logo image" — e.g. the purple NYU torch, not the seal) rasterized by - // Commons at 512px → crisp at any tile size. Fallbacks: Wikipedia page - // image, then the favicon. Save is held while resolving so the upgraded - // URL is what gets persisted (the old async upgrade lost a race with Save). + const pickInstitution = async (sug: InstitutionSuggestion) => { + // Instant favicon mark, then resolve the real BRAND logo (Wikidata P154 → + // Wikipedia pageimage → favicon; see institution-lookup.ts). Save is held + // while resolving so the upgraded URL is what gets persisted. setDraft({ ...draft(), affiliation: sug.name, affiliation_logo: institutionLogoUrl(sug.domain) }) setSuggestions([]) setResolvingLogo((n) => n + 1) try { - let logo: string | undefined - const found = await wikiJson( - `https://www.wikidata.org/w/api.php?action=wbsearchentities&search=${encodeURIComponent(sug.name)}&language=en&format=json&origin=*`, - ) - const qid = found?.search?.[0]?.id - if (qid) { - const claims = await wikiJson( - `https://www.wikidata.org/w/api.php?action=wbgetclaims&entity=${qid}&property=P154&format=json&origin=*`, - ) - const file = claims?.claims?.P154?.[0]?.mainsnak?.datavalue?.value - if (typeof file === "string" && file) { - logo = `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(file)}?width=512` - } - } - if (!logo) { - const page = await wikiJson( - `https://en.wikipedia.org/w/api.php?action=query&format=json&origin=*&redirects=1&titles=${encodeURIComponent(sug.name)}&prop=pageimages&piprop=original`, - ) - const orig = (Object.values(page?.query?.pages ?? {})[0] as any)?.original?.source - if (typeof orig === "string" && /\.(svg|png|jpe?g|webp)$/i.test(orig)) logo = orig - } + const logo = await resolveBrandLogo(sug.name, sug.domain) if (logo && draft().affiliation === sug.name) setDraft({ ...draft(), affiliation_logo: logo }) } finally { setResolvingLogo((n) => Math.max(0, n - 1)) @@ -1033,6 +1003,97 @@ function Sparkline(props: { values: number[] }) { ) } +// --------------------------------------------------------------------------- +// LIBRARY — upload papers so Amico learns YOUR work (the personalization card) +// --------------------------------------------------------------------------- +function LibraryCard(props: { + library?: { count: number; latestName?: string; latestPath?: string } + onUploadPaper: (filename: string, dataB64: string) => Promise + onStart: (prompt: string) => void +}) { + const [busy, setBusy] = createSignal(false) + const [error, setError] = createSignal(undefined) + let fileInput: HTMLInputElement | undefined + + const upload = async (files: FileList | null) => { + if (!files || files.length === 0) return + setBusy(true) + setError(undefined) + try { + for (const file of Array.from(files)) { + await props.onUploadPaper(file.name, await fileToBase64(file)) + } + } catch { + setError("Upload failed — is the server up?") + } finally { + setBusy(false) + if (fileInput) fileInput.value = "" + } + } + + return ( + + void upload(e.currentTarget.files)} + /> +
+ {(props.library?.count ?? 0) > 0 + ? `${props.library!.count} paper${props.library!.count === 1 ? "" : "s"}` + : "Make Amico smarter"} +
+
{error() ?? props.library?.latestName ?? "upload papers — Amico learns your work"}
+
+ + + + +
+
+ ) +} + export interface HomeLiveRun { name?: string iteration?: number | null @@ -1077,8 +1138,10 @@ const COMPACT_CSS = ` export function AmicodeHomeCards(props: { profile: ProfileView | undefined - starters: readonly { label: string; prompt: string }[] onStart: (prompt: string) => void + // Library ("make Amico smarter"): uploaded papers land in ~/.amico/library + library?: { count: number; latestName?: string; latestPath?: string } + onUploadPaper?: (filename: string, dataB64: string) => Promise onEditProfile: () => void onSaveProfile?: (fields: { name?: string @@ -1182,32 +1245,13 @@ export function AmicodeHomeCards(props: {
- {/* Start something */} - -
- - {(starter) => ( - - )} - -
-
+ {/* Library — papers that make Amico smarter (replaces the old starter + chips: Open chat already owns "start something"). Uploads go to + POST /amicode/library; the extension grants the agent read access + to ~/.amico/library so "read my latest paper" just works. */} + + + ) diff --git a/packages/ui/src/amicode/institution-lookup.ts b/packages/ui/src/amicode/institution-lookup.ts new file mode 100644 index 0000000000..a5588f255a --- /dev/null +++ b/packages/ui/src/amicode/institution-lookup.ts @@ -0,0 +1,58 @@ +// AMICODE: institution name/logo lookup — shared by the About-You card and the +// onboarding wizard. Pipeline (client-side by design, all CORS-open): +// 1. Clearbit autocomplete → name + domain (its logo CDN is sunset; only the +// suggest API lives on) +// 2. Wikidata P154 "logo image" → Commons FilePath @512px (crisp BRAND mark — +// the NYU torch, not the seal) +// 3. Wikipedia pageimage original (when P154 is absent) +// 4. Google faviconV2 @256 (instant placeholder + last resort) +// Pure async functions — callers own debounce/sequencing/signals. + +export type InstitutionSuggestion = { name: string; domain: string; logo: string } + +/** Instant favicon mark for a domain (placeholder + last-resort logo). */ +export function institutionLogoUrl(domain: string): string { + return `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${encodeURIComponent(domain)}&size=256` +} + +/** Clearbit autocomplete: name + domain suggestions (top 5). Never rejects. */ +export async function suggestInstitutions(query: string): Promise { + const q = query.trim() + if (q.length < 2) return [] + try { + const r = await fetch(`https://autocomplete.clearbit.com/v1/companies/suggest?query=${encodeURIComponent(q)}`) + const rows = r.ok ? await r.json() : [] + return Array.isArray(rows) ? rows.slice(0, 5) : [] + } catch { + return [] + } +} + +const wikiJson = (url: string) => + fetch(url) + .then((r) => (r.ok ? r.json() : undefined)) + .catch(() => undefined) + +/** Best brand logo for an institution: Wikidata P154 → Wikipedia pageimage → + * favicon. Never rejects; always returns SOME url. */ +export async function resolveBrandLogo(name: string, domain: string): Promise { + const found = await wikiJson( + `https://www.wikidata.org/w/api.php?action=wbsearchentities&search=${encodeURIComponent(name)}&language=en&format=json&origin=*`, + ) + const qid = found?.search?.[0]?.id + if (qid) { + const claims = await wikiJson( + `https://www.wikidata.org/w/api.php?action=wbgetclaims&entity=${qid}&property=P154&format=json&origin=*`, + ) + const file = claims?.claims?.P154?.[0]?.mainsnak?.datavalue?.value + if (typeof file === "string" && file) { + return `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(file)}?width=512` + } + } + const page = await wikiJson( + `https://en.wikipedia.org/w/api.php?action=query&format=json&origin=*&redirects=1&titles=${encodeURIComponent(name)}&prop=pageimages&piprop=original`, + ) + const orig = (Object.values(page?.query?.pages ?? {})[0] as any)?.original?.source + if (typeof orig === "string" && /\.(svg|png|jpe?g|webp)$/i.test(orig)) return orig + return institutionLogoUrl(domain) +} diff --git a/packages/ui/src/amicode/onboarding-wizard.test.ts b/packages/ui/src/amicode/onboarding-wizard.test.ts new file mode 100644 index 0000000000..8e77470a85 --- /dev/null +++ b/packages/ui/src/amicode/onboarding-wizard.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { shouldShowWizard } from "./onboarding-wizard" + +// The wizard is session-zero UI: it must appear exactly once — for a fresh +// profile that was never dismissed — and never fight a filled-in profile. +describe("shouldShowWizard", () => { + test("fresh profile, not dismissed → show", () => { + expect(shouldShowWizard({}, false)).toBe(true) + expect(shouldShowWizard({ affiliation: "", scholar: "", focus: "" }, false)).toBe(true) + }) + test("any identity field set → never show (wizard or card already filled it)", () => { + expect(shouldShowWizard({ affiliation: "NYU" }, false)).toBe(false) + expect(shouldShowWizard({ scholar: "https://scholar.google.com/x" }, false)).toBe(false) + expect(shouldShowWizard({ focus: "transmon gates" }, false)).toBe(false) + }) + test("dismissed → never show, even fresh", () => { + expect(shouldShowWizard({}, true)).toBe(false) + }) + test("profile not loaded yet → never flash the wizard", () => { + expect(shouldShowWizard(undefined, false)).toBe(false) + }) +}) diff --git a/packages/ui/src/amicode/onboarding-wizard.tsx b/packages/ui/src/amicode/onboarding-wizard.tsx new file mode 100644 index 0000000000..bf36a0bdbc --- /dev/null +++ b/packages/ui/src/amicode/onboarding-wizard.tsx @@ -0,0 +1,546 @@ +import { For, Show, createSignal, onCleanup } from "solid-js" +import { Mark } from "../components/logo" +import { + institutionLogoUrl, + suggestInstitutions, + resolveBrandLogo, + type InstitutionSuggestion, +} from "./institution-lookup" +import { fileToBase64 } from "./upload" + +// AMICODE: first-run onboarding wizard — the dedicated welcome UI (session +// zero, visual edition). Three steps: brand welcome → about-you (name, focus, +// institution with live logo lookup, Scholar) → preview + open chat. Saving +// goes through the SAME POST /amicode/profile the About-You card uses (the +// host passes onComplete), so the home page reflects it instantly and the +// chat's overture interview skips what's already answered. + +export type WizardFields = { + name: string + affiliation: string + focus: string + scholar: string + affiliation_logo: string +} + +/** Show the wizard only for a genuinely fresh profile: nothing identity-like + * saved yet and no prior dismiss. Pure — unit-tested. */ +export function shouldShowWizard( + profile: { affiliation?: string | null; scholar?: string | null; focus?: string | null } | undefined, + dismissed: boolean, +): boolean { + if (dismissed || profile === undefined) return false + return !profile.affiliation && !profile.scholar && !profile.focus +} + +const FIELD: Record = { + width: "100%", + "box-sizing": "border-box", + background: "var(--v2-background-bg-layer-02, transparent)", + border: "1px solid var(--v2-border-border-base)", + "border-radius": "8px", + padding: "9px 12px", + "font-size": "13px", + color: "var(--v2-text-text-base)", + outline: "none", +} +const LABEL: Record = { + "font-size": "11px", + "font-weight": "650", + "letter-spacing": "0.06em", + "text-transform": "uppercase", + color: "var(--v2-text-text-muted)", + "margin-bottom": "4px", +} + +export function AmicodeOnboardingWizard(props: { + initialName?: string + onComplete: (fields: WizardFields) => Promise + /** Optional: enables the library step (papers that make Amico smarter). */ + onUploadPaper?: (filename: string, dataB64: string) => Promise + onDismiss: () => void + onOpenChat: () => void +}) { + const [step, setStep] = createSignal<0 | 1 | 2 | 3>(0) + const FINAL = 3 + // library step state (skipped entirely when onUploadPaper isn't wired) + const [uploaded, setUploaded] = createSignal([]) + const [uploadBusy, setUploadBusy] = createSignal(false) + const [uploadError, setUploadError] = createSignal(undefined) + let paperInput: HTMLInputElement | undefined + const uploadPapers = async (files: FileList | null) => { + if (!files || files.length === 0 || !props.onUploadPaper) return + setUploadBusy(true) + setUploadError(undefined) + try { + for (const file of Array.from(files)) { + await props.onUploadPaper(file.name, await fileToBase64(file)) + setUploaded([...uploaded(), file.name]) + } + } catch { + setUploadError("Upload failed — PDFs only, up to 30MB.") + } finally { + setUploadBusy(false) + if (paperInput) paperInput.value = "" + } + } + const [fields, setFields] = createSignal({ + name: props.initialName ?? "", + affiliation: "", + focus: "", + scholar: "", + affiliation_logo: "", + }) + const [suggestions, setSuggestions] = createSignal([]) + const [resolvingLogo, setResolvingLogo] = createSignal(0) + const [saving, setSaving] = createSignal(false) + const [saveError, setSaveError] = createSignal(undefined) + + // same debounce + out-of-order discipline as the About-You card + let searchTimer: ReturnType | undefined + let searchSeq = 0 + onCleanup(() => { + if (searchTimer) clearTimeout(searchTimer) + }) + const search = (q: string) => { + if (searchTimer) clearTimeout(searchTimer) + if (q.trim().length < 2) { + searchSeq++ + setSuggestions([]) + return + } + searchTimer = setTimeout(() => { + const seq = ++searchSeq + void suggestInstitutions(q).then((rows) => { + if (seq === searchSeq) setSuggestions(rows) + }) + }, 200) + } + const pick = async (sug: InstitutionSuggestion) => { + setFields({ ...fields(), affiliation: sug.name, affiliation_logo: institutionLogoUrl(sug.domain) }) + setSuggestions([]) + setResolvingLogo((n) => n + 1) + try { + const logo = await resolveBrandLogo(sug.name, sug.domain) + if (logo && fields().affiliation === sug.name) setFields({ ...fields(), affiliation_logo: logo }) + } finally { + setResolvingLogo((n) => Math.max(0, n - 1)) + } + } + + const saveAndPreview = async () => { + setSaving(true) + setSaveError(undefined) + try { + await props.onComplete(fields()) + setStep(props.onUploadPaper ? 2 : FINAL) + } catch { + setSaveError("Couldn't save — server unreachable. Try again.") + } finally { + setSaving(false) + } + } + + const Dots = () => ( +
+ + {(i) => ( + + )} + +
+ ) + + const PrimaryBtn = (p: { label: string; onClick: () => void; disabled?: boolean }) => ( + + ) + const QuietBtn = (p: { label: string; onClick: () => void }) => ( + + ) + + return ( +
+
+ {/* step 0 — welcome */} + +
+ +
+
+ Welcome to Amicode +
+
+ Amico is your quantum-computing agent — it designs pulses from a conversation, warm-starts from your + pulse bank, and tunes on real hardware. Thirty seconds of setup makes it yours. +
+
+
+ setStep(1)} /> + props.onDismiss()} /> +
+
+
+ + {/* step 1 — about you */} + +
+
+
+ About you +
+
+ This fills your home page and helps Amico tailor its physics to you. +
+
+
+
Name
+ setFields({ ...fields(), name: e.currentTarget.value })} + placeholder="Ada Lovelace" + /> +
+
+
Institution
+
+ + (e.currentTarget.style.display = "none")} + /> + + { + setFields({ ...fields(), affiliation: e.currentTarget.value }) + search(e.currentTarget.value) + }} + placeholder="Start typing — we'll find the logo" + /> +
+ 0}> +
+ + {(sug) => ( + + )} + +
+
+
+
+
What you work on
+ setFields({ ...fields(), focus: e.currentTarget.value })} + placeholder="e.g. high-fidelity gates on transmons" + /> +
+
+
Google Scholar (optional)
+ setFields({ ...fields(), scholar: e.currentTarget.value })} + placeholder="https://scholar.google.com/…" + /> +
+ +
{saveError()}
+
+
+ 0 ? "Finding logo…" : "Continue"} + disabled={saving() || resolvingLogo() > 0} + onClick={() => void saveAndPreview()} + /> + setStep(0)} /> + + props.onDismiss()} /> + +
+
+
+ + {/* step 2 — library: papers that make Amico smarter (optional) */} + +
+
+
+ Teach Amico your work +
+
+ Upload papers (PDFs) — Amico reads them to learn your methods and results. You can add more anytime from + the Library card on the home page. +
+
+ void uploadPapers(e.currentTarget.files)} + /> + 0}> +
+ + {(name) => ( +
+ + + {name} + +
+ )} +
+
+
+ +
{uploadError()}
+
+
+ 0 ? "Add another PDF" : "Upload a PDF"} + disabled={uploadBusy()} + onClick={() => paperInput?.click()} + /> + 0 ? "Continue" : "Continue without papers"} + disabled={uploadBusy()} + onClick={() => setStep(FINAL)} + /> + + setStep(1)} /> + +
+
+
+ + {/* final step — done: the profile as the home page will show it */} + +
+
+ }> + + (e.currentTarget.style.display = "none")} + /> + + +
+
+ {fields().name || "You"} +
+
+ {fields().affiliation || "Independent"} +
+
+
+
+ You're set. Amico will remember this — say hi and design your first pulse. +
+
+ props.onDismiss()} /> + props.onOpenChat()} /> +
+
+
+ + +
+
+ ) +} diff --git a/packages/ui/src/amicode/upload.ts b/packages/ui/src/amicode/upload.ts new file mode 100644 index 0000000000..fd8eeca23d --- /dev/null +++ b/packages/ui/src/amicode/upload.ts @@ -0,0 +1,12 @@ +// AMICODE: browser File → base64 for the JSON upload routes (POST +// /amicode/library). Chunked conversion — String.fromCharCode(...whole-buffer) +// blows the arg-spread limit on multi-MB PDFs. Shared by the home Library +// card and the onboarding wizard's library step. + +export async function fileToBase64(file: File): Promise { + const buf = new Uint8Array(await file.arrayBuffer()) + let bin = "" + const CHUNK = 0x8000 + for (let i = 0; i < buf.length; i += CHUNK) bin += String.fromCharCode(...buf.subarray(i, i + CHUNK)) + return btoa(bin) +} diff --git a/packages/ui/src/components/amicode-onboarding-wizard.tsx b/packages/ui/src/components/amicode-onboarding-wizard.tsx new file mode 100644 index 0000000000..0e1c1b2430 --- /dev/null +++ b/packages/ui/src/components/amicode-onboarding-wizard.tsx @@ -0,0 +1,2 @@ +// AMICODE: re-export shim (wildcard export path) — logic in ../amicode/onboarding-wizard.tsx. +export { AmicodeOnboardingWizard, shouldShowWizard, type WizardFields } from "../amicode/onboarding-wizard"