- 1
//! Weekly usage digest (docs/design/29-personal-os.md P3): one report over - 2
//! the cost ledger, memory stores, and skill-proposal queue. Reads are - 3
//! bounded (line-streamed, window-filtered); missing files contribute - 4
//! zeros, never errors — absence of history is a fact about the report, - 5
//! not a failure of the run. - 6
- 7
use std::collections::{BTreeMap, BTreeSet}; - 8
use std::io::{BufRead, BufReader}; - 9
use std::path::{Path, PathBuf}; - 10
- 11
use serde::Serialize; - 12
- 13
use crate::finops::CostRow; - 14
- 15
#[derive(Debug, Clone, Default, Serialize, PartialEq)] - 16
pub struct ModelRollup { - 17
pub rows: u64, - 18
pub usd: f64, - 19
pub input_tokens: u64, - 20
pub output_tokens: u64, - 21
pub cache_read_tokens: u64, - 22
} - 23
- 24
#[derive(Debug, Clone, Default, Serialize, PartialEq)] - 25
pub struct ProviderRollup { - 26
pub rows: u64, - 27
pub usd: f64, - 28
} - 29
- 30
#[derive(Debug, Clone, Default, Serialize, PartialEq)] - 31
pub struct DayRollup { - 32
/// Local calendar day, "YYYY-MM-DD". - 33
pub day: String, - 34
pub usd: f64, - 35
/// Rows with unknown price that day: UNKNOWN is never folded into $0. - 36
pub unpriced_rows: u64, - 37
} - 38
- 39
#[derive(Debug, Clone, Default, Serialize)] - 40
pub struct DigestReport { - 41
pub days: u32, - 42
/// Window start (now − days), echoed so surfaces can label it. - 43
pub since: Option<chrono::DateTime<chrono::Utc>>, - 44
pub total_usd: f64, - 45
/// Priced dispatches excluded from total_usd because the model had no - 46
/// price — surfaced instead of silently reading as zero spend. - 47
pub unpriced_rows: u64, - 48
pub input_tokens: u64, - 49
pub output_tokens: u64, - 50
pub cache_read_tokens: u64, - 51
pub dispatches: u64, - 52
pub by_model: BTreeMap<String, ModelRollup>, - 53
pub by_provider: BTreeMap<String, ProviderRollup>, - 54
/// Ascending by day; only days with rows appear. - 55
pub per_day: Vec<DayRollup>, - 56
/// Distinct session ids touched in the window, sorted. - 57
pub distinct_sessions: Vec<String>, - 58
/// Memory notes (per-workspace MEMORY.md + global USER.md profile) - 59
/// whose provenance timestamp falls inside the window. - 60
pub memory_notes_appended: usize, - 61
/// Skill proposals created inside the window (proposal comment - 62
/// timestamp; file mtime as fallback). - 63
pub skill_proposals_opened: usize, - 64
} - 65
- 66
fn cost_log_path(home: &Path) -> PathBuf { - 67
home.join("cost-log.jsonl") - 68
} - 69
- 70
/// Stream the cost ledger once, folding priced/unpriced rows inside the - 71
/// window into the running aggregates. - 72
fn fold_costs( - 73
home: &Path, - 74
since: chrono::DateTime<chrono::Utc>, - 75
sessions: &mut BTreeSet<String>, - 76
report: &mut DigestReport, - 77
) { - 78
let Ok(f) = std::fs::File::open(cost_log_path(home)) else { - 79
return; - 80
}; - 81
for line in BufReader::new(f).lines().map_while(Result::ok) { - 82
let Ok(row) = serde_json::from_str::<CostRow>(&line) else { - 83
continue; - 84
}; - 85
if row.ts < since { - 86
continue; - 87
} - 88
let local_day = row - 89
.ts - 90
.with_timezone(&chrono::Local) - 91
.format("%Y-%m-%d") - 92
.to_string(); - 93
let rollup = |usd: Option<f64>, map: &mut BTreeMap<String, ModelRollup>| { - 94
let e = map.entry(row.model.clone()).or_default(); - 95
e.rows += 1; - 96
e.input_tokens += row.input_tokens; - 97
e.output_tokens += row.output_tokens; - 98
e.cache_read_tokens += row.cache_read_input_tokens.unwrap_or(0); - 99
if let Some(usd) = usd { - 100
e.usd += usd; - 101
} - 102
}; - 103
rollup(row.usd, &mut report.by_model); - 104
- 105
let provider_key = if row.provider.is_empty() { - 106
"(unattributed)".to_string() - 107
} else { - 108
row.provider.clone() - 109
}; - 110
let pe = report.by_provider.entry(provider_key).or_default(); - 111
pe.rows += 1; - 112
if let Some(usd) = row.usd { - 113
pe.usd += usd; - 114
} - 115
- 116
let de = report.per_day.iter_mut().find(|d| d.day == local_day); - 117
match de { - 118
Some(d) => { - 119
d.usd += row.usd.unwrap_or(0.0); - 120
d.unpriced_rows += u64::from(row.usd.is_none()); - 121
} - 122
None => report.per_day.push(DayRollup { - 123
day: local_day, - 124
usd: row.usd.unwrap_or(0.0), - 125
unpriced_rows: u64::from(row.usd.is_none()), - 126
}), - 127
} - 128
- 129
report.total_usd += row.usd.unwrap_or(0.0); - 130
report.unpriced_rows += u64::from(row.usd.is_none()); - 131
report.input_tokens += row.input_tokens; - 132
report.output_tokens += row.output_tokens; - 133
report.cache_read_tokens += row.cache_read_input_tokens.unwrap_or(0); - 134
report.dispatches += 1; - 135
sessions.insert(row.session_id.clone()); - 136
} - 137
report.per_day.sort_by(|a, b| a.day.cmp(&b.day)); - 138
} - 139
- 140
fn count_fresh_notes(path: &Path, since: chrono::DateTime<chrono::Utc>) -> usize { - 141
let Ok(raw) = std::fs::read_to_string(path) else { - 142
return 0; - 143
}; - 144
crate::memory::parse_blocks(&raw) - 145
.iter() - 146
.filter(|n| n.ts >= since) - 147
.count() - 148
} - 149
- 150
/// Every markdown store under `<home>/memory/` — per-workspace MEMORY.md - 151
/// files plus the global USER.md profile tier. - 152
fn memory_files(home: &Path) -> Vec<PathBuf> { - 153
let mut out = Vec::new(); - 154
let root = home.join("memory"); - 155
let Ok(read) = std::fs::read_dir(&root) else { - 156
return out; - 157
}; - 158
let mut projects: Vec<PathBuf> = read.flatten().map(|e| e.path()).collect(); - 159
projects.sort(); - 160
for project in projects { - 161
if project.is_file() { - 162
if project.extension().and_then(|e| e.to_str()) == Some("md") { - 163
out.push(project); - 164
} - 165
continue; - 166
} - 167
let Ok(files) = std::fs::read_dir(&project) else { - 168
continue; - 169
}; - 170
out.extend( - 171
files - 172
.flatten() - 173
.map(|f| f.path()) - 174
.filter(|p| p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("md")), - 175
); - 176
} - 177
out.sort(); - 178
out - 179
} - 180
- 181
fn proposal_opened_ts(path: &Path) -> Option<chrono::DateTime<chrono::Utc>> { - 182
let raw = std::fs::read_to_string(path).ok()?; - 183
// Proposals carry "<!-- proposed-by: <sid> at <rfc3339>; ... -->". - 184
if let Some(rest) = raw.split(" at ").nth(1) - 185
&& let Some(ts_raw) = rest.split(';').next() - 186
&& let Ok(ts) = chrono::DateTime::parse_from_rfc3339(ts_raw.trim()) - 187
{ - 188
return Some(ts.with_timezone(&chrono::Utc)); - 189
} - 190
let meta = std::fs::metadata(path).ok()?; - 191
let modified = meta.modified().ok()?; - 192
Some(chrono::DateTime::<chrono::Utc>::from(modified)) - 193
} - 194
- 195
/// Build the digest over the trailing `days` (24h windows). `days == 0` - 196
/// yields an empty report by construction (the window is empty). - 197
/// `home` is the Agent's home (its memory and skill proposals); - 198
/// `shared_home` holds what every Agent shares: the cost ledger and the - 199
/// trash. Spend stays whole, because it was spent; a trashed session is left - 200
/// out of `distinct_sessions`, the one place a digest names a session. - 201
pub fn digest(home: &Path, shared_home: &Path, days: u32) -> DigestReport { - 202
let mut report = DigestReport { - 203
days, - 204
..Default::default() - 205
}; - 206
if days == 0 { - 207
return report; - 208
} - 209
let since = chrono::Utc::now() - chrono::Duration::hours(days.saturating_mul(24) as i64); - 210
report.since = Some(since); - 211
- 212
let mut sessions = BTreeSet::new(); - 213
fold_costs(shared_home, since, &mut sessions, &mut report); - 214
let trashed = crate::trash::trashed(shared_home); - 215
report.distinct_sessions = sessions - 216
.into_iter() - 217
.filter(|id| !trashed.contains(id)) - 218
.collect(); - 219
- 220
for path in memory_files(home) { - 221
report.memory_notes_appended += count_fresh_notes(&path, since); - 222
} - 223
- 224
let mut proposals: Vec<PathBuf> = Vec::new(); - 225
let root = home.join("skill-proposals"); - 226
if let Ok(read) = std::fs::read_dir(&root) { - 227
for project in read.flatten() { - 228
let Ok(files) = std::fs::read_dir(project.path()) else { - 229
continue; - 230
}; - 231
proposals.extend( - 232
files - 233
.flatten() - 234
.map(|f| f.path()) - 235
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md")), - 236
); - 237
} - 238
} - 239
proposals.sort(); - 240
report.skill_proposals_opened = proposals - 241
.iter() - 242
.filter(|p| proposal_opened_ts(p).is_some_and(|ts| ts >= since)) - 243
.count(); - 244
- 245
report - 246
} - 247
- 248
#[cfg(test)] - 249
mod tests { - 250
#![allow(clippy::unwrap_used, clippy::expect_used)] - 251
use super::*; - 252
use crate::memory; - 253
- 254
fn row( - 255
ts: chrono::DateTime<chrono::Utc>, - 256
model: &str, - 257
provider: &str, - 258
usd: Option<f64>, - 259
sid: &str, - 260
) -> CostRow { - 261
CostRow { - 262
ts, - 263
model: model.into(), - 264
provider: provider.into(), - 265
input_tokens: 100, - 266
output_tokens: 50, - 267
cache_read_input_tokens: None, - 268
usd, - 269
source: "estimated".into(), - 270
session_id: sid.into(), - 271
} - 272
} - 273
- 274
#[test] - 275
fn missing_files_yield_zeros_not_errors() { - 276
let dir = tempfile::tempdir().unwrap(); - 277
let r = digest(dir.path(), dir.path(), 7); - 278
assert_eq!(r.total_usd, 0.0); - 279
assert_eq!(r.dispatches, 0); - 280
assert!(r.by_model.is_empty()); - 281
assert!(r.per_day.is_empty()); - 282
assert!(r.distinct_sessions.is_empty()); - 283
assert_eq!(r.memory_notes_appended, 0); - 284
assert_eq!(r.skill_proposals_opened, 0); - 285
// Empty home with no memory/ dir at all must behave identically. - 286
let empty = tempfile::tempdir().unwrap(); - 287
assert_eq!(digest(empty.path(), empty.path(), 30).dispatches, 0); - 288
} - 289
- 290
#[test] - 291
fn zero_days_is_an_empty_window() { - 292
let dir = tempfile::tempdir().unwrap(); - 293
let ledger = crate::finops::FinOpsLedger::new(dir.path()); - 294
ledger - 295
.append(&row(chrono::Utc::now(), "m", "p", Some(1.0), "s")) - 296
.unwrap(); - 297
let r = digest(dir.path(), dir.path(), 0); - 298
assert_eq!(r.dispatches, 0); - 299
assert!(r.since.is_none()); - 300
} - 301
- 302
#[test] - 303
fn ledger_math_matches_seeded_rows() { - 304
let dir = tempfile::tempdir().unwrap(); - 305
let ledger = crate::finops::FinOpsLedger::new(dir.path()); - 306
let now = chrono::Utc::now(); - 307
ledger - 308
.append(&row( - 309
now - chrono::Duration::hours(1), - 310
"claude-sonnet", - 311
"anthropic", - 312
Some(2.0), - 313
"s1", - 314
)) - 315
.unwrap(); - 316
ledger - 317
.append(&row( - 318
now - chrono::Duration::hours(2), - 319
"claude-sonnet", - 320
"anthropic", - 321
None, - 322
"s1", - 323
)) - 324
.unwrap(); - 325
ledger - 326
.append(&row( - 327
now - chrono::Duration::hours(3), - 328
"gpt-x", - 329
"openai", - 330
Some(0.5), - 331
"s2", - 332
)) - 333
.unwrap(); - 334
// Legacy row without provider attribution. - 335
ledger - 336
.append(&row( - 337
now - chrono::Duration::hours(4), - 338
"old-m", - 339
"", - 340
Some(0.25), - 341
"s3", - 342
)) - 343
.unwrap(); - 344
// Outside the window: ignored entirely. - 345
ledger - 346
.append(&row( - 347
now - chrono::Duration::hours(24 * 9), - 348
"ancient", - 349
"anthropic", - 350
Some(99.0), - 351
"s0", - 352
)) - 353
.unwrap(); - 354
- 355
let r = digest(dir.path(), dir.path(), 7); - 356
assert!((r.total_usd - 2.75).abs() < 1e-9, "{}", r.total_usd); - 357
assert_eq!(r.unpriced_rows, 1); - 358
assert_eq!(r.dispatches, 4); - 359
assert_eq!(r.input_tokens, 400); - 360
assert_eq!(r.output_tokens, 200); - 361
assert_eq!( - 362
r.distinct_sessions, - 363
vec!["s1".to_string(), "s2".to_string(), "s3".to_string()] - 364
); - 365
- 366
let sonnet = &r.by_model["claude-sonnet"]; - 367
assert_eq!(sonnet.rows, 2); - 368
assert!((sonnet.usd - 2.0).abs() < 1e-9); - 369
assert_eq!(sonnet.input_tokens, 200); - 370
let openai = &r.by_provider["openai"]; - 371
assert!((openai.usd - 0.5).abs() < 1e-9); - 372
assert_eq!(r.by_provider["(unattributed)"].rows, 1); - 373
- 374
// Per-day buckets cover every in-window row exactly once. - 375
let day_sum: f64 = r.per_day.iter().map(|d| d.usd).sum(); - 376
let day_unpriced: u64 = r.per_day.iter().map(|d| d.unpriced_rows).sum(); - 377
assert!((day_sum - r.total_usd).abs() < 1e-9); - 378
assert_eq!(day_unpriced, r.unpriced_rows); - 379
assert!(r.per_day.windows(2).all(|w| w[0].day < w[1].day)); - 380
} - 381
- 382
#[test] - 383
fn memory_notes_and_profile_counted_from_provenance_ts() { - 384
let dir = tempfile::tempdir().unwrap(); - 385
let home = dir.path(); - 386
let cwd = home.join("ws"); - 387
std::fs::create_dir_all(&cwd).unwrap(); - 388
- 389
memory::append_note(home, &cwd, "fact", "fresh", "s", "brand new note").unwrap(); - 390
- 391
let old_header = - 392
"## 2026-01-01T00:00:00+00:00 [fact] tag=stale session=old\nancient note\n"; - 393
let mem_dir = home.join("memory").join(memory::hash_cwd(&cwd)); - 394
std::fs::create_dir_all(&mem_dir).unwrap(); - 395
let fresh = std::fs::read_to_string(mem_dir.join("MEMORY.md")).unwrap(); - 396
std::fs::write(mem_dir.join("MEMORY.md"), format!("{fresh}{old_header}")).unwrap(); - 397
- 398
memory::append_profile_note(home, "preference", "editor", "vim bindings", "su").unwrap(); - 399
- 400
let r = digest(home, home, 7); - 401
assert_eq!( - 402
r.memory_notes_appended, 2, - 403
"fresh workspace + profile notes" - 404
); - 405
} - 406
- 407
#[test] - 408
fn skill_proposals_counted_within_window() { - 409
let dir = tempfile::tempdir().unwrap(); - 410
let home = dir.path(); - 411
let proj = home.join("skill-proposals").join("abc"); - 412
std::fs::create_dir_all(&proj).unwrap(); - 413
- 414
let now = chrono::Utc::now().to_rfc3339(); - 415
std::fs::write( - 416
proj.join("fresh.md"), - 417
format!("---\nname: \"a\"\ndescription: \"d\"\n---\n\nbody\n\n<!-- proposed-by: s1 at {now}; proposal id x -->\n"), - 418
) - 419
.unwrap(); - 420
std::fs::write( - 421
proj.join("old.md"), - 422
"---\nname: \"b\"\ndescription: \"d\"\n---\n\nbody\n\n<!-- proposed-by: s2 at 2026-01-01T00:00:00+00:00; proposal id y -->\n", - 423
) - 424
.unwrap(); - 425
// No parseable comment → falls back to mtime (now) → counts. - 426
std::fs::write(proj.join("mtime-only.md"), "no comment here").unwrap(); - 427
- 428
let r = digest(home, home, 7); - 429
assert_eq!(r.skill_proposals_opened, 2); - 430
} - 431
} - 432
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.