# Context Aggregation: How Manual Slop Builds the AI's Context

[Top](../Readme.md) | [Discussions](guide_discussions.md) | [Context Curation](guide_context_curation.md) | [Models](guide_models.md) | [Architecture](guide_architecture.md)

---

## Overview

`src/aggregate.py` (518 lines) is the **context composition pipeline** — the single function that turns a project's `files` + `screenshots` + `history` config into the final markdown string the AI sees. It is called by:

- `src/ai_client.py:_send_anthropic`, `_send_deepseek`, `_send_gemini`, `_send_gemini_cli`, `_send_minimax` (every provider)
- `src/app_controller.py:AppController._do_generate` (the main send path)
- `src/app_controller.py:AppController._cb_start_track`, `AppController._process_event_queue`, `AppController._start_track_logic` (MMA paths)
- `src/gui_2.py:App.run`, `App.main`, `App._render_snapshot_tab` (the GUI and the prior-session replay)
- `simulation/sim_base.py:run_sim` and 6 other simulation entry points

This is one of the most-touched modules in the project. After the nagent_review, this pipeline is recognized as **Manual Slop's strongest curation dimension** (vs nagent's conversation-log dimension). See `conductor/tracks/nagent_review_20260608/report.md §6` and `decisions.md` candidate #7 for the related future-track.

> **Domain classification.** The pipeline is **Application**-domain. The MMA sub-agents consume it but the pipeline itself does not call into Meta-Tooling code. See `guide_meta_boundary.md`.

---

## The Pipeline At A Glance

```
aggregate.run(config, aggregation_strategy)
  ├─ find_next_increment(output_dir, namespace)    # next file number for output
  ├─ build_file_items(base_dir, files)             # read + view-mode transform
  ├─ build_markdown_from_items(file_items, ...)   # compose sections
  │   ├─ ## Files (or Files (Summary) or Files (Tier 3 - Focused))
  │   │   └─ _build_files_section_from_items OR summarize.build_summary_markdown
  │   ├─ ## Screenshots (if any)
  │   ├─ ## Beads Mode: Progress Track (if execution_mode == "beads")
  │   └─ ## Discussion History (if any)
  └─ output_file.write_text(markdown)
```

The **output** is a markdown file at `{output_dir}/{namespace}_{NNN}.md` where `NNN` is a zero-padded increment. The pipeline does not *send* the markdown — that's the AI client's job. The pipeline *produces* the markdown.

The **return value** is `(markdown: str, output_file: Path, file_items: list[dict])`. The file_items list is reused by callers that want to inspect the read state without re-reading from disk.

---

## The Three Aggregation Strategies

`aggregation_strategy: str` selects how files are rendered. The values:

| Strategy | File rendering | History rendering | Tier 3 handling | Use case |
|---|---|---|---|---|
| `auto` | If `summary_only` is True → summary; else → full | Standard | Standard | Default. Reads `config.project.summary_only`. |
| `summarize` | Always `summarize.build_summary_markdown(file_items)` (compact multi-file view) | Standard | Standard | Token-budget-constrained runs. |
| `full` | Always `_build_files_section_from_items(file_items)` (full content) | Standard | Standard | Debugging; when you want the AI to see everything. |

**Implementation:** `aggregate.py:330-346 build_markdown_from_items`. The three-way dispatch is at lines 335-339:

```python
if   aggregation_strategy == "summarize": parts.append("## Files (Summary)\n\n" + summarize.build_summary_markdown(file_items))
elif aggregation_strategy == "full":      parts.append("## Files\n\n"           + _build_files_section_from_items(file_items))
else: # auto
    if summary_only: parts.append("## Files (Summary)\n\n" + summarize.build_summary_markdown(file_items))
    else:            parts.append("## Files\n\n"           + _build_files_section_from_items(file_items))
```

The `auto` strategy is the *only* one that respects `config.project.summary_only`; the other two are explicit overrides. Personas can also set `aggregation_strategy` (per `guide_personas.md`), and a persona-set strategy overrides the config-level setting.

---

## View Modes — The Per-File Transform

`view_mode: str` is the per-file content transform. The value is set on the `FileItem` (or the legacy dict-shaped config entry) and determines how the file's bytes are rendered into the markdown.

