insika 0.2.0 → 0.3.0
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/CHANGELOG.md +145 -17
- data/README.md +7 -1
- data/bin/insika +211 -8
- data/docs/AGENTS.md +377 -2
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +99 -9
- data/docs/CONTEXT.md +127 -16
- data/docs/DEPLOY.md +5 -2
- data/docs/EVALS.md +19 -0
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +14 -8
- data/docs/OBSERVABILITY.md +54 -0
- data/docs/REFINEMENT.md +3 -3
- data/docs/RELEASING.md +27 -0
- data/docs/RUNNING-LOCAL.md +3 -3
- data/docs/SECURITY.md +61 -0
- data/docs/SKILLS.md +2 -2
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +1 -1
- data/lib/insika/agent_profile.rb +145 -10
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +118 -13
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +84 -1
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_tenant_data.rb +95 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +716 -6
- data/lib/insika/dsl/runtime.rb +9 -3
- data/lib/insika/dsl/server_boot.rb +70 -1
- data/lib/insika/dsl.rb +83 -3
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +9 -2
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/pairwise.rb +18 -6
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +687 -70
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +1 -1
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +6 -1
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +6 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +22 -1
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +144 -4
- data/lib/insika/server/rack_app.rb +16 -0
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +4 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +875 -14
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +22 -22
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +262 -5
- data/lib/insika/studio/nav_icons.rb +6 -1
- data/lib/insika/studio/views/agent_detail.erb +627 -84
- data/lib/insika/studio/views/agents.erb +10 -6
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +4 -1
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +56 -1
- data/lib/insika/studio/views/settings.erb +2 -0
- data/lib/insika/studio/views/skills.erb +0 -1
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +0 -1
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +25 -1
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_state.rb +38 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +300 -13
- data/lib/insika.rb +92 -3
- metadata +77 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# the durable CONTACT STATE cell per (tenant, customer)— ONE
|
|
7
|
+
# derived cell, never a transition log (D2): `granted | revoked |
|
|
8
|
+
# unavailable`, the sends-without-reply counter and the last outbound
|
|
9
|
+
# timestamp. Dumb domain store: it holds no policy and no follow-up records
|
|
10
|
+
# (the firer and the inbound hook own those transformations).
|
|
11
|
+
#
|
|
12
|
+
# Invariants (the firer enforces them, the store only records):
|
|
13
|
+
# · only `granted` may be messaged; absent = never messaged (:consent block);
|
|
14
|
+
# · `revoked` is immediate and permanent until the customer speaks;
|
|
15
|
+
# · `unavailable` means silence ≠ refusal — set when sends_without_reply
|
|
16
|
+
# reaches the policy's ceiling; further fires block until the customer
|
|
17
|
+
# speaks; any customer message reopens (sets granted + zeroes the counter).
|
|
18
|
+
#
|
|
19
|
+
# The customer identifier is the SAME string the message contract carries
|
|
20
|
+
# (`customer:` on /v1/messages, WS8).
|
|
21
|
+
class ContactStore
|
|
22
|
+
SCOPE = "contacts"
|
|
23
|
+
STATES = %w[granted revoked unavailable].freeze
|
|
24
|
+
|
|
25
|
+
Cell = Data.define(:state, :sends_without_reply, :last_outbound_at, :updated_at)
|
|
26
|
+
|
|
27
|
+
def initialize(store:)
|
|
28
|
+
@store = store
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# -> [Cell] | nil (absent = never messaged — the :consent block). nil
|
|
32
|
+
# customer -> nil (an untagged conversation has no cell).
|
|
33
|
+
def get(tenant:, customer:)
|
|
34
|
+
return nil if customer.to_s.empty?
|
|
35
|
+
|
|
36
|
+
record = @store.get(SCOPE, key(tenant, customer))
|
|
37
|
+
record && to_cell(record)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# -> { "<tenant>:<customer>" => raw record } — the whole scope, for the
|
|
41
|
+
# doctor's contact summary and the Studio (read-only folds; the mutations
|
|
42
|
+
# go through the commands, D10).
|
|
43
|
+
def cells
|
|
44
|
+
@store.list(SCOPE).each_with_object({}) do |k, acc|
|
|
45
|
+
record = @store.get(SCOPE, k)
|
|
46
|
+
acc[k] = record if record
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The ONLY writers. Each is a read-check-write on the cell (#transaction —
|
|
51
|
+
# two processes flipping the state in the same second serialize).
|
|
52
|
+
|
|
53
|
+
# Any customer message reopens the conversation: `granted` + the counter
|
|
54
|
+
# reset. The consent record itself (D2/D7) is the `schedule` tool call.
|
|
55
|
+
def set_granted(tenant:, customer:, now: Time.now.utc)
|
|
56
|
+
write(tenant, customer, now) do |record|
|
|
57
|
+
record["state"] = "granted"
|
|
58
|
+
record["sends_without_reply"] = 0
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# D7: the schedule tool's consent write. The customer agreeing
|
|
63
|
+
# in-conversation IS the consent — but ONLY a customer message reopens
|
|
64
|
+
# (D2): this NEVER lifts `:unavailable` and NEVER resets
|
|
65
|
+
# sends_without_reply, so a re-booking inside the scheduled turn cannot
|
|
66
|
+
# clear the silence protection. Creates the cell when absent (the first
|
|
67
|
+
# consent). Raises Insika::ValidationError on `:revoked` — an opt-out is
|
|
68
|
+
# permanent; the caller refuses.
|
|
69
|
+
def consent(tenant:, customer:, now: Time.now.utc)
|
|
70
|
+
raise Insika::ValidationError, "customer is required" if customer.to_s.empty?
|
|
71
|
+
|
|
72
|
+
@store.transaction do
|
|
73
|
+
record = @store.get(SCOPE, key(tenant, customer)) ||
|
|
74
|
+
{ "state" => "granted", "sends_without_reply" => 0,
|
|
75
|
+
"last_outbound_at" => nil, "updated_at" => nil }
|
|
76
|
+
if record["state"] == "revoked"
|
|
77
|
+
raise Insika::ValidationError,
|
|
78
|
+
"this customer opted out — you cannot schedule a follow-up"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
record["state"] ||= "granted"
|
|
82
|
+
record["updated_at"] = now.iso8601
|
|
83
|
+
@store.set(SCOPE, key(tenant, customer), record)
|
|
84
|
+
to_cell(record)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Keyword / channel opt-out / operator: immediate and permanent until the
|
|
89
|
+
# customer speaks. Nothing auto-revokes.
|
|
90
|
+
def set_revoked(tenant:, customer:, now: Time.now.utc)
|
|
91
|
+
write(tenant, customer, now) do |record|
|
|
92
|
+
record["state"] = "revoked"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Silence reached the policy's ceiling (the firer counts sends without a
|
|
97
|
+
# reply); further fires block until the customer speaks.
|
|
98
|
+
def mark_unavailable(tenant:, customer:, now: Time.now.utc)
|
|
99
|
+
write(tenant, customer, now) do |record|
|
|
100
|
+
record["state"] = "unavailable"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The firer's call: sends_without_reply += 1, last_outbound_at = now.
|
|
105
|
+
# Creates the cell when absent (granted — the firer checks the GO before
|
|
106
|
+
# bumping, so a bump only happens after the consent gate).
|
|
107
|
+
def bump_outbound(tenant:, customer:, now: Time.now.utc)
|
|
108
|
+
write(tenant, customer, now) do |record|
|
|
109
|
+
record["state"] ||= "granted"
|
|
110
|
+
record["sends_without_reply"] = record["sends_without_reply"].to_i + 1
|
|
111
|
+
record["last_outbound_at"] = now.iso8601
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Purge paths (C11 — the LGPD footprint): one cell; a whole tenant's
|
|
116
|
+
# (prefix scan); age-based. All nil-safe.
|
|
117
|
+
|
|
118
|
+
# -> true | false (did the cell exist?)
|
|
119
|
+
def delete(tenant:, customer:)
|
|
120
|
+
return false if customer.to_s.empty?
|
|
121
|
+
|
|
122
|
+
@store.delete(SCOPE, key(tenant, customer))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# -> count removed.
|
|
126
|
+
def purge(tenant:)
|
|
127
|
+
prefix = "#{tenant_id(tenant)}:"
|
|
128
|
+
keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
|
|
129
|
+
keys.each { |k| @store.delete(SCOPE, k) }
|
|
130
|
+
keys.size
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Cells untouched past the cutoff (WS8 retention). -> count removed.
|
|
134
|
+
def delete_older_than(time)
|
|
135
|
+
cutoff = time.utc.iso8601
|
|
136
|
+
removed = 0
|
|
137
|
+
@store.list(SCOPE).each do |k|
|
|
138
|
+
record = @store.get(SCOPE, k)
|
|
139
|
+
next unless record && record["updated_at"].to_s < cutoff
|
|
140
|
+
|
|
141
|
+
@store.delete(SCOPE, k)
|
|
142
|
+
removed += 1
|
|
143
|
+
end
|
|
144
|
+
removed
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# One cell per (tenant, customer); blank tenant -> the literal "platform"
|
|
150
|
+
# (outcome_store.rb's rule — contact, follow-up and outcome keys share one
|
|
151
|
+
# tenant segment so the purge prefix scans line up).
|
|
152
|
+
def key(tenant, customer)
|
|
153
|
+
"#{tenant_id(tenant)}:#{customer}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def tenant_id(tenant)
|
|
157
|
+
t = tenant.to_s
|
|
158
|
+
t.empty? ? "platform" : t
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Read-check-write inside the backend transaction (the budget_ledger.rb:38
|
|
162
|
+
# discipline): two writers racing the same cell serialize on the backend
|
|
163
|
+
# lock and the loser re-reads.
|
|
164
|
+
def write(tenant, customer, now, &block)
|
|
165
|
+
raise Insika::ValidationError, "customer is required" if customer.to_s.empty?
|
|
166
|
+
|
|
167
|
+
@store.transaction do
|
|
168
|
+
record = @store.get(SCOPE, key(tenant, customer)) ||
|
|
169
|
+
{ "state" => "granted", "sends_without_reply" => 0,
|
|
170
|
+
"last_outbound_at" => nil, "updated_at" => nil }
|
|
171
|
+
block.call(record)
|
|
172
|
+
record["updated_at"] = now.iso8601
|
|
173
|
+
@store.set(SCOPE, key(tenant, customer), record)
|
|
174
|
+
to_cell(record)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def to_cell(record)
|
|
179
|
+
Cell.new(state: record["state"], sends_without_reply: record["sends_without_reply"].to_i,
|
|
180
|
+
last_outbound_at: record["last_outbound_at"], updated_at: record["updated_at"])
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -66,7 +66,14 @@ module Insika
|
|
|
66
66
|
|
|
67
67
|
fragments = []
|
|
68
68
|
tasks.each do |provider, child|
|
|
69
|
-
|
|
69
|
+
# stamp the layer at PRODUCTION from the PROVIDER's
|
|
70
|
+
# declaration — authoritative, so a fragment that arrives pre-stamped
|
|
71
|
+
# cannot sneak above the boundary (a :volatile provider emitting a
|
|
72
|
+
# fragment with layer :identity would otherwise bypass the doctor's
|
|
73
|
+
# class-level check). A custom provider that never learned the contract
|
|
74
|
+
# leaves :volatile.
|
|
75
|
+
layer = provider.respond_to?(:layer) ? provider.layer : :volatile
|
|
76
|
+
Array(child.wait).each { |f| fragments << f.with(layer: layer) }
|
|
70
77
|
rescue StandardError => e # Async::TimeoutError is a StandardError; Async::Stop is NOT (propagates)
|
|
71
78
|
handle_provider_failure(provider, e, request)
|
|
72
79
|
end
|
|
@@ -135,10 +142,14 @@ module Insika
|
|
|
135
142
|
[survivors, evicted_sources]
|
|
136
143
|
end
|
|
137
144
|
|
|
138
|
-
# Step 6: assembly in DETERMINISTIC canonical order.
|
|
145
|
+
# Step 6: assembly in DETERMINISTIC canonical order. The
|
|
146
|
+
# identity-first partition — nothing volatile renders above the cache
|
|
147
|
+
# boundary. Each partition keeps the existing canonical sort; the partition
|
|
148
|
+
# is by LAYER only.
|
|
139
149
|
def assemble(fragments, cap, evicted)
|
|
140
150
|
system_frags = fragments.select { |f| f.placement == :system }
|
|
141
|
-
|
|
151
|
+
identity, volatile = system_frags.partition { |f| (f.layer || :volatile) == :identity }
|
|
152
|
+
system_frags = sort_canonical(identity) + sort_canonical(volatile)
|
|
142
153
|
history_frags = fragments.select { |f| f.placement == :history } # production order (chronological)
|
|
143
154
|
tool_frags = fragments.select { |f| f.placement == :tool_context }
|
|
144
155
|
|
|
@@ -153,6 +164,13 @@ module Insika
|
|
|
153
164
|
)
|
|
154
165
|
end
|
|
155
166
|
|
|
167
|
+
# The canonical system sort (priority DESC, source ASC, production index) —
|
|
168
|
+
# applied WITHIN each layer partition, so the prior order survives inside
|
|
169
|
+
# it and the boundary is the only thing that moved.
|
|
170
|
+
def sort_canonical(frags)
|
|
171
|
+
frags.sort_by.with_index { |f, i| [-f.priority, f.source.to_s, i] }
|
|
172
|
+
end
|
|
173
|
+
|
|
156
174
|
# :provider_warning. The Builder does not know task_id/seq (correlation is
|
|
157
175
|
# the Executor's job) — emits with what it has; Event#to_h does meta.compact.
|
|
158
176
|
def emit_warning(provider_id, message, request)
|
|
@@ -8,6 +8,10 @@ module Insika
|
|
|
8
8
|
# tokens: Integer | nil; estimated by the Builder when nil
|
|
9
9
|
# source: String — provider id (audit)
|
|
10
10
|
# pinned: true -> uncuttable in the budget (e.g. identity)
|
|
11
|
+
# layer: :identity | :volatile | nil. Stamped by the Builder
|
|
12
|
+
# at production from the provider's declaration; nil (a fragment
|
|
13
|
+
# built outside the Builder) reads as :volatile everywhere it is
|
|
14
|
+
# consumed — parity, never a crash.
|
|
11
15
|
# labels: [{ "name" =>, "reason" => }] — WHAT this fragment carries and WHY,
|
|
12
16
|
# as ids. Content-FREE by contract, so the context trace can report
|
|
13
17
|
# which skills a turn injected without storing a byte of the bodies.
|
|
@@ -22,12 +26,12 @@ module Insika
|
|
|
22
26
|
# written to the context trace and to events as JSON: the round-trip
|
|
23
27
|
# is then the identity, and no reader has to defend against both.
|
|
24
28
|
ContextFragment = Data.define(:content, :placement, :priority, :tokens,
|
|
25
|
-
:source, :pinned, :labels) do
|
|
29
|
+
:source, :pinned, :labels, :layer) do
|
|
26
30
|
def self.build(content:, placement:, source:, priority: 50, tokens: nil,
|
|
27
|
-
pinned: false, labels: [])
|
|
31
|
+
pinned: false, labels: [], layer: nil)
|
|
28
32
|
new(content: content, placement: placement, priority: priority,
|
|
29
33
|
tokens: tokens, source: source, pinned: pinned,
|
|
30
|
-
labels: Array(labels).map { |l| label(l) })
|
|
34
|
+
labels: Array(labels).map { |l| label(l) }, layer: layer)
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
# A bare String is still a valid label (a provider that has an id but no reason
|
|
@@ -22,6 +22,9 @@ module Insika
|
|
|
22
22
|
SKILL = 80 # <available_skills> level 1 (Skill)
|
|
23
23
|
MEMORY = 75 # <memory> read path (Memory)
|
|
24
24
|
TOOL_SEARCH = 70 # <available_tools> level 1 (ToolSearch)
|
|
25
|
+
BRIEFING = 65 # <briefing> session working state (Briefing) — D5:
|
|
26
|
+
# below every identity/skill/memory block (never breaks the
|
|
27
|
+
# pinned prefix), above the turn's own <request_context>.
|
|
25
28
|
HISTORY_MAX = 79 # history ceiling by recency (Session)
|
|
26
29
|
HISTORY_BASE = 60 # history base; +idx up to the ceiling (Session)
|
|
27
30
|
REQUEST = 40 # <request_context> — turn injection, the most cuttable
|
|
@@ -9,11 +9,25 @@ module Insika
|
|
|
9
9
|
def required? = false # true -> failure aborts the turn
|
|
10
10
|
def enabled_for?(_profile) = true
|
|
11
11
|
def call(_request) = [] # -> [ContextFragment]; may do IO
|
|
12
|
+
# which cache layer the output belongs to.
|
|
13
|
+
# :identity -> changes only on deploy/config edit (the cacheable prefix);
|
|
14
|
+
# :volatile -> may change per turn (the Builder renders identity first).
|
|
15
|
+
# :volatile is the conservative default — nothing gets pinned by accident.
|
|
16
|
+
def layer = :volatile
|
|
12
17
|
end
|
|
13
18
|
|
|
14
19
|
# Input for the provider contract.
|
|
15
|
-
# session:
|
|
16
|
-
# checkpoint:
|
|
20
|
+
# session: SessionStore::Session | nil
|
|
21
|
+
# checkpoint: Checkpoint | nil (present on ResumeTask — history comes from it)
|
|
22
|
+
# memory_scope: the CUSTOMER-scoped memory cell (WS8): "[tenant:]customer"
|
|
23
|
+
# when the request carries a customer, else nil (the providers
|
|
24
|
+
# fall back to tenant || session). Kept separate from `tenant`
|
|
25
|
+
# (the <request_context> merchant label) on purpose.
|
|
17
26
|
ContextRequest = Data.define(:session, :message, :profile, :tenant, :vars,
|
|
18
|
-
:checkpoint)
|
|
27
|
+
:checkpoint, :memory_scope) do
|
|
28
|
+
def initialize(session: nil, message: nil, profile: nil, tenant: nil, vars: {},
|
|
29
|
+
checkpoint: nil, memory_scope: nil)
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
end
|
|
19
33
|
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Context
|
|
5
|
+
module Providers
|
|
6
|
+
# Read path for the session briefing: the per-session
|
|
7
|
+
# working-state the agent keeps and asks for. Thin adapter over the
|
|
8
|
+
# SessionStore, same pattern as Memory: one `:system` fragment,
|
|
9
|
+
# deterministic. The MISSING list is rendered, never implied — that list
|
|
10
|
+
# is what stops the model re-asking for a field already given.
|
|
11
|
+
class Briefing < ContextProvider
|
|
12
|
+
def initialize(session_store:)
|
|
13
|
+
@session_store = session_store
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Stable id -> the context-trace category "briefing".
|
|
17
|
+
def id = "briefing"
|
|
18
|
+
|
|
19
|
+
# Pack-declared: no briefing_fields -> no provider (the Builder still
|
|
20
|
+
# applies the `context_providers` allowlist on top — two gates, like Memory).
|
|
21
|
+
def enabled_for?(profile)
|
|
22
|
+
fields = profile.respond_to?(:briefing_fields) ? profile.briefing_fields : nil
|
|
23
|
+
!Array(fields).empty?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# required? == false (default): a store failure degrades via the Builder's
|
|
27
|
+
# warning path, never aborts the turn.
|
|
28
|
+
def call(request)
|
|
29
|
+
session = request.respond_to?(:session) ? request.session : nil
|
|
30
|
+
return [] if session.nil? # one-shot turns have no briefing
|
|
31
|
+
|
|
32
|
+
briefing = briefing_for(session)
|
|
33
|
+
fields = briefing["fields"] || {}
|
|
34
|
+
declared = Array(request.profile.briefing_fields)
|
|
35
|
+
return [] if declared.empty? # defensive; enabled_for? already gates
|
|
36
|
+
|
|
37
|
+
block = format_block(declared, fields, briefing["next_step"])
|
|
38
|
+
return [] if block.nil?
|
|
39
|
+
|
|
40
|
+
[ContextFragment.build(content: block, placement: :system,
|
|
41
|
+
priority: Context::Priority::BRIEFING,
|
|
42
|
+
source: id)]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Re-reads the briefing from the store, like the Session provider: the
|
|
48
|
+
# persisted record is the source of truth, not the request's turn-start
|
|
49
|
+
# snapshot. A read failure propagates to the Builder, which degrades it
|
|
50
|
+
# to a :provider_warning (required? == false).
|
|
51
|
+
def briefing_for(session)
|
|
52
|
+
@session_store.find(session.id)&.briefing || {}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Byte contract (the specs assert this shape):
|
|
56
|
+
# <briefing>
|
|
57
|
+
# known:
|
|
58
|
+
# size: M
|
|
59
|
+
# still missing: delivery_day
|
|
60
|
+
# next step: send the payment link tomorrow at 10
|
|
61
|
+
# </briefing>
|
|
62
|
+
# Rules: `known` renders only when at least one declared field has a
|
|
63
|
+
# stored value; `still missing` renders every declared field with no
|
|
64
|
+
# stored value (including the all-missing case — that is the block's
|
|
65
|
+
# job); `next step` renders only when non-nil; stored keys NOT in the
|
|
66
|
+
# declaration are never rendered (they stay in the store and reappear if
|
|
67
|
+
# the pack re-declares them).
|
|
68
|
+
def format_block(declared, fields, next_step)
|
|
69
|
+
known = declared.filter_map do |name|
|
|
70
|
+
" #{name}: #{flatten(fields[name])}" if Coercion.present?(fields[name])
|
|
71
|
+
end
|
|
72
|
+
missing = declared.reject { |name| Coercion.present?(fields[name]) }
|
|
73
|
+
|
|
74
|
+
lines = []
|
|
75
|
+
lines << "known:" unless known.empty?
|
|
76
|
+
lines.concat(known)
|
|
77
|
+
lines << "still missing: #{missing.join(', ')}" unless missing.empty?
|
|
78
|
+
lines << "next step: #{flatten(next_step)}" if Coercion.present?(next_step)
|
|
79
|
+
return nil if lines.empty?
|
|
80
|
+
|
|
81
|
+
<<~BLOCK.strip
|
|
82
|
+
<briefing>
|
|
83
|
+
#{lines.join("\n")}
|
|
84
|
+
</briefing>
|
|
85
|
+
BLOCK
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# utf8 the value and flatten newlines/whitespace so a value can never
|
|
89
|
+
# break the block's line structure.
|
|
90
|
+
def flatten(value)
|
|
91
|
+
Coercion.utf8(value.to_s).gsub(/\s+/, " ").strip
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -20,7 +20,7 @@ module Insika
|
|
|
20
20
|
# required? == false (default): a failure (store unavailable) becomes a
|
|
21
21
|
# :provider_warning + graceful degradation — never aborts the turn.
|
|
22
22
|
def call(request)
|
|
23
|
-
tenant =
|
|
23
|
+
tenant = memory_scope(request)
|
|
24
24
|
facts = @store.facts(tenant: tenant)
|
|
25
25
|
notes = @store.notes(tenant: tenant, limit: @notes_limit)
|
|
26
26
|
return [] if facts.empty? && notes.empty?
|
|
@@ -33,15 +33,24 @@ module Insika
|
|
|
33
33
|
|
|
34
34
|
private
|
|
35
35
|
|
|
36
|
-
# Engine memory scope:
|
|
37
|
-
# (
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
|
|
36
|
+
# Engine memory scope (WS8): the request's CUSTOMER-scoped cell
|
|
37
|
+
# ("[tenant:]customer" — engine-owner memory is per customer, never per
|
|
38
|
+
# tenant) wins; otherwise an EXPLICIT tenant from the Command (the
|
|
39
|
+
# multi-merchant override); otherwise the SESSION (=chat), MARKED like
|
|
40
|
+
# the write path ("chat:<session id>" — : a session cell is
|
|
41
|
+
# never a bare cell, so the drill cannot read a conversation as a
|
|
42
|
+
# customer). No session (one-shot) and no tenant -> nil (MemoryStore
|
|
43
|
+
# applies _default). Symmetric to the write path (`state.tenant` in the
|
|
44
|
+
# Executor).
|
|
45
|
+
def memory_scope(request)
|
|
46
|
+
scoped = request.respond_to?(:memory_scope) ? request.memory_scope : nil
|
|
47
|
+
return scoped if scoped
|
|
48
|
+
|
|
41
49
|
explicit = request.respond_to?(:tenant) ? request.tenant : nil
|
|
42
50
|
return explicit if explicit
|
|
43
51
|
|
|
44
|
-
request.respond_to?(:session) ? request.session
|
|
52
|
+
session = request.respond_to?(:session) ? request.session : nil
|
|
53
|
+
session && session.id ? "#{Insika::MemoryStore::SESSION_TAG}:#{session.id}" : nil
|
|
45
54
|
end
|
|
46
55
|
|
|
47
56
|
# Passive <memory> (no instruction — the HOW of writing lives in the `remember` tool).
|
|
@@ -10,11 +10,26 @@ module Insika
|
|
|
10
10
|
#
|
|
11
11
|
# PER-AGENT identity. `profile.prompt_files` (file names)
|
|
12
12
|
# wins over the wiring's `files:` — this fixes the limitation of a new
|
|
13
|
-
# agent inheriting
|
|
13
|
+
# agent inheriting the previous persona's prompt. The content comes from `agent_files`
|
|
14
14
|
# (AgentFileStore, lives in the Store), with a File.read fallback
|
|
15
15
|
# for on-disk paths (compat/seed). Without prompt_files -> uses the wiring's
|
|
16
16
|
# `files:` (deployment default; byte-for-byte parity).
|
|
17
17
|
class Prompt < ContextProvider
|
|
18
|
+
# Engine-owned execution discipline, appended AFTER the agent's identity.
|
|
19
|
+
# The one behavior every reference harness bakes into its base prompt
|
|
20
|
+
# (OpenClaw's "Execution Bias") and this engine was missing: a weak tool
|
|
21
|
+
# result read as final. A constant — byte-identical every turn, so
|
|
22
|
+
# prompt_caching pays ONE write on the deploy that introduces it, never
|
|
23
|
+
# per turn. Opt-out per profile (`tool_persistence false`), the single
|
|
24
|
+
# default-ON profile flag: the proven-good behavior is the default, the
|
|
25
|
+
# exception is the thing an operator declares.
|
|
26
|
+
TOOL_PERSISTENCE = "## Tool discipline\n" \
|
|
27
|
+
"- Weak or empty tool result: try again with a different approach — rephrase the " \
|
|
28
|
+
"query, use a synonym or broader term, drop a secondary filter — before telling " \
|
|
29
|
+
"the user you found nothing. Do not narrate the retries. Then conclude.\n" \
|
|
30
|
+
"- Tool error: read the error, fix the arguments or try another path; never " \
|
|
31
|
+
"repeat the exact same call."
|
|
32
|
+
|
|
18
33
|
def initialize(base: "", files: [], catalog: nil, agent_files: nil, system_files: nil)
|
|
19
34
|
@base = base
|
|
20
35
|
@files = Array(files)
|
|
@@ -24,6 +39,8 @@ module Insika
|
|
|
24
39
|
end
|
|
25
40
|
|
|
26
41
|
def required? = true
|
|
42
|
+
# identity (config/agent-file derived — already pinned).
|
|
43
|
+
def layer = :identity
|
|
27
44
|
|
|
28
45
|
def call(request)
|
|
29
46
|
fragments = []
|
|
@@ -62,7 +79,18 @@ module Insika
|
|
|
62
79
|
else
|
|
63
80
|
sources.each { |src| parts << read_source(profile&.id, src.to_s) }
|
|
64
81
|
end
|
|
65
|
-
parts.reject { |p| p.nil? || p.strip.empty? }.join("\n\n")
|
|
82
|
+
identity = parts.reject { |p| p.nil? || p.strip.empty? }.join("\n\n")
|
|
83
|
+
# Discipline rides an EXISTING identity, never substitutes one: an
|
|
84
|
+
# agent with no identity at all must stay detectably empty.
|
|
85
|
+
return identity if identity.empty? || !tool_persistence?(profile)
|
|
86
|
+
|
|
87
|
+
"#{identity}\n\n#{TOOL_PERSISTENCE}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# nil/absent/true = ON (the engine default); only an explicit `false`
|
|
91
|
+
# turns it off. Defensive respond_to?: a minimal profile stub reads ON.
|
|
92
|
+
def tool_persistence?(profile)
|
|
93
|
+
!(profile.respond_to?(:tool_persistence) && profile.tool_persistence == false)
|
|
66
94
|
end
|
|
67
95
|
|
|
68
96
|
# GLOBAL system files: apply to every agent,
|
|
@@ -24,7 +24,8 @@ module Insika
|
|
|
24
24
|
# suspends (approval) and resumes re-runs the context stage, and the
|
|
25
25
|
# re-record replaces the first one instead of duplicating it. (`turn` is
|
|
26
26
|
# 1-based PER TASK, so the task is part of the key.) Missing session_id ->
|
|
27
|
-
# no-op.
|
|
27
|
+
# no-op. -> the sanitized entry (parks it on TurnState for the
|
|
28
|
+
# stage-8 cache merge).
|
|
28
29
|
def record(session_id:, entry:)
|
|
29
30
|
sid = session_id.to_s
|
|
30
31
|
return if sid.empty?
|
|
@@ -33,6 +34,7 @@ module Insika
|
|
|
33
34
|
key = [e["task_id"], e["turn"]]
|
|
34
35
|
list = (@store.get(SCOPE, sid) || []).reject { |x| [x["task_id"], x["turn"]] == key } + [e]
|
|
35
36
|
@store.set(SCOPE, sid, list.last(MAX_PER_SESSION))
|
|
37
|
+
e
|
|
36
38
|
rescue StandardError
|
|
37
39
|
nil
|
|
38
40
|
end
|
|
@@ -63,6 +65,11 @@ module Insika
|
|
|
63
65
|
cat = { "tokens" => int(c[:tokens] || c["tokens"]),
|
|
64
66
|
"fragments" => int(c[:fragments] || c["fragments"]),
|
|
65
67
|
"pinned" => int(c[:pinned] || c["pinned"]) }
|
|
68
|
+
# which cache layer the category belongs to ("identity" |
|
|
69
|
+
# "volatile"). Absent for a category recorded before the contract (or
|
|
70
|
+
# one that never learned it) — the view guards on nil.
|
|
71
|
+
layer = c[:layer] || c["layer"]
|
|
72
|
+
cat["layer"] = layer.to_s if layer
|
|
66
73
|
# WHAT the category carried and WHY ({name, reason}) — still ids only, so
|
|
67
74
|
# the no-masking-needed contract above holds. Omitted when empty: most
|
|
68
75
|
# categories have nothing to name and an empty key is just noise.
|
|
@@ -71,8 +78,36 @@ module Insika
|
|
|
71
78
|
acc[name.to_s] = cat
|
|
72
79
|
end,
|
|
73
80
|
"tools" => { "count" => int(tools[:count] || tools["count"]),
|
|
74
|
-
"tokens" => int(tools[:tokens] || tools["tokens"]) }
|
|
81
|
+
"tokens" => int(tools[:tokens] || tools["tokens"]) },
|
|
82
|
+
"fingerprints" => fingerprints_of(e[:fingerprints] || e["fingerprints"]),
|
|
83
|
+
"cache" => cache_of(e[:cache] || e["cache"])
|
|
84
|
+
}.compact
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# { name => sha256-hex }; names stringified, non-strings
|
|
88
|
+
# dropped. Absent when the caller passed nothing (a trace recorded before
|
|
89
|
+
# this feature has no key and the view guards on nil).
|
|
90
|
+
def fingerprints_of(raw)
|
|
91
|
+
return nil unless raw.is_a?(Hash) && !raw.empty?
|
|
92
|
+
|
|
93
|
+
raw.each_with_object({}) do |(name, hex), acc|
|
|
94
|
+
acc[name.to_s] = hex.to_s if hex.is_a?(String)
|
|
95
|
+
end.then { |h| h.empty? ? nil : h }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# { hit_pct, cached_tokens, prompt_tokens, invalidation_reason }.
|
|
99
|
+
# Unknown keys dropped. Present only when the caller passed it.
|
|
100
|
+
def cache_of(raw)
|
|
101
|
+
return nil unless raw.is_a?(Hash)
|
|
102
|
+
|
|
103
|
+
c = {
|
|
104
|
+
"hit_pct" => int_or_nil(raw[:hit_pct] || raw["hit_pct"]),
|
|
105
|
+
"cached_tokens" => int(raw[:cached_tokens] || raw["cached_tokens"]),
|
|
106
|
+
"prompt_tokens" => int(raw[:prompt_tokens] || raw["prompt_tokens"]),
|
|
107
|
+
"invalidation_reason" => raw[:invalidation_reason] || raw["invalidation_reason"]
|
|
75
108
|
}
|
|
109
|
+
c["invalidation_reason"] = c["invalidation_reason"].to_s unless c["invalidation_reason"].nil?
|
|
110
|
+
c
|
|
76
111
|
end
|
|
77
112
|
|
|
78
113
|
# Labels are {name, reason} in string keys (ContextFragment.label). A bare string
|
|
@@ -88,5 +123,6 @@ module Insika
|
|
|
88
123
|
end
|
|
89
124
|
|
|
90
125
|
def int(value) = Integer(value || 0)
|
|
126
|
+
def int_or_nil(value) = value.nil? ? nil : Integer(value)
|
|
91
127
|
end
|
|
92
128
|
end
|