Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ CODEX_REFRESH_TOKEN=
CODEX_ACCOUNT_ID=
# CODEX_BIN_PATH= # Optional: path to Codex native binary (binary builds only)

# Default AI Assistant (claude | codex)
# Default AI Assistant (claude | codex | ollama)
# Used for new conversations when no codebase specified
DEFAULT_AI_ASSISTANT=claude

# Ollama server base URL (optional, defaults to http://localhost:11434)
# OLLAMA_BASE_URL=http://localhost:11434

# Title Generation Model (optional)
# Model used for generating conversation titles (lightweight task)
# When unset, uses the SDK's default model
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"./state/*": "./src/state/*.ts"
},
"scripts": {
"test": "bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/connection.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts",
"test": "bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/connection.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/config/ src/state/ && bun test src/db/conversations.test.ts && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts",
"type-check": "bun x tsc --noEmit",
"build": "echo 'No build needed - Bun runs TypeScript directly'"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/config/config-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ concurrency:
const config = await loadConfig();

expect(config.assistant).toBe('claude');
expect(config.assistants).toEqual({ claude: {}, codex: {} });
expect(config.assistants).toEqual({ claude: {}, codex: {}, ollama: {} });
expect(config.streaming.telegram).toBe('stream');
expect(config.concurrency.maxConversations).toBe(10);
});
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/config/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ function getDefaults(): MergedConfig {
assistants: {
claude: {},
codex: {},
ollama: {},
},
streaming: {
telegram: 'stream',
Expand Down Expand Up @@ -258,6 +259,7 @@ function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): Merged
assistants: {
claude: { ...defaults.assistants.claude },
codex: { ...defaults.assistants.codex },
ollama: { ...defaults.assistants.ollama },
},
};

Expand All @@ -283,6 +285,9 @@ function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): Merged
...global.assistants.codex,
};
}
if (global.assistants?.ollama !== undefined) {
result.assistants.ollama = global.assistants.ollama;
}
Comment on lines +288 to +290
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't replace the whole Ollama block on partial merges.

These assignments drop unspecified fields. A repo config like assistants.ollama: { model: 'llama3' } will erase a globally configured baseUrl, and a PATCH payload that updates only model will wipe the saved URL for the same reason. Please merge ollama like the other providers here, and use an explicit clear signal if the API needs to remove a field.

