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,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)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync command — manual one-shot sync + background daemon management.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Command } from 'commander'
|
|
6
|
+
import { fork } from 'node:child_process'
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { store } from '../lib/config.js'
|
|
12
|
+
import { requireAuth } from '../lib/gh.js'
|
|
13
|
+
import { syncIssues, syncLabels, syncRepos } from '../lib/cache.js'
|
|
14
|
+
import { pickRepo } from '../lib/repo-picker.js'
|
|
15
|
+
import { green, yellow, red, dim, cyan, bold } from '../lib/color.js'
|
|
16
|
+
import ora from 'ora'
|
|
17
|
+
|
|
18
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'tissues')
|
|
19
|
+
const PID_FILE = path.join(CONFIG_DIR, 'sync.pid')
|
|
20
|
+
const STATUS_FILE = path.join(CONFIG_DIR, 'sync-status.json')
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Daemon helpers
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
function readPid() {
|
|
27
|
+
try {
|
|
28
|
+
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10)
|
|
29
|
+
// Check if process is alive
|
|
30
|
+
process.kill(pid, 0)
|
|
31
|
+
return pid
|
|
32
|
+
} catch {
|
|
33
|
+
// Clean up stale PID file
|
|
34
|
+
try { fs.unlinkSync(PID_FILE) } catch { /* ok */ }
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readStatus() {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(fs.readFileSync(STATUS_FILE, 'utf8'))
|
|
42
|
+
} catch {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function recordActivity() {
|
|
48
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true })
|
|
49
|
+
const status = readStatus() || {}
|
|
50
|
+
status.lastActivity = new Date().toISOString()
|
|
51
|
+
fs.writeFileSync(STATUS_FILE, JSON.stringify(status, null, 2), 'utf8')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Main sync command (one-shot)
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
export const syncCommand = new Command('sync')
|
|
59
|
+
.description('Sync issues, labels, and repos to local cache')
|
|
60
|
+
.option('--repo <repo>', 'Repository to sync (default: active repo)')
|
|
61
|
+
.option('--force', 'Force full sync, ignore freshness')
|
|
62
|
+
.option('--quiet', 'Suppress output')
|
|
63
|
+
.action(async (opts) => {
|
|
64
|
+
requireAuth()
|
|
65
|
+
|
|
66
|
+
let repo = opts.repo || store.get('activeRepo')
|
|
67
|
+
if (!repo) {
|
|
68
|
+
console.log(yellow('No active repo. Pick one:\n'))
|
|
69
|
+
repo = await pickRepo()
|
|
70
|
+
console.log()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const log = opts.quiet ? () => {} : console.log
|
|
74
|
+
|
|
75
|
+
// Sync repos
|
|
76
|
+
const repoSpinner = opts.quiet ? null : ora('Syncing repos...').start()
|
|
77
|
+
try {
|
|
78
|
+
const result = syncRepos({ force: opts.force })
|
|
79
|
+
if (repoSpinner) repoSpinner.succeed(`Repos: ${result.synced} cached`)
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (repoSpinner) repoSpinner.warn(`Repos sync failed: ${err.message}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Sync labels
|
|
85
|
+
const labelSpinner = opts.quiet ? null : ora('Syncing labels...').start()
|
|
86
|
+
try {
|
|
87
|
+
const result = syncLabels(repo, { force: opts.force })
|
|
88
|
+
if (labelSpinner) labelSpinner.succeed(`Labels: ${result.synced} cached`)
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (labelSpinner) labelSpinner.warn(`Labels sync failed: ${err.message}`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Sync issues (incremental)
|
|
94
|
+
const issueSpinner = opts.quiet ? null : ora('Syncing issues...').start()
|
|
95
|
+
try {
|
|
96
|
+
const result = syncIssues(repo, { force: opts.force })
|
|
97
|
+
if (issueSpinner) issueSpinner.succeed(`Issues: ${result.synced} synced (${result.total} total)`)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (issueSpinner) issueSpinner.warn(`Issues sync failed: ${err.message}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
log()
|
|
103
|
+
log(green('Sync complete.'))
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Daemon subcommands
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
const startCommand = new Command('start')
|
|
111
|
+
.description('Start the background sync daemon')
|
|
112
|
+
.action(() => {
|
|
113
|
+
const existing = readPid()
|
|
114
|
+
if (existing) {
|
|
115
|
+
console.log(yellow(`Daemon already running (PID ${existing}).`))
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
120
|
+
const daemonScript = path.join(__dirname, '..', 'daemon', 'sync.js')
|
|
121
|
+
|
|
122
|
+
const child = fork(daemonScript, [], {
|
|
123
|
+
detached: true,
|
|
124
|
+
stdio: 'ignore',
|
|
125
|
+
})
|
|
126
|
+
child.unref()
|
|
127
|
+
|
|
128
|
+
console.log(green(`Sync daemon started (PID ${child.pid}).`))
|
|
129
|
+
console.log(dim('Run `tissues sync status` to check on it.'))
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const stopCommand = new Command('stop')
|
|
133
|
+
.description('Stop the background sync daemon')
|
|
134
|
+
.action(() => {
|
|
135
|
+
const pid = readPid()
|
|
136
|
+
if (!pid) {
|
|
137
|
+
console.log(dim('No daemon running.'))
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
process.kill(pid, 'SIGTERM')
|
|
143
|
+
console.log(green(`Daemon stopped (PID ${pid}).`))
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.warn(yellow(`Could not stop daemon: ${err.message}`))
|
|
146
|
+
// Clean up stale PID file
|
|
147
|
+
try { fs.unlinkSync(PID_FILE) } catch { /* ok */ }
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const restartCommand = new Command('restart')
|
|
152
|
+
.description('Restart the background sync daemon')
|
|
153
|
+
.action(async () => {
|
|
154
|
+
// Stop
|
|
155
|
+
const pid = readPid()
|
|
156
|
+
if (pid) {
|
|
157
|
+
try {
|
|
158
|
+
process.kill(pid, 'SIGTERM')
|
|
159
|
+
console.log(dim(`Stopped daemon (PID ${pid}).`))
|
|
160
|
+
// Brief wait for cleanup
|
|
161
|
+
await new Promise(r => setTimeout(r, 500))
|
|
162
|
+
} catch { /* ok */ }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Start
|
|
166
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
167
|
+
const daemonScript = path.join(__dirname, '..', 'daemon', 'sync.js')
|
|
168
|
+
|
|
169
|
+
const child = fork(daemonScript, [], {
|
|
170
|
+
detached: true,
|
|
171
|
+
stdio: 'ignore',
|
|
172
|
+
})
|
|
173
|
+
child.unref()
|
|
174
|
+
|
|
175
|
+
console.log(green(`Sync daemon restarted (PID ${child.pid}).`))
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const statusCommand = new Command('status')
|
|
179
|
+
.description('Show background sync daemon status')
|
|
180
|
+
.action(() => {
|
|
181
|
+
const pid = readPid()
|
|
182
|
+
const status = readStatus()
|
|
183
|
+
|
|
184
|
+
if (!pid && !status) {
|
|
185
|
+
console.log(dim('No daemon has been started.'))
|
|
186
|
+
console.log(dim('Run `tissues sync start` to begin background syncing.'))
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
console.log(bold('\nSync Daemon'))
|
|
191
|
+
if (pid) {
|
|
192
|
+
console.log(` Status: ${green('running')} (PID ${pid})`)
|
|
193
|
+
} else {
|
|
194
|
+
console.log(` Status: ${dim(status?.status || 'stopped')}`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (status) {
|
|
198
|
+
if (status.startedAt) console.log(` Started: ${dim(status.startedAt)}`)
|
|
199
|
+
if (status.lastSync) console.log(` Last sync: ${dim(status.lastSync)}`)
|
|
200
|
+
if (status.nextSync) console.log(` Next sync: ${dim(status.nextSync)}`)
|
|
201
|
+
if (status.lastError) console.log(` Last error: ${red(status.lastError)}`)
|
|
202
|
+
|
|
203
|
+
if (status.perRepoStats) {
|
|
204
|
+
console.log(bold('\n Per-repo stats:'))
|
|
205
|
+
for (const [repo, stats] of Object.entries(status.perRepoStats)) {
|
|
206
|
+
if (stats.status === 'ok') {
|
|
207
|
+
console.log(` ${cyan(repo)}: ${stats.issues} issues, ${stats.labels} labels`)
|
|
208
|
+
} else if (stats.status === 'error') {
|
|
209
|
+
console.log(` ${cyan(repo)}: ${red(stats.error)}`)
|
|
210
|
+
} else if (stats.status === 'skipped') {
|
|
211
|
+
console.log(` ${cyan(repo)}: ${yellow(stats.reason)}`)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
console.log()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
syncCommand.addCommand(startCommand)
|
|
220
|
+
syncCommand.addCommand(stopCommand)
|
|
221
|
+
syncCommand.addCommand(restartCommand)
|
|
222
|
+
syncCommand.addCommand(statusCommand)
|
|
223
|
+
|
|
224
|
+
// Export recordActivity for use in preAction hook
|
|
225
|
+
export { recordActivity }
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Background sync daemon for tissues.
|
|
5
|
+
*
|
|
6
|
+
* Keeps the local cache warm by periodically syncing issues, labels,
|
|
7
|
+
* and repos in the background. Runs as a detached child process.
|
|
8
|
+
*
|
|
9
|
+
* PID file: ~/.config/tissues/sync.pid
|
|
10
|
+
* Status file: ~/.config/tissues/sync-status.json
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import os from 'node:os'
|
|
16
|
+
import { syncIssues, syncLabels, syncRepos } from '../lib/cache.js'
|
|
17
|
+
import { getToken } from '../lib/gh.js'
|
|
18
|
+
import { loadConfig } from '../lib/defaults.js'
|
|
19
|
+
import { store } from '../lib/config.js'
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Paths
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'tissues')
|
|
26
|
+
const PID_FILE = path.join(CONFIG_DIR, 'sync.pid')
|
|
27
|
+
const STATUS_FILE = path.join(CONFIG_DIR, 'sync-status.json')
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Status file operations
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
function readStatus() {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(STATUS_FILE, 'utf8'))
|
|
36
|
+
} catch {
|
|
37
|
+
return {}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeStatus(updates) {
|
|
42
|
+
const current = readStatus()
|
|
43
|
+
const merged = { ...current, ...updates }
|
|
44
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true })
|
|
45
|
+
fs.writeFileSync(STATUS_FILE, JSON.stringify(merged, null, 2), 'utf8')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Adaptive interval
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
function getInterval(config) {
|
|
53
|
+
const syncConfig = config.sync || {}
|
|
54
|
+
const baseInterval = (syncConfig.interval || 300) * 1000
|
|
55
|
+
|
|
56
|
+
if (!syncConfig.adaptiveInterval) return baseInterval
|
|
57
|
+
|
|
58
|
+
const status = readStatus()
|
|
59
|
+
const lastActivity = status.lastActivity ? new Date(status.lastActivity).getTime() : 0
|
|
60
|
+
const idle = Date.now() - lastActivity
|
|
61
|
+
|
|
62
|
+
const THIRTY_MIN = 30 * 60 * 1000
|
|
63
|
+
const TWO_HOURS = 2 * 60 * 60 * 1000
|
|
64
|
+
const EIGHT_HOURS = 8 * 60 * 60 * 1000
|
|
65
|
+
|
|
66
|
+
if (idle < THIRTY_MIN) return 2 * 60 * 1000 // Active: every 2 min
|
|
67
|
+
if (idle < TWO_HOURS) return 5 * 60 * 1000 // Normal: every 5 min
|
|
68
|
+
if (idle < EIGHT_HOURS) return 15 * 60 * 1000 // Idle: every 15 min
|
|
69
|
+
return 60 * 60 * 1000 // Sleep: every 60 min
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Sync cycle
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
const failureCounts = new Map() // repo -> consecutive failure count
|
|
77
|
+
const SKIP_THRESHOLD = 5
|
|
78
|
+
const SKIP_DURATION = 60 * 60 * 1000 // 1 hour
|
|
79
|
+
const skipUntil = new Map()
|
|
80
|
+
|
|
81
|
+
async function syncCycle(config) {
|
|
82
|
+
// Auth check
|
|
83
|
+
const token = getToken()
|
|
84
|
+
if (!token) {
|
|
85
|
+
writeStatus({ lastError: 'Not authenticated', lastErrorAt: new Date().toISOString() })
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const syncConfig = config.sync || {}
|
|
90
|
+
const repos = syncConfig.repos?.length
|
|
91
|
+
? syncConfig.repos
|
|
92
|
+
: [store.get('activeRepo')].filter(Boolean)
|
|
93
|
+
|
|
94
|
+
if (repos.length === 0) {
|
|
95
|
+
writeStatus({ lastError: 'No repos configured', lastErrorAt: new Date().toISOString() })
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const perRepoStats = {}
|
|
100
|
+
|
|
101
|
+
for (const repo of repos) {
|
|
102
|
+
// Check skip
|
|
103
|
+
const skipTime = skipUntil.get(repo)
|
|
104
|
+
if (skipTime && Date.now() < skipTime) {
|
|
105
|
+
perRepoStats[repo] = { status: 'skipped', reason: 'too many failures' }
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const issueResult = syncIssues(repo)
|
|
111
|
+
const labelResult = syncLabels(repo)
|
|
112
|
+
perRepoStats[repo] = {
|
|
113
|
+
status: 'ok',
|
|
114
|
+
issues: issueResult.synced,
|
|
115
|
+
labels: labelResult.synced,
|
|
116
|
+
}
|
|
117
|
+
failureCounts.set(repo, 0)
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const count = (failureCounts.get(repo) || 0) + 1
|
|
120
|
+
failureCounts.set(repo, count)
|
|
121
|
+
perRepoStats[repo] = { status: 'error', error: err.message }
|
|
122
|
+
|
|
123
|
+
if (count >= SKIP_THRESHOLD) {
|
|
124
|
+
skipUntil.set(repo, Date.now() + SKIP_DURATION)
|
|
125
|
+
perRepoStats[repo].skippedUntil = new Date(Date.now() + SKIP_DURATION).toISOString()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Sync repos list (global, not per-repo)
|
|
131
|
+
try {
|
|
132
|
+
syncRepos()
|
|
133
|
+
} catch {
|
|
134
|
+
// Non-fatal
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
writeStatus({
|
|
138
|
+
lastSync: new Date().toISOString(),
|
|
139
|
+
perRepoStats,
|
|
140
|
+
lastError: null,
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Main loop
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
async function main() {
|
|
149
|
+
// Write PID
|
|
150
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true })
|
|
151
|
+
fs.writeFileSync(PID_FILE, String(process.pid), 'utf8')
|
|
152
|
+
|
|
153
|
+
writeStatus({
|
|
154
|
+
pid: process.pid,
|
|
155
|
+
startedAt: new Date().toISOString(),
|
|
156
|
+
status: 'running',
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// Graceful shutdown
|
|
160
|
+
const cleanup = () => {
|
|
161
|
+
writeStatus({ status: 'stopped', stoppedAt: new Date().toISOString() })
|
|
162
|
+
try { fs.unlinkSync(PID_FILE) } catch { /* ok */ }
|
|
163
|
+
process.exit(0)
|
|
164
|
+
}
|
|
165
|
+
process.on('SIGTERM', cleanup)
|
|
166
|
+
process.on('SIGINT', cleanup)
|
|
167
|
+
|
|
168
|
+
// Run first cycle immediately
|
|
169
|
+
const config = loadConfig()
|
|
170
|
+
await syncCycle(config)
|
|
171
|
+
|
|
172
|
+
// Schedule recurring
|
|
173
|
+
const schedule = () => {
|
|
174
|
+
const freshConfig = loadConfig()
|
|
175
|
+
const interval = getInterval(freshConfig)
|
|
176
|
+
writeStatus({ nextSync: new Date(Date.now() + interval).toISOString() })
|
|
177
|
+
setTimeout(async () => {
|
|
178
|
+
await syncCycle(freshConfig)
|
|
179
|
+
schedule()
|
|
180
|
+
}, interval)
|
|
181
|
+
}
|
|
182
|
+
schedule()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
main().catch(err => {
|
|
186
|
+
writeStatus({ status: 'crashed', error: err.message, crashedAt: new Date().toISOString() })
|
|
187
|
+
try { fs.unlinkSync(PID_FILE) } catch { /* ok */ }
|
|
188
|
+
process.exit(1)
|
|
189
|
+
})
|
|
@@ -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 Claude Code CLI (anthropic claude-code).
|
|
8
|
+
* Uses: claude --print 'prompt' --model MODEL --dangerously-skip-permissions
|
|
9
|
+
*/
|
|
10
|
+
export class ClaudeCliAdapter extends BaseAdapter {
|
|
11
|
+
get name() {
|
|
12
|
+
return 'claude-cli'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
isConfigured() {
|
|
16
|
+
return !!this.config.binary
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async complete(messages, opts = {}) {
|
|
20
|
+
const binary = this.config.binary || 'claude'
|
|
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 = ['--print', fullPrompt, '--dangerously-skip-permissions']
|
|
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(`claude-cli error: ${this.sanitizeErrorBody(msg)}`))
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
const text = stdout.trim()
|
|
47
|
+
if (!text) {
|
|
48
|
+
reject(new Error('claude-cli returned no output'))
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
resolve(text)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
}
|