llmemory 0.1.10 → 0.1.11

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: 81446e0a05a3028dbcd9bb4745295714304eab3c5b7b898ebd0bb72aa0e48c4e
4
- data.tar.gz: 43a8426f108c3c927e72540738fb1b2f946395ad12e01eaba3183e28a38382e4
3
+ metadata.gz: 2016468aa862476d2bfc4fafef85e340433b0bf940519351a9fb72f3bda5e796
4
+ data.tar.gz: 31d1d72a3ef8b94b6cef5e696ed29f72357377670d484897350cddcdf17a20ae
5
5
  SHA512:
6
- metadata.gz: '0396292691b3f8705988d9b86fe33f4f84caee8fcafc498f18631042dff94895175060fd876f6400668effc2e87ae061af60c6bee28c10b69526fab36bfdbdcd'
7
- data.tar.gz: 6b7706873ad15231dc70e22c96b89090b9beb36c5fb9b0630e6f5112f125a83e5014964448e37ec59e3e5b248593b55279b2c99f1228218c86f59fd9de8cbec0
6
+ metadata.gz: f360315f571e5641f53af4006f661a9e432f80280d53a2a7f99d7563f318f23b04950925d2bb2e0716bcad6088b5f929fa15d2b25523f7d83af04b0ac269db3b
7
+ data.tar.gz: aef3ce481555a0f84630207ff0333fe749d841656dd41c1c6449b4e3797cff73ecd033ce1817ff50d8e87913e783dfed3198eb85ccadfd6683388f9a4e5a8780
data/README.md CHANGED
@@ -259,6 +259,189 @@ end
259
259
 
260
260
  The dashboard uses your existing `Llmemory.configuration` (short-term store, long-term store/type, etc.) and does not add any gem dependency; it only runs when Rails is present and you require `llmemory/dashboard`.
261
261
 
