@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.
- package/dist/assets/{AgentAvatar-Dp_hbVcD.js → AgentAvatar-BsU82CEP.js} +1 -1
- package/dist/assets/AssistantView-CKNR7FFf.js +2 -0
- package/dist/assets/{DiagramView-iX3nhWaN.js → DiagramView-Db1VvSIR.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-tDry9J76.js → MarkdownRendererImpl-NBG7O4aX.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-BDVGi72n.js → MarkstreamRendererImpl-Bs829ng-.js} +1 -1
- package/dist/assets/MobileDetailNavigation-CooM1BN7.js +1 -0
- package/dist/assets/{MobileShareBridge-BQ79cuPX.js → MobileShareBridge-CcG4lLaD.js} +3 -3
- package/dist/assets/MobileSurface-c_7RkYa2.js +2 -0
- package/dist/assets/{MultiRunWindow-CzMbzYD3.js → MultiRunWindow-Ds8nyikm.js} +1 -1
- package/dist/assets/{OnboardingScreen-CuvjeXSo.js → OnboardingScreen-BNEtPmvw.js} +1 -1
- package/dist/assets/SettingsView-Dac1dDOs.js +25 -0
- package/dist/assets/{SettingsWindow-DqwoBYvU.js → SettingsWindow-CHnMwzd6.js} +1 -1
- package/dist/assets/{TerminalView-4pFqXfvj.js → TerminalView-DfGueSOI.js} +1 -1
- package/dist/assets/{ToolOutputDialog-GmD4kIoH.js → ToolOutputDialog-BrR9iqiW.js} +1 -1
- package/dist/assets/{appThemeRegistry-BlEYFhXM.js → appThemeRegistry-BEG7NRFm.js} +2 -2
- package/dist/assets/{clipboard-Bu0lpyX1.js → clipboard-BXxLshVq.js} +1 -1
- package/dist/assets/context-menu-Dpjla5p4.js +1 -0
- package/dist/assets/{dialog-D21YndEx.js → dialog-BkhHBTd4.js} +1 -1
- package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
- package/dist/assets/{es-C8Sqjsia.js → es-BHqnSjoZ.js} +1 -1
- package/dist/assets/{fr-p-Zwm_j2.js → fr-BfrlJY9i.js} +1 -1
- package/dist/assets/{gitApi-CciB055B.js → gitApi-Br5EdS29.js} +3 -3
- package/dist/assets/insertionBoundaries-ncyYkgWy.js +4 -0
- package/dist/assets/{ja-B6REu1qP.js → ja-DP-7qt-x.js} +1 -1
- package/dist/assets/{ko-CJBNH9IZ.js → ko-E2B5vGQP.js} +1 -1
- package/dist/assets/main-DX_9iX64.js +169 -0
- package/dist/assets/{main-DYFky6Uj.js → main-Dl-XWJxN.js} +2 -2
- package/dist/assets/miniChat-Cho-Pywx.js +2 -0
- package/dist/assets/mobile-1_JD1EGE.js +2 -0
- package/dist/assets/multirun-ByFn3xhO.js +4 -0
- package/dist/assets/{pl-B2cBI_7K.js → pl-r8X5gRM5.js} +1 -1
- package/dist/assets/projectMeta-BHw8kRhQ.js +3 -0
- package/dist/assets/{pt-BR-CrCc9hmt.js → pt-BR-DcpdXCf5.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-CvIxnUQY.js +2 -0
- package/dist/assets/renderMobileApp-DscicBzZ.js +28 -0
- package/dist/assets/{runtimeConfig-DNsnUkyp.js → runtimeConfig-Dkt3p8CZ.js} +1 -1
- package/dist/assets/runtimeEndpointReset-hBFsO83_.js +147 -0
- package/dist/assets/sessionChangeSummary-CX2MJIkc.js +1 -0
- package/dist/assets/sessionLookup-jCEot_fX.js +1 -0
- package/dist/assets/{sortable-tabs-strip-Biow4Fqu.js → sortable-tabs-strip-C5gGnikj.js} +1 -1
- package/dist/assets/src-KcdsQIuP.css +2 -0
- package/dist/assets/{store-CL3WP48T.js → store-BDFdnk6l.js} +2 -2
- package/dist/assets/textarea-CIO34TZz.js +1 -0
- package/dist/assets/{uk-C8YKAz0x.js → uk-Di0vl9V-.js} +1 -1
- package/dist/assets/{ChatContainer-C04WuLo8.js → useAppFontEffects-kJR0NlNH.js} +44 -39
- package/dist/assets/useDesktopWindowControlsLayout-BmbOASBQ.js +1 -0
- package/dist/assets/useEffectiveDirectory-DOToiRZd.js +1 -0
- package/dist/assets/useMobileNavigationStore-CEmn-d1X.js +412 -0
- package/dist/assets/useSessionAutoCleanup-DcmXMo-p.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-BaNK-wuY.js → useWorkerHighlightedLines-r5Ww4sY7.js} +1 -1
- package/dist/assets/{zh-CN-CCYwUnmB.js → zh-CN-coFCfeQE.js} +1 -1
- package/dist/assets/{zh-TW-D8Y6py08.js → zh-TW-oDRZ1-yb.js} +1 -1
- package/dist/index.html +3 -3
- package/dist/mini-chat.html +3 -3
- package/dist/mobile.html +3 -3
- 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/runtime.js +52 -0
- package/server/lib/notifications/runtime.test.js +39 -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/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/AssistantView-CQHey6je.js +0 -1
- package/dist/assets/ContextPanel-BJEFzgz7.js +0 -285
- package/dist/assets/MobileDetailNavigation-Dcl7Upvc.js +0 -1
- package/dist/assets/MobileSurface-CpAbBAQ5.js +0 -2
- package/dist/assets/SettingsView-DRiEDi23.js +0 -25
- package/dist/assets/insertionBoundaries-DcS10A9Q.js +0 -4
- package/dist/assets/main-Coj1Z4LN.js +0 -29
- package/dist/assets/miniChat-DK-d_3-7.js +0 -2
- package/dist/assets/mobile-8QzXy5wz.js +0 -2
- package/dist/assets/multirun-BFHWrDOR.js +0 -4
- package/dist/assets/projectMeta-CEGa4Czs.js +0 -3
- package/dist/assets/radio-Bw11T40N.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-_fchgm-8.js +0 -2
- package/dist/assets/renderMobileApp-B1j1BUXB.js +0 -28
- package/dist/assets/runtimeEndpointReset-C-Sm4wpj.js +0 -4
- package/dist/assets/shikiHighlight-GaNLBDUk.js +0 -1
- package/dist/assets/src-BGao3JmJ.css +0 -2
- package/dist/assets/useAppFontEffects-D0UWTJui.js +0 -7
- package/dist/assets/useDesktopWindowControlsLayout-_5M7NHJ8.js +0 -1
- package/dist/assets/useEffectiveDirectory-DqOLKISv.js +0 -1
- package/dist/assets/useMobileNavigationStore-DxH1DjU-.js +0 -412
- package/dist/assets/useSessionAutoCleanup-Dpp0-zw2.js +0 -1
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
import { AssignError, PROJECT_REQUIRED_MESSAGE } from './assign.js';
|
|
2
|
+
import { createAssistantCardPart, createScheduleCardPart, createSessionCardPart } from './cards.js';
|
|
3
|
+
|
|
4
|
+
const typeboxString = (description) => {
|
|
5
|
+
const schema = { type: 'string', description };
|
|
6
|
+
Object.defineProperty(schema, '~kind', { value: 'String' });
|
|
7
|
+
return schema;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const typeboxOptional = (schema) => {
|
|
11
|
+
Object.defineProperty(schema, '~optional', { value: true });
|
|
12
|
+
return schema;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const typeboxObject = (properties) => {
|
|
16
|
+
const required = Object.entries(properties)
|
|
17
|
+
.filter(([, schema]) => !schema['~optional'])
|
|
18
|
+
.map(([key]) => key);
|
|
19
|
+
const schema = { type: 'object', required, properties };
|
|
20
|
+
Object.defineProperty(schema, '~kind', { value: 'Object' });
|
|
21
|
+
return schema;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const ASSIGN_SESSION_TOOL_NAME = 'assign_session';
|
|
25
|
+
export const CREATE_ASSISTANT_TOOL_NAME = 'create_assistant';
|
|
26
|
+
export const SCHEDULE_TASK_TOOL_NAME = 'schedule_task';
|
|
27
|
+
export const MESSAGE_ASSISTANT_TOOL_NAME = 'message_assistant';
|
|
28
|
+
export const NEW_CONVERSATION_TOOL_NAME = 'new_conversation';
|
|
29
|
+
export const LIST_PROJECTS_TOOL_NAME = 'list_projects';
|
|
30
|
+
export const LIST_SESSIONS_TOOL_NAME = 'list_sessions';
|
|
31
|
+
const CONTACT_TOOL_FENCE = 'openchamber-tool';
|
|
32
|
+
export const ASSIGNED_SESSION_FALLBACK_BUBBLE = 'Opened a coding session.';
|
|
33
|
+
export const NEW_CONVERSATION_CONFIRM_BUBBLE = 'Started a new conversation. Previous contact messages are cleared.';
|
|
34
|
+
const LIST_SESSIONS_LIMIT_DEFAULT = 20;
|
|
35
|
+
const LIST_SESSIONS_LIMIT_MAX = 50;
|
|
36
|
+
|
|
37
|
+
const DENIED_CODING_TOOLS = new Set(['bash', 'edit', 'read', 'write', 'glob', 'grep', 'shell']);
|
|
38
|
+
|
|
39
|
+
const FENCE = new RegExp(`\`\`\`${CONTACT_TOOL_FENCE}\\s*([\\s\\S]*?)\`\`\``, 'u');
|
|
40
|
+
export const MISSED_FENCE_RETRY_USER_TEXT = 'emit the fence now, do not claim success.';
|
|
41
|
+
export const MISSED_TOOL_FAILURE_BUBBLE = 'I could not complete that. No tool ran, so nothing was created.';
|
|
42
|
+
|
|
43
|
+
const CREATE_ASSISTANT_INTENT = /建助理|新建[^。\n!]{0,24}助理|创建[^。\n!]{0,24}助理|加一个助理|create (?:an |a new )?assistant|new assistant/iu;
|
|
44
|
+
const SCHEDULE_TASK_INTENT = /排定时任务|排个?定时任务|定时任务|schedule (?:a )?(?:daily )?(?:task|ping)|scheduled task|排个?(?:每日)?(?:任务|ping)/iu;
|
|
45
|
+
const ASSIGN_SESSION_INTENT = /建会话|开会话|开(?:一个)?(?:编码\s*)?(?:session|会话)|open (?:a )?(?:coding )?session|assign_session|write a file|写(?:一个)?文件/giu;
|
|
46
|
+
const MESSAGE_ASSISTANT_INTENT = /给[^。\n]{1,40}说(?:一声)?|跟[^。\n]{1,24}说(?:一声)?|告诉(?!我)[^。\n]{1,40}|说一声|message (?:the )?(?:assistant|peer)|(?:tell|message)\s+[A-Za-z0-9._-]+|send (?:a )?message to/iu;
|
|
47
|
+
const NEW_CONVERSATION_INTENT = /开新对话|新对话|清空(?:聊天|对话)|clear chat|new conversation|start over/iu;
|
|
48
|
+
const LIST_PROJECTS_INTENT = /找项目|查项目|看看项目|有哪些项目|项目列表|list projects|find project|registered project|which project/iu;
|
|
49
|
+
const LIST_SESSIONS_INTENT = /现有对话|现有会话|查会话|找会话|会话列表|有哪些会话|list sessions|find (?:a )?session|existing (?:conversation|session|chat)|active (?:conversation|session)/iu;
|
|
50
|
+
const INTENT_NEGATION = /不要|别|不用|不开|don't|do\s+not/iu;
|
|
51
|
+
const newConversationParameters = typeboxObject({});
|
|
52
|
+
|
|
53
|
+
const assignParameters = typeboxObject({
|
|
54
|
+
prompt: typeboxString('Coding prompt to kick into the worker OpenCode session.'),
|
|
55
|
+
projectPath: typeboxOptional(typeboxString('Registered project path. Required when more than one project exists.')),
|
|
56
|
+
directory: typeboxOptional(typeboxString('Existing project or worktree directory to reuse.')),
|
|
57
|
+
branch: typeboxOptional(typeboxString('Existing Chat worktree branch to reuse. Do not create a new worktree.')),
|
|
58
|
+
sessionID: typeboxOptional(typeboxString('Existing OpenCode session to reuse instead of creating one.')),
|
|
59
|
+
title: typeboxOptional(typeboxString('Optional title for the worker session and contact card.')),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const createAssistantParameters = typeboxObject({
|
|
63
|
+
name: typeboxString('Display name for the new assistant contact, e.g. FlowQA.'),
|
|
64
|
+
providerID: typeboxOptional(typeboxString('OpenCode provider ID already connected in Settings, e.g. opencode-go.')),
|
|
65
|
+
modelID: typeboxOptional(typeboxString('OpenCode model ID already connected in Settings, e.g. deepseek-v4-flash.')),
|
|
66
|
+
model: typeboxOptional(typeboxString('Optional provider/model string such as opencode-go/deepseek-v4-flash.')),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const scheduleTaskParameters = typeboxObject({
|
|
70
|
+
name: typeboxString('Short scheduled-task name.'),
|
|
71
|
+
prompt: typeboxString('Prompt the scheduled run should send to the worker session.'),
|
|
72
|
+
projectPath: typeboxOptional(typeboxString('Registered project path. Required when more than one project exists.')),
|
|
73
|
+
kind: typeboxOptional(typeboxString('Schedule kind: daily, weekly, once, or cron. Default daily.')),
|
|
74
|
+
time: typeboxOptional(typeboxString('HH:mm local time, e.g. 18:00.')),
|
|
75
|
+
timezone: typeboxOptional(typeboxString('IANA timezone, e.g. Asia/Shanghai.')),
|
|
76
|
+
date: typeboxOptional(typeboxString('YYYY-MM-DD for kind=once.')),
|
|
77
|
+
weekdays: typeboxOptional(typeboxString('Comma-separated 0-6 weekdays for kind=weekly.')),
|
|
78
|
+
cron: typeboxOptional(typeboxString('Cron expression for kind=cron.')),
|
|
79
|
+
providerID: typeboxOptional(typeboxString('OpenCode provider ID. Defaults to this contact\'s provider.')),
|
|
80
|
+
modelID: typeboxOptional(typeboxString('OpenCode model ID. Defaults to this contact\'s model.')),
|
|
81
|
+
model: typeboxOptional(typeboxString('Optional provider/model string such as opencode-go/deepseek-v4-flash.')),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const messageAssistantParameters = typeboxObject({
|
|
85
|
+
text: typeboxString('Read-only message to insert into the other assistant contact transcript.'),
|
|
86
|
+
to: typeboxOptional(typeboxString('Recipient assistant display name, e.g. PeerQA.')),
|
|
87
|
+
name: typeboxOptional(typeboxString('Recipient assistant display name if `to` is omitted.')),
|
|
88
|
+
toAssistantID: typeboxOptional(typeboxString('Recipient assistant id when already known.')),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const listProjectsParameters = typeboxObject({
|
|
92
|
+
query: typeboxOptional(typeboxString('Optional fuzzy filter against project label, path, or id (e.g. "openchamber yee").')),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const listSessionsParameters = typeboxObject({
|
|
96
|
+
projectPath: typeboxOptional(typeboxString('Registered project path to scope the session search.')),
|
|
97
|
+
projectID: typeboxOptional(typeboxString('Registered project id to scope the session search.')),
|
|
98
|
+
query: typeboxOptional(typeboxString('Optional fuzzy filter against session title or id.')),
|
|
99
|
+
limit: typeboxOptional(typeboxString('Max sessions to return (default 20, max 50).')),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/** Normalize a registered project row for model/tool consumption (no secrets). */
|
|
103
|
+
export function sanitizeRegisteredProject(project) {
|
|
104
|
+
if (!project || typeof project !== 'object') return null;
|
|
105
|
+
const id = typeof project.id === 'string' ? project.id.trim() : '';
|
|
106
|
+
const projectPath = typeof project.path === 'string' ? project.path.trim() : '';
|
|
107
|
+
if (!id || !projectPath) return null;
|
|
108
|
+
const label = typeof project.label === 'string' && project.label.trim()
|
|
109
|
+
? project.label.trim()
|
|
110
|
+
: null;
|
|
111
|
+
return label ? { id, path: projectPath, label } : { id, path: projectPath };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function normalizeRegisteredProjects(projects) {
|
|
115
|
+
return (Array.isArray(projects) ? projects : [])
|
|
116
|
+
.map(sanitizeRegisteredProject)
|
|
117
|
+
.filter(Boolean);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const fuzzyHaystack = (value) => (typeof value === 'string' ? value.toLowerCase().replace(/[\s/_-]+/g, '') : '');
|
|
121
|
+
|
|
122
|
+
/** True when every character of needle appears in order inside haystack (typo-tolerant). */
|
|
123
|
+
const isFuzzySubsequence = (needle, haystack) => {
|
|
124
|
+
if (!needle || !haystack) return false;
|
|
125
|
+
if (haystack.includes(needle) || needle.includes(haystack)) return true;
|
|
126
|
+
let index = 0;
|
|
127
|
+
for (const character of haystack) {
|
|
128
|
+
if (character === needle[index]) index += 1;
|
|
129
|
+
if (index >= needle.length) return true;
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Case/space-insensitive match against label, path, id, title. Empty query matches all. */
|
|
135
|
+
export function matchesProjectQuery(project, query) {
|
|
136
|
+
const needle = fuzzyHaystack(typeof query === 'string' ? query.trim() : '');
|
|
137
|
+
if (!needle) return true;
|
|
138
|
+
const fields = [project?.label, project?.path, project?.id, project?.title, project?.sessionID]
|
|
139
|
+
.filter((value) => typeof value === 'string' && value.trim())
|
|
140
|
+
.map((value) => fuzzyHaystack(value));
|
|
141
|
+
return fields.some((field) => isFuzzySubsequence(needle, field) || isFuzzySubsequence(field, needle));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function filterRegisteredProjects(projects, query) {
|
|
145
|
+
const list = normalizeRegisteredProjects(projects);
|
|
146
|
+
const needle = typeof query === 'string' ? query.trim() : '';
|
|
147
|
+
if (!needle) return list;
|
|
148
|
+
return list.filter((project) => matchesProjectQuery(project, needle));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Injected into every contact turn so the model can match names without a tool call. */
|
|
152
|
+
export function formatRegisteredProjectsPrompt(projects) {
|
|
153
|
+
const list = normalizeRegisteredProjects(projects);
|
|
154
|
+
if (list.length === 0) {
|
|
155
|
+
return [
|
|
156
|
+
'Registered projects: none.',
|
|
157
|
+
'You can see this catalog. There are zero registered projects right now.',
|
|
158
|
+
'Tell the user to add a project in Settings. Never invent a filesystem path and never claim you cannot see the project list.',
|
|
159
|
+
].join(' ');
|
|
160
|
+
}
|
|
161
|
+
return [
|
|
162
|
+
'Registered projects (you CAN see this list every turn — look them up yourself; never say you cannot see registered projects; never ask for a raw path when a name/label matches):',
|
|
163
|
+
...list.map((project) => (
|
|
164
|
+
project.label
|
|
165
|
+
? `- label=${JSON.stringify(project.label)} path=${JSON.stringify(project.path)} id=${JSON.stringify(project.id)}`
|
|
166
|
+
: `- path=${JSON.stringify(project.path)} id=${JSON.stringify(project.id)}`
|
|
167
|
+
)),
|
|
168
|
+
'Fuzzy-match user names like "openchamber yee" / "openchamer yee" against label and path.',
|
|
169
|
+
'To open coding work after a match, call assign_session with that projectPath (or an existing sessionID from list_sessions).',
|
|
170
|
+
'Use list_projects to refresh/filter and list_sessions to search existing conversations in a project.',
|
|
171
|
+
].join('\n');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function boundSessionListLimit(value) {
|
|
175
|
+
if (value == null || value === '') return LIST_SESSIONS_LIMIT_DEFAULT;
|
|
176
|
+
const parsed = typeof value === 'number' ? value : Number(String(value).trim());
|
|
177
|
+
if (!Number.isFinite(parsed)) return LIST_SESSIONS_LIMIT_DEFAULT;
|
|
178
|
+
return Math.min(LIST_SESSIONS_LIMIT_MAX, Math.max(1, Math.trunc(parsed)));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const allowedToolName = (name, allowedNames) => {
|
|
182
|
+
if (typeof name !== 'string' || !name.trim()) return false;
|
|
183
|
+
const next = name.trim();
|
|
184
|
+
if (DENIED_CODING_TOOLS.has(next)) return false;
|
|
185
|
+
return allowedNames.has(next);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const parseToolPayload = (raw, allowedNames) => {
|
|
189
|
+
let parsed;
|
|
190
|
+
try {
|
|
191
|
+
parsed = JSON.parse(raw);
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
196
|
+
const name = typeof parsed.name === 'string' ? parsed.name.trim() : '';
|
|
197
|
+
if (!allowedToolName(name, allowedNames)) return null;
|
|
198
|
+
const args = parsed.arguments;
|
|
199
|
+
if (args != null && (typeof args !== 'object' || Array.isArray(args))) return null;
|
|
200
|
+
return { name, arguments: args && typeof args === 'object' ? args : {} };
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const extractJsonObjectAt = (text, start) => {
|
|
204
|
+
if (text[start] !== '{') return null;
|
|
205
|
+
let depth = 0;
|
|
206
|
+
let inString = false;
|
|
207
|
+
let escape = false;
|
|
208
|
+
for (let index = start; index < text.length; index += 1) {
|
|
209
|
+
const character = text[index];
|
|
210
|
+
if (inString) {
|
|
211
|
+
if (escape) {
|
|
212
|
+
escape = false;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (character === '\\') {
|
|
216
|
+
escape = true;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (character === '"') inString = false;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (character === '"') {
|
|
223
|
+
inString = true;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (character === '{') depth += 1;
|
|
227
|
+
else if (character === '}') {
|
|
228
|
+
depth -= 1;
|
|
229
|
+
if (depth === 0) return text.slice(start, index + 1);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const findEmbeddedToolCall = (text, allowedNames) => {
|
|
236
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
237
|
+
if (text[index] !== '{') continue;
|
|
238
|
+
const snippet = extractJsonObjectAt(text, index);
|
|
239
|
+
if (!snippet) continue;
|
|
240
|
+
const toolCall = parseToolPayload(snippet, allowedNames);
|
|
241
|
+
if (!toolCall) continue;
|
|
242
|
+
const chatText = stripContactToolFences(`${text.slice(0, index)}${text.slice(index + snippet.length)}`.trim());
|
|
243
|
+
return { chatText, toolCall };
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
export function stripContactToolFences(text) {
|
|
249
|
+
if (typeof text !== 'string') return '';
|
|
250
|
+
return text.replace(new RegExp(`\`\`\`${CONTACT_TOOL_FENCE}[\\s\\S]*?\`\`\``, 'gu'), '').trim();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function parseContactToolCalls(text, allowedNames = []) {
|
|
254
|
+
const allowed = new Set(
|
|
255
|
+
(Array.isArray(allowedNames) ? allowedNames : [])
|
|
256
|
+
.filter((name) => typeof name === 'string' && name.trim() && !DENIED_CODING_TOOLS.has(name.trim()))
|
|
257
|
+
.map((name) => name.trim()),
|
|
258
|
+
);
|
|
259
|
+
const raw = typeof text === 'string' ? text : '';
|
|
260
|
+
const chatText = stripContactToolFences(raw);
|
|
261
|
+
const fence = raw.match(FENCE);
|
|
262
|
+
if (fence?.[1]) {
|
|
263
|
+
const toolCall = parseToolPayload(fence[1], allowed);
|
|
264
|
+
if (toolCall) return { chatText, toolCall };
|
|
265
|
+
}
|
|
266
|
+
const trimmed = raw.trim();
|
|
267
|
+
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
|
268
|
+
const toolCall = parseToolPayload(trimmed, allowed);
|
|
269
|
+
if (toolCall) return { chatText: '', toolCall };
|
|
270
|
+
}
|
|
271
|
+
const embedded = findEmbeddedToolCall(raw, allowed);
|
|
272
|
+
if (embedded) return embedded;
|
|
273
|
+
return { chatText, toolCall: null };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const isNegatedAt = (text, index) => INTENT_NEGATION.test(text.slice(Math.max(0, index - 12), index));
|
|
277
|
+
|
|
278
|
+
const hasAssignSessionIntent = (text) => {
|
|
279
|
+
ASSIGN_SESSION_INTENT.lastIndex = 0;
|
|
280
|
+
let match = ASSIGN_SESSION_INTENT.exec(text);
|
|
281
|
+
while (match) {
|
|
282
|
+
if (!isNegatedAt(text, match.index)) return true;
|
|
283
|
+
match = ASSIGN_SESSION_INTENT.exec(text);
|
|
284
|
+
}
|
|
285
|
+
return false;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/** Which attached tools the user asked for in natural language. */
|
|
289
|
+
export function detectRequestedContactTools(userText, allowedNames = []) {
|
|
290
|
+
const allowed = new Set(
|
|
291
|
+
(Array.isArray(allowedNames) ? allowedNames : [])
|
|
292
|
+
.filter((name) => typeof name === 'string' && name.trim() && !DENIED_CODING_TOOLS.has(name.trim()))
|
|
293
|
+
.map((name) => name.trim()),
|
|
294
|
+
);
|
|
295
|
+
const text = typeof userText === 'string' ? userText : '';
|
|
296
|
+
const requested = [];
|
|
297
|
+
if (allowed.has(NEW_CONVERSATION_TOOL_NAME) && NEW_CONVERSATION_INTENT.test(text)) {
|
|
298
|
+
requested.push(NEW_CONVERSATION_TOOL_NAME);
|
|
299
|
+
}
|
|
300
|
+
if (allowed.has(LIST_PROJECTS_TOOL_NAME) && LIST_PROJECTS_INTENT.test(text)) {
|
|
301
|
+
requested.push(LIST_PROJECTS_TOOL_NAME);
|
|
302
|
+
}
|
|
303
|
+
if (allowed.has(LIST_SESSIONS_TOOL_NAME) && LIST_SESSIONS_INTENT.test(text)) {
|
|
304
|
+
requested.push(LIST_SESSIONS_TOOL_NAME);
|
|
305
|
+
}
|
|
306
|
+
if (allowed.has(CREATE_ASSISTANT_TOOL_NAME) && CREATE_ASSISTANT_INTENT.test(text)) {
|
|
307
|
+
requested.push(CREATE_ASSISTANT_TOOL_NAME);
|
|
308
|
+
}
|
|
309
|
+
if (allowed.has(SCHEDULE_TASK_TOOL_NAME) && SCHEDULE_TASK_INTENT.test(text)) {
|
|
310
|
+
requested.push(SCHEDULE_TASK_TOOL_NAME);
|
|
311
|
+
}
|
|
312
|
+
if (allowed.has(ASSIGN_SESSION_TOOL_NAME) && hasAssignSessionIntent(text)) {
|
|
313
|
+
requested.push(ASSIGN_SESSION_TOOL_NAME);
|
|
314
|
+
}
|
|
315
|
+
if (allowed.has(MESSAGE_ASSISTANT_TOOL_NAME) && MESSAGE_ASSISTANT_INTENT.test(text)) {
|
|
316
|
+
requested.push(MESSAGE_ASSISTANT_TOOL_NAME);
|
|
317
|
+
}
|
|
318
|
+
return requested;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function contactTurnHasToolResult(messages) {
|
|
322
|
+
return (Array.isArray(messages) ? messages : []).some((message) => message?.role === 'toolResult');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const trim = (value, max = 10_000) => {
|
|
326
|
+
if (typeof value !== 'string') return null;
|
|
327
|
+
const next = value.trim();
|
|
328
|
+
if (!next || next.length > max) return null;
|
|
329
|
+
return next;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/** Resolve OpenCode provider/model from tool args or the current contact. */
|
|
333
|
+
export function resolveContactProviderModel(params = {}, fallback = {}) {
|
|
334
|
+
const providerID = trim(params.providerID, 256);
|
|
335
|
+
const modelID = trim(params.modelID, 256);
|
|
336
|
+
if (providerID && modelID) return { providerID, modelID };
|
|
337
|
+
const combined = trim(params.model, 512);
|
|
338
|
+
if (combined && combined.includes('/')) {
|
|
339
|
+
const slash = combined.indexOf('/');
|
|
340
|
+
const fromModel = {
|
|
341
|
+
providerID: combined.slice(0, slash).trim(),
|
|
342
|
+
modelID: combined.slice(slash + 1).trim(),
|
|
343
|
+
};
|
|
344
|
+
if (fromModel.providerID && fromModel.modelID) return fromModel;
|
|
345
|
+
}
|
|
346
|
+
const fallbackProvider = trim(fallback.providerID, 256);
|
|
347
|
+
const fallbackModel = trim(fallback.modelID, 256);
|
|
348
|
+
if (fallbackProvider && fallbackModel) return { providerID: fallbackProvider, modelID: fallbackModel };
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const assistantIdentity = (assistant) => assistant?.id || assistant?.assistantID || null;
|
|
353
|
+
|
|
354
|
+
/** Resolve a live peer by id or display name. */
|
|
355
|
+
export function resolvePeerAssistant(params = {}, assistants = [], currentAssistant = {}) {
|
|
356
|
+
const toAssistantID = trim(params.toAssistantID, 256);
|
|
357
|
+
const toName = trim(params.to, 256) || trim(params.name, 256);
|
|
358
|
+
const list = Array.isArray(assistants) ? assistants : [];
|
|
359
|
+
const currentID = assistantIdentity(currentAssistant);
|
|
360
|
+
if (toAssistantID) {
|
|
361
|
+
const match = list.find((item) => assistantIdentity(item) === toAssistantID);
|
|
362
|
+
if (!match) {
|
|
363
|
+
throw new AssignError('not_found', 'No assistant with that id is available.');
|
|
364
|
+
}
|
|
365
|
+
if (assistantIdentity(match) === currentID) {
|
|
366
|
+
throw new AssignError('validation_error', 'Cannot message this same assistant.');
|
|
367
|
+
}
|
|
368
|
+
return match;
|
|
369
|
+
}
|
|
370
|
+
if (!toName) {
|
|
371
|
+
throw new AssignError('validation_error', 'message_assistant requires a recipient name or toAssistantID.');
|
|
372
|
+
}
|
|
373
|
+
const matches = list.filter((item) => {
|
|
374
|
+
const name = typeof item?.name === 'string' ? item.name.trim() : '';
|
|
375
|
+
return name === toName || name.toLowerCase() === toName.toLowerCase();
|
|
376
|
+
});
|
|
377
|
+
if (matches.length === 0) {
|
|
378
|
+
throw new AssignError('not_found', `No assistant named ${toName} is available.`);
|
|
379
|
+
}
|
|
380
|
+
if (matches.length > 1) {
|
|
381
|
+
throw new AssignError('validation_error', `Several assistants are named ${toName}; use toAssistantID.`);
|
|
382
|
+
}
|
|
383
|
+
if (assistantIdentity(matches[0]) === currentID) {
|
|
384
|
+
throw new AssignError('validation_error', 'Cannot message this same assistant.');
|
|
385
|
+
}
|
|
386
|
+
return matches[0];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function formatContactToolsPrompt(tools) {
|
|
390
|
+
const list = Array.isArray(tools) ? tools.filter((tool) => tool?.name && !DENIED_CODING_TOOLS.has(tool.name)) : [];
|
|
391
|
+
if (list.length === 0) return '';
|
|
392
|
+
return [
|
|
393
|
+
'The user talks in natural language (including Chinese). Never ask them to type slash commands.',
|
|
394
|
+
'When they want a fresh contact chat (开新对话 / new conversation / clear chat), call new_conversation. That clears this contact transcript only. It is not OpenCode session/new and does not open a coding session.',
|
|
395
|
+
'When they want to find a registered project (找项目 / list projects / "openchamber yee"), call list_projects or use the Registered projects block already in context.',
|
|
396
|
+
'When they want existing conversations in a project (现有对话 / list sessions), call list_sessions.',
|
|
397
|
+
'When they want another assistant (建助理 / create an assistant), call create_assistant.',
|
|
398
|
+
'When they want coding work or a Chat session (建会话 / open a session / write a file / 开个新会话), call assign_session after matching the project.',
|
|
399
|
+
'When they want a scheduled task (排定时任务 / schedule daily ping), call schedule_task.',
|
|
400
|
+
'When they want to tell another assistant (给 PeerQA 说一声 / message PeerQA), call message_assistant.',
|
|
401
|
+
`Call exactly one tool per reply by emitting one fenced JSON block:`,
|
|
402
|
+
`\`\`\`${CONTACT_TOOL_FENCE}`,
|
|
403
|
+
`{"name":"${NEW_CONVERSATION_TOOL_NAME}","arguments":{}}`,
|
|
404
|
+
'```',
|
|
405
|
+
`\`\`\`${CONTACT_TOOL_FENCE}`,
|
|
406
|
+
`{"name":"${LIST_PROJECTS_TOOL_NAME}","arguments":{"query":"openchamber yee"}}`,
|
|
407
|
+
'```',
|
|
408
|
+
`\`\`\`${CONTACT_TOOL_FENCE}`,
|
|
409
|
+
`{"name":"${LIST_SESSIONS_TOOL_NAME}","arguments":{"projectPath":"/path/to/repo","query":"login"}}`,
|
|
410
|
+
'```',
|
|
411
|
+
`\`\`\`${CONTACT_TOOL_FENCE}`,
|
|
412
|
+
`{"name":"${CREATE_ASSISTANT_TOOL_NAME}","arguments":{"name":"FlowQA","model":"opencode-go/deepseek-v4-flash"}}`,
|
|
413
|
+
'```',
|
|
414
|
+
`\`\`\`${CONTACT_TOOL_FENCE}`,
|
|
415
|
+
`{"name":"${SCHEDULE_TASK_TOOL_NAME}","arguments":{"name":"Daily ping","prompt":"ping","time":"18:00","timezone":"Asia/Shanghai"}}`,
|
|
416
|
+
'```',
|
|
417
|
+
`\`\`\`${CONTACT_TOOL_FENCE}`,
|
|
418
|
+
`{"name":"${MESSAGE_ASSISTANT_TOOL_NAME}","arguments":{"to":"PeerQA","text":"hello-from-assistant 写好了"}}`,
|
|
419
|
+
'```',
|
|
420
|
+
`\`\`\`${CONTACT_TOOL_FENCE}`,
|
|
421
|
+
`{"name":"${ASSIGN_SESSION_TOOL_NAME}","arguments":{"prompt":"...","projectPath":"..."}}`,
|
|
422
|
+
'```',
|
|
423
|
+
'If the user asked for more than one of these, do them in that order across turns: new_conversation, then list_projects, then list_sessions, then create_assistant, then schedule_task, then message_assistant, then assign_session.',
|
|
424
|
+
'new_conversation deletes this contact\'s stored messages and watches. It never calls session/new or createNew.',
|
|
425
|
+
'You already receive the registered project catalog each turn. Prefer matching label/path yourself; list_projects refreshes or filters. Never claim you cannot see projects; never ask for a raw path when a name matches.',
|
|
426
|
+
'list_sessions searches the OpenChamber session index for existing chats in a project. A failure is not an empty list — surface the error.',
|
|
427
|
+
'assign_session opens a real OpenChamber/OpenCode session on a registered project (or reuses sessionID). You are not the worker.',
|
|
428
|
+
'create_assistant reuses already-connected OpenCode providers (providerID/modelID). Mode is continuous.',
|
|
429
|
+
'schedule_task writes the same payload as PUT /api/projects/:id/scheduled-tasks onto a registered project.',
|
|
430
|
+
'message_assistant is read-only: it inserts into the other contact transcript. It never runs promptAsync or mutates sessions or files. Never assign through a peer message.',
|
|
431
|
+
'A reply without the tool call does nothing — agreeing in Chinese (好的 / 我来创建 / 我去说一声) is not sending.',
|
|
432
|
+
'When calling a tool that takes a beat (找项目 / 开会话), you may say one short spoken line first (≤40 characters, e.g. 我去找一下), then only the fence. No planning, no tool names, no "let me think".',
|
|
433
|
+
'Never say 已创建, 已发送, created, scheduled, opened, or sent unless the tool already returned success.',
|
|
434
|
+
'If no registered project exists, tell the user to add one in Settings — do not use assistant-workspaces.',
|
|
435
|
+
'After a successful tool, confirm in one short bubble. The user sees a contact card, not tool traces.',
|
|
436
|
+
'Available OpenChamber tools:',
|
|
437
|
+
...list.map((tool) => `- ${tool.name}: ${tool.description || tool.label || tool.name}`),
|
|
438
|
+
].join('\n');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function contactTurnHasSuccessfulReset(messages) {
|
|
442
|
+
return (Array.isArray(messages) ? messages : []).some((message) => (
|
|
443
|
+
message?.role === 'toolResult'
|
|
444
|
+
&& (message.toolName === NEW_CONVERSATION_TOOL_NAME || message.details?.reset === true)
|
|
445
|
+
&& !message.details?.error
|
|
446
|
+
));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** After reset, keep only the short confirm — leftover pre-reset model text is discarded. */
|
|
450
|
+
export function confirmBubbleAfterContactReset(bubbles) {
|
|
451
|
+
const list = (Array.isArray(bubbles) ? bubbles : [])
|
|
452
|
+
.filter((item) => typeof item === 'string' && item.trim())
|
|
453
|
+
.map((item) => item.trim());
|
|
454
|
+
const confirm = list.find((item) => item === NEW_CONVERSATION_CONFIRM_BUBBLE);
|
|
455
|
+
return [confirm || NEW_CONVERSATION_CONFIRM_BUBBLE];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function extractContactCardsFromMessages(messages) {
|
|
459
|
+
const cards = [];
|
|
460
|
+
for (const message of Array.isArray(messages) ? messages : []) {
|
|
461
|
+
if (message?.role !== 'toolResult') continue;
|
|
462
|
+
const card = message.details?.card;
|
|
463
|
+
if (card && typeof card === 'object') cards.push(card);
|
|
464
|
+
}
|
|
465
|
+
return cards;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const toolFailure = (error, fallbackCode, fallbackMessage) => {
|
|
469
|
+
const code = error instanceof AssignError ? error.code : (error?.code || fallbackCode);
|
|
470
|
+
const message = typeof error?.message === 'string' && error.message.trim()
|
|
471
|
+
? error.message.trim()
|
|
472
|
+
: fallbackMessage;
|
|
473
|
+
return {
|
|
474
|
+
content: [{ type: 'text', text: message }],
|
|
475
|
+
details: { error: code },
|
|
476
|
+
terminate: true,
|
|
477
|
+
};
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
export function createContactTools({
|
|
481
|
+
assignWork,
|
|
482
|
+
createAssistant,
|
|
483
|
+
scheduleTask,
|
|
484
|
+
deliverPeerMessage,
|
|
485
|
+
resetContact,
|
|
486
|
+
listAssistants,
|
|
487
|
+
listProjects,
|
|
488
|
+
listSessions,
|
|
489
|
+
currentAssistant,
|
|
490
|
+
onCard,
|
|
491
|
+
} = {}) {
|
|
492
|
+
const emitCard = (card) => {
|
|
493
|
+
if (typeof onCard === 'function') onCard(card);
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
return [
|
|
497
|
+
{
|
|
498
|
+
name: NEW_CONVERSATION_TOOL_NAME,
|
|
499
|
+
label: 'New conversation',
|
|
500
|
+
description: [
|
|
501
|
+
'Clear this assistant contact transcript (messages, parts, and watches).',
|
|
502
|
+
'Use when the user says 开新对话, new conversation, or clear chat.',
|
|
503
|
+
'Does not call OpenCode session/new or create a worker session.',
|
|
504
|
+
].join(' '),
|
|
505
|
+
parameters: newConversationParameters,
|
|
506
|
+
execute: async () => {
|
|
507
|
+
try {
|
|
508
|
+
if (typeof resetContact !== 'function') {
|
|
509
|
+
throw new AssignError('upstream_error', 'Resetting this conversation is unavailable.');
|
|
510
|
+
}
|
|
511
|
+
await resetContact();
|
|
512
|
+
return {
|
|
513
|
+
content: [{ type: 'text', text: NEW_CONVERSATION_CONFIRM_BUBBLE }],
|
|
514
|
+
details: { reset: true },
|
|
515
|
+
terminate: true,
|
|
516
|
+
};
|
|
517
|
+
} catch (error) {
|
|
518
|
+
return toolFailure(error, 'new_conversation_failed', 'Could not start a new conversation.');
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
name: LIST_PROJECTS_TOOL_NAME,
|
|
524
|
+
label: 'List projects',
|
|
525
|
+
description: [
|
|
526
|
+
'List registered OpenChamber projects ({ id, path, label }).',
|
|
527
|
+
'Optional query fuzzy-matches label, path, or id (e.g. "openchamber yee").',
|
|
528
|
+
'Use when the user asks 找项目 / list projects. Prefer the Registered projects context first.',
|
|
529
|
+
].join(' '),
|
|
530
|
+
parameters: listProjectsParameters,
|
|
531
|
+
execute: async (_toolCallId, params) => {
|
|
532
|
+
try {
|
|
533
|
+
if (typeof listProjects !== 'function') {
|
|
534
|
+
throw new AssignError('upstream_error', 'Listing registered projects is unavailable.');
|
|
535
|
+
}
|
|
536
|
+
const listed = await listProjects();
|
|
537
|
+
if (!Array.isArray(listed)) {
|
|
538
|
+
throw new AssignError('upstream_error', 'Registered project catalog failed to load.');
|
|
539
|
+
}
|
|
540
|
+
const projects = filterRegisteredProjects(listed, params?.query);
|
|
541
|
+
const text = projects.length === 0
|
|
542
|
+
? (typeof params?.query === 'string' && params.query.trim()
|
|
543
|
+
? `No registered projects matched ${JSON.stringify(params.query.trim())}.`
|
|
544
|
+
: 'No registered projects. Tell the user to add one in Settings.')
|
|
545
|
+
: `Registered projects (${projects.length}): ${JSON.stringify(projects)}`;
|
|
546
|
+
return {
|
|
547
|
+
content: [{ type: 'text', text }],
|
|
548
|
+
details: { projects, count: projects.length },
|
|
549
|
+
terminate: false,
|
|
550
|
+
};
|
|
551
|
+
} catch (error) {
|
|
552
|
+
return toolFailure(error, 'list_projects_failed', 'Could not list registered projects.');
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
name: LIST_SESSIONS_TOOL_NAME,
|
|
558
|
+
label: 'List sessions',
|
|
559
|
+
description: [
|
|
560
|
+
'Search existing OpenCode/OpenChamber sessions for a registered project.',
|
|
561
|
+
'Optional projectPath/projectID scopes the search; query filters title/id.',
|
|
562
|
+
'Returns bounded { sessionID, title, directory, updatedAt }. Failure is not an empty success.',
|
|
563
|
+
'Use when the user asks 现有对话 / list sessions. Then assign_session with sessionID to reuse, or without sessionID to create.',
|
|
564
|
+
].join(' '),
|
|
565
|
+
parameters: listSessionsParameters,
|
|
566
|
+
execute: async (_toolCallId, params) => {
|
|
567
|
+
try {
|
|
568
|
+
if (typeof listSessions !== 'function') {
|
|
569
|
+
throw new AssignError('upstream_error', 'Listing sessions is unavailable.');
|
|
570
|
+
}
|
|
571
|
+
const result = await listSessions({
|
|
572
|
+
projectPath: params?.projectPath,
|
|
573
|
+
projectID: params?.projectID,
|
|
574
|
+
query: params?.query,
|
|
575
|
+
limit: boundSessionListLimit(params?.limit),
|
|
576
|
+
});
|
|
577
|
+
if (!result || !Array.isArray(result.sessions)) {
|
|
578
|
+
throw new AssignError('upstream_error', 'Session listing failed.');
|
|
579
|
+
}
|
|
580
|
+
const sessions = result.sessions;
|
|
581
|
+
const text = sessions.length === 0
|
|
582
|
+
? 'No matching sessions found for that project/query.'
|
|
583
|
+
: `Sessions (${sessions.length}${result.truncated ? ', truncated' : ''}): ${JSON.stringify(sessions)}`;
|
|
584
|
+
return {
|
|
585
|
+
content: [{ type: 'text', text }],
|
|
586
|
+
details: { sessions, count: sessions.length, truncated: Boolean(result.truncated) },
|
|
587
|
+
terminate: false,
|
|
588
|
+
};
|
|
589
|
+
} catch (error) {
|
|
590
|
+
return toolFailure(error, 'list_sessions_failed', 'Could not list sessions.');
|
|
591
|
+
}
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
{
|
|
595
|
+
name: CREATE_ASSISTANT_TOOL_NAME,
|
|
596
|
+
label: 'Create assistant',
|
|
597
|
+
description: [
|
|
598
|
+
'Create another OpenChamber assistant contact (POST createAssistant).',
|
|
599
|
+
'Requires a name. Reuse a connected OpenCode provider/model (providerID + modelID or model like opencode-go/deepseek-v4-flash).',
|
|
600
|
+
'Mode is always continuous. Emits an assistant card that opens that contact.',
|
|
601
|
+
].join(' '),
|
|
602
|
+
parameters: createAssistantParameters,
|
|
603
|
+
execute: async (_toolCallId, params) => {
|
|
604
|
+
try {
|
|
605
|
+
if (typeof createAssistant !== 'function') {
|
|
606
|
+
throw new AssignError('upstream_error', 'Creating an assistant is unavailable.');
|
|
607
|
+
}
|
|
608
|
+
const name = trim(params?.name, 256);
|
|
609
|
+
if (!name) {
|
|
610
|
+
throw new AssignError('validation_error', 'create_assistant requires a name.');
|
|
611
|
+
}
|
|
612
|
+
const model = resolveContactProviderModel(params, currentAssistant);
|
|
613
|
+
if (!model) {
|
|
614
|
+
throw new AssignError('no_provider', 'No connected OpenCode provider/model is available. Reuse a provider already configured in Settings.');
|
|
615
|
+
}
|
|
616
|
+
const created = await createAssistant({
|
|
617
|
+
name,
|
|
618
|
+
providerID: model.providerID,
|
|
619
|
+
modelID: model.modelID,
|
|
620
|
+
mode: 'continuous',
|
|
621
|
+
});
|
|
622
|
+
const card = createAssistantCardPart({
|
|
623
|
+
assistantID: created.id || created.assistantID,
|
|
624
|
+
name: created.name || name,
|
|
625
|
+
providerID: created.providerID || model.providerID,
|
|
626
|
+
modelID: created.modelID || model.modelID,
|
|
627
|
+
mode: created.mode || 'continuous',
|
|
628
|
+
});
|
|
629
|
+
emitCard(card);
|
|
630
|
+
return {
|
|
631
|
+
content: [{ type: 'text', text: `Created assistant ${card.name}. The user will see an assistant card.` }],
|
|
632
|
+
details: { card, assistant: created },
|
|
633
|
+
terminate: false,
|
|
634
|
+
};
|
|
635
|
+
} catch (error) {
|
|
636
|
+
return toolFailure(error, 'create_assistant_failed', 'Could not create that assistant.');
|
|
637
|
+
}
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
name: SCHEDULE_TASK_TOOL_NAME,
|
|
642
|
+
label: 'Schedule task',
|
|
643
|
+
description: [
|
|
644
|
+
'Create a scheduled task on a registered project (same payload as PUT /api/projects/:id/scheduled-tasks).',
|
|
645
|
+
'Needs name, prompt, and a daily/weekly/once/cron schedule (time + timezone).',
|
|
646
|
+
'Reuses this contact\'s provider/model unless the user named another connected model.',
|
|
647
|
+
'Emits a schedule card.',
|
|
648
|
+
].join(' '),
|
|
649
|
+
parameters: scheduleTaskParameters,
|
|
650
|
+
execute: async (_toolCallId, params) => {
|
|
651
|
+
try {
|
|
652
|
+
if (typeof scheduleTask !== 'function') {
|
|
653
|
+
throw new AssignError('upstream_error', 'Scheduling a task is unavailable.');
|
|
654
|
+
}
|
|
655
|
+
const name = trim(params?.name, 80);
|
|
656
|
+
const prompt = trim(params?.prompt, 20_000);
|
|
657
|
+
if (!name || !prompt) {
|
|
658
|
+
throw new AssignError('validation_error', 'schedule_task requires a name and prompt.');
|
|
659
|
+
}
|
|
660
|
+
const model = resolveContactProviderModel(params, currentAssistant);
|
|
661
|
+
if (!model) {
|
|
662
|
+
throw new AssignError('no_provider', 'No connected OpenCode provider/model is available for the scheduled task.');
|
|
663
|
+
}
|
|
664
|
+
const scheduled = await scheduleTask({
|
|
665
|
+
...params,
|
|
666
|
+
name,
|
|
667
|
+
prompt,
|
|
668
|
+
providerID: model.providerID,
|
|
669
|
+
modelID: model.modelID,
|
|
670
|
+
});
|
|
671
|
+
const card = createScheduleCardPart({
|
|
672
|
+
taskID: scheduled.taskID || scheduled.task?.id,
|
|
673
|
+
projectID: scheduled.projectID,
|
|
674
|
+
name: scheduled.name || scheduled.task?.name || name,
|
|
675
|
+
kind: scheduled.kind || scheduled.task?.schedule?.kind,
|
|
676
|
+
time: scheduled.time || scheduled.task?.schedule?.time || scheduled.task?.schedule?.times?.[0],
|
|
677
|
+
timezone: scheduled.timezone || scheduled.task?.schedule?.timezone,
|
|
678
|
+
prompt: scheduled.prompt || scheduled.task?.execution?.prompt || prompt,
|
|
679
|
+
});
|
|
680
|
+
emitCard(card);
|
|
681
|
+
return {
|
|
682
|
+
content: [{ type: 'text', text: `Scheduled ${card.name}. The user will see a schedule card.` }],
|
|
683
|
+
details: { card, scheduled },
|
|
684
|
+
terminate: false,
|
|
685
|
+
};
|
|
686
|
+
} catch (error) {
|
|
687
|
+
const fallback = error?.code === 'project_required' ? PROJECT_REQUIRED_MESSAGE : 'Could not create that scheduled task.';
|
|
688
|
+
return toolFailure(error, 'schedule_failed', fallback);
|
|
689
|
+
}
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
name: MESSAGE_ASSISTANT_TOOL_NAME,
|
|
694
|
+
label: 'Message assistant',
|
|
695
|
+
description: [
|
|
696
|
+
'Send a read-only peer message to another OpenChamber assistant contact.',
|
|
697
|
+
'Resolve the recipient by display name (to/name) or toAssistantID.',
|
|
698
|
+
'Inserts into their contact transcript only. Never promptAsync. Never mutates sessions or files.',
|
|
699
|
+
].join(' '),
|
|
700
|
+
parameters: messageAssistantParameters,
|
|
701
|
+
execute: async (_toolCallId, params) => {
|
|
702
|
+
try {
|
|
703
|
+
if (typeof deliverPeerMessage !== 'function') {
|
|
704
|
+
throw new AssignError('upstream_error', 'Messaging another assistant is unavailable.');
|
|
705
|
+
}
|
|
706
|
+
const text = trim(params?.text, 20_000) || trim(params?.message, 20_000);
|
|
707
|
+
if (!text) {
|
|
708
|
+
throw new AssignError('validation_error', 'message_assistant requires text.');
|
|
709
|
+
}
|
|
710
|
+
const listed = typeof listAssistants === 'function' ? await listAssistants() : [];
|
|
711
|
+
const recipient = resolvePeerAssistant(params, listed, currentAssistant);
|
|
712
|
+
const toAssistantID = assistantIdentity(recipient);
|
|
713
|
+
const delivered = await deliverPeerMessage({
|
|
714
|
+
toAssistantID,
|
|
715
|
+
text,
|
|
716
|
+
});
|
|
717
|
+
return {
|
|
718
|
+
content: [{ type: 'text', text: `Sent to ${recipient.name}. They will see it in their contact.` }],
|
|
719
|
+
details: { delivered, toAssistantID, toName: recipient.name, text },
|
|
720
|
+
terminate: false,
|
|
721
|
+
};
|
|
722
|
+
} catch (error) {
|
|
723
|
+
return toolFailure(error, 'message_assistant_failed', 'Could not message that assistant.');
|
|
724
|
+
}
|
|
725
|
+
},
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
name: ASSIGN_SESSION_TOOL_NAME,
|
|
729
|
+
label: 'Assign session',
|
|
730
|
+
description: [
|
|
731
|
+
'Open or reuse a real OpenChamber coding session on a registered project path',
|
|
732
|
+
'and kick the prompt into that session. Optional existing worktree branch or sessionID.',
|
|
733
|
+
'Never codes here. Never uses assistant-workspaces.',
|
|
734
|
+
].join(' '),
|
|
735
|
+
parameters: assignParameters,
|
|
736
|
+
execute: async (_toolCallId, params) => {
|
|
737
|
+
try {
|
|
738
|
+
if (typeof assignWork !== 'function') {
|
|
739
|
+
throw new AssignError('upstream_error', 'Assign is unavailable.');
|
|
740
|
+
}
|
|
741
|
+
const assigned = await assignWork(params || {});
|
|
742
|
+
const card = createSessionCardPart({
|
|
743
|
+
sessionID: assigned.sessionID,
|
|
744
|
+
directory: assigned.directory,
|
|
745
|
+
title: assigned.title,
|
|
746
|
+
status: assigned.status || 'busy',
|
|
747
|
+
branch: assigned.branch,
|
|
748
|
+
});
|
|
749
|
+
emitCard(card);
|
|
750
|
+
return {
|
|
751
|
+
content: [{ type: 'text', text: `Opened coding session ${assigned.sessionID}. The user will see a session card.` }],
|
|
752
|
+
details: { card, assigned },
|
|
753
|
+
terminate: false,
|
|
754
|
+
};
|
|
755
|
+
} catch (error) {
|
|
756
|
+
return toolFailure(error, 'assign_failed', 'Could not assign a coding session.');
|
|
757
|
+
}
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
];
|
|
761
|
+
}
|