Files
flynn/.planning/codebase/CONVENTIONS.md
T
2026-02-09 19:31:05 -08:00

9.7 KiB

Coding Conventions

Analysis Date: 2026-02-09

Naming Patterns

Files:

  • Source files: camelCase with .ts extension — clientFactory.ts, hybridSearch.ts, vectorStore.ts
  • React/Ink components: PascalCase with .tsx extension (in src/frontends/tui/components/)
  • Test files: co-located with source, suffixed .test.tsagent.test.ts beside agent.ts
  • Type-only files: types.ts in each module directory
  • Index barrel files: index.ts in each module directory

Classes:

  • PascalCase: NativeAgent, ModelRouter, ToolRegistry, ChannelRegistry, SessionStore
  • Implementation classes use implements with interface: class AnthropicClient implements ModelClient

Interfaces/Types:

  • PascalCase: ChatRequest, ChatResponse, ToolResult, InboundMessage
  • Config interfaces suffixed with Config: NativeAgentConfig, OrchestratorConfig, AnthropicClientConfig
  • Union types use type keyword: type ConversationMessage = Message | ToolMessage
  • Zod-inferred types: type Config = z.infer<typeof configSchema> in src/config/schema.ts

Functions:

  • camelCase: loadConfig(), expandEnvVars(), createClientFromConfig()
  • Factory functions prefixed with create: createMemoryReadTool(), createWebSearchTool(), createBrowserTools()
  • Boolean checks prefixed with is/has/should: isSupportedImage(), hasImages(), shouldCompact()

Variables/Fields:

  • camelCase for public: modelClient, systemPrompt, toolRegistry
  • Underscore prefix for private fields: _config, _dirtyNamespaces, _totalUsage, _callCount
  • Constants: UPPER_SNAKE_CASE: FETCH_TIMEOUT_MS, MAX_RESULTS, DEFAULT_RETRY_CONFIG, MODEL_COSTS_PER_MILLION

Tool Names:

  • Dot-separated namespacing: shell.exec, file.read, file.write, memory.read, web.fetch
  • MCP tools use colon separator: mcp:filesystem:read_file
  • Tool groups prefixed with group:: group:fs, group:runtime, group:web, group:memory

Code Style

Formatting:

  • No Prettier or formatter config detected — formatting is enforced by convention
  • 2-space indentation throughout
  • Single quotes for strings
  • Trailing commas in multiline structures
  • Semicolons always used

Linting:

  • ESLint v9+ configured (via eslint devDependency), no config file found — likely uses flat config defaults
  • Run with: pnpm lint (runs eslint src/)

TypeScript Strictness:

  • strict: true in tsconfig.json
  • Target: ES2022, Module: NodeNext, ModuleResolution: NodeNext
  • JSX: react-jsx (for Ink TUI components)
  • All declarations, declaration maps, and source maps enabled

Import Organization

Order:

  1. Node.js stdlib: import { readFileSync } from 'fs';
  2. Third-party packages: import Anthropic from '@anthropic-ai/sdk';
  3. Local imports: import { configSchema } from './schema.js';

Path Style:

  • Always use .js extensions for local imports (NodeNext resolution):
    import { NativeAgent } from './agent.js';
    import type { Config } from '../config/schema.js';
    

Type-only Imports:

  • Use import type for type-only imports:
    import type { ModelClient, ChatResponse } from '../../models/types.js';
    import type { Tool, ToolResult } from '../../tools/types.js';
    

Barrel Files:

  • Every module has an index.ts that re-exports public API
  • Use explicit named exports, not export *:
    // src/tools/index.ts
    export type { Tool, ToolCall, ToolResult } from './types.js';
    export { ToolRegistry } from './registry.js';
    export { ToolExecutor } from './executor.js';
    
  • Types re-exported with export type:
    export type { AnthropicToolDef, OpenAIToolDef } from './registry.js';
    

Tool Patterns

Flynn tools follow three distinct patterns. Use the appropriate one:

Static Tool (no dependencies):

// src/tools/builtin/shell.ts
import type { Tool, ToolResult } from '../types.js';

interface ShellExecArgs {
  command: string;
  cwd?: string;
}

export const shellExecTool: Tool = {
  name: 'shell.exec',
  description: 'Execute a shell command...',
  inputSchema: {
    type: 'object',
    properties: {
      command: { type: 'string', description: 'The shell command to execute' },
    },
    required: ['command'],
  },
  execute: async (rawArgs: unknown): Promise<ToolResult> => {
    const args = rawArgs as ShellExecArgs;
    // implementation
  },
};

Factory Tool (single tool needing dependency injection):