262
+ ## MCP Server (Model Context Protocol)
263
+
264
+ llmemory includes an MCP server that allows LLM agents (like Claude Code) to interact directly with the memory system. This gives agents "agency" over their own memory—they can search, save, and retrieve memories autonomously.
265
+
266
+ ### Starting the Server
267
+
268
+ **Stdio mode** (default, for local use with Claude Code):
269
+
270
+ ```bash
271
+ # Via CLI
272
+ llmemory mcp serve
273
+
274
+ # Or via standalone executable
275
+ llmemory-mcp
276
+
277
+ # With custom server name
278
+ llmemory mcp serve --name my-memory
279
+ ```
280
+
281
+ **HTTP mode** (for remote access or web integrations):
282
+
283
+ ```bash
284
+ # Start HTTP server on default port 3100
285
+ llmemory mcp serve --http
286
+
287
+ # Custom port and host
288
+ llmemory mcp serve --http --port 8080 --host 127.0.0.1
289
+
290
+ # With authentication (recommended for HTTP/HTTPS)
291
+ MCP_TOKEN=your-secret-token llmemory mcp serve --http
292
+ ```
293
+
294
+ **HTTPS mode** (secure remote access):
295
+
296
+ ```bash
297
+ # Start HTTPS server with SSL certificates
298
+ llmemory mcp serve --http --port 443 \
299
+ --ssl-cert /path/to/cert.pem \
300
+ --ssl-key /path/to/key.pem
301
+
302
+ # With authentication (strongly recommended)
303
+ MCP_TOKEN=your-secret-token llmemory mcp serve --http --port 443 \
304
+ --ssl-cert /path/to/cert.pem \
305
+ --ssl-key /path/to/key.pem
306
+ ```
307
+
308
+ ### Available Tools
309
+
310
+ | Tool | Description |
311
+ |------|-------------|
312
+ | `memory_search` | Search memories by semantic query |
313
+ | `memory_save` | Save new observations/facts to long-term memory |
314
+ | `memory_retrieve` | Get context optimized for LLM inference (supports timeline context) |
315
+ | `memory_timeline` | Get chronological timeline of recent memories |
316
+ | `memory_timeline_context` | Get N items before/after a specific memory |
317
+ | `memory_add_message` | Add message to short-term conversation |
318
+ | `memory_consolidate` | Extract facts from conversation to long-term |
319
+ | `memory_stats` | Get memory statistics for a user |
320
+ | `memory_info` | Documentation on how to use the tools |
321
+
322
+ ### Configuration for Claude Code
323
+
324
+ Add to `~/.claude/claude_code_config.json`:
325
+
326
+ ```json
327
+ {
328
+ "mcpServers": {
329
+ "llmemory": {
330
+ "command": "llmemory",
331
+ "args": ["mcp", "serve"],
332
+ "env": {
333
+ "OPENAI_API_KEY": "sk-..."
334
+ }
335
+ }
336
+ }
337
+ }
338
+ ```
339
+
340
+ Or with the standalone executable:
341
+
342
+ ```json
343
+ {
344
+ "mcpServers": {
345
+ "llmemory": {
346
+ "command": "llmemory-mcp"
347
+ }
348
+ }
349
+ }
350
+ ```
351
+
352
+ ### Environment Variables
353
+
354
+ | Variable | Description |
355
+ |----------|-------------|
356
+ | `MCP_TOKEN` | Token for HTTP authentication (if set, requests must include valid token) |
357
+ | `LLMEMORY_DEBUG` | Set to `1` to enable debug output on stderr |
358
+ | `OPENAI_API_KEY` | API key for LLM/embeddings |
359
+ | `REDIS_URL` | Redis URL for short-term store |
360
+ | `DATABASE_URL` | Database URL for persistence |
361
+
362
+ ### HTTP Authentication
363
+
364
+ When `MCP_TOKEN` is set, the HTTP server requires authentication. Requests must include the token via:
365
+
366
+ - **Authorization header**: `Authorization: Bearer <token>` or `Authorization: <token>`
367
+ - **Query parameter**: `?token=<token>`
368
+
369
+ Example with curl:
370
+
371
+ ```bash
372
+ # Using Authorization header
373
+ curl -H "Authorization: Bearer your-secret-token" \
374
+ -H "Accept: application/json, text/event-stream" \
375
+ -H "Content-Type: application/json" \
376
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
377
+ http://localhost:3100/
378
+
379
+ # Using query parameter
380
+ curl "http://localhost:3100/?token=your-secret-token" \
381
+ -H "Accept: application/json, text/event-stream" \
382
+ -H "Content-Type: application/json" \
383
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
384
+ ```
385
+
386
+ ### Recommended Workflow
387
+
388
+ 1. **Start of conversation**: Use `memory_retrieve` to get relevant context
389
+ 2. **During conversation**: Use `memory_save` for important observations
390
+ 3. **End of conversation**: Use `memory_consolidate` to persist facts
391
+
392
+ ### Timeline Context
393
+
394
+ The `memory_retrieve` tool supports **timeline context** - showing N events before and after matched memories. This provides situational context around relevant memories:
395
+
396
+ ```json
397
+ {
398
+ "name": "memory_retrieve",
399
+ "arguments": {
400
+ "query": "trabajo",
401
+ "user_id": "user123",
402
+ "include_timeline_context": true,
403
+ "timeline_window": 3
404
+ }
405
+ }
406
+ ```
407
+
408
+ This returns:
409
+ - Recent conversation (short-term)
410
+ - Relevant memories (long-term)
411
+ - **Timeline context**: 3 events before and after each match
412
+
413
+ You can also use `memory_timeline_context` directly to explore temporal context around a specific memory:
414
+
415
+ ```json
416
+ {
417
+ "name": "memory_timeline_context",
418
+ "arguments": {
419
+ "user_id": "user123",
420
+ "item_id": "item_42",
421
+ "before": 5,
422
+ "after": 5
423
+ }
424
+ }
425
+ ```
426
+
427
+ Example output:
428
+ ```
429
+ Timeline Context around 'item_42':
430
+
431
+ BEFORE (3 items):
432
+ - [2024-01-14] [personal] Usuario vive en Madrid
433
+ - [2024-01-15] [technical] Usuario programa en Python
434
+ - [2024-01-16] [preferences] Usuario usa VS Code
435
+
436
+ TARGET:
437
+ >>> [2024-01-17] [work] Usuario trabaja en Acme Corp
438
+
439
+ AFTER (3 items):
440
+ - [2024-01-18] [personal] Usuario tiene un gato
441
+ - [2024-01-19] [work] Usuario lidera equipo backend
442
+ - [2024-01-20] [preferences] Usuario prefiere café
443
+ ```
444
+
262
445
  ## License
