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 +9 -0
- package/package.json +1 -1
- package/src/session-manager.js +164 -3
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
package/src/session-manager.js
CHANGED
|
@@ -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)
|
|
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)
|
|
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);
|