@coffer-org/plugin-orchestrator 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,16 @@
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: 'orchestrator',
6
+ version: '1.0.0',
7
+ dependsOn: ['agent'],
8
+ settings: defineSettings({
9
+ label: 'orchestrator.settings.label',
10
+ fields: [
11
+ field.password({ key: 'access_password', label: 'orchestrator.settings.access_password' }),
12
+ field.string({ key: 'trigger_prefix', label: 'orchestrator.settings.trigger_prefix' }),
13
+ field.int({ key: 'reply_window', label: 'orchestrator.settings.reply_window', default: 1800 }),
14
+ ],
15
+ }),
16
+ });
@@ -0,0 +1,9 @@
1
+ export interface AllowState {
2
+ ids: Record<string, number>;
3
+ }
4
+ export declare function emptyAllowed(): AllowState;
5
+ export declare function loadAllowed(file: string): AllowState;
6
+ export declare function saveAllowed(file: string, state: AllowState): void;
7
+ export declare function isAllowed(state: AllowState, id: string | number | null | undefined): boolean;
8
+ export declare function addAllowed(state: AllowState, id: string | number, now: number): AllowState;
9
+ export declare function makeThrottle(maxAttempts?: number, windowMs?: number): (id: string | number, now?: number) => boolean;
@@ -0,0 +1,44 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export function emptyAllowed() {
4
+ return { ids: {} };
5
+ }
6
+ export function loadAllowed(file) {
7
+ try {
8
+ const obj = JSON.parse(fs.readFileSync(file, 'utf-8'));
9
+ if (obj && typeof obj === 'object' && 'ids' in obj && typeof obj.ids === 'object') {
10
+ return obj;
11
+ }
12
+ return emptyAllowed();
13
+ }
14
+ catch {
15
+ return emptyAllowed();
16
+ }
17
+ }
18
+ export function saveAllowed(file, state) {
19
+ fs.mkdirSync(path.dirname(file), { recursive: true });
20
+ const tmp = `${file}.${process.pid}.tmp`;
21
+ fs.writeFileSync(tmp, JSON.stringify(state), 'utf-8');
22
+ fs.renameSync(tmp, file);
23
+ }
24
+ export function isAllowed(state, id) {
25
+ return id != null && Object.prototype.hasOwnProperty.call(state.ids, String(id));
26
+ }
27
+ export function addAllowed(state, id, now) {
28
+ return { ids: { ...state.ids, [String(id)]: now } };
29
+ }
30
+ export function makeThrottle(maxAttempts = 5, windowMs = 60_000) {
31
+ const hits = new Map();
32
+ return function allowAttempt(id, now = Date.now()) {
33
+ const key = String(id);
34
+ const rec = hits.get(key);
35
+ if (!rec || now - rec.start >= windowMs) {
36
+ hits.set(key, { start: now, count: 1 });
37
+ return true;
38
+ }
39
+ if (rec.count >= maxAttempts)
40
+ return false;
41
+ rec.count += 1;
42
+ return true;
43
+ };
44
+ }
@@ -0,0 +1,3 @@
1
+ import type { GatePolicy } from './types.ts';
2
+ export declare function buildPolicy(dbSettings?: Record<string, unknown>): GatePolicy;
3
+ export declare function loadGatePolicy(): Promise<GatePolicy>;
@@ -0,0 +1,12 @@
1
+ export function buildPolicy(dbSettings = {}) {
2
+ const db = dbSettings;
3
+ return {
4
+ accessPassword: db.access_password ?? '',
5
+ triggerPrefix: db.trigger_prefix ?? '',
6
+ replyWindow: Number(db.reply_window ?? 1800) || 1800,
7
+ };
8
+ }
9
+ export async function loadGatePolicy() {
10
+ const { getPluginSettings } = await import('@coffer-org/server/plugin-runtime');
11
+ return buildPolicy(await getPluginSettings('orchestrator'));
12
+ }
@@ -0,0 +1,16 @@
1
+ export type LogTurn = {
2
+ connector: string;
3
+ chatId: string;
4
+ userId: string;
5
+ role: 'user' | 'assistant';
6
+ text: string;
7
+ sessionId: string | null;
8
+ tokensIn: number | null;
9
+ tokensOut: number | null;
10
+ ms: number | null;
11
+ };
12
+ export interface LogDb {
13
+ logTurn(row: LogTurn): void;
14
+ close(): void;
15
+ }
16
+ export declare function openLogDb(): LogDb;
@@ -0,0 +1,7 @@
1
+ import { logMessage } from '@coffer-org/server/msg-log';
2
+ export function openLogDb() {
3
+ return {
4
+ logTurn(row) { logMessage(row).catch((e) => console.error('[orchestrator] logTurn:', e)); },
5
+ close() { },
6
+ };
7
+ }
@@ -0,0 +1 @@
1
+ export declare function chunk(text: string, max: number): string[];
@@ -0,0 +1,6 @@
1
+ export function chunk(text, max) {
2
+ const out = [];
3
+ for (let i = 0; i < text.length; i += max)
4
+ out.push(text.slice(i, i + max));
5
+ return out.length ? out : [''];
6
+ }
@@ -0,0 +1,5 @@
1
+ import type { PluginHooks } from '@coffer-org/server/plugin-hooks';
2
+ export { handleIncoming } from './pipeline.ts';
3
+ export { buildPolicy, loadGatePolicy } from './config.ts';
4
+ export type { Connector, ConnectorCapabilities, IncomingPayload, GatePolicy } from './types.ts';
5
+ export declare const serverHooks: PluginHooks;
@@ -0,0 +1,12 @@
1
+ import { openLogDb } from "./db.js";
2
+ import { setLogDb } from "./pipeline.js";
3
+ export { handleIncoming } from "./pipeline.js";
4
+ export { buildPolicy, loadGatePolicy } from "./config.js";
5
+ let db;
6
+ export const serverHooks = {
7
+ init: () => { db = openLogDb(); setLogDb(db); },
8
+ teardown: () => { try {
9
+ db?.close();
10
+ }
11
+ catch { } db = undefined; setLogDb(undefined); },
12
+ };
@@ -0,0 +1,12 @@
1
+ import { runAgent } from '@coffer-org/plugin-agent/runtime';
2
+ import type { Connector, IncomingPayload, GatePolicy } from './types.ts';
3
+ import type { LogDb } from './db.ts';
4
+ export declare function setLogDb(db: LogDb | undefined): void;
5
+ export type RunAgentFn = typeof runAgent;
6
+ export interface PipelineDeps {
7
+ runAgent?: RunAgentFn;
8
+ logDb?: LogDb | null;
9
+ throttleMs?: number;
10
+ policy?: GatePolicy;
11
+ }
12
+ export declare function handleIncoming(connector: Connector, payload: IncomingPayload, deps?: PipelineDeps): Promise<void>;
@@ -0,0 +1,141 @@
1
+ import { runAgent } from '@coffer-org/plugin-agent/runtime';
2
+ import { chunk } from "./format.js";
3
+ import { join } from 'node:path';
4
+ import { loadThreads, saveThreads, resolveParent, getSession, recordSession, pruneThreads, } from "./threads.js";
5
+ import { loadAllowed, saveAllowed, isAllowed, addAllowed, makeThrottle } from "./allow.js";
6
+ import { loadGatePolicy } from "./config.js";
7
+ const DEFAULT_STREAM_THROTTLE_MS = 2000;
8
+ const stateDir = () => process.env['ORCHESTRATOR_STATE_DIR']
9
+ ?? join(new URL('../../runtime/state', import.meta.url).pathname);
10
+ let logDb;
11
+ export function setLogDb(db) { logDb = db; }
12
+ const throttleByConnector = new Map();
13
+ function passThrottle(connectorId) {
14
+ let t = throttleByConnector.get(connectorId);
15
+ if (!t) {
16
+ t = makeThrottle(5, 60_000);
17
+ throttleByConnector.set(connectorId, t);
18
+ }
19
+ return t;
20
+ }
21
+ async function safeCall(fn, fallback, label) {
22
+ try {
23
+ return await fn();
24
+ }
25
+ catch (err) {
26
+ console.error(`[orchestrator] connector error in ${label}:`, err instanceof Error ? err.message : String(err));
27
+ return fallback;
28
+ }
29
+ }
30
+ export async function handleIncoming(connector, payload, deps) {
31
+ const agent = deps?.runAgent ?? runAgent;
32
+ const db = deps && 'logDb' in deps ? deps.logDb : logDb;
33
+ const throttleMs = deps?.throttleMs ?? DEFAULT_STREAM_THROTTLE_MS;
34
+ const policy = deps?.policy ?? await loadGatePolicy();
35
+ const { chatId, userId, text, replyToId } = payload;
36
+ const dir = stateDir();
37
+ const allowFile = join(dir, `${connector.id}.allowed.json`);
38
+ const threadsFile = join(dir, `${connector.id}.threads.json`);
39
+ if (policy.accessPassword) {
40
+ let allow = loadAllowed(allowFile);
41
+ if (!isAllowed(allow, userId)) {
42
+ if (text.trim() === policy.accessPassword) {
43
+ allow = addAllowed(allow, userId, Date.now());
44
+ saveAllowed(allowFile, allow);
45
+ await safeCall(() => connector.sendMessage(chatId, '✅ Access granted. Send your requests.'), null, 'sendMessage(enroll)');
46
+ }
47
+ else if (passThrottle(connector.id)(userId)) {
48
+ await safeCall(() => connector.sendMessage(chatId, '🔒 Access locked. Send the password to gain access.'), null, 'sendMessage(locked)');
49
+ }
50
+ return;
51
+ }
52
+ }
53
+ if (policy.triggerPrefix && !text.toLowerCase().startsWith(policy.triggerPrefix.toLowerCase()))
54
+ return;
55
+ const queryText = policy.triggerPrefix ? text.slice(policy.triggerPrefix.length).trim() : text.trim();
56
+ if (!queryText)
57
+ return;
58
+ const now = Date.now();
59
+ let threads = loadThreads(threadsFile);
60
+ const parent = resolveParent(threads, { replyToMsgId: replyToId, now }, policy.replyWindow);
61
+ const parentSession = getSession(threads, parent);
62
+ db?.logTurn({ connector: connector.id, chatId, userId, role: 'user', text: queryText, sessionId: parentSession, tokensIn: null, tokensOut: null, ms: null });
63
+ const startedAt = Date.now();
64
+ const canStream = connector.capabilities.streaming && typeof connector.editMessage === 'function';
65
+ const max = connector.capabilities.maxMessageLength;
66
+ let result;
67
+ let placeholderId = null;
68
+ if (canStream) {
69
+ const stopTyping = connector.capabilities.typing && connector.startTyping
70
+ ? await safeCall(async () => connector.startTyping(chatId), undefined, 'startTyping')
71
+ : undefined;
72
+ let pending = '', lastEdited = '', editing = false, closed = false;
73
+ let editPromise = Promise.resolve();
74
+ const timer = setInterval(() => {
75
+ if (closed || editing)
76
+ return;
77
+ const t = pending.slice(0, max);
78
+ if (!t || t === lastEdited)
79
+ return;
80
+ editing = true;
81
+ if (!placeholderId) {
82
+ editPromise = safeCall(() => connector.sendMessage(chatId, t), null, 'sendMessage(stream-create)')
83
+ .then((s) => { placeholderId = s?.msgId ?? null; lastEdited = t; })
84
+ .finally(() => { editing = false; });
85
+ return;
86
+ }
87
+ editPromise = safeCall(() => connector.editMessage(chatId, placeholderId, t), undefined, 'editMessage(stream)')
88
+ .then(() => { lastEdited = t; })
89
+ .finally(() => { editing = false; });
90
+ }, throttleMs);
91
+ try {
92
+ result = await agent({ prompt: queryText, sessionId: parentSession, onDelta: (acc) => { pending = acc; } });
93
+ if (result.text == null && parentSession)
94
+ result = await agent({ prompt: queryText, sessionId: null, onDelta: (acc) => { pending = acc; } });
95
+ }
96
+ finally {
97
+ closed = true;
98
+ clearInterval(timer);
99
+ await editPromise;
100
+ stopTyping?.();
101
+ }
102
+ }
103
+ else {
104
+ const stopTyping = connector.capabilities.typing && connector.startTyping
105
+ ? await safeCall(async () => connector.startTyping(chatId), undefined, 'startTyping')
106
+ : undefined;
107
+ try {
108
+ result = await agent({ prompt: queryText, sessionId: parentSession });
109
+ if (result.text == null && parentSession)
110
+ result = await agent({ prompt: queryText, sessionId: null });
111
+ }
112
+ finally {
113
+ stopTyping?.();
114
+ }
115
+ }
116
+ let botMsgId = null;
117
+ const out = result.text ?? '⚠️ the agent returned no response';
118
+ const parts = chunk(out, max);
119
+ if (canStream && placeholderId) {
120
+ await safeCall(() => connector.editMessage(chatId, placeholderId, parts[0]), undefined, 'editMessage(final)');
121
+ botMsgId = placeholderId;
122
+ for (const p of parts.slice(1)) {
123
+ const s = await safeCall(() => connector.sendMessage(chatId, p), null, 'sendMessage(overflow)');
124
+ botMsgId = s?.msgId ?? botMsgId;
125
+ }
126
+ }
127
+ else {
128
+ for (const p of parts) {
129
+ const s = await safeCall(() => connector.sendMessage(chatId, p), null, 'sendMessage(deliver)');
130
+ botMsgId = s?.msgId ?? botMsgId;
131
+ }
132
+ }
133
+ if (result.text)
134
+ db?.logTurn({ connector: connector.id, chatId, userId, role: 'assistant', text: result.text, sessionId: result.sessionId, tokensIn: result.tokensIn, tokensOut: result.tokensOut, ms: Date.now() - startedAt });
135
+ if (result.sessionId) {
136
+ const incomingMsgId = replyToId ?? String(now);
137
+ threads = recordSession(threads, { msgId: incomingMsgId, botMsgId, sessionId: result.sessionId, now });
138
+ threads = pruneThreads(threads, now, 7);
139
+ saveThreads(threadsFile, threads);
140
+ }
141
+ }
@@ -0,0 +1,26 @@
1
+ export interface ThreadEntry {
2
+ sessionId: string;
3
+ ts: number;
4
+ }
5
+ export interface Threads {
6
+ map: Record<string, ThreadEntry>;
7
+ last: {
8
+ msgId: string;
9
+ ts: number;
10
+ } | null;
11
+ }
12
+ export declare function emptyThreads(): Threads;
13
+ export declare function resolveParent(threads: Threads, { replyToMsgId, now }: {
14
+ replyToMsgId: string | null;
15
+ now: number;
16
+ }, replyWindowSec: number): string | null;
17
+ export declare function getSession(threads: Threads, parentId: string | null): string | null;
18
+ export declare function recordSession(threads: Threads, { msgId, botMsgId, sessionId, now }: {
19
+ msgId: string;
20
+ botMsgId: string | null;
21
+ sessionId: string;
22
+ now: number;
23
+ }): Threads;
24
+ export declare function pruneThreads(threads: Threads, now: number, ttlDays: number): Threads;
25
+ export declare function loadThreads(file: string): Threads;
26
+ export declare function saveThreads(file: string, threads: Threads): void;
@@ -0,0 +1,49 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export function emptyThreads() {
4
+ return { map: {}, last: null };
5
+ }
6
+ export function resolveParent(threads, { replyToMsgId, now }, replyWindowSec) {
7
+ if (replyToMsgId != null)
8
+ return replyToMsgId;
9
+ const last = threads.last;
10
+ if (last && now - last.ts < replyWindowSec * 1000)
11
+ return last.msgId;
12
+ return null;
13
+ }
14
+ export function getSession(threads, parentId) {
15
+ if (parentId == null)
16
+ return null;
17
+ return threads.map[parentId]?.sessionId ?? null;
18
+ }
19
+ export function recordSession(threads, { msgId, botMsgId, sessionId, now }) {
20
+ const map = { ...threads.map };
21
+ map[msgId] = { sessionId, ts: now };
22
+ if (botMsgId != null)
23
+ map[botMsgId] = { sessionId, ts: now };
24
+ return { map, last: { msgId: botMsgId ?? msgId, ts: now } };
25
+ }
26
+ export function pruneThreads(threads, now, ttlDays) {
27
+ const cutoff = now - ttlDays * 86_400_000;
28
+ const map = {};
29
+ for (const [k, v] of Object.entries(threads.map)) {
30
+ if (v.ts >= cutoff)
31
+ map[k] = v;
32
+ }
33
+ return { map, last: threads.last };
34
+ }
35
+ export function loadThreads(file) {
36
+ try {
37
+ const obj = JSON.parse(fs.readFileSync(file, 'utf-8'));
38
+ if (obj && typeof obj === 'object' && 'map' in obj)
39
+ return obj;
40
+ return emptyThreads();
41
+ }
42
+ catch {
43
+ return emptyThreads();
44
+ }
45
+ }
46
+ export function saveThreads(file, threads) {
47
+ fs.mkdirSync(path.dirname(file), { recursive: true });
48
+ fs.writeFileSync(file, JSON.stringify(threads), 'utf-8');
49
+ }
@@ -0,0 +1,25 @@
1
+ export interface ConnectorCapabilities {
2
+ streaming: boolean;
3
+ typing: boolean;
4
+ maxMessageLength: number;
5
+ }
6
+ export interface Connector {
7
+ id: string;
8
+ capabilities: ConnectorCapabilities;
9
+ sendMessage(chatId: string, text: string): Promise<{
10
+ msgId: string;
11
+ } | null>;
12
+ editMessage?(chatId: string, msgId: string, text: string): Promise<void>;
13
+ startTyping?(chatId: string): () => void;
14
+ }
15
+ export interface IncomingPayload {
16
+ chatId: string;
17
+ userId: string;
18
+ text: string;
19
+ replyToId: string | null;
20
+ }
21
+ export interface GatePolicy {
22
+ accessPassword: string;
23
+ triggerPrefix: string;
24
+ replyWindow: number;
25
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@coffer-org/plugin-orchestrator",
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
+ "@coffer-org/sdk": "^1.2.0",
31
+ "@coffer-org/server": "^1.2.0",
32
+ "@coffer-org/plugin-agent": "^1.2.0"
33
+ }
34
+ }