handoff-mcp-server 0.26.0 → 0.28.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/README.md +31 -4
- package/bin/handoff-mcp.js +56 -13
- package/bin/resolve-binary.js +122 -0
- package/package.json +14 -9
- package/Cargo.lock +0 -686
- package/Cargo.toml +0 -30
- package/scripts/cargo-env.sh +0 -29
- package/scripts/handoff-memory-hook.py +0 -208
- package/scripts/install-local.sh +0 -109
- package/scripts/postinstall.js +0 -50
- package/scripts/sync-plugin-skills.sh +0 -35
- package/scripts/sync-plugin-version.sh +0 -85
- package/scripts/sync-workflow-inline.sh +0 -138
- package/src/cli.rs +0 -551
- package/src/context/injection.rs +0 -276
- package/src/context/mod.rs +0 -129
- package/src/lib.rs +0 -5
- package/src/main.rs +0 -157
- package/src/mcp/handlers/assignees.rs +0 -254
- package/src/mcp/handlers/auto_schedule.rs +0 -489
- package/src/mcp/handlers/bulk_update.rs +0 -155
- package/src/mcp/handlers/calendar.rs +0 -196
- package/src/mcp/handlers/capacity.rs +0 -318
- package/src/mcp/handlers/check_criterion.rs +0 -70
- package/src/mcp/handlers/config.rs +0 -402
- package/src/mcp/handlers/config_crud.rs +0 -183
- package/src/mcp/handlers/dashboard.rs +0 -214
- package/src/mcp/handlers/docs.rs +0 -2288
- package/src/mcp/handlers/docs_query.rs +0 -1335
- package/src/mcp/handlers/fork_session.rs +0 -91
- package/src/mcp/handlers/get_session.rs +0 -48
- package/src/mcp/handlers/get_task.rs +0 -53
- package/src/mcp/handlers/import_context.rs +0 -470
- package/src/mcp/handlers/init.rs +0 -28
- package/src/mcp/handlers/list_sessions.rs +0 -187
- package/src/mcp/handlers/list_tasks.rs +0 -308
- package/src/mcp/handlers/load_context.rs +0 -361
- package/src/mcp/handlers/log_time.rs +0 -67
- package/src/mcp/handlers/memory.rs +0 -961
- package/src/mcp/handlers/merge_sessions.rs +0 -103
- package/src/mcp/handlers/metrics.rs +0 -196
- package/src/mcp/handlers/milestones.rs +0 -102
- package/src/mcp/handlers/mod.rs +0 -140
- package/src/mcp/handlers/refer.rs +0 -307
- package/src/mcp/handlers/referrals.rs +0 -74
- package/src/mcp/handlers/save_context.rs +0 -354
- package/src/mcp/handlers/task_checklist.rs +0 -507
- package/src/mcp/handlers/timer.rs +0 -529
- package/src/mcp/handlers/update_session.rs +0 -197
- package/src/mcp/handlers/update_task.rs +0 -452
- package/src/mcp/mod.rs +0 -6
- package/src/mcp/protocol.rs +0 -41
- package/src/mcp/resources.rs +0 -57
- package/src/mcp/router.rs +0 -154
- package/src/mcp/tools.rs +0 -1522
- package/src/mcp/types.rs +0 -108
- package/src/setup.rs +0 -1212
- package/src/storage/config.rs +0 -578
- package/src/storage/docs/frontmatter.rs +0 -509
- package/src/storage/docs/mod.rs +0 -835
- package/src/storage/docs/model.rs +0 -708
- package/src/storage/docs/reassemble.rs +0 -167
- package/src/storage/docs/split.rs +0 -377
- package/src/storage/git.rs +0 -47
- package/src/storage/memory/injected.rs +0 -340
- package/src/storage/memory/mod.rs +0 -236
- package/src/storage/memory/model.rs +0 -127
- package/src/storage/mod.rs +0 -96
- package/src/storage/referrals.rs +0 -248
- package/src/storage/sessions.rs +0 -859
- package/src/storage/tasks.rs +0 -957
- package/templates/claude-md-section.md +0 -12
package/src/context/injection.rs
DELETED
|
@@ -1,276 +0,0 @@
|
|
|
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
|
-
/// 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,
|
|
26
|
-
/// Added to a candidate's BM25 score when [`scope_matches`] is true.
|
|
27
|
-
pub scope_path_bonus: f64,
|
|
28
|
-
/// Max number of items returned (applied after sort, before session diff).
|
|
29
|
-
pub limit: usize,
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/// True if any `scope` prefix matches any `file` path (substring match on the
|
|
33
|
-
/// path, not a strict prefix — mirrors the original `memory_query` behavior).
|
|
34
|
-
pub fn scope_matches(scopes: &[String], files: &[String]) -> bool {
|
|
35
|
-
if scopes.is_empty() || files.is_empty() {
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
38
|
-
scopes
|
|
39
|
-
.iter()
|
|
40
|
-
.any(|scope| files.iter().any(|f| f.contains(scope.as_str())))
|
|
41
|
-
}
|
|
42
|
-
|
|
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`.
|
|
47
|
-
///
|
|
48
|
-
/// `scope_paths` and the corpus must be index-aligned (one entry per
|
|
49
|
-
/// document); `corpus.len()` and `scope_paths.len()` are expected to match —
|
|
50
|
-
/// a mismatch simply means the extra `scope_paths` entries are never
|
|
51
|
-
/// consulted (indices beyond `corpus.len()` are not produced).
|
|
52
|
-
pub fn rank_by_bm25_and_scope(
|
|
53
|
-
corpus: &lexsim::Corpus,
|
|
54
|
-
query_tokens: &[lexsim::WeightedToken],
|
|
55
|
-
scope_paths: &[Vec<String>],
|
|
56
|
-
file_paths: &[String],
|
|
57
|
-
config: &RankConfig,
|
|
58
|
-
) -> Vec<RankItem> {
|
|
59
|
-
let scores = corpus.bm25_scores_weighted_tokens(query_tokens);
|
|
60
|
-
|
|
61
|
-
let mut ranked: Vec<RankItem> = scores
|
|
62
|
-
.into_iter()
|
|
63
|
-
.enumerate()
|
|
64
|
-
.map(|(index, mut score)| {
|
|
65
|
-
if let Some(scopes) = scope_paths.get(index) {
|
|
66
|
-
if scope_matches(scopes, file_paths) {
|
|
67
|
-
score += config.scope_path_bonus;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
RankItem { index, score }
|
|
71
|
-
})
|
|
72
|
-
.filter(|item| item.score > 0.0 && item.score >= config.min_score)
|
|
73
|
-
.collect();
|
|
74
|
-
|
|
75
|
-
ranked.sort_by(|a, b| {
|
|
76
|
-
b.score
|
|
77
|
-
.partial_cmp(&a.score)
|
|
78
|
-
.unwrap_or(std::cmp::Ordering::Equal)
|
|
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
|
-
|
|
90
|
-
ranked.truncate(config.limit);
|
|
91
|
-
ranked
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/// Drop already-injected candidates (per the caller's session sidecar) from
|
|
95
|
-
/// `ranked`, then truncate the survivors to `limit`.
|
|
96
|
-
///
|
|
97
|
-
/// `already_injected(index)` receives the original document index (as stored
|
|
98
|
-
/// on [`RankItem::index`]) and returns true when that document was already
|
|
99
|
-
/// injected into the current session at its current content hash.
|
|
100
|
-
pub fn filter_already_injected<F>(
|
|
101
|
-
ranked: Vec<RankItem>,
|
|
102
|
-
already_injected: F,
|
|
103
|
-
limit: usize,
|
|
104
|
-
) -> Vec<RankItem>
|
|
105
|
-
where
|
|
106
|
-
F: Fn(usize) -> bool,
|
|
107
|
-
{
|
|
108
|
-
ranked
|
|
109
|
-
.into_iter()
|
|
110
|
-
.filter(|item| !already_injected(item.index))
|
|
111
|
-
.take(limit)
|
|
112
|
-
.collect()
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
#[cfg(test)]
|
|
116
|
-
mod tests {
|
|
117
|
-
use super::*;
|
|
118
|
-
|
|
119
|
-
fn docs() -> Vec<String> {
|
|
120
|
-
vec![
|
|
121
|
-
"rust error handling with Result and anyhow".to_string(),
|
|
122
|
-
"javascript promises and async await".to_string(),
|
|
123
|
-
"rust ownership borrow checker ownership".to_string(),
|
|
124
|
-
]
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
fn default_config() -> RankConfig {
|
|
128
|
-
RankConfig {
|
|
129
|
-
min_score: 0.0,
|
|
130
|
-
relative_threshold: 0.0,
|
|
131
|
-
scope_path_bonus: 2.0,
|
|
132
|
-
limit: 10,
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
#[test]
|
|
137
|
-
fn scope_matches_prefix() {
|
|
138
|
-
let scopes = vec!["src/storage/".to_string()];
|
|
139
|
-
let files = vec!["/repo/src/storage/mod.rs".to_string()];
|
|
140
|
-
assert!(scope_matches(&scopes, &files));
|
|
141
|
-
let files2 = vec!["/repo/src/mcp/mod.rs".to_string()];
|
|
142
|
-
assert!(!scope_matches(&scopes, &files2));
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
#[test]
|
|
146
|
-
fn scope_matches_empty_inputs_false() {
|
|
147
|
-
assert!(!scope_matches(&[], &["a".to_string()]));
|
|
148
|
-
assert!(!scope_matches(&["a".to_string()], &[]));
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
#[test]
|
|
152
|
-
fn rank_by_bm25_orders_relevant_docs_first() {
|
|
153
|
-
let corpus = lexsim::Corpus::build_weighted(&docs());
|
|
154
|
-
let query_tokens = lexsim::tokenize_weighted("rust ownership");
|
|
155
|
-
let scope_paths: Vec<Vec<String>> = vec![vec![], vec![], vec![]];
|
|
156
|
-
let ranked =
|
|
157
|
-
rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &default_config());
|
|
158
|
-
|
|
159
|
-
assert!(!ranked.is_empty());
|
|
160
|
-
assert_eq!(ranked[0].index, 2);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
#[test]
|
|
164
|
-
fn rank_by_bm25_applies_scope_path_bonus() {
|
|
165
|
-
let corpus = lexsim::Corpus::build_weighted(&docs());
|
|
166
|
-
let query_tokens = lexsim::tokenize_weighted("javascript");
|
|
167
|
-
let scope_paths: Vec<Vec<String>> = vec![vec![], vec!["src/web/".to_string()], vec![]];
|
|
168
|
-
let file_paths = vec!["/repo/src/web/app.js".to_string()];
|
|
169
|
-
let config = RankConfig {
|
|
170
|
-
min_score: 0.0,
|
|
171
|
-
relative_threshold: 0.0,
|
|
172
|
-
scope_path_bonus: 2.0,
|
|
173
|
-
limit: 10,
|
|
174
|
-
};
|
|
175
|
-
let ranked =
|
|
176
|
-
rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &file_paths, &config);
|
|
177
|
-
assert_eq!(ranked[0].index, 1);
|
|
178
|
-
assert!(ranked[0].score >= 2.0);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
#[test]
|
|
182
|
-
fn rank_by_bm25_filters_below_min_score() {
|
|
183
|
-
let corpus = lexsim::Corpus::build_weighted(&docs());
|
|
184
|
-
let query_tokens = lexsim::tokenize_weighted("completely unrelated gibberish zzz");
|
|
185
|
-
let scope_paths: Vec<Vec<String>> = vec![vec![], vec![], vec![]];
|
|
186
|
-
let config = RankConfig {
|
|
187
|
-
min_score: 0.01,
|
|
188
|
-
relative_threshold: 0.0,
|
|
189
|
-
scope_path_bonus: 2.0,
|
|
190
|
-
limit: 10,
|
|
191
|
-
};
|
|
192
|
-
let ranked = rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &config);
|
|
193
|
-
assert!(ranked.is_empty());
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
#[test]
|
|
197
|
-
fn rank_by_bm25_respects_limit() {
|
|
198
|
-
let corpus = lexsim::Corpus::build_weighted(&docs());
|
|
199
|
-
let query_tokens = lexsim::tokenize_weighted("rust javascript");
|
|
200
|
-
let scope_paths: Vec<Vec<String>> = vec![vec![], vec![], vec![]];
|
|
201
|
-
let config = RankConfig {
|
|
202
|
-
min_score: 0.0,
|
|
203
|
-
relative_threshold: 0.0,
|
|
204
|
-
scope_path_bonus: 2.0,
|
|
205
|
-
limit: 1,
|
|
206
|
-
};
|
|
207
|
-
let ranked = rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &config);
|
|
208
|
-
assert_eq!(ranked.len(), 1);
|
|
209
|
-
}
|
|
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
|
-
|
|
234
|
-
#[test]
|
|
235
|
-
fn filter_already_injected_drops_marked_and_respects_limit() {
|
|
236
|
-
let ranked = vec![
|
|
237
|
-
RankItem {
|
|
238
|
-
index: 0,
|
|
239
|
-
score: 5.0,
|
|
240
|
-
},
|
|
241
|
-
RankItem {
|
|
242
|
-
index: 1,
|
|
243
|
-
score: 4.0,
|
|
244
|
-
},
|
|
245
|
-
RankItem {
|
|
246
|
-
index: 2,
|
|
247
|
-
score: 3.0,
|
|
248
|
-
},
|
|
249
|
-
];
|
|
250
|
-
let already = |i: usize| i == 1;
|
|
251
|
-
let out = filter_already_injected(ranked, already, 10);
|
|
252
|
-
assert_eq!(out.iter().map(|i| i.index).collect::<Vec<_>>(), vec![0, 2]);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
#[test]
|
|
256
|
-
fn filter_already_injected_applies_limit_after_filtering() {
|
|
257
|
-
let ranked = vec![
|
|
258
|
-
RankItem {
|
|
259
|
-
index: 0,
|
|
260
|
-
score: 5.0,
|
|
261
|
-
},
|
|
262
|
-
RankItem {
|
|
263
|
-
index: 1,
|
|
264
|
-
score: 4.0,
|
|
265
|
-
},
|
|
266
|
-
RankItem {
|
|
267
|
-
index: 2,
|
|
268
|
-
score: 3.0,
|
|
269
|
-
},
|
|
270
|
-
];
|
|
271
|
-
let out = filter_already_injected(ranked, |_| false, 2);
|
|
272
|
-
assert_eq!(out.len(), 2);
|
|
273
|
-
assert_eq!(out[0].index, 0);
|
|
274
|
-
assert_eq!(out[1].index, 1);
|
|
275
|
-
}
|
|
276
|
-
}
|
package/src/context/mod.rs
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
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_weighted(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
DELETED
package/src/main.rs
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
use std::io::{self, BufRead, Write};
|
|
2
|
-
use std::sync::mpsc;
|
|
3
|
-
use std::thread;
|
|
4
|
-
use std::time::Duration;
|
|
5
|
-
|
|
6
|
-
use handoff_mcp::mcp::protocol::process_line;
|
|
7
|
-
|
|
8
|
-
/// Maximum time a single JSON-RPC request may take before the server gives
|
|
9
|
-
/// up waiting and returns a fail-safe error response. Processing continues
|
|
10
|
-
/// on the worker thread in the background; the timeout only bounds how long
|
|
11
|
-
/// the main loop blocks waiting for a reply. Override for tests via
|
|
12
|
-
/// `HANDOFF_MCP_REQUEST_TIMEOUT_SECS` (parsed once at startup; falls back to
|
|
13
|
-
/// the 30s default on missing/invalid values).
|
|
14
|
-
const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
|
|
15
|
-
|
|
16
|
-
fn request_timeout() -> Duration {
|
|
17
|
-
let secs = std::env::var("HANDOFF_MCP_REQUEST_TIMEOUT_SECS")
|
|
18
|
-
.ok()
|
|
19
|
-
.and_then(|v| v.parse::<u64>().ok())
|
|
20
|
-
.unwrap_or(DEFAULT_REQUEST_TIMEOUT_SECS);
|
|
21
|
-
Duration::from_secs(secs)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
fn main() {
|
|
25
|
-
let args: Vec<String> = std::env::args().collect();
|
|
26
|
-
|
|
27
|
-
if args.len() >= 2 {
|
|
28
|
-
match args[1].as_str() {
|
|
29
|
-
"setup" => {
|
|
30
|
-
let check = args.iter().any(|a| a == "--check");
|
|
31
|
-
let uninstall = args.iter().any(|a| a == "--uninstall");
|
|
32
|
-
let mcp_json = args.iter().any(|a| a == "--mcp-json");
|
|
33
|
-
let yes = args.iter().any(|a| a == "-y" || a == "--yes");
|
|
34
|
-
let global = args.iter().any(|a| a == "--global");
|
|
35
|
-
let force = args.iter().any(|a| a == "--force");
|
|
36
|
-
if let Err(e) = handoff_mcp::setup::run_setup_with_opts(
|
|
37
|
-
check, uninstall, mcp_json, yes, global, force,
|
|
38
|
-
) {
|
|
39
|
-
eprintln!("Error: {e:#}");
|
|
40
|
-
std::process::exit(1);
|
|
41
|
-
}
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
"--version" | "-V" => {
|
|
45
|
-
println!("handoff-mcp v{}", env!("CARGO_PKG_VERSION"));
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
"--help" | "-h" => {
|
|
49
|
-
print_help();
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
other => {
|
|
53
|
-
if handoff_mcp::cli::is_cli_command(other) {
|
|
54
|
-
let cli_args: Vec<String> = args[1..].to_vec();
|
|
55
|
-
// Handle --help for group
|
|
56
|
-
if cli_args.len() >= 2 && (cli_args[1] == "--help" || cli_args[1] == "-h") {
|
|
57
|
-
handoff_mcp::cli::print_group_help(&cli_args[0]);
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
let code = handoff_mcp::cli::run(&cli_args);
|
|
61
|
-
std::process::exit(code);
|
|
62
|
-
}
|
|
63
|
-
eprintln!("Unknown command: {other}");
|
|
64
|
-
eprintln!("Run `handoff-mcp --help` for usage.");
|
|
65
|
-
std::process::exit(1);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
eprintln!("handoff-mcp v{}", env!("CARGO_PKG_VERSION"));
|
|
71
|
-
|
|
72
|
-
let stdin = io::stdin();
|
|
73
|
-
let mut stdout = io::stdout();
|
|
74
|
-
let timeout = request_timeout();
|
|
75
|
-
|
|
76
|
-
for line in stdin.lock().lines() {
|
|
77
|
-
let line = match line {
|
|
78
|
-
Ok(l) => l,
|
|
79
|
-
Err(e) => {
|
|
80
|
-
eprintln!("stdin read error: {e}");
|
|
81
|
-
break;
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
// Run this request on a dedicated worker thread so a slow handler
|
|
86
|
-
// can't block the main loop forever: `recv_timeout` below bounds how
|
|
87
|
-
// long we wait for a reply. The next line is only read after this
|
|
88
|
-
// request's worker completes (or times out) — sequential processing
|
|
89
|
-
// order is preserved; this is a fail-safe timeout, not parallelism.
|
|
90
|
-
let (tx, rx) = mpsc::channel();
|
|
91
|
-
let line_for_worker = line.clone();
|
|
92
|
-
thread::spawn(move || {
|
|
93
|
-
let result = process_line(&line_for_worker);
|
|
94
|
-
let _ = tx.send(result);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
match rx.recv_timeout(timeout) {
|
|
98
|
-
Ok(Some(response)) => {
|
|
99
|
-
if writeln!(stdout, "{response}").is_err() {
|
|
100
|
-
break;
|
|
101
|
-
}
|
|
102
|
-
if stdout.flush().is_err() {
|
|
103
|
-
break;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
Ok(None) => {
|
|
107
|
-
// Notification: no response expected.
|
|
108
|
-
}
|
|
109
|
-
Err(mpsc::RecvTimeoutError::Timeout) => {
|
|
110
|
-
eprintln!("Request timed out after {}s", timeout.as_secs());
|
|
111
|
-
let id = serde_json::from_str::<serde_json::Value>(&line)
|
|
112
|
-
.ok()
|
|
113
|
-
.and_then(|req| req.get("id").cloned())
|
|
114
|
-
.unwrap_or(serde_json::Value::Null);
|
|
115
|
-
let error_response = serde_json::json!({
|
|
116
|
-
"jsonrpc": "2.0",
|
|
117
|
-
"id": id,
|
|
118
|
-
"error": {
|
|
119
|
-
"code": -32603,
|
|
120
|
-
"message": format!("Request timed out after {}s", timeout.as_secs())
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
if writeln!(stdout, "{error_response}").is_err() {
|
|
124
|
-
break;
|
|
125
|
-
}
|
|
126
|
-
if stdout.flush().is_err() {
|
|
127
|
-
break;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
|
131
|
-
eprintln!("Worker thread disconnected without a response");
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
fn print_help() {
|
|
138
|
-
handoff_mcp::cli::print_cli_help();
|
|
139
|
-
println!(
|
|
140
|
-
"
|
|
141
|
-
SERVER MODE:
|
|
142
|
-
handoff-mcp Start the MCP server (stdio transport)
|
|
143
|
-
|
|
144
|
-
SETUP:
|
|
145
|
-
handoff-mcp setup Install hooks + MCP server + CLAUDE.md template
|
|
146
|
-
handoff-mcp setup --global Use ~/.claude/settings.json mcpServers (not .mcp.json)
|
|
147
|
-
handoff-mcp setup --check Check if hooks, MCP, and CLAUDE.md are configured
|
|
148
|
-
handoff-mcp setup --uninstall Remove handoff hooks and MCP entry
|
|
149
|
-
handoff-mcp setup --mcp-json Add handoff MCP server entry only (non-interactive)
|
|
150
|
-
handoff-mcp setup --force Replace existing CLAUDE.md handoff section with latest
|
|
151
|
-
handoff-mcp setup -y Skip all confirmation prompts
|
|
152
|
-
|
|
153
|
-
OPTIONS:
|
|
154
|
-
-h, --help Print this help message
|
|
155
|
-
-V, --version Print version"
|
|
156
|
-
);
|
|
157
|
-
}
|