@coffer-org/server 7.2.0 → 7.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.
Files changed (57) hide show
  1. package/dist/auth-api.d.ts +4 -0
  2. package/dist/auth-api.js +53 -0
  3. package/dist/auth-store.d.ts +2 -0
  4. package/dist/auth-store.js +1 -0
  5. package/dist/entity-schema.d.ts +2 -0
  6. package/dist/entity-schema.js +26 -0
  7. package/dist/identity-link.d.ts +15 -0
  8. package/dist/identity-link.js +76 -0
  9. package/dist/index.js +8 -1
  10. package/dist/mcp-http.js +7 -3
  11. package/dist/mcp-tools.d.ts +4 -3
  12. package/dist/mcp-tools.js +84 -84
  13. package/dist/media/image.d.ts +23 -0
  14. package/dist/media/image.js +103 -0
  15. package/dist/media/index.d.ts +1 -0
  16. package/dist/media/index.js +1 -0
  17. package/dist/migrations.js +1 -1
  18. package/dist/orchestrator/agent-capabilities.d.ts +2 -2
  19. package/dist/orchestrator/agent-capabilities.js +3 -3
  20. package/dist/orchestrator/allow.d.ts +1 -16
  21. package/dist/orchestrator/allow.js +3 -53
  22. package/dist/orchestrator/config.js +0 -1
  23. package/dist/orchestrator/context-facts.d.ts +27 -0
  24. package/dist/orchestrator/context-facts.js +89 -0
  25. package/dist/orchestrator/conversation-access.d.ts +9 -0
  26. package/dist/orchestrator/conversation-access.js +12 -0
  27. package/dist/orchestrator/environment.d.ts +1 -0
  28. package/dist/orchestrator/environment.js +10 -0
  29. package/dist/orchestrator/file-inspection.d.ts +2 -2
  30. package/dist/orchestrator/file-inspection.js +41 -19
  31. package/dist/orchestrator/index.d.ts +15 -9
  32. package/dist/orchestrator/index.js +13 -7
  33. package/dist/orchestrator/live-message.d.ts +7 -4
  34. package/dist/orchestrator/live-message.js +48 -31
  35. package/dist/orchestrator/pipeline.d.ts +25 -4
  36. package/dist/orchestrator/pipeline.js +214 -94
  37. package/dist/orchestrator/registry.d.ts +4 -2
  38. package/dist/orchestrator/registry.js +10 -1
  39. package/dist/orchestrator/system-areas.d.ts +12 -0
  40. package/dist/orchestrator/system-areas.js +63 -0
  41. package/dist/orchestrator/system-capabilities.js +1 -1
  42. package/dist/orchestrator/turn-context.d.ts +18 -0
  43. package/dist/orchestrator/turn-context.js +39 -0
  44. package/dist/orchestrator/types.d.ts +101 -44
  45. package/dist/plugin-hooks.d.ts +26 -0
  46. package/dist/plugin-http-mounts.d.ts +18 -0
  47. package/dist/plugin-http-mounts.js +94 -0
  48. package/dist/plugin-runtime.js +2 -2
  49. package/dist/records-api.js +15 -3
  50. package/dist/system-settings.js +0 -1
  51. package/dist/thread-state.d.ts +14 -0
  52. package/dist/thread-state.js +71 -11
  53. package/dist/thread-store.d.ts +5 -3
  54. package/dist/thread-store.js +12 -9
  55. package/dist/turn-gate.d.ts +8 -0
  56. package/dist/turn-gate.js +39 -0
  57. package/package.json +7 -2
@@ -13,7 +13,7 @@ async function swallow(fn, fallback, label) {
13
13
  return fallback;
14
14
  }
15
15
  }
