@lightupai/polaris 0.0.4 → 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.
@@ -7,8 +7,7 @@ import {
7
7
 
8
8
  // --- Configuration ---
9
9
 
10
- const DAEMON_URL = process.env.POLARIS_DAEMON_URL ?? "http://127.0.0.1:4321";
11
- const SERVICE_URL = process.env.POLARIS_SERVICE_URL ?? "http://localhost:4321";
10
+ const DAEMON_URL = process.env.POLARIS_DAEMON_URL ?? "http://127.0.0.1:4322";
12
11
 
13
12
  // Generate a stable session ID for this MCP server instance
14
13
  const CC_SESSION_ID = process.env.POLARIS_CC_SESSION_ID ?? crypto.randomUUID();
@@ -27,12 +26,6 @@ async function daemonGet(path: string): Promise<Response> {
27
26
  return fetch(`${DAEMON_URL}${path}`);
28
27
  }
29
28
 
30
- // --- Cloud service (direct, for context queries) ---
31
-
32
- async function serviceGet(path: string): Promise<Response> {
33
- return fetch(`${SERVICE_URL}${path}`);
34
- }
35
-
36
29
  // --- Current connection state ---
37
30
 
38
31
  let currentProject = "";
@@ -94,6 +87,17 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
94
87
  required: ["message"],
95
88
  },
96
89
  },
90
+ {
91
+ name: "polaris_rename",
92
+ description: "Rename the current project. Also renames the Slack channel.",
93
+ inputSchema: {
94
+ type: "object" as const,
95
+ properties: {
96
+ name: { type: "string", description: "New project name" },
97
+ },
98
+ required: ["name"],
99
+ },
100
+ },
97
101
  {
98
102
  name: "polaris_context",
99
103
  description: "Fetch activity from a sibling session in this project. Use this to see what other drivers have been doing.",
@@ -164,24 +168,33 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
164
168
  }
165
169
  const message = (args as { message: string }).message;
166
170
  try {
167
- const res = await fetch(`${SERVICE_URL}/projects/${currentProject}/sessions/${currentSession}/events`, {
168
- method: "POST",
169
- headers: { "Content-Type": "application/json" },
170
- body: JSON.stringify({
171
- sender: currentUser,
172
- payload: {
173
- hook_event_name: "Stop",
174
- session_id: CC_SESSION_ID,
175
- stop_response: message,
176
- },
177
- }),
178
- });
171
+ const res = await daemonPost("/reply", { ccSessionId: CC_SESSION_ID, message });
179
172
  if (res.ok) {
180
173
  return { content: [{ type: "text", text: "Reply sent to the floor." }] };
181
174
  }
182
- return { content: [{ type: "text", text: `Failed to send reply: ${res.status}` }] };
175
+ const body = await res.json();
176
+ return { content: [{ type: "text", text: `Failed to send reply: ${(body as { error?: string }).error ?? res.status}` }] };
177
+ } catch {
178
+ return { content: [{ type: "text", text: "Failed to reach the daemon." }] };
179
+ }
180
+ }
181
+
182
+ if (name === "polaris_rename") {
183
+ if (!currentProject) {
184
+ return { content: [{ type: "text", text: "Not connected to a Polaris session. Use polaris_connect first." }] };
185
+ }
186
+ const newName = (args as { name: string }).name;
187
+ try {
188
+ const res = await daemonPost("/rename", { oldName: currentProject, newName });
189
+ const body = await res.json();
190
+ if (res.ok) {
191
+ const oldName = currentProject;
192
+ currentProject = newName;
193
+ return { content: [{ type: "text", text: `Renamed project "${oldName}" to "${newName}".` }] };
194
+ }
195
+ return { content: [{ type: "text", text: `Failed to rename: ${(body as { error?: string }).error ?? "unknown error"}` }] };
183
196
  } catch {
184
- return { content: [{ type: "text", text: "Failed to reach the cloud service." }] };
197
+ return { content: [{ type: "text", text: "Failed to rename — is the Polaris daemon running?" }] };
185
198
  }
186
199
  }
187
200
 
@@ -191,7 +204,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
191
204
  }
192
205
  const targetSession = (args as { session: string }).session;
193
206
  try {
194
- const res = await serviceGet(`/projects/${currentProject}/sessions/${targetSession}/messages`);
207
+ const res = await daemonGet(`/context/${CC_SESSION_ID}/${targetSession}`);
195
208
  if (!res.ok) {
196
209
  return { content: [{ type: "text", text: `Could not fetch session "${targetSession}": ${res.status}` }] };
197
210
  }
@@ -208,7 +221,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
208
221
  .join("\n");
209
222
  return { content: [{ type: "text", text: summary || "(no activity yet)" }] };
210
223
  } catch {
211
- return { content: [{ type: "text", text: "Failed to reach the cloud service." }] };
224
+ return { content: [{ type: "text", text: "Failed to reach the daemon." }] };
212
225
  }
213
226
  }
214
227
 
@@ -1,4 +1,7 @@
1
1
  import type { Server, ServerWebSocket } from "bun";
2
+ import { readFile, appendFile, mkdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
2
5
 
3
6
  // --- Session registry ---
4
7
 
@@ -7,6 +10,7 @@ interface SessionMapping {
7
10
  project: string;
8
11
  session: string;
9
12
  user: string;
13
+ slackChannel?: string;
10
14
  ws: WebSocket | null;
11
15
  }
12
16
 
@@ -15,8 +19,67 @@ const sessions = new Map<string, SessionMapping>(); // keyed by ccSessionId
15
19
  // IPC callbacks for MCP servers to receive advisor messages
16
20
  const mcpCallbacks = new Map<string, (event: unknown) => void>(); // keyed by ccSessionId
17
21
 
22
+ // --- Config resolution (env var > config.json > legacy credentials.json > defaults) ---
23
+
24
+ interface PolarisConfig {
25
+ active: string;
26
+ profiles: Record<string, { api: string; token: string; [key: string]: unknown }>;
27
+ }
28
+
29
+ let cachedConfig: PolarisConfig | null | undefined = undefined;
30
+ async function loadConfig(): Promise<PolarisConfig | null> {
31
+ if (cachedConfig !== undefined) return cachedConfig;
32
+ try {
33
+ const configPath = join(homedir(), ".polaris", "config.json");
34
+ cachedConfig = JSON.parse(await readFile(configPath, "utf-8"));
35
+ return cachedConfig;
36
+ } catch {
37
+ cachedConfig = null;
38
+ return null;
39
+ }
40
+ }
41
+
18
42
  function getServiceUrl(): string {
19
- return process.env.POLARIS_SERVICE_URL ?? "http://localhost:4321";
43
+ // 1. Env var override (Makefile uses this for local dev)
44
+ if (process.env.POLARIS_SERVICE_URL) return process.env.POLARIS_SERVICE_URL;
45
+ // 2. Active profile (read synchronously from cache — loaded at startup)
46
+ if (cachedConfig?.active && cachedConfig.profiles[cachedConfig.active]) {
47
+ return cachedConfig.profiles[cachedConfig.active].api;
48
+ }
49
+ // 3. Fallback
50
+ return "https://api.polaris.lightup.ai";
51
+ }
52
+
53
+ let cachedToken: string | null | undefined = undefined;
54
+ async function getAuthToken(): Promise<string | null> {
55
+ if (cachedToken !== undefined) return cachedToken;
56
+ // 1. Env var (for testing). Empty string means "no auth".
57
+ if (process.env.POLARIS_AUTH_TOKEN !== undefined) {
58
+ cachedToken = process.env.POLARIS_AUTH_TOKEN || null;
59
+ return cachedToken;
60
+ }
61
+ // 2. Active profile in config.json
62
+ const config = await loadConfig();
63
+ if (config?.active && config.profiles[config.active]?.token) {
64
+ cachedToken = config.profiles[config.active].token;
65
+ return cachedToken;
66
+ }
67
+ // 3. Legacy credentials.json
68
+ try {
69
+ const credsPath = join(homedir(), ".polaris", "credentials.json");
70
+ const creds = JSON.parse(await readFile(credsPath, "utf-8"));
71
+ cachedToken = creds.token ?? null;
72
+ return cachedToken;
73
+ } catch {
74
+ cachedToken = null;
75
+ return null;
76
+ }
77
+ }
78
+
79
+ async function authHeaders(): Promise<Record<string, string>> {
80
+ const token = await getAuthToken();
81
+ if (token) return { "Content-Type": "application/json", Authorization: `Bearer ${token}` };
82
+ return { "Content-Type": "application/json" };
20
83
  }
21
84
 
22
85
  // --- Cloud WebSocket management ---
@@ -65,6 +128,26 @@ function disconnectCloudWs(ccSessionId: string) {
65
128
  }
66
129
  }