263
446
 
264
447
  MIT. See [LICENSE.txt](LICENSE.txt).
data/exe/llmemory-mcp ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) unless $LOAD_PATH.include?(File.expand_path("../lib", __dir__))
5
+
6
+ require "llmemory"
7
+ require "llmemory/mcp"
8
+
9
+ # Parse minimal arguments
10
+ name = "llmemory"
11
+ ARGV.each_with_index do |arg, i|
12
+ if arg == "--name" && ARGV[i + 1]
13
+ name = ARGV[i + 1]
14
+ elsif arg == "--help" || arg == "-h"
15
+ puts "Usage: llmemory-mcp [--name NAME]"
16
+ puts ""
17
+ puts "Starts an MCP (Model Context Protocol) server for llmemory."
18
+ puts ""
19
+ puts "Options:"
20
+ puts " --name NAME Server name (default: llmemory)"
21
+ puts " --help, -h Show this help"
22
+ puts ""
23
+ puts "Environment variables:"
24
+ puts " LLMEMORY_DEBUG Set to 1 to enable debug output on stderr"
25
+ puts ""
26
+ puts "Example configuration for Claude Code (~/.claude/claude_code_config.json):"
27
+ puts ' {'
28
+ puts ' "mcpServers": {'
29
+ puts ' "llmemory": {'
30
+ puts ' "command": "llmemory-mcp"'
31
+ puts ' }'
32
+ puts ' }'
33
+ puts ' }'
34
+ exit 0
35
+ end
36
+ end
37
+
38
+ # Silence stderr for clean protocol unless debugging
39
+ unless ENV["LLMEMORY_DEBUG"]
40
+ $stderr.reopen(File::NULL, "w")
41
+ end
42
+
43
+ server = Llmemory::MCP::Server.new(name: name)
44
+ server.run_stdio
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Llmemory
6
+ module Cli
7
+ module Commands
8
+ class Mcp < Base
9
+ def option_parser(parser)
10
+ @server_name = "llmemory"
11
+ @http_mode = false
12
+ @port = 3100
13
+ @host = "0.0.0.0"
14
+ @ssl_cert = nil
15
+ @ssl_key = nil
16
+
17
+ parser.banner = "Usage: llmemory mcp [serve] [options]"
18
+ parser.on("--name NAME", "Server name (default: llmemory)") { |v| @server_name = v }
19
+ parser.on("--http", "Run as HTTP server instead of stdio") { @http_mode = true }
20
+ parser.on("--port PORT", Integer, "HTTP port (default: 3100)") { |v| @port = v }
21
+ parser.on("--host HOST", "HTTP host (default: 0.0.0.0)") { |v| @host = v }
22
+ parser.on("--ssl-cert FILE", "SSL certificate file for HTTPS") { |v| @ssl_cert = v }
23
+ parser.on("--ssl-key FILE", "SSL private key file for HTTPS") { |v| @ssl_key = v }
24
+ parser.on("-h", "--help", "Show this help") do
25
+ puts parser
26
+ exit
27
+ end
28
+ end
29
+
30
+ def execute(argv, _opts)
31
+ action = argv.shift || "serve"
32
+
33
+ case action
34
+ when "serve"
35
+ run_server
36
+ when "help", "--help", "-h"
37
+ show_help
38
+ else
39
+ $stderr.puts "Unknown MCP action: #{action}"
40
+ $stderr.puts "Usage: llmemory mcp [serve] [options]"
41
+ exit 1
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def show_help
48
+ puts "Usage: llmemory mcp [serve] [options]"
49
+ puts ""
50
+ puts "Options:"
51
+ puts " --name NAME Server name (default: llmemory)"
52
+ puts " --http Run as HTTP server instead of stdio"
53
+ puts " --port PORT HTTP port (default: 3100)"
54
+ puts " --host HOST HTTP host (default: 0.0.0.0)"
55
+ puts " --ssl-cert FILE SSL certificate file for HTTPS"
56
+ puts " --ssl-key FILE SSL private key file for HTTPS"
57
+ puts ""
58
+ puts "Environment Variables:"
59
+ puts " MCP_TOKEN If set, enables token authentication for HTTP mode"
60
+ puts " LLMEMORY_DEBUG Set to 1 to enable debug output"
61
+ puts ""
62
+ puts "Authentication (HTTP/HTTPS mode only):"
63
+ puts " When MCP_TOKEN is set, requests must include either:"
64
+ puts " - Authorization header: 'Bearer <token>' or '<token>'"
65
+ puts " - Query parameter: '?token=<token>'"
66
+ puts ""
67
+ puts "Starts an MCP (Model Context Protocol) server that exposes llmemory"
68
+ puts "tools for use with LLM agents like Claude Code."
69
+ puts ""
70
+ puts "Example configuration for Claude Code (~/.claude/claude_code_config.json):"
71
+ puts ' {'
72
+ puts ' "mcpServers": {'
73
+ puts ' "llmemory": {'
74
+ puts ' "command": "llmemory",'
75
+ puts ' "args": ["mcp", "serve"]'
76
+ puts ' }'
77
+ puts ' }'
78
+ puts ' }'
79
+ puts ""
80
+ puts "HTTP mode example:"
81
+ puts " MCP_TOKEN=secret123 llmemory mcp serve --http --port 3100"
82
+ puts ""
83
+ puts "HTTPS mode example:"
84
+ puts " MCP_TOKEN=secret123 llmemory mcp serve --http --port 443 \\"
85
+ puts " --ssl-cert /path/to/cert.pem --ssl-key /path/to/key.pem"
86
+ end
87
+
88
+ def run_server
89
+ require_relative "../../mcp"
90
+
91
+ server = Llmemory::MCP::Server.new(name: @server_name)
92
+
93
+ if @http_mode
94
+ validate_ssl_options!
95
+ server.run_http(port: @port, host: @host, ssl_cert: @ssl_cert, ssl_key: @ssl_key)
96
+ else
97
+ # Silence stderr to avoid interference with MCP protocol
98
+ # unless debugging is enabled
99
+ unless ENV["LLMEMORY_DEBUG"]
100
+ $stderr.reopen(File::NULL, "w")
101
+ end
102
+
103
+ server.run_stdio
104
+ end
105
+ end
106
+
107
+ def validate_ssl_options!
108
+ # If one SSL option is provided, both must be provided
109
+ if (@ssl_cert && !@ssl_key) || (!@ssl_cert && @ssl_key)
110
+ $stderr.puts "Error: Both --ssl-cert and --ssl-key must be provided for HTTPS"
111
+ exit 1
112
+ end
113
+
114
+ # Validate files exist if SSL is enabled
115
+ if @ssl_cert && @ssl_key
116
+ unless File.exist?(@ssl_cert)
117
+ $stderr.puts "Error: SSL certificate file not found: #{@ssl_cert}"
118
+ exit 1
119
+ end
120
+ unless File.exist?(@ssl_key)
121
+ $stderr.puts "Error: SSL key file not found: #{@ssl_key}"
122
+ exit 1
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
data/lib/llmemory/cli.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "cli/commands/short_term"
7
7
  require_relative "cli/commands/long_term"
