@volter/supercode-orchestrator 0.4.160 → 0.5.1

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/reducer.mjs CHANGED
@@ -11,6 +11,7 @@ import { parseCommand, frame, REPLY } from './commands.mjs';
11
11
  import { cronTick, gateResult, fireFinal, fireFailed, finishHostedFire, homeZone, isSynthetic, nextRunAt, scheduleError, unattendedRequest } from './cron.mjs';
12
12
  import { handleWebhook } from './webhook.mjs';
13
13
  import { modelError } from './activation.mjs';
14
+ import { addParticipant, agentSlot, budgetRefusal, conversationId, ensureConversation, isAgent, profileOfAgent, receivingAgents, recordUsage } from './conversations.mjs';
14
15
 
15
16
  export function keyOf(event) {
16
17
  return surfaceKeyString(event.key);
@@ -150,7 +151,8 @@ function handleInbound(state, ev, effects) {
150
151
 
151
152
  // 1. access
152
153
  const policy = accessPolicy(owner, ev.key.platform);
153
- const allowed = policy === 'open' || (owner.access.allowlist[ev.key.platform] ?? []).includes(ev.sender);
154
+ const principal = ev.identity?.classification==='proven'?ev.identity.principal?.id:null;
155
+ const allowed = policy === 'open' || (principal && (owner.access.allowlist[ev.key.platform] ?? []).includes(`principal:${principal}`)) || (owner.access.allowlist[ev.key.platform] ?? []).includes(ev.sender);
154
156
  if (!allowed) {
155
157
  // Hermes's gateway: in a DM whose platform pairs, a stranger is offered a code from Hermes's own
156
158
  // pairing store (its codes, expiry, rate limit and lockout; docs/plans/hermes-compat.md row 8);
@@ -163,16 +165,31 @@ function handleInbound(state, ev, effects) {
163
165
  return;
164
166
  }
165
167
 
166
- // 2. route
167
- const routed = resolveRoute(owner.routes, ev.key, ev.guild_id) ?? owner.name;
168
- const profile = profileOf(state, routed);
169
- const keyString = keyOf(ev);
168
+ // 2. route: to an agent (G1); a named profile's own agent bears its name
169
+ const routedAgent = resolveRoute(owner.routes, ev.key, ev.guild_id) ?? owner.name;
170
+ const routedProfile = profileOf(state, profileOfAgent(state, routedAgent) ?? owner.name);
170
171
 
171
- // 3. command
172
+ // 3. command: the routed agent's
172
173
  const cmd = parseCommand(ev.text);
173
- if (cmd) { handleCommand(state, profile, ev, keyString, cmd, effects); return; }
174
+ if (cmd) { handleCommand(state, routedProfile, ev, agentSlot(routedProfile.name, routedAgent, keyOf(ev)), cmd, effects); return; }
175
+
176
+ // 4. the conversation (G5, created on first sight); its speaker policy picks who receives the line (G1)
177
+ const conversation = ensureConversation(state, ev.key, routedAgent, now);
178
+ addParticipant(conversation, `${ev.key.platform}:${ev.sender}`, 'to', now);
179
+ const agents = receivingAgents(state, conversation, ev, routedAgent);
180
+ if (agents.length) conversation.last_speaker = agents[agents.length - 1];
181
+ for (const agent of agents) inboundToAgent(state, agent, conversation, ev, effects);
182
+ effects.push({ type: 'save' });
183
+ }
174
184
 
175
- // 4. get-or-create
185
+ /** One receiving agent's binding for the conversation: get-or-create, then frame and send (§4.1 steps 4–5). */
186
+ function inboundToAgent(state, agent, conversation, ev, effects) {
187
+ const now = ev.at;
188
+ const profile = profileOf(state, profileOfAgent(state, agent) ?? ev.profile);
189
+ const keyString = agentSlot(profile.name, agent, keyOf(ev));
190
+ addParticipant(conversation, agent, 'to', now);
191
+
192
+ // get-or-create the (conversation, agent) binding
176
193
  let b = liveBinding(profile, keyString);
177
194
  if (b) {
178
195
  const reason = expired(b, sessionReset(profile), now);
@@ -180,13 +197,13 @@ function handleInbound(state, ev, effects) {
180
197
  }
181
198
  if (!b) {
182
199
  b = {
183
- key: ev.key,
200
+ key: ev.key, conversation: conversation.id, agent,
184
201
  worker: { harness: profile.worker?.harness ?? 'unknown', session_id: null, locator: null },
185
- started_at: now, last_activity_at: now, ended_at: null, end_reason: null, handoff: null, recurrence: null, residue: {},
202
+ started_at: now, last_activity_at: now, ended_at: null, end_reason: null, recurrence: null, residue: {},
186
203
  };
187
204
  profile.bindings[keyString] = b;
188
205
  if (!profile.worker) { reply(state, profile, ev.key, `Profile ${profile.name} has no worker configured.`, now, effects); return; }
189
- startWorker(state, profile, keyString, effects);
206
+ if (!startWorker(state, profile, keyString, effects, now)) return;
190
207
  } else {
191
208
  // A live binding whose lease is gone — a restart, a worker that died — must
192
209
  // get its worker back on the next message rather than queue it forever
@@ -199,15 +216,17 @@ function handleInbound(state, ev, effects) {
199
216
  // The conversation's session comes back, not a blank one; one that will not reopen starts fresh (loop).
200
217
  b.residue.resume_runtime_id ??= b.residue.runtime_id ?? b.worker.session_id;
201
218
  }
202
- startWorker(state, profile, keyString, effects);
219
+ if (!startWorker(state, profile, keyString, effects, now)) return;
203
220
  }
204
221
  }
205
222
  b.last_activity_at = now;
223
+ b.conversation ??= conversation.id;
224
+ b.agent ??= agent;
206
225
 
207
226
  // 5. frame and send, or queue behind the running turn
208
227
  const rt = ensureRuntime(state);
209
228
  const lid = leaseId(profile.name, keyString);
210
- const text = frame(profile.name, ev.key, ev.sender, ev.sender_name, now, ev.text);
229
+ const text = frame(profile.name, ev.key, ev.sender, ev.sender_name, now, ev.text, ev.identity);
211
230
  const inFlight = Boolean(rt.turns[lid]);
212
231
  const leased = Boolean(rt.leases[lid]);
213
232
  if (inFlight && leased && rt.leases[lid].steer) {
@@ -220,7 +239,6 @@ function handleInbound(state, ev, effects) {
220
239
  rt.turns[lid] = { started_at: now, buffer: '' };
221
240
  effects.push({ type: 'send_input', profile: profile.name, keyString, text });
222
241
  }
223
- effects.push({ type: 'save' });
224
242
  }
225
243
 
226
244
  /**
@@ -228,13 +246,24 @@ function handleInbound(state, ev, effects) {
228
246
  * a burst of messages before the lease exists queues behind ONE start (the
229
247
  * `started`/`error` worker events clear the mark).
230
248
  */
231
- export function startWorker(state, profile, keyString, effects) {
249
+ export function startWorker(state, profile, keyString, effects, now = null) {
232
250
  const rt = ensureRuntime(state);
251
+ const binding = profile.bindings[keyString];
252
+ const agent = binding?.agent ?? profile.name;
253
+ // G3: a start or resume over the agent's budget is refused with the reason; nothing is paused or ended
254
+ const session = binding?.worker?.session_id ? `${binding.worker.harness}:${binding.worker.session_id}` : null;
255
+ const refused = now ? budgetRefusal(state, profile, agent, now, session) : null;
256
+ if (refused) {
257
+ if (binding) reply(state, profile, binding.key, `Not started: ${refused}.`, now, effects);
258
+ effects.push({ type: 'budget_refused', profile: profile.name, agent, reason: refused });
259
+ return false;
260
+ }
233
261
  rt.starting[leaseId(profile.name, keyString)] = true;
234
262
  // A binding whose runtime went away with the daemon reopens that same session where its harness can, so the
235
263
  // conversation it held is not lost; the loop starts fresh when the harness cannot.
236
- const resume = profile.bindings[keyString]?.residue?.resume_runtime_id ?? null;
237
- effects.push({ type: 'runtime_start', profile: profile.name, keyString, worker: profile.worker, prompt: null, resume_runtime_id: resume });
264
+ const resume = binding?.residue?.resume_runtime_id ?? null;
265
+ effects.push({ type: 'runtime_start', profile: profile.name, keyString, worker: profile.worker, prompt: null, resume_runtime_id: resume, agent });
266
+ return true;
238
267
  }
239
268
 
240
269
 
@@ -270,7 +299,7 @@ function handleCommand(state, profile, ev, keyString, cmd, effects) {
270
299
  break;
271
300
  }
272
301
  case 'whoami': {
273
- reply(state, profile, ev.key, `${ev.sender}${ev.sender_name ? ` (${ev.sender_name})` : ''} on ${renderSurfaceKey(profile.name, ev.key)}`, now, effects);
302
+ reply(state, profile, ev.key, `${ev.identity?.label??'unresolved'}${ev.identity?.principal?` (${ev.identity.principal.id}; proven)`:''}; ${ev.sender}${ev.sender_name ? ` (${ev.sender_name})` : ''} on ${renderSurfaceKey(profile.name, ev.key)}`, now, effects);
274
303
  break;
275
304
  }
276
305
  case 'model': {
@@ -322,18 +351,80 @@ export function doHandoff(state, profile, keyString, to, note, now, effects) {
322
351
  const b = liveBinding(profile, keyString);
323
352
  if (!b) return { ok: false, message: 'No live session to hand off.' };
324
353
  if (!to) return { ok: false, message: 'Cannot hand off: no platform named.' };
325
- b.handoff = { to, state: 'pending', error: null };
354
+ // G2: a Handoff record (session → target conversation), never a field on the binding
326
355
  const target = homeChannel(profile, to);
356
+ const agent = b.agent ?? profile.name;
357
+ const record = { session: `${b.worker.harness}:${b.worker.session_id ?? ''}`, to: target ? conversationId(target) : `platform:${to}`, by: agent, state: target ? 'pending' : 'failed', at: now };
327
358
  if (target) {
359
+ addParticipant(ensureConversation(state, target, agent, now), agent, 'to', now);
328
360
  reply(state, profile, target, `Continuing session ${b.worker.session_id} from ${b.key.platform}. Reply here to pick it up.${note ? `\n${note}` : ''}`, now, effects);
329
361
  } else {
330
- b.handoff.state = 'failed';
331
- b.handoff.error = `no home chat on ${to}`;
362
+ record.error = `no home chat on ${to}`;
332
363
  }
364
+ (state.handoffs ??= []).push(record);
333
365
  effects.push({ type: 'save' });
334
- return b.handoff.state === 'pending'
366
+ return record.state === 'pending'
335
367
  ? { ok: true, message: `Handed off to ${to}.` }
336
- : { ok: false, message: `Cannot hand off: ${b.handoff.error}.` };
368
+ : { ok: false, message: `Cannot hand off: ${record.error}.` };
369
+ }
370
+
371
+ /** A transfer's line reaches its receiver: queued behind a running turn, or sent now. */
372
+ function handOver(rt, profileName, keyString, line, now, effects) {
373
+ const lid = leaseId(profileName, keyString);
374
+ if (rt.turns[lid] || !rt.leases[lid]) (rt.queues[lid] ??= []).push(line);
375
+ else { rt.turns[lid] = { started_at: now, buffer: '' }; effects.push(queuedInput(profileName, keyString, line)); }
376
+ }
377
+
378
+ /** The handovers a giver's turn held back go out when that turn ends. */
379
+ function releaseHandovers(rt, lid, now, effects) {
380
+ for (const held of rt.handovers?.[lid] ?? []) handOver(rt, held.profile, held.keyString, held.line, now, effects);
381
+ if (rt.handovers) delete rt.handovers[lid];
382
+ }
383
+
384
+ /**
385
+ * G2: the conversation passes from one agent to another. The giver's binding ends with `transfer` (it stays a
386
+ * participant); the receiver gets or continues its binding, and its next turn is given the conversation's history
387
+ * as the transfer says. Refused unless the receiver is an agent of this home.
388
+ */
389
+ export function doTransfer(state, profile, keyString, args, now, effects) {
390
+ const from = liveBinding(profile, keyString);
391
+ if (!from) return { ok: false, message: 'No live conversation to transfer.' };
392
+ const giver = from.agent ?? profile.name;
393
+ const conversation = state.conversations?.[from.conversation] ?? ensureConversation(state, from.key, giver, now);
394
+ const history = ['full', 'filtered', 'none'].includes(args.history) ? args.history : 'full';
395
+ const record = { conversation: conversation.id, from: giver, to: String(args.to ?? ''), by: args.by ?? giver, reason: String(args.reason ?? ''), history,
396
+ ...(history === 'filtered' ? { filter: args.filter ?? { last: 20 } } : {}), state: 'pending', at: now };
397
+ (state.transfers ??= []).push(record);
398
+ const refuse = (error) => { record.state = 'refused'; record.error = error; effects.push({ type: 'save' }); return { ok: false, message: `Transfer refused: ${error}.` }; };
399
+ if (!isAgent(state, record.to)) return refuse(`${record.to || 'nobody'} is not an agent of this home (its agents: ${[...new Set([...Object.keys(state.profiles ?? {}), ...Object.keys(state.agents ?? {})])].join(', ')})`);
400
+ if (record.to === giver) return refuse('the conversation is already this agent\'s');
401
+ const toProfile = profileOf(state, profileOfAgent(state, record.to));
402
+ if (!toProfile.worker) return refuse(`${record.to}'s profile has no worker`);
403
+ endBinding(state, profile.name, keyString, 'transfer', now, effects);
404
+ addParticipant(conversation, record.to, 'to', now);
405
+ conversation.holder = record.to;
406
+ const slot = agentSlot(toProfile.name, record.to, surfaceKeyString(from.key));
407
+ let b = liveBinding(toProfile, slot);
408
+ if (!b) {
409
+ b = { key: from.key, conversation: conversation.id, agent: record.to, worker: { harness: toProfile.worker.harness, session_id: null, locator: null },
410
+ started_at: now, last_activity_at: now, ended_at: null, end_reason: null, recurrence: null, residue: {} };
411
+ toProfile.bindings[slot] = b;
412
+ }
413
+ const rt = ensureRuntime(state);
414
+ const lid = leaseId(toProfile.name, slot);
415
+ if (!rt.leases[lid] && !rt.starting[lid] && !startWorker(state, toProfile, slot, effects, now)) return refuse(`${record.to} may not start`);
416
+ const text = `${giver} transferred this conversation to you${record.reason ? `: ${record.reason}` : '.'}`;
417
+ const handover = history === 'none' || !from.worker.session_id ? null
418
+ : { harness: from.worker.harness, session_id: from.worker.session_id, mode: history, filter: record.filter ?? null, from: giver };
419
+ const line = { text, at: now, ...(handover ? { history: handover } : {}) };
420
+ // The giver is mid-turn (it called the tool): its reply of that turn is part of the conversation, so the receiver
421
+ // is handed it when that turn ends, its history read then.
422
+ const giverLid = leaseId(profile.name, keyString);
423
+ if (rt.turns[giverLid]) (rt.handovers ??= {})[giverLid] = [...(rt.handovers?.[giverLid] ?? []), { profile: toProfile.name, keyString: slot, line }];
424
+ else handOver(rt, toProfile.name, slot, line, now, effects);
425
+ record.state = 'done';
426
+ effects.push({ type: 'save' });
427
+ return { ok: true, message: `Transferred to ${record.to}.` };
337
428
  }
338
429
 
339
430
  // ---------------------------------------------------------------- worker events
@@ -355,6 +446,9 @@ function handleWorker(state, ev, effects) {
355
446
  // any word from the worker is activity; a fire's inactivity limit counts from the last (§4.3)
356
447
  rt.activity[lid] = now;
357
448
  switch (w.kind) {
449
+ case 'session':
450
+ if(binding&&binding.worker.session_id!==w.session_id){binding.worker.session_id=w.session_id;effects.push({type:'save'});}
451
+ break;
358
452
  case 'activity':
359
453
  break;
360
454
  case 'started': {
@@ -440,12 +534,15 @@ function handleWorker(state, ev, effects) {
440
534
  if (failed) reply(state, profile, binding.key, `Turn failed: ${message}`, now, effects);
441
535
  else if (text && !t?.replied) reply(state, profile, binding.key, text, now, effects);
442
536
  }
537
+ releaseHandovers(rt, lid, now, effects);
443
538
  const q = rt.queues[lid] ?? [];
444
539
  if (q.length && rt.leases[lid]) {
445
540
  const next = q.shift();
446
541
  rt.turns[lid] = { started_at: now, buffer: '' };
447
542
  effects.push(queuedInput(profile.name, ev.keyString, next));
448
543
  }
544
+ // G3: the session's usage is read again from the harness's own records after every turn
545
+ if (binding?.worker?.session_id) effects.push({ type: 'usage_refresh', session: { harness: binding.worker.harness, session_id: binding.worker.session_id }, agent: binding.agent ?? profile.name });
449
546
  effects.push({ type: 'save' });
450
547
  break;
451
548
  }
@@ -467,6 +564,7 @@ function handleWorker(state, ev, effects) {
467
564
  case 'error': {
468
565
  delete rt.turns[lid];
469
566
  delete rt.starting[lid];
567
+ releaseHandovers(rt, lid, now, effects);
470
568
  const synthetic = binding ? fireFailed(state, profile, binding, w.message ?? w.reason ?? 'worker exited', now, effects) : false;
471
569
  if (binding) {
472
570
  endBinding(state, profile.name, ev.keyString, 'error', now, effects);
@@ -502,7 +600,7 @@ export function permissionResponse(options, allow, reason = null) {
502
600
  */
503
601
  function queuedInput(profile, keyString, next) {
504
602
  const framed = next.job_id && (next.context_from || next.skills?.length);
505
- return { type: 'send_input', profile, keyString, text: next.text, ...(framed ? { context_from: next.context_from ?? null, skills: next.skills ?? [], job_id: next.job_id } : {}) };
603
+ return { type: 'send_input', profile, keyString, text: next.text, ...(framed ? { context_from: next.context_from ?? null, skills: next.skills ?? [], job_id: next.job_id } : {}), ...(next.history ? { history: next.history } : {}) };
506
604
  }
507
605
 
508
606
  /** An admin's numeric reply resolves the oldest pending request on that surface. */
@@ -510,7 +608,7 @@ function tryResolveRequest(state, ev, effects) {
510
608
  const rt = ensureRuntime(state);
511
609
  const keyString = keyOf(ev);
512
610
  const owner = profileOf(state, ev.profile);
513
- const isAdmin = (owner.access.admins[ev.key.platform] ?? []).includes(ev.sender) || accessPolicy(owner, ev.key.platform) === 'open';
611
+ const isAdmin = (ev.identity?.classification==='proven' && (owner.access.admins[ev.key.platform] ?? []).includes(`principal:${ev.identity.principal.id}`)) || (owner.access.admins[ev.key.platform] ?? []).includes(ev.sender) || accessPolicy(owner, ev.key.platform) === 'open';
514
612
  const m = /^\s*(\d+)\s*$/.exec(ev.text);
515
613
  if (!m || !isAdmin) return false;
516
614
  const entry = Object.entries(rt.pending_requests).find(([, p]) => p.keyString === keyString);
@@ -588,7 +686,7 @@ function handleRestart(state, ev, effects) {
588
686
  if (!b.residue?.reopen_at_start) continue;
589
687
  delete b.residue.reopen_at_start;
590
688
  live += 1;
591
- startWorker(state, profile, ks, effects);
689
+ startWorker(state, profile, ks, effects, ev.at);
592
690
  continue;
593
691
  }
594
692
  live += 1;
@@ -626,7 +724,7 @@ function handleOperator(state, ev, effects) {
626
724
  switch (ev.verb) {
627
725
  case 'pair_approve': {
628
726
  // Hermes's pairing store holds the code (hashed); the loop asks it, and a grant comes back as `access_granted`
629
- effects.push({ type: 'access_write', profile: profile.name, op: 'approve', code: ev.args.code, platforms: Object.keys(profile.channels ?? {}) });
727
+ effects.push({ type: 'access_write', profile: profile.name, op: 'approve', code: ev.args.code, principal: ev.args.principal ?? null, platforms: Object.keys(profile.channels ?? {}) });
630
728
  break;
631
729
  }
632
730
  // what Hermes's store granted, mirrored into the loaded access
@@ -676,7 +774,7 @@ function handleOperator(state, ev, effects) {
676
774
  }
677
775
  // the attach is over; what queued behind it gets a worker now (resumed where the harness can)
678
776
  delete rt.starting[lid];
679
- if (rt.queues[lid]?.length && profile.worker) startWorker(state, profile, ev.args.keyString, effects);
777
+ if (rt.queues[lid]?.length && profile.worker) startWorker(state, profile, ev.args.keyString, effects, ev.at);
680
778
  effects.push({ type: 'save' });
681
779
  break;
682
780
  }
@@ -750,6 +848,11 @@ function handleOperator(state, ev, effects) {
750
848
  if (!outcome.ok) effects.push({ type: 'log', level: 'warn', message: `session_handoff: ${outcome.message}` });
751
849
  break;
752
850
  }
851
+ case 'conversation_transfer': {
852
+ const outcome = doTransfer(state, profile, ev.args.keyString, ev.args, ev.at, effects);
853
+ if (!outcome.ok) effects.push({ type: 'log', level: 'warn', message: `conversation_transfer: ${outcome.message}` });
854
+ break;
855
+ }
753
856
  // Profile verbs (§4.6): a profile IS a folder, so `create` adds the record
754
857
  // and lets `save` write the folder, and `delete` removes the record and
755
858
  // asks the loop to move the folder aside — the reducer performs no I/O and
@@ -795,6 +898,8 @@ export function step(state, event) {
795
898
  case 'adapter': handleAdapter(state, event); break;
796
899
  case 'operator': handleOperator(state, event, effects); break;
797
900
  case 'pairing_issued': handlePairingIssued(state, event, effects); break;
901
+ // G3: a session's usage as its harness recorded it, recomputed whole
902
+ case 'usage': recordUsage(state, event.session, event.agent, event.usage); effects.push({ type: 'save' }); break;
798
903
  default: throw new Error(`unknown event type ${event.type}`);
799
904
  }
800
905
  return effects;
package/webhook.mjs CHANGED
@@ -10,7 +10,8 @@
10
10
  // and `node:http` at call time instead.
11
11
 
12
12
  import { surfaceKeyString, sessionReset } from './ir.mjs';
13
- import { endBinding, ensureRuntime, expired, leaseId, liveBinding, newObligation } from './reducer.mjs';
13
+ import { endBinding, ensureRuntime, expired, leaseId, liveBinding, newObligation, reply } from './reducer.mjs';
14
+ import { budgetRefusal } from './conversations.mjs';
14
15
  import { compactStamp, resolveTarget } from './cron.mjs';
15
16
  import * as events from './events.mjs';
16
17
 
@@ -150,14 +151,17 @@ export function handleWebhook(state, ev, effects) {
150
151
  if (reason) { endBinding(state, profile.name, keyString, reason, now, effects); b = null; }
151
152
  }
152
153
  if (!b) {
154
+ // G3: an agent over its budget starts nothing; the refusal is told, never a pause
155
+ const refused = budgetRefusal(state, profile, profile.name, now);
156
+ if (refused) { effects.push({ type: 'budget_refused', profile: profile.name, agent: profile.name, reason: refused }); return; }
153
157
  b = {
154
- key,
158
+ key, agent: profile.name,
155
159
  worker: { harness: profile.worker.harness, session_id: `webhook_${ev.name}_${compactStamp(now)}`, locator: null },
156
160
  started_at: now, last_activity_at: now, ended_at: null, end_reason: null,
157
- handoff: null, recurrence: null, residue: { webhook: ev.name },
161
+ recurrence: null, residue: { webhook: ev.name },
158
162
  };
159
163
  profile.bindings[keyString] = b;
160
- effects.push({ type: 'runtime_start', profile: profile.name, keyString, worker: profile.worker, prompt: null });
164
+ effects.push({ type: 'runtime_start', profile: profile.name, keyString, worker: profile.worker, prompt: null, agent: profile.name });
161
165
  }
162
166
  b.residue.webhook = ev.name;
163
167
  b.last_activity_at = now;