dsh-titlecraft 0.1.0-rc.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/src/core.ts ADDED
@@ -0,0 +1,516 @@
1
+ import iconCatalogData from './icons.json' with { type: 'json' }
2
+
3
+ export const ALLOWED_TYPES = Object.freeze([
4
+ '功能',
5
+ '设计',
6
+ '修复',
7
+ '优化',
8
+ '发布',
9
+ '探索',
10
+ '文档',
11
+ '研究',
12
+ ])
13
+
14
+ export const TITLE_TEMPLATES = Object.freeze({
15
+ standard: '{icon} {date}|{type}|{topic}',
16
+ concise: '{icon} {type}|{topic}',
17
+ 'topic-first': '{icon} {topic}|{type} · {date}',
18
+ bracketed: '{icon} [{type}] {topic} · {date}',
19
+ compact: '{icon} {date} · {topic}',
20
+ timeline: '{icon} {date} — {topic} [{type}]',
21
+ minimal: '{icon} {topic}',
22
+ })
23
+ export const TITLE_ICON_GROUPS = Object.freeze(
24
+ iconCatalogData.groups.map((group) =>
25
+ Object.freeze({
26
+ id: group.id,
27
+ zh: group.zh,
28
+ en: group.en,
29
+ icons: Object.freeze(group.icons.map((icon) => Object.freeze({ ...icon }))),
30
+ }),
31
+ ),
32
+ )
33
+ export const TITLE_ICON_CATALOG = Object.freeze(TITLE_ICON_GROUPS.flatMap(({ icons }) => icons))
34
+ export const TITLE_ICON_LEGACY_GLYPHS = Object.freeze([...iconCatalogData.legacyGlyphs])
35
+ export const TITLE_ICONS = Object.freeze(
36
+ Object.fromEntries([['none', ''], ...TITLE_ICON_CATALOG.map(({ id, glyph }) => [id, glyph])]),
37
+ )
38
+ const FONT_FAMILIES = new Set(['dsh', 'system-ui', 'ui-monospace', 'ui-serif'])
39
+
40
+ export const DEFAULTS: any = Object.freeze({
41
+ enabled: true,
42
+ dateSource: 'createdAt',
43
+ types: ALLOWED_TYPES,
44
+ enabledTypes: ALLOWED_TYPES,
45
+ templatePreset: 'standard',
46
+ formatTemplate: TITLE_TEMPLATES.standard,
47
+ iconMode: 'none',
48
+ iconId: 'spark',
49
+ fontFamily: 'dsh',
50
+ fontSize: 14,
51
+ sessionTitleLeftOffset: 0,
52
+ timezone: 'Asia/Shanghai',
53
+ datePattern: 'MMDD',
54
+ instantEnabled: true,
55
+ firstMessageLlmEnabled: false,
56
+ refineEnabled: true,
57
+ idleDelayMs: 3000,
58
+ maxInputBytes: 6000,
59
+ maxOutputTokens: 96,
60
+ timeoutMs: 60000,
61
+ topicMaxChars: 16,
62
+ minConfidence: 0.72,
63
+ preferCheap: true,
64
+ fallbackToSessionModel: false,
65
+ modelProvider: '',
66
+ modelName: '',
67
+ respectManualTitles: true,
68
+ topicGuidance: '主题应简洁、具体,基于对话实际内容;不要重复项目名称。无法判断时返回空主题。',
69
+ })
70
+
71
+ const TYPE_RULES: ReadonlyArray<readonly [string, RegExp]> = Object.freeze([
72
+ ['修复', /(?:修复|修好|排查|解决|报错|错误|故障|崩溃|失败|异常|\bfix(?:ed|ing)?\b|\bbug\b)/iu],
73
+ [
74
+ '发布',
75
+ /(?:发布|部署|上线|发版|提交代码到\s*github|推送到\s*github|\brelease\b|\bdeploy\b|\bpublish\b)/iu,
76
+ ],
77
+ [
78
+ '优化',
79
+ /(?:优化|改进|提升|加速|精简|重构|性能|体验|\boptimi[sz]e\b|\brefactor\b|\bperformance\b)/iu,
80
+ ],
81
+ ['文档', /(?:文档|说明书|使用说明|教程|指南|readme|\bdocs?\b|\bdocument(?:ation)?\b)/iu],
82
+ ['研究', /(?:研究|调研|分析|对比|评估|验证原理|benchmark|\bresearch\b|\binvestigate\b)/iu],
83
+ ['设计', /(?:设计|规划|方案|架构|界面|交互|布局|样式|视觉|\bdesign\b|\barchitecture\b)/iu],
84
+ ['探索', /(?:探索|讨论|尝试|可行性|设想|脑暴|brainstorm|\bexplore\b|\bprototype\b)/iu],
85
+ [
86
+ '功能',
87
+ /(?:新增|添加|实现|开发|创建|整合|集成|接入|支持|完成|功能|\badd\b|\bimplement\b|\bintegrat(?:e|ion)\b|\bfeature\b)/iu,
88
+ ],
89
+ ])
90
+
91
+ const GENERIC_TOPICS = new Set([
92
+ '新功能',
93
+ '新功能讨论',
94
+ '功能讨论',
95
+ '讨论',
96
+ '继续',
97
+ '继续处理',
98
+ '帮我看看',
99
+ '处理一下',
100
+ '看看这个',
101
+ '解决问题',
102
+ '修复问题',
103
+ '优化一下',
104
+ '做个设计',
105
+ 'new feature',
106
+ 'discussion',
107
+ 'continue',
108
+ 'help me',
109
+ ])
110
+
111
+ // Control characters are deliberately stripped from untrusted title input.
112
+ // eslint-disable-next-line no-control-regex
113
+ const CONTROL_PATTERN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g
114
+ const EDGE_PUNCTUATION =
115
+ /^[\s.,;:!?。,;:!?、·\-—_'"“”‘’()[\]{}<>/\\|~`#*+=&%$@]+|[\s.,;:!?。,;:!?、·\-—_'"“”‘’()[\]{}<>/\\|~`#*+=&%$@]+$/g
116
+
117
+ export function normalizeConfig(value: any = {}) {
118
+ const out = { ...DEFAULTS, ...(value && typeof value === 'object' ? value : {}) }
119
+ out.enabled = Boolean(out.enabled)
120
+ out.dateSource = ['createdAt', 'lastUserMessage', 'lastCompletedTurn', 'lastActivity'].includes(
121
+ out.dateSource,
122
+ )
123
+ ? out.dateSource
124
+ : DEFAULTS.dateSource
125
+ out.types = normalizeTypes(out.types, 'types')
126
+ out.enabledTypes = normalizeTypes(value?.enabledTypes ?? out.types, 'enabledTypes')
127
+ if (out.enabledTypes.some((type) => !out.types.includes(type))) {
128
+ throw new Error('启用类型必须存在于类型列表中')
129
+ }
130
+ out.instantEnabled = Boolean(out.instantEnabled)
131
+ out.firstMessageLlmEnabled = Boolean(out.firstMessageLlmEnabled)
132
+ out.refineEnabled = Boolean(out.refineEnabled)
133
+ out.preferCheap = Boolean(out.preferCheap)
134
+ out.fallbackToSessionModel = Boolean(out.fallbackToSessionModel)
135
+ out.respectManualTitles = Boolean(out.respectManualTitles)
136
+ out.templatePreset = Object.hasOwn(TITLE_TEMPLATES, out.templatePreset)
137
+ ? out.templatePreset
138
+ : 'custom'
139
+ out.formatTemplate = String(
140
+ out.formatTemplate || TITLE_TEMPLATES[out.templatePreset] || DEFAULTS.formatTemplate,
141
+ ).trim()
142
+ const requestedIconMode = out.iconMode === 'type' ? 'smart' : out.iconMode
143
+ out.iconMode = ['none', 'selected', 'smart'].includes(requestedIconMode)
144
+ ? requestedIconMode
145
+ : DEFAULTS.iconMode
146
+ out.iconId = Object.hasOwn(TITLE_ICONS, out.iconId) ? out.iconId : DEFAULTS.iconId
147
+ out.fontFamily = FONT_FAMILIES.has(out.fontFamily) ? out.fontFamily : DEFAULTS.fontFamily
148
+ out.fontSize = integerIn(out.fontSize, 12, 20, DEFAULTS.fontSize)
149
+ out.sessionTitleLeftOffset = integerIn(
150
+ out.sessionTitleLeftOffset,
151
+ 0,
152
+ 8,
153
+ DEFAULTS.sessionTitleLeftOffset,
154
+ )
155
+ out.timezone = String(out.timezone || DEFAULTS.timezone).trim()
156
+ out.datePattern = String(out.datePattern || DEFAULTS.datePattern).trim()
157
+ out.modelProvider = String(out.modelProvider || '').trim()
158
+ out.modelName = String(out.modelName || '').trim()
159
+ out.topicGuidance = String(out.topicGuidance || DEFAULTS.topicGuidance).trim()
160
+ out.idleDelayMs = integerIn(out.idleDelayMs, 250, 600000, DEFAULTS.idleDelayMs)
161
+ out.maxInputBytes = integerIn(out.maxInputBytes, 512, 65536, DEFAULTS.maxInputBytes)
162
+ out.maxOutputTokens = integerIn(out.maxOutputTokens, 32, 512, DEFAULTS.maxOutputTokens)
163
+ out.timeoutMs = integerIn(out.timeoutMs, 1000, 2 ** 31 - 1, DEFAULTS.timeoutMs)
164
+ out.topicMaxChars = integerIn(out.topicMaxChars, 4, 40, DEFAULTS.topicMaxChars)
165
+ out.minConfidence = numberIn(out.minConfidence, 0, 1, DEFAULTS.minConfidence)
166
+
167
+ const tokens = [...out.formatTemplate.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1])
168
+ if (
169
+ tokens.some((token) => !['date', 'type', 'topic', 'icon'].includes(token)) ||
170
+ /[{}]/.test(out.formatTemplate.replace(/\{(?:date|type|topic|icon)\}/g, ''))
171
+ )
172
+ throw new Error('formatTemplate 包含未知或不完整的占位符')
173
+ if (!out.formatTemplate.includes('{topic}')) {
174
+ throw new Error('formatTemplate 必须包含 {topic}')
175
+ }
176
+ if (out.modelProvider.length > 0 !== out.modelName.length > 0) {
177
+ throw new Error('modelProvider 与 modelName 必须同时设置或同时留空')
178
+ }
179
+ try {
180
+ new Intl.DateTimeFormat('en-US', { timeZone: out.timezone }).format(0)
181
+ } catch {
182
+ throw new Error(`无效时区:${out.timezone}`)
183
+ }
184
+ return Object.freeze(out)
185
+ }
186
+
187
+ export function normalizeTypes(value, field = 'types') {
188
+ if (!Array.isArray(value) || value.length < 1 || value.length > 32) {
189
+ throw new Error(`${field} 必须包含 1–32 个类型`)
190
+ }
191
+ const types = value.map((type) => {
192
+ if (typeof type !== 'string') throw new Error('类型名称必须是文字')
193
+ const name = type.trim()
194
+ if (!/^[\p{L}\p{N}][\p{L}\p{N} _-]*$/u.test(name) || [...name].length > 12) {
195
+ throw new Error('类型名称须为 1–12 个字符,仅允许文字、数字、空格、下划线和短横线')
196
+ }
197
+ return name
198
+ })
199
+ if (new Set(types.map((type) => type.toLowerCase())).size !== types.length) {
200
+ throw new Error('类型名称不能重复')
201
+ }
202
+ return Object.freeze(types)
203
+ }
204
+
205
+ export function formatCreatedDate(
206
+ createdAt,
207
+ timezone = DEFAULTS.timezone,
208
+ pattern = DEFAULTS.datePattern,
209
+ ) {
210
+ if (!Number.isFinite(createdAt) || createdAt < 0)
211
+ throw new Error('createdAt 必须是非负 Unix 毫秒时间戳')
212
+ const parts = new Intl.DateTimeFormat('en-US', {
213
+ timeZone: timezone,
214
+ year: 'numeric',
215
+ month: '2-digit',
216
+ day: '2-digit',
217
+ }).formatToParts(new Date(createdAt))
218
+ const table = Object.fromEntries(parts.map((part) => [part.type, part.value]))
219
+ return pattern
220
+ .replaceAll('YYYY', table.year)
221
+ .replaceAll('MM', table.month)
222
+ .replaceAll('DD', table.day)
223
+ }
224
+
225
+ export function formatSessionTitle({
226
+ createdAt,
227
+ dateTimestamp,
228
+ type,
229
+ topic,
230
+ iconId: semanticIconId = '',
231
+ config = DEFAULTS,
232
+ maxBytes = 80,
233
+ }) {
234
+ const policy = normalizeConfig(config)
235
+ if (!policy.enabledTypes.includes(type)) throw new Error(`未启用的类型:${String(type)}`)
236
+ const cleanTopic = normalizeTopic(topic, policy.topicMaxChars)
237
+ if (cleanTopic.length === 0) return null
238
+ const timestamp = Number.isFinite(dateTimestamp) ? dateTimestamp : createdAt
239
+ const date = formatCreatedDate(timestamp, policy.timezone, policy.datePattern)
240
+ const resolvedIconId =
241
+ policy.iconMode === 'smart' &&
242
+ Object.hasOwn(TITLE_ICONS, semanticIconId) &&
243
+ semanticIconId !== 'none'
244
+ ? semanticIconId
245
+ : policy.iconId
246
+ const icon = policy.iconMode === 'none' ? '' : TITLE_ICONS[resolvedIconId] || ''
247
+ const render = () =>
248
+ policy.formatTemplate
249
+ .replaceAll('{date}', date)
250
+ .replaceAll('{type}', type)
251
+ .replaceAll('{topic}', cleanTopic)
252
+ .replaceAll('{icon}', icon ? `${icon} ` : '')
253
+ .replace(/\s+/g, ' ')
254
+ .trim()
255
+ const title = render()
256
+ return Buffer.byteLength(title, 'utf8') <= maxBytes ? title : null
257
+ }
258
+
259
+ export function inferInstant(text, options: any = {}) {
260
+ const policy = normalizeConfig(options.config ?? options)
261
+ if (
262
+ /(?:[\n\r`“”「」]|https?:\/\/|^\s*>|^(?:继续|已重启|不要|无需|别|还是|现在|好的))/u.test(
263
+ String(text),
264
+ )
265
+ )
266
+ return null
267
+ const cleaned = cleanSourceText(text)
268
+ if (cleaned.length === 0) return null
269
+ // Extract an explicit capability instead of truncating a provider/model prefix.
270
+ if (
271
+ /^(?:请\s*)?(?:修改|调整|配置)[^\n]{0,100}(?:模型|参数)[^\n]{0,40}(?:使之|使其|让其|以便)[^\n]{0,20}(?:调整|选择|设置)(?:思考|推理)强度[。!!]?$/u.test(
272
+ cleaned,
273
+ )
274
+ ) {
275
+ if (!policy.enabledTypes.includes('功能')) return null
276
+ const topic = normalizeTopic('思考强度选择', policy.topicMaxChars)
277
+ return topic ? { type: '功能', topic, confidence: 0.9 } : null
278
+ }
279
+ const type = classifyType(cleaned)
280
+ if (type === null || !policy.enabledTypes.includes(type)) return null
281
+ const topic = extractHeuristicTopic(cleaned, type, options.projectName, policy.topicMaxChars)
282
+ if (topic === null) return null
283
+ return { type, topic, confidence: 0.78 }
284
+ }
285
+
286
+ export function classifyType(text) {
287
+ for (const [type, pattern] of TYPE_RULES) {
288
+ if (pattern.test(text)) return type
289
+ }
290
+ return null
291
+ }
292
+
293
+ export function extractHeuristicTopic(text, type, projectName, maxChars = DEFAULTS.topicMaxChars) {
294
+ if (/[,,;;。!?!?::]/u.test(text.replace(/[。!!]$/u, ''))) return null
295
+ let topic = cleanSourceText(text)
296
+ if (projectName) topic = removeProjectName(topic, projectName)
297
+ topic = topic
298
+ .replace(
299
+ /^(?:麻烦你?|请你?|请|帮我(?:一下|看看|把)?|你能(?:不能)?|可以帮我|我想(?:要)?|我需要|please|could you|can you)\s*/iu,
300
+ '',
301
+ )
302
+ .replace(/^(?:关于|针对)\s*/u, '')
303
+
304
+ if (type === '优化') topic = topic.replace(/^(?:优化|改进|提升|精简|重构)\s*/u, '')
305
+ if (type === '修复') topic = topic.replace(/^(?:修复|修好|解决|排查)\s*/u, '')
306
+ if (type === '设计') topic = topic.replace(/^(?:设计|规划)\s*/u, '')
307
+ if (type === '文档') topic = topic.replace(/^(?:编写|补充|更新)\s*(?:文档)?\s*/u, '')
308
+ if (type === '研究') topic = topic.replace(/^(?:研究|调研|分析|评估)\s*/u, '')
309
+
310
+ if (/(?:这个|那个|一下|使之|使其|以便|然后|同时|怎么|如何)/u.test(topic)) return null
311
+ topic = normalizeTopic(topic, maxChars)
312
+ if (topic.length < 2 || isGenericTopic(topic)) return null
313
+ return topic
314
+ }
315
+
316
+ export function inspectModelDecision(raw, options: any = {}) {
317
+ const policy = normalizeConfig(options.config ?? options)
318
+ let parsed
319
+ try {
320
+ parsed = JSON.parse(String(raw ?? '').trim())
321
+ } catch {
322
+ return { kind: 'invalid', reason: 'invalid-json' }
323
+ }
324
+ if (
325
+ !parsed ||
326
+ Array.isArray(parsed) ||
327
+ typeof parsed !== 'object' ||
328
+ Object.keys(parsed).some(
329
+ (key) =>
330
+ ![
331
+ 'type',
332
+ 'topic',
333
+ 'confidence',
334
+ ...(policy.iconMode === 'smart' ? ['iconId'] : []),
335
+ ].includes(key),
336
+ ) ||
337
+ typeof parsed.type !== 'string' ||
338
+ typeof parsed.topic !== 'string' ||
339
+ typeof parsed.confidence !== 'number' ||
340
+ !Number.isFinite(parsed.confidence) ||
341
+ parsed.confidence < 0 ||
342
+ parsed.confidence > 1
343
+ ) {
344
+ return { kind: 'invalid', reason: 'invalid-schema' }
345
+ }
346
+ if (parsed.confidence < policy.minConfidence || !parsed.topic.trim())
347
+ return { kind: 'uncertain', reason: 'uncertain-topic' }
348
+ if (!policy.enabledTypes.includes(parsed.type)) return { kind: 'invalid', reason: 'invalid-type' }
349
+ if (
350
+ policy.iconMode === 'smart' &&
351
+ (typeof parsed.iconId !== 'string' ||
352
+ parsed.iconId === 'none' ||
353
+ !Object.hasOwn(TITLE_ICONS, parsed.iconId))
354
+ )
355
+ return { kind: 'invalid', reason: 'invalid-icon' }
356
+ let topic = parsed.topic
357
+ if (options.projectName) topic = removeProjectName(topic, options.projectName)
358
+ topic = normalizeTopic(topic, policy.topicMaxChars)
359
+ if (!topic) return { kind: 'invalid', reason: 'invalid-topic-length' }
360
+ if ([...topic].length < 2 || isGenericTopic(topic))
361
+ return { kind: 'uncertain', reason: 'generic-topic' }
362
+ if (/[\r\n||{}]/u.test(parsed.topic)) return { kind: 'invalid', reason: 'invalid-topic-format' }
363
+ return {
364
+ kind: 'valid',
365
+ decision: {
366
+ type: parsed.type,
367
+ topic,
368
+ confidence: parsed.confidence,
369
+ ...(policy.iconMode === 'smart' ? { iconId: parsed.iconId } : {}),
370
+ },
371
+ }
372
+ }
373
+
374
+ export function parseModelDecision(raw, options: any = {}) {
375
+ const result = inspectModelDecision(raw, options)
376
+ return result.kind === 'valid' ? result.decision : null
377
+ }
378
+
379
+ export function normalizeTopic(text, maxChars = DEFAULTS.topicMaxChars) {
380
+ let topic = String(text ?? '')
381
+ .replace(CONTROL_PATTERN, '')
382
+ .replace(/^["'“”‘’]+|["'“”‘’]+$/g, '')
383
+ .replace(EDGE_PUNCTUATION, '')
384
+ .replace(/\s+/g, ' ')
385
+ .trim()
386
+ if (/\p{Script=Han}/u.test(topic)) topic = topic.replace(/\s+/g, '')
387
+ return [...topic].length <= maxChars ? topic : ''
388
+ }
389
+
390
+ export function sessionDateTimestamp(session, source = DEFAULTS.dateSource) {
391
+ const createdAt = session?.header?.createdAt
392
+ if (source === 'createdAt' || !session || typeof session.snapshotEvents !== 'function')
393
+ return createdAt
394
+ const events = session.snapshotEvents()
395
+ for (let i = events.length - 1; i >= 0; i--) {
396
+ const event = events[i]
397
+ if (!Number.isFinite(event?.time)) continue
398
+ if (source === 'lastActivity') return event.time
399
+ if (
400
+ source === 'lastUserMessage' &&
401
+ event.type === 'user/message' &&
402
+ event.data?.source?.kind === 'user'
403
+ )
404
+ return event.time
405
+ if (
406
+ source === 'lastCompletedTurn' &&
407
+ event.type === 'turn/end' &&
408
+ event.data?.reason?.kind === 'completed'
409
+ )
410
+ return event.time
411
+ }
412
+ return createdAt
413
+ }
414
+
415
+ export function projectNameOf(session) {
416
+ const cwd = session?.header?.cwd
417
+ if (typeof cwd !== 'string' || cwd.trim() === '') return ''
418
+ return (
419
+ cwd
420
+ .replace(/[\\/]+$/u, '')
421
+ .split(/[\\/]/u)
422
+ .pop() ?? ''
423
+ )
424
+ }
425
+
426
+ export function isSameTitle(a, b) {
427
+ const comparable = (value) =>
428
+ String(value ?? '')
429
+ .toLowerCase()
430
+ .replace(/[^\p{L}\p{N}]+/gu, '')
431
+ const left = comparable(a)
432
+ return left.length > 0 && left === comparable(b)
433
+ }
434
+
435
+ export function selectWithinBudget(messages, maxBytes) {
436
+ const selected = []
437
+ const bytes = (items) => Buffer.byteLength(JSON.stringify({ messages: items }), 'utf8')
438
+ // Keep the initiating task plus recent context; measure the actual JSON payload.
439
+ const ordered = messages.length > 1 ? [messages[0], ...messages.slice(1).reverse()] : messages
440
+ for (const message of ordered) {
441
+ if (!message || typeof message.text !== 'string') continue
442
+ const item = { seq: message.seq, text: message.text }
443
+ const candidate = [...selected, item].sort((a, b) => a.seq - b.seq)
444
+ if (bytes(candidate) <= maxBytes) {
445
+ selected.push(item)
446
+ continue
447
+ }
448
+ if (selected.length) continue
449
+ const chars = [...item.text]
450
+ let low = 0,
451
+ high = chars.length
452
+ while (low < high) {
453
+ const mid = Math.ceil((low + high) / 2)
454
+ if (bytes([{ ...item, text: chars.slice(0, mid).join('') }]) <= maxBytes) low = mid
455
+ else high = mid - 1
456
+ }
457
+ if (low) selected.push({ ...item, text: chars.slice(0, low).join('') })
458
+ }
459
+ return selected.sort((a, b) => a.seq - b.seq)
460
+ }
461
+
462
+ export function truncateUtf8(text, maxBytes) {
463
+ const value = String(text ?? '')
464
+ if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value
465
+ let out = ''
466
+ let bytes = 0
467
+ for (const char of value) {
468
+ const width = Buffer.byteLength(char, 'utf8')
469
+ if (bytes + width > maxBytes) break
470
+ out += char
471
+ bytes += width
472
+ }
473
+ return out
474
+ }
475
+
476
+ function cleanSourceText(text) {
477
+ return String(text ?? '')
478
+ .replace(/```[\s\S]*?```/g, ' ')
479
+ .replace(/`[^`]*`/g, ' ')
480
+ .replace(/https?:\/\/\S+/giu, ' ')
481
+ .replace(/^#{1,6}\s+/gm, '')
482
+ .replace(/^\s*[-*+]\s+/gm, '')
483
+ .replace(/^\s*>\s?/gm, '')
484
+ .replace(/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/g, '$1')
485
+ .replace(CONTROL_PATTERN, '')
486
+ .replace(/\s+/g, ' ')
487
+ .trim()
488
+ }
489
+
490
+ function removeProjectName(text, projectName) {
491
+ const name = String(projectName ?? '').trim()
492
+ if (name.length < 2) return text
493
+ return text
494
+ .replace(new RegExp(escapeRegExp(name), 'giu'), ' ')
495
+ .replace(/\s+/g, ' ')
496
+ .trim()
497
+ }
498
+
499
+ function isGenericTopic(topic) {
500
+ const normalized = topic.toLowerCase().replace(/[。.!!??\s]/g, '')
501
+ return GENERIC_TOPICS.has(topic.toLowerCase()) || GENERIC_TOPICS.has(normalized)
502
+ }
503
+
504
+ function escapeRegExp(text) {
505
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
506
+ }
507
+
508
+ function integerIn(value, min, max, fallback) {
509
+ const number = Number(value)
510
+ return Number.isSafeInteger(number) && number >= min && number <= max ? number : fallback
511
+ }
512
+
513
+ function numberIn(value, min, max, fallback) {
514
+ const number = Number(value)
515
+ return Number.isFinite(number) && number >= min && number <= max ? number : fallback
516
+ }