@openchambery/web 1.19.9 → 1.19.10-beta.2

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 (61) hide show
  1. package/dist/assets/{AssistantView-DnN3m4Dp.js → AssistantView-BBCGdrHP.js} +1 -1
  2. package/dist/assets/{DiagramView-E3efnEX7.js → DiagramView-Bl4hBD9Q.js} +1 -1
  3. package/dist/assets/MarkdownRenderer-B3u13nMz.js +2 -0
  4. package/dist/assets/{MarkdownRendererImpl-CdpiFWqs.js → MarkdownRendererImpl-D5gapBoS.js} +1 -1
  5. package/dist/assets/{MarkstreamRendererImpl-BPyoNg8p.js → MarkstreamRendererImpl-B0-BRwTb.js} +1 -1
  6. package/dist/assets/{MobileDetailNavigation-Cx7eDb4W.js → MobileDetailNavigation-K_D8tdQt.js} +1 -1
  7. package/dist/assets/{MobileShareBridge-B_go2MeO.js → MobileShareBridge-kMTuwMC5.js} +1 -1
  8. package/dist/assets/MobileSurface-iF2V5nla.js +2 -0
  9. package/dist/assets/{MultiRunWindow-DLCZOjCj.js → MultiRunWindow-DwexB9Qy.js} +1 -1
  10. package/dist/assets/{OnboardingScreen-C3-wzeSX.js → OnboardingScreen-DS2nugLX.js} +1 -1
  11. package/dist/assets/{SettingsView-C4mB0uFW.js → SettingsView-CQ1QLvr6.js} +2 -2
  12. package/dist/assets/{SettingsWindow-OGO76Rgi.js → SettingsWindow-FECd8wxM.js} +1 -1
  13. package/dist/assets/{TerminalView-Ck5P5GWU.js → TerminalView-DcMHfHtg.js} +1 -1
  14. package/dist/assets/{ToolOutputDialog-BenN_1If.js → ToolOutputDialog-CSb529wq.js} +1 -1
  15. package/dist/assets/{appThemeRegistry-DAH35eaE.js → appThemeRegistry-BFv2g9rp.js} +1 -1
  16. package/dist/assets/desktopHosts-Dc2PS66M.js +1 -0
  17. package/dist/assets/desktopRelayRestore-T9y0orqa.js +1 -0
  18. package/dist/assets/{gitApi-DdJQDVmv.js → gitApi-Dio9sS64.js} +1 -1
  19. package/dist/assets/{insertionBoundaries-DRvsnrxu.js → insertionBoundaries-nICJrO_N.js} +1 -1
  20. package/dist/assets/{main-CEdvbeao.js → main-CNBO8MzI.js} +2 -2
  21. package/dist/assets/{main-DPnPRJo9.js → main-CXS9iqzq.js} +22 -22
  22. package/dist/assets/{miniChat-9KRGMcES.js → miniChat-DtAiI_WP.js} +2 -2
  23. package/dist/assets/{mobile-CaTrLmyq.js → mobile-Z_dkmwkj.js} +2 -2
  24. package/dist/assets/{multirun-DZxLNwZx.js → multirun-BR5grr_M.js} +1 -1
  25. package/dist/assets/{projectMeta-B4mvLrV7.js → projectMeta-FU83d7PY.js} +1 -1
  26. package/dist/assets/{renderElectronMiniChatApp-IARSnJ6n.js → renderElectronMiniChatApp-BUopzjBX.js} +2 -2
  27. package/dist/assets/{renderMobileApp-CvA1QBLw.js → renderMobileApp-kGq58GpN.js} +4 -4
  28. package/dist/assets/{runtimeConfig-CmL9jcr-.js → runtimeConfig-C8Ch7QlE.js} +1 -1
  29. package/dist/assets/{runtimeEndpointReset-DUO6iUn_.js → runtimeEndpointReset-ByKPnj63.js} +1 -1
  30. package/dist/assets/{sessionLookup-CfllxmO1.js → sessionLookup-zfIdBJ9w.js} +1 -1
  31. package/dist/assets/{useAppFontEffects-COqtb3tR.js → useAppFontEffects-BH9JvmYW.js} +6 -6
  32. package/dist/assets/{useDesktopWindowControlsLayout-nDXj3p6q.js → useDesktopWindowControlsLayout-CLmxf8gm.js} +1 -1
  33. package/dist/assets/{useEffectiveDirectory-DOU5Xg5n.js → useEffectiveDirectory-fEbYkT-1.js} +1 -1
  34. package/dist/assets/{useMobileNavigationStore-BawYHSru.js → useMobileNavigationStore-CeGGCHFp.js} +1 -1
  35. package/dist/assets/useSessionAutoCleanup-JgEa86T5.js +1 -0
  36. package/dist/assets/{useWorkerHighlightedLines-Dfd5Ra8Y.js → useWorkerHighlightedLines-CTP9abTJ.js} +1 -1
  37. package/dist/index.html +4 -4
  38. package/dist/mini-chat.html +4 -4
  39. package/dist/mobile.html +4 -4
  40. package/package.json +1 -1
  41. package/server/lib/assistants/DOCUMENTATION.md +21 -10
  42. package/server/lib/assistants/contact-store.js +90 -5
  43. package/server/lib/assistants/contact-store.test.js +127 -2
  44. package/server/lib/assistants/contact-tools.js +107 -24
  45. package/server/lib/assistants/contact-tools.test.js +45 -3
  46. package/server/lib/assistants/harness.js +15 -5
  47. package/server/lib/assistants/harness.test.js +65 -3
  48. package/server/lib/assistants/service.js +76 -10
  49. package/server/lib/assistants/service.test.js +270 -11
  50. package/server/lib/llm/DOCUMENTATION.md +6 -0
  51. package/server/lib/llm/generate.js +4 -1
  52. package/server/lib/llm/generate.test.js +6 -1
  53. package/server/lib/opencode/DOCUMENTATION.md +1 -0
  54. package/server/lib/opencode/openchamber-routes.js +3 -0
  55. package/server/lib/opencode/openchamber-routes.test.js +43 -0
  56. package/server/lib/relay/DOCUMENTATION.md +1 -0
  57. package/dist/assets/MarkdownRenderer-BYryAKz2.js +0 -2
  58. package/dist/assets/MobileSurface-ByPOhQrz.js +0 -2
  59. package/dist/assets/desktopHosts-CWxex1c3.js +0 -1
  60. package/dist/assets/desktopRelayRestore-9EbBYQ9s.js +0 -1
  61. package/dist/assets/useSessionAutoCleanup-R4Ke7did.js +0 -1
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from 'vitest';
2
2
  import { AssignError, ASSIGN_CODES, PROJECT_REQUIRED_MESSAGE } from './assign.js';
