@@ -191,6 +191,38 @@ function listWorkspaceSkillCandidates(options = {}) {
191191 return out
192192}
193193
194+ /**
195+ * Short / ambiguous skill ids (e.g. test) must not fire from long task sentences (C1.1).
196+ * Allow: exact, short alone prompt, quoted trigger alone, or explicit "use skill X".
197+ */
198+ function isAmbiguousShortToken ( token ) {
199+ const t = String ( token || '' ) . trim ( )
200+ if ( ! t ) return false
201+ if ( t . length <= 4 ) return true
202+ return / ^ ( t e s t s ? | d e m o | t m p | t e m p | o k | h i | p i n g | f o o | b a r | b a z | s a m p l e ) $ / i. test ( t )
203+ }
204+
205+ function isShortAlonePrompt ( cleaned , id , name ) {
206+ const text = String ( cleaned || '' ) . trim ( )
207+ if ( ! text ) return false
208+ const tokens = text . split ( / \s + / ) . filter ( Boolean )
209+ const maxLen = Math . max ( String ( name || '' ) . length , String ( id || '' ) . length , 4 )
210+ // 1–2 tokens and not a long sentence; e.g. "test", "测试", "@rocky test" → after normalize "test"
211+ if ( tokens . length <= 2 && text . length <= maxLen + 16 ) return true
212+ // single CJK trigger phrase
213+ if ( tokens . length === 1 && / [ \u4e00 - \u9fff ] / . test ( text ) && text . length <= 8 ) return true
214+ return false
215+ }
216+
217+ function hasExplicitSkillInvoke ( raw , id , name ) {
218+ const text = String ( raw || '' )
219+ // "用 test skill" / "用 skill test" / "workspace skill test" / "加载 skill X"
220+ const invokeShape = / (?: 用 | 加 载 | 执 行 ) \s + (?: w o r k s p a c e \s + ) ? s k i l l \b | (?: 用 | 加 载 | 执 行 ) \s + \S + \s + s k i l l \b | w o r k s p a c e \s * s k i l l | s k i l l \s * [: = ] / i. test ( text )
221+ if ( ! invokeShape ) return false
222+ const cleaned = normalizePrompt ( text )
223+ return wordBoundaryHit ( cleaned , id ) || wordBoundaryHit ( cleaned , name )
224+ }
225+
194226/**
195227 * Score a single candidate against user prompt.
196228 * @returns {{ score: number, reasons: string[] } }
@@ -204,37 +236,59 @@ function scoreCandidate(prompt, candidate) {
204236
205237 const id = candidate . skillId
206238 const name = candidate . name || id
207- const alone = cleaned . length <= Math . max ( name . length , id . length ) + 12
239+ const alone = isShortAlonePrompt ( cleaned , id , name )
240+ const idAmbiguous = isAmbiguousShortToken ( id )
241+ const nameAmbiguous = isAmbiguousShortToken ( name )
208242
209243 if ( cleaned . toLowerCase ( ) === id . toLowerCase ( ) || cleaned === name ) {
210- score += 100
244+ score = Math . max ( score , 100 )
211245 reasons . push ( 'exact-id-or-name' )
212246 } else if ( wordBoundaryHit ( cleaned , id ) ) {
213- score += alone ? 90 : 70
214- reasons . push ( 'id-token' )
247+ // C1.1: short id in long sentences does not auto-match
248+ if ( alone || ! idAmbiguous ) {
249+ score = Math . max ( score , alone ? 90 : ( idAmbiguous ? 0 : 55 ) )
250+ if ( score >= MIN_MATCH_SCORE ) reasons . push ( alone ? 'id-token-alone' : 'id-token' )
251+ else if ( idAmbiguous && ! alone ) reasons . push ( 'id-token-suppressed-long-prompt' )
252+ } else {
253+ reasons . push ( 'id-token-suppressed-long-prompt' )
254+ }
215255 } else if ( wordBoundaryHit ( cleaned , name ) && name . toLowerCase ( ) !== id . toLowerCase ( ) ) {
216- score += alone ? 85 : 65
217- reasons . push ( 'name-token' )
256+ if ( alone || ! nameAmbiguous ) {
257+ score = Math . max ( score , alone ? 85 : ( nameAmbiguous ? 0 : 50 ) )
258+ if ( score >= MIN_MATCH_SCORE ) reasons . push ( alone ? 'name-token-alone' : 'name-token' )
259+ else if ( nameAmbiguous && ! alone ) reasons . push ( 'name-token-suppressed-long-prompt' )
260+ } else {
261+ reasons . push ( 'name-token-suppressed-long-prompt' )
262+ }
218263 }
219264
220265 for ( const t of candidate . triggers || [ ] ) {
266+ const tAmbiguous = isAmbiguousShortToken ( t ) || t . length <= 4
221267 if ( cleaned === t || cleaned . toLowerCase ( ) === t . toLowerCase ( ) ) {
222268 score = Math . max ( score , 95 )
223269 reasons . push ( `trigger-exact:${ t } ` )
224270 } else if ( wordBoundaryHit ( cleaned , t ) ) {
225- score = Math . max ( score , alone ? 88 : 60 )
226- reasons . push ( `trigger-token:${ t } ` )
271+ if ( alone || ! tAmbiguous ) {
272+ const next = alone ? 88 : ( tAmbiguous ? 0 : 50 )
273+ if ( next >= MIN_MATCH_SCORE ) {
274+ score = Math . max ( score , next )
275+ reasons . push ( alone ? `trigger-alone:${ t } ` : `trigger-token:${ t } ` )
276+ } else if ( tAmbiguous && ! alone ) {
277+ reasons . push ( `trigger-suppressed-long-prompt:${ t } ` )
278+ }
279+ } else {
280+ reasons . push ( `trigger-suppressed-long-prompt:${ t } ` )
281+ }
227282 }
228283 }
229284
230- // Explicit invoke
231- if ( / 用 \s * ( w o r k s p a c e \s * ) ? s k i l l | w o r k s p a c e \s * s k i l l | 加 载 \s * s k i l l | 执 行 \s * s k i l l / i. test ( raw ) &&
232- ( wordBoundaryHit ( cleaned , id ) || wordBoundaryHit ( cleaned , name ) ) ) {
285+ // Explicit invoke always allowed when skill id/name present
286+ if ( hasExplicitSkillInvoke ( raw , id , name ) ) {
233287 score = Math . max ( score , 92 )
234288 reasons . push ( 'explicit-invoke' )
235289 }
236290
237- // Description keyword soft match only when prompt is short (avoid false positives)
291+ // Description keyword soft match only when prompt is short alone (avoid false positives)
238292 if ( score < MIN_MATCH_SCORE && alone && cleaned . length >= 2 && cleaned . length <= 24 ) {
239293 const desc = String ( candidate . description || '' ) . toLowerCase ( )
240294 if ( desc && desc . includes ( cleaned . toLowerCase ( ) ) && cleaned . length >= 2 ) {
@@ -436,5 +490,8 @@ module.exports = {
436490 toStateRecord,
437491 looksLikeConnectivityPing,
438492 normalizePrompt,
439- extractLastAssistantMessage
493+ extractLastAssistantMessage,
494+ isAmbiguousShortToken,
495+ isShortAlonePrompt,
496+ hasExplicitSkillInvoke
440497}
0 commit comments