@openchambery/web 1.19.13 → 1.19.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-m1jNq4bX.js} +1 -1
- package/dist/assets/AssistantView-Dncbu_Z2.js +2 -0
- package/dist/assets/{DiagramView-BdI4PT2H.js → DiagramView-CovEl1BY.js} +1 -1
- package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
- package/dist/assets/Icon-D1BcR8Tz.js +1 -0
- package/dist/assets/{MarkdownRenderer-CLQ4Z8Z0.js → MarkdownRenderer-CZqok-Cf.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-2ewbRZE2.js → MarkdownRendererImpl-CCfHY-86.js} +3 -3
- package/dist/assets/{MarkstreamRendererImpl-DXewO7S_.js → MarkstreamRendererImpl-y6QgIUZA.js} +2 -2
- package/dist/assets/MobileDetailNavigation-Ca-d5eZ9.js +1 -0
- package/dist/assets/MobileShareBridge-B4S7BaUj.js +4 -0
- package/dist/assets/MobileSurface-xL5Jby4U.js +2 -0
- package/dist/assets/{MultiRunWindow-C8ukIdLA.js → MultiRunWindow-BeHa-CG6.js} +1 -1
- package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-bIWeMLVx.js} +1 -1
- package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
- package/dist/assets/SettingsView-Dn7odtQz.js +25 -0
- package/dist/assets/{SettingsWindow-CSePZ_V8.js → SettingsWindow-C0tWPS5f.js} +1 -1
- package/dist/assets/{TerminalView-BmSIrZ81.js → TerminalView-BnJgC7Qo.js} +1 -1
- package/dist/assets/{ToolOutputDialog-2BYaWLo1.js → ToolOutputDialog-D9S-X8hM.js} +1 -1
- package/dist/assets/{appThemeRegistry-Dk4gBaOu.js → appThemeRegistry-DBrvaJnq.js} +1 -1
- package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
- package/dist/assets/{clipboard-CUasgPiF.js → clipboard-GUUNlWIv.js} +1 -1
- package/dist/assets/{collapsible-CACHBRYZ.js → collapsible-Bbz9KiJf.js} +1 -1
- package/dist/assets/{context-menu-CYbm4wBZ.js → context-menu-CSyOhRz8.js} +1 -1
- package/dist/assets/{desktopHosts-DILvrAok.js → desktopHosts-B052a4UK.js} +1 -1
- package/dist/assets/{desktopRelayRestore-t8asCr3m.js → desktopRelayRestore-OjKNXr10.js} +1 -1
- package/dist/assets/{diagram-u0BC9uuY.js → diagram-NdsZih8P.js} +1 -1
- package/dist/assets/{dialog-BwQ3rY_x.js → dialog-DhMGbF_B.js} +1 -1
- package/dist/assets/{es-CRjOwvYM.js → es-BQb6I8gl.js} +2 -2
- package/dist/assets/fileMentionTouchSelection-Dc004xOh.js +4 -0
- package/dist/assets/{fileQueries-Bgly-E8f.js → fileQueries-B_s_B-fH.js} +1 -1
- package/dist/assets/{fr-D9taEN9L.js → fr-BjGouC_l.js} +2 -2
- package/dist/assets/{gitApi-DtPf-h-O.js → gitApi-Cm3JLQqh.js} +3 -3
- package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
- package/dist/assets/insertionBoundaries-CTWYFsbH.js +1 -0
- package/dist/assets/{ja-CYQ5aoWp.js → ja-Cw9zliYY.js} +2 -2
- package/dist/assets/{ko-BwASV0Q0.js → ko-Dyz0Dk9b.js} +2 -2
- package/dist/assets/main-CSqu3MB4.js +169 -0
- package/dist/assets/{main-Ci3JmtpW.js → main-DwMprEyr.js} +2 -2
- package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
- package/dist/assets/miniChat-C8bzY_Ye.js +2 -0
- package/dist/assets/mobile-Oy-SRKkr.js +2 -0
- package/dist/assets/{multirun-CmaTeZON.js → multirun-ClcuaiKU.js} +1 -1
- package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
- package/dist/assets/{pl-DUI1vPil.js → pl-C7EY-dTH.js} +1 -1
- package/dist/assets/platform-DQ5_LNKO.js +2 -0
- package/dist/assets/{projectMeta-cw9nrwpr.js → projectMeta-F93wE2Ye.js} +2 -2
- package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-B1uHpjFW.js} +2 -2
- package/dist/assets/renderElectronMiniChatApp-rOOhMVlQ.js +2 -0
- package/dist/assets/renderMobileApp-BP_hGwHP.js +28 -0
- package/dist/assets/{runtimeConfig-DI8l0VyZ.js → runtimeConfig-iJ2mTBVN.js} +1 -1
- package/dist/assets/runtimeEndpointReset-ISnhAS3q.js +147 -0
- package/dist/assets/sessionChangeSummary-BAzU2J1H.js +2 -0
- package/dist/assets/sessionLookup-DQQ9-DuP.js +1 -0
- package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-dYW6SH62.js} +1 -1
- package/dist/assets/src-DYeppLfP.css +2 -0
- package/dist/assets/{store-CSnH_QXM.js → store-ClnJdJSj.js} +3 -3
- package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
- package/dist/assets/{textarea-BfNtUdJE.js → textarea-B9CmN13Z.js} +1 -1
- package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
- package/dist/assets/{uk-C1FYZlmA.js → uk-DgG8v2md.js} +2 -2
- package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
- package/dist/assets/{useAppFontEffects-BDFUND4v.js → useAppFontEffects--IcU7jyv.js} +43 -44
- package/dist/assets/useDesktopWindowControlsLayout-BqJ5R_T2.js +1 -0
- package/dist/assets/{useEffectiveDirectory-e9HjIqGz.js → useEffectiveDirectory-ChWXV8xM.js} +1 -1
- package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
- package/dist/assets/useMobileNavigationStore-DQkgxuzg.js +413 -0
- package/dist/assets/useSessionAutoCleanup-DWWAvQQ0.js +2 -0
- package/dist/assets/{useWorkerHighlightedLines-DTGE0CDs.js → useWorkerHighlightedLines-D2oQszsk.js} +1 -1
- package/dist/assets/{utils-BHTVlVov.js → utils-2ZK8yboQ.js} +1 -1
- package/dist/assets/{vendor-reactuses-core-DDoDDyM9.js → vendor-reactuses-core-BnW6GxJt.js} +1 -1
- package/dist/assets/{zh-CN-C96qP1ip.js → zh-CN-C6OFKjeT.js} +2 -2
- package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-BVP99aMr.js} +2 -2
- package/dist/index.html +8 -8
- package/dist/mini-chat.html +8 -8
- package/dist/mobile.html +8 -8
- package/package.json +1 -1
- package/server/index.js +1 -0
- package/server/lib/assistants/DOCUMENTATION.md +93 -16
- package/server/lib/assistants/assign.js +117 -12
- package/server/lib/assistants/assign.test.js +79 -0
- package/server/lib/assistants/bubbles.js +1 -1
- package/server/lib/assistants/contact-store.js +496 -11
- package/server/lib/assistants/contact-store.test.js +323 -7
- package/server/lib/assistants/contact-tools.js +303 -52
- package/server/lib/assistants/contact-tools.test.js +239 -7
- package/server/lib/assistants/contracts.js +31 -0
- package/server/lib/assistants/harness.js +178 -50
- package/server/lib/assistants/harness.test.js +428 -3
- package/server/lib/assistants/routes.js +3 -0
- package/server/lib/assistants/service.js +586 -43
- package/server/lib/assistants/service.test.js +696 -7
- package/server/lib/llm/DOCUMENTATION.md +2 -0
- package/server/lib/llm/completions.js +4 -0
- package/server/lib/llm/completions.test.js +3 -0
- package/server/lib/llm/generate.js +12 -5
- package/server/lib/llm/generate.test.js +40 -0
- package/server/lib/opencode/feature-routes-runtime.js +5 -0
- package/dist/assets/AssistantView-CKmIS1cS.js +0 -2
- package/dist/assets/Icon-CkzzhaLr.js +0 -1
- package/dist/assets/MobileDetailNavigation-BGaAyuBH.js +0 -1
- package/dist/assets/MobileShareBridge-BAZoyeIP.js +0 -4
- package/dist/assets/MobileSurface-BVb3xNWt.js +0 -2
- package/dist/assets/SettingsView-CkQ61pbr.js +0 -25
- package/dist/assets/insertionBoundaries-BMhzPFft.js +0 -4
- package/dist/assets/main-BjeOhdcG.js +0 -169
- package/dist/assets/miniChat-BYrjYEXJ.js +0 -2
- package/dist/assets/mobile-BVMNnPK2.js +0 -2
- package/dist/assets/platform-DzsGqlGW.js +0 -2
- package/dist/assets/renderElectronMiniChatApp-COwia8Cs.js +0 -2
- package/dist/assets/renderMobileApp-CXwwpY1t.js +0 -28
- package/dist/assets/runtimeEndpointReset-BkAQXmXJ.js +0 -147
- package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
- package/dist/assets/sessionLookup-Imc995rW.js +0 -1
- package/dist/assets/src-AA-5nXsF.css +0 -2
- package/dist/assets/useDesktopWindowControlsLayout-C5hZD1co.js +0 -1
- package/dist/assets/useMobileNavigationStore-CYbDxRTM.js +0 -413
- package/dist/assets/useSessionAutoCleanup-BGy9_AhN.js +0 -2
|
@@ -9,9 +9,11 @@ import {
|
|
|
9
9
|
attachmentScopeKey,
|
|
10
10
|
buildAssignParts,
|
|
11
11
|
extractAssignSessionModel,
|
|
12
|
+
extractSessionDirectory,
|
|
12
13
|
hasAssignImageParts,
|
|
13
14
|
isAmbiguousPromptFailure,
|
|
14
15
|
isManagedAssistantWorkspace,
|
|
16
|
+
mapSessionToWatchStatus,
|
|
15
17
|
normalizeAssignSessionModel,
|
|
16
18
|
requireProvidedString,
|
|
17
19
|
resolveAssignDirectory,
|
|
@@ -65,6 +67,22 @@ describe('resolveAssignDirectory', () => {
|
|
|
65
67
|
});
|
|
66
68
|
|
|
67
69
|
describe('requireProvidedString / resolveAssignWorkerModel', () => {
|
|
70
|
+
it('resolves a unique informal alias while keeping explicit ids exact and ambiguity closed', () => {
|
|
71
|
+
const catalog = { models: [
|
|
72
|
+
{ providerID: 'a', modelID: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
|
|
73
|
+
{ providerID: 'a', modelID: 'gpt-5.4', name: 'GPT 5.4' },
|
|
74
|
+
{ providerID: 'b', modelID: 'gpt-5.4-mini', name: 'GPT 5.4 Mini' },
|
|
75
|
+
] }
|
|
76
|
+
expect(resolveAssignWorkerModel({ model: 'sonnet 4.6', catalog }).modelID).toBe('claude-sonnet-4-6')
|
|
77
|
+
expect(resolveAssignWorkerModel({ model: 'gpt54', catalog }).modelID).toBe('gpt-5.4')
|
|
78
|
+
expect(() => resolveAssignWorkerModel({ model: 'gpt', catalog })).toThrow(/Several connected models/)
|
|
79
|
+
expect(() => resolveAssignWorkerModel({ providerID: 'a', modelID: 'sonnet 4.6', catalog })).toThrow(/No connected/)
|
|
80
|
+
expect(() => resolveAssignWorkerModel({ model: 'made-up-model', catalog })).toThrow(/No connected/)
|
|
81
|
+
expect(() => resolveAssignWorkerModel({ modelID: 'GPT 5.4', catalog })).toThrow(/No connected/)
|
|
82
|
+
expect(resolveAssignWorkerModel({ providerID: 'a', model: 'gpt', catalog }).modelID).toBe('gpt-5.4')
|
|
83
|
+
expect(() => resolveAssignWorkerModel({ model: 'sonnet', catalog: { models: [catalog.models[0], { ...catalog.models[0], providerID: 'b' }] } })).toThrow(/Several connected models/)
|
|
84
|
+
})
|
|
85
|
+
|
|
68
86
|
const catalog = {
|
|
69
87
|
models: [
|
|
70
88
|
{ providerID: 'openai', modelID: 'gpt-4o', name: 'GPT-4o', acceptsImages: true },
|
|
@@ -209,6 +227,25 @@ describe('requireProvidedString / resolveAssignWorkerModel', () => {
|
|
|
209
227
|
});
|
|
210
228
|
|
|
211
229
|
describe('resolveAssignWorkerVariant', () => {
|
|
230
|
+
it('does not submit or delete a session whose create completes after cancellation', async () => {
|
|
231
|
+
const directory = root();
|
|
232
|
+
const controller = new AbortController();
|
|
233
|
+
const promptExisting = vi.fn();
|
|
234
|
+
const deleteSession = vi.fn();
|
|
235
|
+
const createSession = vi.fn(async () => {
|
|
236
|
+
controller.abort();
|
|
237
|
+
return { data: { id: 'ses_created' } };
|
|
238
|
+
});
|
|
239
|
+
await expect(assignSession({
|
|
240
|
+
prompt: 'Fix', directory, assistant: { providerID: 'p', modelID: 'm' },
|
|
241
|
+
allowedRoots: [directory], managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
|
|
242
|
+
createSession, promptExisting, deleteSession, signal: controller.signal,
|
|
243
|
+
})).rejects.toMatchObject({ name: 'AbortError' });
|
|
244
|
+
expect(createSession).toHaveBeenCalledTimes(1);
|
|
245
|
+
expect(promptExisting).not.toHaveBeenCalled();
|
|
246
|
+
expect(deleteSession).not.toHaveBeenCalled();
|
|
247
|
+
});
|
|
248
|
+
|
|
212
249
|
it('reuses assistant variant only for same/default model; drops it cross-model', () => {
|
|
213
250
|
expect(resolveAssignWorkerVariant({
|
|
214
251
|
source: 'assistant',
|
|
@@ -526,4 +563,46 @@ describe('assignSession', () => {
|
|
|
526
563
|
expect(deleteSession).not.toHaveBeenCalled();
|
|
527
564
|
expect(promptExisting).toHaveBeenCalledWith(expect.objectContaining({ sessionID: 'ses_existing' }));
|
|
528
565
|
});
|
|
566
|
+
|
|
567
|
+
it('mapSessionToWatchStatus baselines terminal idle without inventing busy', () => {
|
|
568
|
+
expect(mapSessionToWatchStatus({
|
|
569
|
+
session: { id: 'ses_1', status: { type: 'idle' }, time: { completed: 1 } },
|
|
570
|
+
messages: [{ info: { role: 'assistant', time: { completed: 1 } } }],
|
|
571
|
+
})).toBe('complete');
|
|
572
|
+
expect(mapSessionToWatchStatus({
|
|
573
|
+
session: { id: 'ses_2', status: { type: 'busy' } },
|
|
574
|
+
})).toBe('busy');
|
|
575
|
+
expect(mapSessionToWatchStatus({
|
|
576
|
+
session: { id: 'ses_3', error: { message: 'boom' } },
|
|
577
|
+
})).toBe('error');
|
|
578
|
+
expect(mapSessionToWatchStatus({ missing: true })).toBe('complete');
|
|
579
|
+
expect(extractSessionDirectory({
|
|
580
|
+
directory: '/repo/app',
|
|
581
|
+
project: { worktree: '/other' },
|
|
582
|
+
})).toBe('/repo/app');
|
|
583
|
+
expect(extractSessionDirectory({
|
|
584
|
+
project: { worktree: '/repo/wt' },
|
|
585
|
+
})).toBe('/repo/wt');
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('resolveAssignDirectory accepts a realpath candidate against non-realpath allowed roots', () => {
|
|
589
|
+
const directory = root();
|
|
590
|
+
const project = path.join(directory, 'app');
|
|
591
|
+
fs.mkdirSync(project, { recursive: true });
|
|
592
|
+
const realProject = fs.realpathSync(project);
|
|
593
|
+
// First resolve returns realpath; second call with the same realpath must still pass.
|
|
594
|
+
const first = resolveAssignDirectory({
|
|
595
|
+
directory: project,
|
|
596
|
+
allowedRoots: [directory],
|
|
597
|
+
managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
|
|
598
|
+
});
|
|
599
|
+
expect(first).toBe(realProject);
|
|
600
|
+
const second = resolveAssignDirectory({
|
|
601
|
+
directory: realProject,
|
|
602
|
+
projectPath: realProject,
|
|
603
|
+
allowedRoots: [directory],
|
|
604
|
+
managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
|
|
605
|
+
});
|
|
606
|
+
expect(second).toBe(realProject);
|
|
607
|
+
});
|
|
529
608
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const SENTENCE = /(?<=[.!?])\s+(?=[A-Z“"‘])/u;
|
|
2
|
-
const CONTACT_PLANNING = /\b(let me think|i should|i need to|i'll call|i will call|the user (said|wants|asked)|actually,?|hmm\.?|assign_session|list_projects|list_sessions|create_assistant|schedule_task|tool call|projectPath)\b/i;
|
|
2
|
+
const CONTACT_PLANNING = /\b(let me think|i should|i need to|i'll call|i will call|the user (said|wants|asked)|actually,?|hmm\.?|assign_session|watch_session|stop_session|list_projects|list_sessions|create_assistant|schedule_task|tool call|projectPath)\b/i;
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* A short spoken contact line (not chain-of-thought). Shown before a tool and kept.
|
|
@@ -56,6 +56,17 @@ export const CONTACT_SCHEMA_SQL = `
|
|
|
56
56
|
assistant_id TEXT PRIMARY KEY,
|
|
57
57
|
generation INTEGER NOT NULL DEFAULT 0
|
|
58
58
|
);
|
|
59
|
+
-- Shared per-assistant contact read watermark (multi-client). Keyset order is
|
|
60
|
+
-- (ordinal ASC, message_id ASC). generation must match transcript generation.
|
|
61
|
+
-- Missing row ≡ nothing read (ordinal 0, empty message_id). Schema migrate
|
|
62
|
+
-- seeds existing assistants to the current tip so legacy history is read.
|
|
63
|
+
CREATE TABLE IF NOT EXISTS assistant_contact_read_state (
|
|
64
|
+
assistant_id TEXT PRIMARY KEY,
|
|
65
|
+
last_read_ordinal INTEGER NOT NULL DEFAULT 0,
|
|
66
|
+
last_read_message_id TEXT NOT NULL DEFAULT '',
|
|
67
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
68
|
+
updated_at INTEGER NOT NULL
|
|
69
|
+
);
|
|
59
70
|
`;
|
|
60
71
|
|
|
61
72
|
export const CONTACT_SETTLE_TEXT = Object.freeze({
|
|
@@ -87,6 +98,8 @@ export function ensureContactSchema(db) {
|
|
|
87
98
|
const columns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant_contact_message')").all().map((column) => column.name));
|
|
88
99
|
if (!columns.has('from_assistant_id')) db.exec('ALTER TABLE assistant_contact_message ADD COLUMN from_assistant_id TEXT');
|
|
89
100
|
if (!columns.has('from_assistant_name')) db.exec('ALTER TABLE assistant_contact_message ADD COLUMN from_assistant_name TEXT');
|
|
101
|
+
const watchColumns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant_contact_watch')").all().map((column) => column.name));
|
|
102
|
+
if (!watchColumns.has('resume_allowed')) db.exec('ALTER TABLE assistant_contact_watch ADD COLUMN resume_allowed INTEGER NOT NULL DEFAULT 1');
|
|
90
103
|
// Attachment table ownership lives in contact-attachments.ensureContactAttachmentSchema.
|
|
91
104
|
}
|
|
92
105
|
|
|
@@ -114,6 +127,473 @@ export function bumpContactGeneration(db, assistantID) {
|
|
|
114
127
|
return getContactGeneration(db, assistantID);
|
|
115
128
|
}
|
|
116
129
|
|
|
130
|
+
/** Empty / start-of-transcript read cursor (nothing read). */
|
|
131
|
+
export const EMPTY_CONTACT_READ_CURSOR = Object.freeze({
|
|
132
|
+
ordinal: 0,
|
|
133
|
+
messageID: '',
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Compare contact keyset cursors (ordinal ASC, message_id ASC).
|
|
138
|
+
* Returns negative when left < right, 0 when equal, positive when left > right.
|
|
139
|
+
*/
|
|
140
|
+
export function compareContactReadCursor(left, right) {
|
|
141
|
+
const leftOrdinal = Number(left?.ordinal);
|
|
142
|
+
const rightOrdinal = Number(right?.ordinal);
|
|
143
|
+
const a = Number.isFinite(leftOrdinal) ? leftOrdinal : 0;
|
|
144
|
+
const b = Number.isFinite(rightOrdinal) ? rightOrdinal : 0;
|
|
145
|
+
if (a !== b) return a - b;
|
|
146
|
+
const leftID = typeof left?.messageID === 'string' ? left.messageID : '';
|
|
147
|
+
const rightID = typeof right?.messageID === 'string' ? right.messageID : '';
|
|
148
|
+
if (leftID < rightID) return -1;
|
|
149
|
+
if (leftID > rightID) return 1;
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const normalizeReadCursor = (value) => {
|
|
154
|
+
const ordinalRaw = Number(value?.ordinal);
|
|
155
|
+
const ordinal = Number.isSafeInteger(ordinalRaw) && ordinalRaw >= 0 ? ordinalRaw : 0;
|
|
156
|
+
const messageID = typeof value?.messageID === 'string' ? value.messageID : '';
|
|
157
|
+
return { ordinal, messageID };
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Persisted shared read watermark for one assistant.
|
|
162
|
+
* Missing row → generation from transcript gen table, cursor at empty (nothing read).
|
|
163
|
+
*/
|
|
164
|
+
export function getContactReadWatermark(db, assistantID) {
|
|
165
|
+
if (typeof assistantID !== 'string' || !assistantID) {
|
|
166
|
+
return {
|
|
167
|
+
generation: 0,
|
|
168
|
+
ordinal: EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
169
|
+
messageID: EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const generation = getContactGeneration(db, assistantID);
|
|
173
|
+
const row = db.prepare(
|
|
174
|
+
'SELECT last_read_ordinal, last_read_message_id, generation FROM assistant_contact_read_state WHERE assistant_id=?',
|
|
175
|
+
).get(assistantID);
|
|
176
|
+
if (!row) {
|
|
177
|
+
return {
|
|
178
|
+
generation,
|
|
179
|
+
ordinal: EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
180
|
+
messageID: EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const cursor = normalizeReadCursor({
|
|
184
|
+
ordinal: row.last_read_ordinal,
|
|
185
|
+
messageID: row.last_read_message_id,
|
|
186
|
+
});
|
|
187
|
+
// Stale generation on the row (e.g. wipe without reset helper) → treat as unread baseline.
|
|
188
|
+
const rowGeneration = Number(row.generation);
|
|
189
|
+
if (!Number.isSafeInteger(rowGeneration) || rowGeneration !== generation) {
|
|
190
|
+
return {
|
|
191
|
+
generation,
|
|
192
|
+
ordinal: EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
193
|
+
messageID: EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
generation,
|
|
198
|
+
ordinal: cursor.ordinal,
|
|
199
|
+
messageID: cursor.messageID,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Highest transcript keyset tip for one assistant (any role).
|
|
205
|
+
* Empty transcript → empty cursor. Clients report up to this tip after viewing.
|
|
206
|
+
*/
|
|
207
|
+
export function getContactReadTip(db, assistantID) {
|
|
208
|
+
if (typeof assistantID !== 'string' || !assistantID) {
|
|
209
|
+
return {
|
|
210
|
+
generation: 0,
|
|
211
|
+
ordinal: EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
212
|
+
messageID: EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const generation = getContactGeneration(db, assistantID);
|
|
216
|
+
const row = db.prepare(
|
|
217
|
+
`SELECT ordinal, message_id AS messageID
|
|
218
|
+
FROM assistant_contact_message
|
|
219
|
+
WHERE assistant_id=?
|
|
220
|
+
ORDER BY ordinal DESC, message_id DESC
|
|
221
|
+
LIMIT 1`,
|
|
222
|
+
).get(assistantID);
|
|
223
|
+
if (!row) {
|
|
224
|
+
return {
|
|
225
|
+
generation,
|
|
226
|
+
ordinal: EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
227
|
+
messageID: EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
generation,
|
|
232
|
+
ordinal: Number(row.ordinal) || 0,
|
|
233
|
+
messageID: typeof row.messageID === 'string' ? row.messageID : '',
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* True when one part is user-visible — matches UI settle-per-part filter
|
|
239
|
+
* (AssistantConversationSurface / getLoadedAssistantReadPosition):
|
|
240
|
+
* file/card, or non-empty text that is not an internal `oc.settle.*` marker.
|
|
241
|
+
* Empty/whitespace text and settle markers alone are not visible.
|
|
242
|
+
*/
|
|
243
|
+
export function isContactVisiblePart(part) {
|
|
244
|
+
if (!part || typeof part !== 'object') return false;
|
|
245
|
+
if (part.type === 'file' || part.type === 'card') return true;
|
|
246
|
+
if (part.type !== 'text') return false;
|
|
247
|
+
const trimmed = typeof part.text === 'string' ? part.text.trim() : '';
|
|
248
|
+
return Boolean(trimmed) && !trimmed.startsWith('oc.settle.');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* True when a contact row should contribute to unreadCount.
|
|
253
|
+
* Counts complete/error assistant replies and user-facing peer DMs that have
|
|
254
|
+
* ≥1 visible part (per-part settle filter). User messages, blank rows,
|
|
255
|
+
* settle-only markers, and non-visible roles are excluded.
|
|
256
|
+
* Streaming tokens never create rows — one persisted bubble ≡ one count unit.
|
|
257
|
+
*/
|
|
258
|
+
export function isContactUnreadCountableMessage(message) {
|
|
259
|
+
if (!message || typeof message !== 'object') return false;
|
|
260
|
+
const role = message.role;
|
|
261
|
+
if (role !== 'assistant' && role !== 'peer') return false;
|
|
262
|
+
const status = typeof message.status === 'string' ? message.status : 'complete';
|
|
263
|
+
if (status !== 'complete' && status !== 'error') return false;
|
|
264
|
+
const parts = Array.isArray(message.parts) ? message.parts : [];
|
|
265
|
+
if (parts.length > 0) return parts.some(isContactVisiblePart);
|
|
266
|
+
// Parts-less fixtures: treat top-level text like a single text part.
|
|
267
|
+
const trimmed = typeof message.text === 'string' ? message.text.trim() : '';
|
|
268
|
+
return Boolean(trimmed) && !trimmed.startsWith('oc.settle.');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** SQL predicate: message is after the read watermark keyset (exclusive). */
|
|
272
|
+
const contactUnreadAfterWatermarkSql = (ordinalAlias, messageIdAlias) => (
|
|
273
|
+
`(${ordinalAlias} > ?
|
|
274
|
+
OR (${ordinalAlias} = ? AND ${messageIdAlias} > ?))`
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* SQL: message has ≥1 user-visible part (file/card/non-empty non-settle text).
|
|
279
|
+
* Per-part — settle prefix on one part does not hide sibling body/file/card.
|
|
280
|
+
* Uses json_extract + GLOB (case-sensitive) to match JS startsWith('oc.settle.').
|
|
281
|
+
*/
|
|
282
|
+
const contactUnreadHasVisiblePartSql = (messageIdExpr) => (
|
|
283
|
+
`EXISTS (
|
|
284
|
+
SELECT 1 FROM assistant_contact_part p
|
|
285
|
+
WHERE p.message_id = ${messageIdExpr}
|
|
286
|
+
AND (
|
|
287
|
+
json_extract(p.part_json, '$.type') IN ('file', 'card')
|
|
288
|
+
OR (
|
|
289
|
+
json_extract(p.part_json, '$.type') = 'text'
|
|
290
|
+
AND length(trim(coalesce(json_extract(p.part_json, '$.text'), ''))) > 0
|
|
291
|
+
AND trim(coalesce(json_extract(p.part_json, '$.text'), '')) NOT GLOB 'oc.settle.*'
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
)`
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Count unread user-facing assistant/peer replies after the watermark.
|
|
299
|
+
* Watermark keyset is applied in SQL; only post-watermark candidates hit the
|
|
300
|
+
* visible-part EXISTS. Fully-read history (no candidates) does not scan parts.
|
|
301
|
+
*/
|
|
302
|
+
export function countContactUnread(db, assistantID, watermark = null) {
|
|
303
|
+
if (typeof assistantID !== 'string' || !assistantID) return 0;
|
|
304
|
+
const mark = watermark && typeof watermark === 'object'
|
|
305
|
+
? normalizeReadCursor(watermark)
|
|
306
|
+
: normalizeReadCursor(getContactReadWatermark(db, assistantID));
|
|
307
|
+
const afterSql = contactUnreadAfterWatermarkSql('m.ordinal', 'm.message_id');
|
|
308
|
+
const row = db.prepare(
|
|
309
|
+
`SELECT COUNT(*) AS count
|
|
310
|
+
FROM assistant_contact_message m
|
|
311
|
+
WHERE m.assistant_id = ?
|
|
312
|
+
AND m.role IN ('assistant', 'peer')
|
|
313
|
+
AND m.status IN ('complete', 'error')
|
|
314
|
+
AND ${afterSql}
|
|
315
|
+
AND ${contactUnreadHasVisiblePartSql('m.message_id')}`,
|
|
316
|
+
).get(assistantID, mark.ordinal, mark.ordinal, mark.messageID);
|
|
317
|
+
return Number(row?.count) || 0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Batch unread counts + watermarks + tips for snapshot (catalog ≤100).
|
|
322
|
+
* Returns Map(assistantID → { unreadCount, readWatermark, readTip }).
|
|
323
|
+
* Per-assistant watermark keyset is pushed into SQL via a VALUES marks CTE;
|
|
324
|
+
* COUNT aggregates only post-watermark visible candidates (no full-history
|
|
325
|
+
* message/.parts scan when the catalog is fully read).
|
|
326
|
+
*/
|
|
327
|
+
export function getContactUnreadSnapshots(db, assistantIDs) {
|
|
328
|
+
const result = new Map();
|
|
329
|
+
const ids = [...new Set(
|
|
330
|
+
(Array.isArray(assistantIDs) ? assistantIDs : [])
|
|
331
|
+
.filter((id) => typeof id === 'string' && id),
|
|
332
|
+
)];
|
|
333
|
+
for (const id of ids) {
|
|
334
|
+
result.set(id, {
|
|
335
|
+
unreadCount: 0,
|
|
336
|
+
readWatermark: {
|
|
337
|
+
generation: 0,
|
|
338
|
+
ordinal: EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
339
|
+
messageID: EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
340
|
+
},
|
|
341
|
+
readTip: {
|
|
342
|
+
generation: 0,
|
|
343
|
+
ordinal: EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
344
|
+
messageID: EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
if (ids.length === 0) return result;
|
|
349
|
+
|
|
350
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
351
|
+
const generationRows = db.prepare(
|
|
352
|
+
`SELECT assistant_id, generation FROM assistant_contact_generation
|
|
353
|
+
WHERE assistant_id IN (${placeholders})`,
|
|
354
|
+
).all(...ids);
|
|
355
|
+
const generationByID = new Map(ids.map((id) => [id, 0]));
|
|
356
|
+
for (const row of generationRows) {
|
|
357
|
+
const generation = Number(row.generation);
|
|
358
|
+
generationByID.set(
|
|
359
|
+
row.assistant_id,
|
|
360
|
+
Number.isSafeInteger(generation) && generation >= 0 ? generation : 0,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const stateRows = db.prepare(
|
|
365
|
+
`SELECT assistant_id, last_read_ordinal, last_read_message_id, generation
|
|
366
|
+
FROM assistant_contact_read_state
|
|
367
|
+
WHERE assistant_id IN (${placeholders})`,
|
|
368
|
+
).all(...ids);
|
|
369
|
+
const watermarkByID = new Map();
|
|
370
|
+
for (const id of ids) {
|
|
371
|
+
watermarkByID.set(id, {
|
|
372
|
+
generation: generationByID.get(id) ?? 0,
|
|
373
|
+
ordinal: EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
374
|
+
messageID: EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
for (const row of stateRows) {
|
|
378
|
+
const liveGeneration = generationByID.get(row.assistant_id) ?? 0;
|
|
379
|
+
const rowGeneration = Number(row.generation);
|
|
380
|
+
if (!Number.isSafeInteger(rowGeneration) || rowGeneration !== liveGeneration) {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const cursor = normalizeReadCursor({
|
|
384
|
+
ordinal: row.last_read_ordinal,
|
|
385
|
+
messageID: row.last_read_message_id,
|
|
386
|
+
});
|
|
387
|
+
watermarkByID.set(row.assistant_id, {
|
|
388
|
+
generation: liveGeneration,
|
|
389
|
+
ordinal: cursor.ordinal,
|
|
390
|
+
messageID: cursor.messageID,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Tip: newest (ordinal, message_id) per assistant — one indexed probe each (≤100).
|
|
395
|
+
const tipProbe = db.prepare(
|
|
396
|
+
`SELECT ordinal, message_id AS messageID
|
|
397
|
+
FROM assistant_contact_message
|
|
398
|
+
WHERE assistant_id=?
|
|
399
|
+
ORDER BY ordinal DESC, message_id DESC
|
|
400
|
+
LIMIT 1`,
|
|
401
|
+
);
|
|
402
|
+
const tipByID = new Map();
|
|
403
|
+
for (const id of ids) {
|
|
404
|
+
const tipRow = tipProbe.get(id);
|
|
405
|
+
tipByID.set(id, {
|
|
406
|
+
generation: generationByID.get(id) ?? 0,
|
|
407
|
+
ordinal: tipRow ? (Number(tipRow.ordinal) || 0) : EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
408
|
+
messageID: tipRow && typeof tipRow.messageID === 'string' ? tipRow.messageID : EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Unread: one aggregated COUNT with per-id watermark keyset in SQL.
|
|
413
|
+
// Fully-read assistants contribute zero candidate rows → no parts EXISTS work.
|
|
414
|
+
const markValuesSql = ids.map(() => '(?,?,?)').join(',');
|
|
415
|
+
const markParams = [];
|
|
416
|
+
for (const id of ids) {
|
|
417
|
+
const mark = watermarkByID.get(id) || EMPTY_CONTACT_READ_CURSOR;
|
|
418
|
+
markParams.push(id, mark.ordinal, mark.messageID);
|
|
419
|
+
}
|
|
420
|
+
const unreadByID = new Map(ids.map((id) => [id, 0]));
|
|
421
|
+
const countRows = db.prepare(
|
|
422
|
+
`WITH marks(assistant_id, mark_ordinal, mark_message_id) AS (
|
|
423
|
+
VALUES ${markValuesSql}
|
|
424
|
+
)
|
|
425
|
+
SELECT m.assistant_id AS assistant_id, COUNT(*) AS count
|
|
426
|
+
FROM assistant_contact_message m
|
|
427
|
+
INNER JOIN marks ON marks.assistant_id = m.assistant_id
|
|
428
|
+
WHERE m.role IN ('assistant', 'peer')
|
|
429
|
+
AND m.status IN ('complete', 'error')
|
|
430
|
+
AND (
|
|
431
|
+
m.ordinal > marks.mark_ordinal
|
|
432
|
+
OR (m.ordinal = marks.mark_ordinal AND m.message_id > marks.mark_message_id)
|
|
433
|
+
)
|
|
434
|
+
AND ${contactUnreadHasVisiblePartSql('m.message_id')}
|
|
435
|
+
GROUP BY m.assistant_id`,
|
|
436
|
+
).all(...markParams);
|
|
437
|
+
for (const row of countRows) {
|
|
438
|
+
unreadByID.set(row.assistant_id, Number(row.count) || 0);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
for (const id of ids) {
|
|
442
|
+
result.set(id, {
|
|
443
|
+
unreadCount: unreadByID.get(id) || 0,
|
|
444
|
+
readWatermark: watermarkByID.get(id),
|
|
445
|
+
readTip: tipByID.get(id),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
return result;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Monotonic advance of the shared read watermark.
|
|
453
|
+
* - generation must match live transcript generation (else contact_generation_conflict).
|
|
454
|
+
* - Only moves forward in (ordinal, messageID) keyset order; late/stale lower
|
|
455
|
+
* cursors are no-ops (changed:false) so concurrent clients cannot erase newer unread.
|
|
456
|
+
* - Incoming cursor is clamped to the current transcript tip (cannot invent future ids).
|
|
457
|
+
* - Idempotent when equal: changed false, no write.
|
|
458
|
+
*/
|
|
459
|
+
export function advanceContactReadWatermark(db, assistantID, input = {}, { updatedAt = Date.now() } = {}) {
|
|
460
|
+
if (typeof assistantID !== 'string' || !assistantID) {
|
|
461
|
+
throw contactStoreError('validation_error', 'assistantID required');
|
|
462
|
+
}
|
|
463
|
+
const liveGeneration = getContactGeneration(db, assistantID);
|
|
464
|
+
const requestedGeneration = Number(input.generation);
|
|
465
|
+
if (!Number.isSafeInteger(requestedGeneration) || requestedGeneration < 0) {
|
|
466
|
+
throw contactStoreError('validation_error', 'generation must be a non-negative integer');
|
|
467
|
+
}
|
|
468
|
+
if (requestedGeneration !== liveGeneration) {
|
|
469
|
+
throw contactStoreError(
|
|
470
|
+
'contact_generation_conflict',
|
|
471
|
+
'Contact transcript generation changed; refetch snapshot before marking read.',
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
const requested = normalizeReadCursor({
|
|
475
|
+
ordinal: input.ordinal,
|
|
476
|
+
messageID: input.messageID,
|
|
477
|
+
});
|
|
478
|
+
if (requested.ordinal < 0 || (requested.ordinal > 0 && !requested.messageID)) {
|
|
479
|
+
// ordinal 0 + empty messageID is the valid "nothing" cursor; otherwise require messageID.
|
|
480
|
+
if (requested.ordinal !== 0 || requested.messageID !== '') {
|
|
481
|
+
throw contactStoreError('validation_error', 'ordinal/messageID cursor invalid');
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (requested.ordinal > 0 && typeof input.messageID !== 'string') {
|
|
485
|
+
throw contactStoreError('validation_error', 'messageID must be a string');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const tip = getContactReadTip(db, assistantID);
|
|
489
|
+
const current = getContactReadWatermark(db, assistantID);
|
|
490
|
+
|
|
491
|
+
// Clamp to tip so deleted/future ids cannot jump past the authoritative head.
|
|
492
|
+
let next = requested;
|
|
493
|
+
if (compareContactReadCursor(next, tip) > 0) {
|
|
494
|
+
next = { ordinal: tip.ordinal, messageID: tip.messageID };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Monotonic: never move backward.
|
|
498
|
+
if (compareContactReadCursor(next, current) <= 0) {
|
|
499
|
+
return {
|
|
500
|
+
changed: false,
|
|
501
|
+
readWatermark: current,
|
|
502
|
+
readTip: tip,
|
|
503
|
+
unreadCount: countContactUnread(db, assistantID, current),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
db.prepare(
|
|
508
|
+
`INSERT INTO assistant_contact_read_state(
|
|
509
|
+
assistant_id, last_read_ordinal, last_read_message_id, generation, updated_at
|
|
510
|
+
) VALUES (?,?,?,?,?)
|
|
511
|
+
ON CONFLICT(assistant_id) DO UPDATE SET
|
|
512
|
+
last_read_ordinal=excluded.last_read_ordinal,
|
|
513
|
+
last_read_message_id=excluded.last_read_message_id,
|
|
514
|
+
generation=excluded.generation,
|
|
515
|
+
updated_at=excluded.updated_at`,
|
|
516
|
+
).run(assistantID, next.ordinal, next.messageID, liveGeneration, updatedAt);
|
|
517
|
+
|
|
518
|
+
const readWatermark = {
|
|
519
|
+
generation: liveGeneration,
|
|
520
|
+
ordinal: next.ordinal,
|
|
521
|
+
messageID: next.messageID,
|
|
522
|
+
};
|
|
523
|
+
return {
|
|
524
|
+
changed: true,
|
|
525
|
+
readWatermark,
|
|
526
|
+
readTip: tip,
|
|
527
|
+
unreadCount: countContactUnread(db, assistantID, readWatermark),
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* After a generation bump (wipe/reset): bind read state to the new generation
|
|
533
|
+
* with an empty watermark so remaining/new rows can become unread. Does not
|
|
534
|
+
* seed to tip — post-wipe confirm bubbles should surface as unread until viewed.
|
|
535
|
+
*/
|
|
536
|
+
export function resetContactReadWatermarkForGeneration(db, assistantID, generation, { updatedAt = Date.now() } = {}) {
|
|
537
|
+
if (typeof assistantID !== 'string' || !assistantID) {
|
|
538
|
+
throw contactStoreError('validation_error', 'assistantID required');
|
|
539
|
+
}
|
|
540
|
+
const nextGeneration = Number(generation);
|
|
541
|
+
if (!Number.isSafeInteger(nextGeneration) || nextGeneration < 0) {
|
|
542
|
+
throw contactStoreError('validation_error', 'generation must be a non-negative integer');
|
|
543
|
+
}
|
|
544
|
+
db.prepare(
|
|
545
|
+
`INSERT INTO assistant_contact_read_state(
|
|
546
|
+
assistant_id, last_read_ordinal, last_read_message_id, generation, updated_at
|
|
547
|
+
) VALUES (?,?,?,?,?)
|
|
548
|
+
ON CONFLICT(assistant_id) DO UPDATE SET
|
|
549
|
+
last_read_ordinal=excluded.last_read_ordinal,
|
|
550
|
+
last_read_message_id=excluded.last_read_message_id,
|
|
551
|
+
generation=excluded.generation,
|
|
552
|
+
updated_at=excluded.updated_at`,
|
|
553
|
+
).run(
|
|
554
|
+
assistantID,
|
|
555
|
+
EMPTY_CONTACT_READ_CURSOR.ordinal,
|
|
556
|
+
EMPTY_CONTACT_READ_CURSOR.messageID,
|
|
557
|
+
nextGeneration,
|
|
558
|
+
updatedAt,
|
|
559
|
+
);
|
|
560
|
+
return getContactReadWatermark(db, assistantID);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Schema migrate helper: seed every assistant that has transcript rows so
|
|
565
|
+
* legacy history is treated as already read (unreadCount 0). Assistants with
|
|
566
|
+
* no messages keep the implicit empty watermark.
|
|
567
|
+
*/
|
|
568
|
+
export function migrateContactReadStateDefaultRead(db, { updatedAt = Date.now() } = {}) {
|
|
569
|
+
const assistants = db.prepare(
|
|
570
|
+
`SELECT DISTINCT assistant_id AS assistantID FROM assistant_contact_message`,
|
|
571
|
+
).all();
|
|
572
|
+
let seeded = 0;
|
|
573
|
+
for (const row of assistants) {
|
|
574
|
+
const assistantID = row.assistantID;
|
|
575
|
+
if (typeof assistantID !== 'string' || !assistantID) continue;
|
|
576
|
+
const existing = db.prepare(
|
|
577
|
+
'SELECT assistant_id FROM assistant_contact_read_state WHERE assistant_id=?',
|
|
578
|
+
).get(assistantID);
|
|
579
|
+
if (existing) continue;
|
|
580
|
+
const tip = getContactReadTip(db, assistantID);
|
|
581
|
+
db.prepare(
|
|
582
|
+
`INSERT INTO assistant_contact_read_state(
|
|
583
|
+
assistant_id, last_read_ordinal, last_read_message_id, generation, updated_at
|
|
584
|
+
) VALUES (?,?,?,?,?)`,
|
|
585
|
+
).run(assistantID, tip.ordinal, tip.messageID, tip.generation, updatedAt);
|
|
586
|
+
seeded += 1;
|
|
587
|
+
}
|
|
588
|
+
return { seeded };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Drop read-state row (assistant delete / full cleanup). */
|
|
592
|
+
export function deleteContactReadState(db, assistantID) {
|
|
593
|
+
if (typeof assistantID !== 'string' || !assistantID) return;
|
|
594
|
+
db.prepare('DELETE FROM assistant_contact_read_state WHERE assistant_id=?').run(assistantID);
|
|
595
|
+
}
|
|
596
|
+
|
|
117
597
|
/**
|
|
118
598
|
* Decode an opaque contact page cursor.
|
|
119
599
|
* v1: { v:1, assistantID, generation, ordinal, messageID }
|
|
@@ -288,6 +768,7 @@ export function buildContactMessagePreview(message) {
|
|
|
288
768
|
return {
|
|
289
769
|
messageID,
|
|
290
770
|
ordinal,
|
|
771
|
+
createdAt: message.createdAt,
|
|
291
772
|
// Peer DMs are user-visible inbox rows; list language is user|assistant only.
|
|
292
773
|
role: sourceRole === 'assistant' ? 'assistant' : 'user',
|
|
293
774
|
text: text || '',
|
|
@@ -658,6 +1139,9 @@ export function deleteContactMessages(db, assistantID, { upToOrdinal = null } =
|
|
|
658
1139
|
}
|
|
659
1140
|
db.prepare('DELETE FROM assistant_contact_watch WHERE assistant_id=?').run(assistantID);
|
|
660
1141
|
db.prepare('DELETE FROM assistant_contact_context_boundary WHERE assistant_id=?').run(assistantID);
|
|
1142
|
+
// Unbounded wipe leaves generation/read_state for the service layer to rebind
|
|
1143
|
+
// (resetContact bumps generation + resetContactReadWatermarkForGeneration).
|
|
1144
|
+
// Assistant delete clears read_state via deleteContactReadState.
|
|
661
1145
|
}
|
|
662
1146
|
|
|
663
1147
|
/** Durable LLM context watermark for one assistant (0 = no boundary). */
|
|
@@ -701,19 +1185,20 @@ export function clearContactMemory(db, assistantID, { updatedAt = Date.now(), up
|
|
|
701
1185
|
return { assistantID, afterOrdinal, memoryCleared: true };
|
|
702
1186
|
}
|
|
703
1187
|
|
|
704
|
-
export function upsertContactWatch(db, { assistantID, sessionID, directory, status, updatedAt }) {
|
|
1188
|
+
export function upsertContactWatch(db, { assistantID, sessionID, directory, status, updatedAt, resumeAllowed = true }) {
|
|
705
1189
|
db.prepare(
|
|
706
|
-
'INSERT INTO assistant_contact_watch(assistant_id,session_id,directory,status,updated_at) VALUES (
|
|
707
|
-
).run(assistantID, sessionID, directory || null, status, updatedAt);
|
|
1190
|
+
'INSERT INTO assistant_contact_watch(assistant_id,session_id,directory,status,updated_at,resume_allowed) VALUES (?,?,?,?,?,?) ON CONFLICT(assistant_id,session_id) DO UPDATE SET directory=excluded.directory,status=excluded.status,updated_at=excluded.updated_at,resume_allowed=excluded.resume_allowed',
|
|
1191
|
+
).run(assistantID, sessionID, directory || null, status, updatedAt, resumeAllowed ? 1 : 0);
|
|
708
1192
|
}
|
|
709
1193
|
|
|
710
1194
|
export function listWatchesBySession(db, sessionID) {
|
|
711
|
-
return db.prepare('SELECT assistant_id, session_id, directory, status FROM assistant_contact_watch WHERE session_id=?').all(sessionID)
|
|
1195
|
+
return db.prepare('SELECT assistant_id, session_id, directory, status, resume_allowed FROM assistant_contact_watch WHERE session_id=?').all(sessionID)
|
|
712
1196
|
.map((row) => ({
|
|
713
1197
|
assistantID: row.assistant_id,
|
|
714
1198
|
sessionID: row.session_id,
|
|
715
1199
|
directory: row.directory,
|
|
716
1200
|
status: row.status,
|
|
1201
|
+
resumeAllowed: row.resume_allowed !== 0,
|
|
717
1202
|
}));
|
|
718
1203
|
}
|
|
719
1204
|
|
|
@@ -760,9 +1245,9 @@ export function updateSessionCardStatus(db, { assistantID, sessionID, status })
|
|
|
760
1245
|
* clearContactMemory advances assistant_contact_context_boundary.after_ordinal;
|
|
761
1246
|
* only messages with ordinal > that watermark enter this window.
|
|
762
1247
|
*/
|
|
763
|
-
export const CONTACT_LLM_MAX_TURNS =
|
|
764
|
-
export const CONTACT_LLM_MAX_CHARS =
|
|
765
|
-
export const CONTACT_LLM_FETCH_LIMIT =
|
|
1248
|
+
export const CONTACT_LLM_MAX_TURNS = 32;
|
|
1249
|
+
export const CONTACT_LLM_MAX_CHARS = 48_000;
|
|
1250
|
+
export const CONTACT_LLM_FETCH_LIMIT = 100;
|
|
766
1251
|
export const CONTACT_LLM_FILE_CHAR_WEIGHT = 80;
|
|
767
1252
|
|
|
768
1253
|
const estimateLlmChars = (item) => {
|
|
@@ -773,7 +1258,8 @@ const estimateLlmChars = (item) => {
|
|
|
773
1258
|
|
|
774
1259
|
const toLlmHistoryItem = (message) => {
|
|
775
1260
|
const files = message.parts.filter((part) => part.type === 'file');
|
|
776
|
-
const
|
|
1261
|
+
const cards = message.parts.filter((part) => part.type === 'card').map((part) => `[OpenChamber card context: ${JSON.stringify(part)}]`);
|
|
1262
|
+
const content = [message.text.trim() || (files.length > 0 ? '[attachment]' : ''), ...cards].filter(Boolean).join('\n');
|
|
777
1263
|
return files.length > 0 ? { role: message.role, content, parts: files } : { role: message.role, content };
|
|
778
1264
|
};
|
|
779
1265
|
|
|
@@ -784,9 +1270,8 @@ const isLlmEligibleContactMessage = (message) => {
|
|
|
784
1270
|
if (message.role === 'peer' || message.fromAssistantID) return false;
|
|
785
1271
|
const hasText = Boolean(message.text?.trim());
|
|
786
1272
|
const hasFiles = Array.isArray(message.parts) && message.parts.some((part) => part.type === 'file');
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
return hasText || hasFiles;
|
|
1273
|
+
const hasCards = Array.isArray(message.parts) && message.parts.some((part) => part.type === 'card');
|
|
1274
|
+
return hasText || hasFiles || hasCards;
|
|
790
1275
|
};
|
|
791
1276
|
|
|
792
1277
|
/** Turn-aware keep of recent user+assistant text pairs for the model only. */
|