Skip to content

Latest commit

 

History

History
500 lines (365 loc) · 16 KB

File metadata and controls

500 lines (365 loc) · 16 KB

TENEX Architecture Guide

Table of Contents

Related architecture notes:

  • docs/system-prompt-architecture.md covers how TENEX builds the base system prompt.
  • docs/CONTEXT-MANAGEMENT-AND-REMINDERS.md covers request-time context management, reminders, and prompt-history overlays.
  • docs/SUPERVISION.md covers supervision heuristics, post-completion gating, retry semantics, and structured resolution paths.
  • docs/plans/2026-04-28-architecture-map.md covers Rust workspace crates, including the JSON-backed installed-agent registry.

Core Principles

1. Unidirectional Dependencies

Dependencies flow downward only, never upward:

commands/daemon/event-handler → services/agents/conversations/tools
  ↓
llm/nostr/prompts/events
  ↓
utils
  ↓
lib

Rule: Lower layers never import from higher layers.

2. Pure Utilities in lib/

The lib/ layer contains zero TENEX-specific code. These are pure, reusable utilities that could work in any Node.js project:

  • Filesystem operations
  • String manipulation
  • Time formatting
  • Validation helpers
  • Error formatting

Rule: lib/ has NO imports from utils/, services/, or any TENEX modules.

3. TENEX-Specific Utilities in utils/

The utils/ layer contains helpers specific to TENEX's domain:

  • Nostr entity parsing
  • Phase management
  • Git operations
  • Conversation utilities

Rule: utils/ can import from lib/ but not from services/ or higher layers.

4. Business Logic in services/

The services/ layer contains stateful business logic and domain services:

  • Configuration management
  • RAG operations
  • Scheduling
  • Delegation/RAL state tracking
  • MCP integration

Rule: Services can import from utils/, lib/, nostr/, llm/, but not from commands/, daemon/, or event-handler/.


Layered Architecture

Layer 0: Platform Primitives (lib/)

Purpose: Framework-agnostic utilities

Contains:

  • lib/fs/ - Filesystem operations
  • lib/string.ts - String utilities
  • lib/error-formatter.ts - Error formatting
  • lib/time.ts - Time utilities

Dependencies: Node.js built-ins, npm packages only

Example:

// ✅ Good - pure utility
export function toKebabCase(str: string): string {
    return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
}

// ❌ Bad - depends on TENEX code
import { config } from "@/services/ConfigService";

Layer 1: TENEX Utilities (utils/)

Purpose: Domain-specific helpers

Contains:

  • utils/nostr-entity-parser.ts - Nostr parsing utilities
  • utils/git/ - Git operations (including worktree management)
  • utils/phase-utils.ts - Phase management
  • utils/conversation-id.ts - Conversation identifier helpers
  • utils/logger.ts - Logging (can depend on services/config)

Dependencies: lib/, Node.js built-ins, npm packages

Example:

// ✅ Good - TENEX-specific helper using lib utilities
import { formatAnyError } from "@/lib/error-formatter";

export function parseNostrUser(input: string): { pubkey: string } | null {
    // ... implementation
}

Layer 2: Protocol & Abstraction Layers

Modules: events/, nostr/, llm/, prompts/

Purpose: Protocol implementations and provider abstractions

Dependencies: utils/, lib/


Layer 3: Domain Logic

Modules: services/, agents/, conversations/, tools/

Purpose: Business logic, state management, capabilities

Notes: Tools stay stateless even when they are runtime-gated. Context-injected capabilities such as send_message belong in src/tools/registry.ts and must delegate transport work to services like src/services/telegram/TelegramDeliveryService. Within the conversation domain, canonical transcripts remain JSON in ConversationStore, while metadata-style reads belong on the per-project SQLite catalog in ConversationCatalogService. Prompt and tool code should query that catalog or ConversationRegistry compatibility APIs instead of reparsing transcript files.

Teams are a local-only service concern. src/services/teams/ loads JSON definitions from disk, normalizes membership, resolves team names to lead agents for delegation, and feeds prompt-facing team summaries into the prompt layer. Team scope itself is carried across Nostr via ["team", "..."] tags, not via team objects in relay state.

Dependencies: Everything below (layers 0-2)


Layer 4: Application Entry Points

Modules: commands/, daemon/, event-handler/

Purpose: CLI, runtime orchestration, event routing

Dependencies: Everything below (layers 0-3)


Module Organization

Services Directory Structure

Services should be organized by domain, with related code grouped together:

