@workclaw/openclaw-workclaw 1.0.0

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.
Files changed (48) hide show
  1. package/README.md +325 -0
  2. package/index.ts +298 -0
  3. package/openclaw.plugin.json +10 -0
  4. package/package.json +43 -0
  5. package/skills/openclaw-workclaw-cron/SKILL.md +458 -0
  6. package/src/accounts.ts +287 -0
  7. package/src/api/accounts-api.ts +157 -0
  8. package/src/api/prompts-api.ts +123 -0
  9. package/src/api/session-api.ts +247 -0
  10. package/src/api/skills-api.ts +74 -0
  11. package/src/api/workspace.ts +43 -0
  12. package/src/channel.ts +227 -0
  13. package/src/config-schema.ts +110 -0
  14. package/src/connection/workclaw-client.ts +656 -0
  15. package/src/gateway/agent-handlers.ts +557 -0
  16. package/src/gateway/config-writer.ts +311 -0
  17. package/src/gateway/message-context.ts +422 -0
  18. package/src/gateway/message-dispatcher.ts +601 -0
  19. package/src/gateway/reconnect.ts +149 -0
  20. package/src/gateway/skills-handler.ts +759 -0
  21. package/src/gateway/skills-list-handler.ts +332 -0
  22. package/src/gateway/tools-list-handler.ts +162 -0
  23. package/src/gateway/workclaw-gateway.ts +521 -0
  24. package/src/media/upload.ts +168 -0
  25. package/src/outbound/index.ts +183 -0
  26. package/src/outbound/workclaw-sender.ts +157 -0
  27. package/src/runtime.ts +400 -0
  28. package/src/send.ts +1 -0
  29. package/src/tools/openclaw-workclaw-cron/api/index.ts +326 -0
  30. package/src/tools/openclaw-workclaw-cron/index.ts +39 -0
  31. package/src/tools/openclaw-workclaw-cron/src/add/params.ts +176 -0
  32. package/src/tools/openclaw-workclaw-cron/src/add/sync.ts +188 -0
  33. package/src/tools/openclaw-workclaw-cron/src/disable/params.ts +100 -0
  34. package/src/tools/openclaw-workclaw-cron/src/disable/sync.ts +127 -0
  35. package/src/tools/openclaw-workclaw-cron/src/enable/params.ts +100 -0
  36. package/src/tools/openclaw-workclaw-cron/src/enable/sync.ts +127 -0
  37. package/src/tools/openclaw-workclaw-cron/src/notify/sync.ts +148 -0
  38. package/src/tools/openclaw-workclaw-cron/src/remove/params.ts +109 -0
  39. package/src/tools/openclaw-workclaw-cron/src/remove/sync.ts +127 -0
  40. package/src/tools/openclaw-workclaw-cron/src/update/params.ts +197 -0
  41. package/src/tools/openclaw-workclaw-cron/src/update/sync.ts +161 -0
  42. package/src/tools/openclaw-workclaw-cron/types/index.ts +55 -0
  43. package/src/tools/openclaw-workclaw-cron/utils/index.ts +141 -0
  44. package/src/types.ts +60 -0
  45. package/src/utils/content.ts +40 -0
  46. package/templates/IDENTITY.md +14 -0
  47. package/templates/SOUL.md +0 -0
  48. package/tsconfig.json +11 -0
