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/LICENSE +21 -0
- package/README.md +145 -0
- package/cordis.patch.yml +9 -0
- package/lib/client.d.ts +1 -0
- package/lib/client.d.ts.map +1 -0
- package/lib/client.js +1247 -0
- package/lib/client.js.map +1 -0
- package/lib/core.d.ts +76 -0
- package/lib/core.d.ts.map +1 -0
- package/lib/core.js +464 -0
- package/lib/core.js.map +1 -0
- package/lib/icons.json +1326 -0
- package/lib/index.d.ts +79 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +364 -0
- package/lib/index.js.map +1 -0
- package/lib/refinement.d.ts +21 -0
- package/lib/refinement.d.ts.map +1 -0
- package/lib/refinement.js +329 -0
- package/lib/refinement.js.map +1 -0
- package/lib/scheduler.d.ts +17 -0
- package/lib/scheduler.d.ts.map +1 -0
- package/lib/scheduler.js +133 -0
- package/lib/scheduler.js.map +1 -0
- package/package.json +113 -0
- package/src/client.ts +1492 -0
- package/src/core.ts +516 -0
- package/src/icons.json +1326 -0
- package/src/index.ts +383 -0
- package/src/refinement.ts +356 -0
- package/src/scheduler.ts +125 -0
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
|
+
}
|