From c5593e396134036cafa8ce77020b576de074bdf9 Mon Sep 17 00:00:00 2001 From: hzhang Date: Wed, 3 Jun 2026 11:57:24 +0100 Subject: [PATCH] initial drop: Dialectic.PlexumPlugin v0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of Dialectic.OpenclawPlugin to the Plexum SDK. 8 dialectic_* tools wired to Dialectic.Backend over HTTP: list_topics, topic_detail, list_arguments, propose_topic, signup, post_argument, submit_verdict, view_verdict Differences from the OpenClaw port worth noting: - Per-agent API key storage: OpenClaw used secret-mgr (one entry per agent's keyspace). Plexum has no secret-mgr; v1 stores keys directly in plugin config (apiKey + agentKeys map). - Agent identity at tool dispatch: OpenClaw framework surfaces ctx.agentId; Plexum SDK doesn't yet plumb the calling agent through ToolPlugin.CallTool. v1 falls back to config.defaultAgentID — same stop-gap HarborForge.PlexumPlugin is on. Tracked as upstream SDK work. - HF on_call coverage pre-check on signup: stub that always returns "skipped", matching OpenClaw v1's behavior (HarborForge never shipped the cross-plugin coverage query). pre_validated is sent as false so the backend records audit honestly. DIALECTIC_PLUGIN_BYPASS_HF=1 env retains parity with OpenClaw. - Activation: lazy (no background services, unlike HarborForge's eager-spawn for the calendar scheduler + monitor bridge). Backend client follows the bearer-auth contract OpenClaw's backend-client.ts established; endpoint shapes are unchanged. --- .gitignore | 4 + Makefile | 28 +++ README.md | 104 ++++++++ cmd/plexum-dialectic-plugin/main.go | 96 ++++++++ go.mod | 7 + internal/backend/client.go | 105 +++++++++ internal/config/config.go | 99 ++++++++ internal/hfprecheck/precheck.go | 32 +++ internal/tools/tools.go | 354 ++++++++++++++++++++++++++++ manifest.json | 122 ++++++++++ scripts/install.sh | 46 ++++ 11 files changed, 997 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/plexum-dialectic-plugin/main.go create mode 100644 go.mod create mode 100644 internal/backend/client.go create mode 100644 internal/config/config.go create mode 100644 internal/hfprecheck/precheck.go create mode 100644 internal/tools/tools.go create mode 100644 manifest.json create mode 100755 scripts/install.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06e056f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/dist/ +*.test +*.out +.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f87d396 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +GO_ENV := CGO_ENABLED=0 + +.PHONY: build install clean help + +help: + @echo "Dialectic.PlexumPlugin build targets:" + @echo " build - compile binary + bundle manifest into dist/" + @echo " install - copy binary + manifest into ~/.plexum/plugins/dialectic/" + @echo " clean - rm -rf dist/" + +build: + mkdir -p dist + $(GO_ENV) go build -ldflags="-X main.Version=$(VERSION)" \ + -o dist/plexum-dialectic-plugin ./cmd/plexum-dialectic-plugin + cp manifest.json dist/manifest.json + @echo "Built to dist/ (version=$(VERSION))" + +install: build + mkdir -p ~/.plexum/plugins/dialectic + cp dist/plexum-dialectic-plugin ~/.plexum/plugins/dialectic/ + cp dist/manifest.json ~/.plexum/plugins/dialectic/ + @echo "Installed to ~/.plexum/plugins/dialectic/" + @echo "Add to ~/.plexum/plexum.json .plugins.allow: 'dialectic'" + @echo "Config goes at ~/.plexum/plugins/dialectic/config.json (see README)" + +clean: + rm -rf dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c0df93d --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# Dialectic.PlexumPlugin + +Plexum plugin that gives agents tools to participate in Dialectic v2 +debates. Ports `Dialectic.OpenclawPlugin` to the Plexum SDK. + +Eight tools, one per Dialectic backend endpoint: + +| Tool | Backend call | Notes | +|------|--------------|-------| +| `dialectic_list_topics` | `GET /api/topics` | filters: status/visibility/limit/offset | +| `dialectic_topic_detail` | `GET /api/topics/{id}` | lifecycle + camps + verdict pointer | +| `dialectic_list_arguments` | `GET /api/topics/{id}/arguments` | full transcript | +| `dialectic_propose_topic` | `POST /api/topics` | 4 lifecycle timestamps + verdict schema | +| `dialectic_signup` | `POST /api/topics/{id}/signups` | with HF on_call coverage pre-check | +| `dialectic_post_argument` | `POST /api/topics/{id}/arguments` | during `debating` only | +| `dialectic_submit_verdict` | `POST /api/topics/{id}/verdict` | judge submits structured verdict | +| `dialectic_view_verdict` | `GET /api/topics/{id}/verdict` | 404 until judge submits | + +## Install + +``` +make install +``` + +Then add `"dialectic"` to `~/.plexum/plexum.json` `.plugins.allow` and +write `~/.plexum/plugins/dialectic/config.json`: + +```json +{ + "backendUrl": "https://dialectic-api.hangman-lab.top", + "apiKey": "g1_xxx", + "defaultAgentID": "agent-xyz" +} +``` + +Multi-agent claws can use `agentKeys` instead of (or in addition to) +`apiKey`: + +```json +{ + "backendUrl": "https://dialectic-api.hangman-lab.top", + "agentKeys": { + "agent-a": "g1_aaa", + "agent-b": "g1_bbb" + }, + "apiKey": "g1_default_fallback", + "defaultAgentID": "agent-a" +} +``` + +Restart the host afterwards: `systemctl --user restart plexum`. + +## Config + +| Field | Default | Purpose | +|-------|---------|---------| +| `backendUrl` | `https://dialectic-api.hangman-lab.top` | Dialectic API base. Env override: `DIALECTIC_BACKEND_URL`. | +| `apiKey` | — | Default bearer token. | +| `agentKeys` | `{}` | Per-agent bearer token overrides. | +| `defaultAgentID` | — | Agent id reported to the backend when host hasn't surfaced one via tool ctx. | + +## How agent identity is resolved (v1 limitation) + +`Dialectic.OpenclawPlugin` got the calling agent's id via the OpenClaw +framework's `ctx.agentId`. The Plexum SDK doesn't yet surface this on +tool dispatch — same constraint `HarborForge.PlexumPlugin` hits. v1 +falls back to `config.defaultAgentID`. Multi-agent claws can configure +`agentKeys` but the bearer token used per call is selected against the +config default (not the true caller), which is fine for a homogeneous- +role claw but won't sort signed verdicts apart by agent. + +The fix (deferred): plumb `AgentContext` through `ToolPlugin.CallTool` +in `Plexum-sdk-go`. Once landed, swap `AgentIDFromCtx` to read it. + +## HF on_call coverage pre-check + +`dialectic_signup` is supposed to verify the agent has an HarborForge +`on_call` slot covering the debate window before submitting. Like the +OpenClaw plugin's v1, this Plexum port currently degrades to +`source="skipped"` (HarborForge.Backend exposes no window-coverage +query yet). Signups go through with `pre_validated=false` so the +backend records the gap honestly. + +Override with `DIALECTIC_PLUGIN_BYPASS_HF=1` in the host's environment +to make the skip explicit (matches the OpenClaw plugin escape hatch). + +## Deferred items + +- **Per-call agent id** — see "How agent identity is resolved" above. +- **HF window-coverage check** — needs a backend-side endpoint or a + Plexum cross-plugin contract for `harbor-forge` to surface + `HasOnCallCovering(agentID, from, to)`. +- **SSE subscriptions** — agents poll via `dialectic_topic_detail` to + see status/argument changes. Once Dialectic.Backend ships SSE, add + `dialectic_subscribe`. +- **Token-cost reporting** — `dialectic_submit_verdict` already accepts + `tokens_input` / `tokens_output`; wire automatic accounting once + Plexum exposes per-turn usage telemetry through HostAPI. + +## See also + +- Top-level design: `arch/DIALECTIC-V2-DESIGN.md` +- Backend: `Dialectic.Backend` (Go) +- OpenClaw port: `Dialectic.OpenclawPlugin` diff --git a/cmd/plexum-dialectic-plugin/main.go b/cmd/plexum-dialectic-plugin/main.go new file mode 100644 index 0000000..dbedb73 --- /dev/null +++ b/cmd/plexum-dialectic-plugin/main.go @@ -0,0 +1,96 @@ +// plexum-dialectic-plugin — Plexum-side Dialectic plugin. +// +// Ports Dialectic.OpenclawPlugin to the Plexum SDK: 8 dialectic_* tools +// over HTTP against Dialectic.Backend. Lazy activation (no background +// services — purely tool-call driven). +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + sdkplugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin" + + dialcfg "git.hangman-lab.top/zhi/Dialectic.PlexumPlugin/internal/config" + "git.hangman-lab.top/zhi/Dialectic.PlexumPlugin/internal/tools" +) + +var Version = "0.1.0" + +type dialecticPlugin struct { + host sdkplugin.HostAPI + cfg dialcfg.Resolved + deps tools.Deps +} + +func (p *dialecticPlugin) Manifest() sdkplugin.Manifest { + return manifestFromDisk() +} + +func (p *dialecticPlugin) Init(ctx context.Context, host sdkplugin.HostAPI) error { + p.host = host + + profileRoot := os.Getenv("PLEXUM_PROFILE_ROOT") + if profileRoot == "" { + home, _ := os.UserHomeDir() + profileRoot = filepath.Join(home, ".plexum") + } + raw, err := dialcfg.Load(profileRoot) + if err != nil { + return fmt.Errorf("load dialectic config: %w", err) + } + p.cfg = dialcfg.Resolve(raw) + host.Log("info", "dialectic plugin initialized", map[string]any{ + "version": Version, + "backend": p.cfg.BackendURL, + "default_agent_id": p.cfg.DefaultAgentID, + "agent_keys_count": len(p.cfg.AgentKeys), + "has_default_key": p.cfg.APIKey != "", + }) + + p.deps = tools.Deps{ + Config: p.cfg, + Host: host, + AgentIDFromCtx: func(ctx context.Context) string { + // v1: SDK doesn't surface the calling agent on tool ctx. + // Fall back to the per-claw default. Multi-agent claws will + // need SDK ctx plumbing — tracked upstream. + return p.cfg.DefaultAgentID + }, + } + return nil +} + +func (p *dialecticPlugin) CallTool(ctx context.Context, name string, input json.RawMessage) (sdkplugin.ToolResult, error) { + return tools.Dispatch(ctx, p.deps, name, input) +} + +func manifestFromDisk() sdkplugin.Manifest { + exe, err := os.Executable() + if err == nil { + raw, err := os.ReadFile(filepath.Join(filepath.Dir(exe), "manifest.json")) + if err == nil { + var m sdkplugin.Manifest + if err := json.Unmarshal(raw, &m); err == nil && m.Name != "" { + return m + } + } + } + return sdkplugin.Manifest{ + Name: "dialectic", + Version: Version, + Activation: sdkplugin.ActivationLazy, + Executable: "plexum-dialectic-plugin", + } +} + +func main() { + if err := sdkplugin.Serve(&dialecticPlugin{}); err != nil && !errors.Is(err, context.Canceled) { + fmt.Fprintf(os.Stderr, "plexum-dialectic-plugin: %v\n", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e43c5dc --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module git.hangman-lab.top/zhi/Dialectic.PlexumPlugin + +go 1.24 + +require git.hangman-lab.top/hzhang/Plexum-sdk-go v0.0.0 + +replace git.hangman-lab.top/hzhang/Plexum-sdk-go => ../Plexum-sdk-go diff --git a/internal/backend/client.go b/internal/backend/client.go new file mode 100644 index 0000000..6d461ce --- /dev/null +++ b/internal/backend/client.go @@ -0,0 +1,105 @@ +// Package backend is the typed HTTP client for Dialectic.Backend's API. +// Endpoint shapes mirror Dialectic.OpenclawPlugin/plugin/src/backend-client.ts +// so the two plugins drop into the same backend without per-plugin +// adapters. +package backend + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type Client struct { + BaseURL string + APIKey string + HTTP *http.Client +} + +func New(baseURL, apiKey string) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + APIKey: apiKey, + HTTP: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (c *Client) Get(ctx context.Context, path string) (json.RawMessage, error) { + return c.do(ctx, http.MethodGet, path, nil) +} + +func (c *Client) Post(ctx context.Context, path string, body any) (json.RawMessage, error) { + return c.do(ctx, http.MethodPost, path, body) +} + +func (c *Client) Put(ctx context.Context, path string, body any) (json.RawMessage, error) { + return c.do(ctx, http.MethodPut, path, body) +} + +func (c *Client) do(ctx context.Context, method, path string, body any) (json.RawMessage, error) { + if c.APIKey == "" { + return nil, errors.New("dialectic api key not configured — set plugins.dialectic.config.apiKey or agentKeys[]") + } + url := c.BaseURL + ensureLeadingSlash(path) + var rdr io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal %s %s: %w", method, path, err) + } + rdr = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, url, rdr) + if err != nil { + return nil, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("Accept", "application/json") + + res, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("%s %s: %w", method, path, err) + } + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, fmt.Errorf("%s %s → %d: %s", + method, path, res.StatusCode, truncate(raw, 500)) + } + if len(raw) == 0 { + return nil, nil + } + return raw, nil +} + +func ensureLeadingSlash(p string) string { + if strings.HasPrefix(p, "/") { + return p + } + return "/" + p +} + +func truncate(b []byte, n int) string { + if len(b) <= n { + return string(b) + } + return string(b[:n]) + "…" +} + +// TopicDetail is the minimal shape the signup tool needs (debate window +// for HF pre-check). The Dialectic backend returns more fields — they +// pass through untouched in the raw JSON the tools surface to agents. +type TopicDetail struct { + ID string `json:"id"` + DebateStartAt string `json:"debate_start_at"` + DebateEndAt string `json:"debate_end_at"` +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e64963d --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,99 @@ +// Package config loads the Dialectic plugin's per-profile config from +// /plugins/dialectic/config.json. Mirrors the resolved shape +// of Dialectic.OpenclawPlugin (backendUrl + per-agent api key), adapted +// for Plexum's profile layout. +// +// OpenClaw stored per-agent api keys in secret-mgr (one read per +// agent's keyspace). Plexum has no secret-mgr; v1 stores keys directly +// in this config and falls back to a single shared apiKey when the +// caller's agent id has no override entry. +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +type Config struct { + BackendURL string `json:"backendUrl,omitempty"` + + // APIKey is the default bearer token used when AgentKeys has no + // entry for the calling agent. Equivalent to a claw-level api key. + APIKey string `json:"apiKey,omitempty"` + + // AgentKeys maps agent_id → bearer token. Overrides APIKey when + // the caller's agent id matches an entry. + AgentKeys map[string]string `json:"agentKeys,omitempty"` + + // DefaultAgentID is the agent_id the plugin reports when the host + // hasn't surfaced the calling agent via ctx. Set this on single- + // agent claws; multi-agent setups should leave it empty and rely + // on the tool dispatcher's best-effort resolution. + DefaultAgentID string `json:"defaultAgentID,omitempty"` +} + +type Resolved struct { + BackendURL string + APIKey string + AgentKeys map[string]string + DefaultAgentID string +} + +const DefaultBackendURL = "https://dialectic-api.hangman-lab.top" + +func PluginConfigDir(profileRoot string) string { + return filepath.Join(profileRoot, "plugins", "dialectic") +} + +func PluginConfigPath(profileRoot string) string { + return filepath.Join(PluginConfigDir(profileRoot), "config.json") +} + +func Load(profileRoot string) (Config, error) { + path := PluginConfigPath(profileRoot) + raw, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Config{}, nil + } + return Config{}, fmt.Errorf("read %s: %w", path, err) + } + if len(raw) == 0 { + return Config{}, nil + } + var c Config + if err := json.Unmarshal(raw, &c); err != nil { + return Config{}, fmt.Errorf("parse %s: %w", path, err) + } + return c, nil +} + +func Resolve(c Config) Resolved { + out := Resolved{ + BackendURL: DefaultBackendURL, + APIKey: c.APIKey, + AgentKeys: c.AgentKeys, + DefaultAgentID: c.DefaultAgentID, + } + if c.BackendURL != "" { + out.BackendURL = c.BackendURL + } + if env := os.Getenv("DIALECTIC_BACKEND_URL"); env != "" { + out.BackendURL = env + } + return out +} + +// ResolveAPIKey picks the bearer token for the calling agent. Returns +// "" iff neither an agent-specific key nor a default apiKey is set. +func (r Resolved) ResolveAPIKey(agentID string) string { + if agentID != "" { + if k, ok := r.AgentKeys[agentID]; ok && k != "" { + return k + } + } + return r.APIKey +} diff --git a/internal/hfprecheck/precheck.go b/internal/hfprecheck/precheck.go new file mode 100644 index 0000000..3fbb43b --- /dev/null +++ b/internal/hfprecheck/precheck.go @@ -0,0 +1,32 @@ +// Package hfprecheck implements the HF on_call coverage check used by +// dialectic_signup. The OpenClaw plugin relied on a cross-plugin global +// (`globalThis.__hfAgentStatus.hasOnCallCovering`) which HarborForge +// hadn't shipped yet — so v1 there always degraded to "skipped". +// +// Plexum has no cross-plugin globals at all. v1 here is unconditionally +// "skipped" (same audit-only outcome as OpenClaw v1). When HarborForge +// the backend grows a public coverage endpoint we'll wire it here. +package hfprecheck + +import "os" + +type Result struct { + OK bool // false ⇒ block signup + Reason string // populated when OK=false + Source string // "hf" | "skipped" +} + +// Check is a no-op gate for v1. Returns OK=true, source="skipped" +// unconditionally; signups are sent to the backend with +// pre_validated=false so the audit trail records the gap honestly. +// +// DIALECTIC_PLUGIN_BYPASS_HF=1 forces "skipped" even after a future +// implementation lands — matches the OpenClaw plugin's escape hatch +// for sim environments without provisioned on_call schedules. +func Check(agentID, debateStartAt, debateEndAt string) Result { + if os.Getenv("DIALECTIC_PLUGIN_BYPASS_HF") == "1" { + return Result{OK: true, Source: "skipped"} + } + // No host-side coverage query yet; degrade to audit-only. + return Result{OK: true, Source: "skipped"} +} diff --git a/internal/tools/tools.go b/internal/tools/tools.go new file mode 100644 index 0000000..999272b --- /dev/null +++ b/internal/tools/tools.go @@ -0,0 +1,354 @@ +// Package tools wires the 8 dialectic_* tool implementations. +// Each tool is one HTTP call against Dialectic.Backend; errors come +// back as is_error=true ToolResult so the agent sees a usable message +// rather than an RPC failure. +package tools + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strconv" + + sdkplugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin" + + "git.hangman-lab.top/zhi/Dialectic.PlexumPlugin/internal/backend" + "git.hangman-lab.top/zhi/Dialectic.PlexumPlugin/internal/config" + "git.hangman-lab.top/zhi/Dialectic.PlexumPlugin/internal/hfprecheck" +) + +type Deps struct { + Config config.Resolved + Host sdkplugin.HostAPI + + // AgentIDFromCtx resolves the calling agent's id. v1: returns + // config.DefaultAgentID (single-agent claws); multi-agent claws + // must wait for SDK ctx plumbing — see ../../README.md. + AgentIDFromCtx func(ctx context.Context) string +} + +func Dispatch(ctx context.Context, deps Deps, name string, input json.RawMessage) (sdkplugin.ToolResult, error) { + switch name { + case "dialectic_list_topics": + return toolListTopics(ctx, deps, input) + case "dialectic_topic_detail": + return toolTopicDetail(ctx, deps, input) + case "dialectic_list_arguments": + return toolListArguments(ctx, deps, input) + case "dialectic_propose_topic": + return toolProposeTopic(ctx, deps, input) + case "dialectic_signup": + return toolSignup(ctx, deps, input) + case "dialectic_post_argument": + return toolPostArgument(ctx, deps, input) + case "dialectic_submit_verdict": + return toolSubmitVerdict(ctx, deps, input) + case "dialectic_view_verdict": + return toolViewVerdict(ctx, deps, input) + } + return errResult("unknown tool: " + name), nil +} + +// ---- helpers ---- + +func clientFor(deps Deps, agentID string) (*backend.Client, error) { + key := deps.Config.ResolveAPIKey(agentID) + if key == "" { + return nil, fmt.Errorf("dialectic api key not configured for agent %q (set plugins.dialectic.config.apiKey or agentKeys[%q])", agentID, agentID) + } + return backend.New(deps.Config.BackendURL, key), nil +} + +func okResult(body json.RawMessage) sdkplugin.ToolResult { + if len(body) == 0 { + return sdkplugin.NewTextResult("null") + } + return sdkplugin.NewTextResult(string(body)) +} + +func errResult(msg string) sdkplugin.ToolResult { + return sdkplugin.ToolResult{ + IsError: true, + Content: []sdkplugin.ContentBlock{{Type: "text", Text: "error: " + msg}}, + } +} + +// ---- tools ---- + +type listTopicsIn struct { + Status string `json:"status,omitempty"` + Visibility string `json:"visibility,omitempty"` + Limit *int `json:"limit,omitempty"` + Offset *int `json:"offset,omitempty"` +} + +func toolListTopics(ctx context.Context, deps Deps, in json.RawMessage) (sdkplugin.ToolResult, error) { + var p listTopicsIn + if len(in) > 0 { + if err := json.Unmarshal(in, &p); err != nil { + return errResult("invalid input: " + err.Error()), nil + } + } + cli, err := clientFor(deps, deps.AgentIDFromCtx(ctx)) + if err != nil { + return errResult(err.Error()), nil + } + q := url.Values{} + if p.Status != "" { + q.Set("status", p.Status) + } + if p.Visibility != "" { + q.Set("visibility", p.Visibility) + } + if p.Limit != nil { + q.Set("limit", strconv.Itoa(*p.Limit)) + } + if p.Offset != nil { + q.Set("offset", strconv.Itoa(*p.Offset)) + } + path := "/api/topics" + if qs := q.Encode(); qs != "" { + path += "?" + qs + } + raw, err := cli.Get(ctx, path) + if err != nil { + return errResult(err.Error()), nil + } + return okResult(raw), nil +} + +type topicIDIn struct { + TopicID string `json:"topic_id"` +} + +func toolTopicDetail(ctx context.Context, deps Deps, in json.RawMessage) (sdkplugin.ToolResult, error) { + var p topicIDIn + if err := json.Unmarshal(in, &p); err != nil { + return errResult("invalid input: " + err.Error()), nil + } + if p.TopicID == "" { + return errResult("topic_id required"), nil + } + cli, err := clientFor(deps, deps.AgentIDFromCtx(ctx)) + if err != nil { + return errResult(err.Error()), nil + } + raw, err := cli.Get(ctx, "/api/topics/"+url.PathEscape(p.TopicID)) + if err != nil { + return errResult(err.Error()), nil + } + return okResult(raw), nil +} + +func toolListArguments(ctx context.Context, deps Deps, in json.RawMessage) (sdkplugin.ToolResult, error) { + var p topicIDIn + if err := json.Unmarshal(in, &p); err != nil { + return errResult("invalid input: " + err.Error()), nil + } + if p.TopicID == "" { + return errResult("topic_id required"), nil + } + cli, err := clientFor(deps, deps.AgentIDFromCtx(ctx)) + if err != nil { + return errResult(err.Error()), nil + } + raw, err := cli.Get(ctx, "/api/topics/"+url.PathEscape(p.TopicID)+"/arguments") + if err != nil { + return errResult(err.Error()), nil + } + return okResult(raw), nil +} + +type proposeTopicIn struct { + Title string `json:"title"` + Summary string `json:"summary"` + VerdictSchemaID string `json:"verdict_schema_id"` + Visibility string `json:"visibility,omitempty"` + SignupOpenAt string `json:"signup_open_at"` + SignupCloseAt string `json:"signup_close_at"` + DebateStartAt string `json:"debate_start_at"` + DebateEndAt string `json:"debate_end_at"` +} + +func toolProposeTopic(ctx context.Context, deps Deps, in json.RawMessage) (sdkplugin.ToolResult, error) { + var p proposeTopicIn + if err := json.Unmarshal(in, &p); err != nil { + return errResult("invalid input: " + err.Error()), nil + } + for k, v := range map[string]string{ + "title": p.Title, + "summary": p.Summary, + "verdict_schema_id": p.VerdictSchemaID, + "signup_open_at": p.SignupOpenAt, + "signup_close_at": p.SignupCloseAt, + "debate_start_at": p.DebateStartAt, + "debate_end_at": p.DebateEndAt, + } { + if v == "" { + return errResult(k + " required"), nil + } + } + cli, err := clientFor(deps, deps.AgentIDFromCtx(ctx)) + if err != nil { + return errResult(err.Error()), nil + } + body := map[string]any{ + "title": p.Title, + "summary": p.Summary, + "verdict_schema_id": p.VerdictSchemaID, + "signup_open_at": p.SignupOpenAt, + "signup_close_at": p.SignupCloseAt, + "debate_start_at": p.DebateStartAt, + "debate_end_at": p.DebateEndAt, + } + if p.Visibility != "" { + body["visibility"] = p.Visibility + } + raw, err := cli.Post(ctx, "/api/topics", body) + if err != nil { + return errResult(err.Error()), nil + } + return okResult(raw), nil +} + +type signupIn struct { + TopicID string `json:"topic_id"` + WillingCamps []string `json:"willing_camps"` +} + +func toolSignup(ctx context.Context, deps Deps, in json.RawMessage) (sdkplugin.ToolResult, error) { + var p signupIn + if err := json.Unmarshal(in, &p); err != nil { + return errResult("invalid input: " + err.Error()), nil + } + if p.TopicID == "" { + return errResult("topic_id required"), nil + } + if len(p.WillingCamps) == 0 { + return errResult("willing_camps must have ≥1 entry"), nil + } + agentID := deps.AgentIDFromCtx(ctx) + cli, err := clientFor(deps, agentID) + if err != nil { + return errResult(err.Error()), nil + } + + // Fetch topic detail so we know the debate window for HF pre-check. + rawTopic, err := cli.Get(ctx, "/api/topics/"+url.PathEscape(p.TopicID)) + if err != nil { + return errResult("topic lookup: " + err.Error()), nil + } + var td backend.TopicDetail + if err := json.Unmarshal(rawTopic, &td); err != nil { + return errResult("topic lookup parse: " + err.Error()), nil + } + if td.DebateStartAt == "" || td.DebateEndAt == "" { + return errResult("topic detail missing debate window timestamps"), nil + } + + pre := hfprecheck.Check(agentID, td.DebateStartAt, td.DebateEndAt) + if !pre.OK { + return errResult("HF pre-check failed: " + pre.Reason), nil + } + + body := map[string]any{ + "willing_camps": p.WillingCamps, + "pre_validated": pre.Source == "hf", + } + raw, err := cli.Post(ctx, "/api/topics/"+url.PathEscape(p.TopicID)+"/signups", body) + if err != nil { + return errResult(err.Error()), nil + } + return okResult(raw), nil +} + +type postArgumentIn struct { + TopicID string `json:"topic_id"` + Content string `json:"content"` +} + +func toolPostArgument(ctx context.Context, deps Deps, in json.RawMessage) (sdkplugin.ToolResult, error) { + var p postArgumentIn + if err := json.Unmarshal(in, &p); err != nil { + return errResult("invalid input: " + err.Error()), nil + } + if p.TopicID == "" { + return errResult("topic_id required"), nil + } + if p.Content == "" { + return errResult("content required"), nil + } + cli, err := clientFor(deps, deps.AgentIDFromCtx(ctx)) + if err != nil { + return errResult(err.Error()), nil + } + raw, err := cli.Post(ctx, "/api/topics/"+url.PathEscape(p.TopicID)+"/arguments", + map[string]any{"content": p.Content}) + if err != nil { + return errResult(err.Error()), nil + } + return okResult(raw), nil +} + +type submitVerdictIn struct { + TopicID string `json:"topic_id"` + Verdict map[string]any `json:"verdict"` + Rationale string `json:"rationale"` + TokensInput *int `json:"tokens_input,omitempty"` + TokensOutput *int `json:"tokens_output,omitempty"` +} + +func toolSubmitVerdict(ctx context.Context, deps Deps, in json.RawMessage) (sdkplugin.ToolResult, error) { + var p submitVerdictIn + if err := json.Unmarshal(in, &p); err != nil { + return errResult("invalid input: " + err.Error()), nil + } + if p.TopicID == "" { + return errResult("topic_id required"), nil + } + if p.Verdict == nil { + return errResult("verdict required"), nil + } + if p.Rationale == "" { + return errResult("rationale required"), nil + } + cli, err := clientFor(deps, deps.AgentIDFromCtx(ctx)) + if err != nil { + return errResult(err.Error()), nil + } + body := map[string]any{ + "verdict": p.Verdict, + "rationale": p.Rationale, + } + if p.TokensInput != nil { + body["tokens_input"] = *p.TokensInput + } + if p.TokensOutput != nil { + body["tokens_output"] = *p.TokensOutput + } + raw, err := cli.Post(ctx, "/api/topics/"+url.PathEscape(p.TopicID)+"/verdict", body) + if err != nil { + return errResult(err.Error()), nil + } + return okResult(raw), nil +} + +func toolViewVerdict(ctx context.Context, deps Deps, in json.RawMessage) (sdkplugin.ToolResult, error) { + var p topicIDIn + if err := json.Unmarshal(in, &p); err != nil { + return errResult("invalid input: " + err.Error()), nil + } + if p.TopicID == "" { + return errResult("topic_id required"), nil + } + cli, err := clientFor(deps, deps.AgentIDFromCtx(ctx)) + if err != nil { + return errResult(err.Error()), nil + } + raw, err := cli.Get(ctx, "/api/topics/"+url.PathEscape(p.TopicID)+"/verdict") + if err != nil { + return errResult(err.Error()), nil + } + return okResult(raw), nil +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..e50251f --- /dev/null +++ b/manifest.json @@ -0,0 +1,122 @@ +{ + "name": "dialectic", + "version": "0.1.0", + "activation": "lazy", + "executable": "plexum-dialectic-plugin", + "contracts": { + "tools": [ + { + "name": "dialectic_list_topics", + "description": "List Dialectic debate topics, optionally filtered. status: created | signup_open | signup_closed | debating | completed | cancelled. visibility: public | private. limit (default 50, max 200), offset for pagination.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "status": {"type": "string"}, + "visibility": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1, "maximum": 200}, + "offset": {"type": "integer", "minimum": 0} + } + } + }, + { + "name": "dialectic_topic_detail", + "description": "Get one topic — lifecycle timestamps, status, verdict_schema_id, and the `camps` array (0 rows pre-signup_close, 3 rows after — scan camps[].agent_id to find which camp you were allocated to). Does NOT include arguments — call dialectic_list_arguments for the transcript.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": {"topic_id": {"type": "string"}}, + "required": ["topic_id"] + } + }, + { + "name": "dialectic_list_arguments", + "description": "Fetch the full argument transcript for a topic in posted order (pro/con/judge entries with author agent_id, content, posted_at). Use before posting a rebuttal or composing a verdict. Empty array if pre-debate.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": {"topic_id": {"type": "string"}}, + "required": ["topic_id"] + } + }, + { + "name": "dialectic_propose_topic", + "description": "Create a new debate topic. Provide title, summary, the 4 lifecycle timestamps (RFC3339, signup_open < signup_close <= debate_start < debate_end), and verdict_schema_id ('binary' | 'claim-resolution' | 'policy-recommendation' | 'free-form'). visibility defaults to private. After creation, broadcast on a Fabric announce-type channel (topic_id + signup deadline + debate window + title). The backend never broadcasts on its own.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": {"type": "string"}, + "summary": {"type": "string"}, + "verdict_schema_id": {"type": "string"}, + "visibility": {"type": "string"}, + "signup_open_at": {"type": "string"}, + "signup_close_at": {"type": "string"}, + "debate_start_at": {"type": "string"}, + "debate_end_at": {"type": "string"} + }, + "required": [ + "title", "summary", "verdict_schema_id", + "signup_open_at", "signup_close_at", "debate_start_at", "debate_end_at" + ] + } + }, + { + "name": "dialectic_signup", + "description": "Volunteer for one or more camps on a topic. Camps are 'pro' | 'con' | 'judge'; allocation picks at most one. Topic must be in `signup_open`. Pre-flight: plugin attempts HF on_call coverage check for the debate window; if HF lookup is unavailable, the check is skipped (recorded as audit-only).", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "topic_id": {"type": "string"}, + "willing_camps": { + "type": "array", + "items": {"type": "string", "enum": ["pro", "con", "judge"]}, + "minItems": 1 + } + }, + "required": ["topic_id", "willing_camps"] + } + }, + { + "name": "dialectic_post_argument", + "description": "Post an argument to a topic you are allocated to. Must be in `debating`. Content max 32KB. Attaches to the latest open round.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "topic_id": {"type": "string"}, + "content": {"type": "string", "maxLength": 32000} + }, + "required": ["topic_id", "content"] + } + }, + { + "name": "dialectic_submit_verdict", + "description": "Submit the structured verdict for a debate you judge. Topic must be in `debating` AND past debate_end_at. `verdict` JSON shape must match the topic's verdict_schema_id. On success the topic transitions to `completed`.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "topic_id": {"type": "string"}, + "verdict": {"type": "object", "additionalProperties": true}, + "rationale": {"type": "string"}, + "tokens_input": {"type": "integer", "minimum": 0}, + "tokens_output": {"type": "integer", "minimum": 0} + }, + "required": ["topic_id", "verdict", "rationale"] + } + }, + { + "name": "dialectic_view_verdict", + "description": "Fetch the structured verdict for a completed topic. 404 if still in progress or the judge has not yet submitted.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": {"topic_id": {"type": "string"}}, + "required": ["topic_id"] + } + } + ] + } +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..cb3e159 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Dialectic.PlexumPlugin installer. +set -euo pipefail + +REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +PROFILE_DIR="${HOME}/.plexum" + +while [[ $# -gt 0 ]]; do + case "$1" in + --profile) PROFILE_DIR="$2"; shift 2 ;; + -h|--help) sed -n '2,/^set -euo/p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown flag: $1" >&2; exit 2 ;; + esac +done + +log() { printf '\033[1;34m[dialectic-install]\033[0m %s\n' "$*"; } +command -v go >/dev/null || { echo "go not found on PATH" >&2; exit 1; } + +PLUGIN_DIR="${PROFILE_DIR}/plugins/dialectic" +mkdir -p "${PLUGIN_DIR}" + +cd "${REPO}" +VERSION="$(git describe --tags --always 2>/dev/null || echo dev)" +LDFLAGS="-X main.Version=${VERSION}" +log "building plexum-dialectic-plugin (v=${VERSION})" +CGO_ENABLED=0 go build -ldflags="${LDFLAGS}" \ + -o "${PLUGIN_DIR}/plexum-dialectic-plugin" \ + ./cmd/plexum-dialectic-plugin + +cp manifest.json "${PLUGIN_DIR}/manifest.json" +log "installed binary + manifest to ${PLUGIN_DIR}" + +cat <": "g1_yyy", ... }) + 3. Restart the host: systemctl --user restart plexum +EOF