@jini-ai/chat 0.6.6 → 0.6.10

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 (160) hide show
  1. package/CHANGELOG.md +129 -1
  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 +187 -98
  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 +31 -6
  21. package/dist/core/durable-runs/ledger-contracts.d.ts +2 -1
  22. package/dist/core/durable-runs/ports.d.ts +6 -2
  23. package/dist/core/index.d.ts +2 -0
  24. package/dist/core/index.js +2 -0
  25. package/dist/core/messages.d.ts +2 -0
  26. package/dist/core/partial-json.d.ts +6 -7
  27. package/dist/core/partial-json.js +81 -47
  28. package/dist/core/persistence/title.js +4 -2
  29. package/dist/core/question-answer.d.ts +23 -0
  30. package/dist/core/question-answer.js +66 -0
  31. package/dist/core/question-form/format.d.ts +6 -6
  32. package/dist/core/question-form/format.js +16 -17
  33. package/dist/core/question-form/normalize.d.ts +18 -0
  34. package/dist/core/question-form/normalize.js +112 -122
  35. package/dist/core/question-form/scan.js +141 -203
  36. package/dist/core/run-activity.js +62 -49
  37. package/dist/core/run-events/entry.d.ts +10 -1
  38. package/dist/core/run-events/entry.js +117 -87
  39. package/dist/core/todos.d.ts +1 -1
  40. package/dist/core/todos.js +60 -85
  41. package/dist/core/tool-events.js +14 -12
  42. package/dist/core/transcript.js +62 -16
  43. package/dist/core/turn-usage.js +54 -32
  44. package/dist/core/user-text-redaction.js +3 -0
  45. package/dist/core/util/manifest.js +77 -82
  46. package/dist/core/util/markdown-context.js +51 -53
  47. package/dist/core/util/parser.js +136 -144
  48. package/dist/core/util/pointer.js +16 -16
  49. package/dist/core/util/strip.js +57 -67
  50. package/dist/core/util/validate.js +15 -17
  51. package/dist/embed-widget/browser/session-store.js +21 -12
  52. package/dist/embed-widget/transports/fetch-sse.js +108 -95
  53. package/dist/embed-widget/vanilla/index.js +81 -70
  54. package/dist/react/agent-bridge/frontend-session-bridge.js +80 -52
  55. package/dist/react/components/AttachmentPreviewModal.js +25 -1
  56. package/dist/react/components/ChatSettingsDialog.d.ts +13 -0
  57. package/dist/react/components/ChatSettingsDialog.hooks.d.ts +115 -0
  58. package/dist/react/components/ChatSettingsDialog.hooks.js +40 -0
  59. package/dist/react/components/ChatSettingsDialog.js +72 -0
  60. package/dist/react/components/ChatSettingsSegmented.d.ts +18 -0
  61. package/dist/react/components/ChatSettingsSegmented.js +11 -0
  62. package/dist/react/components/Composer.d.ts +5 -1
  63. package/dist/react/components/Composer.js +126 -82
  64. package/dist/react/components/ConversationList.js +25 -39
  65. package/dist/react/components/ConversationTitleEditor.d.ts +27 -0
  66. package/dist/react/components/ConversationTitleEditor.js +54 -0
  67. package/dist/react/components/Icon.d.ts +1 -1
  68. package/dist/react/components/Icon.js +25 -45
  69. package/dist/react/components/Markdown.js +134 -123
  70. package/dist/react/components/McpUiSurfaceCard.d.ts +2 -2
  71. package/dist/react/components/McpUiSurfaceCard.js +30 -9
  72. package/dist/react/components/MessageList.d.ts +13 -13
  73. package/dist/react/components/MessageList.hooks.d.ts +6 -1
  74. package/dist/react/components/MessageList.hooks.js +19 -3
  75. package/dist/react/components/MessageList.js +34 -10
  76. package/dist/react/components/MessageRow.d.ts +1 -1
  77. package/dist/react/components/MessageRow.js +75 -47
  78. package/dist/react/components/MessageRow.usage.js +16 -11
  79. package/dist/react/components/MidRunUserMessage.js +16 -3
  80. package/dist/react/components/QuestionForm.js +148 -76
  81. package/dist/react/components/QuestionsPanel.js +1 -1
  82. package/dist/react/components/TodoCard.js +12 -6
  83. package/dist/react/components/ToolCallGroup.d.ts +4 -3
  84. package/dist/react/components/ToolCallGroup.js +7 -5
  85. package/dist/react/components/ToolCard.js +47 -40
  86. package/dist/react/components/composer-discovery.js +6 -1
  87. package/dist/react/components/mcp-ui-surface-state.d.ts +6 -11
  88. package/dist/react/components/mcp-ui-surface-state.js +23 -8
  89. package/dist/react/ext-event-renderer-registry.d.ts +8 -0
  90. package/dist/react/ext-event-renderer-registry.js +5 -2
  91. package/dist/react/features/chat-pane/components/AgentPluginUsage.d.ts +7 -0
  92. package/dist/react/features/chat-pane/components/AgentPluginUsage.js +45 -0
  93. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +55 -19
  94. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  95. package/dist/react/features/chat-pane/components/ChatPane.js +192 -74
  96. package/dist/react/features/chat-pane/components/RuntimeDropUp.d.ts +29 -0
  97. package/dist/react/features/chat-pane/components/RuntimeDropUp.js +45 -0
  98. package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +80 -47
  99. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +44 -40
  100. package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +6 -6
  101. package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -8
  102. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +3 -3
  103. package/dist/react/features/chat-pane/create-typed-answer-poster.js +3 -3
  104. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.d.ts +6 -1
  105. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +11 -5
  106. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +12 -7
  107. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +340 -149
  108. package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +7 -6
  109. package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +28 -10
  110. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +3 -3
  111. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +60 -12
  112. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.d.ts +49 -0
  113. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +143 -0
  114. package/dist/react/features/chat-pane/model-options.d.ts +14 -0
  115. package/dist/react/features/chat-pane/model-options.js +45 -25
  116. package/dist/react/features/chat-pane/rules.d.ts +8 -9
  117. package/dist/react/features/chat-pane/rules.js +84 -46
  118. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  119. package/dist/react/features/chat-pane/styles.js +152 -36
  120. package/dist/react/features/chat-pane/types.d.ts +19 -9
  121. package/dist/react/hooks/assistant-chats.js +12 -5
  122. package/dist/react/hooks/composer-draft-cache.js +116 -118
  123. package/dist/react/hooks/composer-history-storage.js +5 -5
  124. package/dist/react/hooks/fab-position.js +7 -4
  125. package/dist/react/hooks/useAnsweredCards.d.ts +21 -4
  126. package/dist/react/hooks/useAnsweredCards.js +38 -11
  127. package/dist/react/hooks/useArtifactStream.js +28 -22
  128. package/dist/react/hooks/useComposer.d.ts +20 -0
  129. package/dist/react/hooks/useComposer.js +22 -9
  130. package/dist/react/hooks/useComposerHistory.hooks.d.ts +6 -1
  131. package/dist/react/hooks/useComposerHistory.hooks.js +39 -19
  132. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +24 -14
  133. package/dist/react/hooks/useConversation.d.ts +7 -0
  134. package/dist/react/hooks/useConversation.js +23 -10
  135. package/dist/react/hooks/useExtEventGroups.js +39 -39
  136. package/dist/react/hooks/useQuestionForms.js +21 -26
  137. package/dist/react/hooks/useRunStream.js +10 -2
  138. package/dist/react/index.d.ts +7 -1
  139. package/dist/react/index.js +9 -0
  140. package/dist/react/message-blocks.d.ts +6 -4
  141. package/dist/react/message-blocks.js +94 -72
  142. package/dist/react/slots.d.ts +2 -2
  143. package/dist/react/tool-call-display.d.ts +12 -6
  144. package/dist/react/tool-call-display.js +53 -24
  145. package/dist/server/durable-runs/recover.js +132 -80
  146. package/dist/server/run-finalizer/entry.js +25 -15
  147. package/dist/store/legacy/sqlite/conversations.d.ts +11 -11
  148. package/dist/store/legacy/sqlite/conversations.js +69 -98
  149. package/dist/store/legacy/sqlite/messages.d.ts +10 -2
  150. package/dist/store/legacy/sqlite/messages.js +113 -71
  151. package/dist/store/runtime/chat-history-store.js +9 -3
  152. package/dist/store/runtime/durable-run-store.d.ts +2 -2
  153. package/dist/store/runtime/durable-run-store.js +165 -117
  154. package/dist/store/runtime/history-tools.js +118 -82
  155. package/dist/store/runtime/run-ledger.d.ts +4 -1
  156. package/dist/store/runtime/run-ledger.js +56 -16
  157. package/dist/store/sql/cursor.js +38 -18
  158. package/dist/store/sql/store.d.ts +18 -0
  159. package/dist/store/sql/store.js +46 -15
  160. package/package.json +18 -16
