@simonyea/holysheep-cli 1.7.134 → 1.7.136

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.
@@ -18,6 +18,8 @@ const { removeEnvFromShell, writeEnvToShell, getShellRcFiles } = require('../uti
18
18
  const { commandExists } = require('../utils/which')
19
19
  const TOOLS = require('../tools')
20
20
  const pkg = require('../../package.json')
21
+ const workspaceStore = require('./workspace-store')
22
+ const workspaceRuntime = require('./workspace-runtime')
21
23
 
22
24
  // ── Helpers ──────────────────────────────────────────────────────────────────
23
25
 
@@ -190,6 +192,11 @@ async function handleLogin(req, res) {
190
192
  const valid = await validateApiKey(apiKey)
191
193
  if (!valid) return json(res, { success: false, message: 'API Key 无效' }, 401)
192
194
  saveConfig({ apiKey, savedAt: new Date().toISOString() })
195
+ workspaceStore.saveHolySheepApiConfig({
196
+ apiKey,
197
+ baseUrl: workspaceStore.getHolySheepApiConfig().baseUrl || BASE_URL_OPENAI,
198
+ model: workspaceStore.getHolySheepApiConfig().model || 'gpt-5.4',
199
+ })
193
200
  json(res, { success: true, apiKey: maskKey(apiKey) })
194
201
  } catch (e) {
195
202
  json(res, { success: false, message: `验证失败: ${e.message}` }, 500)
@@ -201,6 +208,11 @@ async function handleLogout(_req, res) {
201
208
  delete config.apiKey
202
209
  delete config.savedAt
203
210
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
211
+ workspaceStore.saveHolySheepApiConfig({
212
+ apiKey: '',
213
+ baseUrl: workspaceStore.getHolySheepApiConfig().baseUrl || BASE_URL_OPENAI,
214
+ model: workspaceStore.getHolySheepApiConfig().model || 'gpt-5.4',
215
+ })
204
216
  json(res, { success: true })
205
217
  }
206
218
 
@@ -1034,6 +1046,192 @@ function handleModels(_req, res) {
1034
1046
  ])
1035
1047
  }
1036
1048
 
