@coffer-org/plugin-agent 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ declare const _default: import("@coffer-org/sdk/plugin").PluginManifest;
2
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,37 @@
1
+ import { definePlugin } from '@coffer-org/sdk/plugin';
2
+ import { defineSettings } from '@coffer-org/sdk/settings';
3
+ import { field } from '@coffer-org/sdk/fields';
4
+ export default definePlugin({
5
+ id: 'agent',
6
+ version: '1.0.0',
7
+ dependsOn: [],
8
+ settings: defineSettings({
9
+ label: 'agent.settings.label',
10
+ fields: [
11
+ field.select({
12
+ key: 'auth_mode',
13
+ label: 'agent.settings.auth_mode',
14
+ default: 'subscription',
15
+ options: [
16
+ { value: 'subscription', title: 'agent.settings.auth_mode_subscription' },
17
+ { value: 'api_key', title: 'agent.settings.auth_mode_api_key' },
18
+ ],
19
+ }),
20
+ field.password({
21
+ key: 'anthropic_api_key',
22
+ label: 'agent.settings.anthropic_api_key',
23
+ view: { hidden: { auth_mode: { $ne: 'api_key' } } },
24
+ }),
25
+ field.string({ key: 'claude_model', label: 'agent.settings.claude_model', default: 'haiku' }),
26
+ field.boolean({ key: 'skip_permissions', label: 'agent.settings.skip_permissions', default: true }),
27
+ field.int({ key: 'response_timeout', label: 'agent.settings.response_timeout', default: 600 }),
28
+ field.boolean({ key: 'rag_enabled', label: 'agent.settings.rag_enabled', default: true }),
29
+ field.int({ key: 'rag_top_k', label: 'agent.settings.rag_top_k', default: 5 }),
30
+ field.password({ key: 'openai_api_key', label: 'agent.settings.openai_api_key' }),
31
+ field.password({ key: 'langfuse_public_key', label: 'agent.settings.langfuse_public_key' }),
32
+ field.password({ key: 'langfuse_secret_key', label: 'agent.settings.langfuse_secret_key' }),
33
+ field.string({ key: 'langfuse_base_url', label: 'agent.settings.langfuse_base_url' }),
34
+ field.boolean({ key: 'tracing_enabled', label: 'agent.settings.tracing_enabled' }),
35
+ ],
36
+ }),
37
+ });
@@ -0,0 +1,25 @@
1
+ import type { Options, McpServerConfig } from '@anthropic-ai/claude-agent-sdk';
2
+ import type { AgentConfig } from './config.ts';
3
+ export declare function buildAgentEnv(cfg: Pick<AgentConfig, 'authMode' | 'anthropicApiKey' | 'claudeHomeDir'>): Record<string, string | undefined>;
4
+ export declare function buildQueryOptions(cfg: Pick<AgentConfig, 'claudeModel' | 'skipPermissions' | 'workspaceDir' | 'claudeHomeDir' | 'authMode' | 'anthropicApiKey'>, sessionId: string | null, ragServer: McpServerConfig | undefined, pluginServers?: Record<string, McpServerConfig>): Options & {
5
+ allowedTools: string[];
6
+ mcpServers: Record<string, McpServerConfig>;
7
+ };
8
+ export interface RunAgentOpts {
9
+ prompt: string;
10
+ sessionId: string | null;
11
+ onDelta?: (accumulated: string) => void;
12
+ }
13
+ export interface RunAgentResult {
14
+ text: string | null;
15
+ sessionId: string | null;
16
+ tokensIn: number | null;
17
+ tokensOut: number | null;
18
+ }
19
+ export declare function runAgent(opts: RunAgentOpts): Promise<RunAgentResult>;
20
+ export declare function fallbackMessages(language: unknown): {
21
+ timeout: string;
22
+ error: string;
23
+ };
24
+ export declare function responseLanguageInstruction(language: unknown): string | undefined;
25
+ export declare function buildSystemPrompt(language: unknown, ragEnabled: boolean, instructions?: string[]): string;
@@ -0,0 +1,144 @@
1
+ import { query } from '@anthropic-ai/claude-agent-sdk';
2
+ import { loadAgentConfig } from "./config.js";
3
+ import { makeCofferServer, makePluginServers } from "./coffer.js";
4
+ import { collectContributions } from "./contributions.js";
5
+ import { makeRagServer } from "./rag.js";
6
+ import { isTracing, flushTracing, langfuseFactory, TurnTracer } from "./tracing.js";
7
+ const log = {
8
+ warn: (m) => console.warn(`[agent] ${new Date().toISOString().slice(0, 19)} WARN ${m}`),
9
+ error: (m) => console.error(`[agent] ${new Date().toISOString().slice(0, 19)} ERROR ${m}`),
10
+ };
11
+ export function buildAgentEnv(cfg) {
12
+ const { ANTHROPIC_API_KEY: _k, ANTHROPIC_AUTH_TOKEN: _t, ...rest } = process.env;
13
+ const base = { ...rest, CLAUDE_CONFIG_DIR: cfg.claudeHomeDir };
14
+ if (cfg.authMode === 'api_key' && cfg.anthropicApiKey)
15
+ return { ...base, ANTHROPIC_API_KEY: cfg.anthropicApiKey };
16
+ return base;
17
+ }
18
+ export function buildQueryOptions(cfg, sessionId, ragServer, pluginServers = {}) {
19
+ const mcpServers = { coffer: makeCofferServer(), ...pluginServers };
20
+ if (ragServer)
21
+ mcpServers.rag = ragServer;
22
+ const allowedTools = ['mcp__coffer__*', 'WebSearch', 'WebFetch'];
23
+ if (ragServer)
24
+ allowedTools.push('mcp__rag__search_records');
25
+ for (const id of Object.keys(pluginServers))
26
+ allowedTools.push(`mcp__${id}__*`);
27
+ return {
28
+ model: cfg.claudeModel,
29
+ resume: sessionId ?? undefined,
30
+ cwd: cfg.workspaceDir,
31
+ mcpServers,
32
+ allowedTools,
33
+ disallowedTools: ['Bash', 'Edit', 'Write', 'Read', 'Glob', 'Grep'],
34
+ permissionMode: cfg.skipPermissions ? 'bypassPermissions' : 'default',
35
+ allowDangerouslySkipPermissions: cfg.skipPermissions,
36
+ settingSources: ['project'],
37
+ skills: 'all',
38
+ includePartialMessages: true,
39
+ env: buildAgentEnv(cfg),
40
+ };
41
+ }
42
+ export async function runAgent(opts) {
43
+ const cfg = loadAgentConfig({ dbSettings: await loadAgentSettings() });
44
+ const ragServer = cfg.ragEnabled && cfg.embeddingApiKey ? makeRagServer(cfg) : undefined;
45
+ const sys = await loadSystemSettings();
46
+ const fb = fallbackMessages(sys.language);
47
+ const ac = new AbortController();
48
+ const timer = setTimeout(() => { log.warn(`agent timeout after ${cfg.responseTimeout}s — aborting`); ac.abort(); }, cfg.responseTimeout * 1000);
49
+ let tracer;
50
+ try {
51
+ let text = '', streamed = '';
52
+ let newSessionId = opts.sessionId, tokensIn = null, tokensOut = null;
53
+ const contribs = await collectContributions();
54
+ const pluginServers = makePluginServers(contribs);
55
+ const sections = contribs
56
+ .filter((c) => c.instructions)
57
+ .map((c) => `## ${c.id}\n${c.instructions}`);
58
+ const qopts = buildQueryOptions(cfg, opts.sessionId, ragServer, pluginServers);
59
+ const systemPrompt = buildSystemPrompt(sys.language, Boolean(ragServer), sections);
60
+ tracer = isTracing() ? new TurnTracer(langfuseFactory, opts.prompt, opts.sessionId) : undefined;
61
+ for await (const msg of query({ prompt: opts.prompt, options: { ...qopts, abortController: ac, systemPrompt } })) {
62
+ const sev = msg;
63
+ if (sev.type === 'stream_event') {
64
+ const ev = sev.event;
65
+ if (ev?.type === 'content_block_delta' && ev.delta?.type === 'text_delta' && ev.delta.text) {
66
+ streamed += ev.delta.text;
67
+ opts.onDelta?.(streamed);
68
+ }
69
+ continue;
70
+ }
71
+ tracer?.onMessage(msg);
72
+ if (msg.type === 'assistant') {
73
+ for (const b of msg.message.content)
74
+ if (b.type === 'text')
75
+ text += b.text;
76
+ }
77
+ else if (msg.type === 'result') {
78
+ newSessionId = msg.session_id ?? newSessionId;
79
+ if (msg.subtype !== 'success')
80
+ log.error(`agent result subtype=${msg.subtype}`);
81
+ tokensIn = msg.usage?.input_tokens ?? null;
82
+ tokensOut = msg.usage?.output_tokens ?? null;
83
+ if (msg.subtype === 'success' && !text)
84
+ text = msg.result;
85
+ }
86
+ }
87
+ if (!text && streamed)
88
+ text = streamed;
89
+ return { text: text.trim() || null, sessionId: newSessionId, tokensIn, tokensOut };
90
+ }
91
+ catch (e) {
92
+ if (ac.signal.aborted) {
93
+ tracer?.fail('aborted: response timeout');
94
+ return { text: fb.timeout, sessionId: null, tokensIn: null, tokensOut: null };
95
+ }
96
+ const m = e instanceof Error ? e.message : String(e);
97
+ log.error(`agent error: ${m}`);
98
+ tracer?.fail(m);
99
+ return { text: fb.error, sessionId: null, tokensIn: null, tokensOut: null };
100
+ }
101
+ finally {
102
+ clearTimeout(timer);
103
+ if (tracer)
104
+ await flushTracing();
105
+ }
106
+ }
107
+ async function loadAgentSettings() {
108
+ const { getPluginSettings } = await import('@coffer-org/server/plugin-runtime');
109
+ return getPluginSettings('agent');
110
+ }
111
+ const LANG_NAME = { uk: 'Ukrainian', ru: 'Russian', en: 'English' };
112
+ const FALLBACKS = {
113
+ uk: { timeout: '⏱ відповідь не отримана (перевищено ліміт часу)', error: '⚠️ помилка обробки (деталі в логах сервера)' },
114
+ ru: { timeout: '⏱ ответ не получен (превышен лимит времени)', error: '⚠️ ошибка обработки (детали в логах сервера)' },
115
+ en: { timeout: '⏱ no response (timed out)', error: '⚠️ processing error (see server logs)' },
116
+ };
117
+ export function fallbackMessages(language) {
118
+ return (typeof language === 'string' && FALLBACKS[language]) || FALLBACKS.uk;
119
+ }
120
+ export function responseLanguageInstruction(language) {
121
+ const name = typeof language === 'string' ? LANG_NAME[language] : undefined;
122
+ return name
123
+ ? `Respond in ${name} by default, even when the user writes in another language — unless they explicitly ask you to reply in a different language.`
124
+ : undefined;
125
+ }
126
+ export function buildSystemPrompt(language, ragEnabled, instructions = []) {
127
+ const lines = [
128
+ "You are the user's personal home assistant. You have full access to their personal database, Coffer, which stores their household data across vaults: kitchen (food items, recipes), people, finance, health, devices, home, garden, documents, travel, and more.",
129
+ 'To answer any question about the user or their household, USE THE TOOLS to look up the data — never claim you lack access. The data is there; find it.',
130
+ ragEnabled
131
+ ? 'Start with mcp__rag__search_records for a semantic search (e.g. query "milk"). Use the mcp__coffer__ tools (list_vaults, list_records, get_record) to browse precisely or confirm quantities.'
132
+ : 'Use the mcp__coffer__ tools: call list_vaults to see what exists, then list_records / get_record to read the data.',
133
+ 'Record field values may be JSON-encoded (e.g. quantity {"value":2000,"unit":"ml"}) — parse and present them in plain language.',
134
+ ];
135
+ const lang = responseLanguageInstruction(language);
136
+ if (lang)
137
+ lines.push(lang);
138
+ const base = lines.join('\n');
139
+ return instructions.length ? `${base}\n\n${instructions.join('\n\n')}` : base;
140
+ }
141
+ async function loadSystemSettings() {
142
+ const { getPluginSettings } = await import('@coffer-org/server/plugin-runtime');
143
+ return getPluginSettings('core');
144
+ }
@@ -0,0 +1,5 @@
1
+ import type { McpServerConfig } from '@anthropic-ai/claude-agent-sdk';
2
+ import type { EntityManager } from '@mikro-orm/core';
3
+ import type { CollectedContribution } from './contributions.ts';
4
+ export declare function makeCofferServer(): McpServerConfig;
5
+ export declare function makePluginServers(contribs: CollectedContribution[], emFactory?: () => EntityManager): Record<string, McpServerConfig>;
@@ -0,0 +1,29 @@
1
+ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
2
+ import { buildTools } from '@coffer-org/mcp/tools';
3
+ import { SchemaCache } from '@coffer-org/mcp/schema';
4
+ import { getEm } from '@coffer-org/server/db';
5
+ import { LocalClient } from "./local-client.js";
6
+ export function makeCofferServer() {
7
+ const client = new LocalClient();
8
+ const cache = new SchemaCache(client);
9
+ const tools = buildTools(client, cache).map((t) => tool(t.name, t.description, t.inputSchema, async (args) => (await t.handler(args))));
10
+ return createSdkMcpServer({ name: 'coffer', version: '1.0.0', tools });
11
+ }
12
+ export function makePluginServers(contribs, emFactory = () => getEm().fork()) {
13
+ const servers = {};
14
+ for (const c of contribs) {
15
+ if (!c.tools.length)
16
+ continue;
17
+ const tools = c.tools.map((t) => tool(t.name, t.description, t.inputSchema, async (args) => {
18
+ try {
19
+ const data = await t.handler(args, { em: emFactory() });
20
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
21
+ }
22
+ catch (e) {
23
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
24
+ }
25
+ }));
26
+ servers[c.id] = createSdkMcpServer({ name: c.id, version: '1.0.0', tools });
27
+ }
28
+ return servers;
29
+ }
@@ -0,0 +1,29 @@
1
+ export declare const APP_ROOT: string;
2
+ export declare const PLUGIN_ROOT: string;
3
+ export type AuthMode = 'subscription' | 'api_key';
4
+ export interface AgentConfig {
5
+ authMode: AuthMode;
6
+ anthropicApiKey: string;
7
+ claudeModel: string;
8
+ skipPermissions: boolean;
9
+ responseTimeout: number;
10
+ ragEnabled: boolean;
11
+ ragTopK: number;
12
+ embeddingApiKey: string;
13
+ langfusePublicKey: string;
14
+ langfuseSecretKey: string;
15
+ langfuseBaseUrl: string;
16
+ tracingEnabled: boolean;
17
+ appRoot: string;
18
+ cofferUrl: string;
19
+ globalCredentials: string;
20
+ runtimeDir: string;
21
+ workspaceDir: string;
22
+ claudeHomeDir: string;
23
+ stateDir: string;
24
+ }
25
+ export interface LoadOpts {
26
+ env?: Record<string, string | undefined>;
27
+ dbSettings?: Record<string, unknown>;
28
+ }
29
+ export declare function loadAgentConfig(opts?: LoadOpts): AgentConfig;
@@ -0,0 +1,34 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
5
+ export const APP_ROOT = path.resolve(__dirname, '../../../..');
6
+ export const PLUGIN_ROOT = path.resolve(__dirname, '../..');
7
+ export function loadAgentConfig(opts = {}) {
8
+ const env = opts.env ?? process.env;
9
+ const db = (opts.dbSettings ?? {});
10
+ const runtimeDir = path.join(PLUGIN_ROOT, 'runtime');
11
+ return {
12
+ authMode: (env.AGENT_AUTH_MODE ?? db.auth_mode ?? 'subscription') === 'api_key' ? 'api_key' : 'subscription',
13
+ anthropicApiKey: env.AGENT_ANTHROPIC_API_KEY ?? db.anthropic_api_key ?? env.ANTHROPIC_API_KEY ?? '',
14
+ claudeModel: env.AGENT_CLAUDE_MODEL ?? db.claude_model ?? 'haiku',
15
+ skipPermissions: db.skip_permissions !== false,
16
+ responseTimeout: Math.max(60, Number(env.AGENT_RESPONSE_TIMEOUT ?? db.response_timeout ?? 600) || 600),
17
+ ragEnabled: db.rag_enabled !== false,
18
+ ragTopK: Number(env.AGENT_RAG_TOP_K ?? db.rag_top_k ?? 5) || 5,
19
+ embeddingApiKey: env.OPENAI_API_KEY ?? db.openai_api_key ?? '',
20
+ langfusePublicKey: env.LANGFUSE_PUBLIC_KEY ?? db.langfuse_public_key ?? '',
21
+ langfuseSecretKey: env.LANGFUSE_SECRET_KEY ?? db.langfuse_secret_key ?? '',
22
+ langfuseBaseUrl: env.LANGFUSE_BASE_URL ?? db.langfuse_base_url ?? 'https://cloud.langfuse.com',
23
+ tracingEnabled: db.tracing_enabled === false
24
+ ? false
25
+ : Boolean((env.LANGFUSE_PUBLIC_KEY ?? db.langfuse_public_key) && (env.LANGFUSE_SECRET_KEY ?? db.langfuse_secret_key)),
26
+ appRoot: APP_ROOT,
27
+ cofferUrl: env.COFFER_URL ?? db.coffer_url ?? 'http://localhost:7023',
28
+ globalCredentials: env.CLAUDE_GLOBAL_CREDENTIALS ?? path.join(os.homedir(), '.claude', '.credentials.json'),
29
+ runtimeDir,
30
+ workspaceDir: path.join(runtimeDir, 'workspace'),
31
+ claudeHomeDir: path.join(runtimeDir, 'claude-home'),
32
+ stateDir: path.join(runtimeDir, 'state'),
33
+ };
34
+ }
@@ -0,0 +1,8 @@
1
+ import type { EntityManager } from '@mikro-orm/core';
2
+ import type { AgentTool, PluginHooks } from '@coffer-org/server/plugin-hooks';
3
+ export interface CollectedContribution {
4
+ id: string;
5
+ instructions: string | null;
6
+ tools: AgentTool[];
7
+ }
8
+ export declare function collectContributions(hooks?: Record<string, PluginHooks>, emFactory?: () => EntityManager): Promise<CollectedContribution[]>;
@@ -0,0 +1,23 @@
1
+ import { pluginHooks } from '@coffer-org/server/plugin-hooks';
2
+ import { getEm } from '@coffer-org/server/db';
3
+ const log = { warn: (m) => console.warn(`[agent] ${m}`) };
4
+ export async function collectContributions(hooks = pluginHooks, emFactory = () => getEm().fork()) {
5
+ const out = [];
6
+ for (const [id, h] of Object.entries(hooks)) {
7
+ const a = h.agent;
8
+ if (!a)
9
+ continue;
10
+ let instructions = null;
11
+ if (a.instructions != null) {
12
+ try {
13
+ instructions =
14
+ typeof a.instructions === 'function' ? await a.instructions({ em: emFactory() }) : a.instructions;
15
+ }
16
+ catch (e) {
17
+ log.warn(`contribution ${id}: instructions skipped — ${e.message}`);
18
+ }
19
+ }
20
+ out.push({ id, instructions, tools: a.tools ?? [] });
21
+ }
22
+ return out;
23
+ }
@@ -0,0 +1,10 @@
1
+ export declare const EMBED_MODEL = "text-embedding-3-small";
2
+ export declare const EMBED_DIM = 1024;
3
+ export declare function embedBatch(texts: string[], apiKey: string, fetchImpl?: typeof fetch): Promise<{
4
+ vectors: number[][];
5
+ tokens: number;
6
+ }>;
7
+ export declare function embedOne(text: string, apiKey: string, fetchImpl?: typeof fetch): Promise<{
8
+ vector: number[];
9
+ tokens: number;
10
+ }>;
@@ -0,0 +1,28 @@
1
+ const OPENAI_URL = 'https://api.openai.com/v1/embeddings';
2
+ export const EMBED_MODEL = 'text-embedding-3-small';
3
+ export const EMBED_DIM = 1024;
4
+ export async function embedBatch(texts, apiKey, fetchImpl = fetch) {
5
+ if (texts.length === 0)
6
+ return { vectors: [], tokens: 0 };
7
+ if (!apiKey)
8
+ throw new Error('embeddings: missing API key (OPENAI_API_KEY)');
9
+ const res = await fetchImpl(OPENAI_URL, {
10
+ method: 'POST',
11
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
12
+ body: JSON.stringify({ model: EMBED_MODEL, input: texts, dimensions: EMBED_DIM }),
13
+ });
14
+ if (!res.ok) {
15
+ const body = await res.text().catch(() => '');
16
+ throw new Error(`openai embeddings ${res.status}: ${body.slice(0, 200)}`);
17
+ }
18
+ const json = (await res.json());
19
+ const vectors = [...json.data].sort((a, b) => a.index - b.index).map((d) => d.embedding);
20
+ return { vectors, tokens: json.usage?.total_tokens ?? 0 };
21
+ }
22
+ export async function embedOne(text, apiKey, fetchImpl = fetch) {
23
+ const { vectors, tokens } = await embedBatch([text], apiKey, fetchImpl);
24
+ const vector = vectors[0];
25
+ if (!vector)
26
+ throw new Error('embeddings: empty response');
27
+ return { vector, tokens };
28
+ }
@@ -0,0 +1,4 @@
1
+ import type { PluginHooks } from '@coffer-org/server/plugin-hooks';
2
+ export { runAgent } from './agent.ts';
3
+ export type { RunAgentOpts, RunAgentResult } from './agent.ts';
4
+ export declare const serverHooks: PluginHooks;
@@ -0,0 +1,31 @@
1
+ import { getPluginSettings } from '@coffer-org/server/plugin-runtime';
2
+ import { loadAgentConfig } from "./config.js";
3
+ import { ensureWorkspace } from "./workspace.js";
4
+ import { indexOnce } from "./indexer.js";
5
+ import { initTracing, flushTracing, isTracing } from "./tracing.js";
6
+ export { runAgent } from "./agent.js";
7
+ const log = { warn: (m) => console.warn(`[agent] ${m}`) };
8
+ let indexTimer;
9
+ export const serverHooks = {
10
+ init: async () => {
11
+ const cfg = loadAgentConfig({ dbSettings: await getPluginSettings('agent') });
12
+ initTracing(cfg);
13
+ console.log(isTracing()
14
+ ? `[plugin] agent: Langfuse tracing ON → ${cfg.langfuseBaseUrl}`
15
+ : `[plugin] agent: Langfuse tracing OFF (tracing_enabled=${cfg.tracingEnabled}, keys=${cfg.langfusePublicKey && cfg.langfuseSecretKey ? 'set' : 'missing'})`);
16
+ ensureWorkspace(cfg, console);
17
+ if (cfg.ragEnabled && cfg.embeddingApiKey) {
18
+ const run = () => indexOnce({ apiKey: cfg.embeddingApiKey }).catch((e) => log.warn(`indexer: ${e instanceof Error ? e.message : String(e)}`));
19
+ void run();
20
+ indexTimer = setInterval(() => void run(), 60_000);
21
+ if (typeof indexTimer.unref === 'function')
22
+ indexTimer.unref();
23
+ }
24
+ console.log('[plugin] agent: init ✓');
25
+ },
26
+ teardown: async () => {
27
+ clearInterval(indexTimer);
28
+ indexTimer = undefined;
29
+ await flushTracing();
30
+ },
31
+ };
@@ -0,0 +1,7 @@
1
+ import { embedBatch } from './embed.ts';
2
+ export declare function buildSnippet(after: Record<string, unknown>, type?: string): string;
3
+ export declare function indexOnce(opts: {
4
+ apiKey: string;
5
+ batch?: number;
6
+ embed?: typeof embedBatch;
7
+ }): Promise<number>;
@@ -0,0 +1,110 @@
1
+ import { listEventsSince, upsertEmbedding, deleteEmbedding } from '@coffer-org/server/embeddings';
2
+ import { getPluginState, setPluginState } from '@coffer-org/server/plugin-state';
3
+ import { embedBatch, EMBED_MODEL } from "./embed.js";
4
+ import { isTracing, flushTracing, langfuseFactory } from "./tracing.js";
5
+ const PLUGIN = 'agent';
6
+ const STATE_KEY = 'last_event_id';
7
+ const VERSION_KEY = 'snippet_version';
8
+ const SNIPPET_VERSION = '2';
9
+ const MAX_SNIPPET = 1000;
10
+ const MAX_DEPTH = 2;
11
+ const SKIP_FIELDS = new Set(['id', 'created_at', 'updated_at']);
12
+ function flattenScalars(prefix, v, out, depth) {
13
+ if (v == null)
14
+ return;
15
+ if (typeof v === 'string') {
16
+ if (v.trim())
17
+ out.push(`${prefix}: ${v}`);
18
+ return;
19
+ }
20
+ if (typeof v === 'number' || typeof v === 'boolean') {
21
+ out.push(`${prefix}: ${String(v)}`);
22
+ return;
23
+ }
24
+ if (depth <= 0)
25
+ return;
26
+ if (Array.isArray(v)) {
27
+ v.forEach((item, i) => flattenScalars(`${prefix}[${i}]`, item, out, depth - 1));
28
+ return;
29
+ }
30
+ if (typeof v === 'object') {
31
+ for (const [k, val] of Object.entries(v)) {
32
+ flattenScalars(`${prefix}.${k}`, val, out, depth - 1);
33
+ }
34
+ }
35
+ }
36
+ export function buildSnippet(after, type) {
37
+ const parts = [];
38
+ if (type)
39
+ parts.push(type.replace('/', ' / '));
40
+ for (const [k, v] of Object.entries(after)) {
41
+ if (SKIP_FIELDS.has(k))
42
+ continue;
43
+ flattenScalars(k, v, parts, MAX_DEPTH);
44
+ }
45
+ return parts.join('\n').slice(0, MAX_SNIPPET);
46
+ }
47
+ function parseRef(type, recordId) {
48
+ const [vault, module] = type.split('/');
49
+ if (!vault || !module)
50
+ return null;
51
+ return { vault, module, recordId };
52
+ }
53
+ export async function indexOnce(opts) {
54
+ const embed = opts.embed ?? embedBatch;
55
+ const batch = opts.batch ?? 64;
56
+ if ((await getPluginState(PLUGIN, VERSION_KEY)) !== SNIPPET_VERSION) {
57
+ await setPluginState(PLUGIN, STATE_KEY, '0');
58
+ await setPluginState(PLUGIN, VERSION_KEY, SNIPPET_VERSION);
59
+ }
60
+ const last = Number((await getPluginState(PLUGIN, STATE_KEY)) ?? '0');
61
+ const rows = await listEventsSince(last, 5000);
62
+ if (rows.length === 0)
63
+ return 0;
64
+ const byRef = new Map();
65
+ for (const r of rows) {
66
+ if (r.record_id == null)
67
+ continue;
68
+ const ref = parseRef(r.type, r.record_id);
69
+ if (!ref)
70
+ continue;
71
+ const key = `${ref.vault}/${ref.module}/${ref.recordId}`;
72
+ if (r.op === 'delete') {
73
+ byRef.set(key, { ref, op: 'delete', type: r.type, snippet: '' });
74
+ }
75
+ else if (r.after) {
76
+ const after = JSON.parse(r.after);
77
+ byRef.set(key, { ref, op: 'upsert', type: r.type, snippet: buildSnippet(after, r.type) });
78
+ }
79
+ }
80
+ const upserts = [...byRef.values()].filter((p) => p.op === 'upsert' && p.snippet.trim());
81
+ const deletes = [...byRef.values()].filter((p) => p.op === 'delete');
82
+ for (const d of deletes)
83
+ await deleteEmbedding(d.type, d.ref.recordId);
84
+ const root = isTracing()
85
+ ? langfuseFactory.startObservation('indexer-run', { metadata: { events: rows.length, upserts: upserts.length } }, { asType: 'span' })
86
+ : undefined;
87
+ try {
88
+ for (let i = 0; i < upserts.length; i += batch) {
89
+ const slice = upserts.slice(i, i + batch);
90
+ const gen = root?.startObservation('embed-batch', { model: EMBED_MODEL, metadata: { count: slice.length } }, { asType: 'generation' });
91
+ const { vectors, tokens } = await embed(slice.map((p) => p.snippet), opts.apiKey);
92
+ gen?.update({ usageDetails: { total: tokens }, output: { count: vectors.length } }).end();
93
+ for (let j = 0; j < slice.length; j++) {
94
+ const p = slice[j];
95
+ const vec = vectors[j];
96
+ if (vec)
97
+ await upsertEmbedding({ type: p.type, recordId: p.ref.recordId, snippet: p.snippet, vector: vec, model: EMBED_MODEL });
98
+ }
99
+ }
100
+ }
101
+ finally {
102
+ root?.end();
103
+ if (root)
104
+ await flushTracing();
105
+ }
106
+ const lastRow = rows[rows.length - 1];
107
+ if (lastRow)
108
+ await setPluginState(PLUGIN, STATE_KEY, String(lastRow.id));
109
+ return rows.length;
110
+ }
@@ -0,0 +1,10 @@
1
+ import type { CofferClientApi } from '@coffer-org/mcp/client';
2
+ export declare function mapError(e: unknown): never;
3
+ export declare class LocalClient implements CofferClientApi {
4
+ getSchema(): Promise<unknown>;
5
+ listRecords(vault: string, type: string, query?: Record<string, unknown>): Promise<unknown>;
6
+ getRecord(vault: string, type: string, id: number): Promise<unknown>;
7
+ createRecord(vault: string, type: string, fields: Record<string, unknown>): Promise<unknown>;
8
+ updateRecord(vault: string, type: string, id: number, fields: Record<string, unknown>): Promise<unknown>;
9
+ deleteRecord(vault: string, type: string, id: number): Promise<unknown>;
10
+ }
@@ -0,0 +1,57 @@
1
+ import { ValidationError, NotFoundError } from '@coffer-org/mcp/client';
2
+ import { recordList, recordGet, recordCreate, recordUpdate, recordDelete, UnknownTypeError, } from '@coffer-org/server/records-api';
3
+ import { ValidationError as ServerValidationError } from '@coffer-org/server/mutate';
4
+ import { buildClientSchema } from '@coffer-org/server/schema-api';
5
+ export function mapError(e) {
6
+ if (e instanceof ServerValidationError)
7
+ throw new ValidationError(e.issues ?? []);
8
+ if (e instanceof UnknownTypeError)
9
+ throw new NotFoundError('not_found');
10
+ throw e;
11
+ }
12
+ export class LocalClient {
13
+ async getSchema() {
14
+ return buildClientSchema();
15
+ }
16
+ async listRecords(vault, type, query = {}) {
17
+ try {
18
+ return await recordList(vault, type, query);
19
+ }
20
+ catch (e) {
21
+ mapError(e);
22
+ }
23
+ }
24
+ async getRecord(vault, type, id) {
25
+ try {
26
+ return await recordGet(vault, type, id);
27
+ }
28
+ catch (e) {
29
+ mapError(e);
30
+ }
31
+ }
32
+ async createRecord(vault, type, fields) {
33
+ try {
34
+ return await recordCreate(vault, type, fields);
35
+ }
36
+ catch (e) {
37
+ mapError(e);
38
+ }
39
+ }
40
+ async updateRecord(vault, type, id, fields) {
41
+ try {
42
+ return await recordUpdate(vault, type, id, fields);
43
+ }
44
+ catch (e) {
45
+ mapError(e);
46
+ }
47
+ }
48
+ async deleteRecord(vault, type, id) {
49
+ try {
50
+ await recordDelete(vault, type, id);
51
+ return null;
52
+ }
53
+ catch (e) {
54
+ mapError(e);
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,7 @@
1
+ import type { McpServerConfig } from '@anthropic-ai/claude-agent-sdk';
2
+ import type { EmbeddingHit } from '@coffer-org/server/embeddings';
3
+ export declare function formatHits(hits: EmbeddingHit[]): string;
4
+ export declare function makeRagServer(cfg: {
5
+ embeddingApiKey: string;
6
+ ragTopK: number;
7
+ }): McpServerConfig;
@@ -0,0 +1,38 @@
1
+ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
2
+ import { z } from 'zod';
3
+ import { searchEmbeddings } from '@coffer-org/server/embeddings';
4
+ import { embedOne, EMBED_MODEL } from "./embed.js";
5
+ import { isTracing, flushTracing, langfuseFactory } from "./tracing.js";
6
+ export function formatHits(hits) {
7
+ if (hits.length === 0)
8
+ return 'No matching records.';
9
+ return hits
10
+ .map((h) => `[${h.type}/${h.recordId}] (dist ${h.distance.toFixed(3)})\n${h.snippet}`)
11
+ .join('\n\n');
12
+ }
13
+ export function makeRagServer(cfg) {
14
+ return createSdkMcpServer({
15
+ name: 'rag',
16
+ version: '1.0.0',
17
+ tools: [
18
+ tool('search_records', "Semantic search over the user's coffer records. Returns the most relevant records as type/id refs with a text snippet.", { query: z.string(), k: z.number().int().positive().optional() }, async (args) => {
19
+ const gen = isTracing()
20
+ ? langfuseFactory.startObservation('embed-query', { model: EMBED_MODEL, input: args.query }, { asType: 'generation' })
21
+ : undefined;
22
+ let vector;
23
+ try {
24
+ const r = await embedOne(args.query, cfg.embeddingApiKey);
25
+ vector = r.vector;
26
+ gen?.update({ usageDetails: { total: r.tokens } });
27
+ }
28
+ finally {
29
+ gen?.end();
30
+ if (gen)
31
+ await flushTracing();
32
+ }
33
+ const hits = await searchEmbeddings(vector, args.k ?? cfg.ragTopK);
34
+ return { content: [{ type: 'text', text: formatHits(hits) }] };
35
+ }),
36
+ ],
37
+ });
38
+ }
@@ -0,0 +1,73 @@
1
+ export interface TracingConfig {
2
+ tracingEnabled: boolean;
3
+ langfusePublicKey: string;
4
+ langfuseSecretKey: string;
5
+ langfuseBaseUrl: string;
6
+ }
7
+ export interface Obs {
8
+ startObservation(name: string, attrs?: Record<string, unknown>, opts?: {
9
+ asType?: string;
10
+ }): Obs;
11
+ update(attrs: Record<string, unknown>): Obs;
12
+ updateTrace(attrs: Record<string, unknown>): Obs;
13
+ end(): void;
14
+ }
15
+ export interface ObsFactory {
16
+ startObservation(name: string, attrs?: Record<string, unknown>, opts?: {
17
+ asType?: string;
18
+ }): Obs;
19
+ }
20
+ export declare function shouldEnable(cfg: TracingConfig): boolean;
21
+ export declare function initTracing(cfg: TracingConfig): void;
22
+ export declare function isTracing(): boolean;
23
+ export declare function flushTracing(): Promise<void>;
24
+ export declare const langfuseFactory: ObsFactory;
25
+ type ContentBlock = {
26
+ type: 'text';
27
+ text: string;
28
+ } | {
29
+ type: 'tool_use';
30
+ id: string;
31
+ name: string;
32
+ input: unknown;
33
+ } | {
34
+ type: 'tool_result';
35
+ tool_use_id: string;
36
+ content: unknown;
37
+ } | {
38
+ type: string;
39
+ [k: string]: unknown;
40
+ };
41
+ interface Usage {
42
+ input_tokens?: number;
43
+ output_tokens?: number;
44
+ cache_read_input_tokens?: number;
45
+ cache_creation_input_tokens?: number;
46
+ }
47
+ export type AgentMsg = {
48
+ type: 'assistant';
49
+ message: {
50
+ model?: string;
51
+ content: ContentBlock[];
52
+ usage?: Usage;
53
+ };
54
+ } | {
55
+ type: 'user';
56
+ message: {
57
+ content: ContentBlock[];
58
+ };
59
+ } | {
60
+ type: 'result';
61
+ subtype: string;
62
+ session_id?: string;
63
+ usage?: Usage;
64
+ result?: string;
65
+ };
66
+ export declare class TurnTracer {
67
+ private root;
68
+ private tools;
69
+ constructor(factory: ObsFactory, prompt: string, resumeSessionId: string | null);
70
+ onMessage(msg: AgentMsg): void;
71
+ fail(message: string): void;
72
+ }
73
+ export {};
@@ -0,0 +1,114 @@
1
+ import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
2
+ import { LangfuseSpanProcessor } from '@langfuse/otel';
3
+ import { startObservation } from '@langfuse/tracing';
4
+ export function shouldEnable(cfg) {
5
+ return Boolean(cfg.tracingEnabled && cfg.langfusePublicKey && cfg.langfuseSecretKey);
6
+ }
7
+ let provider;
8
+ let processor;
9
+ let enabled = false;
10
+ export function initTracing(cfg) {
11
+ if (provider)
12
+ return;
13
+ if (!shouldEnable(cfg))
14
+ return;
15
+ processor = new LangfuseSpanProcessor({
16
+ publicKey: cfg.langfusePublicKey,
17
+ secretKey: cfg.langfuseSecretKey,
18
+ baseUrl: cfg.langfuseBaseUrl,
19
+ });
20
+ provider = new NodeTracerProvider({ spanProcessors: [processor] });
21
+ provider.register();
22
+ enabled = true;
23
+ }
24
+ export function isTracing() {
25
+ return enabled;
26
+ }
27
+ export async function flushTracing() {
28
+ if (processor)
29
+ await processor.forceFlush();
30
+ }
31
+ export const langfuseFactory = {
32
+ startObservation: (name, attrs, opts) => startObservation(name, attrs, opts),
33
+ };
34
+ function usageDetails(u) {
35
+ if (!u)
36
+ return undefined;
37
+ const d = {};
38
+ if (u.input_tokens != null)
39
+ d.input = u.input_tokens;
40
+ if (u.output_tokens != null)
41
+ d.output = u.output_tokens;
42
+ if (u.cache_read_input_tokens != null)
43
+ d.cache_read_input_tokens = u.cache_read_input_tokens;
44
+ if (u.cache_creation_input_tokens != null)
45
+ d.cache_creation_input_tokens = u.cache_creation_input_tokens;
46
+ return Object.keys(d).length ? d : undefined;
47
+ }
48
+ export class TurnTracer {
49
+ root;
50
+ tools = new Map();
51
+ constructor(factory, prompt, resumeSessionId) {
52
+ this.root = factory.startObservation('agent-turn', { input: prompt, metadata: { resumeSessionId } }, { asType: 'agent' });
53
+ if (resumeSessionId)
54
+ this.root.updateTrace({ sessionId: resumeSessionId });
55
+ }
56
+ onMessage(msg) {
57
+ if (msg.type === 'assistant') {
58
+ let text = '';
59
+ const toolUses = [];
60
+ for (const b of msg.message.content) {
61
+ if (b.type === 'text')
62
+ text += b.text;
63
+ else if (b.type === 'tool_use')
64
+ toolUses.push(b);
65
+ }
66
+ const gen = this.root.startObservation('llm-call', { model: msg.message.model, output: { text, toolUses } }, { asType: 'generation' });
67
+ const ud = usageDetails(msg.message.usage);
68
+ if (ud)
69
+ gen.update({ usageDetails: ud });
70
+ gen.end();
71
+ for (const tu of toolUses) {
72
+ const t = tu;
73
+ const obs = this.root.startObservation(t.name, { input: t.input }, { asType: 'tool' });
74
+ this.tools.set(t.id, obs);
75
+ }
76
+ return;
77
+ }
78
+ if (msg.type === 'user') {
79
+ for (const b of msg.message.content) {
80
+ if (b.type === 'tool_result') {
81
+ const r = b;
82
+ const obs = this.tools.get(r.tool_use_id);
83
+ if (obs) {
84
+ obs.update({ output: r.content }).end();
85
+ this.tools.delete(r.tool_use_id);
86
+ }
87
+ }
88
+ }
89
+ return;
90
+ }
91
+ if (msg.type === 'result') {
92
+ for (const obs of this.tools.values())
93
+ obs.end();
94
+ this.tools.clear();
95
+ if (msg.session_id)
96
+ this.root.updateTrace({ sessionId: msg.session_id });
97
+ const out = { output: msg.result ?? null };
98
+ const ud = usageDetails(msg.usage);
99
+ if (ud)
100
+ out.usageDetails = ud;
101
+ if (msg.subtype !== 'success') {
102
+ out.level = 'ERROR';
103
+ out.statusMessage = `result subtype=${msg.subtype}`;
104
+ }
105
+ this.root.update(out).end();
106
+ }
107
+ }
108
+ fail(message) {
109
+ for (const obs of this.tools.values())
110
+ obs.end();
111
+ this.tools.clear();
112
+ this.root.update({ level: 'ERROR', statusMessage: message }).end();
113
+ }
114
+ }
@@ -0,0 +1,7 @@
1
+ import type { AgentConfig } from './config.ts';
2
+ interface WarnLog {
3
+ warn?: (m: string) => void;
4
+ }
5
+ export declare function linkCredentials(cfg: Pick<AgentConfig, 'claudeHomeDir' | 'globalCredentials'>, log?: WarnLog): boolean;
6
+ export declare function ensureWorkspace(cfg: Pick<AgentConfig, 'appRoot' | 'workspaceDir' | 'claudeHomeDir' | 'stateDir' | 'globalCredentials'>, log?: WarnLog): void;
7
+ export {};
@@ -0,0 +1,29 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export function linkCredentials(cfg, log = console) {
4
+ const target = path.join(cfg.claudeHomeDir, '.credentials.json');
5
+ fs.rmSync(target, { force: true });
6
+ if (fs.existsSync(cfg.globalCredentials)) {
7
+ fs.symlinkSync(cfg.globalCredentials, target);
8
+ return true;
9
+ }
10
+ log.warn?.(`[agent] global credentials not found (${cfg.globalCredentials}) — claude will be "Not logged in"`);
11
+ return false;
12
+ }
13
+ export function ensureWorkspace(cfg, log = console) {
14
+ fs.mkdirSync(path.join(cfg.stateDir, '.sessions'), { recursive: true });
15
+ fs.mkdirSync(path.join(cfg.workspaceDir, '.claude', 'skills'), { recursive: true });
16
+ fs.mkdirSync(cfg.claudeHomeDir, { recursive: true });
17
+ const mcp = { mcpServers: {} };
18
+ fs.writeFileSync(path.join(cfg.workspaceDir, '.mcp.json'), JSON.stringify(mcp, null, 2) + '\n', 'utf-8');
19
+ const appGlob = `//${cfg.appRoot.replace(/^\/+/, '')}/**`;
20
+ const settings = {
21
+ permissions: {
22
+ allow: ['mcp__coffer__*', 'WebSearch', 'WebFetch', 'Skill'],
23
+ deny: ['Bash', 'Edit', 'Write', `Read(${appGlob})`, `Glob(${appGlob})`, `Grep(${appGlob})`],
24
+ },
25
+ enableAllProjectMcpServers: false,
26
+ };
27
+ fs.writeFileSync(path.join(cfg.claudeHomeDir, 'settings.json'), JSON.stringify(settings, null, 2) + '\n', 'utf-8');
28
+ linkCredentials(cfg, log);
29
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@coffer-org/plugin-agent",
3
+ "version": "1.2.0",
4
+ "type": "module",
5
+ "engines": {
6
+ "node": ">=24"
7
+ },
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "exports": {
12
+ ".": "./src/index.ts",
13
+ "./runtime": "./src/runtime/index.ts"
14
+ },
15
+ "distExports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./runtime": {
21
+ "types": "./dist/runtime/index.d.ts",
22
+ "default": "./dist/runtime/index.js"
23
+ }
24
+ },
25
+ "scripts": {
26
+ "build": "tsc -b tsconfig.build.json",
27
+ "test": "node --import tsx --test \"src/runtime/*.test.ts\""
28
+ },
29
+ "dependencies": {
30
+ "@anthropic-ai/claude-agent-sdk": "^0.3.191",
31
+ "@coffer-org/mcp": "^1.2.0",
32
+ "@coffer-org/sdk": "^1.2.0",
33
+ "@coffer-org/server": "^1.2.0",
34
+ "@langfuse/otel": "^4.6.1",
35
+ "@langfuse/tracing": "^4.6.1",
36
+ "@opentelemetry/sdk-trace-node": "^2.8.0",
37
+ "zod": "^4.4.0"
38
+ }
39
+ }