lwc 0.17.7

Agent-driven proactive memory CLI for AI agents — autonomously recall, maintain, and evolve persistent, source-grounded knowledge across sessions.
fn normalize_slug(slug: &str) -> Result<String, Error> {
    let slug = slug.trim().replace('\\', "/");
    if slug.is_empty() || slug.starts_with('/') || slug.len() > REL_PATH_MAX {
        return Err(Error(format!("unsafe page slug: {slug}")));
    }
    let mut parts = Vec::new();
    for part in slug.split('/') {
        if part.is_empty()
            || part == "."
            || part == ".."
            || part.ends_with(' ')
            || part.ends_with('.')
            || part.chars().count() > SLUG_SEGMENT_MAX
            || part.chars().any(bad_path_char)
        {
            return Err(Error(format!("unsafe page slug: {slug}")));
        }
        parts.push(part.to_string());
    }
    Ok(parts.join("/"))
}

fn validate_raw_ref(path: &str) -> Result<(), Error> {
    let path = path.trim().replace('\\', "/");
    if !(path.starts_with("raw/sources/") || path.starts_with("raw/assets/")) {
        return Err(Error(format!(
            "artifact reference must stay under raw/: {path}"
        )));
    }
    for component in Path::new(&path).components() {
        let Component::Normal(part) = component else {
            return Err(Error(format!("unsafe artifact reference: {path}")));
        };
        let part = part.to_string_lossy();
        if part.is_empty() || part == "." || part == ".." || part.chars().any(bad_path_char) {
            return Err(Error(format!("unsafe artifact reference: {path}")));
        }
    }
    Ok(())
}

fn normalize_source_id(id: &str) -> Result<String, Error> {
    let id = id.trim();
    if id.is_empty() {
        return Err(Error("source id must not be empty".into()));
    }
    let mut out = String::new();
    let mut dashed = false;
    for ch in id.chars() {
        let next = if ch.is_ascii_alphanumeric() {
            dashed = false;
            Some(ch.to_ascii_lowercase())
        } else if matches!(ch, '-' | '_') {
            dashed = false;
            Some(ch)
        } else if !dashed {
            dashed = true;
            Some('-')
        } else {
            None
        };
        if let Some(ch) = next {
            out.push(ch);
            if out.chars().count() >= SOURCE_ID_MAX {
                break;
            }
        }
    }
    let out = out.trim_matches('-').to_string();
    if out.is_empty() {
        Err(Error(format!(
            "source id cannot be normalized safely: {id}"
        )))
    } else {
        Ok(out)
    }
}

fn safe_basename(origin: &str) -> String {
    let raw = origin
        .rsplit(['/', '\\'])
        .find(|part| !part.is_empty())
        .unwrap_or("source.txt");
    let mut out = String::new();
    let mut dashed = false;
    for ch in raw.chars() {
        if out.chars().count() >= BASENAME_MAX {
            break;
        }
        if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
            out.push(ch);
            dashed = false;
        } else if !dashed {
            out.push('-');
            dashed = true;
        }
    }
    let out = out.trim_matches(['-', '.']).to_string();
    if out.is_empty() {
        "source.txt".into()
    } else if out.contains('.') {
        out
    } else {
        format!("{out}.txt")
    }
}

fn normalize_kind(kind: Option<&str>) -> String {
    let kind = kind.unwrap_or("other").trim().to_ascii_lowercase();
    if kind.is_empty() {
        "other".into()
    } else {
        kind
    }
}

fn normalize_provenance(values: &[String]) -> Result<Vec<String>, Error> {
    let values = values.iter().map(String::as_str).collect::<BTreeSet<_>>();
    if let Some(value) = values
        .iter()
        .find(|value| !PROVENANCE_ORDER.contains(value))
    {
        return Err(Error(format!("unsupported page provenance: {value}")));
    }
    Ok(PROVENANCE_ORDER
        .iter()
        .filter(|value| values.contains(**value))
        .map(|value| (*value).to_string())
        .collect())
}

fn folder_for(kind: Option<&str>) -> &'static str {
    match normalize_kind(kind).as_str() {
        "entity" | "entities" => "entities",
        "concept" | "concepts" => "concepts",
        "source" | "sources" => "sources",
        "query" | "queries" => "queries",
        "comparison" | "comparisons" => "comparisons",
        "synthesis" => "synthesis",
        _ => "other",
    }
}

fn day(value: &str) -> String {
    let value = value.trim();
    if value.len() >= 10
        && value.as_bytes().get(4) == Some(&b'-')
        && value.as_bytes().get(7) == Some(&b'-')
    {
        value[..10].to_string()
    } else {
        "undated".into()
    }
}

fn trimmed(value: Option<&str>) -> Option<String> {
    value
        .map(str::trim)
        .filter(|v| !v.is_empty())
        .map(str::to_string)
}

fn text(value: &str) -> String {
    let mut value = value.replace("\r\n", "\n").replace('\r', "\n");
    if !value.is_empty() && !value.ends_with('\n') {
        value.push('\n');
    }
    value
}

fn yaml(value: &str) -> String {
    let mut out = String::from("\"");
    for ch in value.chars() {
        match ch {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

fn wiki_label(value: &str) -> String {
    value
        .chars()
        .map(|ch| match ch {
            '[' | ']' | '|' | '\n' | '\r' | '\t' => ' ',
            c if c.is_control() => ' ',
            c => c,
        })
        .collect::<String>()
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
}

fn bad_path_char(ch: char) -> bool {
    ch.is_control() || matches!(ch, '<' | '>' | ':' | '"' | '|' | '?' | '*')
}

fn obsidian_app_json() -> &'static str {
    "{\n  \"attachmentFolderPath\": \"raw/assets\",\n  \"userIgnoreFilters\": [\n    \".lwc\"\n  ],\n  \"useMarkdownLinks\": false,\n  \"newLinkFormat\": \"shortest\",\n  \"showUnsupportedFiles\": false\n}\n"
}