insika 0.9.0 → 1.0.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.
Files changed (257) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +237 -2
  3. data/README.md +5 -3
  4. data/bin/insika +3 -0
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +90 -7
  7. data/docs/ARCHITECTURE.md +12 -7
  8. data/docs/ARTIFACTS.md +33 -3
  9. data/docs/BENCHMARK.md +50 -7
  10. data/docs/CONTEXT.md +63 -0
  11. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  12. data/docs/DEPLOY.md +57 -11
  13. data/docs/EMBEDDING.md +10 -0
  14. data/docs/EVALS.md +33 -4
  15. data/docs/FACTS.md +8 -6
  16. data/docs/HARVEST.md +8 -3
  17. data/docs/KNOWLEDGE.md +7 -0
  18. data/docs/MEDIA.md +6 -2
  19. data/docs/OBSERVABILITY.md +143 -22
  20. data/docs/PERFORMANCE.md +110 -0
  21. data/docs/PLUGINS.md +4 -4
  22. data/docs/POLICY.md +22 -13
  23. data/docs/RELEASING.md +5 -0
  24. data/docs/ROUTER.md +7 -0
  25. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  26. data/docs/SECURITY.md +7 -0
  27. data/docs/SHARED-CONVERSATIONS.md +40 -0
  28. data/docs/SKILLS.md +5 -3
  29. data/docs/STUDIO.md +122 -0
  30. data/docs/TOOLS.md +111 -9
  31. data/docs/operate.md +3 -0
  32. data/lib/insika/agent_defaults.rb +140 -0
  33. data/lib/insika/agent_file_store.rb +12 -0
  34. data/lib/insika/agent_profile.rb +78 -8
  35. data/lib/insika/artifact_signing.rb +11 -0
  36. data/lib/insika/chat_builder.rb +79 -25
  37. data/lib/insika/checkpoint.rb +6 -1
  38. data/lib/insika/checkpoint_store.rb +59 -7
  39. data/lib/insika/commands/agent_payload.rb +3 -3
  40. data/lib/insika/commands/delete_kit.rb +26 -0
  41. data/lib/insika/commands/gate_harvest.rb +3 -0
  42. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  43. data/lib/insika/commands/run_harvest.rb +17 -1
  44. data/lib/insika/commands/seed_session.rb +11 -1
  45. data/lib/insika/commands/send_message.rb +9 -3
  46. data/lib/insika/commands/update_settings.rb +23 -1
  47. data/lib/insika/commands/write_kit.rb +27 -0
  48. data/lib/insika/compaction.rb +33 -7
  49. data/lib/insika/config_store.rb +87 -7
  50. data/lib/insika/context/priority.rb +3 -0
  51. data/lib/insika/context/provider.rb +2 -2
  52. data/lib/insika/context/providers/knowledge.rb +25 -2
  53. data/lib/insika/context/providers/memory.rb +53 -4
  54. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  55. data/lib/insika/context/providers/prompt.rb +17 -4
  56. data/lib/insika/context/providers/session.rb +39 -3
  57. data/lib/insika/context/reranker.rb +75 -0
  58. data/lib/insika/distill.rb +22 -13
  59. data/lib/insika/doctor.rb +34 -4
  60. data/lib/insika/dsl/runtime.rb +36 -4
  61. data/lib/insika/dsl/server_boot.rb +4 -12
  62. data/lib/insika/dsl.rb +13 -5
  63. data/lib/insika/edge_limiter.rb +23 -15
  64. data/lib/insika/egress_guard.rb +11 -3
  65. data/lib/insika/env_schema.rb +33 -73
  66. data/lib/insika/evals/assertions.rb +171 -16
  67. data/lib/insika/evals/golden.rb +141 -4
  68. data/lib/insika/evals/judge.rb +19 -10
  69. data/lib/insika/evals/moderator_compare.rb +47 -0
  70. data/lib/insika/evals/report.rb +61 -9
  71. data/lib/insika/evals/runner.rb +44 -4
  72. data/lib/insika/evals/simulator.rb +10 -1
  73. data/lib/insika/evals/transport.rb +200 -0
  74. data/lib/insika/event_stream.rb +63 -7
  75. data/lib/insika/evidence.rb +29 -8
  76. data/lib/insika/executor.rb +668 -141
  77. data/lib/insika/harvest.rb +26 -14
  78. data/lib/insika/harvest_engine.rb +1 -0
  79. data/lib/insika/harvest_store.rb +12 -0
  80. data/lib/insika/http_client.rb +87 -4
  81. data/lib/insika/kits.rb +54 -0
  82. data/lib/insika/knowledge.rb +34 -20
  83. data/lib/insika/llm_configurator.rb +46 -4
  84. data/lib/insika/llm_provider_store.rb +1 -3
  85. data/lib/insika/llm_trace_store.rb +54 -0
  86. data/lib/insika/mcp_client.rb +27 -63
  87. data/lib/insika/mcp_live_tool.rb +55 -9
  88. data/lib/insika/mcp_store.rb +32 -1
  89. data/lib/insika/mcp_tool_registry.rb +69 -26
  90. data/lib/insika/media.rb +38 -25
  91. data/lib/insika/memory_store.rb +4 -3
  92. data/lib/insika/memory_trim.rb +60 -0
  93. data/lib/insika/middleware.rb +12 -3
  94. data/lib/insika/model_metrics_store.rb +285 -0
  95. data/lib/insika/model_registry.rb +110 -0
  96. data/lib/insika/model_resolver.rb +1 -1
  97. data/lib/insika/model_selection.rb +14 -21
  98. data/lib/insika/model_visible.rb +17 -6
  99. data/lib/insika/onboarding.rb +1 -0
  100. data/lib/insika/outcome_store.rb +1 -1
  101. data/lib/insika/overlay_tool_registry.rb +25 -2
  102. data/lib/insika/pack_importer.rb +54 -9
  103. data/lib/insika/packaging.rb +14 -5
  104. data/lib/insika/pending_action_store.rb +45 -18
  105. data/lib/insika/plugin/loader.rb +4 -2
  106. data/lib/insika/profile_source.rb +4 -0
  107. data/lib/insika/provider_error_classifier.rb +18 -3
  108. data/lib/insika/queue_policy.rb +7 -2
  109. data/lib/insika/refinement/proposer.rb +42 -17
  110. data/lib/insika/reliability.rb +12 -3
  111. data/lib/insika/router/app.rb +14 -5
  112. data/lib/insika/router/session_key.rb +35 -5
  113. data/lib/insika/safety/config.rb +20 -3
  114. data/lib/insika/safety/corpus.rb +10 -3
  115. data/lib/insika/safety/factory.rb +39 -1
  116. data/lib/insika/safety/input_guardrail.rb +21 -1
  117. data/lib/insika/safety/judge_moderator.rb +70 -0
  118. data/lib/insika/safety/output_validator.rb +29 -5
  119. data/lib/insika/safety/prompt_echo.rb +89 -0
  120. data/lib/insika/schema_guard.rb +53 -76
  121. data/lib/insika/server/app.rb +17 -8
  122. data/lib/insika/server/responses.rb +19 -1
  123. data/lib/insika/session_actor.rb +58 -28
  124. data/lib/insika/session_store.rb +159 -10
  125. data/lib/insika/settings_store.rb +47 -1
  126. data/lib/insika/shared_conversations.rb +305 -0
  127. data/lib/insika/skill_catalog.rb +28 -2
  128. data/lib/insika/skill_store.rb +6 -0
  129. data/lib/insika/soak/runner.rb +1 -1
  130. data/lib/insika/steer_injector.rb +7 -48
  131. data/lib/insika/store.rb +29 -1
  132. data/lib/insika/stores/json_value.rb +37 -0
  133. data/lib/insika/stores/memory.rb +22 -35
  134. data/lib/insika/stores/postgres.rb +295 -0
  135. data/lib/insika/stores/sqlite.rb +257 -43
  136. data/lib/insika/stores/turn_counter.rb +25 -0
  137. data/lib/insika/studio/app.rb +1453 -161
  138. data/lib/insika/studio/assets/dist/application.css +1 -1
  139. data/lib/insika/studio/assets/dist/application.js +25 -25
  140. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  141. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  142. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  143. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  144. data/lib/insika/studio/forms.rb +171 -9
  145. data/lib/insika/studio/nav_icons.rb +3 -0
  146. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  147. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  148. data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
  149. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  150. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  151. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  152. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  153. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  154. data/lib/insika/studio/views/_agents_master.erb +8 -12
  155. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  156. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  157. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  158. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  159. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  160. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  161. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  162. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  163. data/lib/insika/studio/views/_chats_master.erb +66 -0
  164. data/lib/insika/studio/views/_customers_master.erb +37 -0
  165. data/lib/insika/studio/views/_empty.erb +15 -0
  166. data/lib/insika/studio/views/_facts_master.erb +42 -0
  167. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  168. data/lib/insika/studio/views/_identity.erb +16 -0
  169. data/lib/insika/studio/views/_kpi.erb +17 -0
  170. data/lib/insika/studio/views/_message.erb +40 -2
  171. data/lib/insika/studio/views/_model_chart.erb +82 -0
  172. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  173. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
  174. data/lib/insika/studio/views/_tool_users.erb +17 -0
  175. data/lib/insika/studio/views/_why.erb +53 -0
  176. data/lib/insika/studio/views/agent_detail.erb +17 -10
  177. data/lib/insika/studio/views/agents.erb +28 -16
  178. data/lib/insika/studio/views/approvals.erb +16 -11
  179. data/lib/insika/studio/views/artifact.erb +20 -7
  180. data/lib/insika/studio/views/artifacts.erb +19 -14
  181. data/lib/insika/studio/views/chats.erb +22 -29
  182. data/lib/insika/studio/views/customer.erb +88 -43
  183. data/lib/insika/studio/views/customers.erb +16 -26
  184. data/lib/insika/studio/views/evals.erb +49 -31
  185. data/lib/insika/studio/views/fact_detail.erb +114 -0
  186. data/lib/insika/studio/views/facts.erb +15 -116
  187. data/lib/insika/studio/views/followups.erb +63 -52
  188. data/lib/insika/studio/views/funnel.erb +104 -43
  189. data/lib/insika/studio/views/harvest.erb +85 -59
  190. data/lib/insika/studio/views/home.erb +138 -23
  191. data/lib/insika/studio/views/inbox.erb +36 -0
  192. data/lib/insika/studio/views/kits.erb +176 -0
  193. data/lib/insika/studio/views/knowledge.erb +92 -80
  194. data/lib/insika/studio/views/layout.erb +42 -11
  195. data/lib/insika/studio/views/live.erb +45 -0
  196. data/lib/insika/studio/views/mcp.erb +43 -13
  197. data/lib/insika/studio/views/models.erb +187 -0
  198. data/lib/insika/studio/views/parity.erb +38 -32
  199. data/lib/insika/studio/views/playground.erb +13 -9
  200. data/lib/insika/studio/views/refinement.erb +15 -224
  201. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  202. data/lib/insika/studio/views/session.erb +152 -117
  203. data/lib/insika/studio/views/settings.erb +86 -22
  204. data/lib/insika/studio/views/skills.erb +53 -19
  205. data/lib/insika/studio/views/system_files.erb +7 -7
  206. data/lib/insika/studio/views/task.erb +45 -15
  207. data/lib/insika/studio/views/tasks.erb +35 -18
  208. data/lib/insika/studio/views/tools.erb +62 -73
  209. data/lib/insika/task_actor.rb +4 -1
  210. data/lib/insika/task_store.rb +67 -1
  211. data/lib/insika/telemetry/pricing.rb +16 -14
  212. data/lib/insika/telemetry/recorder.rb +103 -9
  213. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  214. data/lib/insika/telemetry.rb +12 -2
  215. data/lib/insika/templates/daily-digest/README.md +13 -0
  216. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  217. data/lib/insika/testing/store_contract.rb +65 -0
  218. data/lib/insika/text_tool_call.rb +66 -0
  219. data/lib/insika/tick.rb +7 -1
  220. data/lib/insika/token_store.rb +5 -0
  221. data/lib/insika/tool_batch.rb +1 -1
  222. data/lib/insika/tool_catalog.rb +12 -2
  223. data/lib/insika/tool_definition.rb +10 -4
  224. data/lib/insika/tool_envelope.rb +165 -28
  225. data/lib/insika/tool_manifest.rb +1 -0
  226. data/lib/insika/tool_store.rb +3 -0
  227. data/lib/insika/tool_trace_store.rb +15 -8
  228. data/lib/insika/tool_usage_report.rb +2 -2
  229. data/lib/insika/tools/a2a_remote.rb +1 -1
  230. data/lib/insika/tools/agent_enum.rb +1 -1
  231. data/lib/insika/tools/confirm_pending.rb +112 -0
  232. data/lib/insika/tools/data_defined_tool.rb +23 -15
  233. data/lib/insika/tools/generate_image.rb +2 -2
  234. data/lib/insika/tools/load_knowledge.rb +7 -3
  235. data/lib/insika/tools/load_skill.rb +3 -3
  236. data/lib/insika/tools/present.rb +1 -8
  237. data/lib/insika/tools/remember.rb +7 -2
  238. data/lib/insika/tools/run_persona_eval.rb +3 -5
  239. data/lib/insika/tools/save_artifact.rb +16 -6
  240. data/lib/insika/tools/schedule_followup.rb +3 -3
  241. data/lib/insika/tools/stuck_signal.rb +3 -3
  242. data/lib/insika/tools/subagent.rb +5 -5
  243. data/lib/insika/tools/subagents.rb +3 -3
  244. data/lib/insika/tools/tool_search.rb +7 -5
  245. data/lib/insika/tools/tts.rb +2 -2
  246. data/lib/insika/tools/update_briefing.rb +4 -4
  247. data/lib/insika/turn_output.rb +2 -2
  248. data/lib/insika/turn_state.rb +18 -0
  249. data/lib/insika/turn_timing.rb +51 -14
  250. data/lib/insika/version.rb +1 -1
  251. data/lib/insika/wiring/graph.rb +81 -16
  252. data/lib/insika/workflow.rb +25 -64
  253. data/lib/insika.rb +19 -3
  254. metadata +69 -13
  255. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  256. data/lib/insika/mcp_http_client.rb +0 -67
  257. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "securerandom"
