@dxos/plugin-assistant 0.13.0 → 0.14.0

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 (234) hide show
  1. package/PLUGIN.mdl +25 -0
  2. package/dist/lib/AssistantCapabilities.mjs +8 -1
  3. package/dist/lib/AssistantCapabilities.mjs.map +1 -1
  4. package/dist/lib/Settings.mjs +4 -0
  5. package/dist/lib/Settings.mjs.map +1 -1
  6. package/dist/lib/TurnReviewSkill.mjs +55 -0
  7. package/dist/lib/TurnReviewSkill.mjs.map +1 -0
  8. package/dist/lib/chunk-AgentArticle.mjs +1 -1
  9. package/dist/lib/chunk-AssistantSettings.mjs +1 -1
  10. package/dist/lib/chunk-AssistantSettings.mjs.map +1 -1
  11. package/dist/lib/chunk-Chat.mjs +322 -174
  12. package/dist/lib/chunk-Chat.mjs.map +1 -1
  13. package/dist/lib/chunk-ChatArticle.mjs +14 -7
  14. package/dist/lib/chunk-ChatArticle.mjs.map +1 -1
  15. package/dist/lib/chunk-ChatDialog.mjs +5 -4
  16. package/dist/lib/chunk-ChatDialog.mjs.map +1 -1
  17. package/dist/lib/chunk-IntegrationPrompt.mjs +1 -1
  18. package/dist/lib/chunk-PLUGIN.mjs +1 -1
  19. package/dist/lib/chunk-PluginUrlPrompt.mjs +1 -1
  20. package/dist/lib/chunk-QuestionSurface.mjs +1 -1
  21. package/dist/lib/chunk-SpaceHomePrompt.mjs +116 -60
  22. package/dist/lib/chunk-SpaceHomePrompt.mjs.map +1 -1
  23. package/dist/lib/chunk-TriggerStatus.mjs +1 -1
  24. package/dist/lib/chunk-agent-service.mjs +3 -1
  25. package/dist/lib/chunk-agent-service.mjs.map +1 -1
  26. package/dist/lib/chunk-analyze-turn.mjs +220 -0
  27. package/dist/lib/chunk-analyze-turn.mjs.map +1 -0
  28. package/dist/lib/{chunk-processor.mjs → chunk-chat-model.mjs} +95 -43
  29. package/dist/lib/chunk-chat-model.mjs.map +1 -0
  30. package/dist/lib/chunk-context.mjs.map +1 -1
  31. package/dist/lib/chunk-create-chat.mjs +5 -19
  32. package/dist/lib/chunk-create-chat.mjs.map +1 -1
  33. package/dist/lib/chunk-create-object.mjs +1 -1
  34. package/dist/lib/chunk-default-skills.mjs +28 -0
  35. package/dist/lib/chunk-default-skills.mjs.map +1 -0
  36. package/dist/lib/chunk-generate-home-suggestions.mjs +8 -6
  37. package/dist/lib/chunk-generate-home-suggestions.mjs.map +1 -1
  38. package/dist/lib/chunk-presets.mjs +1 -1
  39. package/dist/lib/chunk-presets.mjs.map +1 -1
  40. package/dist/lib/chunk-question-resumer.mjs +1 -1
  41. package/dist/lib/chunk-react-surface.mjs +1 -1
  42. package/dist/lib/chunk-run-prompt-in-chat.mjs +2 -1
  43. package/dist/lib/chunk-run-prompt-in-chat.mjs.map +1 -1
  44. package/dist/lib/chunk-run-prompt-in-new-chat.mjs +1 -1
  45. package/dist/lib/chunk-schema-defs.mjs +2 -2
  46. package/dist/lib/chunk-skill-definition.mjs +3 -2
  47. package/dist/lib/chunk-skill-definition.mjs.map +1 -1
  48. package/dist/lib/components.mjs +13 -13
  49. package/dist/lib/components.mjs.map +1 -1
  50. package/dist/lib/hooks.mjs +40 -31
  51. package/dist/lib/hooks.mjs.map +1 -1
  52. package/dist/lib/operations.mjs +3 -1
  53. package/dist/lib/operations.mjs.map +1 -1
  54. package/dist/lib/skills.mjs +2 -1
  55. package/dist/lib/translations.mjs +4 -2
  56. package/dist/lib/translations.mjs.map +1 -1
  57. package/dist/types/src/Chat.d.ts +1 -1
  58. package/dist/types/src/capabilities/agent-service.d.ts.map +1 -1
  59. package/dist/types/src/capabilities/skill-definition.d.ts.map +1 -1
  60. package/dist/types/src/{processor/processor.d.ts → chat-model/chat-model.d.ts} +24 -22
  61. package/dist/types/src/chat-model/chat-model.d.ts.map +1 -0
  62. package/dist/types/src/chat-model/chat-model.edge.test.d.ts +2 -0
  63. package/dist/types/src/chat-model/chat-model.edge.test.d.ts.map +1 -0
  64. package/dist/types/src/chat-model/chat-model.node.test.d.ts +2 -0
  65. package/dist/types/src/chat-model/chat-model.node.test.d.ts.map +1 -0
  66. package/dist/types/src/chat-model/chat-model.scripted.node.test.d.ts +2 -0
  67. package/dist/types/src/chat-model/chat-model.scripted.node.test.d.ts.map +1 -0
  68. package/dist/types/src/{processor → chat-model}/index.d.ts +1 -1
  69. package/dist/types/src/chat-model/index.d.ts.map +1 -0
  70. package/dist/types/src/chat-model/outbox.d.ts.map +1 -0
  71. package/dist/types/src/chat-model/outbox.test.d.ts.map +1 -0
  72. package/dist/types/src/chat-model/presets.d.ts.map +1 -0
  73. package/dist/types/src/chat-model/presets.test.d.ts.map +1 -0
  74. package/dist/types/src/{processor → chat-model}/prompt.d.ts +2 -2
  75. package/dist/types/src/chat-model/prompt.d.ts.map +1 -0
  76. package/dist/types/src/chat-model/prompt.test.d.ts.map +1 -0
  77. package/dist/types/src/chat-model/streaming.node.test.d.ts.map +1 -0
  78. package/dist/types/src/chat-model/testing/chat-model-recorder.d.ts +58 -0
  79. package/dist/types/src/chat-model/testing/chat-model-recorder.d.ts.map +1 -0
  80. package/dist/types/src/chat-model/testing/harness.d.ts +22 -0
  81. package/dist/types/src/chat-model/testing/harness.d.ts.map +1 -0
  82. package/dist/types/src/chat-model/thread.d.ts.map +1 -0
  83. package/dist/types/src/chat-model/thread.test.d.ts.map +1 -0
  84. package/dist/types/src/components/Chat/Chat.d.ts +6 -6
  85. package/dist/types/src/components/Chat/Chat.d.ts.map +1 -1
  86. package/dist/types/src/components/Chat/context.d.ts +5 -3
  87. package/dist/types/src/components/Chat/context.d.ts.map +1 -1
  88. package/dist/types/src/components/ChatPrompt/ChatActions.d.ts +1 -1
  89. package/dist/types/src/components/ChatPrompt/ChatActions.d.ts.map +1 -1
  90. package/dist/types/src/components/ChatPrompt/ChatMcpErrors.d.ts +3 -3
  91. package/dist/types/src/components/ChatPrompt/ChatMcpErrors.d.ts.map +1 -1
  92. package/dist/types/src/components/ChatPrompt/ChatOptions.d.ts +3 -1
  93. package/dist/types/src/components/ChatPrompt/ChatOptions.d.ts.map +1 -1
  94. package/dist/types/src/components/ChatPrompt/ChatOptions.stories.d.ts +4 -3
  95. package/dist/types/src/components/ChatPrompt/ChatOptions.stories.d.ts.map +1 -1
  96. package/dist/types/src/components/ChatPrompt/ChatPrompt.d.ts +15 -5
  97. package/dist/types/src/components/ChatPrompt/ChatPrompt.d.ts.map +1 -1
  98. package/dist/types/src/components/ChatPrompt/ChatPrompt.stories.d.ts +1 -1
  99. package/dist/types/src/components/ChatPrompt/ChatStatus.d.ts +1 -1
  100. package/dist/types/src/components/ChatPrompt/ChatStatus.d.ts.map +1 -1
  101. package/dist/types/src/components/ConnectorAuthMenu/ConnectorAuthMenu.stories.d.ts +9 -0
  102. package/dist/types/src/components/ConnectorAuthMenu/ConnectorAuthMenu.stories.d.ts.map +1 -1
  103. package/dist/types/src/components/Toolbox/Toolbox.d.ts +3 -3
  104. package/dist/types/src/components/Toolbox/Toolbox.d.ts.map +1 -1
  105. package/dist/types/src/containers/ChatArticle/ChatArticle.d.ts.map +1 -1
  106. package/dist/types/src/containers/ChatArticle/ChatArticle.stories.d.ts +1 -1
  107. package/dist/types/src/containers/SpaceHomePrompt/SpaceHomePrompt.d.ts.map +1 -1
  108. package/dist/types/src/hooks/index.d.ts +2 -2
  109. package/dist/types/src/hooks/index.d.ts.map +1 -1
  110. package/dist/types/src/hooks/{useChatProcessor.d.ts → useChatModel.d.ts} +6 -6
  111. package/dist/types/src/hooks/useChatModel.d.ts.map +1 -0
  112. package/dist/types/src/hooks/useDebug.d.ts +3 -3
  113. package/dist/types/src/hooks/useDebug.d.ts.map +1 -1
  114. package/dist/types/src/hooks/useHomeSuggestions.d.ts +3 -3
  115. package/dist/types/src/hooks/useHomeSuggestions.d.ts.map +1 -1
  116. package/dist/types/src/hooks/usePresets.d.ts +1 -1
  117. package/dist/types/src/hooks/usePresets.d.ts.map +1 -1
  118. package/dist/types/src/hooks/useSelectionContext.d.ts +3 -3
  119. package/dist/types/src/hooks/useSelectionContext.d.ts.map +1 -1
  120. package/dist/types/src/operations/analyze-turn.d.ts +5 -0
  121. package/dist/types/src/operations/analyze-turn.d.ts.map +1 -0
  122. package/dist/types/src/operations/analyze-turn.test.d.ts +2 -0
  123. package/dist/types/src/operations/analyze-turn.test.d.ts.map +1 -0
  124. package/dist/types/src/operations/create-chat.d.ts.map +1 -1
  125. package/dist/types/src/operations/generate-home-suggestions.d.ts.map +1 -1
  126. package/dist/types/src/operations/index.d.ts.map +1 -1
  127. package/dist/types/src/operations/run-prompt-in-chat.d.ts.map +1 -1
  128. package/dist/types/src/review/turn-review.d.ts +58 -0
  129. package/dist/types/src/review/turn-review.d.ts.map +1 -0
  130. package/dist/types/src/review/turn-review.test.d.ts +2 -0
  131. package/dist/types/src/review/turn-review.test.d.ts.map +1 -0
  132. package/dist/types/src/skills/index.d.ts +1 -0
  133. package/dist/types/src/skills/index.d.ts.map +1 -1
  134. package/dist/types/src/skills/turn-review/TurnReviewSkill.d.ts +21 -0
  135. package/dist/types/src/skills/turn-review/TurnReviewSkill.d.ts.map +1 -0
  136. package/dist/types/src/skills/turn-review/index.d.ts +2 -0
  137. package/dist/types/src/skills/turn-review/index.d.ts.map +1 -0
  138. package/dist/types/src/translations.d.ts.map +1 -1
  139. package/dist/types/src/types/Assistant.d.ts +1 -0
  140. package/dist/types/src/types/Assistant.d.ts.map +1 -1
  141. package/dist/types/src/types/AssistantCapabilities.d.ts +7 -1
  142. package/dist/types/src/types/AssistantCapabilities.d.ts.map +1 -1
  143. package/dist/types/src/types/Settings.d.ts +1 -0
  144. package/dist/types/src/types/Settings.d.ts.map +1 -1
  145. package/dist/types/src/util/agent-options.d.ts +27 -0
  146. package/dist/types/src/util/agent-options.d.ts.map +1 -0
  147. package/dist/types/src/util/agent-options.test.d.ts +2 -0
  148. package/dist/types/src/util/agent-options.test.d.ts.map +1 -0
  149. package/dist/types/src/util/default-skills.d.ts +10 -0
  150. package/dist/types/src/util/default-skills.d.ts.map +1 -0
  151. package/dist/types/tsconfig.tsbuildinfo +1 -1
  152. package/package.json +89 -80
  153. package/src/capabilities/agent-service.ts +2 -1
  154. package/src/capabilities/skill-definition.ts +2 -1
  155. package/src/chat-model/chat-model.edge.test.ts +301 -0
  156. package/src/{processor/processor.node.test.ts → chat-model/chat-model.node.test.ts} +19 -9
  157. package/src/chat-model/chat-model.scripted.node.test.ts +441 -0
  158. package/src/{processor/processor.ts → chat-model/chat-model.ts} +140 -45
  159. package/src/{processor → chat-model}/index.ts +1 -1
  160. package/src/{processor → chat-model}/prompt.ts +2 -2
  161. package/src/{processor → chat-model}/streaming.node.test.ts +108 -116
  162. package/src/chat-model/testing/chat-model-recorder.ts +249 -0
  163. package/src/chat-model/testing/harness.ts +81 -0
  164. package/src/{processor → chat-model}/thread.test.ts +1 -1
  165. package/src/components/Chat/Chat.tsx +44 -38
  166. package/src/components/Chat/Error.stories.tsx +1 -1
  167. package/src/components/Chat/context.ts +5 -3
  168. package/src/components/ChatPrompt/ChatActions.tsx +1 -1
  169. package/src/components/ChatPrompt/ChatMcpErrors.tsx +6 -6
  170. package/src/components/ChatPrompt/ChatOptions.stories.tsx +37 -3
  171. package/src/components/ChatPrompt/ChatOptions.tsx +118 -10
  172. package/src/components/ChatPrompt/ChatPrompt.stories.tsx +10 -10
  173. package/src/components/ChatPrompt/ChatPrompt.tsx +44 -27
  174. package/src/components/ChatPrompt/ChatStatus.tsx +8 -7
  175. package/src/components/Toolbox/Toolbox.tsx +12 -13
  176. package/src/containers/AssistantSettings/AssistantSettings.tsx +1 -1
  177. package/src/containers/ChatArticle/ChatArticle.stories.tsx +1 -1
  178. package/src/containers/ChatArticle/ChatArticle.tsx +16 -6
  179. package/src/containers/ChatDialog/ChatDialog.tsx +4 -4
  180. package/src/containers/SpaceHomePrompt/SpaceHomePrompt.tsx +110 -57
  181. package/src/hooks/index.ts +2 -2
  182. package/src/hooks/{useChatProcessor.ts → useChatModel.ts} +16 -14
  183. package/src/hooks/useDebug.ts +8 -8
  184. package/src/hooks/useHomeSuggestions.ts +14 -9
  185. package/src/hooks/usePresets.ts +2 -2
  186. package/src/hooks/useProcessEphemeralStatus.ts +1 -1
  187. package/src/hooks/useSelectionContext.ts +3 -5
  188. package/src/operations/analyze-turn.test.ts +194 -0
  189. package/src/operations/analyze-turn.ts +96 -0
  190. package/src/operations/create-chat.ts +4 -36
  191. package/src/operations/generate-home-suggestions.test.ts +1 -2
  192. package/src/operations/generate-home-suggestions.ts +17 -9
  193. package/src/operations/index.ts +2 -0
  194. package/src/operations/run-prompt-in-chat.ts +3 -2
  195. package/src/review/turn-review.test.ts +92 -0
  196. package/src/review/turn-review.ts +213 -0
  197. package/src/skills/index.ts +1 -0
  198. package/src/skills/turn-review/TurnReviewSkill.ts +46 -0
  199. package/src/skills/turn-review/index.ts +5 -0
  200. package/src/translations.ts +2 -0
  201. package/src/types/AssistantCapabilities.ts +8 -1
  202. package/src/types/Settings.ts +7 -0
  203. package/src/util/agent-options.test.ts +54 -0
  204. package/src/util/agent-options.ts +60 -0
  205. package/src/util/default-skills.ts +35 -0
  206. package/dist/lib/chunk-processor.mjs.map +0 -1
  207. package/dist/types/src/hooks/useChatProcessor.d.ts.map +0 -1
  208. package/dist/types/src/processor/index.d.ts.map +0 -1
  209. package/dist/types/src/processor/outbox.d.ts.map +0 -1
  210. package/dist/types/src/processor/outbox.test.d.ts.map +0 -1
  211. package/dist/types/src/processor/presets.d.ts.map +0 -1
  212. package/dist/types/src/processor/presets.test.d.ts.map +0 -1
  213. package/dist/types/src/processor/processor.d.ts.map +0 -1
  214. package/dist/types/src/processor/processor.node.test.d.ts +0 -2
  215. package/dist/types/src/processor/processor.node.test.d.ts.map +0 -1
  216. package/dist/types/src/processor/prompt.d.ts.map +0 -1
  217. package/dist/types/src/processor/prompt.test.d.ts.map +0 -1
  218. package/dist/types/src/processor/streaming.node.test.d.ts.map +0 -1
  219. package/dist/types/src/processor/thread.d.ts.map +0 -1
  220. package/dist/types/src/processor/thread.test.d.ts.map +0 -1
  221. /package/dist/types/src/{processor → chat-model}/outbox.d.ts +0 -0
  222. /package/dist/types/src/{processor → chat-model}/outbox.test.d.ts +0 -0
  223. /package/dist/types/src/{processor → chat-model}/presets.d.ts +0 -0
  224. /package/dist/types/src/{processor → chat-model}/presets.test.d.ts +0 -0
  225. /package/dist/types/src/{processor → chat-model}/prompt.test.d.ts +0 -0
  226. /package/dist/types/src/{processor → chat-model}/streaming.node.test.d.ts +0 -0
  227. /package/dist/types/src/{processor → chat-model}/thread.d.ts +0 -0
  228. /package/dist/types/src/{processor → chat-model}/thread.test.d.ts +0 -0
  229. /package/src/{processor → chat-model}/outbox.test.ts +0 -0
  230. /package/src/{processor → chat-model}/outbox.ts +0 -0
  231. /package/src/{processor → chat-model}/presets.test.ts +0 -0
  232. /package/src/{processor → chat-model}/presets.ts +0 -0
  233. /package/src/{processor → chat-model}/prompt.test.ts +0 -0
  234. /package/src/{processor → chat-model}/thread.ts +0 -0
