@jini-ai/chat 0.6.5 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/core/accepted-input.d.ts +54 -0
  3. package/dist/core/accepted-input.js +165 -0
  4. package/dist/core/agent-plugin-chat-state.d.ts +43 -0
  5. package/dist/core/agent-plugin-chat-state.js +48 -0
  6. package/dist/core/assistant-client/a2ui-action-poster.js +32 -28
  7. package/dist/core/assistant-client/messages.d.ts +1 -1
  8. package/dist/core/assistant-client/messages.js +2 -2
  9. package/dist/core/assistant-client/transport.d.ts +2 -2
  10. package/dist/core/assistant-client/transport.js +184 -97
  11. package/dist/core/assistant-content.d.ts +2 -1
  12. package/dist/core/assistant-content.js +9 -11
  13. package/dist/core/browser/page-actions.js +4 -2
  14. package/dist/core/composer-history.d.ts +16 -9
  15. package/dist/core/composer-history.js +28 -16
  16. package/dist/core/conversation-title.d.ts +3 -0
  17. package/dist/core/conversation-title.js +3 -0
  18. package/dist/core/durable-projection.js +20 -13
  19. package/dist/core/durable-runs/continuation.d.ts +4 -0
  20. package/dist/core/durable-runs/continuation.js +35 -6
  21. package/dist/core/durable-runs/ledger-contracts.d.ts +2 -1
  22. package/dist/core/durable-runs/policies.d.ts +1 -0
  23. package/dist/core/durable-runs/policies.js +1 -0
  24. package/dist/core/durable-runs/ports.d.ts +6 -2
  25. package/dist/core/index.d.ts +2 -0
  26. package/dist/core/index.js +2 -0
  27. package/dist/core/messages.d.ts +2 -0
  28. package/dist/core/partial-json.d.ts +6 -7
  29. package/dist/core/partial-json.js +81 -47
  30. package/dist/core/persistence/title.js +4 -2
  31. package/dist/core/question-answer.d.ts +23 -0
  32. package/dist/core/question-answer.js +66 -0
  33. package/dist/core/question-form/format.d.ts +6 -6
  34. package/dist/core/question-form/format.js +16 -17
  35. package/dist/core/question-form/normalize.d.ts +18 -0
  36. package/dist/core/question-form/normalize.js +112 -122
  37. package/dist/core/question-form/scan.js +141 -203
  38. package/dist/core/run-activity.js +62 -49
  39. package/dist/core/run-events/entry.d.ts +10 -1
  40. package/dist/core/run-events/entry.js +117 -87
  41. package/dist/core/todos.d.ts +1 -1
  42. package/dist/core/todos.js +60 -85
  43. package/dist/core/tool-events.js +14 -12
  44. package/dist/core/transcript.js +62 -16
  45. package/dist/core/turn-usage.d.ts +2 -1
  46. package/dist/core/turn-usage.js +56 -27
  47. package/dist/core/user-text-redaction.js +3 -0
  48. package/dist/core/util/manifest.js +77 -82
  49. package/dist/core/util/markdown-context.js +51 -53
  50. package/dist/core/util/parser.js +136 -144
  51. package/dist/core/util/pointer.js +16 -16
  52. package/dist/core/util/strip.js +57 -67
  53. package/dist/core/util/validate.js +15 -17
  54. package/dist/embed-widget/browser/session-store.js +21 -12
  55. package/dist/embed-widget/transports/fetch-sse.js +108 -95
  56. package/dist/embed-widget/vanilla/index.js +81 -70
  57. package/dist/react/agent-bridge/frontend-session-bridge.js +80 -52
  58. package/dist/react/components/AttachmentPreviewModal.js +25 -1
  59. package/dist/react/components/ChatSettingsDialog.d.ts +13 -0
  60. package/dist/react/components/ChatSettingsDialog.hooks.d.ts +115 -0
  61. package/dist/react/components/ChatSettingsDialog.hooks.js +40 -0
  62. package/dist/react/components/ChatSettingsDialog.js +72 -0
  63. package/dist/react/components/ChatSettingsSegmented.d.ts +18 -0
  64. package/dist/react/components/ChatSettingsSegmented.js +11 -0
  65. package/dist/react/components/Composer.d.ts +5 -1
  66. package/dist/react/components/Composer.js +126 -82
  67. package/dist/react/components/ConversationList.js +25 -39
  68. package/dist/react/components/ConversationTitleEditor.d.ts +27 -0
  69. package/dist/react/components/ConversationTitleEditor.js +54 -0
  70. package/dist/react/components/Icon.d.ts +1 -1
  71. package/dist/react/components/Icon.js +25 -45
  72. package/dist/react/components/Markdown.js +134 -123
  73. package/dist/react/components/McpUiSurfaceCard.d.ts +2 -2
  74. package/dist/react/components/McpUiSurfaceCard.js +30 -9
  75. package/dist/react/components/MessageList.d.ts +13 -13
  76. package/dist/react/components/MessageList.hooks.d.ts +6 -1
  77. package/dist/react/components/MessageList.hooks.js +19 -3
  78. package/dist/react/components/MessageList.js +34 -10
  79. package/dist/react/components/MessageRow.js +48 -32
  80. package/dist/react/components/MessageRow.usage.d.ts +5 -2
  81. package/dist/react/components/MessageRow.usage.js +20 -11
  82. package/dist/react/components/MidRunUserMessage.js +16 -3
  83. package/dist/react/components/QuestionForm.js +148 -76
  84. package/dist/react/components/QuestionsPanel.js +1 -1
  85. package/dist/react/components/TodoCard.js +12 -6
  86. package/dist/react/components/ToolCard.js +47 -40
  87. package/dist/react/components/composer-discovery.js +6 -1
  88. package/dist/react/components/mcp-ui-surface-state.d.ts +6 -11
  89. package/dist/react/components/mcp-ui-surface-state.js +23 -8
  90. package/dist/react/ext-event-renderer-registry.d.ts +8 -0
  91. package/dist/react/ext-event-renderer-registry.js +5 -2
  92. package/dist/react/features/chat-pane/components/AgentPluginUsage.d.ts +7 -0
  93. package/dist/react/features/chat-pane/components/AgentPluginUsage.js +45 -0
  94. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +55 -19
  95. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  96. package/dist/react/features/chat-pane/components/ChatPane.js +192 -74
  97. package/dist/react/features/chat-pane/components/RuntimeDropUp.d.ts +29 -0
  98. package/dist/react/features/chat-pane/components/RuntimeDropUp.js +45 -0
  99. package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +80 -47
  100. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +44 -40
  101. package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +6 -6
  102. package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -8
  103. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +3 -3
  104. package/dist/react/features/chat-pane/create-typed-answer-poster.js +3 -3
  105. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.d.ts +6 -1
  106. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +11 -5
  107. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +12 -7
  108. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +340 -149
  109. package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +7 -6
  110. package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +28 -10
  111. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +3 -3
  112. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +60 -12
  113. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.d.ts +49 -0
  114. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +143 -0
  115. package/dist/react/features/chat-pane/model-options.d.ts +18 -1
  116. package/dist/react/features/chat-pane/model-options.js +49 -20
  117. package/dist/react/features/chat-pane/rules.d.ts +8 -9
  118. package/dist/react/features/chat-pane/rules.js +84 -46
  119. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  120. package/dist/react/features/chat-pane/styles.js +156 -37
  121. package/dist/react/features/chat-pane/types.d.ts +17 -8
  122. package/dist/react/hooks/assistant-chats.js +12 -5
  123. package/dist/react/hooks/composer-draft-cache.js +116 -118
  124. package/dist/react/hooks/composer-history-storage.js +5 -5
  125. package/dist/react/hooks/fab-position.js +7 -4
  126. package/dist/react/hooks/useAnsweredCards.d.ts +21 -4
  127. package/dist/react/hooks/useAnsweredCards.js +38 -11
  128. package/dist/react/hooks/useArtifactStream.js +28 -22
  129. package/dist/react/hooks/useComposer.d.ts +20 -0
  130. package/dist/react/hooks/useComposer.js +22 -9
  131. package/dist/react/hooks/useComposerHistory.hooks.d.ts +6 -1
  132. package/dist/react/hooks/useComposerHistory.hooks.js +39 -19
  133. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +24 -14
  134. package/dist/react/hooks/useConversation.d.ts +7 -0
  135. package/dist/react/hooks/useConversation.js +23 -10
  136. package/dist/react/hooks/useExtEventGroups.js +39 -39
  137. package/dist/react/hooks/useQuestionForms.js +21 -26
  138. package/dist/react/hooks/useRunStream.js +10 -2
  139. package/dist/react/index.d.ts +7 -1
  140. package/dist/react/index.js +9 -0
  141. package/dist/react/message-blocks.d.ts +6 -4
  142. package/dist/react/message-blocks.js +94 -72
  143. package/dist/react/slots.d.ts +2 -2
  144. package/dist/react/tool-call-display.js +19 -17
  145. package/dist/server/durable-runs/recover.d.ts +1 -1
  146. package/dist/server/durable-runs/recover.js +139 -76
  147. package/dist/server/run-finalizer/entry.js +25 -15
  148. package/dist/store/legacy/sqlite/conversations.d.ts +11 -11
  149. package/dist/store/legacy/sqlite/conversations.js +69 -98
  150. package/dist/store/legacy/sqlite/messages.d.ts +10 -2
  151. package/dist/store/legacy/sqlite/messages.js +113 -71
  152. package/dist/store/runtime/chat-history-store.js +9 -3
  153. package/dist/store/runtime/durable-run-store.d.ts +2 -2
  154. package/dist/store/runtime/durable-run-store.js +165 -117
  155. package/dist/store/runtime/history-tools.js +118 -82
  156. package/dist/store/runtime/run-ledger.d.ts +4 -1
  157. package/dist/store/runtime/run-ledger.js +56 -16
  158. package/dist/store/sql/cursor.js +38 -18
  159. package/dist/store/sql/store.d.ts +18 -0
  160. package/dist/store/sql/store.js +46 -15
  161. package/package.json +12 -10
