insika 0.7.0 → 0.8.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/README.md +4 -3
  4. data/bin/insika +43 -2
  5. data/docs/AGENTS.md +22 -3
  6. data/docs/ARTIFACTS.md +42 -0
  7. data/docs/CONTEXT.md +54 -12
  8. data/docs/DEPLOY.md +16 -7
  9. data/docs/LOADTEST.md +15 -27
  10. data/docs/MEDIA.md +1 -1
  11. data/docs/OBSERVABILITY.md +31 -2
  12. data/docs/POLICY.md +10 -4
  13. data/docs/RUNNING-LOCAL.md +2 -2
  14. data/docs/SECURITY.md +1 -1
  15. data/docs/SOAK.md +1 -1
  16. data/docs/TOOLS.md +24 -0
  17. data/docs/prompts/GO-LIVE.md +3 -3
  18. data/lib/insika/agent_profile.rb +26 -1
  19. data/lib/insika/chat_builder.rb +28 -17
  20. data/lib/insika/compaction.rb +196 -0
  21. data/lib/insika/context/builder.rb +6 -2
  22. data/lib/insika/context/fragment.rb +4 -1
  23. data/lib/insika/context/priority.rb +6 -0
  24. data/lib/insika/context/providers/briefing.rb +53 -24
  25. data/lib/insika/context/providers/session.rb +46 -10
  26. data/lib/insika/context_trace_store.rb +11 -1
  27. data/lib/insika/doctor.rb +105 -10
  28. data/lib/insika/dsl/runtime.rb +4 -0
  29. data/lib/insika/env_schema.rb +5 -6
  30. data/lib/insika/evals/transport.rb +1 -1
  31. data/lib/insika/executor.rb +64 -0
  32. data/lib/insika/loop_detector.rb +5 -34
  33. data/lib/insika/profile_source.rb +7 -0
  34. data/lib/insika/server/responses.rb +4 -4
  35. data/lib/insika/session_store.rb +34 -4
  36. data/lib/insika/settings_store.rb +8 -1
  37. data/lib/insika/soak/runner.rb +4 -4
  38. data/lib/insika/studio/app.rb +28 -6
  39. data/lib/insika/studio/forms.rb +11 -0
  40. data/lib/insika/studio/views/settings.erb +11 -0
  41. data/lib/insika/telemetry/recorder.rb +49 -1
  42. data/lib/insika/templates/daily-digest/README.md +9 -0
  43. data/lib/insika/templates/research-analyst/agent.rb +10 -0
  44. data/lib/insika/tool_batch.rb +67 -0
  45. data/lib/insika/tool_usage_report.rb +162 -0
  46. data/lib/insika/turn_budget.rb +91 -0
  47. data/lib/insika/version.rb +1 -1
  48. data/lib/insika.rb +7 -0
  49. metadata +5 -1
@@ -5,9 +5,20 @@ module Insika
5
5
  module Providers
6
6
  # Read path for the session briefing: the per-session
7
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.
8
+ # SessionStore, same pattern as Memory, deterministic. The MISSING list is
9
+ # rendered, never implied — that list is what stops the model re-asking
10
+ # for a field already given.
11
+ #
12
+ # TWO fragments, and the split is the whole point:
13
+ # · `<briefing>` (:system) — the DURABLE facts, what is already known.
14
+ # Head of the prompt, where reference material belongs.
15
+ # · `<recitation>` (:tail) — what is still missing and what the next step
16
+ # is, rendered AFTER the whole history, as the last thing before the
17
+ # current user message.
18
+ # Attention is strongest at the END of the context: a goal stated only in
19
+ # the head is the first thing a 30-call turn forgets. The recitation lives
20
+ # in exactly one place — it was MOVED out of the head, never duplicated, so
21
+ # a turn pays for it once.
11
22
  class Briefing < ContextProvider
12
23
  def initialize(session_store:)
13
24
  @session_store = session_store
@@ -34,12 +45,10 @@ module Insika
34
45
  declared = Array(request.profile.briefing_fields)
35
46
  return [] if declared.empty? # defensive; enabled_for? already gates
36
47
 
37
- block = format_block(declared, fields, briefing["next_step"])
38
- return [] if block.nil?
48
+ missing = declared.reject { |name| Coercion.present?(fields[name]) }
49
+ next_step = briefing["next_step"]
39
50
 
40
- [ContextFragment.build(content: block, placement: :system,
41
- priority: Context::Priority::BRIEFING,
42
- source: id)]
51
+ [head_fragment(declared, fields), tail_fragment(missing, next_step)].compact
43
52
  end
