use anyhow::{Context, Result};
use mimir_core::model::now_unix;
use mimir_core::{learn, store, Mimir};
pub fn dashboard(out: Option<String>, open: bool) -> Result<()> {
let mimir = Mimir::open()?;
let html = render(&mimir)?;
let path = out.unwrap_or_else(|| {
std::env::temp_dir()
.join("mimir-dashboard.html")
.to_string_lossy()
.into_owned()
});
std::fs::write(&path, html).with_context(|| format!("write {path}"))?;
println!("dashboard → {path}");
if open {
let _ = std::process::Command::new(if cfg!(target_os = "macos") {
"open"
} else if cfg!(target_os = "windows") {
"cmd"
} else {
"xdg-open"
})
.args(if cfg!(target_os = "windows") {
vec!["/c", "start", "", &path]
} else {
vec![path.as_str()]
})
.spawn();
}
Ok(())
}
struct Tile {
label: &'static str,
value: i64,
unit: &'static str,
}
fn render(mimir: &Mimir) -> Result<String> {
let conn = &mimir.conn;
let now = now_unix();
let q1 = |sql: &str| -> i64 { conn.query_row(sql, [], |r| r.get(0)).unwrap_or(0) };
let memories =
q1("SELECT count(*) FROM node WHERE kind='memory' AND deleted_at IS NULL AND superseded_by IS NULL");
let chunks = q1("SELECT count(*) FROM node WHERE kind='chunk' AND deleted_at IS NULL");
let symbols = q1("SELECT count(*) FROM node WHERE kind='symbol' AND deleted_at IS NULL");
let edges = q1("SELECT count(*) FROM edge");
let tiles = [
Tile {
label: "memories",
value: memories,
unit: "",
},
Tile {
label: "doc chunks",
value: chunks,
unit: "",
},
Tile {
label: "code symbols",
value: symbols,
unit: "",
},
Tile {
label: "edges",
value: edges,
unit: "",
},
];
let projects = q1("SELECT count(*) FROM node WHERE kind='project' AND deleted_at IS NULL");
let collections =
q1("SELECT count(*) FROM node WHERE kind='collection' AND deleted_at IS NULL");
let files = q1("SELECT count(*) FROM node WHERE kind='file' AND deleted_at IS NULL");
let mut stmt = conn.prepare(
"SELECT coalesce(subkind,'note'), count(*) FROM node
WHERE kind='memory' AND deleted_at IS NULL AND superseded_by IS NULL
GROUP BY 1 ORDER BY 2 DESC",
)?;
let types: Vec<(String, i64)> = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<rusqlite::Result<_>>()?;
drop(stmt);
let day0 = now / 86_400 - 13;
let mut stmt = conn.prepare(
"SELECT at/86400, event, count(*) FROM recall_event
WHERE at >= ?1 GROUP BY 1, 2",
)?;
let mut shown = [0i64; 14];
let mut opened = [0i64; 14];
for row in stmt.query_map([day0 * 86_400], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i64>(2)?,
))
})? {
let (day, event, n) = row?;
let idx = (day - day0).clamp(0, 13) as usize;
match event.as_str() {
"shown" => shown[idx] += n,
"opened" | "useful" => opened[idx] += n,
_ => {}
}
}
drop(stmt);
let mut stmt = conn.prepare(&format!(
"SELECT {} FROM node WHERE kind='memory' AND deleted_at IS NULL AND superseded_by IS NULL",
store::NODE_COLS
))?;
let mems: Vec<mimir_core::model::Node> = stmt
.query_map([], store::row_to_node)?
.collect::<rusqlite::Result<_>>()?;
drop(stmt);
let mut cohorts = [0i64; 5]; for m in &mems {
let s = learn::effective_strength(m, now);
let idx = if s < 0.25 {
0
} else if s < 0.6 {
1
} else if s < 1.2 {
2
} else if s < 2.5 {
3
} else {
4
};
cohorts[idx] += 1;
}
let mut stmt = conn.prepare(
"SELECT uid, coalesce(title,'(untitled)'), access_count FROM node
WHERE deleted_at IS NULL AND access_count > 0 AND kind IN ('memory','symbol','file')
ORDER BY access_count DESC, updated_at DESC LIMIT 6",
)?;
let top: Vec<(String, String, i64)> = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
.collect::<rusqlite::Result<_>>()?;
drop(stmt);
let mut stmt = conn.prepare(
"SELECT coalesce(p.title,'global'), count(n.id) FROM node n
LEFT JOIN node p ON p.id = n.project_id
WHERE n.deleted_at IS NULL AND n.kind IN ('memory','chunk','symbol','file')
GROUP BY 1 ORDER BY 2 DESC LIMIT 6",
)?;
let per_project: Vec<(String, i64)> = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<rusqlite::Result<_>>()?;
drop(stmt);
let calls_resolved =
q1("SELECT count(*) FROM edge WHERE rel='calls' AND json_extract(meta,'$.resolved')=1");
let calls_heuristic =
q1("SELECT count(*) FROM edge WHERE rel='calls' AND json_extract(meta,'$.resolved')=0");
let imports = q1("SELECT count(*) FROM edge WHERE rel='imports'");
let links = q1("SELECT count(*) FROM edge WHERE rel IN ('about','relates','links','mentions')");
let embedded = q1("SELECT count(*) FROM embedding");
let embeddable = q1(
"SELECT count(*) FROM node WHERE deleted_at IS NULL AND body IS NOT NULL
AND kind IN ('memory','chunk','annotation','symbol')",
);
let superseded = q1("SELECT count(*) FROM node WHERE superseded_by IS NOT NULL");
let archived =
q1("SELECT count(*) FROM node WHERE deleted_at IS NOT NULL AND json_extract(meta,'$.archived')=1");
let pinned = q1("SELECT count(*) FROM node WHERE pinned=1 AND deleted_at IS NULL");
let events_total = q1("SELECT count(*) FROM recall_event");
let db_bytes = std::fs::metadata(&mimir.paths.db_file)
.map(|m| m.len())
.unwrap_or(0);
let tiles_html: String = tiles
.iter()
.map(|t| {
format!(
r#"<div class="tile"><div class="tile-v">{}{}</div><div class="tile-l">{}</div></div>"#,
fmt_n(t.value),
t.unit,
t.label
)
})
.collect();
let comp = [
("memories", memories, "var(--teal)"),
("doc chunks", chunks, "var(--teal-dim)"),
("code symbols", symbols, "var(--navy-bright)"),
];
let total: i64 = comp.iter().map(|c| c.1).sum::<i64>().max(1);
let mut acc = 0f64;
let mut rings = String::new();
let mut legend = String::new();
for (i, (label, value, color)) in comp.iter().enumerate() {
acc += *value as f64 / total as f64;
let r = 24.0 + 64.0 * acc.sqrt();
rings.push_str(&format!(
r#"<circle class="ripple" style="animation-delay:{}ms" cx="110" cy="110" r="{:.1}" fill="none" stroke="{}" stroke-width="{}"/>"#,
200 + i * 180,
r,
color,
if i == 0 { 2.5 } else { 1.5 }
));
legend.push_str(&format!(
r#"<div class="lg"><span class="dot" style="background:{}"></span>{} <b>{}</b></div>"#,
color,
label,
fmt_n(*value)
));
}
let max_act = shown
.iter()
.chain(opened.iter())
.copied()
.max()
.unwrap_or(1)
.max(1);
let mut act_bars = String::new();
for i in 0..14 {
let x = i as f64 * 22.0;
let hs = 72.0 * shown[i] as f64 / max_act as f64;
let ho = 72.0 * opened[i] as f64 / max_act as f64;
act_bars.push_str(&format!(
r#"<rect class="grow" style="animation-delay:{}ms" x="{:.0}" y="{:.1}" width="9" height="{:.1}" fill="var(--navy-bright)"/><rect class="grow" style="animation-delay:{}ms" x="{:.0}" y="{:.1}" width="9" height="{:.1}" fill="var(--teal)"/>"#,
i * 40, x, 76.0 - hs, hs.max(1.0),
i * 40 + 20, x + 10.0, 76.0 - ho, ho.max(1.0),
));
}
let max_t = types.first().map(|t| t.1).unwrap_or(1).max(1);
let type_rows: String = types
.iter()
.map(|(name, n)| {
format!(
r#"<div class="hrow"><span class="hlabel">{}</span><span class="hbar"><i class="grow-x" style="width:{:.1}%"></i></span><span class="hval">{}</span></div>"#,
name,
100.0 * *n as f64 / max_t as f64,
fmt_n(*n)
)
})
.collect();
let cohort_labels = ["fading", "waning", "steady", "strong", "reinforced"];
let max_c = cohorts.iter().copied().max().unwrap_or(1).max(1);
let cohort_cols: String = cohorts
.iter()
.zip(cohort_labels)
.enumerate()
.map(|(i, (n, label))| {
let h = 64.0 * *n as f64 / max_c as f64;
let color = if i == 0 { "var(--gold)" } else { "var(--teal)" };
format!(
r#"<div class="ccol"><div class="cbarwrap"><div class="cbar grow" style="height:{:.0}px;background:{};animation-delay:{}ms"></div></div><div class="clabel">{}</div><div class="cval">{}</div></div>"#,
h.max(2.0), color, i * 90, label, fmt_n(*n)
)
})
.collect();
let top_rows: String = if top.is_empty() {
r#"<div class="empty">no recalls yet — the well is undisturbed</div>"#.into()
} else {
top.iter()
.map(|(uid, title, n)| {
let tail = &uid[uid.len().saturating_sub(6)..];
format!(
r#"<div class="trow"><span class="tid">{}</span><span class="ttitle">{}</span><span class="tcount">↑{}</span></div>"#,
tail,
esc(&truncate(title, 58)),
n
)
})
.collect()
};
let max_p = per_project.first().map(|p| p.1).unwrap_or(1).max(1);
let project_rows: String = per_project
.iter()
.map(|(name, n)| {
format!(
r#"<div class="hrow"><span class="hlabel">{}</span><span class="hbar"><i class="grow-x" style="width:{:.1}%"></i></span><span class="hval">{}</span></div>"#,
esc(&truncate(name, 18)),
100.0 * *n as f64 / max_p as f64,
fmt_n(*n)
)
})
.collect();
let coverage = if embeddable > 0 {
100.0 * embedded as f64 / embeddable as f64
} else {
0.0
};
let html = TEMPLATE
.replace("{{GENERATED}}", &mimir_core::format::full_date(now))
.replace("{{VERSION}}", env!("CARGO_PKG_VERSION"))
.replace(
"{{DB_PATH}}",
&esc(&mimir.paths.db_file.display().to_string()),
)
.replace("{{DB_SIZE}}", &fmt_bytes(db_bytes))
.replace("{{TILES}}", &tiles_html)
.replace("{{RINGS}}", &rings)
.replace("{{LEGEND}}", &legend)
.replace("{{ACT_BARS}}", &act_bars)
.replace(
"{{DAY_FIRST}}",
&mimir_core::format::month_day(day0 * 86_400),
)
.replace("{{DAY_LAST}}", &mimir_core::format::month_day(now))
.replace("{{TYPE_ROWS}}", &type_rows)
.replace("{{COHORTS}}", &cohort_cols)
.replace("{{TOP_ROWS}}", &top_rows)
.replace("{{PROJECT_ROWS}}", &project_rows)
.replace("{{PROJECTS}}", &fmt_n(projects))
.replace("{{COLLECTIONS}}", &fmt_n(collections))
.replace("{{FILES}}", &fmt_n(files))
.replace("{{CALLS_R}}", &fmt_n(calls_resolved))
.replace("{{CALLS_H}}", &fmt_n(calls_heuristic))
.replace("{{IMPORTS}}", &fmt_n(imports))
.replace("{{LINKS}}", &fmt_n(links))
.replace("{{EMBEDDED}}", &fmt_n(embedded))
.replace("{{COVERAGE}}", &format!("{coverage:.0}"))
.replace("{{MODEL}}", &mimir.config.embedding.model)
.replace("{{SUPERSEDED}}", &fmt_n(superseded))
.replace("{{ARCHIVED}}", &fmt_n(archived))
.replace("{{ARCHIVED_CLASS}}", if archived > 0 { "gold" } else { "" })
.replace("{{PINNED}}", &fmt_n(pinned))
.replace("{{EVENTS}}", &fmt_n(events_total));
Ok(html)
}
fn fmt_n(n: i64) -> String {
let s = n.to_string();
let mut out = String::new();
for (i, c) in s.chars().enumerate() {
if i > 0 && (s.len() - i).is_multiple_of(3) {
out.push('\u{2009}'); }
out.push(c);
}
out
}
fn fmt_bytes(b: u64) -> String {
if b > 1_048_576 * 100 {
format!("{:.1} GB", b as f64 / 1_073_741_824.0)
} else if b > 10_240 {
format!("{:.1} MB", b as f64 / 1_048_576.0)
} else {
format!("{b} B")
}
}
fn esc(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max).collect();
out.push('…');
out
}
}
const TEMPLATE: &str = r##"<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Mimir — the well</title>
<style>
:root{
--bg:#0a0e13; --panel:#0e151d; --line:#1c2936; --line-soft:#15202b;
--ink:#c9d7e2; --dim:#5d7287; --faint:#3a4c5e;
--teal:#1fb6a8; --teal-dim:#157f78; --navy-bright:#4e8cc7; --gold:#d9a441;
}
*{box-sizing:border-box;margin:0;padding:0}
html{background:var(--bg)}
body{
font-family:"Berkeley Mono","JetBrains Mono","IBM Plex Mono","Cascadia Code",ui-monospace,Menlo,monospace;
color:var(--ink); min-height:100vh; padding:40px 24px 64px;
background:
radial-gradient(1100px 500px at 70% -10%, rgba(31,182,168,.05), transparent 60%),
repeating-linear-gradient(0deg, transparent 0 2px, rgba(255,255,255,.012) 2px 4px),
var(--bg);
}
.wrap{max-width:1180px;margin:0 auto}
header{display:flex;align-items:baseline;gap:18px;border-bottom:1px solid var(--line);padding-bottom:18px;margin-bottom:26px;flex-wrap:wrap}
.mark{font-size:22px;font-weight:700;letter-spacing:.42em;color:var(--ink)}
.mark b{color:var(--teal);font-weight:700}
.sub{color:var(--dim);font-size:12px;letter-spacing:.14em;text-transform:uppercase}
.meta{margin-left:auto;color:var(--faint);font-size:11.5px;text-align:right;line-height:1.7}
.meta b{color:var(--dim);font-weight:400}
.tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:14px}
.tile{position:relative;background:var(--panel);border:1px solid var(--line);padding:18px 20px 14px}
.tile-v{font-size:34px;font-weight:700;letter-spacing:.02em;color:var(--ink);font-variant-numeric:tabular-nums}
.tile-l{color:var(--dim);font-size:11px;letter-spacing:.22em;text-transform:uppercase;margin-top:6px}
.tile::before,.panel::before{content:"";position:absolute;top:-1px;left:-1px;width:9px;height:9px;border-top:1px solid var(--teal);border-left:1px solid var(--teal);opacity:.85}
.tile::after,.panel::after{content:"";position:absolute;bottom:-1px;right:-1px;width:9px;height:9px;border-bottom:1px solid var(--teal);border-right:1px solid var(--teal);opacity:.35}
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:14px}
.panel{position:relative;background:var(--panel);border:1px solid var(--line);padding:18px 20px}
.s4{grid-column:span 4}.s5{grid-column:span 5}.s6{grid-column:span 6}.s7{grid-column:span 7}.s8{grid-column:span 8}.s12{grid-column:span 12}
h2{font-size:11px;font-weight:700;letter-spacing:.26em;text-transform:uppercase;color:var(--dim);margin-bottom:16px}
h2::before{content:"◈ ";color:var(--teal)}
.well{display:flex;gap:18px;align-items:center}
.well svg{flex:none}
.ripple{transform-box:fill-box;transform-origin:center;animation:ripple .9s cubic-bezier(.2,.8,.2,1) backwards}
@keyframes ripple{from{transform:scale(.55);opacity:0}to{transform:scale(1);opacity:1}}
.well-eye{fill:var(--teal);opacity:.9}
.lg{display:flex;align-items:center;gap:8px;color:var(--dim);font-size:12.5px;margin:7px 0}
.lg b{color:var(--ink);margin-left:auto;font-variant-numeric:tabular-nums}
.legend{flex:1;min-width:0}
.dot{width:8px;height:8px;flex:none;display:inline-block}
.grow{transform-box:fill-box;transform-origin:bottom;animation:grow .7s cubic-bezier(.2,.8,.2,1) backwards}
@keyframes grow{from{transform:scaleY(0)}to{transform:scaleY(1)}}
.axis{color:var(--faint);font-size:10px}
.key{display:flex;gap:16px;color:var(--dim);font-size:11px;margin-top:10px}
.key i{width:8px;height:8px;display:inline-block;margin-right:6px}
.hrow{display:flex;align-items:center;gap:10px;margin:8px 0;font-size:12.5px}
.hlabel{width:92px;flex:none;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.hbar{flex:1;height:10px;background:var(--line-soft);position:relative;overflow:hidden}
.hbar i{position:absolute;inset:0 auto 0 0;background:var(--teal)}
.grow-x{animation:growx .8s cubic-bezier(.2,.8,.2,1) backwards}
@keyframes growx{from{width:0!important}}
.hval{width:56px;text-align:right;color:var(--ink);font-variant-numeric:tabular-nums}
.cohorts{display:flex;gap:10px;align-items:flex-end}
.ccol{flex:1;text-align:center}
.cbarwrap{height:64px;display:flex;align-items:flex-end;justify-content:center}
.cbar{width:60%;}
.clabel{color:var(--dim);font-size:10px;letter-spacing:.08em;margin-top:8px;text-transform:uppercase}
.cval{color:var(--ink);font-size:13px;margin-top:2px;font-variant-numeric:tabular-nums}
.trow{display:flex;gap:12px;align-items:baseline;padding:7px 0;border-bottom:1px dashed var(--line-soft);font-size:12.5px}
.trow:last-child{border-bottom:none}
.tid{color:var(--teal);flex:none}
.ttitle{color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tcount{margin-left:auto;color:var(--gold);flex:none}
.empty{color:var(--faint);font-size:12.5px;padding:14px 0;font-style:italic}
.kv{display:grid;grid-template-columns:1fr 1fr;gap:7px 24px;font-size:12.5px}
.kv div{display:flex;justify-content:space-between;border-bottom:1px dashed var(--line-soft);padding:5px 0}
.kv span{color:var(--dim)} .kv b{color:var(--ink);font-weight:400;font-variant-numeric:tabular-nums}
.kv b.gold{color:var(--gold)} .kv b.teal{color:var(--teal)}
footer{margin-top:26px;border-top:1px solid var(--line);padding-top:14px;display:flex;color:var(--faint);font-size:11px;letter-spacing:.18em}
footer span{margin-left:auto}
@media (prefers-reduced-motion:reduce){.ripple,.grow,.grow-x{animation:none}}
@media (max-width:900px){.tiles{grid-template-columns:repeat(2,1fr)}.grid>*{grid-column:span 12}}
</style></head><body><div class="wrap">
<header>
<div class="mark">MIMI<b>R</b></div>
<div class="sub">memory engine · telemetry</div>
<div class="meta">v{{VERSION}} · {{GENERATED}}<br><b>{{DB_PATH}}</b> · {{DB_SIZE}}</div>
</header>
<div class="tiles">{{TILES}}</div>
<div class="grid">
<div class="panel s5">
<h2>The well — store composition</h2>
<div class="well">
<svg width="220" height="220" viewBox="0 0 220 220">
<circle cx="110" cy="110" r="97" fill="none" stroke="var(--line)" stroke-width="1"/>
<circle cx="110" cy="110" r="3.5" class="well-eye"/>
{{RINGS}}
</svg>
<div class="legend">{{LEGEND}}
<div class="lg"><span class="dot" style="background:var(--faint)"></span>projects <b>{{PROJECTS}}</b></div>
<div class="lg"><span class="dot" style="background:var(--faint)"></span>collections <b>{{COLLECTIONS}}</b></div>
<div class="lg"><span class="dot" style="background:var(--faint)"></span>files <b>{{FILES}}</b></div>
</div>
</div>
</div>
<div class="panel s7">
<h2>Recall activity — 14 days</h2>
<svg width="100%" height="96" viewBox="0 0 308 96" preserveAspectRatio="none">
<line x1="0" y1="76.5" x2="308" y2="76.5" stroke="var(--line)" stroke-width="1"/>
{{ACT_BARS}}
<text x="0" y="92" class="axis" fill="var(--faint)" font-size="9" font-family="inherit">{{DAY_FIRST}}</text>
<text x="308" y="92" text-anchor="end" class="axis" fill="var(--faint)" font-size="9" font-family="inherit">{{DAY_LAST}}</text>
</svg>
<div class="key"><span><i style="background:var(--navy-bright)"></i>shown in results</span>
<span><i style="background:var(--teal)"></i>opened / marked useful</span></div>
</div>
<div class="panel s4">
<h2>Memory types</h2>
{{TYPE_ROWS}}
</div>
<div class="panel s4">
<h2>Strength cohorts</h2>
<div class="cohorts">{{COHORTS}}</div>
</div>
<div class="panel s4">
<h2>Most recalled</h2>
{{TOP_ROWS}}
</div>
<div class="panel s6">
<h2>Knowledge by project</h2>
{{PROJECT_ROWS}}
</div>
<div class="panel s6">
<h2>Graph & hygiene</h2>
<div class="kv">
<div><span>calls resolved</span><b class="teal">{{CALLS_R}}</b></div>
<div><span>calls heuristic</span><b>{{CALLS_H}}</b></div>
<div><span>import edges</span><b>{{IMPORTS}}</b></div>
<div><span>memory↔code links</span><b class="teal">{{LINKS}}</b></div>
<div><span>embedded</span><b>{{EMBEDDED}} ({{COVERAGE}}%)</b></div>
<div><span>model</span><b>{{MODEL}}</b></div>
<div><span>superseded</span><b>{{SUPERSEDED}}</b></div>
<div><span>archived</span><b class="{{ARCHIVED_CLASS}}">{{ARCHIVED}}</b></div>
<div><span>pinned</span><b>{{PINNED}}</b></div>
<div><span>recall events</span><b>{{EVENTS}}</b></div>
</div>
</div>
</div>
<footer>ᛗᛁᛗᛁᚱ — what is remembered, remains<span>local · private · yours</span></footer>
</div></body></html>
"##;