Class: Insika::SessionStore
- Inherits:
-
Object
- Object
- Insika::SessionStore
- Includes:
- Coercion
- Defined in:
- lib/insika/session_store.rb
Overview
Domain store for sessions. Persists transcript + vars
over an injected Insika::Store, with a fixed schema
session:<id> in the "sessions" scope.
The persisted transcript is the SOURCE OF TRUTH for reconstruction; live
events are just delivery state. The message shape
({"role"=>, "content"=>}, role ∈ user|assistant|system|tool) is the
same one Runner#seed_history already consumes — the Executor converts nothing.
Normalizes symbol→string on WRITE (the backend only guarantees round-trip of JSON types); READ returns the data as it comes from the backend (string keys), never symmetrizing back to symbols.
Defined Under Namespace
Classes: Session
Constant Summary collapse
- SCOPE =
"sessions"- KEY_PREFIX =
"session:"
Constants included from Coercion
Instance Method Summary collapse
-
#append_evidence(id, ids:, ungrounded:) ⇒ Object
appends this turn's evidence (ids + ungrounded delta) to the session record.
-
#append_messages(id, messages) ⇒ Object
-> Session (transcript += messages).
-
#create(id: SecureRandom.uuid, vars: {}) ⇒ Object
-> Session; ArgumentError if id already exists (a duplicate session is a domain violation — it never overwrites silently).
-
#delete(id) ⇒ Object
-> bool (delegates to the backend: false for a nonexistent id).
-
#each_id ⇒ Object
-> enumerates ids without the "session:" prefix.
-
#find(id) ⇒ Object
-> Session | nil.
-
#initialize(store:) ⇒ SessionStore
constructor
store: any Insika::Store (Memory, SQLite, ...) — injected by the composition root (config/wiring.rb).
-
#set_next_step(id, text:) ⇒ Object
-> Session.
-
#update_briefing(id, field:, value:) ⇒ Object
-> Session.
-
#update_vars(id, vars) ⇒ Object
-> Session (SHALLOW merge: an existing nested key is replaced wholesale, not merged).
Methods included from Coercion
blank?, deep_stringify, presence, present?, truthy?, utf8
Constructor Details
#initialize(store:) ⇒ SessionStore
store: any Insika::Store (Memory, SQLite, ...) — injected by the composition root (config/wiring.rb). SessionStore does not know the concrete backend.
38 39 40 |
# File 'lib/insika/session_store.rb', line 38 def initialize(store:) @store = store end |
Instance Method Details
#append_evidence(id, ids:, ungrounded:) ⇒ Object
appends this turn's evidence (ids + ungrounded delta) to the session record. RMW like append_messages — the SessionActor serializes same-session turns; the copy is in the method comment.
104 105 106 107 108 109 110 111 112 113 |
# File 'lib/insika/session_store.rb', line 104 def append_evidence(id, ids:, ungrounded:) record = fetch!(id) ev = record["evidence"] ||= { "ids" => [], "ungrounded" => 0 } fresh = (ev["ids"] + Array(ids).map(&:to_s).reject(&:empty?)).uniq.last(EvidenceLedger::MAX_IDS) ev["ids"] = fresh ev["ungrounded"] = ev["ungrounded"].to_i + ungrounded.to_i record["updated_at"] = @store.set(SCOPE, key_for(id), record) to_session(record) end |
#append_messages(id, messages) ⇒ Object
-> Session (transcript += messages). Read-modify-write on the task's own fiber, without a lock. Each message gets an "at" (ISO8601 UTC) if not provided. NotFoundError if the session does not exist.
CONCURRENCY LIMITATION (R2c): the RMW (read record -> += -> set) is atomic ONLY because the SessionActor serializes turns of the same session (one owner at a time). That serialization exists solely in SUPERVISED mode (the actor loop lives on the supervisor). Two concurrent send_message on the same session_id OUTSIDE that path (e.g. calling append_messages directly, or a non-supervised deployment) would interleave read/set and LOSE messages — there is no compare-and-swap here. Route same-session writes through the SessionActor; see session_actor.rb.
81 82 83 84 85 86 87 88 89 |
# File 'lib/insika/session_store.rb', line 81 def (id, ) record = fetch!(id) incoming = (.is_a?(Hash) ? [] : Array()) .map { |msg| stamp(deep_stringify(msg)) } record["messages"] += incoming record["updated_at"] = @store.set(SCOPE, key_for(id), record) to_session(record) end |
#create(id: SecureRandom.uuid, vars: {}) ⇒ Object
-> Session; ArgumentError if id already exists (a duplicate session is a domain violation — it never overwrites silently).
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
# File 'lib/insika/session_store.rb', line 44 def create(id: SecureRandom.uuid, vars: {}) key = key_for(id) raise ArgumentError, "session already exists: #{id}" unless @store.get(SCOPE, key).nil? now = record = { "id" => id.to_s, "messages" => [], "vars" => deep_stringify(vars), "memory_refs" => [], "briefing" => { "fields" => {}, "next_step" => nil }, "evidence" => { "ids" => [], "ungrounded" => 0 }, "created_at" => now, "updated_at" => now } @store.set(SCOPE, key, record) to_session(record) end |
#delete(id) ⇒ Object
-> bool (delegates to the backend: false for a nonexistent id)
154 155 156 |
# File 'lib/insika/session_store.rb', line 154 def delete(id) @store.delete(SCOPE, key_for(id)) end |
#each_id ⇒ Object
-> enumerates ids without the "session:" prefix. Without a block, returns an Enumerator.
160 161 162 163 164 165 166 |
# File 'lib/insika/session_store.rb', line 160 def each_id return enum_for(:each_id) unless block_given? @store.list(SCOPE, KEY_PREFIX).each do |key| yield key.delete_prefix(KEY_PREFIX) end end |
#find(id) ⇒ Object
-> Session | nil
64 65 66 67 |
# File 'lib/insika/session_store.rb', line 64 def find(id) record = @store.get(SCOPE, key_for(id)) record && to_session(record) end |
#set_next_step(id, text:) ⇒ Object
-> Session. Upsert the agreed next step; a blank text clears to nil NotFoundError if absent.
144 145 146 147 148 149 150 151 |
# File 'lib/insika/session_store.rb', line 144 def set_next_step(id, text:) record = fetch!(id) briefing = record["briefing"] ||= { "fields" => {}, "next_step" => nil } briefing["next_step"] = Coercion.presence(Coercion.utf8(text.to_s)) record["updated_at"] = @store.set(SCOPE, key_for(id), record) to_session(record) end |
#update_briefing(id, field:, value:) ⇒ Object
-> Session. Upsert ONE briefing field. The pack owns the schema; the engine validates nothing about field NAMES here (the tools do, at the write edge). value is a String (anything else -> to_s); a BLANK value (after strip) REMOVES the key — absence means "not yet asked" ( D4). NotFoundError if the session does not exist.
CONCURRENCY NOTE: an unlocked RMW (read -> mutate -> set), like append_messages — but the SessionActor argument does NOT apply here. A briefing writer is a system tool, never enveloped, and with tool_concurrency > 1 the gem runs each call in its OWN fiber — outside the actor's per-session turn serialization. The RMW is still safe, for a different reason: nothing in the read/mutate/set path suspends. Store get/set are synchronous (the SQLite write semaphore is a non-yielding fast path when free), and a fiber only switches at a scheduler suspension point — so no other writer can interleave mid-RMW (measured: N concurrent writers lose nothing). It holds ONLY while that path never suspends; an async store (a real yield in get/set) would need a lock or CAS.
132 133 134 135 136 137 138 139 140 |
# File 'lib/insika/session_store.rb', line 132 def update_briefing(id, field:, value:) record = fetch!(id) briefing = record["briefing"] ||= { "fields" => {}, "next_step" => nil } value = Coercion.presence(Coercion.utf8(value.to_s)) value ? briefing["fields"][field.to_s] = value : briefing["fields"].delete(field.to_s) record["updated_at"] = @store.set(SCOPE, key_for(id), record) to_session(record) end |
#update_vars(id, vars) ⇒ Object
-> Session (SHALLOW merge: an existing nested key is replaced wholesale, not merged). NotFoundError if absent.
93 94 95 96 97 98 99 |
# File 'lib/insika/session_store.rb', line 93 def update_vars(id, vars) record = fetch!(id) record["vars"] = record["vars"].merge(deep_stringify(vars)) record["updated_at"] = @store.set(SCOPE, key_for(id), record) to_session(record) end |