insika 0.7.0 → 0.9.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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +191 -0
  3. data/README.md +9 -6
  4. data/bin/insika +44 -3
  5. data/docs/AGENTS.md +74 -14
  6. data/docs/API.md +73 -0
  7. data/docs/ARCHITECTURE.md +45 -44
  8. data/docs/ARTIFACTS.md +42 -0
  9. data/docs/CHANNELS.md +19 -2
  10. data/docs/CONTEXT.md +86 -38
  11. data/docs/DEPLOY.md +27 -7
  12. data/docs/EVALS.md +98 -8
  13. data/docs/FACTS.md +4 -0
  14. data/docs/KNOWLEDGE.md +7 -0
  15. data/docs/LOADTEST.md +15 -27
  16. data/docs/MEDIA.md +1 -1
  17. data/docs/OBSERVABILITY.md +48 -4
  18. data/docs/POLICY.md +14 -5
  19. data/docs/RELEASING.md +4 -0
  20. data/docs/RUNNING-LOCAL.md +2 -2
  21. data/docs/SECURITY.md +28 -2
  22. data/docs/SOAK.md +1 -1
  23. data/docs/TOOLS.md +150 -32
  24. data/docs/prompts/ADD-TOOL.md +12 -2
  25. data/docs/prompts/DIAGNOSE-TURN.md +3 -0
  26. data/docs/prompts/GO-LIVE.md +6 -4
  27. data/lib/insika/agent_profile.rb +47 -10
  28. data/lib/insika/channels/web/widget.js +33 -0
  29. data/lib/insika/channels/web.rb +5 -2
  30. data/lib/insika/chat_builder.rb +90 -37
  31. data/lib/insika/commands/agent_payload.rb +1 -1
  32. data/lib/insika/commands/run_distillation.rb +5 -8
  33. data/lib/insika/commands/seed_session.rb +118 -0
  34. data/lib/insika/compaction.rb +196 -0
  35. data/lib/insika/context/builder.rb +35 -11
  36. data/lib/insika/context/fragment.rb +4 -1
  37. data/lib/insika/context/priority.rb +8 -0
  38. data/lib/insika/context/provider.rb +5 -0
  39. data/lib/insika/context/providers/briefing.rb +61 -29
  40. data/lib/insika/context/providers/fence_notice.rb +27 -0
  41. data/lib/insika/context/providers/knowledge.rb +7 -4
  42. data/lib/insika/context/providers/memory.rb +8 -4
  43. data/lib/insika/context/providers/session.rb +50 -10
  44. data/lib/insika/context_trace_store.rb +11 -1
  45. data/lib/insika/doctor.rb +213 -10
  46. data/lib/insika/dsl/runtime.rb +5 -0
  47. data/lib/insika/dsl.rb +6 -0
  48. data/lib/insika/edge_limiter.rb +4 -1
  49. data/lib/insika/env_schema.rb +5 -6
  50. data/lib/insika/errors.rb +1 -0
  51. data/lib/insika/evals/assertions.rb +92 -6
  52. data/lib/insika/evals/golden.rb +91 -2
  53. data/lib/insika/evals/runner.rb +20 -0
  54. data/lib/insika/evals/simulator.rb +11 -2
  55. data/lib/insika/evals/transport.rb +118 -16
  56. data/lib/insika/evidence.rb +79 -12
  57. data/lib/insika/executor.rb +94 -23
  58. data/lib/insika/fence.rb +96 -0
  59. data/lib/insika/golden_store.rb +3 -0
  60. data/lib/insika/loop_detector.rb +5 -34
  61. data/lib/insika/mcp_store.rb +5 -2
  62. data/lib/insika/mcp_tool_registry.rb +8 -1
  63. data/lib/insika/memory_store.rb +12 -0
  64. data/lib/insika/overlay_tool_registry.rb +5 -0
  65. data/lib/insika/prefix_fingerprint.rb +32 -27
  66. data/lib/insika/profile_source.rb +8 -0
  67. data/lib/insika/server/app.rb +43 -1
  68. data/lib/insika/server/rack_app.rb +2 -0
  69. data/lib/insika/server/responses.rb +35 -8
  70. data/lib/insika/session_store.rb +38 -5
  71. data/lib/insika/settings_store.rb +18 -2
  72. data/lib/insika/soak/runner.rb +4 -4
  73. data/lib/insika/spoken_transcript.rb +31 -0
  74. data/lib/insika/studio/app.rb +34 -8
  75. data/lib/insika/studio/forms.rb +29 -3
  76. data/lib/insika/studio/views/_agent_tab_config.erb +5 -1
  77. data/lib/insika/studio/views/session.erb +1 -1
  78. data/lib/insika/studio/views/settings.erb +11 -0
  79. data/lib/insika/studio/views/tool_edit.erb +6 -2
  80. data/lib/insika/telemetry/recorder.rb +61 -1
  81. data/lib/insika/templates/daily-digest/README.md +9 -0
  82. data/lib/insika/templates/research-analyst/agent.rb +10 -0
  83. data/lib/insika/tool_assembly.rb +21 -13
  84. data/lib/insika/tool_batch.rb +67 -0
  85. data/lib/insika/tool_definition.rb +73 -10
  86. data/lib/insika/tool_envelope.rb +102 -2
  87. data/lib/insika/tool_store.rb +9 -4
  88. data/lib/insika/tool_trace_store.rb +1 -1
  89. data/lib/insika/tool_usage_report.rb +172 -0
  90. data/lib/insika/tools/data_defined_tool.rb +1 -0
  91. data/lib/insika/tools/present.rb +122 -0
  92. data/lib/insika/tools/run_persona_eval.rb +6 -1
  93. data/lib/insika/tools/tool_search.rb +4 -2
  94. data/lib/insika/turn_budget.rb +91 -0
  95. data/lib/insika/turn_state.rb +13 -1
  96. data/lib/insika/version.rb +1 -1
  97. data/lib/insika/wiring/graph.rb +7 -0
  98. data/lib/insika/wiring/graph_chat.rb +4 -0
  99. data/lib/insika.rb +11 -0
  100. metadata +10 -1
