@jini-ai/chat 0.6.15-rc.1 → 0.6.15

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 (93) hide show
  1. package/dist/core/accepted-input.js +29 -14
  2. package/dist/core/ag-ui/entry.js +3 -1
  3. package/dist/core/agent-plugin-chat-state.js +3 -1
  4. package/dist/core/assistant-client/ag-ui-transport.js +1 -1
  5. package/dist/core/assistant-client/attachment-liveness.js +1 -1
  6. package/dist/core/assistant-client/durable-run-subscription.js +2 -1
  7. package/dist/core/assistant-client/transport.js +13 -7
  8. package/dist/core/assistant-content.js +2 -1
  9. package/dist/core/composer-history.js +2 -1
  10. package/dist/core/durable-projection.js +2 -1
  11. package/dist/core/durable-runs/continuation.js +2 -1
  12. package/dist/core/messages.d.ts +5 -1
  13. package/dist/core/partial-json.js +18 -9
  14. package/dist/core/persistence/title.d.ts +1 -1
  15. package/dist/core/persistence/title.js +25 -7
  16. package/dist/core/question-answer.js +9 -4
  17. package/dist/core/question-form/normalize.js +1 -1
  18. package/dist/core/question-form/scan.js +25 -15
  19. package/dist/core/question-form/secret-answers.js +2 -1
  20. package/dist/core/run-activity.js +8 -4
  21. package/dist/core/run-events/entry.js +6 -2
  22. package/dist/core/transcript.js +13 -6
  23. package/dist/core/turn-usage.js +12 -4
  24. package/dist/core/user-text-redaction.d.ts +2 -5
  25. package/dist/core/user-text-redaction.js +3 -3
  26. package/dist/core/util/manifest.js +13 -11
  27. package/dist/core/util/markdown-context.d.ts +14 -0
  28. package/dist/core/util/markdown-context.js +42 -4
  29. package/dist/core/util/markup-attributes.js +1 -1
  30. package/dist/core/util/parser.js +7 -7
  31. package/dist/core/util/pointer.js +22 -6
  32. package/dist/core/util/recover.js +5 -8
  33. package/dist/core/util/strip.js +12 -15
  34. package/dist/core/util/validate.js +30 -18
  35. package/dist/embed-widget/browser/lazy-loader.js +16 -12
  36. package/dist/embed-widget/react/dialog.hooks.js +2 -2
  37. package/dist/embed-widget/react/embed.js +1 -1
  38. package/dist/embed-widget/react/header.hooks.js +1 -1
  39. package/dist/embed-widget/transports/fetch-sse.js +2 -1
  40. package/dist/embed-widget/vanilla/index.js +19 -12
  41. package/dist/embed-widget/vanilla/state.js +4 -2
  42. package/dist/react/components/AttachmentPreviewModal.hooks.js +2 -1
  43. package/dist/react/components/ChatSettingsDialog.js +18 -9
  44. package/dist/react/components/Composer.js +5 -4
  45. package/dist/react/components/ConversationList.js +5 -4
  46. package/dist/react/components/Markdown.js +29 -26
  47. package/dist/react/components/McpUiSurfaceCard.js +2 -1
  48. package/dist/react/components/MessageList.hooks.js +3 -2
  49. package/dist/react/components/MessageRow.js +23 -7
  50. package/dist/react/components/MessageRow.usage.js +7 -2
  51. package/dist/react/components/QuestionForm.js +4 -5
  52. package/dist/react/components/SentAttachment.js +4 -1
  53. package/dist/react/components/TodoCard.js +9 -1
  54. package/dist/react/components/ToolCard.js +8 -7
  55. package/dist/react/components/composer-discovery.js +8 -8
  56. package/dist/react/features/chat-pane/components/AgentPluginUsage.js +1 -2
  57. package/dist/react/features/chat-pane/components/AgentRuntimePicker.d.ts +1 -1
  58. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +15 -5
  59. package/dist/react/features/chat-pane/components/ChatPane.js +5 -8
  60. package/dist/react/features/chat-pane/components/RuntimeDropUp.js +1 -1
  61. package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +1 -1
  62. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +2 -2
  63. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +12 -7
  64. package/dist/react/features/chat-pane/hooks/useChatPaneAgentControl.hooks.js +18 -22
  65. package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +1 -1
  66. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +2 -2
  67. package/dist/react/features/chat-pane/hooks/useChatPaneWorkingDirectory.hooks.js +2 -2
  68. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +2 -2
  69. package/dist/react/features/chat-pane/model-options.js +4 -2
  70. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  71. package/dist/react/features/chat-pane/styles.js +26 -3
  72. package/dist/react/features/chat-pane/types.d.ts +8 -0
  73. package/dist/react/hooks/assistant-chats.js +22 -8
  74. package/dist/react/hooks/useArtifactStream.js +2 -1
  75. package/dist/react/hooks/useComposer.js +2 -2
  76. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +2 -2
  77. package/dist/react/hooks/useConversation.js +9 -4
  78. package/dist/react/hooks/useExtEventGroups.js +4 -2
  79. package/dist/react/hooks/useLatestOperation.js +2 -1
  80. package/dist/react/hooks/useQuestionForms.js +32 -22
  81. package/dist/react/hooks/useRunStream.js +4 -2
  82. package/dist/react/message-blocks.js +4 -2
  83. package/dist/react/styles/reference.css +9 -0
  84. package/dist/react/tool-call-display.js +1 -2
  85. package/dist/server/durable-runs/recover.js +13 -5
  86. package/dist/server/run-finalizer/entry.js +1 -0
  87. package/dist/store/runtime/chat-history-store.js +9 -3
  88. package/dist/store/runtime/durable-run-store.js +34 -24
  89. package/dist/store/runtime/run-ledger.js +7 -4
  90. package/dist/store/sql/boundary.js +3 -1
  91. package/dist/store/sql/cursor.js +8 -3
  92. package/dist/store/sql/store.js +36 -28
  93. package/package.json +15 -15