| View mode | Behavior | Source |
|---|---|---|
| `full` | Raw `path.read_text(encoding="utf-8")` content. | `aggregate.py:205` |
| `summary` | `summarize.summarise_file(path, content)` — heuristic summary from `src/summarize.py`. | `aggregate.py:210` |
| `skeleton` | For `.py`: `ASTParser("python").get_skeleton(content)` (tree-sitter). For `.c`/`.h`: `mcp_client.ts_c_get_skeleton`. For `.cpp`/`.hpp`: `mcp_client.ts_cpp_get_skeleton`. Other → summary. | `aggregate.py:211-220` |
| `outline` | For `.py`: `ASTParser("python").get_code_outline(content)`. For C/C++: `mcp_client.ts_c*_get_code_outline`. Other → summary. | `aggregate.py:221-230` |
| `masked` | For each `{symbol: mode}` in `ast_mask`, fetch `def` or `sig` via `mcp_client.py/ts_*_get_definition/signature`. Concatenate. | `aggregate.py:231-249` |
| `none` | Literal string `"(context excluded)"` — the file is in the file_items list but contributes no content. | `aggregate.py:250` |
| `custom` | Render only the `custom_slices` from the FileItem. Each slice is a `{start_line, end_line, tag, comment}` dict. Lines outside the slices are excluded. | `aggregate.py:251-266` |