@@ -50,9 +50,7 @@ module Insika
50
50
  @streak = 0
51
51
  @intervened = false # the ONE warning of this turn has been delivered
52
52
  @pending = false # detection fired; waiting for the batch boundary
53
- @expected = nil # tool calls announced by the batch in flight
54
- @seen = 0
55
- @halted = false
53
+ @batch = ToolBatch.new
56
54
  end
57
55
 
58
56
  # From ChatBuilder's before_tool_call. Raises BEFORE the call executes once
@@ -80,41 +78,21 @@ module Insika
80
78
 
81
79
  # From ChatBuilder's after_tool_result, with the RAW result — the only place
82
80
  # a Tool::Halt is still recognizable (SteerInjector's comment applies here).
83
- def tool_result(result)
84
- @halted = true if defined?(RubyLLM::Tool::Halt) && result.is_a?(RubyLLM::Tool::Halt)
85
- end
81
+ def tool_result(result) = @batch.halt!(result)
86
82
 
87
83
  # RubyLLM after_message. An assistant message carrying tool calls OPENS a
88
84
  # batch; the Nth tool result CLOSES it — the one boundary where appending
89
- # is valid.
85
+ # is valid (ToolBatch owns that arithmetic; TurnBudget follows the same rule).
90
86
  def message_ended(message)
91
- role = field(message, :role).to_s
92
- return open_batch(message) if role == "assistant"
93
- return unless role == "tool" && @expected
94
-
95
- @seen += 1
96
- intervene! if @seen >= @expected
87
+ intervene! if @batch.closed?(message)
97
88
  end
98
89
 
99
90
  private
100
91
 
