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.
@@ -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
- const AdapterClass = ADAPTER_MAP[provider]
51
- if (!AdapterClass) throw new Error(`Unknown AI provider: ${provider}`)
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 available provider names.
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
+ }
@@ -114,9 +114,11 @@ const dedupStep = {
114
114
  displayName: 'Duplicate check',
115
115
  maxTokens: 1024,
116
116
 
117
- shouldRun(ctx) {
118
- // Only useful when there are existing issues to compare against
119
- return ctx.existingIssues?.length > 0
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
- * Compute Levenshtein distance between two strings.
57
+ * Tokenize text: normalize, split on spaces, remove stop words.
50
58
  *
51
- * @param {string} a
52
- * @param {string} b
53
- * @returns {number}
59
+ * @param {string} text
60
+ * @returns {string[]}
54
61
  */
55
- function levenshteinDistance(a, b) {
56
- const m = a.length
57
- const n = b.length
58
- const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)])
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 as a value in [0, 1].
73
- * 1.0 means identical, 0.0 means completely different.
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 levenshteinSimilarity(a, b) {
80
- if (a === b) return 1
81
- const maxLen = Math.max(a.length, b.length)
82
- if (maxLen === 0) return 1
83
- return 1 - levenshteinDistance(a, b) / maxLen
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 normalizedTitle = normalizeText(title)
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 = levenshteinSimilarity(normalizedTitle, normalizeText(issue.title))
175
- if (similarity >= 0.90) {
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 >= 0.75) {
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`,
@@ -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