Class: Insika::SkillCatalog
- Inherits:
-
Object
- Object
- Insika::SkillCatalog
- Defined in:
- lib/insika/skill_catalog.rb
Overview
OpenClaw / AgentSkills convention: each skill is a directory with a SKILL.md (YAML frontmatter + markdown body). Progressive disclosure: level 1 = name+description in the system prompt; level 2 = body loaded on demand by the load_skill tool.
Consumed by the Executor (skill_catalog:) and by stage 3 (effective/format_for_prompt).
Defined Under Namespace
Classes: Skill
Instance Method Summary collapse
-
#all(agent: nil) ⇒ Object
agent(an agent id) resolves the AGENT SCOPE first, then the shared one — the same precedence chain the catalog already runs for store-over-disk and workspace-over-managed-over-bundled, with one more dimension. -
#eager_for(profile) ⇒ Object
THE single definition of "always in the prompt", consulted by all three surfaces that must agree: the body provider (injects these), the level-1 catalog (hides them) and load_skill (refuses them).
-
#effective(skills_policy, agent: nil) ⇒ Object
Per-agent allowlist: nil -> all | [] -> none | [names] -> subset.
- #find(name, agent: nil) ⇒ Object
-
#format_for_prompt(skills = all) ⇒ Object
Level 1: compact list injected into the system prompt.
-
#initialize(roots, store: nil) ⇒ SkillCatalog
constructor
roots ordered by PRECEDENCE (highest first): workspace, managed, bundled.
-
#lazy_for(profile) ⇒ Object
The complement: what the model still has to ASK for — and therefore what the level-1 list advertises and load_skill will serve.
-
#reload ⇒ Object
Reloads from disk + Store and SWAPS the index atomically: an authored/edited skill takes effect without a restart.
Constructor Details
#initialize(roots, store: nil) ⇒ SkillCatalog
roots ordered by PRECEDENCE (highest first): workspace, managed, bundled. Same name in more than one root: the first wins.
store (optional): a SkillStore with the skills AUTHORED in the Studio. They overlay the on-disk ones (seed) — the Store wins, it is the source of truth. Nil = disk-only behavior, zero regression.
31 32 33 34 35 |
# File 'lib/insika/skill_catalog.rb', line 31 def initialize(roots, store: nil) @roots = Array(roots) @store = store @skills, @agent_skills = load_all end |
Instance Method Details
#all(agent: nil) ⇒ Object
agent (an agent id) resolves the AGENT SCOPE first, then the shared one — the
same precedence chain the catalog already runs for store-over-disk and
workspace-over-managed-over-bundled, with one more dimension.
Three cases fall out of that one rule: SHARED (only the shared record exists), OVERRIDE (both exist, the agent's wins) and AGENT-PRIVATE (only the agent record exists — invisible elsewhere, and its name may collide freely).
Without agent the shared scope is all there is, which is what every caller
that has no agent in hand (the Studio's shared editor, a bare catalog) means.
47 48 49 50 51 52 53 |
# File 'lib/insika/skill_catalog.rb', line 47 def all(agent: nil) shared = @skills overrides = agent_scope(agent) return shared.values if overrides.empty? shared.merge(overrides).values end |
#eager_for(profile) ⇒ Object
THE single definition of "always in the prompt", consulted by all three surfaces that must agree: the body provider (injects these), the level-1 catalog (hides them) and load_skill (refuses them). Split the rule across three files and they drift — which is the failure this whole feature came from.
profile.skills_eager — a PER-AGENT decision, so a shared skill stays shared:
nil | false -> none (progressive disclosure; the default)
true -> every allowed skill (blanket; only for a corpus that fits the budget)
[names] -> exactly these
Deliberately NOT Allowlist.filter: there nil means ALL, which is the safe
default for skills/tools_allow where nil is "no policy". Here nil must mean
NONE — an unconfigured agent waking up with every skill body on every turn is
the opposite of a safe default. A name that is not in the agent's skills
allowlist is a silent no-op here (the intersection with effective); doctor
flags it, because the operator who wrote the name meant it.
90 91 92 93 94 95 96 97 98 |
# File 'lib/insika/skill_catalog.rb', line 90 def eager_for(profile) allowed = effective(profile.skills, agent: profile.id) spec = profile.skills_eager return allowed if blanket?(spec) return [] if spec.nil? || spec == false names = Array(spec).map { |n| n.to_s.strip } allowed.select { |s| names.include?(s.name) } end |
#effective(skills_policy, agent: nil) ⇒ Object
Per-agent allowlist: nil -> all | [] -> none | [names] -> subset. agent
selects WHICH body each allowed name resolves to (see #find); the allowlist is
by NAME either way, so specializing a skill never touches the allowlist.
70 71 72 |
# File 'lib/insika/skill_catalog.rb', line 70 def effective(skills_policy, agent: nil) Allowlist.filter(all(agent: agent), skills_policy) { |s| s.name } end |
#find(name, agent: nil) ⇒ Object
55 56 57 |
# File 'lib/insika/skill_catalog.rb', line 55 def find(name, agent: nil) agent_scope(agent)[name.to_s] || @skills[name.to_s] end |
#format_for_prompt(skills = all) ⇒ Object
Level 1: compact list injected into the system prompt. Metadata only. Receives the set already filtered by the agent.
when= carries the skill's triggers: — THE ROUTING TABLE, GENERATED. What
actually made activation reliable on the pilot was a hand-written companion file
listing each skill with its trigger phrases, and nothing checked it against the
catalog: a skill created at 11:28 was invisible to a table written the day
before, and the model obeyed the table. Rendering the same information from the
catalog means it cannot disagree with the allowlist — a newly allowed skill
appears the moment it is allowed. Detecting that drift would have been strictly
worse than removing its source.
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 |
# File 'lib/insika/skill_catalog.rb', line 115 def format_for_prompt(skills = all) return "" if skills.empty? entries = skills.map do |s| when_attr = Array(s.triggers).empty? ? "" : %( when="#{Array(s.triggers).join('; ')}") %( <skill name="#{s.name}"#{when_attr}>#{s.description}</skill>) end.join("\n") <<~PROMPT.strip <available_skills> #{entries} </available_skills> Before ANY reply or tool call: scan the skills above. If one matches or is even partially relevant to the task, you MUST call `load_skill("name")` FIRST and follow what it returns. Err on the side of loading. Only skip when genuinely none apply. PROMPT end |
#lazy_for(profile) ⇒ Object
The complement: what the model still has to ASK for — and therefore what the level-1 list advertises and load_skill will serve.
102 |
# File 'lib/insika/skill_catalog.rb', line 102 def lazy_for(profile) = effective(profile.skills, agent: profile.id) - eager_for(profile) |
#reload ⇒ Object
Reloads from disk + Store and SWAPS the index atomically: an authored/edited skill takes effect without a restart. A turn in progress captured @skills at dispatch, so it does not see the swap mid-flight.
62 63 64 65 |
# File 'lib/insika/skill_catalog.rb', line 62 def reload @skills, @agent_skills = load_all self end |