llmemory 0.1.16 → 0.1.17
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/lib/llmemory/long_term/episodic/episode.rb +94 -0
- data/lib/llmemory/long_term/episodic/memory.rb +93 -0
- data/lib/llmemory/long_term/episodic/storage.rb +31 -0
- data/lib/llmemory/long_term/episodic/storages/base.rb +39 -0
- data/lib/llmemory/long_term/episodic/storages/file_storage.rb +117 -0
- data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +67 -0
- data/lib/llmemory/long_term/episodic.rb +12 -0
- data/lib/llmemory/long_term/file_based/memory.rb +15 -0
- data/lib/llmemory/long_term.rb +1 -0
- data/lib/llmemory/reflection/reflector.rb +116 -0
- data/lib/llmemory/reflection.rb +8 -0
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory.rb +1 -0
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b86647810e47140fff5732a066da4a16188704249d09db14720d8c565b6eaf0e
|
|
4
|
+
data.tar.gz: 7c52c551746d22c29e41015098a68e166788bb614b0e0c2b064fa5fc0824989f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f584d487eca13280b13f7a3e9f4b8eda60ed10ec8dbb45ff72483acbc76b7feb7f79fb5ae572640a31e87ade05b0762f07cacbecb778dffa6d62a7096231fb12
|
|
7
|
+
data.tar.gz: c68e284c21fc22ccdf20160d9082575a47482b03c5a91fb3b52ec5a6c51b7f5cf13a8915bfd3fe2f5933aa6a52fe8c382e2373393b27d68d8ed1c7ac83766e49
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module LongTerm
|
|
7
|
+
module Episodic
|
|
8
|
+
# An Episode is a trajectory of an agent's experience: an ordered list of
|
|
9
|
+
# steps (observation -> action -> result) plus a summary, an outcome label
|
|
10
|
+
# and an importance score. This is CoALA's "episodic memory" — distinct
|
|
11
|
+
# from semantic memory (facts), it stores what happened so it can later be
|
|
12
|
+
# retrieved as examples or distilled into semantic knowledge (see P2,
|
|
13
|
+
# reflection).
|
|
14
|
+
class Episode
|
|
15
|
+
attr_reader :id, :user_id, :steps, :summary, :outcome, :importance, :provenance, :created_at
|
|
16
|
+
|
|
17
|
+
STEP_KEYS = %i[observation action result timestamp].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(id:, user_id:, steps: [], summary: nil, outcome: nil, importance: 0.5, provenance: nil, created_at: nil)
|
|
20
|
+
@id = id
|
|
21
|
+
@user_id = user_id
|
|
22
|
+
@steps = self.class.normalize_steps(steps)
|
|
23
|
+
@summary = summary
|
|
24
|
+
@outcome = outcome
|
|
25
|
+
@importance = importance.nil? ? 0.5 : importance.to_f
|
|
26
|
+
@provenance = provenance
|
|
27
|
+
@created_at = created_at || Time.now
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Flat, searchable representation used for keyword retrieval and, in the
|
|
31
|
+
# future, embedding. Combines summary, outcome and every step field.
|
|
32
|
+
def searchable_text
|
|
33
|
+
parts = [summary, outcome]
|
|
34
|
+
steps.each do |s|
|
|
35
|
+
parts << s[:observation]
|
|
36
|
+
parts << s[:action]
|
|
37
|
+
parts << s[:result]
|
|
38
|
+
end
|
|
39
|
+
parts.compact.map(&:to_s).reject(&:empty?).join("\n")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.normalize_steps(steps)
|
|
43
|
+
Array(steps).filter_map do |step|
|
|
44
|
+
next nil unless step.is_a?(Hash)
|
|
45
|
+
{
|
|
46
|
+
observation: step[:observation] || step["observation"],
|
|
47
|
+
action: step[:action] || step["action"],
|
|
48
|
+
result: step[:result] || step["result"],
|
|
49
|
+
timestamp: normalize_time(step[:timestamp] || step["timestamp"])
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.normalize_time(value)
|
|
55
|
+
return nil if value.nil?
|
|
56
|
+
value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.from_h(hash)
|
|
60
|
+
new(
|
|
61
|
+
id: hash[:id] || hash["id"],
|
|
62
|
+
user_id: hash[:user_id] || hash["user_id"],
|
|
63
|
+
steps: hash[:steps] || hash["steps"] || [],
|
|
64
|
+
summary: hash[:summary] || hash["summary"],
|
|
65
|
+
outcome: hash[:outcome] || hash["outcome"],
|
|
66
|
+
importance: hash[:importance] || hash["importance"] || 0.5,
|
|
67
|
+
provenance: hash[:provenance] || hash["provenance"],
|
|
68
|
+
created_at: parse_created_at(hash[:created_at] || hash["created_at"])
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.parse_created_at(value)
|
|
73
|
+
return value if value.nil? || value.is_a?(Time)
|
|
74
|
+
Time.parse(value.to_s)
|
|
75
|
+
rescue ArgumentError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def to_h
|
|
80
|
+
{
|
|
81
|
+
id: id,
|
|
82
|
+
user_id: user_id,
|
|
83
|
+
steps: steps,
|
|
84
|
+
summary: summary,
|
|
85
|
+
outcome: outcome,
|
|
86
|
+
importance: importance,
|
|
87
|
+
provenance: provenance,
|
|
88
|
+
created_at: created_at.respond_to?(:iso8601) ? created_at.iso8601(6) : created_at
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "episode"
|
|
4
|
+
require_relative "storage"
|
|
5
|
+
|
|
6
|
+
module Llmemory
|
|
7
|
+
module LongTerm
|
|
8
|
+
module Episodic
|
|
9
|
+
# Episodic long-term memory: records agent trajectories and retrieves them
|
|
10
|
+
# by recency, importance and relevance. Designed to coexist with semantic
|
|
11
|
+
# memory (file/graph), not replace it, and to feed reflection (P2), which
|
|
12
|
+
# distills episodes into semantic knowledge.
|
|
13
|
+
#
|
|
14
|
+
# Deliberately LLM-free: recording and retrieval are deterministic. Higher
|
|
15
|
+
# order summarization belongs to reflection.
|
|
16
|
+
class Memory
|
|
17
|
+
attr_reader :user_id, :storage
|
|
18
|
+
|
|
19
|
+
def initialize(user_id:, storage: nil)
|
|
20
|
+
@user_id = user_id
|
|
21
|
+
@storage = storage || Storages.build
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Records a trajectory. `steps` is an array of hashes with any of
|
|
25
|
+
# :observation, :action, :result, :timestamp. Returns the episode id.
|
|
26
|
+
def record_episode(steps:, summary: nil, outcome: nil, importance: 0.5)
|
|
27
|
+
episode = Episode.new(
|
|
28
|
+
id: nil,
|
|
29
|
+
user_id: @user_id,
|
|
30
|
+
steps: steps,
|
|
31
|
+
summary: summary || derive_summary(steps),
|
|
32
|
+
outcome: outcome,
|
|
33
|
+
importance: importance
|
|
34
|
+
)
|
|
35
|
+
provenance = Llmemory::Provenance.from_text_fingerprint(
|
|
36
|
+
episode.searchable_text, method: "episode_recording", confidence: episode.importance
|
|
37
|
+
)
|
|
38
|
+
record = episode.to_h.merge(provenance: provenance)
|
|
39
|
+
@storage.save_episode(@user_id, record)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def recent_episodes(limit: 10)
|
|
43
|
+
@storage.list_episodes(@user_id, limit: limit).map { |e| Episode.from_h(e) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def episodes(limit: nil)
|
|
47
|
+
@storage.list_episodes(@user_id, limit: limit).map { |e| Episode.from_h(e) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def find_episode(id)
|
|
51
|
+
raw = @storage.get_episode(@user_id, id)
|
|
52
|
+
raw && Episode.from_h(raw)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def count
|
|
56
|
+
@storage.count_episodes(@user_id)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Retrieval Engine integration. Returns candidates shaped like the other
|
|
60
|
+
# long-term memories so the Engine can rank episodes by relevance,
|
|
61
|
+
# recency (temporal decay) and importance (P3), with provenance (P10).
|
|
62
|
+
def search_candidates(query, user_id: nil, top_k: 20)
|
|
63
|
+
uid = user_id || @user_id
|
|
64
|
+
return [] unless uid == @user_id
|
|
65
|
+
|
|
66
|
+
@storage.search_episodes(uid, query).first(top_k).map do |e|
|
|
67
|
+
episode = Episode.from_h(e)
|
|
68
|
+
{
|
|
69
|
+
text: episode.summary.to_s.empty? ? episode.searchable_text : episode.summary,
|
|
70
|
+
timestamp: episode.created_at,
|
|
71
|
+
score: 1.0,
|
|
72
|
+
importance: episode.importance,
|
|
73
|
+
evergreen: false,
|
|
74
|
+
provenance: e[:provenance] || e["provenance"]
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# Cheap, deterministic summary when the caller does not provide one.
|
|
82
|
+
# LLM-based summarization is reflection's job (P2).
|
|
83
|
+
def derive_summary(steps)
|
|
84
|
+
normalized = Episode.normalize_steps(steps)
|
|
85
|
+
return nil if normalized.empty?
|
|
86
|
+
actions = normalized.filter_map { |s| s[:action] }.reject { |a| a.to_s.strip.empty? }
|
|
87
|
+
return nil if actions.empty?
|
|
88
|
+
"Episode with #{normalized.size} step(s): #{actions.join(' -> ')}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "storages/base"
|
|
4
|
+
require_relative "storages/memory_storage"
|
|
5
|
+
require_relative "storages/file_storage"
|
|
6
|
+
|
|
7
|
+
module Llmemory
|
|
8
|
+
module LongTerm
|
|
9
|
+
module Episodic
|
|
10
|
+
# Backward compatibility: Storage points to the in-memory backend.
|
|
11
|
+
Storage = Storages::MemoryStorage
|
|
12
|
+
|
|
13
|
+
module Storages
|
|
14
|
+
def self.build(store: nil, base_path: nil)
|
|
15
|
+
case (store || Llmemory.configuration.long_term_store).to_s.to_sym
|
|
16
|
+
when :memory
|
|
17
|
+
MemoryStorage.new
|
|
18
|
+
when :file
|
|
19
|
+
FileStorage.new(base_path: base_path || Llmemory.configuration.long_term_storage_path)
|
|
20
|
+
when :postgres, :database, :active_record, :activerecord
|
|
21
|
+
raise NotImplementedError,
|
|
22
|
+
"Episodic SQL/ActiveRecord storage is not implemented yet; use :memory or :file " \
|
|
23
|
+
"(or pass an explicit storage instance)."
|
|
24
|
+
else
|
|
25
|
+
MemoryStorage.new
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module LongTerm
|
|
5
|
+
module Episodic
|
|
6
|
+
module Storages
|
|
7
|
+
# Storage contract for episodic memory. Implementations persist Episode
|
|
8
|
+
# hashes and expose recency-ordered listing plus keyword search so the
|
|
9
|
+
# retrieval Engine can rank episodes alongside other memory types.
|
|
10
|
+
class Base
|
|
11
|
+
def save_episode(user_id, episode)
|
|
12
|
+
raise NotImplementedError, "#{self.class}#save_episode must be implemented"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def get_episode(user_id, id)
|
|
16
|
+
raise NotImplementedError, "#{self.class}#get_episode must be implemented"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Newest first. Optionally capped by limit.
|
|
20
|
+
def list_episodes(user_id, limit: nil)
|
|
21
|
+
raise NotImplementedError, "#{self.class}#list_episodes must be implemented"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def search_episodes(user_id, query)
|
|
25
|
+
raise NotImplementedError, "#{self.class}#search_episodes must be implemented"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def count_episodes(user_id)
|
|
29
|
+
raise NotImplementedError, "#{self.class}#count_episodes must be implemented"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def list_users
|
|
33
|
+
raise NotImplementedError, "#{self.class}#list_users must be implemented"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "base"
|
|
7
|
+
|
|
8
|
+
module Llmemory
|
|
9
|
+
module LongTerm
|
|
10
|
+
module Episodic
|
|
11
|
+
module Storages
|
|
12
|
+
class FileStorage < Base
|
|
13
|
+
def initialize(base_path: nil)
|
|
14
|
+
@base_path = base_path || Llmemory.configuration.long_term_storage_path || "./llmemory_data"
|
|
15
|
+
@base_path = File.expand_path(@base_path)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def save_episode(user_id, episode)
|
|
19
|
+
id = episode[:id] || episode["id"] || "ep_#{next_seq(user_id)}"
|
|
20
|
+
data = stringify_for_json(episode).merge("id" => id, "user_id" => user_id)
|
|
21
|
+
data["created_at"] ||= Time.now.iso8601
|
|
22
|
+
File.write(episode_path(user_id, id), JSON.generate(data))
|
|
23
|
+
id
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def get_episode(user_id, id)
|
|
27
|
+
path = episode_path(user_id, id)
|
|
28
|
+
return nil unless File.file?(path)
|
|
29
|
+
load_episode(path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def list_episodes(user_id, limit: nil)
|
|
33
|
+
sorted = all_episodes(user_id).sort_by { |e| e[:created_at] }.reverse
|
|
34
|
+
limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def search_episodes(user_id, query)
|
|
38
|
+
q = query.to_s.downcase
|
|
39
|
+
return list_episodes(user_id) if q.strip.empty?
|
|
40
|
+
all_episodes(user_id).select { |e| episode_text(e).downcase.include?(q) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def count_episodes(user_id)
|
|
44
|
+
dir = user_path(user_id, "episodes")
|
|
45
|
+
return 0 unless Dir.exist?(dir)
|
|
46
|
+
Dir.children(dir).count { |f| f.end_with?(".json") }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def list_users
|
|
50
|
+
return [] unless Dir.exist?(@base_path)
|
|
51
|
+
Dir.children(@base_path).select { |d| Dir.exist?(File.join(@base_path, d, "episodes")) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def all_episodes(user_id)
|
|
57
|
+
dir = user_path(user_id, "episodes")
|
|
58
|
+
return [] unless Dir.exist?(dir)
|
|
59
|
+
Dir.children(dir).select { |f| f.end_with?(".json") }.map { |f| load_episode(File.join(dir, f)) }.compact
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def load_episode(path)
|
|
63
|
+
data = JSON.parse(File.read(path), symbolize_names: true)
|
|
64
|
+
data[:created_at] = parse_time(data[:created_at])
|
|
65
|
+
data
|
|
66
|
+
rescue JSON::ParserError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def episode_text(episode)
|
|
71
|
+
parts = [episode[:summary], episode[:outcome]]
|
|
72
|
+
Array(episode[:steps]).each do |s|
|
|
73
|
+
next unless s.is_a?(Hash)
|
|
74
|
+
parts << s[:observation] << s[:action] << s[:result]
|
|
75
|
+
end
|
|
76
|
+
parts.compact.join("\n")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def stringify_for_json(episode)
|
|
80
|
+
JSON.parse(JSON.generate(episode))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def user_path(user_id, *parts)
|
|
84
|
+
safe = user_id.to_s.gsub(%r{[^\w\-.]}, "_")
|
|
85
|
+
File.join(@base_path, safe, *parts)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def episode_path(user_id, id)
|
|
89
|
+
dir = user_path(user_id, "episodes")
|
|
90
|
+
FileUtils.mkdir_p(dir)
|
|
91
|
+
File.join(dir, "#{id}.json")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def meta_path(user_id)
|
|
95
|
+
FileUtils.mkdir_p(user_path(user_id))
|
|
96
|
+
File.join(user_path(user_id), "meta.json")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def next_seq(user_id)
|
|
100
|
+
path = meta_path(user_id)
|
|
101
|
+
meta = File.file?(path) ? JSON.parse(File.read(path)) : {}
|
|
102
|
+
meta["episode_id_seq"] = (meta["episode_id_seq"] || 0) + 1
|
|
103
|
+
File.write(path, JSON.generate(meta))
|
|
104
|
+
meta["episode_id_seq"]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def parse_time(val)
|
|
108
|
+
return val if val.is_a?(Time)
|
|
109
|
+
Time.parse(val.to_s)
|
|
110
|
+
rescue ArgumentError
|
|
111
|
+
Time.now
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module LongTerm
|
|
7
|
+
module Episodic
|
|
8
|
+
module Storages
|
|
9
|
+
class MemoryStorage < Base
|
|
10
|
+
def initialize
|
|
11
|
+
@episodes = Hash.new { |h, k| h[k] = [] }
|
|
12
|
+
@seq = 0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def save_episode(user_id, episode)
|
|
16
|
+
@seq += 1
|
|
17
|
+
id = episode[:id] || episode["id"] || "ep_#{@seq}"
|
|
18
|
+
record = symbolize(episode).merge(id: id, user_id: user_id)
|
|
19
|
+
record[:created_at] ||= Time.now
|
|
20
|
+
@episodes[user_id] << record
|
|
21
|
+
id
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def get_episode(user_id, id)
|
|
25
|
+
@episodes[user_id].find { |e| e[:id] == id }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def list_episodes(user_id, limit: nil)
|
|
29
|
+
sorted = @episodes[user_id].sort_by { |e| e[:created_at] }.reverse
|
|
30
|
+
limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def search_episodes(user_id, query)
|
|
34
|
+
q = query.to_s.downcase
|
|
35
|
+
return list_episodes(user_id) if q.strip.empty?
|
|
36
|
+
@episodes[user_id].select { |e| episode_text(e).downcase.include?(q) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def count_episodes(user_id)
|
|
40
|
+
@episodes[user_id].size
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def list_users
|
|
44
|
+
@episodes.keys
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def symbolize(hash)
|
|
50
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def episode_text(episode)
|
|
54
|
+
parts = [episode[:summary], episode[:outcome]]
|
|
55
|
+
Array(episode[:steps]).each do |s|
|
|
56
|
+
next unless s.is_a?(Hash)
|
|
57
|
+
parts << (s[:observation] || s["observation"])
|
|
58
|
+
parts << (s[:action] || s["action"])
|
|
59
|
+
parts << (s[:result] || s["result"])
|
|
60
|
+
end
|
|
61
|
+
parts.compact.join("\n")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -85,6 +85,21 @@ module Llmemory
|
|
|
85
85
|
out
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
+
# Stores a single fact produced outside the extraction flow (e.g. by
|
|
89
|
+
# reflection over episodes), preserving caller-supplied provenance so the
|
|
90
|
+
# insight remains traceable to its source. Returns the item id.
|
|
91
|
+
def remember_fact(content:, category: "general", importance: 0.6, provenance: nil)
|
|
92
|
+
return nil if content.to_s.strip.empty?
|
|
93
|
+
@storage.save_item(
|
|
94
|
+
@user_id,
|
|
95
|
+
category: category.to_s,
|
|
96
|
+
content: content.to_s,
|
|
97
|
+
source_resource_id: nil,
|
|
98
|
+
importance: importance,
|
|
99
|
+
provenance: provenance
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
88
103
|
attr_reader :storage, :user_id
|
|
89
104
|
|
|
90
105
|
private
|
data/lib/llmemory/long_term.rb
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module Reflection
|
|
7
|
+
# Reflection distills an agent's recent episodes (episodic memory) into
|
|
8
|
+
# durable, higher-order insights and writes them to semantic memory. This is
|
|
9
|
+
# CoALA's "updating semantic memory with knowledge" (Reflexion / Generative
|
|
10
|
+
# Agents): unlike one-shot extraction from raw text, it reasons over lived
|
|
11
|
+
# experience to generalize lessons and patterns.
|
|
12
|
+
#
|
|
13
|
+
# Each insight is stored with provenance { method: "reflection",
|
|
14
|
+
# sources: [{ type: "episode", id: ... }] } so it stays traceable to the
|
|
15
|
+
# experiences that produced it.
|
|
16
|
+
#
|
|
17
|
+
# `semantic` must respond to:
|
|
18
|
+
# remember_fact(content:, category:, importance:, provenance:)
|
|
19
|
+
# (FileBased::Memory implements this; graph-based is a future target.)
|
|
20
|
+
class Reflector
|
|
21
|
+
DEFAULT_CATEGORY = "insights"
|
|
22
|
+
DEFAULT_IMPORTANCE = 0.6
|
|
23
|
+
|
|
24
|
+
def initialize(episodic:, semantic:, llm: nil)
|
|
25
|
+
@episodic = episodic
|
|
26
|
+
@semantic = semantic
|
|
27
|
+
@llm = llm || Llmemory::LLM.client
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Reflects over the most recent `window` episodes and writes the resulting
|
|
31
|
+
# insights to semantic memory. Returns the ids of the stored insights.
|
|
32
|
+
def reflect(window: 10, category: DEFAULT_CATEGORY)
|
|
33
|
+
episodes = @episodic.recent_episodes(limit: window)
|
|
34
|
+
return [] if episodes.empty?
|
|
35
|
+
|
|
36
|
+
insights = distill(episodes)
|
|
37
|
+
return [] if insights.empty?
|
|
38
|
+
|
|
39
|
+
sources = episodes.map(&:id).compact.map { |id| { type: "episode", id: id } }
|
|
40
|
+
|
|
41
|
+
insights.filter_map do |insight|
|
|
42
|
+
provenance = Llmemory::Provenance.build(
|
|
43
|
+
method: "reflection",
|
|
44
|
+
sources: sources,
|
|
45
|
+
confidence: insight[:confidence]
|
|
46
|
+
)
|
|
47
|
+
@semantic.remember_fact(
|
|
48
|
+
content: insight[:content],
|
|
49
|
+
category: category,
|
|
50
|
+
importance: insight[:confidence] || DEFAULT_IMPORTANCE,
|
|
51
|
+
provenance: provenance
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def distill(episodes)
|
|
59
|
+
response = @llm.invoke(build_prompt(episodes))
|
|
60
|
+
parse_insights(response)
|
|
61
|
+
rescue Llmemory::LLMError
|
|
62
|
+
[]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_prompt(episodes)
|
|
66
|
+
episodes_text = episodes.each_with_index.map do |ep, i|
|
|
67
|
+
"Episode #{i + 1} (outcome: #{ep.outcome || 'n/a'}):\n#{ep.searchable_text}"
|
|
68
|
+
end.join("\n\n")
|
|
69
|
+
|
|
70
|
+
<<~PROMPT
|
|
71
|
+
You are reflecting on an agent's recent experiences to distill durable,
|
|
72
|
+
higher-order insights: lessons learned, recurring patterns, and stable
|
|
73
|
+
preferences that will help in future situations. Generalize; do not
|
|
74
|
+
restate raw events.
|
|
75
|
+
|
|
76
|
+
Recent episodes:
|
|
77
|
+
#{episodes_text}
|
|
78
|
+
|
|
79
|
+
Return a JSON array of objects with "content" (the insight) and
|
|
80
|
+
"confidence" (0-1) keys. Return an empty array if nothing durable can
|
|
81
|
+
be concluded.
|
|
82
|
+
Example: [{"content": "Rolling back on deploy failure reliably restores service", "confidence": 0.8}]
|
|
83
|
+
PROMPT
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse_insights(response)
|
|
87
|
+
json = extract_json_array(response)
|
|
88
|
+
return [] unless json
|
|
89
|
+
|
|
90
|
+
json.filter_map do |item|
|
|
91
|
+
next nil unless item.is_a?(Hash)
|
|
92
|
+
content = item["content"] || item[:content]
|
|
93
|
+
next nil if content.to_s.strip.empty?
|
|
94
|
+
{ content: content.to_s, confidence: normalize_confidence(item["confidence"] || item[:confidence]) }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def normalize_confidence(value)
|
|
99
|
+
return nil if value.nil?
|
|
100
|
+
v = value.to_f
|
|
101
|
+
v.between?(0, 1) ? v : nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def extract_json_array(response)
|
|
105
|
+
response = response.to_s.strip
|
|
106
|
+
start_idx = response.index("[")
|
|
107
|
+
end_idx = response.rindex("]")
|
|
108
|
+
return nil unless start_idx && end_idx
|
|
109
|
+
|
|
110
|
+
JSON.parse(response[start_idx..end_idx])
|
|
111
|
+
rescue JSON::ParserError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
data/lib/llmemory/version.rb
CHANGED
data/lib/llmemory.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative "llmemory/retrieval"
|
|
|
10
10
|
require_relative "llmemory/vector_store"
|
|
11
11
|
require_relative "llmemory/maintenance"
|
|
12
12
|
require_relative "llmemory/extractors"
|
|
13
|
+
require_relative "llmemory/reflection"
|
|
13
14
|
require_relative "llmemory/memory"
|
|
14
15
|
|
|
15
16
|
module Llmemory
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: llmemory
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.17
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- llmemory
|
|
@@ -152,6 +152,13 @@ files:
|
|
|
152
152
|
- lib/llmemory/llm/base.rb
|
|
153
153
|
- lib/llmemory/llm/openai.rb
|
|
154
154
|
- lib/llmemory/long_term.rb
|
|
155
|
+
- lib/llmemory/long_term/episodic.rb
|
|
156
|
+
- lib/llmemory/long_term/episodic/episode.rb
|
|
157
|
+
- lib/llmemory/long_term/episodic/memory.rb
|
|
158
|
+
- lib/llmemory/long_term/episodic/storage.rb
|
|
159
|
+
- lib/llmemory/long_term/episodic/storages/base.rb
|
|
160
|
+
- lib/llmemory/long_term/episodic/storages/file_storage.rb
|
|
161
|
+
- lib/llmemory/long_term/episodic/storages/memory_storage.rb
|
|
155
162
|
- lib/llmemory/long_term/file_based.rb
|
|
156
163
|
- lib/llmemory/long_term/file_based/category.rb
|
|
157
164
|
- lib/llmemory/long_term/file_based/item.rb
|
|
@@ -196,6 +203,8 @@ files:
|
|
|
196
203
|
- lib/llmemory/memory.rb
|
|
197
204
|
- lib/llmemory/noise_filter.rb
|
|
198
205
|
- lib/llmemory/provenance.rb
|
|
206
|
+
- lib/llmemory/reflection.rb
|
|
207
|
+
- lib/llmemory/reflection/reflector.rb
|
|
199
208
|
- lib/llmemory/retrieval.rb
|
|
200
209
|
- lib/llmemory/retrieval/bm25_scorer.rb
|
|
201
210
|
- lib/llmemory/retrieval/context_assembler.rb
|