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 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
466 format!(
467 "c-{:x}{:04x}{:x}",
468 nanos / 1_000_000,
469 std::process::id() & 0xffff,
470 N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
471 )
472}
473
474#[must_use]
477pub fn turn(
478 path: &Path,
479 request: &Request,
480 state: Option<&Path>,
481 sink: Option<Arc<dyn Progress>>,
482) -> CommandResult {
483 let state = match state_or(state) {
484 Ok(s) => s,
485 Err(e) => return *e,
486 };
487 let config = crate::verify::find_config(path)
488 .and_then(|root| std::fs::read_to_string(root.join(crate::verify::config::FILE)).ok())
489 .unwrap_or_default();
490 let ask = match settings(&config) {
491 Ok(a) => a,
492 Err(e) => {
493 return refuse(
494 CommandStatus::ConfigError,
495 "CONFIG",
496 &format!("speccoder.toml: {e}"),
497 );
498 }
499 };
500 let model = match substrate::model_config::parse(&config) {
501 Ok(Some(mut m)) => {
502 if let Some(name) = &ask.model {
503 m.model.clone_from(name);
504 }
505 m
506 }
507 Ok(None) => {
508 return refuse(
509 CommandStatus::ConfigError,
510 "ASK-NO-MODEL",
511 "Ask needs a model ([model] in speccoder.toml); \"Make this a…\" works without one",
512 );
513 }
514 Err(e) => {
515 return refuse(
516 CommandStatus::ConfigError,
517 "CONFIG",
518 &format!("speccoder.toml: {e}"),
519 );
520 }
521 };
522 match substrate::model_config::open_driver(&model) {
523 Ok((provider, _)) => turn_with(path, request, &state, &ask, &model, provider, sink),
524 Err(e) => substrate::model_config::refused("ask", &e),
525 }
526}
527
528#[derive(Debug)]
531struct Collector {
532 conversation: String,
533 turn: u32,
534 tag: String,
535 sink: Option<Arc<dyn Progress>>,
536 ended: Mutex<Option<Value>>,
537}
538
539impl Progress for Collector {
540 fn event(&self, event: &Event) {
541 match event {
542 Event::ToolCall {
543 task,
544 tool,
545 target,
546 decision,
547 ok,
548 ..
549 } if *task == self.tag => {
550 if let Some(sink) = &self.sink {
551 sink.event(&Event::AskStep {
552 conversation: self.conversation.clone(),
553 turn: self.turn,
554 tool: tool.clone(),
555 target: target.clone(),
556 read: None,
557 refused: decision != "allow" && decision != "proposed"
558 || *ok == Some(false),
559 });
560 }
561 }
562 Event::AskTurnEnded { tag, output } if *tag == self.tag => {
563 if let Ok(mut e) = self.ended.lock() {
564 *e = Some((**output).clone());
565 }
566 }
567 _ => {}
568 }
569 }
570}
571
572#[derive(Debug)]
574struct Words {
575 conversation: String,
576 turn: u32,
577 sink: Option<Arc<dyn Progress>>,
578 text: Mutex<String>,
579 halt: Halt,
581}
582
583impl Words {
584 fn text(&self) -> String {
585 self.text.lock().map(|t| t.clone()).unwrap_or_default()
586 }
587}
588
589impl agentplane::runtime::RunStreamObserver for Words {
590 fn event(
591 &self,
592 _run: agentplane::core::RunId,
593 event: agentplane::core::Tainted<agentplane::model::ModelStreamEvent>,
594 ) {
595 if self.halt.is_stopped() {
596 return;
597 }
598 if let agentplane::model::ModelStreamEvent::TextDelta(delta) = event.peek() {
599 if let Ok(mut t) = self.text.lock() {
600 t.push_str(delta);
601 }
602 if let Some(sink) = &self.sink {
603 sink.event(&Event::AskText {
604 conversation: self.conversation.clone(),
605 turn: self.turn,
606 delta: delta.clone(),
607 });
608 }
609 }
610 }
611}
612
613#[must_use]
615pub fn turn_with(
616 path: &Path,
617 request: &Request,
618 state: &Path,
619 ask: &Settings,
620 model: &ModelConfig,
621 provider: Arc<dyn ModelProvider>,
622 sink: Option<Arc<dyn Progress>>,
623) -> CommandResult {
624 let text = request.text.trim();
625 if !text.chars().any(char::is_alphanumeric) {
626 return refuse(
627 CommandStatus::UsageError,
628 "USAGE",
629 "ask a question first — any words",
630 );
631 }
632 if text.chars().count() > MAX_QUESTION {
633 return refuse(
634 CommandStatus::UsageError,
635 "USAGE",
636 &format!("a question is at most {MAX_QUESTION} characters"),
637 );
638 }
639 if request.chips.len() > chips::MAX {
640 return refuse(
641 CommandStatus::UsageError,
642 "USAGE",
643 &format!("at most {} chips per question", chips::MAX),
644 );
645 }
646 let (root, dir) = match plane_dir(path, state) {
647 Ok(d) => d,
648 Err(e) => return *e,
649 };
650 let mut thread = match &request.conversation {
651 Some(id) => match load(&dir, id) {
652 Some(t) => t,
653 None => {
654 return refuse(
655 CommandStatus::NotFound,
656 "NOT-FOUND",
657 &format!("no conversation {id}"),
658 );
659 }
660 },
661 None if request.id.as_deref().is_some_and(|i| !valid_id(i)) => {
662 return refuse(
663 CommandStatus::UsageError,
664 "USAGE",
665 "a conversation id is `c-` and hex digits",
666 );
667 }
668 None => AskThread {
669 conversation: Conversation {
670 id: request.id.clone().unwrap_or_else(new_id),
671 feature: request.feature.clone(),
672 title: text.chars().take(120).collect(),
673 at: now(),
674 turns: 0,
675 open: true,
676 },
677 turns: Vec::new(),
678 segment: 0,
679 run: None,
680 results: Vec::new(),
681 },
682 };
683 let id = thread.conversation.id.clone();
684 let n = u32::try_from(thread.turns.len()).unwrap_or(u32::MAX - 1) + 1;
685 let reads_from = 1 + thread.turns.iter().map(|t| t.reads.len()).sum::<usize>();
686 let resolved: Result<Vec<_>, String> = request
687 .chips
688 .iter()
689 .enumerate()
690 .map(|(i, c)| chips::resolve(&root, &format!("c{}", i + 1), c))
691 .collect();
692 let resolved = match resolved {
693 Ok(r) => r,
694 Err(e) => {
695 return refuse(
696 CommandStatus::UsageError,
697 "USAGE",
698 &format!("a chip cannot be read: {e}"),
699 );
700 }
701 };
702 let mut report = AskReport {
703 conversation: id.clone(),
704 turn: n,
705 question: text.to_owned(),
706 chips: resolved.iter().map(|(c, _)| c.clone()).collect(),
707 reads: Vec::new(),
708 answer: String::new(),
709 citations: Vec::new(),
710 proposal: None,
711 usage: AskUsage::default(),
712 end: "answered".to_owned(),
713 reason: None,
714 context: None,
715 };
716 let (_, used) = used_today(&dir);
717 let left = ask.daily_tokens.saturating_sub(used);
718 if left == 0 {
719 report.end = "budget".to_owned();
720 report.reason = Some(format!(
721 "Ask's tokens for today are spent ([ask] daily_tokens = {})",
722 ask.daily_tokens
723 ));
724 return finish(&dir, thread, report, sink.as_ref());
725 }
726 let collector = Arc::new(Collector {
727 conversation: id.clone(),
728 turn: n,
729 tag: turn_tag(&id, n),
730 sink: sink.clone(),
731 ended: Mutex::new(None),
732 });
733 let journal = crate::worktree::project_state_dir(state, &root);
734 let workspace =
735 match crate::tools::WorkspaceTools::new(&root, crate::tools::policy::Policy::new(&[], &[]))
736 {
737 Ok(t) => Arc::new(t),
738 Err(e) => return refuse(CommandStatus::Error, "ERROR", &e.to_string()),
739 };
740 let flag = Halt::new();
741 let words = Arc::new(Words {
742 conversation: id.clone(),
743 turn: n,
744 sink: sink.clone(),
745 text: Mutex::new(String::new()),
746 halt: flag.clone(),
747 });
748 let wiring = substrate::ask::AskWiring {
749 halt: flag.clone(),
750 workspace,
751 readers: Arc::new(tools::AskTools::new(&root, &journal)),
752 progress: Some(Arc::clone(&collector) as Arc<dyn Progress>),
753 stream: Some(Arc::clone(&words) as Arc<dyn agentplane::runtime::RunStreamObserver>),
754 };
755 let format = crate::format::at(&root);
756 let rt = match crate::pipeline::tokio_runtime() {
757 Ok(rt) => rt,
758 Err(e) => return refuse(CommandStatus::Error, "ERROR", &e),
759 };
760 let message = chips::message(text, &resolved);
761 let mut manifest = crate::context::Manifest::new();
764 for (chip, content) in &resolved {
765 manifest.push(
766 speccoder_core::context::ContextKind::Chip,
767 &format!("speccoder://ask/chip/{}", chip.id),
768 &chip.label,
769 content.as_bytes(),
770 );
771 }
772 let context = (!resolved.is_empty()).then(|| manifest.finish());
773 report.context.clone_from(&context);
774 let payload = |carried: bool| {
775 let text = if carried {
776 format!("{}{message}", earlier(&thread.turns))
777 } else {
778 message.clone()
779 };
780 json!({
781 "text": text,
782 "tag": turn_tag(&id, n),
783 "limit": ask.max_turn_tokens.min(left),
784 "reads": reads_from,
785 "context": context,
786 })
787 };
788 let start = json!({
789 "root": root.to_string_lossy(),
790 "format": format.kind().name(),
791 "feature": request.feature,
792 });
793 let halt = Live::enter(&id, &flag);
794 let by = crate::approvals::who_in(&root);
795 let mut segment = thread.segment;
799 let mut run = thread.run.clone().filter(|_| thread.conversation.open);
800 let waited = rt.block_on(async {
801 let plane = substrate::Plane::open_ask(&dir, model, provider, wiring)?;
802 loop {
803 let carried = run.is_none() && !thread.turns.is_empty();
804 let started_now = run.is_none();
805 if run.is_none() {
806 if !thread.turns.is_empty() {
807 segment += 1;
808 }
809 let key = segment_key(&id, segment);
810 let mut input = start.clone();
811 input["conversation"] = json!(key);
812 let started = plane
813 .ask_start(&key, agentplane::core::Tainted::trusted(input))
814 .await?;
815 run = Some(started.run_id.to_string());
816 }
817 let key = segment_key(&id, segment);
818 let message_id = format!("{key}-{n}");
819 let deliver = plane.ask_deliver(&key, &message_id, payload(carried));
820 tokio::pin!(deliver);
821 let resumed = tokio::select! {
823 biased;
824 () = halt.notified() => {
825 if let Some(r) = &run {
826 plane.ask_stop(r, &by).await?;
827 }
828 let _ = deliver.await;
829 return Ok(true);
830 }
831 r = &mut deliver => r?,
832 };
833 if resumed {
834 return Ok(false);
835 }
836 if started_now {
837 return Err(substrate::PlaneError(
838 "a new run of the conversation did not take the message".to_owned(),
839 ));
840 }
841 run = None;
842 }
843 });
844 drop(halt);
845 thread.segment = segment;
846 thread.run.clone_from(&run);
847 thread.conversation.open = true;
848 match waited {
849 Ok(false) => {}
850 Ok(true) => thread.conversation.open = false,
852 Err(e) if e.0.contains("another speccoder is working") => {
853 return refuse(
854 CommandStatus::UsageError,
855 "USAGE",
856 "another question is being answered in this project; ask when it is done",
857 );
858 }
859 Err(e) => return refuse(CommandStatus::RunFailed, "RUN-FAILED", &e.0),
860 }
861 let output = collector
862 .ended
863 .lock()
864 .ok()
865 .and_then(|e| e.clone())
866 .unwrap_or(Value::Null);
867 if output["status"] == "stopped" {
869 thread.conversation.open = false;
870 }
871 let u = &output["usage"];
872 let n_of = |k: &str| u[k].as_u64().unwrap_or(0);
873 report.usage = AskUsage {
874 input: n_of("input_tokens"),
875 output: n_of("output_tokens"),
876 cache_read: n_of("cache_read_tokens"),
877 };
878 let log = output["tool_calls"].as_array().cloned().unwrap_or_default();
879 report.reads = log
880 .iter()
881 .filter_map(|e| serde_json::from_value::<Read>(e["read"].clone()).ok())
882 .collect();
883 if let Some(p) = log
884 .iter()
885 .find(|e| e["tool"] == PROPOSE && e["decision"] == "proposed")
886 {
887 let mut p = proposal::validate(&root, request.feature.as_deref(), &id, &p["arguments"]);
888 p.turn = Some(n);
889 report.proposal = Some(proposal::revalidate(&root, request.feature.as_deref(), &p));
890 }
891 match output["status"].as_str() {
892 Some("answered") => {
893 report.answer = output["text"]
894 .as_str()
895 .unwrap_or_default()
896 .trim()
897 .to_owned()
898 }
899 Some("stopped") => {
900 report.end = "stopped".to_owned();
901 report.answer = words.text().trim().to_owned();
904 report.reason = Some("stopped by you — ask again to go on".to_owned());
905 }
906 Some("budget") => {
907 report.end = "budget".to_owned();
908 report.reason = Some(format!(
909 "{} ([ask] max_turn_tokens = {})",
910 output["reason"]
911 .as_str()
912 .unwrap_or("the turn's tokens are spent"),
913 ask.max_turn_tokens
914 ));
915 }
916 _ => {
917 report.end = "failed".to_owned();
918 report.reason = Some(
919 output["reason"]
920 .as_str()
921 .unwrap_or("the turn did not finish")
922 .to_owned(),
923 );
924 }
925 }
926 report.citations = speccoder_core::ask::check(&report.answer, &report.reads, &report.chips);
927 add_today(&dir, report.usage.billed());
928 if report.usage.input + report.usage.cache_read > FULL_AT {
930 thread.conversation.open = false;
931 }
932 finish(&dir, thread, report, sink.as_ref())
933}
934
935fn finish(
936 dir: &Path,
937 mut thread: AskThread,
938 report: AskReport,
939 sink: Option<&Arc<dyn Progress>>,
940) -> CommandResult {
941 thread.conversation.turns += 1;
942 thread.conversation.at = now();
943 thread.turns.push(report.clone());
944 if let Err(e) = save(dir, &thread) {
945 return refuse(
946 CommandStatus::Error,
947 "ERROR",
948 &format!("the conversation could not be saved: {e}"),
949 );
950 }
951 if let Some(sink) = sink {
952 sink.event(&Event::AskDone {
953 report: Box::new(report.clone()),
954 });
955 }
956 let sources = report
957 .citations
958 .iter()
959 .filter(|c| c.state == "read")
960 .count();
961 let (status, code, message) = match report.end.as_str() {
962 "answered" => (
963 CommandStatus::Ok,
964 None,
965 format!("answered with {sources} sources"),
966 ),
967 "stopped" => (CommandStatus::Ok, None, "stopped".to_owned()),
968 "budget" => (
969 CommandStatus::NeedsHuman,
970 Some("ASK-BUDGET"),
971 report.reason.clone().unwrap_or_default(),
972 ),
973 _ => (
974 CommandStatus::RunFailed,
975 Some("RUN-FAILED"),
976 report.reason.clone().unwrap_or_default(),
977 ),
978 };
979 let mut result = CommandResult::new("ask", status, &message);
980 if let Some(code) = code {
981 result = result.with_code(code);
982 }
983 result.with_data(CommandData::Ask(Box::new(report)))
984}
985
986#[must_use]
989pub fn words(path: &Path, state: &Path, id: &str) -> Option<(String, String)> {
990 let (_, dir) = plane_dir(path, state).ok()?;
991 let t = load(&dir, id)?;
992 let text: String = t
993 .turns
994 .iter()
995 .map(|t| format!("Q: {}\nA: {}\n", t.question, t.answer))
996 .collect::<Vec<_>>()
997 .join("\n");
998 let digest = crate::integrity::sha256_hex(text.as_bytes());
999 Some((text, digest))
1000}
1001
1002#[must_use]
1005pub fn spent(path: &Path, state: Option<&Path>, feature: &str) -> AskUsage {
1006 let mut total = AskUsage::default();
1007 let Some(dir) = state_or(state)
1008 .ok()
1009 .and_then(|s| plane_dir(path, &s).ok())
1010 .map(|(_, d)| d)
1011 else {
1012 return total;
1013 };
1014 let threads = std::fs::read_dir(dir.join("threads"))
1015 .into_iter()
1016 .flatten()
1017 .flatten();
1018 for t in threads.filter_map(|e| {
1019 serde_json::from_str::<AskThread>(&std::fs::read_to_string(e.path()).ok()?).ok()
1020 }) {
1021 if t.conversation.feature.as_deref() == Some(feature) {
1022 for turn in &t.turns {
1023 total.input += turn.usage.input;
1024 total.output += turn.usage.output;
1025 total.cache_read += turn.usage.cache_read;
1026 }
1027 }
1028 }
1029 total
1030}
1031
1032#[must_use]
1034pub fn conversations(path: &Path, state: Option<&Path>, feature: Option<&str>) -> CommandResult {
1035 let state = match state_or(state) {
1036 Ok(s) => s,
1037 Err(e) => return *e,
1038 };
1039 let (_, dir) = match plane_dir(path, &state) {
1040 Ok(d) => d,
1041 Err(e) => return *e,
1042 };
1043 let mut list: Vec<Conversation> = std::fs::read_dir(dir.join("threads"))
1044 .into_iter()
1045 .flatten()
1046 .flatten()
1047 .filter_map(|e| {
1048 let t: AskThread =
1049 serde_json::from_str(&std::fs::read_to_string(e.path()).ok()?).ok()?;
1050 Some(t.conversation)
1051 })
1052 .filter(|c| feature.is_none() || c.feature.as_deref() == feature)
1053 .collect();
1054 list.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| b.id.cmp(&a.id)));
1055 let message = format!("{} conversations", list.len());
1056 CommandResult::new("ask", CommandStatus::Ok, &message).with_data(CommandData::AskConversations(
1057 Box::new(AskConversations {
1058 conversations: list,
1059 }),
1060 ))
1061}
1062
1063#[must_use]
1065pub fn thread(path: &Path, state: Option<&Path>, id: &str) -> CommandResult {
1066 let state = match state_or(state) {
1067 Ok(s) => s,
1068 Err(e) => return *e,
1069 };
1070 let (_, dir) = match plane_dir(path, &state) {
1071 Ok(d) => d,
1072 Err(e) => return *e,
1073 };
1074 match load(&dir, id) {
1075 Some(mut t) => {
1076 t.results = results(path, &state, id);
1077 CommandResult::new("ask", CommandStatus::Ok, &t.conversation.title.clone())
1078 .with_data(CommandData::AskThread(Box::new(t)))
1079 }
1080 None => refuse(
1081 CommandStatus::NotFound,
1082 "NOT-FOUND",
1083 &format!("no conversation {id}"),
1084 ),
1085 }
1086}