Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 9 additions & 164 deletions bridges/kimaki/plugins/dm-agent-sync.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// dm-agent-sync.ts — OpenCode plugin that syncs Data Machine agents into
// OpenCode's agent switcher.
// dm-agent-sync.ts — OpenCode plugin that refreshes Data Machine memory.
//
// On session start, queries Data Machine for all registered agents and their
// file paths, then registers each as an OpenCode agent with the correct
// identity files (SOUL.md, MEMORY.md, USER.md, SITE.md) and AGENTS.md.
// On session start, asks Data Machine to recompose its memory files so
// OpenCode's top-level `instructions` and AGENTS.md auto-discovery read fresh
// content. Identity stays owned by Data Machine/channel routing; this plugin
// must not set OpenCode `agent.*.prompt` fields or register Data Machine agents
// into OpenCode's agent switcher.
//
// This gives every Data Machine agent its own identity in the agent switcher
// without manual opencode.json maintenance.
// The filename is kept for upgrade compatibility with existing opencode.json
// plugin arrays that reference dm-agent-sync.ts.
//
// How to use:
// Add to opencode.json: "plugin": ["path/to/dm-agent-sync.ts"]
Expand All @@ -17,32 +18,9 @@
*/
import type { Plugin } from "@opencode-ai/plugin";

interface DmAgent {
agent_id: number;
agent_slug: string;
agent_name: string;
owner_id: number;
status?: string;
agent_config?: {
default_model?: string;
tool_policy?: Record<string, boolean>;
model?: {
default?: {
provider?: string;
model?: string;
};
};
};
}

interface DmPaths {
agent_slug: string;
relative_files: string[];
}

