@openchambery/web 1.19.12 → 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.
- package/dist/assets/{AgentAvatar-CX0a1rKE.js → AgentAvatar-mbvX7YlW.js} +1 -1
- package/dist/assets/AssistantView-D5c557Oe.js +2 -0
- package/dist/assets/DiagramView-CjNUrHsU.js +1 -0
- package/dist/assets/FadeInOnReveal-moBbL8Ll.js +1 -0
- package/dist/assets/{Icon-BYM02QyU.js → Icon-CkzzhaLr.js} +1 -1
- package/dist/assets/MarkdownRenderer-BHfbAwVO.js +2 -0
- package/dist/assets/MarkdownRendererImpl-CkBoUibc.js +7 -0
- package/dist/assets/{MarkstreamRendererImpl-BFdJzxia.js → MarkstreamRendererImpl-BrXUOI9g.js} +1 -1
- package/dist/assets/MobileDetailNavigation-aY9-HwBN.js +1 -0
- package/dist/assets/MobileShareBridge-CRt_8DmM.js +4 -0
- package/dist/assets/MobileSurface-CIhtF-CU.js +2 -0
- package/dist/assets/{MultiRunWindow-C3p3lj3U.js → MultiRunWindow-DDq6C8t7.js} +1 -1
- package/dist/assets/OnboardingScreen-j0iCyH7f.js +1 -0
- package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-CDC5B3RH.js} +1 -1
- package/dist/assets/SettingsView-DFWWMvAj.js +25 -0
- package/dist/assets/{SettingsWindow-8IDuPZA5.js → SettingsWindow-CnYfBplB.js} +1 -1
- package/dist/assets/TerminalView-D5CQFVVm.js +8 -0
- package/dist/assets/ToolOutputDialog-VMI7TQFT.js +10 -0
- package/dist/assets/{appThemeRegistry-B87OLIgc.js → appThemeRegistry-UIrP0-5h.js} +1 -1
- package/dist/assets/assistant-attachment-cache-C0aRnFkj.js +1 -0
- package/dist/assets/{button-C0cth9tl.js → button-xx1UOBA5.js} +1 -1
- package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-CUasgPiF.js} +1 -1
- package/dist/assets/{collapsible-DFT9cE80.js → collapsible-CACHBRYZ.js} +1 -1
- package/dist/assets/{context-menu-CAp74NfV.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-BryzMuta.js → diagram-u0BC9uuY.js} +1 -1
- package/dist/assets/{dialog-CgbV5ljg.js → dialog-BwQ3rY_x.js} +1 -1
- package/dist/assets/dualLimitLru-D8Sphgua.js +1 -0
- package/dist/assets/{es-BHqnSjoZ.js → es-CRjOwvYM.js} +1 -1
- package/dist/assets/fileQueries-Bgly-E8f.js +1 -0
- package/dist/assets/{fr-BfrlJY9i.js → fr-D9taEN9L.js} +1 -1
- package/dist/assets/{gitApi-tTtHQVA3.js → gitApi-Bu4W3bgD.js} +1 -1
- package/dist/assets/imageSource-BlAJJUEI.js +1 -0
- package/dist/assets/{input-DFLpqjGR.js → input-CI730njs.js} +1 -1
- package/dist/assets/insertionBoundaries-B1AqxsBD.js +4 -0
- package/dist/assets/{ja-DP-7qt-x.js → ja-CYQ5aoWp.js} +1 -1
- package/dist/assets/{ko-E2B5vGQP.js → ko-BwASV0Q0.js} +1 -1
- package/dist/assets/main-CqcB6tKc.js +169 -0
- package/dist/assets/main-DG-k0j8A.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-BQtYQrsh.js +2 -0
- package/dist/assets/mobile-8iyqtXkr.js +2 -0
- package/dist/assets/{multirun-B0V3hdnD.js → multirun-CG_FCb_Z.js} +1 -1
- package/dist/assets/persistence-YbQKzHUQ.js +3 -0
- package/dist/assets/{pl-r8X5gRM5.js → pl-DUI1vPil.js} +1 -1
- package/dist/assets/platform-DzsGqlGW.js +2 -0
- package/dist/assets/projectMeta-DMpMDWE1.js +3 -0
- package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-CZQFJllP.js} +1 -1
- package/dist/assets/relay-image-stream-DM7atqJs.js +2 -0
- package/dist/assets/renderElectronMiniChatApp-DBzRdJl8.js +2 -0
- package/dist/assets/renderMobileApp-CIseqCyn.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-D8nJYB-e.js +2 -0
- package/dist/assets/runtimeEndpointReset-CSub_ecV.js +147 -0
- package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DQDZ5HW-.js} +1 -1
- package/dist/assets/{sessionLookup-B6P_aCun.js → sessionLookup-BiBD-4wC.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-BDFdnk6l.js → store-CSnH_QXM.js} +2 -2
- package/dist/assets/terminalApi-noa4Cdbh.js +1 -0
- package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BfNtUdJE.js} +1 -1
- package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-DPxumadF.js} +1 -1
- package/dist/assets/{uk-Di0vl9V-.js → uk-C1FYZlmA.js} +1 -1
- package/dist/assets/{url-Pk6tQ1EZ.js → url-CVhsI9kR.js} +1 -1
- package/dist/assets/{useAppFontEffects-Dmes1chr.js → useAppFontEffects-qHt5uaFN.js} +32 -32
- package/dist/assets/useAssistantUIStore-CLPCNoj_.js +1 -0
- package/dist/assets/useDesktopWindowControlsLayout-BipyIXiK.js +1 -0
- package/dist/assets/{useEffectiveDirectory-CU2ChP0J.js → useEffectiveDirectory-DSNCCbCt.js} +1 -1
- package/dist/assets/useFontPreferences-CB-cGo3I.js +1 -0
- package/dist/assets/useMobileNavigationStore-C1NJGAik.js +413 -0
- package/dist/assets/useSessionAutoCleanup-Dcn6Unvd.js +2 -0
- package/dist/assets/useUIStore-CxOoCv7B.js +1 -0
- package/dist/assets/useWorkerHighlightedLines-CzpT4M7V.js +7 -0
- package/dist/assets/{utils-BiK3tBGM.js → utils-BHTVlVov.js} +1 -1
- package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-C96qP1ip.js} +1 -1
- package/dist/assets/{zh-TW-oDRZ1-yb.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/index.js +19 -0
- package/server/lib/assistant-delivery-parts.js +19 -1
- package/server/lib/assistants/DOCUMENTATION.md +56 -11
- package/server/lib/assistants/assign.js +455 -30
- package/server/lib/assistants/assign.test.js +351 -0
- package/server/lib/assistants/cards.js +16 -2
- package/server/lib/assistants/cards.test.js +16 -1
- package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
- 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 +552 -19
- package/server/lib/assistants/contact-store.test.js +415 -1
- package/server/lib/assistants/contact-tools.js +114 -14
- package/server/lib/assistants/contact-tools.test.js +135 -6
- package/server/lib/assistants/contracts.js +42 -2
- package/server/lib/assistants/harness.js +4 -1
- package/server/lib/assistants/harness.test.js +124 -0
- package/server/lib/assistants/routes.js +46 -1
- package/server/lib/assistants/service.js +569 -84
- package/server/lib/assistants/service.test.js +936 -37
- 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/llm/catalog.js +91 -16
- package/server/lib/llm/catalog.test.js +152 -11
- package/server/lib/notifications/APNS.md +20 -4
- package/server/lib/notifications/DOCUMENTATION.md +5 -0
- package/server/lib/notifications/apns-runtime.js +136 -18
- package/server/lib/notifications/apns-runtime.test.js +128 -0
- package/server/lib/notifications/apns-titles.js +21 -1
- package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
- package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
- package/server/lib/notifications/runtime.js +93 -2
- package/server/lib/notifications/runtime.test.js +167 -4
- package/server/lib/opencode/core-routes.js +2 -1
- package/server/lib/opencode/settings-helpers.js +12 -0
- package/server/lib/opencode/settings-runtime.js +16 -0
- package/server/lib/opencode/settings-runtime.test.js +21 -0
- package/server/lib/scheduled-tasks/runtime.js +21 -0
- package/server/lib/scheduled-tasks/runtime.test.js +24 -0
- package/dist/assets/AssistantView-DxIghtun.js +0 -2
- package/dist/assets/DiagramView-BeUgp_Y0.js +0 -1
- package/dist/assets/FadeInOnReveal-CTlNE4Hg.js +0 -1
- package/dist/assets/MarkdownRenderer-C4MGa_kQ.js +0 -2
- package/dist/assets/MarkdownRendererImpl-DB4Q3kGa.js +0 -7
- package/dist/assets/MobileDetailNavigation-B_RwWOwv.js +0 -1
- package/dist/assets/MobileShareBridge-UiOLx0-2.js +0 -4
- package/dist/assets/MobileSurface-CNChj_dX.js +0 -2
- package/dist/assets/OnboardingScreen-DS2nugLX.js +0 -1
- package/dist/assets/SettingsView-BXfKsz0J.js +0 -25
- package/dist/assets/TerminalView-BiKCueth.js +0 -8
- package/dist/assets/ToolOutputDialog-BC2F_Fdl.js +0 -10
- package/dist/assets/desktopHosts-Dc2PS66M.js +0 -1
- package/dist/assets/desktopRelayRestore-T9y0orqa.js +0 -1
- package/dist/assets/fileQueries-BY-9OWzp.js +0 -1
- package/dist/assets/insertionBoundaries-CCQpZ5zn.js +0 -4
- package/dist/assets/main-C_PUoT3E.js +0 -169
- package/dist/assets/main-Dkg31fRY.js +0 -2
- package/dist/assets/markdown-worker-CTsrtpPh.js +0 -2
- package/dist/assets/markdownHeightCache-CYDlisxr.js +0 -3
- package/dist/assets/markdownTheme-DyJ4Gfcj.js +0 -2
- package/dist/assets/miniChat-C9QMj8T8.js +0 -2
- package/dist/assets/mobile-Z_X7CbzB.js +0 -2
- package/dist/assets/persistence-DdUac14I.js +0 -3
- package/dist/assets/platform-B4KKLnZ5.js +0 -2
- package/dist/assets/projectMeta-DRW9MQ4z.js +0 -3
- package/dist/assets/renderElectronMiniChatApp-Dn1Su1dd.js +0 -2
- package/dist/assets/renderMobileApp-BG1JJt1Q.js +0 -28
- package/dist/assets/runtimeConfig-C6matIEB.js +0 -2
- package/dist/assets/runtimeEndpointReset-kgwmUJDb.js +0 -147
- package/dist/assets/sortable-tabs-strip-C8_9kHqu.js +0 -1
- package/dist/assets/src-B-P5qIBc.css +0 -2
- package/dist/assets/terminalApi-947l7Ra3.js +0 -1
- package/dist/assets/useAssistantUIStore-me6s63b2.js +0 -1
- package/dist/assets/useDesktopWindowControlsLayout-DuZ6URPR.js +0 -1
- package/dist/assets/useFontPreferences-BCOBlaAd.js +0 -1
- package/dist/assets/useMobileNavigationStore-C8_J-qTp.js +0 -413
- package/dist/assets/useSessionAutoCleanup-FOQeu1_T.js +0 -1
- package/dist/assets/useUIStore-CuaA8-Hp.js +0 -1
- package/dist/assets/useWorkerHighlightedLines-GBqKnki1.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(
|
|
@@ -108,6 +203,305 @@ export function nextContactOrdinal(db, assistantID) {
|
|
|
108
203
|
).get(assistantID).next);
|
|
109
204
|
}
|
|
110
205
|
|
|
206
|
+
/** Load one contact row by message id (null when missing). */
|
|
207
|
+
export function getContactMessage(db, messageID) {
|
|
208
|
+
if (typeof messageID !== 'string' || !messageID) return null;
|
|
209
|
+
const row = db.prepare('SELECT * FROM assistant_contact_message WHERE message_id=?').get(messageID);
|
|
210
|
+
if (!row) return null;
|
|
211
|
+
return hydrateMessage(db, row);
|
|
212
|
+
}
|
|
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
|
+
|
|
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
|
+
*/
|
|
456
|
+
export function contactPartsFingerprint(parts) {
|
|
457
|
+
const list = Array.isArray(parts) ? parts : [];
|
|
458
|
+
return json(list.map((part) => {
|
|
459
|
+
if (!part || typeof part !== 'object') return null;
|
|
460
|
+
if (part.type === 'text') return { type: 'text', text: typeof part.text === 'string' ? part.text : '' };
|
|
461
|
+
if (part.type === 'file') {
|
|
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 };
|
|
500
|
+
}
|
|
501
|
+
return part;
|
|
502
|
+
}));
|
|
503
|
+
}
|
|
504
|
+
|
|
111
505
|
export function insertContactMessage(db, {
|
|
112
506
|
messageID,
|
|
113
507
|
assistantID,
|
|
@@ -132,35 +526,136 @@ export function insertContactMessage(db, {
|
|
|
132
526
|
});
|
|
133
527
|
}
|
|
134
528
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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');
|
|
141
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
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
142
592
|
const rows = cursor
|
|
143
593
|
? db.prepare(
|
|
144
594
|
'SELECT * FROM assistant_contact_message WHERE assistant_id=? AND (ordinal<? OR (ordinal=? AND message_id<?)) ORDER BY ordinal DESC, message_id DESC LIMIT ?',
|
|
145
|
-
).all(assistantID, cursor.ordinal, cursor.ordinal, cursor.messageID,
|
|
595
|
+
).all(assistantID, cursor.ordinal, cursor.ordinal, cursor.messageID, parsedLimit + 1)
|
|
146
596
|
: db.prepare(
|
|
147
597
|
'SELECT * FROM assistant_contact_message WHERE assistant_id=? ORDER BY ordinal DESC, message_id DESC LIMIT ?',
|
|
148
|
-
).all(assistantID,
|
|
149
|
-
const page = rows.slice(0,
|
|
150
|
-
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;
|
|
151
609
|
return {
|
|
152
610
|
messages: [...page].reverse().map((row) => hydrateMessage(db, row)),
|
|
153
611
|
nextCursor,
|
|
154
612
|
complete: nextCursor === null,
|
|
613
|
+
generation,
|
|
155
614
|
};
|
|
156
615
|
}
|
|
157
616
|
|
|
158
|
-
|
|
159
|
-
|
|
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);
|
|
160
647
|
for (const messageID of ids) {
|
|
161
648
|
db.prepare('DELETE FROM assistant_contact_part WHERE message_id=?').run(messageID);
|
|
162
649
|
}
|
|
163
|
-
|
|
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
|
+
}
|
|
164
659
|
db.prepare('DELETE FROM assistant_contact_watch WHERE assistant_id=?').run(assistantID);
|
|
165
660
|
db.prepare('DELETE FROM assistant_contact_context_boundary WHERE assistant_id=?').run(assistantID);
|
|
166
661
|
}
|
|
@@ -338,6 +833,44 @@ export function trimContactHistoryForLlm(messages, {
|
|
|
338
833
|
* rows (queued turns) must not be injected into this turn; assistant rows written
|
|
339
834
|
* after that ordinal by a prior completed lane turn still enter the window.
|
|
340
835
|
*/
|
|
836
|
+
/**
|
|
837
|
+
* In-process contact-turn activity (not SQLite). Process memory only: APP
|
|
838
|
+
* restart while the server keeps running can rehydrate from snapshot; a server
|
|
839
|
+
* process restart clears every active turn so the green dot cannot stick forever.
|
|
840
|
+
* Status ladder: admission → queued → running → settled (removed).
|
|
841
|
+
*/
|
|
842
|
+
export const CONTACT_ACTIVE_TURN_STATUSES = Object.freeze(['queued', 'running']);
|
|
843
|
+
|
|
844
|
+
export function createActiveContactTurn({ turnID, messageID, status = 'queued', admittedAt }) {
|
|
845
|
+
if (typeof turnID !== 'string' || !turnID.trim()) return null;
|
|
846
|
+
if (typeof messageID !== 'string' || !messageID.trim()) return null;
|
|
847
|
+
if (!CONTACT_ACTIVE_TURN_STATUSES.includes(status)) return null;
|
|
848
|
+
const at = Number(admittedAt);
|
|
849
|
+
if (!Number.isFinite(at)) return null;
|
|
850
|
+
return {
|
|
851
|
+
turnID: turnID.trim(),
|
|
852
|
+
messageID: messageID.trim(),
|
|
853
|
+
status,
|
|
854
|
+
admittedAt: Math.trunc(at),
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Earliest admitted active turn; running preferred when admittedAt ties. */
|
|
859
|
+
export function projectActiveContactTurn(activeTurns) {
|
|
860
|
+
const list = Array.isArray(activeTurns)
|
|
861
|
+
? activeTurns
|
|
862
|
+
: (activeTurns instanceof Map ? [...activeTurns.values()] : []);
|
|
863
|
+
const valid = list
|
|
864
|
+
.map((item) => createActiveContactTurn(item))
|
|
865
|
+
.filter(Boolean)
|
|
866
|
+
.sort((left, right) => {
|
|
867
|
+
if (left.admittedAt !== right.admittedAt) return left.admittedAt - right.admittedAt;
|
|
868
|
+
if (left.status === right.status) return left.turnID.localeCompare(right.turnID);
|
|
869
|
+
return left.status === 'running' ? -1 : 1;
|
|
870
|
+
});
|
|
871
|
+
return valid[0] ?? null;
|
|
872
|
+
}
|
|
873
|
+
|
|
341
874
|
export function contactHistoryForLlm(db, assistantID, { excludeMessageIDs = [], beforeOrdinal = null } = {}) {
|
|
342
875
|
const afterOrdinal = getContactContextBoundary(db, assistantID);
|
|
343
876
|
const exclude = new Set(
|