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.
- package/README.md +149 -102
- package/README.zh-CN.md +170 -0
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +24 -1
- package/spec-cli/src/attach.ts +50 -0
- package/spec-cli/src/cli.ts +227 -66
- package/spec-cli/src/client.ts +47 -9
- package/spec-cli/src/{self.ts → doctor.ts} +26 -25
- package/spec-cli/src/gateway.ts +15 -11
- package/spec-cli/src/guide.ts +73 -17
- package/spec-cli/src/harness.ts +48 -19
- package/spec-cli/src/help.ts +141 -51
- package/spec-cli/src/index.ts +41 -14
- package/spec-cli/src/issues.ts +109 -31
- package/spec-cli/src/layout.ts +4 -4
- package/spec-cli/src/localIssues.ts +59 -58
- package/spec-cli/src/materialize.ts +4 -2
- package/spec-cli/src/mentions.ts +22 -1
- package/spec-cli/src/pty-bridge.ts +39 -4
- package/spec-cli/src/ranker.ts +31 -12
- package/spec-cli/src/search.bench.mjs +30 -7
- package/spec-cli/src/search.ts +39 -0
- package/spec-cli/src/sessions.ts +149 -62
- package/spec-cli/src/specs.ts +16 -4
- package/spec-cli/src/supervise.ts +30 -6
- package/spec-cli/src/tree.ts +118 -0
- package/spec-cli/templates/hooks/post-merge +2 -2
- package/spec-cli/templates/hooks/pre-commit +34 -15
- package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
- package/spec-dashboard/dist/assets/Dashboard-BwZ2KzxB.js +27 -0
- package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
- package/spec-dashboard/dist/assets/EvalsPage-DV75EdP4.js +3 -0
- package/spec-dashboard/dist/assets/FoldToggle-GwE0-k1d.js +1 -0
- package/spec-dashboard/dist/assets/IssuesPage-B17pnl9I.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-WEZbR8M1.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-Sh8kHpnj.js +71 -0
- package/spec-dashboard/dist/assets/SessionWindow-EzFq-hLG.js +9 -0
- package/spec-dashboard/dist/assets/Settings-Dgtg-Xb9.js +1 -0
- package/spec-dashboard/dist/assets/index-CCmnCbKS.css +1 -0
- package/spec-dashboard/dist/assets/index-Dd0_U5rk.js +41 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-forge/src/cli.ts +4 -10
- package/spec-forge/src/drivers.ts +13 -0
- package/spec-yatsu/src/cli.ts +89 -15
- package/spec-yatsu/src/scenariofresh.ts +100 -30
- package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +0 -145
- 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.
|
|
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
|
-
|
|
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
|
package/spec-cli/src/ranker.ts
CHANGED
|
@@ -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
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
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
|
|
2
|
-
// cases and reports recall@1, recall@3, MRR.
|
|
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?', ['
|
|
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-
|
|
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', ['
|
|
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.
|
|
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'}`)
|
package/spec-cli/src/search.ts
CHANGED
|
@@ -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.
|