@lightupai/polaris 0.0.31 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightupai/polaris",
3
- "version": "0.0.31",
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
  });