67
130
 
131
+ // --- Local event log (JSONL) for manual recovery ---
132
+
133
+ const LOG_DIR = join(homedir(), ".polaris", "logs");
134
+ let logReady: Promise<void> | null = null;
135
+
136
+ function ensureLogDir(): Promise<void> {
137
+ if (!logReady) logReady = mkdir(LOG_DIR, { recursive: true }).then(() => {});
138
+ return logReady;
139
+ }
140
+
141
+ async function logEvent(endpoint: string, payload: unknown, response?: { status: number; body?: unknown }): Promise<void> {
142
+ try {
143
+ await ensureLogDir();
144
+ const entry: Record<string, unknown> = { t: new Date().toISOString(), endpoint, payload };
145
+ if (response) entry.response = response;
146
+ const file = join(LOG_DIR, `daemon-${new Date().toISOString().slice(0, 10)}.jsonl`);
147
+ await appendFile(file, JSON.stringify(entry) + "\n");
148
+ } catch { /* best-effort — don't break the request */ }
149
+ }
150
+
68
151
  // --- HTTP Server ---
69
152
 
70
153
  function json(data: unknown, status = 200): Response {
@@ -123,6 +206,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
123
206
  session: string;
124
207
  user: string;
125
208
  };
209
+ await logEvent("/connect", body);
126
210
  if (!body.ccSessionId || !body.project || !body.session || !body.user) {
127
211
  return error("ccSessionId, project, session, and user are required", 400);
128
212
  }
@@ -143,18 +227,19 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
143
227
  const serviceUrl = getServiceUrl();
144
228
  await fetch(`${serviceUrl}/projects`, {
145
229
  method: "POST",
146
- headers: { "Content-Type": "application/json" },
230
+ headers: await authHeaders(),
147
231
  body: JSON.stringify({ name: body.project }),
148
232
  }); // Ignore 409 (already exists)
149
233
 
150
234
  // Ensure the session exists (create if not, claim driver)
151
235
  const sessionRes = await fetch(`${serviceUrl}/projects/${body.project}/sessions`, {
152
236
  method: "POST",
153
- headers: { "Content-Type": "application/json" },
237
+ headers: await authHeaders(),
154
238
  body: JSON.stringify({ name: body.session, driver: body.user }),
155
239
  });
156
240
  if (!sessionRes.ok && sessionRes.status !== 409) {
157
241
  const err = await sessionRes.text();
242
+ await logEvent("/connect", body, { status: sessionRes.status, body: err });
158
243
  return error(`Failed to create session: ${err}`, 500);
159
244
  }
160
245
 
@@ -162,11 +247,22 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
162
247
  if (sessionRes.status === 409) {
163
248
  await fetch(`${serviceUrl}/projects/${body.project}/sessions/${body.session}/driver`, {
164
249
  method: "POST",
165
- headers: { "Content-Type": "application/json" },
250
+ headers: await authHeaders(),
166
251
  body: JSON.stringify({ driver: body.user }),
167
252
  }); // Ignore errors (might already be driver)
168
253
  }
169
254
 
255
+ // Fetch Slack channel name for status display
256
+ try {
257
+ const projRes = await fetch(`${serviceUrl}/projects/${body.project}`, {
258
+ headers: await authHeaders(),
259
+ });
260
+ if (projRes.ok) {
261
+ const projData = await projRes.json() as { slack_channel_name?: string };
262
+ mapping.slackChannel = projData.slack_channel_name ?? undefined;
263
+ }
264
+ } catch {}
265
+
170
266
  // Connect to cloud WebSocket
171
267
  connectCloudWs(mapping);
172
268
 
@@ -199,17 +295,42 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
199
295
  }
200
296
  }
201
297
 
