tissues 0.6.1 → 0.6.2
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/README.md +78 -1
- package/package.json +4 -2
- package/src/cli.js +10 -1
- package/src/commands/ai.js +147 -173
- package/src/commands/create.js +6 -6
- package/src/commands/create.test.js +381 -0
- package/src/commands/flush.test.js +299 -0
- package/src/commands/list.js +3 -2
- package/src/commands/providers.js +347 -0
- package/src/commands/providers.test.js +28 -0
- package/src/commands/storage.js +167 -0
- package/src/commands/sync.js +225 -0
- package/src/daemon/sync.js +189 -0
- package/src/lib/ai/adapters/claude-cli.js +55 -0
- package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
- package/src/lib/ai/adapters/codex-cli.js +77 -0
- package/src/lib/ai/adapters/gemini-cli.js +55 -0
- package/src/lib/ai/adapters/openclaw.js +91 -0
- package/src/lib/ai/agent-actions.js +271 -0
- package/src/lib/ai/agent.js +323 -0
- package/src/lib/ai/discovery.js +89 -0
- package/src/lib/ai/discovery.test.js +74 -0
- package/src/lib/ai/enhancement-adapter.test.js +188 -0
- package/src/lib/ai/pipeline.test.js +257 -0
- package/src/lib/ai/prompt.test.js +30 -0
- package/src/lib/ai/router.js +23 -0
- package/src/lib/ai/router.test.js +481 -0
- package/src/lib/ai/steps.test.js +335 -0
- package/src/lib/attribution.test.js +64 -0
- package/src/lib/cache.js +408 -0
- package/src/lib/db.js +42 -0
- package/src/lib/dedup.js +8 -18
- package/src/lib/dedup.test.js +227 -0
- package/src/lib/defaults.js +20 -0
- package/src/lib/defaults.test.js +217 -0
- package/src/lib/drafts-perf.test.js +203 -0
- package/src/lib/drafts.test.js +300 -0
- package/src/lib/enhancements.test.js +294 -0
- package/src/lib/gh.js +60 -0
- package/src/lib/safety.test.js +217 -0
- package/src/lib/storage.js +298 -0
- package/src/lib/templates.test.js +207 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* General-purpose AI agent for tissues CLI.
|
|
3
|
+
*
|
|
4
|
+
* Builds a token-efficient system prompt from local cache + filesystem,
|
|
5
|
+
* dispatches JSON-based actions, and supports multi-turn conversation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { store } from '../config.js'
|
|
9
|
+
import { loadConfig, findRepoRoot } from '../defaults.js'
|
|
10
|
+
import { ensureFresh, getCachedIssues } from '../cache.js'
|
|
11
|
+
import { getAuthenticatedUser } from '../gh.js'
|
|
12
|
+
import { listTemplates } from '../templates.js'
|
|
13
|
+
import { listEnhancements } from '../enhancements.js'
|
|
14
|
+
import { countPending } from '../drafts.js'
|
|
15
|
+
import { resolveRoute } from './router.js'
|
|
16
|
+
import { checkAvailable } from './index.js'
|
|
17
|
+
import { ACTION_SCHEMAS, executeAction } from './agent-actions.js'
|
|
18
|
+
import { bold, dim, cyan, green, yellow, red } from '../color.js'
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// System prompt builder
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the system prompt with dynamic state from cache + filesystem.
|
|
26
|
+
* Designed to be token-efficient (~1000-1400 tokens, max ~2000).
|
|
27
|
+
*
|
|
28
|
+
* @param {{ activeRepo: string, config: object, repoRoot: string, user?: string }} context
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function buildAgentSystemPrompt(context) {
|
|
32
|
+
const sections = []
|
|
33
|
+
|
|
34
|
+
// Section 1: Identity + constraints + guardrails
|
|
35
|
+
sections.push([
|
|
36
|
+
'You are the tissues CLI agent. You manage GitHub issues via JSON actions.',
|
|
37
|
+
'Return ONLY a single JSON object: { "action": "<name>", "params": { ... } }',
|
|
38
|
+
'',
|
|
39
|
+
'Rules:',
|
|
40
|
+
'- Output MUST be valid JSON. No markdown, no explanation, no text outside JSON.',
|
|
41
|
+
'- For read-only queries (list, show), execute immediately.',
|
|
42
|
+
'- For mutations (create, set, switch), include all required params.',
|
|
43
|
+
'- Use "answer" action for conversational replies or to ask clarifying questions.',
|
|
44
|
+
'',
|
|
45
|
+
'Guardrails:',
|
|
46
|
+
'- You ONLY handle tissues/GitHub issue operations. Nothing else.',
|
|
47
|
+
'- NEVER generate code, open URLs, write files, or answer off-topic questions.',
|
|
48
|
+
'- If asked something outside your scope (weather, coding help, general knowledge),',
|
|
49
|
+
' respond with: {"action":"answer","params":{"text":"I can only help with GitHub issues via tissues. Try `tissues --help` for available commands."}}',
|
|
50
|
+
].join('\n'))
|
|
51
|
+
|
|
52
|
+
// Section 2: Action schemas (compact)
|
|
53
|
+
const schemaLines = ['Available actions:']
|
|
54
|
+
for (const schema of ACTION_SCHEMAS) {
|
|
55
|
+
const paramStr = Object.entries(schema.params)
|
|
56
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
57
|
+
.join(', ')
|
|
58
|
+
const confirm = schema.requiresConfirmation ? ' [confirms]' : ''
|
|
59
|
+
schemaLines.push(` ${schema.name}(${paramStr})${confirm} — ${schema.description}`)
|
|
60
|
+
}
|
|
61
|
+
sections.push(schemaLines.join('\n'))
|
|
62
|
+
|
|
63
|
+
// Section 3: CLI command reference (so agent can point users to commands)
|
|
64
|
+
sections.push([
|
|
65
|
+
'CLI commands (you cannot run these, but can tell users about them):',
|
|
66
|
+
' tissues auth — configure GitHub authentication',
|
|
67
|
+
' tissues config — view/set configuration',
|
|
68
|
+
' tissues create — create an issue interactively',
|
|
69
|
+
' tissues draft — manage pending issue drafts',
|
|
70
|
+
' tissues list — list issues from cache',
|
|
71
|
+
' tissues status — show safety status (circuit breaker, rate limits)',
|
|
72
|
+
' tissues templates — list/manage issue templates',
|
|
73
|
+
' tissues enhancements — list AI enhancement plugins',
|
|
74
|
+
' tissues drafts — review and submit pending drafts',
|
|
75
|
+
' tissues ai — run AI agent (this is you)',
|
|
76
|
+
' tissues sync — force-sync cache with GitHub',
|
|
77
|
+
' tissues storage — manage local database',
|
|
78
|
+
].join('\n'))
|
|
79
|
+
|
|
80
|
+
// Section 4: Dynamic state snapshot (from cache, zero network calls)
|
|
81
|
+
const stateLines = ['Current state:']
|
|
82
|
+
|
|
83
|
+
if (context.activeRepo) stateLines.push(` Active repo: ${context.activeRepo}`)
|
|
84
|
+
if (context.user) stateLines.push(` User: ${context.user}`)
|
|
85
|
+
|
|
86
|
+
// Repos from cache
|
|
87
|
+
try {
|
|
88
|
+
const repos = ensureFresh('_global', 'repos', { acceptStale: true })
|
|
89
|
+
if (repos.length > 0) stateLines.push(` Available repos: ${repos.slice(0, 15).join(', ')}${repos.length > 15 ? ` (+${repos.length - 15} more)` : ''}`)
|
|
90
|
+
} catch { /* no cached repos */ }
|
|
91
|
+
|
|
92
|
+
// Templates from filesystem
|
|
93
|
+
try {
|
|
94
|
+
const templates = listTemplates(context.repoRoot)
|
|
95
|
+
const keys = [...new Set(templates.map(t => t.key))]
|
|
96
|
+
if (keys.length > 0) stateLines.push(` Templates: ${keys.join(', ')}`)
|
|
97
|
+
} catch { /* ok */ }
|
|
98
|
+
|
|
99
|
+
// Enhancements from filesystem
|
|
100
|
+
try {
|
|
101
|
+
const enhancements = listEnhancements(context.repoRoot)
|
|
102
|
+
const keys = [...new Set(enhancements.map(e => e.key))]
|
|
103
|
+
if (keys.length > 0) stateLines.push(` Enhancements: ${keys.join(', ')}`)
|
|
104
|
+
} catch { /* ok */ }
|
|
105
|
+
|
|
106
|
+
// Labels from cache
|
|
107
|
+
if (context.activeRepo) {
|
|
108
|
+
try {
|
|
109
|
+
const labels = ensureFresh(context.activeRepo, 'labels', { acceptStale: true })
|
|
110
|
+
if (labels.length > 0) stateLines.push(` Labels: ${labels.join(', ')}`)
|
|
111
|
+
} catch { /* ok */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Pending drafts
|
|
115
|
+
try {
|
|
116
|
+
const pending = countPending(context.repoRoot)
|
|
117
|
+
if (pending > 0) stateLines.push(` Pending drafts: ${pending}`)
|
|
118
|
+
} catch { /* ok */ }
|
|
119
|
+
|
|
120
|
+
// AI provider info
|
|
121
|
+
const ai = context.config?.ai || {}
|
|
122
|
+
stateLines.push(` AI provider: ${ai.provider || 'none'}`)
|
|
123
|
+
|
|
124
|
+
// Recent issues — prefer last-24h with labels+author, fallback to top 10 titles
|
|
125
|
+
if (context.activeRepo) {
|
|
126
|
+
try {
|
|
127
|
+
let issueLimit = 30
|
|
128
|
+
const allIssues = getCachedIssues(context.activeRepo, { state: 'open', limit: issueLimit })
|
|
129
|
+
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
|
|
130
|
+
let recentIssues = allIssues.filter(i => i.updatedAt && i.updatedAt >= oneDayAgo)
|
|
131
|
+
|
|
132
|
+
if (recentIssues.length > 0) {
|
|
133
|
+
stateLines.push(` Issues updated in last 24h:`)
|
|
134
|
+
for (const i of recentIssues.slice(0, issueLimit)) {
|
|
135
|
+
const labelStr = i.labels?.length ? ` [${i.labels.join(',')}]` : ''
|
|
136
|
+
const authorStr = i.author ? ` @${i.author}` : ''
|
|
137
|
+
stateLines.push(` #${i.number}: ${i.title}${labelStr}${authorStr}`)
|
|
138
|
+
}
|
|
139
|
+
} else if (allIssues.length > 0) {
|
|
140
|
+
// Fallback: show top 10 titles
|
|
141
|
+
stateLines.push(` Recent open issues:`)
|
|
142
|
+
for (const i of allIssues.slice(0, 10)) {
|
|
143
|
+
stateLines.push(` #${i.number}: ${i.title}`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch { /* ok */ }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
sections.push(stateLines.join('\n'))
|
|
150
|
+
|
|
151
|
+
// Token safety valve: if prompt is too long, trim issues and rebuild
|
|
152
|
+
let prompt = sections.join('\n\n')
|
|
153
|
+
if (prompt.length > 6000) {
|
|
154
|
+
// Rebuild the state section with halved issue count
|
|
155
|
+
const trimmedStateLines = stateLines.filter(l => !l.startsWith(' #'))
|
|
156
|
+
try {
|
|
157
|
+
const allIssues = getCachedIssues(context.activeRepo, { state: 'open', limit: 5 })
|
|
158
|
+
if (allIssues.length > 0) {
|
|
159
|
+
trimmedStateLines.push(` Recent open issues:`)
|
|
160
|
+
for (const i of allIssues.slice(0, 5)) {
|
|
161
|
+
trimmedStateLines.push(` #${i.number}: ${i.title}`)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch { /* ok */ }
|
|
165
|
+
sections[sections.length - 1] = trimmedStateLines.join('\n')
|
|
166
|
+
prompt = sections.join('\n\n')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return prompt
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Response parser
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Parse an AI response into an action object.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} raw - raw AI response text
|
|
180
|
+
* @returns {{ action: string, params: object }}
|
|
181
|
+
*/
|
|
182
|
+
export function parseAgentResponse(raw) {
|
|
183
|
+
let cleaned = raw.trim()
|
|
184
|
+
|
|
185
|
+
// Strip markdown code fences if present
|
|
186
|
+
if (cleaned.startsWith('```')) {
|
|
187
|
+
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Try to extract JSON from mixed content
|
|
191
|
+
const jsonMatch = cleaned.match(/\{[\s\S]*\}/)
|
|
192
|
+
if (jsonMatch) {
|
|
193
|
+
cleaned = jsonMatch[0]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const parsed = JSON.parse(cleaned)
|
|
197
|
+
|
|
198
|
+
if (!parsed.action) {
|
|
199
|
+
// If the response has create-style fields (title, body), treat as create_issue
|
|
200
|
+
if (parsed.title) {
|
|
201
|
+
return { action: 'create_issue', params: parsed }
|
|
202
|
+
}
|
|
203
|
+
throw new Error('Response missing "action" field')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { action: parsed.action, params: parsed.params || parsed }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Single-shot dispatch
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Run a single-shot AI agent: one LLM call, parse action, execute.
|
|
215
|
+
*
|
|
216
|
+
* @param {string} prompt - user's natural language prompt
|
|
217
|
+
* @param {object} config - merged config
|
|
218
|
+
* @param {{ activeRepo: string, repoRoot: string, user?: string }} context
|
|
219
|
+
* @param {{ provider?: string, model?: string, dryRun?: boolean }} [opts]
|
|
220
|
+
* @returns {Promise<{ action: string, result: any } | null>}
|
|
221
|
+
*/
|
|
222
|
+
export async function runAgentSingleShot(prompt, config, context, opts = {}) {
|
|
223
|
+
const systemPrompt = buildAgentSystemPrompt({ ...context, config })
|
|
224
|
+
const messages = [
|
|
225
|
+
{ role: 'system', content: systemPrompt },
|
|
226
|
+
{ role: 'user', content: prompt },
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
const aiContext = { provider: opts.provider, model: opts.model }
|
|
230
|
+
const { adapter, model } = resolveRoute(config, aiContext)
|
|
231
|
+
const raw = await adapter.complete(messages, { model, maxTokens: 2048 })
|
|
232
|
+
|
|
233
|
+
let action, params
|
|
234
|
+
try {
|
|
235
|
+
({ action, params } = parseAgentResponse(raw))
|
|
236
|
+
} catch {
|
|
237
|
+
// AI responded conversationally instead of JSON — treat as answer
|
|
238
|
+
action = 'answer'
|
|
239
|
+
params = { text: raw.trim() }
|
|
240
|
+
}
|
|
241
|
+
return { action, params, raw }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Multi-turn conversation loop
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Run the agent in multi-turn chat mode.
|
|
250
|
+
*
|
|
251
|
+
* @param {object} config
|
|
252
|
+
* @param {{ activeRepo: string, repoRoot: string, user?: string }} context
|
|
253
|
+
* @param {{ provider?: string, model?: string, onInput: () => Promise<string>, onOutput: (text: string) => void }} opts
|
|
254
|
+
*/
|
|
255
|
+
export async function runAgentLoop(config, context, opts) {
|
|
256
|
+
const { onInput, onOutput } = opts
|
|
257
|
+
const aiContext = { provider: opts.provider, model: opts.model }
|
|
258
|
+
|
|
259
|
+
const systemPrompt = buildAgentSystemPrompt({ ...context, config })
|
|
260
|
+
const messages = [{ role: 'system', content: systemPrompt }]
|
|
261
|
+
|
|
262
|
+
// Token management: keep last N turns to stay within context limits
|
|
263
|
+
const MAX_TURNS = 20
|
|
264
|
+
const SYSTEM_MSGS = 1 // system prompt is always first
|
|
265
|
+
|
|
266
|
+
while (true) {
|
|
267
|
+
const userInput = await onInput()
|
|
268
|
+
if (!userInput || /^(exit|quit|done|bye)$/i.test(userInput.trim())) {
|
|
269
|
+
onOutput(dim('Goodbye.'))
|
|
270
|
+
break
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
messages.push({ role: 'user', content: userInput })
|
|
274
|
+
|
|
275
|
+
// Trim old turns if conversation is getting long
|
|
276
|
+
while (messages.length > SYSTEM_MSGS + MAX_TURNS * 2) {
|
|
277
|
+
// Remove oldest user/assistant pair (keep system prompt)
|
|
278
|
+
messages.splice(SYSTEM_MSGS, 2)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let raw
|
|
282
|
+
try {
|
|
283
|
+
const { adapter, model } = resolveRoute(config, aiContext)
|
|
284
|
+
raw = await adapter.complete(messages, { model, maxTokens: 2048 })
|
|
285
|
+
} catch (err) {
|
|
286
|
+
onOutput(red(`AI error: ${err.message}`))
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
messages.push({ role: 'assistant', content: raw })
|
|
291
|
+
|
|
292
|
+
let action, params
|
|
293
|
+
try {
|
|
294
|
+
({ action, params } = parseAgentResponse(raw))
|
|
295
|
+
} catch {
|
|
296
|
+
// If we can't parse, show the raw response
|
|
297
|
+
onOutput(raw)
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Execute action
|
|
302
|
+
const result = await executeAction(action, params || {}, { ...context, config })
|
|
303
|
+
|
|
304
|
+
if (result.display) {
|
|
305
|
+
onOutput(result.display)
|
|
306
|
+
} else if (result.result) {
|
|
307
|
+
onOutput(typeof result.result === 'string' ? result.result : JSON.stringify(result.result, null, 2))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Feed result back as a system message for context
|
|
311
|
+
if (action !== 'answer') {
|
|
312
|
+
messages.push({
|
|
313
|
+
role: 'user',
|
|
314
|
+
content: `[System: ${action} result: ${JSON.stringify(result.result).slice(0, 500)}]`,
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// If it was just an answer with no follow-up question, check if we should continue
|
|
319
|
+
if (action === 'answer' && !result.result?.includes('?')) {
|
|
320
|
+
// Still continue — user drives the loop
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check if a binary is available on PATH.
|
|
5
|
+
* @param {string} name - binary name
|
|
6
|
+
* @returns {Promise<boolean>}
|
|
7
|
+
*/
|
|
8
|
+
function hasBinary(name) {
|
|
9
|
+
const cmd = process.platform === 'win32' ? 'where' : 'command'
|
|
10
|
+
const args = process.platform === 'win32' ? [name] : ['-v', name]
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
execFile(cmd, args, { timeout: 5000 }, (err) => resolve(!err))
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Auto-discover available AI providers on this system.
|
|
18
|
+
*
|
|
19
|
+
* Checks for:
|
|
20
|
+
* - CLI binaries: gemini, claude, codex
|
|
21
|
+
* - OpenClaw gateway (env vars)
|
|
22
|
+
* - API keys in env
|
|
23
|
+
*
|
|
24
|
+
* @returns {Promise<Array<{ name: string, type: 'cli'|'api'|'openclaw', status: 'available'|'configured', config: object }>>}
|
|
25
|
+
*/
|
|
26
|
+
export async function discoverProviders() {
|
|
27
|
+
const results = []
|
|
28
|
+
|
|
29
|
+
// CLI binaries — check in parallel
|
|
30
|
+
const cliChecks = [
|
|
31
|
+
{ name: 'gemini-cli', binary: 'gemini' },
|
|
32
|
+
{ name: 'claude-cli', binary: 'claude' },
|
|
33
|
+
{ name: 'codex-cli', binary: 'codex' },
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
const binaryResults = await Promise.all(
|
|
37
|
+
cliChecks.map(async ({ name, binary }) => ({
|
|
38
|
+
name,
|
|
39
|
+
binary,
|
|
40
|
+
found: await hasBinary(binary),
|
|
41
|
+
})),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
for (const { name, binary, found } of binaryResults) {
|
|
45
|
+
if (found) {
|
|
46
|
+
results.push({
|
|
47
|
+
name,
|
|
48
|
+
type: 'cli',
|
|
49
|
+
status: 'available',
|
|
50
|
+
config: { binary },
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// OpenClaw gateway
|
|
56
|
+
const openclawToken = process.env.OPENCLAW_GATEWAY_TOKEN
|
|
57
|
+
const openclawPort = process.env.OPENCLAW_GATEWAY_PORT || '18790'
|
|
58
|
+
if (openclawToken) {
|
|
59
|
+
results.push({
|
|
60
|
+
name: 'openclaw',
|
|
61
|
+
type: 'openclaw',
|
|
62
|
+
status: 'configured',
|
|
63
|
+
config: {
|
|
64
|
+
gatewayUrl: `http://localhost:${openclawPort}`,
|
|
65
|
+
token: openclawToken,
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// API keys in environment
|
|
71
|
+
const apiKeyChecks = [
|
|
72
|
+
{ name: 'anthropic', env: 'ANTHROPIC_API_KEY' },
|
|
73
|
+
{ name: 'openai', env: 'OPENAI_API_KEY' },
|
|
74
|
+
{ name: 'gemini', env: 'GEMINI_API_KEY' },
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
for (const { name, env } of apiKeyChecks) {
|
|
78
|
+
if (process.env[env]) {
|
|
79
|
+
results.push({
|
|
80
|
+
name,
|
|
81
|
+
type: 'api',
|
|
82
|
+
status: 'configured',
|
|
83
|
+
config: { envVar: env },
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return results
|
|
89
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { discoverProviders } from './discovery.js'
|
|
4
|
+
|
|
5
|
+
describe('discoverProviders', () => {
|
|
6
|
+
const savedEnv = {}
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
// Save and clear relevant env vars
|
|
10
|
+
for (const key of ['OPENCLAW_GATEWAY_TOKEN', 'OPENCLAW_GATEWAY_PORT', 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY']) {
|
|
11
|
+
savedEnv[key] = process.env[key]
|
|
12
|
+
delete process.env[key]
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
// Restore env vars
|
|
18
|
+
for (const [key, val] of Object.entries(savedEnv)) {
|
|
19
|
+
if (val === undefined) delete process.env[key]
|
|
20
|
+
else process.env[key] = val
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('returns an array', async () => {
|
|
25
|
+
const result = await discoverProviders()
|
|
26
|
+
assert.ok(Array.isArray(result))
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('discovers API keys from environment', async () => {
|
|
30
|
+
process.env.ANTHROPIC_API_KEY = 'sk-ant-test'
|
|
31
|
+
process.env.OPENAI_API_KEY = 'sk-openai-test'
|
|
32
|
+
const result = await discoverProviders()
|
|
33
|
+
const apiProviders = result.filter((r) => r.type === 'api')
|
|
34
|
+
const names = apiProviders.map((p) => p.name)
|
|
35
|
+
assert.ok(names.includes('anthropic'))
|
|
36
|
+
assert.ok(names.includes('openai'))
|
|
37
|
+
assert.ok(!names.includes('gemini'))
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('discovers OpenClaw from environment', async () => {
|
|
41
|
+
process.env.OPENCLAW_GATEWAY_TOKEN = 'test-token'
|
|
42
|
+
process.env.OPENCLAW_GATEWAY_PORT = '9999'
|
|
43
|
+
const result = await discoverProviders()
|
|
44
|
+
const openclaw = result.find((r) => r.name === 'openclaw')
|
|
45
|
+
assert.ok(openclaw)
|
|
46
|
+
assert.equal(openclaw.type, 'openclaw')
|
|
47
|
+
assert.equal(openclaw.status, 'configured')
|
|
48
|
+
assert.equal(openclaw.config.gatewayUrl, 'http://localhost:9999')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('uses default port for OpenClaw when not set', async () => {
|
|
52
|
+
process.env.OPENCLAW_GATEWAY_TOKEN = 'test-token'
|
|
53
|
+
const result = await discoverProviders()
|
|
54
|
+
const openclaw = result.find((r) => r.name === 'openclaw')
|
|
55
|
+
assert.equal(openclaw.config.gatewayUrl, 'http://localhost:18790')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('does not include OpenClaw without token', async () => {
|
|
59
|
+
const result = await discoverProviders()
|
|
60
|
+
const openclaw = result.find((r) => r.name === 'openclaw')
|
|
61
|
+
assert.equal(openclaw, undefined)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('each result has required shape', async () => {
|
|
65
|
+
process.env.ANTHROPIC_API_KEY = 'sk-test'
|
|
66
|
+
const result = await discoverProviders()
|
|
67
|
+
for (const item of result) {
|
|
68
|
+
assert.ok(item.name, 'has name')
|
|
69
|
+
assert.ok(['cli', 'api', 'openclaw'].includes(item.type), `valid type: ${item.type}`)
|
|
70
|
+
assert.ok(['available', 'configured'].includes(item.status), `valid status: ${item.status}`)
|
|
71
|
+
assert.ok(item.config && typeof item.config === 'object', 'has config object')
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { enhancementToStep } from './enhancement-adapter.js'
|
|
4
|
+
import { BUILT_IN_ENHANCEMENTS } from '../enhancements.js'
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
function baseCtx(overrides = {}) {
|
|
11
|
+
return {
|
|
12
|
+
rawInput: '',
|
|
13
|
+
title: 'Fix auth bug',
|
|
14
|
+
description: 'The login page throws a 401 error',
|
|
15
|
+
instructions: '',
|
|
16
|
+
templateBody: '## Summary\n\n{{description}}',
|
|
17
|
+
labels: ['bug'],
|
|
18
|
+
existingIssues: [],
|
|
19
|
+
repoLabels: ['bug', 'feature', 'P0-critical'],
|
|
20
|
+
dedupScore: null,
|
|
21
|
+
structuredContext: null,
|
|
22
|
+
scopeAnalysis: null,
|
|
23
|
+
complexity: null,
|
|
24
|
+
complexityRationale: null,
|
|
25
|
+
risk: null,
|
|
26
|
+
riskRationale: null,
|
|
27
|
+
aiLabels: null,
|
|
28
|
+
body: '',
|
|
29
|
+
...overrides,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Built-in enhancement → step conversion
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
describe('enhancementToStep — built-in enhancements', () => {
|
|
38
|
+
test('converts built-in triage to step with correct fields', () => {
|
|
39
|
+
const step = enhancementToStep(BUILT_IN_ENHANCEMENTS.triage)
|
|
40
|
+
assert.equal(step.name, 'triage')
|
|
41
|
+
assert.equal(step.displayName, 'Input Analysis')
|
|
42
|
+
assert.equal(step.maxTokens, 1024)
|
|
43
|
+
assert.ok(typeof step.shouldRun === 'function')
|
|
44
|
+
assert.ok(typeof step.buildMessages === 'function')
|
|
45
|
+
assert.ok(typeof step.parseResponse === 'function')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('built-in step uses original shouldRun', () => {
|
|
49
|
+
const step = enhancementToStep(BUILT_IN_ENHANCEMENTS.triage)
|
|
50
|
+
assert.equal(step.shouldRun(baseCtx({ rawInput: 'hello' })), true)
|
|
51
|
+
assert.equal(step.shouldRun(baseCtx({ rawInput: '' })), false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('built-in step uses original buildMessages', () => {
|
|
55
|
+
const step = enhancementToStep(BUILT_IN_ENHANCEMENTS.context)
|
|
56
|
+
const ctx = baseCtx()
|
|
57
|
+
const msgs = step.buildMessages(ctx)
|
|
58
|
+
assert.ok(Array.isArray(msgs))
|
|
59
|
+
assert.equal(msgs[0].role, 'system')
|
|
60
|
+
assert.equal(msgs[1].role, 'user')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('built-in step uses original parseResponse', () => {
|
|
64
|
+
const step = enhancementToStep(BUILT_IN_ENHANCEMENTS.complexity)
|
|
65
|
+
const ctx = baseCtx()
|
|
66
|
+
step.parseResponse(JSON.stringify({ score: 7, rationale: 'complex' }), ctx)
|
|
67
|
+
assert.equal(ctx.complexity, 7)
|
|
68
|
+
assert.equal(ctx.complexityRationale, 'complex')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('all built-in enhancements convert to valid steps', () => {
|
|
72
|
+
for (const [key, enh] of Object.entries(BUILT_IN_ENHANCEMENTS)) {
|
|
73
|
+
const step = enhancementToStep(enh)
|
|
74
|
+
assert.equal(step.name, key)
|
|
75
|
+
assert.ok(step.displayName)
|
|
76
|
+
assert.ok(typeof step.maxTokens === 'number')
|
|
77
|
+
assert.ok(typeof step.shouldRun === 'function')
|
|
78
|
+
assert.ok(typeof step.buildMessages === 'function')
|
|
79
|
+
assert.ok(typeof step.parseResponse === 'function')
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Prompt override
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
describe('enhancementToStep — prompt override', () => {
|
|
89
|
+
test('user-overridden built-in uses custom prompt', () => {
|
|
90
|
+
const overridden = {
|
|
91
|
+
...BUILT_IN_ENHANCEMENTS.context,
|
|
92
|
+
source: 'user',
|
|
93
|
+
prompt: 'Custom context prompt here.',
|
|
94
|
+
}
|
|
95
|
+
const step = enhancementToStep(overridden)
|
|
96
|
+
const msgs = step.buildMessages(baseCtx())
|
|
97
|
+
// Should use the custom prompt, not the built-in
|
|
98
|
+
assert.ok(msgs[0].content.includes('Custom context prompt here.'))
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('built-in source uses original buildMessages', () => {
|
|
102
|
+
const builtIn = { ...BUILT_IN_ENHANCEMENTS.context, source: 'built-in' }
|
|
103
|
+
const step = enhancementToStep(builtIn)
|
|
104
|
+
const msgs = step.buildMessages(baseCtx())
|
|
105
|
+
// Should use built-in prompt (includes "Extract structured context")
|
|
106
|
+
assert.ok(msgs[0].content.includes('Extract structured context'))
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Custom enhancement → step conversion
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
describe('enhancementToStep — custom enhancements', () => {
|
|
115
|
+
const customEnh = {
|
|
116
|
+
key: 'security',
|
|
117
|
+
name: 'Security Review',
|
|
118
|
+
maxTokens: 1024,
|
|
119
|
+
mode: 'auto',
|
|
120
|
+
format: 'json',
|
|
121
|
+
contextKey: 'securityScore',
|
|
122
|
+
order: 55,
|
|
123
|
+
requires: ['scopeAnalysis'],
|
|
124
|
+
isStructural: false,
|
|
125
|
+
prompt: 'You are a security reviewer.\nReturn JSON.',
|
|
126
|
+
source: 'user',
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
test('converts custom to step with correct fields', () => {
|
|
130
|
+
const step = enhancementToStep(customEnh)
|
|
131
|
+
assert.equal(step.name, 'security')
|
|
132
|
+
assert.equal(step.displayName, 'Security Review')
|
|
133
|
+
assert.equal(step.maxTokens, 1024)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('custom shouldRun checks requires array', () => {
|
|
137
|
+
const step = enhancementToStep(customEnh)
|
|
138
|
+
// Should not run when scopeAnalysis is missing
|
|
139
|
+
assert.equal(step.shouldRun(baseCtx({ scopeAnalysis: null })), false)
|
|
140
|
+
// Should run when scopeAnalysis is present
|
|
141
|
+
assert.equal(step.shouldRun(baseCtx({ scopeAnalysis: { files: [] } })), true)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('custom shouldRun returns true when no requires', () => {
|
|
145
|
+
const noReqs = { ...customEnh, requires: [] }
|
|
146
|
+
const step = enhancementToStep(noReqs)
|
|
147
|
+
assert.equal(step.shouldRun(baseCtx()), true)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('custom buildMessages uses prompt and context', () => {
|
|
151
|
+
const step = enhancementToStep(customEnh)
|
|
152
|
+
const msgs = step.buildMessages(baseCtx())
|
|
153
|
+
assert.equal(msgs[0].role, 'system')
|
|
154
|
+
assert.ok(msgs[0].content.includes('You are a security reviewer.'))
|
|
155
|
+
assert.equal(msgs[1].role, 'user')
|
|
156
|
+
assert.ok(msgs[1].content.includes('Fix auth bug'))
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('custom JSON parseResponse stores at contextKey', () => {
|
|
160
|
+
const step = enhancementToStep(customEnh)
|
|
161
|
+
const ctx = baseCtx()
|
|
162
|
+
step.parseResponse(JSON.stringify({ score: 8, rationale: 'risky' }), ctx)
|
|
163
|
+
assert.deepEqual(ctx.securityScore, { score: 8, rationale: 'risky' })
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('custom JSON parseResponse handles malformed JSON', () => {
|
|
167
|
+
const step = enhancementToStep(customEnh)
|
|
168
|
+
const ctx = baseCtx()
|
|
169
|
+
step.parseResponse('not json', ctx)
|
|
170
|
+
assert.equal(ctx.securityScore, null)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('custom markdown parseResponse stores raw text', () => {
|
|
174
|
+
const mdEnh = { ...customEnh, format: 'markdown', contextKey: 'review' }
|
|
175
|
+
const step = enhancementToStep(mdEnh)
|
|
176
|
+
const ctx = baseCtx()
|
|
177
|
+
step.parseResponse('## Review\n\nLooks good.', ctx)
|
|
178
|
+
assert.equal(ctx.review, '## Review\n\nLooks good.')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('custom markdown parseResponse strips code fences', () => {
|
|
182
|
+
const mdEnh = { ...customEnh, format: 'markdown', contextKey: 'review' }
|
|
183
|
+
const step = enhancementToStep(mdEnh)
|
|
184
|
+
const ctx = baseCtx()
|
|
185
|
+
step.parseResponse('```markdown\n## Review\n\nContent here.\n```', ctx)
|
|
186
|
+
assert.equal(ctx.review, '## Review\n\nContent here.')
|
|
187
|
+
})
|
|
188
|
+
})
|