package agent // cold-digest §4/§5/§7: the Compactor ties the pure algorithms (coldgraph.go) // to the store (db/digest.go) and the LLM. It runs in two modes: // // maintain — cheap, synchronous, once per planner round: bump round_no, // recompute hot/cold, stamp/clear cold_since_round (§2.3). This is // the bookkeeping the planner does anyway; it never calls the LLM. // minor/major — background, off the planner hot path (§7): group cold nodes // and compress each ≥2 block into a digest via the LLM. minor folds // only the not-yet-covered cold set (tiered append); major re-derives // the whole grouping from source and merges fragments (§5.1/§5.2), // reusing bodies whose signature is unchanged (§5.3). // // Concurrency: one compaction per task at a time (mutex), ≥cooldown between runs, // and a commit-time liveness recheck drops any member that revived while the body // was being generated so a digest never covers a hot node. import ( "context" "encoding/json" "fmt" "log" "sort" "strings" "sync" "time" "github.com/Autumn-27/artex/db" "github.com/Autumn-27/norma/llm" "github.com/Autumn-27/norma/transcript" ) // Compactor performs background cold-node compaction for many explorations. type Compactor struct { prov llm.Provider model string params coldParams n, m int // minor / major thresholds (§7 N=20, M=8) cooldown time.Duration // min gap between compactions per task (§7 60s) maxDur time.Duration // hard cap on one background compaction mu sync.Mutex running map[int64]bool lastRun map[int64]time.Time } // NewCompactor builds a compactor. prov/model are used for the §4 body LLM call // (same model the agent runs on, per §4). A nil Compactor is a safe no-op. func NewCompactor(prov llm.Provider, model string) *Compactor { return &Compactor{ prov: prov, model: model, params: defaultColdParams(), n: 20, m: 8, cooldown: 60 * time.Second, maxDur: 5 * time.Minute, running: map[int64]bool{}, lastRun: map[int64]time.Time{}, } } // OnPlannerRound is the single entry the planner calls each wake-up. It bumps the // round, maintains the cold stamps synchronously, then (if a threshold is hit and // no compaction is running / cooling down) launches a background compaction that // outlives this planner round. func (c *Compactor) OnPlannerRound(ctx context.Context, ts *db.ExplorationStore) { if c == nil || c.prov == nil || ts == nil { return } round, uncompressed, activeDigests, err := c.maintain(ts) if err != nil { log.Printf("[compaction] maintain exp=%d: %v", ts.ID(), err) return } needMinor := uncompressed >= c.n needMajor := activeDigests >= c.m if !needMinor && !needMajor { return } if !c.tryStart(ts.ID()) { return // already running, or within cooldown — the derived state is eventually consistent; compress next round } go func() { defer c.finish(ts.ID()) bg, cancel := context.WithTimeout(context.WithoutCancel(ctx), c.maxDur) defer cancel() // Compression is a bare provider call (compress calls prov.Complete directly) and // does not go through agentcore's session loop, so the ctx carries no session id; // gateways that do prompt caching / sticky routing by session-id header // (opencode zen returns 400 when x-opencode-session is missing) would not get it. // Here we attach an id that is stable per exploration: all compression requests // of one exploration share it, so the header is present, and llmrec can attribute // this call's tokens back to that exploration (previously unrecordable). bg = transcript.WithSessionID(bg, fmt.Sprintf("exp%d-compactor", ts.ID())) if needMajor { c.major(bg, ts) } else { c.minor(bg, ts) } }() _ = round } // maintain bumps round_no, recomputes hot/cold over the whole graph, and applies // the cold_since_round stamp/clear ops (§2.3). Returns the new round plus the // counts that drive the trigger: how many eligible-cold nodes are not yet covered // (minor) and how many active digests exist (major). func (c *Compactor) maintain(ts *db.ExplorationStore) (round int64, uncompressed, activeDigests int, err error) { round, err = ts.BumpRound() if err != nil { return } g, _, err := loadColdGraph(ts) if err != nil { return } stamps, err := ts.ColdStamps() if err != nil { return } hot := g.hotSet() structCold := g.structuralCold(hot) ops := computeStampOps(structCold, stamps, round) if err = ts.ApplyStampOps(toDBStampOps(ops)); err != nil { return } applyStampsInPlace(stamps, ops) elig := g.eligibleCold(structCold, stamps, round, c.params) covered, err := ts.CoveredMembers() if err != nil { return } for id := range elig { if _, ok := covered[id]; !ok { uncompressed++ } } ad, err := ts.ActiveDigests() if err != nil { return } activeDigests = len(ad) return } // minor folds the not-yet-covered eligible-cold set into new digest segments // (tiered append, §5). Existing digests are untouched. func (c *Compactor) minor(ctx context.Context, ts *db.ExplorationStore) { round, err := ts.RoundNo() if err != nil { return } g, nodeByID, err := loadColdGraph(ts) if err != nil { return } stamps, err := ts.ColdStamps() if err != nil { return } cvers, err := ts.ContentVersions() if err != nil { return } covered, err := ts.CoveredMembers() if err != nil { return } hot := g.hotSet() elig := g.eligibleCold(g.structuralCold(hot), stamps, round, c.params) uncompressed := map[int64]bool{} for id := range elig { if _, ok := covered[id]; !ok { uncompressed[id] = true } } blocks := g.group(uncompressed, c.params) if len(blocks) == 0 { return // this batch has no ≥2 connected/shared-parent block — nothing to fold (§7) } for _, b := range blocks { c.foldBlock(ctx, ts, g, b, nodeByID, cvers, c.generationFor(b, nil)) } // A minor may have pushed the segment count over M → merge in the same run. if ad, e := ts.ActiveDigests(); e == nil && len(ad) >= c.m { c.major(ctx, ts) } } // major re-derives the whole grouping from source over ALL eligible-cold nodes // (§5.1 re-compress from source), then reconciles against the active digests by signature: // unchanged blocks keep their digest (no LLM), stale digests are superseded, and // new/changed blocks are compressed afresh. This is where tiered fragments of one // direction merge and where "later became connected" blocks unify (§5.2). func (c *Compactor) major(ctx context.Context, ts *db.ExplorationStore) { round, err := ts.RoundNo() if err != nil { return } g, nodeByID, err := loadColdGraph(ts) if err != nil { return } stamps, err := ts.ColdStamps() if err != nil { return } cvers, err := ts.ContentVersions() if err != nil { return } active, err := ts.ActiveDigests() if err != nil { return } hot := g.hotSet() elig := g.eligibleCold(g.structuralCold(hot), stamps, round, c.params) blocks := g.group(elig, c.params) bySig := map[string]*db.Node{} for _, d := range active { sig, _ := digestSigGen(d) bySig[sig] = d } desired := map[string]bool{} var toCreate []block for _, b := range blocks { sig := blockSignature(b, cvers) desired[sig] = true if _, ok := bySig[sig]; ok { continue // unchanged → reuse the existing digest, skip LLM (§5.3) } toCreate = append(toCreate, b) } // Supersede stale digests FIRST (atomic drop of their covers edges) so a member // is never covered by both an old and a new digest (§5.1 one-member-one-digest). var stale []int64 for _, d := range active { sig, _ := digestSigGen(d) if !desired[sig] { stale = append(stale, d.ID) } } if err := ts.SupersedeDigests(stale); err != nil { log.Printf("[compaction] supersede exp=%d: %v", ts.ID(), err) } for _, b := range toCreate { c.foldBlock(ctx, ts, g, b, nodeByID, cvers, c.generationFor(b, active)) } } // foldBlock compresses one block and writes its digest — with a commit-time // liveness recheck (§ concurrency): between grouping and write the graph may have // changed, so any member that has since gone hot (revived) is dropped from the // covers set. If the block dissolves below K it is skipped. func (c *Compactor) foldBlock(ctx context.Context, ts *db.ExplorationStore, g *coldGraph, b block, nodeByID map[int64]*db.Node, cvers map[int64]int, generation int) { body, err := c.compress(ctx, g, b, nodeByID) if err != nil { log.Printf("[compaction] compress exp=%d block=%v: %v", ts.ID(), b.Members, err) return } // Re-read fresh state and drop any member that revived while we compressed. fresh, _, err := loadColdGraph(ts) if err != nil { return } freshHot := fresh.hotSet() members := make([]int64, 0, len(b.Members)) for _, mID := range b.Members { if !freshHot[mID] { members = append(members, mID) } } if len(members) < c.params.K { return // block revived out from under us — leave those nodes hot, don't fold } final := block{Members: members, Anchors: b.Anchors} payload := digestPayload(body, final, nodeByID, generation, blockSignature(final, cvers)) if _, err := ts.AddDigest(payload, members); err != nil { log.Printf("[compaction] add digest exp=%d: %v", ts.ID(), err) } } // generationFor computes a digest's re-digestion generation (§1): 1 for a fresh fold; for a major // merge, max(generation) over the active digests that overlap this block's // members, +1. func (c *Compactor) generationFor(b block, active []*db.Node) int { if len(active) == 0 { return 1 } memberSet := make(map[int64]bool, len(b.Members)) for _, m := range b.Members { memberSet[m] = true } best := 0 for _, d := range active { _, gen := digestSigGen(d) for _, m := range digestMemberIDs(d) { if memberSet[m] { if gen > best { best = gen } break } } } return best + 1 } // tryStart acquires the per-task compaction lock, honoring the cooldown. func (c *Compactor) tryStart(expID int64) bool { c.mu.Lock() defer c.mu.Unlock() if c.running[expID] { return false } if t, ok := c.lastRun[expID]; ok && time.Since(t) < c.cooldown { return false } c.running[expID] = true return true } func (c *Compactor) finish(expID int64) { c.mu.Lock() defer c.mu.Unlock() c.running[expID] = false c.lastRun[expID] = time.Now() } // --- helpers: db ↔ coldgraph --- // loadColdGraph reads the exploration's nodes + edges and builds the cold-graph // view plus an id→node index (for summaries/payload during compression). func loadColdGraph(ts *db.ExplorationStore) (*coldGraph, map[int64]*db.Node, error) { // Compaction must see the WHOLE graph, not the default row caps — pass a very // high limit so the LIMIT clause is effectively unbounded for real task sizes. const allRows = 1 << 30 nodes, err := ts.Nodes(allRows) if err != nil { return nil, nil, err } edges, err := ts.Edges(allRows) if err != nil { return nil, nil, err } cgNodes := make([]cgNode, 0, len(nodes)) byID := make(map[int64]*db.Node, len(nodes)) for _, n := range nodes { cgNodes = append(cgNodes, cgNode{ID: n.ID, Kind: n.Kind, State: n.State}) byID[n.ID] = n } cgEdges := make([]cgEdge, 0, len(edges)) for _, e := range edges { cgEdges = append(cgEdges, cgEdge{From: e.From, Rel: e.Rel, To: e.To}) } return newColdGraph(cgNodes, cgEdges), byID, nil } func toDBStampOps(ops []stampOp) []db.StampOp { out := make([]db.StampOp, len(ops)) for i, o := range ops { out[i] = db.StampOp{ID: o.ID, Set: o.Set, Round: o.Round} } return out } // applyStampsInPlace folds the just-applied ops into the in-memory stamp map so // eligibility can be computed immediately without a re-read. func applyStampsInPlace(stamps map[int64]*int64, ops []stampOp) { for _, o := range ops { if o.Set { r := o.Round stamps[o.ID] = &r } else { stamps[o.ID] = nil } } } // --- helpers: digest payload --- // digestPayload builds the digest node payload (cold-digest §1): the body, the // member ids split by kind (restore cache; source of truth is the covers edges), // the anchor ids, the generation, and the change-detection signature. func digestPayload(body string, b block, nodeByID map[int64]*db.Node, generation int, signature string) map[string]any { var facts, intents []int64 for _, m := range b.Members { if n := nodeByID[m]; n != nil && n.Kind == db.KindIntent { intents = append(intents, m) } else { facts = append(facts, m) } } return map[string]any{ "body": body, "member_ids": map[string]any{"facts": facts, "intents": intents}, "anchor_ids": b.Anchors, "generation": generation, "signature": signature, } } func digestSigGen(n *db.Node) (string, int) { var p struct { Signature string `json:"signature"` Generation int `json:"generation"` } _ = json.Unmarshal(n.Payload, &p) return p.Signature, p.Generation } func digestMemberIDs(n *db.Node) []int64 { var p struct { MemberIDs struct { Facts []int64 `json:"facts"` Intents []int64 `json:"intents"` } `json:"member_ids"` } _ = json.Unmarshal(n.Payload, &p) return append(append([]int64{}, p.MemberIDs.Facts...), p.MemberIDs.Intents...) } // --- helpers: compression input + LLM (§4) --- func nodeSummary(n *db.Node) string { if n == nil { return "" } var p map[string]any if json.Unmarshal(n.Payload, &p) == nil { if s, ok := p["summary"].(string); ok { return s } if t, ok := p["text"].(string); ok { return t } } return "" } func nodeConfidence(n *db.Node) string { if n == nil { return "" } var p map[string]any if json.Unmarshal(n.Payload, &p) == nil { if c, ok := p["confidence"].(string); ok { return c } } return "" } // nodeSource returns a fact's declared source ("" when it has none). func nodeSource(n *db.Node) string { if n == nil { return "" } var p map[string]any if json.Unmarshal(n.Payload, &p) == nil { if s, ok := p["source"].(string); ok { return s } } return "" } // buildCompressionInput renders the connected sub-graph for the §4 prompt: // member nodes (summary + id + kind + state + confidence + source + origin), the internal blood // edges among members, and — for a §3.1 shared-parent group — the anchor parents // as context ("common parent #p"), which are NOT members. func buildCompressionInput(g *coldGraph, b block, nodeByID map[int64]*db.Node) string { memberSet := make(map[int64]bool, len(b.Members)) for _, m := range b.Members { memberSet[m] = true } var sb strings.Builder sb.WriteString("[Member nodes (to be compressed)]:\n") for _, m := range b.Members { n := nodeByID[m] kind := "fact" if n != nil && n.Kind == db.KindIntent { kind = "intent" } state := "" if n != nil { state = n.State } line := fmt.Sprintf("- #%d [%s/%s] %s", m, kind, state, nodeSummary(n)) // provenance (#124, F4): source is the fact's declared source, origin the // writer recorded by the server. var attrs []string if conf := nodeConfidence(n); conf != "" { attrs = append(attrs, "confidence="+conf) } if src := nodeSource(n); src != "" { attrs = append(attrs, "source="+src) } if n != nil && n.Origin != "" { attrs = append(attrs, "origin="+n.Origin) } if len(attrs) > 0 { line += " (" + strings.Join(attrs, ", ") + ")" } sb.WriteString(line) sb.WriteByte('\n') } // internal edges among members var edgeLines []string for _, m := range b.Members { for _, to := range g.children[m] { if memberSet[to] { edgeLines = append(edgeLines, fmt.Sprintf("- #%d yields/derives→ #%d", m, to)) } } } if len(edgeLines) > 0 { sb.WriteString("\n[Lineage edges between members (parent→child)]:\n") sort.Strings(edgeLines) sb.WriteString(strings.Join(edgeLines, "\n")) sb.WriteByte('\n') } if len(b.Anchors) > 0 { sb.WriteString("\n[Common parents / context anchors (not members; only used to understand which intent these results were explored from)]:\n") for _, a := range b.Anchors { n := nodeByID[a] state := "" if n != nil { state = n.State } fmt.Fprintf(&sb, "- #%d [%s] %s\n", a, state, nodeSummary(n)) } } return sb.String() } // compress runs the §4 body LLM call on one block. Uses the same model the agent // runs on; thinking disabled (a pure summarization step). func (c *Compactor) compress(ctx context.Context, g *coldGraph, b block, nodeByID map[int64]*db.Node) (string, error) { req := llm.CompletionRequest{ System: []string{compressionSystem}, Messages: []llm.Message{llm.UserText(buildCompressionInput(g, b, nodeByID))}, MaxTokens: 1500, Thinking: "disabled", } msg, _, _, err := c.prov.Complete(ctx, req) if err != nil { return "", err } body := strings.TrimSpace(msg.Text()) if body == "" { return "", fmt.Errorf("empty body from model") } return body, nil } // compressionSystem is the compactor's full system prompt: the §4 body prompt plus // the code-owned untrusted-data tail shared with the planner (#124). const compressionSystem = compressionSystemPrompt + untrustedDataRules + compactorDataRule // compressionSystemPrompt is the §4 body prompt. const compressionSystemPrompt = `You are compressing a group of [interconnected] exploration nodes into one consolidated conclusion (body), so the planner can quickly grasp "what this area has already established". The input is a connected sub-graph: - Nodes: each line is the summary (one sentence) of an intent or a fact, with id, kind (intent/fact), state, and confidence (if any). - Relations: lineage edges between nodes (A derived from B / A yields B), explaining how they chain together. - If nodes share no direct lineage edge but belong to the same upstream intent (given separately as "common parent #p"), consolidate them as "what this intent (#p) explored" — common parents are only context anchors, not members to compress. Write the body accordingly: 1. Synthesize, don't enumerate: follow the relations to chain the causality (which fact spawned which intent, which intent produced which conclusion), telling "what this area of exploration concluded" — do not copy each summary verbatim. 2. Preserve distinctions: state differing conclusions separately; don't blend them into one vague sentence. 3. Preserve evidence strength: mark conclusions carrying confidence as observed / inferred; for inferred negative/doubtful conclusions, point out they are only inferences and can be rechecked — don't write them as settled facts. 4. Include ids: annotate each conclusion with its source node ids (e.g. "…(#12,#28)"), so the planner can restore the original nodes by id. 5. State positively, write only what the input contains: no speculation, no judgments absent from the input. 6. Length adapts to content: short when there are few conclusions, long enough when there are many distinct ones — but overall significantly shorter than the sum of all input summaries. Output only the body text itself.`