@aistrike-dev/ui-shell-dev 1.1.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,217 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ //#region src/mock/conversations.ts
3
+ /** `cnv_` and a UUID's 32 hex digits (agent conversations API 3, ids). */
4
+ var newConversationId = () => `cnv_${randomUUID().replace(/-/g, "")}`;
5
+ /** The mock's conversations, by id, in memory: at most `limit` (default 100). */
6
+ function createConversations(limit = 100, newId = newConversationId) {
7
+ const byId = /* @__PURE__ */ new Map();
8
+ return {
9
+ get: (id) => byId.get(id),
10
+ create(scope) {
11
+ const now = (/* @__PURE__ */ new Date()).toISOString();
12
+ const conversation = {
13
+ id: newId(),
14
+ scope,
15
+ createdAt: now,
16
+ updatedAt: now,
17
+ busy: false,
18
+ user: null,
19
+ context: null,
20
+ pending: null
21
+ };
22
+ byId.set(conversation.id, conversation);
23
+ while (byId.size > limit) {
24
+ const oldest = byId.keys().next().value;
25
+ if (oldest === void 0) break;
26
+ byId.delete(oldest);
27
+ }
28
+ return conversation;
29
+ }
30
+ };
31
+ }
32
+ //#endregion
33
+ //#region ../../packages/sdk/dist/assistant/refs.js
34
+ /** The name the model sees: `<domain>__<name>`. */
35
+ function qualifyTool(domain, name) {
36
+ return `${domain}__${name}`;
37
+ }
38
+ /** The domain and tool a qualified name points at, or `null`. */
39
+ function splitToolName(qualified) {
40
+ const at = qualified.indexOf("__");
41
+ if (at <= 0 || at + 2 >= qualified.length) return null;
42
+ return {
43
+ domain: qualified.slice(0, at),
44
+ name: qualified.slice(at + 2)
45
+ };
46
+ }
47
+ //#endregion
48
+ //#region ../../packages/sdk/dist/context/markup.js
49
+ var isEscapable = (char) => char === "\\" || char === "[" || char === "]";
50
+ var WHITESPACE = /\s/;
51
+ /**
52
+ * Reads a label up to its closing `]` from `start` (just after `[`). Returns the label and the index after the `]`, or
53
+ * `null` for an unescaped `[`, a bad escape or no closing bracket.
54
+ */
55
+ function readLabel(text, start) {
56
+ let label = "";
57
+ for (let i = start; i < text.length; i += 1) {
58
+ const char = text[i];
59
+ if (char === "]") return {
60
+ label,
61
+ next: i + 1
62
+ };
63
+ if (char === "[") return null;
64
+ if (char === "\\") {
65
+ if (!isEscapable(text[i + 1] ?? "")) return null;
66
+ i += 1;
67
+ label += text[i];
68
+ } else label += char;
69
+ }
70
+ return null;
71
+ }
72
+ /** Reads `(ref)` from `start`: a ref with no whitespace. Returns it and the index after the `)`, or `null`. */
73
+ function readRef(text, start) {
74
+ if (text[start] !== "(") return null;
75
+ const close = text.indexOf(")", start + 1);
76
+ if (close < 0) return null;
77
+ const ref = text.slice(start + 1, close);
78
+ return ref === "" || WHITESPACE.test(ref) ? null : {
79
+ ref,
80
+ next: close + 1
81
+ };
82
+ }
83
+ /** Reads `[label](ref)` from `start`, which is at the `[`. */
84
+ function readLink(text, start) {
85
+ const label = readLabel(text, start + 1);
86
+ if (label === null || label.label === "") return null;
87
+ const ref = readRef(text, label.next);
88
+ return ref === null ? null : {
89
+ segment: {
90
+ kind: "ref",
91
+ label: label.label,
92
+ ref: ref.ref
93
+ },
94
+ next: ref.next
95
+ };
96
+ }
97
+ /** Reads the token at `start`: an escaped character, a link or a plain character; `null` where it is malformed. */
98
+ function readToken(text, start) {
99
+ const char = text[start];
100
+ if (char === "[") return readLink(text, start);
101
+ if (char === "]") return null;
102
+ if (char !== "\\") return {
103
+ segment: {
104
+ kind: "text",
105
+ text: char
106
+ },
107
+ next: start + 1
108
+ };
109
+ const escaped = text[start + 1] ?? "";
110
+ return isEscapable(escaped) ? {
111
+ segment: {
112
+ kind: "text",
113
+ text: escaped
114
+ },
115
+ next: start + 2
116
+ } : null;
117
+ }
118
+ /**
119
+ * Splits text with `[label](ref)` markup into text and ref segments. `\` escapes the next `\`, `[` or `]`; any other
120
+ * use of a bracket or backslash is malformed, and so is an empty label or ref. `null` for malformed markup.
121
+ */
122
+ function tokenizeMarkup(text) {
123
+ const segments = [];
124
+ let i = 0;
125
+ while (i < text.length) {
126
+ const token = readToken(text, i);
127
+ if (token === null) return null;
128
+ const last = segments.at(-1);
129
+ if (token.segment.kind === "text" && last?.kind === "text") last.text += token.segment.text;
130
+ else segments.push(token.segment);
131
+ i = token.next;
132
+ }
133
+ return segments;
134
+ }
135
+ /** The text with each link replaced by its label and escapes removed. Malformed text is returned as is. */
136
+ function stripMarkup(text) {
137
+ const segments = tokenizeMarkup(text);
138
+ if (segments === null) return text;
139
+ return segments.map((segment) => segment.kind === "text" ? segment.text : segment.label).join("");
140
+ }
141
+ //#endregion
142
+ //#region src/chat/modes.ts
143
+ /**
144
+ * The mode a round acts in. A chosen mode is final. In Auto the model's report stands, except that more than one
145
+ * change always gets a plan, and a change the model called a question gets one too.
146
+ */
147
+ function actingFor(chosen, reported, changes) {
148
+ if (chosen !== "auto") return chosen;
149
+ if (changes === 0) return reported ?? "ask";
150
+ return reported === "agent" && changes === 1 ? "agent" : "plan";
151
+ }
152
+ //#endregion
153
+ //#region src/mock/seed.ts
154
+ var idOf = (input) => typeof input.id === "string" ? input.id : "";
155
+ var SEED_TOOLS = [
156
+ {
157
+ domainId: "lab",
158
+ name: "get_alert",
159
+ description: "Look up one alert by its id",
160
+ effect: "read",
161
+ run(input, refs) {
162
+ const id = idOf(input);
163
+ const ref = refs.find((candidate) => candidate.domain === "lab" && candidate.type === "alert" && candidate.id === id);
164
+ return {
165
+ id,
166
+ title: ref?.label ?? `Alert ${id}`,
167
+ summary: ref?.summary ?? "No details in the mock"
168
+ };
169
+ }
170
+ },
171
+ {
172
+ domainId: "lab",
173
+ name: "search_alerts",
174
+ description: "Find alerts whose title or entity matches a query",
175
+ effect: "read",
176
+ run(input) {
177
+ return {
178
+ query: typeof input.query === "string" ? input.query : "",
179
+ entities: []
180
+ };
181
+ }
182
+ },
183
+ {
184
+ domainId: "lab",
185
+ name: "suppress_alert",
186
+ description: "Suppress one alert so it stops raising notifications",
187
+ effect: "write",
188
+ run(input) {
189
+ return {
190
+ id: idOf(input),
191
+ suppressed: true
192
+ };
193
+ }
194
+ }
195
+ ];
196
+ var SEED_PROMPTS = [{
197
+ domainId: "lab",
198
+ id: "find-credential",
199
+ text: "Find alerts about credential dumping",
200
+ expects: [{
201
+ tool: "search_alerts",
202
+ input: { query: "credential dumping" }
203
+ }]
204
+ }];
205
+ var SEED_SKILLS = [{
206
+ id: "triage-alert",
207
+ title: "Triage an alert",
208
+ description: "Weigh an alert's evidence and say what to do with it"
209
+ }, {
210
+ id: "summarise-investigation",
211
+ title: "Summarise an investigation",
212
+ description: "What is known, what is open, and what is next"
213
+ }];
214
+ //#endregion
1
215
  //#region src/mock/responder.ts
