slotlock 0.6.3 → 0.8.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 (76) hide show
  1. package/CHANGELOG.md +511 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1551
  4. package/SECURITY.md +5 -2
  5. package/SPEC.md +1 -1
  6. package/agent-server.d.ts +160 -8
  7. package/agent-server.js +559 -44
  8. package/agent-store-backend.d.ts +34 -1
  9. package/agent-store-backend.js +1399 -38
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +217 -28
  12. package/application-api.d.ts +56 -3
  13. package/application-api.js +826 -15
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +70 -1
  17. package/assistant.js +478 -50
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/config.d.ts +25 -4
  21. package/config.js +72 -3
  22. package/dashboard-assistant-ui.d.ts +21 -0
  23. package/dashboard-assistant-ui.js +438 -0
  24. package/dashboard-icon.d.ts +1 -0
  25. package/dashboard-icon.js +5 -0
  26. package/dashboard-markdown.d.ts +2 -0
  27. package/dashboard-markdown.js +157 -0
  28. package/dashboard-settings-ui.d.ts +1 -0
  29. package/dashboard-settings-ui.js +78 -0
  30. package/dashboard.d.ts +227 -7
  31. package/dashboard.js +2313 -247
  32. package/ddl.d.ts +51 -12
  33. package/ddl.js +2566 -54
  34. package/event-feed.d.ts +30 -4
  35. package/event-feed.js +171 -19
  36. package/index.d.ts +15 -4
  37. package/index.js +8 -2
  38. package/maintenance.d.ts +10 -0
  39. package/maintenance.js +16 -2
  40. package/metrics.d.ts +41 -0
  41. package/metrics.js +22 -0
  42. package/openapi.json +14675 -5957
  43. package/otel.d.ts +86 -0
  44. package/otel.js +273 -0
  45. package/overage.d.ts +33 -0
  46. package/overage.js +115 -0
  47. package/package.json +1 -1
  48. package/partner-billing.d.ts +81 -0
  49. package/partner-billing.js +333 -0
  50. package/partner-names.d.ts +8 -0
  51. package/partner-names.js +10 -0
  52. package/partners.d.ts +147 -0
  53. package/partners.js +375 -0
  54. package/plans.d.ts +92 -0
  55. package/plans.js +115 -0
  56. package/quotas.d.ts +108 -0
  57. package/quotas.js +258 -0
  58. package/rate-limit.d.ts +21 -1
  59. package/rate-limit.js +82 -15
  60. package/resource-profile.d.ts +135 -0
  61. package/resource-profile.js +263 -0
  62. package/rest-api.d.ts +2 -0
  63. package/rest-api.js +68 -3
  64. package/sandbox.d.ts +70 -0
  65. package/sandbox.js +295 -0
  66. package/self-host.d.ts +33 -5
  67. package/self-host.js +950 -22
  68. package/slotlock.manifest.json +1 -1
  69. package/store.d.ts +373 -4
  70. package/store.js +1212 -118
  71. package/teams.d.ts +25 -0
  72. package/teams.js +63 -0
  73. package/timezone.js +13 -1
  74. package/types.d.ts +95 -1
  75. package/usage.d.ts +106 -0
  76. package/usage.js +238 -0
package/assistant.js CHANGED
@@ -5,7 +5,9 @@
5
5
  // Nothing the model says changes the list of tools.
6
6
  //
7
7
  // Request formats as the vendors' official TypeScript SDKs describe them: @anthropic-ai/sdk 1.3.0
8
- // and openai 7.45.1 (read from the npm registry 2026-10-28).
8
+ // and openai 7.45.1 (read from the npm registry 2026-10-28). Streamed answers as Anthropic's
9
+ // "Streaming messages" page, the Chat Completions chunk object, @anthropic-ai/sdk 1.8.0 and openai
10
+ // 7.55.0 describe them, read as server-sent events by the WHATWG HTML standard (all read 2026-11-10).
9
11
  //
10
12
  // The API key is sealed with AES-256-GCM (WebCrypto) under a key HKDF-SHA-256-derived from
11
13
  // SLOTLOCK_ASSISTANT_SECRET, with the tenant as additional data: a sealed key copied to another
@@ -34,6 +36,9 @@ export const SLOTLOCK_ASSISTANT_TOOL_USE = Object.freeze({
34
36
  slotlock_create_event: 'propose',
35
37
  slotlock_update_event: 'propose',
36
38
  slotlock_delete_event: 'propose',
39
+ slotlock_confirm_slot: 'propose',
40
+ slotlock_reschedule: 'propose',
41
+ slotlock_cancel: 'propose',
37
42
  });
