slotlock 0.5.1 → 0.5.3

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/dashboard.js CHANGED
@@ -14,7 +14,12 @@
14
14
  // keeps these in Postgres, so they hold on every server that shares the database; the resource cap
15
15
  // is held in the database too.
16
16
  import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
17
+ import { describeSlotlockAgentWrite, invokeSlotlockAgentOperation, slotlockAgentTools, } from './agent-server.js';
18
+ import { SLOTLOCK_ASSISTANT_API_KEY, SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES, SLOTLOCK_ASSISTANT_MAX_MESSAGE, SLOTLOCK_ASSISTANT_MAX_PENDING, SLOTLOCK_ASSISTANT_MODEL_NAME, SLOTLOCK_ASSISTANT_TOOL_USE, boundSlotlockAssistantEntries, createSlotlockAssistantStore, openSlotlockAssistantKey, pendingSlotlockAssistantActions, pruneSlotlockAssistantActions, checkSlotlockAssistantHost, readSlotlockAssistantBaseUrl, slotlockAssistantUrlAllowed, readSlotlockAssistantEntries, runSlotlockAssistantTurn, sealSlotlockAssistantKey, slotlockAssistantProviderMessage, slotlockAssistantSystemPrompt, testSlotlockAssistantModel, } from './assistant.js';
17
19
  import { SLOTLOCK_DASHBOARD_NAME_PATTERN } from './config.js';
20
+ import { createSlotlockRateLimiter } from './rate-limit.js';
21
+ import { createSlotlockGuardedFetch } from './webhooks.js';
22
+ import { lookup as dnsLookup } from 'node:dns/promises';
18
23
  import { SLOTLOCK_DASHBOARD_FORM_LIMIT } from './ddl.js';
19
24
  import { ignoreRejection } from './hooks.js';
20
25
  import { SLOTLOCK_BOOKING_RULE_LIMITS } from './booking-rules.js';
@@ -25,6 +30,8 @@ export const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 100;
25
30
  const SESSION_TTL_MS = 12 * 3_600_000;
26
31
  const SIGN_IN_TTL_MS = 10 * 60_000;
27
32
  const MAX_FORM_BYTES = 8_192;
33
+ /** A message to the assistant: 2,000 characters, each up to 9 bytes as a form encodes it. */
34
+ const MAX_ASSISTANT_FORM_BYTES = 24_576;
28
35
  /**
29
36
  * Sign-ins (record, then GitHub's token and user) one instance runs at once; more are told to retry.
30
37
  * This also bounds how fast unauthenticated callbacks can write sign-in records.
@@ -50,6 +57,13 @@ const POST_ROUTES = new Set([
50
57
  '/waitlist/leave',
51
58
  '/hours',
52
59
  '/organization',
60
+ '/assistant',
61
+ '/assistant/approve',
62
+ '/assistant/dismiss',
63
+ '/assistant/reset',
64
+ '/settings/assistant',
65
+ '/settings/assistant/test',
66
+ '/settings/assistant/remove',
53
67
  ]);
54
68
  const SIGN_IN_AGAIN = 'That sign-in did not finish here, or took too long. Sign in again.';
55
69
  const GITHUB_ERROR_CODE = /^[a-z0-9_]{1,64}$/;
@@ -88,7 +102,8 @@ const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'self'; style-sr
88
102
  * in the database (a READ COMMITTED transaction, under a per-tenant lock).
89
103
  */
90
104
  export function createSlotlockDashboardResources(store) {
91
- const by = (actor) => (actor === undefined ? {} : { actor });
105
+ // Every change here is the dashboard's, made by the person signed in (actor) when known.
106
+ const by = (actor) => actor === undefined ? { origin: 'dashboard' } : { origin: 'dashboard', actor };
92
107
  return {
93
108
  list: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listResources({ tenantRef, limit: SLOTLOCK_DASHBOARD_MAX_RESOURCES + 1 })),
94
109
  add: (tenantRef, externalRef, timezone, actor) => store.withTenant(tenantRef, (tenant) => tenant.createResource({
@@ -106,6 +121,7 @@ export function createSlotlockDashboardResources(store) {
106
121
  agenda: (tenantRef, window, limit) => store.withTenant(tenantRef, (tenant) => tenant.listAgenda({ tenantRef, window, limit })),
107
122
  recentChanges: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listRecentChanges({ tenantRef, limit })),
108
123
  setRules: (tenantRef, id, rules, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourceBookingRules({ tenantRef, id, rules }), by(actor)),
124
+ ruleDefaults: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.getBookingRuleDefaults({ tenantRef })),
109
125
  setPool: (tenantRef, id, pool, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourcePool({ tenantRef, id, pool }), by(actor)),
110
126
  waitlist: {
111
127
  list: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listWaitlist({ tenantRef, limit })),
@@ -114,7 +130,9 @@ export function createSlotlockDashboardResources(store) {
114
130
  // An offered entry's hold went with it: offer that time to the next, as the API does.
115
131
  if (left) {
116
132
  await store
117
- .withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }))
133
+ .withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }), {
134
+ origin: 'maintenance',
135
+ })
118
136
  .catch(() => undefined);
119
137
  }
120
138
  return left;
@@ -130,6 +148,7 @@ export function createSlotlockDashboardResources(store) {
130
148
  remove: async (tenantRef, id, actor) => (await store.withTenant(tenantRef, (tenant) => tenant.deleteWebhookEndpoint({ tenantRef, id }), by(actor))).deleted,
131
149
  enable: (tenantRef, id, actor) => store.withTenant(tenantRef, (tenant) => tenant.enableWebhookEndpoint({ tenantRef, id }), by(actor)),
132
150
  },
151
+ assistant: createSlotlockAssistantStore(store),
133
152
  };
134
153
  }
