build(plugin): isolate dist/, add install.mjs, untrack .js artifacts
Closes the long-standing 'jiti picks stale .js over updated .ts' trap
that silently shipped wrong code at least 5 times during sim e2e. Root
cause was three things compounding:
1. tsconfig.plugin.json had outDir=plugin (same as rootDir), so tsc
emitted compiled .js next to .ts sources. jiti's extension resolver
prefers .js, so the moment a .ts changed without a matching rebuild,
the .js won and the new code never ran.
2. The .js artifacts were tracked in git, so even on clean clones the
stale files came back.
3. There was no install script. Every deploy was an ad-hoc tar + rsync
that copied both .ts and .js to the same target dir, recreating the
race on the install side.
This commit fixes all three together:
- tsconfig.plugin.json: outDir=dist/dialectic, module/moduleResolution
flipped from ESNext/bundler to NodeNext/NodeNext (so emitted .js
works under plain Node ESM at runtime, the only thing jiti and the
openclaw gateway actually use).
- .gitignore: /dist/ + plugin/**/*.{js,mjs,cjs,js.map,d.ts}, then
git rm --cached the 4 existing tracked .js files.
- scripts/install.mjs (new, ESM): mirrors Fabric.OpenclawPlugin's
install.mjs pattern — detect, checkDeps, build (clean dist/ first),
install (copy dist/dialectic/ + openclaw.plugin.json + package.json
to ~/.openclaw/plugins/dialectic/), configure (plugins.allow,
plugins.load.paths, plugins.entries.<id>.enabled). Supports
--install / --build-only / --uninstall / --skip-check / --verbose /
--openclaw-profile-path / --backend-url.
Verified on sim dind-t2: install.mjs --install produces a plugin
directory with ONLY .js files (no .ts left behind), gateway loads it,
8 tools register cleanly, dialectic_list_topics + fabric-guild-list
work end-to-end.
Plugin is now fully ESM-clean: type=module + NodeNext + .js import
extensions + no bundler-only knobs. Matches the project-wide invariant
[[project-fabric-esm]] (every Fabric subproject is ESM).
This commit is contained in:
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* HTTP client for Dialectic backend (Go service).
|
||||
*
|
||||
* Auth: per-agent api key via `Authorization: Bearer <raw>`. 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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}` }] };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user