// src/tools/builtin/memory-read.ts
export function createMemoryReadTool(store: MemoryStore): Tool {
  return {
    name: 'memory.read',
    description: '...',
    inputSchema: { ... },
    execute: async (rawArgs: unknown): Promise<ToolResult> => {
      const args = rawArgs as MemoryReadArgs;
      // uses `store` from closure
    },
  };
}

Multi-Factory (related tool set):

// src/tools/builtin/index.ts
export function createMemoryTools(store: MemoryStore, hybridSearch?: HybridSearch): Tool[] {
  return [
    createMemoryReadTool(store),
    createMemoryWriteTool(store),
    createMemorySearchTool(store, hybridSearch),
  ];
}

Registration chain:

  1. Tool file (e.g., src/tools/builtin/shell.ts)
  2. src/tools/builtin/index.ts (barrel exports + allBuiltinTools array)
  3. src/tools/index.ts (barrel re-exports)
  4. Registered in src/daemon/index.ts via ToolRegistry.register()

Error Handling

Pattern 1: Return ToolResult with error field (tools):

try {
  const content = store.read(args.namespace);
  return { success: true, output: content };
} catch (error) {
  return {
    success: false,
    output: '',
    error: error instanceof Error ? error.message : String(error),
  };
}

Pattern 2: Throw with descriptive message (config/setup):

if (envValue === undefined) {
  throw new Error(`Environment variable ${envVar} is not set`);
}

Pattern 3: Throw for duplicate registration (registries):

if (this.tools.has(tool.name)) {
  throw new Error(`Tool '${tool.name}' is already registered`);
}

Pattern 4: Yield error events in streams:

try {
  for await (const event of stream) {
    yield { type: 'content', content: event.delta.text };
  }
} catch (error) {
  yield {
    type: 'error',
    error: error instanceof Error ? error : new Error(String(error)),
  };
}

Pattern 5: Fire-and-forget with error logging (channels):

this.messageHandler(msg, reply).catch((err: unknown) => {
  console.error(`Error handling message from '${msg.channel}':`, err);
});

Pattern 6: Promise.allSettled for non-critical failures:

const results = await Promise.allSettled(adapters.map((a) => a.connect()));
for (const [i, result] of results.entries()) {
  if (result.status === 'rejected') {
    console.error(`Failed to start channel '${adapters[i].name}':`, result.reason);
  }
}

instanceof check pattern: Always use error instanceof Error ? error.message : String(error) for unknown errors.

Logging

Framework: console (no logging library)

Patterns:

  • console.log() for informational messages: startup, config loaded
  • console.warn() for non-fatal issues: missing handler, unknown channel
  • console.error() for failures: failed connections, adapter errors
  • No structured logging — messages are plain strings with context

Comments

JSDoc-style comments on interfaces:

/** Media attachment received from or sent to a channel. */
export interface Attachment {
  /** MIME type (e.g. "image/jpeg", "audio/ogg", "application/pdf"). */
  mimeType: string;
  /** Base64-encoded data (preferred for model APIs). */
  data?: string;
}

Section dividers in larger files:

// ── Public types ──────────────────────────────────────────────────────
// ── Helpers ─────────────────────────────────────────────────────────

Inline comments for non-obvious logic:

// Policy check (defense in depth — tools should also be filtered at listing time)
// Fire and forget — errors are logged, not propagated

When to comment:

  • Always add JSDoc on exported interfaces and their fields
  • Use section dividers in files > 100 lines
  • Add inline comments for non-obvious behavior, workarounds, or design decisions
  • No comments on self-explanatory code

Function Design

Size: Functions generally stay under 50 lines. Complex logic is extracted into private methods (e.g., toolLoop(), singleTurn() in NativeAgent).

Parameters:

  • Config objects for constructors with >2 params: NativeAgentConfig, OrchestratorConfig
  • Raw args typed as unknown, then cast internally: const args = rawArgs as ShellExecArgs
  • Optional params use ? with defaults via ??: config.maxTokens ?? 4096

Return Values:

  • Async functions return Promise<T>
  • Tools return Promise<ToolResult> with { success, output, error? }
  • Streams use AsyncIterable<ChatStreamEvent>
  • Model clients always return ChatResponse with required content, stopReason, usage

Module Design

Exports: Named exports only — no default exports anywhere in the codebase.

Barrel Files: Every module directory has index.ts. Import from barrel in consuming code:

import { ToolRegistry, ToolExecutor, ToolPolicy } from '../../tools/index.js';

Config validation: Use Zod schemas in src/config/schema.ts. All config types are inferred from schemas:

export const configSchema = z.object({ ... });
export type Config = z.infer<typeof configSchema>;

Convention analysis: 2026-02-09