1049
+ function getWorkspacePayload() {
1050
+ const config = workspaceRuntime.normalizeRuntimeConfig({})
1051
+ const hasRuntimeConfig = Boolean(config.apiKey && config.baseUrl && config.model)
1052
+ return {
1053
+ conversations: workspaceStore.listConversations(),
1054
+ scheduledTasks: workspaceStore.listTasks(),
1055
+ holySheepApi: {
1056
+ ...config,
1057
+ apiKeyMasked: config.apiKey ? maskKey(config.apiKey) : null,
1058
+ ready: hasRuntimeConfig,
1059
+ },
1060
+ tools: TOOLS.map((tool) => ({
1061
+ id: tool.id,
1062
+ name: tool.name,
1063
+ launchCmd: tool.launchCmd || null,
1064
+ hint: tool.hint || null,
1065
+ })),
1066
+ }
1067
+ }
1068
+
1069
+ async function handleWorkspaceState(_req, res) {
1070
+ json(res, getWorkspacePayload())
1071
+ }
1072
+
1073
+ async function handleWorkspaceSearch(req, res, url) {
1074
+ const query = url.searchParams.get('q') || ''
1075
+ json(res, workspaceStore.searchWorkspace(query))
1076
+ }
1077
+
1078
+ async function handleWorkspaceApiConfig(req, res) {
1079
+ if (req.method === 'GET') {
1080
+ const config = workspaceRuntime.normalizeRuntimeConfig({})
1081
+ return json(res, {
1082
+ apiKey: config.apiKey,
1083
+ apiKeyMasked: config.apiKey ? maskKey(config.apiKey) : null,
1084
+ baseUrl: config.baseUrl,
1085
+ model: config.model,
1086
+ ready: Boolean(config.apiKey && config.baseUrl && config.model),
1087
+ })
1088
+ }
1089
+
1090
+ const body = await parseBody(req)
1091
+ const apiKey = String(body.apiKey || '').trim()
1092
+ const baseUrl = String(body.baseUrl || '').trim() || BASE_URL_OPENAI
1093
+ const model = String(body.model || '').trim()
1094
+ if (!apiKey || !apiKey.startsWith('cr_')) {
1095
+ return json(res, { success: false, error: 'HolySheep API Key 必须以 cr_ 开头' }, 400)
1096
+ }
1097
+ if (!model) {
1098
+ return json(res, { success: false, error: '模型不能为空' }, 400)
1099
+ }
1100
+
1101
+ workspaceStore.saveHolySheepApiConfig({ apiKey, baseUrl, model })
1102
+ saveConfig({ apiKey, savedAt: new Date().toISOString() })
1103
+ json(res, { success: true, config: getWorkspacePayload().holySheepApi })
1104
+ }
1105
+
1106
+ async function handleWorkspaceConversations(req, res) {
1107
+ if (req.method === 'GET') {
1108
+ return json(res, workspaceStore.listConversations())
1109
+ }
1110
+ const body = await parseBody(req)
1111
+ const conversation = workspaceStore.createConversation({
1112
+ title: body.title,
1113
+ toolId: body.toolId,
1114
+ pinned: body.pinned,
1115
+ })
1116
+ json(res, { success: true, conversation })
1117
+ }
1118
+
1119
+ async function handleWorkspaceConversationById(req, res, conversationId) {
1120
+ const conversation = workspaceStore.getConversation(conversationId)
1121
+ if (!conversation) return json(res, { success: false, error: '会话不存在' }, 404)
1122
+
1123
+ if (req.method === 'GET') {
1124
+ return json(res, conversation)
1125
+ }
1126
+
1127
+ if (req.method === 'PATCH') {
1128
+ const body = await parseBody(req)
1129
+ const updated = workspaceStore.updateConversation(conversationId, {
1130
+ title: body.title,
1131
+ toolId: body.toolId,
1132
+ pinned: body.pinned,
1133
+ })
1134
+ return json(res, { success: true, conversation: updated })
1135
+ }
1136
+
1137
+ return json(res, { success: false, error: 'Method not allowed' }, 405)
1138
+ }
1139
+
1140
+ async function handleWorkspaceConversationMessages(req, res, conversationId) {
1141
+ if (req.method === 'GET') {
1142
+ const conversation = workspaceStore.getConversation(conversationId)
1143
+ if (!conversation) return json(res, { success: false, error: '会话不存在' }, 404)
1144
+ return json(res, conversation.messages)
1145
+ }
1146
+
1147
+ const body = await parseBody(req)
1148
+ const content = String(body.content || '').trim()
1149
+ if (!content) return json(res, { success: false, error: '消息不能为空' }, 400)
1150
+
1151
+ try {
1152
+ const result = await workspaceRuntime.sendConversationMessage(conversationId, content, body.runtimeConfig || {})
1153
+ return json(res, {
1154
+ success: true,
1155
+ messages: [result.userMessage, result.assistantMessage],
1156
+ conversation: workspaceStore.getConversation(conversationId),
1157
+ })
1158
+ } catch (error) {
1159
+ return json(res, {
1160
+ success: false,
1161
+ error: error.message,
1162
+ conversation: workspaceStore.getConversation(conversationId),
1163
+ }, 500)
1164
+ }
1165
+ }
1166
+
1167
+ async function handleWorkspaceTasks(req, res) {
1168
+ if (req.method === 'GET') {
1169
+ return json(res, workspaceStore.listTasks())
1170
+ }
1171
+ const body = await parseBody(req)
1172
+ const title = String(body.title || '').trim()
1173
+ const prompt = String(body.prompt || '').trim()
1174
+ if (!title || !prompt) return json(res, { success: false, error: '任务标题和提示词不能为空' }, 400)
1175
+
1176
+ try {
1177
+ workspaceRuntime.parseScheduleToMs(body.schedule || '1h')
1178
+ } catch (error) {
1179
+ return json(res, { success: false, error: error.message }, 400)
1180
+ }
1181
+
1182
+ const task = workspaceStore.saveTask({
1183
+ title,
1184
+ prompt,
1185
+ schedule: body.schedule || '1h',
1186
+ active: body.active !== false,
1187
+ conversationId: body.conversationId || null,
1188
+ modelOverride: body.modelOverride || '',
1189
+ })
1190
+ workspaceRuntime.rescheduleAllTasks()
1191
+ json(res, { success: true, task })
1192
+ }
1193
+
1194
+ async function handleWorkspaceTaskById(req, res, taskId) {
1195
+ if (req.method === 'DELETE') {
1196
+ workspaceStore.deleteTask(taskId)
1197
+ workspaceRuntime.rescheduleAllTasks()
1198
+ return json(res, { success: true })
1199
+ }
1200
+
1201
+ if (req.method === 'PATCH') {
1202
+ const body = await parseBody(req)
1203
+ if (body.schedule) {
1204
+ try {
1205
+ workspaceRuntime.parseScheduleToMs(body.schedule)
1206
+ } catch (error) {
1207
+ return json(res, { success: false, error: error.message }, 400)
1208
+ }
1209
+ }
1210
+ const task = workspaceStore.saveTask({
1211
+ id: taskId,
1212
+ title: body.title,
1213
+ prompt: body.prompt,
1214
+ schedule: body.schedule,
1215
+ active: body.active,
1216
+ conversationId: body.conversationId,
1217
+ modelOverride: body.modelOverride,
1218
+ })
1219
+ workspaceRuntime.rescheduleAllTasks()
1220
+ return json(res, { success: true, task })
1221
+ }
1222
+
1223
+ if (req.method === 'POST') {
1224
+ try {
1225
+ const result = await workspaceRuntime.runTask(taskId)
1226
+ return json(res, { success: true, result })
1227
+ } catch (error) {
1228
+ return json(res, { success: false, error: error.message }, 500)
1229
+ }
1230
+ }
1231
+
1232
+ return json(res, { success: false, error: 'Method not allowed' }, 405)
1233
+ }
1234
+
1037
1235
  // ── Router ───────────────────────────────────────────────────────────────────