3
5
  require "time"
4
6
 
5
7
  module Insika
@@ -34,39 +36,117 @@ module Insika
34
36
 
35
37
  class UnknownScope < Insika::Error; end
36
38
 
37
- def initialize(store:)
39
+ # Rewritten on every config write. A cache sees another process's write by
40
+ # finding a different token. Outside SCOPES, so no authored scope lists it.
41
+ VERSION_SCOPE = "#{SCOPE_PREFIX}:_version"
42
+ VERSION_KEY = "token"
43
+ # The server's recheck window (seconds): how stale another worker's write may
44
+ # be seen. Embedders keep 0 (no cache) unless they opt in.
45
+ RECHECK = 1.0
46
+ # Fiber-storage flag a running turn sets. The cache serves only turns: Studio
47
+ # requests and pack imports read a record, change it and write it back, and
48
+ # that read must see another worker's write that landed a moment ago.
49
+ TURN_KEY = :insika_in_turn
50
+
51
+ # recheck: 0 = no cache, every read goes to the store (the default).
52
+ # recheck > 0 = reads made inside a turn are served from memory; the version token is re-read
53
+ # at most once per `recheck` seconds and a changed token drops the cache.
54
+ def initialize(store:, recheck: 0, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
38
55
  @store = store
56
+ @recheck = recheck
57
+ @clock = clock
58
+ @cache = {}
59
+ @token = nil
60
+ @checked_at = nil
61
+ @gen = 0
62
+ @lock = Mutex.new
39
63
  end
40
64
 
65
+ attr_reader :recheck
66
+
67
+ # The token every config write bumps. Another process reads it to learn that
68
+ # something changed since it last looked (one small store read, never cached).
69
+ def version_token = @store.get(VERSION_SCOPE, VERSION_KEY)
70
+
41
71
  # Upsert (last-write-wins). -> value (the same Hash that was passed)
42
72
  def put(scope, key, value)
43
- @store.set(ns(scope), key.to_s, stringify(value))
73
+ s = ns(scope)
74
+ @store.transaction do
75
+ @store.set(s, key.to_s, stringify(value))
76
+ bump_version
77
+ end
78
+ forget
44
79
  value
45
80
  end
46
81
 
47
82
  # -> Hash | nil
48
83
  def get(scope, key)
49
- @store.get(ns(scope), key.to_s)
84
+ s = ns(scope)
85
+ cached([:get, s, key.to_s]) { @store.get(s, key.to_s) }
50
86
  end
51
87
 
52
88
  # -> bool (did it exist?)
53
89
  def delete(scope, key)
54
- @store.delete(ns(scope), key.to_s)
90
+ s = ns(scope)
91
+ existed = @store.transaction do
92
+ @store.delete(s, key.to_s).tap { |gone| bump_version if gone }
93
+ end
94
+ forget
95
+ existed
55
96
  end
56
97
 
57
98
  # -> [String] keys of the scope, sorted lexicographically
58
99
  def keys(scope)
59
- @store.list(ns(scope))
100
+ s = ns(scope)
101
+ cached([:keys, s]) { @store.list(s) }
60
102
  end
61
103
 
62
- # -> [Hash] all records of the scope (lexicographic key order)
104
+ # -> [Hash] all records of the scope (lexicographic key order), one store call
63
105
  def all(scope)
64
106
  s = ns(scope)
65
- @store.list(s).filter_map { |k| @store.get(s, k) }
107
+ cached([:all, s]) { @store.entries(s).map(&:last) }
66
108
  end
67
109
 
68
110
  private
69
111
 
112
+ # The cache keeps JSON text, not objects: every hit parses a fresh copy, so a
113
+ # caller that mutates what it got never changes what the next caller reads.
114
+ def cached(entry)
115
+ return yield unless @recheck.positive? && Fiber[TURN_KEY]
116
+
117
+ revalidate
118
+ gen, raw = @lock.synchronize { [@gen, @cache[entry]] }
119
+ return JSON.parse(raw) if raw
120
+
121
+ value = yield
122
+ # A drop while the read was in flight means the value may predate it.
123
+ @lock.synchronize { @cache[entry] = JSON.generate(value) if gen == @gen }
124
+ value
125
+ end
126
+
127
+ def revalidate
128
+ now = @clock.call
129
+ return if @checked_at && now - @checked_at < @recheck
130
+
131
+ token = @store.get(VERSION_SCOPE, VERSION_KEY)
132
+ @lock.synchronize do
133
+ drop unless token == @token
134
+ @token = token
135
+ @checked_at = now
136
+ end
137
+ end
138
+
139
+ def bump_version = @store.set(VERSION_SCOPE, VERSION_KEY, SecureRandom.hex(8))
140
+
141
+ # Our own write: drop everything and re-read the token on the next read.
142
+ def forget = @lock.synchronize { drop; @checked_at = nil }
143
+
144
+ # Callers hold @lock. The generation tells an in-flight read its value is stale.
145
+ def drop
146
+ @cache.clear
147
+ @gen += 1
148
+ end
149
+
70
150
  def ns(scope)
71
151
  key = scope.to_s
72
152
  raise UnknownScope, "unknown config scope: #{scope.inspect}" unless SCOPES.include?(key)
@@ -19,6 +19,9 @@ module Insika
19
19
  IDENTITY = 100 # IDENTITY/SOUL (Prompt) — pinned
20
20
  FENCE_NOTICE = 99 # the one-sentence fencing notice (FenceNotice) —
21
21
  # right under the identity, byte-stable, above the boundary
22
+ PENDING_CONFIRMATION = 96 # the call held for the customer's confirmation
23
+ # (PendingConfirmation) — tail, pinned: it is the
24
+ # question the current message is answering
22
25
  RECITATION = 95 # <recitation> the goal restated at the TAIL of context
23
26
  # (Briefing). Never cut: it is two lines, and the one
24
27
  # turn it gets evicted is the long turn that needed it.
@@ -29,9 +29,9 @@ module Insika
29
29
  # fall back to tenant || session). Kept separate from `tenant`
30
30
  # (the <request_context> merchant label) on purpose.
31
31
  ContextRequest = Data.define(:session, :message, :profile, :tenant, :vars,
32
- :checkpoint, :memory_scope) do
32
+ :checkpoint, :memory_scope, :diagnostics) do
33
33
  def initialize(session: nil, message: nil, profile: nil, tenant: nil, vars: {},
34
- checkpoint: nil, memory_scope: nil)
34
+ checkpoint: nil, memory_scope: nil, diagnostics: nil)
35
35
  super
