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: tool body 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.prompt REPLACES 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

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(messages)
  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(messages, 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(messages, plan)
  Array(messages)[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