formal-ai 0.287.0

Formal symbolic AI implementation with OpenAI-compatible APIs
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
408
409
410
411
412
413
414
415
416
417
418
419
//! Deterministic fallback planner for repository change requests (issue #654).
//!
//! Unlike the stored recipe fixtures, this planner derives its target and payload
//! from the formalized request.  The resulting plan is data: it is serialized to
//! Links Notation and written before execution, so the tool transcript is an
//! append-only record of the decision that caused the change.

use std::fmt::Write as _;

use crate::engine::stable_id;
use crate::intent_formalization::formalize_intent;
use crate::seed::{self, Slot};

use super::planner::Capability;

/// Workspace-relative event-log artifact written before a general plan executes.
pub const PLAN_PATH: &str = ".formal-ai/general-change-plan.lino";

/// One ordered, capability-tagged operation in a general change plan.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeneralPlanStep {
    pub capability: Capability,
    pub action: String,
    pub expected_evidence: String,
    pub command: Option<String>,
}

/// A deterministic plan composed from a formalized, previously unrecognised request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeneralChangePlan {
    pub id: String,
    pub goal: String,
    pub target: String,
    pub content: String,
    pub steps: Vec<GeneralPlanStep>,
    pub verification_command: String,
}

impl GeneralChangePlan {
    /// Render the plan shape consumed by the driver and documented by the meta fixture.
    #[must_use]
    pub fn links_notation(&self) -> String {
        let mut out = String::from("general_change_plan\n");
        field(&mut out, "id", &self.id);
        field(&mut out, "goal", &self.goal);
        field(&mut out, "target", &self.target);
        for (index, step) in self.steps.iter().enumerate() {
            let _ = writeln!(out, "  step {}", index + 1);
            field_nested(&mut out, "capability", capability_slug(step.capability));
            field_nested(&mut out, "action", &step.action);
            field_nested(&mut out, "expected_evidence", &step.expected_evidence);
            if let Some(command) = &step.command {
                field_nested(&mut out, "command", command);
            }
        }
        field(&mut out, "verification_command", &self.verification_command);
        out
    }
}

/// Compose a safe file-creation plan from arbitrary wording in any supported
/// language (issue #680).
///
/// The universal intent formalizer supplies the stable impulse identity.  The
/// decomposition is deliberately bounded to requests that state both a relative
/// target file and literal content; ambiguous requests continue to the ordinary
/// solver instead of inventing a patch or shell command.
///
/// The target, the content, and the write *intent* itself are all recognised
/// from the seed lexicon (the `file_write_*` roles in
/// `data/seed/meanings-file-write.lino`) rather than from a hardcoded list of
/// English or Russian phrasings, so a file-creation request in en/ru/hi/zh — in
/// any phrasing — routes to the write tool (CONTRIBUTING §2).
#[must_use]
pub fn compose_general_change_plan(request: &str) -> Option<GeneralChangePlan> {
    let (target, content) = parse_write_request(request)?;
    if !safe_relative_path(&target) {
        return None;
    }
    let intent = formalize_intent(request, language(request), None);
    let verification_command = format!("cat {target}");
    let steps = vec![
        GeneralPlanStep {
            capability: Capability::Write,
            action: format!("append the composed plan to {PLAN_PATH}"),
            expected_evidence: format!("written plan event {}", intent.impulse_id),
            command: None,
        },
        GeneralPlanStep {
            capability: Capability::Write,
            action: format!("write the requested content to {target}"),
            expected_evidence: format!("workspace file {target}"),
            command: None,
        },
        GeneralPlanStep {
            capability: Capability::Run,
            action: String::from("run the request-derived verification command"),
            expected_evidence: content.clone(),
            command: Some(verification_command.clone()),
        },
    ];
    Some(GeneralChangePlan {
        id: stable_id(
            "general_change_plan",
            &format!("{}:{target}:{content}", intent.impulse_id),
        ),
        goal: intent.source_text,
        target,
        content,
        steps,
        verification_command,
    })
}

