anima-core 1.0.2 → 1.1.1
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/.gitattributes +1 -0
- data/.reek.yml +51 -0
- data/README.md +63 -29
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +30 -11
- data/app/decorators/tool_call_decorator.rb +32 -3
- data/app/decorators/tool_decorator.rb +57 -0
- data/app/decorators/tool_response_decorator.rb +12 -4
- data/app/decorators/web_get_tool_decorator.rb +102 -0
- data/app/jobs/agent_request_job.rb +93 -23
- data/app/jobs/mneme_job.rb +51 -0
- data/app/jobs/passive_recall_job.rb +29 -0
- data/app/models/concerns/event/broadcasting.rb +4 -0
- data/app/models/event.rb +10 -0
- data/app/models/goal.rb +27 -0
- data/app/models/goal_pinned_event.rb +11 -0
- data/app/models/pinned_event.rb +41 -0
- data/app/models/session.rb +402 -6
- data/app/models/snapshot.rb +76 -0
- data/bin/jobs +5 -0
- data/config/initializers/event_subscribers.rb +12 -3
- data/config/initializers/fts5_schema_dump.rb +21 -0
- data/config/queue.yml +0 -1
- data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
- data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
- data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
- data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
- data/lib/agent_loop.rb +63 -20
- data/lib/analytical_brain/runner.rb +158 -65
- data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
- data/lib/analytical_brain/tools/finish_goal.rb +6 -1
- data/lib/anima/cli.rb +32 -9
- data/lib/anima/installer.rb +11 -24
- data/lib/anima/settings.rb +59 -0
- data/lib/anima/spinner.rb +75 -0
- data/lib/anima/version.rb +1 -1
- data/lib/environment_probe.rb +4 -4
- data/lib/events/bounce_back.rb +37 -0
- data/lib/events/subscribers/persister.rb +19 -0
- data/lib/events/subscribers/subagent_message_router.rb +102 -0
- data/lib/events/subscribers/transient_broadcaster.rb +36 -0
- data/lib/events/tool_call.rb +5 -3
- data/lib/llm/client.rb +19 -9
- data/lib/mneme/compressed_viewport.rb +200 -0
- data/lib/mneme/l2_runner.rb +138 -0
- data/lib/mneme/passive_recall.rb +69 -0
- data/lib/mneme/runner.rb +254 -0
- data/lib/mneme/search.rb +150 -0
- data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
- data/lib/mneme/tools/everything_ok.rb +24 -0
- data/lib/mneme/tools/save_snapshot.rb +68 -0
- data/lib/mneme.rb +29 -0
- data/lib/providers/anthropic.rb +57 -13
- data/lib/shell_session.rb +194 -63
- data/lib/tasks/fts5.rake +6 -0
- data/lib/tools/base.rb +2 -1
- data/lib/tools/bash.rb +4 -2
- data/lib/tools/registry.rb +22 -3
- data/lib/tools/remember.rb +179 -0
- data/lib/tools/request_feature.rb +3 -1
- data/lib/tools/spawn_specialist.rb +21 -9
- data/lib/tools/spawn_subagent.rb +22 -11
- data/lib/tools/subagent_prompts.rb +20 -3
- data/lib/tools/web_get.rb +21 -10
- data/lib/tui/app.rb +222 -125
- data/lib/tui/decorators/base_decorator.rb +165 -0
- data/lib/tui/decorators/bash_decorator.rb +20 -0
- data/lib/tui/decorators/edit_decorator.rb +19 -0
- data/lib/tui/decorators/read_decorator.rb +24 -0
- data/lib/tui/decorators/think_decorator.rb +36 -0
- data/lib/tui/decorators/web_get_decorator.rb +19 -0
- data/lib/tui/decorators/write_decorator.rb +19 -0
- data/lib/tui/flash.rb +139 -0
- data/lib/tui/formatting.rb +28 -0
- data/lib/tui/height_map.rb +93 -0
- data/lib/tui/message_store.rb +97 -8
- data/lib/tui/performance_logger.rb +90 -0
- data/lib/tui/screens/chat.rb +358 -133
- data/templates/config.toml +47 -0
- data/templates/soul.md +1 -1
- metadata +83 -4
- data/CHANGELOG.md +0 -80
- data/Gemfile +0 -17
- data/lib/tools/return_result.rb +0 -81
data/lib/mneme/search.rb
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mneme
|
|
4
|
+
# Full-text search over event history using SQLite FTS5.
|
|
5
|
+
# Covers user messages, agent messages, and think events across all sessions.
|
|
6
|
+
#
|
|
7
|
+
# The interface is intentionally abstract — callers receive {Result} structs
|
|
8
|
+
# and never touch FTS5 directly. A future semantic search backend (embeddings,
|
|
9
|
+
# BM25 + re-ranking) can replace the implementation without changing callers.
|
|
10
|
+
#
|
|
11
|
+
# @example Search across all sessions
|
|
12
|
+
# results = Mneme::Search.query("authentication flow")
|
|
13
|
+
# results.each { |r| puts "event #{r.event_id}: #{r.snippet}" }
|
|
14
|
+
#
|
|
15
|
+
# @example Search within a single session
|
|
16
|
+
# results = Mneme::Search.query("OAuth config", session_id: 42)
|
|
17
|
+
class Search
|
|
18
|
+
# A single search result with enough context for display and drill-down.
|
|
19
|
+
#
|
|
20
|
+
# @!attribute event_id [Integer] the event's database ID
|
|
21
|
+
# @!attribute session_id [Integer] the session owning this event
|
|
22
|
+
# @!attribute snippet [String] highlighted excerpt from the matching content
|
|
23
|
+
# @!attribute rank [Float] FTS5 relevance score (lower = more relevant)
|
|
24
|
+
# @!attribute event_type [String] one of Event::TYPES
|
|
25
|
+
Result = Struct.new(:event_id, :session_id, :snippet, :rank, :event_type, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
# Searches event history for the given terms.
|
|
28
|
+
#
|
|
29
|
+
# @param terms [String] search query (FTS5 syntax: words, phrases, OR/AND/NOT)
|
|
30
|
+
# @param session_id [Integer, nil] scope to a specific session (nil = all sessions)
|
|
31
|
+
# @param limit [Integer] maximum results
|
|
32
|
+
# @return [Array<Result>] ranked by relevance (best first)
|
|
33
|
+
def self.query(terms, session_id: nil, limit: Anima::Settings.recall_max_results)
|
|
34
|
+
new(terms, session_id: session_id, limit: limit).call
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(terms, session_id: nil, limit: 5)
|
|
38
|
+
@terms = sanitize_query(terms)
|
|
39
|
+
@session_id = session_id
|
|
40
|
+
@limit = limit
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Array<Result>] ranked by relevance (best first)
|
|
44
|
+
def call
|
|
45
|
+
return [] if @terms.blank?
|
|
46
|
+
|
|
47
|
+
rows = execute_fts_query
|
|
48
|
+
rows.map { |row| build_result(row) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Executes the FTS5 MATCH query with optional session scoping.
|
|
54
|
+
# Joins back to events table for session_id and event_type.
|
|
55
|
+
#
|
|
56
|
+
# @return [Array<Hash>] raw database rows
|
|
57
|
+
def execute_fts_query
|
|
58
|
+
if @session_id
|
|
59
|
+
connection.select_all(scoped_sql, "Mneme::Search", [@terms, @session_id, @limit]).to_a
|
|
60
|
+
else
|
|
61
|
+
connection.select_all(global_sql, "Mneme::Search", [@terms, @limit]).to_a
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# FTS5 query across all sessions.
|
|
66
|
+
# Contentless FTS5 can't use snippet() — extract content from events directly.
|
|
67
|
+
def global_sql
|
|
68
|
+
<<~SQL
|
|
69
|
+
SELECT
|
|
70
|
+
e.id AS event_id,
|
|
71
|
+
e.session_id,
|
|
72
|
+
e.event_type,
|
|
73
|
+
CASE
|
|
74
|
+
WHEN e.event_type IN ('user_message', 'agent_message', 'system_message')
|
|
75
|
+
THEN substr(json_extract(e.payload, '$.content'), 1, 300)
|
|
76
|
+
WHEN e.event_type = 'tool_call'
|
|
77
|
+
THEN substr(json_extract(e.payload, '$.tool_input.thoughts'), 1, 300)
|
|
78
|
+
END AS snippet,
|
|
79
|
+
rank
|
|
80
|
+
FROM events_fts
|
|
81
|
+
JOIN events e ON e.id = events_fts.rowid
|
|
82
|
+
WHERE events_fts MATCH ?
|
|
83
|
+
ORDER BY rank
|
|
84
|
+
LIMIT ?
|
|
85
|
+
SQL
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# FTS5 query scoped to a specific session.
|
|
89
|
+
def scoped_sql
|
|
90
|
+
<<~SQL
|
|
91
|
+
SELECT
|
|
92
|
+
e.id AS event_id,
|
|
93
|
+
e.session_id,
|
|
94
|
+
e.event_type,
|
|
95
|
+
CASE
|
|
96
|
+
WHEN e.event_type IN ('user_message', 'agent_message', 'system_message')
|
|
97
|
+
THEN substr(json_extract(e.payload, '$.content'), 1, 300)
|
|
98
|
+
WHEN e.event_type = 'tool_call'
|
|
99
|
+
THEN substr(json_extract(e.payload, '$.tool_input.thoughts'), 1, 300)
|
|
100
|
+
END AS snippet,
|
|
101
|
+
rank
|
|
102
|
+
FROM events_fts
|
|
103
|
+
JOIN events e ON e.id = events_fts.rowid
|
|
104
|
+
WHERE events_fts MATCH ?
|
|
105
|
+
AND e.session_id = ?
|
|
106
|
+
ORDER BY rank
|
|
107
|
+
LIMIT ?
|
|
108
|
+
SQL
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Builds a Result from a raw database row.
|
|
112
|
+
#
|
|
113
|
+
# @param row [Hash]
|
|
114
|
+
# @return [Result]
|
|
115
|
+
def build_result(row)
|
|
116
|
+
Result.new(
|
|
117
|
+
event_id: row["event_id"],
|
|
118
|
+
session_id: row["session_id"],
|
|
119
|
+
snippet: row["snippet"],
|
|
120
|
+
rank: row["rank"],
|
|
121
|
+
event_type: row["event_type"]
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Sanitizes user input for FTS5 MATCH safety.
|
|
126
|
+
# Strips special FTS5 operators that could cause syntax errors,
|
|
127
|
+
# keeps only alphanumeric words and quoted phrases.
|
|
128
|
+
#
|
|
129
|
+
# @param raw [String]
|
|
130
|
+
# @return [String] safe FTS5 query
|
|
131
|
+
def sanitize_query(raw)
|
|
132
|
+
return "" unless raw
|
|
133
|
+
|
|
134
|
+
# Extract quoted phrases and individual words, drop FTS5 operators
|
|
135
|
+
tokens = raw.scan(/"[^"]+?"|\S+/).reject { |token| token.match?(/\A[*:^{}()]+\z/) }
|
|
136
|
+
tokens.filter_map { |token| sanitize_token(token) }.join(" ")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def sanitize_token(token)
|
|
140
|
+
return token if token.start_with?('"')
|
|
141
|
+
|
|
142
|
+
cleaned = token.gsub(/[^a-zA-Z0-9-]/, "")
|
|
143
|
+
cleaned.empty? ? nil : cleaned
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def connection
|
|
147
|
+
ActiveRecord::Base.connection
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mneme
|
|
4
|
+
module Tools
|
|
5
|
+
# Pins critical events to active Goals so they survive viewport eviction.
|
|
6
|
+
# Mneme calls this when it sees important events (user instructions, key
|
|
7
|
+
# decisions, critical corrections) approaching the eviction zone.
|
|
8
|
+
#
|
|
9
|
+
# Events are pinned via a many-to-many join: one event can be attached
|
|
10
|
+
# to multiple Goals. When all referencing Goals complete, the pin is
|
|
11
|
+
# automatically released (reference-counted cleanup in {Goal#release_orphaned_pins!}).
|
|
12
|
+
class AttachEventsToGoals < ::Tools::Base
|
|
13
|
+
def self.tool_name = "attach_events_to_goals"
|
|
14
|
+
|
|
15
|
+
def self.description = "Pin critical events to active goals so they survive " \
|
|
16
|
+
"viewport eviction. Use this for events that are too important to lose — " \
|
|
17
|
+
"exact user instructions, key decisions, critical corrections. " \
|
|
18
|
+
"Events stay pinned until all attached goals complete."
|
|
19
|
+
|
|
20
|
+
def self.input_schema
|
|
21
|
+
{
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
event_ids: {
|
|
25
|
+
type: "array",
|
|
26
|
+
items: {type: "integer"},
|
|
27
|
+
description: "Database IDs of events to pin (from `event N` prefixes in the viewport)"
|
|
28
|
+
},
|
|
29
|
+
goal_ids: {
|
|
30
|
+
type: "array",
|
|
31
|
+
items: {type: "integer"},
|
|
32
|
+
description: "IDs of active goals to attach the events to"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
required: %w[event_ids goal_ids]
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param main_session [Session] the session being observed
|
|
40
|
+
def initialize(main_session:, **)
|
|
41
|
+
@session = main_session
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param input [Hash<String, Object>] with "event_ids" and "goal_ids"
|
|
45
|
+
# @return [String] confirmation with link count, or error description
|
|
46
|
+
def execute(input)
|
|
47
|
+
event_ids = Array(input["event_ids"]).map(&:to_i).uniq
|
|
48
|
+
goal_ids = Array(input["goal_ids"]).map(&:to_i).uniq
|
|
49
|
+
|
|
50
|
+
return "Error: event_ids cannot be empty" if event_ids.empty?
|
|
51
|
+
return "Error: goal_ids cannot be empty" if goal_ids.empty?
|
|
52
|
+
|
|
53
|
+
events = @session.events.where(id: event_ids)
|
|
54
|
+
goals = @session.goals.active.where(id: goal_ids)
|
|
55
|
+
|
|
56
|
+
missing_events = event_ids - events.pluck(:id)
|
|
57
|
+
inactive_goal_ids = goal_ids - goals.pluck(:id)
|
|
58
|
+
|
|
59
|
+
errors = []
|
|
60
|
+
errors << "Events not found: #{missing_events.join(", ")}" if missing_events.any?
|
|
61
|
+
|
|
62
|
+
if inactive_goal_ids.any?
|
|
63
|
+
completed_ids = @session.goals.completed.where(id: inactive_goal_ids).pluck(:id)
|
|
64
|
+
not_found_ids = inactive_goal_ids - completed_ids
|
|
65
|
+
errors << "Goals already completed: #{completed_ids.join(", ")}" if completed_ids.any?
|
|
66
|
+
errors << "Goals not found: #{not_found_ids.join(", ")}" if not_found_ids.any?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
return "Error: #{errors.join("; ")}" if errors.any?
|
|
70
|
+
|
|
71
|
+
attached = attach(events, goals)
|
|
72
|
+
"Pinned #{attached} event-goal links"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def attach(events, goals)
|
|
78
|
+
events.sum do |event|
|
|
79
|
+
pinned = find_or_create_pinned_event(event)
|
|
80
|
+
link_to_goals(pinned, goals)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def link_to_goals(pinned, goals)
|
|
85
|
+
goals.each { |goal| GoalPinnedEvent.find_or_create_by!(goal: goal, pinned_event: pinned) }
|
|
86
|
+
goals.size
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def find_or_create_pinned_event(event)
|
|
90
|
+
PinnedEvent.find_or_create_by!(event: event) do |pe|
|
|
91
|
+
pe.display_text = truncate_event_content(event)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def truncate_event_content(event)
|
|
96
|
+
content = event.payload&.dig("content").to_s.strip
|
|
97
|
+
content = "event #{event.id}" if content.empty?
|
|
98
|
+
|
|
99
|
+
if content.length > PinnedEvent::MAX_DISPLAY_TEXT_LENGTH
|
|
100
|
+
content[0, PinnedEvent::MAX_DISPLAY_TEXT_LENGTH - 1] + "…"
|
|
101
|
+
else
|
|
102
|
+
content
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mneme
|
|
4
|
+
module Tools
|
|
5
|
+
# Sentinel tool signaling that Mneme has reviewed the viewport and
|
|
6
|
+
# determined no snapshot is needed. Called when the conversation
|
|
7
|
+
# context doesn't contain enough meaningful content to summarize.
|
|
8
|
+
class EverythingOk < ::Tools::Base
|
|
9
|
+
def self.tool_name = "everything_ok"
|
|
10
|
+
|
|
11
|
+
def self.description = "Signal that no snapshot is needed. " \
|
|
12
|
+
"Call this when the eviction zone contains only mechanical " \
|
|
13
|
+
"activity (tool calls) with no meaningful conversation to summarize."
|
|
14
|
+
|
|
15
|
+
def self.input_schema
|
|
16
|
+
{type: "object", properties: {}, required: []}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def execute(_input)
|
|
20
|
+
"Acknowledged. No snapshot needed."
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mneme
|
|
4
|
+
module Tools
|
|
5
|
+
# Saves a summary snapshot of conversation context that is about to
|
|
6
|
+
# leave the viewport. The snapshot captures the "gist" of what happened
|
|
7
|
+
# so the agent retains awareness of past context.
|
|
8
|
+
#
|
|
9
|
+
# The text field has a max_tokens limit for predictable sizing — each
|
|
10
|
+
# snapshot is a fixed-size tile, enabling calculation of how many fit
|
|
11
|
+
# at each compression level.
|
|
12
|
+
class SaveSnapshot < ::Tools::Base
|
|
13
|
+
def self.tool_name = "save_snapshot"
|
|
14
|
+
|
|
15
|
+
def self.description = "Save a summary of the conversation context " \
|
|
16
|
+
"that is about to leave the viewport. Write a concise summary " \
|
|
17
|
+
"capturing key decisions, topics discussed, and important context. " \
|
|
18
|
+
"Focus on WHAT was decided and WHY, not mechanical details."
|
|
19
|
+
|
|
20
|
+
def self.input_schema
|
|
21
|
+
{
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
text: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "The summary text. Be concise but preserve key decisions, " \
|
|
27
|
+
"goals discussed, and important context. Max #{Anima::Settings.mneme_max_tokens} tokens."
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
required: %w[text]
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param main_session [Session] the session being observed
|
|
35
|
+
# @param from_event_id [Integer] first event ID covered by this snapshot
|
|
36
|
+
# @param to_event_id [Integer] last event ID covered by this snapshot
|
|
37
|
+
# @param level [Integer] compression level (1 = from events, 2 = from L1 snapshots)
|
|
38
|
+
def initialize(main_session:, from_event_id:, to_event_id:, level: 1, **)
|
|
39
|
+
@main_session = main_session
|
|
40
|
+
@from_event_id = from_event_id
|
|
41
|
+
@to_event_id = to_event_id
|
|
42
|
+
@level = level
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def execute(input)
|
|
46
|
+
text = input["text"].to_s.strip
|
|
47
|
+
return "Error: Summary text cannot be blank" if text.empty?
|
|
48
|
+
|
|
49
|
+
snapshot = @main_session.snapshots.create!(
|
|
50
|
+
text: text,
|
|
51
|
+
from_event_id: @from_event_id,
|
|
52
|
+
to_event_id: @to_event_id,
|
|
53
|
+
level: @level,
|
|
54
|
+
token_count: estimate_tokens(text)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
"Snapshot saved (id: #{snapshot.id}, events #{@from_event_id}..#{@to_event_id})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# @return [Integer] estimated token count for the summary text
|
|
63
|
+
def estimate_tokens(text)
|
|
64
|
+
[(text.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/mneme.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Mneme — the memory department. Watches for viewport eviction and creates
|
|
4
|
+
# summaries before context is lost. Named after the Greek Titaness of memory.
|
|
5
|
+
#
|
|
6
|
+
# Mneme is the third event bus department alongside Nous (main agent) and
|
|
7
|
+
# the Analytical Brain. It operates as a phantom LLM loop: observes the
|
|
8
|
+
# main session, creates snapshots, but leaves no trace of its own reasoning.
|
|
9
|
+
module Mneme
|
|
10
|
+
# Dev-only logger that writes to log/mneme.log.
|
|
11
|
+
# In non-development environments returns a null logger so
|
|
12
|
+
# call sites don't need conditionals.
|
|
13
|
+
#
|
|
14
|
+
# @return [Logger]
|
|
15
|
+
def self.logger
|
|
16
|
+
@logger ||= build_logger
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.build_logger
|
|
20
|
+
return Logger.new(File::NULL) unless Rails.env.development?
|
|
21
|
+
|
|
22
|
+
Logger.new(Rails.root.join("log", "mneme.log")).tap do |log|
|
|
23
|
+
log.formatter = proc { |severity, time, _progname, msg|
|
|
24
|
+
"[#{time.strftime("%H:%M:%S.%L")}] #{severity} #{msg}\n"
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
private_class_method :build_logger
|
|
29
|
+
end
|
data/lib/providers/anthropic.rb
CHANGED
|
@@ -13,6 +13,10 @@ module Providers
|
|
|
13
13
|
API_VERSION = "2023-06-01"
|
|
14
14
|
REQUIRED_BETA = "oauth-2025-04-20"
|
|
15
15
|
|
|
16
|
+
# Anthropic requires this exact string as the first system block for OAuth
|
|
17
|
+
# subscription tokens on Sonnet/Opus. Without it, /v1/messages returns 400.
|
|
18
|
+
OAUTH_PASSPHRASE = "You are Claude Code, Anthropic's official CLI for Claude."
|
|
19
|
+
|
|
16
20
|
class Error < StandardError; end
|
|
17
21
|
class AuthenticationError < Error; end
|
|
18
22
|
class TokenFormatError < Error; end
|
|
@@ -25,11 +29,13 @@ module Providers
|
|
|
25
29
|
class << self
|
|
26
30
|
def fetch_token
|
|
27
31
|
token = CredentialStore.read("anthropic", "subscription_token")
|
|
28
|
-
|
|
32
|
+
return token if token.present?
|
|
33
|
+
return "sk-ant-oat01-#{"0" * 68}" if ENV["CI"]
|
|
34
|
+
|
|
35
|
+
raise AuthenticationError, <<~MSG.strip
|
|
29
36
|
No Anthropic subscription token found in credentials.
|
|
30
37
|
Use the TUI token setup (Ctrl+a → a) to configure your token.
|
|
31
38
|
MSG
|
|
32
|
-
token
|
|
33
39
|
end
|
|
34
40
|
|
|
35
41
|
def validate_token_format!(token)
|
|
@@ -46,6 +52,13 @@ module Providers
|
|
|
46
52
|
true
|
|
47
53
|
end
|
|
48
54
|
|
|
55
|
+
# Validate a token against the live Anthropic API.
|
|
56
|
+
# Delegates to {#validate_credentials!} on a throwaway instance.
|
|
57
|
+
#
|
|
58
|
+
# @param token [String] Anthropic API token to validate
|
|
59
|
+
# @return [true] when the API accepts the token
|
|
60
|
+
# @raise [TransientError] on network failures or server errors (retryable)
|
|
61
|
+
# @raise [AuthenticationError] on 401/403 (permanent)
|
|
49
62
|
def validate_token_api!(token)
|
|
50
63
|
provider = new(token)
|
|
51
64
|
provider.validate_credentials!
|
|
@@ -58,7 +71,18 @@ module Providers
|
|
|
58
71
|
@token = token || self.class.fetch_token
|
|
59
72
|
end
|
|
60
73
|
|
|
74
|
+
# Send a message to the Anthropic API and return the parsed response.
|
|
75
|
+
#
|
|
76
|
+
# @param model [String] Anthropic model identifier
|
|
77
|
+
# @param messages [Array<Hash>] conversation messages
|
|
78
|
+
# @param max_tokens [Integer] maximum tokens in the response
|
|
79
|
+
# @param options [Hash] additional parameters (e.g. +system:+, +tools:+)
|
|
80
|
+
# @return [Hash] parsed API response
|
|
81
|
+
# @raise [TransientError] on network failures or server errors (retryable)
|
|
82
|
+
# @raise [AuthenticationError] on 401/403 (permanent)
|
|
83
|
+
# @raise [Error] on other API errors
|
|
61
84
|
def create_message(model:, messages:, max_tokens:, **options)
|
|
85
|
+
wrap_system_prompt!(options)
|
|
62
86
|
body = {model: model, messages: messages, max_tokens: max_tokens}.merge(options)
|
|
63
87
|
|
|
64
88
|
response = self.class.post(
|
|
@@ -69,8 +93,8 @@ module Providers
|
|
|
69
93
|
)
|
|
70
94
|
|
|
71
95
|
handle_response(response)
|
|
72
|
-
rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError =>
|
|
73
|
-
raise TransientError, "#{
|
|
96
|
+
rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => network_error
|
|
97
|
+
raise TransientError, "#{network_error.class}: #{network_error.message}"
|
|
74
98
|
end
|
|
75
99
|
|
|
76
100
|
# Count tokens in a message payload without creating a message.
|
|
@@ -82,6 +106,7 @@ module Providers
|
|
|
82
106
|
# @return [Integer] estimated input token count
|
|
83
107
|
# @raise [Error] on API errors
|
|
84
108
|
def count_tokens(model:, messages:, **options)
|
|
109
|
+
wrap_system_prompt!(options)
|
|
85
110
|
body = {model: model, messages: messages}.merge(options)
|
|
86
111
|
|
|
87
112
|
response = self.class.post(
|
|
@@ -93,18 +118,22 @@ module Providers
|
|
|
93
118
|
|
|
94
119
|
result = handle_response(response)
|
|
95
120
|
result["input_tokens"]
|
|
96
|
-
rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError =>
|
|
97
|
-
raise TransientError, "#{
|
|
121
|
+
rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => network_error
|
|
122
|
+
raise TransientError, "#{network_error.class}: #{network_error.message}"
|
|
98
123
|
end
|
|
99
124
|
|
|
125
|
+
# Verify the token is accepted by Anthropic using the free models endpoint.
|
|
126
|
+
# Returns +true+ on success; raises typed exceptions on failure so callers
|
|
127
|
+
# can distinguish permanent auth problems from transient outages.
|
|
128
|
+
#
|
|
129
|
+
# @return [true] when the API accepts the token
|
|
130
|
+
# @raise [AuthenticationError] on 401 (invalid token) or 403 (restricted credential)
|
|
131
|
+
# @raise [RateLimitError] on 429
|
|
132
|
+
# @raise [ServerError] on 5xx
|
|
133
|
+
# @raise [TransientError] on network-level failures
|
|
100
134
|
def validate_credentials!
|
|
101
|
-
response = self.class.
|
|
102
|
-
"/v1/
|
|
103
|
-
body: {
|
|
104
|
-
model: Anima::Settings.model,
|
|
105
|
-
messages: [{role: "user", content: "Hi"}],
|
|
106
|
-
max_tokens: 1
|
|
107
|
-
}.to_json,
|
|
135
|
+
response = self.class.get(
|
|
136
|
+
"/v1/models",
|
|
108
137
|
headers: request_headers,
|
|
109
138
|
timeout: Anima::Settings.api_timeout
|
|
110
139
|
)
|
|
@@ -121,10 +150,25 @@ module Providers
|
|
|
121
150
|
else
|
|
122
151
|
handle_response(response)
|
|
123
152
|
end
|
|
153
|
+
rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => network_error
|
|
154
|
+
raise TransientError, "#{network_error.class}: #{network_error.message}"
|
|
124
155
|
end
|
|
125
156
|
|
|
126
157
|
private
|
|
127
158
|
|
|
159
|
+
# Wraps the system parameter in the array-of-blocks format required by
|
|
160
|
+
# Anthropic for OAuth tokens. The passphrase block is always present;
|
|
161
|
+
# the caller's prompt (if any) is appended as the second block.
|
|
162
|
+
#
|
|
163
|
+
# @param options [Hash] mutable options hash (modified in place)
|
|
164
|
+
# @return [void]
|
|
165
|
+
def wrap_system_prompt!(options)
|
|
166
|
+
prompt = options[:system]
|
|
167
|
+
blocks = [{type: "text", text: OAUTH_PASSPHRASE}]
|
|
168
|
+
blocks << {type: "text", text: prompt} if prompt
|
|
169
|
+
options[:system] = blocks
|
|
170
|
+
end
|
|
171
|
+
|
|
128
172
|
def request_headers
|
|
129
173
|
{
|
|
130
174
|
"Authorization" => "Bearer #{token}",
|