1038
1236
 
1039
1237
  async function handleRequest(req, res) {
@@ -1062,6 +1260,17 @@ async function handleRequest(req, res) {
1062
1260
  if (route === '/api/doctor' && req.method === 'GET') return await handleDoctor(req, res)
1063
1261
  if (route === '/api/tools' && req.method === 'GET') return await handleTools(req, res)
1064
1262
  if (route === '/api/models' && req.method === 'GET') return await handleModels(req, res)
1263
+ if (route === '/api/workspace/state' && req.method === 'GET') return await handleWorkspaceState(req, res)
1264
+ if (route === '/api/workspace/search' && req.method === 'GET') return await handleWorkspaceSearch(req, res, url)
1265
+ if (route === '/api/workspace/api-config' && (req.method === 'GET' || req.method === 'POST')) return await handleWorkspaceApiConfig(req, res)
1266
+ if (route === '/api/workspace/conversations' && (req.method === 'GET' || req.method === 'POST')) return await handleWorkspaceConversations(req, res)
1267
+ const conversationMatch = route.match(/^\/api\/workspace\/conversations\/([^/]+)$/)
1268
+ if (conversationMatch) return await handleWorkspaceConversationById(req, res, conversationMatch[1])
1269
+ const messagesMatch = route.match(/^\/api\/workspace\/conversations\/([^/]+)\/messages$/)
1270
+ if (messagesMatch) return await handleWorkspaceConversationMessages(req, res, messagesMatch[1])
1271
+ if (route === '/api/workspace/tasks' && (req.method === 'GET' || req.method === 'POST')) return await handleWorkspaceTasks(req, res)
1272
+ const taskMatch = route.match(/^\/api\/workspace\/tasks\/([^/]+)$/)
1273
+ if (taskMatch) return await handleWorkspaceTaskById(req, res, taskMatch[1])
1065
1274
  if (route === '/api/upgrade' && req.method === 'POST') return await handleUpgrade(req, res)
1066
1275
  if (route === '/api/tool/install' && req.method === 'POST') return await handleToolInstall(req, res)
1067
1276
  if (route === '/api/tool/configure' && req.method === 'POST') return await handleToolConfigure(req, res)
@@ -1113,13 +1322,19 @@ function startServer(port) {
1113
1322
  setTimeout(() => {
1114
1323
  const retry = http.createServer(handleRequest)
1115
1324
  retry.on('error', (err2) => reject(err2))
1116
- retry.listen(port, '127.0.0.1', () => resolve(retry))
1325
+ retry.listen(port, '127.0.0.1', () => {
1326
+ workspaceRuntime.startScheduler()
1327
+ resolve(retry)
1328
+ })
1117
1329
  }, 500)
1118
1330
  } else {
1119
1331
  reject(err)
1120
1332
  }
1121
1333
  })
1122
- server.listen(port, '127.0.0.1', () => resolve(server))
1334
+ server.listen(port, '127.0.0.1', () => {
1335
+ workspaceRuntime.startScheduler()
1336
+ resolve(server)
1337
+ })
1123
1338
  })
1124
1339
  }
1125
1340
 
