@simonyea/holysheep-cli 1.7.135 → 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.
@@ -0,0 +1,325 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const crypto = require('crypto')
6
+ const { CONFIG_DIR } = require('../utils/config')
7
+
8
+ const STATE_FILE = path.join(CONFIG_DIR, 'workspace-state.json')
9
+ const STATE_VERSION = 1
10
+
11
+ function now() {
12
+ return new Date().toISOString()
13
+ }
14
+
15
+ function createDefaultState() {
16
+ return {
17
+ version: STATE_VERSION,
18
+ updatedAt: now(),
19
+ holySheepApi: {
20
+ apiKey: '',
21
+ baseUrl: '',
22
+ model: '',
23
+ },
24
+ conversations: [],
25
+ scheduledTasks: [],
26
+ }
27
+ }
28
+
29
+ function ensureDir() {
30
+ fs.mkdirSync(CONFIG_DIR, { recursive: true })
31
+ }
32
+
33
+ function clone(value) {
34
+ return JSON.parse(JSON.stringify(value))
35
+ }
36
+
37
+ function normalizeConversation(item = {}) {
38
+ const createdAt = item.createdAt || now()
39
+ const messages = Array.isArray(item.messages)
40
+ ? item.messages.map((message) => ({
41
+ id: message.id || crypto.randomUUID(),
42
+ role: message.role === 'assistant' ? 'assistant' : 'user',
43
+ content: String(message.content || ''),
44
+ status: message.status || 'done',
45
+ createdAt: message.createdAt || createdAt,
46
+ meta: typeof message.meta === 'object' && message.meta ? message.meta : {},
47
+ }))
48
+ : []
49
+
50
+ const lastMessage = messages[messages.length - 1] || null
51
+
52
+ return {
53
+ id: item.id || crypto.randomUUID().slice(0, 8),
54
+ title: String(item.title || 'New Conversation'),
55
+ toolId: String(item.toolId || 'codex'),
56
+ createdAt,
57
+ updatedAt: item.updatedAt || lastMessage?.createdAt || createdAt,
58
+ pinned: Boolean(item.pinned),
59
+ summary: String(item.summary || lastMessage?.content || '').slice(0, 240),
60
+ messages,
61
+ }
62
+ }
63
+
64
+ function normalizeTask(item = {}) {
65
+ return {
66
+ id: item.id || crypto.randomUUID().slice(0, 8),
67
+ title: String(item.title || 'Untitled Task'),
68
+ prompt: String(item.prompt || ''),
69
+ schedule: String(item.schedule || '1h'),
70
+ active: item.active !== false,
71
+ createdAt: item.createdAt || now(),
72
+ updatedAt: item.updatedAt || now(),
73
+ lastRunAt: item.lastRunAt || null,
74
+ lastStatus: item.lastStatus || 'idle',
75
+ lastResult: String(item.lastResult || ''),
76
+ conversationId: item.conversationId || null,
77
+ modelOverride: item.modelOverride ? String(item.modelOverride) : '',
78
+ }
79
+ }
80
+
81
+ function normalizeState(raw = {}) {
82
+ const state = createDefaultState()
83
+ state.holySheepApi = {
84
+ ...state.holySheepApi,
85
+ ...(typeof raw.holySheepApi === 'object' && raw.holySheepApi ? raw.holySheepApi : {}),
86
+ }
87
+ state.conversations = Array.isArray(raw.conversations) ? raw.conversations.map(normalizeConversation) : []
88
+ state.scheduledTasks = Array.isArray(raw.scheduledTasks) ? raw.scheduledTasks.map(normalizeTask) : []
89
+ state.updatedAt = raw.updatedAt || now()
90
+ return state
91
+ }
92
+
93
+ function loadState() {
94
+ ensureDir()
95
+ try {
96
+ if (!fs.existsSync(STATE_FILE)) {
97
+ return createDefaultState()
98
+ }
99
+ return normalizeState(JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')))
100
+ } catch {
101
+ return createDefaultState()
102
+ }
103
+ }
104
+
105
+ function saveState(next) {
106
+ ensureDir()
107
+ const normalized = normalizeState(next)
108
+ normalized.updatedAt = now()
109
+ fs.writeFileSync(STATE_FILE, JSON.stringify(normalized, null, 2), 'utf8')
110
+ return normalized
111
+ }
112
+
113
+ function updateState(mutator) {
114
+ const current = loadState()
115
+ const draft = clone(current)
116
+ const result = mutator(draft) || draft
117
+ return saveState(result)
118
+ }
119
+
120
+ function sortConversations(items) {
121
+ return [...items].sort((a, b) => {
122
+ if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
123
+ return Date.parse(b.updatedAt) - Date.parse(a.updatedAt)
124
+ })
125
+ }
126
+
127
+ function listConversations() {
128
+ return sortConversations(loadState().conversations).map((item) => ({
129
+ id: item.id,
130
+ title: item.title,
131
+ toolId: item.toolId,
132
+ pinned: item.pinned,
133
+ summary: item.summary,
134
+ createdAt: item.createdAt,
135
+ updatedAt: item.updatedAt,
136
+ messageCount: item.messages.length,
137
+ }))
138
+ }
139
+
140
+ function getConversation(id) {
141
+ return loadState().conversations.find((item) => item.id === id) || null
142
+ }
143
+
144
+ function ensureConversation(id) {
145
+ const conversation = getConversation(id)
146
+ if (!conversation) throw new Error('Conversation not found')
147
+ return conversation
148
+ }
149
+
150
+ function createConversation(payload = {}) {
151
+ const conversation = normalizeConversation({
152
+ title: payload.title || 'New Conversation',
153
+ toolId: payload.toolId || 'codex',
154
+ pinned: Boolean(payload.pinned),
155
+ })
156
+ updateState((state) => {
157
+ state.conversations.unshift(conversation)
158
+ })
159
+ return conversation
160
+ }
161
+
162
+ function updateConversation(id, patch = {}) {
163
+ let updated = null
164
+ updateState((state) => {
165
+ const index = state.conversations.findIndex((item) => item.id === id)
166
+ if (index === -1) throw new Error('Conversation not found')
167
+ const current = state.conversations[index]
168
+ updated = normalizeConversation({
169
+ ...current,
170
+ ...patch,
171
+ messages: patch.messages || current.messages,
172
+ updatedAt: now(),
173
+ })
174
+ state.conversations[index] = updated
175
+ })
176
+ return updated
177
+ }
178
+
179
+ function addMessage(conversationId, payload = {}) {
180
+ let updated = null
181
+ updateState((state) => {
182
+ const conversation = state.conversations.find((item) => item.id === conversationId)
183
+ if (!conversation) throw new Error('Conversation not found')
184
+ const message = {
185
+ id: payload.id || crypto.randomUUID(),
186
+ role: payload.role === 'assistant' ? 'assistant' : 'user',
187
+ content: String(payload.content || ''),
188
+ status: payload.status || 'done',
189
+ createdAt: payload.createdAt || now(),
190
+ meta: typeof payload.meta === 'object' && payload.meta ? payload.meta : {},
191
+ }
192
+ conversation.messages.push(message)
193
+ conversation.updatedAt = message.createdAt
194
+ if (!conversation.summary || message.role === 'assistant') {
195
+ conversation.summary = message.content.slice(0, 240)
196
+ }
197
+ updated = clone(message)
198
+ })
199
+ return updated
200
+ }
201
+
202
+ function searchWorkspace(query) {
203
+ const needle = String(query || '').trim().toLowerCase()
204
+ if (!needle) return { conversations: [], tasks: [] }
205
+
206
+ const state = loadState()
207
+ const conversations = state.conversations
208
+ .filter((item) => {
209
+ if (item.title.toLowerCase().includes(needle)) return true
210
+ if (String(item.summary || '').toLowerCase().includes(needle)) return true
211
+ return item.messages.some((message) => String(message.content || '').toLowerCase().includes(needle))
212
+ })
213
+ .map((item) => ({
214
+ id: item.id,
215
+ title: item.title,
216
+ summary: item.summary,
217
+ updatedAt: item.updatedAt,
218
+ toolId: item.toolId,
219
+ }))
220
+
221
+ const tasks = state.scheduledTasks
222
+ .filter((item) => {
223
+ return item.title.toLowerCase().includes(needle) || item.prompt.toLowerCase().includes(needle)
224
+ })
225
+ .map((item) => ({
226
+ id: item.id,
227
+ title: item.title,
228
+ prompt: item.prompt.slice(0, 180),
229
+ schedule: item.schedule,
230
+ active: item.active,
231
+ updatedAt: item.updatedAt,
232
+ }))
233
+
234
+ return { conversations, tasks }
235
+ }
236
+
237
+ function listTasks() {
238
+ return [...loadState().scheduledTasks].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
239
+ }
240
+
241
+ function getTask(id) {
242
+ return loadState().scheduledTasks.find((item) => item.id === id) || null
243
+ }
244
+
245
+ function saveTask(payload = {}) {
246
+ let result = null
247
+ updateState((state) => {
248
+ if (payload.id) {
249
+ const index = state.scheduledTasks.findIndex((item) => item.id === payload.id)
250
+ if (index === -1) throw new Error('Task not found')
251
+ result = normalizeTask({
252
+ ...state.scheduledTasks[index],
253
+ ...payload,
254
+ updatedAt: now(),
255
+ })
256
+ state.scheduledTasks[index] = result
257
+ return
258
+ }
259
+ result = normalizeTask({
260
+ ...payload,
261
+ createdAt: now(),
262
+ updatedAt: now(),
263
+ })
264
+ state.scheduledTasks.unshift(result)
265
+ })
266
+ return result
267
+ }
268
+
269
+ function deleteTask(id) {
270
+ updateState((state) => {
271
+ state.scheduledTasks = state.scheduledTasks.filter((item) => item.id !== id)
272
+ })
273
+ }
274
+
275
+ function updateTaskRun(id, patch = {}) {
276
+ let result = null
277
+ updateState((state) => {
278
+ const index = state.scheduledTasks.findIndex((item) => item.id === id)
279
+ if (index === -1) throw new Error('Task not found')
280
+ result = normalizeTask({
281
+ ...state.scheduledTasks[index],
282
+ ...patch,
283
+ updatedAt: now(),
284
+ })
285
+ state.scheduledTasks[index] = result
286
+ })
287
+ return result
288
+ }
289
+
290
+ function getHolySheepApiConfig() {
291
+ return loadState().holySheepApi
292
+ }
293
+
294
+ function saveHolySheepApiConfig(config = {}) {
295
+ let result = null
296
+ updateState((state) => {
297
+ state.holySheepApi = {
298
+ apiKey: String(config.apiKey || state.holySheepApi.apiKey || ''),
299
+ baseUrl: String(config.baseUrl || state.holySheepApi.baseUrl || ''),
300
+ model: String(config.model || state.holySheepApi.model || ''),
301
+ }
302
+ result = clone(state.holySheepApi)
303
+ })
304
+ return result
305
+ }
306
+
307
+ module.exports = {
308
+ STATE_FILE,
309
+ loadState,
310
+ saveState,
311
+ listConversations,
312
+ getConversation,
313
+ createConversation,
314
+ updateConversation,
315
+ addMessage,
316
+ searchWorkspace,
317
+ listTasks,
318
+ getTask,
319
+ saveTask,
320
+ deleteTask,
321
+ updateTaskRun,
322
+ getHolySheepApiConfig,
323
+ saveHolySheepApiConfig,
324
+ ensureConversation,
325
+ }
@@ -0,0 +1,57 @@
1
+ const assert = require('assert')
2
+ const fs = require('fs')
3
+ const os = require('os')
4
+ const path = require('path')
5
+
6
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-workspace-test-'))
7
+ process.env.HOME = tempHome
8
+
9
+ const store = require('../src/webui/workspace-store')
10
+
11
+ store.saveHolySheepApiConfig({
12
+ apiKey: 'cr_test_runtime',
13
+ baseUrl: 'https://api.holysheep.ai/v1',
14
+ model: 'gpt-5.4',
15
+ })
16
+
17
+ const conversation = store.createConversation({
18
+ title: 'Build workspace shell',
19
+ toolId: 'codex',
20
+ })
21
+
22
+ store.addMessage(conversation.id, {
23
+ role: 'user',
24
+ content: 'Need AionUi-style sidebar and search box',
25
+ })
26
+
27
+ store.addMessage(conversation.id, {
28
+ role: 'assistant',
29
+ content: 'Implement the shell and keep the HolySheep tool APIs intact.',
30
+ })
31
+
32
+ const task = store.saveTask({
33
+ title: 'Nightly summary',
34
+ prompt: 'Summarize the latest workspace changes',
35
+ schedule: '1h',
36
+ active: true,
37
+ })
38
+
39
+ const stateFile = store.STATE_FILE
40
+ assert.ok(fs.existsSync(stateFile), 'workspace state file should be created')
41
+
42
+ const loadedConversation = store.getConversation(conversation.id)
43
+ assert.ok(loadedConversation, 'conversation should be persisted')
44
+ assert.strictEqual(loadedConversation.messages.length, 2, 'conversation messages should be stored')
45
+
46
+ const search = store.searchWorkspace('sidebar')
47
+ assert.strictEqual(search.conversations.length, 1, 'conversation search should match message content')
48
+
49
+ const tasks = store.listTasks()
50
+ assert.strictEqual(tasks.length, 1, 'scheduled task should be persisted')
51
+ assert.strictEqual(tasks[0].id, task.id, 'task id should remain stable')
52
+
53
+ const runtime = store.getHolySheepApiConfig()
54
+ assert.strictEqual(runtime.apiKey, 'cr_test_runtime', 'runtime api key should be stored')
55
+ assert.strictEqual(runtime.model, 'gpt-5.4', 'runtime model should be stored')
56
+
57
+ console.log('workspace store test passed')