101
- def open_batch(message)
102
- calls = field(message, :tool_calls)
103
- size = calls.respond_to?(:size) ? calls.size : 0
104
- # No tool call = the model talking; the turn is ending and a pending
105
- # warning is moot — the loop resolved itself.
106
- return @expected = nil if size.zero?
107
-
108
- @expected = size
109
- @seen = 0
110
- @halted = false
111
- end
112
-
113
92
  def intervene!
114
- @expected = nil
115
93
  return unless @pending
116
94
  @pending = false
117
- return if @halted # nothing will read it (halt_when): drop, never deliver
95
+ return if @batch.halted? # nothing will read it (halt_when): drop, never deliver
118
96
 
119
97
  @intervened = true
120
98
  name, = @last
@@ -132,12 +110,5 @@ module Insika
132
110
  else value
133
111
  end
134
112
  end
135
-
136
- def field(message, name)
137
- return message.public_send(name) if message.respond_to?(name)
138
- return message[name] || message[name.to_s] if message.respond_to?(:[])
139
-
140
- nil
141
- end
142
113
  end
143
114
  end
@@ -135,7 +135,7 @@ module Insika
135
135
 
136
136
  def http_like?(transport) = %w[http sse].include?(transport.to_s)
137
137
 
138
- # -> [{"name","description","inputSchema"}] string-keyed, dropping any
138
+ # -> [{"name","description","inputSchema"[,"annotations"]}] string-keyed, dropping any
139
139
  # entry without a name (nothing to register a Registry::Entry under).
140
140
  def normalize_tools_cache(tools)
141
141
  Array(tools).filter_map do |t|
@@ -143,7 +143,10 @@ module Insika
143
143
  name = presence(h["name"])
144
144
  next nil if name.nil?
145
145
 
146
- { "name" => name, "description" => h["description"].to_s, "inputSchema" => h["inputSchema"] || {} }
146
+ cached = { "name" => name, "description" => h["description"].to_s, "inputSchema" => h["inputSchema"] || {} }
147
+ # the server's own hints (readOnlyHint decides side_effect at registration)
148
+ cached["annotations"] = h["annotations"] if h["annotations"].is_a?(Hash)
149
+ cached
147
150
  end
148
151
  end
149
152
 
@@ -71,11 +71,18 @@ module Insika
71
71
  instance = record["name"]
72
72
  Insika::Registry::Entry.new(
73
73
  name: tool["name"], plugin: "mcp:#{instance}",
74
- metadata: { optional: false, side_effect: true, group: "mcp:#{instance}", tags: [] },
74
+ metadata: { optional: false, side_effect: !read_only?(tool), group: "mcp:#{instance}", tags: [] },
75
75
  factory: -> { build_tool(record, tool) }
76
76
  )
77
77
  end
78
78
 
79
+ # An MCP tool is a side effect unless its server says otherwise
80
+ # (`annotations.readOnlyHint`): a write is never re-run on resume and runs
81
+ # serially within the session; a declared read keeps `tool_concurrency`.
82
+ def read_only?(tool)
83
+ Coercion.truthy?(tool.dig("annotations", "readOnlyHint"))
84
+ end
85
+
79
86
  # Lazy require (McpLiveTool < RubyLLM::Tool pulls in ruby_llm) — kept out
80
87
  # of insika.rb load-time, loaded on the 1st instance (turn time), same
81
88
  # discipline as OverlayToolRegistry#build_tool for data-tools.
@@ -42,6 +42,18 @@ module Insika
42
42
  # ids live in a different namespace in practice.
43
43
  SESSION_TAG = "chat"
44
44
 
45
+ # The ONE memory-cell rule, read by the turn (Executor) and by whoever
46
+ # writes a cell the turn must find (SeedSession): a customer -> the
47
+ # "[tenant:]customer" cell; otherwise the tenant's cell; otherwise the marked
48
+ # per-session cell "chat:<session id>" (nil for a one-shot turn with no
49
+ # session — the store applies _default).
50
+ def self.scope_for(tenant:, customer:, session_id:)
51
+ return [tenant, customer].compact.join(":") if customer
52
+ return tenant if tenant
53
+
54
+ "#{SESSION_TAG}:#{session_id}" if session_id
55
+ end
56
+
45
57
  Fact = Data.define(:key, :value, :origin, :created_at, :updated_at, :expires_at)
