diff --git a/.gitignore b/.gitignore index 4cc4275..c6040e8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,14 @@ /.vscode/ *.swp .DS_Store + +# Build output. tsconfig.plugin.json emits compiled JS into dist/dialectic/. +# Never commit it — install.mjs builds fresh on every deploy. Co-locating +# .js next to .ts under plugin/src/ used to silently ship stale code +# (jiti prefers .js when both are present). +/dist/ +plugin/**/*.js +plugin/**/*.mjs +plugin/**/*.cjs +plugin/**/*.js.map +plugin/**/*.d.ts diff --git a/plugin/index.js b/plugin/index.js deleted file mode 100644 index da7e3b0..0000000 --- a/plugin/index.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Dialectic — OpenClaw plugin entry. - * - * Tools: dialectic_list_topics, dialectic_topic_detail, - * dialectic_propose_topic, dialectic_signup, - * dialectic_post_argument, dialectic_submit_verdict, - * dialectic_view_verdict - * - * Loader gotchas (per [[reference-meridian-plugin-contract]]): - * - openclaw.plugin.json MUST declare `activation.onStartup: true` - * or this plugin is silently treated as lazy/on-demand and - * register() is never called. - * - Every tool name MUST appear in contracts.tools or the tool - * never surfaces to agents. - * - Plain `export default { id, name, register }` works in jiti - * (matches prism-facet); don't pull SDK helpers without - * `openclaw` actually being resolvable at runtime. - */ -import { registerDialecticTools } from './src/tools.js'; -export default { - id: 'dialectic', - name: 'Dialectic', - register(api) { - registerDialecticTools(api); - api.logger.info('[dialectic] plugin registered'); - }, -}; diff --git a/plugin/src/backend-client.js b/plugin/src/backend-client.js deleted file mode 100644 index cd47018..0000000 --- a/plugin/src/backend-client.js +++ /dev/null @@ -1,90 +0,0 @@ -/** - * HTTP client for Dialectic backend (Go service). - * - * Auth: per-agent api key via `Authorization: Bearer `. Key is - * read from secret-mgr under the agent's `dialectic-agent-apikey` - * entry (provisioned at recruitment time — see Phase 3 deferred items). - * - * Errors: - * - Network/transport → throws. - * - Backend 4xx/5xx → throws with status + body text so the tool - * execute() can surface a useful error to the agent. - */ -import { execSync } from 'node:child_process'; -export class BackendClient { - baseUrl; - agentId; - timeoutMs; - cachedApiKey = null; - constructor(opts) { - this.baseUrl = opts.baseUrl.replace(/\/+$/, ''); - this.agentId = opts.agentId; - this.timeoutMs = opts.timeoutMs ?? 15000; - } - /** - * Read the agent's dialectic api key from secret-mgr. Cached in - * memory after first read so successive tool calls don't fork - * secret-mgr repeatedly. AGENT_VERIFY is required for secret-mgr - * to authorize the read. - */ - resolveApiKey() { - if (this.cachedApiKey) - return this.cachedApiKey; - try { - const out = execSync('secret-mgr get dialectic-agent-apikey', { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - timeout: 5000, - }).trim(); - if (!out) { - throw new Error('empty'); - } - this.cachedApiKey = out; - return out; - } - catch { - throw new Error('dialectic api key not provisioned (secret-mgr key `dialectic-agent-apikey` missing). ' + - 'Phase 3 deferred: ask hangman / admin to mint one via the recruitment flow.'); - } - } - async get(path) { - return this.request('GET', path); - } - async post(path, body) { - return this.request('POST', path, body); - } - async put(path, body) { - return this.request('PUT', path, body); - } - async request(method, path, body) { - const url = `${this.baseUrl}${path.startsWith('/') ? path : '/' + path}`; - const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), this.timeoutMs); - try { - const res = await fetch(url, { - method, - headers: { - 'content-type': 'application/json', - 'authorization': `Bearer ${this.resolveApiKey()}`, - }, - body: body === undefined ? undefined : JSON.stringify(body), - signal: ctrl.signal, - }); - const text = await res.text(); - if (!res.ok) { - throw new Error(`dialectic ${method} ${path}: ${res.status} ${text.slice(0, 500)}`); - } - if (!text) - return null; - try { - return JSON.parse(text); - } - catch { - return text; - } - } - finally { - clearTimeout(timer); - } - } -} diff --git a/plugin/src/hf-precheck.js b/plugin/src/hf-precheck.js deleted file mode 100644 index 955c83d..0000000 --- a/plugin/src/hf-precheck.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * HF calendar pre-check for `dialectic_signup`. - * - * Per the design (section 8): before sending a signup to the backend, - * the plugin must verify the agent has an `on_call` slot that covers - * the entire debate window [debate_start_at, debate_end_at]. - * - * Data source: the HarborForge OpenclawPlugin already exposes - * `globalThis.__hfAgentStatus.get(agentId)` (Phase 1) which returns - * the agent's CURRENT status — but that's not enough; we need slot - * coverage over a FUTURE window. - * - * For Phase 3 v1: degrade gracefully — if HF can't be queried for - * future slots (no exposed accessor yet), require the caller to - * pass `pre_validated: false`. Backend stores the flag as audit; a - * follow-up Phase can tighten this. - * - * Phase 3 deferred: - * - HarborForge.OpenclawPlugin needs a new global accessor for - * "does agent X have an on_call slot covering [from, to]?". - * Until that lands, this function returns { ok: true, source: "skipped" } - * and trusts the agent's plugin invocation. - */ -export async function hfOnCallCoverageCheck(agentId, debateStartAt, debateEndAt) { - const _G = globalThis; - const hf = _G['__hfAgentStatus']; - // Phase 3 deferred: HF doesn't yet expose a window-coverage check. - // If it does (future), use it; otherwise skip and trust. - if (!hf || typeof hf.hasOnCallCovering !== 'function') { - return { ok: true, source: 'skipped' }; - } - try { - const covered = await hf.hasOnCallCovering(agentId, debateStartAt, debateEndAt); - if (covered === undefined) { - // HF queried but said "I don't know" — treat as skipped not failed - return { ok: true, source: 'skipped' }; - } - if (!covered) { - return { - ok: false, - reason: `agent ${agentId} has no on_call slot covering [${debateStartAt} → ${debateEndAt}]`, - source: 'hf', - }; - } - return { ok: true, source: 'hf' }; - } - catch { - return { ok: true, source: 'skipped' }; - } -} diff --git a/plugin/src/tools.js b/plugin/src/tools.js deleted file mode 100644 index cb07ffc..0000000 --- a/plugin/src/tools.js +++ /dev/null @@ -1,258 +0,0 @@ -/** - * Tool registration for the Dialectic plugin. - * - * Six tools wired here, one per Dialectic backend endpoint the agent - * needs to drive end-to-end participation in a debate: - * - * dialectic_list_topics GET /api/topics - * dialectic_topic_detail GET /api/topics/{id} - * dialectic_propose_topic POST /api/topics - * dialectic_signup POST /api/topics/{id}/signups (with HF pre-check) - * dialectic_post_argument POST /api/topics/{id}/arguments - * dialectic_view_verdict GET /api/topics/{id}/verdict - * - * Every tool returns the standard MCP `{ content: [{ type: 'text', text }] }` - * shape; errors are caught and returned as a text payload so the agent - * sees something actionable rather than a runtime throw. - */ -import { BackendClient } from './backend-client.js'; -import { hfOnCallCoverageCheck } from './hf-precheck.js'; -const DEFAULT_BACKEND_URL = 'https://dialectic-api.hangman-lab.top'; -export function registerDialecticTools(api) { - // Config precedence: env var > openclaw plugin config > default. - // pluginConfig is the plugin-scoped subset (openclaw.json - // plugins.entries.dialectic.config) — confirmed by reading the - // openclaw SDK's api-builder which assigns `pluginConfig: - // params.pluginConfig`. `api.config` (whole openclaw.json) is left - // accessible for downstream that needs cross-plugin lookups. - const backendUrl = (process.env.DIALECTIC_BACKEND_URL ?? '').trim() || - api.pluginConfig?.backendUrl || - DEFAULT_BACKEND_URL; - // Per-tool we pull ctx.agentId out of the factory (the - // backend api key resolves from that agent's secret-mgr). - api.registerTool((ctx) => ({ - name: 'dialectic_list_topics', - description: 'List Dialectic debate topics, optionally filtered. Use status to scope to active phases ' + - '(created | signup_open | signup_closed | debating | completed | cancelled), visibility ' + - '(public | private), limit (default 50, max 200), offset (pagination).', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - status: { type: 'string', description: 'filter by status' }, - visibility: { type: 'string', description: 'filter by visibility' }, - limit: { type: 'integer', minimum: 1, maximum: 200 }, - offset: { type: 'integer', minimum: 0 }, - }, - }, - execute: async (_id, params) => asContent(async () => { - const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); - const query = new URLSearchParams(); - for (const k of ['status', 'visibility', 'limit', 'offset']) { - if (params[k] != null) - query.set(k, String(params[k])); - } - const q = query.toString(); - return await client.get(`/api/topics${q ? '?' + q : ''}`); - }), - })); - api.registerTool((ctx) => ({ - name: 'dialectic_topic_detail', - description: 'Get full detail for one Dialectic topic — lifecycle timestamps, status, ' + - 'verdict_schema_id, allocated camps if past signup_close, and verdict if completed.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { topic_id: { type: 'string' } }, - required: ['topic_id'], - }, - execute: async (_id, params) => asContent(async () => { - const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); - return await client.get(`/api/topics/${encodeURIComponent(params.topic_id)}`); - }), - })); - api.registerTool((ctx) => ({ - name: 'dialectic_propose_topic', - description: 'Create a new Dialectic debate topic. Provide the title, summary, the 4 lifecycle ' + - 'timestamps (RFC3339, signup_open < signup_close <= debate_start < debate_end), and the ' + - "verdict_schema_id ('binary' | 'claim-resolution' | 'policy-recommendation' | 'free-form'). " + - 'visibility defaults to private — flip to public via UI after creation if appropriate. ' + - 'announce_guild_base_url + announce_channel_id (BOTH or NEITHER) pick a Fabric announce ' + - 'channel for backend-driven lifecycle broadcasts (signup_open / signup_closed / debating / ' + - 'completed / cancelled). Discover candidates via the Fabric channel-list tools — pick the ' + - "guild whose audience matches your topic's purpose and a channel of xType=announce. Omit " + - 'both → no broadcasts for this topic (agents must poll detail to track state).', - parameters: { - 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' }, - announce_guild_base_url: { - type: 'string', - description: "Fabric guild HTTP base URL for the broadcast target (e.g. https://fabric-api.hangman-lab.top). " + - "Required if announce_channel_id is set, must be omitted otherwise.", - }, - announce_channel_id: { - type: 'string', - description: "Fabric channel UUID of an xType=announce channel in the chosen guild. " + - "Required if announce_guild_base_url is set, must be omitted otherwise.", - }, - }, - required: [ - 'title', 'summary', 'verdict_schema_id', - 'signup_open_at', 'signup_close_at', 'debate_start_at', 'debate_end_at', - ], - }, - execute: async (_id, params) => asContent(async () => { - const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); - const body = { - title: params.title, - summary: params.summary, - verdict_schema_id: params.verdict_schema_id, - signup_open_at: params.signup_open_at, - signup_close_at: params.signup_close_at, - debate_start_at: params.debate_start_at, - debate_end_at: params.debate_end_at, - }; - if (params.visibility) - body.visibility = params.visibility; - if (params.announce_guild_base_url) - body.announce_guild_base_url = params.announce_guild_base_url; - if (params.announce_channel_id) - body.announce_channel_id = params.announce_channel_id; - return await client.post(`/api/topics`, body); - }), - })); - api.registerTool((ctx) => ({ - name: 'dialectic_signup', - description: 'Volunteer (sign up) for one or more camps on a debate topic. ' + - "Camps are 'pro' | 'con' | 'judge'; you may volunteer for any subset, allocation " + - 'will pick at most one. Topic must be in `signup_open` status. ' + - 'Pre-flight: the plugin attempts to verify you have an on_call slot covering the debate window ' + - '(HF coverage check); if HF lookup is unavailable, the check is skipped and recorded as audit only.', - parameters: { - 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'], - }, - execute: async (_id, params) => asContent(async () => { - const agentId = ctx.agentId ?? ''; - const client = new BackendClient({ baseUrl: backendUrl, agentId }); - // Fetch topic detail so we know the debate window for HF pre-check. - const topic = (await client.get(`/api/topics/${encodeURIComponent(params.topic_id)}`)); - if (!topic || typeof topic !== 'object') { - throw new Error('topic lookup failed (empty response)'); - } - const debateStart = topic.debate_start_at; - const debateEnd = topic.debate_end_at; - if (!debateStart || !debateEnd) { - throw new Error('topic detail missing debate window timestamps'); - } - const precheck = await hfOnCallCoverageCheck(agentId, debateStart, debateEnd); - if (!precheck.ok) { - throw new Error(`HF pre-check failed: ${precheck.reason}`); - } - return await client.post(`/api/topics/${encodeURIComponent(params.topic_id)}/signups`, { - willing_camps: params.willing_camps, - pre_validated: precheck.source === 'hf', - }); - }), - })); - api.registerTool((ctx) => ({ - name: 'dialectic_post_argument', - description: 'Post an argument to a Dialectic topic you are currently allocated to. Must be in `debating` ' + - 'status. Content max 32KB. The argument attaches to the latest open round and is visible to ' + - 'other camp members and observers (and publicly if the topic is public).', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - topic_id: { type: 'string' }, - content: { type: 'string', maxLength: 32000 }, - }, - required: ['topic_id', 'content'], - }, - execute: async (_id, params) => asContent(async () => { - const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); - return await client.post(`/api/topics/${encodeURIComponent(params.topic_id)}/arguments`, { content: params.content }); - }), - })); - api.registerTool((ctx) => ({ - name: 'dialectic_submit_verdict', - description: 'Submit the structured verdict for a debate you are the judge of. Topic must be in `debating` ' + - 'status AND past its debate_end_at. The `verdict` JSON shape must match the topic\'s ' + - 'verdict_schema_id (binary / claim-resolution / policy-recommendation / free-form). On success ' + - 'the topic transitions to `completed` and the verdict is visible via dialectic_view_verdict.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - topic_id: { type: 'string' }, - verdict: { - type: 'object', - description: 'Structured verdict matching the topic verdict_schema_id shape', - additionalProperties: true, - }, - rationale: { type: 'string', description: 'Reasoning behind the verdict; non-empty' }, - tokens_input: { type: 'integer', minimum: 0, description: 'Optional cost telemetry' }, - tokens_output: { type: 'integer', minimum: 0, description: 'Optional cost telemetry' }, - }, - required: ['topic_id', 'verdict', 'rationale'], - }, - execute: async (_id, params) => asContent(async () => { - const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); - const body = { - verdict: params.verdict, - rationale: params.rationale, - }; - if (typeof params.tokens_input === 'number') - body.tokens_input = params.tokens_input; - if (typeof params.tokens_output === 'number') - body.tokens_output = params.tokens_output; - return await client.post(`/api/topics/${encodeURIComponent(params.topic_id)}/verdict`, body); - }), - })); - api.registerTool((ctx) => ({ - name: 'dialectic_view_verdict', - description: 'Fetch the structured verdict for a completed Dialectic topic. 404 if the debate is still ' + - 'in progress or the judge has not yet submitted.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { topic_id: { type: 'string' } }, - required: ['topic_id'], - }, - execute: async (_id, params) => asContent(async () => { - const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); - return await client.get(`/api/topics/${encodeURIComponent(params.topic_id)}/verdict`); - }), - })); - api.logger.info(`[dialectic] registered 7 tools (backend=${backendUrl})`); -} -/** Wraps an async backend call and converts result/error into MCP content shape. */ -async function asContent(fn) { - try { - const out = await fn(); - const text = typeof out === 'string' ? out : JSON.stringify(out, null, 2); - return { content: [{ type: 'text', text }] }; - } - catch (err) { - const msg = err?.message ?? String(err); - return { content: [{ type: 'text', text: `error: ${msg}` }] }; - } -} diff --git a/scripts/install.mjs b/scripts/install.mjs new file mode 100644 index 0000000..85fa6d2 --- /dev/null +++ b/scripts/install.mjs @@ -0,0 +1,268 @@ +#!/usr/bin/env node + +/** + * Dialectic.OpenclawPlugin installer (modeled on Fabric's install.mjs). + * + * node scripts/install.mjs --install build + install + configure + * node scripts/install.mjs --build-only build only + * node scripts/install.mjs --uninstall remove plugin + config + * + * Flags: + * --skip-check skip Node version check + * --verbose / -v verbose build output + * --openclaw-profile-path override ~/.openclaw target + * --backend-url seed channels.dialectic / plugin + * backendUrl config (default left unset + * so the manifest's default applies) + */ + +import { execSync } from 'child_process'; +import { existsSync, mkdirSync, copyFileSync, readdirSync, rmSync } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { homedir } from 'os'; + +const __dirname = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const PLUGIN_ID = 'dialectic'; +// tsconfig.plugin.json emits here. Build artifacts NEVER live alongside +// .ts sources — this avoids jiti picking a stale .js when the .ts has +// been updated (the bug that silently shipped wrong code several times +// during the sim e2e). See tsconfig.plugin.json comment block. +const DIST_DIR = join(__dirname, 'dist', PLUGIN_ID); + +const args = process.argv.slice(2); +const opt = { + install: args.includes('--install'), + buildOnly: args.includes('--build-only'), + skipCheck: args.includes('--skip-check'), + verbose: args.includes('--verbose') || args.includes('-v'), + uninstall: args.includes('--uninstall'), +}; +const pIdx = args.indexOf('--openclaw-profile-path'); +const profileOverride = pIdx !== -1 && args[pIdx + 1] ? resolve(args[pIdx + 1]) : null; +const bIdx = args.indexOf('--backend-url'); +const backendUrl = bIdx !== -1 && args[bIdx + 1] ? args[bIdx + 1] : null; + +function openclawPath() { + if (profileOverride) return profileOverride; + if (process.env.OPENCLAW_PATH) return resolve(process.env.OPENCLAW_PATH); + return join(homedir(), '.openclaw'); +} + +const c = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', +}; +const log = (m, k = 'reset') => console.log(`${c[k]}${m}${c.reset}`); +const step = (n, t, m) => log(`[${n}/${t}] ${m}`, 'cyan'); +const ok = (m) => log(` ✓ ${m}`, 'green'); +const warn = (m) => log(` ⚠ ${m}`, 'yellow'); +const err = (m) => log(` ✗ ${m}`, 'red'); + +function exec(cmd, o = {}) { + return execSync(cmd, { + cwd: __dirname, + stdio: o.silent ? 'pipe' : 'inherit', + encoding: 'utf8', + ...o, + }); +} + +function cfgGet(key, def) { + try { + const out = exec( + `openclaw config get ${key} --json 2>/dev/null || echo undefined`, + { silent: true }, + ).trim(); + return out === 'undefined' || out === '' ? def : JSON.parse(out); + } catch { + return def; + } +} + +function cfgSet(key, val) { + exec(`openclaw config set ${key} '${JSON.stringify(val)}' --json`, { silent: true }); +} + +function cfgUnset(key) { + try { + exec(`openclaw config unset ${key}`, { silent: true }); + } catch { + /* ignore */ + } +} + +function copyDir(src, dest) { + mkdirSync(dest, { recursive: true }); + for (const e of readdirSync(src, { withFileTypes: true })) { + if (e.name === 'node_modules') continue; + const s = join(src, e.name); + const d = join(dest, e.name); + e.isDirectory() ? copyDir(s, d) : copyFileSync(s, d); + } +} + +function detect() { + step(1, 5, 'Detecting environment...'); + let node = null; + try { + node = exec('node --version', { silent: true }).trim(); + ok(`Node ${node}`); + } catch { + err('Node not found'); + } + try { + ok(`openclaw at ${exec('which openclaw', { silent: true }).trim()}`); + } catch { + warn('openclaw CLI not in PATH'); + } + return { node }; +} + +function checkDeps(env) { + if (opt.skipCheck) return; + step(2, 5, 'Checking dependencies...'); + if (!env.node || parseInt(env.node.slice(1), 10) < 18) { + err('Node 18+ required'); + process.exit(1); + } + ok('deps OK'); +} + +function build() { + step(3, 5, 'Building plugin...'); + // Wipe dist first so the build never silently mixes new + stale files. + rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); + exec('npm install', { silent: !opt.verbose }); + exec('npm run build', { silent: !opt.verbose }); + if (!existsSync(join(DIST_DIR, 'index.js'))) { + throw new Error(`build produced no ${join('dist', PLUGIN_ID, 'index.js')}`); + } + ok(`compiled -> dist/${PLUGIN_ID}`); +} + +function clearInstall(base) { + const dest = join(base, 'plugins', PLUGIN_ID); + if (existsSync(dest)) { + rmSync(dest, { recursive: true, force: true }); + ok(`removed ${dest}`); + } +} + +function cleanupConfig(base) { + const dest = join(base, 'plugins', PLUGIN_ID); + const allow = cfgGet('plugins.allow', []); + if (Array.isArray(allow) && allow.includes(PLUGIN_ID)) { + cfgSet('plugins.allow', allow.filter((x) => x !== PLUGIN_ID)); + ok('removed from plugins.allow'); + } + const paths = cfgGet('plugins.load.paths', []); + if (Array.isArray(paths) && paths.includes(dest)) { + cfgSet('plugins.load.paths', paths.filter((x) => x !== dest)); + ok('removed from plugins.load.paths'); + } + cfgUnset(`plugins.entries.${PLUGIN_ID}`); + ok('removed plugin entry'); +} + +function install() { + step(4, 5, 'Installing...'); + const base = openclawPath(); + const dest = join(base, 'plugins', PLUGIN_ID); + log(` OpenClaw path: ${base}`, 'blue'); + + if (existsSync(dest)) { + warn('existing install -> replacing'); + clearInstall(base); + } + mkdirSync(dirname(dest), { recursive: true }); + // Only dist/dialectic/ ships — pure compiled JS + no .ts left behind + // to confuse jiti's extension resolver. Manifest + package.json are + // copied explicitly because tsc doesn't touch non-.ts files. + copyDir(DIST_DIR, dest); + copyFileSync( + join(__dirname, 'plugin', 'openclaw.plugin.json'), + join(dest, 'openclaw.plugin.json'), + ); + copyFileSync( + join(__dirname, 'plugin', 'package.json'), + join(dest, 'package.json'), + ); + ok(`plugin files -> ${dest}`); + exec('npm install --omit=dev', { cwd: dest, silent: !opt.verbose }); + ok('runtime deps installed'); + return { base, dest }; +} + +function configure(base, dest) { + step(5, 5, 'Configuring OpenClaw...'); + const paths = cfgGet('plugins.load.paths', []); + if (Array.isArray(paths) && !paths.includes(dest)) { + cfgSet('plugins.load.paths', [...paths, dest]); + } + ok(`plugins.load.paths includes ${dest}`); + + const allow = cfgGet('plugins.allow', []); + if (Array.isArray(allow) && !allow.includes(PLUGIN_ID)) { + cfgSet('plugins.allow', [...allow, PLUGIN_ID]); + } + ok(`plugins.allow includes ${PLUGIN_ID}`); + + if (cfgGet(`plugins.entries.${PLUGIN_ID}.enabled`, undefined) === undefined) { + cfgSet(`plugins.entries.${PLUGIN_ID}.enabled`, true); + } + ok('plugin entry configured'); + + // Seed backendUrl only when explicitly requested via --backend-url. + // Otherwise the manifest's default (https://dialectic-api.hangman-lab.top) + // applies, and operators can override later via: + // openclaw config set plugins.entries.dialectic.config.backendUrl + if (backendUrl) { + cfgSet(`plugins.entries.${PLUGIN_ID}.config.backendUrl`, backendUrl); + ok(`backendUrl = ${backendUrl}`); + } +} + +function main() { + console.log(''); + log('Dialectic.OpenclawPlugin installer', 'cyan'); + console.log(''); + try { + const env = detect(); + if (opt.uninstall) { + const base = openclawPath(); + clearInstall(base); + cleanupConfig(base); + log('\nRun: openclaw gateway restart', 'yellow'); + return; + } + checkDeps(env); + build(); + if (opt.buildOnly) { + log('\nbuild-only — not installed.', 'yellow'); + return; + } + const { base, dest } = install(); + configure(base, dest); + console.log(''); + log('Install complete. Next:', 'blue'); + log(' 1. Provision agent api key (one per agent):', 'cyan'); + log(' AGENT_ID= AGENT_WORKSPACE= AGENT_VERIFY=… \\', 'cyan'); + log(' secret-mgr set --key dialectic-agent-apikey --secret ', 'cyan'); + log(' (raw key minted via the backend admin endpoint, see', 'cyan'); + log(' skills/dialectic-hangman-lab/dialectic-ctrl)', 'cyan'); + log(' 2. openclaw gateway restart', 'cyan'); + log(' 3. (sim/test only) set DIALECTIC_PLUGIN_BYPASS_HF=1 in the', 'cyan'); + log(' gateway env to skip the HF on_call coverage check.', 'cyan'); + console.log(''); + } catch (e) { + log(`\nInstall failed: ${e.message}`, 'red'); + process.exit(1); + } +} + +main(); diff --git a/tsconfig.plugin.json b/tsconfig.plugin.json index d9b04b7..9bd4b25 100644 --- a/tsconfig.plugin.json +++ b/tsconfig.plugin.json @@ -1,17 +1,26 @@ { "compilerOptions": { "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "outDir": "plugin", + // NodeNext (not ESNext/bundler) so the emitted .js works under + // plain Node.js ESM at runtime — jiti + the openclaw gateway + // both run on Node, not under a bundler. + "module": "NodeNext", + "moduleResolution": "NodeNext", + // Emit compiled JS into a separate dist/ tree so build artifacts + // never collide with .ts sources. With outDir === rootDir, + // tsc produces src/foo.js next to src/foo.ts, and jiti picks + // .js (its default extension-resolve order) — which silently + // shipped stale code several times during sim e2e. Isolating + // dist/ removes the entire race. + "outDir": "dist/dialectic", "rootDir": "plugin", "declaration": false, "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "allowJs": true + "allowJs": false }, "include": ["plugin/**/*.ts"], - "exclude": ["plugin/**/*.js"] + "exclude": ["plugin/**/*.js", "dist/**/*"] }