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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,15 @@ data/
docs/ # Architecture documentation
```

## Roadmap to v1

Open Alice is in pre-release. The following items must land before the first stable version:

- [ ] **Tool confirmation** — sensitive tools (order placement, cancellation, position close) require explicit user confirmation before execution, with a per-tool bypass mechanism for trusted workflows
- [ ] **Trading-as-Git stable interface** — finalize the stage → commit → push API surface (including `tradingStatus`, `tradingLog`, `tradingShow`, `tradingSync`) as a stable, versioned contract
- [ ] **IBKR adapter** — Interactive Brokers integration via the Client Portal or TWS API, adding a third trading backend alongside CCXT and Alpaca
- [ ] **Account snapshot & analytics** — unified trading account snapshots with P&L breakdown, exposure analysis, and historical performance tracking

## Star History

[![Star History Chart](https://api.star-history.com/svg?repos=TraderAlice/OpenAlice&type=Date)](https://star-history.com/#TraderAlice/OpenAlice&Date)
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "open-alice",
"version": "0.9.0-beta.3",
"version": "0.9.0-beta.4",
"description": "File-based trading agent engine",
"type": "module",
"scripts": {
Expand Down Expand Up @@ -47,7 +47,7 @@
"grammy": "^1.40.0",
"hono": "^4.12.5",
"json5": "^2.2.3",
"@traderalice/opentypebb": "link:./packages/opentypebb",
"@traderalice/opentypebb": "workspace:*",
"pino": "^10.3.1",
"playwright-core": "1.58.2",
"sharp": "^0.34.5",
Expand Down
492 changes: 491 additions & 1 deletion pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
packages:
- packages/*
1 change: 1 addition & 0 deletions src/connectors/mcp-ask/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { McpAskPlugin } from './mcp-ask-plugin.js'
export type { McpAskConfig } from './mcp-ask-plugin.js'
export { McpAskConnector } from './mcp-ask-connector.js'
21 changes: 21 additions & 0 deletions src/connectors/mcp-ask/mcp-ask-connector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* MCP Ask no-op connector.
*
* MCP is a pull-based protocol — external clients call tools to interact
* with Alice. There is no push channel, so send() always returns
* delivered: false. Registered with ConnectorCenter so the system knows
* this channel exists but cannot receive proactive notifications.
*/

import type { Connector, ConnectorCapabilities, SendPayload, SendResult } from '../types.js'

export class McpAskConnector implements Connector {
readonly channel = 'mcp-ask'
readonly to = 'default'
readonly capabilities: ConnectorCapabilities = { push: false, media: false }

async send(_payload: SendPayload): Promise<SendResult> {
// MCP is pull-based; outbound send is a no-op.
return { delivered: false }
}
}
11 changes: 2 additions & 9 deletions src/connectors/mcp-ask/mcp-ask-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { glob } from 'node:fs/promises'
import { join, basename } from 'node:path'
import type { Plugin, EngineContext } from '../../core/types.js'
import { SessionStore, toTextHistory } from '../../core/session.js'
import { McpAskConnector } from './mcp-ask-connector.js'

export interface McpAskConfig {
port: number
Expand Down Expand Up @@ -126,15 +127,7 @@ export class McpAskPlugin implements Plugin {
})

// Register as connector for outbound delivery (heartbeat/cron)
this.unregisterConnector = ctx.connectorCenter.register({
channel: 'mcp-ask',
to: 'default',
capabilities: { push: false, media: false },
send: async () => {
// MCP is pull-based; outbound send is a no-op.
return { delivered: false }
},
})
this.unregisterConnector = ctx.connectorCenter.register(new McpAskConnector())

this.server = serve({ fetch: app.fetch, port: this.config.port }, (info) => {
console.log(`mcp-ask connector listening on http://localhost:${info.port}/mcp`)
Expand Down
1 change: 1 addition & 0 deletions src/connectors/telegram/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { TelegramPlugin } from './telegram-plugin.js'
export { TelegramConnector } from './telegram-connector.js'
export type { TelegramConfig, ParsedMessage, MediaRef } from './types.js'
85 changes: 85 additions & 0 deletions src/connectors/telegram/telegram-connector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Telegram outbound connector.
*
* Delivers messages and media to a specific Telegram chat via the grammY
* Bot API. Handles photo attachments (read from disk, sent via sendPhoto)
* and automatic text chunking for messages exceeding Telegram's 4096-char limit.
*
* Does not support streaming (no sendStream) — ConnectorCenter falls back
* to draining the stream and calling send() with the completed result.
*/

import { readFile } from 'node:fs/promises'
import { Bot, InputFile } from 'grammy'
import type { Connector, ConnectorCapabilities, SendPayload, SendResult } from '../types.js'

export const MAX_MESSAGE_LENGTH = 4096