46
58
  Note = Data.define(:id, :text, :created_at)
47
59
 
@@ -112,6 +112,11 @@ module Insika
112
112
  # Lazy require: DataDefinedTool inherits from RubyLLM::Tool (pulls in the gem) -> kept
113
113
  # out of insika.rb load-time, loaded on the 1st instance (turn time).
114
114
  def build_tool(definition)
115
+ if definition.presentation?
116
+ require_relative "tools/present"
117
+ return Insika::Tools::Present.new(definition: definition, event_stream: @event_stream)
118
+ end
119
+
115
120
  require_relative "tools/data_defined_tool"
116
121
  Insika::Tools::DataDefinedTool.new(
117
122
  definition: definition, http: @http, egress: @egress,
@@ -9,47 +9,52 @@ module Insika
9
9
  # (name + description + parameters.inspect, executor.rb:957). Outputs are
10
10
  # PII-free digests: it never sees message text, only hashes leave this class.
11
11
  # Pure stdlib (digest/sha2), no gem, no IO.
12
+ #
13
+ # The chain follows the cache boundary: the cumulative "prefix" is over the
14
+ # IDENTITY categories + tool_schemas only — the bytes the cache breakpoint
15
+ # actually covers. Volatile categories (memory, knowledge, briefing, request)
16
+ # render below the breakpoint, so a change there is not a prefix invalidation;
17
+ # they still get their own digest (the trace shows them) but sit AFTER the
18
+ # "prefix" key, outside the chain.
12
19
  class PrefixFingerprint
13
20
  # -> { "prompt" => "sha256…", …, "tool_schemas" => "sha256…",
14
- # "prefix" => "sha256…" }
15
- # Keys are the demodulized, downcased provider ids, in render order.
21
+ # "prefix" => "sha256…", "memory" => "sha256…", … }
22
+ # Keys are the demodulized, downcased provider ids. Order: identity
23
+ # categories (render order), "tool_schemas", "prefix", then the volatile
24
+ # categories — everything before "prefix" is the chain.
16
25
  # A category with no fragments is absent (not an empty hash).
17
26
  # The category digest = SHA256 of the category's fragments joined "\n\n"
18
27
  # (the Builder's separator — the digest matches rendered bytes).
19
- # "prefix" = SHA256 of the category digests concatenated IN CHAIN ORDER
20
- # (identity categories, then volatile, then tool_schemas) — any divergence
21
- # anywhere above the boundary changes it.
28
+ # "prefix" = SHA256 of the chain digests concatenated in order — any
29
+ # divergence anywhere above the boundary changes it. A fragment with no
30
+ # layer stamp reads as volatile, like everywhere else.
22
31
  def self.compute(system_fragments, tool_serial:)
23
- digests = {} # category name -> digest, insertion = render order
24
- grouped = system_fragments.group_by { |f| category(f.source) }
25
- grouped.each do |name, frags|
26
- digests[name] = digest(frags.map(&:content).join("\n\n"))
27
- end
32
+ identity, volatile = system_fragments.partition { |f| (f.layer || :volatile) == :identity }
33
+ digests = category_digests(identity)
28
34
  digests["tool_schemas"] = digest(tool_serial.to_s) unless tool_serial.nil?
29
35
  digests["prefix"] = digest(digests.values.join) unless digests.empty?
30
- digests
36
+ digests.merge(category_digests(volatile))
31
37
  end
32
38
 
33
- # -> String | nil. The first category (in CURRENT chain order) whose digest
34
- # differs or is absent from `previous`; else the first PREVIOUS key now
35
- # absent from the current chain (a vanished block is a divergence too);
36
- # else nil. nil `previous` (first turn) -> nil. The returned name is a
37
- # category id — PII-free by construction.
38
- #
39
- # The cumulative "prefix" key is deliberately SKIPPED in the scan: its
40
- # digest changes whenever ANY category moves, so scanning it would shadow a
41
- # vanished block (every surviving category matches, "prefix" differs, and
42
- # the vanished-fallback below becomes unreachable — reporting `broke:
43
- # prefix` instead of the category that actually left).
39
+ # -> String | nil. nil when the cumulative "prefix" did not move (a
40
+ # volatile change is not an invalidation). Else the first chain category
41
+ # (in CURRENT chain order) whose digest differs or is absent from
42
+ # `previous`; else the first PREVIOUS chain key now absent from the current
43
+ # chain (a vanished block is a divergence too). nil `previous` (first turn)
44
+ # -> nil. The returned name is a category id — PII-free by construction.
44
45
  def self.invalidation_reason(current, previous)
45
46
  return nil unless previous.is_a?(Hash)
47
+ return nil if current["prefix"] == previous["prefix"]
46
48
 
47
- current.each_key do |name|
48
- next if name == "prefix"
49
+ chain(current).find { |name| previous[name] != current[name] } ||
50
+ (chain(previous) - current.keys).first
51
+ end
52
+
53
+ def self.chain(map) = map.keys.take_while { |k| k != "prefix" }
49
54
 
50
- return name unless previous[name] == current[name]
51
- end
52
- (previous.keys - current.keys).first
55
+ def self.category_digests(fragments)
56
+ fragments.group_by { |f| category(f.source) }
57
+ .transform_values { |frags| digest(frags.map(&:content).join("\n\n")) }
53
58
  end
54
59
 
55
60
  def self.category(source) = source.to_s.split("::").last.to_s.downcase
@@ -74,6 +74,13 @@ module Insika
74
74
 
75
75
  def delete(id) = @cs.delete(SCOPE, id.to_s)
76
76
 
77
+ # The STORED records, unbuilt (string keys, straight off the JSON
78
+ # round-trip). `all` runs every record through AgentProfile.build, which
79
+ # normalizes — and repairs — what it reads, so a caller that needs to judge
80
+ # the truth ON DISK (the doctor) cannot use it: a repaired-on-read profile
81
+ # is indistinguishable there from a well-formed record.
82
+ def all_raw = @cs.all(SCOPE)
83
+
77
84
  private
78
85
 
79
86
  # Rebuilds the AgentProfile from the record (the JSON round-trip turns symbols
@@ -108,6 +115,7 @@ module Insika
108
115
  # only a stored explicit false turns the discipline block off.
109
116
  tool_persistence: h[:tool_persistence],
110
117
  tool_output_compression: h[:tool_output_compression],
118
+ fencing: h[:fencing],
111
119
  # params/model_policy: the resolver tolerates string keys from
112
120
  # the JSON round-trip (ModelResolver#normalize_params / ModelPolicy), so no
113
121
  # re-symbolization needed here.
@@ -56,8 +56,13 @@ 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, tool_registry: nil, mcp_store: nil)
59
+ executor: nil, db_path: nil, tool_registry: nil, mcp_store: nil,
60
+ settings_store: nil)
60
61
  @command_bus = command_bus
