1use scc_api::TaskContextRequest;
10use std::collections::{BTreeMap, BTreeSet};
11use std::path::Path;
12
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14pub struct TaskContextArtifact {
16 pub pack: scc_context::ContextPack,
17 pub delta: String,
18 #[serde(default)]
19 pub delta_ids: Vec<String>,
20 #[serde(default)]
21 pub token_count: usize,
22}
23
24pub(crate) fn surface_task_files(
26 ctx: &scc_context::ContextCompiler,
27 goal: &str,
28 limit: usize,
29 tokens: usize,
30 semantic: Option<&dyn scc_context::rank::SemanticScorer>,
31) -> Vec<String> {
32 let budget = tokens.max(1);
33 let request = scc_context::surface::SurfaceRequest {
34 mode: scc_context::surface::SurfaceMode::Task { goal, visible: None },
35 budget,
36 explain: false,
37 policy: scc_context::surface::SurfacePolicy::defaults(budget),
38 semantic,
39 };
40 let result = scc_context::surface::build_surface(ctx, request);
41 let by_id: BTreeMap<&str, &scc_core::SurfaceEntry> = result
42 .rendered_entries
43 .iter()
44 .map(|e| (e.id.as_str(), e))
45 .collect();
46 let mut files: Vec<String> = Vec::new();
47 let mut seen: BTreeSet<String> = BTreeSet::new();
48 for id in &result.rendered_ids {
49 let path = by_id.get(id.as_str()).map(|e| e.path.as_str()).unwrap_or("");
50 if path.is_empty() || !seen.insert(path.to_string()) {
51 continue;
52 }
53 files.push(path.to_string());
54 if files.len() >= limit {
55 break;
56 }
57 }
58 files
59}
60
61pub(crate) fn truncate_to(content: &str, cap: usize) -> String {
63 if scc_core::estimate_tokens(content) <= cap {
64 return content.to_string();
65 }
66 const FOOTER: &str = "\n\n\u{2026} [task hard cap: content truncated to fit budget]\n";
67 let footer_tokens = scc_core::estimate_tokens(FOOTER);
68 if footer_tokens > cap {
69 return String::new();
70 }
71 let target = cap.saturating_sub(footer_tokens);
72 let mut lo = 0usize;
73 let mut hi = content.len();
74 while lo < hi {
75 let mid = (lo + hi).div_ceil(2);
76 let prefix = &content[..content.floor_char_boundary(mid)];
77 if scc_core::estimate_tokens(prefix) <= target {
78 lo = mid;
79 } else {
80 hi = mid.saturating_sub(1);
81 }
82 }
83 let mut cut = content.floor_char_boundary(lo);
84 let min_chars = content[..cut].chars().count() / 2;
85 if let Some(nl) = content[..cut].rfind('\n') {
86 let prefix_chars = content[..nl].chars().count();
87 if prefix_chars >= min_chars {
88 cut = nl;
89 }
90 }
91 if let Some(rest) = content.get(cut..) {
92 if let Some(nl) = rest.find('\n') {
93 let extended = cut + nl;
94 let mut candidate = content[..extended].to_string();
95 candidate.push_str(FOOTER);
96 if scc_core::estimate_tokens(&candidate) <= cap {
97 cut = extended;
98 }
99 }
100 }
101 let mut out = content[..cut].to_string();
102 out.push_str(FOOTER);
103 while scc_core::estimate_tokens(&out) > cap && cut > 0 {
104 cut = content.floor_char_boundary(cut.saturating_sub(1));
105 if let Some(nl) = content[..cut].rfind('\n') {
106 cut = nl;
107 }
108 out = content[..cut].to_string();
109 out.push_str(FOOTER);
110 if cut == 0 {
111 break;
112 }
113 }
114 out
115}
116
117pub struct EnrichedPack {
119 pub pack: scc_context::ContextPack,
120 pub base: String,
121 pub beads: String,
122 pub hindsight: String,
123}
124
125impl EnrichedPack {
127 pub fn tokens_including(&self, hindsight: bool, beads: bool) -> usize {
129 scc_core::estimate_tokens(&self.base)
130 + if beads { scc_core::estimate_tokens(&self.beads) } else { 0 }
131 + if hindsight { scc_core::estimate_tokens(&self.hindsight) } else { 0 }
132 }
133
134 pub fn assemble(&mut self, hindsight: bool, beads: bool) {
136 self.pack.content = String::new();
137 self.pack.content.push_str(&self.base);
138 if beads {
139 self.pack.content.push_str(&self.beads);
140 }
141 if hindsight {
142 self.pack.content.push_str(&self.hindsight);
143 }
144 self.pack.tokens = scc_core::estimate_tokens(&self.pack.content);
145 }
146}
147
148#[allow(clippy::too_many_arguments)]
149pub fn enrich_task_pack(
151 engine: &crate::workspace::Engine,
152 config: &scc_indexer::Config,
153 root: &Path,
154 goal: &str,
155 files: &[String],
156 symbols: &[String],
157 budget: Option<usize>,
158 hook: bool,
159 scorer: Option<&dyn scc_context::rank::SemanticScorer>,
160 reranker: Option<&dyn scc_context::rank::Reranker>,
161) -> EnrichedPack {
162 let mut pack = engine.ctx().task_context_with_rankers(
163 goal,
164 files,
165 symbols,
166 if hook { Some(budget.unwrap_or(1500).min(1500)) } else { budget },
167 scorer,
168 reranker,
169 );
170 let mut beads = String::new();
171 let beads_active = scc_indexer::adapters::beads::active_beads(root, 5);
172 if !beads_active.is_empty() {
173 beads.push_str("\n# ACTIVE TASK STATE (from .beads/issues.jsonl \u{2014} task state, not system facts)\n");
174 for t in beads_active {
175 beads.push_str("- ");
176 beads.push_str(&t);
177 beads.push('\n');
178 }
179 }
180 let mut hindsight = String::new();
181 if config.integrations.hindsight {
182 let lessons = scc_indexer::adapters::hindsight::lessons(engine.store, 5);
183 if !lessons.is_empty() {
184 hindsight.push_str("\n# HINDSIGHT LESSONS (memory, below System IR authority \u{2014} not verified facts)\n");
185 for (content, tags) in lessons {
186 let tag_str = if tags.is_empty() { String::new() } else { format!(" [{}]", tags.join(", ")) };
187 hindsight.push_str(&format!("- {content}{tag_str}\n"));
188 }
189 }
190 }
191 let sections = crate::plugins::context_sections(root, config, goal, files, symbols);
192 let base = pack.content.clone();
193 pack.content.push_str(§ions);
194 pack.content.push_str(&beads);
195 pack.content.push_str(&hindsight);
196 pack.tokens = scc_core::estimate_tokens(&pack.content);
197 let mut base_with_sections = base;
200 base_with_sections.push_str(§ions);
201 EnrichedPack { pack, base: base_with_sections, beads, hindsight }
202}
203
204#[allow(clippy::too_many_arguments)]
208pub fn build_task_context(
210 engine: &crate::workspace::Engine,
211 config: &scc_indexer::Config,
212 root: &Path,
213 req: &TaskContextRequest,
214 scorer: Option<&dyn scc_context::rank::SemanticScorer>,
215 reranker: Option<&dyn scc_context::rank::Reranker>,
216) -> crate::Result<TaskContextArtifact> {
217 let mut ep = enrich_task_pack(engine, config, root, &req.goal, &req.files, &req.symbols, req.budget, req.hook, scorer, reranker);
218 let hard_cap: Option<usize> = if req.hook { Some(req.budget.unwrap_or(1500).min(1500)) } else { req.budget };
219 let ctx = engine.ctx();
220 let ledger_store = scc_context::context_ledger::ContextLedgerStore::new(engine.store);
221 let visible = ledger_store.load();
222 let delta_budget = match hard_cap {
223 Some(c) => c.saturating_sub(ep.pack.tokens),
224 None => scc_core::ContextBudget::default().task_delta,
225 };
226 let (mut delta, mut delta_ids) = scc_context::startup::task_delta_with_ids(&ctx, &req.goal, &visible, delta_budget, scorer);
227 let mut include_hindsight = true;
228 let mut include_beads = true;
229 let mut dropped: Vec<&str> = Vec::new();
230 if let Some(cap) = hard_cap {
231 let full = ep.tokens_including(true, true) + scc_core::estimate_tokens(&delta);
232 if full > cap && include_hindsight && !ep.hindsight.is_empty() {
233 include_hindsight = false;
234 dropped.push("hindsight");
235 }
236 let no_h = ep.tokens_including(include_hindsight, true) + scc_core::estimate_tokens(&delta);
237 if no_h > cap && include_beads && !ep.beads.is_empty() {
238 include_beads = false;
239 dropped.push("beads");
240 }
241 let no_e = ep.tokens_including(include_hindsight, include_beads) + scc_core::estimate_tokens(&delta);
242 if no_e > cap && !delta.is_empty() {
243 delta = String::new();
244 delta_ids.clear();
245 dropped.push("surface-delta");
246 }
247 }
248 ep.assemble(include_hindsight, include_beads);
249 if let Some(cap) = hard_cap {
250 let total = scc_core::estimate_tokens(&ep.pack.content) + scc_core::estimate_tokens(&delta);
251 if total > cap {
252 ep.pack.content = truncate_to(&ep.pack.content, cap);
253 ep.pack.tokens = scc_core::estimate_tokens(&ep.pack.content);
254 ep.pack.hard_truncated = true;
255 dropped.push("task-pack");
256 }
257 }
258 if !delta_ids.is_empty() && req.record_visibility {
259 let mut led = visible;
260 crate::context::record_visible_ids(&mut led, &ctx, &delta_ids);
261 ledger_store.save(&led);
262 }
263 let token_count = scc_core::estimate_tokens(&ep.pack.content) + scc_core::estimate_tokens(&delta);
264 let mut artifact = TaskContextArtifact { pack: ep.pack, delta, delta_ids, token_count };
265 if let Some(cap) = hard_cap {
266 assert!(artifact.token_count <= cap, "task artifact {token_count} exceeded hard cap {cap}");
267 }
268 if !dropped.is_empty() {
269 artifact.pack.warnings.push(format!("task cap enforced: dropped [{}]", dropped.join(", ")));
270 }
271 Ok(artifact)
272}
273
274pub fn build_enriched_task_pack(
278 engine: &crate::workspace::Engine,
279 config: &scc_indexer::Config,
280 root: &Path,
281 req: &TaskContextRequest,
282 scorer: Option<&dyn scc_context::rank::SemanticScorer>,
283 reranker: Option<&dyn scc_context::rank::Reranker>,
284) -> crate::Result<scc_context::ContextPack> {
285 Ok(enrich_task_pack(engine, config, root, &req.goal, &req.files, &req.symbols, req.budget, req.hook, scorer, reranker).pack)
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn truncate_to_never_exceeds_hard_cap_when_next_newline_is_far_past() {
295 let mut body = String::from("HEADER\n");
300 body.push_str(&"x".repeat(400)); body.push('\n');
302 body.push_str("TAIL\n");
303 let cap = 40; let out = truncate_to(&body, cap);
305 let tokens = scc_core::estimate_tokens(&out);
306 assert!(
307 tokens <= cap,
308 "truncate_to must honor the hard cap: {tokens} > {cap}\n{out:?}"
309 );
310 assert!(
311 out.contains("task hard cap"),
312 "footer must be present: {out}"
313 );
314 assert!(
315 !out.contains(&"x".repeat(400)),
316 "must not include the line that sits far past the cap"
317 );
318 }
319
320 #[test]
321 fn truncate_to_returns_original_when_under_cap() {
323 let content = "short\n";
324 assert_eq!(truncate_to(content, 1000), content);
325 }
326
327 #[test]
328 fn truncate_to_returns_empty_when_footer_exceeds_cap() {
330 let content = "HEADER\nbody that does not fit\n";
331 let out = truncate_to(content, 1);
332 assert!(
333 out.is_empty(),
334 "footer larger than the cap must not be returned: {out:?} tokens={}",
335 scc_core::estimate_tokens(&out)
336 );
337 }
338}