Also applies to: 343-345, 454-460

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/config/config-loader.ts` around lines 288 - 290, The
current assignment replaces the entire Ollama config when merging
global.assistants.ollama into result.assistants.ollama, which drops unspecified
fields (e.g., baseUrl) on partial updates; change the merge to shallow-merge
existing result.assistants.ollama with global.assistants.ollama instead of
outright replacement (use the same merge approach used for the other providers),
and introduce/respect an explicit clear signal (e.g., a null/"" sentinel) if
callers intend to remove a field; apply this fix for the occurrences referencing
global.assistants.ollama and result.assistants.ollama (and the similar blocks at
the other noted locations).


// Streaming preferences
if (global.streaming) {
Expand Down Expand Up @@ -314,6 +319,7 @@ function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig {
assistants: {
claude: { ...merged.assistants.claude },
codex: { ...merged.assistants.codex },
ollama: { ...merged.assistants.ollama },
},
};

Expand All @@ -334,6 +340,9 @@ function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig {
...repo.assistants.codex,
};
}
if (repo.assistants?.ollama !== undefined) {
result.assistants.ollama = repo.assistants.ollama;
}

// Commands config
if (repo.commands) {
Expand Down Expand Up @@ -446,6 +455,8 @@ export async function updateGlobalConfig(updates: Partial<GlobalConfig>): Promis
merged.assistants = {
claude: { ...current.assistants?.claude, ...updates.assistants.claude },
codex: { ...current.assistants?.codex, ...updates.assistants.codex },
// Replace ollama section entirely so cleared fields (model, baseUrl) are removed
...(updates.assistants.ollama !== undefined ? { ollama: updates.assistants.ollama } : {}),
};
}

Expand Down Expand Up @@ -484,9 +495,19 @@ export async function updateGlobalConfig(updates: Partial<GlobalConfig>): Promis
* Strips filesystem paths and any other server-internal fields.
*/
export function toSafeConfig(config: MergedConfig): SafeConfig {
// Determine which providers are available based on configuration and environment.
// Claude is always available (API key checked at request time, not config time).
// Codex requires the binary to be present; we optimistically include it here.
// Ollama is included when a model is configured or OLLAMA_BASE_URL is set.
const availableAssistants: ('claude' | 'codex' | 'ollama')[] = ['claude', 'codex'];
if (config.assistants.ollama.model || process.env.OLLAMA_BASE_URL) {
availableAssistants.push('ollama');
}

return {
botName: config.botName,
assistant: config.assistant,
availableAssistants,
assistants: {
claude: {
model: config.assistants.claude.model,
Expand All @@ -496,6 +517,10 @@ export function toSafeConfig(config: MergedConfig): SafeConfig {
modelReasoningEffort: config.assistants.codex.modelReasoningEffort,
webSearchMode: config.assistants.codex.webSearchMode,
},
ollama: {
model: config.assistants.ollama.model,
baseUrl: config.assistants.ollama.baseUrl,
},
},
streaming: {
telegram: config.streaming.telegram,
Expand Down
22 changes: 16 additions & 6 deletions packages/core/src/config/config-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@

// Provider config defaults — canonical definitions live in @archon/providers/types.
// Imported and re-exported here so existing consumers don't break.
import type { ClaudeProviderDefaults, CodexProviderDefaults } from '@archon/providers/types';
import type {
ClaudeProviderDefaults,
CodexProviderDefaults,
OllamaProviderDefaults,
} from '@archon/providers/types';

export type { ClaudeProviderDefaults, CodexProviderDefaults };
export type { ClaudeProviderDefaults, CodexProviderDefaults, OllamaProviderDefaults };

export interface GlobalConfig {
/**
Expand All @@ -28,14 +32,15 @@ export interface GlobalConfig {
* Default AI assistant when no codebase-specific preference
* @default 'claude'
*/
defaultAssistant?: 'claude' | 'codex';
defaultAssistant?: 'claude' | 'codex' | 'ollama';

/**
* Assistant-specific defaults (model, reasoning effort, etc.)
*/
assistants?: {
claude?: ClaudeProviderDefaults;
codex?: CodexProviderDefaults;
ollama?: OllamaProviderDefaults;
};

/**
Expand Down Expand Up @@ -85,14 +90,15 @@ export interface RepoConfig {
* AI assistant preference for this repository
* Overrides global default
*/
assistant?: 'claude' | 'codex';
assistant?: 'claude' | 'codex' | 'ollama';

/**
* Assistant-specific defaults for this repository
*/
assistants?: {
claude?: ClaudeProviderDefaults;
codex?: CodexProviderDefaults;
ollama?: OllamaProviderDefaults;
};

/**
Expand Down Expand Up @@ -182,10 +188,11 @@ export interface RepoConfig {
*/
export interface MergedConfig {
botName: string;
assistant: 'claude' | 'codex';
assistant: 'claude' | 'codex' | 'ollama';
assistants: {
claude: ClaudeProviderDefaults;
codex: CodexProviderDefaults;
ollama: OllamaProviderDefaults;
};
streaming: {
telegram: 'stream' | 'batch';
Expand Down Expand Up @@ -238,10 +245,13 @@ export interface MergedConfig {
*/
export interface SafeConfig {
botName: string;
assistant: 'claude' | 'codex';
assistant: 'claude' | 'codex' | 'ollama';
/** Providers that are configured and available on this server. */
availableAssistants: ('claude' | 'codex' | 'ollama')[];
assistants: {
claude: Pick<ClaudeProviderDefaults, 'model'>;
codex: Pick<CodexProviderDefaults, 'model' | 'modelReasoningEffort' | 'webSearchMode'>;
ollama: Pick<OllamaProviderDefaults, 'model' | 'baseUrl'>;
};
streaming: {
telegram: 'stream' | 'batch';
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/db/conversations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ mock.module('./connection', () => ({
getDialect: () => mockPostgresDialect,
}));

// Mock config-loader: getOrCreateConversation calls loadConfig() for the default assistant
mock.module('../config/config-loader', () => ({
loadConfig: mock(() =>
Promise.resolve({
assistant: 'claude',
assistants: { claude: {}, codex: {}, ollama: {} },
})
),
}));
Comment on lines +14 to +22
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show all Bun module mocks in tests.
rg -n --type=ts "mock\.module\(" packages

# Show every test that mocks config-loader specifically.
rg -n --type=ts "mock\.module\(\s*['\"][^'\"]*config-loader['\"]" packages

# Show existing spy-based loadConfig stubs, if any.
rg -n --type=ts "spyOn\([^,]+,\s*['\"]loadConfig['\"]\)" packages

Repository: coleam00/Archon

Length of output: 29543


🏁 Script executed:

# Check the config-loader.ts file structure and exports
head -100 packages/core/src/config/config-loader.ts

# Check all mock implementations of loadConfig to see if they differ
echo "=== conversations.test.ts mock ===" && sed -n '14,25p' packages/core/src/db/conversations.test.ts

echo -e "\n=== store-adapter.test.ts mock ===" && sed -n '51,60p' packages/core/src/workflows/store-adapter.test.ts

echo -e "\n=== orchestrator.test.ts mock ===" && sed -n '118,130p' packages/core/src/orchestrator/orchestrator.test.ts

echo -e "\n=== orchestrator-isolation.test.ts mock ===" && sed -n '65,75p' packages/core/src/orchestrator/orchestrator-isolation.test.ts

echo -e "\n=== orchestrator-agent.test.ts mock ===" && sed -n '145,155p' packages/core/src/orchestrator/orchestrator-agent.test.ts

echo -e "\n=== config-loader.test.ts mock ===" && sed -n '24,35p' packages/core/src/config/config-loader.test.ts

Repository: coleam00/Archon

Length of output: 5249


🏁 Script executed:

# Check what mockLoadConfig is in orchestrator.test.ts
grep -n "mockLoadConfig" packages/core/src/orchestrator/orchestrator.test.ts | head -20

# Check what mockLoadConfig is in orchestrator-agent.test.ts  
grep -n "mockLoadConfig" packages/core/src/orchestrator/orchestrator-agent.test.ts | head -20

# Check the actual loadConfig export from config-loader
grep -n "export.*loadConfig" packages/core/src/config/config-loader.ts

Repository: coleam00/Archon

Length of output: 593


🏁 Script executed:

# Get full context of conversations.test.ts around lines 14-22
sed -n '1,35p' packages/core/src/db/conversations.test.ts

# Check if loadConfig is used directly in conversations.test.ts
grep -n "loadConfig" packages/core/src/db/conversations.test.ts

# Check the orchestrator.test.ts mockLoadConfig implementation
sed -n '105,115p' packages/core/src/orchestrator/orchestrator.test.ts

# Check the orchestrator-agent.test.ts mockLoadConfig implementation
sed -n '45,55p' packages/core/src/orchestrator/orchestrator-agent.test.ts

Repository: coleam00/Archon

Length of output: 1886


🏁 Script executed:

# Check how loadConfig is imported and used in conversations.ts
sed -n '1,50p' packages/core/src/db/conversations.ts | grep -A5 "loadConfig"

# Check what getOrCreateConversation does
grep -n "getOrCreateConversation" packages/core/src/db/conversations.ts

# View the implementation
sed -n '80,150p' packages/core/src/db/conversations.ts

Repository: coleam00/Archon

Length of output: 2897


🏁 Script executed:

# Find where loadConfig is actually called in getOrCreateConversation
sed -n '58,120p' packages/core/src/db/conversations.ts

Repository: coleam00/Archon

Length of output: 2480


Use spyOn(configLoader, 'loadConfig') instead of mock.module().

This file mocks config-loader alongside at least four other test files (store-adapter.test.ts, orchestrator.test.ts, orchestrator-isolation.test.ts, orchestrator-agent.test.ts) with different implementations. Bun's mock.module() is process-global and persists across test files; when tests run in any order, an earlier test's mock can bleed into this test and cause flaky failures. Import the module and use spyOn(configLoader, 'loadConfig') so mockRestore() cleans up correctly:

import * as configLoader from '../config/config-loader';

spyOn(configLoader, 'loadConfig').mockResolvedValue({
  assistant: 'claude',
  assistants: { claude: {}, codex: {}, ollama: {} },
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/db/conversations.test.ts` around lines 14 - 22, Replace the
process-global Bun mock.module() usage that mocks config-loader with a local spy
so mocks are restored between tests: import the config-loader module (referenced
as configLoader) and replace the mock.module('../config/config-loader') pattern
by calling spyOn(configLoader, 'loadConfig') to mockResolvedValue the desired
config, and ensure tests call mockRestore() (or rely on test framework cleanup)
so loadConfig is restored; remove the mock.module() call and any references to
it to avoid cross-test leakage.


