use anyhow::{anyhow, Context, Result};
use chrono::Local;
use serde_json::Value;
use std::time::Duration;
use crate::worker::claude::ClaudeCli;
use crate::worker::config::WorkerConfig;
use crate::worker::supabase::{MemoryRow, SupabaseClient};
const MAX_PROPOSALS: usize = 5;
const LOOKBACK_DAYS: i64 = 7;
const MAX_ADVICE_SAMPLE: usize = 60;
const MAX_MEMORY_CONTEXT: usize = 50;
pub fn run_task(cfg: &WorkerConfig) -> Result<String> {
let supabase = make_supabase(cfg)
.ok_or_else(|| anyhow!("Supabase not configured (supabase_url/supabase_key)"))?;
let claude = ClaudeCli::new(cfg.claude_bin.clone());
let advice_lines = fetch_recent_advice_with_outcomes(&supabase)?;
if advice_lines.is_empty() {
return Ok(format!(
"no resolved advice in the last {LOOKBACK_DAYS} days — nothing to reflect on"
));
}
let active_memories = supabase.list_all_memories().unwrap_or_default();
let proposed_memories = supabase.list_proposed_memories().unwrap_or_default();
let prompt = build_prompt(
&advice_lines,
&active_memories,
&proposed_memories,
&cfg.advice_locale,
);
let raw = claude
.ask_json(&prompt, Duration::from_secs(120))
.context("claude reflect call")?;
let proposals = raw
.get("proposals")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow!("no proposals[] in response"))?;
let mut inserted = 0usize;
let mut errors = 0usize;
for p in proposals.iter().take(MAX_PROPOSALS) {
let text = p.get("text").and_then(|x| x.as_str()).unwrap_or("").trim();
if text.is_empty() {
continue;
}
let scope = p.get("scope").and_then(|x| x.as_str()).unwrap_or("project");
let priority = p
.get("priority")
.and_then(|x| x.as_str())
.unwrap_or("strong");
let project_raw = p.get("project").and_then(|x| x.as_str());
let project_canonical = project_raw.map(|raw| {
cfg.project_aliases
.get(raw)
.map(|a| a.as_str())
.unwrap_or(raw)
});
let tech: Vec<String> = p
.get("tech")
.and_then(|x| x.as_array())
.map(|arr| {
arr.iter()
.filter_map(|t| t.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let reason = p
.get("reason")
.and_then(|x| x.as_str())
.unwrap_or("(no reason)");
match supabase.insert_memory_with_status(
text,
scope,
priority,
"claude",
project_canonical,
&tech,
"proposed",
Some(reason),
) {
Ok(id) => {
inserted += 1;
log_line(&format!("[reflect] proposed memory #{}: {}", id, reason));
}
Err(e) => {
errors += 1;
log_line(&format!("[reflect] insert err: {}", e));
}
}
}
Ok(format!(
"{} advice samples reviewed → {} proposals inserted (errors {})",
advice_lines.len(),
inserted,
errors,
))
}
fn make_supabase(cfg: &WorkerConfig) -> Option<SupabaseClient> {
let (url, key) = match (&cfg.supabase_url, &cfg.supabase_key) {
(Some(u), Some(k)) if !u.is_empty() && !k.is_empty() => (u.as_str(), k.as_str()),
_ => return None,
};
let client_id = cfg
.client_id
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("unknown");
SupabaseClient::new(url, key, client_id).ok()
}
fn fetch_recent_advice_with_outcomes(supabase: &SupabaseClient) -> Result<Vec<String>> {
let cutoff = chrono::Utc::now() - chrono::Duration::days(LOOKBACK_DAYS);
let url = format!(
"{}/rest/v1/advice_outcomes?created_at=gte.{}&outcome=neq.pending&select=id,project,severity,confirmed_by,outcome,created_at&order=created_at.desc&limit={}",
supabase.base_url(),
urlencoding::encode(&cutoff.to_rfc3339()),
MAX_ADVICE_SAMPLE,
);
let raw = supabase.get_raw(&url)?;
let arr: Vec<Value> = serde_json::from_str(&raw).context("parse advice_outcomes")?;
let ids: Vec<i64> = arr
.iter()
.filter_map(|v| v.get("id").and_then(|x| x.as_i64()))
.collect();
if ids.is_empty() {
return Ok(Vec::new());
}
let id_csv = ids
.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",");
let detail_url = format!(
"{}/rest/v1/worker_events?id=in.({})&select=id,payload",
supabase.base_url(),
id_csv
);
let detail_raw = supabase.get_raw(&detail_url)?;
let details: Vec<Value> = serde_json::from_str(&detail_raw).context("parse details")?;
let text_for_id: std::collections::HashMap<i64, String> = details
.iter()
.filter_map(|d| {
let id = d.get("id").and_then(|x| x.as_i64())?;
let txt = d
.get("payload")
.and_then(|p| p.get("text"))
.and_then(|t| t.as_str())
.unwrap_or("")
.chars()
.take(180)
.collect::<String>();
Some((id, txt))
})
.collect();
Ok(arr
.into_iter()
.filter_map(|v| {
let id = v.get("id").and_then(|x| x.as_i64())?;
let project = v.get("project").and_then(|x| x.as_str()).unwrap_or("-");
let severity = v.get("severity").and_then(|x| x.as_str()).unwrap_or("?");
let outcome = v.get("outcome").and_then(|x| x.as_str()).unwrap_or("?");
let confirmed_by = v.get("confirmed_by").and_then(|x| x.as_str()).unwrap_or("");
let txt = text_for_id.get(&id).cloned().unwrap_or_default();
Some(format!(
"#{id} [{severity}/{outcome}/by={confirmed_by}] {project}: {txt}"
))
})
.collect())
}
fn build_prompt(
advice_lines: &[String],
active: &[MemoryRow],
proposed: &[MemoryRow],
locale: &str,
) -> String {
let mut active_block = String::new();
for m in active.iter().take(MAX_MEMORY_CONTEXT) {
let proj = m.project.as_deref().unwrap_or("-");
active_block.push_str(&format!(
"- [{}/{}/{}] {}\n",
m.priority, m.scope, proj, m.text
));
}
if active_block.is_empty() {
active_block.push_str("(none)\n");
}
let mut proposed_block = String::new();
for m in proposed {
let proj = m.project.as_deref().unwrap_or("-");
proposed_block.push_str(&format!(
"- [{}/{}/{}] {}\n",
m.priority, m.scope, proj, m.text
));
}
if proposed_block.is_empty() {
proposed_block.push_str("(none)\n");
}
let advice_block = advice_lines.join("\n");
format!(
r#"You are the reflection pass for the Devist Reso memory system.
Your job: look at the last {days} days of advice + how it was resolved,
and propose NEW strong-priority memories that would make future advice
better. Most reflections produce zero or one proposals — the bar is high.
============================================================
LAST {days} DAYS — ADVICE OUTCOMES
============================================================
Each line: #id [severity/outcome/by=confirmed_by] project: text…
Outcomes mean:
win — user trusted the advice (verify auto-confirmed it as fixed,
or user clicked Apply with AI)
loss — advice was wrong (user said "intentional" or audit thread
confirmed it as noise)
neutral — user manually confirmed (no signal about advice quality)
ignored — sat unconfirmed for >7 days
{advice_block}
============================================================
CURRENT STRONG / CONSTRAINT MEMORIES (already active)
============================================================
{active_block}
============================================================
ALREADY-PROPOSED MEMORIES (still under user review — DO NOT re-propose)
============================================================
{proposed_block}
============================================================
TASK
============================================================
Find PATTERNS in the outcomes, not individual fixes. For example:
- "all 3 'add tests' suggest items were marked intentional" → propose a
user-scope memory: "Tests are added on demand, not opportunistically".
- "every advice mentioning React useEffect cleanup got Apply'd" → propose
a tech-scope memory: "useEffect callbacks must always return a cleanup
function when subscribing".
- "block-severity advice on auth.* paths gets handled within an hour;
suggest-severity ignored" → propose a project-scope memory: "auth
changes are high-priority; suggest-level on auth.* should be promoted
to warn".
DO NOT propose:
- Restatements of an existing active memory (would just duplicate).
- Anything already in the proposed list above.
- Vague observations ("user is sometimes inconsistent") — must be
actionable, falsifiable, and specific enough to inject into a future
advice prompt.
- Per-incident memories ("issue #42 should not be raised again") — the
audit thread handles those.
OUTPUT — STRICT JSON, no markdown fences:
{{
"proposals": [
{{
"text": "<the memory content, in {locale}>",
"scope": "project|tech|user",
"priority": "strong|constraint",
"project": "<canonical project name, only if scope=project>",
"tech": ["..."],
"reason": "<one sentence in {locale} citing which advice IDs led to this proposal>"
}}
]
}}
Empty `proposals: []` is the correct, expected answer most of the time.
Cap: at most {max_proposals} proposals per cycle.
"#,
days = LOOKBACK_DAYS,
advice_block = advice_block,
active_block = active_block,
proposed_block = proposed_block,
locale = locale,
max_proposals = MAX_PROPOSALS,
)
}
fn log_line(msg: &str) {
let now = Local::now().format("%Y-%m-%d %H:%M:%S");
println!("{} {}", now, msg);
}