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.
Files changed (40) hide show
  1. package/README.md +94 -40
  2. package/package.json +3 -4
  3. package/src/cli.js +26 -22
  4. package/src/commands/ai.js +268 -0
  5. package/src/commands/auth.js +4 -4
  6. package/src/commands/config.js +1035 -12
  7. package/src/commands/create.js +523 -157
  8. package/src/commands/drafts.js +288 -0
  9. package/src/commands/enhancements.js +282 -0
  10. package/src/commands/list.js +7 -5
  11. package/src/commands/status.js +81 -19
  12. package/src/commands/templates.js +157 -0
  13. package/src/lib/ai/adapters/anthropic.js +52 -0
  14. package/src/lib/ai/adapters/base.js +45 -0
  15. package/src/lib/ai/adapters/command.js +68 -0
  16. package/src/lib/ai/adapters/gemini.js +56 -0
  17. package/src/lib/ai/adapters/ollama.js +60 -0
  18. package/src/lib/ai/adapters/openai-compat.js +51 -0
  19. package/src/lib/ai/adapters/openai.js +44 -0
  20. package/src/lib/ai/body-template.js +75 -0
  21. package/src/lib/ai/enhance.js +107 -0
  22. package/src/lib/ai/enhancement-adapter.js +109 -0
  23. package/src/lib/ai/index.js +122 -0
  24. package/src/lib/ai/pipeline.js +97 -0
  25. package/src/lib/ai/prompt.js +39 -0
  26. package/src/lib/ai/router.js +216 -0
  27. package/src/lib/ai/steps.js +492 -0
  28. package/src/lib/attribution.js +18 -179
  29. package/src/lib/clipboard.js +147 -0
  30. package/src/lib/color.js +9 -0
  31. package/src/lib/dedup.js +67 -32
  32. package/src/lib/defaults.js +54 -2
  33. package/src/lib/drafts.js +439 -0
  34. package/src/lib/enhancements.js +436 -0
  35. package/src/lib/gh.js +102 -21
  36. package/src/lib/repo-picker.js +2 -0
  37. package/src/lib/safety.js +1 -1
  38. package/src/lib/templates.js +8 -12
  39. package/src/lib/theme.js +9 -0
  40. package/src/commands/use.js +0 -19
@@ -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 chalk from 'chalk'
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 chalk.green('closed') + ' ' + chalk.green('✓')
70
- if (state === 'open') return chalk.red('open') + ' ' + chalk.red('✗')
71
- return chalk.yellow('half-open') + ' ' + chalk.yellow('~')
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 status (circuit breaker + rate limits) for the active repo')
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(chalk.red('No active repo. Set one with: tissues use <owner/repo>'))
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(chalk.green(`Circuit breaker reset to closed for ${repo} / ${agent}`))
146
+ console.log(green(`\nCircuit breaker reset to closed for ${repo} / ${agent}`))
96
147
  }
97
148
 
98
- const config = loadConfig(findRepoRoot())
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 Breaker: ${colorCircuitState(circuitState)}`)
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(chalk.dim(` Cooldown: ${mins} minute${mins === 1 ? '' : 's'} remaining`))
194
+ console.log(dim(` Cooldown: ${mins} minute${mins === 1 ? '' : 's'} remaining`))
142
195
  }
143
196
  }
144
197
 
145
198
  console.log(
146
- `Rate Limit: ${agentHourCount}/${cfg.maxPerHour} per hour` +
147
- chalk.dim(` (${agentHourRemaining} remaining)`)
199
+ ` Rate: ${agentHourCount}/${cfg.maxPerHour} per hour` +
200
+ dim(` (${agentHourRemaining} remaining)`)
148
201
  )
149
202
  console.log(
150
- `Burst: ${agentBurstCount}/${cfg.burstLimit} in last ${cfg.burstWindowMinutes} min`
203
+ ` Burst: ${agentBurstCount}/${cfg.burstLimit} in last ${cfg.burstWindowMinutes} min`
151
204
  )
152
205
  console.log(
153
- `Global: ${globalHourCount}/${cfg.globalMaxPerHour} per hour` +
154
- chalk.dim(` (${globalHourRemaining} remaining)`)
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 created: ${timeAgo(lastCreatedAt)}`)
212
+ console.log(`Last issue: ${timeAgo(lastCreatedAt)}`)
161
213
  } else {
162
- console.log(chalk.dim('Last issue created: never'))
214
+ console.log(dim('Last issue: never'))
163
215
  }
164
216
 
165
- console.log(`Fingerprints stored: ${fingerprintCount}`)
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
+ }