Release 0.1.1 #3
@@ -1,3 +1,7 @@
|
|||||||
# Tools
|
# Tools
|
||||||
|
|
||||||
Scratchpad notes (`update_scratchpad`) are shared across all agents in the session and injected into every agent's context. Not persisted across sessions. Keep values concise. For a **private** task list that sub-agents should *not* see, use `write_todos` instead.
|
Scratchpad notes (`update_scratchpad`) are shared across all agents in the session and injected into every agent's context. Not persisted across sessions. Keep values concise. For a **private** task list that sub-agents should *not* see, use `write_todos` instead.
|
||||||
|
|
||||||
|
## Understanding code before you read it
|
||||||
|
|
||||||
|
When you need to understand source code you don't already know, reach for `get_ast_outline` **before** `read_file` — especially on a large file. It returns the file's structure and the line range of every definition at a fraction of the tokens. Then `read_file` only the ranges you actually need. Reading a whole unfamiliar file wastes context; outline first, read narrow. (`list_files` with `with_metadata=true` reports each file's size and line count, so you can spot which files are worth outlining.)
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ pub struct MemoryHit {
|
|||||||
pub snippet: String,
|
pub snippet: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A directory listing row carrying cheap size metadata. `line_count` and
|
||||||
|
/// `byte_len` are computed in SQL (`LENGTH` / newline count) so the note body
|
||||||
|
/// never leaves the database.
|
||||||
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||||
|
pub struct MemoryEntryMeta {
|
||||||
|
pub path: String,
|
||||||
|
pub line_count: i64,
|
||||||
|
pub byte_len: i64,
|
||||||
|
}
|
||||||
|
|
||||||
const SELECT: &str = "SELECT id, path, content, created_at, updated_at FROM memory_docs";
|
const SELECT: &str = "SELECT id, path, content, created_at, updated_at FROM memory_docs";
|
||||||
|
|
||||||
/// Fetch one note by its exact path.
|
/// Fetch one note by its exact path.
|
||||||
@@ -84,6 +94,27 @@ pub async fn list(pool: &SqlitePool, prefix: &str) -> Result<Vec<MemoryEntry>> {
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [`list`], but each row also carries a line count and byte length,
|
||||||
|
/// computed in SQL so the body is never transferred. Line count matches the
|
||||||
|
/// on-disk convention: an empty note is 0 lines, otherwise newline-count + 1.
|
||||||
|
pub async fn list_with_metadata(pool: &SqlitePool, prefix: &str) -> Result<Vec<MemoryEntryMeta>> {
|
||||||
|
let pattern = format!("{}%", escape_like(prefix));
|
||||||
|
let rows = sqlx::query_as::<_, MemoryEntryMeta>(
|
||||||
|
"SELECT path,
|
||||||
|
CASE WHEN content = '' THEN 0
|
||||||
|
ELSE LENGTH(content) - LENGTH(REPLACE(content, char(10), '')) + 1
|
||||||
|
END AS line_count,
|
||||||
|
LENGTH(CAST(content AS BLOB)) AS byte_len
|
||||||
|
FROM memory_docs
|
||||||
|
WHERE path LIKE ? ESCAPE '\\'
|
||||||
|
ORDER BY updated_at DESC",
|
||||||
|
)
|
||||||
|
.bind(pattern)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
/// Full-text search over note bodies and paths, best match first. `query` is
|
/// Full-text search over note bodies and paths, best match first. `query` is
|
||||||
/// FTS5 MATCH syntax; `snippet` is a short excerpt of the body with the matched
|
/// FTS5 MATCH syntax; `snippet` is a short excerpt of the body with the matched
|
||||||
/// terms wrapped in `[` … `]`.
|
/// terms wrapped in `[` … `]`.
|
||||||
|
|||||||
@@ -618,9 +618,13 @@ impl MessageBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !active.is_empty() {
|
if !active.is_empty() {
|
||||||
out.push_str("\n**Active** — tools callable as `mcp__<name>__<tool>`:\n");
|
out.push_str("\n**Active** — tools callable as `mcp__<name>__<tool>`:\n\n");
|
||||||
|
out.push_str("| Server | Description |\n|--------|-------------|\n");
|
||||||
for name in &active {
|
for name in &active {
|
||||||
out.push_str(&format!("- `{name}`\n"));
|
let desc = descriptions.get(*name)
|
||||||
|
.and_then(|d| d.as_deref())
|
||||||
|
.unwrap_or("—");
|
||||||
|
out.push_str(&format!("| `{name}` | {desc} |\n"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ impl Tool for AstOutline {
|
|||||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Return the structural outline of a source file: top-level definitions (functions, classes, \
|
"Start here when you need to understand a source file you don't already know — especially a large one. \
|
||||||
structs, methods, traits, interfaces, etc.) without their bodies. \
|
Returns the file's structural outline: top-level definitions (functions, classes, structs, methods, \
|
||||||
Each entry is formatted as 'START-END | <kind>: <name>' where START and END are 1-based \
|
traits, interfaces, etc.) without their bodies, so you grasp the whole shape at a fraction of the \
|
||||||
line numbers of the full definition — same column format as read_file, so you can pass \
|
tokens of reading it. \
|
||||||
START/END directly to read_file's start_line/end_line to read just that definition. \
|
Each entry is formatted as 'START-END | <kind>: <name>' where START and END are 1-based line numbers \
|
||||||
Much cheaper than reading the full file when you only need to understand the shape of the code. \
|
of the full definition — same column format as read_file, so you pass START/END straight to \
|
||||||
|
read_file's start_line/end_line to read just the definition you care about. \
|
||||||
|
Typical flow: outline first, then read only the ranges you need — far cheaper than reading the whole file. \
|
||||||
Supported: .rs .py .js .mjs .ts .tsx .go .java .c .h .cpp .cc .hpp .swift .lua .rb .sh .ex .exs \
|
Supported: .rs .py .js .mjs .ts .tsx .go .java .c .h .cpp .cc .hpp .swift .lua .rb .sh .ex .exs \
|
||||||
.kt .json .toml .yaml .yml .html .css .md .sql"
|
.kt .json .toml .yaml .yml .html .css .md .sql"
|
||||||
}
|
}
|
||||||
@@ -67,7 +69,7 @@ impl Tool for AstOutline {
|
|||||||
"rb" => outline_ts(path, ts_ruby(), "Ruby"),
|
"rb" => outline_ts(path, ts_ruby(), "Ruby"),
|
||||||
"sh" | "bash" => outline_ts(path, ts_bash(), "Bash"),
|
"sh" | "bash" => outline_ts(path, ts_bash(), "Bash"),
|
||||||
"ex" | "exs" => outline_ts(path, ts_elixir(), "Elixir"),
|
"ex" | "exs" => outline_ts(path, ts_elixir(), "Elixir"),
|
||||||
"json" => outline_ts(path, ts_json(), "JSON"),
|
"json" => outline_json(path),
|
||||||
"yaml" | "yml" => outline_ts(path, ts_yaml(), "YAML"),
|
"yaml" | "yml" => outline_ts(path, ts_yaml(), "YAML"),
|
||||||
"html" => outline_ts(path, ts_html(), "HTML"),
|
"html" => outline_ts(path, ts_html(), "HTML"),
|
||||||
"css" => outline_ts(path, ts_css(), "CSS"),
|
"css" => outline_ts(path, ts_css(), "CSS"),
|
||||||
@@ -292,15 +294,6 @@ fn ts_elixir() -> LangConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ts_json() -> LangConfig {
|
|
||||||
LangConfig {
|
|
||||||
language: tree_sitter_json::LANGUAGE.into(),
|
|
||||||
def_kinds: &["pair"],
|
|
||||||
name_field: "key",
|
|
||||||
container_kinds: &[],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ts_yaml() -> LangConfig {
|
fn ts_yaml() -> LangConfig {
|
||||||
LangConfig {
|
LangConfig {
|
||||||
language: tree_sitter_yaml::LANGUAGE.into(),
|
language: tree_sitter_yaml::LANGUAGE.into(),
|
||||||
@@ -331,6 +324,147 @@ fn ts_css() -> LangConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── JSON outline (dedicated tree-sitter walker: nested keys) ────────────────
|
||||||
|
//
|
||||||
|
// The generic `collect_nodes` only descends through `container_kinds`, which for
|
||||||
|
// JSON tops out at the first level of the root object (and never enters arrays
|
||||||
|
// of objects). This walker recurses through the parse tree instead: it lists
|
||||||
|
// every key at every depth, shows scalar values inline, and expands nested
|
||||||
|
// objects/arrays. Line ranges keep the read_file contract (`START-END | …`).
|
||||||
|
|
||||||
|
const JSON_VALUE_KINDS: &[&str] =
|
||||||
|
&["object", "array", "string", "number", "true", "false", "null"];
|
||||||
|
|
||||||
|
fn outline_json(path: &str) -> Result<String> {
|
||||||
|
let source = read_to_string(path)?;
|
||||||
|
let mut parser = tree_sitter::Parser::new();
|
||||||
|
let language: tree_sitter::Language = tree_sitter_json::LANGUAGE.into();
|
||||||
|
parser.set_language(&language)
|
||||||
|
.map_err(|e| anyhow::anyhow!("tree-sitter language load error: {e}"))?;
|
||||||
|
let tree = parser.parse(source.as_bytes(), None)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("tree-sitter parse returned None for {path}"))?;
|
||||||
|
|
||||||
|
let mut out = format!("--- JSON outline: {path} ---\n\n");
|
||||||
|
// document → single top-level value (object or array).
|
||||||
|
if let Some(top) = json_first_value(tree.root_node()) {
|
||||||
|
json_walk(top, &source, 0, &mut out);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First JSON value child of `document` (skips comments/whitespace nodes).
|
||||||
|
fn json_first_value(document: tree_sitter::Node) -> Option<tree_sitter::Node> {
|
||||||
|
for i in 0..document.child_count() {
|
||||||
|
let c = document.child(i as u32).unwrap();
|
||||||
|
if JSON_VALUE_KINDS.contains(&c.kind()) {
|
||||||
|
return Some(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit one line per entry of an object/array, recursing into nested containers.
|
||||||
|
/// Scalars are shown inline; scalar array elements are summarised by the array's
|
||||||
|
/// header only (not listed) to stay readable on large value arrays.
|
||||||
|
fn json_walk(node: tree_sitter::Node, source: &str, depth: usize, out: &mut String) {
|
||||||
|
const MAX_JSON_DEPTH: usize = 16;
|
||||||
|
if depth > MAX_JSON_DEPTH {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match node.kind() {
|
||||||
|
"object" => {
|
||||||
|
for i in 0..node.child_count() {
|
||||||
|
let pair = node.child(i as u32).unwrap();
|
||||||
|
if pair.kind() != "pair" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (Some(key), Some(val)) = (
|
||||||
|
pair.child_by_field_name("key"),
|
||||||
|
pair.child_by_field_name("value"),
|
||||||
|
) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
json_emit(&json_key_text(key, source), val, pair, source, depth, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"array" => {
|
||||||
|
let mut idx = 0usize;
|
||||||
|
for i in 0..node.child_count() {
|
||||||
|
let el = node.child(i as u32).unwrap();
|
||||||
|
if !JSON_VALUE_KINDS.contains(&el.kind()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let this = idx;
|
||||||
|
idx += 1;
|
||||||
|
// Only expand container elements; scalars are covered by the count.
|
||||||
|
if el.kind() == "object" || el.kind() == "array" {
|
||||||
|
json_emit(&format!("[{this}]"), el, el, source, depth, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit one entry line (`name: <value-descriptor>`) spanning `span`'s rows,
|
||||||
|
/// then recurse when the value is itself a container.
|
||||||
|
fn json_emit(
|
||||||
|
name: &str,
|
||||||
|
val: tree_sitter::Node,
|
||||||
|
span: tree_sitter::Node,
|
||||||
|
source: &str,
|
||||||
|
depth: usize,
|
||||||
|
out: &mut String,
|
||||||
|
) {
|
||||||
|
let start = span.start_position().row + 1;
|
||||||
|
let end = span.end_position().row + 1;
|
||||||
|
let indent = " ".repeat(depth);
|
||||||
|
let desc = json_value_desc(val, source);
|
||||||
|
out.push_str(&format!("{start:>4}-{end:>4} | {indent}{name}: {desc}\n"));
|
||||||
|
if val.kind() == "object" || val.kind() == "array" {
|
||||||
|
json_walk(val, source, depth + 1, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short descriptor of a value: `{N keys}`, `[N items]`, or the scalar literal.
|
||||||
|
fn json_value_desc(node: tree_sitter::Node, source: &str) -> String {
|
||||||
|
match node.kind() {
|
||||||
|
"object" => {
|
||||||
|
let n = json_count(node, &["pair"]);
|
||||||
|
format!("{{{n} {}}}", if n == 1 { "key" } else { "keys" })
|
||||||
|
}
|
||||||
|
"array" => {
|
||||||
|
let n = json_count(node, JSON_VALUE_KINDS);
|
||||||
|
format!("[{n} {}]", if n == 1 { "item" } else { "items" })
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let raw = source.get(node.byte_range()).unwrap_or("");
|
||||||
|
let one = raw.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
truncate_label(&one, MAX_LABEL_SHORT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of direct children whose kind is in `kinds`.
|
||||||
|
fn json_count(node: tree_sitter::Node, kinds: &[&str]) -> usize {
|
||||||
|
let mut n = 0;
|
||||||
|
for i in 0..node.child_count() {
|
||||||
|
if kinds.contains(&node.child(i as u32).unwrap().kind()) {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Object key text with the surrounding double-quotes stripped.
|
||||||
|
fn json_key_text(key: tree_sitter::Node, source: &str) -> String {
|
||||||
|
let raw = source.get(key.byte_range()).unwrap_or("");
|
||||||
|
raw.strip_prefix('"')
|
||||||
|
.and_then(|s| s.strip_suffix('"'))
|
||||||
|
.unwrap_or(raw)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
// ── text-based fallbacks (crates incompatible with tree-sitter 0.26) ───────
|
// ── text-based fallbacks (crates incompatible with tree-sitter 0.26) ───────
|
||||||
|
|
||||||
fn outline_kotlin(path: &str) -> Result<String> {
|
fn outline_kotlin(path: &str) -> Result<String> {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ impl Tool for ListFiles {
|
|||||||
Skips .git, target, node_modules, .cache. \
|
Skips .git, target, node_modules, .cache. \
|
||||||
Returns a JSON array of paths relative to the requested directory. \
|
Returns a JSON array of paths relative to the requested directory. \
|
||||||
Use depth=1 for immediate contents only, depth=2-3 for moderate exploration. \
|
Use depth=1 for immediate contents only, depth=2-3 for moderate exploration. \
|
||||||
|
Set with_metadata=true to instead return objects {path, line_count?, size} — handy for spotting a \
|
||||||
|
large file worth outlining with get_ast_outline before you read it. \
|
||||||
Listing under user-memory/ (private) or shared-memory/ (shared) lists your memory notes instead of disk."
|
Listing under user-memory/ (private) or shared-memory/ (shared) lists your memory notes instead of disk."
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +56,10 @@ impl Tool for ListFiles {
|
|||||||
"dirs_only": {
|
"dirs_only": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "If true, return only directories and omit files (default false)."
|
"description": "If true, return only directories and omit files (default false)."
|
||||||
|
},
|
||||||
|
"with_metadata": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "If true, return objects {path, line_count?, size} instead of bare path strings. size is human-readable; line_count is included only for text files (omitted for binaries and very large files). Use it to decide whether to get_ast_outline a large file before reading it."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -71,6 +77,7 @@ impl Tool for ListFiles {
|
|||||||
/// under the prefix is returned, keyed relative to the requested directory.
|
/// under the prefix is returned, keyed relative to the requested directory.
|
||||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||||
let path = args["path"].as_str().unwrap_or("").to_string();
|
let path = args["path"].as_str().unwrap_or("").to_string();
|
||||||
|
let with_metadata = args["with_metadata"].as_bool().unwrap_or(false);
|
||||||
let Some(m) = classify_memory(&path) else {
|
let Some(m) = classify_memory(&path) else {
|
||||||
return match super::rewrite_to_host(&ctx.fs, &path, args) {
|
return match super::rewrite_to_host(&ctx.fs, &path, args) {
|
||||||
Ok(args) => self.run(args),
|
Ok(args) => self.run(args),
|
||||||
@@ -87,6 +94,21 @@ impl Tool for ListFiles {
|
|||||||
// Treat `rel` as a directory prefix: match `rel/…` (or everything at
|
// Treat `rel` as a directory prefix: match `rel/…` (or everything at
|
||||||
// the root), then strip it so results are relative to what was asked.
|
// the root), then strip it so results are relative to what was asked.
|
||||||
let prefix = if rel.is_empty() || rel.ends_with('/') { rel } else { format!("{rel}/") };
|
let prefix = if rel.is_empty() || rel.ends_with('/') { rel } else { format!("{rel}/") };
|
||||||
|
|
||||||
|
if with_metadata {
|
||||||
|
let mut entries: Vec<FileEntry> = crate::db::memory_docs::list_with_metadata(&pool, &prefix)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| FileEntry {
|
||||||
|
path: e.path.strip_prefix(&prefix).unwrap_or(&e.path).to_string(),
|
||||||
|
line_count: Some(e.line_count.max(0) as usize),
|
||||||
|
size: Some(human_size(e.byte_len.max(0) as u64)),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
entries.sort_by(|a, b| a.path.cmp(&b.path));
|
||||||
|
return Ok(ToolResult::Text(serde_json::to_string(&entries)?));
|
||||||
|
}
|
||||||
|
|
||||||
let entries = crate::db::memory_docs::list(&pool, &prefix).await?;
|
let entries = crate::db::memory_docs::list(&pool, &prefix).await?;
|
||||||
let mut paths: Vec<String> = entries.into_iter()
|
let mut paths: Vec<String> = entries.into_iter()
|
||||||
.map(|e| e.path.strip_prefix(&prefix).unwrap_or(&e.path).to_string())
|
.map(|e| e.path.strip_prefix(&prefix).unwrap_or(&e.path).to_string())
|
||||||
@@ -100,12 +122,81 @@ impl Tool for ListFiles {
|
|||||||
let user_path = args["path"].as_str().unwrap_or(".");
|
let user_path = args["path"].as_str().unwrap_or(".");
|
||||||
let max_depth = args["depth"].as_u64().unwrap_or(3) as usize;
|
let max_depth = args["depth"].as_u64().unwrap_or(3) as usize;
|
||||||
let dirs_only = args["dirs_only"].as_bool().unwrap_or(false);
|
let dirs_only = args["dirs_only"].as_bool().unwrap_or(false);
|
||||||
|
let with_metadata = args["with_metadata"].as_bool().unwrap_or(false);
|
||||||
let dir = resolve(user_path)?;
|
let dir = resolve(user_path)?;
|
||||||
|
|
||||||
let mut paths: Vec<String> = Vec::new();
|
let mut paths: Vec<String> = Vec::new();
|
||||||
walk(&dir, &dir, 0, max_depth, dirs_only, &mut paths)?;
|
walk(&dir, &dir, 0, max_depth, dirs_only, &mut paths)?;
|
||||||
paths.sort();
|
paths.sort();
|
||||||
Ok(serde_json::to_string(&paths)?)
|
|
||||||
|
if !with_metadata {
|
||||||
|
return Ok(serde_json::to_string(&paths)?);
|
||||||
|
}
|
||||||
|
let entries: Vec<FileEntry> = paths.into_iter()
|
||||||
|
.map(|rel| file_entry(&dir.join(&rel), rel))
|
||||||
|
.collect();
|
||||||
|
Ok(serde_json::to_string(&entries)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `with_metadata` listing row. Field order (declaration order) is the wire
|
||||||
|
/// order; `line_count` and `size` are omitted when unavailable.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct FileEntry {
|
||||||
|
path: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
line_count: Option<usize>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
size: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Largest file we'll read to count lines; bigger files report `size` only, so
|
||||||
|
/// a metadata listing never turns into a full read of the tree.
|
||||||
|
const LINE_COUNT_SIZE_CAP: u64 = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Build a metadata row for one on-disk path. `size` comes free from a stat;
|
||||||
|
/// `line_count` is read only for text files within the size cap.
|
||||||
|
fn file_entry(abs: &Path, rel: String) -> FileEntry {
|
||||||
|
let meta = std::fs::metadata(abs).ok();
|
||||||
|
let len = meta.as_ref().map(|m| m.len());
|
||||||
|
let is_file = meta.as_ref().map(|m| m.is_file()).unwrap_or(false);
|
||||||
|
let line_count = match len {
|
||||||
|
Some(l) if is_file && l <= LINE_COUNT_SIZE_CAP => count_lines_if_text(abs),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
FileEntry { path: rel, line_count, size: len.map(human_size) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Line count of a text file, or `None` if it reads as binary (contains a NUL).
|
||||||
|
fn count_lines_if_text(abs: &Path) -> Option<usize> {
|
||||||
|
let bytes = std::fs::read(abs).ok()?;
|
||||||
|
if bytes.contains(&0) { return None; }
|
||||||
|
Some(count_lines(&bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of lines an editor would show: 0 for empty, else newline-count plus
|
||||||
|
/// one when the file does not end in a newline.
|
||||||
|
fn count_lines(bytes: &[u8]) -> usize {
|
||||||
|
if bytes.is_empty() { return 0; }
|
||||||
|
let nl = bytes.iter().filter(|&&b| b == b'\n').count();
|
||||||
|
if bytes.last() == Some(&b'\n') { nl } else { nl + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable byte size, `ls -h` style (base 1024): "512 B", "18 KB", "1.4 MB".
|
||||||
|
fn human_size(bytes: u64) -> String {
|
||||||
|
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let mut size = bytes as f64;
|
||||||
|
let mut unit = 0;
|
||||||
|
while size >= 1024.0 && unit < UNITS.len() - 1 {
|
||||||
|
size /= 1024.0;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
if unit == 0 {
|
||||||
|
format!("{bytes} B")
|
||||||
|
} else {
|
||||||
|
let s = format!("{size:.1}");
|
||||||
|
let s = s.strip_suffix(".0").unwrap_or(&s);
|
||||||
|
format!("{s} {}", UNITS[unit])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,3 +229,43 @@ fn walk(root: &Path, dir: &Path, depth: usize, max_depth: usize, dirs_only: bool
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod meta_smoke {
|
||||||
|
use super::*;
|
||||||
|
const SP: &str = "/private/tmp/claude-501/-Users-dguiducci-projects-skald-circle/1cb4c456-6a62-4c67-abf8-bb93ef73e30c/scratchpad/lf";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn human_size_fmt() {
|
||||||
|
assert_eq!(human_size(0), "0 B");
|
||||||
|
assert_eq!(human_size(512), "512 B");
|
||||||
|
assert_eq!(human_size(18 * 1024), "18 KB");
|
||||||
|
assert_eq!(human_size(1024 * 1024 + 400 * 1024), "1.4 MB");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_counts() {
|
||||||
|
assert_eq!(count_lines(b""), 0);
|
||||||
|
assert_eq!(count_lines(b"a\nb\nc\n"), 3);
|
||||||
|
assert_eq!(count_lines(b"no newline"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entries() {
|
||||||
|
let t = std::path::Path::new(SP).join("three.txt");
|
||||||
|
let e = file_entry(&t, "three.txt".into());
|
||||||
|
println!("three.txt -> {}", serde_json::to_string(&e).unwrap());
|
||||||
|
assert_eq!(e.line_count, Some(3));
|
||||||
|
|
||||||
|
let o = std::path::Path::new(SP).join("one.txt");
|
||||||
|
let e = file_entry(&o, "one.txt".into());
|
||||||
|
println!("one.txt -> {}", serde_json::to_string(&e).unwrap());
|
||||||
|
assert_eq!(e.line_count, Some(1));
|
||||||
|
|
||||||
|
let b = std::path::Path::new(SP).join("blob.bin");
|
||||||
|
let e = file_entry(&b, "blob.bin".into());
|
||||||
|
println!("blob.bin -> {}", serde_json::to_string(&e).unwrap());
|
||||||
|
assert_eq!(e.line_count, None); // binary: size only
|
||||||
|
assert!(e.size.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ impl Tool for ReadFile {
|
|||||||
Use instead of cat/head/tail in the terminal. \
|
Use instead of cat/head/tail in the terminal. \
|
||||||
Returns text prefixed as ' N | line'. When calling edit_file, copy the text after '| ' exactly. \
|
Returns text prefixed as ' N | line'. When calling edit_file, copy the text after '| ' exactly. \
|
||||||
For large files use start_line/end_line to read in chunks — files over ~2000 lines should never be read whole. \
|
For large files use start_line/end_line to read in chunks — files over ~2000 lines should never be read whole. \
|
||||||
|
For an unfamiliar source file, call get_ast_outline first to get each definition's line range, then read \
|
||||||
|
just the range you need instead of the whole file. \
|
||||||
Use limit to cap output when end_line is unknown. \
|
Use limit to cap output when end_line is unknown. \
|
||||||
Paths under user-memory/ (private) or shared-memory/ (shared) read a note from your memory instead of disk."
|
Paths under user-memory/ (private) or shared-memory/ (shared) read a note from your memory instead of disk."
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user