@@ -23,12 +23,15 @@ import {
23
23
  createSystemPrompt,
24
24
  formatSystemPrompt,
25
25
  } from '@dxos/assistant';
26
+ import * as Agent from '@dxos/assistant/Agent';
26
27
  import type * as Chat from '@dxos/assistant/Chat';
27
28
  import * as AgentService from '@dxos/compute/AgentService';
28
29
  import type * as Credential from '@dxos/compute/Credential';
29
30
  import type * as Instructions from '@dxos/compute/Instructions';
30
31
  import * as Operation from '@dxos/compute/Operation';
32
+ import type * as Process from '@dxos/compute/Process';
31
33
  import type * as ServiceResolver from '@dxos/compute/ServiceResolver';
34
+ import * as Skill from '@dxos/compute/Skill';
32
35
  import * as Trace from '@dxos/compute/Trace';
33
36
  import { Database, Feed, Filter, Obj, Query, Ref, type Registry } from '@dxos/echo';
34
37
  import { UsageQuotaExceededError } from '@dxos/edge-client';
@@ -38,18 +41,19 @@ import { log } from '@dxos/log';
38
41
  import { ContentBlock, Message } from '@dxos/types';
39
42
  import { markWork } from '@dxos/util';
40
43
 
44
+ import { TurnReviewSkill } from '#skills';
41
45
  import { AssistantOperation } from '#types';
