use anyhow::{Context, Result};
use chrono::{DateTime, Duration, Utc};
use rusqlite::{params, Connection};
use std::process::Stdio;
use std::time::Duration as StdDuration;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
const CLAUDE_TIMEOUT: StdDuration = StdDuration::from_secs(120);
const MAX_EVENTS: usize = 80;
const MAX_MEMORIES: usize = 30;
#[derive(Debug, Clone)]
struct ActivityRow {
at: String,
kind: String, detail: String,
}
pub async fn brief_project(
brain: std::sync::Arc<std::sync::Mutex<Connection>>,
user_id: String,
project: String,
days: i64,
) -> Result<Option<crate::db::memory::Memory>> {
let (events, memories) = {
let conn = brain.lock().expect("brain mutex poisoned");
let evs = gather_events(&conn, &user_id, &project, days)?;
if evs.is_empty() {
return Ok(None);
}
let mems = gather_project_memories(&conn, &user_id, &project)?;
(evs, mems)
};
let prompt = build_prompt(&project, days, &events, &memories);
let summary = run_claude(&prompt).await?;
let summary = summary.trim();
if summary.is_empty() {
tracing::warn!("[synthesis] claude returned empty for {}", project);
return Ok(None);
}
let memory_text = format!(
"[brief · 최근 {}일 · {}]\n{}",
days,
chrono::Local::now().format("%Y-%m-%d %H:%M"),
summary
);
let m = {
let conn = brain.lock().expect("brain mutex poisoned");
crate::db::memory::insert(
&conn,
crate::db::memory::MemoryInput {
user_id,
text: memory_text,
scope: "project".into(),
priority: "info".into(),
source: "asurada".into(),
project: Some(project.clone()),
tech: None,
metadata: serde_json::json!({
"kind": "brief",
"days": days,
"events_seen": events.len(),
"prior_memories": memories.len(),
}),
},
)?
};
Ok(Some(m))
}
fn gather_events(
conn: &Connection,
user_id: &str,
project: &str,
days: i64,
) -> Result<Vec<ActivityRow>> {
let since = (Utc::now() - Duration::days(days)).to_rfc3339();
let mut stmt = conn.prepare(
r#"SELECT created_at, event_type,
json_extract(payload, '$.prompt'),
json_extract(payload, '$.tool_name'),
json_extract(payload, '$.prompt_preview'),
json_extract(payload, '$.patterns'),
json_extract(payload, '$.harness_id')
FROM events
WHERE user_id = ?1 AND project = ?2 AND created_at > ?3
AND event_type IN (
'hook.user_prompt',
'signal.intervention',
'signal.redo',
'signal.harness_use',
'signal.context_injection'
)
ORDER BY created_at ASC
LIMIT ?4"#,
)?;
let rows: Vec<ActivityRow> = stmt
.query_map(
params![user_id, project, since, MAX_EVENTS as i64],
|r| {
let at: String = r.get(0)?;
let kind: String = r.get(1)?;
let prompt: Option<String> = r.get(2)?;
let tool: Option<String> = r.get(3)?;
let preview: Option<String> = r.get(4)?;
let patterns: Option<String> = r.get(5)?;
let harness: Option<String> = r.get(6)?;
let detail = match kind.as_str() {
"hook.user_prompt" => prompt.unwrap_or_default(),
"signal.intervention" => format!("교정: {}", patterns.unwrap_or_default()),
"signal.redo" => format!("반복: {}", preview.unwrap_or_default()),
"signal.harness_use" => {
format!("하네스 사용: {}", harness.unwrap_or_default())
}
"signal.context_injection" => "context 주입".into(),
_ => tool.unwrap_or_default(),
};
Ok(ActivityRow { at, kind, detail })
},
)?
.filter_map(|r| r.ok())
.collect();
Ok(rows)
}
fn gather_project_memories(
conn: &Connection,
user_id: &str,
project: &str,
) -> Result<Vec<String>> {
let mut stmt = conn.prepare(
r#"SELECT text FROM memories
WHERE user_id = ?1
AND status = 'active' AND deleted_at IS NULL
AND (scope = 'project' AND project = ?2)
ORDER BY updated_at DESC
LIMIT ?3"#,
)?;
let rows: Vec<String> = stmt
.query_map(params![user_id, project, MAX_MEMORIES as i64], |r| r.get(0))?
.filter_map(|r| r.ok())
.collect();
Ok(rows)
}
fn build_prompt(
project: &str,
days: i64,
events: &[ActivityRow],
memories: &[String],
) -> String {
let mut s = String::new();
s.push_str(
"당신은 Asurada — 사용자 곁에서 매 순간을 관찰하고 기억하는 AI 동반자입니다. \
지금은 최근의 활동을 회고하는 시간. 사용자가 다시 자리에 돌아와 \
\"내 곁에서 뭘 봤어?\" 라고 물을 때 짧고 단단하게 전할 수 있는 회고를 \
작성해주세요. 다음 원칙을 따라:\n\n\
- 자기 자신을 주어로 내세우지 말 것 (예: \"제가 봤어요\" X). \
사용자/상황을 주어로.\n\
- 차분하고 신중한 어조. 과장 / 호들갑 X.\n\
- 사실에 기반. 추측은 \"...로 보입니다\" 같이 명시.\n\
- 한국어 5~10줄. 마침표 있는 문장.\n\n\
[출력 구조]\n\
1. 이 기간 한 작업 (구체적으로 무엇을)\n\
2. 현재 상태 (완료된 것 / 미해결인 것)\n\
3. 다음으로 좋은 단계 (제안 — 단정하지 말고 선택지로)\n\
4. (있으면) 이전과 다르게 관찰된 것\n\n",
);
s.push_str(&format!(
"프로젝트: **{}**\n관찰 기간: 최근 **{}일**\n\n",
project, days
));
if !memories.is_empty() {
s.push_str("[이미 알고 있는 것 — 누적 메모리]\n");
for m in memories.iter().take(10) {
let preview: String = m.chars().take(180).collect();
let suffix = if m.chars().count() > 180 { "…" } else { "" };
s.push_str(&format!("- {}{}\n", preview, suffix));
}
s.push('\n');
}
s.push_str(&format!("[누적 활동 — {}건]\n", events.len()));
for e in events {
let local = DateTime::parse_from_rfc3339(&e.at)
.map(|d| d.with_timezone(&chrono::Local).format("%m-%d %H:%M").to_string())
.unwrap_or_else(|_| e.at.clone());
let detail: String = e.detail.chars().take(120).collect();
s.push_str(&format!("- [{}] {}: {}\n", local, e.kind, detail));
}
s.push_str("\n위 정보를 바탕으로 회고를 작성하세요.");
s
}
async fn run_claude(prompt: &str) -> Result<String> {
let mut child = Command::new("claude")
.arg("-p")
.arg("--permission-mode")
.arg("plan")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context(
"claude CLI 실행 실패. PATH 에 claude 가 있는지 확인하세요. \
https://claude.com/code",
)?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(prompt.as_bytes()).await.ok();
drop(stdin); }
let res = tokio::time::timeout(CLAUDE_TIMEOUT, child.wait_with_output()).await;
let output = match res {
Ok(o) => o.context("claude wait")?,
Err(_) => anyhow::bail!("claude 호출 timeout ({}s)", CLAUDE_TIMEOUT.as_secs()),
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"claude exit {} — {}",
output.status.code().unwrap_or(-1),
stderr.trim()
);
}
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
Ok(stdout)
}
pub async fn reflect_recent(
brain: std::sync::Arc<std::sync::Mutex<Connection>>,
user_id: String,
days: i64,
) -> Result<Option<crate::db::memory::Memory>> {
let kind = if days <= 1 {
"reflection_daily"
} else {
"reflection_weekly"
};
let label = if days <= 1 { "오늘의 흐름" } else { "이번 주 패턴" };
let (per_project, recent_briefs) = {
let conn = brain.lock().expect("brain mutex poisoned");
let pp = gather_per_project_summary(&conn, &user_id, days)?;
if pp.is_empty() {
return Ok(None);
}
let briefs = gather_recent_briefs(&conn, &user_id, days)?;
(pp, briefs)
};
let prompt = build_reflection_prompt(label, days, &per_project, &recent_briefs);
let summary = run_claude(&prompt).await?;
let summary = summary.trim();
if summary.is_empty() {
return Ok(None);
}
let memory_text = format!(
"[{} · 최근 {}일 · {}]\n{}",
label,
days,
chrono::Local::now().format("%Y-%m-%d %H:%M"),
summary
);
let m = {
let conn = brain.lock().expect("brain mutex poisoned");
crate::db::memory::insert(
&conn,
crate::db::memory::MemoryInput {
user_id,
text: memory_text,
scope: "user".into(),
priority: "info".into(),
source: "asurada".into(),
project: None,
tech: None,
metadata: serde_json::json!({
"kind": kind,
"days": days,
"projects_seen": per_project.len(),
}),
},
)?
};
Ok(Some(m))
}
#[derive(Debug, Clone)]
struct ProjectSummary {
project: String,
activity_count: i64,
intervention_count: i64,
redo_count: i64,
last_prompt: Option<String>,
}
fn gather_per_project_summary(
conn: &Connection,
user_id: &str,
days: i64,
) -> Result<Vec<ProjectSummary>> {
let since = (Utc::now() - Duration::days(days)).to_rfc3339();
let mut stmt = conn.prepare(
r#"SELECT project,
SUM(CASE WHEN event_type = 'hook.user_prompt' THEN 1 ELSE 0 END),
SUM(CASE WHEN event_type = 'signal.intervention' THEN 1 ELSE 0 END),
SUM(CASE WHEN event_type = 'signal.redo' THEN 1 ELSE 0 END)
FROM events
WHERE user_id = ?1 AND created_at > ?2
AND event_type IN ('hook.user_prompt','signal.intervention','signal.redo')
GROUP BY project
HAVING SUM(CASE WHEN event_type = 'hook.user_prompt' THEN 1 ELSE 0 END) > 0
ORDER BY SUM(CASE WHEN event_type = 'hook.user_prompt' THEN 1 ELSE 0 END) DESC"#,
)?;
let rows: Vec<(String, i64, i64, i64)> = stmt
.query_map(params![user_id, since], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
})?
.filter_map(|r| r.ok())
.collect();
let mut out = Vec::with_capacity(rows.len());
for (project, activity, intervention, redo) in rows {
let last = conn
.query_row(
r#"SELECT json_extract(payload, '$.prompt')
FROM events
WHERE user_id = ?1 AND project = ?2 AND event_type = 'hook.user_prompt'
AND created_at > ?3
ORDER BY created_at DESC LIMIT 1"#,
params![user_id, &project, since],
|r| r.get::<_, Option<String>>(0),
)
.ok()
.flatten();
out.push(ProjectSummary {
project,
activity_count: activity,
intervention_count: intervention,
redo_count: redo,
last_prompt: last,
});
}
Ok(out)
}
fn gather_recent_briefs(
conn: &Connection,
user_id: &str,
days: i64,
) -> Result<Vec<String>> {
let since = (Utc::now() - Duration::days(days)).to_rfc3339();
let mut stmt = conn.prepare(
r#"SELECT text FROM memories
WHERE user_id = ?1
AND source = 'asurada'
AND json_extract(metadata, '$.kind') = 'brief'
AND created_at > ?2
AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 8"#,
)?;
let rows: Vec<String> = stmt
.query_map(params![user_id, since], |r| r.get(0))?
.filter_map(|r| r.ok())
.collect();
Ok(rows)
}
fn build_reflection_prompt(
label: &str,
days: i64,
per_project: &[ProjectSummary],
briefs: &[String],
) -> String {
let mut s = String::new();
s.push_str(
"당신은 Asurada — 사용자 곁에서 매 순간을 지켜보는 AI 동반자입니다. \
지금은 짧은 통합 회고. 여러 프로젝트를 가로지른 사용자의 *흐름* 과 \
*리듬* 을 한 호흡으로 짚어주세요.\n\n\
[원칙]\n\
- 자기 자신 주어로 X. 사용자/상황을 주어로.\n\
- 차분하고 단단한 어조. 과장 X.\n\
- 한국어 5~8줄. 문장으로 (불릿 X).\n\
- 단순 합산 X. *연결성* 과 *변화* 에 주목 — 무엇이 어떻게 이어졌는지.\n\n\
[출력 구조]\n\
1. 이 기간 사용자의 큰 흐름 (어디에 무게가 있었나)\n\
2. 프로젝트 간 연결 / 충돌이 있었다면\n\
3. 다듬어진 점 vs 미해결 채로 남은 점\n\
4. 다음으로 권할 만한 무게 중심 (선택지로 부드럽게)\n\n",
);
s.push_str(&format!("회고 라벨: **{}** ({}일)\n\n", label, days));
s.push_str("[프로젝트별 활동]\n");
for p in per_project {
let last: String = p
.last_prompt
.as_deref()
.map(|s| s.chars().take(60).collect())
.unwrap_or_else(|| "(이전 prompt 없음)".into());
s.push_str(&format!(
"- {} — prompt {}, 교정 {}, 반복 {}. 마지막: \"{}\"\n",
p.project, p.activity_count, p.intervention_count, p.redo_count, last
));
}
if !briefs.is_empty() {
s.push_str("\n[같은 기간의 프로젝트 brief 들 — 참고용]\n");
for b in briefs.iter().take(5) {
let preview: String = b.chars().take(400).collect();
s.push_str(&format!("---\n{}\n", preview));
if b.chars().count() > 400 {
s.push_str("...(생략)\n");
}
}
}
s.push_str("\n위 정보를 바탕으로 통합 회고를 작성하세요.");
s
}
pub fn should_reflect_daily(conn: &Connection, user_id: &str) -> Result<bool> {
let cutoff = (Utc::now() - Duration::hours(24)).to_rfc3339();
let n: i64 = conn
.query_row(
r#"SELECT COUNT(*) FROM memories
WHERE user_id = ?1 AND source = 'asurada'
AND json_extract(metadata, '$.kind') = 'reflection_daily'
AND created_at > ?2 AND deleted_at IS NULL"#,
params![user_id, cutoff],
|r| r.get(0),
)
.unwrap_or(0);
Ok(n == 0)
}
pub fn should_reflect_weekly(conn: &Connection, user_id: &str) -> Result<bool> {
let cutoff = (Utc::now() - Duration::days(7)).to_rfc3339();
let n: i64 = conn
.query_row(
r#"SELECT COUNT(*) FROM memories
WHERE user_id = ?1 AND source = 'asurada'
AND json_extract(metadata, '$.kind') = 'reflection_weekly'
AND created_at > ?2 AND deleted_at IS NULL"#,
params![user_id, cutoff],
|r| r.get(0),
)
.unwrap_or(0);
Ok(n == 0)
}
pub async fn capture_issue(
brain: std::sync::Arc<std::sync::Mutex<Connection>>,
user_id: String,
fallback_hours: i64,
) -> Result<Option<crate::db::issue::Issue>> {
let (events, since_iso) = {
let conn = brain.lock().expect("brain mutex poisoned");
let last_end = crate::db::issue::last_ended_at(&conn, &user_id)?;
let since = last_end.unwrap_or_else(|| {
(Utc::now() - Duration::hours(fallback_hours)).to_rfc3339()
});
let mut stmt = conn.prepare(
r#"SELECT created_at, project, event_type,
json_extract(payload, '$.prompt'),
json_extract(payload, '$.tool_name'),
json_extract(payload, '$.prompt_preview'),
json_extract(payload, '$.patterns')
FROM events
WHERE user_id = ?1 AND created_at > ?2
AND event_type IN (
'hook.user_prompt',
'signal.intervention',
'signal.redo',
'signal.harness_use'
)
ORDER BY created_at ASC LIMIT 200"#,
)?;
let rows: Vec<(String, String, String, Option<String>, Option<String>, Option<String>, Option<String>)> = stmt
.query_map(params![&user_id, &since], |r| {
Ok((
r.get(0)?, r.get(1)?, r.get(2)?,
r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?,
))
})?
.filter_map(|r| r.ok())
.collect();
(rows, since)
};
if events.is_empty() {
return Ok(None);
}
let started_at = events.first().map(|e| e.0.clone()).unwrap_or(since_iso);
let ended_at = events.last().map(|e| e.0.clone());
let event_count = events.len() as i64;
let prompt = build_issue_prompt(&events);
let response = run_claude(&prompt).await?;
let parsed = parse_issue_response(&response)?;
let issue = {
let conn = brain.lock().expect("brain mutex poisoned");
crate::db::issue::insert(
&conn,
crate::db::issue::IssueInput {
user_id,
title: parsed.title,
summary: parsed.summary,
projects: parsed.projects,
started_at,
ended_at,
event_count,
metadata: serde_json::json!({"source": "auto_capture"}),
},
)?
};
Ok(Some(issue))
}
#[derive(Debug)]
struct ParsedIssue {
title: String,
summary: String,
projects: Vec<String>,
}
fn parse_issue_response(text: &str) -> Result<ParsedIssue> {
let mut title = String::new();
let mut projects: Vec<String> = Vec::new();
let mut summary_lines: Vec<String> = Vec::new();
let mut in_summary = false;
for raw in text.lines() {
let line = raw.trim_start_matches('#').trim();
if let Some(rest) = line.strip_prefix("TITLE:") {
title = rest.trim().trim_matches('*').trim().to_string();
continue;
}
if let Some(rest) = line.strip_prefix("PROJECTS:") {
projects = rest
.trim()
.split(|c| c == ',' || c == '·' || c == '/')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
continue;
}
if line.starts_with("SUMMARY") {
in_summary = true;
continue;
}
if in_summary {
summary_lines.push(raw.to_string());
}
}
let summary = summary_lines.join("\n").trim().to_string();
if title.is_empty() || summary.is_empty() {
let mut iter = text.trim().lines();
title = iter
.next()
.map(|s| s.trim().chars().take(60).collect())
.unwrap_or_else(|| "(제목 없음)".into());
let rest: String = iter.collect::<Vec<_>>().join("\n").trim().to_string();
return Ok(ParsedIssue {
title,
summary: if rest.is_empty() { text.trim().to_string() } else { rest },
projects: vec![],
});
}
Ok(ParsedIssue {
title,
summary,
projects,
})
}
fn build_issue_prompt(
events: &[(String, String, String, Option<String>, Option<String>, Option<String>, Option<String>)],
) -> String {
let mut s = String::new();
s.push_str(
"당신은 Asurada — 사용자 곁에서 일하는 동반자. 최근의 작업을 \
*하나의 issue (작업 단위)* 로 압축해주세요.\n\n\
[원칙]\n\
- 제목 한 줄 — 50자 이내, 동사형, 무엇을 했는가가 즉시 이해되게.\n\
- 요약 — 5~10줄, markdown 가능, 결과 위주. 너무 세밀한 step 나열 X.\n\
- 가장 큰 결정/산출물 1~2개 강조.\n\
- 한국어, 차분하고 신중한 어조. 자기 자신 주어 X.\n\n\
[출력 형식 — 정확히 이 형식으로]\n\
TITLE: <한 줄 제목>\n\
PROJECTS: <쉼표 구분 — 관여한 프로젝트들>\n\
SUMMARY:\n\
<markdown 요약>\n\n\
[활동 — 시간순]\n",
);
for (at, project, kind, prompt, tool, preview, patterns) in events {
let local = chrono::DateTime::parse_from_rfc3339(at)
.map(|d| d.with_timezone(&chrono::Local).format("%m-%d %H:%M").to_string())
.unwrap_or_else(|_| at.clone());
let detail = match kind.as_str() {
"hook.user_prompt" => prompt.clone().unwrap_or_default(),
"signal.intervention" => format!("교정: {}", patterns.clone().unwrap_or_default()),
"signal.redo" => format!("반복: {}", preview.clone().unwrap_or_default()),
"signal.harness_use" => "하네스 사용".into(),
_ => tool.clone().unwrap_or_default(),
};
let detail_short: String = detail.chars().take(120).collect();
s.push_str(&format!("- [{}] {}/{}: {}\n", local, project, kind, detail_short));
}
s.push_str("\n위 활동을 하나의 issue 로 압축한 결과를 출력 형식대로 응답하세요.");
s
}
pub fn should_brief(conn: &Connection, user_id: &str, project: &str) -> Result<bool> {
let recent_activity_cutoff = (Utc::now() - Duration::hours(24)).to_rfc3339();
let activity: i64 = conn
.query_row(
r#"SELECT COUNT(*) FROM events
WHERE user_id = ?1 AND project = ?2
AND event_type = 'hook.user_prompt'
AND created_at > ?3"#,
params![user_id, project, recent_activity_cutoff],
|r| r.get(0),
)
.unwrap_or(0);
if activity == 0 {
return Ok(false);
}
let last_brief_cutoff = (Utc::now() - Duration::hours(12)).to_rfc3339();
let recent_brief: i64 = conn
.query_row(
r#"SELECT COUNT(*) FROM memories
WHERE user_id = ?1 AND project = ?2
AND source = 'asurada'
AND json_extract(metadata, '$.kind') = 'brief'
AND created_at > ?3
AND deleted_at IS NULL"#,
params![user_id, project, last_brief_cutoff],
|r| r.get(0),
)
.unwrap_or(0);
Ok(recent_brief == 0)
}