@openchambery/web 1.19.13-beta.1 → 1.19.13-beta.3
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-CiQIIM0d.js → AgentAvatar-mbvX7YlW.js} +1 -1
- package/dist/assets/AssistantView-DOZPK9oh.js +2 -0
- package/dist/assets/DiagramView-W0IHFk-1.js +1 -0
- package/dist/assets/FadeInOnReveal-moBbL8Ll.js +1 -0
- package/dist/assets/{Icon-CeKHYyKZ.js → Icon-CkzzhaLr.js} +1 -1
- package/dist/assets/MarkdownRenderer-v7n_D6nL.js +2 -0
- package/dist/assets/MarkdownRendererImpl-Buz5LZsa.js +7 -0
- package/dist/assets/{MarkstreamRendererImpl-B-odd2Gz.js → MarkstreamRendererImpl-D4nUXYpP.js} +1 -1
- package/dist/assets/MobileDetailNavigation-Bfs4CTUh.js +1 -0
- package/dist/assets/MobileShareBridge-DtwVLkBL.js +4 -0
- package/dist/assets/MobileSurface-BmYYdY3g.js +2 -0
- package/dist/assets/{MultiRunWindow-BT5Dwgzu.js → MultiRunWindow-yWIHgr89.js} +1 -1
- package/dist/assets/OnboardingScreen-j0iCyH7f.js +1 -0
- package/dist/assets/{OverlayScrollbar-DUghqFGg.js → OverlayScrollbar-CDC5B3RH.js} +1 -1
- package/dist/assets/SettingsView-C3BbLpHT.js +25 -0
- package/dist/assets/{SettingsWindow-DfEGfyr8.js → SettingsWindow-b0JWdygM.js} +1 -1
- package/dist/assets/TerminalView-C74IvmOt.js +8 -0
- package/dist/assets/ToolOutputDialog-BORu8CtR.js +10 -0
- package/dist/assets/{appThemeRegistry-CIZ-shMD.js → appThemeRegistry-DOwhB93n.js} +1 -1
- package/dist/assets/assistant-attachment-cache-C0aRnFkj.js +1 -0
- package/dist/assets/{button-DFPOdZ0g.js → button-xx1UOBA5.js} +1 -1
- package/dist/assets/{clipboard-C05anBwU.js → clipboard-CUasgPiF.js} +1 -1
- package/dist/assets/{collapsible-oGbEvbe1.js → collapsible-CACHBRYZ.js} +1 -1
- package/dist/assets/{context-menu-DGz4_2hs.js → context-menu-CYbm4wBZ.js} +1 -1
- package/dist/assets/desktopHosts-DILvrAok.js +1 -0
- package/dist/assets/desktopRelayRestore-t8asCr3m.js +1 -0
- package/dist/assets/{diagram-Csk5GpDC.js → diagram-u0BC9uuY.js} +1 -1
- package/dist/assets/{dialog-rrqB-H6q.js → dialog-BwQ3rY_x.js} +1 -1
- package/dist/assets/dualLimitLru-D8Sphgua.js +1 -0
- package/dist/assets/{es-FE0sJ9EC.js → es-CRjOwvYM.js} +1 -1
- package/dist/assets/fileQueries-Bgly-E8f.js +1 -0
- package/dist/assets/{fr-DNDCSfGt.js → fr-D9taEN9L.js} +1 -1
- package/dist/assets/{gitApi-_P0FsoXe.js → gitApi-D1mvv14J.js} +1 -1
- package/dist/assets/imageSource-BlAJJUEI.js +1 -0
- package/dist/assets/{input-BO3r20op.js → input-CI730njs.js} +1 -1
- package/dist/assets/insertionBoundaries-BBn8YW82.js +4 -0
- package/dist/assets/{ja-BDwrk0cM.js → ja-CYQ5aoWp.js} +1 -1
- package/dist/assets/{ko-BeeU2o4I.js → ko-BwASV0Q0.js} +1 -1
- package/dist/assets/main-Cd7NwXx5.js +169 -0
- package/dist/assets/main-phpzFgMN.js +2 -0
- package/dist/assets/markdown-worker-CptoICmq.js +2 -0
- package/dist/assets/markdownHeightCache-DEjGKLv8.js +3 -0
- package/dist/assets/markdownTheme-DOfGg-A7.js +1 -0
- package/dist/assets/miniChat-D6UYe50H.js +2 -0
- package/dist/assets/mobile-w2Ed9I5O.js +2 -0
- package/dist/assets/{multirun-CxhTsmRq.js → multirun-CYuxJPBX.js} +1 -1
- package/dist/assets/persistence-YbQKzHUQ.js +3 -0
- package/dist/assets/{pl-BYuzpcI5.js → pl-DUI1vPil.js} +1 -1
- package/dist/assets/platform-DzsGqlGW.js +2 -0
- package/dist/assets/projectMeta-Cir66uF3.js +3 -0
- package/dist/assets/{pt-BR-Bn7cwEzv.js → pt-BR-CZQFJllP.js} +1 -1
- package/dist/assets/relay-image-stream-DM7atqJs.js +2 -0
- package/dist/assets/renderElectronMiniChatApp-BzYqqr9n.js +2 -0
- package/dist/assets/renderMobileApp-BvIE86_O.js +28 -0
- package/dist/assets/runtime-auth-D66yq6ie.js +1 -0
- package/dist/assets/runtime-fetch-zIalUnbb.js +1 -0
- package/dist/assets/runtime-switch-LvWDU-SL.js +1 -0
- package/dist/assets/runtimeConfig-BXf9GQ0Q.js +2 -0
- package/dist/assets/runtimeEndpointReset-lF5S7U4I.js +147 -0
- package/dist/assets/{sessionChangeSummary-DDVT7O5J.js → sessionChangeSummary-DQDZ5HW-.js} +1 -1
- package/dist/assets/{sessionLookup-C8VZn0gJ.js → sessionLookup-CNRDupYq.js} +1 -1
- package/dist/assets/sortable-tabs-strip-Dom6Sthu.js +1 -0
- package/dist/assets/src-AA-5nXsF.css +2 -0
- package/dist/assets/{store-n94dSb3M.js → store-CSnH_QXM.js} +2 -2
- package/dist/assets/terminalApi-noa4Cdbh.js +1 -0
- package/dist/assets/{textarea-BjX-KhMN.js → textarea-BfNtUdJE.js} +1 -1
- package/dist/assets/{tooltip-CQGqGAG_.js → tooltip-DPxumadF.js} +1 -1
- package/dist/assets/{uk-CwpbHEa3.js → uk-C1FYZlmA.js} +1 -1
- package/dist/assets/{url-BMjqccCo.js → url-CVhsI9kR.js} +1 -1
- package/dist/assets/{useAppFontEffects-BIFw36SA.js → useAppFontEffects-NVyoI_Ei.js} +32 -32
- package/dist/assets/useAssistantUIStore-CLPCNoj_.js +1 -0
- package/dist/assets/useDesktopWindowControlsLayout-CP3NmMYG.js +1 -0
- package/dist/assets/{useEffectiveDirectory-DJSYYkVp.js → useEffectiveDirectory-AUoqSyOJ.js} +1 -1
- package/dist/assets/useFontPreferences-CB-cGo3I.js +1 -0
- package/dist/assets/useMobileNavigationStore-DANrWekj.js +413 -0
- package/dist/assets/useSessionAutoCleanup-ClvQQPqQ.js +2 -0
- package/dist/assets/useUIStore-CxOoCv7B.js +1 -0
- package/dist/assets/useWorkerHighlightedLines-DOk4cAxv.js +7 -0
- package/dist/assets/{utils-81nUI_xp.js → utils-BHTVlVov.js} +1 -1
- package/dist/assets/{zh-CN-BpzFZ-LU.js → zh-CN-C96qP1ip.js} +1 -1
- package/dist/assets/{zh-TW-Bopgc5Eo.js → zh-TW-BsRk1Rbb.js} +1 -1
- package/dist/index.html +12 -9
- package/dist/mini-chat.html +12 -9
- package/dist/mobile.html +12 -9
- package/package.json +1 -1
- package/server/lib/assistant-delivery-parts.js +19 -1
- package/server/lib/assistants/DOCUMENTATION.md +39 -7
- package/server/lib/assistants/cards.js +16 -2
- package/server/lib/assistants/cards.test.js +16 -1
- package/server/lib/assistants/contact-attachments.http.test.js +436 -0
- package/server/lib/assistants/contact-attachments.js +814 -0
- package/server/lib/assistants/contact-attachments.p1.test.js +307 -0
- package/server/lib/assistants/contact-attachments.test.js +249 -0
- package/server/lib/assistants/contact-store.js +494 -25
- package/server/lib/assistants/contact-store.test.js +403 -1
- package/server/lib/assistants/contact-tools.js +6 -5
- package/server/lib/assistants/contact-tools.test.js +24 -6
- package/server/lib/assistants/contracts.js +41 -1
- package/server/lib/assistants/harness.test.js +124 -0
- package/server/lib/assistants/routes.js +46 -1
- package/server/lib/assistants/service.js +258 -66
- package/server/lib/assistants/service.test.js +400 -30
- package/server/lib/fs/prompt-attachment-store.js +663 -0
- package/server/lib/fs/prompt-attachment-store.test.js +263 -0
- package/server/lib/fs/routes.js +28 -72
- package/server/lib/fs/routes.test.js +15 -30
- package/server/lib/opencode/core-routes.js +2 -1
- package/dist/assets/AssistantView-YmeyxehG.js +0 -2
- package/dist/assets/DiagramView-BpZb-G9P.js +0 -1
- package/dist/assets/FadeInOnReveal-nf6-ZCrs.js +0 -1
- package/dist/assets/MarkdownRenderer-wpeGdciL.js +0 -2
- package/dist/assets/MarkdownRendererImpl-Peu0GVSQ.js +0 -7
- package/dist/assets/MobileDetailNavigation-CtYke_1C.js +0 -1
- package/dist/assets/MobileShareBridge-DIfOl8EZ.js +0 -4
- package/dist/assets/MobileSurface-_KoX75Ff.js +0 -2
- package/dist/assets/OnboardingScreen-BPusUgFG.js +0 -1
- package/dist/assets/SettingsView-BwQpbIZl.js +0 -25
- package/dist/assets/TerminalView-8402vpcO.js +0 -8
- package/dist/assets/ToolOutputDialog-B5RdkhsG.js +0 -10
- package/dist/assets/desktopHosts-BVJXQq1S.js +0 -1
- package/dist/assets/desktopRelayRestore-RDfuj1M7.js +0 -1
- package/dist/assets/fileQueries-BcdWOPlD.js +0 -1
- package/dist/assets/insertionBoundaries-CWoioTdE.js +0 -4
- package/dist/assets/main-CLj_WS1o.js +0 -2
- package/dist/assets/main-CnSZ89pD.js +0 -169
- package/dist/assets/markdown-worker-CZWE1F1M.js +0 -2
- package/dist/assets/markdownHeightCache-Cdd3hkMl.js +0 -3
- package/dist/assets/markdownTheme-DIB8WVmz.js +0 -2
- package/dist/assets/miniChat-B9c3y2pD.js +0 -2
- package/dist/assets/mobile-DvbXUfNS.js +0 -2
- package/dist/assets/persistence-CVTWFaqe.js +0 -3
- package/dist/assets/platform-BY_Gtsqr.js +0 -2
- package/dist/assets/projectMeta-Dl5iPCB6.js +0 -3
- package/dist/assets/renderElectronMiniChatApp-CkJ51qCh.js +0 -2
- package/dist/assets/renderMobileApp-DcjQPhWw.js +0 -28
- package/dist/assets/runtimeConfig-Bsk2kWsW.js +0 -2
- package/dist/assets/runtimeEndpointReset-BBLFgl85.js +0 -147
- package/dist/assets/sortable-tabs-strip-s7UUxsa6.js +0 -1
- package/dist/assets/src-BpHDMgud.css +0 -2
- package/dist/assets/terminalApi-C54_rKEK.js +0 -1
- package/dist/assets/useAssistantUIStore-Cg9bsT4b.js +0 -1
- package/dist/assets/useDesktopWindowControlsLayout-DoygEcN4.js +0 -1
- package/dist/assets/useFontPreferences-B5_apbBC.js +0 -1
- package/dist/assets/useMobileNavigationStore-DuTu0Mzh.js +0 -413
- package/dist/assets/useSessionAutoCleanup-C-L9teva.js +0 -1
- package/dist/assets/useUIStore-DwpQTjLO.js +0 -1
- package/dist/assets/useWorkerHighlightedLines-DxTfgdEi.js +0 -7
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { decodeDataUrlStrict } from '../fs/prompt-attachment-store.js';
|
|
1
3
|
import { parseContactCard, parseContactPart, serializeContactPart } from './cards.js';
|
|
2
4
|
|
|
5
|
+
/** Keep in sync with CONTACT_ATTACHMENT_HISTORICAL_MAX_BYTES in contact-attachments.js */
|
|
6
|
+
const FINGERPRINT_DATA_URL_MAX_BYTES = 56 * 1024 * 1024;
|
|
7
|
+
|
|
3
8
|
const json = (value) => JSON.stringify(value);
|
|
4
9
|
const parse = (value) => JSON.parse(value);
|
|
5
10
|
|
|
@@ -45,6 +50,12 @@ export const CONTACT_SCHEMA_SQL = `
|
|
|
45
50
|
after_ordinal INTEGER NOT NULL,
|
|
46
51
|
updated_at INTEGER NOT NULL
|
|
47
52
|
);
|
|
53
|
+
-- Contact transcript generation: bumps only on clear_chat_history / resetContact.
|
|
54
|
+
-- clear memory (context boundary) must not change this. Cursor keyset embeds it.
|
|
55
|
+
CREATE TABLE IF NOT EXISTS assistant_contact_generation (
|
|
56
|
+
assistant_id TEXT PRIMARY KEY,
|
|
57
|
+
generation INTEGER NOT NULL DEFAULT 0
|
|
58
|
+
);
|
|
48
59
|
`;
|
|
49
60
|
|
|
50
61
|
export const CONTACT_SETTLE_TEXT = Object.freeze({
|
|
@@ -53,23 +64,107 @@ export const CONTACT_SETTLE_TEXT = Object.freeze({
|
|
|
53
64
|
question: 'oc.settle.question',
|
|
54
65
|
});
|
|
55
66
|
|
|
67
|
+
/** List-card preview body bound (plain text only; no base64 / tool traces). */
|
|
68
|
+
export const CONTACT_PREVIEW_MAX_CHARS = 500;
|
|
69
|
+
/** How many newest contact rows to scan per assistant for a visible preview. */
|
|
70
|
+
export const CONTACT_PREVIEW_CANDIDATE_LIMIT = 24;
|
|
71
|
+
|
|
72
|
+
/** Frozen UI page contract defaults. */
|
|
73
|
+
export const CONTACT_PAGE_DEFAULT_LIMIT = 20;
|
|
74
|
+
export const CONTACT_PAGE_MAX_LIMIT = 100;
|
|
75
|
+
export const CONTACT_CURSOR_VERSION = 1;
|
|
76
|
+
|
|
56
77
|
const IN_FLIGHT_WATCH = new Set(['busy', 'question']);
|
|
57
78
|
|
|
79
|
+
const contactStoreError = (code, message) => {
|
|
80
|
+
const error = new Error(typeof message === 'string' && message.trim() ? message.trim() : code);
|
|
81
|
+
error.code = code;
|
|
82
|
+
return error;
|
|
83
|
+
};
|
|
84
|
+
|
|
58
85
|
export function ensureContactSchema(db) {
|
|
59
86
|
db.exec(CONTACT_SCHEMA_SQL);
|
|
60
87
|
const columns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant_contact_message')").all().map((column) => column.name));
|
|
61
88
|
if (!columns.has('from_assistant_id')) db.exec('ALTER TABLE assistant_contact_message ADD COLUMN from_assistant_id TEXT');
|
|
62
89
|
if (!columns.has('from_assistant_name')) db.exec('ALTER TABLE assistant_contact_message ADD COLUMN from_assistant_name TEXT');
|
|
90
|
+
// Attachment table ownership lives in contact-attachments.ensureContactAttachmentSchema.
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Current contact transcript generation for one assistant (default 0). */
|
|
94
|
+
export function getContactGeneration(db, assistantID) {
|
|
95
|
+
if (typeof assistantID !== 'string' || !assistantID) return 0;
|
|
96
|
+
const row = db.prepare(
|
|
97
|
+
'SELECT generation FROM assistant_contact_generation WHERE assistant_id=?',
|
|
98
|
+
).get(assistantID);
|
|
99
|
+
if (!row || !Number.isSafeInteger(Number(row.generation)) || Number(row.generation) < 0) return 0;
|
|
100
|
+
return Number(row.generation);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Atomically bump contact generation (clear chat history / reset only).
|
|
105
|
+
* Returns the new generation. clearContactMemory must never call this.
|
|
106
|
+
*/
|
|
107
|
+
export function bumpContactGeneration(db, assistantID) {
|
|
108
|
+
if (typeof assistantID !== 'string' || !assistantID) {
|
|
109
|
+
throw contactStoreError('validation_error', 'assistantID required');
|
|
110
|
+
}
|
|
111
|
+
db.prepare(
|
|
112
|
+
'INSERT INTO assistant_contact_generation(assistant_id, generation) VALUES (?, 1) ON CONFLICT(assistant_id) DO UPDATE SET generation=generation+1',
|
|
113
|
+
).run(assistantID);
|
|
114
|
+
return getContactGeneration(db, assistantID);
|
|
63
115
|
}
|
|
64
116
|
|
|
65
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Decode an opaque contact page cursor.
|
|
119
|
+
* v1: { v:1, assistantID, generation, ordinal, messageID }
|
|
120
|
+
* Legacy (pre-pagination clients): { ordinal, messageID } — treated as generation 0.
|
|
121
|
+
*/
|
|
122
|
+
export function decodeContactCursor(value) {
|
|
66
123
|
if (value == null || value === '') return null;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
124
|
+
let parsed;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(Buffer.from(String(value), 'base64url').toString('utf8'));
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
131
|
+
if (!Number.isSafeInteger(parsed.ordinal) || typeof parsed.messageID !== 'string' || !parsed.messageID) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const hasVersion = parsed.v != null;
|
|
135
|
+
if (hasVersion) {
|
|
136
|
+
if (parsed.v !== CONTACT_CURSOR_VERSION) return null;
|
|
137
|
+
if (typeof parsed.assistantID !== 'string' || !parsed.assistantID.trim()) return null;
|
|
138
|
+
if (!Number.isSafeInteger(parsed.generation) || parsed.generation < 0) return null;
|
|
139
|
+
return {
|
|
140
|
+
v: CONTACT_CURSOR_VERSION,
|
|
141
|
+
assistantID: parsed.assistantID.trim(),
|
|
142
|
+
generation: parsed.generation,
|
|
143
|
+
ordinal: parsed.ordinal,
|
|
144
|
+
messageID: parsed.messageID,
|
|
145
|
+
legacy: false,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// Legacy keyset without generation/assistant — only valid while generation is still 0.
|
|
149
|
+
return {
|
|
150
|
+
v: CONTACT_CURSOR_VERSION,
|
|
151
|
+
assistantID: null,
|
|
152
|
+
generation: 0,
|
|
153
|
+
ordinal: parsed.ordinal,
|
|
154
|
+
messageID: parsed.messageID,
|
|
155
|
+
legacy: true,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
71
158
|
|
|
72
|
-
|
|
159
|
+
export function encodeContactCursor({ assistantID, generation, ordinal, messageID }) {
|
|
160
|
+
return Buffer.from(json({
|
|
161
|
+
v: CONTACT_CURSOR_VERSION,
|
|
162
|
+
assistantID,
|
|
163
|
+
generation,
|
|
164
|
+
ordinal,
|
|
165
|
+
messageID,
|
|
166
|
+
})).toString('base64url');
|
|
167
|
+
}
|
|
73
168
|
|
|
74
169
|
const hydrateMessage = (db, row) => {
|
|
75
170
|
const parts = db.prepare(
|
|
@@ -116,19 +211,292 @@ export function getContactMessage(db, messageID) {
|
|
|
116
211
|
return hydrateMessage(db, row);
|
|
117
212
|
}
|
|
118
213
|
|
|
214
|
+
const isSettleOnlyText = (text) => {
|
|
215
|
+
const trimmed = typeof text === 'string' ? text.trim() : '';
|
|
216
|
+
return trimmed.startsWith('oc.settle.');
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/** Strip data-URLs / dense base64 so list previews never carry attachment bodies. */
|
|
220
|
+
export function sanitizeContactPreviewText(text, maxChars = CONTACT_PREVIEW_MAX_CHARS) {
|
|
221
|
+
const raw = typeof text === 'string' ? text : '';
|
|
222
|
+
const cleaned = raw
|
|
223
|
+
.replace(/data:[^\s]+/giu, ' ')
|
|
224
|
+
.replace(/(?:[A-Za-z0-9+/]{48,}={0,2})/g, ' ')
|
|
225
|
+
.replace(/\s+/gu, ' ')
|
|
226
|
+
.trim();
|
|
227
|
+
const limit = Number.isFinite(maxChars) && maxChars > 0 ? Math.floor(maxChars) : CONTACT_PREVIEW_MAX_CHARS;
|
|
228
|
+
return cleaned.length > limit ? cleaned.slice(0, limit) : cleaned;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Build AssistantDTO.latestMessagePreview from one hydrated contact message.
|
|
233
|
+
* Returns null when the row is not list-visible (internal settle-only, empty,
|
|
234
|
+
* unsupported role). Peer inbox rows map to role `user` (user-visible DM).
|
|
235
|
+
* Visible error bubbles keep real body text. Cards/files set fallbackKind.
|
|
236
|
+
*/
|
|
237
|
+
export function buildContactMessagePreview(message) {
|
|
238
|
+
if (!message || typeof message !== 'object') return null;
|
|
239
|
+
const sourceRole = message.role;
|
|
240
|
+
if (sourceRole !== 'user' && sourceRole !== 'assistant' && sourceRole !== 'peer') return null;
|
|
241
|
+
const parts = Array.isArray(message.parts) ? message.parts : [];
|
|
242
|
+
const textFromParts = parts
|
|
243
|
+
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
244
|
+
.map((part) => part.text)
|
|
245
|
+
.join('');
|
|
246
|
+
const rawText = (typeof message.text === 'string' && message.text) || textFromParts || '';
|
|
247
|
+
const trimmed = rawText.trim();
|
|
248
|
+
const files = parts.filter((part) => part?.type === 'file');
|
|
249
|
+
const cards = parts.filter((part) => part?.type === 'card');
|
|
250
|
+
// Assigned-session settle notices are internal transcript markers, not list copy.
|
|
251
|
+
if (isSettleOnlyText(trimmed) && files.length === 0 && cards.length === 0) return null;
|
|
252
|
+
|
|
253
|
+
let text = '';
|
|
254
|
+
let fallbackKind = null;
|
|
255
|
+
if (trimmed && !isSettleOnlyText(trimmed)) {
|
|
256
|
+
text = sanitizeContactPreviewText(trimmed);
|
|
257
|
+
}
|
|
258
|
+
if (!text && files.length > 0) {
|
|
259
|
+
const file = files[0];
|
|
260
|
+
const mime = typeof file.mime === 'string' ? file.mime : '';
|
|
261
|
+
fallbackKind = mime.startsWith('image/') ? 'image' : 'file';
|
|
262
|
+
const filename = typeof file.filename === 'string' ? file.filename.trim() : '';
|
|
263
|
+
text = sanitizeContactPreviewText(filename);
|
|
264
|
+
}
|
|
265
|
+
if (!text && !fallbackKind && cards.length > 0) {
|
|
266
|
+
const card = cards[0];
|
|
267
|
+
const cardType = card.cardType;
|
|
268
|
+
if (cardType === 'session' || cardType === 'assistant' || cardType === 'schedule') {
|
|
269
|
+
fallbackKind = cardType;
|
|
270
|
+
}
|
|
271
|
+
const label = typeof card.title === 'string' && card.title.trim()
|
|
272
|
+
? card.title
|
|
273
|
+
: (typeof card.name === 'string' ? card.name : '');
|
|
274
|
+
text = sanitizeContactPreviewText(label);
|
|
275
|
+
}
|
|
276
|
+
// Empty pure-card without title still surfaces the card kind for the list icon.
|
|
277
|
+
if (!text && !fallbackKind) return null;
|
|
278
|
+
if (!text && fallbackKind && cards.length > 0 && files.length === 0 && !trimmed) {
|
|
279
|
+
// keep empty text + fallbackKind
|
|
280
|
+
} else if (!text && !fallbackKind) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const messageID = typeof message.messageID === 'string' ? message.messageID : '';
|
|
285
|
+
const ordinal = Number(message.ordinal);
|
|
286
|
+
if (!messageID || !Number.isFinite(ordinal)) return null;
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
messageID,
|
|
290
|
+
ordinal,
|
|
291
|
+
// Peer DMs are user-visible inbox rows; list language is user|assistant only.
|
|
292
|
+
role: sourceRole === 'assistant' ? 'assistant' : 'user',
|
|
293
|
+
text: text || '',
|
|
294
|
+
fallbackKind,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const loadContactPartsByMessageIDs = (db, messageIDs) => {
|
|
299
|
+
const map = new Map();
|
|
300
|
+
const ids = (Array.isArray(messageIDs) ? messageIDs : [])
|
|
301
|
+
.filter((id) => typeof id === 'string' && id);
|
|
302
|
+
if (ids.length === 0) return map;
|
|
303
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
304
|
+
const rows = db.prepare(
|
|
305
|
+
`SELECT message_id, part_json FROM assistant_contact_part
|
|
306
|
+
WHERE message_id IN (${placeholders})
|
|
307
|
+
ORDER BY message_id ASC, ordinal ASC, part_id ASC`,
|
|
308
|
+
).all(...ids);
|
|
309
|
+
for (const row of rows) {
|
|
310
|
+
const part = parseContactPart(parse(row.part_json));
|
|
311
|
+
if (!part) continue;
|
|
312
|
+
const list = map.get(row.message_id);
|
|
313
|
+
if (list) list.push(part);
|
|
314
|
+
else map.set(row.message_id, [part]);
|
|
315
|
+
}
|
|
316
|
+
return map;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const messageFromPreviewRow = (row, parts) => {
|
|
320
|
+
const list = Array.isArray(parts) ? parts : [];
|
|
321
|
+
return {
|
|
322
|
+
messageID: row.message_id,
|
|
323
|
+
assistantID: row.assistant_id,
|
|
324
|
+
role: row.role,
|
|
325
|
+
ordinal: row.ordinal,
|
|
326
|
+
status: row.status,
|
|
327
|
+
parts: list,
|
|
328
|
+
text: list.filter((part) => part.type === 'text').map((part) => part.text).join(''),
|
|
329
|
+
};
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const pickPreviewFromCandidateRows = (rows, partsByMessage) => {
|
|
333
|
+
for (const row of rows) {
|
|
334
|
+
const preview = buildContactMessagePreview(
|
|
335
|
+
messageFromPreviewRow(row, partsByMessage.get(row.message_id) || []),
|
|
336
|
+
);
|
|
337
|
+
if (preview) return preview;
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Indexed newest-first probe for list-visible contact rows.
|
|
344
|
+
*
|
|
345
|
+
* - Uses `assistant_contact_message_page` (assistant_id, ordinal, message_id).
|
|
346
|
+
* - Limits to `candidateLimit` rows per assistant (no full-history sort).
|
|
347
|
+
* - Restricts to user/assistant/peer (preview-visible roles).
|
|
348
|
+
* - Drops pure internal settle markers (`oc.settle.*` text-only parts) in SQL
|
|
349
|
+
* so a long tail of settle rows cannot exhaust the LIMIT and hide the real
|
|
350
|
+
* latest user/assistant bubble. File/card settle hybrids still pass through
|
|
351
|
+
* for buildContactMessagePreview. Other empty/non-preview rows may still
|
|
352
|
+
* occupy a slot and force a deeper probe only within the same LIMIT.
|
|
353
|
+
*/
|
|
354
|
+
const CONTACT_PREVIEW_CANDIDATE_SQL = `
|
|
355
|
+
SELECT message_id, assistant_id, role, ordinal, status
|
|
356
|
+
FROM assistant_contact_message m
|
|
357
|
+
WHERE m.assistant_id = ?
|
|
358
|
+
AND m.role IN ('user', 'assistant', 'peer')
|
|
359
|
+
AND NOT (
|
|
360
|
+
EXISTS (
|
|
361
|
+
SELECT 1 FROM assistant_contact_part p
|
|
362
|
+
WHERE p.message_id = m.message_id
|
|
363
|
+
AND (
|
|
364
|
+
p.part_json LIKE '%"text":"oc.settle.complete"%'
|
|
365
|
+
OR p.part_json LIKE '%"text":"oc.settle.error"%'
|
|
366
|
+
OR p.part_json LIKE '%"text":"oc.settle.question"%'
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
AND NOT EXISTS (
|
|
370
|
+
SELECT 1 FROM assistant_contact_part p2
|
|
371
|
+
WHERE p2.message_id = m.message_id
|
|
372
|
+
AND (
|
|
373
|
+
p2.part_json LIKE '%"type":"file"%'
|
|
374
|
+
OR p2.part_json LIKE '%"type":"card"%'
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
ORDER BY m.ordinal DESC, m.message_id DESC
|
|
379
|
+
LIMIT ?
|
|
380
|
+
`;
|
|
381
|
+
|
|
382
|
+
const boundPreviewCandidateLimit = (candidateLimit) => {
|
|
383
|
+
if (Number.isFinite(candidateLimit) && candidateLimit > 0) {
|
|
384
|
+
return Math.min(Math.floor(candidateLimit), 100);
|
|
385
|
+
}
|
|
386
|
+
return CONTACT_PREVIEW_CANDIDATE_LIMIT;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Authoritative list preview for one assistant: newest visible contact row by
|
|
391
|
+
* (ordinal DESC, message_id DESC). One indexed probe + parts for those IDs only.
|
|
392
|
+
*/
|
|
393
|
+
export function getLatestContactMessagePreview(db, assistantID, {
|
|
394
|
+
candidateLimit = CONTACT_PREVIEW_CANDIDATE_LIMIT,
|
|
395
|
+
} = {}) {
|
|
396
|
+
if (typeof assistantID !== 'string' || !assistantID) return null;
|
|
397
|
+
const limit = boundPreviewCandidateLimit(candidateLimit);
|
|
398
|
+
const rows = db.prepare(CONTACT_PREVIEW_CANDIDATE_SQL).all(assistantID, limit);
|
|
399
|
+
if (rows.length === 0) return null;
|
|
400
|
+
const partsByMessage = loadContactPartsByMessageIDs(db, rows.map((row) => row.message_id));
|
|
401
|
+
return pickPreviewFromCandidateRows(rows, partsByMessage);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Batch previews for snapshot (catalog size ≤100).
|
|
406
|
+
*
|
|
407
|
+
* Reuses **one** prepared statement: per assistant_id, indexed
|
|
408
|
+
* `ORDER BY ordinal DESC, message_id DESC LIMIT candidateLimit` (not a window
|
|
409
|
+
* over the full multi-assistant history). Collects bounded candidate IDs, then
|
|
410
|
+
* **one** parts IN-query for all of them. No HTTP N+1; no full-table rank sort.
|
|
411
|
+
*
|
|
412
|
+
* Returns Map(assistantID → preview | null). Missing / empty assistants → null.
|
|
413
|
+
*/
|
|
414
|
+
export function getLatestContactMessagePreviews(db, assistantIDs, {
|
|
415
|
+
candidateLimit = CONTACT_PREVIEW_CANDIDATE_LIMIT,
|
|
416
|
+
} = {}) {
|
|
417
|
+
const result = new Map();
|
|
418
|
+
const ids = [...new Set(
|
|
419
|
+
(Array.isArray(assistantIDs) ? assistantIDs : [])
|
|
420
|
+
.filter((id) => typeof id === 'string' && id),
|
|
421
|
+
)];
|
|
422
|
+
for (const id of ids) result.set(id, null);
|
|
423
|
+
if (ids.length === 0) return result;
|
|
424
|
+
|
|
425
|
+
const limit = boundPreviewCandidateLimit(candidateLimit);
|
|
426
|
+
const probe = db.prepare(CONTACT_PREVIEW_CANDIDATE_SQL);
|
|
427
|
+
const candidates = [];
|
|
428
|
+
for (const assistantID of ids) {
|
|
429
|
+
const rows = probe.all(assistantID, limit);
|
|
430
|
+
for (const row of rows) candidates.push(row);
|
|
431
|
+
}
|
|
432
|
+
if (candidates.length === 0) return result;
|
|
433
|
+
|
|
434
|
+
const partsByMessage = loadContactPartsByMessageIDs(
|
|
435
|
+
db,
|
|
436
|
+
candidates.map((row) => row.message_id),
|
|
437
|
+
);
|
|
438
|
+
const byAssistant = new Map();
|
|
439
|
+
for (const row of candidates) {
|
|
440
|
+
const list = byAssistant.get(row.assistant_id);
|
|
441
|
+
if (list) list.push(row);
|
|
442
|
+
else byAssistant.set(row.assistant_id, [row]);
|
|
443
|
+
}
|
|
444
|
+
for (const [assistantID, rows] of byAssistant) {
|
|
445
|
+
result.set(assistantID, pickPreviewFromCandidateRows(rows, partsByMessage));
|
|
446
|
+
}
|
|
447
|
+
return result;
|
|
448
|
+
}
|
|
449
|
+
|
|
119
450
|
/** Stable fingerprint for idempotent contact admission replay. */
|
|
451
|
+
/**
|
|
452
|
+
* Canonical fingerprint for idempotent admission.
|
|
453
|
+
* File parts prefer sha256+size+mime+filename (stable across attachmentID/url forms).
|
|
454
|
+
* Legacy data URLs are normalized by decoded content hash when possible.
|
|
455
|
+
*/
|
|
120
456
|
export function contactPartsFingerprint(parts) {
|
|
121
457
|
const list = Array.isArray(parts) ? parts : [];
|
|
122
458
|
return json(list.map((part) => {
|
|
123
459
|
if (!part || typeof part !== 'object') return null;
|
|
124
460
|
if (part.type === 'text') return { type: 'text', text: typeof part.text === 'string' ? part.text : '' };
|
|
125
461
|
if (part.type === 'file') {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
462
|
+
const mime = typeof part.mime === 'string' ? part.mime : '';
|
|
463
|
+
const filename = typeof part.filename === 'string' ? part.filename : '';
|
|
464
|
+
const sha256 = typeof part.sha256 === 'string' ? part.sha256.toLowerCase() : '';
|
|
465
|
+
const size = Number.isSafeInteger(part.size) ? part.size : null;
|
|
466
|
+
// Content identity only when hash/size known — attachmentID is ownership, not replay key.
|
|
467
|
+
if (sha256 || size != null) {
|
|
468
|
+
return {
|
|
469
|
+
type: 'file',
|
|
470
|
+
mime,
|
|
471
|
+
filename,
|
|
472
|
+
sha256,
|
|
473
|
+
size,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
const url = typeof part.url === 'string' ? part.url : '';
|
|
477
|
+
if (url.startsWith('data:')) {
|
|
478
|
+
try {
|
|
479
|
+
// Reuse fs-store strict decoder (same alphabet / padding / charset rules).
|
|
480
|
+
const decoded = decodeDataUrlStrict(url, {
|
|
481
|
+
mode: 'migration',
|
|
482
|
+
maxBytes: FINGERPRINT_DATA_URL_MAX_BYTES,
|
|
483
|
+
});
|
|
484
|
+
return {
|
|
485
|
+
type: 'file',
|
|
486
|
+
mime: mime || decoded.mime,
|
|
487
|
+
filename,
|
|
488
|
+
sha256: crypto.createHash('sha256').update(decoded.buffer).digest('hex'),
|
|
489
|
+
size: decoded.buffer.length,
|
|
490
|
+
};
|
|
491
|
+
} catch {
|
|
492
|
+
return { type: 'file', mime, filename, sha256: '', size: null, urlLen: url.length };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// Ref without hash/size: ownership id is not content identity (cannot key replay alone).
|
|
496
|
+
if (typeof part.attachmentID === 'string' && part.attachmentID) {
|
|
497
|
+
return { type: 'file', mime, filename, sha256: '', size: null, refOnly: true };
|
|
498
|
+
}
|
|
499
|
+
return { type: 'file', mime, filename, sha256: '', size: null };
|
|
132
500
|
}
|
|
133
501
|
return part;
|
|
134
502
|
}));
|
|
@@ -158,35 +526,136 @@ export function insertContactMessage(db, {
|
|
|
158
526
|
});
|
|
159
527
|
}
|
|
160
528
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
529
|
+
/**
|
|
530
|
+
* Page the contact transcript (UI contract).
|
|
531
|
+
* - Ascending messages on the page; keyset is (ordinal DESC, messageID DESC) for older pages.
|
|
532
|
+
* - Default limit 20, max 100.
|
|
533
|
+
* - `before` and `messageID` are mutually exclusive.
|
|
534
|
+
* - Exact `messageID` returns at most one row for this assistant, nextCursor null, complete true.
|
|
535
|
+
* - Stale generation in cursor → contact_generation_conflict.
|
|
536
|
+
*/
|
|
537
|
+
export function listContactMessages(db, assistantID, {
|
|
538
|
+
before,
|
|
539
|
+
limit = CONTACT_PAGE_DEFAULT_LIMIT,
|
|
540
|
+
messageID = null,
|
|
541
|
+
} = {}) {
|
|
542
|
+
if (typeof assistantID !== 'string' || !assistantID) {
|
|
543
|
+
throw contactStoreError('validation_error', 'assistantID required');
|
|
544
|
+
}
|
|
545
|
+
const hasBefore = !(before == null || before === '');
|
|
546
|
+
const hasExact = !(messageID == null || messageID === '');
|
|
547
|
+
if (hasBefore && hasExact) {
|
|
548
|
+
throw contactStoreError('validation_error', 'before and messageID are mutually exclusive');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
let parsedLimit = limit;
|
|
552
|
+
if (parsedLimit == null || parsedLimit === '') parsedLimit = CONTACT_PAGE_DEFAULT_LIMIT;
|
|
553
|
+
parsedLimit = typeof parsedLimit === 'number' ? parsedLimit : Number(String(parsedLimit).trim());
|
|
554
|
+
if (!Number.isInteger(parsedLimit) || parsedLimit < 1 || parsedLimit > CONTACT_PAGE_MAX_LIMIT) {
|
|
555
|
+
throw contactStoreError('validation_error', `limit must be an integer from 1 to ${CONTACT_PAGE_MAX_LIMIT}`);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const generation = getContactGeneration(db, assistantID);
|
|
559
|
+
|
|
560
|
+
if (hasExact) {
|
|
561
|
+
if (typeof messageID !== 'string' || !messageID.trim()) {
|
|
562
|
+
throw contactStoreError('validation_error', 'messageID must be a non-empty string');
|
|
563
|
+
}
|
|
564
|
+
const row = db.prepare(
|
|
565
|
+
'SELECT * FROM assistant_contact_message WHERE assistant_id=? AND message_id=?',
|
|
566
|
+
).get(assistantID, messageID.trim());
|
|
567
|
+
return {
|
|
568
|
+
messages: row ? [hydrateMessage(db, row)] : [],
|
|
569
|
+
nextCursor: null,
|
|
570
|
+
complete: true,
|
|
571
|
+
generation,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
let cursor = null;
|
|
576
|
+
if (hasBefore) {
|
|
577
|
+
cursor = decodeContactCursor(before);
|
|
578
|
+
if (!cursor) {
|
|
579
|
+
throw contactStoreError('validation_error', 'invalid contact messages cursor');
|
|
580
|
+
}
|
|
581
|
+
if (cursor.assistantID && cursor.assistantID !== assistantID) {
|
|
582
|
+
throw contactStoreError('validation_error', 'cursor assistantID does not match path');
|
|
583
|
+
}
|
|
584
|
+
if (cursor.generation !== generation) {
|
|
585
|
+
throw contactStoreError(
|
|
586
|
+
'contact_generation_conflict',
|
|
587
|
+
'Contact transcript generation changed; refetch from the start.',
|
|
588
|
+
);
|
|
589
|
+
}
|
|
167
590
|
}
|
|
591
|
+
|
|
168
592
|
const rows = cursor
|
|
169
593
|
? db.prepare(
|
|
170
594
|
'SELECT * FROM assistant_contact_message WHERE assistant_id=? AND (ordinal<? OR (ordinal=? AND message_id<?)) ORDER BY ordinal DESC, message_id DESC LIMIT ?',
|
|
171
|
-
).all(assistantID, cursor.ordinal, cursor.ordinal, cursor.messageID,
|
|
595
|
+
).all(assistantID, cursor.ordinal, cursor.ordinal, cursor.messageID, parsedLimit + 1)
|
|
172
596
|
: db.prepare(
|
|
173
597
|
'SELECT * FROM assistant_contact_message WHERE assistant_id=? ORDER BY ordinal DESC, message_id DESC LIMIT ?',
|
|
174
|
-
).all(assistantID,
|
|
175
|
-
const page = rows.slice(0,
|
|
176
|
-
const
|
|
598
|
+
).all(assistantID, parsedLimit + 1);
|
|
599
|
+
const page = rows.slice(0, parsedLimit);
|
|
600
|
+
const oldest = page[page.length - 1];
|
|
601
|
+
const nextCursor = rows.length > parsedLimit && oldest
|
|
602
|
+
? encodeContactCursor({
|
|
603
|
+
assistantID,
|
|
604
|
+
generation,
|
|
605
|
+
ordinal: oldest.ordinal,
|
|
606
|
+
messageID: oldest.message_id,
|
|
607
|
+
})
|
|
608
|
+
: null;
|
|
177
609
|
return {
|
|
178
610
|
messages: [...page].reverse().map((row) => hydrateMessage(db, row)),
|
|
179
611
|
nextCursor,
|
|
180
612
|
complete: nextCursor === null,
|
|
613
|
+
generation,
|
|
181
614
|
};
|
|
182
615
|
}
|
|
183
616
|
|
|
184
|
-
|
|
185
|
-
|
|
617
|
+
/**
|
|
618
|
+
* Wipe contact transcript rows for one assistant.
|
|
619
|
+
*
|
|
620
|
+
* Bounded tool wipe (`upToOrdinal` = wiping turn's user ordinal):
|
|
621
|
+
* - **Keep** only `role='user'` rows with `ordinal >= ceiling` (the wipe request
|
|
622
|
+
* itself plus later-admitted queued users) and their parts / message IDs.
|
|
623
|
+
* - **Delete** every other row at any ordinal: prior users, and all
|
|
624
|
+
* assistant / peer / error / card rows — including late writes from an
|
|
625
|
+
* earlier still-running lane whose ordinal landed after the wipe user.
|
|
626
|
+
* Keeping the wipe user avoids delete+re-admit reordering queued users before it.
|
|
627
|
+
*
|
|
628
|
+
* Direct API callers without a turn ceiling omit `upToOrdinal` and delete every
|
|
629
|
+
* row (existing contract; concurrent in-flight admissions are not preserved).
|
|
630
|
+
* Watches and the LLM context boundary always clear on wipe.
|
|
631
|
+
*/
|
|
632
|
+
export function deleteContactMessages(db, assistantID, { upToOrdinal = null } = {}) {
|
|
633
|
+
const parsedCeiling = upToOrdinal == null || upToOrdinal === ''
|
|
634
|
+
? NaN
|
|
635
|
+
: Number(upToOrdinal);
|
|
636
|
+
const bounded = Number.isFinite(parsedCeiling);
|
|
637
|
+
// Single delete set drives both parts and message rows.
|
|
638
|
+
const ids = bounded
|
|
639
|
+
? db.prepare(
|
|
640
|
+
`SELECT message_id FROM assistant_contact_message
|
|
641
|
+
WHERE assistant_id=?
|
|
642
|
+
AND NOT (role = 'user' AND ordinal >= ?)`,
|
|
643
|
+
).all(assistantID, parsedCeiling).map((row) => row.message_id)
|
|
644
|
+
: db.prepare(
|
|
645
|
+
'SELECT message_id FROM assistant_contact_message WHERE assistant_id=?',
|
|
646
|
+
).all(assistantID).map((row) => row.message_id);
|
|
186
647
|
for (const messageID of ids) {
|
|
187
648
|
db.prepare('DELETE FROM assistant_contact_part WHERE message_id=?').run(messageID);
|
|
188
649
|
}
|
|
189
|
-
|
|
650
|
+
if (bounded) {
|
|
651
|
+
db.prepare(
|
|
652
|
+
`DELETE FROM assistant_contact_message
|
|
653
|
+
WHERE assistant_id=?
|
|
654
|
+
AND NOT (role = 'user' AND ordinal >= ?)`,
|
|
655
|
+
).run(assistantID, parsedCeiling);
|
|
656
|
+
} else {
|
|
657
|
+
db.prepare('DELETE FROM assistant_contact_message WHERE assistant_id=?').run(assistantID);
|
|
658
|
+
}
|
|
190
659
|
db.prepare('DELETE FROM assistant_contact_watch WHERE assistant_id=?').run(assistantID);
|
|
191
660
|
db.prepare('DELETE FROM assistant_contact_context_boundary WHERE assistant_id=?').run(assistantID);
|
|
192
661
|
}
|