@lightupai/polaris 0.0.5 → 0.0.7
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 +29 -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
package/src/service/db.ts
CHANGED
|
@@ -54,24 +54,47 @@ export async function createDb(connectionString?: string): Promise<Sql> {
|
|
|
54
54
|
)
|
|
55
55
|
`;
|
|
56
56
|
|
|
57
|
+
// Migrate: if projects table exists without `id` column, drop and recreate
|
|
58
|
+
const [{ exists: hasId }] = await sql`
|
|
59
|
+
SELECT EXISTS (
|
|
60
|
+
SELECT 1 FROM information_schema.columns
|
|
61
|
+
WHERE table_name = 'projects' AND column_name = 'id'
|
|
62
|
+
) as exists
|
|
63
|
+
`;
|
|
64
|
+
if (!hasId) {
|
|
65
|
+
// Check if the old table exists at all
|
|
66
|
+
const [{ exists: hasTable }] = await sql`
|
|
67
|
+
SELECT EXISTS (
|
|
68
|
+
SELECT 1 FROM information_schema.tables WHERE table_name = 'projects'
|
|
69
|
+
) as exists
|
|
70
|
+
`;
|
|
71
|
+
if (hasTable) {
|
|
72
|
+
await sql`DROP TABLE IF EXISTS events`;
|
|
73
|
+
await sql`DROP TABLE IF EXISTS sessions`;
|
|
74
|
+
await sql`DROP TABLE IF EXISTS projects`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
57
78
|
await sql`
|
|
58
79
|
CREATE TABLE IF NOT EXISTS projects (
|
|
59
|
-
|
|
80
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
60
81
|
org_id TEXT NOT NULL REFERENCES orgs(id),
|
|
82
|
+
name TEXT NOT NULL,
|
|
83
|
+
slack_channel_id TEXT,
|
|
84
|
+
slack_channel_name TEXT,
|
|
61
85
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
62
|
-
|
|
86
|
+
UNIQUE (org_id, name)
|
|
63
87
|
)
|
|
64
88
|
`;
|
|
65
89
|
|
|
66
90
|
await sql`
|
|
67
91
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
68
92
|
name TEXT NOT NULL,
|
|
69
|
-
|
|
93
|
+
project_id UUID NOT NULL REFERENCES projects(id),
|
|
70
94
|
org_id TEXT NOT NULL,
|
|
71
95
|
driver TEXT,
|
|
72
96
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
73
|
-
PRIMARY KEY (
|
|
74
|
-
FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name)
|
|
97
|
+
PRIMARY KEY (project_id, name)
|
|
75
98
|
)
|
|
76
99
|
`;
|
|
77
100
|
|
|
@@ -79,7 +102,7 @@ export async function createDb(connectionString?: string): Promise<Sql> {
|
|
|
79
102
|
CREATE TABLE IF NOT EXISTS events (
|
|
80
103
|
id UUID PRIMARY KEY,
|
|
81
104
|
org_id TEXT NOT NULL,
|
|
82
|
-
|
|
105
|
+
project_id UUID NOT NULL REFERENCES projects(id),
|
|
83
106
|
session TEXT NOT NULL,
|
|
84
107
|
timestamp TIMESTAMPTZ NOT NULL,
|
|
85
108
|
source TEXT NOT NULL,
|
|
@@ -89,11 +112,11 @@ export async function createDb(connectionString?: string): Promise<Sql> {
|
|
|
89
112
|
`;
|
|
90
113
|
|
|
91
114
|
await sql`
|
|
92
|
-
CREATE INDEX IF NOT EXISTS idx_events_project ON events(
|
|
115
|
+
CREATE INDEX IF NOT EXISTS idx_events_project ON events(project_id, timestamp)
|
|
93
116
|
`;
|
|
94
117
|
|
|
95
118
|
await sql`
|
|
96
|
-
CREATE INDEX IF NOT EXISTS idx_events_session ON events(
|
|
119
|
+
CREATE INDEX IF NOT EXISTS idx_events_session ON events(project_id, session, timestamp)
|
|
97
120
|
`;
|
|
98
121
|
|
|
99
122
|
return sql;
|
|
@@ -170,17 +193,59 @@ export async function upsertUser(sql: Sql, id: string, email: string, name: stri
|
|
|
170
193
|
|
|
171
194
|
export async function createProject(sql: Sql, orgId: string, name: string): Promise<Project> {
|
|
172
195
|
const [row] = await sql`
|
|
173
|
-
INSERT INTO projects (
|
|
196
|
+
INSERT INTO projects (org_id, name) VALUES (${orgId}, ${name})
|
|
197
|
+
RETURNING id, name, slack_channel_id, slack_channel_name, created_at
|
|
198
|
+
`;
|
|
199
|
+
return {
|
|
200
|
+
id: row.id,
|
|
201
|
+
name: row.name,
|
|
202
|
+
slack_channel_id: row.slack_channel_id ?? null,
|
|
203
|
+
slack_channel_name: row.slack_channel_name ?? null,
|
|
204
|
+
created_at: row.created_at.toISOString(),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function renameProject(sql: Sql, orgId: string, oldName: string, newName: string): Promise<void> {
|
|
209
|
+
await sql`
|
|
210
|
+
UPDATE projects SET name = ${newName}, slack_channel_name = ${newName}
|
|
211
|
+
WHERE org_id = ${orgId} AND name = ${oldName}
|
|
212
|
+
`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function setProjectSlackChannel(sql: Sql, orgId: string, projectName: string, channelId: string, channelName?: string): Promise<void> {
|
|
216
|
+
await sql`
|
|
217
|
+
UPDATE projects SET slack_channel_id = ${channelId}, slack_channel_name = ${channelName ?? null}
|
|
218
|
+
WHERE org_id = ${orgId} AND name = ${projectName}
|
|
219
|
+
`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function listProjects(sql: Sql, orgId: string): Promise<Project[]> {
|
|
223
|
+
const rows = await sql`
|
|
224
|
+
SELECT id, name, slack_channel_id, slack_channel_name, created_at
|
|
225
|
+
FROM projects WHERE org_id = ${orgId} ORDER BY created_at ASC
|
|
174
226
|
`;
|
|
175
|
-
return
|
|
227
|
+
return rows.map((r) => ({
|
|
228
|
+
id: r.id,
|
|
229
|
+
name: r.name,
|
|
230
|
+
slack_channel_id: r.slack_channel_id ?? null,
|
|
231
|
+
slack_channel_name: r.slack_channel_name ?? null,
|
|
232
|
+
created_at: r.created_at.toISOString(),
|
|
233
|
+
}));
|
|
176
234
|
}
|
|
177
235
|
|
|
178
236
|
export async function getProject(sql: Sql, orgId: string, name: string): Promise<Project | null> {
|
|
179
237
|
const [row] = await sql`
|
|
180
|
-
SELECT name,
|
|
238
|
+
SELECT id, name, slack_channel_id, slack_channel_name, created_at
|
|
239
|
+
FROM projects WHERE org_id = ${orgId} AND name = ${name}
|
|
181
240
|
`;
|
|
182
241
|
if (!row) return null;
|
|
183
|
-
return {
|
|
242
|
+
return {
|
|
243
|
+
id: row.id,
|
|
244
|
+
name: row.name,
|
|
245
|
+
slack_channel_id: row.slack_channel_id ?? null,
|
|
246
|
+
slack_channel_name: row.slack_channel_name ?? null,
|
|
247
|
+
created_at: row.created_at.toISOString(),
|
|
248
|
+
};
|
|
184
249
|
}
|
|
185
250
|
|
|
186
251
|
// --- Sessions (org-scoped) ---
|
|
@@ -193,13 +258,15 @@ export async function createSession(
|
|
|
193
258
|
driver: ParticipantId | null
|
|
194
259
|
): Promise<Session> {
|
|
195
260
|
const [row] = await sql`
|
|
196
|
-
INSERT INTO sessions (name,
|
|
197
|
-
|
|
198
|
-
|
|
261
|
+
INSERT INTO sessions (name, project_id, org_id, driver)
|
|
262
|
+
SELECT ${name}, p.id, ${orgId}, ${driver}
|
|
263
|
+
FROM projects p WHERE p.org_id = ${orgId} AND p.name = ${project}
|
|
264
|
+
RETURNING name, driver, created_at
|
|
199
265
|
`;
|
|
266
|
+
if (!row) throw new Error(`Project not found: ${project}`);
|
|
200
267
|
return {
|
|
201
268
|
name: row.name,
|
|
202
|
-
project
|
|
269
|
+
project,
|
|
203
270
|
driver: row.driver,
|
|
204
271
|
created_at: row.created_at.toISOString(),
|
|
205
272
|
};
|
|
@@ -207,8 +274,10 @@ export async function createSession(
|
|
|
207
274
|
|
|
208
275
|
export async function getSession(sql: Sql, orgId: string, project: string, name: string): Promise<Session | null> {
|
|
209
276
|
const [row] = await sql`
|
|
210
|
-
SELECT name, project, driver, created_at
|
|
211
|
-
|
|
277
|
+
SELECT s.name, p.name as project, s.driver, s.created_at
|
|
278
|
+
FROM sessions s
|
|
279
|
+
JOIN projects p ON s.project_id = p.id
|
|
280
|
+
WHERE s.org_id = ${orgId} AND p.name = ${project} AND s.name = ${name}
|
|
212
281
|
`;
|
|
213
282
|
if (!row) return null;
|
|
214
283
|
return {
|
|
@@ -219,17 +288,58 @@ export async function getSession(sql: Sql, orgId: string, project: string, name:
|
|
|
219
288
|
};
|
|
220
289
|
}
|
|
221
290
|
|
|
291
|
+
export async function listSessions(sql: Sql, orgId: string, project?: string): Promise<Session[]> {
|
|
292
|
+
const rows = project
|
|
293
|
+
? await sql`
|
|
294
|
+
SELECT s.name, p.name as project, s.driver, s.created_at
|
|
295
|
+
FROM sessions s
|
|
296
|
+
JOIN projects p ON s.project_id = p.id
|
|
297
|
+
WHERE s.org_id = ${orgId} AND p.name = ${project}
|
|
298
|
+
ORDER BY s.created_at ASC
|
|
299
|
+
`
|
|
300
|
+
: await sql`
|
|
301
|
+
SELECT s.name, p.name as project, s.driver, s.created_at
|
|
302
|
+
FROM sessions s
|
|
303
|
+
JOIN projects p ON s.project_id = p.id
|
|
304
|
+
WHERE s.org_id = ${orgId}
|
|
305
|
+
ORDER BY s.created_at ASC
|
|
306
|
+
`;
|
|
307
|
+
return rows.map((row) => ({
|
|
308
|
+
name: row.name,
|
|
309
|
+
project: row.project,
|
|
310
|
+
driver: row.driver,
|
|
311
|
+
created_at: row.created_at.toISOString(),
|
|
312
|
+
}));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function getSessionPromptCounts(sql: Sql, orgId: string): Promise<Map<string, number>> {
|
|
316
|
+
const rows = await sql`
|
|
317
|
+
SELECT p.name as project, e.session, count(*)::int as count
|
|
318
|
+
FROM events e
|
|
319
|
+
JOIN projects p ON e.project_id = p.id
|
|
320
|
+
WHERE e.org_id = ${orgId} AND e.payload->>'hook_event_name' = 'UserPromptSubmit'
|
|
321
|
+
GROUP BY p.name, e.session
|
|
322
|
+
`;
|
|
323
|
+
const counts = new Map<string, number>();
|
|
324
|
+
for (const row of rows) {
|
|
325
|
+
counts.set(`${row.project}/${row.session}`, row.count);
|
|
326
|
+
}
|
|
327
|
+
return counts;
|
|
328
|
+
}
|
|
329
|
+
|
|
222
330
|
export async function setDriver(sql: Sql, orgId: string, project: string, session: string, driver: ParticipantId): Promise<void> {
|
|
223
331
|
await sql`
|
|
224
332
|
UPDATE sessions SET driver = ${driver}
|
|
225
|
-
WHERE org_id = ${orgId} AND
|
|
333
|
+
WHERE org_id = ${orgId} AND name = ${session}
|
|
334
|
+
AND project_id = (SELECT id FROM projects WHERE org_id = ${orgId} AND name = ${project})
|
|
226
335
|
`;
|
|
227
336
|
}
|
|
228
337
|
|
|
229
338
|
export async function clearDriver(sql: Sql, orgId: string, project: string, session: string): Promise<void> {
|
|
230
339
|
await sql`
|
|
231
340
|
UPDATE sessions SET driver = NULL
|
|
232
|
-
WHERE org_id = ${orgId} AND
|
|
341
|
+
WHERE org_id = ${orgId} AND name = ${session}
|
|
342
|
+
AND project_id = (SELECT id FROM projects WHERE org_id = ${orgId} AND name = ${project})
|
|
233
343
|
`;
|
|
234
344
|
}
|
|
235
345
|
|
|
@@ -237,8 +347,9 @@ export async function clearDriver(sql: Sql, orgId: string, project: string, sess
|
|
|
237
347
|
|
|
238
348
|
export async function pushEvent(sql: Sql, orgId: string, event: PolarisEvent): Promise<void> {
|
|
239
349
|
await sql`
|
|
240
|
-
INSERT INTO events (id, org_id,
|
|
241
|
-
|
|
350
|
+
INSERT INTO events (id, org_id, project_id, session, timestamp, source, sender, payload)
|
|
351
|
+
SELECT ${event.id}, ${orgId}, p.id, ${event.session}, ${event.timestamp}, ${event.source}, ${event.sender}, ${sql.json(event.payload)}
|
|
352
|
+
FROM projects p WHERE p.org_id = ${orgId} AND p.name = ${event.project}
|
|
242
353
|
`;
|
|
243
354
|
}
|
|
244
355
|
|
|
@@ -264,24 +375,44 @@ function rowToEvent(row: {
|
|
|
264
375
|
|
|
265
376
|
export async function getProjectEvents(sql: Sql, orgId: string, project: string): Promise<PolarisEvent[]> {
|
|
266
377
|
const rows = await sql`
|
|
267
|
-
SELECT id, project, session, timestamp, source, sender, payload
|
|
268
|
-
FROM events
|
|
378
|
+
SELECT e.id, p.name as project, e.session, e.timestamp, e.source, e.sender, e.payload
|
|
379
|
+
FROM events e
|
|
380
|
+
JOIN projects p ON e.project_id = p.id
|
|
381
|
+
WHERE e.org_id = ${orgId} AND p.name = ${project}
|
|
382
|
+
ORDER BY e.timestamp ASC
|
|
269
383
|
`;
|
|
270
384
|
return rows.map(rowToEvent);
|
|
271
385
|
}
|
|
272
386
|
|
|
273
387
|
export async function getSessionEvents(sql: Sql, orgId: string, project: string, session: string): Promise<PolarisEvent[]> {
|
|
274
388
|
const rows = await sql`
|
|
275
|
-
SELECT id, project, session, timestamp, source, sender, payload
|
|
276
|
-
FROM events
|
|
389
|
+
SELECT e.id, p.name as project, e.session, e.timestamp, e.source, e.sender, e.payload
|
|
390
|
+
FROM events e
|
|
391
|
+
JOIN projects p ON e.project_id = p.id
|
|
392
|
+
WHERE e.org_id = ${orgId} AND p.name = ${project} AND e.session = ${session}
|
|
393
|
+
ORDER BY e.timestamp ASC
|
|
394
|
+
`;
|
|
395
|
+
return rows.map(rowToEvent);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export async function getOrgEventsSince(sql: Sql, orgId: string, since: string): Promise<PolarisEvent[]> {
|
|
399
|
+
const rows = await sql`
|
|
400
|
+
SELECT e.id, p.name as project, e.session, e.timestamp, e.source, e.sender, e.payload
|
|
401
|
+
FROM events e
|
|
402
|
+
JOIN projects p ON e.project_id = p.id
|
|
403
|
+
WHERE e.org_id = ${orgId} AND e.timestamp > ${since}
|
|
404
|
+
ORDER BY e.timestamp ASC
|
|
277
405
|
`;
|
|
278
406
|
return rows.map(rowToEvent);
|
|
279
407
|
}
|
|
280
408
|
|
|
281
409
|
export async function getEventsSince(sql: Sql, orgId: string, project: string, since: string): Promise<PolarisEvent[]> {
|
|
282
410
|
const rows = await sql`
|
|
283
|
-
SELECT id, project, session, timestamp, source, sender, payload
|
|
284
|
-
FROM events
|
|
411
|
+
SELECT e.id, p.name as project, e.session, e.timestamp, e.source, e.sender, e.payload
|
|
412
|
+
FROM events e
|
|
413
|
+
JOIN projects p ON e.project_id = p.id
|
|
414
|
+
WHERE e.org_id = ${orgId} AND p.name = ${project} AND e.timestamp > ${since}
|
|
415
|
+
ORDER BY e.timestamp ASC
|
|
285
416
|
`;
|
|
286
417
|
return rows.map(rowToEvent);
|
|
287
418
|
}
|
package/src/service/server.ts
CHANGED
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
createOrg,
|
|
7
7
|
getOrg,
|
|
8
8
|
createProject,
|
|
9
|
+
listProjects,
|
|
9
10
|
getProject,
|
|
11
|
+
renameProject,
|
|
10
12
|
createSession,
|
|
11
13
|
getSession,
|
|
12
14
|
setDriver,
|
|
@@ -213,6 +215,11 @@ export async function startServer(opts: {
|
|
|
213
215
|
|
|
214
216
|
// --- Project endpoints ---
|
|
215
217
|
|
|
218
|
+
if (method === "GET" && pathname === "/projects") {
|
|
219
|
+
const projects = await listProjects(sql, orgId);
|
|
220
|
+
return json(projects);
|
|
221
|
+
}
|
|
222
|
+
|
|
216
223
|
params = matchRoute(method, pathname, "/projects", "POST");
|
|
217
224
|
if (params) {
|
|
218
225
|
const body = await jsonBody(req);
|
|
@@ -233,6 +240,37 @@ export async function startServer(opts: {
|
|
|
233
240
|
return json(project);
|
|
234
241
|
}
|
|
235
242
|
|
|
243
|
+
params = matchRoute(method, pathname, "/projects/:proj/rename", "POST");
|
|
244
|
+
if (params) {
|
|
245
|
+
const project = await getProject(sql, orgId, params.proj);
|
|
246
|
+
if (!project) return error("Project not found", 404);
|
|
247
|
+
const body = await req.json() as { name?: string };
|
|
248
|
+
if (!body.name) return error("name is required", 400);
|
|
249
|
+
const existing = await getProject(sql, orgId, body.name);
|
|
250
|
+
if (existing) return error("A project with that name already exists", 409);
|
|
251
|
+
await renameProject(sql, orgId, params.proj, body.name);
|
|
252
|
+
|
|
253
|
+
// Rename Slack channel if one is linked
|
|
254
|
+
if (project.slack_channel_id) {
|
|
255
|
+
try {
|
|
256
|
+
const org = await getOrg(sql, orgId);
|
|
257
|
+
if (org?.slack_bot_token) {
|
|
258
|
+
const { WebClient } = await import("@slack/web-api");
|
|
259
|
+
const web = new WebClient(org.slack_bot_token);
|
|
260
|
+
await web.conversations.rename({
|
|
261
|
+
channel: project.slack_channel_id,
|
|
262
|
+
name: body.name.toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 80),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
} catch (e) {
|
|
266
|
+
// Non-fatal — DB rename succeeded, Slack rename is best-effort
|
|
267
|
+
console.error("[server] Slack channel rename failed:", e);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return json({ status: "renamed", oldName: params.proj, newName: body.name });
|
|
272
|
+
}
|
|
273
|
+
|
|
236
274
|
params = matchRoute(method, pathname, "/projects/:proj/messages", "GET");
|
|
237
275
|
if (params) {
|
|
238
276
|
const project = await getProject(sql, orgId, params.proj);
|
|
@@ -312,6 +350,15 @@ export async function startServer(opts: {
|
|
|
312
350
|
broadcastEvent(event);
|
|
313
351
|
broadcastSse(event);
|
|
314
352
|
|
|
353
|
+
// Notify web dashboard on all events (best-effort, different process)
|
|
354
|
+
const authHeader = req.headers.get("Authorization");
|
|
355
|
+
if (authHeader) {
|
|
356
|
+
fetch(`http://${process.env.WEB_HOST ?? "localhost"}:${Number(process.env.WEB_PORT ?? 3000)}/api/notify-dashboard`, {
|
|
357
|
+
method: "POST",
|
|
358
|
+
headers: { "Content-Type": "application/json", Authorization: authHeader },
|
|
359
|
+
}).catch(() => {});
|
|
360
|
+
}
|
|
361
|
+
|
|
315
362
|
// Forward _system events to Slack
|
|
316
363
|
if (params.proj === "_system") {
|
|
317
364
|
const text = (parsed.data.payload as { stop_response?: string; prompt?: string }).stop_response
|