llmemory 0.2.11 → 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 +4 -4
- data/README.md +23 -0
- data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +71 -0
- data/lib/generators/llmemory/upgrade_zero_mem/templates/create_llmemory_zero_mem_tables.rb +77 -0
- data/lib/generators/llmemory/upgrade_zero_mem/upgrade_zero_mem_generator.rb +19 -0
- data/lib/llmemory/cli/commands/zero_mem.rb +107 -0
- data/lib/llmemory/cli.rb +3 -0
- data/lib/llmemory/configuration.rb +59 -1
- data/lib/llmemory/instrumentation.rb +10 -1
- data/lib/llmemory/maintenance/cognitive_pass.rb +29 -1
- data/lib/llmemory/maintenance/zero_mem_ttl.rb +26 -0
- data/lib/llmemory/maintenance.rb +1 -0
- data/lib/llmemory/mcp/server.rb +2 -0
- data/lib/llmemory/mcp/store_helpers.rb +16 -0
- data/lib/llmemory/mcp/tools/memory_add_message.rb +17 -4
- data/lib/llmemory/mcp/tools/memory_consolidate.rb +9 -4
- data/lib/llmemory/mcp/tools/memory_maintain.rb +6 -1
- data/lib/llmemory/mcp/tools/memory_retrieve.rb +3 -1
- data/lib/llmemory/mcp/tools/memory_retrieve_evidence.rb +70 -0
- data/lib/llmemory/mcp/tools/memory_stats.rb +22 -0
- data/lib/llmemory/memory.rb +257 -9
- data/lib/llmemory/retrieval/bm25_scorer.rb +5 -0
- data/lib/llmemory/tokenizer.rb +18 -8
- data/lib/llmemory/vector_store/memory_store.rb +23 -1
- data/lib/llmemory/vector_store/openai_embeddings.rb +47 -21
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory/zero_mem/answer_calibrator.rb +63 -0
- data/lib/llmemory/zero_mem/backfill.rb +43 -0
- data/lib/llmemory/zero_mem/budget_assembler.rb +62 -0
- data/lib/llmemory/zero_mem/circuit_breaker.rb +37 -0
- data/lib/llmemory/zero_mem/embedding_provider.rb +73 -0
- data/lib/llmemory/zero_mem/engine.rb +247 -0
- data/lib/llmemory/zero_mem/entity.rb +31 -0
- data/lib/llmemory/zero_mem/entity_extractor.rb +23 -0
- data/lib/llmemory/zero_mem/entity_index.rb +65 -0
- data/lib/llmemory/zero_mem/entity_mention.rb +35 -0
- data/lib/llmemory/zero_mem/errors.rb +7 -0
- data/lib/llmemory/zero_mem/evidence.rb +43 -0
- data/lib/llmemory/zero_mem/evidence_calibrator.rb +158 -0
- data/lib/llmemory/zero_mem/evidence_closure.rb +98 -0
- data/lib/llmemory/zero_mem/evidence_fusion.rb +57 -0
- data/lib/llmemory/zero_mem/evidence_set.rb +54 -0
- data/lib/llmemory/zero_mem/extractors/heuristic.rb +44 -0
- data/lib/llmemory/zero_mem/extractors/http.rb +58 -0
- data/lib/llmemory/zero_mem/graph_retriever.rb +107 -0
- data/lib/llmemory/zero_mem/hierarchy_builder.rb +123 -0
- data/lib/llmemory/zero_mem/hierarchy_retriever.rb +133 -0
- data/lib/llmemory/zero_mem/indexer.rb +73 -0
- data/lib/llmemory/zero_mem/mode.rb +30 -0
- data/lib/llmemory/zero_mem/pagerank.rb +42 -0
- data/lib/llmemory/zero_mem/query_profile.rb +48 -0
- data/lib/llmemory/zero_mem/query_profiler.rb +138 -0
- data/lib/llmemory/zero_mem/repair.rb +31 -0
- data/lib/llmemory/zero_mem/router.rb +69 -0
- data/lib/llmemory/zero_mem/sidecar_client.rb +82 -0
- data/lib/llmemory/zero_mem/storage.rb +124 -0
- data/lib/llmemory/zero_mem/storages/active_record.rb +311 -0
- data/lib/llmemory/zero_mem/storages/active_record_models.rb +21 -0
- data/lib/llmemory/zero_mem/storages/file.rb +67 -0
- data/lib/llmemory/zero_mem/storages/memory.rb +294 -0
- data/lib/llmemory/zero_mem/storages/postgres.rb +85 -0
- data/lib/llmemory/zero_mem/storages/snapshot_codec.rb +180 -0
- data/lib/llmemory/zero_mem/storages.rb +43 -0
- data/lib/llmemory/zero_mem/trace.rb +100 -0
- data/lib/llmemory/zero_mem/trace_state_link.rb +64 -0
- data/lib/llmemory/zero_mem/trace_unit.rb +83 -0
- data/lib/llmemory/zero_mem.rb +42 -0
- data/lib/llmemory.rb +7 -0
- data/lib/tasks/llmemory.rake +52 -0
- metadata +48 -2
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
class Repair
|
|
6
|
+
def initialize(storage:)
|
|
7
|
+
@storage = storage
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Reconcile watermark with persisted traces and checkpoint message counts (ZM1).
|
|
11
|
+
def run!(user_id:, session_id:, checkpoint_messages:)
|
|
12
|
+
traces = @storage.list_traces(user_id, session_id: session_id)
|
|
13
|
+
last_sequence = traces.map(&:sequence).max.to_i
|
|
14
|
+
wm = @storage.get_watermark(user_id, session_id)
|
|
15
|
+
@storage.set_watermark(
|
|
16
|
+
user_id,
|
|
17
|
+
session_id,
|
|
18
|
+
last_sequence: last_sequence,
|
|
19
|
+
index_version: [wm[:index_version].to_i, last_sequence].max
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
trace_count: traces.size,
|
|
24
|
+
checkpoint_message_count: Array(checkpoint_messages).size,
|
|
25
|
+
last_sequence: last_sequence,
|
|
26
|
+
index_version: [wm[:index_version].to_i, last_sequence].max
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
class Router
|
|
6
|
+
RELATIONAL_WORKLOADS = %i[multi_hop].freeze
|
|
7
|
+
LOCAL_WORKLOADS = %i[temporal procedural cross_session local_fact].freeze
|
|
8
|
+
|
|
9
|
+
def route(profile, hierarchy_scores:, graph_scores:, config: Llmemory.configuration)
|
|
10
|
+
weights, route, tie_break = route_for_workload(profile, config)
|
|
11
|
+
if tie_break
|
|
12
|
+
weights = resolve_tie(weights, hierarchy_scores, graph_scores, profile)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
route: route,
|
|
17
|
+
weights: weights,
|
|
18
|
+
rho: weights[:graph],
|
|
19
|
+
tie_break: tie_break
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def route_for_workload(profile, config)
|
|
26
|
+
workload = profile.workload_class
|
|
27
|
+
if RELATIONAL_WORKLOADS.include?(workload) || profile.aggregation_cues.any?
|
|
28
|
+
return [relational_weights(config), :relational, false]
|
|
29
|
+
end
|
|
30
|
+
if LOCAL_WORKLOADS.include?(workload)
|
|
31
|
+
return [local_weights(config), :local, false]
|
|
32
|
+
end
|
|
33
|
+
if workload == :current_state
|
|
34
|
+
base = profile.subject_entities.size >= 2 ? relational_weights(config) : local_weights(config)
|
|
35
|
+
return [base, :current_state, profile.subject_entities.size < 2]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
[local_weights(config), :local, true]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def relational_weights(config)
|
|
42
|
+
rho = config.zero_mem_graph_weight || 0.6
|
|
43
|
+
{ graph: rho.to_f, hierarchy: 1.0 - rho.to_f }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def local_weights(_config)
|
|
47
|
+
{ graph: 0.4, hierarchy: 0.6 }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolve_tie(default_weights, hierarchy_scores, graph_scores, profile)
|
|
51
|
+
h = anchor_confidence(hierarchy_scores, profile)
|
|
52
|
+
g = anchor_confidence(graph_scores, profile)
|
|
53
|
+
if g > h
|
|
54
|
+
relational_weights(Llmemory.configuration)
|
|
55
|
+
elsif h > g
|
|
56
|
+
local_weights(Llmemory.configuration)
|
|
57
|
+
else
|
|
58
|
+
default_weights
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def anchor_confidence(scores, profile)
|
|
63
|
+
return 0.0 if scores.nil? || scores.empty?
|
|
64
|
+
|
|
65
|
+
scores.values.sum.to_f / scores.size
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "faraday"
|
|
6
|
+
|
|
7
|
+
module Llmemory
|
|
8
|
+
module ZeroMem
|
|
9
|
+
class SidecarClient
|
|
10
|
+
MAX_TEXT_BYTES = 32_768
|
|
11
|
+
|
|
12
|
+
def initialize(base_url:, timeout: 5, breaker: nil)
|
|
13
|
+
@base_url = base_url.to_s.sub(%r{/\z}, "")
|
|
14
|
+
@timeout = timeout.to_f
|
|
15
|
+
@breaker = breaker || CircuitBreaker.new
|
|
16
|
+
validate_host!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def get_json(path)
|
|
20
|
+
raise StoreError, "sidecar circuit open" unless @breaker.allow_request?
|
|
21
|
+
|
|
22
|
+
response = connection.get(path)
|
|
23
|
+
unless response.success?
|
|
24
|
+
@breaker.record_failure
|
|
25
|
+
raise StoreError, "sidecar HTTP error: #{response.status}"
|
|
26
|
+
end
|
|
27
|
+
@breaker.record_success
|
|
28
|
+
JSON.parse(response.body)
|
|
29
|
+
rescue Faraday::Error
|
|
30
|
+
@breaker.record_failure
|
|
31
|
+
raise StoreError, "sidecar request failed"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def post_json(path, body)
|
|
35
|
+
raise StoreError, "sidecar circuit open" unless @breaker.allow_request?
|
|
36
|
+
|
|
37
|
+
text = body[:text].to_s
|
|
38
|
+
if text.bytesize > MAX_TEXT_BYTES
|
|
39
|
+
raise StoreError, "sidecar payload exceeds max text bytes (#{MAX_TEXT_BYTES})"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
response = connection.post(path) do |req|
|
|
43
|
+
req.headers["Content-Type"] = "application/json"
|
|
44
|
+
req.body = body.to_json
|
|
45
|
+
end
|
|
46
|
+
unless response.success?
|
|
47
|
+
@breaker.record_failure
|
|
48
|
+
raise StoreError, "sidecar HTTP error: #{response.status}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@breaker.record_success
|
|
52
|
+
JSON.parse(response.body)
|
|
53
|
+
rescue Faraday::Error
|
|
54
|
+
@breaker.record_failure
|
|
55
|
+
raise StoreError, "sidecar request failed"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def degraded?
|
|
59
|
+
@breaker.open?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def connection
|
|
65
|
+
@connection ||= Faraday.new(url: @base_url, request: { timeout: @timeout, open_timeout: @timeout }) do |f|
|
|
66
|
+
f.adapter Faraday.default_adapter
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_host!
|
|
71
|
+
return unless Llmemory.configuration.zero_mem_require_local_encoders
|
|
72
|
+
|
|
73
|
+
uri = URI.parse(@base_url)
|
|
74
|
+
host = uri.host.to_s
|
|
75
|
+
allowed = %w[127.0.0.1 localhost ::1]
|
|
76
|
+
return if allowed.include?(host)
|
|
77
|
+
|
|
78
|
+
raise ConfigurationError, "zero_mem sidecar host must be loopback when require_local_encoders is enabled"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
# Append-only trace storage contract (ZM1).
|
|
6
|
+
module Storage
|
|
7
|
+
def write_trace(trace)
|
|
8
|
+
raise NotImplementedError
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def get_trace(user_id, trace_id)
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def find_by_idempotency_key(user_id, idempotency_key)
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def next_sequence(user_id, session_id)
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def list_traces(user_id, session_id: nil, include_archived: false, limit: nil, offset: nil)
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def archive_trace(user_id, trace_id, archived_at: Time.now)
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def write_state_link(link)
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def close_open_state_links(user_id, state_key, valid_to:, except_trace_id: nil)
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def state_links_for(user_id, state_key)
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def current_state_trace_id(user_id, state_key, as_of: Time.now)
|
|
44
|
+
raise NotImplementedError
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def get_watermark(user_id, session_id)
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def set_watermark(user_id, session_id, last_sequence:, index_version:)
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def search_traces_by_tokens(user_id, query, limit: 20)
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def write_unit(unit)
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def list_units(user_id, kind: nil, session_id: nil, boundary_id: nil)
|
|
64
|
+
raise NotImplementedError
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def get_unit(user_id, unit_id)
|
|
68
|
+
raise NotImplementedError
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def open_episode(user_id, episode_key)
|
|
72
|
+
raise NotImplementedError
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def set_open_episode(user_id, episode_key, unit_id)
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def close_episode(unit_id)
|
|
80
|
+
raise NotImplementedError
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def upsert_entity(entity)
|
|
84
|
+
raise NotImplementedError
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def write_mention(mention)
|
|
88
|
+
raise NotImplementedError
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def entity_exists?(user_id, normalized_key)
|
|
92
|
+
raise NotImplementedError
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def trace_ids_for_entity_key(user_id, normalized_key)
|
|
96
|
+
raise NotImplementedError
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def entity_keys_for_trace(user_id, trace_id)
|
|
100
|
+
raise NotImplementedError
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def previous_trace(user_id, session_id, sequence)
|
|
104
|
+
raise NotImplementedError
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def link_adjacent_traces(user_id, prev_trace_id, trace_id)
|
|
108
|
+
raise NotImplementedError
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def adjacent_traces(user_id, trace_id)
|
|
112
|
+
raise NotImplementedError
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def store_turn_embedding(user_id, trace_id, vector, model:, dimensions:)
|
|
116
|
+
raise NotImplementedError
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def all_entity_keys(user_id)
|
|
120
|
+
raise NotImplementedError
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../storage"
|
|
5
|
+
require_relative "../../crypto/field_helpers"
|
|
6
|
+
require_relative "../../active_record_helpers"
|
|
7
|
+
|
|
8
|
+
module Llmemory
|
|
9
|
+
module ZeroMem
|
|
10
|
+
module Storages
|
|
11
|
+
class ActiveRecordStorage
|
|
12
|
+
include Storage
|
|
13
|
+
include Llmemory::Crypto::FieldHelpers
|
|
14
|
+
include Llmemory::ActiveRecordHelpers
|
|
15
|
+
|
|
16
|
+
def initialize(cipher: nil)
|
|
17
|
+
@cipher = cipher || Llmemory.build_cipher
|
|
18
|
+
self.class.load_models!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.load_models!
|
|
22
|
+
return if @models_loaded
|
|
23
|
+
|
|
24
|
+
require "active_record"
|
|
25
|
+
require_relative "active_record_models"
|
|
26
|
+
@models_loaded = true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def write_trace(trace)
|
|
30
|
+
t = trace.is_a?(Trace) ? trace : Trace.new(**trace)
|
|
31
|
+
if t.idempotency_key && (existing = find_by_idempotency_key(t.user_id, t.idempotency_key))
|
|
32
|
+
return existing.id
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
rec = LlmemoryTrace.find_or_initialize_by(id: t.id)
|
|
36
|
+
rec.user_id = t.user_id
|
|
37
|
+
rec.session_id = t.session_id
|
|
38
|
+
rec.boundary_id = t.boundary_id
|
|
39
|
+
rec.sequence = t.sequence
|
|
40
|
+
rec.role = t.role.to_s
|
|
41
|
+
rec.content = enc(t.content)
|
|
42
|
+
rec.content_sha256 = t.content_sha256
|
|
43
|
+
rec.occurred_at = t.occurred_at
|
|
44
|
+
rec.ingested_at = t.ingested_at
|
|
45
|
+
rec.metadata = metadata_column(t.metadata)
|
|
46
|
+
rec.idempotency_key = t.idempotency_key
|
|
47
|
+
rec.archived_at = t.archived_at
|
|
48
|
+
rec.search_tokens = search_tokens_for(t.content) if LlmemoryTrace.column_names.include?("search_tokens")
|
|
49
|
+
with_unique_retry { rec.save! }
|
|
50
|
+
t.id
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def get_trace(user_id, trace_id)
|
|
54
|
+
rec = LlmemoryTrace.find_by(id: trace_id.to_s, user_id: user_id.to_s)
|
|
55
|
+
return nil unless rec
|
|
56
|
+
|
|
57
|
+
record_to_trace(rec)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def find_by_idempotency_key(user_id, idempotency_key)
|
|
61
|
+
return nil if idempotency_key.to_s.empty?
|
|
62
|
+
|
|
63
|
+
rec = LlmemoryTrace.find_by(user_id: user_id.to_s, idempotency_key: idempotency_key.to_s)
|
|
64
|
+
rec ? record_to_trace(rec) : nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def next_sequence(user_id, session_id)
|
|
68
|
+
max = LlmemoryTrace.where(user_id: user_id.to_s, session_id: session_id.to_s).maximum(:sequence)
|
|
69
|
+
(max || 0) + 1
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def list_traces(user_id, session_id: nil, include_archived: false, limit: nil, offset: nil)
|
|
73
|
+
scope = LlmemoryTrace.where(user_id: user_id.to_s)
|
|
74
|
+
scope = scope.where(session_id: session_id.to_s) if session_id
|
|
75
|
+
scope = scope.where(archived_at: nil) unless include_archived
|
|
76
|
+
scope = scope.order(:session_id, :sequence, :ingested_at)
|
|
77
|
+
scope = scope.offset(offset.to_i) if offset
|
|
78
|
+
scope = scope.limit(limit.to_i) if limit && limit.to_i.positive?
|
|
79
|
+
scope.map { |r| record_to_trace(r) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def archive_trace(user_id, trace_id, archived_at: Time.now)
|
|
83
|
+
rec = LlmemoryTrace.find_by(id: trace_id.to_s, user_id: user_id.to_s, archived_at: nil)
|
|
84
|
+
return false unless rec
|
|
85
|
+
|
|
86
|
+
rec.update!(archived_at: archived_at)
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def write_state_link(link)
|
|
91
|
+
rec = LlmemoryTraceStateLink.new(
|
|
92
|
+
user_id: link.user_id,
|
|
93
|
+
state_key: link.state_key,
|
|
94
|
+
trace_id: link.trace_id,
|
|
95
|
+
valid_from: link.valid_from,
|
|
96
|
+
valid_to: link.valid_to,
|
|
97
|
+
supersedes_trace_id: link.supersedes_trace_id,
|
|
98
|
+
source: link.source.to_s,
|
|
99
|
+
created_at: link.created_at
|
|
100
|
+
)
|
|
101
|
+
with_unique_retry { rec.save! }
|
|
102
|
+
link.trace_id
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def close_open_state_links(user_id, state_key, valid_to:, except_trace_id: nil)
|
|
106
|
+
scope = LlmemoryTraceStateLink.where(user_id: user_id.to_s, state_key: state_key.to_s, valid_to: nil)
|
|
107
|
+
scope = scope.where.not(trace_id: except_trace_id.to_s) if except_trace_id
|
|
108
|
+
scope.update_all(valid_to: valid_to)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def state_links_for(user_id, state_key)
|
|
112
|
+
LlmemoryTraceStateLink.where(user_id: user_id.to_s, state_key: state_key.to_s)
|
|
113
|
+
.order(:valid_from, :created_at)
|
|
114
|
+
.map { |r| record_to_state_link(r) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def current_state_trace_id(user_id, state_key, as_of: Time.now)
|
|
118
|
+
t = as_of.is_a?(Time) ? as_of : Time.parse(as_of.to_s)
|
|
119
|
+
link = LlmemoryTraceStateLink.where(user_id: user_id.to_s, state_key: state_key.to_s)
|
|
120
|
+
.where("valid_from <= ?", t)
|
|
121
|
+
.where("valid_to IS NULL OR valid_to >= ?", t)
|
|
122
|
+
.order(valid_from: :desc, created_at: :desc)
|
|
123
|
+
.first
|
|
124
|
+
link&.trace_id
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def get_watermark(user_id, session_id)
|
|
128
|
+
rec = LlmemoryTrace.where(user_id: user_id.to_s, session_id: session_id.to_s)
|
|
129
|
+
.order(sequence: :desc)
|
|
130
|
+
.limit(1)
|
|
131
|
+
.pick(:sequence)
|
|
132
|
+
{
|
|
133
|
+
last_sequence: rec.to_i,
|
|
134
|
+
index_version: rec.to_i
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def set_watermark(user_id, session_id, last_sequence:, index_version:)
|
|
139
|
+
# ZM1: watermark derived from traces; noop placeholder for repair/backfill API.
|
|
140
|
+
{ last_sequence: last_sequence.to_i, index_version: index_version.to_i }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def write_unit(unit)
|
|
144
|
+
return unit.id unless defined?(LlmemoryTraceUnit)
|
|
145
|
+
|
|
146
|
+
data = unit.to_h
|
|
147
|
+
rec = LlmemoryTraceUnit.find_or_initialize_by(id: unit.id)
|
|
148
|
+
rec.user_id = unit.user_id
|
|
149
|
+
rec.trace_id = unit.member_trace_ids.first
|
|
150
|
+
rec.unit_type = unit.kind.to_s
|
|
151
|
+
rec.data = cipher.enabled? ? enc_json(data) : data
|
|
152
|
+
with_unique_retry { rec.save! }
|
|
153
|
+
unit.id
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def list_units(user_id, kind: nil, session_id: nil, boundary_id: nil)
|
|
157
|
+
return [] unless defined?(LlmemoryTraceUnit)
|
|
158
|
+
|
|
159
|
+
scope = LlmemoryTraceUnit.where(user_id: user_id.to_s, archived_at: nil)
|
|
160
|
+
scope = scope.where(unit_type: kind.to_s) if kind
|
|
161
|
+
scope.map { |r| unit_from_record(r) }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def get_unit(user_id, unit_id)
|
|
165
|
+
return nil unless defined?(LlmemoryTraceUnit)
|
|
166
|
+
|
|
167
|
+
rec = LlmemoryTraceUnit.find_by(id: unit_id.to_s, user_id: user_id.to_s)
|
|
168
|
+
rec ? unit_from_record(rec) : nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def open_episode(_user_id, _episode_key)
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def set_open_episode(_user_id, _episode_key, _unit_id)
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def close_episode(_unit_id)
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def upsert_entity(_entity)
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def write_mention(_mention)
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def entity_exists?(_user_id, _normalized_key)
|
|
192
|
+
false
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def trace_ids_for_entity_key(_user_id, _normalized_key)
|
|
196
|
+
[]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def entity_keys_for_trace(_user_id, _trace_id)
|
|
200
|
+
[]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def previous_trace(user_id, session_id, sequence)
|
|
204
|
+
list_traces(user_id, session_id: session_id).reverse.find { |t| t.sequence < sequence.to_i }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def link_adjacent_traces(_user_id, _prev_trace_id, _trace_id)
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def adjacent_traces(_user_id, _trace_id)
|
|
212
|
+
[nil, nil]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def store_turn_embedding(_user_id, _trace_id, _vector, model:, dimensions:)
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def all_entity_keys(_user_id)
|
|
220
|
+
[]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def search_traces_by_tokens(user_id, query, limit: 20)
|
|
224
|
+
scope = LlmemoryTrace.where(user_id: user_id.to_s, archived_at: nil)
|
|
225
|
+
tokens = Llmemory::Tokenizer.tokenize(query)
|
|
226
|
+
return [] if tokens.empty?
|
|
227
|
+
|
|
228
|
+
if cipher.enabled? && LlmemoryTrace.column_names.include?("search_tokens")
|
|
229
|
+
digests = tokens.map { |tok| cipher.blind_index(tok) }
|
|
230
|
+
clause = digests.map { "search_tokens LIKE ?" }.join(" OR ")
|
|
231
|
+
scope = scope.where(clause, *digests.map { |d| "% #{d} %" })
|
|
232
|
+
return scope.limit(limit).map { |r| record_to_trace(r) }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
list_traces(user_id, limit: limit).select do |t|
|
|
236
|
+
down = t.content.downcase
|
|
237
|
+
tokens.all? { |tok| down.include?(tok) }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
private
|
|
242
|
+
|
|
243
|
+
def cipher
|
|
244
|
+
@cipher
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def metadata_column(metadata)
|
|
248
|
+
meta = metadata || {}
|
|
249
|
+
cipher.enabled? ? enc_json(meta) : meta
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def decode_metadata(value)
|
|
253
|
+
return {} if value.nil?
|
|
254
|
+
return dec_json(value) if cipher.enabled? && value.is_a?(String)
|
|
255
|
+
|
|
256
|
+
value.is_a?(Hash) ? value.transform_keys(&:to_sym) : {}
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def record_to_trace(rec)
|
|
260
|
+
Trace.new(
|
|
261
|
+
id: rec.id,
|
|
262
|
+
user_id: rec.user_id,
|
|
263
|
+
session_id: rec.session_id,
|
|
264
|
+
boundary_id: rec.boundary_id,
|
|
265
|
+
sequence: rec.sequence,
|
|
266
|
+
role: rec.role.to_sym,
|
|
267
|
+
content: dec(rec.content),
|
|
268
|
+
occurred_at: rec.occurred_at,
|
|
269
|
+
ingested_at: rec.ingested_at,
|
|
270
|
+
metadata: decode_metadata(rec.metadata),
|
|
271
|
+
content_sha256: rec.content_sha256,
|
|
272
|
+
idempotency_key: rec.idempotency_key,
|
|
273
|
+
archived_at: rec.archived_at
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def record_to_state_link(rec)
|
|
278
|
+
TraceStateLink.new(
|
|
279
|
+
user_id: rec.user_id,
|
|
280
|
+
state_key: rec.state_key,
|
|
281
|
+
trace_id: rec.trace_id,
|
|
282
|
+
valid_from: rec.valid_from,
|
|
283
|
+
valid_to: rec.valid_to,
|
|
284
|
+
supersedes_trace_id: rec.supersedes_trace_id,
|
|
285
|
+
source: rec.source.to_sym,
|
|
286
|
+
created_at: rec.created_at
|
|
287
|
+
)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def unit_from_record(rec)
|
|
291
|
+
data = rec.data.is_a?(Hash) ? rec.data : dec_json(rec.data)
|
|
292
|
+
data = data.transform_keys(&:to_sym)
|
|
293
|
+
TraceUnit.new(
|
|
294
|
+
id: data[:id] || rec.id,
|
|
295
|
+
user_id: data[:user_id] || rec.user_id,
|
|
296
|
+
kind: (data[:kind] || rec.unit_type).to_sym,
|
|
297
|
+
session_id: data[:session_id],
|
|
298
|
+
boundary_id: data[:boundary_id],
|
|
299
|
+
start_sequence: data[:start_sequence],
|
|
300
|
+
end_sequence: data[:end_sequence],
|
|
301
|
+
occurred_from: Time.parse(data[:occurred_from].to_s),
|
|
302
|
+
occurred_to: Time.parse(data[:occurred_to].to_s),
|
|
303
|
+
member_trace_ids: data[:member_trace_ids] || [],
|
|
304
|
+
embedding_ref: data[:embedding_ref],
|
|
305
|
+
index_version: data[:index_version] || 0
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
module Storages
|
|
6
|
+
class LlmemoryTrace < ::ActiveRecord::Base
|
|
7
|
+
self.table_name = "llmemory_traces"
|
|
8
|
+
self.primary_key = "id"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class LlmemoryTraceStateLink < ::ActiveRecord::Base
|
|
12
|
+
self.table_name = "llmemory_trace_state_links"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class LlmemoryTraceUnit < ::ActiveRecord::Base
|
|
16
|
+
self.table_name = "llmemory_trace_units"
|
|
17
|
+
self.primary_key = "id"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "../storage"
|
|
6
|
+
require_relative "memory"
|
|
7
|
+
require_relative "snapshot_codec"
|
|
8
|
+
require_relative "../../crypto/field_helpers"
|
|
9
|
+
|
|
10
|
+
module Llmemory
|
|
11
|
+
module ZeroMem
|
|
12
|
+
module Storages
|
|
13
|
+
class FileStorage
|
|
14
|
+
include Storage
|
|
15
|
+
include Llmemory::Crypto::FieldHelpers
|
|
16
|
+
|
|
17
|
+
MUTATORS = %i[
|
|
18
|
+
write_trace archive_trace write_state_link close_open_state_links set_watermark
|
|
19
|
+
write_unit set_open_episode close_episode upsert_entity write_mention
|
|
20
|
+
link_adjacent_traces store_turn_embedding
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def initialize(base_path: nil, cipher: nil)
|
|
24
|
+
@base_path = File.expand_path(base_path || Llmemory.configuration.long_term_storage_path || "./llmemory_data")
|
|
25
|
+
@cipher = cipher || Llmemory.build_cipher
|
|
26
|
+
@inner = Memory.new
|
|
27
|
+
load!
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Storage.instance_methods(false).each do |method_name|
|
|
31
|
+
next if MUTATORS.include?(method_name)
|
|
32
|
+
|
|
33
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
|
34
|
+
@inner.public_send(method_name, *args, **kwargs, &block)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
MUTATORS.each do |method_name|
|
|
39
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
|
40
|
+
result = @inner.public_send(method_name, *args, **kwargs, &block)
|
|
41
|
+
save!
|
|
42
|
+
result
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def snapshot_path
|
|
49
|
+
File.join(@base_path, "zero_mem", "snapshot.json")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def load!
|
|
53
|
+
path = snapshot_path
|
|
54
|
+
return unless File.file?(path)
|
|
55
|
+
|
|
56
|
+
data = read_encrypted_file(path)
|
|
57
|
+
SnapshotCodec.load!(@inner, data)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def save!
|
|
61
|
+
FileUtils.mkdir_p(File.dirname(snapshot_path))
|
|
62
|
+
write_encrypted_file(snapshot_path, SnapshotCodec.dump(@inner))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|