feat: add DB persistence, real-time WebSocket, goal preprocessor, and Android companion app
- Add device/session/step DB persistence in server agent loop - Add goal preprocessor for compound goals (e.g., "open YouTube and search X") - Add step-level logging to agent loop - Fix dashboard WebSocket auth (direct DB token lookup instead of auth.api) - Fix web layout to use locals.session.token instead of cookie - Add dashboard-ws.svelte.ts WebSocket store with auto-reconnect - Rewrite devices page with direct DB queries and real-time updates - Add device detail page with live step display and session history - Add Android companion app resources, themes, and screen capture consent Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,14 +25,21 @@ import {
|
||||
type LLMConfig,
|
||||
} from "./llm.js";
|
||||
import { createStuckDetector } from "./stuck.js";
|
||||
import { db } from "../db.js";
|
||||
import { agentSession, agentStep } from "../schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { UIElement, ActionDecision } from "@droidclaw/shared";
|
||||
|
||||
// ─── Public Types ───────────────────────────────────────────────
|
||||
|
||||
export interface AgentLoopOptions {
|
||||
deviceId: string;
|
||||
/** Persistent device ID from DB (for FK references) */
|
||||
persistentDeviceId?: string;
|
||||
userId: string;
|
||||
goal: string;
|
||||
/** Original goal before preprocessing (if different from goal) */
|
||||
originalGoal?: string;
|
||||
llmConfig: LLMConfig;
|
||||
maxSteps?: number;
|
||||
onStep?: (step: AgentStep) => void;
|
||||
@@ -211,8 +218,10 @@ export async function runAgentLoop(
|
||||
): Promise<AgentResult> {
|
||||
const {
|
||||
deviceId,
|
||||
persistentDeviceId,
|
||||
userId,
|
||||
goal,
|
||||
originalGoal,
|
||||
llmConfig,
|
||||
maxSteps = 30,
|
||||
onStep,
|
||||
@@ -230,12 +239,28 @@ export async function runAgentLoop(
|
||||
const recentActions: string[] = [];
|
||||
let lastActionFeedback = "";
|
||||
|
||||
// Persist session to DB
|
||||
if (persistentDeviceId) {
|
||||
try {
|
||||
await db.insert(agentSession).values({
|
||||
id: sessionId,
|
||||
userId,
|
||||
deviceId: persistentDeviceId,
|
||||
goal: originalGoal ?? goal,
|
||||
status: "running",
|
||||
stepsUsed: 0,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[Agent ${sessionId}] Failed to create DB session: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify dashboard that a goal has started
|
||||
sessions.notifyDashboard(userId, {
|
||||
type: "goal_started",
|
||||
sessionId,
|
||||
goal,
|
||||
deviceId,
|
||||
goal: originalGoal ?? goal,
|
||||
deviceId: persistentDeviceId ?? deviceId,
|
||||
});
|
||||
|
||||
let stepsUsed = 0;
|
||||
@@ -397,7 +422,10 @@ export async function runAgentLoop(
|
||||
recentActions.push(actionSig);
|
||||
if (recentActions.length > 8) recentActions.shift();
|
||||
|
||||
// ── 7. Done? ────────────────────────────────────────────
|
||||
// ── 7. Log + Done check ────────────────────────────────
|
||||
const reason = action.reason ?? "";
|
||||
console.log(`[Agent ${sessionId}] Step ${step + 1}: ${actionSig} — ${reason}`);
|
||||
|
||||
if (action.action === "done") {
|
||||
success = true;
|
||||
break;
|
||||
@@ -421,6 +449,23 @@ export async function runAgentLoop(
|
||||
screenHash,
|
||||
});
|
||||
|
||||
// ── 8b. Persist step to DB ────────────────────────────────
|
||||
const stepId = crypto.randomUUID();
|
||||
if (persistentDeviceId) {
|
||||
db.insert(agentStep)
|
||||
.values({
|
||||
id: stepId,
|
||||
sessionId,
|
||||
stepNumber: step + 1,
|
||||
screenHash,
|
||||
action: action as unknown as Record<string, unknown>,
|
||||
reasoning: action.reason ?? "",
|
||||
})
|
||||
.catch((err) =>
|
||||
console.error(`[Agent ${sessionId}] Failed to save step ${step + 1}: ${err}`)
|
||||
);
|
||||
}
|
||||
|
||||
// ── 9. Execute on device ────────────────────────────────
|
||||
const command = actionToCommand(action);
|
||||
try {
|
||||
@@ -431,8 +476,19 @@ export async function runAgentLoop(
|
||||
};
|
||||
const resultSuccess = result.success !== false;
|
||||
lastActionFeedback = `${actionSig} -> ${resultSuccess ? "OK" : "FAILED"}: ${result.error ?? result.data ?? "completed"}`;
|
||||
console.log(`[Agent ${sessionId}] Step ${step + 1} result: ${lastActionFeedback}`);
|
||||
// Update step result in DB
|
||||
if (persistentDeviceId) {
|
||||
db.update(agentStep).set({ result: lastActionFeedback }).where(eq(agentStep.id, stepId))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
lastActionFeedback = `${actionSig} -> FAILED: ${(err as Error).message}`;
|
||||
console.log(`[Agent ${sessionId}] Step ${step + 1} result: ${lastActionFeedback}`);
|
||||
if (persistentDeviceId) {
|
||||
db.update(agentStep).set({ result: lastActionFeedback }).where(eq(agentStep.id, stepId))
|
||||
.catch(() => {});
|
||||
}
|
||||
console.error(
|
||||
`[Agent ${sessionId}] Command error at step ${step + 1}: ${(err as Error).message}`
|
||||
);
|
||||
@@ -447,6 +503,20 @@ export async function runAgentLoop(
|
||||
|
||||
const result: AgentResult = { success, stepsUsed, sessionId };
|
||||
|
||||
// Update session in DB
|
||||
if (persistentDeviceId) {
|
||||
db.update(agentSession)
|
||||
.set({
|
||||
status: success ? "completed" : "failed",
|
||||
stepsUsed,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(eq(agentSession.id, sessionId))
|
||||
.catch((err) =>
|
||||
console.error(`[Agent ${sessionId}] Failed to update DB session: ${err}`)
|
||||
);
|
||||
}
|
||||
|
||||
sessions.notifyDashboard(userId, {
|
||||
type: "goal_completed",
|
||||
sessionId,
|
||||
|
||||
166
server/src/agent/preprocessor.ts
Normal file
166
server/src/agent/preprocessor.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Goal preprocessor for DroidClaw agent loop.
|
||||
*
|
||||
* Intercepts simple goals (like "open youtube") and executes direct
|
||||
* actions before the LLM loop starts. This avoids wasting 20 steps
|
||||
* on what should be a 2-step task, especially with weaker LLMs that
|
||||
* navigate via UI instead of using programmatic launch commands.
|
||||
*/
|
||||
|
||||
import { sessions } from "../ws/sessions.js";
|
||||
|
||||
// ─── App Name → Package Name Map ────────────────────────────
|
||||
|
||||
const APP_PACKAGES: Record<string, string> = {
|
||||
youtube: "com.google.android.youtube",
|
||||
gmail: "com.google.android.gm",
|
||||
chrome: "com.android.chrome",
|
||||
maps: "com.google.android.apps.maps",
|
||||
photos: "com.google.android.apps.photos",
|
||||
drive: "com.google.android.apps.docs",
|
||||
calendar: "com.google.android.calendar",
|
||||
contacts: "com.google.android.contacts",
|
||||
messages: "com.google.android.apps.messaging",
|
||||
phone: "com.google.android.dialer",
|
||||
clock: "com.google.android.deskclock",
|
||||
calculator: "com.google.android.calculator",
|
||||
camera: "com.android.camera",
|
||||
settings: "com.android.settings",
|
||||
files: "com.google.android.apps.nbu.files",
|
||||
play: "com.android.vending",
|
||||
"play store": "com.android.vending",
|
||||
"google play": "com.android.vending",
|
||||
whatsapp: "com.whatsapp",
|
||||
telegram: "org.telegram.messenger",
|
||||
instagram: "com.instagram.android",
|
||||
facebook: "com.facebook.katana",
|
||||
twitter: "com.twitter.android",
|
||||
x: "com.twitter.android",
|
||||
spotify: "com.spotify.music",
|
||||
netflix: "com.netflix.mediaclient",
|
||||
tiktok: "com.zhiliaoapp.musically",
|
||||
snapchat: "com.snapchat.android",
|
||||
reddit: "com.reddit.frontpage",
|
||||
discord: "com.discord",
|
||||
slack: "com.Slack",
|
||||
zoom: "us.zoom.videomeetings",
|
||||
teams: "com.microsoft.teams",
|
||||
outlook: "com.microsoft.office.outlook",
|
||||
"google meet": "com.google.android.apps.tachyon",
|
||||
meet: "com.google.android.apps.tachyon",
|
||||
keep: "com.google.android.keep",
|
||||
notes: "com.google.android.keep",
|
||||
sheets: "com.google.android.apps.docs.editors.sheets",
|
||||
docs: "com.google.android.apps.docs.editors.docs",
|
||||
slides: "com.google.android.apps.docs.editors.slides",
|
||||
translate: "com.google.android.apps.translate",
|
||||
weather: "com.google.android.apps.weather",
|
||||
news: "com.google.android.apps.magazines",
|
||||
podcasts: "com.google.android.apps.podcasts",
|
||||
fitbit: "com.fitbit.FitbitMobile",
|
||||
uber: "com.ubercab",
|
||||
lyft: "me.lyft.android",
|
||||
amazon: "com.amazon.mShop.android.shopping",
|
||||
ebay: "com.ebay.mobile",
|
||||
linkedin: "com.linkedin.android",
|
||||
pinterest: "com.pinterest",
|
||||
twitch: "tv.twitch.android.app",
|
||||
};
|
||||
|
||||
// ─── Goal Pattern Matching ───────────────────────────────────
|
||||
|
||||
interface PreprocessResult {
|
||||
/** Whether the preprocessor handled the goal */
|
||||
handled: boolean;
|
||||
/** Command sent to device (if any) */
|
||||
command?: Record<string, unknown>;
|
||||
/** Updated goal text for the LLM (optional) */
|
||||
refinedGoal?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find a known app name at the start of a goal string.
|
||||
* Returns the package name and remaining text, or null.
|
||||
*/
|
||||
function matchAppName(lower: string): { pkg: string; appName: string; rest: string } | null {
|
||||
// Try longest app names first (e.g. "google meet" before "meet")
|
||||
const sorted = Object.keys(APP_PACKAGES).sort((a, b) => b.length - a.length);
|
||||
|
||||
for (const name of sorted) {
|
||||
// Match: "open <app> [app] and <rest>" or "open <app> [app]"
|
||||
const pattern = new RegExp(
|
||||
`^(?:open|launch|start|go to)\\s+(?:the\\s+)?${escapeRegex(name)}(?:\\s+app)?(?:\\s+(?:and|then)\\s+(.+))?$`
|
||||
);
|
||||
const m = lower.match(pattern);
|
||||
if (m) {
|
||||
return { pkg: APP_PACKAGES[name], appName: name, rest: m[1]?.trim() ?? "" };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to preprocess a goal before the LLM loop.
|
||||
*
|
||||
* Three outcomes:
|
||||
* 1. { handled: true, refinedGoal: undefined } — goal fully handled (pure "open X")
|
||||
* 2. { handled: true, refinedGoal: "..." } — app launched, LLM continues with refined goal
|
||||
* 3. { handled: false } — preprocessor can't help, LLM gets full goal
|
||||
*/
|
||||
export async function preprocessGoal(
|
||||
deviceId: string,
|
||||
goal: string
|
||||
): Promise<PreprocessResult> {
|
||||
const lower = goal.toLowerCase().trim();
|
||||
|
||||
// ── Pattern: "open <app> [and <remaining>]" ───────────────
|
||||
const appMatch = matchAppName(lower);
|
||||
|
||||
if (appMatch) {
|
||||
try {
|
||||
await sessions.sendCommand(deviceId, {
|
||||
type: "launch",
|
||||
packageName: appMatch.pkg,
|
||||
});
|
||||
|
||||
if (appMatch.rest) {
|
||||
// Compound goal: app launched, pass remaining instructions to LLM
|
||||
console.log(`[Preprocessor] Launched ${appMatch.pkg}, refined goal: ${appMatch.rest}`);
|
||||
return {
|
||||
handled: true,
|
||||
command: { type: "launch", packageName: appMatch.pkg },
|
||||
refinedGoal: appMatch.rest,
|
||||
};
|
||||
}
|
||||
|
||||
// Pure "open X" — fully handled
|
||||
console.log(`[Preprocessor] Launched ${appMatch.pkg} for goal: ${goal}`);
|
||||
return { handled: true, command: { type: "launch", packageName: appMatch.pkg } };
|
||||
} catch (err) {
|
||||
console.warn(`[Preprocessor] Failed to launch ${appMatch.pkg}: ${err}`);
|
||||
// Fall through to LLM
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pattern: "open <url>" or "go to <url>" ────────────────
|
||||
const urlMatch = lower.match(
|
||||
/^(?:open|go to|visit|navigate to)\s+(https?:\/\/\S+)$/
|
||||
);
|
||||
|
||||
if (urlMatch) {
|
||||
const url = urlMatch[1];
|
||||
try {
|
||||
await sessions.sendCommand(deviceId, { type: "open_url", url });
|
||||
console.log(`[Preprocessor] Opened URL: ${url}`);
|
||||
return { handled: true, command: { type: "open_url", url } };
|
||||
} catch (err) {
|
||||
console.warn(`[Preprocessor] Failed to open URL: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { handled: false };
|
||||
}
|
||||
@@ -2,10 +2,18 @@ import { betterAuth } from "better-auth";
|
||||
import { apiKey } from "better-auth/plugins";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { db } from "./db.js";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema,
|
||||
}),
|
||||
plugins: [apiKey()],
|
||||
plugins: [
|
||||
apiKey({
|
||||
rateLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import { env } from "./env.js";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
const client = postgres(env.DATABASE_URL);
|
||||
export const db = drizzle(client);
|
||||
export const db = drizzle(client, { schema });
|
||||
|
||||
@@ -63,6 +63,8 @@ const server = Bun.serve<WebSocketData>({
|
||||
return app.fetch(req);
|
||||
},
|
||||
websocket: {
|
||||
idleTimeout: 120,
|
||||
sendPings: true,
|
||||
open(ws) {
|
||||
console.log(`WebSocket opened: ${ws.data.path}`);
|
||||
},
|
||||
@@ -73,12 +75,17 @@ const server = Bun.serve<WebSocketData>({
|
||||
: new TextDecoder().decode(message);
|
||||
|
||||
if (ws.data.path === "/ws/device") {
|
||||
handleDeviceMessage(ws, raw);
|
||||
handleDeviceMessage(ws, raw).catch((err) => {
|
||||
console.error(`Device message handler error: ${err}`);
|
||||
});
|
||||
} else if (ws.data.path === "/ws/dashboard") {
|
||||
handleDashboardMessage(ws, raw);
|
||||
handleDashboardMessage(ws, raw).catch((err) => {
|
||||
console.error(`Dashboard message handler error: ${err}`);
|
||||
});
|
||||
}
|
||||
},
|
||||
close(ws) {
|
||||
close(ws, code, reason) {
|
||||
console.log(`WebSocket closed: ${ws.data.path} device=${ws.data.deviceId ?? "unknown"} code=${code} reason=${reason}`);
|
||||
if (ws.data.path === "/ws/device") {
|
||||
handleDeviceClose(ws);
|
||||
} else if (ws.data.path === "/ws/dashboard") {
|
||||
|
||||
@@ -1,22 +1,80 @@
|
||||
import { Hono } from "hono";
|
||||
import { sessionMiddleware, type AuthEnv } from "../middleware/auth.js";
|
||||
import { sessions } from "../ws/sessions.js";
|
||||
import { db } from "../db.js";
|
||||
import { device, agentSession, agentStep } from "../schema.js";
|
||||
import { eq, desc, and } from "drizzle-orm";
|
||||
|
||||
const devices = new Hono<AuthEnv>();
|
||||
devices.use("*", sessionMiddleware);
|
||||
|
||||
devices.get("/", (c) => {
|
||||
/** List all devices for the authenticated user (from DB, includes offline) */
|
||||
devices.get("/", async (c) => {
|
||||
const user = c.get("user");
|
||||
const userDevices = sessions.getDevicesForUser(user.id);
|
||||
|
||||
const dbDevices = await db
|
||||
.select()
|
||||
.from(device)
|
||||
.where(eq(device.userId, user.id))
|
||||
.orderBy(desc(device.lastSeen));
|
||||
|
||||
return c.json(
|
||||
userDevices.map((d) => ({
|
||||
deviceId: d.deviceId,
|
||||
name: d.deviceInfo?.model ?? "Unknown Device",
|
||||
dbDevices.map((d) => ({
|
||||
deviceId: d.id,
|
||||
name: d.name,
|
||||
status: d.status,
|
||||
deviceInfo: d.deviceInfo,
|
||||
connectedAt: d.connectedAt.toISOString(),
|
||||
lastSeen: d.lastSeen?.toISOString() ?? d.createdAt.toISOString(),
|
||||
connectedAt: d.createdAt.toISOString(),
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
/** List agent sessions for a specific device */
|
||||
devices.get("/:deviceId/sessions", async (c) => {
|
||||
const user = c.get("user");
|
||||
const deviceId = c.req.param("deviceId");
|
||||
|
||||
const deviceSessions = await db
|
||||
.select()
|
||||
.from(agentSession)
|
||||
.where(
|
||||
and(
|
||||
eq(agentSession.deviceId, deviceId),
|
||||
eq(agentSession.userId, user.id)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(agentSession.startedAt))
|
||||
.limit(50);
|
||||
|
||||
return c.json(deviceSessions);
|
||||
});
|
||||
|
||||
/** List steps for a specific agent session */
|
||||
devices.get("/:deviceId/sessions/:sessionId/steps", async (c) => {
|
||||
const user = c.get("user");
|
||||
const sessionId = c.req.param("sessionId");
|
||||
|
||||
// Verify session belongs to user
|
||||
const sess = await db
|
||||
.select()
|
||||
.from(agentSession)
|
||||
.where(
|
||||
and(eq(agentSession.id, sessionId), eq(agentSession.userId, user.id))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (sess.length === 0) {
|
||||
return c.json({ error: "Session not found" }, 404);
|
||||
}
|
||||
|
||||
const steps = await db
|
||||
.select()
|
||||
.from(agentStep)
|
||||
.where(eq(agentStep.sessionId, sessionId))
|
||||
.orderBy(agentStep.stepNumber);
|
||||
|
||||
return c.json(steps);
|
||||
});
|
||||
|
||||
export { devices };
|
||||
|
||||
142
server/src/schema.ts
Normal file
142
server/src/schema.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { pgTable, text, timestamp, boolean, integer, jsonb } from "drizzle-orm/pg-core";
|
||||
|
||||
export const user = pgTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const session = pgTable("session", {
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const account = pgTable("account", {
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const verification = pgTable("verification", {
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const apikey = pgTable("apikey", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name"),
|
||||
start: text("start"),
|
||||
prefix: text("prefix"),
|
||||
key: text("key").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
refillInterval: integer("refill_interval"),
|
||||
refillAmount: integer("refill_amount"),
|
||||
lastRefillAt: timestamp("last_refill_at"),
|
||||
enabled: boolean("enabled").default(true),
|
||||
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
|
||||
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
|
||||
rateLimitMax: integer("rate_limit_max").default(10),
|
||||
requestCount: integer("request_count").default(0),
|
||||
remaining: integer("remaining"),
|
||||
lastRequest: timestamp("last_request"),
|
||||
expiresAt: timestamp("expires_at"),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
updatedAt: timestamp("updated_at").notNull(),
|
||||
permissions: text("permissions"),
|
||||
metadata: text("metadata"),
|
||||
});
|
||||
|
||||
export const llmConfig = pgTable("llm_config", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
provider: text("provider").notNull(),
|
||||
apiKey: text("api_key").notNull(),
|
||||
model: text("model"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const device = pgTable("device", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
lastSeen: timestamp("last_seen"),
|
||||
status: text("status").notNull().default("offline"),
|
||||
deviceInfo: jsonb("device_info"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const agentSession = pgTable("agent_session", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
deviceId: text("device_id")
|
||||
.notNull()
|
||||
.references(() => device.id, { onDelete: "cascade" }),
|
||||
goal: text("goal").notNull(),
|
||||
status: text("status").notNull().default("running"),
|
||||
stepsUsed: integer("steps_used").default(0),
|
||||
startedAt: timestamp("started_at").defaultNow().notNull(),
|
||||
completedAt: timestamp("completed_at"),
|
||||
});
|
||||
|
||||
export const agentStep = pgTable("agent_step", {
|
||||
id: text("id").primaryKey(),
|
||||
sessionId: text("session_id")
|
||||
.notNull()
|
||||
.references(() => agentSession.id, { onDelete: "cascade" }),
|
||||
stepNumber: integer("step_number").notNull(),
|
||||
screenHash: text("screen_hash"),
|
||||
action: jsonb("action"),
|
||||
reasoning: text("reasoning"),
|
||||
result: text("result"),
|
||||
timestamp: timestamp("timestamp").defaultNow().notNull(),
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import { auth } from "../auth.js";
|
||||
import { db } from "../db.js";
|
||||
import { session as sessionTable } from "../schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { sessions, type WebSocketData } from "./sessions.js";
|
||||
|
||||
interface DashboardAuthMessage {
|
||||
@@ -28,21 +30,25 @@ export async function handleDashboardMessage(
|
||||
|
||||
if (msg.type === "auth") {
|
||||
try {
|
||||
// Verify the session token by constructing a request with the cookie header
|
||||
const sessionResult = await auth.api.getSession({
|
||||
headers: new Headers({
|
||||
cookie: `better-auth.session_token=${msg.token}`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!sessionResult) {
|
||||
ws.send(
|
||||
JSON.stringify({ type: "auth_error", message: "Invalid session" })
|
||||
);
|
||||
const token = msg.token;
|
||||
if (!token) {
|
||||
ws.send(JSON.stringify({ type: "auth_error", message: "No token" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = sessionResult.user.id;
|
||||
// Look up session directly in DB
|
||||
const rows = await db
|
||||
.select({ userId: sessionTable.userId })
|
||||
.from(sessionTable)
|
||||
.where(eq(sessionTable.token, token))
|
||||
.limit(1);
|
||||
|
||||
if (rows.length === 0) {
|
||||
ws.send(JSON.stringify({ type: "auth_error", message: "Invalid session" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = rows[0].userId;
|
||||
|
||||
// Mark connection as authenticated
|
||||
ws.data.authenticated = true;
|
||||
|
||||
@@ -1,7 +1,63 @@
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import type { DeviceMessage } from "@droidclaw/shared";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { auth } from "../auth.js";
|
||||
import { db } from "../db.js";
|
||||
import { llmConfig, device, agentSession, agentStep } from "../schema.js";
|
||||
import { sessions, type WebSocketData } from "./sessions.js";
|
||||
import { runAgentLoop } from "../agent/loop.js";
|
||||
import { preprocessGoal } from "../agent/preprocessor.js";
|
||||
import type { LLMConfig } from "../agent/llm.js";
|
||||
|
||||
/** Track running agent sessions to prevent duplicates per device */
|
||||
const activeSessions = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Send a JSON message to a device WebSocket (safe — catches send errors).
|
||||
*/
|
||||
function sendToDevice(ws: ServerWebSocket<WebSocketData>, msg: Record<string, unknown>) {
|
||||
try {
|
||||
ws.send(JSON.stringify(msg));
|
||||
} catch {
|
||||
// device disconnected
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a device record in the DB. Returns the persistent device ID.
|
||||
* Matches on userId + model name so reconnects reuse the same record.
|
||||
*/
|
||||
async function upsertDevice(
|
||||
userId: string,
|
||||
name: string,
|
||||
deviceInfo?: Record<string, unknown>
|
||||
): Promise<string> {
|
||||
// Try to find existing device by userId + name
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(device)
|
||||
.where(and(eq(device.userId, userId), eq(device.name, name)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(device)
|
||||
.set({ status: "online", lastSeen: new Date(), deviceInfo: deviceInfo ?? null })
|
||||
.where(eq(device.id, existing[0].id));
|
||||
return existing[0].id;
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
await db.insert(device).values({
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
status: "online",
|
||||
lastSeen: new Date(),
|
||||
deviceInfo: deviceInfo ?? null,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming message from an Android device WebSocket.
|
||||
@@ -39,10 +95,29 @@ export async function handleDeviceMessage(
|
||||
const deviceId = crypto.randomUUID();
|
||||
const userId = result.key.userId;
|
||||
|
||||
// Build device name from device info
|
||||
const name = msg.deviceInfo
|
||||
? `${msg.deviceInfo.model} (Android ${msg.deviceInfo.androidVersion})`
|
||||
: "Unknown Device";
|
||||
|
||||
// Persist device to DB (upsert by userId + name)
|
||||
let persistentDeviceId: string;
|
||||
try {
|
||||
persistentDeviceId = await upsertDevice(
|
||||
userId,
|
||||
name,
|
||||
msg.deviceInfo as unknown as Record<string, unknown>
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`[Device] Failed to upsert device record: ${err}`);
|
||||
persistentDeviceId = deviceId; // fallback to ephemeral ID
|
||||
}
|
||||
|
||||
// Mark connection as authenticated
|
||||
ws.data.authenticated = true;
|
||||
ws.data.userId = userId;
|
||||
ws.data.deviceId = deviceId;
|
||||
ws.data.persistentDeviceId = persistentDeviceId;
|
||||
|
||||
// Register device in session manager
|
||||
sessions.addDevice({
|
||||
@@ -57,17 +132,13 @@ export async function handleDeviceMessage(
|
||||
ws.send(JSON.stringify({ type: "auth_ok", deviceId }));
|
||||
|
||||
// Notify dashboard subscribers
|
||||
const name = msg.deviceInfo
|
||||
? `${msg.deviceInfo.model} (Android ${msg.deviceInfo.androidVersion})`
|
||||
: deviceId;
|
||||
|
||||
sessions.notifyDashboard(userId, {
|
||||
type: "device_online",
|
||||
deviceId,
|
||||
deviceId: persistentDeviceId,
|
||||
name,
|
||||
});
|
||||
|
||||
console.log(`Device authenticated: ${deviceId} for user ${userId}`);
|
||||
console.log(`Device authenticated: ${deviceId} (db: ${persistentDeviceId}) for user ${userId}`);
|
||||
} catch (err) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -91,7 +162,6 @@ export async function handleDeviceMessage(
|
||||
|
||||
switch (msg.type) {
|
||||
case "screen": {
|
||||
// Device is reporting its screen state in response to a get_screen command
|
||||
sessions.resolveRequest(msg.requestId, {
|
||||
type: "screen",
|
||||
elements: msg.elements,
|
||||
@@ -102,7 +172,6 @@ export async function handleDeviceMessage(
|
||||
}
|
||||
|
||||
case "result": {
|
||||
// Device is reporting the result of an action command
|
||||
sessions.resolveRequest(msg.requestId, {
|
||||
type: "result",
|
||||
success: msg.success,
|
||||
@@ -113,16 +182,148 @@ export async function handleDeviceMessage(
|
||||
}
|
||||
|
||||
case "goal": {
|
||||
// Device is requesting a goal to be executed
|
||||
// Task 6 wires up the agent loop here
|
||||
console.log(
|
||||
`Goal request from device ${ws.data.deviceId}: ${msg.text}`
|
||||
);
|
||||
const deviceId = ws.data.deviceId!;
|
||||
const userId = ws.data.userId!;
|
||||
const persistentDeviceId = ws.data.persistentDeviceId!;
|
||||
const goal = msg.text;
|
||||
|
||||
if (!goal) {
|
||||
sendToDevice(ws, { type: "goal_failed", message: "Empty goal" });
|
||||
break;
|
||||
}
|
||||
|
||||
if (activeSessions.has(deviceId)) {
|
||||
sendToDevice(ws, { type: "goal_failed", message: "Agent already running on this device" });
|
||||
break;
|
||||
}
|
||||
|
||||
// Fetch user's LLM config
|
||||
let userLlmConfig: LLMConfig;
|
||||
try {
|
||||
const configs = await db
|
||||
.select()
|
||||
.from(llmConfig)
|
||||
.where(eq(llmConfig.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (configs.length === 0) {
|
||||
sendToDevice(ws, { type: "goal_failed", message: "No LLM provider configured. Set it up in the web dashboard Settings." });
|
||||
break;
|
||||
}
|
||||
|
||||
const cfg = configs[0];
|
||||
userLlmConfig = {
|
||||
provider: cfg.provider,
|
||||
apiKey: cfg.apiKey,
|
||||
model: cfg.model ?? undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`[Agent] Failed to fetch LLM config for user ${userId}:`, err);
|
||||
sendToDevice(ws, { type: "goal_failed", message: "Failed to load LLM configuration" });
|
||||
break;
|
||||
}
|
||||
|
||||
// Preprocess: handle simple goals directly, or extract "open X" prefix
|
||||
let effectiveGoal = goal;
|
||||
try {
|
||||
const preResult = await preprocessGoal(deviceId, goal);
|
||||
if (preResult.handled) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
if (preResult.refinedGoal) {
|
||||
effectiveGoal = preResult.refinedGoal;
|
||||
sendToDevice(ws, {
|
||||
type: "step",
|
||||
step: 0,
|
||||
action: preResult.command,
|
||||
reasoning: "Preprocessor: launched app directly",
|
||||
});
|
||||
} else {
|
||||
// Pure "open X" — fully handled. Persist to DB then return.
|
||||
const sessionId = crypto.randomUUID();
|
||||
try {
|
||||
await db.insert(agentSession).values({
|
||||
id: sessionId,
|
||||
userId,
|
||||
deviceId: persistentDeviceId,
|
||||
goal,
|
||||
status: "completed",
|
||||
stepsUsed: 1,
|
||||
completedAt: new Date(),
|
||||
});
|
||||
await db.insert(agentStep).values({
|
||||
id: crypto.randomUUID(),
|
||||
sessionId,
|
||||
stepNumber: 1,
|
||||
action: preResult.command ?? null,
|
||||
reasoning: `Preprocessor: direct ${preResult.command?.type} action`,
|
||||
result: "OK",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[DB] Failed to save preprocessor session: ${err}`);
|
||||
}
|
||||
|
||||
sendToDevice(ws, { type: "goal_started", sessionId, goal });
|
||||
sendToDevice(ws, {
|
||||
type: "step",
|
||||
step: 1,
|
||||
action: preResult.command,
|
||||
reasoning: `Preprocessor: direct ${preResult.command?.type} action`,
|
||||
});
|
||||
sendToDevice(ws, { type: "goal_completed", success: true, stepsUsed: 1 });
|
||||
|
||||
sessions.notifyDashboard(userId, { type: "goal_completed", sessionId, success: true, stepsUsed: 1 });
|
||||
|
||||
console.log(`[Preprocessor] Goal handled directly: ${goal}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[Preprocessor] Error (falling through to LLM): ${err}`);
|
||||
}
|
||||
|
||||
console.log(`[Agent] Starting goal for device ${deviceId}: ${effectiveGoal}${effectiveGoal !== goal ? ` (original: ${goal})` : ""}`);
|
||||
activeSessions.set(deviceId, goal);
|
||||
|
||||
sendToDevice(ws, { type: "goal_started", sessionId: deviceId, goal });
|
||||
|
||||
// Run agent loop in background (DB persistence happens inside the loop)
|
||||
runAgentLoop({
|
||||
deviceId,
|
||||
persistentDeviceId,
|
||||
userId,
|
||||
goal: effectiveGoal,
|
||||
originalGoal: goal !== effectiveGoal ? goal : undefined,
|
||||
llmConfig: userLlmConfig,
|
||||
onStep(step) {
|
||||
sendToDevice(ws, {
|
||||
type: "step",
|
||||
step: step.stepNumber,
|
||||
action: step.action,
|
||||
reasoning: step.reasoning,
|
||||
});
|
||||
},
|
||||
onComplete(result) {
|
||||
activeSessions.delete(deviceId);
|
||||
sendToDevice(ws, {
|
||||
type: "goal_completed",
|
||||
success: result.success,
|
||||
stepsUsed: result.stepsUsed,
|
||||
});
|
||||
console.log(
|
||||
`[Agent] Completed on ${deviceId}: ${result.success ? "success" : "incomplete"} in ${result.stepsUsed} steps`
|
||||
);
|
||||
},
|
||||
}).catch((err) => {
|
||||
activeSessions.delete(deviceId);
|
||||
sendToDevice(ws, { type: "goal_failed", message: String(err) });
|
||||
console.error(`[Agent] Error on ${deviceId}:`, err);
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "pong": {
|
||||
// Heartbeat response — no-op
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -141,15 +342,24 @@ export async function handleDeviceMessage(
|
||||
export function handleDeviceClose(
|
||||
ws: ServerWebSocket<WebSocketData>
|
||||
): void {
|
||||
const { deviceId, userId } = ws.data;
|
||||
const { deviceId, userId, persistentDeviceId } = ws.data;
|
||||
if (!deviceId) return;
|
||||
|
||||
activeSessions.delete(deviceId);
|
||||
sessions.removeDevice(deviceId);
|
||||
|
||||
// Update device status in DB
|
||||
if (persistentDeviceId) {
|
||||
db.update(device)
|
||||
.set({ status: "offline", lastSeen: new Date() })
|
||||
.where(eq(device.id, persistentDeviceId))
|
||||
.catch((err) => console.error(`[DB] Failed to update device status: ${err}`));
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
sessions.notifyDashboard(userId, {
|
||||
type: "device_offline",
|
||||
deviceId,
|
||||
deviceId: persistentDeviceId ?? deviceId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface WebSocketData {
|
||||
path: "/ws/device" | "/ws/dashboard";
|
||||
userId?: string;
|
||||
deviceId?: string;
|
||||
/** Persistent device ID from the `device` DB table (survives reconnects) */
|
||||
persistentDeviceId?: string;
|
||||
authenticated: boolean;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user