insika 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/README.md +4 -3
  4. data/bin/insika +43 -2
  5. data/docs/AGENTS.md +22 -3
  6. data/docs/ARTIFACTS.md +42 -0
  7. data/docs/CONTEXT.md +54 -12
  8. data/docs/DEPLOY.md +16 -7
  9. data/docs/LOADTEST.md +15 -27
  10. data/docs/MEDIA.md +1 -1
  11. data/docs/OBSERVABILITY.md +31 -2
  12. data/docs/POLICY.md +10 -4
  13. data/docs/RUNNING-LOCAL.md +2 -2
  14. data/docs/SECURITY.md +1 -1
  15. data/docs/SOAK.md +1 -1
  16. data/docs/TOOLS.md +24 -0
  17. data/docs/prompts/GO-LIVE.md +3 -3
  18. data/lib/insika/agent_profile.rb +26 -1
  19. data/lib/insika/chat_builder.rb +28 -17
  20. data/lib/insika/compaction.rb +196 -0
  21. data/lib/insika/context/builder.rb +6 -2
  22. data/lib/insika/context/fragment.rb +4 -1
  23. data/lib/insika/context/priority.rb +6 -0
  24. data/lib/insika/context/providers/briefing.rb +53 -24
  25. data/lib/insika/context/providers/session.rb +46 -10
  26. data/lib/insika/context_trace_store.rb +11 -1
  27. data/lib/insika/doctor.rb +105 -10
  28. data/lib/insika/dsl/runtime.rb +4 -0
  29. data/lib/insika/env_schema.rb +5 -6
  30. data/lib/insika/evals/transport.rb +1 -1
  31. data/lib/insika/executor.rb +64 -0
  32. data/lib/insika/loop_detector.rb +5 -34
  33. data/lib/insika/profile_source.rb +7 -0
  34. data/lib/insika/server/responses.rb +4 -4
  35. data/lib/insika/session_store.rb +34 -4
  36. data/lib/insika/settings_store.rb +8 -1
  37. data/lib/insika/soak/runner.rb +4 -4
  38. data/lib/insika/studio/app.rb +28 -6
  39. data/lib/insika/studio/forms.rb +11 -0
  40. data/lib/insika/studio/views/settings.erb +11 -0
  41. data/lib/insika/telemetry/recorder.rb +49 -1
  42. data/lib/insika/templates/daily-digest/README.md +9 -0
  43. data/lib/insika/templates/research-analyst/agent.rb +10 -0
  44. data/lib/insika/tool_batch.rb +67 -0
  45. data/lib/insika/tool_usage_report.rb +162 -0
  46. data/lib/insika/turn_budget.rb +91 -0
  47. data/lib/insika/version.rb +1 -1
  48. data/lib/insika.rb +7 -0
  49. metadata +5 -1
@@ -25,7 +25,9 @@ authoring writes (`:golden_written`, `:agent_file_written`, …), queue bookkeep
25
25
  channel delivery (`:channel_delivered` — see [Channels](CHANNELS.md))
26
26
  travel the same stream and are **ignored** by the bridge: they open no span and
27
27
  touch no instrument, because they are not part of a turn's latency or cost. Any
28
- other subscriber still sees them.
28
+ other subscriber still sees them. The one mid-turn event the bridge does consume
29
+ is `:tool_loop_intervened` — it feeds the `insika.tool.loop_intervened` counter
30
+ (no span: the intervention is a fact about the turn, not a timed operation).
29
31
 
30
32
  They are worth subscribing to even so, because each is the ONLY record of
31
33
  something that left no task of its own behind:
@@ -145,12 +147,32 @@ knows its outcome.
145
147
  | `insika.cost` | counter | `{USD}` | the turn's model is priced (see below) |
146
148
  | `insika.tool.calls` | counter | `{call}` | a tool call completes |
147
149
  | `insika.tool.duration` | histogram | `s` | a `tool_call`/`tool_result` pair completes |
150
+ | `insika.cache.hit_rate` | histogram | `%` | a turn reported billed prompt tokens (see below) |
151
+ | `insika.tool.loop_intervened` | counter | `{intervention}` | the loop detector delivered its one-shot warning |
152
+ | `insika.context.compacted` | counter | `{compaction}` | an in-session compaction was persisted (RFC-0044) |
148
153
 
149
154
  `insika.tool.duration` is deliberately **not** recorded for data-tools: those are a