@@ -1,3 +1,4 @@
1
+ import { fencedCodeRanges } from './util/markdown-context.js';
1
2
  /**
2
3
  * @module transcript
3
4
  *
@@ -152,12 +153,7 @@ export function sanitizePriorAssistantTurn({ content }, { persistedArtifactFiles
152
153
  // Strip ```json (or plain ```) fenced blocks whose body matches the form
153
154
  // schema shape — `"questions": [` is the strongest tell. A generic JSON
154
155
  // snippet without that key is left intact.
155
- sanitized = sanitized.replace(/```(?:json)?\s*\n([\s\S]*?)\n```/g, (match, body) => {
156
- if (/"questions"\s*:\s*\[/.test(body)) {
157
- return '[form schema was echoed here on a prior turn; stripped to avoid a loop.]';
158
- }
159
- return match;
160
- });
156
+ sanitized = stripEchoedFormFences({ content: sanitized });
161
157
  // Replace prior-turn `<artifact>` HTML with a one-line summary for
162
158
  // artifacts CONFIRMED persisted (see summarizeArtifactsForTranscript's own
163
159
  // doc for why an unconfirmed save is left verbatim).
@@ -192,4 +188,15 @@ export function buildTranscript({ history }, options = {}) {
192
188
  const warning = buildPriorRunContextWarning(scopedHistory, largeToolResultChars, highInputTokenWarningThreshold);
193
189
  return warning ? `${warning}\n\n${transcript}` : transcript;
194
190
  }
191
+ function stripEchoedFormFences({ content }) {
192
+ const chunks = [];
193
+ let copied = 0;
194
+ for (const range of fencedCodeRanges({ content, opener: /```(?:json)?/g })) {
195
+ if (!/"questions"\s*:\s*\[/.test(range.body))
196
+ continue;
197
+ chunks.push(content.slice(copied, range.start), '[form schema was echoed here on a prior turn; stripped to avoid a loop.]');
198
+ copied = range.end;
199
+ }
200
+ return chunks.join('') + content.slice(copied);
201
+ }
195
202
  //# sourceMappingURL=transcript.js.map
@@ -32,6 +32,9 @@ export function replySegments({ events }, _optional = {}) {
32
32
  started = false;
33
33
  continue;
34
34
  }
35
+ // Repeated delivery receipts are bookkeeping, never content of the reply they already split.
36
+ if (isReplyBoundary({ event }))
37
+ continue;
35
38
  const lateResult = event.kind === 'usage' && !started && segments.length > 1;
36
39
  started ||= producesReplyOutput({ event });
37
40
  segments[segments.length - (lateResult ? 2 : 1)].push(event);
@@ -52,8 +55,10 @@ function usageCost({ event, agentId, sessions }, _optional = {}) {
52
55
  const key = JSON.stringify([agentId, sessionId]);
53
56
  const chargedUsd = sessions.get(key)?.chargedUsd ?? 0;
54
57
  const cumulative = validCost(event.cumulativeCostUsd);
55
- const cost = cumulative === undefined ? validCost(event.costUsd)
56
- : cumulative < chargedUsd ? cumulative : cumulative - chargedUsd;
58
+ let snapshotCost;
59
+ if (cumulative !== undefined)
60
+ snapshotCost = cumulative < chargedUsd ? cumulative : cumulative - chargedUsd;
61
+ const cost = cumulative === undefined ? validCost(event.costUsd) : snapshotCost;
57
62
  // Interim interrupted-segment charges are already paid; later snapshots subtract them.
58
63
  // After a counter reset the new snapshot, rather than the old epoch's total, is the baseline.
59
64
  sessions.set(key, { sessionId, chargedUsd: cumulative ?? chargedUsd + (cost ?? 0) });
@@ -77,11 +82,14 @@ export function turnUsage({ events }, { sessions = new Map(), agentId = '' } = {
77
82
  if (event.stopReason !== undefined)
78
83
  total.stopReason = event.stopReason;
79
84
  const cost = usageCost({ event, agentId, sessions });
80
- if (cost !== undefined)
81
- total.costUsd = (total.costUsd ?? 0) + cost;
85
+ addReportedCost({ total, cost });
82
86
  }
83
87
  return total;
84
88
  }
89
+ function addReportedCost({ total, cost }, _options = {}) {
90
+ if (cost !== undefined)
91
+ total.costUsd = (total.costUsd ?? 0) + cost;
92
+ }
85
93
  /** Add an event's well-formed token and duration counts into `total`, in place. */