16
- export function makeLiveChannel(o) {
16
+ export function makeLiveSink(o) {
17
17
  let msgId = null;
18
18
  let pending = '';
19
19
  let lastSent = '';
@@ -62,39 +62,56 @@ export function makeLiveChannel(o) {
62
62
  }, o.throttleMs);
63
63
  if (typeof timer.unref === 'function')
64
64
  timer.unref();
65
+ async function deliver(r) {
66
+ closed = true;
67
+ clearInterval(timer);
68
+ await inFlight;
69
+ const parts = o.render(r);
70
+ const [head, ...rest] = parts;
71
+ if (msgId && head !== undefined) {
72
+ const id = msgId;
73
+ if (head !== lastSent) {
74
+ await swallow(() => o.ops.edit(id, head), undefined, 'edit(final)');
75
+ lastSent = head;
76
+ }
77
+ }
78
+ else if (head !== undefined) {
79
+ await swallow(() => o.ops.send(head), null, 'send(final)');
80
+ }
81
+ for (const p of rest) {
82
+ await swallow(() => o.ops.send(p), null, 'send(overflow)');
83
+ }
84
+ }
85
+ let finished = Promise.resolve();
86
+ let terminalDelivered = false;
87
+ function deliverTerminal(kind, r) {
88
+ if (terminalDelivered) {
89
+ log.warn(`live-message: a second terminal event (${kind}) after the turn already finished — dropped`);
90
+ return;
91
+ }
92
+ terminalDelivered = true;
93
+ finished = deliver(r);
94
+ }
65
95
  return {
66
- update(text) {
67
- pending = text;
68
- },
69
- segment() {
70
- gen++;
71
- msgId = null;
72
- pending = '';
73
- lastSent = '';
96
+ emit(e) {
97
+ if (e.kind === 'delta')
98
+ pending = e.text;
99
+ else if (e.kind === 'segment') {
100
+ gen++;
101
+ msgId = null;
102
+ pending = '';
103
+ lastSent = '';
104
+ }
105
+ else if (e.kind === 'answer')
106
+ deliverTerminal('answer', { text: e.text, reasoning: e.reasoning });
107
+ else if (e.kind === 'notice')
108
+ deliverTerminal('notice', { text: e.text, reasoning: null });
109
+ else if (e.kind === 'error')
110
+ deliverTerminal('error', { text: null, reasoning: null });
74
111
  },
75
- async finish(r) {
76
- closed = true;
112
+ done: () => {
77
113
  clearInterval(timer);
78
- await inFlight;
79
- const parts = o.render(r);
80
- let last = null;
81
- const [head, ...rest] = parts;
82
- if (msgId && head !== undefined) {
83
- const id = msgId;
84
- if (head !== lastSent) {
85
- await swallow(() => o.ops.edit(id, head), undefined, 'edit(final)');
86
- lastSent = head;
87
- }
88
- last = id;
89
- }
90
- else if (head !== undefined) {
91
- last = await swallow(() => o.ops.send(head), null, 'send(final)');
92
- }
93
- for (const p of rest) {
94
- const id = await swallow(() => o.ops.send(p), null, 'send(overflow)');
95
- last = id ?? last;
96
- }
97
- return last;
114
+ return finished;
98
115
  },
99
116
  };
100
117
  }
@@ -1,11 +1,32 @@
1
- import type { Connector, IncomingConversation, GatePolicy, AgentRuntime } from './types.ts';
1
+ import type { AgentMediaLimits, Connector, TurnRequest, GatePolicy, AgentRuntime } from './types.ts';
2
2
  import type { LogDb } from './db.ts';
3
+ import { type AreaWorld, type SystemAreas } from './system-areas.ts';
4
+ import type { AuthUser } from '../auth-store.ts';
3
5
  export declare function setLogDb(db: LogDb | undefined): void;
6
+ export declare const NOTICE_THROTTLE: {
7
+ readonly max: 5;
8
+ readonly windowMs: 60000;
9
+ };
4
10
  export type RunAgentFn = AgentRuntime['run'];
5
- export interface PipelineDeps {
11
+ export declare function liveWorld(): Promise<AreaWorld>;
12
+ export type UserLookup = (id: number) => Promise<AuthUser | null>;
13
+ export declare function defaultResolveUser(id: string, lookup?: UserLookup): Promise<AuthUser | null>;
14
+ export declare function defaultResolveLinkedUser(connectorId: string, externalId: string, lookup?: UserLookup): Promise<AuthUser | null>;
15
+ export declare function resolveSpeaker(connectorId: string, sender: TurnRequest['sender'], deps?: Pick<PipelineDeps, 'resolveUser' | 'resolveLinkedUser'>): Promise<AuthUser | null>;
16
+ export interface PipelineOptions {
17
+ policy?: GatePolicy;
18
+ }
19
+ export interface PipelineDeps extends PipelineOptions {
6
20
  runAgent?: RunAgentFn;
21
+ afterword?: AgentRuntime['afterword'];
7
22
  logDb?: LogDb | null;
8
- policy?: GatePolicy;
9
23
  agentBase?: () => Promise<string>;
24
+ media?: AgentMediaLimits;
25
+ resolveUser?: (id: string) => Promise<AuthUser | null>;
26
+ resolveLinkedUser?: (connectorId: string, externalId: string) => Promise<AuthUser | null>;
27
+ now?: () => Date;
28
+ timeZone?: string;
29
+ world?: AreaWorld;
30
+ domainAreas?: () => Promise<SystemAreas>;
10
31
  }
11
- export declare function handleIncoming(connector: Connector, conversation: IncomingConversation, deps?: PipelineDeps): Promise<void>;
32
+ export declare function handleIncoming(connector: Connector, turn: TurnRequest, deps?: PipelineDeps): Promise<void>;
@@ -1,23 +1,32 @@
1
- import { loadAllowed, saveAllowed, isAllowed, addAllowed, makeThrottle, allowFileFor } from "./allow.js";
1
+ import { makeThrottle } from "./allow.js";
2
2
  import { loadAgentId, loadGatePolicy } from "./config.js";
3
- import { buildSystem } from "./system-assembly.js";
3
+ import { assembleSystem, mergeAreas } from "./system-areas.js";
4
4
  import { attachmentMaterializer } from "./attachments.js";
5
5
  import { makeAttachmentCapabilities } from "./agent-capabilities.js";
6
6
  import { makeSystemCapabilities } from "./system-capabilities.js";
7
- import { makeSuggestionCapabilities } from "./suggestion-capabilities.js";
8
7
  import { resolveAgent } from "./registry.js";
9
8
  import { recordDiagnostic } from "./diagnostics.js";
10
9
  import { getLogger } from '@coffer-org/sdk/logger';
10
+ import { findLinkedUser } from "../identity-link.js";
11
+ import { buildContextFacts } from "./context-facts.js";
12
+ import { systemTimeZone } from "./environment.js";
13
+ import { discoverPlugins } from "../plugin-discovery.js";
14
+ import { SchemaCache } from "../mcp-contract/schema.js";
15
+ import { LocalClient } from "../mcp-local.js";
16
+ import { loadComposedLocales } from "../locale-registry.js";
17
+ import { buildDomainAreas } from "../mcp-tools.js";
18
+ import { getActiveRegistry } from "../registry-context.js";
11
19
  const log = getLogger('orchestrator');
12
20
  let logDb;
13
21
  export function setLogDb(db) {
14
22
  logDb = db;
15
23
  }
24
+ export const NOTICE_THROTTLE = { max: 5, windowMs: 60_000 };
16
25
  const throttleByConnector = new Map();
17
26
  function passThrottle(connectorId) {
18
27
  let t = throttleByConnector.get(connectorId);
19
28
  if (!t) {
20
- t = makeThrottle(5, 60_000);
29
+ t = makeThrottle(NOTICE_THROTTLE.max, NOTICE_THROTTLE.windowMs);
21
30
  throttleByConnector.set(connectorId, t);
22
31
  }
23
32
  return t;
@@ -32,14 +41,75 @@ function cachedAgentBase() {
32
41
  }
33
42
  return cachedBase;
34
43
  }
35
- function openChannel(connector, chatId, ctx) {
44
+ let cachedWorld;
45
+ export function liveWorld() {
46
+ if (!cachedWorld) {
47
+ cachedWorld = (async () => {
48
+ const [plugins, locales] = await Promise.all([discoverPlugins(), loadComposedLocales()]);
49
+ const shelves = await new SchemaCache(new LocalClient(), locales).index();
50
+ const pluginIds = new Set(plugins.map((p) => p.id));
51
+ const shelfKeys = new Set(shelves.map((s) => `${s.library}/${s.shelf}`));
52
+ let libraryIds;
53
+ try {
54
+ libraryIds = new Set(getActiveRegistry().libraries.map((v) => v.meta.id));
55
+ }
56
+ catch {
57
+ libraryIds = new Set(shelves.map((s) => s.library));
58
+ }
59
+ return {
60
+ hasPlugin: (id) => pluginIds.has(id),
61
+ hasLibrary: (id) => libraryIds.has(id),
62
+ hasShelf: (library, shelf) => shelfKeys.has(`${library}/${shelf}`),
63
+ };
64
+ })();
65
+ }
66
+ return cachedWorld;
67
+ }
68
+ let cachedDomain;
69
+ function cachedDomainAreas() {
70
+ if (!cachedDomain)
71
+ cachedDomain = buildDomainAreas();
72
+ return cachedDomain;
73
+ }
74
+ function foldContextFacts(messages) {
75
+ const acc = new Map();
76
+ for (const m of messages) {
77
+ for (const fact of m.context ?? []) {
78
+ if (fact.name === 'cleared')
79
+ acc.delete(fact.value);
80
+ else
81
+ acc.set(fact.name, fact);
82
+ }
83
+ }
84
+ return [...acc.values()];
85
+ }
86
+ const COFFER_USER_ID = /^[1-9][0-9]*$/;
87
+ const storeLookup = async (id) => (await import("../auth-store.js")).findUserById(id);
88
+ export async function defaultResolveUser(id, lookup = storeLookup) {
89
+ if (!COFFER_USER_ID.test(id) || !Number.isSafeInteger(Number(id)))
90
+ return null;
91
+ const user = await lookup(Number(id));
92
+ return user && !user.disabled ? user : null;
93
+ }
94
+ export async function defaultResolveLinkedUser(connectorId, externalId, lookup = storeLookup) {
95
+ const userId = await findLinkedUser(connectorId, externalId);
96
+ if (userId === null)
97
+ return null;
98
+ return await lookup(userId);
99
+ }
100
+ export async function resolveSpeaker(connectorId, sender, deps) {
101
+ return sender.idKind === 'coffer-user'
102
+ ? await (deps?.resolveUser ?? defaultResolveUser)(sender.id)
103
+ : await (deps?.resolveLinkedUser ?? defaultResolveLinkedUser)(connectorId, sender.id);
104
+ }
105
+ function openSink(connector, envelope) {
36
106
  try {
37
- return connector.reply(chatId, ctx);
107
+ return connector.open(envelope);
38
108
  }
39
109
  catch (err) {
40
110
  const message = err instanceof Error ? err.message : String(err);
41
- log.error(`connector error in reply: ${message}`);
42
- recordDiagnostic('error', 'connector.reply', message);
111
+ log.error(`connector error in open: ${message}`);
112
+ recordDiagnostic('error', 'connector.open', message);
43
113
  return null;
44
114
  }
45
115
  }
@@ -54,62 +124,83 @@ async function safeCall(fn, fallback, label) {
54
124
  return fallback;
55
125
  }
56
126
  }
57
- export async function handleIncoming(connector, conversation, deps) {
127
+ export async function handleIncoming(connector, turn, deps) {
58
128
  const policy = deps?.policy ?? (await loadGatePolicy());
59
- const selectedAgentId = conversation.agentId ?? policy.agentId ?? (deps?.runAgent ? undefined : await loadAgentId());
129
+ const selectedAgentId = turn.agentId ?? policy.agentId ?? (deps?.runAgent ? undefined : await loadAgentId());
60
130
  const runtime = deps?.runAgent ? undefined : resolveAgent(selectedAgentId);
61
131
  const agent = deps?.runAgent ?? runtime.run.bind(runtime);
132
+ const agentMedia = () => deps?.media ?? runtime.media;
62
133
  const db = deps && 'logDb' in deps ? deps.logDb : logDb;
63
134
  const agentBase = deps?.agentBase ?? (() => (selectedAgentId ? runtime.systemBase() : cachedAgentBase()));
64
- const { connectorId, chatId, sender, messages } = conversation;
135
+ const { connectorId, chatId } = turn.envelope;
136
+ const { sender } = turn;
137
+ const messages = turn.body.messages;
65
138
  const last = messages[messages.length - 1];
66
139
  if (!last || last.role !== 'user')
67
140
  return;
68
141
  const incomingMsgId = last.msgId;
69
- const allowFile = allowFileFor(connectorId);
70
- if (policy.accessPassword) {
71
- let allow = loadAllowed(allowFile);
72
- if (!isAllowed(allow, sender.id)) {
73
- if (last.content.trim() === policy.accessPassword) {
74
- allow = addAllowed(allow, sender.id, Date.now());
75
- saveAllowed(allowFile, allow);
76
- const ch = openChannel(connector, chatId, { parentMsgId: incomingMsgId });
77
- if (ch)
78
- await safeCall(() => ch.finish({ text: '✅ Access granted. Send your requests.', reasoning: null, suggestions: null }), null, 'finish(enroll)');
79
- }
80
- else if (passThrottle(connectorId)(sender.id)) {
81
- const ch = openChannel(connector, chatId, { parentMsgId: incomingMsgId });
82
- if (ch)
83
- await safeCall(() => ch.finish({
84
- text: '🔒 Access locked. Send the password to gain access.',
85
- reasoning: null,
86
- suggestions: null,
87
- }), null, 'finish(locked)');
142
+ if (policy.triggerPrefix && !last.content.toLowerCase().startsWith(policy.triggerPrefix.toLowerCase()))
143
+ return;
144
+ const speakerRow = await resolveSpeaker(connectorId, sender, deps);
145
+ if (speakerRow === null) {
146
+ if (passThrottle(connectorId)(sender.id)) {
147
+ const sink = openSink(connector, turn.envelope);
148
+ if (sink) {
149
+ sink.emit({ kind: 'notice', text: connector.enrolmentNotice });
150
+ await safeCall(() => sink.done(), undefined, 'done(unidentified)');
88
151
  }
89
- return;
90
152
  }
153
+ return;
91
154
  }
92
- if (policy.triggerPrefix && !last.content.toLowerCase().startsWith(policy.triggerPrefix.toLowerCase()))
155
+ if (turn.signal?.aborted)
93
156
  return;
94
157
  const queryText = policy.triggerPrefix ? last.content.slice(policy.triggerPrefix.length).trim() : last.content.trim();
95
158
  if (!queryText) {
96
- if (conversation.prepareAttachments && !policy.triggerPrefix) {
97
- await safeCall(() => conversation.prepareAttachments(attachmentMaterializer), messages, 'prepareAttachments');
159
+ if (turn.prepareAttachments && !policy.triggerPrefix) {
160
+ await safeCall(() => turn.prepareAttachments(attachmentMaterializer), messages, 'prepareAttachments');
98
161
  }
99
162
  return;
100
163
  }
101
- const preparedMessages = conversation.prepareAttachments
102
- ? await safeCall(() => conversation.prepareAttachments(attachmentMaterializer), messages, 'prepareAttachments')
164
+ const preparedMessages = turn.prepareAttachments
165
+ ? await safeCall(() => turn.prepareAttachments(attachmentMaterializer), messages, 'prepareAttachments')
103
166
  : messages;
104
- const agentMessages = preparedMessages.map((m, i) => i === preparedMessages.length - 1
105
- ? {
106
- ...m,
107
- ...(policy.triggerPrefix ? { content: queryText } : {}),
108
- ...(conversation.turnContext ? { context: conversation.turnContext } : {}),
109
- }
110
- : m);
111
- const base = await agentBase();
112
- const system = buildSystem({ base, channelSystem: conversation.channelSystem });
167
+ const speaker = {
168
+ id: sender.id,
169
+ name: speakerRow.displayName ?? sender.displayName ?? sender.id,
170
+ role: speakerRow.role,
171
+ };
172
+ const stated = preparedMessages.filter((m) => m.context?.length);
173
+ const lastStated = stated[stated.length - 1];
174
+ const now = deps?.now ? deps.now() : new Date();
175
+ const facts = buildContextFacts({
176
+ connectorFacts: turn.turnContext ?? [],
177
+ speaker,
178
+ now,
179
+ timeZone: deps?.timeZone ?? systemTimeZone(),
180
+ previous: stated.length ? foldContextFacts(preparedMessages) : null,
181
+ previousAt: lastStated ? new Date(lastStated.ts * 1000) : null,
182
+ });
183
+ const withText = preparedMessages.map((m, i) => i === preparedMessages.length - 1 && policy.triggerPrefix ? { ...m, content: queryText } : m);
184
+ const userMsg = withText[withText.length - 1];
185
+ const agentMessages = facts.length ? [...withText.slice(0, -1), { ...userMsg, context: facts }] : withText;
186
+ const persistContext = async () => {
187
+ if (!facts.length)
188
+ return;
189
+ await safeCall(() => connector.recordContext({
190
+ chatId,
191
+ userMsgId: incomingMsgId,
192
+ facts,
193
+ ts: userMsg.ts - 1,
194
+ }), undefined, 'recordContext');
195
+ };
196
+ const authored = turn.body.systemPrompt;
197
+ const domain = await (deps?.domainAreas ?? cachedDomainAreas)();
198
+ const areas = mergeAreas({ root: [await agentBase()] }, domain, authored);
199
+ const system = assembleSystem(areas, deps?.world ?? (await liveWorld()));
200
+ if (turn.signal?.aborted) {
201
+ await persistContext();
202
+ return;
203
+ }
113
204
  db?.logTurn({
114
205
  connector: connectorId,
115
206
  chatId,
@@ -120,79 +211,108 @@ export async function handleIncoming(connector, conversation, deps) {
120
211
  tokensOut: null,
121
212
  ms: null,
122
213
  agentId: selectedAgentId ?? null,
123
- presetId: conversation.presetId ?? null,
214
+ presetId: turn.presetId ?? null,
124
215
  });
125
216
  const startedAt = Date.now();
126
- const ch = openChannel(connector, chatId, { parentMsgId: incomingMsgId });
127
- if (!ch)
217
+ const sink = openSink(connector, turn.envelope);
218
+ if (!sink)
128
219
  return;
129
- let result;
130
- const suggestionCaps = conversation.supportsSuggestions ? makeSuggestionCapabilities() : undefined;
220
+ let usage;
221
+ let answerText = null;
222
+ let runFailed = false;
223
+ let agentTurn;
131
224
  try {
132
- const attachmentTools = await makeAttachmentCapabilities(agentMessages.flatMap((m) => m.attachments ?? []));
225
+ const attachmentTools = await makeAttachmentCapabilities(agentMessages.flatMap((m) => m.attachments ?? []), agentMedia());
133
226
  const toolProvider = {
134
- tools: [...attachmentTools.tools, ...makeSystemCapabilities().tools, ...(suggestionCaps?.tools ?? [])],
227
+ tools: [...attachmentTools.tools, ...makeSystemCapabilities().tools],
135
228
  };
136
- result = await agent({
137
- system,
138
- messages: agentMessages,
229
+ agentTurn = {
230
+ envelope: turn.envelope,
231
+ body: { system, messages: agentMessages, userTurns: turn.body.userTurns },
139
232
  toolProvider,
140
- ...(conversation.presetId ? { presetId: conversation.presetId } : {}),
141
- onDelta: (acc) => {
142
- try {
143
- ch.update(acc);
233
+ ...(turn.presetId ? { presetId: turn.presetId } : {}),
234
+ senderRole: speaker.role,
235
+ ...(turn.signal ? { signal: turn.signal } : {}),
236
+ };
237
+ await agent(agentTurn, (event) => {
238
+ try {
239
+ if (event.kind === 'usage') {
240
+ usage = { tokensIn: event.tokensIn, tokensOut: event.tokensOut, presetId: event.presetId };
144
241
  }
145
- catch (err) {
146
- log.error(`connector error in update: ${err instanceof Error ? err.message : String(err)}`);
242
+ else if (event.kind === 'answer') {
243
+ answerText = event.text;
147
244
  }
148
- },
149
- onReasoning: (acc) => {
245
+ sink.emit(event);
246
+ }
247
+ catch (err) {
248
+ log.error(`connector error in ${event.kind}: ${err instanceof Error ? err.message : String(err)}`);
249
+ }
250
+ });
251
+ }
252
+ catch (err) {
253
+ runFailed = true;
254
+ const message = err instanceof Error ? err.message : String(err);
255
+ log.error(`agent error: ${message}`);
256
+ recordDiagnostic('error', 'agent.run', message);
257
+ try {
258
+ sink.emit({ kind: 'error', message });
259
+ }
260
+ catch (sinkErr) {
261
+ log.error(`connector error in error: ${sinkErr instanceof Error ? sinkErr.message : String(sinkErr)}`);
262
+ }
263
+ }
264
+ await safeCall(() => sink.done(), undefined, 'done');
265
+ if (!runFailed && answerText && agentTurn) {
266
+ const events = turn.body.capabilities.events;
267
+ const want = { suggestions: events.includes('suggestions'), title: events.includes('title') };
268
+ const afterwordFn = deps?.afterword ?? runtime?.afterword;
269
+ if ((want.suggestions || want.title) && afterwordFn) {
270
+ let extra;
271
+ try {
272
+ extra = await afterwordFn(agentTurn, answerText, want);
273
+ }
274
+ catch (err) {
275
+ const message = err instanceof Error ? err.message : String(err);
276
+ recordDiagnostic('error', 'agent.afterword', message);
277
+ }
278
+ if (extra?.suggestions?.length) {
150
279
  try {
151
- ch.updateReasoning?.(acc);
280
+ sink.emit({ kind: 'suggestions', items: extra.suggestions });
152
281
  }
153
282
  catch (err) {
154
- log.error(`connector error in updateReasoning: ${err instanceof Error ? err.message : String(err)}`);
283
+ log.error(`connector error in suggestions: ${err instanceof Error ? err.message : String(err)}`);
155
284
  }
156
- },
157
- onSegment: () => {
285
+ }
286
+ if (extra?.title) {
158
287
  try {
159
- ch.segment();
288
+ sink.emit({ kind: 'title', text: extra.title });
160
289
  }
161
290
  catch (err) {
162
- log.error(`connector error in segment: ${err instanceof Error ? err.message : String(err)}`);
291
+ log.error(`connector error in title: ${err instanceof Error ? err.message : String(err)}`);
163
292
  }
164
- },
165
- });
166
- }
167
- catch (err) {
168
- const message = err instanceof Error ? err.message : String(err);
169
- log.error(`agent error: ${message}`);
170
- recordDiagnostic('error', 'agent.run', message);
293
+ }
294
+ if (usage && extra && (extra.tokensIn !== undefined || extra.tokensOut !== undefined)) {
295
+ usage = {
296
+ ...usage,
297
+ tokensIn: usage.tokensIn === null ? null : usage.tokensIn + (extra.tokensIn ?? 0),
298
+ tokensOut: usage.tokensOut === null ? null : usage.tokensOut + (extra.tokensOut ?? 0),
299
+ };
300
+ }
301
+ }
171
302
  }
172
- if (result && suggestionCaps)
173
- result.suggestions = suggestionCaps.getCaptured();
174
- const botMsgId = await safeCall(() => ch.finish({
175
- text: result?.text ?? null,
176
- reasoning: result?.reasoning ?? null,
177
- suggestions: result?.suggestions ?? null,
178
- }), null, 'finish');
179
- if (result?.text)
303
+ await safeCall(() => sink.done(), undefined, 'done');
304
+ if (!runFailed && usage && answerText)
180
305
  db?.logTurn({
181
306
  connector: connectorId,
182
307
  chatId,
183
308
  userId: sender.id,
184
309
  role: 'assistant',
185
- text: result.text,
186
- tokensIn: result.tokensIn,
187
- tokensOut: result.tokensOut,
310
+ text: answerText,
311
+ tokensIn: usage.tokensIn,
312
+ tokensOut: usage.tokensOut,
188
313
  ms: Date.now() - startedAt,
189
314
  agentId: selectedAgentId ?? null,
190
- presetId: result.presetId,
315
+ presetId: usage.presetId,
191
316
  });
192
- await safeCall(() => connector.recordAssistant({
193
- parentMsgId: incomingMsgId,
194
- botMsgId,
195
- text: result?.text ?? '',
196
- reasoning: result?.reasoning ?? null,
197
- }), undefined, 'recordAssistant');
317
+ await persistContext();
198
318
  }
@@ -1,4 +1,4 @@
1
- import type { AgentDescriptor, AgentRuntime, ConnectorRegistration } from './types.ts';
1
+ import type { AgentCatalogEntry, AgentRuntime, ConnectorRegistration } from './types.ts';
2
2
  export declare function registerAgent(runtime: AgentRuntime, opts?: {
3
3
  default?: boolean;
4
4
  }): () => void;
@@ -8,6 +8,8 @@ export declare function registerConnector(registration: ConnectorRegistration):
8
8
  export declare function isConnectorRegistered(id: string): boolean;
9
9
  export declare function listRegisteredAgents(): string[];
10
10
  export declare function listRegisteredConnectors(): string[];
11
- export declare function listAgentCatalog(): Promise<AgentDescriptor[]>;
11
+ export declare function listLinkableConnectors(): string[];
12
+ export declare function connectorLinkUrl(connectorId: string, code: string): string | undefined;
13
+ export declare function listAgentCatalog(): Promise<AgentCatalogEntry[]>;
12
14
  export declare function getDefaultAgentId(): string | undefined;
13
15
  export declare function clearRuntimeRegistries(): void;
@@ -48,6 +48,15 @@ export function listRegisteredAgents() {
48
48
  export function listRegisteredConnectors() {
49
49
  return [...connectors.keys()].sort();
50
50
  }
51
+ export function listLinkableConnectors() {
52
+ return [...connectors.entries()]
53
+ .filter(([, registration]) => typeof registration.linkUrl === 'function')
54
+ .map(([id]) => id)
55
+ .sort();
56
+ }
57
+ export function connectorLinkUrl(connectorId, code) {
58
+ return connectors.get(connectorId)?.linkUrl?.(code);
59
+ }
51
60
  export async function listAgentCatalog() {
52
61
  const ids = [...agents.keys()].sort();
53
62
  const out = [];
@@ -56,7 +65,7 @@ export async function listAgentCatalog() {
56
65
  if (!runtime)
57
66
  continue;
58
67
  try {
59
- out.push(await runtime.describe());
68
+ out.push({ ...(await runtime.describe()), media: runtime.media });
60
69
  }
61
70
  catch (err) {
62
71
  log.error(`agent describe failed for ${id}: ${err instanceof Error ? err.message : String(err)}`);
@@ -0,0 +1,12 @@
1
+ export type SystemAreas = Record<string, string[]>;
2
+ export interface AreaWorld {
3
+ hasPlugin(id: string): boolean;
4
+ hasLibrary(id: string): boolean;
5
+ hasShelf(library: string, shelf: string): boolean;
6
+ }
7
+ export declare const KIND_ORDER: readonly ["root", "plugin", "library", "shelf", "channel"];
8
+ export declare const UNCONDITIONAL: ReadonlySet<string>;
9
+ export declare function rank(key: string): number;
10
+ export declare const ALL_PRESENT: AreaWorld;
11
+ export declare function mergeAreas(...areas: SystemAreas[]): SystemAreas;
12
+ export declare function assembleSystem(areas: SystemAreas, world: AreaWorld): string[];
@@ -0,0 +1,63 @@
1
+ import { getLogger } from '@coffer-org/sdk/logger';
2
+ const log = getLogger('orchestrator');
3
+ export const KIND_ORDER = ['root', 'plugin', 'library', 'shelf', 'channel'];
4
+ export const UNCONDITIONAL = new Set(['root', 'channel']);
5
+ function keep(key, world) {
6
+ if (UNCONDITIONAL.has(key))
7
+ return true;
8
+ const colon = key.indexOf(':');
9
+ if (colon < 0)
10
+ return null;
11
+ const kind = key.slice(0, colon);
12
+ const id = key.slice(colon + 1);
13
+ if (!id)
14
+ return null;
15
+ if (kind === 'plugin')
16
+ return world.hasPlugin(id);
17
+ if (kind === 'library')
18
+ return world.hasLibrary(id);
19
+ if (kind === 'shelf') {
20
+ const slash = id.indexOf('/');
21
+ if (slash < 1 || slash === id.length - 1 || id.indexOf('/', slash + 1) >= 0)
22
+ return null;
23
+ return world.hasShelf(id.slice(0, slash), id.slice(slash + 1));
24
+ }
25
+ return null;
26
+ }
27
+ export function rank(key) {
28
+ const colon = key.indexOf(':');
29
+ const kind = colon < 0 ? key : key.slice(0, colon);
30
+ const i = KIND_ORDER.indexOf(kind);
31
+ return i < 0 ? KIND_ORDER.length : i;
32
+ }
33
+ export const ALL_PRESENT = {
34
+ hasPlugin: () => true,
35
+ hasLibrary: () => true,
36
+ hasShelf: () => true,
37
+ };
38
+ export function mergeAreas(...areas) {
39
+ const out = {};
40
+ for (const a of areas) {
41
+ for (const [key, lines] of Object.entries(a)) {
42
+ out[key] = [...(out[key] ?? []), ...lines];
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+ export function assembleSystem(areas, world) {
48
+ const kept = [];
49
+ for (const key of Object.keys(areas)) {
50
+ const verdict = keep(key, world);
51
+ if (verdict === null) {
52
+ log.warn(`unknown system-prompt area '${key}' — dropped`);
53
+ continue;
54
+ }
55
+ if (verdict)
56
+ kept.push(key);
57
+ }
58
+ kept.sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0));
59
+ return kept
60
+ .flatMap((key) => areas[key] ?? [])
61
+ .map((line) => line.trim())
62
+ .filter(Boolean);
63
+ }
@@ -42,7 +42,7 @@ export function makeSystemCapabilities() {
42
42
  'platform, memory, and the database file path with its size and free disk space. ' +
43
43
  'Use it for questions about the instance itself ("which version am I running", "is the disk filling up", ' +
44
44
  '"how long has the server been up"). It returns nothing about the user\'s records — use count_records for those. ' +
45
- 'The current date and time are already given in the system prompt; do not call this tool to find them.',
45
+ "The current date and time are already given in each turn's context; do not call this tool to find them.",
46
46
  inputSchema: {},
47
47
  handler: async () => ({
48
48
  coffer_version: await runtimeVersion(),