@goodandready/dsh-kanban 0.2.37 → 0.2.38

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/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.37",
3
+ "version": "0.2.38",
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",