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
6 changes: 5 additions & 1 deletion packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import { postBugReportPoke } from "@/utils/amicode-bug-report"
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { NewHome } from "@/pages/home"
import { LegacyHome } from "@/pages/home/legacy-home"
import { AmicodeFileRefBridge } from "@/components/amicode-file-ref-bridge"

const NewSession = lazy(() => import("@/pages/new-session"))

Expand Down Expand Up @@ -175,7 +176,10 @@ function SelectedServerProviders(props: ParentProps) {
return (
<ServerKey>
<ServerSDKProvider>
<ServerSyncProvider>{props.children}</ServerSyncProvider>
<ServerSyncProvider>
<AmicodeFileRefBridge />
{props.children}
</ServerSyncProvider>
</ServerSDKProvider>
</ServerKey>
)
Expand Down
33 changes: 33 additions & 0 deletions packages/app/src/components/amicode-file-ref-bridge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// AMICODE: registers the chat file-reference resolver with session-ui's
// markdown renderer. Rides the same per-call server pick as the vault browser
// (focused server, else first healthy, else first), so a server switch is
// picked up by the very next resolution; resolutions never block render.
// Mounted under SelectedServerProviders (needs the server + global contexts);
// with the bridge unmounted no resolver is registered and pills stay pills.
import { onCleanup, onMount } from "solid-js"
import { registerFileRefResolver } from "@opencode-ai/session-ui/markdown-file-refs"
import { ServerConnection, useServer } from "@/context/server"
import { useGlobal } from "@/context/global"
import { amicodeGet } from "@/utils/amicode-fetch"
import { pickVaultServer } from "@/components/vault-browser-model"

export function AmicodeFileRefBridge() {
const server = useServer()
const global = useGlobal()
onMount(() => {
registerFileRefResolver(async (text) => {
const conn = pickVaultServer({
current: server.current,
list: server.list,
healthy: (c) => global.servers.health[ServerConnection.key(c)]?.healthy === true,
})
if (!conn) return null
const raw = (await amicodeGet(conn, `/amicode/resolve-file?path=${encodeURIComponent(text)}`)) as
| { ok?: boolean; found?: boolean; path?: unknown }
| undefined
return raw?.ok === true && raw.found === true && typeof raw.path === "string" ? raw.path : null
})
})
onCleanup(() => registerFileRefResolver(undefined))
return null
}
163 changes: 163 additions & 0 deletions packages/opencode/src/server/amicode/file-resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// AMICODE: chat file-reference resolver (GET /amicode/resolve-file?path=<text>).
// Resolves a path-like string from chat markdown (an inline-code pill, or a
// relative authored link) to an absolute on-disk path, so the app can wrap it
// in a file:// link and the VS Code bridge can open it. RESOLVES ONLY — never
// reads content. Contract (same as vault-browser.ts): the body builder returns
// a JSON string and never throws.
//
// Resolution tiers, in order:
// 1. absolute path (/…) → existsSync
// 2. ~ / ~/… → home expansion, then existsSync
// 3. mount-prefixed (<mount>/<rel>) → under that mount (containment-guarded)
// 4. relative with a directory part → first-hit relpath across mounts in
// precedence order, then <mount>/amicode/<rel> (problem & demo cards),
// then project-directory-relative (the server's cwd)
// 5. bare filename → typed-prefix vault search ONLY
// (insight-* → insights/, spec-* → specs/, … per the amico-vault folder
// contract). A bare name with no typed prefix never resolves — a random
// `result.toml` must not become a link into the vault.
import { realpathSync, statSync } from "node:fs"
import { homedir } from "node:os"
import path from "node:path"
import { browseAllowed, mountDir } from "./vault-browser"
import { listMounts } from "./vaults"

const MAX_LEN = 4_096

function vaultsRoot(): string {
return process.env.AMICO_VAULTS_ROOT || path.join(homedir(), ".amico", "vaults")
}

const err = (code: string, detail: string) => JSON.stringify({ ok: false, error: `${code}: ${detail}` })

/** Vault naming contract (amico-vault skill, folder table): a timestamped
* note's filename prefix maps to its typed folder. Order irrelevant except
* that `morning-brief-` must precede nothing — prefixes are disjoint. */
const TYPED_PREFIX_DIRS: ReadonlyArray<readonly [prefix: string, dir: string]> = [
["experiment-", "experiments"],
["method-", "methods"],
["paper-", "papers"],
["insight-", "insights"],
["spec-", "specs"],
["plan-", "plans"],
["meeting-", "meetings"],
["hypothesis-", "hypotheses"],
["hopper-", "hopper"],
["retro-", "retrospectives"],
["morning-brief-", "briefs"],
["person-", "people"],
["org-", "orgs"],
["session-", "sessions"],
]

export type ResolvedRef = { path: string; mount?: string; kind: "file" | "dir" }

/** stat an absolute candidate; undefined when absent or not file/dir. */
function statRef(abs: string, mount?: string): ResolvedRef | undefined {
let st
try {
st = statSync(abs)
} catch {
return undefined
}
if (st.isDirectory()) return { path: abs, mount, kind: "dir" }
if (st.isFile()) return { path: abs, mount, kind: "file" }
return undefined
}

/** Join + realpath containment guard (mounts may be symlinked, and a chat
* string may carry `..`): returns the REAL path when the candidate exists and
* stays inside the mount, else undefined. Same idiom as vaultFileBody. */
function containedExisting(mountDirAbs: string, rel: string): string | undefined {
let realRoot: string
let realTarget: string
try {
realRoot = realpathSync(mountDirAbs)
realTarget = realpathSync(path.resolve(mountDirAbs, rel))
} catch {
return undefined
}
if (realTarget !== realRoot && !realTarget.startsWith(realRoot + path.sep)) return undefined
return realTarget
}

/** The project directory the server was launched with (extension spawns
* `opencode serve` with cwd = the project). */
function projectDir(): string {
return process.env.AMICODE_PROJECT_DIR || process.cwd()
}

export function resolveFileRef(
text: string,
opts: { vaultRoot?: string; cwd?: string; home?: string } = {},
): ResolvedRef | undefined {
const home = opts.home ?? homedir()
const vaultRoot = opts.vaultRoot ?? vaultsRoot()
const cwd = opts.cwd ?? projectDir()
const t = text.trim()
if (!t || t.length > MAX_LEN) return undefined
// scheme-ful strings (https://, mailto:, …) are not file refs
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t) || /^mailto:/i.test(t)) return undefined

