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,257 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { runPipeline } from './pipeline.js'
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Mock adapter
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
function mockAdapter(responses = {}) {
|
|
10
|
+
return {
|
|
11
|
+
name: 'mock',
|
|
12
|
+
complete(messages, opts) {
|
|
13
|
+
const lastUser = messages.find((m) => m.role === 'user')?.content || ''
|
|
14
|
+
// Return pre-configured response or a default
|
|
15
|
+
for (const [key, val] of Object.entries(responses)) {
|
|
16
|
+
if (lastUser.includes(key) || opts._stepName === key) return Promise.resolve(val)
|
|
17
|
+
}
|
|
18
|
+
return Promise.resolve('{}')
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeStep(name, { shouldRun = true, response = '{}', parseResult = {}, failOnParse = false, provider = null } = {}) {
|
|
24
|
+
return {
|
|
25
|
+
name,
|
|
26
|
+
displayName: name,
|
|
27
|
+
maxTokens: 512,
|
|
28
|
+
provider,
|
|
29
|
+
shouldRun: () => shouldRun,
|
|
30
|
+
buildMessages: (ctx) => [
|
|
31
|
+
{ role: 'system', content: 'test' },
|
|
32
|
+
{ role: 'user', content: `step:${name} title:${ctx.title}` },
|
|
33
|
+
],
|
|
34
|
+
parseResponse: (raw, ctx) => {
|
|
35
|
+
if (failOnParse) throw new Error('parse failed')
|
|
36
|
+
Object.assign(ctx, parseResult)
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Tests
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
describe('runPipeline', () => {
|
|
46
|
+
test('steps execute in order, context accumulates', async () => {
|
|
47
|
+
const order = []
|
|
48
|
+
const steps = [
|
|
49
|
+
makeStep('a', { parseResult: { aResult: 'from-a' } }),
|
|
50
|
+
makeStep('b', { parseResult: { bResult: 'from-b' } }),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
const ctx = { title: 'test', _stepConfigs: { a: 'always', b: 'always' } }
|
|
54
|
+
const route = { adapter: mockAdapter(), model: 'test-model' }
|
|
55
|
+
|
|
56
|
+
await runPipeline(steps, ctx, route, null, null, {
|
|
57
|
+
onStepStart(step) { order.push(`start:${step.name}`) },
|
|
58
|
+
onStepDone(step) { order.push(`done:${step.name}`) },
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
assert.deepEqual(order, ['start:a', 'done:a', 'start:b', 'done:b'])
|
|
62
|
+
assert.equal(ctx.aResult, 'from-a')
|
|
63
|
+
assert.equal(ctx.bResult, 'from-b')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('shouldRun: false skips steps correctly', async () => {
|
|
67
|
+
const skipped = []
|
|
68
|
+
const steps = [
|
|
69
|
+
makeStep('a', { shouldRun: false }),
|
|
70
|
+
makeStep('b', { parseResult: { bDone: true } }),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
const ctx = { title: 'test', _stepConfigs: { a: 'auto', b: 'always' } }
|
|
74
|
+
const route = { adapter: mockAdapter(), model: 'test' }
|
|
75
|
+
|
|
76
|
+
await runPipeline(steps, ctx, route, null, null, {
|
|
77
|
+
onStepSkip(step, reason) { skipped.push({ name: step.name, reason }) },
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
assert.equal(skipped.length, 1)
|
|
81
|
+
assert.equal(skipped[0].name, 'a')
|
|
82
|
+
assert.equal(ctx.bDone, true)
|
|
83
|
+
assert.equal(ctx.aResult, undefined)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('step config "never" skips regardless of shouldRun', async () => {
|
|
87
|
+
const skipped = []
|
|
88
|
+
const steps = [makeStep('a', { shouldRun: true, parseResult: { aResult: true } })]
|
|
89
|
+
|
|
90
|
+
const ctx = { title: 'test', _stepConfigs: { a: 'never' } }
|
|
91
|
+
const route = { adapter: mockAdapter(), model: 'test' }
|
|
92
|
+
|
|
93
|
+
await runPipeline(steps, ctx, route, null, null, {
|
|
94
|
+
onStepSkip(step) { skipped.push(step.name) },
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
assert.equal(skipped.length, 1)
|
|
98
|
+
assert.equal(ctx.aResult, undefined)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('failed step does not block subsequent steps', async () => {
|
|
102
|
+
const failed = []
|
|
103
|
+
const done = []
|
|
104
|
+
const steps = [
|
|
105
|
+
makeStep('a'),
|
|
106
|
+
makeStep('b', { parseResult: { bDone: true } }),
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
// 'a' will fail because the adapter throws, 'b' uses a working adapter
|
|
110
|
+
// Actually both use the same adapter. Let's make a smarter adapter.
|
|
111
|
+
let callCount = 0
|
|
112
|
+
const smartAdapter = {
|
|
113
|
+
complete() {
|
|
114
|
+
callCount++
|
|
115
|
+
if (callCount === 1) return Promise.reject(new Error('API error'))
|
|
116
|
+
return Promise.resolve('{}')
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ctx = { title: 'test', _stepConfigs: { a: 'always', b: 'always' } }
|
|
121
|
+
const route = { adapter: smartAdapter, model: 'test' }
|
|
122
|
+
|
|
123
|
+
await runPipeline(steps, ctx, route, null, null, {
|
|
124
|
+
onStepFail(step) { failed.push(step.name) },
|
|
125
|
+
onStepDone(step) { done.push(step.name) },
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
assert.deepEqual(failed, ['a'])
|
|
129
|
+
assert.deepEqual(done, ['b'])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('budget exceeded mid-pipeline skips remaining non-format steps', async () => {
|
|
133
|
+
const failed = []
|
|
134
|
+
const done = []
|
|
135
|
+
const skipped = []
|
|
136
|
+
|
|
137
|
+
const steps = [
|
|
138
|
+
makeStep('context', { parseResult: { contextDone: true } }),
|
|
139
|
+
makeStep('scope', { parseResult: { scopeDone: true } }),
|
|
140
|
+
makeStep('risk', { parseResult: { riskDone: true } }),
|
|
141
|
+
{ ...makeStep('format', { parseResult: { body: 'formatted' } }), name: 'format' },
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
const ctx = { title: 'test', _stepConfigs: { context: 'always', scope: 'always', risk: 'always', format: 'always' } }
|
|
145
|
+
const route = { adapter: mockAdapter(), model: 'test' }
|
|
146
|
+
|
|
147
|
+
// Budget check fails after first step
|
|
148
|
+
let budgetCalls = 0
|
|
149
|
+
const budgetHelpers = {
|
|
150
|
+
checkBudgets() {
|
|
151
|
+
budgetCalls++
|
|
152
|
+
if (budgetCalls > 1) throw new Error('budget exceeded')
|
|
153
|
+
},
|
|
154
|
+
recordUsage() {},
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await runPipeline(steps, ctx, route, null, {}, {
|
|
158
|
+
onStepDone(step) { done.push(step.name) },
|
|
159
|
+
onStepSkip(step, reason) { skipped.push(step.name) },
|
|
160
|
+
onStepFail(step) { failed.push(step.name) },
|
|
161
|
+
}, budgetHelpers)
|
|
162
|
+
|
|
163
|
+
// context runs successfully
|
|
164
|
+
assert.ok(done.includes('context'))
|
|
165
|
+
// scope fails on budget check
|
|
166
|
+
assert.ok(failed.includes('scope'))
|
|
167
|
+
// risk is skipped because budgetExhausted is true and it's not format
|
|
168
|
+
assert.ok(skipped.includes('risk'))
|
|
169
|
+
// format still runs (allowed even when budget exhausted)
|
|
170
|
+
assert.ok(done.includes('format'))
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('callbacks fire with correct arguments', async () => {
|
|
174
|
+
const events = []
|
|
175
|
+
const steps = [
|
|
176
|
+
makeStep('a', { parseResult: { x: 1 } }),
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
const ctx = { title: 'test', _stepConfigs: { a: 'always' } }
|
|
180
|
+
const route = { adapter: mockAdapter(), model: 'test' }
|
|
181
|
+
|
|
182
|
+
await runPipeline(steps, ctx, route, null, null, {
|
|
183
|
+
onStepStart(step) { events.push({ type: 'start', step: step.name }) },
|
|
184
|
+
onStepDone(step, resultCtx) { events.push({ type: 'done', step: step.name, hasCtx: !!resultCtx }) },
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
assert.equal(events.length, 2)
|
|
188
|
+
assert.equal(events[0].type, 'start')
|
|
189
|
+
assert.equal(events[0].step, 'a')
|
|
190
|
+
assert.equal(events[1].type, 'done')
|
|
191
|
+
assert.equal(events[1].hasCtx, true)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('step with provider override uses different adapter', async () => {
|
|
195
|
+
const adapterNames = []
|
|
196
|
+
|
|
197
|
+
// Override adapter that records its name
|
|
198
|
+
const overrideAdapter = {
|
|
199
|
+
name: 'override',
|
|
200
|
+
complete() { return Promise.resolve('{}') },
|
|
201
|
+
isConfigured() { return true },
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const steps = [
|
|
205
|
+
makeStep('a', { parseResult: { aDone: true }, provider: 'my-cli' }),
|
|
206
|
+
makeStep('b', { parseResult: { bDone: true } }),
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
const ctx = { title: 'test', _stepConfigs: { a: 'always', b: 'always' } }
|
|
210
|
+
const defaultAdapter = mockAdapter()
|
|
211
|
+
const route = { adapter: defaultAdapter, model: 'test-model' }
|
|
212
|
+
|
|
213
|
+
// Config with a custom provider that resolveProviderAdapter will find
|
|
214
|
+
const config = {
|
|
215
|
+
ai: {
|
|
216
|
+
providers: {
|
|
217
|
+
'my-cli': { command: 'echo test' },
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const done = []
|
|
223
|
+
await runPipeline(steps, ctx, route, config, null, {
|
|
224
|
+
onStepDone(step) { done.push(step.name) },
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// Both steps should complete (step a uses the override provider, step b uses default)
|
|
228
|
+
assert.deepEqual(done, ['a', 'b'])
|
|
229
|
+
assert.equal(ctx.aDone, true)
|
|
230
|
+
assert.equal(ctx.bDone, true)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('step with invalid provider falls back to default route', async () => {
|
|
234
|
+
const failed = []
|
|
235
|
+
const done = []
|
|
236
|
+
|
|
237
|
+
const steps = [
|
|
238
|
+
makeStep('a', { parseResult: { aDone: true }, provider: 'nonexistent-provider' }),
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
const ctx = { title: 'test', _stepConfigs: { a: 'always' } }
|
|
242
|
+
const route = { adapter: mockAdapter(), model: 'test' }
|
|
243
|
+
const config = { ai: {} }
|
|
244
|
+
|
|
245
|
+
await runPipeline(steps, ctx, route, config, null, {
|
|
246
|
+
onStepFail(step, err) { failed.push({ name: step.name, msg: err.message }) },
|
|
247
|
+
onStepDone(step) { done.push(step.name) },
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// Provider override fails — onStepFail called with warning, but step continues with default
|
|
251
|
+
assert.equal(failed.length, 1)
|
|
252
|
+
assert.ok(failed[0].msg.includes('nonexistent-provider'))
|
|
253
|
+
// Step still runs with default adapter
|
|
254
|
+
assert.deepEqual(done, ['a'])
|
|
255
|
+
assert.equal(ctx.aDone, true)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { buildMessages } from './prompt.js'
|
|
4
|
+
|
|
5
|
+
describe('buildMessages', () => {
|
|
6
|
+
it('builds system and user messages', () => {
|
|
7
|
+
const msgs = buildMessages('Fix login', 'Login is broken', '## Bug\n\nLogin is broken')
|
|
8
|
+
assert.equal(msgs.length, 2)
|
|
9
|
+
assert.equal(msgs[0].role, 'system')
|
|
10
|
+
assert.equal(msgs[1].role, 'user')
|
|
11
|
+
assert.ok(msgs[1].content.includes('Fix login'))
|
|
12
|
+
assert.ok(msgs[1].content.includes('Login is broken'))
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('includes instructions in system message', () => {
|
|
16
|
+
const msgs = buildMessages('Fix it', 'desc', 'body', 'keep it short')
|
|
17
|
+
assert.ok(msgs[0].content.includes('keep it short'))
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('omits instructions when not provided', () => {
|
|
21
|
+
const msgs = buildMessages('Fix it', 'desc', 'body')
|
|
22
|
+
assert.ok(!msgs[0].content.includes('Additional instructions'))
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('handles empty description', () => {
|
|
26
|
+
const msgs = buildMessages('Title', '', '## Template')
|
|
27
|
+
assert.equal(msgs.length, 2)
|
|
28
|
+
assert.ok(msgs[1].content.includes('Title'))
|
|
29
|
+
})
|
|
30
|
+
})
|
package/src/lib/ai/router.js
CHANGED
|
@@ -4,6 +4,10 @@ import { GeminiAdapter } from './adapters/gemini.js'
|
|
|
4
4
|
import { OllamaAdapter } from './adapters/ollama.js'
|
|
5
5
|
import { OpenAICompatAdapter } from './adapters/openai-compat.js'
|
|
6
6
|
import { CommandAdapter } from './adapters/command.js'
|
|
7
|
+
import { GeminiCliAdapter } from './adapters/gemini-cli.js'
|
|
8
|
+
import { ClaudeCliAdapter } from './adapters/claude-cli.js'
|
|
9
|
+
import { CodexCliAdapter } from './adapters/codex-cli.js'
|
|
10
|
+
import { OpenClawAdapter } from './adapters/openclaw.js'
|
|
7
11
|
|
|
8
12
|
const ADAPTER_MAP = {
|
|
9
13
|
anthropic: AnthropicAdapter,
|
|
@@ -12,6 +16,10 @@ const ADAPTER_MAP = {
|
|
|
12
16
|
ollama: OllamaAdapter,
|
|
13
17
|
'openai-compat': OpenAICompatAdapter,
|
|
14
18
|
command: CommandAdapter,
|
|
19
|
+
'gemini-cli': GeminiCliAdapter,
|
|
20
|
+
'claude-cli': ClaudeCliAdapter,
|
|
21
|
+
'codex-cli': CodexCliAdapter,
|
|
22
|
+
openclaw: OpenClawAdapter,
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
/**
|
|
@@ -164,6 +172,21 @@ function buildAdapterConfig(ai, provider) {
|
|
|
164
172
|
return { baseUrl: ai.custom?.url, apiKey: resolveApiKey(ai, 'openai-compat') }
|
|
165
173
|
case 'command':
|
|
166
174
|
return { command: ai.command || null }
|
|
175
|
+
case 'gemini-cli':
|
|
176
|
+
return { binary: 'gemini', model: ai.models?.['gemini-cli'] || null }
|
|
177
|
+
case 'claude-cli':
|
|
178
|
+
return { binary: 'claude', model: ai.models?.['claude-cli'] || null }
|
|
179
|
+
case 'codex-cli':
|
|
180
|
+
return { binary: 'codex', model: ai.models?.['codex-cli'] || null }
|
|
181
|
+
case 'openclaw': {
|
|
182
|
+
const port = process.env.OPENCLAW_GATEWAY_PORT || '18790'
|
|
183
|
+
return {
|
|
184
|
+
gatewayUrl: ai.openclaw?.gatewayUrl || `http://localhost:${port}`,
|
|
185
|
+
token: ai.openclaw?.token || process.env.OPENCLAW_GATEWAY_TOKEN || null,
|
|
186
|
+
agentId: ai.openclaw?.agentId || null,
|
|
187
|
+
model: ai.models?.openclaw || null,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
167
190
|
default:
|
|
168
191
|
return { apiKey: resolveApiKey(ai, provider) }
|
|
169
192
|
}
|