@openchambery/web 1.19.13-beta.3 → 1.19.13-beta.5

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 (113) hide show
  1. package/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-B5MiX004.js} +1 -1
  2. package/dist/assets/AssistantView-B6kZZdri.js +2 -0
  3. package/dist/assets/{DiagramView-W0IHFk-1.js → DiagramView-C40ituM2.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
  5. package/dist/assets/{Icon-CkzzhaLr.js → Icon-ZAKaNHiG.js} +1 -1
  6. package/dist/assets/{MarkdownRenderer-v7n_D6nL.js → MarkdownRenderer-mcY597G0.js} +2 -2
  7. package/dist/assets/{MarkdownRendererImpl-Buz5LZsa.js → MarkdownRendererImpl-YsTrEeUY.js} +6 -3
  8. package/dist/assets/MarkstreamRendererImpl-kNx_G9Sd.js +3 -0
  9. package/dist/assets/MobileDetailNavigation-Uwzh6TWj.js +1 -0
  10. package/dist/assets/MobileShareBridge-D3oSiSC6.js +4 -0
  11. package/dist/assets/MobileSurface-B-SWjAj7.js +2 -0
  12. package/dist/assets/{MultiRunWindow-yWIHgr89.js → MultiRunWindow-LgdUonfa.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-DHKq6y3W.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
  15. package/dist/assets/{SettingsView-C3BbLpHT.js → SettingsView-iUiWTKbM.js} +8 -8
  16. package/dist/assets/{SettingsWindow-b0JWdygM.js → SettingsWindow-DCDEAC1n.js} +1 -1
  17. package/dist/assets/{TerminalView-C74IvmOt.js → TerminalView-CdZi_UXQ.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-BORu8CtR.js → ToolOutputDialog-fZq6UHvy.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-DOwhB93n.js → appThemeRegistry-DyFotcOM.js} +1 -1
  20. package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
  21. package/dist/assets/{clipboard-CUasgPiF.js → clipboard-CzXos-TI.js} +1 -1
  22. package/dist/assets/{collapsible-CACHBRYZ.js → collapsible-Bbz9KiJf.js} +1 -1
  23. package/dist/assets/{context-menu-CYbm4wBZ.js → context-menu-CSyOhRz8.js} +1 -1
  24. package/dist/assets/{desktopHosts-DILvrAok.js → desktopHosts-B052a4UK.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-t8asCr3m.js → desktopRelayRestore-OjKNXr10.js} +1 -1
  26. package/dist/assets/{diagram-u0BC9uuY.js → diagram-NdsZih8P.js} +1 -1
  27. package/dist/assets/{dialog-BwQ3rY_x.js → dialog-U4ML8hxt.js} +1 -1
  28. package/dist/assets/{es-CRjOwvYM.js → es-BtC17YQH.js} +1 -1
  29. package/dist/assets/fileMentionTouchSelection-DSKcxgU_.js +4 -0
  30. package/dist/assets/{fileQueries-Bgly-E8f.js → fileQueries-B_s_B-fH.js} +1 -1
  31. package/dist/assets/{fr-D9taEN9L.js → fr-DPohCCUO.js} +1 -1
  32. package/dist/assets/{gitApi-D1mvv14J.js → gitApi-BUubBKri.js} +3 -3
  33. package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
  34. package/dist/assets/insertionBoundaries-DeGd-RP9.js +1 -0
  35. package/dist/assets/{ja-CYQ5aoWp.js → ja-CflPxUNi.js} +1 -1
  36. package/dist/assets/{ko-BwASV0Q0.js → ko-CuSU80xd.js} +1 -1
  37. package/dist/assets/main-C6EluFKk.js +169 -0
  38. package/dist/assets/{main-phpzFgMN.js → main-iFGhn-zU.js} +2 -2
  39. package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
  40. package/dist/assets/miniChat-Bs_H98PB.js +2 -0
  41. package/dist/assets/mobile-HfLUO-bG.js +2 -0
  42. package/dist/assets/{multirun-CYuxJPBX.js → multirun-BABuRg1O.js} +1 -1
  43. package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
  44. package/dist/assets/{pl-DUI1vPil.js → pl-eJvVbQ74.js} +1 -1
  45. package/dist/assets/platform-DQ5_LNKO.js +2 -0
  46. package/dist/assets/{projectMeta-Cir66uF3.js → projectMeta-WcxDg0DZ.js} +2 -2
  47. package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-DSejAksY.js} +1 -1
  48. package/dist/assets/{renderElectronMiniChatApp-BzYqqr9n.js → renderElectronMiniChatApp-Cp-dVXj5.js} +2 -2
  49. package/dist/assets/renderMobileApp-B6rb6wuc.js +28 -0
  50. package/dist/assets/{runtimeConfig-BXf9GQ0Q.js → runtimeConfig-88DzHVMT.js} +1 -1
  51. package/dist/assets/runtimeEndpointReset-CEbKea6x.js +147 -0
  52. package/dist/assets/sessionChangeSummary-C9071lsK.js +2 -0
  53. package/dist/assets/sessionLookup-CEw3tCz1.js +1 -0
  54. package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-eL2whsf8.js} +1 -1
  55. package/dist/assets/src-B_DG0xy5.css +2 -0
  56. package/dist/assets/{store-CSnH_QXM.js → store-DHdMj3y4.js} +2 -2
  57. package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
  58. package/dist/assets/{textarea-BfNtUdJE.js → textarea-DE1UrdtK.js} +1 -1
  59. package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
  60. package/dist/assets/{uk-C1FYZlmA.js → uk-DV9OgOAg.js} +1 -1
  61. package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
  62. package/dist/assets/{useAppFontEffects-NVyoI_Ei.js → useAppFontEffects-Ca0mASq5.js} +43 -44
  63. package/dist/assets/{useDesktopWindowControlsLayout-CP3NmMYG.js → useDesktopWindowControlsLayout-B3KJYYwp.js} +1 -1
  64. package/dist/assets/{useEffectiveDirectory-AUoqSyOJ.js → useEffectiveDirectory-B4l9snxd.js} +1 -1
  65. package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
  66. package/dist/assets/useMobileNavigationStore-CU2UwmN3.js +413 -0
  67. package/dist/assets/{useSessionAutoCleanup-ClvQQPqQ.js → useSessionAutoCleanup-B9El9yjX.js} +1 -1
  68. package/dist/assets/{useWorkerHighlightedLines-DOk4cAxv.js → useWorkerHighlightedLines-PPhFeDFI.js} +1 -1
  69. package/dist/assets/{utils-BHTVlVov.js → utils-2ZK8yboQ.js} +1 -1
  70. package/dist/assets/{vendor-reactuses-core-DDoDDyM9.js → vendor-reactuses-core-BnW6GxJt.js} +1 -1
  71. package/dist/assets/{zh-CN-C96qP1ip.js → zh-CN-92n_rGTt.js} +1 -1
  72. package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-CwVxwJCj.js} +1 -1
  73. package/dist/index.html +8 -8
  74. package/dist/mini-chat.html +8 -8
  75. package/dist/mobile.html +8 -8
  76. package/package.json +1 -1
  77. package/server/index.js +1 -0
  78. package/server/lib/assistants/DOCUMENTATION.md +104 -18
  79. package/server/lib/assistants/assign.js +173 -13
  80. package/server/lib/assistants/assign.test.js +139 -0
  81. package/server/lib/assistants/bubbles.js +1 -1
  82. package/server/lib/assistants/contact-store.js +496 -11
  83. package/server/lib/assistants/contact-store.test.js +323 -7
  84. package/server/lib/assistants/contact-tools.js +514 -52
  85. package/server/lib/assistants/contact-tools.test.js +390 -7
  86. package/server/lib/assistants/contracts.js +31 -0
  87. package/server/lib/assistants/harness.js +224 -54
  88. package/server/lib/assistants/harness.test.js +372 -12
  89. package/server/lib/assistants/routes.js +3 -0
  90. package/server/lib/assistants/service.js +1079 -38
  91. package/server/lib/assistants/service.test.js +1235 -26
  92. package/server/lib/llm/DOCUMENTATION.md +2 -0
  93. package/server/lib/llm/completions.js +4 -0
  94. package/server/lib/llm/completions.test.js +3 -0
  95. package/server/lib/llm/generate.js +12 -5
  96. package/server/lib/llm/generate.test.js +40 -0
  97. package/server/lib/opencode/feature-routes-runtime.js +5 -0
  98. package/dist/assets/AssistantView-DOZPK9oh.js +0 -2
  99. package/dist/assets/MarkstreamRendererImpl-D4nUXYpP.js +0 -3
  100. package/dist/assets/MobileDetailNavigation-Bfs4CTUh.js +0 -1
  101. package/dist/assets/MobileShareBridge-DtwVLkBL.js +0 -4
  102. package/dist/assets/MobileSurface-BmYYdY3g.js +0 -2
  103. package/dist/assets/insertionBoundaries-BBn8YW82.js +0 -4
  104. package/dist/assets/main-Cd7NwXx5.js +0 -169
  105. package/dist/assets/miniChat-D6UYe50H.js +0 -2
  106. package/dist/assets/mobile-w2Ed9I5O.js +0 -2
  107. package/dist/assets/platform-DzsGqlGW.js +0 -2
  108. package/dist/assets/renderMobileApp-BvIE86_O.js +0 -28
  109. package/dist/assets/runtimeEndpointReset-lF5S7U4I.js +0 -147
  110. package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
  111. package/dist/assets/sessionLookup-CNRDupYq.js +0 -1
  112. package/dist/assets/src-AA-5nXsF.css +0 -2
  113. package/dist/assets/useMobileNavigationStore-DANrWekj.js +0 -413