@@ -11,6 +11,8 @@ import { computeSkipRanges, FENCE_OPEN_RE, isRealArtifactOpenAt, rangeContains }
11
11
  import { parseQuotedAttrs } from './markup-attributes.js';
12
12
  const OPEN_PREFIX = '<artifact';
13
13
  const CLOSE_TAG = '</artifact>';
14
+ const NO_OPEN = { kind: 'none' };
15
+ const SKIP = { kind: 'skip' };
14
16
  // Scan the buffer for `<artifact …>` while skipping any positions that a
15
17
  // Markdown renderer would render as a fenced code block or inline code span
16
18
  // — see markdown-context.ts for the shared classification used by both this
@@ -23,107 +25,100 @@ const CLOSE_TAG = '</artifact>';
23
25
  // * Unmatched opening backtick after the last \n → hold back from it; a
24
26
  // future chunk may turn it into an inline code span.
25
27
  function findOpenTag(buffer) {
26
- const len = buffer.length;
27
28
  const { ranges, unclosedFenceStart } = computeSkipRanges({ buffer: buffer });
29
+ const ctx = { buffer, ranges };
28
30
  // Pass 1: scan for the earliest *complete* real `<artifact …>` open outside
29
31
  // any skip range, before any hold-back decision — otherwise a stray
30
32
  // backtick or fence-opener prefix on a tail line would suppress an
31
33
  // already self-contained artifact earlier in the buffer.
32
- let earliestPartialOpen = -1;
33
- let from = 0;
34
- while (from < len) {
35
- const idx = buffer.indexOf(OPEN_PREFIX, from);
36
- if (idx === -1)
37
- break;
38
- if (rangeContains({ ranges: ranges, p: idx })) {
39
- from = idx + OPEN_PREFIX.length;
40
- continue;
41
- }
42
- if (unclosedFenceStart !== null && idx >= unclosedFenceStart) {
43
- // Anything past an unclosed fence opener is inside a code block that
44
- // will close in a later chunk; treat as skip range, not a real tag.
45
- break;
46
- }
47
- const after = idx + OPEN_PREFIX.length;
48
- const next = buffer.charAt(after);
49
- if (next === '') {
50
- // `<artifact` at very end of buffer — could become real with the next
51
- // chunk. Remember the earliest one and keep looking for a complete tag.
52
- if (earliestPartialOpen === -1)
53
- earliestPartialOpen = idx;
54
- break;
55
- }
56
- if (!isRealArtifactOpenAt({ content: buffer, idx: idx })) {
57
- from = after; // e.g. "<artifactual" — not a real open, keep scanning
58
- continue;
59
- }
60
- let j = after;
61
- let quote = null;
62
- while (j < len) {
63
- const c = buffer.charAt(j);
64
- if (quote !== null) {
65
- if (c === quote)
66
- quote = null;
67
- }
68
- else if (c === '"' || c === "'") {
69
- quote = c;
70
- }
71
- else if (c === '>') {
72
- return { kind: 'complete', start: idx, end: j + 1, attrs: buffer.slice(after, j) };
73
- }
74
- j++;
75
- }
76
- // Ran out of buffer before the closing `>` arrived — an open tag mid-stream.
77
- if (earliestPartialOpen === -1)
78
- earliestPartialOpen = idx;
79
- break;
80
- }
34
+ const open = scanForOpen(ctx, { unclosedFenceStart });
35
+ if (open.kind === 'complete')
36
+ return open;
81
37
  // Pass 2: no complete open found. Decide whether to hold back, and if so,
82
38
  // from which position. Earliest hold-back wins so the text-flush boundary
83
39
  // never crosses something that might still resolve into a tag/fence/span.
84
- let holdback = -1;
85
- const note = (pos) => {
86
- if (pos !== null && pos !== -1 && (holdback === -1 || pos < holdback))
87
- holdback = pos;
88
- };
89
- note(earliestPartialOpen);
90
- note(unclosedFenceStart);
91
- const lastNl = buffer.lastIndexOf('\n');
92
- if (lastNl < len - 1) {
93
- const tailLineStart = lastNl + 1;
94
- const tail = buffer.slice(tailLineStart);
95
- if (FENCE_OPEN_RE.test(tail) || /^`{1,2}$/.test(tail)) {
96
- note(tailLineStart);
40
+ const holdbacks = [
41
+ open.kind === 'partial' ? open.start : -1,
42
+ unclosedFenceStart ?? -1,
43
+ tailFenceHoldback(ctx),
44
+ unmatchedBacktickHoldback(ctx),
45
+ tailPrefixHoldback(ctx),
46
+ ].filter((pos) => pos !== -1);
47
+ if (holdbacks.length === 0)
48
+ return NO_OPEN;
49
+ return { kind: 'partial', start: Math.min(...holdbacks) };
50
+ }
51
+ function scanForOpen(ctx, { unclosedFenceStart }) {
52
+ let idx = ctx.buffer.indexOf(OPEN_PREFIX);
53
+ while (idx !== -1) {
54
+ const found = classifyOpenAt(ctx, { idx, unclosedFenceStart });
55
+ if (found.kind !== 'skip')
56
+ return found;
57
+ idx = ctx.buffer.indexOf(OPEN_PREFIX, idx + OPEN_PREFIX.length);
58
+ }
59
+ return NO_OPEN;
60
+ }
61
+ function classifyOpenAt({ buffer, ranges }, { idx, unclosedFenceStart }) {
62
+ if (rangeContains({ ranges: ranges, p: idx }))
63
+ return SKIP;
64
+ // Anything past an unclosed fence opener is inside a code block that
65
+ // will close in a later chunk; treat as skip range, not a real tag.
66
+ if (unclosedFenceStart !== null && idx >= unclosedFenceStart)
67
+ return NO_OPEN;
68
+ const after = idx + OPEN_PREFIX.length;
69
+ // `<artifact` at very end of buffer — could become real with the next
70
+ // chunk. Remember the earliest one and keep looking for a complete tag.
71
+ if (after === buffer.length)
72
+ return { kind: 'partial', start: idx };
73
+ if (!isRealArtifactOpenAt({ content: buffer, idx: idx }))
74
+ return SKIP; // e.g. "<artifactual" — not a real open, keep scanning
75
+ const close = findTagEnd({ buffer, from: after });
76
+ // Ran out of buffer before the closing `>` arrived — an open tag mid-stream.
77
+ if (close === -1)
78
+ return { kind: 'partial', start: idx };
79
+ return { kind: 'complete', start: idx, end: close + 1, attrs: buffer.slice(after, close) };
80
+ }
81
+ /** Index of the open tag's closing `>`, ignoring any `>` inside a quoted attribute value; -1 if not yet arrived. */
82
+ function findTagEnd({ buffer, from }) {
83
+ let quote = null;
84
+ for (let j = from; j < buffer.length; j++) {
85
+ const c = buffer.charAt(j);
86
+ if (quote !== null) {
87
+ if (c === quote)
88
+ quote = null;
89
+ }
90
+ else if (c === '"' || c === "'") {
91
+ quote = c;
92
+ }
93
+ else if (c === '>') {
94
+ return j;
97
95
  }
98
96
  }
97
+ return -1;
98
+ }
99
+ /** Start of an unterminated tail line that could still resolve into a fence delimiter; -1 otherwise. */
100
+ function tailFenceHoldback({ buffer }) {
101
+ const tailLineStart = buffer.lastIndexOf('\n') + 1;
102
+ const tail = buffer.slice(tailLineStart);
103
+ return FENCE_OPEN_RE.test(tail) || /^`{1,2}$/.test(tail) ? tailLineStart : -1;
104
+ }
105
+ /** Position of an unmatched opening backtick after the last `\n` (outside skip ranges); -1 otherwise. */
106
+ function unmatchedBacktickHoldback({ buffer, ranges }) {
99
107
  let firstUnmatched = -1;
100
- let parity = 0;
101
- for (let k = lastNl + 1; k < len; k++) {
102
- if (buffer.charAt(k) !== '`')
108
+ for (let k = buffer.lastIndexOf('\n') + 1; k < buffer.length; k++) {
109
+ if (buffer.charAt(k) !== '`' || rangeContains({ ranges: ranges, p: k }))
103
110
  continue;
104
- if (rangeContains({ ranges: ranges, p: k }))
105
- continue;
106
- if (parity === 0) {
107
- firstUnmatched = k;
108
- parity = 1;
109
- }
110
- else {
111
- firstUnmatched = -1;
112
- parity = 0;
113
- }
111
+ firstUnmatched = firstUnmatched === -1 ? k : -1;
114
112
  }
115
- note(firstUnmatched);
116
- // Strict prefix at the tail (e.g. "<art") — hold back.
113
+ return firstUnmatched;
114
+ }
115
+ // Strict prefix at the tail (e.g. "<art") — hold back.
116
+ function tailPrefixHoldback({ buffer, ranges }) {
117
117
  const tailLt = buffer.lastIndexOf('<');
118
- if (tailLt !== -1 && !rangeContains({ ranges: ranges, p: tailLt })) {
119
- const slice = buffer.slice(tailLt);
120
- if (OPEN_PREFIX.startsWith(slice) && slice.length < OPEN_PREFIX.length) {
121
- note(tailLt);
122
- }
123
- }
124
- if (holdback !== -1)
125
- return { kind: 'partial', start: holdback };
126
- return { kind: 'none' };
118
+ if (tailLt === -1 || rangeContains({ ranges: ranges, p: tailLt }))
119
+ return -1;
120
+ const slice = buffer.slice(tailLt);
121
+ return OPEN_PREFIX.startsWith(slice) && slice.length < OPEN_PREFIX.length ? tailLt : -1;
127
122
  }
128
123
  /**
129
124
  * Create a fresh streaming artifact parser. Call `feed({ delta })` for each
@@ -146,64 +141,61 @@ export function createArtifactParser(_requiredArgs) {
146
141
  };
147
142
  function* feed({ delta }) {
148
143
  state.buffer += delta;
149
- while (state.buffer.length > 0) {
150
- if (!state.inside) {
151
- const open = findOpenTag(state.buffer);
152
- if (open.kind === 'none') {
153
- yield { type: 'text', delta: state.buffer };
154
- state.buffer = '';
155
- return;
156
- }
157
- if (open.kind === 'partial') {
158
- if (open.start > 0) {
159
- yield { type: 'text', delta: state.buffer.slice(0, open.start) };
160
- state.buffer = state.buffer.slice(open.start);
161
- }
162
- return;
163
- }
164
- if (open.start > 0) {
165
- yield { type: 'text', delta: state.buffer.slice(0, open.start) };
166
- }
167
- const attrs = parseQuotedAttrs({ raw: open.attrs });
168
- state.inside = true;
169
- state.identifier = attrs['identifier'] ?? '';
170
- state.artifactType = attrs['type'] ?? '';
171
- state.title = attrs['title'] ?? '';
172
- state.content = '';
173
- state.buffer = state.buffer.slice(open.end);
174
- yield {
175
- type: 'artifact:start',
176
- identifier: state.identifier,
177
- artifactType: state.artifactType,
178
- title: state.title,
179
- };
180
- continue;
181
- }
182
- const closeIdx = state.buffer.indexOf(CLOSE_TAG);
183
- if (closeIdx === -1) {
184
- // Hold back enough bytes to detect a partial close tag at the tail.
185
- const flushUpTo = state.buffer.length - (CLOSE_TAG.length - 1);
186
- if (flushUpTo > 0) {
187
- const chunk = state.buffer.slice(0, flushUpTo);
188
- state.content += chunk;
189
- state.buffer = state.buffer.slice(flushUpTo);
190
- yield { type: 'artifact:chunk', identifier: state.identifier, delta: chunk };
191
- }
192
- return;
193
- }
194
- const finalChunk = state.buffer.slice(0, closeIdx);
195
- if (finalChunk.length > 0) {
196
- state.content += finalChunk;
197
- yield { type: 'artifact:chunk', identifier: state.identifier, delta: finalChunk };
198
- }
199
- yield { type: 'artifact:end', identifier: state.identifier, fullContent: state.content };
200
- state.buffer = state.buffer.slice(closeIdx + CLOSE_TAG.length);
201
- state.inside = false;
202
- state.identifier = '';
203
- state.artifactType = '';
204
- state.title = '';
205
- state.content = '';
144
+ let progressed = true;
145
+ while (progressed && state.buffer.length > 0) {
146
+ progressed = state.inside ? yield* drainInside() : yield* drainOutside();
147
+ }
148
+ }
149
+ /** Emit text up to the next open tag; `true` when an artifact was opened and draining should continue. */
150
+ function* drainOutside() {
151
+ const open = findOpenTag(state.buffer);
152
+ const textEnd = open.kind === 'none' ? state.buffer.length : open.start;
153
+ if (textEnd > 0) {
154
+ yield { type: 'text', delta: state.buffer.slice(0, textEnd) };
155
+ state.buffer = state.buffer.slice(textEnd);
156
+ }
157
+ if (open.kind !== 'complete')
158
+ return false;
159
+ const attrs = parseQuotedAttrs({ raw: open.attrs });
160
+ Object.assign(state, {
161
+ inside: true,
162
+ identifier: attrs['identifier'] ?? '',
163
+ artifactType: attrs['type'] ?? '',
164
+ title: attrs['title'] ?? '',
165
+ content: '',
166
+ buffer: state.buffer.slice(open.end - open.start),
167
+ });
168
+ yield {
169
+ type: 'artifact:start',
170
+ identifier: state.identifier,
171
+ artifactType: state.artifactType,
172
+ title: state.title,
173
+ };
174
+ return true;
175
+ }
176
+ /** Emit body up to the close tag; `true` when the artifact closed and draining should continue. */
177
+ function* drainInside() {
178
+ const closeIdx = state.buffer.indexOf(CLOSE_TAG);
179
+ // Hold back enough bytes to detect a partial close tag at the tail.
180
+ const chunkEnd = closeIdx === -1 ? state.buffer.length - (CLOSE_TAG.length - 1) : closeIdx;
181
+ if (chunkEnd > 0) {
182
+ const chunk = state.buffer.slice(0, chunkEnd);
183
+ state.content += chunk;
184
+ state.buffer = state.buffer.slice(chunkEnd);
185
+ yield { type: 'artifact:chunk', identifier: state.identifier, delta: chunk };
206
186
  }
187
+ if (closeIdx === -1)
188
+ return false;
189
+ yield { type: 'artifact:end', identifier: state.identifier, fullContent: state.content };
190
+ Object.assign(state, {
191
+ inside: false,
192
+ identifier: '',
193
+ artifactType: '',
194
+ title: '',
195
+ content: '',
196
+ buffer: state.buffer.slice(CLOSE_TAG.length),
197
+ });
198
+ return true;
207
199
  }
208
200
  function* flush(_requiredArgs) {
209
201
  if (state.inside) {
@@ -13,29 +13,29 @@ const HTML_TAG_RE = /<[^>]+>/g;
13
13
  * @complexity O(n + m) in `content.length` plus `projectFiles.length`.
14
14
  */
15
15
  export function resolveHtmlPointerArtifactTarget(input) {
16
- const pointerText = visiblePointerText(input.content);
17
- if (!pointerText || utf8ByteLength(pointerText) > MAX_POINTER_TEXT_BYTES) {
16
+ const target = extractPointerTarget(input.content);
17
+ if (target === null || target === input.candidateFileName || !isSafeHtmlTarget(target))
18
18
  return null;
19
- }
20
- const match = POINTER_TARGET_RE.exec(pointerText);
21
- if (!match)
19
+ return resolveAgainstProjectFiles({ target, candidateFileName: input.candidateFileName, projectFiles: input.projectFiles });
20
+ }
21
+ /** The normalized file name a bare short pointer reply names, or `null` when `content` isn't one. */
22
+ function extractPointerTarget(content) {
23
+ const pointerText = visiblePointerText(content);
24
+ if (!pointerText || utf8ByteLength(pointerText) > MAX_POINTER_TEXT_BYTES)
22
25
  return null;
26
+ const match = POINTER_TARGET_RE.exec(pointerText);
23
27
  // The target group is a mandatory (non-optional) part of the pattern, not
24
28
  // inside an alternation, so it's always defined when `match` is non-null;
25
29
  // the assertion just satisfies `noUncheckedIndexedAccess`.
26
- const target = normalizeTarget(match[1]);
27
- if (!target || target === input.candidateFileName || !isSafeHtmlTarget(target)) {
28
- return null;
29
- }
30
- const projectFileNames = input.projectFiles.map((file) => file.path || file.name).filter((name) => name.toLowerCase().endsWith('.html') || name.toLowerCase().endsWith('.htm'));
30
+ return match ? normalizeTarget(match[1]) : null;
31
+ }
32
+ /** A full-path match wins; otherwise exactly one basename match that isn't the candidate itself. */
33
+ function resolveAgainstProjectFiles({ target, candidateFileName, projectFiles }) {
34
+ const projectFileNames = projectFiles.map((file) => file.path || file.name).filter((name) => /\.html?$/i.test(name));
31
35
  if (projectFileNames.includes(target))
32
36
  return target;
33
- const basenameMatches = projectFileNames.filter((name) => basename(name) === target);
34
- const [basenameMatch] = basenameMatches;
35
- if (basenameMatches.length === 1 && basenameMatch && basenameMatch !== input.candidateFileName) {
36
- return basenameMatch;
37
- }
38
- return null;
37
+ const [only, ...others] = projectFileNames.filter((name) => basename(name) === target);
38
+ return only !== undefined && others.length === 0 && only !== candidateFileName ? only : null;
39
39
  }
40
40
  function visiblePointerText(content) {
41
41
  const stripped = content
@@ -45,6 +45,29 @@ function findRealOpen(content, fromIndex, ranges) {
45
45
  return idx;
46
46
  }
47
47
  }
48
+ // For complete (non-streaming) content, an unclosed fence renders as a code
49
+ // block extending to end of input — the stripper mirrors that, otherwise a
50
+ // literal `<artifact …>` tucked into a trailing code example (no trailing
51
+ // newline) gets treated as a real protocol tag and eaten.
52
+ function completeContentSkipRanges({ content }) {
53
+ const { ranges, unclosedFenceStart } = computeSkipRanges({ buffer: content });
54
+ return unclosedFenceStart !== null ? [...ranges, [unclosedFenceStart, content.length]] : ranges;
55
+ }
56
+ /**
57
+ * The first complete real `<artifact …>…</artifact>` block at or after
58
+ * `fromIndex`, or `null` when there is no real open, the open tag never
59
+ * terminates with `>`, or no real close follows it.
60
+ */
61
+ function findCompleteArtifactBlock({ content, ranges }, { fromIndex = 0 } = {}) {
62
+ const open = findRealOpen(content, fromIndex, ranges);
63
+ if (open === -1)
64
+ return null;
65
+ const openEnd = content.indexOf('>', open);
66
+ if (openEnd === -1)
67
+ return null;
68
+ const close = findUnskipped(content, CLOSE, openEnd, ranges);
69
+ return close === -1 ? null : { open, openEnd, close };
70
+ }
48
71
  /**
49
72
  * Remove the first real `<artifact …>…</artifact>` block from `content`.
50
73
  *
@@ -62,22 +85,10 @@ function findRealOpen(content, fromIndex, ranges) {
62
85
  * pair of linear scans for the open/close tags.
63
86
  */
64
87
  export function stripArtifact({ content }) {
65
- const { ranges: baseRanges, unclosedFenceStart } = computeSkipRanges({ buffer: content });
66
- // For complete (non-streaming) content, an unclosed fence renders as a code
67
- // block extending to end of input — the stripper mirrors that, otherwise a
68
- // literal `<artifact …>` tucked into a trailing code example (no trailing
69
- // newline) gets treated as a real protocol tag and eaten.
70
- const ranges = unclosedFenceStart !== null ? [...baseRanges, [unclosedFenceStart, content.length]] : baseRanges;
71
- const open = findRealOpen(content, 0, ranges);
72
- if (open === -1)
88
+ const block = findCompleteArtifactBlock({ content, ranges: completeContentSkipRanges({ content }) });
89
+ if (!block)
73
90
  return content;
74
- const closeTag = content.indexOf('>', open);
75
- if (closeTag === -1)
76
- return content;
77
- const end = findUnskipped(content, CLOSE, closeTag, ranges);
78
- if (end === -1)
79
- return content;
80
- return (content.slice(0, open) + content.slice(end + CLOSE.length)).trim();
91
+ return (content.slice(0, block.open) + content.slice(block.close + CLOSE.length)).trim();
81
92
  }
82
93
  function findSingleRecoverableHtmlFence(content) {
83
94
  HTML_FENCE_RE.lastIndex = 0;
@@ -95,31 +106,24 @@ function findSingleRecoverableHtmlFence(content) {
95
106
  return count === 1 ? recovered : null;
96
107
  }
97
108
  function findRecoverablePrecedingHtmlArtifact(sourceText) {
98
- const { ranges: baseRanges, unclosedFenceStart } = computeSkipRanges({ buffer: sourceText });
99
- const ranges = unclosedFenceStart !== null ? [...baseRanges, [unclosedFenceStart, sourceText.length]] : baseRanges;
109
+ const ranges = completeContentSkipRanges({ content: sourceText });
100
110
  let from = 0;
101
111
  // `findRealOpen` already returns -1 once `from` runs past `sourceText.length`
102
112
  // (it inherits the same `indexOf`-based bound safety), so this loop also
103
113
  // always exits through an inner return — no trailing fallback needed.
104
114
  for (;;) {
105
- const open = findRealOpen(sourceText, from, ranges);
106
- if (open === -1)
107
- return null;
108
- const closeTag = sourceText.indexOf('>', open);
109
- if (closeTag === -1)
110
- return null;
111
- const end = findUnskipped(sourceText, CLOSE, closeTag, ranges);
112
- if (end === -1)
115
+ const block = findCompleteArtifactBlock({ content: sourceText, ranges }, { fromIndex: from });
116
+ if (!block)
113
117
  return null;
114
- const attrs = parseQuotedAttrs({ raw: sourceText.slice(open, closeTag) });
118
+ const attrs = parseQuotedAttrs({ raw: sourceText.slice(block.open, block.openEnd) });
115
119
  const recovered = recoverHtmlArtifactFromPrecedingDocument({
116
- artifactHtml: sourceText.slice(closeTag + 1, end),
120
+ artifactHtml: sourceText.slice(block.openEnd + 1, block.close),
117
121
  identifier: attrs['identifier'],
118
122
  sourceText,
119
123
  });
120
124
  if (recovered)
121
125
  return recovered;
122
- from = end + CLOSE.length;
126
+ from = block.close + CLOSE.length;
123
127
  }
124
128
  }
125
129
  function stripRecoverablePrecedingHtml(content, sourceText) {
@@ -156,21 +160,20 @@ export function stripRecoveredHtmlFallbackForDisplay({ content }, { sourceText =
156
160
  return content;
157
161
  return `${content.slice(0, fence.start)}${content.slice(fence.end)}`.trim();
158
162
  }
163
+ // Checked in order; the first rule whose identifier suffix, exact type, or type substring matches wins.
164
+ const EXTENSION_RULES = [
165
+ { ext: '.tsx', typeIncludes: ['tsx'] },
166
+ { ext: '.jsx', typeIncludes: ['jsx', 'react'] },
167
+ { ext: '.css', typeIncludes: ['css'] },
168
+ { ext: '.svg', typeIncludes: ['svg'] },
169
+ { ext: '.md', typeIncludes: ['markdown'], typeEquals: 'md' },
170
+ ];
159
171
  // The on-disk extension a persist path would pick from the artifact's type/identifier.
160
172
  function artifactExtensionForAttrs(attrs) {
161
173
  const type = (attrs['type'] || '').toLowerCase();
162
174
  const identifier = (attrs['identifier'] || '').toLowerCase();
163
- if (type.includes('tsx') || identifier.endsWith('.tsx'))
164
- return '.tsx';
165
- if (type.includes('jsx') || type.includes('react') || identifier.endsWith('.jsx'))
166
- return '.jsx';
167
- if (type.includes('css') || identifier.endsWith('.css'))
168
- return '.css';
169
- if (type.includes('svg') || identifier.endsWith('.svg'))
170
- return '.svg';
171
- if (type.includes('markdown') || type === 'md' || identifier.endsWith('.md'))
172
- return '.md';
173
- return '.html';
175
+ const rule = EXTENSION_RULES.find((r) => identifier.endsWith(r.ext) || r.typeEquals === type || r.typeIncludes.some((hint) => type.includes(hint)));
176
+ return rule?.ext ?? '.html';
174
177
  }
175
178
  // The slug a persist path would derive the file name from.
176
179
  function artifactBaseNameForAttrs(attrs) {
@@ -234,38 +237,26 @@ export function summarizeArtifactsForTranscript({ content, persistedFiles }) {
234
237
  let result = '';
235
238
  let cursor = 0;
236
239
  // Recompute skip ranges per iteration against the remaining tail so indices
237
- // stay valid as we consume the string left to right.
238
- while (cursor <= content.length) {
240
+ // stay valid as we consume the string left to right. Each iteration either
241
+ // returns or consumes at least one whole block, so the loop terminates.
242
+ for (;;) {
239
243
  const tail = content.slice(cursor);
240
- const { ranges: baseRanges, unclosedFenceStart } = computeSkipRanges({ buffer: tail });
241
- const ranges = unclosedFenceStart !== null ? [...baseRanges, [unclosedFenceStart, tail.length]] : baseRanges;
242
- const open = findRealOpen(tail, 0, ranges);
243
- if (open === -1) {
244
- result += tail;
245
- break;
246
- }
247
- const gt = tail.indexOf('>', open);
248
- if (gt === -1) {
249
- result += tail;
250
- break;
251
- }
252
- const end = findUnskipped(tail, CLOSE, gt, ranges);
253
- if (end === -1) {
254
- // Real open but no real close — refuse to summarize (safer than eating
255
- // to end-of-string on a malformed/streaming tag). Keep the rest as-is.
256
- result += tail;
257
- break;
258
- }
259
- const attrs = parseQuotedAttrs({ raw: tail.slice(open, gt) });
244
+ const block = findCompleteArtifactBlock({ content: tail, ranges: completeContentSkipRanges({ content: tail }) });
245
+ // No real open, an open tag that never terminates, or a real open but no
246
+ // real close — refuse to summarize (safer than eating to end-of-string on
247
+ // a malformed/streaming tag). Keep the rest as-is.
248
+ if (!block)
249
+ return result + tail;
250
+ const afterBlock = block.close + CLOSE.length;
251
+ const attrs = parseQuotedAttrs({ raw: tail.slice(block.open, block.openEnd) });
260
252
  const persisted = matchPersistedArtifactFile({ attrs: attrs, persistedFiles: persistedFiles });
261
253
  result += persisted
262
- ? tail.slice(0, open) + artifactTranscriptSummary(attrs, persisted)
254
+ ? tail.slice(0, block.open) + artifactTranscriptSummary(attrs, persisted)
263
255
  : // Unconfirmed save — the transcript copy may be the only surviving
264
256
  // body. Keep the block verbatim and keep scanning past it.
265
- tail.slice(0, end + CLOSE.length);
266
- cursor += end + CLOSE.length;
257
+ tail.slice(0, afterBlock);
258
+ cursor += afterBlock;
267
259
  }
268
- return result;
269
260
  }
270
261
  function artifactTranscriptSummary(attrs, persisted) {
271
262
  const id = attrs['identifier'] ?? '';
@@ -295,8 +286,7 @@ function artifactTranscriptSummary(attrs, persisted) {
295
286
  * @complexity O(n) in `content.length`.
296
287
  */
297
288
  export function splitStreamingArtifact({ content }) {
298
- const { ranges: baseRanges, unclosedFenceStart } = computeSkipRanges({ buffer: content });
299
- const ranges = unclosedFenceStart !== null ? [...baseRanges, [unclosedFenceStart, content.length]] : baseRanges;
289
+ const ranges = completeContentSkipRanges({ content });
300
290
  const open = findRealOpen(content, 0, ranges);
301
291
  if (open === -1)
302
292
  return { head: content, live: null };
@@ -162,34 +162,32 @@ function pathnameOnly(path) {
162
162
  function srcsetCandidateUrls(srcset) {
163
163
  const candidates = [];
164
164
  let start = 0;
165
- let sawCandidate = false;
166
- let dataUrlCandidate = false;
167
- let sawWhitespaceAfterUrl = false;
165
+ let candidate = null;
168
166
  for (let index = 0; index < srcset.length; index += 1) {
169
167
  const char = srcset[index];
170
- if (!sawCandidate) {
171
- if (char === ',' || /\s/.test(char)) {
172
- start = index + 1;
173
- continue;
174
- }
175
- sawCandidate = true;
176
- dataUrlCandidate = /^data:/i.test(srcset.slice(index));
177
- }
178
- if (/\s/.test(char)) {
179
- sawWhitespaceAfterUrl = true;
168
+ const isSpace = /\s/.test(char);
169
+ if (candidate === null && (char === ',' || isSpace)) {
170
+ start = index + 1;
180
171
  continue;
181
172
  }
182
- if (char === ',' && (!dataUrlCandidate || sawWhitespaceAfterUrl)) {
173
+ candidate ??= { isDataUrl: /^data:/i.test(srcset.slice(index)), sawWhitespaceAfterUrl: false };
174
+ if (isSpace) {
175
+ candidate.sawWhitespaceAfterUrl = true;
176
+ }
177
+ else if (endsSrcsetCandidate(candidate, char)) {
183
178
  candidates.push(srcset.slice(start, index));
184
179
  start = index + 1;
185
- sawCandidate = false;
186
- dataUrlCandidate = false;
187
- sawWhitespaceAfterUrl = false;
180
+ candidate = null;
188
181
  }
189
182
  }
190
183
  candidates.push(srcset.slice(start));
191
184
  return candidates.map(firstUrlToken).filter(Boolean);
192
185
  }
186
+ // A data: URL may itself contain commas, so for one a comma only ends the
187
+ // candidate once whitespace has separated the URL from its descriptor.
188
+ function endsSrcsetCandidate(candidate, char) {
189
+ return char === ',' && (!candidate.isDataUrl || candidate.sawWhitespaceAfterUrl);
190
+ }
193
191
  function firstUrlToken(value) {
194
192
  // `String.split()` on a regex separator always returns at least one
195
193
  // element (even `''.split(/\s+/)` is `['']`), so `[0]` is never