spexcode 0.2.0 → 0.2.2

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 (48) hide show
  1. package/README.md +149 -102
  2. package/README.zh-CN.md +170 -0
  3. package/package.json +1 -1
  4. package/spec-cli/bin/spex.mjs +24 -1
  5. package/spec-cli/src/attach.ts +50 -0
  6. package/spec-cli/src/cli.ts +227 -66
  7. package/spec-cli/src/client.ts +47 -9
  8. package/spec-cli/src/{self.ts → doctor.ts} +26 -25
  9. package/spec-cli/src/gateway.ts +15 -11
  10. package/spec-cli/src/guide.ts +73 -17
  11. package/spec-cli/src/harness.ts +48 -19
  12. package/spec-cli/src/help.ts +141 -51
  13. package/spec-cli/src/index.ts +41 -14
  14. package/spec-cli/src/issues.ts +109 -31
  15. package/spec-cli/src/layout.ts +4 -4
  16. package/spec-cli/src/localIssues.ts +59 -58
  17. package/spec-cli/src/materialize.ts +4 -2
  18. package/spec-cli/src/mentions.ts +22 -1
  19. package/spec-cli/src/pty-bridge.ts +39 -4
  20. package/spec-cli/src/ranker.ts +31 -12
  21. package/spec-cli/src/search.bench.mjs +30 -7
  22. package/spec-cli/src/search.ts +39 -0
  23. package/spec-cli/src/sessions.ts +149 -62
  24. package/spec-cli/src/specs.ts +16 -4
  25. package/spec-cli/src/supervise.ts +30 -6
  26. package/spec-cli/src/tree.ts +118 -0
  27. package/spec-cli/templates/hooks/post-merge +2 -2
  28. package/spec-cli/templates/hooks/pre-commit +34 -15
  29. package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
  30. package/spec-dashboard/dist/assets/Dashboard-BwZ2KzxB.js +27 -0
  31. package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
  32. package/spec-dashboard/dist/assets/EvalsPage-DV75EdP4.js +3 -0
  33. package/spec-dashboard/dist/assets/FoldToggle-GwE0-k1d.js +1 -0
  34. package/spec-dashboard/dist/assets/IssuesPage-B17pnl9I.js +1 -0
  35. package/spec-dashboard/dist/assets/MobileApp-WEZbR8M1.js +1 -0
  36. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
  37. package/spec-dashboard/dist/assets/SessionInterface-Sh8kHpnj.js +71 -0
  38. package/spec-dashboard/dist/assets/SessionWindow-EzFq-hLG.js +9 -0
  39. package/spec-dashboard/dist/assets/Settings-Dgtg-Xb9.js +1 -0
  40. package/spec-dashboard/dist/assets/index-CCmnCbKS.css +1 -0
  41. package/spec-dashboard/dist/assets/index-Dd0_U5rk.js +41 -0
  42. package/spec-dashboard/dist/index.html +2 -2
  43. package/spec-forge/src/cli.ts +4 -10
  44. package/spec-forge/src/drivers.ts +13 -0
  45. package/spec-yatsu/src/cli.ts +89 -15
  46. package/spec-yatsu/src/scenariofresh.ts +100 -30
  47. package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +0 -145
  48. package/spec-dashboard/dist/assets/index-BTU-44Os.css +0 -32
@@ -17,6 +17,11 @@ type Pending = (lines: Buffer[]) => void
17
17
  type Bridge = {
18
18
  id: string; pty: IPty; cols: number; rows: number; prewarmed: boolean
19
19
  repaintToken: number
20
+ // the size VOTE: whether this client currently asserts window size. Only a bridge some viewer has SIZED
21
+ // (visible connect / resize — never a hidden board-load connect) votes; all others carry tmux's
22
+ // ignore-size client flag and are size-NEUTRAL, so a foreign backend instance sharing the socket can
23
+ // never move a window a human is watching (see setVote).
24
+ voting: boolean
20
25
  // control-mode parser state: an incomplete-line BYTE buffer, the in-flight command block (%begin..%end) with
21
26
  // its command number, a FIFO of one resolver per command sent (tmux answers in order), and the last
22
27
  // %layout-change size so a repaint knows the pane already converged and needn't wait for the event.
@@ -269,10 +274,15 @@ function ensureBridge(id: string, prewarm = false): Bridge | null {
269
274
  env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' } as Record<string, string>,
270
275
  })
271
276
  } catch { return null }
