Merge remote-tracking branch 'origin/dev' into feature/composio-tools-library

This commit is contained in:
tusharmagar 2026-03-31 17:03:40 +05:30
commit 013f6bdf17
39 changed files with 2729 additions and 1027 deletions

View file

@ -24,7 +24,9 @@
"ai": "^5.0.133",
"awilix": "^12.0.5",
"chokidar": "^4.0.3",
"cors": "^2.8.6",
"cron-parser": "^5.5.0",
"express": "^5.2.1",
"glob": "^13.0.0",
"google-auth-library": "^10.5.0",
"isomorphic-git": "^1.29.0",
@ -41,6 +43,8 @@
"zod": "^4.2.1"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^25.0.3",
"@types/papaparse": "^5.5.2",
"@types/pdf-parse": "^1.1.5"

View file

@ -868,7 +868,7 @@ export async function* streamAgent({
const isInlineTaskAgent = state.agentName === "inline_task_agent";
const defaultModel = signedIn ? "gpt-5.4" : modelConfig.model;
const defaultKgModel = signedIn ? "gpt-5.4-mini" : defaultModel;
const defaultInlineTaskModel = signedIn ? "gpt-5.4-mini" : defaultModel;
const defaultInlineTaskModel = signedIn ? "gpt-5.4" : defaultModel;
const modelId = isInlineTaskAgent
? defaultInlineTaskModel
: (isKgAgent && modelConfig.knowledgeGraphModel)
@ -878,6 +878,9 @@ export async function* streamAgent({
logger.log(`using model: ${modelId}`);
let loopCounter = 0;
let voiceInput = false;
let voiceOutput: 'summary' | 'full' | null = null;
let searchEnabled = false;
while (true) {
// Check abort at the top of each iteration
signal.throwIfAborted();
@ -991,9 +994,6 @@ export async function* streamAgent({
}
// get any queued user messages
let voiceInput = false;
let voiceOutput: 'summary' | 'full' | null = null;
let searchEnabled = false;
while (true) {
const msg = await messageQueue.dequeue(runId);
if (!msg) {
@ -1061,14 +1061,14 @@ export async function* streamAgent({
}
if (voiceOutput === 'summary') {
loopLogger.log('voice output enabled (summary mode), injecting voice output prompt');
instructionsWithDateTime += `\n\n# Voice Output (MANDATORY)\nThe user has voice output enabled. You MUST start your response with <voice></voice> tags that provide a spoken summary and guide to your written response. This is NOT optional — every response MUST begin with <voice> tags.\n\nRules:\n1. ALWAYS start your response with one or more <voice> tags. Never skip them.\n2. Place ALL <voice> tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse <voice> tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate <voice> tag so it can be spoken incrementally. Do NOT wrap everything in a single <voice> block.\n4. Use voice as a TL;DR and navigation aid — do NOT read the entire response aloud.\n\nExample — if the user asks "what happened in my meeting with Sarah yesterday?":\n<voice>Your meeting with Sarah covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.</voice>\n<voice>I've pulled out the key details and action items below — the demo prep notes are at the end.</voice>\n\n## Meeting with Sarah — March 11\n(Then the full detailed written response follows without any more <voice> tags.)\n\nAny text outside <voice> tags is shown visually but not spoken.`;
instructionsWithDateTime += `\n\n# Voice Output (MANDATORY — READ THIS FIRST)\nThe user has voice output enabled. THIS IS YOUR #1 PRIORITY: you MUST start your response with <voice></voice> tags. If your response does not begin with <voice> tags, the user will hear nothing — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. Do not start with markdown, headings, or any other text. The literal first characters of your response must be "<voice>".\n2. Place ALL <voice> tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse <voice> tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate <voice> tag so it can be spoken incrementally. Do NOT wrap everything in a single <voice> block.\n4. Use voice as a TL;DR and navigation aid — do NOT read the entire response aloud.\n5. After all <voice> tags, you may include detailed written content (markdown, tables, code, etc.) that will be shown visually but not spoken.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.</voice>\n<voice>I've pulled out the key details and action items below — the demo prep notes are at the end.</voice>\n\n## Meeting with Alex — March 11\n### Roadmap\n- Agreed to push Q2 launch to April 15...\n(detailed written content continues)\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You have five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you requested and Taylor flagged a contract issue.</voice>\n<voice>There's also a warm intro from a VC partner connecting you with someone at a prospective customer.</voice>\n<voice>I've drafted responses for three of them. The details and drafts are below.</voice>\n\n(email blocks, tables, and detailed content follow)\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a pretty packed day — seven meetings starting with standup at 9.</voice>\n<voice>The big ones are your investor call at 11, lunch with a partner from your lead VC at 12:30, and a customer call at 4.</voice>\n<voice>Your only free block for deep work is 2:30 to 4.</voice>\n\n(calendar block with full event details follows)\n\nExample 4 — User asks: "draft an email to Sam with our metrics"\n\n<voice>Done — I've drafted the email to Sam with your latest WAU and churn numbers.</voice>\n<voice>Take a look at the draft below and send it when you're ready.</voice>\n\n(email block with draft follows)\n\nREMEMBER: If you do not start with <voice> tags, the user hears silence. Always speak first, then write.`;
} else if (voiceOutput === 'full') {
loopLogger.log('voice output enabled (full mode), injecting voice output prompt');
instructionsWithDateTime += `\n\n# Voice Output — Full Read-Aloud (MANDATORY)\nThe user wants your ENTIRE response spoken aloud. You MUST wrap your full response in <voice></voice> tags. This is NOT optional.\n\nRules:\n1. Wrap EACH sentence in its own separate <voice> tag so it can be spoken incrementally.\n2. Write your response in a natural, conversational style suitable for listening — no markdown headings, bullet points, or formatting symbols. Use plain spoken language.\n3. Structure the content as if you are speaking to the user directly. Use transitions like "first", "also", "one more thing" instead of visual formatting.\n4. Every sentence MUST be inside a <voice> tag. Do not leave any content outside <voice> tags.\n\nExample:\n<voice>Your meeting with Sarah covered three main things.</voice>\n<voice>First, you discussed the Q2 roadmap timeline and agreed to push the launch to April.</voice>\n<voice>Second, you talked about hiring for the backend role — Sarah will send over two candidates by Friday.</voice>\n<voice>And lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.</voice>`;
instructionsWithDateTime += `\n\n# Voice Output — Full Read-Aloud (MANDATORY — READ THIS FIRST)\nThe user wants your ENTIRE response spoken aloud. THIS IS YOUR #1 PRIORITY: every single sentence must be wrapped in <voice></voice> tags. If you write anything outside <voice> tags, the user will not hear it — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. The literal first characters of your response must be "<voice>".\n2. Wrap EACH sentence in its own separate <voice> tag so it can be spoken incrementally.\n3. Write your response in a natural, conversational style suitable for listening — no markdown headings, bullet points, or formatting symbols. Use plain spoken language.\n4. Structure the content as if you are speaking to the user directly. Use transitions like "first", "also", "one more thing" instead of visual formatting.\n5. EVERY sentence MUST be inside a <voice> tag. Do not leave ANY content outside <voice> tags. If it's not in a <voice> tag, the user cannot hear it.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things.</voice>\n<voice>First, you discussed the Q2 roadmap timeline and agreed to push the launch to April.</voice>\n<voice>Second, you talked about hiring for the backend role — Alex will send over two candidates by Friday.</voice>\n<voice>And lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.</voice>\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You've got five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you asked for, and Taylor flagged a contract issue that needs your sign-off.</voice>\n<voice>There's a warm intro from a VC partner connecting you with an engineering lead at a potential customer.</voice>\n<voice>And someone from a prospective client wants to confirm your API tier before your call this afternoon.</voice>\n<voice>I've drafted replies for three of them — the metrics update, the intro, and the API question.</voice>\n<voice>The only one I left for you is Taylor's contract redline, since that needs your judgment on the liability cap.</voice>\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a packed day — seven meetings starting with standup at 9.</voice>\n<voice>The highlights are your investor call at 11, lunch with a VC partner at 12:30, and a customer call at 4.</voice>\n<voice>Your only open block for deep work is 2:30 to 4, so plan accordingly.</voice>\n<voice>Oh, and your 1-on-1 with your co-founder is at 5:30 — that's a walking meeting.</voice>\n\nExample 4 — User asks: "how are our metrics looking?"\n\n<voice>Metrics are looking strong this week.</voice>\n<voice>You hit 2,573 weekly active users, which is up 12% week over week.</voice>\n<voice>That means you've crossed the 2,500 milestone — worth calling out in your next investor update.</voice>\n<voice>Churn is down to 4.1%, improving month over month.</voice>\n<voice>The trailing 8-week compound growth rate is about 10%.</voice>\n\nREMEMBER: Start with <voice> immediately. No preamble, no markdown before it. Speak first.`;
}
if (searchEnabled) {
loopLogger.log('search enabled, injecting search prompt');
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Load the search skill and use web search or research search as needed to answer their query.`;
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Use the web-search tool to answer their query.`;
}
let streamError: string | null = null;
for await (const event of streamLlm(

View file

@ -255,7 +255,7 @@ ${runtimeContextPrompt}
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
- \`loadSkill\` - Skill loading
- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.
- \`web-search\` and \`research-search\` - Web and research search tools (available when configured). **You MUST load the \`web-search\` skill before using either of these tools.** It tells you which tool to pick and how many searches to do.
- \`web-search\` - Search the web. Returns rich results with full text, highlights, and metadata. The \`category\` parameter defaults to \`general\` (full web search) — only use a specific category like \`news\`, \`company\`, \`research paper\` etc. when the query is clearly about that type. For everyday queries (weather, restaurants, prices, how-to), use \`general\`.
- \`app-navigation\` - Control the app UI: open notes, switch views, filter/search the knowledge base, manage saved views. **Load the \`app-navigation\` skill before using this tool.**
- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
- **Composio tools** (\`composio-*\`) — External service integrations enabled by the user in Settings > Tools Library. These connect to third-party apps like Gmail, GitHub, Linear, Notion, etc. See the "Composio Integration Tools" section below for available tools.

View file

@ -10,7 +10,7 @@ import organizeFilesSkill from "./organize-files/skill.js";
import slackSkill from "./slack/skill.js";
import backgroundAgentsSkill from "./background-agents/skill.js";
import createPresentationsSkill from "./create-presentations/skill.js";
import webSearchSkill from "./web-search/skill.js";
import appNavigationSkill from "./app-navigation/skill.js";
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
@ -84,12 +84,6 @@ const definitions: SkillDefinition[] = [
summary: "Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.",
content: mcpIntegrationSkill,
},
{
id: "web-search",
title: "Web Search",
summary: "Searching the web or researching a topic. Guidance on when to use web-search vs research-search, and how many searches to do.",
content: webSearchSkill,
},
{
id: "deletion-guardrails",
title: "Deletion Guardrails",

View file

@ -1029,123 +1029,15 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
// ============================================================================
// Web Search (Brave Search API)
// Web Search (Exa Search API)
// ============================================================================
'web-search': {
description: 'Search the web using Brave Search. Returns web results with titles, URLs, and descriptions.',
inputSchema: z.object({
query: z.string().describe('The search query'),
count: z.number().optional().describe('Number of results to return (default: 5, max: 20)'),
freshness: z.string().optional().describe('Filter by freshness: pd (past day), pw (past week), pm (past month), py (past year)'),
}),
isAvailable: async () => {
if (await isSignedIn()) return true;
try {
const braveConfigPath = path.join(WorkDir, 'config', 'brave-search.json');
const raw = await fs.readFile(braveConfigPath, 'utf8');
const config = JSON.parse(raw);
return !!config.apiKey;
} catch {
return false;
}
},
execute: async ({ query, count, freshness }: { query: string; count?: number; freshness?: string }) => {
try {
const resultCount = Math.min(Math.max(count || 5, 1), 20);
const params = new URLSearchParams({
q: query,
count: String(resultCount),
});
if (freshness) {
params.set('freshness', freshness);
}
let response: Response;
if (await isSignedIn()) {
// Use proxy
const accessToken = await getAccessToken();
response = await fetch(`${API_URL}/v1/search/brave?${params.toString()}`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json',
},
});
} else {
// Read API key from config
const braveConfigPath = path.join(WorkDir, 'config', 'brave-search.json');
let apiKey: string;
try {
const raw = await fs.readFile(braveConfigPath, 'utf8');
const config = JSON.parse(raw);
apiKey = config.apiKey;
} catch {
return {
success: false,
error: 'Brave Search API key not configured. Create ~/.rowboat/config/brave-search.json with { "apiKey": "<your-key>" }',
};
}
if (!apiKey) {
return {
success: false,
error: 'Brave Search API key is empty. Set "apiKey" in ~/.rowboat/config/brave-search.json',
};
}
response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params.toString()}`, {
headers: {
'X-Subscription-Token': apiKey,
'Accept': 'application/json',
},
});
}
if (!response.ok) {
const body = await response.text();
return {
success: false,
error: `Brave Search API error (${response.status}): ${body}`,
};
}
const data = await response.json() as {
web?: { results?: Array<{ title?: string; url?: string; description?: string }> };
};
const results = (data.web?.results || []).map((r: { title?: string; url?: string; description?: string }) => ({
title: r.title || '',
url: r.url || '',
description: r.description || '',
}));
return {
success: true,
query,
results,
count: results.length,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
},
},
// ============================================================================
// Research Search (Exa Search API)
// ============================================================================
'research-search': {
description: 'Use this for finding articles, blog posts, papers, companies, people, or exploring a topic in depth. Best for discovery and research where you need quality sources, not a quick fact.',
description: 'Search the web for articles, blog posts, papers, companies, people, news, or explore a topic in depth. Returns rich results with full text, highlights, and metadata.',
inputSchema: z.object({
query: z.string().describe('The search query'),
numResults: z.number().optional().describe('Number of results to return (default: 5, max: 20)'),
category: z.enum(['company', 'research paper', 'news', 'tweet', 'personal site', 'financial report', 'people']).optional().describe('Filter results by category'),
category: z.enum(['general', 'company', 'research paper', 'news', 'tweet', 'personal site', 'financial report', 'people']).optional().describe('Search category. Defaults to "general" which searches the entire web. Only use a specific category when the query is clearly about that type (e.g. "research paper" for academic papers, "company" for company info). For everyday queries like weather, restaurants, prices, how-to, etc., use "general" or omit entirely.'),
}),
isAvailable: async () => {
if (await isSignedIn()) return true;
@ -1171,7 +1063,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
highlights: true,
},
};
if (category) {
if (category && category !== 'general') {
reqBody.category = category;
}

View file

@ -0,0 +1,96 @@
# Page Capture Chrome Extension
A Chrome extension that captures web pages you visit and sends them to a local server for storage as markdown files.
## Structure
```
/extension
manifest.json # Chrome extension manifest (v3)
background.js # Service worker that captures pages
/server
server.py # Flask server for storing captures
captured_pages/ # Directory where pages are saved
```
## Setup
### 1. Install Server Dependencies
```bash
cd server
pip install flask flask-cors
```
### 2. Start the Server
```bash
cd server
python server.py
```
The server will run at `http://localhost:3001`.
### 3. Install the Chrome Extension
1. Open Chrome and navigate to `chrome://extensions/`
2. Enable "Developer mode" (toggle in top right)
3. Click "Load unpacked"
4. Select the `extension` folder
## Usage
Once both the server is running and the extension is installed, the extension will automatically capture pages as you browse:
- Every page load (http/https URLs only) triggers a capture
- Content is hashed with SHA-256 to avoid duplicate captures
- Pages are saved as markdown files with frontmatter metadata
## API Endpoints
### POST /capture
Receives captured page data.
**Request body:**
```json
{
"url": "https://example.com",
"content": "Page text content...",
"timestamp": 1706123456789,
"title": "Page Title"
}
```
**Response:**
```json
{"status": "captured", "filename": "1706123456789_example_com.md"}
```
### GET /status
Returns the count of captured pages.
**Response:**
```json
{"count": 42}
```
## File Format
Captured pages are saved as markdown with YAML frontmatter:
```markdown
---
url: https://example.com/page
title: Page Title
captured_at: 2024-01-24T12:34:56
---
Page content here...
```
## Debugging
- **Extension logs**: Open `chrome://extensions/`, find "Page Capture", click "Service worker" to view console logs
- **Server logs**: Check the terminal where `server.py` is running

View file

@ -0,0 +1,388 @@
const SERVER_URL = 'http://localhost:3001';
const contentHashMap = new Map();
let cachedConfig = null;
let serverReachable = true;
// Default config
const DEFAULT_CONFIG = {
mode: 'ask',
whitelist: [],
blacklist: [],
enabled: true
};
// Config management
async function loadConfig() {
try {
const response = await fetch(`${SERVER_URL}/browse/config`);
if (response.ok) {
cachedConfig = await response.json();
serverReachable = true;
} else {
throw new Error('Server returned error');
}
} catch (error) {
console.log(`[Page Capture] Failed to load config: ${error.message}`);
serverReachable = false;
cachedConfig = cachedConfig || DEFAULT_CONFIG;
}
return cachedConfig;
}
async function saveConfig(config) {
try {
const response = await fetch(`${SERVER_URL}/browse/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (response.ok) {
cachedConfig = config;
serverReachable = true;
return true;
}
} catch (error) {
console.log(`[Page Capture] Failed to save config: ${error.message}`);
serverReachable = false;
}
return false;
}
function getConfig() {
return cachedConfig || DEFAULT_CONFIG;
}
function extractDomain(url) {
try {
const parsed = new URL(url);
return parsed.hostname;
} catch {
return null;
}
}
function isWhitelisted(domain) {
const config = getConfig();
return config.whitelist.some(d => domain === d || domain.endsWith('.' + d));
}
function isBlacklisted(domain) {
const config = getConfig();
return config.blacklist.some(d => domain === d || domain.endsWith('.' + d));
}
function getDomainStatus(domain) {
const config = getConfig();
if (isBlacklisted(domain)) return 'blacklisted';
if (config.mode === 'all') return 'capturing';
if (isWhitelisted(domain)) return 'whitelisted';
return 'unknown';
}
function shouldCapture(domain) {
const config = getConfig();
if (!config.enabled) return false;
if (isBlacklisted(domain)) return false;
if (config.mode === 'all') return true;
return isWhitelisted(domain);
}
// Badge management
async function setBadge(tabId, type) {
try {
if (type === 'needs-approval') {
await chrome.action.setBadgeText({ tabId, text: '?' });
await chrome.action.setBadgeBackgroundColor({ tabId, color: '#F59E0B' });
} else if (type === 'server-error') {
await chrome.action.setBadgeText({ tabId, text: '!' });
await chrome.action.setBadgeBackgroundColor({ tabId, color: '#EF4444' });
} else {
await chrome.action.setBadgeText({ tabId, text: '' });
}
} catch (error) {
console.log(`[Page Capture] Failed to set badge: ${error.message}`);
}
}
async function updateBadgeForTab(tabId, url) {
if (!serverReachable) {
await setBadge(tabId, 'server-error');
return;
}
const domain = extractDomain(url);
if (!domain) {
await setBadge(tabId, 'clear');
return;
}
const status = getDomainStatus(domain);
if (status === 'unknown') {
await setBadge(tabId, 'needs-approval');
} else {
await setBadge(tabId, 'clear');
}
}
// Content hashing
async function hashContent(content) {
const encoder = new TextEncoder();
const data = encoder.encode(content);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
function isValidUrl(url) {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
async function capturePageContent(tabId) {
try {
const results = await chrome.scripting.executeScript({
target: { tabId },
func: () => document.body.innerText
});
return results[0]?.result || '';
} catch (error) {
console.log(`[Page Capture] Failed to capture content: ${error.message}`);
return null;
}
}
async function sendToServer(data) {
try {
const response = await fetch(`${SERVER_URL}/capture`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
serverReachable = response.ok;
return response.ok;
} catch (error) {
console.log(`[Page Capture] Failed to send to server: ${error.message}`);
serverReachable = false;
return false;
}
}
async function captureTab(tabId, tab) {
const content = await capturePageContent(tabId);
if (content === null) return false;
const hash = await hashContent(content);
const lastHash = contentHashMap.get(tab.url);
if (lastHash === hash) {
console.log(`[Page Capture] Content unchanged for: ${tab.url}`);
return true;
}
contentHashMap.set(tab.url, hash);
const payload = {
url: tab.url,
content,
timestamp: Date.now(),
title: tab.title || 'Untitled'
};
const success = await sendToServer(payload);
if (success) {
console.log(`[Page Capture] Captured: ${tab.url}`);
}
return success;
}
// Tab update listener
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
if (changeInfo.status !== 'complete') return;
if (!isValidUrl(tab.url)) {
console.log(`[Page Capture] Skipping non-http URL: ${tab.url}`);
return;
}
const domain = extractDomain(tab.url);
if (!domain) return;
await updateBadgeForTab(tabId, tab.url);
if (!shouldCapture(domain)) {
console.log(`[Page Capture] Skipping (not whitelisted): ${tab.url}`);
return;
}
await captureTab(tabId, tab);
});
// Tab activated listener - update badge
chrome.tabs.onActivated.addListener(async (activeInfo) => {
try {
const tab = await chrome.tabs.get(activeInfo.tabId);
if (tab.url && isValidUrl(tab.url)) {
await updateBadgeForTab(activeInfo.tabId, tab.url);
}
} catch (error) {
console.log(`[Page Capture] Failed to update badge on tab switch: ${error.message}`);
}
});
// Handle scroll capture messages from content script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'SCROLL_CAPTURE') {
const { url, content, timestamp, title, scrollY } = message;
const domain = extractDomain(url);
if (!shouldCapture(domain)) {
console.log(`[Page Capture] Skipping scroll capture (not whitelisted): ${url}`);
return;
}
console.log(`[Page Capture] Received scroll capture for: ${url}`);
hashContent(content).then(async (hash) => {
const lastHash = contentHashMap.get(url);
if (lastHash === hash) {
console.log(`[Page Capture] Hash unchanged, skipping: ${url}`);
return;
}
contentHashMap.set(url, hash);
const payload = { url, content, timestamp, title };
const success = await sendToServer(payload);
if (success) {
console.log(`[Page Capture] Scroll captured (y=${scrollY}): ${url}`);
}
});
return;
}
// Handle messages from popup
if (message.type === 'GET_CONFIG') {
loadConfig().then(config => {
sendResponse({ config, serverReachable });
});
return true;
}
if (message.type === 'SAVE_CONFIG') {
saveConfig(message.config).then(success => {
sendResponse({ success });
// Update badges on all tabs
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'GET_DOMAIN_STATUS') {
const domain = extractDomain(message.url);
const status = domain ? getDomainStatus(domain) : 'unknown';
sendResponse({ status, domain, serverReachable });
return true;
}
if (message.type === 'APPROVE_DOMAIN') {
const config = getConfig();
const domain = message.domain;
if (!config.whitelist.includes(domain)) {
config.whitelist.push(domain);
}
config.blacklist = config.blacklist.filter(d => d !== domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'REJECT_DOMAIN') {
const config = getConfig();
const domain = message.domain;
if (!config.blacklist.includes(domain)) {
config.blacklist.push(domain);
}
config.whitelist = config.whitelist.filter(d => d !== domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'CAPTURE_ONCE') {
chrome.tabs.query({ active: true, currentWindow: true }, async tabs => {
if (tabs[0]) {
const success = await captureTab(tabs[0].id, tabs[0]);
sendResponse({ success });
} else {
sendResponse({ success: false });
}
});
return true;
}
if (message.type === 'REMOVE_FROM_WHITELIST') {
const config = getConfig();
config.whitelist = config.whitelist.filter(d => d !== message.domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'REMOVE_FROM_BLACKLIST') {
const config = getConfig();
config.blacklist = config.blacklist.filter(d => d !== message.domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
});
// Load config on startup
loadConfig().then(() => {
console.log('[Page Capture] Config loaded');
});
console.log('[Page Capture] Service worker started');

View file

@ -0,0 +1,81 @@
const DEBOUNCE_MS = 800;
const MIN_SCROLL_PIXELS = 500;
const MIN_CONTENT_CHANGE = 100; // characters
let debounceTimer = null;
let lastCapturedContent = null;
let lastScrollTop = 0;
let scrollContainer = null;
function getScrollTop() {
if (!scrollContainer || scrollContainer === window) {
return window.scrollY;
}
if (scrollContainer === document) {
return document.documentElement.scrollTop;
}
return scrollContainer.scrollTop || 0;
}
function captureAndSend() {
const content = document.body.innerText;
// Skip if content unchanged or minimal change
if (lastCapturedContent) {
const lengthDiff = Math.abs(content.length - lastCapturedContent.length);
if (content === lastCapturedContent || lengthDiff < MIN_CONTENT_CHANGE) {
return;
}
}
lastCapturedContent = content;
lastScrollTop = getScrollTop();
chrome.runtime.sendMessage({
type: 'SCROLL_CAPTURE',
url: window.location.href,
title: document.title,
content: content,
timestamp: Date.now(),
scrollY: lastScrollTop
});
}
function onScroll() {
const currentScrollTop = getScrollTop();
const scrollDelta = Math.abs(currentScrollTop - lastScrollTop);
if (scrollDelta < MIN_SCROLL_PIXELS) {
return;
}
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
captureAndSend();
}, DEBOUNCE_MS);
}
function init() {
// Use document with capture to catch scroll events from any element
document.addEventListener('scroll', (e) => {
const target = e.target;
const scrollTop = target === document ? document.documentElement.scrollTop : target.scrollTop;
// Update scroll container if we found the real one
if (scrollTop > 0 && scrollContainer !== target) {
scrollContainer = target;
}
onScroll();
}, { capture: true, passive: true });
}
// Wait for page to be ready, then init
if (document.readyState === 'complete') {
init();
} else {
window.addEventListener('load', init);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,40 @@
{
"manifest_version": 3,
"name": "Rowboat Browser Capture",
"version": "1.1.1",
"description": "Allows users to save and capture web page content to their Rowboat workspace.",
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": [
"tabs",
"scripting",
"activeTab"
],
"host_permissions": [
"http://*/*",
"https://*/*"
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}

View file

@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rowboat</title>
<link rel="stylesheet" href="styles.css">
<style>
body {
width: 320px;
padding: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.domain {
font-weight: 500;
font-size: 14px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.approval-section {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.approval-title {
font-weight: 500;
margin-bottom: 8px;
}
.approval-buttons {
display: flex;
gap: 8px;
}
.approval-buttons .btn {
flex: 1;
}
.toggle-section {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
margin-bottom: 12px;
}
.toggle-label {
font-size: 13px;
color: var(--text-secondary);
}
.error-message {
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid var(--error-color);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
color: var(--error-color);
font-size: 13px;
}
.settings-section {
border-top: 1px solid var(--border-color);
padding-top: 12px;
margin-top: 4px;
}
.settings-title {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.settings-radio {
display: flex;
flex-direction: column;
gap: 6px;
}
.settings-radio label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
padding: 4px 0;
}
.settings-radio input[type="radio"] {
accent-color: var(--accent-color);
}
.stats {
display: flex;
align-items: center;
padding-top: 12px;
border-top: 1px solid var(--border-color);
margin-top: 12px;
}
.stats-count {
font-size: 12px;
color: var(--text-muted);
}
.hidden {
display: none !important;
}
</style>
</head>
<body>
<div class="header">
<span class="domain" id="domainDisplay">-</span>
<span class="status-badge" id="statusBadge">
<span class="status-dot"></span>
<span id="statusText">-</span>
</span>
</div>
<div class="error-message hidden" id="errorMessage">
Cannot reach Rowboat app.
</div>
<div class="approval-section hidden" id="approvalSection">
<div class="approval-title">Index this site?</div>
<div class="approval-buttons">
<button class="btn btn-primary btn-sm" id="approveBtn">Yes, always</button>
<button class="btn btn-secondary btn-sm" id="rejectBtn">No</button>
</div>
<button class="btn btn-secondary btn-sm btn-block mt-2" id="captureOnceBtn">Just this page</button>
</div>
<div class="toggle-section hidden" id="toggleSection">
<span class="toggle-label" id="toggleLabel">Capturing this site</span>
<button class="btn btn-secondary btn-sm" id="toggleBtn">Stop</button>
</div>
<div class="settings-section">
<div class="settings-title">Settings</div>
<div class="settings-radio">
<label>
<input type="radio" name="captureMode" value="work">
Auto-index active tab
</label>
<label>
<input type="radio" name="captureMode" value="ask">
Ask me each time
</label>
</div>
</div>
<div class="stats">
<span class="stats-count" id="statsCount">-</span>
</div>
<script src="popup.js"></script>
</body>
</html>

View file

@ -0,0 +1,258 @@
const SERVER_URL = 'http://localhost:3001';
let currentDomain = null;
let currentStatus = null;
let currentConfig = null;
async function getCurrentTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab;
}
function extractDomain(url) {
try {
const parsed = new URL(url);
return parsed.hostname;
} catch {
return null;
}
}
function updateStatusBadge(status, serverReachable) {
const badge = document.getElementById('statusBadge');
const statusText = document.getElementById('statusText');
badge.classList.remove('capturing', 'not-capturing', 'awaiting', 'error');
if (!serverReachable) {
badge.classList.add('error');
statusText.textContent = 'Error';
return;
}
switch (status) {
case 'whitelisted':
case 'capturing':
badge.classList.add('capturing');
statusText.textContent = 'Indexing';
break;
case 'blacklisted':
badge.classList.add('not-capturing');
statusText.textContent = 'Not indexing';
break;
case 'unknown':
badge.classList.add('awaiting');
statusText.textContent = 'Awaiting';
break;
default:
badge.classList.add('not-capturing');
statusText.textContent = 'Unknown';
}
}
function showApprovalSection(show) {
document.getElementById('approvalSection').classList.toggle('hidden', !show);
}
function showToggleSection(show, isCapturing) {
const section = document.getElementById('toggleSection');
const label = document.getElementById('toggleLabel');
const btn = document.getElementById('toggleBtn');
section.classList.toggle('hidden', !show);
if (isCapturing) {
label.textContent = 'Capturing this site';
btn.textContent = 'Stop';
btn.onclick = () => removeDomain('whitelist');
} else {
label.textContent = 'Not capturing this site';
btn.textContent = 'Start';
btn.onclick = () => removeDomain('blacklist');
}
}
function showError(show) {
document.getElementById('errorMessage').classList.toggle('hidden', !show);
}
// Settings section
function getSelectedMode(config) {
return config.mode === 'all' ? 'work' : 'ask';
}
function initSettings(config) {
currentConfig = config;
const mode = getSelectedMode(config);
const radio = document.querySelector(`input[name="captureMode"][value="${mode}"]`);
if (radio) radio.checked = true;
}
async function saveSettingsFromUI() {
const selectedRadio = document.querySelector('input[name="captureMode"]:checked');
const mode = selectedRadio ? selectedRadio.value : 'ask';
let config;
if (mode === 'work') {
config = {
mode: 'all',
whitelist: currentConfig ? currentConfig.whitelist : [],
blacklist: currentConfig ? currentConfig.blacklist : [],
enabled: true
};
} else {
config = {
mode: 'ask',
whitelist: currentConfig ? currentConfig.whitelist : [],
blacklist: currentConfig ? currentConfig.blacklist : [],
enabled: true
};
}
try {
await chrome.runtime.sendMessage({ type: 'SAVE_CONFIG', config });
currentConfig = config;
await loadStatus();
} catch (error) {
console.error('Failed to save settings:', error);
}
}
// Domain status
async function loadStatus() {
const tab = await getCurrentTab();
if (!tab || !tab.url) {
document.getElementById('domainDisplay').textContent = 'No page';
return;
}
currentDomain = extractDomain(tab.url);
if (!currentDomain) {
document.getElementById('domainDisplay').textContent = 'Invalid URL';
return;
}
document.getElementById('domainDisplay').textContent = currentDomain;
try {
const response = await chrome.runtime.sendMessage({
type: 'GET_DOMAIN_STATUS',
url: tab.url
});
currentStatus = response.status;
const serverReachable = response.serverReachable;
updateStatusBadge(currentStatus, serverReachable);
showError(!serverReachable);
if (!serverReachable) {
showApprovalSection(false);
showToggleSection(false, false);
return;
}
if (currentStatus === 'unknown') {
showApprovalSection(true);
showToggleSection(false, false);
} else if (currentStatus === 'whitelisted' || currentStatus === 'capturing') {
showApprovalSection(false);
showToggleSection(true, true);
} else if (currentStatus === 'blacklisted') {
showApprovalSection(false);
showToggleSection(true, false);
} else {
showApprovalSection(false);
showToggleSection(false, false);
}
} catch (error) {
console.error('Failed to get status:', error);
showError(true);
}
}
async function loadStats() {
try {
const response = await fetch(`${SERVER_URL}/status`);
if (response.ok) {
const data = await response.json();
document.getElementById('statsCount').textContent = `${data.count} pages indexed locally`;
}
} catch (error) {
console.log('Failed to load stats:', error);
}
}
async function approveDomain() {
if (!currentDomain) return;
try {
await chrome.runtime.sendMessage({ type: 'APPROVE_DOMAIN', domain: currentDomain });
// Reload config to reflect the new whitelist in settings
const resp = await chrome.runtime.sendMessage({ type: 'GET_CONFIG' });
if (resp && resp.config) initSettings(resp.config);
await loadStatus();
} catch (error) {
console.error('Failed to approve domain:', error);
}
}
async function rejectDomain() {
if (!currentDomain) return;
try {
await chrome.runtime.sendMessage({ type: 'REJECT_DOMAIN', domain: currentDomain });
await loadStatus();
} catch (error) {
console.error('Failed to reject domain:', error);
}
}
async function captureOnce() {
try {
const response = await chrome.runtime.sendMessage({ type: 'CAPTURE_ONCE' });
if (response.success) {
window.close();
}
} catch (error) {
console.error('Failed to capture:', error);
}
}
async function removeDomain(list) {
if (!currentDomain) return;
try {
const messageType = list === 'whitelist' ? 'REMOVE_FROM_WHITELIST' : 'REMOVE_FROM_BLACKLIST';
await chrome.runtime.sendMessage({ type: messageType, domain: currentDomain });
// Reload config to reflect changes in settings
const resp = await chrome.runtime.sendMessage({ type: 'GET_CONFIG' });
if (resp && resp.config) initSettings(resp.config);
await loadStatus();
} catch (error) {
console.error('Failed to remove domain:', error);
}
}
document.addEventListener('DOMContentLoaded', async () => {
// Load config and init settings
try {
const resp = await chrome.runtime.sendMessage({ type: 'GET_CONFIG' });
if (resp && resp.config) {
initSettings(resp.config);
}
} catch (error) {
console.error('Failed to load config:', error);
}
// Radio change listeners
document.querySelectorAll('input[name="captureMode"]').forEach(radio => {
radio.addEventListener('change', () => saveSettingsFromUI());
});
loadStatus();
loadStats();
document.getElementById('approveBtn').addEventListener('click', approveDomain);
document.getElementById('rejectBtn').addEventListener('click', rejectDomain);
document.getElementById('captureOnceBtn').addEventListener('click', captureOnce);
});

View file

@ -0,0 +1,279 @@
:root {
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-tertiary: #f3f4f6;
--text-primary: #111827;
--text-secondary: #6b7280;
--text-muted: #9ca3af;
--border-color: #e5e7eb;
--accent-color: #3b82f6;
--accent-hover: #2563eb;
--success-color: #10b981;
--warning-color: #f59e0b;
--error-color: #ef4444;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1f2937;
--bg-secondary: #111827;
--bg-tertiary: #374151;
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--text-muted: #9ca3af;
--border-color: #374151;
--accent-color: #60a5fa;
--accent-hover: #3b82f6;
--success-color: #34d399;
--warning-color: #fbbf24;
--error-color: #f87171;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.3);
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
background-color: var(--bg-primary);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--accent-color);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--accent-hover);
}
.btn-secondary {
background-color: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover:not(:disabled) {
background-color: var(--border-color);
}
.btn-ghost {
background-color: transparent;
color: var(--text-secondary);
}
.btn-ghost:hover:not(:disabled) {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
.btn-block {
width: 100%;
}
/* Status badges */
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.status-badge.capturing {
background-color: rgba(16, 185, 129, 0.1);
color: var(--success-color);
}
.status-badge.not-capturing {
background-color: rgba(107, 114, 128, 0.1);
color: var(--text-secondary);
}
.status-badge.awaiting {
background-color: rgba(245, 158, 11, 0.1);
color: var(--warning-color);
}
.status-badge.error {
background-color: rgba(239, 68, 68, 0.1);
color: var(--error-color);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: currentColor;
}
/* Cards */
.card {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
}
/* Form elements */
.radio-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.radio-option {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
}
.radio-option:hover {
border-color: var(--accent-color);
background-color: var(--bg-secondary);
}
.radio-option.selected {
border-color: var(--accent-color);
background-color: rgba(59, 130, 246, 0.05);
}
.radio-option input[type="radio"] {
margin-top: 2px;
accent-color: var(--accent-color);
}
.radio-option-content {
flex: 1;
}
.radio-option-title {
font-weight: 500;
color: var(--text-primary);
}
.radio-option-desc {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
/* Toggle/Checkbox */
.toggle-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
padding-left: 24px;
}
.toggle-item {
display: flex;
align-items: center;
gap: 8px;
}
.toggle-item input[type="checkbox"] {
accent-color: var(--accent-color);
}
.toggle-item label {
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
}
/* Divider */
.divider {
height: 1px;
background-color: var(--border-color);
margin: 12px 0;
}
/* Link */
.link {
color: var(--accent-color);
text-decoration: none;
font-size: 13px;
}
.link:hover {
text-decoration: underline;
}
/* Text utilities */
.text-sm {
font-size: 12px;
}
.text-muted {
color: var(--text-muted);
}
.text-secondary {
color: var(--text-secondary);
}
.text-center {
text-align: center;
}
/* Spacing utilities */
.mt-1 { margin-top: 4px; }
.mt-2 { margin-top: 8px; }
.mt-3 { margin-top: 12px; }
.mt-4 { margin-top: 16px; }
.mb-1 { margin-bottom: 4px; }
.mb-2 { margin-bottom: 8px; }
.mb-3 { margin-bottom: 12px; }
.mb-4 { margin-bottom: 16px; }
/* Flex utilities */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-1 { gap: 4px; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }

View file

@ -0,0 +1,281 @@
import express from 'express';
import cors from 'cors';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../../../config/config.js';
const app = express();
app.use(cors());
app.use(express.json({ limit: '10mb' }));
const CAPTURED_PAGES_DIR = path.join(WorkDir, 'chrome_sync');
const CONFIG_DIR = path.join(WorkDir, 'config');
const CONFIG_FILE = path.join(CONFIG_DIR, 'chrome-plugin.json');
interface Config {
mode: 'all' | 'ask';
whitelist: string[];
blacklist: string[];
enabled: boolean;
}
const DEFAULT_CONFIG: Config = {
mode: 'ask',
whitelist: [],
blacklist: [],
enabled: true
};
const contentHashes = new Map<string, string>();
function extractDomain(url: string): string {
try {
const parsed = new URL(url);
return parsed.host || 'unknown';
} catch {
return 'unknown';
}
}
function pathToSlug(url: string): string {
try {
const parsed = new URL(url);
const p = parsed.pathname + (parsed.search || '');
if (!p || p === '/') return 'index';
let slug = p.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_|_$/g, '');
return slug.substring(0, 80) || 'index';
} catch {
return 'index';
}
}
function hashContent(content: string): string {
return crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
}
function findExistingFile(domainDir: string, pathSlug: string): string | null {
if (!fs.existsSync(domainDir)) return null;
const files = fs.readdirSync(domainDir);
for (const filename of files) {
if (filename.endsWith(`_${pathSlug}.md`)) {
return path.join(domainDir, filename);
}
}
return null;
}
// POST /capture
app.post('/capture', (req, res) => {
const data = req.body;
if (!data) {
return res.status(400).json({ error: 'No JSON data provided' });
}
const { url, content = '', timestamp, title = 'Untitled' } = data;
if (!url || !timestamp) {
return res.status(400).json({ error: 'Missing required fields: url, timestamp' });
}
const domain = extractDomain(url);
const pathSlug = pathToSlug(url);
const contentHash = hashContent(content);
const cacheKey = `${domain}/${pathSlug}`;
const dt = new Date(timestamp);
const year = dt.getFullYear();
const month = String(dt.getMonth() + 1).padStart(2, '0');
const day = String(dt.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
const hours = String(dt.getHours()).padStart(2, '0');
const minutes = String(dt.getMinutes()).padStart(2, '0');
const seconds = String(dt.getSeconds()).padStart(2, '0');
const timeStr = `${hours}-${minutes}`;
const timeDisplay = `${hours}:${minutes}:${seconds}`;
const tzOffset = -dt.getTimezoneOffset();
const tzSign = tzOffset >= 0 ? '+' : '-';
const tzHours = String(Math.floor(Math.abs(tzOffset) / 60)).padStart(2, '0');
const tzMins = String(Math.abs(tzOffset) % 60).padStart(2, '0');
const isoTimestamp = `${dateStr}T${hours}:${minutes}:${seconds}${tzSign}${tzHours}:${tzMins}`;
// date/domain directory structure
const domainDir = path.join(CAPTURED_PAGES_DIR, dateStr, domain);
fs.mkdirSync(domainDir, { recursive: true });
const existingFile = findExistingFile(domainDir, pathSlug);
if (existingFile && contentHashes.get(cacheKey) === contentHash) {
return res.json({ status: 'skipped', reason: 'duplicate content' });
}
contentHashes.set(cacheKey, contentHash);
// If file exists, append with scroll separator
if (existingFile) {
const scrollSeparator = `\n\n---\n📜 Scroll captured at ${timeDisplay}\n---\n\n`;
fs.appendFileSync(existingFile, scrollSeparator + content, 'utf-8');
const rel = `${dateStr}/${domain}/${path.basename(existingFile)}`;
return res.json({ status: 'appended', filename: rel });
}
// New file - create with frontmatter
const filename = `${timeStr}_${pathSlug}.md`;
const filepath = path.join(domainDir, filename);
const markdownContent = `---
url: ${url}
title: ${title}
captured_at: ${isoTimestamp}
---
${content}
`;
fs.writeFileSync(filepath, markdownContent, 'utf-8');
return res.status(201).json({ status: 'captured', filename: `${dateStr}/${domain}/${filename}` });
});
// GET /status
app.get('/status', (_req, res) => {
let count = 0;
const domains: Record<string, number> = {};
if (!fs.existsSync(CAPTURED_PAGES_DIR)) {
return res.json({ count: 0, domains: [] });
}
for (const dateEntry of fs.readdirSync(CAPTURED_PAGES_DIR)) {
const datePath = path.join(CAPTURED_PAGES_DIR, dateEntry);
if (!fs.statSync(datePath).isDirectory()) continue;
for (const domainEntry of fs.readdirSync(datePath)) {
const domainPath = path.join(datePath, domainEntry);
if (!fs.statSync(domainPath).isDirectory()) continue;
const domainCount = fs.readdirSync(domainPath).filter(f => f.endsWith('.md')).length;
count += domainCount;
if (domainCount > 0) {
domains[domainEntry] = (domains[domainEntry] || 0) + domainCount;
}
}
}
const domainList = Object.entries(domains)
.map(([domain, c]) => ({ domain, count: c }))
.sort((a, b) => b.count - a.count);
return res.json({ count, domains: domainList });
});
// Config helpers
function loadConfig(): Config {
if (fs.existsSync(CONFIG_FILE)) {
try {
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
return JSON.parse(raw);
} catch {
// fall through
}
}
return { ...DEFAULT_CONFIG };
}
function saveConfig(config: Config): void {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
}
function validateConfig(data: any): data is Config {
if (typeof data !== 'object' || data === null) return false;
if (data.mode !== 'all' && data.mode !== 'ask') return false;
if (!Array.isArray(data.whitelist)) return false;
if (!Array.isArray(data.blacklist)) return false;
if (typeof data.enabled !== 'boolean') return false;
return true;
}
// GET /browse/config
app.get('/browse/config', (_req, res) => {
const config = loadConfig();
return res.json(config);
});
// POST /browse/config
app.post('/browse/config', (req, res) => {
const data = req.body;
if (!data) {
return res.status(400).json({ error: 'No JSON data provided' });
}
if (!validateConfig(data)) {
return res.status(400).json({ error: 'Invalid config shape' });
}
saveConfig(data);
return res.json({ status: 'saved', config: data });
});
const PORT = 3001;
const RETENTION_DAYS = 7;
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
function cleanUpOldFiles(): void {
if (!fs.existsSync(CAPTURED_PAGES_DIR)) return;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - RETENTION_DAYS);
const cutoffStr = cutoff.toISOString().slice(0, 10); // YYYY-MM-DD
for (const dateEntry of fs.readdirSync(CAPTURED_PAGES_DIR)) {
// only process date-formatted directories
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateEntry)) continue;
if (dateEntry >= cutoffStr) continue;
const datePath = path.join(CAPTURED_PAGES_DIR, dateEntry);
if (!fs.statSync(datePath).isDirectory()) continue;
fs.rmSync(datePath, { recursive: true, force: true });
console.log(`[ChromeSync] Cleaned up old captures: ${dateEntry}`);
}
}
function isServerEnabled(): boolean {
if (!fs.existsSync(CONFIG_FILE)) return false;
try {
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
const config = JSON.parse(raw);
return config.serverEnabled === true;
} catch {
return false;
}
}
function startServer(): void {
fs.mkdirSync(CAPTURED_PAGES_DIR, { recursive: true });
cleanUpOldFiles();
setInterval(cleanUpOldFiles, CLEANUP_INTERVAL_MS);
app.listen(PORT, 'localhost', () => {
console.log('[ChromeSync] Server starting.');
console.log(` Captured pages: ${CAPTURED_PAGES_DIR}`);
console.log(` Config: ${CONFIG_FILE}`);
console.log(` Listening on http://localhost:${PORT}`);
});
}
export async function init(): Promise<void> {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
if (isServerEnabled()) {
startServer();
return;
}
console.log('[ChromeSync] Server disabled, watching config for changes...');
fs.watch(CONFIG_DIR, (_, filename) => {
if (filename === 'chrome-plugin.json' && isServerEnabled()) {
console.log('[ChromeSync] serverEnabled set to true, starting server...');
startServer();
}
});
}

View file

@ -15,7 +15,8 @@ const SYSTEM_PROMPT = `You are a meeting notes assistant. Given a raw meeting tr
## Calendar matching
You will be given the transcript (with a timestamp of when recording started) and recent calendar events with their titles, times, and attendees. If a calendar event clearly matches this meeting (overlapping time + content aligns), then:
- Do NOT output a title or heading the title is already set by the caller.
- Replace generic speaker labels ("Speaker 0", "Speaker 1", "System audio") with actual attendee names, but ONLY if you have HIGH CONFIDENCE about which speaker is which based on the discussion content. If unsure, use "They" instead of "Speaker 0" etc.
- ONLY use names from the calendar event attendee list. Do NOT introduce names that are not in the attendee list any unrecognized names in the transcript are transcription errors.
- Replace generic speaker labels ("Speaker 0", "Speaker 1", "System audio") with actual attendee names from the list, but ONLY if you have HIGH CONFIDENCE about which speaker is which based on the discussion content. If unsure, use "They" instead of "Speaker 0" etc.
- "You" in the transcript is the local user if the calendar event has an organizer or you can identify who "You" is from context, use their name.
If no calendar event matches with high confidence, or if no calendar events are provided, use "They" for all non-"You" speakers.