1pub mod chips;
12pub mod proposal;
13pub mod skill;
14pub mod tools;
15
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex};
18
19use agentplane::model::ModelProvider;
20use serde::Deserialize;
21use serde_json::{Value, json};
22use speccoder_core::ask::{
23 AskConversations, AskReport, AskResult, AskThread, AskUsage, Conversation, Read,
24};
25use speccoder_core::{CommandData, CommandResult, CommandStatus};
26
27use crate::progress::{Event, Progress};
28use crate::substrate::{self, model_config::ModelConfig};
29
30pub use chips::ChipRef;
31
32pub const PROPOSE: &str = "propose";
34
35pub const MAX_QUESTION: usize = 8_000;
37
38const FULL_AT: u64 = 150_000;
41
42const CARRIED: usize = 24_000;
44
45fn segment_key(id: &str, segment: u32) -> String {
47 if segment == 0 {
48 id.to_owned()
49 } else {
50 format!("{id}.{segment}")
51 }
52}
53
54fn earlier(turns: &[AskReport]) -> String {
56 let mut kept: Vec<String> = Vec::new();
57 let mut size = 0;
58 for t in turns.iter().rev() {
59 let answer = if t.answer.is_empty() {
60 "(no answer)"
61 } else {
62 &t.answer
63 };
64 let block = format!("Q: {}\nA: {answer}\n", t.question);
65 size += block.len();
66 if size > CARRIED {
67 break;
68 }
69 kept.push(block);
70 }
71 kept.reverse();
72 format!(
73 "<context id=\"earlier\" kind=\"conversation\">\nThe conversation so far, carried over from \
74its earlier run. What it read then is not read again: read before you cite.\n\n{}</context>\n\n",
75 kept.join("\n")
76 )
77}
78
79type Halt = agentplane::model::Stop;
82static LIVE: std::sync::LazyLock<Mutex<std::collections::HashMap<String, Halt>>> =
83 std::sync::LazyLock::new(Mutex::default);
84
85struct Live {
87 id: String,
88 halt: Halt,
89 owned: bool,
92}
93
94impl Live {
95 fn enter(id: &str, halt: &Halt) -> Self {
96 let owned = LIVE.lock().is_ok_and(|mut live| {
97 if live.contains_key(id) {
98 return false;
99 }
100 live.insert(id.to_owned(), halt.clone());
101 true
102 });
103 Self {
104 id: id.to_owned(),
105 halt: halt.clone(),
106 owned,
107 }
108 }
109
110 async fn notified(&self) {
111 self.halt.stopped().await;
112 }
113}
114
115impl Drop for Live {
116 fn drop(&mut self) {
117 if !self.owned {
118 return;
119 }
120 if let Ok(mut live) = LIVE.lock() {
121 live.remove(&self.id);
122 }
123 }
124}
125
126#[must_use]
131pub fn stop(id: Option<&str>) -> bool {
132 let halts: Vec<Halt> = LIVE.lock().map_or_else(
133 |_| Vec::new(),
134 |live| {
135 live.iter()
136 .filter(|(k, _)| id.is_none_or(|id| *k == id))
137 .map(|(_, h)| h.clone())
138 .collect()
139 },
140 );
141 for halt in &halts {
142 halt.stop();
143 }
144 !halts.is_empty()
145}
146
147#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
149#[serde(deny_unknown_fields)]
150pub struct Settings {
151 pub model: Option<String>,
153 #[serde(default = "turn_default")]
155 pub max_turn_tokens: u64,
156 #[serde(default = "day_default")]
158 pub daily_tokens: u64,
159}
160
161const fn turn_default() -> u64 {
162 100_000
163}
164
165const fn day_default() -> u64 {
166 1_000_000
167}
168
169impl Default for Settings {
170 fn default() -> Self {
171 Self {
172 model: None,
173 max_turn_tokens: turn_default(),
174 daily_tokens: day_default(),
175 }
176 }
177}
178
179pub fn settings(config: &str) -> Result<Settings, String> {
185 let table: toml::Table = config.parse().map_err(|e: toml::de::Error| e.to_string())?;
186 match table.get("ask") {
187 None => Ok(Settings::default()),
188 Some(v) => v
189 .clone()
190 .try_into()
191 .map_err(|e: toml::de::Error| format!("[ask]: {e}")),
192 }
193}
194
195#[must_use]
197pub fn turn_tag(id: &str, n: u32) -> String {
198 format!("ask:{id}#{n}")
199}
200
201#[must_use]
203pub fn read_of(id: &str, tool: &str, arguments: &Value, output: &Value) -> Read {
204 let digest = crate::integrity::sha256_hex(output.to_string().as_bytes());
205 let line = |v: &Value| v.as_u64().and_then(|n| u32::try_from(n).ok());
206 let (path, start, end) = match tool {
207 "read_file" => (
208 output["path"].as_str().map(str::to_owned),
209 line(&output["start_line"]),
210 line(&output["end_line"]),
211 ),
212 "git_show" => (None, None, None),
214 "journal_account" => (None, line(&arguments["from"]), line(&arguments["to"])),
216 _ => (None, None, None),
217 };
218 let a = |k: &str| arguments[k].as_str().unwrap_or_default().to_owned();
219 let target = match tool {
220 "task_evidence" => format!("evidence of {}", a("task")),
221 "report" => match a("feature") {
222 f if f.is_empty() => "the report".to_owned(),
223 f => format!("the report of {f}"),
224 },
225 "inbox" => "Needs You".to_owned(),
226 "explain" => format!("code {}", a("code")),
227 "journal_account" => format!("account of run {}", a("run")),
228 "git_log" => match a("path") {
229 p if p.is_empty() => "git history".to_owned(),
230 p => format!("git history of {p}"),
231 },
232 "git_show" => format!("{} at {}", a("path"), a("rev")).trim().to_owned(),
233 "git_status" => "the working tree".to_owned(),
234 "git_diff" => format!("diff {}", a("path")).trim().to_owned(),
235 "list_files" => match a("path") {
236 p if p.is_empty() || p == "." => "the files".to_owned(),
237 p => format!("files in {p}"),
238 },
239 _ => crate::progress::target(tool, arguments),
240 };
241 let content_sha256 = (tool == "read_file")
242 .then(|| output["content"].as_str())
243 .flatten()
244 .map(|c| crate::integrity::sha256_hex(c.as_bytes()));
245 let subject = match tool {
246 "task_evidence" => Some(a("task")),
247 "journal_account" => Some(a("run")),
248 _ => None,
249 }
250 .filter(|s| !s.is_empty());
251 Read {
252 id: id.to_owned(),
253 tool: tool.to_owned(),
254 target,
255 path,
256 start,
257 end,
258 digest,
259 content_sha256,
260 subject,
261 }
262}
263
264#[must_use]
268pub fn results(path: &Path, state: &Path, id: &str) -> Vec<AskResult> {
269 use speccoder_core::ladder::Origin;
270 let plane = crate::worktree::project_state_dir(state, path);
271 let mut out: Vec<(u64, AskResult)> = Vec::new();
272 for (subject, origins) in crate::ladder::origins(&plane) {
273 for o in origins {
274 match o {
275 Origin::Asked {
276 conversation,
277 turn,
278 at,
279 } if conversation == id => {
280 let (unit, requirement) = subject
281 .rsplit_once('/')
282 .map_or((subject.as_str(), None), |(u, r)| (u, Some(r)));
283 let task = requirement.and_then(|r| task_of(path, unit, r));
284 out.push((
285 at,
286 AskResult {
287 turn,
288 kind: "change".to_owned(),
289 unit: unit.to_owned(),
290 requirement: requirement.map(str::to_owned),
291 task,
292 },
293 ));
294 }
295 Origin::PromotedFrom {
296 conversations, at, ..
297 } if conversations.iter().any(|(c, _)| c == id) => out.push((
298 at,
299 AskResult {
300 turn: None,
301 kind: "spec".to_owned(),
302 unit: subject.clone(),
303 requirement: None,
304 task: None,
305 },
306 )),
307 _ => {}
308 }
309 }
310 }
311 out.sort_by_key(|(at, _)| *at);
312 out.into_iter().map(|(_, r)| r).collect()
313}
314
315fn task_of(path: &Path, unit: &str, requirement: &str) -> Option<String> {
317 let r = crate::status::report(path, Some(unit));
318 let Some(CommandData::Report(s)) = r.data() else {
319 return None;
320 };
321 s.trace
322 .iter()
323 .find(|t| t.requirement == requirement && t.inherited.is_none())
324 .and_then(|t| t.task.clone())
325}
326
327#[must_use]
331pub fn changed_since(
332 root: &Path,
333 path: &str,
334 start: Option<u32>,
335 end: Option<u32>,
336 content_sha256: &str,
337) -> Option<bool> {
338 let tools =
339 crate::tools::WorkspaceTools::new(root, crate::tools::policy::Policy::new(&[], &[]))
340 .ok()?;
341 let args = json!({ "path": path, "start_line": start, "end_line": end });
342 let out = crate::pipeline::tokio_runtime()
343 .ok()?
344 .block_on(tools.run("read_file", &args));
345 let now = out["content"].as_str()?;
346 Some(crate::integrity::sha256_hex(now.as_bytes()) != content_sha256)
347}
348
349#[derive(Debug, Clone, Default, Deserialize)]
351pub struct Request {
352 pub conversation: Option<String>,
354 #[serde(default)]
357 pub id: Option<String>,
358 pub text: String,
360 #[serde(default)]
362 pub chips: Vec<ChipRef>,
363 pub feature: Option<String>,
365}
366
367fn refuse(status: CommandStatus, code: &str, message: &str) -> CommandResult {
368 CommandResult::new("ask", status, message).with_code(code)
369}
370
371fn plane_dir(path: &Path, state: &Path) -> Result<(PathBuf, PathBuf), Box<CommandResult>> {
373 let Some(root) = crate::format::at(path).detect(path) else {
374 return Err(Box::new(refuse(
375 CommandStatus::NotAProject,
376 "NO-PROJECT",
377 "not a spec project here",
378 )));
379 };
380 let dir = crate::worktree::project_state_dir(state, &root).join("ask");
381 Ok((root, dir))
382}
383
384fn state_or(state: Option<&Path>) -> Result<PathBuf, Box<CommandResult>> {
385 state
386 .map(Path::to_path_buf)
387 .or_else(crate::worktree::state_dir)
388 .ok_or_else(|| {
389 Box::new(refuse(
390 CommandStatus::ConfigError,
391 "NO-STATE-DIR",
392 "no state directory (set SPECCODER_STATE_DIR or HOME)",
393 ))
394 })
395}
396
397fn thread_path(dir: &Path, id: &str) -> PathBuf {
398 dir.join("threads").join(format!("{id}.json"))
399}
400
401#[must_use]
404pub fn valid_id(id: &str) -> bool {
405 id.strip_prefix("c-")
406 .is_some_and(|h| !h.is_empty() && h.len() <= 40 && h.bytes().all(|b| b.is_ascii_hexdigit()))
407}
408
409fn load(dir: &Path, id: &str) -> Option<AskThread> {
410 if !valid_id(id) {
411 return None;
412 }
413 serde_json::from_str(&std::fs::read_to_string(thread_path(dir, id)).ok()?).ok()
414}
415
416fn save(dir: &Path, thread: &AskThread) -> Result<(), String> {
417 let path = thread_path(dir, &thread.conversation.id);
418 if let Some(parent) = path.parent() {
419 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
420 }
421 let tmp = path.with_extension("json.tmp");
422 std::fs::write(
423 &tmp,
424 serde_json::to_vec_pretty(thread).map_err(|e| e.to_string())?,
425 )
426 .and_then(|()| std::fs::rename(&tmp, &path))
427 .map_err(|e| e.to_string())
428}
429
430fn now() -> u64 {
431 std::time::SystemTime::now()
432 .duration_since(std::time::UNIX_EPOCH)
433 .map_or(0, |d| d.as_secs())
434}
435
436fn used_today(dir: &Path) -> (u64, u64) {
438 let day = now() / 86_400;
439 let used = std::fs::read_to_string(dir.join("usage.json"))
440 .ok()
441 .and_then(|t| serde_json::from_str::<Value>(&t).ok())
442 .filter(|v| v["day"].as_u64() == Some(day))
443 .and_then(|v| v["tokens"].as_u64())
444 .unwrap_or(0);
445 (day, used)
446}
447
448fn add_today(dir: &Path, tokens: u64) {
449 let (day, used) = used_today(dir);
450 let _ = std::fs::create_dir_all(dir);
451 let _ = std::fs::write(
452 dir.join("usage.json"),
453 json!({ "day": day, "tokens": used + tokens }).to_string(),
454 );
455}
456
457#[must_use]
459pub fn new_id() -> String {
460 let nanos = std::time::SystemTime::now()
461 .duration_since(std::time::UNIX_EPOCH)
462 .map_or(0, |d| d.as_nanos());
463 format!(
464 "c-{:x}{:04x}",
465 nanos / 1_000_000,
466 std::process::id() & 0xffff
467 )
468}
469
470#[must_use]
473pub fn turn(
474 path: &Path,
475 request: &Request,
476 state: Option<&Path>,
477 sink: Option<Arc<dyn Progress>>,
478) -> CommandResult {
479 let state = match state_or(state) {
480 Ok(s) => s,
481 Err(e) => return *e,
482 };
483 let config = crate::verify::find_config(path)
484 .and_then(|root| std::fs::read_to_string(root.join(crate::verify::config::FILE)).ok())
485 .unwrap_or_default();
486 let ask = match settings(&config) {
487 Ok(a) => a,
488 Err(e) => {
489 return refuse(
490 CommandStatus::ConfigError,
491 "CONFIG",
492 &format!("speccoder.toml: {e}"),
493 );
494 }
495 };
496 let model = match substrate::model_config::parse(&config) {
497 Ok(Some(mut m)) => {
498 if let Some(name) = &ask.model {
499 m.model.clone_from(name);
500 }
501 m
502 }
503 Ok(None) => {
504 return refuse(
505 CommandStatus::ConfigError,
506 "ASK-NO-MODEL",
507 "Ask needs a model ([model] in speccoder.toml); \"Make this a…\" works without one",
508 );
509 }
510 Err(e) => {
511 return refuse(
512 CommandStatus::ConfigError,
513 "CONFIG",
514 &format!("speccoder.toml: {e}"),
515 );
516 }
517 };
518 match substrate::model_config::open_driver(&model) {
519 Ok((provider, _)) => turn_with(path, request, &state, &ask, &model, provider, sink),
520 Err(e) => substrate::model_config::refused("ask", &e),
521 }
522}
523
524#[derive(Debug)]
527struct Collector {
528 conversation: String,
529 turn: u32,
530 tag: String,
531 sink: Option<Arc<dyn Progress>>,
532 ended: Mutex<Option<Value>>,
533}
534
535impl Progress for Collector {
536 fn event(&self, event: &Event) {
537 match event {
538 Event::ToolCall {
539 task,
540 tool,
541 target,
542 decision,
543 ok,
544 ..
545 } if *task == self.tag => {
546 if let Some(sink) = &self.sink {
547 sink.event(&Event::AskStep {
548 conversation: self.conversation.clone(),
549 turn: self.turn,
550 tool: tool.clone(),
551 target: target.clone(),
552 read: None,
553 refused: decision != "allow" && decision != "proposed"
554 || *ok == Some(false),
555 });
556 }
557 }
558 Event::AskTurnEnded { tag, output } if *tag == self.tag => {
559 if let Ok(mut e) = self.ended.lock() {
560 *e = Some((**output).clone());
561 }
562 }
563 _ => {}
564 }
565 }
566}
567
568#[derive(Debug)]
570struct Words {
571 conversation: String,
572 turn: u32,
573 sink: Option<Arc<dyn Progress>>,
574 text: Mutex<String>,
575 halt: Halt,
577}
578
579impl Words {
580 fn text(&self) -> String {
581 self.text.lock().map(|t| t.clone()).unwrap_or_default()
582 }
583}
584
585impl agentplane::runtime::RunStreamObserver for Words {
586 fn event(
587 &self,
588 _run: agentplane::core::RunId,
589 event: agentplane::core::Tainted<agentplane::model::ModelStreamEvent>,
590 ) {
591 if self.halt.is_stopped() {
592 return;
593 }
594 if let agentplane::model::ModelStreamEvent::TextDelta(delta) = event.peek() {
595 if let Ok(mut t) = self.text.lock() {
596 t.push_str(delta);
597 }
598 if let Some(sink) = &self.sink {
599 sink.event(&Event::AskText {
600 conversation: self.conversation.clone(),
601 turn: self.turn,
602 delta: delta.clone(),
603 });
604 }
605 }
606 }
607}
608
609#[must_use]
611pub fn turn_with(
612 path: &Path,
613 request: &Request,
614 state: &Path,
615 ask: &Settings,
616 model: &ModelConfig,
617 provider: Arc<dyn ModelProvider>,
618 sink: Option<Arc<dyn Progress>>,
619) -> CommandResult {
620 let text = request.text.trim();
621 if !text.chars().any(char::is_alphanumeric) {
622 return refuse(
623 CommandStatus::UsageError,
624 "USAGE",
625 "ask a question first — any words",
626 );
627 }
628 if text.chars().count() > MAX_QUESTION {
629 return refuse(
630 CommandStatus::UsageError,
631 "USAGE",
632 &format!("a question is at most {MAX_QUESTION} characters"),
633 );
634 }
635 if request.chips.len() > chips::MAX {
636 return refuse(
637 CommandStatus::UsageError,
638 "USAGE",
639 &format!("at most {} chips per question", chips::MAX),
640 );
641 }
642 let (root, dir) = match plane_dir(path, state) {
643 Ok(d) => d,
644 Err(e) => return *e,
645 };
646 let mut thread = match &request.conversation {
647 Some(id) => match load(&dir, id) {
648 Some(t) => t,
649 None => {
650 return refuse(
651 CommandStatus::NotFound,
652 "NOT-FOUND",
653 &format!("no conversation {id}"),
654 );
655 }
656 },
657 None if request.id.as_deref().is_some_and(|i| !valid_id(i)) => {
658 return refuse(
659 CommandStatus::UsageError,
660 "USAGE",
661 "a conversation id is `c-` and hex digits",
662 );
663 }
664 None => AskThread {
665 conversation: Conversation {
666 id: request.id.clone().unwrap_or_else(new_id),
667 feature: request.feature.clone(),
668 title: text.chars().take(120).collect(),
669 at: now(),
670 turns: 0,
671 open: true,
672 },
673 turns: Vec::new(),
674 segment: 0,
675 run: None,
676 results: Vec::new(),
677 },
678 };
679 let id = thread.conversation.id.clone();
680 let n = u32::try_from(thread.turns.len()).unwrap_or(u32::MAX - 1) + 1;
681 let reads_from = 1 + thread.turns.iter().map(|t| t.reads.len()).sum::<usize>();
682 let resolved: Result<Vec<_>, String> = request
683 .chips
684 .iter()
685 .enumerate()
686 .map(|(i, c)| chips::resolve(&root, &format!("c{}", i + 1), c))
687 .collect();
688 let resolved = match resolved {
689 Ok(r) => r,
690 Err(e) => {
691 return refuse(
692 CommandStatus::UsageError,
693 "USAGE",
694 &format!("a chip cannot be read: {e}"),
695 );
696 }
697 };
698 let mut report = AskReport {
699 conversation: id.clone(),
700 turn: n,
701 question: text.to_owned(),
702 chips: resolved.iter().map(|(c, _)| c.clone()).collect(),
703 reads: Vec::new(),
704 answer: String::new(),
705 citations: Vec::new(),
706 proposal: None,
707 usage: AskUsage::default(),
708 end: "answered".to_owned(),
709 reason: None,
710 context: None,
711 };
712 let (_, used) = used_today(&dir);
713 let left = ask.daily_tokens.saturating_sub(used);
714 if left == 0 {
715 report.end = "budget".to_owned();
716 report.reason = Some(format!(
717 "Ask's tokens for today are spent ([ask] daily_tokens = {})",
718 ask.daily_tokens
719 ));
720 return finish(&dir, thread, report, sink.as_ref());
721 }
722 let collector = Arc::new(Collector {
723 conversation: id.clone(),
724 turn: n,
725 tag: turn_tag(&id, n),
726 sink: sink.clone(),
727 ended: Mutex::new(None),
728 });
729 let journal = crate::worktree::project_state_dir(state, &root);
730 let workspace =
731 match crate::tools::WorkspaceTools::new(&root, crate::tools::policy::Policy::new(&[], &[]))
732 {
733 Ok(t) => Arc::new(t),
734 Err(e) => return refuse(CommandStatus::Error, "ERROR", &e.to_string()),
735 };
736 let flag = Halt::new();
737 let words = Arc::new(Words {
738 conversation: id.clone(),
739 turn: n,
740 sink: sink.clone(),
741 text: Mutex::new(String::new()),
742 halt: flag.clone(),
743 });
744 let wiring = substrate::ask::AskWiring {
745 halt: flag.clone(),
746 workspace,
747 readers: Arc::new(tools::AskTools::new(&root, &journal)),
748 progress: Some(Arc::clone(&collector) as Arc<dyn Progress>),
749 stream: Some(Arc::clone(&words) as Arc<dyn agentplane::runtime::RunStreamObserver>),
750 };
751 let format = crate::format::at(&root);
752 let rt = match crate::pipeline::tokio_runtime() {
753 Ok(rt) => rt,
754 Err(e) => return refuse(CommandStatus::Error, "ERROR", &e),
755 };
756 let message = chips::message(text, &resolved);
757 let mut manifest = crate::context::Manifest::new();
760 for (chip, content) in &resolved {
761 manifest.push(
762 speccoder_core::context::ContextKind::Chip,
763 &format!("speccoder://ask/chip/{}", chip.id),
764 &chip.label,
765 content.as_bytes(),
766 );
767 }
768 let context = (!resolved.is_empty()).then(|| manifest.finish());
769 report.context.clone_from(&context);
770 let payload = |carried: bool| {
771 let text = if carried {
772 format!("{}{message}", earlier(&thread.turns))
773 } else {
774 message.clone()
775 };
776 json!({
777 "text": text,
778 "tag": turn_tag(&id, n),
779 "limit": ask.max_turn_tokens.min(left),
780 "reads": reads_from,
781 "context": context,
782 })
783 };
784 let start = json!({
785 "root": root.to_string_lossy(),
786 "format": format.kind().name(),
787 "feature": request.feature,
788 });
789 let halt = Live::enter(&id, &flag);
790 let by = crate::approvals::who_in(&root);
791 let mut segment = thread.segment;
795 let mut run = thread.run.clone().filter(|_| thread.conversation.open);
796 let waited = rt.block_on(async {
797 let plane = substrate::Plane::open_ask(&dir, model, provider, wiring)?;
798 loop {
799 let carried = run.is_none() && !thread.turns.is_empty();
800 let started_now = run.is_none();
801 if run.is_none() {
802 if !thread.turns.is_empty() {
803 segment += 1;
804 }
805 let key = segment_key(&id, segment);
806 let mut input = start.clone();
807 input["conversation"] = json!(key);
808 let started = plane
809 .ask_start(&key, agentplane::core::Tainted::trusted(input))
810 .await?;
811 run = Some(started.run_id.to_string());
812 }
813 let key = segment_key(&id, segment);
814 let message_id = format!("{key}-{n}");
815 let deliver = plane.ask_deliver(&key, &message_id, payload(carried));
816 tokio::pin!(deliver);
817 let resumed = tokio::select! {
819 biased;
820 () = halt.notified() => {
821 if let Some(r) = &run {
822 plane.ask_stop(r, &by).await?;
823 }
824 let _ = deliver.await;
825 return Ok(true);
826 }
827 r = &mut deliver => r?,
828 };
829 if resumed {
830 return Ok(false);
831 }
832 if started_now {
833 return Err(substrate::PlaneError(
834 "a new run of the conversation did not take the message".to_owned(),
835 ));
836 }
837 run = None;
838 }
839 });
840 drop(halt);
841 thread.segment = segment;
842 thread.run.clone_from(&run);
843 thread.conversation.open = true;
844 match waited {
845 Ok(false) => {}
846 Ok(true) => thread.conversation.open = false,
848 Err(e) if e.0.contains("another speccoder is working") => {
849 return refuse(
850 CommandStatus::UsageError,
851 "USAGE",
852 "another question is being answered in this project; ask when it is done",
853 );
854 }
855 Err(e) => return refuse(CommandStatus::RunFailed, "RUN-FAILED", &e.0),
856 }
857 let output = collector
858 .ended
859 .lock()
860 .ok()
861 .and_then(|e| e.clone())
862 .unwrap_or(Value::Null);
863 if output["status"] == "stopped" {
865 thread.conversation.open = false;
866 }
867 let u = &output["usage"];
868 let n_of = |k: &str| u[k].as_u64().unwrap_or(0);
869 report.usage = AskUsage {
870 input: n_of("input_tokens"),
871 output: n_of("output_tokens"),
872 cache_read: n_of("cache_read_tokens"),
873 };
874 let log = output["tool_calls"].as_array().cloned().unwrap_or_default();
875 report.reads = log
876 .iter()
877 .filter_map(|e| serde_json::from_value::<Read>(e["read"].clone()).ok())
878 .collect();
879 if let Some(p) = log
880 .iter()
881 .find(|e| e["tool"] == PROPOSE && e["decision"] == "proposed")
882 {
883 let mut p = proposal::validate(&root, request.feature.as_deref(), &id, &p["arguments"]);
884 p.turn = Some(n);
885 report.proposal = Some(proposal::revalidate(&root, request.feature.as_deref(), &p));
886 }
887 match output["status"].as_str() {
888 Some("answered") => {
889 report.answer = output["text"]
890 .as_str()
891 .unwrap_or_default()
892 .trim()
893 .to_owned()
894 }
895 Some("stopped") => {
896 report.end = "stopped".to_owned();
897 report.answer = words.text().trim().to_owned();
900 report.reason = Some("stopped by you — ask again to go on".to_owned());
901 }
902 Some("budget") => {
903 report.end = "budget".to_owned();
904 report.reason = Some(format!(
905 "{} ([ask] max_turn_tokens = {})",
906 output["reason"]
907 .as_str()
908 .unwrap_or("the turn's tokens are spent"),
909 ask.max_turn_tokens
910 ));
911 }
912 _ => {
913 report.end = "failed".to_owned();
914 report.reason = Some(
915 output["reason"]
916 .as_str()
917 .unwrap_or("the turn did not finish")
918 .to_owned(),
919 );
920 }
921 }
922 report.citations = speccoder_core::ask::check(&report.answer, &report.reads, &report.chips);
923 add_today(&dir, report.usage.billed());
924 if report.usage.input + report.usage.cache_read > FULL_AT {
926 thread.conversation.open = false;
927 }
928 finish(&dir, thread, report, sink.as_ref())
929}
930
931fn finish(
932 dir: &Path,
933 mut thread: AskThread,
934 report: AskReport,
935 sink: Option<&Arc<dyn Progress>>,
936) -> CommandResult {
937 thread.conversation.turns += 1;
938 thread.conversation.at = now();
939 thread.turns.push(report.clone());
940 if let Err(e) = save(dir, &thread) {
941 return refuse(
942 CommandStatus::Error,
943 "ERROR",
944 &format!("the conversation could not be saved: {e}"),
945 );
946 }
947 if let Some(sink) = sink {
948 sink.event(&Event::AskDone {
949 report: Box::new(report.clone()),
950 });
951 }
952 let sources = report
953 .citations
954 .iter()
955 .filter(|c| c.state == "read")
956 .count();
957 let (status, code, message) = match report.end.as_str() {
958 "answered" => (
959 CommandStatus::Ok,
960 None,
961 format!("answered with {sources} sources"),
962 ),
963 "stopped" => (CommandStatus::Ok, None, "stopped".to_owned()),
964 "budget" => (
965 CommandStatus::NeedsHuman,
966 Some("ASK-BUDGET"),
967 report.reason.clone().unwrap_or_default(),
968 ),
969 _ => (
970 CommandStatus::RunFailed,
971 Some("RUN-FAILED"),
972 report.reason.clone().unwrap_or_default(),
973 ),
974 };
975 let mut result = CommandResult::new("ask", status, &message);
976 if let Some(code) = code {
977 result = result.with_code(code);
978 }
979 result.with_data(CommandData::Ask(Box::new(report)))
980}
981
982#[must_use]
985pub fn words(path: &Path, state: &Path, id: &str) -> Option<(String, String)> {
986 let (_, dir) = plane_dir(path, state).ok()?;
987 let t = load(&dir, id)?;
988 let text: String = t
989 .turns
990 .iter()
991 .map(|t| format!("Q: {}\nA: {}\n", t.question, t.answer))
992 .collect::<Vec<_>>()
993 .join("\n");
994 let digest = crate::integrity::sha256_hex(text.as_bytes());
995 Some((text, digest))
996}
997
998#[must_use]
1001pub fn spent(path: &Path, state: Option<&Path>, feature: &str) -> AskUsage {
1002 let mut total = AskUsage::default();
1003 let Some(dir) = state_or(state)
1004 .ok()
1005 .and_then(|s| plane_dir(path, &s).ok())
1006 .map(|(_, d)| d)
1007 else {
1008 return total;
1009 };
1010 let threads = std::fs::read_dir(dir.join("threads"))
1011 .into_iter()
1012 .flatten()
1013 .flatten();
1014 for t in threads.filter_map(|e| {
1015 serde_json::from_str::<AskThread>(&std::fs::read_to_string(e.path()).ok()?).ok()
1016 }) {
1017 if t.conversation.feature.as_deref() == Some(feature) {
1018 for turn in &t.turns {
1019 total.input += turn.usage.input;
1020 total.output += turn.usage.output;
1021 total.cache_read += turn.usage.cache_read;
1022 }
1023 }
1024 }
1025 total
1026}
1027
1028#[must_use]
1030pub fn conversations(path: &Path, state: Option<&Path>, feature: Option<&str>) -> CommandResult {
1031 let state = match state_or(state) {
1032 Ok(s) => s,
1033 Err(e) => return *e,
1034 };
1035 let (_, dir) = match plane_dir(path, &state) {
1036 Ok(d) => d,
1037 Err(e) => return *e,
1038 };
1039 let mut list: Vec<Conversation> = std::fs::read_dir(dir.join("threads"))
1040 .into_iter()
1041 .flatten()
1042 .flatten()
1043 .filter_map(|e| {
1044 let t: AskThread =
1045 serde_json::from_str(&std::fs::read_to_string(e.path()).ok()?).ok()?;
1046 Some(t.conversation)
1047 })
1048 .filter(|c| feature.is_none() || c.feature.as_deref() == feature)
1049 .collect();
1050 list.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| b.id.cmp(&a.id)));
1051 let message = format!("{} conversations", list.len());
1052 CommandResult::new("ask", CommandStatus::Ok, &message).with_data(CommandData::AskConversations(
1053 Box::new(AskConversations {
1054 conversations: list,
1055 }),
1056 ))
1057}
1058
1059#[must_use]
1061pub fn thread(path: &Path, state: Option<&Path>, id: &str) -> CommandResult {
1062 let state = match state_or(state) {
1063 Ok(s) => s,
1064 Err(e) => return *e,
1065 };
1066 let (_, dir) = match plane_dir(path, &state) {
1067 Ok(d) => d,
1068 Err(e) => return *e,
1069 };
1070 match load(&dir, id) {
1071 Some(mut t) => {
1072 t.results = results(path, &state, id);
1073 CommandResult::new("ask", CommandStatus::Ok, &t.conversation.title.clone())
1074 .with_data(CommandData::AskThread(Box::new(t)))
1075 }
1076 None => refuse(
1077 CommandStatus::NotFound,
1078 "NOT-FOUND",
1079 &format!("no conversation {id}"),
1080 ),
1081 }
1082}