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
package/src/lib/ai/router.js
CHANGED
|
@@ -4,6 +4,10 @@ import { GeminiAdapter } from './adapters/gemini.js'
|
|
|
4
4
|
import { OllamaAdapter } from './adapters/ollama.js'
|
|
5
5
|
import { OpenAICompatAdapter } from './adapters/openai-compat.js'
|
|
6
6
|
import { CommandAdapter } from './adapters/command.js'
|
|
7
|
+
import { GeminiCliAdapter } from './adapters/gemini-cli.js'
|
|
8
|
+
import { ClaudeCliAdapter } from './adapters/claude-cli.js'
|
|
9
|
+
import { CodexCliAdapter } from './adapters/codex-cli.js'
|
|
10
|
+
import { OpenClawAdapter } from './adapters/openclaw.js'
|
|
7
11
|
|
|
8
12
|
const ADAPTER_MAP = {
|
|
9
13
|
anthropic: AnthropicAdapter,
|
|
@@ -12,6 +16,38 @@ const ADAPTER_MAP = {
|
|
|
12
16
|
ollama: OllamaAdapter,
|
|
13
17
|
'openai-compat': OpenAICompatAdapter,
|
|
14
18
|
command: CommandAdapter,
|
|
19
|
+
'gemini-cli': GeminiCliAdapter,
|
|
20
|
+
'claude-cli': ClaudeCliAdapter,
|
|
21
|
+
'codex-cli': CodexCliAdapter,
|
|
22
|
+
openclaw: OpenClawAdapter,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Merge custom providers from both old (`ai.commands`) and new (`ai.providers`) keys,
|
|
27
|
+
* and migrate the legacy singleton `ai.command` into the registry.
|
|
28
|
+
*
|
|
29
|
+
* `ai.providers` wins on conflict with `ai.commands`.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} ai - the `config.ai` object
|
|
32
|
+
* @returns {object} merged custom providers map
|
|
33
|
+
*/
|
|
34
|
+
export function migrateProviderConfig(ai) {
|
|
35
|
+
const merged = {}
|
|
36
|
+
|
|
37
|
+
// Legacy: ai.commands (old key)
|
|
38
|
+
if (ai.commands && typeof ai.commands === 'object') {
|
|
39
|
+
Object.assign(merged, ai.commands)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Canonical: ai.providers (new key, wins on conflict)
|
|
43
|
+
if (ai.providers && typeof ai.providers === 'object') {
|
|
44
|
+
Object.assign(merged, ai.providers)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Note: ai.command (legacy singleton) is NOT merged here — the built-in
|
|
48
|
+
// "command" provider already reads it via buildAdapterConfig.
|
|
49
|
+
|
|
50
|
+
return merged
|
|
15
51
|
}
|
|
16
52
|
|
|
17
53
|
/**
|
|
@@ -23,14 +59,15 @@ const ADAPTER_MAP = {
|
|
|
23
59
|
* 3. Default provider + model from config
|
|
24
60
|
*
|
|
25
61
|
* @param {object} config - merged config object
|
|
26
|
-
* @param {{ template?: string, labels?: string[], provider?: string, model?: string }} context
|
|
27
|
-
* @returns {{ adapter: import('./adapters/base.js').BaseAdapter, model: string }}
|
|
62
|
+
* @param {{ template?: string, labels?: string[], provider?: string, model?: string, enhancements?: string[] }} context
|
|
63
|
+
* @returns {{ adapter: import('./adapters/base.js').BaseAdapter, model: string, enhancements?: string[] }}
|
|
28
64
|
*/
|
|
29
65
|
export function resolveRoute(config, context = {}) {
|
|
30
66
|
const ai = config.ai || {}
|
|
31
67
|
|
|
32
68
|
let provider = context.provider || null
|
|
33
69
|
let model = context.model || null
|
|
70
|
+
let enhancements = null
|
|
34
71
|
|
|
35
72
|
// 2. Check routing rules (only if no explicit override)
|
|
36
73
|
if (!provider && Array.isArray(ai.routes)) {
|
|
@@ -38,6 +75,7 @@ export function resolveRoute(config, context = {}) {
|
|
|
38
75
|
if (matchesRule(rule.match, context)) {
|
|
39
76
|
provider = rule.provider || provider
|
|
40
77
|
model = rule.model || model
|
|
78
|
+
if (rule.enhancements) enhancements = rule.enhancements
|
|
41
79
|
break
|
|
42
80
|
}
|
|
43
81
|
}
|
|
@@ -47,12 +85,57 @@ export function resolveRoute(config, context = {}) {
|
|
|
47
85
|
if (!provider) provider = ai.provider || 'anthropic'
|
|
48
86
|
if (!model) model = ai.model || ai.models?.[provider] || null
|
|
49
87
|
|
|
50
|
-
|
|
51
|
-
|
|
88
|
+
let AdapterClass = ADAPTER_MAP[provider]
|
|
89
|
+
let adapterConfig
|
|
90
|
+
|
|
91
|
+
if (!AdapterClass) {
|
|
92
|
+
// Check custom providers registry (merged from ai.commands + ai.providers)
|
|
93
|
+
const customProviders = migrateProviderConfig(ai)
|
|
94
|
+
const cmdEntry = customProviders[provider]
|
|
95
|
+
if (!cmdEntry) throw new Error(`Unknown AI provider: ${provider}`)
|
|
96
|
+
AdapterClass = CommandAdapter
|
|
97
|
+
adapterConfig = { command: cmdEntry.command || null, timeout: cmdEntry.timeout || undefined }
|
|
98
|
+
} else {
|
|
99
|
+
// Build adapter config based on provider type
|
|
100
|
+
adapterConfig = buildAdapterConfig(ai, provider)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const adapter = new AdapterClass(adapterConfig)
|
|
104
|
+
|
|
105
|
+
const result = { adapter, model }
|
|
106
|
+
if (enhancements) result.enhancements = enhancements
|
|
107
|
+
return result
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve an adapter + model for a specific provider name.
|
|
112
|
+
*
|
|
113
|
+
* Checks ADAPTER_MAP first, then the merged custom providers registry.
|
|
114
|
+
* Used by the pipeline for per-step provider overrides.
|
|
115
|
+
*
|
|
116
|
+
* @param {object} config - merged config object
|
|
117
|
+
* @param {string} providerName - provider to resolve
|
|
118
|
+
* @param {string} [modelOverride] - optional model override
|
|
119
|
+
* @returns {{ adapter: import('./adapters/base.js').BaseAdapter, model: string }}
|
|
120
|
+
*/
|
|
121
|
+
export function resolveProviderAdapter(config, providerName, modelOverride) {
|
|
122
|
+
const ai = config.ai || {}
|
|
123
|
+
|
|
124
|
+
let AdapterClass = ADAPTER_MAP[providerName]
|
|
125
|
+
let adapterConfig
|
|
126
|
+
|
|
127
|
+
if (!AdapterClass) {
|
|
128
|
+
const customProviders = migrateProviderConfig(ai)
|
|
129
|
+
const cmdEntry = customProviders[providerName]
|
|
130
|
+
if (!cmdEntry) throw new Error(`Unknown AI provider: ${providerName}`)
|
|
131
|
+
AdapterClass = CommandAdapter
|
|
132
|
+
adapterConfig = { command: cmdEntry.command || null, timeout: cmdEntry.timeout || undefined }
|
|
133
|
+
} else {
|
|
134
|
+
adapterConfig = buildAdapterConfig(ai, providerName)
|
|
135
|
+
}
|
|
52
136
|
|
|
53
|
-
// Build adapter config based on provider type
|
|
54
|
-
const adapterConfig = buildAdapterConfig(ai, provider)
|
|
55
137
|
const adapter = new AdapterClass(adapterConfig)
|
|
138
|
+
const model = modelOverride || ai.model || ai.models?.[providerName] || null
|
|
56
139
|
|
|
57
140
|
return { adapter, model }
|
|
58
141
|
}
|
|
@@ -89,6 +172,21 @@ function buildAdapterConfig(ai, provider) {
|
|
|
89
172
|
return { baseUrl: ai.custom?.url, apiKey: resolveApiKey(ai, 'openai-compat') }
|
|
90
173
|
case 'command':
|
|
91
174
|
return { command: ai.command || null }
|
|
175
|
+
case 'gemini-cli':
|
|
176
|
+
return { binary: 'gemini', model: ai.models?.['gemini-cli'] || null }
|
|
177
|
+
case 'claude-cli':
|
|
178
|
+
return { binary: 'claude', model: ai.models?.['claude-cli'] || null }
|
|
179
|
+
case 'codex-cli':
|
|
180
|
+
return { binary: 'codex', model: ai.models?.['codex-cli'] || null }
|
|
181
|
+
case 'openclaw': {
|
|
182
|
+
const port = process.env.OPENCLAW_GATEWAY_PORT || '18790'
|
|
183
|
+
return {
|
|
184
|
+
gatewayUrl: ai.openclaw?.gatewayUrl || `http://localhost:${port}`,
|
|
185
|
+
token: ai.openclaw?.token || process.env.OPENCLAW_GATEWAY_TOKEN || null,
|
|
186
|
+
agentId: ai.openclaw?.agentId || null,
|
|
187
|
+
model: ai.models?.openclaw || null,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
92
190
|
default:
|
|
93
191
|
return { apiKey: resolveApiKey(ai, provider) }
|
|
94
192
|
}
|
|
@@ -120,9 +218,22 @@ function matchesRule(match, context) {
|
|
|
120
218
|
}
|
|
121
219
|
|
|
122
220
|
/**
|
|
123
|
-
* List
|
|
221
|
+
* List built-in provider names.
|
|
124
222
|
* @returns {string[]}
|
|
125
223
|
*/
|
|
126
224
|
export function listProviders() {
|
|
127
225
|
return Object.keys(ADAPTER_MAP)
|
|
128
226
|
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* List all available provider names: built-in + custom providers from config.
|
|
230
|
+
* @param {object} config - merged config object
|
|
231
|
+
* @returns {string[]}
|
|
232
|
+
*/
|
|
233
|
+
export function listAllProviders(config) {
|
|
234
|
+
const builtIn = Object.keys(ADAPTER_MAP)
|
|
235
|
+
const ai = config?.ai || {}
|
|
236
|
+
const customProviders = migrateProviderConfig(ai)
|
|
237
|
+
const custom = Object.keys(customProviders)
|
|
238
|
+
return [...builtIn, ...custom.filter((k) => !builtIn.includes(k))]
|
|
239
|
+
}
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { resolveRoute, resolveProviderAdapter, listProviders, listAllProviders, migrateProviderConfig } from './router.js'
|
|
4
|
+
|
|
5
|
+
const baseConfig = {
|
|
6
|
+
ai: {
|
|
7
|
+
enabled: true,
|
|
8
|
+
provider: 'anthropic',
|
|
9
|
+
model: null,
|
|
10
|
+
models: {
|
|
11
|
+
anthropic: 'claude-haiku-4-5-20251001',
|
|
12
|
+
openai: 'gpt-4o-mini',
|
|
13
|
+
gemini: 'gemini-2.0-flash',
|
|
14
|
+
},
|
|
15
|
+
keys: {
|
|
16
|
+
anthropic: 'sk-ant-test',
|
|
17
|
+
openai: 'sk-openai-test',
|
|
18
|
+
gemini: 'gm-test',
|
|
19
|
+
},
|
|
20
|
+
routes: [
|
|
21
|
+
{ match: { template: 'bug' }, provider: 'gemini', model: 'gemini-2.0-flash' },
|
|
22
|
+
{ match: { template: 'security' }, provider: 'anthropic', model: 'claude-sonnet-4-5-20241022' },
|
|
23
|
+
{ match: { labels: ['P0-critical'] }, provider: 'anthropic', model: 'claude-opus-4-6' },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('resolveRoute', () => {
|
|
29
|
+
it('uses default provider when no context', () => {
|
|
30
|
+
const { adapter, model } = resolveRoute(baseConfig, {})
|
|
31
|
+
assert.equal(adapter.name, 'anthropic')
|
|
32
|
+
assert.equal(model, 'claude-haiku-4-5-20251001')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('matches template routing rule', () => {
|
|
36
|
+
const { adapter, model } = resolveRoute(baseConfig, { template: 'bug' })
|
|
37
|
+
assert.equal(adapter.name, 'gemini')
|
|
38
|
+
assert.equal(model, 'gemini-2.0-flash')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('matches security template routing rule', () => {
|
|
42
|
+
const { adapter, model } = resolveRoute(baseConfig, { template: 'security' })
|
|
43
|
+
assert.equal(adapter.name, 'anthropic')
|
|
44
|
+
assert.equal(model, 'claude-sonnet-4-5-20241022')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('matches label routing rule', () => {
|
|
48
|
+
const { adapter, model } = resolveRoute(baseConfig, { labels: ['P0-critical', 'urgent'] })
|
|
49
|
+
assert.equal(adapter.name, 'anthropic')
|
|
50
|
+
assert.equal(model, 'claude-opus-4-6')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('explicit provider overrides routes', () => {
|
|
54
|
+
const { adapter, model } = resolveRoute(baseConfig, {
|
|
55
|
+
template: 'bug',
|
|
56
|
+
provider: 'openai',
|
|
57
|
+
model: 'gpt-4o',
|
|
58
|
+
})
|
|
59
|
+
assert.equal(adapter.name, 'openai')
|
|
60
|
+
assert.equal(model, 'gpt-4o')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('falls through to default when no rule matches', () => {
|
|
64
|
+
const { adapter, model } = resolveRoute(baseConfig, { template: 'feature' })
|
|
65
|
+
assert.equal(adapter.name, 'anthropic')
|
|
66
|
+
assert.equal(model, 'claude-haiku-4-5-20251001')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('throws on unknown provider', () => {
|
|
70
|
+
assert.throws(
|
|
71
|
+
() => resolveRoute(baseConfig, { provider: 'unknown' }),
|
|
72
|
+
/Unknown AI provider: unknown/,
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('first matching rule wins', () => {
|
|
77
|
+
const config = {
|
|
78
|
+
ai: {
|
|
79
|
+
...baseConfig.ai,
|
|
80
|
+
routes: [
|
|
81
|
+
{ match: { template: 'bug' }, provider: 'openai' },
|
|
82
|
+
{ match: { template: 'bug' }, provider: 'gemini' },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
const { adapter } = resolveRoute(config, { template: 'bug' })
|
|
87
|
+
assert.equal(adapter.name, 'openai')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('resolveRoute — ollama', () => {
|
|
92
|
+
it('creates OllamaAdapter with baseUrl from config', () => {
|
|
93
|
+
const config = {
|
|
94
|
+
ai: {
|
|
95
|
+
provider: 'ollama',
|
|
96
|
+
models: { ollama: 'mistral' },
|
|
97
|
+
ollama: { url: 'http://myhost:11434' },
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
const { adapter, model } = resolveRoute(config, {})
|
|
101
|
+
assert.equal(adapter.name, 'ollama')
|
|
102
|
+
assert.equal(model, 'mistral')
|
|
103
|
+
assert.equal(adapter.config.baseUrl, 'http://myhost:11434')
|
|
104
|
+
assert.equal(adapter.isConfigured(), true)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('resolveRoute — openai-compat', () => {
|
|
109
|
+
it('creates OpenAICompatAdapter with baseUrl and optional key', () => {
|
|
110
|
+
const config = {
|
|
111
|
+
ai: {
|
|
112
|
+
provider: 'openai-compat',
|
|
113
|
+
models: { 'openai-compat': 'my-model' },
|
|
114
|
+
custom: { url: 'http://localhost:8080' },
|
|
115
|
+
keys: { 'openai-compat': 'sk-test' },
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
const { adapter, model } = resolveRoute(config, {})
|
|
119
|
+
assert.equal(adapter.name, 'openai-compat')
|
|
120
|
+
assert.equal(model, 'my-model')
|
|
121
|
+
assert.equal(adapter.config.baseUrl, 'http://localhost:8080')
|
|
122
|
+
assert.equal(adapter.config.apiKey, 'sk-test')
|
|
123
|
+
assert.equal(adapter.isConfigured(), true)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('isConfigured returns false without baseUrl', () => {
|
|
127
|
+
const config = {
|
|
128
|
+
ai: {
|
|
129
|
+
provider: 'openai-compat',
|
|
130
|
+
custom: { url: null },
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
const { adapter } = resolveRoute(config, {})
|
|
134
|
+
assert.equal(adapter.isConfigured(), false)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('resolveRoute — command', () => {
|
|
139
|
+
it('creates CommandAdapter with command from config', () => {
|
|
140
|
+
const config = {
|
|
141
|
+
ai: {
|
|
142
|
+
provider: 'command',
|
|
143
|
+
command: 'my-ai-tool enhance',
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
const { adapter } = resolveRoute(config, {})
|
|
147
|
+
assert.equal(adapter.name, 'command')
|
|
148
|
+
assert.equal(adapter.config.command, 'my-ai-tool enhance')
|
|
149
|
+
assert.equal(adapter.isConfigured(), true)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('isConfigured returns false without command', () => {
|
|
153
|
+
const config = {
|
|
154
|
+
ai: {
|
|
155
|
+
provider: 'command',
|
|
156
|
+
command: null,
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
const { adapter } = resolveRoute(config, {})
|
|
160
|
+
assert.equal(adapter.isConfigured(), false)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('listProviders', () => {
|
|
165
|
+
it('returns known providers', () => {
|
|
166
|
+
const providers = listProviders()
|
|
167
|
+
assert.ok(providers.includes('anthropic'))
|
|
168
|
+
assert.ok(providers.includes('openai'))
|
|
169
|
+
assert.ok(providers.includes('gemini'))
|
|
170
|
+
assert.ok(providers.includes('ollama'))
|
|
171
|
+
assert.ok(providers.includes('openai-compat'))
|
|
172
|
+
assert.ok(providers.includes('command'))
|
|
173
|
+
assert.ok(providers.includes('gemini-cli'))
|
|
174
|
+
assert.ok(providers.includes('claude-cli'))
|
|
175
|
+
assert.ok(providers.includes('codex-cli'))
|
|
176
|
+
assert.ok(providers.includes('openclaw'))
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('resolveRoute — named CLI commands (legacy ai.commands)', () => {
|
|
181
|
+
it('resolves a named command from ai.commands to CommandAdapter', () => {
|
|
182
|
+
const config = {
|
|
183
|
+
ai: {
|
|
184
|
+
provider: 'my-cli',
|
|
185
|
+
commands: {
|
|
186
|
+
'my-cli': { command: 'co gemini', timeout: 120000 },
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
const { adapter } = resolveRoute(config, {})
|
|
191
|
+
assert.equal(adapter.name, 'command')
|
|
192
|
+
assert.equal(adapter.config.command, 'co gemini')
|
|
193
|
+
assert.equal(adapter.config.timeout, 120000)
|
|
194
|
+
assert.equal(adapter.isConfigured(), true)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('named command works via explicit --provider flag', () => {
|
|
198
|
+
const config = {
|
|
199
|
+
ai: {
|
|
200
|
+
provider: 'anthropic',
|
|
201
|
+
keys: { anthropic: 'sk-test' },
|
|
202
|
+
commands: {
|
|
203
|
+
'my-claude': { command: 'claude --print' },
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
const { adapter } = resolveRoute(config, { provider: 'my-claude' })
|
|
208
|
+
assert.equal(adapter.name, 'command')
|
|
209
|
+
assert.equal(adapter.config.command, 'claude --print')
|
|
210
|
+
assert.equal(adapter.config.timeout, undefined)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('named command works in routing rules', () => {
|
|
214
|
+
const config = {
|
|
215
|
+
ai: {
|
|
216
|
+
provider: 'anthropic',
|
|
217
|
+
keys: { anthropic: 'sk-test' },
|
|
218
|
+
commands: {
|
|
219
|
+
'my-gemini': { command: 'co gemini' },
|
|
220
|
+
},
|
|
221
|
+
routes: [
|
|
222
|
+
{ match: { template: 'bug' }, provider: 'my-gemini' },
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
const { adapter } = resolveRoute(config, { template: 'bug' })
|
|
227
|
+
assert.equal(adapter.name, 'command')
|
|
228
|
+
assert.equal(adapter.config.command, 'co gemini')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('legacy provider: "command" still works', () => {
|
|
232
|
+
const config = {
|
|
233
|
+
ai: {
|
|
234
|
+
provider: 'command',
|
|
235
|
+
command: 'old-tool',
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
const { adapter } = resolveRoute(config, {})
|
|
239
|
+
assert.equal(adapter.name, 'command')
|
|
240
|
+
assert.equal(adapter.config.command, 'old-tool')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('throws on unknown provider not in commands', () => {
|
|
244
|
+
const config = {
|
|
245
|
+
ai: {
|
|
246
|
+
provider: 'nonexistent',
|
|
247
|
+
commands: {},
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
assert.throws(
|
|
251
|
+
() => resolveRoute(config, {}),
|
|
252
|
+
/Unknown AI provider: nonexistent/,
|
|
253
|
+
)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('named command without command string is not configured', () => {
|
|
257
|
+
const config = {
|
|
258
|
+
ai: {
|
|
259
|
+
provider: 'bad-cmd',
|
|
260
|
+
commands: {
|
|
261
|
+
'bad-cmd': {},
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
const { adapter } = resolveRoute(config, {})
|
|
266
|
+
assert.equal(adapter.name, 'command')
|
|
267
|
+
assert.equal(adapter.isConfigured(), false)
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
describe('resolveRoute — ai.providers (new canonical key)', () => {
|
|
272
|
+
it('resolves a provider from ai.providers', () => {
|
|
273
|
+
const config = {
|
|
274
|
+
ai: {
|
|
275
|
+
provider: 'my-tool',
|
|
276
|
+
providers: {
|
|
277
|
+
'my-tool': { command: 'my-tool enhance', timeout: 30000 },
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
const { adapter } = resolveRoute(config, {})
|
|
282
|
+
assert.equal(adapter.name, 'command')
|
|
283
|
+
assert.equal(adapter.config.command, 'my-tool enhance')
|
|
284
|
+
assert.equal(adapter.config.timeout, 30000)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('ai.providers wins over ai.commands on conflict', () => {
|
|
288
|
+
const config = {
|
|
289
|
+
ai: {
|
|
290
|
+
provider: 'my-tool',
|
|
291
|
+
commands: {
|
|
292
|
+
'my-tool': { command: 'old-command' },
|
|
293
|
+
},
|
|
294
|
+
providers: {
|
|
295
|
+
'my-tool': { command: 'new-command' },
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
}
|
|
299
|
+
const { adapter } = resolveRoute(config, {})
|
|
300
|
+
assert.equal(adapter.config.command, 'new-command')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('ai.commands entries still work alongside ai.providers', () => {
|
|
304
|
+
const config = {
|
|
305
|
+
ai: {
|
|
306
|
+
provider: 'anthropic',
|
|
307
|
+
keys: { anthropic: 'sk-test' },
|
|
308
|
+
commands: {
|
|
309
|
+
'old-tool': { command: 'old cmd' },
|
|
310
|
+
},
|
|
311
|
+
providers: {
|
|
312
|
+
'new-tool': { command: 'new cmd' },
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
// old-tool from commands still works
|
|
317
|
+
const { adapter: old } = resolveRoute(config, { provider: 'old-tool' })
|
|
318
|
+
assert.equal(old.config.command, 'old cmd')
|
|
319
|
+
// new-tool from providers works
|
|
320
|
+
const { adapter: nw } = resolveRoute(config, { provider: 'new-tool' })
|
|
321
|
+
assert.equal(nw.config.command, 'new cmd')
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
describe('migrateProviderConfig', () => {
|
|
326
|
+
it('merges ai.commands and ai.providers', () => {
|
|
327
|
+
const ai = {
|
|
328
|
+
commands: { a: { command: 'cmd-a' } },
|
|
329
|
+
providers: { b: { command: 'cmd-b' } },
|
|
330
|
+
}
|
|
331
|
+
const merged = migrateProviderConfig(ai)
|
|
332
|
+
assert.equal(merged.a.command, 'cmd-a')
|
|
333
|
+
assert.equal(merged.b.command, 'cmd-b')
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('ai.providers wins on conflict', () => {
|
|
337
|
+
const ai = {
|
|
338
|
+
commands: { x: { command: 'old' } },
|
|
339
|
+
providers: { x: { command: 'new' } },
|
|
340
|
+
}
|
|
341
|
+
const merged = migrateProviderConfig(ai)
|
|
342
|
+
assert.equal(merged.x.command, 'new')
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('does not include ai.command (handled by built-in command provider)', () => {
|
|
346
|
+
const ai = { command: 'legacy-tool' }
|
|
347
|
+
const merged = migrateProviderConfig(ai)
|
|
348
|
+
assert.deepEqual(merged, {})
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('works with empty ai object', () => {
|
|
352
|
+
const merged = migrateProviderConfig({})
|
|
353
|
+
assert.deepEqual(merged, {})
|
|
354
|
+
})
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
describe('resolveProviderAdapter', () => {
|
|
358
|
+
it('resolves a built-in provider', () => {
|
|
359
|
+
const config = {
|
|
360
|
+
ai: {
|
|
361
|
+
keys: { anthropic: 'sk-test' },
|
|
362
|
+
models: { anthropic: 'claude-haiku-4-5-20251001' },
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
const { adapter, model } = resolveProviderAdapter(config, 'anthropic')
|
|
366
|
+
assert.equal(adapter.name, 'anthropic')
|
|
367
|
+
assert.equal(model, 'claude-haiku-4-5-20251001')
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('resolves a custom provider from ai.providers', () => {
|
|
371
|
+
const config = {
|
|
372
|
+
ai: {
|
|
373
|
+
providers: {
|
|
374
|
+
'my-cli': { command: 'my-tool run', timeout: 5000 },
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
}
|
|
378
|
+
const { adapter } = resolveProviderAdapter(config, 'my-cli')
|
|
379
|
+
assert.equal(adapter.name, 'command')
|
|
380
|
+
assert.equal(adapter.config.command, 'my-tool run')
|
|
381
|
+
assert.equal(adapter.config.timeout, 5000)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it('resolves a custom provider from legacy ai.commands', () => {
|
|
385
|
+
const config = {
|
|
386
|
+
ai: {
|
|
387
|
+
commands: {
|
|
388
|
+
'old-cli': { command: 'old-tool' },
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
}
|
|
392
|
+
const { adapter } = resolveProviderAdapter(config, 'old-cli')
|
|
393
|
+
assert.equal(adapter.name, 'command')
|
|
394
|
+
assert.equal(adapter.config.command, 'old-tool')
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('applies model override', () => {
|
|
398
|
+
const config = {
|
|
399
|
+
ai: {
|
|
400
|
+
keys: { openai: 'sk-test' },
|
|
401
|
+
models: { openai: 'gpt-4o-mini' },
|
|
402
|
+
},
|
|
403
|
+
}
|
|
404
|
+
const { model } = resolveProviderAdapter(config, 'openai', 'gpt-4o')
|
|
405
|
+
assert.equal(model, 'gpt-4o')
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('throws on unknown provider', () => {
|
|
409
|
+
assert.throws(
|
|
410
|
+
() => resolveProviderAdapter({ ai: {} }, 'nonexistent'),
|
|
411
|
+
/Unknown AI provider: nonexistent/,
|
|
412
|
+
)
|
|
413
|
+
})
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
describe('listAllProviders', () => {
|
|
417
|
+
it('includes built-in providers and named commands', () => {
|
|
418
|
+
const config = {
|
|
419
|
+
ai: {
|
|
420
|
+
commands: {
|
|
421
|
+
'my-gemini': { command: 'co gemini' },
|
|
422
|
+
'my-claude': { command: 'claude --print' },
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
}
|
|
426
|
+
const all = listAllProviders(config)
|
|
427
|
+
assert.ok(all.includes('anthropic'))
|
|
428
|
+
assert.ok(all.includes('openai'))
|
|
429
|
+
assert.ok(all.includes('command'))
|
|
430
|
+
assert.ok(all.includes('my-gemini'))
|
|
431
|
+
assert.ok(all.includes('my-claude'))
|
|
432
|
+
// New built-in CLI adapters
|
|
433
|
+
assert.ok(all.includes('gemini-cli'))
|
|
434
|
+
assert.ok(all.includes('claude-cli'))
|
|
435
|
+
assert.ok(all.includes('codex-cli'))
|
|
436
|
+
assert.ok(all.includes('openclaw'))
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it('includes providers from ai.providers', () => {
|
|
440
|
+
const config = {
|
|
441
|
+
ai: {
|
|
442
|
+
providers: {
|
|
443
|
+
'new-tool': { command: 'new cmd' },
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
}
|
|
447
|
+
const all = listAllProviders(config)
|
|
448
|
+
assert.ok(all.includes('new-tool'))
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('merges ai.commands and ai.providers in listing', () => {
|
|
452
|
+
const config = {
|
|
453
|
+
ai: {
|
|
454
|
+
commands: { 'old-tool': { command: 'old' } },
|
|
455
|
+
providers: { 'new-tool': { command: 'new' } },
|
|
456
|
+
},
|
|
457
|
+
}
|
|
458
|
+
const all = listAllProviders(config)
|
|
459
|
+
assert.ok(all.includes('old-tool'))
|
|
460
|
+
assert.ok(all.includes('new-tool'))
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('does not duplicate built-in names', () => {
|
|
464
|
+
const config = {
|
|
465
|
+
ai: {
|
|
466
|
+
commands: {
|
|
467
|
+
anthropic: { command: 'echo test' },
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
}
|
|
471
|
+
const all = listAllProviders(config)
|
|
472
|
+
const count = all.filter((p) => p === 'anthropic').length
|
|
473
|
+
assert.equal(count, 1)
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('works with no commands configured', () => {
|
|
477
|
+
const all = listAllProviders({ ai: {} })
|
|
478
|
+
assert.ok(all.includes('anthropic'))
|
|
479
|
+
assert.ok(all.length >= 10)
|
|
480
|
+
})
|
|
481
|
+
})
|
package/src/lib/ai/steps.js
CHANGED
|
@@ -114,9 +114,11 @@ const dedupStep = {
|
|
|
114
114
|
displayName: 'Duplicate check',
|
|
115
115
|
maxTokens: 1024,
|
|
116
116
|
|
|
117
|
-
shouldRun(
|
|
118
|
-
//
|
|
119
|
-
|
|
117
|
+
shouldRun() {
|
|
118
|
+
// Disabled: deterministic dedup (Jaccard) runs before the pipeline.
|
|
119
|
+
// The AI step hallucinated connections between unrelated issues (#56).
|
|
120
|
+
// Keep the code for future opt-in via config.
|
|
121
|
+
return false
|
|
120
122
|
},
|
|
121
123
|
|
|
122
124
|
buildMessages(ctx) {
|
|
@@ -462,6 +464,21 @@ export const ALL_STEPS = [
|
|
|
462
464
|
formatStep,
|
|
463
465
|
]
|
|
464
466
|
|
|
467
|
+
/**
|
|
468
|
+
* Map of built-in step name → step object.
|
|
469
|
+
* Used by the enhancement adapter to bridge built-in logic with custom prompts.
|
|
470
|
+
*/
|
|
471
|
+
export const BUILT_IN_STEPS_MAP = {
|
|
472
|
+
triage: triageStep,
|
|
473
|
+
dedup: dedupStep,
|
|
474
|
+
context: contextStep,
|
|
475
|
+
scope: scopeStep,
|
|
476
|
+
complexity: complexityStep,
|
|
477
|
+
risk: riskStep,
|
|
478
|
+
labels: labelsStep,
|
|
479
|
+
format: formatStep,
|
|
480
|
+
}
|
|
481
|
+
|
|
465
482
|
/**
|
|
466
483
|
* Get a step by name.
|
|
467
484
|
* @param {string} name
|
|
@@ -470,3 +487,6 @@ export const ALL_STEPS = [
|
|
|
470
487
|
export function getStep(name) {
|
|
471
488
|
return ALL_STEPS.find((s) => s.name === name)
|
|
472
489
|
}
|
|
490
|
+
|
|
491
|
+
// Re-export helpers for use by the enhancement adapter
|
|
492
|
+
export { tryParseJSON, contextSummary, priorStepsSummary }
|