insika 0.3.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 (204) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +296 -0
  3. data/README.md +48 -12
  4. data/bin/insika +725 -0
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +116 -406
  7. data/docs/API.md +5 -5
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +137 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +14 -14
  12. data/docs/CONTEXT.md +63 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +87 -10
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +128 -3
  17. data/docs/FACTS.md +3 -3
  18. data/docs/HARVEST.md +5 -6
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +17 -29
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +46 -12
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +222 -0
  26. data/docs/REFINEMENT.md +14 -9
  27. data/docs/RELEASING.md +4 -4
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +5 -5
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +23 -7
  32. data/docs/SKILLS.md +11 -2
  33. data/docs/SOAK.md +3 -3
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +176 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +4 -4
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +8 -5
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +99 -17
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/channel_delivery.rb +1 -1
  62. data/lib/insika/chat_builder.rb +50 -19
  63. data/lib/insika/commands/agent_payload.rb +2 -2
  64. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  65. data/lib/insika/commands/delete_artifact.rb +35 -0
  66. data/lib/insika/commands/delete_concept.rb +34 -0
  67. data/lib/insika/commands/delete_mcp.rb +6 -2
  68. data/lib/insika/commands/delete_tenant_data.rb +15 -3
  69. data/lib/insika/commands/gate_refinement.rb +1 -1
  70. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  71. data/lib/insika/commands/restore_concept.rb +34 -0
  72. data/lib/insika/commands/seed_demo_data.rb +31 -0
  73. data/lib/insika/commands/upsert_mcp.rb +6 -3
  74. data/lib/insika/commands/write_concept.rb +57 -0
  75. data/lib/insika/compaction.rb +196 -0
  76. data/lib/insika/context/builder.rb +6 -2
  77. data/lib/insika/context/fragment.rb +4 -1
  78. data/lib/insika/context/priority.rb +8 -0
  79. data/lib/insika/context/providers/briefing.rb +53 -24
  80. data/lib/insika/context/providers/knowledge.rb +108 -0
  81. data/lib/insika/context/providers/prompt.rb +30 -24
  82. data/lib/insika/context/providers/session.rb +46 -10
  83. data/lib/insika/context_trace_store.rb +11 -1
  84. data/lib/insika/cron.rb +189 -0
  85. data/lib/insika/demo/agent_attrs.rb +43 -0
  86. data/lib/insika/demo/golden_cases.rb +81 -0
  87. data/lib/insika/demo/seeder.rb +336 -0
  88. data/lib/insika/doctor.rb +280 -17
  89. data/lib/insika/dsl/definition.rb +3 -2
  90. data/lib/insika/dsl/runtime.rb +64 -79
  91. data/lib/insika/dsl/server_boot.rb +23 -1
  92. data/lib/insika/dsl/system.rb +10 -2
  93. data/lib/insika/dsl.rb +103 -2
  94. data/lib/insika/env_schema.rb +21 -7
  95. data/lib/insika/evals/golden.rb +41 -4
  96. data/lib/insika/evals/judge.rb +47 -2
  97. data/lib/insika/evals/pairwise.rb +11 -0
  98. data/lib/insika/evals/persona.rb +98 -0
  99. data/lib/insika/evals/runner.rb +9 -0
  100. data/lib/insika/evals/simulator.rb +225 -0
  101. data/lib/insika/evals/transport.rb +84 -2
  102. data/lib/insika/event_stream.rb +10 -0
  103. data/lib/insika/executor.rb +295 -55
  104. data/lib/insika/followup_policy.rb +2 -25
  105. data/lib/insika/golden_store.rb +16 -1
  106. data/lib/insika/grounding/matcher.rb +1 -1
  107. data/lib/insika/knowledge.rb +680 -0
  108. data/lib/insika/knowledge_store.rb +140 -0
  109. data/lib/insika/loop_detector.rb +5 -34
  110. data/lib/insika/mcp_client.rb +94 -0
  111. data/lib/insika/mcp_json.rb +74 -0
  112. data/lib/insika/mcp_live_tool.rb +43 -0
  113. data/lib/insika/mcp_store.rb +98 -26
  114. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  115. data/lib/insika/mcp_tool_registry.rb +100 -0
  116. data/lib/insika/media.rb +115 -31
  117. data/lib/insika/message_origin.rb +1 -1
  118. data/lib/insika/middleware.rb +9 -0
  119. data/lib/insika/onboarding.rb +17 -1
  120. data/lib/insika/outcome_store.rb +1 -1
  121. data/lib/insika/overlay_tool_registry.rb +37 -17
  122. data/lib/insika/packaging.rb +2 -2
  123. data/lib/insika/profile_source.rb +15 -1
  124. data/lib/insika/prompt_catalog.rb +10 -0
  125. data/lib/insika/retention.rb +36 -1
  126. data/lib/insika/router/app.rb +157 -0
  127. data/lib/insika/router/backend_pool.rb +98 -0
  128. data/lib/insika/router/hash_ring.rb +55 -0
  129. data/lib/insika/router/proxy_body.rb +34 -0
  130. data/lib/insika/router/session_key.rb +54 -0
  131. data/lib/insika/router.rb +18 -0
  132. data/lib/insika/schedule.rb +177 -0
  133. data/lib/insika/schedule_engine.rb +314 -0
  134. data/lib/insika/schedule_store.rb +208 -0
  135. data/lib/insika/server/app.rb +105 -15
  136. data/lib/insika/server/rack_app.rb +5 -1
  137. data/lib/insika/server/responses.rb +5 -5
  138. data/lib/insika/session_store.rb +34 -4
  139. data/lib/insika/settings_store.rb +8 -1
  140. data/lib/insika/skill_catalog.rb +12 -0
  141. data/lib/insika/soak/runner.rb +4 -4
  142. data/lib/insika/steer_injector.rb +21 -10
  143. data/lib/insika/studio/app.rb +591 -47
  144. data/lib/insika/studio/assets/dist/application.css +1 -1
  145. data/lib/insika/studio/assets/dist/application.js +21 -21
  146. data/lib/insika/studio/forms.rb +57 -5
  147. data/lib/insika/studio/nav_icons.rb +14 -1
  148. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  149. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  150. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  151. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  152. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  153. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  154. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  155. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  156. data/lib/insika/studio/views/_agents_master.erb +44 -0
  157. data/lib/insika/studio/views/_message.erb +49 -32
  158. data/lib/insika/studio/views/agent_detail.erb +61 -820
  159. data/lib/insika/studio/views/agents.erb +70 -57
  160. data/lib/insika/studio/views/artifact.erb +23 -0
  161. data/lib/insika/studio/views/artifacts.erb +59 -0
  162. data/lib/insika/studio/views/evals.erb +2 -2
  163. data/lib/insika/studio/views/facts.erb +1 -1
  164. data/lib/insika/studio/views/funnel.erb +1 -1
  165. data/lib/insika/studio/views/home.erb +106 -67
  166. data/lib/insika/studio/views/knowledge.erb +123 -0
  167. data/lib/insika/studio/views/layout.erb +14 -11
  168. data/lib/insika/studio/views/mcp.erb +174 -80
  169. data/lib/insika/studio/views/session.erb +231 -177
  170. data/lib/insika/studio/views/settings.erb +50 -1
  171. data/lib/insika/studio/views/skills.erb +1 -1
  172. data/lib/insika/studio/views/tools.erb +24 -9
  173. data/lib/insika/telemetry/recorder.rb +49 -1
  174. data/lib/insika/templates/browser-agent/README.md +36 -0
  175. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  176. data/lib/insika/templates/daily-digest/README.md +47 -0
  177. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  178. data/lib/insika/templates/repo-explorer/README.md +36 -0
  179. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  180. data/lib/insika/templates/research-analyst/README.md +26 -0
  181. data/lib/insika/templates/research-analyst/agent.rb +68 -0
  182. data/lib/insika/templates/review-panel/README.md +20 -0
  183. data/lib/insika/templates/review-panel/agent.rb +50 -0
  184. data/lib/insika/templates/travel-planner/README.md +35 -0
  185. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  186. data/lib/insika/templates.rb +112 -0
  187. data/lib/insika/tick.rb +24 -12
  188. data/lib/insika/timezone.rb +45 -0
  189. data/lib/insika/tool_batch.rb +67 -0
  190. data/lib/insika/tool_usage_report.rb +162 -0
  191. data/lib/insika/tools/generate_image.rb +52 -7
  192. data/lib/insika/tools/load_knowledge.rb +74 -0
  193. data/lib/insika/tools/run_persona_eval.rb +328 -0
  194. data/lib/insika/tools/save_artifact.rb +95 -0
  195. data/lib/insika/turn_budget.rb +91 -0
  196. data/lib/insika/turn_output.rb +1 -1
  197. data/lib/insika/turn_state.rb +15 -4
  198. data/lib/insika/version.rb +1 -1
  199. data/lib/insika/wiring/graph.rb +184 -12
  200. data/lib/insika/wiring/graph_chat.rb +102 -0
  201. data/lib/insika.rb +64 -0
  202. metadata +109 -5
  203. data/docs/build.md +0 -14
  204. data/docs/understand.md +0 -10