44
53
 
45
54
  private
@@ -52,37 +61,57 @@ module Insika
52
61
  @session_store.find(session.id)&.briefing || {}
53
62
  end
54
63
 
55
- # Byte contract (the specs assert this shape):
64
+ # The HEAD block — durable facts only. Byte contract:
56
65
  # <briefing>
57
66
  # known:
58
67
  # size: M
59
- # still missing: delivery_day
60
- # next step: send the payment link tomorrow at 10
61
68
  # </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
+ # Nothing known yet -> no fragment at all (an empty `known:` header
70
+ # teaches the model nothing and still costs a cache invalidation).
71
+ # Stored keys NOT in the declaration are never rendered (they stay in the
72
+ # store and reappear if the pack re-declares them).
73
+ def head_fragment(declared, fields)
69
74
  known = declared.filter_map do |name|
70
75
  " #{name}: #{flatten(fields[name])}" if Coercion.present?(fields[name])
71
76
  end
72
- missing = declared.reject { |name| Coercion.present?(fields[name]) }
77
+ return nil if known.empty?
73
78
 
79
+ block = <<~BLOCK.strip
80
+ <briefing>
81
+ known:
82
+ #{known.join("\n")}
83
+ </briefing>
84
+ BLOCK
85
+ ContextFragment.build(content: block, placement: :system,
86
+ priority: Context::Priority::BRIEFING, source: id)
87
+ end
88
+
89
+ # The TAIL recitation — two lines, no more. Byte contract:
90
+ # <recitation>
91
+ # still missing: delivery_day
92
+ # next step: send the payment link tomorrow at 10
93
+ # </recitation>
94
+ # `still missing` renders every declared field with no stored value
95
+ # (including the all-missing case — that is this block's job); `next step`
96
+ # renders only when non-nil. Neither -> no fragment.
97
+ #
98
+ # A `user` message, like every other engine append inside a turn
99
+ # (LoopDetector, TurnBudget): the system prefix stays byte-stable, so the
100
+ # cache breakpoint at its end keeps hitting.
101
+ def tail_fragment(missing, next_step)
74
102
  lines = []
75
- lines << "known:" unless known.empty?
76
- lines.concat(known)
77
103
  lines << "still missing: #{missing.join(', ')}" unless missing.empty?
78
104
  lines << "next step: #{flatten(next_step)}" if Coercion.present?(next_step)
79
105
  return nil if lines.empty?
80
106
 
81
- <<~BLOCK.strip
82
- <briefing>
107
+ block = <<~BLOCK.strip
108
+ <recitation>
83
109
  #{lines.join("\n")}
84
- </briefing>
110
+ </recitation>
85
111
  BLOCK
112
+ ContextFragment.build(content: { role: :user, content: block },
113
+ placement: :tail,
114
+ priority: Context::Priority::RECITATION, source: id)
86
115
  end
87
116
 
88
117
  # utf8 the value and flatten newlines/whitespace so a value can never
@@ -14,8 +14,8 @@ module Insika
14
14
  end
15
15
 
16
16
  def call(request)
17
- messages = transcript_for(request)
18
- return [] if messages.nil? || messages.empty?
17
+ messages, compaction = transcript_for(request)
18
+ return [] if messages.nil? || (messages.empty? && compaction.nil?)
19
19
 
20
20
  # A3/C3 opt-in: identical tool results in the transcript collapse to a
21
21
  # back-reference (the cheap half of compaction). CHANGES WHAT THE MODEL
@@ -28,7 +28,7 @@ module Insika
28
28
  # the fragment level means the budget cut (apply_budget) drops a whole
29
29
  # tool cycle atomically — a tool_use is NEVER seeded without its result
30
30
  # (which providers reject), without touching apply_budget itself.
31
- eviction_units(messages).each_with_index.map do |unit, idx|
31
+ fragments = eviction_units(messages).each_with_index.map do |unit, idx|
32
32
  ContextFragment.build(
33
33
  # single message stays a Hash (compat with existing fragments); a
34
34
  # multi-message cycle is an Array (seed_history flattens it back).
@@ -39,6 +39,7 @@ module Insika
39
39
  source: id
40
40
  )
41
41
  end
42
+ compaction ? [compaction_fragment(compaction)] + fragments : fragments
42
43
  end
43
44
 
44
45
  private
@@ -90,15 +91,50 @@ module Insika
90
91
  end
91
92
 
92
93
  # Precedence: checkpoint -> explicit history -> store.
