Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


## [2.1.0] - 2026-05-31

### Added
- **🎯 AI-Native Navigation (P1)**: Implemented line-numbered code fences and symbol-level XML anchors (`<anchor id="...">`) for classes and functions in full mode, allowing AI to navigate and apply Search & Replace diffs flawlessly.
- **🚀 Ultra-Focused Mode Optimization**: Focused mode (`scriber <path>`) now acts as a precise surgical tool, cutting out unnecessary contextual noise.
- **🛡️ Support Files Pruning**: Support files (`pyproject.toml`, `README.md`, Dockerfiles) are no longer granted automatic `full` mode immunity when running focused scans. They now decay to tree mode unless explicitly targeted.
- **🧪 Test File Quarantine**: Test modules are heavily penalized in focused mode, dropping out of full/excerpt context to keep the generated pack laser-focused on actual implementation logic.

### Fixed
- **🐛 Excerpt Fallback Bug**: Fixed a critical bug where `excerpt` files failed to render and completely dropped their token estimates, resulting in `_Excerpt unavailable_` placeholders. They now correctly fall back to outline AST structures and compute tokens accurately.
- **⚖️ Graph Token Hard-Capping**: Re-engineered token budgeting with rigid distance-based hard caps in `ranker.py` (Max scores: 100/79/74/44 for Dist 0/1/2/3+ respectively). Focused mode is now reliably ~45% of the full project token size, completely eliminating distant `full` mode leaks.

## [2.0.0] - 2026-05-30

### Added
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "maturin"

[project]
name = "project-scriber"
version = "2.0.0"
version = "2.1.0"
description = "Scriber 2.0: build intelligent code packs from one or more project paths."
readme = "README.md"
requires-python = ">=3.10"
Expand Down Expand Up @@ -54,8 +54,8 @@ format = "md"
output = ".scriber/scriber_pack.md"
only_tree = false
use_gitignore = true
max_files = 60
max_tokens = 100000
max_files = 0
max_tokens = 0
min_score = 45
path_style = "project-relative"
allow_external_paths = false
Expand Down
76 changes: 76 additions & 0 deletions rust/scriber_native/src/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -620,3 +620,79 @@ pub fn build_import_graph(

Ok(edges)
}

#[pyclass]
#[derive(Clone, Debug)]
pub struct NativeRelationEdge {
#[pyo3(get)]
pub source: String,
#[pyo3(get)]
pub target: String,
#[pyo3(get)]
pub kind: String,
#[pyo3(get)]
pub weight: f64,
#[pyo3(get)]
pub confidence: f64,
#[pyo3(get)]
pub evidence: Option<String>,
#[pyo3(get)]
pub line: Option<usize>,
#[pyo3(get)]
pub analyzer: String,
}

#[pymethods]
impl NativeRelationEdge {
#[new]
#[pyo3(signature = (source, target, kind, weight, confidence, evidence, line, analyzer))]
#[allow(clippy::too_many_arguments)]
fn new(
source: String,
target: String,
kind: String,
weight: f64,
confidence: f64,
evidence: Option<String>,
line: Option<usize>,
analyzer: String,
) -> Self {
NativeRelationEdge {
source,
target,
kind,
weight,
confidence,
evidence,
line,
analyzer,
}
}
}

