@openchambery/web 1.19.13-beta.1 → 1.19.13-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/dist/assets/{AgentAvatar-CiQIIM0d.js → AgentAvatar-mbvX7YlW.js} +1 -1
  2. package/dist/assets/AssistantView-D5c557Oe.js +2 -0
  3. package/dist/assets/DiagramView-CjNUrHsU.js +1 -0
  4. package/dist/assets/FadeInOnReveal-moBbL8Ll.js +1 -0
  5. package/dist/assets/{Icon-CeKHYyKZ.js → Icon-CkzzhaLr.js} +1 -1
  6. package/dist/assets/MarkdownRenderer-BHfbAwVO.js +2 -0
  7. package/dist/assets/MarkdownRendererImpl-CkBoUibc.js +7 -0
  8. package/dist/assets/{MarkstreamRendererImpl-B-odd2Gz.js → MarkstreamRendererImpl-BrXUOI9g.js} +1 -1
  9. package/dist/assets/MobileDetailNavigation-aY9-HwBN.js +1 -0
  10. package/dist/assets/MobileShareBridge-CRt_8DmM.js +4 -0
  11. package/dist/assets/MobileSurface-CIhtF-CU.js +2 -0
  12. package/dist/assets/{MultiRunWindow-BT5Dwgzu.js → MultiRunWindow-DDq6C8t7.js} +1 -1
  13. package/dist/assets/OnboardingScreen-j0iCyH7f.js +1 -0
  14. package/dist/assets/{OverlayScrollbar-DUghqFGg.js → OverlayScrollbar-CDC5B3RH.js} +1 -1
  15. package/dist/assets/SettingsView-DFWWMvAj.js +25 -0
  16. package/dist/assets/{SettingsWindow-DfEGfyr8.js → SettingsWindow-CnYfBplB.js} +1 -1
  17. package/dist/assets/TerminalView-D5CQFVVm.js +8 -0
  18. package/dist/assets/ToolOutputDialog-VMI7TQFT.js +10 -0
  19. package/dist/assets/{appThemeRegistry-CIZ-shMD.js → appThemeRegistry-UIrP0-5h.js} +1 -1
  20. package/dist/assets/assistant-attachment-cache-C0aRnFkj.js +1 -0
  21. package/dist/assets/{button-DFPOdZ0g.js → button-xx1UOBA5.js} +1 -1
  22. package/dist/assets/{clipboard-C05anBwU.js → clipboard-CUasgPiF.js} +1 -1
  23. package/dist/assets/{collapsible-oGbEvbe1.js → collapsible-CACHBRYZ.js} +1 -1
  24. package/dist/assets/{context-menu-DGz4_2hs.js → context-menu-CYbm4wBZ.js} +1 -1
  25. package/dist/assets/desktopHosts-DILvrAok.js +1 -0
  26. package/dist/assets/desktopRelayRestore-t8asCr3m.js +1 -0
  27. package/dist/assets/{diagram-Csk5GpDC.js → diagram-u0BC9uuY.js} +1 -1
  28. package/dist/assets/{dialog-rrqB-H6q.js → dialog-BwQ3rY_x.js} +1 -1
  29. package/dist/assets/dualLimitLru-D8Sphgua.js +1 -0
  30. package/dist/assets/{es-FE0sJ9EC.js → es-CRjOwvYM.js} +1 -1
  31. package/dist/assets/fileQueries-Bgly-E8f.js +1 -0
  32. package/dist/assets/{fr-DNDCSfGt.js → fr-D9taEN9L.js} +1 -1
  33. package/dist/assets/{gitApi-_P0FsoXe.js → gitApi-Bu4W3bgD.js} +1 -1
  34. package/dist/assets/imageSource-BlAJJUEI.js +1 -0
  35. package/dist/assets/{input-BO3r20op.js → input-CI730njs.js} +1 -1
  36. package/dist/assets/insertionBoundaries-B1AqxsBD.js +4 -0
  37. package/dist/assets/{ja-BDwrk0cM.js → ja-CYQ5aoWp.js} +1 -1
  38. package/dist/assets/{ko-BeeU2o4I.js → ko-BwASV0Q0.js} +1 -1
  39. package/dist/assets/main-CqcB6tKc.js +169 -0
  40. package/dist/assets/main-DG-k0j8A.js +2 -0
  41. package/dist/assets/markdown-worker-CptoICmq.js +2 -0
  42. package/dist/assets/markdownHeightCache-DEjGKLv8.js +3 -0
  43. package/dist/assets/markdownTheme-DOfGg-A7.js +1 -0
  44. package/dist/assets/miniChat-BQtYQrsh.js +2 -0
  45. package/dist/assets/mobile-8iyqtXkr.js +2 -0
  46. package/dist/assets/{multirun-CxhTsmRq.js → multirun-CG_FCb_Z.js} +1 -1
  47. package/dist/assets/persistence-YbQKzHUQ.js +3 -0
  48. package/dist/assets/{pl-BYuzpcI5.js → pl-DUI1vPil.js} +1 -1
  49. package/dist/assets/platform-DzsGqlGW.js +2 -0
  50. package/dist/assets/projectMeta-DMpMDWE1.js +3 -0
  51. package/dist/assets/{pt-BR-Bn7cwEzv.js → pt-BR-CZQFJllP.js} +1 -1
  52. package/dist/assets/relay-image-stream-DM7atqJs.js +2 -0
  53. package/dist/assets/renderElectronMiniChatApp-DBzRdJl8.js +2 -0
  54. package/dist/assets/renderMobileApp-CIseqCyn.js +28 -0
  55. package/dist/assets/runtime-auth-D66yq6ie.js +1 -0
  56. package/dist/assets/runtime-fetch-zIalUnbb.js +1 -0
  57. package/dist/assets/runtime-switch-LvWDU-SL.js +1 -0
  58. package/dist/assets/runtimeConfig-D8nJYB-e.js +2 -0
  59. package/dist/assets/{runtimeEndpointReset-BBLFgl85.js → runtimeEndpointReset-CSub_ecV.js} +6 -6
  60. package/dist/assets/{sessionChangeSummary-DDVT7O5J.js → sessionChangeSummary-DQDZ5HW-.js} +1 -1
  61. package/dist/assets/{sessionLookup-C8VZn0gJ.js → sessionLookup-BiBD-4wC.js} +1 -1
  62. package/dist/assets/sortable-tabs-strip-Dom6Sthu.js +1 -0
  63. package/dist/assets/src-AA-5nXsF.css +2 -0
  64. package/dist/assets/{store-n94dSb3M.js → store-CSnH_QXM.js} +2 -2
  65. package/dist/assets/terminalApi-noa4Cdbh.js +1 -0
  66. package/dist/assets/{textarea-BjX-KhMN.js → textarea-BfNtUdJE.js} +1 -1
  67. package/dist/assets/{tooltip-CQGqGAG_.js → tooltip-DPxumadF.js} +1 -1
  68. package/dist/assets/{uk-CwpbHEa3.js → uk-C1FYZlmA.js} +1 -1
  69. package/dist/assets/{url-BMjqccCo.js → url-CVhsI9kR.js} +1 -1
  70. package/dist/assets/{useAppFontEffects-BIFw36SA.js → useAppFontEffects-qHt5uaFN.js} +32 -32
  71. package/dist/assets/useAssistantUIStore-CLPCNoj_.js +1 -0
  72. package/dist/assets/useDesktopWindowControlsLayout-BipyIXiK.js +1 -0
  73. package/dist/assets/{useEffectiveDirectory-DJSYYkVp.js → useEffectiveDirectory-DSNCCbCt.js} +1 -1
  74. package/dist/assets/useFontPreferences-CB-cGo3I.js +1 -0
  75. package/dist/assets/useMobileNavigationStore-C1NJGAik.js +413 -0
  76. package/dist/assets/useSessionAutoCleanup-Dcn6Unvd.js +2 -0
  77. package/dist/assets/useUIStore-CxOoCv7B.js +1 -0
  78. package/dist/assets/useWorkerHighlightedLines-CzpT4M7V.js +7 -0
  79. package/dist/assets/{utils-81nUI_xp.js → utils-BHTVlVov.js} +1 -1
  80. package/dist/assets/{zh-CN-BpzFZ-LU.js → zh-CN-C96qP1ip.js} +1 -1
  81. package/dist/assets/{zh-TW-Bopgc5Eo.js → zh-TW-BsRk1Rbb.js} +1 -1
  82. package/dist/index.html +12 -9
  83. package/dist/mini-chat.html +12 -9
  84. package/dist/mobile.html +12 -9
  85. package/package.json +1 -1
  86. package/server/lib/assistant-delivery-parts.js +19 -1
  87. package/server/lib/assistants/DOCUMENTATION.md +39 -7
  88. package/server/lib/assistants/cards.js +16 -2
  89. package/server/lib/assistants/cards.test.js +16 -1
  90. package/server/lib/assistants/contact-attachments.http.test.js +436 -0
  91. package/server/lib/assistants/contact-attachments.js +814 -0
  92. package/server/lib/assistants/contact-attachments.p1.test.js +307 -0
  93. package/server/lib/assistants/contact-attachments.test.js +249 -0
  94. package/server/lib/assistants/contact-store.js +494 -25
  95. package/server/lib/assistants/contact-store.test.js +403 -1
  96. package/server/lib/assistants/contact-tools.js +6 -5
  97. package/server/lib/assistants/contact-tools.test.js +24 -6
  98. package/server/lib/assistants/contracts.js +41 -1
  99. package/server/lib/assistants/harness.test.js +124 -0
  100. package/server/lib/assistants/routes.js +46 -1
  101. package/server/lib/assistants/service.js +258 -66
  102. package/server/lib/assistants/service.test.js +400 -30
  103. package/server/lib/fs/prompt-attachment-store.js +663 -0
  104. package/server/lib/fs/prompt-attachment-store.test.js +263 -0
  105. package/server/lib/fs/routes.js +28 -72
  106. package/server/lib/fs/routes.test.js +15 -30
  107. package/server/lib/opencode/core-routes.js +2 -1
  108. package/dist/assets/AssistantView-YmeyxehG.js +0 -2
  109. package/dist/assets/DiagramView-BpZb-G9P.js +0 -1
  110. package/dist/assets/FadeInOnReveal-nf6-ZCrs.js +0 -1
  111. package/dist/assets/MarkdownRenderer-wpeGdciL.js +0 -2
  112. package/dist/assets/MarkdownRendererImpl-Peu0GVSQ.js +0 -7
  113. package/dist/assets/MobileDetailNavigation-CtYke_1C.js +0 -1
  114. package/dist/assets/MobileShareBridge-DIfOl8EZ.js +0 -4
  115. package/dist/assets/MobileSurface-_KoX75Ff.js +0 -2
  116. package/dist/assets/OnboardingScreen-BPusUgFG.js +0 -1
  117. package/dist/assets/SettingsView-BwQpbIZl.js +0 -25
  118. package/dist/assets/TerminalView-8402vpcO.js +0 -8
  119. package/dist/assets/ToolOutputDialog-B5RdkhsG.js +0 -10
  120. package/dist/assets/desktopHosts-BVJXQq1S.js +0 -1
  121. package/dist/assets/desktopRelayRestore-RDfuj1M7.js +0 -1
  122. package/dist/assets/fileQueries-BcdWOPlD.js +0 -1
  123. package/dist/assets/insertionBoundaries-CWoioTdE.js +0 -4
  124. package/dist/assets/main-CLj_WS1o.js +0 -2
  125. package/dist/assets/main-CnSZ89pD.js +0 -169
  126. package/dist/assets/markdown-worker-CZWE1F1M.js +0 -2
  127. package/dist/assets/markdownHeightCache-Cdd3hkMl.js +0 -3
  128. package/dist/assets/markdownTheme-DIB8WVmz.js +0 -2
  129. package/dist/assets/miniChat-B9c3y2pD.js +0 -2
  130. package/dist/assets/mobile-DvbXUfNS.js +0 -2
  131. package/dist/assets/persistence-CVTWFaqe.js +0 -3
  132. package/dist/assets/platform-BY_Gtsqr.js +0 -2
  133. package/dist/assets/projectMeta-Dl5iPCB6.js +0 -3
  134. package/dist/assets/renderElectronMiniChatApp-CkJ51qCh.js +0 -2
  135. package/dist/assets/renderMobileApp-DcjQPhWw.js +0 -28
  136. package/dist/assets/runtimeConfig-Bsk2kWsW.js +0 -2
  137. package/dist/assets/sortable-tabs-strip-s7UUxsa6.js +0 -1
  138. package/dist/assets/src-BpHDMgud.css +0 -2
  139. package/dist/assets/terminalApi-C54_rKEK.js +0 -1
  140. package/dist/assets/useAssistantUIStore-Cg9bsT4b.js +0 -1
  141. package/dist/assets/useDesktopWindowControlsLayout-DoygEcN4.js +0 -1
  142. package/dist/assets/useFontPreferences-B5_apbBC.js +0 -1
  143. package/dist/assets/useMobileNavigationStore-DuTu0Mzh.js +0 -413
  144. package/dist/assets/useSessionAutoCleanup-C-L9teva.js +0 -1
  145. package/dist/assets/useUIStore-DwpQTjLO.js +0 -1
  146. package/dist/assets/useWorkerHighlightedLines-DxTfgdEi.js +0 -7