135
154
  function assertExpiry(expiresAt) {
@@ -296,6 +315,8 @@ const RULE_NAMES = Object.freeze({
296
315
  minNoticeMinutes: 'Minimum notice',
297
316
  maxAdvanceDays: 'Book up to',
298
317
  slotStepMinutes: 'Start step',
318
+ stepOffsetMinutes: 'Step offset',
319
+ bufferBeforeMinutes: 'Buffer before',
299
320
  turnaroundMinutes: 'Turnaround',
300
321
  checkInMinutes: 'Check-in release',
301
322
  });
@@ -304,17 +325,59 @@ const RULE_FIELDS = Object.freeze({
304
325
  minNoticeMinutes: 'min_notice_minutes',
305
326
  maxAdvanceDays: 'max_advance_days',
306
327
  slotStepMinutes: 'slot_step_minutes',
328
+ stepOffsetMinutes: 'step_offset_minutes',
329
+ bufferBeforeMinutes: 'buffer_before_minutes',
307
330
  turnaroundMinutes: 'turnaround_minutes',
308
331
  checkInMinutes: 'check_in_minutes',
309
332
  });
333
+ /** How the inherited-rules note names each rule, with its unit. */
334
+ const INHERITED_NAMES = Object.freeze({
335
+ ...RULE_NAMES,
336
+ maxAdvanceDays: 'Book up to (days ahead)',
337
+ enforceHours: 'Only inside bookable hours',
338
+ requireCertain: 'Only in time its calendar syncs have read today',
339
+ });
340
+ /**
341
+ * The rules a resource takes from defaults, each with where it comes from: a rule its pool's
342
+ * defaults set, else one the tenant's set, where its own rules do not. Without the defaults, the
343
+ * rules that apply less its own, from "defaults".
344
+ */
345
+ function inheritedRules(resource, defaults) {
346
+ const own = resource.bookingRules ?? {};
347
+ const levels = defaults
348
+ ? [
349
+ ['every resource', defaults.tenant],
350
+ [`pool ${resource.pool ?? ''}`, resource.pool === undefined ? undefined : defaults.pools[resource.pool]],
351
+ ]
352
+ : [['defaults', resource.effectiveBookingRules]];
353
+ const inherited = new Map();
354
+ for (const [from, rules] of levels) {
355
+ for (const [name, value] of Object.entries(rules ?? {})) {
356
+ if (own[name] === undefined)
357
+ inherited.set(name, { value, from });
358
+ }
359
+ }
360
+ return inherited;
361
+ }
362
+ function inheritedNote(inherited) {
363
+ if (inherited.size === 0)
364
+ return html ``;
365
+ const parts = [...inherited].map(([name, { value, from }]) => typeof value === 'boolean'
366
+ ? `${INHERITED_NAMES[name]}${value ? '' : ': off'} (${from})`
367
+ : `${INHERITED_NAMES[name]} ${value.toLocaleString('en')} (${from})`);
368
+ return html `<p class="fine">Inherited: ${parts.join('; ')}.</p>`;
369
+ }
310
370
  const icon = (paths) => new Html(`<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${paths}</svg>`);
311
371
  const ICONS = Object.freeze({
312
- mark: new Html('<svg class="mark" viewBox="0 0 64 64" aria-hidden="true" focusable="false"><path d="M8 46 L26 14 L56 44"></path></svg>'),
372
+ mark: new Html(
373
+ // The Slotlock mark: a padlock whose body holds one booked slot (the site's logo, without the wordmark).
374
+ '<svg class="mark" viewBox="0 0 85.32 77.12" aria-hidden="true" focusable="false"><path class="mark-ink" d="M13.13 34.46L72.2 34.46C79.45 34.46 85.32 40.33 85.32 47.58L85.32 63.99C85.32 71.24 79.45 77.12 72.2 77.12L13.13 77.12C5.88 77.12 0 71.24 0 63.99L0 47.58C0 40.33 5.88 34.46 13.13 34.46ZM22.97 39.38L22.97 19.69C22.97 8.82 31.79 0 42.66 0C53.54 0 62.35 8.82 62.35 19.69L62.35 39.38L56.61 39.38L56.61 19.69C56.61 11.99 50.36 5.74 42.66 5.74C34.96 5.74 28.71 11.99 28.71 19.69L28.71 39.38Z"></path><path class="mark-slot" d="M19.69 52.51L65.63 52.51C67.45 52.51 68.91 53.98 68.91 55.79C68.91 57.6 67.45 59.07 65.63 59.07L19.69 59.07C17.88 59.07 16.41 57.6 16.41 55.79C16.41 53.98 17.88 52.51 19.69 52.51Z"></path></svg>'),
313
375
  overview: icon('<path d="M4 13h6V4H4zM14 20h6v-9h-6zM4 20h6v-3H4zM14 7h6V4h-6z"/>'),
314
376
  resources: icon('<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>'),
315
377
  keys: icon('<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3"/>'),
316
378
  waitlist: icon('<path d="M4 6h16M4 12h10M4 18h6"/><circle cx="18" cy="16" r="3"/>'),
317
379
  webhooks: icon('<path d="M9 7a3 3 0 1 1 4 2.8L16 15M6.5 13.5A3 3 0 1 0 9 18h6M15 18a3 3 0 1 0 1.5-5.6"/>'),
380
+ assistant: icon('<path d="M4 5h16v11H9l-5 4z"/><path d="M8 9h8M8 12h5"/>'),
318
381
  settings: icon('<path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/>'),
319
382
  check: icon('<path d="M5 12l5 5L20 7"/>'),
320
383
  github: new Html('<svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path class="fill" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>'),
@@ -724,6 +787,9 @@ export function createSlotlockDashboard(options) {
724
787
  ...(options.resources.webhooks
725
788
  ? [{ section: 'webhooks', label: 'Webhooks', path: '/webhooks', icon: ICONS.webhooks }]
726
789
  : []),
790
+ ...(assistantStore()
791
+ ? [{ section: 'assistant', label: 'Assistant', path: '/assistant', icon: ICONS.assistant }]
792
+ : []),
727
793
  { section: 'settings', label: 'Settings', path: '/settings', icon: ICONS.settings },
728
794
  ];
729
795
  /** The workspace the session acts in: its organization, or the person themselves. */
@@ -855,7 +921,11 @@ ${served ? html `<td data-label="Created by">${key.createdBy === personOf(sessio
855
921
  ? 'A deleted key'
856
922
  : /^github:\d+$/.test(change.actor)
857
923
  ? `GitHub user ${change.actor.slice(7)}`
858
- : change.actor.slice(0, 60)));
924
+ : /^dashboard-assistant:github:\d+$/.test(change.actor)
925
+ ? change.actor === assistantActor(session)
926
+ ? `Assistant for @${session.login}`
927
+ : `Assistant for GitHub user ${change.actor.slice(27)}`
928
+ : change.actor.slice(0, 60)));
859
929
  const where = change.resourceId
860
930
  ? resourceName(byId.get(change.resourceId), change.resourceId)
861
931
  : null;
@@ -954,10 +1024,12 @@ ${resources.length === 0
954
1024
  : html `<div class="table"><table><thead><tr><th>Reference</th><th>Time zone</th><th>Bookable hours</th><th>Resource id</th></tr></thead><tbody>${resources.map((resource) => html `<tr><td data-label="Reference" class="strong"><span class="cell"><a href="${root}/resources/${resource.id}">${resourceName(resource, resource.id)}</a>${resource.pool ? html `<span class="meta">Pool ${resource.pool}</span>` : html ``}</span></td><td data-label="Time zone">${resource.timezone}</td><td data-label="Bookable hours">${hoursSummary(resource.availabilityRules)} <a href="${root}/hours?resource=${resource.id}">Set hours</a></td><td data-label="Resource id"><code class="muted">${resource.id}</code></td></tr>`)}</tbody></table></div>`}`;
955
1025
  return respond(status, layout('Resources', content, session, 'resources'));
956
1026
  }
957
- function settingsPage(session) {
1027
+ async function settingsPage(session, status = 200, notice) {
958
1028
  const rules = options.availability ?? [];
959
1029
  const current = session.orgs?.find((org) => org.id === session.org);
960
- const content = html `${pageHead('Settings')}
1030
+ const content = html `${noticeOf(notice)}
1031
+ ${pageHead('Settings')}
1032
+ ${await assistantSettingsPanel(session)}
961
1033
  <section class="panel">
962
1034
  <h2>Endpoints</h2>
963
1035
  ${endpoints()}
@@ -976,7 +1048,7 @@ ${rules.length === 0
976
1048
  : html `<dt>Workspace</dt><dd>Personal</dd>`}<dt>Session ends</dt><dd>${formatDate(new Date(session.exp))}</dd></dl>
977
1049
  ${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}
978
1050
  </section>`;
979
- return respond(200, layout('Settings', content, session, 'settings'));
1051
+ return respond(status, layout('Settings', content, session, 'settings'));
980
1052
  }
981
1053
  /** The tenant's resource with that id and every resource (for names), or null for anything else. */
982
1054
  async function resourceDetailPage(session, id, status = 200, notice) {
@@ -986,17 +1058,33 @@ ${current ? html `<p class="fine">Membership is read from GitHub when you sign i
986
1058
  return notFound(session);
987
1059
  const start = new Date(now());
988
1060
  const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
989
- const [agenda, changes, keys] = await Promise.all([
1061
+ const [agenda, changes, keys, defaults] = await Promise.all([
990
1062
  options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
991
1063
  options.resources.recentChanges?.(tenantRef, 100),
992
1064
  options.keys.list({ tenantRef }),
1065
+ options.resources.ruleDefaults?.(tenantRef),
993
1066
  ]);
994
1067
  const byId = new Map([[resource.id, resource]]);
995
1068
  const mine = agenda?.filter((entry) => entry.resourceId === resource.id);
996
1069
  const activity = changes?.filter((change) => change.resourceId === resource.id).slice(0, 10);
997
1070
  const token = csrfToken(session);
998
1071
  const rules = resource.bookingRules ?? {};
999
- const field = (name, label, unit) => html `<label>${label}<span class="unit-input"><input type="number" inputmode="numeric" min="${String(RULE_LIMITS[name][0])}" max="${String(RULE_LIMITS[name][1])}" name="${RULE_FIELDS[name]}" value="${rules[name] === undefined ? '' : String(rules[name])}"><span>${unit}</span></span></label>`;
1072
+ const inherited = inheritedRules(resource, defaults);
1073
+ const field = (name, label, unit) => {
1074
+ const from = inherited.get(name);
1075
+ return html `<label>${label}<span class="unit-input"><input type="number" inputmode="numeric" min="${String(RULE_LIMITS[name][0])}" max="${String(RULE_LIMITS[name][1])}" name="${RULE_FIELDS[name]}" value="${rules[name] === undefined ? '' : String(rules[name])}"${from ? html ` placeholder="${String(from.value)}"` : html ``}><span>${unit}</span></span></label>`;
1076
+ };
1077
+ // On or off: a checkbox, or, once a default sets it (or the resource turned it off), a choice
1078
+ // of the default, on or off, so a resource can turn an inherited rule off.
1079
+ const toggle = (name, key, label) => {
1080
+ const from = inherited.get(name);
1081
+ if (from === undefined && rules[name] !== false) {
1082
+ return html `<label class="choice"><input type="checkbox" name="${key}" value="1"${rules[name] ? html ` checked` : html ``}> ${label}</label>`;
1083
+ }
1084
+ const chosen = (value) => (rules[name] === value ? html ` selected` : html ``);
1085
+ const fallback = from ? `As its default (${from.value ? 'on' : 'off'}, ${from.from})` : 'Off';
1086
+ return html `<label>${label}<select name="${key}"><option value=""${chosen(undefined)}>${fallback}</option><option value="1"${chosen(true)}>On</option><option value="0"${chosen(false)}>Off</option></select></label>`;
1087
+ };
1000
1088
  const content = html `${noticeOf(notice)}
1001
1089
  <p><a class="back" href="${root}/resources">Resources</a></p>
1002
1090
  ${pageHead(resourceName(resource, resource.id))}
@@ -1014,12 +1102,15 @@ ${options.resources.setRules
1014
1102
  ${field('minNoticeMinutes', 'Minimum notice', 'min')}
1015
1103
  ${field('maxAdvanceDays', 'Book up to', 'days ahead')}
1016
1104
  ${field('slotStepMinutes', 'Start on a step of', 'min')}
1105
+ ${field('stepOffsetMinutes', 'Steps start past each step by', 'min')}
1106
+ ${field('bufferBeforeMinutes', 'Kept free before each booking', 'min')}
1017
1107
  ${field('turnaroundMinutes', 'Turnaround after each booking', 'min')}
1018
1108
  ${field('checkInMinutes', 'Release if not checked in after', 'min')}
1019
1109
  </div>
1020
- <label class="choice"><input type="checkbox" name="enforce_hours" value="1"${rules.enforceHours ? html ` checked` : html ``}> Only inside bookable hours</label>
1021
- <label class="choice"><input type="checkbox" name="require_certain" value="1"${rules.requireCertain ? html ` checked` : html ``}> Only in time its calendar syncs have read today</label>
1022
- <p class="fine">Leave a field empty for no rule.</p>
1110
+ ${toggle('enforceHours', 'enforce_hours', 'Only inside bookable hours')}
1111
+ ${toggle('requireCertain', 'require_certain', 'Only in time its calendar syncs have read today')}
1112
+ ${inheritedNote(inherited)}
1113
+ <p class="fine">Leave a field empty for no rule${inherited.size > 0 ? ' of its own' : ''}.</p>
1023
1114
  <button class="primary" type="submit">Save rules</button>
1024
1115
  </form>
1025
1116
  </section>`
@@ -1061,10 +1152,17 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
1061
1152
  }
1062
1153
  rules[name] = Number(value);
1063
1154
  }
1064
- if (form.get('enforce_hours') === '1')
1065
- rules.enforceHours = true;
1066
- if (form.get('require_certain') === '1')
1067
- rules.requireCertain = true;
1155
+ // '1' on, '0' off (over a default that is on), anything else not set.
1156
+ const choice = (key) => {
1157
+ const value = form.get(key);
1158
+ return value === '1' ? true : value === '0' ? false : undefined;
1159
+ };
1160
+ const enforceHours = choice('enforce_hours');
1161
+ if (enforceHours !== undefined)
1162
+ rules.enforceHours = enforceHours;
1163
+ const requireCertain = choice('require_certain');
1164
+ if (requireCertain !== undefined)
1165
+ rules.requireCertain = requireCertain;
1068
1166
  try {
1069
1167
  await options.resources.setRules(tenantOf(session), id, Object.keys(rules).length === 0 ? null : rules, personOf(session));
1070
1168
  }
@@ -1244,6 +1342,519 @@ ${entries.length === 0
1244
1342
  }
1245
1343
  return redirect(303, `${root}/waitlist`);
1246
1344
  }
1345
+ // ---- The assistant ----
1346
+ const assistantOptions = options.assistant;
1347
+ /** The assistant's records, when the assistant is on and the adapter keeps them. Per request (Workers). */
1348
+ const assistantStore = () => assistantOptions ? options.resources.assistant : undefined;
1349
+ const privateUrls = assistantOptions?.privateUrls === true;
1350
+ const assistantFetch = assistantOptions?.fetch ?? createSlotlockGuardedFetch({ allowPrivateNetworks: privateUrls });
1351
+ const resolveHost = assistantOptions?.resolve ??
1352
+ (async (hostname) => (await dnsLookup(hostname, { all: true })).map(({ address }) => address));
1353
+ const PRIVATE_URL_REFUSAL = 'That URL is, or resolves to, a local or private network address (loopback, private, link-local, shared, unique-local or cloud metadata), which this server does not reach. For a model on its own machine or network, the server’s operator can allow them with SLOTLOCK_ASSISTANT_PRIVATE_URLS=on.';
1354
+ const assistantTimeout = assistantOptions?.timeoutMs ?? 30_000;
1355
+ const assistantTurnTimeout = assistantOptions?.turnTimeoutMs ?? 90_000;
1356
+ const conversationTtl = assistantOptions?.conversationTtlMs ?? 2 * 3_600_000;
1357
+ if (assistantOptions && Buffer.byteLength(assistantOptions.secret, 'utf8') < 32) {
1358
+ throw new Error('Slotlock dashboard assistant secret must be at least 32 bytes');
1359
+ }
1360
+ if (assistantOptions && assistantOptions.secret === options.sessionSecret) {
1361
+ throw new Error('Slotlock dashboard assistant secret must differ from the session secret');
1362
+ }
1363
+ const memberMessages = createSlotlockRateLimiter(assistantOptions?.rate ?? { perSecond: 10 / 60, burst: 10 }, now);
1364
+ const toolTitles = new Map(slotlockAgentTools().map((tool) => [tool.name, tool.title]));
1365
+ /** The writes that take an idempotency key: the dashboard sets it, from the action's id. */
1366
+ const keyed = new Set(slotlockAgentTools()
1367
+ .filter((tool) => 'idempotency_key' in (tool.inputSchema.properties ?? {}))
1368
+ .map((tool) => tool.name));
1369
+ const ACTION_ID = /^[A-Za-z0-9_-]{16}$/;
1370
+ /** Reads one member message may make, and the resource-days they may cover together. */
1371
+ const MAX_READS_PER_TURN = 16;
1372
+ const MAX_READ_COST_PER_TURN = 400;
1373
+ /** What a read costs the turn: the resources it names times the days its window spans. */
1374
+ const readCostOf = (input) => {
1375
+ if (!isRecord(input))
1376
+ return 1;
1377
+ const ids = Array.isArray(input.resource_ids) ? input.resource_ids.length : 1;
1378
+ const start = typeof input.start === 'string' ? Date.parse(input.start) : Number.NaN;
1379
+ const end = typeof input.end === 'string' ? Date.parse(input.end) : Number.NaN;
1380
+ const days = Number.isFinite(start) && Number.isFinite(end) && end > start ? Math.ceil((end - start) / DAY_MS) : 1;
1381
+ return Math.max(1, ids) * Math.max(1, days);
1382
+ };
1383
+ /**
1384
+ * Who the assistant acts as, for the record and for ownership: the member by GitHub user id,
1385
+ * which a rename does not change. Shown as "Assistant for @login".
1386
+ */
1387
+ const assistantActor = (session) => `dashboard-assistant:github:${session.uid}`;
1388
+ const CONFIRMED_WRITES = new Set([
1389
+ 'slotlock_create_event',
1390
+ 'slotlock_update_event',
1391
+ 'slotlock_delete_event',
1392
+ 'slotlock_book_together',
1393
+ 'slotlock_book_pool',
1394
+ ]);
1395
+ /**
1396
+ * The sentence a proposed change's card leads with: the agent server's confirmation question
1397
+ * (the resource by name, the times in its zone, an event as it stands: its title, resource and
1398
+ * time), or for holds the slots by resource. Read as the member's assistant, under `authorize`.
1399
+ */
1400
+ async function summarize(session, operation, input) {
1401
+ const assistant = assistantOptions;
1402
+ let text;
1403
+ if (CONFIRMED_WRITES.has(operation)) {
1404
+ text = await describeSlotlockAgentWrite({
1405
+ backend: assistant.backend,
1406
+ authorize: assistantAuthorize,
1407
+ principal: assistantPrincipal(session),
1408
+ operation: operation,
1409
+ input,
1410
+ signal: AbortSignal.timeout(assistantTimeout),
1411
+ }).catch(() => '');
1412
+ }
1413
+ else if (operation === 'slotlock_hold_slots' && Array.isArray(input.slots)) {
1414
+ const names = new Map((await options.resources.list(tenantOf(session))).map((resource) => [resource.id, resourceName(resource, resource.id)]));
1415
+ const slots = input.slots.map((slot) => `${names.get(String(slot.resource_id)) ?? `resource ${String(slot.resource_id)}`} ${String(slot.start)} to ${String(slot.end)}`);
1416
+ text = `Hold ${slots.join('; ')} for ${String(input.ttl_seconds ?? '')} seconds, as alternatives.`;
1417
+ }
1418
+ else if (operation === 'slotlock_release_slots') {
1419
+ text = `Release the hold ${String(input.hold_id)} and every slot held with it.`;
1420
+ }
1421
+ else {
1422
+ text = '';
1423
+ }
1424
+ // At most 1,500 UTF-8 bytes, so four waiting changes stay within the conversation's bound.
1425
+ return text.length > 500 ? `${text.slice(0, 500)}…` : text;
1426
+ }
1427
+ /** Owners of the organization in use may change its assistant; a person always may their own. */
1428
+ const isAdmin = (session) => !served || session.orgs?.find((org) => org.id === session.org)?.role === 'admin';
1429
+ const assistantPrincipal = (session) => ({
1430
+ subject: assistantActor(session),
1431
+ tenantRef: tenantOf(session),
1432
+ scopes: ['read', 'write'],
1433
+ });
1434
+ /** The fixed list, then the embedder's check: nothing the model sends adds to it. */
1435
+ const assistantAuthorize = async (args) => SLOTLOCK_ASSISTANT_TOOL_USE[args.operation] !== 'excluded' &&
1436
+ (assistantOptions?.authorize ? await assistantOptions.authorize(args) : true);
1437
+ /**
1438
+ * One agent operation as the member's assistant. A `propose` call is checked as an agent's is
1439
+ * (arguments, scopes, authorize) and halted by `confirm` before it runs; it takes no admission
1440
+ * slot and no rate token, which the write takes when it runs, approved (`run`).
1441
+ */
1442
+ function dispatch(session, operation, input, mode, confirm) {
1443
+ const assistant = assistantOptions;
1444
+ const principal = assistantPrincipal(session);
1445
+ return invokeSlotlockAgentOperation({
1446
+ operation,
1447
+ origin: 'dashboard-assistant',
1448
+ input,
1449
+ request: new Request(`${origin}${root}/assistant`, {
1450
+ signal: AbortSignal.timeout(assistantTimeout),
1451
+ }),
1452
+ options: {
1453
+ backend: assistant.backend,
1454
+ authenticate: async () => principal,
1455
+ authorize: async (args) => {
1456
+ if (!(await assistantAuthorize(args)))
1457
+ return false;
1458
+ if (mode === 'run')
1459
+ await assistant.admit?.(args);
1460
+ return true;
1461
+ },
1462
+ ...(assistant.consumeRateLimit && mode === 'run'
1463
+ ? { consumeRateLimit: assistant.consumeRateLimit }
1464
+ : {}),
1465
+ ...(confirm ? { confirm } : {}),
1466
+ onError: (error) => {
1467
+ try {
1468
+ ignoreRejection(options.onError?.(error));
1469
+ }
1470
+ catch {
1471
+ // A failing reporter must not change the answer.
1472
+ }
1473
+ },
1474
+ },
1475
+ });
1476
+ }
1477
+ async function assistantModel(session) {
1478
+ const store = assistantStore();
1479
+ const settings = store ? await store.getSettings(tenantOf(session)) : null;
1480
+ if (!settings || !assistantOptions)
1481
+ return null;
1482
+ if (!slotlockAssistantUrlAllowed(settings.baseUrl, privateUrls) ||
1483
+ !(await checkSlotlockAssistantHost(settings.baseUrl, { privateUrls, resolve: resolveHost }))) {
1484
+ return { problem: PRIVATE_URL_REFUSAL };
1485
+ }
1486
+ let apiKey = null;
1487
+ if (settings.sealedKey !== null) {
1488
+ apiKey = await openSlotlockAssistantKey(assistantOptions.secret, tenantOf(session), settings.sealedKey);
1489
+ if (apiKey === null) {
1490
+ return {
1491
+ problem: 'The stored API key cannot be opened: the server’s assistant secret changed. An admin can enter the key again in Settings.',
1492
+ };
1493
+ }
1494
+ }
1495
+ return {
1496
+ model: { provider: settings.provider, baseUrl: settings.baseUrl, model: settings.model, apiKey },
1497
+ };
1498
+ }
1499
+ /**
1500
+ * A field of a proposed change, as the card shows it: all of it, wrapped, never cut (the
1501
+ * proposal is bounded instead). A resource is named, with its id beside the name.
1502
+ */
1503
+ function fieldValue(name, value, names) {
1504
+ const named = (id) => (names.has(id) ? `${names.get(id)} (${id})` : id);
1505
+ if (typeof value === 'string') {
1506
+ return html `<span class="wrap">${name === 'resource_id' ? named(value) : value}</span>`;
1507
+ }
1508
+ if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {
1509
+ return html `<span class="wrap">${value.map((item) => (name === 'resource_ids' ? named(item) : item)).join(', ')}</span>`;
1510
+ }
1511
+ if (typeof value === 'number' || typeof value === 'boolean' || value === null) {
1512
+ return html `${String(value)}`;
1513
+ }
1514
+ return html `<pre class="wrap">${JSON.stringify(value, null, 2)}</pre>`;
1515
+ }
1516
+ function actionCard(session, action, names) {
1517
+ const token = csrfToken(session);
1518
+ const fields = Object.entries(action.input).filter(([name]) => name !== 'idempotency_key');
1519
+ const summary = action.summary ? html `<p class="action-summary">${action.summary}</p>` : html ``;
1520
+ const state = {
1521
+ pending: { text: 'Waiting for you', tone: 'held' },
1522
+ approved: { text: 'Running', tone: 'held' },
1523
+ dismissed: { text: 'Dismissed', tone: 'revoked' },
1524
+ done: { text: 'Done', tone: 'active' },
1525
+ failed: { text: 'Failed', tone: 'revoked' },
1526
+ }[action.status] ?? { text: action.status, tone: 'revoked' };
1527
+ const buttons = action.status === 'pending'
1528
+ ? html `<div class="action-buttons"><form class="inline" method="post" action="${root}/assistant/approve"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="action" value="${action.id}"><button class="primary" type="submit">Approve</button></form><form class="inline" method="post" action="${root}/assistant/dismiss"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="action" value="${action.id}"><button type="submit">Dismiss</button></form></div>`
1529
+ : html ``;
1530
+ return html `<li class="action-card" id="action-${action.id}"><div class="action-head"><span class="title">${toolTitles.get(action.operation) ?? action.operation}</span><span class="pill ${state.tone}">${state.text}</span></div>${summary}<dl class="facts">${fields.map(([name, value]) => html `<dt>${name.replaceAll('_', ' ')}</dt><dd>${fieldValue(name, value, names)}</dd>`)}</dl>${action.status === 'failed' && action.outcome ? html `<p class="fine">Refused: <code>${action.outcome}</code></p>` : html ``}${buttons}</li>`;
1531
+ }
1532
+ function readAction(id, value) {
1533
+ if (!isRecord(value) || typeof value.operation !== 'string')
1534
+ return null;
1535
+ return {
1536
+ id,
1537
+ operation: value.operation,
1538
+ input: isRecord(value.input) ? value.input : {},
1539
+ ...(typeof value.summary === 'string' ? { summary: value.summary } : {}),
1540
+ status: (typeof value.status === 'string' ? value.status : 'failed'),
1541
+ ...(typeof value.outcome === 'string' ? { outcome: value.outcome } : {}),
1542
+ };
1543
+ }
1544
+ async function assistantPage(session, status = 200, notice) {
1545
+ const store = assistantStore();
1546
+ if (!store)
1547
+ return notFound(session);
1548
+ const tenantRef = tenantOf(session);
1549
+ const [settings, conversation] = await Promise.all([
1550
+ store.getSettings(tenantRef),
1551
+ store.getConversation(tenantRef, personOf(session)),
1552
+ ]);
1553
+ const entries = readSlotlockAssistantEntries(conversation?.messages);
1554
+ const actions = conversation?.actions ?? {};
1555
+ const names = new Map();
1556
+ if (Object.keys(actions).length > 0) {
1557
+ for (const resource of await options.resources.list(tenantRef)) {
1558
+ names.set(resource.id, resourceName(resource, resource.id));
1559
+ }
1560
+ }
1561
+ const token = csrfToken(session);
1562
+ const items = [];
1563
+ for (const entry of entries) {
1564
+ if (entry.role === 'user')
1565
+ items.push(html `<li class="msg mine"><p>${entry.text}</p></li>`);
1566
+ else if (entry.role === 'assistant' && entry.text.trim() !== '') {
1567
+ items.push(html `<li class="msg bot"><p>${entry.text}</p></li>`);
1568
+ }
1569
+ else if (entry.role === 'note')
1570
+ items.push(html `<li class="notice">${entry.text}</li>`);
1571
+ else if (entry.role === 'tool') {
1572
+ for (const result of entry.results) {
1573
+ const action = result.action ? readAction(result.action, actions[result.action]) : null;
1574
+ if (action)
1575
+ items.push(actionCard(session, action, names));
1576
+ else {
1577
+ items.push(html `<li class="tool-line">${result.error ? 'Could not run' : 'Read'}: ${toolTitles.get(result.name) ?? result.name}</li>`);
1578
+ }
1579
+ }
1580
+ }
1581
+ }
1582
+ const composer = html `<form class="composer" method="post" action="${root}/assistant"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><label class="visually-hidden" for="message">Message</label><textarea id="message" name="message" rows="3" maxlength="${String(SLOTLOCK_ASSISTANT_MAX_MESSAGE)}" required placeholder="Find a free van tomorrow morning and book it for Ana"></textarea><button class="primary" type="submit">Send</button></form>`;
1583
+ const reset = entries.length > 0
1584
+ ? html `<form class="inline" method="post" action="${root}/assistant/reset"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">New conversation</button></form>`
1585
+ : undefined;
1586
+ const content = html `${noticeOf(notice)}
1587
+ ${pageHead('Assistant', reset)}
1588
+ ${settings
1589
+ ? html `<section class="panel chat">${items.length === 0
1590
+ ? html `<p class="empty">Ask about bookings and free time. Changes wait for your approval.</p>`
1591
+ : html `<ol class="thread">${items}</ol>`}<span id="latest"></span>${composer}</section>`
1592
+ : html `<p class="empty">${isAdmin(session)
1593
+ ? html `No model is connected. <a href="${root}/settings#assistant">Connect one in Settings</a>.`
1594
+ : 'No model is connected. An admin of this organization can connect one in Settings.'}</p>`}`;
1595
+ return respond(status, layout('Assistant', content, session, 'assistant'));
1596
+ }
1597
+ async function assistantSettingsPanel(session) {
1598
+ const store = assistantStore();
1599
+ if (!store)
1600
+ return html ``;
1601
+ const settings = await store.getSettings(tenantOf(session));
1602
+ const sentTo = html `<p class="fine">Members' messages, and the bookings and resources the assistant reads for them, are sent to this provider.</p>`;
1603
+ if (!isAdmin(session)) {
1604
+ return html `<section class="panel" id="assistant"><h2>Assistant</h2>${settings
1605
+ ? html `<dl class="facts"><dt>Provider</dt><dd>${settings.provider === 'anthropic' ? 'Anthropic' : 'OpenAI-compatible'}</dd><dt>Model</dt><dd>${settings.model}</dd></dl>${sentTo}`
1606
+ : html `<p class="empty">No model is connected. An admin can connect one.</p>`}</section>`;
1607
+ }
1608
+ const token = csrfToken(session);
1609
+ const selected = (provider) => (settings?.provider === provider ? html ` selected` : html ``);
1610
+ return html `<section class="panel" id="assistant">
1611
+ <h2>Assistant</h2>
1612
+ <form method="post" action="${root}/settings/assistant">
1613
+ <input type="hidden" name="csrf" value="${token}">
1614
+ <input type="hidden" name="once" value="${formNonce(session)}">
1615
+ <div class="grid-form">
1616
+ <label>Provider<select name="provider"><option value="anthropic"${selected('anthropic')}>Anthropic Messages API</option><option value="openai"${selected('openai')}>OpenAI-compatible</option></select></label>
1617
+ <label>Base URL<input name="base_url" required maxlength="2048" value="${settings?.baseUrl ?? ''}" placeholder="https://api.anthropic.com"></label>
1618
+ <label>Model<input name="model" required maxlength="200" value="${settings?.model ?? ''}" placeholder="your-model"></label>
1619
+ <label>API key<input name="api_key" type="password" autocomplete="off" maxlength="4096" placeholder="${settings?.keyHint ? 'Leave empty to keep it' : 'Optional for a local model'}"></label>
1620
+ </div>
1621
+ <p class="fine">Key: ${settings?.keyHint ? html `set, ending in <code>${settings.keyHint}</code>` : 'none'}. OpenAI-compatible covers OpenAI, OpenRouter, vLLM, Ollama and LM Studio; give the URL that ends in <code>/v1</code>.</p>
1622
+ ${settings?.keyHint ? html `<label class="choice"><input type="checkbox" name="clear_key" value="1"> Remove the key</label>` : html ``}
1623
+ <button class="primary" type="submit">Save</button>
1624
+ </form>
1625
+ ${sentTo}
1626
+ ${settings
1627
+ ? html `<div class="row-actions"><form class="inline" method="post" action="${root}/settings/assistant/test"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">Test connection</button></form><details class="act danger"><summary>Disconnect</summary><div class="pop"><p>Members can no longer chat. The key is deleted.</p><form method="post" action="${root}/settings/assistant/remove"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button class="danger" type="submit">Disconnect</button></form></div></details></div>`
1628
+ : html ``}
1629
+ </section>`;
1630
+ }
1631
+ const adminOnly = (session) => settingsPage(session, 403, 'Only an owner of this organization can change the assistant.');
1632
+ async function saveAssistantSettings(session, form) {
1633
+ const store = assistantStore();
1634
+ if (!store || !assistantOptions)
1635
+ return notFound(session);
1636
+ if (!isAdmin(session))
1637
+ return adminOnly(session);
1638
+ const provider = form.get('provider');
1639
+ const checkedUrl = readSlotlockAssistantBaseUrl(form.get('base_url') ?? '', { privateUrls });
1640
+ const model = (form.get('model') ?? '').trim();
1641
+ const apiKey = (form.get('api_key') ?? '').trim();
1642
+ if (provider !== 'anthropic' && provider !== 'openai') {
1643
+ return settingsPage(session, 400, 'Choose a provider from the list.');
1644
+ }
1645
+ if (!checkedUrl.ok) {
1646
+ return settingsPage(session, 400, checkedUrl.reason === 'private'
1647
+ ? PRIVATE_URL_REFUSAL
1648
+ : 'Give an https base URL (http only for localhost or 127.0.0.1), with no user name, query or #fragment.');
1649
+ }
1650
+ const baseUrl = checkedUrl.url;
1651
+ if (!(await checkSlotlockAssistantHost(baseUrl, { privateUrls, resolve: resolveHost }))) {
1652
+ return settingsPage(session, 400, PRIVATE_URL_REFUSAL);
1653
+ }
1654
+ if (!SLOTLOCK_ASSISTANT_MODEL_NAME.test(model)) {
1655
+ return settingsPage(session, 400, 'Give the model name, 1 to 200 characters without spaces.');
1656
+ }
1657
+ if (apiKey !== '' && !SLOTLOCK_ASSISTANT_API_KEY.test(apiKey)) {
1658
+ return settingsPage(session, 400, 'An API key is up to 4,096 characters without spaces.');
1659
+ }
1660
+ const tenantRef = tenantOf(session);
1661
+ const key = apiKey !== ''
1662
+ ? { sealed: await sealSlotlockAssistantKey(assistantOptions.secret, tenantRef, apiKey), hint: apiKey.slice(-4) }
1663
+ : form.get('clear_key') === '1'
1664
+ ? null
1665
+ : 'keep';
1666
+ // The stored key is only kept for the provider and URL it was entered for: the store decides
1667
+ // that in the write itself, so an owner cannot send it elsewhere, however saves race.
1668
+ const { saved } = await store.putSettings(tenantRef, { provider, baseUrl, model, key }, personOf(session));
1669
+ if (!saved) {
1670
+ return settingsPage(session, 400, 'Enter the API key again: the provider or the base URL changed, and the stored key is only sent where it was entered for. Or remove the key.');
1671
+ }
1672
+ return redirect(303, `${root}/settings#assistant`);
1673
+ }
1674
+ async function removeAssistantSettings(session) {
1675
+ const store = assistantStore();
1676
+ if (!store)
1677
+ return notFound(session);
1678
+ if (!isAdmin(session))
1679
+ return adminOnly(session);
1680
+ await store.deleteSettings(tenantOf(session), personOf(session));
1681
+ return redirect(303, `${root}/settings#assistant`);
1682
+ }
1683
+ const tooManyMessages = (session, page) => page(session, 429, 'You have sent the assistant a lot in the last minute. Wait a moment and try again.');
1684
+ async function testAssistant(session) {
1685
+ if (!assistantStore())
1686
+ return notFound(session);
1687
+ if (!isAdmin(session))
1688
+ return adminOnly(session);
1689
+ if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
1690
+ return tooManyMessages(session, settingsPage);
1691
+ }
1692
+ const found = await assistantModel(session);
1693
+ if (found === null)
1694
+ return settingsPage(session, 409, 'Connect a model first.');
1695
+ if ('problem' in found)
1696
+ return settingsPage(session, 409, found.problem);
1697
+ try {
1698
+ const ok = await testSlotlockAssistantModel({
1699
+ model: found.model,
1700
+ fetch: assistantFetch,
1701
+ timeoutMs: assistantTimeout,
1702
+ });
1703
+ return settingsPage(session, ok ? 200 : 502, ok
1704
+ ? `Connected: ${found.model.model} answered and called a tool correctly.`
1705
+ : `${found.model.model} answered, but did not call the tool it was given. Choose a model that supports tool calls.`);
1706
+ }
1707
+ catch (error) {
1708
+ report(error instanceof Error ? error.message : 'assistant connection test failed');
1709
+ return settingsPage(session, 502, slotlockAssistantProviderMessage(error));
1710
+ }
1711
+ }
1712
+ async function sendToAssistant(session, form) {
1713
+ const store = assistantStore();
1714
+ if (!store || !assistantOptions)
1715
+ return notFound(session);
1716
+ const text = (form.get('message') ?? '').trim();
1717
+ if (text === '' || text.length > SLOTLOCK_ASSISTANT_MAX_MESSAGE) {
1718
+ return assistantPage(session, 400, `Write a message of 1 to ${SLOTLOCK_ASSISTANT_MAX_MESSAGE.toLocaleString('en')} characters.`);
1719
+ }
1720
+ if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
1721
+ return tooManyMessages(session, assistantPage);
1722
+ }
1723
+ const found = await assistantModel(session);
1724
+ if (found === null)
1725
+ return assistantPage(session, 409, 'No model is connected.');
1726
+ if ('problem' in found)
1727
+ return assistantPage(session, 409, found.problem);
1728
+ const tenantRef = tenantOf(session);
1729
+ const member = personOf(session);
1730
+ const stored = await store.getConversation(tenantRef, member);
1731
+ const entries = boundSlotlockAssistantEntries([
1732
+ ...readSlotlockAssistantEntries(stored?.messages),
1733
+ { role: 'user', text },
1734
+ ]);
1735
+ const proposed = {};
1736
+ const pendingBefore = pendingSlotlockAssistantActions(stored?.actions ?? {});
1737
+ let reads = 0;
1738
+ let readCost = 0;
1739
+ const current = session.orgs?.find((org) => org.id === session.org);
1740
+ const turn = await runSlotlockAssistantTurn({
1741
+ model: found.model,
1742
+ system: slotlockAssistantSystemPrompt({
1743
+ brand: brandName,
1744
+ workspace: current?.login ?? session.login,
1745
+ login: session.login,
1746
+ now: new Date(now()),
1747
+ }),
1748
+ entries,
1749
+ fetch: assistantFetch,
1750
+ timeoutMs: assistantTimeout,
1751
+ turnTimeoutMs: assistantTurnTimeout,
1752
+ ...(assistantOptions.maxSteps !== undefined ? { maxSteps: assistantOptions.maxSteps } : {}),
1753
+ read: async (operation, input) => {
1754
+ // A turn's reads share one budget of resource-days, so 8 steps of wide reads cannot each
1755
+ // scan a hundred resources over years.
1756
+ reads += 1;
1757
+ readCost += readCostOf(input);
1758
+ if (reads > MAX_READS_PER_TURN || readCost > MAX_READ_COST_PER_TURN) {
1759
+ return { ok: false, code: 'turn_read_budget_exceeded' };
1760
+ }
1761
+ const outcome = await dispatch(session, operation, input, 'run');
1762
+ return outcome.ok
1763
+ ? outcome
1764
+ : { ok: false, code: outcome.code, ...(outcome.details ? { details: outcome.details } : {}) };
1765
+ },
1766
+ propose: async (operation, input) => {
1767
+ if (pendingBefore + Object.keys(proposed).length >= SLOTLOCK_ASSISTANT_MAX_PENDING) {
1768
+ return { ok: false, code: 'too_many_pending_changes' };
1769
+ }
1770
+ const id = randomBytes(12).toString('base64url');
1771
+ const prepared = isRecord(input) && keyed.has(operation)
1772
+ ? { ...input, idempotency_key: `dashboard-assistant.${id}` }
1773
+ : input;
1774
+ let checked;
1775
+ // Every check an agent's call passes (arguments, scopes, authorize, rate limit), then halted
1776
+ // here: it runs only when the member approves it.
1777
+ const outcome = await dispatch(session, operation, prepared, 'propose', async ({ input: parsed }) => {
1778
+ checked = parsed;
1779
+ return { status: 409, code: 'approval_required' };
1780
+ });
1781
+ if (checked === undefined || outcome.ok || outcome.code !== 'approval_required') {
1782
+ return { ok: false, code: outcome.ok ? 'internal_error' : outcome.code };
1783
+ }
1784
+ // The card shows every argument: one too large to show is not proposed.
1785
+ if (Buffer.byteLength(JSON.stringify(checked), 'utf8') > SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES) {
1786
+ return { ok: false, code: 'change_too_large_to_show' };
1787
+ }
1788
+ proposed[id] = {
1789
+ operation,
1790
+ input: checked,
1791
+ status: 'pending',
1792
+ summary: await summarize(session, operation, checked),
1793
+ };
1794
+ return { ok: true, actionId: id };
1795
+ },
1796
+ });
1797
+ if (turn.failure !== undefined) {
1798
+ report(turn.failure instanceof Error ? turn.failure.message : 'assistant provider failed');
1799
+ }
1800
+ const messages = boundSlotlockAssistantEntries([...entries, ...turn.added]);
1801
+ // Only this turn's proposals are added; the rest are kept as the row holds them now, so one
1802
+ // approved (or a conversation reset) while the turn ran is never put back as it stood.
1803
+ const kept = pruneSlotlockAssistantActions(messages, { ...(stored?.actions ?? {}), ...proposed });
1804
+ await store.putConversation(tenantRef, member, {
1805
+ messages,
1806
+ added: Object.fromEntries(Object.entries(proposed).filter(([id]) => id in kept)),
1807
+ keep: Object.keys(kept),
1808
+ }, conversationTtl);
1809
+ return redirect(303, `${root}/assistant#latest`);
1810
+ }
1811
+ async function settleAction(session, form, decision) {
1812
+ const store = assistantStore();
1813
+ if (!store || !assistantOptions)
1814
+ return notFound(session);
1815
+ const id = form.get('action') ?? '';
1816
+ const tenantRef = tenantOf(session);
1817
+ const member = personOf(session);
1818
+ const claimed = ACTION_ID.test(id)
1819
+ ? await store.settleAction(tenantRef, member, id, 'pending', decision === 'approve' ? { status: 'approved' } : { status: 'dismissed', input: {} })
1820
+ : null;
1821
+ const action = claimed ? readAction(id, claimed) : null;
1822
+ if (!action)
1823
+ return assistantPage(session, 409, 'That change was already approved or dismissed.');
1824
+ const title = toolTitles.get(action.operation) ?? action.operation;
1825
+ let event;
1826
+ if (decision === 'dismiss') {
1827
+ event = `The member dismissed the proposed change ${id} (${title}); it did not run.`;
1828
+ }
1829
+ else if (SLOTLOCK_ASSISTANT_TOOL_USE[action.operation] !== 'propose') {
1830
+ await store.settleAction(tenantRef, member, id, 'approved', { status: 'failed', outcome: 'tool_not_available', input: {} });
1831
+ event = `The proposed change ${id} (${title}) could not run: tool_not_available.`;
1832
+ }
1833
+ else {
1834
+ const outcome = await dispatch(session, action.operation, action.input, 'run');
1835
+ // Settled, the card keeps what it said, not the arguments: the actions stay small.
1836
+ await store.settleAction(tenantRef, member, id, 'approved', outcome.ok ? { status: 'done', input: {} } : { status: 'failed', outcome: outcome.code, input: {} });
1837
+ const result = JSON.stringify(outcome.ok ? outcome.data : { error: { code: outcome.code } });
1838
+ event = `The member approved the proposed change ${id} (${title}). ${outcome.ok ? 'It ran' : 'It was refused'}: ${result.length > 4_000 ? `${result.slice(0, 4_000)} …[truncated]` : result}`;
1839
+ }
1840
+ const stored = await store.getConversation(tenantRef, member);
1841
+ if (stored) {
1842
+ const entries = [
1843
+ ...readSlotlockAssistantEntries(stored.messages),
1844
+ { role: 'event', text: event },
1845
+ ];
1846
+ const messages = boundSlotlockAssistantEntries(entries);
1847
+ await store.putConversation(tenantRef, member, { messages, added: {}, keep: Object.keys(pruneSlotlockAssistantActions(messages, stored.actions)) }, conversationTtl);
1848
+ }
1849
+ return redirect(303, `${root}/assistant#action-${id}`);
1850
+ }
1851
+ async function resetAssistant(session) {
1852
+ const store = assistantStore();
1853
+ if (!store)
1854
+ return notFound(session);
1855
+ await store.deleteConversation(tenantOf(session), personOf(session));
1856
+ return redirect(303, `${root}/assistant`);
1857
+ }
1247
1858
  /** The page a form was sent from, to show what went wrong with it. */
1248
1859
  function pageFor(route) {
1249
1860
  if (route.startsWith('/keys'))
@@ -1254,6 +1865,10 @@ ${entries.length === 0
1254
1865
  return waitlistPage;
1255
1866
  if (route.startsWith('/resources') || route === '/hours')
1256
1867
  return resourcesPage;
1868
+ if (route.startsWith('/assistant') && assistantStore())
1869
+ return assistantPage;
1870
+ if (route.startsWith('/settings'))
1871
+ return settingsPage;
1257
1872
  return overviewPage;
1258
1873
  }
1259
1874
  function hoursPage(session, state) {
@@ -1523,8 +2138,9 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
1523
2138
  continue;
1524
2139
  }
1525
2140
  const id = String(organization.id);
1526
- if (served?.has(id))
1527
- found.set(id, { id, login: organization.login });
2141
+ if (served?.has(id)) {
2142
+ found.set(id, { id, login: organization.login, role: membership.role === 'admin' ? 'admin' : 'member' });
2143
+ }
1528
2144
  }
1529
2145
  return [...found.values()]
1530
2146
  .sort((a, b) => a.login.localeCompare(b.login))
@@ -1615,12 +2231,12 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
1615
2231
  ]);
