tissues 0.5.1 → 0.6.0

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.
@@ -0,0 +1,52 @@
1
+ import { BaseAdapter } from './base.js'
2
+
3
+ const DEFAULT_MODEL = 'claude-haiku-4-5-20251001'
4
+ const DEFAULT_MAX_TOKENS = 4096
5
+ const API_URL = 'https://api.anthropic.com/v1/messages'
6
+
7
+ export class AnthropicAdapter extends BaseAdapter {
8
+ get name() {
9
+ return 'anthropic'
10
+ }
11
+
12
+ async complete(messages, opts = {}) {
13
+ const apiKey = this.config.apiKey
14
+ if (!apiKey) throw new Error('Anthropic API key not configured (ai.keys.anthropic)')
15
+
16
+ const model = opts.model || DEFAULT_MODEL
17
+ const maxTokens = opts.maxTokens || DEFAULT_MAX_TOKENS
18
+
19
+ // Anthropic Messages API uses a separate `system` param
20
+ const systemMsg = messages.find((m) => m.role === 'system')
21
+ const userMessages = messages
22
+ .filter((m) => m.role !== 'system')
23
+ .map((m) => ({ role: m.role, content: m.content }))
24
+
25
+ const body = {
26
+ model,
27
+ max_tokens: maxTokens,
28
+ messages: userMessages,
29
+ }
30
+ if (systemMsg) body.system = systemMsg.content
31
+
32
+ const res = await fetch(API_URL, {
33
+ method: 'POST',
34
+ headers: {
35
+ 'Content-Type': 'application/json',
36
+ 'x-api-key': apiKey,
37
+ 'anthropic-version': '2023-06-01',
38
+ },
39
+ body: JSON.stringify(body),
40
+ })
41
+
42
+ if (!res.ok) {
43
+ const text = await res.text().catch(() => '')
44
+ throw new Error(`Anthropic API error ${res.status}: ${this.sanitizeErrorBody(text)}`)
45
+ }
46
+
47
+ const data = await res.json()
48
+ const textBlock = data.content?.find((b) => b.type === 'text')
49
+ if (!textBlock) throw new Error('Anthropic returned no text content')
50
+ return textBlock.text
51
+ }
52
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Base adapter interface for AI providers.
3
+ * Each adapter owns its own HTTP calls — callers never touch fetch.
4
+ */
5
+ export class BaseAdapter {
6
+ constructor(config) {
7
+ this.config = config
8
+ }
9
+
10
+ /** @returns {string} provider name (e.g. 'anthropic', 'openai', 'gemini') */
11
+ get name() {
12
+ throw new Error('not implemented')
13
+ }
14
+
15
+ /**
16
+ * Check if this adapter has enough configuration to make requests.
17
+ * Override in adapters that don't need an API key.
18
+ * @returns {boolean}
19
+ */
20
+ isConfigured() {
21
+ return !!this.config.apiKey
22
+ }
23
+
24
+ /**
25
+ * Sanitize API error response body — strip anything that looks like an API key
26
+ * to prevent accidental leakage in error messages or logs.
27
+ */
28
+ sanitizeErrorBody(text, maxLen = 200) {
29
+ if (!text) return ''
30
+ // Strip anything that looks like an API key (sk-*, gm-*, key-*, etc.)
31
+ const cleaned = text.replace(/\b(sk-|gm-|key-|Bearer\s+)[A-Za-z0-9_-]{8,}\b/gi, '[REDACTED]')
32
+ return cleaned.length > maxLen ? cleaned.slice(0, maxLen) + '...' : cleaned
33
+ }
34
+
35
+ /**
36
+ * Send a completion request.
37
+ *
38
+ * @param {Array<{ role: 'user'|'system', content: string }>} messages
39
+ * @param {{ model?: string, maxTokens?: number }} opts
40
+ * @returns {Promise<string>} extracted text response
41
+ */
42
+ async complete(messages, opts = {}) {
43
+ throw new Error('not implemented')
44
+ }
45
+ }
@@ -0,0 +1,58 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { BaseAdapter } from './base.js'
3
+
4
+ const DEFAULT_TIMEOUT_MS = 60_000
5
+
6
+ export class CommandAdapter extends BaseAdapter {
7
+ get name() {
8
+ return 'command'
9
+ }
10
+
11
+ isConfigured() {
12
+ return !!this.config.command
13
+ }
14
+
15
+ async complete(messages, opts = {}) {
16
+ const command = this.config.command
17
+ if (!command) throw new Error('Command not configured (ai.command)')
18
+
19
+ const timeoutMs = opts.maxTokens
20
+ ? Math.max(DEFAULT_TIMEOUT_MS, opts.maxTokens * 15) // rough heuristic
21
+ : DEFAULT_TIMEOUT_MS
22
+
23
+ const payload = JSON.stringify({
24
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
25
+ model: opts.model || null,
26
+ maxTokens: opts.maxTokens || 4096,
27
+ })
28
+
29
+ return new Promise((resolve, reject) => {
30
+ // Split command string into executable + args
31
+ const parts = command.split(/\s+/)
32
+ const exe = parts[0]
33
+ const args = parts.slice(1)
34
+
35
+ const isWindows = process.platform === 'win32'
36
+ const child = execFile(exe, args, {
37
+ timeout: timeoutMs,
38
+ maxBuffer: 10 * 1024 * 1024, // 10 MB
39
+ shell: isWindows,
40
+ }, (err, stdout, stderr) => {
41
+ if (err) {
42
+ const msg = stderr?.trim() || err.message
43
+ reject(new Error(`Command adapter error: ${msg}`))
44
+ return
45
+ }
46
+ const text = stdout.trim()
47
+ if (!text) {
48
+ reject(new Error('Command adapter returned no output'))
49
+ return
50
+ }
51
+ resolve(text)
52
+ })
53
+
54
+ child.stdin.write(payload)
55
+ child.stdin.end()
56
+ })
57
+ }
58
+ }
@@ -0,0 +1,56 @@
1
+ import { BaseAdapter } from './base.js'
2
+
3
+ const DEFAULT_MODEL = 'gemini-2.0-flash'
4
+ const DEFAULT_MAX_TOKENS = 4096
5
+ const API_BASE = 'https://generativelanguage.googleapis.com/v1beta/models'
6
+
7
+ export class GeminiAdapter extends BaseAdapter {
8
+ get name() {
9
+ return 'gemini'
10
+ }
11
+
12
+ async complete(messages, opts = {}) {
13
+ const apiKey = this.config.apiKey
14
+ if (!apiKey) throw new Error('Gemini API key not configured (ai.keys.gemini)')
15
+
16
+ const model = opts.model || DEFAULT_MODEL
17
+ const maxTokens = opts.maxTokens || DEFAULT_MAX_TOKENS
18
+
19
+ // Gemini uses systemInstruction + contents
20
+ const systemMsg = messages.find((m) => m.role === 'system')
21
+ const userMessages = messages.filter((m) => m.role !== 'system')
22
+
23
+ const body = {
24
+ contents: userMessages.map((m) => ({
25
+ role: m.role === 'user' ? 'user' : 'model',
26
+ parts: [{ text: m.content }],
27
+ })),
28
+ generationConfig: {
29
+ maxOutputTokens: maxTokens,
30
+ },
31
+ }
32
+ if (systemMsg) {
33
+ body.systemInstruction = { parts: [{ text: systemMsg.content }] }
34
+ }
35
+
36
+ const url = `${API_BASE}/${model}:generateContent`
37
+ const res = await fetch(url, {
38
+ method: 'POST',
39
+ headers: {
40
+ 'Content-Type': 'application/json',
41
+ 'x-goog-api-key': apiKey,
42
+ },
43
+ body: JSON.stringify(body),
44
+ })
45
+
46
+ if (!res.ok) {
47
+ const text = await res.text().catch(() => '')
48
+ throw new Error(`Gemini API error ${res.status}: ${this.sanitizeErrorBody(text)}`)
49
+ }
50
+
51
+ const data = await res.json()
52
+ const text = data.candidates?.[0]?.content?.parts?.[0]?.text
53
+ if (!text) throw new Error('Gemini returned no text content')
54
+ return text
55
+ }
56
+ }
@@ -0,0 +1,60 @@
1
+ import { BaseAdapter } from './base.js'
2
+
3
+ const DEFAULT_MODEL = 'llama3.2'
4
+ const DEFAULT_MAX_TOKENS = 4096
5
+ const DEFAULT_BASE_URL = 'http://localhost:11434'
6
+
7
+ export class OllamaAdapter extends BaseAdapter {
8
+ get name() {
9
+ return 'ollama'
10
+ }
11
+
12
+ isConfigured() {
13
+ return true // no API key needed — server just needs to be running
14
+ }
15
+
16
+ async complete(messages, opts = {}) {
17
+ const baseUrl = (this.config.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
18
+ const model = opts.model || DEFAULT_MODEL
19
+ const maxTokens = opts.maxTokens || DEFAULT_MAX_TOKENS
20
+
21
+ const body = {
22
+ model,
23
+ max_tokens: maxTokens,
24
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
25
+ }
26
+
27
+ const url = `${baseUrl}/v1/chat/completions`
28
+ const res = await fetch(url, {
29
+ method: 'POST',
30
+ headers: { 'Content-Type': 'application/json' },
31
+ body: JSON.stringify(body),
32
+ })
33
+
34
+ if (!res.ok) {
35
+ const text = await res.text().catch(() => '')
36
+ throw new Error(`Ollama API error ${res.status}: ${this.sanitizeErrorBody(text)}`)
37
+ }
38
+
39
+ const data = await res.json()
40
+ const content = data.choices?.[0]?.message?.content
41
+ if (!content) throw new Error('Ollama returned no content')
42
+ return content
43
+ }
44
+ }
45
+
46
+ /**
47
+ * List models installed on an Ollama server.
48
+ *
49
+ * @param {string} [baseUrl] - Ollama server URL (default: http://localhost:11434)
50
+ * @returns {Promise<string[]>} array of model names
51
+ */
52
+ export async function listModels(baseUrl) {
53
+ const url = `${(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')}/api/tags`
54
+ const res = await fetch(url)
55
+ if (!res.ok) {
56
+ throw new Error(`Ollama /api/tags error ${res.status}`)
57
+ }
58
+ const data = await res.json()
59
+ return (data.models || []).map((m) => m.name)
60
+ }
@@ -0,0 +1,51 @@
1
+ import { BaseAdapter } from './base.js'
2
+
3
+ const DEFAULT_MAX_TOKENS = 4096
4
+
5
+ export class OpenAICompatAdapter extends BaseAdapter {
6
+ get name() {
7
+ return 'openai-compat'
8
+ }
9
+
10
+ isConfigured() {
11
+ return !!this.config.baseUrl
12
+ }
13
+
14
+ async complete(messages, opts = {}) {
15
+ const baseUrl = this.config.baseUrl
16
+ if (!baseUrl) throw new Error('OpenAI-compatible base URL not configured (ai.custom.url)')
17
+
18
+ const model = opts.model
19
+ if (!model) throw new Error('Model must be configured for openai-compat provider (ai.models.openai-compat)')
20
+
21
+ const maxTokens = opts.maxTokens || DEFAULT_MAX_TOKENS
22
+
23
+ const body = {
24
+ model,
25
+ max_tokens: maxTokens,
26
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
27
+ }
28
+
29
+ const url = `${baseUrl.replace(/\/+$/, '')}/v1/chat/completions`
30
+ const headers = { 'Content-Type': 'application/json' }
31
+ if (this.config.apiKey) {
32
+ headers.Authorization = `Bearer ${this.config.apiKey}`
33
+ }
34
+
35
+ const res = await fetch(url, {
36
+ method: 'POST',
37
+ headers,
38
+ body: JSON.stringify(body),
39
+ })
40
+
41
+ if (!res.ok) {
42
+ const text = await res.text().catch(() => '')
43
+ throw new Error(`OpenAI-compatible API error ${res.status}: ${this.sanitizeErrorBody(text)}`)
44
+ }
45
+
46
+ const data = await res.json()
47
+ const content = data.choices?.[0]?.message?.content
48
+ if (!content) throw new Error('OpenAI-compatible endpoint returned no content')
49
+ return content
50
+ }
51
+ }
@@ -0,0 +1,44 @@
1
+ import { BaseAdapter } from './base.js'
2
+
3
+ const DEFAULT_MODEL = 'gpt-4o-mini'
4
+ const DEFAULT_MAX_TOKENS = 4096
5
+ const API_URL = 'https://api.openai.com/v1/chat/completions'
6
+
7
+ export class OpenAIAdapter extends BaseAdapter {
8
+ get name() {
9
+ return 'openai'
10
+ }
11
+
12
+ async complete(messages, opts = {}) {
13
+ const apiKey = this.config.apiKey
14
+ if (!apiKey) throw new Error('OpenAI API key not configured (ai.keys.openai)')
15
+
16
+ const model = opts.model || DEFAULT_MODEL
17
+ const maxTokens = opts.maxTokens || DEFAULT_MAX_TOKENS
18
+
19
+ const body = {
20
+ model,
21
+ max_tokens: maxTokens,
22
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
23
+ }
24
+
25
+ const res = await fetch(API_URL, {
26
+ method: 'POST',
27
+ headers: {
28
+ 'Content-Type': 'application/json',
29
+ Authorization: `Bearer ${apiKey}`,
30
+ },
31
+ body: JSON.stringify(body),
32
+ })
33
+
34
+ if (!res.ok) {
35
+ const text = await res.text().catch(() => '')
36
+ throw new Error(`OpenAI API error ${res.status}: ${this.sanitizeErrorBody(text)}`)
37
+ }
38
+
39
+ const data = await res.json()
40
+ const content = data.choices?.[0]?.message?.content
41
+ if (!content) throw new Error('OpenAI returned no content')
42
+ return content
43
+ }
44
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Rich body section guidance for the pipeline `format` step.
3
+ *
4
+ * Provides the target structure that the LLM should produce when formatting
5
+ * an issue body. Sections are conditionally included based on what upstream
6
+ * pipeline steps produced.
7
+ */
8
+
9
+ export const RICH_BODY_SECTIONS = [
10
+ { key: 'summary', heading: 'Summary', always: true },
11
+ { key: 'context', heading: 'Context', requires: 'structuredContext' },
12
+ { key: 'problem', heading: 'Problem', always: true },
13
+ { key: 'files', heading: 'Files Involved', requires: 'scopeAnalysis' },
14
+ { key: 'solution', heading: 'Proposed Solution', always: true },
15
+ { key: 'acceptance', heading: 'Acceptance Criteria', always: true },
16
+ { key: 'complexity', heading: 'Scope & Complexity', requires: 'complexity' },
17
+ ]
18
+
19
+ /**
20
+ * Build a guidance string for the `format` step's system prompt.
21
+ *
22
+ * Only includes section headings for which upstream data exists (or that are
23
+ * always-on). This prevents the LLM from hallucinating sections it has no
24
+ * data for.
25
+ *
26
+ * @param {object} ctx - PipelineContext accumulated so far
27
+ * @returns {string} markdown-formatted guidance
28
+ */
29
+ export function buildBodyGuidance(ctx) {
30
+ const lines = [
31
+ 'Structure the issue body with these markdown sections:',
32
+ '',
33
+ ]
34
+
35
+ for (const section of RICH_BODY_SECTIONS) {
36
+ if (section.always || ctx[section.requires] != null) {
37
+ lines.push(`## ${section.heading}`)
38
+ }
39
+ }
40
+
41
+ lines.push(
42
+ '',
43
+ 'Rules:',
44
+ '- Use bullet points for lists, not paragraphs.',
45
+ '- Keep each section concise (2-5 bullet points).',
46
+ '- If a section has no relevant information, omit it entirely.',
47
+ '- Return ONLY the markdown body — no preamble, no explanation, no code fences.',
48
+ )
49
+
50
+ if (ctx.complexity != null && ctx.risk != null) {
51
+ lines.push(
52
+ '',
53
+ 'In the "Scope & Complexity" section, include:',
54
+ `- Complexity: ${ctx.complexity}/10 — ${ctx.complexityRationale || ''}`,
55
+ `- Risk: ${ctx.risk}/10 — ${ctx.riskRationale || ''}`,
56
+ )
57
+ }
58
+
59
+ return lines.join('\n')
60
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Bridge between the create command and the pipeline runner.
3
+ *
4
+ * Sets up the PipelineContext, resolves routing, filters steps by config,
5
+ * and calls runPipeline.
6
+ */
7
+
8
+ import { resolveRoute } from './router.js'
9
+ import { runPipeline } from './pipeline.js'
10
+ import { ALL_STEPS } from './steps.js'
11
+ import { checkBudgets, recordUsage } from './index.js'
12
+
13
+ /**
14
+ * Run the full enhancement pipeline.
15
+ *
16
+ * @param {object} config - merged config
17
+ * @param {object} input - { title, description, instructions, templateBody, labels, existingIssues, repoLabels }
18
+ * @param {object} [callbacks] - pipeline callbacks for progress display
19
+ * @param {{ provider?: string, model?: string, template?: string }} [routeContext] - routing context
20
+ * @returns {Promise<object>} PipelineContext with all accumulated results
21
+ */
22
+ export async function runEnhancePipeline(config, input, callbacks, routeContext = {}) {
23
+ const ai = config.ai || {}
24
+ const budgets = ai.budgets || {}
25
+ const pipelineConfig = ai.pipeline || {}
26
+
27
+ // Resolve adapter + model
28
+ const route = resolveRoute(config, routeContext)
29
+
30
+ // Build step configs from pipeline.steps config
31
+ const stepConfigs = {}
32
+ const cfgSteps = pipelineConfig.steps || {}
33
+ for (const step of ALL_STEPS) {
34
+ stepConfigs[step.name] = cfgSteps[step.name] || 'auto'
35
+ }
36
+
37
+ // Build the initial PipelineContext
38
+ const ctx = {
39
+ // Input data
40
+ rawInput: input.rawInput || '',
41
+ title: input.title,
42
+ description: input.description || '',
43
+ instructions: input.instructions || '',
44
+ templateBody: input.templateBody || '',
45
+ labels: input.labels || [],
46
+ existingIssues: input.existingIssues || [],
47
+ repoLabels: input.repoLabels || [],
48
+
49
+ // Step config (internal)
50
+ _stepConfigs: stepConfigs,
51
+
52
+ // Accumulated results — steps populate these
53
+ dedupScore: null,
54
+ structuredContext: null,
55
+ scopeAnalysis: null,
56
+ complexity: null,
57
+ complexityRationale: null,
58
+ risk: null,
59
+ riskRationale: null,
60
+ aiLabels: null,
61
+ body: input.templateBody || '', // fallback to template
62
+ }
63
+
64
+ // Filter steps to only those not set to 'never'
65
+ const activeSteps = ALL_STEPS.filter((s) => stepConfigs[s.name] !== 'never')
66
+
67
+ await runPipeline(activeSteps, ctx, route, budgets, callbacks, { checkBudgets, recordUsage })
68
+
69
+ return ctx
70
+ }
@@ -0,0 +1,122 @@
1
+ import { resolveRoute, listProviders } from './router.js'
2
+ import { buildMessages } from './prompt.js'
3
+
4
+ export { listProviders }
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Token usage tracking (in-memory, resets on process exit)
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const tokenLog = []
11
+
12
+ export function recordUsage(tokens) {
13
+ tokenLog.push({ tokens, timestamp: Date.now() })
14
+ }
15
+
16
+ function getUsage(windowMs) {
17
+ const cutoff = Date.now() - windowMs
18
+ return tokenLog
19
+ .filter((e) => e.timestamp >= cutoff)
20
+ .reduce((sum, e) => sum + e.tokens, 0)
21
+ }
22
+
23
+ /**
24
+ * Check token budgets before making a request.
25
+ * Throws if a budget would be exceeded.
26
+ */
27
+ export function checkBudgets(budgets, requestTokens) {
28
+ if (!budgets) return
29
+
30
+ if (budgets.maxTokensPerRequest && requestTokens > budgets.maxTokensPerRequest) {
31
+ throw new Error(
32
+ `Token budget: request (${requestTokens}) exceeds per-request limit (${budgets.maxTokensPerRequest})`,
33
+ )
34
+ }
35
+
36
+ if (budgets.maxTokensPerHour) {
37
+ const hourlyUsed = getUsage(60 * 60 * 1000)
38
+ if (hourlyUsed + requestTokens > budgets.maxTokensPerHour) {
39
+ throw new Error(
40
+ `Token budget: hourly usage (${hourlyUsed} + ${requestTokens}) would exceed limit (${budgets.maxTokensPerHour})`,
41
+ )
42
+ }
43
+ }
44
+
45
+ if (budgets.maxTokensPerDay) {
46
+ const dailyUsed = getUsage(24 * 60 * 60 * 1000)
47
+ if (dailyUsed + requestTokens > budgets.maxTokensPerDay) {
48
+ throw new Error(
49
+ `Token budget: daily usage (${dailyUsed} + ${requestTokens}) would exceed limit (${budgets.maxTokensPerDay})`,
50
+ )
51
+ }
52
+ }
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Public API
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Check if AI enhancement is available (enabled + key configured).
61
+ *
62
+ * @param {object} config - merged config
63
+ * @param {{ provider?: string }} [context]
64
+ * @returns {boolean}
65
+ */
66
+ export function checkAvailable(config, context = {}) {
67
+ const ai = config.ai || {}
68
+ if (!ai.enabled) return false
69
+ try {
70
+ const { adapter } = resolveRoute(config, context)
71
+ return adapter.isConfigured()
72
+ } catch {
73
+ return false
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Enhance an issue body using AI.
79
+ *
80
+ * @param {object} config - merged config
81
+ * @param {string} title - issue title
82
+ * @param {string} description - actual issue content
83
+ * @param {string} templateBody - already-rendered template
84
+ * @param {string} [instructions] - optional AI guidance
85
+ * @param {{ template?: string, labels?: string[], provider?: string, model?: string }} [context] - routing context
86
+ * @returns {Promise<string>} enhanced body
87
+ */
88
+ export async function enhance(config, title, description, templateBody, instructions, context = {}) {
89
+ const ai = config.ai || {}
90
+ const budgets = ai.budgets || {}
91
+ const { adapter, model } = resolveRoute(config, context)
92
+ const messages = buildMessages(title, description, templateBody, instructions)
93
+
94
+ // Enforce per-request cap: clamp maxTokens to budget
95
+ const requestMax = budgets.maxTokensPerRequest || 4096
96
+
97
+ // Check rolling budgets before the call
98
+ checkBudgets(budgets, requestMax)
99
+
100
+ let result = await adapter.complete(messages, { model, maxTokens: requestMax })
101
+
102
+ // Strip wrapping code fences (```markdown ... ```) that LLMs often add
103
+ const fenceMatch = result.match(/^```(?:markdown|md)?\n([\s\S]*)\n```$/)
104
+ if (fenceMatch) result = fenceMatch[1].trim()
105
+
106
+ // Record approximate usage (response length in chars / 4 is a rough token estimate)
107
+ const approxTokens = Math.ceil(result.length / 4)
108
+ recordUsage(approxTokens)
109
+
110
+ return result
111
+ }
112
+
113
+ /**
114
+ * Get current token usage stats.
115
+ * @returns {{ hourly: number, daily: number }}
116
+ */
117
+ export function getTokenUsage() {
118
+ return {
119
+ hourly: getUsage(60 * 60 * 1000),
120
+ daily: getUsage(24 * 60 * 60 * 1000),
121
+ }
122
+ }