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,347 @@
1
+ import { Command } from 'commander'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { bold, dim, green, yellow, red, cyan } from '../lib/color.js'
5
+ import { select, input } from '@inquirer/prompts'
6
+ import { loadConfig, userConfigPath } from '../lib/defaults.js'
7
+ import { theme } from '../lib/theme.js'
8
+ import { listProviders, listAllProviders, resolveProviderAdapter, migrateProviderConfig } from '../lib/ai/router.js'
9
+ import { discoverProviders } from '../lib/ai/discovery.js'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function readUserConfig() {
16
+ try {
17
+ return JSON.parse(fs.readFileSync(userConfigPath(), 'utf8'))
18
+ } catch {
19
+ return {}
20
+ }
21
+ }
22
+
23
+ function writeUserConfig(obj) {
24
+ const filePath = userConfigPath()
25
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 })
26
+ fs.writeFileSync(filePath, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
27
+ }
28
+
29
+ const PROVIDER_TYPES = {
30
+ anthropic: 'api',
31
+ openai: 'api',
32
+ gemini: 'api',
33
+ ollama: 'api',
34
+ 'openai-compat': 'api',
35
+ command: 'cli',
36
+ 'gemini-cli': 'cli',
37
+ 'claude-cli': 'cli',
38
+ 'codex-cli': 'cli',
39
+ openclaw: 'openclaw',
40
+ }
41
+
42
+ function providerType(name) {
43
+ return PROVIDER_TYPES[name] || 'custom'
44
+ }
45
+
46
+ function statusIcon(configured) {
47
+ return configured ? green('●') : dim('○')
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Dashboard
52
+ // ---------------------------------------------------------------------------
53
+
54
+ async function showDashboard() {
55
+ const config = loadConfig()
56
+ const ai = config.ai || {}
57
+ const activeProvider = ai.provider || 'anthropic'
58
+
59
+ // Discover available providers on this system
60
+ const discovered = await discoverProviders()
61
+ const discoveredNames = new Set(discovered.map((d) => d.name))
62
+
63
+ // All known providers (built-in + custom)
64
+ const allProviders = listAllProviders(config)
65
+
66
+ console.log(bold('\n AI Providers\n'))
67
+
68
+ // Active provider
69
+ console.log(` ${green('●')} ${bold(activeProvider)} ${dim('(active)')}\n`)
70
+
71
+ // Built-in providers
72
+ console.log(dim(' Built-in:'))
73
+ const builtIn = listProviders()
74
+ for (const name of builtIn) {
75
+ if (name === 'command') continue // skip generic command adapter
76
+ try {
77
+ const { adapter } = resolveProviderAdapter(config, name)
78
+ const configured = adapter.isConfigured()
79
+ const type = providerType(name)
80
+ const active = name === activeProvider ? ` ${green('← active')}` : ''
81
+ const disc = discoveredNames.has(name) ? ` ${cyan('discovered')}` : ''
82
+ console.log(` ${statusIcon(configured)} ${name} ${dim(`[${type}]`)}${active}${disc}`)
83
+ } catch {
84
+ console.log(` ${dim('○')} ${name} ${dim('[unavailable]')}`)
85
+ }
86
+ }
87
+
88
+ // Custom providers
89
+ const customNames = allProviders.filter((n) => !builtIn.includes(n))
90
+ if (customNames.length > 0) {
91
+ console.log(dim('\n Custom:'))
92
+ for (const name of customNames) {
93
+ try {
94
+ const { adapter } = resolveProviderAdapter(config, name)
95
+ const configured = adapter.isConfigured()
96
+ const active = name === activeProvider ? ` ${green('← active')}` : ''
97
+ console.log(` ${statusIcon(configured)} ${name} ${dim('[custom]')}${active}`)
98
+ } catch {
99
+ console.log(` ${dim('○')} ${name} ${dim('[error]')}`)
100
+ }
101
+ }
102
+ }
103
+
104
+ // Discovered but not yet configured
105
+ const unconfigured = discovered.filter((d) => !allProviders.includes(d.name))
106
+ if (unconfigured.length > 0) {
107
+ console.log(dim('\n Discovered (not configured):'))
108
+ for (const d of unconfigured) {
109
+ console.log(` ${yellow('◌')} ${d.name} ${dim(`[${d.type}]`)} — ${dim(`run: tissues providers add ${d.name}`)}`)
110
+ }
111
+ }
112
+
113
+ // Routes
114
+ if (ai.routes?.length) {
115
+ console.log(dim('\n Routes:'))
116
+ for (const rule of ai.routes) {
117
+ const match = rule.match?.template
118
+ ? `template:${rule.match.template}`
119
+ : rule.match?.labels
120
+ ? `labels:${rule.match.labels.join(',')}`
121
+ : '?'
122
+ console.log(` ${dim('→')} ${match} ${dim('→')} ${rule.provider}${rule.model ? ` (${rule.model})` : ''}`)
123
+ }
124
+ }
125
+
126
+ console.log()
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Add provider
131
+ // ---------------------------------------------------------------------------
132
+
133
+ async function addProvider(name) {
134
+ const config = loadConfig()
135
+ const ai = config.ai || {}
136
+ const builtIn = listProviders()
137
+ const activeProvider = ai.provider || 'anthropic'
138
+
139
+ if (!name) {
140
+ // Interactive: discover + let user pick (exclude already-active provider)
141
+ const discovered = await discoverProviders()
142
+ const choices = discovered
143
+ .filter((d) => d.name !== activeProvider) // Don't show the already-active provider
144
+ .map((d) => ({
145
+ name: `${d.name} [${d.type}]${d.status === 'configured' ? dim(' (configured)') : ''}`,
146
+ value: d.name,
147
+ }))
148
+ if (choices.length === 0) {
149
+ console.log(yellow('No new providers discovered. Install a CLI (gemini, claude, codex) or set API keys.'))
150
+ return
151
+ }
152
+ name = await select({
153
+ message: 'Select a provider to configure:',
154
+ choices,
155
+ theme,
156
+ })
157
+ }
158
+
159
+ if (builtIn.includes(name)) {
160
+ // Built-in provider — just set as active
161
+ const current = readUserConfig()
162
+ if (!current.ai) current.ai = {}
163
+ current.ai.provider = name
164
+ writeUserConfig(current)
165
+ console.log(green(`Set ${bold(name)} as active provider.`))
166
+ return
167
+ }
168
+
169
+ // CLI or OpenClaw provider that was discovered
170
+ const discovered = await discoverProviders()
171
+ const match = discovered.find((d) => d.name === name)
172
+
173
+ if (match?.type === 'openclaw') {
174
+ const current = readUserConfig()
175
+ if (!current.ai) current.ai = {}
176
+ current.ai.openclaw = {
177
+ gatewayUrl: match.config.gatewayUrl,
178
+ token: match.config.token,
179
+ }
180
+ current.ai.provider = 'openclaw'
181
+ writeUserConfig(current)
182
+ console.log(green(`Configured OpenClaw gateway and set as active provider.`))
183
+ return
184
+ }
185
+
186
+ if (match?.type === 'cli') {
187
+ const current = readUserConfig()
188
+ if (!current.ai) current.ai = {}
189
+ current.ai.provider = name
190
+ writeUserConfig(current)
191
+ console.log(green(`Set ${bold(name)} as active provider.`))
192
+ return
193
+ }
194
+
195
+ // Custom provider — prompt for command
196
+ const command = await input({
197
+ message: `Shell command for ${name}:`,
198
+ theme,
199
+ })
200
+ if (!command) return
201
+
202
+ const current = readUserConfig()
203
+ if (!current.ai) current.ai = {}
204
+ if (!current.ai.providers) current.ai.providers = {}
205
+ current.ai.providers[name] = { command }
206
+ writeUserConfig(current)
207
+ console.log(green(`Added custom provider ${bold(name)}.`))
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Test provider
212
+ // ---------------------------------------------------------------------------
213
+
214
+ async function testProvider(name) {
215
+ const config = loadConfig()
216
+
217
+ if (!name) {
218
+ // Interactive: let user pick from configured providers
219
+ const allProviders = listAllProviders(config)
220
+ const discovered = await discoverProviders()
221
+ const configured = discovered.filter((d) => d.status === 'configured' || allProviders.includes(d.name))
222
+
223
+ if (configured.length === 0) {
224
+ console.log(yellow('No configured providers to test. Run `tissues providers add` first.'))
225
+ return
226
+ }
227
+
228
+ const choices = configured.map((d) => ({
229
+ name: `${d.name} [${d.type}]`,
230
+ value: d.name,
231
+ }))
232
+
233
+ name = await select({
234
+ message: 'Select a provider to test:',
235
+ choices,
236
+ theme,
237
+ })
238
+ }
239
+
240
+ console.log(dim(`Testing ${name}...`))
241
+
242
+ try {
243
+ const { adapter, model } = resolveProviderAdapter(config, name)
244
+ if (!adapter.isConfigured()) {
245
+ console.error(red(`Provider ${name} is not configured.`))
246
+ process.exitCode = 1
247
+ return
248
+ }
249
+
250
+ const messages = [
251
+ { role: 'system', content: 'You are a helpful assistant. Reply in one short sentence.' },
252
+ { role: 'user', content: 'Say "hello from tissues" and nothing else.' },
253
+ ]
254
+ const response = await adapter.complete(messages, { model, maxTokens: 100 })
255
+ console.log(green('✓ Response:'), response)
256
+ } catch (err) {
257
+ console.error(red(`✗ ${err.message}`))
258
+ process.exitCode = 1
259
+ }
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Remove provider
264
+ // ---------------------------------------------------------------------------
265
+
266
+ async function removeProvider(name) {
267
+ const config = loadConfig()
268
+ const builtIn = listProviders()
269
+
270
+ if (!name) {
271
+ // Interactive: let user pick from custom providers only
272
+ const customProviders = migrateProviderConfig(config.ai || {})
273
+ const customNames = Object.keys(customProviders)
274
+
275
+ if (customNames.length === 0) {
276
+ console.log(yellow('No custom providers to remove.'))
277
+ return
278
+ }
279
+
280
+ const choices = customNames.map((n) => ({
281
+ name: n,
282
+ value: n,
283
+ }))
284
+
285
+ name = await select({
286
+ message: 'Select a provider to remove:',
287
+ choices,
288
+ theme,
289
+ })
290
+ }
291
+
292
+ if (builtIn.includes(name)) {
293
+ console.error(red(`Cannot remove built-in provider ${name}. Use 'tissues config set ai.provider <other>' to switch.`))
294
+ process.exitCode = 1
295
+ return
296
+ }
297
+
298
+ const current = readUserConfig()
299
+ if (current.ai?.providers?.[name]) {
300
+ delete current.ai.providers[name]
301
+ if (current.ai.provider === name) {
302
+ current.ai.provider = 'anthropic'
303
+ }
304
+ writeUserConfig(current)
305
+ console.log(green(`Removed provider ${bold(name)}.`))
306
+ } else if (current.ai?.commands?.[name]) {
307
+ delete current.ai.commands[name]
308
+ if (current.ai.provider === name) {
309
+ current.ai.provider = 'anthropic'
310
+ }
311
+ writeUserConfig(current)
312
+ console.log(green(`Removed legacy provider ${bold(name)}.`))
313
+ } else {
314
+ console.error(yellow(`Provider ${name} not found in user config.`))
315
+ }
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Command
320
+ // ---------------------------------------------------------------------------
321
+
322
+ export const providersCommand = new Command('providers')
323
+ .description('Manage AI providers — discover, configure, and test')
324
+ .action(async () => {
325
+ await showDashboard()
326
+ })
327
+
328
+ providersCommand
329
+ .command('add [name]')
330
+ .description('Add or auto-configure a provider')
331
+ .action(async (name) => {
332
+ await addProvider(name)
333
+ })
334
+
335
+ providersCommand
336
+ .command('test [name]')
337
+ .description('Send a test prompt to verify a provider works')
338
+ .action(async (name) => {
339
+ await testProvider(name)
340
+ })
341
+
342
+ providersCommand
343
+ .command('remove [name]')
344
+ .description('Remove a custom provider from config')
345
+ .action(async (name) => {
346
+ await removeProvider(name)
347
+ })
@@ -0,0 +1,28 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { providersCommand } from './providers.js'
4
+
5
+ describe('providersCommand', () => {
6
+ it('is a Commander command', () => {
7
+ assert.equal(providersCommand.name(), 'providers')
8
+ })
9
+
10
+ it('has description', () => {
11
+ assert.ok(providersCommand.description().length > 0)
12
+ })
13
+
14
+ it('has add subcommand', () => {
15
+ const sub = providersCommand.commands.find((c) => c.name() === 'add')
16
+ assert.ok(sub, 'add subcommand exists')
17
+ })
18
+
19
+ it('has test subcommand', () => {
20
+ const sub = providersCommand.commands.find((c) => c.name() === 'test')
21
+ assert.ok(sub, 'test subcommand exists')
22
+ })
23
+
24
+ it('has remove subcommand', () => {
25
+ const sub = providersCommand.commands.find((c) => c.name() === 'remove')
26
+ assert.ok(sub, 'remove subcommand exists')
27
+ })
28
+ })
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Storage manager command — inspect and manage all cached/stored data.
3
+ */
4
+
5
+ import { Command } from 'commander'
6
+ import { confirm } from '@inquirer/prompts'
7
+ import { store } from '../lib/config.js'
8
+ import { theme } from '../lib/theme.js'
9
+ import {
10
+ getStorageOverview,
11
+ getIssueBreakdown,
12
+ clearCachedData,
13
+ exportStorageJson,
14
+ formatBytes,
15
+ timeAgo,
16
+ } from '../lib/storage.js'
17
+ import { bold, dim, cyan, green, yellow, red } from '../lib/color.js'
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Display helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ function padRight(str, len) {
24
+ return (str + ' '.repeat(len)).slice(0, len)
25
+ }
26
+
27
+ function printOverview(overview) {
28
+ const { database, files, totalDiskUsage } = overview
29
+
30
+ console.log(bold('\nDatabase'))
31
+ console.log(` Path: ${dim(database.path)}`)
32
+ console.log(` Size: ${formatBytes(database.size)}`)
33
+
34
+ const tableOrder = [
35
+ 'fingerprints', 'idempotency_keys',
36
+ 'cached_issues', 'cached_labels', 'cached_repos',
37
+ 'circuit_breaker', 'rate_events', 'sync_meta',
38
+ ]
39
+
40
+ for (const name of tableOrder) {
41
+ const info = database.tables[name]
42
+ if (!info) continue
43
+ const rowStr = padRight(`${info.rows} rows`, 14)
44
+ const syncStr = info.lastSync ? `last: ${timeAgo(info.lastSync)}` : ''
45
+ console.log(` ${padRight(name, 18)} ${rowStr} ${dim(syncStr)}`)
46
+ }
47
+
48
+ console.log(bold('\nFiles'))
49
+ if (files.drafts.count > 0) {
50
+ console.log(` Drafts: ${files.drafts.count} file${files.drafts.count === 1 ? '' : 's'} ${formatBytes(files.drafts.totalSize)}`)
51
+ }
52
+ if (files.images.count > 0) {
53
+ console.log(` Images: ${files.images.count} file${files.images.count === 1 ? '' : 's'} ${formatBytes(files.images.totalSize)}`)
54
+ }
55
+ if (files.templates.count > 0) {
56
+ console.log(` Templates: ${files.templates.count} file${files.templates.count === 1 ? '' : 's'}`)
57
+ }
58
+ if (files.enhancements.count > 0) {
59
+ console.log(` Enhancements: ${files.enhancements.count} file${files.enhancements.count === 1 ? '' : 's'}`)
60
+ }
61
+ if (files.drafts.count + files.images.count + files.templates.count + files.enhancements.count === 0) {
62
+ console.log(dim(' (none)'))
63
+ }
64
+
65
+ console.log(`\n ${bold('Total disk usage:')} ${formatBytes(totalDiskUsage)}`)
66
+ console.log()
67
+ }
68
+
69
+ function printIssueBreakdown(breakdown) {
70
+ if (breakdown.length === 0) {
71
+ console.log(dim('\n No cached issues.\n'))
72
+ return
73
+ }
74
+
75
+ console.log(bold('\nCached Issues'))
76
+ for (const entry of breakdown) {
77
+ const total = entry.open + entry.closed
78
+ const sync = entry.lastSync ? timeAgo(entry.lastSync) : 'never'
79
+ console.log(` ${cyan(entry.repo)}`)
80
+ console.log(` ${entry.open} open, ${entry.closed} closed (${total} total) last sync: ${dim(sync)}`)
81
+ }
82
+ console.log()
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Command
87
+ // ---------------------------------------------------------------------------
88
+
89
+ export const storageCommand = new Command('storage')
90
+ .description('Inspect and manage cached data')
91
+ .argument('[category]', 'Drill-down category (issues, labels, fingerprints)')
92
+ .option('--repo <repo>', 'Scope to a specific repo')
93
+ .option('--json', 'Output as JSON')
94
+ .action(async (category, opts) => {
95
+ const repo = opts.repo || store.get('activeRepo') || undefined
96
+
97
+ if (opts.json) {
98
+ const data = exportStorageJson({ repo })
99
+ console.log(JSON.stringify(data, null, 2))
100
+ return
101
+ }
102
+
103
+ if (category === 'issues') {
104
+ const breakdown = getIssueBreakdown()
105
+ printIssueBreakdown(breakdown)
106
+ return
107
+ }
108
+
109
+ const overview = getStorageOverview({ repo })
110
+ printOverview(overview)
111
+ })
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Subcommands
115
+ // ---------------------------------------------------------------------------
116
+
117
+ const clearCommand = new Command('clear')
118
+ .description('Clear cached data')
119
+ .argument('<target>', 'What to clear (issues, labels, repos, fingerprints, all)')
120
+ .option('--repo <repo>', 'Scope to a specific repo')
121
+ .option('--include-safety', 'Also reset circuit breaker and rate events')
122
+ .option('--force', 'Skip confirmation')
123
+ .action(async (target, opts) => {
124
+ const validTargets = ['issues', 'labels', 'repos', 'fingerprints', 'all']
125
+ if (!validTargets.includes(target)) {
126
+ console.error(red(`Invalid target: ${target}`))
127
+ console.error(dim(`Valid targets: ${validTargets.join(', ')}`))
128
+ process.exit(1)
129
+ }
130
+
131
+ const repo = opts.repo || undefined
132
+ const scope = repo ? ` for ${cyan(repo)}` : ''
133
+
134
+ if (!opts.force) {
135
+ const msg = target === 'all'
136
+ ? `Clear ALL cached data${scope}?${opts.includeSafety ? ' (including safety data)' : ''}`
137
+ : `Clear cached ${target}${scope}?`
138
+ const ok = await confirm({ message: msg, default: false, theme })
139
+ if (!ok) {
140
+ console.log(dim('Aborted.'))
141
+ return
142
+ }
143
+ }
144
+
145
+ const { cleared } = clearCachedData(target, {
146
+ repo,
147
+ includeSafety: opts.includeSafety || false,
148
+ })
149
+
150
+ if (cleared.length > 0) {
151
+ console.log(green('Cleared: ') + cleared.join(', '))
152
+ } else {
153
+ console.log(dim('Nothing to clear.'))
154
+ }
155
+ })
156
+
157
+ const exportCommand = new Command('export')
158
+ .description('Export storage metadata as JSON')
159
+ .option('--repo <repo>', 'Scope to a specific repo')
160
+ .action((opts) => {
161
+ const repo = opts.repo || store.get('activeRepo') || undefined
162
+ const data = exportStorageJson({ repo })
163
+ console.log(JSON.stringify(data, null, 2))
164
+ })
165
+
166
+ storageCommand.addCommand(clearCommand)
167
+ storageCommand.addCommand(exportCommand)