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,408 @@
1
+ /**
2
+ * Local SQLite cache for issues, labels, and repos.
3
+ *
4
+ * Eliminates redundant network calls by storing GitHub data locally.
5
+ * Uses incremental sync for issues (since-based) and full-replace for
6
+ * labels and repos.
7
+ */
8
+
9
+ import { getDb } from './db.js'
10
+ import { fetchIssuesApi, fetchLabelsApi, listIssues, listLabels, listRepos } from './gh.js'
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Staleness TTLs (milliseconds)
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const TTL = {
17
+ issues_open: { fresh: 2 * 60 * 1000, stale: 10 * 60 * 1000 },
18
+ issues_closed: { fresh: 24 * 60 * 60 * 1000, stale: 7 * 24 * 60 * 60 * 1000 },
19
+ labels: { fresh: 15 * 60 * 1000, stale: 60 * 60 * 1000 },
20
+ repos: { fresh: 30 * 60 * 1000, stale: 2 * 60 * 60 * 1000 },
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Sync metadata CRUD
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * Get sync metadata for a repo+resource pair.
29
+ *
30
+ * @param {string} repo
31
+ * @param {string} resource
32
+ * @returns {{ lastSyncedAt: string|null, lastCursor: string|null } | null}
33
+ */
34
+ export function getSyncMeta(repo, resource) {
35
+ const db = getDb()
36
+ const row = db.prepare(
37
+ 'SELECT last_synced_at, last_cursor FROM sync_meta WHERE repo = ? AND resource = ?',
38
+ ).get(repo, resource)
39
+ if (!row) return null
40
+ return { lastSyncedAt: row.last_synced_at, lastCursor: row.last_cursor }
41
+ }
42
+
43
+ /**
44
+ * Update sync metadata for a repo+resource pair.
45
+ *
46
+ * @param {string} repo
47
+ * @param {string} resource
48
+ * @param {{ lastSyncedAt?: string, lastCursor?: string }} meta
49
+ */
50
+ export function setSyncMeta(repo, resource, meta) {
51
+ const db = getDb()
52
+ db.prepare(`
53
+ INSERT INTO sync_meta (repo, resource, last_synced_at, last_cursor)
54
+ VALUES (?, ?, ?, ?)
55
+ ON CONFLICT(repo, resource) DO UPDATE SET
56
+ last_synced_at = COALESCE(?, last_synced_at),
57
+ last_cursor = COALESCE(?, last_cursor)
58
+ `).run(
59
+ repo, resource,
60
+ meta.lastSyncedAt || null, meta.lastCursor || null,
61
+ meta.lastSyncedAt || null, meta.lastCursor || null,
62
+ )
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Freshness checks
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Check freshness of cached data for a repo+resource.
71
+ *
72
+ * @param {string} repo
73
+ * @param {string} resource - 'issues' | 'labels' | 'repos'
74
+ * @returns {'fresh' | 'stale' | 'expired' | 'empty'}
75
+ */
76
+ export function checkFreshness(repo, resource) {
77
+ const meta = getSyncMeta(repo, resource)
78
+ if (!meta || !meta.lastSyncedAt) {
79
+ // Check if we have any rows at all
80
+ const db = getDb()
81
+ let count = 0
82
+ if (resource === 'issues') {
83
+ count = db.prepare('SELECT COUNT(*) as cnt FROM cached_issues WHERE repo = ?').get(repo)?.cnt || 0
84
+ } else if (resource === 'labels') {
85
+ count = db.prepare('SELECT COUNT(*) as cnt FROM cached_labels WHERE repo = ?').get(repo)?.cnt || 0
86
+ } else if (resource === 'repos') {
87
+ count = db.prepare('SELECT COUNT(*) as cnt FROM cached_repos').get()?.cnt || 0
88
+ }
89
+ return count > 0 ? 'expired' : 'empty'
90
+ }
91
+
92
+ const syncedAt = new Date(meta.lastSyncedAt + 'Z').getTime()
93
+ const age = Date.now() - syncedAt
94
+ const ttlKey = resource === 'issues' ? 'issues_open' : resource
95
+ const ttl = TTL[ttlKey]
96
+ if (!ttl) return 'expired'
97
+
98
+ if (age <= ttl.fresh) return 'fresh'
99
+ if (age <= ttl.stale) return 'stale'
100
+ return 'expired'
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Cache read operations
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Get cached issues for a repo.
109
+ *
110
+ * @param {string} repo
111
+ * @param {{ state?: string, limit?: number }} [opts]
112
+ * @returns {Array<{ number: number, title: string, body: string|null, state: string, labels: string[], url: string|null, author: string|null, createdAt: string|null, updatedAt: string|null }>}
113
+ */
114
+ export function getCachedIssues(repo, opts = {}) {
115
+ const db = getDb()
116
+ const { state = 'open', limit = 500 } = opts
117
+ const rows = db.prepare(
118
+ `SELECT number, title, body, state, labels, url, author, created_at, updated_at
119
+ FROM cached_issues
120
+ WHERE repo = ? AND state = ?
121
+ ORDER BY updated_at DESC
122
+ LIMIT ?`,
123
+ ).all(repo, state, limit)
124
+
125
+ return rows.map(r => ({
126
+ number: r.number,
127
+ title: r.title,
128
+ body: r.body,
129
+ state: r.state,
130
+ labels: JSON.parse(r.labels || '[]'),
131
+ url: r.url,
132
+ author: r.author,
133
+ createdAt: r.created_at,
134
+ updatedAt: r.updated_at,
135
+ }))
136
+ }
137
+
138
+ /**
139
+ * Get cached labels for a repo.
140
+ *
141
+ * @param {string} repo
142
+ * @returns {string[]}
143
+ */
144
+ export function getCachedLabels(repo) {
145
+ const db = getDb()
146
+ const rows = db.prepare(
147
+ 'SELECT name FROM cached_labels WHERE repo = ? ORDER BY name',
148
+ ).all(repo)
149
+ return rows.map(r => r.name)
150
+ }
151
+
152
+ /**
153
+ * Get cached repos.
154
+ *
155
+ * @returns {string[]}
156
+ */
157
+ export function getCachedRepos() {
158
+ const db = getDb()
159
+ const rows = db.prepare(
160
+ 'SELECT name_with_owner FROM cached_repos ORDER BY name_with_owner',
161
+ ).all()
162
+ return rows.map(r => r.name_with_owner)
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Sync operations
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /**
170
+ * Sync issues for a repo using incremental `since`-based fetching.
171
+ * Uses `gh api` with `since` parameter to fetch only changed issues.
172
+ *
173
+ * @param {string} repo
174
+ * @param {{ force?: boolean }} [opts]
175
+ * @returns {{ synced: number, total: number }}
176
+ */
177
+ export function syncIssues(repo, opts = {}) {
178
+ const db = getDb()
179
+ const meta = opts.force ? null : getSyncMeta(repo, 'issues')
180
+ const since = meta?.lastCursor || undefined
181
+
182
+ let totalSynced = 0
183
+ let maxUpdatedAt = since || ''
184
+ let page = 1
185
+
186
+ while (true) {
187
+ const issues = fetchIssuesApi(repo, { since, state: 'all', perPage: 100, page })
188
+ if (issues.length === 0) break
189
+
190
+ const upsert = db.prepare(`
191
+ INSERT INTO cached_issues (repo, number, title, body, state, labels, url, author, created_at, updated_at, closed_at, synced_at)
192
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
193
+ ON CONFLICT(repo, number) DO UPDATE SET
194
+ title = excluded.title,
195
+ body = excluded.body,
196
+ state = excluded.state,
197
+ labels = excluded.labels,
198
+ url = excluded.url,
199
+ author = excluded.author,
200
+ updated_at = excluded.updated_at,
201
+ closed_at = excluded.closed_at,
202
+ synced_at = datetime('now')
203
+ `)
204
+
205
+ const upsertMany = db.transaction((items) => {
206
+ for (const issue of items) {
207
+ const labels = JSON.stringify((issue.labels || []).map(l => typeof l === 'string' ? l : l.name))
208
+ upsert.run(
209
+ repo,
210
+ issue.number,
211
+ issue.title,
212
+ issue.body || null,
213
+ issue.state || 'open',
214
+ labels,
215
+ issue.html_url || null,
216
+ issue.user?.login || null,
217
+ issue.created_at || null,
218
+ issue.updated_at || null,
219
+ issue.closed_at || null,
220
+ )
221
+ if (issue.updated_at && issue.updated_at > maxUpdatedAt) {
222
+ maxUpdatedAt = issue.updated_at
223
+ }
224
+ }
225
+ })
226
+
227
+ upsertMany(issues)
228
+ totalSynced += issues.length
229
+
230
+ if (issues.length < 100) break
231
+ page++
232
+ }
233
+
234
+ const now = new Date().toISOString().replace('Z', '')
235
+ setSyncMeta(repo, 'issues', {
236
+ lastSyncedAt: now,
237
+ lastCursor: maxUpdatedAt || now,
238
+ })
239
+
240
+ const total = db.prepare(
241
+ 'SELECT COUNT(*) as cnt FROM cached_issues WHERE repo = ?',
242
+ ).get(repo)?.cnt || 0
243
+
244
+ return { synced: totalSynced, total }
245
+ }
246
+
247
+ /**
248
+ * Sync labels for a repo (full-replace strategy).
249
+ *
250
+ * @param {string} repo
251
+ * @param {{ force?: boolean }} [opts]
252
+ * @returns {{ synced: number }}
253
+ */
254
+ export function syncLabels(repo, opts = {}) {
255
+ const db = getDb()
256
+
257
+ // Fetch all labels via API
258
+ const allLabels = []
259
+ let page = 1
260
+ while (true) {
261
+ const batch = fetchLabelsApi(repo, { perPage: 100, page })
262
+ allLabels.push(...batch)
263
+ if (batch.length < 100) break
264
+ page++
265
+ }
266
+
267
+ // Full replace in transaction
268
+ const sync = db.transaction(() => {
269
+ db.prepare('DELETE FROM cached_labels WHERE repo = ?').run(repo)
270
+ const insert = db.prepare(
271
+ `INSERT INTO cached_labels (repo, name, color, description, synced_at)
272
+ VALUES (?, ?, ?, ?, datetime('now'))`,
273
+ )
274
+ for (const label of allLabels) {
275
+ insert.run(repo, label.name, label.color, label.description)
276
+ }
277
+ })
278
+ sync()
279
+
280
+ const now = new Date().toISOString().replace('Z', '')
281
+ setSyncMeta(repo, 'labels', { lastSyncedAt: now })
282
+
283
+ return { synced: allLabels.length }
284
+ }
285
+
286
+ /**
287
+ * Sync repos (full-replace strategy).
288
+ *
289
+ * @param {{ force?: boolean }} [opts]
290
+ * @returns {{ synced: number }}
291
+ */
292
+ export function syncRepos(opts = {}) {
293
+ const db = getDb()
294
+ const repos = listRepos({ limit: 200 })
295
+
296
+ const sync = db.transaction(() => {
297
+ db.prepare('DELETE FROM cached_repos').run()
298
+ const insert = db.prepare(
299
+ `INSERT INTO cached_repos (name_with_owner, synced_at) VALUES (?, datetime('now'))`,
300
+ )
301
+ for (const r of repos) {
302
+ insert.run(r)
303
+ }
304
+ })
305
+ sync()
306
+
307
+ const now = new Date().toISOString().replace('Z', '')
308
+ setSyncMeta('_global', 'repos', { lastSyncedAt: now })
309
+
310
+ return { synced: repos.length }
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // ensureFresh — the main API for consumers
315
+ // ---------------------------------------------------------------------------
316
+
317
+ /**
318
+ * Ensure cached data is fresh, syncing if needed, and return it.
319
+ *
320
+ * @param {string} repo - repo for issues/labels, ignored for repos
321
+ * @param {string} resource - 'issues' | 'labels' | 'repos'
322
+ * @param {{ acceptStale?: boolean, force?: boolean, state?: string, limit?: number }} [opts]
323
+ * @returns {Array} cached data
324
+ */
325
+ export function ensureFresh(repo, resource, opts = {}) {
326
+ const { acceptStale = true, force = false } = opts
327
+ const effectiveRepo = resource === 'repos' ? '_global' : repo
328
+
329
+ if (!force) {
330
+ const freshness = checkFreshness(effectiveRepo, resource)
331
+ if (freshness === 'fresh') {
332
+ return readFromCache(repo, resource, opts)
333
+ }
334
+ if (freshness === 'stale' && acceptStale) {
335
+ return readFromCache(repo, resource, opts)
336
+ }
337
+ }
338
+
339
+ // Need to sync
340
+ try {
341
+ if (resource === 'issues') {
342
+ syncIssues(repo, { force })
343
+ } else if (resource === 'labels') {
344
+ syncLabels(repo, { force })
345
+ } else if (resource === 'repos') {
346
+ syncRepos({ force })
347
+ }
348
+ } catch {
349
+ // Sync failed — return stale data if available, else fall back to live fetch
350
+ const cached = readFromCache(repo, resource, opts)
351
+ if (cached.length > 0) return cached
352
+
353
+ // Last resort: live fetch (original behavior)
354
+ return liveFallback(repo, resource, opts)
355
+ }
356
+
357
+ return readFromCache(repo, resource, opts)
358
+ }
359
+
360
+ /**
361
+ * Read from cache based on resource type.
362
+ * @private
363
+ */
364
+ function readFromCache(repo, resource, opts) {
365
+ if (resource === 'issues') return getCachedIssues(repo, opts)
366
+ if (resource === 'labels') return getCachedLabels(repo)
367
+ if (resource === 'repos') return getCachedRepos()
368
+ return []
369
+ }
370
+
371
+ /**
372
+ * Live fallback when cache is empty and sync fails.
373
+ * @private
374
+ */
375
+ function liveFallback(repo, resource, opts) {
376
+ if (resource === 'issues') {
377
+ return listIssues(repo, { limit: opts?.limit || 100 })
378
+ }
379
+ if (resource === 'labels') {
380
+ return listLabels(repo)
381
+ }
382
+ if (resource === 'repos') {
383
+ return listRepos({ limit: 200 })
384
+ }
385
+ return []
386
+ }
387
+
388
+ /**
389
+ * Upsert a single issue into the cache (e.g. after creation).
390
+ *
391
+ * @param {string} repo
392
+ * @param {{ number: number, title: string, url?: string, labels?: string[], state?: string }} issue
393
+ */
394
+ export function upsertCachedIssue(repo, issue) {
395
+ const db = getDb()
396
+ const labels = JSON.stringify(issue.labels || [])
397
+ db.prepare(`
398
+ INSERT INTO cached_issues (repo, number, title, state, labels, url, synced_at, updated_at)
399
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
400
+ ON CONFLICT(repo, number) DO UPDATE SET
401
+ title = excluded.title,
402
+ state = COALESCE(excluded.state, state),
403
+ labels = excluded.labels,
404
+ url = COALESCE(excluded.url, url),
405
+ synced_at = datetime('now'),
406
+ updated_at = datetime('now')
407
+ `).run(repo, issue.number, issue.title, issue.state || 'open', labels, issue.url || null)
408
+ }
package/src/lib/db.js CHANGED
@@ -42,6 +42,8 @@ function getDbPath() {
42
42
  return path.join(home, '.config', 'tissues', 'tissues.db')
43
43
  }
44
44
 
45
+ export { getDbPath }
46
+
45
47
  const DB_PATH = getDbPath()
46
48
 
47
49
  // ---------------------------------------------------------------------------
@@ -92,6 +94,46 @@ CREATE TABLE IF NOT EXISTS _meta (
92
94
  key TEXT PRIMARY KEY,
93
95
  value TEXT NOT NULL
94
96
  );
97
+
98
+ CREATE TABLE IF NOT EXISTS cached_issues (
99
+ repo TEXT NOT NULL,
100
+ number INTEGER NOT NULL,
101
+ title TEXT NOT NULL,
102
+ body TEXT,
103
+ state TEXT NOT NULL DEFAULT 'open',
104
+ labels TEXT DEFAULT '[]',
105
+ url TEXT,
106
+ author TEXT,
107
+ created_at TEXT,
108
+ updated_at TEXT,
109
+ closed_at TEXT,
110
+ synced_at TEXT DEFAULT (datetime('now')),
111
+ PRIMARY KEY (repo, number)
112
+ );
113
+ CREATE INDEX IF NOT EXISTS idx_ci_repo_state ON cached_issues(repo, state);
114
+ CREATE INDEX IF NOT EXISTS idx_ci_repo_updated ON cached_issues(repo, updated_at);
115
+
116
+ CREATE TABLE IF NOT EXISTS cached_labels (
117
+ repo TEXT NOT NULL,
118
+ name TEXT NOT NULL,
119
+ color TEXT,
120
+ description TEXT,
121
+ synced_at TEXT DEFAULT (datetime('now')),
122
+ PRIMARY KEY (repo, name)
123
+ );
124
+
125
+ CREATE TABLE IF NOT EXISTS cached_repos (
126
+ name_with_owner TEXT PRIMARY KEY,
127
+ synced_at TEXT DEFAULT (datetime('now'))
128
+ );
129
+
130
+ CREATE TABLE IF NOT EXISTS sync_meta (
131
+ repo TEXT NOT NULL,
132
+ resource TEXT NOT NULL,
133
+ last_synced_at TEXT,
134
+ last_cursor TEXT,
135
+ PRIMARY KEY (repo, resource)
136
+ );
95
137
  `
96
138
 
97
139
  // ---------------------------------------------------------------------------
package/src/lib/dedup.js CHANGED
@@ -1,10 +1,6 @@
1
1
  import crypto from 'node:crypto'
2
2
  import { checkFingerprint, storeFingerprint, checkIdempotencyKey, storeIdempotencyKey } from './db.js'
3
- import { listIssues } from './gh.js'
4
-
5
- /** @type {Map<string, { issues: Array, fetchedAt: number }>} */
6
- const issuesCache = new Map()
7
- const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
3
+ import { ensureFresh, upsertCachedIssue } from './cache.js'
8
4
 
9
5
  /**
10
6
  * Normalize text for comparison: lowercase, replace non-alphanumeric with
@@ -86,20 +82,13 @@ export function jaccardSimilarity(a, b) {
86
82
  }
87
83
 
88
84
  /**
89
- * Fetch open issues for a repo, using a session-level cache that expires after
90
- * 5 minutes.
85
+ * Get open issues for a repo from the SQLite cache, syncing if stale.
91
86
  *
92
87
  * @param {string} repo
93
88
  * @returns {Promise<Array>}
94
89
  */
95
90
  async function getCachedIssues(repo) {
96
- const cached = issuesCache.get(repo)
97
- if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
98
- return cached.issues
99
- }
100
- const issues = listIssues(repo)
101
- issuesCache.set(repo, { issues, fetchedAt: Date.now() })
102
- return issues
91
+ return ensureFresh(repo, 'issues', { state: 'open', acceptStale: true })
103
92
  }
104
93
 
105
94
  /**
@@ -232,9 +221,10 @@ export async function recordCreation(repo, { title, body, issueNumber, url, idem
232
221
  await storeIdempotencyKey(idempotencyKey, repo, issueNumber)
233
222
  }
234
223
 
235
- // Atomically append to the issues cache so back-to-back runs catch duplicates
236
- const cached = issuesCache.get(repo)
237
- if (cached) {
238
- cached.issues.unshift({ number: issueNumber, title, url: url ?? null })
224
+ // Upsert into SQLite cache so back-to-back runs catch duplicates immediately
225
+ try {
226
+ upsertCachedIssue(repo, { number: issueNumber, title, url: url ?? null })
227
+ } catch {
228
+ // Non-fatal — cache upsert failure shouldn't abort
239
229
  }
240
230
  }