1616
2232
  }
1617
2233
  /** The form, `'too_large'` past MAX_FORM_BYTES (read no further), or null for another type. */
1618
- async function readForm(request) {
2234
+ async function readForm(request, maxBytes = MAX_FORM_BYTES) {
1619
2235
  const type = request.headers.get('content-type')?.split(';')[0]?.trim();
1620
2236
  if (type !== 'application/x-www-form-urlencoded')
1621
2237
  return null;
1622
2238
  const declared = Number(request.headers.get('content-length'));
1623
- if (Number.isFinite(declared) && declared > MAX_FORM_BYTES)
2239
+ if (Number.isFinite(declared) && declared > maxBytes)
1624
2240
  return 'too_large';
1625
2241
  if (!request.body)
1626
2242
  return new URLSearchParams();
@@ -1632,7 +2248,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
1632
2248
  if (done)
1633
2249
  break;
1634
2250
  size += value.byteLength;
1635
- if (size > MAX_FORM_BYTES) {
2251
+ if (size > maxBytes) {
1636
2252
  await reader.cancel().catch(() => undefined);
1637
2253
  return 'too_large';
1638
2254
  }
@@ -1653,6 +2269,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
1653
2269
  name: form.get('name') ?? '',
1654
2270
  scopes,
1655
2271
  createdBy: personOf(session),
2272
+ origin: 'dashboard',
1656
2273
  ...(typeof days === 'number' ? { expiresAt: new Date(now() + days * DAY_MS) } : {}),
1657
2274
  });
1658
2275
  return shownOncePage(session, created.key, created.apiKey, false);
@@ -1668,17 +2285,23 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
1668
2285
  throw error;
1669
2286
  }
