@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
@@ -37,6 +37,14 @@ export const CONTACT_SCHEMA_SQL = `
37
37
  );
38
38
  CREATE INDEX IF NOT EXISTS assistant_contact_watch_session
39
39
  ON assistant_contact_watch(session_id, status);
40
+ -- Per-assistant LLM context watermark. Messages with ordinal <= after_ordinal
41
+ -- stay in the transcript UI but are excluded from contactHistoryForLlm.
42
+ -- Survives restart. Cleared only when the transcript is fully deleted.
43
+ CREATE TABLE IF NOT EXISTS assistant_contact_context_boundary (
44
+ assistant_id TEXT PRIMARY KEY,
45
+ after_ordinal INTEGER NOT NULL,
46
+ updated_at INTEGER NOT NULL
47
+ );
40
48
  `;
41
49
 
42
50
  export const CONTACT_SETTLE_TEXT = Object.freeze({
@@ -154,6 +162,48 @@ export function deleteContactMessages(db, assistantID) {
154
162
  }
155
163
  db.prepare('DELETE FROM assistant_contact_message WHERE assistant_id=?').run(assistantID);
156
164
  db.prepare('DELETE FROM assistant_contact_watch WHERE assistant_id=?').run(assistantID);
165
+ db.prepare('DELETE FROM assistant_contact_context_boundary WHERE assistant_id=?').run(assistantID);
166
+ }
167
+
168
+ /** Durable LLM context watermark for one assistant (0 = no boundary). */
169
+ export function getContactContextBoundary(db, assistantID) {
170
+ const row = db.prepare(
171
+ 'SELECT after_ordinal FROM assistant_contact_context_boundary WHERE assistant_id=?',
172
+ ).get(assistantID);
173
+ if (!row || !Number.isFinite(Number(row.after_ordinal))) return 0;
174
+ return Number(row.after_ordinal);
175
+ }
176
+
177
+ /**
178
+ * Clear LLM memory only: keep every transcript row, advance the watermark so
179
+ * later contactHistoryForLlm drops prior turns.
180
+ *
181
+ * Prefer `upToOrdinal` (the clearing turn's user ordinal). Using global MAX
182
+ * would swallow later-admitted queued user rows and permanently exclude them
183
+ * from subsequent LLM windows. API/direct callers without a turn ceiling may
184
+ * omit `upToOrdinal` and clear through the current max.
185
+ * Never lowers an existing boundary. Failed callers must roll back the
186
+ * surrounding transaction to keep the old boundary.
187
+ */
188
+ export function clearContactMemory(db, assistantID, { updatedAt = Date.now(), upToOrdinal = null } = {}) {
189
+ const current = getContactContextBoundary(db, assistantID);
190
+ const parsedCeiling = upToOrdinal == null || upToOrdinal === ''
191
+ ? NaN
192
+ : Number(upToOrdinal);
193
+ let afterOrdinal;
194
+ if (Number.isFinite(parsedCeiling)) {
195
+ afterOrdinal = parsedCeiling;
196
+ } else {
197
+ afterOrdinal = Number(db.prepare(
198
+ 'SELECT COALESCE(MAX(ordinal), 0) AS max FROM assistant_contact_message WHERE assistant_id=?',
199
+ ).get(assistantID).max);
200
+ }
201
+ if (!Number.isFinite(afterOrdinal)) afterOrdinal = 0;
202
+ afterOrdinal = Math.max(afterOrdinal, current);
203
+ db.prepare(
204
+ 'INSERT INTO assistant_contact_context_boundary(assistant_id, after_ordinal, updated_at) VALUES (?,?,?) ON CONFLICT(assistant_id) DO UPDATE SET after_ordinal=excluded.after_ordinal, updated_at=excluded.updated_at',
205
+ ).run(assistantID, afterOrdinal, updatedAt);
206
+ return { assistantID, afterOrdinal, memoryCleared: true };
157
207
  }
158
208
 
159
209
  export function upsertContactWatch(db, { assistantID, sessionID, directory, status, updatedAt }) {
@@ -202,14 +252,18 @@ export function updateSessionCardStatus(db, { assistantID, sessionID, status })
202
252
  }
203
253
 
204
254
  /**
205
- * LLM-only contact window. SQLite and the transcript UI may keep older bubbles;
206
- * this budget is the only model context. There is no summarizer and no
207
- * user-facing compress / continuous / stateless control on this path.
255
+ * LLM-only contact window. SQLite and the transcript UI may keep older bubbles
256
+ * (including rows before the durable context boundary). This budget is the only
257
+ * model context. There is no summarizer and no user-facing compress /
258
+ * continuous / stateless control on this path.
208
259
  *
209
260
  * A turn is a user message plus the assistant replies that follow it until the
210
261
  * next user. Newest turn is always kept, even when it exceeds the char budget.
211
262
  * Char estimate is text length plus CONTACT_LLM_FILE_CHAR_WEIGHT per file part
212
263
  * (CJK counts as one char; no tokenizer).
264
+ *
265
+ * clearContactMemory advances assistant_contact_context_boundary.after_ordinal;
266
+ * only messages with ordinal > that watermark enter this window.
213
267
  */
214
268
  export const CONTACT_LLM_MAX_TURNS = 8;
215
269
  export const CONTACT_LLM_MAX_CHARS = 6_000;
@@ -276,9 +330,40 @@ export function trimContactHistoryForLlm(messages, {
276
330
  return kept.reverse().flat();
277
331
  }
278
332
 
279
- export function contactHistoryForLlm(db, assistantID) {
333
+ /**
334
+ * LLM window for one contact turn.
335
+ * - after_ordinal watermark drops cleared memory.
336
+ * - excludeMessageIDs drops the current admitted user row (prompt still gets userText).
337
+ * - beforeOrdinal is the current turn's user message ordinal. Later-admitted user
338
+ * rows (queued turns) must not be injected into this turn; assistant rows written
339
+ * after that ordinal by a prior completed lane turn still enter the window.
340
+ */
341
+ export function contactHistoryForLlm(db, assistantID, { excludeMessageIDs = [], beforeOrdinal = null } = {}) {
342
+ const afterOrdinal = getContactContextBoundary(db, assistantID);
343
+ const exclude = new Set(
344
+ (Array.isArray(excludeMessageIDs) ? excludeMessageIDs : [])
345
+ .filter((id) => typeof id === 'string' && id),
346
+ );
347
+ // null/undefined must not become 0 via Number(null) — that would drop every user row.
348
+ const parsedCeiling = beforeOrdinal == null || beforeOrdinal === ''
349
+ ? NaN
350
+ : Number(beforeOrdinal);
351
+ const userOrdinalCeiling = Number.isFinite(parsedCeiling) ? parsedCeiling : null;
280
352
  const page = listContactMessages(db, assistantID, { limit: CONTACT_LLM_FETCH_LIMIT });
281
- return trimContactHistoryForLlm(page.messages);
353
+ const messages = page.messages.filter((message) => {
354
+ if (exclude.has(message.messageID)) return false;
355
+ if (!(message.ordinal > afterOrdinal)) return false;
356
+ // Queued user admits after this turn's ordinal belong to later lane work.
357
+ if (
358
+ userOrdinalCeiling != null
359
+ && message.role === 'user'
360
+ && message.ordinal > userOrdinalCeiling
361
+ ) {
362
+ return false;
363
+ }
364
+ return true;
365
+ });
366
+ return trimContactHistoryForLlm(messages);
282
367
  }
283
368
 
284
369
  export { parseContactCard };
@@ -9,9 +9,11 @@ import {
9
9
  CONTACT_LLM_FILE_CHAR_WEIGHT,
10
10
  CONTACT_LLM_MAX_CHARS,
11
11
  CONTACT_LLM_MAX_TURNS,
12
+ clearContactMemory,
12
13
  contactHistoryForLlm,
13
14
  deleteContactMessages,
14
15
  ensureContactSchema,
16
+ getContactContextBoundary,
15
17
  insertContactMessage,
16
18
  listContactMessages,
17
19
  nextContactOrdinal,
@@ -30,6 +32,7 @@ const openDb = () => {
30
32
 
31
33
  const insert = (db, assistantID, { role, text = '', parts, status = 'complete', fromAssistantID = null, fromAssistantName = null }) => {
32
34
  const messageID = crypto.randomUUID();
35
+ const ordinal = nextContactOrdinal(db, assistantID);
33
36
  insertContactMessage(db, {
34
37
  messageID,
35
38
  assistantID,
@@ -37,13 +40,13 @@ const insert = (db, assistantID, { role, text = '', parts, status = 'complete',
37
40
  turnID: messageID,
38
41
  bubbleIndex: 0,
39
42
  createdAt: Date.now(),
40
- ordinal: nextContactOrdinal(db, assistantID),
43
+ ordinal,
41
44
  status,
42
45
  parts: parts || (text ? [{ type: 'text', text }] : [{ type: 'text', text: '' }]),
43
46
  fromAssistantID,
44
47
  fromAssistantName,
45
48
  });
46
- return messageID;
49
+ return { messageID, ordinal };
47
50
  };
48
51
 
49
52
  describe('contact LLM history trim', () => {
@@ -118,6 +121,25 @@ describe('contact LLM history trim', () => {
118
121
  ]);
119
122
  });
120
123
 
124
+ it('excludes later-admitted user rows past beforeOrdinal while keeping later assistant rows', () => {
125
+ const db = openDb();
126
+ const assistantID = 'asst_ceiling';
127
+ insert(db, assistantID, { role: 'user', text: 'prior-user' });
128
+ insert(db, assistantID, { role: 'assistant', text: 'prior-assistant' });
129
+ const current = insert(db, assistantID, { role: 'user', text: 'current-turn' });
130
+ insert(db, assistantID, { role: 'user', text: 'queued-later-user' });
131
+ insert(db, assistantID, { role: 'assistant', text: 'prior-lane-assistant-after-admit' });
132
+ expect(contactHistoryForLlm(db, assistantID, {
133
+ excludeMessageIDs: [current.messageID],
134
+ beforeOrdinal: current.ordinal,
135
+ })).toEqual([
136
+ { role: 'user', content: 'prior-user' },
137
+ { role: 'assistant', content: 'prior-assistant' },
138
+ { role: 'assistant', content: 'prior-lane-assistant-after-admit' },
139
+ ]);
140
+ db.close();
141
+ });
142
+
121
143
  it('clears LLM history after deleteContactMessages', () => {
122
144
  const db = openDb();
123
145
  const assistantID = 'asst_reset';
@@ -126,6 +148,109 @@ describe('contact LLM history trim', () => {
126
148
  deleteContactMessages(db, assistantID);
127
149
  expect(contactHistoryForLlm(db, assistantID)).toEqual([]);
128
150
  expect(listContactMessages(db, assistantID, { limit: 50 }).messages).toEqual([]);
151
+ expect(getContactContextBoundary(db, assistantID)).toBe(0);
152
+ db.close();
153
+ });
154
+
155
+ it('clearContactMemory keeps transcript rows and only advances the LLM boundary', () => {
156
+ const db = openDb();
157
+ const assistantID = 'asst_memory';
158
+ insert(db, assistantID, { role: 'user', text: 'secret-before' });
159
+ insert(db, assistantID, { role: 'assistant', text: 'remembered' });
160
+ const cleared = clearContactMemory(db, assistantID, { updatedAt: 42 });
161
+ expect(cleared).toMatchObject({ assistantID, memoryCleared: true, afterOrdinal: 2 });
162
+ expect(getContactContextBoundary(db, assistantID)).toBe(2);
163
+ expect(listContactMessages(db, assistantID, { limit: 50 }).messages.map((message) => message.text)).toEqual([
164
+ 'secret-before',
165
+ 'remembered',
166
+ ]);
167
+ expect(contactHistoryForLlm(db, assistantID)).toEqual([]);
168
+ insert(db, assistantID, { role: 'user', text: 'after-clear' });
169
+ insert(db, assistantID, { role: 'assistant', text: 'fresh-reply' });
170
+ expect(contactHistoryForLlm(db, assistantID)).toEqual([
171
+ { role: 'user', content: 'after-clear' },
172
+ { role: 'assistant', content: 'fresh-reply' },
173
+ ]);
174
+ db.close();
175
+ });
176
+
177
+ it('clearContactMemory upToOrdinal does not swallow later-admitted queued users', () => {
178
+ const db = openDb();
179
+ const assistantID = 'asst_queue_waterline';
180
+ insert(db, assistantID, { role: 'user', text: 'seed-secret' });
181
+ insert(db, assistantID, { role: 'assistant', text: 'seed-reply' });
182
+ const clearTurn = insert(db, assistantID, { role: 'user', text: '开新对话' });
183
+ const queued = insert(db, assistantID, { role: 'user', text: 'queued-after-admit' });
184
+ // Wrong: MAX would set afterOrdinal to queued.ordinal and permanently drop it.
185
+ const cleared = clearContactMemory(db, assistantID, {
186
+ updatedAt: 99,
187
+ upToOrdinal: clearTurn.ordinal,
188
+ });
189
+ expect(cleared.afterOrdinal).toBe(clearTurn.ordinal);
190
+ expect(getContactContextBoundary(db, assistantID)).toBe(clearTurn.ordinal);
191
+ expect(contactHistoryForLlm(db, assistantID)).toEqual([
192
+ { role: 'user', content: 'queued-after-admit' },
193
+ ]);
194
+ insert(db, assistantID, { role: 'assistant', text: 'queued-reply' });
195
+ insert(db, assistantID, { role: 'user', text: 'later-follow-up' });
196
+ const later = contactHistoryForLlm(db, assistantID);
197
+ expect(later.some((item) => item.content.includes('seed-secret'))).toBe(false);
198
+ expect(later.some((item) => item.content === 'queued-after-admit')).toBe(true);
199
+ expect(later.some((item) => item.content === 'queued-reply')).toBe(true);
200
+ expect(later.some((item) => item.content === 'later-follow-up')).toBe(true);
201
+ expect(queued.ordinal).toBeGreaterThan(clearTurn.ordinal);
202
+ db.close();
203
+ });
204
+
205
+ it('isolates context boundaries per assistant and survives reopen', () => {
206
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'contact-boundary-'));
207
+ const Database = require('better-sqlite3');
208
+ const dbPath = path.join(directory, 'contact.sqlite');
209
+ const db = new Database(dbPath);
210
+ ensureContactSchema(db);
211
+ insert(db, 'asst_a', { role: 'user', text: 'a-secret' });
212
+ insert(db, 'asst_a', { role: 'assistant', text: 'a-reply' });
213
+ insert(db, 'asst_b', { role: 'user', text: 'b-secret' });
214
+ insert(db, 'asst_b', { role: 'assistant', text: 'b-reply' });
215
+ clearContactMemory(db, 'asst_a', { updatedAt: 1 });
216
+ expect(contactHistoryForLlm(db, 'asst_a')).toEqual([]);
217
+ expect(contactHistoryForLlm(db, 'asst_b')).toEqual([
218
+ { role: 'user', content: 'b-secret' },
219
+ { role: 'assistant', content: 'b-reply' },
220
+ ]);
221
+ db.close();
222
+ const reopened = new Database(dbPath);
223
+ ensureContactSchema(reopened);
224
+ expect(getContactContextBoundary(reopened, 'asst_a')).toBe(2);
225
+ expect(contactHistoryForLlm(reopened, 'asst_a')).toEqual([]);
226
+ expect(listContactMessages(reopened, 'asst_a', { limit: 50 }).messages).toHaveLength(2);
227
+ expect(contactHistoryForLlm(reopened, 'asst_b')).toEqual([
228
+ { role: 'user', content: 'b-secret' },
229
+ { role: 'assistant', content: 'b-reply' },
230
+ ]);
231
+ reopened.close();
232
+ });
233
+
234
+ it('keeps the previous boundary when clearContactMemory write fails inside a transaction', () => {
235
+ const db = openDb();
236
+ const assistantID = 'asst_fail';
237
+ insert(db, assistantID, { role: 'user', text: 'first' });
238
+ clearContactMemory(db, assistantID, { updatedAt: 10 });
239
+ expect(getContactContextBoundary(db, assistantID)).toBe(1);
240
+ insert(db, assistantID, { role: 'user', text: 'second' });
241
+ insert(db, assistantID, { role: 'assistant', text: 'second-reply' });
242
+ db.exec('BEGIN IMMEDIATE');
243
+ try {
244
+ clearContactMemory(db, assistantID, { updatedAt: 20 });
245
+ throw new Error('forced_write_failure');
246
+ } catch {
247
+ db.exec('ROLLBACK');
248
+ }
249
+ expect(getContactContextBoundary(db, assistantID)).toBe(1);
250
+ expect(contactHistoryForLlm(db, assistantID)).toEqual([
251
+ { role: 'user', content: 'second' },
252
+ { role: 'assistant', content: 'second-reply' },
253
+ ]);
129
254
  db.close();
130
255
  });
131
256
  });
@@ -27,11 +27,15 @@ export const CREATE_ASSISTANT_TOOL_NAME = 'create_assistant';
27
27
  export const SCHEDULE_TASK_TOOL_NAME = 'schedule_task';
28
28
  export const MESSAGE_ASSISTANT_TOOL_NAME = 'message_assistant';
29
29
  export const NEW_CONVERSATION_TOOL_NAME = 'new_conversation';
30
+ export const CLEAR_CHAT_HISTORY_TOOL_NAME = 'clear_chat_history';
30
31
  export const LIST_PROJECTS_TOOL_NAME = 'list_projects';
31
32
  export const LIST_SESSIONS_TOOL_NAME = 'list_sessions';
32
33
  const CONTACT_TOOL_FENCE = 'openchamber-tool';
33
34
  export const ASSIGNED_SESSION_FALLBACK_BUBBLE = 'Opened a coding session.';
34
- export const NEW_CONVERSATION_CONFIRM_BUBBLE = 'Started a new conversation. Previous contact messages are cleared.';
35
+ /** Clear-memory confirm: transcript rows stay; only the LLM window resets. */
36
+ export const NEW_CONVERSATION_CONFIRM_BUBBLE = 'Memory cleared. Previous messages stay in the chat; I will not use them as context.';
37
+ /** Explicit transcript wipe confirm (clear_chat_history / POST contact/reset). */
38
+ export const CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE = 'Chat history cleared.';
35
39
  const LIST_SESSIONS_LIMIT_DEFAULT = 20;
36
40
  const LIST_SESSIONS_LIMIT_MAX = 50;
37
41
 
@@ -43,13 +47,18 @@ export const MISSED_TOOL_FAILURE_BUBBLE = 'I could not complete that. No tool ra
43
47
 
44
48
  const CREATE_ASSISTANT_INTENT = /建助理|新建[^。\n!]{0,24}助理|创建[^。\n!]{0,24}助理|加一个助理|create (?:an |a new )?assistant|new assistant/iu;
45
49
  const SCHEDULE_TASK_INTENT = /排定时任务|排个?定时任务|定时任务|schedule (?:a )?(?:daily )?(?:task|ping)|scheduled task|排个?(?:每日)?(?:任务|ping)/iu;
46
- const ASSIGN_SESSION_INTENT = /建会话|开会话|开(?:一个)?(?:编码\s*)?(?:session|会话)|open (?:a )?(?:coding )?session|assign_session/giu;
50
+ const ASSIGN_SESSION_INTENT = /(?:建|开)(?:一个|个)?(?:新)?(?:编码\s*)?(?:session|会话)|open (?:a )?(?:coding )?session|assign_session/giu;
47
51
  const MESSAGE_ASSISTANT_INTENT = /给[^。\n]{1,40}说(?:一声)?|跟[^。\n]{1,24}说(?:一声)?|告诉(?!我)[^。\n]{1,40}|说一声|message (?:the )?(?:assistant|peer)|(?:tell|message)\s+[A-Za-z0-9._-]+|send (?:a )?message to/iu;
48
- const NEW_CONVERSATION_INTENT = /开新对话|新对话|清空(?:聊天|对话)|clear chat|new conversation|start over/iu;
52
+ // Explicit wipe only must stay stricter than the safe clear-memory intent.
53
+ // `g` so hasClearChatHistoryIntent can walk matches and honor nearby negation.
54
+ const CLEAR_CHAT_HISTORY_INTENT = /(?:清空|清除|删除)(?:聊天|对话)记录|wipe (?:the )?chat(?: history)?|delete (?:the )?(?:chat|conversation) history|clear (?:the )?(?:chat|conversation) history/giu;
55
+ // Default safe semantics: fresh LLM context, keep the transcript UI.
56
+ const NEW_CONVERSATION_INTENT = /开新对话|新对话|清除记忆|清空(?:聊天|对话)(?!记录)|clear memory|clear chat(?! history)|new conversation|start over|forget (?:everything|this|what we|what i)/giu;
49
57
  const LIST_PROJECTS_INTENT = /找项目|查项目|看看项目|有哪些项目|项目列表|list projects|find project|registered project|which project/iu;
50
58
  const LIST_SESSIONS_INTENT = /现有对话|现有会话|查会话|找会话|会话列表|有哪些会话|list sessions|find (?:a )?session|existing (?:conversation|session|chat)|active (?:conversation|session)/iu;
51
59
  const INTENT_NEGATION = /不要|别|不用|不开|don't|do\s+not/iu;
52
60
  const newConversationParameters = typeboxObject({});
61
+ const clearChatHistoryParameters = typeboxObject({});
53
62
 
54
63
  const assignParameters = typeboxObject({
55
64
  prompt: typeboxString('Coding prompt to kick into the worker OpenCode session.'),
@@ -276,16 +285,29 @@ export function parseContactToolCalls(text, allowedNames = []) {
276
285
 
277
286
  const isNegatedAt = (text, index) => INTENT_NEGATION.test(text.slice(Math.max(0, index - 12), index));
278
287
 
279
- const hasAssignSessionIntent = (text) => {
280
- ASSIGN_SESSION_INTENT.lastIndex = 0;
281
- let match = ASSIGN_SESSION_INTENT.exec(text);
288
+ const hasIntentMatch = (regex, text) => {
289
+ regex.lastIndex = 0;
290
+ let match = regex.exec(text);
282
291
  while (match) {
283
292
  if (!isNegatedAt(text, match.index)) return true;
284
- match = ASSIGN_SESSION_INTENT.exec(text);
293
+ match = regex.exec(text);
285
294
  }
286
295
  return false;
287
296
  };
288
297
 
298
+ const hasAssignSessionIntent = (text) => hasIntentMatch(ASSIGN_SESSION_INTENT, text);
299
+
300
+ /**
301
+ * True when userText has an explicit, non-negated transcript-wipe phrase.
302
+ * Server core uses this to authorize clear_chat_history / resetContact — the
303
+ * model cannot self-authorize through tool parameters.
304
+ */
305
+ export function userTextAuthorizesClearChatHistory(userText) {
306
+ return hasIntentMatch(CLEAR_CHAT_HISTORY_INTENT, typeof userText === 'string' ? userText : '');
307
+ }
308
+
309
+ const hasNewConversationIntent = (text) => hasIntentMatch(NEW_CONVERSATION_INTENT, text);
310
+
289
311
  /** Which attached tools the user asked for in natural language. */
290
312
  export function detectRequestedContactTools(userText, allowedNames = []) {
291
313
  const allowed = new Set(
@@ -295,7 +317,12 @@ export function detectRequestedContactTools(userText, allowedNames = []) {
295
317
  );
296
318
  const text = typeof userText === 'string' ? userText : '';
297
319
  const requested = [];
298
- if (allowed.has(NEW_CONVERSATION_TOOL_NAME) && NEW_CONVERSATION_INTENT.test(text)) {
320
+ // Prefer explicit wipe over the safe clear-memory default when both could match.
321
+ // Negation (不要清除聊天记录) must not authorize delete.
322
+ const wantsClearHistory = allowed.has(CLEAR_CHAT_HISTORY_TOOL_NAME) && userTextAuthorizesClearChatHistory(text);
323
+ if (wantsClearHistory) {
324
+ requested.push(CLEAR_CHAT_HISTORY_TOOL_NAME);
325
+ } else if (allowed.has(NEW_CONVERSATION_TOOL_NAME) && hasNewConversationIntent(text)) {
299
326
  requested.push(NEW_CONVERSATION_TOOL_NAME);
300
327
  }
301
328
  if (allowed.has(LIST_PROJECTS_TOOL_NAME) && LIST_PROJECTS_INTENT.test(text)) {
@@ -394,7 +421,8 @@ export function formatContactToolsPrompt(tools) {
394
421
  if (list.length === 0) return '';
395
422
  return [
396
423
  'The user talks in natural language (including Chinese). Never ask them to type slash commands.',
397
- 'When they want a fresh contact chat (开新对话 / new conversation / clear chat), call new_conversation. That clears this contact transcript only. It is not OpenCode session/new and does not open a coding session.',
424
+ 'When they want a fresh model context (开新对话 / new conversation / 清除记忆 / clear memory / clear chat), call new_conversation. That clears LLM memory only — chat history stays visible. It is not OpenCode session/new and does not open a coding session.',
425
+ 'When they explicitly want to delete the stored chat (清空聊天记录 / clear chat history / delete chat history), call clear_chat_history. Do not use clear_chat_history for ordinary 开新对话 / new conversation wording.',
398
426
  'When they want to find a registered project (找项目 / list projects / "openchamber yee"), call list_projects or use the Registered projects block already in context.',
399
427
  'When they want existing conversations in a project (现有对话 / list sessions), call list_sessions.',
400
428
  'When they want another assistant (建助理 / create an assistant), call create_assistant.',
@@ -406,6 +434,9 @@ export function formatContactToolsPrompt(tools) {
406
434
  `{"name":"${NEW_CONVERSATION_TOOL_NAME}","arguments":{}}`,
407
435
  '```',
408
436
  `\`\`\`${CONTACT_TOOL_FENCE}`,
437
+ `{"name":"${CLEAR_CHAT_HISTORY_TOOL_NAME}","arguments":{}}`,
438
+ '```',
439
+ `\`\`\`${CONTACT_TOOL_FENCE}`,
409
440
  `{"name":"${LIST_PROJECTS_TOOL_NAME}","arguments":{"query":"openchamber yee"}}`,
410
441
  '```',
411
442
  `\`\`\`${CONTACT_TOOL_FENCE}`,
@@ -423,8 +454,9 @@ export function formatContactToolsPrompt(tools) {
423
454
  `\`\`\`${CONTACT_TOOL_FENCE}`,
424
455
  `{"name":"${ASSIGN_SESSION_TOOL_NAME}","arguments":{"prompt":"...","projectPath":"..."}}`,
425
456
  '```',
426
- 'If the user asked for more than one of these, do them in that order across turns: new_conversation, then list_projects, then list_sessions, then create_assistant, then schedule_task, then message_assistant, then assign_session.',
427
- 'new_conversation deletes this contact\'s stored messages and watches. It never calls session/new or createNew.',
457
+ 'If the user asked for more than one of these, do them in that order across turns: new_conversation or clear_chat_history, then list_projects, then list_sessions, then create_assistant, then schedule_task, then message_assistant, then assign_session.',
458
+ 'new_conversation advances this contact\'s LLM context boundary only. Stored messages and watches remain. It never calls session/new or createNew.',
459
+ 'clear_chat_history deletes this contact\'s stored messages, parts, and watches. Use only for explicit wipe intent.',
428
460
  'You already receive the registered project catalog each turn. Prefer matching label/path yourself; list_projects refreshes or filters. Never claim you cannot see projects; never ask for a raw path when a name matches.',
429
461
  'list_sessions searches the OpenChamber session index for existing chats in a project. A failure is not an empty list — surface the error.',
430
462
  'assign_session opens a real OpenChamber/OpenCode session on a registered project (or reuses sessionID). You are not the worker.',
@@ -441,21 +473,45 @@ export function formatContactToolsPrompt(tools) {
441
473
  ].join('\n');
442
474
  }
443
475
 
476
+ const isSuccessfulContextResetResult = (message) => (
477
+ message?.role === 'toolResult'
478
+ && !message.details?.error
479
+ && (
480
+ message.toolName === NEW_CONVERSATION_TOOL_NAME
481
+ || message.toolName === CLEAR_CHAT_HISTORY_TOOL_NAME
482
+ || message.details?.reset === true
483
+ || message.details?.memoryCleared === true
484
+ || message.details?.historyCleared === true
485
+ )
486
+ );
487
+
444
488
  export function contactTurnHasSuccessfulReset(messages) {
489
+ return (Array.isArray(messages) ? messages : []).some(isSuccessfulContextResetResult);
490
+ }
491
+
492
+ /** True when this turn wiped the stored transcript (not just LLM memory). */
493
+ export function contactTurnClearedChatHistory(messages) {
445
494
  return (Array.isArray(messages) ? messages : []).some((message) => (
446
- message?.role === 'toolResult'
447
- && (message.toolName === NEW_CONVERSATION_TOOL_NAME || message.details?.reset === true)
448
- && !message.details?.error
495
+ isSuccessfulContextResetResult(message)
496
+ && (
497
+ message.toolName === CLEAR_CHAT_HISTORY_TOOL_NAME
498
+ || message.details?.historyCleared === true
499
+ )
449
500
  ));
450
501
  }
451
502
 
452
503
  /** After reset, keep only the short confirm — leftover pre-reset model text is discarded. */
453
- export function confirmBubbleAfterContactReset(bubbles) {
504
+ export function confirmBubbleAfterContactReset(bubbles, preferredConfirm = NEW_CONVERSATION_CONFIRM_BUBBLE) {
454
505
  const list = (Array.isArray(bubbles) ? bubbles : [])
455
506
  .filter((item) => typeof item === 'string' && item.trim())
456
507
  .map((item) => item.trim());
457
- const confirm = list.find((item) => item === NEW_CONVERSATION_CONFIRM_BUBBLE);
458
- return [confirm || NEW_CONVERSATION_CONFIRM_BUBBLE];
508
+ const fallback = preferredConfirm === CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE
509
+ ? CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE
510
+ : NEW_CONVERSATION_CONFIRM_BUBBLE;
511
+ const confirm = list.find((item) => (
512
+ item === NEW_CONVERSATION_CONFIRM_BUBBLE || item === CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE
513
+ ));
514
+ return [confirm || fallback];
459
515
  }
460
516
 
461
517
  export function extractContactCardsFromMessages(messages) {
@@ -485,6 +541,7 @@ export function createContactTools({
485
541
  createAssistant,
486
542
  scheduleTask,
487
543
  deliverPeerMessage,
544
+ clearContactMemory,
488
545
  resetContact,
489
546
  listAssistants,
490
547
  listProjects,
@@ -501,24 +558,50 @@ export function createContactTools({
501
558
  name: NEW_CONVERSATION_TOOL_NAME,
502
559
  label: 'New conversation',
503
560
  description: [
504
- 'Clear this assistant contact transcript (messages, parts, and watches).',
505
- 'Use when the user says 开新对话, new conversation, or clear chat.',
506
- 'Does not call OpenCode session/new or create a worker session.',
561
+ 'Clear this assistant\'s LLM memory only. Chat history stays in the transcript UI.',
562
+ 'Use when the user says 开新对话, new conversation, 清除记忆, clear memory, or clear chat.',
563
+ 'Does not delete stored messages. Does not call OpenCode session/new.',
507
564
  ].join(' '),
508
565
  parameters: newConversationParameters,
566
+ execute: async () => {
567
+ try {
568
+ if (typeof clearContactMemory !== 'function') {
569
+ throw new AssignError('upstream_error', 'Clearing contact memory is unavailable.');
570
+ }
571
+ await clearContactMemory();
572
+ return {
573
+ content: [{ type: 'text', text: NEW_CONVERSATION_CONFIRM_BUBBLE }],
574
+ details: { reset: true, memoryCleared: true },
575
+ terminate: true,
576
+ };
577
+ } catch (error) {
578
+ return toolFailure(error, 'new_conversation_failed', 'Could not clear contact memory.');
579
+ }
580
+ },
581
+ },
582
+ {
583
+ name: CLEAR_CHAT_HISTORY_TOOL_NAME,
584
+ label: 'Clear chat history',
585
+ description: [
586
+ 'Delete this assistant contact transcript (messages, parts, and watches).',
587
+ 'Use only when the user explicitly says 清空聊天记录, clear chat history, or delete chat history.',
588
+ 'Ordinary 开新对话 / new conversation must use new_conversation instead.',
589
+ 'Does not call OpenCode session/new or create a worker session.',
590
+ ].join(' '),
591
+ parameters: clearChatHistoryParameters,
509
592
  execute: async () => {
510
593
  try {
511
594
  if (typeof resetContact !== 'function') {
512
- throw new AssignError('upstream_error', 'Resetting this conversation is unavailable.');
595
+ throw new AssignError('upstream_error', 'Clearing chat history is unavailable.');
513
596
  }
514
597
  await resetContact();
515
598
  return {
516
- content: [{ type: 'text', text: NEW_CONVERSATION_CONFIRM_BUBBLE }],
517
- details: { reset: true },
599
+ content: [{ type: 'text', text: CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE }],
600
+ details: { reset: true, historyCleared: true },
518
601
  terminate: true,
519
602
  };
520
603
  } catch (error) {
521
- return toolFailure(error, 'new_conversation_failed', 'Could not start a new conversation.');
604
+ return toolFailure(error, 'clear_chat_history_failed', 'Could not clear chat history.');
522
605
  }
523
606
  },
524
607
  },