@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.
@@ -0,0 +1,85 @@
1
+ services:
2
+ postgres:
3
+ image: postgres:17
4
+ restart: unless-stopped
5
+ environment:
6
+ POSTGRES_USER: polaris
7
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
8
+ POSTGRES_DB: polaris
9
+ volumes:
10
+ - pgdata:/var/lib/postgresql/data
11
+ healthcheck:
12
+ test: ["CMD-SHELL", "pg_isready -U polaris"]
13
+ interval: 5s
14
+ timeout: 3s
15
+ retries: 5
16
+
17
+ api:
18
+ build:
19
+ context: .
20
+ dockerfile: docker/Dockerfile
21
+ restart: unless-stopped
22
+ command: ["bun", "run", "src/service/server.ts"]
23
+ environment:
24
+ DATABASE_URL: postgres://polaris:${POSTGRES_PASSWORD}@postgres:5432/polaris
25
+ POLARIS_JWT_SECRET: ${POLARIS_JWT_SECRET}
26
+ WEB_HOST: web
27
+ WEB_PORT: 3000
28
+ depends_on:
29
+ postgres:
30
+ condition: service_healthy
31
+
32
+ web:
33
+ build:
34
+ context: .
35
+ dockerfile: docker/Dockerfile
36
+ restart: unless-stopped
37
+ command: ["bun", "run", "src/web/serve.ts"]
38
+ environment:
39
+ DATABASE_URL: postgres://polaris:${POSTGRES_PASSWORD}@postgres:5432/polaris
40
+ POLARIS_JWT_SECRET: ${POLARIS_JWT_SECRET}
41
+ WEB_PORT: 3000
42
+ GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
43
+ GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
44
+ GOOGLE_REDIRECT_URI: https://app.polaris.lightup.ai/auth/google/callback
45
+ SLACK_CLIENT_ID: ${SLACK_CLIENT_ID}
46
+ SLACK_CLIENT_SECRET: ${SLACK_CLIENT_SECRET}
47
+ SLACK_REDIRECT_URI: https://app.polaris.lightup.ai/slack/callback
48
+ depends_on:
49
+ postgres:
50
+ condition: service_healthy
51
+
52
+ bridge:
53
+ build:
54
+ context: .
55
+ dockerfile: docker/Dockerfile
56
+ restart: unless-stopped
57
+ entrypoint: ["/bin/sh", "/app/docker/bridge-entrypoint.sh"]
58
+ environment:
59
+ DATABASE_URL: postgres://polaris:${POSTGRES_PASSWORD}@postgres:5432/polaris
60
+ SLACK_APP_TOKEN: ${SLACK_APP_TOKEN}
61
+ POLARIS_API_URL: http://api:4321
62
+ POLARIS_LONG_MSG: ${POLARIS_LONG_MSG:-snippet}
63
+ depends_on:
64
+ postgres:
65
+ condition: service_healthy
66
+
67
+ caddy:
68
+ image: caddy:2-alpine
69
+ restart: unless-stopped
70
+ ports:
71
+ - "80:80"
72
+ - "443:443"
73
+ - "443:443/udp"
74
+ volumes:
75
+ - ./docker/Caddyfile:/etc/caddy/Caddyfile:ro
76
+ - caddy_data:/data
77
+ - caddy_config:/config
78
+ depends_on:
79
+ - api
80
+ - web
81
+
82
+ volumes:
83
+ pgdata:
84
+ caddy_data:
85
+ caddy_config:
@@ -0,0 +1,99 @@
1
+ # Deploy Polaris to Hetzner Cloud VPS
2
+
3
+ ## Context
4
+
5
+ Polaris runs entirely on localhost. We need a production deployment so the API, web dashboard, Slack bridge, and Postgres all run on a Hetzner Cloud VPS with proper HTTPS. The daemon and MCP client stay local on each developer's machine — they talk to the cloud API.
6
+
7
+ Domain: `polaris.lightup.ai` (subdomains: `api.polaris.lightup.ai`, `app.polaris.lightup.ai`)
8
+
9
+ ## Architecture
10
+
11
+ **On Hetzner VPS (Docker Compose):**
12
+ - **Caddy** — reverse proxy, automatic Let's Encrypt HTTPS (ports 80/443)
13
+ - **API** — `src/service/server.ts` (port 4321 internal)
14
+ - **Web** — `src/web/serve.ts` (port 3000 internal)
15
+ - **Bridge** — `src/slack/bridge.ts` (no port, outbound only)
16
+ - **Postgres 17** — persistent volume
17
+
18
+ **Stays local (each dev machine):**
19
+ - Daemon (port 4322)
20
+ - MCP client
21
+ - Hooks
22
+
23
+ ## Files to Create
24
+
25
+ | File | Purpose |
26
+ |------|---------|
27
+ | `docker/Dockerfile` | Single Bun image, each service overrides CMD |
28
+ | `docker/Caddyfile` | Reverse proxy: app.polaris.lightup.ai → web:3000, api.polaris.lightup.ai → api:4321 |
29
+ | `docker/bridge-entrypoint.sh` | Wait for Postgres, discover org ID, start bridge |
30
+ | `src/bridge-discover-org.ts` | Tiny script: query DB for Slack-connected org ID |
31
+ | `docker-compose.prod.yml` | Full production orchestration |
32
+ | `.env.example` | Template of required env vars (no secrets) |
33
+ | `deploy.sh` | SSH deploy script: git pull + docker compose up |
34
+
35
+ ## Code Change
36
+
37
+ **`src/service/server.ts`** — The API calls `http://localhost:${WEB_PORT}/api/notify-dashboard` to push SSE updates to the web app. In Docker, `localhost` doesn't reach other containers. Change to:
38
+ ```
39
+ http://${process.env.WEB_HOST ?? "localhost"}:${WEB_PORT}/api/notify-dashboard
40
+ ```
41
+ Set `WEB_HOST=web` in the production compose. Backward compatible for local dev.
42
+
43
+ ## Docker Strategy
44
+
45
+ **Single Dockerfile**, multi-service via different `command:` overrides in compose:
46
+ ```dockerfile
47
+ FROM oven/bun:1
48
+ WORKDIR /app
49
+ COPY package.json bun.lock* ./
50
+ RUN bun install --frozen-lockfile
51
+ COPY src/ ./src/
52
+ COPY docker/bridge-entrypoint.sh ./docker/
53
+ CMD ["bun", "run", "src/service/server.ts"]
54
+ ```
55
+
56
+ **Bridge entrypoint** waits for Postgres, queries `orgs` table for first Slack-connected org, starts bridge with that ID. Retries every 30s if no org found.
57
+
58
+ ## Caddy Config
59
+
60
+ ```
61
+ app.polaris.lightup.ai {
62
+ reverse_proxy web:3000
63
+ }
64
+
65
+ api.polaris.lightup.ai {
66
+ reverse_proxy api:4321
67
+ }
68
+ ```
69
+
70
+ Caddy auto-provisions Let's Encrypt certs. WebSocket upgrades pass through transparently.
71
+
72
+ ## Production Compose (key decisions)
73
+
74
+ - Only Caddy exposes ports (80, 443). All other services are internal.
75
+ - Postgres has healthcheck; API and web depend on it.
76
+ - Bridge depends on API being healthy.
77
+ - Secrets via `.env` file on server (Docker Compose reads it automatically).
78
+ - Persistent volumes: `pgdata` (Postgres), `caddy_data` (TLS certs).
79
+
80
+ ## Server Setup (one-time)
81
+
82
+ 1. Provision Hetzner CX22 (2 vCPU, 4 GB RAM)
83
+ 2. Install Docker + Docker Compose
84
+ 3. Create deploy user, add SSH key
85
+ 4. Clone repo to `/opt/polaris`
86
+ 5. Create `.env` with production secrets
87
+ 6. DNS: A records for `api.polaris.lightup.ai` and `app.polaris.lightup.ai` → VPS IP
88
+ 7. Update Google OAuth + Slack redirect URIs to `https://app.polaris.lightup.ai/...`
89
+ 8. Firewall: allow 80, 443, 22 only
90
+ 9. `docker compose -f docker-compose.prod.yml up -d`
91
+
92
+ ## Verification
93
+
94
+ 1. `docker compose -f docker-compose.prod.yml up --build` locally — all services start
95
+ 2. `curl https://api.polaris.lightup.ai/status` returns `{"ok":true}`
96
+ 3. `https://app.polaris.lightup.ai` loads login page
97
+ 4. Google SSO login works end-to-end
98
+ 5. Slack bridge connects and posts to channels
99
+ 6. Local daemon connects to `https://api.polaris.lightup.ai` and events flow
@@ -0,0 +1,6 @@
1
+ #!/bin/sh
2
+ # hooks/capture-stop.sh — Wrapper for capture-stop.ts
3
+ # Always exits 0 to avoid blocking the coding agent.
4
+ DIR="$(cd "$(dirname "$0")" && pwd)"
5
+ cat | npx bun "$DIR/capture-stop.ts" 2>/tmp/polaris-capture-stop.log || true
6
+ exit 0
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env bun
2
+ // hooks/capture-stop.ts — Extract the full assistant turn from the transcript
3
+ // and POST it to the daemon as a Stop event with the complete response.
4
+ //
5
+ // A single Claude turn can span multiple assistant entries in the transcript
6
+ // (text → tool_use → tool_result → text → tool_use → ...). We collect ALL
7
+ // assistant text parts since the last user message.
8
+
9
+ const POLARIS_PORT = process.env.POLARIS_PORT ?? "4322";
10
+ const POLARIS_URL = `http://127.0.0.1:${POLARIS_PORT}/events`;
11
+
12
+ try {
13
+ const input = JSON.parse(await Bun.stdin.text());
14
+
15
+ if (input.hook_event_name !== "Stop") {
16
+ // Not a Stop event — forward as-is
17
+ await fetch(POLARIS_URL, {
18
+ method: "POST",
19
+ headers: { "Content-Type": "application/json" },
20
+ body: JSON.stringify(input),
21
+ }).catch(() => {});
22
+ process.exit(0);
23
+ }
24
+
25
+ // Read the transcript to get the full assistant turn
26
+ const transcriptPath = input.transcript_path;
27
+ let fullResponse = input.last_assistant_message ?? "";
28
+
29
+ if (transcriptPath) {
30
+ try {
31
+ const file = Bun.file(transcriptPath);
32
+ const text = await file.text();
33
+ const lines = text.trim().split("\n");
34
+
35
+ // Walk backwards to find the last user message, then collect all
36
+ // assistant text parts between that user message and the end.
37
+ let userIdx = -1;
38
+ for (let i = lines.length - 1; i >= 0; i--) {
39
+ try {
40
+ const entry = JSON.parse(lines[i]);
41
+ if ((entry.type === "user" || entry.type === "human") && typeof entry.message?.content === "string") {
42
+ userIdx = i;
43
+ break;
44
+ }
45
+ } catch {}
46
+ }
47
+
48
+ // Collect everything since the last user message:
49
+ // 1. rawTurn: full structured data for zero-loss DB logging
50
+ // 2. displayResponse: formatted text for Slack display
51
+ const rawTurn: unknown[] = [];
52
+ const displayParts: string[] = [];
53
+ const startIdx = userIdx >= 0 ? userIdx + 1 : 0;
54
+
55
+ for (let i = startIdx; i < lines.length; i++) {
56
+ try {
57
+ const entry = JSON.parse(lines[i]);
58
+ // Capture raw entries for full fidelity
59
+ if (entry.type === "assistant" || (entry.type === "user" && Array.isArray(entry.message?.content))) {
60
+ rawTurn.push(entry);
61
+ }
62
+ // Build display text
63
+ if (entry.type === "assistant" && entry.message?.content) {
64
+ for (const c of entry.message.content) {
65
+ if (c.type === "text" && c.text) {
66
+ displayParts.push(c.text);
67
+ } else if (c.type === "tool_use" && c.name) {
68
+ const inputSummary = c.input?.command?.slice(0, 80)
69
+ ?? c.input?.file_path?.slice(0, 80)
70
+ ?? c.input?.pattern?.slice(0, 80)
71
+ ?? "";
72
+ displayParts.push(`> _\`${c.name}\`${inputSummary ? ": " + inputSummary : ""}_`);
73
+ }
74
+ }
75
+ }
76
+ } catch {}
77
+ }
78
+
79
+ if (displayParts.length > 0) {
80
+ fullResponse = displayParts.join("\n\n");
81
+ }
82
+
83
+ // Attach raw turn data to the payload for zero-loss storage
84
+ if (rawTurn.length > 0) {
85
+ // Sanitize: strip null bytes and invalid Unicode surrogates
86
+ const sanitized = JSON.parse(
87
+ JSON.stringify(rawTurn).replace(/\\u0000/g, "").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "")
88
+ );
89
+ (input as Record<string, unknown>).raw_turn = sanitized;
90
+ }
91
+ } catch {
92
+ // Fall back to last_assistant_message
93
+ }
94
+ }
95
+
96
+ // POST the Stop event with the full response
97
+ const payload = {
98
+ ...input,
99
+ stop_response: fullResponse,
100
+ last_assistant_message: fullResponse,
101
+ };
102
+
103
+ const res = await fetch(POLARIS_URL, {
104
+ method: "POST",
105
+ headers: { "Content-Type": "application/json" },
106
+ body: JSON.stringify(payload),
107
+ }).catch(() => null);
108
+
109
+ // If the POST failed (e.g., Unicode issue in raw_turn), retry without it
110
+ if (!res || !res.ok) {
111
+ delete (payload as Record<string, unknown>).raw_turn;
112
+ await fetch(POLARIS_URL, {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json" },
115
+ body: JSON.stringify(payload),
116
+ }).catch(() => {});
117
+ }
118
+ } catch {
119
+ // Always exit 0 to avoid blocking the coding agent
120
+ }
121
+
122
+ process.exit(0);
package/hooks/capture.sh CHANGED
@@ -3,7 +3,7 @@
3
3
  # Reads hook JSON from stdin, POSTs to the local polaris client.
4
4
  # Always exits 0 to avoid blocking the coding agent.
5
5
 
6
- POLARIS_PORT="${POLARIS_PORT:-4321}"
6
+ POLARIS_PORT="${POLARIS_PORT:-4322}"
7
7
  POLARIS_URL="http://127.0.0.1:${POLARIS_PORT}/events"
8
8
 
9
9
  # Read all of stdin
@@ -2,29 +2,40 @@
2
2
  # hooks/statusline.sh — Polaris status line for coding agent CLI
3
3
  # Reads session JSON from stdin, queries daemon for connection state.
4
4
 
5
- POLARIS_DAEMON_PORT="${POLARIS_DAEMON_PORT:-4321}"
5
+ POLARIS_DAEMON_PORT="${POLARIS_DAEMON_PORT:-4322}"
6
6
 
7
7
  # Read stdin (session JSON from the coding agent)
8
8
  INPUT=$(cat)
9
9
 
10
- # Extract the CC session ID if available (jq optional, fallback to "unknown")
11
- CC_SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"' 2>/dev/null || echo "unknown")
10
+ # Extract the CC session ID if available
11
+ CC_SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
12
12
 
13
- # Query daemon for polaris connection status
14
- STATUS=$(curl -s "http://127.0.0.1:${POLARIS_DAEMON_PORT}/status/${CC_SESSION_ID}" 2>/dev/null)
15
-
16
- if [ -z "$STATUS" ]; then
17
- echo "polaris: daemon offline"
18
- exit 0
13
+ # Query daemon try specific session first, fall back to any connected session
14
+ if [ -n "$CC_SESSION_ID" ]; then
15
+ STATUS=$(curl -s "http://127.0.0.1:${POLARIS_DAEMON_PORT}/status/${CC_SESSION_ID}" 2>/dev/null)
16
+ CONNECTED=$(echo "$STATUS" | jq -r '.connected' 2>/dev/null || echo "false")
19
17
  fi
20
18
 
21
- CONNECTED=$(echo "$STATUS" | jq -r '.connected' 2>/dev/null || echo "false")
19
+ # If no match, check if there's any active session
20
+ if [ "$CONNECTED" != "true" ]; then
21
+ STATUS=$(curl -s "http://127.0.0.1:${POLARIS_DAEMON_PORT}/status" 2>/dev/null)
22
+ FIRST_SESSION=$(echo "$STATUS" | jq -r '.sessions[0] // empty' 2>/dev/null)
23
+ if [ -n "$FIRST_SESSION" ]; then
24
+ STATUS="{\"connected\":true,\"project\":$(echo "$STATUS" | jq '.sessions[0].project'),\"session\":$(echo "$STATUS" | jq '.sessions[0].session'),\"user\":$(echo "$STATUS" | jq '.sessions[0].user')}"
25
+ CONNECTED="true"
26
+ fi
27
+ fi
22
28
 
23
29
  if [ "$CONNECTED" = "true" ]; then
24
30
  PROJECT=$(echo "$STATUS" | jq -r '.project' 2>/dev/null)
25
31
  SESSION=$(echo "$STATUS" | jq -r '.session' 2>/dev/null)
26
32
  USER=$(echo "$STATUS" | jq -r '.user' 2>/dev/null)