@@ -0,0 +1,287 @@
1
+ import type { OpenClawConfig } from 'openclaw/plugin-sdk'
2
+ import type {
3
+ OpenclawWorkclawAccountConfig,
4
+ OpenclawWorkclawConfig,
5
+ ResolvedOpenclawWorkclawAccount,
6
+ } from './types.js'
7
+ import { getOpenclawWorkclawLogger } from './runtime.js'
8
+
9
+ const DEFAULT_ACCOUNT_ID = 'default'
10
+
11
+ // =============================================================================
12
+ // In-memory lookup cache for (userId, agentId) → workspace-N accountId
13
+ // Problem: OpenClaw doesn't support dashes in account IDs, agentId can be negative
14
+ // Solution: workspace-N naming + in-memory Map for O(1) lookups
15
+ // =============================================================================
16
+
17
+ /**
18
+ * Normalize account ID by trimming whitespace
19
+ */
20
+ export function normalizeAccountId(accountId: string | null | undefined): string {
21
+ return String(accountId ?? '').trim()
22
+ }
23
+
24
+ /**
25
+ * Key format for the lookup map: "userId|agentId"
26
+ */
27
+ function makeLookupKey(userId: string, agentId: string): string {
28
+ return `${userId}|${agentId}`
29
+ }
30
+
31
+ /**
32
+ * In-memory map: lookupKey -> accountId
33
+ */
34
+ const lookupCache = new Map<string, string>()
35
+
36
+ /**
37
+ * In-memory map: accountId -> { userId, agentId }
38
+ */
39
+ const accountIndex = new Map<string, { userId: string, agentId: string }>()
40
+
41
+ /**
42
+ * Next workspace number to allocate
43
+ */
44
+ let nextWorkspaceNum = 1
45
+
46
+ /**
47
+ * Build the lookup cache from existing accounts in cfg.
48
+ * Called once at gateway startup.
49
+ */
50
+ export function buildAccountMap(cfg: OpenClawConfig): void {
51
+ lookupCache.clear()
52
+ accountIndex.clear()
53
+ nextWorkspaceNum = 1
54
+
55
+ const _OpenclawWorkclawConfig = (cfg.channels?.['openclaw-workclaw'] || {}) as OpenclawWorkclawConfig
56
+
57
+ const accounts = _OpenclawWorkclawConfig?.accounts
58
+ if (!accounts || typeof accounts !== 'object')
59
+ return
60
+
61
+ for (const [accountId, accountCfg] of Object.entries(accounts)) {
62
+ if (!accountCfg)
63
+ continue
64
+ const userId = accountCfg.userId || _OpenclawWorkclawConfig.userId
65
+ const agentId = accountCfg.agentId
66
+ if (!agentId)
67
+ continue
68
+
69
+ // userId may not be present for config-defined accounts; only build
70
+ // lookup if userId is available (dynamically created accounts have it)
71
+ if (userId) {
72
+ const key = makeLookupKey(String(userId), String(agentId))
73
+ lookupCache.set(key, accountId)
74
+ }
75
+ accountIndex.set(accountId, { userId: String(userId ?? ''), agentId: String(agentId) })
76
+
77
+ if (accountId.startsWith('workspace-')) {
78
+ const num = Number.parseInt(accountId.slice(accountId.lastIndexOf('-') + 1), 10)
79
+ if (!Number.isNaN(num) && num >= nextWorkspaceNum) {
80
+ nextWorkspaceNum = num + 1
81
+ }
82
+ }
83
+ }
84
+
85
+ getOpenclawWorkclawLogger().info(`[AccountManager] Built account map with ${lookupCache.size} entries, nextWorkspaceNum=${nextWorkspaceNum}`)
86
+ }
87
+
88
+ /**
89
+ * Resolve accountId by userId + agentId.
90
+ * First checks cache, then falls back to iteration.
91
+ */
92
+ export function resolveAccountByUserIdAndAgentId(
93
+ cfg: OpenClawConfig,
94
+ userId: string,
95
+ agentId: string,
96
+ ): string | null {
97
+ const key = makeLookupKey(userId, agentId)
98
+
99
+ if (lookupCache.has(key)) {
100
+ return lookupCache.get(key)!
101
+ }
102
+
103
+ const _OpenclawWorkclawConfig = (cfg.channels?.['openclaw-workclaw'] || {}) as OpenclawWorkclawConfig
104
+ const accounts = _OpenclawWorkclawConfig?.accounts
105
+ if (accounts && typeof accounts === 'object') {
106
+ for (const [accountId, accountCfg] of Object.entries(accounts)) {
107
+ if (!accountCfg)
108
+ continue
109
+ const accountUserId = accountCfg.userId || _OpenclawWorkclawConfig.userId
110
+ if (String(accountUserId) === userId && String(accountCfg.agentId) === agentId) {
111
+ lookupCache.set(key, accountId)
112
+ accountIndex.set(accountId, { userId, agentId })
113
+ return accountId
114
+ }
115
+ }
116
+ }
117
+
118
+ return null
119
+ }
120
+
121
+ /**
122
+ * Allocate a new workspace-N accountId and register it in the map.
123
+ */
124
+ export function allocateWorkerAccountId(
125
+ _cfg: OpenClawConfig,
126
+ userId: string,
127
+ agentId: string,
128
+ ): string {
129
+ const accountId = `workspace-${nextWorkspaceNum}`
130
+ nextWorkspaceNum++
131
+
132
+ const key = makeLookupKey(userId, agentId)
133
+ lookupCache.set(key, accountId)
134
+ accountIndex.set(accountId, { userId, agentId })
135
+
136
+ getOpenclawWorkclawLogger().info(`[AccountManager] Allocated accountId=${accountId} for userId=${userId} agentId=${agentId}`)
137
+ return accountId
138
+ }
139
+
140
+ /**
141
+ * Refresh the cache when an account's userId or agentId changes.
142
+ */
143
+ export function refreshAccountCache(cfg: OpenClawConfig): void {
144
+ buildAccountMap(cfg)
145
+ }
146
+
147
+ // =============================================================================
148
+ // Account config resolution (pure functions, no in-memory state)
149
+ // =============================================================================
150
+
151
+ /**
152
+ * List all configured account IDs from the accounts field.
153
+ */
154
+ function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
155
+ const accounts = (cfg.channels?.['openclaw-workclaw'] as OpenclawWorkclawConfig)?.accounts
156
+ if (!accounts || typeof accounts !== 'object') {
157
+ return []
158
+ }
159
+ return Object.keys(accounts).filter(Boolean)
160
+ }
161
+
162
+ /**
163
+ * Check if plugin-level workclaw config exists (appKey + appSecret)
164
+ */
165
+ function hasPluginLevelWorkClawConfig(cfg: OpenClawConfig): boolean {
166
+ const openclawWorkclawCfg = cfg.channels?.['openclaw-workclaw'] as OpenclawWorkclawConfig | undefined
167
+ const key = typeof openclawWorkclawCfg?.appKey === 'string' ? openclawWorkclawCfg.appKey.trim() : ''
168
+ const secret = typeof openclawWorkclawCfg?.appSecret === 'string' ? openclawWorkclawCfg.appSecret.trim() : ''
169
+ return Boolean(key && secret)
170
+ }
171
+
172
+ /**
173
+ * List all account IDs.
174
+ * If no accounts are configured but plugin has workclaw config, returns [DEFAULT_ACCOUNT_ID].
175
+ */
176
+ export function listOpenclawWorkclawAccountIds(cfg: OpenClawConfig): string[] {
177
+ const ids = listConfiguredAccountIds(cfg)
178
+ if (ids.length === 0) {
179
+ // 如果没有配置账户,但插件级别有 workclaw 配置,返回默认账户
180
+ if (hasPluginLevelWorkClawConfig(cfg)) {
181
+ return [DEFAULT_ACCOUNT_ID]
182
+ }
183
+ // Backward compatibility: no accounts configured, use default
184
+ return [DEFAULT_ACCOUNT_ID]
185
+ }
186
+ return [...ids].toSorted((a, b) => a.localeCompare(b))
187
+ }
188
+
189
+ /**
190
+ * Resolve the default account ID.
191
+ */
192
+ export function resolveDefaultOpenclawWorkclawAccountId(cfg: OpenClawConfig): string {
193
+ const ids = listOpenclawWorkclawAccountIds(cfg)
194
+ if (ids.includes(DEFAULT_ACCOUNT_ID)) {
195
+ return DEFAULT_ACCOUNT_ID
196
+ }
197
+ return ids[0] ?? DEFAULT_ACCOUNT_ID
198
+ }
199
+
200
+ /**
201
+ * Get the raw account-specific config.
202
+ */
203
+ function resolveAccountConfig(
204
+ cfg: OpenClawConfig,
205
+ accountId: string,
206
+ ): OpenclawWorkclawAccountConfig | undefined {
207
+ const accounts = (cfg.channels?.['openclaw-workclaw'] as OpenclawWorkclawConfig)?.accounts
208
+ if (!accounts || typeof accounts !== 'object') {
209
+ return undefined
210
+ }
211
+ return accounts[accountId]
212
+ }
213
+
214
+ function hasWorkClawConfig(config: {
215
+ appKey?: string
216
+ appSecret?: string
217
+ agentId?: string | number
218
+ }): boolean {
219
+ const key = typeof config.appKey === 'string' ? config.appKey.trim() : ''
220
+ const secret
221
+ = typeof config.appSecret === 'string' ? config.appSecret.trim() : ''
222
+ // 只要有 appKey 和 appSecret 就认为配置了 workclaw(agentId 可以动态添加)
223
+ return Boolean(key && secret)
224
+ }
225
+
226
+ export function isOpenclawWorkclawAccountConfigured(config: {
227
+ appKey?: string
228
+ appSecret?: string
229
+ agentId?: string | number
230
+ }): boolean {
231
+ return hasWorkClawConfig(config)
232
+ }
233
+
234
+ /**
235
+ * Merge top-level config with account-specific config.
236
+ * Account-specific fields override top-level fields.
237
+ */
238
+ function mergeOpenclawWorkclawAccountConfig(cfg: OpenClawConfig, accountId: string): OpenclawWorkclawConfig {
239
+ const openclawWorkclawCfg = cfg.channels?.['openclaw-workclaw'] as OpenclawWorkclawConfig | undefined
240
+
241
+ // Extract base config (exclude accounts field to avoid recursion)
242
+ const { accounts: _ignored, ...base } = openclawWorkclawCfg ?? {}
243
+
244
+ // Get account-specific overrides
245
+ const account = resolveAccountConfig(cfg, accountId) ?? {}
246
+
247
+ // Merge: account config overrides base config
248
+ return { ...base, ...account } as OpenclawWorkclawConfig
249
+ }
250
+
251
+ /**
252
+ * Resolve a complete account with merged config.
253
+ */
254
+ export function resolveOpenclawWorkclawAccount(params: {
255
+ cfg: OpenClawConfig
256
+ accountId?: string | null
257
+ }): ResolvedOpenclawWorkclawAccount {
258
+ const accountId = normalizeAccountId(params.accountId)
259
+ const openclawWorkclawCfg = params.cfg.channels?.['openclaw-workclaw'] as OpenclawWorkclawConfig | undefined
260
+
261
+ // Base enabled state (top-level)
262
+ const baseEnabled = openclawWorkclawCfg?.enabled !== false
263
+
264
+ // Merge configs
265
+ const merged = mergeOpenclawWorkclawAccountConfig(params.cfg, accountId)
266
+
267
+ // Account-level enabled state
268
+ const accountEnabled = merged.enabled !== false
269
+ const enabled = baseEnabled && accountEnabled
270
+
271
+ return {
272
+ accountId,
273
+ enabled,
274
+ configured: isOpenclawWorkclawAccountConfigured(merged),
275
+ name: (merged as OpenclawWorkclawAccountConfig).name?.trim() || undefined,
276
+ config: merged,
277
+ }
278
+ }
279
+
280
+ /**
281
+ * List all enabled and configured accounts.
282
+ */
283
+ export function listEnabledOpenclawWorkclawAccounts(cfg: OpenClawConfig): ResolvedOpenclawWorkclawAccount[] {
284
+ return listConfiguredAccountIds(cfg)
285
+ .map(accountId => resolveOpenclawWorkclawAccount({ cfg, accountId }))
286
+ .filter(account => account.enabled && account.configured)
287
+ }
@@ -0,0 +1,157 @@
1
+ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk'
2
+ import { Buffer } from 'node:buffer'
3
+ import { normalizeAccountId } from '../accounts.js'
4
+
5
+ function sendJson(res: any, statusCode: number, payload: unknown): void {
6
+ res.statusCode = statusCode
7
+ res.setHeader('Content-Type', 'application/json')
8
+ res.end(JSON.stringify(payload))
9
+ }
10
+
11
+ async function readRequestBody(req: any): Promise<string> {
12
+ const chunks: Buffer[] = []
13
+ await new Promise<void>((resolve, reject) => {
14
+ req.on('data', (chunk: any) => {
15
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
16
+ })
17
+ req.on('end', () => resolve())
18
+ req.on('error', (err: unknown) => reject(err))
19
+ })
20
+ return Buffer.concat(chunks).toString('utf-8')
21
+ }
22
+
23
+ async function loadRuntimeConfig(api: OpenClawPluginApi): Promise<any> {
24
+ const runtimeConfig = (api.runtime as any)?.config
25
+ if (runtimeConfig?.loadConfig) {
26
+ return runtimeConfig.loadConfig()
27
+ }
28
+ return api.config
29
+ }
30
+
31
+ async function writeRuntimeConfig(api: OpenClawPluginApi, config: any): Promise<void> {
32
+ const runtimeConfig = (api.runtime as any)?.config
33
+ if (runtimeConfig?.writeConfigFile) {
34
+ await runtimeConfig.writeConfigFile(config)
35
+ return
36
+ }
37
+ throw new Error('Config write not supported')
38
+ }
39
+
40
+ function getAccountMap(config: any): Record<string, any> {
41
+ const channels = config?.channels && typeof config.channels === 'object' ? config.channels : {}
42
+ const openclawWorkclaw
43
+ = channels['openclaw-workclaw'] && typeof channels['openclaw-workclaw'] === 'object' ? channels['openclaw-workclaw'] : {}
44
+ const accounts
45
+ = openclawWorkclaw.accounts && typeof openclawWorkclaw.accounts === 'object' ? openclawWorkclaw.accounts : {}
46
+ return { ...accounts }
47
+ }
48
+
49
+ function withAccounts(config: any, accounts: Record<string, any>): any {
50
+ const channels = config?.channels && typeof config.channels === 'object' ? config.channels : {}
51
+ const openclawWorkclaw
52
+ = channels['openclaw-workclaw'] && typeof channels['openclaw-workclaw'] === 'object' ? channels['openclaw-workclaw'] : {}
53
+ return {
54
+ ...config,
55
+ channels: {
56
+ ...channels,
57
+ 'openclaw-workclaw': {
58
+ ...openclawWorkclaw,
59
+ accounts,
60
+ },
61
+ },
62
+ }
63
+ }
64
+
65
+ export function createAccountsApiHandler(api: OpenClawPluginApi) {
66
+ return async (req: any, res: any) => {
67
+ const method = String(req.method ?? 'GET').toUpperCase()
68
+ const url = new URL(req.url ?? '', 'http://localhost')
69
+ const idRaw = url.searchParams.get('id') ?? url.searchParams.get('accountId')
70
+ const accountId = idRaw ? normalizeAccountId(idRaw) : null
71
+
72
+ if (method === 'GET') {
73
+ const cfg = await loadRuntimeConfig(api)
74
+ const accounts = getAccountMap(cfg)
75
+
76
+ if (accountId) {
77
+ const account = accounts[accountId]
78
+ if (!account) {
79
+ sendJson(res, 404, { ok: false, error: 'Not Found' })
80
+ return
81
+ }
82
+ sendJson(res, 200, { ok: true, accountId, config: account })
83
+ return
84
+ }
85
+
86
+ const entries = Object.entries(accounts).map(([id, config]) => ({
87
+ accountId: id,
88
+ config,
89
+ }))
90
+ sendJson(res, 200, { ok: true, accounts: entries })
91
+ return
92
+ }
93
+
94
+ if (method === 'POST' || method === 'PUT') {
95
+ const raw = await readRequestBody(req)
96
+ let input: any = {}
97
+ try {
98
+ input = raw ? JSON.parse(raw) : {}
99
+ }
100
+ catch {
101
+ sendJson(res, 400, { ok: false, error: 'Invalid JSON' })
102
+ return
103
+ }
104
+
105
+ const inputId = input?.accountId ?? input?.id
106
+ const normalizedId = inputId ? normalizeAccountId(String(inputId)) : ''
107
+ const patch = input?.config
108
+
109
+ if (!normalizedId || !patch || typeof patch !== 'object') {
110
+ sendJson(res, 400, { ok: false, error: 'Missing accountId or config' })
111
+ return
112
+ }
113
+
114
+ const cfg = await loadRuntimeConfig(api)
115
+ const accounts = getAccountMap(cfg)
116
+
117
+ if (method === 'POST' && accounts[normalizedId]) {
118
+ sendJson(res, 409, { ok: false, error: 'Account already exists' })
119
+ return
120
+ }
121
+
122
+ if (method === 'PUT' && !accounts[normalizedId]) {
123
+ sendJson(res, 404, { ok: false, error: 'Not Found' })
124
+ return
125
+ }
126
+
127
+ const next = method === 'PUT' ? { ...accounts[normalizedId], ...patch } : patch
128
+ const updated = withAccounts(cfg, { ...accounts, [normalizedId]: next })
129
+ await writeRuntimeConfig(api, updated)
130
+ sendJson(res, 200, { ok: true, accountId: normalizedId })
131
+ return
132
+ }
133
+
134
+ if (method === 'DELETE') {
135
+ if (!accountId) {
136
+ sendJson(res, 400, { ok: false, error: 'Missing accountId' })
137
+ return
138
+ }
139
+
140
+ const cfg = await loadRuntimeConfig(api)
141
+ const accounts = getAccountMap(cfg)
142
+
143
+ if (!accounts[accountId]) {
144
+ sendJson(res, 404, { ok: false, error: 'Not Found' })
145
+ return
146
+ }
147
+
148
+ const { [accountId]: _ignored, ...rest } = accounts
149
+ const updated = withAccounts(cfg, rest)
150
+ await writeRuntimeConfig(api, updated)
151
+ sendJson(res, 200, { ok: true, accountId, deleted: true })
152
+ return
153
+ }
154
+
155
+ sendJson(res, 405, { ok: false, error: 'Method Not Allowed' })
156
+ }
157
+ }
@@ -0,0 +1,123 @@
1
+ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk'
2
+ import { Buffer } from 'node:buffer'
3
+ import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
4
+ import path from 'node:path'
5
+
6
+ import { resolveWorkspaceDir } from './workspace.js'
7
+
8
+ const promptNames = ['SOUL.md', 'IDENTITY.md'] as const
9
+
10
+ function normalizePromptName(raw: string | null | undefined): (typeof promptNames)[number] | null {
11
+ const name = String(raw ?? '').trim().toUpperCase()
12
+ if (name === 'SOUL' || name === 'SOUL.MD')
13
+ return 'SOUL.md'
14
+ if (name === 'IDENTITY' || name === 'IDENTITY.MD')
15
+ return 'IDENTITY.md'
16
+ return null
17
+ }
18
+
19
+ function resolvePromptPath(api: OpenClawPluginApi, name: (typeof promptNames)[number]): string {
20
+ return path.join(resolveWorkspaceDir(api), name)
21
+ }
22
+
23
+ function sendJson(res: any, statusCode: number, payload: unknown): void {
24
+ res.statusCode = statusCode
25
+ res.setHeader('Content-Type', 'application/json')
26
+ res.end(JSON.stringify(payload))
27
+ }
28
+
29
+ export function createPromptsApiHandler(api: OpenClawPluginApi) {
30
+ return async (req: any, res: any) => {
31
+ const method = String(req.method ?? 'GET').toUpperCase()
32
+ const url = new URL(req.url ?? '', 'http://localhost')
33
+ const name = normalizePromptName(url.searchParams.get('name'))
34
+
35
+ if (method === 'GET') {
36
+ if (name) {
37
+ const filePath = resolvePromptPath(api, name)
38
+ try {
39
+ const content = await readFile(filePath, 'utf-8')
40
+ sendJson(res, 200, { ok: true, name, content })
41
+ return
42
+ }
43
+ catch {
44
+ sendJson(res, 404, { ok: false, error: 'Not Found' })
45
+ return
46
+ }
47
+ }
48
+
49
+ const entries = await Promise.all(
50
+ promptNames.map(async (promptName) => {
51
+ const filePath = resolvePromptPath(api, promptName)
52
+ try {
53
+ const info = await stat(filePath)
54
+ return { name: promptName, exists: true, bytes: info.size }
55
+ }
56
+ catch {
57
+ return { name: promptName, exists: false, bytes: 0 }
58
+ }
59
+ }),
60
+ )
61
+ sendJson(res, 200, { ok: true, entries })
62
+ return
63
+ }
64
+
65
+ if (method === 'PUT' || method === 'POST') {
66
+ const raw = await readRequestBody(req)
67
+ let input: any = {}
68
+ try {
69
+ input = raw ? JSON.parse(raw) : {}
70
+ }
71
+ catch {
72
+ sendJson(res, 400, { ok: false, error: 'Invalid JSON' })
73
+ return
74
+ }
75
+
76
+ const targetName = normalizePromptName(input?.name)
77
+ const content = typeof input?.content === 'string' ? input.content : null
78
+
79
+ if (!targetName || content === null) {
80
+ sendJson(res, 400, { ok: false, error: 'Missing name or content' })
81
+ return
82
+ }
83
+
84
+ const workspaceDir = resolveWorkspaceDir(api)
85
+ await mkdir(workspaceDir, { recursive: true })
86
+ await writeFile(resolvePromptPath(api, targetName), content, { encoding: 'utf-8' })
87
+ sendJson(res, 200, { ok: true, name: targetName })
88
+ return
89
+ }
90
+
91
+ if (method === 'DELETE') {
92
+ if (!name) {
93
+ sendJson(res, 400, { ok: false, error: 'Missing name' })
94
+ return
95
+ }
96
+
97
+ const filePath = resolvePromptPath(api, name)
98
+ try {
99
+ await unlink(filePath)
100
+ sendJson(res, 200, { ok: true, name, deleted: true })
101
+ return
102
+ }
103
+ catch {
104
+ sendJson(res, 200, { ok: true, name, deleted: false })
105
+ return
106
+ }
107
+ }
108
+
109
+ sendJson(res, 405, { ok: false, error: 'Method Not Allowed' })
110
+ }
111
+ }
112
+
113
+ async function readRequestBody(req: any): Promise<string> {
114
+ const chunks: Buffer[] = []
115
+ await new Promise<void>((resolve, reject) => {
116
+ req.on('data', (chunk: any) => {
117
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
118
+ })
119
+ req.on('end', () => resolve())
120
+ req.on('error', (err: unknown) => reject(err))
121
+ })
122
+ return Buffer.concat(chunks).toString('utf-8')
123
+ }