@@ -8,7 +8,10 @@ import { reduceBackfillState } from './history-state.js';
8
8
  import { getWorktrees as defaultListWorktrees } from '../git/service.js';
9
9
  import { contactCardIdentity, parseContactCard, parseContactPart } from './cards.js';
10
10
  import {
11
+ CONTACT_PAGE_DEFAULT_LIMIT,
12
+ CONTACT_PAGE_MAX_LIMIT,
11
13
  CONTACT_SETTLE_TEXT,
14
+ bumpContactGeneration,
12
15
  clearContactMemory as clearContactMemoryStore,
13
16
  contactHistoryForLlm,
14
17
  createActiveContactTurn,
@@ -16,6 +19,8 @@ import {
16
19
  ensureContactSchema,
17
20
  contactPartsFingerprint,
18
21
  getContactMessage,
22
+ getLatestContactMessagePreview,
23
+ getLatestContactMessagePreviews,
19
24
  insertContactMessage,
20
25
  listContactMessages,
21
26
  listInFlightWatches,
@@ -25,6 +30,21 @@ import {
25
30
  updateSessionCardStatus,
26
31
  upsertContactWatch,
27
32
  } from './contact-store.js';
33
+ import {
34
+ CONTACT_ATTACHMENT_TURN_BUDGET_BYTES,
35
+ assertContactAttachmentOwned,
36
+ assertContactAttachmentReadable,
37
+ canonicalDescriptorFromRow,
38
+ ensureContactAttachmentSchema,
39
+ getContactAttachmentRow,
40
+ ingestDataUrlFilePart,
41
+ isSafeInlineImageMime,
42
+ materializeContactHistory,
43
+ materializeContactParts,
44
+ migrateContactDataUrlParts,
45
+ putContactAttachment,
46
+ readContactAttachmentBytes,
47
+ } from './contact-attachments.js';
28
48
  import {
29
49
  ASSIGNED_SESSION_FALLBACK_BUBBLE,
30
50
  boundSessionListLimit,
@@ -38,7 +58,6 @@ import {
38
58
  normalizeConnectedModels,
39
59
  normalizeRegisteredProjects,
40
60
  sanitizeRegisteredProject,
41
- userTextAuthorizesClearChatHistory,
42
61
  } from './contact-tools.js';
43
62
  import {
44
63
  AssignError,
@@ -54,7 +73,7 @@ import { runContactTurn as defaultRunContactTurn } from './harness.js';
54
73
  import { loadConnectedCatalog } from '../llm/catalog.js';
55
74
 
56
75
  const require = createRequire(import.meta.url);
57
- const SCHEMA_VERSION = 11;
76
+ const SCHEMA_VERSION = 12;
58
77
  const normalizeSessionDirectory = (value) => {
59
78
  if (typeof value !== 'string') return null;
60
79
  const trimmed = value.trim();
@@ -162,6 +181,21 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
162
181
  CREATE INDEX IF NOT EXISTS assistant_scheduled_task_assistant
163
182
  ON assistant_scheduled_task(assistant_id, created_at DESC);`);
164
183
  ensureContactSchema(db);
184
+ ensureContactAttachmentSchema(db);
185
+ // Background legacy data-URL → descriptor migration (cancelable; never touches foreign DBs).
186
+ const contactAttachmentMigration = { controller: new AbortController(), promise: null };
187
+ contactAttachmentMigration.promise = Promise.resolve().then(() => migrateContactDataUrlParts({
188
+ db,
189
+ dataDir,
190
+ signal: contactAttachmentMigration.controller.signal,
191
+ yieldFn: () => new Promise((resolve) => setImmediateFn(resolve)),
192
+ })).catch((error) => {
193
+ if (error?.name !== 'AbortError' && !closed) {
194
+ // Diagnostics only — never throw into boot.
195
+ console.warn('[assistants] contact attachment migration:', error?.code || error?.message || error);
196
+ }
197
+ return { migrated: 0, failed: 0, scanned: 0, failures: [], error: error?.code || error?.message };
198
+ });
165
199
  const historyColumns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant_session_history')").all().map((column) => column.name));
166
200
  if (!historyColumns.has('directory')) db.exec('ALTER TABLE assistant_session_history ADD COLUMN directory TEXT');
167
201
  const mirrorColumns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant_message_mirror')").all().map((column) => column.name));
@@ -249,9 +283,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
249
283
  }
250
284
  activeContactTurnsByAssistant.delete(assistantID);
251
285
  };
252
- const output = (row) => {
286
+ const output = (row, latestMessagePreview) => {
253
287
  const watches = listInFlightWatches(db, row.assistant_id);
254
288
  const activeContactTurn = projectActiveContactTurn(activeContactTurnsFor(row.assistant_id));
289
+ const preview = latestMessagePreview !== undefined
290
+ ? latestMessagePreview
291
+ : getLatestContactMessagePreview(db, row.assistant_id);
255
292
  return {
256
293
  id: row.assistant_id,
257
294
  revision: row.revision,
@@ -275,6 +312,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
275
312
  // on the session card / assignedSessionIDs and must not keep the avatar lit.
276
313
  working: activeContactTurn != null,
277
314
  activeContactTurn,
315
+ // Contact-transcript list desc: authoritative last visible bubble (not
316
+ // defaultPrompt / updatedAt). null when the contact has no list-visible rows.
317
+ latestMessagePreview: preview ?? null,
278
318
  createdAt: row.created_at,
279
319
  updatedAt: row.updated_at,
280
320
  tombstoneAt: row.tombstone_at,
@@ -327,7 +367,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
327
367
  const restoreOnce = async (row, expectedSessionID, expectedGeneration) => { for (let attempt = 0; attempt < 3; attempt++) { const current = active(row.assistant_id); if (current.current_session_id !== expectedSessionID || current.session_generation !== expectedGeneration) return binding(current); const created = await createSession(current); const won = replaceBinding(current, created); if (won) return binding(won); } return binding(active(row.assistant_id)); };
328
368
  const configuration = (row) => ({ model: { providerID: row.provider_id, modelID: row.model_id }, ...(row.agent ? { agent: row.agent } : {}), ...(row.variant ? { variant: row.variant } : {}), ...(row.default_prompt ? { system: row.default_prompt } : {}) });
329
369
  const capturedConfiguration = (target) => ({ model: { providerID: target.providerID, modelID: target.modelID }, ...(target.agent ? { agent: target.agent } : {}), ...(target.variant ? { variant: target.variant } : {}), ...(target.system ? { system: target.system } : target.defaultPrompt ? { system: target.defaultPrompt } : {}) });
330
- const validateParts = (parts) => { if (!validAssistantDeliveryParts(parts)) fail('validation_error'); };
370
+ const validateParts = (parts) => {
371
+ if (!validAssistantDeliveryParts(parts, { allowAttachmentRefs: true })) fail('validation_error');
372
+ };
331
373
  const migrate = () => {
332
374
  if (db.prepare("SELECT value FROM assistant_meta WHERE key='schema_version'").get()?.value === String(SCHEMA_VERSION)) return;
333
375
  const v2Info = db.prepare("SELECT name,\"notnull\" AS required FROM pragma_table_info('assistant_v2')").all(); const v2Columns = new Set(v2Info.map((column) => column.name));
@@ -723,13 +765,60 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
723
765
  if (text) return text;
724
766
  return input.parts.some((part) => part?.type === 'file') ? '[attachment]' : '';
725
767
  };
726
- const userContactParts = (input, userText) => {
768
+ const userContactParts = async (assistantID, input, userText) => {
727
769
  const parts = [];
770
+ let turnBytes = 0;
728
771
  if (Array.isArray(input.parts)) {
729
772
  for (const part of input.parts) {
730
773
  const parsed = parseContactPart(part);
731
- if (parsed?.type === 'text' && parsed.text.trim()) parts.push({ type: 'text', text: parsed.text });
732
- if (parsed?.type === 'file') parts.push(parsed);
774
+ if (parsed?.type === 'text' && parsed.text.trim()) {
775
+ parts.push({ type: 'text', text: parsed.text });
776
+ continue;
777
+ }
778
+ if (parsed?.type !== 'file') continue;
779
+ if (parsed.attachmentID) {
780
+ let row;
781
+ try {
782
+ // Admission: current-generation ownership + canonical row rebuild (ignore client meta).
783
+ row = assertContactAttachmentReadable(db, { attachmentID: parsed.attachmentID, assistantID });
784
+ } catch (error) {
785
+ fail(error?.code === 'not_found' ? 'not_found' : 'validation_error', error?.message);
786
+ }
787
+ const descriptor = canonicalDescriptorFromRow(row);
788
+ turnBytes += descriptor.size || 0;
789
+ if (turnBytes > CONTACT_ATTACHMENT_TURN_BUDGET_BYTES) {
790
+ fail('validation_error', 'Contact attachment total exceeds 50MiB budget');
791
+ }
792
+ parts.push(descriptor);
793
+ continue;
794
+ }
795
+ if (typeof parsed.url === 'string' && parsed.url.startsWith('data:')) {
796
+ try {
797
+ const descriptor = await ingestDataUrlFilePart({
798
+ db,
799
+ dataDir,
800
+ assistantID,
801
+ part: parsed,
802
+ clock: now,
803
+ mode: 'admission',
804
+ });
805
+ // Re-check generation after await.
806
+ const row = assertContactAttachmentReadable(db, {
807
+ attachmentID: descriptor.attachmentID,
808
+ assistantID,
809
+ });
810
+ const canonical = canonicalDescriptorFromRow(row);
811
+ turnBytes += canonical.size || 0;
812
+ if (turnBytes > CONTACT_ATTACHMENT_TURN_BUDGET_BYTES) {
813
+ fail('validation_error', 'Contact attachment total exceeds 50MiB budget');
814
+ }
815
+ parts.push(canonical);
816
+ } catch (error) {
817
+ fail(error?.code || 'validation_error', error?.message);
818
+ }
819
+ continue;
820
+ }
821
+ fail('validation_error', 'File parts require attachmentID or data URL');
733
822
  }
734
823
  }
735
824
  if (parts.length === 0 && userText) parts.push({ type: 'text', text: userText });
@@ -1260,8 +1349,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1260
1349
  const messageID = string(input.messageID, 256, true);
1261
1350
  const userText = extractUserText(input);
1262
1351
  if (!userText) fail('validation_error');
1263
- const userParts = userContactParts(input, userText);
1264
1352
  const row = active(assistantID);
1353
+ const userParts = await userContactParts(row.assistant_id, input, userText);
1265
1354
  if (typeof createChatCompletion !== 'function' && runContactTurn === defaultRunContactTurn) fail('upstream_error');
1266
1355
  let registeredProjects = [];
1267
1356
  try {
@@ -1308,8 +1397,6 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1308
1397
  messageID,
1309
1398
  });
1310
1399
  contactTurnSettlement(messageID);
1311
- // Authorize wipe from this turn's real userText only — not tool args.
1312
- const clearChatHistoryAuthorized = userTextAuthorizesClearChatHistory(userText);
1313
1400
  const kickTurn = () => {
1314
1401
  void inContactTurnLane(row.assistant_id, async () => {
1315
1402
  if (closed) {
@@ -1334,11 +1421,6 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1334
1421
  const assignedCards = [];
1335
1422
  let contactResetThisTurn = false;
1336
1423
  let contactHistoryClearedThisTurn = false;
1337
- // Current-turn attachments only — never unbounded history image forwarding.
1338
- const turnFileParts = sanitizeAssignFileParts(
1339
- (Array.isArray(userParts) ? userParts : []).filter((part) => part?.type === 'file'),
1340
- );
1341
- const turnAttachmentScope = attachmentScopeKey(turnFileParts);
1342
1424
  let connectedModels = [];
1343
1425
  try {
1344
1426
  const catalog = await loadAssignCatalog(undefined);
@@ -1348,47 +1430,65 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1348
1430
  // Explicit worker model / image capability checks still fail closed inside assignWork.
1349
1431
  connectedModels = [];
1350
1432
  }
1351
- const tools = createContactTools({
1352
- assignWork: (params) => assignWork(row, params),
1353
- createAssistant: (toolInput) => createAssistant(toolInput),
1354
- scheduleTask: (params) => scheduleWork(row, params),
1355
- deliverPeerMessage: (toolInput) => deliverPeerMessage(row.assistant_id, toolInput),
1356
- clearContactMemory: () => {
1357
- // Cap the watermark at this turn's user ordinal — never global MAX,
1358
- // or later-admitted queued users are permanently excluded from LLM history.
1359
- const result = clearContactMemory(row.assistant_id, {
1360
- upToOrdinal: beforeOrdinal,
1361
- });
1362
- contactResetThisTurn = true;
1363
- return result;
1364
- },
1365
- resetContact: () => {
1366
- // Server core gate: model mis-select cannot self-authorize delete.
1367
- if (!clearChatHistoryAuthorized) {
1368
- throw new AssignError(
1369
- ASSIGN_CODES.VALIDATION,
1370
- 'clear_chat_history requires explicit user wipe intent in this message.',
1371
- );
1372
- }
1373
- const result = resetContact(row.assistant_id);
1374
- contactResetThisTurn = true;
1375
- contactHistoryClearedThisTurn = true;
1376
- return result;
1377
- },
1378
- listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
1379
- listProjects: async () => loadRegisteredProjects(),
1380
- listSessions: (params) => listSessionsWork(params),
1381
- currentAssistant: assistantSnapshot,
1382
- onCard: (card) => assignedCards.push(card),
1383
- turnFileParts,
1384
- turnAttachmentScope,
1385
- });
1386
1433
  try {
1434
+ // Materialize inside turn try so failures durable-error + settle working.
1435
+ // DB/UI keep descriptors; harness + assign get execution-time data URLs under budget.
1436
+ const executionHistory = await materializeContactHistory(
1437
+ db,
1438
+ dataDir,
1439
+ row.assistant_id,
1440
+ history,
1441
+ { budgetBytes: CONTACT_ATTACHMENT_TURN_BUDGET_BYTES },
1442
+ );
1443
+ const executionParts = await materializeContactParts(
1444
+ db,
1445
+ dataDir,
1446
+ row.assistant_id,
1447
+ userParts,
1448
+ { budgetBytes: CONTACT_ATTACHMENT_TURN_BUDGET_BYTES },
1449
+ );
1450
+ // Current-turn attachments only — never unbounded history image forwarding.
1451
+ const turnFileParts = sanitizeAssignFileParts(
1452
+ (Array.isArray(executionParts) ? executionParts : []).filter((part) => part?.type === 'file'),
1453
+ );
1454
+ const turnAttachmentScope = attachmentScopeKey(turnFileParts);
1455
+ const tools = createContactTools({
1456
+ assignWork: (params) => assignWork(row, params),
1457
+ createAssistant: (toolInput) => createAssistant(toolInput),
1458
+ scheduleTask: (params) => scheduleWork(row, params),
1459
+ deliverPeerMessage: (toolInput) => deliverPeerMessage(row.assistant_id, toolInput),
1460
+ clearContactMemory: () => {
1461
+ // Cap the watermark at this turn's user ordinal — never global MAX,
1462
+ // or later-admitted queued users are permanently excluded from LLM history.
1463
+ const result = clearContactMemory(row.assistant_id, {
1464
+ upToOrdinal: beforeOrdinal,
1465
+ });
1466
+ contactResetThisTurn = true;
1467
+ return result;
1468
+ },
1469
+ resetContact: () => {
1470
+ // Tool wipe: keep wipe user + later-admitted users only; delete all
1471
+ // other roles at any ordinal (incl. late prior-lane assistant/cards).
1472
+ const result = resetContact(row.assistant_id, {
1473
+ upToOrdinal: beforeOrdinal,
1474
+ });
1475
+ contactResetThisTurn = true;
1476
+ contactHistoryClearedThisTurn = true;
1477
+ return result;
1478
+ },
1479
+ listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
1480
+ listProjects: async () => loadRegisteredProjects(),
1481
+ listSessions: (params) => listSessionsWork(params),
1482
+ currentAssistant: assistantSnapshot,
1483
+ onCard: (card) => assignedCards.push(card),
1484
+ turnFileParts,
1485
+ turnAttachmentScope,
1486
+ });
1387
1487
  const generated = await runContactTurn({
1388
1488
  assistant: assistantSnapshot,
1389
- history,
1489
+ history: executionHistory,
1390
1490
  userText,
1391
- userParts,
1491
+ userParts: executionParts,
1392
1492
  createChatCompletion,
1393
1493
  tools,
1394
1494
  projects: registeredProjects,
@@ -1453,9 +1553,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1453
1553
  resolveContactTurnSettlement(messageID, { status: 'error', error: detail });
1454
1554
  return;
1455
1555
  }
1456
- // clear_chat_history / resetContact wipe the transcript (including the
1457
- // admitted user row). Re-admit that user message, then persist only the
1458
- // confirm bubble. new_conversation keeps rows and only moves the watermark.
1556
+ // clear_chat_history keeps the wipe user row (bounded delete). Re-admit
1557
+ // only if the row is missing (unbounded API wipe / edge). Then persist
1558
+ // only the confirm bubble. new_conversation keeps all rows + watermark.
1459
1559
  if (resetThisTurn) {
1460
1560
  const existingUser = db.prepare('SELECT 1 AS ok FROM assistant_contact_message WHERE message_id=?').get(messageID);
1461
1561
  if (!existingUser) {
@@ -1527,10 +1627,32 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1527
1627
  return { binding: binding(row), messageID, admitted: true, revision: revision() };
1528
1628
  };
1529
1629
  const contactMessages = (assistantID, query = {}) => {
1530
- editable(assistantID);
1531
- const limit = query.limit == null ? 50 : Number(query.limit);
1532
- if (!Number.isInteger(limit) || limit < 1 || limit > 100) fail('validation_error');
1533
- return listContactMessages(db, assistantID, { before: query.before, limit });
1630
+ const row = editable(assistantID);
1631
+ const hasBefore = !(query.before == null || query.before === '');
1632
+ const hasExact = !(query.messageID == null || query.messageID === '');
1633
+ if (hasBefore && hasExact) fail('validation_error', 'before and messageID are mutually exclusive');
1634
+ let limit = query.limit == null || query.limit === '' ? CONTACT_PAGE_DEFAULT_LIMIT : Number(query.limit);
1635
+ if (!Number.isInteger(limit) || limit < 1 || limit > CONTACT_PAGE_MAX_LIMIT) fail('validation_error');
1636
+ let page;
1637
+ try {
1638
+ page = listContactMessages(db, row.assistant_id, {
1639
+ before: hasBefore ? query.before : undefined,
1640
+ limit,
1641
+ messageID: hasExact ? query.messageID : undefined,
1642
+ });
1643
+ } catch (error) {
1644
+ if (error?.code === 'contact_generation_conflict' || error?.code === 'validation_error') {
1645
+ fail(error.code, error.message);
1646
+ }
1647
+ throw error;
1648
+ }
1649
+ return {
1650
+ messages: page.messages,
1651
+ nextCursor: page.nextCursor,
1652
+ complete: page.complete,
1653
+ generation: page.generation,
1654
+ revision: revision(),
1655
+ };
1534
1656
  };
1535
1657
  /**
1536
1658
  * Clear LLM memory only: advance the durable context boundary so later
@@ -1557,23 +1679,34 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1557
1679
  };
1558
1680
  /**
1559
1681
  * Delete this assistant's OpenChamber contact transcript (messages, parts,
1560
- * watches) and clear the context boundary. Intentional wipe only — also the
1561
- * POST /contact/reset contract. Does not call OpenCode session/new.
1682
+ * watches) and clear the context boundary. Also the POST /contact/reset
1683
+ * contract. Does not call OpenCode session/new.
1684
+ *
1685
+ * Tool path may pass `upToOrdinal` (wiping turn's user ordinal) so later-
1686
+ * admitted queued rows survive. Direct API without a ceiling deletes every
1687
+ * row (existing contract). Concurrent in-flight admissions already past 202
1688
+ * keep their message IDs only when bounded; unbounded API wipe drops them
1689
+ * and a later same-ID send replays as a fresh admission only if the row is gone.
1562
1690
  */
1563
- const resetContact = (assistantID) => {
1691
+ const resetContact = (assistantID, options = {}) => {
1564
1692
  const row = editable(assistantID);
1693
+ const upToOrdinal = options && typeof options === 'object' ? options.upToOrdinal : undefined;
1565
1694
  db.exec('BEGIN IMMEDIATE');
1566
1695
  try {
1567
- deleteContactMessages(db, row.assistant_id);
1696
+ // Generation bumps only on transcript wipe — not on clear-memory.
1697
+ const generation = bumpContactGeneration(db, row.assistant_id);
1698
+ deleteContactMessages(db, row.assistant_id, {
1699
+ ...(upToOrdinal !== undefined ? { upToOrdinal } : {}),
1700
+ });
1568
1701
  // Active contact turns stay until settle — wipe must not drop the green
1569
1702
  // dot while the clearing turn is still running.
1570
1703
  bump();
1571
1704
  db.exec('COMMIT');
1705
+ return { assistantID: row.assistant_id, reset: true, historyCleared: true, generation };
1572
1706
  } catch (error) {
1573
1707
  db.exec('ROLLBACK');
1574
1708
  throw error;
1575
1709
  }
1576
- return { assistantID: row.assistant_id, reset: true, historyCleared: true };
1577
1710
  };
1578
1711
  const appendContactCard = (assistantID, input) => {
1579
1712
  const row = active(assistantID);
@@ -1711,7 +1844,57 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1711
1844
  operation = db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID); if (operation?.phase !== 'reserving') { const claimed = claim(operationID, operation?.state === 'failed'); if (claimed) await submitClaim(claimed); } return shareOperation(operationID); };
1712
1845
  const timer = setIntervalFn(() => { if (!closed) { db.prepare('DELETE FROM assistant_share_operation WHERE updated_at<?').run(now() - SHARE_RETENTION_MS); return reconcile(); } }, reconcileIntervalMs);
1713
1846
  void reconcile();
1714
- return { capability: async () => ({ supported: true, enabled: enabled(), revision: revision(), serverInstanceID: await getServerId() }), snapshot: () => ({ revision: revision(), enabled: enabled(), assistants: db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output) }), createAssistant, updateAssistant, setEnabled: (input) => { if (!plainObject(input) || typeof input.enabled !== 'boolean' || input.expectedRevision !== revision()) fail('revision_conflict'); db.prepare("UPDATE assistant_meta SET value=? WHERE key='enabled'").run(input.enabled ? '1' : '0'); return { enabled: input.enabled, revision: bump() }; }, removeAssistant: (assistantID, expectedRevision) => {
1847
+ const putAssistantContactAttachment = async (assistantID, uploadID, { stream, headers, signal, storeBytes } = {}) => {
1848
+ const row = editable(assistantID);
1849
+ try {
1850
+ return await putContactAttachment({
1851
+ db,
1852
+ dataDir,
1853
+ assistantID: row.assistant_id,
1854
+ uploadID,
1855
+ stream,
1856
+ headers,
1857
+ clock: now,
1858
+ signal,
1859
+ ...(storeBytes ? { storeBytes } : {}),
1860
+ });
1861
+ } catch (error) {
1862
+ if (error?.code) fail(error.code, error.message);
1863
+ throw error;
1864
+ }
1865
+ };
1866
+ const getAssistantContactAttachment = async (assistantID, attachmentID) => {
1867
+ const row = editable(assistantID);
1868
+ try {
1869
+ return await readContactAttachmentBytes({
1870
+ db,
1871
+ dataDir,
1872
+ assistantID: row.assistant_id,
1873
+ attachmentID,
1874
+ external: true,
1875
+ });
1876
+ } catch (error) {
1877
+ if (error?.code) fail(error.code, error.message);
1878
+ throw error;
1879
+ }
1880
+ };
1881
+ const migrateContactAttachments = async (assistantID = null) => migrateContactDataUrlParts({
1882
+ db,
1883
+ dataDir,
1884
+ assistantID,
1885
+ clock: now,
1886
+ });
1887
+ const snapshot = () => {
1888
+ const rows = db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all();
1889
+ // Per-assistant indexed LIMIT probes + one parts batch (catalog ≤100).
1890
+ const previews = getLatestContactMessagePreviews(db, rows.map((row) => row.assistant_id));
1891
+ return {
1892
+ revision: revision(),
1893
+ enabled: enabled(),
1894
+ assistants: rows.map((row) => output(row, previews.get(row.assistant_id) ?? null)),
1895
+ };
1896
+ };
1897
+ return { capability: async () => ({ supported: true, enabled: enabled(), revision: revision(), serverInstanceID: await getServerId() }), snapshot, createAssistant, updateAssistant, setEnabled: (input) => { if (!plainObject(input) || typeof input.enabled !== 'boolean' || input.expectedRevision !== revision()) fail('revision_conflict'); db.prepare("UPDATE assistant_meta SET value=? WHERE key='enabled'").run(input.enabled ? '1' : '0'); return { enabled: input.enabled, revision: bump() }; }, removeAssistant: (assistantID, expectedRevision) => {
1715
1898
  db.exec('BEGIN IMMEDIATE');
1716
1899
  try {
1717
1900
  const result = db.prepare('UPDATE assistant_v2 SET tombstone_at=?,revision=revision+1,updated_at=? WHERE assistant_id=? AND revision=? AND tombstone_at IS NULL').run(now(), now(), assistantID, expectedRevision);
@@ -1730,5 +1913,14 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1730
1913
  db.exec('ROLLBACK');
1731
1914
  throw error;
1732
1915
  }
1733
- }, ensure, createNew, compact, send, whenContactTurnSettled, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, contactMessages, clearContactMemory, resetContact, appendContactCard, deliverPeerMessage, listAssistantScheduledTasks, processEvent, reportAssignedSessionSettle: reportAssignedSession, reconcile, close: () => { if (!closed) { closed = true; clearActiveContactTurns(); unsubscribeEvents?.(); clearIntervalFn(timer); db.close(); } } };
1916
+ }, ensure, createNew, compact, send, whenContactTurnSettled, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, contactMessages, clearContactMemory, resetContact, appendContactCard, deliverPeerMessage, listAssistantScheduledTasks, putAssistantContactAttachment, getAssistantContactAttachment, migrateContactAttachments, processEvent, reportAssignedSessionSettle: reportAssignedSession, reconcile, close: () => {
1917
+ if (!closed) {
1918
+ closed = true;
1919
+ try { contactAttachmentMigration.controller.abort(); } catch { /* ignore */ }
1920
+ clearActiveContactTurns();
1921
+ unsubscribeEvents?.();
1922
+ clearIntervalFn(timer);
1923
+ db.close();
1924
+ }
1925
+ } };
1734
1926
  };