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
package/src/lib/ai/router.js
CHANGED
|
@@ -14,6 +14,34 @@ const ADAPTER_MAP = {
|
|
|
14
14
|
command: CommandAdapter,
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Merge custom providers from both old (`ai.commands`) and new (`ai.providers`) keys,
|
|
19
|
+
* and migrate the legacy singleton `ai.command` into the registry.
|
|
20
|
+
*
|
|
21
|
+
* `ai.providers` wins on conflict with `ai.commands`.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} ai - the `config.ai` object
|
|
24
|
+
* @returns {object} merged custom providers map
|
|
25
|
+
*/
|
|
26
|
+
export function migrateProviderConfig(ai) {
|
|
27
|
+
const merged = {}
|
|
28
|
+
|
|
29
|
+
// Legacy: ai.commands (old key)
|
|
30
|
+
if (ai.commands && typeof ai.commands === 'object') {
|
|
31
|
+
Object.assign(merged, ai.commands)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Canonical: ai.providers (new key, wins on conflict)
|
|
35
|
+
if (ai.providers && typeof ai.providers === 'object') {
|
|
36
|
+
Object.assign(merged, ai.providers)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Note: ai.command (legacy singleton) is NOT merged here — the built-in
|
|
40
|
+
// "command" provider already reads it via buildAdapterConfig.
|
|
41
|
+
|
|
42
|
+
return merged
|
|
43
|
+
}
|
|
44
|
+
|
|
17
45
|
/**
|
|
18
46
|
* Resolve which adapter + model to use for a given issue context.
|
|
19
47
|
*
|
|
@@ -23,14 +51,15 @@ const ADAPTER_MAP = {
|
|
|
23
51
|
* 3. Default provider + model from config
|
|
24
52
|
*
|
|
25
53
|
* @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 }}
|
|
54
|
+
* @param {{ template?: string, labels?: string[], provider?: string, model?: string, enhancements?: string[] }} context
|
|
55
|
+
* @returns {{ adapter: import('./adapters/base.js').BaseAdapter, model: string, enhancements?: string[] }}
|
|
28
56
|
*/
|
|
29
57
|
export function resolveRoute(config, context = {}) {
|
|
30
58
|
const ai = config.ai || {}
|
|
31
59
|
|
|
32
60
|
let provider = context.provider || null
|
|
33
61
|
let model = context.model || null
|
|
62
|
+
let enhancements = null
|
|
34
63
|
|
|
35
64
|
// 2. Check routing rules (only if no explicit override)
|
|
36
65
|
if (!provider && Array.isArray(ai.routes)) {
|
|
@@ -38,6 +67,7 @@ export function resolveRoute(config, context = {}) {
|
|
|
38
67
|
if (matchesRule(rule.match, context)) {
|
|
39
68
|
provider = rule.provider || provider
|
|
40
69
|
model = rule.model || model
|
|
70
|
+
if (rule.enhancements) enhancements = rule.enhancements
|
|
41
71
|
break
|
|
42
72
|
}
|
|
43
73
|
}
|
|
@@ -47,12 +77,57 @@ export function resolveRoute(config, context = {}) {
|
|
|
47
77
|
if (!provider) provider = ai.provider || 'anthropic'
|
|
48
78
|
if (!model) model = ai.model || ai.models?.[provider] || null
|
|
49
79
|
|
|
50
|
-
|
|
51
|
-
|
|
80
|
+
let AdapterClass = ADAPTER_MAP[provider]
|
|
81
|
+
let adapterConfig
|
|
82
|
+
|
|
83
|
+
if (!AdapterClass) {
|
|
84
|
+
// Check custom providers registry (merged from ai.commands + ai.providers)
|
|
85
|
+
const customProviders = migrateProviderConfig(ai)
|
|
86
|
+
const cmdEntry = customProviders[provider]
|
|
87
|
+
if (!cmdEntry) throw new Error(`Unknown AI provider: ${provider}`)
|
|
88
|
+
AdapterClass = CommandAdapter
|
|
89
|
+
adapterConfig = { command: cmdEntry.command || null, timeout: cmdEntry.timeout || undefined }
|
|
90
|
+
} else {
|
|
91
|
+
// Build adapter config based on provider type
|
|
92
|
+
adapterConfig = buildAdapterConfig(ai, provider)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const adapter = new AdapterClass(adapterConfig)
|
|
96
|
+
|
|
97
|
+
const result = { adapter, model }
|
|
98
|
+
if (enhancements) result.enhancements = enhancements
|
|
99
|
+
return result
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve an adapter + model for a specific provider name.
|
|
104
|
+
*
|
|
105
|
+
* Checks ADAPTER_MAP first, then the merged custom providers registry.
|
|
106
|
+
* Used by the pipeline for per-step provider overrides.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} config - merged config object
|
|
109
|
+
* @param {string} providerName - provider to resolve
|
|
110
|
+
* @param {string} [modelOverride] - optional model override
|
|
111
|
+
* @returns {{ adapter: import('./adapters/base.js').BaseAdapter, model: string }}
|
|
112
|
+
*/
|
|
113
|
+
export function resolveProviderAdapter(config, providerName, modelOverride) {
|
|
114
|
+
const ai = config.ai || {}
|
|
115
|
+
|
|
116
|
+
let AdapterClass = ADAPTER_MAP[providerName]
|
|
117
|
+
let adapterConfig
|
|
118
|
+
|
|
119
|
+
if (!AdapterClass) {
|
|
120
|
+
const customProviders = migrateProviderConfig(ai)
|
|
121
|
+
const cmdEntry = customProviders[providerName]
|
|
122
|
+
if (!cmdEntry) throw new Error(`Unknown AI provider: ${providerName}`)
|
|
123
|
+
AdapterClass = CommandAdapter
|
|
124
|
+
adapterConfig = { command: cmdEntry.command || null, timeout: cmdEntry.timeout || undefined }
|
|
125
|
+
} else {
|
|
126
|
+
adapterConfig = buildAdapterConfig(ai, providerName)
|
|
127
|
+
}
|
|
52
128
|
|
|
53
|
-
// Build adapter config based on provider type
|
|
54
|
-
const adapterConfig = buildAdapterConfig(ai, provider)
|
|
55
129
|
const adapter = new AdapterClass(adapterConfig)
|
|
130
|
+
const model = modelOverride || ai.model || ai.models?.[providerName] || null
|
|
56
131
|
|
|
57
132
|
return { adapter, model }
|
|
58
133
|
}
|
|
@@ -120,9 +195,22 @@ function matchesRule(match, context) {
|
|
|
120
195
|
}
|
|
121
196
|
|
|
122
197
|
/**
|
|
123
|
-
* List
|
|
198
|
+
* List built-in provider names.
|
|
124
199
|
* @returns {string[]}
|
|
125
200
|
*/
|
|
126
201
|
export function listProviders() {
|
|
127
202
|
return Object.keys(ADAPTER_MAP)
|
|
128
203
|
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* List all available provider names: built-in + custom providers from config.
|
|
207
|
+
* @param {object} config - merged config object
|
|
208
|
+
* @returns {string[]}
|
|
209
|
+
*/
|
|
210
|
+
export function listAllProviders(config) {
|
|
211
|
+
const builtIn = Object.keys(ADAPTER_MAP)
|
|
212
|
+
const ai = config?.ai || {}
|
|
213
|
+
const customProviders = migrateProviderConfig(ai)
|
|
214
|
+
const custom = Object.keys(customProviders)
|
|
215
|
+
return [...builtIn, ...custom.filter((k) => !builtIn.includes(k))]
|
|
216
|
+
}
|
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 }
|
package/src/lib/dedup.js
CHANGED
|
@@ -45,42 +45,44 @@ export function computeIdempotencyKey({ agent, trigger, issueType, repo }) {
|
|
|
45
45
|
return crypto.createHash('sha256').update(raw).digest('hex')
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
const STOP_WORDS = new Set([
|
|
49
|
+
'a','an','the','in','on','at','to','for','of','with','by','from',
|
|
50
|
+
'is','are','was','were','be','been','being','has','have','had',
|
|
51
|
+
'do','does','did','will','would','could','should','can','may',
|
|
52
|
+
'not','no','but','or','and','if','then','so','as','it','its',
|
|
53
|
+
'this','that','we','our','i','my','you','your','they','their',
|
|
54
|
+
])
|
|
55
|
+
|
|
48
56
|
/**
|
|
49
|
-
*
|
|
57
|
+
* Tokenize text: normalize, split on spaces, remove stop words.
|
|
50
58
|
*
|
|
51
|
-
* @param {string}
|
|
52
|
-
* @
|
|
53
|
-
* @returns {number}
|
|
59
|
+
* @param {string} text
|
|
60
|
+
* @returns {string[]}
|
|
54
61
|
*/
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
for (let j = 0; j <= n; j++) dp[0][j] = j
|
|
60
|
-
for (let i = 1; i <= m; i++) {
|
|
61
|
-
for (let j = 1; j <= n; j++) {
|
|
62
|
-
dp[i][j] =
|
|
63
|
-
a[i - 1] === b[j - 1]
|
|
64
|
-
? dp[i - 1][j - 1]
|
|
65
|
-
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return dp[m][n]
|
|
62
|
+
function tokenize(text) {
|
|
63
|
+
return normalizeText(text)
|
|
64
|
+
.split(' ')
|
|
65
|
+
.filter(w => w.length > 0 && !STOP_WORDS.has(w))
|
|
69
66
|
}
|
|
70
67
|
|
|
71
68
|
/**
|
|
72
|
-
* Compute similarity between two strings
|
|
73
|
-
*
|
|
69
|
+
* Compute Jaccard similarity between two strings based on token sets.
|
|
70
|
+
* Tokens are words with stop words removed.
|
|
71
|
+
* Returns a value in [0, 1]: 1.0 = identical token sets, 0.0 = no overlap.
|
|
74
72
|
*
|
|
75
73
|
* @param {string} a
|
|
76
74
|
* @param {string} b
|
|
77
75
|
* @returns {number}
|
|
78
76
|
*/
|
|
79
|
-
export function
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
83
|
-
|
|
77
|
+
export function jaccardSimilarity(a, b) {
|
|
78
|
+
const setA = new Set(tokenize(a))
|
|
79
|
+
const setB = new Set(tokenize(b))
|
|
80
|
+
if (setA.size === 0 && setB.size === 0) return 1
|
|
81
|
+
if (setA.size === 0 || setB.size === 0) return 0
|
|
82
|
+
let intersection = 0
|
|
83
|
+
for (const w of setA) if (setB.has(w)) intersection++
|
|
84
|
+
const union = new Set([...setA, ...setB]).size
|
|
85
|
+
return intersection / union
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
/**
|
|
@@ -167,12 +169,16 @@ export async function checkDuplicate(repo, { title, body, idempotencyKey, agent
|
|
|
167
169
|
})
|
|
168
170
|
}
|
|
169
171
|
|
|
170
|
-
// Layer 3: fuzzy title match
|
|
171
|
-
const
|
|
172
|
+
// Layer 3: fuzzy title match (token-set Jaccard)
|
|
173
|
+
const newTokens = tokenize(title)
|
|
174
|
+
const isShortTitle = newTokens.length <= 2
|
|
172
175
|
const openIssues = await getCachedIssues(repo)
|
|
173
176
|
for (const issue of openIssues) {
|
|
174
|
-
const similarity =
|
|
175
|
-
|
|
177
|
+
const similarity = jaccardSimilarity(title, issue.title)
|
|
178
|
+
// Short-title guard: ≤2 meaningful tokens require exact match to block, ≥0.80 to warn
|
|
179
|
+
const blockThreshold = isShortTitle ? 1.0 : 0.80
|
|
180
|
+
const warnThreshold = isShortTitle ? 0.80 : 0.50
|
|
181
|
+
if (similarity >= blockThreshold) {
|
|
176
182
|
results.push({
|
|
177
183
|
action: 'block',
|
|
178
184
|
reason: `Fuzzy title match (similarity ${(similarity * 100).toFixed(1)}%) with existing issue`,
|
|
@@ -183,7 +189,7 @@ export async function checkDuplicate(repo, { title, body, idempotencyKey, agent
|
|
|
183
189
|
},
|
|
184
190
|
})
|
|
185
191
|
break
|
|
186
|
-
} else if (similarity >=
|
|
192
|
+
} else if (similarity >= warnThreshold) {
|
|
187
193
|
results.push({
|
|
188
194
|
action: 'warn',
|
|
189
195
|
reason: `Fuzzy title is similar (similarity ${(similarity * 100).toFixed(1)}%) to existing issue`,
|
package/src/lib/defaults.js
CHANGED
|
@@ -36,6 +36,11 @@ export const BUILT_IN_DEFAULTS = {
|
|
|
36
36
|
default: 'default', // default template name
|
|
37
37
|
},
|
|
38
38
|
|
|
39
|
+
// Enhancements
|
|
40
|
+
enhancements: {
|
|
41
|
+
dir: '.tissues/enhancements', // relative to repo root, or absolute
|
|
42
|
+
},
|
|
43
|
+
|
|
39
44
|
// AI
|
|
40
45
|
ai: {
|
|
41
46
|
enabled: true,
|
|
@@ -58,6 +63,7 @@ export const BUILT_IN_DEFAULTS = {
|
|
|
58
63
|
ollama: { url: 'http://localhost:11434' },
|
|
59
64
|
custom: { url: null }, // openai-compat base URL
|
|
60
65
|
command: null, // e.g. 'co inference'
|
|
66
|
+
providers: {}, // named custom providers: { "my-gemini": { command: "co gemini", timeout: 120000 } }
|
|
61
67
|
routes: [], // routing rules
|
|
62
68
|
budgets: {
|
|
63
69
|
maxTokensPerRequest: 4096, // hard cap per single AI call
|
|
@@ -66,7 +72,7 @@ export const BUILT_IN_DEFAULTS = {
|
|
|
66
72
|
},
|
|
67
73
|
pipeline: {
|
|
68
74
|
enabled: true, // multi-step enhancement pipeline
|
|
69
|
-
steps: {
|
|
75
|
+
steps: { // legacy key (alias for enhancements)
|
|
70
76
|
triage: 'always', // 'always' | 'auto' | 'never'
|
|
71
77
|
dedup: 'auto', // 'always' | 'auto' | 'never'
|
|
72
78
|
context: 'always',
|
|
@@ -76,6 +82,16 @@ export const BUILT_IN_DEFAULTS = {
|
|
|
76
82
|
labels: 'auto',
|
|
77
83
|
format: 'always',
|
|
78
84
|
},
|
|
85
|
+
enhancements: { // preferred key (same shape as steps)
|
|
86
|
+
triage: 'always',
|
|
87
|
+
dedup: 'auto',
|
|
88
|
+
context: 'always',
|
|
89
|
+
scope: 'auto',
|
|
90
|
+
complexity: 'auto',
|
|
91
|
+
risk: 'auto',
|
|
92
|
+
labels: 'auto',
|
|
93
|
+
format: 'always',
|
|
94
|
+
},
|
|
79
95
|
},
|
|
80
96
|
},
|
|
81
97
|
|