8
8
  require_relative "cli/commands/stats"
9
9
  require_relative "cli/commands/search"
10
+ require_relative "cli/commands/mcp"
10
11
 
11
12
  module Llmemory
12
13
  class CLI
@@ -47,7 +48,8 @@ module Llmemory
47
48
  "edges" => Cli::Commands::LongTerm::Edges,
48
49
  "graph" => Cli::Commands::LongTerm::Graph,
49
50
  "search" => Cli::Commands::Search,
50
- "stats" => Cli::Commands::Stats
51
+ "stats" => Cli::Commands::Stats,
52
+ "mcp" => Cli::Commands::Mcp
51
53
  }[normalized]
52
54
  end
53
55
 
@@ -68,6 +70,7 @@ module Llmemory
68
70
  graph USER_ID Export graph (--format dot|json)
69
71
  search USER_ID "query" Search in memory
70
72
  stats [USER_ID] Show statistics
73
+ mcp [serve] Start MCP server for LLM agents
71
74
 
72
75
  Run 'llmemory <command> --help' for command-specific options.
73
76
  HELP
@@ -138,8 +138,40 @@ module Llmemory
138
138
  LlmemoryItem.where(user_id: user_id).count
139
139
  end
140
140
 
141
+ def get_items_around(user_id, reference, before: 5, after: 5)
142
+ items = get_all_items(user_id)
143
+ find_around(items, reference, before, after)
144
+ end
145
+
146
+ def get_resources_around(user_id, reference, before: 5, after: 5)
147
+ resources = get_all_resources(user_id)
148
+ find_around(resources, reference, before, after)
149
+ end
150
+
141
151
  private
