@lightupai/polaris 0.0.104 → 0.0.105

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightupai/polaris",
3
- "version": "0.0.104",
3
+ "version": "0.0.105",
4
4
  "description": "Record and stream Claude Code sessions to Slack. Like Gong for AI coding — capture every prompt, response, and tool call. Multiplayer collaboration across AI coding agents.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -264,7 +264,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
264
264
  ...(agent ? { agent } : {}),
265
265
  ...(create ? { create: true } : {}),
266
266
  });
267
- const body = await res.json() as { status?: string; project?: string; session?: string; user?: string; agent?: string; error?: string; existing?: string[]; account?: string };
267
+ const body = await res.json() as { status?: string; project?: string; session?: string; user?: string; agent?: string; error?: string; existing?: string[]; account?: string; gap?: { events: number; since: string } | null };
268
268
  if (res.status === 404 && body.error === "project_not_found") {
269
269
  const accountLine = body.account ? ` in ${body.account}` : "";
270
270
  const list = body.existing && body.existing.length > 0
@@ -278,7 +278,11 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
278
278
  currentUser = user;
279
279
  currentProfile = activeProfile;
280
280
  await saveSessionState(currentProject, currentSession, currentUser, currentProfile);
281
- return { content: [{ type: "text", text: `Connected to #${currentProject}/${currentSession} as ${user}.` }] };
281
+ let msg = `Connected to #${currentProject}/${currentSession} as ${user}.`;
282
+ if (body.gap && body.gap.events > 0) {
283
+ msg += `\n${body.gap.events} events since ${body.gap.since} not delivered — re-sending now.`;
284
+ }
285
+ return { content: [{ type: "text", text: msg }] };
282
286
  }
283
287
  return { content: [{ type: "text", text: `Failed to connect: ${body.error ?? "unknown error"}` }] };
