@lightupai/polaris 0.0.30 → 0.0.32

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.
@@ -139,6 +139,23 @@ Response:
139
139
  3. **Slack recovery summary** — nice to have, reuse the pattern from the manual recovery we did
140
140
  4. **Deduplication** — important for correctness, add after basic replay works
141
141
 
142
+ ## Name Changes During Gap
143
+
144
+ Project renames, session changes, and Slack channel renames can happen during a gap. Backfill must handle these correctly.
145
+
146
+ **Key insight**: The daemon log stores raw hook payloads, not project/session names. The daemon adds the project/session from its session mapping at relay time. So on replay, the daemon should use the *current* mapping, not reconstruct a historical one.
147
+
148
+ | Change during gap | Impact | Handling |
149
+ |---|---|---|
150
+ | Project renamed | Log doesn't contain project name — daemon resolves it from current mapping | Works automatically |
151
+ | Slack channel renamed | Bridge looks up channel by project → channel ID in DB | Works if DB mapping is current |
152
+ | User switched projects | Log has events for both time periods | Filter by timestamp range per project |
153
+ | Session handed off to new driver | Sender identity changes | Use current session's driver/agent at replay time |
154
+
155
+ **Transcript fallback complication**: The transcript doesn't know about Polaris projects/sessions at all. When parsing the transcript, backfill must ask: "which project was this CC session connected to at this timestamp?" The daemon log has `/connect` entries that establish the timeline of project associations. If the daemon log is also missing, the current session mapping is the only reference — which may not reflect historical state.
156
+
157
+ **Recommendation**: Always log `/connect` and `/disconnect` events to a separate persistent file (`~/.polaris/session-history.jsonl`) that survives daemon restarts. This gives backfill a reliable timeline of which CC session was in which project at what time.
158
+
142
159
  ## Open Questions
143
160
 
144
161
  1. **Should backfill be automatic?** The daemon could detect gaps on startup (compare last log entry to last API event) and auto-backfill. Risk: could replay stale events unintentionally.
@@ -148,3 +165,5 @@ Response:
148
165
  3. **Multi-day gaps**: The daemon log is per-day. A multi-day outage requires reading multiple files. The transcript spans the whole session.
149
166
 
150
167
  4. **Cross-session backfill**: If the user was in session A, disconnected, joined session B, and wants to backfill A — the daemon log has events for both. Need to filter by session/project.
168
+
169
+ 5. **Session history persistence**: Should we add `~/.polaris/session-history.jsonl` now (cheap, foundational for backfill) or defer until backfill is implemented?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightupai/polaris",
3
- "version": "0.0.30",
3
+ "version": "0.0.32",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "polaris": "bin/polaris",
@@ -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 polaris_rename
5
- argument-hint: [join #channel | rename <new-name> | disconnect | (no args for status)]
4
+ allowed-tools: polaris_connect polaris_disconnect polaris_status polaris_reply polaris_context polaris_rename polaris_backfill
5
+ argument-hint: [join #channel | backfill [duration] | rename <new-name> | disconnect | (no args for status)]
6
6
  ---
7
7
 
8
8
  ## Polaris — Multiplayer Collaboration
@@ -22,6 +22,10 @@ Based on the arguments provided, do ONE of the following:
22
22
  1. Call `polaris_rename` with the new name
23
23
  2. Report the result
24
24
 
25
+ **`/polaris backfill [duration]`** — Recover lost events:
26
+ 1. Call `polaris_backfill` with the optional duration (e.g., `2h`, `30m`)
27
+ 2. Report how many events were recovered
28
+
25
29
  **`/polaris disconnect`** — Disconnect:
26
30
  1. Call `polaris_disconnect`
27
31
  2. Confirm disconnection
@@ -110,6 +110,17 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
110
110
  required: ["session"],
111
111
  },
112
112
  },
113
+ {
114
+ name: "polaris_backfill",
115
+ description: "Recover lost events from local daemon logs. Use when events were lost due to disconnection or API downtime.",
116
+ inputSchema: {
117
+ type: "object" as const,
118
+ properties: {
119
+ duration: { type: "string", description: "Time range to backfill (e.g., '2h', '30m', '1d'). Auto-detects if omitted." },
120
+ from: { type: "string", description: "ISO timestamp to backfill from. Overrides duration." },
121
+ },
122
+ },
123
+ },
113
124
  ],