142
152
 
153
+ def find_around(items, reference, before, after)
154
+ return { before: [], target: nil, after: [] } if items.empty?
155
+
156
+ idx = if reference.is_a?(String) && reference.match?(/^\d{4}-/)
157
+ target_time = Time.parse(reference)
158
+ items.index { |i| i[:created_at] >= target_time } || items.size
159
+ else
160
+ items.index { |i| i[:id] == reference }
161
+ end
162
+
163
+ return { before: [], target: nil, after: [] } unless idx
164
+
165
+ start_idx = [idx - before, 0].max
166
+ end_idx = [idx + after, items.size - 1].min
167
+
168
+ {
169
+ before: items[start_idx...idx] || [],
170
+ target: items[idx],
171
+ after: items[(idx + 1)..end_idx] || []
172
+ }
173
+ end
174
+
143
175
  def sanitize_like(str)
144
176
  (str || "").to_s.gsub(/[%_\\]/) { |c| "\\#{c}" }
145
177
  end
@@ -80,6 +80,14 @@ module Llmemory
80
80
  def count_items(user_id:)
81
81
  raise NotImplementedError, "#{self.class}#count_items must be implemented"
82
82
  end
83
+
84
+ def get_items_around(user_id, reference, before: 5, after: 5)
85
+ raise NotImplementedError, "#{self.class}#get_items_around must be implemented"
86
+ end
87
+
88
+ def get_resources_around(user_id, reference, before: 5, after: 5)
89
+ raise NotImplementedError, "#{self.class}#get_resources_around must be implemented"
90
+ end
83
91
  end
84
92
  end
85
93
  end
@@ -197,8 +197,42 @@ module Llmemory
197
197
  result.first["c"].to_i
198
198
  end
199
199
 
200
+ def get_items_around(user_id, reference, before: 5, after: 5)
201
+ ensure_tables!
202
+ items = get_all_items(user_id)
203
+ find_around(items, reference, before, after)
204
+ end
205
+
206
+ def get_resources_around(user_id, reference, before: 5, after: 5)
207
+ ensure_tables!
208
+ resources = get_all_resources(user_id)
209
+ find_around(resources, reference, before, after)
210
+ end
211
+
200
212
  private
201
213
 
214
+ def find_around(items, reference, before, after)
215
+ return { before: [], target: nil, after: [] } if items.empty?
216
+
217
+ idx = if reference.is_a?(String) && reference.match?(/^\d{4}-/)
218
+ target_time = Time.parse(reference)
219
+ items.index { |i| i[:created_at] >= target_time } || items.size
220
+ else
221
+ items.index { |i| i[:id] == reference }
222
+ end
223
+
224
+ return { before: [], target: nil, after: [] } unless idx
225
+
226
+ start_idx = [idx - before, 0].max
227
+ end_idx = [idx + after, items.size - 1].min
228
+
229
+ {
230
+ before: items[start_idx...idx] || [],
231
+ target: items[idx],
232
+ after: items[(idx + 1)..end_idx] || []
233
+ }
234
+ end
235
+
202
236
  def conn
203
237
  @connection ||= begin
204
238
  require "pg"
@@ -116,6 +116,42 @@ module Llmemory
116
116
  def count_items(user_id:)
117
117
  @items[user_id].size
118
118
  end
