Class: Insika::MemoryStore

Inherits:
Object
  • Object
show all
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::" scope the Executor derives), and the store enumerates its cells (the Studio drill, the doctor, the retention sweep). Migration is lazy: tolerant reads, materialized on the first write. The audit trail lives in 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

Classes: Fact, Note

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

Instance Method Summary collapse

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:")? The one classification both the drill and the doctor share — a session-derived cell must never read as a customer.

Returns:

  • (Boolean)


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 ||= timestamp
  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)

#cellsObject

-> [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:") plus each cell whose scope starts with "memory::" (the customer cells), plus the tenant's SESSION-marked cells ("memory:chat::*" — in multi-tenant a session id is ":", so its memory cell is "memory:chat::"). The scope enumeration is the Store's — no session-derived list, so a customer cell whose session was already deleted is still purged. ONE transaction for the whole tenant (the repo rule): the scope enumeration and every delete see the same snapshot, so a customer cell born mid-purge cannot slip through a deletion that reported success. -> count of records purged.



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:", NEVER _default). 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 = timestamp
  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