@chatpanel/events 0.3.0 → 0.4.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/router.js +52 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "The canonical ChatPanel event-log and capability contracts — typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/router.js CHANGED
@@ -144,6 +144,12 @@ const TIE_BAND = 0.10;
144
144
  // worth a few seconds, which is the same reasoning the provider order already encodes.
145
145
  const SECONDS_PER_UNIT_COST = 5;
146
146
 
147
+ // How much closer-to-home is worth in the balanced score. Lower is better, so a model on the
148
+ // user's own machine gets a discount and a third party pays full price. Deliberately gentle:
149
+ // a 30% edge decides between comparable candidates and loses to any real difference in speed,
150
+ // cost or quality — privacy that ELIMINATES is the reach ceiling, and it lives in route().
151
+ const REACH_WEIGHT = Object.freeze({ device: 0.7, trusted: 0.85, any: 1 });
152
+
147
153
  /**
148
154
  * The model name stripped of provider prefix and tag, so the SAME model matches across hosts:
149
155
  * `deepseek-ai/DeepSeek-V4-Flash` and `deepseek/deepseek-v4-flash` are one model reached two
@@ -182,8 +188,21 @@ export function signalsFrom(request = {}) {
182
188
  // short. The keyword heuristics are not, so those need enough surrounding text to be
183
189
  // describing code rather than mentioning it: prose can say "import" or end a line with a
184
190
  // semicolon without being a programming task.
191
+ // ASKING FOR CODE IS A CODING REQUEST, not only pasting some.
192
+ //
193
+ // This read the MATERIAL and nothing else, so "write a function that debounces this" —
194
+ // no fence, too short for the token heuristic — required no coding capability at all.
195
+ // That made the per-model Coding checkbox unreachable for most real coding requests:
196
+ // turning it off for one agent changed nothing, because nothing ever asked for it, and
197
+ // the agent kept being chosen. A lever that only moves on pasted code is a lever the
198
+ // user cannot see working.
199
+ //
200
+ // Deliberately a VERB NEXT TO A CODE NOUN rather than either alone: 'test' and 'api'
201
+ // appear in ordinary prose constantly, and a signal that fires on prose would put a
202
+ // quality floor on every message.
185
203
  code: /```/.test(text)
186
- || (chars > 80 && /\bfunction\b|\bclass\b|=>|;\s*$|\bdef\b|\bimport\b|\bconst\b/m.test(text)),
204
+ || (chars > 80 && /\bfunction\b|\bclass\b|=>|;\s*$|\bdef\b|\bimport\b|\bconst\b/m.test(text))
205
+ || /\b(write|fix|debug|refactor|implement|optimi[sz]e|review|explain|generate|add|update|port|migrate)\b[^.?!]{0,60}\b(code|function|method|class|script|bug|regex|query|unit ?test|module|component|endpoint|snippet|compiler?|stack ?trace|typescript|javascript|python|rust|golang|sql|css|html)\b/i.test(text),
187
206
  complexity: (chars > 4000 || /```/.test(text)
188
207
  || (chars >= 200 && /\bstep by step\b|\bplan\b|\brefactor\b|\bmigrate\b|\banalyse|\banalyze/i.test(text)))
189
208
  ? 'high'
@@ -203,8 +222,20 @@ export function signalsFrom(request = {}) {
203
222
  // bare, they demanded a word boundary immediately after the prefix, so "summarize this
204
223
  // document" matched nothing and was classified as SMALL TALK. A prefix that can never
205
224
  // fire is worse than an absent one — it reads as covered.
225
+ // ASKING A QUESTION ABOUT SOMETHING IS WORK. The list below was a list of things you DO,
226
+ // so a request to UNDERSTAND — "can you explain what this does", "why is this failing",
227
+ // "how does routing pick a model" — matched nothing and came back as small talk. That is
228
+ // not a cosmetic misfile: small talk is what makes preferenceFor ask for LATENCY, and the
229
+ // latency axis reads nothing but milliseconds, so a genuine question was routed for speed
230
+ // and went past a free local model to a third party. The verbs of explanation and of
231
+ // changing code belong here for the same reason 'summarise' does.
206
232
  smalltalk: chars < 100 && !/```/.test(text)
207
- && !/\b(draw|click|open|fill|read|find|search|edit|write|create|update|delete|run|fix|change|add|remove|select|scroll|extract|summar\w*|analy\w*|check|review|list|show|go to|navigate|my|mine|this page|here|it|that)\b/i.test(text),
233
+ && !/\b(draw|click|open|fill|read|find|search|edit|write|create|update|delete|run|fix|change|add|remove|select|scroll|extract|summar\w*|analy\w*|check|review|list|show|go to|navigate|my|mine|this page|here|it|that)\b/i.test(text)
234
+ && !/\b(explain|describe|define|compare|translate|why|debug|refactor|implement|improve|optimi[sz]e|convert|calculate|generate|draft|rename|build|test|install|deploy|design|plan)\b/i.test(text)
235
+ // 'how' only when it opens a question about something — bare "how are you" is the
236
+ // pleasantry this whole test exists to catch, and putting it in the word list would
237
+ // have reclassified the one case everybody agrees on.
238
+ && !/\bhow\s+(do|does|did|can|could|should|would|to|much|many|long)\b/i.test(text),
208
239
  chars,
209
240
  };
210
241
  }
@@ -592,10 +623,26 @@ export function createModelRouter({ models = [], middleware = [], strategies = [
592
623
  // zero — burying every model we have not benchmarked would make the router
593
624
  // permanently prefer whatever it happened to measure first.
594
625
  const q = Number.isFinite(m.quality) ? Math.max(0.1, m.quality) : 0.5;
595
- if (prefer === 'latency') return m.latencyMs * busy;
596
- if (prefer === 'cost') return m.costPer1k * busy;
626
+ // NEARER IS WORTH SOMETHING, and the trade-making axes are where it can be said.
627
+ //
628
+ // The provider order already claims it — "the user's own machine: no quota, no
629
+ // outage, no third party" is its first rung — but providerRank only arranges a
630
+ // near-tie, and a local model and a hosted one are almost never within the tie band.
631
+ // So every preference weighed time and money and ignored where the request goes, and
632
+ // a free model on the user's own machine lost to a third party over a latency figure
633
+ // that is itself a guess.
634
+ //
635
+ // A multiplier, not a term, and a small one: it shifts a close call and cannot rescue
636
+ // a model that is genuinely slower AND worse. Applied to 'latency', 'cost' and
637
+ // 'balanced' — all of which trade one number against another — and NOT to 'quality',
638
+ // because a nearer model is not a better one and saying so would invert that axis the
639
+ // way dividing by quality once inverted these. Reach remains a ceiling in route():
640
+ // this only orders survivors, it never admits a candidate the ceiling excluded.
641
+ const near = REACH_WEIGHT[m.reach] ?? 1;
642
+ if (prefer === 'latency') return m.latencyMs * busy * near;
643
+ if (prefer === 'cost') return m.costPer1k * busy * near;
597
644
  if (prefer === 'quality') return busy / q;
598
- return ((m.latencyMs / 1000) + m.costPer1k * SECONDS_PER_UNIT_COST) * busy / q;
645
+ return ((m.latencyMs / 1000) + m.costPer1k * SECONDS_PER_UNIT_COST) * busy * near / q;
599
646
  };
600
647
  // ORDER IS A SETTING, NOT A TIE-BREAK THAT NEVER FIRES.
601
648
  //