livedesk 0.1.203 → 0.1.204

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.
@@ -0,0 +1,131 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { AgentSettingsStore } from './agent-settings.js';
4
+ import { createOsSecretStore } from './secret-store.js';
5
+ import { createOpenCodeGoProvider } from './opencode-go-provider.js';
6
+
7
+ function maskApiKey(value) {
8
+ const key = String(value || '');
9
+ return key.length >= 4 ? `••••${key.slice(-4)}` : '';
10
+ }
11
+
12
+ export function createAgentManager({ dataDir = path.join(os.homedir(), '.livedesk'), settingsStore, secretStore, provider } = {}) {
13
+ const settings = settingsStore || new AgentSettingsStore({ dataDir });
14
+ const secrets = secretStore || createOsSecretStore({ dataDir });
15
+ const agentProvider = provider || createOpenCodeGoProvider();
16
+
17
+ async function publicSettings() {
18
+ const current = await settings.get();
19
+ let hasApiKey = false;
20
+ let apiKeyPreview = '';
21
+ let secretStoreAvailable = true;
22
+ try {
23
+ const key = await secrets.getApiKey(current.provider);
24
+ hasApiKey = Boolean(key);
25
+ apiKeyPreview = maskApiKey(key);
26
+ } catch (error) {
27
+ secretStoreAvailable = false;
28
+ }
29
+ return { ...current, hasApiKey, apiKeyPreview, secretStoreAvailable };
30
+ }
31
+
32
+ async function apiKeyForCurrentSettings() {
33
+ const current = await settings.get();
34
+ const apiKey = await secrets.getApiKey(current.provider);
35
+ if (!apiKey) {
36
+ const error = new Error('OpenCode Go API key is not configured.');
37
+ error.code = 'agent-api-key-missing';
38
+ error.status = 401;
39
+ throw error;
40
+ }
41
+ return { current, apiKey };
42
+ }
43
+
44
+ return {
45
+ getSettings: publicSettings,
46
+ async updateSettings(patch = {}) {
47
+ const nextPatch = { ...patch };
48
+ const apiKey = typeof nextPatch.apiKey === 'string' ? nextPatch.apiKey.trim() : '';
49
+ delete nextPatch.apiKey;
50
+ const current = await settings.get();
51
+ if (apiKey) await secrets.setApiKey(current.provider, apiKey);
52
+ await settings.update(nextPatch);
53
+ return publicSettings();
54
+ },
55
+ async resetSettings() {
56
+ await settings.reset();
57
+ return publicSettings();
58
+ },
59
+ async deleteApiKey() {
60
+ const current = await settings.get();
61
+ await secrets.deleteApiKey(current.provider);
62
+ return publicSettings();
63
+ },
64
+ async getModels() {
65
+ const { current, apiKey } = await apiKeyForCurrentSettings();
66
+ return agentProvider.getModels(current, apiKey);
67
+ },
68
+ async testConnection() {
69
+ const { current, apiKey } = await apiKeyForCurrentSettings();
70
+ return agentProvider.testConnection(current, apiKey);
71
+ },
72
+ async createPlan(input) {
73
+ const current = await settings.get();
74
+ if (!current.enabled) {
75
+ const error = new Error('Agent AI is disabled in Settings.');
76
+ error.code = 'agent-ai-disabled';
77
+ error.status = 409;
78
+ throw error;
79
+ }
80
+ const apiKey = await secrets.getApiKey(current.provider);
81
+ if (!apiKey) {
82
+ const error = new Error('OpenCode Go API key is not configured.');
83
+ error.code = 'agent-api-key-missing';
84
+ error.status = 401;
85
+ throw error;
86
+ }
87
+ return agentProvider.createPlan(input, current, apiKey);
88
+ },
89
+ async createSummary(input) {
90
+ const current = await settings.get();
91
+ if (!current.enabled || !current.generateSummary) {
92
+ const error = new Error('Agent summaries are disabled in Settings.');
93
+ error.code = 'agent-summary-disabled';
94
+ error.status = 409;
95
+ throw error;
96
+ }
97
+ const apiKey = await secrets.getApiKey(current.provider);
98
+ if (!apiKey) {
99
+ const error = new Error('OpenCode Go API key is not configured.');
100
+ error.code = 'agent-api-key-missing';
101
+ error.status = 401;
102
+ throw error;
103
+ }
104
+ const source = input && typeof input === 'object' ? input : {};
105
+ const results = Array.isArray(source.results) ? source.results : [];
106
+ const safeResults = current.includeRawDeviceDetails
107
+ ? results.map(result => ({
108
+ deviceName: String(result?.deviceName || '').slice(0, 120),
109
+ status: String(result?.status || '').slice(0, 40),
110
+ result: String(result?.result || '').slice(0, 1200),
111
+ error: String(result?.error || '').slice(0, 500)
112
+ }))
113
+ : results.map(result => ({
114
+ deviceName: String(result?.deviceName || '').slice(0, 120),
115
+ status: String(result?.status || '').slice(0, 40),
116
+ hasResult: Boolean(result?.result),
117
+ hasError: Boolean(result?.error)
118
+ }));
119
+ return agentProvider.createSummary({
120
+ instruction: String(source.instruction || '').slice(0, 500),
121
+ operation: String(source.operation || '').slice(0, 80),
122
+ summary: {
123
+ total: Number(source.total || results.length),
124
+ completed: Number(source.completed || 0),
125
+ failed: Number(source.failed || 0),
126
+ results: safeResults
127
+ }
128
+ }, current, apiKey);
129
+ }
130
+ };
131
+ }
@@ -0,0 +1,154 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ export const AGENT_PROVIDER = 'opencode-go';
6
+ export const DEFAULT_AGENT_BASE_URL = 'https://opencode.ai/zen/go/v1';
7
+
8
+ export const DEFAULT_AGENT_SETTINGS = Object.freeze({
9
+ enabled: false,
10
+ provider: AGENT_PROVIDER,
11
+ baseUrl: DEFAULT_AGENT_BASE_URL,
12
+ defaultModelId: 'deepseek-v4-flash',
13
+ planModelId: '',
14
+ summaryModelId: '',
15
+ reasoningLevel: 'auto',
16
+ planTemperature: 0.1,
17
+ summaryTemperature: 0.2,
18
+ topP: 1,
19
+ planMaxOutputTokens: 1024,
20
+ summaryMaxOutputTokens: 1024,
21
+ planTimeoutMs: 60000,
22
+ summaryTimeoutMs: 60000,
23
+ retryCount: 1,
24
+ retryDelayMs: 1000,
25
+ maxConcurrentRequests: 2,
26
+ streamSummary: false,
27
+ deterministicParserFirst: true,
28
+ generateSummary: true,
29
+ fallbackSummary: true,
30
+ includeRawDeviceDetails: false
31
+ });
32
+
33
+ const REASONING_LEVELS = new Set(['auto', 'low', 'medium', 'high']);
34
+
35
+ export class AgentSettingsError extends Error {
36
+ constructor(code, message) {
37
+ super(message);
38
+ this.name = 'AgentSettingsError';
39
+ this.code = code;
40
+ this.status = 400;
41
+ }
42
+ }
43
+
44
+ function booleanValue(value, fallback) {
45
+ return typeof value === 'boolean' ? value : fallback;
46
+ }
47
+
48
+ function numberValue(value, min, max, fallback, integer = false) {
49
+ const number = Number(value);
50
+ if (!Number.isFinite(number)) return fallback;
51
+ const clamped = Math.max(min, Math.min(max, number));
52
+ return integer ? Math.round(clamped) : clamped;
53
+ }
54
+
55
+ function stringValue(value, fallback, maxLength = 240) {
56
+ if (typeof value !== 'string') return fallback;
57
+ return value.trim().slice(0, maxLength);
58
+ }
59
+
60
+ function normalizeBaseUrl(value) {
61
+ const candidate = stringValue(value, DEFAULT_AGENT_BASE_URL, 500).replace(/\/+$/, '');
62
+ try {
63
+ const parsed = new URL(candidate);
64
+ if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
65
+ throw new Error('invalid protocol');
66
+ }
67
+ parsed.hash = '';
68
+ parsed.search = '';
69
+ return parsed.toString().replace(/\/+$/, '');
70
+ } catch {
71
+ throw new AgentSettingsError('agent-invalid-base-url', 'Base URL must be a valid HTTP or HTTPS URL.');
72
+ }
73
+ }
74
+
75
+ export function normalizeAgentSettings(value = {}) {
76
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
77
+ const provider = stringValue(source.provider, DEFAULT_AGENT_SETTINGS.provider, 40);
78
+ if (provider !== AGENT_PROVIDER) {
79
+ throw new AgentSettingsError('agent-unsupported-provider', 'Only OpenCode Go is supported by this LiveDesk build.');
80
+ }
81
+ const reasoningLevel = stringValue(source.reasoningLevel, DEFAULT_AGENT_SETTINGS.reasoningLevel, 20).toLowerCase();
82
+ if (!REASONING_LEVELS.has(reasoningLevel)) {
83
+ throw new AgentSettingsError('agent-invalid-reasoning-level', 'Reasoning level is invalid.');
84
+ }
85
+ return {
86
+ enabled: booleanValue(source.enabled, DEFAULT_AGENT_SETTINGS.enabled),
87
+ provider: AGENT_PROVIDER,
88
+ baseUrl: normalizeBaseUrl(source.baseUrl ?? DEFAULT_AGENT_SETTINGS.baseUrl),
89
+ defaultModelId: stringValue(source.defaultModelId, DEFAULT_AGENT_SETTINGS.defaultModelId, 160),
90
+ planModelId: stringValue(source.planModelId, DEFAULT_AGENT_SETTINGS.planModelId, 160),
91
+ summaryModelId: stringValue(source.summaryModelId, DEFAULT_AGENT_SETTINGS.summaryModelId, 160),
92
+ reasoningLevel,
93
+ planTemperature: numberValue(source.planTemperature, 0, 2, DEFAULT_AGENT_SETTINGS.planTemperature),
94
+ summaryTemperature: numberValue(source.summaryTemperature, 0, 2, DEFAULT_AGENT_SETTINGS.summaryTemperature),
95
+ topP: numberValue(source.topP, 0, 1, DEFAULT_AGENT_SETTINGS.topP),
96
+ planMaxOutputTokens: numberValue(source.planMaxOutputTokens, 128, 16384, DEFAULT_AGENT_SETTINGS.planMaxOutputTokens, true),
97
+ summaryMaxOutputTokens: numberValue(source.summaryMaxOutputTokens, 128, 16384, DEFAULT_AGENT_SETTINGS.summaryMaxOutputTokens, true),
98
+ planTimeoutMs: numberValue(source.planTimeoutMs, 5000, 180000, DEFAULT_AGENT_SETTINGS.planTimeoutMs, true),
99
+ summaryTimeoutMs: numberValue(source.summaryTimeoutMs, 5000, 180000, DEFAULT_AGENT_SETTINGS.summaryTimeoutMs, true),
100
+ retryCount: numberValue(source.retryCount, 0, 4, DEFAULT_AGENT_SETTINGS.retryCount, true),
101
+ retryDelayMs: numberValue(source.retryDelayMs, 100, 10000, DEFAULT_AGENT_SETTINGS.retryDelayMs, true),
102
+ maxConcurrentRequests: numberValue(source.maxConcurrentRequests, 1, 8, DEFAULT_AGENT_SETTINGS.maxConcurrentRequests, true),
103
+ streamSummary: booleanValue(source.streamSummary, DEFAULT_AGENT_SETTINGS.streamSummary),
104
+ deterministicParserFirst: booleanValue(source.deterministicParserFirst, DEFAULT_AGENT_SETTINGS.deterministicParserFirst),
105
+ generateSummary: booleanValue(source.generateSummary, DEFAULT_AGENT_SETTINGS.generateSummary),
106
+ fallbackSummary: booleanValue(source.fallbackSummary, DEFAULT_AGENT_SETTINGS.fallbackSummary),
107
+ includeRawDeviceDetails: booleanValue(source.includeRawDeviceDetails, DEFAULT_AGENT_SETTINGS.includeRawDeviceDetails)
108
+ };
109
+ }
110
+
111
+ async function writeJsonAtomic(filePath, value) {
112
+ await mkdir(path.dirname(filePath), { recursive: true });
113
+ const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
114
+ await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
115
+ await rename(temporaryPath, filePath);
116
+ }
117
+
118
+ export class AgentSettingsStore {
119
+ constructor({ dataDir = path.join(os.homedir(), '.livedesk') } = {}) {
120
+ this.filePath = path.join(dataDir, 'agent-settings.json');
121
+ this.settings = null;
122
+ }
123
+
124
+ async get() {
125
+ if (this.settings) return { ...this.settings };
126
+ try {
127
+ const raw = await readFile(this.filePath, 'utf8');
128
+ this.settings = normalizeAgentSettings(JSON.parse(raw));
129
+ } catch (error) {
130
+ if (error?.code !== 'ENOENT') {
131
+ this.settings = normalizeAgentSettings(DEFAULT_AGENT_SETTINGS);
132
+ } else {
133
+ this.settings = normalizeAgentSettings(DEFAULT_AGENT_SETTINGS);
134
+ }
135
+ }
136
+ return { ...this.settings };
137
+ }
138
+
139
+ async update(patch = {}) {
140
+ const current = await this.get();
141
+ const next = normalizeAgentSettings({ ...current, ...patch });
142
+ await writeJsonAtomic(this.filePath, next);
143
+ this.settings = next;
144
+ return { ...next };
145
+ }
146
+
147
+ async reset() {
148
+ const next = normalizeAgentSettings(DEFAULT_AGENT_SETTINGS);
149
+ await writeJsonAtomic(this.filePath, next);
150
+ this.settings = next;
151
+ return { ...next };
152
+ }
153
+ }
154
+
@@ -0,0 +1,260 @@
1
+ import { AgentProviderError, isRetryableProviderError } from './provider-errors.js';
2
+ import { DEFAULT_AGENT_BASE_URL } from './agent-settings.js';
3
+
4
+ export const OPENCODE_GO_DEFAULT_MODEL_ID = 'deepseek-v4-flash';
5
+
6
+ const AGENT_OPERATIONS = new Set([
7
+ 'system.health',
8
+ 'gpu.status',
9
+ 'disk.status',
10
+ 'process.list',
11
+ 'service.status',
12
+ 'diagnostics.collect'
13
+ ]);
14
+
15
+ function normalizeBaseUrl(value) {
16
+ try {
17
+ const url = new URL(String(value || DEFAULT_AGENT_BASE_URL));
18
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error('protocol');
19
+ return url.toString().replace(/\/+$/, '');
20
+ } catch {
21
+ throw new AgentProviderError('agent-invalid-base-url', 'The Agent provider URL is invalid.', { status: 400 });
22
+ }
23
+ }
24
+
25
+ function normalizeCapabilities(source = {}) {
26
+ const capabilities = source.capabilities && typeof source.capabilities === 'object' ? source.capabilities : source;
27
+ return {
28
+ supportsReasoningLevel: capabilities.supportsReasoningLevel === true || capabilities.reasoning === true,
29
+ reasoningParameter: typeof capabilities.reasoningParameter === 'string' ? capabilities.reasoningParameter : '',
30
+ supportsTemperature: capabilities.supportsTemperature !== false,
31
+ supportsTopP: capabilities.supportsTopP !== false,
32
+ supportsStreaming: capabilities.supportsStreaming !== false,
33
+ supportsTools: capabilities.supportsTools === true,
34
+ supportsJsonSchema: capabilities.supportsJsonSchema === true
35
+ };
36
+ }
37
+
38
+ function normalizeModel(model) {
39
+ const id = String(model?.id || model?.model || '').trim();
40
+ if (!id) return null;
41
+ const protocolValue = String(model?.protocol || model?.api?.protocol || model?.type || '').trim().toLowerCase();
42
+ const protocol = /anthropic|messages/.test(protocolValue) ? 'anthropic-messages' : 'openai-chat-completions';
43
+ return {
44
+ id,
45
+ name: String(model?.name || model?.display_name || id).trim().slice(0, 200),
46
+ protocol,
47
+ available: model?.available !== false && protocol === 'openai-chat-completions',
48
+ capabilities: normalizeCapabilities(model)
49
+ };
50
+ }
51
+
52
+ export function normalizeModelList(payload) {
53
+ const source = Array.isArray(payload) ? payload : Array.isArray(payload?.data) ? payload.data : [];
54
+ return source.map(normalizeModel).filter(Boolean);
55
+ }
56
+
57
+ function delay(ms) {
58
+ return new Promise(resolve => setTimeout(resolve, ms));
59
+ }
60
+
61
+ function contentFromCompletion(payload) {
62
+ const content = payload?.choices?.[0]?.message?.content ?? payload?.choices?.[0]?.text ?? '';
63
+ if (Array.isArray(content)) return content.map(item => typeof item === 'string' ? item : String(item?.text || '')).join('');
64
+ return String(content || '');
65
+ }
66
+
67
+ function parsePlan(content) {
68
+ const trimmed = String(content || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();
69
+ let candidate = trimmed;
70
+ try {
71
+ return JSON.parse(candidate);
72
+ } catch {
73
+ const start = trimmed.indexOf('{');
74
+ const end = trimmed.lastIndexOf('}');
75
+ if (start < 0 || end <= start) throw new AgentProviderError('agent-provider-invalid-response', 'The Agent provider returned an invalid plan.', { status: 502 });
76
+ candidate = trimmed.slice(start, end + 1);
77
+ try {
78
+ return JSON.parse(candidate);
79
+ } catch {
80
+ throw new AgentProviderError('agent-provider-invalid-response', 'The Agent provider returned an invalid plan.', { status: 502 });
81
+ }
82
+ }
83
+ }
84
+
85
+ function validatePlan(value) {
86
+ const operation = String(value?.operation || '').trim();
87
+ if (!AGENT_OPERATIONS.has(operation)) {
88
+ throw new AgentProviderError('agent-provider-invalid-plan', 'The Agent provider returned an unsupported operation.', { status: 502 });
89
+ }
90
+ const targetQuery = operation === 'process.list' && typeof value?.targetQuery === 'string'
91
+ ? value.targetQuery.replace(/[\0\r\n]/g, ' ').trim().slice(0, 120)
92
+ : '';
93
+ return {
94
+ operation,
95
+ targetQuery: targetQuery || undefined,
96
+ title: typeof value?.title === 'string' ? value.title.replace(/[\0\r\n]/g, ' ').trim().slice(0, 120) || undefined : undefined
97
+ };
98
+ }
99
+
100
+ export function createOpenCodeGoProvider() {
101
+ async function requestJson({ settings, apiKey, endpoint, method = 'GET', body, timeoutMs }) {
102
+ if (!apiKey) throw new AgentProviderError('agent-api-key-missing', 'OpenCode Go API key is not configured.', { status: 401 });
103
+ const url = `${normalizeBaseUrl(settings.baseUrl)}/${String(endpoint).replace(/^\/+/, '')}`;
104
+ const retryCount = Math.max(0, Math.min(4, Number(settings.retryCount) || 0));
105
+ const retryDelayMs = Math.max(100, Math.min(10000, Number(settings.retryDelayMs) || 1000));
106
+ for (let attempt = 0; attempt <= retryCount; attempt += 1) {
107
+ const controller = new AbortController();
108
+ const timer = setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutMs) || 60000));
109
+ try {
110
+ const response = await fetch(url, {
111
+ method,
112
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
113
+ body: body === undefined ? undefined : JSON.stringify(body),
114
+ signal: controller.signal
115
+ });
116
+ clearTimeout(timer);
117
+ if (response.ok) return await response.json();
118
+ const status = response.status;
119
+ const retryable = status === 408 || status === 429 || status >= 500;
120
+ if (retryable && attempt < retryCount) {
121
+ await delay(retryDelayMs * (attempt + 1));
122
+ continue;
123
+ }
124
+ if (status === 401 || status === 403) throw new AgentProviderError('agent-api-key-invalid', 'OpenCode Go rejected the API key.', { status: 401 });
125
+ if (status === 429) throw new AgentProviderError('agent-provider-rate-limited', 'OpenCode Go is rate limiting requests.', { status: 429, retryable: true });
126
+ throw new AgentProviderError(status >= 500 ? 'agent-provider-upstream' : 'agent-provider-request-invalid', 'OpenCode Go rejected the request.', { status: status >= 500 ? 502 : 400, retryable });
127
+ } catch (error) {
128
+ clearTimeout(timer);
129
+ if (error instanceof AgentProviderError) {
130
+ if (isRetryableProviderError(error) && attempt < retryCount) {
131
+ await delay(retryDelayMs * (attempt + 1));
132
+ continue;
133
+ }
134
+ throw error;
135
+ }
136
+ const code = error?.name === 'AbortError' ? 'agent-provider-timeout' : 'agent-provider-network';
137
+ const message = code === 'agent-provider-timeout' ? 'OpenCode Go request timed out.' : 'OpenCode Go could not be reached.';
138
+ const providerError = new AgentProviderError(code, message, { status: code === 'agent-provider-timeout' ? 504 : 502, retryable: true });
139
+ if (attempt < retryCount) {
140
+ await delay(retryDelayMs * (attempt + 1));
141
+ continue;
142
+ }
143
+ throw providerError;
144
+ }
145
+ }
146
+ throw new AgentProviderError('agent-provider-network', 'OpenCode Go could not be reached.', { status: 502, retryable: true });
147
+ }
148
+
149
+ async function getModels(settings, apiKey) {
150
+ return normalizeModelList(await requestJson({ settings, apiKey, endpoint: 'models', timeoutMs: settings.summaryTimeoutMs }));
151
+ }
152
+
153
+ async function selectedModel(settings, apiKey, modelId) {
154
+ const models = await getModels(settings, apiKey);
155
+ const selected = models.find(model => model.id === modelId);
156
+ if (models.length > 0 && (!selected || !selected.available)) {
157
+ throw new AgentProviderError('agent-model-unavailable', 'The selected model is not available for LiveDesk.', { status: 409 });
158
+ }
159
+ return { models, model: selected || { id: modelId, name: modelId, available: true, protocol: 'openai-chat-completions', capabilities: normalizeCapabilities() } };
160
+ }
161
+
162
+ function completionBody({ model, messages, temperature, topP, maxTokens, reasoningLevel }) {
163
+ const body = { model: model.id, messages, max_tokens: maxTokens };
164
+ if (model.capabilities.supportsTemperature) body.temperature = temperature;
165
+ if (model.capabilities.supportsTopP) body.top_p = topP;
166
+ if (reasoningLevel !== 'auto' && model.capabilities.supportsReasoningLevel && model.capabilities.reasoningParameter) {
167
+ body[model.capabilities.reasoningParameter] = reasoningLevel;
168
+ }
169
+ return body;
170
+ }
171
+
172
+ async function testConnection(settings, apiKey) {
173
+ const startedAt = Date.now();
174
+ const modelId = settings.defaultModelId || OPENCODE_GO_DEFAULT_MODEL_ID;
175
+ const { model } = await selectedModel(settings, apiKey, modelId);
176
+ const payload = await requestJson({
177
+ settings,
178
+ apiKey,
179
+ endpoint: 'chat/completions',
180
+ method: 'POST',
181
+ timeoutMs: settings.planTimeoutMs,
182
+ body: completionBody({
183
+ model,
184
+ messages: [
185
+ { role: 'system', content: 'Return only the JSON object {"ok":true}. Do not add markdown.' },
186
+ { role: 'user', content: 'Connection test.' }
187
+ ],
188
+ temperature: 0,
189
+ topP: 1,
190
+ maxTokens: 32,
191
+ reasoningLevel: 'auto'
192
+ })
193
+ });
194
+ if (!contentFromCompletion(payload)) throw new AgentProviderError('agent-provider-invalid-response', 'OpenCode Go returned an empty response.', { status: 502 });
195
+ return { ok: true, modelId: model.id, modelName: model.name, latencyMs: Date.now() - startedAt };
196
+ }
197
+
198
+ async function createPlan({ instruction }, settings, apiKey) {
199
+ const normalizedInstruction = String(instruction || '').replace(/[\0]/g, ' ').trim().slice(0, 2000);
200
+ if (!normalizedInstruction) throw new AgentProviderError('agent-invalid-instruction', 'Instruction is required.', { status: 400 });
201
+ const modelId = settings.planModelId || settings.defaultModelId || OPENCODE_GO_DEFAULT_MODEL_ID;
202
+ const { model } = await selectedModel(settings, apiKey, modelId);
203
+ const payload = await requestJson({
204
+ settings,
205
+ apiKey,
206
+ endpoint: 'chat/completions',
207
+ method: 'POST',
208
+ timeoutMs: settings.planTimeoutMs,
209
+ body: completionBody({
210
+ model,
211
+ messages: [
212
+ {
213
+ role: 'system',
214
+ content: 'You are the LiveDesk read-only task planner. Return JSON only with exactly these keys: operation, targetQuery, title. Allowed operation values are system.health, gpu.status, disk.status, process.list, service.status, diagnostics.collect. Never suggest shell commands, PowerShell, Bash, file changes, installs, deletion, reboot, credentials, or arbitrary tools. Use targetQuery only for process.list; otherwise use null. If the request is outside the allowlist, return {"operation":null,"targetQuery":null,"title":"Unsupported request"}.'
215
+ },
216
+ { role: 'user', content: normalizedInstruction }
217
+ ],
218
+ temperature: settings.planTemperature,
219
+ topP: settings.topP,
220
+ maxTokens: settings.planMaxOutputTokens,
221
+ reasoningLevel: settings.reasoningLevel
222
+ })
223
+ });
224
+ return validatePlan(parsePlan(contentFromCompletion(payload)));
225
+ }
226
+
227
+ async function createSummary({ instruction, operation, summary }, settings, apiKey) {
228
+ const modelId = settings.summaryModelId || settings.defaultModelId || OPENCODE_GO_DEFAULT_MODEL_ID;
229
+ const { model } = await selectedModel(settings, apiKey, modelId);
230
+ const payload = await requestJson({
231
+ settings,
232
+ apiKey,
233
+ endpoint: 'chat/completions',
234
+ method: 'POST',
235
+ timeoutMs: settings.summaryTimeoutMs,
236
+ body: completionBody({
237
+ model,
238
+ messages: [
239
+ {
240
+ role: 'system',
241
+ content: 'You summarize completed LiveDesk read-only checks. Return plain text only, in at most three short sentences. Never include or invent commands, credentials, file paths, or remediation instructions.'
242
+ },
243
+ {
244
+ role: 'user',
245
+ content: JSON.stringify({ instruction: String(instruction || '').slice(0, 500), operation, summary })
246
+ }
247
+ ],
248
+ temperature: settings.summaryTemperature,
249
+ topP: settings.topP,
250
+ maxTokens: settings.summaryMaxOutputTokens,
251
+ reasoningLevel: 'auto'
252
+ })
253
+ });
254
+ const text = contentFromCompletion(payload).replace(/[\0]/g, ' ').trim().slice(0, 1200);
255
+ if (!text) throw new AgentProviderError('agent-provider-invalid-response', 'OpenCode Go returned an empty summary.', { status: 502 });
256
+ return { summary: text, modelId: model.id };
257
+ }
258
+
259
+ return { getModels, testConnection, createPlan, createSummary };
260
+ }
@@ -0,0 +1,14 @@
1
+ export class AgentProviderError extends Error {
2
+ constructor(code, message, { status = 502, retryable = false } = {}) {
3
+ super(message);
4
+ this.name = 'AgentProviderError';
5
+ this.code = code;
6
+ this.status = status;
7
+ this.retryable = retryable;
8
+ }
9
+ }
10
+
11
+ export function isRetryableProviderError(error) {
12
+ return error?.retryable === true || ['agent-provider-timeout', 'agent-provider-network', 'agent-provider-rate-limited', 'agent-provider-upstream'].includes(error?.code);
13
+ }
14
+