shraga 0.1.78 → 0.1.79
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/package.json +1 -1
- package/src/server/skills.ts +78 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.79",
|
|
4
4
|
"description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/server/skills.ts
CHANGED
|
@@ -323,6 +323,83 @@ export function buildSkillIndexBlock(): string {
|
|
|
323
323
|
return `<available-skills>\nSkills available in data/skills/. Use Read to load full skill content when needed.\n${lines.join('\n')}\n</available-skills>`;
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
/* ── Trigger matching ─────────────────────────────────────────────────────────
|
|
327
|
+
* Triggers are authored as natural phrases ("make a video ad"), but real briefs
|
|
328
|
+
* insert words into them ("make a NEW video ad variant"). A plain substring test
|
|
329
|
+
* missed those, and a missed trigger silently costs more than the skill text —
|
|
330
|
+
* it also drops the skill's `turns` budget (see resolveSkillTurns).
|
|
331
|
+
*
|
|
332
|
+
* So: match on WORD TOKENS, not raw characters.
|
|
333
|
+
* - Short triggers (< MIN_TOKENS_FOR_GAPS) must still match as a contiguous run.
|
|
334
|
+
* This is strictly NARROWER than `includes` — "cost per" no longer fires on
|
|
335
|
+
* "cost performance", "ad" no longer fires on "adding".
|
|
336
|
+
* - Longer triggers (3+ tokens) tolerate a few inserted words, bounded hard so
|
|
337
|
+
* the phrase cannot smear across a whole message.
|
|
338
|
+
* A trailing plural on either side is tolerated at every position ("video ads").
|
|
339
|
+
*/
|
|
340
|
+
|
|
341
|
+
/** Words, keeping decimal numbers whole so "wan 2.2" is [wan, 2.2] and not [wan, 2, 2]. */
|
|
342
|
+
const TRIGGER_TOKEN_RE = /[a-z0-9]+(?:\.[0-9]+)*/g;
|
|
343
|
+
/** Triggers with fewer tokens than this must match contiguously — too short to be safely loosened. */
|
|
344
|
+
const MIN_TOKENS_FOR_GAPS = 3;
|
|
345
|
+
/** Words that may be inserted across the WHOLE trigger phrase. This one bound is what stops a
|
|
346
|
+
* phrase smearing over a long message: "make a video ad" reaches "make a NEW video ad" and
|
|
347
|
+
* "make me a new video ad", but not "make something. later, a video. then an ad.". */
|
|
348
|
+
const MAX_INSERTED = 3;
|
|
349
|
+
/** Articles the trigger's AUTHOR typed that the asker may not ("make SOME video ads"). Skipping
|
|
350
|
+
* one costs an insertion, so it is not free. */
|
|
351
|
+
const TRIGGER_FILLER = new Set(['a', 'an', 'the']);
|
|
352
|
+
|
|
353
|
+
function tokenizeTrigger(s: string): string[] {
|
|
354
|
+
return s.toLowerCase().match(TRIGGER_TOKEN_RE) ?? [];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Token equality with a tolerated trailing plural on either side ("ad" ~ "ads", "match" ~ "matches"). */
|
|
358
|
+
function tokenEq(a: string, b: string): boolean {
|
|
359
|
+
if (a === b) return true;
|
|
360
|
+
const [long, short] = a.length > b.length ? [a, b] : [b, a];
|
|
361
|
+
return long === `${short}s` || long === `${short}es`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function matchTokens(hay: string[], needle: string[]): boolean {
|
|
365
|
+
if (!needle.length || needle.length > hay.length) return false;
|
|
366
|
+
for (let start = 0; start + needle.length <= hay.length; start++) {
|
|
367
|
+
if (!tokenEq(hay[start], needle[0])) continue;
|
|
368
|
+
if (needle.length < MIN_TOKENS_FOR_GAPS) {
|
|
369
|
+
// Too short to loosen. Contiguous whole words only — strictly NARROWER than `includes`,
|
|
370
|
+
// which fired "ad set" on "ad settings" and "cost per" on "cost performance".
|
|
371
|
+
if (needle.every((t, k) => tokenEq(hay[start + k], t))) return true;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
// Leftmost-greedy subsequence, then bound the SPAN it consumed.
|
|
375
|
+
let i = start + 1, n = 1, inserted = 0;
|
|
376
|
+
while (n < needle.length && i < hay.length && inserted <= MAX_INSERTED) {
|
|
377
|
+
if (tokenEq(hay[i], needle[n])) { n++; i++; continue; }
|
|
378
|
+
i++; inserted++;
|
|
379
|
+
}
|
|
380
|
+
if (n === needle.length && inserted <= MAX_INSERTED) return true;
|
|
381
|
+
}
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* True when `trigger` occurs in `text` as an in-order run of whole words spanning at most
|
|
387
|
+
* MAX_INSERTED extra words. Exported for tests — a trigger layer is easy to widen by accident.
|
|
388
|
+
*
|
|
389
|
+
* Two passes rather than one clever one: the trigger as authored, then the trigger with its
|
|
390
|
+
* articles dropped ("make a video ad" -> make/video/ad, so "make SOME video ads" lands). A single
|
|
391
|
+
* pass that skipped articles inline had to guess, greedily and wrongly, whether the article ahead
|
|
392
|
+
* in the message was the one the trigger meant.
|
|
393
|
+
*/
|
|
394
|
+
export function triggerMatches(text: string, trigger: string): boolean {
|
|
395
|
+
const hay = tokenizeTrigger(text);
|
|
396
|
+
const needle = tokenizeTrigger(trigger);
|
|
397
|
+
if (matchTokens(hay, needle)) return true;
|
|
398
|
+
const stripped = needle.filter(t => !TRIGGER_FILLER.has(t));
|
|
399
|
+
return stripped.length !== needle.length && stripped.length >= MIN_TOKENS_FOR_GAPS
|
|
400
|
+
&& matchTokens(hay, stripped);
|
|
401
|
+
}
|
|
402
|
+
|
|
326
403
|
/**
|
|
327
404
|
* Match message text against skill triggers. Returns matched skill names.
|
|
328
405
|
* Skips skills already in the defaults list (they're already injected).
|
|
@@ -343,7 +420,7 @@ export function matchTriggeredSkillNames(message: string, context?: Record<strin
|
|
|
343
420
|
if (isExpired(meta)) continue;
|
|
344
421
|
if (!meta.triggers?.length) continue;
|
|
345
422
|
if (meta.origin === 'auto' && meta.reviewed === false) continue;
|
|
346
|
-
const hit = meta.triggers.some(t => lower
|
|
423
|
+
const hit = meta.triggers.some(t => triggerMatches(lower, t));
|
|
347
424
|
if (hit) {
|
|
348
425
|
console.log(`[skills] Trigger matched: ${name}${ctxPrefix ? ` (context: ${ctxPrefix})` : ''}`);
|
|
349
426
|
matched.push(name);
|