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.
- checksums.yaml +7 -0
- data/lib/CLAUDE.md +11 -0
- data/lib/spurline/CLAUDE.md +16 -0
- data/lib/spurline/adapters/CLAUDE.md +12 -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/CLAUDE.md +12 -0
- data/lib/spurline/cartographer/analyzer.rb +71 -0
- data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -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/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +18 -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/CLAUDE.md +12 -0
- data/lib/spurline/memory/context_assembler.rb +100 -0
- data/lib/spurline/memory/embedder/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +12 -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/CLAUDE.md +12 -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 +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
|