@openchambery/web 1.19.13 → 1.19.15-beta.1
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-mbvX7YlW.js → AgentAvatar-m1jNq4bX.js} +1 -1
- package/dist/assets/AssistantView-D5rEZ-Ss.js +2 -0
- package/dist/assets/{DiagramView-BdI4PT2H.js → DiagramView-DaB9Sb7S.js} +1 -1
- package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
- package/dist/assets/Icon-D1BcR8Tz.js +1 -0
- package/dist/assets/MarkdownRenderer-emLeoNjI.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-2ewbRZE2.js → MarkdownRendererImpl-BWCAPlqn.js} +3 -3
- package/dist/assets/{MarkstreamRendererImpl-DXewO7S_.js → MarkstreamRendererImpl-BD_NFKUv.js} +2 -2
- package/dist/assets/MobileDetailNavigation-DqXVAIap.js +1 -0
- package/dist/assets/MobileShareBridge-Bk2fdkJk.js +4 -0
- package/dist/assets/MobileSurface-C4mpcOCh.js +2 -0
- package/dist/assets/{MultiRunWindow-C8ukIdLA.js → MultiRunWindow-rs49_K1E.js} +1 -1
- package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-bIWeMLVx.js} +1 -1
- package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
- package/dist/assets/SettingsView-b8LUjoad.js +25 -0
- package/dist/assets/{SettingsWindow-CSePZ_V8.js → SettingsWindow-D8JFlPdA.js} +1 -1
- package/dist/assets/{TerminalView-BmSIrZ81.js → TerminalView-DCd-Godi.js} +1 -1
- package/dist/assets/{ToolOutputDialog-2BYaWLo1.js → ToolOutputDialog-B50L_nUP.js} +1 -1
- package/dist/assets/{appThemeRegistry-Dk4gBaOu.js → appThemeRegistry-BWtCaTMe.js} +1 -1
- package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
- package/dist/assets/{clipboard-CUasgPiF.js → clipboard-GUUNlWIv.js} +1 -1
- package/dist/assets/{collapsible-CACHBRYZ.js → collapsible-Bbz9KiJf.js} +1 -1
- package/dist/assets/{context-menu-CYbm4wBZ.js → context-menu-CSyOhRz8.js} +1 -1
- package/dist/assets/{desktopHosts-DILvrAok.js → desktopHosts-B052a4UK.js} +1 -1
- package/dist/assets/{desktopRelayRestore-t8asCr3m.js → desktopRelayRestore-OjKNXr10.js} +1 -1
- package/dist/assets/{diagram-u0BC9uuY.js → diagram-NdsZih8P.js} +1 -1
- package/dist/assets/{dialog-BwQ3rY_x.js → dialog-DhMGbF_B.js} +1 -1
- package/dist/assets/{es-CRjOwvYM.js → es-BQb6I8gl.js} +2 -2
- package/dist/assets/fileMentionTouchSelection-DKYkoypI.js +4 -0
- package/dist/assets/{fileQueries-Bgly-E8f.js → fileQueries-B_s_B-fH.js} +1 -1
- package/dist/assets/{fr-D9taEN9L.js → fr-BjGouC_l.js} +2 -2
- package/dist/assets/{gitApi-DtPf-h-O.js → gitApi-D4PyYgLZ.js} +3 -3
- package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
- package/dist/assets/insertionBoundaries-Bnnx03Wj.js +1 -0
- package/dist/assets/{ja-CYQ5aoWp.js → ja-Cw9zliYY.js} +2 -2
- package/dist/assets/{ko-BwASV0Q0.js → ko-Dyz0Dk9b.js} +2 -2
- package/dist/assets/main-BRiQTNFX.js +169 -0
- package/dist/assets/{main-Ci3JmtpW.js → main-CnY0LWNc.js} +2 -2
- package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
- package/dist/assets/miniChat-B0pw_mUs.js +2 -0
- package/dist/assets/mobile-BKHmxwXz.js +2 -0
- package/dist/assets/{multirun-CmaTeZON.js → multirun-BQKqVfFT.js} +1 -1
- package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
- package/dist/assets/{pl-DUI1vPil.js → pl-C7EY-dTH.js} +1 -1
- package/dist/assets/platform-DQ5_LNKO.js +2 -0
- package/dist/assets/{projectMeta-cw9nrwpr.js → projectMeta-BvG8sdtC.js} +2 -2
- package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-B1uHpjFW.js} +2 -2
- package/dist/assets/renderElectronMiniChatApp-E5a0yy7E.js +2 -0
- package/dist/assets/renderMobileApp-DT0L4Ah6.js +28 -0
- package/dist/assets/{runtimeConfig-DI8l0VyZ.js → runtimeConfig-utWzrDwa.js} +1 -1
- package/dist/assets/runtimeEndpointReset-DE6lwHyg.js +147 -0
- package/dist/assets/sessionChangeSummary-BG7l2heP.js +2 -0
- package/dist/assets/sessionLookup-12zVOR0J.js +1 -0
- package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-dYW6SH62.js} +1 -1
- package/dist/assets/src-CFUi4riW.css +2 -0
- package/dist/assets/{store-CSnH_QXM.js → store-ClnJdJSj.js} +3 -3
- package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
- package/dist/assets/{textarea-BfNtUdJE.js → textarea-B9CmN13Z.js} +1 -1
- package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
- package/dist/assets/{uk-C1FYZlmA.js → uk-DgG8v2md.js} +2 -2
- package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
- package/dist/assets/{useAppFontEffects-BDFUND4v.js → useAppFontEffects-BhYuP4L2.js} +43 -44
- package/dist/assets/useDesktopWindowControlsLayout-DPNMRkrI.js +1 -0
- package/dist/assets/{useEffectiveDirectory-e9HjIqGz.js → useEffectiveDirectory-Ji8AKgzY.js} +1 -1
- package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
- package/dist/assets/useMobileNavigationStore-BYdpuwL7.js +413 -0
- package/dist/assets/useSessionAutoCleanup-CADZCvZL.js +2 -0
- package/dist/assets/{useWorkerHighlightedLines-DTGE0CDs.js → useWorkerHighlightedLines-CuG88Lsj.js} +1 -1
- package/dist/assets/{utils-BHTVlVov.js → utils-2ZK8yboQ.js} +1 -1
- package/dist/assets/{vendor-reactuses-core-DDoDDyM9.js → vendor-reactuses-core-BnW6GxJt.js} +1 -1
- package/dist/assets/{zh-CN-C96qP1ip.js → zh-CN-C6OFKjeT.js} +2 -2
- package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-BVP99aMr.js} +2 -2
- package/dist/index.html +8 -8
- package/dist/mini-chat.html +8 -8
- package/dist/mobile.html +8 -8
- package/package.json +1 -1
- package/server/index.js +1 -0
- package/server/lib/assistants/DOCUMENTATION.md +120 -22
- package/server/lib/assistants/assign.js +117 -12
- package/server/lib/assistants/assign.test.js +79 -0
- package/server/lib/assistants/bubbles.js +1 -1
- package/server/lib/assistants/contact-store.js +520 -11
- package/server/lib/assistants/contact-store.test.js +362 -12
- package/server/lib/assistants/contact-tools.js +305 -54
- package/server/lib/assistants/contact-tools.test.js +239 -7
- package/server/lib/assistants/contracts.js +31 -0
- package/server/lib/assistants/harness.js +295 -132
- package/server/lib/assistants/harness.test.js +662 -10
- package/server/lib/assistants/pi-tools.js +2 -0
- package/server/lib/assistants/pi-tools.test.js +8 -0
- package/server/lib/assistants/routes.js +3 -0
- package/server/lib/assistants/service.js +861 -79
- package/server/lib/assistants/service.test.js +1234 -8
- package/server/lib/llm/DOCUMENTATION.md +10 -3
- package/server/lib/llm/completions.js +4 -0
- package/server/lib/llm/completions.test.js +3 -0
- package/server/lib/llm/generate.js +57 -11
- package/server/lib/llm/generate.test.js +92 -0
- package/server/lib/opencode/feature-routes-runtime.js +5 -0
- package/dist/assets/AssistantView-CKmIS1cS.js +0 -2
- package/dist/assets/Icon-CkzzhaLr.js +0 -1
- package/dist/assets/MarkdownRenderer-CLQ4Z8Z0.js +0 -2
- package/dist/assets/MobileDetailNavigation-BGaAyuBH.js +0 -1
- package/dist/assets/MobileShareBridge-BAZoyeIP.js +0 -4
- package/dist/assets/MobileSurface-BVb3xNWt.js +0 -2
- package/dist/assets/SettingsView-CkQ61pbr.js +0 -25
- package/dist/assets/insertionBoundaries-BMhzPFft.js +0 -4
- package/dist/assets/main-BjeOhdcG.js +0 -169
- package/dist/assets/miniChat-BYrjYEXJ.js +0 -2
- package/dist/assets/mobile-BVMNnPK2.js +0 -2
- package/dist/assets/platform-DzsGqlGW.js +0 -2
- package/dist/assets/renderElectronMiniChatApp-COwia8Cs.js +0 -2
- package/dist/assets/renderMobileApp-CXwwpY1t.js +0 -28
- package/dist/assets/runtimeEndpointReset-BkAQXmXJ.js +0 -147
- package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
- package/dist/assets/sessionLookup-Imc995rW.js +0 -1
- package/dist/assets/src-AA-5nXsF.css +0 -2
- package/dist/assets/useDesktopWindowControlsLayout-C5hZD1co.js +0 -1
- package/dist/assets/useMobileNavigationStore-CYbDxRTM.js +0 -413
- package/dist/assets/useSessionAutoCleanup-BGy9_AhN.js +0 -2
|
@@ -56,6 +56,17 @@ export const CONTACT_SCHEMA_SQL = `
|
|
|
56
56
|
assistant_id TEXT PRIMARY KEY,
|
|
57
57
|
generation INTEGER NOT NULL DEFAULT 0
|
|
58
58
|
);
|
|
59
|
+
-- Shared per-assistant contact read watermark (multi-client). Keyset order is
|
|
60
|
+
-- (ordinal ASC, message_id ASC). generation must match transcript generation.
|
|
61
|
+
-- Missing row ≡ nothing read (ordinal 0, empty message_id). Schema migrate
|
|
62
|
+
-- seeds existing assistants to the current tip so legacy history is read.
|
|
63
|
+
CREATE TABLE IF NOT EXISTS assistant_contact_read_state (
|
|
64
|
+
assistant_id TEXT PRIMARY KEY,
|
|
65
|
+
last_read_ordinal INTEGER NOT NULL DEFAULT 0,
|
|
66
|
+
last_read_message_id TEXT NOT NULL DEFAULT '',
|
|
67
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
68
|
+
updated_at INTEGER NOT NULL
|
|
69
|
+
);
|
|
59
70
|
`;
|
|
60
71
|
|
|
61
72
|
export const CONTACT_SETTLE_TEXT = Object.freeze({
|
|
@@ -87,6 +98,8 @@ export function ensureContactSchema(db) {
|
|
|
87
98
|
const columns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant_contact_message')").all().map((column) => column.name));
|
|
88
99
|
if (!columns.has('from_assistant_id')) db.exec('ALTER TABLE assistant_contact_message ADD COLUMN from_assistant_id TEXT');
|
|
89
100
|
if (!columns.has('from_assistant_name')) db.exec('ALTER TABLE assistant_contact_message ADD COLUMN from_assistant_name TEXT');
|
|
101
|
+
const watchColumns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant_contact_watch')").all().map((column) => column.name));
|
|
102
|
+
if (!watchColumns.has('resume_allowed')) db.exec('ALTER TABLE assistant_contact_watch ADD COLUMN resume_allowed INTEGER NOT NULL DEFAULT 1');
|
|
90
103
|
// Attachment table ownership lives in contact-attachments.ensureContactAttachmentSchema.
|
|
91
104
|
}
|
|
92
105
|
|
|
@@ -114,6 +127,474 @@ export function bumpContactGeneration(db, assistantID) {
|
|
|
114
127
|
return getContactGeneration(db, assistantID);
|
|
115
128
|
}
|
|
116
129
|
|
|
130
|
+
/** Empty / start-of-transcript read cursor (nothing read). */
|
|
131
|
+
export const EMPTY_CONTACT_READ_CURSOR = Object.freeze({
|
|
132
|
+
ordinal: 0,
|
|
133
|
+
messageID: '',
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Compare contact keyset cursors (ordinal ASC, message_id ASC).
|
|
138
|
+
* Returns negative when left < right, 0 when equal, positive when left > right.
|
|
139
|
+
*/
|
|
140
|
+
export function compareContactReadCursor(left, right) {
|
|
141
|
+
const leftOrdinal = Number(left?.ordinal);
|
|
142
|
+
const rightOrdinal = Number(right?.ordinal);
|
|
143
|
+
const a = Number.isFinite(leftOrdinal) ? leftOrdinal : 0;
|
|
144
|
+
const b = Number.isFinite(rightOrdinal) ? rightOrdinal : 0;
|
|
145
|
+
if (a !== b) return a - b;
|
|
146
|
+
const leftID = typeof left?.messageID === 'string' ? left.messageID : '';
|
|
147
|
+
const rightID = typeof right?.messageID === 'string' ? right.messageID : '';
|
|
148
|
+
if (leftID < rightID) return -1;
|
|
149
|
+
if (leftID > rightID) return 1;
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const normalizeReadCursor = (value) => {
|
|
154
|
+
const ordinalRaw = Number(value?.ordinal);
|
|
155
|
+
const ordinal = Number.isSafeInteger(ordinalRaw) && ordinalRaw >= 0 ? ordinalRaw : 0;
|
|
156
|
+
const messageID = typeof value?.messageID === 'string' ? value.messageID : '';
|
|
157
|
+
return { ordinal, messageID };
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Persisted shared read watermark for one assistant.
|
|
162
|
+
* Missing row → generation from transcript gen table, cursor at empty (nothing read).
|
|
163
|
+
*/
|
|
164
|
+
export function getContactReadWatermark(db, assistantID) {
|
|
165
|
+
if (typeof assistantID !== 'string' || !assistantID) {
|
|
166
|
+
return {
|
|
167
|
+
generation: 0,
|
|
168
|
+
ordinal: EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
169
|
+
messageID: EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const generation = getContactGeneration(db, assistantID);
|
|
173
|
+
const row = db.prepare(
|
|
174
|
+
'SELECT last_read_ordinal, last_read_message_id, generation FROM assistant_contact_read_state WHERE assistant_id=?',
|
|
175
|
+
).get(assistantID);
|
|
176
|
+
if (!row) {
|
|
177
|
+
return {
|
|
178
|
+
generation,
|
|
179
|
+
ordinal: EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
180
|
+
messageID: EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const cursor = normalizeReadCursor({
|
|
184
|
+
ordinal: row.last_read_ordinal,
|
|
185
|
+
messageID: row.last_read_message_id,
|
|
186
|
+
});
|
|
187
|
+
// Stale generation on the row (e.g. wipe without reset helper) → treat as unread baseline.
|
|
188
|
+
const rowGeneration = Number(row.generation);
|
|
189
|
+
if (!Number.isSafeInteger(rowGeneration) || rowGeneration !== generation) {
|
|
190
|
+
return {
|
|
191
|
+
generation,
|
|
192
|
+
ordinal: EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
193
|
+
messageID: EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
generation,
|
|
198
|
+
ordinal: cursor.ordinal,
|
|
199
|
+
messageID: cursor.messageID,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Highest transcript keyset tip for one assistant (any role).
|
|
205
|
+
* Empty transcript → empty cursor. Clients report up to this tip after viewing.
|
|
206
|
+
*/
|
|
207
|
+
export function getContactReadTip(db, assistantID) {
|
|
208
|
+
if (typeof assistantID !== 'string' || !assistantID) {
|
|
209
|
+
return {
|
|
210
|
+
generation: 0,
|
|
211
|
+
ordinal: EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
212
|
+
messageID: EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const generation = getContactGeneration(db, assistantID);
|
|
216
|
+
const row = db.prepare(
|
|
217
|
+
`SELECT ordinal, message_id AS messageID
|
|
218
|
+
FROM assistant_contact_message
|
|
219
|
+
WHERE assistant_id=?
|
|
220
|
+
ORDER BY ordinal DESC, message_id DESC
|
|
221
|
+
LIMIT 1`,
|
|
222
|
+
).get(assistantID);
|
|
223
|
+
if (!row) {
|
|
224
|
+
return {
|
|
225
|
+
generation,
|
|
226
|
+
ordinal: EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
227
|
+
messageID: EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
generation,
|
|
232
|
+
ordinal: Number(row.ordinal) || 0,
|
|
233
|
+
messageID: typeof row.messageID === 'string' ? row.messageID : '',
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* True when one part is user-visible — matches UI settle-per-part filter
|
|
239
|
+
* (AssistantConversationSurface / getLoadedAssistantReadPosition):
|
|
240
|
+
* file/card, or non-empty text that is not an internal `oc.settle.*` marker.
|
|
241
|
+
* Empty/whitespace text and settle markers alone are not visible.
|
|
242
|
+
*/
|
|
243
|
+
export function isContactVisiblePart(part) {
|
|
244
|
+
if (!part || typeof part !== 'object') return false;
|
|
245
|
+
if (part.type === 'file' || part.type === 'card') return true;
|
|
246
|
+
if (part.type !== 'text') return false;
|
|
247
|
+
const trimmed = typeof part.text === 'string' ? part.text.trim() : '';
|
|
248
|
+
return Boolean(trimmed) && !trimmed.startsWith('oc.settle.');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* True when a contact row should contribute to unreadCount.
|
|
253
|
+
* Counts complete/error assistant replies and user-facing peer DMs that have
|
|
254
|
+
* ≥1 visible part (per-part settle filter). User messages, blank rows,
|
|
255
|
+
* settle-only markers, and non-visible roles are excluded.
|
|
256
|
+
* Token-level SSE deltas never create rows. Each published spoken bubble
|
|
257
|
+
* or card is one count unit, including rows persisted before turn end.
|
|
258
|
+
*/
|
|
259
|
+
export function isContactUnreadCountableMessage(message) {
|
|
260
|
+
if (!message || typeof message !== 'object') return false;
|
|
261
|
+
const role = message.role;
|
|
262
|
+
if (role !== 'assistant' && role !== 'peer') return false;
|
|
263
|
+
const status = typeof message.status === 'string' ? message.status : 'complete';
|
|
264
|
+
if (status !== 'complete' && status !== 'error') return false;
|
|
265
|
+
const parts = Array.isArray(message.parts) ? message.parts : [];
|
|
266
|
+
if (parts.length > 0) return parts.some(isContactVisiblePart);
|
|
267
|
+
// Parts-less fixtures: treat top-level text like a single text part.
|
|
268
|
+
const trimmed = typeof message.text === 'string' ? message.text.trim() : '';
|
|
269
|
+
return Boolean(trimmed) && !trimmed.startsWith('oc.settle.');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** SQL predicate: message is after the read watermark keyset (exclusive). */
|
|
273
|
+
const contactUnreadAfterWatermarkSql = (ordinalAlias, messageIdAlias) => (
|
|
274
|
+
`(${ordinalAlias} > ?
|
|
275
|
+
OR (${ordinalAlias} = ? AND ${messageIdAlias} > ?))`
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* SQL: message has ≥1 user-visible part (file/card/non-empty non-settle text).
|
|
280
|
+
* Per-part — settle prefix on one part does not hide sibling body/file/card.
|
|
281
|
+
* Uses json_extract + GLOB (case-sensitive) to match JS startsWith('oc.settle.').
|
|
282
|
+
*/
|
|
283
|
+
const contactUnreadHasVisiblePartSql = (messageIdExpr) => (
|
|
284
|
+
`EXISTS (
|
|
285
|
+
SELECT 1 FROM assistant_contact_part p
|
|
286
|
+
WHERE p.message_id = ${messageIdExpr}
|
|
287
|
+
AND (
|
|
288
|
+
json_extract(p.part_json, '$.type') IN ('file', 'card')
|
|
289
|
+
OR (
|
|
290
|
+
json_extract(p.part_json, '$.type') = 'text'
|
|
291
|
+
AND length(trim(coalesce(json_extract(p.part_json, '$.text'), ''))) > 0
|
|
292
|
+
AND trim(coalesce(json_extract(p.part_json, '$.text'), '')) NOT GLOB 'oc.settle.*'
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
)`
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Count unread user-facing assistant/peer replies after the watermark.
|
|
300
|
+
* Watermark keyset is applied in SQL; only post-watermark candidates hit the
|
|
301
|
+
* visible-part EXISTS. Fully-read history (no candidates) does not scan parts.
|
|
302
|
+
*/
|
|
303
|
+
export function countContactUnread(db, assistantID, watermark = null) {
|
|
304
|
+
if (typeof assistantID !== 'string' || !assistantID) return 0;
|
|
305
|
+
const mark = watermark && typeof watermark === 'object'
|
|
306
|
+
? normalizeReadCursor(watermark)
|
|
307
|
+
: normalizeReadCursor(getContactReadWatermark(db, assistantID));
|
|
308
|
+
const afterSql = contactUnreadAfterWatermarkSql('m.ordinal', 'm.message_id');
|
|
309
|
+
const row = db.prepare(
|
|
310
|
+
`SELECT COUNT(*) AS count
|
|
311
|
+
FROM assistant_contact_message m
|
|
312
|
+
WHERE m.assistant_id = ?
|
|
313
|
+
AND m.role IN ('assistant', 'peer')
|
|
314
|
+
AND m.status IN ('complete', 'error')
|
|
315
|
+
AND ${afterSql}
|
|
316
|
+
AND ${contactUnreadHasVisiblePartSql('m.message_id')}`,
|
|
317
|
+
).get(assistantID, mark.ordinal, mark.ordinal, mark.messageID);
|
|
318
|
+
return Number(row?.count) || 0;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Batch unread counts + watermarks + tips for snapshot (catalog ≤100).
|
|
323
|
+
* Returns Map(assistantID → { unreadCount, readWatermark, readTip }).
|
|
324
|
+
* Per-assistant watermark keyset is pushed into SQL via a VALUES marks CTE;
|
|
325
|
+
* COUNT aggregates only post-watermark visible candidates (no full-history
|
|
326
|
+
* message/.parts scan when the catalog is fully read).
|
|
327
|
+
*/
|
|
328
|
+
export function getContactUnreadSnapshots(db, assistantIDs) {
|
|
329
|
+
const result = new Map();
|
|
330
|
+
const ids = [...new Set(
|
|
331
|
+
(Array.isArray(assistantIDs) ? assistantIDs : [])
|
|
332
|
+
.filter((id) => typeof id === 'string' && id),
|
|
333
|
+
)];
|
|
334
|
+
for (const id of ids) {
|
|
335
|
+
result.set(id, {
|
|
336
|
+
unreadCount: 0,
|
|
337
|
+
readWatermark: {
|
|
338
|
+
generation: 0,
|
|
339
|
+
ordinal: EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
340
|
+
messageID: EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
341
|
+
},
|
|
342
|
+
readTip: {
|
|
343
|
+
generation: 0,
|
|
344
|
+
ordinal: EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
345
|
+
messageID: EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
if (ids.length === 0) return result;
|
|
350
|
+
|
|
351
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
352
|
+
const generationRows = db.prepare(
|
|
353
|
+
`SELECT assistant_id, generation FROM assistant_contact_generation
|
|
354
|
+
WHERE assistant_id IN (${placeholders})`,
|
|
355
|
+
).all(...ids);
|
|
356
|
+
const generationByID = new Map(ids.map((id) => [id, 0]));
|
|
357
|
+
for (const row of generationRows) {
|
|
358
|
+
const generation = Number(row.generation);
|
|
359
|
+
generationByID.set(
|
|
360
|
+
row.assistant_id,
|
|
361
|
+
Number.isSafeInteger(generation) && generation >= 0 ? generation : 0,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const stateRows = db.prepare(
|
|
366
|
+
`SELECT assistant_id, last_read_ordinal, last_read_message_id, generation
|
|
367
|
+
FROM assistant_contact_read_state
|
|
368
|
+
WHERE assistant_id IN (${placeholders})`,
|
|
369
|
+
).all(...ids);
|
|
370
|
+
const watermarkByID = new Map();
|
|
371
|
+
for (const id of ids) {
|
|
372
|
+
watermarkByID.set(id, {
|
|
373
|
+
generation: generationByID.get(id) ?? 0,
|
|
374
|
+
ordinal: EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
375
|
+
messageID: EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
for (const row of stateRows) {
|
|
379
|
+
const liveGeneration = generationByID.get(row.assistant_id) ?? 0;
|
|
380
|
+
const rowGeneration = Number(row.generation);
|
|
381
|
+
if (!Number.isSafeInteger(rowGeneration) || rowGeneration !== liveGeneration) {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const cursor = normalizeReadCursor({
|
|
385
|
+
ordinal: row.last_read_ordinal,
|
|
386
|
+
messageID: row.last_read_message_id,
|
|
387
|
+
});
|
|
388
|
+
watermarkByID.set(row.assistant_id, {
|
|
389
|
+
generation: liveGeneration,
|
|
390
|
+
ordinal: cursor.ordinal,
|
|
391
|
+
messageID: cursor.messageID,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Tip: newest (ordinal, message_id) per assistant — one indexed probe each (≤100).
|
|
396
|
+
const tipProbe = db.prepare(
|
|
397
|
+
`SELECT ordinal, message_id AS messageID
|
|
398
|
+
FROM assistant_contact_message
|
|
399
|
+
WHERE assistant_id=?
|
|
400
|
+
ORDER BY ordinal DESC, message_id DESC
|
|
401
|
+
LIMIT 1`,
|
|
402
|
+
);
|
|
403
|
+
const tipByID = new Map();
|
|
404
|
+
for (const id of ids) {
|
|
405
|
+
const tipRow = tipProbe.get(id);
|
|
406
|
+
tipByID.set(id, {
|
|
407
|
+
generation: generationByID.get(id) ?? 0,
|
|
408
|
+
ordinal: tipRow ? (Number(tipRow.ordinal) || 0) : EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
409
|
+
messageID: tipRow && typeof tipRow.messageID === 'string' ? tipRow.messageID : EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Unread: one aggregated COUNT with per-id watermark keyset in SQL.
|
|
414
|
+
// Fully-read assistants contribute zero candidate rows → no parts EXISTS work.
|
|
415
|
+
const markValuesSql = ids.map(() => '(?,?,?)').join(',');
|
|
416
|
+
const markParams = [];
|
|
417
|
+
for (const id of ids) {
|
|
418
|
+
const mark = watermarkByID.get(id) || EMPTY_CONTACT_READ_CURSOR;
|
|
419
|
+
markParams.push(id, mark.ordinal, mark.messageID);
|
|
420
|
+
}
|
|
421
|
+
const unreadByID = new Map(ids.map((id) => [id, 0]));
|
|
422
|
+
const countRows = db.prepare(
|
|
423
|
+
`WITH marks(assistant_id, mark_ordinal, mark_message_id) AS (
|
|
424
|
+
VALUES ${markValuesSql}
|
|
425
|
+
)
|
|
426
|
+
SELECT m.assistant_id AS assistant_id, COUNT(*) AS count
|
|
427
|
+
FROM assistant_contact_message m
|
|
428
|
+
INNER JOIN marks ON marks.assistant_id = m.assistant_id
|
|
429
|
+
WHERE m.role IN ('assistant', 'peer')
|
|
430
|
+
AND m.status IN ('complete', 'error')
|
|
431
|
+
AND (
|
|
432
|
+
m.ordinal > marks.mark_ordinal
|
|
433
|
+
OR (m.ordinal = marks.mark_ordinal AND m.message_id > marks.mark_message_id)
|
|
434
|
+
)
|
|
435
|
+
AND ${contactUnreadHasVisiblePartSql('m.message_id')}
|
|
436
|
+
GROUP BY m.assistant_id`,
|
|
437
|
+
).all(...markParams);
|
|
438
|
+
for (const row of countRows) {
|
|
439
|
+
unreadByID.set(row.assistant_id, Number(row.count) || 0);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
for (const id of ids) {
|
|
443
|
+
result.set(id, {
|
|
444
|
+
unreadCount: unreadByID.get(id) || 0,
|
|
445
|
+
readWatermark: watermarkByID.get(id),
|
|
446
|
+
readTip: tipByID.get(id),
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
return result;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Monotonic advance of the shared read watermark.
|
|
454
|
+
* - generation must match live transcript generation (else contact_generation_conflict).
|
|
455
|
+
* - Only moves forward in (ordinal, messageID) keyset order; late/stale lower
|
|
456
|
+
* cursors are no-ops (changed:false) so concurrent clients cannot erase newer unread.
|
|
457
|
+
* - Incoming cursor is clamped to the current transcript tip (cannot invent future ids).
|
|
458
|
+
* - Idempotent when equal: changed false, no write.
|
|
459
|
+
*/
|
|
460
|
+
export function advanceContactReadWatermark(db, assistantID, input = {}, { updatedAt = Date.now() } = {}) {
|
|
461
|
+
if (typeof assistantID !== 'string' || !assistantID) {
|
|
462
|
+
throw contactStoreError('validation_error', 'assistantID required');
|
|
463
|
+
}
|
|
464
|
+
const liveGeneration = getContactGeneration(db, assistantID);
|
|
465
|
+
const requestedGeneration = Number(input.generation);
|
|
466
|
+
if (!Number.isSafeInteger(requestedGeneration) || requestedGeneration < 0) {
|
|
467
|
+
throw contactStoreError('validation_error', 'generation must be a non-negative integer');
|
|
468
|
+
}
|
|
469
|
+
if (requestedGeneration !== liveGeneration) {
|
|
470
|
+
throw contactStoreError(
|
|
471
|
+
'contact_generation_conflict',
|
|
472
|
+
'Contact transcript generation changed; refetch snapshot before marking read.',
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
const requested = normalizeReadCursor({
|
|
476
|
+
ordinal: input.ordinal,
|
|
477
|
+
messageID: input.messageID,
|
|
478
|
+
});
|
|
479
|
+
if (requested.ordinal < 0 || (requested.ordinal > 0 && !requested.messageID)) {
|
|
480
|
+
// ordinal 0 + empty messageID is the valid "nothing" cursor; otherwise require messageID.
|
|
481
|
+
if (requested.ordinal !== 0 || requested.messageID !== '') {
|
|
482
|
+
throw contactStoreError('validation_error', 'ordinal/messageID cursor invalid');
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (requested.ordinal > 0 && typeof input.messageID !== 'string') {
|
|
486
|
+
throw contactStoreError('validation_error', 'messageID must be a string');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const tip = getContactReadTip(db, assistantID);
|
|
490
|
+
const current = getContactReadWatermark(db, assistantID);
|
|
491
|
+
|
|
492
|
+
// Clamp to tip so deleted/future ids cannot jump past the authoritative head.
|
|
493
|
+
let next = requested;
|
|
494
|
+
if (compareContactReadCursor(next, tip) > 0) {
|
|
495
|
+
next = { ordinal: tip.ordinal, messageID: tip.messageID };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Monotonic: never move backward.
|
|
499
|
+
if (compareContactReadCursor(next, current) <= 0) {
|
|
500
|
+
return {
|
|
501
|
+
changed: false,
|
|
502
|
+
readWatermark: current,
|
|
503
|
+
readTip: tip,
|
|
504
|
+
unreadCount: countContactUnread(db, assistantID, current),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
db.prepare(
|
|
509
|
+
`INSERT INTO assistant_contact_read_state(
|
|
510
|
+
assistant_id, last_read_ordinal, last_read_message_id, generation, updated_at
|
|
511
|
+
) VALUES (?,?,?,?,?)
|
|
512
|
+
ON CONFLICT(assistant_id) DO UPDATE SET
|
|
513
|
+
last_read_ordinal=excluded.last_read_ordinal,
|
|
514
|
+
last_read_message_id=excluded.last_read_message_id,
|
|
515
|
+
generation=excluded.generation,
|
|
516
|
+
updated_at=excluded.updated_at`,
|
|
517
|
+
).run(assistantID, next.ordinal, next.messageID, liveGeneration, updatedAt);
|
|
518
|
+
|
|
519
|
+
const readWatermark = {
|
|
520
|
+
generation: liveGeneration,
|
|
521
|
+
ordinal: next.ordinal,
|
|
522
|
+
messageID: next.messageID,
|
|
523
|
+
};
|
|
524
|
+
return {
|
|
525
|
+
changed: true,
|
|
526
|
+
readWatermark,
|
|
527
|
+
readTip: tip,
|
|
528
|
+
unreadCount: countContactUnread(db, assistantID, readWatermark),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* After a generation bump (wipe/reset): bind read state to the new generation
|
|
534
|
+
* with an empty watermark so remaining/new rows can become unread. Does not
|
|
535
|
+
* seed to tip — post-wipe confirm bubbles should surface as unread until viewed.
|
|
536
|
+
*/
|
|
537
|
+
export function resetContactReadWatermarkForGeneration(db, assistantID, generation, { updatedAt = Date.now() } = {}) {
|
|
538
|
+
if (typeof assistantID !== 'string' || !assistantID) {
|
|
539
|
+
throw contactStoreError('validation_error', 'assistantID required');
|
|
540
|
+
}
|
|
541
|
+
const nextGeneration = Number(generation);
|
|
542
|
+
if (!Number.isSafeInteger(nextGeneration) || nextGeneration < 0) {
|
|
543
|
+
throw contactStoreError('validation_error', 'generation must be a non-negative integer');
|
|
544
|
+
}
|
|
545
|
+
db.prepare(
|
|
546
|
+
`INSERT INTO assistant_contact_read_state(
|
|
547
|
+
assistant_id, last_read_ordinal, last_read_message_id, generation, updated_at
|
|
548
|
+
) VALUES (?,?,?,?,?)
|
|
549
|
+
ON CONFLICT(assistant_id) DO UPDATE SET
|
|
550
|
+
last_read_ordinal=excluded.last_read_ordinal,
|
|
551
|
+
last_read_message_id=excluded.last_read_message_id,
|
|
552
|
+
generation=excluded.generation,
|
|
553
|
+
updated_at=excluded.updated_at`,
|
|
554
|
+
).run(
|
|
555
|
+
assistantID,
|
|
556
|
+
EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
557
|
+
EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
558
|
+
nextGeneration,
|
|
559
|
+
updatedAt,
|
|
560
|
+
);
|
|
561
|
+
return getContactReadWatermark(db, assistantID);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Schema migrate helper: seed every assistant that has transcript rows so
|
|
566
|
+
* legacy history is treated as already read (unreadCount 0). Assistants with
|
|
567
|
+
* no messages keep the implicit empty watermark.
|
|
568
|
+
*/
|
|
569
|
+
export function migrateContactReadStateDefaultRead(db, { updatedAt = Date.now() } = {}) {
|
|
570
|
+
const assistants = db.prepare(
|
|
571
|
+
`SELECT DISTINCT assistant_id AS assistantID FROM assistant_contact_message`,
|
|
572
|
+
).all();
|
|
573
|
+
let seeded = 0;
|
|
574
|
+
for (const row of assistants) {
|
|
575
|
+
const assistantID = row.assistantID;
|
|
576
|
+
if (typeof assistantID !== 'string' || !assistantID) continue;
|
|
577
|
+
const existing = db.prepare(
|
|
578
|
+
'SELECT assistant_id FROM assistant_contact_read_state WHERE assistant_id=?',
|
|
579
|
+
).get(assistantID);
|
|
580
|
+
if (existing) continue;
|
|
581
|
+
const tip = getContactReadTip(db, assistantID);
|
|
582
|
+
db.prepare(
|
|
583
|
+
`INSERT INTO assistant_contact_read_state(
|
|
584
|
+
assistant_id, last_read_ordinal, last_read_message_id, generation, updated_at
|
|
585
|
+
) VALUES (?,?,?,?,?)`,
|
|
586
|
+
).run(assistantID, tip.ordinal, tip.messageID, tip.generation, updatedAt);
|
|
587
|
+
seeded += 1;
|
|
588
|
+
}
|
|
589
|
+
return { seeded };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Drop read-state row (assistant delete / full cleanup). */
|
|
593
|
+
export function deleteContactReadState(db, assistantID) {
|
|
594
|
+
if (typeof assistantID !== 'string' || !assistantID) return;
|
|
595
|
+
db.prepare('DELETE FROM assistant_contact_read_state WHERE assistant_id=?').run(assistantID);
|
|
596
|
+
}
|
|
597
|
+
|
|
117
598
|
/**
|
|
118
599
|
* Decode an opaque contact page cursor.
|
|
119
600
|
* v1: { v:1, assistantID, generation, ordinal, messageID }
|
|
@@ -288,6 +769,7 @@ export function buildContactMessagePreview(message) {
|
|
|
288
769
|
return {
|
|
289
770
|
messageID,
|
|
290
771
|
ordinal,
|
|
772
|
+
createdAt: message.createdAt,
|
|
291
773
|
// Peer DMs are user-visible inbox rows; list language is user|assistant only.
|
|
292
774
|
role: sourceRole === 'assistant' ? 'assistant' : 'user',
|
|
293
775
|
text: text || '',
|
|
@@ -658,6 +1140,32 @@ export function deleteContactMessages(db, assistantID, { upToOrdinal = null } =
|
|
|
658
1140
|
}
|
|
659
1141
|
db.prepare('DELETE FROM assistant_contact_watch WHERE assistant_id=?').run(assistantID);
|
|
660
1142
|
db.prepare('DELETE FROM assistant_contact_context_boundary WHERE assistant_id=?').run(assistantID);
|
|
1143
|
+
// Unbounded wipe leaves generation/read_state for the service layer to rebind
|
|
1144
|
+
// (resetContact bumps generation + resetContactReadWatermarkForGeneration).
|
|
1145
|
+
// Assistant delete clears read_state via deleteContactReadState.
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Delete only this turn's assistant-role output rows (spoken bubbles / cards / errors).
|
|
1150
|
+
* Keeps the clearing user, queued users, peers, older turns, watches, generation,
|
|
1151
|
+
* context boundary, and read watermark. Does not call deleteContactMessages.
|
|
1152
|
+
*/
|
|
1153
|
+
export function deleteContactTurnAssistantOutputs(db, assistantID, turnID) {
|
|
1154
|
+
if (typeof assistantID !== 'string' || !assistantID.trim()) return 0;
|
|
1155
|
+
if (typeof turnID !== 'string' || !turnID.trim()) return 0;
|
|
1156
|
+
const ids = db.prepare(
|
|
1157
|
+
`SELECT message_id FROM assistant_contact_message
|
|
1158
|
+
WHERE assistant_id=? AND turn_id=? AND role='assistant'`,
|
|
1159
|
+
).all(assistantID, turnID).map((row) => row.message_id);
|
|
1160
|
+
for (const messageID of ids) {
|
|
1161
|
+
db.prepare('DELETE FROM assistant_contact_part WHERE message_id=?').run(messageID);
|
|
1162
|
+
}
|
|
1163
|
+
if (ids.length === 0) return 0;
|
|
1164
|
+
const result = db.prepare(
|
|
1165
|
+
`DELETE FROM assistant_contact_message
|
|
1166
|
+
WHERE assistant_id=? AND turn_id=? AND role='assistant'`,
|
|
1167
|
+
).run(assistantID, turnID);
|
|
1168
|
+
return Number(result?.changes) || ids.length;
|
|
661
1169
|
}
|
|
662
1170
|
|
|
663
1171
|
/** Durable LLM context watermark for one assistant (0 = no boundary). */
|
|
@@ -701,19 +1209,20 @@ export function clearContactMemory(db, assistantID, { updatedAt = Date.now(), up
|
|
|
701
1209
|
return { assistantID, afterOrdinal, memoryCleared: true };
|
|
702
1210
|
}
|
|
703
1211
|
|
|
704
|
-
export function upsertContactWatch(db, { assistantID, sessionID, directory, status, updatedAt }) {
|
|
1212
|
+
export function upsertContactWatch(db, { assistantID, sessionID, directory, status, updatedAt, resumeAllowed = true }) {
|
|
705
1213
|
db.prepare(
|
|
706
|
-
'INSERT INTO assistant_contact_watch(assistant_id,session_id,directory,status,updated_at) VALUES (
|
|
707
|
-
).run(assistantID, sessionID, directory || null, status, updatedAt);
|
|
1214
|
+
'INSERT INTO assistant_contact_watch(assistant_id,session_id,directory,status,updated_at,resume_allowed) VALUES (?,?,?,?,?,?) ON CONFLICT(assistant_id,session_id) DO UPDATE SET directory=excluded.directory,status=excluded.status,updated_at=excluded.updated_at,resume_allowed=excluded.resume_allowed',
|
|
1215
|
+
).run(assistantID, sessionID, directory || null, status, updatedAt, resumeAllowed ? 1 : 0);
|
|
708
1216
|
}
|
|
709
1217
|
|
|
710
1218
|
export function listWatchesBySession(db, sessionID) {
|
|
711
|
-
return db.prepare('SELECT assistant_id, session_id, directory, status FROM assistant_contact_watch WHERE session_id=?').all(sessionID)
|
|
1219
|
+
return db.prepare('SELECT assistant_id, session_id, directory, status, resume_allowed FROM assistant_contact_watch WHERE session_id=?').all(sessionID)
|
|
712
1220
|
.map((row) => ({
|
|
713
1221
|
assistantID: row.assistant_id,
|
|
714
1222
|
sessionID: row.session_id,
|
|
715
1223
|
directory: row.directory,
|
|
716
1224
|
status: row.status,
|
|
1225
|
+
resumeAllowed: row.resume_allowed !== 0,
|
|
717
1226
|
}));
|
|
718
1227
|
}
|
|
719
1228
|
|
|
@@ -760,9 +1269,9 @@ export function updateSessionCardStatus(db, { assistantID, sessionID, status })
|
|
|
760
1269
|
* clearContactMemory advances assistant_contact_context_boundary.after_ordinal;
|
|
761
1270
|
* only messages with ordinal > that watermark enter this window.
|
|
762
1271
|
*/
|
|
763
|
-
export const CONTACT_LLM_MAX_TURNS =
|
|
764
|
-
export const CONTACT_LLM_MAX_CHARS =
|
|
765
|
-
export const CONTACT_LLM_FETCH_LIMIT =
|
|
1272
|
+
export const CONTACT_LLM_MAX_TURNS = 32;
|
|
1273
|
+
export const CONTACT_LLM_MAX_CHARS = 48_000;
|
|
1274
|
+
export const CONTACT_LLM_FETCH_LIMIT = 100;
|
|
766
1275
|
export const CONTACT_LLM_FILE_CHAR_WEIGHT = 80;
|
|
767
1276
|
|
|
768
1277
|
const estimateLlmChars = (item) => {
|
|
@@ -773,7 +1282,8 @@ const estimateLlmChars = (item) => {
|
|
|
773
1282
|
|
|
774
1283
|
const toLlmHistoryItem = (message) => {
|
|
775
1284
|
const files = message.parts.filter((part) => part.type === 'file');
|
|
776
|
-
const
|
|
1285
|
+
const cards = message.parts.filter((part) => part.type === 'card').map((part) => `[OpenChamber card context: ${JSON.stringify(part)}]`);
|
|
1286
|
+
const content = [message.text.trim() || (files.length > 0 ? '[attachment]' : ''), ...cards].filter(Boolean).join('\n');
|
|
777
1287
|
return files.length > 0 ? { role: message.role, content, parts: files } : { role: message.role, content };
|
|
778
1288
|
};
|
|
779
1289
|
|
|
@@ -784,9 +1294,8 @@ const isLlmEligibleContactMessage = (message) => {
|
|
|
784
1294
|
if (message.role === 'peer' || message.fromAssistantID) return false;
|
|
785
1295
|
const hasText = Boolean(message.text?.trim());
|
|
786
1296
|
const hasFiles = Array.isArray(message.parts) && message.parts.some((part) => part.type === 'file');
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
return hasText || hasFiles;
|
|
1297
|
+
const hasCards = Array.isArray(message.parts) && message.parts.some((part) => part.type === 'card');
|
|
1298
|
+
return hasText || hasFiles || hasCards;
|
|
790
1299
|
};
|
|
791
1300
|
|
|
792
1301
|
/** Turn-aware keep of recent user+assistant text pairs for the model only. */
|