Repository navigation
feat: spec section search — rank spec sections by words for the palette's # scope and the agent's spec_search, and ask the agent from # - #939
Conversation
| useEffect(() => { | ||
| const article = articleRef.current; | ||
| if (!article || revealLine === undefined || revealTick === undefined) return; | ||
| return revealSourceLine(article, revealLine); |
There was a problem hiding this comment.
Non-blocking:
Problem: The new Markdown reveal effect reapplies the retained request on every mount, even when its tick is unchanged, overriding the saved reading position.
Failure scenario: Open a section from #, scroll farther down the spec, switch to a chat tab, then return. The preview remounts and jumps back to the original heading instead of restoring where the reader left off.
Suggested fix: Track the handled reveal tick across mounts, or consume the request at the tab owner after a successful reveal, so ordinary tab restoration preserves subsequent scrolling.
| let bestStart = 0; | ||
| for (const element of container.querySelectorAll<HTMLElement>("[data-md-line-start]")) { | ||
| const start = Number(element.dataset.mdLineStart); | ||
| if (start <= line && start > bestStart) { |
There was a problem hiding this comment.
Non-blocking:
Problem: A reveal before the first stamped block never scrolls the document; the observer cannot find a target even after rendering finishes.
Failure scenario: A spec has a blank line after frontmatter and introductory prose before its first heading. Searching that prose returns line 6, while the first rendered paragraph starts at line 7. If the spec is already open and scrolled down, selecting its preamble hit leaves it at the old position instead of revealing the result.
Suggested fix: When rendered blocks exist but the requested line precedes them all, reveal the first block, retaining the observer for documents that have not rendered yet.
… agent and the palette
…ec sections by words
…ts on protocol v99
…README, and cover spec_search live
…y once the host has seen it
…uard, not a CI speed gate
004bc58 to
25539bb
Compare
| if (closesFence(line, fence)) fence = null; | ||
| continue; | ||
| } | ||
| const open = FENCE.exec(line)?.[1]; |
There was a problem hiding this comment.
Non-blocking:
Problem: The fence opener accepts backtick-containing info strings, so a standalone inline code span is mistaken for an unclosed fenced block.
Failure scenario: With ## Syntax, a line containing ```literal```, and then ## Shutdown, both #shutdown and spec_search("shutdown") return Syntax at its earlier line; the real Shutdown heading is never indexed. The installed Markdown parser renders Shutdown as a heading.
Suggested fix: Reject backtick fence openers whose remaining info string contains a backtick, as the existing indivisibleSpans scanner does.
| if (closesFence(line, fence)) fence = null; | ||
| continue; | ||
| } | ||
| const open = FENCE.exec(line)?.[1]; |
There was a problem hiding this comment.
Non-blocking:
Problem: Fence recognition misses code blocks opened inside list items, corrupting subsequent section boundaries.
Failure scenario: A spec has a list item beginning with a fenced Markdown example, an indented ## Example inside it, its indented closing fence, then ## Boundary. The closing fence becomes a new opener, so Boundary disappears as a section. Both # and spec_search return Boundary's text under Example and jump into the code sample. This reproduces with the pinned Markdown parser recognizing Boundary as a real heading.
Suggested fix: Account for list/container indentation when recognizing fence openers and closers, keeping sample headings excluded without swallowing subsequent document sections.
Problem
The palette's
#scope matched spec bodies by substring: every typed word somewhere in the file, the first matching line as the hit, no ranking beyond the matcher'scontenttier. The agent had onlyspec_grep(regex / substring) to find the spec that governs a change. Neither could answer a question such as rename chat title with the section that decides it (agent › Session titles). The requirement was one engine for both surfaces, so the user and the agent get the same answer:Seventh PR of the global-search stack; stacked on #933.
Approach
pi-spec-graph/core.core/is the only code both the agent (vanilla pi, the CLI's bundled extension) and the host can reach.renameSession→renamesession,rename,session), English stopwords, and a conservative stemmer (Porter step 1 + Snowball-ly+ finale, sorenaming↔renamewhileisolation≠isolate).-ingextends, so the palette's half-typed last word still matches.minisearch/ Orama (a dependency outside the module's allowed set), and a local embedding model (transformers.js needs nativeonnxruntime-node, ~300 MB, and breaksbun build --compile). No model was the user's call: paraphrases go to the agent, whose model reformulates them.Maps), ~160 ms to index and 2–10 ms per query.indexSections(entries, previous)re-tokenizes only files whose content changed. Both the tool and the host keep at most four roots.*.md.txt, so the repo's indexer never sees a duplicate id) and 20 questions are the gate. A separate agent wrote the questions without seeing the engine.bun run --cwd packages/spec-graph bench:searchprints the same report for a live tree (this repo: 17/20; its scratch task-specs quote the very questions).spec_search.query(a string, or an array of phrasings),limit(8, max 25),type/tag/parent.path:line Spec › Section — snippet, with(n/m words)on a partial match.spec_grepstays for exact patterns. No call is mandatory.sectionhits, protocol v96.SearchKindwithSearchHit.section { heading, line, matched, terms }, rather than aspechit with an extra field. The separate kind gives them their owntotal/state/limitand keeps them out of All at the request level: the web never asks All for them.specmodule builds a root's section index off the request path. It answersindexinguntil the index is ready, so the client's bounded polling picks it up, and snapshot rebuilds re-sync it.contentspec hits are gone.#lists title matches, then Sections (Spec › Headingwith the spec name muted, the best line highlighted, ann/m wordsbadge).⌥↵from any row. This is variant B of the design mockup: a fixed place keeps muscle memory, where a "weak results" heuristic misfires both ways.↵opens the spec in the Specs tool scrolled to the heading;⌘Ipreviews it at the section. The rendered Markdown view now honoursResourceReveal.Changes
packages/spec-graph:core/tokenize.ts,core/sections.ts,core/search.ts;indexSections/searchSpecsand their types exported frompi-spec-graph/core.core/bench/: the frozen corpus,questions.json, the gate test and thebench:searchlive script.tools/search.ts(spec_search); the bounded section-index cache intools/shared.ts.core/SPEC.md(a Ranked section search record),tools/SPEC.md,SPEC.md.packages/contracts:PROTOCOL_VERSION = 96=SPEC_SECTIONS_PROTOCOL_VERSION;sectioninSEARCH_KINDS;SearchHitSection; the v95 pin now asserts>=.packages/server:spec:searchSpecSections, plus the section index scheduled off-request and re-synced with the snapshot.SpecSectionHitis re-exported as a type sosearchnever imports the package.search:searchSpecskeeps title / id / path; the newsearchSectionsprovider receives the query as typed;ProviderInput.text; the deadlineSnippetis removed.host/search.tswiring;host/reviewerRole.ts.packages/resource-renderers:MarkdownPreviewhonoursreveal: it scrolls the last block whose source-line stamp is at or before the line, waiting for the document to render. The non-review branch now stamps lines too.apps/web:search: thesectionitem kind, theSectionsgroup,titleLead, the words badge,PaletteFile.line, the Ask row +askEligible+⌥↵+ footer hint, and thePaletteAskexport.scopeKinds:#asks forspec+section, All never asks forsection.shell:PaletteAsk→startChatInTab(viaenterDefaultWorkspaceon Project Home); a section hit →requestSpecView(…, line); the preview getsreveal.store:specRequest.line.panels/SpecsPanel: reveals the line with no renderer override.chat/tools:spec_searchrenders inSpecToolCardwith path links.en.jsonkeys.packages/pi-subagents:spec_searchadded to the builtin read tools.goal-and-requirements(the Specs capability),architecture(whatcore/backs),module-spec-graph,submodule-spec-graph-core,submodule-spec-graph-tools,module-contracts,submodule-server-search,submodule-server-spec,submodule-web-search(new Spec sections / Ask the agent sections),shell,store,panels,resource-renderers,pi-subagents,planReview. README: the Specs bullet.search-palette.spec.ts: the#SPECGRAPHPROBEcase now expects asectionrow. A new scenario covers:⌘Iand↵both land on the heading;⌥↵opens a new chat with the question and the note;A second new scenario asks from Project Home:
⌥↵enters the project's Default workspace and starts the chat there.A spec the test writes is searched only once the Specs tree shows it. A shard's host outlives the per-test reset, and its 5 s spec snapshot can otherwise answer for an earlier test's worktree at the same path.
spec-tools.live.spec.ts: aspec_searchcase.workflows/routing.live.spec.ts: the forbidden-tool list.Deferred: a second (embedding) ranker fused behind
searchSpecs, Russian or other-language queries (through the agent meanwhile), sections in the All list, an on-disk index, and non-spec documents.Screenshots
Same scenario and viewport on both builds: a workspace carrying six of this repository's specs, the palette in
#.Before (the base, #933):
#rename chat titlematched each spec's frontmattertitle:line — one row per file, unranked:After — the same query: ranked Sections,
Spec › Heading, the best line highlighted,n/m words:Before / after — a paraphrase that shares no words with its section (
#who keeps my chats when I reload); after, «Ask the agent» is the last row (selected here,⌥↵from any row):After —
⌘Ipreviews the rendered spec at the section, and↵opens it in the Specs tool scrolled to the heading:Testing
bun run test→ 22/22 tasks (web 2002 pass; server 1837 pass, 4 skip;pi-spec-graph107 pass, 3 skip, including the benchmark gate; contracts, resource-renderers, pi-subagents, shared, … green)core/bench) → 20/20 expected sections in the top 3 (gate ≥ 16); paraphrase misses 0/5, as designed;bun run --cwd packages/spec-graph bench:searchon this repository → 17/20bun run e2e(full no-agent suite, 8 shards, 650 tests) → all passedbun run e2e:full e2e/spec-tools.live.spec.ts→ 2 passed (a real agent callsspec_searchand getsSPEC.md:7 Sample Project › Goal)bun run e2e e2e/search-palette.spec.ts e2e/specs-panel.spec.ts→ 15 passed; the new scenario was checked by sabotage (reveal forced to line 1 → it fails at the preview'stoBeInViewport)bun run e2e e2e/search-palette.spec.ts --repeat-each=3→ 39 passed (the two ask scenarios alone,--repeat-each=5→ 10 passed)bun run lint,bun run typecheck(22 packages),check:deps,check:boundaries,check:spec-surface,check:i18n,check:compiler→ green004bc58f2(after linking feat: spec section search — rank spec sections by words for the palette's # scope and the agent's spec_search, and ask the agent from # #939 into stack #807, which is what dispatches CI for a stacked PR) → 16/16 jobs green, including unit tests, both e2e shards, binary and desktop smoke + e2e (4 shards each), the Windows binary smoke and vanilla-pi package parityRelated issues
Part of the global-search stack (#804 → #805 → #806 → #887 → #888 → #933 → this).
Checklist
bun run lint,bun run typecheck,bun run testbun run e2e, orbun run e2e:fullwhen touching agent behavior)SPEC.md/ top-level specs updated to reflect any boundary, contract, or behavior change