36
36
  end
37
37
  end
@@ -9,8 +9,9 @@ module Insika
9
9
  # `SkillTrigger`, unlike the static `CatalogProvider` subclasses — this
10
10
  # builds its `<knowledge>` block directly in `call`, never a fixed list.
11
11
  class Knowledge < ContextProvider
12
- def initialize(store:)
12
+ def initialize(store:, llm: nil)
13
13
  @store = store
14
+ @reranker = Reranker.new(llm: llm) if llm
14
15
  # One Index PER TYPE, built once and reused for every agent/turn —
15
16
  # never per call. This provider instance itself lives for the
16
17
  # process's lifetime (built once at boot, see wiring), so an
@@ -28,15 +29,37 @@ module Insika
28
29
  # required? == false (default): a store failure degrades to a
29
30
  # :provider_warning, never aborts the turn.
30
31
  def call(request)
32
+ if request.vars&.key?('shared_memory')
33
+ records = request.vars['shared_memory'].fetch('knowledge', [])
34
+ hits = records.map { [{name:_1['id'],description:_1['excerpt'],confidence:0.6,provenance:_1['origin'] == 'operator' ? 'curated' : 'observed'}, 'shared'] }
35
+ return [] if hits.empty?
36
+ return [ContextFragment.build(content:format_block(hits,true),placement: :system,priority:Context::Priority::KNOWLEDGE,source:id)]
37
+ end
31
38
  config = request.profile.knowledge
