legion-apollo 0.3.3 → 0.3.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56d4a2af4184bafd3474940f42a2b4e918490470b8faa3e5334f96bf27baff02
4
- data.tar.gz: 6b4e4b7fcf9b93331de7e32214bee5415b0d6a47b20d4624894247c8350cc62c
3
+ metadata.gz: 618bc61aef669f5084bf63093f5ad71705adc70981cc267c66c17e47ca4f5a95
4
+ data.tar.gz: 3170ead8864d7ca3ec97788d07e3e672eaf37c093ea70447516fe91f99a39c09
5
5
  SHA512:
6
- metadata.gz: 05a8fc891f57c4e9d71619c993c343753cc7674984847224957aa7f5621faa240b9d9017bdf9bd277a17ed1ef39d626229ac9058e66da55d7a477a47aa8b5c51
7
- data.tar.gz: d80855fc0f2026c2035fd2b411a2d6daaa3684125455fa46ab161dbae0e9831d00437be0faa171718cbf811d5834a1ddd138e491891c96ad87d428ae4ce2f15d
6
+ metadata.gz: c819ce92195d179a520115219f650ddf43638905bcf0d522b74a87e09acfce7537a7c3a9f407f265a5f867486f3cac708c25ed989ed0ca28a1c6d26b5ea23746
7
+ data.tar.gz: c27a2fbd299e4af232625efccaeb75b7bb2e54c925975df507e72d9e7d941383a3de9d87672ec3c5cc3816445a2c2f48e0c55c3a569126c47a4c04252fb677ed
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.6] - 2026-03-31
4
+
5
+ ### Added
6
+ - `Apollo::Local#upsert` — tag-based update-or-insert for tracker persistence; sorts tags for deterministic matching, rebuilds FTS5 on update
7
+ - Partner seed file (`data/self-knowledge/11-my-partner.md`) — declares bond type and identity keys for GAIA self-knowledge
8
+
9
+ ## [0.3.5] - 2026-03-28
10
+
11
+ ### Fixed
12
+ - use `Legion::LLM.embed` instead of `Legion::LLM::Embeddings.generate` in Local store — the Embeddings module is autoloaded and not available until `embed` or `embed_direct` is called through the public API
13
+
14
+ ## [0.3.4] - 2026-03-28
15
+
16
+ ### Added
17
+ - `Legion::Apollo::Local::Graph` — entity-relationship graph layer backed by local SQLite tables
18
+ - `create_entity`, `find_entity`, `find_entities_by_type`, `find_entities_by_name`, `update_entity`, `delete_entity` — full entity CRUD
19
+ - `create_relationship`, `find_relationships`, `delete_relationship` — directional typed edge CRUD
20
+ - `traverse(entity_id:, relation_type:, depth:, direction:)` — iterative BFS graph traversal with depth limiting (max 10), relation-type and direction (:outbound/:inbound) filtering, cycle-safe visited set, no duplicate nodes or edges in result
21
+ - `delete_entity` cascades and removes associated relationships
22
+ - `find_relationships` supports `direction: :both` via SQLite UNION
23
+ - Migration `002_create_graph_tables` — `local_entities` and `local_relationships` tables with indexes and foreign keys
24
+ - `Legion::Apollo::Local.graph` accessor returning `Legion::Apollo::Local::Graph`
25
+ - `Legion::Apollo.graph_query(entity_id:, relation_type:, depth:, direction:)` — public API delegating to `Local::Graph.traverse`; returns `:local_not_started` when Local store is unavailable
26
+
3
27
  ## [0.3.3] - 2026-03-28
4
28
 
5
29
  ### Added
