handoff-mcp-server 0.24.6 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.lock CHANGED
@@ -145,7 +145,7 @@ dependencies = [
145
145
 
146
146
  [[package]]
147
147
  name = "handoff-mcp"
148
- version = "0.24.6"
148
+ version = "0.25.0"
149
149
  dependencies = [
150
150
  "anyhow",
151
151
  "chrono",
@@ -153,6 +153,7 @@ dependencies = [
153
153
  "pulldown-cmark",
154
154
  "serde",
155
155
  "serde_json",
156
+ "serde_yaml",
156
157
  "tempfile",
157
158
  "thiserror",
158
159
  "toml",
@@ -218,9 +219,9 @@ dependencies = [
218
219
 
219
220
  [[package]]
220
221
  name = "lexsim"
221
- version = "0.6.0"
222
+ version = "0.7.0"
222
223
  source = "registry+https://github.com/rust-lang/crates.io-index"
223
- checksum = "b64f48de8c9d203c0ed3ac8c68e9ba1072206d5072551c08889a4bbae9d84677"
224
+ checksum = "c7bf6d7f096edac1f6d1f955d41d2acbe238b5775da9c7f4039fdd975138a5d1"
224
225
  dependencies = [
225
226
  "unicode-normalization",
226
227
  "unicode-segmentation",
@@ -333,6 +334,12 @@ version = "1.0.23"
333
334
  source = "registry+https://github.com/rust-lang/crates.io-index"
334
335
  checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
335
336
 
337
+ [[package]]
338
+ name = "ryu"
339
+ version = "1.0.23"
340
+ source = "registry+https://github.com/rust-lang/crates.io-index"
341
+ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
342
+
336
343
  [[package]]
337
344
  name = "serde"
338
345
  version = "1.0.228"
@@ -386,6 +393,19 @@ dependencies = [
386
393
  "serde",
387
394
  ]
388
395
 
396
+ [[package]]
397
+ name = "serde_yaml"
398
+ version = "0.9.34+deprecated"
399
+ source = "registry+https://github.com/rust-lang/crates.io-index"
400
+ checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
401
+ dependencies = [
402
+ "indexmap",
403
+ "itoa",
404
+ "ryu",
405
+ "serde",
406
+ "unsafe-libyaml",
407
+ ]
408
+
389
409
  [[package]]
390
410
  name = "shlex"
391
411
  version = "2.0.1"
@@ -531,6 +551,12 @@ version = "0.2.2"
531
551
  source = "registry+https://github.com/rust-lang/crates.io-index"
532
552
  checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
533
553
 
554
+ [[package]]
555
+ name = "unsafe-libyaml"
556
+ version = "0.2.11"
557
+ source = "registry+https://github.com/rust-lang/crates.io-index"
558
+ checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
559
+
534
560
  [[package]]
535
561
  name = "wasm-bindgen"
536
562
  version = "0.2.126"
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.24.6"
3
+ version = "0.25.0"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
@@ -20,8 +20,10 @@ toml_edit = "0.22"
20
20
  chrono = { version = "0.4", features = ["serde"] }
21
21
  anyhow = "1"
22
22
  thiserror = "2"
23
- lexsim = ">=0.1.0, <1.0.0"
23
+ lexsim = ">=0.7.0, <1.0.0"
24
24
  pulldown-cmark = "0.13"
25
+ serde_yaml = "0.9"
25
26
 
26
27
  [dev-dependencies]
27
28
  tempfile = "3.27.0"
29
+
package/README.md CHANGED
@@ -452,21 +452,29 @@ See [Project Memory](#project-memory-1) below for what it is and how to wire aut
452
452
 
453
453
  | Tool | Purpose |
454
454
  |------|---------|
455
- | `handoff_doc_save` | Create or update a document (auto-splits into fragments) |
456
- | `handoff_doc_get` | Read a document — full, meta, or single fragment |
455
+ | `handoff_doc_save` | Create or update a document (auto-splits into sections) |
456
+ | `handoff_doc_get` | Read a document — full, meta, or single section |
457
457
  | `handoff_doc_list` | List/search documents with BM25 and filters |
458
- | `handoff_doc_delete` | Delete a document and its fragments |
459
- | `handoff_doc_reassemble` | Reconstruct original Markdown from fragments |
458
+ | `handoff_doc_delete` | Delete a document; unlinks from tasks |
459
+ | `handoff_doc_reassemble` | Reconstruct original Markdown from sections, with drift detection |
460
+ | `handoff_doc_update_section` | Replace a single section by seq (optimistic locking) |
460
461
  | `handoff_doc_tree` | Walk family tree (ancestors/descendants/related) |
462
+ | `handoff_doc_graph` | Visualize inter-document relationships with optional verification status |
463
+ | `handoff_doc_trace` | Trace a document's lineage or dependency chain |
461
464
  | `handoff_doc_query` | Context injection — staged full/outline, hook-driven |
465
+ | `handoff_doc_verify` | Verification matrix: generate, check, check_all, skip, sync, set_refs |
466
+ | `handoff_doc_verify_status` | Verification progress summary with optional per-section details |
462
467
  | `handoff_doc_analyze` | Read-only heuristic scan (import step 1) |
463
468
  | `handoff_doc_import` | Atomic bulk write after analysis (import step 3) |
464
469
 
465
- Documents live in `.handoff/docs/` (`_doc.*.json` metadata + `_frag.*.{json,md}`
466
- fragments). Large Markdown is split into fragments on save; `handoff_doc_reassemble`
467
- reconstructs the original with drift detection, and `handoff_doc_query` feeds
468
- staged (outline-first, then full-text) context to the agent — the same mechanism
469
- that powers the hook-driven injection described below.
470
+ Documents live in `.handoff/docs/` as single `_doc.<slug>.md` files (YAML
471
+ frontmatter + body). Large Markdown is split into sections on save;
472
+ `handoff_doc_reassemble` reconstructs the original with drift detection, and
473
+ `handoff_doc_query` feeds staged (outline-first, then full-text) context to
474
+ the agent — the same mechanism that powers the hook-driven injection described
475
+ below. `handoff_doc_verify` provides a verification matrix for tracking
476
+ per-section review status, implementation/test references, and staleness
477
+ detection after spec changes.
470
478
 
471
479
  ### Task Data Model
472
480
 
@@ -823,10 +831,15 @@ This project uses handoff-mcp for session continuity.
823
831
  When the VSCode extension is running, the timer delegates to it automatically.
824
832
  When the extension is absent, MCP runs a fallback timer and logs hours on stop.
825
833
  Use `handoff_timer_get_time` to check elapsed time without stopping.
834
+ - **Spec registration**: if the task has a spec or design document,
835
+ register it via `handoff_doc_save(task_ids=[...])` so it survives across
836
+ sessions and links bidirectionally to the task. Generate a verification
837
+ matrix with `handoff_doc_verify(action="generate")` for review tracking.
826
838
  - **Project memory**: Use `handoff_memory_save` to record durable lessons, rules,
827
839
  conventions, and gotchas that every future session should know. Use
828
840
  `handoff_memory_query` to retrieve relevant memories. Near-duplicate memories are
829
841
  surfaced as conflicts for you to merge or force-save — never merged silently.
842
+ Save **as you learn them** during work, not at session end.
830
843
  ```
831
844
 
832
845
  ## Skills
@@ -837,6 +850,7 @@ This repository includes skill files that make handoff behavior automatic in Cla
837
850
  |-------|---------|
838
851
  | `handoff` | Core session lifecycle, task management, metrics, scheduling |
839
852
  | `handoff-load` | Quick session-start procedure |
853
+ | `handoff-docs` | Document management — save, search, verify, import, family tree |
840
854
  | `handoff-memory` | Memory CRUD, conflict handling, cleanup |
841
855
  | `handoff-refer` | Cross-project referrals |
842
856
  | `handoff-import` | Bulk import from documents |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.24.6",
3
+ "version": "0.25.0",
4
4
  "description": "MCP server that gives AI coding agents persistent memory across sessions",
5
5
  "license": "MIT",
6
6
  "author": "AlphaElements <66808803+alphaelements@users.noreply.github.com>",
@@ -60,17 +60,21 @@ Check readiness:
60
60
  - MCP automatically computes a section index at each h2 heading boundary
61
61
  - Use `doc_get(format="section", seq=N)` to retrieve individual sections on demand
62
62
 
63
- ## The 9 Doc Tools
63
+ ## The 13 Doc Tools
64
64
 
65
65
  | Tool | Purpose |
66
66
  |---|---|
67
- | `handoff_doc_save` | Create or update a document. Splits `body` into fragments automatically. |
68
- | `handoff_doc_get` | Read a document — `full` (reassembled body), `meta` (manifest only), or `fragment` (one section). |
69
- | `handoff_doc_list` | List/search documents (BM25 over title + fragment bodies), filter by `doc_type`, `tags`, `task_id`. |
70
- | `handoff_doc_delete` | Delete a document and all its fragments; unlinks it from any linked tasks. |
71
- | `handoff_doc_reassemble` | Reconstruct the original Markdown from fragments, with drift detection. |
67
+ | `handoff_doc_save` | Create or update a document. Splits `body` into sections automatically. |
68
+ | `handoff_doc_get` | Read a document — `full` (reassembled body), `meta` (manifest only), or `section` (one section by seq). |
69
+ | `handoff_doc_list` | List/search documents (BM25 over title + section bodies), filter by `doc_type`, `tags`, `task_id`. |
70
+ | `handoff_doc_delete` | Delete a document; unlinks it from any linked tasks. |
71
+ | `handoff_doc_reassemble` | Reconstruct the original Markdown from sections, with drift detection. |
72
+ | `handoff_doc_update_section` | Replace a single section's content by seq (optimistic locking via `expected_hash`). |
72
73
  | `handoff_doc_tree` | Walk the family tree (ancestors/descendants/related) for a document. |
74
+ | `handoff_doc_graph` | Visualize inter-document relationships; optionally includes verification status per node. |
75
+ | `handoff_doc_trace` | Trace a document's lineage or dependency chain. |
73
76
  | `handoff_doc_query` | Context injection — hook-driven, staged `full`/`outline` results ranked by relevance. |
77
+ | `handoff_doc_verify` | Verification matrix operations: `generate`, `check`, `check_all`, `skip`, `sync`, `set_refs`, `add_item` (v2 — freeform items / sub_items), `suggest_refs` (scan scope_paths for impl/test ref candidates). |
74
78
  | `handoff_doc_analyze` | Read-only heuristic scan of a file or directory — step 1 of the import flow. |
75
79
  | `handoff_doc_import` | Atomic bulk write of analyzed + AI-reviewed documents — step 3 of the import flow. |
76
80
 
@@ -133,6 +137,19 @@ preserved — `save(body) → reassemble()` is byte-identical. If a fragment was
133
137
  edited directly after the split, its `content_hash` no longer matches and
134
138
  `reassemble` reports the drift instead of silently returning stale content.
135
139
 
140
+ ### `handoff_doc_update_section`
141
+
142
+ | Param | Required | Description |
143
+ |---|---|---|
144
+ | `doc_id` | yes | Document to update |
145
+ | `seq` | yes | Section sequence number to replace |
146
+ | `new_content` | yes | New Markdown content for the section (empty string deletes it) |
147
+ | `expected_hash` | no | Optimistic lock — if set, the update fails when the section's current `content_hash` differs (returns the current hash so you can retry) |
148
+
149
+ Replaces a single section's content without rewriting the entire document.
150
+ The section's `content_hash` is recomputed after the update, and any
151
+ verification matrix item for this seq is marked stale.
152
+
136
153
  ### `handoff_doc_tree`
137
154
 
138
155
  | Param | Required | Description |
@@ -141,6 +158,20 @@ edited directly after the split, its `content_hash` no longer matches and
141
158
  | `depth` | no | How many parent/child levels to return |
142
159
  | `include_related` | no | Whether to also include semantically `related` documents |
143
160
 
161
+ ### `handoff_doc_graph`
162
+
163
+ | Param | Required | Description |
164
+ |---|---|---|
165
+ | `doc_id` | no | Focus on a specific document and its neighbors |
166
+ | `include_verification` | no | Include `{total, verified}` verification progress per node |
167
+
168
+ ### `handoff_doc_trace`
169
+
170
+ | Param | Required | Description |
171
+ |---|---|---|
172
+ | `doc_id` | yes | Document to trace from |
173
+ | `direction` | no | `"up"` (ancestors) or `"down"` (descendants), default both |
174
+
144
175
  ### `handoff_doc_query`
145
176
 
146
177
  | Param | Required | Description |
@@ -177,9 +208,150 @@ each carrying a concrete `suggestion` the AI can approve, edit, or reject.
177
208
  | `overrides` | no | Per-file corrections (`doc_type`, relationship resolutions, etc.) |
178
209
  | `task_ids` | no | Link every imported document to these tasks |
179
210
 
180
- Writes all `_doc.*.json` + `_frag.*.{json,md}` atomically in one transaction,
181
- including any task links — matches the "validate whole tree, then write"
182
- pattern used by `handoff_import_context` for tasks.
211
+ Writes all documents atomically in one transaction, including any task links
212
+ — matches the "validate whole tree, then write" pattern used by
213
+ `handoff_import_context` for tasks.
214
+
215
+ ### `handoff_doc_verify`
216
+
217
+ | Param | Required | Description |
218
+ |---|---|---|
219
+ | `doc_id` | yes | Document whose verification matrix to operate on |
220
+ | `action` | yes | One of: `generate`, `check`, `check_all`, `skip`, `sync`, `set_refs`, `add_item`, `suggest_refs` |
221
+ | `fragment_seq` | for `check`/`skip`/`set_refs`/`add_item` | Section seq to operate on (integer or array of integers for batch). For `add_item`, omit to add a freeform top-level item instead of a section sub_item. |
222
+ | `sub_item_index` | no | For `check`/`skip`: the 0-based `SubItem.index` within `fragment_seq`'s `sub_items` to operate on, instead of the parent item itself (v2) |
223
+ | `description` | for `add_item` when `fragment_seq` given | The new sub_item's description (v2) |
224
+ | `label` | for `add_item` when `fragment_seq` omitted | The new freeform top-level item's label (v2) |
225
+ | `category` | no | For `add_item`: item/sub_item category — `"requirement"` (default for sub_items), `"visual"`, `"regression"`, `"manual"`, ... free-extensible (v2) |
226
+ | `skip_seqs` | for `generate` | Seqs to mark `skipped` on generation (e.g. `[0]` to skip the preamble) |
227
+ | `reviewer` | no | `"ai"` or `"user"` — who performed the review |
228
+ | `notes` | no | Free-text notes attached to the check |
229
+ | `impl_refs` | for `set_refs` | Array of `{ path, lines?, label? }` — implementation locations |
230
+ | `test_refs` | for `set_refs` | Array of `{ path, lines?, label? }` — test locations |
231
+
232
+ **Actions:**
233
+
234
+ | Action | What it does |
235
+ |---|---|
236
+ | `generate` | Create a new verification matrix from the document's sections. Errors if a matrix already exists (use `sync` to update). |
237
+ | `check` | Mark one or more sections (or, with `sub_item_index`, a single sub_item) as `verified`. Records `verified_at` and `content_hash_at_verify`. |
238
+ | `check_all` | Mark every section — and every sub_item (v2) — in the matrix as `verified` in one call. |
239
+ | `skip` | Mark a section (or, with `sub_item_index`, a single sub_item) as `skipped` (not applicable for review). |
240
+ | `sync` | Re-synchronize the matrix after sections changed (added/removed). Preserves existing item statuses; freeform items (v2) are never dropped. |
241
+ | `set_refs` | Attach `impl_refs` / `test_refs` to a section item. |
242
+ | `add_item` (v2) | With `fragment_seq`: append a `SubItem` (individual requirement) to that section's `sub_items` — `description` required. Without `fragment_seq`: append a freeform top-level item not tied to any section (e.g. a GUI check or regression test) — `label` required. |
243
+ | `suggest_refs` | Read-only. Scans the document's `scope_paths` for source/test files (`.rs`/`.ts`/`.tsx`/`.py`/`.go`/`.js`/`.jsx`) and fuzzy-matches `fn`/`struct`/`impl`/`mod` definitions and test functions (`#[test]`, `fn test_*`, files under `tests/`) against each item's heading, returning up to 20 `impl_refs`/`test_refs` candidates per item for review. Requires an existing matrix (`generate` first). Does not mutate the document — accept candidates by passing them to `set_refs`. |
244
+
245
+ ### `handoff_doc_verify_status`
246
+
247
+ | Param | Required | Description |
248
+ |---|---|---|
249
+ | `doc_id` | yes | Document to query |
250
+ | `include_items` | no | `true` to include per-section item details (default `false` — summary only) |
251
+ | `format` | no | `"json"` (default) or `"checklist"` (v2 — Markdown checklist rendering, see below) |
252
+
253
+ Returns verification progress: `{ verification_status, progress: { checked, skipped, pending, total, stale, percentage } }`.
254
+ When `include_items: true`, also returns an `items` array with each section's
255
+ status, staleness flag, refs, reviewer, and notes (v2: including `category`,
256
+ `sub_items`, and `label` for freeform items). v2 progress counts are
257
+ leaf-based: an item with `sub_items` contributes its sub_items to
258
+ `checked`/`skipped`/`pending`/`total` instead of itself, and freeform items
259
+ (`fragment_seq: null`) are counted directly.
260
+
261
+ #### `format="checklist"` (v2)
262
+
263
+ `handoff_doc_verify_status(doc_id=..., include_items=true, format="checklist")`
264
+ returns a Markdown checklist instead of JSON — useful for pasting into a PR
265
+ description or presenting readiness to a human reviewer:
266
+
267
+ ```markdown
268
+ # Verification: Document Title
269
+ Status: in_review (5/16, 31%)
270
+
271
+ ## §2 1. Requirements ✓ verified ⚠ stale
272
+ - impl: src/storage/docs/mod.rs:42-180 (DocStore)
273
+ - test: tests/doc_save.rs (roundtrip test)
274
+ - [ ] Shape must be an octahedron
275
+ - [x] Color matches status (@ai, 2026-07-11)
276
+
277
+ ## — Drag-and-drop visual check ○ pending [visual]
278
+ ## — No layout regressions in the existing task list ○ pending [regression]
279
+ ```
280
+
281
+ ## Verification Workflow
282
+
283
+ ### When to use each action
284
+
285
+ | Situation | What to do |
286
+ |---|---|
287
+ | Spec just saved | `generate` (optionally with `skip_seqs: [0]` to skip the preamble) |
288
+ | Implementation complete for a section | `check(fragment_seq=N, reviewer="ai")` + `set_refs` |
289
+ | Section is background/context only | `skip(fragment_seq=N)` |
290
+ | Spec was updated after matrix existed | `sync` to add/remove items, then re-check stale items |
291
+ | GUI/visual check needed | `check(fragment_seq=N, reviewer="user")` — user confirms manually |
292
+ | A section has multiple distinct requirements to track individually | `add_item(fragment_seq=N, description=...)` per requirement, then `check(fragment_seq=N, sub_item_index=I)` on each |
293
+ | A check doesn't map to any single section (GUI/regression/manual sweep) | `add_item(label=..., category="visual"\|"regression"\|"manual")` (freeform, no `fragment_seq`), then `check(fragment_seq=<its seq>)` |
294
+ | Human-readable readiness summary (PR description, review handoff) | `doc_verify_status(include_items=true, format="checklist")` — Markdown checklist |
295
+ | Quick release readiness | `doc_verify_status` — check `verification_status == "verified"` |
296
+ | Don't want to hunt for impl/test locations by hand | `suggest_refs` to get candidates per item, review them, then `set_refs(fragment_seq=N, impl_refs=..., test_refs=...)` with the ones you accept |
297
+
298
+ ### `reviewer` guidelines
299
+
300
+ | Reviewer | When |
301
+ |---|---|
302
+ | `"ai"` | AI verified by reading the code, running tests, or comparing spec vs implementation |
303
+ | `"user"` | User verified visually (GUI, layout, drag behavior) or confirmed a judgment call |
304
+
305
+ ### Stale detection and response
306
+
307
+ When a section's content changes after being verified, `doc_verify_status`
308
+ flags it as `stale: true`. Response flow:
309
+
310
+ 1. Check `doc_verify_status(include_items: true)` — look for `stale` items
311
+ 2. Review the changed section: `doc_get(format="section", seq=N)`
312
+ 3. If still valid: `check(fragment_seq=N)` to re-verify (updates `content_hash_at_verify`)
313
+ 4. If invalid: update implementation, then re-verify
314
+
315
+ ### Multi-document release verification
316
+
317
+ For release readiness across multiple specs:
318
+
319
+ ```
320
+ 1. handoff_doc_list(doc_type="spec", tags=["release-target"])
321
+ 2. For each doc: handoff_doc_verify_status(doc_id=...)
322
+ 3. All docs must have verification_status == "verified" and stale == 0
323
+ ```
324
+
325
+ ### E2E workflow example
326
+
327
+ ```
328
+ # 1. Write and save the spec
329
+ handoff_doc_save(slug="auth-spec", title="Authentication Spec",
330
+ body="# Auth Spec\n\n## Requirements\n...",
331
+ doc_type="spec", task_ids=["t42"])
332
+
333
+ # 2. Generate verification matrix (skip preamble)
334
+ handoff_doc_verify(doc_id="doc-...", action="generate", skip_seqs=[0])
335
+
336
+ # 3. Implement, then mark sections verified
337
+ handoff_doc_verify(doc_id="doc-...", action="check", fragment_seq=1,
338
+ reviewer="ai", notes="Implemented and tested")
339
+ handoff_doc_verify(doc_id="doc-...", action="set_refs", fragment_seq=1,
340
+ impl_refs=[{path: "src/auth.rs", lines: "10-50"}],
341
+ test_refs=[{path: "tests/auth.rs", label: "login flow"}])
342
+
343
+ # 3b. Or let suggest_refs propose candidates instead of hand-picking them —
344
+ # requires scope_paths to be set on the document (doc_save(scope_paths=[...]))
345
+ handoff_doc_verify(doc_id="doc-...", action="suggest_refs")
346
+ # → { suggestions: [{ fragment_seq: 1, heading: "Requirements",
347
+ # suggested_impl_refs: [{path: "src/auth.rs", lines: "12", label: "handle_login"}],
348
+ # suggested_test_refs: [{path: "tests/auth.rs", lines: "5", label: "test_login_flow"}] }] }
349
+ # Review the candidates, then accept the ones you want via set_refs (same as 3).
350
+
351
+ # 4. Check release readiness
352
+ handoff_doc_verify_status(doc_id="doc-...", include_items=true)
353
+ # → verification_status: "verified", stale: 0 → ready to ship
354
+ ```
183
355
 
184
356
  ## Staged Injection (outline vs full)
185
357
 
@@ -20,6 +20,9 @@ pub struct RankItem {
20
20
  pub struct RankConfig {
21
21
  /// Candidates scoring below this are dropped before sorting.
22
22
  pub min_score: f64,
23
+ /// Relative threshold (0.0–1.0): after ranking, a candidate is dropped
24
+ /// unless `score >= top_score * relative_threshold`. 0.0 disables.
25
+ pub relative_threshold: f64,
23
26
  /// Added to a candidate's BM25 score when [`scope_matches`] is true.
24
27
  pub scope_path_bonus: f64,
25
28
  /// Max number of items returned (applied after sort, before session diff).
@@ -37,10 +40,10 @@ pub fn scope_matches(scopes: &[String], files: &[String]) -> bool {
37
40
  .any(|scope| files.iter().any(|f| f.contains(scope.as_str())))
38
41
  }
39
42
 
40
- /// Rank every document in `corpus` against `query_tokens` via BM25, add
41
- /// `config.scope_path_bonus` when `scope_paths[i]` matches `file_paths`, drop
42
- /// anything below `config.min_score`, sort descending by score, and truncate
43
- /// to `config.limit`.
43
+ /// Rank every document in `corpus` against `query_tokens` via weighted BM25,
44
+ /// add `config.scope_path_bonus` when `scope_paths[i]` matches `file_paths`,
45
+ /// drop anything below `config.min_score`, sort descending by score, and
46
+ /// truncate to `config.limit`.
44
47
  ///
45
48
  /// `scope_paths` and the corpus must be index-aligned (one entry per
46
49
  /// document); `corpus.len()` and `scope_paths.len()` are expected to match —
@@ -48,12 +51,12 @@ pub fn scope_matches(scopes: &[String], files: &[String]) -> bool {
48
51
  /// consulted (indices beyond `corpus.len()` are not produced).
49
52
  pub fn rank_by_bm25_and_scope(
50
53
  corpus: &lexsim::Corpus,
51
- query_tokens: &[String],
54
+ query_tokens: &[lexsim::WeightedToken],
52
55
  scope_paths: &[Vec<String>],
53
56
  file_paths: &[String],
54
57
  config: &RankConfig,
55
58
  ) -> Vec<RankItem> {
56
- let scores = corpus.bm25_scores_tokens(query_tokens);
59
+ let scores = corpus.bm25_scores_weighted_tokens(query_tokens);
57
60
 
58
61
  let mut ranked: Vec<RankItem> = scores
59
62
  .into_iter()
@@ -66,7 +69,7 @@ pub fn rank_by_bm25_and_scope(
66
69
  }
67
70
  RankItem { index, score }
68
71
  })
69
- .filter(|item| item.score >= config.min_score)
72
+ .filter(|item| item.score > 0.0 && item.score >= config.min_score)
70
73
  .collect();
71
74
 
72
75
  ranked.sort_by(|a, b| {
@@ -74,6 +77,16 @@ pub fn rank_by_bm25_and_scope(
74
77
  .partial_cmp(&a.score)
75
78
  .unwrap_or(std::cmp::Ordering::Equal)
76
79
  });
80
+
81
+ // Relative threshold: drop candidates whose score is below a fraction of
82
+ // the top hit. Applied after sorting so `ranked[0]` is the best match.
83
+ if config.relative_threshold > 0.0 {
84
+ if let Some(top) = ranked.first() {
85
+ let floor = top.score * config.relative_threshold;
86
+ ranked.retain(|item| item.score >= floor);
87
+ }
88
+ }
89
+
77
90
  ranked.truncate(config.limit);
78
91
  ranked
79
92
  }
@@ -114,6 +127,7 @@ mod tests {
114
127
  fn default_config() -> RankConfig {
115
128
  RankConfig {
116
129
  min_score: 0.0,
130
+ relative_threshold: 0.0,
117
131
  scope_path_bonus: 2.0,
118
132
  limit: 10,
119
133
  }
@@ -136,27 +150,25 @@ mod tests {
136
150
 
137
151
  #[test]
138
152
  fn rank_by_bm25_orders_relevant_docs_first() {
139
- let corpus = lexsim::Corpus::build(&docs());
140
- let query_tokens = lexsim::tokenize("rust ownership");
153
+ let corpus = lexsim::Corpus::build_weighted(&docs());
154
+ let query_tokens = lexsim::tokenize_weighted("rust ownership");
141
155
  let scope_paths: Vec<Vec<String>> = vec![vec![], vec![], vec![]];
142
156
  let ranked =
143
157
  rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &default_config());
144
158
 
145
159
  assert!(!ranked.is_empty());
146
- // Doc 2 mentions "rust" and "ownership" twice — should rank first.
147
160
  assert_eq!(ranked[0].index, 2);
148
161
  }
149
162
 
150
163
  #[test]
151
164
  fn rank_by_bm25_applies_scope_path_bonus() {
152
- let corpus = lexsim::Corpus::build(&docs());
153
- // Query barely mentions doc 1's content, but its scope matches the
154
- // queried file path, so the bonus should promote it into the results.
155
- let query_tokens = lexsim::tokenize("javascript");
165
+ let corpus = lexsim::Corpus::build_weighted(&docs());
166
+ let query_tokens = lexsim::tokenize_weighted("javascript");
156
167
  let scope_paths: Vec<Vec<String>> = vec![vec![], vec!["src/web/".to_string()], vec![]];
157
168
  let file_paths = vec!["/repo/src/web/app.js".to_string()];
158
169
  let config = RankConfig {
159
170
  min_score: 0.0,
171
+ relative_threshold: 0.0,
160
172
  scope_path_bonus: 2.0,
161
173
  limit: 10,
162
174
  };
@@ -168,11 +180,12 @@ mod tests {
168
180
 
169
181
  #[test]
170
182
  fn rank_by_bm25_filters_below_min_score() {
171
- let corpus = lexsim::Corpus::build(&docs());
172
- let query_tokens = lexsim::tokenize("completely unrelated gibberish zzz");
183
+ let corpus = lexsim::Corpus::build_weighted(&docs());
184
+ let query_tokens = lexsim::tokenize_weighted("completely unrelated gibberish zzz");
173
185
  let scope_paths: Vec<Vec<String>> = vec![vec![], vec![], vec![]];
174
186
  let config = RankConfig {
175
187
  min_score: 0.01,
188
+ relative_threshold: 0.0,
176
189
  scope_path_bonus: 2.0,
177
190
  limit: 10,
178
191
  };
@@ -182,11 +195,12 @@ mod tests {
182
195
 
183
196
  #[test]
184
197
  fn rank_by_bm25_respects_limit() {
185
- let corpus = lexsim::Corpus::build(&docs());
186
- let query_tokens = lexsim::tokenize("rust javascript");
198
+ let corpus = lexsim::Corpus::build_weighted(&docs());
199
+ let query_tokens = lexsim::tokenize_weighted("rust javascript");
187
200
  let scope_paths: Vec<Vec<String>> = vec![vec![], vec![], vec![]];
188
201
  let config = RankConfig {
189
202
  min_score: 0.0,
203
+ relative_threshold: 0.0,
190
204
  scope_path_bonus: 2.0,
191
205
  limit: 1,
192
206
  };
@@ -194,6 +208,29 @@ mod tests {
194
208
  assert_eq!(ranked.len(), 1);
195
209
  }
196
210
 
211
+ #[test]
212
+ fn rank_by_bm25_applies_relative_threshold() {
213
+ let corpus = lexsim::Corpus::build_weighted(&docs());
214
+ let query_tokens = lexsim::tokenize_weighted("rust ownership");
215
+ let scope_paths: Vec<Vec<String>> = vec![vec![], vec![], vec![]];
216
+ let all =
217
+ rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &default_config());
218
+ assert!(all.len() >= 2, "need at least 2 results to test relative");
219
+ let config_rel = RankConfig {
220
+ min_score: 0.0,
221
+ relative_threshold: 0.95,
222
+ scope_path_bonus: 0.0,
223
+ limit: 10,
224
+ };
225
+ let filtered =
226
+ rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &config_rel);
227
+ assert!(
228
+ filtered.len() < all.len(),
229
+ "relative threshold should drop low-scoring tail"
230
+ );
231
+ assert_eq!(filtered[0].index, all[0].index, "top hit must survive");
232
+ }
233
+
197
234
  #[test]
198
235
  fn filter_already_injected_drops_marked_and_respects_limit() {
199
236
  let ranked = vec![
@@ -54,7 +54,7 @@ impl CorpusCache {
54
54
  pub fn get_or_build_corpus(&mut self, doc_texts: &[String]) -> &lexsim::Corpus {
55
55
  let stale = self.built_generation != Some(self.generation) || self.doc_texts != doc_texts;
56
56
  if stale {
57
- self.corpus = Some(lexsim::Corpus::build(doc_texts));
57
+ self.corpus = Some(lexsim::Corpus::build_weighted(doc_texts));
58
58
  self.doc_texts = doc_texts.to_vec();
59
59
  self.built_generation = Some(self.generation);
60
60
  }
@@ -6,6 +6,7 @@ use serde_json::{json, Value};
6
6
  use toml_edit::DocumentMut;
7
7
 
8
8
  use super::resolve_project_dir;
9
+ use crate::storage::config::weekday_to_num;
9
10
  use crate::storage::ensure_handoff_exists;
10
11
  use crate::storage::tasks::*;
11
12
 
@@ -486,16 +487,3 @@ fn parse_assignee_calendars(config_path: &std::path::Path) -> Result<HashMap<Str
486
487
 
487
488
  Ok(result)
488
489
  }
489
-
490
- fn weekday_to_num(s: &str) -> Option<u32> {
491
- match s.to_lowercase().as_str() {
492
- "sun" | "sunday" => Some(0),
493
- "mon" | "monday" => Some(1),
494
- "tue" | "tuesday" => Some(2),
495
- "wed" | "wednesday" => Some(3),
496
- "thu" | "thursday" => Some(4),
497
- "fri" | "friday" => Some(5),
498
- "sat" | "saturday" => Some(6),
499
- _ => None,
500
- }
501
- }
@@ -4,7 +4,7 @@ use serde_json::{json, Value};
4
4
  use toml_edit::DocumentMut;
5
5
 
6
6
  use super::resolve_project_dir;
7
- use crate::storage::config::read_config;
7
+ use crate::storage::config::{read_config, weekday_to_num};
8
8
  use crate::storage::ensure_handoff_exists;
9
9
  use crate::storage::tasks::{build_task_index, is_terminal_status, TaskIndex};
10
10
 
@@ -245,19 +245,6 @@ fn parse_calendar(config_path: &std::path::Path, assignee: Option<&str>) -> Resu
245
245
  Ok(cal)
246
246
  }
247
247
 
248
- fn weekday_to_num(s: &str) -> Option<u32> {
249
- match s.to_lowercase().as_str() {
250
- "sun" | "sunday" => Some(0),
251
- "mon" | "monday" => Some(1),
252
- "tue" | "tuesday" => Some(2),
253
- "wed" | "wednesday" => Some(3),
254
- "thu" | "thursday" => Some(4),
255
- "fri" | "friday" => Some(5),
256
- "sat" | "saturday" => Some(6),
257
- _ => None,
258
- }
259
- }
260
-
261
248
  fn allocate_task_hours(
262
249
  tree: &[TaskIndex],
263
250
  assignee_filter: Option<&str>,
@@ -50,6 +50,7 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
50
50
  "settings.memory_enabled",
51
51
  "settings.memory_dup_threshold",
52
52
  "settings.memory_query_min_score",
53
+ "settings.memory_query_relative_threshold",
53
54
  "settings.memory_query_limit",
54
55
  "settings.memory_stale_days",
55
56
  "settings.memory_injected_gc_days",
@@ -153,6 +154,18 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
153
154
  config.settings.memory_query_min_score = n;
154
155
  applied.push(format!("settings.memory_query_min_score = {n}"));
155
156
  }
157
+ "settings.memory_query_relative_threshold" => {
158
+ let Some(n) = value.as_f64() else {
159
+ anyhow::bail!("settings.memory_query_relative_threshold must be a number");
160
+ };
161
+ if !(0.0..=1.0).contains(&n) {
162
+ anyhow::bail!(
163
+ "settings.memory_query_relative_threshold must be between 0 and 1"
164
+ );
165
+ }
166
+ config.settings.memory_query_relative_threshold = n;
167
+ applied.push(format!("settings.memory_query_relative_threshold = {n}"));
168
+ }
156
169
  "settings.memory_query_limit" => {
157
170
  let Some(n) = value.as_u64() else {
158
171
  anyhow::bail!("settings.memory_query_limit must be a non-negative integer");