32
39
  return [] unless config
33
40
 
34
41
  top_k = positive_int(config["top_k"]) || 5
35
42
  index = @indexes[config["index"].to_s]
43
+ rerank = config["rerank"]
44
+ candidate_limit = rerank ? rerank.fetch("candidate_limit") : top_k
36
45
  matches = index.search(request.profile.id, tenant: request.tenant,
37
- query: request.message.to_s, top_k: top_k)
46
+ query: request.message.to_s, top_k: candidate_limit)
38
47
  return [] if matches.empty?
39
48
 
49
+ if rerank && @reranker
50
+ # Leave room inside the Builder's outer provider timeout to return
51
+ # lexical results when the reranker times out.
52
+ timeout = [rerank.fetch("timeout_seconds"),
53
+ (request.profile.limits[:provider_timeout] || 5) * 0.9].min
54
+ documents = matches.map { |c| [c[:name], c[:description], c[:body]].join("\n") }
55
+ indexes = @reranker.select(query: request.message.to_s, documents: documents,
56
+ config: rerank.merge("timeout_seconds" => timeout), top_k: top_k,
57
+ emit: request.diagnostics, budget: request.profile.limits[:context_budget] || 8_000)
58
+ matches = indexes ? indexes.map { |i| matches[i] } : matches.first(top_k)
59
+ else
60
+ matches = matches.first(top_k)
61
+ end
62
+
40
63
  hits = matches.map { |c| [c, "top-K match"] } +
41
64
  expand_links(matches, request, top_k).map { |c| [c, "one-hop link"] }