114
125
  }));
115
126
 
@@ -228,6 +239,28 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
228
239
  }
229
240
  }
230
241
 
242
+ if (name === "polaris_backfill") {
243
+ if (!currentProject) {
244
+ return { content: [{ type: "text", text: "Not connected to a Polaris session. Use polaris_connect first." }] };
245
+ }
246
+ const { duration, from } = (args ?? {}) as { duration?: string; from?: string };
247
+ try {
248
+ const res = await daemonPost("/backfill", {
249
+ ccSessionId: CC_SESSION_ID,
250
+ ...(duration ? { duration } : {}),
251
+ ...(from ? { from } : {}),
252
+ });
253
+ const body = await res.json() as { recovered: number; source: string; gaps: string[] };
254
+ if (res.ok) {
255
+ const gapInfo = body.gaps.length > 0 ? `\nGaps: ${body.gaps.join(", ")}` : "";
256
+ return { content: [{ type: "text", text: `Backfill complete: ${body.recovered} events recovered from ${body.source}.${gapInfo}` }] };
257
+ }
258
+ return { content: [{ type: "text", text: `Backfill failed: ${(body as unknown as { error?: string }).error ?? "unknown error"}` }] };
259
+ } catch {
260
+ return { content: [{ type: "text", text: "Failed to reach the daemon." }] };
261
+ }
262
+ }
263
+
231
264
  throw new Error(`Unknown tool: ${name}`);
232
265
  });
233
266
 
@@ -154,6 +154,114 @@ async function logEvent(endpoint: string, payload: unknown, response?: { status:
154
154
  } catch { /* best-effort — don't break the request */ }
155
155
  }
156
156
 
