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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 75 additions & 10 deletions packages/app/src/pages/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -339,6 +339,62 @@ 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<string, string | undefined>) {
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) {
Expand Down Expand Up @@ -642,17 +698,11 @@ function HomeDesign() {
<div class="relative z-[1] flex-none pt-1">
<AmicodeHomeCards
profile={profileView()}
starters={AMICODE_STARTERS}
onStart={startWithPrompt}
library={libraryView()}
onUploadPaper={uploadPaper}
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={() => {
Expand All @@ -669,6 +719,21 @@ function HomeDesign() {
/>
</div>
<AmicodeFooter />
<Show when={wizardOpen()}>
<AmicodeOnboardingWizard
initialName={(() => {
const v = profileView()
return v?.ok ? v.you.name : ""
})()}
onComplete={saveProfileFields}
onUploadPaper={uploadPaper}
onDismiss={dismissWizard}
onOpenChat={() => {
dismissWizard()
startWithPrompt("")
}}
/>
</Show>
<Show when={galleryOpen()}>
<AmicodeRunGallery
cards={runCards()}
Expand Down
13 changes: 11 additions & 2 deletions packages/app/src/utils/amicode-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,24 @@ 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<unknown> {
export async function amicodePost(
conn: ServerConnection.Any | undefined,
route: string,
jsonBody?: unknown,
): Promise<unknown> {
if (!conn) throw new Error("no active server")
const headers: Record<string, string> = {}
if (conn.http.password)
headers.Authorization = `Basic ${authTokenFromCredentials({
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
}
83 changes: 83 additions & 0 deletions packages/opencode/src/server/amicode/library.ts
Original file line number Diff line number Diff line change
@@ -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)
}
10 changes: 10 additions & 0 deletions packages/opencode/src/server/routes/instance/httpapi/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions packages/opencode/test/server/amicode-library.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading
Loading