asurada 0.3.0

Asurada — a memory + cognition daemon that grows with the user. Local-first, BYOK, shared by Devist/Webchemist Core/etc.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! ClaudeCodeAdapter — Asurada 의 intent + patterns 를 Claude Code 형식으로 컴파일.
//!
//! 라우팅 전략 (CLAUDE.md 비대화 방지):
//!   짧은 preference (≤ 120자, 1줄) → CLAUDE.md preferences 블록
//!   긴 preference            → ~/.claude/skills/asurada-preferences/SKILL.md
//!   principle                → ~/.asurada/gates.json (능동 게이트) +
//!                              CLAUDE.md principles 블록 (1줄 transparency)
//!   짧은 context             → CLAUDE.md context 블록
//!   긴 context               → ~/.claude/skills/asurada-context/SKILL.md
//!   patterns (active)        → CLAUDE.md patterns 블록 (사용량 상위 N개 1줄씩)
//!
//! 각 블록은 ASURADA_BLOCK_MAX_LINES 를 넘으면 초과분을 자동으로 skill 파일로 demote.

use anyhow::{Context, Result};
use std::path::PathBuf;

use super::limits::*;
use super::*;
use crate::db::intent::{Intent, Strength};
use crate::db::pattern::Pattern;

pub struct ClaudeCodeAdapter {
    pub global_claude_md: PathBuf,
    pub gates_path: PathBuf,
    pub global_skills_dir: PathBuf,
}

impl ClaudeCodeAdapter {
    pub fn default() -> Result<Self> {
        let home = dirs::home_dir().context("home directory not found")?;
        Ok(Self {
            global_claude_md: home.join(".claude/CLAUDE.md"),
            gates_path: home.join(".asurada/gates.json"),
            global_skills_dir: home.join(".claude/skills"),
        })
    }
}

#[derive(Debug)]
enum Route {
    /// CLAUDE.md 의 짧은 한 줄.
    ClaudeMdLine {
        block_id: &'static str,
        line: String,
    },
    /// ~/.claude/skills/<slug>/SKILL.md 의 일부 (배치 단위로 합쳐짐).
    SkillBullet {
        slug: &'static str,
        title: &'static str,
        description: &'static str,
        bullet: String,
    },
    /// gates.json 으로 — hook 이 PreToolUse 시점에 평가.
    Gate(GateRule),
}

fn route_intent(intent: &Intent) -> Vec<Route> {
    let line_text = intent.intent_text.trim();
    let scope_suffix = intent
        .project
        .as_deref()
        .map(|p| format!(" *(scope: {})*", p))
        .unwrap_or_default();
    let one_liner = format!("- {}{}", line_text, scope_suffix);
    let is_short =
        line_text.chars().count() <= SHORT_INTENT_CHAR_LIMIT && !line_text.contains('\n');

    match intent.strength {
        Strength::Preference if is_short => vec![Route::ClaudeMdLine {
            block_id: "preferences",
            line: one_liner,
        }],
        Strength::Preference => vec![Route::SkillBullet {
            slug: "asurada-preferences",
            title: "사용자 선호 (Asurada)",
            description: "사용자가 Asurada 에 등록한 작업 선호. 코드 작성/응답 스타일/도구 사용 \
                         규약을 다룰 때 이 skill 의 항목들을 적극 반영하라.",
            bullet: format!("- {}{}", line_text, scope_suffix),
        }],
        Strength::Principle => {
            let mut out = vec![];
            if let Some(rule) = principle_to_gate_rule(intent) {
                out.push(Route::Gate(rule));
            }
            // 사용자 투명성 — CLAUDE.md 에 1줄. 게이트가 차단 사유를 *왜* 적용하는지 노출.
            out.push(Route::ClaudeMdLine {
                block_id: "principles",
                line: format!("- {} *(자동 차단/확인 적용)*", line_text),
            });
            out
        }
        Strength::Context if is_short => vec![Route::ClaudeMdLine {
            block_id: "context",
            line: one_liner,
        }],
        Strength::Context => vec![Route::SkillBullet {
            slug: "asurada-context",
            title: "프로젝트 맥락 (Asurada)",
            description: "Asurada 가 누적한 프로젝트별 맥락. 사용자 작업이 이 항목들과 \
                         관련될 때 이 skill 을 사용해 기존 결정/제약을 회상하라.",
            bullet: format!("- {}{}", line_text, scope_suffix),
        }],
    }
}

