tissues 0.6.1 → 0.6.2
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 +78 -1
- package/package.json +4 -2
- package/src/cli.js +10 -1
- package/src/commands/ai.js +147 -173
- package/src/commands/create.js +6 -6
- package/src/commands/create.test.js +381 -0
- package/src/commands/flush.test.js +299 -0
- package/src/commands/list.js +3 -2
- package/src/commands/providers.js +347 -0
- package/src/commands/providers.test.js +28 -0
- package/src/commands/storage.js +167 -0
- package/src/commands/sync.js +225 -0
- package/src/daemon/sync.js +189 -0
- package/src/lib/ai/adapters/claude-cli.js +55 -0
- package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
- package/src/lib/ai/adapters/codex-cli.js +77 -0
- package/src/lib/ai/adapters/gemini-cli.js +55 -0
- package/src/lib/ai/adapters/openclaw.js +91 -0
- package/src/lib/ai/agent-actions.js +271 -0
- package/src/lib/ai/agent.js +323 -0
- package/src/lib/ai/discovery.js +89 -0
- package/src/lib/ai/discovery.test.js +74 -0
- package/src/lib/ai/enhancement-adapter.test.js +188 -0
- package/src/lib/ai/pipeline.test.js +257 -0
- package/src/lib/ai/prompt.test.js +30 -0
- package/src/lib/ai/router.js +23 -0
- package/src/lib/ai/router.test.js +481 -0
- package/src/lib/ai/steps.test.js +335 -0
- package/src/lib/attribution.test.js +64 -0
- package/src/lib/cache.js +408 -0
- package/src/lib/db.js +42 -0
- package/src/lib/dedup.js +8 -18
- package/src/lib/dedup.test.js +227 -0
- package/src/lib/defaults.js +20 -0
- package/src/lib/defaults.test.js +217 -0
- package/src/lib/drafts-perf.test.js +203 -0
- package/src/lib/drafts.test.js +300 -0
- package/src/lib/enhancements.test.js +294 -0
- package/src/lib/gh.js +60 -0
- package/src/lib/safety.test.js +217 -0
- package/src/lib/storage.js +298 -0
- package/src/lib/templates.test.js +207 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { BaseAdapter } from './base.js'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 120_000
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Adapter for the Gemini CLI (google-gemini/gemini-cli).
|
|
8
|
+
* Uses: gemini -p 'prompt' --model MODEL -s
|
|
9
|
+
*/
|
|
10
|
+
export class GeminiCliAdapter extends BaseAdapter {
|
|
11
|
+
get name() {
|
|
12
|
+
return 'gemini-cli'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
isConfigured() {
|
|
16
|
+
return !!this.config.binary
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async complete(messages, opts = {}) {
|
|
20
|
+
const binary = this.config.binary || 'gemini'
|
|
21
|
+
const model = opts.model || this.config.model || null
|
|
22
|
+
const timeoutMs = this.config.timeout || DEFAULT_TIMEOUT_MS
|
|
23
|
+
|
|
24
|
+
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user')
|
|
25
|
+
const promptText = lastUserMsg?.content || ''
|
|
26
|
+
|
|
27
|
+
// Build system context from system messages
|
|
28
|
+
const systemParts = messages.filter((m) => m.role === 'system').map((m) => m.content)
|
|
29
|
+
const fullPrompt = systemParts.length
|
|
30
|
+
? `${systemParts.join('\n\n')}\n\n${promptText}`
|
|
31
|
+
: promptText
|
|
32
|
+
|
|
33
|
+
const args = ['-p', fullPrompt, '-s']
|
|
34
|
+
if (model) args.push('--model', model)
|
|
35
|
+
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
execFile(binary, args, {
|
|
38
|
+
timeout: timeoutMs,
|
|
39
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
40
|
+
}, (err, stdout, stderr) => {
|
|
41
|
+
if (err) {
|
|
42
|
+
const msg = stderr?.trim() || err.message
|
|
43
|
+
reject(new Error(`gemini-cli error: ${this.sanitizeErrorBody(msg)}`))
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
const text = stdout.trim()
|
|
47
|
+
if (!text) {
|
|
48
|
+
reject(new Error('gemini-cli returned no output'))
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
resolve(text)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { BaseAdapter } from './base.js'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 120_000
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Adapter for OpenClaw gateway.
|
|
7
|
+
* Connects via HTTP API to the local (or remote) OpenClaw gateway.
|
|
8
|
+
*/
|
|
9
|
+
export class OpenClawAdapter extends BaseAdapter {
|
|
10
|
+
get name() {
|
|
11
|
+
return 'openclaw'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
isConfigured() {
|
|
15
|
+
return !!this.config.gatewayUrl && !!this.config.token
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check if the OpenClaw gateway is healthy.
|
|
20
|
+
* @returns {Promise<boolean>}
|
|
21
|
+
*/
|
|
22
|
+
async healthCheck() {
|
|
23
|
+
try {
|
|
24
|
+
const url = `${this.config.gatewayUrl}/api/v1/health`
|
|
25
|
+
const res = await fetch(url, {
|
|
26
|
+
method: 'GET',
|
|
27
|
+
headers: this._headers(),
|
|
28
|
+
signal: AbortSignal.timeout(5000),
|
|
29
|
+
})
|
|
30
|
+
return res.ok
|
|
31
|
+
} catch {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async complete(messages, opts = {}) {
|
|
37
|
+
const gatewayUrl = this.config.gatewayUrl
|
|
38
|
+
if (!gatewayUrl) throw new Error('OpenClaw gateway URL not configured')
|
|
39
|
+
if (!this.config.token) throw new Error('OpenClaw gateway token not configured')
|
|
40
|
+
|
|
41
|
+
// Check gateway health before making the request
|
|
42
|
+
const healthy = await this.healthCheck()
|
|
43
|
+
if (!healthy) {
|
|
44
|
+
throw new Error(`OpenClaw gateway not reachable at ${gatewayUrl}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const timeoutMs = this.config.timeout || DEFAULT_TIMEOUT_MS
|
|
48
|
+
const agentId = this.config.agentId || null
|
|
49
|
+
|
|
50
|
+
const body = {
|
|
51
|
+
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
52
|
+
model: opts.model || this.config.model || null,
|
|
53
|
+
max_tokens: opts.maxTokens || 4096,
|
|
54
|
+
}
|
|
55
|
+
if (agentId) body.agent_id = agentId
|
|
56
|
+
|
|
57
|
+
const url = `${gatewayUrl}/api/v1/inference`
|
|
58
|
+
const res = await fetch(url, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: {
|
|
61
|
+
...this._headers(),
|
|
62
|
+
'Content-Type': 'application/json',
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify(body),
|
|
65
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
const errText = await res.text().catch(() => '')
|
|
70
|
+
throw new Error(`OpenClaw error (${res.status}): ${this.sanitizeErrorBody(errText)}`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const data = await res.json()
|
|
74
|
+
|
|
75
|
+
// Support both { text } and { choices[0].message.content } response shapes
|
|
76
|
+
const text = data.text
|
|
77
|
+
|| data.choices?.[0]?.message?.content
|
|
78
|
+
|| data.content
|
|
79
|
+
|| ''
|
|
80
|
+
|
|
81
|
+
if (!text) throw new Error('OpenClaw returned no output')
|
|
82
|
+
return text.trim()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @private */
|
|
86
|
+
_headers() {
|
|
87
|
+
return {
|
|
88
|
+
Authorization: `Bearer ${this.config.token}`,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action handlers for the AI agent.
|
|
3
|
+
*
|
|
4
|
+
* Each handler wraps an existing library function and returns a
|
|
5
|
+
* result object suitable for feeding back into the conversation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { store } from '../config.js'
|
|
9
|
+
import { loadConfig, getConfigValue, findRepoRoot } from '../defaults.js'
|
|
10
|
+
import { ensureFresh, getCachedIssues, getCachedLabels, upsertCachedIssue } from '../cache.js'
|
|
11
|
+
import { readDrafts } from '../drafts.js'
|
|
12
|
+
import { listTemplates } from '../templates.js'
|
|
13
|
+
import { listEnhancements } from '../enhancements.js'
|
|
14
|
+
import { runCreate } from '../../commands/create.js'
|
|
15
|
+
import { checkSafety, getSafetyStatus } from '../safety.js'
|
|
16
|
+
import { bold, dim, cyan, green, yellow, red } from '../color.js'
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Action registry
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Action schema definitions for the AI system prompt.
|
|
24
|
+
* Each entry: { name, description, params, requiresConfirmation }
|
|
25
|
+
*/
|
|
26
|
+
export const ACTION_SCHEMAS = [
|
|
27
|
+
{
|
|
28
|
+
name: 'create_issue',
|
|
29
|
+
description: 'Create a new GitHub issue',
|
|
30
|
+
params: { title: 'string (required)', body: 'string', labels: 'string (comma-separated)', template: 'string', repo: 'string' },
|
|
31
|
+
requiresConfirmation: true,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'list_issues',
|
|
35
|
+
description: 'List issues from the local cache',
|
|
36
|
+
params: { repo: 'string', state: '"open" | "closed"', limit: 'number' },
|
|
37
|
+
requiresConfirmation: false,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'list_labels',
|
|
41
|
+
description: 'List labels for a repo',
|
|
42
|
+
params: { repo: 'string' },
|
|
43
|
+
requiresConfirmation: false,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'list_drafts',
|
|
47
|
+
description: 'List pending issue drafts',
|
|
48
|
+
params: {},
|
|
49
|
+
requiresConfirmation: false,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'show_config',
|
|
53
|
+
description: 'Show a config value',
|
|
54
|
+
params: { key: 'string (dot-notation, e.g. "ai.provider")' },
|
|
55
|
+
requiresConfirmation: false,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'set_config',
|
|
59
|
+
description: 'Set a runtime config value',
|
|
60
|
+
params: { key: 'string', value: 'any' },
|
|
61
|
+
requiresConfirmation: true,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'switch_repo',
|
|
65
|
+
description: 'Switch the active repository',
|
|
66
|
+
params: { repo: 'string (owner/name)' },
|
|
67
|
+
requiresConfirmation: true,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'list_templates',
|
|
71
|
+
description: 'List available issue templates',
|
|
72
|
+
params: {},
|
|
73
|
+
requiresConfirmation: false,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'list_enhancements',
|
|
77
|
+
description: 'List available AI enhancements',
|
|
78
|
+
params: {},
|
|
79
|
+
requiresConfirmation: false,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'show_status',
|
|
83
|
+
description: 'Show safety status (circuit breaker, rate limits)',
|
|
84
|
+
params: { repo: 'string' },
|
|
85
|
+
requiresConfirmation: false,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'answer',
|
|
89
|
+
description: 'Respond to the user with text (use when no action is needed)',
|
|
90
|
+
params: { text: 'string' },
|
|
91
|
+
requiresConfirmation: false,
|
|
92
|
+
},
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Action handlers
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Execute an action by name with the given params.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} actionName
|
|
103
|
+
* @param {object} params
|
|
104
|
+
* @param {{ activeRepo: string, config: object, repoRoot: string }} context
|
|
105
|
+
* @returns {Promise<{ success: boolean, result: any, display?: string }>}
|
|
106
|
+
*/
|
|
107
|
+
export async function executeAction(actionName, params, context) {
|
|
108
|
+
const handler = handlers[actionName]
|
|
109
|
+
if (!handler) {
|
|
110
|
+
return { success: false, result: `Unknown action: ${actionName}` }
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
return await handler(params, context)
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return { success: false, result: err.message }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const handlers = {
|
|
120
|
+
async create_issue(params, context) {
|
|
121
|
+
const repo = params.repo || context.activeRepo
|
|
122
|
+
if (!repo) return { success: false, result: 'No repo specified and no active repo set.' }
|
|
123
|
+
|
|
124
|
+
const result = await runCreate({
|
|
125
|
+
repo,
|
|
126
|
+
title: params.title,
|
|
127
|
+
body: params.body || undefined,
|
|
128
|
+
labels: params.labels || undefined,
|
|
129
|
+
template: params.template || undefined,
|
|
130
|
+
enhance: true,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
if (result) {
|
|
134
|
+
return {
|
|
135
|
+
success: true,
|
|
136
|
+
result: { number: result.number, url: result.url },
|
|
137
|
+
display: `${green('Issue created:')} ${cyan(result.url)}`,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { success: true, result: 'Issue creation completed (dry-run or draft mode).' }
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async list_issues(params, context) {
|
|
144
|
+
const repo = params.repo || context.activeRepo
|
|
145
|
+
if (!repo) return { success: false, result: 'No repo specified.' }
|
|
146
|
+
|
|
147
|
+
const state = params.state || 'open'
|
|
148
|
+
const limit = params.limit || 20
|
|
149
|
+
const issues = ensureFresh(repo, 'issues', { state, limit, acceptStale: true })
|
|
150
|
+
const display = issues.length === 0
|
|
151
|
+
? dim('No issues found.')
|
|
152
|
+
: issues.map(i => ` #${i.number} ${i.title}${i.labels?.length ? ' ' + dim(i.labels.join(', ')) : ''}`).join('\n')
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
success: true,
|
|
156
|
+
result: issues.map(i => ({ number: i.number, title: i.title, labels: i.labels, state: i.state })),
|
|
157
|
+
display: `${bold(`${state} issues in ${repo}`)} (${issues.length})\n${display}`,
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
async list_labels(params, context) {
|
|
162
|
+
const repo = params.repo || context.activeRepo
|
|
163
|
+
if (!repo) return { success: false, result: 'No repo specified.' }
|
|
164
|
+
|
|
165
|
+
const labels = ensureFresh(repo, 'labels', { acceptStale: true })
|
|
166
|
+
return {
|
|
167
|
+
success: true,
|
|
168
|
+
result: labels,
|
|
169
|
+
display: labels.length === 0
|
|
170
|
+
? dim('No labels.')
|
|
171
|
+
: `${bold(`Labels in ${repo}`)} (${labels.length})\n ${labels.join(', ')}`,
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async list_drafts(_params, context) {
|
|
176
|
+
const drafts = readDrafts(context.repoRoot)
|
|
177
|
+
const pending = drafts.filter(d => d.status === 'draft' || d.status === 'pending')
|
|
178
|
+
return {
|
|
179
|
+
success: true,
|
|
180
|
+
result: pending.map(d => ({ id: d.id, title: d.title, status: d.status, repo: d.repo })),
|
|
181
|
+
display: pending.length === 0
|
|
182
|
+
? dim('No pending drafts.')
|
|
183
|
+
: pending.map(d => ` ${d.status === 'pending' ? yellow('pending') : dim('draft')} ${d.title}`).join('\n'),
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async show_config(params, _context) {
|
|
188
|
+
const key = params.key
|
|
189
|
+
if (!key) return { success: false, result: 'No config key specified.' }
|
|
190
|
+
const value = getConfigValue(key)
|
|
191
|
+
return {
|
|
192
|
+
success: true,
|
|
193
|
+
result: value,
|
|
194
|
+
display: `${dim(key + ':')} ${typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)}`,
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
async set_config(params, _context) {
|
|
199
|
+
const { key, value } = params
|
|
200
|
+
if (!key) return { success: false, result: 'No config key specified.' }
|
|
201
|
+
store.set(key, value)
|
|
202
|
+
return {
|
|
203
|
+
success: true,
|
|
204
|
+
result: { key, value },
|
|
205
|
+
display: `${green('Set')} ${key} = ${JSON.stringify(value)}`,
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
async switch_repo(params, _context) {
|
|
210
|
+
const { repo } = params
|
|
211
|
+
if (!repo) return { success: false, result: 'No repo specified.' }
|
|
212
|
+
store.set('activeRepo', repo)
|
|
213
|
+
return {
|
|
214
|
+
success: true,
|
|
215
|
+
result: { activeRepo: repo },
|
|
216
|
+
display: `${green('Switched to')} ${cyan(repo)}`,
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
async list_templates(_params, context) {
|
|
221
|
+
const templates = listTemplates(context.repoRoot)
|
|
222
|
+
const seen = new Set()
|
|
223
|
+
const unique = templates.filter(t => {
|
|
224
|
+
if (seen.has(t.key)) return false
|
|
225
|
+
seen.add(t.key)
|
|
226
|
+
return true
|
|
227
|
+
})
|
|
228
|
+
return {
|
|
229
|
+
success: true,
|
|
230
|
+
result: unique.map(t => ({ key: t.key, name: t.name, source: t.source })),
|
|
231
|
+
display: unique.map(t => ` ${t.key} ${dim(`(${t.source})`)}`).join('\n'),
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
async list_enhancements(_params, context) {
|
|
236
|
+
const enhancements = listEnhancements(context.repoRoot)
|
|
237
|
+
const seen = new Set()
|
|
238
|
+
const unique = enhancements.filter(e => {
|
|
239
|
+
if (seen.has(e.key)) return false
|
|
240
|
+
seen.add(e.key)
|
|
241
|
+
return true
|
|
242
|
+
})
|
|
243
|
+
return {
|
|
244
|
+
success: true,
|
|
245
|
+
result: unique.map(e => ({ key: e.key, name: e.name, mode: e.mode, source: e.source })),
|
|
246
|
+
display: unique.map(e => ` ${e.key} ${dim(e.mode)} ${dim(`(${e.source})`)}`).join('\n'),
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
async show_status(params, context) {
|
|
251
|
+
const repo = params.repo || context.activeRepo
|
|
252
|
+
if (!repo) return { success: false, result: 'No repo specified.' }
|
|
253
|
+
|
|
254
|
+
const config = context.config
|
|
255
|
+
const agent = config.attribution?.defaultAgent || 'human'
|
|
256
|
+
const status = getSafetyStatus(repo, agent, config.safety)
|
|
257
|
+
return {
|
|
258
|
+
success: true,
|
|
259
|
+
result: status,
|
|
260
|
+
display: status,
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
async answer(params, _context) {
|
|
265
|
+
return {
|
|
266
|
+
success: true,
|
|
267
|
+
result: params.text || '',
|
|
268
|
+
display: params.text || '',
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
}
|