tissues 0.6.0 → 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 +12 -1
- package/src/commands/ai.js +149 -173
- package/src/commands/config.js +143 -69
- package/src/commands/create.js +16 -9
- package/src/commands/create.test.js +381 -0
- package/src/commands/enhancements.js +282 -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/command.js +23 -13
- 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/body-template.js +15 -0
- package/src/lib/ai/discovery.js +89 -0
- package/src/lib/ai/discovery.test.js +74 -0
- package/src/lib/ai/enhance.js +48 -11
- package/src/lib/ai/enhancement-adapter.js +109 -0
- package/src/lib/ai/enhancement-adapter.test.js +188 -0
- package/src/lib/ai/index.js +2 -2
- package/src/lib/ai/pipeline.js +20 -2
- package/src/lib/ai/pipeline.test.js +257 -0
- package/src/lib/ai/prompt.test.js +30 -0
- package/src/lib/ai/router.js +118 -7
- package/src/lib/ai/router.test.js +481 -0
- package/src/lib/ai/steps.js +23 -3
- 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 +44 -48
- package/src/lib/dedup.test.js +227 -0
- package/src/lib/defaults.js +37 -1
- 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.js +436 -0
- package/src/lib/enhancements.test.js +294 -0
- package/src/lib/gh.js +76 -10
- 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,133 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { GeminiCliAdapter } from './gemini-cli.js'
|
|
4
|
+
import { ClaudeCliAdapter } from './claude-cli.js'
|
|
5
|
+
import { CodexCliAdapter } from './codex-cli.js'
|
|
6
|
+
import { OpenClawAdapter } from './openclaw.js'
|
|
7
|
+
|
|
8
|
+
describe('GeminiCliAdapter', () => {
|
|
9
|
+
it('has correct name', () => {
|
|
10
|
+
const adapter = new GeminiCliAdapter({ binary: 'gemini' })
|
|
11
|
+
assert.equal(adapter.name, 'gemini-cli')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('isConfigured returns true when binary is set', () => {
|
|
15
|
+
const adapter = new GeminiCliAdapter({ binary: 'gemini' })
|
|
16
|
+
assert.equal(adapter.isConfigured(), true)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('isConfigured returns false when binary is not set', () => {
|
|
20
|
+
const adapter = new GeminiCliAdapter({})
|
|
21
|
+
assert.equal(adapter.isConfigured(), false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('complete rejects when binary does not exist', async () => {
|
|
25
|
+
const adapter = new GeminiCliAdapter({ binary: 'nonexistent-gemini-binary-xyz' })
|
|
26
|
+
await assert.rejects(
|
|
27
|
+
() => adapter.complete([{ role: 'user', content: 'test' }]),
|
|
28
|
+
/gemini-cli error/,
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('ClaudeCliAdapter', () => {
|
|
34
|
+
it('has correct name', () => {
|
|
35
|
+
const adapter = new ClaudeCliAdapter({ binary: 'claude' })
|
|
36
|
+
assert.equal(adapter.name, 'claude-cli')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('isConfigured returns true when binary is set', () => {
|
|
40
|
+
const adapter = new ClaudeCliAdapter({ binary: 'claude' })
|
|
41
|
+
assert.equal(adapter.isConfigured(), true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('isConfigured returns false when binary is not set', () => {
|
|
45
|
+
const adapter = new ClaudeCliAdapter({})
|
|
46
|
+
assert.equal(adapter.isConfigured(), false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('complete rejects when binary does not exist', async () => {
|
|
50
|
+
const adapter = new ClaudeCliAdapter({ binary: 'nonexistent-claude-binary-xyz' })
|
|
51
|
+
await assert.rejects(
|
|
52
|
+
() => adapter.complete([{ role: 'user', content: 'test' }]),
|
|
53
|
+
/claude-cli error/,
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('CodexCliAdapter', () => {
|
|
59
|
+
it('has correct name', () => {
|
|
60
|
+
const adapter = new CodexCliAdapter({ binary: 'codex' })
|
|
61
|
+
assert.equal(adapter.name, 'codex-cli')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('isConfigured returns true when binary is set', () => {
|
|
65
|
+
const adapter = new CodexCliAdapter({ binary: 'codex' })
|
|
66
|
+
assert.equal(adapter.isConfigured(), true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('isConfigured returns false when binary is not set', () => {
|
|
70
|
+
const adapter = new CodexCliAdapter({})
|
|
71
|
+
assert.equal(adapter.isConfigured(), false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('complete rejects when binary does not exist', async () => {
|
|
75
|
+
const adapter = new CodexCliAdapter({ binary: 'nonexistent-codex-binary-xyz' })
|
|
76
|
+
await assert.rejects(
|
|
77
|
+
() => adapter.complete([{ role: 'user', content: 'test' }]),
|
|
78
|
+
/codex-cli error|git/,
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('OpenClawAdapter', () => {
|
|
84
|
+
it('has correct name', () => {
|
|
85
|
+
const adapter = new OpenClawAdapter({ gatewayUrl: 'http://localhost:18790', token: 'test' })
|
|
86
|
+
assert.equal(adapter.name, 'openclaw')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('isConfigured returns true with url and token', () => {
|
|
90
|
+
const adapter = new OpenClawAdapter({ gatewayUrl: 'http://localhost:18790', token: 'test' })
|
|
91
|
+
assert.equal(adapter.isConfigured(), true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('isConfigured returns false without token', () => {
|
|
95
|
+
const adapter = new OpenClawAdapter({ gatewayUrl: 'http://localhost:18790' })
|
|
96
|
+
assert.equal(adapter.isConfigured(), false)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('isConfigured returns false without url', () => {
|
|
100
|
+
const adapter = new OpenClawAdapter({ token: 'test' })
|
|
101
|
+
assert.equal(adapter.isConfigured(), false)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('healthCheck returns false for unreachable gateway', async () => {
|
|
105
|
+
const adapter = new OpenClawAdapter({ gatewayUrl: 'http://localhost:1', token: 'test' })
|
|
106
|
+
const healthy = await adapter.healthCheck()
|
|
107
|
+
assert.equal(healthy, false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('complete rejects without gateway url', async () => {
|
|
111
|
+
const adapter = new OpenClawAdapter({ token: 'test' })
|
|
112
|
+
await assert.rejects(
|
|
113
|
+
() => adapter.complete([{ role: 'user', content: 'test' }]),
|
|
114
|
+
/gateway URL not configured/,
|
|
115
|
+
)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('complete rejects without token', async () => {
|
|
119
|
+
const adapter = new OpenClawAdapter({ gatewayUrl: 'http://localhost:18790' })
|
|
120
|
+
await assert.rejects(
|
|
121
|
+
() => adapter.complete([{ role: 'user', content: 'test' }]),
|
|
122
|
+
/token not configured/,
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('complete rejects when gateway is unreachable', async () => {
|
|
127
|
+
const adapter = new OpenClawAdapter({ gatewayUrl: 'http://localhost:1', token: 'test' })
|
|
128
|
+
await assert.rejects(
|
|
129
|
+
() => adapter.complete([{ role: 'user', content: 'test' }]),
|
|
130
|
+
/not reachable/,
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { BaseAdapter } from './base.js'
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 120_000
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Adapter for the OpenAI Codex CLI.
|
|
11
|
+
* Codex requires a git repo, so we create a temp dir with git init.
|
|
12
|
+
* Uses: codex exec 'prompt'
|
|
13
|
+
*/
|
|
14
|
+
export class CodexCliAdapter extends BaseAdapter {
|
|
15
|
+
get name() {
|
|
16
|
+
return 'codex-cli'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
isConfigured() {
|
|
20
|
+
return !!this.config.binary
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async complete(messages, opts = {}) {
|
|
24
|
+
const binary = this.config.binary || 'codex'
|
|
25
|
+
const timeoutMs = this.config.timeout || DEFAULT_TIMEOUT_MS
|
|
26
|
+
|
|
27
|
+
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user')
|
|
28
|
+
const promptText = lastUserMsg?.content || ''
|
|
29
|
+
|
|
30
|
+
const systemParts = messages.filter((m) => m.role === 'system').map((m) => m.content)
|
|
31
|
+
const fullPrompt = systemParts.length
|
|
32
|
+
? `${systemParts.join('\n\n')}\n\n${promptText}`
|
|
33
|
+
: promptText
|
|
34
|
+
|
|
35
|
+
// Codex requires a git repository — create a temp one
|
|
36
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'tissues-codex-'))
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
// Initialize git repo in temp dir
|
|
40
|
+
await this._exec('git', ['init', '--quiet'], { cwd: tempDir, timeout: 10_000 })
|
|
41
|
+
|
|
42
|
+
const args = ['exec', fullPrompt]
|
|
43
|
+
|
|
44
|
+
return await new Promise((resolve, reject) => {
|
|
45
|
+
execFile(binary, args, {
|
|
46
|
+
cwd: tempDir,
|
|
47
|
+
timeout: timeoutMs,
|
|
48
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
49
|
+
}, (err, stdout, stderr) => {
|
|
50
|
+
if (err) {
|
|
51
|
+
const msg = stderr?.trim() || err.message
|
|
52
|
+
reject(new Error(`codex-cli error: ${this.sanitizeErrorBody(msg)}`))
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
const text = stdout.trim()
|
|
56
|
+
if (!text) {
|
|
57
|
+
reject(new Error('codex-cli returned no output'))
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
resolve(text)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
} finally {
|
|
64
|
+
try { rmSync(tempDir, { recursive: true, force: true }) } catch { /* cleanup best-effort */ }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @private */
|
|
69
|
+
_exec(cmd, args, opts) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
execFile(cmd, args, opts, (err, stdout) => {
|
|
72
|
+
if (err) reject(err)
|
|
73
|
+
else resolve(stdout)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -16,27 +16,35 @@ export class CommandAdapter extends BaseAdapter {
|
|
|
16
16
|
const command = this.config.command
|
|
17
17
|
if (!command) throw new Error('Command not configured (ai.command)')
|
|
18
18
|
|
|
19
|
-
const timeoutMs =
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
const timeoutMs = this.config.timeout
|
|
20
|
+
|| (opts.maxTokens
|
|
21
|
+
? Math.max(DEFAULT_TIMEOUT_MS, opts.maxTokens * 15) // rough heuristic
|
|
22
|
+
: DEFAULT_TIMEOUT_MS)
|
|
22
23
|
|
|
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({
|
|
24
36
|
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
25
37
|
model: opts.model || null,
|
|
26
38
|
maxTokens: opts.maxTokens || 4096,
|
|
27
39
|
})
|
|
28
40
|
|
|
29
41
|
return new Promise((resolve, reject) => {
|
|
30
|
-
//
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const args = parts.slice(1)
|
|
34
|
-
|
|
35
|
-
const isWindows = process.platform === 'win32'
|
|
36
|
-
const child = execFile(exe, args, {
|
|
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], {
|
|
37
45
|
timeout: timeoutMs,
|
|
38
46
|
maxBuffer: 10 * 1024 * 1024, // 10 MB
|
|
39
|
-
shell:
|
|
47
|
+
shell: false, // we're already wrapping in sh -c
|
|
40
48
|
}, (err, stdout, stderr) => {
|
|
41
49
|
if (err) {
|
|
42
50
|
const msg = stderr?.trim() || err.message
|
|
@@ -51,7 +59,9 @@ export class CommandAdapter extends BaseAdapter {
|
|
|
51
59
|
resolve(text)
|
|
52
60
|
})
|
|
53
61
|
|
|
54
|
-
|
|
62
|
+
if (payload) {
|
|
63
|
+
child.stdin.write(payload)
|
|
64
|
+
}
|
|
55
65
|
child.stdin.end()
|
|
56
66
|
})
|
|
57
67
|
}
|
|
@@ -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
|
+
}
|