62
+ # READ for one gate: `POST /v1/conversations/:id/seed` answers only while
63
+ # the platform setting `evals.seeding` is on. nil = no settings = seeding
64
+ # off (fail-closed — the base wiring has no SettingsStore and cannot seed).
65
+ @settings_store = settings_store
61
66
  @event_stream = event_stream
62
67
  @session_store = session_store
63
68
  @task_store = task_store
@@ -132,6 +137,8 @@ module Insika
132
137
  error_response(422, e)
133
138
  rescue Insika::NotFoundError => e
134
139
  error_response(404, e)
140
+ rescue Insika::ConflictError => e
141
+ error_response(409, e) # the write contradicts existing state (a seed on a used conversation)
135
142
  rescue Async::TimeoutError => e
136
143
  error_response(504, e) # synchronous control request exceeded the ceiling
137
144
  rescue StandardError => e
@@ -186,6 +193,8 @@ module Insika
186
193
  handle_trigger_workflow(req, name)
187
194
  in ["POST", ["v1", "responses"]]
188
195
  handle_responses(req)
196
+ in ["POST", ["v1", "conversations", id, "seed"]]
197
+ handle_seed(req, id)
189
198
  in ["POST", ["v1", "outcomes"]] if @outcome_store
190
199
  handle_record_outcome(req)
