tissues 0.6.0 → 0.6.1
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/package.json +1 -1
- package/src/cli.js +3 -1
- package/src/commands/ai.js +2 -0
- package/src/commands/config.js +143 -69
- package/src/commands/create.js +10 -3
- package/src/commands/enhancements.js +282 -0
- package/src/lib/ai/adapters/command.js +23 -13
- package/src/lib/ai/body-template.js +15 -0
- package/src/lib/ai/enhance.js +48 -11
- package/src/lib/ai/enhancement-adapter.js +109 -0
- package/src/lib/ai/index.js +2 -2
- package/src/lib/ai/pipeline.js +20 -2
- package/src/lib/ai/router.js +95 -7
- package/src/lib/ai/steps.js +23 -3
- package/src/lib/dedup.js +36 -30
- package/src/lib/defaults.js +17 -1
- package/src/lib/enhancements.js +436 -0
- package/src/lib/gh.js +16 -10
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import { dim, red, green } from '../lib/color.js'
|
|
6
|
+
import { select, input, editor } from '@inquirer/prompts'
|
|
7
|
+
import {
|
|
8
|
+
listEnhancements,
|
|
9
|
+
loadEnhancement,
|
|
10
|
+
builtInEnhancementKeys,
|
|
11
|
+
BUILT_IN_ENHANCEMENTS,
|
|
12
|
+
parseEnhancementFile,
|
|
13
|
+
} from '../lib/enhancements.js'
|
|
14
|
+
import { findRepoRoot, loadConfig } from '../lib/defaults.js'
|
|
15
|
+
import { listProviders, listAllProviders } from '../lib/ai/index.js'
|
|
16
|
+
import { theme } from '../lib/theme.js'
|
|
17
|
+
|
|
18
|
+
const PROVIDER_LABELS = {
|
|
19
|
+
anthropic: 'Anthropic',
|
|
20
|
+
openai: 'OpenAI',
|
|
21
|
+
gemini: 'Gemini',
|
|
22
|
+
ollama: 'Ollama',
|
|
23
|
+
'openai-compat': 'OpenAI Custom',
|
|
24
|
+
command: 'Command',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const builtInProviders = new Set(listProviders())
|
|
28
|
+
|
|
29
|
+
function formatProviderName(name) {
|
|
30
|
+
if (!name) return null
|
|
31
|
+
if (builtInProviders.has(name)) return PROVIDER_LABELS[name] || name
|
|
32
|
+
return `${name} ${dim('(custom)')}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isCancelled(err) {
|
|
36
|
+
return err?.name === 'ExitPromptError' || err?.message?.includes('User force closed')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function promptOrBack(fn) {
|
|
40
|
+
try { return await fn() } catch (err) { if (isCancelled(err)) return Symbol.for('back'); throw err }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function userEnhancementDir() {
|
|
44
|
+
return path.join(os.homedir(), '.config', 'tissues', 'enhancements')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const enhancementsCommand = new Command('enhancements')
|
|
48
|
+
.description('Manage AI pipeline enhancements (view, edit, create)')
|
|
49
|
+
.action(async () => {
|
|
50
|
+
while (true) {
|
|
51
|
+
const repoRoot = findRepoRoot()
|
|
52
|
+
const enhancements = listEnhancements(repoRoot)
|
|
53
|
+
const builtInKeys = builtInEnhancementKeys()
|
|
54
|
+
|
|
55
|
+
// Deduplicate by key (higher-priority sources shadow lower ones)
|
|
56
|
+
const seen = new Set()
|
|
57
|
+
const choices = []
|
|
58
|
+
for (const enh of enhancements) {
|
|
59
|
+
if (!seen.has(enh.key)) {
|
|
60
|
+
seen.add(enh.key)
|
|
61
|
+
const provLabel = enh.provider ? formatProviderName(enh.provider) : 'default'
|
|
62
|
+
choices.push({
|
|
63
|
+
name: `${enh.key.padEnd(18)} ${dim(provLabel)}`,
|
|
64
|
+
value: enh.key,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
choices.push({ name: green('Create New Enhancement'), value: '_create' })
|
|
69
|
+
choices.push({ name: dim('Done'), value: 'done' })
|
|
70
|
+
|
|
71
|
+
const chosen = await promptOrBack(() => select({ message: 'Enhancements', choices, theme }))
|
|
72
|
+
if (chosen === Symbol.for('back') || chosen === 'done') break
|
|
73
|
+
|
|
74
|
+
if (chosen === '_create') {
|
|
75
|
+
await createNewEnhancement(builtInKeys, seen)
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// View / edit existing enhancement
|
|
80
|
+
await viewEnhancement(chosen, repoRoot, builtInKeys)
|
|
81
|
+
console.log()
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
async function createNewEnhancement(builtInKeys, existingKeys) {
|
|
86
|
+
const name = await promptOrBack(() => input({ message: 'Enhancement key (lowercase, no spaces)', theme }))
|
|
87
|
+
if (name === Symbol.for('back') || !name) return
|
|
88
|
+
|
|
89
|
+
const key = name.trim().toLowerCase().replace(/\s+/g, '-')
|
|
90
|
+
if (!key) return
|
|
91
|
+
|
|
92
|
+
// Block reusing exact built-in names
|
|
93
|
+
if (builtInKeys.includes(key)) {
|
|
94
|
+
console.log(red(` "${key}" is a built-in enhancement. Use edit to customize it instead.`))
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Block duplicates
|
|
99
|
+
if (existingKeys.has(key)) {
|
|
100
|
+
console.log(red(` Enhancement "${key}" already exists.`))
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const displayName = await promptOrBack(() =>
|
|
105
|
+
input({ message: 'Display name', default: key.charAt(0).toUpperCase() + key.slice(1), theme }),
|
|
106
|
+
)
|
|
107
|
+
if (displayName === Symbol.for('back')) return
|
|
108
|
+
|
|
109
|
+
const contextKey = await promptOrBack(() =>
|
|
110
|
+
input({ message: 'Context key (where result is stored)', default: key, theme }),
|
|
111
|
+
)
|
|
112
|
+
if (contextKey === Symbol.for('back')) return
|
|
113
|
+
|
|
114
|
+
const skeleton = [
|
|
115
|
+
'---',
|
|
116
|
+
`name: ${displayName}`,
|
|
117
|
+
'maxTokens: 1024',
|
|
118
|
+
'mode: auto',
|
|
119
|
+
'format: json',
|
|
120
|
+
`contextKey: ${contextKey}`,
|
|
121
|
+
'order: 50',
|
|
122
|
+
'requires: []',
|
|
123
|
+
'---',
|
|
124
|
+
`You are an expert at analyzing GitHub issues.`,
|
|
125
|
+
`Assess the ${key} aspects of the proposed change.`,
|
|
126
|
+
'Return a JSON object with your analysis.',
|
|
127
|
+
'Return ONLY valid JSON.',
|
|
128
|
+
].join('\n')
|
|
129
|
+
|
|
130
|
+
const body = await promptOrBack(() =>
|
|
131
|
+
editor({
|
|
132
|
+
message: 'Enhancement file (opens editor)',
|
|
133
|
+
default: skeleton,
|
|
134
|
+
theme,
|
|
135
|
+
}),
|
|
136
|
+
)
|
|
137
|
+
if (body === Symbol.for('back') || !body) return
|
|
138
|
+
|
|
139
|
+
const dir = userEnhancementDir()
|
|
140
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
141
|
+
const filePath = path.join(dir, `${key}.md`)
|
|
142
|
+
fs.writeFileSync(filePath, body, 'utf8')
|
|
143
|
+
console.log(green(` ✔ Enhancement "${key}" created: ${filePath}`))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function viewEnhancement(key, repoRoot, builtInKeys) {
|
|
147
|
+
let enh
|
|
148
|
+
try {
|
|
149
|
+
enh = loadEnhancement(key, repoRoot)
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.log(red(` ${err.message}`))
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
console.log(`\n ${enh.name} ${dim(`(${enh.source})`)}`)
|
|
156
|
+
console.log(dim(' ─'.repeat(20)))
|
|
157
|
+
console.log(dim(` Mode: ${enh.mode} Format: ${enh.format} Tokens: ${enh.maxTokens} Order: ${enh.order}`))
|
|
158
|
+
if (enh.provider) console.log(dim(` Provider: ${enh.provider}`))
|
|
159
|
+
if (enh.contextKey) console.log(dim(` Context key: ${enh.contextKey}`))
|
|
160
|
+
if (enh.requires?.length) console.log(dim(` Requires: ${enh.requires.join(', ')}`))
|
|
161
|
+
console.log()
|
|
162
|
+
|
|
163
|
+
// Show prompt preview (truncated)
|
|
164
|
+
const preview = enh.prompt.split('\n').slice(0, 8).join('\n')
|
|
165
|
+
console.log(dim(preview))
|
|
166
|
+
if (enh.prompt.split('\n').length > 8) console.log(dim(' ...'))
|
|
167
|
+
console.log()
|
|
168
|
+
|
|
169
|
+
const actionChoices = [
|
|
170
|
+
{ name: 'Edit', value: 'edit' },
|
|
171
|
+
{ name: `Provider ${formatProviderName(enh.provider) || dim('default')}`, value: 'provider' },
|
|
172
|
+
]
|
|
173
|
+
if (builtInKeys.includes(key) && enh.source === 'built-in') {
|
|
174
|
+
actionChoices[0] = { name: 'Customize (creates user copy)', value: 'edit' }
|
|
175
|
+
}
|
|
176
|
+
// Only allow deleting user enhancements
|
|
177
|
+
const userFile = path.join(userEnhancementDir(), `${key}.md`)
|
|
178
|
+
if (fs.existsSync(userFile)) {
|
|
179
|
+
actionChoices.push({ name: red('Delete user copy'), value: 'delete' })
|
|
180
|
+
}
|
|
181
|
+
actionChoices.push({ name: dim('Back'), value: 'back' })
|
|
182
|
+
|
|
183
|
+
const action = await promptOrBack(() => select({ message: enh.name, choices: actionChoices, theme }))
|
|
184
|
+
if (action === Symbol.for('back') || action === 'back') return
|
|
185
|
+
|
|
186
|
+
if (action === 'provider') {
|
|
187
|
+
const cfg = loadConfig()
|
|
188
|
+
const allProviders = listAllProviders(cfg)
|
|
189
|
+
const providerChoices = [
|
|
190
|
+
{ name: 'Default (use pipeline provider)', value: '_default' },
|
|
191
|
+
...allProviders.map((p) => ({
|
|
192
|
+
name: formatProviderName(p),
|
|
193
|
+
value: p,
|
|
194
|
+
})),
|
|
195
|
+
{ name: dim('Back'), value: 'back' },
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
const chosen = await promptOrBack(() =>
|
|
199
|
+
select({
|
|
200
|
+
message: `Provider for ${enh.name}`,
|
|
201
|
+
choices: providerChoices,
|
|
202
|
+
default: enh.provider || '_default',
|
|
203
|
+
theme,
|
|
204
|
+
}),
|
|
205
|
+
)
|
|
206
|
+
if (chosen === Symbol.for('back') || chosen === 'back') return
|
|
207
|
+
|
|
208
|
+
const newProvider = chosen === '_default' ? null : chosen
|
|
209
|
+
|
|
210
|
+
// Update the .md file frontmatter (creates user copy if built-in)
|
|
211
|
+
const updatedEnh = { ...enh, provider: newProvider }
|
|
212
|
+
const content = buildFileContent(updatedEnh)
|
|
213
|
+
const dir = userEnhancementDir()
|
|
214
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
215
|
+
fs.writeFileSync(path.join(dir, `${key}.md`), content, 'utf8')
|
|
216
|
+
|
|
217
|
+
if (newProvider) {
|
|
218
|
+
console.log(green(` ✔ ${enh.name}: → ${newProvider}`))
|
|
219
|
+
} else {
|
|
220
|
+
console.log(green(` ✔ ${enh.name}: using default provider`))
|
|
221
|
+
}
|
|
222
|
+
if (enh.source === 'built-in') {
|
|
223
|
+
console.log(dim(` User copy created (overrides built-in)`))
|
|
224
|
+
}
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (action === 'delete') {
|
|
229
|
+
fs.unlinkSync(userFile)
|
|
230
|
+
console.log(green(` ✔ User copy of "${key}" deleted`))
|
|
231
|
+
if (builtInKeys.includes(key)) {
|
|
232
|
+
console.log(dim(` Built-in "${key}" will be used again.`))
|
|
233
|
+
}
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Edit — build the file content from the current enhancement
|
|
238
|
+
const currentContent = buildFileContent(enh)
|
|
239
|
+
|
|
240
|
+
const newBody = await promptOrBack(() =>
|
|
241
|
+
editor({
|
|
242
|
+
message: `Edit ${key}`,
|
|
243
|
+
default: currentContent,
|
|
244
|
+
theme,
|
|
245
|
+
}),
|
|
246
|
+
)
|
|
247
|
+
if (newBody === Symbol.for('back') || !newBody) return
|
|
248
|
+
|
|
249
|
+
// Save as user enhancement
|
|
250
|
+
const dir = userEnhancementDir()
|
|
251
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
252
|
+
fs.writeFileSync(path.join(dir, `${key}.md`), newBody, 'utf8')
|
|
253
|
+
|
|
254
|
+
if (enh.source === 'built-in') {
|
|
255
|
+
console.log(green(` ✔ User copy of "${key}" created (overrides built-in)`))
|
|
256
|
+
} else {
|
|
257
|
+
console.log(green(` ✔ Enhancement "${key}" updated`))
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function buildFileContent(enh) {
|
|
262
|
+
const lines = [
|
|
263
|
+
'---',
|
|
264
|
+
`name: ${enh.name}`,
|
|
265
|
+
`maxTokens: ${enh.maxTokens}`,
|
|
266
|
+
`mode: ${enh.mode}`,
|
|
267
|
+
`format: ${enh.format}`,
|
|
268
|
+
`contextKey: ${enh.contextKey}`,
|
|
269
|
+
`order: ${enh.order}`,
|
|
270
|
+
]
|
|
271
|
+
if (enh.provider) {
|
|
272
|
+
lines.push(`provider: ${enh.provider}`)
|
|
273
|
+
}
|
|
274
|
+
if (enh.requires?.length) {
|
|
275
|
+
lines.push(`requires: [${enh.requires.map((r) => `"${r}"`).join(', ')}]`)
|
|
276
|
+
} else {
|
|
277
|
+
lines.push('requires: []')
|
|
278
|
+
}
|
|
279
|
+
lines.push('---')
|
|
280
|
+
lines.push(enh.prompt)
|
|
281
|
+
return lines.join('\n')
|
|
282
|
+
}
|
|
@@ -16,27 +16,35 @@ export class CommandAdapter extends BaseAdapter {
|
|
|
16
16
|
const command = this.config.command
|
|
17
17
|
if (!command) throw new Error('Command not configured (ai.command)')
|
|
18
18
|
|
|
19
|
-
const timeoutMs =
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
const timeoutMs = this.config.timeout
|
|
20
|
+
|| (opts.maxTokens
|
|
21
|
+
? Math.max(DEFAULT_TIMEOUT_MS, opts.maxTokens * 15) // rough heuristic
|
|
22
|
+
: DEFAULT_TIMEOUT_MS)
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
// Extract the last user message as the prompt text
|
|
25
|
+
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user')
|
|
26
|
+
const promptText = lastUserMsg?.content || ''
|
|
27
|
+
|
|
28
|
+
// Check if command uses {prompt} placeholder (arg mode) or JSON stdin
|
|
29
|
+
const usesPlaceholder = command.includes('{prompt}')
|
|
30
|
+
|
|
31
|
+
const resolvedCommand = usesPlaceholder
|
|
32
|
+
? command.replace(/\{prompt\}/g, promptText)
|
|
33
|
+
: command
|
|
34
|
+
|
|
35
|
+
const payload = usesPlaceholder ? null : JSON.stringify({
|
|
24
36
|
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
25
37
|
model: opts.model || null,
|
|
26
38
|
maxTokens: opts.maxTokens || 4096,
|
|
27
39
|
})
|
|
28
40
|
|
|
29
41
|
return new Promise((resolve, reject) => {
|
|
30
|
-
//
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const args = parts.slice(1)
|
|
34
|
-
|
|
35
|
-
const isWindows = process.platform === 'win32'
|
|
36
|
-
const child = execFile(exe, args, {
|
|
42
|
+
// Always use shell to support quoted args, pipes, builtins, etc.
|
|
43
|
+
const shell = process.platform === 'win32' ? true : '/bin/sh'
|
|
44
|
+
const child = execFile('sh', ['-c', resolvedCommand], {
|
|
37
45
|
timeout: timeoutMs,
|
|
38
46
|
maxBuffer: 10 * 1024 * 1024, // 10 MB
|
|
39
|
-
shell:
|
|
47
|
+
shell: false, // we're already wrapping in sh -c
|
|
40
48
|
}, (err, stdout, stderr) => {
|
|
41
49
|
if (err) {
|
|
42
50
|
const msg = stderr?.trim() || err.message
|
|
@@ -51,7 +59,9 @@ export class CommandAdapter extends BaseAdapter {
|
|
|
51
59
|
resolve(text)
|
|
52
60
|
})
|
|
53
61
|
|
|
54
|
-
|
|
62
|
+
if (payload) {
|
|
63
|
+
child.stdin.write(payload)
|
|
64
|
+
}
|
|
55
65
|
child.stdin.end()
|
|
56
66
|
})
|
|
57
67
|
}
|
|
@@ -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:',
|
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
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge between enhancement descriptors and pipeline step objects.
|
|
3
|
+
*
|
|
4
|
+
* Converts enhancement descriptors (from enhancements.js) into step objects
|
|
5
|
+
* that pipeline.js can execute. Built-in steps retain their specialized
|
|
6
|
+
* shouldRun/parseResponse logic; custom steps get generic implementations.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { BUILT_IN_STEPS_MAP, tryParseJSON, contextSummary, priorStepsSummary } from './steps.js'
|
|
10
|
+
import { buildBodyGuidance } from './body-template.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Convert an enhancement descriptor into a pipeline step object.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} enhancement - descriptor from enhancements.js
|
|
16
|
+
* @returns {object} step object compatible with pipeline.js
|
|
17
|
+
*/
|
|
18
|
+
export function enhancementToStep(enhancement) {
|
|
19
|
+
const builtInStep = BUILT_IN_STEPS_MAP[enhancement.key]
|
|
20
|
+
|
|
21
|
+
// If this is a built-in enhancement, use the built-in step's specialized
|
|
22
|
+
// shouldRun and parseResponse, but allow the prompt to be overridden
|
|
23
|
+
if (builtInStep) {
|
|
24
|
+
const promptOverridden = enhancement.source && enhancement.source !== 'built-in'
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
name: enhancement.key,
|
|
28
|
+
displayName: enhancement.name,
|
|
29
|
+
maxTokens: enhancement.maxTokens,
|
|
30
|
+
provider: enhancement.provider || null,
|
|
31
|
+
|
|
32
|
+
shouldRun: builtInStep.shouldRun,
|
|
33
|
+
parseResponse: builtInStep.parseResponse,
|
|
34
|
+
|
|
35
|
+
buildMessages(ctx) {
|
|
36
|
+
if (promptOverridden && enhancement.prompt) {
|
|
37
|
+
// Use the user/repo-overridden prompt with the standard user message
|
|
38
|
+
return buildCustomMessages(enhancement, ctx)
|
|
39
|
+
}
|
|
40
|
+
return builtInStep.buildMessages(ctx)
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Custom enhancement — generic implementation
|
|
46
|
+
return {
|
|
47
|
+
name: enhancement.key,
|
|
48
|
+
displayName: enhancement.name,
|
|
49
|
+
maxTokens: enhancement.maxTokens,
|
|
50
|
+
provider: enhancement.provider || null,
|
|
51
|
+
|
|
52
|
+
shouldRun(ctx) {
|
|
53
|
+
// Check requires — all required context keys must be present
|
|
54
|
+
if (enhancement.requires?.length > 0) {
|
|
55
|
+
return enhancement.requires.every((key) => ctx[key] != null)
|
|
56
|
+
}
|
|
57
|
+
return true
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
buildMessages(ctx) {
|
|
61
|
+
return buildCustomMessages(enhancement, ctx)
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
parseResponse(raw, ctx) {
|
|
65
|
+
if (enhancement.format === 'json') {
|
|
66
|
+
try {
|
|
67
|
+
const parsed = tryParseJSON(raw)
|
|
68
|
+
ctx[enhancement.contextKey] = parsed
|
|
69
|
+
} catch {
|
|
70
|
+
ctx[enhancement.contextKey] = null
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
// markdown — store raw text
|
|
74
|
+
let cleaned = raw.trim()
|
|
75
|
+
const fenceMatch = cleaned.match(/^```(?:markdown|md)?\n([\s\S]*)\n```$/)
|
|
76
|
+
if (fenceMatch) cleaned = fenceMatch[1].trim()
|
|
77
|
+
if (cleaned.length > 0) {
|
|
78
|
+
ctx[enhancement.contextKey] = cleaned
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build messages for a custom or prompt-overridden enhancement.
|
|
87
|
+
*/
|
|
88
|
+
function buildCustomMessages(enhancement, ctx) {
|
|
89
|
+
let systemContent = enhancement.prompt
|
|
90
|
+
|
|
91
|
+
// For the format step, append body guidance
|
|
92
|
+
if (enhancement.key === 'format') {
|
|
93
|
+
const guidance = buildBodyGuidance(ctx)
|
|
94
|
+
systemContent = systemContent + '\n\n' + guidance
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// For the labels step, inject available labels
|
|
98
|
+
if (enhancement.key === 'labels' && ctx.repoLabels?.length) {
|
|
99
|
+
systemContent = systemContent.replace(
|
|
100
|
+
/Return ONLY valid JSON\.$/m,
|
|
101
|
+
`Available labels in this repo: ${ctx.repoLabels.join(', ')}\nReturn ONLY valid JSON.`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return [
|
|
106
|
+
{ role: 'system', content: systemContent },
|
|
107
|
+
{ role: 'user', content: contextSummary(ctx) + priorStepsSummary(ctx) },
|
|
108
|
+
]
|
|
109
|
+
}
|
package/src/lib/ai/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { resolveRoute, listProviders } from './router.js'
|
|
1
|
+
import { resolveRoute, resolveProviderAdapter, listProviders, listAllProviders } from './router.js'
|
|
2
2
|
import { buildMessages } from './prompt.js'
|
|
3
3
|
|
|
4
|
-
export { listProviders }
|
|
4
|
+
export { listProviders, listAllProviders, resolveProviderAdapter }
|
|
5
5
|
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
// Token usage tracking (in-memory, resets on process exit)
|
package/src/lib/ai/pipeline.js
CHANGED
|
@@ -5,19 +5,22 @@
|
|
|
5
5
|
* Failed steps log a warning and continue (graceful degradation).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { resolveProviderAdapter } from './router.js'
|
|
9
|
+
|
|
8
10
|
/**
|
|
9
11
|
* Run a sequence of pipeline steps.
|
|
10
12
|
*
|
|
11
13
|
* @param {object[]} steps - array of step objects (from steps.js)
|
|
12
14
|
* @param {object} ctx - mutable PipelineContext, accumulates results
|
|
13
15
|
* @param {{ adapter: object, model: string }} route - resolved adapter + model
|
|
16
|
+
* @param {object} [config] - merged config object (enables per-step provider overrides)
|
|
14
17
|
* @param {object} [budgets] - token budget config
|
|
15
18
|
* @param {object} [callbacks] - { onStepStart, onStepDone, onStepSkip, onStepFail }
|
|
16
19
|
* @param {{ checkBudgets: Function, recordUsage: Function }} [budgetHelpers]
|
|
17
20
|
* @returns {Promise<object>} the final ctx
|
|
18
21
|
*/
|
|
19
|
-
export async function runPipeline(steps, ctx, route, budgets, callbacks, budgetHelpers) {
|
|
20
|
-
const { adapter, model } = route
|
|
22
|
+
export async function runPipeline(steps, ctx, route, config, budgets, callbacks, budgetHelpers) {
|
|
23
|
+
const { adapter: defaultAdapter, model: defaultModel } = route
|
|
21
24
|
const cb = callbacks || {}
|
|
22
25
|
const { checkBudgets, recordUsage } = budgetHelpers || {}
|
|
23
26
|
|
|
@@ -43,6 +46,21 @@ export async function runPipeline(steps, ctx, route, budgets, callbacks, budgetH
|
|
|
43
46
|
|
|
44
47
|
cb.onStepStart?.(step)
|
|
45
48
|
|
|
49
|
+
// Resolve per-step adapter override
|
|
50
|
+
let adapter = defaultAdapter
|
|
51
|
+
let model = defaultModel
|
|
52
|
+
if (step.provider && config) {
|
|
53
|
+
try {
|
|
54
|
+
const override = resolveProviderAdapter(config, step.provider)
|
|
55
|
+
adapter = override.adapter
|
|
56
|
+
model = override.model
|
|
57
|
+
} catch (err) {
|
|
58
|
+
// Unknown/unconfigured provider — warn and fall back to default
|
|
59
|
+
cb.onStepFail?.(step, new Error(`Provider override "${step.provider}" failed: ${err.message}, using default`))
|
|
60
|
+
// Continue with default adapter (don't skip — aligns with "never lose data" principle)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
try {
|
|
47
65
|
// Check budget before the call (skip for format when budget already exhausted — it degrades gracefully)
|
|
48
66
|
if (checkBudgets && budgets && !budgetExhausted) {
|