assistme 0.1.13 → 0.1.15
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/dist/{chunk-QXT7DH44.js → chunk-ERK6A6GH.js} +16 -0
- package/dist/index.js +180 -13
- package/dist/{supabase-QU7MFNDI.js → supabase-5QTM5WBI.js} +3 -1
- package/package.json +1 -1
- package/src/agent/processor.ts +51 -4
- package/src/agent/scheduler.ts +16 -39
- package/src/db/supabase.test.ts +98 -41
- package/src/db/supabase.ts +49 -0
- package/src/tools/browser.ts +73 -0
- package/src/tools/index.ts +86 -2
- package/src/utils/config.test.ts +4 -6
|
@@ -315,6 +315,21 @@ async function failTask(messageId, errorMessage) {
|
|
|
315
315
|
});
|
|
316
316
|
if (error) log.error(`Failed to update task status: ${error.message}`);
|
|
317
317
|
}
|
|
318
|
+
async function getConversationHistory(conversationId, excludeMessageId, limit = 20) {
|
|
319
|
+
const sb = getSupabase();
|
|
320
|
+
const { data, error } = await sb.from("conversation_messages").select("id, role, content, status, metadata, created_at").eq("conversation_id", conversationId).in("status", ["completed", "failed"]).neq("id", excludeMessageId).order("created_at", { ascending: false }).limit(limit);
|
|
321
|
+
if (error) {
|
|
322
|
+
log.debug(`Failed to fetch conversation history: ${error.message}`);
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
const rows = data || [];
|
|
326
|
+
return rows.reverse().map((row) => {
|
|
327
|
+
const prompt = row.metadata?.prompt || "";
|
|
328
|
+
const content = row.content || "";
|
|
329
|
+
const response = row.status === "failed" ? `[Task failed] ${content}` : content;
|
|
330
|
+
return { prompt, response };
|
|
331
|
+
}).filter((entry) => entry.prompt && entry.response);
|
|
332
|
+
}
|
|
318
333
|
var eventSequence = 0;
|
|
319
334
|
function resetEventSequence() {
|
|
320
335
|
eventSequence = 0;
|
|
@@ -371,6 +386,7 @@ export {
|
|
|
371
386
|
claimTask,
|
|
372
387
|
completeTask,
|
|
373
388
|
failTask,
|
|
389
|
+
getConversationHistory,
|
|
374
390
|
resetEventSequence,
|
|
375
391
|
emitEvent,
|
|
376
392
|
emitEvents
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
failTask,
|
|
11
11
|
getConfig,
|
|
12
12
|
getConfigPath,
|
|
13
|
+
getConversationHistory,
|
|
13
14
|
getCurrentUserId,
|
|
14
15
|
getOrCreateCliConversation,
|
|
15
16
|
getSupabase,
|
|
@@ -24,7 +25,7 @@ import {
|
|
|
24
25
|
setLogLevel,
|
|
25
26
|
setSessionBusy,
|
|
26
27
|
updateHeartbeat
|
|
27
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-ERK6A6GH.js";
|
|
28
29
|
|
|
29
30
|
// src/index.ts
|
|
30
31
|
import { Command } from "commander";
|
|
@@ -63,14 +64,15 @@ function getNextRunTime(cronExpr, timezone, fromDate) {
|
|
|
63
64
|
const daysOfMonth = parseField(domExpr, 1, 31);
|
|
64
65
|
const months = parseField(monExpr, 1, 12);
|
|
65
66
|
const daysOfWeek = parseField(dowExpr, 0, 6);
|
|
67
|
+
const useUTC = timezone === "UTC";
|
|
66
68
|
const candidate = new Date(now.getTime() + 6e4);
|
|
67
69
|
candidate.setSeconds(0, 0);
|
|
68
70
|
for (let i = 0; i < 527040; i++) {
|
|
69
|
-
const m = candidate.getMinutes();
|
|
70
|
-
const h = candidate.getHours();
|
|
71
|
-
const dom = candidate.getDate();
|
|
72
|
-
const mon = candidate.getMonth() + 1;
|
|
73
|
-
const dow = candidate.getDay();
|
|
71
|
+
const m = useUTC ? candidate.getUTCMinutes() : candidate.getMinutes();
|
|
72
|
+
const h = useUTC ? candidate.getUTCHours() : candidate.getHours();
|
|
73
|
+
const dom = useUTC ? candidate.getUTCDate() : candidate.getDate();
|
|
74
|
+
const mon = (useUTC ? candidate.getUTCMonth() : candidate.getMonth()) + 1;
|
|
75
|
+
const dow = useUTC ? candidate.getUTCDay() : candidate.getDay();
|
|
74
76
|
if (minutes.includes(m) && hours.includes(h) && daysOfMonth.includes(dom) && months.includes(mon) && (dowExpr === "*" || daysOfWeek.includes(dow))) {
|
|
75
77
|
return candidate;
|
|
76
78
|
}
|
|
@@ -849,6 +851,76 @@ URL: ${info.url}`;
|
|
|
849
851
|
isConnected() {
|
|
850
852
|
return this.connected && this.ws?.readyState === WebSocket.OPEN;
|
|
851
853
|
}
|
|
854
|
+
// ── Login Detection ────────────────────────────────────────────
|
|
855
|
+
/**
|
|
856
|
+
* Detect if the current page appears to be a login/authentication page.
|
|
857
|
+
* Checks URL patterns, password input fields, and login form actions.
|
|
858
|
+
*/
|
|
859
|
+
async detectLoginPage() {
|
|
860
|
+
try {
|
|
861
|
+
const result = await this.send("Runtime.evaluate", {
|
|
862
|
+
expression: `
|
|
863
|
+
(function() {
|
|
864
|
+
var url = window.location.href.toLowerCase();
|
|
865
|
+
|
|
866
|
+
// URL-based detection
|
|
867
|
+
var loginPatterns = [
|
|
868
|
+
'/login', '/signin', '/sign-in', '/sign_in',
|
|
869
|
+
'/auth/', '/sso/', '/oauth/', '/session/new',
|
|
870
|
+
'/accounts/login', '/users/sign_in',
|
|
871
|
+
'accounts.google.com', 'login.microsoftonline.com',
|
|
872
|
+
'github.com/login', 'github.com/session',
|
|
873
|
+
'login.live.com', 'appleid.apple.com'
|
|
874
|
+
];
|
|
875
|
+
for (var i = 0; i < loginPatterns.length; i++) {
|
|
876
|
+
if (url.indexOf(loginPatterns[i]) !== -1) {
|
|
877
|
+
return JSON.stringify({
|
|
878
|
+
isLoginPage: true,
|
|
879
|
+
reason: 'URL contains login pattern: ' + loginPatterns[i]
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Password input detection (visible only)
|
|
885
|
+
var passwordInputs = document.querySelectorAll('input[type="password"]');
|
|
886
|
+
for (var j = 0; j < passwordInputs.length; j++) {
|
|
887
|
+
var input = passwordInputs[j];
|
|
888
|
+
var rect = input.getBoundingClientRect();
|
|
889
|
+
var style = window.getComputedStyle(input);
|
|
890
|
+
if (rect.width > 0 && rect.height > 0 &&
|
|
891
|
+
style.display !== 'none' && style.visibility !== 'hidden') {
|
|
892
|
+
return JSON.stringify({
|
|
893
|
+
isLoginPage: true,
|
|
894
|
+
reason: 'Page contains visible password input field'
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Login form action detection
|
|
900
|
+
var formSelectors = [
|
|
901
|
+
'form[action*="login"]', 'form[action*="signin"]',
|
|
902
|
+
'form[action*="session"]', 'form[action*="auth"]',
|
|
903
|
+
'form[action*="authenticate"]'
|
|
904
|
+
];
|
|
905
|
+
var loginForms = document.querySelectorAll(formSelectors.join(','));
|
|
906
|
+
if (loginForms.length > 0) {
|
|
907
|
+
return JSON.stringify({
|
|
908
|
+
isLoginPage: true,
|
|
909
|
+
reason: 'Page contains login form'
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
return JSON.stringify({ isLoginPage: false, reason: '' });
|
|
914
|
+
})()
|
|
915
|
+
`,
|
|
916
|
+
returnByValue: true
|
|
917
|
+
});
|
|
918
|
+
const value = result.result?.value;
|
|
919
|
+
return JSON.parse(value || '{"isLoginPage":false,"reason":""}');
|
|
920
|
+
} catch {
|
|
921
|
+
return { isLoginPage: false, reason: "" };
|
|
922
|
+
}
|
|
923
|
+
}
|
|
852
924
|
};
|
|
853
925
|
function findChromePath() {
|
|
854
926
|
const os = platform();
|
|
@@ -2275,6 +2347,60 @@ ${stderr}` : "";
|
|
|
2275
2347
|
}
|
|
2276
2348
|
|
|
2277
2349
|
// src/tools/index.ts
|
|
2350
|
+
async function detectAndHandleLogin(browser) {
|
|
2351
|
+
const detection = await browser.detectLoginPage();
|
|
2352
|
+
if (!detection.isLoginPage) return null;
|
|
2353
|
+
const pageInfo = await browser.getPageInfo();
|
|
2354
|
+
let siteName;
|
|
2355
|
+
try {
|
|
2356
|
+
siteName = new URL(pageInfo.url).hostname;
|
|
2357
|
+
} catch {
|
|
2358
|
+
siteName = pageInfo.url;
|
|
2359
|
+
}
|
|
2360
|
+
console.log("\n");
|
|
2361
|
+
console.log("\u2501".repeat(60));
|
|
2362
|
+
console.log(" \u{1F510} LOGIN REQUIRED");
|
|
2363
|
+
console.log("\u2501".repeat(60));
|
|
2364
|
+
console.log(` ${detection.reason}`);
|
|
2365
|
+
console.log(` Site: ${siteName}`);
|
|
2366
|
+
console.log(` Please log in using the browser window.`);
|
|
2367
|
+
console.log(` Your session will be saved for future use.`);
|
|
2368
|
+
console.log(` (Waiting up to 120s for you to complete login)`);
|
|
2369
|
+
console.log("\u2501".repeat(60));
|
|
2370
|
+
console.log("\n");
|
|
2371
|
+
const loginUrl = pageInfo.url;
|
|
2372
|
+
const deadline = Date.now() + 12e4;
|
|
2373
|
+
while (Date.now() < deadline) {
|
|
2374
|
+
await new Promise((r) => setTimeout(r, 3e3));
|
|
2375
|
+
try {
|
|
2376
|
+
const currentInfo = await browser.getPageInfo();
|
|
2377
|
+
if (currentInfo.url !== loginUrl) {
|
|
2378
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
2379
|
+
const newPage = await browser.readPage();
|
|
2380
|
+
return `Login completed. Redirected to: ${currentInfo.url}
|
|
2381
|
+
Session saved in assistme browser profile for future use.
|
|
2382
|
+
|
|
2383
|
+
Current page:
|
|
2384
|
+
${newPage.slice(0, 3e3)}`;
|
|
2385
|
+
}
|
|
2386
|
+
const stillLogin = await browser.detectLoginPage();
|
|
2387
|
+
if (!stillLogin.isLoginPage) {
|
|
2388
|
+
const newPage = await browser.readPage();
|
|
2389
|
+
return `Login completed on ${siteName}.
|
|
2390
|
+
Session saved in assistme browser profile for future use.
|
|
2391
|
+
|
|
2392
|
+
Current page:
|
|
2393
|
+
${newPage.slice(0, 3e3)}`;
|
|
2394
|
+
}
|
|
2395
|
+
} catch {
|
|
2396
|
+
try {
|
|
2397
|
+
await browser.connect();
|
|
2398
|
+
} catch {
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
return `Login wait timed out after 120s. The user may still need to log in at ${siteName}.`;
|
|
2403
|
+
}
|
|
2278
2404
|
async function ensureConnected(browser, tabIndex) {
|
|
2279
2405
|
if (browser.isConnected() && tabIndex === void 0) return;
|
|
2280
2406
|
if (!await browser.isAvailable()) {
|
|
@@ -2316,9 +2442,15 @@ async function executeTool(name, input) {
|
|
|
2316
2442
|
await ensureConnected(browser, input.tab_index);
|
|
2317
2443
|
return browser.isConnected() ? "Connected to browser." : "Failed to connect.";
|
|
2318
2444
|
}
|
|
2319
|
-
case "browser_navigate":
|
|
2445
|
+
case "browser_navigate": {
|
|
2320
2446
|
await ensureConnected(browser);
|
|
2321
|
-
|
|
2447
|
+
const navResult = await browser.navigate(input.url);
|
|
2448
|
+
const loginResult = await detectAndHandleLogin(browser);
|
|
2449
|
+
if (loginResult) {
|
|
2450
|
+
return navResult + "\n\n" + loginResult;
|
|
2451
|
+
}
|
|
2452
|
+
return navResult;
|
|
2453
|
+
}
|
|
2322
2454
|
case "browser_read_page":
|
|
2323
2455
|
await ensureConnected(browser);
|
|
2324
2456
|
return browser.readPage();
|
|
@@ -2832,7 +2964,9 @@ var BASE_SYSTEM_PROMPT = `You are AssistMe, an AI assistant that operates like a
|
|
|
2832
2964
|
KEY PRINCIPLE: You operate the user's real browser, not a headless sandbox. This means:
|
|
2833
2965
|
- The browser has the user's real cookies, logins, and sessions
|
|
2834
2966
|
- When you navigate to amazon.com, you see the user's logged-in Amazon
|
|
2835
|
-
- If a site needs login,
|
|
2967
|
+
- If a site needs login, the browser will auto-detect the login page and prompt the user
|
|
2968
|
+
- After the user logs in, their session is saved in the persistent browser profile (~/.assistme/browser-profile)
|
|
2969
|
+
- Saved sessions persist across assistme restarts \u2014 the user only needs to log in once per site
|
|
2836
2970
|
- You are like a human assistant sitting at the user's computer
|
|
2837
2971
|
- Chrome is automatically managed \u2014 just call browser_connect and it will auto-launch if needed
|
|
2838
2972
|
- NEVER ask the user to manually start Chrome or run any terminal commands for browser setup
|
|
@@ -2859,9 +2993,9 @@ Available capabilities:
|
|
|
2859
2993
|
Workflow for web tasks (e.g. "\u67E5\u4E00\u4E0B kindle \u6700\u65B0\u6B3E\u4EF7\u683C"):
|
|
2860
2994
|
1. browser_connect \u2192 connect to user's Chrome
|
|
2861
2995
|
2. browser_new_tab \u2192 open a new tab
|
|
2862
|
-
3. browser_navigate \u2192 go to the website
|
|
2996
|
+
3. browser_navigate \u2192 go to the website (login pages are auto-detected \u2014 the user will be prompted and their session saved)
|
|
2863
2997
|
4. browser_read_page or browser_screenshot \u2192 read the content
|
|
2864
|
-
5. If login
|
|
2998
|
+
5. If login is needed but not auto-detected \u2192 use browser_request_user_action to ask the user
|
|
2865
2999
|
6. Repeat across multiple sites as needed
|
|
2866
3000
|
7. Summarize findings
|
|
2867
3001
|
|
|
@@ -2869,15 +3003,20 @@ Guidelines:
|
|
|
2869
3003
|
- Always use the real browser for web tasks, never try to fetch URLs programmatically
|
|
2870
3004
|
- Use browser_screenshot when you need to see the visual layout
|
|
2871
3005
|
- Use browser_get_elements to find clickable elements before clicking
|
|
2872
|
-
-
|
|
3006
|
+
- Login pages are auto-detected after navigation \u2014 the user is prompted and sessions are saved automatically
|
|
3007
|
+
- If auto-detection misses a login page, use browser_request_user_action manually
|
|
2873
3008
|
- Be thorough: check multiple sources when comparing prices/products
|
|
2874
3009
|
- Summarize results clearly at the end
|
|
2875
3010
|
- When you learn something about the user (preferences, habits), use memory_store to remember it
|
|
2876
3011
|
|
|
2877
3012
|
Workspace path: {workspace_path}`;
|
|
3013
|
+
var MAX_HISTORY_ENTRIES = 10;
|
|
3014
|
+
var MAX_RESPONSE_LENGTH = 1500;
|
|
2878
3015
|
var TaskProcessor = class {
|
|
2879
3016
|
memoryManager = null;
|
|
2880
3017
|
skillManager;
|
|
3018
|
+
/** In-memory conversation history, keyed by conversation_id */
|
|
3019
|
+
historyCache = /* @__PURE__ */ new Map();
|
|
2881
3020
|
constructor() {
|
|
2882
3021
|
this.skillManager = new SkillManager();
|
|
2883
3022
|
this.skillManager.load();
|
|
@@ -2916,6 +3055,28 @@ var TaskProcessor = class {
|
|
|
2916
3055
|
for (const s of matchedSkills) {
|
|
2917
3056
|
usedSkillNames.push(s.name);
|
|
2918
3057
|
}
|
|
3058
|
+
let history = [];
|
|
3059
|
+
try {
|
|
3060
|
+
history = await getConversationHistory(task.conversation_id, task.id, MAX_HISTORY_ENTRIES);
|
|
3061
|
+
} catch {
|
|
3062
|
+
log.debug("DB conversation history unavailable, using in-memory cache");
|
|
3063
|
+
}
|
|
3064
|
+
if (history.length === 0) {
|
|
3065
|
+
history = this.historyCache.get(task.conversation_id) || [];
|
|
3066
|
+
}
|
|
3067
|
+
if (history.length > 0) {
|
|
3068
|
+
log.info(`Loaded ${history.length} message(s) from conversation history`);
|
|
3069
|
+
let historyPrompt = "\n\n## Conversation History\nThe following is the history of previous messages in this conversation. Use this context to maintain continuity and understand references to earlier tasks.\n\n";
|
|
3070
|
+
for (const entry of history) {
|
|
3071
|
+
historyPrompt += `User: ${entry.prompt}
|
|
3072
|
+
`;
|
|
3073
|
+
const truncated = entry.response.length > MAX_RESPONSE_LENGTH ? entry.response.slice(0, MAX_RESPONSE_LENGTH) + "\u2026" : entry.response;
|
|
3074
|
+
historyPrompt += `Assistant: ${truncated}
|
|
3075
|
+
|
|
3076
|
+
`;
|
|
3077
|
+
}
|
|
3078
|
+
systemPrompt += historyPrompt;
|
|
3079
|
+
}
|
|
2919
3080
|
const browserServer = createBrowserMcpServer();
|
|
2920
3081
|
const agentToolsServer = createAgentToolsServer({
|
|
2921
3082
|
memoryManager: this.memoryManager,
|
|
@@ -3037,6 +3198,12 @@ var TaskProcessor = class {
|
|
|
3037
3198
|
});
|
|
3038
3199
|
await emitEvent(task.id, "status_change", { status: "completed" });
|
|
3039
3200
|
log.success("Task completed.");
|
|
3201
|
+
const convHistory = this.historyCache.get(task.conversation_id) || [];
|
|
3202
|
+
convHistory.push({ prompt: task.prompt, response: finalResponse });
|
|
3203
|
+
if (convHistory.length > MAX_HISTORY_ENTRIES * 2) {
|
|
3204
|
+
convHistory.splice(0, convHistory.length - MAX_HISTORY_ENTRIES * 2);
|
|
3205
|
+
}
|
|
3206
|
+
this.historyCache.set(task.conversation_id, convHistory);
|
|
3040
3207
|
if (this.memoryManager && finalResponse) {
|
|
3041
3208
|
const mm = this.memoryManager;
|
|
3042
3209
|
const taskIdRef = task.id;
|
|
@@ -3437,7 +3604,7 @@ program.command("start", { isDefault: true }).description("Start the agent and l
|
|
|
3437
3604
|
program.command("status").description("Check the status of the current agent session").action(async () => {
|
|
3438
3605
|
try {
|
|
3439
3606
|
const userId = await getCurrentUserId();
|
|
3440
|
-
const { getSupabase: getSupabase2 } = await import("./supabase-
|
|
3607
|
+
const { getSupabase: getSupabase2 } = await import("./supabase-5QTM5WBI.js");
|
|
3441
3608
|
const sb = getSupabase2();
|
|
3442
3609
|
const { data: sessions } = await sb.from("agent_sessions").select("*").eq("user_id", userId).in("status", ["online", "busy"]).order("started_at", { ascending: false }).limit(5);
|
|
3443
3610
|
if (!sessions || sessions.length === 0) {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
emitEvents,
|
|
10
10
|
endSession,
|
|
11
11
|
failTask,
|
|
12
|
+
getConversationHistory,
|
|
12
13
|
getCurrentUserId,
|
|
13
14
|
getOrCreateCliConversation,
|
|
14
15
|
getSupabase,
|
|
@@ -19,7 +20,7 @@ import {
|
|
|
19
20
|
resetEventSequence,
|
|
20
21
|
setSessionBusy,
|
|
21
22
|
updateHeartbeat
|
|
22
|
-
} from "./chunk-
|
|
23
|
+
} from "./chunk-ERK6A6GH.js";
|
|
23
24
|
export {
|
|
24
25
|
CLI_AGENT_ID,
|
|
25
26
|
DAYBOX_AGENT_ID,
|
|
@@ -31,6 +32,7 @@ export {
|
|
|
31
32
|
emitEvents,
|
|
32
33
|
endSession,
|
|
33
34
|
failTask,
|
|
35
|
+
getConversationHistory,
|
|
34
36
|
getCurrentUserId,
|
|
35
37
|
getOrCreateCliConversation,
|
|
36
38
|
getSupabase,
|
package/package.json
CHANGED
package/src/agent/processor.ts
CHANGED
|
@@ -8,10 +8,12 @@ import {
|
|
|
8
8
|
} from "@anthropic-ai/claude-agent-sdk";
|
|
9
9
|
import {
|
|
10
10
|
type AgentTask,
|
|
11
|
+
type HistoryEntry,
|
|
11
12
|
completeTask,
|
|
12
13
|
failTask,
|
|
13
14
|
emitEvent,
|
|
14
15
|
resetEventSequence,
|
|
16
|
+
getConversationHistory,
|
|
15
17
|
} from "../db/supabase.js";
|
|
16
18
|
import { getConfig } from "../utils/config.js";
|
|
17
19
|
import { log, newCorrelationId, setCorrelationId } from "../utils/logger.js";
|
|
@@ -37,7 +39,9 @@ const BASE_SYSTEM_PROMPT = `You are AssistMe, an AI assistant that operates like
|
|
|
37
39
|
KEY PRINCIPLE: You operate the user's real browser, not a headless sandbox. This means:
|
|
38
40
|
- The browser has the user's real cookies, logins, and sessions
|
|
39
41
|
- When you navigate to amazon.com, you see the user's logged-in Amazon
|
|
40
|
-
- If a site needs login,
|
|
42
|
+
- If a site needs login, the browser will auto-detect the login page and prompt the user
|
|
43
|
+
- After the user logs in, their session is saved in the persistent browser profile (~/.assistme/browser-profile)
|
|
44
|
+
- Saved sessions persist across assistme restarts — the user only needs to log in once per site
|
|
41
45
|
- You are like a human assistant sitting at the user's computer
|
|
42
46
|
- Chrome is automatically managed — just call browser_connect and it will auto-launch if needed
|
|
43
47
|
- NEVER ask the user to manually start Chrome or run any terminal commands for browser setup
|
|
@@ -64,9 +68,9 @@ Available capabilities:
|
|
|
64
68
|
Workflow for web tasks (e.g. "查一下 kindle 最新款价格"):
|
|
65
69
|
1. browser_connect → connect to user's Chrome
|
|
66
70
|
2. browser_new_tab → open a new tab
|
|
67
|
-
3. browser_navigate → go to the website
|
|
71
|
+
3. browser_navigate → go to the website (login pages are auto-detected — the user will be prompted and their session saved)
|
|
68
72
|
4. browser_read_page or browser_screenshot → read the content
|
|
69
|
-
5. If login
|
|
73
|
+
5. If login is needed but not auto-detected → use browser_request_user_action to ask the user
|
|
70
74
|
6. Repeat across multiple sites as needed
|
|
71
75
|
7. Summarize findings
|
|
72
76
|
|
|
@@ -74,16 +78,22 @@ Guidelines:
|
|
|
74
78
|
- Always use the real browser for web tasks, never try to fetch URLs programmatically
|
|
75
79
|
- Use browser_screenshot when you need to see the visual layout
|
|
76
80
|
- Use browser_get_elements to find clickable elements before clicking
|
|
77
|
-
-
|
|
81
|
+
- Login pages are auto-detected after navigation — the user is prompted and sessions are saved automatically
|
|
82
|
+
- If auto-detection misses a login page, use browser_request_user_action manually
|
|
78
83
|
- Be thorough: check multiple sources when comparing prices/products
|
|
79
84
|
- Summarize results clearly at the end
|
|
80
85
|
- When you learn something about the user (preferences, habits), use memory_store to remember it
|
|
81
86
|
|
|
82
87
|
Workspace path: {workspace_path}`;
|
|
83
88
|
|
|
89
|
+
const MAX_HISTORY_ENTRIES = 10;
|
|
90
|
+
const MAX_RESPONSE_LENGTH = 1500;
|
|
91
|
+
|
|
84
92
|
export class TaskProcessor {
|
|
85
93
|
private memoryManager: MemoryManager | null = null;
|
|
86
94
|
private skillManager: SkillManager;
|
|
95
|
+
/** In-memory conversation history, keyed by conversation_id */
|
|
96
|
+
private historyCache: Map<string, HistoryEntry[]> = new Map();
|
|
87
97
|
|
|
88
98
|
constructor() {
|
|
89
99
|
this.skillManager = new SkillManager();
|
|
@@ -143,6 +153,34 @@ export class TaskProcessor {
|
|
|
143
153
|
usedSkillNames.push(s.name);
|
|
144
154
|
}
|
|
145
155
|
|
|
156
|
+
// Inject conversation history for multi-turn context
|
|
157
|
+
let history: HistoryEntry[] = [];
|
|
158
|
+
try {
|
|
159
|
+
history = await getConversationHistory(task.conversation_id, task.id, MAX_HISTORY_ENTRIES);
|
|
160
|
+
} catch {
|
|
161
|
+
log.debug("DB conversation history unavailable, using in-memory cache");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Fall back to in-memory cache if DB returned nothing
|
|
165
|
+
if (history.length === 0) {
|
|
166
|
+
history = this.historyCache.get(task.conversation_id) || [];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (history.length > 0) {
|
|
170
|
+
log.info(`Loaded ${history.length} message(s) from conversation history`);
|
|
171
|
+
let historyPrompt =
|
|
172
|
+
"\n\n## Conversation History\nThe following is the history of previous messages in this conversation. Use this context to maintain continuity and understand references to earlier tasks.\n\n";
|
|
173
|
+
for (const entry of history) {
|
|
174
|
+
historyPrompt += `User: ${entry.prompt}\n`;
|
|
175
|
+
const truncated =
|
|
176
|
+
entry.response.length > MAX_RESPONSE_LENGTH
|
|
177
|
+
? entry.response.slice(0, MAX_RESPONSE_LENGTH) + "…"
|
|
178
|
+
: entry.response;
|
|
179
|
+
historyPrompt += `Assistant: ${truncated}\n\n`;
|
|
180
|
+
}
|
|
181
|
+
systemPrompt += historyPrompt;
|
|
182
|
+
}
|
|
183
|
+
|
|
146
184
|
// Create MCP servers for custom tools
|
|
147
185
|
const browserServer = createBrowserMcpServer();
|
|
148
186
|
const agentToolsServer = createAgentToolsServer({
|
|
@@ -288,6 +326,15 @@ export class TaskProcessor {
|
|
|
288
326
|
await emitEvent(task.id, "status_change", { status: "completed" });
|
|
289
327
|
log.success("Task completed.");
|
|
290
328
|
|
|
329
|
+
// Save to in-memory conversation history cache
|
|
330
|
+
const convHistory = this.historyCache.get(task.conversation_id) || [];
|
|
331
|
+
convHistory.push({ prompt: task.prompt, response: finalResponse });
|
|
332
|
+
// Keep only the most recent entries
|
|
333
|
+
if (convHistory.length > MAX_HISTORY_ENTRIES * 2) {
|
|
334
|
+
convHistory.splice(0, convHistory.length - MAX_HISTORY_ENTRIES * 2);
|
|
335
|
+
}
|
|
336
|
+
this.historyCache.set(task.conversation_id, convHistory);
|
|
337
|
+
|
|
291
338
|
// ── Post-task extraction (fire-and-forget, non-blocking) ──────
|
|
292
339
|
|
|
293
340
|
// Auto-extract memories using LLM
|
package/src/agent/scheduler.ts
CHANGED
|
@@ -25,11 +25,7 @@ export interface ScheduledTask {
|
|
|
25
25
|
* Supports: minute hour day-of-month month day-of-week
|
|
26
26
|
* Examples: "0 8 * * *" (daily 8am), "0,30 * * * *" (every 30min)
|
|
27
27
|
*/
|
|
28
|
-
export function getNextRunTime(
|
|
29
|
-
cronExpr: string,
|
|
30
|
-
timezone: string,
|
|
31
|
-
fromDate?: Date
|
|
32
|
-
): Date {
|
|
28
|
+
export function getNextRunTime(cronExpr: string, timezone: string, fromDate?: Date): Date {
|
|
33
29
|
const now = fromDate || new Date();
|
|
34
30
|
const parts = cronExpr.trim().split(/\s+/);
|
|
35
31
|
if (parts.length !== 5) {
|
|
@@ -65,6 +61,8 @@ export function getNextRunTime(
|
|
|
65
61
|
const months = parseField(monExpr, 1, 12);
|
|
66
62
|
const daysOfWeek = parseField(dowExpr, 0, 6); // 0 = Sunday
|
|
67
63
|
|
|
64
|
+
const useUTC = timezone === "UTC";
|
|
65
|
+
|
|
68
66
|
// Find the next matching time after 'now'
|
|
69
67
|
const candidate = new Date(now.getTime() + 60_000); // Start from next minute
|
|
70
68
|
candidate.setSeconds(0, 0);
|
|
@@ -72,11 +70,11 @@ export function getNextRunTime(
|
|
|
72
70
|
// Search up to 366 days ahead
|
|
73
71
|
for (let i = 0; i < 527040; i++) {
|
|
74
72
|
// 366 * 24 * 60
|
|
75
|
-
const m = candidate.getMinutes();
|
|
76
|
-
const h = candidate.getHours();
|
|
77
|
-
const dom = candidate.getDate();
|
|
78
|
-
const mon = candidate.getMonth() + 1;
|
|
79
|
-
const dow = candidate.getDay();
|
|
73
|
+
const m = useUTC ? candidate.getUTCMinutes() : candidate.getMinutes();
|
|
74
|
+
const h = useUTC ? candidate.getUTCHours() : candidate.getHours();
|
|
75
|
+
const dom = useUTC ? candidate.getUTCDate() : candidate.getDate();
|
|
76
|
+
const mon = (useUTC ? candidate.getUTCMonth() : candidate.getMonth()) + 1;
|
|
77
|
+
const dow = useUTC ? candidate.getUTCDay() : candidate.getDay();
|
|
80
78
|
|
|
81
79
|
if (
|
|
82
80
|
minutes.includes(m) &&
|
|
@@ -98,13 +96,9 @@ export function getNextRunTime(
|
|
|
98
96
|
export class Scheduler {
|
|
99
97
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
100
98
|
private running = false;
|
|
101
|
-
private onScheduledTask:
|
|
102
|
-
| ((task: ScheduledTask) => Promise<void>)
|
|
103
|
-
| null = null;
|
|
99
|
+
private onScheduledTask: ((task: ScheduledTask) => Promise<void>) | null = null;
|
|
104
100
|
|
|
105
|
-
async start(
|
|
106
|
-
onScheduledTask: (task: ScheduledTask) => Promise<void>
|
|
107
|
-
): Promise<void> {
|
|
101
|
+
async start(onScheduledTask: (task: ScheduledTask) => Promise<void>): Promise<void> {
|
|
108
102
|
this.onScheduledTask = onScheduledTask;
|
|
109
103
|
this.running = true;
|
|
110
104
|
|
|
@@ -187,16 +181,10 @@ export class Scheduler {
|
|
|
187
181
|
try {
|
|
188
182
|
await this.onScheduledTask(task);
|
|
189
183
|
|
|
190
|
-
await sb
|
|
191
|
-
.from("agent_scheduled_tasks")
|
|
192
|
-
.update({ last_error: null })
|
|
193
|
-
.eq("id", task.id);
|
|
184
|
+
await sb.from("agent_scheduled_tasks").update({ last_error: null }).eq("id", task.id);
|
|
194
185
|
} catch (err) {
|
|
195
186
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
196
|
-
await sb
|
|
197
|
-
.from("agent_scheduled_tasks")
|
|
198
|
-
.update({ last_error: errMsg })
|
|
199
|
-
.eq("id", task.id);
|
|
187
|
+
await sb.from("agent_scheduled_tasks").update({ last_error: errMsg }).eq("id", task.id);
|
|
200
188
|
log.error(`Scheduled task "${task.name}" failed: ${errMsg}`);
|
|
201
189
|
}
|
|
202
190
|
} catch (err) {
|
|
@@ -234,9 +222,7 @@ export async function createScheduledTask(
|
|
|
234
222
|
return data as ScheduledTask;
|
|
235
223
|
}
|
|
236
224
|
|
|
237
|
-
export async function listScheduledTasks(
|
|
238
|
-
userId: string
|
|
239
|
-
): Promise<ScheduledTask[]> {
|
|
225
|
+
export async function listScheduledTasks(userId: string): Promise<ScheduledTask[]> {
|
|
240
226
|
const sb = getSupabase();
|
|
241
227
|
const { data, error } = await sb
|
|
242
228
|
.from("agent_scheduled_tasks")
|
|
@@ -248,10 +234,7 @@ export async function listScheduledTasks(
|
|
|
248
234
|
return (data || []) as ScheduledTask[];
|
|
249
235
|
}
|
|
250
236
|
|
|
251
|
-
export async function toggleScheduledTask(
|
|
252
|
-
taskId: string,
|
|
253
|
-
enabled: boolean
|
|
254
|
-
): Promise<void> {
|
|
237
|
+
export async function toggleScheduledTask(taskId: string, enabled: boolean): Promise<void> {
|
|
255
238
|
const sb = getSupabase();
|
|
256
239
|
const update: Record<string, unknown> = { enabled };
|
|
257
240
|
if (enabled) {
|
|
@@ -267,20 +250,14 @@ export async function toggleScheduledTask(
|
|
|
267
250
|
}
|
|
268
251
|
}
|
|
269
252
|
|
|
270
|
-
const { error } = await sb
|
|
271
|
-
.from("agent_scheduled_tasks")
|
|
272
|
-
.update(update)
|
|
273
|
-
.eq("id", taskId);
|
|
253
|
+
const { error } = await sb.from("agent_scheduled_tasks").update(update).eq("id", taskId);
|
|
274
254
|
|
|
275
255
|
if (error) throw new Error(`Failed to toggle schedule: ${error.message}`);
|
|
276
256
|
}
|
|
277
257
|
|
|
278
258
|
export async function deleteScheduledTask(taskId: string): Promise<void> {
|
|
279
259
|
const sb = getSupabase();
|
|
280
|
-
const { error } = await sb
|
|
281
|
-
.from("agent_scheduled_tasks")
|
|
282
|
-
.delete()
|
|
283
|
-
.eq("id", taskId);
|
|
260
|
+
const { error } = await sb.from("agent_scheduled_tasks").delete().eq("id", taskId);
|
|
284
261
|
|
|
285
262
|
if (error) throw new Error(`Failed to delete schedule: ${error.message}`);
|
|
286
263
|
}
|
package/src/db/supabase.test.ts
CHANGED
|
@@ -8,14 +8,28 @@ let finalResult: Record<string, unknown> = { data: [], error: null };
|
|
|
8
8
|
|
|
9
9
|
const chain: Record<string, unknown> = {};
|
|
10
10
|
const chainMethods = [
|
|
11
|
-
"select",
|
|
12
|
-
"
|
|
11
|
+
"select",
|
|
12
|
+
"insert",
|
|
13
|
+
"update",
|
|
14
|
+
"delete",
|
|
15
|
+
"eq",
|
|
16
|
+
"neq",
|
|
17
|
+
"not",
|
|
18
|
+
"or",
|
|
19
|
+
"in",
|
|
20
|
+
"order",
|
|
21
|
+
"limit",
|
|
22
|
+
"single",
|
|
23
|
+
"from",
|
|
24
|
+
"lt",
|
|
13
25
|
];
|
|
14
26
|
for (const method of chainMethods) {
|
|
15
27
|
chain[method] = vi.fn().mockImplementation(() => chain);
|
|
16
28
|
}
|
|
17
29
|
// Make chain thenable — always resolves to current finalResult
|
|
18
|
-
chain.then = function (resolve: (value: unknown) => void) {
|
|
30
|
+
chain.then = function (resolve: (value: unknown) => void) {
|
|
31
|
+
return resolve(finalResult);
|
|
32
|
+
};
|
|
19
33
|
chain.single = vi.fn().mockImplementation(() => ({
|
|
20
34
|
...chain,
|
|
21
35
|
then: (resolve: (value: unknown) => void) => resolve(finalResult),
|
|
@@ -48,7 +62,9 @@ const stableMockClient = {
|
|
|
48
62
|
|
|
49
63
|
function setResult(result: Record<string, unknown>) {
|
|
50
64
|
finalResult = result;
|
|
51
|
-
chain.then = function (resolve: (value: unknown) => void) {
|
|
65
|
+
chain.then = function (resolve: (value: unknown) => void) {
|
|
66
|
+
return resolve(result);
|
|
67
|
+
};
|
|
52
68
|
chain.single = vi.fn().mockImplementation(() => ({
|
|
53
69
|
...chain,
|
|
54
70
|
then: (resolve: (value: unknown) => void) => resolve(result),
|
|
@@ -73,7 +89,10 @@ vi.mock("../utils/config.js", () => ({
|
|
|
73
89
|
|
|
74
90
|
vi.mock("../utils/logger.js", () => ({
|
|
75
91
|
log: {
|
|
76
|
-
debug: vi.fn(),
|
|
92
|
+
debug: vi.fn(),
|
|
93
|
+
info: vi.fn(),
|
|
94
|
+
warn: vi.fn(),
|
|
95
|
+
error: vi.fn(),
|
|
77
96
|
},
|
|
78
97
|
}));
|
|
79
98
|
|
|
@@ -81,9 +100,9 @@ vi.mock("fs", async (importOriginal) => {
|
|
|
81
100
|
const original = (await importOriginal()) as Record<string, unknown>;
|
|
82
101
|
return {
|
|
83
102
|
...original,
|
|
84
|
-
existsSync: vi.fn().mockReturnValue(
|
|
103
|
+
existsSync: vi.fn().mockReturnValue(true),
|
|
85
104
|
mkdirSync: vi.fn(),
|
|
86
|
-
readFileSync: vi.fn().mockReturnValue("
|
|
105
|
+
readFileSync: vi.fn().mockReturnValue(JSON.stringify({ mcp_token: "am_test_token_123" })),
|
|
87
106
|
writeFileSync: vi.fn(),
|
|
88
107
|
};
|
|
89
108
|
});
|
|
@@ -131,7 +150,14 @@ describe("Supabase DB Layer", () => {
|
|
|
131
150
|
|
|
132
151
|
const result = await createSession("user-123", "Test", "/tmp", "0.1.0");
|
|
133
152
|
|
|
134
|
-
expect(
|
|
153
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
154
|
+
"mcp_create_session",
|
|
155
|
+
expect.objectContaining({
|
|
156
|
+
p_session_name: "Test",
|
|
157
|
+
p_workspace_path: "/tmp",
|
|
158
|
+
p_version: "0.1.0",
|
|
159
|
+
})
|
|
160
|
+
);
|
|
135
161
|
expect(result.id).toBe("sess-001");
|
|
136
162
|
expect(result.status).toBe("online");
|
|
137
163
|
});
|
|
@@ -139,9 +165,9 @@ describe("Supabase DB Layer", () => {
|
|
|
139
165
|
it("throws on DB error", async () => {
|
|
140
166
|
setResult({ data: null, error: { message: "insert failed" } });
|
|
141
167
|
|
|
142
|
-
await expect(
|
|
143
|
-
|
|
144
|
-
)
|
|
168
|
+
await expect(createSession("user-123", "Test", "/tmp", "0.1.0")).rejects.toThrow(
|
|
169
|
+
"Failed to create session"
|
|
170
|
+
);
|
|
145
171
|
});
|
|
146
172
|
});
|
|
147
173
|
|
|
@@ -149,8 +175,12 @@ describe("Supabase DB Layer", () => {
|
|
|
149
175
|
it("updates the heartbeat timestamp", async () => {
|
|
150
176
|
setResult({ error: null });
|
|
151
177
|
await updateHeartbeat("sess-001");
|
|
152
|
-
expect(
|
|
153
|
-
|
|
178
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
179
|
+
"mcp_heartbeat",
|
|
180
|
+
expect.objectContaining({
|
|
181
|
+
p_session_id: "sess-001",
|
|
182
|
+
})
|
|
183
|
+
);
|
|
154
184
|
});
|
|
155
185
|
});
|
|
156
186
|
|
|
@@ -158,8 +188,12 @@ describe("Supabase DB Layer", () => {
|
|
|
158
188
|
it("sets session to offline", async () => {
|
|
159
189
|
setResult({ error: null });
|
|
160
190
|
await endSession("sess-001");
|
|
161
|
-
expect(
|
|
162
|
-
|
|
191
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
192
|
+
"mcp_end_session",
|
|
193
|
+
expect.objectContaining({
|
|
194
|
+
p_session_id: "sess-001",
|
|
195
|
+
})
|
|
196
|
+
);
|
|
163
197
|
});
|
|
164
198
|
});
|
|
165
199
|
|
|
@@ -167,7 +201,13 @@ describe("Supabase DB Layer", () => {
|
|
|
167
201
|
it("sets session to busy", async () => {
|
|
168
202
|
setResult({ error: null });
|
|
169
203
|
await setSessionBusy("sess-001", true);
|
|
170
|
-
expect(
|
|
204
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
205
|
+
"mcp_set_session_busy",
|
|
206
|
+
expect.objectContaining({
|
|
207
|
+
p_session_id: "sess-001",
|
|
208
|
+
p_busy: true,
|
|
209
|
+
})
|
|
210
|
+
);
|
|
171
211
|
});
|
|
172
212
|
});
|
|
173
213
|
|
|
@@ -208,9 +248,12 @@ describe("Supabase DB Layer", () => {
|
|
|
208
248
|
it("updates status to running", async () => {
|
|
209
249
|
setResult({ error: null });
|
|
210
250
|
await claimTask("msg-001");
|
|
211
|
-
expect(
|
|
212
|
-
|
|
213
|
-
|
|
251
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
252
|
+
"mcp_claim_task",
|
|
253
|
+
expect.objectContaining({
|
|
254
|
+
p_message_id: "msg-001",
|
|
255
|
+
})
|
|
256
|
+
);
|
|
214
257
|
});
|
|
215
258
|
|
|
216
259
|
it("throws on DB error", async () => {
|
|
@@ -223,7 +266,13 @@ describe("Supabase DB Layer", () => {
|
|
|
223
266
|
it("updates status to completed with result", async () => {
|
|
224
267
|
setResult({ data: { metadata: {} }, error: null });
|
|
225
268
|
await completeTask("msg-001", "Task completed successfully");
|
|
226
|
-
expect(
|
|
269
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
270
|
+
"mcp_complete_task",
|
|
271
|
+
expect.objectContaining({
|
|
272
|
+
p_message_id: "msg-001",
|
|
273
|
+
p_result: "Task completed successfully",
|
|
274
|
+
})
|
|
275
|
+
);
|
|
227
276
|
});
|
|
228
277
|
});
|
|
229
278
|
|
|
@@ -231,7 +280,13 @@ describe("Supabase DB Layer", () => {
|
|
|
231
280
|
it("updates status to failed with error", async () => {
|
|
232
281
|
setResult({ data: { metadata: {} }, error: null });
|
|
233
282
|
await failTask("msg-001", "Something went wrong");
|
|
234
|
-
expect(
|
|
283
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
284
|
+
"mcp_fail_task",
|
|
285
|
+
expect.objectContaining({
|
|
286
|
+
p_message_id: "msg-001",
|
|
287
|
+
p_error: "Something went wrong",
|
|
288
|
+
})
|
|
289
|
+
);
|
|
235
290
|
});
|
|
236
291
|
});
|
|
237
292
|
|
|
@@ -242,42 +297,44 @@ describe("Supabase DB Layer", () => {
|
|
|
242
297
|
await emitEvent("msg-001", "text_delta", { text: "hello" });
|
|
243
298
|
await emitEvent("msg-001", "text_delta", { text: "world" });
|
|
244
299
|
|
|
245
|
-
expect(
|
|
300
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
301
|
+
"mcp_emit_event",
|
|
302
|
+
expect.objectContaining({
|
|
303
|
+
p_message_id: "msg-001",
|
|
304
|
+
p_event_type: "text_delta",
|
|
305
|
+
})
|
|
306
|
+
);
|
|
246
307
|
});
|
|
247
308
|
});
|
|
248
309
|
|
|
249
310
|
describe("loginWithToken()", () => {
|
|
250
|
-
it("
|
|
251
|
-
|
|
252
|
-
JSON.stringify({
|
|
253
|
-
access_token: "test-access",
|
|
254
|
-
refresh_token: "test-refresh",
|
|
255
|
-
})
|
|
256
|
-
).toString("base64");
|
|
311
|
+
it("validates am_ token via RPC and returns user ID", async () => {
|
|
312
|
+
setResult({ data: [{ out_user_id: "user-123" }], error: null });
|
|
257
313
|
|
|
258
|
-
const userId = await loginWithToken(
|
|
314
|
+
const userId = await loginWithToken("am_valid_test_token");
|
|
259
315
|
expect(userId).toBe("user-123");
|
|
316
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
317
|
+
"validate_mcp_token",
|
|
318
|
+
expect.objectContaining({
|
|
319
|
+
p_token_hash: expect.any(String),
|
|
320
|
+
})
|
|
321
|
+
);
|
|
260
322
|
});
|
|
261
323
|
|
|
262
|
-
it("throws on invalid token format", async () => {
|
|
263
|
-
await expect(loginWithToken("not-
|
|
264
|
-
"Invalid token format"
|
|
265
|
-
);
|
|
324
|
+
it("throws on invalid token format (no am_ prefix)", async () => {
|
|
325
|
+
await expect(loginWithToken("not-am-token")).rejects.toThrow("Invalid token format");
|
|
266
326
|
});
|
|
267
327
|
|
|
268
|
-
it("throws on
|
|
269
|
-
|
|
270
|
-
JSON.stringify({ access_token: "only-one" })
|
|
271
|
-
).toString("base64");
|
|
328
|
+
it("throws on RPC validation failure", async () => {
|
|
329
|
+
setResult({ data: null, error: { message: "validation failed" } });
|
|
272
330
|
|
|
273
|
-
await expect(loginWithToken(
|
|
274
|
-
"Invalid token format"
|
|
275
|
-
);
|
|
331
|
+
await expect(loginWithToken("am_bad_token")).rejects.toThrow("Token validation failed");
|
|
276
332
|
});
|
|
277
333
|
});
|
|
278
334
|
|
|
279
335
|
describe("getCurrentUserId()", () => {
|
|
280
336
|
it("returns user ID when authenticated", async () => {
|
|
337
|
+
setResult({ data: [{ out_user_id: "user-123" }], error: null });
|
|
281
338
|
const userId = await getCurrentUserId();
|
|
282
339
|
expect(userId).toBe("user-123");
|
|
283
340
|
});
|
package/src/db/supabase.ts
CHANGED
|
@@ -329,6 +329,55 @@ export async function failTask(
|
|
|
329
329
|
if (error) log.error(`Failed to update task status: ${error.message}`);
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
+
// ── Conversation History ─────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
export interface HistoryEntry {
|
|
335
|
+
prompt: string;
|
|
336
|
+
response: string;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Fetch completed messages from a conversation to build history context.
|
|
341
|
+
* Returns messages in chronological order (oldest first).
|
|
342
|
+
*/
|
|
343
|
+
export async function getConversationHistory(
|
|
344
|
+
conversationId: string,
|
|
345
|
+
excludeMessageId: string,
|
|
346
|
+
limit: number = 20
|
|
347
|
+
): Promise<HistoryEntry[]> {
|
|
348
|
+
const sb = getSupabase();
|
|
349
|
+
|
|
350
|
+
const { data, error } = await sb
|
|
351
|
+
.from("conversation_messages")
|
|
352
|
+
.select("id, role, content, status, metadata, created_at")
|
|
353
|
+
.eq("conversation_id", conversationId)
|
|
354
|
+
.in("status", ["completed", "failed"])
|
|
355
|
+
.neq("id", excludeMessageId)
|
|
356
|
+
.order("created_at", { ascending: false })
|
|
357
|
+
.limit(limit);
|
|
358
|
+
|
|
359
|
+
if (error) {
|
|
360
|
+
log.debug(`Failed to fetch conversation history: ${error.message}`);
|
|
361
|
+
return [];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const rows = (data || []) as Array<Record<string, unknown>>;
|
|
365
|
+
|
|
366
|
+
return rows
|
|
367
|
+
.reverse() // chronological order (oldest first)
|
|
368
|
+
.map((row) => {
|
|
369
|
+
const prompt =
|
|
370
|
+
((row.metadata as Record<string, unknown>)?.prompt as string) || "";
|
|
371
|
+
const content = (row.content as string) || "";
|
|
372
|
+
const response =
|
|
373
|
+
row.status === "failed"
|
|
374
|
+
? `[Task failed] ${content}`
|
|
375
|
+
: content;
|
|
376
|
+
return { prompt, response };
|
|
377
|
+
})
|
|
378
|
+
.filter((entry) => entry.prompt && entry.response);
|
|
379
|
+
}
|
|
380
|
+
|
|
332
381
|
// ── Event Streaming ─────────────────────────────────────────────────
|
|
333
382
|
|
|
334
383
|
export type EventType =
|
package/src/tools/browser.ts
CHANGED
|
@@ -583,6 +583,79 @@ export class BrowserController {
|
|
|
583
583
|
isConnected(): boolean {
|
|
584
584
|
return this.connected && this.ws?.readyState === WebSocket.OPEN;
|
|
585
585
|
}
|
|
586
|
+
|
|
587
|
+
// ── Login Detection ────────────────────────────────────────────
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Detect if the current page appears to be a login/authentication page.
|
|
591
|
+
* Checks URL patterns, password input fields, and login form actions.
|
|
592
|
+
*/
|
|
593
|
+
async detectLoginPage(): Promise<{ isLoginPage: boolean; reason: string }> {
|
|
594
|
+
try {
|
|
595
|
+
const result = await this.send("Runtime.evaluate", {
|
|
596
|
+
expression: `
|
|
597
|
+
(function() {
|
|
598
|
+
var url = window.location.href.toLowerCase();
|
|
599
|
+
|
|
600
|
+
// URL-based detection
|
|
601
|
+
var loginPatterns = [
|
|
602
|
+
'/login', '/signin', '/sign-in', '/sign_in',
|
|
603
|
+
'/auth/', '/sso/', '/oauth/', '/session/new',
|
|
604
|
+
'/accounts/login', '/users/sign_in',
|
|
605
|
+
'accounts.google.com', 'login.microsoftonline.com',
|
|
606
|
+
'github.com/login', 'github.com/session',
|
|
607
|
+
'login.live.com', 'appleid.apple.com'
|
|
608
|
+
];
|
|
609
|
+
for (var i = 0; i < loginPatterns.length; i++) {
|
|
610
|
+
if (url.indexOf(loginPatterns[i]) !== -1) {
|
|
611
|
+
return JSON.stringify({
|
|
612
|
+
isLoginPage: true,
|
|
613
|
+
reason: 'URL contains login pattern: ' + loginPatterns[i]
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Password input detection (visible only)
|
|
619
|
+
var passwordInputs = document.querySelectorAll('input[type="password"]');
|
|
620
|
+
for (var j = 0; j < passwordInputs.length; j++) {
|
|
621
|
+
var input = passwordInputs[j];
|
|
622
|
+
var rect = input.getBoundingClientRect();
|
|
623
|
+
var style = window.getComputedStyle(input);
|
|
624
|
+
if (rect.width > 0 && rect.height > 0 &&
|
|
625
|
+
style.display !== 'none' && style.visibility !== 'hidden') {
|
|
626
|
+
return JSON.stringify({
|
|
627
|
+
isLoginPage: true,
|
|
628
|
+
reason: 'Page contains visible password input field'
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Login form action detection
|
|
634
|
+
var formSelectors = [
|
|
635
|
+
'form[action*="login"]', 'form[action*="signin"]',
|
|
636
|
+
'form[action*="session"]', 'form[action*="auth"]',
|
|
637
|
+
'form[action*="authenticate"]'
|
|
638
|
+
];
|
|
639
|
+
var loginForms = document.querySelectorAll(formSelectors.join(','));
|
|
640
|
+
if (loginForms.length > 0) {
|
|
641
|
+
return JSON.stringify({
|
|
642
|
+
isLoginPage: true,
|
|
643
|
+
reason: 'Page contains login form'
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return JSON.stringify({ isLoginPage: false, reason: '' });
|
|
648
|
+
})()
|
|
649
|
+
`,
|
|
650
|
+
returnByValue: true,
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
const value = (result as CDPEvalResult).result?.value as string;
|
|
654
|
+
return JSON.parse(value || '{"isLoginPage":false,"reason":""}');
|
|
655
|
+
} catch {
|
|
656
|
+
return { isLoginPage: false, reason: "" };
|
|
657
|
+
}
|
|
658
|
+
}
|
|
586
659
|
}
|
|
587
660
|
|
|
588
661
|
// ── Chrome Auto-Launch ──────────────────────────────────────────────
|
package/src/tools/index.ts
CHANGED
|
@@ -270,6 +270,81 @@ export function getToolDefinitions(): ToolDefinition[] {
|
|
|
270
270
|
];
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
+
/**
|
|
274
|
+
* After navigation, detect if the page requires login and automatically prompt
|
|
275
|
+
* the user to log in. Waits for login completion and returns context about the
|
|
276
|
+
* login result. Returns null if no login was needed.
|
|
277
|
+
*
|
|
278
|
+
* The browser uses a persistent profile (~/.assistme/browser-profile), so once
|
|
279
|
+
* the user logs in, their session is saved and reused in future sessions.
|
|
280
|
+
*/
|
|
281
|
+
async function detectAndHandleLogin(browser: BrowserController): Promise<string | null> {
|
|
282
|
+
const detection = await browser.detectLoginPage();
|
|
283
|
+
if (!detection.isLoginPage) return null;
|
|
284
|
+
|
|
285
|
+
const pageInfo = await browser.getPageInfo();
|
|
286
|
+
let siteName: string;
|
|
287
|
+
try {
|
|
288
|
+
siteName = new URL(pageInfo.url).hostname;
|
|
289
|
+
} catch {
|
|
290
|
+
siteName = pageInfo.url;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Notify user via terminal
|
|
294
|
+
console.log("\n");
|
|
295
|
+
console.log("\u2501".repeat(60));
|
|
296
|
+
console.log(" \uD83D\uDD10 LOGIN REQUIRED");
|
|
297
|
+
console.log("\u2501".repeat(60));
|
|
298
|
+
console.log(` ${detection.reason}`);
|
|
299
|
+
console.log(` Site: ${siteName}`);
|
|
300
|
+
console.log(` Please log in using the browser window.`);
|
|
301
|
+
console.log(` Your session will be saved for future use.`);
|
|
302
|
+
console.log(` (Waiting up to 120s for you to complete login)`);
|
|
303
|
+
console.log("\u2501".repeat(60));
|
|
304
|
+
console.log("\n");
|
|
305
|
+
|
|
306
|
+
// Wait for user to log in — poll for URL change or login form disappearing
|
|
307
|
+
const loginUrl = pageInfo.url;
|
|
308
|
+
const deadline = Date.now() + 120_000;
|
|
309
|
+
|
|
310
|
+
while (Date.now() < deadline) {
|
|
311
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
312
|
+
try {
|
|
313
|
+
const currentInfo = await browser.getPageInfo();
|
|
314
|
+
// URL changed = user likely logged in and was redirected
|
|
315
|
+
if (currentInfo.url !== loginUrl) {
|
|
316
|
+
// Give the page a moment to settle after redirect
|
|
317
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
318
|
+
const newPage = await browser.readPage();
|
|
319
|
+
return (
|
|
320
|
+
`Login completed. Redirected to: ${currentInfo.url}\n` +
|
|
321
|
+
`Session saved in assistme browser profile for future use.\n\n` +
|
|
322
|
+
`Current page:\n${newPage.slice(0, 3000)}`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
// Check if login form disappeared (user logged in on same page)
|
|
326
|
+
const stillLogin = await browser.detectLoginPage();
|
|
327
|
+
if (!stillLogin.isLoginPage) {
|
|
328
|
+
const newPage = await browser.readPage();
|
|
329
|
+
return (
|
|
330
|
+
`Login completed on ${siteName}.\n` +
|
|
331
|
+
`Session saved in assistme browser profile for future use.\n\n` +
|
|
332
|
+
`Current page:\n${newPage.slice(0, 3000)}`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
} catch {
|
|
336
|
+
// Connection may drop during navigation — reconnect
|
|
337
|
+
try {
|
|
338
|
+
await browser.connect();
|
|
339
|
+
} catch {
|
|
340
|
+
/* best effort */
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return `Login wait timed out after 120s. The user may still need to log in at ${siteName}.`;
|
|
346
|
+
}
|
|
347
|
+
|
|
273
348
|
/**
|
|
274
349
|
* Ensure Chrome is running with CDP and we have an active WebSocket connection.
|
|
275
350
|
* Called lazily by every browser tool so the user never has to call browser_connect explicitly.
|
|
@@ -320,9 +395,18 @@ export async function executeTool(name: string, input: Record<string, unknown>):
|
|
|
320
395
|
await ensureConnected(browser, input.tab_index as number | undefined);
|
|
321
396
|
return browser.isConnected() ? "Connected to browser." : "Failed to connect.";
|
|
322
397
|
}
|
|
323
|
-
case "browser_navigate":
|
|
398
|
+
case "browser_navigate": {
|
|
324
399
|
await ensureConnected(browser);
|
|
325
|
-
|
|
400
|
+
const navResult = await browser.navigate(input.url as string);
|
|
401
|
+
|
|
402
|
+
// Auto-detect login pages and prompt user if needed
|
|
403
|
+
const loginResult = await detectAndHandleLogin(browser);
|
|
404
|
+
if (loginResult) {
|
|
405
|
+
return navResult + "\n\n" + loginResult;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return navResult;
|
|
409
|
+
}
|
|
326
410
|
case "browser_read_page":
|
|
327
411
|
await ensureConnected(browser);
|
|
328
412
|
return browser.readPage();
|
package/src/utils/config.test.ts
CHANGED
|
@@ -28,9 +28,7 @@ vi.mock("conf", () => {
|
|
|
28
28
|
};
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
const { getConfig, setConfig, clearConfig, getConfigPath } = await import(
|
|
32
|
-
"./config.js"
|
|
33
|
-
);
|
|
31
|
+
const { getConfig, setConfig, clearConfig, getConfigPath } = await import("./config.js");
|
|
34
32
|
|
|
35
33
|
describe("config module", () => {
|
|
36
34
|
const originalEnv = { ...process.env };
|
|
@@ -74,10 +72,10 @@ describe("config module", () => {
|
|
|
74
72
|
expect(config.supabaseUrl).toBe("https://config.supabase.co");
|
|
75
73
|
});
|
|
76
74
|
|
|
77
|
-
it("returns empty string for unset values", () => {
|
|
75
|
+
it("returns defaults for supabase and empty string for unset optional values", () => {
|
|
78
76
|
const config = getConfig();
|
|
79
|
-
expect(config.supabaseUrl).
|
|
80
|
-
expect(config.supabaseAnonKey).
|
|
77
|
+
expect(config.supabaseUrl).toContain("supabase.co");
|
|
78
|
+
expect(config.supabaseAnonKey).toBeTruthy();
|
|
81
79
|
expect(config.anthropicApiKey).toBe("");
|
|
82
80
|
});
|
|
83
81
|
|