tissues 0.5.2 → 0.6.0

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.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Clipboard image detection and extraction.
3
+ *
4
+ * macOS: uses osascript to read «class PNGf» from NSPasteboard.
5
+ * Linux: uses xclip (X11) or wl-paste (Wayland).
6
+ * Windows: not supported yet.
7
+ */
8
+
9
+ import { execFileSync, spawnSync } from 'node:child_process'
10
+ import fs from 'node:fs'
11
+ import os from 'node:os'
12
+ import path from 'node:path'
13
+
14
+ /**
15
+ * Check if the system clipboard currently holds image data.
16
+ *
17
+ * @returns {boolean}
18
+ */
19
+ export function clipboardHasImage() {
20
+ const platform = os.platform()
21
+ if (platform === 'darwin') return clipboardHasImageMac()
22
+ if (platform === 'linux') return clipboardHasImageLinux()
23
+ return false
24
+ }
25
+
26
+ /**
27
+ * Save clipboard image to a PNG file on disk.
28
+ *
29
+ * @param {string} destPath - where to write the PNG
30
+ * @returns {boolean} true if saved successfully
31
+ */
32
+ export function saveClipboardImage(destPath) {
33
+ const platform = os.platform()
34
+ if (platform === 'darwin') return saveClipboardImageMac(destPath)
35
+ if (platform === 'linux') return saveClipboardImageLinux(destPath)
36
+ return false
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // macOS
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function clipboardHasImageMac() {
44
+ try {
45
+ const info = execFileSync('osascript', ['-e', 'clipboard info'], {
46
+ encoding: 'utf8',
47
+ stdio: ['ignore', 'pipe', 'ignore'],
48
+ })
49
+ return info.includes('PNGf') || info.includes('TIFF picture')
50
+ } catch {
51
+ return false
52
+ }
53
+ }
54
+
55
+ function saveClipboardImageMac(destPath) {
56
+ // Prefer pngpaste if installed (more reliable)
57
+ const hasPngpaste = spawnSync('which', ['pngpaste'], {
58
+ encoding: 'utf8',
59
+ stdio: ['ignore', 'pipe', 'ignore'],
60
+ }).status === 0
61
+
62
+ if (hasPngpaste) {
63
+ const result = spawnSync('pngpaste', [destPath], {
64
+ encoding: 'utf8',
65
+ stdio: ['ignore', 'pipe', 'pipe'],
66
+ })
67
+ return result.status === 0
68
+ }
69
+
70
+ // Fallback: osascript with temp script file to avoid quoting issues
71
+ const scriptFile = path.join(os.tmpdir(), `tissues-clip-${Date.now()}.scpt`)
72
+ const script = [
73
+ 'try',
74
+ ' set theImage to (the clipboard as «class PNGf»)',
75
+ ` set fd to open for access POSIX file "${destPath}" with write permission`,
76
+ ' set eof of fd to 0',
77
+ ' write theImage to fd',
78
+ ' close access fd',
79
+ ' return "ok"',
80
+ 'on error errMsg',
81
+ ' try',
82
+ ` close access POSIX file "${destPath}"`,
83
+ ' end try',
84
+ ' return "fail"',
85
+ 'end try',
86
+ ].join('\n')
87
+
88
+ try {
89
+ fs.writeFileSync(scriptFile, script, 'utf8')
90
+ const result = execFileSync('osascript', [scriptFile], {
91
+ encoding: 'utf8',
92
+ stdio: ['ignore', 'pipe', 'ignore'],
93
+ }).trim()
94
+ return result === 'ok'
95
+ } catch {
96
+ return false
97
+ } finally {
98
+ try { fs.unlinkSync(scriptFile) } catch { /* ignore */ }
99
+ }
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Linux
104
+ // ---------------------------------------------------------------------------
105
+
106
+ function clipboardHasImageLinux() {
107
+ // Try wl-paste first (Wayland), then xclip (X11)
108
+ const wl = spawnSync('wl-paste', ['--list-types'], {
109
+ encoding: 'utf8',
110
+ stdio: ['ignore', 'pipe', 'ignore'],
111
+ })
112
+ if (wl.status === 0) {
113
+ return wl.stdout.includes('image/png') || wl.stdout.includes('image/jpeg')
114
+ }
115
+
116
+ const xc = spawnSync('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], {
117
+ encoding: 'utf8',
118
+ stdio: ['ignore', 'pipe', 'ignore'],
119
+ })
120
+ if (xc.status === 0) {
121
+ return xc.stdout.includes('image/png') || xc.stdout.includes('image/jpeg')
122
+ }
123
+
124
+ return false
125
+ }
126
+
127
+ function saveClipboardImageLinux(destPath) {
128
+ // Wayland
129
+ const wlResult = spawnSync('wl-paste', ['--type', 'image/png'], {
130
+ stdio: ['ignore', 'pipe', 'ignore'],
131
+ })
132
+ if (wlResult.status === 0 && wlResult.stdout.length > 0) {
133
+ fs.writeFileSync(destPath, wlResult.stdout)
134
+ return true
135
+ }
136
+
137
+ // X11
138
+ const xcResult = spawnSync('xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o'], {
139
+ stdio: ['ignore', 'pipe', 'ignore'],
140
+ })
141
+ if (xcResult.status === 0 && xcResult.stdout.length > 0) {
142
+ fs.writeFileSync(destPath, xcResult.stdout)
143
+ return true
144
+ }
145
+
146
+ return false
147
+ }
@@ -0,0 +1,9 @@
1
+ import { styleText } from 'node:util'
2
+
3
+ export const bold = (s) => styleText('bold', s)
4
+ export const dim = (s) => styleText('dim', s)
5
+ export const red = (s) => styleText('red', s)
6
+ export const green = (s) => styleText('green', s)
7
+ export const yellow = (s) => styleText('yellow', s)
8
+ export const cyan = (s) => styleText('cyan', s)
9
+ export const blue = (s) => styleText('blue', s)
package/src/lib/dedup.js CHANGED
@@ -104,6 +104,27 @@ async function getCachedIssues(repo) {
104
104
  * @typedef {{ action: 'block' | 'warn' | 'allow', reason: string, existingIssue?: { number: number, title: string, url: string } }} LayerResult
105
105
  */
106
106
 
107
+ /**
108
+ * Score dedup confidence from cumulative overlap signals.
109
+ *
110
+ * Used by the AI dedup pipeline step to convert raw LLM overlap scores
111
+ * into deterministic confidence levels.
112
+ *
113
+ * @param {{ titleOverlap?: number, fileMentions?: number, labelMatches?: number }} scores
114
+ * - titleOverlap: 0-50 points (how similar the titles are)
115
+ * - fileMentions: 0-30 points (shared file references)
116
+ * - labelMatches: 0-20 points (shared labels)
117
+ * @returns {{ confidence: number, level: 'high' | 'medium' | 'low' | 'none' }}
118
+ */
119
+ export function scoreDedupConfidence({ titleOverlap = 0, fileMentions = 0, labelMatches = 0 } = {}) {
120
+ const confidence = Math.min(100, Math.max(0, titleOverlap + fileMentions + labelMatches))
121
+ let level = 'none'
122
+ if (confidence >= 70) level = 'high'
123
+ else if (confidence >= 40) level = 'medium'
124
+ else if (confidence > 20) level = 'low'
125
+ return { confidence, level }
126
+ }
127
+
107
128
  /**
108
129
  * Run the full deduplication pipeline for a prospective issue.
109
130
  *
@@ -151,7 +172,7 @@ export async function checkDuplicate(repo, { title, body, idempotencyKey, agent
151
172
  const openIssues = await getCachedIssues(repo)
152
173
  for (const issue of openIssues) {
153
174
  const similarity = levenshteinSimilarity(normalizedTitle, normalizeText(issue.title))
154
- if (similarity > 0.95) {
175
+ if (similarity >= 0.90) {
155
176
  results.push({
156
177
  action: 'block',
157
178
  reason: `Fuzzy title match (similarity ${(similarity * 100).toFixed(1)}%) with existing issue`,
@@ -162,7 +183,7 @@ export async function checkDuplicate(repo, { title, body, idempotencyKey, agent
162
183
  },
163
184
  })
164
185
  break
165
- } else if (similarity > 0.80) {
186
+ } else if (similarity >= 0.75) {
166
187
  results.push({
167
188
  action: 'warn',
168
189
  reason: `Fuzzy title is similar (similarity ${(similarity * 100).toFixed(1)}%) to existing issue`,
@@ -190,16 +211,24 @@ export async function checkDuplicate(repo, { title, body, idempotencyKey, agent
190
211
  /**
191
212
  * Persist deduplication records after a successful issue creation.
192
213
  * Stores both the content fingerprint and, if provided, the idempotency key.
214
+ * Also appends the new issue to the in-memory cache so subsequent dedup
215
+ * checks within the same process (or cache TTL window) see it immediately.
193
216
  *
194
217
  * @param {string} repo
195
- * @param {{ title: string, body: string, issueNumber: number, idempotencyKey?: string, agent?: string }} options
218
+ * @param {{ title: string, body: string, issueNumber: number, url?: string, idempotencyKey?: string, agent?: string }} options
196
219
  * @returns {Promise<void>}
197
220
  */
198
- export async function recordCreation(repo, { title, body, issueNumber, idempotencyKey, agent }) {
221
+ export async function recordCreation(repo, { title, body, issueNumber, url, idempotencyKey, agent }) {
199
222
  const fingerprint = computeFingerprint(title, body ?? '')
200
223
  await storeFingerprint(repo, fingerprint, issueNumber, title, agent ?? 'unknown')
201
224
 
202
225
  if (idempotencyKey) {
203
226
  await storeIdempotencyKey(idempotencyKey, repo, issueNumber)
204
227
  }
228
+
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 })
233
+ }
205
234
  }
