forge-remote 0.1.22 → 0.1.24

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/firestore.rules CHANGED
@@ -91,6 +91,15 @@ service cloud.firestore {
91
91
  && request.resource.data.claimedBy == request.auth.uid;
92
92
  }
93
93
 
94
+ // ---- Characters (RPG character system) ----
95
+ // BYOF: single-player, so 'player' is the only document.
96
+ // The relay writes XP awards; the mobile app reads + creates.
97
+ match /characters/{characterId} {
98
+ allow read: if isSignedIn();
99
+ allow create: if isSignedIn();
100
+ allow update: if isSignedIn() && isValidSize();
101
+ }
102
+
94
103
  // ---- User profiles (for preferences) ----
95
104
  match /users/{userId} {
96
105
  allow read, write: if request.auth != null && request.auth.uid == userId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-remote",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Desktop relay for Forge Remote — monitor and control Claude Code sessions from your phone",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -570,6 +570,9 @@ export async function startNewSession(desktopId, payload) {
570
570
  startTime: Date.now(),
571
571
  messageCount: 0,
572
572
  toolCallCount: 0,
573
+ turnToolCalls: 0, // Tool calls this turn (reset on each prompt)
574
+ turnTokensInput: 0, // Input tokens this turn
575
+ turnTokensOutput: 0, // Output tokens this turn
573
576
  isFirstPrompt: true,
574
577
  lastToolCall: null, // Last tool_use block (for permission requests)
575
578
  permissionNeeded: false, // True when Claude reports permission denial
@@ -690,10 +693,13 @@ async function runClaudeProcess(sessionId, prompt) {
690
693
  const session = activeSessions.get(sessionId);
691
694
  if (!session) return;
692
695
 
693
- // Reset permission state for this new turn.
696
+ // Reset permission state and per-turn stats for this new turn.
694
697
  session.permissionNeeded = false;
695
698
  session.deniedToolCall = null;
696
699
  session.lastToolCall = null;
700
+ session.turnToolCalls = 0;
701
+ session.turnTokensInput = 0;
702
+ session.turnTokensOutput = 0;
697
703
 
698
704
  const db = getDb();
699
705
  const sessionRef = db.collection("sessions").doc(sessionId);
@@ -1031,6 +1037,17 @@ async function runClaudeProcess(sessionId, prompt) {
1031
1037
  timestamp: FieldValue.serverTimestamp(),
1032
1038
  });
1033
1039
 
1040
+ // Award XP for completed turn.
1041
+ awardSessionXp(sessionId, {
1042
+ toolCalls: sess?.turnToolCalls || 0,
1043
+ tokensUsed:
1044
+ (sess?.turnTokensInput || 0) + (sess?.turnTokensOutput || 0),
1045
+ }).catch((e) =>
1046
+ log.warn(
1047
+ `Failed to award XP for ${sessionId.slice(0, 8)}: ${e.message}`,
1048
+ ),
1049
+ );
1050
+
1034
1051
  // Push notification for idle.
1035
1052
  notifySessionIdle(sess.desktopId, sessionId, {
1036
1053
  projectName: sess.projectName || "Unknown",
@@ -1162,6 +1179,17 @@ async function stopSession(sessionId) {
1162
1179
  : 0;
1163
1180
  const durationStr = formatDuration(duration);
1164
1181
 
1182
+ // Award XP before deleting session from the map.
1183
+ if (session) {
1184
+ awardSessionXp(sessionId, {
1185
+ toolCalls: session.toolCallCount || 0,
1186
+ tokensUsed:
1187
+ (session.tokenUsage?.input || 0) + (session.tokenUsage?.output || 0),
1188
+ }).catch((e) =>
1189
+ log.warn(`Failed to award XP for ${sessionId.slice(0, 8)}: ${e.message}`),
1190
+ );
1191
+ }
1192
+
1165
1193
  activeSessions.delete(sessionId);
1166
1194
 
1167
1195
  await sessionRef.update({
@@ -1238,6 +1266,17 @@ async function killSession(sessionId) {
1238
1266
  : 0;
1239
1267
  const durationStr = formatDuration(duration);
1240
1268
 
1269
+ // Award XP before deleting session from the map.
1270
+ if (session) {
1271
+ awardSessionXp(sessionId, {
1272
+ toolCalls: session.toolCallCount || 0,
1273
+ tokensUsed:
1274
+ (session.tokenUsage?.input || 0) + (session.tokenUsage?.output || 0),
1275
+ }).catch((e) =>
1276
+ log.warn(`Failed to award XP for ${sessionId.slice(0, 8)}: ${e.message}`),
1277
+ );
1278
+ }
1279
+
1241
1280
  activeSessions.delete(sessionId);
1242
1281
 
1243
1282
  await sessionRef.update({
@@ -1288,7 +1327,12 @@ async function handleStreamEvent(sessionId, sessionRef, event) {
1288
1327
  lastActivity: FieldValue.serverTimestamp(),
1289
1328
  });
1290
1329
 
1291
- if (session) session.tokenUsage = tokenUsage;
1330
+ if (session) {
1331
+ session.tokenUsage = tokenUsage;
1332
+ // Track per-turn tokens for XP calculation.
1333
+ session.turnTokensInput = tokenUsage.input;
1334
+ session.turnTokensOutput = tokenUsage.output;
1335
+ }
1292
1336
 
1293
1337
  log.session(
1294
1338
  sessionId,
@@ -1487,7 +1531,10 @@ const toolCallDocIds = new Map();
1487
1531
  async function storeToolCall(sessionId, block) {
1488
1532
  const db = getDb();
1489
1533
  const session = activeSessions.get(sessionId);
1490
- if (session) session.toolCallCount = (session.toolCallCount || 0) + 1;
1534
+ if (session) {
1535
+ session.toolCallCount = (session.toolCallCount || 0) + 1;
1536
+ session.turnToolCalls = (session.turnToolCalls || 0) + 1;
1537
+ }
1491
1538
 
1492
1539
  const toolName = block.name || "unknown";
1493
1540
  const toolInput =
@@ -1803,6 +1850,17 @@ export async function shutdownAllSessions() {
1803
1850
 
1804
1851
  const duration = Math.round((Date.now() - session.startTime) / 1000);
1805
1852
 
1853
+ // Award XP before deleting session from the map.
1854
+ try {
1855
+ await awardSessionXp(sessionId, {
1856
+ toolCalls: session.toolCallCount || 0,
1857
+ tokensUsed:
1858
+ (session.tokenUsage?.input || 0) + (session.tokenUsage?.output || 0),
1859
+ });
1860
+ } catch (e) {
1861
+ log.warn(`Failed to award XP for ${sessionId.slice(0, 8)}: ${e.message}`);
1862
+ }
1863
+
1806
1864
  try {
1807
1865
  await db.collection("sessions").doc(sessionId).update({
1808
1866
  status: "completed",
@@ -2108,6 +2166,109 @@ async function handlePermissionDenied(sessionId) {
2108
2166
  // Helpers
2109
2167
  // ---------------------------------------------------------------------------
2110
2168
 
2169
+ // ---------------------------------------------------------------------------
2170
+ // Character XP — award XP when a session completes
2171
+ // ---------------------------------------------------------------------------
2172
+
2173
+ const XP_SESSION_COMPLETED = 50;
2174
+ const XP_PER_TOOL_CALL = 2;
2175
+ const XP_PER_1K_TOKENS = 3;
2176
+ const XP_DAILY_STREAK = 25;
2177
+ const XP_FIRST_SESSION = 15;
2178
+
2179
+ function xpForLevel(level) {
2180
+ if (level <= 1) return 0;
2181
+ return Math.round(100 * Math.pow(level, 1.5));
2182
+ }
2183
+
2184
+ function levelForXp(totalXp) {
2185
+ if (totalXp <= 0) return 1;
2186
+ let level = 1;
2187
+ while (xpForLevel(level + 1) <= totalXp && level < 99) {
2188
+ level++;
2189
+ }
2190
+ return level;
2191
+ }
2192
+
2193
+ /**
2194
+ * Award XP for a completed session.
2195
+ *
2196
+ * @param {string} sessionId — for logging only
2197
+ * @param {{ toolCalls: number, tokensUsed: number }} stats — session stats
2198
+ */
2199
+ async function awardSessionXp(sessionId, { toolCalls = 0, tokensUsed = 0 }) {
2200
+ const db = getDb();
2201
+ const charRef = db.collection("characters").doc("player");
2202
+
2203
+ await db.runTransaction(async (txn) => {
2204
+ const charDoc = await txn.get(charRef);
2205
+ if (!charDoc.exists) {
2206
+ log.warn(
2207
+ `[${sessionId.slice(0, 8)}] No character doc — skipping XP award`,
2208
+ );
2209
+ return;
2210
+ }
2211
+
2212
+ const data = charDoc.data();
2213
+ const currentXp = data.totalXp || 0;
2214
+ const sessions = data.sessionsCompleted || 0;
2215
+ const totalTools = data.totalToolCalls || 0;
2216
+ const totalTokens = data.totalTokensUsed || 0;
2217
+ const currentStreak = data.currentStreak || 0;
2218
+ const longestStreak = data.longestStreak || 0;
2219
+ const lastSessionDate = data.lastSessionDate?.toDate?.() || null;
2220
+
2221
+ // Calculate XP.
2222
+ let xpEarned = XP_SESSION_COMPLETED;
2223
+ xpEarned += toolCalls * XP_PER_TOOL_CALL;
2224
+ xpEarned += Math.round((tokensUsed / 1000) * XP_PER_1K_TOKENS);
2225
+
2226
+ // Streak calculation.
2227
+ const now = new Date();
2228
+ let newStreak = currentStreak;
2229
+ if (lastSessionDate) {
2230
+ const lastDay = new Date(
2231
+ lastSessionDate.getFullYear(),
2232
+ lastSessionDate.getMonth(),
2233
+ lastSessionDate.getDate(),
2234
+ );
2235
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
2236
+ const daysDiff = Math.round((today - lastDay) / 86400000);
2237
+
2238
+ if (daysDiff === 1) {
2239
+ newStreak = currentStreak + 1;
2240
+ xpEarned += XP_DAILY_STREAK;
2241
+ } else if (daysDiff > 1) {
2242
+ newStreak = 1;
2243
+ }
2244
+ } else {
2245
+ newStreak = 1;
2246
+ xpEarned += XP_FIRST_SESSION;
2247
+ }
2248
+
2249
+ const newXp = currentXp + xpEarned;
2250
+ const newLevel = levelForXp(newXp);
2251
+ const newLongest = Math.max(newStreak, longestStreak);
2252
+
2253
+ txn.update(charRef, {
2254
+ totalXp: newXp,
2255
+ level: newLevel,
2256
+ sessionsCompleted: sessions + 1,
2257
+ totalToolCalls: totalTools + toolCalls,
2258
+ totalTokensUsed: totalTokens + tokensUsed,
2259
+ currentStreak: newStreak,
2260
+ longestStreak: newLongest,
2261
+ lastSessionDate: FieldValue.serverTimestamp(),
2262
+ });
2263
+
2264
+ log.info(
2265
+ `[${sessionId.slice(0, 8)}] Awarded ${xpEarned} XP — ` +
2266
+ `tools: ${toolCalls}, tokens: ${tokensUsed}, ` +
2267
+ `level: ${newLevel}, streak: ${newStreak}`,
2268
+ );
2269
+ });
2270
+ }
2271
+
2111
2272
  function formatDuration(seconds) {
2112
2273
  if (seconds < 60) return `${seconds}s`;
2113
2274
  const mins = Math.floor(seconds / 60);