insika 0.8.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 (276) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +312 -2
  3. data/README.md +10 -6
  4. data/bin/insika +4 -1
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +142 -18
  7. data/docs/API.md +73 -0
  8. data/docs/ARCHITECTURE.md +56 -50
  9. data/docs/ARTIFACTS.md +33 -3
  10. data/docs/BENCHMARK.md +50 -7
  11. data/docs/CHANNELS.md +19 -2
  12. data/docs/CONTEXT.md +96 -27
  13. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  14. data/docs/DEPLOY.md +70 -13
  15. data/docs/EMBEDDING.md +10 -0
  16. data/docs/EVALS.md +129 -10
  17. data/docs/FACTS.md +12 -6
  18. data/docs/HARVEST.md +8 -3
  19. data/docs/KNOWLEDGE.md +14 -0
  20. data/docs/MEDIA.md +6 -2
  21. data/docs/OBSERVABILITY.md +164 -28
  22. data/docs/PERFORMANCE.md +110 -0
  23. data/docs/PLUGINS.md +4 -4
  24. data/docs/POLICY.md +26 -14
  25. data/docs/RELEASING.md +9 -0
  26. data/docs/ROUTER.md +7 -0
  27. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  28. data/docs/SECURITY.md +34 -1
  29. data/docs/SHARED-CONVERSATIONS.md +40 -0
  30. data/docs/SKILLS.md +5 -3
  31. data/docs/STUDIO.md +122 -0
  32. data/docs/TOOLS.md +238 -42
  33. data/docs/operate.md +3 -0
  34. data/docs/prompts/ADD-TOOL.md +12 -2
  35. data/docs/prompts/DIAGNOSE-TURN.md +3 -0
  36. data/docs/prompts/GO-LIVE.md +3 -1
  37. data/lib/insika/agent_defaults.rb +140 -0
  38. data/lib/insika/agent_file_store.rb +12 -0
  39. data/lib/insika/agent_profile.rb +99 -17
  40. data/lib/insika/artifact_signing.rb +11 -0
  41. data/lib/insika/channels/web/widget.js +33 -0
  42. data/lib/insika/channels/web.rb +5 -2
  43. data/lib/insika/chat_builder.rb +124 -28
  44. data/lib/insika/checkpoint.rb +6 -1
  45. data/lib/insika/checkpoint_store.rb +59 -7
  46. data/lib/insika/commands/agent_payload.rb +3 -3
  47. data/lib/insika/commands/delete_kit.rb +26 -0
  48. data/lib/insika/commands/gate_harvest.rb +3 -0
  49. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  50. data/lib/insika/commands/run_distillation.rb +5 -8
  51. data/lib/insika/commands/run_harvest.rb +17 -1
  52. data/lib/insika/commands/seed_session.rb +128 -0
  53. data/lib/insika/commands/send_message.rb +9 -3
  54. data/lib/insika/commands/update_settings.rb +23 -1
  55. data/lib/insika/commands/write_kit.rb +27 -0
  56. data/lib/insika/compaction.rb +33 -7
  57. data/lib/insika/config_store.rb +87 -7
  58. data/lib/insika/context/builder.rb +29 -9
  59. data/lib/insika/context/priority.rb +5 -0
  60. data/lib/insika/context/provider.rb +7 -2
  61. data/lib/insika/context/providers/briefing.rb +11 -8
  62. data/lib/insika/context/providers/fence_notice.rb +27 -0
  63. data/lib/insika/context/providers/knowledge.rb +32 -6
  64. data/lib/insika/context/providers/memory.rb +61 -8
  65. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  66. data/lib/insika/context/providers/prompt.rb +17 -4
  67. data/lib/insika/context/providers/session.rb +46 -6
  68. data/lib/insika/context/reranker.rb +75 -0
  69. data/lib/insika/distill.rb +22 -13
  70. data/lib/insika/doctor.rb +142 -4
  71. data/lib/insika/dsl/runtime.rb +37 -4
  72. data/lib/insika/dsl/server_boot.rb +4 -12
  73. data/lib/insika/dsl.rb +19 -5
  74. data/lib/insika/edge_limiter.rb +27 -16
  75. data/lib/insika/egress_guard.rb +11 -3
  76. data/lib/insika/env_schema.rb +33 -73
  77. data/lib/insika/errors.rb +1 -0
  78. data/lib/insika/evals/assertions.rb +259 -18
  79. data/lib/insika/evals/golden.rb +230 -4
  80. data/lib/insika/evals/judge.rb +19 -10
  81. data/lib/insika/evals/moderator_compare.rb +47 -0
  82. data/lib/insika/evals/report.rb +61 -9
  83. data/lib/insika/evals/runner.rb +64 -4
  84. data/lib/insika/evals/simulator.rb +21 -3
  85. data/lib/insika/evals/transport.rb +317 -15
  86. data/lib/insika/event_stream.rb +63 -7
  87. data/lib/insika/evidence.rb +105 -17
  88. data/lib/insika/executor.rb +698 -164
  89. data/lib/insika/fence.rb +96 -0
  90. data/lib/insika/golden_store.rb +3 -0
  91. data/lib/insika/harvest.rb +26 -14
  92. data/lib/insika/harvest_engine.rb +1 -0
  93. data/lib/insika/harvest_store.rb +12 -0
  94. data/lib/insika/http_client.rb +87 -4
  95. data/lib/insika/kits.rb +54 -0
  96. data/lib/insika/knowledge.rb +34 -20
  97. data/lib/insika/llm_configurator.rb +46 -4
  98. data/lib/insika/llm_provider_store.rb +1 -3
  99. data/lib/insika/llm_trace_store.rb +54 -0
  100. data/lib/insika/mcp_client.rb +27 -63
  101. data/lib/insika/mcp_live_tool.rb +55 -9
  102. data/lib/insika/mcp_store.rb +37 -3
  103. data/lib/insika/mcp_tool_registry.rb +76 -26
  104. data/lib/insika/media.rb +38 -25
  105. data/lib/insika/memory_store.rb +16 -3
  106. data/lib/insika/memory_trim.rb +60 -0
  107. data/lib/insika/middleware.rb +12 -3
  108. data/lib/insika/model_metrics_store.rb +285 -0
  109. data/lib/insika/model_registry.rb +110 -0
  110. data/lib/insika/model_resolver.rb +1 -1
  111. data/lib/insika/model_selection.rb +14 -21
  112. data/lib/insika/model_visible.rb +17 -6
  113. data/lib/insika/onboarding.rb +1 -0
  114. data/lib/insika/outcome_store.rb +1 -1
  115. data/lib/insika/overlay_tool_registry.rb +30 -2
  116. data/lib/insika/pack_importer.rb +54 -9
  117. data/lib/insika/packaging.rb +14 -5
  118. data/lib/insika/pending_action_store.rb +45 -18
  119. data/lib/insika/plugin/loader.rb +4 -2
  120. data/lib/insika/prefix_fingerprint.rb +32 -27
  121. data/lib/insika/profile_source.rb +5 -0
  122. data/lib/insika/provider_error_classifier.rb +18 -3
  123. data/lib/insika/queue_policy.rb +7 -2
  124. data/lib/insika/refinement/proposer.rb +42 -17
  125. data/lib/insika/reliability.rb +12 -3
  126. data/lib/insika/router/app.rb +14 -5
  127. data/lib/insika/router/session_key.rb +35 -5
  128. data/lib/insika/safety/config.rb +20 -3
  129. data/lib/insika/safety/corpus.rb +10 -3
  130. data/lib/insika/safety/factory.rb +39 -1
  131. data/lib/insika/safety/input_guardrail.rb +21 -1
  132. data/lib/insika/safety/judge_moderator.rb +70 -0
  133. data/lib/insika/safety/output_validator.rb +29 -5
  134. data/lib/insika/safety/prompt_echo.rb +89 -0
  135. data/lib/insika/schema_guard.rb +53 -76
  136. data/lib/insika/server/app.rb +58 -7
  137. data/lib/insika/server/rack_app.rb +2 -0
  138. data/lib/insika/server/responses.rb +50 -5
  139. data/lib/insika/session_actor.rb +58 -28
  140. data/lib/insika/session_store.rb +163 -11
  141. data/lib/insika/settings_store.rb +57 -2
  142. data/lib/insika/shared_conversations.rb +305 -0
  143. data/lib/insika/skill_catalog.rb +28 -2
  144. data/lib/insika/skill_store.rb +6 -0
  145. data/lib/insika/soak/runner.rb +1 -1
  146. data/lib/insika/spoken_transcript.rb +31 -0
  147. data/lib/insika/steer_injector.rb +7 -48
  148. data/lib/insika/store.rb +29 -1
  149. data/lib/insika/stores/json_value.rb +37 -0
  150. data/lib/insika/stores/memory.rb +22 -35
  151. data/lib/insika/stores/postgres.rb +295 -0
  152. data/lib/insika/stores/sqlite.rb +257 -43
  153. data/lib/insika/stores/turn_counter.rb +25 -0
  154. data/lib/insika/studio/app.rb +1459 -163
  155. data/lib/insika/studio/assets/dist/application.css +1 -1
  156. data/lib/insika/studio/assets/dist/application.js +25 -25
  157. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  158. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  159. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  160. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  161. data/lib/insika/studio/forms.rb +189 -12
  162. data/lib/insika/studio/nav_icons.rb +3 -0
  163. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  164. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  165. data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
  166. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  167. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  168. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  169. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  170. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  171. data/lib/insika/studio/views/_agents_master.erb +8 -12
  172. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  173. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  174. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  175. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  176. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  177. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  178. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  179. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  180. data/lib/insika/studio/views/_chats_master.erb +66 -0
  181. data/lib/insika/studio/views/_customers_master.erb +37 -0
  182. data/lib/insika/studio/views/_empty.erb +15 -0
  183. data/lib/insika/studio/views/_facts_master.erb +42 -0
  184. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  185. data/lib/insika/studio/views/_identity.erb +16 -0
  186. data/lib/insika/studio/views/_kpi.erb +17 -0
  187. data/lib/insika/studio/views/_message.erb +40 -2
  188. data/lib/insika/studio/views/_model_chart.erb +82 -0
  189. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  190. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
  191. data/lib/insika/studio/views/_tool_users.erb +17 -0
  192. data/lib/insika/studio/views/_why.erb +53 -0
  193. data/lib/insika/studio/views/agent_detail.erb +17 -10
  194. data/lib/insika/studio/views/agents.erb +28 -16
  195. data/lib/insika/studio/views/approvals.erb +16 -11
  196. data/lib/insika/studio/views/artifact.erb +20 -7
  197. data/lib/insika/studio/views/artifacts.erb +19 -14
  198. data/lib/insika/studio/views/chats.erb +22 -29
  199. data/lib/insika/studio/views/customer.erb +88 -43
  200. data/lib/insika/studio/views/customers.erb +16 -26
  201. data/lib/insika/studio/views/evals.erb +49 -31
  202. data/lib/insika/studio/views/fact_detail.erb +114 -0
  203. data/lib/insika/studio/views/facts.erb +15 -116
  204. data/lib/insika/studio/views/followups.erb +63 -52
  205. data/lib/insika/studio/views/funnel.erb +104 -43
  206. data/lib/insika/studio/views/harvest.erb +85 -59
  207. data/lib/insika/studio/views/home.erb +138 -23
  208. data/lib/insika/studio/views/inbox.erb +36 -0
  209. data/lib/insika/studio/views/kits.erb +176 -0
  210. data/lib/insika/studio/views/knowledge.erb +92 -80
  211. data/lib/insika/studio/views/layout.erb +42 -11
  212. data/lib/insika/studio/views/live.erb +45 -0
  213. data/lib/insika/studio/views/mcp.erb +43 -13
  214. data/lib/insika/studio/views/models.erb +187 -0
  215. data/lib/insika/studio/views/parity.erb +38 -32
  216. data/lib/insika/studio/views/playground.erb +13 -9
  217. data/lib/insika/studio/views/refinement.erb +15 -224
  218. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  219. data/lib/insika/studio/views/session.erb +153 -118
  220. data/lib/insika/studio/views/settings.erb +86 -22
  221. data/lib/insika/studio/views/skills.erb +53 -19
  222. data/lib/insika/studio/views/system_files.erb +7 -7
  223. data/lib/insika/studio/views/task.erb +45 -15
  224. data/lib/insika/studio/views/tasks.erb +35 -18
  225. data/lib/insika/studio/views/tools.erb +62 -73
  226. data/lib/insika/task_actor.rb +4 -1
  227. data/lib/insika/task_store.rb +67 -1
  228. data/lib/insika/telemetry/pricing.rb +16 -14
  229. data/lib/insika/telemetry/recorder.rb +115 -9
  230. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  231. data/lib/insika/telemetry.rb +12 -2
  232. data/lib/insika/templates/daily-digest/README.md +13 -0
  233. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  234. data/lib/insika/testing/store_contract.rb +65 -0
  235. data/lib/insika/text_tool_call.rb +66 -0
  236. data/lib/insika/tick.rb +7 -1
  237. data/lib/insika/token_store.rb +5 -0
  238. data/lib/insika/tool_assembly.rb +21 -13
  239. data/lib/insika/tool_batch.rb +1 -1
  240. data/lib/insika/tool_catalog.rb +12 -2
  241. data/lib/insika/tool_definition.rb +82 -13
  242. data/lib/insika/tool_envelope.rb +266 -29
  243. data/lib/insika/tool_manifest.rb +1 -0
  244. data/lib/insika/tool_store.rb +12 -4
  245. data/lib/insika/tool_trace_store.rb +15 -8
  246. data/lib/insika/tool_usage_report.rb +14 -4
  247. data/lib/insika/tools/a2a_remote.rb +1 -1
  248. data/lib/insika/tools/agent_enum.rb +1 -1
  249. data/lib/insika/tools/confirm_pending.rb +112 -0
  250. data/lib/insika/tools/data_defined_tool.rb +24 -15
  251. data/lib/insika/tools/generate_image.rb +2 -2
  252. data/lib/insika/tools/load_knowledge.rb +7 -3
  253. data/lib/insika/tools/load_skill.rb +3 -3
  254. data/lib/insika/tools/present.rb +115 -0
  255. data/lib/insika/tools/remember.rb +7 -2
  256. data/lib/insika/tools/run_persona_eval.rb +9 -6
  257. data/lib/insika/tools/save_artifact.rb +16 -6
  258. data/lib/insika/tools/schedule_followup.rb +3 -3
  259. data/lib/insika/tools/stuck_signal.rb +3 -3
  260. data/lib/insika/tools/subagent.rb +5 -5
  261. data/lib/insika/tools/subagents.rb +3 -3
  262. data/lib/insika/tools/tool_search.rb +11 -7
  263. data/lib/insika/tools/tts.rb +2 -2
  264. data/lib/insika/tools/update_briefing.rb +4 -4
  265. data/lib/insika/turn_output.rb +2 -2
  266. data/lib/insika/turn_state.rb +31 -1
  267. data/lib/insika/turn_timing.rb +51 -14
  268. data/lib/insika/version.rb +1 -1
  269. data/lib/insika/wiring/graph.rb +88 -16
  270. data/lib/insika/wiring/graph_chat.rb +4 -0
  271. data/lib/insika/workflow.rb +25 -64
  272. data/lib/insika.rb +23 -3
  273. metadata +74 -13
  274. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  275. data/lib/insika/mcp_http_client.rb +0 -67
  276. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -36,8 +36,12 @@ module Insika