38
43
  for (const [operation, use] of Object.entries(SLOTLOCK_ASSISTANT_TOOL_USE)) {
39
44
  // A read the assistant runs freely must be one the agent server calls a read.
@@ -56,6 +61,8 @@ export const SLOTLOCK_ASSISTANT_MAX_STORED_BYTES = 160_000;
56
61
  const MAX_ACTIONS_BYTES = 80_000;
57
62
  /** UTF-8 bytes of one proposed change's arguments as JSON; a larger one is not proposed. */
58
63
  export const SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES = 16_000;
64
+ /** Previous secrets a deployment may keep while keys are sealed again under its current one. */
65
+ export const SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS = 3;
59
66
  /** Proposed changes one conversation may have waiting at once (4 of the largest fit the bound). */
60
67
  export const SLOTLOCK_ASSISTANT_MAX_PENDING = 4;
61
68
  /** Characters of one tool result the model is shown. */
@@ -80,6 +87,7 @@ export function createSlotlockAssistantStore(store) {
80
87
  ...(actor !== undefined ? { updatedBy: actor } : {}),
81
88
  }), by(actor)),
82
89
  deleteSettings: (tenantRef, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteAssistantSettings({ tenantRef }), by(actor)),
90
+ resealKey: (tenantRef, from, to) => store.withTenant(tenantRef, (tenant) => tenant.resealAssistantKey({ tenantRef, from, to }), by(undefined)),
83
91
  // Each read deletes the tenant's expired conversations first, for deployments whose maintenance
84
92
  // never meets the tenant (a Worker's).
