diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 9088a31..93446e2 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -40,3 +40,12 @@ {"id":"int-192bdc48","kind":"field_change","created_at":"2026-06-27T17:35:59.399003763Z","actor":"dawn","issue_id":"klbr-u3t.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented query-aware adaptive neighbor window expansion using looks_incomplete, query_is_wh_like, and a cheap FTS/Entity matching score helper. Verified all tests pass."}} {"id":"int-323272d6","kind":"field_change","created_at":"2026-06-27T17:37:28.432255908Z","actor":"dawn","issue_id":"klbr-u3t.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented BGE-M3 sparse embeddings retrieval path via API fallback, customized SQLite LIKE-based sparse match scoring in memory.rs, and integrated into pipeline.rs. Verified all tests pass."}} {"id":"int-67cb73f9","kind":"field_change","created_at":"2026-06-27T17:37:41.203515867Z","actor":"dawn","issue_id":"klbr-u3t","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"All subtasks and blocker tasks are completed: Refactored RetrievedRef to EvidenceAtom, implemented the principled RRF/bonus fusion scoring formula, query-aware adaptive neighbor window expansion, and BGE-M3 sparse retrieval integration. All tests are passing green."}} +{"id":"int-2f66bdff","kind":"field_change","created_at":"2026-06-28T10:55:43.459636782Z","actor":"dawn","issue_id":"klbr-a9b.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Recorded tree-sitter-first, file-local first-pass boundary and explicit non-goals in issue design."}} +{"id":"int-677e7ff2","kind":"field_change","created_at":"2026-06-28T11:02:30.689146931Z","actor":"dawn","issue_id":"klbr-a9b.6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented generic tree-sitter language detection, query support, and parse-status substrate via tree-sitter-language-pack; focused code_intel tests and klbr-core check pass."}} +{"id":"int-eaafe5e4","kind":"field_change","created_at":"2026-06-28T11:05:59.957452341Z","actor":"dawn","issue_id":"klbr-a9b.8","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented generic file-local symbol extraction through tree-sitter-tags over language-pack query bundles, including definition/reference roles, capture-derived kinds, ranges, docs, containment name paths, and focused tests."}} +{"id":"int-23ae46e8","kind":"field_change","created_at":"2026-06-28T11:09:27.068889325Z","actor":"dawn","issue_id":"klbr-a9b.2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented generic tree-sitter-backed symbol_overview and find_symbol tools with focused tests."}} +{"id":"int-3bb3de01","kind":"field_change","created_at":"2026-06-28T11:11:25.555490986Z","actor":"dawn","issue_id":"klbr-a9b.4","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented read_symbol using the shared generic tree-sitter symbol matcher and body range extraction."}} +{"id":"int-c69a9f24","kind":"field_change","created_at":"2026-06-28T11:14:47.097183497Z","actor":"dawn","issue_id":"klbr-a9b.5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented best-effort same-file find_references and find_definition tools over generic tree-sitter tags."}} +{"id":"int-401d0a5b","kind":"field_change","created_at":"2026-06-28T11:18:16.246397759Z","actor":"dawn","issue_id":"klbr-a9b.3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented replace_symbol_body, insert_before_symbol, and insert_after_symbol using generic tree-sitter symbol ranges."}} +{"id":"int-21f83166","kind":"field_change","created_at":"2026-06-28T11:20:24.873425877Z","actor":"dawn","issue_id":"klbr-a9b.7","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added code-intel docs, registration coverage, and recorded focused verification commands."}} +{"id":"int-c8b11ed4","kind":"field_change","created_at":"2026-06-28T11:20:38.81144388Z","actor":"dawn","issue_id":"klbr-a9b","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Completed first-pass generic tree-sitter file-local code retrieval, navigation, symbolic edits, tests, and docs."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index ae97343..6689084 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,9 @@ +{"_type":"issue","id":"klbr-a9b.8","title":"Extract file-local symbols from tree-sitter tags","description":"Build symbol extraction on top of generic tree-sitter language bundles. Prefer tags/locals/code-intel APIs when a language provides them, and keep the internal symbol model independent of any one grammar. Convert definition/reference captures or equivalent structure output into name, kind, role, name range, body range, parent/name path when derivable, docs when provided, and source snippet boundaries.","acceptance_criteria":"Fixture files in multiple representative languages produce stable symbol lists with definitions, ranges, names, and basic nesting/name paths where the language bundle supports it; extraction tolerates parse errors by returning partial symbols plus parse status rather than panicking; unsupported query features degrade cleanly.","status":"closed","priority":1,"issue_type":"feature","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-28T10:46:33Z","created_by":"dawn","updated_at":"2026-06-28T11:06:00Z","started_at":"2026-06-28T11:02:44Z","closed_at":"2026-06-28T11:06:00Z","close_reason":"Implemented generic file-local symbol extraction through tree-sitter-tags over language-pack query bundles, including definition/reference roles, capture-derived kinds, ranges, docs, containment name paths, and focused tests.","labels":["code-intel","retrieval","tools","tree-sitter"],"dependencies":[{"issue_id":"klbr-a9b.8","depends_on_id":"klbr-a9b","type":"parent-child","created_at":"2026-06-28T13:46:32Z","created_by":"dawn","metadata":"{}"},{"issue_id":"klbr-a9b.8","depends_on_id":"klbr-a9b.6","type":"blocks","created_at":"2026-06-28T13:46:50Z","created_by":"dawn","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"klbr-a9b.1","title":"Define tree-sitter code-intel backend boundary","description":"Decide the first-pass tree-sitter backend shape before code lands: supported languages, crate choices, tool naming, data structures, and explicit non-goals. The boundary should keep this file-local and tree-sitter-first, with no LSP, diagnostics tool, project-wide semantic resolution, or automatic check/format hook in the first slice.","design":"first pass is tree-sitter-first, file-local, and generic across languages. the implementation should not hardcode rust/typescript as the architecture; concrete languages are only fixtures. the core model is source text plus path/language hint -\u003e generic parser/query registry -\u003e tags/locals captures or generic code-intel output -\u003e file-local symbols with kind/name/name_path/ranges/docs/parse status. prefer tree-sitter-language-pack or an equivalent bundle registry so languages can be added by parser/query availability rather than bespoke code. tool behavior follows serena: OK on successful edits; concise errors for unsupported language, missing/ambiguous symbol, parse/range failure, or invalid replacement. no lsp backend, no diagnostics tool, no automatic cargo/check/format hook, no verification hints, and no project-wide semantic resolution in this first implementation. project-wide indexing can be a later layer over the same file-local extractor.","acceptance_criteria":"A short design note is recorded in the issue or docs; the first implementation surface lists supported languages, commands/tools, and unsupported behavior; later implementation issues can proceed without re-litigating LSP/project-wide scope.","status":"closed","priority":1,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-28T10:46:31Z","created_by":"dawn","updated_at":"2026-06-28T10:59:12Z","started_at":"2026-06-28T10:55:29Z","closed_at":"2026-06-28T10:55:43Z","close_reason":"Recorded tree-sitter-first, file-local first-pass boundary and explicit non-goals in issue design.","labels":["code-intel","design","tools","tree-sitter"],"dependencies":[{"issue_id":"klbr-a9b.1","depends_on_id":"klbr-a9b","type":"parent-child","created_at":"2026-06-28T13:46:30Z","created_by":"dawn","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"klbr-a9b.6","title":"Add tree-sitter parser and language registry","description":"Add the klbr-core substrate for selecting a tree-sitter parser and query bundle from a file path or optional language hint. This should be generic: use tree-sitter-language-pack or an equivalent registry/loader rather than handcoding a small set of language crates. Concrete languages in tests are only fixtures proving the generic path.","acceptance_criteria":"Given a supported file path or language hint and source text, klbr can parse it with tree-sitter, report parse errors/MISSING nodes as parse status, and expose language metadata plus tags/locals/query availability when present; unsupported or unavailable languages return a concise unsupported-language error without panicking.","status":"closed","priority":1,"issue_type":"feature","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-28T10:46:31Z","created_by":"dawn","updated_at":"2026-06-28T11:02:31Z","started_at":"2026-06-28T10:55:54Z","closed_at":"2026-06-28T11:02:31Z","close_reason":"Implemented generic tree-sitter language detection, query support, and parse-status substrate via tree-sitter-language-pack; focused code_intel tests and klbr-core check pass.","labels":["code-intel","tools","tree-sitter"],"dependencies":[{"issue_id":"klbr-a9b.6","depends_on_id":"klbr-a9b","type":"parent-child","created_at":"2026-06-28T13:46:31Z","created_by":"dawn","metadata":"{}"},{"issue_id":"klbr-a9b.6","depends_on_id":"klbr-a9b.1","type":"blocks","created_at":"2026-06-28T13:46:50Z","created_by":"dawn","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"klbr-a9b.3","title":"Implement tree-sitter symbolic edit tools","description":"Implement Serena-style file-local symbolic edits using tree-sitter ranges: replace_symbol_body, insert_before_symbol, and insert_after_symbol. Keep behavior terse: OK on success; concise error on unsupported language, missing symbol, ambiguity, invalid range, or parse/index mismatch. Do not emit verification hints, diagnostics summaries, or format/check advice.","acceptance_criteria":"Editing tools work on fixture languages covered by the generic extractor; they preserve surrounding whitespace reasonably; success returns OK; failures are concise and actionable; no cargo/check/format hook runs as part of the edit.","status":"closed","priority":1,"issue_type":"feature","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-28T10:46:30Z","created_by":"dawn","updated_at":"2026-06-28T11:18:16Z","started_at":"2026-06-28T11:15:55Z","closed_at":"2026-06-28T11:18:16Z","close_reason":"Implemented replace_symbol_body, insert_before_symbol, and insert_after_symbol using generic tree-sitter symbol ranges.","labels":["code-intel","editing","tools","tree-sitter"],"dependencies":[{"issue_id":"klbr-a9b.3","depends_on_id":"klbr-a9b","type":"parent-child","created_at":"2026-06-28T13:46:30Z","created_by":"dawn","metadata":"{}"},{"issue_id":"klbr-a9b.3","depends_on_id":"klbr-a9b.4","type":"blocks","created_at":"2026-06-28T13:46:50Z","created_by":"dawn","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"klbr-a9b.2","title":"Implement file-local symbol overview and find tools","description":"Expose Serena-like retrieval tools for one file: symbol overview and find symbol by name/name-path pattern, backed by the tree-sitter symbol extractor. Keep outputs compact and structured enough for an agent to choose a symbol without reading the whole file.","acceptance_criteria":"Tools can list top-level and nested symbols for a file; find_symbol can return exact or bounded fuzzy/name-path matches in that file; include_body is optional and token-conscious; ambiguous queries return concise disambiguation instead of guessing.","status":"closed","priority":1,"issue_type":"feature","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-28T10:46:29Z","created_by":"dawn","updated_at":"2026-06-28T11:09:27Z","started_at":"2026-06-28T11:06:14Z","closed_at":"2026-06-28T11:09:27Z","close_reason":"Implemented generic tree-sitter-backed symbol_overview and find_symbol tools with focused tests.","labels":["code-intel","retrieval","tools","tree-sitter"],"dependencies":[{"issue_id":"klbr-a9b.2","depends_on_id":"klbr-a9b","type":"parent-child","created_at":"2026-06-28T13:46:30Z","created_by":"dawn","metadata":"{}"},{"issue_id":"klbr-a9b.2","depends_on_id":"klbr-a9b.8","type":"blocks","created_at":"2026-06-28T13:46:50Z","created_by":"dawn","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"klbr-a9b","title":"Build tree-sitter semantic code tools","description":"Implement Serena-like symbolic code retrieval and editing in klbr with tree-sitter as the first backend. The first pass is file-local and syntax-derived: detect a language for one file, load its tree-sitter parser/query bundle through a generic registry, extract symbols, read symbol bodies, and edit by symbol ranges with terse OK/error behavior. Explicit non-goals for this epic's first implementation are LSP integration, diagnostics tools, automatic format/check hooks, and project-wide semantic resolution.","design":"Use generic tree-sitter language bundles as the substrate. Prefer tree-sitter-language-pack or an equivalent registry that can detect/load many grammars and expose parser plus query availability. Treat definitions and references as syntax-derived facts, not typechecker truth. Prefer Serena-style tool behavior: deterministic operation, terse result string, no advisory verification text.","acceptance_criteria":"Tree-sitter-backed tools can overview, find, read, insert around, and replace symbols in any language with an available parser/query bundle; edit tools return OK or concise errors; fixtures cover multiple representative languages without hardcoding the architecture to them; docs state the file-local/tree-sitter-first boundary.","status":"closed","priority":1,"issue_type":"epic","owner":"90008@klbr.net","created_at":"2026-06-28T10:45:53Z","created_by":"dawn","updated_at":"2026-06-28T11:20:39Z","closed_at":"2026-06-28T11:20:39Z","close_reason":"Completed first-pass generic tree-sitter file-local code retrieval, navigation, symbolic edits, tests, and docs.","labels":["code-intel","tools","tree-sitter"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"klbr-u3t.3","title":"Implement query-aware neighbor window expansion","description":"Upgrade candidate neighbor expansion to be query-aware (expand_turn_window), using FTS, dense, and entity matching weights to select the most relevant turns instead of a hardcoded window, and budget dynamically under token limits.","status":"closed","priority":1,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T17:26:29Z","created_by":"dawn","updated_at":"2026-06-27T17:35:59Z","closed_at":"2026-06-27T17:35:59Z","close_reason":"Implemented query-aware adaptive neighbor window expansion using looks_incomplete, query_is_wh_like, and a cheap FTS/Entity matching score helper. Verified all tests pass.","dependencies":[{"issue_id":"klbr-u3t.3","depends_on_id":"klbr-u3t","type":"parent-child","created_at":"2026-06-27T20:26:29Z","created_by":"dawn","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"klbr-u3t.2","title":"Implement principled packet fusion scoring formula","description":"Implement the full scoring formula for EvidencePacket in evidence.rs, including token size penalty, explicit anchor+neighbor pair bonus, and weighted outer reciprocal rank fusion over exact, FTS, dense, and graph channels.","status":"closed","priority":1,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T17:26:16Z","created_by":"dawn","updated_at":"2026-06-27T17:34:57Z","closed_at":"2026-06-27T17:34:57Z","close_reason":"Implemented principled RRF and bonus/penalty scoring formula in evidence.rs. verified all tests pass.","dependencies":[{"issue_id":"klbr-u3t.2","depends_on_id":"klbr-u3t","type":"parent-child","created_at":"2026-06-27T20:26:15Z","created_by":"dawn","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"klbr-9al","title":"add entity and negation constraints to evidence packets","description":"benchmark report shows film-vs-camera and unrelated project bleed failures. add lightweight query/evidence entity boundary checks and negation-aware suppression in klbr-core evidence selection, with deterministic fixtures covering valid answer retention and invalid-query bleed.","status":"closed","priority":1,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-27T13:15:19Z","created_by":"dawn","updated_at":"2026-06-27T13:28:10Z","started_at":"2026-06-27T13:16:10Z","closed_at":"2026-06-27T13:28:10Z","close_reason":"Implemented query constraint pass for fuzzy evidence packets with entity-boundary and negated-term suppression, exact-ref bypass, docs sync, and deterministic core fixtures.","dependency_count":0,"dependent_count":1,"comment_count":0} @@ -16,6 +22,9 @@ {"_type":"issue","id":"klbr-wmz.2","title":"Route runtime passive recall through the canonical memory pipeline","description":"Bench retrieval now goes through MemoryPipeline, but runtime passive recall in klbr-core/src/agent.rs still embeds the prompt, calls MemoryStore::get_searchable, runs retrieval::retrieve_exact over legacy memories, then injects Context memory packets. That bypasses fts, exact refs, markdown notes, graph expansion, and the canonical lane/lifecycle path the docs describe.","design":"Avoid duplicating retrieval logic in agent.rs. Either make MemoryPipeline usable by AgentRuntime or extract a shared retrieval facade that both MemoryPipeline and runtime passive recall call.","acceptance_criteria":"Runtime passive recall uses the same lane-aware canonical retrieval and context packet assembly policy as the production pipeline; recalled packets can include fts/exact/dense/graph candidates from refs and markdown notes; archived/tombstoned/suppressed refs do not leak; klbr-core/src/instructions.md matches the actual memory packet format; tests or a focused integration fixture cover passive recall from a markdown note and from an explicit ref.","notes":"Runtime passive recall now calls MemoryPipeline::retrieve_evidence and injects shared EvidencePacket XML via Context::inject_evidence_packets; no-model runtime packet fixture covers turn-window expansion. Remaining acceptance is blocked on klbr-wmz.1 because dense search still starts from legacy memory rows before ref mapping.","status":"closed","priority":1,"issue_type":"feature","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-26T17:53:19Z","created_by":"dawn","updated_at":"2026-06-26T19:44:55Z","started_at":"2026-06-26T19:37:32Z","closed_at":"2026-06-26T19:44:55Z","close_reason":"Completed: runtime passive recall now uses MemoryPipeline/EvidencePlanner packets, instructions document current packet XML, and fixtures cover turn-window recall plus explicit markdown refs; dense canonical dependency completed in klbr-wmz.1.","labels":["architecture","memory","retrieval","runtime"],"dependencies":[{"issue_id":"klbr-wmz.2","depends_on_id":"klbr-wmz","type":"parent-child","created_at":"2026-06-26T20:53:19Z","created_by":"dawn","metadata":"{}"},{"issue_id":"klbr-wmz.2","depends_on_id":"klbr-wmz.1","type":"blocks","created_at":"2026-06-26T22:37:53Z","created_by":"dawn","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"klbr-wmz.1","title":"Move dense retrieval onto canonical refs and embedding_items","description":"Current source still has dense retrieval on the legacy memory-row surface: klbr-core/src/pipeline.rs search_dense calls MemoryStore::get_searchable and retrieval::retrieve_exact, while fts/exact retrieval uses canonical refs and promptable_text. The schema already has refs, promptable_text, markdown_note_chunks, and embedding_items, so dense retrieval should not be the odd path out.","design":"Prefer a ref-native embedding index backed by embedding_items. Backfill embeddings from promptable_text, keep memory-id aliases as compatibility aliases, and make klbr/full versus dense-only profiles exercise the same canonical identity layer as fts and exact retrieval.","acceptance_criteria":"Dense candidate generation works over canonical ref ids for memories, turn chunks, markdown note chunks, episode notes, profile notes, and procedural notes; lane and lifecycle filtering come from refs/ref_metadata instead of memory tags alone; benchmark traces return canonical refs for dense hits; regression tests cover a markdown-note-only hit and a tombstoned/suppressed ref not leaking through dense search.","status":"closed","priority":1,"issue_type":"feature","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-26T17:53:11Z","created_by":"dawn","updated_at":"2026-06-26T19:43:27Z","started_at":"2026-06-26T19:38:02Z","closed_at":"2026-06-26T19:43:27Z","close_reason":"Completed: dense candidate generation now lazily backfills embedding_items from active promptable refs, scores canonical ref embeddings directly, and tests markdown-note dense hits plus suppressed-ref filtering.","labels":["architecture","memory","refs","retrieval"],"dependencies":[{"issue_id":"klbr-wmz.1","depends_on_id":"klbr-wmz","type":"parent-child","created_at":"2026-06-26T20:53:11Z","created_by":"dawn","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"klbr-wmz","title":"Finish memory architecture follow-through","description":"Tracks the remaining memory architecture work identified from docs/memory-arch.md, docs/memory-benches.md, docs/memory-implementation-status.md, and current klbr-core/klbr-bench source. Current status says pipeline, typed memory packets, markdown notes, edge mirroring, lifecycle projection, and benchmark runner integration exist; this epic is for gaps still present in source/docs.","acceptance_criteria":"Close when the child issues are complete, docs/memory-implementation-status.md is updated from current verification, and the architecture docs no longer point at missing or stale follow-up work.","status":"closed","priority":1,"issue_type":"epic","owner":"90008@klbr.net","created_at":"2026-06-26T17:52:54Z","created_by":"dawn","updated_at":"2026-06-26T23:45:35Z","closed_at":"2026-06-26T23:45:35Z","close_reason":"All 19 memory architecture child issues are closed; docs/status were updated from current verification; remaining official evaluator run is tracked separately as external blocked klbr-1yn.","labels":["architecture","memory"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"klbr-a9b.7","title":"Document and test tree-sitter code tools","description":"Add focused fixtures, regression tests, and docs for the tree-sitter-first code tools. Tests should cover multiple representative languages through the generic registry, symbol extraction, overview/find/read flows, symbolic edits, terse OK/error behavior, and the explicitly skipped diagnostics/project-wide/LSP scope.","acceptance_criteria":"Core tests cover supported generic file-local behavior and failure modes; docs explain parser/query-bundle availability, the first-pass boundary, and future expansion path; the epic's verification commands are recorded after implementation lands.","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-28T10:46:32Z","created_by":"dawn","updated_at":"2026-06-28T11:20:25Z","started_at":"2026-06-28T11:18:27Z","closed_at":"2026-06-28T11:20:25Z","close_reason":"Added code-intel docs, registration coverage, and recorded focused verification commands.","labels":["code-intel","docs","tests","tools","tree-sitter"],"dependencies":[{"issue_id":"klbr-a9b.7","depends_on_id":"klbr-a9b","type":"parent-child","created_at":"2026-06-28T13:46:32Z","created_by":"dawn","metadata":"{}"},{"issue_id":"klbr-a9b.7","depends_on_id":"klbr-a9b.2","type":"blocks","created_at":"2026-06-28T13:46:51Z","created_by":"dawn","metadata":"{}"},{"issue_id":"klbr-a9b.7","depends_on_id":"klbr-a9b.3","type":"blocks","created_at":"2026-06-28T13:46:50Z","created_by":"dawn","metadata":"{}"},{"issue_id":"klbr-a9b.7","depends_on_id":"klbr-a9b.5","type":"blocks","created_at":"2026-06-28T13:46:58Z","created_by":"dawn","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"klbr-a9b.4","title":"Implement read_symbol and range extraction","description":"Add a tool/path for retrieving the exact source body for a single file-local symbol selected by name path. This is the retrieval step agents should use before symbolic replacement, mirroring Serena's expectation that replace_symbol_body follows a prior body read.","acceptance_criteria":"read_symbol returns the selected symbol body and location for fixture languages covered by the generic extractor; ambiguous or missing symbols return concise errors; returned body range is the same range used by replace_symbol_body.","status":"closed","priority":2,"issue_type":"feature","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-28T10:46:31Z","created_by":"dawn","updated_at":"2026-06-28T11:11:25Z","started_at":"2026-06-28T11:09:41Z","closed_at":"2026-06-28T11:11:25Z","close_reason":"Implemented read_symbol using the shared generic tree-sitter symbol matcher and body range extraction.","labels":["code-intel","retrieval","tools","tree-sitter"],"dependencies":[{"issue_id":"klbr-a9b.4","depends_on_id":"klbr-a9b","type":"parent-child","created_at":"2026-06-28T13:46:31Z","created_by":"dawn","metadata":"{}"},{"issue_id":"klbr-a9b.4","depends_on_id":"klbr-a9b.8","type":"blocks","created_at":"2026-06-28T13:46:50Z","created_by":"dawn","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"klbr-a9b.5","title":"Add best-effort file-local references and declaration lookup","description":"Add file-local references/declaration helpers using tree-sitter tags/locals/name matching. This is explicitly best-effort syntax-derived navigation, not typechecker-accurate LSP behavior. Project-wide reference/declaration resolution remains out of scope for the first implementation.","acceptance_criteria":"For fixture files, the tool can find same-file call/reference captures for a selected symbol and jump from a captured reference to a same-file definition when names are unambiguous; ambiguous or unsupported cases return concise best-effort errors.","status":"closed","priority":2,"issue_type":"feature","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-28T10:46:30Z","created_by":"dawn","updated_at":"2026-06-28T11:14:47Z","started_at":"2026-06-28T11:11:41Z","closed_at":"2026-06-28T11:14:47Z","close_reason":"Implemented best-effort same-file find_references and find_definition tools over generic tree-sitter tags.","labels":["code-intel","retrieval","tools","tree-sitter"],"dependencies":[{"issue_id":"klbr-a9b.5","depends_on_id":"klbr-a9b","type":"parent-child","created_at":"2026-06-28T13:46:30Z","created_by":"dawn","metadata":"{}"},{"issue_id":"klbr-a9b.5","depends_on_id":"klbr-a9b.8","type":"blocks","created_at":"2026-06-28T13:46:50Z","created_by":"dawn","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"klbr-u3t.1","title":"Refactor RetrievedRef into EvidenceAtom abstraction","description":"Introduce a clean EvidenceAtom type that represents retrieved query hits (with source, rank, score, lane, and anchor vs neighbor kinds) to decouple candidate generation from raw RetrievedRef representations as specified in docs/principled-evidence-selection-fusion.md.","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-27T17:26:03Z","created_by":"dawn","updated_at":"2026-06-27T17:29:45Z","started_at":"2026-06-27T17:27:03Z","closed_at":"2026-06-27T17:29:45Z","close_reason":"Refactored RetrievedRef into EvidenceAtom with enums for source, entity type, and anchor kind, compiling cleanly across core and bench crates with passing tests.","dependencies":[{"issue_id":"klbr-u3t.1","depends_on_id":"klbr-u3t","type":"parent-child","created_at":"2026-06-27T20:26:02Z","created_by":"dawn","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"klbr-g5i","title":"import official LongMemEval QA evaluator","description":"local LongMemEval answer scoring still uses normalized substring matching. bring in or wrap the official evaluate_qa.py flow so klbr-bench can report upstream QA parity through --official-eval-cmd or a first-class command path.","status":"closed","priority":2,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T13:15:41Z","created_by":"dawn","updated_at":"2026-06-27T13:28:59Z","closed_at":"2026-06-27T13:28:59Z","close_reason":"Duplicate of existing blocked klbr-1yn, which already tracks running the official LongMemEval QA evaluator once the upstream evaluator command/environment is available.","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"klbr-9yo","title":"recalibrate router for tools recall","description":"tools lane report shows router test precision/recall collapsed for gold tools queries even though the prompt fallback sometimes recovered. update router bench sweeps/datasets so toolish abstain behavior is measured explicitly and thresholds optimize tools recall without regressing memory recall.","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-27T13:15:36Z","created_by":"dawn","updated_at":"2026-06-27T14:56:52Z","started_at":"2026-06-27T14:44:50Z","closed_at":"2026-06-27T14:56:52Z","close_reason":"Implemented tool-required router calibration metrics and threshold selection; filed klbr-b4z for regenerating saved router artifacts.","dependency_count":0,"dependent_count":2,"comment_count":0} diff --git a/AGENTS.md b/AGENTS.md index c5ccf16..3f37a34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ memory docs: - [docs/memory-implementation-status.md](file:///home/mayer/proj/klbr/docs/memory-implementation-status.md) — current implementation, verified commands, and open bead ids. - [docs/memory-benches.md](file:///home/mayer/proj/klbr/docs/memory-benches.md) — benchmark protocol and run commands. - [docs/evidence-selection.md](file:///home/mayer/proj/klbr/docs/evidence-selection.md) — evidence packet selection/rerank research report. +- [docs/code-intel-tools.md](file:///home/mayer/proj/klbr/docs/code-intel-tools.md) — tree-sitter-first file-local code retrieval/editing tools. --- @@ -86,7 +87,7 @@ implements [MemoryGarden](file:///home/mayer/proj/klbr/klbr-core/src/garden.rs#L individual tool definitions are split into standalone subfiles. the registry [Subroutines](file:///home/mayer/proj/klbr/klbr-core/src/tools/mod.rs#L82) is configured via [tools/mod.rs](file:///home/mayer/proj/klbr/klbr-core/src/tools/mod.rs): - `memory_tools()` (for reflection): `remember`, `recall`, `context_for`, `fetch_memories`, `memory_provenance`, `write_memory_note`, `edit_memory`, `list_memories`. -- `all_tools()` (adds shell/files): `shell`, `read_file`, `read_media`, `write_file`, `local_send`, `wait_and_continue`, `restart_harness`. +- `all_tools()` (adds shell/files/code): `shell`, `symbol_overview`, `find_symbol`, `find_references`, `find_definition`, `read_symbol`, `replace_symbol_body`, `insert_before_symbol`, `insert_after_symbol`, `read_file`, `read_media`, `write_file`, `local_send`, `wait_and_continue`, `restart_harness`. ### [agent.rs](file:///home/mayer/proj/klbr/klbr-core/src/agent.rs) diff --git a/Cargo.lock b/Cargo.lock index dae8d68..ed2bd28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,26 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -51,7 +71,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tokio", "tracing", "tracing-subscriber", @@ -219,6 +239,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "brotli-decompressor" version = "5.0.0" @@ -315,6 +344,32 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -365,6 +420,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -375,6 +445,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -396,8 +475,19 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] @@ -406,7 +496,16 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", ] [[package]] @@ -417,10 +516,22 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.4.6", "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -490,12 +601,43 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -779,6 +921,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1112,7 +1263,7 @@ dependencies = [ "anyhow", "base64", "chrono", - "dirs", + "dirs 5.0.1", "futures", "kdl", "pulldown-cmark", @@ -1126,6 +1277,8 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "tree-sitter-language-pack", + "tree-sitter-tags", ] [[package]] @@ -1193,6 +1346,16 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libredox" version = "0.1.14" @@ -1274,6 +1437,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -1677,6 +1850,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.12.3" @@ -1996,6 +2180,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -2045,7 +2230,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -2062,7 +2247,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -2090,6 +2286,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simdutf8" version = "0.1.5" @@ -2124,6 +2326,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "sqlite-vec" version = "0.1.9" @@ -2151,6 +2364,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "subtle" version = "2.6.1" @@ -2209,6 +2428,17 @@ dependencies = [ "libc", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2301,6 +2531,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2516,6 +2755,61 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tree-sitter" +version = "0.26.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dab76d0b724ba557954125188cf0633a1ca43199ced82d95c7b9c32cc3de1f3" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-language-pack" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612b125cb189b3d80ff9cf69b6c56552c23df584acc9368058a1a9cc8669a05c" +dependencies = [ + "ahash", + "cc", + "dirs 6.0.0", + "fd-lock", + "libloading", + "memchr", + "serde", + "serde_json", + "sha2 0.11.0", + "tar", + "thiserror 2.0.18", + "tree-sitter", + "ureq", + "zstd", +] + +[[package]] +name = "tree-sitter-tags" +version = "0.26.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba419171ace99ea326903dffc603ce627d6936719812d50cc95ba4377abd9cd1" +dependencies = [ + "memchr", + "regex", + "streaming-iterator", + "thiserror 2.0.18", + "tree-sitter", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -2631,9 +2925,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicase" @@ -2677,6 +2971,37 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -2695,6 +3020,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2904,6 +3235,31 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2913,6 +3269,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -3309,6 +3671,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.2" @@ -3418,6 +3790,15 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + [[package]] name = "zstd-safe" version = "7.2.4" diff --git a/docs/code-intel-tools.md b/docs/code-intel-tools.md new file mode 100644 index 0000000..b1d572a --- /dev/null +++ b/docs/code-intel-tools.md @@ -0,0 +1,56 @@ +# tree-sitter code tools + +klbr has a first-pass semantic code tool layer in `klbr-core/src/code_intel.rs` +and `klbr-core/src/tools/`. it is deliberately file-local and tree-sitter +first. + +## boundary + +- language support comes from `tree-sitter-language-pack`, not hard-coded + rust/typescript/python branches. +- language detection is explicit hint first, then file path, then content. +- symbol extraction uses a grammar's bundled tags query and locals query when + available. +- references and definitions are best-effort syntax navigation over same-file + tags. grammars expose different reference captures, so an empty references + list can be valid for a supported parser. +- there is no lsp, typechecker, project-wide reference index, diagnostics tool, + or edit-time format/check hook in this first implementation. + +## retrieval tools + +- `symbol_overview`: list definitions for one file, with optional reference + tags. +- `find_symbol`: select one symbol by name or file-local name path, optionally + returning its body. +- `read_symbol`: return the selected symbol, source body, and exact range. +- `find_references`: return same-file reference tags whose captured name matches + the selected definition. +- `find_definition`: resolve a name path or reference byte to a same-file + definition when the match is unambiguous. + +## edit tools + +- `replace_symbol_body`: replace the exact range returned for the selected + symbol. +- `insert_before_symbol`: insert text before the selected symbol's source line. +- `insert_after_symbol`: insert text after the selected symbol's source range. + +successful edits return `OK`. failures are concise `error: ...` strings for +missing args, unsupported language/query setup, missing symbols, ambiguous +matches, invalid ranges, or file i/o errors. + +## verification + +focused commands used for this implementation: + +```bash +rtk cargo test -p klbr-core code_intel +rtk cargo test -p klbr-core symbol_overview +rtk cargo test -p klbr-core find_symbol +rtk cargo test -p klbr-core read_symbol +rtk cargo test -p klbr-core find_references +rtk cargo test -p klbr-core find_definition +rtk cargo test -p klbr-core symbol_edit +rtk cargo check -p klbr-core +``` diff --git a/klbr-core/Cargo.toml b/klbr-core/Cargo.toml index 278f647..1c56045 100644 --- a/klbr-core/Cargo.toml +++ b/klbr-core/Cargo.toml @@ -24,5 +24,7 @@ tracing-subscriber = "0.3.23" chrono = "0.4.44" pulldown-cmark = "0.11" regex = "1" +tree-sitter-language-pack = "1.11.0" +tree-sitter-tags = "0.26.9" [dev-dependencies] tempfile = "3" diff --git a/klbr-core/src/code_intel.rs b/klbr-core/src/code_intel.rs new file mode 100644 index 0000000..797b343 --- /dev/null +++ b/klbr-core/src/code_intel.rs @@ -0,0 +1,564 @@ +use anyhow::{anyhow, Result}; +use std::ops::Range; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LanguageDetectionSource { + Explicit, + Path, + Content, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ResolvedLanguage { + pub name: String, + pub source: LanguageDetectionSource, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct QuerySupport { + pub highlights: bool, + pub injections: bool, + pub locals: bool, + pub tags: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ParseStatus { + pub root_kind: String, + pub has_error: bool, + pub error_count: usize, + pub missing_count: usize, + pub node_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ParsedSource { + pub language: ResolvedLanguage, + pub queries: QuerySupport, + pub status: ParseStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SourceRange { + pub start_byte: usize, + pub end_byte: usize, + pub start_line: usize, + pub start_column: usize, + pub end_line: usize, + pub end_column: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SymbolRole { + Definition, + Reference, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct CodeSymbol { + pub name: String, + pub name_path: Vec, + pub kind: String, + pub role: SymbolRole, + pub range: SourceRange, + pub name_range: SourceRange, + pub docs: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SymbolExtraction { + pub language: ResolvedLanguage, + pub queries: QuerySupport, + pub status: ParseStatus, + pub symbols: Vec, +} + +pub fn matching_symbols<'a>( + symbols: &'a [CodeSymbol], + query: &str, + include_references: bool, +) -> Vec<&'a CodeSymbol> { + let candidates = symbols + .iter() + .filter(|symbol| include_references || symbol.role == SymbolRole::Definition) + .collect::>(); + let query = query.trim(); + let exact = candidates + .iter() + .copied() + .filter(|symbol| symbol.name == query || symbol_path(symbol) == query) + .collect::>(); + if !exact.is_empty() { + return exact; + } + + let suffix = candidates + .iter() + .copied() + .filter(|symbol| symbol_path(symbol).ends_with(query)) + .collect::>(); + if !suffix.is_empty() { + return suffix; + } + + candidates + .iter() + .copied() + .filter(|symbol| symbol.name.contains(query) || symbol_path(symbol).contains(query)) + .collect() +} + +pub fn definitions_named<'a>(symbols: &'a [CodeSymbol], name: &str) -> Vec<&'a CodeSymbol> { + let name = name.trim(); + symbols + .iter() + .filter(|symbol| { + symbol.role == SymbolRole::Definition + && (symbol.name == name || symbol_path(symbol) == name) + }) + .collect() +} + +pub fn references_named<'a>(symbols: &'a [CodeSymbol], name: &str) -> Vec<&'a CodeSymbol> { + let name = name.trim(); + symbols + .iter() + .filter(|symbol| symbol.role == SymbolRole::Reference && symbol.name == name) + .collect() +} + +pub fn symbol_at_byte( + symbols: &[CodeSymbol], + byte: usize, + include_references: bool, +) -> Option<&CodeSymbol> { + symbols + .iter() + .filter(|symbol| include_references || symbol.role == SymbolRole::Definition) + .filter(|symbol| symbol.name_range.start_byte <= byte && byte < symbol.name_range.end_byte) + .min_by_key(|symbol| { + symbol + .name_range + .end_byte + .saturating_sub(symbol.name_range.start_byte) + }) +} + +pub fn symbol_path(symbol: &CodeSymbol) -> String { + symbol.name_path.join(".") +} + +pub fn display_range(range: &SourceRange) -> String { + format!( + "{}:{}-{}:{}", + range.start_line + 1, + range.start_column + 1, + range.end_line + 1, + range.end_column + 1 + ) +} + +pub fn format_symbol_choices(symbols: &[&CodeSymbol], limit: usize) -> String { + symbols + .iter() + .take(limit) + .map(|symbol| { + format!( + "{} [{}] {}", + symbol_path(symbol), + symbol.kind, + display_range(&symbol.range) + ) + }) + .collect::>() + .join(", ") +} + +pub fn available_languages() -> Vec { + tree_sitter_language_pack::available_languages() +} + +pub fn detect_language( + path: Option<&str>, + source: &str, + language_hint: Option<&str>, +) -> Result { + if let Some(language) = language_hint.and_then(non_empty) { + return Ok(ResolvedLanguage { + name: language.to_string(), + source: LanguageDetectionSource::Explicit, + }); + } + + if let Some(language) = path + .and_then(non_empty) + .and_then(tree_sitter_language_pack::detect_language_from_path) + { + return Ok(ResolvedLanguage { + name: language.to_string(), + source: LanguageDetectionSource::Path, + }); + } + + if let Some(language) = tree_sitter_language_pack::detect_language_from_content(source) { + return Ok(ResolvedLanguage { + name: language.to_string(), + source: LanguageDetectionSource::Content, + }); + } + + Err(anyhow!( + "unsupported language: could not detect language from path or content" + )) +} + +pub fn query_support(language: &str) -> QuerySupport { + QuerySupport { + highlights: tree_sitter_language_pack::get_highlights_query(language).is_some(), + injections: tree_sitter_language_pack::get_injections_query(language).is_some(), + locals: tree_sitter_language_pack::get_locals_query(language).is_some(), + tags: tree_sitter_language_pack::get_tags_query(language).is_some(), + } +} + +pub fn parse_source( + path: Option<&str>, + source: &str, + language_hint: Option<&str>, +) -> Result { + let language = detect_language(path, source, language_hint)?; + let mut parser = tree_sitter_language_pack::get_parser(&language.name) + .map_err(|err| anyhow!("unsupported language: {} ({err})", language.name))?; + let tree = parser + .parse(source) + .ok_or_else(|| anyhow!("parse failed: {}", language.name))?; + let root = tree.root_node(); + let mut counts = NodeCounts::default(); + count_nodes(&root, &mut counts); + let status = ParseStatus { + root_kind: root.kind(), + has_error: root.has_error(), + error_count: counts.errors, + missing_count: counts.missing, + node_count: counts.total, + }; + + Ok(ParsedSource { + queries: query_support(&language.name), + language, + status, + }) +} + +pub fn extract_symbols( + path: Option<&str>, + source: &str, + language_hint: Option<&str>, +) -> Result { + let parsed = parse_source(path, source, language_hint)?; + let tags_query = match tree_sitter_language_pack::get_tags_query(&parsed.language.name) { + Some(query) => query, + None => { + return Ok(SymbolExtraction { + language: parsed.language, + queries: parsed.queries, + status: parsed.status, + symbols: Vec::new(), + }); + } + }; + let locals_query = + tree_sitter_language_pack::get_locals_query(&parsed.language.name).unwrap_or(""); + let language = tree_sitter_language_pack::get_language(&parsed.language.name) + .map_err(|err| anyhow!("unsupported language: {} ({err})", parsed.language.name))?; + let config = tree_sitter_tags::TagsConfiguration::new(language, tags_query, locals_query) + .map_err(|err| anyhow!("tag query failed: {} ({err})", parsed.language.name))?; + let mut context = tree_sitter_tags::TagsContext::new(); + let (tags, has_error) = context + .generate_tags(&config, source.as_bytes(), None) + .map_err(|err| anyhow!("tag extraction failed: {} ({err})", parsed.language.name))?; + let mut symbols = tags + .map(|tag| tag.map_err(|err| anyhow!("tag extraction failed: {err}"))) + .collect::>>()? + .into_iter() + .filter_map(|tag| code_symbol_from_tag(source, &config, tag)) + .collect::>(); + attach_definition_paths(&mut symbols); + symbols.sort_by(|left, right| { + left.range + .start_byte + .cmp(&right.range.start_byte) + .then_with(|| left.name.cmp(&right.name)) + }); + + let mut status = parsed.status; + status.has_error |= has_error; + Ok(SymbolExtraction { + language: parsed.language, + queries: parsed.queries, + status, + symbols, + }) +} + +fn non_empty(value: &str) -> Option<&str> { + let trimmed = value.trim(); + (!trimmed.is_empty()).then_some(trimmed) +} + +#[derive(Default)] +struct NodeCounts { + total: usize, + errors: usize, + missing: usize, +} + +fn count_nodes(node: &tree_sitter_language_pack::Node, counts: &mut NodeCounts) { + counts.total += 1; + if node.is_error() { + counts.errors += 1; + } + if node.is_missing() { + counts.missing += 1; + } + for index in 0..node.child_count() { + if let Some(child) = node.child(index as u32) { + count_nodes(&child, counts); + } + } +} + +fn code_symbol_from_tag( + source: &str, + config: &tree_sitter_tags::TagsConfiguration, + tag: tree_sitter_tags::Tag, +) -> Option { + let name = source.get(tag.name_range.clone())?.to_string(); + if name.trim().is_empty() { + return None; + } + Some(CodeSymbol { + name: name.clone(), + name_path: vec![name], + kind: config.syntax_type_name(tag.syntax_type_id).to_string(), + role: if tag.is_definition { + SymbolRole::Definition + } else { + SymbolRole::Reference + }, + range: source_range(source, tag.range), + name_range: source_range(source, tag.name_range), + docs: tag.docs, + }) +} + +fn attach_definition_paths(symbols: &mut [CodeSymbol]) { + let definitions = symbols + .iter() + .enumerate() + .filter(|(_, symbol)| symbol.role == SymbolRole::Definition) + .map(|(index, symbol)| (index, symbol.range.start_byte, symbol.range.end_byte)) + .collect::>(); + + for index in 0..symbols.len() { + let start = symbols[index].range.start_byte; + let end = symbols[index].range.end_byte; + let mut ancestors = definitions + .iter() + .filter(|(definition_index, def_start, def_end)| { + *definition_index != index && *def_start <= start && end <= *def_end + }) + .copied() + .collect::>(); + ancestors.sort_by(|left, right| { + let left_width = left.2.saturating_sub(left.1); + let right_width = right.2.saturating_sub(right.1); + right_width.cmp(&left_width) + }); + + let mut path = ancestors + .into_iter() + .map(|(ancestor_index, _, _)| symbols[ancestor_index].name.clone()) + .collect::>(); + path.push(symbols[index].name.clone()); + symbols[index].name_path = path; + } +} + +fn source_range(source: &str, range: Range) -> SourceRange { + let start = byte_point(source, range.start); + let end = byte_point(source, range.end); + SourceRange { + start_byte: range.start, + end_byte: range.end, + start_line: start.0, + start_column: start.1, + end_line: end.0, + end_column: end.1, + } +} + +fn byte_point(source: &str, byte: usize) -> (usize, usize) { + let byte = byte.min(source.len()); + let prefix = &source[..byte]; + match prefix.rfind('\n') { + Some(newline) => ( + prefix.as_bytes().iter().filter(|&&b| b == b'\n').count(), + byte - newline - 1, + ), + None => (0, byte), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_language_from_path_without_language_specific_branching() { + let rust = detect_language(Some("src/lib.rs"), "", None).unwrap(); + let python = detect_language(Some("tool.py"), "", None).unwrap(); + + assert_eq!(rust.name, "rust"); + assert_eq!(rust.source, LanguageDetectionSource::Path); + assert_eq!(python.name, "python"); + assert_eq!(python.source, LanguageDetectionSource::Path); + } + + #[test] + fn detects_language_from_shebang_when_path_is_unhelpful() { + let detected = detect_language( + Some("script"), + "#!/usr/bin/env python3\nprint('hello')\n", + None, + ) + .unwrap(); + + assert_eq!(detected.name, "python"); + assert_eq!(detected.source, LanguageDetectionSource::Content); + } + + #[test] + fn explicit_language_hint_wins_over_path() { + let detected = + detect_language(Some("not-python.py"), "fn main() {}", Some("rust")).unwrap(); + + assert_eq!(detected.name, "rust"); + assert_eq!(detected.source, LanguageDetectionSource::Explicit); + } + + #[test] + fn parses_supported_source_and_reports_status() { + let parsed = parse_source(Some("main.py"), "def hello():\n return 1\n", None).unwrap(); + + assert_eq!(parsed.language.name, "python"); + assert!(!parsed.status.has_error); + assert_eq!(parsed.status.error_count, 0); + assert!(parsed.status.node_count > 0); + } + + #[test] + fn parse_status_reports_tree_sitter_errors() { + let parsed = parse_source(Some("bad.py"), "def (((\n", None).unwrap(); + + assert_eq!(parsed.language.name, "python"); + assert!(parsed.status.has_error); + assert!(parsed.status.error_count > 0 || parsed.status.missing_count > 0); + } + + #[test] + fn extracts_file_local_symbols_from_tags_query() { + let extracted = extract_symbols( + Some("demo.py"), + "class Widget:\n def render(self):\n return helper()\n\ndef helper():\n return 1\n", + None, + ) + .unwrap(); + + assert_eq!(extracted.language.name, "python"); + assert!(extracted.queries.tags); + assert!(extracted.symbols.iter().any(|symbol| { + symbol.role == SymbolRole::Definition + && symbol.kind == "class" + && symbol.name == "Widget" + })); + assert!(extracted.symbols.iter().any(|symbol| { + symbol.role == SymbolRole::Definition + && symbol.kind == "function" + && symbol.name == "helper" + })); + } + + #[test] + fn extracted_symbols_include_containment_name_paths() { + let extracted = extract_symbols( + Some("demo.py"), + "class Widget:\n def render(self):\n return 1\n", + None, + ) + .unwrap(); + + let render = extracted + .symbols + .iter() + .find(|symbol| { + symbol.role == SymbolRole::Definition + && symbol.kind == "function" + && symbol.name == "render" + }) + .expect("nested function symbol should exist"); + + assert_eq!( + render.name_path, + vec!["Widget".to_string(), "render".to_string()] + ); + } + + #[test] + fn matching_symbols_prefers_exact_then_name_path_suffix() { + let extracted = extract_symbols( + Some("demo.py"), + "def render():\n return 1\nclass Widget:\n def render(self):\n return 2\n", + None, + ) + .unwrap(); + + let ambiguous = matching_symbols(&extracted.symbols, "render", false); + assert_eq!(ambiguous.len(), 2); + + let nested = matching_symbols(&extracted.symbols, "Widget.render", false); + assert_eq!(nested.len(), 1); + assert_eq!(symbol_path(nested[0]), "Widget.render"); + } + + #[test] + fn finds_references_and_definition_by_name_without_language_branches() { + let source = "fn helper() -> i32 { 1 }\nfn caller() -> i32 { helper() }\n"; + let extracted = extract_symbols(Some("demo.rs"), source, None).unwrap(); + + let definitions = definitions_named(&extracted.symbols, "helper"); + let references = references_named(&extracted.symbols, "helper"); + + assert_eq!(definitions.len(), 1); + assert_eq!(references.len(), 1); + assert_eq!(references[0].role, SymbolRole::Reference); + } + + #[test] + fn finds_symbol_at_reference_byte() { + let source = "fn helper() -> i32 { 1 }\nfn caller() -> i32 { helper() }\n"; + let extracted = extract_symbols(Some("demo.rs"), source, None).unwrap(); + let reference = references_named(&extracted.symbols, "helper") + .into_iter() + .next() + .expect("reference should exist"); + + let found = symbol_at_byte(&extracted.symbols, reference.name_range.start_byte, true) + .expect("reference should be found at its start byte"); + + assert_eq!(found.name, "helper"); + assert_eq!(found.role, SymbolRole::Reference); + } +} diff --git a/klbr-core/src/lib.rs b/klbr-core/src/lib.rs index e590b06..4bc0b12 100644 --- a/klbr-core/src/lib.rs +++ b/klbr-core/src/lib.rs @@ -1,4 +1,5 @@ pub mod agent; +pub mod code_intel; pub mod config; pub mod context; pub mod evidence; diff --git a/klbr-core/src/tools/find_definition.rs b/klbr-core/src/tools/find_definition.rs new file mode 100644 index 0000000..cdc2236 --- /dev/null +++ b/klbr-core/src/tools/find_definition.rs @@ -0,0 +1,157 @@ +use std::future::Future; +use std::pin::Pin; + +use serde_json::json; + +use crate::code_intel::{ + definitions_named, extract_symbols, format_symbol_choices, symbol_at_byte, +}; +use crate::models::ToolDef; + +use super::{Tool, ToolContext}; + +pub fn tool() -> Tool { + Tool::new(definition(), exec) +} + +fn definition() -> ToolDef { + ToolDef::function( + "find_definition", + "find a best-effort same-file tree-sitter definition for a name or reference byte", + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "absolute or relative path to the file" + }, + "name_path": { + "type": "string", + "description": "symbol name or file-local path" + }, + "byte": { + "type": "integer", + "description": "byte offset inside a tree-sitter symbol name" + }, + "language": { + "type": "string", + "description": "optional tree-sitter language name override" + } + }, + "required": ["path"] + }), + ) +} + +fn exec( + args: serde_json::Value, + _ctx: ToolContext, +) -> Pin + Send>> { + Box::pin(execute(args)) +} + +async fn execute(args: serde_json::Value) -> String { + let path = match args["path"].as_str() { + Some(path) => path, + None => return "error: missing required arg 'path'".into(), + }; + let name_path = args["name_path"] + .as_str() + .map(str::trim) + .filter(|name_path| !name_path.is_empty()); + let byte = args["byte"].as_u64().map(|byte| byte as usize); + let language = args["language"].as_str(); + + let source = match tokio::fs::read_to_string(path).await { + Ok(source) => source, + Err(err) => return format!("error: {err}"), + }; + + definition_from_source(path, &source, language, name_path, byte) +} + +fn definition_from_source( + path: &str, + source: &str, + language: Option<&str>, + name_path: Option<&str>, + byte: Option, +) -> String { + let extraction = match extract_symbols(Some(path), source, language) { + Ok(extraction) => extraction, + Err(err) => return format!("error: {err}"), + }; + let source_symbol = match byte { + Some(byte) => match symbol_at_byte(&extraction.symbols, byte, true) { + Some(symbol) => Some(symbol), + None => return format!("error: no symbol at byte {byte}"), + }, + None => None, + }; + let lookup = match source_symbol { + Some(symbol) => symbol.name.as_str(), + None => match name_path { + Some(name_path) => name_path, + None => return "error: missing required arg 'name_path' or 'byte'".into(), + }, + }; + + let definitions = definitions_named(&extraction.symbols, lookup); + if definitions.is_empty() { + return format!("error: definition not found: {lookup}"); + } + if definitions.len() > 1 { + let choices = format_symbol_choices(&definitions, 12); + return format!("error: ambiguous definition: {choices}"); + } + + serde_json::to_string(&json!({ + "language": extraction.language, + "queries": extraction.queries, + "status": extraction.status, + "source_symbol": source_symbol, + "definition": definitions[0], + })) + .unwrap_or_else(|err| format!("error: {err}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::code_intel::{extract_symbols, references_named}; + + #[test] + fn find_definition_from_reference_byte() { + let source = "fn helper() -> i32 { 1 }\nfn caller() -> i32 { helper() }\n"; + let extracted = extract_symbols(Some("demo.rs"), source, None).unwrap(); + let reference = references_named(&extracted.symbols, "helper") + .into_iter() + .next() + .expect("reference should exist"); + + let output = definition_from_source( + "demo.rs", + source, + None, + None, + Some(reference.name_range.start_byte), + ); + + assert!(output.contains("\"source_symbol\"")); + assert!(output.contains("\"definition\"")); + assert!(output.contains("\"role\":\"definition\"")); + } + + #[test] + fn find_definition_reports_ambiguous_name() { + let output = definition_from_source( + "demo.rs", + "fn helper() -> i32 { 1 }\nimpl Thing { fn helper(&self) {} }\n", + None, + Some("helper"), + None, + ); + + assert!(output.starts_with("error: ambiguous definition:")); + } +} diff --git a/klbr-core/src/tools/find_references.rs b/klbr-core/src/tools/find_references.rs new file mode 100644 index 0000000..02ae238 --- /dev/null +++ b/klbr-core/src/tools/find_references.rs @@ -0,0 +1,131 @@ +use std::future::Future; +use std::pin::Pin; + +use serde_json::json; + +use crate::code_intel::{ + extract_symbols, format_symbol_choices, matching_symbols, references_named, +}; +use crate::models::ToolDef; + +use super::{Tool, ToolContext}; + +pub fn tool() -> Tool { + Tool::new(definition(), exec) +} + +fn definition() -> ToolDef { + ToolDef::function( + "find_references", + "find best-effort same-file tree-sitter reference tags for one symbol", + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "absolute or relative path to the file" + }, + "name_path": { + "type": "string", + "description": "definition symbol name or file-local path" + }, + "language": { + "type": "string", + "description": "optional tree-sitter language name override" + } + }, + "required": ["path", "name_path"] + }), + ) +} + +fn exec( + args: serde_json::Value, + _ctx: ToolContext, +) -> Pin + Send>> { + Box::pin(execute(args)) +} + +async fn execute(args: serde_json::Value) -> String { + let path = match args["path"].as_str() { + Some(path) => path, + None => return "error: missing required arg 'path'".into(), + }; + let name_path = match args["name_path"].as_str().map(str::trim) { + Some(name_path) if !name_path.is_empty() => name_path, + None => return "error: missing required arg 'name_path'".into(), + Some(_) => return "error: missing required arg 'name_path'".into(), + }; + let language = args["language"].as_str(); + + let source = match tokio::fs::read_to_string(path).await { + Ok(source) => source, + Err(err) => return format!("error: {err}"), + }; + + references_from_source(path, &source, language, name_path) +} + +fn references_from_source( + path: &str, + source: &str, + language: Option<&str>, + name_path: &str, +) -> String { + let extraction = match extract_symbols(Some(path), source, language) { + Ok(extraction) => extraction, + Err(err) => return format!("error: {err}"), + }; + let matches = matching_symbols(&extraction.symbols, name_path, false); + if matches.is_empty() { + return format!("error: symbol not found: {name_path}"); + } + if matches.len() > 1 { + let choices = format_symbol_choices(&matches, 12); + return format!("error: ambiguous symbol: {choices}"); + } + + let target = matches[0]; + let references = references_named(&extraction.symbols, &target.name); + + serde_json::to_string(&json!({ + "language": extraction.language, + "queries": extraction.queries, + "status": extraction.status, + "target": target, + "references": references, + })) + .unwrap_or_else(|err| format!("error: {err}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn find_references_returns_same_file_reference_tags() { + let output = references_from_source( + "demo.rs", + "fn helper() -> i32 { 1 }\nfn caller() -> i32 { helper() }\n", + None, + "helper", + ); + + assert!(output.contains("\"target\"")); + assert!(output.contains("\"references\"")); + assert!(output.contains("\"role\":\"reference\"")); + assert!(output.contains("\"kind\":\"call\"")); + } + + #[test] + fn find_references_reports_ambiguous_target() { + let output = references_from_source( + "demo.rs", + "fn helper() -> i32 { 1 }\nimpl Thing { fn helper(&self) {} }\n", + None, + "helper", + ); + + assert!(output.starts_with("error: ambiguous symbol:")); + } +} diff --git a/klbr-core/src/tools/find_symbol.rs b/klbr-core/src/tools/find_symbol.rs new file mode 100644 index 0000000..cf71d4e --- /dev/null +++ b/klbr-core/src/tools/find_symbol.rs @@ -0,0 +1,156 @@ +use std::future::Future; +use std::pin::Pin; + +use serde_json::json; + +use crate::code_intel::{extract_symbols, format_symbol_choices, matching_symbols}; +use crate::models::ToolDef; + +use super::{Tool, ToolContext}; + +pub fn tool() -> Tool { + Tool::new(definition(), exec) +} + +fn definition() -> ToolDef { + ToolDef::function( + "find_symbol", + "find one file-local code symbol by name or name path using tree-sitter", + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "absolute or relative path to the file" + }, + "name_path": { + "type": "string", + "description": "symbol name or file-local path, e.g. Widget.render" + }, + "language": { + "type": "string", + "description": "optional tree-sitter language name override" + }, + "include_body": { + "type": "boolean", + "description": "include the matched symbol source range text (default false)" + }, + "include_references": { + "type": "boolean", + "description": "allow reference matches as well as definitions (default false)" + } + }, + "required": ["path", "name_path"] + }), + ) +} + +fn exec( + args: serde_json::Value, + _ctx: ToolContext, +) -> Pin + Send>> { + Box::pin(execute(args)) +} + +async fn execute(args: serde_json::Value) -> String { + let path = match args["path"].as_str() { + Some(path) => path, + None => return "error: missing required arg 'path'".into(), + }; + let name_path = match args["name_path"].as_str().map(str::trim) { + Some(name_path) if !name_path.is_empty() => name_path, + None => return "error: missing required arg 'name_path'".into(), + Some(_) => return "error: missing required arg 'name_path'".into(), + }; + let language = args["language"].as_str(); + let include_body = args["include_body"].as_bool().unwrap_or(false); + let include_references = args["include_references"].as_bool().unwrap_or(false); + + let source = match tokio::fs::read_to_string(path).await { + Ok(source) => source, + Err(err) => return format!("error: {err}"), + }; + + find_in_source( + path, + &source, + language, + name_path, + include_body, + include_references, + ) +} + +fn find_in_source( + path: &str, + source: &str, + language: Option<&str>, + name_path: &str, + include_body: bool, + include_references: bool, +) -> String { + let extraction = match extract_symbols(Some(path), source, language) { + Ok(extraction) => extraction, + Err(err) => return format!("error: {err}"), + }; + let matches = matching_symbols(&extraction.symbols, name_path, include_references); + if matches.is_empty() { + return format!("error: symbol not found: {name_path}"); + } + if matches.len() > 1 { + let choices = format_symbol_choices(&matches, 12); + return format!("error: ambiguous symbol: {choices}"); + } + + let symbol = matches[0]; + let body = if include_body { + source + .get(symbol.range.start_byte..symbol.range.end_byte) + .map(str::to_string) + } else { + None + }; + + serde_json::to_string(&json!({ + "language": extraction.language, + "status": extraction.status, + "symbol": symbol, + "body": body, + })) + .unwrap_or_else(|err| format!("error: {err}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn find_symbol_returns_one_match_with_body() { + let output = find_in_source( + "demo.py", + "class Widget:\n def render(self):\n return 1\n", + None, + "Widget.render", + true, + false, + ); + + assert!(output.contains("\"name\":\"render\"")); + assert!(output.contains("\"body\":\"def render")); + } + + #[test] + fn find_symbol_reports_ambiguity() { + let output = find_in_source( + "demo.py", + "def render():\n return 1\nclass Widget:\n def render(self):\n return 2\n", + None, + "render", + false, + false, + ); + + assert!(output.starts_with("error: ambiguous symbol:")); + assert!(output.contains("Widget.render")); + } +} diff --git a/klbr-core/src/tools/mod.rs b/klbr-core/src/tools/mod.rs index 69bc1d1..80b355f 100644 --- a/klbr-core/src/tools/mod.rs +++ b/klbr-core/src/tools/mod.rs @@ -1,15 +1,21 @@ mod context_for; mod edit_memory; mod fetch_memories; +mod find_definition; +mod find_references; +mod find_symbol; mod list_memories; mod local_send; mod memory_provenance; mod read_file; mod read_media; +mod read_symbol; mod recall; mod remember; mod restart_harness; mod shell; +mod symbol_edit; +mod symbol_overview; mod wait_and_continue; mod write_file; mod write_memory_note; @@ -153,6 +159,14 @@ pub fn memory_tools() -> Subroutines { pub fn all_tools() -> Subroutines { let mut tools = vec![ shell::tool(), + symbol_overview::tool(), + find_symbol::tool(), + find_references::tool(), + find_definition::tool(), + read_symbol::tool(), + symbol_edit::replace_tool(), + symbol_edit::insert_before_tool(), + symbol_edit::insert_after_tool(), read_file::tool(), read_media::tool(), write_file::tool(), @@ -214,4 +228,26 @@ mod tests { assert_eq!(names, vec!["first", "second", "third"]); } + + #[test] + fn all_tools_registers_tree_sitter_code_tools() { + let names = all_tools() + .definitions() + .into_iter() + .map(|def| def.function.name) + .collect::>(); + + for expected in [ + "symbol_overview", + "find_symbol", + "find_references", + "find_definition", + "read_symbol", + "replace_symbol_body", + "insert_before_symbol", + "insert_after_symbol", + ] { + assert!(names.iter().any(|name| name == expected), "{expected}"); + } + } } diff --git a/klbr-core/src/tools/read_symbol.rs b/klbr-core/src/tools/read_symbol.rs new file mode 100644 index 0000000..0edc426 --- /dev/null +++ b/klbr-core/src/tools/read_symbol.rs @@ -0,0 +1,140 @@ +use std::future::Future; +use std::pin::Pin; + +use serde_json::json; + +use crate::code_intel::{extract_symbols, format_symbol_choices, matching_symbols}; +use crate::models::ToolDef; + +use super::{Tool, ToolContext}; + +pub fn tool() -> Tool { + Tool::new(definition(), exec) +} + +fn definition() -> ToolDef { + ToolDef::function( + "read_symbol", + "read the exact source body for one file-local tree-sitter symbol", + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "absolute or relative path to the file" + }, + "name_path": { + "type": "string", + "description": "symbol name or file-local path" + }, + "language": { + "type": "string", + "description": "optional tree-sitter language name override" + }, + "include_references": { + "type": "boolean", + "description": "allow reference matches as well as definitions (default false)" + } + }, + "required": ["path", "name_path"] + }), + ) +} + +fn exec( + args: serde_json::Value, + _ctx: ToolContext, +) -> Pin + Send>> { + Box::pin(execute(args)) +} + +async fn execute(args: serde_json::Value) -> String { + let path = match args["path"].as_str() { + Some(path) => path, + None => return "error: missing required arg 'path'".into(), + }; + let name_path = match args["name_path"].as_str().map(str::trim) { + Some(name_path) if !name_path.is_empty() => name_path, + None => return "error: missing required arg 'name_path'".into(), + Some(_) => return "error: missing required arg 'name_path'".into(), + }; + let language = args["language"].as_str(); + let include_references = args["include_references"].as_bool().unwrap_or(false); + + let source = match tokio::fs::read_to_string(path).await { + Ok(source) => source, + Err(err) => return format!("error: {err}"), + }; + + read_from_source(path, &source, language, name_path, include_references) +} + +fn read_from_source( + path: &str, + source: &str, + language: Option<&str>, + name_path: &str, + include_references: bool, +) -> String { + let extraction = match extract_symbols(Some(path), source, language) { + Ok(extraction) => extraction, + Err(err) => return format!("error: {err}"), + }; + let matches = matching_symbols(&extraction.symbols, name_path, include_references); + if matches.is_empty() { + return format!("error: symbol not found: {name_path}"); + } + if matches.len() > 1 { + let choices = format_symbol_choices(&matches, 12); + return format!("error: ambiguous symbol: {choices}"); + } + + let symbol = matches[0]; + let body = match source.get(symbol.range.start_byte..symbol.range.end_byte) { + Some(body) => body, + None => return "error: symbol range is not a utf-8 boundary".into(), + }; + + serde_json::to_string(&json!({ + "language": extraction.language, + "queries": extraction.queries, + "status": extraction.status, + "symbol": symbol, + "body": body, + })) + .unwrap_or_else(|err| format!("error: {err}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_symbol_returns_body_and_range() { + let output = read_from_source( + "demo.py", + "class Widget:\n def render(self):\n return 1\n", + None, + "Widget.render", + false, + ); + + assert!(output.contains("\"name\":\"render\"")); + assert!(output.contains("\"body\":\"def render")); + assert!(output.contains("\"start_byte\"")); + assert!(output.contains("\"end_byte\"")); + } + + #[test] + fn read_symbol_reports_missing_symbol() { + let output = read_from_source( + "demo.py", + "class Widget:\n pass\n", + None, + "Widget.render", + false, + ); + + assert_eq!(output, "error: symbol not found: Widget.render"); + } +} diff --git a/klbr-core/src/tools/symbol_edit.rs b/klbr-core/src/tools/symbol_edit.rs new file mode 100644 index 0000000..7d68116 --- /dev/null +++ b/klbr-core/src/tools/symbol_edit.rs @@ -0,0 +1,394 @@ +use std::future::Future; +use std::pin::Pin; + +use serde_json::json; + +use crate::code_intel::{extract_symbols, format_symbol_choices, matching_symbols, SourceRange}; +use crate::models::ToolDef; + +use super::{Tool, ToolContext}; + +enum SymbolEdit { + Replace { body: String }, + InsertBefore { text: String }, + InsertAfter { text: String }, +} + +pub fn replace_tool() -> Tool { + Tool::new(replace_definition(), replace_exec) +} + +pub fn insert_before_tool() -> Tool { + Tool::new(insert_before_definition(), insert_before_exec) +} + +pub fn insert_after_tool() -> Tool { + Tool::new(insert_after_definition(), insert_after_exec) +} + +fn replace_definition() -> ToolDef { + ToolDef::function( + "replace_symbol_body", + "replace the exact source range for one file-local tree-sitter symbol", + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "absolute or relative path to the file" + }, + "name_path": { + "type": "string", + "description": "symbol name or file-local path" + }, + "body": { + "type": "string", + "description": "replacement source body" + }, + "language": { + "type": "string", + "description": "optional tree-sitter language name override" + } + }, + "required": ["path", "name_path", "body"] + }), + ) +} + +fn insert_before_definition() -> ToolDef { + ToolDef::function( + "insert_before_symbol", + "insert source text immediately before one file-local tree-sitter symbol", + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "absolute or relative path to the file" + }, + "name_path": { + "type": "string", + "description": "symbol name or file-local path" + }, + "text": { + "type": "string", + "description": "source text to insert" + }, + "language": { + "type": "string", + "description": "optional tree-sitter language name override" + } + }, + "required": ["path", "name_path", "text"] + }), + ) +} + +fn insert_after_definition() -> ToolDef { + ToolDef::function( + "insert_after_symbol", + "insert source text immediately after one file-local tree-sitter symbol", + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "absolute or relative path to the file" + }, + "name_path": { + "type": "string", + "description": "symbol name or file-local path" + }, + "text": { + "type": "string", + "description": "source text to insert" + }, + "language": { + "type": "string", + "description": "optional tree-sitter language name override" + } + }, + "required": ["path", "name_path", "text"] + }), + ) +} + +fn replace_exec( + args: serde_json::Value, + _ctx: ToolContext, +) -> Pin + Send>> { + Box::pin(async move { + let body = match args["body"].as_str() { + Some(body) => body.to_string(), + None => return "error: missing required arg 'body'".into(), + }; + execute_edit(args, SymbolEdit::Replace { body }).await + }) +} + +fn insert_before_exec( + args: serde_json::Value, + _ctx: ToolContext, +) -> Pin + Send>> { + Box::pin(async move { + let text = match args["text"].as_str() { + Some(text) => text.to_string(), + None => return "error: missing required arg 'text'".into(), + }; + execute_edit(args, SymbolEdit::InsertBefore { text }).await + }) +} + +fn insert_after_exec( + args: serde_json::Value, + _ctx: ToolContext, +) -> Pin + Send>> { + Box::pin(async move { + let text = match args["text"].as_str() { + Some(text) => text.to_string(), + None => return "error: missing required arg 'text'".into(), + }; + execute_edit(args, SymbolEdit::InsertAfter { text }).await + }) +} + +async fn execute_edit(args: serde_json::Value, edit: SymbolEdit) -> String { + let path = match args["path"].as_str() { + Some(path) => path, + None => return "error: missing required arg 'path'".into(), + }; + let name_path = match args["name_path"].as_str().map(str::trim) { + Some(name_path) if !name_path.is_empty() => name_path, + None => return "error: missing required arg 'name_path'".into(), + Some(_) => return "error: missing required arg 'name_path'".into(), + }; + let language = args["language"].as_str(); + + let source = match tokio::fs::read_to_string(path).await { + Ok(source) => source, + Err(err) => return format!("error: {err}"), + }; + let updated = match edit_source(path, &source, language, name_path, edit) { + Ok(updated) => updated, + Err(err) => return err, + }; + match tokio::fs::write(path, updated).await { + Ok(()) => "OK".into(), + Err(err) => format!("error: {err}"), + } +} + +fn edit_source( + path: &str, + source: &str, + language: Option<&str>, + name_path: &str, + edit: SymbolEdit, +) -> Result { + let extraction = + extract_symbols(Some(path), source, language).map_err(|err| format!("error: {err}"))?; + let matches = matching_symbols(&extraction.symbols, name_path, false); + if matches.is_empty() { + return Err(format!("error: symbol not found: {name_path}")); + } + if matches.len() > 1 { + let choices = format_symbol_choices(&matches, 12); + return Err(format!("error: ambiguous symbol: {choices}")); + } + + let range = &matches[0].range; + validate_range(source, range)?; + Ok(match edit { + SymbolEdit::Replace { body } => replace_range(source, range, &body), + SymbolEdit::InsertBefore { text } => insert_before_range(source, range, &text), + SymbolEdit::InsertAfter { text } => insert_after_range(source, range, &text), + }) +} + +fn validate_range(source: &str, range: &SourceRange) -> Result<(), String> { + if range.start_byte > range.end_byte || range.end_byte > source.len() { + return Err("error: invalid symbol range".into()); + } + if !source.is_char_boundary(range.start_byte) || !source.is_char_boundary(range.end_byte) { + return Err("error: symbol range is not a utf-8 boundary".into()); + } + Ok(()) +} + +fn replace_range(source: &str, range: &SourceRange, body: &str) -> String { + let mut updated = String::with_capacity(source.len() - range_len(range) + body.len()); + updated.push_str(&source[..range.start_byte]); + updated.push_str(body); + updated.push_str(&source[range.end_byte..]); + updated +} + +fn insert_before_range(source: &str, range: &SourceRange, text: &str) -> String { + let line_start = line_start(source, range.start_byte); + let indent = symbol_indent(source, line_start, range.start_byte); + let block = insertion_block(text, indent); + let mut updated = String::with_capacity(source.len() + block.len()); + updated.push_str(&source[..line_start]); + updated.push_str(&block); + updated.push_str(&source[line_start..]); + updated +} + +fn insert_after_range(source: &str, range: &SourceRange, text: &str) -> String { + let line_start = line_start(source, range.start_byte); + let indent = symbol_indent(source, line_start, range.start_byte); + let insert_at = line_end_after(source, range.end_byte); + let mut block = insertion_block(text, indent); + if insert_at == source.len() && !source.ends_with('\n') { + block.insert(0, '\n'); + } + + let mut updated = String::with_capacity(source.len() + block.len()); + updated.push_str(&source[..insert_at]); + updated.push_str(&block); + updated.push_str(&source[insert_at..]); + updated +} + +fn insertion_block(text: &str, indent: &str) -> String { + let mut block = if should_apply_indent(text, indent) { + indent_lines(text, indent) + } else { + text.to_string() + }; + if !block.ends_with('\n') { + block.push('\n'); + } + block +} + +fn should_apply_indent(text: &str, indent: &str) -> bool { + !indent.is_empty() + && text + .lines() + .find(|line| !line.trim().is_empty()) + .is_some_and(|line| !line.starts_with(indent)) +} + +fn indent_lines(text: &str, indent: &str) -> String { + let mut indented = text + .lines() + .map(|line| { + if line.is_empty() { + String::new() + } else { + format!("{indent}{line}") + } + }) + .collect::>() + .join("\n"); + if text.ends_with('\n') { + indented.push('\n'); + } + indented +} + +fn line_start(source: &str, byte: usize) -> usize { + source[..byte] + .rfind('\n') + .map(|newline| newline + 1) + .unwrap_or(0) +} + +fn line_end_after(source: &str, byte: usize) -> usize { + source[byte..] + .find('\n') + .map(|offset| byte + offset + 1) + .unwrap_or(source.len()) +} + +fn symbol_indent<'a>(source: &'a str, line_start: usize, symbol_start: usize) -> &'a str { + let prefix = &source[line_start..symbol_start]; + if prefix.chars().all(|ch| ch == ' ' || ch == '\t') { + prefix + } else { + "" + } +} + +fn range_len(range: &SourceRange) -> usize { + range.end_byte.saturating_sub(range.start_byte) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replace_symbol_body_replaces_exact_range() { + let updated = edit_source( + "demo.py", + "class Widget:\n def render(self):\n return 1\n", + None, + "Widget.render", + SymbolEdit::Replace { + body: "def render(self):\n return 2".into(), + }, + ) + .unwrap(); + + assert_eq!( + updated, + "class Widget:\n def render(self):\n return 2\n" + ); + } + + #[test] + fn insert_before_symbol_preserves_symbol_indentation() { + let updated = edit_source( + "demo.py", + "class Widget:\n def render(self):\n return 1\n", + None, + "Widget.render", + SymbolEdit::InsertBefore { + text: "def setup(self):\n return 0".into(), + }, + ) + .unwrap(); + + assert_eq!( + updated, + "class Widget:\n def setup(self):\n return 0\n def render(self):\n return 1\n" + ); + } + + #[test] + fn insert_after_symbol_preserves_symbol_indentation() { + let updated = edit_source( + "demo.py", + "class Widget:\n def render(self):\n return 1\n", + None, + "Widget.render", + SymbolEdit::InsertAfter { + text: "def finish(self):\n return 2".into(), + }, + ) + .unwrap(); + + assert_eq!( + updated, + "class Widget:\n def render(self):\n return 1\n def finish(self):\n return 2\n" + ); + } + + #[test] + fn edit_reports_ambiguous_symbol() { + let error = edit_source( + "demo.py", + "def render():\n return 1\nclass Widget:\n def render(self):\n return 2\n", + None, + "render", + SymbolEdit::Replace { + body: "def render():\n return 3".into(), + }, + ) + .unwrap_err(); + + assert!(error.starts_with("error: ambiguous symbol:")); + } +} diff --git a/klbr-core/src/tools/symbol_overview.rs b/klbr-core/src/tools/symbol_overview.rs new file mode 100644 index 0000000..404904b --- /dev/null +++ b/klbr-core/src/tools/symbol_overview.rs @@ -0,0 +1,113 @@ +use std::future::Future; +use std::pin::Pin; + +use serde_json::json; + +use crate::code_intel::{extract_symbols, SymbolRole}; +use crate::models::ToolDef; + +use super::{Tool, ToolContext}; + +pub fn tool() -> Tool { + Tool::new(definition(), exec) +} + +fn definition() -> ToolDef { + ToolDef::function( + "symbol_overview", + "list file-local code symbols from tree-sitter for one source file", + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "absolute or relative path to the file" + }, + "language": { + "type": "string", + "description": "optional tree-sitter language name override" + }, + "include_references": { + "type": "boolean", + "description": "include reference tags as well as definitions (default false)" + }, + "limit": { + "type": "integer", + "description": "maximum symbols to return (default 80, max 300)" + } + }, + "required": ["path"] + }), + ) +} + +fn exec( + args: serde_json::Value, + _ctx: ToolContext, +) -> Pin + Send>> { + Box::pin(execute(args)) +} + +async fn execute(args: serde_json::Value) -> String { + let path = match args["path"].as_str() { + Some(path) => path, + None => return "error: missing required arg 'path'".into(), + }; + let language = args["language"].as_str(); + let include_references = args["include_references"].as_bool().unwrap_or(false); + let limit = args["limit"].as_u64().unwrap_or(80).clamp(1, 300) as usize; + + let source = match tokio::fs::read_to_string(path).await { + Ok(source) => source, + Err(err) => return format!("error: {err}"), + }; + + overview_from_source(path, &source, language, include_references, limit) +} + +fn overview_from_source( + path: &str, + source: &str, + language: Option<&str>, + include_references: bool, + limit: usize, +) -> String { + let mut extraction = match extract_symbols(Some(path), source, language) { + Ok(extraction) => extraction, + Err(err) => return format!("error: {err}"), + }; + if !include_references { + extraction + .symbols + .retain(|symbol| symbol.role == SymbolRole::Definition); + } + extraction.symbols.truncate(limit); + + serde_json::to_string(&json!({ + "language": extraction.language, + "queries": extraction.queries, + "status": extraction.status, + "symbols": extraction.symbols, + })) + .unwrap_or_else(|err| format!("error: {err}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overview_returns_definition_symbols() { + let output = overview_from_source( + "demo.py", + "class Widget:\n def render(self):\n return 1\n", + None, + false, + 20, + ); + + assert!(output.contains("\"name\":\"Widget\"")); + assert!(output.contains("\"name\":\"render\"")); + assert!(!output.contains("\"role\":\"reference\"")); + } +}