119
+
120
+ def get_items_around(user_id, reference, before: 5, after: 5)
121
+ items = @items[user_id].sort_by { |i| i[:created_at] }
122
+ find_around(items, reference, :id, before, after)
123
+ end
124
+
125
+ def get_resources_around(user_id, reference, before: 5, after: 5)
126
+ resources = @resources[user_id].sort_by { |r| r[:created_at] }
127
+ find_around(resources, reference, :id, before, after)
128
+ end
129
+
130
+ private
131
+
132
+ def find_around(items, reference, id_key, before, after)
133
+ return { before: [], target: nil, after: [] } if items.empty?
134
+
135
+ idx = if reference.is_a?(String) && reference.match?(/^\d{4}-/)
136
+ # ISO timestamp - find closest item at or after this time
137
+ target_time = Time.parse(reference)
138
+ items.index { |i| i[:created_at] >= target_time } || items.size
139
+ else
140
+ # Item ID
141
+ items.index { |i| i[id_key] == reference }
142
+ end
143
+
144
+ return { before: [], target: nil, after: [] } unless idx
145
+
146
+ start_idx = [idx - before, 0].max
147
+ end_idx = [idx + after, items.size - 1].min
148
+
149
+ {
150
+ before: items[start_idx...idx] || [],
151
+ target: items[idx],
152
+ after: items[(idx + 1)..end_idx] || []
153
+ }
154
+ end
119
155
  end
120
156
  end
121
157
  end
@@ -123,6 +123,32 @@ module Llmemory
123
123
  LlmemoryGraphEdge.where(user_id: user_id, archived_at: nil).count
124
124
  end
125
125
 
126
+ def get_edges_around(user_id, reference, before: 5, after: 5)
127
+ edges = LlmemoryGraphEdge.where(user_id: user_id, archived_at: nil)
128
+ .order(:created_at)
129
+ .map { |r| record_to_edge(r) }
130
+
131
+ return { before: [], target: nil, after: [] } if edges.empty?
132
+
133
+ idx = if reference.is_a?(String) && reference.match?(/^\d{4}-/)
134
+ target_time = Time.parse(reference)
135
+ edges.index { |e| e.created_at >= target_time } || edges.size
136
+ else
137
+ edges.index { |e| e.id == reference || e.id.to_s == reference.to_s }
138
+ end
139
+
140
+ return { before: [], target: nil, after: [] } unless idx
141
+
142
+ start_idx = [idx - before, 0].max
143
+ end_idx = [idx + after, edges.size - 1].min
144
+
145
+ {
146
+ before: edges[start_idx...idx] || [],
147
+ target: edges[idx],
148
+ after: edges[(idx + 1)..end_idx] || []
149
+ }
150
+ end
151
+
126
152
  private
127
153
 
128
154
  def record_to_node(r)
@@ -48,6 +48,10 @@ module Llmemory
48
48
  def count_edges(user_id)
49
49
  raise NotImplementedError, "#{self.class}#count_edges must be implemented"
50
50
  end
51
+
52
+ def get_edges_around(user_id, reference, before: 5, after: 5)
53
+ raise NotImplementedError, "#{self.class}#get_edges_around must be implemented"
54
+ end
51
55
  end
52
56
  end
53
57
  end
@@ -118,6 +118,29 @@ module Llmemory
118
118
  def count_edges(user_id)
119
119
  @edges[user_id].count { |e| !e.archived? }
120
120
  end
121
+
122
+ def get_edges_around(user_id, reference, before: 5, after: 5)
123
+ edges = @edges[user_id].reject(&:archived?).sort_by(&:created_at)
124
+ return { before: [], target: nil, after: [] } if edges.empty?
125
+
126
+ idx = if reference.is_a?(String) && reference.match?(/^\d{4}-/)
127
+ target_time = Time.parse(reference)
128
+ edges.index { |e| e.created_at >= target_time } || edges.size
129
+ else
130
+ edges.index { |e| e.id == reference }
131
+ end
132
+
133
+ return { before: [], target: nil, after: [] } unless idx
134
+
135
+ start_idx = [idx - before, 0].max
136
+ end_idx = [idx + after, edges.size - 1].min
137
+
138
+ {
139
+ before: edges[start_idx...idx] || [],
140
+ target: edges[idx],
141
+ after: edges[(idx + 1)..end_idx] || []
142
+ }
143
+ end
121
144
  end
122
145
  end
123
146
  end