191
200
  in ["GET", ["v1", "outcomes"]] if @outcome_store
@@ -427,6 +436,38 @@ module Insika
427
436
  tenant: tenant)
428
437
  end
429
438
 
439
+ # POST /v1/conversations/:id/seed — loads the snapshot an eval case starts from
440
+ # (its `state:` — evidence ids, memory facts/notes, history, briefing fields)
441
+ # into the conversation BEFORE its first turn. Body = the state mapping, plus an
442
+ # optional `customer` (the memory scope the turns will carry). Same Bearer as
443
+ # /v1/responses and the same id namespacing for a tenant, so the seeded session
444
+ # IS the one the turn continues. 200 {session}; 409 when the conversation
445
+ # already has messages (seeding a used one is a test bug, not a merge).
446
+ #
447
+ # Refused (auth error) unless the platform setting `evals.seeding` is on. A seeded
448
+ # conversation is a fabricated precondition written under the tenant token:
449
+ # right on the machine running snapshot evals, wrong in production — so the
450
+ # default is off and the doctor warns while it is on.
451
+ def handle_seed(req, id)
452
+ return auth_error(403, "seeding is off (settings evals.seeding)") unless seeding_enabled?
453
+
454
+ body = parse_body(req)
455
+ tenant = req_tenant(req)
456
+ # the conversation id as the TURN will send it in `user` — the path
457
+ # segment arrives percent-encoded (the eval transport encodes "loja:c1").
458
+ conv = URI.decode_www_form_component(id)
459
+ payload = { id: scoped_session_id(tenant, conv), state: body.except(:customer) }
460
+ (customer = Insika::Coercion.presence(body[:customer])) && (payload[:customer] = customer)
461
+ command = Insika::Command.build(:seed_session, payload, transport: :http, tenant: tenant)
462
+ session = dispatch_with_timeout(command)
463
+ json_response(200, { session: session.to_h })
464
+ end
465
+
466
+ def seeding_enabled?
467
+ settings = @settings_store&.get
468
+ settings.is_a?(Hash) && (settings["evals"] || {})["seeding"] == true
469
+ end
470
+
430
471
  # POST /v1/agents — provisions (upserts) an agent from a standardized
431
472
  # PACK. Same Bearer as /v1/responses (gateway_token,
432
473
  # fail-closed). The consumer (GatewayClient/ProvisionStore) sends the pack as
@@ -625,6 +666,7 @@ TENANT_SURFACES = [
625
666
  ["POST", ["v1", "sessions"]],
626
667
  ["POST", ["v1", "messages"]],
627
668
  ["POST", ["v1", "responses"]],
669
+ ["POST", ["v1", "conversations", nil, "seed"]],
628
670
  ["POST", ["v1", "outcomes"]],
629
671
  ["GET", ["v1", "outcomes"]],
630
672
  ["POST", ["v1", "workflows", nil]],
@@ -80,6 +80,8 @@ module Insika
80
80
  outcome_store: @graph.outcome_store,
81
81
  # GET/PUT/DELETE /v1/mcp[/:name] — the config surface.
82
82
  mcp_store: @rt.component(:mcp_store),
83
+ # POST /v1/conversations/:id/seed answers only while `evals.seeding` is on.
84
+ settings_store: @rt.component(:settings_store),
83
85
  # a 500's error_ref must be findable in the process log.
84
86
  logger: $stdout
85
87
  )
