tissues 0.5.2 → 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/README.md +94 -40
- package/package.json +3 -4
- package/src/cli.js +26 -22
- package/src/commands/ai.js +268 -0
- package/src/commands/auth.js +4 -4
- package/src/commands/config.js +1035 -12
- package/src/commands/create.js +523 -157
- package/src/commands/drafts.js +288 -0
- package/src/commands/enhancements.js +282 -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 +68 -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 +75 -0
- package/src/lib/ai/enhance.js +107 -0
- package/src/lib/ai/enhancement-adapter.js +109 -0
- package/src/lib/ai/index.js +122 -0
- package/src/lib/ai/pipeline.js +97 -0
- package/src/lib/ai/prompt.js +39 -0
- package/src/lib/ai/router.js +216 -0
- package/src/lib/ai/steps.js +492 -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 +67 -32
- package/src/lib/defaults.js +54 -2
- package/src/lib/drafts.js +439 -0
- package/src/lib/enhancements.js +436 -0
- package/src/lib/gh.js +102 -21
- 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
package/src/commands/status.js
CHANGED
|
@@ -4,7 +4,9 @@ import { loadConfig, findRepoRoot } from '../lib/defaults.js'
|
|
|
4
4
|
import { getSafetyStatus, forceReset } from '../lib/safety.js'
|
|
5
5
|
import { getDb } from '../lib/db.js'
|
|
6
6
|
import { getCircuitState, countRecentEvents, probeCircuit } from '../lib/db.js'
|
|
7
|
-
import
|
|
7
|
+
import { countPending } from '../lib/drafts.js'
|
|
8
|
+
import { getAuthStatus } from '../lib/gh.js'
|
|
9
|
+
import { bold, dim, red, green, yellow, cyan } from '../lib/color.js'
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Query fingerprint count and last creation time for a repo from the DB.
|
|
@@ -66,13 +68,24 @@ function timeAgo(isoString) {
|
|
|
66
68
|
* @returns {string}
|
|
67
69
|
*/
|
|
68
70
|
function colorCircuitState(state) {
|
|
69
|
-
if (state === 'closed') return
|
|
70
|
-
if (state === 'open') return
|
|
71
|
-
return
|
|
71
|
+
if (state === 'closed') return green('closed') + ' ' + green('✓')
|
|
72
|
+
if (state === 'open') return red('open') + ' ' + red('✗')
|
|
73
|
+
return yellow('half-open') + ' ' + yellow('~')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function maskKey(value) {
|
|
77
|
+
if (!value || typeof value !== 'string') return null
|
|
78
|
+
if (value.length <= 4) return '****'
|
|
79
|
+
return '****' + value.slice(-4)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatBudget(val) {
|
|
83
|
+
if (val === 0 || !val) return 'unlimited'
|
|
84
|
+
return val.toLocaleString()
|
|
72
85
|
}
|
|
73
86
|
|
|
74
87
|
export const statusCommand = new Command('status')
|
|
75
|
-
.description('Show safety
|
|
88
|
+
.description('Show auth, safety, and config status')
|
|
76
89
|
.option('--repo <repo>', 'Repository override (owner/name)')
|
|
77
90
|
.option('--agent <name>', "Check a specific agent's status (default: 'human')", 'human')
|
|
78
91
|
.option('--reset', 'Force-reset the circuit breaker to closed')
|
|
@@ -83,19 +96,59 @@ export const statusCommand = new Command('status')
|
|
|
83
96
|
}
|
|
84
97
|
|
|
85
98
|
if (!repo) {
|
|
86
|
-
console.error(
|
|
99
|
+
console.error(red('No active repo. Set one with: tissues config'))
|
|
87
100
|
process.exit(1)
|
|
88
101
|
}
|
|
89
102
|
|
|
103
|
+
const repoRoot = findRepoRoot()
|
|
104
|
+
const config = loadConfig(repoRoot)
|
|
90
105
|
const agent = opts.agent
|
|
91
106
|
|
|
107
|
+
// --- Auth ---
|
|
108
|
+
console.log(bold('Auth'))
|
|
109
|
+
try {
|
|
110
|
+
const status = getAuthStatus()
|
|
111
|
+
const active = status.accounts?.find((a) => a.active)
|
|
112
|
+
if (active) {
|
|
113
|
+
console.log(` GitHub: ${green('✔')} ${active.login}`)
|
|
114
|
+
} else {
|
|
115
|
+
console.log(` GitHub: ${red('✗')} not logged in`)
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
console.log(` GitHub: ${red('✗')} gh CLI not available`)
|
|
119
|
+
}
|
|
120
|
+
console.log(` Active repo: ${cyan(repo)}`)
|
|
121
|
+
|
|
122
|
+
// --- AI config ---
|
|
123
|
+
console.log()
|
|
124
|
+
console.log(bold('AI'))
|
|
125
|
+
const aiEnabled = config.ai?.enabled !== false
|
|
126
|
+
console.log(` Enabled: ${aiEnabled ? green('yes') : dim('no')}`)
|
|
127
|
+
if (aiEnabled) {
|
|
128
|
+
console.log(` Provider: ${config.ai?.provider || dim('not set')}`)
|
|
129
|
+
const providers = ['anthropic', 'openai', 'gemini']
|
|
130
|
+
const keys = providers.filter((p) => config.ai?.keys?.[p]).map((p) => `${p} (${maskKey(config.ai.keys[p])})`)
|
|
131
|
+
console.log(` Keys: ${keys.length > 0 ? keys.join(', ') : dim('none')}`)
|
|
132
|
+
const budgets = config.ai?.budgets || {}
|
|
133
|
+
console.log(` Budgets: ${formatBudget(budgets.maxTokensPerRequest)}/req, ${formatBudget(budgets.maxTokensPerHour)}/hr, ${formatBudget(budgets.maxTokensPerDay)}/day`)
|
|
134
|
+
const routes = config.ai?.routes || []
|
|
135
|
+
console.log(` Routes: ${routes.length > 0 ? `${routes.length} rule${routes.length === 1 ? '' : 's'}` : dim('none')}`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- Template ---
|
|
139
|
+
console.log()
|
|
140
|
+
console.log(bold('Templates'))
|
|
141
|
+
console.log(` Default: ${config.templates?.default || 'default'}`)
|
|
142
|
+
|
|
92
143
|
// Handle --reset before displaying status
|
|
93
144
|
if (opts.reset) {
|
|
94
145
|
forceReset(repo, agent)
|
|
95
|
-
console.log(
|
|
146
|
+
console.log(green(`\nCircuit breaker reset to closed for ${repo} / ${agent}`))
|
|
96
147
|
}
|
|
97
148
|
|
|
98
|
-
|
|
149
|
+
// --- Safety ---
|
|
150
|
+
console.log()
|
|
151
|
+
console.log(bold('Safety'))
|
|
99
152
|
const safetyCfg = config.safety ?? {}
|
|
100
153
|
|
|
101
154
|
const cfg = {
|
|
@@ -130,7 +183,7 @@ export const statusCommand = new Command('status')
|
|
|
130
183
|
const agentHourRemaining = Math.max(0, cfg.maxPerHour - agentHourCount)
|
|
131
184
|
const globalHourRemaining = Math.max(0, cfg.globalMaxPerHour - globalHourCount)
|
|
132
185
|
|
|
133
|
-
console.log(`Circuit
|
|
186
|
+
console.log(` Circuit: ${colorCircuitState(circuitState)}`)
|
|
134
187
|
if (circuitState === 'open' && circuit.cooldownUntil) {
|
|
135
188
|
const until = new Date(
|
|
136
189
|
circuit.cooldownUntil.endsWith('Z') ? circuit.cooldownUntil : circuit.cooldownUntil + 'Z'
|
|
@@ -138,29 +191,38 @@ export const statusCommand = new Command('status')
|
|
|
138
191
|
const diffMs = until - Date.now()
|
|
139
192
|
if (diffMs > 0) {
|
|
140
193
|
const mins = Math.ceil(diffMs / 60_000)
|
|
141
|
-
console.log(
|
|
194
|
+
console.log(dim(` Cooldown: ${mins} minute${mins === 1 ? '' : 's'} remaining`))
|
|
142
195
|
}
|
|
143
196
|
}
|
|
144
197
|
|
|
145
198
|
console.log(
|
|
146
|
-
`Rate
|
|
147
|
-
|
|
199
|
+
` Rate: ${agentHourCount}/${cfg.maxPerHour} per hour` +
|
|
200
|
+
dim(` (${agentHourRemaining} remaining)`)
|
|
148
201
|
)
|
|
149
202
|
console.log(
|
|
150
|
-
`Burst:
|
|
203
|
+
` Burst: ${agentBurstCount}/${cfg.burstLimit} in last ${cfg.burstWindowMinutes} min`
|
|
151
204
|
)
|
|
152
205
|
console.log(
|
|
153
|
-
`Global:
|
|
154
|
-
|
|
206
|
+
` Global: ${globalHourCount}/${cfg.globalMaxPerHour} per hour` +
|
|
207
|
+
dim(` (${globalHourRemaining} remaining)`)
|
|
155
208
|
)
|
|
156
209
|
|
|
157
210
|
console.log()
|
|
158
|
-
|
|
159
211
|
if (lastCreatedAt) {
|
|
160
|
-
console.log(`Last issue
|
|
212
|
+
console.log(`Last issue: ${timeAgo(lastCreatedAt)}`)
|
|
161
213
|
} else {
|
|
162
|
-
console.log(
|
|
214
|
+
console.log(dim('Last issue: never'))
|
|
163
215
|
}
|
|
164
216
|
|
|
165
|
-
console.log(`Fingerprints
|
|
217
|
+
console.log(`Fingerprints: ${fingerprintCount}`)
|
|
218
|
+
|
|
219
|
+
const pendingCount = countPending(repoRoot)
|
|
220
|
+
if (pendingCount > 0) {
|
|
221
|
+
console.log(
|
|
222
|
+
cyan(`Drafts: ${pendingCount} pending`) +
|
|
223
|
+
dim(' (run ') + cyan('tissues drafts') + dim(' to manage)'),
|
|
224
|
+
)
|
|
225
|
+
} else {
|
|
226
|
+
console.log(dim('Drafts: empty'))
|
|
227
|
+
}
|
|
166
228
|
})
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import { dim, red, green } from '../lib/color.js'
|
|
6
|
+
import { select, input, editor } from '@inquirer/prompts'
|
|
7
|
+
import { listTemplates, loadTemplate, builtInTemplateKeys } from '../lib/templates.js'
|
|
8
|
+
import { findRepoRoot, loadConfig } from '../lib/defaults.js'
|
|
9
|
+
import { theme } from '../lib/theme.js'
|
|
10
|
+
|
|
11
|
+
function isCancelled(err) {
|
|
12
|
+
return err?.name === 'ExitPromptError' || err?.message?.includes('User force closed')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function promptOrBack(fn) {
|
|
16
|
+
try { return await fn() } catch (err) { if (isCancelled(err)) return Symbol.for('back'); throw err }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function userTemplateDir() {
|
|
20
|
+
return path.join(os.homedir(), '.config', 'tissues', 'templates')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const templatesCommand = new Command('templates')
|
|
24
|
+
.description('Manage issue templates (view, edit, create)')
|
|
25
|
+
.action(async () => {
|
|
26
|
+
while (true) {
|
|
27
|
+
const repoRoot = findRepoRoot()
|
|
28
|
+
const templates = listTemplates(repoRoot)
|
|
29
|
+
const builtInKeys = builtInTemplateKeys()
|
|
30
|
+
|
|
31
|
+
// Deduplicate by key
|
|
32
|
+
const seen = new Set()
|
|
33
|
+
const choices = []
|
|
34
|
+
for (const tpl of templates) {
|
|
35
|
+
if (!seen.has(tpl.key)) {
|
|
36
|
+
seen.add(tpl.key)
|
|
37
|
+
choices.push({
|
|
38
|
+
name: `${tpl.key.padEnd(14)} ${dim(tpl.name)} ${dim(`(${tpl.source})`)}`,
|
|
39
|
+
value: tpl.key,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
choices.push({ name: green('Create New Template'), value: '_create' })
|
|
44
|
+
choices.push({ name: dim('Done'), value: 'done' })
|
|
45
|
+
|
|
46
|
+
const chosen = await promptOrBack(() => select({ message: 'Templates', choices, theme }))
|
|
47
|
+
if (chosen === Symbol.for('back') || chosen === 'done') break
|
|
48
|
+
|
|
49
|
+
if (chosen === '_create') {
|
|
50
|
+
await createNewTemplate(builtInKeys, seen)
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// View / edit existing template
|
|
55
|
+
await editTemplate(chosen, repoRoot, builtInKeys)
|
|
56
|
+
console.log()
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
async function createNewTemplate(builtInKeys, existingKeys) {
|
|
61
|
+
const name = await promptOrBack(() => input({ message: 'Template name (lowercase, no spaces)', theme }))
|
|
62
|
+
if (name === Symbol.for('back') || !name) return
|
|
63
|
+
|
|
64
|
+
const key = name.trim().toLowerCase().replace(/\s+/g, '-')
|
|
65
|
+
if (!key) return
|
|
66
|
+
|
|
67
|
+
// Block reusing exact built-in names
|
|
68
|
+
if (builtInKeys.includes(key)) {
|
|
69
|
+
console.log(red(` "${key}" is a built-in template. Use edit to customize it instead.`))
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Block duplicates
|
|
74
|
+
if (existingKeys.has(key)) {
|
|
75
|
+
console.log(red(` Template "${key}" already exists.`))
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const body = await promptOrBack(() =>
|
|
80
|
+
editor({
|
|
81
|
+
message: 'Template body (opens editor)',
|
|
82
|
+
default: `## ${key.charAt(0).toUpperCase() + key.slice(1)}\n\n{{description}}\n\n## Details\n\n`,
|
|
83
|
+
theme,
|
|
84
|
+
}),
|
|
85
|
+
)
|
|
86
|
+
if (body === Symbol.for('back') || !body) return
|
|
87
|
+
|
|
88
|
+
const dir = userTemplateDir()
|
|
89
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
90
|
+
const filePath = path.join(dir, `${key}.md`)
|
|
91
|
+
fs.writeFileSync(filePath, body, 'utf8')
|
|
92
|
+
console.log(green(` ✔ Template "${key}" created: ${filePath}`))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function editTemplate(key, repoRoot, builtInKeys) {
|
|
96
|
+
let tpl
|
|
97
|
+
try {
|
|
98
|
+
tpl = loadTemplate(key, repoRoot)
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.log(red(` ${err.message}`))
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log(`\n ${tpl.name} ${dim(`(${tpl.source})`)}`)
|
|
105
|
+
console.log(dim(' ─'.repeat(20)))
|
|
106
|
+
// Show preview (truncated)
|
|
107
|
+
const preview = tpl.body.split('\n').slice(0, 8).join('\n')
|
|
108
|
+
console.log(dim(preview))
|
|
109
|
+
if (tpl.body.split('\n').length > 8) console.log(dim(' ...'))
|
|
110
|
+
console.log()
|
|
111
|
+
|
|
112
|
+
const actionChoices = [
|
|
113
|
+
{ name: 'Edit', value: 'edit' },
|
|
114
|
+
]
|
|
115
|
+
if (builtInKeys.includes(key) && tpl.source === 'built-in') {
|
|
116
|
+
actionChoices[0] = { name: 'Customize (creates user copy)', value: 'edit' }
|
|
117
|
+
}
|
|
118
|
+
// Only allow deleting user templates
|
|
119
|
+
const userFile = path.join(userTemplateDir(), `${key}.md`)
|
|
120
|
+
if (fs.existsSync(userFile)) {
|
|
121
|
+
actionChoices.push({ name: red('Delete user copy'), value: 'delete' })
|
|
122
|
+
}
|
|
123
|
+
actionChoices.push({ name: dim('Back'), value: 'back' })
|
|
124
|
+
|
|
125
|
+
const action = await promptOrBack(() => select({ message: tpl.name, choices: actionChoices, theme }))
|
|
126
|
+
if (action === Symbol.for('back') || action === 'back') return
|
|
127
|
+
|
|
128
|
+
if (action === 'delete') {
|
|
129
|
+
fs.unlinkSync(userFile)
|
|
130
|
+
console.log(green(` ✔ User copy of "${key}" deleted`))
|
|
131
|
+
if (builtInKeys.includes(key)) {
|
|
132
|
+
console.log(dim(` Built-in "${key}" will be used again.`))
|
|
133
|
+
}
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Edit — open in editor with current body
|
|
138
|
+
const newBody = await promptOrBack(() =>
|
|
139
|
+
editor({
|
|
140
|
+
message: `Edit ${key}`,
|
|
141
|
+
default: tpl.body,
|
|
142
|
+
theme,
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
|
+
if (newBody === Symbol.for('back') || !newBody) return
|
|
146
|
+
|
|
147
|
+
// Save as user template (even if editing a built-in — creates a user override)
|
|
148
|
+
const dir = userTemplateDir()
|
|
149
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
150
|
+
fs.writeFileSync(path.join(dir, `${key}.md`), newBody, 'utf8')
|
|
151
|
+
|
|
152
|
+
if (tpl.source === 'built-in') {
|
|
153
|
+
console.log(green(` ✔ User copy of "${key}" created (overrides built-in)`))
|
|
154
|
+
} else {
|
|
155
|
+
console.log(green(` ✔ Template "${key}" updated`))
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -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,68 @@
|
|
|
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 = this.config.timeout
|
|
20
|
+
|| (opts.maxTokens
|
|
21
|
+
? Math.max(DEFAULT_TIMEOUT_MS, opts.maxTokens * 15) // rough heuristic
|
|
22
|
+
: DEFAULT_TIMEOUT_MS)
|
|
23
|
+
|
|
24
|
+
// Extract the last user message as the prompt text
|
|
25
|
+
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user')
|
|
26
|
+
const promptText = lastUserMsg?.content || ''
|
|
27
|
+
|
|
28
|
+
// Check if command uses {prompt} placeholder (arg mode) or JSON stdin
|
|
29
|
+
const usesPlaceholder = command.includes('{prompt}')
|
|
30
|
+
|
|
31
|
+
const resolvedCommand = usesPlaceholder
|
|
32
|
+
? command.replace(/\{prompt\}/g, promptText)
|
|
33
|
+
: command
|
|
34
|
+
|
|
35
|
+
const payload = usesPlaceholder ? null : JSON.stringify({
|
|
36
|
+
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
37
|
+
model: opts.model || null,
|
|
38
|
+
maxTokens: opts.maxTokens || 4096,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
// Always use shell to support quoted args, pipes, builtins, etc.
|
|
43
|
+
const shell = process.platform === 'win32' ? true : '/bin/sh'
|
|
44
|
+
const child = execFile('sh', ['-c', resolvedCommand], {
|
|
45
|
+
timeout: timeoutMs,
|
|
46
|
+
maxBuffer: 10 * 1024 * 1024, // 10 MB
|
|
47
|
+
shell: false, // we're already wrapping in sh -c
|
|
48
|
+
}, (err, stdout, stderr) => {
|
|
49
|
+
if (err) {
|
|
50
|
+
const msg = stderr?.trim() || err.message
|
|
51
|
+
reject(new Error(`Command adapter error: ${msg}`))
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
const text = stdout.trim()
|
|
55
|
+
if (!text) {
|
|
56
|
+
reject(new Error('Command adapter returned no output'))
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
resolve(text)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
if (payload) {
|
|
63
|
+
child.stdin.write(payload)
|
|
64
|
+
}
|
|
65
|
+
child.stdin.end()
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -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
|
+
}
|