impl RuntimeAdapter for ClaudeCodeAdapter {
    fn name(&self) -> &'static str {
        "claude-code"
    }

    fn compile(&self, intents: &[Intent], patterns: &[Pattern]) -> Result<CompiledArtifacts> {
        let mut artifacts = CompiledArtifacts::default();

        // 1. 라우팅
        let mut claude_md_lines: std::collections::BTreeMap<&'static str, Vec<String>> =
            Default::default();
        let mut skill_buckets: std::collections::BTreeMap<&'static str, SkillBucket> =
            Default::default();

        for it in intents {
            for r in route_intent(it) {
                match r {
                    Route::ClaudeMdLine { block_id, line } => {
                        claude_md_lines.entry(block_id).or_default().push(line);
                    }
                    Route::SkillBullet {
                        slug,
                        title,
                        description,
                        bullet,
                    } => {
                        skill_buckets
                            .entry(slug)
                            .or_insert_with(|| SkillBucket {
                                slug,
                                title,
                                description,
                                bullets: vec![],
                            })
                            .bullets
                            .push(bullet);
                    }
                    Route::Gate(rule) => {
                        artifacts.gate_rules.push(rule);
                    }
                }
            }
        }

        // 2. CLAUDE.md 블록 — per-block cap 강제. 초과는 skill 로 demote.
        let block_titles: &[(&str, &str)] = &[
            ("preferences", "사용자 선호 (Asurada)"),
            ("context", "프로젝트 맥락 (Asurada)"),
            ("principles", "원칙 (Asurada — 자동 차단/확인 적용)"),
        ];

        for (block_id, title) in block_titles {
            let lines = claude_md_lines.remove(block_id).unwrap_or_default();
            if lines.is_empty() {
                continue;
            }

            let head = ASURADA_BLOCK_MAX_LINES.saturating_sub(3); // title 줄 + 빈줄 고려.
            let (kept, overflow): (Vec<_>, Vec<_>) =
                lines.into_iter().enumerate().partition(|(i, _)| *i < head);
            let kept: Vec<String> = kept.into_iter().map(|(_, s)| s).collect();
            let overflow: Vec<String> = overflow.into_iter().map(|(_, s)| s).collect();

            artifacts.text_blocks.push(TextBlock {
                id: (*block_id).to_string(),
                target: BlockTarget::GlobalClaudeMd,
                body: format!("## {}\n\n{}\n", title, kept.join("\n")),
            });

            // 초과분 → skill demote. block 별 demote slug 매핑.
            if !overflow.is_empty() {
                let (demote_slug, demote_title, demote_desc) = match *block_id {
                    "preferences" => (
                        "asurada-preferences",
                        "사용자 선호 (Asurada)",
                        "사용자가 등록한 선호 — CLAUDE.md 에서 demote 된 항목.",
                    ),
                    "context" => (
                        "asurada-context",
                        "프로젝트 맥락 (Asurada)",
                        "Asurada 가 누적한 맥락 — CLAUDE.md 에서 demote 된 항목.",
                    ),
                    _ => continue,
                };
                skill_buckets
                    .entry(demote_slug)
                    .or_insert_with(|| SkillBucket {
                        slug: demote_slug,
                        title: demote_title,
                        description: demote_desc,
                        bullets: vec![],
                    })
                    .bullets
                    .extend(overflow);
            }
        }

        // 3. 패턴 레지스트리 블록 — 사용량 상위 N개 1줄씩.
        let mut sorted = patterns
            .iter()
            .filter(|h| h.status == "active")
            .collect::<Vec<_>>();
        sorted.sort_by_key(|h| -h.usage_count);
        let top: Vec<&Pattern> = sorted
            .into_iter()
            .take(HARNESS_REGISTRY_MAX_ENTRIES)
            .collect();
        if !top.is_empty() {
            let mut body = String::from("## 등록된 패턴 (Asurada — 자동 생성)\n\n");
            body.push_str(
                "자세한 내용은 각 skill 파일 참조. 비슷한 작업 요청 시 자동 트리거됩니다.\n\n",
            );
            for h in &top {
                body.push_str(&format!(
                    "- **{}** *(used {}x)* — {}\n",
                    h.title, h.usage_count, h.description
                ));
            }
            artifacts.text_blocks.push(TextBlock {
                id: "patterns".into(),
                target: BlockTarget::GlobalClaudeMd,
                body,
            });
        }

        // 4. Skill 파일 생성 (~/.claude/skills/<slug>/SKILL.md).
        for (_, bucket) in skill_buckets {
            if bucket.bullets.is_empty() {
                continue;
            }
            let path = self.global_skills_dir.join(bucket.slug).join("SKILL.md");
            let body = render_aux_skill(&bucket);
            artifacts.files.push(GeneratedFile { path, body });
        }

        Ok(artifacts)
    }

    fn apply(&self, artifacts: &CompiledArtifacts) -> Result<ApplyReport> {
        let mut report = ApplyReport::default();

        // text blocks → CLAUDE.md
        for block in &artifacts.text_blocks {
            let target = match &block.target {
                BlockTarget::GlobalClaudeMd => self.global_claude_md.clone(),
                BlockTarget::ProjectClaudeMd(p) => p.clone(),
            };
            let changed = upsert_managed_block(&target, block)?;
            if changed {
                report.files_written.push(target);
                report.blocks_updated.push(block.id.clone());
            }
        }

        // 정리 — 라우팅 결과 빈 블록은 디스크에서도 제거.
        let present_ids: std::collections::HashSet<&str> = artifacts
            .text_blocks
            .iter()
            .map(|b| b.id.as_str())
            .collect();
        // "harnesses" 는 v0.2 이전 블록 id — 마이그레이션 위해 cleanup 목록에 유지.
        let all_known_ids: &[&str] = &[
            "preferences",
            "context",
            "principles",
            "patterns",
            "harnesses",
        ];
        for &id in all_known_ids {
            if !present_ids.contains(id) {
                let _ = remove_managed_block(&self.global_claude_md, id);
            }
        }

        // generated files
        for gf in &artifacts.files {
            if let Some(parent) = gf.path.parent() {
                std::fs::create_dir_all(parent)?;
            }
            let tmp = gf.path.with_extension(format!(
                "{}.asurada.tmp",
                gf.path.extension().and_then(|e| e.to_str()).unwrap_or("md")
            ));
            std::fs::write(&tmp, &gf.body)?;
            std::fs::rename(&tmp, &gf.path)?;
            report.files_written.push(gf.path.clone());
        }

        // gate rules → ~/.asurada/gates.json
        if let Some(parent) = self.gates_path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let body = serde_json::to_string_pretty(&artifacts.gate_rules)?;
        let tmp = self.gates_path.with_extension("json.asurada.tmp");
        std::fs::write(&tmp, body)?;
        std::fs::rename(&tmp, &self.gates_path)?;
        report.gate_rules_count = artifacts.gate_rules.len();
        report.files_written.push(self.gates_path.clone());

        Ok(report)
    }
}