@@ -10,19 +10,34 @@
10
10
  */
11
11
  import { parsePartialJson } from '../partial-json.js';
12
12
  import { parseQuotedAttrs } from '../util/markup-attributes.js';
13
- import { mapRawQuestion } from './normalize.js';
13
+ import { asRecord, mapRawQuestion, nonEmptyString, omitUndefined, stringOrUndefined } from './normalize.js';
14
14
  // `question-form` is the canonical tag; `ask-question` is an alias the model
15
15
  // occasionally drifts to. The close tag must match the open tag name, so each
16
16
  // match captures the name and computes its own close-tag string. Matching is
17
17
  // case-insensitive so `<Question-Form>` / `<ASK-QUESTION>` still parse.
18
18
  const OPEN_RE = /<(question-form|ask-question)\b([^>]*)>/i;
19
- // Group 1 is a mandatory (non-optional) alternation and group 2 is an
20
- // unconditional `*`-quantified capture, so both always participate whenever
21
- // `m` itself is non-null — `noUncheckedIndexedAccess` just can't see that
22
- // from the regex alone. Centralized here so the six call sites below don't
23
- // each repeat the same assertion and reasoning.
24
- function matchedTagAndAttrs(m) {
25
- return { tagName: m[1].toLowerCase(), rawAttrs: m[2] };
19
+ /** The first question-form block opening at or after `from`, or `null` when there is none. */
20
+ function locateBlock({ input, from }) {
21
+ const m = OPEN_RE.exec(input.slice(from));
22
+ if (!m)
23
+ return null;
24
+ // Group 1 is a mandatory (non-optional) alternation and group 2 is an
25
+ // unconditional `*`-quantified capture, so both always participate whenever
26
+ // `m` itself is non-null — `noUncheckedIndexedAccess` just can't see that
27
+ // from the regex alone.
28
+ const closeTag = `</${m[1].toLowerCase()}>`;
29
+ const start = from + m.index;
30
+ const bodyStart = start + m[0].length;
31
+ const closeIdx = findCloseTag(input, bodyStart, closeTag);
32
+ const closed = closeIdx !== -1;
33
+ return {
34
+ start,
35
+ bodyStart,
36
+ bodyEnd: closed ? closeIdx : input.length,
37
+ end: closed ? closeIdx + closeTag.length : input.length,
38
+ closed,
39
+ rawAttrs: m[2],
40
+ };
26
41
  }
27
42
  /**
28
43
  * Split `input` into ordered prose/form segments. Scans repeatedly for
@@ -37,37 +52,20 @@ export function splitOnQuestionForms({ input }) {
37
52
  const out = [];
38
53
  let cursor = 0;
39
54
  while (cursor < input.length) {
40
- const slice = input.slice(cursor);
41
- const m = OPEN_RE.exec(slice);
42
- if (!m) {
43
- out.push({ kind: 'text', text: slice });
55
+ const block = locateBlock({ input, from: cursor });
56
+ if (!block?.closed) {
57
+ // No block, or an unterminated one — leave the rest as prose so we don't swallow it.
58
+ out.push({ kind: 'text', text: input.slice(cursor) });
44
59
  break;
45
60
  }
46
- const { tagName, rawAttrs } = matchedTagAndAttrs(m);
47
- const closeTag = `</${tagName}>`;
48
- const openStart = cursor + m.index;
49
- const openEnd = openStart + m[0].length;
50
- const closeIdx = findCloseTag(input, openEnd, closeTag);
51
- if (closeIdx === -1) {
52
- // Unterminated — leave the rest as prose so we don't swallow it.
53
- out.push({ kind: 'text', text: slice });
54
- break;
55
- }
56
- if (openStart > cursor) {
57
- out.push({ kind: 'text', text: input.slice(cursor, openStart) });
58
- }
59
- const body = input.slice(openEnd, closeIdx);
60
- const attrs = parseQuotedAttrs({ raw: rawAttrs });
61
- const form = tryParseForm(body, attrs);
62
- const blockEnd = closeIdx + closeTag.length;
63
- if (form) {
64
- out.push({ kind: 'form', form, raw: input.slice(openStart, blockEnd) });
61
+ if (block.start > cursor) {
62
+ out.push({ kind: 'text', text: input.slice(cursor, block.start) });
65
63
  }
66
- else {
67
- // Malformed — keep raw text so the user can still see it.
68
- out.push({ kind: 'text', text: input.slice(openStart, blockEnd) });
69
- }
70
- cursor = blockEnd;
64
+ const raw = input.slice(block.start, block.end);
65
+ const form = tryParseForm(input.slice(block.bodyStart, block.bodyEnd), parseQuotedAttrs({ raw: block.rawAttrs }));
66
+ // Malformed — keep raw text so the user can still see it.
67
+ out.push(form ? { kind: 'form', form, raw } : { kind: 'text', text: raw });
68
+ cursor = block.end;
71
69
  }
72
70
  return out;
73
71
  }
@@ -98,19 +96,12 @@ export function parseQuestionForm({ input }) {
98
96
  export function stripTrailingOpenQuestionForm({ input }) {
99
97
  let cursor = 0;
100
98
  while (cursor < input.length) {
101
- const slice = input.slice(cursor);
102
- const m = OPEN_RE.exec(slice);
103
- if (!m)
99
+ const block = locateBlock({ input, from: cursor });
100
+ if (!block)
104
101
  break;
105
- const { tagName } = matchedTagAndAttrs(m);
106
- const closeTag = `</${tagName}>`;
107
- const openStart = cursor + m.index;
108
- const openEnd = openStart + m[0].length;
109
- const closeIdx = findCloseTag(input, openEnd, closeTag);
110
- if (closeIdx === -1) {
111
- return { text: input.slice(0, openStart), hadOpenForm: true };
112
- }
113
- cursor = closeIdx + closeTag.length;
102
+ if (!block.closed)
103
+ return { text: input.slice(0, block.start), hadOpenForm: true };
104
+ cursor = block.end;
114
105
  }
115
106
  return { text: input, hadOpenForm: false };
116
107
  }
@@ -130,12 +121,10 @@ function findCloseTag(input, from, closeTag) {
130
121
  return -1;
131
122
  }
132
123
  function tryParseForm(body, attrs) {
133
- const trimmed = body.trim();
134
- if (!trimmed)
135
- return null;
136
124
  // Allow the JSON to be wrapped in a fenced ```json block — common when the
137
125
  // model echoes its own indented body.
138
- const stripped = trimmed
126
+ const stripped = body
127
+ .trim()
139
128
  .replace(/^```(?:json)?\s*/i, '')
