handoff-mcp-server 0.19.1 → 0.22.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.
@@ -0,0 +1,233 @@
1
+ ---
2
+ name: handoff-docs
3
+ description: "Document management — save, read, search, import, and traverse structured project documents (specs, designs, ADRs, guides, notes). Triggers on 'ドキュメント保存', '仕様書を管理', '設計書をインポート', 'save this doc', 'import specs', 'document management', or when the user asks to persist/organize/search multi-section markdown that is too structured for a single memory entry."
4
+ ---
5
+
6
+ # Handoff Docs Skill
7
+
8
+ ## When to use
9
+
10
+ - The user asks to save a spec, design doc, ADR, guide, or note so it survives across sessions
11
+ - The user wants to import an existing pile of Markdown files (e.g. a `wiki/` or `specs/` directory) into structured document management
12
+ - You need to read a specific section of a large document without pulling the whole thing into context
13
+ - You need to trace how documents relate to each other (parent/child hierarchy, or semantic links like "this design implements that spec")
14
+ - A document should be linked to one or more tasks so it surfaces automatically while that task is being worked on
15
+
16
+ If the knowledge is a short, standalone lesson/rule/convention/gotcha (< 1 page,
17
+ no internal sections), use `handoff-memory` instead — see "Memory vs Documents"
18
+ in `skills/handoff-memory/SKILL.md` for the boundary.
19
+
20
+ ## The 9 Doc Tools
21
+
22
+ | Tool | Purpose |
23
+ |---|---|
24
+ | `handoff_doc_save` | Create or update a document. Splits `body` into fragments automatically. |
25
+ | `handoff_doc_get` | Read a document — `full` (reassembled body), `meta` (manifest only), or `fragment` (one section). |
26
+ | `handoff_doc_list` | List/search documents (BM25 over title + fragment bodies), filter by `doc_type`, `tags`, `task_id`. |
27
+ | `handoff_doc_delete` | Delete a document and all its fragments; unlinks it from any linked tasks. |
28
+ | `handoff_doc_reassemble` | Reconstruct the original Markdown from fragments, with drift detection. |
29
+ | `handoff_doc_tree` | Walk the family tree (ancestors/descendants/related) for a document. |
30
+ | `handoff_doc_query` | Context injection — hook-driven, staged `full`/`outline` results ranked by relevance. |
31
+ | `handoff_doc_analyze` | Read-only heuristic scan of a file or directory — step 1 of the import flow. |
32
+ | `handoff_doc_import` | Atomic bulk write of analyzed + AI-reviewed documents — step 3 of the import flow. |
33
+
34
+ ### `handoff_doc_save`
35
+
36
+ | Param | Required | Description |
37
+ |---|---|---|
38
+ | `title` | yes | Document title |
39
+ | `body` | yes | Full Markdown source — this is what gets split into fragments |
40
+ | `doc_type` | no | One of `spec`, `design`, `adr`, `guide`, `note` |
41
+ | `tags` | no | Free-form tags, folded into the BM25 index |
42
+ | `scope_paths` | no | Path prefixes this doc applies to — boosts relevance in `doc_query` when the matching file is being edited |
43
+ | `parent_id` | no | Places this document under a parent in the family tree |
44
+ | `related` | no | Array of `{ id, rel }` — semantic links to other documents (see Family Tree below) |
45
+ | `task_ids` | no | Task IDs to bidirectionally link (see Task Linking below) |
46
+ | `split_level` | no | ATX heading level to split on (default: `2`, i.e. `##`) |
47
+ | `auto_inject` | no | Injection hint: `auto` (default) \| `full` \| `outline` \| `none` |
48
+ | `doc_id` | no | Provide to update an existing document; omit to create a new one |
49
+
50
+ ### `handoff_doc_get`
51
+
52
+ | Param | Required | Description |
53
+ |---|---|---|
54
+ | `doc_id` | yes | Document to read |
55
+ | `format` | no | `full` (default-equivalent reassembly) \| `meta` (manifest only, no body) \| `fragment` |
56
+ | `seq` | when `format=fragment` | Fragment sequence number to return |
57
+
58
+ Use `meta` when you only need to walk the graph (titles, tags, relations)
59
+ without paying the token cost of fragment bodies. Use `fragment` with a `seq`
60
+ from an outline injection (see Staged Injection) to fetch exactly the section
61
+ you need.
62
+
63
+ ### `handoff_doc_list`
64
+
65
+ | Param | Required | Description |
66
+ |---|---|---|
67
+ | `query` | no | BM25 search over title + fragment bodies |
68
+ | `doc_type` | no | Filter by type |
69
+ | `tags` | no | Filter by tags |
70
+ | `task_id` | no | Documents linked to this task |
71
+ | `include_body` | no | Default `false` — metadata-only listing |
72
+
73
+ ### `handoff_doc_delete`
74
+
75
+ | Param | Required | Description |
76
+ |---|---|---|
77
+ | `doc_id` | yes | Document to delete, along with all its fragments |
78
+
79
+ Deleting also removes the document from any linked task's `task_links`.
80
+
81
+ ### `handoff_doc_reassemble`
82
+
83
+ | Param | Required | Description |
84
+ |---|---|---|
85
+ | `doc_id` | yes | Document to reconstruct |
86
+ | `output_path` | no | If set, also writes the reassembled Markdown to this path |
87
+
88
+ Fragments are concatenated in `seq` order with original heading markers
89
+ preserved — `save(body) → reassemble()` is byte-identical. If a fragment was
90
+ edited directly after the split, its `content_hash` no longer matches and
91
+ `reassemble` reports the drift instead of silently returning stale content.
92
+
93
+ ### `handoff_doc_tree`
94
+
95
+ | Param | Required | Description |
96
+ |---|---|---|
97
+ | `doc_id` | yes | Root of the traversal |
98
+ | `depth` | no | How many parent/child levels to return |
99
+ | `include_related` | no | Whether to also include semantically `related` documents |
100
+
101
+ ### `handoff_doc_query`
102
+
103
+ | Param | Required | Description |
104
+ |---|---|---|
105
+ | `text` | no | Prompt/query text (BM25 relevance ranking) |
106
+ | `file_paths` | no | Files being worked on — boosts documents whose `scope_paths` prefix-match |
107
+ | `task_id` | no | Boosts documents linked to this task |
108
+ | `session_id` | no | Enables per-session dedup — a fragment already injected this session is skipped unless its content changed |
109
+ | `limit` | no | Max fragments to return |
110
+ | `mark_injected` | no | Whether to record injection for dedup (default `true`) |
111
+ | `suppress_doc_ids` | no | Document ids to exclude entirely from this call's results |
112
+ | `suppress_until_changed` | no | With `suppress_doc_ids` and `session_id`: persists the suppression in the session sidecar so those documents stay excluded from future calls until their `content_hash` changes (default `false`) |
113
+
114
+ This is the hook-driven tool — see Staged Injection below for how results are shaped.
115
+
116
+ ### `handoff_doc_analyze`
117
+
118
+ | Param | Required | Description |
119
+ |---|---|---|
120
+ | `path` | yes | File or directory to scan |
121
+ | `recursive` | no | Recurse into subdirectories |
122
+ | `flatten` | no | Skip hierarchy inference (no `parent_id` guessing) |
123
+
124
+ Read-only — writes nothing. Returns a conditioning report with
125
+ `auto_resolved` entries (high-confidence `doc_type`/`tags`/`scope_paths`) and
126
+ `needs_review` entries (broken links, missing relationships, near-duplicates)
127
+ each carrying a concrete `suggestion` the AI can approve, edit, or reject.
128
+
129
+ ### `handoff_doc_import`
130
+
131
+ | Param | Required | Description |
132
+ |---|---|---|
133
+ | `analyzed` | yes | The `handoff_doc_analyze` output (possibly after AI review) |
134
+ | `overrides` | no | Per-file corrections (`doc_type`, relationship resolutions, etc.) |
135
+ | `task_ids` | no | Link every imported document to these tasks |
136
+
137
+ Writes all `_doc.*.json` + `_frag.*.{json,md}` atomically in one transaction,
138
+ including any task links — matches the "validate whole tree, then write"
139
+ pattern used by `handoff_import_context` for tasks.
140
+
141
+ ## Staged Injection (outline vs full)
142
+
143
+ `handoff_doc_query` avoids flooding context with large documents:
144
+
145
+ | Mode | When | What's injected |
146
+ |---|---|---|
147
+ | `full` | Fragment body <= `doc_inline_threshold` tokens (default 300) | Metadata + the entire fragment body |
148
+ | `outline` | Fragment body > threshold | Metadata + heading list only — no body. Read a specific section with `handoff_doc_get(format="fragment", seq=N)` |
149
+
150
+ This means short documents (ADRs, conventions, short notes) get pulled in
151
+ ready-to-use, while long documents (full specs, design docs) only announce
152
+ their structure — the AI decides which section is actually worth fetching.
153
+
154
+ ### `auto_inject` override
155
+
156
+ Set on `handoff_doc_save` (or later via an update) to force behavior
157
+ regardless of size:
158
+
159
+ | Value | Effect |
160
+ |---|---|
161
+ | `auto` (default) | Size-based automatic choice between `full`/`outline` |
162
+ | `full` | Always inject the full body |
163
+ | `outline` | Always inject headings only, even if small |
164
+ | `none` | Never auto-inject — only surfaced via explicit `handoff_doc_get` |
165
+
166
+ ## Family Tree
167
+
168
+ Two distinct kinds of document relationship:
169
+
170
+ - **parent/child** (`parent_id`) — structural hierarchy, e.g. a directory of
171
+ specs where each file's document is a child of the directory's document.
172
+ - **related** (`related: [{ id, rel }]`) — semantic links between documents
173
+ that are not structurally nested. `rel` is one of:
174
+
175
+ | `rel` | Meaning |
176
+ |---|---|
177
+ | `supersedes` | Replaces the target (a version bump) |
178
+ | `references` | Loose cross-reference |
179
+ | `implements` | This document implements the spec the target describes |
180
+ | `extends` | Extends the target without replacing it |
181
+ | `conflicts` | Known contradiction that needs resolving |
182
+
183
+ Use `handoff_doc_tree` to walk both kinds together (`include_related: true`)
184
+ or just the structural hierarchy.
185
+
186
+ ## Task Linking
187
+
188
+ `handoff_doc_save(task_ids: [...])` creates a **bidirectional** link:
189
+
190
+ 1. The document's own `task_ids` field is set.
191
+ 2. Each linked task gets a `TaskLink { target: doc_id, link_type: "doc", label: <doc title> }` entry in its `task_links`.
192
+ 3. Deleting the document removes it from the linked tasks' `task_links` automatically.
193
+
194
+ Look up the relationship from either side:
195
+ - `handoff_doc_list(task_id: "T-79")` — documents linked to a task.
196
+ - `handoff_get_task(task_id: "T-79")` — inspect `task_links` on the task record to
197
+ see which documents (and other targets) it links to.
198
+
199
+ Note: there is no dedicated `doc_id` filter on `handoff_list_tasks` — the
200
+ `task_links` field is populated and readable per-task via `handoff_get_task`,
201
+ but a document → "which tasks link to me" listing must be done by scanning
202
+ `task_links` yourself, not via a built-in filter.
203
+
204
+ ## Fragment Granularity
205
+
206
+ - Default split boundary: ATX heading level 2 (`##`).
207
+ - Override per-call with `split_level` on `handoff_doc_save` (e.g. `1` to
208
+ split only on `#`, or `3` for finer-grained `###` sections).
209
+ - Content before the first qualifying heading becomes fragment `seq: 0` (the
210
+ preamble). Nested headings below `split_level` stay inside their parent
211
+ fragment rather than becoming their own fragment.
212
+
213
+ ## Import Workflow (3 steps)
214
+
215
+ Use this instead of calling `doc_save` file-by-file when bringing in an
216
+ existing pile of Markdown — cross-document relationships and duplicates can
217
+ only be validated when every file is visible at once.
218
+
219
+ 1. **`handoff_doc_analyze(path, recursive, flatten)`** — read-only scan.
220
+ Returns `auto_resolved` (confident guesses) and `needs_review` (broken
221
+ links, missing relationships, near-duplicates), each with a `suggestion`.
222
+ 2. **AI reviews the report** — approve `auto_resolved` entries as-is, and for
223
+ each `needs_review` item either accept the `suggestion`, correct it, or
224
+ reject it. Nothing is written yet.
225
+ 3. **`handoff_doc_import(analyzed, overrides, task_ids)`** — takes the
226
+ analyzed payload plus the AI's overrides and writes the whole tree
227
+ atomically, including task links.
228
+
229
+ ## `doc_type` Values
230
+
231
+ `spec` (requirements/behavior contracts) · `design` (architecture/design
232
+ docs) · `adr` (architecture decision records) · `guide` (how-to/operational
233
+ docs) · `note` (fallback — anything that doesn't fit the above).
@@ -33,5 +33,13 @@ user-invocable: true
33
33
  - **決定事項**: confidence が `unverified` のものを警告
34
34
  - **申し送り**: `caution` を最初に、`context` → `suggestion` の順
35
35
  - **コンテキストポインタ**: 次に読むべきファイル一覧
36
- 6. ユーザーに現状サマリーを日本語で報告
37
- 7. 次のアクションを提案(「何から始めますか?」)
36
+ 6. **関連ドキュメントの確認**:
37
+ - `in_progress` タスクにリンクされたドキュメントがあれば、フックの
38
+ `handoff_doc_query`(`UserPromptSubmit`/`PreToolUse`)が自動注入する
39
+ — セッション開始時点で明示的に呼ぶ必要はない
40
+ - 特定のセクションだけ読みたい場合は `handoff_doc_get(doc_id, format="fragment", seq=N)`
41
+ を手動で呼ぶ(outline 注入で見出し一覧だけ渡された場合など)
42
+ - ドキュメント管理の詳細(段階的注入・家系図・インポート手順)は
43
+ `handoff-docs` スキルを参照
44
+ 7. ユーザーに現状サマリーを日本語で報告
45
+ 8. 次のアクションを提案(「何から始めますか?」)
@@ -137,3 +137,23 @@ JSON configuration.
137
137
 
138
138
  Rule changes: discover via memory → codify in skills/CLAUDE.md. Memory is the
139
139
  source of evidence; skills and CLAUDE.md are the source of authority.
140
+
141
+ ## Memory vs Documents
142
+
143
+ | | Memory | Documents |
144
+ |---|---|---|
145
+ | Shape | Short, atomic lesson/rule/convention/gotcha | Structured, multi-section Markdown (specs, designs, ADRs, guides) |
146
+ | Storage | One flat entry (`.handoff/memory/`) | Split into fragments with a family tree (`.handoff/docs/`) |
147
+ | Relationships | None | parent/child (structural) + `related` (semantic: supersedes/references/implements/extends/conflicts) |
148
+ | Task linking | Via `scope_paths` boost only | Bidirectional `task_ids` ↔ `task_links` |
149
+ | Injection | Always inline (BM25 similarity) | Staged — `full` body for small fragments, `outline` (headings only) for large ones |
150
+
151
+ **Boundary rule**: if it is a standalone piece of knowledge that fits in a
152
+ paragraph or two (< 1 page, no internal sections), save it as a memory. If it
153
+ has structure — sections, a hierarchy, cross-references to other documents —
154
+ use document management (`handoff_doc_save`; see the `handoff-docs` skill).
155
+
156
+ There is **no migration path** between the two stores — they are independent.
157
+ If a memory grows into something that needs sections and cross-references,
158
+ recreate it as a document with `handoff_doc_save` and delete the memory with
159
+ `handoff_memory_delete` rather than expecting an automatic conversion.
@@ -0,0 +1,239 @@
1
+ //! Shared BM25 + scope-path ranking used by `memory_query` and (from t96.3)
2
+ //! `doc_query`.
3
+ //!
4
+ //! Extracted from the original `memory_query` implementation
5
+ //! (`src/mcp/handlers/memory.rs`) so both features rank candidates the same
6
+ //! way: BM25 relevance over a `lexsim::Corpus`, a fixed bonus when a
7
+ //! candidate's `scope_paths` prefix-matches one of the query's `file_paths`,
8
+ //! a `min_score` floor, then a stable sort + `limit` truncation.
9
+
10
+ /// One ranked candidate: the index into the caller's original slice, plus its
11
+ /// final score (BM25 + scope bonus).
12
+ #[derive(Debug, Clone, Copy, PartialEq)]
13
+ pub struct RankItem {
14
+ pub index: usize,
15
+ pub score: f64,
16
+ }
17
+
18
+ /// Tuning knobs for [`rank_by_bm25_and_scope`].
19
+ #[derive(Debug, Clone, Copy)]
20
+ pub struct RankConfig {
21
+ /// Candidates scoring below this are dropped before sorting.
22
+ pub min_score: f64,
23
+ /// Added to a candidate's BM25 score when [`scope_matches`] is true.
24
+ pub scope_path_bonus: f64,
25
+ /// Max number of items returned (applied after sort, before session diff).
26
+ pub limit: usize,
27
+ }
28
+
29
+ /// True if any `scope` prefix matches any `file` path (substring match on the
30
+ /// path, not a strict prefix — mirrors the original `memory_query` behavior).
31
+ pub fn scope_matches(scopes: &[String], files: &[String]) -> bool {
32
+ if scopes.is_empty() || files.is_empty() {
33
+ return false;
34
+ }
35
+ scopes
36
+ .iter()
37
+ .any(|scope| files.iter().any(|f| f.contains(scope.as_str())))
38
+ }
39
+
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`.
44
+ ///
45
+ /// `scope_paths` and the corpus must be index-aligned (one entry per
46
+ /// document); `corpus.len()` and `scope_paths.len()` are expected to match —
47
+ /// a mismatch simply means the extra `scope_paths` entries are never
48
+ /// consulted (indices beyond `corpus.len()` are not produced).
49
+ pub fn rank_by_bm25_and_scope(
50
+ corpus: &lexsim::Corpus,
51
+ query_tokens: &[String],
52
+ scope_paths: &[Vec<String>],
53
+ file_paths: &[String],
54
+ config: &RankConfig,
55
+ ) -> Vec<RankItem> {
56
+ let scores = corpus.bm25_scores_tokens(query_tokens);
57
+
58
+ let mut ranked: Vec<RankItem> = scores
59
+ .into_iter()
60
+ .enumerate()
61
+ .map(|(index, mut score)| {
62
+ if let Some(scopes) = scope_paths.get(index) {
63
+ if scope_matches(scopes, file_paths) {
64
+ score += config.scope_path_bonus;
65
+ }
66
+ }
67
+ RankItem { index, score }
68
+ })
69
+ .filter(|item| item.score >= config.min_score)
70
+ .collect();
71
+
72
+ ranked.sort_by(|a, b| {
73
+ b.score
74
+ .partial_cmp(&a.score)
75
+ .unwrap_or(std::cmp::Ordering::Equal)
76
+ });
77
+ ranked.truncate(config.limit);
78
+ ranked
79
+ }
80
+
81
+ /// Drop already-injected candidates (per the caller's session sidecar) from
82
+ /// `ranked`, then truncate the survivors to `limit`.
83
+ ///
84
+ /// `already_injected(index)` receives the original document index (as stored
85
+ /// on [`RankItem::index`]) and returns true when that document was already
86
+ /// injected into the current session at its current content hash.
87
+ pub fn filter_already_injected<F>(
88
+ ranked: Vec<RankItem>,
89
+ already_injected: F,
90
+ limit: usize,
91
+ ) -> Vec<RankItem>
92
+ where
93
+ F: Fn(usize) -> bool,
94
+ {
95
+ ranked
96
+ .into_iter()
97
+ .filter(|item| !already_injected(item.index))
98
+ .take(limit)
99
+ .collect()
100
+ }
101
+
102
+ #[cfg(test)]
103
+ mod tests {
104
+ use super::*;
105
+
106
+ fn docs() -> Vec<String> {
107
+ vec![
108
+ "rust error handling with Result and anyhow".to_string(),
109
+ "javascript promises and async await".to_string(),
110
+ "rust ownership borrow checker ownership".to_string(),
111
+ ]
112
+ }
113
+
114
+ fn default_config() -> RankConfig {
115
+ RankConfig {
116
+ min_score: 0.0,
117
+ scope_path_bonus: 2.0,
118
+ limit: 10,
119
+ }
120
+ }
121
+
122
+ #[test]
123
+ fn scope_matches_prefix() {
124
+ let scopes = vec!["src/storage/".to_string()];
125
+ let files = vec!["/repo/src/storage/mod.rs".to_string()];
126
+ assert!(scope_matches(&scopes, &files));
127
+ let files2 = vec!["/repo/src/mcp/mod.rs".to_string()];
128
+ assert!(!scope_matches(&scopes, &files2));
129
+ }
130
+
131
+ #[test]
132
+ fn scope_matches_empty_inputs_false() {
133
+ assert!(!scope_matches(&[], &["a".to_string()]));
134
+ assert!(!scope_matches(&["a".to_string()], &[]));
135
+ }
136
+
137
+ #[test]
138
+ fn rank_by_bm25_orders_relevant_docs_first() {
139
+ let corpus = lexsim::Corpus::build(&docs());
140
+ let query_tokens = lexsim::tokenize("rust ownership");
141
+ let scope_paths: Vec<Vec<String>> = vec![vec![], vec![], vec![]];
142
+ let ranked =
143
+ rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &default_config());
144
+
145
+ assert!(!ranked.is_empty());
146
+ // Doc 2 mentions "rust" and "ownership" twice — should rank first.
147
+ assert_eq!(ranked[0].index, 2);
148
+ }
149
+
150
+ #[test]
151
+ 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");
156
+ let scope_paths: Vec<Vec<String>> = vec![vec![], vec!["src/web/".to_string()], vec![]];
157
+ let file_paths = vec!["/repo/src/web/app.js".to_string()];
158
+ let config = RankConfig {
159
+ min_score: 0.0,
160
+ scope_path_bonus: 2.0,
161
+ limit: 10,
162
+ };
163
+ let ranked =
164
+ rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &file_paths, &config);
165
+ assert_eq!(ranked[0].index, 1);
166
+ assert!(ranked[0].score >= 2.0);
167
+ }
168
+
169
+ #[test]
170
+ 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");
173
+ let scope_paths: Vec<Vec<String>> = vec![vec![], vec![], vec![]];
174
+ let config = RankConfig {
175
+ min_score: 0.01,
176
+ scope_path_bonus: 2.0,
177
+ limit: 10,
178
+ };
179
+ let ranked = rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &config);
180
+ assert!(ranked.is_empty());
181
+ }
182
+
183
+ #[test]
184
+ fn rank_by_bm25_respects_limit() {
185
+ let corpus = lexsim::Corpus::build(&docs());
186
+ let query_tokens = lexsim::tokenize("rust javascript");
187
+ let scope_paths: Vec<Vec<String>> = vec![vec![], vec![], vec![]];
188
+ let config = RankConfig {
189
+ min_score: 0.0,
190
+ scope_path_bonus: 2.0,
191
+ limit: 1,
192
+ };
193
+ let ranked = rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &config);
194
+ assert_eq!(ranked.len(), 1);
195
+ }
196
+
197
+ #[test]
198
+ fn filter_already_injected_drops_marked_and_respects_limit() {
199
+ let ranked = vec![
200
+ RankItem {
201
+ index: 0,
202
+ score: 5.0,
203
+ },
204
+ RankItem {
205
+ index: 1,
206
+ score: 4.0,
207
+ },
208
+ RankItem {
209
+ index: 2,
210
+ score: 3.0,
211
+ },
212
+ ];
213
+ let already = |i: usize| i == 1;
214
+ let out = filter_already_injected(ranked, already, 10);
215
+ assert_eq!(out.iter().map(|i| i.index).collect::<Vec<_>>(), vec![0, 2]);
216
+ }
217
+
218
+ #[test]
219
+ fn filter_already_injected_applies_limit_after_filtering() {
220
+ let ranked = vec![
221
+ RankItem {
222
+ index: 0,
223
+ score: 5.0,
224
+ },
225
+ RankItem {
226
+ index: 1,
227
+ score: 4.0,
228
+ },
229
+ RankItem {
230
+ index: 2,
231
+ score: 3.0,
232
+ },
233
+ ];
234
+ let out = filter_already_injected(ranked, |_| false, 2);
235
+ assert_eq!(out.len(), 2);
236
+ assert_eq!(out[0].index, 0);
237
+ assert_eq!(out[1].index, 1);
238
+ }
239
+ }
@@ -0,0 +1,129 @@
1
+ //! Shared ranking + corpus-cache infrastructure for full-text search across
2
+ //! features (`memory_query` today, `doc_query` from t96.3).
3
+ //!
4
+ //! See `wiki/130-document-management.md` §3.1 for the design rationale: a
5
+ //! single BM25 corpus cache, invalidated by a generation counter bumped on
6
+ //! every mutating call (`doc_save`/`doc_delete`/`doc_import`), avoids
7
+ //! rebuilding the corpus on every query once fragment counts reach the
8
+ //! thousands. `memory_query` stays on the un-cached path (memory counts are
9
+ //! small enough that a per-call rebuild is cheap) and only adopts the shared
10
+ //! ranking function from [`injection`].
11
+
12
+ pub mod injection;
13
+
14
+ use std::sync::{Mutex, OnceLock};
15
+
16
+ /// Process-wide cache of the document-fragment BM25 corpus, invalidated by a
17
+ /// monotonically increasing generation counter.
18
+ ///
19
+ /// `doc_save`/`doc_delete`/`doc_import` call [`CorpusCache::increment_generation`]
20
+ /// on every mutation; the next [`CorpusCache::get_or_build_corpus`] call
21
+ /// notices its cached generation is stale and rebuilds. The MCP server is
22
+ /// single-threaded stdio today, so the `Mutex` is uncontended in practice —
23
+ /// it exists for forward compatibility with a future multi-session server.
24
+ pub struct CorpusCache {
25
+ generation: u64,
26
+ built_generation: Option<u64>,
27
+ corpus: Option<lexsim::Corpus>,
28
+ doc_texts: Vec<String>,
29
+ }
30
+
31
+ impl CorpusCache {
32
+ fn new() -> Self {
33
+ CorpusCache {
34
+ generation: 0,
35
+ built_generation: None,
36
+ corpus: None,
37
+ doc_texts: Vec::new(),
38
+ }
39
+ }
40
+
41
+ /// Invalidate the cached corpus. Called after any mutation to the
42
+ /// underlying fragment store (`doc_save`, `doc_delete`, `doc_import`).
43
+ pub fn increment_generation(&mut self) {
44
+ self.generation += 1;
45
+ }
46
+
47
+ /// Return the cached corpus if it is still current for `doc_texts`,
48
+ /// otherwise rebuild it from `doc_texts` and cache the result.
49
+ ///
50
+ /// `doc_texts` is the caller's current full set of fragment index texts,
51
+ /// supplied fresh on every call (the cache does not own fragment
52
+ /// storage) — only the expensive `lexsim::Corpus::build` step is skipped
53
+ /// when the generation hasn't moved since the last build.
54
+ pub fn get_or_build_corpus(&mut self, doc_texts: &[String]) -> &lexsim::Corpus {
55
+ let stale = self.built_generation != Some(self.generation) || self.doc_texts != doc_texts;
56
+ if stale {
57
+ self.corpus = Some(lexsim::Corpus::build(doc_texts));
58
+ self.doc_texts = doc_texts.to_vec();
59
+ self.built_generation = Some(self.generation);
60
+ }
61
+ self.corpus
62
+ .as_ref()
63
+ .expect("corpus is always populated by the stale branch above")
64
+ }
65
+
66
+ /// Current generation counter (test/inspection hook).
67
+ pub fn generation(&self) -> u64 {
68
+ self.generation
69
+ }
70
+ }
71
+
72
+ impl Default for CorpusCache {
73
+ fn default() -> Self {
74
+ Self::new()
75
+ }
76
+ }
77
+
78
+ static DOC_CORPUS_CACHE: OnceLock<Mutex<CorpusCache>> = OnceLock::new();
79
+
80
+ /// The process-wide document corpus cache, created lazily on first access.
81
+ pub fn doc_corpus_cache() -> &'static Mutex<CorpusCache> {
82
+ DOC_CORPUS_CACHE.get_or_init(|| Mutex::new(CorpusCache::new()))
83
+ }
84
+
85
+ #[cfg(test)]
86
+ mod tests {
87
+ use super::*;
88
+
89
+ #[test]
90
+ fn get_or_build_corpus_reuses_cache_when_generation_unchanged() {
91
+ let mut cache = CorpusCache::new();
92
+ let texts = vec!["alpha beta".to_string(), "gamma delta".to_string()];
93
+ {
94
+ let corpus = cache.get_or_build_corpus(&texts);
95
+ assert_eq!(corpus.len(), 2);
96
+ }
97
+ assert_eq!(cache.built_generation, Some(0));
98
+ // Second call with identical inputs and generation should not rebuild
99
+ // (rebuild is observable only via built_generation staying put).
100
+ let _ = cache.get_or_build_corpus(&texts);
101
+ assert_eq!(cache.built_generation, Some(0));
102
+ }
103
+
104
+ #[test]
105
+ fn increment_generation_forces_rebuild() {
106
+ let mut cache = CorpusCache::new();
107
+ let texts = vec!["alpha".to_string()];
108
+ let _ = cache.get_or_build_corpus(&texts);
109
+ assert_eq!(cache.built_generation, Some(0));
110
+
111
+ cache.increment_generation();
112
+ assert_eq!(cache.generation(), 1);
113
+
114
+ let texts2 = vec!["alpha".to_string(), "beta".to_string()];
115
+ let corpus = cache.get_or_build_corpus(&texts2);
116
+ assert_eq!(corpus.len(), 2);
117
+ assert_eq!(cache.built_generation, Some(1));
118
+ }
119
+
120
+ #[test]
121
+ fn doc_corpus_cache_is_a_shared_singleton() {
122
+ {
123
+ let mut guard = doc_corpus_cache().lock().expect("cache mutex poisoned");
124
+ guard.increment_generation();
125
+ }
126
+ let guard = doc_corpus_cache().lock().expect("cache mutex poisoned");
127
+ assert!(guard.generation() >= 1);
128
+ }
129
+ }
package/src/lib.rs CHANGED
@@ -1,4 +1,5 @@
1
1
  pub mod cli;
2
+ pub mod context;
2
3
  pub mod mcp;
3
4
  pub mod setup;
4
5
  pub mod storage;