//// Fresh management discovery, independent of prepared commands and prompt state. import albedo/harness/capabilities import albedo/harness/extension import albedo/harness/extensions/skills/catalog as skills import albedo/harness/instruction_files import gleam/dynamic/decode import gleam/json import gleam/list import gleam/option.{type Option, None, Some} import gleam/result pub type Extensions { Extensions(skills: Bool, instructions: Bool, fingerprint: String) } pub type Change { Change( workspace: String, revision: String, id: String, scope: String, enabled: Option(Bool), ) } pub type Candidate { Candidate( id: String, kind: String, title: String, description: Option(String), source: String, resolved_source: Option(String), preference_key: Option(String), valid: Bool, diagnostic: Option(String), shadowed_by: Option(String), global_preference: Option(Bool), session_override: Option(Bool), effective_enabled: Bool, eligible: Bool, ) } pub type Snapshot { Snapshot( workspace: String, revision: String, extensions: Extensions, diagnostics: List(String), candidates: List(Candidate), ) } pub const stale_error = "catalog changed; refresh before changing a capability" pub fn change_decoder() -> decode.Decoder(Change) { use revision <- decode.field("revision", decode.string) use id <- decode.field("id", decode.string) use scope <- decode.field("scope", decode.string) use enabled <- decode.field("enabled", decode.optional(decode.bool)) decode.success(Change("", revision, id, scope, enabled)) } pub fn extension_state(summaries: List(extension.Summary)) -> Extensions { let relevant = list.filter(summaries, fn(item) { item.name == "skills" || item.name == "instructions" }) let enabled = fn(name) { list.any(relevant, fn(item) { item.name == name && item.enabled }) } Extensions( enabled("skills"), enabled("instructions"), fingerprint( list.map(relevant, fn(item) { #(item.name, item.enabled, item.overridden, item.global_enabled) }), ), ) } pub fn inspect( workspace: String, home: String, session: String, extensions: Extensions, ) -> Result(Snapshot, String) { use discovered_skills <- result.try(skills.discover_at( workspace, skills.native_home(), skills.native_builtin(), )) use discovered_instructions <- result.try(instruction_files.inspect_at( workspace, instruction_files.home(), )) use preferences <- result.try(capabilities.load(home, Some(session))) use _ <- result.try(capabilities.validate_preferences(preferences)) use skill_rows <- result.try( list.try_map(discovered_skills.candidates, fn(skill) { use #(global, override, effective) <- result.try(choices( preferences, "skills", skill.name, )) Ok(Candidate( skill.id, "skills", option.unwrap(skill.name, source_title(skill.source)), skill.description, skill.source, skill.resolved_source, skill.name, skill.valid, skill.diagnostic, skill.shadowed_by, global, override, effective, skill.eligible && effective && extensions.skills, )) }), ) use instruction_rows <- result.try( list.try_map(discovered_instructions.candidates, fn(file) { use #(global, override, effective) <- result.try(capabilities.choices( preferences, "instructions", file.key, )) Ok(Candidate( file.id, "instructions", file.display, None, file.path, None, Some(file.key), file.valid, file.diagnostic, None, global, override, effective, file.valid && effective && extensions.instructions, )) }), ) let candidates = list.append(skill_rows, instruction_rows) let revision = fingerprint(#( workspace, home, session, discovered_skills.fingerprint, discovered_instructions.fingerprint, extensions.fingerprint, preferences, )) Ok(Snapshot( workspace, revision, extensions, discovered_skills.diagnostics, candidates, )) } fn choices( preferences: capabilities.Preferences, kind: String, key: Option(String), ) -> Result(#(Option(Bool), Option(Bool), Bool), String) { case key { None -> Ok(#(None, None, True)) Some(key) -> capabilities.choices(preferences, kind, key) } } pub fn save( home: String, session: String, change: Change, extensions: fn() -> Result(Extensions, String), enabling: fn(String, String) -> Result(Nil, String), after: fn() -> Result(a, String), ) -> Result(a, String) { guarded_capability( home, session, fn() { use current_extensions <- result.try(extensions()) use snapshot <- result.try(inspect( change.workspace, home, session, current_extensions, )) case snapshot.revision == change.revision { False -> Error(stale_error) True -> { use row <- result.try( list.find(snapshot.candidates, fn(row) { row.id == change.id }) |> result.replace_error(stale_error), ) use key <- result.try(option.to_result( row.preference_key, "candidate has no validated preference key", )) case change.scope == "global" || change.scope == "session" { False -> Error("choose global or session scope") True -> case row.shadowed_by, row.valid, change.enabled { Some(_), _, _ -> Error("shadowed candidates cannot be changed") _, False, Some(True) -> Error("invalid candidates cannot be enabled") _, _, Some(True) -> enabling(row.kind, key) |> result.replace(#( row.kind, key, change.scope, change.enabled, )) _, _, _ -> Ok(#(row.kind, key, change.scope, change.enabled)) } } } } }, after, ) } pub fn to_json(snapshot: Snapshot) -> json.Json { json.object([ #("workspace", json.string(snapshot.workspace)), #("revision", json.string(snapshot.revision)), #( "extensions", json.object([ #("skills", json.bool(snapshot.extensions.skills)), #("instructions", json.bool(snapshot.extensions.instructions)), ]), ), #("diagnostics", json.array(snapshot.diagnostics, json.string)), #("candidates", json.array(snapshot.candidates, candidate_json)), ]) } fn candidate_json(row: Candidate) -> json.Json { json.object([ #("id", json.string(row.id)), #("kind", json.string(row.kind)), #("title", json.string(row.title)), #("description", json.nullable(row.description, json.string)), #("source", json.string(row.source)), #("resolved_source", json.nullable(row.resolved_source, json.string)), #("preference_key", json.nullable(row.preference_key, json.string)), #("valid", json.bool(row.valid)), #("diagnostic", json.nullable(row.diagnostic, json.string)), #("shadowed_by", json.nullable(row.shadowed_by, json.string)), #("global_preference", json.nullable(row.global_preference, json.bool)), #("session_override", json.nullable(row.session_override, json.bool)), #("effective_enabled", json.bool(row.effective_enabled)), #("eligible", json.bool(row.eligible)), ]) } @external(erlang, "albedo_capability_catalog", "fingerprint") fn fingerprint(value: a) -> String @external(erlang, "albedo_settings_store", "catalog_capability") fn guarded_capability( home: String, session: String, resolve: fn() -> Result(#(String, String, String, Option(Bool)), String), after: fn() -> Result(a, String), ) -> Result(a, String) @external(erlang, "albedo_capability_catalog", "source_title") fn source_title(source: String) -> String