diff --git a/AGENTS.md b/AGENTS.md index bdfa3e5d..44e5c2fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,3 +146,44 @@ A change is done when all are true: 2. Typecheck, tests, and build pass 3. Contracts are synced across db/shared/server/ui 4. Docs updated when behavior or commands change + +## 11. Fork-Specific: HenkDz/paperclip + +This is a fork of `paperclipai/paperclip` with QoL patches and an **external-only** Hermes adapter story on branch `feat/externalize-hermes-adapter` ([tree](https://github.com/HenkDz/paperclip/tree/feat/externalize-hermes-adapter)). + +### Branch Strategy + +- `feat/externalize-hermes-adapter` → core has **no** `hermes-paperclip-adapter` dependency and **no** built-in `hermes_local` registration. Install Hermes via the Adapter Plugin manager (`@henkey/hermes-paperclip-adapter` or a `file:` path). +- Older fork branches may still document built-in Hermes; treat this file as authoritative for the externalize branch. + +### Hermes (plugin only) + +- Register through **Board → Adapter manager** (same as Droid). Type remains `hermes_local` once the package is loaded. +- UI uses generic **config-schema** + **ui-parser.js** from the package — no Hermes imports in `server/` or `ui/` source. +- Optional: `file:` entry in `~/.paperclip/adapter-plugins.json` for local dev of the adapter repo. + +### Local Dev + +- Fork runs on port 3101+ (auto-detects if 3100 is taken by upstream instance) +- `npx vite build` hangs on NTFS — use `node node_modules/vite/bin/vite.js build` instead +- Server startup from NTFS takes 30-60s — don't assume failure immediately +- Kill ALL paperclip processes before starting: `pkill -f "paperclip"; pkill -f "tsx.*index.ts"` +- Vite cache survives `rm -rf dist` — delete both: `rm -rf ui/dist ui/node_modules/.vite` + +### Fork QoL Patches (not in upstream) + +These are local modifications in the fork's UI. If re-copying source, these must be re-applied: + +1. **stderr_group** — amber accordion for MCP init noise in `RunTranscriptView.tsx` +2. **tool_group** — accordion for consecutive non-terminal tools (write, read, search, browser) +3. **Dashboard excerpt** — `LatestRunCard` strips markdown, shows first 3 lines/280 chars + +### Plugin System + +PR #2218 (`feat/external-adapter-phase1`) adds external adapter support. See root `AGENTS.md` for full details. + +- Adapters can be loaded as external plugins via `~/.paperclip/adapter-plugins.json` +- The plugin-loader should have ZERO hardcoded adapter imports — pure dynamic loading +- `createServerAdapter()` must include ALL optional fields (especially `detectModel`) +- Built-in UI adapters can shadow external plugin parsers — remove built-in when fully externalizing +- Reference external adapters: Hermes (`@henkey/hermes-paperclip-adapter` or `file:`) and Droid (npm) diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 943db253..6770ae51 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -22,6 +22,9 @@ export type { AdapterModel, HireApprovedPayload, HireApprovedHookResult, + ConfigFieldOption, + ConfigFieldSchema, + AdapterConfigSchema, ServerAdapterModule, QuotaWindow, ProviderQuotaResult, diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 42f91fda..2dedc534 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -261,6 +261,30 @@ export interface ProviderQuotaResult { windows: QuotaWindow[]; } +// --------------------------------------------------------------------------- +// Adapter config schema — declarative UI config for external adapters +// --------------------------------------------------------------------------- + +export interface ConfigFieldOption { + label: string; + value: string; +} + +export interface ConfigFieldSchema { + key: string; + label: string; + type: "text" | "select" | "toggle" | "number" | "textarea"; + options?: ConfigFieldOption[]; + default?: unknown; + hint?: string; + required?: boolean; + group?: string; +} + +export interface AdapterConfigSchema { + fields: ConfigFieldSchema[]; +} + export interface ServerAdapterModule { type: string; execute(ctx: AdapterExecutionContext): Promise; @@ -293,6 +317,13 @@ export interface ServerAdapterModule { * the adapter does not support detection or no config is found. */ detectModel?: () => Promise<{ model: string; provider: string; source: string; candidates?: string[] } | null>; + /** + * Optional: return a declarative config schema so the UI can render + * adapter-specific form fields without shipping React components. + * Dynamic options (e.g. scanning a profiles directory) should be + * resolved inside this method — the caller receives a fully hydrated schema. + */ + getConfigSchema?: () => Promise | AdapterConfigSchema; } // --------------------------------------------------------------------------- @@ -353,4 +384,6 @@ export interface CreateConfigValues { maxTurnsPerRun: number; heartbeatEnabled: boolean; intervalSec: number; + /** Arbitrary key-value pairs populated by schema-driven config fields. */ + adapterSchemaValues?: Record; } diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts index 7df54741..b8f32568 100644 --- a/server/src/adapters/types.ts +++ b/server/src/adapters/types.ts @@ -25,5 +25,8 @@ export type { NativeContextManagement, ResolvedSessionCompactionPolicy, SessionCompactionPolicy, + ConfigFieldOption, + ConfigFieldSchema, + AdapterConfigSchema, ServerAdapterModule, } from "@paperclipai/adapter-utils"; diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index 579ed31d..49e76c25 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -35,7 +35,7 @@ import { setAdapterDisabled, } from "../services/adapter-plugin-store.js"; import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js"; -import type { ServerAdapterModule } from "../adapters/types.js"; +import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js"; import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js"; import { logger } from "../middleware/logger.js"; import { assertBoard } from "./authz.js"; @@ -453,6 +453,7 @@ export function adapterRoutes() { // Swap in the reloaded module unregisterServerAdapter(type); registerWithSessionManagement(newModule); + configSchemaCache.delete(type); // Sync store.version from package.json (store may be missing version for local installs). const record = getAdapterPluginByType(type); @@ -520,6 +521,7 @@ export function adapterRoutes() { unregisterServerAdapter(type); registerWithSessionManagement(newModule); + configSchemaCache.delete(type); // Sync store version from disk let newVersion: string | undefined; @@ -541,6 +543,44 @@ export function adapterRoutes() { } }); + // ── GET /api/adapters/:type/config-schema ──────────────────────────────── + // Serve a declarative config schema for an adapter's UI form fields. + // The adapter's getConfigSchema() resolves all options (static and dynamic) + // so the UI receives a fully hydrated schema in a single fetch. + const configSchemaCache = new Map(); + const CONFIG_SCHEMA_TTL_MS = 30_000; + + router.get("/adapters/:type/config-schema", async (req, res) => { + assertBoard(req); + const { type } = req.params; + + const adapter = findServerAdapter(type); + if (!adapter) { + res.status(404).json({ error: `Adapter "${type}" is not registered.` }); + return; + } + if (!adapter.getConfigSchema) { + res.status(404).json({ error: `Adapter "${type}" does not provide a config schema.` }); + return; + } + + const cached = configSchemaCache.get(type); + if (cached && Date.now() - cached.fetchedAt < CONFIG_SCHEMA_TTL_MS) { + res.json(cached.schema); + return; + } + + try { + const schema = await adapter.getConfigSchema(); + configSchemaCache.set(type, { schema, fetchedAt: Date.now() }); + res.json(schema); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ err, type }, "Failed to resolve config schema"); + res.status(500).json({ error: `Failed to resolve config schema: ${message}` }); + } + }); + // ── GET /api/adapters/:type/ui-parser.js ───────────────────────────────── // Serve the self-contained UI parser JS for an adapter type. // This allows external adapters to provide custom run-log parsing diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 8c48ecbc..1e3a4452 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -10,6 +10,7 @@ import { hermesLocalUIAdapter } from "./hermes-local"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; import { loadDynamicParser } from "./dynamic-loader"; +import { SchemaConfigFields, buildSchemaAdapterConfig } from "./schema-config-fields"; const uiAdapters: UIAdapterModule[] = []; const adaptersByType = new Map(); @@ -60,7 +61,6 @@ export function getUIAdapter(type: string): UIAdapterModule { const builtIn = adaptersByType.get(type); if (!builtIn) { - // No built-in adapter — fall through to the external-only path. let loadStarted = false; return { type, @@ -74,16 +74,16 @@ export function getUIAdapter(type: string): UIAdapterModule { type, label: type, parseStdoutLine: parser, - ConfigFields: processUIAdapter.ConfigFields, - buildAdapterConfig: processUIAdapter.buildAdapterConfig, + ConfigFields: SchemaConfigFields, + buildAdapterConfig: buildSchemaAdapterConfig, }); } }); } return processUIAdapter.parseStdoutLine(line, ts); }, - ConfigFields: processUIAdapter.ConfigFields, - buildAdapterConfig: processUIAdapter.buildAdapterConfig, + ConfigFields: SchemaConfigFields, + buildAdapterConfig: buildSchemaAdapterConfig, }; } @@ -117,16 +117,16 @@ export function syncExternalAdapters( type, label, parseStdoutLine: parser, - ConfigFields: processUIAdapter.ConfigFields, - buildAdapterConfig: processUIAdapter.buildAdapterConfig, + ConfigFields: SchemaConfigFields, + buildAdapterConfig: buildSchemaAdapterConfig, }); } }); } return processUIAdapter.parseStdoutLine(line, ts); }, - ConfigFields: processUIAdapter.ConfigFields, - buildAdapterConfig: processUIAdapter.buildAdapterConfig, + ConfigFields: SchemaConfigFields, + buildAdapterConfig: buildSchemaAdapterConfig, }); } } diff --git a/ui/src/adapters/schema-config-fields.tsx b/ui/src/adapters/schema-config-fields.tsx new file mode 100644 index 00000000..52a67fdc --- /dev/null +++ b/ui/src/adapters/schema-config-fields.tsx @@ -0,0 +1,259 @@ +import { useState, useEffect } from "react"; + +import type { AdapterConfigSchema, ConfigFieldSchema, CreateConfigValues } from "@paperclipai/adapter-utils"; + +import type { AdapterConfigFieldsProps } from "./types"; +import { + Field, + DraftInput, + DraftNumberInput, + DraftTextarea, + ToggleField, +} from "../components/agent-config-primitives"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; + +const selectClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono"; + +// --------------------------------------------------------------------------- +// Schema cache (module-level, survives re-renders) +// --------------------------------------------------------------------------- + +const schemaCache = new Map(); +const schemaFetchInflight = new Map>(); +const failedSchemaTypes = new Set(); + +async function fetchConfigSchema(adapterType: string): Promise { + const cached = schemaCache.get(adapterType); + if (cached !== undefined) return cached; + if (failedSchemaTypes.has(adapterType)) return null; + + const inflight = schemaFetchInflight.get(adapterType); + if (inflight) return inflight; + + const promise = (async () => { + try { + const res = await fetch(`/api/adapters/${encodeURIComponent(adapterType)}/config-schema`); + if (!res.ok) { + failedSchemaTypes.add(adapterType); + return null; + } + const schema = (await res.json()) as AdapterConfigSchema; + schemaCache.set(adapterType, schema); + return schema; + } catch { + failedSchemaTypes.add(adapterType); + return null; + } finally { + schemaFetchInflight.delete(adapterType); + } + })(); + + schemaFetchInflight.set(adapterType, promise); + return promise; +} + +export function invalidateConfigSchemaCache(adapterType: string): void { + schemaCache.delete(adapterType); + failedSchemaTypes.delete(adapterType); +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +function useConfigSchema(adapterType: string): AdapterConfigSchema | null { + const [schema, setSchema] = useState( + schemaCache.get(adapterType) ?? null, + ); + + useEffect(() => { + let cancelled = false; + fetchConfigSchema(adapterType).then((s) => { + if (!cancelled) setSchema(s); + }); + return () => { + cancelled = true; + }; + }, [adapterType]); + + return schema; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function getDefaultValue(field: ConfigFieldSchema): unknown { + if (field.default !== undefined) return field.default; + switch (field.type) { + case "toggle": + return false; + case "number": + return 0; + case "text": + case "textarea": + return ""; + case "select": + return field.options?.[0]?.value ?? ""; + } +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function SchemaConfigFields({ + adapterType, + isCreate, + values, + set, + config, + eff, + mark, +}: AdapterConfigFieldsProps) { + const schema = useConfigSchema(adapterType); + + const [defaultsApplied, setDefaultsApplied] = useState(false); + useEffect(() => { + if (!schema || !isCreate || defaultsApplied) return; + const defaults: Record = {}; + for (const field of schema.fields) { + const def = getDefaultValue(field); + if (def !== undefined && def !== "") { + defaults[field.key] = def; + } + } + if (Object.keys(defaults).length > 0) { + set?.({ + adapterSchemaValues: { ...values?.adapterSchemaValues, ...defaults }, + }); + } + setDefaultsApplied(true); + }, [schema, isCreate, defaultsApplied, set, values?.adapterSchemaValues]); + + if (!schema || schema.fields.length === 0) return null; + + function readValue(field: ConfigFieldSchema): unknown { + if (isCreate) { + return values?.adapterSchemaValues?.[field.key] ?? getDefaultValue(field); + } + const stored = config[field.key]; + return eff("adapterConfig", field.key, (stored ?? getDefaultValue(field)) as string); + } + + function writeValue(field: ConfigFieldSchema, value: unknown): void { + if (isCreate) { + set?.({ + adapterSchemaValues: { + ...values?.adapterSchemaValues, + [field.key]: value, + }, + }); + } else { + mark("adapterConfig", field.key, value); + } + } + + return ( + <> + {schema.fields.map((field) => { + switch (field.type) { + case "select": + return ( + + + + ); + + case "toggle": + return ( + writeValue(field, v)} + /> + ); + + case "number": + return ( + + writeValue(field, v)} + immediate + className={inputClass} + /> + + ); + + case "textarea": + return ( + + writeValue(field, v || undefined)} + immediate + /> + + ); + + case "text": + default: + return ( + + writeValue(field, v || undefined)} + immediate + className={inputClass} + /> + + ); + } + })} + + ); +} + +// --------------------------------------------------------------------------- +// Build adapter config from schema values + standard CreateConfigValues fields +// --------------------------------------------------------------------------- + +export function buildSchemaAdapterConfig( + values: CreateConfigValues, +): Record { + const ac: Record = {}; + + if (values.model?.trim()) ac.model = values.model.trim(); + if (values.cwd) ac.cwd = values.cwd; + if (values.command) ac.command = values.command; + if (values.instructionsFilePath) ac.instructionsFilePath = values.instructionsFilePath; + if (values.thinkingEffort) ac.thinkingEffort = values.thinkingEffort; + + if (values.extraArgs) { + ac.extraArgs = values.extraArgs + .split(/\s+/) + .filter(Boolean); + } + + if (values.adapterSchemaValues) { + Object.assign(ac, values.adapterSchemaValues); + } + + return ac; +} diff --git a/ui/src/pages/AdapterManager.tsx b/ui/src/pages/AdapterManager.tsx index 0c16261f..39df6e75 100644 --- a/ui/src/pages/AdapterManager.tsx +++ b/ui/src/pages/AdapterManager.tsx @@ -31,6 +31,7 @@ import { useToast } from "@/context/ToastContext"; import { cn } from "@/lib/utils"; import { ChoosePathButton } from "@/components/PathInstructionsModal"; import { invalidateDynamicParser } from "@/adapters/dynamic-loader"; +import { invalidateConfigSchemaCache } from "@/adapters/schema-config-fields"; function AdapterRow({ adapter, @@ -215,6 +216,7 @@ export function AdapterManager() { onSuccess: (result) => { invalidate(); invalidateDynamicParser(result.type); + invalidateConfigSchemaCache(result.type); pushToast({ title: "Adapter reloaded", body: `Type "${result.type}" reloaded.${result.version ? ` (v${result.version})` : ""}`, @@ -231,6 +233,7 @@ export function AdapterManager() { onSuccess: (result) => { invalidate(); invalidateDynamicParser(result.type); + invalidateConfigSchemaCache(result.type); pushToast({ title: "Adapter reinstalled", body: `Type "${result.type}" updated from npm.${result.version ? ` (v${result.version})` : ""}`,