use std::path::PathBuf;
const RECENT_CAP: usize = 24;
const BLOB_CAP: usize = 60;
const DEFAULT_DEDUP_MIN_TOKENS: usize = 200;
fn dedup_enabled() -> bool {
!std::env::var("TOKENIX_DEDUP").is_ok_and(|v| v == "0")
}
fn dedup_min_tokens() -> usize {
std::env::var("TOKENIX_DEDUP_MIN_TOKENS")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(DEFAULT_DEDUP_MIN_TOKENS)
}
fn base_dir() -> Option<PathBuf> {
Some(dirs::home_dir()?.join(".tokenix"))
}
fn blob_dir() -> Option<PathBuf> {
Some(base_dir()?.join("blobs"))
}
fn index_path() -> Option<PathBuf> {
Some(base_dir()?.join("recent_outputs.json"))
}
pub fn digest(s: &str) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for b in s.as_bytes() {
hash ^= *b as u64;
hash = hash.wrapping_mul(0x1000_0000_01b3);
}
format!("{hash:016x}")
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct RecentOutput {
pub key: String,
#[serde(default)]
pub raw_key: String,
pub command: String,
pub ts: f64,
pub tokens: usize,
}
fn load_index() -> Vec<RecentOutput> {
let Some(path) = index_path() else {
return Vec::new();
};
std::fs::read_to_string(path)
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default()
}
fn save_index(entries: &[RecentOutput]) {
let Some(path) = index_path() else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(raw) = serde_json::to_string(entries) {
let _ = std::fs::write(path, raw);
}
}
pub fn stash(content: &str) -> Option<String> {
let key = digest(content);
let dir = blob_dir()?;
std::fs::create_dir_all(&dir).ok()?;
let path = dir.join(format!("{key}.txt"));
if !path.exists() {
std::fs::write(&path, content).ok()?;
prune_blobs(&dir);
}
Some(key)
}
pub fn retrieve(key: &str) -> Option<String> {
if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric()) {
return None;
}
std::fs::read_to_string(blob_dir()?.join(format!("{key}.txt"))).ok()
}
fn prune_blobs(dir: &std::path::Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut blobs: Vec<(std::time::SystemTime, PathBuf)> = entries
.flatten()
.filter_map(|e| {
let path = e.path();
let modified = e.metadata().ok()?.modified().ok()?;
Some((modified, path))
})
.collect();
if blobs.len() <= BLOB_CAP {
return;
}
blobs.sort_by_key(|(t, _)| *t);
for (_, old) in &blobs[..blobs.len() - BLOB_CAP] {
let _ = std::fs::remove_file(old);
}
}
pub fn remember(
command: &str,
compressed: &str,
raw: &str,
tokens: usize,
ts: f64,
) -> Option<String> {
let key = stash(compressed)?;
let raw_key = stash(raw).unwrap_or_else(|| key.clone());
let mut index = load_index();
index.retain(|e| e.key != key);
index.insert(
0,
RecentOutput {
key: key.clone(),
raw_key,
command: command.chars().take(120).collect(),
ts,
tokens,
},
);
index.truncate(RECENT_CAP);
save_index(&index);
Some(key)
}
pub fn find_identical(content: &str, tokens: usize) -> Option<RecentOutput> {
if !dedup_enabled() || tokens < dedup_min_tokens() {
return None;
}
let key = digest(content);
let mut hit = load_index().into_iter().find(|e| e.key == key)?;
if retrieve(&hit.key).as_deref() != Some(content) {
return None;
}
if !hit.raw_key.is_empty() && retrieve(&hit.raw_key).is_none() {
hit.raw_key = String::new();
}
Some(hit)
}
pub fn dedup_marker(hit: &RecentOutput, now: f64) -> String {
let age = (now - hit.ts).max(0.0) as u64;
let ago = if age < 90 {
format!("{age}s ago")
} else if age < 5400 {
format!("{}m ago", age / 60)
} else {
format!("{}h ago", age / 3600)
};
let key = if hit.raw_key.is_empty() {
&hit.key
} else {
&hit.raw_key
};
format!(
"[tokenix: output identical to `{}` from {} ({} tokens). Unchanged — run `tokenix retrieve {}` for the full text.]",
hit.command, ago, hit.tokens, key
)
}
const DEFAULT_READ_TTL_SECS: f64 = 900.0;
const DEFAULT_READ_MIN_TOKENS: usize = 1_500;
fn read_dedup_enabled() -> bool {
!std::env::var("TOKENIX_READ_DEDUP").is_ok_and(|v| v == "0")
}
fn read_ttl_secs() -> f64 {
std::env::var("TOKENIX_READ_DEDUP_TTL")
.ok()
.and_then(|v| v.trim().parse::<f64>().ok())
.unwrap_or(DEFAULT_READ_TTL_SECS)
}
fn read_min_tokens() -> usize {
std::env::var("TOKENIX_READ_DEDUP_MIN_TOKENS")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(DEFAULT_READ_MIN_TOKENS)
}
fn reads_path() -> Option<PathBuf> {
Some(base_dir()?.join("recent_reads.json"))
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct RecentRead {
pub path: String,
pub content_key: String,
pub ts: f64,
pub tokens: usize,
}
fn load_reads() -> Vec<RecentRead> {
let Some(path) = reads_path() else {
return Vec::new();
};
std::fs::read_to_string(path)
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default()
}
fn save_reads(entries: &[RecentRead]) {
let Some(path) = reads_path() else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(raw) = serde_json::to_string(entries) {
let _ = std::fs::write(path, raw);
}
}
pub fn remember_read(path: &str, content: &str, tokens: usize, ts: f64) {
let Some(content_key) = stash(content) else {
return;
};
let mut reads = load_reads();
reads.retain(|r| r.path != path);
reads.insert(
0,
RecentRead {
path: path.to_string(),
content_key,
ts,
tokens,
},
);
reads.truncate(RECENT_CAP);
save_reads(&reads);
}
pub fn find_recent_read(path: &str, content: &str, tokens: usize, now: f64) -> Option<RecentRead> {
if !read_dedup_enabled() || tokens < read_min_tokens() {
return None;
}
let key = digest(content);
let hit = load_reads()
.into_iter()
.find(|r| r.path == path && r.content_key == key)?;
if now - hit.ts > read_ttl_secs() {
return None;
}
if retrieve(&hit.content_key).as_deref() != Some(content) {
return None;
}
Some(hit)
}
pub fn read_marker(hit: &RecentRead, now: f64) -> String {
let mins = ((now - hit.ts).max(0.0) / 60.0).round() as u64;
format!(
"[tokenix] `{}` is unchanged since you read it {}m ago ({} tokens) — it is already in this conversation.\n\
If you no longer have it: `tokenix retrieve {}` returns the exact bytes, or Read with offset/limit for a slice.",
hit.path, mins, hit.tokens, hit.content_key
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_marker_points_at_recovery() {
let hit = RecentRead {
path: "src/main.rs".to_string(),
content_key: "deadbeef".to_string(),
ts: 0.0,
tokens: 3000,
};
let marker = read_marker(&hit, 300.0);
assert!(marker.contains("src/main.rs"));
assert!(marker.contains("5m ago"));
assert!(marker.contains("tokenix retrieve deadbeef"));
assert!(marker.contains("offset/limit"));
}
#[test]
fn small_reads_are_never_suppressed() {
assert!(find_recent_read("x.rs", "fn main() {}", 5, 0.0).is_none());
}
#[test]
fn digest_is_stable_and_content_sensitive() {
assert_eq!(digest("hello"), digest("hello"));
assert_ne!(digest("hello"), digest("hellp"));
assert_eq!(digest("hello").len(), 16);
}
#[test]
fn retrieve_rejects_path_traversal_keys() {
assert!(retrieve("../../etc/passwd").is_none());
assert!(retrieve("a/b").is_none());
assert!(retrieve("").is_none());
}
#[test]
fn dedup_marker_mentions_command_key_and_recovery() {
let hit = RecentOutput {
key: "compressedkey".to_string(),
raw_key: "abc123".to_string(),
command: "git status".to_string(),
ts: 1000.0,
tokens: 420,
};
let marker = dedup_marker(&hit, 1120.0);
assert!(marker.contains("git status"));
assert!(marker.contains("2m ago"));
assert!(marker.contains("tokenix retrieve abc123"));
}
#[test]
fn dedup_marker_uses_seconds_then_minutes_then_hours() {
let hit = RecentOutput {
key: "k".to_string(),
raw_key: "k".to_string(),
command: "c".to_string(),
ts: 0.0,
tokens: 1,
};
assert!(dedup_marker(&hit, 30.0).contains("30s ago"));
assert!(dedup_marker(&hit, 600.0).contains("10m ago"));
assert!(dedup_marker(&hit, 7200.0).contains("2h ago"));
}
#[test]
fn short_output_is_never_deduped() {
assert!(find_identical("tiny", 3).is_none());
}
}