tissues 0.5.2 → 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.
- package/README.md +94 -40
- package/package.json +3 -4
- package/src/cli.js +24 -22
- package/src/commands/ai.js +266 -0
- package/src/commands/auth.js +4 -4
- package/src/commands/config.js +961 -12
- package/src/commands/create.js +516 -157
- package/src/commands/drafts.js +288 -0
- package/src/commands/list.js +7 -5
- package/src/commands/status.js +81 -19
- package/src/commands/templates.js +157 -0
- package/src/lib/ai/adapters/anthropic.js +52 -0
- package/src/lib/ai/adapters/base.js +45 -0
- package/src/lib/ai/adapters/command.js +58 -0
- package/src/lib/ai/adapters/gemini.js +56 -0
- package/src/lib/ai/adapters/ollama.js +60 -0
- package/src/lib/ai/adapters/openai-compat.js +51 -0
- package/src/lib/ai/adapters/openai.js +44 -0
- package/src/lib/ai/body-template.js +60 -0
- package/src/lib/ai/enhance.js +70 -0
- package/src/lib/ai/index.js +122 -0
- package/src/lib/ai/pipeline.js +79 -0
- package/src/lib/ai/prompt.js +39 -0
- package/src/lib/ai/router.js +128 -0
- package/src/lib/ai/steps.js +472 -0
- package/src/lib/attribution.js +18 -179
- package/src/lib/clipboard.js +147 -0
- package/src/lib/color.js +9 -0
- package/src/lib/dedup.js +33 -4
- package/src/lib/defaults.js +38 -2
- package/src/lib/drafts.js +439 -0
- package/src/lib/gh.js +86 -11
- package/src/lib/repo-picker.js +2 -0
- package/src/lib/safety.js +1 -1
- package/src/lib/templates.js +8 -12
- package/src/lib/theme.js +9 -0
- package/src/commands/use.js +0 -19
|
@@ -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
|
+
}
|