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.
- package/README.md +94 -40
- package/package.json +3 -4
- package/src/cli.js +24 -22
- package/src/commands/ai.js +266 -0
- package/src/commands/auth.js +4 -4
- package/src/commands/config.js +961 -12
- package/src/commands/create.js +516 -157
- package/src/commands/drafts.js +288 -0
- package/src/commands/list.js +7 -5
- package/src/commands/status.js +81 -19
- package/src/commands/templates.js +157 -0
- package/src/lib/ai/adapters/anthropic.js +52 -0
- package/src/lib/ai/adapters/base.js +45 -0
- package/src/lib/ai/adapters/command.js +58 -0
- package/src/lib/ai/adapters/gemini.js +56 -0
- package/src/lib/ai/adapters/ollama.js +60 -0
- package/src/lib/ai/adapters/openai-compat.js +51 -0
- package/src/lib/ai/adapters/openai.js +44 -0
- package/src/lib/ai/body-template.js +60 -0
- package/src/lib/ai/enhance.js +70 -0
- package/src/lib/ai/index.js +122 -0
- package/src/lib/ai/pipeline.js +79 -0
- package/src/lib/ai/prompt.js +39 -0
- package/src/lib/ai/router.js +128 -0
- package/src/lib/ai/steps.js +472 -0
- package/src/lib/attribution.js +18 -179
- package/src/lib/clipboard.js +147 -0
- package/src/lib/color.js +9 -0
- package/src/lib/dedup.js +33 -4
- package/src/lib/defaults.js +38 -2
- package/src/lib/drafts.js +439 -0
- package/src/lib/gh.js +86 -11
- package/src/lib/repo-picker.js +2 -0
- package/src/lib/safety.js +1 -1
- package/src/lib/templates.js +8 -12
- package/src/lib/theme.js +9 -0
- package/src/commands/use.js +0 -19
|
@@ -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
|
+
}
|
package/src/lib/color.js
ADDED
|
@@ -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
|
|
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
|
|
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
|
}
|
package/src/lib/defaults.js
CHANGED
|
@@ -39,8 +39,44 @@ export const BUILT_IN_DEFAULTS = {
|
|
|
39
39
|
// AI
|
|
40
40
|
ai: {
|
|
41
41
|
enabled: true,
|
|
42
|
-
provider: 'anthropic',
|
|
43
|
-
model:
|
|
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)
|