spurline-deploy 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 (109) hide show
  1. checksums.yaml +7 -0
  2. data/lib/spurline/adapters/base.rb +17 -0
  3. data/lib/spurline/adapters/claude.rb +208 -0
  4. data/lib/spurline/adapters/open_ai.rb +213 -0
  5. data/lib/spurline/adapters/registry.rb +33 -0
  6. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  7. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  8. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  9. data/lib/spurline/agent.rb +433 -0
  10. data/lib/spurline/audit/log.rb +156 -0
  11. data/lib/spurline/audit/secret_filter.rb +121 -0
  12. data/lib/spurline/base.rb +130 -0
  13. data/lib/spurline/cartographer/analyzer.rb +71 -0
  14. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  15. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  16. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  17. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  18. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  19. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  20. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  21. data/lib/spurline/cartographer/runner.rb +88 -0
  22. data/lib/spurline/cartographer.rb +6 -0
  23. data/lib/spurline/channels/base.rb +41 -0
  24. data/lib/spurline/channels/event.rb +136 -0
  25. data/lib/spurline/channels/github.rb +205 -0
  26. data/lib/spurline/channels/router.rb +103 -0
  27. data/lib/spurline/cli/check.rb +88 -0
  28. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  29. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  30. data/lib/spurline/cli/checks/base.rb +35 -0
  31. data/lib/spurline/cli/checks/credentials.rb +43 -0
  32. data/lib/spurline/cli/checks/permissions.rb +22 -0
  33. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  34. data/lib/spurline/cli/checks/session_store.rb +97 -0
  35. data/lib/spurline/cli/console.rb +73 -0
  36. data/lib/spurline/cli/credentials.rb +181 -0
  37. data/lib/spurline/cli/generators/agent.rb +123 -0
  38. data/lib/spurline/cli/generators/migration.rb +62 -0
  39. data/lib/spurline/cli/generators/project.rb +331 -0
  40. data/lib/spurline/cli/generators/tool.rb +98 -0
  41. data/lib/spurline/cli/router.rb +121 -0
  42. data/lib/spurline/configuration.rb +23 -0
  43. data/lib/spurline/dsl/guardrails.rb +108 -0
  44. data/lib/spurline/dsl/hooks.rb +51 -0
  45. data/lib/spurline/dsl/memory.rb +39 -0
  46. data/lib/spurline/dsl/model.rb +23 -0
  47. data/lib/spurline/dsl/persona.rb +74 -0
  48. data/lib/spurline/dsl/suspend_until.rb +53 -0
  49. data/lib/spurline/dsl/tools.rb +176 -0
  50. data/lib/spurline/errors.rb +109 -0
  51. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  52. data/lib/spurline/lifecycle/runner.rb +456 -0
  53. data/lib/spurline/lifecycle/states.rb +47 -0
  54. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  55. data/lib/spurline/memory/context_assembler.rb +100 -0
  56. data/lib/spurline/memory/embedder/base.rb +17 -0
  57. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  58. data/lib/spurline/memory/episode.rb +56 -0
  59. data/lib/spurline/memory/episodic_store.rb +147 -0
  60. data/lib/spurline/memory/long_term/base.rb +22 -0
  61. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  62. data/lib/spurline/memory/manager.rb +147 -0
  63. data/lib/spurline/memory/short_term.rb +57 -0
  64. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  65. data/lib/spurline/orchestration/judge.rb +109 -0
  66. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  67. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  68. data/lib/spurline/orchestration/ledger.rb +339 -0
  69. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  70. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  71. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  72. data/lib/spurline/persona/base.rb +42 -0
  73. data/lib/spurline/persona/registry.rb +42 -0
  74. data/lib/spurline/secrets/resolver.rb +65 -0
  75. data/lib/spurline/secrets/vault.rb +42 -0
  76. data/lib/spurline/security/content.rb +76 -0
  77. data/lib/spurline/security/context_pipeline.rb +58 -0
  78. data/lib/spurline/security/gates/base.rb +36 -0
  79. data/lib/spurline/security/gates/operator_config.rb +22 -0
  80. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  81. data/lib/spurline/security/gates/tool_result.rb +23 -0
  82. data/lib/spurline/security/gates/user_input.rb +22 -0
  83. data/lib/spurline/security/injection_scanner.rb +109 -0
  84. data/lib/spurline/security/pii_filter.rb +104 -0
  85. data/lib/spurline/session/resumption.rb +36 -0
  86. data/lib/spurline/session/serializer.rb +169 -0
  87. data/lib/spurline/session/session.rb +154 -0
  88. data/lib/spurline/session/store/base.rb +27 -0
  89. data/lib/spurline/session/store/memory.rb +45 -0
  90. data/lib/spurline/session/store/postgres.rb +123 -0
  91. data/lib/spurline/session/store/sqlite.rb +139 -0
  92. data/lib/spurline/session/suspension.rb +93 -0
  93. data/lib/spurline/session/turn.rb +98 -0
  94. data/lib/spurline/spur.rb +213 -0
  95. data/lib/spurline/streaming/buffer.rb +77 -0
  96. data/lib/spurline/streaming/chunk.rb +62 -0
  97. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  98. data/lib/spurline/testing.rb +245 -0
  99. data/lib/spurline/toolkit.rb +110 -0
  100. data/lib/spurline/tools/base.rb +209 -0
  101. data/lib/spurline/tools/idempotency.rb +220 -0
  102. data/lib/spurline/tools/permissions.rb +44 -0
  103. data/lib/spurline/tools/registry.rb +43 -0
  104. data/lib/spurline/tools/runner.rb +255 -0
  105. data/lib/spurline/tools/scope.rb +309 -0
  106. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  107. data/lib/spurline/version.rb +5 -0
  108. data/lib/spurline.rb +56 -0
  109. metadata +161 -0
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Lifecycle
5
+ # Immutable value object marking where suspension can happen.
6
+ # Types: :after_tool_result, :before_llm_call
7
+ class SuspensionBoundary
8
+ TYPES = %i[after_tool_result before_llm_call].freeze
9
+
10
+ attr_reader :type, :context
11
+
12
+ def initialize(type:, context: {})
13
+ normalized_type = type.to_sym
14
+ unless TYPES.include?(normalized_type)
15
+ raise ArgumentError,
16
+ "Invalid suspension boundary type #{type.inspect}. " \
17
+ "Expected one of #{TYPES.inspect}."
18
+ end
19
+
20
+ unless context.is_a?(Hash)
21
+ raise ArgumentError, "Suspension boundary context must be a Hash"
22
+ end
23
+
24
+ @type = normalized_type
25
+ @context = context.dup.freeze
26
+ freeze
27
+ end
28
+ end
29
+
30
+ # Internal flow control signal — NOT an error class.
31
+ # Raised by Runner when suspension_check returns :suspend.
32
+ # Caught by Agent to trigger session suspension.
33
+ class SuspensionSignal < StandardError
34
+ attr_reader :checkpoint
35
+
36
+ def initialize(checkpoint:)
37
+ @checkpoint = checkpoint
38
+ super("Agent suspended at boundary")
39
+ end
40
+ end
41
+
42
+ # Callable interface for suspension decisions.
43
+ # Receives a SuspensionBoundary, returns :continue or :suspend.
44
+ class SuspensionCheck
45
+ def initialize(&block)
46
+ @check = block || ->(_boundary) { :continue }
47
+ end
48
+
49
+ def call(boundary)
50
+ result = @check.call(boundary)
51
+ unless %i[continue suspend].include?(result)
52
+ raise ArgumentError,
53
+ "SuspensionCheck must return :continue or :suspend, got #{result.inspect}"
54
+ end
55
+
56
+ result
57
+ end
58
+
59
+ # Factory: always continue (default)
60
+ def self.none
61
+ new { :continue }
62
+ end
63
+
64
+ # Factory: suspend after N tool calls
65
+ def self.after_tool_calls(n)
66
+ unless n.is_a?(Integer) && n.positive?
67
+ raise ArgumentError, "n must be a positive Integer"
68
+ end
69
+
70
+ count = 0
71
+ new do |boundary|
72
+ if boundary.type == :after_tool_result
73
+ count += 1
74
+ count >= n ? :suspend : :continue
75
+ else
76
+ :continue
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+ require "date"
3
+
4
+ module Spurline
5
+ module Memory
6
+ # Assembles context for the LLM from persona, memory, and user input.
7
+ # Returns an ordered array of Content objects — never raw strings.
8
+ #
9
+ # Assembly order:
10
+ # 1. System prompt (trust: :system)
11
+ # 2. Persona supplements (trust: :system, optional)
12
+ # 3. Recalled long-term memories (trust: :operator, optional)
13
+ # 4. Recent conversation history (trust: inherited from original)
14
+ # 5. Current user input (trust: :user)
15
+ class ContextAssembler
16
+ def assemble(input:, memory:, persona:, session: nil, agent_context: nil)
17
+ contents = []
18
+
19
+ # 1. System prompt (trust: :system)
20
+ contents << persona.render if persona
21
+
22
+ # 2. Persona injection supplements (trust: :system)
23
+ if persona
24
+ inject_persona_supplements!(
25
+ contents,
26
+ persona,
27
+ session: session,
28
+ agent_context: agent_context
29
+ )
30
+ end
31
+
32
+ # 3. Recalled long-term memories (trust: :operator)
33
+ if memory.respond_to?(:recall)
34
+ recalled = memory.recall(query: extract_query_text(input), limit: 5)
35
+ contents.concat(recalled) if recalled.any?
36
+ end
37
+
38
+ # 4. Recent conversation history (trust: inherited from original)
39
+ memory.recent_turns.each do |turn|
40
+ contents << turn.input if turn.input.is_a?(Security::Content)
41
+ contents << turn.output if turn.output.is_a?(Security::Content)
42
+ end
43
+
44
+ # 5. Current user input (trust: :user)
45
+ contents << input if input.is_a?(Security::Content)
46
+
47
+ contents.compact
48
+ end
49
+
50
+ # Estimates token count for assembled context. Rough approximation
51
+ # at ~4 characters per token. Used for trimming decisions.
52
+ def estimate_tokens(contents)
53
+ contents.sum { |c| (c.text.length / 4.0).ceil }
54
+ end
55
+
56
+ private
57
+
58
+ def inject_persona_supplements!(contents, persona, session:, agent_context:)
59
+ if persona.inject_date?
60
+ contents << Security::Gates::SystemPrompt.wrap(
61
+ "Current date: #{Date.today.iso8601}",
62
+ persona: "injection:date"
63
+ )
64
+ end
65
+
66
+ if persona.inject_user_context? && session&.user
67
+ contents << Security::Gates::SystemPrompt.wrap(
68
+ "Current user: #{session.user}",
69
+ persona: "injection:user_context"
70
+ )
71
+ end
72
+
73
+ if persona.inject_agent_context? && agent_context
74
+ contents << Security::Gates::SystemPrompt.wrap(
75
+ build_agent_context_text(agent_context),
76
+ persona: "injection:agent_context"
77
+ )
78
+ end
79
+ end
80
+
81
+ def build_agent_context_text(context)
82
+ parts = []
83
+ parts << "Agent: #{context[:class_name]}" if context[:class_name]
84
+ if context[:tool_names]&.any?
85
+ parts << "Available tools: #{context[:tool_names].join(', ')}"
86
+ end
87
+ parts.join("\n")
88
+ end
89
+
90
+ def extract_query_text(input)
91
+ case input
92
+ when Security::Content
93
+ input.text
94
+ else
95
+ input.to_s
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -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,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