298
+ // POST /disconnect-all — disconnect all sessions (for testing)
299
+ if (method === "POST" && pathname === "/disconnect-all") {
300
+ for (const [id, mapping] of sessions) {
301
+ disconnectCloudWs(id);
302
+ mapping.project = "";
303
+ mapping.session = "";
304
+ mapping.user = "";
305
+ }
306
+ sessions.clear();
307
+ return json({ status: "all_disconnected" });
308
+ }
309
+
202
310
  // POST /events — hook events arrive here, routed by session_id in the payload
203
311
  if (method === "POST" && pathname === "/events") {
204
312
  try {
205
313
  const body = (await req.json()) as { session_id?: string; [key: string]: unknown };
314
+ await logEvent("/events", body);
206
315
  const ccSessionId = body.session_id;
207
316
  if (!ccSessionId) return error("session_id required in hook payload", 400);
208
317
 
209
- const mapping = sessions.get(ccSessionId);
318
+ let mapping = sessions.get(ccSessionId);
210
319
  if (!mapping || !mapping.project) {
211
- // Session not connected to polaris silently discard
212
- return json({ status: "not_connected" });
320
+ // CC session_id doesn't match any registered MCP client.
321
+ // Try to find a connected session to route to (the MCP client
322
+ // generates its own UUID, which differs from CC's session_id).
323
+ const connectedSessions = Array.from(sessions.values()).filter((m) => m.project);
324
+ if (connectedSessions.length === 1) {
325
+ // Only one active session — route to it and remember the mapping
326
+ mapping = connectedSessions[0];
327
+ sessions.set(ccSessionId, { ...mapping, ccSessionId, slackChannel: undefined });
328
+ } else if (connectedSessions.length > 1) {
329
+ // Multiple sessions — can't determine which one. Discard.
330
+ return json({ status: "ambiguous" });
331
+ } else {
332
+ return json({ status: "not_connected" });
333
+ }
213
334
  }
214
335
 
215
336
  // Relay to cloud service
@@ -218,13 +339,14 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
218
339
  `${serviceUrl}/projects/${mapping.project}/sessions/${mapping.session}/events`,
219
340
  {
220
341
  method: "POST",
221
- headers: { "Content-Type": "application/json" },
342
+ headers: await authHeaders(),
222
343
  body: JSON.stringify({ sender: mapping.user, payload: body }),
223
344
  }
224
345
  );
225
346
 
226
347
  if (!res.ok) {
227
348
  const err = await res.text();
349
+ await logEvent("/events", body, { status: res.status, body: err });
228
350
  return new Response(err, { status: res.status });
229
351
  }
230
352
  return json({ status: "relayed" });
@@ -233,6 +355,55 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
233
355
  }
234
356
  }
235
357
 
358
+ // POST /rename — rename a project (proxies to cloud API, updates local state)
359
+ if (method === "POST" && pathname === "/rename") {
360
+ try {
361
+ const body = (await req.json()) as { oldName: string; newName: string };
362
+ if (!body.oldName || !body.newName) return error("oldName and newName required", 400);
363
+
364
+ // Call cloud API to rename in DB
365
+ const serviceUrl = getServiceUrl();
366
+ const res = await fetch(`${serviceUrl}/projects/${body.oldName}/rename`, {
367
+ method: "POST",
368
+ headers: await authHeaders(),
369
+ body: JSON.stringify({ name: body.newName }),
370
+ });
371
+ if (!res.ok) {
372
+ const err = await res.text();
373
+ return new Response(err, { status: res.status });
374
+ }
375
+
376
+ // Update in-memory sessions
377
+ for (const m of sessions.values()) {
378
+ if (m.project === body.oldName) {
379
+ m.project = body.newName;
380
+ m.slackChannel = body.newName;
381
+ }
382
+ }
383
+
384
+ return json({ status: "renamed", oldName: body.oldName, newName: body.newName });
385
+ } catch {
386
+ return error("Invalid JSON", 400);
387
+ }
388
+ }
389
+
390
+ // POST /channel-update — bridge pushes channel rename notifications
391
+ if (method === "POST" && pathname === "/channel-update") {
392
+ try {
393
+ const body = (await req.json()) as { project: string; slackChannel: string };
394
+ if (!body.project || !body.slackChannel) return error("project and slackChannel required", 400);
395
+ // Update all sessions for this project
396
+ for (const m of sessions.values()) {
397
+ if (m.project === body.project) {
398
+ m.slackChannel = body.slackChannel;
399
+ }
400
+ }
401
+ return json({ status: "updated" });
402
+ } catch {
403
+ return error("Invalid JSON", 400);
404
+ }
405
+ }
406
+
236
407
  // GET /status/:ccSessionId — status line queries this
237
408
  if (method === "GET" && pathname.startsWith("/status/")) {
238
409
  const ccSessionId = pathname.slice("/status/".length);
@@ -240,11 +411,22 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
240
411
  if (!mapping || !mapping.project) {
241
412
  return json({ connected: false });
242
413
  }
414
+ // Resolve slackChannel from any session in the same project
415
+ let slackChannel = mapping.slackChannel ?? null;
416
+ if (!slackChannel) {
417
+ for (const m of sessions.values()) {
418
+ if (m.project === mapping.project && m.slackChannel) {
419
+ slackChannel = m.slackChannel;
420
+ break;
421
+ }
422
+ }
423
+ }
243
424
  return json({
244
425
  connected: true,
245
426
  project: mapping.project,
246
427
  session: mapping.session,
247
428
  user: mapping.user,
429
+ slackChannel,
248
430
  });
249
431
  }
250
432
 
@@ -261,6 +443,63 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
261
443
  return json({ ok: true, version: "0.0.1", sessions: active });
262
444
  }
263
445
 
446
+ // POST /reply — proxy a reply event to the cloud API
447
+ if (method === "POST" && pathname === "/reply") {
448
+ try {
449
+ const body = (await req.json()) as { ccSessionId: string; message: string };
450
+ await logEvent("/reply", body);
451
+ if (!body.ccSessionId || !body.message) return error("ccSessionId and message required", 400);
452
+ const mapping = sessions.get(body.ccSessionId);
453
+ if (!mapping || !mapping.project) return error("Not connected", 400);
454
+
455
+ const serviceUrl = getServiceUrl();
456
+ const res = await fetch(
457
+ `${serviceUrl}/projects/${mapping.project}/sessions/${mapping.session}/events`,
458
+ {
459
+ method: "POST",
460
+ headers: await authHeaders(),
461
+ body: JSON.stringify({
462
+ sender: mapping.user,
463
+ payload: {
464
+ hook_event_name: "Stop",
465
+ session_id: body.ccSessionId,
466
+ stop_response: body.message,
467
+ },
468
+ }),
469
+ }
470
+ );
471
+ if (!res.ok) {
472
+ const err = await res.text();
473
+ await logEvent("/reply", body, { status: res.status, body: err });
474
+ return new Response(err, { status: res.status });
475
+ }
476
+ return json({ status: "sent" });
477
+ } catch {
478
+ return error("Invalid JSON", 400);
479
+ }
480
+ }
481
+
482
+ // GET /context/:ccSessionId/:session — proxy context fetch from cloud API
483
+ if (method === "GET" && pathname.match(/^\/context\/[^/]+\/[^/]+$/)) {
484
+ const parts = pathname.split("/");
485
+ const ccSessionId = parts[2];
486
+ const targetSession = parts[3];
487
+ const mapping = sessions.get(ccSessionId);
488
+ if (!mapping || !mapping.project) return error("Not connected", 400);
489
+
490
+ const serviceUrl = getServiceUrl();
491
+ const res = await fetch(
492
+ `${serviceUrl}/projects/${mapping.project}/sessions/${targetSession}/messages`,
493
+ { headers: await authHeaders() }
494
+ );
495
+ if (!res.ok) {
496
+ const err = await res.text();
497
+ return new Response(err, { status: res.status });
498
+ }
499
+ const data = await res.json();
500
+ return json(data);
501
+ }
502
+
264
503
  return error("Not found", 404);
265
504
  },
266
505
  });
@@ -270,6 +509,9 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
270
509
 
271
510
  // --- Run if executed directly ---
272
511
  if (import.meta.main) {
512
+ // Load config before starting so getServiceUrl() has the active profile
513
+ await loadConfig();
273
514
  const { server } = startDaemon();
274
515
  console.error(`Polaris daemon listening on http://127.0.0.1:${server.port}`);
516
+ console.error(` API endpoint: ${getServiceUrl()}`);
275
517
  }