36
36
  # platform utility_model) into one history fragment. `prompt` replaces
37
37
  # the engine default wholesale (the distill convention). enabled: false
38
38
  # = parity (nothing runs). Additive keys — reads overlay DEFAULTS.
39
- "compaction" => { "enabled" => false, "keep_last" => 20,
39
+ "compaction" => { "enabled" => false, "mode" => "summary", "keep_last" => 20,
40
40
  "compact_after" => 40, "model" => nil },
41
+ # Fencing (per-agent `fencing` flag): the cap on ONE string leaf of a tool
42
+ # result after sanitizing. Platform-wide — the leaf size is a context-budget
43
+ # concern, not a persona one. Additive key — reads overlay DEFAULTS.
44
+ "fencing" => { "max_chars" => 12_000 },
41
45
  # Data lifecycle (WS8, phase 2): the RETENTION window in days. The
42
46
  # tick's Retention sweep purges sessions (+traces), terminal tasks
43
47
  # (+checkpoints), memory cells and outcomes older than this. nil/0 =
@@ -81,7 +85,12 @@ module Insika
81
85
  "aggregate" => "median",
82
86
  "min_agreement" => 0.5,
83
87
  "quorum" => 1,
84
- "tolerance" => 0.05
88
+ "tolerance" => 0.05,
89
+ # seeding -> opens POST /v1/conversations/:id/seed, the route a snapshot eval
90
+ # loads a case's `state:` through. OFF by default: a seeded
91
+ # conversation is a fabricated precondition, and production must
92
+ # not accept one under the tenant token. The doctor warns when on.
93
+ "seeding" => false
85
94
  },
