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.
Files changed (52) hide show
  1. package/README.md +78 -1
  2. package/package.json +4 -2
  3. package/src/cli.js +12 -1
  4. package/src/commands/ai.js +149 -173
  5. package/src/commands/config.js +143 -69
  6. package/src/commands/create.js +16 -9
  7. package/src/commands/create.test.js +381 -0
  8. package/src/commands/enhancements.js +282 -0
  9. package/src/commands/flush.test.js +299 -0
  10. package/src/commands/list.js +3 -2
  11. package/src/commands/providers.js +347 -0
  12. package/src/commands/providers.test.js +28 -0
  13. package/src/commands/storage.js +167 -0
  14. package/src/commands/sync.js +225 -0
  15. package/src/daemon/sync.js +189 -0
  16. package/src/lib/ai/adapters/claude-cli.js +55 -0
  17. package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
  18. package/src/lib/ai/adapters/codex-cli.js +77 -0
  19. package/src/lib/ai/adapters/command.js +23 -13
  20. package/src/lib/ai/adapters/gemini-cli.js +55 -0
  21. package/src/lib/ai/adapters/openclaw.js +91 -0
  22. package/src/lib/ai/agent-actions.js +271 -0
  23. package/src/lib/ai/agent.js +323 -0
  24. package/src/lib/ai/body-template.js +15 -0
  25. package/src/lib/ai/discovery.js +89 -0
  26. package/src/lib/ai/discovery.test.js +74 -0
  27. package/src/lib/ai/enhance.js +48 -11
  28. package/src/lib/ai/enhancement-adapter.js +109 -0
  29. package/src/lib/ai/enhancement-adapter.test.js +188 -0
  30. package/src/lib/ai/index.js +2 -2
  31. package/src/lib/ai/pipeline.js +20 -2
  32. package/src/lib/ai/pipeline.test.js +257 -0
  33. package/src/lib/ai/prompt.test.js +30 -0
  34. package/src/lib/ai/router.js +118 -7
  35. package/src/lib/ai/router.test.js +481 -0
  36. package/src/lib/ai/steps.js +23 -3
  37. package/src/lib/ai/steps.test.js +335 -0
  38. package/src/lib/attribution.test.js +64 -0
  39. package/src/lib/cache.js +408 -0
  40. package/src/lib/db.js +42 -0
  41. package/src/lib/dedup.js +44 -48
  42. package/src/lib/dedup.test.js +227 -0
  43. package/src/lib/defaults.js +37 -1
  44. package/src/lib/defaults.test.js +217 -0
  45. package/src/lib/drafts-perf.test.js +203 -0
  46. package/src/lib/drafts.test.js +300 -0
  47. package/src/lib/enhancements.js +436 -0
  48. package/src/lib/enhancements.test.js +294 -0
  49. package/src/lib/gh.js +76 -10
  50. package/src/lib/safety.test.js +217 -0
  51. package/src/lib/storage.js +298 -0
  52. 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
@@ -45,59 +41,54 @@ export function computeIdempotencyKey({ agent, trigger, issueType, repo }) {
45
41
  return crypto.createHash('sha256').update(raw).digest('hex')
46
42
  }
47
43
 
44
+ const STOP_WORDS = new Set([
45
+ 'a','an','the','in','on','at','to','for','of','with','by','from',
46
+ 'is','are','was','were','be','been','being','has','have','had',
47
+ 'do','does','did','will','would','could','should','can','may',
48
+ 'not','no','but','or','and','if','then','so','as','it','its',
49
+ 'this','that','we','our','i','my','you','your','they','their',
50
+ ])
51
+
48
52
  /**
49
- * Compute Levenshtein distance between two strings.
53
+ * Tokenize text: normalize, split on spaces, remove stop words.
50
54
  *
51
- * @param {string} a
52
- * @param {string} b
53
- * @returns {number}
55
+ * @param {string} text
56
+ * @returns {string[]}
54
57
  */
55
- function levenshteinDistance(a, b) {
56
- const m = a.length
57
- const n = b.length
58
- const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)])
59
- for (let j = 0; j <= n; j++) dp[0][j] = j
60
- for (let i = 1; i <= m; i++) {
61
- for (let j = 1; j <= n; j++) {
62
- dp[i][j] =
63
- a[i - 1] === b[j - 1]
64
- ? dp[i - 1][j - 1]
65
- : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
66
- }
67
- }
68
- return dp[m][n]
58
+ function tokenize(text) {
59
+ return normalizeText(text)
60
+ .split(' ')
61
+ .filter(w => w.length > 0 && !STOP_WORDS.has(w))
69
62
  }