/// Recover the `(target, content)` of a write request from its wording.
///
/// The recogniser is entirely seed-driven (issue #680). It locates the target
/// file by a `file_write_target_cue`/`file_write_destination_cue` that directly
/// precedes a file-looking, safe relative path, then recovers the content two
/// ways:
///
/// * **Marker-led** — a `file_write_content_lead` phrase ("containing", "with
///   the following", …) introduces the payload. The content is the span after
///   the marker (when the file precedes it) or the span between the marker and
///   the file clause (when the content precedes the file).
/// * **Destination-led** — the "write CONTENT to FILE" shape, where a
///   `file_write_action_cue` opens the request and a *destination* cue (not a
///   positional target cue) routes the preceding span into the file.
///
/// Both byte offsets index the lowercased copy, which is byte-length preserving
/// for en/ru/hi/zh, so the same offsets slice the original request and the
/// recovered content keeps its case and punctuation.
fn parse_write_request(request: &str) -> Option<(String, String)> {
    let lowered = request.to_lowercase();
    let toks = tokens(request);
    let target_cues = bare_surfaces(seed::ROLE_FILE_WRITE_TARGET_CUE);
    let dest_cues = bare_surfaces(seed::ROLE_FILE_WRITE_DESTINATION_CUE);

    // The target file: the first safe, file-looking token that directly follows
    // a target or destination cue. Requiring the cue keeps an incidental dotted
    // token (a version, an abbreviation) out of the write path.
    let (file_index, target) = toks.iter().enumerate().find_map(|(index, token)| {
        let cleaned = clean_path_token(token.text);
        let looks_like_file = cleaned.contains('.') && !cleaned.contains("://");
        if !looks_like_file || !safe_relative_path(cleaned) {
            return None;
        }
        let previous = index.checked_sub(1).map(|i| &toks[i])?;
        let previous_word = clean_cue_token(previous.text);
        (target_cues.contains(&previous_word) || dest_cues.contains(&previous_word))
            .then(|| (index, cleaned.to_owned()))
    })?;

    let cue = &toks[file_index - 1];
    let clause_start = cue.start;
    let cue_is_destination = dest_cues.contains(&clean_cue_token(cue.text));

    let content_span = if let Some((_, marker_end)) = first_content_lead_end(&lowered) {
        if marker_end <= clause_start {
            request.get(marker_end..clause_start)
        } else {
            request.get(marker_end..)
        }
    } else if cue_is_destination {
        let action_end = first_action_cue_end(&toks)?;
        (action_end <= clause_start).then(|| request.get(action_end..clause_start))?
    } else {
        None
    };

    let content = clean_content(content_span?)?;
    Some((target, content))
}

