opencrabs 0.3.12

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Install with: cargo install opencrabs
Documentation
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
407
use chrono::Local;
use crossterm::event::{KeyCode, KeyEvent};

use super::types::*;
use super::wizard::OnboardingWizard;

impl OnboardingWizard {
    pub(super) fn handle_brain_setup_key(&mut self, event: KeyEvent) -> WizardAction {
        // While generating: Esc or Enter cancel and skip to Complete.
        // All other keys are ignored so the user can't corrupt input mid-stream.
        if self.brain_generating {
            if matches!(event.code, KeyCode::Esc | KeyCode::Enter) {
                self.brain_generating = false;
                self.step = OnboardingStep::Complete;
                return WizardAction::Complete;
            }
            return WizardAction::None;
        }

        // If already generated or errored, Enter advances
        if self.brain_generated || self.brain_error.is_some() {
            if event.code == KeyCode::Enter {
                self.next_step();
                return WizardAction::Complete;
            }
            return WizardAction::None;
        }

        match event.code {
            KeyCode::Esc => {
                // Esc always skips
                self.step = OnboardingStep::Complete;
                return WizardAction::Complete;
            }
            KeyCode::Tab => {
                self.brain_field = match self.brain_field {
                    BrainField::AboutMe => BrainField::AboutAgent,
                    BrainField::AboutAgent => BrainField::AboutMe,
                };
            }
            KeyCode::BackTab => {
                self.brain_field = match self.brain_field {
                    BrainField::AboutMe => BrainField::AboutAgent,
                    BrainField::AboutAgent => BrainField::AboutMe,
                };
            }
            KeyCode::Enter => {
                if self.brain_field == BrainField::AboutAgent {
                    if self.about_me.is_empty() && self.about_opencrabs.is_empty() {
                        // Nothing to work with — skip straight to Complete
                        self.step = OnboardingStep::Complete;
                        return WizardAction::Complete;
                    }
                    // If inputs unchanged from loaded values, skip without regenerating
                    if !self.brain_inputs_changed() && !self.original_about_me.is_empty() {
                        self.step = OnboardingStep::Complete;
                        return WizardAction::Complete;
                    }
                    // Inputs changed or new — normalize into valid markdown and trigger
                    // generation. `normalize_brain_inputs` wraps raw prose in a minimal
                    // markdown skeleton so the model sees consistent structure even if
                    // the user pasted plain text. Preview is implicit: the formatted
                    // values live in `formatted_about_me`/`formatted_about_agent` and
                    // the brain render shows them alongside the raw input.
                    self.normalize_brain_inputs();
                    self.preview_shown = true;
                    return WizardAction::GenerateBrain;
                }
                // Enter on AboutMe moves to AboutAgent
                self.brain_field = BrainField::AboutAgent;
            }
            KeyCode::Char(c) => {
                self.mark_brain_field_edited();
                self.active_brain_field_mut().push(c);
            }
            KeyCode::Backspace => {
                if !self.is_brain_field_edited() && !self.active_brain_field().is_empty() {
                    // First destructive action on untouched template — wipe it
                    self.active_brain_field_mut().clear();
                    self.mark_brain_field_edited();
                } else {
                    self.active_brain_field_mut().pop();
                }
            }
            KeyCode::Delete
                if !self.is_brain_field_edited() && !self.active_brain_field().is_empty() =>
            {
                self.active_brain_field_mut().clear();
                self.mark_brain_field_edited();
            }
            KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down => {
                // Arrow navigation signals intent to edit — mark as touched
                self.mark_brain_field_edited();
            }
            _ => {}
        }
        WizardAction::None
    }

    /// Get reference to the currently focused brain text area
    fn active_brain_field(&self) -> &str {
        match self.brain_field {
            BrainField::AboutMe => &self.about_me,
            BrainField::AboutAgent => &self.about_opencrabs,
        }
    }