150
155
  single point-in-time event, so there is no measured duration to report. A tool left
151
156
  open by a mid-turn failure is not counted as a completed call either — its span is
152
157
  closed, but a failed call must not inflate the success histogram.
153
158
 
159
+ `insika.cache.hit_rate` is the same arithmetic the Studio's per-agent series uses:
160
+ cache **reads** over the whole **billed** prompt (fresh input + cache reads + cache
161
+ writes), always in `[0,100]`. A turn with no billed prompt tokens records nothing —
162
+ absence is not a 0% hit. It is recorded per turn on the terminal event, so it
163
+ carries the turn labels (`insika.agent`, `insika.model`, …); the token-counter
164
+ recipe below still works and answers the fleet-wide version of the question.
165
+
166
+ `insika.tool.loop_intervened` counts deliveries of the loop detector's one
167
+ warning per turn (see [Agents](AGENTS.md) `max_tool_repeat`), labelled with
168
+ `insika.tool`. It counts interventions, not repeats: a turn contributes at most 1.
169
+
170
+ `insika.context.compacted` counts persisted in-session compactions
171
+ (`:context_compacted` — see [Context](CONTEXT.md)), labelled with
172
+ `insika.agent` and `insika.model` (the summarizer's model, not the turn's). It
173
+ fires post-turn, after the turn span already closed, so it deliberately rides
174
+ its own labels rather than the open-turn set.
175
+
154
176
  ## Attribute reference
155
177
 
156
178
  The same names are used on spans and on metrics. **Metrics carry a deliberate
@@ -267,7 +289,14 @@ climbing while `input` stays flat.
267
289
 
268
290
  **Cache hit ratio**
269
291
  `insika.tokens` filtered to `insika.token.type="cached"` over the same counter
270
- filtered to `input`. This is the number that moves your bill.
292
+ filtered to `input`. This is the number that moves your bill. For the per-turn
293
+ distribution (does every turn hit, or do fleet averages hide cold agents?), chart
294
+ `insika.cache.hit_rate` — p50 by `insika.agent`; a healthy agent sits near 100.
295
+
296
+ **Loop interventions**
297
+ `insika.tool.loop_intervened`, rate, grouped by `insika.agent` and `insika.tool`.
298
+ Any sustained non-zero rate means one tool keeps being retried with identical
299
+ arguments — fix the tool's contract or the prompt, not the detector.
271
300
 
272
301
  **Spend per tenant**
273
302
  `insika.cost`, rate (or `increase` over a billing window), grouped by
data/docs/POLICY.md CHANGED
@@ -18,13 +18,19 @@ matters, and editable hot.
18
18
  ## Layer 1: Tools (what it can call)
19
19
 
20
20
  `tools_allow` / `tools_deny` / `tools_allow_groups` decide which tools enter the