86
95
  # Edge limits — the platform layer of the EdgeLimiter.
87
96
  # nil/0 = off (opt-in). chat_rate_limit = turn attempts per chat per
@@ -94,6 +103,23 @@ module Insika
94
103
  "agent_token_ceiling" => nil,
95
104
  "agent_token_window" => 86_400,
96
105
  "limit_response" => nil
106
+ },
107
+ # Burst policy — the platform layer of the QueuePolicy (session vars >
108
+ # agent limits > HERE > QueuePolicy::DEFAULTS). nil = no platform default:
109
+ # the key falls through to QueuePolicy::DEFAULTS (followup, no window,
110
+ # steer_max 5, raw text). Same keys the agent config form writes into
111
+ # profile.limits, so one deploy-wide answer no longer means editing every
112
+ # agent. Additive key — reads overlay DEFAULTS.
113
+ # Platform defaults for agent fields (Insika::AgentDefaults): an agent with
114
+ # no value of its own for one of AgentDefaults::FIELDS runs with this one.
115
+ # Written through #put_agent_defaults. Additive key.
116
+ "agent_defaults" => {},
117
+ "queue" => {
118
+ "queue_mode" => nil,
119
+ "debounce_ms" => nil,
120
+ "debounce_max_ms" => nil,
121
+ "steer_max_messages" => nil,
122
+ "steer_join" => nil
97
123
  }
98
124
  }.freeze
99
125
 
@@ -114,6 +140,35 @@ module Insika
114
140
  merged
115
141
  end
116
142
 
