From 4bfe3dcf00d3727557b038df3dcfc24a88910938 Mon Sep 17 00:00:00 2001 From: Benedikt Koehler Date: Fri, 10 Apr 2026 20:04:37 +0200 Subject: [PATCH] feat: add byterover memory plugin --- README.md | 1 + docs/development/agents.md | 1 + .../extensibility/byterover-memory-plugin.md | 157 ++++++ docs/static/docs.js | 4 + .../byterover-memory/hybridclaw.plugin.yaml | 50 ++ plugins/byterover-memory/package.json | 10 + .../byterover-memory/src/byterover-process.js | 207 +++++++ plugins/byterover-memory/src/config.js | 87 +++ plugins/byterover-memory/src/index.js | 532 ++++++++++++++++++ tests/byterover-memory-plugin.test.ts | 411 ++++++++++++++ 10 files changed, 1460 insertions(+) create mode 100644 docs/development/extensibility/byterover-memory-plugin.md create mode 100644 plugins/byterover-memory/hybridclaw.plugin.yaml create mode 100644 plugins/byterover-memory/package.json create mode 100644 plugins/byterover-memory/src/byterover-process.js create mode 100644 plugins/byterover-memory/src/config.js create mode 100644 plugins/byterover-memory/src/index.js create mode 100644 tests/byterover-memory-plugin.test.ts diff --git a/README.md b/README.md index 1b5aaf52..1d96fcc7 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ Browse the full manual at [Extensibility](https://www.hybridclaw.io/docs/extensibility), [Bundled Skills](https://www.hybridclaw.io/docs/guides/bundled-skills), [Plugin System](https://www.hybridclaw.io/docs/extensibility/plugins), + [ByteRover Memory Plugin](https://www.hybridclaw.io/docs/extensibility/byterover-memory-plugin), [Honcho Memory Plugin](https://www.hybridclaw.io/docs/extensibility/honcho-memory-plugin), and [MemPalace Memory Plugin](https://www.hybridclaw.io/docs/extensibility/mempalace-memory-plugin) - Configuration: diff --git a/docs/development/agents.md b/docs/development/agents.md index 7f7e6de5..ab1c9fa7 100644 --- a/docs/development/agents.md +++ b/docs/development/agents.md @@ -43,6 +43,7 @@ Main docs landing pages: - [Extensibility](./extensibility/README.md) - [Adaptive Skills](./extensibility/adaptive-skills.md) - [Agent Packages](./extensibility/agent-packages.md) +- [ByteRover Memory Plugin](./extensibility/byterover-memory-plugin.md) - [Honcho Memory Plugin](./extensibility/honcho-memory-plugin.md) - [MemPalace Memory Plugin](./extensibility/mempalace-memory-plugin.md) - [OTEL Plugin](./extensibility/otel-plugin.md) diff --git a/docs/development/extensibility/byterover-memory-plugin.md b/docs/development/extensibility/byterover-memory-plugin.md new file mode 100644 index 00000000..abc42e83 --- /dev/null +++ b/docs/development/extensibility/byterover-memory-plugin.md @@ -0,0 +1,157 @@ +--- +title: ByteRover Memory Plugin +description: Setup, configuration, commands, and runtime behavior for the bundled `byterover-memory` plugin. +sidebar_position: 8 +--- + +# ByteRover Memory Plugin + +HybridClaw ships a bundled ByteRover integration at +[`plugins/byterover-memory`](https://github.com/HybridAIOne/hybridclaw/tree/main/plugins/byterover-memory). + +The plugin keeps HybridClaw built-in memory active and adds ByteRover in four +places: + +- prompt-time recall through `brv query` using the latest user message +- model tools: `brv_query`, `brv_curate`, and `brv_status` +- a local operator command: `/byterover ...` +- background curation for finished turns, successful native `memory` writes, + and pre-compaction summaries + +Like `honcho-memory`, ByteRover is marked as an external `memoryProvider`. +Only one such plugin can be active at a time, alongside HybridClaw's built-in +`MEMORY.md`, `USER.md`, and SQLite session store. + +## Install + +1. Install the ByteRover CLI: + + ```bash + npm install -g byterover-cli + # or + curl -fsSL https://byterover.dev/install.sh | sh + ``` + +2. Install the bundled plugin from this repo: + + ```bash + hybridclaw plugin install ./plugins/byterover-memory + ``` + +3. Reload plugins in an active session: + + ```text + /plugin reload + ``` + +4. Optional: store a ByteRover cloud key through HybridClaw secrets: + + ```text + /secret set BRV_API_KEY your-byterover-key + ``` + +The key is optional. Without it, ByteRover still runs in local-first mode as +long as the `brv` CLI is installed. + +## Config + +The plugin works with defaults after install. A small explicit config looks +like this: + +```json +{ + "plugins": { + "list": [ + { + "id": "byterover-memory", + "enabled": true, + "config": { + "command": "brv", + "workingDirectory": "~/.hybridclaw/byterover", + "maxInjectedChars": 4000, + "queryTimeoutMs": 10000, + "curateTimeoutMs": 120000 + } + } + ] + } +} +``` + +Supported config keys: + +- `command`: ByteRover executable to spawn. Defaults to `brv`. +- `workingDirectory`: cwd used for every `brv` invocation. Defaults to + `/byterover`, so the knowledge tree is profile-scoped rather + than repo-scoped. +- `autoCurate`: when `true`, queue `brv curate` after completed assistant + turns. Defaults to `true`. +- `mirrorMemoryWrites`: when `true`, mirror successful native `memory` writes + into ByteRover as labeled curations. Defaults to `true`. +- `maxInjectedChars`: prompt budget for auto-injected ByteRover recall. +- `queryTimeoutMs`: timeout for prompt recall, `brv_query`, and + `/byterover query`. +- `curateTimeoutMs`: timeout for queued and explicit `brv curate` calls. + +## Behavior + +- Before each turn, the plugin runs `brv query -- `. +- If ByteRover returns usable text, that recall is injected into prompt context + as current-turn external memory. +- The plugin also injects a short tool-use guide so the model knows when to use + `brv_query`, `brv_curate`, and `brv_status`. +- After a completed turn, the plugin queues a `brv curate` call with a compact + `User:` / `Assistant:` summary. +- Successful native `memory` writes are mirrored into ByteRover with labels + such as `User profile` or `Durable memory`. +- Before compaction, the plugin curates the compaction summary plus a few recent + user/assistant excerpts so older context is not lost before SQLite archival. +- All ByteRover calls run on the gateway host process, not inside the agent + container. + +## Tools and Commands + +### Model tools + +- `brv_status`: show ByteRover CLI health, working directory, and whether + `BRV_API_KEY` is configured +- `brv_query`: search ByteRover memory for relevant prior knowledge +- `brv_curate`: explicitly store durable facts, decisions, or preferences + +### Local command + +```text +/byterover status +/byterover query auth migration +/byterover curate Remember that concise answers are preferred. +``` + +The command is a direct CLI passthrough with `status` as the default when no +subcommand is given. + +## Verifying It + +1. Confirm the plugin is enabled: + + ```text + /plugin list + /byterover status + ``` + +2. Check that the model can see the tools: + + ```text + /show tools + ``` + + Expected: `brv_query`, `brv_curate`, and `brv_status` appear. + +3. Give the agent a few durable facts, then ask a follow-up question that + should benefit from recall. + +4. Confirm plugin usage: + + - the TUI footer shows `plugins: byterover-memory` when prompt recall was + injected + - `/byterover query ` returns the same kind of knowledge the prompt + path is using diff --git a/docs/static/docs.js b/docs/static/docs.js index d2fd62af..bfe714c1 100644 --- a/docs/static/docs.js +++ b/docs/static/docs.js @@ -39,6 +39,10 @@ export const DEVELOPMENT_DOCS_SECTIONS = [ { title: 'Extensibility', path: 'extensibility/README.md' }, { title: 'Adaptive Skills', path: 'extensibility/adaptive-skills.md' }, { title: 'Agent Packages', path: 'extensibility/agent-packages.md' }, + { + title: 'ByteRover Memory Plugin', + path: 'extensibility/byterover-memory-plugin.md', + }, { title: 'Honcho Memory Plugin', path: 'extensibility/honcho-memory-plugin.md', diff --git a/plugins/byterover-memory/hybridclaw.plugin.yaml b/plugins/byterover-memory/hybridclaw.plugin.yaml new file mode 100644 index 00000000..ac128ed7 --- /dev/null +++ b/plugins/byterover-memory/hybridclaw.plugin.yaml @@ -0,0 +1,50 @@ +id: byterover-memory +name: ByteRover Memory +version: 0.1.0 +kind: memory +memoryProvider: true +description: Mirror HybridClaw turns into ByteRover, inject prompt-time recall, and expose ByteRover memory tools. +entrypoint: src/index.js +credentials: + - BRV_API_KEY +requires: + bins: + - name: brv + configKey: command + installHint: npm install -g byterover-cli + installUrl: https://byterover.dev +externalDependencies: + - name: brv + check: brv --version + installHint: npm install -g byterover-cli + installUrl: https://byterover.dev +configSchema: + type: object + additionalProperties: false + properties: + command: + type: string + default: brv + workingDirectory: + type: string + autoCurate: + type: boolean + default: true + mirrorMemoryWrites: + type: boolean + default: true + maxInjectedChars: + type: number + default: 4000 + minimum: 500 + maximum: 16000 + queryTimeoutMs: + type: number + default: 10000 + minimum: 1000 + maximum: 60000 + curateTimeoutMs: + type: number + default: 120000 + minimum: 1000 + maximum: 600000 diff --git a/plugins/byterover-memory/package.json b/plugins/byterover-memory/package.json new file mode 100644 index 00000000..9e0dfb0e --- /dev/null +++ b/plugins/byterover-memory/package.json @@ -0,0 +1,10 @@ +{ + "name": "hybridclaw-plugin-byterover-memory", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "HybridClaw memory plugin for ByteRover CLI recall and curation", + "engines": { + "node": "22.x" + } +} diff --git a/plugins/byterover-memory/src/byterover-process.js b/plugins/byterover-memory/src/byterover-process.js new file mode 100644 index 00000000..ff89b295 --- /dev/null +++ b/plugins/byterover-memory/src/byterover-process.js @@ -0,0 +1,207 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; + +const BRV_ENV_ALLOWLIST = [ + 'PATH', + 'HOME', + 'TMPDIR', + 'XDG_CACHE_HOME', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'LANG', + 'LC_ALL', + 'LC_CTYPE', +]; +const BRV_WINDOWS_ENV_ALLOWLIST = [ + 'APPDATA', + 'ComSpec', + 'LOCALAPPDATA', + 'PATHEXT', + 'SystemRoot', + 'TEMP', + 'TMP', + 'USERPROFILE', +]; +const PROCESS_KILL_GRACE_MS = 250; +const MIN_CAPTURE_BYTES = 32_768; +const CAPTURE_BYTES_PER_CHAR = 2; + +export function normalizeByteRoverText(value) { + return String(value || '') + .replace(/\r/g, '') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +export function truncateByteRoverText(value, maxChars) { + const normalized = normalizeByteRoverText(value); + if (!maxChars || normalized.length <= maxChars) return normalized; + return `${normalized.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; +} + +function buildProcessEnv(apiKey) { + const env = {}; + const allowlist = + process.platform === 'win32' + ? [...BRV_ENV_ALLOWLIST, ...BRV_WINDOWS_ENV_ALLOWLIST] + : BRV_ENV_ALLOWLIST; + + for (const key of allowlist) { + const value = process.env[key]; + if (typeof value === 'string' && value.length > 0) { + env[key] = value; + } + } + if (typeof apiKey === 'string' && apiKey.trim()) { + env.BRV_API_KEY = apiKey.trim(); + } + return env; +} + +function resolveCaptureLimitBytes(maxChars) { + const configuredBudget = + typeof maxChars === 'number' && Number.isFinite(maxChars) + ? Math.max(0, Math.trunc(maxChars)) * CAPTURE_BYTES_PER_CHAR + : 0; + return Math.max(MIN_CAPTURE_BYTES, configuredBudget); +} + +function createOutputCollector(maxBytes) { + return { + maxBytes, + totalBytes: 0, + truncated: false, + chunks: [], + }; +} + +function appendOutputChunk(collector, chunk) { + const buffer = Buffer.isBuffer(chunk) + ? chunk + : Buffer.from(String(chunk), 'utf-8'); + const remaining = collector.maxBytes - collector.totalBytes; + if (remaining <= 0) { + collector.truncated = true; + return; + } + if (buffer.length <= remaining) { + collector.totalBytes += buffer.length; + collector.chunks.push(buffer); + return; + } + collector.totalBytes += remaining; + collector.chunks.push(buffer.subarray(0, remaining)); + collector.truncated = true; +} + +function readCollectedOutput(collector) { + if (collector.chunks.length === 0) return ''; + return Buffer.concat(collector.chunks).toString('utf-8'); +} + +export async function runByteRover(subcommandArgs, config, options = {}) { + const timeoutMs = + typeof options.timeoutMs === 'number' && Number.isFinite(options.timeoutMs) + ? options.timeoutMs + : config.queryTimeoutMs; + const captureLimitBytes = resolveCaptureLimitBytes(options.maxChars); + const args = subcommandArgs.map((arg) => String(arg)); + fs.mkdirSync(config.workingDirectory, { recursive: true }); + + return await new Promise((resolve) => { + const child = spawn(config.command, args, { + cwd: config.workingDirectory, + env: buildProcessEnv(options.apiKey), + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdoutCollector = createOutputCollector(captureLimitBytes); + const stderrCollector = createOutputCollector(captureLimitBytes); + let settled = false; + let timedOut = false; + let killTimer = null; + + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearTimeout(killTimer); + resolve({ + ...result, + stdout: readCollectedOutput(stdoutCollector), + stderr: readCollectedOutput(stderrCollector), + stdoutTruncated: stdoutCollector.truncated, + stderrTruncated: stderrCollector.truncated, + }); + }; + + const timer = + typeof timeoutMs === 'number' && + Number.isFinite(timeoutMs) && + timeoutMs > 0 + ? setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + killTimer = setTimeout(() => { + if (!settled) { + child.kill('SIGKILL'); + } + }, PROCESS_KILL_GRACE_MS); + }, timeoutMs) + : null; + + child.stdout?.on('data', (chunk) => + appendOutputChunk(stdoutCollector, chunk), + ); + child.stderr?.on('data', (chunk) => + appendOutputChunk(stderrCollector, chunk), + ); + child.on('error', (error) => finish({ ok: false, error })); + child.on('close', (code, signal) => { + if (timedOut) { + finish({ + ok: false, + error: new Error(`ByteRover timed out after ${timeoutMs}ms.`), + }); + return; + } + if (signal) { + finish({ + ok: false, + error: new Error(`ByteRover terminated with signal ${signal}.`), + }); + return; + } + if (code !== 0) { + const stderrText = normalizeByteRoverText( + readCollectedOutput(stderrCollector), + ); + const stdoutText = normalizeByteRoverText( + readCollectedOutput(stdoutCollector), + ); + const errorMessage = + stderrText || + stdoutText || + `ByteRover exited with code ${String(code)}.`; + finish({ + ok: false, + error: new Error(errorMessage), + }); + return; + } + finish({ ok: true }); + }); + }); +} + +export async function runByteRoverCommandText( + subcommandArgs, + config, + options = {}, +) { + const result = await runByteRover(subcommandArgs, config, options); + if (!result.ok) { + throw result.error; + } + return truncateByteRoverText(result.stdout, options.maxChars); +} diff --git a/plugins/byterover-memory/src/config.js b/plugins/byterover-memory/src/config.js new file mode 100644 index 00000000..e3ab0c52 --- /dev/null +++ b/plugins/byterover-memory/src/config.js @@ -0,0 +1,87 @@ +import os from 'node:os'; +import path from 'node:path'; + +const DEFAULT_MAX_INJECTED_CHARS = 4000; +const DEFAULT_QUERY_TIMEOUT_MS = 10_000; +const DEFAULT_CURATE_TIMEOUT_MS = 120_000; + +function normalizeString(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizeInteger(value, fallback, key, bounds = {}) { + const raw = + typeof value === 'number' && Number.isFinite(value) + ? Math.trunc(value) + : fallback; + if (!Number.isFinite(raw)) { + throw new Error(`byterover-memory plugin config.${key} must be a number.`); + } + if ( + typeof bounds.minimum === 'number' && + Number.isFinite(bounds.minimum) && + raw < bounds.minimum + ) { + throw new Error( + `byterover-memory plugin config.${key} must be >= ${bounds.minimum}.`, + ); + } + if ( + typeof bounds.maximum === 'number' && + Number.isFinite(bounds.maximum) && + raw > bounds.maximum + ) { + throw new Error( + `byterover-memory plugin config.${key} must be <= ${bounds.maximum}.`, + ); + } + return raw; +} + +function resolveRuntimePath(value, runtime) { + const normalized = normalizeString(value); + if (!normalized) return ''; + if (normalized === '~') return os.homedir(); + if (normalized.startsWith('~/')) { + return path.join(os.homedir(), normalized.slice(2)); + } + if (path.isAbsolute(normalized)) return normalized; + return path.resolve(runtime.cwd, normalized); +} + +function resolveDefaultWorkingDirectory(runtime) { + const runtimeHome = normalizeString(runtime?.homeDir); + if (runtimeHome) { + return path.resolve(runtimeHome, 'byterover'); + } + return path.resolve(os.homedir(), '.hybridclaw', 'byterover'); +} + +export function resolveByteRoverPluginConfig(pluginConfig, runtime) { + return Object.freeze({ + command: normalizeString(pluginConfig?.command) || 'brv', + workingDirectory: + resolveRuntimePath(pluginConfig?.workingDirectory, runtime) || + resolveDefaultWorkingDirectory(runtime), + autoCurate: pluginConfig?.autoCurate !== false, + mirrorMemoryWrites: pluginConfig?.mirrorMemoryWrites !== false, + maxInjectedChars: normalizeInteger( + pluginConfig?.maxInjectedChars, + DEFAULT_MAX_INJECTED_CHARS, + 'maxInjectedChars', + { minimum: 500, maximum: 16_000 }, + ), + queryTimeoutMs: normalizeInteger( + pluginConfig?.queryTimeoutMs, + DEFAULT_QUERY_TIMEOUT_MS, + 'queryTimeoutMs', + { minimum: 1_000, maximum: 60_000 }, + ), + curateTimeoutMs: normalizeInteger( + pluginConfig?.curateTimeoutMs, + DEFAULT_CURATE_TIMEOUT_MS, + 'curateTimeoutMs', + { minimum: 1_000, maximum: 600_000 }, + ), + }); +} diff --git a/plugins/byterover-memory/src/index.js b/plugins/byterover-memory/src/index.js new file mode 100644 index 00000000..bbe19df7 --- /dev/null +++ b/plugins/byterover-memory/src/index.js @@ -0,0 +1,532 @@ +import { + runByteRoverCommandText, + truncateByteRoverText, +} from './byterover-process.js'; +import { resolveByteRoverPluginConfig } from './config.js'; + +const MIN_QUERY_CHARS = 10; +const MIN_RESULT_CHARS = 20; +const MAX_QUERY_CHARS = 5000; +const MAX_TOOL_RESULT_CHARS = 8000; +const MAX_TURN_MESSAGE_CHARS = 2000; +const MAX_COMPACTION_MESSAGE_CHARS = 500; +const MAX_COMPACTION_MESSAGES = 10; + +function normalizeString(value) { + return String(value || '').trim(); +} + +function collapseWhitespace(value) { + return normalizeString(value).replace(/\s+/g, ' '); +} + +function normalizeSearchQuery(value) { + const normalized = collapseWhitespace(value); + if (normalized.length < MIN_QUERY_CHARS) return ''; + if (normalized.length <= MAX_QUERY_CHARS) return normalized; + return `${normalized.slice(0, Math.max(0, MAX_QUERY_CHARS - 1)).trimEnd()}…`; +} + +function truncateForCurate(value, maxChars) { + const normalized = normalizeString(value); + if (normalized.length <= maxChars) return normalized; + return `${normalized.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; +} + +function stripMatchingQuotes(value) { + const normalized = normalizeString(value); + if (normalized.length < 2) return normalized; + const first = normalized[0]; + const last = normalized.at(-1); + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return normalized.slice(1, -1).trim(); + } + return normalized; +} + +function getLatestMessageByRole(messages, role) { + let latestMessage = null; + for (const message of messages || []) { + if (String(message?.role || '').toLowerCase() !== role) continue; + if (!latestMessage) { + latestMessage = message; + continue; + } + const currentTime = Date.parse(String(message.created_at || '')); + const latestTime = Date.parse(String(latestMessage.created_at || '')); + if ( + (Number.isFinite(currentTime) ? currentTime : Number.NEGATIVE_INFINITY) >= + (Number.isFinite(latestTime) ? latestTime : Number.NEGATIVE_INFINITY) + ) { + latestMessage = message; + } + } + return latestMessage; +} + +function getLatestUserQuery(recentMessages) { + return normalizeSearchQuery( + getLatestMessageByRole(recentMessages, 'user')?.content, + ); +} + +function buildQueryArgs(query) { + return ['query', '--', query]; +} + +function buildCurateArgs(content) { + return ['curate', '--', content]; +} + +function describeMemoryFile(memoryFilePath) { + if (memoryFilePath === 'USER.md') return 'User profile'; + if (memoryFilePath === 'MEMORY.md') return 'Durable memory'; + return `Daily memory note (${memoryFilePath})`; +} + +function resolveMirroredMemoryContent(context) { + if (context.action === 'append' || context.action === 'write') { + return normalizeString(context.content); + } + if (context.action === 'replace') { + return normalizeString(context.newText); + } + return ''; +} + +function buildMemoryWriteCurateContent(context) { + const content = resolveMirroredMemoryContent(context); + if (!content) return ''; + return `[${describeMemoryFile(context.memoryFilePath)}]\n${content}`; +} + +function buildTurnCurateContent(messages) { + const userMessage = getLatestMessageByRole(messages, 'user'); + const assistantMessage = getLatestMessageByRole(messages, 'assistant'); + const userContent = normalizeString(userMessage?.content); + if (userContent.length < MIN_QUERY_CHARS) return ''; + const assistantContent = normalizeString(assistantMessage?.content); + const lines = [ + `User: ${truncateForCurate(userContent, MAX_TURN_MESSAGE_CHARS)}`, + ]; + if (assistantContent) { + lines.push( + `Assistant: ${truncateForCurate(assistantContent, MAX_TURN_MESSAGE_CHARS)}`, + ); + } + return lines.join('\n'); +} + +function buildCompactionCurateContent(summary, olderMessages) { + const sections = ['[Pre-compaction context]']; + const normalizedSummary = normalizeString(summary); + if (normalizedSummary) { + sections.push(''); + sections.push('Summary:'); + sections.push(truncateForCurate(normalizedSummary, 1500)); + } + + const excerpts = (olderMessages || []) + .filter((message) => { + const role = String(message?.role || '').toLowerCase(); + if (role !== 'user' && role !== 'assistant') return false; + return normalizeString(message?.content).length > 0; + }) + .slice(-MAX_COMPACTION_MESSAGES) + .map((message) => { + const role = String(message.role || '').toLowerCase(); + const content = truncateForCurate( + message.content, + MAX_COMPACTION_MESSAGE_CHARS, + ); + return `${role}: ${content}`; + }); + + if (excerpts.length === 0 && !normalizedSummary) return ''; + if (excerpts.length > 0) { + sections.push(''); + sections.push(...excerpts); + } + return sections.join('\n'); +} + +function normalizeManualCommandArgs(args) { + const normalizedArgs = Array.isArray(args) + ? args.map((arg) => String(arg || '').trim()).filter(Boolean) + : []; + if (normalizedArgs.length === 0) { + return ['status']; + } + const command = normalizedArgs[0].toLowerCase(); + if ( + (command === 'query' || command === 'curate') && + !normalizedArgs.includes('--') + ) { + const payload = stripMatchingQuotes(normalizedArgs.slice(1).join(' ')); + return payload ? [command, '--', payload] : [command]; + } + return normalizedArgs; +} + +function timeoutForCommand(command, config) { + const normalized = normalizeString(command).toLowerCase(); + if (normalized === 'status' || normalized === 'query') { + return config.queryTimeoutMs; + } + if (normalized === 'curate') { + return config.curateTimeoutMs; + } + return Math.max(config.queryTimeoutMs, config.curateTimeoutMs); +} + +function buildStatusText(output, config, apiKey) { + return [ + `Command: ${config.command}`, + `Working directory: ${config.workingDirectory}`, + `BRV_API_KEY: ${apiKey ? 'configured' : 'unset (optional)'}`, + '', + output, + ].join('\n'); +} + +function formatCommandFailure(error, config, args) { + const message = + error instanceof Error ? error.message : String(error || 'Unknown error'); + return [ + 'ByteRover command failed.', + `Command: ${config.command}`, + `Working directory: ${config.workingDirectory}`, + ...(args.length > 0 ? [`Arguments: ${args.join(' ')}`] : []), + '', + message, + ].join('\n'); +} + +function createByteRoverRuntime(api, config) { + let queue = Promise.resolve(); + + function getApiKey() { + return normalizeString(api.getCredential('BRV_API_KEY')); + } + + async function runQuery(query, maxChars = MAX_TOOL_RESULT_CHARS) { + return await runByteRoverCommandText(buildQueryArgs(query), config, { + apiKey: getApiKey(), + timeoutMs: config.queryTimeoutMs, + maxChars, + }); + } + + async function runCurate(content) { + return await runByteRoverCommandText(buildCurateArgs(content), config, { + apiKey: getApiKey(), + timeoutMs: config.curateTimeoutMs, + maxChars: 2000, + }); + } + + function enqueueCurate(params) { + const { reason, sessionId, content } = params; + if (!normalizeString(content)) { + return queue.catch(() => undefined); + } + const task = queue + .catch(() => undefined) + .then(async () => { + try { + const output = await runCurate(content); + api.logger.debug( + { + reason, + sessionId, + workingDirectory: config.workingDirectory, + output: truncateByteRoverText(output, 240), + }, + 'ByteRover curate completed', + ); + } catch (error) { + api.logger.warn( + { + error, + reason, + sessionId, + workingDirectory: config.workingDirectory, + }, + 'ByteRover curate failed', + ); + } + }); + queue = task; + return task; + } + + return { + async start() { + try { + const output = await runByteRoverCommandText(['status'], config, { + apiKey: getApiKey(), + timeoutMs: Math.min(config.queryTimeoutMs, 15_000), + maxChars: 2000, + }); + api.logger.debug( + { + command: config.command, + workingDirectory: config.workingDirectory, + output: truncateByteRoverText(output, 240), + }, + 'ByteRover startup health-check passed', + ); + } catch (error) { + api.logger.warn( + { + error, + command: config.command, + workingDirectory: config.workingDirectory, + }, + 'ByteRover startup health-check failed', + ); + } + }, + async stop() { + await queue.catch(() => undefined); + }, + async getContextForPrompt({ recentMessages }) { + const query = getLatestUserQuery(recentMessages); + if (!query) return null; + try { + const output = await runQuery(query, config.maxInjectedChars); + if (normalizeString(output).length < MIN_RESULT_CHARS) { + return null; + } + return truncateByteRoverText( + [ + 'ByteRover recalled context for the latest user message:', + `Query: ${query}`, + '', + output, + ].join('\n'), + config.maxInjectedChars, + ); + } catch (error) { + api.logger.warn( + { + error, + query, + workingDirectory: config.workingDirectory, + }, + 'ByteRover prompt recall failed', + ); + return null; + } + }, + renderPromptGuide() { + return [ + 'ByteRover memory tools are enabled.', + 'Use `brv_query` when prior project decisions, preferences, or patterns may matter.', + 'Use `brv_curate` to save durable facts, corrections, or workflows worth keeping across sessions.', + 'Use `brv_status` or `/byterover status` to inspect CLI health and the working directory.', + ].join('\n'); + }, + onTurnComplete(params) { + if (!config.autoCurate) return; + const content = buildTurnCurateContent(params.messages); + void enqueueCurate({ + sessionId: params.sessionId, + reason: 'turn complete', + content, + }); + }, + onMemoryWrite(context) { + if (!config.mirrorMemoryWrites) return; + const content = buildMemoryWriteCurateContent(context); + if (!content) return; + void enqueueCurate({ + sessionId: context.sessionId, + reason: `memory write ${context.action}`, + content, + }); + }, + async onBeforeCompaction(context) { + const content = buildCompactionCurateContent( + context.summary, + context.olderMessages, + ); + await enqueueCurate({ + sessionId: context.sessionId, + reason: 'before compaction', + content, + }); + }, + async handleToolStatus() { + try { + const output = await runByteRoverCommandText(['status'], config, { + apiKey: getApiKey(), + timeoutMs: timeoutForCommand('status', config), + maxChars: MAX_TOOL_RESULT_CHARS, + }); + return buildStatusText(output, config, getApiKey()); + } catch (error) { + return formatCommandFailure(error, config, ['status']); + } + }, + async handleToolQuery(args) { + const query = normalizeSearchQuery(args?.query); + if (!query) { + return 'Error: query is required.'; + } + try { + const output = await runQuery(query); + if (normalizeString(output).length < MIN_RESULT_CHARS) { + return 'No relevant ByteRover memories found.'; + } + return output; + } catch (error) { + return formatCommandFailure(error, config, ['query', '--', query]); + } + }, + async handleToolCurate(args) { + const content = normalizeString(args?.content); + if (!content) { + return 'Error: content is required.'; + } + try { + await runCurate(content); + return 'ByteRover memory updated.'; + } catch (error) { + return formatCommandFailure(error, config, ['curate', '--', content]); + } + }, + async handleCommand(args) { + const commandArgs = normalizeManualCommandArgs(args); + try { + const output = await runByteRoverCommandText(commandArgs, config, { + apiKey: getApiKey(), + timeoutMs: timeoutForCommand(commandArgs[0], config), + maxChars: MAX_TOOL_RESULT_CHARS, + }); + if (commandArgs[0] === 'status') { + return buildStatusText(output, config, getApiKey()); + } + return output; + } catch (error) { + return formatCommandFailure(error, config, commandArgs); + } + }, + }; +} + +export default { + id: 'byterover-memory', + kind: 'memory', + register(api) { + const config = resolveByteRoverPluginConfig(api.pluginConfig, api.runtime); + const runtime = createByteRoverRuntime(api, config); + + api.registerMemoryLayer({ + id: 'byterover-memory-layer', + priority: 48, + start() { + return runtime.start(); + }, + stop() { + return runtime.stop(); + }, + getContextForPrompt(params) { + return runtime.getContextForPrompt(params); + }, + onTurnComplete(params) { + return runtime.onTurnComplete(params); + }, + }); + + api.registerPromptHook({ + id: 'byterover-memory-guide', + priority: 48, + render() { + return runtime.renderPromptGuide(); + }, + }); + + api.registerTool({ + name: 'brv_status', + description: + 'Check ByteRover CLI health, working-directory state, and optional API-key availability.', + parameters: { + type: 'object', + properties: {}, + required: [], + }, + handler() { + return runtime.handleToolStatus(); + }, + }); + + api.registerTool({ + name: 'brv_query', + description: + 'Search ByteRover persistent memory for relevant project knowledge, preferences, or past decisions.', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'What to search for in ByteRover memory.', + }, + }, + required: ['query'], + }, + handler(args) { + return runtime.handleToolQuery(args); + }, + }); + + api.registerTool({ + name: 'brv_curate', + description: + 'Store a durable fact, decision, preference, or pattern in ByteRover memory.', + parameters: { + type: 'object', + properties: { + content: { + type: 'string', + description: 'The information to save into ByteRover.', + }, + }, + required: ['content'], + }, + handler(args) { + return runtime.handleToolCurate(args); + }, + }); + + api.registerCommand({ + name: 'byterover', + description: 'Run ByteRover CLI commands (defaults to status).', + handler(args) { + return runtime.handleCommand(args); + }, + }); + + api.on('memory_write', (context) => runtime.onMemoryWrite(context), { + priority: 48, + }); + + api.on( + 'before_compaction', + async (context) => { + await runtime.onBeforeCompaction(context); + }, + { priority: 48 }, + ); + + api.logger.info( + { + workingDirectory: config.workingDirectory, + autoCurate: config.autoCurate, + mirrorMemoryWrites: config.mirrorMemoryWrites, + maxInjectedChars: config.maxInjectedChars, + }, + 'ByteRover memory plugin registered', + ); + }, +}; diff --git a/tests/byterover-memory-plugin.test.ts b/tests/byterover-memory-plugin.test.ts new file mode 100644 index 00000000..f8f75352 --- /dev/null +++ b/tests/byterover-memory-plugin.test.ts @@ -0,0 +1,411 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, expect, test, vi } from 'vitest'; + +import type { RuntimeConfig } from '../src/config/runtime-config.js'; + +const tempDirs: string[] = []; +const originalBrvApiKey = process.env.BRV_API_KEY; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function loadRuntimeConfig(): RuntimeConfig { + return JSON.parse( + fs.readFileSync(path.join(process.cwd(), 'config.example.json'), 'utf-8'), + ) as RuntimeConfig; +} + +function installBundledPlugin(cwd: string): void { + const sourceDir = path.join(process.cwd(), 'plugins', 'byterover-memory'); + const targetDir = path.join( + cwd, + '.hybridclaw', + 'plugins', + 'byterover-memory', + ); + fs.mkdirSync(path.dirname(targetDir), { recursive: true }); + fs.cpSync(sourceDir, targetDir, { recursive: true }); +} + +function writeByteRoverStub(rootDir: string): string { + const scriptPath = path.join(rootDir, 'mock-brv.mjs'); + const commandLogPath = path.join(rootDir, 'byterover-command-log.jsonl'); + fs.writeFileSync( + scriptPath, + [ + '#!/usr/bin/env node', + 'import fs from "node:fs";', + `const commandLogPath = ${JSON.stringify(commandLogPath)};`, + 'const argv = process.argv.slice(2);', + 'const command = String(argv[0] || "");', + 'const args = argv.slice(1);', + 'fs.appendFileSync(commandLogPath, JSON.stringify({', + ' command,', + ' args,', + ' cwd: process.cwd(),', + ' apiKey: process.env.BRV_API_KEY || "",', + '}) + "\\n", "utf8");', + 'if (command === "status") {', + ' console.log("ByteRover ready");', + ' console.log("Tree nodes: 42");', + ' process.exit(0);', + '}', + 'if (command === "query") {', + ' const queryIndex = args.indexOf("--");', + ' const query = queryIndex >= 0 ? args.slice(queryIndex + 1).join(" ") : args.join(" ");', + ' if (query.toLowerCase().includes("unknown")) {', + ' console.log("No relevant memories found.");', + ' process.exit(0);', + ' }', + ' console.log("Decision: Clerk reduced auth integration time.");', + ' console.log("Preference: concise answers are preferred.");', + ' process.exit(0);', + '}', + 'if (command === "curate") {', + ' console.log("Curated into ByteRover tree.");', + ' process.exit(0);', + '}', + 'console.error("unexpected command: " + command);', + 'process.exit(1);', + '', + ].join('\n'), + 'utf-8', + ); + fs.chmodSync(scriptPath, 0o755); + return scriptPath; +} + +function readByteRoverCommandLog(rootDir: string): Array<{ + command: string; + args: string[]; + cwd: string; + apiKey: string; +}> { + const logPath = path.join(rootDir, 'byterover-command-log.jsonl'); + if (!fs.existsSync(logPath)) return []; + return fs + .readFileSync(logPath, 'utf-8') + .split('\n') + .filter(Boolean) + .map( + (line) => + JSON.parse(line) as { + command: string; + args: string[]; + cwd: string; + apiKey: string; + }, + ); +} + +function decodeCommandPayload(args: string[]): string { + const markerIndex = args.indexOf('--'); + if (markerIndex < 0) return args.join(' '); + return args.slice(markerIndex + 1).join(' '); +} + +afterEach(() => { + if (typeof originalBrvApiKey === 'string') { + process.env.BRV_API_KEY = originalBrvApiKey; + } else { + delete process.env.BRV_API_KEY; + } + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); + vi.resetModules(); +}); + +test('resolveByteRoverPluginConfig resolves defaults and ~ paths', async () => { + const cwd = makeTempDir('hybridclaw-byterover-project-'); + const runtimeHome = makeTempDir('hybridclaw-byterover-home-'); + const { resolveByteRoverPluginConfig } = await import( + '../plugins/byterover-memory/src/config.js' + ); + + const resolved = resolveByteRoverPluginConfig( + { + command: 'custom-brv', + workingDirectory: '~/byterover-sandbox', + autoCurate: false, + maxInjectedChars: 1200, + queryTimeoutMs: 15000, + curateTimeoutMs: 180000, + }, + { + cwd, + homeDir: runtimeHome, + installRoot: '/tmp/install-root', + runtimeConfigPath: '/tmp/config.json', + }, + ); + + expect(resolved.command).toBe('custom-brv'); + expect(resolved.workingDirectory).toBe( + path.join(os.homedir(), 'byterover-sandbox'), + ); + expect(resolved.autoCurate).toBe(false); + expect(resolved.mirrorMemoryWrites).toBe(true); + expect(resolved.maxInjectedChars).toBe(1200); + expect(resolved.queryTimeoutMs).toBe(15000); + expect(resolved.curateTimeoutMs).toBe(180000); + + const defaults = resolveByteRoverPluginConfig( + {}, + { + cwd, + homeDir: runtimeHome, + installRoot: '/tmp/install-root', + runtimeConfigPath: '/tmp/config.json', + }, + ); + expect(defaults.command).toBe('brv'); + expect(defaults.workingDirectory).toBe(path.join(runtimeHome, 'byterover')); + expect(defaults.autoCurate).toBe(true); + expect(defaults.mirrorMemoryWrites).toBe(true); +}); + +test('byterover-memory injects recall context, exposes tools and command, and curates HybridClaw events', async () => { + process.env.BRV_API_KEY = 'test-brv-key'; + + const homeDir = makeTempDir('hybridclaw-byterover-home-'); + const cwd = makeTempDir('hybridclaw-byterover-project-'); + installBundledPlugin(cwd); + const byteroverCommand = writeByteRoverStub(cwd); + const workingDirectory = path.join(homeDir, 'byterover-store'); + const resolvedWorkingDirectory = path.join( + fs.realpathSync(path.dirname(workingDirectory)), + 'byterover-store', + ); + + const config = loadRuntimeConfig(); + config.plugins.list = [ + { + id: 'byterover-memory', + enabled: true, + config: { + command: byteroverCommand, + workingDirectory, + maxInjectedChars: 900, + }, + }, + ]; + + const { PluginManager } = await import('../src/plugins/plugin-manager.js'); + const manager = new PluginManager({ + homeDir, + cwd, + getRuntimeConfig: () => config, + }); + + await manager.ensureInitialized(); + + expect(manager.getLoadedPlugins()).toEqual([ + expect.objectContaining({ + id: 'byterover-memory', + enabled: true, + status: 'loaded', + }), + ]); + expect(manager.getToolDefinitions()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'brv_curate' }), + expect.objectContaining({ name: 'brv_query' }), + expect.objectContaining({ name: 'brv_status' }), + ]), + ); + + const promptContext = await manager.collectPromptContext({ + sessionId: 'session-1', + userId: 'user-1', + agentId: 'main', + channelId: 'web', + workspacePath: cwd, + recentMessages: [ + { + id: 1, + session_id: 'session-1', + user_id: 'user-1', + username: 'alice', + role: 'user', + content: + 'Why did we switch auth providers, and what response style do I prefer?', + created_at: '2026-04-10T10:00:00.000Z', + }, + ], + }); + + expect(promptContext.join('\n\n')).toContain( + 'ByteRover memory tools are enabled.', + ); + expect(promptContext.join('\n\n')).toContain( + 'ByteRover recalled context for the latest user message:', + ); + expect(promptContext.join('\n\n')).toContain( + 'Clerk reduced auth integration time.', + ); + + await expect( + manager.executeTool({ + toolName: 'brv_status', + args: {}, + sessionId: 'session-1', + channelId: 'web', + }), + ).resolves.toContain(`Working directory: ${workingDirectory}`); + + await expect( + manager.executeTool({ + toolName: 'brv_query', + args: { query: 'auth provider decision' }, + sessionId: 'session-1', + channelId: 'web', + }), + ).resolves.toContain('Preference: concise answers are preferred.'); + + await expect( + manager.executeTool({ + toolName: 'brv_curate', + args: { content: 'Remember the repo uses Biome formatting.' }, + sessionId: 'session-1', + channelId: 'web', + }), + ).resolves.toBe('ByteRover memory updated.'); + + const command = manager.findCommand('byterover'); + expect(command).toBeDefined(); + await expect( + Promise.resolve( + command?.handler(['status'], { + sessionId: 'session-1', + channelId: 'web', + userId: 'user-1', + }), + ), + ).resolves.toContain(`Command: ${byteroverCommand}`); + + await manager.notifyTurnComplete({ + sessionId: 'session-1', + userId: 'user-1', + agentId: 'main', + workspacePath: cwd, + messages: [ + { + id: 2, + session_id: 'session-1', + user_id: 'user-1', + username: 'alice', + role: 'user', + content: 'Remember zebra lantern 42 for the release checklist.', + created_at: '2026-04-10T10:01:00.000Z', + }, + { + id: 3, + session_id: 'session-1', + user_id: 'assistant', + username: null, + role: 'assistant', + content: 'I will keep that in mind for the release checklist.', + created_at: '2026-04-10T10:01:05.000Z', + }, + ], + }); + + await manager.notifyMemoryWrites({ + sessionId: 'session-1', + agentId: 'main', + channelId: 'web', + toolExecutions: [ + { + name: 'memory', + arguments: + '{"action":"append","target":"user","content":"Prefers concise answers."}', + result: 'Appended 25 chars to USER.md', + durationMs: 8, + }, + ], + }); + + await manager.notifyBeforeCompaction({ + sessionId: 'session-1', + agentId: 'main', + channelId: 'web', + summary: 'Auth migration decisions and tone preferences.', + olderMessages: [ + { + id: 4, + session_id: 'session-1', + user_id: 'user-1', + username: 'alice', + role: 'user', + content: 'Clerk reduced the auth integration time significantly.', + created_at: '2026-04-10T09:00:00.000Z', + }, + { + id: 5, + session_id: 'session-1', + user_id: 'assistant', + username: null, + role: 'assistant', + content: 'We should preserve that decision and keep responses concise.', + created_at: '2026-04-10T09:00:10.000Z', + }, + ], + }); + + await manager.shutdown(); + + const commandLog = readByteRoverCommandLog(cwd); + expect(commandLog).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + command: 'status', + cwd: resolvedWorkingDirectory, + apiKey: 'test-brv-key', + }), + expect.objectContaining({ + command: 'query', + cwd: resolvedWorkingDirectory, + apiKey: 'test-brv-key', + }), + expect.objectContaining({ + command: 'curate', + cwd: resolvedWorkingDirectory, + apiKey: 'test-brv-key', + }), + ]), + ); + + const curatePayloads = commandLog + .filter((entry) => entry.command === 'curate') + .map((entry) => decodeCommandPayload(entry.args)); + expect( + curatePayloads.some((payload) => + payload.includes('Remember the repo uses Biome formatting.'), + ), + ).toBe(true); + expect( + curatePayloads.some((payload) => + payload.includes( + 'User: Remember zebra lantern 42 for the release checklist.', + ), + ), + ).toBe(true); + expect( + curatePayloads.some((payload) => + payload.includes('[User profile]\nPrefers concise answers.'), + ), + ).toBe(true); + expect( + curatePayloads.some((payload) => + payload.includes('[Pre-compaction context]'), + ), + ).toBe(true); +});