42
46
 
43
47
  import { findInCause } from '../util/error-cause.ts';
44
48
  import { Outbox, type OutboxEntry, PromptCancelledError } from './outbox.ts';
45
49
  import { providerForModel } from './presets.ts';
46
- import { type ProcessorRequestContext, createPromptContent } from './prompt.ts';
50
+ import { type ChatRequestContext, createPromptContent } from './prompt.ts';
47
51
  import { EMPTY_THREAD, type ThreadProjection, projectThread } from './thread.ts';
48
52
 
49
53
  /**
50
54
  * Space-scoped services materialised by the layer passed into
51
- * {@link AiChatProcessor}. Mirrors the tag list that
52
- * {@link useChatProcessor} passes to {@link ServiceResolver.provide}.
55
+ * {@link ChatModel}. Mirrors the tag list that
56
+ * {@link useChatModel} passes to {@link ServiceResolver.provide}.
53
57
  */
54
58
  export type SpaceServices =
55
59
  | Database.Service
@@ -59,7 +63,7 @@ export type SpaceServices =
59
63
  | Registry.Service
60
64
  | OpaqueToolkit.OpaqueToolkitProvider;
61
65
 
62
- export type AiChatProcessorOptions = {
66
+ export type ChatModelOptions = {
63
67
  /**
64
68
  * The model the chat's picker shows selected. The agent process reads the model off the chat, so
65
69
  * this is stamped onto a chat that has not selected one before its first request — otherwise the
@@ -77,26 +81,28 @@ export type AiChatProcessorOptions = {
77
81
  */
78
82
  chat?: Ref.Ref<Chat.Chat>;
79
83
  system?: string;
80
- /** Who this processor's prompts come from, when the chat is one of several people's with one agent. */
84
+ /** Who this chat model's prompts come from, when the chat is one of several people's with one agent. */
81
85
  sender?: AgentService.PromptSender;
86
+ /** Binds {@link TurnReviewSkill} so each turn is reviewed for struggles (opt-in setting). */
87
+ reportStruggles?: boolean;
82
88
  };
83
89
 
84
- const defaultOptions: Partial<AiChatProcessorOptions> = {
90
+ const defaultOptions: Partial<ChatModelOptions> = {
85
91
  model: Model.DEFAULT_EDGE,
86
92
  };
87
93
 
88
- export type ProcessorRequestOptions = {};
94
+ export type ChatRequestOptions = {};
89
95
 
90
- export type ProcessorRequest = {
96
+ export type ChatRequest = {
91
97
  message: string;
92
98
  /** `synthetic` marks system-generated turn content (e.g. a completed inline flow reporting itself). */
93
99
  disposition?: ContentBlock.Text['disposition'];
94
100
  /** Ephemeral context (e.g. companion-document selection) captured at submit time. */
95
- context?: ProcessorRequestContext;
96
- options?: ProcessorRequestOptions;
101
+ context?: ChatRequestContext;
102
+ options?: ChatRequestOptions;
97
103
  };
98
104
 
99
- export type ProcessorSendOptions = {
105
+ export type ChatSendOptions = {
100
106
  /**
101
107
  * Runs before the prompt is dispatched — after it is already showing — and a rejection fails the
102
108
  * prompt. Lets a transient chat persist and flush its conversation feed so the agent can resolve it.
@@ -107,7 +113,7 @@ export type ProcessorSendOptions = {
107
113
  /** Settles the dispatch of one prompt: resolved once the agent holds it, rejected if it never will. */
108
114
  export type PromptSubmission = { resolve: () => void; reject: (error: unknown) => void };
109
115
 
110
- type OutboxPayload = { request: ProcessorRequest; prepare?: () => Promise<void> | void };
116
+ type OutboxPayload = { request: ChatRequest; prepare?: () => Promise<void> | void };
111
117
 
112
118
  /** User-facing message shown when an AI request is rejected for exceeding the account usage quota (HTTP 429). */
113
119
  const QUOTA_EXCEEDED_MESSAGE = 'You have reached your AI usage limit for this period.';
@@ -157,6 +163,30 @@ const describeAiError = (err: AiError.AiError): string =>
157
163
  */
158
164
  const MODEL_UNAVAILABLE_PATTERN = /AI Model not available:\s*(\S+?):?(?=\s|$)/i;
159
165
 
166
+ /**
167
+ * Matches the first line of a stringified {@link AiError.AiError} ("…AiError: Module.method: detail"), the
168
+ * form a provider failure takes once it has crossed the agent-process boundary.
169
+ */
170
+ const AI_ERROR_PATTERN = /AiError: [\w$.]+: ([^\n]+)/;
171
+
172
+ /** The provider's own description of a failure that arrived stringified, anywhere in the cause chain. */
173
+ const stringifiedAiErrorText = (err: unknown): string | undefined => {
174
+ const visited = new Set<unknown>();
175
+ for (
176
+ let current = err;
177
+ current && !visited.has(current);
178
+ current = current instanceof Error ? current.cause : undefined
179
+ ) {
180
+ visited.add(current);
181
+ const text = typeof current === 'string' ? current : current instanceof Error ? current.message : '';
182
+ const detail = text.match(AI_ERROR_PATTERN)?.[1]?.trim();
183
+ if (detail) {
184
+ return detail;
185
+ }
186
+ }
187
+ return undefined;
188
+ };
189
+
160
190
  /** The displayable text of a failure, which reaches the chat either typed or already stringified. */
161
191
  const errorText = (err: unknown): string => (typeof err === 'string' ? err : err instanceof Error ? err.message : '');
162
192
 
@@ -204,6 +234,10 @@ export const parseError = (err: unknown): Error => {
204
234
  }
205
235
  }
206
236
 
237
+ if (!message) {
238
+ message = stringifiedAiErrorText(err);
239
+ }
240
+
207
241
  if (!message) {
208
242
  message = 'An unexpected error occurred.';
209
243
  }
@@ -215,7 +249,7 @@ export const parseError = (err: unknown): Error => {
215
249
  * Handles interactions with the AI service.
216
250
  * Uses AgentService to spawn a process-backed agent and subscribes to ephemeral trace events for streaming.
217
251
  */
218
- export class AiChatProcessor {
252
+ export class ChatModel {
219
253
  readonly #registry: AtomRegistry.AtomRegistry;
220
254
 
221
255
  /** Pending messages (finalized, non-streaming). */
@@ -227,14 +261,18 @@ export class AiChatProcessor {
227
261
  /** Set of message IDs that have been finalized (non-pending delivered via ephemeral). */
228
262
  readonly #finalizedIds = new Set<string>();
229
263
 
264
+ /** Timestamp of the newest trace event applied this turn, overall and per streamed message. */
265
+ #appliedAt = 0;
266
+ readonly #appliedAtByMessage = new Map<string, number>();
267
+
230
268
  /** Currently active request fiber. */
231
269
  #requestFiber: Fiber.Fiber<void, unknown> | undefined;
232
270
 
233
- /** Fiber following a turn this processor did not issue ({@link adopt}). */
271
+ /** Fiber following a turn this chat model did not issue ({@link adopt}). */
234
272
  #observeFiber: Fiber.Fiber<void, unknown> | undefined;
235
273
 
236
274
  /** Last request (for retries). */
237
- #lastRequest: ProcessorRequest | undefined;
275
+ #lastRequest: ChatRequest | undefined;
238
276
 
239
277
  /** Streaming state. */
240
278
  public readonly streaming = Atom.make<boolean>((get) => get(this.#streaming).length > 0);
@@ -287,16 +325,16 @@ export class AiChatProcessor {
287
325
  * {@link ServiceResolver.provide} with the {@link ServiceResolver} already
288
326
  * supplied (hence `RIn = never`); the {@link ServiceNotAvailableError}
289
327
  * error channel surfaces when a tag is not available for the space.
290
- * Provided to every effect run by the processor so the underlying
328
+ * Provided to every effect run by the chat model so the underlying
291
329
  * {@link ProcessManagerRuntime} has access to space-affinity services.
292
330
  */
293
331
  private readonly _spaceLayer: Layer.Layer<SpaceServices, ServiceResolver.ServiceNotAvailableError, never>,
294
- private readonly _options: AiChatProcessorOptions = defaultOptions,
332
+ private readonly _options: ChatModelOptions = defaultOptions,
295
333
  ) {
296
334
  this.#registry = this._options.observableRegistry ?? AtomRegistry.make();
297
335
  this.#outbox = new Outbox(this.#registry, (payload) => this.#dispatch(payload));
298
336
  this.outbox = this.#outbox.entries;
299
- // Held for the processor's life: a query's atom is memoized per result, so the query must be too.
337
+ // Held for the chat model's life: a query's atom is memoized per result, so the query must be too.
300
338
  const db = Obj.getDatabase(this._feed);
301
339
  this.#feedMessages = db
302
340
  ? db.query(Query.select(Filter.type(Message.Message)).from(this._feed)).atom
@@ -379,7 +417,7 @@ export class AiChatProcessor {
379
417
  *
380
418
  * Returns the outbox id, which is the prompt's thread row for its whole life.
381
419
  */
382
- send(request: ProcessorRequest, { prepare }: ProcessorSendOptions = {}): string {
420
+ send(request: ChatRequest, { prepare }: ChatSendOptions = {}): string {
383
421
  const content = createPromptContent(request);
384
422
  const blocks = typeof content === 'string' ? [ContentBlock.Text.make({ text: content })] : content;
385
423
  // What the feed holds now cannot be this prompt's echo, however alike it reads.
@@ -420,7 +458,7 @@ export class AiChatProcessor {
420
458
  * `submission` settles once the prompt is submitted (or cannot be), which is long before the turn
421
459
  * this resolves after.
422
460
  */
423
- async request(requestProp: ProcessorRequest, submission?: PromptSubmission): Promise<void> {
461
+ async request(requestProp: ChatRequest, submission?: PromptSubmission): Promise<void> {
424
462
  if (this.#requestFiber) {
425
463
  await this.cancel();
426
464
  }
@@ -433,6 +471,7 @@ export class AiChatProcessor {
433
471
  // that resolve is itself part of the wait the reader is watching.
434
472
  this.#registry.set(this.activity, { phase: 'starting' });
435
473
  this.#registry.set(this.active, true);
474
+ await this.#ensureTurnReview();
436
475
 
437
476
  const effect = Effect.gen({ self: this }, function* () {
438
477
  // NOTE: Gets or creates a session for the feed.
@@ -443,13 +482,15 @@ export class AiChatProcessor {
443
482
  });
444
483
  const session = yield* this.#getSession();
445
484
  markWork('chat.session-ready');
446
- yield* this.#forkEphemeralCollector(session);
485
+ // Subscribed before the prompt is submitted, so nothing of this turn needs replaying, and a
486
+ // replay would carry the process's earlier turns.
487
+ yield* this.#forkEphemeralCollector(session, { replay: false });
447
488
 
448
- log('chat processor submitting prompt', { length: requestProp.message.length });
489
+ log('chat model submitting prompt', { length: requestProp.message.length });
449
490
  yield* session.submitPrompt(createPromptContent(requestProp), { sender: this._options.sender });
450
491
  markWork('chat.prompt-submitted');
451
492
  submission?.resolve();
452
- log('chat processor submitPrompt returned, waiting for agent', {});
493
+ log('chat model submitPrompt returned, waiting for agent', {});
453
494
 
454
495
  // On the first message (no name yet), schedule rename immediately so it
455
496
  // runs concurrently with the AI response rather than waiting for completion. A synthetic
@@ -507,7 +548,7 @@ export class AiChatProcessor {
507
548
  * `waitForCompletion` already covers the queued turn, since the agent does not report completion
508
549
  * while its queue is non-empty.
509
550
  */
510
- async enqueue(requestProp: ProcessorRequest): Promise<void> {
551
+ async enqueue(requestProp: ChatRequest): Promise<void> {
511
552
  try {
512
553
  await this.#enqueue(requestProp);
513
554
  } catch (err) {
@@ -516,7 +557,7 @@ export class AiChatProcessor {
516
557
  }
517
558
  }
518
559
 
519
- async #enqueue(requestProp: ProcessorRequest): Promise<void> {
560
+ async #enqueue(requestProp: ChatRequest): Promise<void> {
520
561
  await this._runtime.runPromise(
521
562
  Effect.gen({ self: this }, function* () {
522
563
  const session = yield* this.#getSession();
@@ -527,8 +568,8 @@ export class AiChatProcessor {
527
568
  }
528
569
 
529
570
  /**
530
- * Mirrors turns this processor did not initiate into its own state: active/streaming state is
531
- * per-processor ({@link useChatProcessor} builds one per mount) while the agent process outlives
571
+ * Mirrors turns this chat model did not initiate into its own state: active/streaming state is
572
+ * per-chat model ({@link useChatModel} builds one per mount) while the agent process outlives
532
573
  * the mount, so a chat remounted mid-turn would otherwise render as idle.
533
574
  *
534
575
  * Returns a disposer that stops observing.
@@ -571,7 +612,7 @@ export class AiChatProcessor {
571
612
 
572
613
  /**
573
614
  * Follows a turn started elsewhere to completion, surfacing its streamed blocks here.
574
- * A turn this processor issued is owned by {@link request}, which reports its own errors.
615
+ * A turn this chat model issued is owned by {@link request}, which reports its own errors.
575
616
  */
576
617
  async #observe(session: AgentService.Session): Promise<void> {
577
618
  if (this.#requestFiber || this.#observeFiber || this.#registry.get(this.active)) {
@@ -580,9 +621,12 @@ export class AiChatProcessor {
580
621
 
581
622
  log.info('observing agent turn', { feed: Obj.getURI(this._feed) });
582
623
  try {
624
+ // Attaching is the `starting` phase; the agent's next report names the real one.
625
+ this.#registry.set(this.activity, { phase: 'starting' });
583
626
  this.#registry.set(this.active, true);
584
627
  const effect = Effect.gen({ self: this }, function* () {
585
- yield* this.#forkEphemeralCollector(session);
628
+ // Replayed: the turn being adopted is already under way.
629
+ yield* this.#forkEphemeralCollector(session, { replay: true });
586
630
  yield* session.waitForCompletion();
587
631
  this.#flushStreaming();
588
632
  });
@@ -612,9 +656,9 @@ export class AiChatProcessor {
612
656
  return Effect.gen({ self: this }, function* () {
613
657
  const chat = this._options.chat?.target;
614
658
  if (!chat) {
615
- // The agent process is bound to a chat; a processor constructed without one has no
659
+ // The agent process is bound to a chat; a chat model constructed without one has no
616
660
  // conversation to run.
617
- return yield* Effect.die(new Error('Chat processor requires a chat.'));
661
+ return yield* Effect.die(new Error('Chat model requires a chat.'));
618
662
  }
619
663
  const selected = this._options.model;
620
664
  if (!chat.session?.model && selected) {
@@ -627,7 +671,7 @@ export class AiChatProcessor {
627
671
  const model = chat.session?.model ?? selected;
628
672
  return yield* AgentService.getSession(chat, {
629
673
  provider: model ? providerForModel(model, this._options.provider) : this._options.provider,
630
- location: chat.remote ? 'edge' : 'local',
674
+ location: Agent.chatLocation(chat),
631
675
  });
632
676
  });
633
677
  }
@@ -636,14 +680,28 @@ export class AiChatProcessor {
636
680
  * Forks the collector for the session's ephemeral trace events (streaming blocks and MCP
637
681
  * failures) as a child of the calling fiber.
638
682
  */
639
- #forkEphemeralCollector(session: AgentService.Session): Effect.Effect<void> {
640
- return session.subscribeEphemeral().pipe(
683
+ #forkEphemeralCollector(
684
+ session: AgentService.Session,
685
+ options: Process.SubscribeEphemeralOptions,
686
+ ): Effect.Effect<void> {
687
+ return session.subscribeEphemeral(options).pipe(
641
688
  Stream.runForEach((message) =>
642
689
  Effect.sync(() => {
643
690
  for (const event of message.events) {
691
+ // A remote agent's trace is relayed over the swarm, which does not preserve order, so a
692
+ // phase or partial older than one already applied is stale rather than news.
644
693
  if (Trace.isOfType(Trace.PartialBlock, event)) {
645
- this.#handleEphemeralMessage(event.data);
694
+ // Read before `#isStale` records it: a block can be news for its message yet older than a
695
+ // phase already shown, which it must not turn back into `generating`.
696
+ const current = event.timestamp >= this.#appliedAt;
697
+ if (this.#isStale(event.timestamp, event.data.messageId)) {
698
+ continue;
699
+ }
700
+ this.#handleEphemeralMessage(event.data, current);
646
701
  } else if (Trace.isOfType(Trace.RequestPhase, event)) {
702
+ if (this.#isStale(event.timestamp)) {
703
+ continue;
704
+ }
647
705
  this.#registry.set(this.activity, event.data);
648
706
  } else if (Trace.isOfType(Trace.McpServerError, event)) {
649
707
  this.#handleMcpError(event.data);
@@ -712,17 +770,35 @@ export class AiChatProcessor {
712
770
  );
713
771
  }
714
772
 
773
+ /**
774
+ * Whether a trace event is older than one already applied (per message for a streamed block, whose
775
+ * text is the whole reply so far), recording it as the newest when it is not.
776
+ */
777
+ #isStale(timestamp: number, messageId?: string): boolean {
778
+ const appliedAt = messageId === undefined ? this.#appliedAt : (this.#appliedAtByMessage.get(messageId) ?? 0);
779
+ if (timestamp < appliedAt) {
780
+ return true;
781
+ }
782
+ this.#appliedAt = Math.max(this.#appliedAt, timestamp);
783
+ if (messageId !== undefined) {
784
+ this.#appliedAtByMessage.set(messageId, timestamp);
785
+ }
786
+ return false;
787
+ }
788
+
715
789
  /**
716
790
  * Handles an ephemeral message from the agent process.
717
791
  * Both pending and completed blocks arrive here. Completed blocks are deduped
718
792
  * against messages already written to the feed queue to handle the race between
719
793
  * ephemeral delivery and feed replication.
720
794
  */
721
- #handleEphemeralMessage(event: Trace.PayloadType<typeof Trace.PartialBlock>) {
795
+ #handleEphemeralMessage(event: Trace.PayloadType<typeof Trace.PartialBlock>, current = true) {
722
796
  // Content arriving is what "generating" means, and deriving it here keeps it out of the agent's
723
797
  // streaming pipeline, where the extra yield a trace write costs is observable to the turn's
724
798
  // tools. A tool call the agent reports supersedes it for as long as the tool runs.
725
- this.#registry.set(this.activity, { phase: 'generating' });
799
+ if (current) {
800
+ this.#registry.set(this.activity, { phase: 'generating' });
801
+ }
726
802
 
727
803
  const isPending = event.block.pending;
728
804
  const message = Obj.make(Message.Message, {
@@ -778,19 +854,42 @@ export class AiChatProcessor {
778
854
  this.#registry.set(this.#streaming, []);
779
855
  this.#registry.set(this.activity, undefined);
780
856
  this.#finalizedIds.clear();
857
+ this.#resetApplied();
781
858
  }
782
859
 
783
860
  /**
784
861
  * Move remaining streaming messages to pending (called when agent completes).
785
862
  */
786
863
  #flushStreaming() {
787
- this.#registry.set(this.activity, undefined);
864
+ // The activity line is left to the settle that clears `active`: cleared here, it would vanish a tick
865
+ // before the turn reads as finished.
788
866
  const remaining = this.#registry.get(this.#streaming);
789
867
  if (remaining.length > 0) {
790
868
  this.#registry.update(this.#pending, (pending) => [...pending, ...remaining]);
791
869
  this.#registry.set(this.#streaming, []);
792
870
  }
793
871
  this.#finalizedIds.clear();
872
+ this.#resetApplied();
873
+ }
874
+
875
+ #resetApplied() {
876
+ this.#appliedAt = 0;
877
+ this.#appliedAtByMessage.clear();
878
+ }
879
+
880
+ /**
881
+ * Binds the turn-review skill before a request while the user is opted in; its background
882
+ * end-request hook does the review, so nothing here waits on it. Left bound after an opt-out,
883
+ * since the hook re-checks the setting.
884
+ */
885
+ async #ensureTurnReview(): Promise<void> {
886
+ if (!this._options.reportStruggles) {
887
+ return;
888
+ }
889
+ const bound = this.context.getSkills().some((skill) => Obj.getMeta(skill).key === TurnReviewSkill.key);
890
+ if (!bound) {
891
+ await this.context.bind({ skills: [Ref.fromURI(Skill.registryURI(TurnReviewSkill.key))] });
892
+ }
794
893
  }
795
894
 
796
895
  /**
@@ -813,12 +912,9 @@ export class AiChatProcessor {
813
912
  }
814
913
  }
815
914
 
816
- export type AiChatProcessorState = Pick<
817
- AiChatProcessor,
818
- 'streaming' | 'active' | 'thread' | 'error' | 'mcpErrors' | 'activity'
819
- >;
915
+ export type ChatModelState = Pick<ChatModel, 'streaming' | 'active' | 'thread' | 'error' | 'mcpErrors' | 'activity'>;
820
916
 
821
- const idleProcessorState: AiChatProcessorState = {
917
+ const idleChatModelState: ChatModelState = {
822
918
  streaming: Atom.make(false),
823
919
  active: Atom.make(false),
824
920
  thread: Atom.make<ThreadProjection>(EMPTY_THREAD),
@@ -827,5 +923,4 @@ const idleProcessorState: AiChatProcessorState = {
827
923
  activity: Atom.make<Trace.PayloadType<typeof Trace.RequestPhase> | undefined>(undefined),
828
924
  };
829
925
 
830
- export const getProcessorState = (processor: AiChatProcessor | undefined): AiChatProcessorState =>
831
- processor ?? idleProcessorState;
926
+ export const getChatModelState = (chatModel: ChatModel | undefined): ChatModelState => chatModel ?? idleChatModelState;
@@ -4,6 +4,6 @@
4
4
 
5
5
  export * from './outbox.ts';
6
6
  export * from './presets.ts';
7
- export * from './processor.ts';
7
+ export * from './chat-model.ts';
8
8
  export * from './prompt.ts';
9
9
  export * from './thread.ts';
@@ -6,7 +6,7 @@ import { ContentBlock } from '@dxos/types';
6
6
  import { trim } from '@dxos/util';
7
7
 
8
8
  /** Ephemeral, per-request context captured at submit time (not part of the durable chat state). */
9
- export type ProcessorRequestContext = {
9
+ export type ChatRequestContext = {
10
10
  selection?: {
11
11
  /** Anchor strings (`"${from}:${to}"` cursor pairs) the text was resolved from; enables future in-place actions. */
12
12
  anchors: string[];
@@ -23,7 +23,7 @@ export type ProcessorRequestContext = {
23
23
  export const createPromptContent = (request: {
24
24
  message: string;
25
25
  disposition?: ContentBlock.Text['disposition'];
26
- context?: ProcessorRequestContext;
26
+ context?: ChatRequestContext;
27
27
  }): string | ContentBlock.Any[] => {
28
28
  if (request.disposition === 'synthetic') {
29
29
  return [ContentBlock.Text.make({ text: request.message, disposition: 'synthetic' })];