@openchambery/web 1.18.4-beta.9 → 1.18.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{AgentAvatar-DAnelAnB.js → AgentAvatar-DDcjamcJ.js} +1 -1
- package/dist/assets/AssistantView-n8llxdCo.js +1 -0
- package/dist/assets/{ChatContainer-CSHDg3oS.js → ChatContainer-B7H_fTkm.js} +35 -35
- package/dist/assets/{DiagramView-DUciKofo.js → DiagramView-DK5RkG9r.js} +1 -1
- package/dist/assets/{FadeInOnReveal-QxF1vuqX.js → FadeInOnReveal-By2F9vcI.js} +1 -1
- package/dist/assets/{Icon-D-X70irR.js → Icon-DpjYnXxn.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-BjAOpViW.js → MarkdownRendererImpl-DOpja779.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-EXZlPK6E.js → MobileDetailNavigation-k1tBlKW5.js} +1 -1
- package/dist/assets/{MobileShareBridge-CevrYlt3.js → MobileShareBridge-HxyfCuga.js} +3 -3
- package/dist/assets/MobileSurface-BRouCuNF.js +2 -0
- package/dist/assets/{MultiRunWindow-CNWuOY4y.js → MultiRunWindow-CwzxTb3p.js} +1 -1
- package/dist/assets/{OnboardingScreen-BhSzVmYo.js → OnboardingScreen-BKmuT7AL.js} +1 -1
- package/dist/assets/{OverlayScrollbar-CHXk_jvF.js → OverlayScrollbar-Ch3J6ASR.js} +1 -1
- package/dist/assets/{SettingsView-YyQ-C-qq.js → SettingsView-Bx9tuWxH.js} +11 -11
- package/dist/assets/{SettingsWindow-uUezVVwi.js → SettingsWindow-sVzjzaGh.js} +1 -1
- package/dist/assets/{TerminalView-DPto-DoO.js → TerminalView-DSwK1UwL.js} +1 -1
- package/dist/assets/{ToolOutputDialog-CgPYRmDX.js → ToolOutputDialog-D9di4LaC.js} +1 -1
- package/dist/assets/appThemeRegistry-CN1tLL9G.js +2 -0
- package/dist/assets/{button-CwLFtUNT.js → button-C7OXr4ae.js} +1 -1
- package/dist/assets/{clipboard-DhOMUCwT.js → clipboard-DWuULPzo.js} +1 -1
- package/dist/assets/{collapsible-CP1-_pY6.js → collapsible-DaEya6b6.js} +1 -1
- package/dist/assets/{context-menu-BPxTMlbo.js → context-menu-BrLPLxWa.js} +1 -1
- package/dist/assets/{desktopHosts-Cr3LE55y.js → desktopHosts-Txp4xOVQ.js} +1 -1
- package/dist/assets/desktopRelayRestore-C_FSEHKM.js +1 -0
- package/dist/assets/{diagram-rYL80jaS.js → diagram-CHfginjS.js} +1 -1
- package/dist/assets/{dialog-BoAAoL5Z.js → dialog-DPS0W9eS.js} +1 -1
- package/dist/assets/{es-BXQb1dPR.js → es-BX0Vm3Ac.js} +2 -2
- package/dist/assets/fileQueries-BrUKKgPW.js +1 -0
- package/dist/assets/{fr-BTyDIvxw.js → fr-CuPzXTYx.js} +2 -2
- package/dist/assets/{gitApi-BktvSPdR.js → gitApi-C_tpQa_z.js} +2 -2
- package/dist/assets/{input-D52Jicb1.js → input-BzYA603C.js} +1 -1
- package/dist/assets/insertionBoundaries-CzVPcHK4.js +1 -0
- package/dist/assets/{ja-fcQgtBFy.js → ja-DGtzjrFO.js} +2 -2
- package/dist/assets/{ko-CgoVt1eb.js → ko-DZ9Di0je.js} +2 -2
- package/dist/assets/{main-DOGudNxM.js → main-BJBQp1tB.js} +2 -2
- package/dist/assets/{main-UJICKUVV.js → main-BUGDfAIY.js} +23 -23
- package/dist/assets/{markdown-worker-N9d_Z-7s.js → markdown-worker-W06-MhF5.js} +1 -1
- package/dist/assets/{markdownTheme-Bl0Sy0Lf.js → markdownTheme-B89dbj3N.js} +1 -1
- package/dist/assets/{miniChat-C465ME90.js → miniChat-BLtGuyQZ.js} +2 -2
- package/dist/assets/mobile-0Sb3IsCl.js +2 -0
- package/dist/assets/multirun-BC-EFVZ0.js +4 -0
- package/dist/assets/{persistence-vZ7H8WKh.js → persistence-6sclr7B6.js} +2 -2
- package/dist/assets/{pl-DVDnk2fT.js → pl-BuCO3qb1.js} +2 -2
- package/dist/assets/{platform-CTaznShj.js → platform-CbeLpKPk.js} +1 -1
- package/dist/assets/projectMeta-CYvQd9iC.js +3 -0
- package/dist/assets/{pt-BR-ofV_Qcd_.js → pt-BR-Brf4RMsw.js} +2 -2
- package/dist/assets/radio-CEt2UjvA.js +1 -0
- package/dist/assets/renderElectronMiniChatApp-ByA4BElP.js +2 -0
- package/dist/assets/renderMobileApp-upKrXoPH.js +23 -0
- package/dist/assets/{runtimeConfig-Dofg9S27.js → runtimeConfig-BAx9ix2e.js} +1 -1
- package/dist/assets/runtimeEndpointReset-Dst0Yqyz.js +147 -0
- package/dist/assets/{sortable-tabs-strip-N3j5So4j.js → sortable-tabs-strip-nLsPC06f.js} +1 -1
- package/dist/assets/src-BDOLh3pc.css +2 -0
- package/dist/assets/{store-Hr-xvtDJ.js → store-DQY8Fkb0.js} +3 -3
- package/dist/assets/{terminalApi-KDUc5mh2.js → terminalApi-BpPeXWIu.js} +1 -1
- package/dist/assets/{tooltip-CaCe-GAm.js → tooltip-D_orJMYd.js} +1 -1
- package/dist/assets/{uk-Dzob0yMx.js → uk-C-wmaPTi.js} +2 -2
- package/dist/assets/{url-B-FWrnxu.js → url-BsaBG5-1.js} +1 -1
- package/dist/assets/useAppFontEffects-Bo__MTWV.js +7 -0
- package/dist/assets/{useAssistantUIStore-ifqNyja4.js → useAssistantUIStore-BHsHXCKE.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-CGYSjr_T.js → useDesktopWindowControlsLayout-DkmRLrOT.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-BtkZPQZ2.js → useEffectiveDirectory-ByQPsbk0.js} +1 -1
- package/dist/assets/{useFontPreferences-D_LFFhFS.js → useFontPreferences-ckzpXQaJ.js} +1 -1
- package/dist/assets/useMobileNavigationStore-DLYb8f5L.js +475 -0
- package/dist/assets/useSessionAutoCleanup-C5knpuh5.js +1 -0
- package/dist/assets/useUIStore-DzwCfRqr.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-Bo9meAHp.js → useWorkerHighlightedLines-Ce2Bs8_N.js} +1 -1
- package/dist/assets/{utils-C2P0wcWO.js → utils-B1EK5zKa.js} +1 -1
- package/dist/assets/{zh-CN-Sndj2nhS.js → zh-CN-DueRrIql.js} +2 -2
- package/dist/assets/{zh-TW-S24a8EMD.js → zh-TW-6875X80V.js} +2 -2
- package/dist/index.html +9 -9
- package/dist/mini-chat.html +9 -9
- package/dist/mobile.html +9 -9
- package/package.json +1 -1
- package/server/lib/config-sync/config-sync.test.js +52 -0
- package/server/lib/config-sync/scripts.js +10 -2
- package/server/lib/notifications/DOCUMENTATION.md +2 -1
- package/server/lib/notifications/emitter-runtime.js +78 -1
- package/server/lib/notifications/emitter-runtime.test.js +94 -1
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +4 -1
- package/server/lib/scheduled-tasks/routes.js +51 -8
- package/server/lib/session-assist/DOCUMENTATION.md +4 -2
- package/server/lib/session-assist/runtime.js +67 -30
- package/server/lib/session-assist/runtime.test.js +114 -0
- package/server/lib/session-index/DOCUMENTATION.md +9 -3
- package/server/lib/session-index/service.js +45 -22
- package/server/lib/session-index/service.test.js +81 -0
- package/server/lib/session-title/DOCUMENTATION.md +15 -10
- package/server/lib/session-title/runtime.js +24 -0
- package/server/lib/session-title/runtime.test.js +143 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +2 -1
- package/server/lib/session-turn-pages/service.js +14 -1
- package/server/lib/session-turn-pages/service.test.js +133 -0
- package/server/lib/ui-auth/ui-auth.js +3 -1
- package/server/lib/ui-auth/ui-auth.test.js +25 -0
- package/server/sse-routes.test.js +64 -1
- package/dist/assets/AssistantView-CbCe7NJG.js +0 -1
- package/dist/assets/MobileSurface-cYYd428z.js +0 -2
- package/dist/assets/appThemeRegistry-CPVFQqR9.js +0 -2
- package/dist/assets/desktopRelayRestore-VfMK9fdk.js +0 -1
- package/dist/assets/fileQueries-CXaw4b1W.js +0 -1
- package/dist/assets/insertionBoundaries-Cqk1aLwK.js +0 -1
- package/dist/assets/mobile-BHRw1m5c.js +0 -2
- package/dist/assets/multirun-BCvIqAk-.js +0 -4
- package/dist/assets/projectMeta-Wxow1DQz.js +0 -3
- package/dist/assets/radio-CS9DITGQ.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-DAIgdBk9.js +0 -2
- package/dist/assets/renderMobileApp-BXOtgQHy.js +0 -23
- package/dist/assets/runtimeEndpointReset-Cc3yypUQ.js +0 -147
- package/dist/assets/src-Dbdlm5x1.css +0 -2
- package/dist/assets/useAppFontEffects-BpT_sNyZ.js +0 -7
- package/dist/assets/useMobileNavigationStore-BNnnW08z.js +0 -476
- package/dist/assets/useSessionAutoCleanup-C5V-GCiC.js +0 -1
- package/dist/assets/useUIStore-NfqK-HA7.js +0 -1
|
@@ -33,6 +33,8 @@ const isSessionRecapEnabled = () => {
|
|
|
33
33
|
|
|
34
34
|
const IDLE_QUIET_MS = 60_000;
|
|
35
35
|
const TRANSCRIPT_MESSAGE_LIMIT = 12;
|
|
36
|
+
/** Latest real user messages fed to the recap prompt. */
|
|
37
|
+
const TRANSCRIPT_USER_MESSAGE_LIMIT = 3;
|
|
36
38
|
const TRANSCRIPT_PART_CHAR_LIMIT = 6_000;
|
|
37
39
|
const RECAP_CHAR_LIMIT = 320;
|
|
38
40
|
const FETCH_TIMEOUT_MS = 5_000;
|
|
@@ -92,13 +94,63 @@ const extractUserMessage = (payload) => {
|
|
|
92
94
|
};
|
|
93
95
|
};
|
|
94
96
|
|
|
95
|
-
|
|
97
|
+
/** Real (non-synthetic) text parts of a message — hidden instructions excluded. */
|
|
98
|
+
const realTextParts = (message) => {
|
|
96
99
|
const parts = Array.isArray(message?.parts) ? message.parts : [];
|
|
97
100
|
return parts
|
|
98
|
-
.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
.filter((part) => part?.type === 'text'
|
|
102
|
+
&& typeof part.text === 'string'
|
|
103
|
+
&& part.text.trim().length > 0
|
|
104
|
+
&& part.synthetic !== true)
|
|
105
|
+
.map((part) => part.text);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Body text of a message: only its LAST text block, char-clamped. */
|
|
109
|
+
const lastMessageText = (message) => {
|
|
110
|
+
const texts = realTextParts(message);
|
|
111
|
+
return texts.length > 0 ? texts[texts.length - 1].slice(0, TRANSCRIPT_PART_CHAR_LIMIT) : '';
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build the recap transcript from a bounded recent-message window.
|
|
116
|
+
* - Assistant: only the LAST text block of the last assistant message —
|
|
117
|
+
* earlier blocks, reasoning, and tool output are token waste for a
|
|
118
|
+
* one-line recap.
|
|
119
|
+
* - User: the latest real user messages (default 3), chronological.
|
|
120
|
+
*/
|
|
121
|
+
export const buildAssistTranscript = (
|
|
122
|
+
messages,
|
|
123
|
+
{ userLimit = TRANSCRIPT_USER_MESSAGE_LIMIT } = {},
|
|
124
|
+
) => {
|
|
125
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
126
|
+
return { transcript: '', assistantInfo: null, userTexts: [], assistantText: '' };
|
|
127
|
+
}
|
|
128
|
+
let lastAssistant = null;
|
|
129
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
130
|
+
if (messages[i]?.info?.role === 'assistant') {
|
|
131
|
+
lastAssistant = messages[i];
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const lastAssistantInfo = lastAssistant?.info ?? null;
|
|
136
|
+
if (!lastAssistantInfo?.id) {
|
|
137
|
+
return { transcript: '', assistantInfo: null, userTexts: [], assistantText: '' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const userTexts = [];
|
|
141
|
+
for (let i = messages.length - 1; i >= 0 && userTexts.length < userLimit; i -= 1) {
|
|
142
|
+
const message = messages[i];
|
|
143
|
+
if (message?.info?.role !== 'user') continue;
|
|
144
|
+
const text = lastMessageText(message);
|
|
145
|
+
if (!text) continue;
|
|
146
|
+
userTexts.unshift(text);
|
|
147
|
+
}
|
|
148
|
+
const assistantText = lastMessageText(lastAssistant);
|
|
149
|
+
const transcript = [
|
|
150
|
+
...userTexts.map((text) => `User:\n${text}`),
|
|
151
|
+
assistantText ? `Assistant:\n${assistantText}` : '',
|
|
152
|
+
].filter(Boolean).join('\n\n');
|
|
153
|
+
return { transcript, assistantInfo: lastAssistantInfo, userTexts, assistantText };
|
|
102
154
|
};
|
|
103
155
|
|
|
104
156
|
export const createSessionAssistRuntime = ({
|
|
@@ -169,36 +221,21 @@ export const createSessionAssistRuntime = ({
|
|
|
169
221
|
return;
|
|
170
222
|
}
|
|
171
223
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
lastAssistant = messages[i];
|
|
177
|
-
break;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
const lastAssistantInfo = lastAssistant?.info;
|
|
224
|
+
// Trimmed extraction for a one-line recap: the last assistant text block
|
|
225
|
+
// plus the latest real user messages. Everything else (earlier blocks,
|
|
226
|
+
// reasoning, tool output, older turns) is token waste.
|
|
227
|
+
const { transcript, assistantInfo: lastAssistantInfo, userTexts, assistantText } = buildAssistTranscript(messages);
|
|
181
228
|
if (!lastAssistantInfo?.id) return;
|
|
182
|
-
|
|
183
|
-
// Only the last exchange: the assistant reply plus the user message it
|
|
184
|
-
// answered (assistant info.parentID → user info.id). Everything else is
|
|
185
|
-
// token waste for a one-line recap.
|
|
186
|
-
const parentUserMessage = typeof lastAssistantInfo.parentID === 'string' && lastAssistantInfo.parentID
|
|
187
|
-
? messages.find((message) => message?.info?.id === lastAssistantInfo.parentID && message?.info?.role === 'user')
|
|
188
|
-
: null;
|
|
189
|
-
const userText = parentUserMessage ? messagePartsToText(parentUserMessage) : '';
|
|
190
|
-
const assistantText = messagePartsToText(lastAssistant);
|
|
191
|
-
const transcript = [
|
|
192
|
-
userText ? `User:\n${userText}` : '',
|
|
193
|
-
assistantText ? `Assistant:\n${assistantText}` : '',
|
|
194
|
-
].filter(Boolean).join('\n\n');
|
|
195
229
|
if (!transcript) return;
|
|
196
230
|
|
|
197
231
|
const { generateSmallModelText } = await getSmallModelService();
|
|
198
232
|
// Instruct the language by example, not by description — account-side
|
|
199
233
|
// personalization (e.g. the ChatGPT backend knowing the user's locale)
|
|
200
234
|
// otherwise leaks a different language into the output.
|
|
201
|
-
const languageSample = (
|
|
235
|
+
const languageSample = (userTexts[userTexts.length - 1] || assistantText)
|
|
236
|
+
.slice(0, 200)
|
|
237
|
+
.replace(/\s+/g, ' ')
|
|
238
|
+
.trim();
|
|
202
239
|
let generated;
|
|
203
240
|
try {
|
|
204
241
|
generated = await generateSmallModelText({
|
|
@@ -206,7 +243,7 @@ export const createSessionAssistRuntime = ({
|
|
|
206
243
|
// session's own provider unless the user explicitly picked a small
|
|
207
244
|
// model (settings override / opencode config).
|
|
208
245
|
restrictToPreferredProvider: true,
|
|
209
|
-
prompt: `The latest
|
|
246
|
+
prompt: `The latest conversation excerpt:\n\n${transcript}\n\nWrite recap in the SAME language as this sample from the conversation: "${languageSample}"`,
|
|
210
247
|
system: ASSIST_SYSTEM_PROMPT,
|
|
211
248
|
directory,
|
|
212
249
|
preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
|
|
@@ -228,7 +265,7 @@ export const createSessionAssistRuntime = ({
|
|
|
228
265
|
// no Cyrillic/CJK at all, the output must not either.
|
|
229
266
|
const hasCyrillic = (text) => /[\u0400-\u04FF]/.test(text);
|
|
230
267
|
const hasCjk = (text) => /[\u3040-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/.test(text);
|
|
231
|
-
const inputText = `${
|
|
268
|
+
const inputText = `${userTexts.join('\n')}\n${assistantText}`;
|
|
232
269
|
const scriptMismatch = (text) => (hasCyrillic(text) && !hasCyrillic(inputText))
|
|
233
270
|
|| (hasCjk(text) && !hasCjk(inputText));
|
|
234
271
|
if (recap && scriptMismatch(recap)) {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { buildAssistTranscript } from './runtime.js';
|
|
3
|
+
|
|
4
|
+
const textPart = (text, { synthetic = false } = {}) => ({
|
|
5
|
+
type: 'text',
|
|
6
|
+
text,
|
|
7
|
+
...(synthetic ? { synthetic: true } : {}),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const userMessage = (id, parts) => ({ info: { id, role: 'user' }, parts });
|
|
11
|
+
const assistantMessage = (id, parts) => ({ info: { id, role: 'assistant' }, parts });
|
|
12
|
+
|
|
13
|
+
describe('buildAssistTranscript', () => {
|
|
14
|
+
test('assistant keeps only the LAST text block of the last assistant message', () => {
|
|
15
|
+
const result = buildAssistTranscript([
|
|
16
|
+
userMessage('u1', [textPart('first user message')]),
|
|
17
|
+
assistantMessage('a1', [textPart('old assistant reply')]),
|
|
18
|
+
userMessage('u2', [textPart('second user message')]),
|
|
19
|
+
assistantMessage('a2', [
|
|
20
|
+
textPart('intro before tools'),
|
|
21
|
+
{ type: 'tool', tool: 'bash' },
|
|
22
|
+
textPart('final body text'),
|
|
23
|
+
]),
|
|
24
|
+
]);
|
|
25
|
+
expect(result.assistantInfo.id).toBe('a2');
|
|
26
|
+
expect(result.assistantText).toBe('final body text');
|
|
27
|
+
expect(result.transcript).toBe(
|
|
28
|
+
'User:\nfirst user message\n\nUser:\nsecond user message\n\nAssistant:\nfinal body text',
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('reasoning parts never enter the transcript', () => {
|
|
33
|
+
const result = buildAssistTranscript([
|
|
34
|
+
userMessage('u1', [textPart('请帮我看看')]),
|
|
35
|
+
assistantMessage('a1', [
|
|
36
|
+
{ type: 'reasoning', text: 'thinking about the problem' },
|
|
37
|
+
textPart('最终答复'),
|
|
38
|
+
]),
|
|
39
|
+
]);
|
|
40
|
+
expect(result.transcript).toBe('User:\n请帮我看看\n\nAssistant:\n最终答复');
|
|
41
|
+
expect(result.transcript).not.toContain('thinking');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('user side keeps only the latest 3 real user messages, chronologically', () => {
|
|
45
|
+
const result = buildAssistTranscript([
|
|
46
|
+
userMessage('u1', [textPart('question 1')]),
|
|
47
|
+
assistantMessage('a1', [textPart('answer 1')]),
|
|
48
|
+
userMessage('u2', [textPart('question 2')]),
|
|
49
|
+
assistantMessage('a2', [textPart('answer 2')]),
|
|
50
|
+
userMessage('u3', [textPart('question 3')]),
|
|
51
|
+
userMessage('u4', [textPart('question 4')]),
|
|
52
|
+
assistantMessage('a3', [textPart('answer 3')]),
|
|
53
|
+
]);
|
|
54
|
+
expect(result.userTexts).toEqual(['question 2', 'question 3', 'question 4']);
|
|
55
|
+
expect(result.transcript).toBe(
|
|
56
|
+
'User:\nquestion 2\n\nUser:\nquestion 3\n\nUser:\nquestion 4\n\nAssistant:\nanswer 3',
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('synthetic-only user messages do not count toward the user window', () => {
|
|
61
|
+
const result = buildAssistTranscript([
|
|
62
|
+
userMessage('u1', [textPart('visible request')]),
|
|
63
|
+
userMessage('u2', [textPart('hidden instruction', { synthetic: true })]),
|
|
64
|
+
userMessage('u3', [textPart('real follow-up')]),
|
|
65
|
+
assistantMessage('a1', [textPart('reply body')]),
|
|
66
|
+
]);
|
|
67
|
+
expect(result.userTexts).toEqual(['visible request', 'real follow-up']);
|
|
68
|
+
expect(result.transcript).not.toContain('hidden instruction');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('synthetic instruction part on a real user message is dropped', () => {
|
|
72
|
+
const result = buildAssistTranscript([
|
|
73
|
+
userMessage('u1', [
|
|
74
|
+
textPart('visible request'),
|
|
75
|
+
textPart('/summary hidden instructions', { synthetic: true }),
|
|
76
|
+
]),
|
|
77
|
+
assistantMessage('a1', [textPart('reply body')]),
|
|
78
|
+
]);
|
|
79
|
+
expect(result.userTexts).toEqual(['visible request']);
|
|
80
|
+
expect(result.transcript).not.toContain('/summary hidden instructions');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('fewer than 3 user messages keeps all of them', () => {
|
|
84
|
+
const result = buildAssistTranscript([
|
|
85
|
+
userMessage('u1', [textPart('only question')]),
|
|
86
|
+
assistantMessage('a1', [textPart('only answer')]),
|
|
87
|
+
]);
|
|
88
|
+
expect(result.userTexts).toEqual(['only question']);
|
|
89
|
+
expect(result.transcript).toBe('User:\nonly question\n\nAssistant:\nonly answer');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('no assistant message yields an empty transcript', () => {
|
|
93
|
+
const result = buildAssistTranscript([
|
|
94
|
+
userMessage('u1', [textPart('unanswered question')]),
|
|
95
|
+
]);
|
|
96
|
+
expect(result.transcript).toBe('');
|
|
97
|
+
expect(result.assistantInfo).toBeNull();
|
|
98
|
+
expect(result.userTexts).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('assistant with no text parts still summarizes the user side', () => {
|
|
102
|
+
const result = buildAssistTranscript([
|
|
103
|
+
userMessage('u1', [textPart('question')]),
|
|
104
|
+
assistantMessage('a1', [{ type: 'tool', tool: 'bash' }]),
|
|
105
|
+
]);
|
|
106
|
+
expect(result.assistantInfo.id).toBe('a1');
|
|
107
|
+
expect(result.transcript).toBe('User:\nquestion');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('empty input yields an empty transcript', () => {
|
|
111
|
+
expect(buildAssistTranscript([]).transcript).toBe('');
|
|
112
|
+
expect(buildAssistTranscript(null).transcript).toBe('');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -20,13 +20,19 @@ the index excludes them from every snapshot and clears prior summaries when a
|
|
|
20
20
|
`metadata.openchamber.smallModel.purpose`. Title prefixes are human labels and
|
|
21
21
|
never participate in this filter. Metadata is the ownership/isolation signal;
|
|
22
22
|
`time.archived` is archive state; `time.pinned` is OpenChamber-owned pin state
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
overlaid from the independent `session_pin` table (also mirrored onto in-window
|
|
24
|
+
`session_summary.pinned_at` for older readers). Pins are not subject to the
|
|
25
|
+
newest-20 summary bound: evicting or rebuilding a directory keeps pin
|
|
26
|
+
membership, and `setPinned` can target a session that is not currently in the
|
|
27
|
+
index. Archive or `remove` still clears the pin. Live OpenCode upserts never
|
|
28
|
+
overwrite pin membership. Titles are for recognition. The index does
|
|
25
29
|
not cache system sessions, so cold-start and live upserts stay aligned with
|
|
26
30
|
ordinary sidebar lists.
|
|
27
31
|
Pin and unpin use `POST` / `DELETE`
|
|
28
|
-
`/api/openchamber/session-index/session/:id/pin`,
|
|
32
|
+
`/api/openchamber/session-index/session/:id/pin`, write `session_pin`, then
|
|
29
33
|
`publishChange()` so revision tips broadcast `openchamber:session-index-changed`.
|
|
34
|
+
Snapshots include `pinnedSessionIds` plus `time.pinned` on any matching
|
|
35
|
+
summary row.
|
|
30
36
|
|
|
31
37
|
The server-side global OpenCode event subscriber writes session summary events
|
|
32
38
|
directly into this index. User `message.updated` events and `session.idle`
|
|
@@ -140,6 +140,12 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
140
140
|
ON session_summary(runtime_key, directory, activity_updated_at DESC, session_id DESC);
|
|
141
141
|
CREATE INDEX IF NOT EXISTS session_child_parent
|
|
142
142
|
ON session_child(runtime_key, directory, parent_id);
|
|
143
|
+
CREATE TABLE IF NOT EXISTS session_pin (
|
|
144
|
+
runtime_key TEXT NOT NULL,
|
|
145
|
+
session_id TEXT NOT NULL,
|
|
146
|
+
pinned_at INTEGER NOT NULL,
|
|
147
|
+
PRIMARY KEY (runtime_key, session_id)
|
|
148
|
+
);
|
|
143
149
|
`);
|
|
144
150
|
const sessionSummaryColumns = new Set(
|
|
145
151
|
db.prepare("SELECT name FROM pragma_table_info('session_summary')").all().map((column) => column.name),
|
|
@@ -147,6 +153,12 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
147
153
|
if (!sessionSummaryColumns.has('pinned_at')) {
|
|
148
154
|
db.exec('ALTER TABLE session_summary ADD COLUMN pinned_at INTEGER');
|
|
149
155
|
}
|
|
156
|
+
db.prepare(`
|
|
157
|
+
INSERT OR IGNORE INTO session_pin(runtime_key, session_id, pinned_at)
|
|
158
|
+
SELECT runtime_key, session_id, pinned_at
|
|
159
|
+
FROM session_summary
|
|
160
|
+
WHERE pinned_at IS NOT NULL
|
|
161
|
+
`).run();
|
|
150
162
|
db.prepare('INSERT OR REPLACE INTO session_index_meta(key, value) VALUES (?, ?)')
|
|
151
163
|
.run('schema_version', String(SCHEMA_VERSION));
|
|
152
164
|
|
|
@@ -240,6 +252,23 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
240
252
|
SELECT COUNT(*) AS count FROM session_child
|
|
241
253
|
WHERE runtime_key = ? AND directory = ? AND parent_id = ?
|
|
242
254
|
`);
|
|
255
|
+
const readPins = db.prepare(`
|
|
256
|
+
SELECT session_id AS id, pinned_at AS pinnedAt
|
|
257
|
+
FROM session_pin
|
|
258
|
+
WHERE runtime_key = ?
|
|
259
|
+
ORDER BY pinned_at DESC, session_id DESC
|
|
260
|
+
`);
|
|
261
|
+
const upsertPin = db.prepare(`
|
|
262
|
+
INSERT INTO session_pin(runtime_key, session_id, pinned_at)
|
|
263
|
+
VALUES (?, ?, ?)
|
|
264
|
+
ON CONFLICT(runtime_key, session_id) DO UPDATE SET pinned_at = excluded.pinned_at
|
|
265
|
+
`);
|
|
266
|
+
const deletePin = db.prepare('DELETE FROM session_pin WHERE runtime_key = ? AND session_id = ?');
|
|
267
|
+
const writeSummaryPinnedAt = db.prepare(`
|
|
268
|
+
UPDATE session_summary
|
|
269
|
+
SET pinned_at = ?
|
|
270
|
+
WHERE runtime_key = ? AND session_id = ?
|
|
271
|
+
`);
|
|
243
272
|
|
|
244
273
|
const replaceDirectoryRows = ({ directory, sessions, cursor, hasMore, fullSync = true, now = Date.now() }) => {
|
|
245
274
|
const normalizedDirectory = normalizeDirectory(directory);
|
|
@@ -363,6 +392,8 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
363
392
|
|
|
364
393
|
const snapshot = () => {
|
|
365
394
|
const key = runtimeKey();
|
|
395
|
+
const pinRows = readPins.all(key);
|
|
396
|
+
const pinById = new Map(pinRows.map((row) => [row.id, row.pinnedAt]));
|
|
366
397
|
const directories = db.prepare(`
|
|
367
398
|
SELECT directory, cursor, has_more AS hasMore, last_synced_at AS lastSyncedAt,
|
|
368
399
|
last_full_synced_at AS lastFullSyncedAt, last_accessed_at AS lastAccessedAt
|
|
@@ -387,7 +418,9 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
387
418
|
created: session.createdAt,
|
|
388
419
|
updated: session.updatedAt,
|
|
389
420
|
...(session.archivedAt ? { archived: session.archivedAt } : {}),
|
|
390
|
-
...(session.
|
|
421
|
+
...((pinById.get(session.id) ?? session.pinnedAt)
|
|
422
|
+
? { pinned: pinById.get(session.id) ?? session.pinnedAt }
|
|
423
|
+
: {}),
|
|
391
424
|
},
|
|
392
425
|
metadata: {
|
|
393
426
|
openchamber: {
|
|
@@ -401,13 +434,17 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
401
434
|
}))
|
|
402
435
|
.filter(isVisibleSession),
|
|
403
436
|
}));
|
|
404
|
-
return {
|
|
437
|
+
return {
|
|
438
|
+
directories,
|
|
439
|
+
pinnedSessionIds: pinRows.map((row) => row.id),
|
|
440
|
+
};
|
|
405
441
|
};
|
|
406
442
|
|
|
407
443
|
function remove(sessionID) {
|
|
408
444
|
if (typeof sessionID !== 'string' || !sessionID) return false;
|
|
409
445
|
const key = runtimeKey();
|
|
410
446
|
const parent = parentForChild.get(key, sessionID);
|
|
447
|
+
const pinResult = deletePin.run(key, sessionID);
|
|
411
448
|
const result = db.prepare('DELETE FROM session_summary WHERE runtime_key = ? AND session_id = ?').run(key, sessionID);
|
|
412
449
|
deleteChildrenWithParent.run(key, sessionID);
|
|
413
450
|
deleteChild.run(key, sessionID);
|
|
@@ -419,7 +456,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
419
456
|
parent.parentID,
|
|
420
457
|
);
|
|
421
458
|
}
|
|
422
|
-
return result.changes > 0;
|
|
459
|
+
return result.changes > 0 || pinResult.changes > 0;
|
|
423
460
|
}
|
|
424
461
|
|
|
425
462
|
/**
|
|
@@ -480,36 +517,22 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
480
517
|
return result.changes > 0;
|
|
481
518
|
};
|
|
482
519
|
|
|
483
|
-
const summaryExists = db.prepare(`
|
|
484
|
-
SELECT 1 AS present FROM session_summary
|
|
485
|
-
WHERE runtime_key = ? AND session_id = ?
|
|
486
|
-
LIMIT 1
|
|
487
|
-
`);
|
|
488
|
-
|
|
489
520
|
const setPinned = (sessionID, pinnedAt = Date.now()) => {
|
|
490
521
|
if (typeof sessionID !== 'string' || !sessionID) return false;
|
|
491
522
|
const timestamp = toTimestamp(pinnedAt);
|
|
492
523
|
if (!timestamp) return false;
|
|
493
524
|
const key = runtimeKey();
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
UPDATE session_summary
|
|
497
|
-
SET pinned_at = ?
|
|
498
|
-
WHERE runtime_key = ? AND session_id = ?
|
|
499
|
-
`).run(timestamp, key, sessionID);
|
|
525
|
+
upsertPin.run(key, sessionID, timestamp);
|
|
526
|
+
writeSummaryPinnedAt.run(timestamp, key, sessionID);
|
|
500
527
|
return true;
|
|
501
528
|
};
|
|
502
529
|
|
|
503
530
|
const clearPinned = (sessionID) => {
|
|
504
531
|
if (typeof sessionID !== 'string' || !sessionID) return false;
|
|
505
532
|
const key = runtimeKey();
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
SET pinned_at = NULL
|
|
510
|
-
WHERE runtime_key = ? AND session_id = ?
|
|
511
|
-
`).run(key, sessionID);
|
|
512
|
-
return true;
|
|
533
|
+
const pinResult = deletePin.run(key, sessionID);
|
|
534
|
+
const summaryResult = writeSummaryPinnedAt.run(null, key, sessionID);
|
|
535
|
+
return pinResult.changes > 0 || summaryResult.changes > 0;
|
|
513
536
|
};
|
|
514
537
|
|
|
515
538
|
const replaceChildSessions = db.transaction((directory, parentSessionID, children) => {
|
|
@@ -385,7 +385,88 @@ describe('Electron session index', () => {
|
|
|
385
385
|
|
|
386
386
|
expect(service.upsert({ ...session('ses_a', 11), time: { created: 9, updated: 11, archived: 12 } })).toBe(true);
|
|
387
387
|
expect(service.snapshot().directories[0].sessions).toEqual([]);
|
|
388
|
+
expect(service.snapshot().pinnedSessionIds ?? []).not.toContain('ses_a');
|
|
388
389
|
expect(service.clearPinned('ses_a')).toBe(false);
|
|
389
390
|
service.close();
|
|
390
391
|
});
|
|
392
|
+
|
|
393
|
+
it('keeps a pin after the session falls outside the newest-20 rebuild window', () => {
|
|
394
|
+
const runtimeRef = { value: 'http://runtime-a.test' };
|
|
395
|
+
const service = createService(runtimeRef);
|
|
396
|
+
service.replaceDirectory({
|
|
397
|
+
directory: '/repo',
|
|
398
|
+
sessions: [session('ses_pinned', 10)],
|
|
399
|
+
cursor: null,
|
|
400
|
+
hasMore: false,
|
|
401
|
+
});
|
|
402
|
+
expect(service.setPinned('ses_pinned', 42)).toBe(true);
|
|
403
|
+
|
|
404
|
+
const newer = Array.from({ length: 20 }, (_, index) => session(`ses_new_${index}`, 200 - index));
|
|
405
|
+
service.replaceDirectory({ directory: '/repo', sessions: newer, cursor: null, hasMore: true, now: 3000 });
|
|
406
|
+
|
|
407
|
+
const snapshot = service.snapshot();
|
|
408
|
+
expect(snapshot.directories[0].sessions).toHaveLength(20);
|
|
409
|
+
expect(snapshot.directories[0].sessions.some((item) => item.id === 'ses_pinned')).toBe(false);
|
|
410
|
+
expect(snapshot.pinnedSessionIds).toEqual(['ses_pinned']);
|
|
411
|
+
service.close();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('keeps a pin after live upserts evict the pinned summary row', () => {
|
|
415
|
+
const runtimeRef = { value: 'http://runtime-a.test' };
|
|
416
|
+
const service = createService(runtimeRef);
|
|
417
|
+
service.replaceDirectory({
|
|
418
|
+
directory: '/repo',
|
|
419
|
+
sessions: [session('ses_pinned', 10)],
|
|
420
|
+
cursor: null,
|
|
421
|
+
hasMore: false,
|
|
422
|
+
});
|
|
423
|
+
expect(service.setPinned('ses_pinned', 43)).toBe(true);
|
|
424
|
+
|
|
425
|
+
for (let index = 0; index < 20; index += 1) {
|
|
426
|
+
service.upsert(session(`ses_new_${index}`, 200 - index));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const snapshot = service.snapshot();
|
|
430
|
+
expect(snapshot.directories[0].sessions).toHaveLength(20);
|
|
431
|
+
expect(snapshot.directories[0].sessions.some((item) => item.id === 'ses_pinned')).toBe(false);
|
|
432
|
+
expect(snapshot.pinnedSessionIds).toEqual(['ses_pinned']);
|
|
433
|
+
service.close();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('backfills session_pin from existing summary pinned_at on open', () => {
|
|
437
|
+
const runtimeRef = { value: 'http://runtime-a.test' };
|
|
438
|
+
const service = createService(runtimeRef);
|
|
439
|
+
service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
|
|
440
|
+
expect(service.setPinned('ses_a', 66)).toBe(true);
|
|
441
|
+
service.close();
|
|
442
|
+
|
|
443
|
+
const dbPath = path.join(tempDirectories[tempDirectories.length - 1], 'session-index.sqlite');
|
|
444
|
+
const Database = createRequire(import.meta.url)('better-sqlite3');
|
|
445
|
+
const db = new Database(dbPath);
|
|
446
|
+
db.exec('DELETE FROM session_pin');
|
|
447
|
+
db.close();
|
|
448
|
+
|
|
449
|
+
const reopened = createSessionIndexService({
|
|
450
|
+
dbPath,
|
|
451
|
+
getRuntimeConfig: () => ({ apiBaseUrl: runtimeRef.value }),
|
|
452
|
+
});
|
|
453
|
+
expect(reopened.snapshot().pinnedSessionIds).toEqual(['ses_a']);
|
|
454
|
+
expect(reopened.snapshot().directories[0].sessions[0].time.pinned).toBe(66);
|
|
455
|
+
reopened.close();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('pins a session that is not in the newest-20 index', () => {
|
|
459
|
+
const runtimeRef = { value: 'http://runtime-a.test' };
|
|
460
|
+
const service = createService(runtimeRef);
|
|
461
|
+
const newest = Array.from({ length: 20 }, (_, index) => session(`ses_${index}`, 100 - index));
|
|
462
|
+
service.replaceDirectory({ directory: '/repo', sessions: newest, cursor: 80, hasMore: true, now: 1000 });
|
|
463
|
+
|
|
464
|
+
expect(service.setPinned('ses_old', 55)).toBe(true);
|
|
465
|
+
expect(service.snapshot().pinnedSessionIds).toEqual(['ses_old']);
|
|
466
|
+
expect(service.snapshot().directories[0].sessions.some((item) => item.id === 'ses_old')).toBe(false);
|
|
467
|
+
|
|
468
|
+
expect(service.clearPinned('ses_old')).toBe(true);
|
|
469
|
+
expect(service.snapshot().pinnedSessionIds ?? []).toEqual([]);
|
|
470
|
+
service.close();
|
|
471
|
+
});
|
|
391
472
|
});
|
|
@@ -18,16 +18,21 @@ throttle (`TITLE_THROTTLE_MS`) when a refresh is armed.
|
|
|
18
18
|
pattern as session-assist. Purely event-driven — dormant sessions never
|
|
19
19
|
generate anything.
|
|
20
20
|
2. Auto title refresh is intentionally sparse (title stability first). A newly
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
21
|
+
observed root `session.created` generates its first title immediately on the
|
|
22
|
+
first `session.status: idle`. A fork title (`(fork #n)`) waits for its first
|
|
23
|
+
newly-created user message; the matching assistant completion triggers an
|
|
24
|
+
immediate title refresh that bypasses inherited title metadata and throttle.
|
|
25
|
+
If the fork's `session.created` was lost (SSE reconnect gap, server or
|
|
26
|
+
OpenCode restart), the first newly-created user message lazily re-registers
|
|
27
|
+
the pending fork from the session read inside `recordUserActivity` (fork
|
|
28
|
+
title + message created after the fork + activity timestamp not yet past
|
|
29
|
+
the fork time), so the first-reply refresh still fires.
|
|
30
|
+
Ordinary later idle transitions do **not** arm another refresh. Any
|
|
31
|
+
`busy`/`retry` status or a fresh user `message.updated` still clears an
|
|
32
|
+
already-armed timer (so initial/fork timers cancel if the user keeps going).
|
|
33
|
+
A sidebar smart-title request sets `titleRefresh.requestedAt`; its
|
|
34
|
+
`session.updated` event arms the same flow immediately (forced / manual
|
|
35
|
+
refresh is unaffected by the background gate).
|
|
31
36
|
3. On fire:
|
|
32
37
|
- Skip when Settings → Chat → session title refresh is off.
|
|
33
38
|
- Skip system-owned sessions whose metadata carries a non-empty
|
|
@@ -484,6 +484,30 @@ export const createSessionTitleRuntime = ({
|
|
|
484
484
|
if (!session || typeof session !== 'object') return;
|
|
485
485
|
|
|
486
486
|
const meta = readTitleRefreshMeta(session);
|
|
487
|
+
|
|
488
|
+
// Lazy fork registration: when the fork's `session.created` event was lost
|
|
489
|
+
// (SSE reconnect gap, server/OpenCode restart), the in-memory pending map
|
|
490
|
+
// never learned about the fork. The first newly-created user message on a
|
|
491
|
+
// fork-titled session (message created after the fork, before any activity
|
|
492
|
+
// timestamp advanced past the fork) rebuilds that pending entry so the
|
|
493
|
+
// matching idle still triggers the first-refresh. Idempotent: the normal
|
|
494
|
+
// session.created path already holds a pending entry, and once activity
|
|
495
|
+
// advances past the fork time later messages never re-register.
|
|
496
|
+
const forkCreated = Number(session?.time?.created);
|
|
497
|
+
if (
|
|
498
|
+
!forkFirstSendPending.has(sessionId)
|
|
499
|
+
&& isForkedSessionTitle(typeof session.title === 'string' ? session.title : '')
|
|
500
|
+
&& Number.isFinite(forkCreated)
|
|
501
|
+
&& createdAt > forkCreated
|
|
502
|
+
&& !(Number(meta.titleRefresh.activityUpdatedAt) > forkCreated)
|
|
503
|
+
) {
|
|
504
|
+
forkFirstSendPending.set(sessionId, {
|
|
505
|
+
createdAt: forkCreated,
|
|
506
|
+
directory: directory || '',
|
|
507
|
+
hasNewUserMessage: true,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
487
511
|
const previous = Number(meta.titleRefresh.activityUpdatedAt);
|
|
488
512
|
const activityUpdatedAt = Number.isFinite(previous) ? Math.max(previous, createdAt) : createdAt;
|
|
489
513
|
if (activityUpdatedAt === previous) return;
|