@@ -56,7 +56,7 @@ module Insika
56
56
  config:, pending_action_store: nil, a2a: nil, provisioner: nil,
57
57
  workflow_registry: nil, onboarding: nil, profiles: nil,
58
58
  channels: nil, logger: nil, token_store: nil, outcome_store: nil,
59
- executor: nil, db_path: nil)
59
+ executor: nil, db_path: nil, tool_registry: nil, mcp_store: nil)
60
60
  @command_bus = command_bus
61
61
  @event_stream = event_stream
62
62
  @session_store = session_store
@@ -104,6 +104,17 @@ module Insika
104
104
  # Both optional — nil means the body simply omits those readings.
105
105
  @executor = executor
106
106
  @db_path = db_path
107
+ # The deployment's EFFECTIVE tool registry, so the capability view can
108
+ # answer what the agent can reach that can WRITE (the derived
109
+ # eval-profile swap list). Read-only use (names/side_effect?) — same
110
+ # constitutional footing as the profile source. nil = the view omits it.
111
+ @tool_registry = tool_registry
112
+ # The durable MCP instance CRUD (GET/PUT/DELETE
113
+ # /v1/mcp[/:name]) — a direct store read/Command surface, same footing
114
+ # as @profiles/@outcome_store. nil = the routes 404 (parity — a
115
+ # deployment that never wires an McpStore never exposes MCP config
116
+ # over HTTP, same as one that skips provisioning).
117
+ @mcp_store = mcp_store
107
118
  @heartbeat = config.fetch(:heartbeat, 15)
