spurline-dashboard 0.3.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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/lib/CLAUDE.md +11 -0
  3. data/lib/spurline/CLAUDE.md +16 -0
  4. data/lib/spurline/adapters/CLAUDE.md +12 -0
  5. data/lib/spurline/adapters/base.rb +17 -0
  6. data/lib/spurline/adapters/claude.rb +208 -0
  7. data/lib/spurline/adapters/open_ai.rb +213 -0
  8. data/lib/spurline/adapters/registry.rb +33 -0
  9. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  10. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  11. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  12. data/lib/spurline/agent.rb +433 -0
  13. data/lib/spurline/audit/log.rb +156 -0
  14. data/lib/spurline/audit/secret_filter.rb +121 -0
  15. data/lib/spurline/base.rb +130 -0
  16. data/lib/spurline/cartographer/CLAUDE.md +12 -0
  17. data/lib/spurline/cartographer/analyzer.rb +71 -0
  18. data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -0
  19. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  20. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  21. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  22. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  23. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  24. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  25. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  26. data/lib/spurline/cartographer/runner.rb +88 -0
  27. data/lib/spurline/cartographer.rb +6 -0
  28. data/lib/spurline/channels/base.rb +41 -0
  29. data/lib/spurline/channels/event.rb +136 -0
  30. data/lib/spurline/channels/github.rb +205 -0
  31. data/lib/spurline/channels/router.rb +103 -0
  32. data/lib/spurline/cli/check.rb +88 -0
  33. data/lib/spurline/cli/checks/CLAUDE.md +11 -0
  34. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  35. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  36. data/lib/spurline/cli/checks/base.rb +35 -0
  37. data/lib/spurline/cli/checks/credentials.rb +43 -0
  38. data/lib/spurline/cli/checks/permissions.rb +22 -0
  39. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  40. data/lib/spurline/cli/checks/session_store.rb +97 -0
  41. data/lib/spurline/cli/console.rb +73 -0
  42. data/lib/spurline/cli/credentials.rb +181 -0
  43. data/lib/spurline/cli/generators/CLAUDE.md +11 -0
  44. data/lib/spurline/cli/generators/agent.rb +123 -0
  45. data/lib/spurline/cli/generators/migration.rb +62 -0
  46. data/lib/spurline/cli/generators/project.rb +331 -0
  47. data/lib/spurline/cli/generators/tool.rb +98 -0
  48. data/lib/spurline/cli/router.rb +121 -0
  49. data/lib/spurline/configuration.rb +23 -0
  50. data/lib/spurline/dsl/CLAUDE.md +11 -0
  51. data/lib/spurline/dsl/guardrails.rb +108 -0
  52. data/lib/spurline/dsl/hooks.rb +51 -0
  53. data/lib/spurline/dsl/memory.rb +39 -0
  54. data/lib/spurline/dsl/model.rb +23 -0
  55. data/lib/spurline/dsl/persona.rb +74 -0
  56. data/lib/spurline/dsl/suspend_until.rb +53 -0
  57. data/lib/spurline/dsl/tools.rb +176 -0
  58. data/lib/spurline/errors.rb +109 -0
  59. data/lib/spurline/lifecycle/CLAUDE.md +18 -0
  60. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  61. data/lib/spurline/lifecycle/runner.rb +456 -0
  62. data/lib/spurline/lifecycle/states.rb +47 -0
  63. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  64. data/lib/spurline/memory/CLAUDE.md +12 -0
  65. data/lib/spurline/memory/context_assembler.rb +100 -0
  66. data/lib/spurline/memory/embedder/CLAUDE.md +11 -0
  67. data/lib/spurline/memory/embedder/base.rb +17 -0
  68. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  69. data/lib/spurline/memory/episode.rb +56 -0
  70. data/lib/spurline/memory/episodic_store.rb +147 -0
  71. data/lib/spurline/memory/long_term/CLAUDE.md +11 -0
  72. data/lib/spurline/memory/long_term/base.rb +22 -0
  73. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  74. data/lib/spurline/memory/manager.rb +147 -0
  75. data/lib/spurline/memory/short_term.rb +57 -0
  76. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  77. data/lib/spurline/orchestration/judge.rb +109 -0
  78. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  79. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  80. data/lib/spurline/orchestration/ledger.rb +339 -0
  81. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  82. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  83. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  84. data/lib/spurline/persona/base.rb +42 -0
  85. data/lib/spurline/persona/registry.rb +42 -0
  86. data/lib/spurline/secrets/resolver.rb +65 -0
  87. data/lib/spurline/secrets/vault.rb +42 -0
  88. data/lib/spurline/security/content.rb +76 -0
  89. data/lib/spurline/security/context_pipeline.rb +58 -0
  90. data/lib/spurline/security/gates/base.rb +36 -0
  91. data/lib/spurline/security/gates/operator_config.rb +22 -0
  92. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  93. data/lib/spurline/security/gates/tool_result.rb +23 -0
  94. data/lib/spurline/security/gates/user_input.rb +22 -0
  95. data/lib/spurline/security/injection_scanner.rb +109 -0
  96. data/lib/spurline/security/pii_filter.rb +104 -0
  97. data/lib/spurline/session/CLAUDE.md +11 -0
  98. data/lib/spurline/session/resumption.rb +36 -0
  99. data/lib/spurline/session/serializer.rb +169 -0
  100. data/lib/spurline/session/session.rb +154 -0
  101. data/lib/spurline/session/store/CLAUDE.md +12 -0
  102. data/lib/spurline/session/store/base.rb +27 -0
  103. data/lib/spurline/session/store/memory.rb +45 -0
  104. data/lib/spurline/session/store/postgres.rb +123 -0
  105. data/lib/spurline/session/store/sqlite.rb +139 -0
  106. data/lib/spurline/session/suspension.rb +93 -0
  107. data/lib/spurline/session/turn.rb +98 -0
  108. data/lib/spurline/spur.rb +213 -0
  109. data/lib/spurline/streaming/CLAUDE.md +12 -0
  110. data/lib/spurline/streaming/buffer.rb +77 -0
  111. data/lib/spurline/streaming/chunk.rb +62 -0
  112. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  113. data/lib/spurline/testing.rb +245 -0
  114. data/lib/spurline/toolkit.rb +110 -0
  115. data/lib/spurline/tools/base.rb +209 -0
  116. data/lib/spurline/tools/idempotency.rb +220 -0
  117. data/lib/spurline/tools/permissions.rb +44 -0
  118. data/lib/spurline/tools/registry.rb +43 -0
  119. data/lib/spurline/tools/runner.rb +255 -0
  120. data/lib/spurline/tools/scope.rb +309 -0
  121. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  122. data/lib/spurline/version.rb +5 -0
  123. data/lib/spurline.rb +56 -0
  124. metadata +218 -0
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Memory
5
+ module Embedder
6
+ class Base
7
+ def embed(_text)
8
+ raise NotImplementedError, "#{self.class.name} must implement #embed"
9
+ end
10
+
11
+ def dimensions
12
+ raise NotImplementedError, "#{self.class.name} must implement #dimensions"
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Memory
5
+ module Embedder
6
+ class OpenAI < Base
7
+ DEFAULT_MODEL = "text-embedding-3-small"
8
+ DIMENSIONS = 1536
9
+
10
+ def initialize(api_key: nil, model: nil)
11
+ @api_key = resolve_api_key(api_key)
12
+ @model = model || DEFAULT_MODEL
13
+ end
14
+
15
+ def embed(text)
16
+ # ASYNC-READY: Embedding requests are blocking in v1 and run at this seam.
17
+ response = build_client.embeddings(parameters: { model: @model, input: text.to_s })
18
+ embedding = response.dig("data", 0, "embedding")
19
+
20
+ if embedding.is_a?(Array) && embedding.all? { |value| value.is_a?(Numeric) }
21
+ embedding
22
+ else
23
+ raise Spurline::EmbedderError,
24
+ "OpenAI embedding response did not include a valid embedding vector"
25
+ end
26
+ rescue Spurline::EmbedderError
27
+ raise
28
+ rescue StandardError => e
29
+ raise Spurline::EmbedderError, "OpenAI embedding failed: #{e.message}"
30
+ end
31
+
32
+ def dimensions
33
+ DIMENSIONS
34
+ end
35
+
36
+ private
37
+
38
+ def resolve_api_key(explicit_key)
39
+ candidates = [
40
+ explicit_key,
41
+ ENV.fetch("OPENAI_API_KEY", nil),
42
+ Spurline.credentials["openai_api_key"],
43
+ ]
44
+ key = candidates.find { |value| present_string?(value) }
45
+ return key if key
46
+
47
+ raise Spurline::ConfigurationError,
48
+ "Missing OpenAI API key for embedding model :openai. " \
49
+ "Set OPENAI_API_KEY, add openai_api_key to Spurline.credentials, " \
50
+ "or pass api_key:."
51
+ end
52
+
53
+ def present_string?(value)
54
+ return false if value.nil?
55
+ return !value.strip.empty? if value.respond_to?(:strip)
56
+
57
+ true
58
+ end
59
+
60
+ def build_client
61
+ require "openai"
62
+ ::OpenAI::Client.new(access_token: @api_key)
63
+ rescue LoadError
64
+ raise Spurline::EmbedderError,
65
+ "The 'openai' gem is required for embedding_model :openai"
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Spurline
6
+ module Memory
7
+ # Immutable value object representing one structured event in an agent session.
8
+ class Episode
9
+ attr_reader :id, :type, :content, :metadata, :timestamp, :turn_number, :parent_episode_id
10
+
11
+ def initialize(
12
+ type:,
13
+ content:,
14
+ metadata: {},
15
+ timestamp: Time.now,
16
+ turn_number: nil,
17
+ parent_episode_id: nil,
18
+ id: SecureRandom.uuid
19
+ )
20
+ @id = id.to_s
21
+ @type = type.to_sym
22
+ @content = content
23
+ @metadata = (metadata || {}).dup.freeze
24
+ @timestamp = timestamp
25
+ @turn_number = turn_number
26
+ @parent_episode_id = parent_episode_id
27
+ freeze
28
+ end
29
+
30
+ def to_h
31
+ {
32
+ id: id,
33
+ type: type,
34
+ content: content,
35
+ metadata: metadata,
36
+ timestamp: timestamp,
37
+ turn_number: turn_number,
38
+ parent_episode_id: parent_episode_id,
39
+ }
40
+ end
41
+
42
+ def self.from_h(data)
43
+ hash = data.transform_keys(&:to_sym)
44
+ new(
45
+ id: hash[:id],
46
+ type: hash.fetch(:type),
47
+ content: hash[:content],
48
+ metadata: hash[:metadata] || {},
49
+ timestamp: hash[:timestamp] || Time.now,
50
+ turn_number: hash[:turn_number],
51
+ parent_episode_id: hash[:parent_episode_id]
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Memory
5
+ # Structured per-session trace for replay and explainability.
6
+ class EpisodicStore
7
+ attr_reader :enabled
8
+
9
+ def initialize(enabled: true, episodes: [])
10
+ @enabled = enabled
11
+ @episodes = Array(episodes).map { |episode| coerce_episode(episode) }
12
+ end
13
+
14
+ def record(type:, content:, metadata: {}, turn_number: nil, parent_episode_id: nil, timestamp: Time.now)
15
+ return nil unless enabled
16
+
17
+ episode = Episode.new(
18
+ type: type,
19
+ content: content,
20
+ metadata: metadata,
21
+ timestamp: timestamp,
22
+ turn_number: turn_number,
23
+ parent_episode_id: parent_episode_id
24
+ )
25
+ @episodes << episode
26
+ episode
27
+ end
28
+
29
+ def all
30
+ @episodes.dup
31
+ end
32
+
33
+ def count
34
+ @episodes.length
35
+ end
36
+
37
+ def empty?
38
+ @episodes.empty?
39
+ end
40
+
41
+ def clear!
42
+ @episodes.clear
43
+ end
44
+
45
+ def for_turn(turn_number)
46
+ @episodes.select { |episode| episode.turn_number == turn_number }
47
+ end
48
+
49
+ def tool_calls
50
+ by_type(:tool_call)
51
+ end
52
+
53
+ def decisions
54
+ by_type(:decision)
55
+ end
56
+
57
+ def external_data
58
+ by_type(:external_data)
59
+ end
60
+
61
+ def user_messages
62
+ by_type(:user_message)
63
+ end
64
+
65
+ def assistant_responses
66
+ by_type(:assistant_response)
67
+ end
68
+
69
+ def find(id)
70
+ @episodes.find { |episode| episode.id == id }
71
+ end
72
+
73
+ def serialize
74
+ @episodes.map(&:to_h)
75
+ end
76
+
77
+ def restore(serialized_episodes)
78
+ @episodes = Array(serialized_episodes).map { |episode| coerce_episode(episode) }
79
+ self
80
+ end
81
+
82
+ def explain
83
+ return "No episodes recorded." if @episodes.empty?
84
+
85
+ @episodes.sort_by(&:timestamp).map do |episode|
86
+ turn_label = episode.turn_number ? "Turn #{episode.turn_number}" : "Turn ?"
87
+ "#{turn_label} | #{episode_label(episode)}#{episode_parent_label(episode)}"
88
+ end.join("\n")
89
+ end
90
+
91
+ private
92
+
93
+ def by_type(type)
94
+ target = type.to_sym
95
+ @episodes.select { |episode| episode.type == target }
96
+ end
97
+
98
+ def coerce_episode(episode)
99
+ return episode if episode.is_a?(Episode)
100
+
101
+ Episode.from_h(episode)
102
+ end
103
+
104
+ def episode_label(episode)
105
+ case episode.type
106
+ when :user_message
107
+ "User message: #{summarize(episode.content)}"
108
+ when :decision
109
+ decision = episode.metadata[:decision] || episode.metadata["decision"] || "decision"
110
+ "Decision (#{decision}): #{summarize(episode.content)}"
111
+ when :tool_call
112
+ tool_name = episode.metadata[:tool_name] || episode.metadata["tool_name"] || "unknown_tool"
113
+ "Tool call #{tool_name}: #{summarize(episode.content)}"
114
+ when :external_data
115
+ source = episode.metadata[:source] || episode.metadata["source"] || "external"
116
+ "External data (#{source}): #{summarize(episode.content)}"
117
+ when :assistant_response
118
+ "Assistant response: #{summarize(episode.content)}"
119
+ else
120
+ "#{episode.type}: #{summarize(episode.content)}"
121
+ end
122
+ end
123
+
124
+ def episode_parent_label(episode)
125
+ return "" unless episode.parent_episode_id
126
+
127
+ " [after #{episode.parent_episode_id}]"
128
+ end
129
+
130
+ def summarize(content)
131
+ text = case content
132
+ when Spurline::Security::Content
133
+ content.text
134
+ when String
135
+ content
136
+ else
137
+ content.inspect
138
+ end
139
+
140
+ cleaned = text.to_s.gsub(/\s+/, " ").strip
141
+ return cleaned if cleaned.length <= 120
142
+
143
+ "#{cleaned[0, 117]}..."
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,11 @@
1
+ <claude-mem-context>
2
+ # Recent Activity
3
+
4
+ <!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
5
+
6
+ ### Feb 21, 2026
7
+
8
+ | ID | Time | T | Title | Read |
9
+ |----|------|---|-------|------|
10
+ | #3664 | 8:45 PM | 🔵 | Spurline Milestone 1 implementation status audit completed | ~598 |
11
+ </claude-mem-context>
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Memory
5
+ module LongTerm
6
+ class Base
7
+ def store(content:, metadata: {})
8
+ raise NotImplementedError, "#{self.class.name} must implement #store"
9
+ end
10
+
11
+ # Returns an array of Security::Content objects at :operator trust.
12
+ def retrieve(query:, limit: 5)
13
+ raise NotImplementedError, "#{self.class.name} must implement #retrieve"
14
+ end
15
+
16
+ def clear!
17
+ raise NotImplementedError, "#{self.class.name} must implement #clear!"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Spurline
6
+ module Memory
7
+ module LongTerm
8
+ class Postgres < Base
9
+ TABLE_NAME = "spurline_memories"
10
+
11
+ def initialize(connection_string:, embedder:)
12
+ @connection_string = connection_string
13
+ @embedder = embedder
14
+ @connection = nil
15
+ end
16
+
17
+ def store(content:, metadata: {})
18
+ embedding = @embedder.embed(content)
19
+ session_id = metadata[:session_id] || metadata["session_id"]
20
+ sql = <<~SQL
21
+ INSERT INTO #{TABLE_NAME} (session_id, content, embedding, metadata)
22
+ VALUES ($1, $2, $3::vector, $4::jsonb)
23
+ SQL
24
+ params = [
25
+ session_id,
26
+ content,
27
+ vector_literal(embedding),
28
+ JSON.generate(metadata),
29
+ ]
30
+
31
+ # ASYNC-READY: Database writes are synchronous in v1 at this boundary.
32
+ connection.exec_params(sql, params)
33
+ rescue StandardError => e
34
+ raise Spurline::LongTermMemoryError, "Failed storing long-term memory: #{e.message}"
35
+ end
36
+
37
+ def retrieve(query:, limit: 5)
38
+ query_embedding = @embedder.embed(query)
39
+ sql = <<~SQL
40
+ SELECT content, metadata
41
+ FROM #{TABLE_NAME}
42
+ ORDER BY embedding <-> $1::vector
43
+ LIMIT $2
44
+ SQL
45
+ params = [vector_literal(query_embedding), limit]
46
+ # ASYNC-READY: Database reads are synchronous in v1 at this boundary.
47
+ result = connection.exec_params(sql, params)
48
+
49
+ result.map do |row|
50
+ Security::Content.new(
51
+ text: row["content"],
52
+ trust: :operator,
53
+ source: "memory:long_term"
54
+ )
55
+ end
56
+ rescue StandardError => e
57
+ raise Spurline::LongTermMemoryError, "Failed retrieving long-term memory: #{e.message}"
58
+ end
59
+
60
+ def clear!
61
+ connection.exec("DELETE FROM #{TABLE_NAME}")
62
+ rescue StandardError => e
63
+ raise Spurline::LongTermMemoryError, "Failed clearing long-term memory: #{e.message}"
64
+ end
65
+
66
+ def create_table!
67
+ dim = @embedder.dimensions
68
+
69
+ connection.exec("CREATE EXTENSION IF NOT EXISTS vector")
70
+ connection.exec(<<~SQL)
71
+ CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
72
+ id BIGSERIAL PRIMARY KEY,
73
+ session_id TEXT,
74
+ content TEXT NOT NULL,
75
+ embedding vector(#{dim}) NOT NULL,
76
+ metadata JSONB DEFAULT '{}',
77
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
78
+ )
79
+ SQL
80
+ connection.exec(<<~SQL)
81
+ CREATE INDEX IF NOT EXISTS idx_#{TABLE_NAME}_session_id
82
+ ON #{TABLE_NAME} (session_id)
83
+ SQL
84
+ rescue StandardError => e
85
+ raise Spurline::LongTermMemoryError, "Failed creating long-term memory schema: #{e.message}"
86
+ end
87
+
88
+ private
89
+
90
+ def vector_literal(embedding)
91
+ "[#{embedding.join(",")}]"
92
+ end
93
+
94
+ def connection
95
+ @connection ||= begin
96
+ require "pg"
97
+ PG.connect(@connection_string)
98
+ rescue LoadError
99
+ raise Spurline::LongTermMemoryError,
100
+ "The 'pg' gem is required for long-term memory adapter :postgres"
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Memory
5
+ # Orchestrates short-term and long-term memory stores.
6
+ class Manager
7
+ attr_reader :short_term, :long_term, :episodic
8
+
9
+ def initialize(config: {})
10
+ window = config.fetch(:short_term, {}).fetch(:window, ShortTerm::DEFAULT_WINDOW)
11
+ @short_term = ShortTerm.new(window: window)
12
+ @long_term = build_long_term_store(config.fetch(:long_term, nil))
13
+ @episodic = build_episodic_store(config.fetch(:episodic, nil))
14
+ end
15
+
16
+ def add_turn(turn)
17
+ evicted_before = short_term.last_evicted
18
+ short_term.add_turn(turn)
19
+
20
+ evicted_turn = short_term.last_evicted
21
+ if long_term && evicted_turn && !evicted_turn.equal?(evicted_before)
22
+ persist_to_long_term!(evicted_turn)
23
+ end
24
+ end
25
+
26
+ def recent_turns(n = nil)
27
+ short_term.recent(n)
28
+ end
29
+
30
+ def turn_count
31
+ short_term.size
32
+ end
33
+
34
+ def recall(query:, limit: 5)
35
+ return [] unless long_term
36
+
37
+ long_term.retrieve(query: query, limit: limit)
38
+ end
39
+
40
+ def clear!
41
+ short_term.clear!
42
+ long_term&.clear!
43
+ episodic&.clear!
44
+ end
45
+
46
+ # Whether any turns have been evicted from the window.
47
+ # Useful for determining if summarization should kick in.
48
+ def window_overflowed?
49
+ !short_term.last_evicted.nil?
50
+ end
51
+
52
+ def record_episode(type:, content:, metadata: {}, turn_number: nil, parent_episode_id: nil, timestamp: Time.now)
53
+ return nil unless episodic
54
+
55
+ episodic.record(
56
+ type: type,
57
+ content: content,
58
+ metadata: metadata,
59
+ turn_number: turn_number,
60
+ parent_episode_id: parent_episode_id,
61
+ timestamp: timestamp
62
+ )
63
+ end
64
+
65
+ def restore_episodes(serialized_episodes)
66
+ return unless episodic
67
+
68
+ episodic.restore(serialized_episodes)
69
+ end
70
+
71
+ private
72
+
73
+ def build_long_term_store(config)
74
+ return nil unless config
75
+
76
+ adapter = config[:adapter]
77
+ case adapter
78
+ when :postgres
79
+ embedder = build_embedder(config)
80
+ LongTerm::Postgres.new(connection_string: config[:connection_string], embedder: embedder)
81
+ when nil
82
+ nil
83
+ else
84
+ return adapter if adapter.respond_to?(:store) && adapter.respond_to?(:retrieve)
85
+
86
+ raise Spurline::ConfigurationError,
87
+ "Unknown long-term memory adapter: #{adapter.inspect}."
88
+ end
89
+ end
90
+
91
+ def build_embedder(config)
92
+ model = config[:embedding_model] || config[:embedder]
93
+
94
+ case model
95
+ when :openai
96
+ Embedder::OpenAI.new
97
+ when nil
98
+ raise Spurline::ConfigurationError,
99
+ "Long-term memory requires an embedding_model. " \
100
+ "Example: memory :long_term, adapter: :postgres, embedding_model: :openai"
101
+ else
102
+ return model if model.respond_to?(:embed) && model.respond_to?(:dimensions)
103
+
104
+ raise Spurline::ConfigurationError,
105
+ "Unknown embedding model: #{model.inspect}."
106
+ end
107
+ end
108
+
109
+ def persist_to_long_term!(turn)
110
+ text_parts = []
111
+ text_parts << extract_text(turn.input) if turn.input
112
+ text_parts << extract_text(turn.output) if turn.output
113
+ content_text = text_parts.join("\n")
114
+ return if content_text.strip.empty?
115
+
116
+ long_term.store(content: content_text, metadata: { turn_number: turn.number })
117
+ end
118
+
119
+ def build_episodic_store(config)
120
+ enabled = case config
121
+ when nil
122
+ true
123
+ when true, false
124
+ config
125
+ when Hash
126
+ config.fetch(:enabled, true)
127
+ else
128
+ !!config
129
+ end
130
+
131
+ episodes = config.is_a?(Hash) ? config.fetch(:episodes, []) : []
132
+ EpisodicStore.new(enabled: enabled, episodes: episodes)
133
+ end
134
+
135
+ def extract_text(value)
136
+ case value
137
+ when Security::Content
138
+ value.text
139
+ when String
140
+ value
141
+ else
142
+ value.to_s
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Memory
5
+ # Sliding window of recent turns. Holds Turn objects with their Content
6
+ # (input/output) carrying inherited trust levels.
7
+ #
8
+ # When the window overflows, oldest turns are evicted. The last evicted
9
+ # turn is available via #last_evicted for potential summarization.
10
+ class ShortTerm
11
+ DEFAULT_WINDOW = 20
12
+
13
+ attr_reader :window_size, :last_evicted
14
+
15
+ def initialize(window: DEFAULT_WINDOW)
16
+ @window_size = window
17
+ @turns = []
18
+ @last_evicted = nil
19
+ end
20
+
21
+ def add_turn(turn)
22
+ @turns << turn
23
+ trim!
24
+ end
25
+
26
+ # Returns recent turns as an array, most recent last.
27
+ def recent(n = nil)
28
+ n ? @turns.last(n) : @turns.dup
29
+ end
30
+
31
+ def size
32
+ @turns.length
33
+ end
34
+
35
+ def full?
36
+ @turns.length >= @window_size
37
+ end
38
+
39
+ def empty?
40
+ @turns.empty?
41
+ end
42
+
43
+ def clear!
44
+ @turns.clear
45
+ @last_evicted = nil
46
+ end
47
+
48
+ private
49
+
50
+ def trim!
51
+ while @turns.length > window_size
52
+ @last_evicted = @turns.shift
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end