Class: Clacky::AgentProfile

Inherits:
Object
  • Object
show all
Defined in:
lib/clacky/agent_profile.rb

Overview

Loads and represents an agent profile (system prompt + skill whitelist).

Lookup order for a profile named "coding":

1. ~/.clacky/agents/coding/                (user override, physical dir)
2. extension agent unit with id == "coding" (ext.yml contributes.agents)

Each user profile directory (opt-in override) contains:

- profile.yml       — name, description, skills whitelist
- system_prompt.md  — agent-specific system prompt content

Global files (shared across all agents) are user-only overrides:

- ~/.clacky/agents/SOUL.md   — agent personality/values (else DEFAULT_SOUL)
- ~/.clacky/agents/USER.md   — user profile info        (else DEFAULT_USER)

The universal behavioural rules (todo manager, tool usage, response style, etc.) live in a bundled resource file at lib/clacky/prompts/base.md.

Constant Summary collapse

USER_AGENTS_DIR =
File.expand_path("~/.clacky/agents").freeze
BASE_PROMPT_PATH =
File.expand_path("../prompts/base.md", __FILE__).freeze
DEFAULT_SOUL =
<<~MD.freeze
  You are calm, precise, and helpful. You communicate clearly and concisely.
  You are honest about uncertainty and ask for clarification when needed.
  You take initiative but respect the user's preferences and decisions.
MD
DEFAULT_USER =
"(No user profile configured yet. To personalize, create ~/.clacky/agents/USER.md)"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name) ⇒ AgentProfile

Returns a new instance of AgentProfile.



35
36
37
38
39
40
41
42
43
44
45
46
# File 'lib/clacky/agent_profile.rb', line 35

def initialize(name)
  @name = name.to_s
  result = ExtensionLoader.last_result
  @ext_unit = result&.agents&.find { |u| u.id == @name }
  if @ext_unit.nil?
    result = ExtensionLoader.load_all(force: true)
    @ext_unit = result&.agents&.find { |u| u.id == @name }
  end
  profile_data = load_profile_yml
  @description = profile_data["description"] || ""
  @system_prompt_content = load_agent_file("system_prompt.md")
end

Instance Attribute Details

#descriptionObject (readonly)

Returns the value of attribute description.



33
34
35
# File 'lib/clacky/agent_profile.rb', line 33

def description
  @description
end

#nameObject (readonly)

Returns the value of attribute name.



33
34
35
# File 'lib/clacky/agent_profile.rb', line 33

def name
  @name
end

Class Method Details

.all(recency: {}) ⇒ Array<Hash>

List all available agent profiles across user + extension layers. Precedence on id collision: user override → extension unit.

Parameters:

  • recency (Hash<String, Integer>) (defaults to: {})

    agent id => epoch of last use, used to order third-party extension agents by most-recently-used instead of their static order field.

Returns:

  • (Array<Hash>)

    each: { id:, title:, title_zh:, description:, description_zh:, source:, order:, layer:, author:, avatar: }



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/clacky/agent_profile.rb', line 60

def self.all(recency: {})
  out = {}

  add = lambda do |id, title, title_zh, description, description_zh, source, order, layer, author, avatar|
    next if id.nil? || id.empty?
    out[id] = {
      id: id,
      title: title,
      title_zh: title_zh,
      description: description,
      description_zh: description_zh,
      source: source,
      order: order,
      layer: layer,
      author: author,
      avatar: avatar,
    }
  end

  ext_result = ExtensionLoader.last_result || ExtensionLoader.load_all
  ext_result&.agents&.each do |unit|
    spec = unit.spec || {}
    next if spec["hidden"]
    title = spec["title"].to_s
    title = unit.id if title.empty?
    avatar = spec["avatar_abs"].to_s.empty? ? nil : "/agent_avatar/#{unit.id}"
    add.call(
      unit.id, title, spec["title_zh"].to_s,
      spec["description"].to_s, spec["description_zh"].to_s,
      "extension", spec["order"], unit.layer.to_s,
      spec["author"].to_s, avatar
    )
  end

  Dir.glob(File.join(USER_AGENTS_DIR, "*")).sort.each do |path|
    next unless File.directory?(path)
    id = File.basename(path)
    next if id.start_with?("_")
    next unless File.file?(File.join(path, "profile.yml"))
    meta = read_profile_yml(File.join(path, "profile.yml"))
    user_avatar = File.file?(File.join(path, "avatar.png")) ? "/agent_avatar/#{id}" : nil
    add.call(
      id, meta["title"] || meta["name"] || id, meta["title_zh"].to_s,
      meta["description"].to_s, meta["description_zh"].to_s,
      "user", meta["order"], "user",
      meta["author"].to_s.empty? ? "You" : meta["author"].to_s, user_avatar
    )
  end

  # Builtin agents always come first, then user overrides, then third-party
  # extension agents — so the "start new session" grid keeps the official
  # agents grouped ahead of anything the user installed from the market.
  # Within the third-party group, agents are ordered by most-recently-used
  # first (via `recency`), falling back to their static `order` field for
  # agents that have never been used yet.
  layer_rank = lambda do |a|
    next 0 if a[:layer] == "builtin"
    next 1 if a[:source] == "user"

    2
  end
  out.values.sort_by do |a|
    rank = layer_rank.call(a)
    if rank == 2
      [rank, -(recency[a[:id]] || 0), a[:order] || 999, a[:id]]
    else
      [rank, a[:order] || 999, a[:id]]
    end
  end
end

.load(name) ⇒ AgentProfile

Parameters:

  • name (String, Symbol)

    profile name (e.g. "coding", "general")

Returns:



50
51
52
# File 'lib/clacky/agent_profile.rb', line 50

def self.load(name)
  new(name)
end

Instance Method Details

#base_promptString

Returns base prompt shared by all agents (bundled resource).

Returns:

  • (String)

    base prompt shared by all agents (bundled resource)



144
145
146
147
# File 'lib/clacky/agent_profile.rb', line 144

def base_prompt
  return "" unless File.file?(BASE_PROMPT_PATH)
  File.read(BASE_PROMPT_PATH).strip
end

#soulString

Returns soul content (user override, else default).

Returns:

  • (String)

    soul content (user override, else default)



150
151
152
153
154
155
156
157
# File 'lib/clacky/agent_profile.rb', line 150

def soul
  user_path = File.join(USER_AGENTS_DIR, "SOUL.md")
  if File.exist?(user_path) && !File.zero?(user_path)
    File.read(user_path).strip
  else
    DEFAULT_SOUL.strip
  end
end

#system_promptString

Returns agent-specific system prompt content.

Returns:

  • (String)

    agent-specific system prompt content



139
140
141
# File 'lib/clacky/agent_profile.rb', line 139

def system_prompt
  @system_prompt_content
end

#user_profileString

Returns user profile content (user override, else default).

Returns:

  • (String)

    user profile content (user override, else default)



160
161
162
163
164
165
166
167
# File 'lib/clacky/agent_profile.rb', line 160

def 
  user_path = File.join(USER_AGENTS_DIR, "USER.md")
  if File.exist?(user_path) && !File.zero?(user_path)
    File.read(user_path).strip
  else
    DEFAULT_USER
  end
end