tissues 0.5.2 → 0.6.1
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 +26 -22
- package/src/commands/ai.js +268 -0
- package/src/commands/auth.js +4 -4
- package/src/commands/config.js +1035 -12
- package/src/commands/create.js +523 -157
- package/src/commands/drafts.js +288 -0
- package/src/commands/enhancements.js +282 -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 +68 -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 +75 -0
- package/src/lib/ai/enhance.js +107 -0
- package/src/lib/ai/enhancement-adapter.js +109 -0
- package/src/lib/ai/index.js +122 -0
- package/src/lib/ai/pipeline.js +97 -0
- package/src/lib/ai/prompt.js +39 -0
- package/src/lib/ai/router.js +216 -0
- package/src/lib/ai/steps.js +492 -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 +67 -32
- package/src/lib/defaults.js +54 -2
- package/src/lib/drafts.js +439 -0
- package/src/lib/enhancements.js +436 -0
- package/src/lib/gh.js +102 -21
- 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
package/src/lib/attribution.js
CHANGED
|
@@ -1,216 +1,55 @@
|
|
|
1
|
-
import { createRequire } from 'node:module'
|
|
2
|
-
import { fileURLToPath } from 'node:url'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
import fs from 'node:fs'
|
|
5
|
-
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
// Package version (read once at module load)
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Resolve the package version from the nearest package.json.
|
|
12
|
-
* Falls back to '0.0.0' if unavailable.
|
|
13
|
-
*
|
|
14
|
-
* @returns {string}
|
|
15
|
-
*/
|
|
16
|
-
function resolvePackageVersion() {
|
|
17
|
-
try {
|
|
18
|
-
const require = createRequire(import.meta.url)
|
|
19
|
-
// Walk up from this file to find package.json
|
|
20
|
-
let dir = path.dirname(fileURLToPath(import.meta.url))
|
|
21
|
-
const { root } = path.parse(dir)
|
|
22
|
-
while (dir !== root) {
|
|
23
|
-
const pkgPath = path.join(dir, 'package.json')
|
|
24
|
-
if (fs.existsSync(pkgPath)) {
|
|
25
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
26
|
-
if (pkg.name === 'tissues' && pkg.version) return pkg.version
|
|
27
|
-
}
|
|
28
|
-
dir = path.dirname(dir)
|
|
29
|
-
}
|
|
30
|
-
} catch {
|
|
31
|
-
// ignore
|
|
32
|
-
}
|
|
33
|
-
return '0.0.0'
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const PKG_VERSION = resolvePackageVersion()
|
|
37
|
-
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
// Helpers
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Return the current UTC timestamp in ISO 8601 format.
|
|
44
|
-
*
|
|
45
|
-
* @returns {string}
|
|
46
|
-
*/
|
|
47
|
-
function nowISO() {
|
|
48
|
-
return new Date().toISOString()
|
|
49
|
-
}
|
|
50
|
-
|
|
51
1
|
// ---------------------------------------------------------------------------
|
|
52
2
|
// Public API
|
|
53
3
|
// ---------------------------------------------------------------------------
|
|
54
4
|
|
|
55
5
|
/**
|
|
56
6
|
* @typedef {object} AttributionOpts
|
|
57
|
-
* @property {string} [
|
|
58
|
-
* @property {string} [
|
|
59
|
-
* @property {number} [pid] - process ID (opt-in; omitted by default)
|
|
60
|
-
* @property {string} [model] - AI model used (if any)
|
|
61
|
-
* @property {string} [trigger] - how the issue was created (e.g. 'cli-create')
|
|
62
|
-
* @property {string} [fingerprint] - content fingerprint (sha256:...)
|
|
63
|
-
* @property {string} [idempotencyKey] - deterministic idempotency key
|
|
64
|
-
* @property {number} [risk] - risk score [0,1]
|
|
65
|
-
* @property {number} [complexity] - complexity estimate [0,1]
|
|
66
|
-
* @property {number} [confidence] - AI confidence score [0,1]
|
|
67
|
-
* @property {string[]} [contextTags] - free-form tags for filtering/search
|
|
68
|
-
* @property {string} [createdAt] - override timestamp (defaults to now)
|
|
7
|
+
* @property {string} [session] - session or conversation ID
|
|
8
|
+
* @property {string} [model] - AI model used (if any)
|
|
69
9
|
*/
|
|
70
10
|
|
|
71
11
|
/**
|
|
72
12
|
* Build a normalized attribution metadata object from raw options.
|
|
73
13
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* `pid` is opt-in — it is only included if explicitly passed.
|
|
14
|
+
* Only includes session and model — fields the user actually cares about.
|
|
15
|
+
* Undefined/null fields are omitted.
|
|
77
16
|
*
|
|
78
17
|
* @param {AttributionOpts} opts
|
|
79
18
|
* @returns {object} metadata record
|
|
80
19
|
*/
|
|
81
20
|
export function buildAttribution(opts = {}) {
|
|
82
|
-
const {
|
|
83
|
-
agent,
|
|
84
|
-
session,
|
|
85
|
-
pid,
|
|
86
|
-
model,
|
|
87
|
-
trigger,
|
|
88
|
-
fingerprint,
|
|
89
|
-
idempotencyKey,
|
|
90
|
-
risk,
|
|
91
|
-
complexity,
|
|
92
|
-
confidence,
|
|
93
|
-
contextTags,
|
|
94
|
-
createdAt,
|
|
95
|
-
} = opts
|
|
96
|
-
|
|
21
|
+
const { session, model } = opts
|
|
97
22
|
const meta = {}
|
|
98
23
|
|
|
99
|
-
// Identity
|
|
100
|
-
if (agent != null) meta.agent = String(agent)
|
|
101
24
|
if (session != null) meta.session = String(session)
|
|
102
25
|
if (model != null) meta.model = String(model)
|
|
103
26
|
|
|
104
|
-
// Process info (pid is opt-in — pass it explicitly to include)
|
|
105
|
-
if (pid != null) meta.pid = Number(pid)
|
|
106
|
-
meta.trigger = trigger != null ? String(trigger) : 'cli-create'
|
|
107
|
-
|
|
108
|
-
// Deduplication handles
|
|
109
|
-
if (fingerprint != null) meta.fingerprint = String(fingerprint)
|
|
110
|
-
if (idempotencyKey != null) meta.idempotency_key = String(idempotencyKey)
|
|
111
|
-
|
|
112
|
-
// Scoring (numeric, clamped to [0,1])
|
|
113
|
-
if (risk != null) meta.risk = Math.max(0, Math.min(1, Number(risk)))
|
|
114
|
-
if (complexity != null) meta.complexity = Math.max(0, Math.min(1, Number(complexity)))
|
|
115
|
-
if (confidence != null) meta.confidence = Math.max(0, Math.min(1, Number(confidence)))
|
|
116
|
-
|
|
117
|
-
// Tags
|
|
118
|
-
if (Array.isArray(contextTags) && contextTags.length > 0) {
|
|
119
|
-
meta.context_tags = contextTags.map(String)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Timestamps / versioning
|
|
123
|
-
meta.created_at = createdAt ?? nowISO()
|
|
124
|
-
meta.created_via = `tissues-cli/${PKG_VERSION.split('.').slice(0, 2).join('.')}`
|
|
125
|
-
|
|
126
27
|
return meta
|
|
127
28
|
}
|
|
128
29
|
|
|
129
30
|
/**
|
|
130
|
-
* Render attribution metadata as
|
|
131
|
-
* bottom of a GitHub issue body.
|
|
132
|
-
*
|
|
133
|
-
* The format is a YAML-like key: value listing inside an HTML comment, which
|
|
134
|
-
* GitHub renders as invisible text. Other tools (including tissues itself)
|
|
135
|
-
* can parse it back via `parseAttribution`.
|
|
31
|
+
* Render attribution metadata as a YAML frontmatter block for inclusion at
|
|
32
|
+
* the bottom of a GitHub issue body.
|
|
136
33
|
*
|
|
137
34
|
* @example
|
|
138
|
-
*
|
|
139
|
-
*
|
|
35
|
+
* ---
|
|
36
|
+
* tissues-meta:
|
|
140
37
|
* session: abc123
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* created_at: 2026-02-19T15:30:00Z
|
|
144
|
-
* created_via: tissues-cli/0.1
|
|
145
|
-
* -->
|
|
38
|
+
* model: claude-opus-4-6
|
|
39
|
+
* ---
|
|
146
40
|
*
|
|
147
41
|
* @param {AttributionOpts} opts
|
|
148
|
-
* @returns {string}
|
|
42
|
+
* @returns {string | null} frontmatter block, or null if no fields to render
|
|
149
43
|
*/
|
|
150
44
|
export function renderAttribution(opts = {}) {
|
|
151
45
|
const meta = buildAttribution(opts)
|
|
152
|
-
const
|
|
46
|
+
const entries = Object.entries(meta)
|
|
47
|
+
if (entries.length === 0) return null
|
|
153
48
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
lines.push(`${key}: ${value.join(', ')}`)
|
|
158
|
-
} else {
|
|
159
|
-
lines.push(`${key}: ${value}`)
|
|
160
|
-
}
|
|
49
|
+
const lines = ['---', 'tissues-meta:']
|
|
50
|
+
for (const [key, value] of entries) {
|
|
51
|
+
lines.push(`${key}: ${value}`)
|
|
161
52
|
}
|
|
162
|
-
|
|
163
|
-
lines.push('-->')
|
|
53
|
+
lines.push('---')
|
|
164
54
|
return lines.join('\n')
|
|
165
55
|
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Parse a `<!-- tissues-meta ... -->` attribution block from an issue body.
|
|
169
|
-
*
|
|
170
|
-
* Returns the parsed key/value pairs as a plain object, or `null` if no
|
|
171
|
-
* attribution block is present in `issueBody`.
|
|
172
|
-
*
|
|
173
|
-
* Numeric fields (`risk`, `complexity`, `confidence`) are coerced to
|
|
174
|
-
* numbers. The `context_tags` field is split back into an array.
|
|
175
|
-
*
|
|
176
|
-
* @param {string} issueBody - raw GitHub issue body markdown
|
|
177
|
-
* @returns {object|null} parsed metadata, or null if not found
|
|
178
|
-
*/
|
|
179
|
-
export function parseAttribution(issueBody) {
|
|
180
|
-
if (!issueBody) return null
|
|
181
|
-
|
|
182
|
-
// Match the comment block (non-greedy, handle CRLF)
|
|
183
|
-
const match = issueBody.match(/<!--\s*tissues-meta\s*([\s\S]*?)-->/m)
|
|
184
|
-
if (!match) return null
|
|
185
|
-
|
|
186
|
-
const block = match[1]
|
|
187
|
-
const meta = {}
|
|
188
|
-
|
|
189
|
-
for (const line of block.split(/\r?\n/)) {
|
|
190
|
-
const trimmed = line.trim()
|
|
191
|
-
if (!trimmed) continue
|
|
192
|
-
|
|
193
|
-
const colonIdx = trimmed.indexOf(':')
|
|
194
|
-
if (colonIdx === -1) continue
|
|
195
|
-
|
|
196
|
-
const key = trimmed.slice(0, colonIdx).trim()
|
|
197
|
-
const rawValue = trimmed.slice(colonIdx + 1).trim()
|
|
198
|
-
|
|
199
|
-
if (!key) continue
|
|
200
|
-
|
|
201
|
-
// Type coercions
|
|
202
|
-
if (['pid'].includes(key)) {
|
|
203
|
-
const n = Number(rawValue)
|
|
204
|
-
meta[key] = Number.isNaN(n) ? rawValue : n
|
|
205
|
-
} else if (['risk', 'complexity', 'confidence'].includes(key)) {
|
|
206
|
-
const n = parseFloat(rawValue)
|
|
207
|
-
meta[key] = Number.isNaN(n) ? rawValue : n
|
|
208
|
-
} else if (key === 'context_tags') {
|
|
209
|
-
meta[key] = rawValue.split(',').map((t) => t.trim()).filter(Boolean)
|
|
210
|
-
} else {
|
|
211
|
-
meta[key] = rawValue
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
return Object.keys(meta).length > 0 ? meta : null
|
|
216
|
-
}
|
|
@@ -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
|
@@ -45,42 +45,44 @@ export function computeIdempotencyKey({ agent, trigger, issueType, repo }) {
|
|
|
45
45
|
return crypto.createHash('sha256').update(raw).digest('hex')
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
const STOP_WORDS = new Set([
|
|
49
|
+
'a','an','the','in','on','at','to','for','of','with','by','from',
|
|
50
|
+
'is','are','was','were','be','been','being','has','have','had',
|
|
51
|
+
'do','does','did','will','would','could','should','can','may',
|
|
52
|
+
'not','no','but','or','and','if','then','so','as','it','its',
|
|
53
|
+
'this','that','we','our','i','my','you','your','they','their',
|
|
54
|
+
])
|
|
55
|
+
|
|
48
56
|
/**
|
|
49
|
-
*
|
|
57
|
+
* Tokenize text: normalize, split on spaces, remove stop words.
|
|
50
58
|
*
|
|
51
|
-
* @param {string}
|
|
52
|
-
* @
|
|
53
|
-
* @returns {number}
|
|
59
|
+
* @param {string} text
|
|
60
|
+
* @returns {string[]}
|
|
54
61
|
*/
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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]
|
|
62
|
+
function tokenize(text) {
|
|
63
|
+
return normalizeText(text)
|
|
64
|
+
.split(' ')
|
|
65
|
+
.filter(w => w.length > 0 && !STOP_WORDS.has(w))
|
|
69
66
|
}
|
|
70
67
|
|
|
71
68
|
/**
|
|
72
|
-
* Compute similarity between two strings
|
|
73
|
-
*
|
|
69
|
+
* Compute Jaccard similarity between two strings based on token sets.
|
|
70
|
+
* Tokens are words with stop words removed.
|
|
71
|
+
* Returns a value in [0, 1]: 1.0 = identical token sets, 0.0 = no overlap.
|
|
74
72
|
*
|
|
75
73
|
* @param {string} a
|
|
76
74
|
* @param {string} b
|
|
77
75
|
* @returns {number}
|
|
78
76
|
*/
|
|
79
|
-
export function
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
83
|
-
|
|
77
|
+
export function jaccardSimilarity(a, b) {
|
|
78
|
+
const setA = new Set(tokenize(a))
|
|
79
|
+
const setB = new Set(tokenize(b))
|
|
80
|
+
if (setA.size === 0 && setB.size === 0) return 1
|
|
81
|
+
if (setA.size === 0 || setB.size === 0) return 0
|
|
82
|
+
let intersection = 0
|
|
83
|
+
for (const w of setA) if (setB.has(w)) intersection++
|
|
84
|
+
const union = new Set([...setA, ...setB]).size
|
|
85
|
+
return intersection / union
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
/**
|
|
@@ -104,6 +106,27 @@ async function getCachedIssues(repo) {
|
|
|
104
106
|
* @typedef {{ action: 'block' | 'warn' | 'allow', reason: string, existingIssue?: { number: number, title: string, url: string } }} LayerResult
|
|
105
107
|
*/
|
|
106
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Score dedup confidence from cumulative overlap signals.
|
|
111
|
+
*
|
|
112
|
+
* Used by the AI dedup pipeline step to convert raw LLM overlap scores
|
|
113
|
+
* into deterministic confidence levels.
|
|
114
|
+
*
|
|
115
|
+
* @param {{ titleOverlap?: number, fileMentions?: number, labelMatches?: number }} scores
|
|
116
|
+
* - titleOverlap: 0-50 points (how similar the titles are)
|
|
117
|
+
* - fileMentions: 0-30 points (shared file references)
|
|
118
|
+
* - labelMatches: 0-20 points (shared labels)
|
|
119
|
+
* @returns {{ confidence: number, level: 'high' | 'medium' | 'low' | 'none' }}
|
|
120
|
+
*/
|
|
121
|
+
export function scoreDedupConfidence({ titleOverlap = 0, fileMentions = 0, labelMatches = 0 } = {}) {
|
|
122
|
+
const confidence = Math.min(100, Math.max(0, titleOverlap + fileMentions + labelMatches))
|
|
123
|
+
let level = 'none'
|
|
124
|
+
if (confidence >= 70) level = 'high'
|
|
125
|
+
else if (confidence >= 40) level = 'medium'
|
|
126
|
+
else if (confidence > 20) level = 'low'
|
|
127
|
+
return { confidence, level }
|
|
128
|
+
}
|
|
129
|
+
|
|
107
130
|
/**
|
|
108
131
|
* Run the full deduplication pipeline for a prospective issue.
|
|
109
132
|
*
|
|
@@ -146,12 +169,16 @@ export async function checkDuplicate(repo, { title, body, idempotencyKey, agent
|
|
|
146
169
|
})
|
|
147
170
|
}
|
|
148
171
|
|
|
149
|
-
// Layer 3: fuzzy title match
|
|
150
|
-
const
|
|
172
|
+
// Layer 3: fuzzy title match (token-set Jaccard)
|
|
173
|
+
const newTokens = tokenize(title)
|
|
174
|
+
const isShortTitle = newTokens.length <= 2
|
|
151
175
|
const openIssues = await getCachedIssues(repo)
|
|
152
176
|
for (const issue of openIssues) {
|
|
153
|
-
const similarity =
|
|
154
|
-
|
|
177
|
+
const similarity = jaccardSimilarity(title, issue.title)
|
|
178
|
+
// Short-title guard: ≤2 meaningful tokens require exact match to block, ≥0.80 to warn
|
|
179
|
+
const blockThreshold = isShortTitle ? 1.0 : 0.80
|
|
180
|
+
const warnThreshold = isShortTitle ? 0.80 : 0.50
|
|
181
|
+
if (similarity >= blockThreshold) {
|
|
155
182
|
results.push({
|
|
156
183
|
action: 'block',
|
|
157
184
|
reason: `Fuzzy title match (similarity ${(similarity * 100).toFixed(1)}%) with existing issue`,
|
|
@@ -162,7 +189,7 @@ export async function checkDuplicate(repo, { title, body, idempotencyKey, agent
|
|
|
162
189
|
},
|
|
163
190
|
})
|
|
164
191
|
break
|
|
165
|
-
} else if (similarity
|
|
192
|
+
} else if (similarity >= warnThreshold) {
|
|
166
193
|
results.push({
|
|
167
194
|
action: 'warn',
|
|
168
195
|
reason: `Fuzzy title is similar (similarity ${(similarity * 100).toFixed(1)}%) to existing issue`,
|
|
@@ -190,16 +217,24 @@ export async function checkDuplicate(repo, { title, body, idempotencyKey, agent
|
|
|
190
217
|
/**
|
|
191
218
|
* Persist deduplication records after a successful issue creation.
|
|
192
219
|
* Stores both the content fingerprint and, if provided, the idempotency key.
|
|
220
|
+
* Also appends the new issue to the in-memory cache so subsequent dedup
|
|
221
|
+
* checks within the same process (or cache TTL window) see it immediately.
|
|
193
222
|
*
|
|
194
223
|
* @param {string} repo
|
|
195
|
-
* @param {{ title: string, body: string, issueNumber: number, idempotencyKey?: string, agent?: string }} options
|
|
224
|
+
* @param {{ title: string, body: string, issueNumber: number, url?: string, idempotencyKey?: string, agent?: string }} options
|
|
196
225
|
* @returns {Promise<void>}
|
|
197
226
|
*/
|
|
198
|
-
export async function recordCreation(repo, { title, body, issueNumber, idempotencyKey, agent }) {
|
|
227
|
+
export async function recordCreation(repo, { title, body, issueNumber, url, idempotencyKey, agent }) {
|
|
199
228
|
const fingerprint = computeFingerprint(title, body ?? '')
|
|
200
229
|
await storeFingerprint(repo, fingerprint, issueNumber, title, agent ?? 'unknown')
|
|
201
230
|
|
|
202
231
|
if (idempotencyKey) {
|
|
203
232
|
await storeIdempotencyKey(idempotencyKey, repo, issueNumber)
|
|
204
233
|
}
|
|
234
|
+
|
|
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 })
|
|
239
|
+
}
|
|
205
240
|
}
|
package/src/lib/defaults.js
CHANGED
|
@@ -36,11 +36,63 @@ export const BUILT_IN_DEFAULTS = {
|
|
|
36
36
|
default: 'default', // default template name
|
|
37
37
|
},
|
|
38
38
|
|
|
39
|
+
// Enhancements
|
|
40
|
+
enhancements: {
|
|
41
|
+
dir: '.tissues/enhancements', // relative to repo root, or absolute
|
|
42
|
+
},
|
|
43
|
+
|
|
39
44
|
// AI
|
|
40
45
|
ai: {
|
|
41
46
|
enabled: true,
|
|
42
|
-
provider: 'anthropic',
|
|
43
|
-
model:
|
|
47
|
+
provider: 'anthropic',
|
|
48
|
+
model: null, // global model override
|
|
49
|
+
models: { // per-provider defaults
|
|
50
|
+
anthropic: 'claude-haiku-4-5-20251001',
|
|
51
|
+
openai: 'gpt-4o-mini',
|
|
52
|
+
gemini: 'gemini-2.0-flash',
|
|
53
|
+
ollama: 'llama3.2',
|
|
54
|
+
'openai-compat': null, // user must set
|
|
55
|
+
command: null, // not applicable
|
|
56
|
+
},
|
|
57
|
+
keys: {
|
|
58
|
+
anthropic: null,
|
|
59
|
+
openai: null,
|
|
60
|
+
gemini: null,
|
|
61
|
+
'openai-compat': null,
|
|
62
|
+
},
|
|
63
|
+
ollama: { url: 'http://localhost:11434' },
|
|
64
|
+
custom: { url: null }, // openai-compat base URL
|
|
65
|
+
command: null, // e.g. 'co inference'
|
|
66
|
+
providers: {}, // named custom providers: { "my-gemini": { command: "co gemini", timeout: 120000 } }
|
|
67
|
+
routes: [], // routing rules
|
|
68
|
+
budgets: {
|
|
69
|
+
maxTokensPerRequest: 4096, // hard cap per single AI call
|
|
70
|
+
maxTokensPerHour: 50000, // rolling hourly token budget
|
|
71
|
+
maxTokensPerDay: 200000, // rolling daily token budget
|
|
72
|
+
},
|
|
73
|
+
pipeline: {
|
|
74
|
+
enabled: true, // multi-step enhancement pipeline
|
|
75
|
+
steps: { // legacy key (alias for enhancements)
|
|
76
|
+
triage: 'always', // 'always' | 'auto' | 'never'
|
|
77
|
+
dedup: 'auto', // 'always' | 'auto' | 'never'
|
|
78
|
+
context: 'always',
|
|
79
|
+
scope: 'auto',
|
|
80
|
+
complexity: 'auto',
|
|
81
|
+
risk: 'auto',
|
|
82
|
+
labels: 'auto',
|
|
83
|
+
format: 'always',
|
|
84
|
+
},
|
|
85
|
+
enhancements: { // preferred key (same shape as steps)
|
|
86
|
+
triage: 'always',
|
|
87
|
+
dedup: 'auto',
|
|
88
|
+
context: 'always',
|
|
89
|
+
scope: 'auto',
|
|
90
|
+
complexity: 'auto',
|
|
91
|
+
risk: 'auto',
|
|
92
|
+
labels: 'auto',
|
|
93
|
+
format: 'always',
|
|
94
|
+
},
|
|
95
|
+
},
|
|
44
96
|
},
|
|
45
97
|
|
|
46
98
|
// Hooks (shell commands to run)
|