70
63
 
71
64
  /**
72
- * Compute similarity between two strings as a value in [0, 1].
73
- * 1.0 means identical, 0.0 means completely different.
65
+ * Compute Jaccard similarity between two strings based on token sets.
66
+ * Tokens are words with stop words removed.
67
+ * Returns a value in [0, 1]: 1.0 = identical token sets, 0.0 = no overlap.
74
68
  *
75
69
  * @param {string} a
76
70
  * @param {string} b
77
71
  * @returns {number}
78
72
  */
79
- export function levenshteinSimilarity(a, b) {
80
- if (a === b) return 1
81
- const maxLen = Math.max(a.length, b.length)
82
- if (maxLen === 0) return 1
83
- return 1 - levenshteinDistance(a, b) / maxLen
73
+ export function jaccardSimilarity(a, b) {
74
+ const setA = new Set(tokenize(a))
75
+ const setB = new Set(tokenize(b))
76
+ if (setA.size === 0 && setB.size === 0) return 1
77
+ if (setA.size === 0 || setB.size === 0) return 0
78
+ let intersection = 0
79
+ for (const w of setA) if (setB.has(w)) intersection++
80
+ const union = new Set([...setA, ...setB]).size
81
+ return intersection / union
84
82
  }
85
83
 
86
84
  /**
87
- * Fetch open issues for a repo, using a session-level cache that expires after
88
- * 5 minutes.
85
+ * Get open issues for a repo from the SQLite cache, syncing if stale.
89
86
  *
90
87
  * @param {string} repo
91
88
  * @returns {Promise<Array>}
92
89
  */
93
90
  async function getCachedIssues(repo) {
94
- const cached = issuesCache.get(repo)
95
- if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
96
- return cached.issues
97
- }
98
- const issues = listIssues(repo)
99
- issuesCache.set(repo, { issues, fetchedAt: Date.now() })
100
- return issues
91
+ return ensureFresh(repo, 'issues', { state: 'open', acceptStale: true })
101
92
  }
102
93
 
103
94
  /**
@@ -167,12 +158,16 @@ export async function checkDuplicate(repo, { title, body, idempotencyKey, agent
167
158
  })
168
159
  }
169
160
 
170
- // Layer 3: fuzzy title match
171
- const normalizedTitle = normalizeText(title)
161
+ // Layer 3: fuzzy title match (token-set Jaccard)
162
+ const newTokens = tokenize(title)
163
+ const isShortTitle = newTokens.length <= 2
172
164
  const openIssues = await getCachedIssues(repo)
173
165
  for (const issue of openIssues) {
174
- const similarity = levenshteinSimilarity(normalizedTitle, normalizeText(issue.title))
175
- if (similarity >= 0.90) {
166
+ const similarity = jaccardSimilarity(title, issue.title)
167
+ // Short-title guard: ≤2 meaningful tokens require exact match to block, ≥0.80 to warn
168
+ const blockThreshold = isShortTitle ? 1.0 : 0.80
169
+ const warnThreshold = isShortTitle ? 0.80 : 0.50
170
+ if (similarity >= blockThreshold) {
176
171
  results.push({
177
172
  action: 'block',
178
173
  reason: `Fuzzy title match (similarity ${(similarity * 100).toFixed(1)}%) with existing issue`,
@@ -183,7 +178,7 @@ export async function checkDuplicate(repo, { title, body, idempotencyKey, agent
183
178
  },
184
179
  })
185
180
  break
186
- } else if (similarity >= 0.75) {
181
+ } else if (similarity >= warnThreshold) {
187
182
  results.push({
188
183
  action: 'warn',
189
184
  reason: `Fuzzy title is similar (similarity ${(similarity * 100).toFixed(1)}%) to existing issue`,
@@ -226,9 +221,10 @@ export async function recordCreation(repo, { title, body, issueNumber, url, idem
226
221
  await storeIdempotencyKey(idempotencyKey, repo, issueNumber)
227
222
  }
228
223
 
229
- // Atomically append to the issues cache so back-to-back runs catch duplicates
230
- const cached = issuesCache.get(repo)
231
- if (cached) {
232
- 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
233
229
  }
234
230
  }