140
129
  .replace(/```\s*$/i, '')
141
130
  .trim();
@@ -146,31 +135,24 @@ function tryParseForm(body, attrs) {
146
135
  catch {
147
136
  return null;
148
137
  }
149
- if (!data || typeof data !== 'object')
138
+ const obj = asRecord(data);
139
+ if (!obj || !Array.isArray(obj.questions))
150
140
  return null;
151
- const obj = data;
152
- const rawQuestions = Array.isArray(obj.questions) ? obj.questions : null;
153
- if (!rawQuestions)
154
- return null;
155
- const questions = [];
156
- rawQuestions.forEach((q, i) => {
157
- const mapped = mapRawQuestion({ q: q, index: i });
158
- if (mapped)
159
- questions.push(mapped);
160
- });
141
+ const questions = obj.questions.flatMap((q, index) => mapRawQuestion({ q, index }) ?? []);
161
142
  if (questions.length === 0)
162
143
  return null;
163
- const id = attrs.id ?? (typeof obj.id === 'string' ? obj.id : 'discovery');
164
- const title = attrs.title ?? (typeof obj.title === 'string' ? obj.title : 'A few quick questions');
165
- const description = typeof obj.description === 'string' ? obj.description : undefined;
166
- const submitLabel = typeof obj.submitLabel === 'string' ? obj.submitLabel : undefined;
167
- return {
168
- id,
169
- title,
144
+ return assembleForm({ attrs, top: obj, bodyId: stringOrUndefined(obj.id), bodyTitle: stringOrUndefined(obj.title), questions });
145
+ }
146
+ // Form-level fields shared by the complete and streaming parsers: the open
147
+ // tag's attrs win over the body's own id/title, which win over the defaults.
148
+ function assembleForm({ attrs, top, bodyId, bodyTitle, questions }) {
149
+ return omitUndefined({
150
+ id: attrs.id ?? bodyId ?? 'discovery',
151
+ title: attrs.title ?? bodyTitle ?? 'A few quick questions',
170
152
  questions,
171
- ...(description ? { description } : {}),
172
- ...(submitLabel ? { submitLabel } : {}),
173
- };
153
+ description: nonEmptyString(top.description),
154
+ submitLabel: nonEmptyString(top.submitLabel),
155
+ });
174
156
  }
175
157
  /**
176
158
  * Tolerant parser for a still-streaming `<question-form>` block. Unlike
@@ -185,15 +167,10 @@ function tryParseForm(body, attrs) {
185
167
  * string-aware brace walk via {@link parsePartialJson}).
186
168
  */
187
169
  export function parsePartialQuestionForm({ input }) {
188
- const m = OPEN_RE.exec(input);
189
- if (!m)
170
+ const block = locateBlock({ input, from: 0 });
171
+ if (!block)
190
172
  return null;
191
- const { tagName, rawAttrs } = matchedTagAndAttrs(m);
192
- const closeTag = `</${tagName}>`;
193
- const openEnd = m.index + m[0].length;
194
- const attrs = parseQuotedAttrs({ raw: rawAttrs });
195
- const closeIdx = findCloseTag(input, openEnd, closeTag);
196
- const rawBody = closeIdx === -1 ? input.slice(openEnd) : input.slice(openEnd, closeIdx);
173
+ const rawBody = input.slice(block.bodyStart, block.bodyEnd);
197
174
  // Strip the fenced ```json wrapper some models emit. The opening fence is
198
175
  // removed always; the trailing fence is removed too once it streams in
199
176
  // (possibly only a partial ``` so far) — otherwise leftover backticks make
@@ -203,24 +180,18 @@ export function parsePartialQuestionForm({ input }) {
203
180
  // Derive form-level metadata from the *parsed top-level object*, not a
204
181
  // whole-body regex scan: a nested question/option `id`/`title`/`description`
205
182
  // must not masquerade as the form's own.
206
- const parsed = parsePartialJson({ buf: body });
207
- const top = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
208
- const topTitle = typeof top.title === 'string' && top.title.trim().length > 0 ? top.title : undefined;
183
+ const top = asRecord(parsePartialJson({ buf: body })) ?? {};
184
+ const title = stringOrUndefined(top.title);
209
185
  // `id` keys a still-editable panel, so it must be stable for the whole
210
186
  // stream — don't adopt it until its string literal is fully terminated,
211
187
  // otherwise it would churn character-by-character and remount the panel.
212
- const id = attrs.id ?? completeTopLevelString(body, 'id') ?? 'discovery';
213
- const title = attrs.title ?? topTitle ?? 'A few quick questions';
214
- const description = typeof top.description === 'string' ? top.description : undefined;
215
- const submitLabel = typeof top.submitLabel === 'string' ? top.submitLabel : undefined;
216
- const questions = shapeStreamingQuestions(top.questions, countClosedQuestionObjects(body));
217
- return {
218
- id,
219
- title,
220
- questions,
221
- ...(description ? { description } : {}),
222
- ...(submitLabel ? { submitLabel } : {}),
223
- };
188
+ return assembleForm({
189
+ attrs: parseQuotedAttrs({ raw: block.rawAttrs }),
190
+ top,
191
+ bodyId: completeTopLevelString(body, 'id'),
192
+ bodyTitle: title?.trim() ? title : undefined,
193
+ questions: shapeStreamingQuestions(top.questions, countClosedQuestionObjects(body)),
194
+ });
224
195
  }
225
196
  // Strip a trailing ``` fence (possibly only partially streamed) from a form
226
197
  // body — but only when those backticks are the closing wrapper, not content
@@ -235,104 +206,82 @@ function stripTrailingFence(body) {
235
206
  return before;
236
207
  }
237
208
  function endsInsideJsonString(s) {
238
- let inStr = false;
239
- let esc = false;
240
- for (let i = 0; i < s.length; i++) {
241
- const c = s[i];
242
- if (inStr) {
243
- if (esc)
244
- esc = false;
245
- else if (c === '\\')
246
- esc = true;
247
- else if (c === '"')
248
- inStr = false;
249
- }
250
- else if (c === '"') {
251
- inStr = true;
252
- }
209
+ for (let i = s.indexOf('"'); i !== -1; i = s.indexOf('"', i)) {
210
+ i = jsonStringEnd({ s, start: i });
211
+ if (i === -1)
212
+ return true;
253
213
  }
254
- return inStr;
214
+ return false;
255
215
  }
216
+ // Index just past the closing quote of the JSON string literal whose opening
217
+ // quote is at `start`, or -1 when that literal hasn't finished streaming.
218
+ function jsonStringEnd({ s, start }) {
219
+ for (let i = start + 1; i < s.length; i++) {
220
+ if (s[i] === '\\')
221
+ i++;
222
+ else if (s[i] === '"')
223
+ return i + 1;
224
+ }
225
+ return -1;
226
+ }
227
+ const DEPTH_DELTA = { '{': 1, '[': 1, '}': -1, ']': -1 };
256
228
  // Return a top-level (depth-1) string field's value ONLY if its string
257
229
  // literal is fully terminated in the (possibly partial) body. Depth-aware so
258
230
  // a nested question `id` can't be mistaken for the form's own.
259
231
  function completeTopLevelString(body, field) {
260
232
  const marker = `"${field}"`;
261
233
  let depth = 0;
262
- let inStr = false;
263
- let esc = false;
264
234
  for (let i = 0; i < body.length; i++) {
265
235
  const c = body[i];
266
- if (inStr) {
267
- if (esc)
268
- esc = false;
269
- else if (c === '\\')
270
- esc = true;
271
- else if (c === '"')
272
- inStr = false;
273
- continue;
274
- }
275
- if (c === '{' || c === '[') {
276
- depth++;
277
- continue;
278
- }
279
- if (c === '}' || c === ']') {
280
- depth--;
236
+ if (c !== '"') {
237
+ depth += DEPTH_DELTA[c] ?? 0;
281
238
  continue;
282
239
  }
283
- if (c === '"') {
284
- if (depth === 1 && body.startsWith(marker, i)) {
285
- let j = i + marker.length;
286
- while (j < body.length && /\s/.test(body[j]))
287
- j++;
288
- if (body[j] !== ':') {
289
- inStr = true; // it's a value string, not our key — skip it
290
- continue;
291
- }
292
- j++;
293
- while (j < body.length && /\s/.test(body[j]))
294
- j++;
295
- if (body[j] !== '"')
296
- return undefined; // value not a (started) string
297
- let value = '';
298
- let vesc = false;
299
- for (let k = j + 1; k < body.length; k++) {
300
- const vc = body[k];
301
- if (vesc) {
302
- value += vc;
303
- vesc = false;
304
- }
305
- else if (vc === '\\') {
306
- value += vc;
307
- vesc = true;
308
- }
309
- else if (vc === '"') {
310
- try {
311
- return JSON.parse(`"${value}"`);
312
- }
313
- catch {
314
- return value;
315
- }
316
- }
317
- else {
318
- value += vc;
319
- }
320
- }
321
- return undefined; // closing quote hasn't streamed yet
322
- }
323
- inStr = true;
324
- }
240
+ const valueStart = depth === 1 ? keyValueStart({ body, at: i, marker }) : -1;
241
+ if (valueStart !== -1)
242
+ return readStringLiteral({ body, start: valueStart });
243
+ // A string that isn't our key (any other key, or a value) — skip it.
244
+ const end = jsonStringEnd({ s: body, start: i });
245
+ if (end === -1)
246
+ return undefined;
247
+ i = end - 1;
325
248
  }
326
249
  return undefined;
327
250
  }
251
+ const KEY_COLON_RE = /\s*:\s*/y;
252
+ // Where the value starts when the string literal at `at` is the `marker` key,
253
+ // or -1 when it isn't — including a value string that merely spells the key,
254
+ // which no `:` follows.
255
+ function keyValueStart({ body, at, marker }) {
256
+ if (!body.startsWith(marker, at))
257
+ return -1;
258
+ KEY_COLON_RE.lastIndex = at + marker.length;
259
+ return KEY_COLON_RE.test(body) ? KEY_COLON_RE.lastIndex : -1;
260
+ }
261
+ // The decoded string literal at `start`, or `undefined` when the value isn't
262
+ // a (started) string or its closing quote hasn't streamed yet.
263
+ function readStringLiteral({ body, start }) {
264
+ if (body[start] !== '"')
265
+ return undefined; // value not a (started) string
266
+ const end = jsonStringEnd({ s: body, start });
267
+ if (end === -1)
268
+ return undefined; // closing quote hasn't streamed yet
269
+ const value = body.slice(start + 1, end - 1);
270
+ try {
271
+ return JSON.parse(`"${value}"`);
272
+ }
273
+ catch {
274
+ return value;
275
+ }
276
+ }
328
277
  function shapeStreamingQuestions(rawQuestions, closedCount) {
329
278
  if (!Array.isArray(rawQuestions))
330
279
  return [];
331
280
  const out = [];
332
281
  rawQuestions.forEach((raw, index) => {
333
- if (!raw || typeof raw !== 'object')
282
+ const q = asRecord(raw);
283
+ if (!q)
334
284
  return;
335
- const q = raw;
336
285
  const label = q.label;
337
286
  if (typeof label !== 'string' || label.trim().length === 0)
338
287
  return;
@@ -357,48 +306,37 @@ function countClosedQuestionObjects(body) {
357
306
  return 0;
358
307
  let i = keyMatch.index + keyMatch[0].length;
359
308
  let count = 0;
360
- while (i < body.length) {
309
+ for (;;) {
361
310
  while (i < body.length && /[\s,]/.test(body[i]))
362
311
  i++;
363
- if (i >= body.length || body[i] === ']')
364
- break;
312
+ // Past the end, at `]`, or at a non-object element: nothing more to count.
365
313
  if (body[i] !== '{')
366
- break;
367
- const obj = extractBalancedObject(body, i);
368
- if (!obj)
369
- break; // trailing object hasn't closed yet
314
+ return count;
315
+ i = balancedObjectEnd({ s: body, start: i });
316
+ if (i === -1)
317
+ return count; // trailing object hasn't closed yet
370
318
  count++;
371
- i += obj.length;
372
319
  }
373
- return count;
374
320
  }
375
- // Return the substring for the balanced `{...}` object starting at `start`,
376
- // or null if it never closes (string-aware so braces inside strings don't count).
377
- function extractBalancedObject(s, start) {
321
+ // Index just past the balanced `{...}` object starting at `start`, or -1 if
322
+ // it never closes (string-aware so braces inside strings don't count).
323
+ function balancedObjectEnd({ s, start }) {
378
324
  let depth = 0;
379
- let inStr = false;
380
- let esc = false;
381
325
  for (let i = start; i < s.length; i++) {
382
326
  const c = s[i];
383
- if (inStr) {
384
- if (esc)
385
- esc = false;
386
- else if (c === '\\')
387
- esc = true;
388
- else if (c === '"')
389
- inStr = false;
390
- continue;
327
+ if (c === '"') {
328
+ const end = jsonStringEnd({ s, start: i });
329
+ if (end === -1)
330
+ return -1;
331
+ i = end - 1;
391
332
  }
392
- if (c === '"')
393
- inStr = true;
394
- else if (c === '{')
333
+ else if (c === '{') {
395
334
  depth++;
396
- else if (c === '}') {
397
- depth--;
398
- if (depth === 0)
399
- return s.slice(start, i + 1);
335
+ }
336
+ else if (c === '}' && --depth === 0) {
337
+ return i + 1;
400
338
  }
401
339
  }
402
- return null;
340
+ return -1;
403
341
  }
404
342
  //# sourceMappingURL=scan.js.map
@@ -77,6 +77,16 @@ function isAnswerCard(scan, name, data) {
77
77
  const surfaceId = a2uiSurfaceIdOf({ message: data });
78
78
  return surfaceId === undefined || !scan.displayOnlySurfaces.has(surfaceId);
79
79
  }
80
+ function scanStatus({ scan, event, index }, _optional = {}) {
81
+ if (event.code === 'api_retry') {
82
+ scan.retry = { index, data: event.data };
83
+ }
84
+ else if (event.code === 'tool_progress') {
85
+ const seconds = numberField(event.data, 'elapsedSeconds');
86
+ if (seconds !== undefined)
87
+ scan.progress = { index, seconds };
88
+ }
89
+ }
80
90
  function scanEvent(scan, event, index) {
81
91
  if (isVisible(event)) {
82
92
  scan.lastVisible = { index, kind: event.kind };
@@ -89,17 +99,60 @@ function scanEvent(scan, event, index) {
89
99
  else if (event.kind === 'tool_result') {
90
100
  scan.open = scan.open.filter((tool) => tool.id !== event.toolUseId);
91
101
  }
92
- else if (event.kind === 'ext' && isAnswerCard(scan, event.name, event.data)) {
93
- scan.lastCardIndex = index;
102
+ else if (event.kind === 'ext') {
103
+ if (isAnswerCard(scan, event.name, event.data))
104
+ scan.lastCardIndex = index;
94
105
  }
95
- else if (event.kind === 'status' && event.code === 'api_retry') {
96
- scan.retry = { index, data: event.data };
106
+ else if (event.kind === 'status') {
107
+ scanStatus({ scan, event, index });
97
108
  }
98
- else if (event.kind === 'status' && event.code === 'tool_progress') {
99
- const seconds = numberField(event.data, 'elapsedSeconds');
100
- if (seconds !== undefined)
101
- scan.progress = { index, seconds };
109
+ }
110
+ /** `{ a: 1, b: undefined }` → `{ a: 1 }`, so optional activity fields are absent rather than undefined. */
111
+ function definedFields({ fields }, _optional = {}) {
112
+ return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
113
+ }
114
+ function retryingActivity({ data }, _optional = {}) {
115
+ return {
116
+ kind: 'retrying',
117
+ ...definedFields({
118
+ fields: {
119
+ attempt: numberField(data, 'attempt'),
120
+ maxAttempts: numberField(data, 'maxAttempts'),
121
+ service: stringField(data, 'service'),
122
+ },
123
+ }),
124
+ };
125
+ }
126
+ /** The signal while a tool call is open: a card waiting on the person, tool discovery, or the tool itself. */
127
+ function openToolSignal({ scan, newestOpen }, _optional = {}) {
128
+ if (scan.lastCardIndex > newestOpen.index) {
129
+ return { activity: { kind: 'awaiting-answer' }, key: `card:${scan.lastCardIndex}` };
102
130
  }
131
+ const key = `tool:${newestOpen.id}`;
132
+ if (DISCOVERY_TOOL_NAMES.has(newestOpen.name))
133
+ return { activity: { kind: 'finding-tool' }, key };
134
+ const reported = scan.progress && scan.progress.index > newestOpen.index ? scan.progress.seconds : undefined;
135
+ return {
136
+ activity: {
137
+ kind: 'running-tool',
138
+ tool: humanizeToolName({ name: newestOpen.name }, { input: newestOpen.input }),
139
+ ...definedFields({ fields: { reportedSeconds: reported } }),
140
+ },
141
+ key,
142
+ };
143
+ }
144
+ function latestSignal({ scan }, _optional = {}) {
145
+ const lastVisibleIndex = scan.lastVisible?.index ?? -1;
146
+ if (scan.retry && scan.retry.index > lastVisibleIndex) {
147
+ return { activity: retryingActivity({ data: scan.retry.data }), key: `retry:${lastVisibleIndex}` };
148
+ }
149
+ const newestOpen = scan.open[scan.open.length - 1];
150
+ if (newestOpen)
151
+ return openToolSignal({ scan, newestOpen });
152
+ if (scan.lastVisible?.kind === 'text') {
153
+ return { activity: { kind: 'writing' }, key: `writing:${lastVisibleIndex}` };
154
+ }
155
+ return { activity: { kind: 'thinking' }, key: `thinking:${lastVisibleIndex}` };
103
156
  }
104
157
  /**
105
158
  * The latest signal in a running turn's events.
@@ -114,47 +167,7 @@ function scanEvent(scan, event, index) {
114
167
  export function deriveRunActivity({ events }, { isAnsweredCard } = {}) {
115
168
  const scan = { open: [], lastVisible: null, lastCardIndex: -1, retry: null, progress: null, visibleCount: 0, displayOnlySurfaces: new Set(), isAnsweredCard };
116
169
  (events ?? []).forEach((event, index) => scanEvent(scan, event, index));
117
- const visibleCount = scan.visibleCount;
118
- const lastVisibleIndex = scan.lastVisible?.index ?? -1;
119
- if (scan.retry && scan.retry.index > lastVisibleIndex) {
120
- const data = scan.retry.data;
121
- const attempt = numberField(data, 'attempt');
122
- const maxAttempts = numberField(data, 'maxAttempts');
123
- const service = stringField(data, 'service');
124
- return {
125
- activity: {
126
- kind: 'retrying',
127
- ...(attempt !== undefined ? { attempt } : {}),
128
- ...(maxAttempts !== undefined ? { maxAttempts } : {}),
129
- ...(service !== undefined ? { service } : {}),
130
- },
131
- key: `retry:${lastVisibleIndex}`,
132
- visibleCount,
133
- };
134
- }
135
- const newestOpen = scan.open[scan.open.length - 1];
136
- if (newestOpen) {
137
- if (scan.lastCardIndex > newestOpen.index) {
138
- return { activity: { kind: 'awaiting-answer' }, key: `card:${scan.lastCardIndex}`, visibleCount };
139
- }
140
- if (DISCOVERY_TOOL_NAMES.has(newestOpen.name)) {
141
- return { activity: { kind: 'finding-tool' }, key: `tool:${newestOpen.id}`, visibleCount };
142
- }
143
- const reported = scan.progress && scan.progress.index > newestOpen.index ? scan.progress.seconds : undefined;
144
- return {
145
- activity: {
146
- kind: 'running-tool',
147
- tool: humanizeToolName({ name: newestOpen.name }, { input: newestOpen.input }),
148
- ...(reported !== undefined ? { reportedSeconds: reported } : {}),
149
- },
150
- key: `tool:${newestOpen.id}`,
151
- visibleCount,
152
- };
153
- }
154
- if (scan.lastVisible?.kind === 'text') {
155
- return { activity: { kind: 'writing' }, key: `writing:${lastVisibleIndex}`, visibleCount };
156
- }
157
- return { activity: { kind: 'thinking' }, key: `thinking:${lastVisibleIndex}`, visibleCount };
170
+ return { ...latestSignal({ scan }), visibleCount: scan.visibleCount };
158
171
  }
159
172
  /**
160
173
  * Whether the run is waiting on the person: a card (form, confirm, sign-in) arrived while its tool
@@ -83,6 +83,12 @@ export declare function parseUsageEvent({ payload }: {
83
83
  * cannot fall through to `default` — see below). A lookup table would have to carry those as a
84
84
  * parallel structure, one step removed from the code they explain. Left as a `switch`, documented
85
85
  * here rather than only in this session's report.
86
+ *
87
+ * Superseded (2026-10-20, code-health wave 1; bar is ≤10/≤10): the flat switch is now TWO switches —
88
+ * the chat-core event types here, and the UI-envelope / dropped / `ext` types in
89
+ * {@link translateEnvelopePayload} below — so each scope stays under the cyclomatic bar while BOTH
90
+ * keep what the paragraph above argued for: every case is still a `case` with its own comment
91
+ * attached, not an entry in a parallel lookup table.
86
92
  */
87
93
  /** Reduce one daemon payload; preserve UI envelopes and drop duplicate input fragments. */
88
94
  export declare function translateRunAgentPayload({ payload }: {
@@ -280,7 +286,10 @@ export declare function runContentFromEvents({ events }: {
280
286
  * onto the screen; saved, it only repeats the event wrapper per token. Used by every saver (the server
281
287
  * finalizer and the chat PUT route the browser saves through), so both write the same shape.
282
288
  */
283
- /** Compact adjacent text/thinking deltas for durable storage. */
289
+ /** Compact adjacent text/thinking deltas and keep the first occurrence of each tool-call ID.
290
+ * Replayed tool calls must not become duplicate history rows. Other events retain their order.
291
+ * @complexity O(n + total text length) time and O(n) space for n events.
292
+ */
284
293
  export declare function runEventsForSave({ events }: {
285
294
  events: readonly AgentEvent[];
286
295
  }, _options?: Record<string, never>): AgentEvent[];