21
- turn's tool-loop, enforced by the tool-allowlist policy. See [Tools](TOOLS.md)
22
- for how tools are defined and registered, and [`examples/data-tool/`](https://github.com/guizaols/insika/tree/main/examples/data-tool/).
21
+ turn's tool-loop, enforced by the builtin `tool_allowlist` policy which the
22
+ engine adds for you the moment any of the three is declared, so you never have to
23
+ name it in `policies` (see [Agents](AGENTS.md#the-allowlist-convention)). See
24
+ [Tools](TOOLS.md) for how tools are defined and registered, and
25
+ [`examples/data-tool/`](https://github.com/guizaols/insika/tree/main/examples/data-tool/).
23
26
 
24
27
  ## Layer 2: Policies and approvals
25
28
 
26
- Policies are named entries evaluated before the turn runs. Builtins cover
27
- tool-, skill-, and workflow-allowlisting, plus **`ApprovalRequired`** which
29
+ Policies are named entries evaluated before the turn runs. **Only the policies a
30
+ profile names run** which is why `tool_allowlist` is added implicitly by a
31
+ declared tool list; an allowlist nobody applies is worse than no allowlist.
32
+ Builtins cover tool-, skill-, and workflow-allowlisting, plus
33
+ **`ApprovalRequired`** — which
28
34
  does not allow or deny but *tags* a tool as needing human approval. Set
29
35
  `approvals_required: [tool names]`; the gate then fires when the model tries to
30
36
  call that tool, suspending the turn until an operator approves it in the Studio.
@@ -62,7 +62,7 @@ whole surface answers `503`, never open by omission.
62
62
  | `INSIKA_DB` | — (ephemeral memory) | SQLite path → config + execution survive a restart |
63
63
  | `BIND` | `http://localhost:9292` | host:port |
64
64
  | `ADMIN_TOKEN` | `local-demo` | token for `/studio` |
65
- | `OPENCLAW_GATEWAY_TOKEN` | falls back to `ADMIN_TOKEN` | Bearer for the whole `/v1` + `/a2a` surface |
65
+ | `INSIKA_GATEWAY_TOKEN` | falls back to `ADMIN_TOKEN` | Bearer for the whole `/v1` + `/a2a` surface |
66
66
  | `DEEPSEEK_MODEL` | `deepseek-v4-flash` | model |
67
67
 
68
68
  With persistence:
@@ -128,7 +128,7 @@ Provision it (runs as a client against the live server; the internal token comes
128
128
  from the environment, never disk):
129
129
 
130
130
  ```bash
131
- INSIKA_URL=http://localhost:9292 OPENCLAW_GATEWAY_TOKEN=local-demo \
131
+ INSIKA_URL=http://localhost:9292 INSIKA_GATEWAY_TOKEN=local-demo \
132
132
  bundle exec ruby scripts/import_pack.rb /path/to/pack
133
133
  ```
134
134
 
data/docs/SECURITY.md CHANGED
@@ -27,7 +27,7 @@ The layers, from the edge inward:
27
27
  ## The Bearer gate
28
28
 
29
29
  The `/v1` and `/a2a` surface answers only with
30
- `Authorization: Bearer <OPENCLAW_GATEWAY_TOKEN>` (which falls back to `ADMIN_TOKEN`).
30
+ `Authorization: Bearer <INSIKA_GATEWAY_TOKEN>` (which falls back to `ADMIN_TOKEN`).
31
31
  The check runs in the router, **before** any dispatch, against an **allowlist** of
32
32
  public routes — so a route added later is closed until someone deliberately publishes
33
33
  it. Only these answer without a token:
data/docs/SOAK.md CHANGED
@@ -88,7 +88,7 @@ insika soak --dry-run --envelope soak-envelope.md
88
88
  # every precondition, and nothing else
89
89
  insika soak --preflight --envelope soak-envelope.md
90
90
 
91
- # the run itself (INSIKA_URL + OPENCLAW_GATEWAY_TOKEN, like loadtest.rb)
91
+ # the run itself (INSIKA_URL + INSIKA_GATEWAY_TOKEN, like loadtest.rb)
92
92
  INSIKA_URL=https://<target> insika soak --run --envelope soak-envelope.md --out soak-out/
93
93
 
94
94
  # resume after a short outage (the gap is recorded and counts against the window)
data/docs/TOOLS.md CHANGED
@@ -485,6 +485,30 @@ and gets the URL back; the tenant is bound from the turn, never a parameter the
485
485
  model types. See [Artifacts](ARTIFACTS.md) for the tool contract, the serving
486
486
  routes, the signed link and the retention/LGPD reach.
487
487
 
488
+ ## The usage report — `insika tools:report`
489
+
490
+ The per-session trace answers "what did this conversation call"; nothing used to
491
+ answer "what does this agent carry and never use". The report aggregates the
492
+ stored traces per agent (tasks → sessions → `tool_traces`, the same read the
493
+ Studio does) and flags three shapes:
494
+
495
+ - **`never_called`** — in `tools_allow`, zero calls in any stored trace. Dead
496
+ weight: its schema ships on every request and buys nothing.
497
+ - **`error_rate`** — over 30% conventional errors (the trace's `ok` flag) inside
498
+ the window (default 14 days). Either the tool is broken or the model cannot
499
+ hold its contract.
500
+ - **`stale`** — called at some point, but not once inside the window.
501
+
502
+ ```bash
503
+ insika tools:report # every stored agent
504
+ insika tools:report --agent store-support # one agent
505
+ insika tools:report --days 30 --json # wider window, machine-readable
506
+ ```
507
+
508
+ Read-only by design: the report names candidates, the **operator** removes — a
509
+ flagged tool may still be the one a rare but critical flow needs. Counts are
510
+ "at least", never exact: the trace keeps a capped tail per session.
511
+
488
512
  ## See also
489
513
 
490
514
  - [Agents](AGENTS.md) — allowlists, groups, and per-agent tool exposure.
@@ -40,7 +40,7 @@ convenience only:
40
40
  | Token | Gates | Rotating it |
41
41
  |---|---|---|
42
42
  | `ADMIN_TOKEN` | `/studio` login (the operator — just you) | safe, independent |
43
- | `OPENCLAW_GATEWAY_TOKEN` | Bearer for `/v1/responses` + `/v1/agents` (your API consumers) | both sides together, same step |
43
+ | `INSIKA_GATEWAY_TOKEN` | Bearer for `/v1/responses` + `/v1/agents` (your API consumers) | both sides together, same step |
44
44
 
45
45
  Generate each: `ruby -rsecurerandom -e 'puts SecureRandom.hex(24)'`. Set them as
46
46
  platform env vars. **Never** write either into a file, a commit, or your own output.
@@ -77,7 +77,7 @@ Any Docker host, same contract:
77
77
  ```bash
78
78
  docker build -t insika .
79
79
  docker run -p 9292:9292 -v insika-data:/data \
80
- -e DEEPSEEK_API_KEY=... -e ADMIN_TOKEN=... -e OPENCLAW_GATEWAY_TOKEN=... \
80
+ -e DEEPSEEK_API_KEY=... -e ADMIN_TOKEN=... -e INSIKA_GATEWAY_TOKEN=... \
81
81
  insika
82
82
  ```
83
83
 
@@ -92,7 +92,7 @@ In order, each with evidence:
92
92
 
93
93
  ```bash
94
94
  curl -N https://<host>/v1/responses \
95
- -H "Authorization: Bearer $OPENCLAW_GATEWAY_TOKEN" \
95
+ -H "Authorization: Bearer $INSIKA_GATEWAY_TOKEN" \
96
96
  -H "Content-Type: application/json" \
97
97
  -d '{"model":"<agent-id>","user":"go-live-check","input":"hello"}'
98
98
  ```
@@ -333,7 +333,9 @@ outputs: nil, stt_prompt: nil, briefing_fields: nil, grounding: nil, funnel: nil
333
333
  tools_deny: Array(tools_deny), tools_allow_groups: tools_allow_groups, skills: skills,
334
334
  skills_eager: skills_eager,
335
335
  context_providers: context_providers, workflows_allow: workflows_allow,
336
- policies: Array(policies), prompt_refs: Array(prompt_refs),
336
+ policies: normalize_policies(policies, tools_allow: tools_allow, tools_deny: tools_deny,
337
+ tools_allow_groups: tools_allow_groups),
338
+ prompt_refs: Array(prompt_refs),
337
339
  limits: DEFAULT_LIMITS.merge(limits), approvals_required: approvals_required,
338
340
  capabilities: capabilities,
339
341
  # opt-in like capabilities: nil => NONE. Array-normalize a present value so
@@ -400,6 +402,29 @@ outputs: nil, stt_prompt: nil, briefing_fields: nil, grounding: nil, funnel: nil
400
402
  )
401
403
  end
402
404
 
405
+ # Declaring a tool allow/deny list IS opting into it. The list is only ever
406
+ # applied by the builtin `tool_allowlist` policy, and the Policy::Engine runs
407
+ # ONLY the policies a profile names — so a profile with `tools_allow: [a, b]`
408
+ # and no policies sent EVERY registered tool to the model, silently. A
409
+ # declared allowlist that does nothing is the failure mode; same rule as the
410
+ # "mcp:<name>" group auto-added to `tools_allow_groups` by the DSL.
411
+ #
412
+ # Presence, not emptiness, is the trigger for the two nil-able lists:
413
+ # `tools_allow: []` means "no tools" and must enforce just as hard.
414
+ # `tools_deny` has no nil state (it defaults to []), so only a non-empty
415
+ # deny list counts as a declaration.
416
+ #
417
+ # Appended, never prepended: profile-declared policies keep their order. The
418
+ # engine intersects allows and unions denies, so position changes only the
419
+ # audit order, not the outcome.
420
+ def self.normalize_policies(policies, tools_allow:, tools_deny:, tools_allow_groups:)
421
+ names = Array(policies)
422
+ declared = !tools_allow.nil? || !tools_allow_groups.nil? || !Array(tools_deny).empty?
423
+ return names unless declared && names.none? { |n| n.to_s == "tool_allowlist" }
424
+
425
+ names + ["tool_allowlist"]
426
+ end
427
+
403
428
  # nil/absent -> nil; a single Hash -> [Hash]; else an Array of Hashes —
404
429
  # deep-stringified so JSON round-trips stay stable.
405
430
  def self.normalize_schedules(list)
@@ -332,20 +332,29 @@ module Insika
332
332
  end
333
333
 
334
334
  # RubyLLM's additive callbacks become events. load_skill becomes
335
- # :skill_activated. Adds the max_tool_calls counter: the loop is RubyLLM's;
336
- # here we only count and abort.
335
+ # :skill_activated. Hangs the turn's TurnBudget on them: the loop is
336
+ # RubyLLM's; here we only count, warn and abort.
337
337
  def wire_callbacks(chat, state, emit)
338
- # Per-TURN counter: safe as a closure local even under concurrent tool calls
339
- # (MRI fibers do not preempt between the read and the write). The per-CALL
340
- # correlation is NOT — it lives in fiber storage behind TurnState, because
341
- # each call gets its own fiber once tool concurrency is on.
342
- tool_calls = 0
343
- max_tool_calls = state.profile.limits[:max_tool_calls] || 50
338
+ # The turn's ONE tool-call counter. Safe as a closure-held object even
339
+ # under concurrent tool calls (MRI fibers do not preempt between the read
340
+ # and the write). The per-CALL correlation is NOT — it lives in fiber
341
+ # storage behind TurnState, because each call gets its own fiber once tool
342
+ # concurrency is on.
343
+ #
344
+ # It counts, warns at 10/5/2 calls remaining, and raises the
345
+ # `stage: :tool_limit` abort — the guard-rail that used to be inline here.
346
+ # Announcing the budget needs #add_message at the batch boundary; a chat
347
+ # without it (smoke shim, minimal double) still gets the count and the
348
+ # abort, just no notice.
349
+ budget = Insika::TurnBudget.new(
350
+ chat: chat, max: state.profile.limits[:max_tool_calls] || 50, emit: emit
351
+ )
344
352
 
345
353
  # the loop detector. Needs #after_message + #add_message for the
346
354
  # batch-boundary intervention; a chat without them (smoke shim, minimal
347
355
  # double) stays bounded by max_tool_calls alone — never half-wired.
348
- detector = if %i[after_message add_message].all? { |m| chat.respond_to?(m) }
356
+ appendable = %i[after_message add_message].all? { |m| chat.respond_to?(m) }
357
+ detector = if appendable
349
358
  repeat = state.profile.limits[:max_tool_repeat] || Insika::AgentProfile::DEFAULT_LIMITS[:max_tool_repeat]
350
359
  Insika::LoopDetector.new(chat: chat, limit: repeat, emit: emit) if repeat >= 2
351
360
  end
@@ -355,11 +364,7 @@ module Insika
355
364
  state.current_tool_call = tool_call
356
365
  # max_tool_calls guard-rail: stays inline (not as a registered hook)
357
366
  # because Hooks is shared across turns and has no unregister.
358
- tool_calls += 1
359
- if tool_calls > max_tool_calls
360
- raise Insika::TimeoutError.new("tool call limit exceeded (#{max_tool_calls})",
361
- stage: :tool_limit)
362
- end
367
+ budget.tool_call
363
368
 
364
369
  # AFTER the count, BEFORE the call runs — a post-warning
365
370
  # repeat raises here, so the stubborn loop pays for no extra call.
@@ -394,13 +399,19 @@ module Insika
394
399
  # the RAW result — the only place a Tool::Halt (halt_when) is
395
400
  # still recognizable, and a halted batch must receive no intervention.
396
401
  detector&.tool_result(result)
402
+ budget.tool_result(result)
397
403
  result = @hooks.run_after(:tool, result)
398
404
  emit.call(:tool_result, { name: state.current_tool_name, result: result.to_s })
399
405
  end
400
406
 
401
- # the intervention appends at the batch boundary (the Nth tool
402
- # result closing) — never between two tool results of one batch.
403
- chat.after_message { |message| detector.message_ended(message) } if detector
407
+ # both appends land at the batch boundary (the Nth tool result closing) —
408
+ # never between two tool results of one batch.
409
+ return unless appendable
410
+
411
+ chat.after_message do |message|
412
+ detector&.message_ended(message)
413
+ budget.message_ended(message)
414
+ end
404
415
  end
405
416
  end
406
417
  end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # In-session compaction: when a session's UNCOMPACTED transcript
5
+ # grows past `compact_after` messages, everything but the last `keep_last`
6
+ # is summarized by a cheap model into one fragment; the tail stays verbatim.
7
+ # This module is the pure half — boundary math, the prompt, and the
8
+ # Summarizer over an injected ask (the Distiller shape: unit-testable
9
+ # without a provider). The trigger lives in the Executor (post-turn, off
10
+ # the critical path); the persistence in SessionStore#set_compaction; the
11
+ # read path in Context::Providers::Session.
12
+ module Compaction
13
+ DEFAULT_KEEP_LAST = 20
14
+ DEFAULT_COMPACT_AFTER = 40
15
+ # A summary that outgrows this is truncated — the compaction must never
16
+ # grow the context it exists to shrink.
17
+ MAX_SUMMARY_CHARS = 6_000
18
+ # Per-message cap in the transcript slice sent to the summarizer (a
19
+ # `role: tool` body can be 4 000 chars in the store); the head of a long
20
+ # result carries the identity of what happened, which is what a summary needs.
21
+ MESSAGE_CHAR_CAP = 1_000
22
+
23
+ # The engine's generic prompt. A platform `compaction.prompt` REPLACES it
24
+ # wholesale (the distill convention) — the engine never writes store
25
+ # vocabulary. The preserve-list is the P28 contract: facts (CEP, order
26
+ # numbers), commitments, the MISSING list, decisions.
27
+ DEFAULT_PROMPT = <<~PROMPT.freeze
28
+ You are compacting the OLD part of an ongoing customer conversation into
29
+ one summary that the assistant will read INSTEAD of those messages. The
30
+ recent messages stay verbatim; your summary is the only surviving trace
31
+ of the old ones — anything you drop is gone for good.
32
+
33
+ Preserve, verbatim where short:
34
+ - every fact the customer stated (sizes, budget, address, postal code/CEP,
35
+ order numbers, product choices, dates, quantities);
36
+ - every commitment the assistant made (promises, prices quoted, delivery
37
+ windows, agreed next steps);
38
+ - what was asked and is still unanswered (the missing information);
39
+ - decisions already made, so nothing gets re-asked or re-litigated.
40
+
41
+ Do not invent, do not editorialize, do not add advice. Answer with the
42
+ summary text only — plain text, compact, in the conversation's own language.
43
+ PROMPT
44
+
45
+ # The compaction plan: summarize messages[from...upto] (from = the previous
46
+ # boundary), keep messages[upto..] verbatim. count = upto - from.
47
+ Plan = Data.define(:from, :upto, :count)
48
+
49
+ module_function
50
+
51
+ # Decides whether (and what) to compact. -> Plan | nil.
52
+ # messages: the session transcript (append-only, RFC-0016).
53
+ # state: the persisted "compaction" hash ({"upto"=>, ...}) | nil.
54
+ # config: the Settings "compaction" hash (keep_last/compact_after).
55
+ # `compact_after` is clamped to at least `keep_last` so the plan always
56
+ # moves the boundary forward. The boundary retreats over `role: "tool"`
57
+ # messages so an eviction unit (assistant-with-tool_calls + its results)
58
+ # is never split — the whole cycle stays verbatim instead.
59
+ def plan(messages:, state:, config:)
60
+ msgs = Array(messages)
61
+ keep_last = positive(config && config["keep_last"], DEFAULT_KEEP_LAST)
62
+ compact_after = positive(config && config["compact_after"], DEFAULT_COMPACT_AFTER)
63
+ compact_after = keep_last if compact_after < keep_last
64
+ from = state ? state["upto"].to_i : 0
65
+ return nil unless msgs.size - from > compact_after
66
+
67
+ upto = msgs.size - keep_last
68
+ upto -= 1 while upto > from && role_of(msgs[upto]) == "tool"
69
+ return nil unless upto > from
70
+
71
+ Plan.new(from: from, upto: upto, count: upto - from)
72
+ end
73
+
74
+ # The full prompt for one compaction run: the base rules, the PREVIOUS
75
+ # summary (so a fact from turn 3 survives every re-compaction — each
76
+ # summary folds the last one in) and only the NEW slice. The slice is
77
+ # sent UNREDACTED on purpose: it replaces transcript the main model
78
+ # already reads raw, inside the same trust boundary, and redaction would
79
+ # delete exactly the facts (CEP, order id) the summary must preserve.
80
+ def prompt(messages:, plan:, previous: nil, base: nil)
81
+ rules = Coercion.presence(base.to_s) || DEFAULT_PROMPT
82
+ parts = [rules.rstrip]
83
+ if Coercion.presence(previous.to_s)
84
+ parts << "## The summary so far (fold it into the new one — its facts must survive)\n\n#{previous}"
85
+ end
86
+ parts << "## The messages to compact\n\n#{transcript(messages, plan)}"
87
+ parts.join("\n\n")
88
+ end
89
+
90
+ # "[i] role: content" over the plan's slice, one line per message; a
91
+ # tool-calling assistant message with no text renders the tool names.
92
+ def transcript(messages, plan)
93
+ Array(messages)[plan.from...plan.upto].to_a.each_with_index.map do |msg, offset|
94
+ "[#{plan.from + offset}] #{role_of(msg)}: #{text_of(msg)}"
95
+ end.join("\n")
96
+ end
97
+
98
+ def role_of(msg) = (msg["role"] || msg[:role]).to_s
99
+
100
+ def text_of(msg)
101
+ content = (msg["content"] || msg[:content]).to_s.strip.gsub(/\s+/, " ")
102
+ if content.empty?
103
+ calls = msg["tool_calls"] || msg[:tool_calls]
104
+ names = Array(calls).filter_map { |c| c.is_a?(Hash) ? (c["name"] || c[:name] || c.dig("function", "name")) : nil }
105
+ content = names.empty? ? "(empty)" : "(tool calls: #{names.join(', ')})"
106
+ end
107
+ content[0, MESSAGE_CHAR_CAP]
108
+ end
109
+
110
+ def positive(value, default)
111
+ n = value.to_i
112
+ n.positive? ? n : default
113
+ end
114
+
115
+ # The one place compaction asks a model for anything. Pure over an
116
+ # injected `ask` (the Distiller shape); the real ask is a lambda built by
117
+ # SummarizerFactory (ruby_llm required lazily, load_guard stays green).
118
+ class Summarizer
119
+ # A blank answer must not overwrite the boundary — empty output is a
120
+ # loud failure, never "the old turns said nothing".
121
+ class Unusable < Insika::ValidationError; end
122
+
123
+ # ask: ->(prompt) { "<raw model text>" } | something answering #content
124
+ # (+ #input_tokens/#output_tokens/#cached_tokens for cost).
125
+ # model: the ref recorded on the event ("utility_model" default).
126
+ attr_reader :model
127
+
128
+ def initialize(ask:, model: "utility_model")
129
+ @ask = ask
130
+ @model = model.to_s
131
+ end
132
+
133
+ # -> { summary: String, cost: { "spent" => N, "cached" => N } | nil }
134
+ # Raises Unusable on a blank answer; truncates past MAX_SUMMARY_CHARS.
135
+ def summarize(prompt:)
136
+ answer = @ask.call(prompt)
137
+ text = Coercion.utf8(text_of(answer)).strip
138
+ raise Unusable, "the summarizer answered with nothing" if text.empty?
139
+
140
+ { summary: text[0, MAX_SUMMARY_CHARS], cost: cost_of(answer) }
141
+ end
142
+
143
+ private
144
+
145
+ def text_of(answer) = (answer.respond_to?(:content) ? answer.content : answer).to_s
146
+
147
+ # nil when the provider said nothing — never 0 (the Distiller's
148
+ # discipline). The cached prefix is INCLUDED in the spent total.
149
+ def cost_of(answer)
150
+ return nil unless answer.respond_to?(:input_tokens) && answer.respond_to?(:output_tokens)
151
+
152
+ input = answer.input_tokens.to_i
153
+ output = answer.output_tokens.to_i
154
+ cached = answer.respond_to?(:cached_tokens) ? answer.cached_tokens.to_i : 0
155
+ { "spent" => input + output, "cached" => cached }
156
+ end
157
+ end
158
+
159
+ # Resolves WHICH model summarizes and builds the ask. compaction.model ->
160
+ # platform utility_model -> nil (nil means "feature inert", never a guess —
161
+ # the DistillerFactory ladder). NOT the fallbacks chain: that one answers
162
+ # "which model serves the customer turn". `ask_factory`/`llm` injectable (specs).
163
+ module SummarizerFactory
164
+ module_function
165
+
166
+ # config: the Settings "compaction" hash. -> Summarizer | nil
167
+ def build(config, utility_model: nil, ask_factory: nil, llm: nil)
168
+ ref = Coercion.presence(config && config["model"]) || Coercion.presence(utility_model)
169
+ return nil if ref.nil?
170
+
171
+ provider, model = split_ref(ref)
172
+ factory = ask_factory || ->(m, p) { ruby_llm_ask(m, p, llm: llm) }
173
+ Summarizer.new(ask: factory.call(model, provider), model: ref)
174
+ end
175
+
176
+ # "provider/model" -> [provider, model]; "model" -> [nil, model] — one
177
+ # syntax for "which model" across features (DistillerFactory's reading).
178
+ def split_ref(ref)
179
+ prov, name = ref.to_s.split("/", 2)
180
+ name ? [prov, name] : [nil, prov]
181
+ end
182
+
183
+ # Temperature 0: the same slice must compact to the same boundary
184
+ # deterministically. `ruby_llm` is required lazily so nothing loads a
185
+ # provider gem until compaction is actually configured (load_guard stays green).
186
+ def ruby_llm_ask(model, provider, llm: nil)
187
+ require "ruby_llm"
188
+ llm ||= RubyLLM
189
+ lambda do |prompt|
190
+ llm.chat(model: model, provider: provider, assume_model_exists: true)
191
+ .with_temperature(0).ask(prompt)
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -151,13 +151,17 @@ module Insika
151
151
  identity, volatile = system_frags.partition { |f| (f.layer || :volatile) == :identity }
152
152
  system_frags = sort_canonical(identity) + sort_canonical(volatile)
153
153
  history_frags = fragments.select { |f| f.placement == :history } # production order (chronological)
154
+ # :tail renders after ALL history — the last thing before the current user
155
+ # message. Attention is strongest at the end of the context, so the goal
156
+ # restated here survives a 30-call turn that the head prompt no longer does.
157
+ tail_frags = fragments.select { |f| f.placement == :tail }
154
158
  tool_frags = fragments.select { |f| f.placement == :tool_context }
155
159
 
156
160
  system = system_frags.map(&:content).join("\n\n")
157
- history = history_frags.map(&:content)
161
+ history = history_frags.map(&:content) + tail_frags.map(&:content)
158
162
  tool_context = tool_frags.empty? ? nil : tool_frags.map(&:content).join("\n\n")
159
163
 
160
- canonical = system_frags + history_frags + tool_frags
164
+ canonical = system_frags + history_frags + tail_frags + tool_frags
161
165
  ContextPackage.new(
162
166
  system: system, history: history, tool_context: tool_context,
163
167
  fragments: canonical, budget: { cap: cap, used: canonical.sum(&:tokens), evicted: evicted }
@@ -3,7 +3,10 @@
3
3
  module Insika
4
4
  # Unit of context produced by a provider.
5
5
  # SHARED type (Insika::, not Insika::Context::).
6
- # placement: :system | :history | :tool_context
6
+ # placement: :system | :history | :tail | :tool_context
7
+ # :tail renders AFTER the whole history, i.e. as the last thing
8
+ # the model reads before the current user message. Its content is
9
+ # a message Hash ({role:, content:}), like a :history fragment.
7
10
  # priority: Integer; higher = more important (survives cuts)
8
11
  # tokens: Integer | nil; estimated by the Builder when nil
9
12
  # source: String — provider id (audit)
@@ -17,6 +17,9 @@ module Insika
17
17
  # the injected block.
18
18
  module Priority
19
19
  IDENTITY = 100 # IDENTITY/SOUL (Prompt) — pinned
20
+ RECITATION = 95 # <recitation> the goal restated at the TAIL of context
21
+ # (Briefing). Never cut: it is two lines, and the one
22
+ # turn it gets evicted is the long turn that needed it.
20
23
  PROMPT_REF = 90 # Prompt Catalog guardrails/refs (Prompt) — pinned
21
24
  SKILL_BODY = 85 # <active_skill> trigger-matched body (SkillTrigger)
22
25
  SKILL = 80 # <available_skills> level 1 (Skill)
@@ -29,6 +32,9 @@ module Insika
29
32
  # pinned prefix), above the turn's own <request_context>.
30
33
  HISTORY_MAX = 79 # history ceiling by recency (Session)
31
34
  HISTORY_BASE = 60 # history base; +idx up to the ceiling (Session)
35
+ COMPACTION = 59 # <conversation_summary> the compacted prefix (RFC-0044)
36
+ # — one step below the oldest verbatim message: under
37
+ # budget it is the "oldest unit" and drops first.
32
38
  REQUEST = 40 # <request_context> — turn injection, the most cuttable
33
39
  end
34
40
  end