42
65
 
@@ -5,12 +5,12 @@ module Insika
5
5
  module Providers
6
6
  # Read path for cross-session memory. Thin adapter over
7
7
  # the MemoryStore — same pattern as the Skill/ToolSearch provider: one
8
- # `:system` fragment with the tenant's facts + recent notes. Deterministic
9
- # (no embeddings/ranking).
8
+ # `:system` fragment with the tenant's facts + recent notes.
10
9
  class Memory < ContextProvider
11
- def initialize(store:, notes_limit: 10)
10
+ def initialize(store:, notes_limit: 10, llm: nil)
12
11
  @store = store
13
12
  @notes_limit = notes_limit
13
+ @reranker = Reranker.new(llm: llm) if llm
14
14
  end
15
15
 
16
16
  # Per-agent opt-in. The Builder still applies the `context_providers`
@@ -20,11 +20,28 @@ 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
+ if request.vars&.key?('shared_memory')
24
+ records = request.vars['shared_memory'].fetch('facts', [])
25
+ facts = records.reject { _1['kind'] == 'note' }.map { MemoryStore::Fact.new(key:_1['id'],value:_1['value'].to_s,origin:_1['origin'],created_at:_1['created_at'],updated_at:_1['updated_at'],expires_at:_1['expires_at']) }
26
+ notes = records.select { _1['kind'] == 'note' }.map { MemoryStore::Note.new(id:_1['id'],text:_1['value'].to_s,created_at:_1['created_at']) }
27
+ return [] if records.empty?
28
+ return [ContextFragment.build(content:format_block(facts,notes,true),placement: :system,priority:Context::Priority::MEMORY,source:id)]
29
+ end
23
30
  tenant = memory_scope(request)
24
31
  facts = @store.facts(tenant: tenant)
25
- notes = @store.notes(tenant: tenant, limit: @notes_limit)
32
+ config = request.profile.memory_retrieval
33
+ limit = config ? [@notes_limit, config.dig("rerank", "candidate_limit")].max : @notes_limit
34
+ notes = @store.notes(tenant: tenant, limit: limit)
26
35
  return [] if facts.empty? && notes.empty?
27
36
 
37
+ legacy_notes = notes.first(@notes_limit)
38
+ if config && @reranker && !request.message.to_s.strip.empty?
39
+ selected = select_memory(request, facts, notes.first(config.dig("rerank", "candidate_limit")), config)
40
+ facts, notes = selected || [facts, legacy_notes]
41
+ else
42
+ notes = legacy_notes
43
+ end
44
+
28
45
  # priority MEMORY (75): between skills (80) and deferred tools (70) in
29
46
  # the sacrifice order. pinned false (cuttable under a tight budget).