93
- # The first present source wins; no merge.
94
+ # The first present source wins; no merge. -> [messages, compaction|nil].
95
+ # The compaction state (RFC-0044) applies to the STORE source only: a
96
+ # checkpoint resume replays the checkpoint's own tape (which already
97
+ # contains whatever summary the original turn saw) and an explicit
98
+ # vars[:history] is the caller's contract — neither is rewritten.
94
99
  def transcript_for(request)
95
- return request.checkpoint.messages if request.checkpoint
100
+ return [request.checkpoint.messages, nil] if request.checkpoint
96
101
 
97
102
  explicit = explicit_history(request)
98
- return explicit if explicit
99
- return session_messages(request.session) if request.session
103
+ return [explicit, nil] if explicit
104
+ return compacted_session_messages(request.session) if request.session
100
105
 
101
- nil
106
+ [nil, nil]
107
+ end
108
+
109
+ # Splits the stored transcript at the persisted compaction boundary:
110
+ # messages[0...upto] are represented by the summary, messages[upto..]
111
+ # stay verbatim. `upto` is clamped to the transcript size (defensive —
112
+ # the store is append-only, so it should never outrun it).
113
+ def compacted_session_messages(session)
114
+ fresh = fresh_session(session)
115
+ return [[], nil] if fresh.nil?
116
+
117
+ messages = fresh.messages || []
118
+ state = fresh.respond_to?(:compaction) ? fresh.compaction : nil
119
+ upto = state ? state["upto"].to_i : 0
120
+ return [messages, nil] unless upto.positive? && Coercion.present?(state["summary"])
121
+
122
+ [messages.drop([upto, messages.size].min), state]
123
+ end
124
+
125
+ # The compacted prefix as ONE history fragment, rendered FIRST (the
126
+ # Builder keeps history in production order). role "user" because it is
127
+ # provider-agnostic (a mid-history "system" message is not). Priority
128
+ # COMPACTION (59): the "oldest unit" — under budget it drops before any
129
+ # verbatim message. source "compaction" -> its own context-trace category.
130
+ def compaction_fragment(state)
131
+ ContextFragment.build(
132
+ content: { role: "user",
133
+ content: "<conversation_summary>\n#{state['summary']}\n</conversation_summary>" },
134
+ placement: :history,
135
+ priority: Context::Priority::COMPACTION,
136
+ source: "compaction"
137
+ )
102
138
  end
103
139
 
104
140
  # Source 2 (explicit history): the handler passes it in request.vars[:history].
@@ -111,8 +147,8 @@ module Insika
111
147
  # CONDITIONAL requiredness: when a session is requested, a read
112
148
  # failure becomes a ContextError (aborts the turn); the base required?
113
149
  # does not receive the request, so the behavior lives here.
114
- def session_messages(session)
115
- @session_store.find(session.id)&.messages || []
150
+ def fresh_session(session)
151
+ @session_store.find(session.id)
116
152
  rescue StandardError => e # read failure (exception/StoreError)
117
153
  raise ContextError.new("Session provider failed with a requested session: #{e.message}",
118
154
  provider: id)
@@ -80,10 +80,20 @@ module Insika
80
80
  "tools" => { "count" => int(tools[:count] || tools["count"]),
81
81
  "tokens" => int(tools[:tokens] || tools["tokens"]) },
82
82
  "fingerprints" => fingerprints_of(e[:fingerprints] || e["fingerprints"]),
83
- "cache" => cache_of(e[:cache] || e["cache"])
83
+ "cache" => cache_of(e[:cache] || e["cache"]),
84
+ "compaction" => compaction_of(e[:compaction] || e["compaction"])
84
85
  }.compact
85
86
  end
86
87
 
88
+ # { upto, runs } — present only when the session was compacted (RFC-0044).
89
+ # Counts only, like everything else here; the summary text never lands.
90
+ def compaction_of(raw)
91
+ return nil unless raw.is_a?(Hash)
92
+
93
+ { "upto" => int(raw[:upto] || raw["upto"]),
94
+ "runs" => int(raw[:runs] || raw["runs"]) }
95
+ end
96
+
87
97
  # { name => sha256-hex }; names stringified, non-strings
88
98
  # dropped. Absent when the caller passed nothing (a trace recorded before
89
99
  # this feature has no key and the view guards on nil).
data/lib/insika/doctor.rb CHANGED
@@ -179,7 +179,8 @@ module Insika
179
179
  check_relay_channel check_web_widget check_skill_eager check_skill_drift check_shadow_parity
180
180
  check_soak_envelope check_turn_timing check_grounding check_cache_layers