86
94
  function addReportedCounts({ total, event }, _optional = {}) {
87
95
  for (const field of ['inputTokens', 'outputTokens', 'durationMs']) {
@@ -1,8 +1,5 @@
1
- import type { ChatMessage } from './messages.js';
2
- export interface SecretRedactionSignal {
3
- readonly secretRedacted: true;
4
- readonly count: number;
5
- }
1
+ import type { ChatMessage, SecretRedactionSignal } from './messages.js';
2
+ export type { SecretRedactionSignal } from './messages.js';
6
3
  export interface UserTextRedaction {
7
4
  readonly text: string;
8
5
  readonly secretRedacted: boolean;
@@ -10,9 +10,9 @@ const CHAT_RULES = [
10
10
  // Kept on top of the shared Slack rule: that rule refuses a token preceded by `-` (e.g. a URL path
11
11
  // segment `/-xoxb-…`) and the entropy fallback below exempts URLs, so without this rule such a token
12
12
  // would reach storage and model history. Only drop it once the shared rule covers that case.
13
- { kind: 'chat_key', pattern: /(?<![A-Za-z0-9_])xox[baprs]-[A-Za-z0-9-]{16,}/g, replacement: () => INTERNAL_REMOVED },
13
+ { kind: 'chat_key', pattern: /(?<!\w)xox[baprs]-[A-Za-z0-9-]{16,}/g, replacement: () => INTERNAL_REMOVED },
14
14
  // Explicit authorization syntax catches even short values. Exempt scheme descriptions in prose.
15
- { kind: 'chat_bearer', pattern: /\bBearer[ \t]+(?!\[REDACTED)(?!(?:token|tokens|authentication|authorization|auth|scheme|credentials?)\b)[A-Za-z0-9._~+\/=-]+/gi, replacement: () => 'Bearer ' + INTERNAL_REMOVED },
15
+ { kind: 'chat_bearer', pattern: /\bBearer[ \t]+(?!\[REDACTED)(?!(?:token|tokens|authentication|authorization|auth|scheme|credentials?)\b)[\w.~+\/=-]+/gi, replacement: () => 'Bearer ' + INTERNAL_REMOVED },
16
16
  ];
17
17
  function entropy(text) {
18
18
  const counts = new Map();
@@ -49,7 +49,7 @@ export function redactUserText({ text }, { secretField = false } = {}) {
49
49
  return candidate;
50
50
  const bits = entropy(candidate);
51
51
  const mixedAlphabet = /[A-Z]/.test(candidate) && /[a-z]/.test(candidate);
52
- if (bits < 4 || (!/[0-9]/.test(candidate) && (!mixedAlphabet || bits < 4.5)))
52
+ if (bits < 4 || (!/\d/.test(candidate) && (!mixedAlphabet || bits < 4.5)))
53
53
  return candidate;
54
54
  count += 1;
55
55
  return INTERNAL_REMOVED;
@@ -1,19 +1,21 @@
1
+ const CODE_SNIPPET = 'code-snippet';
2
+ const REACT_COMPONENT = 'react-component';
1
3
  const MANIFEST_VERSION = 1;
2
4
  const ALLOWED_KINDS = new Set([
3
5
  'html',
4
6
  'deck',
5
- 'react-component',
7
+ REACT_COMPONENT,
6
8
  'markdown-document',
7
9
  'svg',
8
10
  'diagram',
9
- 'code-snippet',
11
+ CODE_SNIPPET,
10
12
  'mini-app',
11
13
  'design-system',
12
14
  ]);
13
15
  const ALLOWED_RENDERERS = new Set([
14
16
  'html',
15
17
  'deck-html',
16
- 'react-component',
18
+ REACT_COMPONENT,
17
19
  'markdown',
18
20
  'svg',
19
21
  'diagram',
@@ -32,20 +34,20 @@ const LEGACY_KIND_BY_EXT = new Map([
32
34
  ['.htm', 'html'],
33
35
  ['.svg', 'svg'],
34
36
  ['.md', 'markdown-document'],
35
- ['.jsx', 'react-component'],
36
- ['.tsx', 'react-component'],
37
- ['.js', 'code-snippet'],
38
- ['.ts', 'code-snippet'],
39
- ['.json', 'code-snippet'],
40
- ['.css', 'code-snippet'],
37
+ ['.jsx', REACT_COMPONENT],
38
+ ['.tsx', REACT_COMPONENT],
39
+ ['.js', CODE_SNIPPET],
40
+ ['.ts', CODE_SNIPPET],
41
+ ['.json', CODE_SNIPPET],
42
+ ['.css', CODE_SNIPPET],
41
43
  ]);
42
44
  /** Renderer and export set for every kind `inferLegacyManifest` can resolve to (the inferred kinds plus the deck-name heuristic's `deck`). */
43
45
  const LEGACY_KIND_INFO = {
44
46
  html: { renderer: 'html', exports: ['html', 'pdf', 'zip'] },
45
47
  deck: { renderer: 'deck-html', exports: ['html', 'pdf', 'zip'] },
46
48
  'markdown-document': { renderer: 'markdown', exports: ['md', 'html', 'pdf', 'zip'] },
47
- 'react-component': { renderer: 'react-component', exports: ['jsx', 'html', 'zip'] },
48
- 'code-snippet': { renderer: 'code', exports: ['txt', 'zip'] },
49
+ [REACT_COMPONENT]: { renderer: REACT_COMPONENT, exports: ['jsx', 'html', 'zip'] },
50
+ [CODE_SNIPPET]: { renderer: 'code', exports: ['txt', 'zip'] },
49
51
  svg: { renderer: 'svg', exports: ['svg', 'zip'] }, // 'svg' is a valid ArtifactRendererId as-is
50
52
  };
51
53
  /** The manifest sidecar file name convention for a given artifact entry file name. */
@@ -52,4 +52,18 @@ export declare function rangeContains({ ranges, p }: {
52
52
  ranges: ReadonlyArray<Range>;
53
53
  p: number;
54
54
  }): boolean;
55
+ export interface FencedCodeRange {
56
+ start: number;
57
+ end: number;
58
+ body: string;
59
+ }
60
+ /** Permissive model-output fences used for recovery/transcript cleanup, independent of the
61
+ * renderer's line-anchored fence grammar above. Opening whitespace is greedy through its last
62
+ * newline; an empty block may require giving that last newline back to the closing delimiter.
63
+ * Each opener and closing delimiter is scanned directly instead of backtracking across the body.
64
+ */
65
+ export declare function fencedCodeRanges({ content, opener }: {
66
+ content: string;
67
+ opener: RegExp;
68
+ }, _options?: {}): Generator<FencedCodeRange>;
55
69
  //# sourceMappingURL=markdown-context.d.ts.map
@@ -98,19 +98,21 @@ function visitLineOutsideFence(scan, line) {
98
98
  if (line.hasNewline && FENCE_OPEN_RE.test(line.text)) {
99
99
  closeBlockBefore(scan, line.start);
100
100
  scan.fenceStart = line.start;
101
+ return;
101
102
  }
102
- else if (line.text.trim() === '') {
103
+ if (line.text.trim() === '') {
103
104
  closeBlockBefore(scan, line.start);
105
+ return;
104
106
  }
105
- else if (HEADING_RE.test(line.text) || UL_ITEM_RE.test(line.text) || OL_ITEM_RE.test(line.text)) {
107
+ if (HEADING_RE.test(line.text) || UL_ITEM_RE.test(line.text) || OL_ITEM_RE.test(line.text)) {
106
108
  // Each heading/list-item line is its own inline-scan region rather
107
109
  // than joining adjacent paragraphs.
108
110
  closeBlockBefore(scan, line.start);
109
111
  scan.blockRegions.push([line.start, line.end]);
112
+ return;
110
113
  }
111
- else if (scan.blockStart === -1) {
114
+ if (scan.blockStart === -1)
112
115
  scan.blockStart = line.start;
113
- }
114
116
  }
115
117
  /** `true` when position `p` falls inside one of `ranges` (half-open intervals). */
116
118
  export function rangeContains({ ranges, p }) {
@@ -120,4 +122,40 @@ export function rangeContains({ ranges, p }) {
120
122
  }
121
123
  return false;
122
124
  }
125
+ /** Permissive model-output fences used for recovery/transcript cleanup, independent of the
126
+ * renderer's line-anchored fence grammar above. Opening whitespace is greedy through its last
127
+ * newline; an empty block may require giving that last newline back to the closing delimiter.
128
+ * Each opener and closing delimiter is scanned directly instead of backtracking across the body.
129
+ */
130
+ export function* fencedCodeRanges({ content, opener }, _options = {}) {
131
+ const openings = new RegExp(opener.source, 'g');
132
+ let opening = openings.exec(content);
133
+ while (opening !== null) {
134
+ const body = fencedBodyRange({ content, after: opening.index + opening[0].length });
135
+ if (body === null)
136
+ return; // No later closing fence exists for any subsequent opener either.
137
+ if (body !== undefined) {
138
+ yield { start: opening.index, end: body.end, body: content.slice(body.start, body.end - 4) };
139
+ openings.lastIndex = body.end;
140
+ }
141
+ opening = openings.exec(content);
142
+ }
143
+ }
144
+ /** Undefined is an invalid opener; null is a valid opener with no possible closing fence. */
145
+ function fencedBodyRange({ content, after }) {
146
+ let whitespaceEnd = after;
147
+ while (whitespaceEnd < content.length && /\s/.test(content.charAt(whitespaceEnd)))
148
+ whitespaceEnd += 1;
149
+ const newlineOffset = content.slice(after, whitespaceEnd).indexOf('\n');
150
+ if (newlineOffset === -1)
151
+ return undefined;
152
+ const firstNewline = after + newlineOffset;
153
+ const lastNewline = content.lastIndexOf('\n', whitespaceEnd - 1);
154
+ const close = content.indexOf('\n```', lastNewline + 1);
155
+ if (close !== -1)
156
+ return { start: lastNewline + 1, end: close + 4 };
157
+ if (lastNewline === firstNewline || !content.startsWith('\n```', lastNewline))
158
+ return null;
159
+ return { start: content.lastIndexOf('\n', lastNewline - 1) + 1, end: lastNewline + 4 };
160
+ }
123
161
  //# sourceMappingURL=markdown-context.js.map
@@ -17,7 +17,7 @@
17
17
  * string (the content between the tag name and the closing `>`).
18
18
  */
19
19
  export function parseQuotedAttrs({ raw }) {
20
- const re = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
20
+ const re = /(?<!\w)(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
21
21
  const out = {};
22
22
  let m = re.exec(raw);
23
23
  while (m !== null) {
@@ -86,13 +86,12 @@ function findTagEnd({ buffer, from }) {
86
86
  if (quote !== null) {
87
87
  if (c === quote)
88
88
  quote = null;
89
+ continue;
89
90
  }
90
- else if (c === '"' || c === "'") {
91
- quote = c;
92
- }
93
- else if (c === '>') {
91
+ if (c === '>')
94
92
  return j;
95
- }
93
+ if (c === '"' || c === "'")
94
+ quote = c;
96
95
  }
97
96
  return -1;
98
97
  }
@@ -198,7 +197,8 @@ export function createArtifactParser(_requiredArgs) {
198
197
  return true;
199
198
  }
200
199
  function* flush(_requiredArgs) {
201
- if (state.inside) {
200
+ const wasInside = state.inside;
201
+ if (wasInside) {
202
202
  if (state.buffer.length > 0) {
203
203
  state.content += state.buffer;
204
204
  yield { type: 'artifact:chunk', identifier: state.identifier, delta: state.buffer };
@@ -206,7 +206,7 @@ export function createArtifactParser(_requiredArgs) {
206
206
  }
207
207
  yield { type: 'artifact:end', identifier: state.identifier, fullContent: state.content };
208
208
  }
209
- else if (state.buffer.length > 0) {
209
+ if (!wasInside && state.buffer.length > 0) {
210
210
  yield { type: 'text', delta: state.buffer };
211
211
  }
212
212
  state.buffer = '';
@@ -1,7 +1,24 @@
1
+ import { trimChars } from '@jini-ai/core/primitives';
1
2
  const MAX_POINTER_TEXT_BYTES = 100;
2
- const POINTER_TARGET_RE = /^(?:见|see)(?:\s*[::]\s*|\s+)[`"'“”‘’]?(.+?\.html?)[`"'“”‘’]?[.。]?$/iu;
3
+ // Matching only runs after the 100-byte visible-text guard; bound each run to that same ceiling.
4
+ const POINTER_TARGET_RE = /^(?:见|see)(?:\s{0,100}[::]\s{0,100}|\s{1,100})[`"'“”‘’]?(.{1,100}?\.html?)[`"'“”‘’]?[.。]?$/iu;
3
5
  const SCRIPT_OR_STYLE_RE = /<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
4
- const HTML_TAG_RE = /<[^>]+>/g;
6
+ function stripPointerTags(content) {
7
+ const chunks = [];
8
+ let copied = 0;
9
+ let open = content.indexOf('<');
10
+ while (open !== -1) {
11
+ const close = content.indexOf('>', open + 1);
12
+ if (close === -1)
13
+ break;
14
+ if (close > open + 1) {
15
+ chunks.push(content.slice(copied, open), ' ');
16
+ copied = close + 1;
17
+ }
18
+ open = content.indexOf('<', close + 1);
19
+ }
20
+ return chunks.join('') + content.slice(copied);
21
+ }
5
22
  /**
6
23
  * Resolve a short "see foo.html"-style reply to the referenced file name, if
7
24
  * `content` is (after stripping tags) nothing but such a pointer and the
@@ -40,12 +57,11 @@ function resolveAgainstProjectFiles({ target, candidateFileName, projectFiles })
40
57
  function visiblePointerText(content) {
41
58
  const stripped = content
42
59
  .replace(/^/, '')
43
- .replace(SCRIPT_OR_STYLE_RE, ' ')
44
- .replace(HTML_TAG_RE, ' ');
45
- return decodeBasicHtmlEntities(stripped).replace(/\s+/g, ' ').trim();
60
+ .replace(SCRIPT_OR_STYLE_RE, ' ');
61
+ return decodeBasicHtmlEntities(stripPointerTags(stripped)).replace(/\s+/g, ' ').trim();
46
62
  }
47
63
  function normalizeTarget(value) {
48
- return value.trim().replace(/^[`"'“”‘’]+|[`"'“”‘’]+$/g, '');
64
+ return trimChars({ text: value.trim(), chars: "`\"'“”‘’" });
49
65
  }
50
66
  function isSafeHtmlTarget(value) {
51
67
  if (!/\.(?:html?|HTML?)$/.test(value))
@@ -6,10 +6,11 @@
6
6
  * a standalone document reply, or as a single fenced ```html block.
7
7
  */
8
8
  import { validateHtmlArtifact } from './validate.js';
9
+ import { fencedCodeRanges } from './markdown-context.js';
9
10
  const HTML_OPEN_RE = /<html\b/gi;
10
11
  const HTML_CLOSE_RE = /<\/html\s*>/gi;
11
12
  const ADJACENT_DOCTYPE_RE = /<!doctype\s+html\b[^>]*>\s*$/i;
12
- const HTML_FENCE_RE = /```(?:html|HTML)\s*\n([\s\S]*?)\n```/g;
13
+ const HTML_FENCE_RE = /```(?:html|HTML)/g;
13
14
  function findLastArtifactOpen(sourceText, identifier) {
14
15
  if (!identifier)
15
16
  return sourceText.lastIndexOf('<artifact');
@@ -99,15 +100,11 @@ export function recoverStandaloneHtmlDocument({ sourceText }) {
99
100
  */
100
101
  export function recoverHtmlDocumentFromMarkdownFence({ sourceText }) {
101
102
  const text = String(sourceText || '');
102
- HTML_FENCE_RE.lastIndex = 0;
103
103
  let recovered = null;
104
104
  let count = 0;
105
- let match;
106
- while ((match = HTML_FENCE_RE.exec(text)) !== null) {
107
- const candidate = (match[1] || '').replace(/^/, '').trim();
108
- if (!/<\/html\s*>$/i.test(candidate))
109
- continue;
110
- if (!validateHtmlArtifact({ content: candidate }).ok)
105
+ for (const range of fencedCodeRanges({ content: text, opener: HTML_FENCE_RE })) {
106
+ const candidate = range.body.replace(/^/, '').trim();
107
+ if (!/<\/html\s*>$/i.test(candidate) || !validateHtmlArtifact({ content: candidate }).ok)
111
108
  continue;
112
109
  recovered = candidate;
113
110
  count += 1;
@@ -1,15 +1,16 @@
1
+ import { trimChars } from '@jini-ai/core/primitives';
1
2
  /**
2
3
  * @module strip
3
4
  *
4
5
  * Post-stream `<artifact>` removal/summarization and the streaming-display
5
6
  * counterpart that surfaces an in-flight (not-yet-closed) artifact block.
6
7
  */
7
- import { computeSkipRanges, isRealArtifactOpenAt, rangeContains } from './markdown-context.js';
8
+ import { fencedCodeRanges, computeSkipRanges, isRealArtifactOpenAt, rangeContains } from './markdown-context.js';
8
9
  import { parseQuotedAttrs } from './markup-attributes.js';
9
10
  import { recoverHtmlArtifactFromPrecedingDocument, recoverHtmlDocumentFromMarkdownFence, recoverStandaloneHtmlDocument, } from './recover.js';
10
11
  const OPEN = '<artifact';
11
12
  const CLOSE = '</artifact>';
12
- const HTML_FENCE_RE = /```(?:html|HTML)\s*\n([\s\S]*?)\n```/g;
13
+ const HTML_FENCE_RE = /```(?:html|HTML)/g;
13
14
  function findUnskipped(content, needle, fromIndex, ranges) {
14
15
  let from = fromIndex;
15
16
  // `needle` (always `CLOSE`, non-empty) makes `content.indexOf` itself
@@ -91,17 +92,14 @@ export function stripArtifact({ content }) {
91
92
  return (content.slice(0, block.open) + content.slice(block.close + CLOSE.length)).trim();
92
93
  }
93
94
  function findSingleRecoverableHtmlFence(content) {
94
- HTML_FENCE_RE.lastIndex = 0;
95
95
  let recovered = null;
96
96
  let count = 0;
97
- let match = HTML_FENCE_RE.exec(content);
98
- while (match !== null) {
99
- const html = (match[1] || '').replace(/^/, '').trim();
100
- if (recoverHtmlDocumentFromMarkdownFence({ sourceText: match[0] }) === html) {
101
- recovered = { start: match.index, end: match.index + match[0].length, html };
97
+ for (const range of fencedCodeRanges({ content, opener: HTML_FENCE_RE })) {
98
+ const html = range.body.replace(/^/, '').trim();
99
+ if (recoverHtmlDocumentFromMarkdownFence({ sourceText: content.slice(range.start, range.end) }) === html) {
100
+ recovered = { start: range.start, end: range.end, html };
102
101
  count += 1;
103
102
  }
104
- match = HTML_FENCE_RE.exec(content);
105
103
  }
106
104
  return count === 1 ? recovered : null;
107
105
  }
@@ -177,10 +175,9 @@ function artifactExtensionForAttrs(attrs) {
177
175
  }
178
176
  // The slug a persist path would derive the file name from.
179
177
  function artifactBaseNameForAttrs(attrs) {
180
- return ((attrs['identifier'] || attrs['title'] || 'artifact')
181
- .toLowerCase()
182
- .replace(/[^a-z0-9_-]+/g, '-')
183
- .replace(/^-+|-+$/g, '')
178
+ return (trimChars({ text: (attrs['identifier'] || attrs['title'] || 'artifact')
179
+ .toLowerCase()
180
+ .replace(/[^a-z0-9_-]+/g, '-'), chars: '-' })
184
181
  .slice(0, 60) || 'artifact');
185
182
  }
186
183
  /**
@@ -296,7 +293,7 @@ export function splitStreamingArtifact({ content }) {
296
293
  // known yet, but an artifact is starting, so show the box (empty body)
297
294
  // and hide the partial `<artifact …` tail from Markdown.
298
295
  return {
299
- head: content.slice(0, open).replace(/\s+$/, ''),
296
+ head: content.slice(0, open).trimEnd(),
300
297
  live: { artifactType: '', title: '', identifier: '', content: '' },
301
298
  };
302
299
  }
@@ -311,7 +308,7 @@ export function splitStreamingArtifact({ content }) {
311
308
  if (artifactType && !/html|text\//i.test(artifactType))
312
309
  return { head: content, live: null };
313
310
  return {
314
- head: content.slice(0, open).replace(/\s+$/, ''),
311
+ head: content.slice(0, open).trimEnd(),
315
312
  live: {
316
313
  artifactType,
317
314
  title: attrs['title'] ?? '',
@@ -43,8 +43,9 @@ const URL_ATTRIBUTE_RE = /\b(href|src|srcset|poster|action|formaction|data|xlink
43
43
  const STYLE_ATTRIBUTE_RE = /\bstyle\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'`=<>]+))/gi;
44
44
  const HTML_TAG_RE = /<[a-z][^>]*>/gi;
45
45
  const STYLE_BLOCK_RE = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
46
- const CSS_URL_RE = /\burl\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*?))\s*\)/gi;
47
- const CSS_IMPORT_RE = /@import\s+(?:url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*?))\s*\)|"([^"]*)"|'([^']*)')/gi;
46
+ const CSS_URL_RE = /\burl\((?:\s*"([^"]*)"\s*|\s*'([^']*)'\s*|([^)]*))\)/gi;
47
+ const CSS_IMPORT_START_RE = /@import\s+/gi;
48
+ const CSS_QUOTED_IMPORT_RE = /(?:"([^"]*)"|'([^']*)')/y;
48
49
  /**
49
50
  * Sniff-test whether `content` looks like a complete HTML document rather
50
51
  * than prose that happens to mention HTML.
@@ -127,22 +128,34 @@ function hasReservedWorkspacePathInStyleBlocks(content) {
127
128
  return false;
128
129
  }
129
130
  function cssTextReferencesReservedWorkspacePath(cssText) {
130
- for (const pattern of [CSS_URL_RE, CSS_IMPORT_RE]) {
131
- pattern.lastIndex = 0;
132
- let match;
133
- while ((match = pattern.exec(cssText)) !== null) {
134
- // Both CSS_URL_RE and CSS_IMPORT_RE are single top-level alternations
135
- // across all their capture groups, so exactly one group is ever defined
136
- // on a successful match — `.find` always finds it; the assertion just
137
- // satisfies `noUncheckedIndexedAccess`.
138
- const candidate = match.slice(1).find((value) => value !== undefined);
139
- if (candidateReferencesReservedWorkspacePath(candidate, false)) {
140
- return true;
141
- }
142
- }
131
+ for (const match of cssReferenceMatches({ cssText })) {
132
+ // Quote/unquoted alternatives are mutually exclusive, so exactly one capture is defined
133
+ // after success; TypeScript cannot express that guarantee for RegExpExecArray.
134
+ const candidate = match.slice(1).find((value) => value !== undefined);
135
+ if (candidateReferencesReservedWorkspacePath(candidate, false))
136
+ return true;
143
137
  }
144
138
  return false;
145
139
  }
140
+ /** Keep each import's whole span consumed, including URL imports already inspected by the first
141
+ * pass. Rescanning @import text quoted inside such a URL would change the path-refusal policy. */
142
+ function* cssReferenceMatches({ cssText }) {
143
+ yield* cssText.matchAll(CSS_URL_RE);
144
+ const imports = new RegExp(CSS_IMPORT_START_RE);
145
+ const url = new RegExp(CSS_URL_RE.source, 'iy');
146
+ const quoted = new RegExp(CSS_QUOTED_IMPORT_RE);
147
+ let header = imports.exec(cssText);
148
+ while (header !== null) {
149
+ url.lastIndex = imports.lastIndex;
150
+ quoted.lastIndex = imports.lastIndex;
151
+ const value = url.exec(cssText) ?? quoted.exec(cssText);
152
+ if (value) {
153
+ imports.lastIndex = value.index + value[0].length;
154
+ yield value;
155
+ }
156
+ header = imports.exec(cssText);
157
+ }
158
+ }
146
159
  function candidateReferencesReservedWorkspacePath(candidate, splitCandidates) {
147
160
  const paths = splitCandidates ? srcsetCandidateUrls(candidate) : [firstUrlToken(candidate)];
148
161
  return paths.some((path) => {
@@ -171,10 +184,9 @@ function srcsetCandidateUrls(srcset) {
171
184
  continue;
172
185
  }
173
186
  candidate ??= { isDataUrl: /^data:/i.test(srcset.slice(index)), sawWhitespaceAfterUrl: false };
174
- if (isSpace) {
187
+ if (isSpace)
175
188
  candidate.sawWhitespaceAfterUrl = true;
176
- }
177
- else if (endsSrcsetCandidate(candidate, char)) {
189
+ if (!isSpace && endsSrcsetCandidate(candidate, char)) {
178
190
  candidates.push(srcset.slice(start, index));
179
191
  start = index + 1;
180
192
  candidate = null;
@@ -1,3 +1,4 @@
1
+ const ARIA_LABEL = 'aria-label';
1
2
  export { CHAT_BUBBLE_STYLE } from '../../contracts/chat-bubble-style.js';
2
3
  /**
3
4
  * Activates a host-rendered button without importing chat, React, icons or storage.
@@ -11,10 +12,10 @@ export function installLazyChatLoader(args, _options = {}) {
11
12
  let shouldOpen = false;
12
13
  let shouldFocus = false;
13
14
  const initialContent = args.button.innerHTML;
14
- const label = args.button.getAttribute('aria-label') ?? '';
15
+ const label = args.button.getAttribute(ARIA_LABEL) ?? '';
15
16
  const activate = async ({ open = true, focus = open } = {}, _options = {}) => {
16
17
  if (disposed)
17
- return;
18
+ return undefined;
18
19
  if (!pending) {
19
20
  shouldOpen = false;
20
21
  shouldFocus = false;
@@ -24,26 +25,29 @@ export function installLazyChatLoader(args, _options = {}) {
24
25
  if (pending)
25
26
  return pending;
26
27
  args.button.setAttribute('aria-busy', 'true');
27
- args.button.setAttribute('aria-label', args.loadingLabel);
28
+ args.button.setAttribute(ARIA_LABEL, args.loadingLabel);
28
29
  args.button.textContent = args.loadingLabel;
29
30
  delete args.button.dataset.loadFailed;
30
31
  // Defer through a promise so a synchronous host load failure follows the same retry path.
31
- pending = Promise.resolve().then(() => { if (!ready)
32
- return args.load({}); }).then(() => {
32
+ pending = Promise.resolve().then(() => {
33
+ if (!ready)
34
+ return args.load({});
35
+ return undefined;
36
+ }).then(() => {
33
37
  if (disposed)
34
38
  return;
35
39
  // Asset readiness survives an open failure; retry must never evaluate the script twice.
36
40
  ready = true;
37
41
  if (shouldOpen)
38
42
  args.open({ focus: shouldFocus });
39
- args.button.setAttribute('aria-label', label);
43
+ args.button.setAttribute(ARIA_LABEL, label);
40
44
  args.button.innerHTML = initialContent;
41
45
  args.button.hidden = true;
42
46
  }).catch(() => {
43
47
  if (!disposed) {
44
48
  args.button.hidden = false;
45
49
  args.button.dataset.loadFailed = 'true';
46
- args.button.setAttribute('aria-label', args.retryLabel);
50
+ args.button.setAttribute(ARIA_LABEL, args.retryLabel);
47
51
  args.button.textContent = args.retryLabel;
48
52
  }
49
53
  }).finally(() => {
@@ -62,17 +66,17 @@ export function installLazyChatLoader(args, _options = {}) {
62
66
  args.button.addEventListener('click', click);
63
67
  args.button.addEventListener('keydown', keydown);
64
68
  args.button.dataset.loaderReady = 'true';
65
- if (args.resume || args.deepLink)
66
- void activate({ focus: args.deepLink });
67
- else if (args.preload)
68
- void activate({ open: false });
69
+ if (args.resume || args.deepLink || args.preload) {
70
+ const resume = args.resume || args.deepLink;
71
+ void activate({ open: resume, focus: resume && args.deepLink });
72
+ }
69
73
  return {
70
74
  activate,
71
75
  dispose(_input = {}, _options = {}) {
72
76
  disposed = true;
73
77
  args.button.removeEventListener('click', click);
74
78
  args.button.removeEventListener('keydown', keydown);
75
- args.button.setAttribute('aria-label', label);
79
+ args.button.setAttribute(ARIA_LABEL, label);
76
80
  args.button.innerHTML = initialContent;
77
81
  args.button.removeAttribute('aria-busy');
78
82
  delete args.button.dataset.loadFailed;
@@ -25,11 +25,11 @@ export function useEmbedChatDialog({ open, onClose, panelRef, launcherRef }, { r
25
25
  }, [open, launcherRef]);
26
26
  useEffect(() => {
27
27
  if (!open)
28
- return;
28
+ return undefined;
29
29
  const panel = panelRef.current;
30
30
  const view = panel?.ownerDocument.defaultView;
31
31
  if (!panel || !view)
32
- return;
32
+ return undefined;
33
33
  const onKeyDown = (event) => {
34
34
  if (event.key === 'Escape' && !event.defaultPrevented) {
35
35
  event.preventDefault();
@@ -36,7 +36,7 @@ export function EmbedChatWidget(args) {
36
36
  useEffect(() => {
37
37
  const queued = { value: session.drain({}) };
38
38
  if (!isQueuedPageAction(queued) || queued.value.type === 'navigate')
39
- return;
39
+ return undefined;
40
40
  const action = queued.value;
41
41
  return scheduleFrame({ callback: () => { pageActions.execute({ action }); } });
42
42
  }, [session, pageActions, scheduleFrame]);
@@ -15,7 +15,7 @@ export function useEmbedChatHeader({ hasMessages, onReset, keyboard }, _options
15
15
  }, [confirming]);
16
16
  useEffect(() => {
17
17
  if (!confirming)
18
- return;
18
+ return undefined;
19
19
  return keyboard.subscribe({ onEscape: () => setConfirming(false) });
20
20
  }, [confirming, keyboard]);
21
21
  return { confirming, cancel, headerRef, resetButtonRef,
@@ -151,7 +151,7 @@ async function deliverResponse({ run, args, response }) {
151
151
  await response.body?.cancel();
152
152
  }
153
153
  catch { /* closed body */ }
154
- return;
154
+ return undefined;
155
155
  }
156
156
  if (!response.ok)
157
157
  return reportHttpFailure({ run, response, requestFailed: args.copy.requestFailed });
@@ -161,6 +161,7 @@ async function deliverResponse({ run, args, response }) {
161
161
  if (run.isSettled() || !dispatchFrame({ run, result: args.frameMapper({ frame }) }))
162
162
  break;
163
163
  }
164
+ return undefined;
164
165
  }
165
166
  async function reportHttpFailure({ run, response, requestFailed }) {
166
167
  const body = await response.json().catch(() => null);