terret-core 0.1.0 → 0.1.1
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/terret/approvals.rb +267 -0
- data/lib/terret/compactor.rb +116 -0
- data/lib/terret/credentials.rb +172 -0
- data/lib/terret/llm.rb +90 -2
- data/lib/terret/loop.rb +538 -26
- data/lib/terret/redactor.rb +101 -0
- data/lib/terret/sessions.rb +325 -22
- data/lib/terret/store.rb +79 -0
- data/lib/terret/subagents.rb +99 -0
- data/lib/terret/titler.rb +58 -0
- data/lib/terret/tools.rb +280 -11
- data/lib/terret.rb +18 -1
- metadata +8 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terret
|
|
4
|
+
# ctx[:redactor] — §13's "credentials never enter the session log", in the
|
|
5
|
+
# two layers docs/exec.md §6 describes.
|
|
6
|
+
#
|
|
7
|
+
# The FIRST is a tools/post_execute listener: the waterfall every tool
|
|
8
|
+
# result already passes through, so a tool that happened to hand back a
|
|
9
|
+
# credential is rewritten before the loop appends it — and before any other
|
|
10
|
+
# post_execute listener, or any in-memory consumer of the Result, reads it.
|
|
11
|
+
#
|
|
12
|
+
# The SECOND is a Sessions scrubber, and it is the one that makes the
|
|
13
|
+
# promise true rather than likely: a secret can reach the log through a path
|
|
14
|
+
# that never touches a tool result at all — a user's own message, an
|
|
15
|
+
# injected steer, a plugin's durable event — and register_scrubber runs over
|
|
16
|
+
# every String of every append regardless of type. Because that runs INSIDE
|
|
17
|
+
# normalize_payload, the stored bytes and every projection derived from them
|
|
18
|
+
# agree by construction, so the log invariant needs nothing special here.
|
|
19
|
+
#
|
|
20
|
+
# What this is not: comprehensive. Patterns are regexp sources on this row's
|
|
21
|
+
# config, so it catches shapes a deployment named and nothing else. Catching a
|
|
22
|
+
# secret by its exact bytes rather than by a named shape is ctx[:credentials]'s
|
|
23
|
+
# job (plan §6.9, Terret::Credentials): it registers every value it resolves
|
|
24
|
+
# as its own append-boundary scrubber, running alongside this one.
|
|
25
|
+
class Redactor < Hames::Service
|
|
26
|
+
service_key :redactor
|
|
27
|
+
inject :sessions
|
|
28
|
+
config_schema patterns: { type: Array, default: [],
|
|
29
|
+
doc: "regexp/string patterns scrubbed from tool output and the log" },
|
|
30
|
+
replacement: { type: String, default: "[REDACTED]",
|
|
31
|
+
doc: "text a matched secret is replaced with" }
|
|
32
|
+
|
|
33
|
+
DEFAULT_REPLACEMENT = "[REDACTED]"
|
|
34
|
+
|
|
35
|
+
def start(ctx)
|
|
36
|
+
@patterns = compile(config[:patterns])
|
|
37
|
+
|
|
38
|
+
ctx.on("tools/post_execute") { |result, next_| next_.(redact_result(result)) }
|
|
39
|
+
# Registered through the seam rather than as a second listener: this
|
|
40
|
+
# runs at the append boundary itself (Sessions#register_scrubber), which
|
|
41
|
+
# is the whole reason the backstop is trustworthy.
|
|
42
|
+
ctx[:sessions].register_scrubber(method(:redact))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Patterns are compiled, so they are the one thing a hot swap has to
|
|
46
|
+
# re-derive; the replacement token is read per call and is already live.
|
|
47
|
+
# An uncompilable pattern raises here, and the loader rolls the row back.
|
|
48
|
+
def reconfigure(config)
|
|
49
|
+
@patterns = compile(config[:patterns])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The redaction itself, and the method the Sessions scrubber calls. A
|
|
53
|
+
# non-String is handed straight back: this is reached from the tools
|
|
54
|
+
# pipeline too, where a result's content may be anything a handler
|
|
55
|
+
# returned.
|
|
56
|
+
def redact(text)
|
|
57
|
+
return text unless text.is_a?(String)
|
|
58
|
+
|
|
59
|
+
# gsub's BLOCK form, not its string form, which would read `\0`/`\1` in
|
|
60
|
+
# a deployment's replacement token as backreferences — a token
|
|
61
|
+
# containing `\0` would paste the matched secret back in and leave a
|
|
62
|
+
# redactor that silently un-redacts.
|
|
63
|
+
@patterns.reduce(text) { |acc, pattern| acc.gsub(pattern) { replacement } }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Public because a reader downstream has to recognize this mechanism's own
|
|
67
|
+
# mark: the loop refuses to replay a resumed tool call whose stored
|
|
68
|
+
# arguments carry it (Loop#redaction_token).
|
|
69
|
+
def replacement = config[:replacement] || DEFAULT_REPLACEMENT
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Both fields, because both are model-visible: derive_messages projects a
|
|
74
|
+
# tool/result's error alongside its content, so a handler that echoes the
|
|
75
|
+
# credential it was handed into a Failure message leaks through exactly
|
|
76
|
+
# the same door. The append scrubber would catch either, but this layer
|
|
77
|
+
# exists so the Result object itself is clean the moment it leaves the
|
|
78
|
+
# pipeline.
|
|
79
|
+
def redact_result(result)
|
|
80
|
+
return result unless result.is_a?(Tools::Result)
|
|
81
|
+
|
|
82
|
+
result.with(content: redact(result.content), error: redact(result.error))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A pattern that does not compile is a configuration bug, so it fails at
|
|
86
|
+
# boot (or at the reconfigure that introduced it) rather than raising on
|
|
87
|
+
# every append forever after. A Regexp is accepted as itself, so a plugin
|
|
88
|
+
# composing rows in Ruby need not stringify what it already has.
|
|
89
|
+
def compile(patterns)
|
|
90
|
+
Array(patterns).map do |pattern|
|
|
91
|
+
next pattern if pattern.is_a?(Regexp)
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
Regexp.new(pattern.to_s)
|
|
95
|
+
rescue RegexpError => e
|
|
96
|
+
raise RegexpError, "redactor pattern #{pattern.inspect} does not compile: #{e.message}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
data/lib/terret/sessions.rb
CHANGED
|
@@ -8,6 +8,12 @@ module Terret
|
|
|
8
8
|
SessionEvent = Data.define(:id, :session_id, :seq, :at, :type, :payload)
|
|
9
9
|
|
|
10
10
|
class LogInvariantViolation < StandardError; end
|
|
11
|
+
class NonPrimitivePayload < StandardError; end
|
|
12
|
+
# A registered scrubber broke its contract. Distinct from
|
|
13
|
+
# NonPrimitivePayload on purpose: that one means the DATA was unstorable
|
|
14
|
+
# (something a caller can fix by encoding it), this one means a PLUGIN is
|
|
15
|
+
# broken, and the two must not be rescued by the same handler.
|
|
16
|
+
class ScrubberContractViolation < StandardError; end
|
|
11
17
|
|
|
12
18
|
# ctx.sessions — the append-only session log. The single source of the
|
|
13
19
|
# context the model sees: derive_messages projects model history from it,
|
|
@@ -15,36 +21,114 @@ module Terret
|
|
|
15
21
|
# exactly what replaying the log yields ("model-visible means logged").
|
|
16
22
|
class Sessions < Hames::Service
|
|
17
23
|
service_key :sessions
|
|
24
|
+
inject :session_store
|
|
25
|
+
config_schema({}) # the session log takes no config
|
|
18
26
|
|
|
19
27
|
Session = Struct.new(:id, :events, :parent_id, keyword_init: true)
|
|
20
28
|
|
|
29
|
+
# Payload keys whose values the harness minted to point at something else:
|
|
30
|
+
# tool call ids and their approval foreign keys, the part tag decode_part
|
|
31
|
+
# dispatches on, lineage, verdicts, the live allow list.
|
|
32
|
+
# A pattern written for a credential — long hex, a UUID shape — matches
|
|
33
|
+
# these too, and rewriting one protects nothing (none of it is
|
|
34
|
+
# model-carried) while it can wreck the log for good: two tool calls that
|
|
35
|
+
# collapse to one id are a request providers reject, a mangled tag makes
|
|
36
|
+
# the session undecodable, and a rewritten pattern list silently changes
|
|
37
|
+
# what an agent may run. Add a key here only when the harness itself
|
|
38
|
+
# generates its value. A tool NAME is deliberately absent: the model
|
|
39
|
+
# chooses it, so it is content — and redacting one fails safe, because a
|
|
40
|
+
# name that stops resolving comes back as a not-found error rather than
|
|
41
|
+
# collapsing two calls onto one identifier.
|
|
42
|
+
STRUCTURAL_KEYS = %i[id call_id type verdict status agent parent_id
|
|
43
|
+
from boundary upto_seq n patterns].freeze
|
|
44
|
+
|
|
45
|
+
# Keys whose value is a structural CONTAINER rather than a leaf: the
|
|
46
|
+
# exemption reaches through them to the identifiers inside, because an
|
|
47
|
+
# assistant message's encoded parts each carry their own tag and id.
|
|
48
|
+
STRUCTURAL_CONTAINERS = %i[parts].freeze
|
|
49
|
+
|
|
21
50
|
def start(ctx)
|
|
22
51
|
@ctx = ctx
|
|
23
|
-
@store =
|
|
24
|
-
@
|
|
52
|
+
@store = ctx[:session_store]
|
|
53
|
+
@cache = {}
|
|
54
|
+
@locks = {}
|
|
55
|
+
@locks_mutex = Mutex.new
|
|
56
|
+
@emit_mutex = Mutex.new
|
|
57
|
+
@emit_queue = []
|
|
58
|
+
@emitting = false
|
|
59
|
+
@scrubbers = []
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Rewrite every String of every durable payload on its way into the log
|
|
63
|
+
# (§13's log boundary; docs/exec.md §6). Registration is an effect, so the
|
|
64
|
+
# returned disposer unregisters and unloading the owning plugin reaps it.
|
|
65
|
+
#
|
|
66
|
+
# This is the boundary the scrubbing has to happen at rather than anywhere
|
|
67
|
+
# downstream: the stored event and every projection derived from it —
|
|
68
|
+
# derive_messages, and so both sides of the digest assert_log_invariant!
|
|
69
|
+
# compares — read the same already-scrubbed bytes, so "model-visible means
|
|
70
|
+
# logged" holds by construction. A read-time filter over the projection
|
|
71
|
+
# would leave the secret in the log itself and split the digest in two.
|
|
72
|
+
#
|
|
73
|
+
# Scrubbers fold in registration order: each is handed the previous one's
|
|
74
|
+
# output, so a later one can rewrite what an earlier one produced.
|
|
75
|
+
# What a live in-memory value would look like once stored, for a caller
|
|
76
|
+
# that has to compare one against a payload already in the log — the
|
|
77
|
+
# approvals gate matches a recorded verdict on the call's args, and
|
|
78
|
+
# scrubbing rewrites one side of that comparison. CONTENT scope, because
|
|
79
|
+
# such a value always sits nested under a content key (`args`), so its own
|
|
80
|
+
# keys get no structural exemption.
|
|
81
|
+
def stored_form(value) = normalize_payload(value, structural: false)
|
|
82
|
+
|
|
83
|
+
# Whether anything is registered. Callers that must reshape what they
|
|
84
|
+
# append to give a scrubber a fair look at it — the loop's chunk carry —
|
|
85
|
+
# ask this so they can stay exactly as they were when nobody is scrubbing.
|
|
86
|
+
def scrubbing? = !@scrubbers.empty?
|
|
87
|
+
|
|
88
|
+
# `ctx:` decides the registration's LIFETIME, not its reach: the scrubber
|
|
89
|
+
# list is the service's, so a scrubber always sees every append, but a
|
|
90
|
+
# caller passing its forked agent context ties ownership to that fork —
|
|
91
|
+
# the same bleed Registry#register closed, where a registration made by an
|
|
92
|
+
# agent outlived the agent that made it.
|
|
93
|
+
def register_scrubber(callable, ctx: @ctx)
|
|
94
|
+
ctx.effect do
|
|
95
|
+
@scrubbers << callable
|
|
96
|
+
# Identity, not ==: removing "the entry equal to this callable" would
|
|
97
|
+
# take a twin down with it if the same object were registered twice,
|
|
98
|
+
# and a scrubber that defines its own == could unregister a stranger.
|
|
99
|
+
lambda do
|
|
100
|
+
i = @scrubbers.rindex { |s| s.equal?(callable) }
|
|
101
|
+
@scrubbers.delete_at(i) if i
|
|
102
|
+
end
|
|
103
|
+
end
|
|
25
104
|
end
|
|
26
105
|
|
|
27
106
|
def create(id: SecureRandom.hex(6), parent_id: nil)
|
|
28
107
|
s = Session.new(id:, events: [], parent_id:)
|
|
29
|
-
@
|
|
108
|
+
@cache[id] = s
|
|
30
109
|
append(id, "session/created", { parent_id: })
|
|
31
110
|
s
|
|
32
111
|
end
|
|
33
112
|
|
|
34
|
-
def fetch(id) = @
|
|
113
|
+
def fetch(id) = @cache.fetch(id)
|
|
35
114
|
|
|
36
115
|
def append(session_id, type, payload = {})
|
|
37
116
|
decl = Hames.event(type)
|
|
38
117
|
raise Hames::ContractError, "#{type} is not a durable event" unless decl.durable
|
|
39
118
|
|
|
40
119
|
s = fetch(session_id)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
120
|
+
normalized = normalize_payload(payload)
|
|
121
|
+
ev = lock_for(session_id).synchronize do
|
|
122
|
+
e = SessionEvent.new(
|
|
123
|
+
id: SecureRandom.hex(8), session_id:, seq: s.events.length,
|
|
124
|
+
at: Time.now.utc, type: type.to_s, payload: normalized
|
|
125
|
+
)
|
|
126
|
+
# durable first: if the store raises, nothing believes the event happened
|
|
127
|
+
@store.append(e)
|
|
128
|
+
s.events << e
|
|
129
|
+
e
|
|
130
|
+
end
|
|
131
|
+
fan_out(ev)
|
|
48
132
|
ev
|
|
49
133
|
end
|
|
50
134
|
|
|
@@ -52,12 +136,15 @@ module Terret
|
|
|
52
136
|
def derive_messages(session_id, upto: nil)
|
|
53
137
|
events = fetch(session_id).events
|
|
54
138
|
events = events.take(upto) if upto
|
|
55
|
-
events.filter_map do |ev|
|
|
139
|
+
apply_compaction(events).filter_map do |ev|
|
|
56
140
|
case ev.type
|
|
57
141
|
when "user/message", "context/injected"
|
|
58
142
|
LLM::Message.new(role: :user, parts: [LLM::Text.new(text: ev.payload[:text])])
|
|
143
|
+
when "session/compacted"
|
|
144
|
+
LLM::Message.new(role: :user, parts: [LLM::Text.new(text: ev.payload[:summary])])
|
|
59
145
|
when "assistant/message"
|
|
60
|
-
LLM::Message.new(role: :assistant,
|
|
146
|
+
LLM::Message.new(role: :assistant,
|
|
147
|
+
parts: ev.payload[:parts].map { |p| LLM.decode_part(p) })
|
|
61
148
|
when "tool/result"
|
|
62
149
|
LLM::Message.new(role: :tool, parts: [
|
|
63
150
|
LLM::ToolResult.new(id: ev.payload[:id], content: ev.payload[:content],
|
|
@@ -67,6 +154,30 @@ module Terret
|
|
|
67
154
|
end
|
|
68
155
|
end
|
|
69
156
|
|
|
157
|
+
# Lifetime spend, projected from the log: sums every step/end's usage.
|
|
158
|
+
# A step whose provider sent no usage still counts as a step; its costs
|
|
159
|
+
# count as zero rather than poisoning the sum.
|
|
160
|
+
def usage(session_id)
|
|
161
|
+
out = { prompt_tokens: 0, completion_tokens: 0, cost: 0.0, steps: 0 }
|
|
162
|
+
fetch(session_id).events.each do |ev|
|
|
163
|
+
next unless ev.type == "step/end"
|
|
164
|
+
|
|
165
|
+
out[:steps] += 1
|
|
166
|
+
u = ev.payload[:usage] or next
|
|
167
|
+
out[:prompt_tokens] += u[:prompt_tokens] || 0
|
|
168
|
+
out[:completion_tokens] += u[:completion_tokens] || 0
|
|
169
|
+
out[:cost] += u[:cost] || 0.0
|
|
170
|
+
end
|
|
171
|
+
out
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The latest session/titled's title, or nil. Metadata, not model history.
|
|
175
|
+
def title(session_id)
|
|
176
|
+
fetch(session_id).events.reverse_each
|
|
177
|
+
.find { |e| e.type == "session/titled" }
|
|
178
|
+
&.payload&.[](:title)
|
|
179
|
+
end
|
|
180
|
+
|
|
70
181
|
# The enforcement point for "model-visible means logged": the loop calls
|
|
71
182
|
# this with the message list it is about to send; a mismatch against the
|
|
72
183
|
# log projection raises in dev/test.
|
|
@@ -78,29 +189,221 @@ module Terret
|
|
|
78
189
|
"outbound request diverges from session log projection for #{session_id}"
|
|
79
190
|
end
|
|
80
191
|
|
|
192
|
+
# Forks the in-memory working set; resume a store-only session first.
|
|
81
193
|
def fork(source_id, boundary: nil, child_id: SecureRandom.hex(6))
|
|
82
194
|
src = fetch(source_id)
|
|
83
195
|
child = Session.new(id: child_id, events: [], parent_id: source_id)
|
|
84
|
-
@
|
|
196
|
+
@cache[child_id] = child
|
|
85
197
|
events = boundary ? src.events.take(boundary) : src.events.dup
|
|
86
|
-
events.each
|
|
198
|
+
events.each do |ev|
|
|
199
|
+
copy = ev.with(session_id: child_id)
|
|
200
|
+
@store.append(copy)
|
|
201
|
+
child.events << copy
|
|
202
|
+
end
|
|
87
203
|
append(child_id, "session/forked", { from: source_id, boundary: boundary })
|
|
88
204
|
child
|
|
89
205
|
end
|
|
90
206
|
|
|
207
|
+
def read(session_id, from_seq: 0) = @store.read(session_id, from_seq: from_seq)
|
|
208
|
+
|
|
209
|
+
def session_ids = @store.session_ids
|
|
210
|
+
|
|
211
|
+
# Rebuild a session's working set from the durable store. Idempotent: a
|
|
212
|
+
# session already in memory is returned as-is (write-through keeps the
|
|
213
|
+
# store equal). New appends continue after the last recorded seq.
|
|
214
|
+
def resume(session_id)
|
|
215
|
+
return @cache[session_id] if @cache.key?(session_id)
|
|
216
|
+
|
|
217
|
+
events = @store.read(session_id)
|
|
218
|
+
raise KeyError, "unknown session #{session_id}" if events.empty?
|
|
219
|
+
|
|
220
|
+
@cache[session_id] = Session.new(id: session_id, events: events,
|
|
221
|
+
parent_id: parent_id_from(events))
|
|
222
|
+
end
|
|
223
|
+
|
|
91
224
|
private
|
|
92
225
|
|
|
93
|
-
|
|
94
|
-
|
|
226
|
+
# Seq assignment, the durable write, and the memory push are one critical
|
|
227
|
+
# section per session. The store write is a yield point — JSONL opens a
|
|
228
|
+
# file, and a fiber scheduler switches there — so without this two
|
|
229
|
+
# appenders (a connection frame and a turn, say) read the same
|
|
230
|
+
# events.length and both claim it. Mutex is fiber-aware under the
|
|
231
|
+
# scheduler, so this parks a fiber rather than stalling the reactor.
|
|
232
|
+
def lock_for(session_id)
|
|
233
|
+
@locks_mutex.synchronize { @locks[session_id] ||= Mutex.new }
|
|
95
234
|
end
|
|
96
235
|
|
|
97
|
-
|
|
98
|
-
|
|
236
|
+
# Fan-out runs OUTSIDE the append lock, and in seq order. A listener that
|
|
237
|
+
# appends while handling an event — the compactor and the titler both do,
|
|
238
|
+
# on turn/end — would otherwise deliver its nested event to every other
|
|
239
|
+
# subscriber before the event it reacted to, so a socket tail would see
|
|
240
|
+
# the log out of order. Queueing behind the delivery in flight fixes that,
|
|
241
|
+
# at a price worth naming: a listener's own append returns before that
|
|
242
|
+
# event fans out. Assumes one reactor; the flag is mutex-guarded so
|
|
243
|
+
# threaded appenders cannot lose a queued event between the two.
|
|
244
|
+
def fan_out(ev)
|
|
245
|
+
@emit_mutex.synchronize do
|
|
246
|
+
@emit_queue << ev
|
|
247
|
+
return if @emitting
|
|
99
248
|
|
|
100
|
-
|
|
101
|
-
f.puts JSON.generate(type: ev.type, seq: ev.seq, at: ev.at.iso8601,
|
|
102
|
-
payload: ev.payload.inspect)
|
|
249
|
+
@emitting = true
|
|
103
250
|
end
|
|
251
|
+
drain_emits
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def drain_emits
|
|
255
|
+
while (ev = next_emit)
|
|
256
|
+
@ctx.emit("session/event", ev)
|
|
257
|
+
end
|
|
258
|
+
rescue Exception
|
|
259
|
+
@emit_mutex.synchronize { @emitting = false }
|
|
260
|
+
raise
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def next_emit
|
|
264
|
+
@emit_mutex.synchronize do
|
|
265
|
+
ev = @emit_queue.shift
|
|
266
|
+
@emitting = false unless ev
|
|
267
|
+
ev
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Compacted history is still model-visible, so it lives in the log and
|
|
272
|
+
# projects as a user message standing in for everything at or before its
|
|
273
|
+
# boundary. The latest compaction wins; superseded ones drop out.
|
|
274
|
+
def apply_compaction(events)
|
|
275
|
+
latest = nil
|
|
276
|
+
events.each { |ev| latest = ev if ev.type == "session/compacted" }
|
|
277
|
+
return events unless latest
|
|
278
|
+
|
|
279
|
+
survivors = events.select do |ev|
|
|
280
|
+
ev.seq > latest.payload[:upto_seq] && ev.type != "session/compacted"
|
|
281
|
+
end
|
|
282
|
+
[latest] + survivors
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def parent_id_from(events)
|
|
286
|
+
forked = events.reverse.find { |e| e.type == "session/forked" }
|
|
287
|
+
forked ? forked.payload[:from] : events.first&.payload&.[](:parent_id)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# The primitives contract: durable payloads hold only strings, numbers,
|
|
291
|
+
# booleans, nil, arrays, and symbol-keyed hashes of the same. Symbols in
|
|
292
|
+
# value position become strings; string keys become symbols. Anything
|
|
293
|
+
# else raises — typed objects are encoded at the edges (LLM.encode_part).
|
|
294
|
+
#
|
|
295
|
+
# `scrub` and `structural` carry the redaction scope down the recursion:
|
|
296
|
+
# see STRUCTURAL_KEYS for what the exemption is and #child_scope for
|
|
297
|
+
# where it stops.
|
|
298
|
+
def normalize_payload(value, scrub: true, structural: true)
|
|
299
|
+
case value
|
|
300
|
+
when Integer, true, false, nil then value
|
|
301
|
+
when String
|
|
302
|
+
utf8 = begin
|
|
303
|
+
value.encoding == Encoding::UTF_8 ? value : value.encode(Encoding::UTF_8)
|
|
304
|
+
rescue EncodingError
|
|
305
|
+
raise NonPrimitivePayload,
|
|
306
|
+
"#{value.encoding.name} string does not convert to UTF-8; scrub it first"
|
|
307
|
+
end
|
|
308
|
+
unless utf8.valid_encoding?
|
|
309
|
+
raise NonPrimitivePayload, "invalid UTF-8 string is not storable; scrub it first"
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# After validation, so a scrubber is always handed storable UTF-8 —
|
|
313
|
+
# and re-validated below, because it hands something back.
|
|
314
|
+
scrub && !@scrubbers.empty? ? scrub_string(utf8) : utf8
|
|
315
|
+
when Float
|
|
316
|
+
raise NonPrimitivePayload, "non-finite Float is not storable" unless value.finite?
|
|
317
|
+
|
|
318
|
+
value
|
|
319
|
+
# Through the String arm rather than straight to the store: a symbol
|
|
320
|
+
# in value position is content like any other, and `to_s` on an
|
|
321
|
+
# ASCII-only symbol hands back a US-ASCII string that no scrubber
|
|
322
|
+
# would have seen and no encoding check would have normalized.
|
|
323
|
+
when Symbol then normalize_payload(value.to_s, scrub:, structural:)
|
|
324
|
+
when Array then value.map { |v| normalize_payload(v, scrub:, structural:) }
|
|
325
|
+
when Hash
|
|
326
|
+
value.each_with_object({}) do |(k, v), out|
|
|
327
|
+
key = case k
|
|
328
|
+
when Symbol then k
|
|
329
|
+
when String then k.to_sym
|
|
330
|
+
else raise NonPrimitivePayload, "#{k.class} is not a storable hash key"
|
|
331
|
+
end
|
|
332
|
+
# child_scope reads the un-scrubbed key: the structural exemption is
|
|
333
|
+
# decided by the real field NAME, before that name is ever rewritten.
|
|
334
|
+
child_scrub, child_structural = child_scope(key, scrub, structural)
|
|
335
|
+
# A KEY is content too once we have descended past the structural
|
|
336
|
+
# surface. An MCP tool's structured_content is a Hash whose keys the
|
|
337
|
+
# tool authored, and a secret-shaped one reached the log verbatim
|
|
338
|
+
# because only values recursed. Field names AT a structural level are
|
|
339
|
+
# the log's own vocabulary the projection reads by name, so they are
|
|
340
|
+
# never rewritten (`scrub_key` returns them untouched); the value
|
|
341
|
+
# exemption STRUCTURAL_KEYS carries is unaffected.
|
|
342
|
+
stored = scrub_key(key, scrub, structural)
|
|
343
|
+
raise NonPrimitivePayload, "duplicate key #{stored.inspect} after coercion" if out.key?(stored)
|
|
344
|
+
|
|
345
|
+
out[stored] = normalize_payload(v, scrub: child_scrub, structural: child_structural)
|
|
346
|
+
end
|
|
347
|
+
else
|
|
348
|
+
raise NonPrimitivePayload, "#{value.class} is not storable; encode it first"
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Where a key sits decides whether its value is exempt, because the same
|
|
353
|
+
# NAME means different things at different depths: `parts[0][:name]` is
|
|
354
|
+
# the tool a model called, while `args[:name]` is a terminal name the
|
|
355
|
+
# model itself wrote and `args[:content]` is a whole file. So the
|
|
356
|
+
# exemption holds only while nothing content-bearing has been entered:
|
|
357
|
+
# descending into anything outside these two lists turns it off for good,
|
|
358
|
+
# and everything below that point is scrubbed whatever it is called.
|
|
359
|
+
def child_scope(key, scrub, structural)
|
|
360
|
+
return [false, false] unless scrub # already inside an exempt subtree
|
|
361
|
+
return [false, false] if structural && STRUCTURAL_KEYS.include?(key)
|
|
362
|
+
return [true, true] if structural && STRUCTURAL_CONTAINERS.include?(key)
|
|
363
|
+
|
|
364
|
+
[true, false]
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# A key scrubs exactly where its sibling leaves do: only in content scope
|
|
368
|
+
# (past the structural surface) and only when something is registered.
|
|
369
|
+
# Routed through the String arm rather than scrub_string directly, so the
|
|
370
|
+
# key is UTF-8-encoded and validated before a scrubber sees it — the same
|
|
371
|
+
# contract every value String already meets. With no scrubber registered
|
|
372
|
+
# the key is handed back untouched, so a session that never scrubs stores
|
|
373
|
+
# byte-identical keys to before.
|
|
374
|
+
def scrub_key(key, scrub, structural)
|
|
375
|
+
return key unless scrub && !structural && !@scrubbers.empty?
|
|
376
|
+
|
|
377
|
+
normalize_payload(key.to_s, scrub: true, structural: false).to_sym
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Fold a payload string through the registered scrubbers, checking each
|
|
381
|
+
# answer. A scrubber runs after the primitives contract has already
|
|
382
|
+
# admitted the value, so a bad answer would land in the store unexamined:
|
|
383
|
+
# a nil where text was, or bytes the store's JSON generator rejects a
|
|
384
|
+
# frame later, with nothing left to say which plugin did it. Model-reachable
|
|
385
|
+
# data must never crash the harness, but a broken PLUGIN may and should —
|
|
386
|
+
# so this raises, naming the offender (a Proc's inspect carries its
|
|
387
|
+
# source location) rather than repairing what it cannot guess at.
|
|
388
|
+
def scrub_string(text)
|
|
389
|
+
@scrubbers.reduce(text) do |acc, scrubber|
|
|
390
|
+
out = scrubber.call(acc)
|
|
391
|
+
unless out.is_a?(String)
|
|
392
|
+
raise ScrubberContractViolation,
|
|
393
|
+
"scrubber #{scrubber.inspect} returned #{out.class}, not a String"
|
|
394
|
+
end
|
|
395
|
+
unless out.encoding == Encoding::UTF_8 && out.valid_encoding?
|
|
396
|
+
raise ScrubberContractViolation,
|
|
397
|
+
"scrubber #{scrubber.inspect} returned #{out.encoding.name} that is not valid " \
|
|
398
|
+
"UTF-8; a scrubber is handed UTF-8 and must hand UTF-8 back"
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
out
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def digest(messages)
|
|
406
|
+
Digest::SHA256.hexdigest(messages.map(&:inspect).join("\x1e"))
|
|
104
407
|
end
|
|
105
408
|
end
|
|
106
409
|
end
|
data/lib/terret/store.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Terret
|
|
8
|
+
# ctx[:session_store] — the persistence seam behind ctx.sessions. Providers
|
|
9
|
+
# durably record SessionEvents (whose payloads are primitives by the append
|
|
10
|
+
# contract) and hand them back exactly. Swapping the provider is a config
|
|
11
|
+
# row edit; Sessions never knows which one it is talking to. start is
|
|
12
|
+
# idempotent on every provider so an instance can ride across reboots.
|
|
13
|
+
module Store
|
|
14
|
+
# In-memory provider: the test default. Events are immutable, so sharing
|
|
15
|
+
# objects with Sessions' working set costs nothing.
|
|
16
|
+
class Memory < Hames::Service
|
|
17
|
+
service_key :session_store
|
|
18
|
+
config_schema({}) # the test default takes no config
|
|
19
|
+
|
|
20
|
+
def start(_ctx)
|
|
21
|
+
@events ||= Hash.new { |h, k| h[k] = [] }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def append(event) = @events[event.session_id] << event
|
|
25
|
+
|
|
26
|
+
def read(session_id, from_seq: 0)
|
|
27
|
+
@events.fetch(session_id, []).select { |ev| ev.seq >= from_seq }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Order is provider-defined; recency must be derived from event timestamps.
|
|
31
|
+
def session_ids = @events.keys
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# JSONL provider: one file per session, one JSON envelope per line, made
|
|
35
|
+
# for grepping. at carries microseconds so Time round-trips exactly.
|
|
36
|
+
class JSONL < Hames::Service
|
|
37
|
+
service_key :session_store
|
|
38
|
+
config_schema dir: { type: String, required: true,
|
|
39
|
+
doc: "directory holding one JSONL file per session" }
|
|
40
|
+
|
|
41
|
+
def start(_ctx)
|
|
42
|
+
@dir ||= config.fetch(:dir).tap { |d| FileUtils.mkdir_p(d) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def append(event)
|
|
46
|
+
File.open(path(event.session_id), "a") do |f|
|
|
47
|
+
f.puts JSON.generate(
|
|
48
|
+
id: event.id, session_id: event.session_id, seq: event.seq,
|
|
49
|
+
at: event.at.iso8601(6), type: event.type, payload: event.payload
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def read(session_id, from_seq: 0)
|
|
55
|
+
return [] unless File.exist?(path(session_id))
|
|
56
|
+
|
|
57
|
+
File.foreach(path(session_id)).filter_map do |line|
|
|
58
|
+
h = begin
|
|
59
|
+
JSON.parse(line, symbolize_names: true)
|
|
60
|
+
rescue JSON::ParserError
|
|
61
|
+
next # a torn line (crash mid-write) loses itself, not the session
|
|
62
|
+
end
|
|
63
|
+
next if h[:seq] < from_seq
|
|
64
|
+
|
|
65
|
+
SessionEvent.new(id: h[:id], session_id: h[:session_id], seq: h[:seq],
|
|
66
|
+
at: Time.iso8601(h[:at]), type: h[:type], payload: h[:payload])
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def session_ids
|
|
71
|
+
Dir[File.join(@dir, "*.jsonl")].map { |f| File.basename(f, ".jsonl") }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def path(session_id) = File.join(@dir, "#{session_id}.jsonl")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|