85
93
  getConversation: (tenantRef, memberRef) => store.withTenant(tenantRef, async (tenant) => {
@@ -133,6 +141,13 @@ export function createSlotlockMemoryAssistantStore(now = Date.now) {
133
141
  async deleteSettings(tenantRef) {
134
142
  return settings.delete(tenantRef);
135
143
  },
144
+ async resealKey(tenantRef, from, to) {
145
+ const stored = settings.get(tenantRef);
146
+ if (!stored || stored.sealedKey !== from || to.length === 0 || to === from)
147
+ return false;
148
+ settings.set(tenantRef, { ...stored, sealedKey: to });
149
+ return true;
150
+ },
136
151
  async getConversation(tenantRef, memberRef) {
137
152
  const found = live(tenantRef, memberRef);
138
153
  if (!found)
@@ -198,6 +213,19 @@ export async function sealSlotlockAssistantKey(secret, tenantRef, apiKey) {
198
213
  const sealed = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: boundTo(tenantRef) }, await sealingKey(secret), encoder.encode(apiKey));
199
214
  return `${SEAL_VERSION}.${Buffer.from(iv).toString('base64url')}.${Buffer.from(sealed).toString('base64url')}`;
200
215
  }
216
+ /**
217
+ * The key a seal holds, tried with each secret in turn (the current one first, then
218
+ * SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS), and which one opened it: 0 for the current secret. Null when
219
+ * none of them sealed it for this tenant.
220
+ */
221
+ export async function openSlotlockAssistantKeyWithAny(secrets, tenantRef, sealed) {
222
+ for (const [secretIndex, secret] of secrets.entries()) {
223
+ const apiKey = await openSlotlockAssistantKey(secret, tenantRef, sealed);
224
+ if (apiKey !== null)
225
+ return { apiKey, secretIndex };
226
+ }
227
+ return null;
228
+ }
201
229
  /** The key a seal holds, or null when it is not one this secret sealed for this tenant. */
202
230
  export async function openSlotlockAssistantKey(secret, tenantRef, sealed) {
203
231
  const [version, iv, body, ...rest] = sealed.split('.');
@@ -484,14 +512,279 @@ async function readBounded(response) {
484
512
  }
485
513
  return Buffer.concat(chunks).toString('utf8');
486
514
  }
515
+ /** What an HTTP status from a model call means. */
516
+ function statusReason(status) {
517
+ return status === 401 || status === 403
518
+ ? 'refused_key'
519
+ : status === 404
520
+ ? 'not_found'
521
+ : status === 429
522
+ ? 'rate_limited'
523
+ : status === 408 || status === 504
524
+ ? 'timeout'
525
+ : 'unavailable';
526
+ }
527
+ /** The HTTP status each Anthropic error type stands for ("Claude API errors"), for one sent mid-stream. */
528
+ const ANTHROPIC_ERROR_STATUS = new Map([
529
+ ['invalid_request_error', 400],
530
+ ['authentication_error', 401],
531
+ ['billing_error', 402],
532
+ ['permission_error', 403],
533
+ ['not_found_error', 404],
534
+ ['request_too_large', 413],
535
+ ['rate_limit_error', 429],
536
+ ['api_error', 500],
537
+ ['timeout_error', 504],
538
+ ['overloaded_error', 529],
539
+ ]);
540
+ /**
541
+ * Server-sent events as the WHATWG HTML standard interprets them: lines end at CRLF, LF or CR; a
542
+ * line starting with `:` is a comment; one space after the field's colon is dropped; `data` values
543
+ * join with LF; a blank line dispatches; other fields are ignored. Text comes in any pieces; an
544
+ * event the stream ends inside is never dispatched. `push` is true once `dispatch` was (stop).
545
+ */
546
+ function sseLines(dispatch) {
547
+ let line = '';
548
+ let afterCR = false;
549
+ let event = '';
550
+ let data = '';
551
+ let done = false;
552
+ const take = (field) => {
553
+ if (field === '') {
554
+ if (data !== '')
555
+ done = dispatch(event || 'message', data.slice(0, -1));
556
+ event = '';
557
+ data = '';
558
+ return;
559
+ }
560
+ if (field.startsWith(':'))
561
+ return;
562
+ const colon = field.indexOf(':');
563
+ const name = colon === -1 ? field : field.slice(0, colon);
564
+ const value = colon === -1 ? '' : field.slice(field.charCodeAt(colon + 1) === 0x20 ? colon + 2 : colon + 1);
565
+ if (name === 'event')
566
+ event = value;
567
+ else if (name === 'data')
568
+ data += `${value}\n`;
569
+ };
570
+ return (text) => {
571
+ let start = 0;
572
+ for (let index = 0; index < text.length && !done; index += 1) {
573
+ const code = text.charCodeAt(index);
574
+ if (code === 0x0a && afterCR) {
575
+ // The LF of a CRLF whose CR ended the line, perhaps in the previous piece.
576
+ afterCR = false;
577
+ start = index + 1;
578
+ continue;
579
+ }
580
+ afterCR = code === 0x0d;
581
+ if (code !== 0x0a && code !== 0x0d)
582
+ continue;
583
+ take(line + text.slice(start, index));
584
+ line = '';
585
+ start = index + 1;
586
+ }
587
+ if (!done)
588
+ line += text.slice(start);
589
+ return done;
590
+ };
591
+ }
592
+ /**
593
+ * A streamed body, decoded as its bytes arrive (a character split across chunks waits for the
594
+ * rest) and read to `onEvent` until it answers true, within MAX_RESPONSE_BYTES. An abort cancels
595
+ * the read at once and is a timeout, even with events still in hand.
596
+ */
597
+ async function readEvents(response, signal, onEvent) {
598
+ if (!response.body)
599
+ return;
600
+ const reader = response.body.getReader();
601
+ const cancel = () => {
602
+ reader.cancel().catch(() => undefined);
603
+ };
604
+ signal.addEventListener('abort', cancel, { once: true });
605
+ // UTF-8, without a leading byte order mark, as the standard decodes a stream.
606
+ const decoder = new TextDecoder();
607
+ const push = sseLines((event, data) => {
608
+ if (signal.aborted)
609
+ throw new SlotlockAssistantProviderError('timeout');
610
+ return onEvent(event, data);
611
+ });
612
+ let size = 0;
613
+ try {
614
+ for (;;) {
615
+ if (signal.aborted)
616
+ throw new SlotlockAssistantProviderError('timeout');
617
+ let chunk;
618
+ try {
619
+ chunk = await reader.read();
620
+ }
621
+ catch {
622
+ throw new SlotlockAssistantProviderError(signal.aborted ? 'timeout' : 'bad_answer');
623
+ }
624
+ if (chunk.done) {
625
+ if (signal.aborted)
626
+ throw new SlotlockAssistantProviderError('timeout');
627
+ return;
628
+ }
629
+ size += chunk.value.byteLength;
630
+ if (size > MAX_RESPONSE_BYTES)
631
+ throw new SlotlockAssistantProviderError('bad_answer');
632
+ if (push(decoder.decode(chunk.value, { stream: true })))
633
+ return;
634
+ }
635
+ }
636
+ finally {
637
+ signal.removeEventListener('abort', cancel);
638
+ // Whatever follows the last event, or a body given up on, is not read.
639
+ cancel();
640
+ }
641
+ }
642
+ const parsedOr = (json, fallback) => {
643
+ try {
644
+ return JSON.parse(json);
645
+ }
646
+ catch {
647
+ return fallback;
648
+ }
649
+ };
650
+ /**
651
+ * An Anthropic stream: content blocks by index, text from `text_delta`s, a `tool_use`'s input from
652
+ * its `input_json_delta` fragments, parsed once whole; ended by `message_stop`. `ping` and event
653
+ * types this does not know are passed over; `error` names its error as an HTTP status would.
654
+ */
655
+ function anthropicStream(emit) {
656
+ const blocks = new Map();
657
+ let stopped = false;
658
+ return {
659
+ event(name, data) {
660
+ const body = parsedOr(data, undefined);
661
+ if (name === 'error' || (isRecord(body) && body.type === 'error')) {
662
+ const type = isRecord(body) && isRecord(body.error) ? body.error.type : undefined;
663
+ const status = typeof type === 'string' ? ANTHROPIC_ERROR_STATUS.get(type) : undefined;
664
+ throw new SlotlockAssistantProviderError(status === undefined ? 'unavailable' : statusReason(status));
665
+ }
666
+ if (!isRecord(body)) {
667
+ if (name === 'ping')
668
+ return false;
669
+ throw new SlotlockAssistantProviderError('bad_answer');
670
+ }
671
+ const index = typeof body.index === 'number' ? body.index : undefined;
672
+ if (body.type === 'content_block_start' && index !== undefined && isRecord(body.content_block)) {
673
+ const block = body.content_block;
674
+ if (block.type === 'text') {
675
+ blocks.set(index, 'text');
676
+ if (typeof block.text === 'string')
677
+ emit(block.text);
678
+ }
679
+ else if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') {
680
+ blocks.set(index, { id: block.id, name: block.name, input: block.input, json: '' });
681
+ }
682
+ }
683
+ else if (body.type === 'content_block_delta' && index !== undefined && isRecord(body.delta)) {
684
+ const block = blocks.get(index);
685
+ const delta = body.delta;
686
+ if (block === 'text' && delta.type === 'text_delta' && typeof delta.text === 'string')
687
+ emit(delta.text);
688
+ if (typeof block === 'object' && delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
689
+ block.json += delta.partial_json;
690
+ }
691
+ }
692
+ else if (body.type === 'message_stop') {
693
+ stopped = true;
694
+ }
695
+ return stopped;
696
+ },
697
+ complete: () => stopped,
698
+ calls: () => [...blocks.entries()]
699
+ .sort(([a], [b]) => a - b)
700
+ .flatMap(([, block]) => block === 'text'
701
+ ? []
702
+ : [{ id: block.id, name: block.name, input: block.json === '' ? block.input : parsedOr(block.json, null) }]),
703
+ };
704
+ }
705
+ /**
706
+ * A Chat Completions stream: chunks whose first choice's delta carries `content` and `tool_calls`
707
+ * fragments, each call assembled by its `index` (id and name arrive once, arguments in pieces);
708
+ * complete at a `finish_reason`, ended by `data: [DONE]`. A chunk with an `error` is a failure.
709
+ */
710
+ function openaiStream(emit) {
711
+ const slots = new Map();
712
+ let finished = false;
713
+ return {
714
+ event(name, data) {
715
+ if (data === '[DONE]') {
716
+ finished = true;
717
+ return true;
718
+ }
719
+ const body = parsedOr(data, undefined);
720
+ if (!isRecord(body))
721
+ throw new SlotlockAssistantProviderError('bad_answer');
722
+ if (name === 'error' || body.error)
723
+ throw new SlotlockAssistantProviderError('unavailable');
724
+ for (const choice of Array.isArray(body.choices) ? body.choices : []) {
725
+ if (!isRecord(choice) || (choice.index ?? 0) !== 0)
726
+ continue;
727
+ const delta = isRecord(choice.delta) ? choice.delta : {};
728
+ if (typeof delta.content === 'string')
729
+ emit(delta.content);
730
+ for (const [position, fragment] of (Array.isArray(delta.tool_calls) ? delta.tool_calls : []).entries()) {
731
+ if (!isRecord(fragment))
732
+ continue;
733
+ // A server that sends whole calls without an index sends them in order.
734
+ const index = typeof fragment.index === 'number' && Number.isSafeInteger(fragment.index) && fragment.index >= 0
735
+ ? fragment.index
736
+ : position;
737
+ const slot = slots.get(index) ?? { args: '' };
738
+ slots.set(index, slot);
739
+ const fn = isRecord(fragment.function) ? fragment.function : {};
740
+ if (typeof fragment.id === 'string' && fragment.id !== '')
741
+ slot.id = fragment.id;
742
+ if (typeof fn.name === 'string' && fn.name !== '')
743
+ slot.name = fn.name;
744
+ if (typeof fn.arguments === 'string')
745
+ slot.args += fn.arguments;
746
+ }
747
+ if (typeof choice.finish_reason === 'string')
748
+ finished = true;
749
+ }
750
+ return false;
751
+ },
752
+ complete: () => finished,
753
+ calls: () => [...slots.entries()]
754
+ .sort(([a], [b]) => a - b)
755
+ .flatMap(([index, slot]) => slot.name === undefined
756
+ ? []
757
+ : [{ id: slot.id ?? `call_${index}`, name: slot.name, input: parsedOr(slot.args || '{}', null) }]),
758
+ };
759
+ }
760
+ /** Text to `onText`, within MAX_MODEL_TEXT: what it was given is the answer's text. */
761
+ function textSink(onText) {
762
+ let text = '';
763
+ return {
764
+ emit(delta) {
765
+ const kept = delta.slice(0, MAX_MODEL_TEXT - text.length);
766
+ if (kept === '')
767
+ return;
768
+ text += kept;
769
+ try {
770
+ onText(kept);
771
+ }
772
+ catch {
773
+ // Showing the answer never changes it.
774
+ }
775
+ },
776
+ text: () => text,
777
+ };
778
+ }
779
+ const isEventStream = (response) => (response.headers.get('content-type') ?? '').split(';')[0]?.trim().toLowerCase() === 'text/event-stream';
487
780
  /** One model call. Throws SlotlockAssistantProviderError, whose message never holds the key. */
488
781
  export async function callSlotlockAssistantModel(request) {
489
- const { model } = request;
782
+ const { model, onText } = request;
490
783
  const anthropic = model.provider === 'anthropic';
491
784
  const url = anthropic ? `${model.baseUrl}/v1/messages` : `${model.baseUrl}/chat/completions`;
492
785
  const headers = {
493
786
  'content-type': 'application/json',
494
- accept: 'application/json',
787
+ accept: onText ? 'text/event-stream' : 'application/json',
495
788
  'user-agent': 'slotlock-dashboard-assistant',
496
789
  };
497
790
  if (anthropic) {
@@ -502,12 +795,13 @@ export async function callSlotlockAssistantModel(request) {
502
795
  else if (model.apiKey) {
503
796
  headers.authorization = `Bearer ${model.apiKey}`;
504
797
  }
798
+ const body = anthropic ? anthropicBody(request) : openaiBody(request);
505
799
  let response;
506
800
  try {
507
801
  response = await request.fetch(url, {
508
802
  method: 'POST',
509
803
  headers,
510
- body: JSON.stringify(anthropic ? anthropicBody(request) : openaiBody(request)),
804
+ body: JSON.stringify(onText ? { ...body, stream: true } : body),
511
805
  // Never follow a redirect: it would carry the key elsewhere.
512
806
  redirect: 'manual',
513
807
  signal: request.signal,
@@ -520,6 +814,90 @@ export async function callSlotlockAssistantModel(request) {
520
814
  }
521
815
  throw new SlotlockAssistantProviderError(name === 'TimeoutError' || name === 'AbortError' || request.signal.aborted ? 'timeout' : 'unavailable');
522
816
  }
817
+ if (!response.ok) {
818
+ await response.body?.cancel().catch(() => undefined);
819
+ throw new SlotlockAssistantProviderError(statusReason(response.status), response.status);
820
+ }
821
+ const sink = onText ? textSink(onText) : undefined;
822
+ if (sink && isEventStream(response)) {
823
+ const answer = anthropic ? anthropicStream(sink.emit) : openaiStream(sink.emit);
824
+ await readEvents(response, request.signal, answer.event);
825
+ if (!answer.complete())
826
+ throw new SlotlockAssistantProviderError('bad_answer');
827
+ return { text: sink.text(), calls: answer.calls() };
828
+ }
829
+ // One JSON answer: asked for, or sent by a server that does not stream.
830
+ let answer;
831
+ try {
832
+ answer = JSON.parse(await readBounded(response));
833
+ }
834
+ catch (error) {
835
+ if (error instanceof SlotlockAssistantProviderError)
836
+ throw error;
837
+ if (request.signal.aborted)
838
+ throw new SlotlockAssistantProviderError('timeout');
839
+ throw new SlotlockAssistantProviderError('bad_answer');
840
+ }
841
+ const turn = anthropic ? readAnthropic(answer) : readOpenai(answer);
842
+ sink?.emit(turn.text);
843
+ return { text: turn.text.slice(0, MAX_MODEL_TEXT), calls: turn.calls };
844
+ }
845
+ /**
846
+ * Providers the Settings form fills in (RFC 0003 A4): each one's API and the base URL it documents
847
+ * for it. Custom fills nothing, for any other OpenAI-compatible API. Choosing one saves nothing.
848
+ */
849
+ export const SLOTLOCK_ASSISTANT_PRESETS = Object.freeze([
850
+ { id: 'anthropic', name: 'Anthropic', provider: 'anthropic', baseUrl: 'https://api.anthropic.com' },
851
+ { id: 'openai', name: 'OpenAI', provider: 'openai', baseUrl: 'https://api.openai.com/v1' },
852
+ { id: 'openrouter', name: 'OpenRouter', provider: 'openai', baseUrl: 'https://openrouter.ai/api/v1' },
853
+ {
854
+ id: 'google',
855
+ name: 'Google',
856
+ provider: 'openai',
857
+ baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
858
+ },
859
+ { id: 'mistral', name: 'Mistral', provider: 'openai', baseUrl: 'https://api.mistral.ai/v1' },
860
+ { id: 'groq', name: 'Groq', provider: 'openai', baseUrl: 'https://api.groq.com/openai/v1' },
861
+ { id: 'together', name: 'Together', provider: 'openai', baseUrl: 'https://api.together.xyz/v1' },
862
+ { id: 'custom', name: 'Custom', provider: 'openai', baseUrl: '' },
863
+ ]);
864
+ /** Models one list keeps: a provider may offer hundreds. */
865
+ const MAX_LISTED_MODELS = 500;
866
+ /**
867
+ * The models a provider offers (`GET /v1/models` on the Anthropic API, `GET /models` on an
868
+ * OpenAI-compatible one, listed under `data` or as a bare array), by id, sorted, for the Settings
869
+ * form's Model field. Throws
870
+ * SlotlockAssistantProviderError as a model call does, never with the key in its message.
871
+ */
872
+ export async function listSlotlockAssistantModels(args) {
873
+ const anthropic = args.provider === 'anthropic';
874
+ const headers = { accept: 'application/json', 'user-agent': 'slotlock-dashboard-assistant' };
875
+ if (anthropic) {
876
+ headers['anthropic-version'] = SLOTLOCK_ANTHROPIC_VERSION;
877
+ if (args.apiKey)
878
+ headers['x-api-key'] = args.apiKey;
879
+ }
880
+ else if (args.apiKey) {
881
+ headers.authorization = `Bearer ${args.apiKey}`;
882
+ }
883
+ const signal = AbortSignal.timeout(args.timeoutMs);
884
+ let response;
885
+ try {
886
+ response = await args.fetch(anthropic ? `${args.baseUrl}/v1/models?limit=1000` : `${args.baseUrl}/models`, {
887
+ method: 'GET',
888
+ headers,
889
+ // Never follow a redirect: it would carry the key elsewhere.
890
+ redirect: 'manual',
891
+ signal,
892
+ });
893
+ }
894
+ catch (error) {
895
+ const name = error?.name;
896
+ if (error?.code === 'private_address') {
897
+ throw new SlotlockAssistantProviderError('private_address');
898
+ }
899
+ throw new SlotlockAssistantProviderError(name === 'TimeoutError' || name === 'AbortError' || signal.aborted ? 'timeout' : 'unavailable');
900
+ }
523
901
  if (!response.ok) {
524
902
  await response.body?.cancel().catch(() => undefined);
525
903
  const status = response.status;
@@ -529,9 +907,7 @@ export async function callSlotlockAssistantModel(request) {
529
907
  ? 'not_found'
530
908
  : status === 429
531
909
  ? 'rate_limited'
532
- : status === 408 || status === 504
533
- ? 'timeout'
534
- : 'unavailable', status);
910
+ : 'unavailable', status);
535
911
  }
536
912
  let body;
537
913
  try {
@@ -540,12 +916,20 @@ export async function callSlotlockAssistantModel(request) {
540
916
  catch (error) {
541
917
  if (error instanceof SlotlockAssistantProviderError)
542
918
  throw error;
543
- if (request.signal.aborted)
544
- throw new SlotlockAssistantProviderError('timeout');
919
+ throw new SlotlockAssistantProviderError(signal.aborted ? 'timeout' : 'bad_answer');
920
+ }
921
+ // `{ data: [...] }` as OpenAI and Anthropic answer, or the bare array some OpenAI-compatible
922
+ // providers (Together) answer instead.
923
+ const listed = Array.isArray(body) ? body : isRecord(body) && Array.isArray(body.data) ? body.data : null;
924
+ if (!listed)
545
925
  throw new SlotlockAssistantProviderError('bad_answer');
926
+ const ids = new Set();
927
+ for (const entry of listed) {
928
+ const id = isRecord(entry) ? entry.id : undefined;
929
+ if (typeof id === 'string' && SLOTLOCK_ASSISTANT_MODEL_NAME.test(id))
930
+ ids.add(id);
546
931
  }
547
- const turn = anthropic ? readAnthropic(body) : readOpenai(body);
548
- return { text: turn.text.slice(0, MAX_MODEL_TEXT), calls: turn.calls };
932
+ return [...ids].sort().slice(0, MAX_LISTED_MODELS);
549
933
  }
550
934
  /**
551
935
  * The connection test: one request offering one read tool the model must call. True when it
@@ -577,16 +961,66 @@ const toolText = (value) => {
577
961
  /**
578
962
  * One member message: call the model, run what it reads, keep what it would write, and call it
579
963
  * again with the results, up to `maxSteps` calls. Only the offered tools run, whatever the model
580
- * names; everything else is answered `tool_not_available`.
964
+ * names; everything else is answered `tool_not_available`. With `onEvent` each model call streams;
965
+ * once `signal` aborts, no model call or tool goes on, and every call kept has its result.
581
966
  */
582
967
  export async function runSlotlockAssistantTurn(options) {
583
968
  const tools = slotlockAssistantTools();
584
969
  const byName = new Map(tools.map((tool) => [tool.name, tool]));
585
970
  const maxSteps = Math.max(1, Math.min(options.maxSteps ?? SLOTLOCK_ASSISTANT_MAX_STEPS, 20));
586
971
  const turnSignal = AbortSignal.timeout(options.turnTimeoutMs);
972
+ const { onEvent, signal: stop } = options;
587
973
  const added = [];
974
+ const tell = (event) => {
975
+ try {
976
+ onEvent?.(event);
977
+ }
978
+ catch {
979
+ // Showing the turn never changes it.
980
+ }
981
+ };
982
+ const stopped = () => {
983
+ added.push({ role: 'note', text: 'Stopped.' });
984
+ return { added };
985
+ };
986
+ const run = async (call, tool, index) => {
987
+ if (!tool || index >= MAX_CALLS_PER_STEP) {
988
+ return {
989
+ id: call.id,
990
+ name: call.name,
991
+ content: toolText({ error: { code: tool ? 'too_many_calls' : 'tool_not_available' } }),
992
+ error: true,
993
+ };
994
+ }
995
+ if (tool.use === 'read') {
996
+ const outcome = await options.read(tool.name, call.input);
997
+ return {
998
+ id: call.id,
999
+ name: call.name,
1000
+ content: toolText(outcome.ok ? outcome.data : { error: { code: outcome.code, ...(outcome.details ?? {}) } }),
1001
+ error: !outcome.ok,
1002
+ };
1003
+ }
1004
+ const proposed = await options.propose(tool.name, call.input);
1005
+ return proposed.ok
1006
+ ? {
1007
+ id: call.id,
1008
+ name: call.name,
1009
+ content: toolText({
1010
+ status: 'proposed',
1011
+ action_id: proposed.actionId,
1012
+ note: 'Not run. The member sees this change and may approve it; the result comes back as a dashboard note.',
1013
+ }),
1014
+ error: false,
1015
+ action: proposed.actionId,
1016
+ }
1017
+ : { id: call.id, name: call.name, content: toolText({ error: { code: proposed.code } }), error: true };
1018
+ };
588
1019
  for (let step = 0; step < maxSteps; step += 1) {
1020
+ if (stop?.aborted)
1021
+ return stopped();
589
1022
  let turn;
1023
+ let streamed = '';
590
1024
  try {
591
1025
  turn = await callSlotlockAssistantModel({
592
1026
  model: options.model,
@@ -594,10 +1028,24 @@ export async function runSlotlockAssistantTurn(options) {
594
1028
  entries: [...options.entries, ...added],
595
1029
  tools,
596
1030
  fetch: options.fetch,
597
- signal: AbortSignal.any([turnSignal, AbortSignal.timeout(options.timeoutMs)]),
1031
+ signal: AbortSignal.any([turnSignal, AbortSignal.timeout(options.timeoutMs), ...(stop ? [stop] : [])]),
1032
+ ...(onEvent
1033
+ ? {
1034
+ onText: (delta) => {
1035
+ streamed += delta;
1036
+ tell({ type: 'text', delta });
1037
+ },
1038
+ }
1039
+ : {}),
598
1040
  });
599
1041
  }
600
1042
  catch (error) {
1043
+ if (stop?.aborted) {
1044
+ // The text shown so far stays; calls of a reply cut short do not, as none has a result.
1045
+ if (streamed !== '')
1046
+ added.push({ role: 'assistant', text: streamed, calls: [] });
1047
+ return stopped();
1048
+ }
601
1049
  added.push({ role: 'note', text: slotlockAssistantProviderMessage(error) });
602
1050
  return { added, failure: error };
603
1051
  }
@@ -611,48 +1059,28 @@ export async function runSlotlockAssistantTurn(options) {
611
1059
  return { added };
612
1060
  const results = [];
613
1061
  for (const [index, call] of calls.entries()) {
614
- const tool = byName.get(call.name);
615
- if (!tool || index >= MAX_CALLS_PER_STEP) {
616
- results.push({
617
- id: call.id,
618
- name: call.name,
619
- content: toolText({ error: { code: tool ? 'too_many_calls' : 'tool_not_available' } }),
620
- error: true,
621
- });
622
- continue;
623
- }
624
- if (tool.use === 'read') {
625
- const outcome = await options.read(tool.name, call.input);
626
- results.push({
627
- id: call.id,
628
- name: call.name,
629
- content: toolText(outcome.ok ? outcome.data : { error: { code: outcome.code, ...(outcome.details ?? {}) } }),
630
- error: !outcome.ok,
631
- });
1062
+ if (stop?.aborted) {
1063
+ // Not run: answered, so the model is never sent a call without its result.
1064
+ results.push({ id: call.id, name: call.name, content: toolText({ error: { code: 'stopped' } }), error: true });
632
1065
  continue;
633
1066
  }
634
- const proposed = await options.propose(tool.name, call.input);
635
- results.push(proposed.ok
636
- ? {
637
- id: call.id,
638
- name: call.name,
639
- content: toolText({
640
- status: 'proposed',
641
- action_id: proposed.actionId,
642
- note: 'Not run. The member sees this change and may approve it; the result comes back as a dashboard note.',
643
- }),
644
- error: false,
645
- action: proposed.actionId,
646
- }
647
- : {
648
- id: call.id,
649
- name: call.name,
650
- content: toolText({ error: { code: proposed.code } }),
651
- error: true,
652
- });
1067
+ const tool = byName.get(call.name);
1068
+ const use = !tool ? 'unknown' : tool.use === 'read' ? 'read' : 'write';
1069
+ tell({ type: 'call', id: call.id, name: call.name, use });
1070
+ const result = await run(call, tool, index);
1071
+ results.push(result);
1072
+ tell({
1073
+ type: 'result',
1074
+ id: result.id,
1075
+ name: result.name,
1076
+ ok: !result.error,
1077
+ ...(result.action !== undefined ? { actionId: result.action } : {}),
1078
+ });
653
1079
  }
654
1080
  added.push({ role: 'tool', results });
655
1081
  }
1082
+ if (stop?.aborted)
1083
+ return stopped();
656
1084
  added.push({
657
1085
  role: 'note',
658
1086
  text: `The assistant stopped after ${maxSteps} steps. Ask again, or more narrowly, to go on.`,