181
181
  check_memory_scopes check_funnel_declarations check_followup check_distill
182
- check_harvest check_schedules check_guardrail_corpora]
182
+ check_compaction check_harvest check_schedules check_guardrail_corpora
183
+ check_tool_allowlist_policy]
183
184
 
184
185
  def safe(check)
185
186
  Array(send(check))
@@ -722,26 +723,59 @@ module Insika
722
723
  # the mangled prompt on every turn, and nothing else would ever say so: the file is
723
724
  # present, non-empty, and the agent answers — worse than a crash. Found on the pilot
724
725
  # by an `insika refine` report, three weeks after the fact.
726
+ #
727
+ # The same sweep also WARNS (never errors) on a file that outgrew a prompt.
728
+ # Merchant packs are LLM-generated (generate-merchant-pack), and generated prose
729
+ # bloats: the pilot's 28 KB AGENTS.md is the local example, and the ETH Zurich
730
+ # instruction-file study puts the cost of that shape at 20%+ extra tokens per
731
+ # turn for no extra instruction-following. The thresholds are deliberately
732
+ # generous — a hand-written file never meets them; only the generated shape does.
725
733
  def check_prompt_files
726
734
  return [] unless @agent_file_store
727
735
 
728
736
  agents = @agent_file_store.agents
729
- wrapped = agents.flat_map do |agent|
730
- @agent_file_store.list(agent).filter_map do |name|
731
- next unless wrapped_content?(@agent_file_store.read(agent, name))
732
-
733
- Finding.new(check: "prompt-files", severity: :error, fix: nil,
734
- message: "agent '#{agent}' file '#{name}' holds a serialized object, not text — " \
735
- "the model receives `{\"content\" => …}` on one line, escapes and all. " \
736
- "Recover the markdown from inside the wrapper and write it back.")
737
+ wrapped = []
738
+ oversized = []
739
+ agents.each do |agent|
740
+ @agent_file_store.list(agent).each do |name|
741
+ content = @agent_file_store.read(agent, name)
742
+ if wrapped_content?(content)
743
+ wrapped << Finding.new(check: "prompt-files", severity: :error, fix: nil,
744
+ message: "agent '#{agent}' file '#{name}' holds a serialized object, not text — " \
745
+ "the model receives `{\"content\" => …}` on one line, escapes and all. " \
746
+ "Recover the markdown from inside the wrapper and write it back.")
747
+ elsif (finding = oversized_prompt(agent, name, content))
748
+ oversized << finding
749
+ end
737
750
  end
738
751
  end
739
- return wrapped if wrapped.any?
752
+ return wrapped + oversized if wrapped.any? || oversized.any?
740
753
 
741
754
  total = agents.sum { |a| @agent_file_store.list(a).length }
742
755
  [ok("prompt-files", "#{total} prompt file(s) across #{agents.length} agent(s): all text")]
743
756
  end
744
757
 
758
+ # WARN thresholds for one prompt file. ~6 000 estimated tokens (~24 KB) or 600
759
+ # lines: the pilot's generated 28 KB / 292-line AGENTS.md trips the token bar,
760
+ # every hand-written demo file stays far under both. Estimate = chars/4, the
761
+ # same yardstick as Insika::TokenEstimator — cheap and honest about being ±15%.
762
+ PROMPT_FILE_WARN_TOKENS = 6_000
763
+ PROMPT_FILE_WARN_LINES = 600
764
+
765
+ def oversized_prompt(agent, name, content)
766
+ text = content.to_s
767
+ tokens = Insika::TokenEstimator.estimate(text)
768
+ lines = text.lines.count
769
+ return nil if tokens <= PROMPT_FILE_WARN_TOKENS && lines <= PROMPT_FILE_WARN_LINES
770
+
771
+ Finding.new(check: "prompt-files", severity: :warn, fix: nil,
772
+ message: "agent '#{agent}' file '#{name}' is ~#{tokens} tokens over #{lines} line(s) " \
773
+ "(threshold: #{PROMPT_FILE_WARN_TOKENS} tokens / #{PROMPT_FILE_WARN_LINES} lines) — " \
774
+ "a prompt this large costs 20%+ more tokens on every turn for no better " \
775
+ "instruction-following. Trim it, or split the reference material into skills " \
776
+ "the agent loads on demand.")
777
+ end
778
+
745
779
  # Cheap and specific: Ruby's inspect of a Hash whose first key is a string. A real
746
780
  # prompt does not open with `{"…" =>`.
747
781
  def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
@@ -868,6 +902,46 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
868
902
  # harmless but useless — every claim passes and the audit reads zero. A
