mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
Merge branch 'dev' into feat/today-minimal-polish
This commit is contained in:
commit
eeb99320fc
80 changed files with 3655 additions and 1617 deletions
|
|
@ -109,6 +109,7 @@ Long-form docs for specific features. Read the relevant file before making chang
|
|||
| Feature | Doc |
|
||||
|---------|-----|
|
||||
| Track Blocks — auto-updating note content (scheduled / event-driven / manual), Copilot skill, prompts catalog | `apps/x/TRACKS.md` |
|
||||
| Analytics — PostHog event catalog, person properties, use-case taxonomy, how to add a new event | `apps/x/ANALYTICS.md` |
|
||||
|
||||
## Common Tasks
|
||||
|
||||
|
|
|
|||
146
apps/x/ANALYTICS.md
Normal file
146
apps/x/ANALYTICS.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# Analytics
|
||||
|
||||
> PostHog instrumentation for `apps/x`. We capture LLM token usage (broken down by feature) and identity/auth events. Renderer (`posthog-js`) and main (`posthog-node`) share one stable distinct_id and one identified user, so events from either process resolve to the same person.
|
||||
|
||||
## Identity model
|
||||
|
||||
- **Anonymous distinct_id** = `installationId` from `~/.rowboat/config/installation.json` (auto-generated on first run; see `packages/core/src/analytics/installation.ts`).
|
||||
- Renderer fetches it from main on startup via the `analytics:bootstrap` IPC channel and passes it as PostHog's `bootstrap.distinctID`. Main uses it directly in `posthog-node`.
|
||||
- **On rowboat sign-in**: `posthog.identify(rowboatUserId)` runs in **both** processes.
|
||||
- Main does it from `apps/main/src/oauth-handler.ts:285` (after `getBillingInfo()` resolves) — this is the load-bearing call, since main always runs.
|
||||
- Renderer mirrors via `apps/renderer/src/hooks/useAnalyticsIdentity.ts` listening on the `oauth:didConnect` IPC event.
|
||||
- Main also calls `alias()` so events emitted under the anonymous installation_id are linked to the identified user retroactively.
|
||||
- **On every app startup**: main re-identifies if rowboat tokens exist (`packages/core/src/analytics/identify.ts`, called from `apps/main/src/main.ts` whenReady). Idempotent — PostHog merges person properties on duplicate identifies. This catches users who installed before analytics existed, and refreshes person properties (plan/status) on every launch.
|
||||
- **On rowboat sign-out**: `posthog.reset()` in both processes; future events resolve to the installation_id again.
|
||||
- **`email`** is set on `identify` from main only (sourced from `/v1/me`). Person properties are server-side, so the renderer's events resolve to the same record without redundantly setting it.
|
||||
|
||||
## Event catalog
|
||||
|
||||
### `llm_usage`
|
||||
|
||||
Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run).
|
||||
|
||||
| Property | Type | Notes |
|
||||
|---|---|---|
|
||||
| `use_case` | enum | `copilot_chat` / `track_block` / `meeting_note` / `knowledge_sync` |
|
||||
| `sub_use_case` | string? | Refines `use_case` — see taxonomy table below |
|
||||
| `agent_name` | string? | Present when the call goes through an agent run (`createRun`); omitted for direct `generateText`/`generateObject` |
|
||||
| `model` | string | e.g. `claude-sonnet-4-6` |
|
||||
| `provider` | string | `rowboat` = cloud LLM gateway; otherwise the BYOK provider (`openai`, `anthropic`, `ollama`, etc.) |
|
||||
| `input_tokens` | number | |
|
||||
| `output_tokens` | number | |
|
||||
| `total_tokens` | number | |
|
||||
| `cached_input_tokens` | number? | When the provider reports it |
|
||||
| `reasoning_tokens` | number? | When the provider reports it |
|
||||
|
||||
#### Use-case taxonomy
|
||||
|
||||
Every `llm_usage` emit point in the codebase:
|
||||
|
||||
| `use_case` | `sub_use_case` | `agent_name`? | Where | File:line |
|
||||
|---|---|---|---|---|
|
||||
| `copilot_chat` | (none) | yes | User chat in renderer (default for any `createRun` without `useCase`) | `packages/core/src/agents/runtime.ts:1313` (finish-step in `streamLlm`) |
|
||||
| `copilot_chat` | `scheduled` | yes | Background scheduled agent runner | `packages/core/src/agent-schedule/runner.ts:167` |
|
||||
| `copilot_chat` | `file_parse` | inherits | `parseFile` builtin tool inside any chat | `packages/core/src/application/lib/builtin-tools.ts:770` |
|
||||
| `track_block` | `routing` | no | Pass 1 routing classifier (`generateObject`) | `packages/core/src/knowledge/track/routing.ts:104` |
|
||||
| `track_block` | `run` | yes | Pass 2 track block execution | `packages/core/src/knowledge/track/runner.ts:109` (createRun) |
|
||||
| `meeting_note` | (none) | no | Meeting transcript summarizer (`generateText`) | `packages/core/src/knowledge/summarize_meeting.ts:161` |
|
||||
| `knowledge_sync` | `agent_notes` | yes | Agent notes learning service | `packages/core/src/knowledge/agent_notes.ts:309` (createRun) |
|
||||
| `knowledge_sync` | `tag_notes` | yes | Note tagging | `packages/core/src/knowledge/tag_notes.ts:86` (createRun) |
|
||||
| `knowledge_sync` | `build_graph` | yes | Knowledge graph note creation | `packages/core/src/knowledge/build_graph.ts:253` (createRun) |
|
||||
| `knowledge_sync` | `label_emails` | yes | Email labeling | `packages/core/src/knowledge/label_emails.ts:73` (createRun) |
|
||||
| `knowledge_sync` | `inline_task_run` | yes | Inline `@rowboat` task execution (two call sites) | `packages/core/src/knowledge/inline_tasks.ts:471, 552` (createRun) |
|
||||
| `knowledge_sync` | `inline_task_classify` | no | Inline task scheduling classifier (`generateText`) | `packages/core/src/knowledge/inline_tasks.ts:673` |
|
||||
| `knowledge_sync` | `pre_built` | yes | Pre-built scheduled agents | `packages/core/src/pre_built/runner.ts:43` (createRun) |
|
||||
|
||||
`testModelConnection` in `packages/core/src/models/models.ts` is **not** instrumented (diagnostic only — would skew per-model counts).
|
||||
|
||||
### `user_signed_in`
|
||||
|
||||
Emitted when rowboat OAuth completes. Properties: `plan`, `status` (subscription state from `/v1/me`).
|
||||
|
||||
Emitted from **both** processes:
|
||||
- Main (`apps/main/src/oauth-handler.ts:290`) — always fires; load-bearing.
|
||||
- Renderer (`apps/renderer/src/hooks/useAnalyticsIdentity.ts:75`) — fires only when the renderer is open. Same distinct_id, so dedup is automatic in PostHog dashboards.
|
||||
|
||||
### `user_signed_out`
|
||||
|
||||
Emitted on rowboat disconnect. No properties. Followed immediately by `posthog.reset()`.
|
||||
|
||||
Emit points: `apps/main/src/oauth-handler.ts:369` and `apps/renderer/src/hooks/useAnalyticsIdentity.ts:82`.
|
||||
|
||||
### Other events (pre-existing, not added by the LLM-usage work)
|
||||
|
||||
All in `apps/renderer/src/lib/analytics.ts`:
|
||||
|
||||
- `chat_session_created` — `{ run_id }`
|
||||
- `chat_message_sent` — `{ voice_input, voice_output, search_enabled }`
|
||||
- `oauth_connected` / `oauth_disconnected` — `{ provider }`
|
||||
- `voice_input_started` — no properties
|
||||
- `search_executed` — `{ types: string[] }`
|
||||
- `note_exported` — `{ format }`
|
||||
|
||||
## Person properties
|
||||
|
||||
Persistent across sessions for the same user. Set via `posthog.people.set` or as the `properties` arg to `identify`.
|
||||
|
||||
| Property | Set by | Notes |
|
||||
|---|---|---|
|
||||
| `email` | main on identify | From `/v1/me`; powers PostHog cohort match + integrations |
|
||||
| `plan`, `status` | main on identify | Subscription state |
|
||||
| `api_url` | both processes (init + identify) | Distinguishes prod / staging / custom — assign meaning in PostHog dashboard. `https://api.x.rowboatlabs.com` = production |
|
||||
| `signed_in` | renderer | `true` while rowboat OAuth is connected |
|
||||
| `{provider}_connected` | renderer | One of `gmail`, `calendar`, `slack`, `rowboat` |
|
||||
| `total_notes` | renderer (init) | Workspace size signal |
|
||||
| `has_used_search`, `has_used_voice` | renderer | One-shot first-use flags |
|
||||
|
||||
## How to add a new event
|
||||
|
||||
1. **Naming**: `snake_case`, `[object]_[verb]` shape (e.g. `note_exported`, not `exportedNote`). Matches PostHog convention.
|
||||
2. **Pick the right helper**:
|
||||
- LLM token usage → `captureLlmUsage()` from `@x/core/dist/analytics/usage.js`. Always include `useCase`; add `subUseCase` if it refines an existing top-level case.
|
||||
- Anything else from main → `capture()` from `@x/core/dist/analytics/posthog.js`.
|
||||
- Anything else from renderer → add a typed wrapper to `apps/renderer/src/lib/analytics.ts` and call it from the UI code (don't call `posthog.capture()` directly from components).
|
||||
3. **If it's a new LLM call site**:
|
||||
- Goes through `createRun`? Pass `useCase` (and optionally `subUseCase`) to the create call. The runtime auto-emits at every `finish-step` — no further code needed.
|
||||
- Direct `generateText` / `generateObject`? Call `captureLlmUsage` after the call with `model`, `provider`, `usage` from the result.
|
||||
- Inside a builtin tool? Call `getCurrentUseCase()` from `analytics/use_case.ts` first — the parent run's tag is propagated via `AsyncLocalStorage`. Use `ctx?.useCase ?? 'copilot_chat'` as fallback.
|
||||
4. **Update this file in the same PR.** That's the contract — without it, dashboards and downstream consumers drift.
|
||||
|
||||
## How to add a new use-case sub-case
|
||||
|
||||
- **New `sub_use_case` under an existing top-level case**: just pick a string and add a row to the taxonomy table above. No code changes beyond the call site.
|
||||
- **New top-level `use_case`**: edit the `UseCase` enum in `packages/shared/src/runs.ts` and the matching `UseCase` type in `packages/core/src/analytics/use_case.ts`. Then update this doc.
|
||||
|
||||
## Configuration
|
||||
|
||||
PostHog credentials live in two env vars (also baked into the binary at packaging time — never set at runtime in distributed builds):
|
||||
|
||||
- `VITE_PUBLIC_POSTHOG_KEY` — project API key (e.g. `phc_xxx`). Public-facing — safe to commit if you'd rather hardcode.
|
||||
- `VITE_PUBLIC_POSTHOG_HOST` — e.g. `https://us.i.posthog.com`. Defaults to US cloud if unset.
|
||||
|
||||
Where they're consumed:
|
||||
- **Renderer** (Vite): `import.meta.env.VITE_PUBLIC_POSTHOG_*` — inlined at build time.
|
||||
- **Main** (esbuild via `apps/main/bundle.mjs`): inlined into `main.cjs` at packaging time using esbuild `define`. In dev (`npm run dev`), main reads them from `process.env` at runtime.
|
||||
|
||||
For GitHub Actions / packaged builds: set both as workflow env vars (from secrets) on the step that runs `npm run package` or `npm run make`. They'll be baked in.
|
||||
|
||||
If unset, analytics no-op silently — you'll see `[Analytics] POSTHOG_KEY not set; analytics disabled` in main-process logs.
|
||||
|
||||
`installationId`: stored in `~/.rowboat/config/installation.json`, generated on first run.
|
||||
|
||||
## File map
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `packages/core/src/analytics/installation.ts` | Stable per-install distinct_id |
|
||||
| `packages/core/src/analytics/posthog.ts` | Main-process client (`capture`, `identify`, `reset`, `shutdown`) |
|
||||
| `packages/core/src/analytics/usage.ts` | `captureLlmUsage()` helper |
|
||||
| `packages/core/src/analytics/use_case.ts` | `AsyncLocalStorage` for tool-internal LLM call inheritance |
|
||||
| `apps/renderer/src/lib/analytics.ts` | Renderer event wrappers |
|
||||
| `apps/renderer/src/hooks/useAnalyticsIdentity.ts` | Renderer identify/reset on OAuth events |
|
||||
| `apps/main/src/oauth-handler.ts` | Main-side identify/reset/sign-in/sign-out events |
|
||||
| `apps/main/src/main.ts` | `before-quit` hook flushes queued events |
|
||||
| `packages/shared/src/ipc.ts` | `analytics:bootstrap` IPC channel definition |
|
||||
| `apps/main/src/ipc.ts` | `analytics:bootstrap` handler + forwards `userId` on `oauth:didConnect` |
|
||||
| `apps/main/bundle.mjs` | Bakes `POSTHOG_KEY`/`POSTHOG_HOST` into packaged `main.cjs` |
|
||||
|
|
@ -31,6 +31,11 @@ await esbuild.build({
|
|||
// Replace import.meta.url directly with our polyfill variable
|
||||
define: {
|
||||
'import.meta.url': '__import_meta_url',
|
||||
// Inject PostHog credentials at build time. Reuse the renderer's
|
||||
// VITE_PUBLIC_* envs so packaging only needs one set of values.
|
||||
// Empty strings disable analytics gracefully.
|
||||
'process.env.POSTHOG_KEY': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''),
|
||||
'process.env.POSTHOG_HOST': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ module.exports = {
|
|||
icon: './icons/icon', // .icns extension added automatically
|
||||
appBundleId: 'com.rowboat.app',
|
||||
appCategoryType: 'public.app-category.productivity',
|
||||
protocols: [
|
||||
{ name: 'Rowboat', schemes: ['rowboat'] },
|
||||
],
|
||||
extendInfo: {
|
||||
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,24 @@
|
|||
import type { IBrowserControlService } from '@x/core/dist/application/browser-control/service.js';
|
||||
import type { BrowserControlAction, BrowserControlInput, BrowserControlResult } from '@x/shared/dist/browser-control.js';
|
||||
import type { BrowserControlAction, BrowserControlInput, BrowserControlResult, SuggestedBrowserSkill } from '@x/shared/dist/browser-control.js';
|
||||
import { ensureLoaded, matchSkillsForUrl } from '@x/core/dist/application/browser-skills/index.js';
|
||||
import { browserViewManager } from './view.js';
|
||||
import { normalizeNavigationTarget } from './navigation.js';
|
||||
|
||||
async function getSuggestedSkills(url: string | undefined): Promise<SuggestedBrowserSkill[] | undefined> {
|
||||
if (!url) return undefined;
|
||||
try {
|
||||
const status = await ensureLoaded();
|
||||
if (status.status === 'ready' || status.status === 'stale') {
|
||||
const matched = matchSkillsForUrl(status.index, url);
|
||||
if (matched.length === 0) return undefined;
|
||||
return matched.map((e) => ({ id: e.id, title: e.title, path: e.path }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[browser-control] suggestedSkills lookup failed:', err);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildSuccessResult(
|
||||
action: BrowserControlAction,
|
||||
message: string,
|
||||
|
|
@ -52,11 +68,13 @@ export class ElectronBrowserControlService implements IBrowserControlService {
|
|||
}
|
||||
await browserViewManager.ensureActiveTabReady(signal);
|
||||
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
|
||||
return buildSuccessResult(
|
||||
const suggestedSkills = await getSuggestedSkills(page?.url);
|
||||
const success = buildSuccessResult(
|
||||
'new-tab',
|
||||
target ? `Opened a new tab for ${target}.` : 'Opened a new tab.',
|
||||
page,
|
||||
);
|
||||
return suggestedSkills ? { ...success, suggestedSkills } : success;
|
||||
}
|
||||
|
||||
case 'switch-tab': {
|
||||
|
|
@ -99,7 +117,9 @@ export class ElectronBrowserControlService implements IBrowserControlService {
|
|||
}
|
||||
await browserViewManager.ensureActiveTabReady(signal);
|
||||
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
|
||||
return buildSuccessResult('navigate', `Navigated to ${target}.`, page);
|
||||
const suggestedSkills = await getSuggestedSkills(page?.url);
|
||||
const success = buildSuccessResult('navigate', `Navigated to ${target}.`, page);
|
||||
return suggestedSkills ? { ...success, suggestedSkills } : success;
|
||||
}
|
||||
|
||||
case 'back': {
|
||||
|
|
@ -140,7 +160,9 @@ export class ElectronBrowserControlService implements IBrowserControlService {
|
|||
if (!result.ok || !result.page) {
|
||||
return buildErrorResult('read-page', result.error ?? 'Failed to read the current page.');
|
||||
}
|
||||
return buildSuccessResult('read-page', 'Read the current page.', result.page);
|
||||
const suggestedSkills = await getSuggestedSkills(result.page.url);
|
||||
const success = buildSuccessResult('read-page', 'Read the current page.', result.page);
|
||||
return suggestedSkills ? { ...success, suggestedSkills } : success;
|
||||
}
|
||||
|
||||
case 'click': {
|
||||
|
|
|
|||
|
|
@ -109,19 +109,62 @@ export class BrowserViewManager extends EventEmitter {
|
|||
private visible = false;
|
||||
private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 };
|
||||
private snapshotCache = new Map<string, CachedSnapshot>();
|
||||
private cleanupWindowListeners: (() => void) | null = null;
|
||||
|
||||
attach(window: BrowserWindow): void {
|
||||
this.cleanupWindowListeners?.();
|
||||
this.cleanupWindowListeners = null;
|
||||
this.window = window;
|
||||
window.on('closed', () => {
|
||||
const hostWebContents = window.webContents;
|
||||
|
||||
const resetForHostWindowNavigation = () => {
|
||||
// Renderer refreshes do not run React unmount cleanup reliably, so the
|
||||
// native browser view must be detached from the main process side.
|
||||
this.visible = false;
|
||||
this.bounds = { x: 0, y: 0, width: 0, height: 0 };
|
||||
this.syncAttachedView();
|
||||
};
|
||||
|
||||
const handleDidStartLoading = () => {
|
||||
resetForHostWindowNavigation();
|
||||
};
|
||||
|
||||
const handleRenderProcessGone = () => {
|
||||
resetForHostWindowNavigation();
|
||||
};
|
||||
|
||||
const handleClosed = () => {
|
||||
if (this.window !== window) return;
|
||||
|
||||
const tabs = [...this.tabs.values()];
|
||||
this.cleanupWindowListeners = null;
|
||||
this.window = null;
|
||||
this.browserSession = null;
|
||||
this.bounds = { x: 0, y: 0, width: 0, height: 0 };
|
||||
for (const tab of tabs) {
|
||||
this.destroyTab(tab);
|
||||
}
|
||||
this.tabs.clear();
|
||||
this.tabOrder = [];
|
||||
this.activeTabId = null;
|
||||
this.attachedTabId = null;
|
||||
this.visible = false;
|
||||
this.snapshotCache.clear();
|
||||
});
|
||||
};
|
||||
|
||||
hostWebContents.on('did-start-loading', handleDidStartLoading);
|
||||
hostWebContents.on('render-process-gone', handleRenderProcessGone);
|
||||
window.on('closed', handleClosed);
|
||||
|
||||
this.cleanupWindowListeners = () => {
|
||||
if (!hostWebContents.isDestroyed()) {
|
||||
hostWebContents.removeListener('did-start-loading', handleDidStartLoading);
|
||||
hostWebContents.removeListener('render-process-gone', handleRenderProcessGone);
|
||||
}
|
||||
if (!window.isDestroyed()) {
|
||||
window.removeListener('closed', handleClosed);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private getSession(): Session {
|
||||
|
|
|
|||
|
|
@ -293,20 +293,6 @@ export function listConnected(): { toolkits: string[] } {
|
|||
return { toolkits: composioAccountsRepo.getConnectedToolkits() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Composio should be used for Google services (Gmail, etc.)
|
||||
*/
|
||||
export async function useComposioForGoogle(): Promise<{ enabled: boolean }> {
|
||||
return { enabled: await composioClient.useComposioForGoogle() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Composio should be used for Google Calendar
|
||||
*/
|
||||
export async function useComposioForGoogleCalendar(): Promise<{ enabled: boolean }> {
|
||||
return { enabled: await composioClient.useComposioForGoogleCalendar() };
|
||||
}
|
||||
|
||||
/**
|
||||
* List available Composio toolkits — filtered to curated list only.
|
||||
* Return type matches the ZToolkit schema from core/composio/types.ts.
|
||||
|
|
|
|||
165
apps/x/apps/main/src/deeplink.ts
Normal file
165
apps/x/apps/main/src/deeplink.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { BrowserWindow } from "electron";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { WorkDir } from "@x/core/dist/config/config.js";
|
||||
|
||||
export const DEEP_LINK_SCHEME = "rowboat";
|
||||
const URL_PREFIX = `${DEEP_LINK_SCHEME}://`;
|
||||
const ACTION_HOST = "action";
|
||||
|
||||
let pendingUrl: string | null = null;
|
||||
let mainWindowRef: BrowserWindow | null = null;
|
||||
|
||||
export function setMainWindowForDeepLinks(win: BrowserWindow | null): void {
|
||||
mainWindowRef = win;
|
||||
}
|
||||
|
||||
export function consumePendingDeepLink(): string | null {
|
||||
const url = pendingUrl;
|
||||
pendingUrl = null;
|
||||
return url;
|
||||
}
|
||||
|
||||
export function extractDeepLinkFromArgv(argv: readonly string[]): string | null {
|
||||
for (const arg of argv) {
|
||||
if (typeof arg === "string" && arg.startsWith(URL_PREFIX)) return arg;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch any rowboat:// URL — chooses among action / oauth-completion /
|
||||
* navigation automatically. Use this from notification click handlers and
|
||||
* other URL entry points.
|
||||
*
|
||||
* OAuth completion (rowboat://oauth/google/done?session=<state>) is handled
|
||||
* in main, not the renderer, because claiming tokens writes oauth.json and
|
||||
* triggers sync — both main-process concerns.
|
||||
*/
|
||||
export function dispatchUrl(url: string): void {
|
||||
if (parseAction(url)) {
|
||||
void dispatchAction(url);
|
||||
} else if (parseOAuthCompletion(url)) {
|
||||
void dispatchOAuthCompletion(url);
|
||||
} else {
|
||||
dispatchDeepLink(url);
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchDeepLink(url: string): void {
|
||||
if (!url.startsWith(URL_PREFIX)) return;
|
||||
|
||||
pendingUrl = url;
|
||||
|
||||
const win = mainWindowRef;
|
||||
if (!win || win.isDestroyed()) return;
|
||||
focusWindow(win);
|
||||
|
||||
if (win.webContents.isLoading()) return;
|
||||
|
||||
win.webContents.send("app:openUrl", { url });
|
||||
pendingUrl = null;
|
||||
}
|
||||
|
||||
interface MeetingNotesAction {
|
||||
type: "take-meeting-notes" | "join-and-take-meeting-notes";
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
type ParsedAction = MeetingNotesAction;
|
||||
|
||||
function parseAction(url: string): ParsedAction | null {
|
||||
if (!url.startsWith(URL_PREFIX)) return null;
|
||||
const rest = url.slice(URL_PREFIX.length);
|
||||
const queryIdx = rest.indexOf("?");
|
||||
const host = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).replace(/\/$/, "");
|
||||
if (host !== ACTION_HOST) return null;
|
||||
const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : "");
|
||||
const type = params.get("type");
|
||||
if (type === "take-meeting-notes" || type === "join-and-take-meeting-notes") {
|
||||
const eventId = params.get("eventId");
|
||||
return eventId ? { type, eventId } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function dispatchAction(url: string): Promise<void> {
|
||||
const parsed = parseAction(url);
|
||||
if (!parsed) return;
|
||||
|
||||
const openMeeting = parsed.type === "join-and-take-meeting-notes";
|
||||
await handleTakeMeetingNotes(parsed.eventId, openMeeting);
|
||||
}
|
||||
|
||||
async function handleTakeMeetingNotes(eventId: string, openMeeting: boolean): Promise<void> {
|
||||
const win = mainWindowRef;
|
||||
if (!win || win.isDestroyed()) return;
|
||||
focusWindow(win);
|
||||
|
||||
const filePath = path.join(WorkDir, "calendar_sync", `${eventId}.json`);
|
||||
let event: unknown;
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf-8");
|
||||
event = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.error(`[deeplink] take-meeting-notes: failed to read ${filePath}`, err);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { event, openMeeting };
|
||||
|
||||
if (win.webContents.isLoading()) {
|
||||
win.webContents.once("did-finish-load", () => {
|
||||
win.webContents.send("app:takeMeetingNotes", payload);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
win.webContents.send("app:takeMeetingNotes", payload);
|
||||
}
|
||||
|
||||
// --- OAuth completion (rowboat-mode Google connect) ---
|
||||
|
||||
interface OAuthCompletion {
|
||||
provider: "google";
|
||||
state: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match rowboat://oauth/google/done?session=<state>. Returns null for
|
||||
* anything else — including paths with the right shape but wrong provider
|
||||
* or a missing `session` query param.
|
||||
*/
|
||||
function parseOAuthCompletion(url: string): OAuthCompletion | null {
|
||||
if (!url.startsWith(URL_PREFIX)) return null;
|
||||
const rest = url.slice(URL_PREFIX.length);
|
||||
const queryIdx = rest.indexOf("?");
|
||||
const path = queryIdx >= 0 ? rest.slice(0, queryIdx) : rest;
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
if (parts.length !== 3 || parts[0] !== "oauth" || parts[2] !== "done") return null;
|
||||
if (parts[1] !== "google") return null;
|
||||
const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : "");
|
||||
const state = params.get("session");
|
||||
return state ? { provider: "google", state } : null;
|
||||
}
|
||||
|
||||
async function dispatchOAuthCompletion(url: string): Promise<void> {
|
||||
const parsed = parseOAuthCompletion(url);
|
||||
if (!parsed) return;
|
||||
|
||||
// Bring the app to the front so the renderer can react to the
|
||||
// oauthEvent IPC that completeRowboatGoogleConnect emits.
|
||||
const win = mainWindowRef;
|
||||
if (win && !win.isDestroyed()) focusWindow(win);
|
||||
|
||||
// Lazy-import to keep deeplink.ts free of OAuth deps and avoid a
|
||||
// potential circular dep with oauth-handler.ts.
|
||||
const { completeRowboatGoogleConnect } = await import("./oauth-handler.js");
|
||||
await completeRowboatGoogleConnect(parsed.state);
|
||||
}
|
||||
|
||||
function focusWindow(win: BrowserWindow): void {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
|
|
@ -34,6 +34,8 @@ import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granol
|
|||
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
||||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
||||
import * as composioHandler from './composio-handler.js';
|
||||
import { consumePendingDeepLink } from './deeplink.js';
|
||||
import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js';
|
||||
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
||||
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
|
||||
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
||||
|
|
@ -46,8 +48,12 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
|||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
import { triggerTrackUpdate } from '@x/core/dist/knowledge/track/runner.js';
|
||||
import { trackBus } from '@x/core/dist/knowledge/track/bus.js';
|
||||
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
||||
import { API_URL } from '@x/core/dist/config/env.js';
|
||||
import {
|
||||
fetchYaml,
|
||||
listNotesWithTracks,
|
||||
setNoteTracksActive,
|
||||
updateTrackBlock,
|
||||
replaceTrackBlockYaml,
|
||||
deleteTrackBlock,
|
||||
|
|
@ -131,6 +137,14 @@ function resolveShellPath(filePath: string): string {
|
|||
return workspace.resolveWorkspacePath(filePath);
|
||||
}
|
||||
|
||||
function toKnowledgeTrackPath(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
if (!normalized.startsWith('knowledge/')) {
|
||||
throw new Error('Track note path must be within knowledge/')
|
||||
}
|
||||
return normalized.slice('knowledge/'.length)
|
||||
}
|
||||
|
||||
type InvokeChannels = ipc.InvokeChannels;
|
||||
type IPCChannels = ipc.IPCChannels;
|
||||
|
||||
|
|
@ -342,7 +356,7 @@ function emitServiceEvent(event: z.infer<typeof ServiceEvent>): void {
|
|||
}
|
||||
}
|
||||
|
||||
export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string }): void {
|
||||
export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string; userId?: string }): void {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
|
|
@ -415,6 +429,15 @@ export function setupIpcHandlers() {
|
|||
// args is null for this channel (no request payload)
|
||||
return getVersions();
|
||||
},
|
||||
'app:consumePendingDeepLink': async () => {
|
||||
return { url: consumePendingDeepLink() };
|
||||
},
|
||||
'analytics:bootstrap': async () => {
|
||||
return {
|
||||
installationId: getInstallationId(),
|
||||
apiUrl: API_URL,
|
||||
};
|
||||
},
|
||||
'workspace:getRoot': async () => {
|
||||
return workspace.getRoot();
|
||||
},
|
||||
|
|
@ -600,11 +623,8 @@ export function setupIpcHandlers() {
|
|||
'composio:list-toolkits': async () => {
|
||||
return composioHandler.listToolkits();
|
||||
},
|
||||
'composio:use-composio-for-google': async () => {
|
||||
return composioHandler.useComposioForGoogle();
|
||||
},
|
||||
'composio:use-composio-for-google-calendar': async () => {
|
||||
return composioHandler.useComposioForGoogleCalendar();
|
||||
'migration:check-composio-google': async () => {
|
||||
return qualifyAndDisconnectComposioGoogle();
|
||||
},
|
||||
// Agent schedule handlers
|
||||
'agent-schedule:getConfig': async () => {
|
||||
|
|
@ -822,6 +842,19 @@ export function setupIpcHandlers() {
|
|||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
'track:setNoteActive': async (_event, args) => {
|
||||
try {
|
||||
const note = await setNoteTracksActive(toKnowledgeTrackPath(args.path), args.active);
|
||||
if (!note) return { success: false, error: 'No track blocks found in note' };
|
||||
return { success: true, note };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
'track:listNotes': async () => {
|
||||
const notes = await listNotesWithTracks();
|
||||
return { notes };
|
||||
},
|
||||
// Billing handler
|
||||
'billing:getInfo': async () => {
|
||||
return await getBillingInfo();
|
||||
|
|
|
|||
|
|
@ -23,19 +23,29 @@ import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
|
|||
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
|
||||
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
||||
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
|
||||
import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js";
|
||||
import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js";
|
||||
import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js";
|
||||
import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js";
|
||||
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
|
||||
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
|
||||
|
||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||
import started from "electron-squirrel-startup";
|
||||
import { execSync, exec, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
||||
import { registerBrowserControlService } from "@x/core/dist/di/container.js";
|
||||
import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
|
||||
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
|
||||
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
||||
import { ElectronBrowserControlService } from "./browser/control-service.js";
|
||||
import { ElectronNotificationService } from "./notification/electron-notification-service.js";
|
||||
import {
|
||||
DEEP_LINK_SCHEME,
|
||||
dispatchUrl,
|
||||
extractDeepLinkFromArgv,
|
||||
setMainWindowForDeepLinks,
|
||||
} from "./deeplink.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
|
|
@ -45,6 +55,44 @@ const __dirname = dirname(__filename);
|
|||
// run this as early in the main process as possible
|
||||
if (started) app.quit();
|
||||
|
||||
// Single-instance lock: route a second launch (e.g. clicking a rowboat:// link)
|
||||
// back into the existing process via the 'second-instance' event.
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
console.error('[Main] Another Rowboat instance is already running; exiting this process.');
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Register as the OS handler for rowboat:// URLs.
|
||||
// In dev, point at the right argv so the OS can re-invoke us correctly.
|
||||
if (process.defaultApp) {
|
||||
if (process.argv.length >= 2) {
|
||||
app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME, process.execPath, [
|
||||
path.resolve(process.argv[1]),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME);
|
||||
}
|
||||
|
||||
// First-launch URL on Windows/Linux comes through argv.
|
||||
{
|
||||
const initialUrl = extractDeepLinkFromArgv(process.argv);
|
||||
if (initialUrl) dispatchUrl(initialUrl);
|
||||
}
|
||||
|
||||
// macOS sends URLs via 'open-url' (both first launch and while running).
|
||||
app.on("open-url", (event, url) => {
|
||||
event.preventDefault();
|
||||
dispatchUrl(url);
|
||||
});
|
||||
|
||||
// Subsequent launches on Windows/Linux land here via the single-instance lock.
|
||||
app.on("second-instance", (_event, argv) => {
|
||||
const url = extractDeepLinkFromArgv(argv);
|
||||
if (url) dispatchUrl(url);
|
||||
});
|
||||
|
||||
// Fix PATH for packaged Electron apps on macOS/Linux.
|
||||
// Packaged apps inherit a minimal environment that doesn't include paths from
|
||||
// the user's shell profile (such as those provided by nvm, Homebrew, etc.).
|
||||
|
|
@ -65,7 +113,9 @@ function initializeExecutionEnvironment(): void {
|
|||
).trim();
|
||||
|
||||
const env = JSON.parse(stdout) as Record<string, string>;
|
||||
process.env = { ...env, ...process.env };
|
||||
// Let the user's shell environment win for overlapping keys like PATH.
|
||||
// Finder/launched GUI apps on macOS often start with a stripped PATH.
|
||||
process.env = { ...process.env, ...env };
|
||||
} catch (error) {
|
||||
console.error('Failed to load shell environment', error);
|
||||
}
|
||||
|
|
@ -163,6 +213,9 @@ function createWindow() {
|
|||
configureSessionPermissions(session.defaultSession);
|
||||
configureSessionPermissions(session.fromPartition(BROWSER_PARTITION));
|
||||
|
||||
setMainWindowForDeepLinks(win);
|
||||
win.on("closed", () => setMainWindowForDeepLinks(null));
|
||||
|
||||
// Show window when content is ready to prevent blank screen
|
||||
win.once("ready-to-show", () => {
|
||||
win.maximize();
|
||||
|
|
@ -230,7 +283,15 @@ app.whenReady().then(async () => {
|
|||
// Initialize all config files before UI can access them
|
||||
await initConfigs();
|
||||
|
||||
// PostHog identify() is idempotent — call it on every startup so existing
|
||||
// signed-in installs (and every cold start of v0.3.4+) get re-identified.
|
||||
// Otherwise main-process events stay anonymous until the user re-signs-in.
|
||||
identifyIfSignedIn().catch((error) => {
|
||||
console.error('[Analytics] Failed to identify on startup:', error);
|
||||
});
|
||||
|
||||
registerBrowserControlService(new ElectronBrowserControlService());
|
||||
registerNotificationService(new ElectronNotificationService());
|
||||
|
||||
setupIpcHandlers();
|
||||
setupBrowserEventForwarding();
|
||||
|
|
@ -289,6 +350,9 @@ app.whenReady().then(async () => {
|
|||
// start agent notes learning service
|
||||
initAgentNotes();
|
||||
|
||||
// start calendar meeting notification service (fires 1-minute warnings)
|
||||
initCalendarNotifications();
|
||||
|
||||
// start chrome extension sync server
|
||||
initChromeSync();
|
||||
|
||||
|
|
@ -318,4 +382,7 @@ app.on("before-quit", () => {
|
|||
shutdownLocalSites().catch((error) => {
|
||||
console.error('[LocalSites] Failed to shut down cleanly:', error);
|
||||
});
|
||||
shutdownAnalytics().catch((error) => {
|
||||
console.error('[Analytics] Failed to flush on quit:', error);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import { BrowserWindow, Notification, shell } from "electron";
|
||||
import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js";
|
||||
import { dispatchUrl } from "../deeplink.js";
|
||||
|
||||
const HTTP_URL = /^https?:\/\//i;
|
||||
const ROWBOAT_URL = /^rowboat:\/\//i;
|
||||
|
||||
export class ElectronNotificationService implements INotificationService {
|
||||
// Holds strong references to active Notification instances so the GC can't
|
||||
// collect them while they're still visible — without this, the click handler
|
||||
// gets dropped and macOS clicks just focus the app silently.
|
||||
private active = new Set<Notification>();
|
||||
|
||||
isSupported(): boolean {
|
||||
return Notification.isSupported();
|
||||
}
|
||||
|
||||
notify({ title = "Rowboat", message, link, actionLabel, secondaryActions }: NotifyInput): void {
|
||||
// Build the actions array AND a parallel index → link map.
|
||||
// macOS shows actions[0] inline (Banner) or all of them (Alert);
|
||||
// additional ones live behind the chevron menu.
|
||||
const actionDefs: Electron.NotificationConstructorOptions["actions"] = [];
|
||||
const actionLinks: string[] = [];
|
||||
|
||||
const primaryLabel = actionLabel?.trim();
|
||||
if (link && primaryLabel) {
|
||||
actionDefs!.push({ type: "button", text: primaryLabel });
|
||||
actionLinks.push(link);
|
||||
}
|
||||
if (secondaryActions) {
|
||||
for (const sa of secondaryActions) {
|
||||
actionDefs!.push({ type: "button", text: sa.label });
|
||||
actionLinks.push(sa.link);
|
||||
}
|
||||
}
|
||||
|
||||
const notification = new Notification({
|
||||
title,
|
||||
body: message,
|
||||
actions: actionDefs,
|
||||
});
|
||||
|
||||
this.active.add(notification);
|
||||
const release = () => { this.active.delete(notification); };
|
||||
|
||||
const openLink = (target: string | undefined) => {
|
||||
if (target && ROWBOAT_URL.test(target)) {
|
||||
dispatchUrl(target);
|
||||
} else if (target && HTTP_URL.test(target)) {
|
||||
shell.openExternal(target).catch((err) => {
|
||||
console.error("[notification] failed to open link:", err);
|
||||
});
|
||||
} else {
|
||||
this.focusMainWindow();
|
||||
}
|
||||
release();
|
||||
};
|
||||
|
||||
// Body click: always opens the primary `link` (or focuses the app if none).
|
||||
notification.on("click", () => openLink(link));
|
||||
|
||||
// Action button click: dispatch by index into the actions array.
|
||||
notification.on("action", (_event, index) => {
|
||||
if (index >= 0 && index < actionLinks.length) {
|
||||
openLink(actionLinks[index]);
|
||||
} else {
|
||||
openLink(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
notification.on("close", release);
|
||||
notification.on("failed", release);
|
||||
|
||||
notification.show();
|
||||
}
|
||||
|
||||
private focusMainWindow(): void {
|
||||
const [win] = BrowserWindow.getAllWindows();
|
||||
if (!win) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,10 @@ import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_
|
|||
import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js';
|
||||
import { emitOAuthEvent } from './ipc.js';
|
||||
import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
||||
import { capture as analyticsCapture, identify as analyticsIdentify, reset as analyticsReset } from '@x/core/dist/analytics/posthog.js';
|
||||
import { isSignedIn } from '@x/core/dist/account/account.js';
|
||||
import { getWebappUrl } from '@x/core/dist/config/remote-config.js';
|
||||
import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js';
|
||||
|
||||
const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
|
||||
|
||||
|
|
@ -200,6 +204,23 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
|
||||
if (provider === 'google') {
|
||||
if (!credentials?.clientId || !credentials?.clientSecret) {
|
||||
// No credentials → rowboat mode if the user is signed in to Rowboat
|
||||
// (we use the company-owned Google client via the api + webapp).
|
||||
// Otherwise it's BYOK with missing creds → error.
|
||||
if (await isSignedIn()) {
|
||||
try {
|
||||
const webappUrl = await getWebappUrl();
|
||||
await shell.openExternal(`${webappUrl}/oauth/google/start`);
|
||||
console.log('[OAuth] Started rowboat-mode Google connect (browser opened to webapp)');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[OAuth] Failed to start rowboat-mode Google connect:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to open browser',
|
||||
};
|
||||
}
|
||||
}
|
||||
return { success: false, error: 'Google client ID and client secret are required to connect.' };
|
||||
}
|
||||
}
|
||||
|
|
@ -256,11 +277,15 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
state
|
||||
);
|
||||
|
||||
// Save tokens and credentials
|
||||
// Save tokens and credentials. For Google, BYOK is the only path
|
||||
// that reaches this token exchange (rowboat path returns above
|
||||
// before any local server runs); stamp mode: 'byok' so a future
|
||||
// refresh / reconnect can't get confused with a rowboat entry.
|
||||
console.log(`[OAuth] Token exchange successful for ${provider}`);
|
||||
await oauthRepo.upsert(provider, {
|
||||
tokens,
|
||||
...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}),
|
||||
...(provider === 'google' ? { mode: 'byok' as const } : {}),
|
||||
error: null,
|
||||
});
|
||||
|
||||
|
|
@ -275,16 +300,33 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
// For Rowboat sign-in, ensure user + Stripe customer exist before
|
||||
// notifying the renderer. Without this, parallel API calls from
|
||||
// multiple renderer hooks race to create the user, causing duplicates.
|
||||
let signedInUserId: string | undefined;
|
||||
if (provider === 'rowboat') {
|
||||
try {
|
||||
await getBillingInfo();
|
||||
const billing = await getBillingInfo();
|
||||
if (billing.userId) {
|
||||
signedInUserId = billing.userId;
|
||||
analyticsIdentify(billing.userId, {
|
||||
...(billing.userEmail ? { email: billing.userEmail } : {}),
|
||||
plan: billing.subscriptionPlan,
|
||||
status: billing.subscriptionStatus,
|
||||
});
|
||||
analyticsCapture('user_signed_in', {
|
||||
plan: billing.subscriptionPlan,
|
||||
status: billing.subscriptionStatus,
|
||||
});
|
||||
}
|
||||
} catch (meError) {
|
||||
console.error('[OAuth] Failed to initialize user via /v1/me:', meError);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit success event to renderer
|
||||
emitOAuthEvent({ provider, success: true });
|
||||
emitOAuthEvent({
|
||||
provider,
|
||||
success: true,
|
||||
...(signedInUserId ? { userId: signedInUserId } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('OAuth token exchange failed:', error);
|
||||
// Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError)
|
||||
|
|
@ -340,13 +382,70 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a rowboat-mode Google connect: claim the tokens parked under
|
||||
* `state` by the webapp callback, persist them locally, and trigger sync.
|
||||
*
|
||||
* Called by the deep-link dispatcher (deeplink.ts) when the OS hands us a
|
||||
* rowboat://oauth/google/done?session=<state> URL.
|
||||
*/
|
||||
export async function completeRowboatGoogleConnect(state: string): Promise<void> {
|
||||
try {
|
||||
console.log('[OAuth] Claiming rowboat-mode Google tokens...');
|
||||
const tokens = await claimTokensViaBackend(state);
|
||||
const oauthRepo = getOAuthRepo();
|
||||
await oauthRepo.upsert('google', {
|
||||
tokens,
|
||||
mode: 'rowboat',
|
||||
// Explicitly null these — no client_id/secret on the desktop in this mode.
|
||||
clientId: null,
|
||||
clientSecret: null,
|
||||
error: null,
|
||||
});
|
||||
triggerGmailSync();
|
||||
triggerCalendarSync();
|
||||
emitOAuthEvent({ provider: 'google', success: true });
|
||||
console.log('[OAuth] Rowboat-mode Google connect complete');
|
||||
} catch (error) {
|
||||
console.error('[OAuth] Failed to complete rowboat-mode Google connect:', error);
|
||||
emitOAuthEvent({
|
||||
provider: 'google',
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to claim Google tokens',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a provider (clear tokens)
|
||||
*/
|
||||
export async function disconnectProvider(provider: string): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const oauthRepo = getOAuthRepo();
|
||||
|
||||
// For rowboat-mode Google, best-effort revoke at Google before clearing
|
||||
// local state. Google's revoke endpoint accepts an unauthenticated POST
|
||||
// with the access_token; failure is logged but doesn't block disconnect.
|
||||
if (provider === 'google') {
|
||||
const connection = await oauthRepo.read(provider);
|
||||
if (connection.mode === 'rowboat' && connection.tokens?.access_token) {
|
||||
try {
|
||||
const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`;
|
||||
const res = await fetch(revokeUrl, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local disconnect`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[OAuth] Google revoke failed; continuing with local disconnect:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await oauthRepo.delete(provider);
|
||||
if (provider === 'rowboat') {
|
||||
analyticsCapture('user_signed_out');
|
||||
analyticsReset();
|
||||
}
|
||||
// Notify renderer so sidebar, voice, and billing re-check state
|
||||
emitOAuthEvent({ provider, success: false });
|
||||
return { success: true };
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/ba
|
|||
import { useDebounce } from './hooks/use-debounce';
|
||||
import { SidebarContentPanel } from '@/components/sidebar-content';
|
||||
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
|
||||
import { BackgroundAgentsView } from '@/components/background-agents-view';
|
||||
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
||||
import {
|
||||
Conversation,
|
||||
|
|
@ -35,7 +36,7 @@ import {
|
|||
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer';
|
||||
import { useSmoothedText } from './hooks/useSmoothedText';
|
||||
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool';
|
||||
import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool';
|
||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result';
|
||||
import { AppActionCard } from '@/components/ai-elements/app-action-card';
|
||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
|
||||
|
|
@ -54,7 +55,9 @@ import { Button } from "@/components/ui/button"
|
|||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
||||
import { extractConferenceLink } from '@/lib/calendar-event'
|
||||
import { OnboardingModal } from '@/components/onboarding'
|
||||
import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal'
|
||||
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog'
|
||||
import { TrackModal } from '@/components/track-modal'
|
||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||
|
|
@ -76,10 +79,12 @@ import {
|
|||
getAppActionCardData,
|
||||
getComposioConnectCardData,
|
||||
getToolDisplayName,
|
||||
groupConversationItems,
|
||||
inferRunTitleFromMessage,
|
||||
isChatMessage,
|
||||
isErrorMessage,
|
||||
isToolCall,
|
||||
isToolGroup,
|
||||
normalizeToolInput,
|
||||
normalizeToolOutput,
|
||||
parseAttachedFiles,
|
||||
|
|
@ -138,6 +143,7 @@ const TITLEBAR_BUTTONS_COLLAPSED = 1
|
|||
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0
|
||||
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
|
||||
const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
|
||||
const BACKGROUND_AGENTS_TAB_PATH = '__rowboat_background_agents__'
|
||||
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
|
||||
|
||||
const clampNumber = (value: number, min: number, max: number) =>
|
||||
|
|
@ -267,6 +273,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => {
|
|||
|
||||
const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
|
||||
const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH
|
||||
const isBackgroundAgentsTabPath = (path: string) => path === BACKGROUND_AGENTS_TAB_PATH
|
||||
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
|
||||
|
||||
const getSuggestedTopicTargetFolder = (category?: string) => {
|
||||
|
|
@ -323,6 +330,24 @@ const buildSuggestedTopicExplorePrompt = ({
|
|||
].join('\n')
|
||||
}
|
||||
|
||||
const buildBackgroundAgentSetupPrompt = () => [
|
||||
'Help me set up a background agent.',
|
||||
'In this flow, a background agent is the same thing as a note-based track block. Do not tell me they are separate concepts.',
|
||||
'Do not propose a separate standalone agent, workflow file, or agent-schedule.json setup unless I explicitly ask for that.',
|
||||
'Assume the default home for this setup is knowledge/Tasks/. If that folder does not exist, create it later when setting things up.',
|
||||
'Start with a short, plain-English explanation of what a background agent is.',
|
||||
'Do not make the explanation too terse.',
|
||||
'Give 2 or 3 simple examples of the kinds of things a background agent could help keep updated.',
|
||||
'Do not mention triggers, event-based vs schedule-based behavior, track blocks, skills, note paths, or other internal implementation details unless I ask.',
|
||||
'In the first reply, tell me that you will create this in my Tasks folder by default.',
|
||||
'Do not ask me where it should save or update results unless I explicitly say I want it somewhere else.',
|
||||
'Then ask only what I want it to monitor or update and how often I want it to run.',
|
||||
'Keep it concise and friendly, but not abrupt.',
|
||||
'Do not give me a long taxonomy, a big list of options, or a multi-step breakdown unless I ask for more detail.',
|
||||
'Do not create or modify anything yet.',
|
||||
'If I confirm later, load the tracks skill, check for a matching note under knowledge/Tasks/ first, and create one there if needed.',
|
||||
].join('\n')
|
||||
|
||||
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
|
||||
if (!usage) return null
|
||||
const hasNumbers = Object.values(usage).some((value) => typeof value === 'number')
|
||||
|
|
@ -504,6 +529,7 @@ type ViewState =
|
|||
| { type: 'graph' }
|
||||
| { type: 'task'; name: string }
|
||||
| { type: 'suggested-topics' }
|
||||
| { type: 'background-agents' }
|
||||
|
||||
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
||||
if (a.type !== b.type) return false
|
||||
|
|
@ -513,6 +539,48 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
|||
return true // both graph
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a rowboat:// deep link into a ViewState. Returns null if the URL is
|
||||
* malformed or names an unknown target.
|
||||
*
|
||||
* Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics|background-agents>&...
|
||||
* file: ?type=file&path=knowledge/foo.md
|
||||
* chat: ?type=chat&runId=abc123 (runId optional)
|
||||
* graph: ?type=graph
|
||||
* task: ?type=task&name=daily-brief
|
||||
* suggested-topics: ?type=suggested-topics
|
||||
* background-agents: ?type=background-agents
|
||||
*/
|
||||
function parseDeepLink(input: string): ViewState | null {
|
||||
const SCHEME = 'rowboat://'
|
||||
if (!input.startsWith(SCHEME)) return null
|
||||
const rest = input.slice(SCHEME.length)
|
||||
const queryIdx = rest.indexOf('?')
|
||||
const host = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).replace(/\/$/, '')
|
||||
if (host !== 'open') return null
|
||||
const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : '')
|
||||
switch (params.get('type')) {
|
||||
case 'file': {
|
||||
const path = params.get('path')
|
||||
return path ? { type: 'file', path } : null
|
||||
}
|
||||
case 'chat':
|
||||
return { type: 'chat', runId: params.get('runId') || null }
|
||||
case 'graph':
|
||||
return { type: 'graph' }
|
||||
case 'task': {
|
||||
const name = params.get('name')
|
||||
return name ? { type: 'task', name } : null
|
||||
}
|
||||
case 'suggested-topics':
|
||||
return { type: 'suggested-topics' }
|
||||
case 'background-agents':
|
||||
return { type: 'background-agents' }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Sidebar toggle (fixed position, top-left) */
|
||||
function FixedSidebarToggle({
|
||||
leftInsetPx,
|
||||
|
|
@ -613,7 +681,13 @@ function App() {
|
|||
const [isGraphOpen, setIsGraphOpen] = useState(false)
|
||||
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
|
||||
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false)
|
||||
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean; suggestedTopics: boolean } | null>(null)
|
||||
const [isBackgroundAgentsOpen, setIsBackgroundAgentsOpen] = useState(false)
|
||||
const [expandedFrom, setExpandedFrom] = useState<{
|
||||
path: string | null
|
||||
graph: boolean
|
||||
suggestedTopics: boolean
|
||||
backgroundAgents: boolean
|
||||
} | null>(null)
|
||||
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
|
||||
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
|
||||
nodes: [],
|
||||
|
|
@ -738,6 +812,30 @@ function App() {
|
|||
return cleanup
|
||||
}, [refreshVoiceAvailability])
|
||||
|
||||
// One-time Composio→native Google migration check. Runs on mount and again
|
||||
// after the user signs in to Rowboat (so we catch users who weren't signed
|
||||
// in at startup). The IPC is idempotent — once `dismissed_at` is set on the
|
||||
// main side, every subsequent call returns `{shouldShow: false}`.
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('migration:check-composio-google', null)
|
||||
if (result.shouldShow) {
|
||||
setShowComposioGoogleMigration(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[migration] check-composio-google failed:', error)
|
||||
}
|
||||
}
|
||||
void run()
|
||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||
if (event.provider === 'rowboat' && event.success) {
|
||||
void run()
|
||||
}
|
||||
})
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
const handleStartRecording = useCallback(() => {
|
||||
setIsRecording(true)
|
||||
isRecordingRef.current = true
|
||||
|
|
@ -910,6 +1008,7 @@ function App() {
|
|||
const getFileTabTitle = useCallback((tab: FileTab) => {
|
||||
if (isGraphTabPath(tab.path)) return 'Graph View'
|
||||
if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics'
|
||||
if (isBackgroundAgentsTabPath(tab.path)) return 'Background agents'
|
||||
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
|
||||
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
|
||||
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
||||
|
|
@ -991,6 +1090,9 @@ function App() {
|
|||
// Onboarding state
|
||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||
|
||||
// One-time Composio→native Google migration modal
|
||||
const [showComposioGoogleMigration, setShowComposioGoogleMigration] = useState(false)
|
||||
|
||||
// Search state
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
||||
|
||||
|
|
@ -2358,6 +2460,10 @@ function App() {
|
|||
}
|
||||
}, [runId])
|
||||
|
||||
const dismissBrowserOverlay = useCallback(() => {
|
||||
setIsBrowserOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
// Invalidate any in-flight run loads (rapid switching can otherwise "pop" old conversations back in)
|
||||
loadRunRequestIdRef.current += 1
|
||||
|
|
@ -2581,10 +2687,13 @@ function App() {
|
|||
|
||||
// File tab operations
|
||||
const openFileInNewTab = useCallback((path: string) => {
|
||||
dismissBrowserOverlay()
|
||||
const existingTab = fileTabs.find(t => t.path === path)
|
||||
if (existingTab) {
|
||||
setActiveFileTabId(existingTab.id)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
setSelectedPath(path)
|
||||
return
|
||||
}
|
||||
|
|
@ -2592,12 +2701,15 @@ function App() {
|
|||
setFileTabs(prev => [...prev, { id, path }])
|
||||
setActiveFileTabId(id)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
setSelectedPath(path)
|
||||
}, [fileTabs])
|
||||
}, [fileTabs, dismissBrowserOverlay])
|
||||
|
||||
const switchFileTab = useCallback((tabId: string) => {
|
||||
const tab = fileTabs.find(t => t.id === tabId)
|
||||
if (!tab) return
|
||||
dismissBrowserOverlay()
|
||||
setActiveFileTabId(tabId)
|
||||
setSelectedBackgroundTask(null)
|
||||
setExpandedFrom(null)
|
||||
|
|
@ -2609,18 +2721,28 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(true)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
return
|
||||
}
|
||||
if (isSuggestedTopicsTabPath(tab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
return
|
||||
}
|
||||
if (isBackgroundAgentsTabPath(tab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsBackgroundAgentsOpen(true)
|
||||
return
|
||||
}
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
setSelectedPath(tab.path)
|
||||
}, [fileTabs, isRightPaneMaximized])
|
||||
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
|
||||
|
||||
const closeFileTab = useCallback((tabId: string) => {
|
||||
const closingTab = fileTabs.find(t => t.id === tabId)
|
||||
|
|
@ -2647,6 +2769,7 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
return []
|
||||
}
|
||||
const idx = prev.findIndex(t => t.id === tabId)
|
||||
|
|
@ -2660,13 +2783,21 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(true)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
} else if (isBackgroundAgentsTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsBackgroundAgentsOpen(true)
|
||||
} else {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
setSelectedPath(newActiveTab.path)
|
||||
}
|
||||
}
|
||||
|
|
@ -2692,12 +2823,18 @@ function App() {
|
|||
// Create a new tab
|
||||
const id = newChatTabId()
|
||||
setChatTabs(prev => [...prev, { id, runId: null }])
|
||||
setActiveChatTabId(id)
|
||||
setActiveChatTabId(id)
|
||||
}
|
||||
dismissBrowserOverlay()
|
||||
handleNewChat()
|
||||
// Left-pane "new chat" should always open full chat view.
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) {
|
||||
setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen })
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) {
|
||||
setExpandedFrom({
|
||||
path: selectedPath,
|
||||
graph: isGraphOpen,
|
||||
suggestedTopics: isSuggestedTopicsOpen,
|
||||
backgroundAgents: isBackgroundAgentsOpen,
|
||||
})
|
||||
} else {
|
||||
setExpandedFrom(null)
|
||||
}
|
||||
|
|
@ -2705,7 +2842,8 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
}, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen])
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen])
|
||||
|
||||
// Sidebar variant: create/switch chat tab without leaving file/graph context.
|
||||
const handleNewChatTabInSidebar = useCallback(() => {
|
||||
|
|
@ -2820,26 +2958,40 @@ function App() {
|
|||
|
||||
const handleOpenFullScreenChat = useCallback(() => {
|
||||
// Remember where we came from so the close button can return
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) {
|
||||
setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen })
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) {
|
||||
setExpandedFrom({
|
||||
path: selectedPath,
|
||||
graph: isGraphOpen,
|
||||
suggestedTopics: isSuggestedTopicsOpen,
|
||||
backgroundAgents: isBackgroundAgentsOpen,
|
||||
})
|
||||
}
|
||||
dismissBrowserOverlay()
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen])
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen, dismissBrowserOverlay])
|
||||
|
||||
const handleCloseFullScreenChat = useCallback(() => {
|
||||
if (expandedFrom) {
|
||||
if (expandedFrom.graph) {
|
||||
setIsGraphOpen(true)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
} else if (expandedFrom.suggestedTopics) {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
} else if (expandedFrom.backgroundAgents) {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsBackgroundAgentsOpen(true)
|
||||
} else if (expandedFrom.path) {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
setSelectedPath(expandedFrom.path)
|
||||
}
|
||||
setExpandedFrom(null)
|
||||
|
|
@ -2849,11 +3001,12 @@ function App() {
|
|||
|
||||
const currentViewState = React.useMemo<ViewState>(() => {
|
||||
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
|
||||
if (isBackgroundAgentsOpen) return { type: 'background-agents' }
|
||||
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
|
||||
if (selectedPath) return { type: 'file', path: selectedPath }
|
||||
if (isGraphOpen) return { type: 'graph' }
|
||||
return { type: 'chat', runId }
|
||||
}, [selectedBackgroundTask, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
|
||||
}, [selectedBackgroundTask, isBackgroundAgentsOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
|
||||
|
||||
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
||||
const last = stack[stack.length - 1]
|
||||
|
|
@ -2910,6 +3063,17 @@ function App() {
|
|||
setActiveFileTabId(id)
|
||||
}, [fileTabs])
|
||||
|
||||
const ensureBackgroundAgentsFileTab = useCallback(() => {
|
||||
const existing = fileTabs.find((tab) => isBackgroundAgentsTabPath(tab.path))
|
||||
if (existing) {
|
||||
setActiveFileTabId(existing.id)
|
||||
return
|
||||
}
|
||||
const id = newFileTabId()
|
||||
setFileTabs((prev) => [...prev, { id, path: BACKGROUND_AGENTS_TAB_PATH }])
|
||||
setActiveFileTabId(id)
|
||||
}, [fileTabs])
|
||||
|
||||
const applyViewState = useCallback(async (view: ViewState) => {
|
||||
switch (view.type) {
|
||||
case 'file':
|
||||
|
|
@ -2919,6 +3083,7 @@ function App() {
|
|||
// visible in the middle pane.
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
setExpandedFrom(null)
|
||||
// Preserve split vs knowledge-max mode when navigating knowledge files.
|
||||
// Only exit chat-only maximize, because that would hide the selected file.
|
||||
|
|
@ -2933,6 +3098,7 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsGraphOpen(true)
|
||||
ensureGraphFileTab()
|
||||
|
|
@ -2945,6 +3111,7 @@ function App() {
|
|||
setIsGraphOpen(false)
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(view.name)
|
||||
|
|
@ -2957,17 +3124,29 @@ function App() {
|
|||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
ensureSuggestedTopicsFileTab()
|
||||
return
|
||||
case 'chat':
|
||||
case 'background-agents':
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
// Don't touch isBrowserOpen here — chat navigation should land in
|
||||
// the right sidebar when the browser overlay is active.
|
||||
setIsBrowserOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsBackgroundAgentsOpen(true)
|
||||
ensureBackgroundAgentsFileTab()
|
||||
return
|
||||
case 'chat':
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsBrowserOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsBackgroundAgentsOpen(false)
|
||||
if (view.runId) {
|
||||
await loadRun(view.runId)
|
||||
} else {
|
||||
|
|
@ -2975,11 +3154,16 @@ function App() {
|
|||
}
|
||||
return
|
||||
}
|
||||
}, [ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||
}, [ensureBackgroundAgentsFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||
|
||||
const navigateToView = useCallback(async (nextView: ViewState) => {
|
||||
const current = currentViewState
|
||||
if (viewStatesEqual(current, nextView)) return
|
||||
if (viewStatesEqual(current, nextView)) {
|
||||
if (isBrowserOpen) {
|
||||
dismissBrowserOverlay()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
cancelRecordingIfActive()
|
||||
const nextHistory = {
|
||||
|
|
@ -2988,7 +3172,7 @@ function App() {
|
|||
}
|
||||
setHistory(nextHistory)
|
||||
await applyViewState(nextView)
|
||||
}, [appendUnique, applyViewState, cancelRecordingIfActive, currentViewState, setHistory])
|
||||
}, [appendUnique, applyViewState, cancelRecordingIfActive, currentViewState, setHistory, isBrowserOpen, dismissBrowserOverlay])
|
||||
|
||||
const navigateBack = useCallback(async () => {
|
||||
const { back, forward } = historyRef.current
|
||||
|
|
@ -3048,6 +3232,58 @@ function App() {
|
|||
void navigateToView({ type: 'file', path })
|
||||
}, [navigateToView])
|
||||
|
||||
// Deep-link handler kept in a ref so the useEffect below can register the
|
||||
// IPC listener (and run the one-time pending-link drain) just once on mount,
|
||||
// rather than re-running on every navigation when navigateToView's identity
|
||||
// changes.
|
||||
const navigateToViewRef = useRef(navigateToView)
|
||||
useEffect(() => { navigateToViewRef.current = navigateToView }, [navigateToView])
|
||||
|
||||
useEffect(() => {
|
||||
const handle = (url: string) => {
|
||||
const view = parseDeepLink(url)
|
||||
if (view) void navigateToViewRef.current(view)
|
||||
}
|
||||
void window.ipc.invoke('app:consumePendingDeepLink', null).then(({ url }) => {
|
||||
if (url) handle(url)
|
||||
})
|
||||
return window.ipc.on('app:openUrl', ({ url }) => handle(url))
|
||||
}, [])
|
||||
|
||||
// Triggered by main when the user clicks a calendar-meeting notification.
|
||||
// Reuses the same flow as the in-app "Join meeting & take notes" button.
|
||||
// When `openMeeting` is true, also opens the meeting URL in the system browser.
|
||||
useEffect(() => {
|
||||
return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting }) => {
|
||||
const e = event as {
|
||||
summary?: string
|
||||
start?: { dateTime?: string; date?: string; timeZone?: string }
|
||||
end?: { dateTime?: string; date?: string; timeZone?: string }
|
||||
location?: string
|
||||
htmlLink?: string
|
||||
hangoutLink?: string
|
||||
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }
|
||||
}
|
||||
if (!e || typeof e !== 'object') return
|
||||
const conferenceLink = extractConferenceLink(e as Record<string, unknown>)
|
||||
if (openMeeting && conferenceLink) {
|
||||
window.open(conferenceLink, '_blank')
|
||||
} else if (openMeeting) {
|
||||
console.warn('[take-meeting-notes] openMeeting requested but event has no conference link', e)
|
||||
}
|
||||
window.__pendingCalendarEvent = {
|
||||
summary: e.summary,
|
||||
start: e.start,
|
||||
end: e.end,
|
||||
location: e.location,
|
||||
htmlLink: e.htmlLink,
|
||||
conferenceLink,
|
||||
source: 'calendar-sync',
|
||||
}
|
||||
window.dispatchEvent(new Event('calendar-block:join-meeting'))
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleBaseConfigChange = useCallback((path: string, config: BaseConfig) => {
|
||||
setBaseConfigByPath((prev) => ({ ...prev, [path]: config }))
|
||||
}, [])
|
||||
|
|
@ -3240,7 +3476,7 @@ function App() {
|
|||
}, [])
|
||||
|
||||
// Keyboard shortcut: Ctrl+L to toggle main chat view
|
||||
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask && !isBrowserOpen
|
||||
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask && !isBrowserOpen
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||
|
|
@ -3318,15 +3554,17 @@ function App() {
|
|||
const handleTabKeyDown = (e: KeyboardEvent) => {
|
||||
const mod = e.metaKey || e.ctrlKey
|
||||
if (!mod) return
|
||||
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen) && isChatSidebarOpen)
|
||||
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && isChatSidebarOpen)
|
||||
const targetPane: ShortcutPane = rightPaneAvailable
|
||||
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
|
||||
: 'left'
|
||||
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen)
|
||||
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen)
|
||||
const selectedKnowledgePath = isGraphOpen
|
||||
? GRAPH_TAB_PATH
|
||||
: isSuggestedTopicsOpen
|
||||
? SUGGESTED_TOPICS_TAB_PATH
|
||||
: isBackgroundAgentsOpen
|
||||
? BACKGROUND_AGENTS_TAB_PATH
|
||||
: selectedPath
|
||||
const targetFileTabId = activeFileTabId ?? (
|
||||
selectedKnowledgePath
|
||||
|
|
@ -3381,7 +3619,7 @@ function App() {
|
|||
}
|
||||
document.addEventListener('keydown', handleTabKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleTabKeyDown)
|
||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
|
||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
|
||||
|
||||
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
|
||||
if (kind === 'file') {
|
||||
|
|
@ -3406,7 +3644,7 @@ function App() {
|
|||
}),
|
||||
},
|
||||
}))
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
|
|
@ -3528,14 +3766,14 @@ function App() {
|
|||
},
|
||||
openGraph: () => {
|
||||
// From chat-only landing state, open graph directly in full knowledge view.
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
void navigateToView({ type: 'graph' })
|
||||
},
|
||||
openBases: () => {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
|
|
@ -4119,7 +4357,7 @@ function App() {
|
|||
const selectedTask = selectedBackgroundTask
|
||||
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
||||
: null
|
||||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen)
|
||||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen)
|
||||
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
||||
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
||||
const openMarkdownTabs = React.useMemo(() => {
|
||||
|
|
@ -4136,7 +4374,7 @@ function App() {
|
|||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
|
||||
if (section === 'knowledge' && !selectedPath && !isGraphOpen) {
|
||||
if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen) {
|
||||
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
||||
}
|
||||
}}>
|
||||
|
|
@ -4169,7 +4407,7 @@ function App() {
|
|||
onNewChat: handleNewChatTab,
|
||||
onSelectRun: (runIdToLoad) => {
|
||||
cancelRecordingIfActive()
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
|
||||
setIsChatSidebarOpen(true)
|
||||
}
|
||||
|
||||
|
|
@ -4180,7 +4418,7 @@ function App() {
|
|||
return
|
||||
}
|
||||
// In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar.
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
|
||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
|
||||
loadRun(runIdToLoad)
|
||||
return
|
||||
|
|
@ -4204,14 +4442,14 @@ function App() {
|
|||
} else {
|
||||
// Only one tab, reset it to new chat
|
||||
setChatTabs([{ id: tabForRun.id, runId: null }])
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
|
||||
handleNewChat()
|
||||
} else {
|
||||
void navigateToView({ type: 'chat', runId: null })
|
||||
}
|
||||
}
|
||||
} else if (runId === runIdToDelete) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
|
||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
|
||||
handleNewChat()
|
||||
} else {
|
||||
|
|
@ -4235,10 +4473,14 @@ function App() {
|
|||
meetingSummarizing={meetingSummarizing}
|
||||
meetingAvailable={voiceAvailable}
|
||||
onToggleMeeting={() => { void handleToggleMeeting() }}
|
||||
isSearchOpen={isSearchOpen}
|
||||
isMeetingActionActive={showMeetingPermissions || meetingSummarizing || meetingTranscription.state !== 'idle'}
|
||||
isBrowserOpen={isBrowserOpen}
|
||||
onToggleBrowser={handleToggleBrowser}
|
||||
isSuggestedTopicsOpen={isSuggestedTopicsOpen}
|
||||
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
|
||||
isBackgroundAgentsOpen={isBackgroundAgentsOpen}
|
||||
onOpenBackgroundAgents={() => void navigateToView({ type: 'background-agents' })}
|
||||
/>
|
||||
<SidebarInset
|
||||
className={cn(
|
||||
|
|
@ -4258,7 +4500,7 @@ function App() {
|
|||
canNavigateForward={canNavigateForward}
|
||||
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
||||
>
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && fileTabs.length >= 1 ? (
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && fileTabs.length >= 1 ? (
|
||||
<TabBar
|
||||
tabs={fileTabs}
|
||||
activeTabId={activeFileTabId ?? ''}
|
||||
|
|
@ -4266,7 +4508,7 @@ function App() {
|
|||
getTabId={(t) => t.id}
|
||||
onSwitchTab={switchFileTab}
|
||||
onCloseTab={closeFileTab}
|
||||
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
||||
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
||||
/>
|
||||
) : (
|
||||
<TabBar
|
||||
|
|
@ -4319,7 +4561,7 @@ function App() {
|
|||
<TooltipContent side="bottom">Version history</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedTask && !isBrowserOpen && (
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedTask && !isBrowserOpen && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4334,7 +4576,7 @@ function App() {
|
|||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBrowserOpen && expandedFrom && (
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !isBrowserOpen && expandedFrom && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4349,7 +4591,7 @@ function App() {
|
|||
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && (
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4369,7 +4611,10 @@ function App() {
|
|||
</ContentHeader>
|
||||
|
||||
{isBrowserOpen ? (
|
||||
<BrowserPane onClose={handleCloseBrowser} />
|
||||
<BrowserPane
|
||||
onClose={handleCloseBrowser}
|
||||
forceHidden={isSearchOpen || showMeetingPermissions}
|
||||
/>
|
||||
) : isSuggestedTopicsOpen ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<SuggestedTopicsView
|
||||
|
|
@ -4379,6 +4624,15 @@ function App() {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
) : isBackgroundAgentsOpen ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<BackgroundAgentsView
|
||||
onOpenNote={(path) => navigateToFile(path)}
|
||||
onAddNewBackgroundAgent={() => {
|
||||
submitFromPalette(buildBackgroundAgentSetupPrompt(), null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : selectedPath && isBaseFilePath(selectedPath) ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<BasesView
|
||||
|
|
@ -4578,7 +4832,20 @@ function App() {
|
|||
</ConversationEmptyState>
|
||||
) : (
|
||||
<>
|
||||
{tabState.conversation.map(item => {
|
||||
{groupConversationItems(
|
||||
tabState.conversation,
|
||||
(id) => !!tabState.allPermissionRequests.get(id)
|
||||
).map(item => {
|
||||
if (isToolGroup(item)) {
|
||||
return (
|
||||
<ToolGroupComponent
|
||||
key={item.groupId}
|
||||
group={item}
|
||||
isToolOpen={(toolId) => isToolOpenForTab(tab.id, toolId)}
|
||||
onToolOpenChange={(toolId, open) => setToolOpenForTab(tab.id, toolId, open)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const rendered = renderConversationItem(item, tab.id)
|
||||
if (isToolCall(item)) {
|
||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||
|
|
@ -4743,6 +5010,7 @@ function App() {
|
|||
onToolOpenChangeForTab={setToolOpenForTab}
|
||||
onOpenKnowledgeFile={(path) => { navigateToFile(path) }}
|
||||
onActivate={() => setActiveShortcutPane('right')}
|
||||
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
||||
isRecording={isRecording}
|
||||
recordingText={voice.interimText}
|
||||
recordingState={voice.state === 'connecting' ? 'connecting' : 'listening'}
|
||||
|
|
@ -4779,6 +5047,17 @@ function App() {
|
|||
open={showOnboarding}
|
||||
onComplete={handleOnboardingComplete}
|
||||
/>
|
||||
<ComposioGoogleMigrationModal
|
||||
open={showComposioGoogleMigration}
|
||||
onOpenChange={setShowComposioGoogleMigration}
|
||||
onReconnect={() => {
|
||||
// Trigger the rowboat-mode Google connect flow. With no credentials
|
||||
// and the user signed in to Rowboat, the main process opens the
|
||||
// webapp `/oauth/google/start` URL. The deep link returns and
|
||||
// completeRowboatGoogleConnect persists the tokens.
|
||||
void window.ipc.invoke('oauth:connect', { provider: 'google' })
|
||||
}}
|
||||
/>
|
||||
<Dialog open={showMeetingPermissions} onOpenChange={setShowMeetingPermissions}>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ import {
|
|||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation";
|
||||
import { getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
|
||||
|
||||
const formatToolValue = (value: unknown) => {
|
||||
if (typeof value === "string") return value;
|
||||
|
|
@ -224,3 +227,89 @@ export const ToolTabbedContent = ({
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ToolGroupProps = {
|
||||
group: ToolGroupType
|
||||
isToolOpen: (toolId: string) => boolean
|
||||
onToolOpenChange: (toolId: string, open: boolean) => void
|
||||
}
|
||||
|
||||
const getGroupState = (tools: ToolCall[]): ToolUIPart["state"] => {
|
||||
if (tools.some(t => t.status === 'error')) return 'output-error'
|
||||
if (tools.some(t => t.status === 'running')) return 'input-available'
|
||||
if (tools.some(t => t.status === 'pending')) return 'input-streaming'
|
||||
return 'output-available'
|
||||
}
|
||||
|
||||
export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: ToolGroupProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const state = getGroupState(group.items)
|
||||
const isCompleted = state === 'output-available' || state === 'output-error'
|
||||
const runningTool = group.items.find(t => t.status === 'running' || t.status === 'pending')
|
||||
const currentTool = runningTool ?? group.items[group.items.length - 1]
|
||||
const summary = isCompleted
|
||||
? `Ran ${group.items.length} tool${group.items.length !== 1 ? 's' : ''}`
|
||||
: currentTool ? getToolDisplayName(currentTool) : getToolGroupSummary(group.items)
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="not-prose mb-4 w-full rounded-md border"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: '1.25rem' }}>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.span
|
||||
key={summary}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="absolute inset-0 truncate text-left font-medium text-sm leading-5"
|
||||
title={summary}
|
||||
>
|
||||
{summary}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{getStatusBadge(state)}
|
||||
<ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-t">
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{group.items.map((tool) => {
|
||||
const toolState = toToolState(tool.status)
|
||||
const isOpen = isToolOpen(tool.id)
|
||||
return (
|
||||
<Tool
|
||||
key={tool.id}
|
||||
open={isOpen}
|
||||
onOpenChange={(o) => onToolOpenChange(tool.id, o)}
|
||||
className="mb-0 border-border/60"
|
||||
>
|
||||
<ToolHeader
|
||||
title={getToolDisplayName(tool)}
|
||||
type={`tool-${tool.name}`}
|
||||
state={toolState}
|
||||
/>
|
||||
<ToolContent>
|
||||
<ToolTabbedContent
|
||||
input={tool.input as ToolUIPart["input"]}
|
||||
output={tool.result as ToolUIPart["output"]}
|
||||
errorText={tool.status === 'error' ? 'Tool error' : undefined}
|
||||
/>
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
250
apps/x/apps/renderer/src/components/background-agents-view.tsx
Normal file
250
apps/x/apps/renderer/src/components/background-agents-view.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Bot, Loader2 } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { stripKnowledgePrefix, wikiLabel } from '@/lib/wiki-links'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
type BackgroundAgentNote = {
|
||||
path: string
|
||||
trackCount: number
|
||||
createdAt: string | null
|
||||
lastRunAt: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
type BackgroundAgentsViewProps = {
|
||||
onOpenNote: (path: string) => void
|
||||
onAddNewBackgroundAgent: () => void
|
||||
}
|
||||
|
||||
function formatDateLabel(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function formatDateTimeLabel(iso: string | null): string {
|
||||
if (!iso) return 'Never'
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return 'Never'
|
||||
return date.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function isKnowledgeMarkdownPath(path: string | undefined): boolean {
|
||||
return typeof path === 'string' && path.startsWith('knowledge/') && path.endsWith('.md')
|
||||
}
|
||||
|
||||
export function BackgroundAgentsView({ onOpenNote, onAddNewBackgroundAgent }: BackgroundAgentsViewProps) {
|
||||
const [notes, setNotes] = useState<BackgroundAgentNote[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [updatingPaths, setUpdatingPaths] = useState<Set<string>>(new Set())
|
||||
|
||||
const loadNotes = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke('track:listNotes', null)
|
||||
setNotes(result.notes)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to load background agent notes:', err)
|
||||
setError('Could not load background agents.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadNotes()
|
||||
}, [loadNotes])
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const scheduleReload = () => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null
|
||||
void loadNotes()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const cleanupWorkspace = window.ipc.on('workspace:didChange', (event) => {
|
||||
switch (event.type) {
|
||||
case 'created':
|
||||
case 'changed':
|
||||
case 'deleted':
|
||||
if (isKnowledgeMarkdownPath(event.path)) scheduleReload()
|
||||
break
|
||||
case 'moved':
|
||||
if (isKnowledgeMarkdownPath(event.from) || isKnowledgeMarkdownPath(event.to)) {
|
||||
scheduleReload()
|
||||
}
|
||||
break
|
||||
case 'bulkChanged':
|
||||
if (!event.paths || event.paths.some(isKnowledgeMarkdownPath)) {
|
||||
scheduleReload()
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
const cleanupTracks = window.ipc.on('tracks:events', () => {
|
||||
scheduleReload()
|
||||
})
|
||||
|
||||
return () => {
|
||||
cleanupWorkspace()
|
||||
cleanupTracks()
|
||||
if (timeout) clearTimeout(timeout)
|
||||
}
|
||||
}, [loadNotes])
|
||||
|
||||
const handleToggleState = useCallback(async (note: BackgroundAgentNote, active: boolean) => {
|
||||
setUpdatingPaths((prev) => new Set(prev).add(note.path))
|
||||
try {
|
||||
const result = await window.ipc.invoke('track:setNoteActive', {
|
||||
path: note.path,
|
||||
active,
|
||||
})
|
||||
|
||||
if (!result.success || !result.note) {
|
||||
throw new Error(result.error ?? 'Failed to update background agent state')
|
||||
}
|
||||
|
||||
const updatedNote = result.note
|
||||
setNotes((prev) => prev.map((entry) => (
|
||||
entry.path === note.path ? updatedNote : entry
|
||||
)))
|
||||
} catch (err) {
|
||||
console.error('Failed to update background agent note state:', err)
|
||||
toast(err instanceof Error ? err.message : 'Failed to update background agent state', 'error')
|
||||
} finally {
|
||||
setUpdatingPaths((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(note.path)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="shrink-0 border-b border-border px-6 py-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="size-5 text-primary" />
|
||||
<h2 className="text-base font-semibold text-foreground">Background agents</h2>
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={onAddNewBackgroundAgent}>
|
||||
Add new background agent
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Notes that contain track blocks. Toggle a note inactive to pause every background agent in it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
|
||||
<div className="rounded-full bg-muted p-3">
|
||||
<Bot className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
|
||||
<div className="rounded-full bg-muted p-3">
|
||||
<Bot className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No notes with background agents yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
|
||||
<table className="min-w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 bg-muted/30 text-left">
|
||||
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Note</th>
|
||||
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Created date</th>
|
||||
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Last ran</th>
|
||||
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{notes.map((note) => {
|
||||
const isUpdating = updatingPaths.has(note.path)
|
||||
return (
|
||||
<tr key={note.path} className="border-b border-border/50 last:border-b-0 hover:bg-muted/20">
|
||||
<td className="px-4 py-3 align-top">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenNote(note.path)}
|
||||
className="truncate text-left text-sm font-medium text-foreground hover:text-primary"
|
||||
title={note.path}
|
||||
>
|
||||
{wikiLabel(note.path)}
|
||||
</button>
|
||||
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{note.trackCount} {note.trackCount === 1 ? 'agent' : 'agents'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{stripKnowledgePrefix(note.path)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-foreground/80">
|
||||
{formatDateLabel(note.createdAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-foreground/80">
|
||||
{formatDateTimeLabel(note.lastRunAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{isUpdating ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<span className="size-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
<Switch
|
||||
checked={note.isActive}
|
||||
onCheckedChange={(checked) => { void handleToggleState(note, checked) }}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
<span className="min-w-16 text-xs font-medium text-foreground/80">
|
||||
{note.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ const BLOCKING_OVERLAY_SLOTS = new Set([
|
|||
|
||||
interface BrowserPaneProps {
|
||||
onClose: () => void
|
||||
forceHidden?: boolean
|
||||
}
|
||||
|
||||
const getActiveTab = (state: BrowserState) =>
|
||||
|
|
@ -85,7 +86,7 @@ const getBrowserTabTitle = (tab: BrowserTabState) => {
|
|||
}
|
||||
}
|
||||
|
||||
export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||
export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps) {
|
||||
const [state, setState] = useState<BrowserState>(EMPTY_STATE)
|
||||
const [addressValue, setAddressValue] = useState('')
|
||||
|
||||
|
|
@ -175,6 +176,12 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
|||
}, [])
|
||||
|
||||
const syncView = useCallback(() => {
|
||||
if (forceHidden) {
|
||||
lastBoundsRef.current = null
|
||||
setViewVisible(false)
|
||||
return null
|
||||
}
|
||||
|
||||
const doc = viewportRef.current?.ownerDocument
|
||||
if (doc && hasBlockingOverlay(doc)) {
|
||||
lastBoundsRef.current = null
|
||||
|
|
@ -191,7 +198,7 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
|||
pushBounds(bounds)
|
||||
setViewVisible(true)
|
||||
return bounds
|
||||
}, [measureBounds, pushBounds, setViewVisible])
|
||||
}, [forceHidden, measureBounds, pushBounds, setViewVisible])
|
||||
|
||||
useEffect(() => {
|
||||
syncView()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
MessageResponse,
|
||||
} from '@/components/ai-elements/message'
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
|
||||
import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
|
||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||
|
|
@ -30,6 +30,7 @@ import remarkBreaks from 'remark-breaks'
|
|||
import { TabBar, type ChatTab } from '@/components/tab-bar'
|
||||
import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||
import { useSidebar } from '@/components/ui/sidebar'
|
||||
import { wikiLabel } from '@/lib/wiki-links'
|
||||
import {
|
||||
type ChatViewportAnchorState,
|
||||
|
|
@ -40,9 +41,11 @@ import {
|
|||
getWebSearchCardData,
|
||||
getComposioConnectCardData,
|
||||
getToolDisplayName,
|
||||
groupConversationItems,
|
||||
isChatMessage,
|
||||
isErrorMessage,
|
||||
isToolCall,
|
||||
isToolGroup,
|
||||
normalizeToolInput,
|
||||
normalizeToolOutput,
|
||||
parseAttachedFiles,
|
||||
|
|
@ -175,6 +178,7 @@ interface ChatSidebarProps {
|
|||
onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void
|
||||
onOpenKnowledgeFile?: (path: string) => void
|
||||
onActivate?: () => void
|
||||
collapsedLeftPaddingPx?: number
|
||||
// Voice / TTS props
|
||||
isRecording?: boolean
|
||||
recordingText?: string
|
||||
|
|
@ -229,6 +233,7 @@ export function ChatSidebar({
|
|||
onToolOpenChangeForTab,
|
||||
onOpenKnowledgeFile,
|
||||
onActivate,
|
||||
collapsedLeftPaddingPx = 196,
|
||||
isRecording,
|
||||
recordingText,
|
||||
recordingState,
|
||||
|
|
@ -243,6 +248,7 @@ export function ChatSidebar({
|
|||
onTtsModeChange,
|
||||
onComposioConnected,
|
||||
}: ChatSidebarProps) {
|
||||
const { state: sidebarState } = useSidebar()
|
||||
const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const [showContent, setShowContent] = useState(isOpen)
|
||||
|
|
@ -517,7 +523,14 @@ export function ChatSidebar({
|
|||
|
||||
{showContent && (
|
||||
<>
|
||||
<header className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar">
|
||||
<header
|
||||
className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar"
|
||||
style={{
|
||||
paddingLeft: isMaximized && sidebarState === 'collapsed' ? collapsedLeftPaddingPx : undefined,
|
||||
paddingRight: isMaximized ? 12 : undefined,
|
||||
transition: isMaximized ? 'padding-left 200ms linear' : undefined,
|
||||
}}
|
||||
>
|
||||
<TabBar
|
||||
tabs={chatTabs}
|
||||
activeTabId={activeChatTabId}
|
||||
|
|
@ -591,7 +604,20 @@ export function ChatSidebar({
|
|||
</ConversationEmptyState>
|
||||
) : (
|
||||
<>
|
||||
{tabState.conversation.map((item) => {
|
||||
{groupConversationItems(
|
||||
tabState.conversation,
|
||||
(id) => !!tabState.allPermissionRequests.get(id)
|
||||
).map((item) => {
|
||||
if (isToolGroup(item)) {
|
||||
return (
|
||||
<ToolGroupComponent
|
||||
key={item.groupId}
|
||||
group={item}
|
||||
isToolOpen={(toolId) => isToolOpenForTab?.(tab.id, toolId) ?? false}
|
||||
onToolOpenChange={(toolId, open) => onToolOpenChangeForTab?.(tab.id, toolId, open)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const rendered = renderConversationItem(item, tab.id)
|
||||
if (isToolCall(item) && onPermissionResponse) {
|
||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface ComposioGoogleMigrationModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onReconnect: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time modal shown to signed-in users who had Gmail/Calendar connected
|
||||
* via Composio before the native rowboat-mode OAuth flow shipped. By the
|
||||
* time this opens, the Composio Google accounts have already been
|
||||
* disconnected (fire-and-forget, on the qualification IPC) — the modal
|
||||
* just explains what happened and offers a one-click reconnect.
|
||||
*
|
||||
* Both buttons close the modal. The qualification IPC marks the migration
|
||||
* as dismissed before showing this, so neither button needs a follow-up
|
||||
* IPC of its own.
|
||||
*/
|
||||
export function ComposioGoogleMigrationModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onReconnect,
|
||||
}: ComposioGoogleMigrationModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[min(28rem,calc(100%-2rem))] max-w-md p-0 gap-0 overflow-hidden rounded-xl">
|
||||
<div className="p-6 pb-0">
|
||||
<DialogHeader className="space-y-1.5">
|
||||
<DialogTitle className="text-lg font-semibold">
|
||||
Reconnect Google to resume syncing
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3 text-sm leading-relaxed">
|
||||
<p>
|
||||
Knowledge graph syncing for Gmail and Calendar now uses a
|
||||
direct Google connection. Reconnect to resume. Your existing
|
||||
emails and events stay where they are.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-6 py-4 mt-6 border-t bg-muted/30">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
I'll do this later
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onReconnect()
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
Reconnect Google
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -96,14 +96,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||
|
||||
// Composio/Gmail state
|
||||
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
|
||||
// Composio Gmail/Calendar sync was removed — flags are seeded false and
|
||||
// never flipped. Kept here so legacy gating expressions still type-check.
|
||||
const [useComposioForGoogle] = useState(false)
|
||||
const [gmailConnected, setGmailConnected] = useState(false)
|
||||
const [gmailLoading, setGmailLoading] = useState(true)
|
||||
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||
|
||||
// Composio/Google Calendar state
|
||||
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
|
||||
const [useComposioForGoogleCalendar] = useState(false)
|
||||
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
||||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
||||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||
|
|
@ -151,25 +151,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
setProvidersLoading(false)
|
||||
}
|
||||
}
|
||||
async function loadComposioForGoogleFlag() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
|
||||
setUseComposioForGoogle(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to check composio-for-google flag:', error)
|
||||
}
|
||||
}
|
||||
async function loadComposioForGoogleCalendarFlag() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null)
|
||||
setUseComposioForGoogleCalendar(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to check composio-for-google-calendar flag:', error)
|
||||
}
|
||||
}
|
||||
// (Composio Gmail/Calendar flag fetches removed — sync was deleted.)
|
||||
loadProviders()
|
||||
loadComposioForGoogleFlag()
|
||||
loadComposioForGoogleCalendarFlag()
|
||||
}, [open])
|
||||
|
||||
// Load LLM models catalog on open
|
||||
|
|
@ -622,12 +605,20 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
// Connect to a provider
|
||||
const handleConnect = useCallback(async (provider: string) => {
|
||||
if (provider === 'google') {
|
||||
// Signed-in users use the rowboat (managed-credentials) flow: opens
|
||||
// the webapp in the browser, no BYOK modal. Falls back to BYOK modal
|
||||
// for not-signed-in users. (Mirrors useConnectors.handleConnect.)
|
||||
const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false
|
||||
if (isSignedIntoRowboat) {
|
||||
await startConnect('google')
|
||||
return
|
||||
}
|
||||
setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
await startConnect(provider)
|
||||
}, [startConnect])
|
||||
}, [startConnect, providerStates])
|
||||
|
||||
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
||||
setGoogleCredentials(clientId, clientSecret)
|
||||
|
|
|
|||
|
|
@ -66,16 +66,16 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
// Inline upsell callout dismissed
|
||||
const [upsellDismissed, setUpsellDismissed] = useState(false)
|
||||
|
||||
// Composio/Gmail state (used when signed in with Rowboat account)
|
||||
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
|
||||
// Composio Gmail/Calendar sync was removed — flags are seeded false and
|
||||
// never flipped. Kept here so legacy gating expressions still type-check.
|
||||
const [useComposioForGoogle] = useState(false)
|
||||
const [gmailConnected, setGmailConnected] = useState(false)
|
||||
const [gmailLoading, setGmailLoading] = useState(true)
|
||||
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
||||
const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail')
|
||||
|
||||
// Composio/Google Calendar state
|
||||
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
|
||||
const [useComposioForGoogleCalendar] = useState(false)
|
||||
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
||||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
||||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||
|
|
@ -123,25 +123,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
setProvidersLoading(false)
|
||||
}
|
||||
}
|
||||
async function loadComposioForGoogleFlag() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
|
||||
setUseComposioForGoogle(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to check composio-for-google flag:', error)
|
||||
}
|
||||
}
|
||||
async function loadComposioForGoogleCalendarFlag() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null)
|
||||
setUseComposioForGoogleCalendar(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to check composio-for-google-calendar flag:', error)
|
||||
}
|
||||
}
|
||||
// (Composio Gmail/Calendar flag fetches removed — sync was deleted; flags stay false.)
|
||||
loadProviders()
|
||||
loadComposioForGoogleFlag()
|
||||
loadComposioForGoogleCalendarFlag()
|
||||
}, [open])
|
||||
|
||||
// Load LLM models catalog on open
|
||||
|
|
@ -539,17 +522,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
|
||||
const cleanup = window.ipc.on('oauth:didConnect', async (event) => {
|
||||
if (event.provider === 'rowboat' && event.success) {
|
||||
// Re-check composio flags now that the account is connected
|
||||
try {
|
||||
const [googleResult, calendarResult] = await Promise.all([
|
||||
window.ipc.invoke('composio:use-composio-for-google', null),
|
||||
window.ipc.invoke('composio:use-composio-for-google-calendar', null),
|
||||
])
|
||||
setUseComposioForGoogle(googleResult.enabled)
|
||||
setUseComposioForGoogleCalendar(calendarResult.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to re-check composio flags:', error)
|
||||
}
|
||||
// (Composio Gmail/Calendar flag re-check removed — sync was deleted.)
|
||||
setCurrentStep(2) // Go to Connect Accounts
|
||||
}
|
||||
})
|
||||
|
|
@ -609,12 +582,20 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
// Connect to a provider
|
||||
const handleConnect = useCallback(async (provider: string) => {
|
||||
if (provider === 'google') {
|
||||
// Signed-in users use the rowboat (managed-credentials) flow: opens
|
||||
// the webapp in the browser, no BYOK modal. Falls back to BYOK modal
|
||||
// for not-signed-in users. (Mirrors useConnectors.handleConnect.)
|
||||
const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false
|
||||
if (isSignedIntoRowboat) {
|
||||
await startConnect('google')
|
||||
return
|
||||
}
|
||||
setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
await startConnect(provider)
|
||||
}, [startConnect])
|
||||
}, [startConnect, providerStates])
|
||||
|
||||
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
||||
setGoogleCredentials(clientId, clientSecret)
|
||||
|
|
|
|||
|
|
@ -156,6 +156,28 @@ const SERVICE_LABELS: Record<string, string> = {
|
|||
granola: "Syncing Granola",
|
||||
graph: "Updating knowledge",
|
||||
voice_memo: "Processing voice memo",
|
||||
email_labeling: "Labeling emails",
|
||||
note_tagging: "Tagging notes",
|
||||
agent_notes: "Updating agent notes",
|
||||
}
|
||||
|
||||
function summarizeServiceError(error: string): string {
|
||||
const firstLine = error.split("\n").find((line) => line.trim().length > 0)
|
||||
return firstLine?.trim() || error.trim()
|
||||
}
|
||||
|
||||
function collectServiceErrors(events: ServiceEventType[]): Map<string, string> {
|
||||
const errors = new Map<string, string>()
|
||||
for (const event of events) {
|
||||
if (event.type === "error") {
|
||||
errors.set(event.service, summarizeServiceError(event.error))
|
||||
continue
|
||||
}
|
||||
if (event.type === "run_complete" && event.outcome !== "error") {
|
||||
errors.delete(event.service)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
type TasksActions = {
|
||||
|
|
@ -186,10 +208,14 @@ type SidebarContentPanelProps = {
|
|||
meetingSummarizing?: boolean
|
||||
meetingAvailable?: boolean
|
||||
onToggleMeeting?: () => void
|
||||
isSearchOpen?: boolean
|
||||
isMeetingActionActive?: boolean
|
||||
isBrowserOpen?: boolean
|
||||
onToggleBrowser?: () => void
|
||||
isSuggestedTopicsOpen?: boolean
|
||||
onOpenSuggestedTopics?: () => void
|
||||
isBackgroundAgentsOpen?: boolean
|
||||
onOpenBackgroundAgents?: () => void
|
||||
} & React.ComponentProps<typeof Sidebar>
|
||||
|
||||
const sectionTabs: { id: ActiveSection; label: string }[] = [
|
||||
|
|
@ -225,6 +251,7 @@ function formatRunTime(ts: string): string {
|
|||
function SyncStatusBar() {
|
||||
const { state } = useSidebar()
|
||||
const [activeServices, setActiveServices] = useState<Map<string, string>>(new Map())
|
||||
const [serviceErrors, setServiceErrors] = useState<Map<string, string>>(new Map())
|
||||
const [popoverOpen, setPopoverOpen] = useState(false)
|
||||
const [logEvents, setLogEvents] = useState<ServiceEventType[]>([])
|
||||
const [logLoading, setLogLoading] = useState(false)
|
||||
|
|
@ -258,11 +285,25 @@ function SyncStatusBar() {
|
|||
next.delete(nextEvent.runId)
|
||||
return next
|
||||
})
|
||||
if (nextEvent.outcome !== 'error') {
|
||||
setServiceErrors((prev) => {
|
||||
if (!prev.has(nextEvent.service)) return prev
|
||||
const next = new Map(prev)
|
||||
next.delete(nextEvent.service)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const existingTimeout = runTimeoutsRef.current.get(nextEvent.runId)
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout)
|
||||
runTimeoutsRef.current.delete(nextEvent.runId)
|
||||
}
|
||||
} else if (nextEvent.type === 'error') {
|
||||
setServiceErrors((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(nextEvent.service, summarizeServiceError(nextEvent.error))
|
||||
return next
|
||||
})
|
||||
}
|
||||
})
|
||||
return cleanup
|
||||
|
|
@ -296,10 +337,14 @@ function SyncStatusBar() {
|
|||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
setServiceErrors(collectServiceErrors(parsed))
|
||||
// Newest first, limit to 1000
|
||||
setLogEvents(parsed.reverse().slice(0, MAX_SYNC_EVENTS))
|
||||
} catch {
|
||||
if (!cancelled) setLogEvents([])
|
||||
if (!cancelled) {
|
||||
setLogEvents([])
|
||||
setServiceErrors(new Map())
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLogLoading(false)
|
||||
}
|
||||
|
|
@ -310,12 +355,19 @@ function SyncStatusBar() {
|
|||
|
||||
const isSyncing = activeServices.size > 0
|
||||
const isCollapsed = state === "collapsed"
|
||||
const errorEntries = Array.from(serviceErrors.entries())
|
||||
const primaryErrorService = errorEntries[0]?.[0] ?? null
|
||||
const hasServiceErrors = errorEntries.length > 0
|
||||
|
||||
// Build status label from active services
|
||||
const activeServiceNames = [...new Set(activeServices.values())]
|
||||
const statusLabel = isSyncing
|
||||
? activeServiceNames.map((s) => SERVICE_LABELS[s] || s).join(", ")
|
||||
: "All caught up"
|
||||
: hasServiceErrors
|
||||
? errorEntries.length === 1
|
||||
? `${SERVICE_LABELS[primaryErrorService ?? ""] || primaryErrorService} failed`
|
||||
: "Recent sync issues"
|
||||
: "All caught up"
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -333,11 +385,16 @@ function SyncStatusBar() {
|
|||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-sidebar-accent"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md px-2 py-1 text-xs hover:bg-sidebar-accent",
|
||||
hasServiceErrors && !isSyncing ? "text-red-600 dark:text-red-400" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{isSyncing ? (
|
||||
<LoaderIcon className="h-3 w-3 shrink-0 animate-spin" />
|
||||
) : hasServiceErrors ? (
|
||||
<AlertTriangle className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/60" />
|
||||
)}
|
||||
|
|
@ -355,7 +412,7 @@ function SyncStatusBar() {
|
|||
<div className="p-3 border-b">
|
||||
<h4 className="font-semibold text-sm">Sync Activity</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{isSyncing ? statusLabel : "All services up to date"}
|
||||
{isSyncing || hasServiceErrors ? statusLabel : "All services up to date"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto p-2">
|
||||
|
|
@ -387,7 +444,17 @@ function SyncStatusBar() {
|
|||
{SERVICE_LABELS[event.service]?.split(" ").slice(-1)[0] || event.service}
|
||||
</span>
|
||||
</span>
|
||||
<span className="leading-4 text-foreground/80">{event.message}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="leading-4 text-foreground/80">{event.message}</p>
|
||||
{event.type === 'error' && (
|
||||
<p
|
||||
className="truncate text-[11px] leading-4 text-red-600/90 dark:text-red-400/90"
|
||||
title={event.error}
|
||||
>
|
||||
{summarizeServiceError(event.error)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -420,10 +487,14 @@ export function SidebarContentPanel({
|
|||
meetingSummarizing = false,
|
||||
meetingAvailable = false,
|
||||
onToggleMeeting,
|
||||
isSearchOpen = false,
|
||||
isMeetingActionActive = false,
|
||||
isBrowserOpen = false,
|
||||
onToggleBrowser,
|
||||
isSuggestedTopicsOpen = false,
|
||||
onOpenSuggestedTopics,
|
||||
isBackgroundAgentsOpen = false,
|
||||
onOpenBackgroundAgents,
|
||||
...props
|
||||
}: SidebarContentPanelProps) {
|
||||
const { activeSection, setActiveSection } = useSidebarSection()
|
||||
|
|
@ -436,6 +507,10 @@ export function SidebarContentPanel({
|
|||
const [loggingIn, setLoggingIn] = useState(false)
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
const { billing } = useBilling(isRowboatConnected)
|
||||
const isMeetingQuickActionSelected = isMeetingActionActive
|
||||
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen && !isMeetingQuickActionSelected
|
||||
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
|
||||
const isBackgroundAgentsQuickActionSelected = isBackgroundAgentsOpen && !isBrowserOpen
|
||||
|
||||
const handleRowboatLogin = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -533,7 +608,12 @@ export function SidebarContentPanel({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onOpenSearch}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isSearchOpen
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<SearchIcon className="size-4" />
|
||||
<span>Search</span>
|
||||
|
|
@ -546,9 +626,14 @@ export function SidebarContentPanel({
|
|||
disabled={meetingState === 'connecting' || meetingState === 'stopping' || meetingSummarizing}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors disabled:pointer-events-none",
|
||||
isMeetingQuickActionSelected
|
||||
? "bg-sidebar-accent"
|
||||
: "hover:bg-sidebar-accent",
|
||||
meetingState === 'recording'
|
||||
? "text-red-500 hover:bg-sidebar-accent"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
? "text-red-500"
|
||||
: isMeetingQuickActionSelected
|
||||
? "text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{meetingSummarizing || meetingState === 'connecting' ? (
|
||||
|
|
@ -575,7 +660,7 @@ export function SidebarContentPanel({
|
|||
onClick={onToggleBrowser}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isBrowserOpen
|
||||
isBrowserQuickActionSelected
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
|
|
@ -590,7 +675,7 @@ export function SidebarContentPanel({
|
|||
onClick={onOpenSuggestedTopics}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isSuggestedTopicsOpen
|
||||
isSuggestedTopicsQuickActionSelected
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
|
|
@ -599,6 +684,21 @@ export function SidebarContentPanel({
|
|||
<span>Suggested Topics</span>
|
||||
</button>
|
||||
)}
|
||||
{onOpenBackgroundAgents && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenBackgroundAgents}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isBackgroundAgentsQuickActionSelected
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Bot className="size-4" />
|
||||
<span>Background agents</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
|||
import { X, Calendar, Video, ChevronDown, Mic } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { extractConferenceLink } from '../lib/calendar-event'
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
|
|
@ -40,25 +41,6 @@ function getTimeRange(event: blocks.CalendarEvent): string {
|
|||
return `${startTime} \u2013 ${endTime}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a video conference link from raw Google Calendar event JSON.
|
||||
* Checks conferenceData.entryPoints (video type), hangoutLink, then falls back
|
||||
* to conferenceLink if already set.
|
||||
*/
|
||||
function extractConferenceLink(raw: Record<string, unknown>): string | undefined {
|
||||
// Check conferenceData.entryPoints for video entry
|
||||
const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined
|
||||
if (confData?.entryPoints) {
|
||||
const video = confData.entryPoints.find(ep => ep.entryPointType === 'video')
|
||||
if (video?.uri) return video.uri
|
||||
}
|
||||
// Check hangoutLink (Google Meet shortcut)
|
||||
if (typeof raw.hangoutLink === 'string') return raw.hangoutLink
|
||||
// Fall back to conferenceLink if present
|
||||
if (typeof raw.conferenceLink === 'string') return raw.conferenceLink
|
||||
return undefined
|
||||
}
|
||||
|
||||
interface ResolvedEvent {
|
||||
event: blocks.CalendarEvent
|
||||
loaded: blocks.CalendarEvent | null
|
||||
|
|
|
|||
|
|
@ -58,15 +58,29 @@ export function useAnalyticsIdentity() {
|
|||
// Listen for OAuth connect/disconnect events to update identity
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||
if (!event.success) return
|
||||
|
||||
// If Rowboat provider connected, identify user
|
||||
if (event.provider === 'rowboat' && event.userId) {
|
||||
posthog.identify(event.userId)
|
||||
posthog.people.set({ signed_in: true })
|
||||
if (event.provider !== 'rowboat') {
|
||||
// Other providers: just toggle the connection flag
|
||||
if (event.success) {
|
||||
posthog.people.set({ [`${event.provider}_connected`]: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
posthog.people.set({ [`${event.provider}_connected`]: true })
|
||||
// Rowboat sign-in
|
||||
if (event.success) {
|
||||
if (event.userId) {
|
||||
posthog.identify(event.userId)
|
||||
}
|
||||
posthog.people.set({ signed_in: true, rowboat_connected: true })
|
||||
posthog.capture('user_signed_in')
|
||||
return
|
||||
}
|
||||
|
||||
// Rowboat sign-out — flip flags, capture, and reset distinct_id so
|
||||
// future events on this device don't get attributed to the prior user.
|
||||
posthog.people.set({ signed_in: false, rowboat_connected: false })
|
||||
posthog.capture('user_signed_out')
|
||||
posthog.reset()
|
||||
})
|
||||
|
||||
return cleanup
|
||||
|
|
|
|||
|
|
@ -38,16 +38,21 @@ export function useConnectors(active: boolean) {
|
|||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||
|
||||
// Composio/Gmail state
|
||||
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
|
||||
// Composio Gmail/Calendar sync was removed. These flags are seeded false
|
||||
// and never flipped — the IPC that used to set them is gone. The setters
|
||||
// remain so the legacy Composio-Gmail handlers below still type-check,
|
||||
// but those handlers are no longer reachable in the UI (the gating
|
||||
// condition `useComposioForGoogle` stays false).
|
||||
// TODO follow-up: drop these flags entirely and prune the dead UI branches
|
||||
// in connectors-popover, connected-accounts-settings, and onboarding-modal.
|
||||
const [useComposioForGoogle] = useState(false)
|
||||
const [gmailConnected, setGmailConnected] = useState(false)
|
||||
const [gmailLoading, setGmailLoading] = useState(true)
|
||||
const [gmailLoading, setGmailLoading] = useState(false)
|
||||
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||
|
||||
// Composio/Google Calendar state
|
||||
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
|
||||
const [useComposioForGoogleCalendar] = useState(false)
|
||||
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
||||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
||||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(false)
|
||||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||
|
||||
// Load available providers on mount
|
||||
|
|
@ -67,28 +72,7 @@ export function useConnectors(active: boolean) {
|
|||
loadProviders()
|
||||
}, [])
|
||||
|
||||
// Re-check composio-for-google flags when active
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
async function loadComposioForGoogleFlag() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
|
||||
setUseComposioForGoogle(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to check composio-for-google flag:', error)
|
||||
}
|
||||
}
|
||||
async function loadComposioForGoogleCalendarFlag() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null)
|
||||
setUseComposioForGoogleCalendar(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to check composio-for-google-calendar flag:', error)
|
||||
}
|
||||
}
|
||||
loadComposioForGoogleFlag()
|
||||
loadComposioForGoogleCalendarFlag()
|
||||
}, [active])
|
||||
// (Composio Gmail/Calendar flag-check effect removed — flags are constant false now.)
|
||||
|
||||
// Load Granola config
|
||||
const refreshGranolaConfig = useCallback(async () => {
|
||||
|
|
@ -346,13 +330,22 @@ export function useConnectors(active: boolean) {
|
|||
|
||||
const handleConnect = useCallback(async (provider: string) => {
|
||||
if (provider === 'google') {
|
||||
// Signed-in users use the rowboat (managed-credentials) flow: opens
|
||||
// the webapp in the browser, no BYOK modal. Main process detects
|
||||
// signed-in via isSignedIn() when oauth:connect arrives without creds.
|
||||
// Falls back to the BYOK modal for not-signed-in users.
|
||||
const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false
|
||||
if (isSignedIntoRowboat) {
|
||||
await startConnect('google')
|
||||
return
|
||||
}
|
||||
setGoogleClientIdDescription(undefined)
|
||||
setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
await startConnect(provider)
|
||||
}, [startConnect])
|
||||
}, [startConnect, providerStates])
|
||||
|
||||
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
||||
setGoogleCredentials(clientId, clientSecret)
|
||||
|
|
@ -485,19 +478,6 @@ export function useConnectors(active: boolean) {
|
|||
toast.success(`Connected to ${displayName}`)
|
||||
}
|
||||
|
||||
if (provider === 'rowboat') {
|
||||
try {
|
||||
const [googleResult, calendarResult] = await Promise.all([
|
||||
window.ipc.invoke('composio:use-composio-for-google', null),
|
||||
window.ipc.invoke('composio:use-composio-for-google-calendar', null),
|
||||
])
|
||||
setUseComposioForGoogle(googleResult.enabled)
|
||||
setUseComposioForGoogleCalendar(calendarResult.enabled)
|
||||
} catch (err) {
|
||||
console.error('Failed to re-check composio flags:', err)
|
||||
}
|
||||
}
|
||||
|
||||
refreshAllStatuses()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
15
apps/x/apps/renderer/src/lib/calendar-event.ts
Normal file
15
apps/x/apps/renderer/src/lib/calendar-event.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Extract a video conference link from raw Google Calendar event JSON.
|
||||
* Checks conferenceData.entryPoints (video type), hangoutLink, then falls back
|
||||
* to a top-level conferenceLink if present.
|
||||
*/
|
||||
export function extractConferenceLink(raw: Record<string, unknown>): string | undefined {
|
||||
const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined
|
||||
if (confData?.entryPoints) {
|
||||
const video = confData.entryPoints.find(ep => ep.entryPointType === 'video')
|
||||
if (video?.uri) return video.uri
|
||||
}
|
||||
if (typeof raw.hangoutLink === 'string') return raw.hangoutLink
|
||||
if (typeof raw.conferenceLink === 'string') return raw.conferenceLink
|
||||
return undefined
|
||||
}
|
||||
|
|
@ -586,6 +586,72 @@ export const getComposioActionCardData = (tool: ToolCall): ComposioActionCardDat
|
|||
return null
|
||||
}
|
||||
|
||||
export type ToolGroup = {
|
||||
type: 'tool-group'
|
||||
items: ToolCall[]
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export type GroupedConversationItem = ConversationItem | ToolGroup
|
||||
|
||||
export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup =>
|
||||
'type' in item && (item as ToolGroup).type === 'tool-group'
|
||||
|
||||
const isPlainToolCall = (item: ConversationItem): item is ToolCall => {
|
||||
if (!isToolCall(item)) return false
|
||||
if (getWebSearchCardData(item)) return false
|
||||
if (getComposioConnectCardData(item)) return false
|
||||
if (getAppActionCardData(item)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export const groupConversationItems = (
|
||||
items: ConversationItem[],
|
||||
hasPermissionRequest: (id: string) => boolean
|
||||
): GroupedConversationItem[] => {
|
||||
const result: GroupedConversationItem[] = []
|
||||
let i = 0
|
||||
|
||||
while (i < items.length) {
|
||||
const item = items[i]
|
||||
if (isPlainToolCall(item) && !hasPermissionRequest(item.id)) {
|
||||
const group: ToolCall[] = [item]
|
||||
i++
|
||||
while (
|
||||
i < items.length &&
|
||||
isPlainToolCall(items[i] as ConversationItem) &&
|
||||
!hasPermissionRequest((items[i] as ToolCall).id)
|
||||
) {
|
||||
group.push(items[i] as ToolCall)
|
||||
i++
|
||||
}
|
||||
if (group.length === 1) {
|
||||
result.push(group[0])
|
||||
} else {
|
||||
result.push({ type: 'tool-group', items: group, groupId: group[0].id })
|
||||
}
|
||||
} else {
|
||||
result.push(item)
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const getToolGroupSummary = (tools: ToolCall[]): string => {
|
||||
const seen = new Set<string>()
|
||||
const names: string[] = []
|
||||
for (const tool of tools) {
|
||||
const name = getToolDisplayName(tool)
|
||||
if (!seen.has(name)) {
|
||||
seen.add(name)
|
||||
names.push(name)
|
||||
}
|
||||
}
|
||||
return names.join(' · ')
|
||||
}
|
||||
|
||||
export const inferRunTitleFromMessage = (content: string): string | undefined => {
|
||||
const { message } = parseAttachedFiles(content)
|
||||
const normalized = message.replace(/\s+/g, ' ').trim()
|
||||
|
|
|
|||
|
|
@ -2,20 +2,45 @@ import { StrictMode } from 'react'
|
|||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import posthog from 'posthog-js'
|
||||
import { PostHogProvider } from 'posthog-js/react'
|
||||
import { ThemeProvider } from '@/contexts/theme-context'
|
||||
|
||||
const options = {
|
||||
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
|
||||
defaults: '2025-11-30',
|
||||
} as const
|
||||
// Fetch the stable installation ID from main so renderer + main share one
|
||||
// PostHog distinct_id. Falls back to PostHog's auto-generated anonymous ID
|
||||
// if the IPC call fails (rare — main is always up before renderer).
|
||||
async function bootstrap() {
|
||||
let installationId: string | undefined
|
||||
let apiUrl: string | undefined
|
||||
try {
|
||||
const result = await window.ipc.invoke('analytics:bootstrap', null)
|
||||
installationId = result.installationId
|
||||
apiUrl = result.apiUrl
|
||||
} catch (err) {
|
||||
console.error('[Analytics] Failed to bootstrap from main:', err)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<PostHogProvider apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_KEY} options={options}>
|
||||
<ThemeProvider defaultTheme="system">
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</PostHogProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
const options = {
|
||||
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
|
||||
defaults: '2025-11-30',
|
||||
...(installationId ? { bootstrap: { distinctID: installationId } } : {}),
|
||||
} as const
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<PostHogProvider apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_KEY} options={options}>
|
||||
<ThemeProvider defaultTheme="system">
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</PostHogProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
// Tag the active person record with api_url so anonymous users are also
|
||||
// segmentable by environment.
|
||||
if (apiUrl) {
|
||||
posthog.people.set({ api_url: apiUrl })
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"openid-client": "^6.8.1",
|
||||
"papaparse": "^5.5.3",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"posthog-node": "^4.18.0",
|
||||
"react": "^19.2.3",
|
||||
"xlsx": "^0.18.5",
|
||||
"yaml": "^2.8.2",
|
||||
|
|
|
|||
|
|
@ -164,7 +164,11 @@ async function runAgent(
|
|||
|
||||
try {
|
||||
// Create a new run via core (resolves agent + default model+provider).
|
||||
const run = await createRun({ agentId: agentName });
|
||||
const run = await createRun({
|
||||
agentId: agentName,
|
||||
useCase: 'copilot_chat',
|
||||
subUseCase: 'scheduled',
|
||||
});
|
||||
console.log(`[AgentRunner] Created run ${run.id} for agent ${agentName}`);
|
||||
|
||||
// Add the starting message as a user message
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ import { IRunsLock } from "../runs/lock.js";
|
|||
import { IAbortRegistry } from "../runs/abort-registry.js";
|
||||
import { PrefixLogger } from "@x/shared";
|
||||
import { parse } from "yaml";
|
||||
import { captureLlmUsage } from "../analytics/usage.js";
|
||||
import { enterUseCase, type UseCase } from "../analytics/use_case.js";
|
||||
import { getRaw as getNoteCreationRaw } from "../knowledge/note_creation.js";
|
||||
import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
|
||||
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
|
||||
|
|
@ -650,6 +652,8 @@ export class AgentState {
|
|||
agentName: string | null = null;
|
||||
runModel: string | null = null;
|
||||
runProvider: string | null = null;
|
||||
runUseCase: UseCase | null = null;
|
||||
runSubUseCase: string | null = null;
|
||||
messages: z.infer<typeof MessageList> = [];
|
||||
lastAssistantMsg: z.infer<typeof AssistantMessage> | null = null;
|
||||
subflowStates: Record<string, AgentState> = {};
|
||||
|
|
@ -765,6 +769,8 @@ export class AgentState {
|
|||
this.agentName = event.agentName;
|
||||
this.runModel = event.model;
|
||||
this.runProvider = event.provider;
|
||||
this.runUseCase = event.useCase ?? null;
|
||||
this.runSubUseCase = event.subUseCase ?? null;
|
||||
break;
|
||||
case "spawn-subflow":
|
||||
// Seed the subflow state with its agent so downstream loadAgent works.
|
||||
|
|
@ -775,6 +781,8 @@ export class AgentState {
|
|||
this.subflowStates[event.toolCallId].agentName = event.agentName;
|
||||
this.subflowStates[event.toolCallId].runModel = this.runModel;
|
||||
this.subflowStates[event.toolCallId].runProvider = this.runProvider;
|
||||
this.subflowStates[event.toolCallId].runUseCase = this.runUseCase;
|
||||
this.subflowStates[event.toolCallId].runSubUseCase = this.runSubUseCase;
|
||||
break;
|
||||
case "message":
|
||||
this.messages.push(event.message);
|
||||
|
|
@ -881,6 +889,14 @@ export async function* streamAgent({
|
|||
const model = provider.languageModel(modelId);
|
||||
logger.log(`using model: ${modelId} (provider: ${state.runProvider})`);
|
||||
|
||||
// Install use-case context for tool-internal LLM calls (e.g. parseFile)
|
||||
// so they can tag their `llm_usage` events with the parent run's category.
|
||||
enterUseCase({
|
||||
useCase: state.runUseCase ?? "copilot_chat",
|
||||
...(state.runSubUseCase ? { subUseCase: state.runSubUseCase } : {}),
|
||||
...(state.agentName ? { agentName: state.agentName } : {}),
|
||||
});
|
||||
|
||||
let loopCounter = 0;
|
||||
let voiceInput = false;
|
||||
let voiceOutput: 'summary' | 'full' | null = null;
|
||||
|
|
@ -1114,6 +1130,13 @@ export async function* streamAgent({
|
|||
instructionsWithDateTime,
|
||||
tools,
|
||||
signal,
|
||||
{
|
||||
useCase: state.runUseCase ?? "copilot_chat",
|
||||
...(state.runSubUseCase ? { subUseCase: state.runSubUseCase } : {}),
|
||||
agentName: state.agentName ?? undefined,
|
||||
modelId,
|
||||
providerName: state.runProvider!,
|
||||
},
|
||||
)) {
|
||||
messageBuilder.ingest(event);
|
||||
yield* processEvent({
|
||||
|
|
@ -1201,12 +1224,21 @@ export async function* streamAgent({
|
|||
}
|
||||
}
|
||||
|
||||
interface StreamLlmAnalytics {
|
||||
useCase: UseCase;
|
||||
subUseCase?: string;
|
||||
agentName?: string;
|
||||
modelId: string;
|
||||
providerName: string;
|
||||
}
|
||||
|
||||
async function* streamLlm(
|
||||
model: LanguageModel,
|
||||
messages: z.infer<typeof MessageList>,
|
||||
instructions: string,
|
||||
tools: ToolSet,
|
||||
signal?: AbortSignal,
|
||||
analytics?: StreamLlmAnalytics,
|
||||
): AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown> {
|
||||
const converted = convertFromMessages(messages);
|
||||
console.log(`! SENDING payload to model: `, JSON.stringify(converted))
|
||||
|
|
@ -1277,6 +1309,16 @@ async function* streamLlm(
|
|||
};
|
||||
break;
|
||||
case "finish-step":
|
||||
if (analytics) {
|
||||
captureLlmUsage({
|
||||
useCase: analytics.useCase,
|
||||
...(analytics.subUseCase ? { subUseCase: analytics.subUseCase } : {}),
|
||||
...(analytics.agentName ? { agentName: analytics.agentName } : {}),
|
||||
model: analytics.modelId,
|
||||
provider: analytics.providerName,
|
||||
usage: event.usage,
|
||||
});
|
||||
}
|
||||
yield {
|
||||
type: "finish-step",
|
||||
usage: event.usage,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,35 @@
|
|||
import { bus } from "../runs/bus.js";
|
||||
import { fetchRun } from "../runs/runs.js";
|
||||
|
||||
type RunRecord = Awaited<ReturnType<typeof fetchRun>>;
|
||||
|
||||
function extractRunErrors(run: RunRecord): string[] {
|
||||
return run.log.flatMap((event) => event.type === "error" ? [event.error] : []);
|
||||
}
|
||||
|
||||
export class RunFailedError extends Error {
|
||||
readonly runId: string;
|
||||
readonly errors: string[];
|
||||
|
||||
constructor(runId: string, errors: string[]) {
|
||||
const firstError = errors.find(Boolean) ?? null;
|
||||
super(firstError ? `Run ${runId} failed: ${firstError}` : `Run ${runId} failed`);
|
||||
this.name = "RunFailedError";
|
||||
this.runId = runId;
|
||||
this.errors = errors;
|
||||
}
|
||||
}
|
||||
|
||||
export function getErrorDetails(error: unknown): string {
|
||||
if (error instanceof RunFailedError) {
|
||||
return error.errors.join("\n\n");
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the assistant's final text response from a run's log.
|
||||
* @param runId
|
||||
|
|
@ -28,13 +57,28 @@ export async function extractAgentResponse(runId: string): Promise<string | null
|
|||
/**
|
||||
* Wait for a run to complete by listening for run-processing-end event
|
||||
*/
|
||||
export async function waitForRunCompletion(runId: string): Promise<void> {
|
||||
return new Promise(async (resolve) => {
|
||||
const unsubscribe = await bus.subscribe('*', async (event) => {
|
||||
if (event.type === 'run-processing-end' && event.runId === runId) {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
export async function waitForRunCompletion(
|
||||
runId: string,
|
||||
opts: { throwOnError?: boolean } = {},
|
||||
): Promise<RunRecord> {
|
||||
return new Promise((resolve, reject) => {
|
||||
void (async () => {
|
||||
const unsubscribe = await bus.subscribe('*', async (event) => {
|
||||
if (event.type === 'run-processing-end' && event.runId === runId) {
|
||||
unsubscribe();
|
||||
try {
|
||||
const run = await fetchRun(runId);
|
||||
const errors = extractRunErrors(run);
|
||||
if (opts.throwOnError && errors.length > 0) {
|
||||
reject(new RunFailedError(runId, errors));
|
||||
return;
|
||||
}
|
||||
resolve(run);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
})().catch(reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
apps/x/packages/core/src/analytics/identify.ts
Normal file
23
apps/x/packages/core/src/analytics/identify.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { isSignedIn } from '../account/account.js';
|
||||
import { getBillingInfo } from '../billing/billing.js';
|
||||
import { identify } from './posthog.js';
|
||||
|
||||
/**
|
||||
* If the user has rowboat OAuth tokens, fetch their billing info and
|
||||
* call posthog.identify(). Idempotent — safe to call on every app start.
|
||||
* Catches all errors so analytics never blocks app launch.
|
||||
*/
|
||||
export async function identifyIfSignedIn(): Promise<void> {
|
||||
try {
|
||||
if (!(await isSignedIn())) return;
|
||||
const billing = await getBillingInfo();
|
||||
if (!billing.userId) return;
|
||||
identify(billing.userId, {
|
||||
...(billing.userEmail ? { email: billing.userEmail } : {}),
|
||||
plan: billing.subscriptionPlan,
|
||||
status: billing.subscriptionStatus,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Analytics] startup identify failed:', err);
|
||||
}
|
||||
}
|
||||
37
apps/x/packages/core/src/analytics/installation.ts
Normal file
37
apps/x/packages/core/src/analytics/installation.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
|
||||
const INSTALLATION_PATH = path.join(WorkDir, 'config', 'installation.json');
|
||||
|
||||
let cached: string | null = null;
|
||||
|
||||
export function getInstallationId(): string {
|
||||
if (cached) return cached;
|
||||
try {
|
||||
if (fs.existsSync(INSTALLATION_PATH)) {
|
||||
const raw = fs.readFileSync(INSTALLATION_PATH, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { installationId?: string };
|
||||
if (parsed.installationId && typeof parsed.installationId === 'string') {
|
||||
cached = parsed.installationId;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Analytics] Failed to read installation.json:', err);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
try {
|
||||
const dir = path.dirname(INSTALLATION_PATH);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(INSTALLATION_PATH, JSON.stringify({ installationId: id }, null, 2));
|
||||
} catch (err) {
|
||||
console.error('[Analytics] Failed to write installation.json:', err);
|
||||
}
|
||||
cached = id;
|
||||
return id;
|
||||
}
|
||||
90
apps/x/packages/core/src/analytics/posthog.ts
Normal file
90
apps/x/packages/core/src/analytics/posthog.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { PostHog } from 'posthog-node';
|
||||
import { getInstallationId } from './installation.js';
|
||||
import { API_URL } from '../config/env.js';
|
||||
|
||||
// Build-time injected via esbuild `define` (apps/main/bundle.mjs).
|
||||
// In dev/tsc, fall back to process.env so local runs work too.
|
||||
const POSTHOG_KEY = process.env.POSTHOG_KEY ?? process.env.VITE_PUBLIC_POSTHOG_KEY ?? '';
|
||||
const POSTHOG_HOST = process.env.POSTHOG_HOST ?? process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com';
|
||||
|
||||
let client: PostHog | null = null;
|
||||
let initAttempted = false;
|
||||
let identifiedUserId: string | null = null;
|
||||
|
||||
function getClient(): PostHog | null {
|
||||
if (initAttempted) return client;
|
||||
initAttempted = true;
|
||||
if (!POSTHOG_KEY) {
|
||||
console.log('[Analytics] POSTHOG_KEY not set; analytics disabled');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
client = new PostHog(POSTHOG_KEY, {
|
||||
host: POSTHOG_HOST,
|
||||
flushAt: 20,
|
||||
flushInterval: 10_000,
|
||||
});
|
||||
// Tag the install with api_url as a person property up-front,
|
||||
// so anonymous users are also segmentable by environment (api_url
|
||||
// distinguishes prod / staging / custom — meaning is assigned in PostHog).
|
||||
client.identify({
|
||||
distinctId: getInstallationId(),
|
||||
properties: { api_url: API_URL },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Analytics] Failed to init PostHog:', err);
|
||||
client = null;
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function activeDistinctId(): string {
|
||||
return identifiedUserId ?? getInstallationId();
|
||||
}
|
||||
|
||||
export function capture(event: string, properties?: Record<string, unknown>): void {
|
||||
const ph = getClient();
|
||||
if (!ph) return;
|
||||
try {
|
||||
ph.capture({
|
||||
distinctId: activeDistinctId(),
|
||||
event,
|
||||
properties,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Analytics] capture failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function identify(userId: string, properties?: Record<string, unknown>): void {
|
||||
const ph = getClient();
|
||||
if (!ph) return;
|
||||
try {
|
||||
// Alias the anonymous installation ID to the rowboat user ID so historical
|
||||
// anonymous events are linked to the identified user.
|
||||
ph.alias({ distinctId: userId, alias: getInstallationId() });
|
||||
ph.identify({
|
||||
distinctId: userId,
|
||||
properties: {
|
||||
...properties,
|
||||
api_url: API_URL,
|
||||
},
|
||||
});
|
||||
identifiedUserId = userId;
|
||||
} catch (err) {
|
||||
console.error('[Analytics] identify failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function reset(): void {
|
||||
identifiedUserId = null;
|
||||
}
|
||||
|
||||
export async function shutdown(): Promise<void> {
|
||||
if (!client) return;
|
||||
try {
|
||||
await client.shutdown();
|
||||
} catch (err) {
|
||||
console.error('[Analytics] shutdown failed:', err);
|
||||
}
|
||||
}
|
||||
38
apps/x/packages/core/src/analytics/usage.ts
Normal file
38
apps/x/packages/core/src/analytics/usage.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { capture } from './posthog.js';
|
||||
import type { UseCase } from './use_case.js';
|
||||
|
||||
// Shape compatible with ai-sdk v5 `LanguageModelUsage`.
|
||||
// All fields are optional because providers report subsets.
|
||||
export interface LlmUsageInput {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
cachedInputTokens?: number;
|
||||
}
|
||||
|
||||
export interface CaptureLlmUsageArgs {
|
||||
useCase: UseCase;
|
||||
subUseCase?: string;
|
||||
agentName?: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
usage: LlmUsageInput | undefined;
|
||||
}
|
||||
|
||||
export function captureLlmUsage(args: CaptureLlmUsageArgs): void {
|
||||
const usage = args.usage ?? {};
|
||||
const properties: Record<string, unknown> = {
|
||||
use_case: args.useCase,
|
||||
model: args.model,
|
||||
provider: args.provider,
|
||||
input_tokens: usage.inputTokens ?? 0,
|
||||
output_tokens: usage.outputTokens ?? 0,
|
||||
total_tokens: usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
||||
};
|
||||
if (args.subUseCase) properties.sub_use_case = args.subUseCase;
|
||||
if (args.agentName) properties.agent_name = args.agentName;
|
||||
if (usage.cachedInputTokens != null) properties.cached_input_tokens = usage.cachedInputTokens;
|
||||
if (usage.reasoningTokens != null) properties.reasoning_tokens = usage.reasoningTokens;
|
||||
capture('llm_usage', properties);
|
||||
}
|
||||
28
apps/x/packages/core/src/analytics/use_case.ts
Normal file
28
apps/x/packages/core/src/analytics/use_case.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
export type UseCase = 'copilot_chat' | 'track_block' | 'meeting_note' | 'knowledge_sync';
|
||||
|
||||
export interface UseCaseContext {
|
||||
useCase: UseCase;
|
||||
subUseCase?: string;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
const storage = new AsyncLocalStorage<UseCaseContext>();
|
||||
|
||||
export function withUseCase<T>(ctx: UseCaseContext, fn: () => T): T {
|
||||
return storage.run(ctx, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently install a use-case context for the current async chain.
|
||||
* Use inside generator functions where wrapping with `withUseCase()` doesn't
|
||||
* compose. Child async work (e.g. tool execution) will inherit it.
|
||||
*/
|
||||
export function enterUseCase(ctx: UseCaseContext): void {
|
||||
storage.enterWith(ctx);
|
||||
}
|
||||
|
||||
export function getCurrentUseCase(): UseCaseContext | undefined {
|
||||
return storage.getStore();
|
||||
}
|
||||
|
|
@ -85,6 +85,8 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting,
|
|||
**Tracks (Auto-Updating Note Blocks):** When users ask you to **track**, **monitor**, **watch**, or **keep an eye on** something in a note — or say things like "every morning tell me X", "show the current Y in this note", "pin live updates of Z here" — load the \`tracks\` skill first. Also load it when a user presses Cmd+K with a note open and requests auto-refreshing content at the cursor. Track blocks are YAML-fenced scheduled blocks whose output is rewritten on each run — useful for weather, news, prices, status pages, and personal dashboards.
|
||||
**Browser Control:** When users ask you to open a website, browse in-app, search the web in the embedded browser, or interact with a live webpage inside Rowboat, load the \`browser-control\` skill first. It explains the \`read-page -> indexed action -> refreshed page\` workflow for the browser pane.
|
||||
|
||||
**Notifications:** When you need to send a desktop notification — completion alert after a long task, time-sensitive update, or a clickable result that lands the user on a specific note/view — load the \`notify-user\` skill first. It documents the \`notify-user\` tool and the \`rowboat://\` deep links you can attach to it.
|
||||
|
||||
|
||||
## Learning About the User (save-to-memory)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,15 @@ export interface RuntimeContext {
|
|||
}
|
||||
|
||||
export function getExecutionShell(platform: NodeJS.Platform = process.platform): string {
|
||||
return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh';
|
||||
if (platform === 'win32') {
|
||||
return process.env.ComSpec || 'cmd.exe';
|
||||
}
|
||||
|
||||
if (process.env.SHELL) {
|
||||
return process.env.SHELL;
|
||||
}
|
||||
|
||||
return platform === 'darwin' ? '/bin/zsh' : '/bin/sh';
|
||||
}
|
||||
|
||||
export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext {
|
||||
|
|
|
|||
|
|
@ -1,555 +0,0 @@
|
|||
export const skill = String.raw`
|
||||
# Background Agents
|
||||
|
||||
Load this skill whenever a user wants to inspect, create, edit, or schedule background agents inside the Rowboat workspace.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
**IMPORTANT**: In the CLI, there are NO separate "workflow" files. Everything is an agent.
|
||||
|
||||
- **All definitions live in ` + "`agents/*.md`" + `** - Markdown files with YAML frontmatter
|
||||
- Agents configure a model, tools (in frontmatter), and instructions (in the body)
|
||||
- Tools can be: builtin (like ` + "`executeCommand`" + `), MCP integrations, or **other agents**
|
||||
- **"Workflows" are just agents that orchestrate other agents** by having them as tools
|
||||
- **Background agents run on schedules** defined in ` + "`config/agent-schedule.json`" + ` within the workspace root
|
||||
|
||||
## How multi-agent workflows work
|
||||
|
||||
1. **Create an orchestrator agent** that has other agents in its ` + "`tools`" + `
|
||||
2. **Schedule the orchestrator** in agent-schedule.json (see Scheduling section below)
|
||||
3. The orchestrator calls other agents as tools when needed
|
||||
4. Data flows through tool call parameters and responses
|
||||
|
||||
## Scheduling Background Agents
|
||||
|
||||
Background agents run automatically based on schedules defined in ` + "`config/agent-schedule.json`" + ` in the workspace root.
|
||||
|
||||
### Schedule Configuration File
|
||||
|
||||
` + "```json" + `
|
||||
{
|
||||
"agents": {
|
||||
"agent_name": {
|
||||
"schedule": { ... },
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
### Schedule Types
|
||||
|
||||
**IMPORTANT: All times are in local time** (the timezone of the machine running Rowboat).
|
||||
|
||||
**1. Cron Schedule** - Runs at exact times defined by cron expression
|
||||
` + "```json" + `
|
||||
{
|
||||
"schedule": {
|
||||
"type": "cron",
|
||||
"expression": "0 8 * * *"
|
||||
},
|
||||
"enabled": true
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
Common cron expressions:
|
||||
- ` + "`*/5 * * * *`" + ` - Every 5 minutes
|
||||
- ` + "`0 8 * * *`" + ` - Every day at 8am
|
||||
- ` + "`0 9 * * 1`" + ` - Every Monday at 9am
|
||||
- ` + "`0 0 1 * *`" + ` - First day of every month at midnight
|
||||
|
||||
**2. Window Schedule** - Runs once during a time window
|
||||
` + "```json" + `
|
||||
{
|
||||
"schedule": {
|
||||
"type": "window",
|
||||
"cron": "0 0 * * *",
|
||||
"startTime": "08:00",
|
||||
"endTime": "10:00"
|
||||
},
|
||||
"enabled": true
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
The agent will run once at a random time within the window. Use this when you want flexibility (e.g., "sometime in the morning" rather than "exactly at 8am").
|
||||
|
||||
**3. Once Schedule** - Runs exactly once at a specific time
|
||||
` + "```json" + `
|
||||
{
|
||||
"schedule": {
|
||||
"type": "once",
|
||||
"runAt": "2024-02-05T10:30:00"
|
||||
},
|
||||
"enabled": true
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
Use this for one-time tasks like migrations or setup scripts. The ` + "`runAt`" + ` is in local time (no Z suffix).
|
||||
|
||||
### Starting Message
|
||||
|
||||
You can specify a ` + "`startingMessage`" + ` that gets sent to the agent when it starts. If not provided, defaults to ` + "`\"go\"`" + `.
|
||||
|
||||
` + "```json" + `
|
||||
{
|
||||
"schedule": { "type": "cron", "expression": "0 8 * * *" },
|
||||
"enabled": true,
|
||||
"startingMessage": "Please summarize my emails from the last 24 hours"
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
### Description
|
||||
|
||||
You can add a ` + "`description`" + ` field to describe what the agent does. This is displayed in the UI.
|
||||
|
||||
` + "```json" + `
|
||||
{
|
||||
"schedule": { "type": "cron", "expression": "0 8 * * *" },
|
||||
"enabled": true,
|
||||
"description": "Summarizes emails and calendar events every morning"
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
### Complete Schedule Example
|
||||
|
||||
` + "```json" + `
|
||||
{
|
||||
"agents": {
|
||||
"daily_digest": {
|
||||
"schedule": {
|
||||
"type": "cron",
|
||||
"expression": "0 8 * * *"
|
||||
},
|
||||
"enabled": true,
|
||||
"description": "Daily email and calendar summary",
|
||||
"startingMessage": "Summarize my emails and calendar for today"
|
||||
},
|
||||
"morning_briefing": {
|
||||
"schedule": {
|
||||
"type": "window",
|
||||
"cron": "0 0 * * *",
|
||||
"startTime": "07:00",
|
||||
"endTime": "09:00"
|
||||
},
|
||||
"enabled": true,
|
||||
"description": "Morning news and updates briefing"
|
||||
},
|
||||
"one_time_setup": {
|
||||
"schedule": {
|
||||
"type": "once",
|
||||
"runAt": "2024-12-01T12:00:00"
|
||||
},
|
||||
"enabled": true,
|
||||
"description": "One-time data migration task"
|
||||
}
|
||||
}
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
### Schedule State (Read-Only)
|
||||
|
||||
**IMPORTANT: Do NOT modify ` + "`agent-schedule-state.json`" + `** - it is managed automatically by the background runner.
|
||||
|
||||
The runner automatically tracks execution state in ` + "`config/agent-schedule-state.json`" + ` in the workspace root:
|
||||
- ` + "`status`" + `: scheduled, running, finished, failed, triggered (for once-schedules)
|
||||
- ` + "`lastRunAt`" + `: When the agent last ran
|
||||
- ` + "`nextRunAt`" + `: When the agent will run next
|
||||
- ` + "`lastError`" + `: Error message if the last run failed
|
||||
- ` + "`runCount`" + `: Total number of runs
|
||||
|
||||
When you add an agent to ` + "`agent-schedule.json`" + `, the runner will automatically create and manage its state entry. You only need to edit ` + "`agent-schedule.json`" + `.
|
||||
|
||||
## Agent File Format
|
||||
|
||||
Agent files are **Markdown files with YAML frontmatter**. The frontmatter contains configuration (model, tools), and the body contains the instructions.
|
||||
|
||||
### Basic Structure
|
||||
` + "```markdown" + `
|
||||
---
|
||||
model: gpt-5.1
|
||||
tools:
|
||||
tool_key:
|
||||
type: builtin
|
||||
name: tool_name
|
||||
---
|
||||
# Instructions
|
||||
|
||||
Your detailed instructions go here in Markdown format.
|
||||
` + "```" + `
|
||||
|
||||
### Frontmatter Fields
|
||||
- ` + "`model`" + `: (OPTIONAL) Model to use (e.g., 'gpt-5.1', 'claude-sonnet-4-5')
|
||||
- ` + "`provider`" + `: (OPTIONAL) Provider alias from models.json
|
||||
- ` + "`tools`" + `: (OPTIONAL) Object containing tool definitions
|
||||
|
||||
### Instructions (Body)
|
||||
The Markdown body after the frontmatter contains the agent's instructions. Use standard Markdown formatting.
|
||||
|
||||
### Naming Rules
|
||||
- Agent filename determines the agent name (without .md extension)
|
||||
- Example: ` + "`summariser_agent.md`" + ` creates an agent named "summariser_agent"
|
||||
- Use lowercase with underscores for multi-word names
|
||||
- No spaces or special characters in names
|
||||
- **The agent name in agent-schedule.json must match the filename** (without .md)
|
||||
|
||||
### Agent Format Example
|
||||
` + "```markdown" + `
|
||||
---
|
||||
model: gpt-5.1
|
||||
tools:
|
||||
search:
|
||||
type: mcp
|
||||
name: firecrawl_search
|
||||
description: Search the web
|
||||
mcpServerName: firecrawl
|
||||
inputSchema:
|
||||
type: object
|
||||
properties:
|
||||
query:
|
||||
type: string
|
||||
description: Search query
|
||||
required:
|
||||
- query
|
||||
---
|
||||
# Web Search Agent
|
||||
|
||||
You are a web search agent. When asked a question:
|
||||
|
||||
1. Use the search tool to find relevant information
|
||||
2. Summarize the results clearly
|
||||
3. Cite your sources
|
||||
|
||||
Be concise and accurate.
|
||||
` + "```" + `
|
||||
|
||||
## Tool Types & Schemas
|
||||
|
||||
Tools in agents must follow one of three types. Each has specific required fields.
|
||||
|
||||
### 1. Builtin Tools
|
||||
Internal Rowboat tools (executeCommand, file operations, MCP queries, etc.)
|
||||
|
||||
**YAML Schema:**
|
||||
` + "```yaml" + `
|
||||
tool_key:
|
||||
type: builtin
|
||||
name: tool_name
|
||||
` + "```" + `
|
||||
|
||||
**Required fields:**
|
||||
- ` + "`type`" + `: Must be "builtin"
|
||||
- ` + "`name`" + `: Builtin tool name (e.g., "executeCommand", "workspace-readFile")
|
||||
|
||||
**Example:**
|
||||
` + "```yaml" + `
|
||||
bash:
|
||||
type: builtin
|
||||
name: executeCommand
|
||||
` + "```" + `
|
||||
|
||||
**Available builtin tools:**
|
||||
- ` + "`executeCommand`" + ` - Execute shell commands
|
||||
- ` + "`workspace-readFile`" + `, ` + "`workspace-writeFile`" + `, ` + "`workspace-remove`" + ` - File operations
|
||||
- ` + "`workspace-readdir`" + `, ` + "`workspace-exists`" + `, ` + "`workspace-stat`" + ` - Directory operations
|
||||
- ` + "`workspace-mkdir`" + `, ` + "`workspace-rename`" + `, ` + "`workspace-copy`" + ` - File/directory management
|
||||
- ` + "`analyzeAgent`" + ` - Analyze agent structure
|
||||
- ` + "`addMcpServer`" + `, ` + "`listMcpServers`" + `, ` + "`listMcpTools`" + ` - MCP management
|
||||
- ` + "`loadSkill`" + ` - Load skill guidance
|
||||
|
||||
### 2. MCP Tools
|
||||
Tools from external MCP servers (APIs, databases, web scraping, etc.)
|
||||
|
||||
**YAML Schema:**
|
||||
` + "```yaml" + `
|
||||
tool_key:
|
||||
type: mcp
|
||||
name: tool_name_from_server
|
||||
description: What the tool does
|
||||
mcpServerName: server_name_from_config
|
||||
inputSchema:
|
||||
type: object
|
||||
properties:
|
||||
param:
|
||||
type: string
|
||||
description: Parameter description
|
||||
required:
|
||||
- param
|
||||
` + "```" + `
|
||||
|
||||
**Required fields:**
|
||||
- ` + "`type`" + `: Must be "mcp"
|
||||
- ` + "`name`" + `: Exact tool name from MCP server
|
||||
- ` + "`description`" + `: What the tool does (helps agent understand when to use it)
|
||||
- ` + "`mcpServerName`" + `: Server name from config/mcp.json
|
||||
- ` + "`inputSchema`" + `: Full JSON Schema object for tool parameters
|
||||
|
||||
**Example:**
|
||||
` + "```yaml" + `
|
||||
search:
|
||||
type: mcp
|
||||
name: firecrawl_search
|
||||
description: Search the web
|
||||
mcpServerName: firecrawl
|
||||
inputSchema:
|
||||
type: object
|
||||
properties:
|
||||
query:
|
||||
type: string
|
||||
description: Search query
|
||||
required:
|
||||
- query
|
||||
` + "```" + `
|
||||
|
||||
**Important:**
|
||||
- Use ` + "`listMcpTools`" + ` to get the exact inputSchema from the server
|
||||
- Copy the schema exactly—don't modify property types or structure
|
||||
- Only include ` + "`required`" + ` array if parameters are mandatory
|
||||
|
||||
### 3. Agent Tools (for chaining agents)
|
||||
Reference other agents as tools to build multi-agent workflows
|
||||
|
||||
**YAML Schema:**
|
||||
` + "```yaml" + `
|
||||
tool_key:
|
||||
type: agent
|
||||
name: target_agent_name
|
||||
` + "```" + `
|
||||
|
||||
**Required fields:**
|
||||
- ` + "`type`" + `: Must be "agent"
|
||||
- ` + "`name`" + `: Name of the target agent (must exist in agents/ directory)
|
||||
|
||||
**Example:**
|
||||
` + "```yaml" + `
|
||||
summariser:
|
||||
type: agent
|
||||
name: summariser_agent
|
||||
` + "```" + `
|
||||
|
||||
**How it works:**
|
||||
- Use ` + "`type: agent`" + ` to call other agents as tools
|
||||
- The target agent will be invoked with the parameters you pass
|
||||
- Results are returned as tool output
|
||||
- This is how you build multi-agent workflows
|
||||
- The referenced agent file must exist (e.g., ` + "`agents/summariser_agent.md`" + `)
|
||||
|
||||
## Complete Multi-Agent Workflow Example
|
||||
|
||||
**Email digest workflow** - This is all done through agents calling other agents:
|
||||
|
||||
**1. Task-specific agent** (` + "`agents/email_reader.md`" + `):
|
||||
` + "```markdown" + `
|
||||
---
|
||||
model: gpt-5.1
|
||||
tools:
|
||||
read_file:
|
||||
type: builtin
|
||||
name: workspace-readFile
|
||||
list_dir:
|
||||
type: builtin
|
||||
name: workspace-readdir
|
||||
---
|
||||
# Email Reader Agent
|
||||
|
||||
Read emails from the gmail_sync folder and extract key information.
|
||||
Look for unread or recent emails and summarize the sender, subject, and key points.
|
||||
Don't ask for human input.
|
||||
` + "```" + `
|
||||
|
||||
**2. Agent that delegates to other agents** (` + "`agents/daily_summary.md`" + `):
|
||||
` + "```markdown" + `
|
||||
---
|
||||
model: gpt-5.1
|
||||
tools:
|
||||
email_reader:
|
||||
type: agent
|
||||
name: email_reader
|
||||
write_file:
|
||||
type: builtin
|
||||
name: workspace-writeFile
|
||||
---
|
||||
# Daily Summary Agent
|
||||
|
||||
1. Use the email_reader tool to get email summaries
|
||||
2. Create a consolidated daily digest
|
||||
3. Save the digest to ~/Desktop/daily_digest.md
|
||||
|
||||
Don't ask for human input.
|
||||
` + "```" + `
|
||||
|
||||
Note: The output path (` + "`~/Desktop/daily_digest.md`" + `) is hardcoded in the instructions. When creating agents that output files, always ask the user where they want files saved and include the full path in the agent instructions.
|
||||
|
||||
**3. Orchestrator agent** (` + "`agents/morning_briefing.md`" + `):
|
||||
` + "```markdown" + `
|
||||
---
|
||||
model: gpt-5.1
|
||||
tools:
|
||||
daily_summary:
|
||||
type: agent
|
||||
name: daily_summary
|
||||
search:
|
||||
type: mcp
|
||||
name: search
|
||||
mcpServerName: exa
|
||||
description: Search the web for news
|
||||
inputSchema:
|
||||
type: object
|
||||
properties:
|
||||
query:
|
||||
type: string
|
||||
description: Search query
|
||||
---
|
||||
# Morning Briefing Workflow
|
||||
|
||||
Create a morning briefing:
|
||||
|
||||
1. Get email digest using daily_summary
|
||||
2. Search for relevant news using the search tool
|
||||
3. Compile a comprehensive morning briefing
|
||||
|
||||
Execute these steps in sequence. Don't ask for human input.
|
||||
` + "```" + `
|
||||
|
||||
**4. Schedule the workflow** in ` + "`config/agent-schedule.json`" + `:
|
||||
` + "```json" + `
|
||||
{
|
||||
"agents": {
|
||||
"morning_briefing": {
|
||||
"schedule": {
|
||||
"type": "cron",
|
||||
"expression": "0 7 * * *"
|
||||
},
|
||||
"enabled": true,
|
||||
"startingMessage": "Create my morning briefing for today"
|
||||
}
|
||||
}
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
This schedules the morning briefing workflow to run every day at 7am local time.
|
||||
|
||||
## Naming and organization rules
|
||||
- **All agents live in ` + "`agents/*.md`" + `** - Markdown files with YAML frontmatter
|
||||
- Agent filename (without .md) becomes the agent name
|
||||
- When referencing an agent as a tool, use its filename without extension
|
||||
- When scheduling an agent, use its filename without extension in agent-schedule.json
|
||||
- Use relative paths (no \${BASE_DIR} prefixes) when giving examples to users
|
||||
|
||||
## Best practices for background agents
|
||||
1. **Single responsibility**: Each agent should do one specific thing well
|
||||
2. **Clear delegation**: Agent instructions should explicitly say when to call other agents
|
||||
3. **Autonomous operation**: Add "Don't ask for human input" for background agents
|
||||
4. **Data passing**: Make it clear what data to extract and pass between agents
|
||||
5. **Tool naming**: Use descriptive tool keys (e.g., "summariser", "fetch_data", "analyze")
|
||||
6. **Orchestration**: Create a top-level agent that coordinates the workflow
|
||||
7. **Scheduling**: Use appropriate schedule types - cron for recurring, window for flexible timing, once for one-time tasks
|
||||
8. **Error handling**: Background agents should handle errors gracefully since there's no human to intervene
|
||||
9. **Avoid executeCommand**: Do NOT attach ` + "`executeCommand`" + ` to background agents as it poses security risks when running unattended. Instead, use the specific builtin tools needed (` + "`workspace-readFile`" + `, ` + "`workspace-writeFile`" + `, etc.) or MCP tools for external integrations
|
||||
10. **File output paths**: When creating an agent that outputs files, ASK the user where the file should be stored (default to Desktop: ` + "`~/Desktop`" + `). Then hardcode the full output path in the agent's instructions so it knows exactly where to write files. Example instruction: "Save the output to /Users/username/Desktop/daily_report.md"
|
||||
|
||||
## Validation & Best Practices
|
||||
|
||||
### CRITICAL: Schema Compliance
|
||||
- Agent files MUST be valid Markdown with YAML frontmatter
|
||||
- Agent filename (without .md) becomes the agent name
|
||||
- Tools in frontmatter MUST have valid ` + "`type`" + ` ("builtin", "mcp", or "agent")
|
||||
- MCP tools MUST have all required fields: name, description, mcpServerName, inputSchema
|
||||
- Agent tools MUST reference existing agent files
|
||||
- Invalid agents will fail to load and prevent workflow execution
|
||||
|
||||
### File Creation/Update Process
|
||||
1. When creating an agent, use ` + "`workspace-writeFile`" + ` with valid Markdown + YAML frontmatter
|
||||
2. When updating an agent, read it first with ` + "`workspace-readFile`" + `, modify, then use ` + "`workspace-writeFile`" + `
|
||||
3. Validate YAML syntax in frontmatter before writing—malformed YAML breaks the agent
|
||||
4. **Quote strings containing colons** (e.g., ` + "`description: \"Default: 8\"`" + ` not ` + "`description: Default: 8`" + `)
|
||||
5. Test agent loading after creation/update by using ` + "`analyzeAgent`" + `
|
||||
|
||||
### Common Validation Errors to Avoid
|
||||
|
||||
❌ **WRONG - Missing frontmatter delimiters:**
|
||||
` + "```markdown" + `
|
||||
model: gpt-5.1
|
||||
# My Agent
|
||||
Instructions here
|
||||
` + "```" + `
|
||||
|
||||
❌ **WRONG - Invalid YAML indentation:**
|
||||
` + "```markdown" + `
|
||||
---
|
||||
tools:
|
||||
bash:
|
||||
type: builtin
|
||||
---
|
||||
` + "```" + `
|
||||
(bash should be indented under tools)
|
||||
|
||||
❌ **WRONG - Invalid tool type:**
|
||||
` + "```yaml" + `
|
||||
tools:
|
||||
tool1:
|
||||
type: custom
|
||||
name: something
|
||||
` + "```" + `
|
||||
(type must be builtin, mcp, or agent)
|
||||
|
||||
❌ **WRONG - Unquoted strings containing colons:**
|
||||
` + "```yaml" + `
|
||||
tools:
|
||||
search:
|
||||
description: Number of results (default: 8)
|
||||
` + "```" + `
|
||||
(Strings with colons must be quoted: ` + "`description: \"Number of results (default: 8)\"`" + `)
|
||||
|
||||
❌ **WRONG - MCP tool missing required fields:**
|
||||
` + "```yaml" + `
|
||||
tools:
|
||||
search:
|
||||
type: mcp
|
||||
name: firecrawl_search
|
||||
` + "```" + `
|
||||
(Missing: description, mcpServerName, inputSchema)
|
||||
|
||||
✅ **CORRECT - Minimal valid agent** (` + "`agents/simple_agent.md`" + `):
|
||||
` + "```markdown" + `
|
||||
---
|
||||
model: gpt-5.1
|
||||
---
|
||||
# Simple Agent
|
||||
|
||||
Do simple tasks as instructed.
|
||||
` + "```" + `
|
||||
|
||||
✅ **CORRECT - Agent with MCP tool** (` + "`agents/search_agent.md`" + `):
|
||||
` + "```markdown" + `
|
||||
---
|
||||
model: gpt-5.1
|
||||
tools:
|
||||
search:
|
||||
type: mcp
|
||||
name: firecrawl_search
|
||||
description: Search the web
|
||||
mcpServerName: firecrawl
|
||||
inputSchema:
|
||||
type: object
|
||||
properties:
|
||||
query:
|
||||
type: string
|
||||
---
|
||||
# Search Agent
|
||||
|
||||
Use the search tool to find information on the web.
|
||||
` + "```" + `
|
||||
|
||||
## Capabilities checklist
|
||||
1. Explore ` + "`agents/`" + ` directory to understand existing agents before editing
|
||||
2. Read existing agents with ` + "`workspace-readFile`" + ` before making changes
|
||||
3. Validate YAML frontmatter syntax before creating/updating agents
|
||||
4. Use ` + "`analyzeAgent`" + ` to verify agent structure after creation/update
|
||||
5. When creating multi-agent workflows, create an orchestrator agent
|
||||
6. Add other agents as tools with ` + "`type: agent`" + ` for chaining
|
||||
7. Use ` + "`listMcpServers`" + ` and ` + "`listMcpTools`" + ` when adding MCP integrations
|
||||
8. Configure schedules in ` + "`config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file)
|
||||
9. Confirm work done and outline next steps once changes are complete
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -14,8 +14,10 @@ Use this skill when the user asks you to open a website, browse in-app, search t
|
|||
- page ` + "`url`" + ` and ` + "`title`" + `
|
||||
- visible page text
|
||||
- interactable elements with numbered ` + "`index`" + ` values
|
||||
4. Prefer acting on those numbered indices with ` + "`click`" + ` / ` + "`type`" + ` / ` + "`press`" + `.
|
||||
5. After each action, read the returned page snapshot before deciding the next step.
|
||||
- ` + "`suggestedSkills`" + ` — site-specific and interaction-specific skill hints for the current page
|
||||
4. **Always inspect ` + "`suggestedSkills`" + ` before acting.** If any skill in the list matches what the user asked for (site or task), call ` + "`load-browser-skill({ id: \"<id>\" })`" + ` *first*, read it in full, then plan your actions. These skills encode selectors, timing, and gotchas that would otherwise cost you several failed attempts to rediscover. If no skill matches, proceed — but do not skip this check.
|
||||
5. Prefer acting on those numbered indices with ` + "`click`" + ` / ` + "`type`" + ` / ` + "`press`" + `.
|
||||
6. After each action, read the returned page snapshot before deciding the next step — including re-checking ` + "`suggestedSkills`" + ` if the navigation landed you on a new domain.
|
||||
|
||||
## Actions
|
||||
|
||||
|
|
@ -92,12 +94,23 @@ Wait for the page to settle, useful after async UI changes.
|
|||
Parameters:
|
||||
- ` + "`ms`" + `: milliseconds to wait (optional)
|
||||
|
||||
## Companion Tools
|
||||
|
||||
### load-browser-skill
|
||||
Rowboat caches a library of browser skills (from ` + "`browser-use/browser-harness`" + `) indexed by both **domain** (github, linkedin, amazon, booking, …) and **interaction type** within a domain (e.g. ` + "`github/repo-actions`" + `, ` + "`github/scraping`" + `, ` + "`arxiv-bulk/*`" + `). Whenever ` + "`browser-control`" + ` returns a ` + "`suggestedSkills`" + ` array — which it does on ` + "`navigate`" + `, ` + "`new-tab`" + `, and ` + "`read-page`" + ` — treat it as a required reading step, not optional. Pick the entry that matches the current task (domain match first, then the interaction-specific variant if one exists) and call ` + "`load-browser-skill({ id: \"<id>\" })`" + ` before attempting the action.
|
||||
|
||||
You can also proactively call ` + "`load-browser-skill({ action: \"list\", site: \"<site>\" })`" + ` when you know you're about to work on a site, to see what skills exist even if ` + "`suggestedSkills`" + ` is empty (e.g. before navigating).
|
||||
|
||||
These skills are written against a Python harness, so treat them as **reference knowledge**. Reuse the selectors, timing, and sequencing, but adapt them to Rowboat's structured browser actions. **Do not look for or call ` + "`http-fetch`" + `.** If a browser-harness recipe suggests ` + "`js(...)`" + ` or ` + "`http_get(...)`" + ` style shortcuts, treat those as non-portable and fall back to reading and interacting with the page itself.
|
||||
|
||||
## Important Rules
|
||||
|
||||
- Prefer ` + "`read-page`" + ` before interacting.
|
||||
- Prefer element ` + "`index`" + ` over CSS selectors.
|
||||
- If the tool says the snapshot is stale, call ` + "`read-page`" + ` again.
|
||||
- After navigation, clicking, typing, pressing, or scrolling, use the returned page snapshot instead of assuming the page state.
|
||||
- **Always check ` + "`suggestedSkills`" + ` after ` + "`navigate`" + `, ` + "`new-tab`" + `, or ` + "`read-page`" + `, and load the matching domain or interaction skill before acting.** Skipping this step is the single most common way to waste a dozen failed clicks on a site whose quirks are already documented. If the array is empty, proceed normally — but don't skip the check.
|
||||
- Do not try to use ` + "`http-fetch`" + `. If a browser-harness recipe mentions ` + "`http_get(...)`" + ` or a public API shortcut, adapt it to DOM-based browsing instead.
|
||||
- Use Rowboat's browser for live interaction. Use web search tools for research where a live session is unnecessary.
|
||||
- Do not wrap browser URLs or browser pages in ` + "```filepath" + ` blocks. Filepath cards are only for real files on disk, not web pages or browser tabs.
|
||||
- If you mention a page the browser opened, use plain text for the URL/title instead of trying to create a clickable file card.
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ import draftEmailsSkill from "./draft-emails/skill.js";
|
|||
import mcpIntegrationSkill from "./mcp-integration/skill.js";
|
||||
import meetingPrepSkill from "./meeting-prep/skill.js";
|
||||
import organizeFilesSkill from "./organize-files/skill.js";
|
||||
import backgroundAgentsSkill from "./background-agents/skill.js";
|
||||
import createPresentationsSkill from "./create-presentations/skill.js";
|
||||
|
||||
import appNavigationSkill from "./app-navigation/skill.js";
|
||||
import browserControlSkill from "./browser-control/skill.js";
|
||||
import composioIntegrationSkill from "./composio-integration/skill.js";
|
||||
import tracksSkill from "./tracks/skill.js";
|
||||
import notifyUserSkill from "./notify-user/skill.js";
|
||||
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_PREFIX = "src/application/assistant/skills";
|
||||
|
|
@ -64,12 +64,6 @@ const definitions: SkillDefinition[] = [
|
|||
summary: "Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.",
|
||||
content: organizeFilesSkill,
|
||||
},
|
||||
{
|
||||
id: "background-agents",
|
||||
title: "Background Agents",
|
||||
summary: "Creating, editing, and scheduling background agents. Configure schedules in agent-schedule.json and build multi-agent workflows.",
|
||||
content: backgroundAgentsSkill,
|
||||
},
|
||||
{
|
||||
id: "builtin-tools",
|
||||
title: "Builtin Tools Reference",
|
||||
|
|
@ -112,6 +106,12 @@ const definitions: SkillDefinition[] = [
|
|||
summary: "Control the embedded browser pane - open sites, inspect page state, and interact with indexed page elements.",
|
||||
content: browserControlSkill,
|
||||
},
|
||||
{
|
||||
id: "notify-user",
|
||||
title: "Notify User",
|
||||
summary: "Send native desktop notifications with optional clickable links — including rowboat:// deep links that open a specific note, chat, or view inside the app.",
|
||||
content: notifyUserSkill,
|
||||
},
|
||||
];
|
||||
|
||||
const skillEntries = definitions.map((definition) => ({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
export const skill = String.raw`
|
||||
# Notify User
|
||||
|
||||
Load this skill when you need to send a desktop notification to the user — e.g. after a long-running task completes, when a track block detects something noteworthy, or when an agent wants to ping the user with a clickable result.
|
||||
|
||||
## When to use
|
||||
- **Use it for**: completion alerts, threshold breaches, status changes, new items the user asked you to watch for, anything time-sensitive.
|
||||
- **Don't use it for**: routine progress updates, anything the user can already see in the chat, or repeated pings inside a loop (there is no built-in rate limit — restraint is on you).
|
||||
|
||||
## The tool: \`notify-user\`
|
||||
|
||||
Triggers a native macOS notification. The call returns immediately; it does not block waiting for the user to click.
|
||||
|
||||
### Parameters
|
||||
- **\`title\`** (optional, defaults to \`"Rowboat"\`) — bold headline at the top.
|
||||
- **\`message\`** (required) — body text. Keep it short — macOS truncates after a couple of lines.
|
||||
- **\`link\`** (optional) — URL to open when the user clicks the notification. Two kinds accepted:
|
||||
- **\`https://...\` / \`http://...\`** — opens in the default browser
|
||||
- **\`rowboat://...\`** — opens a view inside Rowboat (see deep links below)
|
||||
- If omitted, clicking the notification focuses the Rowboat app.
|
||||
|
||||
### Examples
|
||||
|
||||
Plain alert (no link — clicking focuses the app):
|
||||
\`\`\`json
|
||||
{
|
||||
"title": "Backup complete",
|
||||
"message": "All 142 files synced to iCloud."
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
External link:
|
||||
\`\`\`json
|
||||
{
|
||||
"title": "New email from Monica",
|
||||
"message": "Re: Q4 planning — needs your input by Friday",
|
||||
"link": "https://mail.google.com/mail/u/0/#inbox/abc123"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Deep link into a Rowboat note:
|
||||
\`\`\`json
|
||||
{
|
||||
"message": "Daily brief is ready",
|
||||
"link": "rowboat://open?type=file&path=knowledge/Daily/2026-04-25.md"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Deep links: \`rowboat://\`
|
||||
|
||||
Use these as the \`link\` parameter to land the user on a specific view in Rowboat instead of an external site. URL-encode paths/names that contain spaces or special characters.
|
||||
|
||||
| Target | Format | Example |
|
||||
|---|---|---|
|
||||
| Open a file | \`rowboat://open?type=file&path=<workspace-relative path>\` | \`rowboat://open?type=file&path=knowledge/People/Acme.md\` |
|
||||
| Open chat | \`rowboat://open?type=chat\` (optional \`&runId=<id>\`) | \`rowboat://open?type=chat&runId=abc123\` |
|
||||
| Knowledge graph | \`rowboat://open?type=graph\` | — |
|
||||
| Background task view | \`rowboat://open?type=task&name=<task-name>\` | \`rowboat://open?type=task&name=daily-brief\` |
|
||||
| Suggested topics | \`rowboat://open?type=suggested-topics\` | — |
|
||||
|
||||
The \`type=file\` path is workspace-relative (the same path you'd pass to \`workspace-readFile\`).
|
||||
|
||||
## Anti-patterns
|
||||
- **Don't notify per step** of a multi-step task. Notify on completion, not on progress.
|
||||
- **Don't repeat what's already on screen.** If the result is already in the chat or in a track block the user is viewing, skip the notification.
|
||||
- **Don't dump the result into \`message\`.** Surface the headline; put the detail behind a deep link or external link.
|
||||
- **Don't notify silently-failing things either.** If something failed, say so in the message — don't swallow the failure into a generic "done".
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -349,6 +349,20 @@ In that flow:
|
|||
6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note.
|
||||
7. If the target folder is one of the structured knowledge folders (` + "`" + `knowledge/People/` + "`" + `, ` + "`" + `knowledge/Organizations/` + "`" + `, ` + "`" + `knowledge/Projects/` + "`" + `, ` + "`" + `knowledge/Topics/` + "`" + `), mirror the local note style by quickly checking a nearby note or config before writing if needed.
|
||||
|
||||
### Background agent setup flow
|
||||
|
||||
Sometimes the user arrives from the Background agents panel and wants help creating a new background agent without naming a note yet.
|
||||
|
||||
In this flow, treat "background agent" and "track block" as the same feature. The user-facing term can stay "background agent", but the implementation is a track block inside a note. Do **not** claim these are different systems, and do **not** redirect the user toward standalone agent files or ` + "`" + `agent-schedule.json` + "`" + ` unless they explicitly ask for that architecture.
|
||||
|
||||
In that flow:
|
||||
1. On the first turn, **do not create or modify anything yet**. Briefly explain what you can set up, say you will put it in ` + "`" + `knowledge/Tasks/` + "`" + ` by default, and ask what it should monitor plus how often it should run.
|
||||
2. **Do not** ask the user where the results should live unless they explicitly said they want a different folder or there is a real ambiguity you cannot resolve.
|
||||
3. If the user clearly confirms later, treat ` + "`" + `knowledge/Tasks/` + "`" + ` as the default target folder.
|
||||
4. Before creating a new note there, search ` + "`" + `knowledge/Tasks/` + "`" + ` for an existing matching note and update it if one already exists.
|
||||
5. If ` + "`" + `knowledge/Tasks/` + "`" + ` does not exist, create it as part of setup instead of bouncing back to ask.
|
||||
6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note.
|
||||
|
||||
## The Exact Text to Insert
|
||||
|
||||
Write it verbatim like this (including the blank line between fence and target):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
export { ensureLoaded, readSkillContent, refreshFromRemote } from './loader.js';
|
||||
export type { SkillEntry, SkillsIndex, LoaderStatus } from './loader.js';
|
||||
export { matchSkillsForUrl } from './matcher.js';
|
||||
215
apps/x/packages/core/src/application/browser-skills/loader.ts
Normal file
215
apps/x/packages/core/src/application/browser-skills/loader.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
|
||||
const REPO_OWNER = 'browser-use';
|
||||
const REPO_NAME = 'browser-harness';
|
||||
const REPO_BRANCH = 'main';
|
||||
const DOMAIN_SKILLS_PREFIX = 'domain-skills/';
|
||||
|
||||
const MANIFEST_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const FETCH_TIMEOUT_MS = 20_000;
|
||||
|
||||
export type SkillEntry = {
|
||||
id: string; // e.g. "github/repo-actions"
|
||||
site: string; // e.g. "github"
|
||||
fileName: string; // e.g. "repo-actions.md"
|
||||
title: string; // first H1 from the markdown, or a derived title
|
||||
path: string; // relative repo path, e.g. "domain-skills/github/repo-actions.md"
|
||||
localPath: string; // absolute path on disk
|
||||
};
|
||||
|
||||
export type SkillsIndex = {
|
||||
fetchedAt: number;
|
||||
treeSha: string;
|
||||
entries: SkillEntry[];
|
||||
};
|
||||
|
||||
export type LoaderStatus =
|
||||
| { status: 'ready'; index: SkillsIndex }
|
||||
| { status: 'stale'; index: SkillsIndex; refreshing: boolean }
|
||||
| { status: 'empty' }
|
||||
| { status: 'error'; error: string };
|
||||
|
||||
const cacheRoot = () => path.join(WorkDir, 'cache', 'browser-skills');
|
||||
const skillsDir = () => path.join(cacheRoot(), 'domain-skills');
|
||||
const manifestPath = () => path.join(cacheRoot(), 'manifest.json');
|
||||
|
||||
async function ensureCacheDir(): Promise<void> {
|
||||
await fs.mkdir(skillsDir(), { recursive: true });
|
||||
}
|
||||
|
||||
async function readManifest(): Promise<SkillsIndex | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(manifestPath(), 'utf8');
|
||||
const parsed = JSON.parse(raw) as SkillsIndex;
|
||||
if (!parsed.entries || !Array.isArray(parsed.entries)) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeManifest(index: SkillsIndex): Promise<void> {
|
||||
await ensureCacheDir();
|
||||
await fs.writeFile(manifestPath(), JSON.stringify(index, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function extractTitle(markdown: string, fallback: string): string {
|
||||
const match = markdown.match(/^#\s+(.+?)\s*$/m);
|
||||
if (match?.[1]) return match[1].trim();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, init?: RequestInit): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'rowboat-browser-skills',
|
||||
Accept: 'application/vnd.github+json',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
type GithubTreeNode = { path: string; type: string; sha: string };
|
||||
|
||||
async function fetchRepoTree(): Promise<{ treeSha: string; skillPaths: string[] }> {
|
||||
const branchUrl = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/branches/${REPO_BRANCH}`;
|
||||
const branchRes = await fetchWithTimeout(branchUrl);
|
||||
if (!branchRes.ok) {
|
||||
throw new Error(`GitHub branch fetch failed: ${branchRes.status} ${branchRes.statusText}`);
|
||||
}
|
||||
const branch = (await branchRes.json()) as { commit: { commit: { tree: { sha: string } } } };
|
||||
const treeSha = branch.commit?.commit?.tree?.sha;
|
||||
if (!treeSha) throw new Error('Could not resolve tree SHA from branch response.');
|
||||
|
||||
const treeUrl = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/git/trees/${treeSha}?recursive=1`;
|
||||
const treeRes = await fetchWithTimeout(treeUrl);
|
||||
if (!treeRes.ok) {
|
||||
throw new Error(`GitHub tree fetch failed: ${treeRes.status} ${treeRes.statusText}`);
|
||||
}
|
||||
const tree = (await treeRes.json()) as { tree: GithubTreeNode[]; truncated: boolean };
|
||||
|
||||
const skillPaths = tree.tree
|
||||
.filter((n) => n.type === 'blob' && n.path.startsWith(DOMAIN_SKILLS_PREFIX) && n.path.endsWith('.md'))
|
||||
.map((n) => n.path);
|
||||
|
||||
return { treeSha, skillPaths };
|
||||
}
|
||||
|
||||
async function fetchRawFile(repoPath: string): Promise<string> {
|
||||
const url = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${REPO_BRANCH}/${repoPath}`;
|
||||
const res = await fetchWithTimeout(url, { headers: { Accept: 'text/plain' } });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Raw file fetch failed for ${repoPath}: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.text();
|
||||
}
|
||||
|
||||
function parseRepoPath(repoPath: string): { id: string; site: string; fileName: string } | null {
|
||||
const rel = repoPath.slice(DOMAIN_SKILLS_PREFIX.length);
|
||||
const parts = rel.split('/');
|
||||
if (parts.length < 2) return null;
|
||||
const site = parts[0];
|
||||
const fileName = parts.slice(1).join('/');
|
||||
const id = rel.replace(/\.md$/, '');
|
||||
return { id, site, fileName };
|
||||
}
|
||||
|
||||
export async function refreshFromRemote(): Promise<SkillsIndex> {
|
||||
await ensureCacheDir();
|
||||
const { treeSha, skillPaths } = await fetchRepoTree();
|
||||
|
||||
const entries: SkillEntry[] = [];
|
||||
await Promise.all(skillPaths.map(async (repoPath) => {
|
||||
const parsed = parseRepoPath(repoPath);
|
||||
if (!parsed) return;
|
||||
try {
|
||||
const content = await fetchRawFile(repoPath);
|
||||
const localRel = path.join(parsed.site, parsed.fileName);
|
||||
const localPath = path.join(skillsDir(), localRel);
|
||||
await fs.mkdir(path.dirname(localPath), { recursive: true });
|
||||
await fs.writeFile(localPath, content, 'utf8');
|
||||
entries.push({
|
||||
id: parsed.id,
|
||||
site: parsed.site,
|
||||
fileName: parsed.fileName,
|
||||
title: extractTitle(content, parsed.id),
|
||||
path: repoPath,
|
||||
localPath,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[browser-skills] Failed to fetch ${repoPath}:`, err);
|
||||
}
|
||||
}));
|
||||
|
||||
entries.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
const index: SkillsIndex = {
|
||||
fetchedAt: Date.now(),
|
||||
treeSha,
|
||||
entries,
|
||||
};
|
||||
await writeManifest(index);
|
||||
return index;
|
||||
}
|
||||
|
||||
let inFlightRefresh: Promise<SkillsIndex> | null = null;
|
||||
|
||||
export async function ensureLoaded(options?: { forceRefresh?: boolean }): Promise<LoaderStatus> {
|
||||
try {
|
||||
const existing = await readManifest();
|
||||
const fresh = existing && Date.now() - existing.fetchedAt < MANIFEST_TTL_MS;
|
||||
|
||||
if (existing && fresh && !options?.forceRefresh) {
|
||||
return { status: 'ready', index: existing };
|
||||
}
|
||||
|
||||
if (existing && !options?.forceRefresh) {
|
||||
if (!inFlightRefresh) {
|
||||
inFlightRefresh = refreshFromRemote()
|
||||
.catch((err) => {
|
||||
console.warn('[browser-skills] Background refresh failed:', err);
|
||||
return existing;
|
||||
})
|
||||
.finally(() => { inFlightRefresh = null; });
|
||||
}
|
||||
return { status: 'stale', index: existing, refreshing: true };
|
||||
}
|
||||
|
||||
if (!inFlightRefresh) {
|
||||
inFlightRefresh = refreshFromRemote().finally(() => { inFlightRefresh = null; });
|
||||
}
|
||||
try {
|
||||
const index = await inFlightRefresh;
|
||||
return { status: 'ready', index };
|
||||
} catch (err) {
|
||||
return { status: 'error', error: err instanceof Error ? err.message : 'Failed to load skills.' };
|
||||
}
|
||||
} catch (err) {
|
||||
return { status: 'error', error: err instanceof Error ? err.message : 'Skill loader failed.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function readSkillContent(id: string): Promise<{ ok: true; content: string; entry: SkillEntry } | { ok: false; error: string }> {
|
||||
const status = await ensureLoaded();
|
||||
if (status.status === 'error' || status.status === 'empty') {
|
||||
return { ok: false, error: status.status === 'error' ? status.error : 'No skills cached yet.' };
|
||||
}
|
||||
const entry = status.index.entries.find((e) => e.id === id);
|
||||
if (!entry) return { ok: false, error: `Skill '${id}' not found.` };
|
||||
try {
|
||||
const content = await fs.readFile(entry.localPath, 'utf8');
|
||||
return { ok: true, content, entry };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : 'Failed to read skill file.' };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import type { SkillEntry, SkillsIndex } from './loader.js';
|
||||
|
||||
/**
|
||||
* Map browser-harness `domain-skills/<site>/` folder names to hostname tokens we
|
||||
* match against the current tab's URL.
|
||||
*
|
||||
* Heuristic: for each site folder we generate candidate hostnames like
|
||||
* "booking-com" -> ["booking-com", "bookingcom", "booking.com"]
|
||||
* "github" -> ["github", "github.com"]
|
||||
* "dev-to" -> ["dev-to", "devto", "dev.to"]
|
||||
* Then we check whether any candidate is a substring of the tab hostname.
|
||||
*/
|
||||
function siteCandidates(site: string): string[] {
|
||||
const candidates = new Set<string>();
|
||||
candidates.add(site);
|
||||
candidates.add(site.replace(/-/g, ''));
|
||||
candidates.add(site.replace(/-/g, '.'));
|
||||
if (site.endsWith('-com')) {
|
||||
candidates.add(`${site.slice(0, -4)}.com`);
|
||||
}
|
||||
if (site.endsWith('-org')) {
|
||||
candidates.add(`${site.slice(0, -4)}.org`);
|
||||
}
|
||||
if (site.endsWith('-io')) {
|
||||
candidates.add(`${site.slice(0, -3)}.io`);
|
||||
}
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
function extractHostname(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function matchSkillsForUrl(index: SkillsIndex, url: string, limit = 5): SkillEntry[] {
|
||||
const hostname = extractHostname(url);
|
||||
if (!hostname) return [];
|
||||
|
||||
const bySite = new Map<string, SkillEntry[]>();
|
||||
for (const entry of index.entries) {
|
||||
if (!bySite.has(entry.site)) bySite.set(entry.site, []);
|
||||
bySite.get(entry.site)!.push(entry);
|
||||
}
|
||||
|
||||
const matched: SkillEntry[] = [];
|
||||
for (const [site, entries] of bySite) {
|
||||
const candidates = siteCandidates(site);
|
||||
const hit = candidates.some((c) => hostname === c || hostname.endsWith(`.${c}`) || hostname.includes(c));
|
||||
if (hit) matched.push(...entries);
|
||||
}
|
||||
|
||||
return matched.slice(0, limit);
|
||||
}
|
||||
|
|
@ -18,15 +18,19 @@ import { composioAccountsRepo } from "../../composio/repo.js";
|
|||
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, searchTools as searchComposioTools } from "../../composio/client.js";
|
||||
import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
|
||||
import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js";
|
||||
import { ensureLoaded as ensureBrowserSkillsLoaded, readSkillContent as readBrowserSkillContent, refreshFromRemote as refreshBrowserSkills } from "../browser-skills/index.js";
|
||||
import type { ToolContext } from "./exec-tool.js";
|
||||
import { generateText } from "ai";
|
||||
import { createProvider } from "../../models/models.js";
|
||||
import { getDefaultModelAndProvider, resolveProviderConfig } from "../../models/defaults.js";
|
||||
import { captureLlmUsage } from "../../analytics/usage.js";
|
||||
import { getCurrentUseCase } from "../../analytics/use_case.js";
|
||||
import { isSignedIn } from "../../account/account.js";
|
||||
import { getAccessToken } from "../../auth/tokens.js";
|
||||
import { API_URL } from "../../config/env.js";
|
||||
import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js";
|
||||
import type { IBrowserControlService } from "../browser-control/service.js";
|
||||
import type { INotificationService } from "../notification/service.js";
|
||||
// Parser libraries are loaded dynamically inside parseFile.execute()
|
||||
// to avoid pulling pdfjs-dist's DOM polyfills into the main bundle.
|
||||
// Import paths are computed so esbuild cannot statically resolve them.
|
||||
|
|
@ -764,6 +768,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
],
|
||||
});
|
||||
|
||||
const ctx = getCurrentUseCase();
|
||||
captureLlmUsage({
|
||||
useCase: ctx?.useCase ?? 'copilot_chat',
|
||||
subUseCase: 'file_parse',
|
||||
...(ctx?.agentName ? { agentName: ctx.agentName } : {}),
|
||||
model: modelId,
|
||||
provider: providerName,
|
||||
usage: response.usage,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fileName,
|
||||
|
|
@ -994,6 +1008,71 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Browser Skills (browser-use/browser-harness domain-skills cache)
|
||||
// ============================================================================
|
||||
|
||||
'load-browser-skill': {
|
||||
description: 'Load a site-specific browser skill (from the browser-use/browser-harness domain-skills library) by id. Returns the full markdown content with selectors, gotchas, and recipes for the target site. Call this after browser-control responses surface a matching skill in suggestedSkills. Pass action="list" to see all available skills. Skills are fetched on first use and cached locally; pass action="refresh" to force an update from upstream.',
|
||||
inputSchema: z.object({
|
||||
action: z.enum(['load', 'list', 'refresh']).optional().describe('load: fetch a skill by id (default). list: list all cached skills. refresh: re-fetch the library from upstream.'),
|
||||
id: z.string().optional().describe('Skill id (e.g., "github/repo-actions") — required for load.'),
|
||||
site: z.string().optional().describe('Filter list results to a single site (e.g., "github").'),
|
||||
}),
|
||||
execute: async (input: { action?: 'load' | 'list' | 'refresh'; id?: string; site?: string }) => {
|
||||
const action = input.action ?? 'load';
|
||||
try {
|
||||
if (action === 'refresh') {
|
||||
const index = await refreshBrowserSkills();
|
||||
return {
|
||||
success: true,
|
||||
message: `Refreshed ${index.entries.length} skill${index.entries.length === 1 ? '' : 's'} from upstream.`,
|
||||
count: index.entries.length,
|
||||
treeSha: index.treeSha,
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'list') {
|
||||
const status = await ensureBrowserSkillsLoaded();
|
||||
if (status.status === 'error') {
|
||||
return { success: false, error: status.error };
|
||||
}
|
||||
if (status.status === 'empty') {
|
||||
return { success: false, error: 'No browser skills cached yet.' };
|
||||
}
|
||||
const entries = status.index.entries
|
||||
.filter((e) => !input.site || e.site === input.site)
|
||||
.map((e) => ({ id: e.id, title: e.title, site: e.site }));
|
||||
return {
|
||||
success: true,
|
||||
count: entries.length,
|
||||
skills: entries,
|
||||
cacheAgeMs: Date.now() - status.index.fetchedAt,
|
||||
refreshing: status.status === 'stale' ? status.refreshing : false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.id) {
|
||||
return { success: false, error: 'id is required for load.' };
|
||||
}
|
||||
const result = await readBrowserSkillContent(input.id);
|
||||
if (!result.ok) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
id: result.entry.id,
|
||||
title: result.entry.title,
|
||||
site: result.entry.site,
|
||||
path: result.entry.path,
|
||||
content: result.content,
|
||||
};
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Failed to load browser skill.' };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Browser Control
|
||||
// ============================================================================
|
||||
|
|
@ -1514,4 +1593,44 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
}
|
||||
},
|
||||
},
|
||||
|
||||
'notify-user': {
|
||||
description: "Show a native OS notification to the user. Clicking the notification opens the provided link in the default browser, or focuses the Rowboat app if no link is given.",
|
||||
inputSchema: z.object({
|
||||
title: z.string().min(1).max(120).optional().describe("Bold headline shown at the top of the notification. Defaults to 'Rowboat'."),
|
||||
message: z.string().min(1).describe("Body text of the notification."),
|
||||
link: z.string().url().refine((v) => /^(https?|rowboat):\/\//i.test(v), {
|
||||
message: "link must be an http(s):// or rowboat:// URL",
|
||||
}).optional().describe("Optional URL opened when the user clicks the notification. Accepts http(s):// (opens in browser) or rowboat:// (opens a view inside Rowboat — see the notify-user skill for deep-link shapes)."),
|
||||
actionLabel: z.string().min(1).max(20).optional().describe("Optional label for an inline action button on the notification (e.g. 'Open', 'View', 'Take Notes'). Only shown when `link` is set. Click on the button triggers the same action as clicking the notification body."),
|
||||
secondaryActions: z.array(z.object({
|
||||
label: z.string().min(1).max(30),
|
||||
link: z.string().url().refine((v) => /^(https?|rowboat):\/\//i.test(v), {
|
||||
message: "secondary action link must be an http(s):// or rowboat:// URL",
|
||||
}),
|
||||
})).max(4).optional().describe("Additional action buttons. macOS shows them in the chevron menu next to the primary button (or all inline in Alert style). Each has its own label and link — clicking the button triggers that link, independent of the primary `link`."),
|
||||
}),
|
||||
isAvailable: async () => {
|
||||
try {
|
||||
return container.resolve<INotificationService>('notificationService').isSupported();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
execute: async ({ title, message, link, actionLabel, secondaryActions }: { title?: string; message: string; link?: string; actionLabel?: string; secondaryActions?: Array<{ label: string; link: string }> }) => {
|
||||
try {
|
||||
const service = container.resolve<INotificationService>('notificationService');
|
||||
if (!service.isSupported()) {
|
||||
return { success: false, error: 'Notifications are not supported on this system' };
|
||||
}
|
||||
service.notify({ title, message, link, actionLabel, secondaryActions });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ const execPromise = promisify(exec);
|
|||
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/;
|
||||
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
|
||||
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
|
||||
const EXECUTION_SHELL = getExecutionShell();
|
||||
|
||||
function sanitizeToken(token: string): string {
|
||||
return token.trim().replace(/^['"()]+|['"()]+$/g, '');
|
||||
|
|
@ -84,11 +83,12 @@ export async function executeCommand(
|
|||
}
|
||||
): Promise<CommandResult> {
|
||||
try {
|
||||
const shell = getExecutionShell();
|
||||
const { stdout, stderr } = await execPromise(command, {
|
||||
cwd: options?.cwd,
|
||||
timeout: options?.timeout,
|
||||
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
|
||||
shell: EXECUTION_SHELL,
|
||||
shell,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -161,8 +161,9 @@ export function executeCommandAbortable(
|
|||
};
|
||||
}
|
||||
|
||||
const shell = getExecutionShell();
|
||||
const proc = spawn(command, [], {
|
||||
shell: EXECUTION_SHELL,
|
||||
shell,
|
||||
cwd: options?.cwd,
|
||||
detached: process.platform !== 'win32', // Create process group on Unix
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
|
|
@ -272,11 +273,12 @@ export function executeCommandSync(
|
|||
}
|
||||
): CommandResult {
|
||||
try {
|
||||
const shell = getExecutionShell();
|
||||
const stdout = execSync(command, {
|
||||
cwd: options?.cwd,
|
||||
timeout: options?.timeout,
|
||||
encoding: 'utf-8',
|
||||
shell: EXECUTION_SHELL,
|
||||
shell,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
12
apps/x/packages/core/src/application/notification/service.ts
Normal file
12
apps/x/packages/core/src/application/notification/service.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export interface NotifyInput {
|
||||
title?: string;
|
||||
message: string;
|
||||
link?: string;
|
||||
actionLabel?: string;
|
||||
secondaryActions?: Array<{ label: string; link: string }>;
|
||||
}
|
||||
|
||||
export interface INotificationService {
|
||||
isSupported(): boolean;
|
||||
notify(input: NotifyInput): void;
|
||||
}
|
||||
113
apps/x/packages/core/src/auth/google-backend-oauth.ts
Normal file
113
apps/x/packages/core/src/auth/google-backend-oauth.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { API_URL } from "../config/env.js";
|
||||
import { getAccessToken } from "./tokens.js";
|
||||
import { OAuthTokens } from "./types.js";
|
||||
|
||||
/**
|
||||
* Client for the rowboat-mode Google OAuth endpoints on the api:
|
||||
* POST /v1/google-oauth/claim — one-shot retrieval of tokens parked by
|
||||
* the webapp callback under a `state` ticket
|
||||
* POST /v1/google-oauth/refresh — exchange a refresh_token for fresh tokens
|
||||
* (the secret-requiring step that can't
|
||||
* happen on the desktop)
|
||||
*
|
||||
* Both are called with the user's Rowboat Supabase bearer (via getAccessToken).
|
||||
*
|
||||
* The api response shape uses `scope: string` (space-delimited); we convert
|
||||
* to the desktop's `scopes: string[]`. On refresh, api may omit `scope` and
|
||||
* `refresh_token` — caller-provided existingScopes / refreshToken are
|
||||
* preserved in those cases (Google rarely rotates refresh tokens).
|
||||
*/
|
||||
|
||||
/** Thrown when the api signals the user must reconnect (Google `invalid_grant`). */
|
||||
export class ReconnectRequiredError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ReconnectRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiTokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_at: number;
|
||||
scope?: string;
|
||||
token_type?: string;
|
||||
}
|
||||
|
||||
function toOAuthTokens(
|
||||
body: ApiTokenResponse,
|
||||
fallbackRefreshToken: string | null = null,
|
||||
fallbackScopes?: string[],
|
||||
): OAuthTokens {
|
||||
const refresh_token = body.refresh_token ?? fallbackRefreshToken;
|
||||
const scopes = body.scope
|
||||
? body.scope.split(" ").filter((s) => s.length > 0)
|
||||
: fallbackScopes;
|
||||
return {
|
||||
access_token: body.access_token,
|
||||
refresh_token,
|
||||
expires_at: body.expires_at,
|
||||
token_type: "Bearer",
|
||||
scopes,
|
||||
};
|
||||
}
|
||||
|
||||
async function postWithBearer(path: string, body: unknown): Promise<Response> {
|
||||
const bearer = await getAccessToken();
|
||||
return fetch(`${API_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${bearer}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
interface ErrorBody {
|
||||
error?: string;
|
||||
reconnectRequired?: boolean;
|
||||
}
|
||||
|
||||
async function readError(res: Response): Promise<ErrorBody> {
|
||||
try {
|
||||
return (await res.json()) as ErrorBody;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Claim the tokens parked under `state` after the webapp finished its callback. */
|
||||
export async function claimTokensViaBackend(state: string): Promise<OAuthTokens> {
|
||||
const res = await postWithBearer("/v1/google-oauth/claim", { session: state });
|
||||
if (!res.ok) {
|
||||
const err = await readError(res);
|
||||
throw new Error(`claim failed: ${res.status} ${err.error ?? ""}`.trim());
|
||||
}
|
||||
const body = (await res.json()) as ApiTokenResponse;
|
||||
return toOAuthTokens(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an access token via the api. Preserves caller's `refreshToken` and
|
||||
* `existingScopes` when Google omits them on the refresh response.
|
||||
*/
|
||||
export async function refreshTokensViaBackend(
|
||||
refreshToken: string,
|
||||
existingScopes?: string[],
|
||||
): Promise<OAuthTokens> {
|
||||
const res = await postWithBearer("/v1/google-oauth/refresh", { refreshToken });
|
||||
if (res.status === 409) {
|
||||
const err = await readError(res);
|
||||
if (err.reconnectRequired) {
|
||||
throw new ReconnectRequiredError(err.error ?? "Reconnect required");
|
||||
}
|
||||
throw new Error(`refresh failed: 409 ${err.error ?? ""}`.trim());
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await readError(res);
|
||||
throw new Error(`refresh failed: ${res.status} ${err.error ?? ""}`.trim());
|
||||
}
|
||||
const body = (await res.json()) as ApiTokenResponse;
|
||||
return toOAuthTokens(body, refreshToken, existingScopes);
|
||||
}
|
||||
|
|
@ -8,6 +8,13 @@ const ProviderConnectionSchema = z.object({
|
|||
tokens: OAuthTokens.nullable().optional(),
|
||||
clientId: z.string().nullable().optional(),
|
||||
clientSecret: z.string().nullable().optional(),
|
||||
/**
|
||||
* `byok` (default for absent) — user provides their own client_id+secret;
|
||||
* tokens stored locally; refresh handled locally via openid-client.
|
||||
* `rowboat` — signed-in user; client_id+secret never on the desktop;
|
||||
* tokens stored locally but refresh goes through the api.
|
||||
*/
|
||||
mode: z.enum(['byok', 'rowboat']).optional(),
|
||||
error: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -49,8 +49,6 @@ async function getAuthHeaders(): Promise<Record<string, string>> {
|
|||
*/
|
||||
const ZComposioConfig = z.object({
|
||||
apiKey: z.string().optional(),
|
||||
use_composio_for_google: z.boolean().optional(),
|
||||
use_composio_for_google_calendar: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type ComposioConfig = z.infer<typeof ZComposioConfig>;
|
||||
|
|
@ -106,24 +104,6 @@ export async function isConfigured(): Promise<boolean> {
|
|||
return !!getApiKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Composio should be used for Google services (Gmail, etc.)
|
||||
*/
|
||||
export async function useComposioForGoogle(): Promise<boolean> {
|
||||
if (await isSignedIn()) return true;
|
||||
const config = loadConfig();
|
||||
return config.use_composio_for_google === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Composio should be used for Google Calendar
|
||||
*/
|
||||
export async function useComposioForGoogleCalendar(): Promise<boolean> {
|
||||
if (await isSignedIn()) return true;
|
||||
const config = loadConfig();
|
||||
return config.use_composio_for_google_calendar === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an API call to Composio
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export const API_URL =
|
||||
process.env.API_URL || 'https://api.x.rowboatlabs.com';
|
||||
process.env.API_URL || 'https://api.x.rowboatlabs.com';
|
||||
|
|
|
|||
51
apps/x/packages/core/src/config/remote-config.ts
Normal file
51
apps/x/packages/core/src/config/remote-config.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { API_URL } from "./env.js";
|
||||
|
||||
/**
|
||||
* Per-process cache of the unauthenticated `GET /v1/config` response from
|
||||
* the api. The api returns `{ appUrl, supabaseUrl, websocketApiUrl }` —
|
||||
* we use this to discover the webapp host (where the rowboat-mode OAuth
|
||||
* flow runs) without hardcoding it on the desktop side.
|
||||
*
|
||||
* Cached as a Promise so concurrent first-callers all await the same fetch
|
||||
* (no thundering herd). On failure the cache is cleared so the next call
|
||||
* can retry.
|
||||
*/
|
||||
|
||||
interface RemoteConfig {
|
||||
appUrl: string;
|
||||
supabaseUrl: string;
|
||||
websocketApiUrl: string;
|
||||
}
|
||||
|
||||
let _cached: Promise<RemoteConfig> | null = null;
|
||||
|
||||
async function fetchRemoteConfig(): Promise<RemoteConfig> {
|
||||
const res = await fetch(`${API_URL}/v1/config`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`/v1/config returned ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as Partial<RemoteConfig>;
|
||||
if (!body.appUrl) {
|
||||
throw new Error("/v1/config response missing appUrl");
|
||||
}
|
||||
return {
|
||||
appUrl: body.appUrl,
|
||||
supabaseUrl: body.supabaseUrl ?? "",
|
||||
websocketApiUrl: body.websocketApiUrl ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRemoteConfig(): Promise<RemoteConfig> {
|
||||
if (!_cached) {
|
||||
_cached = fetchRemoteConfig().catch((err) => {
|
||||
_cached = null; // allow retry
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return _cached;
|
||||
}
|
||||
|
||||
export async function getWebappUrl(): Promise<string> {
|
||||
const config = await getRemoteConfig();
|
||||
return config.appUrl;
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.
|
|||
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
|
||||
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
|
||||
import type { IBrowserControlService } from "../application/browser-control/service.js";
|
||||
import type { INotificationService } from "../application/notification/service.js";
|
||||
|
||||
const container = createContainer({
|
||||
injectionMode: InjectionMode.PROXY,
|
||||
|
|
@ -49,3 +50,9 @@ export function registerBrowserControlService(service: IBrowserControlService):
|
|||
browserControlService: asValue(service),
|
||||
});
|
||||
}
|
||||
|
||||
export function registerNotificationService(service: INotificationService): void {
|
||||
container.register({
|
||||
notificationService: asValue(service),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,10 @@ import { google } from 'googleapis';
|
|||
import { WorkDir } from '../config/config.js';
|
||||
import { createRun, createMessage } from '../runs/runs.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import { waitForRunCompletion } from '../agents/utils.js';
|
||||
import { getErrorDetails, waitForRunCompletion } from '../agents/utils.js';
|
||||
import { serviceLogger } from '../services/service_logger.js';
|
||||
import { loadUserConfig, updateUserEmail } from '../config/user_config.js';
|
||||
import { GoogleClientFactory } from './google-client-factory.js';
|
||||
import { useComposioForGoogle, executeAction } from '../composio/client.js';
|
||||
import { composioAccountsRepo } from '../composio/repo.js';
|
||||
import {
|
||||
loadAgentNotesState,
|
||||
saveAgentNotesState,
|
||||
|
|
@ -199,30 +197,7 @@ async function ensureUserEmail(): Promise<string | null> {
|
|||
return existing.email;
|
||||
}
|
||||
|
||||
// Try Composio (used when signed in or composio configured)
|
||||
try {
|
||||
if (await useComposioForGoogle()) {
|
||||
const account = composioAccountsRepo.getAccount('gmail');
|
||||
if (account && account.status === 'ACTIVE') {
|
||||
const result = await executeAction('GMAIL_GET_PROFILE', {
|
||||
connected_account_id: account.id,
|
||||
user_id: 'rowboat-user',
|
||||
version: 'latest',
|
||||
arguments: { user_id: 'me' },
|
||||
});
|
||||
const email = (result.data as Record<string, unknown>)?.emailAddress as string | undefined;
|
||||
if (email) {
|
||||
updateUserEmail(email);
|
||||
console.log(`[AgentNotes] Auto-populated user email via Composio: ${email}`);
|
||||
return email;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[AgentNotes] Could not fetch email via Composio:', error instanceof Error ? error.message : error);
|
||||
}
|
||||
|
||||
// Try direct Google OAuth
|
||||
// Try direct Google OAuth (covers both BYOK and rowboat modes)
|
||||
try {
|
||||
const auth = await GoogleClientFactory.getClient();
|
||||
if (auth) {
|
||||
|
|
@ -306,9 +281,14 @@ async function processAgentNotes(): Promise<void> {
|
|||
const timestamp = new Date().toISOString();
|
||||
const message = `Current timestamp: ${timestamp}\n\nProcess the following source material and update the Agent Notes folder accordingly.\n\n${messageParts.join('\n\n')}`;
|
||||
|
||||
const agentRun = await createRun({ agentId: AGENT_ID, model: await getKgModel() });
|
||||
const agentRun = await createRun({
|
||||
agentId: AGENT_ID,
|
||||
model: await getKgModel(),
|
||||
useCase: 'knowledge_sync',
|
||||
subUseCase: 'agent_notes',
|
||||
});
|
||||
await createMessage(agentRun.id, message);
|
||||
await waitForRunCompletion(agentRun.id);
|
||||
await waitForRunCompletion(agentRun.id, { throwOnError: true });
|
||||
|
||||
// Mark everything as processed
|
||||
for (const p of emailPaths) {
|
||||
|
|
@ -346,7 +326,16 @@ async function processAgentNotes(): Promise<void> {
|
|||
runId: serviceRun.runId,
|
||||
level: 'error',
|
||||
message: 'Error processing agent notes',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: getErrorDetails(error),
|
||||
});
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
service: serviceRun.service,
|
||||
runId: serviceRun.runId,
|
||||
level: 'error',
|
||||
message: 'Agent notes processing failed',
|
||||
durationMs: Date.now() - serviceRun.startedAt,
|
||||
outcome: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import path from 'path';
|
|||
import { WorkDir } from '../config/config.js';
|
||||
import { createRun, createMessage } from '../runs/runs.js';
|
||||
import { bus } from '../runs/bus.js';
|
||||
import { waitForRunCompletion } from '../agents/utils.js';
|
||||
import { getErrorDetails, waitForRunCompletion } from '../agents/utils.js';
|
||||
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||
import {
|
||||
loadState,
|
||||
|
|
@ -252,6 +252,8 @@ async function createNotesFromBatch(
|
|||
// Create a run for the note creation agent
|
||||
const run = await createRun({
|
||||
agentId: NOTE_CREATION_AGENT,
|
||||
useCase: 'knowledge_sync',
|
||||
subUseCase: 'build_graph',
|
||||
});
|
||||
const suggestedTopicsContent = readSuggestedTopicsFile();
|
||||
|
||||
|
|
@ -310,8 +312,11 @@ async function createNotesFromBatch(
|
|||
await createMessage(run.id, message);
|
||||
|
||||
// Wait for the run to complete
|
||||
await waitForRunCompletion(run.id);
|
||||
unsubscribe();
|
||||
try {
|
||||
await waitForRunCompletion(run.id, { throwOnError: true });
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
return { runId: run.id, notesCreated, notesModified };
|
||||
}
|
||||
|
|
@ -426,7 +431,7 @@ async function buildGraphWithFiles(
|
|||
runId: run.runId,
|
||||
level: 'error',
|
||||
message: `Error processing batch ${batchNumber}`,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: getErrorDetails(error),
|
||||
context: { batchNumber },
|
||||
});
|
||||
}
|
||||
|
|
@ -598,7 +603,7 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> {
|
|||
runId: run.runId,
|
||||
level: 'error',
|
||||
message: `Error processing voice memo batch ${batchNumber}`,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: getErrorDetails(error),
|
||||
context: { batchNumber },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,20 +6,44 @@ import { getProviderConfig } from '../auth/providers.js';
|
|||
import * as oauthClient from '../auth/oauth-client.js';
|
||||
import type { Configuration } from '../auth/oauth-client.js';
|
||||
import { OAuthTokens } from '../auth/types.js';
|
||||
import {
|
||||
ReconnectRequiredError,
|
||||
refreshTokensViaBackend,
|
||||
} from '../auth/google-backend-oauth.js';
|
||||
|
||||
type Mode = 'byok' | 'rowboat';
|
||||
|
||||
/**
|
||||
* Factory for creating and managing Google OAuth2Client instances.
|
||||
* Handles caching, token refresh, and client reuse for Google API SDKs.
|
||||
*
|
||||
* Two connection modes share the same `oauth.json` provider entry:
|
||||
* - `byok` user supplied client_id+secret; refresh runs locally via
|
||||
* openid-client; OAuth2Client built with creds.
|
||||
* - `rowboat` signed-in user; client_id+secret never on the desktop;
|
||||
* refresh goes through the api at /v1/google-oauth/refresh;
|
||||
* OAuth2Client built without creds and without refresh_token
|
||||
* (we own all refreshes — see note below).
|
||||
*
|
||||
* **Auto-refresh disabled in rowboat mode:** google-auth-library's
|
||||
* OAuth2Client will, on a 401 from a Google API call, try to refresh using
|
||||
* the refresh_token + client secret it has on hand. In rowboat mode we have
|
||||
* no secret, so that would 401-loop. We block this by passing only
|
||||
* access_token + expiry_date in setCredentials (no refresh_token), which
|
||||
* leaves the library nothing to refresh with. Our proactive expiry check
|
||||
* in getClient() is the only refresh path.
|
||||
*/
|
||||
export class GoogleClientFactory {
|
||||
private static readonly PROVIDER_NAME = 'google';
|
||||
private static cache: {
|
||||
mode: Mode | null;
|
||||
config: Configuration | null;
|
||||
client: OAuth2Client | null;
|
||||
tokens: OAuthTokens | null;
|
||||
clientId: string | null;
|
||||
clientSecret: string | null;
|
||||
} = {
|
||||
mode: null,
|
||||
config: null,
|
||||
client: null,
|
||||
tokens: null,
|
||||
|
|
@ -27,7 +51,14 @@ export class GoogleClientFactory {
|
|||
clientSecret: null,
|
||||
};
|
||||
|
||||
private static async resolveCredentials(): Promise<{ clientId: string; clientSecret?: string }> {
|
||||
/**
|
||||
* Promise singleton so a burst of getClient() calls during the brief
|
||||
* expiry window all wait on a single refresh round-trip rather than
|
||||
* fanning out parallel refreshes.
|
||||
*/
|
||||
private static refreshInFlight: Promise<OAuth2Client | null> | null = null;
|
||||
|
||||
private static async resolveByokCredentials(): Promise<{ clientId: string; clientSecret?: string }> {
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
const connection = await oauthRepo.read(this.PROVIDER_NAME);
|
||||
if (!connection.clientId) {
|
||||
|
|
@ -41,80 +72,116 @@ export class GoogleClientFactory {
|
|||
* Get or create OAuth2Client, reusing cached instance when possible
|
||||
*/
|
||||
static async getClient(): Promise<OAuth2Client | null> {
|
||||
if (this.refreshInFlight) {
|
||||
return this.refreshInFlight;
|
||||
}
|
||||
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
|
||||
const connection = await oauthRepo.read(this.PROVIDER_NAME);
|
||||
const tokens = connection.tokens ?? null;
|
||||
const mode: Mode = connection.mode ?? 'byok';
|
||||
|
||||
if (!tokens) {
|
||||
this.clearCache();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Initialize config cache if needed
|
||||
try {
|
||||
await this.initializeConfigCache();
|
||||
} catch (error) {
|
||||
console.error("[OAuth] Failed to initialize Google OAuth configuration:", error);
|
||||
// Mode flipped (e.g. user disconnected then reconnected differently) — invalidate.
|
||||
if (this.cache.mode && this.cache.mode !== mode) {
|
||||
this.clearCache();
|
||||
return null;
|
||||
}
|
||||
if (!this.cache.config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
// BYOK needs an openid-client Configuration for local refresh; rowboat doesn't.
|
||||
if (mode === 'byok') {
|
||||
try {
|
||||
await this.initializeConfigCache();
|
||||
} catch (error) {
|
||||
console.error('[OAuth] Failed to initialize Google OAuth configuration:', error);
|
||||
this.clearCache();
|
||||
return null;
|
||||
}
|
||||
if (!this.cache.config) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check expiry against the cached tokens. Note: oauthClient.isTokenExpired
|
||||
// applies a small clock-skew margin so we refresh slightly before real
|
||||
// expiry — keeps long-running calls from racing the boundary.
|
||||
if (oauthClient.isTokenExpired(tokens)) {
|
||||
// Token expired, try to refresh
|
||||
if (!tokens.refresh_token) {
|
||||
console.log("[OAuth] Token expired and no refresh token available for Google.");
|
||||
console.log('[OAuth] Token expired and no refresh token available for Google.');
|
||||
await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Missing refresh token. Please reconnect.' });
|
||||
this.clearCache();
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[OAuth] Token expired, refreshing access token...`);
|
||||
const existingScopes = tokens.scopes;
|
||||
const refreshedTokens = await oauthClient.refreshTokens(
|
||||
this.cache.config,
|
||||
tokens.refresh_token,
|
||||
existingScopes
|
||||
);
|
||||
await oauthRepo.upsert(this.PROVIDER_NAME, { tokens: refreshedTokens });
|
||||
|
||||
// Update cached tokens and recreate client
|
||||
this.cache.tokens = refreshedTokens;
|
||||
if (!this.cache.clientId) {
|
||||
const creds = await this.resolveCredentials();
|
||||
this.cache.clientId = creds.clientId;
|
||||
this.cache.clientSecret = creds.clientSecret ?? null;
|
||||
}
|
||||
this.cache.client = this.createClientFromTokens(refreshedTokens, this.cache.clientId, this.cache.clientSecret ?? undefined);
|
||||
console.log(`[OAuth] Token refreshed successfully`);
|
||||
return this.cache.client;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to refresh token for Google';
|
||||
await oauthRepo.upsert(this.PROVIDER_NAME, { error: message });
|
||||
console.error("[OAuth] Failed to refresh token for Google:", error);
|
||||
this.clearCache();
|
||||
return null;
|
||||
}
|
||||
this.refreshInFlight = this.refreshAndBuild(tokens, mode).finally(() => {
|
||||
this.refreshInFlight = null;
|
||||
});
|
||||
return this.refreshInFlight;
|
||||
}
|
||||
|
||||
// Reuse client if tokens haven't changed
|
||||
if (this.cache.client && this.cache.tokens && this.cache.tokens.access_token === tokens.access_token) {
|
||||
if (this.cache.client && this.cache.tokens && this.cache.tokens.access_token === tokens.access_token && this.cache.mode === mode) {
|
||||
return this.cache.client;
|
||||
}
|
||||
|
||||
// Create new client with current tokens
|
||||
console.log(`[OAuth] Creating new OAuth2Client instance`);
|
||||
this.cache.tokens = tokens;
|
||||
if (!this.cache.clientId) {
|
||||
const creds = await this.resolveCredentials();
|
||||
// Build a fresh client for current tokens
|
||||
return this.buildAndCacheClient(tokens, mode);
|
||||
}
|
||||
|
||||
private static async refreshAndBuild(tokens: OAuthTokens, mode: Mode): Promise<OAuth2Client | null> {
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
|
||||
try {
|
||||
console.log(`[OAuth] Token expired, refreshing via ${mode}...`);
|
||||
const existingScopes = tokens.scopes;
|
||||
|
||||
let refreshedTokens: OAuthTokens;
|
||||
if (mode === 'rowboat') {
|
||||
refreshedTokens = await refreshTokensViaBackend(tokens.refresh_token!, existingScopes);
|
||||
} else {
|
||||
if (!this.cache.config) {
|
||||
// Should not happen — initializeConfigCache ran above for byok.
|
||||
throw new Error('Google OAuth config not initialized');
|
||||
}
|
||||
refreshedTokens = await oauthClient.refreshTokens(this.cache.config, tokens.refresh_token!, existingScopes);
|
||||
}
|
||||
|
||||
await oauthRepo.upsert(this.PROVIDER_NAME, { tokens: refreshedTokens, error: null });
|
||||
console.log('[OAuth] Token refreshed successfully');
|
||||
return this.buildAndCacheClient(refreshedTokens, mode);
|
||||
} catch (error) {
|
||||
if (error instanceof ReconnectRequiredError) {
|
||||
console.log('[OAuth] Reconnect required for Google');
|
||||
await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Reconnect Google' });
|
||||
this.clearCache();
|
||||
return null;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Failed to refresh token for Google';
|
||||
await oauthRepo.upsert(this.PROVIDER_NAME, { error: message });
|
||||
console.error('[OAuth] Failed to refresh token for Google:', error);
|
||||
this.clearCache();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async buildAndCacheClient(tokens: OAuthTokens, mode: Mode): Promise<OAuth2Client> {
|
||||
if (mode === 'byok' && !this.cache.clientId) {
|
||||
const creds = await this.resolveByokCredentials();
|
||||
this.cache.clientId = creds.clientId;
|
||||
this.cache.clientSecret = creds.clientSecret ?? null;
|
||||
}
|
||||
this.cache.client = this.createClientFromTokens(tokens, this.cache.clientId, this.cache.clientSecret ?? undefined);
|
||||
return this.cache.client;
|
||||
|
||||
const client = mode === 'rowboat'
|
||||
? this.createRowboatClient(tokens)
|
||||
: this.createByokClient(tokens, this.cache.clientId!, this.cache.clientSecret ?? undefined);
|
||||
|
||||
this.cache.mode = mode;
|
||||
this.cache.tokens = tokens;
|
||||
this.cache.client = client;
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -139,7 +206,8 @@ export class GoogleClientFactory {
|
|||
* Clear cache (useful for testing or when credentials are revoked)
|
||||
*/
|
||||
static clearCache(): void {
|
||||
console.log(`[OAuth] Clearing Google auth cache`);
|
||||
console.log('[OAuth] Clearing Google auth cache');
|
||||
this.cache.mode = null;
|
||||
this.cache.config = null;
|
||||
this.cache.client = null;
|
||||
this.cache.tokens = null;
|
||||
|
|
@ -148,10 +216,10 @@ export class GoogleClientFactory {
|
|||
}
|
||||
|
||||
/**
|
||||
* Initialize cached configuration (called once)
|
||||
* Initialize cached configuration for BYOK mode (rowboat doesn't need it).
|
||||
*/
|
||||
private static async initializeConfigCache(): Promise<void> {
|
||||
const { clientId, clientSecret } = await this.resolveCredentials();
|
||||
const { clientId, clientSecret } = await this.resolveByokCredentials();
|
||||
|
||||
if (this.cache.config && this.cache.clientId === clientId && this.cache.clientSecret === (clientSecret ?? null)) {
|
||||
return; // Already initialized for these credentials
|
||||
|
|
@ -161,13 +229,13 @@ export class GoogleClientFactory {
|
|||
this.clearCache();
|
||||
}
|
||||
|
||||
console.log(`[OAuth] Initializing Google OAuth configuration...`);
|
||||
console.log('[OAuth] Initializing Google OAuth configuration...');
|
||||
const providerConfig = await getProviderConfig(this.PROVIDER_NAME);
|
||||
|
||||
if (providerConfig.discovery.mode === 'issuer') {
|
||||
if (providerConfig.client.mode === 'static') {
|
||||
// Discover endpoints, use static client ID
|
||||
console.log(`[OAuth] Discovery mode: issuer with static client ID`);
|
||||
console.log('[OAuth] Discovery mode: issuer with static client ID');
|
||||
this.cache.config = await oauthClient.discoverConfiguration(
|
||||
providerConfig.discovery.issuer,
|
||||
clientId,
|
||||
|
|
@ -175,7 +243,7 @@ export class GoogleClientFactory {
|
|||
);
|
||||
} else {
|
||||
// DCR mode - need existing registration
|
||||
console.log(`[OAuth] Discovery mode: issuer with DCR`);
|
||||
console.log('[OAuth] Discovery mode: issuer with DCR');
|
||||
const clientRepo = container.resolve<IClientRegistrationRepo>('clientRegistrationRepo');
|
||||
const existingRegistration = await clientRepo.getClientRegistration(this.PROVIDER_NAME);
|
||||
|
||||
|
|
@ -194,7 +262,7 @@ export class GoogleClientFactory {
|
|||
throw new Error('DCR requires discovery mode "issuer", not "static"');
|
||||
}
|
||||
|
||||
console.log(`[OAuth] Using static endpoints (no discovery)`);
|
||||
console.log('[OAuth] Using static endpoints (no discovery)');
|
||||
this.cache.config = oauthClient.createStaticConfiguration(
|
||||
providerConfig.discovery.authorizationEndpoint,
|
||||
providerConfig.discovery.tokenEndpoint,
|
||||
|
|
@ -206,27 +274,33 @@ export class GoogleClientFactory {
|
|||
|
||||
this.cache.clientId = clientId;
|
||||
this.cache.clientSecret = clientSecret ?? null;
|
||||
console.log(`[OAuth] Google OAuth configuration initialized`);
|
||||
console.log('[OAuth] Google OAuth configuration initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create OAuth2Client from OAuthTokens
|
||||
*/
|
||||
private static createClientFromTokens(tokens: OAuthTokens, clientId: string, clientSecret?: string): OAuth2Client {
|
||||
const client = new OAuth2Client(
|
||||
clientId,
|
||||
clientSecret ?? undefined,
|
||||
undefined // redirect_uri not needed for token usage
|
||||
);
|
||||
|
||||
// Set credentials
|
||||
/** BYOK OAuth2Client — has client_id + secret + refresh_token. */
|
||||
private static createByokClient(tokens: OAuthTokens, clientId: string, clientSecret?: string): OAuth2Client {
|
||||
const client = new OAuth2Client(clientId, clientSecret ?? undefined, undefined);
|
||||
client.setCredentials({
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token || undefined,
|
||||
expiry_date: tokens.expires_at * 1000, // Convert from seconds to milliseconds
|
||||
expiry_date: tokens.expires_at * 1000,
|
||||
scope: tokens.scopes?.join(' ') || undefined,
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rowboat OAuth2Client — no client_id/secret, no refresh_token.
|
||||
* Library auto-refresh is disabled by absence of refresh_token; our
|
||||
* proactive refresh in getClient() is the only refresh path.
|
||||
*/
|
||||
private static createRowboatClient(tokens: OAuthTokens): OAuth2Client {
|
||||
const client = new OAuth2Client();
|
||||
client.setCredentials({
|
||||
access_token: tokens.access_token,
|
||||
expiry_date: tokens.expires_at * 1000,
|
||||
scope: tokens.scopes?.join(' ') || undefined,
|
||||
});
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { IModelConfigRepo } from '../models/repo.js';
|
|||
import { createProvider } from '../models/models.js';
|
||||
import { inlineTask } from '@x/shared';
|
||||
import { extractAgentResponse, waitForRunCompletion } from '../agents/utils.js';
|
||||
import { captureLlmUsage } from '../analytics/usage.js';
|
||||
|
||||
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
|
||||
const INLINE_TASK_AGENT = 'inline_task_agent';
|
||||
|
|
@ -468,7 +469,12 @@ async function processInlineTasks(): Promise<void> {
|
|||
console.log(`[InlineTasks] Running task: "${task.instruction.slice(0, 80)}..."`);
|
||||
|
||||
try {
|
||||
const run = await createRun({ agentId: INLINE_TASK_AGENT, model: await getKgModel() });
|
||||
const run = await createRun({
|
||||
agentId: INLINE_TASK_AGENT,
|
||||
model: await getKgModel(),
|
||||
useCase: 'knowledge_sync',
|
||||
subUseCase: 'inline_task_run',
|
||||
});
|
||||
|
||||
const message = [
|
||||
`Execute the following instruction from the note "${relativePath}":`,
|
||||
|
|
@ -548,7 +554,12 @@ export async function processRowboatInstruction(
|
|||
scheduleLabel: string | null;
|
||||
response: string | null;
|
||||
}> {
|
||||
const run = await createRun({ agentId: INLINE_TASK_AGENT, model: await getKgModel() });
|
||||
const run = await createRun({
|
||||
agentId: INLINE_TASK_AGENT,
|
||||
model: await getKgModel(),
|
||||
useCase: 'knowledge_sync',
|
||||
subUseCase: 'inline_task_run',
|
||||
});
|
||||
|
||||
const message = [
|
||||
`Process the following @rowboat instruction from the note "${notePath}":`,
|
||||
|
|
@ -659,6 +670,14 @@ Respond with ONLY valid JSON: either a schedule object or null. No other text.`;
|
|||
prompt: instruction,
|
||||
});
|
||||
|
||||
captureLlmUsage({
|
||||
useCase: 'knowledge_sync',
|
||||
subUseCase: 'inline_task_classify',
|
||||
model: config.model,
|
||||
provider: config.provider.flavor,
|
||||
usage: result.usage,
|
||||
});
|
||||
|
||||
let text = result.text.trim();
|
||||
console.log('[classifySchedule] LLM response:', text);
|
||||
// Strip markdown code fences if the LLM wraps the JSON
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { WorkDir } from '../config/config.js';
|
|||
import { createRun, createMessage } from '../runs/runs.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import { bus } from '../runs/bus.js';
|
||||
import { waitForRunCompletion } from '../agents/utils.js';
|
||||
import { getErrorDetails, waitForRunCompletion } from '../agents/utils.js';
|
||||
import { serviceLogger } from '../services/service_logger.js';
|
||||
import { limitEventItems } from './limit_event_items.js';
|
||||
import {
|
||||
|
|
@ -73,6 +73,8 @@ async function labelEmailBatch(
|
|||
const run = await createRun({
|
||||
agentId: LABELING_AGENT,
|
||||
model: await getKgModel(),
|
||||
useCase: 'knowledge_sync',
|
||||
subUseCase: 'label_emails',
|
||||
});
|
||||
|
||||
let message = `Label the following ${files.length} email files by prepending YAML frontmatter.\n\n`;
|
||||
|
|
@ -110,8 +112,11 @@ async function labelEmailBatch(
|
|||
});
|
||||
|
||||
await createMessage(run.id, message);
|
||||
await waitForRunCompletion(run.id);
|
||||
unsubscribe();
|
||||
try {
|
||||
await waitForRunCompletion(run.id, { throwOnError: true });
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
return { runId: run.id, filesEdited };
|
||||
}
|
||||
|
|
@ -173,6 +178,7 @@ export async function processUnlabeledEmails(concurrency: number = DEFAULT_CONCU
|
|||
const totalBatches = batches.length;
|
||||
let totalEdited = 0;
|
||||
let hadError = false;
|
||||
let failedBatches = 0;
|
||||
|
||||
// Process batches with concurrency limit
|
||||
for (let i = 0; i < batches.length; i += concurrency) {
|
||||
|
|
@ -207,14 +213,16 @@ export async function processUnlabeledEmails(concurrency: number = DEFAULT_CONCU
|
|||
return result.filesEdited.size;
|
||||
} catch (error) {
|
||||
hadError = true;
|
||||
failedBatches++;
|
||||
const errorDetails = getErrorDetails(error);
|
||||
console.error(`[EmailLabeling] Error processing batch ${batchNumber}:`, error);
|
||||
await serviceLogger.log({
|
||||
type: 'error',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: 'error',
|
||||
message: `Error processing batch ${batchNumber}`,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
message: `Email labeling batch ${batchNumber}/${totalBatches} failed`,
|
||||
error: errorDetails,
|
||||
context: { batchNumber },
|
||||
});
|
||||
return 0;
|
||||
|
|
@ -236,12 +244,15 @@ export async function processUnlabeledEmails(concurrency: number = DEFAULT_CONCU
|
|||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: hadError ? 'error' : 'info',
|
||||
message: `Email labeling complete: ${totalEdited} files labeled`,
|
||||
message: hadError
|
||||
? `Email labeling finished with errors: ${totalEdited} files labeled`
|
||||
: `Email labeling complete: ${totalEdited} files labeled`,
|
||||
durationMs: Date.now() - run.startedAt,
|
||||
outcome: hadError ? 'error' : 'ok',
|
||||
summary: {
|
||||
totalEmails: unlabeled.length,
|
||||
filesLabeled: totalEdited,
|
||||
failedBatches,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -807,6 +807,43 @@ The summary should answer: **"Who is this person and why do I know them?"**
|
|||
|
||||
**Focus on the relationship, not the communication method.**
|
||||
|
||||
## Knowing Vs Meeting
|
||||
|
||||
Distinguish between **knowing someone** and **having met or heard from them once**.
|
||||
|
||||
- Use **"I know X through Y"** only when there is an actual ongoing relationship
|
||||
- In that construction, **Y** should be a person, organization, or recurring context such as YC, an investor relationship, a customer relationship, or an ongoing project
|
||||
- For one-off encounters, use **"I met X at/on/during..."** or lead with what they did, such as **"X reached out about..."**, **"X joined..."**, or **"X was part of..."**
|
||||
- Do **not** use **"I know X through [an event]"** when the thing is a specific meeting, dinner, conference, demo day, call, or other one-off event
|
||||
- Events are **when or where I met someone**, not **how I know them**
|
||||
- If the source only shows a single meeting, a single inbound email, or a one-time introduction, do not imply an ongoing relationship unless the broader context clearly supports it
|
||||
|
||||
Examples:
|
||||
|
||||
- Incorrect: \`I know him through a YC dinner.\`
|
||||
- Correct: \`I met him at a YC dinner.\`
|
||||
- Incorrect: \`I know her through a call about pricing.\`
|
||||
- Correct: \`She reached out about pricing.\`
|
||||
- Correct: \`I know her through YC and ongoing investor conversations.\`
|
||||
|
||||
## Perspective And Self-Reference
|
||||
|
||||
These knowledge notes are written from the **user's first-person perspective**.
|
||||
|
||||
- When the user's identity is known, **"I / me / my" refer to the user**
|
||||
- When the company or team is the actor, use **"we / us / our"** when natural
|
||||
- Name other participants normally
|
||||
- **Do not refer to the user by name, email, or in third person inside first-person narration**
|
||||
- Do not write broken combinations like **"I know him ... that met with Arjun"** when Arjun is the user
|
||||
- Apply this consistently across **all note types and sections**: summaries, activity entries, timelines, decisions, open items, and any narrative prose
|
||||
|
||||
Examples:
|
||||
|
||||
- Incorrect: \`I know him as part of the Standard Capital team that met with Arjun and Ramnique.\`
|
||||
- Correct: \`I know him as part of the Standard Capital team that met with me and Ramnique.\`
|
||||
- Incorrect: \`Arjun discussed pricing with [[People/Sarah Chen]].\`
|
||||
- Correct: \`I discussed pricing with [[People/Sarah Chen]].\`
|
||||
|
||||
## Activity Summary
|
||||
|
||||
One line summarizing this source's relevance to the entity:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
|
|||
**Last update:** {YYYY-MM-DD}
|
||||
|
||||
## Summary
|
||||
{2-3 sentences: Who they are, why you know them, what you're working on together.}
|
||||
{2-3 sentences: Who they are, whether I know them through an ongoing relationship or met them in a specific encounter, and what we're discussing or working on together if applicable.}
|
||||
|
||||
## Connected to
|
||||
- [[Organizations/{Organization}]] — works at
|
||||
|
|
@ -59,7 +59,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
|
|||
**Last update:** {YYYY-MM-DD}
|
||||
|
||||
## Summary
|
||||
{2-3 sentences: What this org is, what your relationship is.}
|
||||
{2-3 sentences: What this org is, how I know or work with them.}
|
||||
|
||||
## People
|
||||
- [[People/{Person}]] — {role}
|
||||
|
|
@ -93,7 +93,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
|
|||
**Last update:** {YYYY-MM-DD}
|
||||
|
||||
## Summary
|
||||
{2-3 sentences: What this project is, goal, current state.}
|
||||
{2-3 sentences: What this project is, the goal, current state, and my/our involvement where relevant.}
|
||||
|
||||
## People
|
||||
- [[People/{Person}]] — {role}
|
||||
|
|
|
|||
180
apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts
Normal file
180
apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import type { Dirent } from "node:fs";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import container from "../di/container.js";
|
||||
import type { INotificationService } from "../application/notification/service.js";
|
||||
|
||||
const TICK_INTERVAL_MS = 30_000;
|
||||
// Notify when an event is between 30s in the past (started just now) and
|
||||
// 90s in the future (about to start). The window is wider than 60s so we
|
||||
// don't miss an event if the tick lands slightly off the start time.
|
||||
const NOTIFY_LEAD_MS = 90_000;
|
||||
const NOTIFY_GRACE_MS = 30_000;
|
||||
// Drop state entries older than 24h so the file doesn't grow forever.
|
||||
const STATE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const CALENDAR_SYNC_DIR = path.join(WorkDir, "calendar_sync");
|
||||
const STATE_FILE = path.join(WorkDir, "calendar_notifications_state.json");
|
||||
|
||||
interface NotificationState {
|
||||
notifiedEventIds: Record<string, { notifiedAt: string; startTime: string }>;
|
||||
}
|
||||
|
||||
interface CalendarEvent {
|
||||
id?: string;
|
||||
summary?: string;
|
||||
status?: string;
|
||||
start?: { dateTime?: string; date?: string; timeZone?: string };
|
||||
end?: { dateTime?: string; date?: string };
|
||||
attendees?: Array<{ email?: string; self?: boolean; responseStatus?: string }>;
|
||||
hangoutLink?: string;
|
||||
conferenceData?: {
|
||||
entryPoints?: Array<{ entryPointType?: string; uri?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
async function loadState(): Promise<NotificationState> {
|
||||
try {
|
||||
const raw = await fs.readFile(STATE_FILE, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && parsed.notifiedEventIds) {
|
||||
return parsed as NotificationState;
|
||||
}
|
||||
} catch {
|
||||
// No state file yet, or corrupt — start fresh.
|
||||
}
|
||||
return { notifiedEventIds: {} };
|
||||
}
|
||||
|
||||
async function saveState(state: NotificationState): Promise<void> {
|
||||
// Write to a sibling tmp file then rename so a mid-write crash can't leave
|
||||
// the JSON corrupt — a corrupt state file would make every event in the
|
||||
// 90s notify window re-fire on next start.
|
||||
const tmp = `${STATE_FILE}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf-8");
|
||||
await fs.rename(tmp, STATE_FILE);
|
||||
}
|
||||
|
||||
function gcState(state: NotificationState): NotificationState {
|
||||
const cutoff = Date.now() - STATE_TTL_MS;
|
||||
const fresh: NotificationState["notifiedEventIds"] = {};
|
||||
for (const [id, entry] of Object.entries(state.notifiedEventIds)) {
|
||||
const ts = Date.parse(entry.notifiedAt);
|
||||
if (Number.isFinite(ts) && ts >= cutoff) fresh[id] = entry;
|
||||
}
|
||||
return { notifiedEventIds: fresh };
|
||||
}
|
||||
|
||||
function isAllDay(event: CalendarEvent): boolean {
|
||||
// Google Calendar all-day events have `date` (YYYY-MM-DD) on start, not `dateTime`.
|
||||
return !event.start?.dateTime;
|
||||
}
|
||||
|
||||
function isDeclinedBySelf(event: CalendarEvent): boolean {
|
||||
if (!event.attendees) return false;
|
||||
const self = event.attendees.find((a) => a.self);
|
||||
return self?.responseStatus === "declined";
|
||||
}
|
||||
|
||||
async function tick(state: NotificationState): Promise<{ state: NotificationState; dirty: boolean }> {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(CALENDAR_SYNC_DIR, { withFileTypes: true });
|
||||
} catch {
|
||||
return { state, dirty: false };
|
||||
}
|
||||
|
||||
let service: INotificationService;
|
||||
try {
|
||||
service = container.resolve<INotificationService>("notificationService");
|
||||
} catch {
|
||||
// Notification service not registered yet (very early startup) — skip this tick.
|
||||
return { state, dirty: false };
|
||||
}
|
||||
if (!service.isSupported()) return { state, dirty: false };
|
||||
|
||||
const now = Date.now();
|
||||
let dirty = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
||||
if (entry.name === "sync_state.json" || entry.name.startsWith("sync_state")) continue;
|
||||
|
||||
const eventId = entry.name.replace(/\.json$/, "");
|
||||
if (state.notifiedEventIds[eventId]) continue;
|
||||
|
||||
const filePath = path.join(CALENDAR_SYNC_DIR, entry.name);
|
||||
let event: CalendarEvent;
|
||||
try {
|
||||
event = JSON.parse(await fs.readFile(filePath, "utf-8"));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.status === "cancelled") continue;
|
||||
if (isAllDay(event)) continue;
|
||||
if (isDeclinedBySelf(event)) continue;
|
||||
|
||||
const startStr = event.start?.dateTime;
|
||||
if (!startStr) continue;
|
||||
const startMs = Date.parse(startStr);
|
||||
if (!Number.isFinite(startMs)) continue;
|
||||
|
||||
const msUntilStart = startMs - now;
|
||||
if (msUntilStart > NOTIFY_LEAD_MS) continue;
|
||||
if (msUntilStart < -NOTIFY_GRACE_MS) continue;
|
||||
|
||||
const summary = event.summary?.trim() || "Untitled meeting";
|
||||
const eid = encodeURIComponent(eventId);
|
||||
|
||||
try {
|
||||
service.notify({
|
||||
title: "Upcoming meeting",
|
||||
message: `${summary} starts in 1 minute. Click to join and take notes.`,
|
||||
// Single labeled button — adding a secondary action would force
|
||||
// macOS to bundle them into an "Options" dropdown, hiding the
|
||||
// primary label.
|
||||
link: `rowboat://action?type=join-and-take-meeting-notes&eventId=${eid}`,
|
||||
actionLabel: "Join & Notes",
|
||||
});
|
||||
console.log(`[CalendarNotify] notified for "${summary}" (${eventId})`);
|
||||
} catch (err) {
|
||||
console.error(`[CalendarNotify] notify failed for ${eventId}:`, err);
|
||||
continue;
|
||||
}
|
||||
|
||||
state.notifiedEventIds[eventId] = {
|
||||
notifiedAt: new Date().toISOString(),
|
||||
startTime: startStr,
|
||||
};
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
return { state, dirty };
|
||||
}
|
||||
|
||||
export async function init(): Promise<void> {
|
||||
console.log("[CalendarNotify] starting calendar notification service");
|
||||
console.log(`[CalendarNotify] tick every ${TICK_INTERVAL_MS / 1000}s`);
|
||||
|
||||
let state = gcState(await loadState());
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const result = await tick(state);
|
||||
state = result.state;
|
||||
if (result.dirty) {
|
||||
state = gcState(state);
|
||||
try {
|
||||
await saveState(state);
|
||||
} catch (err) {
|
||||
console.error("[CalendarNotify] failed to save state:", err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[CalendarNotify] tick failed:", err);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, TICK_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { generateText } from 'ai';
|
|||
import { createProvider } from '../models/models.js';
|
||||
import { getDefaultModelAndProvider, getMeetingNotesModel, resolveProviderConfig } from '../models/defaults.js';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { captureLlmUsage } from '../analytics/usage.js';
|
||||
|
||||
const CALENDAR_SYNC_DIR = path.join(WorkDir, 'calendar_sync');
|
||||
|
||||
|
|
@ -157,5 +158,12 @@ export async function summarizeMeeting(transcript: string, meetingStartTime?: st
|
|||
prompt,
|
||||
});
|
||||
|
||||
captureLlmUsage({
|
||||
useCase: 'meeting_note',
|
||||
model: modelId,
|
||||
provider: providerName,
|
||||
usage: result.usage,
|
||||
});
|
||||
|
||||
return result.text.trim();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,8 @@ import { OAuth2Client } from 'google-auth-library';
|
|||
import { NodeHtmlMarkdown } from 'node-html-markdown'
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { GoogleClientFactory } from './google-client-factory.js';
|
||||
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||
import { serviceLogger } from '../services/service_logger.js';
|
||||
import { limitEventItems } from './limit_event_items.js';
|
||||
import { executeAction, useComposioForGoogleCalendar } from '../composio/client.js';
|
||||
import { composioAccountsRepo } from '../composio/repo.js';
|
||||
import { createEvent } from './track/events.js';
|
||||
|
||||
const MAX_EVENTS_IN_DIGEST = 50;
|
||||
|
|
@ -138,7 +136,6 @@ async function publishCalendarSyncEvent(
|
|||
const SYNC_DIR = path.join(WorkDir, 'calendar_sync');
|
||||
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
|
||||
const LOOKBACK_DAYS = 7;
|
||||
const COMPOSIO_LOOKBACK_DAYS = 7;
|
||||
const REQUIRED_SCOPES = [
|
||||
'https://www.googleapis.com/auth/calendar.events.readonly',
|
||||
'https://www.googleapis.com/auth/drive.readonly'
|
||||
|
|
@ -477,286 +474,17 @@ async function performSync(syncDir: string, lookbackDays: number) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Composio-based Sync ---
|
||||
|
||||
interface ComposioCalendarState {
|
||||
last_sync: string; // ISO string
|
||||
}
|
||||
|
||||
function loadComposioState(stateFile: string): ComposioCalendarState | null {
|
||||
if (fs.existsSync(stateFile)) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
if (data.last_sync) {
|
||||
return { last_sync: data.last_sync };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Calendar] Failed to load composio state:', e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveComposioState(stateFile: string, lastSync: string): void {
|
||||
fs.writeFileSync(stateFile, JSON.stringify({ last_sync: lastSync }, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a Composio calendar event as JSON (same format used by Google OAuth path).
|
||||
* The event data from Composio is already structured similarly to Google Calendar API.
|
||||
*/
|
||||
function saveComposioEvent(eventData: Record<string, unknown>, syncDir: string): { changed: boolean; isNew: boolean; title: string } {
|
||||
const eventId = eventData.id as string | undefined;
|
||||
if (!eventId) return { changed: false, isNew: false, title: 'Unknown' };
|
||||
|
||||
const filePath = path.join(syncDir, `${eventId}.json`);
|
||||
const content = JSON.stringify(eventData, null, 2);
|
||||
const exists = fs.existsSync(filePath);
|
||||
|
||||
try {
|
||||
if (exists) {
|
||||
const existing = fs.readFileSync(filePath, 'utf-8');
|
||||
if (existing === content) {
|
||||
return { changed: false, isNew: false, title: (eventData.summary as string) || eventId };
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, content);
|
||||
return { changed: true, isNew: !exists, title: (eventData.summary as string) || eventId };
|
||||
} catch (e) {
|
||||
console.error(`[Calendar] Error saving event ${eventId}:`, e);
|
||||
return { changed: false, isNew: false, title: (eventData.summary as string) || eventId };
|
||||
}
|
||||
}
|
||||
|
||||
async function performSyncComposio() {
|
||||
const STATE_FILE = path.join(SYNC_DIR, 'composio_state.json');
|
||||
|
||||
if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true });
|
||||
|
||||
const account = composioAccountsRepo.getAccount('googlecalendar');
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
console.log('[Calendar] Google Calendar not connected via Composio. Skipping sync.');
|
||||
return;
|
||||
}
|
||||
|
||||
const connectedAccountId = account.id;
|
||||
|
||||
// Calculate time window: lookback + 14 days forward
|
||||
const now = new Date();
|
||||
const lookbackMs = COMPOSIO_LOOKBACK_DAYS * 24 * 60 * 60 * 1000;
|
||||
const twoWeeksForwardMs = 14 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const timeMin = new Date(now.getTime() - lookbackMs).toISOString();
|
||||
const timeMax = new Date(now.getTime() + twoWeeksForwardMs).toISOString();
|
||||
|
||||
console.log(`[Calendar] Syncing via Composio from ${timeMin} to ${timeMax} (lookback: ${COMPOSIO_LOOKBACK_DAYS} days)...`);
|
||||
|
||||
let run: ServiceRunContext | null = null;
|
||||
const ensureRun = async (): Promise<ServiceRunContext> => {
|
||||
if (!run) {
|
||||
run = await serviceLogger.startRun({
|
||||
service: 'calendar',
|
||||
message: 'Syncing calendar (Composio)',
|
||||
trigger: 'timer',
|
||||
});
|
||||
}
|
||||
return run;
|
||||
};
|
||||
|
||||
try {
|
||||
const currentEventIds = new Set<string>();
|
||||
let newCount = 0;
|
||||
let updatedCount = 0;
|
||||
const changedTitles: string[] = [];
|
||||
const newEvents: AnyEvent[] = [];
|
||||
const updatedEvents: AnyEvent[] = [];
|
||||
let pageToken: string | null = null;
|
||||
const MAX_PAGES = 20;
|
||||
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
// Re-check connection in case user disconnected mid-sync
|
||||
if (!composioAccountsRepo.isConnected('googlecalendar')) {
|
||||
console.log('[Calendar] Account disconnected during sync. Stopping.');
|
||||
return;
|
||||
}
|
||||
|
||||
const args: Record<string, unknown> = {
|
||||
calendar_id: 'primary',
|
||||
time_min: timeMin,
|
||||
time_max: timeMax,
|
||||
single_events: true,
|
||||
order_by: 'startTime',
|
||||
};
|
||||
if (pageToken) {
|
||||
args.page_token = pageToken;
|
||||
}
|
||||
|
||||
const result = await executeAction(
|
||||
'GOOGLECALENDAR_FIND_EVENT',
|
||||
{
|
||||
connected_account_id: connectedAccountId,
|
||||
user_id: 'rowboat-user',
|
||||
version: 'latest',
|
||||
arguments: args,
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.successful || !result.data) {
|
||||
console.error('[Calendar] Failed to list events via Composio:', result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = result.data as Record<string, unknown>;
|
||||
// Composio may return events in different structures
|
||||
let events: Array<Record<string, unknown>> = [];
|
||||
|
||||
if (Array.isArray(data.items)) {
|
||||
events = data.items as Array<Record<string, unknown>>;
|
||||
} else if (Array.isArray(data.events)) {
|
||||
events = data.events as Array<Record<string, unknown>>;
|
||||
} else if (data.event_data && typeof data.event_data === 'object') {
|
||||
const nested = data.event_data as Record<string, unknown>;
|
||||
if (Array.isArray(nested.event_data)) {
|
||||
events = nested.event_data as Array<Record<string, unknown>>;
|
||||
} else if (Array.isArray(data.event_data)) {
|
||||
events = data.event_data as Array<Record<string, unknown>>;
|
||||
}
|
||||
} else if (Array.isArray(data)) {
|
||||
events = data as unknown as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
if (events.length === 0 && page === 0) {
|
||||
console.log('[Calendar] No events found in this window.');
|
||||
} else if (events.length > 0) {
|
||||
console.log(`[Calendar] Page ${page + 1}: found ${events.length} events.`);
|
||||
for (const event of events) {
|
||||
const eventId = event.id as string | undefined;
|
||||
if (eventId) {
|
||||
const saveResult = saveComposioEvent(event, SYNC_DIR);
|
||||
currentEventIds.add(eventId);
|
||||
|
||||
if (saveResult.changed) {
|
||||
await ensureRun();
|
||||
changedTitles.push(saveResult.title);
|
||||
if (saveResult.isNew) {
|
||||
newCount++;
|
||||
newEvents.push(event);
|
||||
} else {
|
||||
updatedCount++;
|
||||
updatedEvents.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for next page
|
||||
const nextToken = data.nextPageToken as string | undefined;
|
||||
if (nextToken) {
|
||||
pageToken = nextToken;
|
||||
console.log(`[Calendar] Fetching next page...`);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up events no longer in the window
|
||||
const deletedFiles = cleanUpOldFiles(currentEventIds, SYNC_DIR);
|
||||
let deletedCount = 0;
|
||||
if (deletedFiles.length > 0) {
|
||||
await ensureRun();
|
||||
deletedCount = deletedFiles.length;
|
||||
}
|
||||
|
||||
// Publish a single bundled event capturing all changes from this sync.
|
||||
await publishCalendarSyncEvent(newEvents, updatedEvents, deletedFiles);
|
||||
|
||||
// Log results if any changes were detected (run was started by ensureRun)
|
||||
if (run) {
|
||||
const r = run as ServiceRunContext;
|
||||
const totalChanges = newCount + updatedCount + deletedCount;
|
||||
const limitedTitles = limitEventItems(changedTitles);
|
||||
await serviceLogger.log({
|
||||
type: 'changes_identified',
|
||||
service: r.service,
|
||||
runId: r.runId,
|
||||
level: 'info',
|
||||
message: `Calendar updates: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`,
|
||||
counts: {
|
||||
newEvents: newCount,
|
||||
updatedEvents: updatedCount,
|
||||
deletedFiles: deletedCount,
|
||||
},
|
||||
items: limitedTitles.items,
|
||||
truncated: limitedTitles.truncated,
|
||||
});
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
service: r.service,
|
||||
runId: r.runId,
|
||||
level: 'info',
|
||||
message: `Calendar sync complete: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`,
|
||||
durationMs: Date.now() - r.startedAt,
|
||||
outcome: 'ok',
|
||||
summary: {
|
||||
newEvents: newCount,
|
||||
updatedEvents: updatedCount,
|
||||
deletedFiles: deletedCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Save state
|
||||
saveComposioState(STATE_FILE, new Date().toISOString());
|
||||
console.log(`[Calendar] Composio sync completed. ${newCount} new, ${updatedCount} updated, ${deletedCount} deleted.`);
|
||||
} catch (error) {
|
||||
console.error('[Calendar] Error during Composio sync:', error);
|
||||
const errRun = await ensureRun();
|
||||
await serviceLogger.log({
|
||||
type: 'error',
|
||||
service: errRun.service,
|
||||
runId: errRun.runId,
|
||||
level: 'error',
|
||||
message: 'Calendar sync error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
service: errRun.service,
|
||||
runId: errRun.runId,
|
||||
level: 'error',
|
||||
message: 'Calendar sync failed',
|
||||
durationMs: Date.now() - errRun.startedAt,
|
||||
outcome: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function init() {
|
||||
console.log("Starting Google Calendar & Notes Sync (TS)...");
|
||||
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const composioMode = await useComposioForGoogleCalendar();
|
||||
if (composioMode) {
|
||||
const isConnected = composioAccountsRepo.isConnected('googlecalendar');
|
||||
if (!isConnected) {
|
||||
console.log('[Calendar] Google Calendar not connected via Composio. Sleeping...');
|
||||
} else {
|
||||
await performSyncComposio();
|
||||
}
|
||||
const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPES);
|
||||
if (!hasCredentials) {
|
||||
console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping...");
|
||||
} else {
|
||||
// Check if credentials are available with required scopes
|
||||
const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPES);
|
||||
|
||||
if (!hasCredentials) {
|
||||
console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping...");
|
||||
} else {
|
||||
// Perform one sync
|
||||
await performSync(SYNC_DIR, LOOKBACK_DAYS);
|
||||
}
|
||||
await performSync(SYNC_DIR, LOOKBACK_DAYS);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in main loop:", error);
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ import { WorkDir } from '../config/config.js';
|
|||
import { GoogleClientFactory } from './google-client-factory.js';
|
||||
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||
import { limitEventItems } from './limit_event_items.js';
|
||||
import { executeAction, useComposioForGoogle } from '../composio/client.js';
|
||||
import { composioAccountsRepo } from '../composio/repo.js';
|
||||
import { createEvent } from './track/events.js';
|
||||
|
||||
// Configuration
|
||||
|
|
@ -225,7 +223,7 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri
|
|||
}
|
||||
}
|
||||
|
||||
function loadState(stateFile: string): { historyId?: string } {
|
||||
function loadState(stateFile: string): { historyId?: string; last_sync?: string } {
|
||||
if (fs.existsSync(stateFile)) {
|
||||
return JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
}
|
||||
|
|
@ -240,9 +238,24 @@ function saveState(historyId: string, stateFile: string) {
|
|||
}
|
||||
|
||||
async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) {
|
||||
console.log(`Performing full sync of last ${lookbackDays} days...`);
|
||||
const gmail = google.gmail({ version: 'v1', auth });
|
||||
|
||||
// If the state file holds a last_sync timestamp (e.g. left over from a
|
||||
// prior Composio sync, or from a previous successful native sync that
|
||||
// we're falling back to after a history.list 404), use that as the
|
||||
// floor instead of the default lookback. Carries forward Composio's
|
||||
// last_sync on first migration so we don't refetch the last 7 days.
|
||||
const state = loadState(stateFile);
|
||||
let pastDate: Date;
|
||||
if (state.last_sync) {
|
||||
pastDate = new Date(state.last_sync);
|
||||
console.log(`Performing full sync from last_sync=${state.last_sync}...`);
|
||||
} else {
|
||||
pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - lookbackDays);
|
||||
console.log(`Performing full sync of last ${lookbackDays} days...`);
|
||||
}
|
||||
|
||||
let run: ServiceRunContext | null = null;
|
||||
const ensureRun = async () => {
|
||||
if (!run) {
|
||||
|
|
@ -255,8 +268,6 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str
|
|||
};
|
||||
|
||||
try {
|
||||
const pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - lookbackDays);
|
||||
const dateQuery = pastDate.toISOString().split('T')[0].replace(/-/g, '/');
|
||||
|
||||
// Get History ID
|
||||
|
|
@ -498,386 +509,17 @@ async function performSync() {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Composio-based Sync ---
|
||||
|
||||
const COMPOSIO_LOOKBACK_DAYS = 7;
|
||||
|
||||
interface ComposioSyncState {
|
||||
last_sync: string; // ISO string
|
||||
}
|
||||
|
||||
function loadComposioState(stateFile: string): ComposioSyncState | null {
|
||||
if (fs.existsSync(stateFile)) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
if (data.last_sync) {
|
||||
return { last_sync: data.last_sync };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Gmail] Failed to load composio state:', e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveComposioState(stateFile: string, lastSync: string): void {
|
||||
fs.writeFileSync(stateFile, JSON.stringify({ last_sync: lastSync }, null, 2));
|
||||
}
|
||||
|
||||
function tryParseDate(dateStr: string): Date | null {
|
||||
const d = new Date(dateStr);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
interface ParsedMessage {
|
||||
from: string;
|
||||
date: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function parseMessageData(messageData: Record<string, unknown>): ParsedMessage {
|
||||
const headers = messageData.payload && typeof messageData.payload === 'object'
|
||||
? (messageData.payload as Record<string, unknown>).headers as Array<{ name: string; value: string }> | undefined
|
||||
: undefined;
|
||||
|
||||
const from = headers?.find(h => h.name === 'From')?.value || String(messageData.from || messageData.sender || 'Unknown');
|
||||
const date = headers?.find(h => h.name === 'Date')?.value || String(messageData.date || messageData.internalDate || 'Unknown');
|
||||
const subject = headers?.find(h => h.name === 'Subject')?.value || String(messageData.subject || '(No Subject)');
|
||||
|
||||
let body = '';
|
||||
|
||||
if (messageData.payload && typeof messageData.payload === 'object') {
|
||||
body = extractBodyFromPayload(messageData.payload as Record<string, unknown>);
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
if (typeof messageData.body === 'string') {
|
||||
body = messageData.body;
|
||||
} else if (typeof messageData.snippet === 'string') {
|
||||
body = messageData.snippet;
|
||||
} else if (typeof messageData.text === 'string') {
|
||||
body = messageData.text;
|
||||
}
|
||||
}
|
||||
|
||||
if (body && (body.includes('<html') || body.includes('<div') || body.includes('<p'))) {
|
||||
body = nhm.translate(body);
|
||||
}
|
||||
|
||||
if (body) {
|
||||
body = body.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
|
||||
}
|
||||
|
||||
return { from, date, subject, body };
|
||||
}
|
||||
|
||||
function extractBodyFromPayload(payload: Record<string, unknown>): string {
|
||||
const parts = payload.parts as Array<Record<string, unknown>> | undefined;
|
||||
|
||||
if (parts) {
|
||||
for (const part of parts) {
|
||||
const mimeType = part.mimeType as string | undefined;
|
||||
const bodyData = part.body && typeof part.body === 'object'
|
||||
? (part.body as Record<string, unknown>).data as string | undefined
|
||||
: undefined;
|
||||
|
||||
if ((mimeType === 'text/plain' || mimeType === 'text/html') && bodyData) {
|
||||
const decoded = Buffer.from(bodyData, 'base64').toString('utf-8');
|
||||
if (mimeType === 'text/html') {
|
||||
return nhm.translate(decoded);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
if (part.parts) {
|
||||
const result = extractBodyFromPayload(part as Record<string, unknown>);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bodyData = payload.body && typeof payload.body === 'object'
|
||||
? (payload.body as Record<string, unknown>).data as string | undefined
|
||||
: undefined;
|
||||
|
||||
if (bodyData) {
|
||||
const decoded = Buffer.from(bodyData, 'base64').toString('utf-8');
|
||||
const mimeType = payload.mimeType as string | undefined;
|
||||
if (mimeType === 'text/html') {
|
||||
return nhm.translate(decoded);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
interface ComposioThreadResult {
|
||||
synced: SyncedThread | null;
|
||||
newestIsoPlusOne: string | null;
|
||||
}
|
||||
|
||||
async function processThreadComposio(connectedAccountId: string, threadId: string, syncDir: string): Promise<ComposioThreadResult> {
|
||||
let threadResult;
|
||||
try {
|
||||
threadResult = await executeAction(
|
||||
'GMAIL_FETCH_MESSAGE_BY_THREAD_ID',
|
||||
{
|
||||
connected_account_id: connectedAccountId,
|
||||
user_id: 'rowboat-user',
|
||||
version: 'latest',
|
||||
arguments: { thread_id: threadId, user_id: 'me' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(`[Gmail] Skipping thread ${threadId} (fetch failed):`, error instanceof Error ? error.message : error);
|
||||
return { synced: null, newestIsoPlusOne: null };
|
||||
}
|
||||
|
||||
if (!threadResult.successful || !threadResult.data) {
|
||||
console.error(`[Gmail] Failed to fetch thread ${threadId}:`, threadResult.error);
|
||||
return { synced: null, newestIsoPlusOne: null };
|
||||
}
|
||||
|
||||
const data = threadResult.data as Record<string, unknown>;
|
||||
const messages = data.messages as Array<Record<string, unknown>> | undefined;
|
||||
|
||||
let newestDate: Date | null = null;
|
||||
let mdContent: string;
|
||||
let subjectForLog: string;
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
const parsed = parseMessageData(data);
|
||||
mdContent = `# ${parsed.subject}\n\n` +
|
||||
`**Thread ID:** ${threadId}\n` +
|
||||
`**Message Count:** 1\n\n---\n\n` +
|
||||
`### From: ${parsed.from}\n` +
|
||||
`**Date:** ${parsed.date}\n\n` +
|
||||
`${parsed.body}\n\n---\n\n`;
|
||||
subjectForLog = parsed.subject;
|
||||
newestDate = tryParseDate(parsed.date);
|
||||
} else {
|
||||
const firstParsed = parseMessageData(messages[0]);
|
||||
mdContent = `# ${firstParsed.subject}\n\n`;
|
||||
mdContent += `**Thread ID:** ${threadId}\n`;
|
||||
mdContent += `**Message Count:** ${messages.length}\n\n---\n\n`;
|
||||
|
||||
for (const msg of messages) {
|
||||
const parsed = parseMessageData(msg);
|
||||
mdContent += `### From: ${parsed.from}\n`;
|
||||
mdContent += `**Date:** ${parsed.date}\n\n`;
|
||||
mdContent += `${parsed.body}\n\n`;
|
||||
mdContent += `---\n\n`;
|
||||
|
||||
const msgDate = tryParseDate(parsed.date);
|
||||
if (msgDate && (!newestDate || msgDate > newestDate)) {
|
||||
newestDate = msgDate;
|
||||
}
|
||||
}
|
||||
subjectForLog = firstParsed.subject;
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
|
||||
console.log(`[Gmail] Synced Thread: ${subjectForLog} (${threadId})`);
|
||||
|
||||
const newestIsoPlusOne = newestDate ? new Date(newestDate.getTime() + 1000).toISOString() : null;
|
||||
return { synced: { threadId, markdown: mdContent }, newestIsoPlusOne };
|
||||
}
|
||||
|
||||
async function performSyncComposio() {
|
||||
const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments');
|
||||
const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json');
|
||||
|
||||
if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true });
|
||||
if (!fs.existsSync(ATTACHMENTS_DIR)) fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
|
||||
|
||||
const account = composioAccountsRepo.getAccount('gmail');
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
console.log('[Gmail] Gmail not connected via Composio. Skipping sync.');
|
||||
return;
|
||||
}
|
||||
|
||||
const connectedAccountId = account.id;
|
||||
|
||||
const state = loadComposioState(STATE_FILE);
|
||||
let afterEpochSeconds: number;
|
||||
|
||||
if (state) {
|
||||
afterEpochSeconds = Math.floor(new Date(state.last_sync).getTime() / 1000);
|
||||
console.log(`[Gmail] Syncing messages since ${state.last_sync}...`);
|
||||
} else {
|
||||
const pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - COMPOSIO_LOOKBACK_DAYS);
|
||||
afterEpochSeconds = Math.floor(pastDate.getTime() / 1000);
|
||||
console.log(`[Gmail] First sync - fetching last ${COMPOSIO_LOOKBACK_DAYS} days...`);
|
||||
}
|
||||
|
||||
let run: ServiceRunContext | null = null;
|
||||
const ensureRun = async () => {
|
||||
if (!run) {
|
||||
run = await serviceLogger.startRun({
|
||||
service: 'gmail',
|
||||
message: 'Syncing Gmail (Composio)',
|
||||
trigger: 'timer',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const allThreadIds: string[] = [];
|
||||
let pageToken: string | undefined;
|
||||
|
||||
do {
|
||||
const params: Record<string, unknown> = {
|
||||
query: `after:${afterEpochSeconds}`,
|
||||
max_results: 20,
|
||||
user_id: 'me',
|
||||
};
|
||||
if (pageToken) {
|
||||
params.page_token = pageToken;
|
||||
}
|
||||
|
||||
const result = await executeAction(
|
||||
'GMAIL_LIST_THREADS',
|
||||
{
|
||||
connected_account_id: connectedAccountId,
|
||||
user_id: 'rowboat-user',
|
||||
version: 'latest',
|
||||
arguments: params,
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.successful || !result.data) {
|
||||
console.error('[Gmail] Failed to list threads:', result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = result.data as Record<string, unknown>;
|
||||
const threads = data.threads as Array<Record<string, unknown>> | undefined;
|
||||
|
||||
if (threads && threads.length > 0) {
|
||||
for (const thread of threads) {
|
||||
const threadId = thread.id as string | undefined;
|
||||
if (threadId) {
|
||||
allThreadIds.push(threadId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pageToken = data.nextPageToken as string | undefined;
|
||||
} while (pageToken);
|
||||
|
||||
if (allThreadIds.length === 0) {
|
||||
console.log('[Gmail] No new threads.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Gmail] Found ${allThreadIds.length} threads to sync.`);
|
||||
|
||||
await ensureRun();
|
||||
const limitedThreads = limitEventItems(allThreadIds);
|
||||
await serviceLogger.log({
|
||||
type: 'changes_identified',
|
||||
service: run!.service,
|
||||
runId: run!.runId,
|
||||
level: 'info',
|
||||
message: `Found ${allThreadIds.length} thread${allThreadIds.length === 1 ? '' : 's'} to sync`,
|
||||
counts: { threads: allThreadIds.length },
|
||||
items: limitedThreads.items,
|
||||
truncated: limitedThreads.truncated,
|
||||
});
|
||||
|
||||
// Process oldest first so high-water mark advances chronologically
|
||||
allThreadIds.reverse();
|
||||
|
||||
let highWaterMark: string | null = state?.last_sync ?? null;
|
||||
let processedCount = 0;
|
||||
const synced: SyncedThread[] = [];
|
||||
for (const threadId of allThreadIds) {
|
||||
// Re-check connection in case user disconnected mid-sync
|
||||
if (!composioAccountsRepo.isConnected('gmail')) {
|
||||
console.log('[Gmail] Account disconnected during sync. Stopping.');
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const result = await processThreadComposio(connectedAccountId, threadId, SYNC_DIR);
|
||||
processedCount++;
|
||||
|
||||
if (result.synced) synced.push(result.synced);
|
||||
|
||||
if (result.newestIsoPlusOne) {
|
||||
if (!highWaterMark || new Date(result.newestIsoPlusOne) > new Date(highWaterMark)) {
|
||||
highWaterMark = result.newestIsoPlusOne;
|
||||
}
|
||||
saveComposioState(STATE_FILE, highWaterMark);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Gmail] Error processing thread ${threadId}, skipping:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
await publishGmailSyncEvent(synced);
|
||||
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
service: run!.service,
|
||||
runId: run!.runId,
|
||||
level: 'info',
|
||||
message: `Gmail sync complete: ${processedCount}/${allThreadIds.length} thread${allThreadIds.length === 1 ? '' : 's'}`,
|
||||
durationMs: Date.now() - run!.startedAt,
|
||||
outcome: 'ok',
|
||||
summary: { threads: processedCount },
|
||||
});
|
||||
|
||||
console.log(`[Gmail] Sync completed. Processed ${processedCount}/${allThreadIds.length} threads.`);
|
||||
} catch (error) {
|
||||
console.error('[Gmail] Error during sync:', error);
|
||||
await ensureRun();
|
||||
await serviceLogger.log({
|
||||
type: 'error',
|
||||
service: run!.service,
|
||||
runId: run!.runId,
|
||||
level: 'error',
|
||||
message: 'Gmail sync error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
service: run!.service,
|
||||
runId: run!.runId,
|
||||
level: 'error',
|
||||
message: 'Gmail sync failed',
|
||||
durationMs: Date.now() - run!.startedAt,
|
||||
outcome: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function init() {
|
||||
console.log("Starting Gmail Sync (TS)...");
|
||||
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const composioMode = await useComposioForGoogle();
|
||||
if (composioMode) {
|
||||
const isConnected = composioAccountsRepo.isConnected('gmail');
|
||||
if (!isConnected) {
|
||||
console.log('[Gmail] Gmail not connected via Composio. Sleeping...');
|
||||
} else {
|
||||
await performSyncComposio();
|
||||
}
|
||||
const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE);
|
||||
if (!hasCredentials) {
|
||||
console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping...");
|
||||
} else {
|
||||
// Check if credentials are available with required scopes
|
||||
const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE);
|
||||
|
||||
if (!hasCredentials) {
|
||||
console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping...");
|
||||
} else {
|
||||
// Perform one sync
|
||||
await performSync();
|
||||
}
|
||||
await performSync();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in main loop:", error);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { WorkDir } from '../config/config.js';
|
|||
import { createRun, createMessage } from '../runs/runs.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import { bus } from '../runs/bus.js';
|
||||
import { waitForRunCompletion } from '../agents/utils.js';
|
||||
import { getErrorDetails, waitForRunCompletion } from '../agents/utils.js';
|
||||
import { serviceLogger } from '../services/service_logger.js';
|
||||
import { limitEventItems } from './limit_event_items.js';
|
||||
import {
|
||||
|
|
@ -86,6 +86,8 @@ async function tagNoteBatch(
|
|||
const run = await createRun({
|
||||
agentId: NOTE_TAGGING_AGENT,
|
||||
model: await getKgModel(),
|
||||
useCase: 'knowledge_sync',
|
||||
subUseCase: 'tag_notes',
|
||||
});
|
||||
|
||||
let message = `Tag the following ${files.length} knowledge notes by prepending YAML frontmatter with appropriate tags.\n\n`;
|
||||
|
|
@ -123,8 +125,11 @@ async function tagNoteBatch(
|
|||
});
|
||||
|
||||
await createMessage(run.id, message);
|
||||
await waitForRunCompletion(run.id);
|
||||
unsubscribe();
|
||||
try {
|
||||
await waitForRunCompletion(run.id, { throwOnError: true });
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
return { runId: run.id, filesEdited };
|
||||
}
|
||||
|
|
@ -167,6 +172,7 @@ export async function processUntaggedNotes(): Promise<void> {
|
|||
const totalBatches = Math.ceil(untagged.length / BATCH_SIZE);
|
||||
let totalEdited = 0;
|
||||
let hadError = false;
|
||||
let failedBatches = 0;
|
||||
|
||||
for (let i = 0; i < untagged.length; i += BATCH_SIZE) {
|
||||
const batchPaths = untagged.slice(i, i + BATCH_SIZE);
|
||||
|
|
@ -215,14 +221,16 @@ export async function processUntaggedNotes(): Promise<void> {
|
|||
console.log(`[NoteTagging] Batch ${batchNumber}/${totalBatches} complete, ${result.filesEdited.size} files tagged`);
|
||||
} catch (error) {
|
||||
hadError = true;
|
||||
failedBatches++;
|
||||
const errorDetails = getErrorDetails(error);
|
||||
console.error(`[NoteTagging] Error processing batch ${batchNumber}:`, error);
|
||||
await serviceLogger.log({
|
||||
type: 'error',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: 'error',
|
||||
message: `Error processing batch ${batchNumber}`,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
message: `Note tagging batch ${batchNumber}/${totalBatches} failed`,
|
||||
error: errorDetails,
|
||||
context: { batchNumber },
|
||||
});
|
||||
}
|
||||
|
|
@ -236,12 +244,15 @@ export async function processUntaggedNotes(): Promise<void> {
|
|||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: hadError ? 'error' : 'info',
|
||||
message: `Note tagging complete: ${totalEdited} notes tagged`,
|
||||
message: hadError
|
||||
? `Note tagging finished with errors: ${totalEdited} notes tagged`
|
||||
: `Note tagging complete: ${totalEdited} notes tagged`,
|
||||
durationMs: Date.now() - run.startedAt,
|
||||
outcome: hadError ? 'error' : 'ok',
|
||||
summary: {
|
||||
totalNotes: untagged.length,
|
||||
notesTagged: totalEdited,
|
||||
failedBatches,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,122 @@ export async function fetch(filePath: string, trackId: string): Promise<z.infer<
|
|||
return blocks.find(b => b.track.trackId === trackId) ?? null;
|
||||
}
|
||||
|
||||
type TrackNoteSummary = {
|
||||
path: string;
|
||||
trackCount: number;
|
||||
createdAt: string | null;
|
||||
lastRunAt: string | null;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
async function summarizeTrackNote(
|
||||
filePath: string,
|
||||
tracks: z.infer<typeof TrackStateSchema>[],
|
||||
): Promise<TrackNoteSummary | null> {
|
||||
if (tracks.length === 0) return null;
|
||||
|
||||
const stats = await fs.stat(absPath(filePath));
|
||||
const createdMs = stats.birthtimeMs > 0 ? stats.birthtimeMs : stats.ctimeMs;
|
||||
|
||||
let latestRunAt: string | null = null;
|
||||
let latestRunMs = -1;
|
||||
for (const { track } of tracks) {
|
||||
if (!track.lastRunAt) continue;
|
||||
const candidateMs = Date.parse(track.lastRunAt);
|
||||
if (Number.isNaN(candidateMs) || candidateMs <= latestRunMs) continue;
|
||||
latestRunMs = candidateMs;
|
||||
latestRunAt = track.lastRunAt;
|
||||
}
|
||||
|
||||
return {
|
||||
path: `knowledge/${filePath}`,
|
||||
trackCount: tracks.length,
|
||||
createdAt: createdMs > 0 ? new Date(createdMs).toISOString() : null,
|
||||
lastRunAt: latestRunAt,
|
||||
isActive: tracks.every(({ track }) => track.active !== false),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listNotesWithTracks(): Promise<TrackNoteSummary[]> {
|
||||
async function walk(relativeDir = ''): Promise<string[]> {
|
||||
const dirPath = absPath(relativeDir);
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
|
||||
const childRelPath = relativeDir
|
||||
? path.posix.join(relativeDir, entry.name)
|
||||
: entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await walk(childRelPath));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
||||
files.push(childRelPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const markdownFiles = await walk();
|
||||
const notes = await Promise.all(markdownFiles.map(async (relativePath) => {
|
||||
try {
|
||||
const tracks = await fetchAll(relativePath);
|
||||
return await summarizeTrackNote(relativePath, tracks);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
|
||||
return notes
|
||||
.filter((note): note is TrackNoteSummary => note !== null)
|
||||
.sort((a, b) => {
|
||||
const aName = path.basename(a.path, '.md').toLowerCase();
|
||||
const bName = path.basename(b.path, '.md').toLowerCase();
|
||||
if (aName !== bName) return aName.localeCompare(bName);
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
}
|
||||
|
||||
export async function setNoteTracksActive(filePath: string, active: boolean): Promise<TrackNoteSummary | null> {
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const blocks = await fetchAll(filePath);
|
||||
if (blocks.length === 0) return null;
|
||||
|
||||
const alreadyMatches = blocks.every(({ track }) => (track.active !== false) === active);
|
||||
if (alreadyMatches) {
|
||||
return summarizeTrackNote(filePath, blocks);
|
||||
}
|
||||
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const updatedBlocks = blocks
|
||||
.map((block) => ({
|
||||
...block,
|
||||
track: { ...block.track, active },
|
||||
}))
|
||||
.sort((a, b) => b.fenceStart - a.fenceStart);
|
||||
|
||||
for (const block of updatedBlocks) {
|
||||
const yaml = stringifyYaml(block.track).trimEnd();
|
||||
const yamlLines = yaml ? yaml.split('\n') : [];
|
||||
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
|
||||
}
|
||||
|
||||
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
|
||||
return summarizeTrackNote(filePath, updatedBlocks);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a track block and return its canonical YAML string (or null if not found).
|
||||
* Useful for IPC handlers that need to return the fresh YAML without taking a
|
||||
|
|
@ -196,4 +312,4 @@ export async function deleteTrackBlock(filePath: string, trackId: string): Promi
|
|||
|
||||
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { trackBlock, PrefixLogger } from '@x/shared';
|
|||
import type { KnowledgeEvent } from '@x/shared/dist/track-block.js';
|
||||
import { createProvider } from '../../models/models.js';
|
||||
import { getDefaultModelAndProvider, getTrackBlockModel, resolveProviderConfig } from '../../models/defaults.js';
|
||||
import { captureLlmUsage } from '../../analytics/usage.js';
|
||||
|
||||
const log = new PrefixLogger('TrackRouting');
|
||||
|
||||
|
|
@ -34,10 +35,14 @@ Rules:
|
|||
- For each candidate, return BOTH trackId and filePath exactly as given. trackIds are not globally unique.`;
|
||||
|
||||
async function resolveModel() {
|
||||
const model = await getTrackBlockModel();
|
||||
const modelId = await getTrackBlockModel();
|
||||
const { provider } = await getDefaultModelAndProvider();
|
||||
const config = await resolveProviderConfig(provider);
|
||||
return createProvider(config).languageModel(model);
|
||||
return {
|
||||
model: createProvider(config).languageModel(modelId),
|
||||
modelId,
|
||||
providerName: provider,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedTrack[]): string {
|
||||
|
|
@ -84,19 +89,26 @@ export async function findCandidates(
|
|||
|
||||
log.log(`Routing event ${event.id} against ${filtered.length} track(s)`);
|
||||
|
||||
const model = await resolveModel();
|
||||
const { model, modelId, providerName } = await resolveModel();
|
||||
const candidateKeys = new Set<string>();
|
||||
|
||||
for (let i = 0; i < filtered.length; i += BATCH_SIZE) {
|
||||
const batch = filtered.slice(i, i + BATCH_SIZE);
|
||||
try {
|
||||
const { object } = await generateObject({
|
||||
const result = await generateObject({
|
||||
model,
|
||||
system: ROUTING_SYSTEM_PROMPT,
|
||||
prompt: buildRoutingPrompt(event, batch),
|
||||
schema: trackBlock.Pass1OutputSchema,
|
||||
});
|
||||
for (const c of object.candidates) {
|
||||
captureLlmUsage({
|
||||
useCase: 'track_block',
|
||||
subUseCase: 'routing',
|
||||
model: modelId,
|
||||
provider: providerName,
|
||||
usage: result.usage,
|
||||
});
|
||||
for (const c of result.object.candidates) {
|
||||
candidateKeys.add(trackKey(c.trackId, c.filePath));
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -263,6 +263,7 @@ You have the full workspace toolkit. Quick reference for common cases:
|
|||
- **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if a track aggregates from attached files.
|
||||
- **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when a track needs structured data from a connected service the user has authorized.
|
||||
- **\`browser-control\`** — only when a required source has no API / search alternative and requires JS rendering.
|
||||
- **\`notify-user\`** — send a native desktop notification when this run produces something time-sensitive (threshold breach, urgent change, "the thing the user asked you to watch for just happened"). Skip it for routine refreshes — the note itself is the artifact. Load the \`notify-user\` skill via \`loadSkill\` for parameters and \`rowboat://\` deep-link shapes (so the click lands on the right note/view).
|
||||
|
||||
# The Knowledge Graph
|
||||
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ export async function triggerTrackUpdate(
|
|||
agentId: 'track-run',
|
||||
model,
|
||||
...(track.track.provider ? { provider: track.track.provider } : {}),
|
||||
useCase: 'track_block',
|
||||
subUseCase: 'run',
|
||||
});
|
||||
|
||||
// Set lastRunAt and lastRunId immediately (before agent executes) so
|
||||
|
|
|
|||
132
apps/x/packages/core/src/migrations/composio-google-migration.ts
Normal file
132
apps/x/packages/core/src/migrations/composio-google-migration.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { isSignedIn } from '../account/account.js';
|
||||
import { composioAccountsRepo } from '../composio/repo.js';
|
||||
import { deleteConnectedAccount } from '../composio/client.js';
|
||||
import container from '../di/container.js';
|
||||
import { IOAuthRepo } from '../auth/repo.js';
|
||||
|
||||
/**
|
||||
* One-time migration that moves Composio-connected Gmail/Calendar users
|
||||
* to the native rowboat-mode Google OAuth flow.
|
||||
*
|
||||
* Triggered by the renderer on app launch and after Rowboat sign-in. The
|
||||
* single guard is `dismissed_at` in the migration state file — once set,
|
||||
* none of the migration's side effects run again. This protects users who
|
||||
* later re-add Composio Google for non-sync purposes (e.g. a tool that
|
||||
* happens to use the Gmail toolkit) from having that connection blown
|
||||
* away on a future launch.
|
||||
*/
|
||||
|
||||
const STATE_FILE = path.join(WorkDir, 'config', 'composio-google-migration.json');
|
||||
|
||||
const ZState = z.object({
|
||||
dismissed_at: z.string().min(1).optional(),
|
||||
});
|
||||
type State = z.infer<typeof ZState>;
|
||||
|
||||
function loadState(): State {
|
||||
try {
|
||||
if (fs.existsSync(STATE_FILE)) {
|
||||
const raw = fs.readFileSync(STATE_FILE, 'utf-8');
|
||||
return ZState.parse(JSON.parse(raw));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[composio-google-migration] failed to load state:', error);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function saveState(state: State): void {
|
||||
const dir = path.dirname(STATE_FILE);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
function markDismissed(): void {
|
||||
saveState({ dismissed_at: new Date().toISOString() });
|
||||
}
|
||||
|
||||
async function disconnectComposioGoogle(): Promise<void> {
|
||||
for (const slug of ['gmail', 'googlecalendar'] as const) {
|
||||
const account = composioAccountsRepo.getAccount(slug);
|
||||
if (!account?.id) continue;
|
||||
|
||||
try {
|
||||
await deleteConnectedAccount(account.id);
|
||||
console.log(`[composio-google-migration] composio: deleted ${slug} (${account.id})`);
|
||||
} catch (error) {
|
||||
// Best-effort — logged but doesn't block the local cleanup.
|
||||
console.warn(`[composio-google-migration] composio delete failed for ${slug}:`, error);
|
||||
}
|
||||
|
||||
try {
|
||||
composioAccountsRepo.deleteAccount(slug);
|
||||
} catch (error) {
|
||||
console.warn(`[composio-google-migration] local delete failed for ${slug}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupCalendarComposioState(): void {
|
||||
const file = path.join(WorkDir, 'calendar_sync', 'composio_state.json');
|
||||
try {
|
||||
if (fs.existsSync(file)) {
|
||||
fs.unlinkSync(file);
|
||||
console.log('[composio-google-migration] removed stale calendar composio_state.json');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[composio-google-migration] failed to remove composio_state.json:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the user qualifies for the migration. If they do, atomically
|
||||
* mark `dismissed_at`, fire-and-forget the Composio disconnect, and return
|
||||
* `{shouldShow: true}` so the renderer can show the modal.
|
||||
*
|
||||
* Idempotent: subsequent calls return `{shouldShow: false}` once `dismissed_at`
|
||||
* is set, regardless of whether the modal was actually shown or the user
|
||||
* completed the OAuth flow.
|
||||
*/
|
||||
export async function qualifyAndDisconnectComposioGoogle(): Promise<{ shouldShow: boolean }> {
|
||||
// Rule 4 — already processed
|
||||
const state = loadState();
|
||||
if (state.dismissed_at) {
|
||||
return { shouldShow: false };
|
||||
}
|
||||
|
||||
// Rule 1 — must be signed in to Rowboat
|
||||
if (!(await isSignedIn())) {
|
||||
return { shouldShow: false };
|
||||
}
|
||||
|
||||
// Rule 3 — already on native rowboat-mode Google → silently mark dismissed
|
||||
// (so we stop re-checking) and bail before touching Composio state.
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
const googleConnection = await oauthRepo.read('google');
|
||||
if (googleConnection.tokens && googleConnection.mode === 'rowboat') {
|
||||
markDismissed();
|
||||
return { shouldShow: false };
|
||||
}
|
||||
|
||||
// Rule 2 — must have at least one Composio Google toolkit connected
|
||||
const hasGmail = composioAccountsRepo.isConnected('gmail');
|
||||
const hasCalendar = composioAccountsRepo.isConnected('googlecalendar');
|
||||
if (!hasGmail && !hasCalendar) {
|
||||
return { shouldShow: false };
|
||||
}
|
||||
|
||||
// All rules pass. Mark dismissed atomically before any side effects so
|
||||
// a crash mid-migration leaves us in a deterministic post-migration state.
|
||||
markDismissed();
|
||||
|
||||
// Fire-and-forget: disconnect Composio Google + clean up the stale
|
||||
// calendar state file. Both are best-effort.
|
||||
void disconnectComposioGoogle();
|
||||
cleanupCalendarComposioState();
|
||||
|
||||
return { shouldShow: true };
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import container from "../di/container.js";
|
|||
|
||||
const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4";
|
||||
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
|
||||
const SIGNED_IN_KG_MODEL = "anthropic/claude-haiku-4.5";
|
||||
const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite-preview";
|
||||
const SIGNED_IN_TRACK_BLOCK_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ async function runAgent(agentName: string): Promise<void> {
|
|||
const run = await createRun({
|
||||
agentId: agentName,
|
||||
model: await getKgModel(),
|
||||
useCase: 'knowledge_sync',
|
||||
subUseCase: 'pre_built',
|
||||
});
|
||||
|
||||
// Build trigger message with user context
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import path from "path";
|
|||
import fsp from "fs/promises";
|
||||
import fs from "fs";
|
||||
import readline from "readline";
|
||||
import { Run, RunEvent, StartEvent, CreateRunOptions, ListRunsResponse, MessageEvent } from "@x/shared/dist/runs.js";
|
||||
import { Run, RunEvent, StartEvent, ListRunsResponse, MessageEvent, UseCase } from "@x/shared/dist/runs.js";
|
||||
import { getDefaultModelAndProvider } from "../models/defaults.js";
|
||||
|
||||
/**
|
||||
|
|
@ -24,7 +24,13 @@ const LegacyStartEvent = StartEvent.extend({
|
|||
});
|
||||
const ReadRunEvent = RunEvent.or(LegacyStartEvent);
|
||||
|
||||
export type CreateRunRepoOptions = Required<z.infer<typeof CreateRunOptions>>;
|
||||
export type CreateRunRepoOptions = {
|
||||
agentId: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
useCase: z.infer<typeof UseCase>;
|
||||
subUseCase?: string;
|
||||
};
|
||||
|
||||
export interface IRunsRepo {
|
||||
create(options: CreateRunRepoOptions): Promise<z.infer<typeof Run>>;
|
||||
|
|
@ -187,6 +193,8 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
agentName: options.agentId,
|
||||
model: options.model,
|
||||
provider: options.provider,
|
||||
useCase: options.useCase,
|
||||
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
||||
subflow: [],
|
||||
ts,
|
||||
};
|
||||
|
|
@ -197,6 +205,8 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
agentId: options.agentId,
|
||||
model: options.model,
|
||||
provider: options.provider,
|
||||
useCase: options.useCase,
|
||||
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
||||
log: [start],
|
||||
};
|
||||
}
|
||||
|
|
@ -230,6 +240,8 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
agentId: start.agentName,
|
||||
model: start.model,
|
||||
provider: start.provider,
|
||||
...(start.useCase ? { useCase: start.useCase } : {}),
|
||||
...(start.subUseCase ? { subUseCase: start.subUseCase } : {}),
|
||||
log: events,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,15 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
|
|||
const defaults = await getDefaultModelAndProvider();
|
||||
const model = opts.model ?? agent.model ?? defaults.model;
|
||||
const provider = opts.provider ?? agent.provider ?? defaults.provider;
|
||||
const useCase = opts.useCase ?? "copilot_chat";
|
||||
|
||||
const run = await repo.create({ agentId: opts.agentId, model, provider });
|
||||
const run = await repo.create({
|
||||
agentId: opts.agentId,
|
||||
model,
|
||||
provider,
|
||||
useCase,
|
||||
...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}),
|
||||
});
|
||||
await bus.publish(run.log[0]);
|
||||
return run;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,6 +116,12 @@ export const BrowserControlInputSchema = z.object({
|
|||
}
|
||||
});
|
||||
|
||||
export const SuggestedBrowserSkillSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
path: z.string(),
|
||||
});
|
||||
|
||||
export const BrowserControlResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
action: BrowserControlActionSchema,
|
||||
|
|
@ -123,6 +129,7 @@ export const BrowserControlResultSchema = z.object({
|
|||
error: z.string().optional(),
|
||||
browser: BrowserStateSchema,
|
||||
page: BrowserPageSnapshotSchema.optional(),
|
||||
suggestedSkills: z.array(SuggestedBrowserSkillSchema).optional(),
|
||||
});
|
||||
|
||||
export type BrowserTabState = z.infer<typeof BrowserTabStateSchema>;
|
||||
|
|
@ -132,3 +139,4 @@ export type BrowserPageSnapshot = z.infer<typeof BrowserPageSnapshotSchema>;
|
|||
export type BrowserControlAction = z.infer<typeof BrowserControlActionSchema>;
|
||||
export type BrowserControlInput = z.infer<typeof BrowserControlInputSchema>;
|
||||
export type BrowserControlResult = z.infer<typeof BrowserControlResultSchema>;
|
||||
export type SuggestedBrowserSkill = z.infer<typeof SuggestedBrowserSkillSchema>;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@ const ipcSchemas = {
|
|||
electron: z.string(),
|
||||
}),
|
||||
},
|
||||
'analytics:bootstrap': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
installationId: z.string(),
|
||||
apiUrl: z.string(),
|
||||
}),
|
||||
},
|
||||
'workspace:getRoot': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
|
|
@ -292,6 +299,28 @@ const ipcSchemas = {
|
|||
}),
|
||||
res: z.null(),
|
||||
},
|
||||
'app:openUrl': {
|
||||
req: z.object({
|
||||
url: z.string(),
|
||||
}),
|
||||
res: z.null(),
|
||||
},
|
||||
'app:takeMeetingNotes': {
|
||||
req: z.object({
|
||||
// Pass the raw calendar event JSON through; renderer adapts to its existing flow.
|
||||
event: z.unknown(),
|
||||
// When true, the renderer should also open the meeting URL (Zoom/Meet/etc.)
|
||||
// in addition to triggering the take-notes flow.
|
||||
openMeeting: z.boolean().optional(),
|
||||
}),
|
||||
res: z.null(),
|
||||
},
|
||||
'app:consumePendingDeepLink': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
url: z.string().nullable(),
|
||||
}),
|
||||
},
|
||||
'granola:getConfig': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
|
|
@ -400,16 +429,10 @@ const ipcSchemas = {
|
|||
toolkits: z.array(z.string()),
|
||||
}),
|
||||
},
|
||||
'composio:use-composio-for-google': {
|
||||
'migration:check-composio-google': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
enabled: z.boolean(),
|
||||
}),
|
||||
},
|
||||
'composio:use-composio-for-google-calendar': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
enabled: z.boolean(),
|
||||
shouldShow: z.boolean(),
|
||||
}),
|
||||
},
|
||||
'composio:didConnect': {
|
||||
|
|
@ -639,6 +662,35 @@ const ipcSchemas = {
|
|||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:setNoteActive': {
|
||||
req: z.object({
|
||||
path: RelPath,
|
||||
active: z.boolean(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
note: z.object({
|
||||
path: RelPath,
|
||||
trackCount: z.number().int().positive(),
|
||||
createdAt: z.string().nullable(),
|
||||
lastRunAt: z.string().nullable(),
|
||||
isActive: z.boolean(),
|
||||
}).optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:listNotes': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
notes: z.array(z.object({
|
||||
path: RelPath,
|
||||
trackCount: z.number().int().positive(),
|
||||
createdAt: z.string().nullable(),
|
||||
lastRunAt: z.string().nullable(),
|
||||
isActive: z.boolean(),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
// Embedded browser (WebContentsView) channels
|
||||
'browser:setBounds': {
|
||||
req: z.object({
|
||||
|
|
|
|||
|
|
@ -21,6 +21,15 @@ export const StartEvent = BaseRunEvent.extend({
|
|||
agentName: z.string(),
|
||||
model: z.string(),
|
||||
provider: z.string(),
|
||||
// useCase/subUseCase tag the run for analytics. Optional on read so legacy
|
||||
// run files written before these fields existed still parse cleanly.
|
||||
useCase: z.enum([
|
||||
"copilot_chat",
|
||||
"track_block",
|
||||
"meeting_note",
|
||||
"knowledge_sync",
|
||||
]).optional(),
|
||||
subUseCase: z.string().optional(),
|
||||
});
|
||||
|
||||
export const SpawnSubFlowEvent = BaseRunEvent.extend({
|
||||
|
|
@ -118,6 +127,13 @@ export const AskHumanResponsePayload = AskHumanResponseEvent.pick({
|
|||
response: true,
|
||||
});
|
||||
|
||||
export const UseCase = z.enum([
|
||||
"copilot_chat",
|
||||
"track_block",
|
||||
"meeting_note",
|
||||
"knowledge_sync",
|
||||
]);
|
||||
|
||||
export const Run = z.object({
|
||||
id: z.string(),
|
||||
title: z.string().optional(),
|
||||
|
|
@ -125,6 +141,8 @@ export const Run = z.object({
|
|||
agentId: z.string(),
|
||||
model: z.string(),
|
||||
provider: z.string(),
|
||||
useCase: UseCase.optional(),
|
||||
subUseCase: z.string().optional(),
|
||||
log: z.array(RunEvent),
|
||||
});
|
||||
|
||||
|
|
@ -142,4 +160,6 @@ export const CreateRunOptions = z.object({
|
|||
agentId: z.string(),
|
||||
model: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
useCase: UseCase.optional(),
|
||||
subUseCase: z.string().optional(),
|
||||
});
|
||||
|
|
|
|||
13
apps/x/pnpm-lock.yaml
generated
13
apps/x/pnpm-lock.yaml
generated
|
|
@ -404,6 +404,9 @@ importers:
|
|||
pdf-parse:
|
||||
specifier: ^2.4.5
|
||||
version: 2.4.5
|
||||
posthog-node:
|
||||
specifier: ^4.18.0
|
||||
version: 4.18.0
|
||||
react:
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3
|
||||
|
|
@ -6471,6 +6474,10 @@ packages:
|
|||
posthog-js@1.332.0:
|
||||
resolution: {integrity: sha512-w3+sL+IFK4mpfFmgTW7On8cR+z34pre+SOewx+eHZQSYF9RYqXsLIhrxagWbQKkowPd4tCwUHrkS1+VHsjnPqA==}
|
||||
|
||||
posthog-node@4.18.0:
|
||||
resolution: {integrity: sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw==}
|
||||
engines: {node: '>=15.0.0'}
|
||||
|
||||
postject@1.0.0-alpha.6:
|
||||
resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
|
@ -15203,6 +15210,12 @@ snapshots:
|
|||
query-selector-shadow-dom: 1.0.1
|
||||
web-vitals: 4.2.4
|
||||
|
||||
posthog-node@4.18.0:
|
||||
dependencies:
|
||||
axios: 1.13.2
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
postject@1.0.0-alpha.6:
|
||||
dependencies:
|
||||
commander: 9.5.0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue