@lightupai/polaris 0.0.5 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +17 -0
- package/.github/workflows/ci.yml +38 -0
- package/.mcp.json +3 -3
- package/Makefile +20 -3
- package/README.md +124 -0
- package/bun.lock +289 -0
- package/deploy.sh +18 -0
- package/docker/Caddyfile +7 -0
- package/docker/Dockerfile +13 -0
- package/docker/bridge-entrypoint.sh +17 -0
- package/docker-compose.prod.yml +85 -0
- package/docs/deploy-hetzner.md +99 -0
- package/hooks/capture-stop.sh +6 -0
- package/hooks/capture-stop.ts +122 -0
- package/hooks/capture.sh +1 -1
- package/hooks/statusline.sh +22 -11
- package/package.json +3 -1
- package/skills/polaris/SKILL.md +6 -2
- package/src/bridge-discover-org.ts +5 -0
- package/src/cli/cli.ts +401 -160
- package/src/client/client.ts +37 -24
- package/src/daemon/daemon.ts +250 -8
- package/src/service/db.ts +159 -28
- package/src/service/server.ts +47 -0
- package/src/slack/bridge.ts +399 -0
- package/src/slack/format.ts +115 -0
- package/src/types.ts +7 -1
- package/src/web/app.ts +40 -10
- package/src/web/layout.ts +16 -2
- package/src/web/views.ts +63 -77
- package/tests/bridge.test.ts +205 -0
- package/tests/client.test.ts +3 -13
- package/tests/daemon.test.ts +5 -14
- package/tests/e2e.test.ts +4 -13
- package/tests/format.test.ts +103 -0
- package/tests/helpers.ts +71 -0
- package/tests/service.test.ts +2 -13
- package/tests/types.test.ts +2 -2
- package/tests/web.test.ts +17 -31
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// --- Slack Bridge ---
|
|
2
|
+
// Connects a project's event stream to a Slack channel.
|
|
3
|
+
// Runs server-side, one bridge per org.
|
|
4
|
+
//
|
|
5
|
+
// Project → Slack: session events → formatted Slack posts
|
|
6
|
+
// Slack → Project: advisor messages → injected into target session
|
|
7
|
+
|
|
8
|
+
import { SocketModeClient } from "@slack/socket-mode";
|
|
9
|
+
import { WebClient } from "@slack/web-api";
|
|
10
|
+
import { createDb, getOrg, listProjects, getProjectEvents, getOrgEventsSince, getSession, createSession, pushEvent, setProjectSlackChannel, type Sql, type Org } from "../service/db";
|
|
11
|
+
import { formatEventForSlack, toMrkdwn, THREAD_THRESHOLD } from "./format";
|
|
12
|
+
import type { PolarisEvent } from "../types";
|
|
13
|
+
|
|
14
|
+
// --- Channel management ---
|
|
15
|
+
|
|
16
|
+
// In-memory cache: project name → Slack channel ID
|
|
17
|
+
const channelCache = new Map<string, string>();
|
|
18
|
+
|
|
19
|
+
async function getOrCreateChannel(web: WebClient, sql: Sql, orgId: string, projectName: string): Promise<string> {
|
|
20
|
+
// 1. Check in-memory cache
|
|
21
|
+
const cached = channelCache.get(projectName);
|
|
22
|
+
if (cached) return cached;
|
|
23
|
+
|
|
24
|
+
// 2. Check DB for stored channel ID (survives renames and restarts)
|
|
25
|
+
const projects = await listProjects(sql, orgId);
|
|
26
|
+
const project = projects.find((p) => p.name === projectName);
|
|
27
|
+
if (project?.slack_channel_id) {
|
|
28
|
+
channelCache.set(projectName, project.slack_channel_id);
|
|
29
|
+
// Ensure bot is in the channel (might have been removed)
|
|
30
|
+
try { await web.conversations.join({ channel: project.slack_channel_id }); } catch {}
|
|
31
|
+
return project.slack_channel_id;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 3. Create or find channel by name
|
|
35
|
+
const channelName = projectName.toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 80);
|
|
36
|
+
let channelId: string | undefined;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const result = await web.conversations.create({ name: channelName, is_private: false });
|
|
40
|
+
if (result.ok && result.channel?.id) {
|
|
41
|
+
channelId = result.channel.id;
|
|
42
|
+
await web.conversations.setTopic({
|
|
43
|
+
channel: channelId,
|
|
44
|
+
topic: `Polaris project: ${projectName}`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// name_taken — find and join
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!channelId) {
|
|
52
|
+
let cursor: string | undefined;
|
|
53
|
+
do {
|
|
54
|
+
const list = await web.conversations.list({
|
|
55
|
+
types: "public_channel",
|
|
56
|
+
limit: 200,
|
|
57
|
+
exclude_archived: true,
|
|
58
|
+
cursor,
|
|
59
|
+
});
|
|
60
|
+
const found = list.channels?.find((c) => c.name === channelName);
|
|
61
|
+
if (found?.id) {
|
|
62
|
+
channelId = found.id;
|
|
63
|
+
await web.conversations.join({ channel: channelId });
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
cursor = list.response_metadata?.next_cursor || undefined;
|
|
67
|
+
} while (cursor);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!channelId) {
|
|
71
|
+
throw new Error(`Could not create or find channel for project: ${projectName}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 4. Persist the channel ID in DB (resilient to renames)
|
|
75
|
+
channelCache.set(projectName, channelId);
|
|
76
|
+
// Resolve and store channel name (for status line display)
|
|
77
|
+
let resolvedName: string | undefined;
|
|
78
|
+
try {
|
|
79
|
+
const info = await web.conversations.info({ channel: channelId });
|
|
80
|
+
resolvedName = info.channel?.name ?? undefined;
|
|
81
|
+
} catch {}
|
|
82
|
+
await setProjectSlackChannel(sql, orgId, projectName, channelId, resolvedName);
|
|
83
|
+
return channelId;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- Event → Slack posting ---
|
|
87
|
+
|
|
88
|
+
async function postEventToSlack(web: WebClient, sql: Sql, orgId: string, event: PolarisEvent): Promise<void> {
|
|
89
|
+
// Skip _system events (handled separately)
|
|
90
|
+
if (event.project === "_system") return;
|
|
91
|
+
|
|
92
|
+
// Skip events that originated from Slack (avoid re-posting)
|
|
93
|
+
if (event.sender.startsWith("slack:")) return;
|
|
94
|
+
|
|
95
|
+
const msg = formatEventForSlack(event);
|
|
96
|
+
if (!msg) return;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const channelId = await getOrCreateChannel(web, sql, orgId, event.project);
|
|
100
|
+
const isLong = msg.text.length > THREAD_THRESHOLD;
|
|
101
|
+
// POLARIS_LONG_MSG controls how messages longer than THREAD_THRESHOLD are posted:
|
|
102
|
+
// "snippet" (default) — 500-char preview + full content as expandable text snippet
|
|
103
|
+
// "thread" — 500-char preview in channel, full content in a thread reply
|
|
104
|
+
// "inline" — post the full message directly in the channel (may be very long)
|
|
105
|
+
const longMode = process.env.POLARIS_LONG_MSG ?? "snippet";
|
|
106
|
+
|
|
107
|
+
if (!isLong || longMode === "inline") {
|
|
108
|
+
await web.chat.postMessage({
|
|
109
|
+
channel: channelId,
|
|
110
|
+
text: msg.text,
|
|
111
|
+
...(msg.blocks ? { blocks: msg.blocks } : {}),
|
|
112
|
+
...(msg.username ? { username: msg.username } : {}),
|
|
113
|
+
...(msg.icon_emoji ? { icon_emoji: msg.icon_emoji } : {}),
|
|
114
|
+
});
|
|
115
|
+
} else if (longMode === "thread") {
|
|
116
|
+
const preview = msg.text.slice(0, 500).trimEnd();
|
|
117
|
+
const summaryText = `${preview}...\n\n_Full response in thread_ :thread:`;
|
|
118
|
+
const summary = await web.chat.postMessage({
|
|
119
|
+
channel: channelId,
|
|
120
|
+
text: summaryText,
|
|
121
|
+
blocks: [{ type: "section", text: { type: "mrkdwn", text: summaryText } }],
|
|
122
|
+
...(msg.username ? { username: msg.username } : {}),
|
|
123
|
+
...(msg.icon_emoji ? { icon_emoji: msg.icon_emoji } : {}),
|
|
124
|
+
});
|
|
125
|
+
if (summary.ok && summary.ts) {
|
|
126
|
+
await web.chat.postMessage({
|
|
127
|
+
channel: channelId,
|
|
128
|
+
thread_ts: summary.ts,
|
|
129
|
+
text: msg.text,
|
|
130
|
+
...(msg.username ? { username: msg.username } : {}),
|
|
131
|
+
...(msg.icon_emoji ? { icon_emoji: msg.icon_emoji } : {}),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
// snippet mode — preview + expandable file attachment
|
|
136
|
+
// Requires files:write scope on the Slack bot token
|
|
137
|
+
const preview = msg.text.slice(0, 500).trimEnd();
|
|
138
|
+
const summaryText = `${preview}...`;
|
|
139
|
+
await web.chat.postMessage({
|
|
140
|
+
channel: channelId,
|
|
141
|
+
text: summaryText,
|
|
142
|
+
blocks: [{ type: "section", text: { type: "mrkdwn", text: summaryText } }],
|
|
143
|
+
...(msg.username ? { username: msg.username } : {}),
|
|
144
|
+
...(msg.icon_emoji ? { icon_emoji: msg.icon_emoji } : {}),
|
|
145
|
+
});
|
|
146
|
+
const sender = msg.username ?? "agent";
|
|
147
|
+
await web.filesUploadV2({
|
|
148
|
+
channel_id: channelId,
|
|
149
|
+
content: msg.text,
|
|
150
|
+
filename: `${sender.toLowerCase().replace(/[^a-z0-9-]/g, "-")}-${event.session}-${Date.now()}.md`,
|
|
151
|
+
title: `${sender} — full response`,
|
|
152
|
+
initial_comment: `_Expand to read the full response from ${sender}_`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
} catch (e) {
|
|
156
|
+
console.error(`[bridge] Failed to post to Slack for project ${event.project}:`, e);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// --- Slack → Project injection ---
|
|
161
|
+
|
|
162
|
+
async function handleSlackMessage(
|
|
163
|
+
web: WebClient,
|
|
164
|
+
sql: Sql,
|
|
165
|
+
orgId: string,
|
|
166
|
+
botUserId: string,
|
|
167
|
+
event: {
|
|
168
|
+
text: string;
|
|
169
|
+
user: string;
|
|
170
|
+
channel: string;
|
|
171
|
+
ts: string;
|
|
172
|
+
}
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
// Ignore bot's own messages
|
|
175
|
+
if (event.user === botUserId) return;
|
|
176
|
+
|
|
177
|
+
// Find which project this channel belongs to
|
|
178
|
+
let projectName: string | undefined;
|
|
179
|
+
|
|
180
|
+
// Check in-memory cache
|
|
181
|
+
for (const [proj, chanId] of channelCache) {
|
|
182
|
+
if (chanId === event.channel) {
|
|
183
|
+
projectName = proj;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Check DB
|
|
189
|
+
if (!projectName) {
|
|
190
|
+
const projects = await listProjects(sql, orgId);
|
|
191
|
+
for (const proj of projects) {
|
|
192
|
+
if (proj.slack_channel_id === event.channel) {
|
|
193
|
+
projectName = proj.name;
|
|
194
|
+
channelCache.set(proj.name, event.channel);
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Fall back to channel name lookup
|
|
201
|
+
if (!projectName) {
|
|
202
|
+
try {
|
|
203
|
+
const info = await web.conversations.info({ channel: event.channel });
|
|
204
|
+
const chanName = info.channel?.name;
|
|
205
|
+
if (chanName) {
|
|
206
|
+
projectName = chanName;
|
|
207
|
+
channelCache.set(chanName, event.channel);
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!projectName) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Parse target session from message: @session-name or first word
|
|
219
|
+
// Format: "@session message" or "session: message"
|
|
220
|
+
const match = event.text.match(/^@(\S+)\s+(.+)$/s) || event.text.match(/^(\S+):\s+(.+)$/s);
|
|
221
|
+
if (!match) {
|
|
222
|
+
// No target specified — post a hint
|
|
223
|
+
try {
|
|
224
|
+
await web.chat.postMessage({
|
|
225
|
+
channel: event.channel,
|
|
226
|
+
thread_ts: event.ts,
|
|
227
|
+
text: `To send a message to a session, use: \`@session-name your message\``,
|
|
228
|
+
});
|
|
229
|
+
} catch { /* ignore */ }
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const targetSession = match[1];
|
|
234
|
+
const content = match[2];
|
|
235
|
+
|
|
236
|
+
// Look up Slack user to get display name
|
|
237
|
+
let senderName = `slack:${event.user}`;
|
|
238
|
+
try {
|
|
239
|
+
const userInfo = await web.users.info({ user: event.user });
|
|
240
|
+
if (userInfo.user?.profile?.display_name || userInfo.user?.real_name) {
|
|
241
|
+
const name = (userInfo.user.profile?.display_name || userInfo.user.real_name || "")
|
|
242
|
+
.toLowerCase().replace(/\s+/g, ".");
|
|
243
|
+
senderName = `slack:${name}`;
|
|
244
|
+
}
|
|
245
|
+
} catch { /* use ID */ }
|
|
246
|
+
|
|
247
|
+
// Inject directly into DB (bridge runs server-side with DB access)
|
|
248
|
+
try {
|
|
249
|
+
// Ensure session exists
|
|
250
|
+
let session = await getSession(sql, orgId, projectName, targetSession);
|
|
251
|
+
if (!session) {
|
|
252
|
+
try { session = await createSession(sql, orgId, projectName, targetSession, null); } catch { /* exists */ }
|
|
253
|
+
session = await getSession(sql, orgId, projectName, targetSession);
|
|
254
|
+
}
|
|
255
|
+
if (!session) {
|
|
256
|
+
console.error(`[bridge] Session ${projectName}/${targetSession} not found`);
|
|
257
|
+
await web.chat.postMessage({
|
|
258
|
+
channel: event.channel,
|
|
259
|
+
thread_ts: event.ts,
|
|
260
|
+
text: `Session \`${targetSession}\` not found in project \`${projectName}\`.`,
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const injectEvent = {
|
|
265
|
+
id: crypto.randomUUID(),
|
|
266
|
+
project: projectName,
|
|
267
|
+
session: targetSession,
|
|
268
|
+
timestamp: new Date().toISOString(),
|
|
269
|
+
source: "inject" as const,
|
|
270
|
+
sender: senderName as `${"user" | "agent" | "slack"}:${string}`,
|
|
271
|
+
payload: {
|
|
272
|
+
type: "inject" as const,
|
|
273
|
+
content,
|
|
274
|
+
sender: senderName as `${"user" | "agent" | "slack"}:${string}`,
|
|
275
|
+
target: targetSession,
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
await pushEvent(sql, orgId, injectEvent);
|
|
279
|
+
console.error(`[bridge] Injected into ${projectName}/${targetSession}: ${content.slice(0, 50)}`);
|
|
280
|
+
} catch (e) {
|
|
281
|
+
console.error(`[bridge] Failed to inject:`, e);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// --- Bridge startup ---
|
|
286
|
+
|
|
287
|
+
export async function startBridge(opts: {
|
|
288
|
+
databaseUrl?: string;
|
|
289
|
+
orgId: string;
|
|
290
|
+
apiBaseUrl?: string;
|
|
291
|
+
}): Promise<{ stop: () => void }> {
|
|
292
|
+
const sql = await createDb(opts.databaseUrl);
|
|
293
|
+
const org = await getOrg(sql, opts.orgId);
|
|
294
|
+
if (!org) throw new Error(`Org not found: ${opts.orgId}`);
|
|
295
|
+
if (!org.slack_bot_token) throw new Error(`Org ${opts.orgId} has no Slack bot token`);
|
|
296
|
+
|
|
297
|
+
const appToken = process.env.SLACK_APP_TOKEN;
|
|
298
|
+
if (!appToken) throw new Error("SLACK_APP_TOKEN required for Socket Mode");
|
|
299
|
+
|
|
300
|
+
const apiBaseUrl = opts.apiBaseUrl ?? process.env.POLARIS_API_URL ?? "http://localhost:4321";
|
|
301
|
+
|
|
302
|
+
const web = new WebClient(org.slack_bot_token);
|
|
303
|
+
const socketMode = new SocketModeClient({ appToken });
|
|
304
|
+
|
|
305
|
+
// Get bot user ID to filter own messages
|
|
306
|
+
let botUserId = "";
|
|
307
|
+
try {
|
|
308
|
+
const auth = await web.auth.test();
|
|
309
|
+
botUserId = auth.user_id as string;
|
|
310
|
+
console.error(`[bridge] Bot user ID: ${botUserId}`);
|
|
311
|
+
} catch (e) {
|
|
312
|
+
console.error("[bridge] Failed to get bot user ID:", e);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Listen for Slack messages
|
|
316
|
+
socketMode.on("message", async ({ event, ack }: { event: Record<string, unknown>; ack: () => Promise<void> }) => {
|
|
317
|
+
try {
|
|
318
|
+
await ack();
|
|
319
|
+
const msg = event as { text?: string; user?: string; channel?: string; ts?: string; subtype?: string; name?: string };
|
|
320
|
+
console.error(`[bridge] message: user=${msg.user} channel=${msg.channel} subtype=${msg.subtype} text=${msg.text?.slice(0, 80)}`);
|
|
321
|
+
|
|
322
|
+
// Handle channel rename system messages
|
|
323
|
+
if (msg.subtype === "channel_name" && msg.channel && msg.name) {
|
|
324
|
+
console.error(`[bridge] channel renamed: ${msg.channel} → ${msg.name}`);
|
|
325
|
+
const projects = await listProjects(sql, opts.orgId);
|
|
326
|
+
for (const proj of projects) {
|
|
327
|
+
if (proj.slack_channel_id === msg.channel) {
|
|
328
|
+
channelCache.set(proj.name, msg.channel);
|
|
329
|
+
await setProjectSlackChannel(sql, opts.orgId, proj.name, msg.channel, msg.name);
|
|
330
|
+
// Notify local daemon so status line updates immediately
|
|
331
|
+
try {
|
|
332
|
+
await fetch(`http://127.0.0.1:${process.env.POLARIS_DAEMON_PORT ?? 4322}/channel-update`, {
|
|
333
|
+
method: "POST",
|
|
334
|
+
headers: { "Content-Type": "application/json" },
|
|
335
|
+
body: JSON.stringify({ project: proj.name, slackChannel: msg.name }),
|
|
336
|
+
});
|
|
337
|
+
} catch { /* daemon may not be running */ }
|
|
338
|
+
console.error(`[bridge] Updated channel name for project ${proj.name}: ${msg.name}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (msg.subtype || !msg.channel || !msg.text || !msg.user) return;
|
|
345
|
+
if (msg.user === botUserId) return;
|
|
346
|
+
await handleSlackMessage(web, sql, opts.orgId, botUserId, msg as { text: string; user: string; channel: string; ts: string });
|
|
347
|
+
} catch (e) {
|
|
348
|
+
console.error(`[bridge] message handler error:`, e);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// Poll for new events directly from DB (bridge runs server-side)
|
|
353
|
+
const postedEventIds = new Set<string>();
|
|
354
|
+
let lastPollTime = new Date().toISOString();
|
|
355
|
+
|
|
356
|
+
async function pollEvents() {
|
|
357
|
+
try {
|
|
358
|
+
const since = lastPollTime;
|
|
359
|
+
const events = await getOrgEventsSince(sql, opts.orgId, since);
|
|
360
|
+
const now = new Date().toISOString();
|
|
361
|
+
|
|
362
|
+
for (const event of events) {
|
|
363
|
+
if (event.project === "_system") continue;
|
|
364
|
+
if (postedEventIds.has(event.id)) continue;
|
|
365
|
+
postedEventIds.add(event.id);
|
|
366
|
+
await postEventToSlack(web, sql, opts.orgId, event);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
lastPollTime = now;
|
|
370
|
+
} catch (e) {
|
|
371
|
+
console.error("[bridge] Poll error:", e);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const pollInterval = setInterval(pollEvents, 5000);
|
|
376
|
+
|
|
377
|
+
// Start Socket Mode
|
|
378
|
+
await socketMode.start();
|
|
379
|
+
console.error(`[bridge] Slack bridge started for org: ${org.name}`);
|
|
380
|
+
console.error(`[bridge] Watching for messages in project channels`);
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
stop: () => {
|
|
384
|
+
clearInterval(pollInterval);
|
|
385
|
+
socketMode.disconnect();
|
|
386
|
+
sql.end();
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// --- Run if executed directly ---
|
|
392
|
+
if (import.meta.main) {
|
|
393
|
+
const orgId = process.argv[2];
|
|
394
|
+
if (!orgId) {
|
|
395
|
+
console.error("Usage: bun run src/slack/bridge.ts <org-id>");
|
|
396
|
+
process.exit(1);
|
|
397
|
+
}
|
|
398
|
+
await startBridge({ orgId });
|
|
399
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// --- Event → Slack message formatting ---
|
|
2
|
+
|
|
3
|
+
import type { PolarisEvent } from "../types";
|
|
4
|
+
|
|
5
|
+
// Messages longer than this get a summary in the channel + full content in a thread
|
|
6
|
+
export const THREAD_THRESHOLD = 700;
|
|
7
|
+
|
|
8
|
+
export interface SlackMessage {
|
|
9
|
+
text: string;
|
|
10
|
+
blocks?: Array<Record<string, unknown>>;
|
|
11
|
+
username?: string;
|
|
12
|
+
icon_emoji?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Derive a display name from a participant ID
|
|
16
|
+
function displayName(participantId: string): string {
|
|
17
|
+
const [type, name] = participantId.split(":", 2);
|
|
18
|
+
if (!name) return participantId;
|
|
19
|
+
const pretty = name.replace(/[._-]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
20
|
+
if (type === "agent") return `Agent: ${pretty}`;
|
|
21
|
+
return pretty;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function personaIcon(participantId: string): string {
|
|
25
|
+
if (participantId.startsWith("agent:")) return ":robot_face:";
|
|
26
|
+
if (participantId.startsWith("slack:")) return ":speech_balloon:";
|
|
27
|
+
return ":bust_in_silhouette:";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Format a PolarisEvent into a Slack message.
|
|
31
|
+
// Returns null if the event should be skipped (e.g., tool calls).
|
|
32
|
+
export function formatEventForSlack(event: PolarisEvent): SlackMessage | null {
|
|
33
|
+
const payload = event.payload;
|
|
34
|
+
|
|
35
|
+
if ("hook_event_name" in payload) {
|
|
36
|
+
switch (payload.hook_event_name) {
|
|
37
|
+
case "UserPromptSubmit":
|
|
38
|
+
return formatUserPrompt(event.sender, event.session, payload.prompt);
|
|
39
|
+
case "Stop": {
|
|
40
|
+
const response = payload.stop_response || payload.last_assistant_message;
|
|
41
|
+
if (!response) return null;
|
|
42
|
+
return formatAgentResponse(event.session, response);
|
|
43
|
+
}
|
|
44
|
+
case "PreToolUse":
|
|
45
|
+
case "PostToolUse":
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if ("type" in payload && payload.type === "inject") {
|
|
51
|
+
return formatAdvisorMessage(
|
|
52
|
+
event.sender,
|
|
53
|
+
(payload as { target: string }).target,
|
|
54
|
+
(payload as { content: string }).content,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if ("type" in payload && payload.type === "reply") {
|
|
59
|
+
const body = (payload as { content: string }).content;
|
|
60
|
+
if (!body) return null;
|
|
61
|
+
return {
|
|
62
|
+
text: toMrkdwn(body),
|
|
63
|
+
blocks: [{ type: "section", text: { type: "mrkdwn", text: toMrkdwn(body) } }],
|
|
64
|
+
username: displayName(event.sender),
|
|
65
|
+
icon_emoji: personaIcon(event.sender),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- User prompt ---
|
|
73
|
+
|
|
74
|
+
function formatUserPrompt(sender: string, session: string, prompt: string): SlackMessage | null {
|
|
75
|
+
if (!prompt) return null;
|
|
76
|
+
return {
|
|
77
|
+
text: toMrkdwn(prompt),
|
|
78
|
+
blocks: [{ type: "section", text: { type: "mrkdwn", text: toMrkdwn(prompt) } }],
|
|
79
|
+
username: `${displayName(sender)} (${session})`,
|
|
80
|
+
icon_emoji: personaIcon(sender),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// --- Agent response ---
|
|
85
|
+
|
|
86
|
+
function formatAgentResponse(session: string, response: string): SlackMessage | null {
|
|
87
|
+
if (!response) return null;
|
|
88
|
+
return {
|
|
89
|
+
text: toMrkdwn(response),
|
|
90
|
+
blocks: [{ type: "section", text: { type: "mrkdwn", text: toMrkdwn(response) } }],
|
|
91
|
+
username: `Agent (${session})`,
|
|
92
|
+
icon_emoji: ":robot_face:",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- Advisor message ---
|
|
97
|
+
|
|
98
|
+
function formatAdvisorMessage(sender: string, target: string, content: string): SlackMessage | null {
|
|
99
|
+
if (!content) return null;
|
|
100
|
+
return {
|
|
101
|
+
text: toMrkdwn(content),
|
|
102
|
+
blocks: [{ type: "section", text: { type: "mrkdwn", text: toMrkdwn(`→ _${target}_: ${content}`) } }],
|
|
103
|
+
username: displayName(sender),
|
|
104
|
+
icon_emoji: personaIcon(sender),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- Markdown → Slack mrkdwn ---
|
|
109
|
+
|
|
110
|
+
export function toMrkdwn(text: string): string {
|
|
111
|
+
return text
|
|
112
|
+
.replace(/```(\w*)\n([\s\S]*?)```/g, "```$2```")
|
|
113
|
+
.replace(/\*\*(.*?)\*\*/g, "*$1*")
|
|
114
|
+
.replace(/__(.*?)__/g, "*$1*");
|
|
115
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -27,7 +27,10 @@ export const UserPromptSubmitPayload = HookCommon.extend({
|
|
|
27
27
|
|
|
28
28
|
export const StopPayload = HookCommon.extend({
|
|
29
29
|
hook_event_name: z.literal("Stop"),
|
|
30
|
-
stop_response: z.string(),
|
|
30
|
+
stop_response: z.string().optional(),
|
|
31
|
+
last_assistant_message: z.string().optional(),
|
|
32
|
+
stop_hook_active: z.boolean().optional(),
|
|
33
|
+
raw_turn: z.array(z.unknown()).optional(),
|
|
31
34
|
});
|
|
32
35
|
|
|
33
36
|
export const PreToolUsePayload = HookCommon.extend({
|
|
@@ -92,7 +95,10 @@ export type PolarisEvent = z.infer<typeof PolarisEvent>;
|
|
|
92
95
|
// --- Project & Session Models ---
|
|
93
96
|
|
|
94
97
|
export const Project = z.object({
|
|
98
|
+
id: z.string().uuid(),
|
|
95
99
|
name: z.string().min(1),
|
|
100
|
+
slack_channel_id: z.string().nullable().optional(),
|
|
101
|
+
slack_channel_name: z.string().nullable().optional(),
|
|
96
102
|
created_at: z.string().datetime(),
|
|
97
103
|
});
|
|
98
104
|
|
package/src/web/app.ts
CHANGED
|
@@ -10,6 +10,10 @@ import {
|
|
|
10
10
|
upsertUser,
|
|
11
11
|
setOrgSlack,
|
|
12
12
|
getSessionEvents,
|
|
13
|
+
listProjects,
|
|
14
|
+
listSessions,
|
|
15
|
+
getSessionPromptCounts,
|
|
16
|
+
getProjectEvents,
|
|
13
17
|
type Sql,
|
|
14
18
|
} from "../service/db";
|
|
15
19
|
import { layout, nav } from "./layout";
|
|
@@ -175,6 +179,34 @@ export function createApp(sql: Sql) {
|
|
|
175
179
|
}
|
|
176
180
|
} catch { /* _system project may not exist yet */ }
|
|
177
181
|
|
|
182
|
+
// Query real projects, sessions, and prompt counts
|
|
183
|
+
const projects = (await listProjects(sql, payload.org_id)).filter((p) => p.name !== "_system");
|
|
184
|
+
const allSessions = (await listSessions(sql, payload.org_id)).filter((s) => s.project !== "_system");
|
|
185
|
+
const promptCounts = await getSessionPromptCounts(sql, payload.org_id);
|
|
186
|
+
|
|
187
|
+
function buildSessionFixture(s: typeof allSessions[0]): import("./fixtures").SessionFixture {
|
|
188
|
+
return {
|
|
189
|
+
name: s.name,
|
|
190
|
+
project: s.project,
|
|
191
|
+
driver: s.driver ?? "",
|
|
192
|
+
role: (s.driver === payload.participant_id ? "driver" : "advisor") as "driver" | "advisor",
|
|
193
|
+
description: "",
|
|
194
|
+
participants: s.driver ? [{ id: s.driver, role: "driver" as const }] : [],
|
|
195
|
+
eventCount: promptCounts.get(`${s.project}/${s.name}`) ?? 0,
|
|
196
|
+
connectedSince: s.created_at,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const sessionFixtures = allSessions.map(buildSessionFixture);
|
|
201
|
+
|
|
202
|
+
const projectFixtures: import("./fixtures").ProjectFixture[] = projects.map((p) => ({
|
|
203
|
+
name: p.name,
|
|
204
|
+
slackChannel: p.slack_channel_name ? `#${p.slack_channel_name}` : "",
|
|
205
|
+
sessions: allSessions.filter((s) => s.project === p.name).map(buildSessionFixture),
|
|
206
|
+
}));
|
|
207
|
+
|
|
208
|
+
const hasConnectedSession = allSessions.length > 0;
|
|
209
|
+
|
|
178
210
|
const ctx = {
|
|
179
211
|
token,
|
|
180
212
|
userName: payload.name,
|
|
@@ -183,14 +215,12 @@ export function createApp(sql: Sql) {
|
|
|
183
215
|
email: payload.email,
|
|
184
216
|
slackConnected: !!org.slack_team_id,
|
|
185
217
|
cliInstalled,
|
|
186
|
-
hasConnectedSession
|
|
218
|
+
hasConnectedSession,
|
|
219
|
+
totalPrompts: Array.from(promptCounts.values()).reduce((a, b) => a + b, 0),
|
|
187
220
|
};
|
|
188
221
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (activeSessions.length > 0) {
|
|
193
|
-
return layout(renderActiveView(ctx, [], [], devices), "Polaris");
|
|
222
|
+
if (hasConnectedSession) {
|
|
223
|
+
return layout(renderActiveView(ctx, sessionFixtures, projectFixtures, devices), "Polaris");
|
|
194
224
|
}
|
|
195
225
|
return layout(renderSetupView(ctx, devices), "Polaris");
|
|
196
226
|
});
|
|
@@ -227,10 +257,10 @@ export function createApp(sql: Sql) {
|
|
|
227
257
|
const mockToken = "preview-token";
|
|
228
258
|
const base = { token: mockToken, userName: mockUser.name, orgName: mockOrg.name, orgSlug: "lightup-data" as string | null, email: mockUser.email };
|
|
229
259
|
|
|
230
|
-
const fresh = { ...base, orgSlug: null, slackConnected: false, cliInstalled: false, hasConnectedSession: false };
|
|
231
|
-
const slackDone = { ...base, slackConnected: true, cliInstalled: false, hasConnectedSession: false };
|
|
232
|
-
const cliDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: false };
|
|
233
|
-
const allDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: true };
|
|
260
|
+
const fresh = { ...base, orgSlug: null, slackConnected: false, cliInstalled: false, hasConnectedSession: false, totalPrompts: 0 };
|
|
261
|
+
const slackDone = { ...base, slackConnected: true, cliInstalled: false, hasConnectedSession: false, totalPrompts: 0 };
|
|
262
|
+
const cliDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: false, totalPrompts: 0 };
|
|
263
|
+
const allDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: true, totalPrompts: 127 };
|
|
234
264
|
|
|
235
265
|
return layout(`
|
|
236
266
|
<div class="max-w-5xl mx-auto px-6 py-12">
|
package/src/web/layout.ts
CHANGED
|
@@ -27,8 +27,22 @@ tailwind.config = {
|
|
|
27
27
|
}
|
|
28
28
|
</script>
|
|
29
29
|
</head>
|
|
30
|
-
<body class="bg-gray-50 text-gray-900 antialiased">${body}
|
|
31
|
-
|
|
30
|
+
<body class="bg-gray-50 text-gray-900 antialiased">${body}
|
|
31
|
+
<script>
|
|
32
|
+
document.addEventListener('click', function(e) {
|
|
33
|
+
const btn = e.target.closest('.polaris-copy');
|
|
34
|
+
if (!btn) return;
|
|
35
|
+
const id = btn.dataset.copy;
|
|
36
|
+
const el = document.getElementById(id);
|
|
37
|
+
if (!el) return;
|
|
38
|
+
navigator.clipboard.writeText(el.textContent);
|
|
39
|
+
const orig = btn.innerHTML;
|
|
40
|
+
btn.innerHTML = '<svg class="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>';
|
|
41
|
+
setTimeout(function() { btn.innerHTML = orig; }, 1500);
|
|
42
|
+
});
|
|
43
|
+
</script>
|
|
44
|
+
</body></html>`,
|
|
45
|
+
{ headers: { "Content-Type": "text/html", "Cache-Control": "no-store" } }
|
|
32
46
|
);
|
|
33
47
|
}
|
|
34
48
|
|