143
+ # Replaces the platform agent defaults per FIELD (AgentDefaults): a sent field
144
+ # replaces its whole value (unlike #update, which deep-merges and could never
145
+ # drop a key the form removed); nil drops it; fields not sent stay.
146
+ # -> Hash (the resulting agent_defaults).
147
+ def put_agent_defaults(patch)
148
+ data = get
149
+ defaults = (data["agent_defaults"] || {}).merge(stringify(patch || {})).compact
150
+ data["agent_defaults"] = defaults
151
+ @cs.put(SCOPE, KEY, data)
152
+ defaults
153
+ end
154
+
155
+ # Kits (Insika::Kits), replaced per kit: a sent kit replaces its whole value,
156
+ # nil deletes it, other kits stay. -> Hash (all kits).
157
+ def kits = get["kits"] || {}
158
+
159
+ def put_kit(name, kit)
160
+ data = get
161
+ all = (data["kits"] || {}).dup
162
+ if kit.nil?
163
+ all.delete(name.to_s)
164
+ else
165
+ all[name.to_s] = Insika::Kits.validate!(name, kit)
166
+ end
167
+ data["kits"] = all
168
+ @cs.put(SCOPE, KEY, data)
169
+ all
170
+ end
171
+
117
172
  # RAW persisted schema version (bypasses the DEFAULTS overlay, which would always
118
173
  # report the current one). nil = no settings persisted yet (fresh deploy — nothing