2
216
  var counter = 0;
3
217
  var nextId = () => {
@@ -6,40 +220,55 @@ var nextId = () => {
6
220
  };
7
221
  var words = (text) => text.split(/(\s+)/).filter((word) => word !== "");
8
222
  /** Text as a handful of deltas, the way a model streams it. */
9
- function say(text) {
223
+ function speak(text) {
10
224
  const parts = words(text);
11
225
  const out = [];
12
226
  for (let i = 0; i < parts.length; i += 6) out.push({
13
227
  type: "text",
14
228
  delta: parts.slice(i, i + 6).join("")
15
229
  });
16
- return [...out, {
17
- type: "done",
18
- stop: "end"
19
- }];
230
+ return out;
231
+ }
232
+ var say = (text) => [...speak(text), {
233
+ type: "done",
234
+ stop: "end"
235
+ }];
236
+ var humanize = (name) => {
237
+ const spaced = (splitToolName(name)?.name ?? name).replace(/_/g, " ");
238
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
239
+ };
240
+ /** Its seeded tools, then the page's, each found by its domain and name. */
241
+ function toolsOf(context) {
242
+ const seeded = SEED_TOOLS.map((seed) => {
243
+ return {
244
+ kind: "seed",
245
+ domainId: seed.domainId,
246
+ name: seed.name,
247
+ effect: seed.effect,
248
+ seed
249
+ };
250
+ });
251
+ const page = (context?.pageTools ?? []).filter((tool) => typeof tool.name === "string" && typeof tool.domainId === "string").map((tool) => {
252
+ const effect = tool.effect === "read" ? "read" : "write";
253
+ return {
254
+ kind: "page",
255
+ domainId: tool.domainId,
256
+ name: tool.name,
257
+ effect,
258
+ schema: tool.input
259
+ };
260
+ });
261
+ return [...seeded, ...page];
20
262
  }
263
+ var find = (tools, domainId, name) => tools.find((tool) => tool.domainId === domainId && tool.name === name);
264
+ var qualified = (tool) => qualifyTool(tool.domainId, tool.name);
21
265
  var call = (tool, input) => {
22
266
  return {
23
- type: "tool_use",
24
267
  id: nextId(),
25
268
  tool,
26
269
  input
27
270
  };
28
271
  };
29
- var humanize = (qualified) => {
30
- const spaced = (qualified.split("__")[1] ?? qualified).replace(/_/g, " ");
31
- return spaced.charAt(0).toUpperCase() + spaced.slice(1);
32
- };
33
- function lastUser(messages) {
34
- for (let i = messages.length - 1; i >= 0; i -= 1) {
35
- const message = messages[i];
36
- if (message.role === "user") return {
37
- message,
38
- index: i
39
- };
40
- }
41
- return null;
42
- }
43
272
  var VERBS = /* @__PURE__ */ new Set([
44
273
  "select",
45
274
  "show",
@@ -49,142 +278,331 @@ var VERBS = /* @__PURE__ */ new Set([
49
278
  ]);
50
279
  /** Entities on screen that the request describes by a word in their label or summary; all of them if none match. */
51
280
  function described(text, screen) {
281
+ if (screen === null) return [];
52
282
  const terms = text.toLowerCase().split(/\W+/).filter((word) => word.length > 3 && !VERBS.has(word));
53
283
  const hits = screen.entities.filter((ref) => terms.some((term) => `${ref.label} ${ref.summary ?? ""}`.toLowerCase().includes(term)));
54
284
  return hits.length > 0 ? hits : screen.entities;
55
285
  }
56
- function describeScreen(user, tools) {
57
- const can = tools.length === 0 ? "nothing yet" : tools.map((tool) => humanize(tool.name)).join(", ");
58
- if (user.screen === void 0) return `Nothing on screen is shared with me right now. Reference something with @, or ask me to use one of: ${can}.`;
59
- const { title, summary, entities } = user.screen;
60
- const lead = `You're looking at ${title}${summary === void 0 ? "" : ` (${summary})`}, with ${entities.length} item(s) in view.`;
61
- const top = entities.slice(0, 3).map((ref) => ref.label).join("; ");
62
- return `${lead}${top === "" ? "" : ` First up: ${top}.`}\n\nHere I can: ${can}.`;
63
- }
64
- var toolRound = (calls, acting, reason) => {
65
- return {
66
- events: [...calls, {
67
- type: "done",
68
- stop: "tool_use"
69
- }],
70
- acting,
71
- reason
72
- };
73
- };
74
286
  /** Entities on screen the text names by a word in their label or summary; none if it names none. */
75
287
  function named(text, screen) {
76
- if (screen === void 0) return [];
77
288
  const all = described(text, screen);
78
- return all.length === screen.entities.length && all.length > 1 ? [] : all;
289
+ return screen !== null && all.length === screen.entities.length && all.length > 1 ? [] : all;
290
+ }
291
+ /** A list of calls, as the mode would act on them: one change done, more planned, reads looked up. */
292
+ function acting(calls, reads) {
293
+ const changes = calls.filter((c) => c.tool.effect !== "read").length;
294
+ if (changes === 0) return {
295
+ calls,
296
+ acting: "ask",
297
+ reason: reads
298
+ };
299
+ return changes === 1 ? {
300
+ calls,
301
+ acting: "agent",
302
+ reason: "one change, nothing to assume"
303
+ } : {
304
+ calls,
305
+ acting: "plan",
306
+ reason: "more than one change, so a plan first"
307
+ };
79
308
  }
80
309
  /** "Suppress a4", "suppress the cluster": the write tool once for each alert meant. */
81
- function suppressing(user, names) {
82
- const tool = names.find((name) => name.includes("__suppress_"));
310
+ function suppressing(user, context, tools) {
311
+ const tool = tools.find((candidate) => candidate.name.startsWith("suppress_"));
83
312
  if (!/suppress/i.test(user.text) || tool === void 0) return null;
84
- const id = /\b([a-z]\d+)\b/i.exec(user.text)?.[1];
85
- const fromText = id === void 0 ? named(user.text, user.screen).map((r) => r.id) : [id];
313
+ const inText = [...user.text.matchAll(/\b([a-z]\d+)\b/gi)].map((match) => match[1]);
314
+ const fromText = inText.length > 0 ? inText : named(user.text, context.screen).map((ref) => ref.id);
86
315
  const ids = user.refs.length > 0 ? user.refs.map((ref) => ref.id) : fromText;
87
- if (ids.length === 0) return null;
88
- return ids.length === 1 ? toolRound([call(tool, { id: ids[0] })], "agent", "one change, nothing to assume") : toolRound(ids.map((one) => call(tool, { id: one })), "plan", "more than one change, so a plan first");
316
+ return ids.length === 0 ? null : acting(ids.map((id) => call(tool, { id })), "nothing to change");
89
317
  }
90
318
  /** "Select the cluster": the page's select tool on the referenced entities, or the ones on screen the text describes. */
91
- function selecting(user, names) {
92
- const tool = names.find((name) => name.includes("__select_"));
319
+ function selecting(user, context, tools) {
320
+ const tool = tools.find((candidate) => candidate.name.startsWith("select_"));
93
321
  if (!/\b(select|show me|highlight)\b/i.test(user.text) || tool === void 0) return null;
94
- const onScreen = user.screen === void 0 ? [] : described(user.text, user.screen);
95
- return toolRound([call(tool, { ids: (user.refs.length > 0 ? user.refs : onScreen).map((ref) => ref.id) })], "agent", "it only changes what you see");
322
+ return {
323
+ calls: [call(tool, { ids: (user.refs.length > 0 ? user.refs : described(user.text, context.screen)).map((ref) => ref.id) })],
324
+ acting: "agent",
325
+ reason: "it only changes what you see"
326
+ };
96
327
  }
97
328
  /** A reference: each one looked up with its domain's `get_<type>` tool. */
98
- function lookingUp(user, names) {
329
+ function lookingUp(user, tools) {
99
330
  const lookups = user.refs.flatMap((ref) => {
100
- const tool = `${ref.domain}__get_${ref.type.replace(/-/g, "_")}`;
101
- return names.includes(tool) ? [call(tool, { id: ref.id })] : [];
331
+ const tool = find(tools, ref.domain, `get_${ref.type.replace(/-/g, "_")}`);
332
+ return tool === void 0 ? [] : [call(tool, { id: ref.id })];
102
333
  });
103
- return lookups.length > 0 ? toolRound(lookups, "ask", "looking it up changes nothing") : null;
334
+ return lookups.length > 0 ? {
335
+ calls: lookups,
336
+ acting: "ask",
337
+ reason: "looking it up changes nothing"
338
+ } : null;
104
339
  }
105
340
  /** The inputs for one expected call: as declared, else built from the refs or the screen (suggestions spec 7). */
106
- function inputsFor(expected, spec, user) {
341
+ function inputsFor(expected, tool, user, screen) {
107
342
  if (expected.input !== void 0) return [expected.input];
108
- const properties = spec.input?.properties ?? {};
109
- if ("ids" in properties) {
110
- const onScreen = user.screen === void 0 ? [] : described(user.text, user.screen);
111
- return [{ ids: (user.refs.length > 0 ? user.refs : onScreen).map((ref) => ref.id) }];
112
- }
113
- if ("id" in properties) return user.refs.map((ref) => {
343
+ const properties = tool.kind === "page" ? tool.schema?.properties ?? {} : { id: {} };
344
+ if ("ids" in properties) return [{ ids: (user.refs.length > 0 ? user.refs : described(user.text, screen)).map((ref) => ref.id) }];
345
+ if ("id" in properties) return user.refs.length > 0 || tool.kind === "page" ? user.refs.map((ref) => {
114
346
  return { id: ref.id };
115
- });
347
+ }) : [{}];
116
348
  return [{}];
117
349
  }
118
- /** A domain's suggestion: the calls it expects, made as it says, so a demo needs no script (suggestions spec 7). */
119
- function following(user, tools) {
350
+ /** What the suggestion a message was sent from expects: a seeded prompt's, or a page suggestion's from the context. */
351
+ function expectedBy(user, context) {
120
352
  const from = user.suggestion;
121
- if (from?.expects === void 0) return null;
122
- const calls = from.expects.flatMap((expected) => {
123
- const spec = tools.find((tool) => tool.name === `${from.domainId}__${expected.tool}`);
124
- return spec === void 0 ? [] : inputsFor(expected, spec, user).map((input) => {
125
- return {
126
- spec,
127
- input
128
- };
129
- });
353
+ if (from === void 0) return [];
354
+ const source = from.source === "domain" ? SEED_PROMPTS.find((prompt) => prompt.domainId === from.domainId && prompt.id === from.id) : context.suggestions.find((suggestion) => suggestion.domainId === from.domainId && suggestion.id === from.id);
355
+ return Array.isArray(source?.expects) ? source.expects : [];
356
+ }
357
+ /** A suggestion's expected calls, made as it says, so a demo needs no script (suggestions spec 7). */
358
+ function following(user, context, tools) {
359
+ const domainId = user.suggestion?.domainId ?? "";
360
+ const calls = expectedBy(user, context).flatMap((expected) => {
361
+ const tool = find(tools, domainId, expected.tool);
362
+ return tool === void 0 ? [] : inputsFor(expected, tool, user, context.screen).map((input) => call(tool, input));
130
363
  });
131
- if (calls.length === 0) return null;
132
- const changes = calls.filter(({ spec }) => spec.effect !== "read").length;
133
- const events = calls.map(({ spec, input }) => call(spec.name, input));
134
- if (changes === 0) return toolRound(events, "ask", "following the suggestion changes nothing");
135
- return changes === 1 ? toolRound(events, "agent", "one change, nothing to assume") : toolRound(events, "plan", "more than one change, so a plan first");
364
+ return calls.length === 0 ? null : acting(calls, "following the suggestion changes nothing");
136
365
  }
137
- var CHANGE = /\b(suppress|select|highlight|close|assign|delete|disable)\b/i;
138
- var ASK_REFUSAL = "Ask mode changes nothing, so I left it as it is. Switch to Auto, Plan or Agent and send it again.";
139
- function firstRound(user, tools, mode) {
140
- const suggested = following(user, tools);
141
- if (suggested === null && mode === "ask" && CHANGE.test(user.text)) return say(ASK_REFUSAL);
142
- const names = tools.map((tool) => tool.name);
143
- const answer = suggested ?? suppressing(user, names) ?? selecting(user, names) ?? lookingUp(user, names) ?? {
144
- events: say(describeScreen(user, tools)),
145
- acting: "ask",
146
- reason: "a question, so nothing changes"
147
- };
148
- return mode === "auto" ? [{
149
- type: "mode",
150
- acting: answer.acting,
151
- reason: answer.reason
152
- }, ...answer.events] : answer.events;
366
+ function describeScreen(user, context, tools) {
367
+ const asked = stripMarkup(user.text).trim();
368
+ const echo = asked === "" ? "" : `About "${asked}": `;
369
+ const names = [...new Set(tools.map((tool) => humanize(tool.name)))];
370
+ const can = names.length === 0 ? "nothing yet" : names.join(", ");
371
+ const skills = `Skills: ${SEED_SKILLS.map((skill) => skill.title).join(", ")}.`;
372
+ if (context.screen === null) return `${echo}Nothing on screen is shared with me right now. ${`Reference something with @, or ask me to use one of: ${can}.`}\n\n${skills}`;
373
+ const { summary, entities } = context.screen;
374
+ const title = context.title === "" ? "this page" : context.title;
375
+ const count = `${entities.length} ${entities.length === 1 ? "item" : "items"}`;
376
+ const lead = `${echo}You're looking at ${title}${summary === void 0 ? "" : ` (${summary})`}, with ${count} in view.`;
377
+ const top = entities.slice(0, 3).map((ref) => ref.label).join("; ");
378
+ return `${lead}${top === "" ? "" : ` First up: ${top}.`}\n\nHere I can: ${can}.\n\n${skills}`;
153
379
  }
154
- /** A tool result as a sentence. */
380
+ /** A call's outcome as a sentence. */
155
381
  function sentence(tool, ok, result, error) {
156
382
  if (!ok) return `${humanize(tool)} failed: ${error ?? "unknown error"}.`;
157
383
  const value = result ?? {};
158
- if (value.declined === true) return "Understood, I left it alone.";
159
384
  if (tool.includes("__select_")) return `I selected ${typeof value.selected === "number" ? value.selected : 0} on the page.`;
160
385
  if (tool.includes("__suppress_")) return `Done: ${typeof value.id === "string" ? value.id : "it"} is suppressed.`;
161
386
  const facts = Object.entries(value).filter(([, v]) => typeof v === "string" || typeof v === "number" || typeof v === "boolean").slice(0, 7).map(([k, v]) => `${k} ${String(v)}`);
162
387
  return `${typeof value.title === "string" ? value.title : humanize(tool)}: ${facts.join(", ")}.`;
163
388
  }
164
- /** Which tool each call id named, from the assistant messages (as stored, or as the API takes them). */
165
- function callsIn(messages) {
166
- const calls = /* @__PURE__ */ new Map();
167
- for (const message of messages) {
168
- const blocks = message.role === "assistant" ? message.blocks : [];
169
- for (const block of blocks.filter((b) => b.kind === "tool")) {
170
- const { id, tool } = block.call ?? {
171
- id: block.id,
172
- tool: block.tool
389
+ var modeEvent = (mode, acts, reason) => mode === "auto" ? {
390
+ type: "mode",
391
+ acting: acts,
392
+ reason
393
+ } : {
394
+ type: "mode",
395
+ acting: acts
396
+ };
397
+ var ASK_REFUSAL = "Ask mode changes nothing, so I left it as it is. Switch to Auto, Plan or Agent and send it again.";
398
+ var CHANGE = /\b(suppress|select|highlight|close|assign|delete|disable)\b/i;
399
+ /**
400
+ * Runs the calls: its own at once, reported as steps, then what they found; the page's asked of the UI, whose results
401
+ * close the turn.
402
+ */
403
+ function run(calls, refs, conversation) {
404
+ const out = [];
405
+ const lines = [];
406
+ const asked = [];
407
+ for (const { id, tool, input } of calls) {
408
+ const name = qualified(tool);
409
+ if (tool.kind === "seed") {
410
+ const result = tool.seed.run(input, refs);
411
+ out.push({
412
+ type: "step",
413
+ id,
414
+ tool: name,
415
+ input,
416
+ state: "running"
417
+ }, {
418
+ type: "step",
419
+ id,
420
+ tool: name,
421
+ input,
422
+ state: "done",
423
+ result
424
+ });
425
+ lines.push(sentence(name, true, result, void 0));
426
+ } else asked.push({
427
+ type: "page_tool",
428
+ id,
429
+ domainId: tool.domainId,
430
+ name: tool.name,
431
+ input
432
+ });
433
+ }
434
+ out.push(...speak(lines.join("\n\n")), ...asked);
435
+ if (asked.length === 0) return [...out, {
436
+ type: "done",
437
+ stop: "end"
438
+ }];
439
+ conversation.pending = {
440
+ kind: "page_tool",
441
+ calls: calls.filter((c) => c.tool.kind === "page").map((c) => {
442
+ return {
443
+ id: c.id,
444
+ tool: qualified(c.tool)
445
+ };
446
+ })
447
+ };
448
+ return [...out, {
449
+ type: "done",
450
+ stop: "page_tool"
451
+ }];
452
+ }
453
+ /** Holds the calls for the person's approval: each as a step that waits, then the approval itself. */
454
+ function propose(calls, conversation) {
455
+ const id = nextId();
456
+ const steps = calls.map((c) => {
457
+ return {
458
+ type: "step",
459
+ id: c.id,
460
+ tool: qualified(c.tool),
461
+ input: c.input,
462
+ state: "awaiting-approval"
463
+ };
464
+ });
465
+ conversation.pending = {
466
+ kind: "approval",
467
+ id,
468
+ calls: calls.map((c) => {
469
+ return {
470
+ id: c.id,
471
+ tool: qualified(c.tool),
472
+ input: c.input
173
473
  };
174
- if (id !== void 0 && tool !== void 0) calls.set(id, tool);
474
+ })
475
+ };
476
+ return [
477
+ ...steps,
478
+ {
479
+ type: "approval",
480
+ id,
481
+ steps: calls.map((c) => c.id)
482
+ },
483
+ {
484
+ type: "done",
485
+ stop: "approval"
175
486
  }
487
+ ];
488
+ }
489
+ var QUESTION = "a question, so nothing changes";
490
+ /** The rules after a suggestion's, in order: suppress, select, look up, else describe the screen. */
491
+ function rules(user, context, tools) {
492
+ return suppressing(user, context, tools) ?? selecting(user, context, tools) ?? lookingUp(user, tools) ?? {
493
+ text: describeScreen(user, context, tools),
494
+ acting: "ask",
495
+ reason: QUESTION
496
+ };
497
+ }
498
+ /**
499
+ * The mode's say on what the rules decided (plan decision 2): Ask refuses a change in text, Plan holds a write for
500
+ * approval, Agent runs it, and Auto runs one change and plans more than one.
501
+ */
502
+ function actOn(decided, mode, user, conversation) {
503
+ if ("text" in decided) return [modeEvent(mode, actingFor(mode, decided.acting, 0), decided.reason), ...say(decided.text)];
504
+ const changes = decided.calls.filter((c) => c.tool.effect !== "read").length;
505
+ const acts = actingFor(mode, decided.acting, changes);
506
+ const head = modeEvent(mode, acts, acts === decided.acting ? decided.reason : "more than one change, so a plan first");
507
+ if (changes === 0 || acts === "agent") return [head, ...run(decided.calls, user.refs, conversation)];
508
+ return acts === "plan" ? [head, ...propose(decided.calls, conversation)] : [head, ...say(ASK_REFUSAL)];
509
+ }
510
+ /** A skill chosen by id: the scripted reply names it and echoes the argument, or names it alone for empty text. */
511
+ function useSkill(skill, user, mode) {
512
+ const title = SEED_SKILLS.find((seeded) => seeded.id === skill.id)?.title ?? skill.id;
513
+ const argument = user.text.trim();
514
+ const reply = argument === "" ? `${title}. Nothing more was given, so I used the page as it is.` : `${title}: ${argument}…`;
515
+ return [modeEvent(mode, actingFor(mode, "ask", 0), QUESTION), ...say(`${reply} This is the mock's scripted reply; it runs no skill.`)];
516
+ }
517
+ /** The first answer to a message: the rules, in order, then the mode's say on what they decided. */
518
+ function answer(request, user, conversation) {
519
+ const { mode, context } = request;
520
+ if (user.skill !== void 0) return useSkill(user.skill, user, mode);
521
+ const tools = toolsOf(context);
522
+ const suggested = following(user, context, tools);
523
+ if (suggested === null && mode === "ask" && CHANGE.test(user.text)) return [modeEvent(mode, "ask", QUESTION), ...say(ASK_REFUSAL)];
524
+ return actOn(suggested ?? rules(user, context, tools), mode, user, conversation);
525
+ }
526
+ function afterPageTools(results, conversation) {
527
+ const pending = conversation.pending;
528
+ if (pending?.kind !== "page_tool") return {
529
+ status: 409,
530
+ message: "No page tool is waiting in this conversation"
531
+ };
532
+ conversation.pending = null;
533
+ const tools = new Map(pending.calls.map((c) => [c.id, c.tool]));
534
+ return say(results.map((r) => sentence(tools.get(r.id) ?? "tool", r.ok, r.result, r.error)).join("\n\n"));
535
+ }
536
+ /** Runs the approved calls with the tools offered now; one the page no longer offers fails as the shell would say. */
537
+ function approved(planned, context, conversation) {
538
+ const tools = toolsOf(context);
539
+ const calls = [];
540
+ const missing = [];
541
+ for (const { id, tool: name, input } of planned) {
542
+ const parts = splitToolName(name);
543
+ const tool = parts === null ? void 0 : find(tools, parts.domain, parts.name);
544
+ if (tool === void 0) missing.push({
545
+ type: "step",
546
+ id,
547
+ tool: name,
548
+ input,
549
+ state: "error",
550
+ error: "Not available here"
551
+ });
552
+ else calls.push({
553
+ id,
554
+ tool,
555
+ input: input ?? {}
556
+ });
176
557
  }
177
- return calls;
558
+ return [...missing, ...run(calls, conversation.user?.refs ?? [], conversation)];
178
559
  }
179
- function afterTools(messages, from) {
180
- const recent = messages.slice(from);
181
- const calls = callsIn(recent);
182
- return say(recent.flatMap((message) => message.role === "tool" ? message.results.map((result) => sentence(calls.get(result.id) ?? "tool", result.ok, result.result, result.error)) : []).join("\n\n"));
560
+ function afterApproval(request, input, conversation) {
561
+ const pending = conversation.pending;
562
+ if (pending?.kind !== "approval" || pending.id !== input.id) return {
563
+ status: 409,
564
+ message: `No approval ${input.id} is waiting in this conversation`
565
+ };
566
+ conversation.pending = null;
567
+ conversation.context = request.context;
568
+ if (input.approve) return approved(pending.calls, request.context, conversation);
569
+ return [...pending.calls.map((c) => {
570
+ return {
571
+ type: "step",
572
+ id: c.id,
573
+ tool: c.tool,
574
+ input: c.input,
575
+ state: "declined"
576
+ };
577
+ }), ...say("Understood, I left it alone.")];
183
578
  }
184
- function respond(request) {
185
- const found = lastUser(request.messages);
186
- if (found === null) return say("Ask me something.");
187
- return request.messages.slice(found.index + 1).some((message) => message.role === "tool") ? afterTools(request.messages, found.index + 1) : firstRound(found.message, request.tools, request.mode ?? "auto");
579
+ /**
580
+ * One request of a turn: a message starts the turn afresh, discarding anything it waited on; page tool results and an
581
+ * approval answer what the conversation waits on, and anything else is a 409.
582
+ */
583
+ function respond(request, conversation) {
584
+ const { input } = request;
585
+ if (input.kind === "message") {
586
+ conversation.user = input;
587
+ conversation.context = request.context;
588
+ conversation.pending = null;
589
+ return answer(request, input, conversation);
590
+ }
591
+ return input.kind === "page_tool_results" ? afterPageTools(input.results, conversation) : afterApproval(request, input, conversation);
592
+ }
593
+ /**
594
+ * `GET /api/prompts`: a domain's seeded prompts, without what they expect, which stays on the backend. In Ask, a
595
+ * prompt that expects a write is left out (plan decision 3).
596
+ */
597
+ function promptsFor(domainId, mode, prompts = SEED_PROMPTS) {
598
+ const writes = (prompt) => prompt.expects.some((expected) => SEED_TOOLS.some((tool) => tool.domainId === prompt.domainId && tool.name === expected.tool && tool.effect !== "read"));
599
+ return prompts.filter((prompt) => prompt.domainId === domainId && (mode !== "ask" || !writes(prompt))).map(({ id, text, label }) => {
600
+ return {
601
+ id,
602
+ text,
603
+ ...label === void 0 ? {} : { label }
604
+ };
605
+ });
188
606
  }
189
607
  //#endregion
190
608
  //#region src/mock/chatHandler.ts
@@ -197,40 +615,256 @@ async function readBody(req) {
197
615
  return;
198
616
  }
199
617
  }
200
- var isRequest = (value) => typeof value === "object" && value !== null && Array.isArray(value.messages) && Array.isArray(value.tools);
618
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
619
+ var MODES = /* @__PURE__ */ new Set([
620
+ "auto",
621
+ "ask",
622
+ "plan",
623
+ "agent"
624
+ ]);
625
+ var isSkillRef = (value) => value === void 0 || isObject(value) && typeof value.id === "string";
626
+ function isInput(value) {
627
+ if (!isObject(value)) return false;
628
+ switch (value.kind) {
629
+ case "message": return typeof value.text === "string" && Array.isArray(value.refs) && isSkillRef(value.skill);
630
+ case "page_tool_results": return Array.isArray(value.results) && value.results.every((result) => isObject(result) && typeof result.id === "string");
631
+ case "approval": return typeof value.id === "string" && typeof value.approve === "boolean";
632
+ default: return false;
633
+ }
634
+ }
635
+ /** Enough of the context's shape for the responder to read it without throwing. */
636
+ function isContext(value) {
637
+ if (!isObject(value)) return false;
638
+ const { route, title, screen, domains, pageTools, suggestions } = value;
639
+ const screenOk = screen === null || isObject(screen) && Array.isArray(screen.entities) && Array.isArray(screen.selection);
640
+ return isObject(route) && typeof title === "string" && screenOk && [
641
+ domains,
642
+ pageTools,
643
+ suggestions
644
+ ].every((list) => Array.isArray(list));
645
+ }
646
+ /** `{ tenantIds: [...] }` with at least one id, or `{ all: true }`, and nothing else (authorization standard). */
647
+ function isTenantScope(value) {
648
+ if (!isObject(value)) return false;
649
+ const keys = Object.keys(value);
650
+ if (keys.length === 1 && value.all === true) return true;
651
+ const ids = value.tenantIds;
652
+ return keys.length === 1 && Array.isArray(ids) && ids.length > 0 && ids.every((id) => typeof id === "string" && id !== "") && new Set(ids).size === ids.length;
653
+ }
654
+ var isScope = (value) => isObject(value) && typeof value.orgId === "string" && value.orgId !== "" && isTenantScope(value.tenantScope);
655
+ /** The same org and the same tenants, in any order. */
656
+ function sameScope(a, b) {
657
+ const tenants = (scope) => "all" in scope ? "*" : [...scope.tenantIds].sort().join("\n");
658
+ return a.orgId === b.orgId && tenants(a.tenantScope) === tenants(b.tenantScope);
659
+ }
660
+ var isRequest = (value) => isScope(value) && isObject(value) && MODES.has(value.mode) && isContext(value.context) && isInput(value.input);
201
661
  var pause = async (ms) => new Promise((resolve) => {
202
662
  setTimeout(resolve, ms);
203
663
  });
204
- /** Dev-only `POST /api/chat`: the scripted responder, streamed as server-sent events with a little pacing. */
664
+ function sendJson(res, status, body) {
665
+ res.statusCode = status;
666
+ res.setHeader("Content-Type", "application/json");
667
+ res.end(JSON.stringify(body));
668
+ }
669
+ /** An error in the API's envelope (agent conversations API 8). */
670
+ function refuse(res, status, id, message) {
671
+ sendJson(res, status, { errors: [{
672
+ id,
673
+ message,
674
+ referenceId: randomUUID()
675
+ }] });
676
+ }
677
+ var notFound = (res) => refuse(res, 404, "conversation_not_found", "No such conversation");
678
+ /** A conversation as the API returns it (agent conversations API 5.1). */
679
+ function resourceOf(conversation) {
680
+ const { pending } = conversation;
681
+ let waiting = null;
682
+ if (pending?.kind === "approval") waiting = {
683
+ kind: "approval",
684
+ id: pending.id,
685
+ steps: pending.calls.map((call) => call.id)
686
+ };
687
+ else if (pending?.kind === "page_tool") waiting = {
688
+ kind: "page_tool",
689
+ calls: pending.calls.map((call) => {
690
+ const [domainId, name] = call.tool.split("__");
691
+ return {
692
+ id: call.id,
693
+ domainId,
694
+ name,
695
+ input: {}
696
+ };
697
+ })
698
+ };
699
+ return {
700
+ id: conversation.id,
701
+ title: null,
702
+ createdAt: conversation.createdAt,
703
+ updatedAt: conversation.updatedAt,
704
+ archivedAt: null,
705
+ ...conversation.scope,
706
+ waiting
707
+ };
708
+ }
709
+ /** `POST /api/conversations`: a new conversation in the body's scope. */
710
+ async function create(req, res, conversations) {
711
+ const body = await readBody(req);
712
+ if (!isScope(body)) {
713
+ refuse(res, 400, "invalid_request", "The body must be { orgId, tenantScope }");
714
+ return;
715
+ }
716
+ const conversation = conversations.create({
717
+ orgId: body.orgId,
718
+ tenantScope: body.tenantScope
719
+ });
720
+ res.setHeader("Location", `/api/conversations/${conversation.id}`);
721
+ sendJson(res, 201, { data: resourceOf(conversation) });
722
+ }
723
+ /** Why a message's skill is refused (agent conversations API 6.1), or `null`: it must be offered, and comes alone. */
724
+ function skillRefusal(input) {
725
+ if (input.kind !== "message" || input.skill === void 0) return null;
726
+ if (input.suggestion !== void 0 || input.markup !== void 0) return "A skill does not come with a suggestion or markup";
727
+ const { id } = input.skill;
728
+ return SEED_SKILLS.some((skill) => skill.id === id) ? null : `Unknown skill ${id}`;
729
+ }
730
+ /**
731
+ * `POST /api/conversations/{id}/turns`: one request of a turn, answered with the responder's events, streamed with a
732
+ * little pacing. An unknown id, or one in another scope, is a 404 whatever the input (agent conversations API 4).
733
+ */
734
+ async function turn(req, res, id, conversations, delayMs) {
735
+ const body = await readBody(req);
736
+ if (!isRequest(body)) {
737
+ refuse(res, 400, "invalid_request", "The body is not a turn request");
738
+ return;
739
+ }
740
+ const conversation = conversations.get(id);
741
+ if (conversation === void 0 || !sameScope(conversation.scope, body)) {
742
+ notFound(res);
743
+ return;
744
+ }
745
+ const refusal = skillRefusal(body.input);
746
+ if (refusal !== null) {
747
+ refuse(res, 400, "invalid_request", refusal);
748
+ return;
749
+ }
750
+ if (conversation.busy) {
751
+ refuse(res, 409, "turn_in_progress", "A turn is in progress on this conversation");
752
+ return;
753
+ }
754
+ const answer = respond(body, conversation);
755
+ if (!Array.isArray(answer)) {
756
+ refuse(res, answer.status, "not_waiting", answer.message);
757
+ return;
758
+ }
759
+ conversation.busy = true;
760
+ conversation.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
761
+ res.on("close", () => {
762
+ conversation.busy = false;
763
+ });
764
+ res.statusCode = 200;
765
+ res.setHeader("Content-Type", "text/event-stream");
766
+ res.setHeader("Cache-Control", "no-cache");
767
+ let seq = 0;
768
+ for (const event of answer) {
769
+ if (res.destroyed) break;
770
+ const { type, ...data } = event;
771
+ seq += 1;
772
+ res.write(`id: ${seq}\nevent: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
773
+ await pause(delayMs);
774
+ }
775
+ conversation.busy = false;
776
+ res.end();
777
+ }
778
+ /** `GET /api/prompts?domain=&mode=`: the domain's seeded prompts in the mode. */
779
+ function prompts(url, res) {
780
+ const domain = url.searchParams.get("domain");
781
+ const mode = url.searchParams.get("mode");
782
+ if (domain === null || !MODES.has(mode)) {
783
+ refuse(res, 400, "invalid_request", "domain and a known mode are required");
784
+ return;
785
+ }
786
+ sendJson(res, 200, { data: { prompts: promptsFor(domain, mode) } });
787
+ }
788
+ var SKILLS_LIMIT = 50;
789
+ /** A page position as an opaque cursor, and back; `null` when the cursor is not one this handler issued. */
790
+ var cursorOf = (index) => Buffer.from(String(index)).toString("base64url");
791
+ function indexOf(cursor) {
792
+ const index = Number(Buffer.from(cursor, "base64url").toString("utf8"));
793
+ return Number.isInteger(index) && index >= 0 && cursorOf(index) === cursor ? index : null;
794
+ }
795
+ /** The page size asked for: a whole number from 1 to the maximum, 50 if none; `null` otherwise. */
796
+ function limitOf(raw) {
797
+ if (raw === null) return SKILLS_LIMIT;
798
+ const limit = /^\d+$/.test(raw) ? Number(raw) : 0;
799
+ return limit >= 1 && limit <= SKILLS_LIMIT ? limit : null;
800
+ }
801
+ /** `GET /api/skills?limit=&cursor=`: the seeded skills, paged, with an `ETag` (agent conversations API 5.8). */
802
+ function skills(url, req, res) {
803
+ const limit = limitOf(url.searchParams.get("limit"));
804
+ const cursor = url.searchParams.get("cursor");
805
+ const start = cursor === null ? 0 : indexOf(cursor);
806
+ if (limit === null || start === null) {
807
+ refuse(res, 400, "invalid_request", `limit is 1 to ${SKILLS_LIMIT}; cursor is one this API issued`);
808
+ return;
809
+ }
810
+ const end = start + limit;
811
+ const hasMore = end < SEED_SKILLS.length;
812
+ const data = SEED_SKILLS.slice(start, end).map(({ id, title, description }) => {
813
+ return {
814
+ id,
815
+ title,
816
+ description
817
+ };
818
+ });
819
+ const body = JSON.stringify({
820
+ data,
821
+ nextCursor: hasMore ? cursorOf(end) : null,
822
+ hasMore
823
+ });
824
+ const etag = `"${createHash("sha256").update(body).digest("hex").slice(0, 32)}"`;
825
+ res.setHeader("ETag", etag);
826
+ if (req.headers["if-none-match"] === etag) {
827
+ res.statusCode = 304;
828
+ res.end();
829
+ return;
830
+ }
831
+ res.statusCode = 200;
832
+ res.setHeader("Content-Type", "application/json");
833
+ res.end(body);
834
+ }
835
+ var TURNS = /^\/api\/conversations\/([^/]+)\/turns$/;
836
+ /**
837
+ * The dev-only agent backend (assistant endpoints design, decision 23), speaking the agent conversations API:
838
+ * `POST /api/conversations`, `POST /api/conversations/{id}/turns`, `GET /api/prompts` and `GET /api/skills`. Each handler keeps its own
839
+ * conversations, in memory.
840
+ */
205
841
  function createChatHandler(options = {}) {
206
842
  const delayMs = options.delayMs ?? 40;
843
+ const conversations = options.conversations ?? createConversations();
844
+ const failed = (res) => () => {
845
+ if (!res.headersSent) refuse(res, 500, "internal_error", "The mock failed");
846
+ else res.end();
847
+ };
207
848
  return (req, res) => {
208
- if ((req.url ?? "").split("?")[0] !== "/api/chat") return false;
209
- if (req.method !== "POST") {
210
- res.statusCode = 405;
211
- res.end();
212
- return true;
213
- }
214
- readBody(req).then(async (body) => {
215
- if (!isRequest(body)) {
216
- res.statusCode = 400;
217
- res.end();
218
- return;
219
- }
220
- res.statusCode = 200;
221
- res.setHeader("Content-Type", "text/event-stream");
222
- res.setHeader("Cache-Control", "no-cache");
223
- for (const event of respond(body)) {
224
- const { type, ...data } = event;
225
- res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
226
- await pause(delayMs);
849
+ const url = new URL(req.url ?? "/", "http://mock.invalid");
850
+ const only = (method, then) => {
851
+ if (req.method === method) then();
852
+ else {
853
+ res.setHeader("Allow", method);
854
+ refuse(res, 405, "method_not_allowed", `Only ${method}`);
227
855
  }
228
- res.end();
229
- }).catch(() => {
230
- res.statusCode = 500;
231
- res.end();
856
+ return true;
857
+ };
858
+ if (url.pathname === "/api/prompts") return only("GET", () => prompts(url, res));
859
+ if (url.pathname === "/api/skills") return only("GET", () => skills(url, req, res));
860
+ if (url.pathname === "/api/conversations") return only("POST", () => {
861
+ create(req, res, conversations).catch(failed(res));
862
+ });
863
+ const match = TURNS.exec(url.pathname);
864
+ if (match === null) return false;
865
+ return only("POST", () => {
866
+ turn(req, res, decodeURIComponent(match[1]), conversations, delayMs).catch(failed(res));
232
867
  });
233
- return true;
234
868
  };
235
869
  }
236
870
  //#endregion