#[pyfunction]
pub fn build_relation_graph(
root: &str,
files: Vec<NativeFileInfo>,
python_source_roots: Vec<String>,
python_module_init_files: Vec<String>,
) -> PyResult<Vec<NativeRelationEdge>> {
let import_edges =
build_import_graph(root, files, python_source_roots, python_module_init_files)?;

let mut relation_edges = Vec::with_capacity(import_edges.len());
for edge in import_edges {
relation_edges.push(NativeRelationEdge {
source: edge.from,
target: edge.to,
kind: "import".to_string(), // we map everything to "import" for now to match python
weight: 1.0,
confidence: 0.98,
evidence: None,
line: None,
analyzer: "imports:native".to_string(),
});
}

Ok(relation_edges)
}
2 changes: 2 additions & 0 deletions rust/scriber_native/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ fn build_info() -> PyResult<String> {
fn _native(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_class::<scan::NativeFileInfo>()?;
m.add_class::<import::NativeImportEdge>()?;
m.add_class::<import::NativeRelationEdge>()?;
m.add_class::<score::NativeCandidate>()?;
m.add_class::<score::NativePackOptions>()?;
m.add_function(wrap_pyfunction!(read_text, m)?)?;
Expand All @@ -84,6 +85,7 @@ fn _native(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(read_many_text, m)?)?;
m.add_function(wrap_pyfunction!(scan_project, m)?)?;
m.add_function(wrap_pyfunction!(import::build_import_graph, m)?)?;
m.add_function(wrap_pyfunction!(import::build_relation_graph, m)?)?;
m.add_function(wrap_pyfunction!(score::score_candidates_native, m)?)?;
m.add_function(wrap_pyfunction!(render::render_tree, m)?)?;
m.add_function(wrap_pyfunction!(native_api_version, m)?)?;
Expand Down
162 changes: 122 additions & 40 deletions rust/scriber_native/src/score.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::import::NativeImportEdge;
use crate::import::NativeRelationEdge;
use crate::scan::NativeFileInfo;
use pyo3::prelude::*;
use std::collections::{HashMap, HashSet};
Expand Down Expand Up @@ -351,39 +351,117 @@ fn is_near_seed(support_file: &str, seed: &str) -> bool {
|| seed_parent.starts_with(sf_parent)
}

fn walk_neighbors(
edges: &HashMap<String, HashSet<String>>,
use std::cmp::Ordering;
use std::collections::BinaryHeap;

#[derive(Debug, Clone)]
struct QueueState {
strength: f64,
depth: usize,
node: String,
}

impl Eq for QueueState {}

impl PartialEq for QueueState {
fn eq(&self, other: &Self) -> bool {
self.strength == other.strength && self.depth == other.depth && self.node == other.node
}
}

impl Ord for QueueState {
fn cmp(&self, other: &Self) -> Ordering {
self.strength
.partial_cmp(&other.strength)
.unwrap_or(Ordering::Equal)
.then_with(|| other.depth.cmp(&self.depth))
}
}

impl PartialOrd for QueueState {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}

fn walk_weighted_neighbors(
edges: &[NativeRelationEdge],
start: &str,
depth: usize,
) -> HashMap<String, usize> {
let mut found = HashMap::new();
let mut frontier = HashSet::new();
frontier.insert(start.to_string());
let mut visited = HashSet::new();
visited.insert(start.to_string());

for distance in 1..=depth {
let mut next_frontier = HashSet::new();
for item in frontier {
if let Some(neighbors) = edges.get(&item) {
for neighbor in neighbors {
if visited.contains(neighbor) {
continue;
reverse: bool,
) -> HashMap<String, f64> {
let mut adj: HashMap<String, Vec<(String, &NativeRelationEdge)>> = HashMap::new();
for edge in edges {
let u = if reverse { &edge.target } else { &edge.source };
let v = if reverse { &edge.source } else { &edge.target };
adj.entry(u.clone()).or_default().push((v.clone(), edge));
}

let mut max_strength: HashMap<String, f64> = HashMap::new();
max_strength.insert(start.to_string(), 1.0);

let mut best_at_state: HashMap<(String, usize), f64> = HashMap::new();
best_at_state.insert((start.to_string(), 0), 1.0);

let mut heap = BinaryHeap::new();
heap.push(QueueState {
strength: 1.0,
depth: 0,
node: start.to_string(),
});

while let Some(QueueState {
strength: u_str,
depth: u_depth,
node: u,
}) = heap.pop()
{
if u_str < *best_at_state.get(&(u.clone(), u_depth)).unwrap_or(&0.0) {
continue;
}

if u_depth >= depth {
continue;
}

if let Some(neighbors) = adj.get(&u) {
for (neighbor, edge) in neighbors {
let edge_str = if edge.kind == "import" || edge.kind == "reexport" {
if u_depth == 0 {
1.0
} else {
0.88
}
visited.insert(neighbor.clone());
found.insert(neighbor.clone(), distance);
next_frontier.insert(neighbor.clone());
} else {
edge.weight * edge.confidence
};

let next_str = u_str * edge_str;
let next_depth = u_depth + 1;

if next_str > *max_strength.get(neighbor).unwrap_or(&0.0) {
max_strength.insert(neighbor.clone(), next_str);
}

let state_key = (neighbor.clone(), next_depth);
if next_str > *best_at_state.get(&state_key).unwrap_or(&0.0) {
best_at_state.insert(state_key, next_str);
heap.push(QueueState {
strength: next_str,
depth: next_depth,
node: neighbor.clone(),
});
}
}
}
frontier = next_frontier;
if frontier.is_empty() {
break;
}
}
found

max_strength.remove(start);
max_strength
}



fn support_base_score(file: &NativeFileInfo, options: &NativePackOptions) -> i32 {
let cat = file.support_category.as_deref().unwrap_or("support file");
match cat {
Expand Down Expand Up @@ -429,7 +507,7 @@ fn matches_entrypoint(rel: &str, entrypoint_patterns: &[String]) -> bool {
pub fn score_candidates_native(
files: Vec<NativeFileInfo>,
seeds_list: Vec<String>,
edges: Vec<NativeImportEdge>,
edges: Vec<NativeRelationEdge>,
options: NativePackOptions,
) -> PyResult<Vec<NativeCandidate>> {
let mut mapped_files = HashMap::new();
Expand All @@ -450,15 +528,17 @@ pub fn score_candidates_native(
// Build graph edges maps
let mut graph_imports: HashMap<String, HashSet<String>> = HashMap::new();
let mut graph_imported_by: HashMap<String, HashSet<String>> = HashMap::new();
for edge in edges {
graph_imports
.entry(edge.from.clone())
.or_default()
.insert(edge.to.clone());
graph_imported_by
.entry(edge.to.clone())
.or_default()
.insert(edge.from.clone());
for edge in &edges {
if edge.kind == "import" || edge.kind == "reexport" {
graph_imports
.entry(edge.source.clone())
.or_default()
.insert(edge.target.clone());
graph_imported_by
.entry(edge.target.clone())
.or_default()
.insert(edge.source.clone());
}
}

if options.mode == "project_snapshot" {
Expand Down Expand Up @@ -531,10 +611,12 @@ pub fn score_candidates_native(
for seed_rel in &seed_files {
// Direct dependencies
if options.include_direct_dependencies {
for (dep, distance) in walk_neighbors(&graph_imports, seed_rel, options.depth) {
for (dep, strength) in
walk_weighted_neighbors(&edges, seed_rel, options.depth, false)
{
let score = std::cmp::max(
options.tree_min_score,
options.direct_dependency_score - ((distance as i32 - 1) * 10),
(options.direct_dependency_score as f64 * strength) as i32,
);
if let Some(c) = mapped_files.get_mut(&dep) {
c.score = std::cmp::max(c.score, score);
Expand All @@ -551,12 +633,12 @@ pub fn score_candidates_native(

// Reverse dependencies
if options.include_reverse_dependencies {
for (dep, distance) in
walk_neighbors(&graph_imported_by, seed_rel, options.depth)
for (dep, strength) in
walk_weighted_neighbors(&edges, seed_rel, options.depth, true)
{
let score = std::cmp::max(
options.tree_min_score,
options.reverse_dependency_score - ((distance as i32 - 1) * 10),
(options.reverse_dependency_score as f64 * strength) as i32,
);
if let Some(c) = mapped_files.get_mut(&dep) {
c.score = std::cmp::max(c.score, score);
Expand Down
5 changes: 3 additions & 2 deletions src/scriber/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""ProjectScriber 2.0."""
"""ProjectScriber 2.1."""

from .packer.pack import build_pack, build_and_write_pack
from .core.models import ScriberPack

__all__ = ["build_pack", "build_and_write_pack", "ScriberPack"]

__version__ = "2.0.0"
__version__ = "2.1.0"

Loading
Loading