119
174
  # to migrate); an Integer otherwise, 0 for a pre-versioning record.
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+ require 'json'
3
+ require 'securerandom'
4
+ require 'digest'
5
+
6
+ module Insika
7
+ # Required HTTP persistence. The native backend owns the execution outbox;
8
+ # the central service alone owns settled conversation history.
9
+ class SharedConversations
10
+ class Recovered < StandardError
11
+ attr_reader :content, :output_parts, :attachments
12
+ def initialize(content, output_parts: [], attachments: [])
13
+ @content, @output_parts, @attachments = content, output_parts, attachments
14
+ super('recorded shared result recovered')
15
+ end
16
+ end
17
+ UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
18
+ SCOPE = 'shared_conversations'
19
+
20
+ def initialize(url:, token:, store:)
21
+ require 'uri'
22
+ @url = URI(url)
23
+ unless %w[http https].include?(@url.scheme) && @url.host && !@url.userinfo && !@url.query && !@url.fragment
24
+ raise StoreError, 'invalid shared conversation URL'
25
+ end
26
+ raise StoreError, 'shared conversation token missing' if token.to_s.empty?
27
+ raise StoreError, 'shared conversations require a durable native backend' unless Stores.durable?(store)
28
+ @token, @store = token, store
29
+ end
30
+
31
+ def begin_turn(task:, profile:)
32
+ data, payload = identity(task)
33
+ acquire = !payload.fetch('shared_conversation').key?('generation')
34
+ path = path_for(data)
35
+ conversation = request('GET', path)
36
+ key = key_for(data)
37
+ old = @store.get(SCOPE,key)
38
+ check_identity!(data, conversation, ownership: !acquire || !!old)
39
+ if old
40
+ raise StoreError, 'shared turn input differs' unless old['input_digest'] == input_digest(payload)
41
+ raise StoreError, 'shared turn requires native reconciliation' unless old['messages']
42
+ flush(data, old)
43
+ raise Recovered.new(old['messages'].last['content'].filter_map { _1['text'] }.join("\n"), output_parts: old.fetch('output_parts', []), attachments: old.fetch('delivery_attachments', []))
44
+ end
45
+ raise StoreError, 'shared turn requires native reconciliation' if conversation['active_turn']
46
+ data = data.merge('generation'=>conversation['generation'] + (conversation['assigned_harness'] == 'insika' ? 0 : 1)) if acquire
47
+ history = history_at(path, conversation.fetch('last_sequence'))
48
+ request('PUT', path+'/bindings/insika', {'generation'=>data['generation'], 'native_session_id'=>task.session_id || task.id,
49
+ 'synchronized_sequence'=>conversation['last_sequence']}) unless acquire
50
+ input = canonical(task, {'role'=>'user','content'=>payload.fetch('user_text')}, id: data['message_id'])
51
+ input['origin'] = payload['origin'] || 'customer'
52
+ input['content'] = data['content'] if data['content']
53
+ record = {'native_run_id'=>task.id, 'input_digest'=>input_digest(payload), 'sequence'=>conversation['last_sequence'] + 1, 'generation'=>data['generation']}
54
+ # Write first: an ambiguous admission or registration must never authorize
55
+ # another execution. Only a recorded result can be flushed automatically.
56
+ @store.transaction do
57
+ raise StoreError, 'shared turn requires native reconciliation' if @store.get(SCOPE,key)
58
+ @store.set(SCOPE,key,record)
59
+ end
60
+ admission = {'id'=>data['turn_id'],'generation'=>data['generation'],
61
+ 'expected_sequence'=>conversation['last_sequence'],'message'=>input}
62
+ admission.merge!('acquire'=>true, 'generation'=>conversation['generation'], 'native_session_id'=>task.session_id || task.id) if acquire
63
+ turn = request('POST', path+'/turns', admission)
64
+ raise StoreError, 'shared acquired generation differs' if acquire && turn['generation'] != data['generation']
65
+ raise StoreError, 'shared turn requires native reconciliation' if turn['native_run_id'] || turn['state'] != 'running'
66
+ request('PUT', path+"/turns/#{data['turn_id']}/native-run", {'generation'=>data['generation'],'native_run_id'=>task.id})
67
+ project(history, path)
68
+ end
69
+
70
+ def resume_turn(task:, profile:, checkpoint:)
71
+ data, = identity(task)
72
+ path = path_for(data)
73
+ conversation = request('GET',path)
74
+ check_identity!(data,conversation)
75
+ active = conversation['active_turn']
76
+ record = @store.get(SCOPE,key_for(data))
77
+ valid = task.status == :waiting && checkpoint.task_id == task.id && checkpoint.continuation &&
78
+ active && active.values_at('id','native_run_id','state') == [data['turn_id'],task.id,'running'] &&
79
+ record && record['native_run_id'] == task.id
80
+ raise StoreError, 'shared turn requires native reconciliation' unless valid
81
+ project(history_at(path,record['sequence'] - 1),path)
82
+ end
83
+
84
+ def complete(task:, messages:, pending_approval: false, output_parts: [], attachments: [])
85
+ data, = identity(task)
86
+ record = @store.get(SCOPE,key_for(data)) || raise(StoreError, 'shared admission missing')
87
+ raise StoreError, 'shared native execution differs' unless record['native_run_id'] == task.id
88
+ unless record['messages']
89
+ output = messages.drop_while { _1['role'] == 'user' }
90
+ raise StoreError, 'shared final answer missing' unless output.last && output.last['role'] == 'assistant'
91
+ record['pending_approval'] = pending_approval
92
+ record['messages'] = output.map do |message|
93
+ full = record.fetch('tool_results', {})[message['tool_call_id']]
94
+ if message['role'] == 'tool' && !full.nil?
95
+ text = full.is_a?(String) ? full : JSON.generate(full)
96
+ message = message.merge('content'=>text)
97
+ end
98
+ canonical(task, message)
99
+ end
100
+ record['output_parts'] = Array(output_parts)
101
+ record['delivery_attachments'] = Array(attachments)
102
+ record['uploads'] = Array(output_parts).map do |part|
103
+ require 'base64'
104
+ raise StoreError, 'invalid shared output media' unless %w[image audio].include?(part['type']) && part['base64'].is_a?(String)
105
+ bytes = Base64.strict_decode64(part['base64'])
106
+ raise StoreError, 'shared output exceeds 10 MiB' if bytes.bytesize > 10*1024*1024
107
+ id = SecureRandom.uuid
108
+ record['messages'].last['content'] << {'type'=>part['type'],'attachment_id'=>id}
109
+ {'id'=>id,'base64'=>part['base64'],'mime_type'=>part.fetch('mime_type'),'sha256'=>Digest::SHA256.hexdigest(bytes)}
110
+ end
111
+ @store.set(SCOPE,key_for(data),record)
112
+ end
113
+ flush(data,record)
114
+ end
115
+
116
+ def input_attachments(task:)
117
+ data, = identity(task)
118
+ return [] unless data['content']
119
+ project([{'role'=>'user', 'content'=>data['content']}],path_for(data)).first['attachments'].map { Media.hydrate_attachment(_1) }
120
+ end
121
+
122
+ def record_tool_result(task:, call_id:, result:)
123
+ raise StoreError, 'shared tool correlation missing' if call_id.to_s.empty?
124
+ data, = identity(task)
125
+ @store.transaction do
126
+ record = @store.get(SCOPE,key_for(data)) || raise(StoreError, 'shared admission missing')
127
+ results = (record['tool_results'] ||= {})
128
+ # The first capture precedes prompt-only evidence reshaping and clipping.
129
+ results[call_id.to_s] ||= ToolTraceStore.mask(result.respond_to?(:content) ? result.content : result)
130
+ @store.set(SCOPE,key_for(data),record)
131
+ end
132
+ end
133
+
134
+ def identity(task)
135
+ payload = task.command.fetch('payload')
136
+ data = payload['shared_conversation']
137
+ raise StoreError, 'explicit shared conversation identity required' unless data.is_a?(Hash)
138
+ %w[tenant_id user_id agent_id conversation_id turn_id message_id].each do |name|
139
+ raise StoreError, "invalid shared #{name}" unless data[name].is_a?(String) && UUID.match?(data[name])
140
+ end
141
+ if data.key?('generation')
142
+ raise StoreError, 'invalid shared generation' unless data['generation'].is_a?(Integer) && data['generation'].positive?
143
+ else
144
+ record = @store.get(SCOPE,key_for(data))
145
+ data = data.merge('generation'=>record.fetch('generation')) if record
146
+ end
147
+ raise StoreError, 'original user_text required' unless payload['user_text'].is_a?(String)
148
+ content = data['content']
149
+ if content
150
+ valid = content.is_a?(Array) && content.all? do |part|
151
+ part.is_a?(Hash) && (part['type'] == 'text' ? part['text'].is_a?(String) :
152
+ %w[image audio document].include?(part['type']) && part['attachment_id'].is_a?(String) && UUID.match?(part['attachment_id']))
153
+ end
154
+ raise StoreError, 'invalid shared content' unless valid
155
+ raise StoreError, 'shared content differs from original speech' unless content.filter_map { _1['text'] }.join("\n") == payload['user_text']
156
+ end
157
+ if Media.parts(payload['parts']).any? { !_1.text? } && !Array(content).any? { _1['type'] != 'text' }
158
+ raise StoreError, 'upload media to central storage before admission'
159
+ end
160
+ tenant = task.command.fetch('meta')['tenant']
161
+ raise StoreError, 'shared tenant identity differs' unless tenant.to_s == data['tenant_id']
162
+ [data,payload]
163
+ end
164
+
165
+ def memory_context(task:, profile:)
166
+ data, payload = identity(task)
167
+ result = {}
168
+ if profile.memory
169
+ refs = request('GET', "/v1/memories/user/#{data['user_id']}?limit=50")['memories']
170
+ result['facts'] = refs.filter_map { memory_get(task:task,id:_1['id']) }
171
+ path = "/v1/memories/conversation/#{data['conversation_id']}"
172
+ request('GET',path+'?limit=50')['memories'].each do |ref|
173
+ record = request('GET',path+'/'+URI.encode_www_form_component(ref['id']),missing:true)
174
+ result['facts'] << record.merge('id'=>"conversation.#{record['id']}") if record
175
+ end
176
+ end
177
+ if Coercion.truthy?(profile.knowledge&.dig('retrieve'))
178
+ query = URI.encode_www_form_component(payload['user_text'][0, 200])
179
+ result['knowledge'] = request('GET', "/v1/memories/agent/#{data['agent_id']}?limit=5&query=#{query}")['memories']
180
+ end
181
+ result
182
+ end
183
+
184
+ def memory_get(task:, id:, kind: 'fact', include_proposed: false)
185
+ data, = identity(task)
186
+ scope, owner = kind == 'knowledge' ? ['agent',data['agent_id']] : ['user',data['user_id']]
187
+ path = "/v1/memories/#{scope}/#{owner}/#{URI.encode_www_form_component(id)}"
188
+ path += '?include_proposed=true' if include_proposed
189
+ request('GET', path, missing:true)
190
+ end
191
+
192
+ def propose_memory(task:, id:, value:, kind: 'fact')
193
+ data, = identity(task)
194
+ scope, owner = kind == 'knowledge' ? ['agent',data['agent_id']] : ['user',data['user_id']]
195
+ current = memory_get(task:task,id:id,kind:kind,include_proposed:true)
196
+ recorded = @store.get(SCOPE,key_for(data))
197
+ source_ids = [data['message_id']] + Array(recorded && recorded['messages']).map { _1['id'] }
198
+ clean = value.is_a?(String) ? Safety::Detectors.redact(value).first : ToolTraceStore.mask(value)
199
+ request('PUT', "/v1/memories/#{scope}/#{owner}/#{URI.encode_www_form_component(id)}",
200
+ {'value'=>clean,'kind'=>kind,'origin'=>'insika','status'=>'proposed','expected_revision'=>current ? current['revision'] : 0,
201
+ 'sources'=>source_ids.uniq.map { {'conversation_id'=>data['conversation_id'],'message_id'=>_1} }})
202
+ end
203
+
204
+ private
205
+
206
+ def check_identity!(data, conversation, ownership: true)
207
+ unless conversation.values_at('tenant_id','user_id','agent_id') == data.values_at('tenant_id','user_id','agent_id') &&
208
+ (!ownership || conversation.values_at('assigned_harness','generation') == ['insika',data['generation']])
209
+ raise StoreError, 'shared conversation identity or generation mismatch'
210
+ end
211
+ end
212
+
213
+ def key_for(data) = data.values_at('tenant_id','conversation_id','turn_id').join(':')
214
+ def path_for(data) = "/v1/conversations/#{data['conversation_id']}"
215
+ def input_digest(payload) = Digest::SHA256.hexdigest(JSON.generate(payload.slice('shared_conversation','user_text','origin')))
216
+
217
+ def canonical(task, message, id: SecureRandom.uuid)
218
+ result = {'schema_version'=>1,'id'=>id,'role'=>message.fetch('role'),
219
+ 'content'=>[{'type'=>'text','text'=>message.fetch('content').to_s}],
220
+ 'source'=>{'harness'=>'insika','session_id'=>task.session_id || task.id}}
221
+ result.merge!(ToolTraceStore.mask(message.slice('tool_calls','tool_call_id','origin','outcome')))
222
+ result
223
+ end
224
+
225
+ def flush(data, record)
226
+ require 'base64'
227
+ record.fetch('uploads',[]).each do |upload|
228
+ request('PUT',path_for(data)+"/attachments/#{upload['id']}",Base64.strict_decode64(upload['base64']),
229
+ headers: {'Content-Type'=>upload['mime_type'],'X-Filename'=>upload['id'],
230
+ 'X-Content-SHA256'=>upload['sha256'],'X-Conversation-Generation'=>data['generation'].to_s})
231
+ end
232
+ path = path_for(data)+"/turns/#{data['turn_id']}"
233
+ prefix = record.fetch('messages')[0...-1]
234
+ prefix.each_with_index do |message, index|
235
+ request('POST', path+'/messages', {'generation'=>data['generation'],'expected_sequence'=>record['sequence']+index,'messages'=>[message]})
236
+ end
237
+ request('POST', path+'/complete', {'generation'=>data['generation'],
238
+ 'expected_sequence'=>record['sequence']+prefix.length,'message'=>record['messages'].last,'pending_approval'=>record.fetch('pending_approval',false)})
239
+ end
240
+
241
+ def history_at(path, sequence)
242
+ messages, cursor = [], 0
243
+ while cursor < sequence
244
+ page = request('GET', path+"/messages?after_sequence=#{cursor}&through_sequence=#{sequence}&limit=10")
245
+ raise StoreError, 'shared history cursor did not advance' unless page['next_sequence'].is_a?(Integer) && page['next_sequence'] > cursor
246
+ messages.concat(page.fetch('messages'))
247
+ cursor = page['next_sequence']
248
+ end
249
+ messages
250
+ end
251
+
252
+ def project(messages, path)
253
+ messages.map do |message|
254
+ text, attachments = [], []
255
+ message.fetch('content').each do |part|
256
+ if part['type'] == 'text'
257
+ text << part.fetch('text')
258
+ else
259
+ id = part.fetch('attachment_id')
260
+ raise StoreError, 'invalid shared attachment ID' unless UUID.match?(id)
261
+ bytes, headers = request('GET', path+"/attachments/#{id}", raw: true)
262
+ raise StoreError, 'shared attachment hash mismatch' unless Digest::SHA256.hexdigest(bytes) == headers['x-content-sha256']
263
+ require 'base64'
264
+ extension = {'image/png'=>'png','image/jpeg'=>'jpg','image/webp'=>'webp','image/gif'=>'gif',
265
+ 'application/pdf'=>'pdf','audio/mpeg'=>'mp3','audio/wav'=>'wav','audio/ogg'=>'ogg','audio/webm'=>'webm','text/plain'=>'txt'}.fetch(headers['content-type'],'bin')
266
+ attachments << {'base64'=>Base64.strict_encode64(bytes),'filename'=>"#{id}.#{extension}"}
267
+ end
268
+ end
269
+ projected = message.slice('role','origin').merge('content'=>text.join("\n"),'attachments'=>attachments)
270
+ # RubyLLM checks answered call IDs across its entire chat, not per turn.
271
+ call_id = ->(id) { "history_#{Digest::SHA256.hexdigest("#{message.fetch('turn_id')}:#{id}")[0, 48]}" }
272
+ projected['tool_calls'] = message['tool_calls'].map { _1.merge('id'=>call_id.call(_1['id'])) } if message['tool_calls']
273
+ projected['tool_call_id'] = call_id.call(message['tool_call_id']) if message['tool_call_id']
274
+ projected
275
+ end
276
+ end
277
+
278
+ def request(method, path, data = nil, raw: false, headers: {}, missing: false)
279
+ require 'net/http'
280
+ uri = @url.dup
281
+ uri.path = @url.path.delete_suffix('/') + path.split('?',2).first
282
+ uri.query = path.split('?',2)[1]
283
+ body = data.is_a?(String) ? data : data && JSON.generate(data)
284
+ raise StoreError, 'shared request exceeds 1 MiB' if body && !data.is_a?(String) && body.bytesize > 1_048_576
285
+ request = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
286
+ {'Authorization'=>"Bearer #{@token}", 'Content-Type'=>'application/json'}.merge(headers))
287
+ request.body = body if body
288
+ Net::HTTP.start(uri.host, uri.port, nil, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 10, write_timeout: 10) do |http|
289
+ http.max_retries = 0
290
+ http.request(request) do |response|
291
+ return nil if missing && response.code == '404'
292
+ raise StoreError, "shared conversation HTTP #{response.code}" unless response.code == '200'
293
+ bytes = +''.b
294
+ response.read_body do |chunk|
295
+ bytes << chunk
296
+ raise StoreError, 'shared response too large' if bytes.bytesize > (raw ? 10 : 16)*1024*1024
297
+ end
298
+ return raw ? [bytes,response.to_hash.transform_values(&:first)] : JSON.parse(bytes)
299
+ end
300
+ end
301
+ rescue JSON::ParserError, IOError, SystemCallError, Timeout::Error, SocketError => error
302
+ raise StoreError, "shared conversation transport failed (#{error.class})"
303
+ end
304
+ end
305
+ end
@@ -28,10 +28,17 @@ module Insika
28
28
  # store (optional): a SkillStore with the skills AUTHORED in the Studio.