869
903
  # warning, never an error: the pack owns matcher quality; the engine refuses
870
904
  # only uncompileable data.
905
+ # A stored agent that declares `tools_allow` / `tools_deny` /
906
+ # `tools_allow_groups` but does not name the `tool_allowlist` policy. Only
907
+ # that policy applies the lists, and the Policy::Engine runs ONLY the
908
+ # policies a profile names — so as written on disk the lists are inert and
909
+ # the model receives every registered tool (hundreds of schemas, tens of
910
+ # thousands of tokens, per request).
911
+ #
912
+ # AgentProfile.build now adds the policy whenever a list is declared, so a
913
+ # live turn off this record is already safe. The record itself stays wrong
914
+ # until something re-saves it, and anything that reads the stored shape
915
+ # directly — an older engine, a pack export, an operator auditing the
916
+ # Studio form — still sees an allowlist that does nothing. That is why this
917
+ # reads the RAW record and not `all`.
918
+ def check_tool_allowlist_policy
919
+ return [] unless @profile_source.respond_to?(:all_raw)
920
+
921
+ records = @profile_source.all_raw
922
+ bad = records.select { |r| tool_lists_declared?(r) && !names_tool_allowlist?(r) }
923
+ return [ok("tool-allowlist", "#{records.length} stored agent(s): every declared tool list names the policy")] if bad.empty?
924
+
925
+ bad.map do |r|
926
+ Finding.new(check: "tool-allowlist", severity: :error, fix: nil,
927
+ message: "agent '#{r["id"]}' declares a tool allow/deny list but its policies " \
928
+ "(#{Array(r["policies"]).join(", ").then { |p| p.empty? ? "none" : p }}) " \
929
+ "do not include tool_allowlist — as stored, the list is never applied and " \
930
+ "the model receives every registered tool. The engine repairs this when it " \
931
+ "loads the agent; re-save it (Studio, or the pack) so the stored policies " \
932
+ "match the intent.")
933
+ end
934
+ end
935
+
936
+ # Presence, not emptiness, for the two nil-able lists: `tools_allow: []`
937
+ # means "no tools" and is as much a declaration as a list of names.
938
+ # `tools_deny` has no nil state, so only a non-empty one counts.
939
+ def tool_lists_declared?(raw)
940
+ !raw["tools_allow"].nil? || !raw["tools_allow_groups"].nil? || !Array(raw["tools_deny"]).empty?
941
+ end
942
+
943
+ def names_tool_allowlist?(raw) = Array(raw["policies"]).any? { |p| p.to_s == "tool_allowlist" }
944
+
871
945
  def check_grounding
872
946
  return [] unless @profile_source
873
947
 
@@ -1230,6 +1304,27 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
1230
1304
  stale: @proposal_store.stale(limit: 10_000).size }
1231
1305
  end
1232
1306
 
1307
+ # the in-session compaction check (RFC-0044): platform-gated,
1308
+ # so ONE line — enabled with no resolvable model (no compaction.model, no
1309
+ # platform utility_model) can never run; the warn is the same "declared
1310
+ # but dead" signal as check_distill's (the engine never guesses a model).
1311
+ def check_compaction
1312
+ return [] unless @settings_store
1313
+
1314
+ settings = @settings_store.get
1315
+ config = settings["compaction"] || {}
1316
+ return [ok("compaction", "in-session compaction off")] unless Coercion.truthy?(config["enabled"])
1317
+
1318
+ if Coercion.presence(config["model"]).nil? && Coercion.presence(settings["utility_model"]).nil?
1319
+ [Finding.new(check: "compaction", severity: :warn, fix: nil,
1320
+ message: "in-session compaction is enabled but has no model slot — it will " \
1321
+ "never run (set compaction.model or the platform utility_model).")]
1322
+ else
1323
+ [ok("compaction", "in-session compaction on — keep_last #{config['keep_last']}, " \
1324
+ "compact_after #{config['compact_after']}")]
1325
+ end
1326
+ end
1327
+
1233
1328
  # the harvest check — per profile WITH a harvest hash:
1234
1329
  # declared-without-model warn (D12), no grounding matcher warn (D3),
1235
1330
  # malformed negative list error (D4), else ok with the pending counts.
@@ -236,6 +236,10 @@ module Insika
236
236
  Insika::Context::Providers::ToolSearch.new(catalog: c[:tool_catalog]),
237
237
  Insika::Context::Providers::Memory.new(store: spine.memory_store),
238
238
  Insika::Context::Providers::Knowledge.new(store: spine.knowledge_store),