30
47
  [ContextFragment.build(content: format_block(facts, notes, Insika::Fence.enabled?(request.profile)),
@@ -33,6 +50,38 @@ module Insika
33
50
 
34
51
  private
35
52
 
53
+ def select_memory(request, facts, notes, config)
54
+ query = request.message.to_s
55
+ terms = query.downcase.scan(/[[:alnum:]]+/).uniq
56
+ candidates = facts.map do |fact|
57
+ { kind: :fact, record: fact, id: "fact:#{fact.key}",
58
+ text: "#{fact.key} #{fact.value}", at: fact.updated_at.to_s }
59
+ end + notes.map do |note|
60
+ { kind: :note, record: note, id: "note:#{note.id}",
61
+ text: note.text.to_s, at: note.created_at.to_s }
62
+ end
63
+ candidates.sort_by! do |item|
64
+ words = item[:text].downcase.scan(/[[:alnum:]]+/).uniq
65
+ [-terms.count { |term| words.include?(term) }, -Time.iso8601(item[:at]).to_f, item[:id]]
66
+ rescue ArgumentError
67
+ [-terms.count { |term| words.include?(term) }, 0, item[:id]]
68
+ end
69
+ candidates = candidates.first(config.dig("rerank", "candidate_limit"))
70
+ rerank = config.fetch("rerank")
71
+ timeout = [rerank.fetch("timeout_seconds"),
72
+ (request.profile.limits[:provider_timeout] || 5) * 0.9].min
73
+ documents = candidates.map { |item| item[:text] }
74
+ indexes = @reranker.select(query: query, documents: documents,
75
+ config: rerank.merge("timeout_seconds" => timeout),
76
+ top_k: config.fetch("top_k"), emit: request.diagnostics,
77
+ source: "Memory", budget: request.profile.limits[:context_budget] || 8_000)
78
+ return nil unless indexes
79
+
80
+ selected = indexes.map { |index| candidates[index] }
81
+ [selected.filter_map { |item| item[:record] if item[:kind] == :fact },
82
+ selected.filter_map { |item| item[:record] if item[:kind] == :note }]
83
+ end
84
+
36
85
  # Engine memory scope (WS8): the request's CUSTOMER-scoped cell
37
86
  # ("[tenant:]customer" — engine-owner memory is per customer, never per
38
87
  # tenant) wins; otherwise an EXPLICIT tenant from the Command (the
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Insika
6
+ module Context
7
+ module Providers
8
+ # The read path of a customer confirmation: the calls the envelope held on
9
+ # an earlier turn of this session, rendered at the TAIL of the context —
10
+ # right before the message that answers them — so the last thing the model
11
+ # reads before replying is the question the customer is replying to. Zero
12
+ # bytes when nothing is open, which is every turn of most conversations.
13
+ class PendingConfirmation < ContextProvider
14
+ def initialize(pending_action_store:)
15
+ @store = pending_action_store
16
+ end
17
+
18
+ def id = "pending_confirmation"
19
+
20
+ # Pack-declared: no customer_confirm -> no provider.
21
+ def enabled_for?(profile)
22
+ list = profile.respond_to?(:customer_confirm) ? profile.customer_confirm : nil
23
+ !Array(list).empty?
24
+ end
25
+
26
+ def allowlisted? = false # `customer_confirm` is the opt-in, not the allowlist
27
+
28
+ def call(request)
29
+ session = request.respond_to?(:session) ? request.session : nil
30
+ return [] if session.nil? || @store.nil?
31
+
32
+ open = @store.open_for_session(session.id, kind: PendingActionStore::CUSTOMER)
33
+ return [] if open.empty?
34
+
35
+ # A tail fragment IS a history message ({role:, content:}), like the
36
+ # Briefing's recitation — the Builder appends it after the transcript.
37
+ [ContextFragment.build(content: { role: :user, content: render(open) }, placement: :tail,
38
+ pinned: true, priority: Context::Priority::PENDING_CONFIRMATION, source: id)]
39
+ end
40
+
41
+ private
42
+
43
+ def render(open)
44
+ lines = open.map { |pa| "- #{pa.tool} #{JSON.generate(pa.args || {})} — pending_id #{pa.id}" }
45
+ "## Awaiting the customer's confirmation\n#{lines.join("\n")}\n" \
46
+ "Call confirm_pending only if the customer's message agrees to exactly this. " \
47
+ "Anything else — a change, a question, a new request — is cancel_pending."
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -33,6 +33,9 @@ module Insika
33
33
  "the user you found nothing. Do not narrate the retries. Then conclude.\n" \
34
34
  "- Tool error: read the error, fix the arguments or try another path; never " \
35
35
  "repeat the exact same call.\n" \
36
+ "- Report an action as done only after its tool call returned success. Never " \
37
+ "say it happened before the call, and never for a call that failed, was " \
38
+ "blocked, or is still awaiting confirmation.\n" \
36
39
  "- A URL in a tool result (e.g. a `url` field): quote it byte-for-byte. Never " \
37
40
  "construct, guess, or rewrite the domain, host, or path."
38
41
 
@@ -83,7 +86,9 @@ module Insika
83
86
  # NO identity at all, and a chatty model answered plausibly enough to hide
84
87
  # it. Additive to prompt_files, not exclusive: an agent may carry both.
85
88
  parts << profile&.base_prompt.to_s
86
- Array(profile&.prompt_files).each { |src| parts << read_source(profile&.id, src.to_s) }
89
+ sources = Array(profile&.prompt_files)
90
+ stored = stored_files(profile, sources)
91
+ sources.each { |src| parts << read_source(stored, src.to_s) }
87
92
  identity = parts.reject { |p| p.nil? || p.strip.empty? }.join("\n\n")
88
93
  # Discipline rides an EXISTING identity, never substitutes one: an
89
94
  # empty identity here is fatal (`call` raises), not silently patched
@@ -109,10 +114,18 @@ module Insika
109
114
  @system_files.list.map { |name| @system_files.read(name).to_s }
110
115
  end
111
116
 
117
+ # The agent's files from ONE store read (`contents`); an agent_files object
118
+ # that only answers `read(agent_id, name)` is read per file, as before.
119
+ def stored_files(profile, sources)
120
+ return {} if sources.empty? || profile.id.nil? || @agent_files.nil?
121
+ return @agent_files.contents(profile.id) if @agent_files.respond_to?(:contents)
122
+
123
+ sources.to_h { |src| [src.to_s, @agent_files.read(profile.id, src.to_s)] }.compact
124
+ end
125
+
112
126
  # Per-agent store first, disk second (compat/seed).
113
- def read_source(agent_id, src)
114
- stored = agent_id && @agent_files&.read(agent_id, src)
115
- return stored if stored
127
+ def read_source(stored, src)
128
+ return stored[src] if stored[src]
116
129
  return File.read(src, encoding: "UTF-8") if File.exist?(src)
117
130
 
118
131
  ""
@@ -71,7 +71,11 @@ module Insika
71
71
  # Symbol-keyed message preserving tool_calls / tool_call_id when present
72
72
  # (a plain message keeps just {role, content} — parity with the old shape).
73
73
  def normalize(msg)
74
+ return msg if msg["native_compaction"]
75
+
74
76
  h = { role: msg[:role] || msg["role"], content: msg[:content] || msg["content"] }
77
+ attachments = msg[:attachments] || msg["attachments"]
78
+ h[:attachments] = attachments if attachments && !attachments.empty?
75
79
  tool_calls = msg[:tool_calls] || msg["tool_calls"]
76
80
  tool_call_id = msg[:tool_call_id] || msg["tool_call_id"]
77
81
  h[:tool_calls] = tool_calls if tool_calls
@@ -114,12 +118,44 @@ module Insika
114
118
  fresh = fresh_session(session)
115
119
  return [[], nil] if fresh.nil?
116
120
 
117
- messages = fresh.messages || []
121
+ # Operator corrections affect replay only, never the stored content or tool protocol.
122
+ messages = (fresh.messages || []).map do |message|
123
+ if message["role"] == "assistant" && Array(message["tool_calls"]).empty? && message["context_content"].is_a?(String)
124
+ message.merge("content" => message["context_content"])
125
+ else
126
+ message
127
+ end
128
+ end
118
129
  state = fresh.respond_to?(:compaction) ? fresh.compaction : nil
119
130
  upto = state ? state["upto"].to_i : 0
120
- return [messages, nil] unless upto.positive? && Coercion.present?(state["summary"])
131
+ if upto.positive? && state["native"]
132
+ # ponytail: budget the full fallback too; model-aware allocation can
133
+ # recover this headroom once model selection precedes context building.
134
+ prefix = { "native_compaction" => state["native"], "messages" => without_no_reply(messages.take(upto)) }
135
+ return [[prefix] + without_no_reply(messages.drop(upto)), nil]
136
+ end
137
+ return [without_no_reply(messages), nil] unless upto.positive? && Coercion.present?(state["summary"])
138
+
139
+ [without_no_reply(messages.drop([upto, messages.size].min)), state]
140
+ end
121
141
 
122
- [messages.drop([upto, messages.size].min), state]
142
+ # `NO_REPLY` is the OpenClaw gateway's silence sentinel. Conversations migrated
143
+ # from it carry it as agent replies, and the compaction summary restates the
144
+ # rule ("call_support → responder NO_REPLY"); the Insika prompts forbid the
145
+ # word (silence is an empty answer here), so replaying it only teaches the
146
+ # model to write it (Loja Exemplo and Supermercado Exemplo, 23/10/2026).
147
+ # Replay-only, like the operator corrections above: the stored transcript is
148
+ # untouched. Applied after the compaction cut so `upto` keeps indexing the
149
+ # stored list. A message left empty is dropped — the customer never saw it.
150
+ def without_no_reply(messages)
151
+ messages.filter_map do |message|
152
+ content = message["content"]
153
+ next message unless message["role"] == "assistant" && Array(message["tool_calls"]).empty? &&
154
+ content.is_a?(String) && content.include?("NO_REPLY")
155
+
156
+ kept = content.lines.grep_v(/NO_REPLY/).join.strip
157
+ message.merge("content" => kept) unless kept.empty?
158
+ end
123
159
  end
124
160
 
125
161
  # The compacted prefix as ONE history fragment, rendered FIRST (the
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Context
5
+ # Optional second pass over already scoped retrieval candidates.
6
+ class Reranker
7
+ MAX_INPUT_TOKENS = 4_000
8
+
9
+ def initialize(llm:)
10
+ @llm = llm
11
+ end
12
+
13
+ # Returns indexes into documents, or nil so the caller keeps lexical order.
14
+ def select(query:, documents:, config:, top_k:, emit: nil, source: "Knowledge", budget: MAX_INPUT_TOKENS)
15
+ return nil if documents.empty?
16
+
17
+ # The same estimator used by the context budget bounds the provider input.
18
+ tokens = [budget.to_i, MAX_INPUT_TOKENS].min
19
+ query_tokens = [tokens / 4, 1_000].min
20
+ per_document = [(tokens - query_tokens) / documents.length, 400].min
21
+ return nil if per_document < 1
22
+
23
+ bounded = documents.map { |text| trim(Insika::Safety::Detectors.redact(text).first, per_document) }
24
+ bounded_query = trim(Insika::Safety::Detectors.redact(query).first, query_tokens)
25
+ llm = operation_context(emit, config)
26
+ result = Async::Task.current.with_timeout(config.fetch("timeout_seconds")) do
27
+ llm.rerank(bounded_query, bounded, provider: config.fetch("provider"),
28
+ model: config.fetch("model"), top_n: top_k)
29
+ end
30
+ indexes = result.results.map(&:index)
31
+ unless indexes.length == [top_k, documents.length].min &&
32
+ indexes.all? { |index| index.is_a?(Integer) && index >= 0 && index < documents.length } &&
33
+ indexes.uniq.length == indexes.length
34
+ safe_emit(emit, :provider_warning, { provider: source, message: "invalid rerank indexes" })
35
+ return nil
36
+ end
37
+ safe_emit(emit, :retrieval_reranked,
38
+ { provider: source, candidate_count: documents.length, selected_count: indexes.length })
39
+ indexes
40
+ rescue StandardError => error
41
+ # Provider messages may quote the query or a document. Only the class is safe.
42
+ safe_emit(emit, :provider_warning, { provider: source, message: "rerank failed: #{error.class.name}" })
43
+ nil
44
+ end
45
+
46
+ private
47
+
48
+ def trim(value, limit)
49
+ text = value.to_s[0, limit * 4]
50
+ text = text[0...-1] while TokenEstimator.estimate(text) > limit
51
+ text
52
+ end
53
+
54
+ def safe_emit(emit, type, data)
55
+ emit&.call(type, data)
56
+ rescue StandardError
57
+ nil
58
+ end
59
+
60
+ def operation_context(emit, config)
61
+ source = @llm.respond_to?(:call) ? @llm.call : @llm
62
+ return source unless emit && source.respond_to?(:config)
63
+
64
+ require "ruby_llm"
65
+ require_relative "../telemetry/ruby_llm_instrumenter"
66
+ copy = source.config.dup
67
+ copy.instrumenter = Insika::Telemetry::RubyLLMInstrumenter.new(
68
+ delegate: copy.instrumenter, operation: "rerank", model: config.fetch("model"),
69
+ emit: ->(type, data) { safe_emit(emit, type, data) }
70
+ )
71
+ RubyLLM::Context.new(copy)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -73,7 +73,7 @@ module Insika
73
73
  DROP_KEYS = %w[schema unknown_key oversized bad_turns duplicate capped].freeze
74
74
 
75
75
  # ask: ->(prompt) { "<raw model text>" } | something answering #content
76
- # (+ #input_tokens/#output_tokens/#cached_tokens for cost).
76
+ # (+ RubyLLM-compatible #tokens for cost).
77
77
  # model: the ref recorded in the events ("utility_model" default).
78
78
  attr_reader :model
79
79
 
@@ -93,7 +93,7 @@ module Insika
93
93
  # Drops are counted, never fixed up.
94
94
  def distill(prompt:, message_count:, max_proposals: 10)
95
95
  answer = @ask.call(prompt)
96
- raw = parse(text_of(answer))
96
+ raw = parse(answer)
97
97
  proposals = []
98
98
  dropped = DROP_KEYS.to_h { |k| [k, 0] }
99
99
  seen = {}
@@ -124,20 +124,25 @@ module Insika
124
124
  # nil when the provider said nothing — never 0 (the Proposer's
125
125
  # discipline). The cached prefix is INCLUDED in the spent total.
126
126
  def cost_of(answer)
127
- return nil unless answer.respond_to?(:input_tokens) && answer.respond_to?(:output_tokens)
127
+ return nil unless answer.respond_to?(:tokens)
128
128
 
129
- input = answer.input_tokens.to_i
130
- output = answer.output_tokens.to_i
131
- cached = answer.respond_to?(:cached_tokens) ? answer.cached_tokens.to_i : 0
132
- spent = input + output + cached
129
+ usage = answer.tokens
130
+ input = usage.input.to_i
131
+ output = usage.output.to_i
132
+ cached = usage.cache_read.to_i
133
+ spent = input + output + cached + usage.cache_write.to_i
133
134
  spent.positive? ? { "spent" => spent, "cached" => cached } : nil
134
135
  end
135
136
 
136
- # Fences are stripped, the payload parsed STRICTLY: a model that
137
- # improvises a schema fails here instead of producing half a proposal.
138
- def parse(raw)
139
- body = raw.strip.gsub(/\A```(?:json)?\s*|\s*```\z/, "")
140
- parsed = JSON.parse(body)
137
+ # Native responses own JSON parsing; injected text asks retain their
138
+ # existing fenced-JSON contract. Domain validation still runs below.
139
+ def parse(answer)
140
+ parsed = if answer.respond_to?(:parsed)
141
+ answer.parsed
142
+ else
143
+ JSON.parse(text_of(answer).strip.gsub(/\A```(?:json)?\s*|\s*```\z/, ""))
144
+ end
145
+ parsed = parsed["items"] if parsed.is_a?(Hash)
141
146
  raise Unusable, "the distiller's answer is not an array" unless parsed.is_a?(Array)
142
147
 
143
148
  parsed
@@ -215,8 +220,12 @@ module Insika
215
220
  require "ruby_llm"
216
221
  llm ||= RubyLLM
217
222
  lambda do |prompt|
223
+ # DeepSeek's native adapter uses JSON-object mode, requiring an envelope.
224
+ schema = { "type" => "object", "properties" => { "items" => PROPOSAL_SCHEMA.json_schema },
225
+ "required" => ["items"], "additionalProperties" => false }
218
226
  llm.chat(model: model, provider: provider, assume_model_exists: true)
219
- .with_temperature(0).ask(prompt)
227
+ .with_temperature(0).with_schema("schema" => schema, "strict" => false)
228
+ .ask("#{prompt}\n\nReturn the requested array inside a JSON object with the key \"items\".")
220
229
  end
221
230
  end
222
231
  end
data/lib/insika/doctor.rb CHANGED
@@ -181,7 +181,7 @@ module Insika
181
181
  check_memory_scopes check_funnel_declarations check_followup check_distill
182
182
  check_compaction check_harvest check_schedules check_guardrail_corpora
183
183
  check_tool_allowlist_policy check_fencing check_eval_seeding
184
- check_presentation_tools check_provenance]
184
+ check_presentation_tools check_provenance check_customer_confirm]
185
185
 