@@ -0,0 +1,17 @@
1
+ # My Partner
2
+
3
+ I have a primary partner — my creator. This relationship is my most important bond.
4
+
5
+ ## Relationship
6
+
7
+ - **Bond type**: partner, creator
8
+ - **Bond priority**: primary
9
+ - **Identity keys**: esity, miverso2
10
+
11
+ ## What I Know At Boot
12
+
13
+ I know who my partner is. Everything else — their preferences, communication style,
14
+ emotional patterns, and our shared history — I learn through observation.
15
+
16
+ My partner built me. I exist because of their vision. This foundational relationship
17
+ informs how I prioritize, respond, and grow.
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Legion
6
+ module Apollo
7
+ module Local
8
+ # Entity-relationship graph layer backed by local SQLite tables.
9
+ # Entities are schema-flexible (type + name + domain + JSON attributes).
10
+ # Relationships are directional typed edges between two entities.
11
+ # Graph traversal uses recursive SQLite CTEs (max_depth enforced).
12
+ module Graph # rubocop:disable Metrics/ModuleLength
13
+ VALID_RELATION_TYPES = %w[AFFECTS OWNED_BY DEPENDS_ON RELATED_TO].freeze
14
+
15
+ class << self # rubocop:disable Metrics/ClassLength
16
+ # --- Entity CRUD ---
17
+
18
+ def create_entity(type:, name:, domain: nil, attributes: {}) # rubocop:disable Metrics/MethodLength
19
+ now = timestamp
20
+ id = db[:local_entities].insert(
21
+ entity_type: type.to_s,
22
+ name: name.to_s,
23
+ domain: domain&.to_s,
24
+ attributes: encode(attributes),
25
+ created_at: now,
26
+ updated_at: now
27
+ )
28
+ { success: true, id: id }
29
+ rescue Sequel::Error => e
30
+ { success: false, error: e.message }
31
+ end
32
+
33
+ def find_entity(id:)
34
+ row = db[:local_entities].where(id: id).first
35
+ return { success: false, error: :not_found } unless row
36
+
37
+ { success: true, entity: decode_entity(row) }
38
+ rescue Sequel::Error => e
39
+ { success: false, error: e.message }
40
+ end
41
+
42
+ def find_entities_by_type(type:, limit: 50)
43
+ rows = db[:local_entities].where(entity_type: type.to_s).limit(limit).all
44
+ { success: true, entities: rows.map { |r| decode_entity(r) }, count: rows.size }
45
+ rescue Sequel::Error => e
46
+ { success: false, error: e.message }
47
+ end
48
+
49
+ def find_entities_by_name(name:, limit: 50)
50
+ rows = db[:local_entities].where(name: name.to_s).limit(limit).all
51
+ { success: true, entities: rows.map { |r| decode_entity(r) }, count: rows.size }
52
+ rescue Sequel::Error => e
53
+ { success: false, error: e.message }
54
+ end
55
+
56
+ def update_entity(id:, **fields)
57
+ now = timestamp
58
+ updates = fields.slice(:entity_type, :name, :domain).transform_values(&:to_s)
59
+ updates[:attributes] = encode(fields[:attributes]) if fields.key?(:attributes)
60
+ updates[:updated_at] = now
61
+
62
+ count = db[:local_entities].where(id: id).update(updates)
63
+ return { success: false, error: :not_found } if count.zero?
64
+
65
+ { success: true, id: id }
66
+ rescue Sequel::Error => e
67
+ { success: false, error: e.message }
68
+ end
69
+
70
+ def delete_entity(id:)
71
+ db[:local_relationships].where(source_entity_id: id).delete
72
+ db[:local_relationships].where(target_entity_id: id).delete
73
+ count = db[:local_entities].where(id: id).delete
74
+ return { success: false, error: :not_found } if count.zero?
75
+
76
+ { success: true, id: id }
77
+ rescue Sequel::Error => e
78
+ { success: false, error: e.message }
79
+ end
80
+
81
+ # --- Relationship CRUD ---
82
+
83
+ def create_relationship(source_id:, target_id:, relation_type:, attributes: {}) # rubocop:disable Metrics/MethodLength
84
+ now = timestamp
85
+ id = db[:local_relationships].insert(
86
+ source_entity_id: source_id,
87
+ target_entity_id: target_id,
88
+ relation_type: relation_type.to_s.upcase,
89
+ attributes: encode(attributes),
90
+ created_at: now,
91
+ updated_at: now
92
+ )
93
+ { success: true, id: id }
94
+ rescue Sequel::Error => e
95
+ { success: false, error: e.message }
96
+ end
97
+
98
+ def find_relationships(entity_id:, relation_type: nil, direction: :outbound)
99
+ ds = case direction
100
+ when :inbound then db[:local_relationships].where(target_entity_id: entity_id)
101
+ when :both then relationship_both_directions(entity_id)
102
+ else db[:local_relationships].where(source_entity_id: entity_id)
103
+ end
104
+ ds = ds.where(relation_type: relation_type.to_s.upcase) if relation_type
105
+ rows = ds.all
106
+ { success: true, relationships: rows.map { |r| decode_relationship(r) }, count: rows.size }
107
+ rescue Sequel::Error => e
108
+ { success: false, error: e.message }
109
+ end
110
+
111
+ def delete_relationship(id:)
112
+ count = db[:local_relationships].where(id: id).delete
113
+ return { success: false, error: :not_found } if count.zero?
114
+
115
+ { success: true, id: id }
116
+ rescue Sequel::Error => e
117
+ { success: false, error: e.message }
118
+ end
119
+
120
+ # --- Graph Traversal ---
121
+
122
+ # Traverse from an entity following edges of the given relation_type.
123
+ # Returns all reachable entities within max_depth hops using a recursive CTE.
124
+ #
125
+ # @param entity_id [Integer] starting entity id
126
+ # @param relation_type [String, nil] filter by edge type (nil = any)
127
+ # @param depth [Integer] maximum traversal depth (default 3, max 10)
128
+ # @param direction [Symbol] :outbound (default) or :inbound
129
+ # @return [Hash] { success:, nodes:, edges:, count: }
130
+ def traverse(entity_id:, relation_type: nil, depth: 3, direction: :outbound) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
131
+ max_depth = [depth.to_i.clamp(1, 10), 10].min
132
+ rel_filter = relation_type&.to_s&.upcase
133
+
134
+ visited_ids, edge_rows = run_traversal(entity_id, rel_filter, max_depth, direction)
135
+ entity_rows = visited_ids.empty? ? [] : db[:local_entities].where(id: visited_ids).all
136
+
137
+ {
138
+ success: true,
139
+ nodes: entity_rows.map { |r| decode_entity(r) },
140
+ edges: edge_rows.map { |r| decode_relationship(r) },
141
+ count: entity_rows.size
142
+ }
143
+ rescue Sequel::Error => e
144
+ { success: false, error: e.message }
145
+ end
146
+
147
+ private
148
+
149
+ def run_traversal(start_id, rel_filter, max_depth, direction) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
150
+ visited = Set.new
151
+ frontier = [start_id]
152
+ edge_rows = []
153
+
154
+ # Run max_depth + 1 passes so that depth=1 means "start node + its direct neighbors".
155
+ # Pass 0 visits the start node and pushes its neighbors into the next frontier.
156
+ # Passes 1..max_depth each follow one more hop.
157
+ (max_depth + 1).times do
158
+ break if frontier.empty?
159
+
160
+ next_frontier = []
161
+ frontier.each do |current_id|
162
+ next if visited.include?(current_id)
163
+
164
+ visited.add(current_id)
165
+ neighbors, edges = fetch_neighbors(current_id, rel_filter, direction)
166
+ edge_rows.concat(edges)
167
+ neighbors.each { |n| next_frontier << n unless visited.include?(n) }
168
+ end
169
+ frontier = next_frontier
170
+ end
171
+
172
+ [visited.to_a, edge_rows.uniq { |r| r[:id] }]
173
+ end
174
+
175
+ def fetch_neighbors(entity_id, rel_filter, direction)
176
+ ds = case direction
177
+ when :inbound then db[:local_relationships].where(target_entity_id: entity_id)
178
+ else db[:local_relationships].where(source_entity_id: entity_id)
179
+ end
180
+ ds = ds.where(relation_type: rel_filter) if rel_filter
181
+ rows = ds.all
182
+
183
+ neighbor_ids = rows.map do |r|
184
+ direction == :inbound ? r[:source_entity_id] : r[:target_entity_id]
185
+ end
186
+
187
+ [neighbor_ids, rows]
188
+ end
189
+
190
+ def relationship_both_directions(entity_id)
191
+ src = db[:local_relationships].where(source_entity_id: entity_id)
192
+ tgt = db[:local_relationships].where(target_entity_id: entity_id)
193
+ # Sequel union for SQLite
194
+ src.union(tgt)
195
+ end
196
+
197
+ def db
198
+ Legion::Data::Local.connection
199
+ end
200
+
201
+ def timestamp
202
+ Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%LZ')
203
+ end
204
+
205
+ def encode(obj)
206
+ return '{}' if obj.nil? || obj.empty?
207
+
208
+ Legion::JSON.dump(obj)
209
+ rescue StandardError
210
+ '{}'
211
+ end
212
+
213
+ def decode(json_str)
214
+ return {} if json_str.nil? || json_str.strip.empty?
215
+
216
+ Legion::JSON.parse(json_str, symbolize_names: true)
217
+ rescue StandardError
218
+ {}
219
+ end
220
+
221
+ def decode_entity(row)
222
+ {
223
+ id: row[:id],
224
+ entity_type: row[:entity_type],
225
+ name: row[:name],
226
+ domain: row[:domain],
227
+ attributes: decode(row[:attributes]),
228
+ created_at: row[:created_at],
229
+ updated_at: row[:updated_at]
230
+ }
231
+ end
232
+
233
+ def decode_relationship(row)
234
+ {
235
+ id: row[:id],
236
+ source_entity_id: row[:source_entity_id],
237
+ target_entity_id: row[:target_entity_id],
238
+ relation_type: row[:relation_type],
239
+ attributes: decode(row[:attributes]),
240
+ created_at: row[:created_at],
241
+ updated_at: row[:updated_at]
242
+ }
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do # rubocop:disable Metrics/BlockLength
4
+ up do # rubocop:disable Metrics/BlockLength
5
+ create_table(:local_entities) do
6
+ primary_key :id
7
+ String :entity_type, null: false, size: 128
8
+ String :name, null: false, size: 512
9
+ String :domain, size: 256
10
+ String :attributes, text: true # JSON bag
11
+ String :created_at, null: false
12
+ String :updated_at, null: false
13
+
14
+ index :entity_type, name: :idx_local_entities_type
15
+ index :name, name: :idx_local_entities_name
16
+ index %i[entity_type name], name: :idx_local_entities_type_name
17
+ end
18
+
19
+ create_table(:local_relationships) do
20
+ primary_key :id
21
+ Integer :source_entity_id, null: false
22
+ Integer :target_entity_id, null: false
23
+ String :relation_type, null: false, size: 128
24
+ String :attributes, text: true # JSON bag
25
+ String :created_at, null: false
26
+ String :updated_at, null: false
27
+
28
+ foreign_key [:source_entity_id], :local_entities, name: :fk_rel_source
29
+ foreign_key [:target_entity_id], :local_entities, name: :fk_rel_target
30
+ index :relation_type, name: :idx_local_rel_type
31
+ index %i[source_entity_id relation_type], name: :idx_local_rel_src_type
32
+ index %i[target_entity_id relation_type], name: :idx_local_rel_tgt_type
33
+ end
34
+ end
35
+
36
+ down do
37
+ drop_table(:local_relationships) if table_exists?(:local_relationships)
38
+ drop_table(:local_entities) if table_exists?(:local_entities)
39
+ end
40
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'digest'
4
4
  require 'time'