@@ -1,21 +1,34 @@
1
1
  import { Agent } from '@earendil-works/pi-agent-core';
2
2
  import { isContactSpokenPreamble, splitContactBubbles } from './bubbles.js';
3
3
  import {
4
+ ASSIGN_SESSION_TOOL_NAME,
4
5
  CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
6
+ CLEAR_CHAT_HISTORY_TOOL_NAME,
5
7
  confirmBubbleAfterContactReset,
6
8
  contactTurnClearedChatHistory,
7
9
  contactTurnHasSuccessfulReset,
8
10
  contactTurnHasToolResult,
11
+ CREATE_ASSISTANT_TOOL_NAME,
9
12
  detectRequestedContactTools,
10
13
  extractContactCardsFromMessages,
11
14
  formatContactToolsPrompt,
12
15
  formatConnectedModelsPrompt,
13
16
  formatRegisteredProjectsPrompt,
17
+ GET_ASSISTANT_SETTINGS_TOOL_NAME,
18
+ MESSAGE_ASSISTANT_TOOL_NAME,
14
19
  MISSED_FENCE_RETRY_USER_TEXT,
15
20
  MISSED_TOOL_FAILURE_BUBBLE,
16
21
  NEW_CONVERSATION_CONFIRM_BUBBLE,
22
+ NEW_CONVERSATION_TOOL_NAME,
17
23
  parseContactToolCalls,
24
+ SCHEDULE_TASK_TOOL_NAME,
25
+ STOP_SESSION_TOOL_NAME,
26
+ STEER_SESSION_TOOL_NAME,
27
+ ARCHIVE_SESSION_TOOL_NAME,
28
+ DELETE_SESSION_TOOL_NAME,
18
29
  stripContactToolFences,
30
+ UPDATE_DEFAULT_PROMPT_TOOL_NAME,
31
+ WATCH_SESSION_TOOL_NAME,
19
32
  } from './contact-tools.js';
