mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 21:37:45 +00:00
mcp: open session agents and the hub
This commit is contained in:
1 parent
18a014b88b
commit
6de05718c8
3 files changed
+183
No files matched your search
@@ -0,0 +1,72 @@
|
||||
import {basename} from "path"
|
||||
import {T} from "@simplex-chat/types"
|
||||
import {api} from "simplex-chat"
|
||||
import {Actions} from "./actions"
|
||||
import {Core, openCore, updatedUser} from "./core"
|
||||
import {ChatItemsWatch, validName} from "./messages"
|
||||
import {AgentTools} from "./tools"
|
||||
|
||||
const profilePrefix = "SimpleX MCP - "
|
||||
const unnamedSession = "Claude Code"
|
||||
|
||||
export interface SessionAgent {
|
||||
readonly number: number
|
||||
readonly chat: api.ChatApi
|
||||
readonly tools: AgentTools
|
||||
readonly displayName: string
|
||||
rename(name: string): Promise<void>
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
export interface SessionAgentOptions {
|
||||
folder: string
|
||||
number: number
|
||||
name: string
|
||||
actions: Actions
|
||||
standingApprover: (chatInfo: T.ChatInfo) => string | undefined
|
||||
}
|
||||
|
||||
export function sessionName(title: string | undefined, project: string): string {
|
||||
const named = title?.trim()
|
||||
if (named) return named
|
||||
return basename(project) || unnamedSession
|
||||
}
|
||||
|
||||
export function profileName(name: string): string {
|
||||
return validName(`${profilePrefix}${name}`)
|
||||
}
|
||||
|
||||
export async function openSessionAgent({folder, number, name, actions, standingApprover}: SessionAgentOptions): Promise<SessionAgent> {
|
||||
const core: Core = await openCore(folder, profileName(name))
|
||||
const {chat, user} = core
|
||||
const watch = new ChatItemsWatch()
|
||||
const tools = new AgentTools({chat, user, actions, watch, session: number, standingApprover})
|
||||
await core.events(async event => {
|
||||
switch (event.type) {
|
||||
case "newChatItems":
|
||||
return watch.deliver(event.chatItems)
|
||||
case "chatItemUpdated":
|
||||
return watch.update(event.chatItem)
|
||||
case "chatItemsDeleted":
|
||||
return watch.remove(event.chatItemDeletions.map(({deletedChatItem}) => deletedChatItem))
|
||||
case "contactConnected":
|
||||
return tools.onContactConnected(event)
|
||||
case "userJoinedGroup":
|
||||
return tools.onUserJoinedGroup(event)
|
||||
}
|
||||
})
|
||||
const agent: SessionAgent = {
|
||||
number,
|
||||
chat,
|
||||
tools,
|
||||
get displayName() {
|
||||
return user.profile.displayName
|
||||
},
|
||||
async rename(newName) {
|
||||
const renamed = await updatedUser(chat, user, profileName(newName))
|
||||
Object.assign(user.profile, renamed.profile)
|
||||
},
|
||||
close: () => chat.close(),
|
||||
}
|
||||
return agent
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import {ChatEvent, T} from "@simplex-chat/types"
|
||||
import {api} from "simplex-chat"
|
||||
import {Action, Actions} from "./actions"
|
||||
import {Config} from "./config"
|
||||
import {Control, Sessions, controlDataOf, createControlChat, findControlGroup, isControlEvent} from "./control"
|
||||
import {deliveries, openCore, updatedUser} from "./core"
|
||||
import {hubFolder} from "./files"
|
||||
import {logError} from "./log"
|
||||
import {OwnerState} from "./protocol"
|
||||
|
||||
export type Perform = (action: Action, signal: AbortSignal) => Promise<void>
|
||||
|
||||
export class Hub {
|
||||
private stopped = false
|
||||
private controlEvents: Promise<void> = Promise.resolve()
|
||||
|
||||
private constructor(
|
||||
private readonly chat: api.ChatApi,
|
||||
private user: T.User,
|
||||
readonly actions: Actions,
|
||||
readonly control: Control,
|
||||
) {}
|
||||
|
||||
static async open(root: string, config: Config, sessions: Sessions, perform: Perform): Promise<Hub> {
|
||||
const core = await openCore(hubFolder(root), config.mainGroup)
|
||||
try {
|
||||
const {chat, user} = core
|
||||
const group = (await findControlGroup(chat, user.userId)) ?? (await createControlChat(chat, user.userId, config.mainGroup, 1))
|
||||
const actions: Actions = new Actions(config.approve, config.expireHours, controlDataOf(group.customData)?.nextAction ?? 1, {
|
||||
refusal: action => control.refusal(action),
|
||||
saveCounter: next => control.saveCounter(next),
|
||||
postCard: action => control.postCard(action),
|
||||
outcome: action => control.outcome(action),
|
||||
perform,
|
||||
})
|
||||
const control: Control = new Control(chat, group, {displayName: config.mainGroup}, actions, sessions)
|
||||
await control.load()
|
||||
const hub = new Hub(chat, user, actions, control)
|
||||
await core.events(event => hub.onEvent(event))
|
||||
await control.start()
|
||||
return hub
|
||||
} catch (error) {
|
||||
await core.chat.close().catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
get mainGroup(): string {
|
||||
return this.user.profile.displayName
|
||||
}
|
||||
|
||||
owner(): OwnerState {
|
||||
const {link} = this.control
|
||||
return {owners: this.control.owners(), ...(link === undefined ? {} : {link})}
|
||||
}
|
||||
|
||||
standingApprover(chatInfo: T.ChatInfo): string | undefined {
|
||||
return this.control.standingApprover(chatInfo)
|
||||
}
|
||||
|
||||
async configure(config: Config): Promise<void> {
|
||||
this.actions.mode = config.approve
|
||||
this.actions.expireHours = config.expireHours
|
||||
if (config.mainGroup === this.mainGroup) return
|
||||
this.user = await updatedUser(this.chat, this.user, config.mainGroup)
|
||||
await this.control.rename(config.mainGroup)
|
||||
}
|
||||
|
||||
resetOwners(): Promise<string> {
|
||||
const reset = this.controlEvents.then(() => this.control.reset(this.user.userId))
|
||||
this.controlEvents = reset.then(() => undefined, error => logError("owner reset", error))
|
||||
return reset
|
||||
}
|
||||
|
||||
async stop(): Promise<number> {
|
||||
this.stopped = true
|
||||
const cancelled = await this.actions.stop()
|
||||
await deliveries(this.chat, this.control.groupId)
|
||||
await this.chat.close()
|
||||
return cancelled
|
||||
}
|
||||
|
||||
private async onEvent(event: ChatEvent): Promise<void> {
|
||||
if (this.stopped) return
|
||||
const {control} = this
|
||||
if (event.type === "newChatItems") {
|
||||
for (const ci of event.chatItems) {
|
||||
if (ci.chatInfo.type === "group" && ci.chatInfo.groupInfo.groupId === control.groupId && ci.chatInfo.groupChatScope === undefined) control.onMessage(ci)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isControlEvent(event, control.groupId)) {
|
||||
this.controlEvents = this.controlEvents.then(() => control.onEvent(event)).catch(error => logError(`${event.type} in the Main group`, error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {profileName, sessionName} from "../../src/agent"
|
||||
|
||||
describe("session profile names", () => {
|
||||
it("use the title, else the project folder, else Claude Code", () => {
|
||||
expect(sessionName(" auth refactor ", "/p/app")).toBe("auth refactor")
|
||||
expect(sessionName(undefined, "/p/simplex-chat-6")).toBe("simplex-chat-6")
|
||||
expect(sessionName("", "/")).toBe("Claude Code")
|
||||
})
|
||||
|
||||
it("start with SimpleX MCP and stay valid SimpleX names of at most 50 characters", () => {
|
||||
expect(profileName("simplex-chat-6")).toBe("SimpleX MCP - simplex-chat-6")
|
||||
expect(profileName("a".repeat(80))).toBe(`SimpleX MCP - ${"a".repeat(36)}`)
|
||||
expect(profileName("fix #12, then @bob")).toBe("SimpleX MCP - fix 12 then bob")
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user