272
- b = { id, pty: p, cols, rows, prewarmed: prewarm, repaintToken: 0, buf: Buffer.alloc(0), block: null, blockNum: '', cmdQ: [], needsFull: true }
277
+ b = { id, pty: p, cols, rows, prewarmed: prewarm, repaintToken: 0, voting: false, buf: Buffer.alloc(0), block: null, blockNum: '', cmdQ: [], needsFull: true }
273
278
  bridges.set(id, b)
274
279
  const bx = b
275
280
  p.onData((d) => feed(bx, d as unknown as Buffer)) // encoding:null → d is a Buffer (typings say string)
281
+ // every client starts size-NEUTRAL: flag it before any refresh-client -C can enter the FIFO (a bare
282
+ // attach asserts nothing — measured: only -C moves a window — so there is no pre-flag race). Sent as a
283
+ // stream command, not an attach-time `-f`, so a pre-3.2 tmux degrades to a harmless in-stream %error
284
+ // (old size-fight behaviour) instead of a client that cannot attach at all.
285
+ void command(b, 'refresh-client -f ignore-size')
276
286
  // attach-session exited (session died or we detached): drop the bridge, unblock any awaiting command AND any
277
287
  // %layout-change waiter (no timer backs it now, so a bridge that dies mid-convergence MUST resolve its
278
288
  // waiter or the awaiting repaint hangs), and if viewers remain kick a reconcile to re-bind fast.
@@ -285,6 +295,19 @@ function ensureBridge(id: string, prewarm = false): Bridge | null {
285
295
  return b
286
296
  }
287
297
 
