@apinggithub/mydsh-auto-model-router 0.0.0-stage → 0.2.1

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/rules.mjs ADDED
@@ -0,0 +1,59 @@
1
+ // Rule layer (tier 1): zero-cost deterministic routing.
2
+ // Rules are tried in order; the first rule whose pattern matches the
3
+ // classification text wins and returns a LEVEL id (L0…L3), which the router
4
+ // maps to the user-configured model for that level.
5
+
6
+ /**
7
+ * Match the classification text against every rule.
8
+ * @param {object[]} rules - normalized rules from resolveConfig.
9
+ * @param {string} text - the text to classify against.
10
+ * @returns {string | null} the first matching rule's level, or null.
11
+ */
12
+ export function matchRules(rules, text) {
13
+ if (!rules || rules.length === 0) return null
14
+ const haystack = String(text ?? '')
15
+ if (haystack.trim() === '') return null
16
+ for (const rule of rules) {
17
+ if (rule.regex) {
18
+ rule.regex.lastIndex = 0
19
+ if (rule.regex.test(haystack)) return rule.level
20
+ } else if (haystack.toLowerCase().includes(rule.match.toLowerCase())) {
21
+ return rule.level
22
+ }
23
+ }
24
+ return null
25
+ }
26
+
27
+ /**
28
+ * Build the text the router classifies on for one step.
29
+ * Concatenates the latest user message content plus the session's cwd so
30
+ * rules can match paths ("src/api", "migrations").
31
+ * @param {object} options - { messages, cwd }.
32
+ * @returns {string} classification haystack.
33
+ */
34
+ export function classificationText({ messages = [], cwd = '' } = {}) {
35
+ const parts = []
36
+ for (let i = messages.length - 1; i >= 0 && parts.length < 3; i--) {
37
+ const message = messages[i]
38
+ if (message?.role !== 'user') continue
39
+ const text = extractText(message)
40
+ if (text) {
41
+ parts.unshift(text)
42
+ break
43
+ }
44
+ }
45
+ if (cwd) parts.push(` cwd:${cwd}`)
46
+ return parts.join('\n')
47
+ }
48
+
49
+ function extractText(message) {
50
+ const content = message?.content
51
+ if (typeof content === 'string') return content
52
+ if (Array.isArray(content)) {
53
+ return content
54
+ .map((block) => (typeof block?.text === 'string' ? block.text : ''))
55
+ .join('\n')
56
+ .trim()
57
+ }
58
+ return ''
59
+ }
package/scoring.mjs ADDED
@@ -0,0 +1,69 @@
1
+ // Keyword scoring for fully-auto mode (MODE C).
2
+ //
3
+ // Each rule carries a level; every keyword HIT in the window text (本次输入 +
4
+ // 上次输入 + 上次回复) adds the level's weight once per occurrence (counted
5
+ // per match, so a message mentioning "refactor" three times scores 3×).
6
+ // The accumulated score maps to a level through the configured bands.
7
+ // A score of 0 means no rule hit → the router falls through to the LLM
8
+ // classifier.
9
+
10
+ /**
11
+ * Score one window text against the rules and return the level band.
12
+ * @param {object[]} rules - [{ match, regex, level }]
13
+ * @param {object} scoring - { weights, bands }
14
+ * @param {string} text - the scoring window text.
15
+ * @returns {{ level: string|null, score: number, hits: number }}
16
+ * level null when the score resolves to the 0 band (caller → classifier).
17
+ */
18
+ export function scoreRules(rules, scoring, text) {
19
+ const haystack = String(text ?? '')
20
+ let score = 0
21
+ let hits = 0
22
+ if (haystack.trim() !== '') {
23
+ for (const rule of rules) {
24
+ const weight = scoring.weights[rule.level] ?? 1
25
+ let count = 0
26
+ if (rule.regex) {
27
+ rule.regex.lastIndex = 0
28
+ const copy = new RegExp(rule.regex.source, rule.regex.flags.includes('g') ? rule.regex.flags : `${rule.regex.flags}g`)
29
+ let m
30
+ while ((m = copy.exec(haystack)) !== null) {
31
+ count++
32
+ if (m[0] === '') copy.lastIndex++
33
+ }
34
+ } else {
35
+ // Count non-overlapping substring occurrences case-insensitively.
36
+ const needle = rule.match.toLowerCase()
37
+ const lower = haystack.toLowerCase()
38
+ let idx = lower.indexOf(needle)
39
+ while (idx !== -1) {
40
+ count++
41
+ idx = lower.indexOf(needle, idx + needle.length)
42
+ }
43
+ }
44
+ if (count > 0) {
45
+ hits += count
46
+ score += count * weight
47
+ }
48
+ }
49
+ }
50
+ const level = bandLevel(scoring.bands, score)
51
+ return { level, score, hits }
52
+ }
53
+
54
+ /**
55
+ * Map a score to a level through the bands (first band whose maxScore
56
+ * contains the score wins; null maxScore is a catch-all).
57
+ * A score of 0 resolves to null — the router falls through to the LLM
58
+ * classifier instead of pinning to the lowest level directly.
59
+ * @param {object[]} bands - [{ maxScore, level }]
60
+ * @param {number} score
61
+ * @returns {string | null} level id, or null when score is 0.
62
+ */
63
+ export function bandLevel(bands, score) {
64
+ if (score <= 0) return null
65
+ for (const band of bands) {
66
+ if (band.maxScore === null || score <= band.maxScore) return band.level
67
+ }
68
+ return bands[bands.length - 1]?.level ?? null
69
+ }
package/text-width.mjs ADDED
@@ -0,0 +1,73 @@
1
+ // Text-width counting for the heuristic lock.
2
+ //
3
+ // counting modes:
4
+ // cjk2 — CJK codepoints (>= U+3000) count 2, everything else 1.
5
+ // A Chinese char is ~1.5 tokens vs an ASCII char ~0.25 tokens;
6
+ // doubling the CJK width keeps "10 chars" intuitive for Chinese.
7
+ // tokens — token estimation: CJK at 1.5/char, other at 0.25/char (chars/4).
8
+
9
+ const CJK_MIN = 0x3000
10
+
11
+ /**
12
+ * Width of one string under the configured counting mode.
13
+ * @param {string} text
14
+ * @param {'cjk2'|'tokens'} [mode]
15
+ * @returns {number}
16
+ */
17
+ export function textWidth(text, mode = 'cjk2') {
18
+ const s = String(text ?? '')
19
+ let cjk = 0
20
+ for (let i = 0; i < s.length; i++) {
21
+ if (s.charCodeAt(i) >= CJK_MIN) cjk++
22
+ }
23
+ const other = s.length - cjk
24
+ if (mode === 'tokens') {
25
+ return Math.ceil(cjk * 1.5 + other / 4)
26
+ }
27
+ return cjk * 2 + other
28
+ }
29
+
30
+ /**
31
+ * Sum the widths of the last `windowSize` texts in order.
32
+ * @param {string[]} texts - newest-last.
33
+ * @param {number} windowSize
34
+ * @param {'cjk2'|'tokens'} [mode]
35
+ * @returns {number}
36
+ */
37
+ export function windowWidth(texts, windowSize, mode = 'cjk2') {
38
+ const tail = texts.slice(-windowSize)
39
+ return tail.reduce((sum, text) => sum + textWidth(text, mode), 0)
40
+ }
41
+
42
+ /**
43
+ * Resolve a level from the heuristic thresholds for a width.
44
+ * Thresholds are ordered ascending; the FIRST threshold with
45
+ * `width <= maxChars` wins. A threshold with `maxChars: null` is a catch-all.
46
+ * Widths above every finite threshold resolve to the LAST threshold's level
47
+ * (the top of the lock); callers that need to distinguish "above the lock"
48
+ * check the top finite threshold themselves.
49
+ * @param {object[]} thresholds - [{ maxChars, level }]
50
+ * @param {number} width
51
+ * @returns {string} LEVEL_IDS entry
52
+ */
53
+ export function levelForWidth(thresholds, width) {
54
+ for (const t of thresholds) {
55
+ if (t.maxChars === null || width <= t.maxChars) return t.level
56
+ }
57
+ return thresholds[thresholds.length - 1]?.level ?? 'L1'
58
+ }
59
+
60
+ /**
61
+ * The top finite threshold's maxChars, or null when every threshold is
62
+ * unbounded. Used by the router to detect "above the heuristic lock".
63
+ * @param {object[]} thresholds
64
+ * @returns {number | null}
65
+ */
66
+ export function lockCeiling(thresholds) {
67
+ for (let i = thresholds.length - 1; i >= 0; i--) {
68
+ if (thresholds[i]?.maxChars !== null && thresholds[i]?.maxChars !== undefined) {
69
+ return thresholds[i].maxChars
70
+ }
71
+ }
72
+ return null
73
+ }