108
119
  @sync_timeout = config.fetch(:sync_timeout, 10) # synchronous control
109
120
  end
@@ -181,6 +192,14 @@ module Insika
181
192
  handle_list_outcomes(req)
182
193
  in ["POST", ["v1", "tools", "manifest"]]
183
194
  handle_import_tools(req)
195
+ in ["GET", ["v1", "mcp"]] if @mcp_store
196
+ handle_list_mcp
197
+ in ["GET", ["v1", "mcp", name]] if @mcp_store
198
+ handle_read_mcp(name)
199
+ in ["PUT", ["v1", "mcp"]] if @mcp_store
200
+ handle_upsert_mcp(req)
201
+ in ["DELETE", ["v1", "mcp", name]] if @mcp_store
202
+ handle_delete_mcp(req, name)
184
203
  in ["POST", ["v1", "mcp", name, "import"]]
185
204
  handle_import_mcp_tools(req, name)
186
205
  in ["POST", ["v1", "agents"]] if @provisioner
@@ -438,18 +457,61 @@ module Insika
438
457
  json_response(200, dispatch_with_timeout(command))
439
458
  end
440
459
 
441
- # POST /v1/mcp/:name/import — LIVE MCP ingestion. Same
460
+ # GET /v1/mcp — every configured MCP instance, MASKED (@mcp_store.all
461
+ # already masks — never plaintext over the wire). A direct store read,
462
+ # not a Command (constitutional rule at the top of this file); the
463
+ # generic route gate already confined this to the operator (not a
464
+ # TENANT_SURFACES entry), same footing as GET /v1/agents/:id.
465
+ def handle_list_mcp
466
+ json_response(200, { instances: @mcp_store.all })
467
+ end
468
+
469
+ # GET /v1/mcp/:name — one instance, MASKED. Missing -> 404.
470
+ def handle_read_mcp(name)
471
+ record = @mcp_store.get(name)
472
+ raise Insika::NotFoundError, "MCP instance '#{name}' not found" if record.nil?
473
+
474
+ json_response(200, record)
475
+ end
476
+
477
+ # PUT /v1/mcp — create/edit an instance (the CLI/DSL/Studio
478
+ # config surfaces share this ONE seam: the `:upsert_mcp` Command already
479
+ # registered on the bus). Per-key secret reconciliation (the `__OCULTO__`
480
+ # sentinel round-trips: preserves; a new string replaces; "" clears) —
481
+ # same contract as `Insika::McpJson`. 200 { the MASKED upserted record }.
482
+ def handle_upsert_mcp(req)
483
+ gate = gateway_gate(req)
484
+ return gate if gate
485
+
486
+ command = Insika::Command.build(:upsert_mcp, parse_body(req), transport: :http)
487
+ json_response(200, dispatch_with_timeout(command))
488
+ end
489
+
490
+ # DELETE /v1/mcp/:name — removes an instance (`:delete_mcp`). Idempotent
491
+ # (200 { existed: false } on a name that was never there — never a 404;
492
+ # deleting a thing that is already gone is not an error). The name comes
493
+ # from the ROUTE (data), not the body.
494
+ def handle_delete_mcp(req, name)
495
+ gate = gateway_gate(req)
496
+ return gate if gate
497
+
498
+ command = Insika::Command.build(:delete_mcp, { name: name }, transport: :http)
499
+ json_response(200, dispatch_with_timeout(command))
500
+ end
501
+
502
+ # POST /v1/mcp/:name/import — kept as the alias for the
503
+ # "Refresh tools" action (the route predates the live registry; the name
504
+ # stuck). Dispatches :refresh_mcp_tools — LIVE connect + tools/list +
505
+ # write McpStore#tools_cache, never the retired ingestor snapshot. Same
442
506
  # Bearer as provisioning (gateway_token, fail-closed): it's an authoring
