@goodandready/dsh-kanban 0.2.37 → 0.2.39
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/lib/client.js +2780 -2687
- package/lib/config.js +4 -0
- package/lib/server/task-handler.js +15 -0
- package/lib/similarity.js +206 -0
- package/package.json +1 -1
package/lib/config.js
CHANGED
|
@@ -96,6 +96,8 @@ export const CONFIG_DEFAULTS = {
|
|
|
96
96
|
watchdogMaxConsecutiveErrors: 5,
|
|
97
97
|
timeMachineEnabled: true,
|
|
98
98
|
timeMachineAutoSnapshot: true,
|
|
99
|
+
notificationsEnabled: false,
|
|
100
|
+
audioAlertsEnabled: false,
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
/** Field descriptions reused in schema and settings card. */
|
|
@@ -151,6 +153,8 @@ export const CONFIG_HINTS = {
|
|
|
151
153
|
watchdogMaxConsecutiveErrors: 'Number of consecutive execution errors before tripping circuit breaker (default: 5).',
|
|
152
154
|
timeMachineEnabled: 'Enable integration with @goodandready/dsh-time-machine for task checkpoints and rollback.',
|
|
153
155
|
timeMachineAutoSnapshot: 'Automatically create inception snapshot when task transitions to in-progress.',
|
|
156
|
+
notificationsEnabled: 'Enable desktop push notifications for key task events.',
|
|
157
|
+
audioAlertsEnabled: 'Enable Web Audio synthesized audio alerts on task transitions.',
|
|
154
158
|
}
|
|
155
159
|
|
|
156
160
|
/** Setting key for column WIP limit; not all columns have limits. */
|
|
@@ -36,6 +36,7 @@ import { runTask, queueTask, unqueueTask, resolveModel, buildStartMessage, PERMI
|
|
|
36
36
|
import { dispatchMove, stopWork, MOVE_DETAIL, STOP_DETAIL } from '../commands.js'
|
|
37
37
|
import { checkWorktreeDirty } from '../worktree.js'
|
|
38
38
|
import { refreshTask } from '../import.js'
|
|
39
|
+
import { findDuplicates } from '../similarity.js'
|
|
39
40
|
import { parseProgressDump, formatProgressDump } from '../progress-dump.js'
|
|
40
41
|
import { rootOf } from '../config.js'
|
|
41
42
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
@@ -67,6 +68,20 @@ export function createTaskRouteHandler(deps) {
|
|
|
67
68
|
return async (req, res) => {
|
|
68
69
|
const pathname = urlOf(req).pathname
|
|
69
70
|
|
|
71
|
+
if (pathname === '/dsh-kanban/task/find-duplicates') {
|
|
72
|
+
if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
73
|
+
if (rejectUntrusted(req, res)) return
|
|
74
|
+
try {
|
|
75
|
+
const input = await readBody(req)
|
|
76
|
+
const candidates = store.listTasks(input?.board || 'main') || []
|
|
77
|
+
const duplicates = findDuplicates(input, candidates, { threshold: input?.threshold, limit: input?.limit })
|
|
78
|
+
writeJson(res, 200, { ok: true, duplicates })
|
|
79
|
+
} catch {
|
|
80
|
+
writeJson(res, 400, { error: 'bad-request' })
|
|
81
|
+
}
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
// Path without ID handles local task creation.
|
|
71
86
|
if (pathname === '/dsh-kanban/task' || pathname === '/dsh-kanban/task/') {
|
|
72
87
|
if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Similarity & Duplicate Task Detection Engine (#380)
|
|
3
|
+
*
|
|
4
|
+
* Implements multilingual tokenization and character n-gram Dice/Jaccard
|
|
5
|
+
* similarity algorithms to identify duplicate or highly similar tasks
|
|
6
|
+
* without external dependencies.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const STOP_WORDS = new Set([
|
|
10
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
|
11
|
+
'of', 'with', 'by', 'from', 'up', 'about', 'into', 'over', 'after',
|
|
12
|
+
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
|
13
|
+
'do', 'does', 'did', 'can', 'could', 'should', 'would', 'will', 'shall',
|
|
14
|
+
'this', 'that', 'these', 'those', 'it', 'its', 'as', 'if', 'when',
|
|
15
|
+
'feature', 'bug', 'task', 'fix', 'add', 'update', 'implement', 'test', 'tests'
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Normalizes input text by removing markdown syntax, code fences,
|
|
20
|
+
* URLs, issue references, and non-letter symbols.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} text
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function normalizeText(text) {
|
|
26
|
+
if (typeof text !== 'string') return ''
|
|
27
|
+
return text
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
30
|
+
.replace(/`/g, ' ')
|
|
31
|
+
.replace(/https?:\/\/[^\s]+/g, ' ')
|
|
32
|
+
.replace(/#\d+/g, ' ')
|
|
33
|
+
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
|
34
|
+
.replace(/\s+/g, ' ')
|
|
35
|
+
.trim()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Splits text into cleaned words, filtering out short tokens and common stop words.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} text
|
|
42
|
+
* @returns {string[]}
|
|
43
|
+
*/
|
|
44
|
+
function tokenizeWords(text) {
|
|
45
|
+
const normalized = normalizeText(text)
|
|
46
|
+
if (!normalized) return []
|
|
47
|
+
return normalized
|
|
48
|
+
.split(' ')
|
|
49
|
+
.filter((w) => w.length > 1 && !STOP_WORDS.has(w))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extracts character n-grams from normalized text for sub-word multilingual matching.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} text
|
|
56
|
+
* @param {number} [n=3]
|
|
57
|
+
* @returns {Set<string>}
|
|
58
|
+
*/
|
|
59
|
+
function extractNgrams(text, n = 3) {
|
|
60
|
+
const normalized = normalizeText(text).replace(/\s+/g, '')
|
|
61
|
+
const ngrams = new Set()
|
|
62
|
+
if (normalized.length < n) {
|
|
63
|
+
if (normalized.length > 0) ngrams.add(normalized)
|
|
64
|
+
return ngrams
|
|
65
|
+
}
|
|
66
|
+
for (let i = 0; i <= normalized.length - n; i++) {
|
|
67
|
+
ngrams.add(normalized.slice(i, i + n))
|
|
68
|
+
}
|
|
69
|
+
return ngrams
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Calculates Dice similarity coefficient between two sets: 2 * |A ∩ B| / (|A| + |B|).
|
|
74
|
+
*
|
|
75
|
+
* @param {Set<string>} setA
|
|
76
|
+
* @param {Set<string>} setB
|
|
77
|
+
* @returns {number} Float between 0.0 and 1.0
|
|
78
|
+
*/
|
|
79
|
+
function diceCoefficient(setA, setB) {
|
|
80
|
+
if (!setA.size || !setB.size) return 0
|
|
81
|
+
let intersection = 0
|
|
82
|
+
for (const item of setA) {
|
|
83
|
+
if (setB.has(item)) intersection++
|
|
84
|
+
}
|
|
85
|
+
return (2 * intersection) / (setA.size + setB.size)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Calculates Jaccard index between two sets: |A ∩ B| / |A ∪ B|.
|
|
90
|
+
*
|
|
91
|
+
* @param {Set<string>} setA
|
|
92
|
+
* @param {Set<string>} setB
|
|
93
|
+
* @returns {number} Float between 0.0 and 1.0
|
|
94
|
+
*/
|
|
95
|
+
function jaccardIndex(setA, setB) {
|
|
96
|
+
if (!setA.size || !setB.size) return 0
|
|
97
|
+
let intersection = 0
|
|
98
|
+
for (const item of setA) {
|
|
99
|
+
if (setB.has(item)) intersection++
|
|
100
|
+
}
|
|
101
|
+
const union = setA.size + setB.size - intersection
|
|
102
|
+
return union > 0 ? intersection / union : 0
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Calculates hybrid text similarity blending word token overlap and character 3-grams.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} a
|
|
109
|
+
* @param {string} b
|
|
110
|
+
* @returns {number} Similarity score between 0.0 and 1.0
|
|
111
|
+
*/
|
|
112
|
+
function calculateTextSimilarity(a, b) {
|
|
113
|
+
if (!a || !b) return 0
|
|
114
|
+
if (a.trim().toLowerCase() === b.trim().toLowerCase()) return 1.0
|
|
115
|
+
|
|
116
|
+
const wordsA = new Set(tokenizeWords(a))
|
|
117
|
+
const wordsB = new Set(tokenizeWords(b))
|
|
118
|
+
const wordScore = diceCoefficient(wordsA, wordsB)
|
|
119
|
+
|
|
120
|
+
const ngramsA = extractNgrams(a, 3)
|
|
121
|
+
const ngramsB = extractNgrams(b, 3)
|
|
122
|
+
const ngramScore = diceCoefficient(ngramsA, ngramsB)
|
|
123
|
+
|
|
124
|
+
// Blend word tokens (0.4) and character n-grams (0.6) for morphological tolerance
|
|
125
|
+
return wordScore * 0.4 + ngramScore * 0.6
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Calculates composite similarity between query task parameters and an existing task card.
|
|
130
|
+
*
|
|
131
|
+
* @param {{ title?: string, description?: string }} query
|
|
132
|
+
* @param {{ title?: string, description?: string }} candidate
|
|
133
|
+
* @returns {number} Score between 0.0 and 1.0
|
|
134
|
+
*/
|
|
135
|
+
function compareTasks(query, candidate) {
|
|
136
|
+
if (!query || !candidate) return 0
|
|
137
|
+
const titleA = query.title || ''
|
|
138
|
+
const titleB = candidate.title || ''
|
|
139
|
+
|
|
140
|
+
if (titleA && titleB && titleA.trim().toLowerCase() === titleB.trim().toLowerCase()) {
|
|
141
|
+
return 1.0
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const titleSim = calculateTextSimilarity(titleA, titleB)
|
|
145
|
+
const descA = query.description || ''
|
|
146
|
+
const descB = candidate.description || ''
|
|
147
|
+
|
|
148
|
+
if (!descA || !descB) {
|
|
149
|
+
return titleSim
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const descSim = calculateTextSimilarity(descA, descB)
|
|
153
|
+
// Title carries 70% weight, description carries 30% weight
|
|
154
|
+
return titleSim * 0.7 + descSim * 0.3
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Finds duplicate or highly similar tasks from an array of candidate cards.
|
|
159
|
+
*
|
|
160
|
+
* @param {{ title?: string, description?: string, excludeId?: string }} query
|
|
161
|
+
* @param {Array<Object>} candidates
|
|
162
|
+
* @param {{ threshold?: number, limit?: number }} [options]
|
|
163
|
+
* @returns {Array<{ id: string, title: string, issueNumber?: number, score: number, percent: number }>}
|
|
164
|
+
*/
|
|
165
|
+
function findDuplicates(query, candidates, options = {}) {
|
|
166
|
+
if (!query || !Array.isArray(candidates) || candidates.length === 0) {
|
|
167
|
+
return []
|
|
168
|
+
}
|
|
169
|
+
const threshold = typeof options.threshold === 'number' ? options.threshold : 0.45
|
|
170
|
+
const limit = typeof options.limit === 'number' ? options.limit : 5
|
|
171
|
+
const excludeId = query.excludeId || query.id
|
|
172
|
+
|
|
173
|
+
const results = []
|
|
174
|
+
for (const candidate of candidates) {
|
|
175
|
+
if (!candidate || !candidate.id) continue
|
|
176
|
+
if (excludeId && candidate.id === excludeId) continue
|
|
177
|
+
|
|
178
|
+
const score = compareTasks(query, candidate)
|
|
179
|
+
if (score >= threshold) {
|
|
180
|
+
results.push({
|
|
181
|
+
id: candidate.id,
|
|
182
|
+
title: candidate.title,
|
|
183
|
+
issueNumber: candidate.issueNumber,
|
|
184
|
+
repo: candidate.repo,
|
|
185
|
+
column: candidate.column,
|
|
186
|
+
score: Math.round(score * 1000) / 1000,
|
|
187
|
+
percent: Math.round(score * 100),
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return results
|
|
193
|
+
.sort((a, b) => b.score - a.score)
|
|
194
|
+
.slice(0, limit)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export {
|
|
198
|
+
normalizeText,
|
|
199
|
+
tokenizeWords,
|
|
200
|
+
extractNgrams,
|
|
201
|
+
diceCoefficient,
|
|
202
|
+
jaccardIndex,
|
|
203
|
+
calculateTextSimilarity,
|
|
204
|
+
compareTasks,
|
|
205
|
+
findDuplicates,
|
|
206
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.39",
|
|
4
4
|
"description": "Visual Kanban board for DeepSeek Harness: Gitea and GitHub-backed tasks, workflow columns, and a dedicated agent session per task.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|