const expanded = t === "~" ? home : t.startsWith("~/") ? path.join(home, t.slice(2)) : t

// tiers 1–2: absolute / home-expanded
if (path.isAbsolute(expanded)) return statRef(expanded)

// normalize away a leading ./ so segment logic sees the real first segment
const rel = expanded.startsWith("./") ? expanded.slice(2) : expanded
if (!rel || rel === "." || rel === "..") return undefined
const segs = rel.split("/").filter((s) => s !== "")
if (segs.length === 0) return undefined

// tier 3: first segment names an attached mount
const mounted = mountDir(segs[0], vaultRoot)
if (mounted) {
if (segs.length === 1) return statRef(mounted, segs[0])
const inside = containedExisting(mounted, segs.slice(1).join("/"))
if (inside) return statRef(inside, segs[0])
return undefined // an explicit mount reference does not fall through
}

// tier 4: relative with a directory part — vault precedence first-hit, then
// the mount's amicode/ state dir (problem cards, demo cards), then the
// project directory.
if (segs.length > 1) {
for (const m of listMounts(vaultRoot)) {
const hit = containedExisting(m.dir, rel)
if (hit) return statRef(hit, m.id)
}
for (const m of listMounts(vaultRoot)) {
const hit = containedExisting(m.dir, path.join("amicode", rel))
if (hit) return statRef(hit, m.id)
}
return statRef(path.resolve(cwd, rel))
}

// tier 5: bare filename — typed-prefix vault search only
for (const [prefix, dir] of TYPED_PREFIX_DIRS) {
if (!rel.startsWith(prefix)) continue
for (const m of listMounts(vaultRoot)) {
const hit = containedExisting(m.dir, path.join(dir, rel))
if (hit) return statRef(hit, m.id)
}
return undefined // one prefix matched; never try other prefixes or dirs
}
return undefined
}