/// Recover the `(target, old, new)` of a file-edit request from its wording
/// (issue #680).
///
/// The recogniser is entirely seed-driven. It locates the target file by a
/// [`ROLE_FILE_EDIT_TARGET_CUE`](seed::ROLE_FILE_EDIT_TARGET_CUE) ("in", "within",
/// "of", "file", …) that directly precedes a file-looking, safe relative path,
/// finds the leftmost
/// [`ROLE_FILE_EDIT_ACTION_CUE`](seed::ROLE_FILE_EDIT_ACTION_CUE) ("change",
/// "replace", "edit", …), then the first
/// [`ROLE_FILE_EDIT_NEW_LEAD_CUE`](seed::ROLE_FILE_EDIT_NEW_LEAD_CUE) ("to",
/// "with", "into", …) after it. The *old* text is the span between the action and
/// the new-lead; the *new* text is the span after the new-lead, bounded by the
/// file clause when the file follows the replacement (the "replace OLD with NEW in
/// FILE" shape) or running to the end when the file was named first (the "in FILE,
/// change OLD to NEW" shape).
///
/// Returns [`None`] unless a target file, an action cue, a new-lead, and non-empty
/// old and new spans are all present — and unless the file clause sits *outside*
/// the replaced span — so ambiguous or non-edit requests fall through to the
/// ordinary solver rather than fabricating an edit.
///
/// Byte offsets index the original request directly (the cue matching lowercases
/// per token, which is byte-length preserving for en/ru/hi/zh), so the recovered
/// old/new text keeps its original case and punctuation.
#[must_use]
pub fn compose_edit_request(request: &str) -> Option<(String, String, String)> {
    let toks = tokens(request);
    let action_cues = bare_surfaces(seed::ROLE_FILE_EDIT_ACTION_CUE);
    let new_leads = bare_surfaces(seed::ROLE_FILE_EDIT_NEW_LEAD_CUE);
    let target_cues = bare_surfaces(seed::ROLE_FILE_EDIT_TARGET_CUE);

    // The target file: the first safe, file-looking token that sits directly beside
    // a target cue — before it in prepositional languages ("in notes.txt") or after
    // it in postpositional ones ("doc.txt में", "the report.md file"). Requiring the
    // cue keeps an incidental dotted token out of the edit path, exactly as the
    // write recogniser does.
    let is_target_cue = |index: usize| target_cues.contains(&clean_cue_token(toks[index].text));
    let (file_index, target) = toks.iter().enumerate().find_map(|(index, token)| {
        let cleaned = clean_path_token(token.text);
        let looks_like_file = cleaned.contains('.') && !cleaned.contains("://");
        if !looks_like_file || !safe_relative_path(cleaned) {
            return None;
        }
        let prev_is_cue = index.checked_sub(1).is_some_and(&is_target_cue);
        let next_is_cue = (index + 1 < toks.len()) && is_target_cue(index + 1);
        (prev_is_cue || next_is_cue).then(|| (index, cleaned.to_owned()))
    })?;
    // Extend the clause boundary left over any run of target cues so a multi-word
    // file clause ("в файле notes.txt") is excluded from the replacement text in
    // full, not just its innermost word.
    let mut clause_start_index = file_index;
    while clause_start_index > 0 && is_target_cue(clause_start_index - 1) {
        clause_start_index -= 1;
    }
    let file_clause_start = toks[clause_start_index].start;

    // The edit action opens the replacement clause; the new-lead separates the old
    // text from the new text. The new-lead must follow the action so a "to"/"with"
    // belonging to an earlier clause is never mistaken for the replacement lead.
    let action_end = toks
        .iter()
        .find(|token| action_cues.contains(&clean_cue_token(token.text)))
        .map(|token| token.end)?;
    let new_lead = toks.iter().find(|token| {
        token.start >= action_end && new_leads.contains(&clean_cue_token(token.text))
    })?;

    // A well-formed edit names the file before the action ("in F, change A to B")
    // or after the replacement ("replace A with B in F") — never between the action
    // and the new-lead, which would fold the filename into the replaced text.
    if file_clause_start >= action_end && file_clause_start < new_lead.start {
        return None;
    }

    let old_span = request.get(action_end..new_lead.start)?;
    let new_end = if file_clause_start > new_lead.end {
        file_clause_start
    } else {
        request.len()
    };
    let new_span = request.get(new_lead.end..new_end)?;

    let old = clean_content(old_span)?;
    let new = clean_content(new_span)?;
    Some((target, old, new))
}

/// One whitespace token together with its byte span in the original request.
struct Token<'a> {
    text: &'a str,
    start: usize,
    end: usize,
}

/// Split a request into whitespace tokens, recording each token's byte span.
fn tokens(request: &str) -> Vec<Token<'_>> {
    let mut cursor = 0;
    request
        .split_whitespace()
        .map(|word| {
            let start = request[cursor..]
                .find(word)
                .map_or(cursor, |offset| cursor + offset);
            let end = start + word.len();
            cursor = end;
            Token {
                text: word,
                start,
                end,
            }
        })
        .collect()
}

/// The bare (whole-word) surface forms for a role, lowercased for token matching.
fn bare_surfaces(role: &str) -> Vec<String> {
    seed::lexicon()
        .role_word_forms(role)
        .iter()
        .filter(|form| form.slot() == Slot::Bare)
        .map(|form| form.text.to_lowercase())
        .collect()
}