import {
getOrCreateConversation,
updateConversation,
Expand Down
18 changes: 15 additions & 3 deletions packages/core/src/db/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { pool, getDialect } from './connection';
import type { Conversation } from '../types';
import { ConversationNotFoundError } from '../types';
import { createLogger } from '@archon/paths';
import { loadConfig } from '../config/config-loader';

/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
let cachedLog: ReturnType<typeof createLogger> | undefined;
Expand Down Expand Up @@ -72,7 +73,18 @@ export async function getOrCreateConversation(
// Check if we should inherit from a parent conversation (e.g., Discord thread inheriting from parent channel)
let inheritedCodebaseId: string | null = null;
let inheritedCwd: string | null = null;
let assistantType = process.env.DEFAULT_AI_ASSISTANT ?? 'claude';
const config = await loadConfig();
let assistantType: 'claude' | 'codex' | 'ollama' = config.assistant;
const envAssistant = process.env.DEFAULT_AI_ASSISTANT;
if (envAssistant) {
if (envAssistant === 'claude' || envAssistant === 'codex' || envAssistant === 'ollama') {
assistantType = envAssistant;
} else {
throw new Error(
`Invalid DEFAULT_AI_ASSISTANT: "${envAssistant}". Must be one of: claude, codex, ollama`
);
}
}
Comment on lines +76 to +87
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep DEFAULT_AI_ASSISTANT parsing in loadConfig(), not in the DB layer.

This block makes getOrCreateConversation() a second source of truth for assistant selection. With the current shared config path, conversation creation can honor DEFAULT_AI_ASSISTANT=ollama here while other callers of loadConfig() still read a different default assistant, so provider choice becomes inconsistent across the app. Please fix the env override centrally in packages/core/src/config/config-loader.ts and keep this function consuming config.assistant only.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/db/conversations.ts` around lines 76 - 87, Remove the
DEFAULT_AI_ASSISTANT env parsing from getOrCreateConversation() in
packages/core/src/db/conversations.ts and instead implement the env override and
validation inside loadConfig() in packages/core/src/config/config-loader.ts:
update loadConfig() to read process.env.DEFAULT_AI_ASSISTANT, validate it
against the allowed values ('claude'|'codex'|'ollama'), override the
config.assistant when valid (and throw or fallback consistently when invalid),
and ensure its return type reflects the validated assistant; after that,
simplify getOrCreateConversation() to consume only config.assistant and delete
the assistantType/envAssistant block so there is a single source of truth.


if (parentConversationId) {
const parent = await pool.query<Conversation>(
Expand All @@ -82,7 +94,7 @@ export async function getOrCreateConversation(
if (parent.rows[0]) {
inheritedCodebaseId = parent.rows[0].codebase_id;
inheritedCwd = parent.rows[0].cwd;
assistantType = parent.rows[0].ai_assistant_type;
assistantType = parent.rows[0].ai_assistant_type as 'claude' | 'codex' | 'ollama';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate DB assistant types before narrowing them.

These casts trust whatever string is stored in ai_assistant_type. If a stale row contains anything else, we'll persist or propagate an invalid provider and only fail later when provider dispatch runs. Parse the value explicitly and throw here.

Proposed fix
+function parseAssistantType(value: string): 'claude' | 'codex' | 'ollama' {
+  if (value === 'claude' || value === 'codex' || value === 'ollama') {
+    return value;
+  }
+  throw new Error(`Unsupported ai_assistant_type: "${value}"`);
+}
+
   if (parent.rows[0]) {
     inheritedCodebaseId = parent.rows[0].codebase_id;
     inheritedCwd = parent.rows[0].cwd;
-    assistantType = parent.rows[0].ai_assistant_type as 'claude' | 'codex' | 'ollama';
+    assistantType = parseAssistantType(parent.rows[0].ai_assistant_type);
     getLog().debug(
       { inheritedCodebaseId, inheritedCwd },
       'db.conversation_parent_context_inherited'
@@
   if (codebase.rows[0]) {
-    assistantType = codebase.rows[0].ai_assistant_type as 'claude' | 'codex' | 'ollama';
+    assistantType = parseAssistantType(codebase.rows[0].ai_assistant_type);
   }

As per coding guidelines: "Prefer throwing early with clear errors for unsupported or unsafe states; never silently swallow errors or broaden permissions; document intentional fallback behavior with comments".

Also applies to: 115-115

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/db/conversations.ts` at line 97, The code currently
force-casts the DB field ai_assistant_type into a union (assistantType =
parent.rows[0].ai_assistant_type as 'claude' | 'codex' | 'ollama'), which can
hide invalid DB values; replace the cast with an explicit whitelist check: read
the raw value (e.g., const raw = parent.rows[0].ai_assistant_type), verify it is
one of ['claude','codex','ollama'], and if not throw a clear Error stating the
invalid ai_assistant_type and the conversation id/context; apply the same check
where the same cast appears (the other occurrence mentioned) so you fail fast
instead of propagating an unsupported provider.

getLog().debug(
{ inheritedCodebaseId, inheritedCwd },
'db.conversation_parent_context_inherited'
Expand All @@ -100,7 +112,7 @@ export async function getOrCreateConversation(
[codebaseId]
);
if (codebase.rows[0]) {
assistantType = codebase.rows[0].ai_assistant_type;
assistantType = codebase.rows[0].ai_assistant_type as 'claude' | 'codex' | 'ollama';
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/orchestrator/orchestrator-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -759,7 +759,7 @@ export async function handleMessage(
// Reuse the config already loaded during workflow discovery (avoids a second disk read).
// Fall back to loadConfig only when no codebase is scoped (discoveredConfig is undefined).
const config = discoveredConfig ?? (await loadConfig());
const providerKey = conversation.ai_assistant_type as 'claude' | 'codex';
const providerKey = conversation.ai_assistant_type as 'claude' | 'codex' | 'ollama';
let dbEnvVars: Record<string, string> = {};
if (conversation.codebase_id) {
try {
Expand Down
2 changes: 2 additions & 0 deletions packages/providers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"./codex/provider": "./src/codex/provider.ts",
"./codex/config": "./src/codex/config.ts",
"./codex/binary-resolver": "./src/codex/binary-resolver.ts",
"./ollama/provider": "./src/ollama/provider.ts",
"./ollama/config": "./src/ollama/config.ts",
"./errors": "./src/errors.ts",
"./factory": "./src/factory.ts"
},
Expand Down
11 changes: 9 additions & 2 deletions packages/providers/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@
* Agent Provider Factory
*
* Dynamic provider instantiation and static capability lookup.
* Built-in providers only: Claude and Codex.
* Built-in providers: Claude, Codex, and Ollama.
*/
import type { IAgentProvider, ProviderCapabilities } from './types';
import { ClaudeProvider } from './claude/provider';
import { CodexProvider } from './codex/provider';
import { OllamaProvider } from './ollama/provider';
import { CLAUDE_CAPABILITIES } from './claude/capabilities';
import { CODEX_CAPABILITIES } from './codex/capabilities';
import { OLLAMA_CAPABILITIES } from './ollama/capabilities';
import { UnknownProviderError } from './errors';
import { createLogger } from '@archon/paths';

/** Built-in provider types. */
const REGISTERED_PROVIDERS = ['claude', 'codex'] as const;
const REGISTERED_PROVIDERS = ['claude', 'codex', 'ollama'] as const;

/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
let cachedLog: ReturnType<typeof createLogger> | undefined;
Expand All @@ -37,6 +39,9 @@ export function getAgentProvider(type: string): IAgentProvider {
case 'codex':
getLog().debug({ provider: 'codex' }, 'provider_selected');
return new CodexProvider();
case 'ollama':
getLog().debug({ provider: 'ollama' }, 'provider_selected');
return new OllamaProvider();
default:
throw new UnknownProviderError(type, [...REGISTERED_PROVIDERS]);
}
Expand All @@ -52,6 +57,8 @@ export function getProviderCapabilities(type: string): ProviderCapabilities {
return CLAUDE_CAPABILITIES;
case 'codex':
return CODEX_CAPABILITIES;
case 'ollama':
return OLLAMA_CAPABILITIES;
default:
throw new UnknownProviderError(type, [...REGISTERED_PROVIDERS]);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/providers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ export { UnknownProviderError } from './errors';
// Provider classes
export { ClaudeProvider } from './claude/provider';
export { CodexProvider } from './codex/provider';
export { OllamaProvider } from './ollama/provider';

// Config parsers
export { parseClaudeConfig, type ClaudeProviderDefaults } from './claude/config';
export { parseCodexConfig, type CodexProviderDefaults } from './codex/config';
export { parseOllamaConfig, type OllamaProviderDefaults } from './ollama/config';

// Utilities (needed by consumers)
export { resetCodexSingleton } from './codex/provider';
Expand Down
22 changes: 22 additions & 0 deletions packages/providers/src/ollama/capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { ProviderCapabilities } from '../types';

/**
* Ollama capability flags.
* Ollama runs locally via /api/chat — no session resume, MCP, hooks, or SDK-level
* tool restrictions. Structured output, cost control, effort, and sandbox are also
* unsupported at this time.
*/
export const OLLAMA_CAPABILITIES: ProviderCapabilities = {
sessionResume: false,
mcp: false,
hooks: false,
skills: false,
toolRestrictions: false,
structuredOutput: false,
envInjection: false,
costControl: false,
effortControl: false,
thinkingControl: false,
fallbackModel: false,
sandbox: false,
};
26 changes: 26 additions & 0 deletions packages/providers/src/ollama/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Typed config parsing for Ollama provider defaults.
* Validates and narrows the opaque assistantConfig to typed fields.
*/
import type { OllamaProviderDefaults } from '../types';

// Re-export so consumers can import the type from either location
export type { OllamaProviderDefaults } from '../types';

/**
* Parse raw assistantConfig into typed Ollama defaults.
* Defensive: invalid fields are silently dropped.
*/
export function parseOllamaConfig(raw: Record<string, unknown>): OllamaProviderDefaults {
const result: OllamaProviderDefaults = {};

if (typeof raw.model === 'string') {
result.model = raw.model;
}

if (typeof raw.baseUrl === 'string') {
result.baseUrl = raw.baseUrl;
}

return result;
}
2 changes: 2 additions & 0 deletions packages/providers/src/ollama/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { OllamaProvider } from './provider';
export { parseOllamaConfig, type OllamaProviderDefaults } from './config';
Loading