tissues 0.6.0 → 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 +12 -1
- package/src/commands/ai.js +149 -173
- package/src/commands/config.js +143 -69
- package/src/commands/create.js +16 -9
- package/src/commands/create.test.js +381 -0
- package/src/commands/enhancements.js +282 -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/command.js +23 -13
- 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/body-template.js +15 -0
- package/src/lib/ai/discovery.js +89 -0
- package/src/lib/ai/discovery.test.js +74 -0
- package/src/lib/ai/enhance.js +48 -11
- package/src/lib/ai/enhancement-adapter.js +109 -0
- package/src/lib/ai/enhancement-adapter.test.js +188 -0
- package/src/lib/ai/index.js +2 -2
- package/src/lib/ai/pipeline.js +20 -2
- package/src/lib/ai/pipeline.test.js +257 -0
- package/src/lib/ai/prompt.test.js +30 -0
- package/src/lib/ai/router.js +118 -7
- package/src/lib/ai/router.test.js +481 -0
- package/src/lib/ai/steps.js +23 -3
- 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 +44 -48
- package/src/lib/dedup.test.js +227 -0
- package/src/lib/defaults.js +37 -1
- 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.js +436 -0
- package/src/lib/enhancements.test.js +294 -0
- package/src/lib/gh.js +76 -10
- 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
|
+
}
|
|
@@ -38,6 +38,21 @@ export function buildBodyGuidance(ctx) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Include sections for custom enhancement results not covered above
|
|
42
|
+
const knownKeys = new Set([
|
|
43
|
+
'structuredContext', 'scopeAnalysis', 'complexity', 'complexityRationale',
|
|
44
|
+
'risk', 'riskRationale', 'aiLabels', 'dedupScore', 'body',
|
|
45
|
+
'rawInput', 'title', 'description', 'instructions', 'templateBody',
|
|
46
|
+
'labels', 'existingIssues', 'repoLabels', '_stepConfigs', 'triage',
|
|
47
|
+
])
|
|
48
|
+
for (const [key, value] of Object.entries(ctx)) {
|
|
49
|
+
if (!knownKeys.has(key) && value != null && !key.startsWith('_')) {
|
|
50
|
+
// Custom enhancement produced a result — add a section hint
|
|
51
|
+
const heading = key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1')
|
|
52
|
+
lines.push(`## ${heading}`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
41
56
|
lines.push(
|
|
42
57
|
'',
|
|
43
58
|
'Rules:',
|
|
@@ -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
|
+
})
|
package/src/lib/ai/enhance.js
CHANGED
|
@@ -9,14 +9,16 @@ import { resolveRoute } from './router.js'
|
|
|
9
9
|
import { runPipeline } from './pipeline.js'
|
|
10
10
|
import { ALL_STEPS } from './steps.js'
|
|
11
11
|
import { checkBudgets, recordUsage } from './index.js'
|
|
12
|
+
import { loadAllEnhancements } from '../enhancements.js'
|
|
13
|
+
import { enhancementToStep } from './enhancement-adapter.js'
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Run the full enhancement pipeline.
|
|
15
17
|
*
|
|
16
18
|
* @param {object} config - merged config
|
|
17
|
-
* @param {object} input - { title, description, instructions, templateBody, labels, existingIssues, repoLabels }
|
|
19
|
+
* @param {object} input - { title, description, instructions, templateBody, labels, existingIssues, repoLabels, rawInput }
|
|
18
20
|
* @param {object} [callbacks] - pipeline callbacks for progress display
|
|
19
|
-
* @param {{ provider?: string, model?: string, template?: string }} [routeContext] - routing context
|
|
21
|
+
* @param {{ provider?: string, model?: string, template?: string, enhancements?: string[] }} [routeContext] - routing context
|
|
20
22
|
* @returns {Promise<object>} PipelineContext with all accumulated results
|
|
21
23
|
*/
|
|
22
24
|
export async function runEnhancePipeline(config, input, callbacks, routeContext = {}) {
|
|
@@ -27,11 +29,49 @@ export async function runEnhancePipeline(config, input, callbacks, routeContext
|
|
|
27
29
|
// Resolve adapter + model
|
|
28
30
|
const route = resolveRoute(config, routeContext)
|
|
29
31
|
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
// Load all enhancements (three-tier: repo > user > built-in)
|
|
33
|
+
let enhancements
|
|
34
|
+
try {
|
|
35
|
+
enhancements = loadAllEnhancements(input._repoRoot)
|
|
36
|
+
} catch {
|
|
37
|
+
enhancements = null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let activeSteps
|
|
41
|
+
let stepConfigs = {}
|
|
42
|
+
|
|
43
|
+
if (enhancements && enhancements.length > 0) {
|
|
44
|
+
// Enhancement-based resolution
|
|
45
|
+
const enhancementNames = routeContext.enhancements || route.enhancements || null
|
|
46
|
+
|
|
47
|
+
// Build step configs from config (supports both old `steps` and new `enhancements` key)
|
|
48
|
+
const cfgEnhancements = pipelineConfig.enhancements || pipelineConfig.steps || {}
|
|
49
|
+
|
|
50
|
+
// Filter enhancements if specific names requested
|
|
51
|
+
let filtered = enhancements
|
|
52
|
+
if (enhancementNames && enhancementNames.length > 0) {
|
|
53
|
+
const requested = new Set(enhancementNames)
|
|
54
|
+
// Always include structural enhancements (triage, format)
|
|
55
|
+
filtered = enhancements.filter(
|
|
56
|
+
(enh) => enh.isStructural || requested.has(enh.key),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Build step configs and convert to step objects
|
|
61
|
+
for (const enh of filtered) {
|
|
62
|
+
stepConfigs[enh.key] = cfgEnhancements[enh.key] || enh.mode || 'auto'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
activeSteps = filtered
|
|
66
|
+
.filter((enh) => stepConfigs[enh.key] !== 'never')
|
|
67
|
+
.map((enh) => enhancementToStep(enh))
|
|
68
|
+
} else {
|
|
69
|
+
// Fallback: use hardcoded ALL_STEPS (backward compat)
|
|
70
|
+
const cfgSteps = pipelineConfig.steps || {}
|
|
71
|
+
for (const step of ALL_STEPS) {
|
|
72
|
+
stepConfigs[step.name] = cfgSteps[step.name] || 'auto'
|
|
73
|
+
}
|
|
74
|
+
activeSteps = ALL_STEPS.filter((s) => stepConfigs[s.name] !== 'never')
|
|
35
75
|
}
|
|
36
76
|
|
|
37
77
|
// Build the initial PipelineContext
|
|
@@ -61,10 +101,7 @@ export async function runEnhancePipeline(config, input, callbacks, routeContext
|
|
|
61
101
|
body: input.templateBody || '', // fallback to template
|
|
62
102
|
}
|
|
63
103
|
|
|
64
|
-
|
|
65
|
-
const activeSteps = ALL_STEPS.filter((s) => stepConfigs[s.name] !== 'never')
|
|
66
|
-
|
|
67
|
-
await runPipeline(activeSteps, ctx, route, budgets, callbacks, { checkBudgets, recordUsage })
|
|
104
|
+
await runPipeline(activeSteps, ctx, route, config, budgets, callbacks, { checkBudgets, recordUsage })
|
|
68
105
|
|
|
69
106
|
return ctx
|
|
70
107
|
}
|