    /// Get mutable reference to the currently focused brain text area
    fn active_brain_field_mut(&mut self) -> &mut String {
        match self.brain_field {
            BrainField::AboutMe => &mut self.about_me,
            BrainField::AboutAgent => &mut self.about_opencrabs,
        }
    }

    /// Whether the current field has been explicitly edited (arrow/char input)
    fn is_brain_field_edited(&self) -> bool {
        match self.brain_field {
            BrainField::AboutMe => self.brain_me_edited,
            BrainField::AboutAgent => self.brain_agent_edited,
        }
    }

    /// Mark the current field as touched by user editing
    fn mark_brain_field_edited(&mut self) {
        match self.brain_field {
            BrainField::AboutMe => self.brain_me_edited = true,
            BrainField::AboutAgent => self.brain_agent_edited = true,
        }
    }

    /// Whether brain inputs have been modified since loading from file
    fn brain_inputs_changed(&self) -> bool {
        self.about_me != self.original_about_me
            || self.about_opencrabs != self.original_about_opencrabs
    }

    /// Truncate file content to first N chars for preview in the wizard
    pub fn truncate_preview(content: &str, max_chars: usize) -> String {
        let trimmed = content.trim();
        if trimmed.len() <= max_chars {
            trimmed.to_string()
        } else {
            let truncated = &trimmed[..trimmed.floor_char_boundary(max_chars)];
            format!("{}...", truncated.trim_end())
        }
    }

    /// Normalize the two free-form user inputs into valid markdown before
    /// feeding them to the AI. If the user pasted raw prose, wrap it in a
    /// minimal markdown skeleton so the model sees consistent structure.
    /// Called right before generation kicks off.
    pub fn normalize_brain_inputs(&mut self) {
        self.formatted_about_me = auto_format_markdown(&self.about_me, "About Me");
        self.formatted_about_agent = auto_format_markdown(&self.about_opencrabs, "About The Agent");
    }