443
- # surface. Discovers the tools of the MCP instance `:name` (via a client injectable
444
- # at the composition root) and ingests them as data-tools (reuses :import_tools:
445
- # upsert + hot reload). Dispatches :import_mcp_tools -> 200 { per-tool report
446
- # + instance }. Missing instance -> 404; disabled/no-url -> 422; per-tool
447
- # failure isolated in `errors[]` (R4). The name comes from the ROUTE (data), not the body.
507
+ # surface. Missing instance -> 404; disabled -> 422; a transport failure
508
+ # surfaces as whatever #call's rescue maps it to. The name comes from the
509
+ # ROUTE (data), not the body.
448
510
  def handle_import_mcp_tools(req, name)
449
511
  gate = gateway_gate(req)
450
512
  return gate if gate
451
513
 
452
- command = Insika::Command.build(:import_mcp_tools, { name: name }, transport: :http)
514
+ command = Insika::Command.build(:refresh_mcp_tools, { name: name }, transport: :http)
453
515
  json_response(200, dispatch_with_timeout(command))
454
516
  end
455
517
 
@@ -466,22 +528,50 @@ module Insika
466
528
  # (a client: it never reads a store) can tell "this case cannot run here" from
467
529
  # "this case failed". Deliberately NOT the profile: the prompt,
468
530
  # the model and the guardrail config are none of the caller's business. Just the
469
- # two facts a case declares `requires` against.
531
+ # three facts a case declares `requires` against — plus the side-effect set,
532
+ # which is what an eval profile must swap.
470
533
  #
471
534
  # `tools` is the DECLARED allowlist, and `null` means the agent has an open one
472
535
  # (every registered tool) — the client reads that as "cannot rule anything out"
473
536
  # and runs the case rather than skipping it.
537
+ #
538
+ # `side_effect_tools` is DERIVED here from the deployment's own registry (the
539
+ # effective one the Executor uses): the agent's reachable tools the registry
540
+ # marks `side_effect` (a data-tool's non-GET method, the MCP ingestor's
541
+ # `tools/call`). It is the list a simulated run's eval profile must cover —
542
+ # and it is a fact of the deployment, never a hand-maintained client list.
543
+ # Absent (no registry wired) -> the key is omitted, and the view behaves as
544
+ # before.
474
545
  def handle_read_agent(id)
475
546
  profile = @profiles.fetch(id)
476
547
  raise Insika::NotFoundError, "agent not found: #{id}" if profile.nil?
477
548
 
478
549
  allow = profile.tools_allow
479
550
  deny = Array(profile.tools_deny).map(&:to_s)
