Skip to content
Merged
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
1 change: 1 addition & 0 deletions server/modules/providers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Current provider ids accepted by the registry and `parseProvider` are:
- `opencode`
- `gjc`
- `omp`
- `omo`

Those ids are mirrored in backend unions and frontend provider constants. If
adding a new provider, update every place that hardcodes this list.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import {
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
import type { AnyRecord } from '@/shared/types.js';

type PiTranscriptProvider = 'gjc' | 'omp';
/**
* CLIs that write the pi-derived transcript dialect: the same JSONL envelope
* (`message`, `model_change`, `thinking_level_change`, `custom`), so one reader
* and one synchronizer serve all of them.
*/
export type PiTranscriptProvider = 'gjc' | 'omp' | 'omo';

export type PiSessionSynchronizerOptions = {
provider?: PiTranscriptProvider;
Expand Down
5 changes: 3 additions & 2 deletions server/modules/providers/list/gjc/gjc-sessions.provider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fsSync from 'node:fs';

import { sessionsDb } from '@/modules/database/index.js';
import type { PiTranscriptProvider } from '@/modules/providers/list/gjc/gjc-session-synchronizer.provider.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
Expand Down Expand Up @@ -188,7 +189,7 @@ function normalizeGjcToolInput(toolName: string, value: unknown): unknown {
* its own intermediate record with a unique id so multi-part turns never collide.
*/
async function streamPiSessionMessages(
provider: 'gjc' | 'omp',
provider: PiTranscriptProvider,
sessionId: string,
onMessage: (message: AnyRecord) => void,
): Promise<void> {
Expand Down Expand Up @@ -337,7 +338,7 @@ async function streamPiSessionMessages(
}

export class GjcSessionsProvider implements IProviderSessions {
constructor(private readonly provider: 'gjc' | 'omp' = 'gjc') {}
constructor(private readonly provider: PiTranscriptProvider = 'gjc') {}
/**
* Normalizes one flattened gjc content-part record into the shared envelope.
*/
Expand Down
19 changes: 19 additions & 0 deletions server/modules/providers/list/omo/omo-auth.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import spawn from 'cross-spawn';

import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';

export class OmoProviderAuth implements IProviderAuth {
async getStatus(): Promise<ProviderAuthStatus> {
const result = spawn.sync('omo', ['--version'], { stdio: 'ignore', timeout: 5_000 });
const installed = !result.error && result.status === 0;
return {
installed,
provider: 'omo',
authenticated: installed,
email: installed ? 'CLI managed' : null,
method: installed ? 'cli' : null,
error: installed ? undefined : 'omo CLI is not installed',
};
}
}
31 changes: 31 additions & 0 deletions server/modules/providers/list/omo/omo-mcp.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
import type { ProviderMcpServer } from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';

export class OmoMcpProvider extends McpProvider {
constructor() {
super('omo', ['user', 'project'], ['stdio', 'http']);
}

protected async readScopedServers(): Promise<Record<string, unknown>> {
return {};
}

protected async writeScopedServers(): Promise<void> {
throw new AppError('omo MCP configuration is not supported by ChatMux.', {
code: 'MCP_WRITE_UNSUPPORTED',
statusCode: 400,
});
}

protected buildServerConfig(): Record<string, unknown> {
throw new AppError('omo MCP configuration is not supported by ChatMux.', {
code: 'MCP_WRITE_UNSUPPORTED',
statusCode: 400,
});
}

protected normalizeServerConfig(): ProviderMcpServer | null {
return null;
}
}
92 changes: 92 additions & 0 deletions server/modules/providers/list/omo/omo-models.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

import {
readLastTranscriptActiveModel,
} from '@/modules/providers/list/omp/omp-models.provider.js';
import { sessionsDb } from '@/modules/database/index.js';
import type { IProviderModels } from '@/shared/interfaces.js';
import type {
ProviderChangeActiveModelInput,
ProviderCurrentActiveModel,
ProviderModelOption,
ProviderModelsDefinition,
ProviderSessionActiveModelChange,
} from '@/shared/types.js';
import {
buildDefaultProviderCurrentActiveModel,
writeProviderSessionActiveModelChange,
} from '@/shared/utils.js';

const execFileAsync = promisify(execFile);

const OMO_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [{ value: 'default', label: 'Current CLI model' }],
DEFAULT: 'default',
};

/**
* `omo --list-models` prints an aligned text table, not JSON:
*
* provider model context max-out thinking images
* alibaba-token-plan deepseek-v3.2 131.1K 65.5K yes no
*
* Every column is whitespace-free, so a row is exactly six tokens. Requiring
* that shape plus yes/no flags rejects the header and any startup chatter the
* CLI writes before the table.
*/
export function parseOmoModelCatalog(raw: string): ProviderModelsDefinition {
const options: ProviderModelOption[] = [];
const seen = new Set<string>();
for (const line of raw.split(/\r?\n/)) {
const columns = line.trim().split(/\s+/);
if (columns.length !== 6) continue;
const [provider, model, context, , thinking, images] = columns;
if (!/^(?:yes|no)$/i.test(thinking) || !/^(?:yes|no)$/i.test(images)) continue;
const selector = `${provider}/${model}`;
if (seen.has(selector)) continue;
seen.add(selector);
options.push({
value: selector,
label: model,
description: `${context} context · ${provider}`,
});
}
return options.length > 0
? { OPTIONS: [...OMO_FALLBACK_MODELS.OPTIONS, ...options], DEFAULT: OMO_FALLBACK_MODELS.DEFAULT }
: OMO_FALLBACK_MODELS;
}

async function loadOmoModelCatalog(): Promise<ProviderModelsDefinition> {
try {
const { stdout } = await execFileAsync(
'omo',
['--list-models'],
{ encoding: 'utf8', timeout: 30_000, maxBuffer: 8 * 1024 * 1024 },
);
return parseOmoModelCatalog(stdout);
} catch {
return OMO_FALLBACK_MODELS;
}
}

export class OmoProviderModels implements IProviderModels {
async getSupportedModels(): Promise<ProviderModelsDefinition> {
return loadOmoModelCatalog();
}

async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {
const row = sessionId ? sessionsDb.getSessionById(sessionId) : null;
if (row?.jsonl_path) {
const activeModel = await readLastTranscriptActiveModel(row.jsonl_path).catch(() => null);
if (activeModel) return activeModel;
}
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}

async changeActiveModel(
input: ProviderChangeActiveModelInput,
): Promise<ProviderSessionActiveModelChange> {
return writeProviderSessionActiveModelChange('omo', input);
}
}
66 changes: 66 additions & 0 deletions server/modules/providers/list/omo/omo-skills.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import os from 'node:os';
import path from 'node:path';

import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
import type { ProviderSkillSource } from '@/shared/types.js';
import { addUniqueProviderSkillSource, findTopmostGitRoot } from '@/shared/utils.js';

const PROJECT_SKILL_DIRS = [
['.omo', 'skills'],
['.agent', 'skills'],
['.agents', 'skills'],
['.codex', 'skills'],
['.claude', 'skills'],
] as const;

const USER_SKILL_DIRS = [
['.omo', 'agent', 'skills'],
['.agent', 'skills'],
['.omo', 'agent', 'managed-skills'],
['.agents', 'skills'],
['.codex', 'skills'],
['.claude', 'skills'],
] as const;

export class OmoSkillsProvider extends SkillsProvider {
constructor() {
super('omo');
}

protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
const sources: ProviderSkillSource[] = [];
const seenRootDirs = new Set<string>();
const repoRoot = await findTopmostGitRoot(workspacePath);
const projectRoots = repoRoot && path.resolve(repoRoot) !== path.resolve(workspacePath)
? [workspacePath, repoRoot]
: [workspacePath];

for (const projectRoot of projectRoots) {
for (const segments of PROJECT_SKILL_DIRS) {
addUniqueProviderSkillSource(sources, seenRootDirs, {
scope: 'project',
rootDir: path.join(projectRoot, ...segments),
commandPrefix: '/skill:',
});
}
}

for (const segments of USER_SKILL_DIRS) {
addUniqueProviderSkillSource(sources, seenRootDirs, {
scope: 'user',
rootDir: path.join(os.homedir(), ...segments),
commandPrefix: '/skill:',
});
}

return sources;
}

protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(os.homedir(), '.omo', 'agent', 'skills'),
commandPrefix: '/skill:',
};
}
}
29 changes: 29 additions & 0 deletions server/modules/providers/list/omo/omo.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { OmoProviderAuth } from '@/modules/providers/list/omo/omo-auth.provider.js';
import { OmoMcpProvider } from '@/modules/providers/list/omo/omo-mcp.provider.js';
import { OmoProviderModels } from '@/modules/providers/list/omo/omo-models.provider.js';
import { OmoSkillsProvider } from '@/modules/providers/list/omo/omo-skills.provider.js';
import { GjcSessionSynchronizer } from '@/modules/providers/list/gjc/gjc-session-synchronizer.provider.js';
import { GjcSessionsProvider } from '@/modules/providers/list/gjc/gjc-sessions.provider.js';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import type {
IProviderAuth,
IProviderModels,
IProviderSessionSynchronizer,
IProviderSkills,
IProviderSessions,
} from '@/shared/interfaces.js';

export class OmoProvider extends AbstractProvider {
readonly models: IProviderModels = new OmoProviderModels();
readonly mcp = new OmoMcpProvider();
readonly auth: IProviderAuth = new OmoProviderAuth();
readonly skills: IProviderSkills = new OmoSkillsProvider();
readonly sessions: IProviderSessions = new GjcSessionsProvider('omo');
readonly sessionSynchronizer: IProviderSessionSynchronizer = new GjcSessionSynchronizer({
provider: 'omo',
});

constructor() {
super('omo');
}
}
7 changes: 6 additions & 1 deletion server/modules/providers/list/omp/omp-models.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,12 @@ export function parseOmpTranscriptActiveModelLine(
}
}

