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.
- checksums.yaml +7 -0
- data/lib/spurline/adapters/base.rb +17 -0
- data/lib/spurline/adapters/claude.rb +208 -0
- data/lib/spurline/adapters/open_ai.rb +213 -0
- data/lib/spurline/adapters/registry.rb +33 -0
- data/lib/spurline/adapters/scheduler/base.rb +15 -0
- data/lib/spurline/adapters/scheduler/sync.rb +15 -0
- data/lib/spurline/adapters/stub_adapter.rb +54 -0
- data/lib/spurline/agent.rb +433 -0
- data/lib/spurline/audit/log.rb +156 -0
- data/lib/spurline/audit/secret_filter.rb +121 -0
- data/lib/spurline/base.rb +130 -0
- data/lib/spurline/cartographer/analyzer.rb +71 -0
- data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
- data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
- data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
- data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
- data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
- data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
- data/lib/spurline/cartographer/repo_profile.rb +140 -0
- data/lib/spurline/cartographer/runner.rb +88 -0
- data/lib/spurline/cartographer.rb +6 -0
- data/lib/spurline/channels/base.rb +41 -0
- data/lib/spurline/channels/event.rb +136 -0
- data/lib/spurline/channels/github.rb +205 -0
- data/lib/spurline/channels/router.rb +103 -0
- data/lib/spurline/cli/check.rb +88 -0
- data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
- data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
- data/lib/spurline/cli/checks/base.rb +35 -0
- data/lib/spurline/cli/checks/credentials.rb +43 -0
- data/lib/spurline/cli/checks/permissions.rb +22 -0
- data/lib/spurline/cli/checks/project_structure.rb +48 -0
- data/lib/spurline/cli/checks/session_store.rb +97 -0
- data/lib/spurline/cli/console.rb +73 -0
- data/lib/spurline/cli/credentials.rb +181 -0
- data/lib/spurline/cli/generators/agent.rb +123 -0
- data/lib/spurline/cli/generators/migration.rb +62 -0
- data/lib/spurline/cli/generators/project.rb +331 -0
- data/lib/spurline/cli/generators/tool.rb +98 -0
- data/lib/spurline/cli/router.rb +121 -0
- data/lib/spurline/configuration.rb +23 -0
- data/lib/spurline/dsl/guardrails.rb +108 -0
- data/lib/spurline/dsl/hooks.rb +51 -0
- data/lib/spurline/dsl/memory.rb +39 -0
- data/lib/spurline/dsl/model.rb +23 -0
- data/lib/spurline/dsl/persona.rb +74 -0
- data/lib/spurline/dsl/suspend_until.rb +53 -0
- data/lib/spurline/dsl/tools.rb +176 -0
- data/lib/spurline/errors.rb +109 -0
- data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
- data/lib/spurline/lifecycle/runner.rb +456 -0
- data/lib/spurline/lifecycle/states.rb +47 -0
- data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
- data/lib/spurline/memory/context_assembler.rb +100 -0
- data/lib/spurline/memory/embedder/base.rb +17 -0
- data/lib/spurline/memory/embedder/open_ai.rb +70 -0
- data/lib/spurline/memory/episode.rb +56 -0
- data/lib/spurline/memory/episodic_store.rb +147 -0
- data/lib/spurline/memory/long_term/base.rb +22 -0
- data/lib/spurline/memory/long_term/postgres.rb +106 -0
- data/lib/spurline/memory/manager.rb +147 -0
- data/lib/spurline/memory/short_term.rb +57 -0
- data/lib/spurline/orchestration/agent_spawner.rb +151 -0
- data/lib/spurline/orchestration/judge.rb +109 -0
- data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
- data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
- data/lib/spurline/orchestration/ledger.rb +339 -0
- data/lib/spurline/orchestration/merge_queue.rb +133 -0
- data/lib/spurline/orchestration/permission_intersection.rb +151 -0
- data/lib/spurline/orchestration/task_envelope.rb +201 -0
- data/lib/spurline/persona/base.rb +42 -0
- data/lib/spurline/persona/registry.rb +42 -0
- data/lib/spurline/secrets/resolver.rb +65 -0
- data/lib/spurline/secrets/vault.rb +42 -0
- data/lib/spurline/security/content.rb +76 -0
- data/lib/spurline/security/context_pipeline.rb +58 -0
- data/lib/spurline/security/gates/base.rb +36 -0
- data/lib/spurline/security/gates/operator_config.rb +22 -0
- data/lib/spurline/security/gates/system_prompt.rb +23 -0
- data/lib/spurline/security/gates/tool_result.rb +23 -0
- data/lib/spurline/security/gates/user_input.rb +22 -0
- data/lib/spurline/security/injection_scanner.rb +109 -0
- data/lib/spurline/security/pii_filter.rb +104 -0
- data/lib/spurline/session/resumption.rb +36 -0
- data/lib/spurline/session/serializer.rb +169 -0
- data/lib/spurline/session/session.rb +154 -0
- data/lib/spurline/session/store/base.rb +27 -0
- data/lib/spurline/session/store/memory.rb +45 -0
- data/lib/spurline/session/store/postgres.rb +123 -0
- data/lib/spurline/session/store/sqlite.rb +139 -0
- data/lib/spurline/session/suspension.rb +93 -0
- data/lib/spurline/session/turn.rb +98 -0
- data/lib/spurline/spur.rb +213 -0
- data/lib/spurline/streaming/buffer.rb +77 -0
- data/lib/spurline/streaming/chunk.rb +62 -0
- data/lib/spurline/streaming/stream_enumerator.rb +29 -0
- data/lib/spurline/testing.rb +245 -0
- data/lib/spurline/toolkit.rb +110 -0
- data/lib/spurline/tools/base.rb +209 -0
- data/lib/spurline/tools/idempotency.rb +220 -0
- data/lib/spurline/tools/permissions.rb +44 -0
- data/lib/spurline/tools/registry.rb +43 -0
- data/lib/spurline/tools/runner.rb +255 -0
- data/lib/spurline/tools/scope.rb +309 -0
- data/lib/spurline/tools/toolkit_registry.rb +63 -0
- data/lib/spurline/version.rb +5 -0
- data/lib/spurline.rb +56 -0
- 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
|