use super::{redacted_first_line, Adapter, Discovered};
use crate::model::{Message, Role, Session};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
pub struct Prodex;
fn registry_path() -> Option<PathBuf> {
if let Some(p) = std::env::var_os("SESSIONWIKI_PRODEX_REGISTRY") {
return Some(p.into());
}
Some(
dirs::home_dir()?
.join(".local")
.join("share")
.join("prodex")
.join("bridges.json"),
)
}
fn bridge_roots() -> (Vec<PathBuf>, bool) {
let Some(path) = registry_path() else {
return (Vec::new(), false);
};
match std::fs::metadata(&path) {
Ok(meta) if meta.is_file() => {}
Ok(_) => return (Vec::new(), true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return (Vec::new(), false);
}
Err(_) => return (Vec::new(), true),
}
let Ok(text) = crate::util::read_to_string_capped(&path) else {
return (Vec::new(), true);
};
let Ok(v) = serde_json::from_str::<Value>(&text) else {
return (Vec::new(), true);
};
let Some(values) = v.get("roots").and_then(Value::as_array) else {
return (Vec::new(), true);
};
let mut roots = Vec::with_capacity(values.len());
let mut had_error = false;
for value in values {
match value.as_str() {
Some(root) if !root.is_empty() => roots.push(PathBuf::from(root)),
_ => had_error = true,
}
}
(roots, had_error)
}
fn parse_ts(s: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(s)
.ok()
.map(|t| t.with_timezone(&Utc))
}
fn ts_from_id(id: &str) -> Option<DateTime<Utc>> {
let mut parts = id.split('_');
if parts.next() != Some("task") {
return None;
}
let (d, t) = (parts.next()?, parts.next()?);
if d.len() != 8
|| t.len() != 6
|| !d.bytes().all(|b| b.is_ascii_digit())
|| !t.bytes().all(|b| b.is_ascii_digit())
{
return None;
}
let iso = format!(
"{}-{}-{}T{}:{}:{}Z",
&d[0..4],
&d[4..6],
&d[6..8],
&t[0..2],
&t[2..4],
&t[4..6]
);
parse_ts(&iso)
}
impl Adapter for Prodex {
fn name(&self) -> &'static str {
"prodex"
}
fn root(&self) -> Option<PathBuf> {
registry_path()
.filter(|p| p.try_exists().unwrap_or(false))
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
}
fn discover(&self) -> Discovered {
let mut files = Vec::new();
let (roots, mut had_error) = bridge_roots();
for root in roots {
let tasks = root.join(".bridge").join("tasks");
match std::fs::read_dir(&tasks) {
Ok(rd) => {
for entry in rd {
match entry {
Ok(e) => {
let p = e.path();
if p.extension().is_some_and(|x| x == "json") {
files.push(p);
}
}
Err(_) => had_error = true,
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => had_error = true,
}
}
Discovered { files, had_error }
}
fn parse(&self, path: &Path) -> Result<Session> {
let task: Value = serde_json::from_str(&crate::util::read_to_string_capped(path)?)
.with_context(|| format!("parse {}", path.display()))?;
let task_id = task["id"].as_str().context("task has no id")?.to_string();
let id = {
let d = Sha256::digest(task_id.as_bytes());
let mut hex = String::with_capacity(12);
for b in &d[..6] {
hex.push_str(&format!("{b:02x}"));
}
hex
};
let prompt = task["prompt"].as_str().unwrap_or("").trim().to_string();
let title = {
let head = redacted_first_line(&prompt, 80);
if !head.is_empty() {
head
} else {
task["title"]
.as_str()
.map(str::trim)
.filter(|t| !t.is_empty())
.unwrap_or("(untitled task)")
.to_string()
}
};
let bridge = path.parent().and_then(|p| p.parent());
let repo = bridge.and_then(|b| b.parent());
let project = repo
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(unknown)".into());
let started = task["claimed_at"]
.as_str()
.and_then(parse_ts)
.or_else(|| ts_from_id(&task_id));
let mut messages = Vec::new();
if !prompt.is_empty() {
messages.push(Message {
role: Role::User,
text: prompt,
ts: started,
});
}
let mut ended = None;
if let Some(bridge) = bridge {
let artifact_text = crate::util::read_to_string_capped(
&bridge
.join("artifacts")
.join("pro-consults")
.join(format!("{task_id}.md")),
)
.ok()
.map(|t| {
let mut t = crate::redact::redact(t.trim()).into_owned();
const CAP: usize = 64 * 1024;
if t.len() > CAP {
let mut end = CAP;
while !t.is_char_boundary(end) {
end -= 1;
}
t.truncate(end);
}
t
})
.filter(|t| !t.is_empty());
let mut summary = None;
if let Ok(text) = crate::util::read_to_string_capped(
&bridge.join("results").join(format!("{task_id}.json")),
) {
if let Ok(result) = serde_json::from_str::<Value>(&text) {
ended = result["created_at"].as_str().and_then(parse_ts);
summary = result["summary"]
.as_str()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
}
}
if let Some(text) = artifact_text.or(summary) {
messages.push(Message {
role: Role::Assistant,
text,
ts: ended.or(started),
});
}
}
let touched: Vec<String> = task["files"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|f| {
f.as_str()
.map(str::to_string)
.or_else(|| f["path"].as_str().map(str::to_string))
})
.collect()
})
.unwrap_or_default();
Ok(Session {
id,
tool: "prodex",
path: path.to_path_buf(),
project,
started,
ended: ended.or(started),
title,
subagent: false,
messages,
touched,
edits: Vec::new(),
})
}
}
pub fn thread_url_for_task(task_path: &Path) -> Option<String> {
let sessions = task_path.parent()?.parent()?.join("sessions");
let mut candidates: Vec<(std::time::SystemTime, String)> = Vec::new();
for e in std::fs::read_dir(sessions).ok()?.flatten() {
let p = e.path();
if p.extension().is_none_or(|x| x != "json") {
continue;
}
let Ok(v) = serde_json::from_str::<Value>(&crate::util::read_to_string_capped(&p).ok()?)
else {
continue;
};
let Some(url) = v["thread"].as_str() else {
continue;
};
if !url.starts_with("https://chatgpt.com/") {
continue;
}
let mtime = e
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
candidates.push((mtime, url.to_string()));
}
candidates.sort_by_key(|(t, _)| *t);
candidates.pop().map(|(_, u)| u)
}