1use std::collections::{BTreeMap, BTreeSet};
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13
14use crate::config::Config;
15
16const NUDGE_CLASSIFIER_FILE: &str = "nudge.toml";
17const BUNDLED_NUDGE_CLASSIFIER: &str = include_str!("classifiers/nudge.toml");
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum NudgeClass {
22 PendingAction,
24 PlanUpdate,
26 FinalAnswer,
28 Unknown,
30}
31
32#[derive(Debug, Clone, PartialEq)]
34pub struct NudgeClassification {
35 pub class: NudgeClass,
36 pub score: f32,
37}
38
39impl NudgeClassification {
40 pub fn is_pending_action(&self) -> bool {
41 matches!(
42 self.class,
43 NudgeClass::PendingAction | NudgeClass::PlanUpdate
44 )
45 }
46
47 pub fn is_plan_update(&self) -> bool {
48 self.class == NudgeClass::PlanUpdate
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct NudgeClassifierConfig {
55 #[serde(default = "default_classifier_version")]
57 pub version: u32,
58 #[serde(default = "default_min_score")]
60 pub min_score: f32,
61 #[serde(default = "default_min_margin")]
63 pub min_margin: f32,
64 #[serde(default)]
67 pub classes: BTreeMap<String, NudgeClassConfig>,
68}
69
70#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
72pub struct NudgeClassConfig {
73 #[serde(default)]
75 pub matchers: Vec<String>,
76 #[serde(default)]
79 pub nudge: String,
80}
81
82impl Default for NudgeClassifierConfig {
83 fn default() -> Self {
84 bundled_nudge_config()
85 }
86}
87
88fn default_classifier_version() -> u32 {
89 1
90}
91
92fn default_min_score() -> f32 {
93 0.28
94}
95
96fn default_min_margin() -> f32 {
97 0.03
98}
99
100fn bundled_nudge_config() -> NudgeClassifierConfig {
101 toml::from_str(BUNDLED_NUDGE_CLASSIFIER).expect("bundled nudge classifier template is valid")
102}
103
104fn bundled_nudge_classes() -> BTreeMap<String, NudgeClassConfig> {
105 bundled_nudge_config().classes
106}
107
108impl NudgeClassifierConfig {
109 pub fn load_file(path: &Path) -> anyhow::Result<Self> {
112 match std::fs::read_to_string(path) {
113 Ok(text) => {
114 let cfg: Self = toml::from_str(&text)?;
115 Ok(cfg.with_builtin_fallbacks())
116 }
117 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
118 Err(e) => Err(e.into()),
119 }
120 }
121
122 pub fn load_from_dir(dir: &Path) -> anyhow::Result<Self> {
124 Self::load_file(&dir.join(NUDGE_CLASSIFIER_FILE))
125 }
126
127 fn with_builtin_fallbacks(mut self) -> Self {
128 let builtins = bundled_nudge_classes();
129 for (class, bundled) in builtins {
130 self.classes
131 .entry(class)
132 .and_modify(|configured| {
133 if configured.matchers.is_empty() {
134 configured.matchers = bundled.matchers.clone();
135 }
136 if configured.nudge.trim().is_empty() {
137 configured.nudge = bundled.nudge.clone();
138 }
139 })
140 .or_insert(bundled);
141 }
142 if self.min_score <= 0.0 {
143 self.min_score = default_min_score();
144 }
145 if self.min_margin < 0.0 {
146 self.min_margin = default_min_margin();
147 }
148 self
149 }
150}
151
152#[derive(Debug, Clone)]
154pub struct NudgeClassifier {
155 cfg: NudgeClassifierConfig,
156}
157
158impl Default for NudgeClassifier {
159 fn default() -> Self {
160 Self::builtin()
161 }
162}
163
164impl NudgeClassifier {
165 pub fn builtin() -> Self {
166 Self {
167 cfg: NudgeClassifierConfig::default(),
168 }
169 }
170
171 pub fn from_config(cfg: NudgeClassifierConfig) -> Self {
172 Self {
173 cfg: cfg.with_builtin_fallbacks(),
174 }
175 }
176
177 pub fn load_from_dir(dir: &Path) -> anyhow::Result<Self> {
178 Ok(Self::from_config(NudgeClassifierConfig::load_from_dir(
179 dir,
180 )?))
181 }
182
183 pub fn load_default() -> Self {
188 #[cfg(test)]
189 {
190 Self::builtin()
191 }
192 #[cfg(not(test))]
193 {
194 let Some(dir) = classifier_config_dir() else {
195 return Self::builtin();
196 };
197 match Self::load_from_dir(&dir) {
198 Ok(classifier) => classifier,
199 Err(e) => {
200 tracing::warn!(
201 path = %dir.join(NUDGE_CLASSIFIER_FILE).display(),
202 error = %e,
203 "failed to load nudge classifier config; using built-ins"
204 );
205 Self::builtin()
206 }
207 }
208 }
209 }
210
211 pub fn classify(&self, text: &str) -> NudgeClassification {
212 let query = tokens(text);
213 if query.is_empty() {
214 return NudgeClassification {
215 class: NudgeClass::Unknown,
216 score: 0.0,
217 };
218 }
219
220 let mut scored: Vec<(NudgeClass, f32)> = self
221 .cfg
222 .classes
223 .iter()
224 .filter_map(|(class, class_cfg)| {
225 let class = parse_nudge_class(class)?;
226 let best = class_cfg
227 .matchers
228 .iter()
229 .map(|example| prototype_similarity(&query, &tokens(example)))
230 .fold(0.0_f32, f32::max);
231 Some((class, best))
232 })
233 .collect();
234 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
235 let Some((class, score)) = scored.first().copied() else {
236 return NudgeClassification {
237 class: NudgeClass::Unknown,
238 score: 0.0,
239 };
240 };
241 let runner_up = scored.get(1).map(|(_, s)| *s).unwrap_or(0.0);
242 if score < self.cfg.min_score || (score - runner_up) < self.cfg.min_margin {
243 return NudgeClassification {
244 class: NudgeClass::Unknown,
245 score,
246 };
247 }
248 NudgeClassification { class, score }
249 }
250
251 pub fn is_pending_action(&self, text: &str) -> bool {
252 self.classify(text).is_pending_action()
253 }
254
255 pub fn direction_for(&self, class: NudgeClass) -> Option<&str> {
256 self.cfg
257 .classes
258 .get(nudge_class_key(class))
259 .map(|class_cfg| class_cfg.nudge.as_str())
260 .map(str::trim)
261 .filter(|direction| !direction.is_empty())
262 }
263}
264
265pub fn classifier_config_dir() -> Option<PathBuf> {
268 Config::user_config_dir().map(|dir| dir.join("classifiers"))
269}
270
271fn parse_nudge_class(s: &str) -> Option<NudgeClass> {
272 match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
273 "pending_action" | "continue" | "continuation" => Some(NudgeClass::PendingAction),
274 "plan_update" | "update_plan" | "stale_plan" | "replan" => Some(NudgeClass::PlanUpdate),
275 "final_answer" | "final" | "done" => Some(NudgeClass::FinalAnswer),
276 _ => None,
277 }
278}
279
280fn nudge_class_key(class: NudgeClass) -> &'static str {
281 match class {
282 NudgeClass::PendingAction => "pending_action",
283 NudgeClass::PlanUpdate => "plan_update",
284 NudgeClass::FinalAnswer => "final_answer",
285 NudgeClass::Unknown => "unknown",
286 }
287}
288
289fn tokens(s: &str) -> BTreeSet<String> {
290 s.split(|c: char| !c.is_alphanumeric())
291 .filter_map(|raw| {
292 let t = raw.trim().to_ascii_lowercase();
293 (t.len() >= 3).then_some(t)
294 })
295 .collect()
296}
297
298fn jaccard(a: &BTreeSet<String>, b: &BTreeSet<String>) -> f32 {
299 if a.is_empty() || b.is_empty() {
300 return 0.0;
301 }
302 let intersection = a.intersection(b).count() as f32;
303 let union = a.union(b).count() as f32;
304 if union == 0.0 {
305 0.0
306 } else {
307 intersection / union
308 }
309}
310
311fn prototype_similarity(query: &BTreeSet<String>, prototype: &BTreeSet<String>) -> f32 {
312 if query.is_empty() || prototype.is_empty() {
313 return 0.0;
314 }
315 let overlap = query.intersection(prototype).count() as f32;
316 let prototype_recall = overlap / prototype.len() as f32;
317 jaccard(query, prototype).max(prototype_recall)
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn nudge_classifier_builtin_matches_known_phrases() {
326 let classifier = NudgeClassifier::builtin();
327 assert!(classifier.is_pending_action(
328 "Plan is current — no update needed. Continuing with step 2: inserting the progressive dispatch into lib.rs."
329 ));
330 assert!(classifier.is_pending_action(
331 "\
332Summary
333
334Progress so far: Step 2 is in progress.
335
336Current blocker: The for loop needs to iterate over the slice directly.
337The fix is to use for entry in page.entries.iter().
338
339Next steps needed:
3401. Fix the iteration type error in lib.rs.
3412. Add tests.
3423. Run just check."
343 ));
344 assert!(classifier.is_pending_action("Now I'll add the --home flag to the Cli struct."));
345 assert!(classifier.is_pending_action("Let me keep editing now."));
346 assert!(classifier.is_pending_action(
347 "I found the issue - there's an extra closing brace } on line 809 of help_sections.rs that's causing a syntax error. I need to remove this stray brace."
348 ));
349 assert!(classifier.is_pending_action(
350 "I have two issues: duplicate topic_has_rollups and a stray brace. Let me fix both — read around 490 to see what needs removing, then verify with a build check."
351 ));
352 assert!(!classifier.is_pending_action("The capital of France is Paris."));
353 assert!(!classifier.is_pending_action("Done. Let me know if you want any further changes."));
354 assert!(!classifier.is_pending_action(
355 "The duplicate helper definitions and stray brace were removed, and the build check passed."
356 ));
357 assert!(
358 classifier
359 .direction_for(NudgeClass::PendingAction)
360 .is_some_and(|d| d.contains("emit the tool call now")),
361 "bundled classifier should carry output direction text"
362 );
363 }
364
365 #[test]
366 fn nudge_classifier_separates_plan_update_summaries_from_final_summaries() {
367 let classifier = NudgeClassifier::builtin();
368 let findings = "\
369Summary of Findings
370
371Across the tool calls, I observed two issues in newt-tui/src/help_sections.rs:
3721. Duplicate function
3732. Stray closing brace
374
375Current Status
376
377The build is broken due to these syntax errors. The plan was at step 2, but we need to fix the immediate compilation issues first.
378
379Next Steps Required
380
381To continue, I would need to remove the duplicate function using edit_file, remove the stray brace using edit_file, verify cargo check, then proceed with step 2 of the plan.
382
383However, I've reached the tool-call limit and cannot make these edits now.";
384
385 let classified = classifier.classify(findings);
386 assert_eq!(classified.class, NudgeClass::PlanUpdate);
387 assert!(classified.is_pending_action());
388 assert!(classified.is_plan_update());
389
390 let resume_handoff = "\
391Summary
392
393I reached the tool-call limit. Current state of newt-tui/src/help_sections.rs:
394duplicate topic_has_rollups and rollup_page_for_topic definitions need to be removed.
395
396Recommended next action if session resumes:
3971. Fix the duplicate functions.
3982. Clean up the broken test block.
3993. Read lib.rs and wire the progressive dispatch.
400
401The build is currently broken due to the duplicate definitions — that's the blocker for any further progress.";
402 assert_eq!(
403 classifier.classify(resume_handoff).class,
404 NudgeClass::PlanUpdate
405 );
406
407 let final_summary =
408 classifier.classify("Here is a summary of what I found across the tool calls.");
409 assert_eq!(final_summary.class, NudgeClass::FinalAnswer);
410 }
411
412 #[test]
413 fn nudge_classifier_loads_dropin_from_classifiers_dir() {
414 let dir = tempfile::tempdir().unwrap();
415 let classifiers = dir.path().join("classifiers");
416 std::fs::create_dir_all(&classifiers).unwrap();
417 std::fs::write(
418 classifiers.join(NUDGE_CLASSIFIER_FILE),
419 r#"
420version = 1
421min_score = 0.20
422min_margin = 0.01
423
424[classes.pending_action]
425matchers = ["Proceeding with the patch by editing the target file."]
426nudge = "Call the edit tool now."
427
428[classes.final_answer]
429matchers = ["No changes are needed."]
430"#,
431 )
432 .unwrap();
433
434 let classifier = NudgeClassifier::load_from_dir(&classifiers).unwrap();
435 assert!(
436 classifier.is_pending_action("Proceeding with the patch by editing the target file.")
437 );
438 assert!(!classifier.is_pending_action("No changes are needed."));
439 assert_eq!(
440 classifier.direction_for(NudgeClass::PendingAction),
441 Some("Call the edit tool now.")
442 );
443 assert!(
444 classifier
445 .direction_for(NudgeClass::PlanUpdate)
446 .is_some_and(|d| d.contains("Update the plan first")),
447 "partial user configs still inherit bundled nudges"
448 );
449 }
450
451 #[test]
452 fn classifier_dir_is_under_user_config_dir() {
453 let dir = classifier_config_dir().unwrap_or_else(|| PathBuf::from(".newt/classifiers"));
454 assert!(dir.ends_with("classifiers"));
455 }
456}