@@ -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
@@ -95,7 +95,7 @@ module Insika
95
95
  end
96
96
 
97
97
  # Turn Event -> OpenAI Responses SSE frame | nil (event with no
98
- # counterpart: :task_started, :tool_result, :skill_activated, ...).
98
+ # counterpart: :task_started, :skill_activated, ...).
99
99
  # Terminal events emit the final frame + `[DONE]` (close the stream).
100
100
  def frame_for(event)
101
101
  case event.type
@@ -103,9 +103,27 @@ module Insika
103
103
  sse("response.output_text.delta",
104
104
  { type: "response.output_text.delta", delta: event.data[:delta].to_s })
105
105
  when :tool_call
106
- sse("response.output_item.added",
107
- { type: "response.output_item.added",
108
- item: { type: "function_call", name: event.data[:name].to_s } })
106
+ item = { type: "function_call", name: event.data[:name].to_s }
107
+ # The provider's call id, as the OpenAI item carries it: `added` and `done`
108
+ # are TWO frames of ONE call, and a consumer pairs them by this. Absent when
109
+ # the emitter had none.
110
+ (id = event.data[:call_id]) && (item[:call_id] = id.to_s)
111
+ # The call's arguments, as the OpenAI item carries them (a JSON string).
112
+ # Absent when the emitter had none to report.
113
+ (args = event.data[:arguments]) && (item[:arguments] = args.is_a?(String) ? args : JSON.generate(args))
114
+ sse("response.output_item.added", { type: "response.output_item.added", item: item })
115
+ when :tool_result
116
+ # How the call ENDED — ok / error / blocked (+ the gate that held it). Until
117
+ # this frame the stream carried tool NAMES only, so nothing outside the
118
+ # process could tell "the tool ran" from "a gate refused it" or "it errored".
119
+ # The result body itself stays inside: it is the model's input, not the
120
+ # consumer's answer. Same `call_id` as the `added` frame — a consumer that
121
+ # counts calls counts `added`, not both.
122
+ item = { type: "function_call", name: event.data[:name].to_s,
123
+ status: (event.data[:status] || "ok").to_s }
124
+ (id = event.data[:call_id]) && (item[:call_id] = id.to_s)
125
+ (gate = event.data[:gate]) && (item[:gate] = gate.to_s)
126
+ sse("response.output_item.done", { type: "response.output_item.done", item: item })
109
127
  when :task_completed
110
128
  completed(event) + done
111
129
  when :task_failed
@@ -149,6 +167,15 @@ module Insika
149
167
  # Studio + the trace. Explicit (not a fall-through) to keep the closed
150
168
  # catalog honest.
151
169
  nil
170
+ when :ui
171
+ # A presentation tool's selection: what the customer should SEE alongside
172
+ # the answer. Namespaced like `insika.intermediate` — no OpenAI Responses
173
+ # counterpart, unknown to strict clients, safely ignored. `items` are the
174
+ # cards the engine validated and joined (id/url/caption); `dropped` is what
175
+ # the model asked for and could not be shown, with the reason.
176
+ sse("insika.ui", { type: "insika.ui", component: event.data[:component].to_s,
177
+ title: event.data[:title], items: Array(event.data[:items]),
178
+ count: event.data[:count].to_i, dropped: Array(event.data[:dropped]) })
152
179
  when :ttft
153
180
  # the live TTFB signal (WS6, INSIKA_TURN_TIMING opt-in): the provider's
154
181
  # ms-to-first-token, emitted when the first content chunk arrives.
@@ -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
@@ -101,12 +101,15 @@ module Insika
101
101
  # appends this turn's evidence (ids + ungrounded delta) to the
102
102
  # session record. RMW like append_messages — the SessionActor serializes
