shraga 0.1.77 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.77",
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",
@@ -2,11 +2,12 @@
2
2
  // the Claude Code CLI maintains on this box. Fails CLOSED: every error path returns null, and the
3
3
  // caller renders nothing — a broken or zeroed gauge is worse than no gauge.
4
4
  import { execFile } from 'node:child_process';
5
- import { readdir, readFile } from 'node:fs/promises';
5
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
6
6
  import { homedir } from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { createRequire } from 'node:module';
9
9
  import { promisify } from 'node:util';
10
+ import { dataPath } from './paths.ts';
10
11
 
11
12
  const execFileAsync = promisify(execFile);
12
13
 
@@ -71,6 +72,10 @@ export class ClaudeUsageOptions {
71
72
  keychainService = 'Claude Code-credentials';
72
73
  /** A locked keychain can block (or prompt) indefinitely — never let that stall a client poll. */
73
74
  keychainTimeoutMs = 3_000;
75
+ /** Where the last known-good reading is mirrored, so a restart (deploy, self-upgrade) does not
76
+ * blank the gauge on a box whose upstream is rate-limited for the next hour. Identity + percentages
77
+ * only — never a token. */
78
+ cachePath = dataPath('claude-usage-last.json');
74
79
  /** Seam: hands back the raw secret string, or null on ANY failure. Tests inject here so the suite
75
80
  * never shells out to the real `security` binary. */
76
81
  readKeychain: (options: ClaudeUsageOptions) => Promise<string | null> = readKeychainSecret;
@@ -144,6 +149,8 @@ export class ClaudeUsageReader {
144
149
  * a widget that flickers in and out reads as a bug. We keep serving this, flagged `stale`, and the
145
150
  * client shows how old it is. Only a box that never had a good reading answers null. */
146
151
  private lastGood: { at: number; value: ClaudeUsage } | null = null;
152
+ /** One-shot rehydrate of `lastGood` from disk, awaited by the first get(). */
153
+ private restored: Promise<void> | null = null;
147
154
 
148
155
  public constructor(options?: Partial<ClaudeUsageOptions>) {
149
156
  this.options = { ...new ClaudeUsageOptions(), ...options };
@@ -151,6 +158,7 @@ export class ClaudeUsageReader {
151
158
 
152
159
  /** null => this box is not on a Claude subscription, or we could not prove that it ever was. */
153
160
  async get(): Promise<ClaudeUsage | null> {
161
+ await (this.restored ??= this.restore());
154
162
  const now = Date.now();
155
163
  if (now < this.cooldownUntil) return this.stale();
156
164
  if (this.cache && now - this.cache.at < this.options.ttlMs) return this.cache.value ?? this.stale();
@@ -162,13 +170,36 @@ export class ClaudeUsageReader {
162
170
  const at = Date.now();
163
171
  const stamped = value ? { ...value, fetchedAt: new Date(at).toISOString() } : null;
164
172
  this.cache = { at, value: stamped };
165
- if (stamped) this.lastGood = { at, value: stamped };
173
+ if (stamped) { this.lastGood = { at, value: stamped }; void this.persist(this.lastGood); }
166
174
  return stamped ?? this.stale();
167
175
  })
168
176
  .finally(() => { this.inflight = null; });
169
177
  return this.inflight;
170
178
  }
171
179
 
180
+ /** Rehydrate the last reading a previous process wrote. Never throws: a missing or corrupt file
181
+ * just means we start with nothing, exactly as before. */
182
+ private async restore(): Promise<void> {
183
+ try {
184
+ const saved = JSON.parse(await readFile(this.options.cachePath, 'utf8'));
185
+ if (Array.isArray(saved?.value?.limits) && saved.value.limits.length && typeof saved.at === 'number') {
186
+ this.lastGood = { at: saved.at, value: saved.value };
187
+ }
188
+ } catch (err) {
189
+ const code = (err as NodeJS.ErrnoException).code;
190
+ if (code !== 'ENOENT') console.debug(`${TAG} could not restore the last reading: ${(err as Error).message}`);
191
+ }
192
+ }
193
+
194
+ /** Mirror a fresh reading to disk. Best-effort — a write failure must never break the response. */
195
+ private async persist(entry: { at: number; value: ClaudeUsage }) {
196
+ try {
197
+ await writeFile(this.options.cachePath, JSON.stringify(entry));
198
+ } catch (err) {
199
+ console.debug(`${TAG} could not persist the last reading: ${(err as Error).message}`);
200
+ }
201
+ }
202
+
172
203
  /** Last known-good reading, marked stale. Never invents numbers — null when we never had any. */
173
204
  private stale(): ClaudeUsage | null {
174
205
  return this.lastGood ? { ...this.lastGood.value, stale: true } : null;
@@ -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);