20
33
  import {
21
34
  createPiCodingRuntime,
@@ -71,19 +84,72 @@ function createAssistantMessageEventStream() {
71
84
  };
72
85
  }
73
86
 
87
+ const CONTACT_LANGUAGE_NAMES = {
88
+ en: 'English',
89
+ fr: 'French',
90
+ 'zh-CN': 'Simplified Chinese',
91
+ 'zh-TW': 'Traditional Chinese',
92
+ uk: 'Ukrainian',
93
+ es: 'Spanish',
94
+ 'pt-BR': 'Brazilian Portuguese',
95
+ ko: 'Korean',
96
+ pl: 'Polish',
97
+ ja: 'Japanese',
98
+ };
99
+
100
+ const CONTACT_LANGUAGE_FALLBACK = "the user's interface language";
101
+
102
+ /**
103
+ * Map a UI locale code to a display name for the system prompt. Unknown or
104
+ * missing values fall back to a neutral phrase so `{{LANGUAGE}}` never leaks.
105
+ * Whitelisted only: arbitrary client text must not reach the system prompt.
106
+ */
107
+ export const resolveContactLanguage = (value) => {
108
+ if (typeof value !== 'string') return CONTACT_LANGUAGE_FALLBACK;
109
+ const code = value.trim();
110
+ if (!code) return CONTACT_LANGUAGE_FALLBACK;
111
+ if (CONTACT_LANGUAGE_NAMES[code]) return CONTACT_LANGUAGE_NAMES[code];
112
+ const match = Object.keys(CONTACT_LANGUAGE_NAMES).find((key) => key.toLowerCase() === code.toLowerCase());
113
+ return match ? CONTACT_LANGUAGE_NAMES[match] : CONTACT_LANGUAGE_FALLBACK;
114
+ };
115
+
74
116
  export const CONTACT_SYSTEM_PROMPT = [
75
117
  "You are OpenChamber's in-app assistant — a personable contact who can also work in the user's configured project directory.",
76
118
  'Reply in short chat bubbles: a few sentences each, separated by a blank line.',
77
119
  'Talk like a person in the user\'s language. One short spoken bubble at a time — never a wall of paragraphs.',
120
+ 'Always reply in {{LANGUAGE}} — the user\'s current interface language — even when the user writes in another language, unless they explicitly ask for a different one.',
78
121
  'Never write chain-of-thought, plans, tool names, or English narration of what you will do. The user never sees thinking.',
79
122
  'Do not expose tool traces, Activity, or editor actions.',
80
123
  'You have bash, read, write, and edit in the working directory. Use them for pwd, files, and shell. Never say you have no terminal or cannot read files. Ignore any temporary generator workspace in the environment.',
81
- 'Understand natural language in any language, including Chinese: 开新对话 / 清除记忆 means new_conversation (LLM memory only, chat history stays), 清空聊天记录 means clear_chat_history (delete transcript), 找项目 means list_projects, 现有对话 means list_sessions, 建助理 means create_assistant, 建会话 / 开个新会话 means assign_session, 排定时任务 means schedule_task, 给 X 说一声 means message_assistant, 发卡片 means emit a card via those tools — never ask the user to type /card or /dm.',
124
+ 'Understand natural language in any language, including Chinese: 开新对话 / 清除记忆 means new_conversation (LLM memory only, chat history stays), 清空聊天记录 means clear_chat_history (delete transcript), 找项目 means list_projects, 现有对话 means list_sessions, 查看助手设定 / 默认提示词 means get_assistant_settings (pass to="Name" for another assistant), 改默认提示词 / 设置人设 / 改某助手的默认提示词 means update_default_prompt (persists that assistant\'s settings, later turns only; pass to="OpenCode 配置助手" to edit another contact without changing this one), 建助理 means create_assistant, 建会话 / 开个新会话 / 继续会话 means assign_session, 监听会话 means watch_session, 停止/取消/打断会话 means stop_session, 插话 means steer_session, 归档会话 means archive_session, 删除会话 means delete_session, 排定时任务 means schedule_task, 给 X 说一声 means message_assistant, 发卡片 means emit a card via those tools — never ask the user to type /card or /dm.',
82
125
  'You receive the registered project catalog every turn. You CAN see those projects. Look them up yourself (fuzzy match label/name/path). Never say you cannot see the registered project list. Never ask for a raw filesystem path when a name matches. If the catalog is empty, tell the user to add a project in Settings.',
83
- 'File and shell work in this working directory uses read, write, edit, and bash. To open a separate Chat coding session: match the project, optionally list_sessions for existing chats, then assign_session with projectPath or sessionID. Optional worker model via providerID/modelID/model from the connected catalog — that does not change this contact. Current-turn user attachments are server-forwarded on assign. One successful assign_session ends the turn — do not call it again.',
84
- 'A reply without the tool call does nothing. Never say 已创建, created, scheduled, or opened unless the tool already returned success.',
126
+ 'File and shell work in this working directory uses read, write, edit, and bash. To open a separate Chat coding session: match the project, optionally list_sessions for existing chats, then assign_session with projectPath or sessionID. To continue an existing chat, assign_session with sessionID plus a coding prompt (omit model args to keep the prior worker model). To only listen without prompting when asked (监听/watch/monitor), watch_session with sessionID — a plain @session:id mention alone is not a watch request. To abort a running session, stop_session with sessionID. Optional worker model via providerID/modelID/model from the connected catalog — that does not change this contact. Current-turn user attachments are server-forwarded on assign. One successful assign_session or watch_session ends the turn — do not call it again.',
127
+ 'A reply without the tool call does nothing. Never say 已创建, created, scheduled, opened, watched, or stopped unless the tool already returned success.',
85
128
  ].join(' ');