export class TelegramConnector implements Connector {
readonly channel = 'telegram'
readonly to: string
readonly capabilities: ConnectorCapabilities = { push: true, media: true }

constructor(
private readonly bot: Bot,
private readonly chatId: number,
) {
this.to = String(chatId)
}

async send(payload: SendPayload): Promise<SendResult> {
// Send media first (photos)
if (payload.media && payload.media.length > 0) {
for (const attachment of payload.media) {
try {
const buf = await readFile(attachment.path)
await this.bot.api.sendPhoto(this.chatId, new InputFile(buf, 'screenshot.jpg'))
} catch (err) {
console.error('telegram: failed to send photo:', err)
}
}
}

// Send text with chunking
if (payload.text) {
const chunks = splitMessage(payload.text, MAX_MESSAGE_LENGTH)
for (const chunk of chunks) {
await this.bot.api.sendMessage(this.chatId, chunk)
}
}

return { delivered: true }
}
}

// ==================== Helpers ====================

export function splitMessage(text: string, maxLength: number): string[] {
if (text.length <= maxLength) return [text]

const chunks: string[] = []
let remaining = text

while (remaining.length > 0) {
if (remaining.length <= maxLength) {
chunks.push(remaining)
break
}

// Try to split at a newline
let splitAt = remaining.lastIndexOf('\n', maxLength)
if (splitAt === -1 || splitAt < maxLength / 2) {
// Fall back to splitting at a space
splitAt = remaining.lastIndexOf(' ', maxLength)
}
if (splitAt === -1 || splitAt < maxLength / 2) {
// Hard split
splitAt = maxLength
}

chunks.push(remaining.slice(0, splitAt))
remaining = remaining.slice(splitAt).trimStart()
}

return chunks
}
67 changes: 2 additions & 65 deletions src/connectors/telegram/telegram-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ import { SessionStore } from '../../core/session'
import { forceCompact } from '../../core/compaction'
import { readAIBackend, writeAIBackend, type AIBackend } from '../../core/config'
import type { ConnectorCenter } from '../../core/connector-center.js'
import type { Connector } from '../types.js'

const MAX_MESSAGE_LENGTH = 4096
import { TelegramConnector, splitMessage, MAX_MESSAGE_LENGTH } from './telegram-connector.js'

const BACKEND_LABELS: Record<AIBackend, string> = {
'claude-code': 'Claude Code',
Expand Down Expand Up @@ -177,7 +175,7 @@ export class TelegramPlugin implements Plugin {
// ── Register connector for outbound delivery (heartbeat / cron responses) ──
if (this.config.allowedChatIds.length > 0) {
const deliveryChatId = this.config.allowedChatIds[0]
this.unregisterConnector = this.connectorCenter!.register(this.createConnector(bot, deliveryChatId))
this.unregisterConnector = this.connectorCenter!.register(new TelegramConnector(bot, deliveryChatId))
}

// ── Start polling ──
Expand All @@ -196,37 +194,6 @@ export class TelegramPlugin implements Plugin {
this.unregisterConnector?.()
}

private createConnector(bot: Bot, chatId: number): Connector {
return {
channel: 'telegram',
to: String(chatId),
capabilities: { push: true, media: true },
send: async (payload) => {
// Send media first (photos)
if (payload.media && payload.media.length > 0) {
for (const attachment of payload.media) {
try {
const buf = await readFile(attachment.path)
await bot.api.sendPhoto(chatId, new InputFile(buf, 'screenshot.jpg'))
} catch (err) {
console.error('telegram: failed to send photo:', err)
}
}
}

// Send text with chunking
if (payload.text) {
const chunks = splitMessage(payload.text, MAX_MESSAGE_LENGTH)
for (const chunk of chunks) {
await bot.api.sendMessage(chatId, chunk)
}
}

return { delivered: true }
},
}
}

private async getSession(userId: number): Promise<SessionStore> {
let session = this.sessions.get(userId)
if (!session) {
Expand Down Expand Up @@ -436,33 +403,3 @@ export class TelegramPlugin implements Plugin {
}
}
}

function splitMessage(text: string, maxLength: number): string[] {
if (text.length <= maxLength) return [text]

const chunks: string[] = []
let remaining = text

while (remaining.length > 0) {
if (remaining.length <= maxLength) {
chunks.push(remaining)
break
}

// Try to split at a newline
let splitAt = remaining.lastIndexOf('\n', maxLength)
if (splitAt === -1 || splitAt < maxLength / 2) {
// Fall back to splitting at a space
splitAt = remaining.lastIndexOf(' ', maxLength)
}
if (splitAt === -1 || splitAt < maxLength / 2) {
// Hard split
splitAt = maxLength
}

chunks.push(remaining.slice(0, splitAt))
remaining = remaining.slice(splitAt).trimStart()
}

return chunks
}
Loading
Loading