services/
├── analysis/            # LLM telemetry schema, migrations, and query services
├── migrations/          # Explicit TENEX state migrations keyed by config.json version
├── dispatch/             # Chat routing + delegation dispatch
│   ├── AgentDispatchService.ts
│   ├── AgentRouter.ts
│   └── DelegationCompletionHandler.ts
├── ral/                  # Delegation/RAL state
│   ├── RALRegistry.ts
│   ├── DelegationRegistry.ts
│   ├── KillSwitchRegistry.ts
│   ├── HeuristicViolationManager.ts
│   ├── MessageInjectionQueue.ts
│   ├── ExecutionTimingTracker.ts
│   ├── types.ts
│   └── index.ts
├── teams/                # Local team definitions, prompt context, and delegate resolution
├── rag/                  # RAG domain
│   ├── RAGService.ts
│   ├── RAGDatabaseService.ts
│   ├── RagSubscriptionService.ts
│   ├── ...
│   └── index.ts
├── projects/
├── scheduling/
├── reports/
├── mcp/
└── ...

When to create a subdirectory:

  • 3+ related files
  • Distinct domain boundary
  • Internal implementation details to hide

Small services (1-2 files): Keep at top level until they grow.

Conversation Read Models

TENEX conversation storage has two intentional layers:

  • ConversationStore is the canonical ledger. It owns full transcript JSON, context-management compactions, and save/load semantics.
  • ConversationCatalogService is the per-project derived read model backed by conversation-catalog.db. It exists for prompt/tool metadata queries such as recent conversations, conversation previews, participant/delegation lookups, and durable embedding indexing state.

ConversationStore also carries per-agent prompt-view state that is intentionally separate from the canonical transcript:

  • Canonical ConversationRecords remain the source of truth for transcript history.
  • Per-agent frozen prompt histories record the exact pre-context-management prompt view previously sent to that agent. Runtime-only system reminder overlays remain ephemeral until a request has actually established prompt caching; after that anchor exists they can be persisted as replayable prompt-history entries.
  • Runtime overlays must never be replayed by mutating historical transcript messages. Append them as prompt-history entries instead.

When adding a new conversation-facing feature, decide explicitly which layer it belongs to:

  • If it needs the full transcript or mutates canonical conversation state, use ConversationStore.
  • If it only needs queryable metadata or list/filter behavior, use the catalog. Do not add new transcript-scanning helpers for those paths.

Teams

Teams are local JSON-defined memberships that never become standalone Nostr entities. The runtime resolves them from disk through src/services/teams/TeamService.ts, which normalizes the team lead into membership, caches by file state, and resolves team names to lead pubkeys for delegation.

  • Team definitions stay local on disk and are not published as Nostr events.
  • Delegation events carry team scope through the ["team", "..."] tag.
  • Prompt rendering uses the teams-aware project context plus the lightweight src/prompts/fragments/teams-context summary fragment.
  • Team names are resolved case-insensitively, with agent slugs still taking priority when a recipient string matches both.

Supporting Tooling

Standalone developer tooling or auxiliary CLIs should live under tools/ at the repo root. Treat each tool as isolated (own package.json/tsconfig.json) and document additions in MODULE_INVENTORY.md.


Naming Conventions

Services

Preferred suffix: Service

// ✅ Preferred
ConfigService
RAGService
SchedulerService
ProjectStatusService

// ✅ Most business-logic classes use the "Service" suffix.

Goal: Consistent "Service" suffix for all business logic classes.

Exceptions: Registries (e.g., RALRegistry) keep their names when they are not business-logic services.


File Naming

  • Services: SomethingService.ts (PascalCase)
  • Utilities: kebab-case.ts or camelCase.ts (be consistent within a directory)
  • Types: types.ts or Something.types.ts
  • Tests: Something.test.ts (co-located in __tests__/)

Import Patterns

Rule 1: No Barrel Exports for Services

Do NOT use services/index.ts barrel export. Import directly from service directories:

// ✅ Good - direct import
import { RALRegistry } from "@/services/ral";
import { RAGService } from "@/services/rag";

// ❌ Bad - barrel import
import { RALRegistry, RAGService } from "@/services";

Why:

  • Explicit dependencies
  • Better tree-shaking
  • Faster TypeScript compilation
  • No barrel maintenance

Rule 2: Subdirectories Control Their Exports

Each service subdirectory has an index.ts that controls what's public:

// services/ral/index.ts
export { RALRegistry } from "./RALRegistry";
export type { PendingDelegation, CompletedDelegation } from "./types";

Rule 3: Use @/ Alias

Always use the @/ path alias for absolute imports:

// ✅ Good
import { config } from "@/services/ConfigService";
import { formatAnyError } from "@/lib/error-formatter";

// ❌ Bad - relative imports for cross-module
import { config } from "../../../services/ConfigService";

Adding New Code

Adding a New Utility