**The default view mode** is `full`. The persona can override via `Persona.aggregation_strategy`; the FileItem can override via `FileItem.view_mode` or `FileItem.force_full` (which forces `full` regardless of the FileItem's own setting).

**Errors are graceful.** A `FileNotFoundError` produces `f"ERROR: file not found: {path}"` content with `error: True` and `mtime: 0.0`. A `view_mode` that throws produces `f"ERROR in {view_mode} view mode for {path}:\n{traceback.format_exc()}"`. Errors do not halt the pipeline.

---

## The FileItem Schema (Full)

`src/models.py:510-559 FileItem` is the **per-file curation memory** that nagent_review identified as Manual Slop's strongest dimension. The dataclass has 9 mutable fields + a `__post_init__` normalizer:

```python
@dataclass
class FileItem:
    path:            str                # the artifact identity (path-keyed, no inode)
    auto_aggregate:  bool = True       # include in auto-aggregation? (skip in build_*_from_items if False)
    force_full:      bool = False      # bypass view_mode; force raw content
    view_mode:       str = 'full'      # one of: full, summary, skeleton, outline, masked, custom, none
    selected:        bool = False      # for batch operations (the Context Panel multi-select)
    ast_signatures:  bool = False      # include only signatures (skeleton-equivalent shortcut)
    ast_definitions: bool = False      # include only definitions (skeleton-equivalent shortcut)
    ast_mask:        dict[str, str]    # per-symbol mask: {symbol_path: 'def'|'sig'|'hide'} (from Structural File Editor)
    custom_slices:   list[dict]        # Fuzzy Anchor slices: {start_line, end_line, tag, comment, ...}
    injected_at:     Optional[float]   # timestamp of last injection
```

The 9 fields are *all* serialized by `to_dict()` and *all* deserialized by `from_dict()` (with `.get(..., default)` for forward compatibility). The dataclass is round-trip-safe through TOML.

`__post_init__` normalizes `custom_slices`: each slice dict gets `tag=None` and `comment=None` defaults added so downstream code can `.get("tag")` safely.

### The Custom Slice Schema

A `custom_slices` entry is `{start_line, end_line, tag, comment, ...}` (plus Fuzzy Anchor metadata). The full schema is in `src/fuzzy_anchor.py:FuzzyAnchor.create_slice`:

```python
{
    "start_line":  int,        # 1-based original line
    "end_line":    int,        # 1-based original line (inclusive)
    "tag":         str|None,   # human label, defaults to None
    "comment":     str|None,   # human comment, defaults to None
    "content_hash": str,       # SHA-256 of the slice content (for Fuzzy Anchor stability)
    "anchor_lines": [str, ...],# surrounding context for re-resolution
    # plus the original positioning metadata
}
```

When `view_mode == 'custom'`, the `aggregate.py:251-264` block renders each slice as:

```markdown
---
[Slice: <tag>] (<comment>)
Lines <start>-<end>:
<content>
```

Multiple slices in a file are joined with `\n\n`.

---

## The ContextPreset Schema

`src/models.py:909-937 ContextPreset` is a *named, persisted set* of `FileItem`s — a reusable "context composition":

```python
@dataclass
class ContextPreset:
    name:        str                                  # the preset name (used as TOML key)
    files:       list[ContextFileEntry] = field(default_factory=list)
    screenshots: list[str] = field(default_factory=list)
    description: str = ""
```

`ContextFileEntry` is a `FileItem` (or a string path that's promoted to a `FileItem` on load). The `description` is a human-readable label for the preset list.

`ContextPresetManager` (in `src/context_presets.py`, 30 lines) handles CRUD:
- `save_preset(preset: ContextPreset)` writes to `manual_slop.toml` or a project TOML
- `load_all() -> dict[str, ContextPreset]` reads all presets
- `delete_preset(name: str)` removes a preset
- `apply_preset(name: str)` switches the active context composition to the named preset

`reload_context_presets()` (in `app_controller.py`) is called when the project TOML changes; it validates that all files in the preset still exist and warns the user about any that don't.

**Scope:** ContextPresets can be **Global** (in `<user_config>/manual_slop.toml`) or **Project-specific** (in the project's `manual_slop.toml`). Project presets override global presets of the same name. This is the same scope-inheritance pattern as Personas, Presets, and Workspace Profiles.

---

## The Discussion History Section

`aggregate.py:109 build_discussion_section(history)` is the section that includes the prior conversation:

```python
def build_discussion_section(history: list[Any]) -> str:
    sections = []
    for i, entry in enumerate(history, start=1):
        if isinstance(entry, dict):
            role    = entry.get("role", "Unknown")
            content = entry.get("content", "").strip()
            text    = f"{role}: {content}"
        else:
            text = str(entry).strip()
        sections.append(f"### Discussion Excerpt {i}\n\n{text}")
    return "\n\n---\n\n".join(sections)
```

The section handles *both* legacy `list[str]` (e.g. `["User: ...", "AI: ..."]`) and the new `list[dict]` shape (`[{"role": ..., "content": ...}, ...]`). The dict shape is what's persisted by `_flush_disc_entries_to_project` (per `app_controller.py:3225-3240`) and what's stored in the new format.

The section is named **`## Discussion History`** and is placed at the *end* of the markdown (after files, screenshots, beads). This is deliberate: the cache-hit-friendly static prefix is at the top, the dynamic history is at the bottom. See `guide_architecture.md §"Cache Strategy"`.

---

## Cache Strategy

The pipeline is structured to maximize provider cache hits. The static prefix (Files + Screenshots + Beads) is the same across all turns of a discussion; only the Discussion History changes. The provider's cache key is the prefix; the history is appended.

`build_markdown_no_history` (`aggregate.py:348-353`) is the explicit "static-only" builder used by `_do_generate` *before* adding the history. The full builder is `build_markdown_from_items` which adds the history if non-empty. This split allows the AI client to:

1. Send the static prefix once.
2. Append the history to the next send without re-sending the prefix.
3. Re-use the cached prefix on the third send (if the files haven't changed).

The cache strategy is documented in detail in `guide_ai_client.md §"Caching Strategy"` and `guide_architecture.md §"Cache Hit Strategy"`.

---

## The Tier-3 Variant

`aggregate.py:364-454 build_tier3_context` is the **MMA worker context** — a different layout for sub-agent invocations. The differences from the standard pipeline:

1. **Focus files** (passed as `focus_files: list[str]`) are rendered as **full content** regardless of their `view_mode`. A file is a focus file if its `entry`, name, or path matches one of the focus paths.
2. **Slices are resolved via FuzzyAnchor.** If a file has `custom_slices` and the file content has been modified since the slice was created, the FuzzyAnchor re-resolves the line ranges. This is critical for sub-agents receiving slices that may be stale.
3. **Section header is `## Files (Tier 3 - Focused)`.** Distinct from the standard `## Files` so the worker (and its tools) can recognize its own context.
4. **The `is_focus` check is multi-level.** Entry match, name match, path match, and substring match. Sub-agents with looser file-matching needs can pass a focus set that's just a list of basenames.

The Tier 3 build skips the `summarize.build_summary_markdown` path entirely; every file is rendered with `_build_files_section_from_items`-style formatting (or the AST skeleton for non-focus Python files, or the AST signature/outline for C/C++).

The Tier 3 build is called from `multi_agent_conductor.py:run_worker_lifecycle` via `aggregate.run(config, aggregation_strategy=tier_strategy)`.

---

## The Bypass — `force_full`

`FileItem.force_full = True` short-circuits the `view_mode` selection:

```python
if force_full: view_mode = "full"
```

This is set at the `FileItem` level (not the strategy level). Use case: the user has set a global "skeleton" view mode for the project but wants one specific file to always be inlined in full. The force is per-file and overrides both the FileItem's own `view_mode` and any strategy-level override.

For Tier 3, `force_full` is treated as a *focus flag*:

```python
if is_focus or tier == 3 or force_full:
    # full content, no skeleton
```

So a `force_full=True` file in a Tier 3 worker context is treated as a focus file and rendered in full.

---

## Auto-Aggregate Skip

`FileItem.auto_aggregate = False` causes the file to be *included in the file_items list* but *excluded from the rendered markdown*:

```python
for item in file_items:
    if not item.get("auto_aggregate", True): continue
    # ... build section
```

Use case: the file is in the `files` list for the AI's *awareness* (e.g. "you can read it via `read_file`") but should not be inlined. The file's `mtime` and `view_mode` are still tracked; the file is *omitted* from the rendered markdown.

This is distinct from `view_mode == "none"`:
- `auto_aggregate = False` → file is not in the rendered markdown at all (no `### File` header)
- `view_mode = "none"` → file is in the rendered markdown as `### File (excluded)` with a `"(context excluded)"` body

The two are useful for different scenarios. `auto_aggregate = False` is for "the AI knows the file exists, can read it on demand." `view_mode = "none"` is for "the AI knows we deliberately excluded this content."

---

## Screenshots

`aggregate.py:126-140 build_screenshots_section` renders the screenshots list as a `## Screenshots` markdown section. Each screenshot is rendered as `![name](path)` (markdown image syntax). Path resolution uses `resolve_paths` (same as for files), so wildcards and absolute paths work.

**Screenshots are placed *after* Files and *before* Beads and Discussion History.** This is a deliberate ordering: the AI sees the project's files first (the static content), then the screenshots (the visual context), then the beads status (if applicable), then the discussion history (the dynamic content).

---

## Beads Mode

When `execution_mode == "beads"` (set in `config.project.execution_mode`), the pipeline appends a `## Beads Mode: Progress Track` section between Screenshots and Discussion History. The section is built by `aggregate.py:309-328 build_beads_section`:

- Lists all *completed* beads as a comma-separated list
- Lists all *active* beads as bullet points with title, id, and description

`build_beads_section` returns an empty string if the project is not a Beads project (`client.is_initialized()` is False) or if there are no beads. The caller (`build_markdown_from_items`) checks the truthiness before appending.

See `guide_beads.md` for the full Beads integration.

---

## Output File Numbering

`find_next_increment(output_dir, namespace)` (`aggregate.py:36-44`) scans `output_dir` for files matching `^{namespace}_(\d+)\.md$` and returns `max_num + 1`. The output filename is `{namespace}_{NNN:03d}.md` (zero-padded to 3 digits). The increment starts at 1 and grows monotonically.

The increment is the *artifact identity* for the conversation. Each turn produces a new file. The current implementation does *not* delete old files; the `LogPruner` (per `guide_architecture.md`) handles cleanup separately.

---

## Pipeline Callers

`aggregate.run` is called from many places. The most important:

| Caller | Purpose |
|---|---|
| `src/ai_client.py:_send_anthropic` | Build the markdown for an Anthropic send. |
| `src/ai_client.py:_send_gemini` | Build the markdown for a Gemini send. |
| `src/ai_client.py:_send_deepseek` | Build the markdown for a DeepSeek send. |
| `src/ai_client.py:_send_gemini_cli` | Build the markdown for a Gemini CLI send. |
| `src/ai_client.py:_send_minimax` | Build the markdown for a MiniMax send. |
| `src/app_controller.py:AppController._do_generate` | The main 1:1 send path. |
| `src/app_controller.py:AppController._cb_start_track` | Start a new MMA track. |
| `src/app_controller.py:AppController._process_event_queue` | Process a queued event (e.g. send, switch discussion). |
| `src/multi_agent_conductor.py:run_worker_lifecycle` | Spawn a Tier 3 worker (with Tier 3 context). |
| `src/gui_2.py:App.run` | The main GUI loop. |
| `src/gui_2.py:App._render_snapshot_tab` | Render a prior-session replay snapshot. |
| `simulation/sim_base.py:run_sim` | Run a simulation. |

The aggregation strategy is set per-call:
- The main `_do_generate` uses `config.project.aggregation_strategy` (which is the persona-set strategy if a persona is active).
- MMA worker contexts use the worker's `aggregation_strategy` from the ticket config.
- The simulation uses a fixed `auto`.

---

## Public API Surface

The public API of `aggregate.py` is:

| Function | Signature | Purpose |
|---|---|---|
| `find_next_increment` | `(output_dir: Path, namespace: str) -> int` | Next file number for output. |
| `resolve_paths` | `(base_dir: Path, entry: str) -> list[Path]` | Expand globs and absolute paths. Blacklist `history.toml` and `*_history.toml`. |
| `group_files_by_dir` | `(files: list[Any]) -> dict[str, list[Any]]` | Group FileItems by relative directory path (used by the Context Panel UI). |
| `compute_file_stats` | `(abs_path: str) -> dict[str, int]` | Line count + AST element count for Python files. |
| `build_file_items` | `(base_dir, files) -> list[dict]` | Read + view-mode transform per file. The most-called function. |
| `build_discussion_section` | `(history) -> str` | Render the `## Discussion History` markdown. |
| `build_screenshots_section` | `(base_dir, screenshots) -> str` | Render the `## Screenshots` markdown. |
| `build_beads_section` | `(base_dir) -> str` | Render the `## Beads Mode: Progress Track` markdown. |
| `build_markdown_from_items` | `(file_items, screenshot_base_dir, screenshots, history, summary_only, aggregation_strategy, execution_mode, base_dir) -> str` | Compose all sections. The "compose" function. |
| `build_markdown_no_history` | `(file_items, screenshot_base_dir, screenshots, summary_only, aggregation_strategy) -> str` | Compose without history (for stable caching). |
| `build_discussion_text` | `(history) -> str` | Just the history section, for callers that want to append to a pre-built static prefix. |
| `build_tier3_context` | `(file_items, screenshot_base_dir, screenshots, history, focus_files) -> str` | Tier 3 worker context. |
| `build_markdown` | `(base_dir, files, screenshot_base_dir, screenshots, history, summary_only, execution_mode) -> str` | Convenience: read files + compose. |
| `run` | `(config, aggregation_strategy) -> tuple[str, Path, list[dict]]` | The full pipeline. |
| `main` | `() -> None` | CLI entry point. Loads config, calls `run`, prints output path. |

**Performance:** the entire pipeline is O(N) in the number of files, with the per-file AST work being the most expensive step. `build_tier3_context` includes `with get_monitor().scope("build_tier3_context")` (and similar for `build_file_items` and `build_markdown_no_history`) for performance monitoring. The monitor is documented in `guide_architecture.md §"Performance"`.

---

## Performance Considerations

The `view_mode` selection has a meaningful performance impact:

| view_mode | Per-file cost | When to use |
|---|---|---|
| `full` | 1 file read + string concat | Small files, files the user is actively editing. |
| `summary` | 1 file read + 1 heuristic call to `summarize.summarise_file` | Large files where structural info is enough. |
| `skeleton` | 1 file read + 1 tree-sitter parse + skeleton build | Python/C/C++ files where the structure matters more than the content. |
| `outline` | 1 file read + 1 tree-sitter parse + outline build | When the AI only needs the public API surface. |
| `masked` | 1 file read + N `mcp_client.py/ts_*_get_*` calls (one per masked symbol) | When the user has explicitly marked symbols as "def" or "sig". |
| `none` | 1 file read (still reads the bytes, just discards) | When the user wants the file in the list but not in the rendered markdown. |
| `custom` | 1 file read + line slicing per slice | When the user has explicitly created Fuzzy Anchor slices. |

The `force_full = True` and `auto_aggregate = False` flags skip *some* of the work:
- `force_full = True` skips the view-mode dispatch and goes straight to raw content.
- `auto_aggregate = False` skips the view-mode dispatch entirely and skips the markdown section build.

For very large codebases (1000+ files), the bottleneck is the tree-sitter parsing for `skeleton` / `outline` / `masked` modes. The Tier 3 builder uses `ASTParser("python")` lazily (`if not parser: parser = ASTParser("python")`) so the tree-sitter grammar is loaded only once per pipeline call.

---

## Tests

- `tests/test_aggregate_flags.py` — `test_auto_aggregate_skip`, `test_force_full`, `test_view_mode_full`, `test_view_mode_summary`, `test_view_mode_skeleton`, `test_view_mode_outline`, `test_view_mode_none`, `test_view_mode_custom`, `test_view_mode_masked`
- `tests/test_aggregate_beads.py` — `test_build_beads_compaction`
- `tests/test_context_composition_phase3.py` — `test_group_files_by_dir`, `test_compute_file_stats`
- `tests/test_context_composition_phase6.py` — `test_view_mode_default_summary`, `test_view_mode_full`, `test_view_mode_none`, `test_view_mode_outline`, `test_view_mode_skeleton`, `test_view_mode_summary`, `test_view_mode_custom`, `test_view_mode_custom_empty_default_to_summary`, `test_files_section_rendering`
- `tests/test_tiered_context.py` — `test_build_tier3_context_exists`, `test_build_tier3_context_ast_skeleton`, `test_build_tier3_context_scaling`, `test_tiered_context_by_tier_field`, `test_build_file_items_with_tiers`, `test_build_files_section_with_dicts`
- `tests/test_ast_masking_core.py` — `test_ast_masking_gencpp_samples`
- `tests/test_gencpp_full_suite.py` — `test_gencpp_full_suite`
- `tests/test_perf_aggregate.py` — `test_build_tier3_context_scaling`
- `tests/test_history_management.py` — `test_aggregate_blacklist`, `test_aggregate_includes_segregated_history`, `test_aggregate_respects_*`
- `tests/test_ui_summary_only_removal.py` — `test_aggregate_from_items_respects_auto_aggregate`
- `tests/test_aggregate_helpers.py` — `test_resolve_paths_blacklist`, `test_resolve_paths_glob`, `test_resolve_paths_absolute`
- `tests/test_aggregate_perf.py` — `test_find_next_increment_*`

---

## Cross-References

- **The pipeline source:** `src/aggregate.py` (518 lines)
- **FileItem schema:** `src/models.py:510-559 FileItem`
- **ContextPreset schema:** `src/models.py:909-937 ContextPreset`
- **ContextPresetManager:** `src/context_presets.py` (30 lines)
- **AI client consumption:** `src/ai_client.py:_send_<provider>` × 5, see `guide_ai_client.md`
- **Tier 3 worker consumption:** `src/multi_agent_conductor.py:run_worker_lifecycle`, see `guide_multi_agent_conductor.md`
- **Per-file curation features:** `guide_context_curation.md` (Fuzzy Anchors, AST Inspector, Granular AST Control)
- **Cache strategy:** `guide_architecture.md §"Cache Hit Strategy"`, `guide_ai_client.md §"Caching"`
- **Discussion section builder:** `guide_discussions.md §"Persistence"`, `src/aggregate.py:109 build_discussion_section`
- **Deep-dive on the design philosophy:** `conductor/tracks/nagent_review_20260608/report.md §6` (per-file memory)
- **Actionable patterns for richer per-file memory:** `conductor/tracks/nagent_review_20260608/nagent_takeaways_20260608.md §4` (file_id), §6 (git history), §7 (Meta-Tooling DSL)
- **Future-track candidate for per-file conversation log:** `conductor/tracks/nagent_review_20260608/decisions.md` candidate #7
