@openchambery/web 1.19.7-beta.2 → 1.19.7-beta.20
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-DI2zs2WH.js +1 -0
- package/dist/assets/AssistantView-C0p-8f2B.js +2 -0
- package/dist/assets/DiagramView-CSXRZQ5S.js +1 -0
- package/dist/assets/FadeInOnReveal-BWJcUp5P.js +1 -0
- package/dist/assets/Icon-4My8YZwt.js +1 -0
- package/dist/assets/MarkdownRendererImpl-C6rkoZYH.js +7 -0
- package/dist/assets/{MarkstreamRendererImpl-CR0fBG5w.css → MarkstreamRendererImpl-Ca7RATbs.css} +1 -1
- package/dist/assets/MarkstreamRendererImpl-CowOCU5W.js +1 -0
- package/dist/assets/MobileDetailNavigation-ACMbLTqb.js +1 -0
- package/dist/assets/MobileShareBridge-CXR-B1xW.js +4 -0
- package/dist/assets/MobileSurface-CKBSKqUH.js +2 -0
- package/dist/assets/{MultiRunWindow-khpnWyZO.js → MultiRunWindow-el9_nl25.js} +1 -1
- package/dist/assets/OnboardingScreen-BNEtPmvw.js +1 -0
- package/dist/assets/OverlayScrollbar-BZB21Yp_.js +2 -0
- package/dist/assets/SettingsView-CVSiIRia.js +25 -0
- package/dist/assets/{SettingsWindow-Dx5rU9v5.js → SettingsWindow-BXYswtp5.js} +1 -1
- package/dist/assets/TerminalView-Bh0IQwa5.js +8 -0
- package/dist/assets/{ToolOutputDialog-BfuXazW9.js → ToolOutputDialog-XeIJ2etT.js} +3 -3
- package/dist/assets/appThemeRegistry-rSdmh4sW.js +2 -0
- package/dist/assets/{button-z0GOBtWi.js → button-Or0daT9o.js} +1 -1
- package/dist/assets/clipboard-BXxLshVq.js +1 -0
- package/dist/assets/{collapsible-D9zJRpeB.js → collapsible-C-kzSGkq.js} +1 -1
- package/dist/assets/context-menu-Dpjla5p4.js +1 -0
- package/dist/assets/desktopHosts-BzSE0sK7.js +1 -0
- package/dist/assets/desktopRelayRestore-BaKjk1kQ.js +1 -0
- package/dist/assets/{diagram-CoEqmReY.js → diagram-D0mT3mo1.js} +1 -1
- package/dist/assets/{dialog-HCE4eLUW.js → dialog-BkhHBTd4.js} +1 -1
- package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
- package/dist/assets/{es-D9KcV5TX.js → es-BHqnSjoZ.js} +1 -1
- package/dist/assets/fileQueries-DFY6Kiy1.js +1 -0
- package/dist/assets/fileReferenceActions-3pPDAJSl.js +1 -0
- package/dist/assets/{fr-DPA9qUl3.js → fr-BfrlJY9i.js} +1 -1
- package/dist/assets/{gitApi-DNTMAa3F.js → gitApi-Uvb9w70Y.js} +3 -3
- package/dist/assets/{input-BG3VWTIF.js → input-BUsGnzwe.js} +1 -1
- package/dist/assets/insertionBoundaries-Bgec5nsP.js +4 -0
- package/dist/assets/{ja-Dsif3NoD.js → ja-DP-7qt-x.js} +1 -1
- package/dist/assets/{ko-Dixx4Ctg.js → ko-E2B5vGQP.js} +1 -1
- package/dist/assets/main-COfQt8Sj.js +169 -0
- package/dist/assets/main-PPlpRj1-.js +2 -0
- package/dist/assets/markdown-worker-wEFz5Hm_.js +3 -0
- package/dist/assets/markdownHeightCache-Bj707yG-.js +2 -0
- package/dist/assets/markdownTheme-CVS01wHf.js +2 -0
- package/dist/assets/miniChat-EZGRqQZr.js +2 -0
- package/dist/assets/mobile-DQNqMo5J.js +2 -0
- package/dist/assets/multirun-EViX73vm.js +4 -0
- package/dist/assets/outsideFileGrants-BB4GPvCq.js +1 -0
- package/dist/assets/path-utils-jGozTg9d.js +1 -0
- package/dist/assets/persistence-CSZp1CNB.js +3 -0
- package/dist/assets/{pl-Bj78ZGgf.js → pl-r8X5gRM5.js} +1 -1
- package/dist/assets/platform-CenlWqxu.js +2 -0
- package/dist/assets/projectMeta-Bv1L_nEb.js +3 -0
- package/dist/assets/{pt-BR-CFL3xSAf.js → pt-BR-DcpdXCf5.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-Nsq4Wc4s.js +2 -0
- package/dist/assets/renderMobileApp-5WJDYaMR.js +28 -0
- package/dist/assets/runtimeConfig-DKUQS4iY.js +2 -0
- package/dist/assets/runtimeEndpointReset-BMeu9Hqj.js +147 -0
- package/dist/assets/sessionChangeSummary-CX2MJIkc.js +1 -0
- package/dist/assets/sessionLookup-Sb2HN3G2.js +1 -0
- package/dist/assets/sortable-tabs-strip-C5gGnikj.js +1 -0
- package/dist/assets/src-Af9cMFKF.css +2 -0
- package/dist/assets/{store-DzpbXWX1.js → store-BDFdnk6l.js} +2 -2
- package/dist/assets/terminalApi-CIIs9vFx.js +1 -0
- package/dist/assets/textarea-CIO34TZz.js +1 -0
- package/dist/assets/{tooltip-CtH61mQh.js → tooltip-BSrJYYYc.js} +1 -1
- package/dist/assets/{uk-BY60Ub3Z.js → uk-Di0vl9V-.js} +1 -1
- package/dist/assets/url-CE_gAfNa.js +1 -0
- package/dist/assets/{ChatContainer-CnOrdAgs.js → useAppFontEffects-B_6vc0u3.js} +46 -41
- package/dist/assets/useAssistantUIStore-BHacrxe_.js +1 -0
- package/dist/assets/useDesktopWindowControlsLayout-B2L5PWHW.js +1 -0
- package/dist/assets/useEffectiveDirectory-CBd5vt8N.js +1 -0
- package/dist/assets/useFontPreferences-BxOHKuVw.js +1 -0
- package/dist/assets/useMobileNavigationStore-CUGaydRW.js +412 -0
- package/dist/assets/useSessionAutoCleanup-Clhzc1wn.js +1 -0
- package/dist/assets/useUIStore-CqqO11ZI.js +1 -0
- package/dist/assets/useWorkerHighlightedLines-C4iQlbPQ.js +7 -0
- package/dist/assets/utils-B0mQXN9L.js +1 -0
- package/dist/assets/{vendor-markstream-react-CwvbEg9R.js → vendor-markstream-react-C3K8YH0d.js} +4 -4
- package/dist/assets/{zh-CN-Cox1LaOW.js → zh-CN-coFCfeQE.js} +1 -1
- package/dist/assets/{zh-TW-B6RU0mxa.js → zh-TW-oDRZ1-yb.js} +1 -1
- package/dist/index.html +9 -11
- package/dist/mini-chat.html +9 -11
- package/dist/mobile.html +15 -17
- package/package.json +2 -1
- package/server/index.js +2 -0
- package/server/lib/assistant-delivery-parts.js +3 -1
- package/server/lib/assistant-delivery-parts.test.js +11 -0
- package/server/lib/assistants/DOCUMENTATION.md +76 -3
- package/server/lib/assistants/assign.js +225 -0
- package/server/lib/assistants/assign.test.js +118 -0
- package/server/lib/assistants/bubbles.js +45 -0
- package/server/lib/assistants/bubbles.test.js +36 -0
- package/server/lib/assistants/cards.js +148 -0
- package/server/lib/assistants/cards.test.js +116 -0
- package/server/lib/assistants/contact-store.js +284 -0
- package/server/lib/assistants/contact-store.test.js +131 -0
- package/server/lib/assistants/contact-tools.js +761 -0
- package/server/lib/assistants/contact-tools.test.js +328 -0
- package/server/lib/assistants/contracts.js +20 -1
- package/server/lib/assistants/harness.js +551 -0
- package/server/lib/assistants/harness.test.js +607 -0
- package/server/lib/assistants/routes.js +44 -4
- package/server/lib/assistants/service.js +930 -17
- package/server/lib/assistants/service.test.js +1163 -45
- package/server/lib/llm/DOCUMENTATION.md +84 -0
- package/server/lib/llm/catalog.js +100 -0
- package/server/lib/llm/catalog.test.js +78 -0
- package/server/lib/llm/completions.js +151 -0
- package/server/lib/llm/completions.test.js +127 -0
- package/server/lib/llm/generate.js +570 -0
- package/server/lib/llm/generate.test.js +459 -0
- package/server/lib/llm/routes.js +47 -0
- package/server/lib/llm/temp-directory.js +37 -0
- package/server/lib/notifications/APNS.md +4 -4
- package/server/lib/notifications/DOCUMENTATION.md +1 -1
- package/server/lib/notifications/apns-runtime.js +139 -20
- package/server/lib/notifications/apns-runtime.test.js +66 -7
- package/server/lib/notifications/apns-titles.js +62 -62
- package/server/lib/notifications/apns-titles.test.js +3 -1
- package/server/lib/notifications/routes.js +1 -1
- package/server/lib/notifications/runtime.js +81 -49
- package/server/lib/notifications/runtime.test.js +61 -0
- package/server/lib/opencode/feature-routes-runtime.js +30 -0
- package/server/lib/opencode/feature-routes-runtime.test.js +20 -0
- package/server/lib/opencode/settings-runtime.js +18 -2
- package/server/lib/session-goal/DOCUMENTATION.md +8 -1
- package/server/lib/session-goal/runtime.js +20 -0
- package/server/lib/session-index/DOCUMENTATION.md +3 -1
- package/server/lib/session-index/service.js +10 -4
- package/server/lib/session-index/service.test.js +37 -0
- package/server/lib/session-title/runtime.js +1 -0
- package/server/lib/session-title/runtime.test.js +11 -0
- package/dist/assets/AgentAvatar-ziUs4sw3.js +0 -1
- package/dist/assets/AssistantView-DF4yXFgS.js +0 -1
- package/dist/assets/ContextPanel-Cg_uCHzJ.js +0 -285
- package/dist/assets/DiagramView-DYh3Zn2y.js +0 -1
- package/dist/assets/FadeInOnReveal-BAlsRFJH.js +0 -1
- package/dist/assets/Icon-CLpn1mIz.js +0 -1
- package/dist/assets/MarkdownRendererImpl-QzUEpBEm.js +0 -7
- package/dist/assets/MarkstreamRendererImpl-CXrfeUia.js +0 -1
- package/dist/assets/MobileDetailNavigation-DJXEYfq4.js +0 -1
- package/dist/assets/MobileShareBridge-CliVo1YH.js +0 -4
- package/dist/assets/MobileSurface-9Xh6WQkY.js +0 -2
- package/dist/assets/OnboardingScreen-CJYyR54B.js +0 -1
- package/dist/assets/OverlayScrollbar-C-jZ8ITG.js +0 -2
- package/dist/assets/SettingsView-DyCZehFG.js +0 -25
- package/dist/assets/TerminalView-XSALfUaH.js +0 -8
- package/dist/assets/appThemeRegistry-DDWrniMM.js +0 -2
- package/dist/assets/clipboard-Bz0Zm9VJ.js +0 -1
- package/dist/assets/desktop-CfLlmafK.js +0 -1
- package/dist/assets/desktopHosts-C1Ns3DUu.js +0 -1
- package/dist/assets/desktopRelayRestore-LBt0CBOj.js +0 -1
- package/dist/assets/fileQueries-Dnn15WsA.js +0 -1
- package/dist/assets/insertionBoundaries-DMxuUnL_.js +0 -4
- package/dist/assets/main-B9HVtgXE.js +0 -2
- package/dist/assets/main-ZNyIs3Sn.js +0 -29
- package/dist/assets/markdown-worker-y3ewaqAK.js +0 -4
- package/dist/assets/markdownHeightCache-CvUyZT4q.js +0 -1
- package/dist/assets/markdownTheme-TdhQOZSN.js +0 -2
- package/dist/assets/miniChat-q0T0WGkZ.js +0 -2
- package/dist/assets/mobile-DEduvLJj.js +0 -2
- package/dist/assets/multirun-DU-wrxlG.js +0 -4
- package/dist/assets/persistence-CmjKImz2.js +0 -3
- package/dist/assets/platform-skqcMARt.js +0 -2
- package/dist/assets/projectMeta-DNWx6Uva.js +0 -3
- package/dist/assets/radio-BEonIX1k.js +0 -1
- package/dist/assets/renderElectronMiniChatApp--JYWY41L.js +0 -2
- package/dist/assets/renderMobileApp-B0Ix_uNo.js +0 -25
- package/dist/assets/runtime-switch-BkpTTbEi.js +0 -1
- package/dist/assets/runtimeConfig-BTDIG7mX.js +0 -2
- package/dist/assets/runtimeEndpointReset-CZQCYtVI.js +0 -4
- package/dist/assets/shikiHighlight-C9Cx56Qg.js +0 -1
- package/dist/assets/sortable-tabs-strip-Bj8N0VWf.js +0 -1
- package/dist/assets/src-2eFAiBLq.css +0 -2
- package/dist/assets/terminalApi-CNqqwjfG.js +0 -1
- package/dist/assets/url-KeihbE0d.js +0 -1
- package/dist/assets/useAppFontEffects-CKLSamae.js +0 -7
- package/dist/assets/useAssistantUIStore-DaplPyb0.js +0 -1
- package/dist/assets/useDesktopWindowControlsLayout-DJSkFfhT.js +0 -1
- package/dist/assets/useEffectiveDirectory-B3rbY1Kv.js +0 -1
- package/dist/assets/useFontPreferences-D8emhcNv.js +0 -1
- package/dist/assets/useMobileNavigationStore-CAvVUItx.js +0 -412
- package/dist/assets/useSessionAutoCleanup-DxNgqJ8s.js +0 -1
- package/dist/assets/useUIStore-B7KJS5G7.js +0 -1
- package/dist/assets/useWorkerHighlightedLines-DiDF68LR.js +0 -7
- package/dist/assets/utils-CTL16t2f.js +0 -1
|
@@ -5,9 +5,51 @@ import path from 'node:path';
|
|
|
5
5
|
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
|
6
6
|
import { validAssistantDeliveryParts } from '../assistant-delivery-parts.js';
|
|
7
7
|
import { reduceBackfillState } from './history-state.js';
|
|
8
|
+
import { getWorktrees as defaultListWorktrees } from '../git/service.js';
|
|
9
|
+
import { contactCardIdentity, parseContactCard, parseContactPart } from './cards.js';
|
|
10
|
+
import {
|
|
11
|
+
CONTACT_SETTLE_TEXT,
|
|
12
|
+
contactHistoryForLlm,
|
|
13
|
+
deleteContactMessages,
|
|
14
|
+
ensureContactSchema,
|
|
15
|
+
insertContactMessage,
|
|
16
|
+
listContactMessages,
|
|
17
|
+
listInFlightWatches,
|
|
18
|
+
listWatchesBySession,
|
|
19
|
+
nextContactOrdinal,
|
|
20
|
+
updateSessionCardStatus,
|
|
21
|
+
upsertContactWatch,
|
|
22
|
+
} from './contact-store.js';
|
|
23
|
+
import {
|
|
24
|
+
ASSIGNED_SESSION_FALLBACK_BUBBLE,
|
|
25
|
+
boundSessionListLimit,
|
|
26
|
+
confirmBubbleAfterContactReset,
|
|
27
|
+
createContactTools,
|
|
28
|
+
filterRegisteredProjects,
|
|
29
|
+
matchesProjectQuery,
|
|
30
|
+
normalizeRegisteredProjects,
|
|
31
|
+
sanitizeRegisteredProject,
|
|
32
|
+
} from './contact-tools.js';
|
|
33
|
+
import { AssignError, ASSIGN_CODES, assignSession, resolveAssignDirectory } from './assign.js';
|
|
34
|
+
import { runContactTurn as defaultRunContactTurn } from './harness.js';
|
|
8
35
|
|
|
9
36
|
const require = createRequire(import.meta.url);
|
|
10
37
|
const SCHEMA_VERSION = 11;
|
|
38
|
+
const normalizeSessionDirectory = (value) => {
|
|
39
|
+
if (typeof value !== 'string') return null;
|
|
40
|
+
const trimmed = value.trim();
|
|
41
|
+
if (!trimmed) return null;
|
|
42
|
+
let resolved = trimmed;
|
|
43
|
+
try { resolved = fs.realpathSync(trimmed); } catch { /* Keep the original path when realpath is unavailable. */ }
|
|
44
|
+
const normalized = resolved.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
45
|
+
return normalized || null;
|
|
46
|
+
};
|
|
47
|
+
const directoryContained = (candidate, root) => {
|
|
48
|
+
const left = normalizeSessionDirectory(candidate);
|
|
49
|
+
const right = normalizeSessionDirectory(root);
|
|
50
|
+
if (!left || !right) return false;
|
|
51
|
+
return left === right || left.startsWith(`${right}/`);
|
|
52
|
+
};
|
|
11
53
|
const BACKFILL_PAGE_SIZE = 100;
|
|
12
54
|
const BACKFILL_MAX_PAGES = 3;
|
|
13
55
|
const BACKFILL_MESSAGES_ATTEMPTS = 3;
|
|
@@ -20,8 +62,13 @@ const json = (value) => JSON.stringify(value);
|
|
|
20
62
|
const parse = (value) => JSON.parse(value);
|
|
21
63
|
const hash = (value) => crypto.createHash('sha256').update(json(value)).digest('hex');
|
|
22
64
|
const id = () => crypto.randomUUID();
|
|
23
|
-
export class AssistantError extends Error {
|
|
24
|
-
|
|
65
|
+
export class AssistantError extends Error {
|
|
66
|
+
constructor(code, message) {
|
|
67
|
+
super(typeof message === 'string' && message.trim() ? message.trim() : code);
|
|
68
|
+
this.code = code;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const fail = (code, message) => { throw new AssistantError(code, message); };
|
|
25
72
|
const string = (value, max = 10_000, required = false) => { if (value == null && !required) return null; if (typeof value !== 'string' || value.length > max || (required && !value.trim())) fail('validation_error'); return value.trim(); };
|
|
26
73
|
const nonEmptyString = (value, max = 10_000) => typeof value === 'string' && value.length > 0 && value.length <= max;
|
|
27
74
|
const isMissing = (result) => result?.error?.status === 404 || result?.error?.statusCode === 404 || result?.error?.code === 'not_found' || result?.status === 404;
|
|
@@ -41,7 +88,7 @@ const isTransientMessagesFailure = (result, error) => {
|
|
|
41
88
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
42
89
|
const promptAdmitted = (result) => !result?.error && (result?.response?.status === 204 || result?.status === 204 || result?.data !== undefined || result?.response?.ok === true);
|
|
43
90
|
|
|
44
|
-
export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, getOpenCodeAuthHeaders, getServerId = async () => null, getAllowedRoots = () => [], globalEventHub = null, onRevisionTip = null, clock = () => Date.now(), setIntervalFn = setInterval, clearIntervalFn = clearInterval, reconcileIntervalMs = 60_000, clientFactory } = {}) => {
|
|
91
|
+
export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, getOpenCodeAuthHeaders, getServerId = async () => null, getAllowedRoots = () => [], listProjects = async () => [], listScheduledTasks = null, sessionIndexService = null, upsertScheduledTask = null, syncScheduledTaskProject = null, globalEventHub = null, onRevisionTip = null, onContactTurnEvent = null, onContactTurnComplete = null, clock = () => Date.now(), setIntervalFn = setInterval, clearIntervalFn = clearInterval, setImmediateFn = setImmediate, reconcileIntervalMs = 60_000, clientFactory, createChatCompletion = null, runContactTurn = defaultRunContactTurn, listWorktrees = defaultListWorktrees } = {}) => {
|
|
45
92
|
if (!dbPath || !dataDir) return null;
|
|
46
93
|
const Database = require('better-sqlite3');
|
|
47
94
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
@@ -61,7 +108,17 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
61
108
|
CREATE INDEX IF NOT EXISTS assistant_message_mirror_page ON assistant_message_mirror(assistant_id, session_id, ordinal, message_id);
|
|
62
109
|
CREATE TABLE IF NOT EXISTS assistant_message_part_mirror (assistant_id TEXT NOT NULL, session_id TEXT NOT NULL, message_id TEXT NOT NULL, part_id TEXT NOT NULL, part_json TEXT NOT NULL, ordinal INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (assistant_id, session_id, message_id, part_id));
|
|
63
110
|
CREATE INDEX IF NOT EXISTS assistant_message_part_mirror_message ON assistant_message_part_mirror(assistant_id, session_id, message_id, ordinal, part_id);
|
|
64
|
-
CREATE TABLE IF NOT EXISTS assistant_message_backfill (assistant_id TEXT NOT NULL, session_id TEXT NOT NULL, cursor TEXT, complete INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, PRIMARY KEY (assistant_id, session_id))
|
|
111
|
+
CREATE TABLE IF NOT EXISTS assistant_message_backfill (assistant_id TEXT NOT NULL, session_id TEXT NOT NULL, cursor TEXT, complete INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, PRIMARY KEY (assistant_id, session_id));
|
|
112
|
+
CREATE TABLE IF NOT EXISTS assistant_scheduled_task (
|
|
113
|
+
assistant_id TEXT NOT NULL,
|
|
114
|
+
project_id TEXT NOT NULL,
|
|
115
|
+
task_id TEXT NOT NULL,
|
|
116
|
+
created_at INTEGER NOT NULL,
|
|
117
|
+
PRIMARY KEY (assistant_id, project_id, task_id)
|
|
118
|
+
);
|
|
119
|
+
CREATE INDEX IF NOT EXISTS assistant_scheduled_task_assistant
|
|
120
|
+
ON assistant_scheduled_task(assistant_id, created_at DESC);`);
|
|
121
|
+
ensureContactSchema(db);
|
|
65
122
|
const historyColumns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant_session_history')").all().map((column) => column.name));
|
|
66
123
|
if (!historyColumns.has('directory')) db.exec('ALTER TABLE assistant_session_history ADD COLUMN directory TEXT');
|
|
67
124
|
const mirrorColumns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant_message_mirror')").all().map((column) => column.name));
|
|
@@ -106,7 +163,33 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
106
163
|
db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=? AND session_id=?').run(assistantID, sessionID);
|
|
107
164
|
db.prepare("UPDATE assistant_message_mirror SET covered=0 WHERE assistant_id=? AND session_id=? AND COALESCE(json_extract(info_json,'$.role'),'')<>'user' AND COALESCE(json_extract(info_json,'$.openchamberAssistantAdmission'),0)<>1").run(assistantID, sessionID);
|
|
108
165
|
};
|
|
109
|
-
const output = (row) =>
|
|
166
|
+
const output = (row) => {
|
|
167
|
+
const watches = listInFlightWatches(db, row.assistant_id);
|
|
168
|
+
return {
|
|
169
|
+
id: row.assistant_id,
|
|
170
|
+
revision: row.revision,
|
|
171
|
+
enabled: Boolean(row.enabled),
|
|
172
|
+
name: row.name,
|
|
173
|
+
defaultPrompt: row.default_prompt,
|
|
174
|
+
workspacePath: row.workspace_path,
|
|
175
|
+
managedWorkspacePath: workspace(null, row.assistant_id, true),
|
|
176
|
+
effectiveWorkspacePath: effectiveWorkspace(row),
|
|
177
|
+
providerID: row.provider_id,
|
|
178
|
+
modelID: row.model_id,
|
|
179
|
+
agent: row.agent,
|
|
180
|
+
variant: row.variant,
|
|
181
|
+
mode: row.mode === 'stateless' ? 'stateless' : 'continuous',
|
|
182
|
+
sessionID: row.current_session_id,
|
|
183
|
+
sessionGeneration: row.session_generation,
|
|
184
|
+
historySessionIDs: historyIDs(row.assistant_id),
|
|
185
|
+
historySessionCount: historyCount(row.assistant_id),
|
|
186
|
+
assignedSessionIDs: watches.map((watch) => watch.sessionID),
|
|
187
|
+
working: watches.some((watch) => watch.status === 'busy'),
|
|
188
|
+
createdAt: row.created_at,
|
|
189
|
+
updatedAt: row.updated_at,
|
|
190
|
+
tombstoneAt: row.tombstone_at,
|
|
191
|
+
};
|
|
192
|
+
};
|
|
110
193
|
const binding = (row) => ({ sessionID: row.current_session_id, directory: effectiveWorkspace(row), sessionGeneration: row.session_generation });
|
|
111
194
|
const client = () => clientFactory ? clientFactory() : createOpencodeClient({ baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''), headers: getOpenCodeAuthHeaders() });
|
|
112
195
|
const metadata = (row) => ({ openchamber: { assistant: { assistantID: row.assistant_id, name: row.name } } });
|
|
@@ -235,6 +318,134 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
235
318
|
db.prepare('UPDATE assistant_message_mirror SET covered=0 WHERE assistant_id=? AND session_id=? AND message_id=?').run(assistantID, sessionID, messageID);
|
|
236
319
|
db.prepare('INSERT INTO assistant_message_backfill(assistant_id,session_id,cursor,complete,updated_at) VALUES (?,?,?,?,?) ON CONFLICT(assistant_id,session_id) DO UPDATE SET cursor=NULL,complete=0,updated_at=excluded.updated_at').run(assistantID, sessionID, null, 0, now());
|
|
237
320
|
};
|
|
321
|
+
const eventSessionID = (properties) => {
|
|
322
|
+
const sessionID = properties?.sessionID || properties?.sessionId || properties?.info?.sessionID || properties?.info?.sessionId;
|
|
323
|
+
return nonEmptyString(sessionID) ? sessionID : '';
|
|
324
|
+
};
|
|
325
|
+
const settleStatusFromEvent = (payload, properties) => {
|
|
326
|
+
if (payload.type === 'session.error') return 'error';
|
|
327
|
+
if (payload.type === 'question.asked' || payload.type === 'permission.asked') return 'question';
|
|
328
|
+
if (payload.type === 'session.idle') return 'complete';
|
|
329
|
+
if (payload.type === 'session.status') {
|
|
330
|
+
const statusType = typeof properties.status?.type === 'string' ? properties.status.type : typeof properties.info?.type === 'string' ? properties.info.type : '';
|
|
331
|
+
if (statusType === 'busy' || statusType === 'retry') return 'busy';
|
|
332
|
+
if (statusType === 'idle') return 'complete';
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
};
|
|
336
|
+
const reportAssignedSession = (sessionID, status) => {
|
|
337
|
+
const watches = listWatchesBySession(db, sessionID);
|
|
338
|
+
if (watches.length === 0) return false;
|
|
339
|
+
let changed = false;
|
|
340
|
+
db.exec('BEGIN IMMEDIATE');
|
|
341
|
+
try {
|
|
342
|
+
for (const watch of watches) {
|
|
343
|
+
if (watch.status === status) continue;
|
|
344
|
+
// session.idle / status idle follow session.error. Do not rewrite 失败 as 完成.
|
|
345
|
+
if (status === 'complete' && watch.status === 'error') continue;
|
|
346
|
+
updateSessionCardStatus(db, { assistantID: watch.assistantID, sessionID, status });
|
|
347
|
+
upsertContactWatch(db, {
|
|
348
|
+
assistantID: watch.assistantID,
|
|
349
|
+
sessionID,
|
|
350
|
+
directory: watch.directory,
|
|
351
|
+
status,
|
|
352
|
+
updatedAt: now(),
|
|
353
|
+
});
|
|
354
|
+
const settleText = CONTACT_SETTLE_TEXT[status];
|
|
355
|
+
const settleID = `settle_${watch.assistantID}_${sessionID}_${status}`;
|
|
356
|
+
if (settleText && !db.prepare('SELECT 1 AS ok FROM assistant_contact_message WHERE message_id=?').get(settleID)) {
|
|
357
|
+
insertContactMessage(db, {
|
|
358
|
+
messageID: settleID,
|
|
359
|
+
assistantID: watch.assistantID,
|
|
360
|
+
role: 'assistant',
|
|
361
|
+
turnID: `settle:${sessionID}`,
|
|
362
|
+
bubbleIndex: 0,
|
|
363
|
+
createdAt: now(),
|
|
364
|
+
ordinal: nextContactOrdinal(db, watch.assistantID),
|
|
365
|
+
status: 'complete',
|
|
366
|
+
parts: [{ type: 'text', text: settleText }],
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
changed = true;
|
|
370
|
+
}
|
|
371
|
+
if (changed) bump();
|
|
372
|
+
db.exec('COMMIT');
|
|
373
|
+
} catch (error) {
|
|
374
|
+
db.exec('ROLLBACK');
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
return changed;
|
|
378
|
+
};
|
|
379
|
+
const sessionStatusType = (session) => {
|
|
380
|
+
if (typeof session?.status?.type === 'string') return session.status.type;
|
|
381
|
+
if (typeof session?.status === 'string') return session.status;
|
|
382
|
+
if (typeof session?.type === 'string') return session.type;
|
|
383
|
+
return '';
|
|
384
|
+
};
|
|
385
|
+
const lastAssistantInfo = (messages) => {
|
|
386
|
+
if (!Array.isArray(messages)) return null;
|
|
387
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
388
|
+
const info = messages[index]?.info ?? messages[index];
|
|
389
|
+
if (info?.role === 'assistant') return info;
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
};
|
|
393
|
+
const inferAssignedSessionSettleStatus = (getResult, messagesResult) => {
|
|
394
|
+
if (isMissing(getResult)) return 'complete';
|
|
395
|
+
if (getResult?.error) return null;
|
|
396
|
+
const session = getResult?.data;
|
|
397
|
+
if (session?.error) return 'error';
|
|
398
|
+
const statusType = sessionStatusType(session);
|
|
399
|
+
if (statusType === 'busy' || statusType === 'retry') return null;
|
|
400
|
+
if (isMissing(messagesResult)) return 'complete';
|
|
401
|
+
const assistant = messagesResult && !messagesResult.error ? lastAssistantInfo(messagesResult.data) : null;
|
|
402
|
+
if (assistant?.error) return 'error';
|
|
403
|
+
if (statusType === 'idle' || session?.time?.completed) return 'complete';
|
|
404
|
+
if (assistant?.time?.completed) return 'complete';
|
|
405
|
+
return null;
|
|
406
|
+
};
|
|
407
|
+
const resolveWatchDirectory = (watch) => {
|
|
408
|
+
if (nonEmptyString(watch.directory)) {
|
|
409
|
+
try { return workspace(watch.directory, watch.assistantID); } catch { /* Fall through to the assistant workspace. */ }
|
|
410
|
+
}
|
|
411
|
+
const row = assistant(watch.assistantID);
|
|
412
|
+
if (!row || row.tombstone_at) return null;
|
|
413
|
+
try { return effectiveWorkspace(row); } catch { return null; }
|
|
414
|
+
};
|
|
415
|
+
const reconcileInFlightWatches = async () => {
|
|
416
|
+
if (closed) return;
|
|
417
|
+
let watches;
|
|
418
|
+
try {
|
|
419
|
+
watches = listInFlightWatches(db);
|
|
420
|
+
} catch {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
for (const watch of watches) {
|
|
424
|
+
if (closed) return;
|
|
425
|
+
try {
|
|
426
|
+
const directory = resolveWatchDirectory(watch);
|
|
427
|
+
if (!directory) continue;
|
|
428
|
+
let getResult;
|
|
429
|
+
try {
|
|
430
|
+
getResult = await client().session.get({ sessionID: watch.sessionID, directory });
|
|
431
|
+
} catch {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (closed) return;
|
|
435
|
+
let messagesResult = null;
|
|
436
|
+
try {
|
|
437
|
+
messagesResult = await client().session.messages({ sessionID: watch.sessionID, directory, limit: 100 });
|
|
438
|
+
} catch {
|
|
439
|
+
messagesResult = null;
|
|
440
|
+
}
|
|
441
|
+
if (closed) return;
|
|
442
|
+
const status = inferAssignedSessionSettleStatus(getResult, messagesResult);
|
|
443
|
+
if (status) reportAssignedSession(watch.sessionID, status);
|
|
444
|
+
} catch {
|
|
445
|
+
// One failed watch must not block unrelated watches.
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
};
|
|
238
449
|
const processEvent = (event) => {
|
|
239
450
|
const payload = event?.payload?.payload ?? event?.payload ?? event;
|
|
240
451
|
const properties = payload?.properties;
|
|
@@ -260,16 +471,24 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
260
471
|
const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { db.prepare('DELETE FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=? AND part_id=?').run(assistantID, sessionID, messageID, partID); if (assistant(assistantID)?.current_session_id !== sessionID) invalidateMessageCoverage(assistantID, sessionID, messageID); } return assistants.length > 0;
|
|
261
472
|
}
|
|
262
473
|
if (payload.type === 'session.idle' || payload.type === 'session.error') {
|
|
263
|
-
const sessionID = properties
|
|
264
|
-
if (!
|
|
265
|
-
const
|
|
474
|
+
const sessionID = eventSessionID(properties);
|
|
475
|
+
if (!sessionID) return false;
|
|
476
|
+
const reported = reportAssignedSession(sessionID, settleStatusFromEvent(payload, properties));
|
|
477
|
+
const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); }
|
|
478
|
+
return reported || assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
|
|
266
479
|
}
|
|
267
480
|
if (payload.type === 'session.status') {
|
|
268
|
-
const sessionID = properties
|
|
269
|
-
const
|
|
270
|
-
if (!
|
|
271
|
-
|
|
272
|
-
const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); }
|
|
481
|
+
const sessionID = eventSessionID(properties);
|
|
482
|
+
const status = settleStatusFromEvent(payload, properties);
|
|
483
|
+
if (!sessionID || !status) return false;
|
|
484
|
+
const reported = reportAssignedSession(sessionID, status);
|
|
485
|
+
const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); }
|
|
486
|
+
return reported || assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
|
|
487
|
+
}
|
|
488
|
+
if (payload.type === 'question.asked' || payload.type === 'permission.asked') {
|
|
489
|
+
const sessionID = eventSessionID(properties);
|
|
490
|
+
if (!sessionID) return false;
|
|
491
|
+
return reportAssignedSession(sessionID, 'question');
|
|
273
492
|
}
|
|
274
493
|
return false;
|
|
275
494
|
};
|
|
@@ -407,7 +626,689 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
407
626
|
if (promptAdmitted(result)) mirrorAdmittedUserMessage(row, sessionID, messageID, parts, config);
|
|
408
627
|
return { result, binding: binding(row) };
|
|
409
628
|
};
|
|
410
|
-
const
|
|
629
|
+
const extractUserText = (input) => {
|
|
630
|
+
if (typeof input.text === 'string' && input.text.trim()) return input.text.trim();
|
|
631
|
+
if (!Array.isArray(input.parts)) return '';
|
|
632
|
+
const text = input.parts.filter((part) => part?.type === 'text' && typeof part.text === 'string').map((part) => part.text).join('\n').trim();
|
|
633
|
+
if (text) return text;
|
|
634
|
+
return input.parts.some((part) => part?.type === 'file') ? '[attachment]' : '';
|
|
635
|
+
};
|
|
636
|
+
const userContactParts = (input, userText) => {
|
|
637
|
+
const parts = [];
|
|
638
|
+
if (Array.isArray(input.parts)) {
|
|
639
|
+
for (const part of input.parts) {
|
|
640
|
+
const parsed = parseContactPart(part);
|
|
641
|
+
if (parsed?.type === 'text' && parsed.text.trim()) parts.push({ type: 'text', text: parsed.text });
|
|
642
|
+
if (parsed?.type === 'file') parts.push(parsed);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (parts.length === 0 && userText) parts.push({ type: 'text', text: userText });
|
|
646
|
+
return parts;
|
|
647
|
+
};
|
|
648
|
+
/** Admit the user row only — HTTP 202 returns here; assistant bubbles persist later. */
|
|
649
|
+
const admitContactUser = (assistantID, { userMessageID, userText, userParts, turnID }) => {
|
|
650
|
+
db.exec('BEGIN IMMEDIATE');
|
|
651
|
+
try {
|
|
652
|
+
insertContactMessage(db, {
|
|
653
|
+
messageID: userMessageID,
|
|
654
|
+
assistantID,
|
|
655
|
+
role: 'user',
|
|
656
|
+
turnID,
|
|
657
|
+
bubbleIndex: 0,
|
|
658
|
+
createdAt: now(),
|
|
659
|
+
ordinal: nextContactOrdinal(db, assistantID),
|
|
660
|
+
status: 'complete',
|
|
661
|
+
parts: Array.isArray(userParts) && userParts.length > 0 ? userParts : [{ type: 'text', text: userText }],
|
|
662
|
+
});
|
|
663
|
+
bump();
|
|
664
|
+
db.exec('COMMIT');
|
|
665
|
+
} catch (error) {
|
|
666
|
+
db.exec('ROLLBACK');
|
|
667
|
+
throw error;
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
/** Persist assistant bubbles/cards for an already-admitted user turn (no user row). */
|
|
671
|
+
const persistContactAssistantReply = (assistantID, { userMessageID, bubbles, cards = [], turnID }) => {
|
|
672
|
+
db.exec('BEGIN IMMEDIATE');
|
|
673
|
+
try {
|
|
674
|
+
let ordinal = nextContactOrdinal(db, assistantID);
|
|
675
|
+
bubbles.forEach((text, index) => {
|
|
676
|
+
const bubbleID = `${userMessageID}:bubble:${index + 1}`;
|
|
677
|
+
insertContactMessage(db, {
|
|
678
|
+
messageID: bubbleID,
|
|
679
|
+
assistantID,
|
|
680
|
+
role: 'assistant',
|
|
681
|
+
turnID,
|
|
682
|
+
bubbleIndex: index,
|
|
683
|
+
createdAt: now(),
|
|
684
|
+
ordinal,
|
|
685
|
+
status: 'complete',
|
|
686
|
+
parts: [{ type: 'text', text }],
|
|
687
|
+
});
|
|
688
|
+
ordinal += 1;
|
|
689
|
+
});
|
|
690
|
+
cards.forEach((cardInput, index) => {
|
|
691
|
+
const card = parseContactCard({ type: 'card', cardType: cardInput?.cardType || 'session', ...cardInput });
|
|
692
|
+
if (!card) return;
|
|
693
|
+
insertContactMessage(db, {
|
|
694
|
+
messageID: `${userMessageID}:card:${index + 1}`,
|
|
695
|
+
assistantID,
|
|
696
|
+
role: 'assistant',
|
|
697
|
+
turnID,
|
|
698
|
+
bubbleIndex: 0,
|
|
699
|
+
createdAt: now(),
|
|
700
|
+
ordinal,
|
|
701
|
+
status: 'complete',
|
|
702
|
+
parts: [card],
|
|
703
|
+
});
|
|
704
|
+
ordinal += 1;
|
|
705
|
+
if (card.cardType === 'session' && card.sessionID) {
|
|
706
|
+
upsertContactWatch(db, {
|
|
707
|
+
assistantID,
|
|
708
|
+
sessionID: card.sessionID,
|
|
709
|
+
directory: card.directory,
|
|
710
|
+
status: card.status === 'error' || card.status === 'question' || card.status === 'complete' ? card.status : 'busy',
|
|
711
|
+
updatedAt: now(),
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
bump();
|
|
716
|
+
db.exec('COMMIT');
|
|
717
|
+
} catch (error) {
|
|
718
|
+
db.exec('ROLLBACK');
|
|
719
|
+
throw error;
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
const notifyContactTurn = ({ assistantID, name, turnID, status, body }) => {
|
|
723
|
+
if (typeof onContactTurnComplete !== 'function' || closed) return;
|
|
724
|
+
try {
|
|
725
|
+
const result = onContactTurnComplete({
|
|
726
|
+
assistantID,
|
|
727
|
+
name,
|
|
728
|
+
turnID,
|
|
729
|
+
status,
|
|
730
|
+
body: typeof body === 'string' ? body : '',
|
|
731
|
+
});
|
|
732
|
+
if (result && typeof result.then === 'function') {
|
|
733
|
+
result.catch(() => { /* Notification fanout must not fail the turn. */ });
|
|
734
|
+
}
|
|
735
|
+
} catch {
|
|
736
|
+
// Notification fanout must not fail the turn.
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
const emitContactTurnEvent = (type, properties) => {
|
|
740
|
+
if (typeof onContactTurnEvent !== 'function' || closed) return;
|
|
741
|
+
const payload = { type, properties: { ...properties, occurredAt: properties?.occurredAt ?? now() } };
|
|
742
|
+
queueMicrotask(() => {
|
|
743
|
+
if (!closed) {
|
|
744
|
+
try { onContactTurnEvent(payload); } catch { /* SSE fan-out must not break the turn. */ }
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
};
|
|
748
|
+
// Serialize contact turns per assistant so rapid sends do not interleave persist.
|
|
749
|
+
const contactTurnLanes = new Map();
|
|
750
|
+
const inContactTurnLane = (assistantID, task) => {
|
|
751
|
+
const previous = contactTurnLanes.get(assistantID) ?? Promise.resolve();
|
|
752
|
+
let tail;
|
|
753
|
+
const run = previous.catch(() => {}).then(task);
|
|
754
|
+
tail = run.catch(() => {}).finally(() => {
|
|
755
|
+
if (contactTurnLanes.get(assistantID) === tail) contactTurnLanes.delete(assistantID);
|
|
756
|
+
});
|
|
757
|
+
contactTurnLanes.set(assistantID, tail);
|
|
758
|
+
return run;
|
|
759
|
+
};
|
|
760
|
+
// In-process test hook — never included in the HTTP JSON body.
|
|
761
|
+
const contactTurnSettlements = new Map();
|
|
762
|
+
const contactTurnSettlement = (messageID) => {
|
|
763
|
+
let entry = contactTurnSettlements.get(messageID);
|
|
764
|
+
if (!entry) {
|
|
765
|
+
let resolve;
|
|
766
|
+
const promise = new Promise((settled) => { resolve = settled; });
|
|
767
|
+
entry = { promise, resolve };
|
|
768
|
+
contactTurnSettlements.set(messageID, entry);
|
|
769
|
+
}
|
|
770
|
+
return entry;
|
|
771
|
+
};
|
|
772
|
+
const whenContactTurnSettled = (messageID) => contactTurnSettlement(messageID).promise;
|
|
773
|
+
const resolveContactTurnSettlement = (messageID, result) => {
|
|
774
|
+
contactTurnSettlement(messageID).resolve(result);
|
|
775
|
+
};
|
|
776
|
+
const assignWork = (row, params) => assignSession({
|
|
777
|
+
...params,
|
|
778
|
+
assistant: output(row),
|
|
779
|
+
defaultProjectPath: row.workspace_path,
|
|
780
|
+
allowedRoots: getAllowedRoots(),
|
|
781
|
+
managedWorkspaceRoot: path.resolve(dataDir, 'assistant-workspaces'),
|
|
782
|
+
listWorktrees,
|
|
783
|
+
createSession: (created) => client().session.create(created),
|
|
784
|
+
promptExisting: (prompted) => client().session.promptAsync(prompted),
|
|
785
|
+
messageID: params?.messageID || `msg_assign_${id()}`,
|
|
786
|
+
});
|
|
787
|
+
const matchRegisteredProject = async (directory) => {
|
|
788
|
+
const projects = await listProjects();
|
|
789
|
+
const resolved = path.resolve(directory);
|
|
790
|
+
let real = resolved;
|
|
791
|
+
try { real = fs.realpathSync(resolved); } catch { /* Compare the resolved path when realpath is unavailable. */ }
|
|
792
|
+
const contained = (candidate, root) => candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
793
|
+
for (const project of Array.isArray(projects) ? projects : []) {
|
|
794
|
+
const sanitized = sanitizeRegisteredProject(project);
|
|
795
|
+
if (!sanitized) continue;
|
|
796
|
+
let projectPath = path.resolve(sanitized.path);
|
|
797
|
+
try { projectPath = fs.realpathSync(projectPath); } catch { /* Keep the resolved project path. */ }
|
|
798
|
+
if (projectPath === real || contained(real, projectPath)) {
|
|
799
|
+
return { id: sanitized.id, path: projectPath, ...(sanitized.label ? { label: sanitized.label } : {}) };
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return null;
|
|
803
|
+
};
|
|
804
|
+
const recordAssistantScheduledTask = (assistantID, projectID, taskID) => {
|
|
805
|
+
if (typeof assistantID !== 'string' || !assistantID.trim()) return;
|
|
806
|
+
if (typeof projectID !== 'string' || !projectID.trim()) return;
|
|
807
|
+
if (typeof taskID !== 'string' || !taskID.trim()) return;
|
|
808
|
+
db.prepare(
|
|
809
|
+
'INSERT OR IGNORE INTO assistant_scheduled_task(assistant_id, project_id, task_id, created_at) VALUES (?,?,?,?)',
|
|
810
|
+
).run(assistantID, projectID, taskID, now());
|
|
811
|
+
};
|
|
812
|
+
const scheduleWork = async (row, params) => {
|
|
813
|
+
if (typeof upsertScheduledTask !== 'function') {
|
|
814
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Scheduling a task is unavailable.');
|
|
815
|
+
}
|
|
816
|
+
const projectDirectory = resolveAssignDirectory({
|
|
817
|
+
projectPath: params?.projectPath,
|
|
818
|
+
directory: params?.directory,
|
|
819
|
+
branch: null,
|
|
820
|
+
allowedRoots: getAllowedRoots(),
|
|
821
|
+
managedWorkspaceRoot: path.resolve(dataDir, 'assistant-workspaces'),
|
|
822
|
+
defaultProjectPath: row.workspace_path,
|
|
823
|
+
});
|
|
824
|
+
const project = await matchRegisteredProject(projectDirectory);
|
|
825
|
+
if (!project) {
|
|
826
|
+
throw new AssignError(
|
|
827
|
+
ASSIGN_CODES.PROJECT_REQUIRED,
|
|
828
|
+
'Choose a registered project path. Several projects are configured; schedule_task cannot guess.',
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
const kind = typeof params?.kind === 'string' && params.kind.trim() ? params.kind.trim() : 'daily';
|
|
832
|
+
const weekdays = Array.isArray(params?.weekdays)
|
|
833
|
+
? params.weekdays
|
|
834
|
+
: typeof params?.weekdays === 'string'
|
|
835
|
+
? params.weekdays.split(',').map((item) => Number(item.trim())).filter((item) => Number.isInteger(item))
|
|
836
|
+
: undefined;
|
|
837
|
+
const upserted = await upsertScheduledTask(project.id, {
|
|
838
|
+
name: params.name,
|
|
839
|
+
enabled: true,
|
|
840
|
+
schedule: {
|
|
841
|
+
kind,
|
|
842
|
+
...(params.time ? { time: params.time } : {}),
|
|
843
|
+
...(params.timezone ? { timezone: params.timezone } : {}),
|
|
844
|
+
...(params.date ? { date: params.date } : {}),
|
|
845
|
+
...(weekdays && weekdays.length > 0 ? { weekdays } : {}),
|
|
846
|
+
...(params.cron ? { cron: params.cron } : {}),
|
|
847
|
+
},
|
|
848
|
+
execution: {
|
|
849
|
+
prompt: params.prompt,
|
|
850
|
+
providerID: params.providerID,
|
|
851
|
+
modelID: params.modelID,
|
|
852
|
+
},
|
|
853
|
+
});
|
|
854
|
+
const taskID = upserted?.task?.id;
|
|
855
|
+
if (typeof taskID === 'string' && taskID.trim()) {
|
|
856
|
+
recordAssistantScheduledTask(row.assistant_id, project.id, taskID);
|
|
857
|
+
}
|
|
858
|
+
if (typeof syncScheduledTaskProject === 'function') {
|
|
859
|
+
try { await syncScheduledTaskProject(project.id); } catch { /* Card persistence does not depend on scheduler sync. */ }
|
|
860
|
+
}
|
|
861
|
+
return {
|
|
862
|
+
task: upserted?.task,
|
|
863
|
+
taskID,
|
|
864
|
+
projectID: project.id,
|
|
865
|
+
name: upserted?.task?.name || params.name,
|
|
866
|
+
kind: upserted?.task?.schedule?.kind || kind,
|
|
867
|
+
time: upserted?.task?.schedule?.time || upserted?.task?.schedule?.times?.[0] || params.time || null,
|
|
868
|
+
timezone: upserted?.task?.schedule?.timezone || params.timezone || null,
|
|
869
|
+
prompt: upserted?.task?.execution?.prompt || params.prompt,
|
|
870
|
+
};
|
|
871
|
+
};
|
|
872
|
+
const listSessionsFromIndex = (params = {}) => {
|
|
873
|
+
if (!sessionIndexService || typeof sessionIndexService.snapshot !== 'function') {
|
|
874
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session index is unavailable.');
|
|
875
|
+
}
|
|
876
|
+
let snapshot;
|
|
877
|
+
try {
|
|
878
|
+
snapshot = sessionIndexService.snapshot();
|
|
879
|
+
} catch (error) {
|
|
880
|
+
throw new AssignError(
|
|
881
|
+
ASSIGN_CODES.UPSTREAM,
|
|
882
|
+
typeof error?.message === 'string' && error.message.trim()
|
|
883
|
+
? error.message.trim()
|
|
884
|
+
: 'Session index snapshot failed.',
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
if (!snapshot || !Array.isArray(snapshot.directories)) {
|
|
888
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session index snapshot failed.');
|
|
889
|
+
}
|
|
890
|
+
const limit = boundSessionListLimit(params.limit);
|
|
891
|
+
const query = typeof params.query === 'string' ? params.query.trim() : '';
|
|
892
|
+
const projectPath = typeof params.projectPath === 'string' ? params.projectPath.trim() : '';
|
|
893
|
+
const projectID = typeof params.projectID === 'string' ? params.projectID.trim() : '';
|
|
894
|
+
const scopeRoots = [];
|
|
895
|
+
if (projectPath) scopeRoots.push(projectPath);
|
|
896
|
+
if (Array.isArray(params.projectRoots)) {
|
|
897
|
+
for (const root of params.projectRoots) {
|
|
898
|
+
if (typeof root === 'string' && root.trim()) scopeRoots.push(root.trim());
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
const sessions = [];
|
|
902
|
+
for (const directory of snapshot.directories) {
|
|
903
|
+
const dir = typeof directory?.directory === 'string' ? directory.directory : '';
|
|
904
|
+
if (!dir) continue;
|
|
905
|
+
if (scopeRoots.length > 0 && !scopeRoots.some((root) => directoryContained(dir, root))) continue;
|
|
906
|
+
for (const session of Array.isArray(directory.sessions) ? directory.sessions : []) {
|
|
907
|
+
if (!session || typeof session.id !== 'string' || !session.id) continue;
|
|
908
|
+
const title = typeof session.title === 'string' ? session.title : '';
|
|
909
|
+
const updatedAt = typeof session.time?.updated === 'number'
|
|
910
|
+
? session.time.updated
|
|
911
|
+
: (typeof session.updatedAt === 'number' ? session.updatedAt : 0);
|
|
912
|
+
const row = {
|
|
913
|
+
sessionID: session.id,
|
|
914
|
+
title,
|
|
915
|
+
directory: dir,
|
|
916
|
+
updatedAt,
|
|
917
|
+
};
|
|
918
|
+
if (query && !matchesProjectQuery({ ...row, id: session.id }, query)) continue;
|
|
919
|
+
sessions.push(row);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
sessions.sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
|
|
923
|
+
const truncated = sessions.length > limit;
|
|
924
|
+
return {
|
|
925
|
+
sessions: sessions.slice(0, limit),
|
|
926
|
+
truncated,
|
|
927
|
+
source: 'session-index',
|
|
928
|
+
...(projectID ? { projectID } : {}),
|
|
929
|
+
};
|
|
930
|
+
};
|
|
931
|
+
const listSessionsWork = async (params = {}) => {
|
|
932
|
+
const projectPath = typeof params.projectPath === 'string' ? params.projectPath.trim() : '';
|
|
933
|
+
const projectID = typeof params.projectID === 'string' ? params.projectID.trim() : '';
|
|
934
|
+
let projectRoots = [];
|
|
935
|
+
if (projectID || projectPath) {
|
|
936
|
+
let projects;
|
|
937
|
+
try {
|
|
938
|
+
projects = await listProjects();
|
|
939
|
+
} catch (error) {
|
|
940
|
+
throw new AssignError(
|
|
941
|
+
ASSIGN_CODES.UPSTREAM,
|
|
942
|
+
typeof error?.message === 'string' && error.message.trim()
|
|
943
|
+
? error.message.trim()
|
|
944
|
+
: 'Registered project catalog failed to load.',
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
if (!Array.isArray(projects)) {
|
|
948
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Registered project catalog failed to load.');
|
|
949
|
+
}
|
|
950
|
+
const normalized = normalizeRegisteredProjects(projects);
|
|
951
|
+
if (projectID) {
|
|
952
|
+
const match = normalized.find((project) => project.id === projectID);
|
|
953
|
+
if (!match) {
|
|
954
|
+
throw new AssignError(ASSIGN_CODES.PROJECT_REQUIRED, `No registered project with id ${projectID}.`);
|
|
955
|
+
}
|
|
956
|
+
projectRoots = [match.path];
|
|
957
|
+
} else if (projectPath) {
|
|
958
|
+
const match = await matchRegisteredProject(projectPath);
|
|
959
|
+
if (match) projectRoots = [match.path];
|
|
960
|
+
else projectRoots = [projectPath];
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return listSessionsFromIndex({
|
|
964
|
+
...params,
|
|
965
|
+
projectPath: projectRoots[0] || projectPath || undefined,
|
|
966
|
+
projectRoots,
|
|
967
|
+
projectID: projectID || undefined,
|
|
968
|
+
});
|
|
969
|
+
};
|
|
970
|
+
const listAssistantScheduledTasks = async (assistantID) => {
|
|
971
|
+
editable(assistantID);
|
|
972
|
+
const mappings = db.prepare(
|
|
973
|
+
'SELECT assistant_id AS assistantID, project_id AS projectID, task_id AS taskID, created_at AS createdAt FROM assistant_scheduled_task WHERE assistant_id=? ORDER BY created_at DESC, task_id ASC',
|
|
974
|
+
).all(assistantID);
|
|
975
|
+
if (mappings.length === 0) return { tasks: [] };
|
|
976
|
+
let projects = [];
|
|
977
|
+
try {
|
|
978
|
+
const listed = await listProjects();
|
|
979
|
+
projects = normalizeRegisteredProjects(Array.isArray(listed) ? listed : []);
|
|
980
|
+
} catch {
|
|
981
|
+
// Failed project lookups must not wipe the mapping.
|
|
982
|
+
projects = [];
|
|
983
|
+
}
|
|
984
|
+
const projectByID = new Map(projects.map((project) => [project.id, project]));
|
|
985
|
+
const liveByKey = new Map();
|
|
986
|
+
if (typeof listScheduledTasks === 'function') {
|
|
987
|
+
const projectIDs = [...new Set(mappings.map((row) => row.projectID))];
|
|
988
|
+
await Promise.all(projectIDs.map(async (projectID) => {
|
|
989
|
+
try {
|
|
990
|
+
const tasks = await listScheduledTasks(projectID);
|
|
991
|
+
if (!Array.isArray(tasks)) return;
|
|
992
|
+
for (const task of tasks) {
|
|
993
|
+
if (task && typeof task.id === 'string') liveByKey.set(`${projectID}:${task.id}`, task);
|
|
994
|
+
}
|
|
995
|
+
} catch {
|
|
996
|
+
// Keep the mapping row with task:null when live lookup fails.
|
|
997
|
+
}
|
|
998
|
+
}));
|
|
999
|
+
}
|
|
1000
|
+
return {
|
|
1001
|
+
tasks: mappings.map((row) => {
|
|
1002
|
+
const project = projectByID.get(row.projectID) || null;
|
|
1003
|
+
const live = liveByKey.get(`${row.projectID}:${row.taskID}`) || null;
|
|
1004
|
+
return {
|
|
1005
|
+
assistantID: row.assistantID,
|
|
1006
|
+
projectID: row.projectID,
|
|
1007
|
+
taskID: row.taskID,
|
|
1008
|
+
createdAt: row.createdAt,
|
|
1009
|
+
projectPath: project?.path ?? null,
|
|
1010
|
+
projectLabel: project?.label ?? null,
|
|
1011
|
+
task: live,
|
|
1012
|
+
};
|
|
1013
|
+
}),
|
|
1014
|
+
};
|
|
1015
|
+
};
|
|
1016
|
+
/**
|
|
1017
|
+
* Contact composer send: validate → admit user (202) → async turn.
|
|
1018
|
+
* Does not await the LLM. Live bubble tokens use openchamber:contact-bubble-delta
|
|
1019
|
+
* on /api/openchamber/events; turn lifecycle uses contact-turn-start/end.
|
|
1020
|
+
*/
|
|
1021
|
+
const send = async (assistantID, input) => {
|
|
1022
|
+
if (!plainObject(input)) fail('validation_error');
|
|
1023
|
+
if (input.parts !== undefined) validateParts(input.parts);
|
|
1024
|
+
const messageID = string(input.messageID, 256, true);
|
|
1025
|
+
const userText = extractUserText(input);
|
|
1026
|
+
if (!userText) fail('validation_error');
|
|
1027
|
+
const userParts = userContactParts(input, userText);
|
|
1028
|
+
const row = active(assistantID);
|
|
1029
|
+
// Snapshot history before admitting the user so the harness does not see a
|
|
1030
|
+
// duplicate of the current userText (prompt still receives userText).
|
|
1031
|
+
const history = contactHistoryForLlm(db, assistantID);
|
|
1032
|
+
if (typeof createChatCompletion !== 'function' && runContactTurn === defaultRunContactTurn) fail('upstream_error');
|
|
1033
|
+
let registeredProjects = [];
|
|
1034
|
+
try {
|
|
1035
|
+
const listed = await listProjects();
|
|
1036
|
+
if (!Array.isArray(listed)) {
|
|
1037
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Registered project catalog failed to load.');
|
|
1038
|
+
}
|
|
1039
|
+
registeredProjects = filterRegisteredProjects(listed);
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
if (error instanceof AssignError || error instanceof AssistantError) throw error;
|
|
1042
|
+
// Still allow the turn — tools can surface a later catalog failure.
|
|
1043
|
+
registeredProjects = [];
|
|
1044
|
+
}
|
|
1045
|
+
const turnID = messageID;
|
|
1046
|
+
const assistantSnapshot = output(row);
|
|
1047
|
+
admitContactUser(row.assistant_id, {
|
|
1048
|
+
userMessageID: messageID,
|
|
1049
|
+
userText,
|
|
1050
|
+
userParts,
|
|
1051
|
+
turnID,
|
|
1052
|
+
});
|
|
1053
|
+
emitContactTurnEvent('openchamber:contact-turn-start', {
|
|
1054
|
+
assistantID: row.assistant_id,
|
|
1055
|
+
turnID,
|
|
1056
|
+
messageID,
|
|
1057
|
+
});
|
|
1058
|
+
contactTurnSettlement(messageID);
|
|
1059
|
+
const kickTurn = () => {
|
|
1060
|
+
void inContactTurnLane(row.assistant_id, async () => {
|
|
1061
|
+
const assignedCards = [];
|
|
1062
|
+
let contactResetThisTurn = false;
|
|
1063
|
+
const tools = createContactTools({
|
|
1064
|
+
assignWork: (params) => assignWork(row, params),
|
|
1065
|
+
createAssistant: (toolInput) => createAssistant(toolInput),
|
|
1066
|
+
scheduleTask: (params) => scheduleWork(row, params),
|
|
1067
|
+
deliverPeerMessage: (toolInput) => deliverPeerMessage(row.assistant_id, toolInput),
|
|
1068
|
+
resetContact: () => {
|
|
1069
|
+
const result = resetContact(row.assistant_id);
|
|
1070
|
+
contactResetThisTurn = true;
|
|
1071
|
+
return result;
|
|
1072
|
+
},
|
|
1073
|
+
listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
|
|
1074
|
+
listProjects: async () => {
|
|
1075
|
+
const listed = await listProjects();
|
|
1076
|
+
if (!Array.isArray(listed)) {
|
|
1077
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Registered project catalog failed to load.');
|
|
1078
|
+
}
|
|
1079
|
+
return listed;
|
|
1080
|
+
},
|
|
1081
|
+
listSessions: (params) => listSessionsWork(params),
|
|
1082
|
+
currentAssistant: assistantSnapshot,
|
|
1083
|
+
onCard: (card) => assignedCards.push(card),
|
|
1084
|
+
});
|
|
1085
|
+
try {
|
|
1086
|
+
const generated = await runContactTurn({
|
|
1087
|
+
assistant: assistantSnapshot,
|
|
1088
|
+
history,
|
|
1089
|
+
userText,
|
|
1090
|
+
userParts,
|
|
1091
|
+
createChatCompletion,
|
|
1092
|
+
tools,
|
|
1093
|
+
projects: registeredProjects,
|
|
1094
|
+
globalEventHub,
|
|
1095
|
+
onBubbleDelta: (bubbleIndex, delta, done) => {
|
|
1096
|
+
emitContactTurnEvent('openchamber:contact-bubble-delta', {
|
|
1097
|
+
assistantID: row.assistant_id,
|
|
1098
|
+
turnID,
|
|
1099
|
+
bubbleIndex,
|
|
1100
|
+
delta: typeof delta === 'string' ? delta : '',
|
|
1101
|
+
done: Boolean(done),
|
|
1102
|
+
});
|
|
1103
|
+
},
|
|
1104
|
+
});
|
|
1105
|
+
if (closed) {
|
|
1106
|
+
resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
const resetThisTurn = contactResetThisTurn || generated?.reset === true;
|
|
1110
|
+
const bubbles = resetThisTurn
|
|
1111
|
+
? confirmBubbleAfterContactReset(generated?.bubbles)
|
|
1112
|
+
: (Array.isArray(generated?.bubbles) ? generated.bubbles.filter((item) => typeof item === 'string' && item.trim()) : []);
|
|
1113
|
+
const cards = resetThisTurn
|
|
1114
|
+
? []
|
|
1115
|
+
: [
|
|
1116
|
+
...assignedCards,
|
|
1117
|
+
...(Array.isArray(generated?.cards) ? generated.cards : []),
|
|
1118
|
+
].filter((card, index, list) => list.findIndex((item) => contactCardIdentity(item) === contactCardIdentity(card)) === index);
|
|
1119
|
+
if (bubbles.length === 0 && cards.length === 0) {
|
|
1120
|
+
const detail = 'Assistant returned no text';
|
|
1121
|
+
emitContactTurnEvent('openchamber:contact-turn-end', {
|
|
1122
|
+
assistantID: row.assistant_id,
|
|
1123
|
+
turnID,
|
|
1124
|
+
status: 'error',
|
|
1125
|
+
error: detail,
|
|
1126
|
+
});
|
|
1127
|
+
notifyContactTurn({
|
|
1128
|
+
assistantID: row.assistant_id,
|
|
1129
|
+
name: assistantSnapshot.name,
|
|
1130
|
+
turnID,
|
|
1131
|
+
status: 'error',
|
|
1132
|
+
body: detail,
|
|
1133
|
+
});
|
|
1134
|
+
resolveContactTurnSettlement(messageID, { status: 'error', error: detail });
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
// new_conversation wipes the transcript (including the admitted user row).
|
|
1138
|
+
// Re-admit the reset user message, then persist only the confirm bubble.
|
|
1139
|
+
if (resetThisTurn) {
|
|
1140
|
+
const existingUser = db.prepare('SELECT 1 AS ok FROM assistant_contact_message WHERE message_id=?').get(messageID);
|
|
1141
|
+
if (!existingUser) {
|
|
1142
|
+
admitContactUser(row.assistant_id, {
|
|
1143
|
+
userMessageID: messageID,
|
|
1144
|
+
userText,
|
|
1145
|
+
userParts,
|
|
1146
|
+
turnID,
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
persistContactAssistantReply(row.assistant_id, {
|
|
1151
|
+
userMessageID: messageID,
|
|
1152
|
+
bubbles: bubbles.length > 0 ? bubbles : [ASSIGNED_SESSION_FALLBACK_BUBBLE],
|
|
1153
|
+
cards,
|
|
1154
|
+
turnID,
|
|
1155
|
+
});
|
|
1156
|
+
emitContactTurnEvent('openchamber:contact-turn-end', {
|
|
1157
|
+
assistantID: row.assistant_id,
|
|
1158
|
+
turnID,
|
|
1159
|
+
status: 'complete',
|
|
1160
|
+
});
|
|
1161
|
+
const spoken = bubbles.filter((text) => typeof text === 'string' && text.trim() && !text.startsWith('oc.settle.'));
|
|
1162
|
+
notifyContactTurn({
|
|
1163
|
+
assistantID: row.assistant_id,
|
|
1164
|
+
name: assistantSnapshot.name,
|
|
1165
|
+
turnID,
|
|
1166
|
+
status: 'complete',
|
|
1167
|
+
body: spoken.join('\n') || cards[0]?.title || '',
|
|
1168
|
+
});
|
|
1169
|
+
resolveContactTurnSettlement(messageID, { status: 'complete' });
|
|
1170
|
+
} catch (error) {
|
|
1171
|
+
const detail = typeof error?.message === 'string' && error.message.trim()
|
|
1172
|
+
? error.message.trim()
|
|
1173
|
+
: (error?.code || 'upstream_error');
|
|
1174
|
+
const statusCode = error?.code === 'no_provider' ? 'no_provider' : (error?.code || 'upstream_error');
|
|
1175
|
+
emitContactTurnEvent('openchamber:contact-turn-end', {
|
|
1176
|
+
assistantID: row.assistant_id,
|
|
1177
|
+
turnID,
|
|
1178
|
+
status: 'error',
|
|
1179
|
+
error: detail,
|
|
1180
|
+
...(statusCode !== detail ? { code: statusCode } : {}),
|
|
1181
|
+
});
|
|
1182
|
+
notifyContactTurn({
|
|
1183
|
+
assistantID: row.assistant_id,
|
|
1184
|
+
name: assistantSnapshot.name,
|
|
1185
|
+
turnID,
|
|
1186
|
+
status: 'error',
|
|
1187
|
+
body: detail,
|
|
1188
|
+
});
|
|
1189
|
+
resolveContactTurnSettlement(messageID, { status: 'error', error: detail, code: statusCode });
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
};
|
|
1193
|
+
// Defer past send() resolution so callers observe admission before assistant rows.
|
|
1194
|
+
setImmediateFn(kickTurn);
|
|
1195
|
+
return { binding: binding(row), messageID, admitted: true };
|
|
1196
|
+
};
|
|
1197
|
+
const contactMessages = (assistantID, query = {}) => {
|
|
1198
|
+
editable(assistantID);
|
|
1199
|
+
const limit = query.limit == null ? 50 : Number(query.limit);
|
|
1200
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) fail('validation_error');
|
|
1201
|
+
return listContactMessages(db, assistantID, { before: query.before, limit });
|
|
1202
|
+
};
|
|
1203
|
+
/**
|
|
1204
|
+
* Clear this assistant's OpenChamber contact transcript (messages, parts,
|
|
1205
|
+
* watches). Does not call OpenCode session/new — that is worker binding.
|
|
1206
|
+
*/
|
|
1207
|
+
const resetContact = (assistantID) => {
|
|
1208
|
+
const row = editable(assistantID);
|
|
1209
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1210
|
+
try {
|
|
1211
|
+
deleteContactMessages(db, row.assistant_id);
|
|
1212
|
+
bump();
|
|
1213
|
+
db.exec('COMMIT');
|
|
1214
|
+
} catch (error) {
|
|
1215
|
+
db.exec('ROLLBACK');
|
|
1216
|
+
throw error;
|
|
1217
|
+
}
|
|
1218
|
+
return { assistantID: row.assistant_id, reset: true };
|
|
1219
|
+
};
|
|
1220
|
+
const appendContactCard = (assistantID, input) => {
|
|
1221
|
+
const row = active(assistantID);
|
|
1222
|
+
const card = parseContactCard({ type: 'card', ...input, cardType: input?.cardType || 'session' });
|
|
1223
|
+
if (!card) fail('validation_error');
|
|
1224
|
+
const messageID = string(input.messageID, 256) || `card_${id()}`;
|
|
1225
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1226
|
+
try {
|
|
1227
|
+
insertContactMessage(db, {
|
|
1228
|
+
messageID,
|
|
1229
|
+
assistantID: row.assistant_id,
|
|
1230
|
+
role: input.role === 'user' ? 'user' : 'assistant',
|
|
1231
|
+
turnID: string(input.turnID, 256) || messageID,
|
|
1232
|
+
bubbleIndex: 0,
|
|
1233
|
+
createdAt: now(),
|
|
1234
|
+
ordinal: nextContactOrdinal(db, row.assistant_id),
|
|
1235
|
+
status: 'complete',
|
|
1236
|
+
parts: [card],
|
|
1237
|
+
});
|
|
1238
|
+
if (card.cardType === 'session' && card.sessionID) {
|
|
1239
|
+
upsertContactWatch(db, {
|
|
1240
|
+
assistantID: row.assistant_id,
|
|
1241
|
+
sessionID: card.sessionID,
|
|
1242
|
+
directory: card.directory,
|
|
1243
|
+
status: card.status === 'error' || card.status === 'question' || card.status === 'complete' ? card.status : 'busy',
|
|
1244
|
+
updatedAt: now(),
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
bump();
|
|
1248
|
+
db.exec('COMMIT');
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
db.exec('ROLLBACK');
|
|
1251
|
+
throw error;
|
|
1252
|
+
}
|
|
1253
|
+
return { messageID, admitted: true, card };
|
|
1254
|
+
};
|
|
1255
|
+
/**
|
|
1256
|
+
* Read-only inter-assistant DM. Inserts into the recipient's OpenChamber
|
|
1257
|
+
* contact transcript only. Must never call OpenCode promptAsync, mutate
|
|
1258
|
+
* sessions/files/worktrees, or run tools on the recipient's behalf.
|
|
1259
|
+
*
|
|
1260
|
+
* Assign opens a worker session on the sender's contact turn — never here.
|
|
1261
|
+
* TODO(watch/summon): later inbound coordination may attach cards on this same peer row.
|
|
1262
|
+
*/
|
|
1263
|
+
const deliverPeerMessage = (fromAssistantID, input) => {
|
|
1264
|
+
if (!plainObject(input)) fail('validation_error');
|
|
1265
|
+
const sender = active(fromAssistantID);
|
|
1266
|
+
const toAssistantID = string(input.toAssistantID, 256, true);
|
|
1267
|
+
if (toAssistantID === sender.assistant_id) fail('validation_error');
|
|
1268
|
+
const recipient = active(toAssistantID);
|
|
1269
|
+
const parts = [];
|
|
1270
|
+
const text = typeof input.text === 'string' ? input.text.trim() : '';
|
|
1271
|
+
if (text) parts.push({ type: 'text', text });
|
|
1272
|
+
if (input.parts !== undefined) {
|
|
1273
|
+
if (!Array.isArray(input.parts)) fail('validation_error');
|
|
1274
|
+
for (const part of input.parts) {
|
|
1275
|
+
const parsed = parseContactPart(part);
|
|
1276
|
+
if (!parsed) fail('validation_error');
|
|
1277
|
+
parts.push(parsed);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
if (parts.length === 0) fail('validation_error');
|
|
1281
|
+
const messageID = string(input.messageID, 256) || `peer_${id()}`;
|
|
1282
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1283
|
+
try {
|
|
1284
|
+
insertContactMessage(db, {
|
|
1285
|
+
messageID,
|
|
1286
|
+
assistantID: recipient.assistant_id,
|
|
1287
|
+
role: 'peer',
|
|
1288
|
+
turnID: string(input.turnID, 256) || messageID,
|
|
1289
|
+
bubbleIndex: 0,
|
|
1290
|
+
createdAt: now(),
|
|
1291
|
+
ordinal: nextContactOrdinal(db, recipient.assistant_id),
|
|
1292
|
+
status: 'complete',
|
|
1293
|
+
parts,
|
|
1294
|
+
fromAssistantID: sender.assistant_id,
|
|
1295
|
+
fromAssistantName: sender.name,
|
|
1296
|
+
});
|
|
1297
|
+
bump();
|
|
1298
|
+
db.exec('COMMIT');
|
|
1299
|
+
} catch (error) {
|
|
1300
|
+
db.exec('ROLLBACK');
|
|
1301
|
+
throw error;
|
|
1302
|
+
}
|
|
1303
|
+
return {
|
|
1304
|
+
messageID,
|
|
1305
|
+
admitted: true,
|
|
1306
|
+
role: 'peer',
|
|
1307
|
+
fromAssistantID: sender.assistant_id,
|
|
1308
|
+
fromAssistantName: sender.name,
|
|
1309
|
+
toAssistantID: recipient.assistant_id,
|
|
1310
|
+
};
|
|
1311
|
+
};
|
|
411
1312
|
const captureQueueDeliveryTarget = ({ assistantID, scope }) => {
|
|
412
1313
|
const row = active(assistantID); const current = binding(row);
|
|
413
1314
|
const expectedSessionID = row.mode === 'stateless' ? `assistant:${assistantID}` : current.sessionID;
|
|
@@ -433,14 +1334,24 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
433
1334
|
const claim = (operationID, retry = false) => { db.exec('BEGIN IMMEDIATE'); try { const operation = db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID); if (!operation) { db.exec('COMMIT'); return null; } const at = now(); const eligible = operation.state === 'failed' ? retry : operation.state === 'running' && operation.lease_expires_at <= at && operation.phase === 'admitted'; if (!eligible || operation.attempt >= SHARE_MAX_ATTEMPTS) { db.exec('COMMIT'); return null; } const result = db.prepare("UPDATE assistant_share_operation SET state='running',phase='submitting',attempt=attempt+1,lease_expires_at=?,error_code=NULL,updated_at=? WHERE operation_id=? AND state=? AND phase=? AND attempt<? AND (state='failed' OR lease_expires_at<=?)").run(at + SHARE_LEASE_MS, at, operationID, operation.state, operation.phase, SHARE_MAX_ATTEMPTS, at); const claimed = result.changes ? db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID) : null; db.exec('COMMIT'); return claimed; } catch (error) { db.exec('ROLLBACK'); throw error; } };
|
|
434
1335
|
const completeOrFail = (operation, errorCode = null) => { const at = now(); if (errorCode) db.prepare("UPDATE assistant_share_operation SET state='failed',phase='admitted',error_code=?,lease_expires_at=NULL,updated_at=? WHERE operation_id=? AND state='running' AND phase='submitting'").run(errorCode, at, operation.operation_id); else db.prepare("UPDATE assistant_share_operation SET state='running',phase='submitted',error_code=NULL,lease_expires_at=?,updated_at=? WHERE operation_id=? AND state='running' AND phase='submitting'").run(at + SHARE_LEASE_MS, at, operation.operation_id); };
|
|
435
1336
|
const submitClaim = async (operation) => { const payload = parse(operation.response); const row = active(operation.assistant_id); try { const config = configuration(row); const result = await client().session.promptAsync({ sessionID: operation.session_id, directory: effectiveWorkspace(row), ...config, parts: payload.parts, messageID: operation.message_id }); if (!promptAdmitted(result)) fail('upstream_error'); mirrorAdmittedUserMessage(row, operation.session_id, operation.message_id, payload.parts, config); completeOrFail(operation); } catch (error) { completeOrFail(operation, error instanceof AssistantError ? error.code : 'upstream_error'); } };
|
|
436
|
-
const
|
|
1337
|
+
const reconcileShareOperations = async () => { const candidates = db.prepare("SELECT * FROM assistant_share_operation WHERE state='running'").all(); for (const operation of candidates) { const row = assistant(operation.assistant_id); if (!row || !operation.session_id || !operation.message_id) continue; try { const result = await client().session.messages({ sessionID: operation.session_id, directory: effectiveWorkspace(row), limit: 100 }); const messages = result.data ?? []; const found = Array.isArray(messages) && messages.some((message) => message?.info?.id === operation.message_id || message?.id === operation.message_id); if (found) db.prepare("UPDATE assistant_share_operation SET state='completed',phase='submitted',lease_expires_at=NULL,updated_at=? WHERE operation_id=? AND state='running'").run(now(), operation.operation_id); else if (operation.phase === 'admitted' && operation.lease_expires_at <= now() && operation.attempt >= SHARE_MAX_ATTEMPTS) db.prepare("UPDATE assistant_share_operation SET state='failed',lease_expires_at=NULL,error_code='attempt_limit',updated_at=? WHERE operation_id=? AND state='running' AND phase='admitted'").run(now(), operation.operation_id); else if (operation.phase === 'admitted' && operation.lease_expires_at <= now()) { const claimed = claim(operation.operation_id); if (claimed) void submitClaim(claimed); } else if (operation.phase === 'submitted' && operation.lease_expires_at <= now()) db.prepare("UPDATE assistant_share_operation SET state='unresolved',lease_expires_at=NULL,error_code='message_unresolved',updated_at=? WHERE operation_id=? AND state='running' AND phase='submitted'").run(now(), operation.operation_id); } catch { /* Reconciliation remains retryable until its lease expires. */ } } };
|
|
1338
|
+
const reconcile = async () => {
|
|
1339
|
+
if (closed) return;
|
|
1340
|
+
try {
|
|
1341
|
+
await reconcileShareOperations();
|
|
1342
|
+
if (closed) return;
|
|
1343
|
+
await reconcileInFlightWatches();
|
|
1344
|
+
} catch {
|
|
1345
|
+
// Reconcile remains retryable on the next timer.
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
437
1348
|
const share = async (assistantID, input) => { if (!plainObject(input) || !plainObject(input.payload)) fail('validation_error'); validateParts(input.payload.parts); const operationID = string(input.operationID, 128, true); const messageID = string(input.payload.messageID, 256, true); const payloadHash = hash(input.payload); active(assistantID); const at = now(); let operation; let reservationOwner = false; db.exec('BEGIN IMMEDIATE'); try { const inserted = db.prepare('INSERT OR IGNORE INTO assistant_share_operation(operation_id,assistant_id,payload_hash,phase,session_id,message_id,state,response,error_code,attempt,lease_expires_at,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)').run(operationID, assistantID, payloadHash, 'reserving', null, messageID, 'running', json(input.payload), null, 0, null, at, at); operation = db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID); if (operation.assistant_id !== assistantID || operation.payload_hash !== payloadHash) fail('idempotency_conflict'); reservationOwner = inserted.changes === 1; db.exec('COMMIT'); } catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
438
1349
|
let resolveReservation; if (reservationOwner) shareReservations.set(operationID, new Promise((resolve) => { resolveReservation = resolve; })); else await shareReservations.get(operationID);
|
|
439
1350
|
if (reservationOwner) {
|
|
440
1351
|
try { const row = active(assistantID); const target = row.mode === 'stateless' ? await createNew(assistantID) : await ensure(assistantID); const attachedAt = now(); const attached = db.prepare("UPDATE assistant_share_operation SET phase='admitted',session_id=?,message_id=?,lease_expires_at=?,updated_at=? WHERE operation_id=? AND state='running' AND phase='reserving'").run(target.sessionID, messageID, attachedAt, attachedAt, operationID); if (!attached.changes) fail('upstream_error'); } catch (error) { db.prepare("DELETE FROM assistant_share_operation WHERE operation_id=? AND state='running' AND phase='reserving'").run(operationID); throw error; } finally { shareReservations.delete(operationID); resolveReservation(); }
|
|
441
1352
|
}
|
|
442
1353
|
operation = db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID); if (operation?.phase !== 'reserving') { const claimed = claim(operationID, operation?.state === 'failed'); if (claimed) await submitClaim(claimed); } return shareOperation(operationID); };
|
|
443
|
-
const timer = setIntervalFn(() => { if (!closed) { db.prepare('DELETE FROM assistant_share_operation WHERE updated_at<?').run(now() - SHARE_RETENTION_MS);
|
|
1354
|
+
const timer = setIntervalFn(() => { if (!closed) { db.prepare('DELETE FROM assistant_share_operation WHERE updated_at<?').run(now() - SHARE_RETENTION_MS); return reconcile(); } }, reconcileIntervalMs);
|
|
444
1355
|
void reconcile();
|
|
445
1356
|
return { capability: async () => ({ supported: true, enabled: enabled(), revision: revision(), serverInstanceID: await getServerId() }), snapshot: () => ({ revision: revision(), enabled: enabled(), assistants: db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output) }), createAssistant, updateAssistant, setEnabled: (input) => { if (!plainObject(input) || typeof input.enabled !== 'boolean' || input.expectedRevision !== revision()) fail('revision_conflict'); db.prepare("UPDATE assistant_meta SET value=? WHERE key='enabled'").run(input.enabled ? '1' : '0'); return { enabled: input.enabled, revision: bump() }; }, removeAssistant: (assistantID, expectedRevision) => {
|
|
446
1357
|
db.exec('BEGIN IMMEDIATE');
|
|
@@ -451,6 +1362,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
451
1362
|
db.prepare('DELETE FROM assistant_message_part_mirror WHERE assistant_id=?').run(assistantID);
|
|
452
1363
|
db.prepare('DELETE FROM assistant_message_mirror WHERE assistant_id=?').run(assistantID);
|
|
453
1364
|
db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=?').run(assistantID);
|
|
1365
|
+
db.prepare('DELETE FROM assistant_scheduled_task WHERE assistant_id=?').run(assistantID);
|
|
1366
|
+
deleteContactMessages(db, assistantID);
|
|
454
1367
|
bump();
|
|
455
1368
|
db.exec('COMMIT');
|
|
456
1369
|
return { assistantID, tombstoneAt: now() };
|
|
@@ -458,5 +1371,5 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
458
1371
|
db.exec('ROLLBACK');
|
|
459
1372
|
throw error;
|
|
460
1373
|
}
|
|
461
|
-
}, ensure, createNew, compact, send, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, processEvent, close: () => { if (!closed) { closed = true; unsubscribeEvents?.(); clearIntervalFn(timer); db.close(); } } };
|
|
1374
|
+
}, ensure, createNew, compact, send, whenContactTurnSettled, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, contactMessages, resetContact, appendContactCard, deliverPeerMessage, listAssistantScheduledTasks, processEvent, reportAssignedSessionSettle: reportAssignedSession, reconcile, close: () => { if (!closed) { closed = true; unsubscribeEvents?.(); clearIntervalFn(timer); db.close(); } } };
|
|
462
1375
|
};
|