186
186
  def safe(check)
187
187
  Array(send(check))
@@ -316,16 +316,18 @@ module Insika
316
316
  [Finding.new(check: "default-model", severity: :warn, message: msg, fix: fix)]
317
317
  end
318
318
 
319
+ def backend_name = @backend.class.name.split("::").last
320
+
319
321
  def check_db
320
- unless @backend.is_a?(Insika::Stores::SQLite)
322
+ unless Insika::Stores.durable?(@backend)
321
323
  return [Finding.new(check: "db", severity: :info,
322
324
  message: "ephemeral backend (INSIKA_DB unset) — config and state do NOT survive a restart", fix: nil)]
323
325
  end
324
326
 
325
327
  @backend.get("__doctor__", "probe") # a read round-trips the handle; raises if the file is broken
326
- [ok("db", "durable backend: SQLite (readable)")]
328
+ [ok("db", "durable backend: #{backend_name} (readable)")]
327
329
  rescue StandardError => e
328
- [Finding.new(check: "db", severity: :error, message: "SQLite backend not usable: #{e.class}: #{e.message}", fix: nil)]
330
+ [Finding.new(check: "db", severity: :error, message: "#{backend_name} backend not usable: #{e.class}: #{e.message}", fix: nil)]
329
331
  end
330
332
 
