From cae21013456a708d2ea04a5cf44f209b163a0cbd Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 8 Jul 2026 12:31:53 -0400 Subject: [PATCH 1/7] =?UTF-8?q?refactor(profile):=20institution=20name/log?= =?UTF-8?q?o=20lookup=20extracted=20to=20institution-lookup.ts=20=E2=80=94?= =?UTF-8?q?=20shared=20by=20the=20About-You=20card=20and=20the=20onboardin?= =?UTF-8?q?g=20wizard;=20card=20keeps=20debounce/sequencing/race=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/ui/src/amicode/home-cards.tsx | 63 +++++-------------- packages/ui/src/amicode/institution-lookup.ts | 58 +++++++++++++++++ 2 files changed, 74 insertions(+), 47 deletions(-) create mode 100644 packages/ui/src/amicode/institution-lookup.ts diff --git a/packages/ui/src/amicode/home-cards.tsx b/packages/ui/src/amicode/home-cards.tsx index 0df447cc6..cf392906e 100644 --- a/packages/ui/src/amicode/home-cards.tsx +++ b/packages/ui/src/amicode/home-cards.tsx @@ -1,5 +1,11 @@ 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" // 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 +403,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 +419,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)) diff --git a/packages/ui/src/amicode/institution-lookup.ts b/packages/ui/src/amicode/institution-lookup.ts new file mode 100644 index 000000000..a5588f255 --- /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) +} From ed80e7fad96705251d50fe46f062173ab71c3fba Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 8 Jul 2026 12:35:09 -0400 Subject: [PATCH 2/7] =?UTF-8?q?feat(onboarding):=20first-run=20welcome=20w?= =?UTF-8?q?izard=20=E2=80=94=203=20steps=20(brand=20welcome=20=E2=86=92=20?= =?UTF-8?q?about-you=20with=20live=20institution/logo=20lookup=20=E2=86=92?= =?UTF-8?q?=20profile=20preview=20+=20open=20chat);=20saves=20through=20PO?= =?UTF-8?q?ST=20/amicode/profile=20so=20the=20home=20page=20autofills=20af?= =?UTF-8?q?filiation=20+=20logo;=20shows=20exactly=20once=20(fresh=20profi?= =?UTF-8?q?le,=20dismiss=20remembered)=20+=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/app/src/pages/home.tsx | 60 ++- .../ui/src/amicode/onboarding-wizard.test.ts | 22 + packages/ui/src/amicode/onboarding-wizard.tsx | 458 ++++++++++++++++++ .../components/amicode-onboarding-wizard.tsx | 2 + 4 files changed, 534 insertions(+), 8 deletions(-) create mode 100644 packages/ui/src/amicode/onboarding-wizard.test.ts create mode 100644 packages/ui/src/amicode/onboarding-wizard.tsx create mode 100644 packages/ui/src/components/amicode-onboarding-wizard.tsx diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index aa0d177af..1391259b9 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -62,6 +62,7 @@ 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" @@ -339,6 +340,42 @@ 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). + 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) { @@ -633,14 +670,7 @@ function HomeDesign() { starters={AMICODE_STARTERS} onStart={startWithPrompt} onEditProfile={() => 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 +687,20 @@ function HomeDesign() { /> + + { + const v = profileView() + return v?.ok ? v.you.name : "" + })()} + onComplete={saveProfileFields} + onDismiss={dismissWizard} + onOpenChat={() => { + dismissWizard() + startWithPrompt("") + }} + /> + { + 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 000000000..d0738b69a --- /dev/null +++ b/packages/ui/src/amicode/onboarding-wizard.tsx @@ -0,0 +1,458 @@ +import { For, Show, createSignal, onCleanup } from "solid-js" +import { Mark } from "../components/logo" +import { + institutionLogoUrl, + suggestInstitutions, + resolveBrandLogo, + type InstitutionSuggestion, +} from "./institution-lookup" + +// 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 + onDismiss: () => void + onOpenChat: () => void +}) { + const [step, setStep] = createSignal<0 | 1 | 2>(0) + 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(2) + } 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 — 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.onOpenChat()} /> + props.onDismiss()} /> +
+
+
+ + +
+
+ ) +} 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 000000000..0e1c1b243 --- /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" From 4db62ee6cc20b999d0b7ba9ff9000afcc243b0db Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 8 Jul 2026 12:53:44 -0400 Subject: [PATCH 3/7] =?UTF-8?q?feat(library):=20papers=20that=20make=20Ami?= =?UTF-8?q?co=20smarter=20=E2=80=94=20Library=20card=20replaces=20the=20st?= =?UTF-8?q?arter=20chips=20(Open=20chat=20owns=20'start=20something');=20P?= =?UTF-8?q?DFs=20upload=20via=20POST=20/amicode/library=20into=20~/.amico/?= =?UTF-8?q?library=20(sanitized=20basename,=20%PDF-=20magic=20check,=2030M?= =?UTF-8?q?B=20cap),=20GET=20lists=20newest-first;=20'Discuss=20latest=20?= =?UTF-8?q?=E2=86=92'=20hands=20the=20agent=20the=20paper=20path=20+=20tes?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/app/src/pages/home.tsx | 24 +++- packages/app/src/utils/amicode-fetch.ts | 13 +- .../opencode/src/server/amicode/library.ts | 83 +++++++++++ .../server/routes/instance/httpapi/server.ts | 10 ++ .../test/server/amicode-library.test.ts | 34 +++++ packages/ui/src/amicode/home-cards.tsx | 132 ++++++++++++++---- 6 files changed, 265 insertions(+), 31 deletions(-) create mode 100644 packages/opencode/src/server/amicode/library.ts create mode 100644 packages/opencode/test/server/amicode-library.test.ts diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 1391259b9..073423e63 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -66,7 +66,6 @@ import { AmicodeOnboardingWizard, shouldShowWizard } from "@opencode-ai/ui/amico 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" @@ -352,6 +351,26 @@ function HomeDesign() { // 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 @@ -667,8 +686,9 @@ function HomeDesign() {
startWithPrompt("update my profile — my name, affiliation, and what I work on")} onSaveProfile={saveProfileFields} resumeName={resumeProblem()?.name} diff --git a/packages/app/src/utils/amicode-fetch.ts b/packages/app/src/utils/amicode-fetch.ts index 9a0873753..64006e981 100644 --- a/packages/app/src/utils/amicode-fetch.ts +++ b/packages/app/src/utils/amicode-fetch.ts @@ -21,7 +21,11 @@ export async function amicodeGet(conn: ServerConnection.Any | undefined, route: /** POST sibling of amicodeGet — the amicode raw routes keep params in the URL * (no body), so this is the same call shape with method POST. Used by the * About-You card's in-place profile save. */ -export async function amicodePost(conn: ServerConnection.Any | undefined, route: string): Promise { +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 000000000..96967f7c0 --- /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/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 9b6142bf6..81cb55fed 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -60,6 +60,7 @@ 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 AmicodeProfile from "@/server/amicode/profile" import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" @@ -233,6 +234,15 @@ 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/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 000000000..3da24cd42 --- /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/ui/src/amicode/home-cards.tsx b/packages/ui/src/amicode/home-cards.tsx index cf392906e..2b53796fb 100644 --- a/packages/ui/src/amicode/home-cards.tsx +++ b/packages/ui/src/amicode/home-cards.tsx @@ -1002,6 +1002,101 @@ 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)) { + 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)) + await props.onUploadPaper(file.name, btoa(bin)) + } + } 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 @@ -1046,8 +1141,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 @@ -1151,32 +1248,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. */} + + +
) From 5a14614d774bc6e878bda688211fd3c308017409 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Wed, 8 Jul 2026 18:08:33 -0400 Subject: [PATCH 4/7] amicode(home): fix silent dead-end on 'Open chat' with no tracked projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a fresh browser profile against a bare `opencode serve`, the home page's primary CTA (Meet-Amico card / "Open chat") did nothing: the persisted client-side project list is empty, so startWithPrompt fell through to openNewSession(), which needs the same newSessionProject() that just came back empty and silently returns. Fall back to the focused server's own working directory, synced from GET /path (.directory; "" until loaded, so the guard holds). Open and touch it as a project — self-healing: the home page tracks it from then on — and start the draft with the prompt preserved. Deliberately not sync.data.project: the server's "global" record has worktree "/". Regression spec drives the real UI against a mocked server with no localStorage seed; verified failing on the unfixed code and passing with the fix. tsgo -b clean; bun test:unit 376 pass. Co-Authored-By: Claude Fable 5 --- AMICODE-PATCHES.md | 20 +++++++++++ .../home-open-chat-empty-projects.spec.ts | 33 +++++++++++++++++++ packages/app/src/pages/home.tsx | 18 ++++++++-- 3 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts diff --git a/AMICODE-PATCHES.md b/AMICODE-PATCHES.md index e89ce2c28..0834bb663 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 000000000..39ee2ca9c --- /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 aa0d177af..cb199904d 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -341,11 +341,23 @@ function HomeDesign() { const [sessionsExpanded, setSessionsExpanded] = createSignal(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) { From 5b4bc4d2bb74e95b7304632046c7aa1b3678adbc Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 8 Jul 2026 21:15:22 -0400 Subject: [PATCH 5/7] =?UTF-8?q?feat(onboarding):=20library=20step=20in=20t?= =?UTF-8?q?he=20wizard=20=E2=80=94=20'Teach=20Amico=20your=20work'=20(uplo?= =?UTF-8?q?ad=20PDFs=20between=20about-you=20and=20the=20finish;=20?= =?UTF-8?q?=E2=9C=93-list=20of=20uploads,=20continue-without-papers=20path?= =?UTF-8?q?;=20step=20skipped=20when=20upload=20isn't=20wired);=20home=20L?= =?UTF-8?q?ibrary=20card=20stays;=20shared=20fileToBase64=20util?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/app/src/pages/home.tsx | 1 + packages/ui/src/amicode/home-cards.tsx | 7 +- packages/ui/src/amicode/onboarding-wizard.tsx | 98 ++++++++++++++++++- packages/ui/src/amicode/upload.ts | 12 +++ 4 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 packages/ui/src/amicode/upload.ts diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 073423e63..5e67da69f 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -714,6 +714,7 @@ function HomeDesign() { return v?.ok ? v.you.name : "" })()} onComplete={saveProfileFields} + onUploadPaper={uploadPaper} onDismiss={dismissWizard} onOpenChat={() => { dismissWizard() diff --git a/packages/ui/src/amicode/home-cards.tsx b/packages/ui/src/amicode/home-cards.tsx index 2b53796fb..b9bd7b57f 100644 --- a/packages/ui/src/amicode/home-cards.tsx +++ b/packages/ui/src/amicode/home-cards.tsx @@ -6,6 +6,7 @@ import { 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 @@ -1020,11 +1021,7 @@ function LibraryCard(props: { setError(undefined) try { for (const file of Array.from(files)) { - 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)) - await props.onUploadPaper(file.name, btoa(bin)) + await props.onUploadPaper(file.name, await fileToBase64(file)) } } catch { setError("Upload failed — is the server up?") diff --git a/packages/ui/src/amicode/onboarding-wizard.tsx b/packages/ui/src/amicode/onboarding-wizard.tsx index d0738b69a..a05943c73 100644 --- a/packages/ui/src/amicode/onboarding-wizard.tsx +++ b/packages/ui/src/amicode/onboarding-wizard.tsx @@ -6,6 +6,7 @@ import { 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, @@ -55,10 +56,34 @@ const LABEL: Record = { 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>(0) + 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: "", @@ -108,7 +133,7 @@ export function AmicodeOnboardingWizard(props: { setSaveError(undefined) try { await props.onComplete(fields()) - setStep(2) + setStep(props.onUploadPaper ? 2 : FINAL) } catch { setSaveError("Couldn't save — server unreachable. Try again.") } finally { @@ -118,7 +143,7 @@ export function AmicodeOnboardingWizard(props: { const Dots = () => (
- + {(i) => ( - {/* step 2 — done: the profile as the home page will show it */} - + {/* 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 */} +
{ + 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) +} From 73d45a1c9f55f294ee96e0940f7c40e211f1ce3e Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 8 Jul 2026 21:21:02 -0400 Subject: [PATCH 6/7] =?UTF-8?q?fix(onboarding):=20wizard=20finale=20lands?= =?UTF-8?q?=20on=20the=20HOME=20page=20(primary),=20chat=20demoted=20to=20?= =?UTF-8?q?the=20quiet=20secondary=20=E2=80=94=20the=20autofilled=20home?= =?UTF-8?q?=20is=20the=20payoff=20shot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/ui/src/amicode/onboarding-wizard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/amicode/onboarding-wizard.tsx b/packages/ui/src/amicode/onboarding-wizard.tsx index a05943c73..bf36a0bdb 100644 --- a/packages/ui/src/amicode/onboarding-wizard.tsx +++ b/packages/ui/src/amicode/onboarding-wizard.tsx @@ -533,8 +533,8 @@ export function AmicodeOnboardingWizard(props: { You're set. Amico will remember this — say hi and design your first pulse.
- props.onOpenChat()} /> - props.onDismiss()} /> + props.onDismiss()} /> + props.onOpenChat()} />
From 85030872d7bf2d89b88a7ac8965e4d7533bada70 Mon Sep 17 00:00:00 2001 From: Raghav Chari Date: Wed, 8 Jul 2026 23:26:06 -0400 Subject: [PATCH 7/7] =?UTF-8?q?feat(traces):=20GET=20/amicode/traces=20?= =?UTF-8?q?=E2=80=94=20reader=20for=20the=20plugin-written=20turn-level=20?= =?UTF-8?q?spans=20(index:=20per-session=20counts/tokens/errors/model-ms;?= =?UTF-8?q?=20=3Fsession=3D=20bounded=20span=20read);=20envelope=20mirrore?= =?UTF-8?q?d=20from=20the=20amicode=20writer=20(change=20both=20in=20one?= =?UTF-8?q?=20change-set)=20+=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../opencode/src/server/amicode/traces.ts | 102 ++++++++++++++++++ .../server/routes/instance/httpapi/server.ts | 10 ++ .../test/server/amicode-traces.test.ts | 57 ++++++++++ 3 files changed, 169 insertions(+) create mode 100644 packages/opencode/src/server/amicode/traces.ts create mode 100644 packages/opencode/test/server/amicode-traces.test.ts diff --git a/packages/opencode/src/server/amicode/traces.ts b/packages/opencode/src/server/amicode/traces.ts new file mode 100644 index 000000000..f839ab611 --- /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 81cb55fed..a7b301a1f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -61,6 +61,7 @@ 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" @@ -234,6 +235,15 @@ 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" })), ) 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 000000000..9d69a3188 --- /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) + }) +})