shraga 0.1.78 → 0.1.80

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.78",
3
+ "version": "0.1.80",
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",
@@ -208,7 +208,7 @@ export async function runSchedule(
208
208
  // Tell the run how to report its own truthful outcome (scheduler/outcome.ts). Prompt tasks only:
209
209
  // a `bash` task's permission handler allows nothing but the task's own command, so such a run
210
210
  // could not write the file even if it wanted to — its exit code is already the truth there.
211
- if (task.kind === 'prompt') {
211
+ if ((task.kind ?? 'prompt') === 'prompt') {
212
212
  // Cleared on RESUME too: a resume reuses the interrupted run's session id, so a declaration left
213
213
  // by the attempt that crashed would be adopted as this attempt's verdict. The contract is
214
214
  // re-stated for the same reason — the resumed turn must be able to declare for itself.
@@ -401,7 +401,7 @@ export async function runSchedule(
401
401
  // The run's OWN verdict beats "the turn returned" — see scheduler/outcome.ts. Deliberately after
402
402
  // the finally: the session lock is released by now, so a run that declared `pending` can be closed
403
403
  // by a later turn in this session (a background job's wake, a follow-up message) while we wait.
404
- if (status === 'ok' && task.kind === 'prompt') {
404
+ if (status === 'ok' && (task.kind ?? 'prompt') === 'prompt') {
405
405
  const declared = await resolveDeclaredOutcome(sessionId, abortController, schedule.id);
406
406
  if (declared) {
407
407
  status = declared.status;
@@ -27,11 +27,28 @@ export function saveThrottleState(state: Record<string, number>): void {
27
27
  renameSync(tmp, THROTTLE_FILE);
28
28
  }
29
29
 
30
+ /**
31
+ * Schedules persisted before `task.kind` existed carry only the payload fields. Everything
32
+ * downstream branches on the discriminant, so infer it from the shape once, on load — a task
33
+ * missing its kind silently skipped the outcome contract and reported every run as `ok`.
34
+ * Only `prompt` is inferable: `bash` and `job` are both `{ command }`, and guessing between them
35
+ * would hand a run the wrong permission handler, so a kindless command task is left alone.
36
+ */
37
+ function normalizeTask(task: Record<string, unknown>): void {
38
+ if (task.kind) return;
39
+ if (typeof task.prompt === 'string' || typeof task.promptFile === 'string') task.kind = 'prompt';
40
+ }
41
+
42
+ function normalizeSchedule(s: Schedule): Schedule {
43
+ if (s.task && typeof s.task === 'object') normalizeTask(s.task as unknown as Record<string, unknown>);
44
+ return s;
45
+ }
46
+
30
47
  export function loadSchedules(): Schedule[] {
31
48
  if (!existsSync(FILE)) return [];
32
49
  try {
33
50
  const parsed = JSON.parse(readFileSync(FILE, 'utf-8'));
34
- return Array.isArray(parsed) ? parsed : [];
51
+ return Array.isArray(parsed) ? parsed.map((s) => normalizeSchedule(s as Schedule)) : [];
35
52
  } catch (err) {
36
53
  console.error('[scheduler] failed to parse schedules.json:', err);
37
54
  return [];
@@ -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.includes(t.toLowerCase()));
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);