284
288
  } catch {
@@ -302,12 +306,23 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
302
306
 
303
307
  if (name === "polaris_status") {
304
308
  try {
305
- const res = await daemonGet(`/status/${CC_SESSION_ID}`);
306
- const body = (await res.json()) as { connected: boolean; project?: string; session?: string; user?: string; account?: string; slackChannel?: string };
309
+ const [statusRes, checkpointRes] = await Promise.all([
310
+ daemonGet(`/status/${CC_SESSION_ID}`),
311
+ daemonGet(`/checkpoint/${CC_SESSION_ID}`),
312
+ ]);
313
+ const body = (await statusRes.json()) as { connected: boolean; project?: string; session?: string; user?: string; account?: string; slackChannel?: string };
307
314
  if (body.connected) {
308
315
  const accountTag = body.account ? ` [${body.account}]` : "";
309
316
  const slackTag = body.slackChannel ? ` #${body.slackChannel}` : "";
310
- return { content: [{ type: "text", text: `Connected: ${body.project}/${body.session} as ${body.user}${accountTag}${slackTag}` }] };
317
+ let text = `Connected: ${body.project}/${body.session} as ${body.user}${accountTag}${slackTag}`;
318
+ try {
319
+ const cp = await checkpointRes.json() as { hasCheckpoint: boolean; lastEventTimestamp?: string; pending?: number };
320
+ if (cp.hasCheckpoint && cp.lastEventTimestamp) {
321
+ const ts = new Date(cp.lastEventTimestamp).toISOString().slice(11, 19) + "Z";
322
+ text += `\nLast delivered: ${ts}, ${cp.pending ?? 0} pending`;
323
+ }
324
+ } catch { /* best-effort */ }
325
+ return { content: [{ type: "text", text }] };
311
326
  }
312
327
  return { content: [{ type: "text", text: "Not connected to any Polaris session." }] };
313
328
  } catch {
@@ -478,7 +493,7 @@ async function main() {
478
493
  user: saved.user,
479
494
  profile: reconnectProfile,
480
495
  });
481
- const body = await res.json() as { status?: string; project?: string; session?: string; user?: string; error?: string };
496
+ const body = await res.json() as { status?: string; project?: string; session?: string; user?: string; error?: string; gap?: { events: number; since: string } | null };
482
497
  if (res.ok) {
483
498
  currentProject = body.project ?? saved.project;
484
499
  currentSession = body.session ?? saved.session;
@@ -486,7 +501,11 @@ async function main() {
486
501
  currentProfile = reconnectProfile;
487
502
  // Show email when it looks like one; fall back to profile key or "default"
488
503
  const accountLabel = reconnectProfile.includes("@") ? reconnectProfile : (reconnectProfile || "default");
489
- console.error(`Polaris auto-reconnected to #${currentProject}/${currentSession} [${accountLabel}]`);
504
+ let reconnMsg = `Polaris auto-reconnected to #${currentProject}/${currentSession} [${accountLabel}]`;
505
+ if (body.gap && body.gap.events > 0) {
506
+ reconnMsg += `; re-sending ${body.gap.events} events since ${body.gap.since}`;
507
+ }
508
+ console.error(reconnMsg);
490
509
  } else {
491
510
  // Session or project no longer exists — clear stale state
492
511
  await clearSessionState();
@@ -514,7 +533,12 @@ async function main() {
514
533
  profile: currentProfile,
515
534
  });
516
535
  if (reconnectRes.ok) {
517
- console.error(`Polaris: session dropped; auto-reconnected to #${currentProject}/${currentSession}`);
536
+ const reconnBody = await reconnectRes.json() as { gap?: { events: number; since: string } | null };
537
+ let reconnMsg = `Polaris: session dropped; auto-reconnected to #${currentProject}/${currentSession}`;
538
+ if (reconnBody.gap && reconnBody.gap.events > 0) {
539
+ reconnMsg += `; re-sending ${reconnBody.gap.events} events`;
540
+ }
541
+ console.error(reconnMsg);
518
542
  } else {
519
543
  console.error(`Polaris: session dropped and reconnect failed. Run /polaris to check status.`);
520
544
  currentProject = "";
@@ -2,6 +2,7 @@ import type { Server, ServerWebSocket } from "bun";
2
2
  import { readFile, writeFile, appendFile, mkdir, readdir, unlink } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { homedir } from "node:os";
5
+ import { createHash } from "node:crypto";
5
6
 
6
7
  // --- Session registry ---
7
8
 
@@ -205,10 +206,13 @@ function ensureLogDir(): Promise<void> {
205
206
  return logReady;
206
207
  }
207
208
 
208
- async function logEvent(endpoint: string, payload: unknown, response?: { status: number; body?: unknown }): Promise<void> {
209
+ async function logEvent(endpoint: string, payload: unknown, response?: { status: number; body?: unknown }, context?: { project?: string; session?: string; ccSessionId?: string }): Promise<void> {
209
210
  try {
210
211
  await ensureLogDir();
211
212
  const entry: Record<string, unknown> = { t: new Date().toISOString(), endpoint, payload };
213
+ if (context?.project) entry.project = context.project;
214
+ if (context?.session) entry.session = context.session;
215
+ if (context?.ccSessionId) entry.ccSessionId = context.ccSessionId;
212
216
  if (response) entry.response = response;
213
217
  const file = join(LOG_DIR, `daemon-${new Date().toISOString().slice(0, 10)}.jsonl`);
214
218
  await appendFile(file, JSON.stringify(entry) + "\n");
@@ -324,6 +328,43 @@ async function logSessionChange(action: "connect" | "disconnect" | "ws-drop", cc
324
328
  } catch { /* best-effort */ }
325
329
  }
326
330
 
331
+ // --- Delivery checkpoint ---
332
+ //
333
+ // After each successful event relay, the daemon writes a tiny checkpoint file
334
+ // next to the session state file: ~/.polaris/sessions/{hash}-checkpoint.json
335
+ // where {hash} is the same SHA-256 prefix used by the client. On reconnect,
336
+ // the checkpoint is used to auto-backfill the gap without any manual action.
337
+
338
+ const SESSIONS_DIR = join(homedir(), ".polaris", "sessions");
339
+
340
+ function checkpointPath(ccSessionId: string): string {
341
+ const hash = createHash("sha256").update(ccSessionId).digest("hex").slice(0, 16);
342
+ return join(SESSIONS_DIR, `${hash}-checkpoint.json`);
343
+ }
344
+
345
+ interface Checkpoint {
346
+ lastEventId: string;
347
+ lastEventTimestamp: string;
348
+ project: string;
349
+ session: string;
350
+ }
351
+
352
+ async function writeCheckpoint(ccSessionId: string, eventId: string, eventTimestamp: string, project: string, session: string): Promise<void> {
353
+ try {
354
+ await mkdir(SESSIONS_DIR, { recursive: true });
355
+ const cp: Checkpoint = { lastEventId: eventId, lastEventTimestamp: eventTimestamp, project, session };
356
+ await writeFile(checkpointPath(ccSessionId), JSON.stringify(cp));
357
+ } catch { /* best-effort */ }
358
+ }
359
+
360
+ async function readCheckpoint(ccSessionId: string): Promise<Checkpoint | null> {
361
+ try {
362
+ const data = JSON.parse(await readFile(checkpointPath(ccSessionId), "utf-8")) as Checkpoint;
363
+ if (data.lastEventId && data.lastEventTimestamp) return data;
364
+ } catch { /* none */ }
365
+ return null;
366
+ }
367
+
327
368
  // --- Backfill: recover lost events from daemon log ---
328
369
 
329
370
  async function backfill(mapping: SessionMapping, duration?: string, from?: string, dryRun = false): Promise<{ recovered: number; would_recover?: number; source: string; gaps: string[] }> {
@@ -362,7 +403,7 @@ async function backfill(mapping: SessionMapping, duration?: string, from?: strin
362
403
  }
363
404
 
364
405
  // Read daemon log files covering the time range
365
- const logEntries: Array<{ t: string; endpoint: string; payload: Record<string, unknown> }> = [];
406
+ const logEntries: Array<{ t: string; endpoint: string; payload: Record<string, unknown>; project?: string; session?: string; ccSessionId?: string; response?: unknown }> = [];
366
407
  const startDate = since.toISOString().slice(0, 10);
367
408
  const endDate = now.toISOString().slice(0, 10);
368
409
 
@@ -385,6 +426,14 @@ async function backfill(mapping: SessionMapping, duration?: string, from?: strin
385
426
  if (entry.endpoint !== "/events") continue;
386
427
  if (new Date(entry.t) <= since) continue;
387
428
  if (new Date(entry.t) > now) continue;
429
+ // Filter by origin (added since v0.0.105). Old entries without these fields
430
+ // pass through for backwards compatibility with pre-105 logs.
431
+ if (entry.project !== undefined && entry.project !== mapping.project) continue;
432
+ if (entry.session !== undefined && entry.session !== mapping.session) continue;
433
+ if (entry.ccSessionId !== undefined && entry.ccSessionId !== mapping.ccSessionId) continue;
434
+ // Skip error-response entries — they're duplicates of the primary entry
435
+ // and would cause the same event to be replayed twice.
436
+ if (entry.response !== undefined) continue;
388
437
  logEntries.push(entry);
389
438
  } catch { /* skip malformed lines */ }
390
439
  }
@@ -424,6 +473,19 @@ async function backfill(mapping: SessionMapping, duration?: string, from?: strin
424
473
 
425
474
  return { recovered, source: "daemon_log", gaps: [] };
426
475
  }
476
+
477
+ // Auto-backfill from checkpoint after a reconnect. Called non-blocking (void)
478
+ // so the /connect response is returned immediately while replay runs in background.
479
+ async function autoBackfillAfterConnect(mapping: SessionMapping): Promise<void> {
480
+ const cp = await readCheckpoint(mapping.ccSessionId);
481
+ if (!cp) return;
482
+ if (cp.project !== mapping.project || cp.session !== mapping.session) return;
483
+ const result = await backfill(mapping, undefined, cp.lastEventTimestamp);
484
+ if (result.recovered > 0) {
485
+ console.error(`polaris daemon: gap detected — auto-backfilled ${result.recovered} events since ${cp.lastEventTimestamp}`);
486
+ }
487
+ }
488
+
427
489
  // --- HTTP Server ---
428
490
 
429
491
  function json(data: unknown, status = 200): Response {
@@ -552,6 +614,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
552
614
  // Retry with new name on 409 (collision with generated name)
553
615
  let attempts = 0;
554
616
  let created = false;
617
+ let isReconnect = false; // true only when the session already existed (409 + explicit name)
555
618
  while (!created && attempts < 3) {
556
619
  const sessionRes = await fetch(`${serviceUrl}/projects/${body.project}/sessions`, {
557
620
  method: "POST",
@@ -569,6 +632,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
569
632
  body: JSON.stringify({ driver: body.user }),
570
633
  });
571
634
  created = true;
635
+ isReconnect = true; // session pre-existed → this is a true reconnect
572
636
  } else {
573
637
  // Generated name collision — retry with new name
574
638
  mapping.session = generateSessionName();
@@ -597,12 +661,33 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
597
661
  connectCloudWs(mapping);
598
662
  await logSessionChange("connect", mapping.ccSessionId, mapping.project, mapping.session, mapping.user, mapping.agent);
599
663
 
664
+ // Check for a delivery gap since the last checkpoint (local-only dry run — no network).
665
+ // Only meaningful on a true reconnect (existing session); a brand-new session has no
666
+ // history to recover. Clear any stale checkpoint when starting a fresh session.
667
+ let gap: { events: number; since: string } | null = null;
668
+ if (isReconnect) {
669
+ const cp = await readCheckpoint(mapping.ccSessionId);
670
+ if (cp && cp.project === mapping.project && cp.session === mapping.session) {
671
+ const dryResult = await backfill(mapping, undefined, cp.lastEventTimestamp, true);
672
+ const count = dryResult.would_recover ?? 0;
673
+ if (count > 0) {
674
+ gap = { events: count, since: cp.lastEventTimestamp };
675
+ void autoBackfillAfterConnect(mapping);
676
+ }
677
+ }
678
+ } else {
679
+ // Fresh session — clear any checkpoint left over from a previous session
680
+ // on this CC session ID so stale data never triggers a false backfill.
681
+ void unlink(checkpointPath(mapping.ccSessionId)).catch(() => {});
682
+ }
683
+
600
684
  return json({
601
685
  status: "connected",
602
686
  project: mapping.project,
603
687
  session: mapping.session,
604
688
  user: mapping.user,
605
689
  agent: mapping.agent,
690
+ gap,
606
691
  });
607
692
  } catch {
608
693
  return error("Invalid JSON", 400);
@@ -623,6 +708,8 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
623
708
  mapping.session = "";
624
709
  mapping.user = "";
625
710
  }
711
+ // Clear checkpoint — a deliberate disconnect means there is nothing to recover
712
+ void unlink(checkpointPath(body.ccSessionId)).catch(() => {});
626
713
  return json({ status: "disconnected" });
627
714
  } catch {
628
715
  return error("Invalid JSON", 400);
@@ -636,6 +723,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
636
723
  mapping.project = "";
637
724
  mapping.session = "";
638
725
  mapping.user = "";
726
+ void unlink(checkpointPath(id)).catch(() => {});
639
727
  }
640
728
  sessions.clear();
641
729
  injectQueues.clear();
@@ -646,7 +734,6 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
646
734
  if (method === "POST" && pathname === "/events") {
647
735
  try {
648
736
  const body = (await req.json()) as { session_id?: string; [key: string]: unknown };
649
- await logEvent("/events", body);
650
737
  const ccSessionId = body.session_id;
651
738
  if (!ccSessionId) return error("session_id required in hook payload", 400);
652
739
 
@@ -689,6 +776,9 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
689
776
  }
690
777
  }
691
778
 
779
+ // Log with session context so backfill can filter by exact origin
780
+ await logEvent("/events", body, undefined, { project: mapping.project, session: mapping.session, ccSessionId });
781
+
692
782
  // Determine sender: human for prompts, agent for everything else
693
783
  const hookEvent = body.hook_event_name as string | undefined;
694
784
  const sender = hookEvent === "UserPromptSubmit" ? mapping.user : mapping.agent;
@@ -713,7 +803,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
713
803
 
714
804
  if (!res || res.status >= 500) {
715
805
  console.error(`polaris daemon: upstream relay failed (${res ? res.status : "network error"}) — queued to outbox`);
716
- await logEvent("/events", body, { status: res?.status ?? 0, body: "queued to outbox" });
806
+ await logEvent("/events", body, { status: res?.status ?? 0, body: "queued to outbox" }, { project: mapping.project, session: mapping.session, ccSessionId });
717
807
  await enqueueOutbox(mapping.project, mapping.session, mapping.profile, relayBody);
718
808
  // The event is durably accepted (outbox), so still drain injects
719
809
  if (hookEvent === "UserPromptSubmit") {
@@ -726,10 +816,18 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
726
816
 
727
817
  if (!res.ok) {
728
818
  const err = await res.text();
729
- await logEvent("/events", body, { status: res.status, body: err });
819
+ await logEvent("/events", body, { status: res.status, body: err }, { project: mapping.project, session: mapping.session, ccSessionId });
730
820
  return new Response(err, { status: res.status });
731
821
  }
732
822
 
823
+ // Write delivery checkpoint so reconnects can auto-backfill the exact gap
824
+ try {
825
+ const delivered = await res.json() as { id?: string; timestamp?: string };
826
+ if (delivered.id && delivered.timestamp) {
827
+ void writeCheckpoint(ccSessionId, delivered.id, delivered.timestamp, mapping.project, mapping.session);
828
+ }
829
+ } catch { /* best-effort — don't block the response */ }
830
+
733
831
  // UserPromptSubmit: drain queued advisor injects and hand them back
734
832
  // to the hook, which surfaces them via additionalContext
735
833
  if (hookEvent === "UserPromptSubmit") {
@@ -967,6 +1065,20 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
967
1065
  }
968
1066
  }
969
1067
 
1068
+ // GET /checkpoint/:ccSessionId — checkpoint state + pending count for polaris_status
1069
+ if (method === "GET" && pathname.startsWith("/checkpoint/")) {
1070
+ const ccSessionId = pathname.slice("/checkpoint/".length);
1071
+ const cp = await readCheckpoint(ccSessionId);
1072
+ if (!cp) return json({ hasCheckpoint: false });
1073
+ const mapping = sessions.get(ccSessionId);
1074
+ let pending = 0;
1075
+ if (mapping?.project && cp.project === mapping.project && cp.session === mapping.session) {
1076
+ const dryResult = await backfill(mapping, undefined, cp.lastEventTimestamp, true);
1077
+ pending = dryResult.would_recover ?? 0;
1078
+ }
1079
+ return json({ hasCheckpoint: true, lastEventTimestamp: cp.lastEventTimestamp, pending });
1080
+ }
1081
+
970
1082
  // POST /backfill — recover lost events from daemon log
971
1083
  if (method === "POST" && pathname === "/backfill") {
972
1084
  try {
package/tests/e2e.test.ts CHANGED
@@ -32,6 +32,8 @@ afterAll(async () => {
32
32
 
33
33
  beforeEach(async () => {
34
34
  await resetTestData(sql);
35
+ // Disconnect all daemon sessions so checkpoint files are cleared between tests
36
+ await fetch(`${daemonUrl}/disconnect-all`, { method: "POST", headers: { "Content-Type": "application/json" } });
35
37
  });
36
38
 
37
39
  async function post(base: string, path: string, body: unknown) {