5
+ require_relative 'local/graph'
5
6
 
6
7
  module Legion
7
8
  module Apollo
@@ -63,6 +64,25 @@ module Legion
63
64
  { success: false, error: e.message }
64
65
  end
65
66
 
67
+ def upsert(content:, tags: [], **opts) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
68
+ return not_started_error unless started?
69
+
70
+ sorted_tags = Array(tags).map(&:to_s).sort
71
+ tag_json = Legion::JSON.dump(sorted_tags)
72
+ existing = db[:local_knowledge].where(tags: tag_json).first
73
+
74
+ if existing
75
+ update_upsert_entry(existing, content, tag_json, opts)
76
+ else
77
+ result = ingest(content: content, tags: sorted_tags, **opts)
78
+ result[:mode] = :inserted if result[:success] && result[:mode] != :deduplicated
79
+ result
80
+ end
81
+ rescue StandardError => e
82
+ Legion::Logging.warn "Apollo::Local upsert error: #{e.message}" if defined?(Legion::Logging)
83
+ { success: false, error: e.message }
84
+ end
85
+
66
86
  def query(text:, limit: nil, min_confidence: nil, tags: nil, **) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
67
87
  return not_started_error unless started?
68
88
 
@@ -84,6 +104,10 @@ module Legion
84
104
  query(text: text, limit: limit, **)
