slotlock 0.7.0 → 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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,50 @@
3
3
  All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and semantic
4
4
  versioning.
5
5
 
6
+ ## 0.8.0 - 2026-11-10
7
+
8
+ ### Added
9
+
10
+ - **Usage** in the dashboard's navigation on hosted Slotlock, with or without Stripe: the plan (or
11
+ the default it moves to, and when) against its limits, this month's operations, bookings and
12
+ holds against the quotas, and which key used what. Without Stripe, an owner asks for another plan
13
+ at `SLOTLOCK_PLAN_CONTACT`. `createSlotlockDashboard` takes `plans` (`plan`, `enrol`,
14
+ `planDefault`, `contact`); `billing` still brings its own.
15
+
16
+ ### Changed
17
+
18
+ - The Overview leads with what needs a person (approvals waiting, a failed payment, a quota nearly
19
+ or fully used, a coming plan the workspace is over), then the week ahead and the activity as
20
+ sentences ("Booking agent booked room-3", passive for what Slotlock did, repeats counted on one
21
+ line), never a change's type. Get started walks a new workspace from its first resource to an
22
+ agent that has used a key, folds to one line half way and goes once done. The plan's use and the
23
+ MCP and A2A endpoints (with the Claude Code command) sit beside the activity.
24
+ - The Plan page is Usage, and a limit refused says "see Usage".
25
+
26
+ - The dashboard assistant is a chat. A reply streams into the page as the model writes it (the
27
+ dashboard's own script reads `POST <root>/assistant/stream`, one JSON object a line; the CSP
28
+ allows `connect-src 'self'` for it), with a Stop button. Without script, a message still posts
29
+ and the page shows the reply. Replies are Markdown, rendered by one renderer on the server and,
30
+ from its source, in the page as they stream: everything escaped first, links http(s) only. The
31
+ page is one centred column, with the box at the centre when the conversation is empty and five
32
+ things to ask beside it. Each run of reads folds into one line ("Read resources, checked
33
+ availability") that names, in words, a step that could not run and why. A proposed change is a
34
+ card that leads with when, which resource and its title, and keeps every other argument one click
35
+ away; its button says what it does ("Book", "Cancel booking"), and once decided it folds into
36
+ one line. A provider failure ends the conversation with Retry.
37
+ - Settings is a page a section (Assistant, Endpoints, Hours, Account). The assistant's section
38
+ shows what is connected, with Test and its result in place, then one form: the provider as tiles
39
+ (a named one brings its own URL and API format; Custom takes both), the model with Load models,
40
+ and the key as its last four characters with Replace. Disconnect stands apart.
41
+
42
+ ### Fixed
43
+
44
+ - A message sent twice (a double click, a reload) no longer ends on "That form was already
45
+ sent": the second lands on the conversation, and the turn runs once. The same holds for
46
+ approving, declining and New conversation.
47
+ - A success notice (Saved, Connected) was shown in the error colour: notices are alerts only on
48
+ an error page, and a status otherwise.
49
+
6
50
  ## 0.7.0 - 2026-11-10
7
51
 
8
52
  ### Added
package/assistant.d.ts CHANGED
@@ -191,6 +191,11 @@ interface ModelRequest {
191
191
  force?: string;
192
192
  fetch: typeof fetch;
193
193
  signal: AbortSignal;
194
+ /**
195
+ * Stream the answer: called with each piece of its text as it arrives, in order; the pieces join
196
+ * to the answer's text. Without it the call asks for one JSON answer.
197
+ */
198
+ onText?: (delta: string) => void;
194
199
  }
195
200
  /** One model call. Throws SlotlockAssistantProviderError, whose message never holds the key. */
196
201
  export declare function callSlotlockAssistantModel(request: ModelRequest): Promise<SlotlockAssistantModelTurn>;
@@ -257,7 +262,30 @@ export interface SlotlockAssistantTurnOptions {
257
262
  ok: false;
258
263
  code: string;
259
264
  }>;
265
+ /** Stream the turn: each model call's text as it arrives, and each tool call before and after it runs. */
266
+ onEvent?: (event: SlotlockAssistantTurnEvent) => void;
267
+ /** The member's Stop: the turn ends at once, keeping what it has, and says it stopped. */
268
+ signal?: AbortSignal;
260
269
  }
