@rebon/cli-darwin-arm64 0.17.2 → 0.18.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 (82) hide show
  1. package/bin/compose-runtime/package.json +17 -0
  2. package/bin/compose-runtime/payload/compose/credentials-runtime.js +38 -0
  3. package/bin/compose-runtime/payload/compose/llm-runtime.js +330 -0
  4. package/bin/compose-runtime/payload/compose/loop-assembly.js +149 -0
  5. package/bin/compose-runtime/payload/compose/serve-dispatch.js +82 -0
  6. package/bin/compose-runtime/payload/compose/shims/dsh-anonymous-user-id.js +10 -0
  7. package/bin/compose-runtime/payload/compose/shims/dsh-credentials.js +13 -0
  8. package/bin/compose-runtime/payload/compose/shims/dsh-launch-environment.js +21 -0
  9. package/bin/compose-runtime/payload/compose/shims/dsh-llm.js +313 -0
  10. package/bin/compose-runtime/payload/compose/shims/dsh-settings.js +43 -0
  11. package/bin/compose-runtime/payload/compose/shims/dsh-tools.js +20 -0
  12. package/bin/compose-runtime/payload/compose/shims/dsh-web.js +10 -0
  13. package/bin/compose-runtime/payload/compose/shims/llm-adapter.js +32 -0
  14. package/bin/compose-runtime/payload/compose/shims/zod-lite.js +49 -0
  15. package/bin/compose-runtime/payload/compose/systemprompt-runtime.js +74 -0
  16. package/bin/compose-runtime/payload/compose/tools-runtime.js +228 -0
  17. package/bin/compose-runtime/payload/compose/web-runtime.js +204 -0
  18. package/bin/compose-runtime/payload/vendor/cordis/index.js +1530 -0
  19. package/bin/compose-runtime/payload/vendor/cosmokit/LICENSE +21 -0
  20. package/bin/compose-runtime/payload/vendor/cosmokit/index.mjs +357 -0
  21. package/bin/compose-runtime/payload/vendor/dsh/LICENSE-agent +21 -0
  22. package/bin/compose-runtime/payload/vendor/dsh/LICENSE-agent-loop +21 -0
  23. package/bin/compose-runtime/payload/vendor/dsh/LICENSE-llm-core +21 -0
  24. package/bin/compose-runtime/payload/vendor/dsh/LICENSE-llm-deepseek +21 -0
  25. package/bin/compose-runtime/payload/vendor/dsh/LICENSE-logger-console +21 -0
  26. package/bin/compose-runtime/payload/vendor/dsh/LICENSE-scope +21 -0
  27. package/bin/compose-runtime/payload/vendor/dsh/LICENSE-session +21 -0
  28. package/bin/compose-runtime/payload/vendor/dsh/LICENSE-system-prompt +21 -0
  29. package/bin/compose-runtime/payload/vendor/dsh/LICENSE-timeout +21 -0
  30. package/bin/compose-runtime/payload/vendor/dsh/LICENSE-timer +21 -0
  31. package/bin/compose-runtime/payload/vendor/dsh/LICENSE-tool-todo +21 -0
  32. package/bin/compose-runtime/payload/vendor/dsh/LICENSE-tool-web +21 -0
  33. package/bin/compose-runtime/payload/vendor/dsh/LICENSE-tools-schema +21 -0
  34. package/bin/compose-runtime/payload/vendor/dsh/LICENSE-web-search-exa +21 -0
  35. package/bin/compose-runtime/payload/vendor/dsh/agent-loop.js +1193 -0
  36. package/bin/compose-runtime/payload/vendor/dsh/agent.js +714 -0
  37. package/bin/compose-runtime/payload/vendor/dsh/llm-core.js +269 -0
  38. package/bin/compose-runtime/payload/vendor/dsh/llm-deepseek.js +672 -0
  39. package/bin/compose-runtime/payload/vendor/dsh/logger-console.js +83 -0
  40. package/bin/compose-runtime/payload/vendor/dsh/scope.js +287 -0
  41. package/bin/compose-runtime/payload/vendor/dsh/session.js +1668 -0
  42. package/bin/compose-runtime/payload/vendor/dsh/system-prompt.js +309 -0
  43. package/bin/compose-runtime/payload/vendor/dsh/timeout.js +100 -0
  44. package/bin/compose-runtime/payload/vendor/dsh/timer.js +128 -0
  45. package/bin/compose-runtime/payload/vendor/dsh/tool-todo.js +143 -0
  46. package/bin/compose-runtime/payload/vendor/dsh/tool-web.js +1527 -0
  47. package/bin/compose-runtime/payload/vendor/dsh/tools-schema.js +849 -0
  48. package/bin/compose-runtime/payload/vendor/dsh/web-search-exa.js +122 -0
  49. package/bin/compose-runtime/payload/vendor/eventsource-parser/LICENSE +21 -0
  50. package/bin/compose-runtime/payload/vendor/eventsource-parser/index.js +177 -0
  51. package/bin/compose-runtime/payload/vendor/eventsource-parser/stream.js +48 -0
  52. package/bin/compose-runtime/payload/vendor/schemastery/index.mjs +656 -0
  53. package/bin/compose-runtime/payload-manifests.json +48 -0
  54. package/bin/compose-runtime/src/bridge.mjs +142 -0
  55. package/bin/compose-runtime/src/credentials-runtime.mjs +50 -0
  56. package/bin/compose-runtime/src/eventsource-stream.mjs +48 -0
  57. package/bin/compose-runtime/src/index.mjs +7 -0
  58. package/bin/compose-runtime/src/llm-runtime.mjs +326 -0
  59. package/bin/compose-runtime/src/loader.mjs +78 -0
  60. package/bin/compose-runtime/src/loop-assembly.mjs +237 -0
  61. package/bin/compose-runtime/src/payload.mjs +49 -0
  62. package/bin/compose-runtime/src/plugin.mjs +59 -0
  63. package/bin/compose-runtime/src/realm.mjs +223 -0
  64. package/bin/compose-runtime/src/registry.mjs +191 -0
  65. package/bin/compose-runtime/src/resolve.mjs +115 -0
  66. package/bin/compose-runtime/src/systemprompt-runtime.mjs +67 -0
  67. package/bin/compose-runtime/src/tools-runtime.mjs +239 -0
  68. package/bin/compose-runtime/src/web-runtime.mjs +209 -0
  69. package/bin/plugin-host/package.json +11 -0
  70. package/bin/plugin-host/src/bridge.mjs +64 -0
  71. package/bin/plugin-host/src/cli.mjs +105 -0
  72. package/bin/plugin-host/src/framing.mjs +113 -0
  73. package/bin/plugin-host/src/host.mjs +624 -0
  74. package/bin/plugin-host/src/json.mjs +196 -0
  75. package/bin/plugin-host/src/lifecycle.mjs +114 -0
  76. package/bin/plugin-host/src/loader.mjs +142 -0
  77. package/bin/plugin-host/src/methods.mjs +256 -0
  78. package/bin/plugin-host/src/protocol.mjs +129 -0
  79. package/bin/plugin-host/src/sdk.mjs +7 -0
  80. package/bin/rebon +0 -0
  81. package/bin/rebon-boa-helper +0 -0
  82. package/package.json +4 -1
