Class: Insika::MemoryStore
- Inherits:
-
Object
- Object
- Insika::MemoryStore
- Defined in:
- lib/insika/memory_store.rb
Overview
AGENT-MEMORY DOMAIN store. Two layers
scoped per tenant over any Insika::Store: profile (stable key-value
facts) and notes (append-only free-form notes). Mirrors the
PendingActionStore (normalizes symbol→string on write, O(n) scan on
read, records with a timestamp).
the record gains origin/created_at/expires_at, the
(tenant, customer) pair becomes first-class at the API (customer:
builds the same "memory:MemoryAuditStore (C2), NOT here —
this stays a dumb domain store.
NOT to be confused with Insika::Stores::Memory (in-memory KV backend):
this is the domain store; that one is one of the backends this writes to.
Defined Under Namespace
Constant Summary collapse
- SCOPE_PREFIX =
scope = "memory:
" "memory"- FACT_PREFIX =
"fact:"- NOTE_PREFIX =
"note:"- DEFAULT_TENANT =
no tenant in the Command
"_default"- ORIGIN_LEGACY =
the record's provenance. A closed string set: "legacy" (migrated, unknown writer), "engine" (the remember tool / an integration write), "operator" (Studio edit), "distilled" reserved).
"legacy"- SESSION_TAG =
the per-SESSION cell marker. Engine-owner memory with no customer and no explicit tenant falls back to the session (executor parity) — the cell is MARKED "memory:chat:
" so the drill and the doctor never read a conversation as a customer (a bare "memory: " cell is indistinguishable from a single-tenant customer ref). A real tenant NAMED "chat" would collide with the marker (its customer cells would read as session cells) — accepted, merchant ids live in a different namespace in practice. "chat"
Class Method Summary collapse
-
.parse_cell(scope) ⇒ Object
The shared classification (public — the Studio/commands split audit fields): "memory:acme:c-123" -> tenant: "acme", customer: "c-123"; "memory:c-123" -> tenant: nil, customer: "c-123"; "memory:_default" -> tenant: nil, customer: nil.
-
.session_cell?(cell) ⇒ Boolean
Is this a classified cell the engine's per-SESSION cell ("memory:chat:
")? The one classification both the drill and the doctor share — a session-derived cell must never read as a customer.
Instance Method Summary collapse
-
#add_note(tenant:, text:, id: SecureRandom.uuid, at: nil, customer: nil) ⇒ Object
Append.
-
#cell_for(tenant, customer = nil) ⇒ Object
The public cell string for a pair (the commands and the Studio build URLs and audit keys with this — the same string the Executor derives).
-
#cells ⇒ Object
-> [tenant: String|nil, customer: String|nil] — every "memory:*" scope, classified by SHAPE (D6).
-
#customer_cells(reserved: []) ⇒ Object
-> [tenant:, customer:] — the cells that hold CUSTOMER memory: 2+-segment scopes always (except the SESSION-marked cells — a session is never a customer); 1-segment scopes whose name is not _default and not in
reserved(the caller's agent ids / tenant cells). -
#fact_count(tenant:, customer: nil) ⇒ Object
Cheap fact count for the drill index (keys only, no payload reads).
-
#facts(tenant:, customer: nil) ⇒ Object
-> [Fact] sorted by key.
-
#forget_fact(tenant:, key:, customer: nil) ⇒ Object
-> bool (did it exist?).
-
#get_fact(tenant:, key:, customer: nil) ⇒ Object
-> Fact | nil (tolerant read: missing origin -> "legacy", created_at -> updated_at, expires_at -> nil).
-
#initialize(store:, clock: nil) ⇒ MemoryStore
constructor
A new instance of MemoryStore.
-
#notes(tenant:, limit: nil, customer: nil) ⇒ Object
-> [Note] MOST RECENT first, capped by
limit. -
#prune_expired(now = nil) ⇒ Object
Removes facts whose expires_at <= now (default clock), EVERY cell.
-
#prune_older_than(time, scope: nil) ⇒ Object
Age-based prune (WS8 retention).
-
#purge(tenant:, customer: nil) ⇒ Object
Purges the WHOLE scope (WS8 — forget_customer / delete_tenant_data).
-
#purge_tenant(tenant) ⇒ Object
Purges a TENANT and every customer cell under it (WS8 phase 2 — delete_tenant_data).
-
#put_fact(tenant:, key:, value:, customer: nil, origin: "engine", expires_at: nil) ⇒ Object
Upsert (last-write-wins, Store contract).
-
#replace_if_revision(tenant:, key:, value:, expected_revision:, customer: nil, origin: "engine", expires_at: nil) ⇒ Object
CAS write (WS8): only writes when the STORED fact's updated_at still equals the caller's revision — a fact a concurrent writer already moved is refused instead of clobbered (last-write-wins is the default; this is the opt-in optimistic path).
Constructor Details
#initialize(store:, clock: nil) ⇒ MemoryStore
Returns a new instance of MemoryStore.
48 49 50 51 |
# File 'lib/insika/memory_store.rb', line 48 def initialize(store:, clock: nil) @store = store @clock = clock # -> Time, injectable for specs end |
Class Method Details
.parse_cell(scope) ⇒ Object
The shared classification (public — the Studio/commands split audit fields): "memory:acme:c-123" -> tenant: "acme", customer: "c-123"; "memory:c-123" -> tenant: nil, customer: "c-123"; "memory:_default" -> tenant: nil, customer: nil.
240 241 242 243 244 245 246 247 248 249 250 |
# File 'lib/insika/memory_store.rb', line 240 def self.parse_cell(scope) rest = scope.to_s.sub(/\A#{SCOPE_PREFIX}:/, "") base, tail = rest.split(":", 2) if base == DEFAULT_TENANT && tail.nil? { scope: scope, tenant: nil, customer: nil } elsif tail { scope: scope, tenant: base, customer: tail } else { scope: scope, tenant: nil, customer: base } end end |
.session_cell?(cell) ⇒ Boolean
Is this a classified cell the engine's per-SESSION cell
("memory:chat:
230 |
# File 'lib/insika/memory_store.rb', line 230 def self.session_cell?(cell) = cell[:tenant] == SESSION_TAG |
Instance Method Details
#add_note(tenant:, text:, id: SecureRandom.uuid, at: nil, customer: nil) ⇒ Object
Append. at (ISO8601) goes at the START of the key so list returns the notes in
chronological order; id/at injectable for deterministic tests. -> Note
254 255 256 257 258 259 |
# File 'lib/insika/memory_store.rb', line 254 def add_note(tenant:, text:, id: SecureRandom.uuid, at: nil, customer: nil) at ||= record = { "id" => id.to_s, "text" => text.to_s, "created_at" => at } @store.set(scope_for(tenant, customer), NOTE_PREFIX + "#{at}:#{id}", record) to_note(record) end |
#cell_for(tenant, customer = nil) ⇒ Object
The public cell string for a pair (the commands and the Studio build URLs and audit keys with this — the same string the Executor derives).
234 |
# File 'lib/insika/memory_store.rb', line 234 def cell_for(tenant, customer = nil) = scope_for(tenant, customer) |
#cells ⇒ Object
-> [tenant: String|nil, customer: String|nil] — every "memory:*" scope, classified by SHAPE (D6). _default -> nil, customer: nil.
211 212 213 |
# File 'lib/insika/memory_store.rb', line 211 def cells @store.scopes("#{SCOPE_PREFIX}:").map { |scope| self.class.parse_cell(scope) } end |
#customer_cells(reserved: []) ⇒ Object
-> [tenant:, customer:] — the cells that hold CUSTOMER memory:
2+-segment scopes always (except the SESSION-marked cells — a session is
never a customer); 1-segment scopes whose name is not _default and not in
reserved (the caller's agent ids / tenant cells). Sorted by scope.
219 220 221 222 223 224 225 |
# File 'lib/insika/memory_store.rb', line 219 def customer_cells(reserved: []) excluded = Array(reserved).map(&:to_s) cells.reject do |c| self.class.session_cell?(c) || (c[:tenant].nil? && (c[:customer].nil? || excluded.include?(c[:customer]))) end.sort_by { |c| c[:scope] } end |
#fact_count(tenant:, customer: nil) ⇒ Object
Cheap fact count for the drill index (keys only, no payload reads). -> Integer
262 263 264 |
# File 'lib/insika/memory_store.rb', line 262 def fact_count(tenant:, customer: nil) @store.list(scope_for(tenant, customer), FACT_PREFIX).size end |
#facts(tenant:, customer: nil) ⇒ Object
-> [Fact] sorted by key. Facts whose expires_at <= now are EXCLUDED (D5: an expired fact is never injected nor shown, even before the sweep runs).
87 88 89 90 91 92 93 94 95 96 97 98 |
# File 'lib/insika/memory_store.rb', line 87 def facts(tenant:, customer: nil) scope = scope_for(tenant, customer) @store.list(scope, FACT_PREFIX).filter_map do |k| record = @store.get(scope, k) next unless record fact = to_fact(record) next if expired?(fact) fact end end |
#forget_fact(tenant:, key:, customer: nil) ⇒ Object
-> bool (did it exist?)
101 102 103 |
# File 'lib/insika/memory_store.rb', line 101 def forget_fact(tenant:, key:, customer: nil) @store.delete(scope_for(tenant, customer), FACT_PREFIX + key.to_s) end |
#get_fact(tenant:, key:, customer: nil) ⇒ Object
-> Fact | nil (tolerant read: missing origin -> "legacy", created_at -> updated_at, expires_at -> nil).
79 80 81 82 |
# File 'lib/insika/memory_store.rb', line 79 def get_fact(tenant:, key:, customer: nil) record = @store.get(scope_for(tenant, customer), FACT_PREFIX + key.to_s) record && to_fact(record) end |
#notes(tenant:, limit: nil, customer: nil) ⇒ Object
-> [Note] MOST RECENT first, capped by limit.
267 268 269 270 271 272 273 274 275 |
# File 'lib/insika/memory_store.rb', line 267 def notes(tenant:, limit: nil, customer: nil) scope = scope_for(tenant, customer) keys = @store.list(scope, NOTE_PREFIX).reverse # list is chronological -> reverse = most recent first keys = keys.first(limit) if limit keys.filter_map do |k| record = @store.get(scope, k) record && to_note(record) end end |
#prune_expired(now = nil) ⇒ Object
Removes facts whose expires_at <= now (default clock), EVERY cell. -> count removed.
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 |
# File 'lib/insika/memory_store.rb', line 186 def prune_expired(now = nil) cutoff = (now || self.now).utc removed = 0 @store.scopes("#{SCOPE_PREFIX}:").each do |scope| @store.list(scope).each do |k| rec = @store.get(scope, k) next unless rec && rec["expires_at"] begin next unless Time.iso8601(rec["expires_at"]) <= cutoff rescue ArgumentError next end @store.delete(scope, k) removed += 1 end end removed end |
#prune_older_than(time, scope: nil) ⇒ Object
Age-based prune (WS8 retention). New : scope: limits the pass
to ONE cell; a fact with an explicit expires_at is SKIPPED (D5 — the
explicit override owns that fact's life). -> count removed.
165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 |
# File 'lib/insika/memory_store.rb', line 165 def prune_older_than(time, scope: nil) cutoff = time.utc.iso8601 removed = 0 scopes = scope ? [scope] : @store.scopes("#{SCOPE_PREFIX}:") scopes.each do |sc| @store.list(sc).each do |k| rec = @store.get(sc, k) next if rec && rec["expires_at"] # the override owns this fact's life stamp = rec && (rec["updated_at"] || rec["created_at"]) next unless stamp && stamp.to_s < cutoff @store.delete(sc, k) removed += 1 end end removed end |
#purge(tenant:, customer: nil) ⇒ Object
Purges the WHOLE scope (WS8 — forget_customer / delete_tenant_data). The
scope string is the isolation boundary: one cell per
(tenant-or-customer), so zeroing the cell cannot touch another's.
The list-then-delete rides @store.transaction (the repo rule): an erasure
that is half-applied is the LGPD defect, and a fact written between the
list and the deletes would survive a purge that reported success.
-> count of records purged.
129 130 131 132 133 134 135 136 |
# File 'lib/insika/memory_store.rb', line 129 def purge(tenant:, customer: nil) scope = scope_for(tenant, customer) @store.transaction do keys = @store.list(scope) keys.each { |k| @store.delete(scope, k) } keys.size end end |
#purge_tenant(tenant) ⇒ Object
Purges a TENANT and every customer cell under it (WS8 phase 2 —
delete_tenant_data). The tenant's own cell ("memory:
149 150 151 152 153 154 155 156 157 158 159 160 |
# File 'lib/insika/memory_store.rb', line 149 def purge_tenant(tenant) cell = scope_for(tenant) @store.transaction do scopes = [cell] + @store.scopes("#{cell}:") + @store.scopes("#{SCOPE_PREFIX}:#{SESSION_TAG}:#{tenant}:") scopes.sum do |scope| keys = @store.list(scope) keys.each { |k| @store.delete(scope, k) } keys.size end end end |
#put_fact(tenant:, key:, value:, customer: nil, origin: "engine", expires_at: nil) ⇒ Object
Upsert (last-write-wins, Store contract). customer: PRESENT moves the
scope to the [tenant:]customer cell (WS8's rule — nil tenant + customer
-> bare "memory:origin defaults to
"engine" (the remember tool's write). expires_at (ISO8601 String) is
normalized and validated here — an invalid value raises ValidationError
(a silently dropped LGPD expiry date is the defect). Preserves the
existing record's created_at; rewrites a legacy record in the full
shape. -> Fact
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
# File 'lib/insika/memory_store.rb', line 61 def put_fact(tenant:, key:, value:, customer: nil, origin: "engine", expires_at: nil) scope = scope_for(tenant, customer) current = @store.get(scope, FACT_PREFIX + key.to_s) prev = current.is_a?(Hash) ? current : {} stamp = record = { "key" => key.to_s, "value" => stringify(value), "origin" => Coercion.presence(origin) || "engine", "created_at" => Coercion.presence(prev["created_at"]) || Coercion.presence(prev["updated_at"]) || stamp, "updated_at" => stamp, "expires_at" => normalize_expiry(expires_at) } @store.set(scope, FACT_PREFIX + key.to_s, record) to_fact(record) end |
#replace_if_revision(tenant:, key:, value:, expected_revision:, customer: nil, origin: "engine", expires_at: nil) ⇒ Object
CAS write (WS8): only writes when the STORED fact's updated_at still
equals the caller's revision — a fact a concurrent writer already moved
is refused instead of clobbered (last-write-wins is the default; this is
the opt-in optimistic path). -> Fact (written) | nil (lost the race — the
caller must re-read and retry). The read-compare-write rides
@store.transaction (the repo rule) — next, never return, inside.
111 112 113 114 115 116 117 118 119 120 |
# File 'lib/insika/memory_store.rb', line 111 def replace_if_revision(tenant:, key:, value:, expected_revision:, customer: nil, origin: "engine", expires_at: nil) @store.transaction do current = @store.get(scope_for(tenant, customer), FACT_PREFIX + key.to_s) next nil if current.nil? || current["updated_at"] != expected_revision put_fact(tenant: tenant, key: key, value: value, customer: customer, origin: origin, expires_at: expires_at) end end |