Class: Insika::SkillCatalog

Inherits:
Object
  • Object
show all
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

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)

#reloadObject

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