@openchambery/web 1.19.7-beta.13 → 1.19.7-beta.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.
Files changed (118) hide show
  1. package/dist/assets/{AgentAvatar-Dp_hbVcD.js → AgentAvatar-BsU82CEP.js} +1 -1
  2. package/dist/assets/AssistantView-CKNR7FFf.js +2 -0
  3. package/dist/assets/{DiagramView-iX3nhWaN.js → DiagramView-Db1VvSIR.js} +1 -1
  4. package/dist/assets/{MarkdownRendererImpl-tDry9J76.js → MarkdownRendererImpl-NBG7O4aX.js} +1 -1
  5. package/dist/assets/{MarkstreamRendererImpl-BDVGi72n.js → MarkstreamRendererImpl-Bs829ng-.js} +1 -1
  6. package/dist/assets/MobileDetailNavigation-CooM1BN7.js +1 -0
  7. package/dist/assets/{MobileShareBridge-BQ79cuPX.js → MobileShareBridge-CcG4lLaD.js} +3 -3
  8. package/dist/assets/MobileSurface-c_7RkYa2.js +2 -0
  9. package/dist/assets/{MultiRunWindow-CzMbzYD3.js → MultiRunWindow-Ds8nyikm.js} +1 -1
  10. package/dist/assets/{OnboardingScreen-CuvjeXSo.js → OnboardingScreen-BNEtPmvw.js} +1 -1
  11. package/dist/assets/SettingsView-Dac1dDOs.js +25 -0
  12. package/dist/assets/{SettingsWindow-DqwoBYvU.js → SettingsWindow-CHnMwzd6.js} +1 -1
  13. package/dist/assets/{TerminalView-4pFqXfvj.js → TerminalView-DfGueSOI.js} +1 -1
  14. package/dist/assets/{ToolOutputDialog-GmD4kIoH.js → ToolOutputDialog-BrR9iqiW.js} +1 -1
  15. package/dist/assets/{appThemeRegistry-BlEYFhXM.js → appThemeRegistry-BEG7NRFm.js} +2 -2
  16. package/dist/assets/{clipboard-Bu0lpyX1.js → clipboard-BXxLshVq.js} +1 -1
  17. package/dist/assets/context-menu-Dpjla5p4.js +1 -0
  18. package/dist/assets/{dialog-D21YndEx.js → dialog-BkhHBTd4.js} +1 -1
  19. package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
  20. package/dist/assets/{es-C8Sqjsia.js → es-BHqnSjoZ.js} +1 -1
  21. package/dist/assets/{fr-p-Zwm_j2.js → fr-BfrlJY9i.js} +1 -1
  22. package/dist/assets/{gitApi-CciB055B.js → gitApi-Br5EdS29.js} +3 -3
  23. package/dist/assets/insertionBoundaries-ncyYkgWy.js +4 -0
  24. package/dist/assets/{ja-B6REu1qP.js → ja-DP-7qt-x.js} +1 -1
  25. package/dist/assets/{ko-CJBNH9IZ.js → ko-E2B5vGQP.js} +1 -1
  26. package/dist/assets/main-DX_9iX64.js +169 -0
  27. package/dist/assets/{main-DYFky6Uj.js → main-Dl-XWJxN.js} +2 -2
  28. package/dist/assets/miniChat-Cho-Pywx.js +2 -0
  29. package/dist/assets/mobile-1_JD1EGE.js +2 -0
  30. package/dist/assets/multirun-ByFn3xhO.js +4 -0
  31. package/dist/assets/{pl-B2cBI_7K.js → pl-r8X5gRM5.js} +1 -1
  32. package/dist/assets/projectMeta-BHw8kRhQ.js +3 -0
  33. package/dist/assets/{pt-BR-CrCc9hmt.js → pt-BR-DcpdXCf5.js} +1 -1
  34. package/dist/assets/renderElectronMiniChatApp-CvIxnUQY.js +2 -0
  35. package/dist/assets/renderMobileApp-DscicBzZ.js +28 -0
  36. package/dist/assets/{runtimeConfig-DNsnUkyp.js → runtimeConfig-Dkt3p8CZ.js} +1 -1
  37. package/dist/assets/runtimeEndpointReset-hBFsO83_.js +147 -0
  38. package/dist/assets/sessionChangeSummary-CX2MJIkc.js +1 -0
  39. package/dist/assets/sessionLookup-jCEot_fX.js +1 -0
  40. package/dist/assets/{sortable-tabs-strip-Biow4Fqu.js → sortable-tabs-strip-C5gGnikj.js} +1 -1
  41. package/dist/assets/src-KcdsQIuP.css +2 -0
  42. package/dist/assets/{store-CL3WP48T.js → store-BDFdnk6l.js} +2 -2
  43. package/dist/assets/textarea-CIO34TZz.js +1 -0
  44. package/dist/assets/{uk-C8YKAz0x.js → uk-Di0vl9V-.js} +1 -1
  45. package/dist/assets/{ChatContainer-C04WuLo8.js → useAppFontEffects-kJR0NlNH.js} +44 -39
  46. package/dist/assets/useDesktopWindowControlsLayout-BmbOASBQ.js +1 -0
  47. package/dist/assets/useEffectiveDirectory-DOToiRZd.js +1 -0
  48. package/dist/assets/useMobileNavigationStore-CEmn-d1X.js +412 -0
  49. package/dist/assets/useSessionAutoCleanup-DcmXMo-p.js +1 -0
  50. package/dist/assets/{useWorkerHighlightedLines-BaNK-wuY.js → useWorkerHighlightedLines-r5Ww4sY7.js} +1 -1
  51. package/dist/assets/{zh-CN-CCYwUnmB.js → zh-CN-coFCfeQE.js} +1 -1
  52. package/dist/assets/{zh-TW-D8Y6py08.js → zh-TW-oDRZ1-yb.js} +1 -1
  53. package/dist/index.html +3 -3
  54. package/dist/mini-chat.html +3 -3
  55. package/dist/mobile.html +3 -3
  56. package/package.json +2 -1
  57. package/server/index.js +2 -0
  58. package/server/lib/assistant-delivery-parts.js +3 -1
  59. package/server/lib/assistant-delivery-parts.test.js +11 -0
  60. package/server/lib/assistants/DOCUMENTATION.md +76 -3
  61. package/server/lib/assistants/assign.js +225 -0
  62. package/server/lib/assistants/assign.test.js +118 -0
  63. package/server/lib/assistants/bubbles.js +45 -0
  64. package/server/lib/assistants/bubbles.test.js +36 -0
  65. package/server/lib/assistants/cards.js +148 -0
  66. package/server/lib/assistants/cards.test.js +116 -0
  67. package/server/lib/assistants/contact-store.js +284 -0
  68. package/server/lib/assistants/contact-store.test.js +131 -0
  69. package/server/lib/assistants/contact-tools.js +761 -0
  70. package/server/lib/assistants/contact-tools.test.js +328 -0
  71. package/server/lib/assistants/contracts.js +20 -1
  72. package/server/lib/assistants/harness.js +551 -0
  73. package/server/lib/assistants/harness.test.js +607 -0
  74. package/server/lib/assistants/routes.js +44 -4
  75. package/server/lib/assistants/service.js +930 -17
  76. package/server/lib/assistants/service.test.js +1163 -45
  77. package/server/lib/llm/DOCUMENTATION.md +84 -0
  78. package/server/lib/llm/catalog.js +100 -0
  79. package/server/lib/llm/catalog.test.js +78 -0
  80. package/server/lib/llm/completions.js +151 -0
  81. package/server/lib/llm/completions.test.js +127 -0
  82. package/server/lib/llm/generate.js +570 -0
  83. package/server/lib/llm/generate.test.js +459 -0
  84. package/server/lib/llm/routes.js +47 -0
  85. package/server/lib/llm/temp-directory.js +37 -0
  86. package/server/lib/notifications/runtime.js +52 -0
  87. package/server/lib/notifications/runtime.test.js +39 -0
  88. package/server/lib/opencode/feature-routes-runtime.js +30 -0
  89. package/server/lib/opencode/feature-routes-runtime.test.js +20 -0
  90. package/server/lib/session-goal/DOCUMENTATION.md +8 -1
  91. package/server/lib/session-goal/runtime.js +20 -0
  92. package/server/lib/session-index/DOCUMENTATION.md +3 -1
  93. package/server/lib/session-index/service.js +10 -4
  94. package/server/lib/session-index/service.test.js +37 -0
  95. package/server/lib/session-title/runtime.js +1 -0
  96. package/server/lib/session-title/runtime.test.js +11 -0
  97. package/dist/assets/AssistantView-CQHey6je.js +0 -1
  98. package/dist/assets/ContextPanel-BJEFzgz7.js +0 -285
  99. package/dist/assets/MobileDetailNavigation-Dcl7Upvc.js +0 -1
  100. package/dist/assets/MobileSurface-CpAbBAQ5.js +0 -2
  101. package/dist/assets/SettingsView-DRiEDi23.js +0 -25
  102. package/dist/assets/insertionBoundaries-DcS10A9Q.js +0 -4
  103. package/dist/assets/main-Coj1Z4LN.js +0 -29
  104. package/dist/assets/miniChat-DK-d_3-7.js +0 -2
  105. package/dist/assets/mobile-8QzXy5wz.js +0 -2
  106. package/dist/assets/multirun-BFHWrDOR.js +0 -4
  107. package/dist/assets/projectMeta-CEGa4Czs.js +0 -3
  108. package/dist/assets/radio-Bw11T40N.js +0 -1
  109. package/dist/assets/renderElectronMiniChatApp-_fchgm-8.js +0 -2
  110. package/dist/assets/renderMobileApp-B1j1BUXB.js +0 -28
  111. package/dist/assets/runtimeEndpointReset-C-Sm4wpj.js +0 -4
  112. package/dist/assets/shikiHighlight-GaNLBDUk.js +0 -1
  113. package/dist/assets/src-BGao3JmJ.css +0 -2
  114. package/dist/assets/useAppFontEffects-D0UWTJui.js +0 -7
  115. package/dist/assets/useDesktopWindowControlsLayout-_5M7NHJ8.js +0 -1
  116. package/dist/assets/useEffectiveDirectory-DqOLKISv.js +0 -1
  117. package/dist/assets/useMobileNavigationStore-DxH1DjU-.js +0 -412
  118. package/dist/assets/useSessionAutoCleanup-Dpp0-zw2.js +0 -1
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { isContactSpokenPreamble, splitContactBubbles } from './bubbles.js'
3
+
4
+ describe('splitContactBubbles', () => {
5
+ it('splits on blank lines into short bubbles', () => {
6
+ expect(splitContactBubbles('Hey.\n\nWant me to open the login session?')).toEqual([
7
+ 'Hey.',
8
+ 'Want me to open the login session?',
9
+ ])
10
+ })
11
+
12
+ it('keeps a single paragraph together', () => {
13
+ expect(splitContactBubbles('Just one bubble.')).toEqual(['Just one bubble.'])
14
+ })
15
+
16
+ it('returns an empty list for blank text', () => {
17
+ expect(splitContactBubbles(' \n\n ')).toEqual([])
18
+ })
19
+ })
20
+
21
+ describe('isContactSpokenPreamble', () => {
22
+ it('accepts a short spoken line', () => {
23
+ expect(isContactSpokenPreamble('我去找一下')).toBe(true)
24
+ expect(isContactSpokenPreamble('On it.')).toBe(true)
25
+ })
26
+
27
+ it('rejects chain-of-thought and tool narration', () => {
28
+ expect(isContactSpokenPreamble('Let me think — I should match the project and call assign_session.')).toBe(false)
29
+ expect(isContactSpokenPreamble('I need to list_projects first.')).toBe(false)
30
+ })
31
+
32
+ it('rejects long or multi-bubble text', () => {
33
+ expect(isContactSpokenPreamble('a'.repeat(81))).toBe(false)
34
+ expect(isContactSpokenPreamble('先找项目。\n\n再开会话。')).toBe(false)
35
+ })
36
+ })
@@ -0,0 +1,148 @@
1
+ export const CONTACT_CARD_TYPES = Object.freeze(['session', 'assistant', 'schedule']);
2
+ const CARD_TYPES = new Set(CONTACT_CARD_TYPES);
3
+ // Assign / create_assistant / schedule_task auto-insert these cards.
4
+ // project | worktree | watch MUST reuse this slot. Do not invent a second card system.
5
+
6
+ const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
7
+ const string = (value) => (typeof value === 'string' && value.trim() ? value.trim() : '');
8
+
9
+ const parseSessionCard = (value) => {
10
+ const sessionID = string(value.sessionID);
11
+ const directory = string(value.directory);
12
+ if (!sessionID || !directory) return null;
13
+ return {
14
+ type: 'card',
15
+ cardType: 'session',
16
+ sessionID,
17
+ directory,
18
+ title: string(value.title) || null,
19
+ status: string(value.status) || null,
20
+ branch: string(value.branch) || null,
21
+ };
22
+ };
23
+
24
+ const parseAssistantCard = (value) => {
25
+ const assistantID = string(value.assistantID);
26
+ const name = string(value.name);
27
+ const providerID = string(value.providerID);
28
+ const modelID = string(value.modelID);
29
+ if (!assistantID || !name || !providerID || !modelID) return null;
30
+ const mode = string(value.mode) === 'stateless' ? 'stateless' : 'continuous';
31
+ return {
32
+ type: 'card',
33
+ cardType: 'assistant',
34
+ assistantID,
35
+ name,
36
+ providerID,
37
+ modelID,
38
+ mode,
39
+ };
40
+ };
41
+
42
+ const parseScheduleCard = (value) => {
43
+ const taskID = string(value.taskID);
44
+ const projectID = string(value.projectID);
45
+ const name = string(value.name);
46
+ if (!taskID || !projectID || !name) return null;
47
+ return {
48
+ type: 'card',
49
+ cardType: 'schedule',
50
+ taskID,
51
+ projectID,
52
+ name,
53
+ kind: string(value.kind) || null,
54
+ time: string(value.time) || null,
55
+ timezone: string(value.timezone) || null,
56
+ prompt: string(value.prompt) || null,
57
+ };
58
+ };
59
+
60
+ /**
61
+ * First-class in-transcript card. Extensible via `cardType`.
62
+ * Session cards navigate with the existing session route, not markdown links.
63
+ */
64
+ export function parseContactCard(value) {
65
+ if (!isRecord(value)) return null;
66
+ const cardType = string(value.cardType);
67
+ if (!CARD_TYPES.has(cardType)) return null;
68
+ if (cardType === 'session') return parseSessionCard(value);
69
+ if (cardType === 'assistant') return parseAssistantCard(value);
70
+ if (cardType === 'schedule') return parseScheduleCard(value);
71
+ return null;
72
+ }
73
+
74
+ function parseContactFilePart(value) {
75
+ if (!isRecord(value) || value.type !== 'file') return null;
76
+ const mime = string(value.mime);
77
+ const url = typeof value.url === 'string' && value.url.trim() ? value.url.trim() : '';
78
+ if (!mime || !url) return null;
79
+ const filename = string(value.filename);
80
+ return filename ? { type: 'file', mime, url, filename } : { type: 'file', mime, url };
81
+ }
82
+
83
+ export function parseContactPart(value) {
84
+ if (!isRecord(value)) return null;
85
+ if (value.type === 'text' && typeof value.text === 'string') {
86
+ return { type: 'text', text: value.text };
87
+ }
88
+ if (value.type === 'file') {
89
+ return parseContactFilePart(value);
90
+ }
91
+ if (value.type === 'card') {
92
+ return parseContactCard(value);
93
+ }
94
+ return null;
95
+ }
96
+
97
+ export function serializeContactPart(part, index) {
98
+ return {
99
+ id: part.id || `oc_contact_part:${index + 1}`,
100
+ ...part,
101
+ };
102
+ }
103
+
104
+ export function contactCardIdentity(card) {
105
+ if (!card || card.type !== 'card') return '';
106
+ if (card.cardType === 'session') return `session:${card.sessionID}:${card.directory}`;
107
+ if (card.cardType === 'assistant') return `assistant:${card.assistantID}`;
108
+ if (card.cardType === 'schedule') return `schedule:${card.projectID}:${card.taskID}`;
109
+ return card.cardType;
110
+ }
111
+
112
+ const createCardPart = (cardType, input, requiredMessage) => {
113
+ const card = parseContactCard({ type: 'card', cardType, ...input });
114
+ if (!card) {
115
+ const error = new Error(requiredMessage);
116
+ error.code = 'validation_error';
117
+ throw error;
118
+ }
119
+ return card;
120
+ };
121
+
122
+ /** Build a session card. Throws so insert paths fail closed. */
123
+ export function createSessionCardPart(input) {
124
+ if (!string(input?.sessionID)) {
125
+ const error = new Error('sessionID is required');
126
+ error.code = 'validation_error';
127
+ throw error;
128
+ }
129
+ return createCardPart('session', input, 'Invalid session card');
130
+ }
131
+
132
+ export function createAssistantCardPart(input) {
133
+ if (!string(input?.assistantID)) {
134
+ const error = new Error('assistantID is required');
135
+ error.code = 'validation_error';
136
+ throw error;
137
+ }
138
+ return createCardPart('assistant', input, 'Invalid assistant card');
139
+ }
140
+
141
+ export function createScheduleCardPart(input) {
142
+ if (!string(input?.taskID) || !string(input?.projectID)) {
143
+ const error = new Error('taskID and projectID are required');
144
+ error.code = 'validation_error';
145
+ throw error;
146
+ }
147
+ return createCardPart('schedule', input, 'Invalid schedule card');
148
+ }
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { CONTACT_CARD_TYPES, createSessionCardPart, parseContactCard, parseContactPart } from './cards.js'
3
+
4
+ describe('contact cards', () => {
5
+ it('creates an extensible session card part', () => {
6
+ const part = createSessionCardPart({
7
+ sessionID: 'ses_1',
8
+ directory: '/tmp/project',
9
+ title: 'Fix login',
10
+ status: 'idle',
11
+ branch: 'feat-login',
12
+ })
13
+ expect(part).toEqual({
14
+ type: 'card',
15
+ cardType: 'session',
16
+ sessionID: 'ses_1',
17
+ directory: '/tmp/project',
18
+ title: 'Fix login',
19
+ status: 'idle',
20
+ branch: 'feat-login',
21
+ })
22
+ expect(CONTACT_CARD_TYPES).toContain('session')
23
+ expect(CONTACT_CARD_TYPES).toContain('assistant')
24
+ expect(CONTACT_CARD_TYPES).toContain('schedule')
25
+ })
26
+
27
+ it('creates assistant and schedule cards in the same slot', () => {
28
+ expect(parseContactCard({
29
+ cardType: 'assistant',
30
+ assistantID: 'asst_flow',
31
+ name: 'FlowQA',
32
+ providerID: 'opencode-go',
33
+ modelID: 'deepseek-v4-flash',
34
+ })).toEqual({
35
+ type: 'card',
36
+ cardType: 'assistant',
37
+ assistantID: 'asst_flow',
38
+ name: 'FlowQA',
39
+ providerID: 'opencode-go',
40
+ modelID: 'deepseek-v4-flash',
41
+ mode: 'continuous',
42
+ })
43
+ expect(parseContactCard({
44
+ cardType: 'schedule',
45
+ taskID: 'task_1',
46
+ projectID: 'proj_1',
47
+ name: 'Daily ping',
48
+ kind: 'daily',
49
+ time: '18:00',
50
+ timezone: 'Asia/Shanghai',
51
+ prompt: 'ping',
52
+ })).toMatchObject({
53
+ type: 'card',
54
+ cardType: 'schedule',
55
+ taskID: 'task_1',
56
+ projectID: 'proj_1',
57
+ name: 'Daily ping',
58
+ kind: 'daily',
59
+ time: '18:00',
60
+ })
61
+ })
62
+
63
+ it('rejects unknown card types so later types reuse this slot', () => {
64
+ expect(parseContactCard({
65
+ cardType: 'not-a-card',
66
+ sessionID: 'ses_1',
67
+ directory: '/tmp/project',
68
+ })).toBeNull()
69
+ })
70
+
71
+ it('parses contact file parts with optional filename', () => {
72
+ expect(parseContactPart({
73
+ type: 'file',
74
+ mime: 'image/png',
75
+ url: 'data:image/png;base64,aa',
76
+ filename: 'shot.png',
77
+ })).toEqual({
78
+ type: 'file',
79
+ mime: 'image/png',
80
+ url: 'data:image/png;base64,aa',
81
+ filename: 'shot.png',
82
+ })
83
+ expect(parseContactPart({
84
+ type: 'file',
85
+ mime: 'text/plain',
86
+ url: 'data:text/plain;base64,eA==',
87
+ })).toEqual({
88
+ type: 'file',
89
+ mime: 'text/plain',
90
+ url: 'data:text/plain;base64,eA==',
91
+ })
92
+ expect(parseContactPart({ type: 'file', mime: 'image/png' })).toBeNull()
93
+ })
94
+
95
+ it('requires a sessionID for session cards', () => {
96
+ expect(() => createSessionCardPart({ directory: '/tmp/project' })).toThrow(/sessionID/)
97
+ })
98
+
99
+ it('treats omitted branch as null so older persisted cards still parse', () => {
100
+ expect(parseContactCard({
101
+ cardType: 'session',
102
+ sessionID: 'ses_1',
103
+ directory: '/tmp/project',
104
+ title: 'Fix login',
105
+ status: 'idle',
106
+ })).toEqual({
107
+ type: 'card',
108
+ cardType: 'session',
109
+ sessionID: 'ses_1',
110
+ directory: '/tmp/project',
111
+ title: 'Fix login',
112
+ status: 'idle',
113
+ branch: null,
114
+ })
115
+ })
116
+ })
@@ -0,0 +1,284 @@
1
+ import { parseContactCard, parseContactPart, serializeContactPart } from './cards.js';
2
+
3
+ const json = (value) => JSON.stringify(value);
4
+ const parse = (value) => JSON.parse(value);
5
+
6
+ export const CONTACT_SCHEMA_SQL = `
7
+ CREATE TABLE IF NOT EXISTS assistant_contact_message (
8
+ message_id TEXT PRIMARY KEY,
9
+ assistant_id TEXT NOT NULL,
10
+ role TEXT NOT NULL,
11
+ turn_id TEXT NOT NULL,
12
+ bubble_index INTEGER NOT NULL DEFAULT 0,
13
+ created_at INTEGER NOT NULL,
14
+ ordinal INTEGER NOT NULL,
15
+ status TEXT NOT NULL DEFAULT 'complete',
16
+ from_assistant_id TEXT,
17
+ from_assistant_name TEXT
18
+ );
19
+ CREATE INDEX IF NOT EXISTS assistant_contact_message_page
20
+ ON assistant_contact_message(assistant_id, ordinal, message_id);
21
+ CREATE TABLE IF NOT EXISTS assistant_contact_part (
22
+ message_id TEXT NOT NULL,
23
+ part_id TEXT NOT NULL,
24
+ ordinal INTEGER NOT NULL,
25
+ part_json TEXT NOT NULL,
26
+ PRIMARY KEY (message_id, part_id)
27
+ );
28
+ CREATE INDEX IF NOT EXISTS assistant_contact_part_message
29
+ ON assistant_contact_part(message_id, ordinal, part_id);
30
+ CREATE TABLE IF NOT EXISTS assistant_contact_watch (
31
+ assistant_id TEXT NOT NULL,
32
+ session_id TEXT NOT NULL,
33
+ directory TEXT,
34
+ status TEXT NOT NULL,
35
+ updated_at INTEGER NOT NULL,
36
+ PRIMARY KEY (assistant_id, session_id)
37
+ );
38
+ CREATE INDEX IF NOT EXISTS assistant_contact_watch_session
39
+ ON assistant_contact_watch(session_id, status);
40
+ `;
41
+
42
+ export const CONTACT_SETTLE_TEXT = Object.freeze({
43
+ complete: 'oc.settle.complete',
44
+ error: 'oc.settle.error',
45
+ question: 'oc.settle.question',
46
+ });
47
+
48
+ const IN_FLIGHT_WATCH = new Set(['busy', 'question']);
49
+
50
+ export function ensureContactSchema(db) {
51
+ db.exec(CONTACT_SCHEMA_SQL);
52
+ const columns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant_contact_message')").all().map((column) => column.name));
53
+ if (!columns.has('from_assistant_id')) db.exec('ALTER TABLE assistant_contact_message ADD COLUMN from_assistant_id TEXT');
54
+ if (!columns.has('from_assistant_name')) db.exec('ALTER TABLE assistant_contact_message ADD COLUMN from_assistant_name TEXT');
55
+ }
56
+
57
+ const decodeCursor = (value) => {
58
+ if (value == null || value === '') return null;
59
+ const parsed = JSON.parse(Buffer.from(String(value), 'base64url').toString('utf8'));
60
+ if (!Number.isSafeInteger(parsed?.ordinal) || typeof parsed?.messageID !== 'string') return null;
61
+ return parsed;
62
+ };
63
+
64
+ const encodeCursor = (row) => Buffer.from(json({ ordinal: row.ordinal, messageID: row.message_id })).toString('base64url');
65
+
66
+ const hydrateMessage = (db, row) => {
67
+ const parts = db.prepare(
68
+ 'SELECT part_json FROM assistant_contact_part WHERE message_id=? ORDER BY ordinal ASC, part_id ASC',
69
+ ).all(row.message_id).map((part) => parse(part.part_json)).map(parseContactPart).filter(Boolean);
70
+ const fromAssistantID = typeof row.from_assistant_id === 'string' && row.from_assistant_id.trim() ? row.from_assistant_id : null;
71
+ const storedName = typeof row.from_assistant_name === 'string' && row.from_assistant_name.trim() ? row.from_assistant_name : null;
72
+ let liveName = null;
73
+ if (fromAssistantID) {
74
+ try {
75
+ liveName = db.prepare('SELECT name FROM assistant_v2 WHERE assistant_id=?').get(fromAssistantID)?.name;
76
+ } catch {
77
+ liveName = null;
78
+ }
79
+ }
80
+ return {
81
+ messageID: row.message_id,
82
+ assistantID: row.assistant_id,
83
+ role: row.role,
84
+ turnID: row.turn_id,
85
+ bubbleIndex: row.bubble_index,
86
+ createdAt: row.created_at,
87
+ ordinal: row.ordinal,
88
+ status: row.status,
89
+ fromAssistantID,
90
+ fromAssistantName: storedName || (typeof liveName === 'string' && liveName.trim() ? liveName : null),
91
+ parts,
92
+ text: parts.filter((part) => part.type === 'text').map((part) => part.text).join(''),
93
+ cards: parts.filter((part) => part.type === 'card'),
94
+ };
95
+ };
96
+
97
+ export function nextContactOrdinal(db, assistantID) {
98
+ return Number(db.prepare(
99
+ 'SELECT COALESCE(MAX(ordinal), 0) + 1 AS next FROM assistant_contact_message WHERE assistant_id=?',
100
+ ).get(assistantID).next);
101
+ }
102
+
103
+ export function insertContactMessage(db, {
104
+ messageID,
105
+ assistantID,
106
+ role,
107
+ turnID,
108
+ bubbleIndex = 0,
109
+ createdAt,
110
+ ordinal,
111
+ status = 'complete',
112
+ parts,
113
+ fromAssistantID = null,
114
+ fromAssistantName = null,
115
+ }) {
116
+ db.prepare(
117
+ 'INSERT INTO assistant_contact_message(message_id,assistant_id,role,turn_id,bubble_index,created_at,ordinal,status,from_assistant_id,from_assistant_name) VALUES (?,?,?,?,?,?,?,?,?,?)',
118
+ ).run(messageID, assistantID, role, turnID, bubbleIndex, createdAt, ordinal, status, fromAssistantID, fromAssistantName);
119
+ parts.forEach((part, index) => {
120
+ const serialized = serializeContactPart(part, index);
121
+ db.prepare(
122
+ 'INSERT INTO assistant_contact_part(message_id,part_id,ordinal,part_json) VALUES (?,?,?,?)',
123
+ ).run(messageID, serialized.id, index + 1, json(serialized));
124
+ });
125
+ }
126
+
127
+ export function listContactMessages(db, assistantID, { before, limit = 50 } = {}) {
128
+ const cursor = before == null || before === '' ? null : decodeCursor(before);
129
+ if (before && !cursor) {
130
+ const error = new Error('validation_error');
131
+ error.code = 'validation_error';
132
+ throw error;
133
+ }
134
+ const rows = cursor
135
+ ? db.prepare(
136
+ 'SELECT * FROM assistant_contact_message WHERE assistant_id=? AND (ordinal<? OR (ordinal=? AND message_id<?)) ORDER BY ordinal DESC, message_id DESC LIMIT ?',
137
+ ).all(assistantID, cursor.ordinal, cursor.ordinal, cursor.messageID, limit + 1)
138
+ : db.prepare(
139
+ 'SELECT * FROM assistant_contact_message WHERE assistant_id=? ORDER BY ordinal DESC, message_id DESC LIMIT ?',
140
+ ).all(assistantID, limit + 1);
141
+ const page = rows.slice(0, limit);
142
+ const nextCursor = rows.length > limit && page[page.length - 1] ? encodeCursor(page[page.length - 1]) : null;
143
+ return {
144
+ messages: [...page].reverse().map((row) => hydrateMessage(db, row)),
145
+ nextCursor,
146
+ complete: nextCursor === null,
147
+ };
148
+ }
149
+
150
+ export function deleteContactMessages(db, assistantID) {
151
+ const ids = db.prepare('SELECT message_id FROM assistant_contact_message WHERE assistant_id=?').all(assistantID).map((row) => row.message_id);
152
+ for (const messageID of ids) {
153
+ db.prepare('DELETE FROM assistant_contact_part WHERE message_id=?').run(messageID);
154
+ }
155
+ db.prepare('DELETE FROM assistant_contact_message WHERE assistant_id=?').run(assistantID);
156
+ db.prepare('DELETE FROM assistant_contact_watch WHERE assistant_id=?').run(assistantID);
157
+ }
158
+
159
+ export function upsertContactWatch(db, { assistantID, sessionID, directory, status, updatedAt }) {
160
+ db.prepare(
161
+ 'INSERT INTO assistant_contact_watch(assistant_id,session_id,directory,status,updated_at) VALUES (?,?,?,?,?) ON CONFLICT(assistant_id,session_id) DO UPDATE SET directory=excluded.directory,status=excluded.status,updated_at=excluded.updated_at',
162
+ ).run(assistantID, sessionID, directory || null, status, updatedAt);
163
+ }
164
+
165
+ export function listWatchesBySession(db, sessionID) {
166
+ return db.prepare('SELECT assistant_id, session_id, directory, status FROM assistant_contact_watch WHERE session_id=?').all(sessionID)
167
+ .map((row) => ({
168
+ assistantID: row.assistant_id,
169
+ sessionID: row.session_id,
170
+ directory: row.directory,
171
+ status: row.status,
172
+ }));
173
+ }
174
+
175
+ export function listInFlightWatches(db, assistantID) {
176
+ const rows = typeof assistantID === 'string' && assistantID
177
+ ? db.prepare('SELECT assistant_id, session_id, directory, status FROM assistant_contact_watch WHERE assistant_id=?').all(assistantID)
178
+ : db.prepare('SELECT assistant_id, session_id, directory, status FROM assistant_contact_watch').all();
179
+ return rows
180
+ .filter((row) => IN_FLIGHT_WATCH.has(row.status))
181
+ .map((row) => ({
182
+ assistantID: row.assistant_id,
183
+ sessionID: row.session_id,
184
+ directory: row.directory,
185
+ status: row.status,
186
+ }));
187
+ }
188
+
189
+ export function updateSessionCardStatus(db, { assistantID, sessionID, status }) {
190
+ const rows = db.prepare(
191
+ 'SELECT p.message_id, p.part_id, p.ordinal, p.part_json FROM assistant_contact_part p JOIN assistant_contact_message m ON m.message_id=p.message_id WHERE m.assistant_id=?',
192
+ ).all(assistantID);
193
+ let updated = 0;
194
+ for (const row of rows) {
195
+ const part = parseContactPart(parse(row.part_json));
196
+ if (!part || part.type !== 'card' || part.cardType !== 'session' || part.sessionID !== sessionID) continue;
197
+ const next = { ...part, status };
198
+ db.prepare('UPDATE assistant_contact_part SET part_json=? WHERE message_id=? AND part_id=?').run(json(serializeContactPart(next, row.ordinal - 1)), row.message_id, row.part_id);
199
+ updated += 1;
200
+ }
201
+ return updated;
202
+ }
203
+
204
+ /**
205
+ * LLM-only contact window. SQLite and the transcript UI may keep older bubbles;
206
+ * this budget is the only model context. There is no summarizer and no
207
+ * user-facing compress / continuous / stateless control on this path.
208
+ *
209
+ * A turn is a user message plus the assistant replies that follow it until the
210
+ * next user. Newest turn is always kept, even when it exceeds the char budget.
211
+ * Char estimate is text length plus CONTACT_LLM_FILE_CHAR_WEIGHT per file part
212
+ * (CJK counts as one char; no tokenizer).
213
+ */
214
+ export const CONTACT_LLM_MAX_TURNS = 8;
215
+ export const CONTACT_LLM_MAX_CHARS = 6_000;
216
+ export const CONTACT_LLM_FETCH_LIMIT = 40;
217
+ export const CONTACT_LLM_FILE_CHAR_WEIGHT = 80;
218
+
219
+ const estimateLlmChars = (item) => {
220
+ const text = typeof item.content === 'string' ? item.content.length : 0;
221
+ const files = Array.isArray(item.parts) ? item.parts.length * CONTACT_LLM_FILE_CHAR_WEIGHT : 0;
222
+ return text + files;
223
+ };
224
+
225
+ const toLlmHistoryItem = (message) => {
226
+ const files = message.parts.filter((part) => part.type === 'file');
227
+ const content = message.text.trim() || (files.length > 0 ? '[attachment]' : '');
228
+ return files.length > 0 ? { role: message.role, content, parts: files } : { role: message.role, content };
229
+ };
230
+
231
+ const isLlmEligibleContactMessage = (message) => {
232
+ if (message.role !== 'user' && message.role !== 'assistant') return false;
233
+ if (message.status === 'error') return false;
234
+ // Peer DMs are read-only inbox rows. They must not become harness turns.
235
+ if (message.role === 'peer' || message.fromAssistantID) return false;
236
+ const hasText = Boolean(message.text?.trim());
237
+ const hasFiles = Array.isArray(message.parts) && message.parts.some((part) => part.type === 'file');
238
+ // Drop pure-card, tool-trace, and thinking-only rows. Process text is not
239
+ // persisted as contact bubbles; card-only rows have neither text nor files.
240
+ return hasText || hasFiles;
241
+ };
242
+
243
+ /** Turn-aware keep of recent user+assistant text pairs for the model only. */
244
+ export function trimContactHistoryForLlm(messages, {
245
+ maxTurns = CONTACT_LLM_MAX_TURNS,
246
+ maxChars = CONTACT_LLM_MAX_CHARS,
247
+ } = {}) {
248
+ const eligible = (Array.isArray(messages) ? messages : [])
249
+ .filter(isLlmEligibleContactMessage)
250
+ .map(toLlmHistoryItem)
251
+ .filter((item) => item.content.trim().length > 0);
252
+
253
+ const turns = [];
254
+ let current = [];
255
+ for (let index = eligible.length - 1; index >= 0; index -= 1) {
256
+ const item = eligible[index];
257
+ if (item.role === 'user') {
258
+ current.unshift(item);
259
+ turns.push(current);
260
+ current = [];
261
+ } else {
262
+ current.unshift(item);
263
+ }
264
+ }
265
+ if (current.length > 0) turns.push(current);
266
+
267
+ const kept = [];
268
+ let chars = 0;
269
+ for (const turn of turns) {
270
+ const turnChars = turn.reduce((sum, item) => sum + estimateLlmChars(item), 0);
271
+ if (kept.length >= maxTurns) break;
272
+ if (kept.length > 0 && chars + turnChars > maxChars) break;
273
+ kept.push(turn);
274
+ chars += turnChars;
275
+ }
276
+ return kept.reverse().flat();
277
+ }
278
+
279
+ export function contactHistoryForLlm(db, assistantID) {
280
+ const page = listContactMessages(db, assistantID, { limit: CONTACT_LLM_FETCH_LIMIT });
281
+ return trimContactHistoryForLlm(page.messages);
282
+ }
283
+
284
+ export { parseContactCard };