    /// Build the prompt sent to the AI to generate personalized brain files.
    /// Uses existing workspace files if available, falls back to static templates.
    pub fn build_brain_prompt(&self) -> String {
        let today = Local::now().format("%Y-%m-%d").to_string();
        let workspace = std::path::Path::new(&self.workspace_path);

        // Read current brain files from workspace, fall back to static templates
        let soul_template_static = include_str!("../../docs/reference/templates/SOUL.md");
        let identity_template_static = include_str!("../../docs/reference/templates/IDENTITY.md");
        let user_template_static = include_str!("../../docs/reference/templates/USER.md");
        let agents_template_static = include_str!("../../docs/reference/templates/AGENTS.md");
        let tools_template_static = include_str!("../../docs/reference/templates/TOOLS.md");
        let memory_template_static = include_str!("../../docs/reference/templates/MEMORY.md");

        let soul_template = std::fs::read_to_string(workspace.join("SOUL.md"))
            .unwrap_or_else(|_| soul_template_static.to_string());
        let identity_template = std::fs::read_to_string(workspace.join("IDENTITY.md"))
            .unwrap_or_else(|_| identity_template_static.to_string());
        let user_template = std::fs::read_to_string(workspace.join("USER.md"))
            .unwrap_or_else(|_| user_template_static.to_string());
        let agents_template = std::fs::read_to_string(workspace.join("AGENTS.md"))
            .unwrap_or_else(|_| agents_template_static.to_string());
        let tools_template = std::fs::read_to_string(workspace.join("TOOLS.md"))
            .unwrap_or_else(|_| tools_template_static.to_string());
        let memory_template = std::fs::read_to_string(workspace.join("MEMORY.md"))
            .unwrap_or_else(|_| memory_template_static.to_string());

        format!(
            r#"You are setting up a personal AI agent's brain — its entire workspace of markdown files that define who it is, who its human is, and how it operates.

The user dumped two blocks of info. One about themselves (name, role, links, projects, whatever they shared). One about how they want their agent to be (personality, vibe, behavior). Use EVERYTHING they gave you to personalize ALL six template files below.

=== ABOUT THE USER ===
{about_me}

=== ABOUT THE AGENT ===
{about_opencrabs}

=== TODAY'S DATE ===
{date}

Below are the 6 template files. Replace ALL <placeholder> tags and HTML comments with real values based on what the user provided. Keep the exact markdown structure. Fill what you can from the user's info, leave sensible defaults for anything not provided. Don't invent facts — if the user didn't mention something, use a reasonable placeholder like "TBD" or remove that line.

===TEMPLATE: SOUL.md===
{soul}

===TEMPLATE: IDENTITY.md===
{identity}

===TEMPLATE: USER.md===
{user}

===TEMPLATE: AGENTS.md===
{agents}

===TEMPLATE: TOOLS.md===
{tools}

===TEMPLATE: MEMORY.md===
{memory}

CRITICAL OUTPUT RULES:
1. Start your response IMMEDIATELY with ---SOUL--- (no preamble, no "Here are", no commentary)
2. Use EXACTLY these delimiters on their own line: ---SOUL--- ---IDENTITY--- ---USER--- ---AGENTS--- ---TOOLS--- ---MEMORY---
3. After the last section (MEMORY content), STOP. No closing remarks, no "Let me know", no summary, no notes.
4. Do NOT wrap output in markdown code fences (no ```). Raw content only.
5. Each section is the complete file content — valid markdown, ready to save as-is.

---SOUL---
(generated SOUL.md content)
---IDENTITY---
(generated IDENTITY.md content)
---USER---
(generated USER.md content)
---AGENTS---
(generated AGENTS.md content)
---TOOLS---
(generated TOOLS.md content)
---MEMORY---
(generated MEMORY.md content)"#,
            // Prefer the normalized/auto-formatted markdown if generation
            // went through `normalize_brain_inputs`; fall back to the raw
            // input otherwise so callers that skip normalization still work.
            about_me = if !self.formatted_about_me.is_empty() {
                self.formatted_about_me.as_str()
            } else if self.about_me.is_empty() {
                "Not provided"
            } else {
                self.about_me.as_str()
            },
            about_opencrabs = if !self.formatted_about_agent.is_empty() {
                self.formatted_about_agent.as_str()
            } else if self.about_opencrabs.is_empty() {
                "Not provided"
            } else {
                self.about_opencrabs.as_str()
            },
            date = today,
            soul = soul_template,
            identity = identity_template,
            user = user_template,
            agents = agents_template,
            tools = tools_template,
            memory = memory_template,
        )
    }

    /// Store the generated brain content from the AI response.
    ///
    /// The response is parsed leniently: the strict `---NAME---` delimiters
    /// are tried first, and if any are missing we fall back to loose matching
    /// that also accepts common markdown-header variants the model sometimes
    /// emits instead (e.g. `## SOUL.md`, `### IDENTITY`, `**USER**`). As long
    /// as we can recover at least SOUL + IDENTITY + USER we count the
    /// generation as a success and fill in whatever else we find.
    pub fn apply_generated_brain(&mut self, response: &str) {
        let parsed = parse_brain_sections(response);

        // Need at least SOUL, IDENTITY, USER to consider it a success
        if parsed[0].is_none() || parsed[1].is_none() || parsed[2].is_none() {
            self.brain_error = Some("Couldn't parse AI response — using defaults".to_string());
            self.brain_generating = false;
            return;
        }

        self.generated_soul = parsed[0].clone();
        self.generated_identity = parsed[1].clone();
        self.generated_user = parsed[2].clone();
        self.generated_agents = parsed[3].clone();
        self.generated_tools = parsed[4].clone();
        self.generated_memory = parsed[5].clone();

        self.brain_generated = true;
        self.brain_generating = false;
    }
}

/// Parse an AI response into six optional brain sections
/// (SOUL, IDENTITY, USER, AGENTS, TOOLS, MEMORY) in that order.
/// Accepts both the strict `---NAME---` delimiters and a variety of
/// header-style fallbacks so a model that forgets the exact format
/// can still be recovered.
pub(crate) fn parse_brain_sections(response: &str) -> [Option<String>; 6] {
    const NAMES: [&str; 6] = ["SOUL", "IDENTITY", "USER", "AGENTS", "TOOLS", "MEMORY"];

    // Each entry: (section_index, byte position of header start, header length)
    let mut hits: Vec<(usize, usize, usize)> = Vec::new();

    for (i, name) in NAMES.iter().enumerate() {
        if let Some((pos, len)) = find_section_header(response, name) {
            hits.push((i, pos, len));
        }
    }

    hits.sort_by_key(|(_, pos, _)| *pos);

    let mut out: [Option<String>; 6] = Default::default();
    for (idx, &(section, pos, len)) in hits.iter().enumerate() {
        let start = pos + len;
        let end = if idx + 1 < hits.len() {
            hits[idx + 1].1
        } else {
            response.len()
        };
        if start > end || start > response.len() {
            continue;
        }
        let content = response[start..end.min(response.len())].trim();
        if !content.is_empty() {
            out[section] = Some(content.to_string());
        }
    }

    out
}

/// Find the first header for `name` in `response`. Returns the byte offset of
/// the header start and its length so the caller can slice content after it.
/// Tries strict `---NAME---`, then common fallbacks the model might emit.
fn find_section_header(response: &str, name: &str) -> Option<(usize, usize)> {
    // 1. Strict delimiter: ---NAME---
    let strict = format!("---{}---", name);
    if let Some(pos) = response.find(&strict) {
        return Some((pos, strict.len()));
    }

    // 2. Line-oriented header fallbacks. Scan line-by-line so we can match
    //    headers that include `.md`, surrounding markdown syntax, or varying
    //    case without false-positives in body text.
    let mut byte_offset = 0usize;
    for line in response.split_inclusive('\n') {
        let trimmed = line.trim();
        if header_line_matches(trimmed, name) {
            // Consume the whole line (including trailing newline) as the header.
            return Some((byte_offset, line.len()));
        }
        byte_offset += line.len();
    }

    None
}

/// Return true if a trimmed line looks like a section header for `name`.
/// Accepts: `## SOUL`, `## SOUL.md`, `### SOUL.md`, `**SOUL**`, `SOUL.md`,
/// `---SOUL---`, `# SOUL`, etc. Case-insensitive on the name.
fn header_line_matches(line: &str, name: &str) -> bool {
    // Strip common markdown decoration characters from both ends, then compare.
    let stripped = line
        .trim_matches(|c: char| {
            c == '#'
                || c == '*'
                || c == '-'
                || c == '='
                || c == '_'
                || c == ':'
                || c.is_whitespace()
        })
        .to_ascii_uppercase();

    let name_upper = name.to_ascii_uppercase();
    stripped == name_upper || stripped == format!("{}.MD", name_upper)
}

/// Detect if text looks like markdown
fn looks_like_markdown(text: &str) -> bool {
    let t = text.trim();
    t.contains('#')
        || t.contains("```")
        || t.contains("- ")
        || t.contains("* ")
        || t.contains("##")
        || t.contains("[")
        || t.contains("![]")
        || t.contains("__")
        || t.contains("**")
        || t.starts_with("> ")
        || t.contains("|")
}

/// Auto-wrap plain text in markdown template
fn auto_format_markdown(input: &str, section_title: &str) -> String {
    let trimmed = input.trim();
    if trimmed.is_empty() || looks_like_markdown(trimmed) {
        return trimmed.to_string();
    }
    format!(
        "# {}\n\n{}\n\n## Preferences\n\n- \n\n## Boundaries\n\n- ",
        section_title, trimmed
    )
}