1. Determine if it's pure or TENEX-specific:

  • Pure (no TENEX deps) → lib/
  • TENEX-specificutils/

2. Create the file:

// lib/array-utils.ts (pure utility)
export function chunk<T>(array: T[], size: number): T[][] {
    // ... implementation
}

3. Prefer direct imports. Add an index.ts only when a subdirectory needs an explicit public surface.


Adding a New Service

1. Decide if it needs a subdirectory:

  • Yes if: 3+ related files, distinct domain
  • No if: 1-2 files, simple service

2. Create the service:

// services/notifications/NotificationService.ts
import { config } from "@/services/ConfigService";
import { logger } from "@/utils/logger";

export class NotificationService {
    constructor(
        private readonly config: typeof config,
        private readonly logger: typeof logger
    ) {}

    // Methods...
}

// Export default instance for convenience
export const notificationService = new NotificationService(config, logger);

3. Create index.ts:

// services/notifications/index.ts
export { NotificationService, notificationService } from "./NotificationService";
export type { NotificationOptions } from "./types";

4. Import where needed:

import { notificationService } from "@/services/notifications";

Adding to Existing Layers

Adding to lib/:

  • Must be pure, no TENEX dependencies
  • No imports from utils/, services/, etc.
  • Use console.error instead of TENEX logger

Adding to utils/:

  • Can import from lib/
  • Should be domain helpers, not business logic
  • If it needs state, it should be a service instead

Adding to services/:

  • Can import from utils/, lib/, nostr/, llm/, prompts/, events/
  • Cannot import from commands/, daemon/, event-handler/

Anti-Patterns to Avoid

❌ Circular Dependencies

// lib/something.ts
import { logger } from "@/utils/logger";  // ❌ lib → utils (wrong direction)

// utils/helper.ts
import { someService } from "@/services/SomeService";  // ❌ utils → services

Solution: Move code to correct layer or use dependency injection.


❌ Barrel Export Bypass

// ❌ Importing from barrel when subdirectory exists
import { RAGService } from "@/services";

// ✅ Import directly from subdirectory
import { RAGService } from "@/services/rag";

❌ Business Logic in Utilities

// ❌ Bad - stateful service masquerading as utility
// utils/user-manager.ts
export class UserManager {
    private users: Map<string, User> = new Map();
    // ... state management
}

Solution: Move to services/ if it has state or business logic.


❌ Inconsistent Naming

Avoid mixing naming styles for the same layer. Use Service for business logic, and reserve Registry/Manager for registries or infrastructure helpers.


Dependency Injection Pattern

Recommended pattern for services:

// services/something/SomethingService.ts
export class SomethingService {
    // Declare dependencies in constructor
    constructor(
        private readonly config: typeof config,
        private readonly logger: typeof logger,
        private readonly someOtherService: SomeOtherService
    ) {}

    doSomething(): void {
        // Use injected dependencies
        const value = this.config.getConfigPath();
        this.logger.info("Doing something", { value });
    }
}

// Export default instance for convenience
import { config } from "@/services/ConfigService";
import { logger } from "@/utils/logger";
import { someOtherService } from "@/services/some-other";

export const somethingService = new SomethingService(
    config,
    logger,
    someOtherService
);

Benefits:

  • Testable (inject mocks)
  • Clear dependencies
  • No hidden singletons
  • Convenient default instance

Evolution Strategy

Completed Improvements

  • Pure Utilities in lib/: All pure, framework-agnostic utilities are now isolated in the lib/ directory with zero TENEX dependencies.
  • No Circular Dependencies: All circular dependencies between layers have been resolved.
  • Consistent Service Naming: All services have been refactored to use the Service suffix, removing legacy names like ReportManager and PubkeyNameRepository.
  • Git Utilities Consolidated: All Git-related helpers, including worktree management, are now centralized in utils/git/.
  • Configuration Architecture: A centralized ConfigService now manages all configuration, ensuring consistent and predictable settings management.

Target State

We are incrementally moving toward:

  1. Subdirectory Grouping for Services: Gradually group related services into subdirectories for better organization.
  2. Dependency Injection Pattern: Continue to adopt dependency injection for all services to improve testability and clarity.
  3. Removal of Barrel Exports: Phase out all remaining barrel exports in favor of direct imports.

Philosophy: Make incremental improvements. Leave code better than you found it (Boy Scout Rule).


Questions?

If unsure where code belongs:

  1. Is it pure/framework-agnostic? → lib/
  2. Is it a TENEX helper with no state? → utils/
  3. Does it manage state or business logic? → services/
  4. Is it protocol-specific? → nostr/, llm/, etc.

When in doubt, ask in PR review or check this document.


See Also