480
- json_response(200, {
481
- id: profile.id,
482
- tools: allow.nil? ? nil : (Array(allow).map(&:to_s) - deny),
483
- capabilities: Array(profile.capabilities_declared).map(&:to_s)
484
- })
551
+ payload = {
552
+ id: profile.id,
553
+ # `tools: null` is MEANINGFUL (an open allowlist — the client reads it
554
+ # as "cannot rule anything out"), so it must survive, unlike the new
555
+ # field below.
556
+ tools: allow.nil? ? nil : (Array(allow).map(&:to_s) - deny),
557
+ capabilities: Array(profile.capabilities_declared).map(&:to_s)
558
+ }
559
+ derived = side_effect_tools(profile)
560
+ payload[:side_effect_tools] = derived unless derived.nil?
561
+ json_response(200, payload)
562
+ end
563
+
564
+ # The derived side-effect set for an agent: its reachable tools (the same
565
+ # allowlist − deny resolution the `tools` field uses) that the registry marks
566
+ # `side_effect`, sorted for a stable answer. -> [names] | nil (no registry).
567
+ def side_effect_tools(profile)
568
+ return nil unless @tool_registry
569
+
570
+ allowed = if profile.tools_allow.nil? then Array(@tool_registry.names)
571
+ else Array(profile.tools_allow).map(&:to_s)
572
+ end
573
+ denied = Array(profile.tools_deny).map(&:to_s)
574
+ (allowed - denied).select { |name| @tool_registry.side_effect?(name) }.sort
485
575
  end
486
576
 
487
577
  # `/v1` only — `/a2a` is versioned by its own JSON-RPC spec and a channel's
@@ -887,7 +977,7 @@ TENANT_SURFACES = [
887
977
  parts = payload[:parts] || payload["parts"]
888
978
  if parts && !Insika::Media.well_formed?(parts)
889
979
  raise Insika::ValidationError,
890
- "malformed content part — each part must be {type: text|image|audio} with text/url"
980
+ "malformed content part — each part must be {type: text|image|audio|document} with text/url"
891
981
  end
892
982
  # WS9: the only declared message source is "voice" (pre-transcribed
893
983
  # audio) — a consumer that writes prose here is told so, not silently.
@@ -59,8 +59,10 @@ module Insika
59
59
  pending_action_store: @graph.pending_action_store,
60
60
  provisioner: Insika::PackImporter.new(bus: @graph.bus, profiles: @graph.profiles),
61
61
  # GET /v1/agents/:id — the read-only capability view a case's `requires`
62
- # resolves against.
62
+ # resolves against (and the derived side-effect set an eval profile must
63
+ # cover).
63
64
  profiles: @graph.profiles,
65
+ tool_registry: @graph.tool_registry,
64
66
  # the OSS onboarding surface (start.md + models.json + docs).
65
67
  # This is the primary "build my first agent" target — models.json reports the
66
68
  # DSL's stores + the agents this process serves (each id IS the `model`).
@@ -76,6 +78,8 @@ module Insika
76
78
  config: { gateway_token: @token, tenancy: tenancy }.merge(@config),
77
79
  token_store: store,
78
80
  outcome_store: @graph.outcome_store,
81
+ # GET/PUT/DELETE /v1/mcp[/:name] — the config surface.
82
+ mcp_store: @rt.component(:mcp_store),
79
83
  # a 500's error_ref must be findable in the process log.
80
84
  logger: $stdout
81
85
  )
@@ -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
@@ -53,7 +53,7 @@ module Insika
53
53
  if raw.is_a?(Array)
54
54
  unless Insika::Media.well_formed?(raw)
55
55
  raise Insika::ValidationError,
56
- "malformed content part — each part must be {type: text|image|audio} with text/url"
56
+ "malformed content part — each part must be {type: text|image|audio|document} with text/url"
57
57
  end
58
58
 
59
59
  normalized = Insika::Media.parts(raw).map do |p|
@@ -23,11 +23,11 @@ module Insika
23
23
  KEY_PREFIX = "session:"
24
24
 
25
25
  Session = Data.define(:id, :messages, :vars, :memory_refs,
26
- :created_at, :updated_at, :briefing, :evidence) do
26
+ :created_at, :updated_at, :briefing, :evidence, :compaction) do
27
27
  # Trailing members with defaults: an old record without the "briefing" /
28
- # "evidence" keys reads as empty/nil without a migration.
28
+ # "evidence" / "compaction" keys reads as empty/nil without a migration.
29
29
  def initialize(id:, messages:, vars:, memory_refs:, created_at:, updated_at:,
30
- briefing: nil, evidence: nil)
30
+ briefing: nil, evidence: nil, compaction: nil)
31
31
  super
32
32
  end
33
33
  end