298
+ // flip this client's size vote — the arbitration that makes ANY number of backend instances share one tmux
299
+ // socket without size-fights. tmux's `ignore-size` client flag means "yield while any unflagged client is
300
+ // attached" (server-wide, and void when ALL clients are flagged — then everyone counts again, which is what
301
+ // keeps the single-backend warm hold working). So: a bridge votes (unflags) from the moment a viewer SIZES
302
+ // it, and goes neutral again when its last viewer leaves. A suppressed refresh-client -C still receives its
303
+ // one %layout-change (measured — announcing the window's real size), so the deterministic resize wait and
304
+ // the accept-any-announced-size rule need no change on either side of the flag.
305
+ function setVote(b: Bridge, on: boolean): void {
306
+ if (b.voting === on) return
307
+ b.voting = on
308
+ void command(b, `refresh-client -f ${on ? '!' : ''}ignore-size`)
309
+ }
310
+
288
311
  function killBridge(id: string): void {
289
312
  const b = bridges.get(id)
290
313
  if (!b) return
@@ -312,6 +335,7 @@ export function attachViewer(id: string, v: Viewer, initialSize?: { cols: number
312
335
  if (!b) return false // spawn failed → caller closes the socket → detachViewer prunes this subscriber
313
336
  b.needsFull = true // a (re)connecting viewer's xterm is blank / just reset → its first frame must be FULL
314
337
  if (initialSize && initialSize.cols > 0 && initialSize.rows > 0) {
338
+ setVote(b, true) // a sized viewer → this client asserts window size
315
339
  applySize(b, initialSize.cols, initialSize.rows) // resize-then-repaint at the client's true size
316
340
  }
317
341
  // else HIDDEN connect (0×0, no size): paint nothing now — the first frame is driven purely by the client's
@@ -455,6 +479,7 @@ export function detachViewer(id: string, v: Viewer): void {
455
479
  subscribers.delete(id)
456
480
  const b = bridges.get(id)
457
481
  if (b && !b.prewarmed) killBridge(id)
482
+ else if (b) setVote(b, false) // kept warm → back to size-neutral: an unwatched client must not out-vote a watched one
458
483
  }
459
484
  // a viewer fitted xterm → record the size as the last-known fit (even with no bridge yet, for pre-warm)
460
485
  // and resize the shared client. Repaints even on an unchanged size (a reconnect needs the frame). `full` (a
@@ -464,7 +489,7 @@ export function resizeBridge(id: string, cols: number, rows: number, full = fals
464
489
  if (!(cols > 0 && rows > 0)) return
465
490
  lastFit.set(id, { cols, rows }); lastFitAny = { cols, rows }
466
491
  const b = bridges.get(id)
467
- if (b) { if (full) b.needsFull = true; applySize(b, cols, rows) }
492
+ if (b) { if (full) b.needsFull = true; setVote(b, true); applySize(b, cols, rows) }
468
493
  }
469
494
  // resize the client + repaint WITHOUT recording a viewer fit — the primitive both a real resize and the
470
495
  // supervisor's pre-sizing share, so the supervisor can't clobber lastFit/lastFitAny with a stale value.
@@ -482,12 +507,22 @@ async function reconcileOnce(): Promise<void> {
482
507
  if (!(await alive(s.id))) continue
483
508
  live.add(s.id)
484
509
  // already ours → keep warm and resize a stale warm bridge to the last-known viewer size off-screen,
485
- // so a first open finds the pane already at its size. The size-diff guard makes a converged bridge a no-op.
510
+ // so a first open finds the pane already at its size. TWO staleness guards, by vote state: a NEUTRAL
511
+ // client's hold is suppressed while any sized viewer votes on the socket, so its own client size would
512
+ // read "converged" after one suppressed attempt and wedge the hold forever — compare against the
513
+ // WINDOW's real size (lastLayout) instead, retrying each tick (one suppressed no-op command) until the
514
+ // first tick after the socket goes quiet: deferred, not lost. A VOTING client keeps the client-size
515
+ // guard: its -C lands, and when two voting instances watch ONE session the window is genuinely
516
+ // contended — latest assert wins and STOPS (a window-truth guard would re-assert every tick and turn
517
+ // that contention into a visible size ping-pong war).
486
518
  const existing = bridges.get(s.id)
487
519
  if (existing) {
488
520
  existing.prewarmed = true
489
521
  const want = prewarmSize(s.id)
490
- if (want.cols !== existing.cols || want.rows !== existing.rows) applySize(existing, want.cols, want.rows)
522
+ const stale = existing.voting
523
+ ? want.cols !== existing.cols || want.rows !== existing.rows
524
+ : existing.lastLayout !== `${want.cols}x${want.rows}`
525
+ if (stale) applySize(existing, want.cols, want.rows)
491
526
  continue
492
527
  }
493
528
  // no bridge for a live session: viewers waiting → re-bind and repaint (nothing else re-arms an idle
@@ -10,7 +10,9 @@ const W_BODY = 1
10
10
 
11
11
  // a tiny stoplist of question scaffolding + length-1 tokens, dropped so "how does the … is it …" can't drown
12
12
  // the content words. Deliberately small and general — NOT tuned to any benchmark; just the function words a
13
- // natural-language query carries that match nothing meaningful.
13
+ // natural-language query carries that match nothing meaningful. Quantifiers (many, several, same, too…) are
14
+ // NOT stopped: in this corpus they are load-bearing ("too many owners" IS the multi-ownership concept —
15
+ // dropping them measurably breaks that reach).
14
16
  const STOP = new Set([
15
17
  'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'is', 'it', 'its', 'as', 'at', 'by', 'for',
16
18
  'how', 'does', 'do', 'what', 'which', 'that', 'this', 'these', 'those', 'with', 'from', 'into', 'are',
@@ -32,11 +34,24 @@ function words(text: string): string[] {
32
34
  return text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
33
35
  }
34
36
 
35
- // name match is forward-only (a chosen short field reverse would let a plural query light up every `spec-*`
36
- // name); desc/body match bidirectionally so a singular/plural mismatch still hits, reverse gated to words ≥3
37
- // chars so a stray short word can't swallow a longer term (IDF neutralises the generic words it pulls in).
38
- function nameMatch(term: string, w: string): boolean { return w.startsWith(term) }
39
- function textMatch(term: string, w: string): boolean { return w.startsWith(term) || (w.length >= 3 && term.startsWith(w)) }
37
+ // light query-side stem for prefix matching: drop a trailing plural 's' (len≥4, not 'ss') then a mute 'e'
38
+ // (len≥5) so `sessions` prefix-reaches `session`, `merge`→`merg` reaches `merging`, `declare`→`declar`
39
+ // reaches `declaration`. Without the e-drop the spec's promised merge↔merging reach silently never worked
40
+ // (`'merging'.startsWith('merge')` is false). Query-side only; IDF self-neutralises the extra reach (a
41
+ // looser term matches more docs bigger df smaller idf), so no flood.
42
+ function stem(t: string): string {
43
+ let s = t
44
+ if (s.length >= 4 && s.endsWith('s') && !s.endsWith('ss')) s = s.slice(0, -1)
45
+ if (s.length >= 5 && s.endsWith('e')) s = s.slice(0, -1)
46
+ return s
47
+ }
48
+
49
+ // name match is forward-only (a chosen short field — reverse would let a stray short word swallow it);
50
+ // desc/body match bidirectionally so a longer doc word still reaches a shorter query term, reverse gated to
51
+ // words ≥3 chars so a stray short word can't swallow a longer term (IDF neutralises the generic words it
52
+ // pulls in).
53
+ function nameMatch(term: string, w: string): boolean { return w.startsWith(stem(term)) }
54
+ function textMatch(term: string, w: string): boolean { return w.startsWith(stem(term)) || (w.length >= 3 && term.startsWith(w)) }
40
55
 
41
56
  // classic BM25 tf: frequency with saturation (K1 sets how fast it saturates) and length-normalisation (B),
42
57
  // both in a wide insensitive plateau. tf=0 → 0.
@@ -51,13 +66,16 @@ function bm25tf(tf: number, len: number, avgLen: number): number {
51
66
  type Fields<T> = { ref: T; name: string; nameWords: string[]; desc: string; descWords: string[]; bodyWords: string[]; snippetText: string }
52
67
 
53
68
  // the pre-IDF weight a term earns against one doc, picking its single best tier (three fields): a name
54
- // word-prefix beats a name substring beats a desc hit beats a body hit. Name and desc are short, chosen
55
- // fields binary presence; the body carries the BM25-saturated, length-normalised frequency that
56
- // discriminates the long ties.
57
- function tierWeight<T>(term: string, n: Fields<T>, avgBodyLen: number): number {
69
+ // word-prefix beats a name substring beats a desc hit beats a body hit. Name is a short, chosen field →
70
+ // binary presence. Desc is presence too (a curated one-liner — repetition there is stuffing, not evidence)
71
+ // but LENGTH-NORMALISED: it was flat-binary until descs drifted long and a bloated desc became a cheat code
72
+ // (one 60-word desc catches every query term a curated one-liner can't). bm25tf(1, avgLen, avgLen) = 1, so
73
+ // a hit in an average-length desc scores exactly the old binary W_DESC — the normalisation only bites
74
+ // outliers. The body keeps the full BM25-saturated term-frequency that discriminates the long ties.
75
+ function tierWeight<T>(term: string, n: Fields<T>, avgBodyLen: number, avgDescLen: number): number {
58
76
  if (n.nameWords.some((w) => nameMatch(term, w))) return W_NAME_PREFIX
59
77
  if (n.name.includes(term)) return W_NAME_SUBSTR
60
- if (n.descWords.some((w) => textMatch(term, w))) return W_DESC
78
+ if (n.descWords.some((w) => textMatch(term, w))) return W_DESC * bm25tf(1, n.descWords.length, avgDescLen)
61
79
  const tf = n.bodyWords.reduce((c, w) => c + (textMatch(term, w) ? 1 : 0), 0)
62
80
  return W_BODY * bm25tf(tf, n.bodyWords.length, avgBodyLen)
63
81
  }
@@ -108,6 +126,7 @@ export function rankDocs<T>(query: string, inputs: RankInput<T>[], opts: { limit
108
126
  // a rare one carries the rank. Read from the corpus, not hand-set.
109
127
  const N = docs.length
110
128
  const avgBodyLen = docs.reduce((a, n) => a + n.bodyWords.length, 0) / (N || 1)
129
+ const avgDescLen = docs.reduce((a, n) => a + n.descWords.length, 0) / (N || 1)
111
130
  const idf: Record<string, number> = {}
112
131
  for (const t of qterms) {
113
132
  let df = 0
@@ -120,7 +139,7 @@ export function rankDocs<T>(query: string, inputs: RankInput<T>[], opts: { limit
120
139
  const scored: Ranked<T>[] = []
121
140
  for (const n of docs) {
122
141
  let score = 0
123
- for (const t of qterms) score += tierWeight(t, n, avgBodyLen) * idf[t]
142
+ for (const t of qterms) score += tierWeight(t, n, avgBodyLen, avgDescLen) * idf[t]
124
143
  if (score <= 0) continue
125
144
  scored.push({ ref: n.ref, score: Math.round(score * 100) / 100, snippet: snippetFor(n.snippetText, n.desc, qterms) })
126
145
  }
@@ -1,5 +1,10 @@
1
- // throwaway benchmark harness for spec-search — drives the REAL `spex search --json` over the 15 holdout
2
- // cases and reports recall@1, recall@3, MRR. Not shipped/committed; the cases live in the node's yatsu.md.
1
+ // throwaway benchmark harness for spec-search — drives the REAL `spex search --json` over the holdout
2
+ // cases and reports recall@1, recall@3, MRR. The cases live in the node's yatsu.md.
3
+ //
4
+ // Labels are node LEAF names, matched with the same de-collision rule the loader applies (specs.ts reId):
5
+ // a returned id matches a label if it IS the label or ends with `_<label>` — so a bare leaf keeps matching
6
+ // after collision-qualification renames it (e.g. `spec-scout` → `injected-context_spec-scout`). A label may
7
+ // also be written pre-qualified to pin one collision branch.
3
8
  import { execFileSync } from 'node:child_process'
4
9
  import { fileURLToPath } from 'node:url'
5
10
  import { dirname, join } from 'node:path'
@@ -8,29 +13,33 @@ const BIN = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'spex.mjs
8
13
 
9
14
  const CASES = [
10
15
  ['exit-cleanup', "does /exit remove the session's worktree and tmux, or just orphan them?", ['session-console']],
11
- ['owner-at-edit', 'how does an agent learn which spec governs a file it just edited?', ['injected-context/spec-of-file']],
16
+ ['owner-at-edit', 'how does an agent learn which spec governs a file it just edited?', ['spec-of-file']],
12
17
  ['main-block', 'what stops an agent from committing or merging straight into main?', ['main-guard']],
13
18
  ['main-escape', 'the escape hatch that lets seeding run on the main branch', ['main-guard']],
14
19
  ['inter-agent-msg', 'how do two running agent sessions send messages to each other?', ['agent-reply-channel', 'comms']],
15
20
  ['search-hidden-node', 'keyboard shortcut to find a node hidden inside a collapsed subtree', ['keyboard-nav']],
16
- ['session-order', 'how is the order of sessions in the session list decided?', ['session-reorder']],
21
+ ['session-order', 'how is the order of sessions in the session list decided?', ['session-console']],
17
22
  ['node-status', 'what makes a node show as pending vs active vs merged vs drift?', ['spec-node-states']],
18
23
  ['dashboard-backend', 'how does the dashboard reach the backend API and on which port?', ['api-endpoint']],
19
24
  ['loss-measured', "how is a node's loss measured and its scenarios scored?", ['yatsu-core']],
20
25
  ['launch-injection', "what context gets injected into a freshly launched agent's prompt?", ['injected-context']],
21
- ['read-before-code', 'the one-shot nudge that makes an agent read its spec before touching code', ['injected-context/spec-first']],
26
+ ['read-before-code', 'the one-shot nudge that makes an agent read its spec before touching code', ['spec-first']],
22
27
  ['hot-reload', 'zero-downtime backend reload without dropping connections', ['supervisor']],
23
28
  ['many-owners', 'can several specs own the same code file, and what happens if too many do?', ['governed-related']],
24
29
  ['active-spec-search', 'an injected sub-agent that searches specs for the agent, the spec analog of Explore', ['spec-scout']],
30
+ ['declare-done', 'how does a worker declare it is done', ['state']],
25
31
  ]
26
32
 
33
+ // de-collision-aware label match: exact id, or the id's trailing `_`-suffix is the label.
34
+ const matches = (id, label) => id === label || id.endsWith('_' + label)
35
+
27
36
  let r1 = 0, r3 = 0, mrr = 0
28
37
  const rows = []
29
38
  for (const [name, query, expect] of CASES) {
30
39
  const out = execFileSync('node', [BIN, 'search', query, '--json', '--limit', '10'], { encoding: 'utf8' })
31
40
  const results = JSON.parse(out)
32
41
  const ids = results.map((x) => x.id)
33
- const rank = ids.findIndex((id) => expect.includes(id)) + 1 // 1-based; 0 = not found
42
+ const rank = ids.findIndex((id) => expect.some((label) => matches(id, label))) + 1 // 1-based; 0 = not found
34
43
  const hit1 = rank === 1
35
44
  const hit3 = rank >= 1 && rank <= 3
36
45
  if (hit1) r1++
@@ -43,5 +52,19 @@ for (const row of rows) {
43
52
  const mark = row.rank === 1 ? '✓1' : (typeof row.rank === 'number' && row.rank <= 3 ? '·3' : (row.rank === '—' ? '✗ ' : `·${row.rank}`))
44
53
  console.log(`${mark} ${row.name.padEnd(20)} want=${row.expect.padEnd(22)} rank=${String(row.rank).padStart(2)} top3: ${row.top}`)
45
54
  }
55
+
56
+ // zero-result fail-loud regression: a CJK query over the English corpus returns nothing, and the
57
+ // zero-result message must carry the corpus-is-English translate-and-retry fact (fail-loud, unconditional)
58
+ // plus the browse-all next step (no nearest titles — a CJK query has nothing to be lexically near).
59
+ const cjk = execFileSync('node', [BIN, 'search', '重命名一个会话'], { encoding: 'utf8' })
60
+ const cjkPass = cjk.includes('corpus is English') && cjk.includes('spex tree')
61
+ console.log(`${cjkPass ? '✓ ' : '✗ '} cjk-zero-result want=corpus-is-English + spex-tree ${cjkPass ? 'both present' : 'MISSING: ' + cjk.trim()}`)
62
+
63
+ // zero-result typo routing: an English typo that matches nothing must surface the nearest node titles
64
+ // (per-word edit distance) plus the browse-all next step, so a typo routes forward instead of dead-ending.
65
+ const typo = execFileSync('node', [BIN, 'search', 'kyeboard'], { encoding: 'utf8' })
66
+ const typoPass = typo.includes('nearest titles') && typo.includes('keyboard-nav') && typo.includes('spex tree')
67
+ console.log(`${typoPass ? '✓ ' : '✗ '} typo-zero-result want=nearest-titles(keyboard-nav) + spex-tree ${typoPass ? 'both present' : 'MISSING: ' + typo.trim()}`)
68
+
46
69
  console.log('—'.repeat(72))
47
- console.log(`recall@1 = ${r1}/${n} = ${(r1 / n).toFixed(3)} recall@3 = ${r3}/${n} = ${(r3 / n).toFixed(3)} MRR = ${(mrr / n).toFixed(3)}`)
70
+ console.log(`recall@1 = ${r1}/${n} = ${(r1 / n).toFixed(3)} recall@3 = ${r3}/${n} = ${(r3 / n).toFixed(3)} MRR = ${(mrr / n).toFixed(3)} cjk-hint = ${cjkPass ? 'PASS' : 'FAIL'} typo-route = ${typoPass ? 'PASS' : 'FAIL'}`)
@@ -4,6 +4,45 @@ import { rankDocs, type RankInput } from './ranker.js'
4
4
  export type SearchResult = { id: string; title: string; path: string; score: number; snippet: string }
5
5
  export type SearchStats = { nodes: number; tokens: number; ms: number }
6
6
 
7
+ // zero-result fallback: nearest node titles by per-word edit distance over title+id — the lightweight
8
+ // string distance that routes a typo'd query to a next step. Each query word takes its best normalised
9
+ // Levenshtein similarity against any title/id word (≥0.5, so unrelated words don't accumulate), then the
10
+ // words sum. Same loadSpecsLite read as the ranker; deliberately NOT part of rankDocs scoring (this
11
+ // tolerates typos, the ranker must not).
12
+ export function nearestTitles(query: string, n = 3): { id: string; title: string }[] {
13
+ const words = (s: string) => s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1)
14
+ const lev = (a: string, b: string) => {
15
+ const dp = Array.from({ length: b.length + 1 }, (_, i) => i)
16
+ for (let i = 1; i <= a.length; i++) {
17
+ let prev = dp[0]
18
+ dp[0] = i
19
+ for (let j = 1; j <= b.length; j++) {
20
+ const cur = dp[j]
21
+ dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1))
22
+ prev = cur
23
+ }
24
+ }
25
+ return dp[b.length]
26
+ }
27
+ const qws = words(query)
28
+ if (!qws.length) return [] // nothing to be "near" (e.g. a pure-CJK query) — the caller still points at spex tree
29
+ return loadSpecsLite()
30
+ .map((s) => {
31
+ const tws = [...new Set(words(`${s.title} ${s.id}`))]
32
+ let score = 0
33
+ for (const q of qws) {
34
+ let best = 0
35
+ for (const t of tws) best = Math.max(best, 1 - lev(q, t) / Math.max(q.length, t.length))
36
+ if (best >= 0.5) score += best
37
+ }
38
+ return { id: s.id, title: s.title, score }
39
+ })
40
+ .filter((s) => s.score > 0)
41
+ .sort((a, b) => b.score - a.score)
42
+ .slice(0, n)
43
+ .map(({ id, title }) => ({ id, title }))
44
+ }
45
+
7
46
  export async function searchSpecs(query: string, opts: { limit?: number; onStats?: (s: SearchStats) => void } = {}): Promise<SearchResult[]> {
8
47
  const t0 = performance.now()
9
48
  // pre-sort by (shorter id, then id): rankDocs sorts stably, so for equal-scored nodes this IS the tiebreak.