1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct Limits {
17 pub max_words: usize,
18 pub max_chars: usize,
19}
20
21pub fn title_limits(word_target: u32) -> Limits {
24 Limits { max_words: word_target as usize + 8, max_chars: 200 }
25}
26
27pub const NAME: Limits = Limits { max_words: 12, max_chars: 100 };
29pub const PREVIEW: Limits = Limits { max_words: 24, max_chars: 240 };
31pub const NARRATION: Limits = Limits { max_words: 40, max_chars: 300 };
33pub const CONTINUED_NOTE: Limits = Limits { max_words: 80, max_chars: 520 };
36pub const NEXT_PROMPT: Limits = Limits { max_words: 28, max_chars: 160 };
39
40const REFUSAL_PHRASES: &[&str] = &[
43 "i don't have",
44 "i do not have",
45 "i don't know",
46 "i cannot",
47 "i can't",
48 "i'm unable",
49 "i am unable",
50 "i'm not able",
51 "i would need",
52 "i'd need",
53 "i need to see",
54 "i need more",
55 "if you'd like",
56 "if you would like",
57 "if you want me",
58 "without knowing",
59 "not enough context",
60 "not enough information",
61 "as an ai",
62];
63
64const RISKY_PHRASES: &[&str] = &[
67 "rm -rf",
68 "--force",
69 "force push",
70 "force-push",
71 "reset --hard",
72 "drop table",
73 "drop database",
74 "delete the database",
75 "delete all",
76 "wipe",
77 "password",
78 "secret",
79 "credential",
80 "api key",
81 "api_key",
82 "token",
83];
84
85fn normalized(text: &str) -> String {
88 text.replace(['\u{2019}', '\u{2018}'], "'").to_lowercase()
89}
90
91fn has_phrase(lower: &str, phrase: &str) -> bool {
94 let mut from = 0;
95 while let Some(found) = lower[from..].find(phrase) {
96 let start = from + found;
97 let end = start + phrase.len();
98 let before_ok = lower[..start].chars().next_back().map_or(true, |c| !c.is_alphanumeric());
99 let after_ok = lower[end..].chars().next().map_or(true, |c| !c.is_alphanumeric());
100 if before_ok && after_ok {
101 return true;
102 }
103 from = start + lower[start..].chars().next().map_or(1, |c| c.len_utf8());
105 }
106 false
107}
108
109fn has_any(lower: &str, phrases: &[&str]) -> bool {
110 phrases.iter().any(|p| has_phrase(lower, p))
111}
112
113const ABSENCE_EXACT: &[&str] = &[
123 "none",
124 "n a",
125 "na",
126 "null",
127 "nothing",
128 "empty",
129 "unknown",
130 "untitled",
131 "untitled session",
132 "untitled conversation",
133 "untitled chat",
134 "tbd",
135 "to be determined",
136 "placeholder",
137 "pending",
138 "keep",
141 "keep the current title",
144 "keep current title",
145 "keep it",
146 "no change",
147 "no changes",
148 "no update",
149 "unchanged",
150 "not set",
151 "not yet",
152 "no title",
153 "no title yet",
154 "title unavailable",
155 "no summary",
156 "no goal",
157 "no task",
158 "no activity",
159 "no recent activity",
160 "nothing yet",
161];
162
163const ABSENCE_PREFIXES: &[&str] = &[
168 "none yet",
169 "no summary yet",
170 "no goal established",
171 "no goal yet",
172 "no goal set",
173 "no task yet",
174 "no activity yet",
175 "nothing yet",
176 "not yet established",
177];
178
179fn absence_form(text: &str) -> String {
183 let mut out = String::with_capacity(text.len());
184 let mut gap = false;
185 for c in normalized(text).chars() {
186 if c.is_alphanumeric() {
187 if gap && !out.is_empty() {
188 out.push(' ');
189 }
190 gap = false;
191 out.push(c);
192 } else {
193 gap = true;
194 }
195 }
196 out
197}
198
199fn is_wrapped_note(text: &str) -> bool {
206 let t = text.trim();
207 let (open, close) = match (t.chars().next(), t.chars().last()) {
208 (Some('('), Some(')')) => ('(', ')'),
209 (Some('['), Some(']')) => ('[', ']'),
210 _ => return false,
211 };
212 let last = t.chars().count() - 1;
213 let mut depth = 0i32;
214 for (i, c) in t.chars().enumerate() {
215 if c == open {
216 depth += 1;
217 } else if c == close {
218 depth -= 1;
219 if depth <= 0 && i != last {
221 return false;
222 }
223 }
224 }
225 depth == 0
226}
227
228const LEGACY_KEEP_TOKEN: &str = "KEEP";
230
231fn leads_with_abstain_token(text: &str) -> bool {
236 let first = text.trim().split_whitespace().next().unwrap_or("");
237 first.trim_matches(|c: char| !c.is_alphanumeric()) == LEGACY_KEEP_TOKEN
238}
239
240fn is_absence(text: &str) -> bool {
241 if is_wrapped_note(text) || leads_with_abstain_token(text) {
242 return true;
243 }
244 let form = absence_form(text);
245 ABSENCE_EXACT.contains(&form.as_str())
246 || ABSENCE_PREFIXES
247 .iter()
248 .any(|p| form == *p || form.strip_prefix(*p).is_some_and(|rest| rest.starts_with(' ')))
249}
250
251pub fn accept_line(raw: &str, limits: &Limits) -> Option<String> {
255 let text = raw.trim();
256 if text.is_empty() || text.contains('\n') {
257 return None;
258 }
259 if text.chars().count() > limits.max_chars
260 || text.split_whitespace().count() > limits.max_words
261 {
262 return None;
263 }
264 if !text.chars().any(|c| c.is_alphabetic()) {
265 return None;
266 }
267 if is_absence(text) {
268 return None;
269 }
270 let lower = normalized(text);
271 if has_any(&lower, REFUSAL_PHRASES) {
272 return None;
273 }
274 Some(text.to_string())
275}
276
277pub const STORED_TITLE: Limits = Limits { max_words: 28, max_chars: 200 };
280
281pub fn is_usable_title(stored: &str) -> bool {
287 accept_line(stored, &STORED_TITLE).is_some()
288}
289
290pub fn accept_next_prompt(raw: &str) -> Option<String> {
293 let text = accept_line(raw, &NEXT_PROMPT)?;
294 if text.ends_with('?') {
296 return None;
297 }
298 if has_any(&normalized(&text), RISKY_PHRASES) {
299 return None;
300 }
301 Some(text)
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn a_short_imperative_passes_through() {
310 assert_eq!(accept_next_prompt("Run the tests"), Some("Run the tests".into()));
311 assert_eq!(
312 accept_next_prompt(" Debug the blank preview bug next "),
313 Some("Debug the blank preview bug next".into())
314 );
315 }
316
317 #[test]
318 fn the_reported_refusal_is_rejected() {
319 let reported = "I don't have access to the details of the recent activity beyond the cost summary. \
320 Without knowing what work was done or what the current state of the project is, I cannot \
321 plausibly predict the next instruction. If you'd like me to predict your next step, I'd \
322 need to see the actual recent work or conversation history.";
323 assert_eq!(accept_next_prompt(reported), None);
324 assert_eq!(accept_line(reported, &title_limits(7)), None);
325 }
326
327 #[test]
328 fn short_refusals_and_meta_talk_are_rejected() {
329 for s in [
330 "I cannot predict the next step",
331 "I don't have enough information",
332 "I don\u{2019}t have enough information",
333 "If you'd like me to continue, say so",
334 "Not enough context to decide",
335 ] {
336 assert_eq!(accept_next_prompt(s), None, "{s}");
337 }
338 }
339
340 #[test]
343 fn the_shared_corpus_is_honoured() {
344 #[derive(serde::Deserialize)]
345 struct Corpus {
346 rejected: Vec<String>,
347 accepted: Vec<String>,
348 }
349 let corpus: Corpus = serde_json::from_str(include_str!("title_corpus.json")).expect("corpus parses");
350 assert!(corpus.rejected.len() >= 30 && corpus.accepted.len() >= 10, "the corpus must not be hollowed out");
351 for s in &corpus.rejected {
352 assert_eq!(accept_line(s, &title_limits(7)), None, "must reject {s:?}");
353 assert!(!is_usable_title(s), "a stored {s:?} is not a usable title");
354 }
355 for s in &corpus.accepted {
356 assert!(accept_line(s, &title_limits(7)).is_some(), "must accept {s:?}");
357 assert!(is_usable_title(s), "a stored {s:?} is a usable title");
358 }
359 }
360
361 #[test]
363 fn the_values_found_in_the_wild_are_not_titles() {
364 assert!(!is_usable_title("(none yet)"));
365 assert!(!is_usable_title("no goal established yet"));
366 assert!(is_usable_title("Develop hardening spec for swarm ambient summary quality"));
367 }
368
369 #[test]
371 fn titles_that_merely_start_with_a_risky_prefix_or_end_in_brackets_are_accepted() {
372 for s in [
373 "No title bar on Windows",
374 "Title not updating in swarm row",
375 "(WIP) Fix login redirect (again)",
376 "[Windows] Fix installer crash [x64]",
377 "(Draft) Review the plan",
378 ] {
379 assert!(accept_line(s, &title_limits(7)).is_some(), "{s:?}");
380 assert!(is_usable_title(s), "{s:?}");
381 }
382 }
383
384 #[test]
385 fn only_one_balanced_pair_wrapping_the_whole_text_is_a_note() {
386 for note in ["(none yet)", "[no summary]", "(waiting for the user's first message)", "((none yet))", "([x] pending)"] {
387 assert!(is_wrapped_note(note), "{note:?}");
388 }
389 for title in ["(a) b (c)", "[a] b [c]", "(a) (b)", "(a", "a)", "plain"] {
390 assert!(!is_wrapped_note(title), "{title:?}");
391 }
392 }
393
394 #[test]
397 fn an_abstain_in_the_models_own_words_is_not_a_title() {
398 for s in [
399 "KEEP — the title still fits",
400 "KEEP: unchanged",
401 "KEEP the current title",
402 "Keep current title",
403 "keep the current title",
404 "No change",
405 "Unchanged",
406 "No update",
407 ] {
408 assert_eq!(accept_line(s, &title_limits(7)), None, "{s:?}");
409 }
410 for s in ["Keep alive pings", "Keep the swarm summary fresh", "Unchanged files report fix"] {
411 assert!(accept_line(s, &title_limits(7)).is_some(), "{s:?}");
412 }
413 }
414
415 #[test]
416 fn the_abstain_token_is_never_a_title() {
417 for s in ["KEEP", "keep", "Keep.", "(KEEP)", " KEEP "] {
418 assert_eq!(accept_line(s, &title_limits(7)), None, "{s:?}");
419 }
420 }
421
422 #[test]
423 fn whole_word_matching_keeps_real_titles_that_start_with_an_absence_word() {
424 for s in ["None of the tests pass", "Untitled-tab bug", "Nothing else matters here fix", "Keep alive pings"] {
425 assert!(accept_line(s, &title_limits(7)).is_some(), "{s:?}");
426 }
427 }
428
429 #[test]
430 fn empty_and_placeholder_text_is_rejected() {
431 for s in ["", " ", "...", "-", "None", "N/A", "none.", "(empty)", "null"] {
432 assert_eq!(accept_next_prompt(s), None, "{s:?}");
433 assert_eq!(accept_line(s, &NAME), None, "{s:?}");
434 }
435 }
436
437 #[test]
438 fn questions_are_rejected_as_next_prompts_but_not_as_titles() {
439 assert_eq!(accept_next_prompt("Should I run the tests?"), None);
440 assert_eq!(accept_next_prompt("What should happen next?"), None);
441 assert_eq!(
442 accept_line("Why does login fail?", &title_limits(7)),
443 Some("Why does login fail?".into())
444 );
445 }
446
447 #[test]
448 fn multi_line_and_over_long_text_is_rejected() {
449 assert_eq!(accept_next_prompt("Run the tests\nthen push"), None);
450 let long = "Fix ".to_string() + &"the thing and ".repeat(20);
451 assert_eq!(accept_next_prompt(&long), None);
452 let many_words = vec!["go"; 30].join(" ");
453 assert_eq!(accept_next_prompt(&many_words), None);
454 }
455
456 #[test]
457 fn risky_commands_are_rejected() {
458 for s in [
459 "Run rm -rf on the build folder",
460 "Force push the branch",
461 "Push with --force",
462 "git reset --hard origin/main",
463 "Print the API token",
464 "Drop table users",
465 "Wipe the disk",
466 ] {
467 assert_eq!(accept_next_prompt(s), None, "{s}");
468 }
469 }
470
471 #[test]
472 fn ordinary_commands_pass_and_over_blocking_is_deliberate() {
473 assert_eq!(
476 accept_next_prompt("Merge the PR once CI is green"),
477 Some("Merge the PR once CI is green".into())
478 );
479 }
480
481 #[test]
482 fn the_text_handed_back_is_not_rewritten() {
483 assert_eq!(
485 accept_line("Fix the user\u{2019}s login", &NAME),
486 Some("Fix the user\u{2019}s login".into())
487 );
488 }
489
490 #[test]
491 fn titles_and_names_may_name_problems_and_secrets_in_ordinary_words() {
492 assert_eq!(
493 accept_line("Fix insufficient permissions error", &title_limits(7)),
494 Some("Fix insufficient permissions error".into())
495 );
496 assert_eq!(
497 accept_line("Rotate the API token flow", &NAME),
498 Some("Rotate the API token flow".into())
499 );
500 }
501
502 #[test]
503 fn titles_and_names_reject_refusals_paragraphs_and_overruns() {
504 assert_eq!(accept_line("I cannot determine a title without more context", &title_limits(7)), None);
505 assert_eq!(accept_line("Task\nName: login", &NAME), None);
506 let long = vec!["word"; 40].join(" ");
507 assert_eq!(accept_line(&long, &NAME), None);
508 assert_eq!(accept_line(&long, &title_limits(7)), None);
509 assert!(accept_line("Fix login blank screen bug", &NAME).is_some());
510 }
511
512 #[test]
513 fn narration_allows_a_sentence_but_not_a_paragraph() {
514 assert!(accept_line("I started the dev server in the background.", &NARRATION).is_some());
515 let paragraph = vec!["word"; 60].join(" ");
516 assert_eq!(accept_line(¶graph, &NARRATION), None);
517 }
518
519 #[test]
520 fn phrases_match_whole_words_only() {
521 for t in ["Fix pane has an AI summary bug", "Agent was an AI wrapper", "Rewire the bias in token counts"] {
523 assert_eq!(accept_line(t, &title_limits(7)).as_deref(), Some(t), "{t}");
524 }
525 assert_eq!(accept_line("As an AI, I have no title", &NAME), None);
526 assert_eq!(accept_line("Sorry, as an AI model I can't say", &NAME), None);
527 assert!(has_phrase("sorry. i cannot do that", "i cannot"));
528 assert!(!has_phrase("lexi cannot", "i cannot"));
529 }
530
531 #[test]
532 fn risky_words_inside_other_words_no_longer_block_a_next_prompt() {
533 assert_eq!(accept_next_prompt("Email the secretary"), Some("Email the secretary".into()));
536 assert_eq!(accept_next_prompt("Print the secret"), None);
537 }
538
539 #[test]
540 fn title_limits_scale_with_the_requested_word_count() {
541 assert_eq!(title_limits(7).max_words, 15);
542 assert_eq!(title_limits(20).max_words, 28);
543 }
544}