async function readLastTranscriptActiveModel(
/**
* Shared by every pi-derived CLI (omp, gjc, omo): they all record
* `model_change`, `thinking_level_change`, and `configured_model_chain` with
* the same shape, so the last active model is read the same way.
*/
export async function readLastTranscriptActiveModel(
filePath: string,
): Promise<ProviderCurrentActiveModel | null> {
let model: string | null = null;
Expand Down
2 changes: 2 additions & 0 deletions server/modules/providers/provider.registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js'
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
import { GjcProvider } from '@/modules/providers/list/gjc/gjc.provider.js';
import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js';
import { OmoProvider } from '@/modules/providers/list/omo/omo.provider.js';
import { OmpProvider } from '@/modules/providers/list/omp/omp.provider.js';
import type { IProvider } from '@/shared/interfaces.js';
import type { LLMProvider } from '@/shared/types.js';
Expand All @@ -15,6 +16,7 @@ const providers: Record<LLMProvider, IProvider> = {
opencode: new OpenCodeProvider(),
gjc: new GjcProvider(),
omp: new OmpProvider(),
omo: new OmoProvider(),
};

/**
Expand Down
1 change: 1 addition & 0 deletions server/modules/providers/provider.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,7 @@ const parseProvider = (value: unknown): LLMProvider => {
|| normalized === 'opencode'
|| normalized === 'gjc'
|| normalized === 'omp'
|| normalized === 'omo'
) {
return normalized;
}
Expand Down
Loading