Something went wrong. Try again.
small gleam coding and (not yet) persistent agent daemon with a detachable cli forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530//// Session commands: one catalog drives the CLI menu, user invocation, and the//// Python kernel's typed `commands` bindings.//////// A command runs outside the session actor — on the kernel's host-call process//// or an HTTP request process — and reaches session state only through//// `Context.state`, which serializes on the session actor. A handler running//// inside that actor must never run a command: its state calls would deadlock//// against the actor it is running in.
import albedo/daemon/storeimport gleam/dict.{type Dict}import gleam/dynamic/decodeimport gleam/intimport gleam/jsonimport gleam/listimport gleam/option.{type Option}import gleam/resultimport gleam/string
/// One command argument, in invocation order.pub type Argument { Argument( name: String, description: String, required: Bool, choices: List(String), )}
/// Who invoked the command. Only a user invocation may submit a turn.pub type Caller { UserCall ModelCall}
pub type Outcome { Data(json.Json) /// One user turn: the label shown in the transcript and the model-visible text. Turn(display: String, text: String)}
/// Session state a command may reach. The session actor answers these through/// its registered bridge, reusing its ordinary message handlers; an unknown/// operation is unrepresentable.pub type StateOp { ModelGet ModelSelect(model: String, provider: Option(String)) ContextSummary Compact ContextPage(section: String, page: Int) Submit(display: String, text: String, client: String) Refresh /// Tell the agent something without starting a turn: the note waits in the /// session's queue and reaches the model at its next step or next turn. /// `origin` labels it in the transcript. Note(origin: String, display: String, text: String)}
/// The dispatch context: `state` is the session's registered command bridge.pub type Context { Context(state: fn(StateOp) -> Result(json.Json, String))}
pub type Command { Command( /// The slash spelling, e.g. "/model". name: String, description: String, /// Declared in invocation order; the last argument takes the rest of the raw /// invocation with outer whitespace trimmed and inner spacing exact, so /// `/fix-lint one two` reaches the command as "one two". arguments: List(Argument), model_callable: Bool, /// User invocations submit the outcome as a turn instead of returning it. user_turn: Bool, /// Run with no arguments, it answers a page document (`page.document`) that /// a client renders as the extension's own screen; its actions run this /// same command with arguments. page: Bool, run: fn(Context, Caller, Dict(String, String)) -> Result(Outcome, String), )}
/// The context every dispatch path builds: state serializes through the/// session's registered command bridge.pub fn context(session: String) -> Context { Context(fn(op) { state_call(session, op) })}
@external(erlang, "albedo_commands", "call")fn state_call(session: String, op: StateOp) -> Result(json.Json, String)
const identifier_characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"
const digits = "0123456789"
/// Binding names the Python object reserves for itself.const reserved_methods = ["catalog", "invoke", "help", "__init__"]
/// The Python binding name of one command: `/model` -> "model",/// `/fix-lint` -> "fix_lint".pub fn method_name(name: String) -> String { let mangled = string.replace(name, "/", "") |> string.to_graphemes |> list.map(fn(char) { case string.contains(identifier_characters, char) { True -> char False -> "_" } }) |> string.join("") case string.first(mangled) { Error(_) -> "command" Ok(char) -> case string.contains(digits, char) { True -> "_" <> mangled False -> mangled } }}
/// Every command's minted binding name, computed for the whole catalog:/// reserved names gain a suffix and collisions gain a numeric suffix, so the/// name advertised in the catalog and prompt is always the name Python mints.pub fn method_names(commands: List(Command)) -> Dict(String, String) { let #(_taken, pairs) = list.fold(commands, #(dict.new(), []), fn(state, command) { let #(taken, pairs) = state let method = unique_method(method_name(command.name), taken) #(dict.insert(taken, method, True), [#(command.name, method), ..pairs]) }) dict.from_list(list.reverse(pairs))}
fn unique_method(base: String, taken: Dict(String, Bool)) -> String { let candidate = case list.contains(reserved_methods, base) { True -> base <> "_" False -> base } find_free(candidate, taken, 2)}
fn find_free(candidate: String, taken: Dict(String, Bool), n: Int) -> String { case dict.has_key(taken, candidate) { False -> candidate True -> find_free(candidate <> int.to_string(n), taken, n + 1) }}
/// A slash name is usable in the catalog: one non-space token after the slash.pub fn valid_name(name: String) -> Bool { string.starts_with(name, "/") && string.length(string.trim(name)) > 1 && !string.contains(name, " ") && !string.contains(name, "\t") && !string.contains(name, "\n")}
/// Split one raw invocation into declared arguments. Leading arguments take one/// whitespace token each; the last declared argument takes the rest with outer/// whitespace trimmed and inner spacing exact, so `/fix-lint one two` reaches/// the command as "one two". Missing required arguments answer a usage error.pub fn parse_arguments( command: Command, raw: String,) -> Result(Dict(String, String), String) { use values <- result.try( take_arguments(command.arguments, string.trim(raw), []), ) Ok(dict.from_list(values))}
fn take_arguments( arguments: List(Argument), raw: String, taken: List(#(String, String)),) -> Result(List(#(String, String)), String) { case arguments, raw { [], "" -> Ok(list.reverse(taken)) [], _ -> Error("usage: command takes no arguments") [argument], _ -> { let value = string.trim(raw) case value, argument.required { "", True -> missing(argument) "", False -> Ok(list.reverse(taken)) _, _ -> Ok(list.reverse([#(argument.name, value), ..taken])) } } [argument, ..rest], _ -> { let #(token, remainder) = case string.split_once(raw, " ") { Ok(#(token, remainder)) -> #(string.trim(token), string.trim(remainder)) Error(_) -> #(string.trim(raw), "") } case token, argument.required { "", True -> missing(argument) "", False -> take_arguments(rest, remainder, taken) _, _ -> take_arguments(rest, remainder, [#(argument.name, token), ..taken]) } } }}
fn missing(argument: Argument) -> Result(List(#(String, String)), String) { Error("missing argument <" <> argument.name <> ">")}
/// One command's invocation shape: `/model [model] [provider]`.pub fn usage(command: Command) -> String { let arguments = command.arguments |> list.map(fn(argument) { case argument.required { True -> "<" <> argument.name <> ">" False -> "[" <> argument.name <> "]" } }) string.join([command.name, ..arguments], " ")}
/// Validate supplied arguments against the declaration: unknown names fail,/// and every required argument must be present and nonempty.pub fn check_arguments( command: Command, args: Dict(String, String),) -> Result(Dict(String, String), String) { use _ <- result.try( dict.to_list(args) |> list.try_each(fn(entry) { case list.any(command.arguments, fn(argument) { argument.name == entry.0 }) { True -> Ok(Nil) False -> Error("unknown argument <" <> entry.0 <> ">") } }) |> result.map_error(fn(error) { error <> "; usage: " <> usage(command) }), ) use _ <- result.try( command.arguments |> list.try_each(fn(argument) { case argument.required, dict.get(args, argument.name) { True, Ok(value) if value != "" -> Ok(Nil) True, _ -> Error("missing argument <" <> argument.name <> ">") False, _ -> Ok(Nil) } }) |> result.map_error(fn(error) { error <> "; usage: " <> usage(command) }), ) Ok(args)}
pub fn find(commands: List(Command), name: String) -> Result(Command, String) { list.find(commands, fn(command) { command.name == name }) |> result.replace_error("unknown command " <> name)}
/// Resolve one wire call: declared arguments or raw invocation text, never/// both. This is the single precedence rule behind both entry paths.pub fn call( commands: List(Command), context: Context, caller: Caller, client: String, name: String, supplied: Dict(String, String), raw: String,) -> Result(Outcome, String) { use command <- result.try(find(commands, name)) use args <- result.try(case raw, dict.size(supplied) { "", _ -> Ok(supplied) text, 0 -> parse_arguments(command, text) _, _ -> Error("pass either arguments or args, not both") }) dispatch(commands, context, caller, client, name, args)}
/// Run one command and apply the caller policy. A user invocation of a/// `user_turn` command submits its turn through state and otherwise returns/// data; a model invocation may never submit a turn.pub fn dispatch( commands: List(Command), context: Context, caller: Caller, client: String, name: String, args: Dict(String, String),) -> Result(Outcome, String) { use command <- result.try(find(commands, name)) use args <- result.try(check_arguments(command, args)) use _ <- result.try(case caller, command.model_callable { ModelCall, False -> Error(command.name <> " is not callable from the model") _, _ -> Ok(Nil) }) use outcome <- result.try(command.run(context, caller, args)) case caller, outcome { ModelCall, Turn(_, _) -> Error( command.name <> " submits a user turn and cannot run from the model; ask the user to run it", ) UserCall, Turn(display, text) if command.user_turn -> { use _ <- result.try( context.state(Submit(display, text, client)) |> result.replace(Nil), ) Ok(Turn(display, text)) } UserCall, Turn(_, _) -> Error(command.name <> " returned a turn but is not a user-turn command") UserCall, Data(_) if command.user_turn -> Error(command.name <> " is a user-turn command but returned data") _, data -> Ok(data) }}
pub fn command_json(command: Command, method: String) -> json.Json { json.object([ #("name", json.string(command.name)), #("description", json.string(command.description)), #("method", json.string(method)), #("usage", json.string(usage(command))), #( "arguments", json.array(command.arguments, fn(argument) { let fields = [ #("name", json.string(argument.name)), #("description", json.string(argument.description)), #("required", json.bool(argument.required)), ] json.object(case argument.choices { [] -> fields choices -> list.append(fields, [#("choices", json.array(choices, json.string))]) }) }), ), #("modelCallable", json.bool(command.model_callable)), #("userTurn", json.bool(command.user_turn)), #("page", json.bool(command.page)), ])}
pub fn catalog_json(commands: List(Command)) -> json.Json { let methods = method_names(commands) json.array(commands, fn(command) { command_json( command, dict.get(methods, command.name) |> result.unwrap(method_name(command.name)), ) })}
/// Metadata-only startup context so the model knows the catalog before it can/// call it, minted from the same list as the typed bindings. An empty catalog/// adds nothing to the request.pub fn context_block(commands: List(Command)) -> String { case commands { [] -> "" _ -> { let methods = method_names(commands) let rows = commands |> list.map(fn(command) { let arguments = command.arguments |> list.map(fn(argument) { case argument.required { True -> "<" <> argument.name <> ">" False -> "[" <> argument.name <> "]" } }) " " <> command.name <> { case arguments { [] -> "" values -> " " <> string.join(values, " ") } } <> { case command.model_callable { True -> " (method: " <> { dict.get(methods, command.name) |> result.unwrap(method_name(command.name)) } <> ")" False -> " (user only)" } } <> ": " <> command.description }) |> string.join("\n") "Session commands are exposed through the async `commands` object. Every" <> " model-callable command is a typed method (commands.<method>(...));" <> " commands.catalog() lists the current catalog with argument details, and" <> " help(commands.<method>) shows one command's help. commands.invoke(name, arguments)" <> " runs any model-callable command by its slash name, including one added by a" <> " session reload after the kernel started. Invoking a command returns data and never" <> " submits a turn; a refusal raises CommandsError.\n<session_commands>\n" <> rows <> "\n</session_commands>" } }}
/// Aggregate `commands.*` kernel routes over one materialized command list./// `commands.list` answers from the captured list alone, so it can serve kernel/// boot; `commands.run` executes outside any session actor.pub fn routes( commands: List(Command),) -> List(#(String, fn(store.Store, String, String) -> String)) { [ #("commands", fn(store, session, request) { respond(commands, store, session, request) }), ]}
fn respond( commands: List(Command), _store: store.Store, session: String, request: String,) -> String { let decoder = { use method <- decode.field("method", decode.string) use args <- decode.field("args", decode.dynamic) decode.success(#(method, args)) } case json.parse(request, decoder) { Error(_) -> failed("invalid", "invalid commands host request") Ok(#(method, args)) -> case method { "commands.list" -> answered(catalog_json(commands)) "commands.run" -> run_request(commands, session, args) _ -> failed("commands", "unknown commands operation") } }}
fn run_request( commands: List(Command), session: String, args: decode.Dynamic,) -> String { case decode_run(["name", "args", "arguments"], args) { Error(message) -> failed("commands", message) Ok(#(name, supplied, raw, _)) -> case call( commands, context(session), ModelCall, "kernel", name, supplied, raw, ) { Ok(Data(value)) -> answered(value) Ok(Turn(_, _)) -> failed("commands", "command submitted a turn from the model") Error(message) -> failed("commands", message) } }}
/// Decode one run request strictly: unknown fields fail, non-string argument/// values fail, and `arguments` (raw text) and `args` (declared names) are/// mutually exclusive at `call`.pub fn decode_run( allowed: List(String), args: decode.Dynamic,) -> Result(#(String, Dict(String, String), String, String), String) { let decoder = { use fields <- decode.then(decode.dict(decode.string, decode.dynamic)) use name <- decode.field("name", decode.string) use supplied <- decode.optional_field( "args", dict.new(), decode.dict(decode.string, decode.string), ) use raw <- decode.optional_field("arguments", "", decode.string) use client <- decode.optional_field("clientId", "", decode.string) use _ <- decode.then(case unknown_fields(fields, allowed) { [] -> decode.success(Nil) names -> decode.failure(Nil, "unknown fields: " <> string.join(names, ", ")) }) decode.success(#(name, supplied, raw, client)) } case decode.run(args, decoder) { Ok(values) -> Ok(values) Error(errors) -> Error( list.first(errors) |> result.map(fn(error) { error.expected }) |> result.unwrap("invalid commands run request"), ) }}
fn unknown_fields( fields: Dict(String, decode.Dynamic), allowed: List(String),) -> List(String) { fields |> dict.keys |> list.filter(fn(key) { !list.contains(allowed, key) }) |> list.sort(string.compare)}
fn answered(value: json.Json) -> String { json.object([#("ok", json.bool(True)), #("value", value)]) |> json.to_string}
fn failed(code: String, message: String) -> String { json.object([ #("ok", json.bool(False)), #("code", json.string(code)), #("message", json.string(message)), ]) |> json.to_string}