27
- echo "polaris: ${PROJECT}/${SESSION} (${USER})"
33
+ SLACK=$(echo "$STATUS" | jq -r '.slackChannel // empty' 2>/dev/null)
34
+ if [ -n "$SLACK" ]; then
35
+ echo "polaris: ${PROJECT}/${SESSION} (${USER}) #${SLACK}"
36
+ else
37
+ echo "polaris: ${PROJECT}/${SESSION} (${USER})"
38
+ fi
28
39
  else
29
40
  echo "polaris: not connected"
30
41
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightupai/polaris",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "polaris": "bin/polaris"
@@ -10,6 +10,8 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@modelcontextprotocol/sdk": "^1.12.0",
13
+ "@slack/socket-mode": "^2.0.0",
14
+ "@slack/web-api": "^7.0.0",
13
15
  "arctic": "^3.5.0",
14
16
  "hono": "^4.7.0",
15
17
  "jose": "^6.0.0",
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: polaris
3
3
  description: Connect to a Polaris multiplayer collaboration session
4
- allowed-tools: polaris_connect polaris_disconnect polaris_status polaris_reply polaris_context
5
- argument-hint: [join <project> <session> | disconnect | (no args for status)]
4
+ allowed-tools: polaris_connect polaris_disconnect polaris_status polaris_reply polaris_context polaris_rename
5
+ argument-hint: [join <project> <session> | rename <new-name> | disconnect | (no args for status)]
6
6
  ---
7
7
 
8
8
  ## Polaris — Multiplayer Collaboration
@@ -18,6 +18,10 @@ Based on the arguments provided, do ONE of the following:
18
18
  2. If `.polaris.json` exists in the repo root, read the `user` field from it. Otherwise ask the user for their participant ID (e.g., `user:manu`).
19
19
  3. Report the connection status
20
20
 
21
+ **`/polaris rename <new-name>`** — Rename the current project:
22
+ 1. Call `polaris_rename` with the new name
23
+ 2. Report the result
24
+
21
25
  **`/polaris disconnect`** — Disconnect:
22
26
  1. Call `polaris_disconnect`
23
27
  2. Confirm disconnection
@@ -0,0 +1,5 @@
1
+ import postgres from "postgres";
2
+ const sql = postgres(process.env.DATABASE_URL ?? "");
3
+ const rows = await sql`SELECT id FROM orgs WHERE slack_team_id IS NOT NULL LIMIT 1`;
4
+ if (rows.length > 0) console.log(rows[0].id);
5
+ await sql.end();