@@ -0,0 +1,78 @@
1
+ // What a plugin package *is*, when the composition is in the picture.
2
+ //
3
+ // The host knows one shape: a module exporting `activate`, which registers
4
+ // through the api it is handed. A Cordis plugin knows nothing about any of
5
+ // that — it exports `apply(ctx, config)` and registers by calling services on
6
+ // a context it shares with every other entry. Both are plugins on the plane;
7
+ // they differ only in what "loading" means, which is exactly the question the
8
+ // host delegates.
9
+ //
10
+ // The rule, in order, and deliberately not a heuristic — a package's shape is
11
+ // something its author decides, so it should be decidable by reading it:
12
+ //
13
+ // 1. `activate` is a function → the host's own loader
14
+ // 2. `apply` is the module's own function → a Cordis plugin (the module)
15
+ // 3. `default` is an object with `apply` → a Cordis plugin
16
+ // 4. `default` is a function → a Cordis functional plugin
17
+ // 5. anything else → the host's own loader, which
18
+ // refuses it by name
19
+ //
20
+ // Step 1 comes first so a rebon-native plugin is never mistaken for a Cordis
21
+ // one, and step 4 is last so the ambiguous case (a bare default function) goes
22
+ // to the composition rather than being guessed at from argument counts.
23
+ import { resolveEntry } from '../../plugin-host/src/loader.mjs';
24
+
25
+ const own = (value, key) => value != null && Object.prototype.hasOwnProperty.call(value, key);
26
+
27
+ class LoaderError extends Error {
28
+ constructor(code, message) {
29
+ super(message);
30
+ this.code = code;
31
+ this.name = 'LoaderError';
32
+ }
33
+ }
34
+
35
+ /// The Cordis plugin a module holds, or undefined if it does not hold one.
36
+ export function cordisPluginOf(module) {
37
+ if (typeof module?.activate === 'function') return undefined;
38
+ if (own(module, 'apply') && typeof module.apply === 'function') return module;
39
+ const fallback = module?.default;
40
+ if (fallback && typeof fallback === 'object' && typeof fallback.apply === 'function') return fallback;
41
+ if (typeof fallback === 'function') return fallback;
42
+ return undefined;
43
+ }
44
+
45
+ /// Builds the load/unload seams the host runs with.
46
+ ///
47
+ /// `next` is the host's own loader, handed back untouched for anything this
48
+ /// module does not recognise — an adapter that reimplemented it would be a
49
+ /// second set of rules for the same shape.
50
+ export async function createLoader({ next }) {
51
+ return {
52
+ async load(request) {
53
+ const url = resolveEntry(request.root, request.entry);
54
+ let module;
55
+ try {
56
+ module = await import(url);
57
+ } catch (cause) {
58
+ throw new LoaderError('[ENTRY_FAILED]', `plugin entry ${request.entry} failed to load: ${cause?.message ?? cause}`);
59
+ }
60
+ const plugin = cordisPluginOf(module);
61
+ // Importing is idempotent, so handing the already-imported module back to
62
+ // the host's loader costs nothing and keeps one import per entry — which
63
+ // matters, because a module evaluated twice would register twice.
64
+ if (plugin === undefined) return next(request, async () => module);
65
+
66
+ // Imported lazily: this module is loaded by the host at startup, long
67
+ // before module resolution for the payload has been installed.
68
+ const { mountEntry } = await import('./realm.mjs');
69
+ const sink = await mountEntry(request, plugin);
70
+ return sink.loaded();
71
+ },
72
+
73
+ async unload(pluginId) {
74
+ const { disposeEntry } = await import('./realm.mjs');
75
+ await disposeEntry(pluginId);
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,237 @@
1
+ // The composition-side glue that lets the REAL dsh agent loop drive a turn
2
+ // inside the composition realm.
3
+ //
4
+ // Ported from `js/compose/loop-assembly.js`. Three duties, all rebon-owned
5
+ // (the dsh packages stay unmodified):
6
+ //
7
+ // 1. **Tool-schema supply** — a dsh systemPrompt tool provider merging the
8
+ // composition's own registered definitions with rebon's core tool catalog,
9
+ // so what the model is offered and what the scheduler can dispatch stay one
10
+ // fact (a local definition shadows a core tool name in both places).
11
+ // 2. **Observability** — every dsh `session/event` (chunks included: the
12
+ // embedder's session face streams from them) and `agent/error` is published
13
+ // onto rebon's event plane.
14
+ // 3. **Drive** — `loop:control` is the embedder's inbound face
15
+ // (followup / steer / cancel / status).
16
+ //
17
+ // Two things moved with the transport:
18
+ //
19
+ // * **The catalog is configuration, not a call.** It used to be
20
+ // `describeTools()`, an op straight into rebon. There is no such method on
21
+ // the plane, and there should not be: what rebon offers a model is settled
22
+ // when the composition is built, so rebon hands it over at load. A snapshot
23
+ // either way — the old op was read once during `apply` too.
24
+ // * **Events publish through a scope handle.** A loop produces turn events on
25
+ // its own schedule, with no inbound call to hang them on, so the assembly
26
+ // takes a handle on each open session and fans its events to whichever are
27
+ // live. A loop exists before any session opens, so the first events are
28
+ // held — boundedly — until one does, and the self-drive kickoff waits for
29
+ // the same moment: an agent that ran a whole turn before anyone was
30
+ // listening would be a turn rebon never saw.
31
+ import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm';
32
+ import { logger } from './bridge.mjs';
33
+ import { sinkOf } from './registry.mjs';
34
+
35
+ export const name = 'rebon-loop-assembly';
36
+ export const inject = ['systemPrompt', 'tools'];
37
+
38
+ /** Topics this assembly publishes; a loop plugin declares exactly these. */
39
+ export const LOOP_TOPICS = Object.freeze([
40
+ 'loop:event',
41
+ 'loop:agent-error',
42
+ 'loop:agent-created',
43
+ ]);
44
+
45
+ export async function apply(ctx, config = {}) {
46
+ const sink = sinkOf(ctx);
47
+ if (sink === undefined) {
48
+ throw new Error('rebon-loop-assembly must be mounted as a composition entry');
49
+ }
50
+ // Embedder-chosen discriminator: with several loops in one composition, every
51
+ // published event carries the owning loop's tag so subscribers can tell them
52
+ // apart.
53
+ const tag = typeof config.tag === 'string' && config.tag.length > 0 ? config.tag : undefined;
54
+ const stamp = (payload) => (tag === undefined ? payload : { tag, ...payload });
55
+
56
+ // Sessions this loop is attached to. A published event goes to all of them;
57
+ // in the ordinary case there is exactly one.
58
+ const sessions = new Set();
59
+ // A loop starts existing when it is loaded, and a session opens after — so
60
+ // the first events (an agent being created, above all) happen with nobody
61
+ // listening. Holding them until the first session attaches is the difference
62
+ // between "rebon saw the loop start" and "rebon saw the loop already
63
+ // running"; the bound is here so a loop nobody ever attaches to cannot grow
64
+ // without limit, and passing it is said out loud rather than swallowed.
65
+ const PRE_ATTACH_LIMIT = 256;
66
+ let attached = false;
67
+ let dropped = 0;
68
+ const held = [];
69
+ const deliver = (session, topic, payload) => {
70
+ // Best effort, and never a reason for the loop to fail: an event nobody
71
+ // could be told about is not a turn that went wrong.
72
+ void session.publish(topic, payload).catch((cause) => {
73
+ logger.warn(`loop-assembly: publishing ${topic} failed: ${cause?.message ?? cause}`);
74
+ });
75
+ };
76
+ sink.scope((scopeCtx) => {
77
+ sessions.add(scopeCtx);
78
+ if (!attached) {
79
+ attached = true;
80
+ if (dropped > 0) {
81
+ logger.warn(`loop-assembly: dropped ${dropped} event(s) produced before any session attached`);
82
+ }
83
+ for (const [topic, payload] of held.splice(0)) deliver(scopeCtx, topic, payload);
84
+ }
85
+ kickoff();
86
+ return () => sessions.delete(scopeCtx);
87
+ });
88
+ const announce = (topic, payload) => {
89
+ const stamped = stamp(payload);
90
+ if (!attached) {
91
+ if (held.length >= PRE_ATTACH_LIMIT) {
92
+ held.shift();
93
+ dropped += 1;
94
+ }
95
+ held.push([topic, stamped]);
96
+ return;
97
+ }
98
+ for (const session of sessions) deliver(session, topic, stamped);
99
+ };
100
+
101
+ // Optional prompt contribution into the loop realm's REAL dsh systemPrompt
102
+ // (the loop's own prompt plane, not rebon's seat — `isolate` keeps them
103
+ // apart, which is why the composition structure declares it).
104
+ if (config.section !== undefined && config.section !== null && typeof config.section === 'object') {
105
+ ctx.systemPrompt.section(config.section);
106
+ }
107
+
108
+ const seatSchemas = (Array.isArray(config.toolCatalog) ? config.toolCatalog : []).map((tool) => ({
109
+ name: String(tool?.name ?? ''),
110
+ description: String(tool?.description ?? ''),
111
+ parameters: tool?.inputSchema ?? tool?.parameters ?? { type: 'object' },
112
+ })).filter((tool) => tool.name.length > 0);
113
+
114
+ ctx.systemPrompt.tools(() => {
115
+ const merged = new Map();
116
+ for (const schema of seatSchemas) merged.set(schema.name, schema);
117
+ for (const [toolName, def] of ctx.tools.defs) {
118
+ merged.set(toolName, {
119
+ name: toolName,
120
+ description: String(def.description ?? ''),
121
+ parameters: def.parameters ?? { type: 'object' },
122
+ });
123
+ }
124
+ return { schemas: [...merged.values()] };
125
+ });
126
+
127
+ // Full-fidelity relay: the dsh session log IS the loop's truth, and the
128
+ // embedder's session face (streaming UI, transcript projection) needs every
129
+ // event — chunks included.
130
+ ctx.on('session/event', (session, event) => {
131
+ announce('loop:event', {
132
+ sessionId: String(session.id),
133
+ seq: event.seq,
134
+ type: event.type,
135
+ data: event.data ?? null,
136
+ });
137
+ });
138
+
139
+ ctx.on('agent/error', (payload) => {
140
+ announce('loop:agent-error', {
141
+ agentId: String(payload.agent?.id ?? ''),
142
+ turn: payload.turn,
143
+ step: payload.step,
144
+ error: errorChain(payload.error),
145
+ });
146
+ });
147
+
148
+ // Which agents are this loop's.
149
+ //
150
+ // The dsh `agents` registry is one per composition realm, so with more than
151
+ // one loop mounted every assembly hears every agent being created. Naming the
152
+ // configured ids is how an assembly knows which of them it is responsible
153
+ // for; dsh appends a session suffix to the configured id, so the match is on
154
+ // that stem. With no list the assembly adopts everything, which is the
155
+ // single-loop composition and the honest default for it.
156
+ const owned = Array.isArray(config.agents)
157
+ ? config.agents.map(String).filter((id) => id.length > 0)
158
+ : undefined;
159
+ const isMine = (agentId) => owned === undefined
160
+ || owned.some((id) => agentId === id || agentId.startsWith(`${id}-`));
161
+
162
+ const live = new Map();
163
+ const kicked = new Set();
164
+ // The self-drive convenience, deliberately tied to a session rather than to
165
+ // an agent appearing: an agent created before anyone is listening would run a
166
+ // whole turn whose events had nowhere to go.
167
+ function kickoff() {
168
+ const text = config.kickoff;
169
+ if (typeof text !== 'string' || text.length === 0) return;
170
+ if (!attached) return;
171
+ for (const [id, agent] of live) {
172
+ if (kicked.has(id)) continue;
173
+ kicked.add(id);
174
+ agent.followup(createUserMessage({
175
+ content: [{ type: 'text', text }],
176
+ source: { kind: 'user' },
177
+ }));
178
+ }
179
+ }
180
+ ctx.on('agent/created', ({ agent }) => {
181
+ const agentId = String(agent.id);
182
+ if (!isMine(agentId)) return;
183
+ live.set(agentId, agent);
184
+ announce('loop:agent-created', { agentId });
185
+ kickoff();
186
+ });
187
+ ctx.on('agent/disposed', ({ agent }) => {
188
+ live.delete(String(agent.id));
189
+ });
190
+
191
+ // Session events carry no agent, so an assembly cannot tell whose turn it is
192
+ // from the event alone. Ownership of the *session* is the same question one
193
+ // step earlier: a session belongs to whichever agent is running in it.
194
+ const ownedSessions = new Set();
195
+ ctx.on('agent/created', ({ agent }) => {
196
+ if (isMine(String(agent.id)) && agent.session?.id !== undefined) {
197
+ ownedSessions.add(String(agent.session.id));
198
+ }
199
+ });
200
+
201
+ // Inbound control face: the embedder drives the loop with ordinary service
202
+ // calls. Commands address the single configured agent by default, or a
203
+ // specific one via `agentId`.
204
+ const resolveAgent = (input) => {
205
+ if (typeof input?.agentId === 'string') return live.get(input.agentId);
206
+ if (live.size === 1) return live.values().next().value;
207
+ return undefined;
208
+ };
209
+ const userMessage = (text) => createUserMessage({
210
+ content: [{ type: 'text', text: String(text) }],
211
+ source: { kind: 'user' },
212
+ });
213
+ sink.service('loop:control', (input) => {
214
+ const agent = resolveAgent(input);
215
+ if (agent === undefined) {
216
+ const known = [...live.keys()];
217
+ throw new Error(`loop:control: no live agent${input?.agentId ? ` "${input.agentId}"` : ''} (live: ${known.join(', ') || '(none)'})`);
218
+ }
219
+ switch (input?.kind) {
220
+ case 'followup':
221
+ agent.followup(userMessage(input.text ?? ''));
222
+ return { accepted: true, agentId: String(agent.id) };
223
+ case 'steer':
224
+ agent.steer(userMessage(input.text ?? ''));
225
+ return { accepted: true, agentId: String(agent.id) };
226
+ case 'cancel':
227
+ agent.cancel({ kind: 'user' });
228
+ return { accepted: true, agentId: String(agent.id) };
229
+ case 'status':
230
+ return { agentId: String(agent.id), status: agent.status };
231
+ default:
232
+ throw new Error(`loop:control: unknown command kind ${JSON.stringify(input?.kind)}`);
233
+ }
234
+ });
235
+ }
236
+
237
+ export default { name, inject, apply };
@@ -0,0 +1,49 @@
1
+ // Where the vendored JS payload lives.
2
+ //
3
+ // The payload — Cordis, cosmokit, schemastery and the dsh package set — is this
4
+ // package's own, in `payload/`. It used to be owned by `crates/rebon-kernel-js`
5
+ // and pointed at from here, because the deno_core host was what shipped and two
6
+ // copies of a 600KB vendored tree would diverge. V8 is gone, so the tree moved
7
+ // next to its runtime and this file is the only one that changed.
8
+ //
9
+ // `vendor/cordis/index.js` is the sentinel, for the same reason `bridge.js`
10
+ // is on the Rust side: every consumer loads it, so a directory without it is
11
+ // not a payload.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const SENTINEL = path.join('vendor', 'cordis', 'index.js');
17
+ const here = path.dirname(fileURLToPath(import.meta.url));
18
+
19
+ const holdsPayload = (dir) => fs.existsSync(path.join(dir, SENTINEL));
20
+
21
+ /// The payload directory, or a refusal naming what was tried.
22
+ ///
23
+ /// A ladder, not a search — the same rule `REBON_PLUGIN_NODE` follows on the
24
+ /// Rust side. `REBON_KERNEL_JS_DIR` is an explicit demand: if what it names
25
+ /// does not hold a payload, that is the answer, not a reason to go looking
26
+ /// somewhere else. Falling back would run the composition against a payload
27
+ /// the operator did not choose and never told them.
28
+ ///
29
+ /// Without the override there is one place it can be: `payload/` inside this
30
+ /// package, in a checkout and in a deployment alike.
31
+ ///
32
+ /// Throws rather than returning a guess, because every module specifier the
33
+ /// composition resolves is built from this path — a wrong one surfaces later
34
+ /// as two dozen unrelated "cannot find module" errors.
35
+ export function payloadDir() {
36
+ const override = process.env.REBON_KERNEL_JS_DIR;
37
+ if (override) {
38
+ if (holdsPayload(override)) return path.resolve(override);
39
+ throw new Error(
40
+ `[NO_PAYLOAD] REBON_KERNEL_JS_DIR names ${override}, which does not hold ${SENTINEL}`,
41
+ );
42
+ }
43
+ const beside = path.join(here, '..', 'payload');
44
+ if (holdsPayload(beside)) return path.resolve(beside);
45
+ throw new Error(
46
+ `[NO_PAYLOAD] no vendored JS payload found (looked for ${SENTINEL} under ${beside}); `
47
+ + 'set REBON_KERNEL_JS_DIR to the directory holding it',
48
+ );
49
+ }
@@ -0,0 +1,59 @@
1
+ // The composition control plugin.
2
+ //
3
+ // rebon loads this one first, and everything else the composition contains is
4
+ // loaded after it, as an ordinary `plugin/load` each. What this one does is
5
+ // make that possible: install module resolution for the vendored payload,
6
+ // create the shared Cordis realm the entries mount into, and answer for the
7
+ // facts the protocol has no field for.
8
+ //
9
+ // **Why the structure lives in this plugin's config.** Groups and `isolate` are
10
+ // composition structure, not plugin facts: a group is a container that is not a
11
+ // module, and an isolated realm is a relationship between entries. Neither can
12
+ // be said in one entry's own load request, so rebon says it once, here, naming
13
+ // only ids and placement. Each entry's `plugin/load` then carries its module
14
+ // and its own configuration.
15
+ //
16
+ // Module resolution has to be installed before Cordis is imported, and static
17
+ // imports are linked before a module body runs — which is why everything below
18
+ // is imported dynamically. Only Node's own builtins, which need no resolution,
19
+ // could be static, and none are needed.
20
+ export async function activate(api, config = {}) {
21
+ const { installModuleResolution } = await import('./resolve.mjs');
22
+ installModuleResolution({
23
+ ...(config.payloadDir ? { dir: config.payloadDir } : {}),
24
+ extraModules: config.modules ?? {},
25
+ });
26
+
27
+ const { withCall } = await import('./bridge.mjs');
28
+ const { builtinSeats, createRealm, mounted, reportFor } = await import('./realm.mjs');
29
+ await createRealm({ web: config.web ?? null, entries: config.entries ?? [] });
30
+
31
+ // Registered before any entry is loaded, so an entry claiming the same
32
+ // service name hits the registrar's refusal rather than replacing this.
33
+ //
34
+ // What it answers is the part of a load report the protocol cannot carry: a
35
+ // route's model catalog, a prompt section, a web provider. Those belong to
36
+ // one plugin kind, and the ready report is a shape every plugin shares — so
37
+ // they are asked for rather than bolted on.
38
+ api.service('compose', async (request, ctx) => withCall(ctx, async () => {
39
+ const kind = request?.kind;
40
+ if (kind === 'list') {
41
+ return { plugins: mounted(), builtin: builtinSeats() };
42
+ }
43
+ if (kind === 'report') {
44
+ const pluginId = String(request?.pluginId ?? '');
45
+ const report = reportFor(pluginId);
46
+ if (report === undefined) {
47
+ const error = new Error(`no composition entry ${JSON.stringify(pluginId)} is mounted`);
48
+ error.code = '[UNKNOWN_PLUGIN]';
49
+ throw error;
50
+ }
51
+ return report;
52
+ }
53
+ const error = new Error(`compose does not answer kind ${JSON.stringify(kind ?? null)}`);
54
+ error.code = '[UNKNOWN_CONTROL]';
55
+ throw error;
56
+ }));
57
+ }
58
+
59
+ export default activate;
@@ -0,0 +1,223 @@
1
+ // The composition realm: one Cordis context, shared by every entry.
2
+ //
3
+ // This is the whole reason the composition exists rather than each plugin
4
+ // living alone. Cordis plugins reach each other through services on a context —
5
+ // `inject: ['tools']`, `ctx.llm.registerAdapter(...)` — and that only works if
6
+ // they are mounted on the same one. So the plane's "one plugin, one load" is
7
+ // the outside view, and inside the host they all land here.
8
+ //
9
+ // Two things live here that a flat list of plugins cannot express, and both are
10
+ // composition structure rather than plugin facts, which is why rebon describes
11
+ // them once when the composition is created rather than per load:
12
+ //
13
+ // * **groups** — a container that is not a module. Unloading one is unloading
14
+ // its members, which rebon does by naming them; nothing is loaded for the
15
+ // group itself.
16
+ // * **isolate** — Cordis's own realm narrowing. The agent loop needs its own
17
+ // `systemPrompt` because the real dsh service and rebon's seat share that
18
+ // name; without isolation one of them loses.
19
+ //
20
+ // Ordering note: this module imports Cordis by bare specifier, so it must not
21
+ // be imported before `installModuleResolution()` has run. Everything that
22
+ // reaches it does so through a dynamic import for exactly that reason.
23
+ import { Context } from 'cordis';
24
+ import { OWNER, RegistrationSink, SINK } from './registry.mjs';
25
+ import RebonLlmRuntime from './llm-runtime.mjs';
26
+ import RebonCredentials from './credentials-runtime.mjs';
27
+ import RebonToolsRuntime from './tools-runtime.mjs';
28
+ import RebonWebRuntime from './web-runtime.mjs';
29
+ import RebonSystemPromptRuntime from './systemprompt-runtime.mjs';
30
+
31
+ /** The built-in seats, in mount order: a dsh `inject` is satisfied at mount
32
+ * time, so a seat that arrives after the plugin needing it never arrives. */
33
+ const BUILTIN_SEATS = ['llm', 'credentials', 'tools', 'web', 'systemPrompt'];
34
+
35
+ let state;
36
+
37
+ class ComposeError extends Error {
38
+ constructor(code, message) {
39
+ super(message);
40
+ this.code = code;
41
+ this.name = 'ComposeError';
42
+ }
43
+ }
44
+
45
+ /// Applies one node's `isolate` declaration to its parent context.
46
+ ///
47
+ /// Cordis semantics, unchanged: `true` mints an entry-local realm; a string
48
+ /// labels a realm SHARED by every node using the same label, so an in-group
49
+ /// provider serves in-group consumers while the outside world keeps its own
50
+ /// service under the same name.
51
+ function derive(parent, node, realms) {
52
+ let derived = parent;
53
+ if (node.isolate && typeof node.isolate === 'object') {
54
+ for (const [service, label] of Object.entries(node.isolate)) {
55
+ if (label === true) {
56
+ derived = derived.isolate(service);
57
+ continue;
58
+ }
59
+ const key = `${service} ${label}`;
60
+ if (!realms.has(key)) realms.set(key, Symbol(`${service}#${label}`));
61
+ derived = derived.isolate(service, realms.get(key));
62
+ }
63
+ }
64
+ return derived;
65
+ }
66
+
67
+ /// Walks the declared structure into a placement per entry id.
68
+ ///
69
+ /// The value is the context an entry is mounted *under*, not the entry's own —
70
+ /// that one is derived per load, because it carries the load's registration
71
+ /// sink and each load needs its own.
72
+ function place(nodes, parent, placements, groups, realms) {
73
+ for (const node of nodes ?? []) {
74
+ const id = node?.id;
75
+ if (typeof id !== 'string' || id.length === 0) {
76
+ throw new ComposeError('[BAD_STRUCTURE]', 'every entry in the composition structure needs an id');
77
+ }
78
+ if (placements.has(id) || groups.has(id)) {
79
+ throw new ComposeError('[BAD_STRUCTURE]', `duplicate entry id ${JSON.stringify(id)}`);
80
+ }
81
+ const derived = derive(parent, node, realms);
82
+ if (Array.isArray(node.group)) {
83
+ groups.set(id, derived);
84
+ place(node.group, derived, placements, groups, realms);
85
+ continue;
86
+ }
87
+ placements.set(id, derived);
88
+ }
89
+ }
90
+
91
+ /// Creates the realm. Called once, by the composition control plugin.
92
+ export async function createRealm({ web = null, entries = [] } = {}) {
93
+ if (state !== undefined) {
94
+ throw new ComposeError('[REALM_EXISTS]', 'the composition realm is already created');
95
+ }
96
+ const ctx = new Context();
97
+ const placements = new Map();
98
+ const groups = new Map();
99
+ const realms = new Map();
100
+ place(entries, ctx, placements, groups, realms);
101
+
102
+ await ctx.plugin(RebonLlmRuntime);
103
+ await ctx.plugin(RebonCredentials);
104
+ await ctx.plugin(RebonToolsRuntime);
105
+ await ctx.plugin(RebonWebRuntime, { web });
106
+ await ctx.plugin(RebonSystemPromptRuntime);
107
+
108
+ state = { ctx, placements, groups, realms, fibers: new Map(), sinks: new Map() };
109
+ return state;
110
+ }
111
+
112
+ /// The realm, or a refusal that names the ordering mistake.
113
+ export function realm() {
114
+ if (state === undefined) {
115
+ throw new ComposeError(
116
+ '[NO_REALM]',
117
+ 'the composition realm does not exist yet; rebon loads the composition control plugin before any entry',
118
+ );
119
+ }
120
+ return state;
121
+ }
122
+
123
+ /// Tears the realm down. Only tests need this; a real host exits instead.
124
+ export async function destroyRealm() {
125
+ if (state === undefined) return;
126
+ for (const pluginId of [...state.fibers.keys()].reverse()) await disposeEntry(pluginId);
127
+ await state.ctx.dispose?.();
128
+ state = undefined;
129
+ }
130
+
131
+ export function builtinSeats() {
132
+ return [...BUILTIN_SEATS];
133
+ }
134
+
135
+ /// The services a Cordis plugin cannot start without.
136
+ ///
137
+ /// Cordis accepts both spellings: a bare array is all-required, an object
138
+ /// separates `required` from `optional`.
139
+ function requiredInjects(plugin) {
140
+ const inject = plugin?.inject;
141
+ if (Array.isArray(inject)) return inject;
142
+ if (inject && typeof inject === 'object' && Array.isArray(inject.required)) return inject.required;
143
+ return [];
144
+ }
145
+
146
+ /// Mounts one Cordis entry and hands back what it registered.
147
+ ///
148
+ /// The sink rides on the entry's own derived context, which is what lets a seat
149
+ /// attribute a registration synchronously — the moment a plugin registers, the
150
+ /// context it registered through says which plugin it was.
151
+ export async function mountEntry(request, plugin) {
152
+ const live = realm();
153
+ const { pluginId } = request;
154
+ if (live.groups.has(pluginId)) {
155
+ throw new ComposeError(
156
+ '[GROUP_NOT_LOADABLE]',
157
+ `${pluginId} is a group in the composition structure, not a module; load its members`,
158
+ );
159
+ }
160
+ if (live.fibers.has(pluginId)) {
161
+ throw new ComposeError('[PLUGIN_ALREADY_LOADED]', `${pluginId} is already mounted`);
162
+ }
163
+ const parent = live.placements.get(pluginId) ?? live.ctx;
164
+ const sink = new RegistrationSink(pluginId, request);
165
+ const derived = parent.extend({ [OWNER]: pluginId, [SINK]: sink });
166
+ // Refused before mounting rather than waited for. Cordis lets a plugin wait
167
+ // for a service that has not arrived yet, and inside one composition that is
168
+ // a feature — but a load is a call rebon is waiting on, and a plugin waiting
169
+ // forever is a call that never answers. So a composition entry's required
170
+ // services must be there when it loads, and rebon orders entries so they
171
+ // are. Saying which one is missing turns a hang into a diagnosis.
172
+ for (const service of requiredInjects(plugin)) {
173
+ if (derived.get(service) === undefined) {
174
+ throw new ComposeError(
175
+ '[MISSING_INJECT]',
176
+ `${pluginId} requires service ${JSON.stringify(service)}, which nothing provides where it is mounted`,
177
+ );
178
+ }
179
+ }
180
+ let fiber;
181
+ try {
182
+ fiber = derived.plugin(plugin, request.config ?? undefined);
183
+ await fiber;
184
+ } catch (cause) {
185
+ // Whatever half-mounted has to go: a fiber left behind would keep serving
186
+ // through registrations rebon was never told about.
187
+ try {
188
+ await fiber?.dispose();
189
+ } catch {}
190
+ if (cause?.code) throw cause;
191
+ throw new ComposeError('[ACTIVATE_FAILED]', `mounting ${pluginId} failed: ${cause?.message ?? cause}`);
192
+ }
193
+ // Every catalog read has to finish before the report is sealed: a provider
194
+ // reported without its models would have rebon resolving against a table
195
+ // that is still being filled in.
196
+ await live.ctx.llm.settle();
197
+ sink.seal();
198
+ live.fibers.set(pluginId, fiber);
199
+ live.sinks.set(pluginId, sink);
200
+ return sink;
201
+ }
202
+
203
+ /// Disposes one entry. Idempotent: the host retires a plugin once, but a realm
204
+ /// torn down underneath it must not turn that into a second failure.
205
+ export async function disposeEntry(pluginId) {
206
+ if (state === undefined) return;
207
+ const fiber = state.fibers.get(pluginId);
208
+ state.fibers.delete(pluginId);
209
+ state.sinks.delete(pluginId);
210
+ if (fiber === undefined) return;
211
+ await fiber.dispose();
212
+ }
213
+
214
+ /// What one entry reported beyond the protocol's own fields.
215
+ export function reportFor(pluginId) {
216
+ const sink = realm().sinks.get(pluginId);
217
+ return sink === undefined ? undefined : sink.report();
218
+ }
219
+
220
+ /// Every entry currently mounted, in load order.
221
+ export function mounted() {
222
+ return [...realm().fibers.keys()];
223
+ }