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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.1.2](https://github.com/intjiraya/constellation/compare/v0.1.1...v0.1.2) - 2026-05-25

### Added

- *(search)* full-text search, DSL, DNS-rebinding hardening, split rebuild

### Documentation

- *(packaging)* document automated AUR flow, drop manual-flow emphasis

## [0.1.0] - 2026-05-25

### Added
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "constellation"
version = "0.1.1"
version = "0.1.2"
edition = "2024"
rust-version = "1.85"
authors = ["Jiraya <177346249+intjiraya@users.noreply.github.com>"]
Expand Down
61 changes: 50 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,26 +62,65 @@ cchats --root /custom/path # override ~/.claude/projects
| feature | what it does |
| :-------------------------------- | :---------------------------------------------------------------------------------------- |
| `every chat in one place` | reads every `~/.claude/projects/<sanitized>/*.jsonl`, groups by project, sorts by recency |
| `full-text search across chats` | server-side inverted index + suffix array for substring lookup, snippet highlighting |
| `smart client search` | multi-term AND, operators (`project:`, `model:`, `has:`, `before:`, `after:`), quotes |
| `live resume in the browser` | click, spawns `claude --resume <id>` inside a PTY, bridged through WebSocket to xterm.js |
| `fork without scarring` | one-click `--fork-session` from any chat, original untouched |
| `new chat from the rail` | start a fresh `claude` session in any indexed project's cwd |
| `token accounting` | input / cache-create / cache-read / output buckets, per chat, per project, all-up |
| `single 3.2 MiB binary` | rust, no runtime, no node, no python, `rust-embed` ships every asset inside |
| `loopback-only, origin-checked` | binds 127.0.0.1, rejects non-loopback `Origin`, strict CSP, vendored CDN scripts |
| `single binary` | rust, no runtime, no node, no python, `rust-embed` ships every asset inside |
| `DNS-rebinding hardened` | binds 127.0.0.1, rejects non-loopback `Host` and `Origin`, strict CSP, vendored scripts |

<br>

## search

Type in the search bar. Supports a small DSL:

```
auth bug multi-term AND across title / content
"merge conflict" quoted phrase
project:web filter by project (matches display path)
model:opus has:tool model contains "opus" AND has tool calls
has:cache before:2026-04-01 has cached tokens, last activity before date
auth project:api after:2026-01-01 combine freely
```

Plain terms hit the server's inverted index and search **inside the message
bodies** — user text, assistant text, thinking blocks, tool inputs and tool
outputs. Operators are evaluated client-side against session metadata.

<br>

## blazing fast

| metric | value |
| :------------------ | -----------: |
| cold start | 5.6 ms |
| index ready (152) | 447 ms |
| RSS idle | 18.6 MiB |
| `/api/stats` p50 | 0.08 ms |
| `/api/projects` p50 | 0.14 ms |
| big session parse | 27 ms |
| reindex 234 MiB | 430 ms |
Split rebuild publishes projects/sessions metadata immediately and indexes
search bodies in a parallel background phase.

| metric | value |
| :----------------------- | -----------: |
| cold start (server up) | 5.6 ms |
| metadata ready (152 ses) | ~3 ms |
| search ready (152 ses) | ~150 ms |
| RSS idle | 18.6 MiB |
| `/api/stats` p50 | 0.08 ms |
| `/api/projects` p50 | 0.14 ms |
| big session parse | 27 ms |
| reindex 234 MiB | 430 ms |

### search benchmark

Synthetic JSONL bodies (~4 KiB each, 30-word vocab repeated, 1 turn per
session), single-threaded x86_64. Real Claude sessions tend to have lower term
density so latency drops accordingly.

| sessions | rebuild() total | RSS Δ (KiB) | search "auth" p50 | search "auth tool" p50 |
| -------: | --------------: | ----------: | ----------------: | ---------------------: |
| 100 | ~8 ms | ~2.5 K| ~0.9 ms | ~1.4 ms |
| 1000 | ~58 ms | ~17 K| ~8.9 ms | ~15 ms |
| 5000 | ~282 ms | ~76 K| ~46 ms | ~76 ms |

Reproduce locally: `cargo run --release --example bench_search`.

Single binary, no runtime, no warmup. It just starts.

Expand Down
168 changes: 168 additions & 0 deletions examples/bench_search.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
use std::path::Path;
use std::time::Instant;

use constellation::index::Index;
use tempfile::TempDir;

const VOCAB: &[&str] = &[
"authentication",
"authorization",
"session",
"database",
"migration",
"performance",
"logging",
"metrics",
"regex",
"parser",
"tokenizer",
"snapshot",
"rebuild",
"concurrent",
"websocket",
"router",
"middleware",
"handler",
"request",
"response",
"search",
"index",
"postings",
"suffix",
"binary",
"claude",
"anthropic",
"model",
"prompt",
"tool",
];

fn synth_body(seed: u64) -> String {
let mut out = String::with_capacity(4096);
for i in 0..200 {
let idx = ((seed.wrapping_mul(31).wrapping_add(i)) as usize) % VOCAB.len();
out.push_str(VOCAB[idx]);
out.push(' ');
if i % 12 == 11 {
out.push('\n');
}
}
out
}

fn seed_session(project_dir: &Path, sid: &str, body: &str) {
std::fs::create_dir_all(project_dir).unwrap();
let escaped: String = body
.chars()
.map(|c| match c {
'\n' => "\\n".to_string(),
'"' => "\\\"".to_string(),
'\\' => "\\\\".to_string(),
c => c.to_string(),
})
.collect();
let content = format!(
"{{\"type\":\"ai-title\",\"aiTitle\":\"bench\",\"sessionId\":\"{sid}\"}}\n\
{{\"type\":\"user\",\"message\":{{\"role\":\"user\",\"content\":\"{escaped}\"}},\
\"uuid\":\"u-1\",\"timestamp\":\"2026-05-25T11:00:00.000Z\",\"sessionId\":\"{sid}\",\"cwd\":\"/srv/x\"}}\n"
);
std::fs::write(project_dir.join(format!("{sid}.jsonl")), content).unwrap();
}

fn rss_kib() -> Option<u64> {
let txt = std::fs::read_to_string("/proc/self/status").ok()?;
for line in txt.lines() {
if let Some(rest) = line.strip_prefix("VmRSS:") {
let n: u64 = rest.split_whitespace().next()?.parse().ok()?;
return Some(n);
}
}
None
}

fn median_ns(samples: &mut [u128]) -> u128 {
samples.sort_unstable();
samples[samples.len() / 2]
}

fn p99_ns(samples: &mut [u128]) -> u128 {
samples.sort_unstable();
samples[(samples.len() * 99 / 100).min(samples.len() - 1)]
}

fn run(n_sessions: usize, queries: &[&str]) {
let tmp = TempDir::new().unwrap();

let seed_start = Instant::now();
let n_projects = (n_sessions / 10).max(1);
for i in 0..n_sessions {
let proj = i % n_projects;
let proj_dir = tmp.path().join(format!("-bench-{proj:03}"));
seed_session(&proj_dir, &format!("sess-{i:05}"), &synth_body(i as u64));
}
let seed_elapsed = seed_start.elapsed();

let rss_before = rss_kib();
let idx = Index::new(tmp.path().to_owned());

let rebuild_start = Instant::now();
idx.rebuild();
let rebuild_total = rebuild_start.elapsed();
let rss_after = rss_kib();

let snap = idx.read();
let projects = snap.projects.len();
let sessions = snap.by_session_id.len();
let search_idx = snap.search_index.clone();
drop(snap);

println!();
println!("=== N = {n_sessions} sessions / {n_projects} projects ===");
println!(
"seed (synthetic JSONL write): {:>7} ms",
seed_elapsed.as_millis()
);
println!(
"rebuild() total: {:>7} ms",
rebuild_total.as_millis()
);
println!(" projects indexed: {projects}, sessions indexed: {sessions}");
if let (Some(b), Some(a)) = (rss_before, rss_after) {
println!(
"RSS delta: {:>7} KiB ({} → {})",
a.saturating_sub(b),
b,
a
);
}

for q in queries {
let terms: Vec<String> = q.split_whitespace().map(str::to_owned).collect();
let mut samples = Vec::with_capacity(50);
for _ in 0..50 {
let t = Instant::now();
let _hits = search_idx.search(&terms, 50);
samples.push(t.elapsed().as_nanos());
}
let n = search_idx.search(&terms, 50).len();
let med = median_ns(&mut samples);
let p99 = p99_ns(&mut samples);
println!(
"search {:<20} hits={:>5} p50 {:>6.2} µs p99 {:>6.2} µs",
format!("\"{q}\""),
n,
med as f64 / 1000.0,
p99 as f64 / 1000.0,
);
}
}

fn main() {
println!("constellation-rs search benchmark");
println!("=================================");
println!("Note: synthetic data, 1 turn per session, ~4 KiB body each.");

run(100, &["auth", "session", "model", "auth tool"]);
run(1_000, &["auth", "session", "model", "auth tool"]);
run(5_000, &["auth", "session", "model", "auth tool"]);
}
3 changes: 3 additions & 0 deletions src/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub struct IndexStats {
pub sessions: usize,
pub last_scan: Option<DateTime<Utc>>,
pub scanning: bool,
pub indexing_search: bool,
pub total_usage: Usage,
}

Expand Down Expand Up @@ -53,6 +54,7 @@ mod tests {
sessions: 7,
last_scan: None,
scanning: false,
indexing_search: false,
total_usage: Usage {
input: 1,
cache_creation: 2,
Expand All @@ -68,6 +70,7 @@ mod tests {
"sessions": 7,
"last_scan": null,
"scanning": false,
"indexing_search": false,
"total_usage": {
"input": 1,
"cache_creation": 2,
Expand Down
Loading
Loading