@@ -150,6 +150,35 @@ module Insika
150
150
  to_session(record)
151
151
  end
152
152
 
153
+ # -> Session. Persists the in-session compaction state (RFC-0044): the
154
+ # summary of messages[0...upto] plus the boundary. `upto` is MONOTONIC —
155
+ # a write that does not move the boundary forward is a no-op (a stale
156
+ # double-write from a racing worker can never move it backwards). `runs`
157
+ # counts compactions over the session's lifetime (the trace reports it).
158
+ #
159
+ # CONCURRENCY NOTE: an unlocked RMW (read -> mutate -> set), like
160
+ # update_briefing — and safe for the same reason: nothing in the
161
+ # read/mutate/set path suspends, so no other writer can interleave
162
+ # mid-RMW. The LLM call that produced the summary happened BEFORE this
163
+ # method; only the plain write lives here.
164
+ def set_compaction(id, summary:, upto:, model: nil)
165
+ record = fetch!(id)
166
+ current = record["compaction"]
167
+ upto = Integer(upto)
168
+ return to_session(record) if current && upto <= current["upto"].to_i
169
+
170
+ record["compaction"] = {
171
+ "summary" => Coercion.utf8(summary.to_s),
172
+ "upto" => upto,
173
+ "runs" => (current ? current["runs"].to_i : 0) + 1,
174
+ "model" => Coercion.presence(model.to_s),
175
+ "at" => timestamp
176
+ }.compact
177
+ record["updated_at"] = timestamp
178
+ @store.set(SCOPE, key_for(id), record)
179
+ to_session(record)
180
+ end
181
+
153
182
  # -> bool (delegates to the backend: false for a nonexistent id)
154
183
  def delete(id)
155
184
  @store.delete(SCOPE, key_for(id))
@@ -189,7 +218,8 @@ module Insika
189
218
  created_at: record["created_at"],
190
219
  updated_at: record["updated_at"],
191
220
  briefing: record["briefing"] || { "fields" => {}, "next_step" => nil },
192
- evidence: record["evidence"]
221
+ evidence: record["evidence"],
222
+ compaction: record["compaction"]
193
223
  )
194
224
  end
195
225
 
@@ -30,7 +30,14 @@ module Insika
30
30
  "max_retries" => 2,
31
31
  "turn_timeout" => 120,
32
32
  "tool_timeout" => 30,
33
- "compaction" => { "enabled" => false, "keep_last" => 20 },
33
+ # In-session compaction (RFC-0044): when a session's UNCOMPACTED
34
+ # transcript grows past `compact_after` messages, everything but the
35
+ # last `keep_last` is summarized (model -> compaction.model, else the
36
+ # platform utility_model) into one history fragment. `prompt` replaces
37
+ # the engine default wholesale (the distill convention). enabled: false
38
+ # = parity (nothing runs). Additive keys — reads overlay DEFAULTS.
39
+ "compaction" => { "enabled" => false, "keep_last" => 20,
40
+ "compact_after" => 40, "model" => nil },
34
41
  # Data lifecycle (WS8, phase 2): the RETENTION window in days. The
35
42
  # tick's Retention sweep purges sessions (+traces), terminal tasks
36
43
  # (+checkpoints), memory cells and outcomes older than this. nil/0 =
@@ -68,6 +68,18 @@ module Insika
68
68
  self
69
69
  end
70
70
 
71
+ # Appends roots at the END — lowest precedence, so a workspace skill always
72
+ # overrides a same-named one shipped by a plugin. This is how
73
+ # the plugin Loader's `skill_dirs` reach the catalog at boot; reloads only
74
+ # when something new actually arrived.
75
+ def add_roots(dirs)
76
+ added = Array(dirs).map { |d| File.expand_path(d.to_s) } - @roots.map { |r| File.expand_path(r) }
77
+ return self if added.empty?
78
+
79
+ @roots.concat(added)
80
+ reload
81
+ end
82
+
71
83
  # Per-agent allowlist: nil -> all | [] -> none | [names] -> subset. `agent`
72
84
  # selects WHICH body each allowed name resolves to (see #find); the allowlist is
73
85
  # by NAME either way, so specializing a skill never touches the allowlist.
@@ -50,7 +50,7 @@ module Insika
50
50
 
51
51
  Environment:
