mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Make chat mentions inline
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { withOutput } from "../../output/index.js";
|
import { withOutput } from "../../output/index.js";
|
||||||
import { addJsonAndDaemonHostOptions, collectMultiple } from "../../utils/command-options.js";
|
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||||
import { runCreateCommand } from "./create.js";
|
import { runCreateCommand } from "./create.js";
|
||||||
import { runLsCommand } from "./ls.js";
|
import { runLsCommand } from "./ls.js";
|
||||||
import { runInspectCommand } from "./inspect.js";
|
import { runInspectCommand } from "./inspect.js";
|
||||||
@@ -44,13 +44,7 @@ export function createChatCommand(): Command {
|
|||||||
.description("Post a chat message")
|
.description("Post a chat message")
|
||||||
.argument("<name-or-id>", "Room name or ID")
|
.argument("<name-or-id>", "Room name or ID")
|
||||||
.argument("<message>", "Message body")
|
.argument("<message>", "Message body")
|
||||||
.option("--reply-to <msg-id>", "Reply to a specific message ID")
|
.option("--reply-to <msg-id>", "Reply to a specific message ID"),
|
||||||
.option(
|
|
||||||
"--mention <agent-id>",
|
|
||||||
"Mention an agent ID (repeatable)",
|
|
||||||
collectMultiple,
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
).action(withOutput(runPostCommand));
|
).action(withOutput(runPostCommand));
|
||||||
|
|
||||||
addJsonAndDaemonHostOptions(
|
addJsonAndDaemonHostOptions(
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./sche
|
|||||||
|
|
||||||
export interface ChatPostOptions extends ChatCommandOptions {
|
export interface ChatPostOptions extends ChatCommandOptions {
|
||||||
replyTo?: string;
|
replyTo?: string;
|
||||||
mention?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runPostCommand(
|
export async function runPostCommand(
|
||||||
@@ -24,7 +23,6 @@ export async function runPostCommand(
|
|||||||
room,
|
room,
|
||||||
body,
|
body,
|
||||||
replyToMessageId: options.replyTo,
|
replyToMessageId: options.replyTo,
|
||||||
mentionAgentIds: options.mention ?? [],
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
type: "single",
|
type: "single",
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ try {
|
|||||||
"chat",
|
"chat",
|
||||||
"post",
|
"post",
|
||||||
"coord-room",
|
"coord-room",
|
||||||
"first message",
|
"first message for @agent-1",
|
||||||
"--mention",
|
|
||||||
"agent-1",
|
|
||||||
]);
|
]);
|
||||||
assert.strictEqual(posted.exitCode, 0, posted.stderr);
|
assert.strictEqual(posted.exitCode, 0, posted.stderr);
|
||||||
assert(posted.stdout.includes("first message"), posted.stdout);
|
assert(posted.stdout.includes("first message"), posted.stdout);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ log.initialize({ spyRendererConsole: true });
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { app, BrowserWindow, nativeImage, net, protocol } from "electron";
|
import { app, BrowserWindow, nativeImage, nativeTheme, net, protocol } from "electron";
|
||||||
import { registerDaemonManager } from "./daemon/daemon-manager.js";
|
import { registerDaemonManager } from "./daemon/daemon-manager.js";
|
||||||
import {
|
import {
|
||||||
parseCliPassthroughArgsFromArgv,
|
parseCliPassthroughArgsFromArgv,
|
||||||
@@ -32,6 +32,17 @@ protocol.registerSchemesAsPrivileged([
|
|||||||
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
|
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Window chrome
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getTitleBarOverlayColors(): Electron.TitleBarOverlayOptions {
|
||||||
|
if (nativeTheme.shouldUseDarkColors) {
|
||||||
|
return { color: "#18181c", symbolColor: "#e4e4e7", height: 48 };
|
||||||
|
}
|
||||||
|
return { color: "#ffffff", symbolColor: "#09090b", height: 48 };
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Window creation
|
// Window creation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -96,11 +107,7 @@ async function createMainWindow(): Promise<void> {
|
|||||||
...(isMac
|
...(isMac
|
||||||
? { trafficLightPosition: { x: 16, y: 14 } }
|
? { trafficLightPosition: { x: 16, y: 14 } }
|
||||||
: {
|
: {
|
||||||
titleBarOverlay: {
|
titleBarOverlay: getTitleBarOverlayColors(),
|
||||||
color: "#18181c",
|
|
||||||
symbolColor: "#e4e4e7",
|
|
||||||
height: 48,
|
|
||||||
},
|
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
}),
|
}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
@@ -113,6 +120,12 @@ async function createMainWindow(): Promise<void> {
|
|||||||
setupWindowResizeEvents(mainWindow);
|
setupWindowResizeEvents(mainWindow);
|
||||||
setupDragDropPrevention(mainWindow);
|
setupDragDropPrevention(mainWindow);
|
||||||
|
|
||||||
|
if (!isMac) {
|
||||||
|
nativeTheme.on("updated", () => {
|
||||||
|
mainWindow.setTitleBarOverlay(getTitleBarOverlayColors());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
mainWindow.once("ready-to-show", () => {
|
mainWindow.once("ready-to-show", () => {
|
||||||
mainWindow.show();
|
mainWindow.show();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -370,7 +370,6 @@ export type PostChatMessageOptions = {
|
|||||||
room: string;
|
room: string;
|
||||||
body: string;
|
body: string;
|
||||||
replyToMessageId?: string | null;
|
replyToMessageId?: string | null;
|
||||||
mentionAgentIds?: string[];
|
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
};
|
};
|
||||||
export type ReadChatMessagesOptions = {
|
export type ReadChatMessagesOptions = {
|
||||||
@@ -2913,9 +2912,6 @@ export class DaemonClient {
|
|||||||
room: options.room,
|
room: options.room,
|
||||||
body: options.body,
|
body: options.body,
|
||||||
...(options.replyToMessageId ? { replyToMessageId: options.replyToMessageId } : {}),
|
...(options.replyToMessageId ? { replyToMessageId: options.replyToMessageId } : {}),
|
||||||
...(options.mentionAgentIds && options.mentionAgentIds.length > 0
|
|
||||||
? { mentionAgentIds: options.mentionAgentIds }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
responseType: "chat/post/response",
|
responseType: "chat/post/response",
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export const ChatPostRequestSchema = z.object({
|
|||||||
room: z.string(),
|
room: z.string(),
|
||||||
body: z.string(),
|
body: z.string(),
|
||||||
replyToMessageId: z.string().optional(),
|
replyToMessageId: z.string().optional(),
|
||||||
mentionAgentIds: z.array(z.string()).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ChatReadRequestSchema = z.object({
|
export const ChatReadRequestSchema = z.object({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { ChatServiceError, FileBackedChatService } from "./chat-service.js";
|
import { ChatServiceError, FileBackedChatService, parseMentionAgentIds } from "./chat-service.js";
|
||||||
|
|
||||||
describe("FileBackedChatService", () => {
|
describe("FileBackedChatService", () => {
|
||||||
let paseoHome: string;
|
let paseoHome: string;
|
||||||
@@ -48,8 +48,7 @@ describe("FileBackedChatService", () => {
|
|||||||
const first = await service.postMessage({
|
const first = await service.postMessage({
|
||||||
room: room.name,
|
room: room.name,
|
||||||
authorAgentId: "agent-a",
|
authorAgentId: "agent-a",
|
||||||
body: "first message",
|
body: "first message for @agent-b and @agent-c and again @agent-b",
|
||||||
mentionAgentIds: ["agent-b", "agent-b", "agent-c"],
|
|
||||||
});
|
});
|
||||||
await service.postMessage({
|
await service.postMessage({
|
||||||
room: room.id,
|
room: room.id,
|
||||||
@@ -141,4 +140,11 @@ describe("FileBackedChatService", () => {
|
|||||||
code: "chat_room_not_found",
|
code: "chat_room_not_found",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("extracts inline mentions from chat bodies", () => {
|
||||||
|
expect(
|
||||||
|
parseMentionAgentIds("Checking with @agent-a, (@agent_b), and duplicate @agent-a again."),
|
||||||
|
).toEqual(["agent-a", "agent_b"]);
|
||||||
|
expect(parseMentionAgentIds("email@example.com is not a mention")).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,19 @@ function trimToNull(value: string | null | undefined): string | null {
|
|||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHAT_MENTION_PATTERN = /(?:^|[\s(])@([A-Za-z0-9][A-Za-z0-9._-]*)/g;
|
||||||
|
|
||||||
|
export function parseMentionAgentIds(body: string): string[] {
|
||||||
|
const mentionAgentIds = new Set<string>();
|
||||||
|
for (const match of body.matchAll(CHAT_MENTION_PATTERN)) {
|
||||||
|
const agentId = match[1]?.trim();
|
||||||
|
if (agentId) {
|
||||||
|
mentionAgentIds.add(agentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(mentionAgentIds).sort();
|
||||||
|
}
|
||||||
|
|
||||||
export class ChatServiceError extends Error {
|
export class ChatServiceError extends Error {
|
||||||
readonly code: string;
|
readonly code: string;
|
||||||
|
|
||||||
@@ -67,7 +80,6 @@ export interface PostChatMessageInput {
|
|||||||
authorAgentId: string;
|
authorAgentId: string;
|
||||||
body: string;
|
body: string;
|
||||||
replyToMessageId?: string | null;
|
replyToMessageId?: string | null;
|
||||||
mentionAgentIds?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReadChatMessagesInput {
|
export interface ReadChatMessagesInput {
|
||||||
@@ -195,9 +207,7 @@ export class FileBackedChatService {
|
|||||||
authorAgentId,
|
authorAgentId,
|
||||||
body,
|
body,
|
||||||
replyToMessageId,
|
replyToMessageId,
|
||||||
mentionAgentIds: [...new Set((input.mentionAgentIds ?? []).map((value) => value.trim()))]
|
mentionAgentIds: parseMentionAgentIds(body),
|
||||||
.filter((value) => value.length > 0)
|
|
||||||
.sort(),
|
|
||||||
createdAt,
|
createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7730,7 +7730,6 @@ export class Session {
|
|||||||
authorAgentId: this.clientId,
|
authorAgentId: this.clientId,
|
||||||
body: request.body,
|
body: request.body,
|
||||||
replyToMessageId: request.replyToMessageId,
|
replyToMessageId: request.replyToMessageId,
|
||||||
mentionAgentIds: request.mentionAgentIds,
|
|
||||||
});
|
});
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "chat/post/response",
|
type: "chat/post/response",
|
||||||
@@ -7740,11 +7739,79 @@ export class Session {
|
|||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
void this.notifyMentionedAgents({
|
||||||
|
room: request.room,
|
||||||
|
authorAgentId: this.clientId,
|
||||||
|
body: request.body,
|
||||||
|
mentionAgentIds: message.mentionAgentIds,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.emitChatRpcError(request, error);
|
this.emitChatRpcError(request, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async notifyMentionedAgents(input: {
|
||||||
|
room: string;
|
||||||
|
authorAgentId: string;
|
||||||
|
body: string;
|
||||||
|
mentionAgentIds: string[];
|
||||||
|
}): Promise<void> {
|
||||||
|
const mentionAgentIds = input.mentionAgentIds.filter(
|
||||||
|
(agentId) => agentId !== input.authorAgentId,
|
||||||
|
);
|
||||||
|
if (mentionAgentIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notification = this.buildChatMentionNotification({
|
||||||
|
...input,
|
||||||
|
mentionAgentIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
mentionAgentIds.map(async (mentionedAgentId) => {
|
||||||
|
const resolved = await this.resolveAgentIdentifier(mentionedAgentId);
|
||||||
|
if (!resolved.ok) {
|
||||||
|
this.sessionLogger.warn(
|
||||||
|
{ mentionedAgentId, room: input.room, error: resolved.error },
|
||||||
|
"Failed to resolve chat mention target",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.handleSendAgentMessage(resolved.agentId, notification);
|
||||||
|
} catch (error) {
|
||||||
|
this.sessionLogger.warn(
|
||||||
|
{ err: error, mentionedAgentId: resolved.agentId, room: input.room },
|
||||||
|
"Failed to notify mentioned agent about chat message",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildChatMentionNotification(input: {
|
||||||
|
room: string;
|
||||||
|
authorAgentId: string;
|
||||||
|
body: string;
|
||||||
|
mentionAgentIds: string[];
|
||||||
|
}): string {
|
||||||
|
const mentioned = input.mentionAgentIds.map((agentId) => `@${agentId}`).join(", ");
|
||||||
|
const bodyWithoutMentions = input.body
|
||||||
|
.replace(/(^|\s)@[A-Za-z0-9][A-Za-z0-9._-]*/g, "$1")
|
||||||
|
.trim();
|
||||||
|
const body = bodyWithoutMentions.length > 0 ? bodyWithoutMentions : input.body;
|
||||||
|
|
||||||
|
return [
|
||||||
|
`Chat mention from ${input.authorAgentId} in room "${input.room}".`,
|
||||||
|
`Mentioned agents: ${mentioned}.`,
|
||||||
|
"Message:",
|
||||||
|
body,
|
||||||
|
`Read the room with: paseo chat read ${input.room} --limit 20`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
private async handleChatReadRequest(
|
private async handleChatReadRequest(
|
||||||
request: Extract<SessionInboundMessage, { type: "chat/read" }>,
|
request: Extract<SessionInboundMessage, { type: "chat/read" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -27,10 +27,8 @@ When using chat:
|
|||||||
- check chat often while working
|
- check chat often while working
|
||||||
|
|
||||||
Mentions are active:
|
Mentions are active:
|
||||||
- use `--mention <agent-id>` on your post to notify a specific agent immediately
|
- write mentions inline in the message body as `@<agent-id>` to notify a specific agent immediately
|
||||||
- if you use `--reply-to`, the author of the replied-to message is notified
|
- notifications are sent to the target agent without blocking the chat post
|
||||||
- notifications are sent via `paseo send --no-wait` under the hood
|
|
||||||
- mentions **interrupt** the target agent, so only mention when they need to act now
|
|
||||||
- if a normal post is enough and no one needs to act right now, skip the mention
|
- if a normal post is enough and no one needs to act right now, skip the mention
|
||||||
|
|
||||||
## Command Surface
|
## Command Surface
|
||||||
@@ -68,7 +66,7 @@ paseo chat post issue-456 "I can take that next." --reply-to msg-001
|
|||||||
With a direct mention:
|
With a direct mention:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
paseo chat post issue-456 "Can you verify the relay path next?" --mention <agent-id>
|
paseo chat post issue-456 "@<agent-id> Can you verify the relay path next?"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Read recent messages
|
### Read recent messages
|
||||||
@@ -101,7 +99,7 @@ When using a room:
|
|||||||
- read only a bounded window before acting
|
- read only a bounded window before acting
|
||||||
- post updates when they would help another agent or your future self
|
- post updates when they would help another agent or your future self
|
||||||
- use `--reply-to` when responding to a specific message
|
- use `--reply-to` when responding to a specific message
|
||||||
- use `--mention` when you want to get a specific agent's attention — but know that mentions **interrupt** the target agent
|
- use inline `@<agent-id>` mentions when you want to get a specific agent's attention
|
||||||
- check chat frequently enough that shared coordination actually works
|
- check chat frequently enough that shared coordination actually works
|
||||||
- your own agent ID is available via `$PASEO_AGENT_ID`
|
- your own agent ID is available via `$PASEO_AGENT_ID`
|
||||||
|
|
||||||
@@ -119,4 +117,4 @@ Typical things to post:
|
|||||||
3. Read the room with bounded history
|
3. Read the room with bounded history
|
||||||
4. Post clearly
|
4. Post clearly
|
||||||
5. Use `--reply-to` when replying to a specific message
|
5. Use `--reply-to` when replying to a specific message
|
||||||
6. Use `--mention` when you want to notify someone directly
|
6. Use inline `@<agent-id>` mentions when you want to notify someone directly
|
||||||
|
|||||||
@@ -97,11 +97,10 @@ paseo chat post <room> "Focus on implementing the API layer. Acceptance criteria
|
|||||||
- all new endpoints have tests
|
- all new endpoints have tests
|
||||||
- typecheck passes
|
- typecheck passes
|
||||||
|
|
||||||
Post your progress here. @mention me ($PASEO_AGENT_ID) when done." \
|
Post your progress here. @$PASEO_AGENT_ID when done, and start on this now @<agent-id>."
|
||||||
--mention <agent-id>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The agent gets interrupted with the message and starts working. When done, it @mentions you back.
|
The agent gets notified with the message and starts working. When done, it mentions you back in chat.
|
||||||
|
|
||||||
### Role-based provider selection
|
### Role-based provider selection
|
||||||
|
|
||||||
@@ -148,13 +147,13 @@ paseo chat read <room> --limit 10
|
|||||||
### Directing work
|
### Directing work
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
paseo chat post <room> "The API is done. Now focus on the frontend integration." --mention <agent-id>
|
paseo chat post <room> "@<agent-id> The API is done. Now focus on the frontend integration."
|
||||||
```
|
```
|
||||||
|
|
||||||
### Course-correcting
|
### Course-correcting
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
paseo chat post <room> "The tests you wrote are asserting the mock, not the real implementation. Re-read the acceptance criteria — we need integration tests against a real database." --mention <agent-id>
|
paseo chat post <room> "@<agent-id> The tests you wrote are asserting the mock, not the real implementation. Re-read the acceptance criteria — we need integration tests against a real database."
|
||||||
```
|
```
|
||||||
|
|
||||||
### Challenging agents
|
### Challenging agents
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ paseo chat inspect <name-or-id>
|
|||||||
# Post a message
|
# Post a message
|
||||||
paseo chat post <room> "<message>"
|
paseo chat post <room> "<message>"
|
||||||
paseo chat post <room> "<message>" --reply-to <msg-id>
|
paseo chat post <room> "<message>" --reply-to <msg-id>
|
||||||
paseo chat post <room> "<message>" --mention <agent-id> # Repeatable
|
paseo chat post <room> "@<agent-id> <message>"
|
||||||
|
|
||||||
# Read messages
|
# Read messages
|
||||||
paseo chat read <room>
|
paseo chat read <room>
|
||||||
|
|||||||
Reference in New Issue
Block a user