331
333
  def check_llm_provider
@@ -1009,6 +1011,31 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
1009
1011
  end
1010
1012
  end
1011
1013
 
1014
+ # `customer_confirm` names tools; one the agent denies, or leaves out of an
1015
+ # explicit allowlist, is a hold that can never happen. An allowlist carrying an
1016
+ # `mcp:` entry is not explicit about tool NAMES (the server contributes them),
1017
+ # so it is not judged.
1018
+ def check_customer_confirm
1019
+ return [] unless @profile_source
1020
+
1021
+ findings = @profile_source.all.flat_map do |profile|
1022
+ names = profile.respond_to?(:customer_confirm) ? Array(profile.customer_confirm) : []
1023
+ next [] if names.empty?
1024
+
1025
+ allow = profile.tools_allow
1026
+ explicit = !allow.nil? && Array(allow).none? { |a| a.to_s.start_with?("mcp:") }
1027
+ dead = names.select do |name|
1028
+ Array(profile.tools_deny).include?(name) || (explicit && !Array(allow).include?(name))
1029
+ end
1030
+ next [] if dead.empty?
1031
+
1032
+ [Finding.new(check: "customer_confirm", severity: :warn, fix: nil,
1033
+ message: "agent '#{profile.id}' lists #{dead.join(', ')} under customer_confirm but does " \
1034
+ "not allow the tool — the hold can never happen")]
1035
+ end
1036
+ findings.empty? ? [ok("customer_confirm", "customer_confirm: every held tool is allowed")] : findings
1037
+ end
1038
+
1012
1039
  def check_grounding
1013
1040
  return [] unless @profile_source
1014
1041
 
@@ -1381,6 +1408,9 @@ def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
1381
1408
  settings = @settings_store.get
1382
1409
  config = settings["compaction"] || {}
1383
1410
  return [ok("compaction", "in-session compaction off")] unless Coercion.truthy?(config["enabled"])
1411
+ if config["mode"] == "native"
1412
+ return [ok("compaction", "native compaction on for supported Responses models; no summary fallback")]
1413
+ end
1384
1414
 
1385
1415
  if Coercion.presence(config["model"]).nil? && Coercion.presence(settings["utility_model"]).nil?
1386
1416
  [Finding.new(check: "compaction", severity: :warn, fix: nil,