struct SkillBucket {
    slug: &'static str,
    title: &'static str,
    description: &'static str,
    bullets: Vec<String>,
}

fn render_aux_skill(bucket: &SkillBucket) -> String {
    format!(
        "---\n\
         name: {slug}\n\
         description: {description}\n\
         asurada-managed: true\n\
         ---\n\
         \n\
         # {title}\n\
         \n\
         이 skill 은 Asurada 가 자동으로 관리합니다 (`asurada intent compile`).\n\
         사용자 직접 편집은 다음 컴파일 시 덮여 쓰일 수 있으니, 영구 변경은\n\
         `asurada intent add/archive` 로 진행하세요.\n\
         \n\
         ## 항목\n\
         \n\
         {bullets}\n",
        slug = bucket.slug,
        description = bucket.description,
        title = bucket.title,
        bullets = bucket.bullets.join("\n"),
    )
}

/// principle intent → GateRule. metadata 의 `trigger` 필드를 읽어 변환.
fn principle_to_gate_rule(intent: &Intent) -> Option<GateRule> {
    let trigger = intent.metadata.get("trigger")?;
    let tool = trigger
        .get("tool")
        .and_then(|v| v.as_str())
        .map(String::from);
    let contains = trigger
        .get("contains")
        .and_then(|v| v.as_str())
        .map(String::from);
    if tool.is_none() && contains.is_none() {
        return None;
    }
    let decision = intent
        .metadata
        .get("decision")
        .and_then(|v| v.as_str())
        .unwrap_or("ask")
        .to_string();
    Some(GateRule {
        intent_id: intent.id.clone(),
        tool,
        contains,
        decision,
        reason: intent.intent_text.clone(),
    })
}

/// PreToolUse hook 시점에 활성 게이트 규칙 평가.
pub fn load_gate_rules(path: &std::path::Path) -> Result<Vec<GateRule>> {
    if !path.exists() {
        return Ok(vec![]);
    }
    let body = std::fs::read_to_string(path)?;
    if body.trim().is_empty() {
        return Ok(vec![]);
    }
    Ok(serde_json::from_str(&body).unwrap_or_default())
}

pub fn match_gate(
    rules: &[GateRule],
    tool_name: Option<&str>,
    tool_input: &serde_json::Value,
) -> Option<GateRule> {
    let tool_input_str = tool_input.to_string();
    for rule in rules {
        let tool_ok = match (&rule.tool, tool_name) {
            (None, _) => true,
            (Some(want), Some(got)) => want == got,
            (Some(_), None) => false,
        };
        if !tool_ok {
            continue;
        }
        let contains_ok = match &rule.contains {
            None => true,
            Some(needle) => tool_input_str.contains(needle),
        };
        if !contains_ok {
            continue;
        }
        return Some(rule.clone());
    }
    None
}