270
+ /**
271
+ * What a streamed turn tells as it goes, in order: text as the model writes it; then each tool call
272
+ * (`read` runs, `write` is proposed, `unknown` is refused) before it runs, and its result after.
273
+ */
274
+ export type SlotlockAssistantTurnEvent = {
275
+ type: 'text';
276
+ delta: string;
277
+ } | {
278
+ type: 'call';
279
+ id: string;
280
+ name: string;
281
+ use: 'read' | 'write' | 'unknown';
282
+ } | {
283
+ type: 'result';
284
+ id: string;
285
+ name: string;
286
+ ok: boolean;
287
+ actionId?: string;
288
+ };
261
289
  export interface SlotlockAssistantTurnResult {
262
290
  /** The entries this turn added. */
263
291
  added: SlotlockAssistantEntry[];
@@ -267,7 +295,8 @@ export interface SlotlockAssistantTurnResult {
267
295
  /**
268
296
  * One member message: call the model, run what it reads, keep what it would write, and call it
269
297
  * again with the results, up to `maxSteps` calls. Only the offered tools run, whatever the model
270
- * names; everything else is answered `tool_not_available`.
298
+ * names; everything else is answered `tool_not_available`. With `onEvent` each model call streams;
299
+ * once `signal` aborts, no model call or tool goes on, and every call kept has its result.
271
300
  */
272
301
  export declare function runSlotlockAssistantTurn(options: SlotlockAssistantTurnOptions): Promise<SlotlockAssistantTurnResult>;
273
302
  /**
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
@@ -510,14 +512,279 @@ async function readBounded(response) {
510
512
  }
511
513
  return Buffer.concat(chunks).toString('utf8');
512
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';
513
780
  /** One model call. Throws SlotlockAssistantProviderError, whose message never holds the key. */
514
781
  export async function callSlotlockAssistantModel(request) {
515
- const { model } = request;
782
+ const { model, onText } = request;
516
783
  const anthropic = model.provider === 'anthropic';
517
784
  const url = anthropic ? `${model.baseUrl}/v1/messages` : `${model.baseUrl}/chat/completions`;
518
785
  const headers = {
519
786
  'content-type': 'application/json',
520
- accept: 'application/json',
787
+ accept: onText ? 'text/event-stream' : 'application/json',
521
788
  'user-agent': 'slotlock-dashboard-assistant',
522
789
  };
523
790
  if (anthropic) {
@@ -528,12 +795,13 @@ export async function callSlotlockAssistantModel(request) {
528
795
  else if (model.apiKey) {
529
796
  headers.authorization = `Bearer ${model.apiKey}`;
530
797
  }
798
+ const body = anthropic ? anthropicBody(request) : openaiBody(request);
531
799
  let response;
532
800
  try {
533
801
  response = await request.fetch(url, {
534
802
  method: 'POST',
535
803
  headers,
536
- body: JSON.stringify(anthropic ? anthropicBody(request) : openaiBody(request)),
804
+ body: JSON.stringify(onText ? { ...body, stream: true } : body),
537
805
  // Never follow a redirect: it would carry the key elsewhere.
538
806
  redirect: 'manual',
539
807
  signal: request.signal,
@@ -548,20 +816,20 @@ export async function callSlotlockAssistantModel(request) {
548
816
  }
549
817
  if (!response.ok) {
550
818
  await response.body?.cancel().catch(() => undefined);
551
- const status = response.status;
552
- throw new SlotlockAssistantProviderError(status === 401 || status === 403
553
- ? 'refused_key'
554
- : status === 404
555
- ? 'not_found'
556
- : status === 429
557
- ? 'rate_limited'
558
- : status === 408 || status === 504
559
- ? 'timeout'
560
- : 'unavailable', status);
819
+ throw new SlotlockAssistantProviderError(statusReason(response.status), response.status);
561
820
  }
562
- let body;
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;
563
831
  try {
564
- body = JSON.parse(await readBounded(response));
832
+ answer = JSON.parse(await readBounded(response));
565
833
  }
566
834
  catch (error) {
567
835
  if (error instanceof SlotlockAssistantProviderError)
@@ -570,7 +838,8 @@ export async function callSlotlockAssistantModel(request) {
570
838
  throw new SlotlockAssistantProviderError('timeout');
571
839
  throw new SlotlockAssistantProviderError('bad_answer');
572
840
  }
573
- const turn = anthropic ? readAnthropic(body) : readOpenai(body);
841
+ const turn = anthropic ? readAnthropic(answer) : readOpenai(answer);
842
+ sink?.emit(turn.text);
574
843
  return { text: turn.text.slice(0, MAX_MODEL_TEXT), calls: turn.calls };
575
844
  }
576
845
  /**
@@ -692,16 +961,66 @@ const toolText = (value) => {
692
961
  /**
693
962
  * One member message: call the model, run what it reads, keep what it would write, and call it
694
963
  * again with the results, up to `maxSteps` calls. Only the offered tools run, whatever the model
695
- * 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.
696
966
  */
697
967
  export async function runSlotlockAssistantTurn(options) {
698
968
  const tools = slotlockAssistantTools();
699
969
  const byName = new Map(tools.map((tool) => [tool.name, tool]));
700
970
  const maxSteps = Math.max(1, Math.min(options.maxSteps ?? SLOTLOCK_ASSISTANT_MAX_STEPS, 20));
701
971
  const turnSignal = AbortSignal.timeout(options.turnTimeoutMs);
972
+ const { onEvent, signal: stop } = options;
702
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
+ };
703
1019
  for (let step = 0; step < maxSteps; step += 1) {
1020
+ if (stop?.aborted)
1021
+ return stopped();
704
1022
  let turn;
1023
+ let streamed = '';
705
1024
  try {
706
1025
  turn = await callSlotlockAssistantModel({
707
1026
  model: options.model,
@@ -709,10 +1028,24 @@ export async function runSlotlockAssistantTurn(options) {
709
1028
  entries: [...options.entries, ...added],
710
1029
  tools,
711
1030
  fetch: options.fetch,
712
- 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
+ : {}),
713
1040
  });
714
1041
  }
715
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
+ }
716
1049
  added.push({ role: 'note', text: slotlockAssistantProviderMessage(error) });
717
1050
  return { added, failure: error };
718
1051
  }
@@ -726,48 +1059,28 @@ export async function runSlotlockAssistantTurn(options) {
726
1059
  return { added };
727
1060
  const results = [];
728
1061
  for (const [index, call] of calls.entries()) {
729
- const tool = byName.get(call.name);
730
- if (!tool || index >= MAX_CALLS_PER_STEP) {
731
- results.push({
732
- id: call.id,
733
- name: call.name,
734
- content: toolText({ error: { code: tool ? 'too_many_calls' : 'tool_not_available' } }),
735
- error: true,
736
- });
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 });
737
1065
  continue;
738
1066
  }
739
- if (tool.use === 'read') {
740
- const outcome = await options.read(tool.name, call.input);
741
- results.push({
742
- id: call.id,
743
- name: call.name,
744
- content: toolText(outcome.ok ? outcome.data : { error: { code: outcome.code, ...(outcome.details ?? {}) } }),
745
- error: !outcome.ok,
746
- });
747
- continue;
748
- }
749
- const proposed = await options.propose(tool.name, call.input);
750
- results.push(proposed.ok
751
- ? {
752
- id: call.id,
753
- name: call.name,
754
- content: toolText({
755
- status: 'proposed',
756
- action_id: proposed.actionId,
757
- note: 'Not run. The member sees this change and may approve it; the result comes back as a dashboard note.',
758
- }),
759
- error: false,
760
- action: proposed.actionId,
761
- }
762
- : {
763
- id: call.id,
764
- name: call.name,
765
- content: toolText({ error: { code: proposed.code } }),
766
- error: true,
767
- });
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
+ });
768
1079
  }
769
1080
  added.push({ role: 'tool', results });
770
1081
  }
1082
+ if (stop?.aborted)
1083
+ return stopped();
771
1084
  added.push({
772
1085
  role: 'note',
773
1086
  text: `The assistant stopped after ${maxSteps} steps. Ask again, or more narrowly, to go on.`,
@@ -0,0 +1,21 @@
1
+ /** How a step reads while it runs, once it has, and when it could not. */
2
+ export type SlotlockAssistantStepState = 'run' | 'ok' | 'bad';
3
+ /** A tool step in plain words: "Checking availability…", "Checked availability", "Couldn't check availability". */
4
+ export declare function slotlockAssistantStepLabel(name: string, title: string, state: SlotlockAssistantStepState, write?: boolean): string;
5
+ /** The verb a proposed change's card is approved with, and whether it removes something. */
6
+ export declare function slotlockAssistantActionVerb(operation: string): {
7
+ verb: string;
8
+ busy: string;
9
+ done: string;
10
+ danger: boolean;
11
+ };
12
+ /** The chat's styles: one centred column, the composer docked under it, quiet steps and cards. */
13
+ export declare const SLOTLOCK_ASSISTANT_CSS = "\n.assistant-page { display: grid; }\n.assistant { position: relative; display: flex; flex-direction: column; width: min(100%, 720px); margin: 0 auto; min-height: calc(100dvh - 180px); }\n.assistant .thread { flex: 1; }\n.assistant[data-empty] { justify-content: center; padding-bottom: 12vh; }\n.assistant[data-empty] .thread { display: none; }\n.assistant[data-empty] .dock { order: 1; position: static; padding: 0; background: none; }\n.assistant[data-empty] .assistant-start { order: 2; padding-top: var(--space-3); }\n.assistant:not([data-empty]) .assistant-start { display: none; }\n.chips { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-2); }\n.chips form { margin: 0; }\n.chip { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 var(--space-3); border-radius: var(--radius-full); border: 1px solid var(--line); background: var(--surface); color: var(--ink-2); font: inherit; font-size: var(--text-md); cursor: pointer; transition: background var(--fast) var(--ease), border-color var(--fast) var(--ease); }\n.chip:hover { background: var(--hover); border-color: var(--line-strong); color: var(--ink); }\n.thread { list-style: none; margin: 0; padding: var(--space-2) 0 var(--space-6); display: grid; gap: var(--space-6); align-content: start; }\n.turn { margin: 0; min-width: 0; }\n.turn.mine { justify-self: end; max-width: 80%; }\n.bubble { padding: 10px 14px; border-radius: 18px; background: var(--sunken); color: var(--ink); font-size: var(--text-lg); line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }\n.turn.bot { justify-self: stretch; }\n.prose { max-width: 68ch; color: var(--ink); font-size: var(--text-lg); line-height: 1.6; overflow-wrap: anywhere; }\n.prose > :first-child { margin-top: 0; }\n.prose > :last-child { margin-bottom: 0; }\n.prose p { margin: 0 0 var(--space-3); color: inherit; }\n.prose strong { font-weight: 600; }\n.prose h4 { margin: var(--space-4) 0 var(--space-2); font-size: var(--text-lg); font-weight: 600; }\n.prose ul, .prose ol { margin: 0 0 var(--space-3); padding-left: 20px; }\n.prose li { margin: 4px 0; }\n.prose li > ul, .prose li > ol { margin: 4px 0 0; }\n.prose a { color: var(--accent); text-underline-offset: 2px; }\n.prose code { font: 400 12.5px/1.4 var(--mono); background: var(--sunken); border-radius: 4px; padding: 1px 5px; }\n.prose pre { margin: 0 0 var(--space-3); padding: var(--space-3); background: var(--sunken); border: 1px solid var(--line); border-radius: var(--radius); overflow-x: auto; }\n.prose pre code { background: none; padding: 0; font-size: 12.5px; }\n.prose blockquote { margin: 0 0 var(--space-3); padding-left: var(--space-3); border-left: 2px solid var(--line-strong); color: var(--ink-2); }\n.prose hr { border: 0; border-top: 1px solid var(--line); margin: var(--space-4) 0; }\n.md-table { overflow-x: auto; margin: 0 0 var(--space-3); }\n.md-table table { border-collapse: collapse; font-size: var(--text-md); min-width: 50%; }\n.md-table table { width: auto; }\n.md-table th { font: 600 var(--text-md)/1.4 var(--font); text-transform: none; letter-spacing: 0; background: none; text-align: left; color: var(--ink-2); padding: 6px 16px 6px 0; border-bottom: 1px solid var(--line); white-space: nowrap; }\n.md-table td { padding: 6px 16px 6px 0; border-bottom: 1px solid var(--line-soft); vertical-align: top; }\n.md-table tr:last-child td { border-bottom: 0; }\n.change details.more { margin: 0; }\n.change details.more > summary { cursor: pointer; color: var(--ink-3); font-size: var(--text-sm); list-style: none; }\n.change details.more > summary::-webkit-details-marker { display: none; }\n.change details.more > summary:hover { color: var(--ink-2); }\n.change details.more[open] > summary { margin-bottom: var(--space-2); }\n.turn.steps { justify-self: start; }\ndetails.steps > summary { list-style: none; display: inline-flex; align-items: center; gap: 6px; cursor: pointer; color: var(--ink-3); font-size: var(--text-md); padding: 2px 0; }\ndetails.steps > summary::-webkit-details-marker { display: none; }\ndetails.steps > summary::after { content: \"\"; width: 6px; height: 6px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); transition: transform var(--fast) var(--ease); margin-left: 2px; }\ndetails.steps[open] > summary::after { transform: rotate(45deg); }\ndetails.steps > summary:hover { color: var(--ink-2); }\ndetails.steps.has-bad > summary::before { content: \"\"; width: 6px; height: 6px; border-radius: 50%; background: var(--bad); }\n.step-list { list-style: none; margin: var(--space-2) 0 0; padding: 0 0 0 var(--space-3); border-left: 1px solid var(--line); display: grid; gap: 6px; }\n.step { display: flex; align-items: baseline; gap: var(--space-2); font-size: var(--text-md); color: var(--ink-2); }\n.step::before { content: \"\"; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--good); transform: translateY(-1px); }\n.step.bad::before { background: var(--bad); }\n.step.run::before { background: var(--ink-3); }\n.step .why { color: var(--ink-3); font-size: var(--text-sm); }\n.live-steps { display: grid; gap: 6px; }\n.live-steps .step.run, .thinking { background: linear-gradient(90deg, var(--ink-3) 0%, var(--ink) 50%, var(--ink-3) 100%); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: assistant-shimmer 1.6s linear infinite; }\n.thinking { font-size: var(--text-md); justify-self: start; }\n@keyframes assistant-shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } }\n.prose.streaming > :last-child::after { content: \"\"; display: inline-block; width: 8px; height: 8px; margin-left: 4px; border-radius: 50%; background: var(--ink-3); vertical-align: baseline; animation: assistant-pulse 1s ease-in-out infinite; }\n@keyframes assistant-pulse { 50% { opacity: 0.25; } }\n.turn.note { color: var(--ink-3); font-size: var(--text-md); }\n.turn.error { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: 10px 12px; border: 1px solid var(--bad-line); background: var(--bad-wash); border-radius: var(--radius); font-size: var(--text-md); color: var(--ink); }\n.turn.error::before { content: \"\"; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--bad); }\n.turn.error > span { flex: 1; }\n.turn.error form, .turn.note form { margin: 0; }\n.text-button { appearance: none; border: 0; background: none; padding: 0; font: inherit; font-size: var(--text-md); color: var(--accent); cursor: pointer; }\n.text-button:hover { text-decoration: underline; text-underline-offset: 2px; }\n.turn.action { justify-self: start; width: min(100%, 560px); }\n.change { padding: var(--space-4) var(--space-5); background: var(--surface); border: 1px solid var(--line-strong); border-radius: var(--radius-lg); display: grid; gap: var(--space-3); }\n.change-title { margin: 0; font-size: var(--text-lg); font-weight: 600; color: var(--ink); overflow-wrap: anywhere; }\n.change .facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px var(--space-4); margin: 0; font-size: var(--text-md); }\n.change .facts dt { color: var(--ink-3); font-size: var(--text-sm); padding-top: 1px; }\n.change .facts dd { margin: 0; color: var(--ink); overflow-wrap: anywhere; }\n.change .facts dd pre { margin: 0; white-space: pre-wrap; font: 400 12px/1.5 var(--mono); }\n.change .facts .was { color: var(--ink-3); text-decoration: line-through; }\n.change-buttons { display: flex; gap: var(--space-2); flex-wrap: wrap; }\n.change-buttons form { margin: 0; }\n.change-buttons button { height: 32px; }\nbutton.danger { background: var(--bad); color: var(--bad-ink); border-color: var(--bad); }\nbutton.danger:hover { filter: brightness(1.08); }\nbutton.ghost { background: transparent; border-color: transparent; color: var(--ink-2); }\nbutton.ghost:hover { background: var(--hover); color: var(--ink); }\n.receipt { margin: 0; display: flex; align-items: baseline; gap: var(--space-2); font-size: var(--text-md); color: var(--ink-2); }\n.receipt::before { content: \"\"; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--ink-3); transform: translateY(-1px); }\n.receipt.ok { color: var(--ink); }\n.receipt.ok::before { background: var(--good); }\n.receipt.bad::before { background: var(--bad); }\n.receipt .why { color: var(--ink-3); }\n.dock { position: sticky; bottom: 0; padding: var(--space-6) 0 var(--space-6); background: linear-gradient(to bottom, transparent, var(--canvas) 24px); z-index: 2; }\n.composer { display: grid; margin: 0; background: var(--surface); border: 1px solid var(--line); border-radius: 16px; transition: border-color var(--fast) var(--ease), box-shadow var(--fast) var(--ease); }\n.composer:focus-within { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--ring); }\n.composer textarea { display: block; width: 100%; min-height: 26px; max-height: 200px; resize: none; border: 0; outline: 0; background: transparent; padding: 14px 16px 0; color: var(--ink); font: inherit; font-size: var(--text-lg); line-height: 1.5; box-shadow: none; }\n.composer textarea:focus { box-shadow: none; border: 0; }\n.composer-bar { display: flex; align-items: center; justify-content: flex-end; gap: var(--space-2); padding: var(--space-2); }\n.composer-bar .count { margin-right: auto; padding-left: var(--space-2); font-size: var(--text-sm); color: var(--ink-3); }\n.composer-bar .count.over { color: var(--warn); }\n.send { display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; border-radius: 50%; border: 0; background: var(--button); color: var(--button-ink); cursor: pointer; transition: opacity var(--fast) var(--ease), background var(--fast) var(--ease); }\n.send:hover { background: var(--button-hover); }\n.send:disabled { opacity: 0.4; cursor: default; }\n.send svg { width: 16px; height: 16px; }\n.send .stop { display: none; }\n.assistant.streaming .send .arrow { display: none; }\n.assistant.streaming .send .stop { display: block; }\n.jump { position: absolute; left: 50%; transform: translate(-50%, 0); top: -8px; width: 32px; height: 32px; padding: 0; border-radius: 50%; border: 1px solid var(--line); background: var(--surface); color: var(--ink-2); display: grid; place-items: center; cursor: pointer; box-shadow: var(--pop-shadow); }\n.jump[hidden] { display: none; }\n.new-chat svg { width: 14px; height: 14px; }\n.assistant-page[data-empty] .new-chat { display: none; }\n@media (prefers-reduced-motion: reduce) {\n .live-steps .step.run, .thinking { animation: none; color: var(--ink-3); background: none; }\n .prose.streaming > :last-child::after { animation: none; }\n}\n@media (max-width: 760px) {\n .assistant { min-height: calc(100dvh - 140px); }\n .assistant[data-empty] { justify-content: flex-end; padding-bottom: 0; }\n .assistant[data-empty] .assistant-start { order: 1; padding: 0 0 var(--space-3); }\n .assistant[data-empty] .dock { order: 2; position: sticky; padding-bottom: max(12px, env(safe-area-inset-bottom)); }\n .chips { flex-wrap: nowrap; justify-content: flex-start; overflow-x: auto; max-width: 100%; padding-bottom: 2px; scrollbar-width: none; }\n .chips::-webkit-scrollbar { display: none; }\n .chip { flex: none; }\n .turn.mine { max-width: 88%; }\n .md-table table { display: table; }\n .md-table thead { display: table-header-group; }\n .md-table tbody { display: table-row-group; }\n .md-table tr { display: table-row; padding: 0; border: 0; }\n .md-table th, .md-table td { display: table-cell; padding: 6px 12px 6px 0; }\n .md-table td { border-bottom: 1px solid var(--line-soft); }\n .composer textarea { font-size: 16px; }\n .dock { padding-bottom: max(12px, env(safe-area-inset-bottom)); }\n .change { padding: var(--space-4); }\n .change .facts { grid-template-columns: minmax(0, 1fr); gap: 2px; }\n .change .facts dd { margin-bottom: var(--space-2); }\n .change-buttons form { flex: 1; }\n .change-buttons button { width: 100%; height: 40px; }\n}\n";
14
+ /**
15
+ * The browser side of the chat, appended to app.js: Enter sends, the box grows, a write chip fills
16
+ * the box instead of sending, and a reply streams in from `<root>/assistant/stream` (newline-
17
+ * delimited JSON: text deltas, steps, then the turn as the server renders it). Without it the
18
+ * page still works: every form posts and the page reloads at the latest message.
19
+ * `markdownSource` is renderSlotlockMarkdown's source, so a reply renders as it will once stored.
20
+ */
21
+ export declare function slotlockAssistantScript(markdownSource: string): string;