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.
Files changed (42) hide show
  1. package/README.md +78 -1
  2. package/package.json +4 -2
  3. package/src/cli.js +10 -1
  4. package/src/commands/ai.js +147 -173
  5. package/src/commands/create.js +6 -6
  6. package/src/commands/create.test.js +381 -0
  7. package/src/commands/flush.test.js +299 -0
  8. package/src/commands/list.js +3 -2
  9. package/src/commands/providers.js +347 -0
  10. package/src/commands/providers.test.js +28 -0
  11. package/src/commands/storage.js +167 -0
  12. package/src/commands/sync.js +225 -0
  13. package/src/daemon/sync.js +189 -0
  14. package/src/lib/ai/adapters/claude-cli.js +55 -0
  15. package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
  16. package/src/lib/ai/adapters/codex-cli.js +77 -0
  17. package/src/lib/ai/adapters/gemini-cli.js +55 -0
  18. package/src/lib/ai/adapters/openclaw.js +91 -0
  19. package/src/lib/ai/agent-actions.js +271 -0
  20. package/src/lib/ai/agent.js +323 -0
  21. package/src/lib/ai/discovery.js +89 -0
  22. package/src/lib/ai/discovery.test.js +74 -0
  23. package/src/lib/ai/enhancement-adapter.test.js +188 -0
  24. package/src/lib/ai/pipeline.test.js +257 -0
  25. package/src/lib/ai/prompt.test.js +30 -0
  26. package/src/lib/ai/router.js +23 -0
  27. package/src/lib/ai/router.test.js +481 -0
  28. package/src/lib/ai/steps.test.js +335 -0
  29. package/src/lib/attribution.test.js +64 -0
  30. package/src/lib/cache.js +408 -0
  31. package/src/lib/db.js +42 -0
  32. package/src/lib/dedup.js +8 -18
  33. package/src/lib/dedup.test.js +227 -0
  34. package/src/lib/defaults.js +20 -0
  35. package/src/lib/defaults.test.js +217 -0
  36. package/src/lib/drafts-perf.test.js +203 -0
  37. package/src/lib/drafts.test.js +300 -0
  38. package/src/lib/enhancements.test.js +294 -0
  39. package/src/lib/gh.js +60 -0
  40. package/src/lib/safety.test.js +217 -0
  41. package/src/lib/storage.js +298 -0
  42. package/src/lib/templates.test.js +207 -0