const dmAgentSync: Plugin = async ({ $ }) => {
return {
config: async (config) => {
config: async () => {
const sitePath = getSitePath();
const wpAvailable = await $`command -v wp`.quiet().nothrow();
if (wpAvailable.exitCode !== 0) {
Expand All @@ -62,109 +40,6 @@ const dmAgentSync: Plugin = async ({ $ }) => {
if (composeResult.exitCode !== 0) {
console.warn(`[dm-agent-sync] memory compose failed (exit ${composeResult.exitCode}): ${await shellOutputText(composeResult)}`);
}

// Query all agents from Data Machine.
const agentsResult = sitePath
? await $`wp --path=${sitePath} datamachine agents list --format=json --allow-root`.quiet().nothrow()
: await $`wp datamachine agents list --format=json --allow-root`.quiet().nothrow();
if (agentsResult.exitCode !== 0) {
console.warn(`[dm-agent-sync] agents list failed (exit ${agentsResult.exitCode}): ${await shellOutputText(agentsResult)}`);
return;
}

const agentsRaw = await shellOutputText(agentsResult);
const jsonMatch = agentsRaw.match(/\[[\s\S]*\]/);
if (!jsonMatch) {
console.warn("[dm-agent-sync] agents list did not contain a JSON array");
return;
}

let agents: DmAgent[];
try {
agents = JSON.parse(jsonMatch[0]);
} catch (error) {
console.warn(`[dm-agent-sync] agents list returned invalid JSON: ${String(error)}`);
return;
}

const entries = [];
for (const agent of agents) {
const status = agent.status || "active";
if (status !== "active") {
continue;
}

const pathsResult = sitePath
? await $`wp --path=${sitePath} datamachine memory paths --agent=${agent.agent_slug} --format=json --allow-root`.quiet().nothrow()
: await $`wp datamachine memory paths --agent=${agent.agent_slug} --format=json --allow-root`.quiet().nothrow();
if (pathsResult.exitCode !== 0) {
console.warn(`[dm-agent-sync] memory paths failed for ${agent.agent_slug} (exit ${pathsResult.exitCode}): ${await shellOutputText(pathsResult)}`);
continue;
}

let paths: DmPaths;
try {
paths = JSON.parse(await shellOutputText(pathsResult));
} catch (error) {
console.warn(`[dm-agent-sync] memory paths returned invalid JSON for ${agent.agent_slug}: ${String(error)}`);
continue;
}

if (!paths?.relative_files?.length) {
console.warn(`[dm-agent-sync] memory paths returned no files for ${agent.agent_slug}`);
continue;
}

const promptRoot = sitePath || ".";
const prompt = [
`{file:${promptRoot}/AGENTS.md}`,
...paths.relative_files.map((f: string) => `{file:${promptRoot}/${f}}`),
].join("\n");

const agentModel =
agent.agent_config?.default_model ||
(agent.agent_config?.model?.default
? `${agent.agent_config.model.default.provider}/${agent.agent_config.model.default.model}`
: undefined);
const tools = agent.agent_config?.tool_policy;
const entry: Record<string, unknown> = {
prompt,
mode: "primary" as const,
};
if (agentModel) {
entry.model = agentModel;
}
if (tools) {
entry.tools = tools;
}

entries.push({ agent, entry, prompt });
}

if (!entries.length) {
console.warn(`[dm-agent-sync] no active Data Machine agents with usable memory paths found (${agents.length} listed)`);
return;
}

if (!config.agent) {
config.agent = {};
}

const primary = entries[0];
syncDefaultSlot(config.agent, "build", primary.entry);
syncDefaultSlot(config.agent, "plan", primary.entry);

for (const { agent, entry } of entries) {
const agentSlug = agent.agent_slug;
if (!config.agent[agentSlug]) {
config.agent[agentSlug] = {
...entry,
description: `Data Machine agent: ${agent.agent_name}`,
};
}
}

console.warn(`[dm-agent-sync] registered ${entries.length} Data Machine agent(s); build/plan prompt uses ${primary.agent.agent_slug}`);
},
};
};
Expand All @@ -173,36 +48,6 @@ function getSitePath(): string {
return process.env.DATAMACHINE_SITE_PATH || process.env.SITE_PATH || process.env.PWD || "";
}

/**
* Populate build/plan defaults without clobbering user-authored fields.
*
* @param {Record<string, unknown>} agentConfig - OpenCode agent config object.
* @param {"build"|"plan"} slot - Default slot to synchronize.
* @param {Record<string, unknown>} managedEntry - Data Machine-managed entry.
*/
function syncDefaultSlot(
agentConfig: Record<string, unknown>,
slot: "build" | "plan",
managedEntry: Record<string, unknown>
): void {
const existing = agentConfig[slot];
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
agentConfig[slot] = { ...managedEntry };
return;
}

const existingEntry = existing as Record<string, unknown>;
if (typeof existingEntry.prompt === "string" && existingEntry.prompt.length > 0) {
return;
}

agentConfig[slot] = {
...managedEntry,
...existingEntry,
prompt: managedEntry.prompt,
};
}

async function shellOutputText(output: any): Promise<string> {
if (typeof output.text === "function") {
return output.text();
Expand Down
4 changes: 2 additions & 2 deletions lib/repair-opencode-json.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ def expected_plugins(
# "drift" comparisons on those runtimes are no-ops.
return plugins

# DM context filter + agent sync: only when the bridge is Kimaki, since
# these plugins rewrite Kimaki-specific prompts. wp-coding-agents does
# DM context filter + memory compose: only when the bridge is Kimaki, since
# these plugins manage Kimaki/OpenCode prompt hygiene. wp-coding-agents does
# not manage opencode-claude-auth on any bridge — Kimaki ships its own
# AnthropicAuthPlugin, and non-kimaki bridges use opencode's native auth
# flow. See wp-coding-agents#117.
Expand Down
2 changes: 2 additions & 0 deletions runtimes/opencode.sh
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ runtime_generate_config() {

# OpenCode plugins. wp-coding-agents only manages plugins it owns end to
# end: dm-context-filter.ts and dm-agent-sync.ts on Kimaki bridges. The
# sync plugin is intentionally compose-only; Data Machine/channel routing
# owns identity, not OpenCode agent slots.
# opencode-claude-auth plugin is intentionally NOT installed on any bridge:
# Kimaki ships a built-in AnthropicAuthPlugin and non-kimaki bridges use
# opencode's native auth flow (`opencode auth login anthropic`). See
Expand Down
33 changes: 12 additions & 21 deletions tests/dm-agent-sync.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// tests/dm-agent-sync.mjs — unit smoke tests for the Kimaki DM agent sync plugin.
// tests/dm-agent-sync.mjs — unit smoke tests for the Kimaki DM memory plugin.

import assert from "node:assert/strict"
import dmAgentSync from "../bridges/kimaki/plugins/dm-agent-sync.ts"
Expand Down Expand Up @@ -49,17 +49,9 @@ async function runConfig(config, responses) {
return warnings
}

const agentsJson = JSON.stringify([
{ agent_id: 1, agent_slug: "franklin", agent_name: "Franklin", owner_id: 1, status: "active" },
{ agent_id: 2, agent_slug: "julia", agent_name: "Julia", owner_id: 1, status: "active" },
])

const commonResponses = [
[/^command -v wp$/, output("/usr/local/bin/wp")],
[/^wp --path=\/tmp\/datamachine-site datamachine memory compose/, output("composed")],
[/^wp --path=\/tmp\/datamachine-site datamachine agents list/, output(`${agentsJson}\nTotal: 2 agent(s).`)],
[/--agent=franklin /, output(JSON.stringify({ agent_slug: "franklin", relative_files: ["SITE.md", "SOUL.md"] }))],
[/--agent=julia /, output(JSON.stringify({ agent_slug: "julia", relative_files: ["SITE.md", "MEMORY.md"] }))],
]

{
Expand All @@ -71,12 +63,12 @@ const commonResponses = [
},
}
const warnings = await runConfig(config, commonResponses)
assert.match(config.agent.build.prompt, /\{file:\/tmp\/datamachine-site\/AGENTS\.md\}/)
assert.match(config.agent.plan.prompt, /\{file:\/tmp\/datamachine-site\/SOUL\.md\}/)
assert.equal(config.agent.build.prompt, undefined)
assert.equal(config.agent.plan.prompt, undefined)
assert.equal(config.agent.build.model, "anthropic/claude-opus-4-7")
assert.match(config.agent.franklin.prompt, /\{file:\/tmp\/datamachine-site\/SITE\.md\}/)
assert.match(config.agent.julia.description, /Data Machine agent: Julia/)
assert.ok(warnings.some((line) => line.includes("registered 2 Data Machine agent(s)")))
assert.equal(config.agent.franklin, undefined)
assert.equal(config.agent.julia, undefined)
assert.deepEqual(warnings, [])
}

{
Expand All @@ -88,8 +80,8 @@ const commonResponses = [
}
await runConfig(config, commonResponses)
assert.deepEqual(config.agent.build.tools, { bash: true })
assert.match(config.agent.build.prompt, /\{file:\/tmp\/datamachine-site\/SOUL\.md\}/)
assert.match(config.agent.plan.prompt, /\{file:\/tmp\/datamachine-site\/SOUL\.md\}/)
assert.equal(config.agent.build.prompt, undefined)
assert.equal(config.agent.plan.prompt, undefined)
}

{
Expand All @@ -100,18 +92,17 @@ const commonResponses = [
}
await runConfig(config, commonResponses)
assert.equal(config.agent.build.prompt, "custom prompt")
assert.match(config.agent.plan.prompt, /\{file:\/tmp\/datamachine-site\/SOUL\.md\}/)
assert.equal(config.agent.plan, undefined)
}

{
const config = {}
const warnings = await runConfig(config, [
[/^command -v wp$/, output("/usr/local/bin/wp")],
[/^wp --path=\/tmp\/datamachine-site datamachine memory compose/, output("composed")],
[/^wp --path=\/tmp\/datamachine-site datamachine agents list/, output("", 1, "db down")],
[/^wp --path=\/tmp\/datamachine-site datamachine memory compose/, output("", 1, "db down")],
])
assert.ok(warnings.some((line) => line.includes("agents list failed")))
assert.ok(warnings.some((line) => line.includes("memory compose failed")))
assert.equal(config.agent, undefined)
}

console.log("OK: dm-agent-sync injects DM prompts and logs failures")
console.log("OK: dm-agent-sync refreshes memory without injecting agent prompts")
2 changes: 1 addition & 1 deletion upgrade.sh
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ check_opencode_json_drift() {

# Runs whenever opencode.json exists on disk. Default behaviour is
# additive repair: managed plugin entries the user is missing get added
# (dm-context-filter.ts and dm-agent-sync.ts on Kimaki bridges), and
# (dm-context-filter.ts and compose-only dm-agent-sync.ts on Kimaki bridges), and
# legacy agent.build.prompt / agent.plan.prompt get migrated to a
# top-level `instructions` array (fixes Anthropic Claude Max OAuth,
# wp-coding-agents#60).
Expand Down
Loading