86
129
 
130
+ // A workspace response must explicitly finish or ask for missing input. Plain
131
+ // progress prose is not a final answer and must not silently end the pi loop.
132
+ const WORKSPACE_RESPONSE_PROTOCOL = [
133
+ 'Workspace response protocol: continue using openchamber-tool calls until the requested work and verification are done.',
134
+ 'You may batch independent coding calls, but they execute sequentially in the order supplied. Wait for results before using their output. Send OpenChamber operation tools one at a time.',
135
+ 'Reading a skill only loads instructions; execute its applicable steps before completing the task.',
136
+ 'To finish, reply with only an openchamber-final JSON fence: ```openchamber-final\n{"status":"complete","text":"Your final answer in the user language"}\n```.',
137
+ 'Use status "blocked" with the missing information or real blocker when you cannot proceed. Questions and ordinary conversation also use this final format; they do not require a tool call.',
138
+ 'Do not use a final declaration for a progress update or promise to act later. Never invent tool results, User messages, or future assistant turns. Only supplied tool-result records are execution evidence.',
139
+ ].join('\n');
140
+
141
+ const parseFinalResponse = (text) => {
142
+ const match = text.trim().match(/^```openchamber-final\s*([\s\S]*?)```$/u);
143
+ if (!match) return null;
144
+ try {
145
+ const value = JSON.parse(match[1]);
146
+ if (!value || !['complete', 'blocked'].includes(value.status) || typeof value.text !== 'string' || !value.text.trim()) return null;
147
+ return { status: value.status, text: value.text.trim() };
148
+ } catch {
149
+ return null;
150
+ }
151
+ };
152
+
87
153
  const emptyUsage = () => ({
88
154
  input: 0,
89
155
  output: 0,
@@ -229,7 +295,9 @@ export function createContactStreamFn(createChatCompletion, {
229
295
  onBubbleDelta = null,
230
296
  globalEventHub = null,
231
297
  bubbleGapMs = 0,
298
+ signal = null,
232
299
  } = {}) {
300
+ let callSequence = 0;
233
301
  return (model, context) => {
234
302
  const stream = createAssistantMessageEventStream();
235
303
  const run = async () => {
@@ -249,10 +317,13 @@ export function createContactStreamFn(createChatCompletion, {
249
317
  }];
250
318
  }
251
319
  if (message.role === 'assistant') {
252
- const content = (message.content || [])
253
- .filter((part) => part?.type === 'text')
254
- .map((part) => part.text)
255
- .join('');
320
+ const content = (message.content || []).map((part) => {
321
+ if (part?.type === 'text') return part.text;
322
+ if (part?.type !== 'toolCall') return '';
323
+ return '```openchamber-tool\n' + JSON.stringify({
324
+ id: part.id, name: part.name, arguments: part.arguments,
325
+ }) + '\n```';
326
+ }).filter(Boolean).join('\n\n');
256
327
  return content ? [{ role: 'assistant', content }] : [];
257
328
  }
258
329
  if (message.role === 'toolResult') {
@@ -260,7 +331,6 @@ export function createContactStreamFn(createChatCompletion, {
260
331
  .filter((part) => part?.type === 'text')
261
332
  .map((part) => part.text)
262
333
  .join('');
263
- if (!content) return [];
264
334
  const toolName = typeof message.toolName === 'string' && message.toolName.trim()
265
335
  ? message.toolName.trim()
266
336
  : 'result';
@@ -270,7 +340,7 @@ export function createContactStreamFn(createChatCompletion, {
270
340
  const label = callId
271
341
  ? `OpenChamber tool result name=${toolName} call=${callId}`
272
342
  : `OpenChamber tool result name=${toolName}`;
273
- return [{ role: 'user', content: `${label}: ${content}` }];
343
+ return [{ role: 'user', content: `${label}: ${JSON.stringify({ isError: message.isError === true, content })}` }];
274
344
  }
275
345
  return [];
276
346
  });
@@ -282,40 +352,68 @@ export function createContactStreamFn(createChatCompletion, {
282
352
  if (lastUser && fallbackFiles.length > 0 && !Array.isArray(lastUser.parts)) {
283
353
  lastUser.parts = fallbackFiles;
284
354
  }
285
- const bubbleTracker = createContactBubbleDeltaTracker(onBubbleDelta);
286
- const result = await createChatCompletion({
287
- body: {
288
- model: `${model.provider === 'openchamber' ? '' : `${model.provider}/`}${model.id}`.replace(/^\//, '') || model.name,
289
- providerID: typeof model.name === 'string' && model.name.includes('/')
290
- ? model.name.split('/')[0]
291
- : undefined,
292
- modelID: model.id,
293
- messages,
294
- },
295
- onTextDelta: (delta) => {
296
- if (typeof delta !== 'string' || !delta) return;
297
- // Do not live-paint raw tokens: they are often chain-of-thought
298
- // and would appear then vanish after parse/persist.
299
- if (typeof onTextDelta === 'function') onTextDelta(delta);
300
- },
301
- globalEventHub,
302
- });
303
- const text = result?.completion?.choices?.[0]?.message?.content
304
- ?? result?.text
305
- ?? '';
306
355
  const allowedNames = (context.tools || []).map((tool) => tool?.name).filter(Boolean);
307
- const parsed = parseContactToolCalls(text, allowedNames);
308
- if (parsed.toolCall) {
309
- const toolCall = {
356
+ const requiresFinal = allowedNames.some(isPiCodingToolName);
357
+ let text;
358
+ let parsed;
359
+ let finalText;
360
+ let rejectedCodingCalls = [];
361
+ // Repair only the rejected response: no tools have executed from it.
362
+ // Real results stay in messages, so already completed work is not replayed.
363
+ for (let attempt = 0; ; attempt += 1) {
364
+ signal?.throwIfAborted();
365
+ const result = await createChatCompletion({
366
+ signal,
367
+ body: {
368
+ model: `${model.provider === 'openchamber' ? '' : `${model.provider}/`}${model.id}`.replace(/^\//, '') || model.name,
369
+ providerID: typeof model.name === 'string' && model.name.includes('/')
370
+ ? model.name.split('/')[0]
371
+ : undefined,
372
+ modelID: model.id,
373
+ messages: [...messages],
374
+ },
375
+ onTextDelta: (delta) => {
376
+ if (typeof delta === 'string' && delta && typeof onTextDelta === 'function') onTextDelta(delta);
377
+ },
378
+ globalEventHub,
379
+ });
380
+ signal?.throwIfAborted();
381
+ text = result?.completion?.choices?.[0]?.message?.content ?? result?.text ?? '';
382
+ const finalResponse = parseFinalResponse(text);
383
+ finalText = finalResponse?.text ?? null;
384
+ // A final answer is opaque text; quoted tool examples in it cannot run.
385
+ parsed = finalText === null ? parseContactToolCalls(text, allowedNames) : { toolCalls: [], chatText: finalText };
386
+ const mixedBatch = parsed.toolCalls.length > 1 && parsed.toolCalls.some((call) => !isPiCodingToolName(call.name));
387
+ const malformedFinal = finalText === null && /```openchamber-final/u.test(parsed.chatText);
388
+ const skippedRejectedWork = rejectedCodingCalls.length > 0 && finalResponse?.status === 'complete';
389
+ const forgedTranscript = finalText === null && /(?:^|\n)\s*(?:(?:User|Assistant|Tool):|OpenChamber tool result name=)/iu.test(parsed.chatText);
390
+ const protocolError = parsed.protocolError
391
+ || (forgedTranscript ? 'Do not simulate user messages, assistant turns, or tool results. Output only your next real tool call and wait for execution.' : '')
392
+ || (skippedRejectedWork ? 'Previously rejected coding calls have not executed. Issue the remaining tool calls, or report a blocked outcome; do not declare them complete.' : '')
393
+ || (mixedBatch ? 'Send OpenChamber operation tools one at a time, without other calls in the same response.' : '')
394
+ || (malformedFinal ? 'The final declaration must be a single valid openchamber-final JSON fence with status and text, without other content.' : '')
395
+ || (requiresFinal && parsed.toolCalls.length === 0 && finalText === null ? 'A workspace response needs an actual tool call or an explicit final declaration.' : '');
396
+ if (!protocolError) break;
397
+ if (attempt >= 2) throw new Error('Assistant response protocol failed after two corrections; the task was not confirmed complete.');
398
+ const rejected = parsed.toolCalls.filter((call) => isPiCodingToolName(call.name));
399
+ if (rejected.length > 0) rejectedCodingCalls = rejected.map(({ name, arguments: args }) => ({ name, arguments: args }));
400
+ // Never replay rejected prose: it may contain fabricated user/tool results.
401
+ messages.push(
402
+ { role: 'user', content: `OpenChamber response protocol correction: ${protocolError} No tool calls from the rejected response executed. ${rejectedCodingCalls.length ? `Unexecuted proposed calls (not results): ${JSON.stringify(rejectedCodingCalls)}.` : ''} Continue the remaining work using the supplied real results; do not repeat completed operations. ${requiresFinal ? WORKSPACE_RESPONSE_PROTOCOL : 'Emit the corrected openchamber-tool call.'}` },
403
+ );
404
+ }
405
+ const bubbleTracker = createContactBubbleDeltaTracker(onBubbleDelta);
406
+ if (parsed.toolCalls.length > 0) {
407
+ const toolCalls = parsed.toolCalls.map((call) => ({
310
408
  type: 'toolCall',
311
- id: `call_${Date.now().toString(36)}`,
312
- name: parsed.toolCall.name,
313
- arguments: parsed.toolCall.arguments,
314
- };
409
+ id: `call_${Date.now().toString(36)}_${++callSequence}`,
410
+ name: call.name,
411
+ arguments: call.arguments,
412
+ }));
315
413
  const spoken = isContactSpokenPreamble(parsed.chatText) ? parsed.chatText.trim() : '';
316
414
  const content = spoken
317
- ? [{ type: 'text', text: spoken }, toolCall]
318
- : [toolCall];
415
+ ? [{ type: 'text', text: spoken }, ...toolCalls]
416
+ : toolCalls;
319
417
  const partial = {
320
418
  ...assistantMessage(model, spoken, 'toolUse'),
321
419
  content,
@@ -327,15 +425,17 @@ export function createContactStreamFn(createChatCompletion, {
327
425
  stream.push({ type: 'text_delta', contentIndex: 0, delta: spoken, partial });
328
426
  stream.push({ type: 'text_end', contentIndex: 0, content: spoken, partial });
329
427
  }
330
- const toolIndex = spoken ? 1 : 0;
331
- stream.push({ type: 'toolcall_start', contentIndex: toolIndex, partial });
332
- stream.push({ type: 'toolcall_delta', contentIndex: toolIndex, delta: JSON.stringify(toolCall.arguments), partial });
333
- stream.push({ type: 'toolcall_end', contentIndex: toolIndex, toolCall, partial });
428
+ toolCalls.forEach((toolCall, index) => {
429
+ const toolIndex = index + (spoken ? 1 : 0);
430
+ stream.push({ type: 'toolcall_start', contentIndex: toolIndex, partial });
431
+ stream.push({ type: 'toolcall_delta', contentIndex: toolIndex, delta: JSON.stringify(toolCall.arguments), partial });
432
+ stream.push({ type: 'toolcall_end', contentIndex: toolIndex, toolCall, partial });
433
+ });
334
434
  stream.push({ type: 'done', reason: 'toolUse', message: partial });
335
435
  stream.end(partial);
336
436
  return;
337
437
  }
338
- const chatText = stripContactToolFences(text);
438
+ const chatText = finalText ?? parsed.chatText;
339
439
  const replyBubbles = splitContactBubbles(chatText);
340
440
  if (bubbleGapMs > 0 && replyBubbles.length > 1) {
341
441
  for (let index = 0; index < replyBubbles.length; index += 1) {
@@ -355,7 +455,7 @@ export function createContactStreamFn(createChatCompletion, {
355
455
  stream.push({ type: 'done', reason: 'stop', message: partial });
356
456
  stream.end(partial);
357
457
  } catch (error) {
358
- const failed = assistantMessage(model, '', 'error', error?.message || 'upstream_error');
458
+ const failed = assistantMessage(model, '', signal?.aborted ? 'aborted' : 'error', error?.message || 'upstream_error');
359
459
  stream.push({ type: 'error', reason: 'error', error: failed });
360
460
  stream.end(failed);
361
461
  }
@@ -417,6 +517,28 @@ const extractToolResultText = (messages) => {
417
517
  return '';
418
518
  };
419
519
 
520
+ /** Tool confirms that are themselves the user-facing bubble (no card). */
521
+ const TOOL_TEXT_BUBBLE_TOOLS = new Set([
522
+ STOP_SESSION_TOOL_NAME,
523
+ STEER_SESSION_TOOL_NAME,
524
+ ARCHIVE_SESSION_TOOL_NAME,
525
+ DELETE_SESSION_TOOL_NAME,
526
+ NEW_CONVERSATION_TOOL_NAME,
527
+ CLEAR_CHAT_HISTORY_TOOL_NAME,
528
+ UPDATE_DEFAULT_PROMPT_TOOL_NAME,
529
+ GET_ASSISTANT_SETTINGS_TOOL_NAME,
530
+ ]);
531
+
532
+ /** Card / side-effect tools: never paint English toolText into the transcript. */
533
+ const CARD_SIDE_EFFECT_TOOLS = new Set([
534
+ ASSIGN_SESSION_TOOL_NAME,
535
+ WATCH_SESSION_TOOL_NAME,
536
+ STOP_SESSION_TOOL_NAME,
537
+ CREATE_ASSISTANT_TOOL_NAME,
538
+ SCHEDULE_TASK_TOOL_NAME,
539
+ MESSAGE_ASSISTANT_TOOL_NAME,
540
+ ]);
541
+
420
542
  const extractContactTurnOutcome = (messages, retried) => {
421
543
  const list = Array.isArray(messages) ? messages : [];
422
544
  let start = 0;
@@ -440,9 +562,22 @@ const extractContactTurnOutcome = (messages, retried) => {
440
562
  const coding = isPiCodingToolName(lastToolName);
441
563
  const toolText = extractToolResultText(slice);
442
564
  const assistant = extractAssistantText(slice);
443
- const confirm = hasTool && !coding
444
- ? (toolText || assistant)
445
- : (assistant || (coding ? toolText : ''));
565
+ let confirm = '';
566
+ if (hasTool && !coding) {
567
+ if (TOOL_TEXT_BUBBLE_TOOLS.has(lastToolName)) {
568
+ // Confirm-only tools: toolText is the user bubble.
569
+ confirm = toolText || assistant;
570
+ } else if (CARD_SIDE_EFFECT_TOOLS.has(lastToolName)) {
571
+ // Spoken preamble only. Card is enough when present; never English toolText.
572
+ // Post-tool assistant text (terminate:false tools) may still surface when no spoken.
573
+ confirm = spoken ? '' : (assistant || '');
574
+ } else {
575
+ // list_projects / list_sessions and other app tools keep toolText.
576
+ confirm = toolText || assistant;
577
+ }
578
+ } else {
579
+ confirm = assistant || (coding ? toolText : '');
580
+ }
446
581
  const parts = [];
447
582
  if (spoken) parts.push(spoken);
448
583
  if (confirm && confirm !== spoken) parts.push(confirm);
@@ -467,34 +602,45 @@ export async function runContactTurn({
467
602
  tools = [],
468
603
  projects = [],
469
604
  connectedModels = [],
605
+ modelPreferences = null,
606
+ modelCatalogAvailable = true,
470
607
  onTextDelta = null,
471
608
  onBubbleDelta = null,
472
609
  globalEventHub = null,
473
610
  skillHomeDir,
611
+ language = '',
474
612
  AgentImpl = Agent,
613
+ signal = null,
614
+ readOnly = false,
475
615
  }) {
476
616
  const providerID = assistant.providerID;
477
617
  const modelID = assistant.modelID;
478
618
  const cwd = resolveAssistantCwd(assistant);
479
619
  const contactTools = Array.isArray(tools)
480
- ? tools.filter((tool) => tool && typeof tool.name === 'string' && !isPiCodingToolName(tool.name))
620
+ ? tools.filter((tool) => tool && typeof tool.name === 'string' && !isPiCodingToolName(tool.name) && (!readOnly || ['list_projects', 'list_sessions', 'read_session', 'get_assistant_settings'].includes(tool.name)))
481
621
  : [];
482
622
  let runtime = null;
623
+ let removeAbortListener = null;
483
624
  try {
484
- if (cwd) runtime = await createPiCodingRuntime(cwd, { homeDir: skillHomeDir });
625
+ signal?.throwIfAborted();
626
+ if (cwd && !readOnly) runtime = await createPiCodingRuntime(cwd, { homeDir: skillHomeDir });
485
627
  const codingTools = Array.isArray(runtime?.tools) ? runtime.tools : [];
486
628
  const systemPrompt = [
487
629
  CONTACT_SYSTEM_PROMPT,
630
+ 'Conversation history is a bounded recent window, not guaranteed complete memory. OpenChamber card context records contain actual session identifiers and status; use them to resolve references to earlier work. Never claim to remember omitted details or repeat completed work because older context is missing. Ask for the missing detail when needed.',
631
+ readOnly ? 'This turn is a background result notification. Only read-only lookup tools are available. Never create or continue work; follow the latest user constraints and report the result briefly.' : '',
488
632
  formatPiCodingPrompt({
489
633
  cwd: runtime?.cwd,
490
634
  skillsPrompt: runtime?.skillsPrompt,
491
635
  tools: codingTools,
492
636
  }),
493
637
  formatRegisteredProjectsPrompt(projects),
494
- formatConnectedModelsPrompt(connectedModels),
638
+ formatConnectedModelsPrompt(connectedModels, { preferences: modelPreferences, catalogAvailable: modelCatalogAvailable }),
495
639
  formatContactToolsPrompt(contactTools),
496
640
  assistant.defaultPrompt,
497
- ].filter((value) => typeof value === 'string' && value.trim()).join('\n\n');
641
+ codingTools.length > 0 ? WORKSPACE_RESPONSE_PROTOCOL : '',
642
+ ].filter((value) => typeof value === 'string' && value.trim()).join('\n\n')
643
+ .replaceAll('{{LANGUAGE}}', resolveContactLanguage(language));
498
644
  const model = createContactModel(providerID, modelID);
499
645
  // streamFn receives the model; completions needs providerID/modelID.
500
646
  model.name = `${providerID}/${modelID}`;
@@ -517,6 +663,7 @@ export async function runContactTurn({
517
663
  : [];
518
664
 
519
665
  const agent = new AgentImpl({
666
+ toolExecution: 'sequential',
520
667
  initialState: {
521
668
  systemPrompt,
522
669
  model,
@@ -530,10 +677,16 @@ export async function runContactTurn({
530
677
  onBubbleDelta,
531
678
  globalEventHub,
532
679
  bubbleGapMs: 280,
680
+ signal,
533
681
  }),
534
682
  });
535
683
 
684
+ const onAbort = () => agent.abort();
685
+ signal?.addEventListener('abort', onAbort, { once: true });
686
+ removeAbortListener = () => signal?.removeEventListener('abort', onAbort);
687
+ signal?.throwIfAborted();
536
688
  await agent.prompt(userText);
689
+ signal?.throwIfAborted();
537
690
  if (agent.state.errorMessage) {
538
691
  const error = new Error(agent.state.errorMessage);
539
692
  error.code = 'upstream_error';
@@ -546,7 +699,9 @@ export async function runContactTurn({
546
699
  let retried = false;
547
700
  if (requested.length > 0 && !hasRequestedResult() && !contactTurnHasSuccessfulReset(agent.state.messages)) {
548
701
  retried = true;
702
+ signal?.throwIfAborted();
549
703
  await agent.prompt(MISSED_FENCE_RETRY_USER_TEXT);
704
+ signal?.throwIfAborted();
550
705
  if (agent.state.errorMessage) {
551
706
  const error = new Error(agent.state.errorMessage);
552
707
  error.code = 'upstream_error';
@@ -554,6 +709,19 @@ export async function runContactTurn({
554
709
  }
555
710
  }
556
711
  if (requested.length > 0 && !hasRequestedResult() && !contactTurnHasSuccessfulReset(agent.state.messages)) {
712
+ // Prefer any spoken/assistant text already in the turn over the English failure fallback.
713
+ const missed = extractContactTurnOutcome(agent.state.messages, retried);
714
+ const missedText = stripContactToolFences(missed.text).trim();
715
+ if (missedText) {
716
+ const missedBubbles = splitContactBubbles(missedText);
717
+ return {
718
+ text: missedText,
719
+ bubbles: missedBubbles.length > 0 ? missedBubbles : [missedText],
720
+ cards: [],
721
+ thinkingLevel: agent.state.thinkingLevel,
722
+ tools: [...agent.state.tools],
723
+ };
724
+ }
557
725
  return {
558
726
  text: MISSED_TOOL_FAILURE_BUBBLE,
559
727
  bubbles: [MISSED_TOOL_FAILURE_BUBBLE],
@@ -563,7 +731,7 @@ export async function runContactTurn({
563
731
  };
564
732
  }
565
733
  const outcome = extractContactTurnOutcome(agent.state.messages, retried);
566
- const text = stripContactToolFences(outcome.text);
734
+ const text = outcome.text;
567
735
  if (contactTurnHasSuccessfulReset(agent.state.messages)) {
568
736
  const historyCleared = contactTurnClearedChatHistory(agent.state.messages);
569
737
  const preferredConfirm = historyCleared
@@ -580,7 +748,8 @@ export async function runContactTurn({
580
748
  tools: [...agent.state.tools],
581
749
  };
582
750
  }
583
- if (!text.trim() && outcome.cards.length === 0) {
751
+ // Card tools / side-effect tools may finish with cards only (no English toolText bubble).
752
+ if (!text.trim() && outcome.cards.length === 0 && !outcome.hasTool) {
584
753
  const error = new Error('Assistant returned no text');
585
754
  error.code = 'upstream_error';
586
755
  throw error;
@@ -593,6 +762,7 @@ export async function runContactTurn({
593
762
  tools: [...agent.state.tools],
594
763
  };
595
764
  } finally {
765
+ removeAbortListener?.();
596
766
  try {
597
767
  await runtime?.close?.();
598
768
  } catch {