/** Route body builder: `{ok:true, found, path?, mount?, kind?}` on success,
* `{ok:false, error}` on bad input or a gated deployment. Never throws. */
export function resolveFileBody(text: string | undefined): string {
if (!browseAllowed())
return err("forbidden", "file resolution serves loopback servers only (set AMICO_VAULT_BROWSER=1 to override)")
if (!text) return err("bad_request", "path is required")
let hit: ResolvedRef | undefined
try {
hit = resolveFileRef(text)
} catch (e) {
return err("resolve_failed", String(e))
}
if (!hit) return JSON.stringify({ ok: true, found: false })
return JSON.stringify({ ok: true, found: true, path: hit.path, mount: hit.mount ?? null, kind: hit.kind })
}
28 changes: 19 additions & 9 deletions packages/opencode/src/server/amicode/vaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,19 @@ const KIND_RANK: Record<string, number> = { personal: 0, engagement: 1, project:
const kindRank = (k: string) => KIND_RANK[k] ?? 6
const writableByKind = (k: string) => k === "personal" || k === "project" || k === "engagement"

/** CLI-less mount listing: scan each vault dir under the vaults root for its
* `.amico-vault.toml` marker and emit the same `{ok,mounts}` wire shape the
* Vaults tab parses. Parity with the extension's resolveMountStack (kind rank +
* writable-by-kind); ordering is kind-rank then name. `last_sync` is unknown
* without the CLI. Never throws. */
export function scanMounts(root: string = vaultsRoot()): string {
export type MountInfo = { id: string; kind: string; writable: boolean; dir: string }

/** Structured mount listing (precedence-ordered: kind-rank then name) for
* in-process consumers — the chat file-ref resolver walks this for
* first-hit resolution. `dir` is the mount's on-disk directory. */
export function listMounts(root: string = vaultsRoot()): MountInfo[] {
let entries: string[]
try {
entries = readdirSync(root).sort()
} catch {
return JSON.stringify({ ok: true, mounts: [], error: null })
return []
}
const mounts: { id: string; kind: string; writable: boolean; last_sync: string }[] = []
const mounts: MountInfo[] = []
for (const base of entries) {
let text: string
try {
Expand All @@ -43,9 +43,19 @@ export function scanMounts(root: string = vaultsRoot()): string {
const kind = text.match(/^\s*kind\s*=\s*"([^"]*)"/m)?.[1] ?? ""
if (!kind) continue
const name = text.match(/^\s*name\s*=\s*"([^"]*)"/m)?.[1] || base
mounts.push({ id: name, kind, writable: writableByKind(kind), last_sync: "unknown" })
mounts.push({ id: name, kind, writable: writableByKind(kind), dir: path.join(root, base) })
}
mounts.sort((a, b) => kindRank(a.kind) - kindRank(b.kind) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
return mounts
}

/** CLI-less mount listing: scan each vault dir under the vaults root for its
* `.amico-vault.toml` marker and emit the same `{ok,mounts}` wire shape the
* Vaults tab parses. Parity with the extension's resolveMountStack (kind rank +
* writable-by-kind); ordering is kind-rank then name. `last_sync` is unknown
* without the CLI. Never throws. */
export function scanMounts(root: string = vaultsRoot()): string {
const mounts = listMounts(root).map(({ id, kind, writable }) => ({ id, kind, writable, last_sync: "unknown" }))
return JSON.stringify({ ok: true, mounts, error: null })
}

Expand Down
12 changes: 12 additions & 0 deletions packages/opencode/src/server/routes/instance/httpapi/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import { serveUIEffect } from "@/server/shared/ui"
import * as AmicodeVaults from "@/server/amicode/vaults"
import * as AmicodeWarrants from "@/server/amicode/warrants"
import * as AmicodeVaultBrowser from "@/server/amicode/vault-browser"
import * as AmicodeFileResolve from "@/server/amicode/file-resolve"
import * as AmicodeProblems from "@/server/amicode/problems"
import * as AmicodeWidgets from "@/server/amicode/widgets"
import * as AmicodeDashboard from "@/server/amicode/dashboard"
Expand Down Expand Up @@ -261,6 +262,17 @@ const amicodeVaultsRoute = HttpRouter.use((router) =>
})
}),
)
// Chat file-reference linkifier: resolve a path-like string from chat
// markdown (inline-code pill, relative authored link) to an absolute
// on-disk path so the app can render it as a file:// link — the VS Code
// bridge opens the file from there. Same loopback gate as the vault
// browser; RESOLVES only, never reads.
yield* router.add("GET", "/amicode/resolve-file", (request) =>
Effect.sync(() => {
const p = new URL(request.url, "http://localhost").searchParams.get("path") ?? undefined
return HttpServerResponse.text(AmicodeFileResolve.resolveFileBody(p), { contentType: "application/json" })
}),
)
// amicode#203: New-project creation — mkdir + best-effort git init. JSON
// body {name, parentDir}; never rejects (failures come back as ok:false).
yield* router.add("POST", "/amicode/project", (request) =>
Expand Down
Loading
Loading