anima-core 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +23 -26
- data/README.md +118 -104
- data/agents/thoughts-analyzer.md +12 -7
- data/anima-core.gemspec +1 -0
- data/app/channels/session_channel.rb +38 -58
- data/app/decorators/agent_message_decorator.rb +7 -2
- data/app/decorators/message_decorator.rb +31 -100
- data/app/decorators/pending_from_melete_decorator.rb +36 -0
- data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
- data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
- data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
- data/app/decorators/pending_from_mneme_decorator.rb +44 -0
- data/app/decorators/pending_message_decorator.rb +94 -0
- data/app/decorators/pending_subagent_decorator.rb +46 -0
- data/app/decorators/pending_tool_response_decorator.rb +51 -0
- data/app/decorators/pending_user_message_decorator.rb +22 -0
- data/app/decorators/system_message_decorator.rb +5 -0
- data/app/decorators/tool_call_decorator.rb +16 -5
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +7 -2
- data/app/jobs/count_tokens_job.rb +23 -0
- data/app/jobs/drain_job.rb +169 -0
- data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
- data/app/jobs/melete_enrichment_job.rb +48 -0
- data/app/jobs/mneme_enrichment_job.rb +46 -0
- data/app/jobs/tool_execution_job.rb +87 -0
- data/app/models/concerns/token_estimation.rb +54 -0
- data/app/models/goal.rb +23 -11
- data/app/models/message.rb +46 -48
- data/app/models/pending_message.rb +407 -12
- data/app/models/pinned_message.rb +8 -3
- data/app/models/session.rb +660 -566
- data/app/models/snapshot.rb +11 -21
- data/bin/inspect-cassette +157 -0
- data/bin/release +212 -0
- data/bin/with-llms +20 -0
- data/config/application.rb +1 -0
- data/config/database.yml +1 -0
- data/config/initializers/event_subscribers.rb +71 -4
- data/config/initializers/inflections.rb +3 -1
- data/db/cable_structure.sql +9 -0
- data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
- data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
- data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
- data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
- data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
- data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
- data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
- data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
- data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
- data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
- data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
- data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
- data/db/queue_structure.sql +61 -0
- data/db/structure.sql +133 -0
- data/lib/agents/registry.rb +1 -1
- data/lib/anima/cli.rb +41 -13
- data/lib/anima/installer.rb +13 -0
- data/lib/anima/settings.rb +16 -36
- data/lib/anima/version.rb +1 -1
- data/lib/events/authentication_required.rb +24 -0
- data/lib/events/bounce_back.rb +4 -4
- data/lib/events/eviction_completed.rb +28 -0
- data/lib/events/goal_created.rb +28 -0
- data/lib/events/goal_updated.rb +32 -0
- data/lib/events/llm_responded.rb +35 -0
- data/lib/events/message_created.rb +27 -0
- data/lib/events/message_updated.rb +25 -0
- data/lib/events/session_state_changed.rb +30 -0
- data/lib/events/skill_activated.rb +28 -0
- data/lib/events/start_melete.rb +36 -0
- data/lib/events/start_mneme.rb +33 -0
- data/lib/events/start_processing.rb +32 -0
- data/lib/events/subagent_evicted.rb +31 -0
- data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
- data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
- data/lib/events/subscribers/drain_kickoff.rb +20 -0
- data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
- data/lib/events/subscribers/llm_response_handler.rb +111 -0
- data/lib/events/subscribers/melete_kickoff.rb +24 -0
- data/lib/events/subscribers/message_broadcaster.rb +34 -0
- data/lib/events/subscribers/mneme_kickoff.rb +24 -0
- data/lib/events/subscribers/mneme_scheduler.rb +21 -0
- data/lib/events/subscribers/persister.rb +8 -9
- data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
- data/lib/events/subscribers/subagent_message_router.rb +28 -34
- data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
- data/lib/events/subscribers/tool_response_creator.rb +33 -0
- data/lib/events/subscribers/transient_broadcaster.rb +1 -1
- data/lib/events/tool_executed.rb +34 -0
- data/lib/events/workflow_activated.rb +27 -0
- data/lib/llm/client.rb +46 -199
- data/lib/mcp/client_manager.rb +41 -46
- data/lib/mcp/stdio_transport.rb +9 -5
- data/lib/{analytical_brain → melete}/runner.rb +73 -68
- data/lib/{analytical_brain → melete}/tools/activate_skill.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/finish_goal.rb +6 -3
- data/lib/melete/tools/goal_messaging.rb +29 -0
- data/lib/{analytical_brain → melete}/tools/read_workflow.rb +4 -4
- data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/set_goal.rb +6 -2
- data/lib/{analytical_brain → melete}/tools/update_goal.rb +9 -5
- data/lib/{analytical_brain.rb → melete.rb} +6 -3
- data/lib/mneme/base_runner.rb +121 -0
- data/lib/mneme/l2_runner.rb +14 -20
- data/lib/mneme/recall_runner.rb +132 -0
- data/lib/mneme/runner.rb +123 -165
- data/lib/mneme/search.rb +104 -62
- data/lib/mneme/tools/nothing_to_surface.rb +25 -0
- data/lib/mneme/tools/save_snapshot.rb +2 -10
- data/lib/mneme/tools/surface_memory.rb +89 -0
- data/lib/mneme.rb +11 -5
- data/lib/providers/anthropic.rb +112 -7
- data/lib/shell_session.rb +290 -432
- data/lib/skills/definition.rb +2 -2
- data/lib/skills/registry.rb +1 -1
- data/lib/tools/base.rb +16 -1
- data/lib/tools/bash.rb +25 -55
- data/lib/tools/edit.rb +2 -0
- data/lib/tools/mark_goal_completed.rb +4 -5
- data/lib/tools/read.rb +2 -0
- data/lib/tools/registry.rb +85 -4
- data/lib/tools/response_truncator.rb +1 -1
- data/lib/tools/{recall.rb → search_messages.rb} +19 -21
- data/lib/tools/spawn_specialist.rb +22 -14
- data/lib/tools/spawn_subagent.rb +30 -20
- data/lib/tools/subagent_prompts.rb +17 -19
- data/lib/tools/think.rb +1 -1
- data/lib/tools/{remember.rb → view_messages.rb} +10 -10
- data/lib/tools/write.rb +2 -0
- data/lib/tui/app.rb +393 -149
- data/lib/tui/braille_spinner.rb +7 -7
- data/lib/tui/cable_client.rb +9 -16
- data/lib/tui/decorators/base_decorator.rb +47 -6
- data/lib/tui/decorators/bash_decorator.rb +1 -1
- data/lib/tui/decorators/edit_decorator.rb +4 -2
- data/lib/tui/decorators/read_decorator.rb +4 -2
- data/lib/tui/decorators/think_decorator.rb +2 -2
- data/lib/tui/decorators/web_get_decorator.rb +1 -1
- data/lib/tui/decorators/write_decorator.rb +4 -2
- data/lib/tui/flash.rb +19 -14
- data/lib/tui/formatting.rb +20 -9
- data/lib/tui/input_buffer.rb +6 -6
- data/lib/tui/message_store.rb +165 -28
- data/lib/tui/performance_logger.rb +2 -3
- data/lib/tui/screens/chat.rb +149 -79
- data/lib/tui/settings.rb +93 -0
- data/lib/workflows/definition.rb +3 -3
- data/lib/workflows/registry.rb +1 -1
- data/skills/github.md +38 -0
- data/templates/config.toml +16 -32
- data/templates/tui.toml +209 -0
- data/workflows/review_pr.md +18 -14
- metadata +98 -29
- data/app/jobs/agent_request_job.rb +0 -199
- data/app/jobs/analytical_brain_job.rb +0 -33
- data/app/jobs/count_message_tokens_job.rb +0 -39
- data/app/jobs/passive_recall_job.rb +0 -29
- data/app/models/concerns/message/broadcasting.rb +0 -85
- data/config/initializers/fts5_schema_dump.rb +0 -21
- data/lib/agent_loop.rb +0 -186
- data/lib/analytical_brain/tools/deactivate_skill.rb +0 -39
- data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -34
- data/lib/environment_probe.rb +0 -232
- data/lib/events/agent_message.rb +0 -11
- data/lib/events/subscribers/message_collector.rb +0 -64
- data/lib/events/tool_call.rb +0 -31
- data/lib/events/tool_response.rb +0 -33
- data/lib/mneme/compressed_viewport.rb +0 -200
- data/lib/mneme/passive_recall.rb +0 -69
data/lib/environment_probe.rb
DELETED
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "etc"
|
|
4
|
-
require "open3"
|
|
5
|
-
require "timeout"
|
|
6
|
-
require "json"
|
|
7
|
-
require "pathname"
|
|
8
|
-
require "uri"
|
|
9
|
-
|
|
10
|
-
# Probes the shell environment and assembles a lightweight metadata block
|
|
11
|
-
# for injection into the system prompt. Gives the agent awareness of its
|
|
12
|
-
# working directory, OS, Git status, and nearby project files — without
|
|
13
|
-
# loading any file content.
|
|
14
|
-
#
|
|
15
|
-
# @example
|
|
16
|
-
# EnvironmentProbe.to_prompt("/home/user/projects/my-app")
|
|
17
|
-
# # => "## Environment\n\nOS: Arch Linux (pacman, yay)\n..."
|
|
18
|
-
class EnvironmentProbe
|
|
19
|
-
# Assembles the environment context block for a given working directory.
|
|
20
|
-
#
|
|
21
|
-
# @param pwd [String, nil] current working directory
|
|
22
|
-
# @return [String, nil] Markdown-formatted environment block, or nil when pwd is unknown
|
|
23
|
-
def self.to_prompt(pwd)
|
|
24
|
-
new(pwd).to_prompt
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# @param pwd [String, nil] current working directory
|
|
28
|
-
def initialize(pwd)
|
|
29
|
-
@pwd = pwd
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# @return [String, nil] Markdown-formatted environment block
|
|
33
|
-
def to_prompt
|
|
34
|
-
return unless @pwd
|
|
35
|
-
|
|
36
|
-
sections = [os_section, working_directory_section, project_files_section].compact
|
|
37
|
-
return if sections.empty?
|
|
38
|
-
|
|
39
|
-
"## Environment\n\n#{sections.join("\n\n")}"
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
private
|
|
43
|
-
|
|
44
|
-
# @return [String] OS name with package manager hint
|
|
45
|
-
def os_section
|
|
46
|
-
sysname = Etc.uname[:sysname]
|
|
47
|
-
"OS: #{format_os(sysname)}"
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# @param sysname [String] kernel name from uname (e.g. "Linux", "Darwin")
|
|
51
|
-
# @return [String] human-readable OS description
|
|
52
|
-
def format_os(sysname)
|
|
53
|
-
case sysname
|
|
54
|
-
when "Linux"
|
|
55
|
-
distro = detect_linux_distro || "Linux"
|
|
56
|
-
pkg = detect_package_manager
|
|
57
|
-
pkg ? "#{distro} (#{pkg})" : distro
|
|
58
|
-
when "Darwin"
|
|
59
|
-
"macOS (Homebrew)"
|
|
60
|
-
else
|
|
61
|
-
sysname
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Reads PRETTY_NAME from /etc/os-release.
|
|
66
|
-
#
|
|
67
|
-
# @return [String, nil] distro name, or nil on non-Linux / missing file
|
|
68
|
-
def detect_linux_distro
|
|
69
|
-
return unless File.exist?("/etc/os-release")
|
|
70
|
-
|
|
71
|
-
File.foreach("/etc/os-release") do |line|
|
|
72
|
-
if line.start_with?("PRETTY_NAME=")
|
|
73
|
-
return line.split("=", 2).last.strip.delete('"')
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
nil
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# Returns the primary package manager(s) for the current system.
|
|
80
|
-
# Arch-based systems list both pacman and yay when present;
|
|
81
|
-
# other families return the first match.
|
|
82
|
-
#
|
|
83
|
-
# @return [String, nil] comma-separated package manager names
|
|
84
|
-
def detect_package_manager
|
|
85
|
-
managers = []
|
|
86
|
-
managers << "pacman" if File.exist?("/usr/bin/pacman")
|
|
87
|
-
managers << "yay" if File.exist?("/usr/bin/yay")
|
|
88
|
-
return managers.join(", ") if managers.any?
|
|
89
|
-
|
|
90
|
-
return "apt" if File.exist?("/usr/bin/apt")
|
|
91
|
-
return "dnf" if File.exist?("/usr/bin/dnf")
|
|
92
|
-
return "Homebrew" if File.exist?("/opt/homebrew/bin/brew") || File.exist?("/usr/local/bin/brew")
|
|
93
|
-
|
|
94
|
-
nil
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
# @return [String] CWD line plus optional Git metadata
|
|
98
|
-
def working_directory_section
|
|
99
|
-
lines = ["CWD: #{@pwd}"]
|
|
100
|
-
append_git_lines(lines)
|
|
101
|
-
lines.join("\n")
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# Appends Git metadata lines (remote, branch, PR) to the output array.
|
|
105
|
-
#
|
|
106
|
-
# @param lines [Array<String>] accumulator for output lines
|
|
107
|
-
# @return [void]
|
|
108
|
-
def append_git_lines(lines)
|
|
109
|
-
git = detect_git
|
|
110
|
-
return unless git
|
|
111
|
-
|
|
112
|
-
remote = git[:remote]
|
|
113
|
-
branch = git[:branch]
|
|
114
|
-
pr_number = git[:pr_number]
|
|
115
|
-
|
|
116
|
-
lines << "Git: #{git[:repo_name]} (#{remote})" if remote
|
|
117
|
-
lines << "Branch: #{branch}" if branch
|
|
118
|
-
lines << "PR: ##{pr_number} (#{git[:pr_state]})" if pr_number
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
# Detects Git repo metadata: remote, branch, and open PR.
|
|
122
|
-
#
|
|
123
|
-
# @return [Hash{Symbol => String}, nil] keys: :remote, :repo_name, :branch,
|
|
124
|
-
# and optionally :pr_number (Integer) and :pr_state (String); nil when not in a repo
|
|
125
|
-
def detect_git
|
|
126
|
-
_, status = Open3.capture2("git", "-C", @pwd, "rev-parse", "--is-inside-work-tree", err: File::NULL)
|
|
127
|
-
return unless status.success?
|
|
128
|
-
|
|
129
|
-
info = {}
|
|
130
|
-
detect_git_remote(info)
|
|
131
|
-
detect_git_branch(info)
|
|
132
|
-
info
|
|
133
|
-
rescue Errno::ENOENT
|
|
134
|
-
nil
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# Populates :remote and :repo_name on the info hash.
|
|
138
|
-
def detect_git_remote(info)
|
|
139
|
-
remote, = Open3.capture2("git", "-C", @pwd, "remote", "get-url", "origin", err: File::NULL)
|
|
140
|
-
remote = remote.strip
|
|
141
|
-
return unless remote.present?
|
|
142
|
-
|
|
143
|
-
info[:remote] = remote
|
|
144
|
-
info[:repo_name] = extract_repo_name(remote)
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
# Populates :branch, :pr_number, and :pr_state on the info hash.
|
|
148
|
-
def detect_git_branch(info)
|
|
149
|
-
branch, = Open3.capture2("git", "-C", @pwd, "rev-parse", "--abbrev-ref", "HEAD", err: File::NULL)
|
|
150
|
-
branch = branch.strip
|
|
151
|
-
return unless branch.present?
|
|
152
|
-
|
|
153
|
-
info[:branch] = branch
|
|
154
|
-
pr = detect_pr(branch)
|
|
155
|
-
info.merge!(pr) if pr
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# Extracts owner/repo from a Git remote URL.
|
|
159
|
-
#
|
|
160
|
-
# @param remote_url [String] SSH or HTTPS remote URL
|
|
161
|
-
# @return [String] "owner/repo" path, or the raw URL when parsing fails
|
|
162
|
-
def extract_repo_name(remote_url)
|
|
163
|
-
path = if remote_url.match?(%r{\A\w+://})
|
|
164
|
-
URI.parse(remote_url).path
|
|
165
|
-
else
|
|
166
|
-
# SSH format: git@host:owner/repo.git
|
|
167
|
-
remote_url.split(":").last
|
|
168
|
-
end
|
|
169
|
-
path.delete_prefix("/").delete_suffix(".git")
|
|
170
|
-
rescue URI::InvalidURIError
|
|
171
|
-
remote_url
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
# Queries GitHub for an open PR on the given branch via the gh CLI.
|
|
175
|
-
#
|
|
176
|
-
# @param branch [String] branch name
|
|
177
|
-
# @return [Hash, nil] with :pr_number and :pr_state, or nil
|
|
178
|
-
# @note Returns nil on timeout, missing gh CLI, or JSON parse errors
|
|
179
|
-
def detect_pr(branch)
|
|
180
|
-
Timeout.timeout(Anima::Settings.web_request_timeout) do
|
|
181
|
-
output, status = Open3.capture2(
|
|
182
|
-
"gh", "pr", "list", "--head", branch,
|
|
183
|
-
"--json", "number,state", "--limit", "1",
|
|
184
|
-
chdir: @pwd, err: File::NULL
|
|
185
|
-
)
|
|
186
|
-
return unless status.success?
|
|
187
|
-
|
|
188
|
-
pr = JSON.parse(output).first
|
|
189
|
-
return unless pr
|
|
190
|
-
|
|
191
|
-
{pr_number: pr["number"], pr_state: pr["state"].downcase}
|
|
192
|
-
end
|
|
193
|
-
rescue Timeout::Error, Errno::ENOENT, JSON::ParserError
|
|
194
|
-
nil
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
# Scans for well-known project files up to a configurable depth.
|
|
198
|
-
#
|
|
199
|
-
# @return [String, nil] project files section, or nil when none found
|
|
200
|
-
def project_files_section
|
|
201
|
-
found = scan_project_files
|
|
202
|
-
return if found.empty?
|
|
203
|
-
|
|
204
|
-
header = "Project files that may contain useful context:"
|
|
205
|
-
entries = found.map { |path| "- #{path}" }
|
|
206
|
-
[header, *entries, "Use read_file to examine these when needed."].join("\n")
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
# Scans the working directory for whitelisted filenames.
|
|
210
|
-
#
|
|
211
|
-
# @return [Array<String>] sorted relative paths
|
|
212
|
-
def scan_project_files
|
|
213
|
-
base = Pathname.new(@pwd)
|
|
214
|
-
|
|
215
|
-
glob_patterns.flat_map { |pattern| Dir.glob(pattern) }
|
|
216
|
-
.map { |full_path| Pathname.new(full_path).relative_path_from(base).to_s }
|
|
217
|
-
.sort
|
|
218
|
-
.uniq
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
# Builds glob patterns for each whitelisted filename at each depth level.
|
|
222
|
-
#
|
|
223
|
-
# @return [Array<String>] glob patterns
|
|
224
|
-
def glob_patterns
|
|
225
|
-
whitelist = Anima::Settings.project_files_whitelist
|
|
226
|
-
max_depth = Anima::Settings.project_files_max_depth
|
|
227
|
-
|
|
228
|
-
whitelist.product((0..max_depth).to_a).map do |filename, depth|
|
|
229
|
-
File.join(@pwd, Array.new(depth, "*"), filename)
|
|
230
|
-
end
|
|
231
|
-
end
|
|
232
|
-
end
|
data/lib/events/agent_message.rb
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Events
|
|
4
|
-
module Subscribers
|
|
5
|
-
# Collects chat-displayable events in-memory for the current session.
|
|
6
|
-
# Provides the message list that the TUI renders and the LLM client consumes.
|
|
7
|
-
#
|
|
8
|
-
# Only user_message and agent_message events are collected — system_message,
|
|
9
|
-
# tool_call, and tool_response are internal and not part of the chat display.
|
|
10
|
-
#
|
|
11
|
-
# @example
|
|
12
|
-
# collector = Events::Subscribers::MessageCollector.new
|
|
13
|
-
# Events::Bus.subscribe(collector)
|
|
14
|
-
# collector.messages # => [{role: "user", content: "hi"}, ...]
|
|
15
|
-
class MessageCollector
|
|
16
|
-
include Events::Subscriber
|
|
17
|
-
|
|
18
|
-
DISPLAYABLE_TYPES = %w[user_message agent_message].freeze
|
|
19
|
-
|
|
20
|
-
# Maps event types to LLM-compatible role identifiers
|
|
21
|
-
ROLE_MAP = {
|
|
22
|
-
"user_message" => "user",
|
|
23
|
-
"agent_message" => "assistant"
|
|
24
|
-
}.freeze
|
|
25
|
-
|
|
26
|
-
def initialize
|
|
27
|
-
@messages = []
|
|
28
|
-
@mutex = Mutex.new
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# @return [Array<Hash>] thread-safe copy of collected messages
|
|
32
|
-
def messages
|
|
33
|
-
@mutex.synchronize { @messages.dup }
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# Receives a Rails.event notification hash.
|
|
37
|
-
# @param event [Hash] with :payload containing :type and :content keys
|
|
38
|
-
def emit(event)
|
|
39
|
-
type = event.dig(:payload, :type)
|
|
40
|
-
return unless DISPLAYABLE_TYPES.include?(type)
|
|
41
|
-
|
|
42
|
-
content = event.dig(:payload, :content)
|
|
43
|
-
return if content.nil?
|
|
44
|
-
|
|
45
|
-
@mutex.synchronize do
|
|
46
|
-
@messages << {
|
|
47
|
-
role: ROLE_MAP.fetch(type),
|
|
48
|
-
content: content
|
|
49
|
-
}
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# Directly push a pre-built message hash (used for loading persisted events).
|
|
54
|
-
# @param message [Hash] with :role and :content keys
|
|
55
|
-
def messages_push(message)
|
|
56
|
-
@mutex.synchronize { @messages << message }
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def clear
|
|
60
|
-
@mutex.synchronize { @messages = [] }
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
end
|
data/lib/events/tool_call.rb
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Events
|
|
4
|
-
class ToolCall < Base
|
|
5
|
-
TYPE = "tool_call"
|
|
6
|
-
|
|
7
|
-
attr_reader :tool_name, :tool_input, :tool_use_id, :timeout
|
|
8
|
-
|
|
9
|
-
# @param content [String] human-readable description of the tool call
|
|
10
|
-
# @param tool_name [String] registered tool name (e.g. "web_get")
|
|
11
|
-
# @param tool_input [Hash] arguments passed to the tool
|
|
12
|
-
# @param tool_use_id [String] Anthropic-assigned ID for correlating call/result
|
|
13
|
-
# @param timeout [Integer] maximum seconds before the call is considered orphaned
|
|
14
|
-
# @param session_id [String, nil] optional session identifier
|
|
15
|
-
def initialize(content:, tool_name:, tool_input: {}, tool_use_id: nil, timeout: nil, session_id: nil)
|
|
16
|
-
super(content: content, session_id: session_id)
|
|
17
|
-
@tool_name = tool_name
|
|
18
|
-
@tool_input = tool_input
|
|
19
|
-
@tool_use_id = tool_use_id
|
|
20
|
-
@timeout = timeout
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def type
|
|
24
|
-
TYPE
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def to_h
|
|
28
|
-
super.merge(tool_name: tool_name, tool_input: tool_input, tool_use_id: tool_use_id, timeout: timeout)
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
data/lib/events/tool_response.rb
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Events
|
|
4
|
-
class ToolResponse < Base
|
|
5
|
-
TYPE = "tool_response"
|
|
6
|
-
|
|
7
|
-
attr_reader :tool_name, :success, :tool_use_id
|
|
8
|
-
|
|
9
|
-
# @param content [String] tool execution output
|
|
10
|
-
# @param tool_name [String] registered tool name
|
|
11
|
-
# @param success [Boolean] whether the tool executed successfully
|
|
12
|
-
# @param tool_use_id [String, nil] Anthropic-assigned ID for correlating call/result
|
|
13
|
-
# @param session_id [String, nil] optional session identifier
|
|
14
|
-
def initialize(content:, tool_name:, success: true, tool_use_id: nil, session_id: nil)
|
|
15
|
-
super(content: content, session_id: session_id)
|
|
16
|
-
@tool_name = tool_name
|
|
17
|
-
@success = success
|
|
18
|
-
@tool_use_id = tool_use_id
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def type
|
|
22
|
-
TYPE
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def success?
|
|
26
|
-
@success
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def to_h
|
|
30
|
-
super.merge(tool_name: tool_name, success: success, tool_use_id: tool_use_id)
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Mneme
|
|
4
|
-
# Builds a compressed viewport for Mneme's LLM context. Mneme sees
|
|
5
|
-
# conversation (user/agent messages and think events) but not mechanical
|
|
6
|
-
# execution (tool calls and responses). Tool calls are compressed to
|
|
7
|
-
# aggregate counters like `[4 tools called]`.
|
|
8
|
-
#
|
|
9
|
-
# The viewport is split into three zones separated by delimiters:
|
|
10
|
-
# - **Eviction zone** — messages about to leave the viewport (upper third)
|
|
11
|
-
# - **Middle zone** — messages in the middle of the viewport
|
|
12
|
-
# - **Recent zone** — the most recent messages (lower third)
|
|
13
|
-
#
|
|
14
|
-
# Zone boundaries are calculated WITH tool call tokens (they affect
|
|
15
|
-
# position), then tool calls are removed and replaced with counters.
|
|
16
|
-
#
|
|
17
|
-
# @example
|
|
18
|
-
# viewport = Mneme::CompressedViewport.new(session, token_budget: 60_000)
|
|
19
|
-
# viewport.render #=> "── EVICTION ZONE ──\nmessage 42 User: ..."
|
|
20
|
-
class CompressedViewport
|
|
21
|
-
ZONE_DELIMITERS = {
|
|
22
|
-
eviction: "── EVICTION ZONE (upper third) ──",
|
|
23
|
-
middle: "── MIDDLE ZONE ──",
|
|
24
|
-
recent: "── RECENT ZONE (lower third) ──"
|
|
25
|
-
}.freeze
|
|
26
|
-
|
|
27
|
-
# @param session [Session] the session to build viewport for
|
|
28
|
-
# @param token_budget [Integer] total tokens available for Mneme's viewport
|
|
29
|
-
# @param from_message_id [Integer, nil] start from this message ID (inclusive);
|
|
30
|
-
# when nil, uses the session's full viewport
|
|
31
|
-
def initialize(session, token_budget:, from_message_id: nil)
|
|
32
|
-
@session = session
|
|
33
|
-
@token_budget = token_budget
|
|
34
|
-
@from_message_id = from_message_id
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# Renders the compressed viewport as a string ready for Mneme's LLM context.
|
|
38
|
-
#
|
|
39
|
-
# @return [String] compressed viewport with zone delimiters
|
|
40
|
-
def render
|
|
41
|
-
return "" if messages.empty?
|
|
42
|
-
|
|
43
|
-
zones = split_into_zones(messages)
|
|
44
|
-
render_zones(zones)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# @return [Array<Message>] the raw messages selected for this viewport
|
|
48
|
-
def messages
|
|
49
|
-
@messages ||= fetch_messages
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
private
|
|
53
|
-
|
|
54
|
-
# Fetches messages within token budget, starting from from_message_id.
|
|
55
|
-
# Selects newest-first until budget exhausted, returns chronological.
|
|
56
|
-
# Caches per-message token costs in @message_costs for reuse by split_into_zones.
|
|
57
|
-
#
|
|
58
|
-
# @return [Array<Message>]
|
|
59
|
-
def fetch_messages
|
|
60
|
-
scope = @session.messages.context_messages
|
|
61
|
-
|
|
62
|
-
if @from_message_id
|
|
63
|
-
scope = scope.where("id >= ?", @from_message_id)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
selected = []
|
|
67
|
-
@message_costs = {}
|
|
68
|
-
remaining = @token_budget
|
|
69
|
-
|
|
70
|
-
scope.reorder(id: :desc).each do |message|
|
|
71
|
-
cost = message_token_cost(message)
|
|
72
|
-
break if cost > remaining && selected.any?
|
|
73
|
-
|
|
74
|
-
selected << message
|
|
75
|
-
@message_costs[message.id] = cost
|
|
76
|
-
remaining -= cost
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
selected.reverse
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# Splits messages into three zones by token count.
|
|
83
|
-
# Zone boundaries are calculated including ALL messages (tool calls count
|
|
84
|
-
# toward position), but zone assignment uses cumulative tokens.
|
|
85
|
-
#
|
|
86
|
-
# @return [Hash{Symbol => Array<Message>}] :eviction, :middle, :recent
|
|
87
|
-
def split_into_zones(messages)
|
|
88
|
-
costs = messages.map { |message| [message, @message_costs[message.id] || message_token_cost(message)] }
|
|
89
|
-
zone_size = costs.sum(&:last) / 3.0
|
|
90
|
-
|
|
91
|
-
result = {eviction: [], middle: [], recent: []}
|
|
92
|
-
cumulative = 0
|
|
93
|
-
|
|
94
|
-
costs.each do |message, cost|
|
|
95
|
-
cumulative += cost
|
|
96
|
-
result[zone_for_cumulative(cumulative, zone_size)] << message
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
result
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
# Renders zones with delimiters, compressing tool calls into counters.
|
|
103
|
-
#
|
|
104
|
-
# @param zones [Hash{Symbol => Array<Message>}]
|
|
105
|
-
# @return [String]
|
|
106
|
-
def render_zones(zones)
|
|
107
|
-
%i[eviction middle recent].flat_map { |name|
|
|
108
|
-
[ZONE_DELIMITERS[name], render_zone(zones[name])]
|
|
109
|
-
}.join("\n")
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# Determines which zone an event belongs to based on cumulative token position.
|
|
113
|
-
#
|
|
114
|
-
# @param cumulative [Numeric] cumulative token count including this event
|
|
115
|
-
# @param zone_size [Float] token count per zone (total / 3)
|
|
116
|
-
# @return [Symbol] :eviction, :middle, or :recent
|
|
117
|
-
def zone_for_cumulative(cumulative, zone_size)
|
|
118
|
-
if cumulative <= zone_size
|
|
119
|
-
:eviction
|
|
120
|
-
elsif cumulative <= zone_size * 2
|
|
121
|
-
:middle
|
|
122
|
-
else
|
|
123
|
-
:recent
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# Renders a single zone: conversation messages as full text, consecutive
|
|
128
|
-
# tool calls/responses compressed into `[N tools called]` counters.
|
|
129
|
-
# tool_response messages are intentionally silent — they affect zone boundaries
|
|
130
|
-
# via token cost but are not rendered; only tool_call messages increment the counter.
|
|
131
|
-
#
|
|
132
|
-
# @param zone_messages [Array<Message>]
|
|
133
|
-
# @return [String]
|
|
134
|
-
def render_zone(zone_messages)
|
|
135
|
-
lines = []
|
|
136
|
-
tool_count = 0
|
|
137
|
-
|
|
138
|
-
zone_messages.each do |message|
|
|
139
|
-
if conversation_message?(message) || think_message?(message)
|
|
140
|
-
lines << flush_tool_count(tool_count)
|
|
141
|
-
tool_count = 0
|
|
142
|
-
lines << render_message_line(message)
|
|
143
|
-
elsif message.message_type == "tool_call"
|
|
144
|
-
tool_count += 1
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
lines << flush_tool_count(tool_count)
|
|
149
|
-
lines.compact.join("\n")
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# @return [Boolean] true if message is a user/agent/system message
|
|
153
|
-
def conversation_message?(message)
|
|
154
|
-
message.message_type.in?(Message::CONVERSATION_TYPES)
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
# Think messages are tool_call messages with tool_name == "think".
|
|
158
|
-
# They carry the agent's reasoning and are treated as conversation.
|
|
159
|
-
#
|
|
160
|
-
# @return [Boolean]
|
|
161
|
-
def think_message?(message)
|
|
162
|
-
message.message_type == "tool_call" && message.payload["tool_name"] == Message::THINK_TOOL
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
ROLE_LABELS = {
|
|
166
|
-
"user_message" => "User",
|
|
167
|
-
"agent_message" => "Assistant",
|
|
168
|
-
"system_message" => "System"
|
|
169
|
-
}.freeze
|
|
170
|
-
|
|
171
|
-
# Renders a single message as a transcript line.
|
|
172
|
-
#
|
|
173
|
-
# @param message [Message]
|
|
174
|
-
# @return [String]
|
|
175
|
-
def render_message_line(message)
|
|
176
|
-
prefix = "message #{message.id}"
|
|
177
|
-
data = message.payload
|
|
178
|
-
if think_message?(message)
|
|
179
|
-
"#{prefix} Think: #{data.dig("tool_input", "thoughts")}"
|
|
180
|
-
else
|
|
181
|
-
"#{prefix} #{ROLE_LABELS.fetch(message.message_type)}: #{data["content"]}"
|
|
182
|
-
end
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
# Returns a tool count string if any tools were called, nil otherwise.
|
|
186
|
-
#
|
|
187
|
-
# @param count [Integer] number of tool calls to flush
|
|
188
|
-
# @return [String, nil]
|
|
189
|
-
def flush_tool_count(count)
|
|
190
|
-
return if count == 0
|
|
191
|
-
"[#{count} #{(count == 1) ? "tool" : "tools"} called]"
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
# @return [Integer] token cost using cached count or heuristic
|
|
195
|
-
def message_token_cost(message)
|
|
196
|
-
cached = message.token_count
|
|
197
|
-
(cached > 0) ? cached : message.estimate_tokens
|
|
198
|
-
end
|
|
199
|
-
end
|
|
200
|
-
end
|
data/lib/mneme/passive_recall.rb
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Mneme
|
|
4
|
-
# Passive recall — automatic memory surfacing triggered by Goal updates.
|
|
5
|
-
# When goals are created or updated, searches event history for related
|
|
6
|
-
# context and caches the results on the session for viewport injection.
|
|
7
|
-
#
|
|
8
|
-
# The agent never calls a tool; relevant memories appear automatically
|
|
9
|
-
# in the viewport between snapshots and the sliding window. This mirrors
|
|
10
|
-
# recognition memory in humans — context surfaces without conscious effort.
|
|
11
|
-
#
|
|
12
|
-
# @example Trigger after a goal update
|
|
13
|
-
# Mneme::PassiveRecall.new(session).call
|
|
14
|
-
class PassiveRecall
|
|
15
|
-
# @param session [Session] the session whose goals drive recall
|
|
16
|
-
def initialize(session)
|
|
17
|
-
@session = session
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# Searches event history using active goal descriptions as queries.
|
|
21
|
-
# Returns recall results suitable for viewport injection.
|
|
22
|
-
#
|
|
23
|
-
# @return [Array<Mneme::Search::Result>] deduplicated, relevance-sorted
|
|
24
|
-
def call
|
|
25
|
-
goals = @session.goals.active.root.includes(:sub_goals)
|
|
26
|
-
return [] if goals.empty?
|
|
27
|
-
|
|
28
|
-
search_terms = build_search_terms(goals)
|
|
29
|
-
return [] if search_terms.blank?
|
|
30
|
-
|
|
31
|
-
results = Mneme::Search.query(search_terms, limit: Anima::Settings.recall_max_results)
|
|
32
|
-
|
|
33
|
-
# Exclude events from the current session's viewport — no point recalling
|
|
34
|
-
# what the agent already sees.
|
|
35
|
-
viewport_ids = @session.viewport_message_ids.to_set
|
|
36
|
-
results.reject { |result| viewport_ids.include?(result.message_id) }
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
private
|
|
40
|
-
|
|
41
|
-
STOP_WORDS = Set.new(%w[
|
|
42
|
-
a an the is are was were be been being do does did
|
|
43
|
-
have has had in on at to for of and or but not with
|
|
44
|
-
this that it its by from as up out if about into
|
|
45
|
-
fix add create update remove implement check set get
|
|
46
|
-
]).freeze
|
|
47
|
-
|
|
48
|
-
# Extracts meaningful keywords from active goals and joins with OR.
|
|
49
|
-
# Stop words and generic verbs are stripped — they're too common to
|
|
50
|
-
# produce useful recall results.
|
|
51
|
-
#
|
|
52
|
-
# @param goals [ActiveRecord::Relation<Goal>]
|
|
53
|
-
# @return [String] FTS5 OR-joined keywords
|
|
54
|
-
def build_search_terms(goals)
|
|
55
|
-
descriptions = goals.flat_map { |goal|
|
|
56
|
-
[goal.description] + goal.sub_goals.reject(&:completed?).map(&:description)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
words = descriptions.join(" ")
|
|
60
|
-
.gsub(/[^a-zA-Z0-9\s-]/, "")
|
|
61
|
-
.downcase
|
|
62
|
-
.split
|
|
63
|
-
.uniq
|
|
64
|
-
.reject { |word| STOP_WORDS.include?(word) || word.length < 3 }
|
|
65
|
-
|
|
66
|
-
words.join(" OR ").truncate(500)
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|