239
+ # Session briefing: read path. Inert for agents without briefing_fields.
240
+ # Before Session, so the durable head renders above the transcript and
241
+ # the tail recitation lands after it.
242
+ Insika::Context::Providers::Briefing.new(session_store: spine.session_store),
239
243
  Insika::Context::Providers::Session.new(session_store: spine.session_store)
240
244
  ] # NOT frozen: load_plugins appends plugin providers at boot
241
245
  end
@@ -72,10 +72,10 @@ module Insika
72
72
 
73
73
  # Prefixes the engine fully OWNS: an unknown key under one of these is a typo, not
74
74
  # a foreign var. INSIKA_ (current) and HARNESS_ (legacy, still honored during the
75
- # deprecation window). Deliberately NOT OPENCLAW_ (shared with the OpenClaw gateway
76
- # product, which sets its own OPENCLAW_HOME/_STATE_DIR/… — the engine merely borrows
77
- # 3 names for interop), nor LITESTREAM_ (the sidecar owns it), nor OTEL_ (the
78
- # OpenTelemetry SDK owns its env).
75
+ # deprecation window). Deliberately NOT OPENCLAW_ (the OpenClaw gateway product
76
+ # sets its own OPENCLAW_HOME/_STATE_DIR/… on the same host; the engine reads none
77
+ # of them), nor LITESTREAM_ (the sidecar owns it), nor OTEL_ (the OpenTelemetry
78
+ # SDK owns its env).
79
79
  OWNED_PREFIXES = [PREFIX, LEGACY_PREFIX].freeze
80
80
 
81
81
  BOOLEANS = %w[1 0 true false yes no on off].freeze
@@ -142,8 +142,7 @@ module Insika
142
142
  spec(name: "INSIKA_ROUTER_BACKEND_TIMEOUT", type: :integer, description: "`insika-router`'s connect/read timeout to a backend, in seconds (default 10)."),
143
143
  spec(name: "INSIKA_ROUTER_HOST", description: "bind address for `insika-router` itself (default 0.0.0.0)."),
144
144
  spec(name: "INSIKA_ROUTER_PORT", type: :integer, description: "listen port for `insika-router` itself (default 9090)."),
145
- spec(name: "OPENCLAW_GATEWAY_TOKEN", secret: true, description: "Bearer for /v1 + /a2a (falls back to ADMIN_TOKEN)."),
146
- spec(name: "OPENCLAW_AGENTS_DIR", type: :path, description: "Directory of OpenClaw-style agent packs."),
145
+ spec(name: "INSIKA_GATEWAY_TOKEN", secret: true, description: "Bearer for /v1 + /a2a (falls back to ADMIN_TOKEN when unset)."),
147
146
  spec(name: "INSIKA_PLUGIN_DIR", type: :path, description: "Workspace plugin root (directories with insika.plugin.yml). Loaded at boot; ids still need INSIKA_PLUGINS."),
148
147
  spec(name: "INSIKA_PLUGINS", type: :csv, description: "Plugin ids to enable from the workspace/bundled roots. Announced gems are enabled by installing them."),
149
148
  spec(name: "INSIKA_PLUGINS_DISABLED", type: :csv, description: "Plugin ids that never load — the absolute veto, wins over INSIKA_PLUGINS and over an announced gem."),
@@ -135,7 +135,7 @@ module Insika
135
135
  req["Authorization"] = "Bearer #{@token}"
136
136
  req["Content-Type"] = "application/json"
137
137
  req["Accept"] = "text/event-stream"
138
- req.body = JSON.generate(model: "openclaw:#{agent}", user: conv, stream: true, input: message)
138
+ req.body = JSON.generate(model: "insika:#{agent}", user: conv, stream: true, input: message)
139
139
 
140
140
  t0 = mono
141
141
  ttfb = nil
@@ -1038,6 +1038,11 @@ module Insika
1038
1038
  tokens: estimate_tools_tokens(state.allowed_tools) },
1039
1039
  fingerprints: fingerprints,
1040
1040
  cache: { invalidation_reason: reason } }
1041
+ # the compaction state this turn was BUILT over (RFC-0044) —
1042
+ # {upto, runs}, counts only; the summary fragment itself already shows
1043
+ # as the trace's own "compaction" category via its fragment source.
1044
+ compaction = state.session.respond_to?(:compaction) ? state.session.compaction : nil
1045
+ entry[:compaction] = { upto: compaction["upto"], runs: compaction["runs"] } if compaction
1041
1046
  # Park the SANITIZED entry (string keys) — the stage-8 stamp merges into