/// Trim the quoting/edge punctuation from a token that may be a file path,
/// preserving the interior dots that make it look like a file. Trailing sentence
/// punctuation is stripped too, so a plain word that merely *ends a sentence*
/// ("… add the plural to томат.") is not mistaken for a file whose only dot is the
/// terminal period — a real filename never ends in a bare `.`/`!`/`?`.
fn clean_path_token(word: &str) -> &str {
    word.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | ':' | ';'))
        .trim_end_matches(['.', '!', '?'])
}

/// Lowercase a token stripped of edge punctuation, for cue/action comparison.
fn clean_cue_token(word: &str) -> String {
    word.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | ':' | ';' | '.' | '!' | '?'))
        .to_lowercase()
}

/// The byte span just past the leftmost `file_write_content_lead` marker in the
/// lowercased request, honouring whole-word boundaries for space-delimited
/// scripts and substring matches for CJK (which has no inter-word spaces).
fn first_content_lead_end(lowered: &str) -> Option<(usize, usize)> {
    let markers: Vec<String> = seed::lexicon()
        .role_word_forms(seed::ROLE_FILE_WRITE_CONTENT_LEAD)
        .iter()
        .filter(|form| form.slot() == Slot::Prefix)
        .map(|form| form.before_slot().trim().to_lowercase())
        .filter(|marker| !marker.is_empty())
        .collect();
    let mut best: Option<(usize, usize)> = None;
    for marker in &markers {
        let mut from = 0;
        while let Some(relative) = lowered[from..].find(marker.as_str()) {
            let start = from + relative;
            let end = start + marker.len();
            let cjk = !marker.contains(' ') && !marker.is_ascii();
            let before_ok = cjk
                || start == 0
                || lowered[..start]
                    .chars()
                    .next_back()
                    .is_some_and(char::is_whitespace);
            let after_ok = cjk
                || end == lowered.len()
                || lowered[end..]
                    .chars()
                    .next()
                    .is_some_and(|c| c.is_whitespace() || c.is_ascii_punctuation());
            if before_ok && after_ok {
                if best.is_none_or(|(best_start, _)| start < best_start) {
                    best = Some((start, end));
                }
                break;
            }
            from = end;
        }
    }
    best
}

/// The byte offset just past the first `file_write_action_cue` token.
fn first_action_cue_end(toks: &[Token<'_>]) -> Option<usize> {
    let actions = bare_surfaces(seed::ROLE_FILE_WRITE_ACTION_CUE);
    toks.iter()
        .find(|token| actions.contains(&clean_cue_token(token.text)))
        .map(|token| token.end)
}

/// Trim a recovered content span down to its literal payload, dropping the
/// leading clause separator ("… the following: hello") and any surrounding
/// quoting. Returns [`None`] when nothing is left.
fn clean_content(raw: &str) -> Option<String> {
    let led = raw.trim().trim_start_matches([':', '-', '', '']).trim();
    let unquoted = led.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '.' | ''));
    let result = unquoted.trim();
    (!result.is_empty()).then(|| result.to_owned())
}

fn safe_relative_path(path: &str) -> bool {
    !path.starts_with('/')
        && !path.starts_with('-')
        && !path.split('/').any(|part| part == ".." || part.is_empty())
        && path
            .chars()
            .all(|c| c.is_alphanumeric() || matches!(c, '/' | '.' | '_' | '-'))
}

const fn capability_slug(capability: Capability) -> &'static str {
    match capability {
        Capability::Search => "Search",
        Capability::Fetch => "Fetch",
        Capability::Read => "Read",
        Capability::Write => "Write",
        Capability::Edit => "Edit",
        Capability::Run => "Run",
    }
}

fn language(request: &str) -> &'static str {
    if request
        .chars()
        .any(|c| ('\u{0400}'..='\u{04ff}').contains(&c))
    {
        "ru"
    } else {
        "en"
    }
}

fn escape(value: &str) -> String {
    value
        .replace('\\', "\\\\")
        .replace('"', "\\\"")
        .replace('\n', "\\n")
}

fn field(out: &mut String, name: &str, value: &str) {
    let _ = writeln!(out, "  {name} \"{}\"", escape(value));
}

fn field_nested(out: &mut String, name: &str, value: &str) {
    let _ = writeln!(out, "    {name} \"{}\"", escape(value));
}