Skip to content

Commit c82ef1c

Browse files
committed
[gobby-cli-#769] feat: add token-budgeted gcode output
1 parent 04052dc commit c82ef1c

12 files changed

Lines changed: 399 additions & 39 deletions

File tree

crates/gcode/contract/gcode.contract.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,14 @@
369369
"allowed_values": [],
370370
"required": false,
371371
"repeatable": false
372+
},
373+
{
374+
"name": "--token-budget",
375+
"takes_value": true,
376+
"value_name": "N",
377+
"allowed_values": [],
378+
"required": false,
379+
"repeatable": false
372380
}
373381
],
374382
"json_output_keys": [
@@ -897,6 +905,7 @@
897905
"name",
898906
"file_path",
899907
"line",
908+
"confidence",
900909
"relation",
901910
"distance",
902911
"metadata",
@@ -941,6 +950,14 @@
941950
],
942951
"required": false,
943952
"repeatable": false
953+
},
954+
{
955+
"name": "--token-budget",
956+
"takes_value": true,
957+
"value_name": "N",
958+
"allowed_values": [],
959+
"required": false,
960+
"repeatable": false
944961
}
945962
],
946963
"json_output_keys": [
@@ -953,6 +970,7 @@
953970
"name",
954971
"file_path",
955972
"line",
973+
"confidence",
956974
"relation",
957975
"distance",
958976
"metadata",
@@ -993,6 +1011,7 @@
9931011
"name",
9941012
"file_path",
9951013
"line",
1014+
"confidence",
9961015
"relation",
9971016
"distance",
9981017
"metadata",
@@ -1073,6 +1092,14 @@
10731092
"required": false,
10741093
"repeatable": false
10751094
},
1095+
{
1096+
"name": "--token-budget",
1097+
"takes_value": true,
1098+
"value_name": "N",
1099+
"allowed_values": [],
1100+
"required": false,
1101+
"repeatable": false
1102+
},
10761103
{
10771104
"name": "--format",
10781105
"takes_value": true,
@@ -1095,6 +1122,7 @@
10951122
"name",
10961123
"file_path",
10971124
"line",
1125+
"confidence",
10981126
"relation",
10991127
"distance",
11001128
"metadata",

crates/gcode/src/cli.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@ pub(crate) enum Command {
200200
/// Filter by source language (e.g. rust, python, css)
201201
#[arg(long)]
202202
language: Option<String>,
203+
/// Trim returned rows to an approximate token budget
204+
#[arg(long, value_parser = positive_usize)]
205+
token_budget: Option<usize>,
203206
},
204207
/// Exact-first symbol/name search with deterministic ranking
205208
SearchSymbol {
@@ -362,6 +365,9 @@ pub(crate) enum Command {
362365
/// Skip first N results (for pagination)
363366
#[arg(long, default_value = "0")]
364367
offset: usize,
368+
/// Trim returned rows to an approximate token budget
369+
#[arg(long, value_parser = positive_usize)]
370+
token_budget: Option<usize>,
365371
},
366372
/// Show import graph for a file [requires graph backend]
367373
Imports { file: String },
@@ -383,6 +389,9 @@ pub(crate) enum Command {
383389
target: String,
384390
#[arg(long, default_value = "3")]
385391
depth: usize,
392+
/// Trim returned rows to an approximate token budget
393+
#[arg(long, value_parser = positive_usize)]
394+
token_budget: Option<usize>,
386395
},
387396

388397
// ── Project Management ───────────────────────────────────────────

crates/gcode/src/cli/tests/search.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,19 @@ fn test_parse_search_language_filters() {
6060
}
6161
}
6262

63+
#[test]
64+
fn test_parse_search_token_budget() {
65+
let cli = Cli::try_parse_from(["gcode", "search", "outline", "--token-budget", "120"])
66+
.expect("search --token-budget parses");
67+
68+
match cli.command {
69+
Command::Search { token_budget, .. } => {
70+
assert_eq!(token_budget, Some(120));
71+
}
72+
_ => panic!("expected search command"),
73+
}
74+
}
75+
6376
#[test]
6477
fn test_parse_search_positional_paths() {
6578
let cli = Cli::try_parse_from([

crates/gcode/src/cli/tests/top_level.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,28 @@ fn test_parse_usages_remains_top_level() {
4545
symbol_name,
4646
limit,
4747
offset,
48+
token_budget,
4849
} => {
4950
assert_eq!(symbol_name, "DatabasePool");
5051
assert_eq!(limit, 10);
5152
assert_eq!(offset, 0);
53+
assert_eq!(token_budget, None);
5254
}
5355
_ => panic!("expected top-level usages command"),
5456
}
5557
}
5658

59+
#[test]
60+
fn test_parse_usages_token_budget() {
61+
let cli = Cli::try_parse_from(["gcode", "usages", "DatabasePool", "--token-budget", "80"])
62+
.expect("usages --token-budget parses");
63+
64+
match cli.command {
65+
Command::Usages { token_budget, .. } => assert_eq!(token_budget, Some(80)),
66+
_ => panic!("expected top-level usages command"),
67+
}
68+
}
69+
5770
#[test]
5871
fn test_parse_imports_remains_top_level() {
5972
let cli = Cli::try_parse_from(["gcode", "imports", "src/auth.ts"]).expect("imports parses");
@@ -89,14 +102,36 @@ fn test_parse_blast_radius_remains_top_level() {
89102
Cli::try_parse_from(["gcode", "blast-radius", "handleAuth"]).expect("blast-radius parses");
90103

91104
match cli.command {
92-
Command::BlastRadius { target, depth } => {
105+
Command::BlastRadius {
106+
target,
107+
depth,
108+
token_budget,
109+
} => {
93110
assert_eq!(target, "handleAuth");
94111
assert_eq!(depth, 3);
112+
assert_eq!(token_budget, None);
95113
}
96114
_ => panic!("expected top-level blast-radius command"),
97115
}
98116
}
99117

118+
#[test]
119+
fn test_parse_blast_radius_token_budget() {
120+
let cli = Cli::try_parse_from([
121+
"gcode",
122+
"blast-radius",
123+
"handleAuth",
124+
"--token-budget",
125+
"100",
126+
])
127+
.expect("blast-radius --token-budget parses");
128+
129+
match cli.command {
130+
Command::BlastRadius { token_budget, .. } => assert_eq!(token_budget, Some(100)),
131+
_ => panic!("expected top-level blast-radius command"),
132+
}
133+
}
134+
100135
#[test]
101136
fn top_level_help_includes_agent_task_examples() {
102137
let help = Cli::command().render_help().to_string();

crates/gcode/src/commands/graph/reads.rs

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::collections::BTreeMap;
22

3+
use crate::commands::token_budget;
34
use crate::config::Context;
45
use crate::db;
56
use crate::graph::code_graph;
@@ -10,6 +11,10 @@ use serde::Serialize;
1011

1112
const GRAPH_BACKEND_HINT: &str =
1213
"Graph commands require a configured FalkorDB graph backend and synced graph projection.";
14+
const USAGES_TOKEN_BUDGET_REFINE_HINT: &str =
15+
"`--limit`, `--offset`, or a more specific symbol query";
16+
const BLAST_RADIUS_TOKEN_BUDGET_REFINE_HINT: &str =
17+
"`--depth`, a more specific symbol query, or a symbol UUID";
1318

1419
fn hint_for(ctx: &Context) -> Option<String> {
1520
if ctx.falkordb.is_none() {
@@ -37,6 +42,12 @@ fn print_graph_hint_text(ctx: &Context, error: Option<&anyhow::Error>) {
3742
}
3843
}
3944

45+
fn print_hint_text(hint: Option<&str>) {
46+
if let Some(hint) = hint {
47+
eprintln!("Hint: {hint}");
48+
}
49+
}
50+
4051
fn graph_read_unavailable(error: &anyhow::Error) -> bool {
4152
matches!(
4253
error.downcast_ref::<code_graph::GraphReadError>(),
@@ -429,6 +440,7 @@ pub fn usages(
429440
symbol_name: &str,
430441
limit: usize,
431442
offset: usize,
443+
token_budget: Option<usize>,
432444
format: Format,
433445
) -> anyhow::Result<()> {
434446
let Some((symbol, total, results)) = read_paged_symbol_graph_results(
@@ -443,6 +455,15 @@ pub fn usages(
443455
else {
444456
return Ok(());
445457
};
458+
let unbudgeted_result_count = results.len();
459+
let budgeted = token_budget::trim_results(
460+
results,
461+
token_budget,
462+
USAGES_TOKEN_BUDGET_REFINE_HINT,
463+
|result| format_usage_result_line(result, &symbol.display_name),
464+
);
465+
let results = budgeted.results;
466+
let hint = token_budget::combine_hints(hint_for(ctx), budgeted.hint);
446467

447468
match format {
448469
Format::Json => output::print_json(&PagedResponse {
@@ -451,18 +472,21 @@ pub fn usages(
451472
offset,
452473
limit,
453474
results,
454-
hint: hint_for(ctx),
475+
hint,
455476
}),
456477
Format::Text => {
457-
if results.is_empty() && offset == 0 {
478+
if unbudgeted_result_count == 0 && offset == 0 {
458479
output::print_text(&format!("No usages found for '{}'", symbol.display_name))?;
459480
print_graph_hint_text(ctx, None);
460-
} else if results.is_empty() {
481+
} else if unbudgeted_result_count == 0 {
461482
eprintln!("No usages at offset {offset} (total {total})");
483+
} else if results.is_empty() {
484+
print_hint_text(hint.as_deref());
462485
} else {
463486
output::print_text(&format_grouped_graph_results(&results, |r| {
464487
format_usage_result_line(r, &symbol.display_name)
465488
}))?;
489+
print_hint_text(hint.as_deref());
466490
if total > offset + results.len() {
467491
eprintln!(
468492
"-- {} of {} results (use --offset {} for more)",
@@ -541,6 +565,7 @@ pub fn blast_radius(
541565
ctx: &Context,
542566
target: &str,
543567
depth: usize,
568+
token_budget: Option<usize>,
544569
format: Format,
545570
) -> anyhow::Result<()> {
546571
let Some(()) =
@@ -560,26 +585,37 @@ pub fn blast_radius(
560585
return Ok(());
561586
};
562587
let total = results.len();
588+
let budgeted = token_budget::trim_results(
589+
results,
590+
token_budget,
591+
BLAST_RADIUS_TOKEN_BUDGET_REFINE_HINT,
592+
format_blast_radius_result_line,
593+
);
594+
let results = budgeted.results;
595+
let hint = token_budget::combine_hints(hint_for(ctx), budgeted.hint);
563596
match format {
564597
Format::Json => output::print_json(&PagedResponse {
565598
project_id: ctx.project_id.clone(),
566599
total,
567600
offset: 0,
568601
limit: total,
569602
results,
570-
hint: hint_for(ctx),
603+
hint,
571604
}),
572605
Format::Text => {
573-
if results.is_empty() {
606+
if total == 0 {
574607
output::print_text(&format!(
575608
"No blast radius found for '{}'",
576609
symbol.display_name
577610
))?;
578611
print_graph_hint_text(ctx, None);
612+
} else if results.is_empty() {
613+
print_hint_text(hint.as_deref());
579614
} else {
580615
output::print_text(&format_grouped_graph_results(&results, |r| {
581616
format_blast_radius_result_line(r)
582617
}))?;
618+
print_hint_text(hint.as_deref());
583619
}
584620
Ok(())
585621
}

crates/gcode/src/commands/graph/tests.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use super::reads::{
88
format_symbol_path_text, format_usage_result_line,
99
};
1010
use super::{imports, report};
11+
use crate::commands::token_budget;
1112
use crate::config::Context;
1213
use crate::graph::code_graph::{self, GraphLifecycleAction, GraphLifecycleOutput};
1314
use crate::models::{
@@ -123,6 +124,59 @@ fn graph_read_text_lines_surface_confidence_labels() {
123124
);
124125
}
125126

127+
#[test]
128+
fn graph_read_token_budget_uses_rendered_rows() {
129+
let first = GraphResult {
130+
id: "sym-1".to_string(),
131+
name: "run".to_string(),
132+
file_path: "src/lib.rs".to_string(),
133+
line: 12,
134+
confidence: ProjectionProvenance::Extracted,
135+
relation: Some("CALLS".to_string()),
136+
distance: Some(1),
137+
metadata: None,
138+
};
139+
let second = GraphResult {
140+
id: "sym-2".to_string(),
141+
name: "run_more".to_string(),
142+
file_path: "src/lib.rs".to_string(),
143+
line: 18,
144+
confidence: ProjectionProvenance::Inferred,
145+
relation: Some("CALLS".to_string()),
146+
distance: Some(2),
147+
metadata: None,
148+
};
149+
let budget = token_budget::estimate_tokens(&format_usage_result_line(&first, "main"));
150+
151+
let trimmed = token_budget::trim_results(
152+
vec![first.clone(), second.clone()],
153+
Some(budget),
154+
"`--limit` or `--offset`",
155+
|result| format_usage_result_line(result, "main"),
156+
);
157+
158+
assert_eq!(trimmed.results.len(), 1);
159+
assert_eq!(trimmed.results[0].id, first.id);
160+
assert!(trimmed.hint.expect("usage budget hint").contains("1 of 2"));
161+
162+
let blast_budget = token_budget::estimate_tokens(&format_blast_radius_result_line(&first));
163+
let trimmed_blast = token_budget::trim_results(
164+
vec![first.clone(), second],
165+
Some(blast_budget),
166+
"`--depth`",
167+
format_blast_radius_result_line,
168+
);
169+
170+
assert_eq!(trimmed_blast.results.len(), 1);
171+
assert_eq!(trimmed_blast.results[0].id, first.id);
172+
assert!(
173+
trimmed_blast
174+
.hint
175+
.expect("blast budget hint")
176+
.contains("refine with `--depth`")
177+
);
178+
}
179+
126180
#[test]
127181
fn graph_path_text_prints_ordered_chain_with_locations() {
128182
let path = vec![

crates/gcode/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ pub mod setup;
1010
pub mod status;
1111
pub mod symbol_at;
1212
pub mod symbols;
13+
pub(crate) mod token_budget;
1314
pub mod vector;

0 commit comments

Comments
 (0)