1042
1047
  # it and re-records the same key; a raw entry would add a SECOND "cache"
1043
1048
  # key that sanitize would then ignore (the symbol one wins).
@@ -2476,6 +2481,12 @@ module Insika
2476
2481
  # next door to the other two, for the same reason: it fires for a fresh
2477
2482
  # turn and a recovered one.
2478
2483
  finalize_knowledge_extraction(task, profile, new_messages)
2484
+
2485
+ # in-session compaction (RFC-0044): when the uncompacted
2486
+ # transcript crossed the threshold this turn, summarize the old prefix
2487
+ # with the cheap model and move the boundary — off the critical path,
2488
+ # same terminal hook, same best-effort discipline as the other three.
2489
+ finalize_compaction(task, profile)
2479
2490
  end
2480
2491
 
2481
2492
  # Records the answer in the outbox and dispatches it. The discriminator is the
@@ -2607,6 +2618,59 @@ module Insika
2607
2618
  @settings_store.get["utility_model"]
2608
2619
  end
2609
2620
 
2621
+ # in-session compaction (RFC-0044). Platform-gated
2622
+ # (Settings compaction.enabled — parity when off), planned over the
2623
+ # POST-append transcript (persist_turn already wrote this turn's messages),
2624
+ # dispatched off the critical path — the SAME shape as
2625
+ # finalize_knowledge_extraction: inline when non-supervised, a child of the
2626
+ # turn supervisor when serving. Best-effort: any failure leaves the session
2627
+ # record untouched and the next turn re-plans.
2628
+ def finalize_compaction(task, profile)
2629
+ return unless task.session_id && @settings_store
2630
+
2631
+ config = Coercion.deep_stringify(@settings_store.get["compaction"])
2632
+ return unless config && Coercion.truthy?(config["enabled"])
2633
+
2634
+ session = @session_store.find(task.session_id)
2635
+ return unless session
2636
+
2637
+ state = session.respond_to?(:compaction) ? session.compaction : nil
2638
+ plan = Insika::Compaction.plan(messages: session.messages, state: state, config: config)
2639
+ return unless plan
2640
+
2641
+ # compaction.model -> platform utility_model -> inert (never a guess);
2642
+ # `insika doctor` warns on enabled-with-no-model. @llm rides along so a
2643
+ # deployment (or spec) that injects its LLM seam covers this call too.
2644
+ summarizer = Compaction::SummarizerFactory.build(config, utility_model: utility_model, llm: @llm)
2645
+ return unless summarizer
2646
+
2647
+ run = lambda { run_compaction(task, profile, session, state, plan, config, summarizer) }
2648
+ return run.call unless @supervised
2649
+
2650
+ turn_parent.async do |t|
2651
+ t.annotate("compaction:#{task.id}")
2652
+ run.call
2653
+ end
2654
+ end
2655
+
2656
+ def run_compaction(task, profile, session, state, plan, config, summarizer)
2657
+ prompt = Insika::Compaction.prompt(messages: session.messages, plan: plan,
2658
+ previous: state && state["summary"],
2659
+ base: config["prompt"])
2660
+ result = summarizer.summarize(prompt: prompt)
2661
+ updated = @session_store.set_compaction(task.session_id, summary: result[:summary],
2662
+ upto: plan.upto, model: summarizer.model)
2663
+ # counts and ids only — the summary text never enters the stream. Feeds
2664
+ # the insika.context.compacted counter (Telemetry::Recorder).
2665
+ emit(:context_compacted,
2666
+ { task_id: task.id, agent: profile.id, from: plan.from, upto: plan.upto,
2667
+ messages: plan.count, runs: updated.compaction && updated.compaction["runs"],
2668
+ model: summarizer.model, cost: result[:cost] },
2669
+ task: task)
2670
+ rescue StandardError
2671
+ nil # best-effort: compaction never re-fails an already-committed turn.
2672
+ end
2673
+
2610
2674
  # ONE supervisor fiber for the whole chain. Sequential deliver
2611
2675
  # calls, so balloon N+1 cannot overtake balloon N on the wire. Still off the
2612
2676
  # session's FIFO — the customer's next message does not wait on this turn's
@@ -50,9 +50,7 @@ module Insika
50
50
  @streak = 0
51
51
  @intervened = false # the ONE warning of this turn has been delivered
52
52
  @pending = false # detection fired; waiting for the batch boundary
53
- @expected = nil # tool calls announced by the batch in flight
54
- @seen = 0
55
- @halted = false
53
+ @batch = ToolBatch.new
56
54
  end
57
55
 