1670
2287
  }
2288
+ /** A key change in the session's tenant, recorded as the person's, from the dashboard. */
2289
+ const asPerson = (session) => ({
2290
+ tenantRef: tenantOf(session),
2291
+ actor: personOf(session),
2292
+ origin: 'dashboard',
2293
+ });
1671
2294
  async function changeKey(session, form, change) {
1672
2295
  const id = form.get('id') ?? '';
1673
2296
  if (!UUID_PATTERN.test(id))
1674
2297
  return keysPage(session, 404, 'That key is not one of yours.');
1675
2298
  if (change === 'rotate') {
1676
- const rotated = await options.keys.rotate({ tenantRef: tenantOf(session), id });
2299
+ const rotated = await options.keys.rotate({ ...asPerson(session), id });
1677
2300
  if (!rotated)
1678
2301
  return keysPage(session, 404, 'That key is not an active key of yours.');
1679
2302
  return shownOncePage(session, rotated.key, rotated.apiKey, true);
1680
2303
  }
1681
- const revoked = await options.keys.revoke({ tenantRef: tenantOf(session), id });
2304
+ const revoked = await options.keys.revoke({ ...asPerson(session), id });
1682
2305
  if (!revoked)
1683
2306
  return keysPage(session, 404, 'That key is not one of yours.');
1684
2307
  return redirect(303, `${root}/keys`);
@@ -1715,7 +2338,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
1715
2338
  const session = await readSession(request);
1716
2339
  if (!session)
1717
2340
  return redirect(303, root);
1718
- const form = await readForm(request);
2341
+ const form = await readForm(request, route === '/assistant' ? MAX_ASSISTANT_FORM_BYTES : MAX_FORM_BYTES);
1719
2342
  if (form === 'too_large') {
1720
2343
  return respond(413, layout('Refused', html `<p class="notice">That form is too large.</p>`, session));
1721
2344
  }
@@ -1784,6 +2407,20 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
1784
2407
  return changeWebhook(session, form, 'enable');
1785
2408
  case '/waitlist/leave':
1786
2409
  return leaveWaitlist(session, form);
2410
+ case '/assistant':
2411
+ return sendToAssistant(session, form);
2412
+ case '/assistant/approve':
2413
+ return settleAction(session, form, 'approve');
2414
+ case '/assistant/dismiss':
2415
+ return settleAction(session, form, 'dismiss');
2416
+ case '/assistant/reset':
2417
+ return resetAssistant(session);
2418
+ case '/settings/assistant':
2419
+ return saveAssistantSettings(session, form);
2420
+ case '/settings/assistant/test':
2421
+ return testAssistant(session);
2422
+ case '/settings/assistant/remove':
2423
+ return removeAssistantSettings(session);
1787
2424
  default:
1788
2425
  return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
1789
2426
  }