@@ -39,8 +39,44 @@ export const BUILT_IN_DEFAULTS = {
39
39
  // AI
40
40
  ai: {
41
41
  enabled: true,
42
- provider: 'anthropic', // or 'openai'
43
- model: 'claude-haiku-4-5-20251001',
42
+ provider: 'anthropic',
43
+ model: null, // global model override
44
+ models: { // per-provider defaults
45
+ anthropic: 'claude-haiku-4-5-20251001',
46
+ openai: 'gpt-4o-mini',
47
+ gemini: 'gemini-2.0-flash',
48
+ ollama: 'llama3.2',
49
+ 'openai-compat': null, // user must set
50
+ command: null, // not applicable
51
+ },
52
+ keys: {
53
+ anthropic: null,
54
+ openai: null,
55
+ gemini: null,
56
+ 'openai-compat': null,
57
+ },
58
+ ollama: { url: 'http://localhost:11434' },
59
+ custom: { url: null }, // openai-compat base URL
60
+ command: null, // e.g. 'co inference'
61
+ routes: [], // routing rules
62
+ budgets: {
63
+ maxTokensPerRequest: 4096, // hard cap per single AI call
64
+ maxTokensPerHour: 50000, // rolling hourly token budget
65
+ maxTokensPerDay: 200000, // rolling daily token budget
66
+ },
67
+ pipeline: {
68
+ enabled: true, // multi-step enhancement pipeline
69
+ steps: {
70
+ triage: 'always', // 'always' | 'auto' | 'never'
71
+ dedup: 'auto', // 'always' | 'auto' | 'never'
72
+ context: 'always',
73
+ scope: 'auto',
74
+ complexity: 'auto',
75
+ risk: 'auto',
76
+ labels: 'auto',
77
+ format: 'always',
78
+ },
79
+ },
44
80
  },
45
81
 
46
82
  // Hooks (shell commands to run)