@@ -0,0 +1,275 @@
1
+ 'use strict'
2
+
3
+ const fetch = require('node-fetch')
4
+ const {
5
+ addMessage,
6
+ createConversation,
7
+ ensureConversation,
8
+ getConversation,
9
+ getHolySheepApiConfig,
10
+ listTasks,
11
+ updateConversation,
12
+ updateTaskRun,
13
+ } = require('./workspace-store')
14
+ const { getApiKey, BASE_URL_OPENAI } = require('../utils/config')
15
+
16
+ const schedulerHandles = new Map()
17
+
18
+ function normalizeRuntimeConfig(config = {}) {
19
+ const saved = getHolySheepApiConfig()
20
+ const apiKey = String(config.apiKey || saved.apiKey || getApiKey() || '').trim()
21
+ const baseUrl = String(config.baseUrl || saved.baseUrl || BASE_URL_OPENAI).replace(/\/+$/, '')
22
+ const model = String(config.model || saved.model || '').trim()
23
+ return { apiKey, baseUrl, model }
24
+ }
25
+
26
+ function assertRuntimeConfig(config) {
27
+ if (!config.apiKey || !config.apiKey.startsWith('cr_')) {
28
+ throw new Error('HolySheep API Key is required')
29
+ }
30
+ if (!config.baseUrl) {
31
+ throw new Error('HolySheep API Base URL is required')
32
+ }
33
+ if (!config.model) {
34
+ throw new Error('HolySheep API model is required')
35
+ }
36
+ }
37
+
38
+ function buildSystemPrompt(toolId) {
39
+ const toolName = String(toolId || 'codex')
40
+ return [
41
+ `You are operating inside HolySheep Workspace.`,
42
+ `The selected coding tool is "${toolName}".`,
43
+ `Respond with concise, implementation-focused guidance suitable for a coding assistant workspace.`,
44
+ `When you mention commands, prefer copy-paste-ready shell commands.`,
45
+ ].join(' ')
46
+ }
47
+
48
+ async function requestCompletion(messages, config) {
49
+ const response = await fetch(`${config.baseUrl}/chat/completions`, {
50
+ method: 'POST',
51
+ headers: {
52
+ Authorization: `Bearer ${config.apiKey}`,
53
+ 'Content-Type': 'application/json',
54
+ },
55
+ body: JSON.stringify({
56
+ model: config.model,
57
+ temperature: 0.2,
58
+ messages,
59
+ }),
60
+ })
61
+
62
+ const payload = await response.json().catch(() => null)
63
+ if (!response.ok) {
64
+ const reason = payload?.error?.message || payload?.message || `HTTP ${response.status}`
65
+ throw new Error(reason)
66
+ }
67
+
68
+ const content = payload?.choices?.[0]?.message?.content
69
+ if (typeof content === 'string' && content.trim()) return content.trim()
70
+ if (Array.isArray(content)) {
71
+ const text = content
72
+ .map((part) => {
73
+ if (typeof part === 'string') return part
74
+ if (typeof part?.text === 'string') return part.text
75
+ if (typeof part?.content === 'string') return part.content
76
+ return ''
77
+ })
78
+ .filter(Boolean)
79
+ .join('\n')
80
+ .trim()
81
+ if (text) return text
82
+ }
83
+ const fallbacks = [
84
+ payload?.choices?.[0]?.text,
85
+ payload?.output_text,
86
+ payload?.response,
87
+ payload?.message,
88
+ ]
89
+ for (const item of fallbacks) {
90
+ if (typeof item === 'string' && item.trim()) return item.trim()
91
+ }
92
+ if (Array.isArray(payload?.output)) {
93
+ const text = payload.output
94
+ .map((part) => {
95
+ if (typeof part?.content === 'string') return part.content
96
+ if (typeof part?.text === 'string') return part.text
97
+ if (Array.isArray(part?.content)) {
98
+ return part.content.map((child) => child?.text || child?.content || '').join('\n')
99
+ }
100
+ return ''
101
+ })
102
+ .filter(Boolean)
103
+ .join('\n')
104
+ .trim()
105
+ if (text) return text
106
+ }
107
+ return 'No response content returned.'
108
+ }
109
+
110
+ async function sendConversationMessage(conversationId, input, options = {}) {
111
+ const conversation = ensureConversation(conversationId)
112
+ const config = normalizeRuntimeConfig(options)
113
+ assertRuntimeConfig(config)
114
+
115
+ const userMessage = addMessage(conversationId, {
116
+ role: 'user',
117
+ content: String(input || ''),
118
+ meta: {
119
+ source: 'workspace',
120
+ },
121
+ })
122
+
123
+ try {
124
+ const messages = [
125
+ { role: 'system', content: buildSystemPrompt(conversation.toolId) },
126
+ ...conversation.messages
127
+ .concat(userMessage)
128
+ .map((message) => ({
129
+ role: message.role,
130
+ content: message.content,
131
+ })),
132
+ ]
133
+ const reply = await requestCompletion(messages, config)
134
+ const assistantMessage = addMessage(conversationId, {
135
+ role: 'assistant',
136
+ content: reply,
137
+ meta: {
138
+ model: config.model,
139
+ source: 'holysheep-api',
140
+ },
141
+ })
142
+ updateConversation(conversationId, {
143
+ summary: reply.slice(0, 240),
144
+ })
145
+ return { userMessage, assistantMessage }
146
+ } catch (error) {
147
+ const assistantMessage = addMessage(conversationId, {
148
+ role: 'assistant',
149
+ content: `Error: ${error.message}`,
150
+ status: 'error',
151
+ meta: {
152
+ model: config.model,
153
+ source: 'holysheep-api',
154
+ },
155
+ })
156
+ updateConversation(conversationId, {
157
+ summary: assistantMessage.content.slice(0, 240),
158
+ })
159
+ throw error
160
+ }
161
+ }
162
+
163
+ function parseScheduleToMs(schedule) {
164
+ const value = String(schedule || '').trim().toLowerCase()
165
+ const match = value.match(/^(\d+)\s*([smhd])$/)
166
+ if (!match) throw new Error('Schedule must use format like 30s, 5m, 1h, 1d')
167
+ const amount = Number(match[1])
168
+ const unit = match[2]
169
+ if (!Number.isFinite(amount) || amount <= 0) throw new Error('Invalid schedule amount')
170
+ const factors = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
171
+ return amount * factors[unit]
172
+ }
173
+
174
+ async function runTask(taskId, options = {}) {
175
+ const task = listTasks().find((item) => item.id === taskId)
176
+ if (!task) throw new Error('Task not found')
177
+
178
+ const config = normalizeRuntimeConfig({
179
+ ...options,
180
+ model: task.modelOverride || options.model,
181
+ })
182
+ assertRuntimeConfig(config)
183
+ updateTaskRun(taskId, {
184
+ lastStatus: 'running',
185
+ })
186
+
187
+ let conversationId = task.conversationId
188
+ if (!conversationId) {
189
+ const conversation = createConversation({
190
+ title: task.title,
191
+ toolId: 'codex',
192
+ })
193
+ conversationId = conversation.id
194
+ updateTaskRun(taskId, { conversationId })
195
+ }
196
+
197
+ try {
198
+ const { assistantMessage } = await sendConversationMessage(conversationId, task.prompt, config)
199
+ updateTaskRun(taskId, {
200
+ lastRunAt: new Date().toISOString(),
201
+ lastStatus: 'ok',
202
+ lastResult: assistantMessage.content,
203
+ conversationId,
204
+ })
205
+ return assistantMessage
206
+ } catch (error) {
207
+ updateTaskRun(taskId, {
208
+ lastRunAt: new Date().toISOString(),
209
+ lastStatus: 'error',
210
+ lastResult: error.message,
211
+ conversationId,
212
+ })
213
+ throw error
214
+ }
215
+ }
216
+
217
+ function clearTaskHandle(taskId) {
218
+ const existing = schedulerHandles.get(taskId)
219
+ if (!existing) return
220
+ clearInterval(existing)
221
+ schedulerHandles.delete(taskId)
222
+ }
223
+
224
+ function scheduleTask(task) {
225
+ clearTaskHandle(task.id)
226
+ if (!task.active) return
227
+
228
+ let intervalMs
229
+ try {
230
+ intervalMs = parseScheduleToMs(task.schedule)
231
+ } catch {
232
+ return
233
+ }
234
+
235
+ const handle = setInterval(() => {
236
+ void runTask(task.id).catch(() => {})
237
+ }, intervalMs)
238
+ schedulerHandles.set(task.id, handle)
239
+ }
240
+
241
+ function rescheduleAllTasks() {
242
+ const tasks = listTasks()
243
+ const seen = new Set(tasks.map((task) => task.id))
244
+ for (const taskId of schedulerHandles.keys()) {
245
+ if (!seen.has(taskId)) {
246
+ clearTaskHandle(taskId)
247
+ }
248
+ }
249
+ for (const task of tasks) {
250
+ scheduleTask(task)
251
+ }
252
+ }
253
+
254
+ function stopScheduler() {
255
+ for (const taskId of schedulerHandles.keys()) {
256
+ clearTaskHandle(taskId)
257
+ }
258
+ }
259
+
260
+ function startScheduler() {
261
+ rescheduleAllTasks()
262
+ }
263
+
264
+ module.exports = {
265
+ normalizeRuntimeConfig,
266
+ assertRuntimeConfig,
267
+ sendConversationMessage,
268
+ parseScheduleToMs,
269
+ runTask,
270
+ startScheduler,
271
+ stopScheduler,
272
+ rescheduleAllTasks,
273
+ createConversation,
274
+ getConversation,
275
+ }