@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.
- package/dist/assets/{AssistantView-DnN3m4Dp.js → AssistantView-BBCGdrHP.js} +1 -1
- package/dist/assets/{DiagramView-E3efnEX7.js → DiagramView-Bl4hBD9Q.js} +1 -1
- package/dist/assets/MarkdownRenderer-B3u13nMz.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-CdpiFWqs.js → MarkdownRendererImpl-D5gapBoS.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-BPyoNg8p.js → MarkstreamRendererImpl-B0-BRwTb.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-Cx7eDb4W.js → MobileDetailNavigation-K_D8tdQt.js} +1 -1
- package/dist/assets/{MobileShareBridge-B_go2MeO.js → MobileShareBridge-kMTuwMC5.js} +1 -1
- package/dist/assets/MobileSurface-iF2V5nla.js +2 -0
- package/dist/assets/{MultiRunWindow-DLCZOjCj.js → MultiRunWindow-DwexB9Qy.js} +1 -1
- package/dist/assets/{OnboardingScreen-C3-wzeSX.js → OnboardingScreen-DS2nugLX.js} +1 -1
- package/dist/assets/{SettingsView-C4mB0uFW.js → SettingsView-CQ1QLvr6.js} +2 -2
- package/dist/assets/{SettingsWindow-OGO76Rgi.js → SettingsWindow-FECd8wxM.js} +1 -1
- package/dist/assets/{TerminalView-Ck5P5GWU.js → TerminalView-DcMHfHtg.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BenN_1If.js → ToolOutputDialog-CSb529wq.js} +1 -1
- package/dist/assets/{appThemeRegistry-DAH35eaE.js → appThemeRegistry-BFv2g9rp.js} +1 -1
- package/dist/assets/desktopHosts-Dc2PS66M.js +1 -0
- package/dist/assets/desktopRelayRestore-T9y0orqa.js +1 -0
- package/dist/assets/{gitApi-DdJQDVmv.js → gitApi-Dio9sS64.js} +1 -1
- package/dist/assets/{insertionBoundaries-DRvsnrxu.js → insertionBoundaries-nICJrO_N.js} +1 -1
- package/dist/assets/{main-CEdvbeao.js → main-CNBO8MzI.js} +2 -2
- package/dist/assets/{main-DPnPRJo9.js → main-CXS9iqzq.js} +22 -22
- package/dist/assets/{miniChat-9KRGMcES.js → miniChat-DtAiI_WP.js} +2 -2
- package/dist/assets/{mobile-CaTrLmyq.js → mobile-Z_dkmwkj.js} +2 -2
- package/dist/assets/{multirun-DZxLNwZx.js → multirun-BR5grr_M.js} +1 -1
- package/dist/assets/{projectMeta-B4mvLrV7.js → projectMeta-FU83d7PY.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-IARSnJ6n.js → renderElectronMiniChatApp-BUopzjBX.js} +2 -2
- package/dist/assets/{renderMobileApp-CvA1QBLw.js → renderMobileApp-kGq58GpN.js} +4 -4
- package/dist/assets/{runtimeConfig-CmL9jcr-.js → runtimeConfig-C8Ch7QlE.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-DUO6iUn_.js → runtimeEndpointReset-ByKPnj63.js} +1 -1
- package/dist/assets/{sessionLookup-CfllxmO1.js → sessionLookup-zfIdBJ9w.js} +1 -1
- package/dist/assets/{useAppFontEffects-COqtb3tR.js → useAppFontEffects-BH9JvmYW.js} +6 -6
- package/dist/assets/{useDesktopWindowControlsLayout-nDXj3p6q.js → useDesktopWindowControlsLayout-CLmxf8gm.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-DOU5Xg5n.js → useEffectiveDirectory-fEbYkT-1.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-BawYHSru.js → useMobileNavigationStore-CeGGCHFp.js} +1 -1
- package/dist/assets/useSessionAutoCleanup-JgEa86T5.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-Dfd5Ra8Y.js → useWorkerHighlightedLines-CTP9abTJ.js} +1 -1
- package/dist/index.html +4 -4
- package/dist/mini-chat.html +4 -4
- package/dist/mobile.html +4 -4
- package/package.json +1 -1
- package/server/lib/assistants/DOCUMENTATION.md +21 -10
- package/server/lib/assistants/contact-store.js +90 -5
- package/server/lib/assistants/contact-store.test.js +127 -2
- package/server/lib/assistants/contact-tools.js +107 -24
- package/server/lib/assistants/contact-tools.test.js +45 -3
- package/server/lib/assistants/harness.js +15 -5
- package/server/lib/assistants/harness.test.js +65 -3
- package/server/lib/assistants/service.js +76 -10
- package/server/lib/assistants/service.test.js +270 -11
- package/server/lib/llm/DOCUMENTATION.md +6 -0
- package/server/lib/llm/generate.js +4 -1
- package/server/lib/llm/generate.test.js +6 -1
- package/server/lib/opencode/DOCUMENTATION.md +1 -0
- package/server/lib/opencode/openchamber-routes.js +3 -0
- package/server/lib/opencode/openchamber-routes.test.js +43 -0
- package/server/lib/relay/DOCUMENTATION.md +1 -0
- package/dist/assets/MarkdownRenderer-BYryAKz2.js +0 -2
- package/dist/assets/MobileSurface-ByPOhQrz.js +0 -2
- package/dist/assets/desktopHosts-CWxex1c3.js +0 -1
- package/dist/assets/desktopRelayRestore-9EbBYQ9s.js +0 -1
- 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
|
-
*
|
|
207
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
280
|
-
|
|
281
|
-
let match =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
447
|
-
&& (
|
|
448
|
-
|
|
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
|
|
458
|
-
|
|
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
|
|
505
|
-
'Use when the user says 开新对话, new conversation, or clear chat.',
|
|
506
|
-
'Does not
|
|
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', '
|
|
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:
|
|
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, '
|
|
604
|
+
return toolFailure(error, 'clear_chat_history_failed', 'Could not clear chat history.');
|
|
522
605
|
}
|
|
523
606
|
},
|
|
524
607
|
},
|