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
51 changes: 37 additions & 14 deletions bridges/kimaki.sh
Original file line number Diff line number Diff line change
Expand Up @@ -811,9 +811,6 @@ _kimaki_install_systemd() {
NODE_BIN_DIR=$(_resolve_node_bin_dir "$KIMAKI_BIN")
PATH_VALUE=$(_compose_path_value "$KIMAKI_BIN_DIR" "$NODE_BIN_DIR" /usr/local/bin /usr/bin /bin)
_kimaki_assert_bin_identity "$KIMAKI_BIN" "$PATH_VALUE"
local DATAMACHINE_WP_CMD
DATAMACHINE_WP_CMD=$(_kimaki_datamachine_wp_cmd)

# Kimaki recreates a general-purpose #kimaki-<bot> channel, welcome message,
# and tutorial thread on every start. On a wp-coding-agents install the real
# project channel is the site channel, so the default is pure noise. Upstream
Expand All @@ -824,7 +821,7 @@ _kimaki_install_systemd() {
Environment=PATH=$PATH_VALUE
Environment=KIMAKI_DATA_DIR=$KIMAKI_DATA_DIR
Environment=DATAMACHINE_SITE_PATH=$SITE_PATH
Environment=DATAMACHINE_WP_CMD=$DATAMACHINE_WP_CMD
$(_kimaki_datamachine_wp_transport_systemd_env)
Environment=KIMAKI_NO_DEFAULT_CHANNEL=1"
if [ -n "${AGENT_SLUG:-}" ]; then
ENV_BLOCK="$ENV_BLOCK
Expand Down Expand Up @@ -1150,6 +1147,8 @@ bridge_update_systemd() {

local CURRENT_ENV
CURRENT_ENV=$(grep '^Environment=' "$UNIT_FILE" || true)
CURRENT_ENV=$(_kimaki_remove_systemd_env_key "$CURRENT_ENV" DATAMACHINE_WP_CMD)
CURRENT_ENV=$(_kimaki_remove_systemd_env_key "$CURRENT_ENV" DATAMACHINE_WP_TRANSPORT_JSON)
if [ "${KIMAKI_DATA_DIR_EXPLICIT:-false}" = true ]; then
CURRENT_ENV=$(_kimaki_remove_systemd_env_key "$CURRENT_ENV" KIMAKI_DATA_DIR)
fi
Expand Down Expand Up @@ -1177,7 +1176,7 @@ bridge_update_systemd() {
Environment=PATH=$PATH_VALUE
Environment=KIMAKI_DATA_DIR=$KIMAKI_DATA_DIR
Environment=DATAMACHINE_SITE_PATH=$SITE_PATH
Environment=DATAMACHINE_WP_CMD=$(_kimaki_datamachine_wp_cmd)
$(_kimaki_datamachine_wp_transport_systemd_env)
Environment=KIMAKI_NO_DEFAULT_CHANNEL=1"
if [ -n "${KIMAKI_LOCK_PORT:-}" ]; then
TEMPLATE_ENV="$TEMPLATE_ENV
Expand Down Expand Up @@ -1323,8 +1322,8 @@ bridge_render_launchd() {
skill_filter_plist_args="$(_kimaki_skill_filter_args_plist)"
local launchd_start
launchd_start="${KIMAKI_DATA_DIR}/kimaki-config/launchd-start.sh"
local datamachine_wp_cmd
datamachine_wp_cmd=$(_kimaki_datamachine_wp_cmd)
local datamachine_wp_transport_json
datamachine_wp_transport_json=$(_kimaki_xml_text "$(_kimaki_datamachine_wp_transport_json)")
cat <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
Expand Down Expand Up @@ -1360,8 +1359,8 @@ $skill_filter_plist_args
<string>${KIMAKI_DATA_DIR}/kimaki-config</string>
<key>DATAMACHINE_SITE_PATH</key>
<string>$SITE_PATH</string>
<key>DATAMACHINE_WP_CMD</key>
<string>$datamachine_wp_cmd</string>
<key>DATAMACHINE_WP_TRANSPORT_JSON</key>
<string>$datamachine_wp_transport_json</string>
<key>KIMAKI_NO_DEFAULT_CHANNEL</key>
<string>1</string>$(if [ -n "${AGENT_SLUG:-}" ]; then echo "
<key>DATAMACHINE_AGENT_SLUG</key>
Expand All @@ -1374,14 +1373,38 @@ $skill_filter_plist_args
EOF
}

_kimaki_datamachine_wp_cmd() {
_kimaki_datamachine_wp_transport_json() {
if [ "${EXTERNAL_WORDPRESS:-false}" = true ]; then
external_wordpress_control_command
python3 - "$(external_wordpress_control_command)" <<'PY'
import json, sys
print(json.dumps(sys.argv[1:], separators=(",", ":")))
PY
return 0
fi
wp_cli_transport_ensure
wp_cli_transport_display
printf '\n'
wp_cli_transport_json
}

_kimaki_datamachine_wp_transport_systemd_env() {
local value escaped
value=$(_kimaki_datamachine_wp_transport_json)
case "$value" in
*[[:space:]]*)
escaped=${value//\\/\\\\}
escaped=${escaped//\"/\\\"}
printf 'Environment=DATAMACHINE_WP_TRANSPORT_JSON="%s"' "$escaped"
;;
*)
printf 'Environment=DATAMACHINE_WP_TRANSPORT_JSON=%s' "$value"
;;
esac
}

_kimaki_xml_text() {
local value="$1"
value=${value//&/\&amp;}
value=${value//</\&lt;}
value=${value//>/\&gt;}
printf '%s' "$value"
}

_kimaki_skill_filter_mode() {
Expand Down
53 changes: 41 additions & 12 deletions bridges/kimaki/plugins/dm-agent-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { spawn } from "node:child_process";
import type { Plugin } from "@opencode-ai/plugin";

type WpCli = string;
type WpCli = string[];

const DEFAULT_COMPOSE_TIMEOUT_MS = 10_000;
const OUTPUT_LIMIT = 16 * 1024;
Expand All @@ -25,8 +25,12 @@ const dmAgentSync: Plugin = async () => {

// Config runs for every CLI command. Capture only local state here;
// invoking WordPress belongs to the real chat lifecycle below.
const wpCli = resolveWpCliTransport();
if (!wpCli) {
return;
}
sessionConfig = {
wpCli: process.env.DATAMACHINE_WP_CMD || process.env.WP_CMD || "wp",
wpCli,
sitePath: getSitePath(),
agentSlug: getAgentSlug(input),
};
Expand All @@ -48,7 +52,7 @@ const dmAgentSync: Plugin = async () => {

async function composeMemory(wpCli: WpCli, sitePath: string, agentSlug: string): Promise<void> {
const startedAt = Date.now();
const result = await runBoundedCommand(datamachineCommand(wpCli, sitePath, agentSlug), getComposeTimeoutMs());
const result = await runBoundedCommand(datamachineArgv(wpCli, sitePath, agentSlug), getComposeTimeoutMs());
const durationMs = Date.now() - startedAt;

if (result.timedOut) {
Expand All @@ -67,21 +71,22 @@ async function composeMemory(wpCli: WpCli, sitePath: string, agentSlug: string):
console.warn(`[dm-agent-sync] recomposed Data Machine memory in ${durationMs}ms`);
}

function datamachineCommand(wpCli: WpCli, sitePath: string, agentSlug: string): string {
const args = ["datamachine", "memory", "compose"];
function datamachineArgv(wpCli: WpCli, sitePath: string, agentSlug: string): string[] {
const args = [...wpCli, "datamachine", "memory", "compose"];
if (agentSlug) {
args.push(`--agent=${agentSlug}`);
}
if (sitePath) {
args.push(`--path=${sitePath}`);
}
args.push("--allow-root");
return [wpCli, ...args.map(shellQuote)].join(" ");
return args;
}

function runBoundedCommand(command: string, timeoutMs: number): Promise<{ exitCode: number; output: string; timedOut: boolean }> {
function runBoundedCommand(argv: string[], timeoutMs: number): Promise<{ exitCode: number; output: string; timedOut: boolean }> {
const [command, ...args] = argv;
return new Promise((resolve) => {
const child = spawn("sh", ["-c", command], { detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
const child = spawn(command, args, { detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
let output = "";
let settled = false;
const finish = (exitCode: number, timedOut: boolean) => {
Expand Down Expand Up @@ -120,6 +125,34 @@ function runBoundedCommand(command: string, timeoutMs: number): Promise<{ exitCo
});
}

function resolveWpCliTransport(): WpCli | undefined {
const json = process.env.DATAMACHINE_WP_TRANSPORT_JSON;
if (json) {
return parseTransportJson(json);
}
return parseShippedWpCmd(process.env.DATAMACHINE_WP_CMD || process.env.WP_CMD || "wp");
}

function parseTransportJson(raw: string): WpCli | undefined {
try {
const value: unknown = JSON.parse(raw);
if (!Array.isArray(value) || value.length === 0) {
return undefined;
}
if (value.some((item) => typeof item !== "string" || item.length === 0 || item.includes("\0"))) {
return undefined;
}
return value;
} catch {
return undefined;
}
}

function parseShippedWpCmd(raw: string): WpCli | undefined {
const parts = raw.trim().split(/[ \t]+/).filter((part) => part.length > 0);
return parts.length > 0 ? parts : undefined;
}

function getComposeTimeoutMs(): number {
const configured = Number(process.env.DATAMACHINE_COMPOSE_TIMEOUT_MS);
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_COMPOSE_TIMEOUT_MS;
Expand All @@ -145,8 +178,4 @@ function getAgentSlug(input: { instructions?: string[] }): string {
return "";
}

function shellQuote(value: string): string {
return `'${value.replace(/'/g, `"'"'`)}'`;
}

export default dmAgentSync;
4 changes: 2 additions & 2 deletions tests/__snapshots__/bridges/kimaki-launchd
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
<string>/home/chubes/.kimaki/kimaki-config</string>
<key>DATAMACHINE_SITE_PATH</key>
<string>/var/www/site</string>
<key>DATAMACHINE_WP_CMD</key>
<string>wp</string>
<key>DATAMACHINE_WP_TRANSPORT_JSON</key>
<string>["wp"]</string>
<key>KIMAKI_NO_DEFAULT_CHANNEL</key>
<string>1</string>
<key>DATAMACHINE_AGENT_SLUG</key>
Expand Down
2 changes: 1 addition & 1 deletion tests/__snapshots__/bridges/kimaki-systemd
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Environment=HOME=/home/chubes
Environment=PATH=/usr/bin:/usr/local/bin:/bin
Environment=KIMAKI_DATA_DIR=/home/chubes/.kimaki
Environment=DATAMACHINE_SITE_PATH=/var/www/site
Environment=DATAMACHINE_WP_CMD=wp
Environment=DATAMACHINE_WP_TRANSPORT_JSON="[\"wp\"]"
Environment=DATAMACHINE_AGENT_SLUG=intelligence-chubes4
# Reap stray opencode-serve children left behind by the previous kimaki
# process before starting a fresh one. Each kimaki session spawns its own
Expand Down
6 changes: 5 additions & 1 deletion tests/bridge-render.sh
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,15 @@ kimaki_env_block() {
kimaki_bin_dir=$(dirname "$KIMAKI_BIN")
node_bin_dir=$(_resolve_node_bin_dir "$KIMAKI_BIN")
path_value=$(_compose_path_value "$kimaki_bin_dir" "$node_bin_dir" /usr/local/bin /usr/bin /bin)
local transport_json
transport_json=$(wp_cli_transport_json)
transport_json=${transport_json//\\/\\\\}
transport_json=${transport_json//\"/\\\"}
local out="Environment=HOME=$SERVICE_HOME
Environment=PATH=$path_value
Environment=KIMAKI_DATA_DIR=$KIMAKI_DATA_DIR
Environment=DATAMACHINE_SITE_PATH=$SITE_PATH
Environment=DATAMACHINE_WP_CMD=$WP_CMD
Environment=DATAMACHINE_WP_TRANSPORT_JSON=\"$transport_json\"
Environment=DATAMACHINE_AGENT_SLUG=$AGENT_SLUG"
if [ -n "${KIMAKI_BOT_TOKEN:-}" ]; then
out="$out
Expand Down
73 changes: 68 additions & 5 deletions tests/dm-agent-sync.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// tests/dm-agent-sync.mjs — lifecycle tests for the Kimaki DM memory sync plugin.

import assert from "node:assert/strict"
import { access, mkdtemp } from "node:fs/promises"
import { access, chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import dmAgentSync from "../bridges/kimaki/plugins/dm-agent-sync.ts"
Expand Down Expand Up @@ -52,7 +52,8 @@ async function loadPlugin(config = {}) {

await withEnv({
DATAMACHINE_SITE_PATH: sitePath,
DATAMACHINE_WP_CMD: "true",
DATAMACHINE_WP_TRANSPORT_JSON: '["true"]',
DATAMACHINE_WP_CMD: undefined,
DATAMACHINE_AGENT_SLUG: "intelligence-chubes4",
EXTERNAL_WORDPRESS: undefined,
}, async () => {
Expand All @@ -74,7 +75,7 @@ await withEnv({

await withEnv({
DATAMACHINE_SITE_PATH: sitePath,
DATAMACHINE_WP_CMD: "false",
DATAMACHINE_WP_TRANSPORT_JSON: '["false"]',
DATAMACHINE_AGENT_SLUG: "intelligence-chubes4",
}, async () => {
const run = await loadPlugin()
Expand All @@ -83,18 +84,45 @@ await withEnv({
assert.ok(run.warnings.some((line) => line.includes("memory compose failed")))
})

await withEnv({ EXTERNAL_WORDPRESS: "true", DATAMACHINE_WP_CMD: "false" }, async () => {
await withEnv({ EXTERNAL_WORDPRESS: "true", DATAMACHINE_WP_TRANSPORT_JSON: '["false"]' }, async () => {
const run = await loadPlugin()
await run.config()
await run.chat()
assert.equal(run.warnings.length, 0)
})

await withEnv({
DATAMACHINE_SITE_PATH: sitePath,
DATAMACHINE_WP_TRANSPORT_JSON: "[]",
DATAMACHINE_WP_CMD: "false",
DATAMACHINE_AGENT_SLUG: "intelligence-chubes4",
}, async () => {
const run = await loadPlugin()
await run.config()
await run.chat()
assert.equal(run.warnings.length, 0)
})

await withEnv({
DATAMACHINE_SITE_PATH: sitePath,
DATAMACHINE_WP_TRANSPORT_JSON: undefined,
DATAMACHINE_WP_CMD: "true",
DATAMACHINE_AGENT_SLUG: "intelligence-chubes4",
}, async () => {
const run = await loadPlugin()
await run.config()
await run.chat()
assert.ok(run.warnings.some((line) => line.includes("recomposed Data Machine memory in")))
})

await withEnv({ DATAMACHINE_COMPOSE_TIMEOUT_MS: "10" }, async () => {
const directory = await mkdtemp(join(tmpdir(), "dm-agent-sync-"))
const marker = join(directory, "compose-finished")
const sleeper = join(directory, "slow-compose")
await writeFile(sleeper, `#!/bin/sh\nsleep 0.2\ntouch '${marker}'\n`)
await chmod(sleeper, 0o755)
const run = await loadPlugin()
await withEnv({ DATAMACHINE_WP_CMD: `sh -c 'sleep 0.2; touch ${marker}'` }, async () => {
await withEnv({ DATAMACHINE_WP_TRANSPORT_JSON: JSON.stringify([sleeper]) }, async () => {
await run.config()
const startedAt = Date.now()
await run.chat()
Expand All @@ -105,4 +133,39 @@ await withEnv({ DATAMACHINE_COMPOSE_TIMEOUT_MS: "10" }, async () => {
assert.ok(run.warnings.some((line) => line.includes("memory compose timed out")))
})

await withEnv({
DATAMACHINE_WP_CMD: "false",
DATAMACHINE_AGENT_SLUG: "agent with spaces",
}, async () => {
const directory = await mkdtemp(join(tmpdir(), "dm-agent-sync-argv-"))
const binDir = join(directory, "bin with spaces")
const siteDir = join(directory, "site with spaces")
const dump = join(directory, "argv.json")
const recorder = join(binDir, "wp cli")
await mkdir(binDir)
await writeFile(recorder, `#!/bin/sh
python3 -c 'import json,os,sys; open(os.environ["DM_ARGV_DUMP"],"w").write(json.dumps(sys.argv[1:]))' "$@"
`)
await chmod(recorder, 0o755)
const run = await loadPlugin()
await withEnv({
DATAMACHINE_SITE_PATH: siteDir,
DATAMACHINE_WP_TRANSPORT_JSON: JSON.stringify([recorder, "--flag with spaces"]),
DM_ARGV_DUMP: dump,
}, async () => {
await run.config()
await run.chat()
})
const argv = JSON.parse(await readFile(dump, "utf8"))
assert.deepEqual(argv, [
"--flag with spaces",
"datamachine",
"memory",
"compose",
"--agent=agent with spaces",
`--path=${siteDir}`,
"--allow-root",
])
})

console.log("OK: dm-agent-sync runs bounded WordPress composition only for the first chat message per session")
Loading
Loading