103
103
  # same-session turns; the copy is in the method comment.
104
- def append_evidence(id, ids:, ungrounded:)
104
+ def append_evidence(id, ids:, ungrounded:, cards: [])
105
105
  record = fetch!(id)
106
106
  ev = record["evidence"] ||= { "ids" => [], "ungrounded" => 0 }
107
107
  fresh = (ev["ids"] + Array(ids).map(&:to_s).reject(&:empty?)).uniq.last(EvidenceLedger::MAX_IDS)
108
108
  ev["ids"] = fresh
109
109
  ev["ungrounded"] = ev["ungrounded"].to_i + ungrounded.to_i
110
+ # the cards those ids came with (one per id, newest wins) — what a
111
+ # presentation tool joins on in a LATER turn. Absent until a card arrives.
112
+ ev["cards"] = EvidenceLedger.merge_cards(ev["cards"], cards) unless Array(cards).empty?
110
113
  record["updated_at"] = timestamp
111
114
  @store.set(SCOPE, key_for(id), record)
112
115
  to_session(record)
@@ -150,6 +153,35 @@ module Insika
150
153
  to_session(record)
151
154
  end
152
155
 
156
+ # -> Session. Persists the in-session compaction state (RFC-0044): the
157
+ # summary of messages[0...upto] plus the boundary. `upto` is MONOTONIC —
158
+ # a write that does not move the boundary forward is a no-op (a stale
159
+ # double-write from a racing worker can never move it backwards). `runs`
160
+ # counts compactions over the session's lifetime (the trace reports it).
161
+ #
162
+ # CONCURRENCY NOTE: an unlocked RMW (read -> mutate -> set), like
163
+ # update_briefing — and safe for the same reason: nothing in the
164
+ # read/mutate/set path suspends, so no other writer can interleave
165
+ # mid-RMW. The LLM call that produced the summary happened BEFORE this
166
+ # method; only the plain write lives here.
167
+ def set_compaction(id, summary:, upto:, model: nil)
168
+ record = fetch!(id)
169
+ current = record["compaction"]
170
+ upto = Integer(upto)
171
+ return to_session(record) if current && upto <= current["upto"].to_i
172
+
173
+ record["compaction"] = {
174
+ "summary" => Coercion.utf8(summary.to_s),
175
+ "upto" => upto,
176
+ "runs" => (current ? current["runs"].to_i : 0) + 1,
177
+ "model" => Coercion.presence(model.to_s),
178
+ "at" => timestamp
179
+ }.compact
180
+ record["updated_at"] = timestamp
181
+ @store.set(SCOPE, key_for(id), record)
182
+ to_session(record)
183
+ end
184
+
153
185
  # -> bool (delegates to the backend: false for a nonexistent id)
154
186
  def delete(id)
155
187
  @store.delete(SCOPE, key_for(id))
@@ -189,7 +221,8 @@ module Insika
189
221
  created_at: record["created_at"],
190
222
  updated_at: record["updated_at"],
191
223
  briefing: record["briefing"] || { "fields" => {}, "next_step" => nil },
192
- evidence: record["evidence"]
224
+ evidence: record["evidence"],
225
+ compaction: record["compaction"]
193
226
  )
194
227
  end
195
228
 
@@ -30,7 +30,18 @@ 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 },
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 },
34
45
  # Data lifecycle (WS8, phase 2): the RETENTION window in days. The
35
46
  # tick's Retention sweep purges sessions (+traces), terminal tasks
36
47
  # (+checkpoints), memory cells and outcomes older than this. nil/0 =
@@ -74,7 +85,12 @@ module Insika
74
85
  "aggregate" => "median",
75
86
  "min_agreement" => 0.5,
76
87
  "quorum" => 1,
77
- "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
78
94
  },
79
95
  # Edge limits — the platform layer of the EdgeLimiter.
80
96
  # nil/0 = off (opt-in). chat_rate_limit = turn attempts per chat per
@@ -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
@@ -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