157
+ // --- Session history (persistent connect/disconnect log for backfill) ---
158
+
159
+ const SESSION_HISTORY_FILE = join(homedir(), ".polaris", "session-history.jsonl");
160
+
161
+ async function logSessionChange(action: "connect" | "disconnect", ccSessionId: string, project: string, session: string, user: string, agent: string): Promise<void> {
162
+ try {
163
+ await ensureLogDir();
164
+ const entry = JSON.stringify({ t: new Date().toISOString(), action, ccSessionId, project, session, user, agent });
165
+ await appendFile(SESSION_HISTORY_FILE, entry + "\n");
166
+ } catch { /* best-effort */ }
167
+ }
168
+
169
+ // --- Backfill: recover lost events from daemon log ---
170
+
171
+ async function backfill(mapping: SessionMapping, duration?: string, from?: string): Promise<{ recovered: number; source: string; gaps: string[] }> {
172
+ const now = new Date();
173
+ let since: Date;
174
+
175
+ if (from) {
176
+ since = new Date(from);
177
+ } else if (duration) {
178
+ const match = duration.match(/^(\d+)(h|m|d)$/);
179
+ if (!match) return { recovered: 0, source: "none", gaps: ["invalid duration format"] };
180
+ const val = parseInt(match[1]);
181
+ const unit = match[2];
182
+ since = new Date(now.getTime() - val * (unit === "h" ? 3600000 : unit === "m" ? 60000 : 86400000));
183
+ } else {
184
+ // Auto-detect: query API for most recent event, backfill from there
185
+ try {
186
+ const serviceUrl = getServiceUrl();
187
+ const res = await fetch(
188
+ `${serviceUrl}/projects/${mapping.project}/sessions/${mapping.session}/messages`,
189
+ { headers: await authHeaders() }
190
+ );
191
+ if (res.ok) {
192
+ const events = (await res.json()) as Array<{ timestamp: string }>;
193
+ if (events.length > 0) {
194
+ since = new Date(events[events.length - 1].timestamp);
195
+ } else {
196
+ since = new Date(now.getTime() - 3600000); // default 1h
197
+ }
198
+ } else {
199
+ since = new Date(now.getTime() - 3600000);
200
+ }
201
+ } catch {
202
+ since = new Date(now.getTime() - 3600000);
203
+ }
204
+ }
205
+
206
+ // Read daemon log files covering the time range
207
+ const logEntries: Array<{ t: string; endpoint: string; payload: Record<string, unknown> }> = [];
208
+ const startDate = since.toISOString().slice(0, 10);
209
+ const endDate = now.toISOString().slice(0, 10);
210
+
211
+ // Collect all dates in range
212
+ const dates: string[] = [];
213
+ const d = new Date(startDate);
214
+ while (d.toISOString().slice(0, 10) <= endDate) {
215
+ dates.push(d.toISOString().slice(0, 10));
216
+ d.setDate(d.getDate() + 1);
217
+ }
218
+
219
+ for (const date of dates) {
220
+ const logFile = join(LOG_DIR, `daemon-${date}.jsonl`);
221
+ try {
222
+ const content = await readFile(logFile, "utf-8");
223
+ for (const line of content.split("\n")) {
224
+ if (!line.trim()) continue;
225
+ try {
226
+ const entry = JSON.parse(line);
227
+ if (entry.endpoint !== "/events") continue;
228
+ if (new Date(entry.t) <= since) continue;
229
+ if (new Date(entry.t) > now) continue;
230
+ logEntries.push(entry);
231
+ } catch { /* skip malformed lines */ }
232
+ }
233
+ } catch { /* log file doesn't exist for this date — not an error */ }
234
+ }
235
+
236
+ if (logEntries.length === 0) {
237
+ return { recovered: 0, source: "daemon_log", gaps: [`no log entries found since ${since.toISOString()}`] };
238
+ }
239
+
240
+ // Replay events to the API
241
+ let recovered = 0;
242
+ const serviceUrl = getServiceUrl();
243
+ const headers = await authHeaders();
244
+
245
+ for (const entry of logEntries) {
246
+ const hookEvent = entry.payload.hook_event_name as string | undefined;
247
+ const sender = hookEvent === "UserPromptSubmit" ? mapping.user : mapping.agent;
248
+
249
+ try {
250
+ const res = await fetch(
251
+ `${serviceUrl}/projects/${mapping.project}/sessions/${mapping.session}/events`,
252
+ {
253
+ method: "POST",
254
+ headers,
255
+ body: JSON.stringify({ sender, payload: entry.payload }),
256
+ }
257
+ );
258
+ if (res.ok) recovered++;
259
+ } catch { /* skip failed replays */ }
260
+ }
261
+
262
+ return { recovered, source: "daemon_log", gaps: [] };
263
+ }
264
+
157
265
  // --- HTTP Server ---
158
266
 
159
267
  function json(data: unknown, status = 200): Response {
@@ -294,6 +402,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
294
402
 
295
403
  // Connect to cloud WebSocket
296
404
  connectCloudWs(mapping);
405
+ await logSessionChange("connect", mapping.ccSessionId, mapping.project, mapping.session, mapping.user, mapping.agent);
297
406
 
298
407
  return json({
299
408
  status: "connected",
@@ -314,7 +423,8 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
314
423
  if (!body.ccSessionId) return error("ccSessionId required", 400);
315
424
  disconnectCloudWs(body.ccSessionId);
316
425
  const mapping = sessions.get(body.ccSessionId);
317
- if (mapping) {
426
+ if (mapping && mapping.project) {
427
+ await logSessionChange("disconnect", body.ccSessionId, mapping.project, mapping.session, mapping.user, mapping.agent);
318
428
  mapping.project = "";
319
429
  mapping.session = "";
320
430
  mapping.user = "";
@@ -541,6 +651,21 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
541
651
  return json(data);
542
652
  }
543
653
 
654
+ // POST /backfill — recover lost events from daemon log
655
+ if (method === "POST" && pathname === "/backfill") {
656
+ try {
657
+ const body = (await req.json()) as { ccSessionId: string; duration?: string; from?: string };
658
+ if (!body.ccSessionId) return error("ccSessionId required", 400);
659
+ const mapping = sessions.get(body.ccSessionId);
660
+ if (!mapping || !mapping.project) return error("Not connected", 400);
661
+
662
+ const result = await backfill(mapping, body.duration, body.from);
663
+ return json(result);
664
+ } catch {
665
+ return error("Invalid JSON", 400);
666
+ }
667
+ }
668
+
544
669
  return error("Not found", 404);
545
670
  },
546
671
  });