import type { Plugin, Hooks } from '@opencode-ai/plugin';
import type { Message, Part, TextPart } from '@opencode-ai/sdk';
import type { EventSessionIdle } from '@opencode-ai/sdk';
export const SIDE_EFFECT_TOOLS = new Set([
'Write', 'Edit', 'MultiEdit', 'Bash', 'NotebookEdit',
]);
export const MAX_CHUNK = 10_000;
export const MAX_CONTEXT_CHARS = 9_500;
export const MIN_PROMPT_LEN = 4;
export const POST_TIMEOUT = 8_000;
export const COMPOSE_TIMEOUT = 13_000;
export const AFTER_TURN_TIMEOUT = 10_000;
export function getApiBaseUrl(): string {
const env = (process.env.OG_MEMORY_URL || '').trim();
if (env) return env.replace(/\/+$/, '');
return 'http://localhost:8090';
}
export function getIdentity(): { accountId: string; userId: string; agentId: string } {
return {
accountId: process.env.OG_MEMORY_ACCOUNT_ID || process.env.OG_ACCOUNT_ID || 'acct-demo',
userId: process.env.OG_MEMORY_USER_ID || process.env.OG_USER_ID || 'u-opencode',
agentId: process.env.OG_MEMORY_AGENT_ID || process.env.OG_AGENT_ID || 'opencode',
};
}
export function getApiKey(): string | null {
const v = (process.env.OG_AUTH_API_KEY || '').trim();
return v || null;
}
export function buildHeaders(): Record<string, string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const key = getApiKey();
if (key) {
const { accountId, userId } = getIdentity();
headers['X-API-Key'] = key;
headers['X-Account-ID'] = accountId;
headers['X-User-ID'] = userId;
}
return headers;
}
export function baseCtx(sessionId: string): Record<string, string> {
const { accountId, userId, agentId } = getIdentity();
return { accountId, userId, agentId, sessionId };
}
let _opencodeClient: ReturnType<typeof import('@opencode-ai/sdk').createOpencodeClient> | null = null;
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export function setOpencodeClient(client: typeof _opencodeClient): void {
_opencodeClient = client;
}
export function getOpencodeClient(): typeof _opencodeClient {
return _opencodeClient;
}
const LOG_LEVELS: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
export function getMinLogLevel(): LogLevel {
return (process.env.OG_LOG_LEVEL as LogLevel) || 'info';
}
export async function log(level: LogLevel, message: string, extra?: Record<string, unknown>): Promise<void> {
if (LOG_LEVELS[level] < LOG_LEVELS[getMinLogLevel()]) return;
const effectiveLevel: LogLevel = level === 'debug' ? 'info' : level;
if (_opencodeClient) {
try {
const body: { service: string; level: LogLevel; message: string; extra?: { [key: string]: unknown } } = {
service: 'oG-Memory', level: effectiveLevel, message,
};
if (extra) body.extra = extra;
await _opencodeClient.app.log({ body });
} catch {
console.error(`[oG-Memory] ${level}: ${message}`, extra ?? '');
}
} else {
console.error(`[oG-Memory] ${level}: ${message}`, extra ?? '');
}
}
export async function postJson(url: string, body: unknown, timeoutMs: number): Promise<unknown | null> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const res = await fetch(url, {
method: 'POST',
headers: buildHeaders(),
body: JSON.stringify(body),
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) {
await log('error', `HTTP ${res.status} from ${url}`);
return null;
}
const data = await res.json();
await log('debug', `Response ${res.status}: ${JSON.stringify(data).slice(0, 200)}`);
return data;
} catch (e) {
await log('error', `postJson error: ${e}`);
return null;
}
}
export function truncate(text: string, maxLen: number = MAX_CHUNK): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen) + `\n… [${text.length - maxLen} more chars omitted]`;
}
export function jsonStringify(obj: unknown): string {
return JSON.stringify(obj, (_, value) => {
if (typeof value === 'bigint' || value instanceof RegExp || value instanceof Date) return String(value);
return value;
});
}
export function jsonChunk(obj: unknown): string {
const raw = jsonStringify(obj);
return truncate(raw);
}
export function buildToolText(toolName: string, toolInput: unknown, toolOutput: unknown): string {
return [
`[PostToolUse] ${toolName}`,
`tool_input: ${jsonChunk(toolInput)}`,
`tool_response: ${jsonChunk(toolOutput)}`,
].join('\n');
}
export async function postSessionMessage(sessionId: string, role: string, content: string): Promise<boolean> {
const url = `${getApiBaseUrl()}/api/v1/sessions/${sessionId}/messages`;
const body = { ...baseCtx(sessionId), role, content };
await log('debug', `POST ${url} role=${role} content_len=${content.length}`);
const res = await postJson(url, body, POST_TIMEOUT);
if (res === null) {
await log('error', `Failed to post session message: session=${sessionId} role=${role}`);
return false;
}
return true;
}
const toolExecuteAfter: Hooks['tool.execute.after'] = async (input, output) => {
const sessionId = input.sessionID;
if (!sessionId) {
await log('debug', 'No sessionID, skip');
return;
}
const toolName = input.tool;
if (!SIDE_EFFECT_TOOLS.has(toolName)) {
await log('debug', `skip non-side-effect tool: ${toolName}`);
return;
}
const text = buildToolText(toolName, input.args, output.output);
if (!text.trim()) return;
try {
await postSessionMessage(sessionId, 'tool', text);
} catch (e) {
await log('error', `toolExecuteAfter error (logged only): ${e}`);
}
};
export function buildAdditionalContext(data: { identityContext?: string; retrievedEvidence?: string }): string | null {
const parts: string[] = [];
const identity = (data.identityContext || '').trim();
if (identity) parts.push(identity);
const evidence = (data.retrievedEvidence || '').trim();
if (evidence) parts.push(evidence);
if (parts.length === 0) return null;
let body = parts.join('\n\n');
if (body.length > MAX_CONTEXT_CHARS) {
body = body.slice(0, MAX_CONTEXT_CHARS);
}
return `[oG-Memory]\n${body}`;
}
export function extractPromptText(output: { message: { role: string }; parts: Array<{ type: string; text?: string }> }): string {
const textParts = output.parts.filter((p) => p.type === 'text' && p.text);
return textParts.map((p) => p.text!).join('\n').trim();
}
const chatMessage: Hooks['chat.message'] = async (input, output) => {
const sessionId = input.sessionID;
if (!sessionId) {
await log('debug', 'chat.message: no sessionID, skip');
return;
}
const prompt = extractPromptText(output);
if (prompt.length < MIN_PROMPT_LEN) {
await log('debug', `chat.message: prompt too short (${prompt.length} chars), skipping`);
return;
}
if (prompt.startsWith('/')) {
await log('debug', 'chat.message: slash command, skipping');
return;
}
const url = `${getApiBaseUrl()}/api/v1/compose`;
const body = { ...baseCtx(sessionId), prompt };
const data = await postJson(url, body, COMPOSE_TIMEOUT) as { identityContext?: string; retrievedEvidence?: string } | null;
if (data === null) return;
const additional = buildAdditionalContext(data);
if (additional === null) {
await log('info', 'chat.message: no relevant context returned');
return;
}
output.parts.push({
type: 'text',
text: additional,
synthetic: true,
} as any);
};
export interface SimpleMessage {
role: 'user' | 'assistant';
content: string;
}
export const sentCountMap = new Map<string, number>();
export function getSentCount(sessionId: string): number {
return sentCountMap.get(sessionId) ?? 0;
}
export function setSentCount(sessionId: string, count: number): void {
sentCountMap.set(sessionId, count);
}
export function messagesToSimple(
msgs: Array<{ info: Message; parts: Array<Part> }>,
): SimpleMessage[] {
const result: SimpleMessage[] = [];
for (const msg of msgs) {
const role = msg.info.role;
if (role !== 'user' && role !== 'assistant') continue;
if ('error' in msg.info && msg.info.error) continue;
const textParts = msg.parts
.filter((p): p is TextPart => p.type === 'text')
.map((p) => p.text);
const content = textParts.join('\n').trim();
if (content) result.push({ role, content });
}
return result;
}
export async function afterTurn(sessionId: string, hookEvent: string): Promise<void> {
const client = getOpencodeClient();
if (!client) {
await log('error', 'afterTurn: no opencode client, skip');
return;
}
try {
const res = await client.session.messages({
path: { id: sessionId },
});
if (!res.data) {
await log('error', 'afterTurn: failed to fetch messages');
return;
}
const allMessages = messagesToSimple(res.data as Array<{ info: Message; parts: Array<Part> }>);
const lastSent = getSentCount(sessionId);
const totalMsgs = allMessages.length;
if (totalMsgs <= lastSent) {
await log('debug', `afterTurn: no new content (sentCount=${lastSent}, total=${totalMsgs})`);
return;
}
const newMessages = allMessages.slice(lastSent);
if (newMessages.length === 0) {
await log('debug', 'afterTurn: no valid messages after filtering');
setSentCount(sessionId, totalMsgs);
return;
}
const url = `${getApiBaseUrl()}/api/v1/after_turn`;
const body = { ...baseCtx(sessionId), messages: newMessages, hook_event_name: hookEvent };
await log('debug', `POST ${url} session=${sessionId} msgs=${newMessages.length} event=${hookEvent}`);
const result = await postJson(url, body, AFTER_TURN_TIMEOUT);
if (result !== null) {
setSentCount(sessionId, totalMsgs);
await log('info', `afterTurn: sentCount updated to ${totalMsgs}`);
} else {
await log('error', 'afterTurn: ingest failed, sentCount NOT updated (will retry next hook)');
}
} catch (e) {
await log('error', `afterTurn error: ${e}`);
}
}
const onEvent: Hooks['event'] = async (input) => {
if (input.event.type === 'session.idle') {
const sessionId = (input.event as EventSessionIdle).properties.sessionID;
afterTurn(sessionId, 'Stop').catch((e) =>
log('error', `onEvent (session.idle) fire-and-forget error: ${e}`),
);
}
};
const sessionCompacting: Hooks['experimental.session.compacting'] = async (input) => {
const sessionId = input.sessionID;
if (!sessionId) {
await log('debug', 'sessionCompacting: no sessionID, skip');
return;
}
await afterTurn(sessionId, 'PreCompact');
};
const oGMemoryPlugin: Plugin = async (ctx) => {
setOpencodeClient(ctx.client);
return {
'tool.execute.after': toolExecuteAfter,
'chat.message': chatMessage,
event: onEvent,
'experimental.session.compacting': sessionCompacting,
};
};
export default { id: 'oG-Memory', server: oGMemoryPlugin };