29
29
  # They overlay the on-disk ones (seed) — the Store wins, it is the source of truth.
30
30
  # Nil = disk-only behavior, zero regression.
31
- def initialize(roots, store: nil)
31
+ #
32
+ # recheck/clock: another worker may write a skill (pack import, Studio edit) and
33
+ # `reload` only runs in the process that handled the write. At most every
34
+ # `recheck` seconds the catalog compares the config version token and reloads
35
+ # when it moved — the same rule as OverlayToolRegistry for data-tools.
36
+ def initialize(roots, store: nil, recheck: 2, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
32
37
  @roots = Array(roots)
33
38
  @store = store
34
- @skills, @agent_skills = load_all
39
+ @recheck = recheck
40
+ @clock = clock
41
+ reload
35
42
  end
36
43
 
37
44
  # The SkillStore the catalog overlays — the composition root hands it to
@@ -49,6 +56,7 @@ module Insika
49
56
  # Without `agent` the shared scope is all there is, which is what every caller
50
57
  # that has no agent in hand (the Studio's shared editor, a bare catalog) means.
51
58
  def all(agent: nil)
59
+ reload if stale?
52
60
  shared = @skills
53
61
  overrides = agent_scope(agent)
54
62
  return shared.values if overrides.empty?
@@ -57,6 +65,7 @@ module Insika
57
65
  end
58
66
 
59
67
  def find(name, agent: nil)
68
+ reload if stale?
60
69
  agent_scope(agent)[name.to_s] || @skills[name.to_s]
61
70
  end
62
71
 
@@ -64,6 +73,8 @@ module Insika
64
73
  # authored/edited skill takes effect without a restart. A turn in progress
65
74
  # captured @skills at dispatch, so it does not see the swap mid-flight.
66
75
  def reload
76
+ @token = version_token
77
+ @checked_at = @clock.call
67
78
  @skills, @agent_skills = load_all
68
79
  self
69
80
  end
@@ -155,6 +166,21 @@ module Insika
155
166
  # AgentProfile#stream_public?. Anything else (a list, nil, false) is not blanket.
156
167
  def blanket?(spec) = Coercion.truthy?(spec)
157
168
 
169
+ # The token moves on any config write, so a reload may find nothing new. Read
170
+ # BEFORE load_all in `reload`: a write landing in between leaves the old token,
171
+ # and the next check reloads again instead of missing it.
172
+ def stale?
173
+ return false unless @store.respond_to?(:version_token)
174
+
175
+ now = @clock.call
176
+ return false if now - @checked_at < @recheck
177
+
178
+ @checked_at = now
179
+ version_token != @token
180
+ end
181
+
182
+ def version_token = @store.respond_to?(:version_token) ? @store.version_token : nil
183
+
158
184
  # An agent's override index; {} for a nil agent or one that specialized nothing.
159
185
  def agent_scope(agent) = agent.nil? ? {} : (@agent_skills[agent.to_s] || {})
160
186
 
@@ -55,6 +55,9 @@ module Insika
55
55
  names(agent: agent).each_with_object({}) { |n, acc| acc[n] = get(n, agent: agent) }
56
56
  end
57
57
 
58
+ # Changes whenever any config is written, in any process (ConfigStore#version_token).
59
+ def version_token = @cs.version_token
60
+
58
61
  # -> [String] every agent that has specialized at least one skill. What the
59
62
  # catalog overlays and `doctor` sweeps.
60
63
  def agents = @cs.keys(AGENT_SCOPE).sort
@@ -86,6 +89,9 @@ module Insika
86
89
  # -> [ { "content" =>, "at" => } ] most recent first.
87
90
  def versions(name, agent: nil) = record(name, agent)&.fetch("history", []) || []
88
91
 
92
+ # -> iso8601 of the current content's write, nil for an unknown skill.
93
+ def updated_at(name, agent: nil) = record(name, agent)&.fetch("updated_at", nil)
94
+
89
95
  # Restores version `index` as the current content (a new write). -> Hash.
90
96
  def restore(name, index, agent: nil)
91
97
  hist = versions(name, agent: agent)
@@ -182,7 +182,7 @@ module Insika
182
182
  end
183
183
 
184
184
  def target_url
185
- (@env["INSIKA_URL"] || @env["HARNESS_URL"] || "http://localhost:9292").sub(%r{/$}, "")
185
+ (@env["INSIKA_URL"] || "http://localhost:9292").sub(%r{/$}, "")
186
186
  end
187
187
 
188
188
  def corpus
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # The transcript slice an EXTRACTOR reads — memory distillation, knowledge
5
+ # extraction. Only what people said: `user` and `assistant` prose. A
6
+ # `role: tool` message is a product description, a search result, an FAQ body
7
+ # — third-party text; a customer fact or a learned concept distilled from it
8
+ # is a defect, not a behaviour to preserve. Tool-call payloads never render
9
+ # either (only `content` does). Indices are the ORIGINAL message positions,
10
+ # so a proposal's `turns` still point at the right message.
11
+ module SpokenTranscript
12
+ module_function
13
+
14
+ ROLES = %w[user assistant].freeze
15
+
16
+ # -> String, PII-redacted (what reaches the utility model follows the same
17
+ # redaction rule as what gets persisted). Messages are store records: string
18
+ # keys, like everything at the persistence boundary.
19
+ def render(messages)
20
+ lines = Array(messages).each_with_index.filter_map do |m, i|
21
+ role = m["role"].to_s
22
+ content = m["content"].to_s
23
+ next unless ROLES.include?(role) && !content.strip.empty?
24
+
25
+ "[#{i}] #{role}: #{content}"
26
+ end
27
+ redacted, = Insika::Safety::Detectors.redact(lines.join("\n"))
28
+ redacted
29
+ end
30
+ end
31
+ end
@@ -6,17 +6,9 @@ module Insika
6
6
  #
7
7
  # A customer who corrects themselves while the agent is calling tools ("1234567",
8
8
  # three seconds after "queria saber do pedido") should have that land before the
9
- # model's next reasoning step, not after the whole run. RubyLLM runs the entire tool
10
- # loop inside `chat.ask`, so the only place to append is from inside its callbacks —
11
- # which is enough, because they are public and additive:
12
- #
13
- # complete_once
14
- # ├─ provider_completion → assistant message announcing N tool_calls
15
- # ├─ after_message(assistant) ← N is read here
16
- # └─ handle_tool_calls
17
- # ├─ add_tool_result_message ×N
18
- # │ └─ after_message(tool) ← counted; the Nth is THE BOUNDARY
19
- # └─ halt_result || complete ← the next model step sees what we appended
9
+ # model's next reasoning step, not after the whole run. RubyLLM emits message
10
+ # callbacks during both `chat.ask` and native steps. ToolBatch finds the last
11
+ # sibling result so steering lands before the next model step.
20
12
  #
21
13
  # Counting to N is not an optimization, it is the correctness condition. A `user`
22
14
  # message inserted BETWEEN tool results is rejected outright by Anthropic (all tool
@@ -41,9 +33,7 @@ module Insika
41
33
  @actor = actor
42
34
  @policy = policy
43
35
  @emit = emit
44
- @expected = nil # tool calls announced by the batch in flight (nil = not in one)
45
- @seen = 0
46
- @halted = false
36
+ @batch = ToolBatch.new
47
37
  @injected = 0
48
38
  end
49
39
 
@@ -53,20 +43,13 @@ module Insika
53
43
  # RubyLLM `after_tool_result`, with the RAW result — the only place a `Tool::Halt`
54
44
  # is still recognizable. By the time it becomes a `role: tool` message its content
55
45
  # is the payload, indistinguishable from an ordinary result.
56
- def tool_result(result)
57
- @halted = true if halt?(result)
58
- end
46
+ def tool_result(result) = @batch.halt!(result)
59
47
 
60
48
  # RubyLLM `after_message`. An assistant message carrying tool calls OPENS a batch;
61
49
  # the Nth tool result CLOSES it, and that is the one boundary where appending is
62
50
  # valid.
63
51
  def message_ended(message)
64
- role = field(message, :role).to_s
65
- return open_batch(message) if role == "assistant"
66
- return unless role == "tool" && @expected
67
-
68
- @seen += 1
69
- inject! if @seen >= @expected
52
+ inject! if @batch.closed?(message)
70
53
  end
71
54
 
72
55
  # Tail-appends whatever is in the mailbox right now and reports how many. Called
@@ -88,34 +71,10 @@ module Insika
88
71
 
89
72
  private
90
73
 
91
- def open_batch(message)
92
- calls = field(message, :tool_calls)
93
- size = calls.respond_to?(:size) ? calls.size : 0
94
- # A message with no tool call is the model talking, not a batch: leave any
95
- # pending message where it is. The turn is about to end, and the Executor
96
- # absorbs it there in ONE extra round (#absorb_pending!) — a text-only turn
97
- # closes no batch, and answering it half-way is not an option.
98
- return @expected = nil if size.zero?
99
-
100
- @expected = size
101
- @seen = 0
102
- @halted = false
103
- end
104
-
105
74
  def inject!
106
- @expected = nil
107
- return if @halted # nothing will read it: leave it in the mailbox
75
+ return if @batch.halted? # nothing will read it: leave it in the mailbox
108
76
 
109
77
  absorb_pending!
110
78
  end
111
-
112
- def halt?(result) = defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)
113
-
114
- def field(message, name)
115
- return message.public_send(name) if message.respond_to?(name)
116
- return message[name] || message[name.to_s] if message.respond_to?(:[])
117
-
118
- nil
119
- end
120
79
  end
121
80
  end
data/lib/insika/store.rb CHANGED
@@ -25,7 +25,9 @@ module Insika
25
25
  #
26
26
  # Backends `include Store` and override the six methods; any forgotten
27
27
  # method raises NotImplementedError (fail-fast, better than a distant
28
- # NoMethodError).
28
+ # NoMethodError). #entries, #recent and #amend have defaults; a backend that
29
+ # knows its write order overrides #recent and #amend together (the contract
30
+ # suite checks that an amended key keeps its place in #recent).
29
31
  module Store
30
32
  # -> Object | nil (deserialized)
31
33
  def get(scope, key)
@@ -37,6 +39,13 @@ module Insika
37
39
  raise NotImplementedError, "#{self.class}#set"
38
40
  end
39
41
 
42
+ # -> value. Like #set, but an existing key keeps its place in #recent: an edit
43
+ # that is not new activity (a review on a session). This default is #set,
44
+ # exact only for a backend whose #recent reads the value's "updated_at".
45
+ def amend(scope, key, value)
46
+ set(scope, key, value)
47
+ end
48
+
40
49
  # -> true | false (did it exist?)
41
50
  def delete(scope, key)
42
51
  raise NotImplementedError, "#{self.class}#delete"
@@ -53,6 +62,25 @@ module Insika
53
62
  raise NotImplementedError, "#{self.class}#scopes"
54
63
  end
55
64
 
65
+ # -> [[key, value], ...] sorted by key, filtered like #list. The bulk read for
66
+ # a caller that lists keys and reads each one. This default is exactly that;
67
+ # a backend overrides it to answer in one query (no lock taken, like #list).
68
+ def entries(scope, prefix = nil)
69
+ list(scope, prefix).filter_map do |key|
70
+ value = get(scope, key)
71
+ [key, value] unless value.nil?
72
+ end
73
+ end
74
+
75
+ # -> [[key, value], ...] the `limit` most recently WRITTEN keys of the scope
76
+ # (filtered like #list), newest first: a "recent" list without reading the
77
+ # whole scope. A backend that knows its write order answers it directly;
78
+ # this default approximates it by each value's "updated_at", when it has one.
79
+ # `offset` skips that many of the newest (the next page).
80
+ def recent(scope, prefix, limit, offset = 0)
81
+ entries(scope, prefix).sort_by { |_, v| v.is_a?(Hash) ? v["updated_at"].to_s : "" }.reverse.drop(offset).first(limit)
82
+ end
83
+
56
84
  # -> the block's result; atomic if the backend supports it
57
85
  def transaction(&blk)
58
86
  raise NotImplementedError, "#{self.class}#transaction"