58
56
  # From ChatBuilder's before_tool_call. Raises BEFORE the call executes once
@@ -80,41 +78,21 @@ module Insika
80
78
 
81
79
  # From ChatBuilder's after_tool_result, with the RAW result — the only place
82
80
  # a Tool::Halt is still recognizable (SteerInjector's comment applies here).
83
- def tool_result(result)
84
- @halted = true if defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)
85
- end
81
+ def tool_result(result) = @batch.halt!(result)
86
82
 
87
83
  # RubyLLM after_message. An assistant message carrying tool calls OPENS a
88
84
  # batch; the Nth tool result CLOSES it — the one boundary where appending
89
- # is valid.
85
+ # is valid (ToolBatch owns that arithmetic; TurnBudget follows the same rule).
90
86
  def message_ended(message)
91
- role = field(message, :role).to_s
92
- return open_batch(message) if role == "assistant"
93
- return unless role == "tool" && @expected
94
-
95
- @seen += 1
96
- intervene! if @seen >= @expected
87
+ intervene! if @batch.closed?(message)
97
88
  end
98
89
 
99
90
  private
100
91
 
101
- def open_batch(message)
102
- calls = field(message, :tool_calls)
103
- size = calls.respond_to?(:size) ? calls.size : 0
104
- # No tool call = the model talking; the turn is ending and a pending
105
- # warning is moot — the loop resolved itself.
106
- return @expected = nil if size.zero?
107
-
108
- @expected = size
109
- @seen = 0
110
- @halted = false
111
- end
112
-
113
92
  def intervene!
114
- @expected = nil
115
93
  return unless @pending
116
94
  @pending = false
117
- return if @halted # nothing will read it (halt_when): drop, never deliver
95
+ return if @batch.halted? # nothing will read it (halt_when): drop, never deliver
118
96
 
119
97
  @intervened = true
120
98
  name, = @last
@@ -132,12 +110,5 @@ module Insika
132
110
  else value
133
111
  end
134
112
  end
135
-
136
- def field(message, name)
137
- return message.public_send(name) if message.respond_to?(name)
138
- return message[name] || message[name.to_s] if message.respond_to?(:[])
139
-
140
- nil
141
- end
142
113
  end
143
114
  end
@@ -74,6 +74,13 @@ module Insika
74
74
 
75
75
  def delete(id) = @cs.delete(SCOPE, id.to_s)
76
76
 
77
+ # The STORED records, unbuilt (string keys, straight off the JSON
78
+ # round-trip). `all` runs every record through AgentProfile.build, which
79
+ # normalizes — and repairs — what it reads, so a caller that needs to judge
80
+ # the truth ON DISK (the doctor) cannot use it: a repaired-on-read profile
81
+ # is indistinguishable there from a well-formed record.
82
+ def all_raw = @cs.all(SCOPE)
83
+
77
84
  private
78
85
 
79
86
  # Rebuilds the AgentProfile from the record (the JSON round-trip turns symbols
@@ -12,9 +12,9 @@ module Insika
12
12
  # OpenAI Responses SSE frame (or nil for events with no counterpart). Follows the
13
13
  # constitutional rule: no business logic, no store access here.
14
14
  #
15
- # Request: { model: "openclaw:<agent>", user: "<chat.id>", stream: true,
15
+ # Request: { model: "insika:<agent>", user: "<chat.id>", stream: true,
16
16
  # input: "<string with already-composed blocks>" } + header
17
- # X-Openclaw-Agent (agent fallback). The `input` enters VERBATIM as the
17
+ # X-Insika-Agent (agent fallback). The `input` enters VERBATIM as the
18
18
  # turn's message — the blocks (<memoria>/<dados_conhecidos>/directives) already come
19
19
  # composed by the consumer (the engine does not interpret them).
20
20
  module Responses
@@ -32,8 +32,8 @@ module Insika
32
32
  # gets that filtered structurally instead of by a regex on the leading tag.
33
33
  # Omitted = a customer typed it, which is what every turn meant before.
34
34
  def parse_request(body, req)
35
- agent = body[:model].to_s.sub(/\Aopenclaw:/, "")
36
- agent = req.get_header("HTTP_X_OPENCLAW_AGENT").to_s if agent.empty?
35
+ agent = body[:model].to_s.sub(/\Ainsika:/, "")
36
+ agent = req.get_header("HTTP_X_INSIKA_AGENT").to_s if agent.empty?
37
37
  raise Insika::ValidationError, "model/agent missing" if agent.strip.empty?
38
38
 
39
39
  user = body[:user].to_s