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 Attribute Summary collapse
-
#store ⇒ Object
readonly
The SkillStore the catalog overlays — the composition root hands it to the harvest (the dedup reads the AUTHORED skills the catalog serves).
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 Attribute Details
#store ⇒ Object (readonly)
The SkillStore the catalog overlays — the composition root hands it to the harvest (the dedup reads the AUTHORED skills the catalog serves).
39 40 41 |
# File 'lib/insika/skill_catalog.rb', line 39 def store @store 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.
51 52 53 54 55 56 57 |
# File 'lib/insika/skill_catalog.rb', line 51 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.
94 95 96 97 98 99 100 101 102 |
# File 'lib/insika/skill_catalog.rb', line 94 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.
74 75 76 |
# File 'lib/insika/skill_catalog.rb', line 74 def effective(skills_policy, agent: nil) Allowlist.filter(all(agent: agent), skills_policy) { |s| s.name } end |
#find(name, agent: nil) ⇒ Object
59 60 61 |
# File 'lib/insika/skill_catalog.rb', line 59 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.
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 |
# File 'lib/insika/skill_catalog.rb', line 119 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.
106 |
# File 'lib/insika/skill_catalog.rb', line 106 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.
66 67 68 69 |
# File 'lib/insika/skill_catalog.rb', line 66 def reload @skills, @agent_skills = load_all self end |