feat(adapters): declarative config-schema API and UI for plugin adapters
Cherry-picked from feat/externalize-hermes-adapter. Resolved conflicts: kept Hermes as built-in on phase1 branch.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -22,6 +22,9 @@ export type {
|
||||
AdapterModel,
|
||||
HireApprovedPayload,
|
||||
HireApprovedHookResult,
|
||||
ConfigFieldOption,
|
||||
ConfigFieldSchema,
|
||||
AdapterConfigSchema,
|
||||
ServerAdapterModule,
|
||||
QuotaWindow,
|
||||
ProviderQuotaResult,
|
||||
|
||||
@@ -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<AdapterExecutionResult>;
|
||||
@@ -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> | 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<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@ export type {
|
||||
NativeContextManagement,
|
||||
ResolvedSessionCompactionPolicy,
|
||||
SessionCompactionPolicy,
|
||||
ConfigFieldOption,
|
||||
ConfigFieldSchema,
|
||||
AdapterConfigSchema,
|
||||
ServerAdapterModule,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
|
||||
@@ -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<string, { schema: AdapterConfigSchema; fetchedAt: number }>();
|
||||
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
|
||||
|
||||
@@ -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<string, UIAdapterModule>();
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, AdapterConfigSchema | null>();
|
||||
const schemaFetchInflight = new Map<string, Promise<AdapterConfigSchema | null>>();
|
||||
const failedSchemaTypes = new Set<string>();
|
||||
|
||||
async function fetchConfigSchema(adapterType: string): Promise<AdapterConfigSchema | null> {
|
||||
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<AdapterConfigSchema | null>(
|
||||
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<string, unknown> = {};
|
||||
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 (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<select
|
||||
className={selectClass}
|
||||
value={String(readValue(field) ?? "")}
|
||||
onChange={(e) => writeValue(field, e.target.value)}
|
||||
>
|
||||
{field.options?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
);
|
||||
|
||||
case "toggle":
|
||||
return (
|
||||
<ToggleField
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
hint={field.hint}
|
||||
checked={readValue(field) === true}
|
||||
onChange={(v) => writeValue(field, v)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftNumberInput
|
||||
value={Number(readValue(field) ?? 0)}
|
||||
onCommit={(v) => writeValue(field, v)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftTextarea
|
||||
value={String(readValue(field) ?? "")}
|
||||
onCommit={(v) => writeValue(field, v || undefined)}
|
||||
immediate
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
case "text":
|
||||
default:
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftInput
|
||||
value={String(readValue(field) ?? "")}
|
||||
onCommit={(v) => writeValue(field, v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build adapter config from schema values + standard CreateConfigValues fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildSchemaAdapterConfig(
|
||||
values: CreateConfigValues,
|
||||
): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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})` : ""}`,
|
||||
|
||||
Reference in New Issue
Block a user