forge-remote 0.1.21 → 0.1.23
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/cli.js +6 -1
- package/src/desktop.js +31 -3
- package/src/session-manager.js +156 -2
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/cli.js
CHANGED
|
@@ -140,7 +140,12 @@ program
|
|
|
140
140
|
// Clean up any orphaned sessions from a previous relay run.
|
|
141
141
|
await cleanupOrphanedSessions(desktopId);
|
|
142
142
|
|
|
143
|
-
const heartbeat = startHeartbeat(desktopId
|
|
143
|
+
const heartbeat = startHeartbeat(desktopId, {
|
|
144
|
+
onConnectionLost: () => {
|
|
145
|
+
log.info("Re-establishing Firestore listeners...");
|
|
146
|
+
listenForCommands(desktopId);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
144
149
|
|
|
145
150
|
listenForCommands(desktopId);
|
|
146
151
|
|
package/src/desktop.js
CHANGED
|
@@ -54,17 +54,45 @@ export async function markOffline(desktopId) {
|
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
56
|
* Start a heartbeat interval that updates lastHeartbeat every 30s.
|
|
57
|
+
* Also monitors Firestore connectivity — if heartbeats fail consecutively,
|
|
58
|
+
* it signals the relay to restart listeners.
|
|
57
59
|
*/
|
|
58
|
-
export function startHeartbeat(desktopId) {
|
|
60
|
+
export function startHeartbeat(desktopId, { onConnectionLost } = {}) {
|
|
61
|
+
let consecutiveFailures = 0;
|
|
62
|
+
const MAX_FAILURES = 5; // ~2.5 minutes of failures before action
|
|
63
|
+
|
|
59
64
|
const interval = setInterval(async () => {
|
|
60
65
|
try {
|
|
61
66
|
const db = getDb();
|
|
62
|
-
|
|
67
|
+
|
|
68
|
+
// Use a timeout to detect hung Firestore connections (e.g., stale TCP).
|
|
69
|
+
const heartbeatPromise = db.collection("desktops").doc(desktopId).update({
|
|
63
70
|
lastHeartbeat: FieldValue.serverTimestamp(),
|
|
64
71
|
});
|
|
72
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
73
|
+
setTimeout(
|
|
74
|
+
() => reject(new Error("Heartbeat timed out (15s)")),
|
|
75
|
+
15_000,
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
await Promise.race([heartbeatPromise, timeoutPromise]);
|
|
79
|
+
|
|
80
|
+
if (consecutiveFailures > 0) {
|
|
81
|
+
log.info(`Heartbeat recovered after ${consecutiveFailures} failure(s)`);
|
|
82
|
+
}
|
|
83
|
+
consecutiveFailures = 0;
|
|
65
84
|
log.heartbeat();
|
|
66
85
|
} catch (e) {
|
|
67
|
-
|
|
86
|
+
consecutiveFailures++;
|
|
87
|
+
log.error(
|
|
88
|
+
`Heartbeat failed (${consecutiveFailures}/${MAX_FAILURES}): ${e.message}`,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (consecutiveFailures >= MAX_FAILURES && onConnectionLost) {
|
|
92
|
+
log.error("Firestore connection appears dead — triggering reconnect");
|
|
93
|
+
consecutiveFailures = 0; // Reset so it doesn't fire again immediately.
|
|
94
|
+
onConnectionLost();
|
|
95
|
+
}
|
|
68
96
|
}
|
|
69
97
|
}, 30_000);
|
|
70
98
|
|
package/src/session-manager.js
CHANGED
|
@@ -224,11 +224,26 @@ function isRateLimited() {
|
|
|
224
224
|
// Command listeners
|
|
225
225
|
// ---------------------------------------------------------------------------
|
|
226
226
|
|
|
227
|
+
// Track active listener unsubscribe functions so we can tear them down
|
|
228
|
+
// before re-subscribing (e.g., after a Firestore reconnect).
|
|
229
|
+
let activeListenerUnsubs = [];
|
|
230
|
+
|
|
227
231
|
export function listenForCommands(desktopId) {
|
|
232
|
+
// Clean up previous listeners to avoid duplicate command handling.
|
|
233
|
+
for (const unsub of activeListenerUnsubs) {
|
|
234
|
+
try {
|
|
235
|
+
unsub();
|
|
236
|
+
} catch {
|
|
237
|
+
/* already dead */
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
activeListenerUnsubs = [];
|
|
241
|
+
|
|
228
242
|
const db = getDb();
|
|
229
243
|
|
|
230
244
|
// Desktop-level commands (start_session, etc.).
|
|
231
|
-
db
|
|
245
|
+
const unsub1 = db
|
|
246
|
+
.collection("desktops")
|
|
232
247
|
.doc(desktopId)
|
|
233
248
|
.collection("commands")
|
|
234
249
|
.where("status", "==", "pending")
|
|
@@ -239,15 +254,18 @@ export function listenForCommands(desktopId) {
|
|
|
239
254
|
}
|
|
240
255
|
}
|
|
241
256
|
});
|
|
257
|
+
activeListenerUnsubs.push(unsub1);
|
|
242
258
|
|
|
243
259
|
// Watch for existing sessions → subscribe to their commands.
|
|
244
|
-
db
|
|
260
|
+
const unsub2 = db
|
|
261
|
+
.collection("sessions")
|
|
245
262
|
.where("desktopId", "==", desktopId)
|
|
246
263
|
.onSnapshot((snap) => {
|
|
247
264
|
for (const doc of snap.docs) {
|
|
248
265
|
watchSessionCommands(doc.id);
|
|
249
266
|
}
|
|
250
267
|
});
|
|
268
|
+
activeListenerUnsubs.push(unsub2);
|
|
251
269
|
}
|
|
252
270
|
|
|
253
271
|
// ---------------------------------------------------------------------------
|
|
@@ -1144,6 +1162,17 @@ async function stopSession(sessionId) {
|
|
|
1144
1162
|
: 0;
|
|
1145
1163
|
const durationStr = formatDuration(duration);
|
|
1146
1164
|
|
|
1165
|
+
// Award XP before deleting session from the map.
|
|
1166
|
+
if (session) {
|
|
1167
|
+
awardSessionXp(sessionId, {
|
|
1168
|
+
toolCalls: session.toolCallCount || 0,
|
|
1169
|
+
tokensUsed:
|
|
1170
|
+
(session.tokenUsage?.input || 0) + (session.tokenUsage?.output || 0),
|
|
1171
|
+
}).catch((e) =>
|
|
1172
|
+
log.warn(`Failed to award XP for ${sessionId.slice(0, 8)}: ${e.message}`),
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1147
1176
|
activeSessions.delete(sessionId);
|
|
1148
1177
|
|
|
1149
1178
|
await sessionRef.update({
|
|
@@ -1220,6 +1249,17 @@ async function killSession(sessionId) {
|
|
|
1220
1249
|
: 0;
|
|
1221
1250
|
const durationStr = formatDuration(duration);
|
|
1222
1251
|
|
|
1252
|
+
// Award XP before deleting session from the map.
|
|
1253
|
+
if (session) {
|
|
1254
|
+
awardSessionXp(sessionId, {
|
|
1255
|
+
toolCalls: session.toolCallCount || 0,
|
|
1256
|
+
tokensUsed:
|
|
1257
|
+
(session.tokenUsage?.input || 0) + (session.tokenUsage?.output || 0),
|
|
1258
|
+
}).catch((e) =>
|
|
1259
|
+
log.warn(`Failed to award XP for ${sessionId.slice(0, 8)}: ${e.message}`),
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1223
1263
|
activeSessions.delete(sessionId);
|
|
1224
1264
|
|
|
1225
1265
|
await sessionRef.update({
|
|
@@ -1785,6 +1825,17 @@ export async function shutdownAllSessions() {
|
|
|
1785
1825
|
|
|
1786
1826
|
const duration = Math.round((Date.now() - session.startTime) / 1000);
|
|
1787
1827
|
|
|
1828
|
+
// Award XP before deleting session from the map.
|
|
1829
|
+
try {
|
|
1830
|
+
await awardSessionXp(sessionId, {
|
|
1831
|
+
toolCalls: session.toolCallCount || 0,
|
|
1832
|
+
tokensUsed:
|
|
1833
|
+
(session.tokenUsage?.input || 0) + (session.tokenUsage?.output || 0),
|
|
1834
|
+
});
|
|
1835
|
+
} catch (e) {
|
|
1836
|
+
log.warn(`Failed to award XP for ${sessionId.slice(0, 8)}: ${e.message}`);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1788
1839
|
try {
|
|
1789
1840
|
await db.collection("sessions").doc(sessionId).update({
|
|
1790
1841
|
status: "completed",
|
|
@@ -2090,6 +2141,109 @@ async function handlePermissionDenied(sessionId) {
|
|
|
2090
2141
|
// Helpers
|
|
2091
2142
|
// ---------------------------------------------------------------------------
|
|
2092
2143
|
|
|
2144
|
+
// ---------------------------------------------------------------------------
|
|
2145
|
+
// Character XP — award XP when a session completes
|
|
2146
|
+
// ---------------------------------------------------------------------------
|
|
2147
|
+
|
|
2148
|
+
const XP_SESSION_COMPLETED = 50;
|
|
2149
|
+
const XP_PER_TOOL_CALL = 2;
|
|
2150
|
+
const XP_PER_1K_TOKENS = 3;
|
|
2151
|
+
const XP_DAILY_STREAK = 25;
|
|
2152
|
+
const XP_FIRST_SESSION = 15;
|
|
2153
|
+
|
|
2154
|
+
function xpForLevel(level) {
|
|
2155
|
+
if (level <= 1) return 0;
|
|
2156
|
+
return Math.round(100 * Math.pow(level, 1.5));
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
function levelForXp(totalXp) {
|
|
2160
|
+
if (totalXp <= 0) return 1;
|
|
2161
|
+
let level = 1;
|
|
2162
|
+
while (xpForLevel(level + 1) <= totalXp && level < 99) {
|
|
2163
|
+
level++;
|
|
2164
|
+
}
|
|
2165
|
+
return level;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
/**
|
|
2169
|
+
* Award XP for a completed session.
|
|
2170
|
+
*
|
|
2171
|
+
* @param {string} sessionId — for logging only
|
|
2172
|
+
* @param {{ toolCalls: number, tokensUsed: number }} stats — session stats
|
|
2173
|
+
*/
|
|
2174
|
+
async function awardSessionXp(sessionId, { toolCalls = 0, tokensUsed = 0 }) {
|
|
2175
|
+
const db = getDb();
|
|
2176
|
+
const charRef = db.collection("characters").doc("player");
|
|
2177
|
+
|
|
2178
|
+
await db.runTransaction(async (txn) => {
|
|
2179
|
+
const charDoc = await txn.get(charRef);
|
|
2180
|
+
if (!charDoc.exists) {
|
|
2181
|
+
log.warn(
|
|
2182
|
+
`[${sessionId.slice(0, 8)}] No character doc — skipping XP award`,
|
|
2183
|
+
);
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
const data = charDoc.data();
|
|
2188
|
+
const currentXp = data.totalXp || 0;
|
|
2189
|
+
const sessions = data.sessionsCompleted || 0;
|
|
2190
|
+
const totalTools = data.totalToolCalls || 0;
|
|
2191
|
+
const totalTokens = data.totalTokensUsed || 0;
|
|
2192
|
+
const currentStreak = data.currentStreak || 0;
|
|
2193
|
+
const longestStreak = data.longestStreak || 0;
|
|
2194
|
+
const lastSessionDate = data.lastSessionDate?.toDate?.() || null;
|
|
2195
|
+
|
|
2196
|
+
// Calculate XP.
|
|
2197
|
+
let xpEarned = XP_SESSION_COMPLETED;
|
|
2198
|
+
xpEarned += toolCalls * XP_PER_TOOL_CALL;
|
|
2199
|
+
xpEarned += Math.round((tokensUsed / 1000) * XP_PER_1K_TOKENS);
|
|
2200
|
+
|
|
2201
|
+
// Streak calculation.
|
|
2202
|
+
const now = new Date();
|
|
2203
|
+
let newStreak = currentStreak;
|
|
2204
|
+
if (lastSessionDate) {
|
|
2205
|
+
const lastDay = new Date(
|
|
2206
|
+
lastSessionDate.getFullYear(),
|
|
2207
|
+
lastSessionDate.getMonth(),
|
|
2208
|
+
lastSessionDate.getDate(),
|
|
2209
|
+
);
|
|
2210
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
2211
|
+
const daysDiff = Math.round((today - lastDay) / 86400000);
|
|
2212
|
+
|
|
2213
|
+
if (daysDiff === 1) {
|
|
2214
|
+
newStreak = currentStreak + 1;
|
|
2215
|
+
xpEarned += XP_DAILY_STREAK;
|
|
2216
|
+
} else if (daysDiff > 1) {
|
|
2217
|
+
newStreak = 1;
|
|
2218
|
+
}
|
|
2219
|
+
} else {
|
|
2220
|
+
newStreak = 1;
|
|
2221
|
+
xpEarned += XP_FIRST_SESSION;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
const newXp = currentXp + xpEarned;
|
|
2225
|
+
const newLevel = levelForXp(newXp);
|
|
2226
|
+
const newLongest = Math.max(newStreak, longestStreak);
|
|
2227
|
+
|
|
2228
|
+
txn.update(charRef, {
|
|
2229
|
+
totalXp: newXp,
|
|
2230
|
+
level: newLevel,
|
|
2231
|
+
sessionsCompleted: sessions + 1,
|
|
2232
|
+
totalToolCalls: totalTools + toolCalls,
|
|
2233
|
+
totalTokensUsed: totalTokens + tokensUsed,
|
|
2234
|
+
currentStreak: newStreak,
|
|
2235
|
+
longestStreak: newLongest,
|
|
2236
|
+
lastSessionDate: FieldValue.serverTimestamp(),
|
|
2237
|
+
});
|
|
2238
|
+
|
|
2239
|
+
log.info(
|
|
2240
|
+
`[${sessionId.slice(0, 8)}] Awarded ${xpEarned} XP — ` +
|
|
2241
|
+
`tools: ${toolCalls}, tokens: ${tokensUsed}, ` +
|
|
2242
|
+
`level: ${newLevel}, streak: ${newStreak}`,
|
|
2243
|
+
);
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2093
2247
|
function formatDuration(seconds) {
|
|
2094
2248
|
if (seconds < 60) return `${seconds}s`;
|
|
2095
2249
|
const mins = Math.floor(seconds / 60);
|