@jini-ai/chat 0.6.15-rc.2 → 0.6.16

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 (126) hide show
  1. package/README.md +6 -1
  2. package/dist/core/accepted-input.js +20 -11
  3. package/dist/core/ag-ui/entry.js +3 -1
  4. package/dist/core/agent-plugin-chat-state.js +3 -1
  5. package/dist/core/antigravity/tool-presentation.d.ts +13 -0
  6. package/dist/core/antigravity/tool-presentation.js +20 -0
  7. package/dist/core/assistant-client/ag-ui-transport.js +14 -2
  8. package/dist/core/assistant-client/attachment-liveness.js +1 -1
  9. package/dist/core/assistant-client/durable-run-subscription.js +2 -1
  10. package/dist/core/assistant-client/transport.js +13 -7
  11. package/dist/core/assistant-content.js +2 -1
  12. package/dist/core/composer-history.js +2 -1
  13. package/dist/core/durable-projection.js +2 -1
  14. package/dist/core/durable-runs/continuation.js +2 -1
  15. package/dist/core/events.d.ts +5 -1
  16. package/dist/core/messages.d.ts +5 -1
  17. package/dist/core/partial-json.js +18 -9
  18. package/dist/core/persistence/title.d.ts +1 -1
  19. package/dist/core/persistence/title.js +25 -7
  20. package/dist/core/question-form/normalize.js +1 -1
  21. package/dist/core/question-form/scan.js +25 -15
  22. package/dist/core/question-form/secret-answers.js +2 -1
  23. package/dist/core/run-activity.js +8 -4
  24. package/dist/core/run-events/entry.js +6 -2
  25. package/dist/core/tool-events.d.ts +18 -8
  26. package/dist/core/tool-events.js +72 -16
  27. package/dist/core/tool-presentation.d.ts +14 -0
  28. package/dist/core/tool-presentation.js +8 -0
  29. package/dist/core/transcript.js +13 -6
  30. package/dist/core/turn-usage.js +7 -2
  31. package/dist/core/user-text-redaction.d.ts +4 -6
  32. package/dist/core/user-text-redaction.js +13 -6
  33. package/dist/core/util/manifest.js +13 -11
  34. package/dist/core/util/markdown-context.d.ts +14 -0
  35. package/dist/core/util/markdown-context.js +42 -4
  36. package/dist/core/util/markup-attributes.js +1 -1
  37. package/dist/core/util/parser.js +7 -7
  38. package/dist/core/util/pointer.js +22 -6
  39. package/dist/core/util/recover.js +5 -8
  40. package/dist/core/util/strip.js +12 -15
  41. package/dist/core/util/validate.js +30 -18
  42. package/dist/embed-widget/browser/frontend-session-bridge.d.ts +99 -0
  43. package/dist/embed-widget/browser/frontend-session-bridge.js +384 -0
  44. package/dist/embed-widget/browser/lazy-loader.js +16 -12
  45. package/dist/embed-widget/browser/session-store.d.ts +3 -9
  46. package/dist/embed-widget/browser/session-store.js +6 -38
  47. package/dist/embed-widget/react/dialog.hooks.js +2 -2
  48. package/dist/embed-widget/react/embed.d.ts +3 -10
  49. package/dist/embed-widget/react/embed.js +8 -52
  50. package/dist/embed-widget/react/header.hooks.js +1 -1
  51. package/dist/embed-widget/transports/fetch-sse.js +2 -1
  52. package/dist/embed-widget/vanilla/index.d.ts +0 -5
  53. package/dist/embed-widget/vanilla/index.js +23 -26
  54. package/dist/embed-widget/vanilla/state.d.ts +3 -11
  55. package/dist/embed-widget/vanilla/state.js +10 -39
  56. package/dist/embed-widget/vanilla/style.css +1 -1
  57. package/dist/react/agent-bridge/frontend-session-bridge.d.ts +2 -56
  58. package/dist/react/agent-bridge/frontend-session-bridge.js +2 -248
  59. package/dist/react/components/AttachmentPreviewModal.hooks.js +2 -1
  60. package/dist/react/components/ChatSettingsDialog.js +18 -9
  61. package/dist/react/components/Composer.js +5 -4
  62. package/dist/react/components/ConversationList.js +5 -4
  63. package/dist/react/components/Icon.d.ts +1 -1
  64. package/dist/react/components/Icon.js +1 -0
  65. package/dist/react/components/Markdown.d.ts +1 -0
  66. package/dist/react/components/Markdown.js +30 -27
  67. package/dist/react/components/McpUiSurfaceCard.js +2 -1
  68. package/dist/react/components/MessageList.d.ts +1 -1
  69. package/dist/react/components/MessageList.hooks.js +3 -2
  70. package/dist/react/components/MessageRow.d.ts +3 -0
  71. package/dist/react/components/MessageRow.js +32 -14
  72. package/dist/react/components/MessageRow.usage.js +3 -1
  73. package/dist/react/components/QuestionForm.js +4 -5
  74. package/dist/react/components/SentAttachment.js +4 -1
  75. package/dist/react/components/TodoCard.js +9 -1
  76. package/dist/react/components/ToolCard.d.ts +3 -1
  77. package/dist/react/components/ToolCard.js +84 -64
  78. package/dist/react/components/ToolCard.rules.d.ts +20 -0
  79. package/dist/react/components/ToolCard.rules.js +92 -0
  80. package/dist/react/components/composer-discovery.js +8 -8
  81. package/dist/react/features/chat-pane/components/AgentPluginUsage.js +1 -2
  82. package/dist/react/features/chat-pane/components/AgentRuntimePicker.d.ts +1 -1
  83. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +31 -9
  84. package/dist/react/features/chat-pane/components/ChatPane.js +7 -5
  85. package/dist/react/features/chat-pane/components/RuntimeDropUp.js +1 -1
  86. package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +1 -1
  87. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +2 -2
  88. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +3 -1
  89. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +13 -5
  90. package/dist/react/features/chat-pane/hooks/useChatPaneAgentControl.hooks.js +2 -2
  91. package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +1 -1
  92. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +2 -2
  93. package/dist/react/features/chat-pane/hooks/useChatPaneWorkingDirectory.hooks.js +2 -2
  94. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +2 -2
  95. package/dist/react/features/chat-pane/model-options.js +4 -2
  96. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  97. package/dist/react/features/chat-pane/styles.js +95 -5
  98. package/dist/react/features/chat-pane/types.d.ts +11 -0
  99. package/dist/react/hooks/assistant-chats.js +22 -8
  100. package/dist/react/hooks/useArtifactStream.js +2 -1
  101. package/dist/react/hooks/useComposer.js +2 -2
  102. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +2 -2
  103. package/dist/react/hooks/useConversation.js +9 -4
  104. package/dist/react/hooks/useExtEventGroups.js +4 -2
  105. package/dist/react/hooks/useLatestOperation.js +2 -1
  106. package/dist/react/hooks/useQuestionForms.js +32 -22
  107. package/dist/react/hooks/useRunStream.js +4 -2
  108. package/dist/react/hooks/useUserTextGuard.d.ts +1 -0
  109. package/dist/react/hooks/useUserTextGuard.js +3 -1
  110. package/dist/react/message-blocks.js +4 -2
  111. package/dist/react/styles/reference.css +9 -0
  112. package/dist/react/tool-call-display.d.ts +11 -0
  113. package/dist/react/tool-call-display.js +59 -2
  114. package/dist/server/durable-runs/recover.js +13 -5
  115. package/dist/server/run-finalizer/entry.js +1 -0
  116. package/dist/store/runtime/chat-history-store.js +9 -3
  117. package/dist/store/runtime/durable-run-store.js +34 -24
  118. package/dist/store/runtime/run-ledger.js +7 -4
  119. package/dist/store/sql/boundary.js +3 -1
  120. package/dist/store/sql/cursor.js +8 -3
  121. package/dist/store/sql/store.js +36 -28
  122. package/package.json +93 -88
  123. package/dist/core/browser/page-actions.d.ts +0 -121
  124. package/dist/core/browser/page-actions.js +0 -115
  125. package/dist/embed-widget/browser/page-actions.d.ts +0 -2
  126. package/dist/embed-widget/browser/page-actions.js +0 -3
