Module: Insika::Compaction
- Defined in:
- lib/insika/compaction.rb
Overview
In-session compaction: when a session's UNCOMPACTED transcript
grows past compact_after messages, everything but the last keep_last
is summarized by a cheap model into one fragment; the tail stays verbatim.
This module is the pure half — boundary math, the prompt, and the
Summarizer over an injected ask (the Distiller shape: unit-testable
without a provider). The trigger lives in the Executor (post-turn, off
the critical path); the persistence in SessionStore#set_compaction; the
read path in Context::Providers::Session.
Defined Under Namespace
Modules: SummarizerFactory Classes: Plan, Summarizer
Constant Summary collapse
- DEFAULT_KEEP_LAST =
20- DEFAULT_COMPACT_AFTER =
40- MAX_SUMMARY_CHARS =
A summary that outgrows this is truncated — the compaction must never grow the context it exists to shrink.
6_000- MESSAGE_CHAR_CAP =
Per-message cap in the transcript slice sent to the summarizer (a
role: toolbody can be 4 000 chars in the store); the head of a long result carries the identity of what happened, which is what a summary needs. 1_000- DEFAULT_PROMPT =
The engine's generic prompt. A platform
compaction.promptREPLACES it wholesale (the distill convention) — the engine never writes store vocabulary. The preserve-list is the P28 contract: facts (CEP, order numbers), commitments, the MISSING list, decisions. <<~PROMPT.freeze You are compacting the OLD part of an ongoing customer conversation into one summary that the assistant will read INSTEAD of those messages. The recent messages stay verbatim; your summary is the only surviving trace of the old ones — anything you drop is gone for good. Preserve, verbatim where short: - every fact the customer stated (sizes, budget, address, postal code/CEP, order numbers, product choices, dates, quantities); - every commitment the assistant made (promises, prices quoted, delivery windows, agreed next steps); - what was asked and is still unanswered (the missing information); - decisions already made, so nothing gets re-asked or re-litigated. Do not invent, do not editorialize, do not add advice. Answer with the summary text only — plain text, compact, in the conversation's own language. PROMPT
Class Method Summary collapse
-
.plan(messages:, state:, config:) ⇒ Object
Decides whether (and what) to compact.
- .positive(value, default) ⇒ Object
-
.prompt(messages:, plan:, previous: nil, base: nil) ⇒ Object
The full prompt for one compaction run: the base rules, the PREVIOUS summary (so a fact from turn 3 survives every re-compaction — each summary folds the last one in) and only the NEW slice.
- .role_of(msg) ⇒ Object
- .text_of(msg) ⇒ Object
-
.transcript(messages, plan) ⇒ Object
"[i] role: content" over the plan's slice, one line per message; a tool-calling assistant message with no text renders the tool names.
Class Method Details
.plan(messages:, state:, config:) ⇒ Object
Decides whether (and what) to compact. -> Plan | nil.
messages: the session transcript (append-only, RFC-0016).
state: the persisted "compaction" hash (...) | nil.
config: the Settings "compaction" hash (keep_last/compact_after).
compact_after is clamped to at least keep_last so the plan always
moves the boundary forward. The boundary retreats over role: "tool"
messages so an eviction unit (assistant-with-tool_calls + its results)
is never split — the whole cycle stays verbatim instead.
59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
# File 'lib/insika/compaction.rb', line 59 def plan(messages:, state:, config:) msgs = Array() keep_last = positive(config && config["keep_last"], DEFAULT_KEEP_LAST) compact_after = positive(config && config["compact_after"], DEFAULT_COMPACT_AFTER) compact_after = keep_last if compact_after < keep_last from = state ? state["upto"].to_i : 0 return nil unless msgs.size - from > compact_after upto = msgs.size - keep_last upto -= 1 while upto > from && role_of(msgs[upto]) == "tool" return nil unless upto > from Plan.new(from: from, upto: upto, count: upto - from) end |
.positive(value, default) ⇒ Object
110 111 112 113 |
# File 'lib/insika/compaction.rb', line 110 def positive(value, default) n = value.to_i n.positive? ? n : default end |
.prompt(messages:, plan:, previous: nil, base: nil) ⇒ Object
The full prompt for one compaction run: the base rules, the PREVIOUS summary (so a fact from turn 3 survives every re-compaction — each summary folds the last one in) and only the NEW slice. The slice is sent UNREDACTED on purpose: it replaces transcript the main model already reads raw, inside the same trust boundary, and redaction would delete exactly the facts (CEP, order id) the summary must preserve.
80 81 82 83 84 85 86 87 88 |
# File 'lib/insika/compaction.rb', line 80 def prompt(messages:, plan:, previous: nil, base: nil) rules = Coercion.presence(base.to_s) || DEFAULT_PROMPT parts = [rules.rstrip] if Coercion.presence(previous.to_s) parts << "## The summary so far (fold it into the new one — its facts must survive)\n\n#{previous}" end parts << "## The messages to compact\n\n#{transcript(, plan)}" parts.join("\n\n") end |
.role_of(msg) ⇒ Object
98 |
# File 'lib/insika/compaction.rb', line 98 def role_of(msg) = (msg["role"] || msg[:role]).to_s |
.text_of(msg) ⇒ Object
100 101 102 103 104 105 106 107 108 |
# File 'lib/insika/compaction.rb', line 100 def text_of(msg) content = (msg["content"] || msg[:content]).to_s.strip.gsub(/\s+/, " ") if content.empty? calls = msg["tool_calls"] || msg[:tool_calls] names = Array(calls).filter_map { |c| c.is_a?(Hash) ? (c["name"] || c[:name] || c.dig("function", "name")) : nil } content = names.empty? ? "(empty)" : "(tool calls: #{names.join(', ')})" end content[0, MESSAGE_CHAR_CAP] end |
.transcript(messages, plan) ⇒ Object
"[i] role: content" over the plan's slice, one line per message; a tool-calling assistant message with no text renders the tool names.
92 93 94 95 96 |
# File 'lib/insika/compaction.rb', line 92 def transcript(, plan) Array()[plan.from...plan.upto].to_a.each_with_index.map do |msg, offset| "[#{plan.from + offset}] #{role_of(msg)}: #{text_of(msg)}" end.join("\n") end |