@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.
Files changed (120) hide show
  1. package/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-m1jNq4bX.js} +1 -1
  2. package/dist/assets/AssistantView-D5rEZ-Ss.js +2 -0
  3. package/dist/assets/{DiagramView-BdI4PT2H.js → DiagramView-DaB9Sb7S.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
  5. package/dist/assets/Icon-D1BcR8Tz.js +1 -0
  6. package/dist/assets/MarkdownRenderer-emLeoNjI.js +2 -0
  7. package/dist/assets/{MarkdownRendererImpl-2ewbRZE2.js → MarkdownRendererImpl-BWCAPlqn.js} +3 -3
  8. package/dist/assets/{MarkstreamRendererImpl-DXewO7S_.js → MarkstreamRendererImpl-BD_NFKUv.js} +2 -2
  9. package/dist/assets/MobileDetailNavigation-DqXVAIap.js +1 -0
  10. package/dist/assets/MobileShareBridge-Bk2fdkJk.js +4 -0
  11. package/dist/assets/MobileSurface-C4mpcOCh.js +2 -0
  12. package/dist/assets/{MultiRunWindow-C8ukIdLA.js → MultiRunWindow-rs49_K1E.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-bIWeMLVx.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
  15. package/dist/assets/SettingsView-b8LUjoad.js +25 -0
  16. package/dist/assets/{SettingsWindow-CSePZ_V8.js → SettingsWindow-D8JFlPdA.js} +1 -1
  17. package/dist/assets/{TerminalView-BmSIrZ81.js → TerminalView-DCd-Godi.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-2BYaWLo1.js → ToolOutputDialog-B50L_nUP.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-Dk4gBaOu.js → appThemeRegistry-BWtCaTMe.js} +1 -1
  20. package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
  21. package/dist/assets/{clipboard-CUasgPiF.js → clipboard-GUUNlWIv.js} +1 -1
  22. package/dist/assets/{collapsible-CACHBRYZ.js → collapsible-Bbz9KiJf.js} +1 -1
  23. package/dist/assets/{context-menu-CYbm4wBZ.js → context-menu-CSyOhRz8.js} +1 -1
  24. package/dist/assets/{desktopHosts-DILvrAok.js → desktopHosts-B052a4UK.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-t8asCr3m.js → desktopRelayRestore-OjKNXr10.js} +1 -1
  26. package/dist/assets/{diagram-u0BC9uuY.js → diagram-NdsZih8P.js} +1 -1
  27. package/dist/assets/{dialog-BwQ3rY_x.js → dialog-DhMGbF_B.js} +1 -1
  28. package/dist/assets/{es-CRjOwvYM.js → es-BQb6I8gl.js} +2 -2
  29. package/dist/assets/fileMentionTouchSelection-DKYkoypI.js +4 -0
  30. package/dist/assets/{fileQueries-Bgly-E8f.js → fileQueries-B_s_B-fH.js} +1 -1
  31. package/dist/assets/{fr-D9taEN9L.js → fr-BjGouC_l.js} +2 -2
  32. package/dist/assets/{gitApi-DtPf-h-O.js → gitApi-D4PyYgLZ.js} +3 -3
  33. package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
  34. package/dist/assets/insertionBoundaries-Bnnx03Wj.js +1 -0
  35. package/dist/assets/{ja-CYQ5aoWp.js → ja-Cw9zliYY.js} +2 -2
  36. package/dist/assets/{ko-BwASV0Q0.js → ko-Dyz0Dk9b.js} +2 -2
  37. package/dist/assets/main-BRiQTNFX.js +169 -0
  38. package/dist/assets/{main-Ci3JmtpW.js → main-CnY0LWNc.js} +2 -2
  39. package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
  40. package/dist/assets/miniChat-B0pw_mUs.js +2 -0
  41. package/dist/assets/mobile-BKHmxwXz.js +2 -0
  42. package/dist/assets/{multirun-CmaTeZON.js → multirun-BQKqVfFT.js} +1 -1
  43. package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
  44. package/dist/assets/{pl-DUI1vPil.js → pl-C7EY-dTH.js} +1 -1
  45. package/dist/assets/platform-DQ5_LNKO.js +2 -0
  46. package/dist/assets/{projectMeta-cw9nrwpr.js → projectMeta-BvG8sdtC.js} +2 -2
  47. package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-B1uHpjFW.js} +2 -2
  48. package/dist/assets/renderElectronMiniChatApp-E5a0yy7E.js +2 -0
  49. package/dist/assets/renderMobileApp-DT0L4Ah6.js +28 -0
  50. package/dist/assets/{runtimeConfig-DI8l0VyZ.js → runtimeConfig-utWzrDwa.js} +1 -1
  51. package/dist/assets/runtimeEndpointReset-DE6lwHyg.js +147 -0
  52. package/dist/assets/sessionChangeSummary-BG7l2heP.js +2 -0
  53. package/dist/assets/sessionLookup-12zVOR0J.js +1 -0
  54. package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-dYW6SH62.js} +1 -1
  55. package/dist/assets/src-CFUi4riW.css +2 -0
  56. package/dist/assets/{store-CSnH_QXM.js → store-ClnJdJSj.js} +3 -3
  57. package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
  58. package/dist/assets/{textarea-BfNtUdJE.js → textarea-B9CmN13Z.js} +1 -1
  59. package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
  60. package/dist/assets/{uk-C1FYZlmA.js → uk-DgG8v2md.js} +2 -2
  61. package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
  62. package/dist/assets/{useAppFontEffects-BDFUND4v.js → useAppFontEffects-BhYuP4L2.js} +43 -44
  63. package/dist/assets/useDesktopWindowControlsLayout-DPNMRkrI.js +1 -0
  64. package/dist/assets/{useEffectiveDirectory-e9HjIqGz.js → useEffectiveDirectory-Ji8AKgzY.js} +1 -1
  65. package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
  66. package/dist/assets/useMobileNavigationStore-BYdpuwL7.js +413 -0
  67. package/dist/assets/useSessionAutoCleanup-CADZCvZL.js +2 -0
  68. package/dist/assets/{useWorkerHighlightedLines-DTGE0CDs.js → useWorkerHighlightedLines-CuG88Lsj.js} +1 -1
  69. package/dist/assets/{utils-BHTVlVov.js → utils-2ZK8yboQ.js} +1 -1
  70. package/dist/assets/{vendor-reactuses-core-DDoDDyM9.js → vendor-reactuses-core-BnW6GxJt.js} +1 -1
  71. package/dist/assets/{zh-CN-C96qP1ip.js → zh-CN-C6OFKjeT.js} +2 -2
  72. package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-BVP99aMr.js} +2 -2
  73. package/dist/index.html +8 -8
  74. package/dist/mini-chat.html +8 -8
  75. package/dist/mobile.html +8 -8
  76. package/package.json +1 -1
  77. package/server/index.js +1 -0
  78. package/server/lib/assistants/DOCUMENTATION.md +120 -22
  79. package/server/lib/assistants/assign.js +117 -12
  80. package/server/lib/assistants/assign.test.js +79 -0
  81. package/server/lib/assistants/bubbles.js +1 -1
  82. package/server/lib/assistants/contact-store.js +520 -11
  83. package/server/lib/assistants/contact-store.test.js +362 -12
  84. package/server/lib/assistants/contact-tools.js +305 -54
  85. package/server/lib/assistants/contact-tools.test.js +239 -7
  86. package/server/lib/assistants/contracts.js +31 -0
  87. package/server/lib/assistants/harness.js +295 -132
  88. package/server/lib/assistants/harness.test.js +662 -10
  89. package/server/lib/assistants/pi-tools.js +2 -0
  90. package/server/lib/assistants/pi-tools.test.js +8 -0
  91. package/server/lib/assistants/routes.js +3 -0
  92. package/server/lib/assistants/service.js +861 -79
  93. package/server/lib/assistants/service.test.js +1234 -8
  94. package/server/lib/llm/DOCUMENTATION.md +10 -3
  95. package/server/lib/llm/completions.js +4 -0
  96. package/server/lib/llm/completions.test.js +3 -0
  97. package/server/lib/llm/generate.js +57 -11
  98. package/server/lib/llm/generate.test.js +92 -0
  99. package/server/lib/opencode/feature-routes-runtime.js +5 -0
  100. package/dist/assets/AssistantView-CKmIS1cS.js +0 -2
  101. package/dist/assets/Icon-CkzzhaLr.js +0 -1
  102. package/dist/assets/MarkdownRenderer-CLQ4Z8Z0.js +0 -2
  103. package/dist/assets/MobileDetailNavigation-BGaAyuBH.js +0 -1
  104. package/dist/assets/MobileShareBridge-BAZoyeIP.js +0 -4
  105. package/dist/assets/MobileSurface-BVb3xNWt.js +0 -2
  106. package/dist/assets/SettingsView-CkQ61pbr.js +0 -25
  107. package/dist/assets/insertionBoundaries-BMhzPFft.js +0 -4
  108. package/dist/assets/main-BjeOhdcG.js +0 -169
  109. package/dist/assets/miniChat-BYrjYEXJ.js +0 -2
  110. package/dist/assets/mobile-BVMNnPK2.js +0 -2
  111. package/dist/assets/platform-DzsGqlGW.js +0 -2
  112. package/dist/assets/renderElectronMiniChatApp-COwia8Cs.js +0 -2
  113. package/dist/assets/renderMobileApp-CXwwpY1t.js +0 -28
  114. package/dist/assets/runtimeEndpointReset-BkAQXmXJ.js +0 -147
  115. package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
  116. package/dist/assets/sessionLookup-Imc995rW.js +0 -1
  117. package/dist/assets/src-AA-5nXsF.css +0 -2
  118. package/dist/assets/useDesktopWindowControlsLayout-C5hZD1co.js +0 -1
  119. package/dist/assets/useMobileNavigationStore-CYbDxRTM.js +0 -413
  120. 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 (?,?,?,?,?) ON CONFLICT(assistant_id,session_id) DO UPDATE SET directory=excluded.directory,status=excluded.status,updated_at=excluded.updated_at',
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 = 8;
764
- export const CONTACT_LLM_MAX_CHARS = 6_000;
765
- export const CONTACT_LLM_FETCH_LIMIT = 40;
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 content = message.text.trim() || (files.length > 0 ? '[attachment]' : '');
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
- // Drop pure-card, tool-trace, and thinking-only rows. Process text is not
788
- // persisted as contact bubbles; card-only rows have neither text nor files.
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. */