@@ -85,8 +85,9 @@ function isAnswerCard(scan, name, data) {
85
85
  function scanStatus({ scan, event, index }, _optional = {}) {
86
86
  if (event.code === 'api_retry') {
87
87
  scan.retry = { index, data: event.data };
88
+ return;
88
89
  }
89
- else if (event.code === 'tool_progress') {
90
+ if (event.code === 'tool_progress') {
90
91
  const seconds = numberField(event.data, 'elapsedSeconds');
91
92
  if (seconds !== undefined)
92
93
  scan.progress = { index, seconds };
@@ -108,15 +109,18 @@ function scanEvent(scan, event, index) {
108
109
  scan.pendingQuestionIndex = -1;
109
110
  scan.open = scan.open.filter((tool) => tool.id !== event.id);
110
111
  scan.open.push({ id: event.id, name: event.name, input: event.input, index });
112
+ return;
111
113
  }
112
- else if (event.kind === 'tool_result') {
114
+ if (event.kind === 'tool_result') {
113
115
  // Completion may be unrelated background work or the ask tool returning before human input.
114
116
  scan.open = scan.open.filter((tool) => tool.id !== event.toolUseId);
117
+ return;
115
118
  }
116
- else if (event.kind === 'ext') {
119
+ if (event.kind === 'ext') {
117
120
  scanCard({ scan, event, index });
121
+ return;
118
122
  }
119
- else if (event.kind === 'status') {
123
+ if (event.kind === 'status') {
120
124
  scanStatus({ scan, event, index });
121
125
  }
122
126
  }
@@ -8,7 +8,9 @@ import { assistantContentFromEvents } from "../assistant-content.js";
8
8
  import { mergeAdjacentTextEvents } from "../compact-events.js";
9
9
  import { dedupeToolUsesById } from "../tool-events.js";
10
10
  function wireString(v) {
11
- return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v);
11
+ if (typeof v === "string")
12
+ return v;
13
+ return v == null ? "" : JSON.stringify(v);
12
14
  }
13
15
  /** Display a wire scalar or JSON value using the existing empty-null convention. */
14
16
  export function asString({ value }, _options = {}) {
@@ -470,7 +472,9 @@ function chunkEvent(raw, channel, carry) {
470
472
  if (!frame)
471
473
  return [];
472
474
  const chunk = wireString(frame.payload?.chunk);
473
- const open = carry ? (carry.openEchoChannels ??= new Set()) : undefined;
475
+ if (carry)
476
+ carry.openEchoChannels ??= new Set();
477
+ const open = carry?.openEchoChannels;
474
478
  const { kept, statuses, droppedAny } = splitChunk({ chunk, channel }, { open });
475
479
  const line = droppedAny ? kept : chunk;
476
480
  return line.length > 0 ? [{ kind: "raw", line }, ...statuses] : statuses;
@@ -29,7 +29,7 @@ export declare function isDelegatedWrapperToolName({ name }: {
29
29
  name: string;
30
30
  }): boolean;
31
31
  /**
32
- * Drop each delegated-tool wrapper call that the daemon also reported as its own canonical row,
32
+ * Drop each delegated or direct MCP wrapper call that the daemon also reported as its own canonical row,
33
33
  * so one call shows as one row.
34
34
  *
35
35
  * WHY. A delegated call arrives twice: the vendor CLI's wrapper `tool_use`
@@ -39,16 +39,26 @@ export declare function isDelegatedWrapperToolName({ name }: {
39
39
  * failed call showed two red rows. The canonical row is the one to keep: it carries the tool's own
40
40
  * name, input and result.
41
41
  *
42
- * A wrapper folds onto the first LATER canonical `tool_use` whose name equals its `input.toolId`
43
- * and that no earlier wrapper has claimed; its `tool_result` events go with it. A wrapper with no
44
- * such canonical row (refused before the bridge, e.g. a read-only 403) stays, so its error is not
45
- * hidden.
42
+ * A direct call (`mcp__jini__theme_read_file`) arrives twice the same way: the CLI's `mcp__<server>__X`
43
+ * row and the bridge's `X` row with the same input, so every direct Jini MCP call drew two rows.
46
44
  *
47
- * @param events - The event list, normally already passed through {@link dedupeToolUsesById}.
45
+ * Antigravity buffers CLI receipts: its adapter-described wrappers match equal inner inputs
46
+ * anywhere in this assistant turn, in appearance order among equal keys. Pass only one turn's events;
47
+ * no state is retained across calls. Wrapper and bridge rows are each claimed at most once.
48
+ * A legacy gateway folds onto the first LATER canonical `tool_use` whose name equals its `input.toolId`.
49
+ * A direct call folds onto the first LATER `X` row with structurally equal JSON input that arrives
50
+ * before the CLI's own result for it. No canonical row is claimed twice, and a folded wrapper's
51
+ * `tool_result` events go with it. A wrapper with no such canonical row (refused before the
52
+ * bridge, e.g. a read-only 403) stays, so its error is not hidden.
53
+ *
54
+ * @param events - One assistant turn's events, normally already passed through {@link dedupeToolUsesById}.
48
55
  * @returns A new array without the folded wrappers, or the original reference when none fold.
49
- * @complexity O(n·w): n events, w wrapper calls (a handful per message).
56
+ * @throws CanonicalJsonError if a direct call's input contains non-JSON values.
57
+ * @complexity O(n·w·s): n events, w wrappers, s canonical input serialization cost (including key sorting).
58
+ * O(n + s) extra space; inputs and events are not mutated.
59
+ * @example foldDelegatedWrapperCalls({ events }); // canonical rows retain their own results
50
60
  */
51
61
  export declare function foldDelegatedWrapperCalls({ events }: {
52
62
  events: AgentEvent[] | undefined;
53
- }): AgentEvent[];
63
+ }, _options?: Record<string, never>): AgentEvent[];
54
64
  //# sourceMappingURL=tool-events.d.ts.map
@@ -1,3 +1,5 @@
1
+ import { canonicalJson } from '@jini-ai/core/records';
2
+ import { toolPresentation } from './tool-presentation.js';
1
3
  /**
2
4
  * Drop repeated `tool_use` events that share an `id`. A retried/duplicated
3
5
  * stream can replay the same tool-call id (e.g. reconnect-and-replay landing
@@ -46,15 +48,31 @@ const DELEGATED_WRAPPER_PATTERN = /^(?:mcp__.+__)?execute_(?:readonly_)?delegate
46
48
  export function isDelegatedWrapperToolName({ name }) {
47
49
  return DELEGATED_WRAPPER_PATTERN.test(name);
48
50
  }
49
- function wrapperToolId({ event }) {
50
- if (!isDelegatedWrapperToolName({ name: event.name }))
51
+ /** The tool `mcp__<server>__X` names (`X`), or undefined for any other name. O(n) in the name length. */
52
+ function directMcpToolName({ name }) {
53
+ if (!name.startsWith('mcp__'))
51
54
  return undefined;
52
- const input = event.input;
53
- const toolId = typeof input === 'object' && input !== null ? input.toolId : undefined;
54
- return typeof toolId === 'string' && toolId ? toolId : undefined;
55
+ // The server segment is at least one character, so its closing `__` starts at index 6 or later.
56
+ const separator = name.indexOf('__', 6);
57
+ return separator === -1 ? undefined : name.slice(separator + 2);
58
+ }
59
+ /** Resolve the generic wrapper contract or an MCP gateway/direct name. Pure; O(n) in the name.
60
+ * Legacy gateways retain name-only matching for malformed-input bridge errors;
61
+ * Antigravity's adapter-described wrappers require equal inner inputs but may arrive buffered.
62
+ */
63
+ function wrapperCall({ event }, _options = {}) {
64
+ const wrapper = toolPresentation(event).wrapper;
65
+ const call = wrapper ?? event;
66
+ if (!isDelegatedWrapperToolName({ name: call.name })) {
67
+ const name = wrapper ? wrapper.name : directMcpToolName({ name: call.name });
68
+ return name ? { name, input: call.input, matchInput: true, buffered: wrapper !== undefined } : undefined;
69
+ }
70
+ const args = call.input !== null && typeof call.input === 'object' ? call.input : {};
71
+ const name = args.toolId;
72
+ return typeof name === 'string' && name ? { name, input: args.input, matchInput: wrapper !== undefined, buffered: wrapper !== undefined } : undefined;
55
73
  }
56
74
  /**
57
- * Drop each delegated-tool wrapper call that the daemon also reported as its own canonical row,
75
+ * Drop each delegated or direct MCP wrapper call that the daemon also reported as its own canonical row,
58
76
  * so one call shows as one row.
59
77
  *
60
78
  * WHY. A delegated call arrives twice: the vendor CLI's wrapper `tool_use`
@@ -64,16 +82,26 @@ function wrapperToolId({ event }) {
64
82
  * failed call showed two red rows. The canonical row is the one to keep: it carries the tool's own
65
83
  * name, input and result.
66
84
  *
67
- * A wrapper folds onto the first LATER canonical `tool_use` whose name equals its `input.toolId`
68
- * and that no earlier wrapper has claimed; its `tool_result` events go with it. A wrapper with no
69
- * such canonical row (refused before the bridge, e.g. a read-only 403) stays, so its error is not
70
- * hidden.
85
+ * A direct call (`mcp__jini__theme_read_file`) arrives twice the same way: the CLI's `mcp__<server>__X`
86
+ * row and the bridge's `X` row with the same input, so every direct Jini MCP call drew two rows.
71
87
  *
72
- * @param events - The event list, normally already passed through {@link dedupeToolUsesById}.
88
+ * Antigravity buffers CLI receipts: its adapter-described wrappers match equal inner inputs
89
+ * anywhere in this assistant turn, in appearance order among equal keys. Pass only one turn's events;
90
+ * no state is retained across calls. Wrapper and bridge rows are each claimed at most once.
91
+ * A legacy gateway folds onto the first LATER canonical `tool_use` whose name equals its `input.toolId`.
92
+ * A direct call folds onto the first LATER `X` row with structurally equal JSON input that arrives
93
+ * before the CLI's own result for it. No canonical row is claimed twice, and a folded wrapper's
94
+ * `tool_result` events go with it. A wrapper with no such canonical row (refused before the
95
+ * bridge, e.g. a read-only 403) stays, so its error is not hidden.
96
+ *
97
+ * @param events - One assistant turn's events, normally already passed through {@link dedupeToolUsesById}.
73
98
  * @returns A new array without the folded wrappers, or the original reference when none fold.
74
- * @complexity O(n·w): n events, w wrapper calls (a handful per message).
99
+ * @throws CanonicalJsonError if a direct call's input contains non-JSON values.
100
+ * @complexity O(n·w·s): n events, w wrappers, s canonical input serialization cost (including key sorting).
101
+ * O(n + s) extra space; inputs and events are not mutated.
102
+ * @example foldDelegatedWrapperCalls({ events }); // canonical rows retain their own results
75
103
  */
76
- export function foldDelegatedWrapperCalls({ events }) {
104
+ export function foldDelegatedWrapperCalls({ events }, _options = {}) {
77
105
  if (!events || events.length === 0)
78
106
  return [];
79
107
  const claimed = new Set();
@@ -81,11 +109,11 @@ export function foldDelegatedWrapperCalls({ events }) {
81
109
  events.forEach((event, i) => {
82
110
  if (event.kind !== 'tool_use')
83
111
  return;
84
- const toolId = wrapperToolId({ event });
112
+ const call = wrapperCall({ event });
85
113
  // A replayed wrapper (same id) must not claim a second canonical row.
86
- if (toolId === undefined || folded.has(event.id))
114
+ if (call === undefined || folded.has(event.id))
87
115
  return;
88
- const match = events.findIndex((other, j) => j > i && !claimed.has(j) && other.kind === 'tool_use' && other.name === toolId);
116
+ const match = findCanonicalRow({ events, wrapper: event, wrapperIndex: i, call, claimed });
89
117
  if (match === -1)
90
118
  return;
91
119
  claimed.add(match);
@@ -96,4 +124,32 @@ export function foldDelegatedWrapperCalls({ events }) {
96
124
  return events.filter((event) => !((event.kind === 'tool_use' && folded.has(event.id)) ||
97
125
  (event.kind === 'tool_result' && folded.has(event.toolUseId))));
98
126
  }
127
+ /**
128
+ * Index of the first unclaimed canonical row in the wrapper's matching window, or -1. Legacy gateways match
129
+ * on name alone; direct calls and adapter-described wrappers also require equal inner input.
130
+ * @throws CanonicalJsonError if a direct call's input contains non-JSON values.
131
+ * @complexity O(n·s): n events, s canonical input serialization cost.
132
+ */
133
+ function findCanonicalRow({ events, wrapper, wrapperIndex, call, claimed }) {
134
+ const inputKey = call.matchInput ? canonicalJson({ value: { input: call.input } }) : undefined;
135
+ const [start, end] = canonicalSearchWindow({ events, wrapper, wrapperIndex, call });
136
+ for (let j = start; j < end; j += 1) {
137
+ const other = events[j];
138
+ if (claimed.has(j) || other.kind !== 'tool_use' || other.name !== call.name)
139
+ continue;
140
+ if (inputKey === undefined || canonicalJson({ value: { input: other.input } }) === inputKey)
141
+ return j;
142
+ }
143
+ return -1;
144
+ }
145
+ /** Pure matching bounds within one turn; O(n) time, O(1) space for n events.
146
+ * Antigravity's buffered receipts cannot establish arrival/result boundaries. Direct MCP calls
147
+ * retain their later-only window so a completed/refused call cannot consume a separate retry.
148
+ */
149
+ function canonicalSearchWindow({ events, wrapper, wrapperIndex, call }, _options = {}) {
150
+ if (call.buffered)
151
+ return [0, events.length];
152
+ const resultIndex = !call.matchInput ? -1 : events.findIndex(other => other.kind === 'tool_result' && other.toolUseId === wrapper.id);
153
+ return [wrapperIndex + 1, resultIndex === -1 ? events.length : resultIndex];
154
+ }
99
155
  //# sourceMappingURL=tool-events.js.map
@@ -0,0 +1,14 @@
1
+ type Call = {
2
+ name: string;
3
+ input: unknown;
4
+ };
5
+ /** Display-only call and, when present, the transport's inner call to correlate with the bridge. */
6
+ export type ToolPresentation = Call & {
7
+ wrapper?: Call;
8
+ };
9
+ /** Resolve CLI argument dialects at one boundary. Raw/persisted events remain untouched.
10
+ * Pure; O(k) only when an adapter copies k argument keys, otherwise O(1).
11
+ */
12
+ export declare function toolPresentation(call: Call, _options?: Record<string, never>): ToolPresentation;
13
+ export {};
14
+ //# sourceMappingURL=tool-presentation.d.ts.map
@@ -0,0 +1,8 @@
1
+ import { antigravityToolPresentation } from './antigravity/tool-presentation.js';
2
+ /** Resolve CLI argument dialects at one boundary. Raw/persisted events remain untouched.
3
+ * Pure; O(k) only when an adapter copies k argument keys, otherwise O(1).
4
+ */
5
+ export function toolPresentation(call, _options = {}) {
6
+ return antigravityToolPresentation(call) ?? call;
7
+ }
8
+ //# sourceMappingURL=tool-presentation.js.map
@@ -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) });
@@ -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;
@@ -22,7 +19,8 @@ export interface UserTextRedactionOptions {
22
19
  export declare const SECRET_REDACTED_NOTICE = "I removed what looked like a key from your message. If it was real, rotate it. Use the secure form to save it.";
23
20
  export declare const CREDENTIAL_CARD_GUIDANCE = "Use the secure form to save keys, tokens, passwords, and secrets. Chat answers are sent to the assistant.";
24
21
  /** Stricter than error redaction: also detects unlabelled long tokens, while retaining ordinary URLs,
25
- * hexadecimal hashes, identifiers and prose. Strong credential syntax inside a URL is still caught. */
22
+ * filesystem paths, hexadecimal hashes, identifiers and prose. Strong credential syntax inside
23
+ * URLs or paths is still caught. */
26
24
  export declare function redactUserText({ text }: {
27
25
  readonly text: string;
28
26
  }, { secretField }?: {
@@ -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();
@@ -26,7 +26,8 @@ function entropy(text) {
26
26
  return bits;
27
27
  }
28
28
  /** Stricter than error redaction: also detects unlabelled long tokens, while retaining ordinary URLs,
29
- * hexadecimal hashes, identifiers and prose. Strong credential syntax inside a URL is still caught. */
29
+ * filesystem paths, hexadecimal hashes, identifiers and prose. Strong credential syntax inside
30
+ * URLs or paths is still caught. */
30
31
  export function redactUserText({ text }, { secretField = false } = {}) {
31
32
  // Existing removal markers must survive a second pass through assignment rules (api_key=…).
32
33
  const literalMarkers = new Map();
@@ -40,16 +41,22 @@ export function redactUserText({ text }, { secretField = false } = {}) {
40
41
  }).replaceAll(REMOVED, INTERNAL_REMOVED);
41
42
  const base = redactSecretShapes({ text: protectedText, policy: { additionalRules: CHAT_RULES } });
42
43
  let count = base.redactions;
43
- const urls = [...base.text.matchAll(/\b[a-z][a-z0-9+.-]*:\/\/[^\s<>"']+/gi)]
44
+ // Exempt locations only AFTER specific secret rules. Only quoted paths may contain whitespace;
45
+ // unquoted paths end there so an adjacent slash-bearing secret cannot inherit the exemption.
46
+ // A quoted path ends at its first matching quote, which must sit on a word boundary, so an
47
+ // apostrophe later in the line ("don't") cannot close an unterminated quote and exempt the gap.
48
+ // Require a root boundary so a slash inside an opaque token cannot exempt that token, and a
49
+ // folder after the root so a bare "/" + token is not mistaken for a path.
50
+ const locations = [...base.text.matchAll(/\b[a-z][a-z0-9+.-]*:\/\/[^\s<>"']+|(?<!\w)(["'`])(?:~\/|[a-z]:[\\/]|\\\\|\/)[^\r\n"'`\\/]+[\\/](?:(?!\1)[^\r\n])*\1(?!\w)|(?<![\w+\/=\\~.-])(?:~\/|[a-z]:[\\/]|\\\\|\/)[^\s<>"'`\\/]+[\\/][^\s<>"'`]*/gi)]
44
51
  .map(match => ({ start: match.index, end: match.index + match[0].length }));
45
52
  let cleaned = base.text.replace(/(?<![A-Za-z0-9_+\/=-])[A-Za-z0-9_+\/=-]{40,}(?![A-Za-z0-9_+\/=-])/g, (candidate, offset) => {
46
- if (urls.some(url => offset >= url.start && offset < url.end))
53
+ if (locations.some(location => offset >= location.start && offset < location.end))
47
54
  return candidate;
48
55
  if (/^[a-f0-9]+$/i.test(candidate) || !/[A-Za-z]/.test(candidate))
49
56
  return candidate;
50
57
  const bits = entropy(candidate);
51
58
  const mixedAlphabet = /[A-Z]/.test(candidate) && /[a-z]/.test(candidate);
52
- if (bits < 4 || (!/[0-9]/.test(candidate) && (!mixedAlphabet || bits < 4.5)))
59
+ if (bits < 4 || (!/\d/.test(candidate) && (!mixedAlphabet || bits < 4.5)))
53
60
  return candidate;
54
61
  count += 1;
55
62
  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))