@@ -0,0 +1,481 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { resolveRoute, resolveProviderAdapter, listProviders, listAllProviders, migrateProviderConfig } from './router.js'
4
+
5
+ const baseConfig = {
6
+ ai: {
7
+ enabled: true,
8
+ provider: 'anthropic',
9
+ model: null,
10
+ models: {
11
+ anthropic: 'claude-haiku-4-5-20251001',
12
+ openai: 'gpt-4o-mini',
13
+ gemini: 'gemini-2.0-flash',
14
+ },
15
+ keys: {
16
+ anthropic: 'sk-ant-test',
17
+ openai: 'sk-openai-test',
18
+ gemini: 'gm-test',
19
+ },
20
+ routes: [
21
+ { match: { template: 'bug' }, provider: 'gemini', model: 'gemini-2.0-flash' },
22
+ { match: { template: 'security' }, provider: 'anthropic', model: 'claude-sonnet-4-5-20241022' },
23
+ { match: { labels: ['P0-critical'] }, provider: 'anthropic', model: 'claude-opus-4-6' },
24
+ ],
25
+ },
26
+ }
27
+
28
+ describe('resolveRoute', () => {
29
+ it('uses default provider when no context', () => {
30
+ const { adapter, model } = resolveRoute(baseConfig, {})
31
+ assert.equal(adapter.name, 'anthropic')
32
+ assert.equal(model, 'claude-haiku-4-5-20251001')
33
+ })
34
+
35
+ it('matches template routing rule', () => {
36
+ const { adapter, model } = resolveRoute(baseConfig, { template: 'bug' })
37
+ assert.equal(adapter.name, 'gemini')
38
+ assert.equal(model, 'gemini-2.0-flash')
39
+ })
40
+
41
+ it('matches security template routing rule', () => {
42
+ const { adapter, model } = resolveRoute(baseConfig, { template: 'security' })
43
+ assert.equal(adapter.name, 'anthropic')
44
+ assert.equal(model, 'claude-sonnet-4-5-20241022')
45
+ })
46
+
47
+ it('matches label routing rule', () => {
48
+ const { adapter, model } = resolveRoute(baseConfig, { labels: ['P0-critical', 'urgent'] })
49
+ assert.equal(adapter.name, 'anthropic')
50
+ assert.equal(model, 'claude-opus-4-6')
51
+ })
52
+
53
+ it('explicit provider overrides routes', () => {
54
+ const { adapter, model } = resolveRoute(baseConfig, {
55
+ template: 'bug',
56
+ provider: 'openai',
57
+ model: 'gpt-4o',
58
+ })
59
+ assert.equal(adapter.name, 'openai')
60
+ assert.equal(model, 'gpt-4o')
61
+ })
62
+
63
+ it('falls through to default when no rule matches', () => {
64
+ const { adapter, model } = resolveRoute(baseConfig, { template: 'feature' })
65
+ assert.equal(adapter.name, 'anthropic')
66
+ assert.equal(model, 'claude-haiku-4-5-20251001')
67
+ })
68
+
69
+ it('throws on unknown provider', () => {
70
+ assert.throws(
71
+ () => resolveRoute(baseConfig, { provider: 'unknown' }),
72
+ /Unknown AI provider: unknown/,
73
+ )
74
+ })
75
+
76
+ it('first matching rule wins', () => {
77
+ const config = {
78
+ ai: {
79
+ ...baseConfig.ai,
80
+ routes: [
81
+ { match: { template: 'bug' }, provider: 'openai' },
82
+ { match: { template: 'bug' }, provider: 'gemini' },
83
+ ],
84
+ },
85
+ }
86
+ const { adapter } = resolveRoute(config, { template: 'bug' })
87
+ assert.equal(adapter.name, 'openai')
88
+ })
89
+ })
90
+
91
+ describe('resolveRoute — ollama', () => {
92
+ it('creates OllamaAdapter with baseUrl from config', () => {
93
+ const config = {
94
+ ai: {
95
+ provider: 'ollama',
96
+ models: { ollama: 'mistral' },
97
+ ollama: { url: 'http://myhost:11434' },
98
+ },
99
+ }
100
+ const { adapter, model } = resolveRoute(config, {})
101
+ assert.equal(adapter.name, 'ollama')
102
+ assert.equal(model, 'mistral')
103
+ assert.equal(adapter.config.baseUrl, 'http://myhost:11434')
104
+ assert.equal(adapter.isConfigured(), true)
105
+ })
106
+ })
107
+
108
+ describe('resolveRoute — openai-compat', () => {
109
+ it('creates OpenAICompatAdapter with baseUrl and optional key', () => {
110
+ const config = {
111
+ ai: {
112
+ provider: 'openai-compat',
113
+ models: { 'openai-compat': 'my-model' },
114
+ custom: { url: 'http://localhost:8080' },
115
+ keys: { 'openai-compat': 'sk-test' },
116
+ },
117
+ }
118
+ const { adapter, model } = resolveRoute(config, {})
119
+ assert.equal(adapter.name, 'openai-compat')
120
+ assert.equal(model, 'my-model')
121
+ assert.equal(adapter.config.baseUrl, 'http://localhost:8080')
122
+ assert.equal(adapter.config.apiKey, 'sk-test')
123
+ assert.equal(adapter.isConfigured(), true)
124
+ })
125
+
126
+ it('isConfigured returns false without baseUrl', () => {
127
+ const config = {
128
+ ai: {
129
+ provider: 'openai-compat',
130
+ custom: { url: null },
131
+ },
132
+ }
133
+ const { adapter } = resolveRoute(config, {})
134
+ assert.equal(adapter.isConfigured(), false)
135
+ })
136
+ })
137
+
138
+ describe('resolveRoute — command', () => {
139
+ it('creates CommandAdapter with command from config', () => {
140
+ const config = {
141
+ ai: {
142
+ provider: 'command',
143
+ command: 'my-ai-tool enhance',
144
+ },
145
+ }
146
+ const { adapter } = resolveRoute(config, {})
147
+ assert.equal(adapter.name, 'command')
148
+ assert.equal(adapter.config.command, 'my-ai-tool enhance')
149
+ assert.equal(adapter.isConfigured(), true)
150
+ })
151
+
152
+ it('isConfigured returns false without command', () => {
153
+ const config = {
154
+ ai: {
155
+ provider: 'command',
156
+ command: null,
157
+ },
158
+ }
159
+ const { adapter } = resolveRoute(config, {})
160
+ assert.equal(adapter.isConfigured(), false)
161
+ })
162
+ })
163
+
164
+ describe('listProviders', () => {
165
+ it('returns known providers', () => {
166
+ const providers = listProviders()
167
+ assert.ok(providers.includes('anthropic'))
168
+ assert.ok(providers.includes('openai'))
169
+ assert.ok(providers.includes('gemini'))
170
+ assert.ok(providers.includes('ollama'))
171
+ assert.ok(providers.includes('openai-compat'))
172
+ assert.ok(providers.includes('command'))
173
+ assert.ok(providers.includes('gemini-cli'))
174
+ assert.ok(providers.includes('claude-cli'))
175
+ assert.ok(providers.includes('codex-cli'))
176
+ assert.ok(providers.includes('openclaw'))
177
+ })
178
+ })
179
+
180
+ describe('resolveRoute — named CLI commands (legacy ai.commands)', () => {
181
+ it('resolves a named command from ai.commands to CommandAdapter', () => {
182
+ const config = {
183
+ ai: {
184
+ provider: 'my-cli',
185
+ commands: {
186
+ 'my-cli': { command: 'co gemini', timeout: 120000 },
187
+ },
188
+ },
189
+ }
190
+ const { adapter } = resolveRoute(config, {})
191
+ assert.equal(adapter.name, 'command')
192
+ assert.equal(adapter.config.command, 'co gemini')
193
+ assert.equal(adapter.config.timeout, 120000)
194
+ assert.equal(adapter.isConfigured(), true)
195
+ })
196
+
197
+ it('named command works via explicit --provider flag', () => {
198
+ const config = {
199
+ ai: {
200
+ provider: 'anthropic',
201
+ keys: { anthropic: 'sk-test' },
202
+ commands: {
203
+ 'my-claude': { command: 'claude --print' },
204
+ },
205
+ },
206
+ }
207
+ const { adapter } = resolveRoute(config, { provider: 'my-claude' })
208
+ assert.equal(adapter.name, 'command')
209
+ assert.equal(adapter.config.command, 'claude --print')
210
+ assert.equal(adapter.config.timeout, undefined)
211
+ })
212
+
213
+ it('named command works in routing rules', () => {
214
+ const config = {
215
+ ai: {
216
+ provider: 'anthropic',
217
+ keys: { anthropic: 'sk-test' },
218
+ commands: {
219
+ 'my-gemini': { command: 'co gemini' },
220
+ },
221
+ routes: [
222
+ { match: { template: 'bug' }, provider: 'my-gemini' },
223
+ ],
224
+ },
225
+ }
226
+ const { adapter } = resolveRoute(config, { template: 'bug' })
227
+ assert.equal(adapter.name, 'command')
228
+ assert.equal(adapter.config.command, 'co gemini')
229
+ })
230
+
231
+ it('legacy provider: "command" still works', () => {
232
+ const config = {
233
+ ai: {
234
+ provider: 'command',
235
+ command: 'old-tool',
236
+ },
237
+ }
238
+ const { adapter } = resolveRoute(config, {})
239
+ assert.equal(adapter.name, 'command')
240
+ assert.equal(adapter.config.command, 'old-tool')
241
+ })
242
+
243
+ it('throws on unknown provider not in commands', () => {
244
+ const config = {
245
+ ai: {
246
+ provider: 'nonexistent',
247
+ commands: {},
248
+ },
249
+ }
250
+ assert.throws(
251
+ () => resolveRoute(config, {}),
252
+ /Unknown AI provider: nonexistent/,
253
+ )
254
+ })
255
+
256
+ it('named command without command string is not configured', () => {
257
+ const config = {
258
+ ai: {
259
+ provider: 'bad-cmd',
260
+ commands: {
261
+ 'bad-cmd': {},
262
+ },
263
+ },
264
+ }
265
+ const { adapter } = resolveRoute(config, {})
266
+ assert.equal(adapter.name, 'command')
267
+ assert.equal(adapter.isConfigured(), false)
268
+ })
269
+ })
270
+
271
+ describe('resolveRoute — ai.providers (new canonical key)', () => {
272
+ it('resolves a provider from ai.providers', () => {
273
+ const config = {
274
+ ai: {
275
+ provider: 'my-tool',
276
+ providers: {
277
+ 'my-tool': { command: 'my-tool enhance', timeout: 30000 },
278
+ },
279
+ },
280
+ }
281
+ const { adapter } = resolveRoute(config, {})
282
+ assert.equal(adapter.name, 'command')
283
+ assert.equal(adapter.config.command, 'my-tool enhance')
284
+ assert.equal(adapter.config.timeout, 30000)
285
+ })
286
+
287
+ it('ai.providers wins over ai.commands on conflict', () => {
288
+ const config = {
289
+ ai: {
290
+ provider: 'my-tool',
291
+ commands: {
292
+ 'my-tool': { command: 'old-command' },
293
+ },
294
+ providers: {
295
+ 'my-tool': { command: 'new-command' },
296
+ },
297
+ },
298
+ }
299
+ const { adapter } = resolveRoute(config, {})
300
+ assert.equal(adapter.config.command, 'new-command')
301
+ })
302
+
303
+ it('ai.commands entries still work alongside ai.providers', () => {
304
+ const config = {
305
+ ai: {
306
+ provider: 'anthropic',
307
+ keys: { anthropic: 'sk-test' },
308
+ commands: {
309
+ 'old-tool': { command: 'old cmd' },
310
+ },
311
+ providers: {
312
+ 'new-tool': { command: 'new cmd' },
313
+ },
314
+ },
315
+ }
316
+ // old-tool from commands still works
317
+ const { adapter: old } = resolveRoute(config, { provider: 'old-tool' })
318
+ assert.equal(old.config.command, 'old cmd')
319
+ // new-tool from providers works
320
+ const { adapter: nw } = resolveRoute(config, { provider: 'new-tool' })
321
+ assert.equal(nw.config.command, 'new cmd')
322
+ })
323
+ })
324
+
325
+ describe('migrateProviderConfig', () => {
326
+ it('merges ai.commands and ai.providers', () => {
327
+ const ai = {
328
+ commands: { a: { command: 'cmd-a' } },
329
+ providers: { b: { command: 'cmd-b' } },
330
+ }
331
+ const merged = migrateProviderConfig(ai)
332
+ assert.equal(merged.a.command, 'cmd-a')
333
+ assert.equal(merged.b.command, 'cmd-b')
334
+ })
335
+
336
+ it('ai.providers wins on conflict', () => {
337
+ const ai = {
338
+ commands: { x: { command: 'old' } },
339
+ providers: { x: { command: 'new' } },
340
+ }
341
+ const merged = migrateProviderConfig(ai)
342
+ assert.equal(merged.x.command, 'new')
343
+ })
344
+
345
+ it('does not include ai.command (handled by built-in command provider)', () => {
346
+ const ai = { command: 'legacy-tool' }
347
+ const merged = migrateProviderConfig(ai)
348
+ assert.deepEqual(merged, {})
349
+ })
350
+
351
+ it('works with empty ai object', () => {
352
+ const merged = migrateProviderConfig({})
353
+ assert.deepEqual(merged, {})
354
+ })
355
+ })
356
+
357
+ describe('resolveProviderAdapter', () => {
358
+ it('resolves a built-in provider', () => {
359
+ const config = {
360
+ ai: {
361
+ keys: { anthropic: 'sk-test' },
362
+ models: { anthropic: 'claude-haiku-4-5-20251001' },
363
+ },
364
+ }
365
+ const { adapter, model } = resolveProviderAdapter(config, 'anthropic')
366
+ assert.equal(adapter.name, 'anthropic')
367
+ assert.equal(model, 'claude-haiku-4-5-20251001')
368
+ })
369
+
370
+ it('resolves a custom provider from ai.providers', () => {
371
+ const config = {
372
+ ai: {
373
+ providers: {
374
+ 'my-cli': { command: 'my-tool run', timeout: 5000 },
375
+ },
376
+ },
377
+ }
378
+ const { adapter } = resolveProviderAdapter(config, 'my-cli')
379
+ assert.equal(adapter.name, 'command')
380
+ assert.equal(adapter.config.command, 'my-tool run')
381
+ assert.equal(adapter.config.timeout, 5000)
382
+ })
383
+
384
+ it('resolves a custom provider from legacy ai.commands', () => {
385
+ const config = {
386
+ ai: {
387
+ commands: {
388
+ 'old-cli': { command: 'old-tool' },
389
+ },
390
+ },
391
+ }
392
+ const { adapter } = resolveProviderAdapter(config, 'old-cli')
393
+ assert.equal(adapter.name, 'command')
394
+ assert.equal(adapter.config.command, 'old-tool')
395
+ })
396
+
397
+ it('applies model override', () => {
398
+ const config = {
399
+ ai: {
400
+ keys: { openai: 'sk-test' },
401
+ models: { openai: 'gpt-4o-mini' },
402
+ },
403
+ }
404
+ const { model } = resolveProviderAdapter(config, 'openai', 'gpt-4o')
405
+ assert.equal(model, 'gpt-4o')
406
+ })
407
+
408
+ it('throws on unknown provider', () => {
409
+ assert.throws(
410
+ () => resolveProviderAdapter({ ai: {} }, 'nonexistent'),
411
+ /Unknown AI provider: nonexistent/,
412
+ )
413
+ })
414
+ })
415
+
416
+ describe('listAllProviders', () => {
417
+ it('includes built-in providers and named commands', () => {
418
+ const config = {
419
+ ai: {
420
+ commands: {
421
+ 'my-gemini': { command: 'co gemini' },
422
+ 'my-claude': { command: 'claude --print' },
423
+ },
424
+ },
425
+ }
426
+ const all = listAllProviders(config)
427
+ assert.ok(all.includes('anthropic'))
428
+ assert.ok(all.includes('openai'))
429
+ assert.ok(all.includes('command'))
430
+ assert.ok(all.includes('my-gemini'))
431
+ assert.ok(all.includes('my-claude'))
432
+ // New built-in CLI adapters
433
+ assert.ok(all.includes('gemini-cli'))
434
+ assert.ok(all.includes('claude-cli'))
435
+ assert.ok(all.includes('codex-cli'))
436
+ assert.ok(all.includes('openclaw'))
437
+ })
438
+
439
+ it('includes providers from ai.providers', () => {
440
+ const config = {
441
+ ai: {
442
+ providers: {
443
+ 'new-tool': { command: 'new cmd' },
444
+ },
445
+ },
446
+ }
447
+ const all = listAllProviders(config)
448
+ assert.ok(all.includes('new-tool'))
449
+ })
450
+
451
+ it('merges ai.commands and ai.providers in listing', () => {
452
+ const config = {
453
+ ai: {
454
+ commands: { 'old-tool': { command: 'old' } },
455
+ providers: { 'new-tool': { command: 'new' } },
456
+ },
457
+ }
458
+ const all = listAllProviders(config)
459
+ assert.ok(all.includes('old-tool'))
460
+ assert.ok(all.includes('new-tool'))
461
+ })
462
+
463
+ it('does not duplicate built-in names', () => {
464
+ const config = {
465
+ ai: {
466
+ commands: {
467
+ anthropic: { command: 'echo test' },
468
+ },
469
+ },
470
+ }
471
+ const all = listAllProviders(config)
472
+ const count = all.filter((p) => p === 'anthropic').length
473
+ assert.equal(count, 1)
474
+ })
475
+
476
+ it('works with no commands configured', () => {
477
+ const all = listAllProviders({ ai: {} })
478
+ assert.ok(all.includes('anthropic'))
479
+ assert.ok(all.length >= 10)
480
+ })
481
+ })