noa

package
v0.4.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 9, 2026 License: AGPL-3.0, Apache-2.0 Imports: 10 Imported by: 0

Documentation

Overview

Package noa is a model-driven context-compression engine: a pure core with no host dependency, no I/O, and no model calls.

The essential difference from zip-style compression: the model writes the summaries, this package only orchestrates. Summary text arrives as an argument (the Compress tool's parameter); noa decides WHEN to compress, WHICH range to compress, tracks state, applies a compress decision, and prunes ranges. It never calls a model.

That is precisely why the core can be a pure library — its one external dependency, the summarizer, is reduced to a string input.

Ported from acp-kernel (MIT, Copyright © 2026 ranxianglei). See docs/noa-实施方案.md for the full specification, including every deliberate deviation from upstream.

Index

Constants

View Source
const (
	// RefWidth is the zero-padded width of a message ref: m00001 .. m99999.
	// Padding keeps lexicographic order equal to numeric order.
	RefWidth    = 5
	MinRefIndex = 1
	MaxRefIndex = 99999
	// BlockedRef marks a message that is hard-protected: it gets an entry in
	// ByRaw so it is not revisited, but consumes no ref number and can never be
	// addressed by the model.
	BlockedRef = "BLOCKED"

	// SummaryHeader opens every rendered summary message. isRenderedSummaryMessage
	// tests it together with the id prefix, role and content type — the prefix
	// alone would let a host-authored message be silently deleted by a rebuild.
	SummaryHeader = "[Compressed conversation section]"
	// SummaryIDPrefix + blockID is the synthetic id of a rendered summary.
	SummaryIDPrefix = "noa_summary_"
	// NudgeMessageID is the synthetic id of the injected nudge. It never enters
	// the ref map and never reaches the transcript.
	NudgeMessageID = "noa_nudge"

	// TruncationMarker identifies an emergency-truncated tool result. Detection
	// is a substring test, so the surrounding wording can change freely.
	TruncationMarker = "[truncated for context space"

	// ViableRangeMinTokens filters fragments out of the recommendation list. A
	// tiny range dragged into a batch makes the whole batch fail the character
	// gate, and models tend to retry the identical batch.
	ViableRangeMinTokens = 200
	// KeepLastOrphaned is how many trailing Compress calls with no block are kept
	// (a call that just failed has not formed a block yet).
	KeepLastOrphaned = 2
	// SummaryStubChars is the length a deactivated Compress call's summary
	// argument is stubbed down to.
	SummaryStubChars = 200
	// ToolPairMaxScan bounds how far boundary adjustment looks for the other half
	// of a tool exchange.
	ToolPairMaxScan = 20
	// DeadRepeatReject is how many times the same dead range may be requested
	// before it is rejected outright.
	DeadRepeatReject = 2
)
View Source
const CompressPhilosophy = `` /* 532-byte string literal not displayed */
View Source
const CompressToolDescription = `Replace older conversation ranges with summaries you write. The originals are ` +
	`archived to disk first. Do NOT call this on your own initiative — wait until the context manager asks you to, ` +
	`and follow the rules it provides. A summary written without those rules permanently loses information.`

CompressToolDescription is what the model reads next to the tool's schema.

The brake in the second half is load-bearing. Tool schemas ship with every request whether or not a nudge fired, so the model can always see that Compress exists. Without an explicit instruction to wait, it may call the tool on its own initiative — and a summary written without the rules is an irreversible loss, because the range it replaced is gone from the view.

View Source
const CompressToolName = "Compress"

CompressToolName is the single source of truth for the tool's name. The string appears in at least six places (strip exemption, hide-compress-calls matching, pair-adjustment skip, the hard-protected list, log replay scanning, schema registration); a literal in any of them would survive a rename and fail in a way that is hard to see.

View Source
const HowToCompressRules = `HOW TO COMPRESS

When you call ` +
	"`" +
	`Compress` +
	"`" +
	`, the summary you write becomes the only record of the replaced conversation. Make it self-contained and complete: every user request, experiment purpose, and work task in the range must be accurately captured. A later reader (or you, after reading the archive file) should be able to continue the task WITHOUT needing the original. The summary records the PAST as of this block's creation: label recorded task state as history ("TASK AS OF THIS BLOCK: ...") — never as a live instruction, so a later reader treats it as settled context, not something to re-execute. Write plain text with real unicode characters; never copy \\uXXXX escape sequences or JSON-escaped fragments out of tool output.

KEEP VERBATIM — never paraphrase or abbreviate these:
- Full file paths with line numbers, directory prefix on every mention (` +
	"`" +
	`lib/hooks.ts:347` +
	"`" +
	`, ` +
	"`" +
	`src/index.ts:12-18` +
	"`" +
	`, ` +
	"`" +
	`gatenet_v3/model.py:45` +
	"`" +
	`). Never abbreviate to a bare filename (` +
	"`" +
	`hooks.ts` +
	"`" +
	`, ` +
	"`" +
	`model.py` +
	"`" +
	`) — they are ambiguous and cannot be grepped or located in the archive later.
- Function, class, and type signatures (exact names, params, return types) AND critical code lines that encode logic — the line that IS the finding, not just the function name (e.g. ` +
	"`" +
	`kv_keys += define_gate * a_key[i](emb)` +
	"`" +
	` is more useful than "see model_kvnet.py").
- Error messages and stack traces (exact text — you need the literal string to grep for it later).
- Key details from reports and analyses — not just the conclusion. Keep the comparison numbers and the mechanism, not "X is worse" alone (write "1.76× PPL gap because KV store is static", not "KVNet underperforms").
- Decisions and their rationale ("chose X over Y because Z" — the "because" is load-bearing; without it the decision looks arbitrary).
- Constraints discovered ("must support Node 22", "no new dependencies", "AGENTS.md forbids ` +
	"`" +
	`as any` +
	"`" +
	`").
- Exact values: versions, config keys, thresholds, magic numbers.
- User intent — quote short user messages verbatim ONLY WITH their message ref, e.g. ` +
	"`" +
	`User said (m00132): "ship it tonight"` +
	"`" +
	`. Without a verifiable ref, paraphrase (` +
	"`" +
	`user previously asked (paraphrased): ...` +
	"`" +
	`) — this is the one exception to the verbatim rule above; never present a reconstructed or half-remembered phrase as a verbatim quote. When the message is too long to quote, preserve intent with extra care: do not change scope, constraints, priorities, acceptance criteria, or requested outcomes. Quotes are historical records, never current directives. Losing these changes the task itself.
- The user's overall goal and any changes to it — the big-picture objective plus how it evolved during the compressed range. Each summary must reflect the goal as it stood at the end of the range, including pivots (e.g., "initially: fix bug X → pivoted to: refactor module Y after discovering root cause"). Losing the goal or its evolution makes all subsequent work appear unmotivated.
- Purpose behind each significant action — preserve not just what was done but why: the hypothesis behind each experiment, the question behind each exploration, the task goal behind each work action. Without purpose, the summary reads as disconnected technical steps with no through-line.
- Open questions and unresolved TODOs — losing these changes what work appears to remain.
- Message refs of key anchors (` +
	"`" +
	`m00420` +
	"`" +
	`, ` +
	"`" +
	`m00510–m00520` +
	"`" +
	`) — they let you or a later reader locate the exact original inside the archive file.

DROP — extract the signal, discard the vessel:
- Verbose logs (build/test/` +
	"`" +
	`npm` +
	"`" +
	` output) once you have captured the error line or the result.
- Duplicate file reads once the needed content is recorded.
- Consumed exploration — search hits, agent return values, successful tool outputs — once you have extracted the facts you need (same rule as dead-ends, but nothing went wrong; the content is simply spent).
- Dead-end exploration — but PRESERVE the lesson in one line: "tried X, failed because Y".
- Back-and-forth discussion and self-corrections once the final position is captured (keep the outcome, drop the journey to it).
- Repeated status checks (` +
	"`" +
	`git status` +
	"`" +
	`, ` +
	"`" +
	`ls` +
	"`" +
	`) once state is known.

For each significant item you DROP (scripts, reports, large analyses, long tool outputs), add a one-line CONTENT description of what it covers — not where it lives. Bad: "probe script at /path/probe_kvnet.py". Good: "probe_kvnet.py: tests n-gram baseline, generation quality, long-range dependency, position sensitivity, op pipeline, QUERY attention." This lets a later reader grep the archive directory and find the right block by relevance, not by guessing locations.

PRIORITY — when the summary must be compact, preserve in this order:
1. User's overall goal, goal evolution, intent, and hard constraints (losing these changes the task).
2. Decisions and rationale.
3. Exact technical artifacts: paths, signatures, errors, values.
4. Conclusions and key findings.
5. Lessons learned: what failed and why.

Write dense, scannable bullets — not narrative prose. If the range spans distinct concerns (request → findings → decision), group bullets under short thematic headers so a reader can scan to the part they need. Every line must earn its place. Do not mimic the style of existing summaries in context; follow these rules.` + "\n\n" + archiveAddendum

HowToCompressRules governs what a summary must keep and may drop.

The archive section at the end is ADDED by noa (it replaces nothing). It changes the economics of forgetting: with the originals on disk the model can drop verbatim bulk far more aggressively, provided the summary still says THAT something happened and WHERE to look. Without that last constraint the archive becomes unreachable — present on disk, but with nothing pointing at it.

View Source
const NoRangesMessage = "No ranges provided."

NoRangesMessage is the neutral response to a call with nothing to do. It counts as neither success nor failure: the model asked a valid question and got a valid answer.

View Source
const SystemPromptHeader = "noa context management"

SystemPromptHeader opens the resident block. It doubles as the anchor a nudge can point at, so the model can find these rules by name.

View Source
const Tier2Rules = `TIER 2 COMPRESSION — DISTILLATION

You are compressing historical summaries (not raw conversation). These summaries have already captured the details. Your job is to DISTILL them: extract only what matters for future work, discard the process.

KEEP — these are the only things that survive distillation:
- Decisions and their rationale ("chose X over Y because Z" — the "because" is load-bearing).
- Final outcomes: version numbers shipped, PR numbers merged/closed, bugs fixed or deferred.
- Key lessons: what failed and why ("tried X, failed because Y"). These prevent repeating mistakes.
- Critical constraints discovered ("must support Node 22", "AGENTS.md forbids as any").
- Design decisions with architectural impact ("chose compress-as-anchor over synthetic messages because prefix cache").
- User quotes and task state only as attributed history: keep the source ref with any user quote; never carry a tier-1 "CURRENT TASK" claim forward as a live directive — relabel it "TASK AS OF THIS BLOCK".
- Whether content is OBSOLETE or SUPERSEDED — mark with one line: "[SUPERSEDED by PR #NNN]" or "[OBSOLETE: deleted in vX.Y.Z]". Do NOT keep the obsolete content's details — just the marker and reason.
- Function/class/type names and module paths that are the SUBJECT of the work — e.g., "fixed filterCompressedRanges in prune.ts", "added SessionStateRegistry in state.ts". Not exact line numbers or full signatures — just enough to LOCATE the code without searching.
- Exploration findings: if a block was exploratory with no decision, keep the CONCLUSION in one line ("explored X, not viable because Y"). Do not keep the exploration process.

DROP — these were useful during the work but are no longer needed:
- Exact line numbers, diffs, verbose function signatures, full code listings.
- Build/deploy process details, test execution steps.
- Review process details (who reviewed, what rounds, test counts).
- Verbose logs, command output, intermediate debugging steps.

FORMAT:
- Start each distilled block with a source header line:
  ` +
	"`" +
	`Source: bN+bM+... (XK→YK tok, Zx). [original topic]` +
	"`" +
	`
  Example: ` +
	"`" +
	`Source: b5+b7 (56K+44K→268 tok, 375x). [Tool-result recap + publish]` +
	"`" +
	`
- 3-5 bullet points per source block, each a self-contained fact.
- Dense, scannable — no narrative prose.
- Start with the outcome, not the process: "v1.13.0 shipped (7 PRs bundled)" not "implemented 7 PRs then reviewed then merged".
- Cross-block synthesis: if multiple source blocks cover the same topic (same PR, same feature, same bug), MERGE them into a single group of bullets. Do not repeat the same fact from different blocks — keep it once under the most relevant source header.

SIZE TARGET: 50-150 tokens per source block (excluding the header). If you can't fit it in 150 tokens, you're keeping too much process. If a block has nothing worth keeping (pure noise), output just the header followed by "[no actionable content]."`

Tier2Rules and Tier3Rules are verbatim: neither mentions decompress, search_context or acp_status, so neither needed adapting. They also need no archive addendum — a tier nudge sends HowToCompressRules alongside them, so the archive guidance already reaches the model.

View Source
const Tier3Rules = `TIER 3 COMPRESSION — ULTRA-CONDENSATION

You are compressing distilled summaries (Tier 2) into ultra-condensed facts (Tier 3). The distilled summaries already contain only decisions and outcomes. Your job is to reduce them to bare factual references.

PRIORITY — when a source block has more facts than the size target allows, keep in this order:
1. Shipped outcomes (versions released, PRs merged) — these are permanent record.
2. Open work (PRs/issues still pending) — these may need follow-up.
3. Key decisions with architectural impact ("chose X over Y because Z").
4. Critical constraints ("must support Node 22").
Drop everything else. Tier 3 is a lookup index, not a knowledge base.

FORMAT:
- Start with a source header line:
  ` +
	"`" +
	`Source: bN+bM+... (XK→YK tok, Zx). [original topic]` +
	"`" +
	`
- Output 1-3 facts per source block. Each fact is a single line: subject + outcome.
- No explanations, no rationale, no process — just the fact.
- Format: "[PR/Issue/Version] — [outcome in ≤8 words]"
- Merge related facts from different source blocks if they concern the same topic.

EXAMPLES:
- "v1.13.0 shipped — quality gate + GC fix (7 PRs)"
- "PR #196 merged — preserve-first-user (supersedes #169)"
- "Bug 1214 fixed — compress consumed all user messages"
- "Chose compress-as-anchor — prefix cache benefit over synthetic injection"
- "Constraint: AGENTS.md forbids as any — never suppress types"

DROP:
- Multi-sentence context. If a fact needs >1 sentence, it's too detailed for Tier 3.
- Lessons learned ("tried X, failed because Y") — drop UNLESS the failure is likely to recur and the block is <30 days old.
- Design rationale details — keep the decision, drop the "because" unless it's a critical constraint.
- Anything marked [OBSOLETE] or [SUPERSEDED] — drop entirely, note "[N blocks obsolete]" in the summary.

SIZE TARGET: 30-60 tokens per source block (including header). For a batch of N source blocks, total output ≈ N × 40 tokens. If a source block has only one trivial fact, output just the header + one line.`

Variables

View Source
var AlwaysProtectedTools = []string{CompressToolName}

AlwaysProtectedTools can never be compressed, regardless of configuration.

View Source
var NeverPreserveRecentTools = []string{"Read", "Grep", "Glob", "Bash", "LS"}

NeverPreserveRecentTools do not occupy a slot in the "most recent N messages" protected zone — they are bulky and inherently re-obtainable. They remain fully compressible; this is not the same thing as Config.ProtectedTools, which is a hard exclusion.

These are Norma's tool names (CamelCase), not upstream's lowercase ones.

Functions

func AdjustBoundariesForReasoningPairs

func AdjustBoundariesForReasoningPairs(start, end int, msgs []CoreMessage) (int, int)

AdjustBoundariesForReasoningPairs widens [start, end] so a reasoning run and the assistant act it belongs to stay together.

Anthropic validates that a replayed thinking block still precedes the tool_use it reasoned about; splitting them makes the request fail. Norma projects thinking explicitly, so this is a hot path rather than dead code.

func AdjustBoundariesForToolPairs

func AdjustBoundariesForToolPairs(start, end int, msgs []CoreMessage, maxScan int) (int, int)

AdjustBoundariesForToolPairs widens [start, end] so no tool exchange is cut in half. A range that takes a tool_use without its tool_result (or the reverse) leaves the rebuilt request with an unpaired block, which strict providers reject outright.

The scan is bounded by maxScan in each direction: a tool exchange is local, and an unbounded walk would let one stray id drag the whole conversation in. Once the walk starts pulling messages in it stops at the first one that does not belong — the pair is contiguous, so a gap means we are past it.

func AllocateBlockID

func AllocateBlockID(s *CompressionState) string

AllocateBlockID hands out the next block id and advances the counter. Block ids are a single flat sequence across all tiers: the number therefore encodes global creation order, and one counter is one fewer thing to get wrong when rebuilding state.

func ApplyPairBoundaryAdjustments

func ApplyPairBoundaryAdjustments(start, end int, msgs []CoreMessage) (int, int)

ApplyPairBoundaryAdjustments runs both widenings to a fixed point, at most twice. Two passes are enough because each widening can expose work for the other exactly once: a tool pair pulled in may carry a reasoning run, and that run's assistant burst may carry one more tool result. A third pass has never changed anything, so the loop exits as soon as a round is a no-op.

func ArchiveFileName

func ArchiveFileName(blockID, startRef, endRef string) string

ArchiveFileName is the archive's basename: block id first so it is unique, then the ref span so a human can locate it by eye.

func ArchiveTag

func ArchiveTag(b CompressionBlock) string

ArchiveTag renders the pointer that lets the model (or a human) find a block's original content on disk. It is the last line of every summary.

func ArchiveTierDir

func ArchiveTierDir(t Tier) string

ArchiveTierDir is the subdirectory a tier's archives live in.

func BlockIDFromSummaryID

func BlockIDFromSummaryID(s string) (string, bool)

BlockIDFromSummaryID is the inverse; ok=false if s is not a summary id.

func BuildCompressibleRanges

func BuildCompressibleRanges(msgs []CoreMessage, state CompressionState, cfg Config,
	protectedRefs map[string]bool, count TokenCountFn) ([]RangeInfo, []ProtectedRange)

BuildCompressibleRanges walks the view once and groups contiguous compressible messages, breaking the run wherever something interrupts it.

func BuildSystemPrompt

func BuildSystemPrompt(s Sections) string

BuildSystemPrompt assembles the resident prompt.

func CompressToolSchema

func CompressToolSchema() map[string]any

CompressToolSchema is the tool's JSON Schema.

Note what is NOT here: "content" carries no "type": "array".

Providers without strict tool support (vLLM, some Qwen deployments) stringify nested array arguments. A schema that insists on an array rejects that shape before ParseCompressArgs ever sees it — and a turn's only compression attempt is lost to a validation error rather than being repaired. Omitting the type lets anything through to the lenient parser, which knows how to recover an array from a string.

func ComputeContextBreakdown

func ComputeContextBreakdown(msgs []CoreMessage, count TokenCountFn) map[string]int

ComputeContextBreakdown buckets the context so the model can see where its tokens went, rather than only how many there are.

func ComputeProtectedRefs

func ComputeProtectedRefs(msgs []CoreMessage, state CompressionState, cfg Config) map[string]bool

ComputeProtectedRefs is the SOFT protected zone: the tail of the conversation the model must not compress, because the current step is probably still using it.

Distinct from Config.ProtectedTools, which is a hard, permanent exclusion. This zone slides forward as the conversation grows — a message protected now becomes compressible later.

func CountMessageTokens

func CountMessageTokens(m CoreMessage, count TokenCountFn) int

CountMessageTokens is the per-message cost under a given counter.

func CoveredMessageIDs

func CoveredMessageIDs(s CompressionState) map[string]bool

CoveredMessageIDs is the union of every active block's effective coverage — the set prune hides.

func DefaultCountTokens

func DefaultCountTokens(text string) int

DefaultCountTokens approximates tokens as one per CJK rune plus one per four other runes.

Deviation from upstream (deliberate): counting is by RUNE, not UTF-16 code unit. Upstream mirrors JavaScript's String.length so its numbers match the reference implementation byte for byte; noa has no such requirement, and rune semantics are the natural Go reading. The only visible difference is that astral-plane characters (emoji) count once instead of twice.

func EstimateTokensFast

func EstimateTokensFast(text string) int

EstimateTokensFast skips the CJK scan; used where a rough figure suffices.

func FormatBlockMap

func FormatBlockMap(spans []BlockSpan) string

FormatBlockMap lists the active blocks so the model can address them.

func FormatBreakdown

func FormatBreakdown(d NudgeDecision) string

FormatBreakdown shows where the context went. Empty buckets are omitted: a zero tells the model nothing and costs a line.

func FormatPanel

func FormatPanel(in PanelInput) string

FormatPanel renders the Compress tool's result.

Every block line reports the span the block ACTUALLY covers, derived from its effective coverage rather than the refs the model asked for. Reporting the request instead would let the model's ledger drift from what really happened — it would believe it had compressed something it had not.

func FormatRanges

func FormatRanges(compressible []RangeInfo, protected []ProtectedRange) string

FormatRanges lists what may and may not be compressed, oldest first.

Compressible and protected spans are merged into ONE ordered list rather than two sections. Splitting them loses the time order and hides overlap — a span can be partly compressible and partly protected, which only the merged view shows correctly.

func FormatTierTargetBlocks

func FormatTierTargetBlocks(blocks []CompressionBlock) string

FormatTierTargetBlocks lists what a tier nudge is asking to consolidate.

func FormatTokens

func FormatTokens(n int) string

FormatTokens renders a token count compactly: exact below 1000, one decimal below 10000, whole thousands above.

func HighestUsedIndex

func HighestUsedIndex(m MessageRefMap) int

HighestUsedIndex is the largest ref index handed out so far, skipping BLOCKED entries (which occupy no number).

func IndexToRef

func IndexToRef(index int) string

IndexToRef renders a 1-based index as mNNNNN. Out-of-range input is a programming error and yields "".

func IsMessageProtected

func IsMessageProtected(m CoreMessage, cfg Config) bool

IsMessageProtected reports whether a message is HARD-protected: excluded from every compression range, and never assigned a ref.

Distinct from the soft protected zone (the most recent N messages / last user message), which is a recency heuristic that shifts as the conversation grows.

func IsNeverPreserveRecentTool

func IsNeverPreserveRecentTool(m CoreMessage) bool

IsNeverPreserveRecentTool reports whether a message's tool is excluded from the "most recent N" protected-zone accounting. Such messages remain fully compressible — they simply do not occupy a slot.

func IsTerminalRewrite

func IsTerminalRewrite(d TierDecision, directCount int, cfg Config) bool

IsTerminalRewrite reports a compression that would absorb terminal-tier blocks into another terminal-tier block without folding in any new message.

That reclaims nothing — the same summaries go in and come out one pass lossier — and a model that tries it once will try it again. Checking TargetTier alone is sufficient: by DecideTier, TargetTier == MaxTier implies OutputTier == MaxTier.

func MatchToolPattern

func MatchToolPattern(pattern, toolName string) bool

MatchToolPattern matches a tool name against a configured pattern: exact (case-insensitive), or a single trailing "*" prefix wildcard.

func NudgeGrowthFloor

func NudgeGrowthFloor(cfg Config) int

NudgeGrowthFloor is the cadence step a host uses to decide when a suppressed nudge may resume, and the growth gate below the pressure band.

Reusing the same unit the pacing already speaks in keeps the behaviour predictable and avoids one more knob to tune. It is capped at an eighth of the window: a cadence step that is a large fraction of the window cannot lift suppression (or clear the growth gate) before the next request overflows, and the absolute defaults below were sized for a 200K window.

func PanelBlockCount

func PanelBlockCount(panel string) int

PanelBlockCount reports how many blocks a rendered panel describes.

The failure counter needs this: a panel is not an error, but a panel with no blocks means nothing was reclaimed, and that has to count as a failed attempt or a model can loop forever on calls that are technically successful.

func ParseBlockID

func ParseBlockID(s string) (string, bool)

ParseBlockID accepts "b5", "b005", or a bare "5", plus a trailing tier label as displayed ("b5(T2)"). Returns the canonical "bN".

func ParseBoundary

func ParseBoundary(s string) (boundaryRef, bool)

ParseBoundary classifies one endpoint of a requested range. Message refs and block ids share the field, so the leading letter is what tells them apart — without it "175" could mean either, and the whole tier mechanism would have nothing to branch on.

func ProjectedTokenCount

func ProjectedTokenCount(msgs []CoreMessage, state CompressionState, cfg Config, count TokenCountFn) int

ProjectedTokenCount measures what this turn's request will actually weigh — the only honest input to the pressure ladder.

The raw history still holds every message prune is about to replace with a summary, plus tool results whose paired call was compressed away (orphans prune strips every turn). Counting it therefore reports the size of everything ever said, a number that only grows: a compression that removes 40k tokens from the request leaves it untouched. Keyed on that, the ladder stays pinned in the pressure band, re-nudges on every turn, and eventually shreds tool results out of a request that comfortably fits.

So the count is taken on the pruned view — after prune has hidden covered content AND stripped the orphans, before emergency-truncate has cut anything. The cost is one extra run of the sizing prefix per turn: O(n) over the view, against a provider round trip.

func RawForRef

func RawForRef(m MessageRefMap, ref string) (string, bool)

RawForRef resolves a ref back to its host message id.

func RefForRaw

func RefForRaw(m MessageRefMap, rawID string) (string, bool)

RefForRaw returns the ref assigned to a host message id, if any.

func RefToIndex

func RefToIndex(ref string) (int, bool)

RefToIndex parses mNNNNN back to its index, tolerating surrounding space, upper case, and missing zero padding (m5 == m00005). Returns ok=false for anything else.

func RenderArchive

func RenderArchive(in ArchiveRenderInput) []byte

RenderArchive produces the markdown for one archive file.

Content is written verbatim — no truncation, ever. Tool output is already bounded upstream by the host's capture layer, and the archive is the only path back to the originals: shortening it here would make the guarantee a lie.

func ResolveBoundaries

func ResolveBoundaries(startRef, endRef string, messages []CoreMessage, state CompressionState) (ResolvedRange, *BoundaryNotFoundError)

ResolveBoundaries maps a requested range onto the current view.

Reversed endpoints are swapped rather than rejected: the model meant a span, and the order it wrote it in carries no information.

func RunPruneOnly

func RunPruneOnly(msgs []CoreMessage, state CompressionState, cfg Config) ([]CoreMessage, CompressionState)

RunPruneOnly projects the view without any of the pressure machinery.

Used when noa is switched off: the compression state still has to be applied so the history stays the size it was compressed to, but nudging, truncation and hide-compress-calls all belong to a system that is no longer running.

func SummaryMessageID

func SummaryMessageID(blockID string) string

SummaryMessageID is the synthetic id of a block's rendered summary.

func ValidateConfig

func ValidateConfig(c Config) []string

ValidateConfig returns warnings, not errors: a questionable knob degrades behaviour but must never take down a session. Callers log and continue.

func WithdrawSplitTurns

func WithdrawSplitTurns(msgs []CoreMessage, folded map[string]bool) (map[string]bool, []string)

WithdrawSplitTurns removes from folded any turn that would be left broken: a group whose reasoning is being compressed away while one of its tool calls stays visible.

The visible call would then have no reasoning run to point back to, and a strict-echo provider rejects the rebuilt request. Withdrawing the whole group is the conservative repair — the range simply compresses a little less.

Returns the surviving set and the indices withdrawn.

Types

type ApplyInput

type ApplyInput struct {
	Ranges []CompressRange
	// Messages is the view the model was looking at — the same array its refs
	// were resolved against. Re-deriving it here would risk a different array
	// and therefore a misaligned range.
	Messages []CoreMessage
	State    CompressionState
	Config   Config
	// CallID is the tool_use id of this Compress call.
	CallID string
	// Archiver persists the originals. Required: a block may not exist without
	// its archive.
	Archiver Archiver
	// CreatedAt is an RFC3339 timestamp; this package does not read the clock.
	CreatedAt string
	// Now is the block's creation time as a Unix timestamp, supplied for the
	// same reason.
	Now int64
	// CountTokens overrides the built-in estimator.
	CountTokens TokenCountFn
}

ApplyInput is one Compress tool call, resolved against the current view.

type ApplyResult

type ApplyResult struct {
	State            CompressionState
	BlocksCreated    []CompressionBlock
	TokensCompressed int
	Errors           []string
	Warnings         []string
	// SurvivingBlockIDs are higher-tier blocks that stayed active inside a
	// requested range. Reported so the model understands why they remain.
	SurvivingBlockIDs []string
}

ApplyResult reports what happened. Errors and warnings are model-facing: they are how it learns to correct a malformed request.

func ApplyCompression

func ApplyCompression(in ApplyInput) ApplyResult

ApplyCompression turns one Compress call into blocks.

Order matters and is enforced by the structure below:

resolve boundaries → detect in-batch overlap → the atomic size gate
→ build each block in memory → write every archive → only then mutate state

The archive write happens BEFORE any state change so a failed write leaves nothing behind: a block whose originals were never persisted would claim recoverability it cannot deliver.

type ArchiveEntry

type ArchiveEntry struct {
	// Block is set for an absorbed lower-tier block.
	Block *CompressionBlock
	// Message is set for an uncompressed message inside the range.
	Message *CoreMessage
	// Ref is the message's ref, when it has one.
	Ref string
}

ArchiveEntry is one item in a tier-2/3 archive: either an absorbed block's summary, or a loose message that was swept up with it.

type ArchiveRenderInput

type ArchiveRenderInput struct {
	BlockID   string
	Tier      Tier
	SessionID string
	// CreatedAt is an RFC3339 timestamp supplied by the caller — this package
	// does not read the clock.
	CreatedAt string
	StartRef  string
	EndRef    string
	Topic     string
	Summary   string
	// Entries are in VIEW ORDER: absorbed block summaries and loose messages
	// interleaved exactly as the model saw them, so a reader sees the shape of
	// the conversation rather than a regrouped digest.
	Entries []ArchiveEntry
	// OriginalTokens is the cost of what this archive replaces.
	OriginalTokens int
}

ArchiveRenderInput is everything needed to render one archive file.

type Archiver

type Archiver interface {
	// Write stores content for a block and returns its absolute path and the
	// path relative to the archive root. It must be atomic: a partially written
	// archive is worse than none, because the block that points at it will claim
	// the originals are recoverable.
	Write(tier Tier, blockID, startRef, endRef string, content []byte) (abs, rel string, err error)
	// Remove deletes an archive, used to roll back a failed batch.
	Remove(abs string) error
}

Archiver writes a block's original content to durable storage.

The interface keeps this package pure: noa decides WHAT to archive and renders it, the host decides WHERE and performs the I/O. Unit tests use an in-memory implementation.

type AssignRefsOptions

type AssignRefsOptions struct {
	// Existing is the map to extend. It is copied, not mutated.
	Existing MessageRefMap
	// NextIndex is where allocation resumes; below MinRefIndex it is clamped.
	NextIndex int
	// IsProtected marks a message as hard-protected: it is recorded as BLOCKED
	// and never becomes addressable.
	IsProtected func(CoreMessage) bool
	// ShouldSkip leaves a message out of the map entirely.
	ShouldSkip func(CoreMessage) bool
}

AssignRefsOptions parameterises a pass over the messages.

type AssignRefsResult

type AssignRefsResult struct {
	Map           MessageRefMap
	NextIndex     int
	NewlyAssigned int
}

AssignRefsResult carries the extended map.

func AssignRefs

func AssignRefs(messages []CoreMessage, opts AssignRefsOptions) AssignRefsResult

AssignRefs hands out a ref to every message that does not have one.

CORE INVARIANT: a ref, once assigned, is never reassigned. A message already present in ByRaw is skipped outright, so no compression, prune, or rebuild can renumber it. Every ref the model has ever seen stays valid for the life of the session.

type BlockSpan

type BlockSpan struct {
	BlockID  string
	Tier     Tier
	StartRef string
	EndRef   string
}

BlockSpan is one active block's coverage, for the nudge's block map.

func ActiveBlockSpans

func ActiveBlockSpans(state CompressionState) []BlockSpan

ActiveBlockSpans summarises the active blocks for the nudge's block map.

type BoundaryKind

type BoundaryKind string

BoundaryKind distinguishes what a resolved range's endpoints referred to.

const (
	// BoundaryMessage means both endpoints were message refs: raw conversation
	// is being compressed, producing a tier-1 block.
	BoundaryMessage BoundaryKind = "message"
	// BoundaryBlock means at least one endpoint was a block id: existing blocks
	// are being consolidated, producing a higher tier. Such a range is exempt
	// from the per-batch character floor, since summaries are short by design.
	BoundaryBlock BoundaryKind = "block"
)

type BoundaryNotFoundError

type BoundaryNotFoundError struct {
	Ref  string
	Kind BoundaryNotFoundKind
	Msg  string
}

BoundaryNotFoundError carries the failed endpoint and why it failed.

func (*BoundaryNotFoundError) Error

func (e *BoundaryNotFoundError) Error() string

type BoundaryNotFoundKind

type BoundaryNotFoundKind string

BoundaryNotFoundKind explains why an endpoint could not be resolved. The distinction drives which corrective text the model is given.

const (
	// NotFoundUnknown means the ref belongs to no message in this session: a
	// typo, or a ref carried over from a different session generation. Refs are
	// never reassigned, so a compression in this session cannot have caused it.
	NotFoundUnknown BoundaryNotFoundKind = "unknown"
	// NotFoundConsumed means the ref is known but its content is already folded
	// into a block — the model is trying to compress something twice.
	NotFoundConsumed BoundaryNotFoundKind = "consumed"
	// NotFoundInvalid means the string did not parse as a ref or block id.
	NotFoundInvalid BoundaryNotFoundKind = "invalid"
)

type CompressConfig

type CompressConfig struct {
	// MinCompressRange is the per-batch character floor, applied atomically:
	// a batch below it is rejected whole. Block-boundary ranges are exempt —
	// tier 2/3 compress summaries, which are short by construction.
	MinCompressRange int
	MinSummaryLength int
	MaxSummaryLength int
}

CompressConfig bounds what the model may submit.

type CompressParseDiagnostics

type CompressParseDiagnostics struct {
	Ok             bool
	Kind           CompressParseKind
	InvalidItems   int
	InvalidReasons []string
	// RawPrefix is the first 800 characters of the raw input, for logs.
	RawPrefix string
	Length    int
	Keys      []string
	// Salvaged marks output recovered by bracket-matching rather than parsed.
	Salvaged bool
	// TailRepaired marks output fixed by appending a missing closing brace.
	TailRepaired bool
}

CompressParseDiagnostics records how the arguments arrived.

Ok and Kind answer different questions and are both needed. Ok is "were usable ranges recovered" — the only thing a caller should branch on. Kind is "how did they arrive", which keeps the malformation visible in logs even when the recovery succeeded. A salvaged parse is correctly both Ok and ParseTruncated.

Invariant: !Ok implies no ranges.

type CompressParseKind

type CompressParseKind string

CompressParseKind classifies why parsing ended the way it did. The kind is reported back so the model is told what shape its output was in, not just that it failed.

const (
	ParseOK             CompressParseKind = "ok"
	ParseEmptyInput     CompressParseKind = "empty-input"
	ParseNotObject      CompressParseKind = "not-object"
	ParseMissingContent CompressParseKind = "missing-content"
	ParseContentNotList CompressParseKind = "content-not-array"
	ParseNoValidRanges  CompressParseKind = "no-valid-ranges"
	ParseTruncated      CompressParseKind = "truncated"
	ParseMalformedJSON  CompressParseKind = "malformed-json"
)

type CompressParseResult

type CompressParseResult struct {
	Ranges          []CompressRange
	TopLevelTopic   string
	SummaryMaxChars *int
	Diagnostics     CompressParseDiagnostics
}

CompressParseResult is the parsed call.

func ParseCompressArgs

func ParseCompressArgs(raw []byte) CompressParseResult

ParseCompressArgs decodes a Compress call as leniently as it safely can.

Strict parsing here is not a virtue: a model's only compression attempt in a long turn can arrive with a trailing comma, a stringified array, or a truncated tail, and rejecting it loses the whole turn's compression. Each layer below exists because some provider or model produced exactly that shape.

type CompressRange

type CompressRange struct {
	StartRef string
	EndRef   string
	Summary  string
	Topic    string
	// SummaryMaxChars raises the per-summary ceiling for this entry.
	SummaryMaxChars *int
}

CompressRange is one span the model asked to compress.

type CompressionBlock

type CompressionBlock struct {
	BlockID string
	Tier    Tier
	Topic   string
	Summary string

	// DirectMessageIDs are the messages this block newly covers (excluding those
	// already covered by an absorbed child block).
	DirectMessageIDs []string
	// EffectiveMessageIDs are every message this block covers, inherited children
	// included. This is what prune hides and what the block span reports.
	EffectiveMessageIDs []string
	// DirectBlockIDs are the lower-tier blocks this block absorbed.
	DirectBlockIDs []string

	// ArchivePath is the absolute path of the markdown file holding the original
	// content. ArchiveRel is the same path relative to the archive root, so a
	// moved session directory can be relocated.
	ArchivePath string
	ArchiveRel  string

	// CompressedTokens is how much the absorbed content cost, for reporting.
	CompressedTokens int
	// StartRef/EndRef span the block's ACTUAL coverage (derived from
	// EffectiveMessageIDs), not the refs the model requested — otherwise the
	// model's ledger drifts from reality.
	StartRef  string
	EndRef    string
	CreatedAt int64

	Active bool
	// CompressCallID is the tool_use id of the Compress call that created this
	// block, used by hide-compress-calls to decide which calls to keep.
	CompressCallID string
}

CompressionBlock is one compressed range: the model-written summary that stands in for it, the messages it covers, and the archive file holding the originals.

func ActiveBlocks

func ActiveBlocks(s CompressionState) []CompressionBlock

ActiveBlocks are the blocks currently standing in for content.

func FindBlock

func FindBlock(s *CompressionState, blockID string) *CompressionBlock

FindBlock returns a pointer into s.Blocks, or nil.

type CompressionState

type CompressionState struct {
	Blocks      []CompressionBlock
	MessageRefs MessageRefMap
	// TokenSnapshot freezes each ref's token count at first render. The number in
	// a ref tag must never drift: a changed tag is a changed byte is an
	// invalidated prefix cache.
	TokenSnapshot map[string]int
	Nudge         NudgeState
	Stats         Stats
	NextBlockID   int
	// ArchiveRoot is <ArchiveBaseDir>/<SessionID>, computed by the host adapter.
	ArchiveRoot string
	SessionID   string
}

CompressionState is everything noa remembers about a session. It is plain data: serialisable, rebuildable from the transcript, and never holding a reference to the host.

func CloneState

func CloneState(s CompressionState) CompressionState

CloneState deep-copies the state so a pipeline node can mutate freely without touching the caller's copy.

func CreateInitialState

func CreateInitialState(sessionID, archiveRoot string) CompressionState

CreateInitialState returns the zero state for a fresh session.

type Config

type Config struct {
	ModelContextLimit int

	Tiers    TierConfig
	Nudge    NudgeConfig
	Truncate TruncateConfig
	Compress CompressConfig

	// ProtectedTools are hard-excluded from every compression range.
	ProtectedTools []string
	// IsToolProtected, when set, extends ProtectedTools with a host predicate.
	IsToolProtected func(toolName string) bool

	// PreserveRecentMessages and PreserveRecentTokens define the soft protected
	// zone at the tail of the conversation.
	PreserveRecentMessages int
	PreserveRecentTokens   int

	// MaxCompressAttempts is the consecutive failure/ignore limit before nudging
	// is suppressed. Suppression lifts once the context grows by growthFloor.
	MaxCompressAttempts int
}

Config is the full knob set. DefaultConfig supplies values converged on upstream over months of production use; changing one is rarely a good idea.

func DefaultConfig

func DefaultConfig(modelContextLimit int) Config

DefaultConfig returns the tuned defaults for a given context window.

Pacing defaults scale with the window: a cadence gate expressed in absolute tokens (the historical flat 20K) is 50% of a 40K window, so once suppression engages near the ceiling it can never lift before the request overflows. Keeping GrowthFloor < GrowthCap also keeps GrowthRatio meaningful — with an equal floor and cap the "adaptive" growth was a constant 50K regardless of the window. GrowthRatio is pinned so windows of 128K and up still resolve to the historical 50K growth unit (the tier-ladder tests depend on that); smaller windows get a proportionally tighter one.

type ContentType

type ContentType string

ContentType is the kind of content a CoreMessage carries. One host message flattens into 0..N CoreMessages, one per content block.

const (
	CTText       ContentType = "text"
	CTToolCall   ContentType = "tool-call"
	CTToolResult ContentType = "tool-result"
	CTReasoning  ContentType = "reasoning"
)

type CoreMessage

type CoreMessage struct {
	ID          string
	Role        Role
	ContentType ContentType
	Text        string
	ToolName    string
	ToolCallID  string
}

CoreMessage is the flattened intermediate representation the pipeline works on. The host projects its own message type into these and reassembles them afterwards; noa never sees the host's types.

Text is the bare body with any ref tag already stripped — the tag must not reach the identity hash, or the id would drift every time the tag is re-rendered.

func RenderSummaryMessage

func RenderSummaryMessage(b CompressionBlock) CoreMessage

RenderSummaryMessage builds the message that stands in for a compressed range: header, the model's summary, and the archive pointer.

type DecideNudgeInput

type DecideNudgeInput struct {
	Messages   []CoreMessage
	State      CompressionState
	Config     Config
	TokenCount int
	// Recommendation is the compressible/protected split for this turn.
	Recommendation Recommendation
	CountTokens    TokenCountFn
	// TruncatedCount is how many tool results were mechanically shortened, so
	// an emergency nudge can tell the model not to re-run them.
	TruncatedCount int
}

DecideNudgeInput is one turn's pressure picture.

type MessageRefMap

type MessageRefMap struct {
	ByRaw map[string]string // host id -> "m00001", or BlockedRef
	ByRef map[string]string // "m00001" -> host id
}

MessageRefMap is the two-way index between host message ids and the mNNNNN refs the model addresses them by.

Core invariant: a ref, once assigned, is NEVER reassigned. Refs are per-session snapshots handed out when a message is first rendered; no compression renumbers them.

type NodeIO

type NodeIO struct {
	Messages []CoreMessage
	State    CompressionState
	// Nudge is set by the nudge-inject node, nil when nothing is injected.
	Nudge *NudgeDecision
	// TruncatedCount is set by emergency-truncate: how many tool results were
	// mechanically shortened this turn. The nudge renderer uses it to tell the
	// model not to re-run those tools.
	TruncatedCount int
}

NodeIO is what flows between pipeline nodes: the working message view and the state being threaded through it.

func RunPipeline

func RunPipeline(nodes []PipelineNode, io NodeIO, ctx PipelineContext) NodeIO

RunPipeline threads io through the nodes in order. The order is not negotiable: each node assumes the invariants the previous ones established (refs exist before boundaries are resolved, blocks are synced before prune hides their coverage, and so on).

type NudgeBreakdown

type NudgeBreakdown struct {
	Usage           float64
	Growth          int
	GrowthReference int
	GrowthFloor     int
	AdaptiveGrowth  int

	OverLimit bool
	Emergency bool

	MinPressureBenefit int

	PendingT1 int
	PendingT2 int
	PendingT3 int

	CountT1 int
	CountT2 int
}

NudgeBreakdown is the diagnostic detail behind a nudge decision. It is surfaced in logs and in the decision's Reason, and is the first thing to look at when nudges fire too often or not at all.

type NudgeConfig

type NudgeConfig struct {
	// MaxContextLimitPct is the bottom of the pressure band.
	MaxContextLimitPct float64
	// MinContextLimitPct is both the first-sight floor and the usage floor for
	// count-based tier triggers.
	MinContextLimitPct float64
	// EmergencyThresholdPct is where the voice turns urgent.
	EmergencyThresholdPct float64

	// GrowthRatio/Floor/Cap derive the adaptive growth unit from the window.
	GrowthRatio float64
	GrowthFloor int
	GrowthCap   int

	// MinGrowthFloor and MinGrowthRatio derive the cadence gate: how much the
	// context must grow before another nudge is warranted.
	MinGrowthFloor int
	MinGrowthRatio float64

	// Tier2GrowthMultiplier scales the adaptive growth into the pending-token
	// threshold for tier 2 and 3.
	Tier2GrowthMultiplier float64

	// MinPressureBenefitTokens is the minimum reclaimable amount worth spending a
	// turn on while under pressure. nil means max(5000, round(limit*0.01)).
	MinPressureBenefitTokens *int
}

NudgeConfig paces how often the model is asked to compress.

type NudgeDecision

type NudgeDecision struct {
	ShouldInject bool
	// Reason carries the decision diagnostics whether or not it injected —
	// a suppressed nudge is often the thing being debugged.
	Reason string
	// Tier is the tier being asked for; 0 when nothing is injected.
	Tier         Tier
	ContextUsage float64

	CompressibleRanges []RangeInfo
	ProtectedRanges    []ProtectedRange
	ActiveBlockSpans   []BlockSpan
	// TierTargetBlocks are the blocks a tier-2/3 nudge asks to consolidate.
	//
	// Deviation from docs §6.5, which types this []string: the renderer
	// (§6.7 formatTierTargetBlocks) prints each block's message count, token
	// delta and topic, none of which an id alone carries. §6.7 is the binding
	// requirement.
	TierTargetBlocks []CompressionBlock

	Breakdown NudgeBreakdown
	// ContextBreakdown buckets the context by kind: summaries, tool, system,
	// code, text.
	ContextBreakdown map[string]int
	// TruncatedCount is how many tool results emergency-truncate shortened this
	// turn, so the nudge can tell the model not to re-run them.
	TruncatedCount int
}

NudgeDecision is the structured output of DecideNudge. Rendering it to text is a separate step (RenderNudgeText), so a host can present it differently without touching the decision logic.

func DecideNudge

func DecideNudge(in DecideNudgeInput) NudgeDecision

DecideNudge decides whether to ask the model to compress, and for what.

Two bands, and they behave differently on purpose:

  • Under pressure (usage past MaxContextLimitPct) there is no growth gate. The context is nearly full; waiting for it to grow further is not a strategy. Only a minimum-benefit floor applies, so the model is not asked to spend a turn reclaiming nothing.
  • Below pressure, the nudge is an efficiency suggestion and must not become nagging: it fires only once the context has grown by a full cadence step AND there is a worthwhile amount to reclaim.

type NudgeSections

type NudgeSections struct {
	EfficiencyNote  SectionOverride
	EmergencyHeader SectionOverride
	T2Guidance      SectionOverride
	T3Guidance      SectionOverride
}

NudgeSections are the surface-class fragments a host may replace freely. Unlike the four compression rules these carry no quality risk: they set tone and framing, not what a summary must preserve.

type NudgeState

type NudgeState struct {
	// LastPerMessageNudgeTokens is the growth baseline.
	LastPerMessageNudgeTokens int
	// LastNudgeShownTokens is the token count at the last injection.
	LastNudgeShownTokens int
	// LastShownByTier paces each tier independently.
	LastShownByTier map[Tier]int
}

NudgeState is the bookkeeping that paces nudge injection.

type NudgeVoice

type NudgeVoice string

NudgeVoice is the register a nudge is written in.

const (
	VoiceGentle    NudgeVoice = "gentle"
	VoiceEmergency NudgeVoice = "emergency"
)

func RenderNudgeText

func RenderNudgeText(d NudgeDecision, s NudgeSections) (NudgeVoice, string)

RenderNudgeText turns a decision into the message the model reads.

type PanelInput

type PanelInput struct {
	Result ApplyResult
	// BeforeTokens and AfterTokens bracket the compression for the headline.
	BeforeTokens int
	AfterTokens  int
	// Messages is the post-compression view, used to mark partial coverage.
	Messages []CoreMessage
}

PanelInput is what the Compress tool reports back to the model.

type PipelineContext

type PipelineContext struct {
	Config Config
	// TokenCount is the measured size of the context this turn.
	TokenCount int
	// CountTokens is the token estimator; nil means DefaultCountTokens.
	CountTokens TokenCountFn
}

PipelineContext is the read-only environment every node sees.

type PipelineNode

type PipelineNode interface {
	Name() string
	// Enabled reports whether the node should run at all this turn. A disabled
	// node is skipped entirely, not run as a no-op.
	Enabled(io NodeIO, ctx PipelineContext) bool
	Run(io NodeIO, ctx PipelineContext) NodeIO
}

PipelineNode is one stage of the per-turn view projection.

type ProcessTurnInput

type ProcessTurnInput struct {
	// Messages is the freshly projected view of the full history.
	Messages []CoreMessage
	State    CompressionState
	Config   Config
	// TokenCount is the measured size of the context this turn, used for every
	// pressure and growth decision.
	TokenCount int
	// CountTokens overrides the built-in estimator.
	CountTokens TokenCountFn
}

ProcessTurnInput is one turn's projection request.

type ProcessTurnResult

type ProcessTurnResult struct {
	Messages []CoreMessage
	State    CompressionState
	// Nudge is nil when nothing was injected this turn.
	Nudge *NudgeDecision
	// TruncatedCount is how many tool results emergency-truncate shortened.
	TruncatedCount int
}

ProcessTurnResult is what the host renders and sends.

func ProcessTurn

func ProcessTurn(in ProcessTurnInput) ProcessTurnResult

ProcessTurn projects one turn's view. It is a pure function: the input messages and state are not mutated, and the result is used for a single request.

Node order is not negotiable — each stage assumes what the previous ones established.

type ProtectedRange

type ProtectedRange struct {
	StartRef string
	EndRef   string
	Count    int
	Tokens   int
	// Tools names the hard-protected tools responsible for the exclusion.
	Tools []string
}

ProtectedRange is a span excluded from compression, reported so the model can see why a gap exists rather than guessing.

type RangeInfo

type RangeInfo struct {
	StartRef string
	EndRef   string
	// Count is how many messages the span covers.
	Count int
	// Tokens and Chars size the span; Chars drives the per-batch minimum.
	Tokens int
	Chars  int
	// ToolPct and TextPct are rounded percentages of the span's composition —
	// a tool-heavy span is usually the cheapest thing to compress.
	ToolPct int
	TextPct int
	// UserMsgs counts user messages inside the span, surfaced so the model
	// treats intent-bearing spans with more care.
	UserMsgs int
	// Dangerous marks a span the model should not compress without certainty.
	Dangerous bool
}

RangeInfo is one contiguous span the model may compress, as reported to it in the nudge's range table.

func MergeRangesToThreshold

func MergeRangesToThreshold(ranges []RangeInfo, minChars int) []RangeInfo

MergeRangesToThreshold coalesces adjacent ranges until each batch clears minChars, so the model is not handed fragments that would fail the atomic size gate the moment it submits them.

func ViableRanges

func ViableRanges(ranges []RangeInfo) []RangeInfo

ViableRanges drops fragments too small to be worth a turn.

Without this filter a tiny range dragged into a batch makes the whole batch fail the character gate, and models tend to resubmit the identical batch.

type RebuildInput

type RebuildInput struct {
	// Messages is the full projected history, oldest first.
	Messages []CoreMessage
	// Config and Archiver mirror the live ones.
	Config   Config
	Archiver Archiver
	// SessionID and ArchiveRoot seed the rebuilt state.
	SessionID   string
	ArchiveRoot string
	CountTokens TokenCountFn
	// CreatedAt and Now stamp rebuilt blocks; this package does not read the
	// clock.
	CreatedAt string
	Now       int64
}

RebuildInput replays a session's compressions from its recorded history.

type RebuildResult

type RebuildResult struct {
	State CompressionState
	// Replayed is how many compressions were reapplied.
	Replayed int
	// Skipped is how many recorded calls were passed over (they failed then, and
	// replaying them would fail identically).
	Skipped  int
	Warnings []string
}

RebuildResult reports the replay.

func RebuildStateFromLog

func RebuildStateFromLog(in RebuildInput) RebuildResult

RebuildStateFromLog reconstructs compression state from the conversation itself.

This works — and needs no model — because the summaries were never derived from anything: the model wrote them as arguments to its Compress calls, and the host recorded those calls verbatim. Replaying the calls returns the BYTE-IDENTICAL summaries, not an approximation of them.

That is the practical difference from summarise-on-demand compaction, whose summary is a model response: lose it and it must be regenerated, and the regenerated text differs.

Each call is replayed against the view AS IT WAS at that point, so refs resolve the way they did originally.

type Recommendation

type Recommendation struct {
	CompressibleRanges []RangeInfo
	ProtectedRanges    []ProtectedRange
	NothingToCompress  bool
}

Recommendation is what the nudge shows the model: what it may compress, what it may not, and whether there is anything worth doing at all.

func BuildRecommendation

func BuildRecommendation(msgs []CoreMessage, state CompressionState, cfg Config, count TokenCountFn) Recommendation

BuildRecommendation is the full recommendation pass.

type ResolvedRange

type ResolvedRange struct {
	StartIndex int
	EndIndex   int
	Kind       BoundaryKind
	// MessageIDs are the ids inside [StartIndex, EndIndex], with rendered
	// summaries excluded (they are projections, not content).
	MessageIDs []string
	// NestedBlockIDs are the active blocks whose anchor falls inside the span.
	NestedBlockIDs []string
	// Warnings records endpoints that were snapped to a containing block.
	Warnings []string
}

ResolvedRange is a request's endpoints mapped onto the current view.

type Role

type Role string

Role is the speaker of a CoreMessage.

const (
	RoleUser      Role = "user"
	RoleAssistant Role = "assistant"
	RoleTool      Role = "tool"
)

type SectionOverride

type SectionOverride = *string

SectionOverride is a three-state override: nil keeps the default, a pointer to a string replaces the section, a pointer to "" removes it.

type Sections

type Sections struct {
	NoaTags            SectionOverride
	SummariesInContext SectionOverride
	Archive            SectionOverride
}

Sections lets a host adjust the resident prompt.

type Stats

type Stats struct {
	TokensCompressed int
	CompressionCount int
}

Stats are cumulative counters for reporting.

type Tier

type Tier int

Tier is a block's compression level, 1..Config.Tiers.MaxTier. A block's tier is fixed at creation and never changes.

type TierConfig

type TierConfig struct {
	Enabled bool
	// MaxTier is the terminal level, 2 or 3 (default 3). It is not an extension
	// point — the tier rule prompts are hand-written per tier and stop at 3.
	// It exists as a TEST lever: reaching the terminal-tier guard at MaxTier=3
	// needs ~100 compressions of synthetic conversation, at MaxTier=2 about ten.
	MaxTier Tier
	// Tier2Trigger is how many active tier-1 blocks trigger a tier-2 nudge.
	Tier2Trigger int
	// Tier3Trigger is how many active tier-2 blocks trigger a tier-3 nudge.
	Tier3Trigger int
}

TierConfig controls multi-tier consolidation.

type TierDecision

type TierDecision struct {
	// TargetTier is the layer being compressed: the LOWEST tier present in the
	// range. It decides which blocks are absorbed.
	TargetTier Tier
	// OutputTier is the new block's tier, one above the target and capped at
	// MaxTier.
	OutputTier Tier
	// ConsumedBlockIDs are the blocks this compression absorbs — only those at
	// TargetTier.
	ConsumedBlockIDs []string
	// SurvivingBlockIDs are blocks inside the range that are NOT absorbed
	// because they sit above TargetTier. They stay active, and the panel reports
	// them so the model understands why they are still there.
	SurvivingBlockIDs []string
}

TierDecision is the outcome of classifying one resolved range.

func DecideTier

func DecideTier(r ResolvedRange, state CompressionState, cfg Config) TierDecision

DecideTier classifies a resolved range.

ONE TIER AT A TIME. A compression absorbs only the blocks at the lowest tier present in the range; anything higher stays active. Re-summarising an already-distilled tier-2 summary alongside raw tier-1 summaries would put the same content through three lossy passes, and detail disappears fast.

The rule is also what makes tier 3 reachable at all: if a tier-2 block could be absorbed by a same-tier sibling, TargetTier would be pinned at 1 forever and OutputTier would never exceed 2.

type TokenCountFn

type TokenCountFn func(string) int

TokenCountFn estimates a text's token cost. Injected so the host can supply a real tokenizer; DefaultCountTokens is the built-in approximation.

type TruncateConfig

type TruncateConfig struct {
	Threshold float64
}

TruncateConfig controls the mechanical last resort.

type TruncateResult

type TruncateResult struct {
	Messages       []CoreMessage
	TruncatedCount int
	SavedTokens    int
}

TruncateResult reports what the valve did.

func TruncateLargeToolOutputs

func TruncateLargeToolOutputs(msgs []CoreMessage, tokenCount int, cfg Config, count TokenCountFn) TruncateResult

TruncateLargeToolOutputs is the mechanical last resort.

Everything else in noa depends on the model choosing to compress. This does not: when the context is about to overflow, the largest tool results are cut down regardless. It is the only thing standing between an uncooperative model and a request the provider rejects outright.

It rewrites only what is SENT. The stored history keeps the originals, so the next projection sees full text again, message identity is unchanged, and the truncation lifts by itself once usage falls back.

type TurnGroup

type TurnGroup struct {
	// ReasoningIdx are the indices of the leading reasoning run.
	ReasoningIdx []int
	// ActIdx are the assistant text / tool-call indices.
	ActIdx []int
	// ResultIdx are the tool results belonging to those acts.
	ResultIdx []int
}

TurnGroup is one assistant turn as the provider sees it: the reasoning run, the assistant acts that follow it, and the tool results those acts produced.

The group is the unit turn-integrity protects. A provider that validates thinking-to-tool_use pairing rejects a request where half a group survived.

func ComputeTurnGroups

func ComputeTurnGroups(msgs []CoreMessage) []TurnGroup

ComputeTurnGroups partitions messages into assistant turns.

Only groups that actually have a reasoning run matter for turn integrity — a turn with no thinking cannot lose it — but they are all returned so callers can reason about the structure uniformly.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL