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
- Variables
- func AdjustBoundariesForReasoningPairs(start, end int, msgs []CoreMessage) (int, int)
- func AdjustBoundariesForToolPairs(start, end int, msgs []CoreMessage, maxScan int) (int, int)
- func AllocateBlockID(s *CompressionState) string
- func ApplyPairBoundaryAdjustments(start, end int, msgs []CoreMessage) (int, int)
- func ArchiveFileName(blockID, startRef, endRef string) string
- func ArchiveTag(b CompressionBlock) string
- func ArchiveTierDir(t Tier) string
- func BlockIDFromSummaryID(s string) (string, bool)
- func BuildCompressibleRanges(msgs []CoreMessage, state CompressionState, cfg Config, ...) ([]RangeInfo, []ProtectedRange)
- func BuildSystemPrompt(s Sections) string
- func CompressToolSchema() map[string]any
- func ComputeContextBreakdown(msgs []CoreMessage, count TokenCountFn) map[string]int
- func ComputeProtectedRefs(msgs []CoreMessage, state CompressionState, cfg Config) map[string]bool
- func CountMessageTokens(m CoreMessage, count TokenCountFn) int
- func CoveredMessageIDs(s CompressionState) map[string]bool
- func DefaultCountTokens(text string) int
- func EstimateTokensFast(text string) int
- func FormatBlockMap(spans []BlockSpan) string
- func FormatBreakdown(d NudgeDecision) string
- func FormatPanel(in PanelInput) string
- func FormatRanges(compressible []RangeInfo, protected []ProtectedRange) string
- func FormatTierTargetBlocks(blocks []CompressionBlock) string
- func FormatTokens(n int) string
- func HighestUsedIndex(m MessageRefMap) int
- func IndexToRef(index int) string
- func IsMessageProtected(m CoreMessage, cfg Config) bool
- func IsNeverPreserveRecentTool(m CoreMessage) bool
- func IsTerminalRewrite(d TierDecision, directCount int, cfg Config) bool
- func MatchToolPattern(pattern, toolName string) bool
- func NudgeGrowthFloor(cfg Config) int
- func PanelBlockCount(panel string) int
- func ParseBlockID(s string) (string, bool)
- func ParseBoundary(s string) (boundaryRef, bool)
- func ProjectedTokenCount(msgs []CoreMessage, state CompressionState, cfg Config, count TokenCountFn) int
- func RawForRef(m MessageRefMap, ref string) (string, bool)
- func RefForRaw(m MessageRefMap, rawID string) (string, bool)
- func RefToIndex(ref string) (int, bool)
- func RenderArchive(in ArchiveRenderInput) []byte
- func ResolveBoundaries(startRef, endRef string, messages []CoreMessage, state CompressionState) (ResolvedRange, *BoundaryNotFoundError)
- func RunPruneOnly(msgs []CoreMessage, state CompressionState, cfg Config) ([]CoreMessage, CompressionState)
- func SummaryMessageID(blockID string) string
- func ValidateConfig(c Config) []string
- func WithdrawSplitTurns(msgs []CoreMessage, folded map[string]bool) (map[string]bool, []string)
- type ApplyInput
- type ApplyResult
- type ArchiveEntry
- type ArchiveRenderInput
- type Archiver
- type AssignRefsOptions
- type AssignRefsResult
- type BlockSpan
- type BoundaryKind
- type BoundaryNotFoundError
- type BoundaryNotFoundKind
- type CompressConfig
- type CompressParseDiagnostics
- type CompressParseKind
- type CompressParseResult
- type CompressRange
- type CompressionBlock
- type CompressionState
- type Config
- type ContentType
- type CoreMessage
- type DecideNudgeInput
- type MessageRefMap
- type NodeIO
- type NudgeBreakdown
- type NudgeConfig
- type NudgeDecision
- type NudgeSections
- type NudgeState
- type NudgeVoice
- type PanelInput
- type PipelineContext
- type PipelineNode
- type ProcessTurnInput
- type ProcessTurnResult
- type ProtectedRange
- type RangeInfo
- type RebuildInput
- type RebuildResult
- type Recommendation
- type ResolvedRange
- type Role
- type SectionOverride
- type Sections
- type Stats
- type Tier
- type TierConfig
- type TierDecision
- type TokenCountFn
- type TruncateConfig
- type TruncateResult
- type TurnGroup
Constants ¶
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 )
const CompressPhilosophy = `` /* 532-byte string literal not displayed */
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.
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.
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.
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.
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.
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.
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 ¶
var AlwaysProtectedTools = []string{CompressToolName}
AlwaysProtectedTools can never be compressed, regardless of configuration.
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 ¶
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 ¶
ArchiveTierDir is the subdirectory a tier's archives live in.
func BlockIDFromSummaryID ¶
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 ¶
BuildSystemPrompt assembles the resident prompt.
func CompressToolSchema ¶
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 ¶
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 ¶
EstimateTokensFast skips the CJK scan; used where a rough figure suffices.
func FormatBlockMap ¶
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 ¶
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 ¶
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 ¶
MatchToolPattern matches a tool name against a configured pattern: exact (case-insensitive), or a single trailing "*" prefix wildcard.
func NudgeGrowthFloor ¶
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 ¶
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 ¶
ParseBlockID accepts "b5", "b005", or a bare "5", plus a trailing tier label as displayed ("b5(T2)"). Returns the canonical "bN".
func ParseBoundary ¶
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 ¶
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 ¶
SummaryMessageID is the synthetic id of a block's rendered summary.
func ValidateConfig ¶
ValidateConfig returns warnings, not errors: a questionable knob degrades behaviour but must never take down a session. Callers log and continue.
func WithdrawSplitTurns ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 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 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 ¶
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.
Source Files
¶
- archive.go
- boundaries.go
- compress.go
- config.go
- const.go
- hideconsumed.go
- nudge.go
- nudge_types.go
- nudgetext.go
- panel.go
- parseinput.go
- pipeline.go
- processturn.go
- prompts_rules.go
- prompts_system.go
- protected.go
- prune.go
- rebuild.go
- recommend.go
- refs.go
- schema.go
- state.go
- sync.go
- tier.go
- tokenize.go
- toolpairs.go
- truncate.go
- turnintegrity.go
- types.go