52
52
  INSIKA_URL base URL of the engine (default: http://localhost:9292)
53
- OPENCLAW_GATEWAY_TOKEN Bearer; falls back to ADMIN_TOKEN, then "local-demo"
53
+ INSIKA_GATEWAY_TOKEN Bearer; falls back to ADMIN_TOKEN, then "local-demo"
54
54
  TXT
55
55
 
56
56
  # Poisson inter-arrival seconds: `-mean * Math.log(1.0 - rand)`. Seeded,
@@ -245,7 +245,7 @@ module Insika
245
245
  " agent: #{@agent}",
246
246
  " shape: #{@envelope[:turns_per_hour]} turns/h poisson, #{@envelope[:session_turns]}-turn sessions, " \
247
247
  "cap #{@envelope[:concurrency_cap]}, #{@envelope[:duration_hours]}h (#{@envelope[:warmup_hours]}h warmup)",
248
- " sample: POST #{URI.join(target_url + '/', 'v1/responses')} model=openclaw:#{@agent} user=soak-1",
248
+ " sample: POST #{URI.join(target_url + '/', 'v1/responses')} model=insika:#{@agent} user=soak-1",
249
249
  " out: #{@out}/"
250
250
  ]
251
251
  unless @envelope.calibrated?
@@ -345,7 +345,7 @@ module Insika
345
345
 
346
346
  private
347
347
 
348
- def resolve_token(env) = env["OPENCLAW_GATEWAY_TOKEN"] || env["ADMIN_TOKEN"] || "local-demo"
348
+ def resolve_token(env) = env["INSIKA_GATEWAY_TOKEN"] || env["ADMIN_TOKEN"] || "local-demo"
349
349
 
350
350
  def install_traps
351
351
  %w[INT TERM].each { |sig| Signal.trap(sig) { @stop_reason = "interrupted" } }
@@ -489,7 +489,7 @@ module Insika
489
489
  req["Authorization"] = "Bearer #{@token}"
490
490
  req["Content-Type"] = "application/json"
491
491
  req["Accept"] = "text/event-stream"
492
- req.body = JSON.generate(model: "openclaw:#{agent}", user: user, stream: true, input: message)
492
+ req.body = JSON.generate(model: "insika:#{agent}", user: user, stream: true, input: message)
493
493
 
494
494
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
495
495
  ttfb = nil
@@ -69,14 +69,32 @@ module Insika
69
69
  inject! if @seen >= @expected
70
70
  end
71
71
 
72
+ # Tail-appends whatever is in the mailbox right now and reports how many. Called
73
+ # at a batch boundary (#inject!) and ONCE MORE by the Executor when the run ended
74
+ # with a message no boundary ever arrived for (a text-only turn closes no batch) —
75
+ # the append, the running count and the `:turn_steered` event belong in one place
76
+ # either way.
77
+ def absorb_pending!
78
+ texts = @actor.take_user_messages!
79
+ return 0 if texts.empty?
80
+
81
+ texts.each { |text| @chat.add_message(role: :user, content: @policy.frame(text)) }
82
+ @injected += texts.size
83
+ # Counts only, never content — the text is already in the transcript, which is the
84
+ # surface that is allowed to carry it. `task_id`/`session_id` are the event's meta.
85
+ @emit.call(:turn_steered, { count: texts.size, total: @injected })
86
+ texts.size
87
+ end
88
+
72
89
  private
73
90
 
74
91
  def open_batch(message)
75
92
  calls = field(message, :tool_calls)
76
93
  size = calls.respond_to?(:size) ? calls.size : 0
77
94
  # A message with no tool call is the model talking, not a batch: leave any
78
- # pending message where it is (the turn is about to end, and the Executor
79
- # releases it as a follow-up turn rather than answering it half-way).
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.
80
98
  return @expected = nil if size.zero?
81
99
 
82
100
  @expected = size
@@ -88,14 +106,7 @@ module Insika
88
106
  @expected = nil
89
107
  return if @halted # nothing will read it: leave it in the mailbox
90
108
 
91
- texts = @actor.take_user_messages!
92
- return if texts.empty?
93
-
94
- texts.each { |text| @chat.add_message(role: :user, content: @policy.frame(text)) }
95
- @injected += texts.size
96
- # Counts only, never content — the text is already in the transcript, which is the
97
- # surface that is allowed to carry it. `task_id`/`session_id` are the event's meta.
98
- @emit.call(:turn_steered, { count: texts.size, total: @injected })
109
+ absorb_pending!
99
110
  end
100
111
 
101
112
  def halt?(result) = defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)