@@ -1807,6 +2444,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
1807
2444
  case '/resources':
1808
2445
  case '/webhooks':
1809
2446
  case '/waitlist':
2447
+ case '/assistant':
1810
2448
  case '/settings': {
1811
2449
  const session = await readSession(request);
1812
2450
  if (!session)
@@ -1819,7 +2457,9 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
1819
2457
  return await webhooksPage(session);
1820
2458
  if (route === '/waitlist')
1821
2459
  return await waitlistPage(session);
1822
- return settingsPage(session);
2460
+ if (route === '/assistant')
2461
+ return await assistantPage(session);
2462
+ return await settingsPage(session);
1823
2463
  }
1824
2464
  case '/style.css':
1825
2465
  return asset(DASHBOARD_CSS, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
@@ -1908,7 +2548,9 @@ a:hover { text-decoration: underline; }
1908
2548
  code, pre, input[readonly] { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; }
1909
2549
  .icon { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
1910
2550
  .icon .fill { fill: currentColor; stroke: none; }
1911
- .mark { width: 18px; height: 18px; flex: none; fill: none; stroke: var(--accent); stroke-width: 7; stroke-linecap: round; stroke-linejoin: round; }
2551
+ .mark { width: 20px; height: 18px; flex: none; }
2552
+ .mark-ink { fill: currentColor; }
2553
+ .mark-slot { fill: #FF5B2E; }
1912
2554
  .brand { display: inline-flex; align-items: center; gap: 8px; color: var(--ink); font-weight: 650; font-size: 15px; letter-spacing: -0.01em; }
1913
2555
  .brand:hover { text-decoration: none; }
1914
2556
  .app { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; background: linear-gradient(to right, var(--side) 239px, var(--line) 239px, var(--line) 240px, var(--paper) 240px); }
@@ -2039,6 +2681,23 @@ td[data-label] > .pill { justify-self: start; }
2039
2681
  td.wrap, .wrap { white-space: normal; overflow-wrap: anywhere; }
2040
2682
  form.inline { display: inline; margin: 0; }
2041
2683
  form > label { margin-bottom: 4px; }
2684
+ .chat { display: grid; gap: 16px; }
2685
+ .thread { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
2686
+ .msg { max-width: 78%; padding: 10px 14px; border-radius: 12px; background: var(--wash); border: 1px solid var(--line); }
2687
+ .msg p { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
2688
+ .msg.mine { justify-self: end; background: var(--accent-wash); border-color: transparent; }
2689
+ .msg.bot { justify-self: start; }
2690
+ .tool-line { font-size: 12.5px; color: var(--ink-3); padding-left: 4px; }
2691
+ .action-card { justify-self: start; width: min(100%, 520px); padding: 14px 16px; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); box-shadow: var(--shadow); display: grid; gap: 10px; }
2692
+ .action-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
2693
+ .action-card .facts { font-size: 13px; }
2694
+ .action-card .facts dd { overflow-wrap: anywhere; }
2695
+ .action-card pre.wrap { white-space: pre-wrap; margin: 0; padding: 8px; font-size: 12px; }
2696
+ .action-summary { margin: 0; color: var(--ink); font-weight: 500; overflow-wrap: anywhere; }
2697
+ .action-buttons, .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
2698
+ .composer { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: end; margin: 0; }
2699
+ textarea { font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--paper); color: var(--ink); resize: vertical; min-height: 64px; min-width: 0; }
2700
+ textarea:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px var(--accent-wash); }
2042
2701
  .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
2043
2702
  @media (max-width: 1000px) {
2044
2703
  .split { grid-template-columns: minmax(0, 1fr); }
@@ -2066,6 +2725,8 @@ form > label { margin-bottom: 4px; }
2066
2725
  .actions { text-align: left; }
2067
2726
  .act { margin: 4px 12px 0 0; }
2068
2727
  .pop { left: 0; right: auto; }
2728
+ .msg { max-width: 92%; }
2729
+ .composer { grid-template-columns: minmax(0, 1fr); }
2069
2730
  }
2070
2731
  `;
2071
2732
  const ASSET_VERSIONS = Object.freeze({