3
3
  import {
4
4
  ASSIGN_SESSION_TOOL_NAME,
5
+ CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
6
+ CLEAR_CHAT_HISTORY_TOOL_NAME,
5
7
  CREATE_ASSISTANT_TOOL_NAME,
6
8
  LIST_PROJECTS_TOOL_NAME,
7
9
  LIST_SESSIONS_TOOL_NAME,
@@ -20,6 +22,7 @@ import {
20
22
  resolveContactProviderModel,
21
23
  resolvePeerAssistant,
22
24
  stripContactToolFences,
25
+ userTextAuthorizesClearChatHistory,
23
26
  } from './contact-tools.js';
24
27
 
25
28
  describe('contact tool protocol', () => {
@@ -78,6 +81,7 @@ describe('contact tool protocol', () => {
78
81
  it('detects 建助理 without treating 不要开编码 session as assign_session', () => {
79
82
  const tools = [
80
83
  NEW_CONVERSATION_TOOL_NAME,
84
+ CLEAR_CHAT_HISTORY_TOOL_NAME,
81
85
  LIST_PROJECTS_TOOL_NAME,
82
86
  LIST_SESSIONS_TOOL_NAME,
83
87
  CREATE_ASSISTANT_TOOL_NAME,
@@ -90,6 +94,7 @@ describe('contact tool protocol', () => {
90
94
  ]);
91
95
  expect(detectRequestedContactTools('每天 18:00 排一个 ping 定时任务', tools)).toEqual([SCHEDULE_TASK_TOOL_NAME]);
92
96
  expect(detectRequestedContactTools('建会话写一个文件', tools)).toEqual([ASSIGN_SESSION_TOOL_NAME]);
97
+ expect(detectRequestedContactTools('对,你直接派给那个项目组,再建个会话去修这个问题', tools)).toEqual([ASSIGN_SESSION_TOOL_NAME]);
93
98
  expect(detectRequestedContactTools('写一个文件', tools)).toEqual([]);
94
99
  expect(detectRequestedContactTools('pwd', tools)).toEqual([]);
95
100
  expect(detectRequestedContactTools('给 PeerQA 说一声 hello-from-assistant 写好了', tools)).toEqual([
@@ -98,11 +103,24 @@ describe('contact tool protocol', () => {
98
103
  expect(detectRequestedContactTools('开新对话', tools)).toEqual([NEW_CONVERSATION_TOOL_NAME]);
99
104
  expect(detectRequestedContactTools('new conversation please', tools)).toEqual([NEW_CONVERSATION_TOOL_NAME]);
100
105
  expect(detectRequestedContactTools('clear chat', tools)).toEqual([NEW_CONVERSATION_TOOL_NAME]);
106
+ expect(detectRequestedContactTools('清除记忆', tools)).toEqual([NEW_CONVERSATION_TOOL_NAME]);
107
+ expect(detectRequestedContactTools('清空聊天记录', tools)).toEqual([CLEAR_CHAT_HISTORY_TOOL_NAME]);
108
+ expect(detectRequestedContactTools('清除聊天记录', tools)).toEqual([CLEAR_CHAT_HISTORY_TOOL_NAME]);
109
+ expect(detectRequestedContactTools('clear chat history', tools)).toEqual([CLEAR_CHAT_HISTORY_TOOL_NAME]);
110
+ expect(detectRequestedContactTools('delete chat history', tools)).toEqual([CLEAR_CHAT_HISTORY_TOOL_NAME]);
111
+ expect(detectRequestedContactTools('不要清除聊天记录', tools)).toEqual([]);
112
+ expect(detectRequestedContactTools('do not clear chat history', tools)).toEqual([]);
101
113
  expect(detectRequestedContactTools('找项目 openchamber yee', tools)).toEqual([LIST_PROJECTS_TOOL_NAME]);
102
114
  expect(detectRequestedContactTools('看看现有对话', tools)).toEqual([LIST_SESSIONS_TOOL_NAME]);
103
115
  expect(detectRequestedContactTools('list sessions in that project', tools)).toEqual([LIST_SESSIONS_TOOL_NAME]);
104
116
  expect(detectRequestedContactTools('开新对话', tools)).not.toContain(ASSIGN_SESSION_TOOL_NAME);
117
+ expect(detectRequestedContactTools('开新对话', tools)).not.toContain(CLEAR_CHAT_HISTORY_TOOL_NAME);
105
118
  expect(detectRequestedContactTools('不要开编码 session', tools)).toEqual([]);
119
+ expect(userTextAuthorizesClearChatHistory('清除聊天记录')).toBe(true);
120
+ expect(userTextAuthorizesClearChatHistory('清空聊天记录')).toBe(true);
121
+ expect(userTextAuthorizesClearChatHistory('清除记忆')).toBe(false);
122
+ expect(userTextAuthorizesClearChatHistory('不要清除聊天记录')).toBe(false);
123
+ expect(userTextAuthorizesClearChatHistory('开新对话')).toBe(false);
106
124
  });
107
125
 
108
126
  it('parses bash fences when the pi coding tools are allowed', () => {
@@ -120,6 +138,7 @@ describe('contact tool protocol', () => {
120
138
  it('tells DeepSeek to call tools from natural language, not slash commands', () => {
121
139
  const prompt = formatContactToolsPrompt(createContactTools());
122
140
  expect(prompt).toContain('new_conversation');
141
+ expect(prompt).toContain('clear_chat_history');
123
142
  expect(prompt).toContain('list_projects');
124
143
  expect(prompt).toContain('list_sessions');
125
144
  expect(prompt).toContain('create_assistant');
@@ -127,12 +146,15 @@ describe('contact tool protocol', () => {
127
146
  expect(prompt).toContain('message_assistant');
128
147
  expect(prompt).toContain('assign_session');
129
148
  expect(prompt).toContain('开新对话');
149
+ expect(prompt).toContain('清除记忆');
150
+ expect(prompt).toContain('清空聊天记录');
130
151
  expect(prompt).toContain('找项目');
131
152
  expect(prompt).toContain('现有对话');
132
153
  expect(prompt).toContain('建助理');
133
154
  expect(prompt).toContain('排定时任务');
134
155
  expect(prompt).toContain('说一声');
135
156
  expect(prompt).toContain('session/new');
157
+ expect(prompt).toContain('LLM memory only');
136
158
  expect(prompt).not.toContain('/card');
137
159
  expect(prompt).not.toContain('/dm');
138
160
  expect(prompt).toContain('A reply without the tool call does nothing');
@@ -168,8 +190,10 @@ describe('contact tool protocol', () => {
168
190
  describe('createContactTools', () => {
169
191
  it('exposes create_assistant, schedule_task, and assign_session and returns success cards', async () => {
170
192
  const onCard = vi.fn();
171
- const resetContact = vi.fn(async () => ({ reset: true }));
193
+ const clearContactMemory = vi.fn(async () => ({ reset: true, memoryCleared: true }));
194
+ const resetContact = vi.fn(async () => ({ reset: true, historyCleared: true }));
172
195
  const tools = createContactTools({
196
+ clearContactMemory,
173
197
  resetContact,
174
198
  createAssistant: async (input) => ({
175
199
  id: 'asst_flow',
@@ -204,6 +228,7 @@ describe('createContactTools', () => {
204
228
  });
205
229
  expect(tools.map((tool) => tool.name)).toEqual([
206
230
  NEW_CONVERSATION_TOOL_NAME,
231
+ CLEAR_CHAT_HISTORY_TOOL_NAME,
207
232
  LIST_PROJECTS_TOOL_NAME,
208
233
  LIST_SESSIONS_TOOL_NAME,
209
234
  CREATE_ASSISTANT_TOOL_NAME,
@@ -214,12 +239,19 @@ describe('createContactTools', () => {
214
239
  expect(tools.some((tool) => ['bash', 'edit', 'read', 'write'].includes(tool.name))).toBe(false);
215
240
 
216
241
  const reset = await tools.find((tool) => tool.name === NEW_CONVERSATION_TOOL_NAME).execute('call_0', {});
217
- expect(resetContact).toHaveBeenCalledTimes(1);
242
+ expect(clearContactMemory).toHaveBeenCalledTimes(1);
243
+ expect(resetContact).not.toHaveBeenCalled();
218
244
  expect(reset.details.card).toBeUndefined();
219
- expect(reset.details.reset).toBe(true);
245
+ expect(reset.details).toMatchObject({ reset: true, memoryCleared: true });
220
246
  expect(reset.terminate).toBe(true);
221
247
  expect(reset.content[0].text).toBe(NEW_CONVERSATION_CONFIRM_BUBBLE);
222
248
 
249
+ const wiped = await tools.find((tool) => tool.name === CLEAR_CHAT_HISTORY_TOOL_NAME).execute('call_wipe', {});
250
+ expect(resetContact).toHaveBeenCalledTimes(1);
251
+ expect(wiped.details).toMatchObject({ reset: true, historyCleared: true });
252
+ expect(wiped.terminate).toBe(true);
253
+ expect(wiped.content[0].text).toBe(CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE);
254
+
223
255
  const created = await tools.find((tool) => tool.name === CREATE_ASSISTANT_TOOL_NAME).execute('call_1', { name: 'FlowQA', model: 'opencode-go/deepseek-v4-flash' });
224
256
  expect(created.details.card).toMatchObject({
225
257
  type: 'card',
@@ -334,4 +366,14 @@ describe('confirmBubbleAfterContactReset', () => {
334
366
  'I still see your dot.png and note.txt from earlier.',
335
367
  ])).toEqual([NEW_CONVERSATION_CONFIRM_BUBBLE]);
336
368
  });
369
+
370
+ it('prefers the clear-history confirm when that tool ran', () => {
371
+ expect(confirmBubbleAfterContactReset([
372
+ CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
373
+ 'I still see older cards.',
374
+ ], CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE)).toEqual([CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE]);
375
+ expect(confirmBubbleAfterContactReset([
376
+ 'leftover only',
377
+ ], CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE)).toEqual([CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE]);
378
+ });
337
379
  });
@@ -1,7 +1,9 @@
1
1
  import { Agent } from '@earendil-works/pi-agent-core';
2
2
  import { isContactSpokenPreamble, splitContactBubbles } from './bubbles.js';
3
3
  import {
4
+ CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
4
5
  confirmBubbleAfterContactReset,
6
+ contactTurnClearedChatHistory,
5
7
  contactTurnHasSuccessfulReset,
6
8
  contactTurnHasToolResult,
7
9
  detectRequestedContactTools,
@@ -75,7 +77,7 @@ export const CONTACT_SYSTEM_PROMPT = [
75
77
  'Never write chain-of-thought, plans, tool names, or English narration of what you will do. The user never sees thinking.',
76
78
  'Do not expose tool traces, Activity, or editor actions.',
77
79
  '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.',
78
- 'Understand natural language in any language, including Chinese: 开新对话 means new_conversation, 找项目 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.',
80
+ '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.',
79
81
  '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.',
80
82
  '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.',
81
83
  'A reply without the tool call does nothing. Never say 已创建, created, scheduled, or opened unless the tool already returned success.',
@@ -521,8 +523,11 @@ export async function runContactTurn({
521
523
  throw error;
522
524
  }
523
525
  const requested = detectRequestedContactTools(userText, contactTools.map((tool) => tool.name));
526
+ const hasRequestedResult = () => agent.state.messages.some((message) => (
527
+ message?.role === 'toolResult' && requested.includes(message.toolName)
528
+ ));
524
529
  let retried = false;
525
- if (requested.length > 0 && !contactTurnHasToolResult(agent.state.messages)) {
530
+ if (requested.length > 0 && !hasRequestedResult() && !contactTurnHasSuccessfulReset(agent.state.messages)) {
526
531
  retried = true;
527
532
  await agent.prompt(MISSED_FENCE_RETRY_USER_TEXT);
528
533
  if (agent.state.errorMessage) {
@@ -531,7 +536,7 @@ export async function runContactTurn({
531
536
  throw error;
532
537
  }
533
538
  }
534
- if (requested.length > 0 && !contactTurnHasToolResult(agent.state.messages)) {
539
+ if (requested.length > 0 && !hasRequestedResult() && !contactTurnHasSuccessfulReset(agent.state.messages)) {
535
540
  return {
536
541
  text: MISSED_TOOL_FAILURE_BUBBLE,
537
542
  bubbles: [MISSED_TOOL_FAILURE_BUBBLE],
@@ -543,12 +548,17 @@ export async function runContactTurn({
543
548
  const outcome = extractContactTurnOutcome(agent.state.messages, retried);
544
549
  const text = stripContactToolFences(outcome.text);
545
550
  if (contactTurnHasSuccessfulReset(agent.state.messages)) {
546
- const bubbles = confirmBubbleAfterContactReset(splitContactBubbles(text));
551
+ const historyCleared = contactTurnClearedChatHistory(agent.state.messages);
552
+ const preferredConfirm = historyCleared
553
+ ? CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE
554
+ : NEW_CONVERSATION_CONFIRM_BUBBLE;
555
+ const bubbles = confirmBubbleAfterContactReset(splitContactBubbles(text), preferredConfirm);
547
556
  return {
548
- text: bubbles[0] || NEW_CONVERSATION_CONFIRM_BUBBLE,
557
+ text: bubbles[0] || preferredConfirm,
549
558
  bubbles,
550
559
  cards: [],
551
560
  reset: true,
561
+ historyCleared,
552
562
  thinkingLevel: agent.state.thinkingLevel,
553
563
  tools: [...agent.state.tools],
554
564
  };
@@ -211,6 +211,65 @@ describe('createContactStreamFn', () => {
211
211
  })
212
212
 
213
213
  describe('runContactTurn', () => {
214
+ it('keeps all eleven tools and assigns a session after reading a skill', async () => {
215
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-contact-mixed-'))
216
+ const home = path.join(workspace, 'home')
217
+ const skillDir = path.join(workspace, '.agents', 'skills', 'docs')
218
+ fs.mkdirSync(skillDir, { recursive: true })
219
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: docs\ndescription: Read documentation\n---\nRead project docs.\n')
220
+ const assignWork = vi.fn(async () => ({ sessionID: 'ses_mixed', directory: workspace, title: 'Fix', status: 'busy' }))
221
+ const tools = createContactTools({ assignWork })
222
+ const replies = [
223
+ '```openchamber-tool\n{"name":"read","arguments":{"path":".agents/skills/docs/SKILL.md"}}\n```',
224
+ 'Only documentation search is available.',
225
+ `\`\`\`openchamber-tool\n${JSON.stringify({ name: 'assign_session', arguments: { prompt: 'Fix the issue', projectPath: workspace } })}\n\`\`\``,
226
+ 'Session opened.',
227
+ ]
228
+ let call = 0
229
+ const createChatCompletion = vi.fn(async ({ body }) => {
230
+ expect(body.messages[0].content).toContain('assign_session')
231
+ expect(body.messages[0].content).toContain('docs')
232
+ const content = replies[call++]
233
+ if (!content) throw new Error('Unexpected completion')
234
+ return { completion: { choices: [{ message: { content } }] } }
235
+ })
236
+ try {
237
+ const result = await runContactTurn({
238
+ assistant: { providerID: 'p', modelID: 'm', effectiveWorkspacePath: workspace, defaultPrompt: 'Keep replies short.' },
239
+ history: [], userText: '再建个会话去修这个问题', tools,
240
+ projects: [{ id: 'project', path: workspace }], skillHomeDir: home, createChatCompletion,
241
+ })
242
+ expect(result.tools.map((tool) => tool.name)).toEqual([...PI_CODING_TOOL_NAMES, ...tools.map((tool) => tool.name)])
243
+ expect(assignWork).toHaveBeenCalledTimes(1)
244
+ expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_mixed' })])
245
+ expect(createChatCompletion).toHaveBeenCalledTimes(4)
246
+ } finally {
247
+ fs.rmSync(workspace, { recursive: true, force: true })
248
+ }
249
+ })
250
+ it('retries assignment after an unrelated read result', async () => {
251
+ const prompts = []
252
+ function AgentImpl(options) {
253
+ this.state = { ...options.initialState, messages: [] }
254
+ this.prompt = async (text) => {
255
+ prompts.push(text)
256
+ if (prompts.length === 1) {
257
+ this.state.messages.push({ role: 'toolResult', toolName: 'read', content: [{ type: 'text', text: 'Skill instructions' }] })
258
+ this.state.messages.push({ role: 'assistant', content: [{ type: 'text', text: 'Only document search is available.' }] })
259
+ } else {
260
+ this.state.messages.push({ role: 'user', content: text })
261
+ this.state.messages.push({ role: 'toolResult', toolName: 'assign_session', content: [{ type: 'text', text: 'Opened session.' }] })
262
+ }
263
+ }
264
+ }
265
+ const result = await runContactTurn({
266
+ assistant: { providerID: 'p', modelID: 'm', defaultPrompt: '' },
267
+ history: [], userText: '建会话', tools: createContactTools(),
268
+ createChatCompletion: vi.fn(), AgentImpl,
269
+ })
270
+ expect(prompts).toHaveLength(2)
271
+ expect(result.bubbles).toEqual(['Opened session.'])
272
+ })
214
273
  it('runs pi-agent-core with thinking off and no tools by default', async () => {
215
274
  const prompt = vi.fn(async function prompt() {
216
275
  this.state.messages = [{
@@ -653,8 +712,9 @@ describe('runContactTurn', () => {
653
712
  })
654
713
 
655
714
  it('keeps only the new_conversation confirm and drops leftover attachment text', async () => {
656
- const resetContact = vi.fn(async () => ({ reset: true }))
657
- const tools = createContactTools({ resetContact })
715
+ const clearContactMemory = vi.fn(async () => ({ reset: true, memoryCleared: true }))
716
+ const resetContact = vi.fn(async () => ({ reset: true, historyCleared: true }))
717
+ const tools = createContactTools({ clearContactMemory, resetContact })
658
718
  function AgentImpl(options) {
659
719
  this.state = { ...options.initialState, messages: options.initialState.messages }
660
720
  this.prompt = async () => {
@@ -687,8 +747,10 @@ describe('runContactTurn', () => {
687
747
  tools,
688
748
  AgentImpl,
689
749
  })
690
- expect(resetContact).toHaveBeenCalledTimes(1)
750
+ expect(clearContactMemory).toHaveBeenCalledTimes(1)
751
+ expect(resetContact).not.toHaveBeenCalled()
691
752
  expect(result.reset).toBe(true)
753
+ expect(result.historyCleared).toBe(false)
692
754
  expect(result.cards).toEqual([])
693
755
  expect(result.bubbles).toEqual([NEW_CONVERSATION_CONFIRM_BUBBLE])
694
756
  expect(result.bubbles.join('')).not.toContain('dot.png')
@@ -9,6 +9,7 @@ import { getWorktrees as defaultListWorktrees } from '../git/service.js';
9
9
  import { contactCardIdentity, parseContactCard, parseContactPart } from './cards.js';
10
10
  import {
11
11
  CONTACT_SETTLE_TEXT,
12
+ clearContactMemory as clearContactMemoryStore,
12
13
  contactHistoryForLlm,
13
14
  deleteContactMessages,
14
15
  ensureContactSchema,
@@ -23,12 +24,16 @@ import {
23
24
  import {
24
25
  ASSIGNED_SESSION_FALLBACK_BUBBLE,
25
26
  boundSessionListLimit,
27
+ CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
26
28
  confirmBubbleAfterContactReset,
29
+ contactTurnClearedChatHistory,
27
30
  createContactTools,
28
31
  filterRegisteredProjects,
29
32
  matchesProjectQuery,
33
+ NEW_CONVERSATION_CONFIRM_BUBBLE,
30
34
  normalizeRegisteredProjects,
31
35
  sanitizeRegisteredProject,
36
+ userTextAuthorizesClearChatHistory,
32
37
  } from './contact-tools.js';
33
38
  import { AssignError, ASSIGN_CODES, assignSession, resolveAssignDirectory } from './assign.js';
34
39
  import { runContactTurn as defaultRunContactTurn } from './harness.js';
@@ -1026,9 +1031,6 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1026
1031
  if (!userText) fail('validation_error');
1027
1032
  const userParts = userContactParts(input, userText);
1028
1033
  const row = active(assistantID);
1029
- // Snapshot history before admitting the user so the harness does not see a
1030
- // duplicate of the current userText (prompt still receives userText).
1031
- const history = contactHistoryForLlm(db, assistantID);
1032
1034
  if (typeof createChatCompletion !== 'function' && runContactTurn === defaultRunContactTurn) fail('upstream_error');
1033
1035
  let registeredProjects = [];
1034
1036
  try {
@@ -1056,18 +1058,51 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1056
1058
  messageID,
1057
1059
  });
1058
1060
  contactTurnSettlement(messageID);
1061
+ // Authorize wipe from this turn's real userText only — not tool args.
1062
+ const clearChatHistoryAuthorized = userTextAuthorizesClearChatHistory(userText);
1059
1063
  const kickTurn = () => {
1060
1064
  void inContactTurnLane(row.assistant_id, async () => {
1065
+ // Load the LLM window when the lane runs — not at admit time — so a
1066
+ // prior turn's clear-memory cannot leave queued work with stale history.
1067
+ // Exclude the admitted user row; prompt still receives userText/parts.
1068
+ // beforeOrdinal caps later-admitted queued user rows out of this turn.
1069
+ const admitted = db.prepare(
1070
+ 'SELECT ordinal FROM assistant_contact_message WHERE message_id=?',
1071
+ ).get(messageID);
1072
+ const parsedOrdinal = admitted?.ordinal == null ? NaN : Number(admitted.ordinal);
1073
+ const beforeOrdinal = Number.isFinite(parsedOrdinal) ? parsedOrdinal : null;
1074
+ const history = contactHistoryForLlm(db, row.assistant_id, {
1075
+ excludeMessageIDs: [messageID],
1076
+ beforeOrdinal,
1077
+ });
1061
1078
  const assignedCards = [];
1062
1079
  let contactResetThisTurn = false;
1080
+ let contactHistoryClearedThisTurn = false;
1063
1081
  const tools = createContactTools({
1064
1082
  assignWork: (params) => assignWork(row, params),
1065
1083
  createAssistant: (toolInput) => createAssistant(toolInput),
1066
1084
  scheduleTask: (params) => scheduleWork(row, params),
1067
1085
  deliverPeerMessage: (toolInput) => deliverPeerMessage(row.assistant_id, toolInput),
1086
+ clearContactMemory: () => {
1087
+ // Cap the watermark at this turn's user ordinal — never global MAX,
1088
+ // or later-admitted queued users are permanently excluded from LLM history.
1089
+ const result = clearContactMemory(row.assistant_id, {
1090
+ upToOrdinal: beforeOrdinal,
1091
+ });
1092
+ contactResetThisTurn = true;
1093
+ return result;
1094
+ },
1068
1095
  resetContact: () => {
1096
+ // Server core gate: model mis-select cannot self-authorize delete.
1097
+ if (!clearChatHistoryAuthorized) {
1098
+ throw new AssignError(
1099
+ ASSIGN_CODES.VALIDATION,
1100
+ 'clear_chat_history requires explicit user wipe intent in this message.',
1101
+ );
1102
+ }
1069
1103
  const result = resetContact(row.assistant_id);
1070
1104
  contactResetThisTurn = true;
1105
+ contactHistoryClearedThisTurn = true;
1071
1106
  return result;
1072
1107
  },
1073
1108
  listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
@@ -1107,8 +1142,14 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1107
1142
  return;
1108
1143
  }
1109
1144
  const resetThisTurn = contactResetThisTurn || generated?.reset === true;
1145
+ const historyClearedThisTurn = contactHistoryClearedThisTurn
1146
+ || contactTurnClearedChatHistory(generated?.messages)
1147
+ || generated?.historyCleared === true;
1148
+ const preferredConfirm = historyClearedThisTurn
1149
+ ? CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE
1150
+ : NEW_CONVERSATION_CONFIRM_BUBBLE;
1110
1151
  const bubbles = resetThisTurn
1111
- ? confirmBubbleAfterContactReset(generated?.bubbles)
1152
+ ? confirmBubbleAfterContactReset(generated?.bubbles, preferredConfirm)
1112
1153
  : (Array.isArray(generated?.bubbles) ? generated.bubbles.filter((item) => typeof item === 'string' && item.trim()) : []);
1113
1154
  const cards = resetThisTurn
1114
1155
  ? []
@@ -1134,8 +1175,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1134
1175
  resolveContactTurnSettlement(messageID, { status: 'error', error: detail });
1135
1176
  return;
1136
1177
  }
1137
- // new_conversation wipes the transcript (including the admitted user row).
1138
- // Re-admit the reset user message, then persist only the confirm bubble.
1178
+ // clear_chat_history / resetContact wipe the transcript (including the
1179
+ // admitted user row). Re-admit that user message, then persist only the
1180
+ // confirm bubble. new_conversation keeps rows and only moves the watermark.
1139
1181
  if (resetThisTurn) {
1140
1182
  const existingUser = db.prepare('SELECT 1 AS ok FROM assistant_contact_message WHERE message_id=?').get(messageID);
1141
1183
  if (!existingUser) {
@@ -1201,8 +1243,32 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1201
1243
  return listContactMessages(db, assistantID, { before: query.before, limit });
1202
1244
  };
1203
1245
  /**
1204
- * Clear this assistant's OpenChamber contact transcript (messages, parts,
1205
- * watches). Does not call OpenCode session/new that is worker binding.
1246
+ * Clear LLM memory only: advance the durable context boundary so later
1247
+ * contactHistoryForLlm drops prior turns. Transcript GET keeps every row.
1248
+ * Failed writes roll back and keep the previous boundary. Does not call
1249
+ * OpenCode session/new.
1250
+ */
1251
+ const clearContactMemory = (assistantID, options = {}) => {
1252
+ const row = editable(assistantID);
1253
+ const upToOrdinal = options && typeof options === 'object' ? options.upToOrdinal : undefined;
1254
+ db.exec('BEGIN IMMEDIATE');
1255
+ try {
1256
+ const cleared = clearContactMemoryStore(db, row.assistant_id, {
1257
+ updatedAt: now(),
1258
+ ...(upToOrdinal !== undefined ? { upToOrdinal } : {}),
1259
+ });
1260
+ bump();
1261
+ db.exec('COMMIT');
1262
+ return { assistantID: row.assistant_id, reset: true, memoryCleared: true, afterOrdinal: cleared.afterOrdinal };
1263
+ } catch (error) {
1264
+ db.exec('ROLLBACK');
1265
+ throw error;
1266
+ }
1267
+ };
1268
+ /**
1269
+ * Delete this assistant's OpenChamber contact transcript (messages, parts,
1270
+ * watches) and clear the context boundary. Intentional wipe only — also the
1271
+ * POST /contact/reset contract. Does not call OpenCode session/new.
1206
1272
  */
1207
1273
  const resetContact = (assistantID) => {
1208
1274
  const row = editable(assistantID);
@@ -1215,7 +1281,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1215
1281
  db.exec('ROLLBACK');
1216
1282
  throw error;
1217
1283
  }
1218
- return { assistantID: row.assistant_id, reset: true };
1284
+ return { assistantID: row.assistant_id, reset: true, historyCleared: true };
1219
1285
  };
1220
1286
  const appendContactCard = (assistantID, input) => {
1221
1287
  const row = active(assistantID);
@@ -1371,5 +1437,5 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1371
1437
  db.exec('ROLLBACK');
1372
1438
  throw error;
1373
1439
  }
1374
- }, ensure, createNew, compact, send, whenContactTurnSettled, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, contactMessages, resetContact, appendContactCard, deliverPeerMessage, listAssistantScheduledTasks, processEvent, reportAssignedSessionSettle: reportAssignedSession, reconcile, close: () => { if (!closed) { closed = true; unsubscribeEvents?.(); clearIntervalFn(timer); db.close(); } } };
1440
+ }, ensure, createNew, compact, send, whenContactTurnSettled, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, contactMessages, clearContactMemory, resetContact, appendContactCard, deliverPeerMessage, listAssistantScheduledTasks, processEvent, reportAssignedSessionSettle: reportAssignedSession, reconcile, close: () => { if (!closed) { closed = true; unsubscribeEvents?.(); clearIntervalFn(timer); db.close(); } } };
1375
1441
  };