use std::collections::HashMap;
use std::path::Path;
use anyhow::Result;
use chrono::{DateTime, Utc};
use crate::prompt_log::PromptLogEntry;
const DRAWER_SECTION_PREFIX: &str = "## Relevant memories";
const TAG_SUFFIX_OPEN: &str = " _(tags: ";
const TAG_SUFFIX_CLOSE: &str = ")_";
const DRAWER_INJECTION_KIND: &str = "prompt-context-facts";
const LOG_PREFIX: &str = "enriched-prompts.";
const LOG_SUFFIX: &str = ".jsonl";
const TRUNCATION_MARKER: char = '…';
const MIN_PARTIAL_CHARS: usize = 60;
#[derive(Debug, Clone, Default)]
pub struct ScanStats {
pub files_scanned: usize,
pub files_failed: usize,
pub entries_read: u64,
pub injections_counted: u64,
pub earliest: Option<DateTime<Utc>>,
pub latest: Option<DateTime<Utc>>,
}
type PreviewKey = (String, String);
#[derive(Debug, Default)]
pub struct InjectionIndex {
totals: HashMap<String, u64>,
exact: HashMap<PreviewKey, u64>,
partial: HashMap<PreviewKey, u64>,
pub stats: ScanStats,
}
impl InjectionIndex {
pub fn scan_dir(dir: &Path) -> Result<Self> {
let mut index = Self::default();
let Ok(entries) = std::fs::read_dir(dir) else {
return Ok(index);
};
let mut paths: Vec<_> = entries
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(LOG_PREFIX) && n.ends_with(LOG_SUFFIX))
})
.collect();
paths.sort();
for path in paths {
match std::fs::read_to_string(&path) {
Ok(body) => {
index.stats.files_scanned += 1;
index.ingest_file(&body);
}
Err(e) => {
index.stats.files_failed += 1;
tracing::warn!(path = %path.display(), "read prompt log failed: {e:#}");
}
}
}
Ok(index)
}
fn ingest_file(&mut self, body: &str) {
for line in body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(entry) = serde_json::from_str::<PromptLogEntry>(line) else {
continue;
};
self.stats.entries_read += 1;
if entry.injection_kind != DRAWER_INJECTION_KIND {
continue;
}
self.ingest_entry(&entry);
}
}
fn ingest_entry(&mut self, entry: &PromptLogEntry) {
self.stats.injections_counted += 1;
*self.totals.entry(entry.palace.clone()).or_default() += 1;
let ts = entry.timestamp;
self.stats.earliest = Some(self.stats.earliest.map_or(ts, |e| e.min(ts)));
self.stats.latest = Some(self.stats.latest.map_or(ts, |l| l.max(ts)));
let (exact, partial) = parse_drawer_bullets(&entry.injection);
for body in exact {
*self.exact.entry((entry.palace.clone(), body)).or_default() += 1;
}
for body in partial {
*self
.partial
.entry((entry.palace.clone(), body))
.or_default() += 1;
}
}
pub fn total_injections(&self, palace: &str) -> u64 {
self.totals.get(palace).copied().unwrap_or(0)
}
pub fn injections_for(&self, palace: &str, preview: &str) -> u64 {
let exact = self
.exact
.get(&(palace.to_string(), preview.to_string()))
.copied()
.unwrap_or(0);
let partial: u64 = self
.partial
.iter()
.filter(|((p, body), _)| {
if p != palace {
return false;
}
let stem = body.trim_end_matches(TRUNCATION_MARKER);
stem.chars().count() >= MIN_PARTIAL_CHARS && preview.starts_with(stem)
})
.map(|(_, n)| *n)
.sum();
exact + partial
}
pub fn saw_no_logs(&self) -> bool {
self.stats.files_scanned == 0
}
}
fn parse_drawer_bullets(injection: &str) -> (Vec<String>, Vec<String>) {
let mut exact: Vec<String> = Vec::new();
let mut partial: Vec<String> = Vec::new();
let mut in_drawer_section = false;
let cap_truncated = injection.ends_with(TRUNCATION_MARKER);
let last_line = injection.lines().next_back();
for line in injection.lines() {
if line.starts_with("## ") {
in_drawer_section = line.starts_with(DRAWER_SECTION_PREFIX);
continue;
}
if !in_drawer_section {
continue;
}
let Some(body) = line.strip_prefix("- ") else {
continue;
};
if let Some(preview) = split_tag_run(body) {
if !exact.iter().any(|e| e == preview) {
exact.push(preview.to_string());
}
continue;
}
let is_cap_tail = cap_truncated && last_line == Some(line);
if is_cap_tail {
partial.push(body.to_string());
} else if !exact.iter().any(|e| e == body) {
exact.push(body.to_string());
}
}
(exact, partial)
}
fn split_tag_run(body: &str) -> Option<&str> {
if !body.ends_with(TAG_SUFFIX_CLOSE) {
return None;
}
let at = body.rfind(TAG_SUFFIX_OPEN)?;
Some(&body[..at])
}