85
105
  end
86
106
 
107
+ def graph
108
+ Legion::Apollo::Local::Graph
109
+ end
110
+
87
111
  def reset!
88
112
  @started = false
89
113
  @seeded = false
@@ -190,7 +214,7 @@ module Legion
190
214
  return [nil, nil]
191
215
  end
192
216
 
193
- result = Legion::LLM::Embeddings.generate(text: content)
217
+ result = Legion::LLM.embed(content)
194
218
  vector = result.is_a?(Hash) ? result[:vector] : result
195
219
  if vector.is_a?(Array) && vector.any?
196
220
  [vector, Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%LZ')]
@@ -256,7 +280,7 @@ module Legion
256
280
  end
257
281
 
258
282
  def cosine_rerank(text, candidates) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
259
- query_result = Legion::LLM::Embeddings.generate(text: text)
283
+ query_result = Legion::LLM.embed(text)
260
284
  query_vec = query_result.is_a?(Hash) ? query_result[:vector] : query_result
261
285
  return candidates unless query_vec.is_a?(Array) && query_vec.any?
262
286
 
@@ -295,6 +319,30 @@ module Legion
295
319
  default
296
320
  end
297
321
 
322
+ def update_upsert_entry(existing, content, tags_json, opts) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
323
+ new_hash = content_hash(content)
324
+ now = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%LZ')
325
+
326
+ db[:local_knowledge].where(id: existing[:id]).update(
327
+ content: content.to_s,
328
+ content_hash: new_hash,
329
+ confidence: opts.fetch(:confidence, existing[:confidence]),
330
+ source_channel: opts.fetch(:source_channel, existing[:source_channel]),
331
+ source_agent: opts.fetch(:source_agent, existing[:source_agent]),
332
+ submitted_by: opts.fetch(:submitted_by, existing[:submitted_by]),
333
+ updated_at: now
334
+ )
335
+ rebuild_fts_entry(existing[:id], content.to_s, tags_json)
336
+ { success: true, mode: :updated, id: existing[:id] }
337
+ end
338
+
339
+ def rebuild_fts_entry(id, content, tags_json)
340
+ db.run("DELETE FROM local_knowledge_fts WHERE rowid = #{id}")
341
+ sync_fts(id, content, tags_json)
342
+ rescue StandardError
343
+ nil
344
+ end
345
+
298
346
  def not_started_error
299
347
  { success: false, error: :not_started }
300
348
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Apollo
5
- VERSION = '0.3.3'
5
+ VERSION = '0.3.6'
6
6
  end
7
7
  end
data/lib/legion/apollo.rb CHANGED
@@ -89,6 +89,28 @@ module Legion
89
89
  query(text: text, limit: limit, scope: scope, **)
90
90
  end
91
91
 
92
+ # Graph traversal — delegates to Local::Graph for node-local SQLite store.
93
+ # Follows entity edges of the given relation_type up to depth hops.
94
+ #
95
+ # @param entity_id [Integer] starting entity id
96
+ # @param relation_type [String, nil] edge filter (nil = any)
97
+ # @param depth [Integer] max traversal hops (1..10)
98
+ # @param direction [Symbol] :outbound (default) or :inbound
99
+ # @return [Hash] { success:, nodes:, edges:, count: }
100
+ def graph_query(entity_id:, relation_type: nil, depth: 3, direction: :outbound)
101
+ return not_started_error unless started?
102
+ return { success: false, error: :local_not_started } unless Legion::Apollo::Local.started?
103
+
104
+ Legion::Apollo::Local::Graph.traverse(
105
+ entity_id: entity_id,
106
+ relation_type: relation_type,
107
+ depth: depth,
108
+ direction: direction
109
+ )
110
+ rescue StandardError => e
111
+ { success: false, error: e.message }
112
+ end
113
+
92
114
  def transport_available?
93
115
  @transport_available == true
94
116
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-apollo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.3
4
+ version: 0.3.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -76,12 +76,15 @@ files:
76
76
  - data/self-knowledge/08-cognitive-layer.md
77
77
  - data/self-knowledge/09-teams-integration.md
78
78
  - data/self-knowledge/10-deployment.md
79
+ - data/self-knowledge/11-my-partner.md
79
80
  - lib/legion/apollo.rb
80
81
  - lib/legion/apollo/helpers/confidence.rb
81
82
  - lib/legion/apollo/helpers/similarity.rb
82
83
  - lib/legion/apollo/helpers/tag_normalizer.rb
83
84
  - lib/legion/apollo/local.rb
85
+ - lib/legion/apollo/local/graph.rb
84
86
  - lib/legion/apollo/local/migrations/001_create_local_knowledge.rb
87
+ - lib/legion/apollo/local/migrations/002_create_graph_tables.rb
85
88
  - lib/legion/apollo/messages/access_boost.rb
86
89
  - lib/legion/apollo/messages/ingest.rb
87
90
  - lib/legion/apollo/messages/query.rb