@coffer-org/plugin-orchestrator 2.2.0 → 2.3.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.
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ export default definePlugin({
8
8
  settings: defineSettings({
9
9
  label: 'orchestrator.settings.label',
10
10
  fields: [
11
+ field.select({ key: 'agent_id', label: 'orchestrator.settings.agent_id' }),
11
12
  field.password({ key: 'access_password', label: 'orchestrator.settings.access_password' }),
12
13
  field.string({ key: 'trigger_prefix', label: 'orchestrator.settings.trigger_prefix' }),
13
14
  field.int({ key: 'reply_window', label: 'orchestrator.settings.reply_window', default: 1800 }),
@@ -0,0 +1,2 @@
1
+ import type { AgentToolProvider, AttachmentRef } from './types.ts';
2
+ export declare function makeAttachmentCapabilities(attachments: AttachmentRef[]): Promise<AgentToolProvider>;
@@ -0,0 +1,131 @@
1
+ import { collectMcpTools } from '@coffer-org/server/mcp-tools';
2
+ import { LocalClient } from '@coffer-org/server/mcp-local';
3
+ const ok = (data) => ({
4
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
5
+ });
6
+ const fail = (text) => ({ content: [{ type: 'text', text }], isError: true });
7
+ function textOf(result) {
8
+ const block = result.content.find((b) => b.type === 'text');
9
+ return block?.type === 'text' ? block.text : '';
10
+ }
11
+ function resultData(result) {
12
+ try {
13
+ return JSON.parse(textOf(result));
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ }
19
+ function fieldType(items, key) {
20
+ for (const item of items) {
21
+ if (!item || typeof item !== 'object')
22
+ continue;
23
+ const raw = item;
24
+ if (raw.key === key && raw.type)
25
+ return raw.type;
26
+ if (raw.kind === 'group' && Array.isArray(raw.fields)) {
27
+ const nested = fieldType(raw.fields, key);
28
+ if (nested)
29
+ return nested;
30
+ }
31
+ }
32
+ return undefined;
33
+ }
34
+ async function findFileField(library, shelf, key) {
35
+ const schema = (await new LocalClient().getSchema());
36
+ const lib = schema.find((item) => item.id === library);
37
+ const sh = lib?.shelves?.find((item) => item.shelf === shelf);
38
+ return sh?.fields ? fieldType(sh.fields, key) : undefined;
39
+ }
40
+ function parseStoredValue(value) {
41
+ if (typeof value !== 'string')
42
+ return value;
43
+ try {
44
+ return JSON.parse(value);
45
+ }
46
+ catch {
47
+ return value;
48
+ }
49
+ }
50
+ function fileItems(value) {
51
+ const parsed = parseStoredValue(value);
52
+ if (Array.isArray(parsed))
53
+ return parsed.filter((item) => !!item && typeof item === 'object');
54
+ return parsed && typeof parsed === 'object' ? [parsed] : [];
55
+ }
56
+ function findTool(defs, name) {
57
+ return defs.find((def) => def.server === 'coffer' && def.bareName === name);
58
+ }
59
+ export async function makeAttachmentCapabilities(attachments) {
60
+ if (!attachments.length)
61
+ return { tools: [] };
62
+ const defs = await collectMcpTools();
63
+ const getRecord = findTool(defs, 'get_record');
64
+ const updateRecord = findTool(defs, 'update_record');
65
+ if (!getRecord || !updateRecord)
66
+ return { tools: [] };
67
+ const attach = {
68
+ name: 'mcp__coffer__attach_conversation_attachments',
69
+ description: 'Attach files already received in the current conversation to a file/image/document field in a Coffer record. ' +
70
+ 'Use attachment_names for selected files, or omit it to attach every current-conversation attachment. ' +
71
+ 'The default mode appends to a multiple field; use mode="replace" only when explicitly requested. ' +
72
+ 'This is the unified attachment tool for every connector — never use a local path or invent an upload name.',
73
+ inputSchema: {
74
+ library: { type: 'string', description: 'Library identifier, e.g. things' },
75
+ shelf: { type: 'string', description: 'Shelf identifier, e.g. item' },
76
+ id: { type: 'integer', description: 'Record id' },
77
+ field: { type: 'string', description: 'File/image/document field key' },
78
+ attachment_names: {
79
+ type: 'array',
80
+ items: { type: 'string' },
81
+ description: 'Exact server-generated names from the current conversation; omit to use all attachments',
82
+ },
83
+ mode: { type: 'string', enum: ['append', 'replace'], description: 'append by default; replace only on explicit request' },
84
+ },
85
+ handler: async (args) => {
86
+ const library = String(args['library'] ?? '');
87
+ const shelf = String(args['shelf'] ?? '');
88
+ const id = Number(args['id']);
89
+ const field = String(args['field'] ?? '');
90
+ const mode = args['mode'] === 'replace' ? 'replace' : 'append';
91
+ if (!library || !shelf || !field || !Number.isInteger(id))
92
+ return fail('library, shelf, field and integer id are required');
93
+ const names = Array.isArray(args['attachment_names'])
94
+ ? args['attachment_names'].filter((name) => typeof name === 'string')
95
+ : attachments.map((item) => item.name);
96
+ const uniqueNames = [...new Set(names)];
97
+ const refs = uniqueNames.map((name) => attachments.find((item) => item.name === name));
98
+ if (refs.some((ref) => !ref)) {
99
+ const missing = uniqueNames.filter((name) => !attachments.some((item) => item.name === name));
100
+ return fail(`Attachment is not available in the current conversation: ${missing.join(', ')}`);
101
+ }
102
+ if (!refs.length)
103
+ return fail('No conversation attachments were selected');
104
+ const type = await findFileField(library, shelf, field);
105
+ if (!type || type.prim !== 'file')
106
+ return fail(`Field is not a file field: ${library}/${shelf}.${field}`);
107
+ const multiple = type.hints?.multiple === true;
108
+ const currentResult = await getRecord.handler({ library, shelf, id });
109
+ if (currentResult.isError)
110
+ return currentResult;
111
+ const current = resultData(currentResult);
112
+ if (!current || typeof current !== 'object')
113
+ return fail(`Record not found: ${library}/${shelf}/${id}`);
114
+ const existing = fileItems(current[field]);
115
+ const incoming = refs.map((ref) => ({ name: ref.name }));
116
+ if (!multiple && mode === 'append' && existing.length)
117
+ return fail(`Field already contains a file; use mode="replace" to replace it`);
118
+ if (!multiple && incoming.length > 1)
119
+ return fail(`Field accepts one file; select one attachment or use a multiple field`);
120
+ const next = mode === 'replace'
121
+ ? incoming
122
+ : [...existing, ...incoming.filter((item) => !existing.some((old) => old.name === item.name))];
123
+ const value = multiple ? next : next[0] ?? null;
124
+ const updated = await updateRecord.handler({ library, shelf, id, fields: { [field]: value } });
125
+ if (updated.isError)
126
+ return updated;
127
+ return ok({ attached: incoming.map((item) => item.name), mode, library, shelf, id, field, record: resultData(updated) });
128
+ },
129
+ };
130
+ return { tools: [attach] };
131
+ }
@@ -0,0 +1,2 @@
1
+ import type { AttachmentMaterializer } from './types.ts';
2
+ export declare const attachmentMaterializer: AttachmentMaterializer;
@@ -0,0 +1,11 @@
1
+ import { saveUploadBytes } from '@coffer-org/server/uploads';
2
+ export const attachmentMaterializer = {
3
+ async store(bytes, opts = {}) {
4
+ const stored = await saveUploadBytes(bytes, opts);
5
+ return {
6
+ name: stored.name,
7
+ ...(stored.mime ? { mime: stored.mime } : {}),
8
+ size: stored.size,
9
+ };
10
+ },
11
+ };
@@ -1,3 +1,4 @@
1
1
  import type { GatePolicy } from './types.ts';
2
2
  export declare function buildPolicy(dbSettings?: Record<string, unknown>): GatePolicy;
3
+ export declare function loadAgentId(): Promise<string | undefined>;
3
4
  export declare function loadGatePolicy(): Promise<GatePolicy>;
@@ -1,11 +1,17 @@
1
1
  export function buildPolicy(dbSettings = {}) {
2
2
  const db = dbSettings;
3
3
  return {
4
+ ...(typeof db.agent_id === 'string' && db.agent_id ? { agentId: db.agent_id } : {}),
4
5
  accessPassword: db.access_password ?? '',
5
6
  triggerPrefix: db.trigger_prefix ?? '',
6
7
  replyWindow: Number(db.reply_window ?? 1800) || 1800,
7
8
  };
8
9
  }
10
+ export async function loadAgentId() {
11
+ const { getPluginSettings } = await import('@coffer-org/server/plugin-runtime');
12
+ const value = (await getPluginSettings('orchestrator'))['agent_id'];
13
+ return typeof value === 'string' && value ? value : undefined;
14
+ }
9
15
  export async function loadGatePolicy() {
10
16
  const { getPluginSettings } = await import('@coffer-org/server/plugin-runtime');
11
17
  return buildPolicy(await getPluginSettings('orchestrator'));
@@ -1,9 +1,12 @@
1
1
  import type { PluginHooks } from '@coffer-org/server/plugin-hooks';
2
2
  export { handleIncoming } from './pipeline.ts';
3
+ export { registerAgent, resolveAgent, registerConnector, isConnectorRegistered, clearRuntimeRegistries } from './registry.ts';
4
+ export { attachmentMaterializer } from './attachments.ts';
5
+ export { makeAttachmentCapabilities } from './agent-capabilities.ts';
3
6
  export type { PipelineDeps, RunAgentFn } from './pipeline.ts';
4
- export { buildPolicy, loadGatePolicy } from './config.ts';
7
+ export { buildPolicy, loadGatePolicy, loadAgentId } from './config.ts';
5
8
  export { makeLiveChannel, plainRender } from './live-message.ts';
6
9
  export type { LiveChannelOps, LiveChannelOpts, RenderFn } from './live-message.ts';
7
10
  export { chunk } from './format.ts';
8
- export type { Connector, IncomingConversation, GatePolicy, ReplyContext, ReplyPayload, ReplyChannel, AttachmentRef, ConvMessage, SystemLayer, AgentRequest, AgentResult, } from './types.ts';
11
+ export type { Connector, IncomingConversation, GatePolicy, ReplyContext, ReplyPayload, ReplyChannel, AttachmentRef, AttachmentMaterializer, AgentToolDefinition, AgentToolProvider, ConvMessage, SystemLayer, AgentRequest, AgentResult, AgentRuntime, ConnectorRegistration, } from './types.ts';
9
12
  export declare const serverHooks: PluginHooks;
@@ -1,7 +1,10 @@
1
1
  import { openLogDb } from "./db.js";
2
2
  import { setLogDb } from "./pipeline.js";
3
3
  export { handleIncoming } from "./pipeline.js";
4
- export { buildPolicy, loadGatePolicy } from "./config.js";
4
+ export { registerAgent, resolveAgent, registerConnector, isConnectorRegistered, clearRuntimeRegistries } from "./registry.js";
5
+ export { attachmentMaterializer } from "./attachments.js";
6
+ export { makeAttachmentCapabilities } from "./agent-capabilities.js";
7
+ export { buildPolicy, loadGatePolicy, loadAgentId } from "./config.js";
5
8
  export { makeLiveChannel, plainRender } from "./live-message.js";
6
9
  export { chunk } from "./format.js";
7
10
  let db;
@@ -1,8 +1,7 @@
1
- import { runAgent } from '@coffer-org/plugin-claude-agent/runtime';
2
- import type { Connector, IncomingConversation, GatePolicy } from './types.ts';
1
+ import type { Connector, IncomingConversation, GatePolicy, AgentRuntime } from './types.ts';
3
2
  import type { LogDb } from './db.ts';
4
3
  export declare function setLogDb(db: LogDb | undefined): void;
5
- export type RunAgentFn = typeof runAgent;
4
+ export type RunAgentFn = AgentRuntime['run'];
6
5
  export interface PipelineDeps {
7
6
  runAgent?: RunAgentFn;
8
7
  logDb?: LogDb | null;
@@ -1,8 +1,10 @@
1
- import { runAgent, agentSystemBase } from '@coffer-org/plugin-claude-agent/runtime';
2
1
  import { join } from 'node:path';
3
2
  import { loadAllowed, saveAllowed, isAllowed, addAllowed, makeThrottle } from "./allow.js";
4
- import { loadGatePolicy } from "./config.js";
3
+ import { loadAgentId, loadGatePolicy } from "./config.js";
5
4
  import { buildSystem } from "./system-assembly.js";
5
+ import { attachmentMaterializer } from "./attachments.js";
6
+ import { makeAttachmentCapabilities } from "./agent-capabilities.js";
7
+ import { resolveAgent } from "./registry.js";
6
8
  import { getLogger } from '@coffer-org/sdk/logger';
7
9
  const log = getLogger('orchestrator');
8
10
  const stateDir = () => process.env['ORCHESTRATOR_STATE_DIR']
@@ -19,7 +21,15 @@ function passThrottle(connectorId) {
19
21
  return t;
20
22
  }
21
23
  let cachedBase;
22
- function cachedAgentBase() { return (cachedBase ??= agentSystemBase()); }
24
+ let cachedBaseAgentId;
25
+ function cachedAgentBase() {
26
+ const runtime = resolveAgent();
27
+ if (cachedBaseAgentId !== runtime.id) {
28
+ cachedBaseAgentId = runtime.id;
29
+ cachedBase = runtime.systemBase();
30
+ }
31
+ return cachedBase;
32
+ }
23
33
  function openChannel(connector, chatId, ctx) {
24
34
  try {
25
35
  return connector.reply(chatId, ctx);
@@ -39,10 +49,12 @@ async function safeCall(fn, fallback, label) {
39
49
  }
40
50
  }
41
51
  export async function handleIncoming(connector, conversation, deps) {
42
- const agent = deps?.runAgent ?? runAgent;
43
- const db = deps && 'logDb' in deps ? deps.logDb : logDb;
44
52
  const policy = deps?.policy ?? await loadGatePolicy();
45
- const agentBase = deps?.agentBase ?? cachedAgentBase;
53
+ const selectedAgentId = conversation.agentId ?? policy.agentId ?? (deps?.runAgent ? undefined : await loadAgentId());
54
+ const runtime = deps?.runAgent ? undefined : resolveAgent(selectedAgentId);
55
+ const agent = deps?.runAgent ?? runtime.run.bind(runtime);
56
+ const db = deps && 'logDb' in deps ? deps.logDb : logDb;
57
+ const agentBase = deps?.agentBase ?? (() => selectedAgentId ? runtime.systemBase() : cachedAgentBase());
46
58
  const { connectorId, chatId, sender, messages } = conversation;
47
59
  const last = messages[messages.length - 1];
48
60
  if (!last || last.role !== 'user')
@@ -73,12 +85,12 @@ export async function handleIncoming(connector, conversation, deps) {
73
85
  const queryText = policy.triggerPrefix ? last.content.slice(policy.triggerPrefix.length).trim() : last.content.trim();
74
86
  if (!queryText) {
75
87
  if (conversation.prepareAttachments && !policy.triggerPrefix) {
76
- await safeCall(conversation.prepareAttachments, messages, 'prepareAttachments');
88
+ await safeCall(() => conversation.prepareAttachments(attachmentMaterializer), messages, 'prepareAttachments');
77
89
  }
78
90
  return;
79
91
  }
80
92
  const preparedMessages = conversation.prepareAttachments
81
- ? await safeCall(conversation.prepareAttachments, messages, 'prepareAttachments')
93
+ ? await safeCall(() => conversation.prepareAttachments(attachmentMaterializer), messages, 'prepareAttachments')
82
94
  : messages;
83
95
  const agentMessages = policy.triggerPrefix
84
96
  ? preparedMessages.map((m, i) => (i === preparedMessages.length - 1 ? { ...m, content: queryText } : m))
@@ -96,9 +108,11 @@ export async function handleIncoming(connector, conversation, deps) {
96
108
  return;
97
109
  let result;
98
110
  try {
111
+ const toolProvider = await makeAttachmentCapabilities(agentMessages.flatMap((m) => m.attachments ?? []));
99
112
  result = await agent({
100
113
  system,
101
114
  messages: agentMessages,
115
+ toolProvider,
102
116
  onDelta: (acc) => { try {
103
117
  ch.update(acc);
104
118
  }
@@ -0,0 +1,8 @@
1
+ import type { AgentRuntime, ConnectorRegistration } from './types.ts';
2
+ export declare function registerAgent(runtime: AgentRuntime, opts?: {
3
+ default?: boolean;
4
+ }): () => void;
5
+ export declare function resolveAgent(id?: string): AgentRuntime;
6
+ export declare function registerConnector(registration: ConnectorRegistration): () => void;
7
+ export declare function isConnectorRegistered(id: string): boolean;
8
+ export declare function clearRuntimeRegistries(): void;
@@ -0,0 +1,44 @@
1
+ const agents = new Map();
2
+ const connectors = new Map();
3
+ let defaultAgentId;
4
+ export function registerAgent(runtime, opts = {}) {
5
+ const current = agents.get(runtime.id);
6
+ if (current && current !== runtime)
7
+ throw new Error(`agent already registered: ${runtime.id}`);
8
+ agents.set(runtime.id, runtime);
9
+ if (opts.default || !defaultAgentId)
10
+ defaultAgentId = runtime.id;
11
+ return () => unregisterAgent(runtime.id, runtime);
12
+ }
13
+ function unregisterAgent(id, expected) {
14
+ if (agents.get(id) !== expected)
15
+ return;
16
+ agents.delete(id);
17
+ if (defaultAgentId === id)
18
+ defaultAgentId = agents.keys().next().value;
19
+ }
20
+ export function resolveAgent(id) {
21
+ const key = id ?? defaultAgentId;
22
+ const runtime = key ? agents.get(key) : undefined;
23
+ if (!runtime)
24
+ throw new Error(id ? `agent is not registered: ${id}` : 'no agent is registered');
25
+ return runtime;
26
+ }
27
+ export function registerConnector(registration) {
28
+ const current = connectors.get(registration.id);
29
+ if (current && current !== registration)
30
+ throw new Error(`connector already registered: ${registration.id}`);
31
+ connectors.set(registration.id, registration);
32
+ return () => {
33
+ if (connectors.get(registration.id) === registration)
34
+ connectors.delete(registration.id);
35
+ };
36
+ }
37
+ export function isConnectorRegistered(id) {
38
+ return connectors.has(id);
39
+ }
40
+ export function clearRuntimeRegistries() {
41
+ agents.clear();
42
+ connectors.clear();
43
+ defaultAgentId = undefined;
44
+ }
@@ -1,7 +1,62 @@
1
- export type { AttachmentRef, ConvMessage, SystemLayer, AgentRequest, AgentResult } from '@coffer-org/plugin-claude-agent/runtime';
2
- import type { ConvMessage } from '@coffer-org/plugin-claude-agent/runtime';
1
+ export interface AttachmentRef {
2
+ name: string;
3
+ mime?: string;
4
+ size?: number;
5
+ label?: string;
6
+ }
7
+ export interface AgentToolDefinition {
8
+ name: string;
9
+ description: string;
10
+ inputSchema: Record<string, unknown>;
11
+ handler: (args: Record<string, unknown>) => Promise<unknown>;
12
+ }
13
+ export interface AgentToolProvider {
14
+ tools: AgentToolDefinition[];
15
+ }
16
+ export interface ConvMessage {
17
+ role: 'user' | 'assistant';
18
+ content: string;
19
+ attachments?: AttachmentRef[];
20
+ sender?: string | null;
21
+ msgId: string;
22
+ ts: number;
23
+ }
24
+ export interface SystemLayer {
25
+ text: string;
26
+ stable: boolean;
27
+ }
28
+ export interface AgentRequest {
29
+ system: SystemLayer[];
30
+ messages: ConvMessage[];
31
+ toolProvider?: AgentToolProvider;
32
+ onDelta?: (accumulated: string) => void;
33
+ onReasoning?: (accumulated: string) => void;
34
+ onSegment?: () => void;
35
+ }
36
+ export interface AgentResult {
37
+ text: string | null;
38
+ reasoning: string | null;
39
+ tokensIn: number | null;
40
+ tokensOut: number | null;
41
+ stopReason: string | null;
42
+ }
43
+ export interface AgentRuntime {
44
+ id: string;
45
+ run(request: AgentRequest): Promise<AgentResult>;
46
+ systemBase(): Promise<string>;
47
+ }
48
+ export interface ConnectorRegistration {
49
+ id: string;
50
+ }
51
+ export interface AttachmentMaterializer {
52
+ store(bytes: Uint8Array, opts?: {
53
+ originalName?: string;
54
+ mime?: string;
55
+ }): Promise<AttachmentRef>;
56
+ }
3
57
  export interface IncomingConversation {
4
58
  connectorId: string;
59
+ agentId?: string;
5
60
  chatId: string;
6
61
  channelSystem?: string;
7
62
  volatileSystem?: string;
@@ -10,7 +65,7 @@ export interface IncomingConversation {
10
65
  displayName?: string;
11
66
  };
12
67
  messages: ConvMessage[];
13
- prepareAttachments?: () => Promise<ConvMessage[]>;
68
+ prepareAttachments?: (materializer: AttachmentMaterializer) => Promise<ConvMessage[]>;
14
69
  }
15
70
  export interface ReplyContext {
16
71
  parentMsgId: string | null;
@@ -36,6 +91,7 @@ export interface Connector {
36
91
  }): Promise<void>;
37
92
  }
38
93
  export interface GatePolicy {
94
+ agentId?: string;
39
95
  accessPassword: string;
40
96
  triggerPrefix: string;
41
97
  replyWindow: number;
package/dist/schema.js CHANGED
@@ -7077,6 +7077,10 @@ var src_default = definePlugin({
7077
7077
  settings: defineSettings({
7078
7078
  label: "orchestrator.settings.label",
7079
7079
  fields: [
7080
+ field.select({
7081
+ key: "agent_id",
7082
+ label: "orchestrator.settings.agent_id"
7083
+ }),
7080
7084
  field.password({
7081
7085
  key: "access_password",
7082
7086
  label: "orchestrator.settings.access_password"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffer-org/plugin-orchestrator",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -25,9 +25,8 @@
25
25
  "postpack": "node ../../scripts/swap-exports.mjs src"
26
26
  },
27
27
  "dependencies": {
28
- "@coffer-org/sdk": "^2.1.1",
29
- "@coffer-org/server": "^2.3.0",
30
- "@coffer-org/plugin-claude-agent": "^2.5.0"
28
+ "@coffer-org/sdk": "^2.1.2",
29
+ "@coffer-org/server": "^2.3.0"
31
30
  },
32
31
  "coffer": {
33
32
  "schema": "dist/schema.js"