mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-24 20:28:16 +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 |
|
| Feature | Doc |
|
||||||
|---------|-----|
|
|---------|-----|
|
||||||
| Track Blocks — auto-updating note content (scheduled / event-driven / manual), Copilot skill, prompts catalog | `apps/x/TRACKS.md` |
|
| 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
|
## 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
|
// Replace import.meta.url directly with our polyfill variable
|
||||||
define: {
|
define: {
|
||||||
'import.meta.url': '__import_meta_url',
|
'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
|
icon: './icons/icon', // .icns extension added automatically
|
||||||
appBundleId: 'com.rowboat.app',
|
appBundleId: 'com.rowboat.app',
|
||||||
appCategoryType: 'public.app-category.productivity',
|
appCategoryType: 'public.app-category.productivity',
|
||||||
|
protocols: [
|
||||||
|
{ name: 'Rowboat', schemes: ['rowboat'] },
|
||||||
|
],
|
||||||
extendInfo: {
|
extendInfo: {
|
||||||
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)',
|
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 { 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 { browserViewManager } from './view.js';
|
||||||
import { normalizeNavigationTarget } from './navigation.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(
|
function buildSuccessResult(
|
||||||
action: BrowserControlAction,
|
action: BrowserControlAction,
|
||||||
message: string,
|
message: string,
|
||||||
|
|
@ -52,11 +68,13 @@ export class ElectronBrowserControlService implements IBrowserControlService {
|
||||||
}
|
}
|
||||||
await browserViewManager.ensureActiveTabReady(signal);
|
await browserViewManager.ensureActiveTabReady(signal);
|
||||||
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
|
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
|
||||||
return buildSuccessResult(
|
const suggestedSkills = await getSuggestedSkills(page?.url);
|
||||||
|
const success = buildSuccessResult(
|
||||||
'new-tab',
|
'new-tab',
|
||||||
target ? `Opened a new tab for ${target}.` : 'Opened a new tab.',
|
target ? `Opened a new tab for ${target}.` : 'Opened a new tab.',
|
||||||
page,
|
page,
|
||||||
);
|
);
|
||||||
|
return suggestedSkills ? { ...success, suggestedSkills } : success;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'switch-tab': {
|
case 'switch-tab': {
|
||||||
|
|
@ -99,7 +117,9 @@ export class ElectronBrowserControlService implements IBrowserControlService {
|
||||||
}
|
}
|
||||||
await browserViewManager.ensureActiveTabReady(signal);
|
await browserViewManager.ensureActiveTabReady(signal);
|
||||||
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
|
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': {
|
case 'back': {
|
||||||
|
|
@ -140,7 +160,9 @@ export class ElectronBrowserControlService implements IBrowserControlService {
|
||||||
if (!result.ok || !result.page) {
|
if (!result.ok || !result.page) {
|
||||||
return buildErrorResult('read-page', result.error ?? 'Failed to read the current 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': {
|
case 'click': {
|
||||||
|
|
|
||||||
|
|
@ -109,19 +109,62 @@ export class BrowserViewManager extends EventEmitter {
|
||||||
private visible = false;
|
private visible = false;
|
||||||
private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 };
|
private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 };
|
||||||
private snapshotCache = new Map<string, CachedSnapshot>();
|
private snapshotCache = new Map<string, CachedSnapshot>();
|
||||||
|
private cleanupWindowListeners: (() => void) | null = null;
|
||||||
|
|
||||||
attach(window: BrowserWindow): void {
|
attach(window: BrowserWindow): void {
|
||||||
|
this.cleanupWindowListeners?.();
|
||||||
|
this.cleanupWindowListeners = null;
|
||||||
this.window = window;
|
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.window = null;
|
||||||
this.browserSession = 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.tabs.clear();
|
||||||
this.tabOrder = [];
|
this.tabOrder = [];
|
||||||
this.activeTabId = null;
|
this.activeTabId = null;
|
||||||
this.attachedTabId = null;
|
this.attachedTabId = null;
|
||||||
this.visible = false;
|
this.visible = false;
|
||||||
this.snapshotCache.clear();
|
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 {
|
private getSession(): Session {
|
||||||
|
|
|
||||||
|
|
@ -293,20 +293,6 @@ export function listConnected(): { toolkits: string[] } {
|
||||||
return { toolkits: composioAccountsRepo.getConnectedToolkits() };
|
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.
|
* List available Composio toolkits — filtered to curated list only.
|
||||||
* Return type matches the ZToolkit schema from core/composio/types.ts.
|
* 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 { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
||||||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
||||||
import * as composioHandler from './composio-handler.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 { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
||||||
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-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';
|
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 { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||||
import { triggerTrackUpdate } from '@x/core/dist/knowledge/track/runner.js';
|
import { triggerTrackUpdate } from '@x/core/dist/knowledge/track/runner.js';
|
||||||
import { trackBus } from '@x/core/dist/knowledge/track/bus.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 {
|
import {
|
||||||
fetchYaml,
|
fetchYaml,
|
||||||
|
listNotesWithTracks,
|
||||||
|
setNoteTracksActive,
|
||||||
updateTrackBlock,
|
updateTrackBlock,
|
||||||
replaceTrackBlockYaml,
|
replaceTrackBlockYaml,
|
||||||
deleteTrackBlock,
|
deleteTrackBlock,
|
||||||
|
|
@ -131,6 +137,14 @@ function resolveShellPath(filePath: string): string {
|
||||||
return workspace.resolveWorkspacePath(filePath);
|
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 InvokeChannels = ipc.InvokeChannels;
|
||||||
type IPCChannels = ipc.IPCChannels;
|
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();
|
const windows = BrowserWindow.getAllWindows();
|
||||||
for (const win of windows) {
|
for (const win of windows) {
|
||||||
if (!win.isDestroyed() && win.webContents) {
|
if (!win.isDestroyed() && win.webContents) {
|
||||||
|
|
@ -415,6 +429,15 @@ export function setupIpcHandlers() {
|
||||||
// args is null for this channel (no request payload)
|
// args is null for this channel (no request payload)
|
||||||
return getVersions();
|
return getVersions();
|
||||||
},
|
},
|
||||||
|
'app:consumePendingDeepLink': async () => {
|
||||||
|
return { url: consumePendingDeepLink() };
|
||||||
|
},
|
||||||
|
'analytics:bootstrap': async () => {
|
||||||
|
return {
|
||||||
|
installationId: getInstallationId(),
|
||||||
|
apiUrl: API_URL,
|
||||||
|
};
|
||||||
|
},
|
||||||
'workspace:getRoot': async () => {
|
'workspace:getRoot': async () => {
|
||||||
return workspace.getRoot();
|
return workspace.getRoot();
|
||||||
},
|
},
|
||||||
|
|
@ -600,11 +623,8 @@ export function setupIpcHandlers() {
|
||||||
'composio:list-toolkits': async () => {
|
'composio:list-toolkits': async () => {
|
||||||
return composioHandler.listToolkits();
|
return composioHandler.listToolkits();
|
||||||
},
|
},
|
||||||
'composio:use-composio-for-google': async () => {
|
'migration:check-composio-google': async () => {
|
||||||
return composioHandler.useComposioForGoogle();
|
return qualifyAndDisconnectComposioGoogle();
|
||||||
},
|
|
||||||
'composio:use-composio-for-google-calendar': async () => {
|
|
||||||
return composioHandler.useComposioForGoogleCalendar();
|
|
||||||
},
|
},
|
||||||
// Agent schedule handlers
|
// Agent schedule handlers
|
||||||
'agent-schedule:getConfig': async () => {
|
'agent-schedule:getConfig': async () => {
|
||||||
|
|
@ -822,6 +842,19 @@ export function setupIpcHandlers() {
|
||||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
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 handler
|
||||||
'billing:getInfo': async () => {
|
'billing:getInfo': async () => {
|
||||||
return await getBillingInfo();
|
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 initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
|
||||||
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.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 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 initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js";
|
||||||
import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.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 { 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 { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||||
import started from "electron-squirrel-startup";
|
import started from "electron-squirrel-startup";
|
||||||
import { execSync, exec, execFileSync } from "node:child_process";
|
import { execSync, exec, execFileSync } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
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 { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
|
||||||
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
||||||
import { ElectronBrowserControlService } from "./browser/control-service.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);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
|
@ -45,6 +55,44 @@ const __dirname = dirname(__filename);
|
||||||
// run this as early in the main process as possible
|
// run this as early in the main process as possible
|
||||||
if (started) app.quit();
|
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.
|
// Fix PATH for packaged Electron apps on macOS/Linux.
|
||||||
// Packaged apps inherit a minimal environment that doesn't include paths from
|
// 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.).
|
// the user's shell profile (such as those provided by nvm, Homebrew, etc.).
|
||||||
|
|
@ -65,7 +113,9 @@ function initializeExecutionEnvironment(): void {
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
const env = JSON.parse(stdout) as Record<string, string>;
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to load shell environment', error);
|
console.error('Failed to load shell environment', error);
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +213,9 @@ function createWindow() {
|
||||||
configureSessionPermissions(session.defaultSession);
|
configureSessionPermissions(session.defaultSession);
|
||||||
configureSessionPermissions(session.fromPartition(BROWSER_PARTITION));
|
configureSessionPermissions(session.fromPartition(BROWSER_PARTITION));
|
||||||
|
|
||||||
|
setMainWindowForDeepLinks(win);
|
||||||
|
win.on("closed", () => setMainWindowForDeepLinks(null));
|
||||||
|
|
||||||
// Show window when content is ready to prevent blank screen
|
// Show window when content is ready to prevent blank screen
|
||||||
win.once("ready-to-show", () => {
|
win.once("ready-to-show", () => {
|
||||||
win.maximize();
|
win.maximize();
|
||||||
|
|
@ -230,7 +283,15 @@ app.whenReady().then(async () => {
|
||||||
// Initialize all config files before UI can access them
|
// Initialize all config files before UI can access them
|
||||||
await initConfigs();
|
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());
|
registerBrowserControlService(new ElectronBrowserControlService());
|
||||||
|
registerNotificationService(new ElectronNotificationService());
|
||||||
|
|
||||||
setupIpcHandlers();
|
setupIpcHandlers();
|
||||||
setupBrowserEventForwarding();
|
setupBrowserEventForwarding();
|
||||||
|
|
@ -289,6 +350,9 @@ app.whenReady().then(async () => {
|
||||||
// start agent notes learning service
|
// start agent notes learning service
|
||||||
initAgentNotes();
|
initAgentNotes();
|
||||||
|
|
||||||
|
// start calendar meeting notification service (fires 1-minute warnings)
|
||||||
|
initCalendarNotifications();
|
||||||
|
|
||||||
// start chrome extension sync server
|
// start chrome extension sync server
|
||||||
initChromeSync();
|
initChromeSync();
|
||||||
|
|
||||||
|
|
@ -318,4 +382,7 @@ app.on("before-quit", () => {
|
||||||
shutdownLocalSites().catch((error) => {
|
shutdownLocalSites().catch((error) => {
|
||||||
console.error('[LocalSites] Failed to shut down cleanly:', 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 { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js';
|
||||||
import { emitOAuthEvent } from './ipc.js';
|
import { emitOAuthEvent } from './ipc.js';
|
||||||
import { getBillingInfo } from '@x/core/dist/billing/billing.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';
|
const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
|
||||||
|
|
||||||
|
|
@ -200,6 +204,23 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
||||||
|
|
||||||
if (provider === 'google') {
|
if (provider === 'google') {
|
||||||
if (!credentials?.clientId || !credentials?.clientSecret) {
|
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.' };
|
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
|
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}`);
|
console.log(`[OAuth] Token exchange successful for ${provider}`);
|
||||||
await oauthRepo.upsert(provider, {
|
await oauthRepo.upsert(provider, {
|
||||||
tokens,
|
tokens,
|
||||||
...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}),
|
...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}),
|
||||||
|
...(provider === 'google' ? { mode: 'byok' as const } : {}),
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -275,16 +300,33 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
||||||
// For Rowboat sign-in, ensure user + Stripe customer exist before
|
// For Rowboat sign-in, ensure user + Stripe customer exist before
|
||||||
// notifying the renderer. Without this, parallel API calls from
|
// notifying the renderer. Without this, parallel API calls from
|
||||||
// multiple renderer hooks race to create the user, causing duplicates.
|
// multiple renderer hooks race to create the user, causing duplicates.
|
||||||
|
let signedInUserId: string | undefined;
|
||||||
if (provider === 'rowboat') {
|
if (provider === 'rowboat') {
|
||||||
try {
|
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) {
|
} catch (meError) {
|
||||||
console.error('[OAuth] Failed to initialize user via /v1/me:', meError);
|
console.error('[OAuth] Failed to initialize user via /v1/me:', meError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit success event to renderer
|
// Emit success event to renderer
|
||||||
emitOAuthEvent({ provider, success: true });
|
emitOAuthEvent({
|
||||||
|
provider,
|
||||||
|
success: true,
|
||||||
|
...(signedInUserId ? { userId: signedInUserId } : {}),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('OAuth token exchange failed:', error);
|
console.error('OAuth token exchange failed:', error);
|
||||||
// Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError)
|
// 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)
|
* Disconnect a provider (clear tokens)
|
||||||
*/
|
*/
|
||||||
export async function disconnectProvider(provider: string): Promise<{ success: boolean }> {
|
export async function disconnectProvider(provider: string): Promise<{ success: boolean }> {
|
||||||
try {
|
try {
|
||||||
const oauthRepo = getOAuthRepo();
|
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);
|
await oauthRepo.delete(provider);
|
||||||
|
if (provider === 'rowboat') {
|
||||||
|
analyticsCapture('user_signed_out');
|
||||||
|
analyticsReset();
|
||||||
|
}
|
||||||
// Notify renderer so sidebar, voice, and billing re-check state
|
// Notify renderer so sidebar, voice, and billing re-check state
|
||||||
emitOAuthEvent({ provider, success: false });
|
emitOAuthEvent({ provider, success: false });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/ba
|
||||||
import { useDebounce } from './hooks/use-debounce';
|
import { useDebounce } from './hooks/use-debounce';
|
||||||
import { SidebarContentPanel } from '@/components/sidebar-content';
|
import { SidebarContentPanel } from '@/components/sidebar-content';
|
||||||
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
|
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
|
||||||
|
import { BackgroundAgentsView } from '@/components/background-agents-view';
|
||||||
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
|
|
@ -35,7 +36,7 @@ import {
|
||||||
|
|
||||||
import { Shimmer } from '@/components/ai-elements/shimmer';
|
import { Shimmer } from '@/components/ai-elements/shimmer';
|
||||||
import { useSmoothedText } from './hooks/useSmoothedText';
|
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 { WebSearchResult } from '@/components/ai-elements/web-search-result';
|
||||||
import { AppActionCard } from '@/components/ai-elements/app-action-card';
|
import { AppActionCard } from '@/components/ai-elements/app-action-card';
|
||||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-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 { Toaster } from "@/components/ui/sonner"
|
||||||
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||||
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
||||||
|
import { extractConferenceLink } from '@/lib/calendar-event'
|
||||||
import { OnboardingModal } from '@/components/onboarding'
|
import { OnboardingModal } from '@/components/onboarding'
|
||||||
|
import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal'
|
||||||
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog'
|
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog'
|
||||||
import { TrackModal } from '@/components/track-modal'
|
import { TrackModal } from '@/components/track-modal'
|
||||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||||
|
|
@ -76,10 +79,12 @@ import {
|
||||||
getAppActionCardData,
|
getAppActionCardData,
|
||||||
getComposioConnectCardData,
|
getComposioConnectCardData,
|
||||||
getToolDisplayName,
|
getToolDisplayName,
|
||||||
|
groupConversationItems,
|
||||||
inferRunTitleFromMessage,
|
inferRunTitleFromMessage,
|
||||||
isChatMessage,
|
isChatMessage,
|
||||||
isErrorMessage,
|
isErrorMessage,
|
||||||
isToolCall,
|
isToolCall,
|
||||||
|
isToolGroup,
|
||||||
normalizeToolInput,
|
normalizeToolInput,
|
||||||
normalizeToolOutput,
|
normalizeToolOutput,
|
||||||
parseAttachedFiles,
|
parseAttachedFiles,
|
||||||
|
|
@ -138,6 +143,7 @@ const TITLEBAR_BUTTONS_COLLAPSED = 1
|
||||||
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0
|
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0
|
||||||
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
|
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
|
||||||
const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
|
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 BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
|
||||||
|
|
||||||
const clampNumber = (value: number, min: number, max: number) =>
|
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 isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
|
||||||
const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_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 isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
|
||||||
|
|
||||||
const getSuggestedTopicTargetFolder = (category?: string) => {
|
const getSuggestedTopicTargetFolder = (category?: string) => {
|
||||||
|
|
@ -323,6 +330,24 @@ const buildSuggestedTopicExplorePrompt = ({
|
||||||
].join('\n')
|
].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 => {
|
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
|
||||||
if (!usage) return null
|
if (!usage) return null
|
||||||
const hasNumbers = Object.values(usage).some((value) => typeof value === 'number')
|
const hasNumbers = Object.values(usage).some((value) => typeof value === 'number')
|
||||||
|
|
@ -504,6 +529,7 @@ type ViewState =
|
||||||
| { type: 'graph' }
|
| { type: 'graph' }
|
||||||
| { type: 'task'; name: string }
|
| { type: 'task'; name: string }
|
||||||
| { type: 'suggested-topics' }
|
| { type: 'suggested-topics' }
|
||||||
|
| { type: 'background-agents' }
|
||||||
|
|
||||||
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
||||||
if (a.type !== b.type) return false
|
if (a.type !== b.type) return false
|
||||||
|
|
@ -513,6 +539,48 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
||||||
return true // both graph
|
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) */
|
/** Sidebar toggle (fixed position, top-left) */
|
||||||
function FixedSidebarToggle({
|
function FixedSidebarToggle({
|
||||||
leftInsetPx,
|
leftInsetPx,
|
||||||
|
|
@ -613,7 +681,13 @@ function App() {
|
||||||
const [isGraphOpen, setIsGraphOpen] = useState(false)
|
const [isGraphOpen, setIsGraphOpen] = useState(false)
|
||||||
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
|
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
|
||||||
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = 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 [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
|
||||||
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
|
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
|
||||||
nodes: [],
|
nodes: [],
|
||||||
|
|
@ -738,6 +812,30 @@ function App() {
|
||||||
return cleanup
|
return cleanup
|
||||||
}, [refreshVoiceAvailability])
|
}, [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(() => {
|
const handleStartRecording = useCallback(() => {
|
||||||
setIsRecording(true)
|
setIsRecording(true)
|
||||||
isRecordingRef.current = true
|
isRecordingRef.current = true
|
||||||
|
|
@ -910,6 +1008,7 @@ function App() {
|
||||||
const getFileTabTitle = useCallback((tab: FileTab) => {
|
const getFileTabTitle = useCallback((tab: FileTab) => {
|
||||||
if (isGraphTabPath(tab.path)) return 'Graph View'
|
if (isGraphTabPath(tab.path)) return 'Graph View'
|
||||||
if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics'
|
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 === BASES_DEFAULT_TAB_PATH) return 'Bases'
|
||||||
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
|
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
|
||||||
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
||||||
|
|
@ -991,6 +1090,9 @@ function App() {
|
||||||
// Onboarding state
|
// Onboarding state
|
||||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||||
|
|
||||||
|
// One-time Composio→native Google migration modal
|
||||||
|
const [showComposioGoogleMigration, setShowComposioGoogleMigration] = useState(false)
|
||||||
|
|
||||||
// Search state
|
// Search state
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
||||||
|
|
||||||
|
|
@ -2358,6 +2460,10 @@ function App() {
|
||||||
}
|
}
|
||||||
}, [runId])
|
}, [runId])
|
||||||
|
|
||||||
|
const dismissBrowserOverlay = useCallback(() => {
|
||||||
|
setIsBrowserOpen(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleNewChat = useCallback(() => {
|
const handleNewChat = useCallback(() => {
|
||||||
// Invalidate any in-flight run loads (rapid switching can otherwise "pop" old conversations back in)
|
// Invalidate any in-flight run loads (rapid switching can otherwise "pop" old conversations back in)
|
||||||
loadRunRequestIdRef.current += 1
|
loadRunRequestIdRef.current += 1
|
||||||
|
|
@ -2581,10 +2687,13 @@ function App() {
|
||||||
|
|
||||||
// File tab operations
|
// File tab operations
|
||||||
const openFileInNewTab = useCallback((path: string) => {
|
const openFileInNewTab = useCallback((path: string) => {
|
||||||
|
dismissBrowserOverlay()
|
||||||
const existingTab = fileTabs.find(t => t.path === path)
|
const existingTab = fileTabs.find(t => t.path === path)
|
||||||
if (existingTab) {
|
if (existingTab) {
|
||||||
setActiveFileTabId(existingTab.id)
|
setActiveFileTabId(existingTab.id)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
setSelectedPath(path)
|
setSelectedPath(path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2592,12 +2701,15 @@ function App() {
|
||||||
setFileTabs(prev => [...prev, { id, path }])
|
setFileTabs(prev => [...prev, { id, path }])
|
||||||
setActiveFileTabId(id)
|
setActiveFileTabId(id)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
setSelectedPath(path)
|
setSelectedPath(path)
|
||||||
}, [fileTabs])
|
}, [fileTabs, dismissBrowserOverlay])
|
||||||
|
|
||||||
const switchFileTab = useCallback((tabId: string) => {
|
const switchFileTab = useCallback((tabId: string) => {
|
||||||
const tab = fileTabs.find(t => t.id === tabId)
|
const tab = fileTabs.find(t => t.id === tabId)
|
||||||
if (!tab) return
|
if (!tab) return
|
||||||
|
dismissBrowserOverlay()
|
||||||
setActiveFileTabId(tabId)
|
setActiveFileTabId(tabId)
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
|
|
@ -2609,18 +2721,28 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isSuggestedTopicsTabPath(tab.path)) {
|
if (isSuggestedTopicsTabPath(tab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(true)
|
setIsSuggestedTopicsOpen(true)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isBackgroundAgentsTabPath(tab.path)) {
|
||||||
|
setSelectedPath(null)
|
||||||
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
setSelectedPath(tab.path)
|
setSelectedPath(tab.path)
|
||||||
}, [fileTabs, isRightPaneMaximized])
|
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
|
||||||
|
|
||||||
const closeFileTab = useCallback((tabId: string) => {
|
const closeFileTab = useCallback((tabId: string) => {
|
||||||
const closingTab = fileTabs.find(t => t.id === tabId)
|
const closingTab = fileTabs.find(t => t.id === tabId)
|
||||||
|
|
@ -2647,6 +2769,7 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const idx = prev.findIndex(t => t.id === tabId)
|
const idx = prev.findIndex(t => t.id === tabId)
|
||||||
|
|
@ -2660,13 +2783,21 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
|
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(true)
|
setIsSuggestedTopicsOpen(true)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
|
} else if (isBackgroundAgentsTabPath(newActiveTab.path)) {
|
||||||
|
setSelectedPath(null)
|
||||||
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(true)
|
||||||
} else {
|
} else {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
setSelectedPath(newActiveTab.path)
|
setSelectedPath(newActiveTab.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2692,12 +2823,18 @@ function App() {
|
||||||
// Create a new tab
|
// Create a new tab
|
||||||
const id = newChatTabId()
|
const id = newChatTabId()
|
||||||
setChatTabs(prev => [...prev, { id, runId: null }])
|
setChatTabs(prev => [...prev, { id, runId: null }])
|
||||||
setActiveChatTabId(id)
|
setActiveChatTabId(id)
|
||||||
}
|
}
|
||||||
|
dismissBrowserOverlay()
|
||||||
handleNewChat()
|
handleNewChat()
|
||||||
// Left-pane "new chat" should always open full chat view.
|
// Left-pane "new chat" should always open full chat view.
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) {
|
||||||
setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen })
|
setExpandedFrom({
|
||||||
|
path: selectedPath,
|
||||||
|
graph: isGraphOpen,
|
||||||
|
suggestedTopics: isSuggestedTopicsOpen,
|
||||||
|
backgroundAgents: isBackgroundAgentsOpen,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
}
|
}
|
||||||
|
|
@ -2705,7 +2842,8 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(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.
|
// Sidebar variant: create/switch chat tab without leaving file/graph context.
|
||||||
const handleNewChatTabInSidebar = useCallback(() => {
|
const handleNewChatTabInSidebar = useCallback(() => {
|
||||||
|
|
@ -2820,26 +2958,40 @@ function App() {
|
||||||
|
|
||||||
const handleOpenFullScreenChat = useCallback(() => {
|
const handleOpenFullScreenChat = useCallback(() => {
|
||||||
// Remember where we came from so the close button can return
|
// Remember where we came from so the close button can return
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) {
|
||||||
setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen })
|
setExpandedFrom({
|
||||||
|
path: selectedPath,
|
||||||
|
graph: isGraphOpen,
|
||||||
|
suggestedTopics: isSuggestedTopicsOpen,
|
||||||
|
backgroundAgents: isBackgroundAgentsOpen,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
dismissBrowserOverlay()
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen])
|
setIsBackgroundAgentsOpen(false)
|
||||||
|
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen, dismissBrowserOverlay])
|
||||||
|
|
||||||
const handleCloseFullScreenChat = useCallback(() => {
|
const handleCloseFullScreenChat = useCallback(() => {
|
||||||
if (expandedFrom) {
|
if (expandedFrom) {
|
||||||
if (expandedFrom.graph) {
|
if (expandedFrom.graph) {
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
} else if (expandedFrom.suggestedTopics) {
|
} else if (expandedFrom.suggestedTopics) {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(true)
|
setIsSuggestedTopicsOpen(true)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
|
} else if (expandedFrom.backgroundAgents) {
|
||||||
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(true)
|
||||||
} else if (expandedFrom.path) {
|
} else if (expandedFrom.path) {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
setSelectedPath(expandedFrom.path)
|
setSelectedPath(expandedFrom.path)
|
||||||
}
|
}
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
|
|
@ -2849,11 +3001,12 @@ function App() {
|
||||||
|
|
||||||
const currentViewState = React.useMemo<ViewState>(() => {
|
const currentViewState = React.useMemo<ViewState>(() => {
|
||||||
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
|
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
|
||||||
|
if (isBackgroundAgentsOpen) return { type: 'background-agents' }
|
||||||
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
|
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
|
||||||
if (selectedPath) return { type: 'file', path: selectedPath }
|
if (selectedPath) return { type: 'file', path: selectedPath }
|
||||||
if (isGraphOpen) return { type: 'graph' }
|
if (isGraphOpen) return { type: 'graph' }
|
||||||
return { type: 'chat', runId }
|
return { type: 'chat', runId }
|
||||||
}, [selectedBackgroundTask, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
|
}, [selectedBackgroundTask, isBackgroundAgentsOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
|
||||||
|
|
||||||
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
||||||
const last = stack[stack.length - 1]
|
const last = stack[stack.length - 1]
|
||||||
|
|
@ -2910,6 +3063,17 @@ function App() {
|
||||||
setActiveFileTabId(id)
|
setActiveFileTabId(id)
|
||||||
}, [fileTabs])
|
}, [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) => {
|
const applyViewState = useCallback(async (view: ViewState) => {
|
||||||
switch (view.type) {
|
switch (view.type) {
|
||||||
case 'file':
|
case 'file':
|
||||||
|
|
@ -2919,6 +3083,7 @@ function App() {
|
||||||
// visible in the middle pane.
|
// visible in the middle pane.
|
||||||
setIsBrowserOpen(false)
|
setIsBrowserOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
// Preserve split vs knowledge-max mode when navigating knowledge files.
|
// Preserve split vs knowledge-max mode when navigating knowledge files.
|
||||||
// Only exit chat-only maximize, because that would hide the selected file.
|
// Only exit chat-only maximize, because that would hide the selected file.
|
||||||
|
|
@ -2933,6 +3098,7 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsBrowserOpen(false)
|
setIsBrowserOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
ensureGraphFileTab()
|
ensureGraphFileTab()
|
||||||
|
|
@ -2945,6 +3111,7 @@ function App() {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsBrowserOpen(false)
|
setIsBrowserOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setSelectedBackgroundTask(view.name)
|
setSelectedBackgroundTask(view.name)
|
||||||
|
|
@ -2957,17 +3124,29 @@ function App() {
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setIsSuggestedTopicsOpen(true)
|
setIsSuggestedTopicsOpen(true)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
ensureSuggestedTopicsFileTab()
|
ensureSuggestedTopicsFileTab()
|
||||||
return
|
return
|
||||||
case 'chat':
|
case 'background-agents':
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
// Don't touch isBrowserOpen here — chat navigation should land in
|
setIsBrowserOpen(false)
|
||||||
// the right sidebar when the browser overlay is active.
|
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setIsSuggestedTopicsOpen(false)
|
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) {
|
if (view.runId) {
|
||||||
await loadRun(view.runId)
|
await loadRun(view.runId)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2975,11 +3154,16 @@ function App() {
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}, [ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
}, [ensureBackgroundAgentsFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||||
|
|
||||||
const navigateToView = useCallback(async (nextView: ViewState) => {
|
const navigateToView = useCallback(async (nextView: ViewState) => {
|
||||||
const current = currentViewState
|
const current = currentViewState
|
||||||
if (viewStatesEqual(current, nextView)) return
|
if (viewStatesEqual(current, nextView)) {
|
||||||
|
if (isBrowserOpen) {
|
||||||
|
dismissBrowserOverlay()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
cancelRecordingIfActive()
|
cancelRecordingIfActive()
|
||||||
const nextHistory = {
|
const nextHistory = {
|
||||||
|
|
@ -2988,7 +3172,7 @@ function App() {
|
||||||
}
|
}
|
||||||
setHistory(nextHistory)
|
setHistory(nextHistory)
|
||||||
await applyViewState(nextView)
|
await applyViewState(nextView)
|
||||||
}, [appendUnique, applyViewState, cancelRecordingIfActive, currentViewState, setHistory])
|
}, [appendUnique, applyViewState, cancelRecordingIfActive, currentViewState, setHistory, isBrowserOpen, dismissBrowserOverlay])
|
||||||
|
|
||||||
const navigateBack = useCallback(async () => {
|
const navigateBack = useCallback(async () => {
|
||||||
const { back, forward } = historyRef.current
|
const { back, forward } = historyRef.current
|
||||||
|
|
@ -3048,6 +3232,58 @@ function App() {
|
||||||
void navigateToView({ type: 'file', path })
|
void navigateToView({ type: 'file', path })
|
||||||
}, [navigateToView])
|
}, [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) => {
|
const handleBaseConfigChange = useCallback((path: string, config: BaseConfig) => {
|
||||||
setBaseConfigByPath((prev) => ({ ...prev, [path]: config }))
|
setBaseConfigByPath((prev) => ({ ...prev, [path]: config }))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -3240,7 +3476,7 @@ function App() {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Keyboard shortcut: Ctrl+L to toggle main chat view
|
// 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(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||||
|
|
@ -3318,15 +3554,17 @@ function App() {
|
||||||
const handleTabKeyDown = (e: KeyboardEvent) => {
|
const handleTabKeyDown = (e: KeyboardEvent) => {
|
||||||
const mod = e.metaKey || e.ctrlKey
|
const mod = e.metaKey || e.ctrlKey
|
||||||
if (!mod) return
|
if (!mod) return
|
||||||
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen) && isChatSidebarOpen)
|
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && isChatSidebarOpen)
|
||||||
const targetPane: ShortcutPane = rightPaneAvailable
|
const targetPane: ShortcutPane = rightPaneAvailable
|
||||||
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
|
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
|
||||||
: 'left'
|
: 'left'
|
||||||
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen)
|
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen)
|
||||||
const selectedKnowledgePath = isGraphOpen
|
const selectedKnowledgePath = isGraphOpen
|
||||||
? GRAPH_TAB_PATH
|
? GRAPH_TAB_PATH
|
||||||
: isSuggestedTopicsOpen
|
: isSuggestedTopicsOpen
|
||||||
? SUGGESTED_TOPICS_TAB_PATH
|
? SUGGESTED_TOPICS_TAB_PATH
|
||||||
|
: isBackgroundAgentsOpen
|
||||||
|
? BACKGROUND_AGENTS_TAB_PATH
|
||||||
: selectedPath
|
: selectedPath
|
||||||
const targetFileTabId = activeFileTabId ?? (
|
const targetFileTabId = activeFileTabId ?? (
|
||||||
selectedKnowledgePath
|
selectedKnowledgePath
|
||||||
|
|
@ -3381,7 +3619,7 @@ function App() {
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', handleTabKeyDown)
|
document.addEventListener('keydown', handleTabKeyDown)
|
||||||
return () => document.removeEventListener('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') => {
|
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
|
||||||
if (kind === 'file') {
|
if (kind === 'file') {
|
||||||
|
|
@ -3406,7 +3644,7 @@ function App() {
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
|
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) {
|
||||||
setIsChatSidebarOpen(false)
|
setIsChatSidebarOpen(false)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
}
|
}
|
||||||
|
|
@ -3528,14 +3766,14 @@ function App() {
|
||||||
},
|
},
|
||||||
openGraph: () => {
|
openGraph: () => {
|
||||||
// From chat-only landing state, open graph directly in full knowledge view.
|
// 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)
|
setIsChatSidebarOpen(false)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
}
|
}
|
||||||
void navigateToView({ type: 'graph' })
|
void navigateToView({ type: 'graph' })
|
||||||
},
|
},
|
||||||
openBases: () => {
|
openBases: () => {
|
||||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
|
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) {
|
||||||
setIsChatSidebarOpen(false)
|
setIsChatSidebarOpen(false)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
}
|
}
|
||||||
|
|
@ -4119,7 +4357,7 @@ function App() {
|
||||||
const selectedTask = selectedBackgroundTask
|
const selectedTask = selectedBackgroundTask
|
||||||
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
||||||
: null
|
: null
|
||||||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen)
|
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen)
|
||||||
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
||||||
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
||||||
const openMarkdownTabs = React.useMemo(() => {
|
const openMarkdownTabs = React.useMemo(() => {
|
||||||
|
|
@ -4136,7 +4374,7 @@ function App() {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
|
<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 })
|
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
|
|
@ -4169,7 +4407,7 @@ function App() {
|
||||||
onNewChat: handleNewChatTab,
|
onNewChat: handleNewChatTab,
|
||||||
onSelectRun: (runIdToLoad) => {
|
onSelectRun: (runIdToLoad) => {
|
||||||
cancelRecordingIfActive()
|
cancelRecordingIfActive()
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
|
||||||
setIsChatSidebarOpen(true)
|
setIsChatSidebarOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4180,7 +4418,7 @@ function App() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar.
|
// 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))
|
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
|
||||||
loadRun(runIdToLoad)
|
loadRun(runIdToLoad)
|
||||||
return
|
return
|
||||||
|
|
@ -4204,14 +4442,14 @@ function App() {
|
||||||
} else {
|
} else {
|
||||||
// Only one tab, reset it to new chat
|
// Only one tab, reset it to new chat
|
||||||
setChatTabs([{ id: tabForRun.id, runId: null }])
|
setChatTabs([{ id: tabForRun.id, runId: null }])
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
|
||||||
handleNewChat()
|
handleNewChat()
|
||||||
} else {
|
} else {
|
||||||
void navigateToView({ type: 'chat', runId: null })
|
void navigateToView({ type: 'chat', runId: null })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (runId === runIdToDelete) {
|
} 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))
|
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
|
||||||
handleNewChat()
|
handleNewChat()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -4235,10 +4473,14 @@ function App() {
|
||||||
meetingSummarizing={meetingSummarizing}
|
meetingSummarizing={meetingSummarizing}
|
||||||
meetingAvailable={voiceAvailable}
|
meetingAvailable={voiceAvailable}
|
||||||
onToggleMeeting={() => { void handleToggleMeeting() }}
|
onToggleMeeting={() => { void handleToggleMeeting() }}
|
||||||
|
isSearchOpen={isSearchOpen}
|
||||||
|
isMeetingActionActive={showMeetingPermissions || meetingSummarizing || meetingTranscription.state !== 'idle'}
|
||||||
isBrowserOpen={isBrowserOpen}
|
isBrowserOpen={isBrowserOpen}
|
||||||
onToggleBrowser={handleToggleBrowser}
|
onToggleBrowser={handleToggleBrowser}
|
||||||
isSuggestedTopicsOpen={isSuggestedTopicsOpen}
|
isSuggestedTopicsOpen={isSuggestedTopicsOpen}
|
||||||
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
|
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
|
||||||
|
isBackgroundAgentsOpen={isBackgroundAgentsOpen}
|
||||||
|
onOpenBackgroundAgents={() => void navigateToView({ type: 'background-agents' })}
|
||||||
/>
|
/>
|
||||||
<SidebarInset
|
<SidebarInset
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -4258,7 +4500,7 @@ function App() {
|
||||||
canNavigateForward={canNavigateForward}
|
canNavigateForward={canNavigateForward}
|
||||||
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
||||||
>
|
>
|
||||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && fileTabs.length >= 1 ? (
|
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && fileTabs.length >= 1 ? (
|
||||||
<TabBar
|
<TabBar
|
||||||
tabs={fileTabs}
|
tabs={fileTabs}
|
||||||
activeTabId={activeFileTabId ?? ''}
|
activeTabId={activeFileTabId ?? ''}
|
||||||
|
|
@ -4266,7 +4508,7 @@ function App() {
|
||||||
getTabId={(t) => t.id}
|
getTabId={(t) => t.id}
|
||||||
onSwitchTab={switchFileTab}
|
onSwitchTab={switchFileTab}
|
||||||
onCloseTab={closeFileTab}
|
onCloseTab={closeFileTab}
|
||||||
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TabBar
|
<TabBar
|
||||||
|
|
@ -4319,7 +4561,7 @@ function App() {
|
||||||
<TooltipContent side="bottom">Version history</TooltipContent>
|
<TooltipContent side="bottom">Version history</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedTask && !isBrowserOpen && (
|
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedTask && !isBrowserOpen && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -4334,7 +4576,7 @@ function App() {
|
||||||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBrowserOpen && expandedFrom && (
|
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !isBrowserOpen && expandedFrom && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -4349,7 +4591,7 @@ function App() {
|
||||||
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
|
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && (
|
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -4369,7 +4611,10 @@ function App() {
|
||||||
</ContentHeader>
|
</ContentHeader>
|
||||||
|
|
||||||
{isBrowserOpen ? (
|
{isBrowserOpen ? (
|
||||||
<BrowserPane onClose={handleCloseBrowser} />
|
<BrowserPane
|
||||||
|
onClose={handleCloseBrowser}
|
||||||
|
forceHidden={isSearchOpen || showMeetingPermissions}
|
||||||
|
/>
|
||||||
) : isSuggestedTopicsOpen ? (
|
) : isSuggestedTopicsOpen ? (
|
||||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
<SuggestedTopicsView
|
<SuggestedTopicsView
|
||||||
|
|
@ -4379,6 +4624,15 @@ function App() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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) ? (
|
) : selectedPath && isBaseFilePath(selectedPath) ? (
|
||||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
<BasesView
|
<BasesView
|
||||||
|
|
@ -4578,7 +4832,20 @@ function App() {
|
||||||
</ConversationEmptyState>
|
</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)
|
const rendered = renderConversationItem(item, tab.id)
|
||||||
if (isToolCall(item)) {
|
if (isToolCall(item)) {
|
||||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||||
|
|
@ -4743,6 +5010,7 @@ function App() {
|
||||||
onToolOpenChangeForTab={setToolOpenForTab}
|
onToolOpenChangeForTab={setToolOpenForTab}
|
||||||
onOpenKnowledgeFile={(path) => { navigateToFile(path) }}
|
onOpenKnowledgeFile={(path) => { navigateToFile(path) }}
|
||||||
onActivate={() => setActiveShortcutPane('right')}
|
onActivate={() => setActiveShortcutPane('right')}
|
||||||
|
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
||||||
isRecording={isRecording}
|
isRecording={isRecording}
|
||||||
recordingText={voice.interimText}
|
recordingText={voice.interimText}
|
||||||
recordingState={voice.state === 'connecting' ? 'connecting' : 'listening'}
|
recordingState={voice.state === 'connecting' ? 'connecting' : 'listening'}
|
||||||
|
|
@ -4779,6 +5047,17 @@ function App() {
|
||||||
open={showOnboarding}
|
open={showOnboarding}
|
||||||
onComplete={handleOnboardingComplete}
|
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}>
|
<Dialog open={showMeetingPermissions} onOpenChange={setShowMeetingPermissions}>
|
||||||
<DialogContent showCloseButton={false}>
|
<DialogContent showCloseButton={false}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ import {
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type ComponentProps, type ReactNode, isValidElement, useState } from "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) => {
|
const formatToolValue = (value: unknown) => {
|
||||||
if (typeof value === "string") return value;
|
if (typeof value === "string") return value;
|
||||||
|
|
@ -224,3 +227,89 @@ export const ToolTabbedContent = ({
|
||||||
</div>
|
</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 {
|
interface BrowserPaneProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
forceHidden?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const getActiveTab = (state: BrowserState) =>
|
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 [state, setState] = useState<BrowserState>(EMPTY_STATE)
|
||||||
const [addressValue, setAddressValue] = useState('')
|
const [addressValue, setAddressValue] = useState('')
|
||||||
|
|
||||||
|
|
@ -175,6 +176,12 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const syncView = useCallback(() => {
|
const syncView = useCallback(() => {
|
||||||
|
if (forceHidden) {
|
||||||
|
lastBoundsRef.current = null
|
||||||
|
setViewVisible(false)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const doc = viewportRef.current?.ownerDocument
|
const doc = viewportRef.current?.ownerDocument
|
||||||
if (doc && hasBlockingOverlay(doc)) {
|
if (doc && hasBlockingOverlay(doc)) {
|
||||||
lastBoundsRef.current = null
|
lastBoundsRef.current = null
|
||||||
|
|
@ -191,7 +198,7 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
pushBounds(bounds)
|
pushBounds(bounds)
|
||||||
setViewVisible(true)
|
setViewVisible(true)
|
||||||
return bounds
|
return bounds
|
||||||
}, [measureBounds, pushBounds, setViewVisible])
|
}, [forceHidden, measureBounds, pushBounds, setViewVisible])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
syncView()
|
syncView()
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
} from '@/components/ai-elements/message'
|
} from '@/components/ai-elements/message'
|
||||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
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 { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
||||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
||||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
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 { TabBar, type ChatTab } from '@/components/tab-bar'
|
||||||
import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
||||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||||
|
import { useSidebar } from '@/components/ui/sidebar'
|
||||||
import { wikiLabel } from '@/lib/wiki-links'
|
import { wikiLabel } from '@/lib/wiki-links'
|
||||||
import {
|
import {
|
||||||
type ChatViewportAnchorState,
|
type ChatViewportAnchorState,
|
||||||
|
|
@ -40,9 +41,11 @@ import {
|
||||||
getWebSearchCardData,
|
getWebSearchCardData,
|
||||||
getComposioConnectCardData,
|
getComposioConnectCardData,
|
||||||
getToolDisplayName,
|
getToolDisplayName,
|
||||||
|
groupConversationItems,
|
||||||
isChatMessage,
|
isChatMessage,
|
||||||
isErrorMessage,
|
isErrorMessage,
|
||||||
isToolCall,
|
isToolCall,
|
||||||
|
isToolGroup,
|
||||||
normalizeToolInput,
|
normalizeToolInput,
|
||||||
normalizeToolOutput,
|
normalizeToolOutput,
|
||||||
parseAttachedFiles,
|
parseAttachedFiles,
|
||||||
|
|
@ -175,6 +178,7 @@ interface ChatSidebarProps {
|
||||||
onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void
|
onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void
|
||||||
onOpenKnowledgeFile?: (path: string) => void
|
onOpenKnowledgeFile?: (path: string) => void
|
||||||
onActivate?: () => void
|
onActivate?: () => void
|
||||||
|
collapsedLeftPaddingPx?: number
|
||||||
// Voice / TTS props
|
// Voice / TTS props
|
||||||
isRecording?: boolean
|
isRecording?: boolean
|
||||||
recordingText?: string
|
recordingText?: string
|
||||||
|
|
@ -229,6 +233,7 @@ export function ChatSidebar({
|
||||||
onToolOpenChangeForTab,
|
onToolOpenChangeForTab,
|
||||||
onOpenKnowledgeFile,
|
onOpenKnowledgeFile,
|
||||||
onActivate,
|
onActivate,
|
||||||
|
collapsedLeftPaddingPx = 196,
|
||||||
isRecording,
|
isRecording,
|
||||||
recordingText,
|
recordingText,
|
||||||
recordingState,
|
recordingState,
|
||||||
|
|
@ -243,6 +248,7 @@ export function ChatSidebar({
|
||||||
onTtsModeChange,
|
onTtsModeChange,
|
||||||
onComposioConnected,
|
onComposioConnected,
|
||||||
}: ChatSidebarProps) {
|
}: ChatSidebarProps) {
|
||||||
|
const { state: sidebarState } = useSidebar()
|
||||||
const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
|
const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
const [showContent, setShowContent] = useState(isOpen)
|
const [showContent, setShowContent] = useState(isOpen)
|
||||||
|
|
@ -517,7 +523,14 @@ export function ChatSidebar({
|
||||||
|
|
||||||
{showContent && (
|
{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
|
<TabBar
|
||||||
tabs={chatTabs}
|
tabs={chatTabs}
|
||||||
activeTabId={activeChatTabId}
|
activeTabId={activeChatTabId}
|
||||||
|
|
@ -591,7 +604,20 @@ export function ChatSidebar({
|
||||||
</ConversationEmptyState>
|
</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)
|
const rendered = renderConversationItem(item, tab.id)
|
||||||
if (isToolCall(item) && onPermissionResponse) {
|
if (isToolCall(item) && onPermissionResponse) {
|
||||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
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 [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Composio/Gmail state
|
// Composio Gmail/Calendar sync was removed — flags are seeded false and
|
||||||
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
|
// never flipped. Kept here so legacy gating expressions still type-check.
|
||||||
|
const [useComposioForGoogle] = useState(false)
|
||||||
const [gmailConnected, setGmailConnected] = useState(false)
|
const [gmailConnected, setGmailConnected] = useState(false)
|
||||||
const [gmailLoading, setGmailLoading] = useState(true)
|
const [gmailLoading, setGmailLoading] = useState(true)
|
||||||
const [gmailConnecting, setGmailConnecting] = useState(false)
|
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||||
|
|
||||||
// Composio/Google Calendar state
|
const [useComposioForGoogleCalendar] = useState(false)
|
||||||
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
|
|
||||||
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
||||||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
||||||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||||
|
|
@ -151,25 +151,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
setProvidersLoading(false)
|
setProvidersLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function loadComposioForGoogleFlag() {
|
// (Composio Gmail/Calendar flag fetches removed — sync was deleted.)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadProviders()
|
loadProviders()
|
||||||
loadComposioForGoogleFlag()
|
|
||||||
loadComposioForGoogleCalendarFlag()
|
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
// Load LLM models catalog on open
|
// Load LLM models catalog on open
|
||||||
|
|
@ -622,12 +605,20 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
// Connect to a provider
|
// Connect to a provider
|
||||||
const handleConnect = useCallback(async (provider: string) => {
|
const handleConnect = useCallback(async (provider: string) => {
|
||||||
if (provider === 'google') {
|
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)
|
setGoogleClientIdOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await startConnect(provider)
|
await startConnect(provider)
|
||||||
}, [startConnect])
|
}, [startConnect, providerStates])
|
||||||
|
|
||||||
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
||||||
setGoogleCredentials(clientId, clientSecret)
|
setGoogleCredentials(clientId, clientSecret)
|
||||||
|
|
|
||||||
|
|
@ -66,16 +66,16 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
||||||
// Inline upsell callout dismissed
|
// Inline upsell callout dismissed
|
||||||
const [upsellDismissed, setUpsellDismissed] = useState(false)
|
const [upsellDismissed, setUpsellDismissed] = useState(false)
|
||||||
|
|
||||||
// Composio/Gmail state (used when signed in with Rowboat account)
|
// Composio Gmail/Calendar sync was removed — flags are seeded false and
|
||||||
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
|
// never flipped. Kept here so legacy gating expressions still type-check.
|
||||||
|
const [useComposioForGoogle] = useState(false)
|
||||||
const [gmailConnected, setGmailConnected] = useState(false)
|
const [gmailConnected, setGmailConnected] = useState(false)
|
||||||
const [gmailLoading, setGmailLoading] = useState(true)
|
const [gmailLoading, setGmailLoading] = useState(true)
|
||||||
const [gmailConnecting, setGmailConnecting] = useState(false)
|
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||||
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
||||||
const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail')
|
const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail')
|
||||||
|
|
||||||
// Composio/Google Calendar state
|
const [useComposioForGoogleCalendar] = useState(false)
|
||||||
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
|
|
||||||
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
||||||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
||||||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||||
|
|
@ -123,25 +123,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
||||||
setProvidersLoading(false)
|
setProvidersLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function loadComposioForGoogleFlag() {
|
// (Composio Gmail/Calendar flag fetches removed — sync was deleted; flags stay false.)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadProviders()
|
loadProviders()
|
||||||
loadComposioForGoogleFlag()
|
|
||||||
loadComposioForGoogleCalendarFlag()
|
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
// Load LLM models catalog on 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) => {
|
const cleanup = window.ipc.on('oauth:didConnect', async (event) => {
|
||||||
if (event.provider === 'rowboat' && event.success) {
|
if (event.provider === 'rowboat' && event.success) {
|
||||||
// Re-check composio flags now that the account is connected
|
// (Composio Gmail/Calendar flag re-check removed — sync was deleted.)
|
||||||
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)
|
|
||||||
}
|
|
||||||
setCurrentStep(2) // Go to Connect Accounts
|
setCurrentStep(2) // Go to Connect Accounts
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -609,12 +582,20 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
||||||
// Connect to a provider
|
// Connect to a provider
|
||||||
const handleConnect = useCallback(async (provider: string) => {
|
const handleConnect = useCallback(async (provider: string) => {
|
||||||
if (provider === 'google') {
|
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)
|
setGoogleClientIdOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await startConnect(provider)
|
await startConnect(provider)
|
||||||
}, [startConnect])
|
}, [startConnect, providerStates])
|
||||||
|
|
||||||
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
||||||
setGoogleCredentials(clientId, clientSecret)
|
setGoogleCredentials(clientId, clientSecret)
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,28 @@ const SERVICE_LABELS: Record<string, string> = {
|
||||||
granola: "Syncing Granola",
|
granola: "Syncing Granola",
|
||||||
graph: "Updating knowledge",
|
graph: "Updating knowledge",
|
||||||
voice_memo: "Processing voice memo",
|
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 = {
|
type TasksActions = {
|
||||||
|
|
@ -186,10 +208,14 @@ type SidebarContentPanelProps = {
|
||||||
meetingSummarizing?: boolean
|
meetingSummarizing?: boolean
|
||||||
meetingAvailable?: boolean
|
meetingAvailable?: boolean
|
||||||
onToggleMeeting?: () => void
|
onToggleMeeting?: () => void
|
||||||
|
isSearchOpen?: boolean
|
||||||
|
isMeetingActionActive?: boolean
|
||||||
isBrowserOpen?: boolean
|
isBrowserOpen?: boolean
|
||||||
onToggleBrowser?: () => void
|
onToggleBrowser?: () => void
|
||||||
isSuggestedTopicsOpen?: boolean
|
isSuggestedTopicsOpen?: boolean
|
||||||
onOpenSuggestedTopics?: () => void
|
onOpenSuggestedTopics?: () => void
|
||||||
|
isBackgroundAgentsOpen?: boolean
|
||||||
|
onOpenBackgroundAgents?: () => void
|
||||||
} & React.ComponentProps<typeof Sidebar>
|
} & React.ComponentProps<typeof Sidebar>
|
||||||
|
|
||||||
const sectionTabs: { id: ActiveSection; label: string }[] = [
|
const sectionTabs: { id: ActiveSection; label: string }[] = [
|
||||||
|
|
@ -225,6 +251,7 @@ function formatRunTime(ts: string): string {
|
||||||
function SyncStatusBar() {
|
function SyncStatusBar() {
|
||||||
const { state } = useSidebar()
|
const { state } = useSidebar()
|
||||||
const [activeServices, setActiveServices] = useState<Map<string, string>>(new Map())
|
const [activeServices, setActiveServices] = useState<Map<string, string>>(new Map())
|
||||||
|
const [serviceErrors, setServiceErrors] = useState<Map<string, string>>(new Map())
|
||||||
const [popoverOpen, setPopoverOpen] = useState(false)
|
const [popoverOpen, setPopoverOpen] = useState(false)
|
||||||
const [logEvents, setLogEvents] = useState<ServiceEventType[]>([])
|
const [logEvents, setLogEvents] = useState<ServiceEventType[]>([])
|
||||||
const [logLoading, setLogLoading] = useState(false)
|
const [logLoading, setLogLoading] = useState(false)
|
||||||
|
|
@ -258,11 +285,25 @@ function SyncStatusBar() {
|
||||||
next.delete(nextEvent.runId)
|
next.delete(nextEvent.runId)
|
||||||
return next
|
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)
|
const existingTimeout = runTimeoutsRef.current.get(nextEvent.runId)
|
||||||
if (existingTimeout) {
|
if (existingTimeout) {
|
||||||
clearTimeout(existingTimeout)
|
clearTimeout(existingTimeout)
|
||||||
runTimeoutsRef.current.delete(nextEvent.runId)
|
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
|
return cleanup
|
||||||
|
|
@ -296,10 +337,14 @@ function SyncStatusBar() {
|
||||||
// skip malformed lines
|
// skip malformed lines
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setServiceErrors(collectServiceErrors(parsed))
|
||||||
// Newest first, limit to 1000
|
// Newest first, limit to 1000
|
||||||
setLogEvents(parsed.reverse().slice(0, MAX_SYNC_EVENTS))
|
setLogEvents(parsed.reverse().slice(0, MAX_SYNC_EVENTS))
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setLogEvents([])
|
if (!cancelled) {
|
||||||
|
setLogEvents([])
|
||||||
|
setServiceErrors(new Map())
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLogLoading(false)
|
if (!cancelled) setLogLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -310,12 +355,19 @@ function SyncStatusBar() {
|
||||||
|
|
||||||
const isSyncing = activeServices.size > 0
|
const isSyncing = activeServices.size > 0
|
||||||
const isCollapsed = state === "collapsed"
|
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
|
// Build status label from active services
|
||||||
const activeServiceNames = [...new Set(activeServices.values())]
|
const activeServiceNames = [...new Set(activeServices.values())]
|
||||||
const statusLabel = isSyncing
|
const statusLabel = isSyncing
|
||||||
? activeServiceNames.map((s) => SERVICE_LABELS[s] || s).join(", ")
|
? 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -333,11 +385,16 @@ function SyncStatusBar() {
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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">
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
{isSyncing ? (
|
{isSyncing ? (
|
||||||
<LoaderIcon className="h-3 w-3 shrink-0 animate-spin" />
|
<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" />
|
<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">
|
<div className="p-3 border-b">
|
||||||
<h4 className="font-semibold text-sm">Sync Activity</h4>
|
<h4 className="font-semibold text-sm">Sync Activity</h4>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-80 overflow-y-auto p-2">
|
<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}
|
{SERVICE_LABELS[event.service]?.split(" ").slice(-1)[0] || event.service}
|
||||||
</span>
|
</span>
|
||||||
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -420,10 +487,14 @@ export function SidebarContentPanel({
|
||||||
meetingSummarizing = false,
|
meetingSummarizing = false,
|
||||||
meetingAvailable = false,
|
meetingAvailable = false,
|
||||||
onToggleMeeting,
|
onToggleMeeting,
|
||||||
|
isSearchOpen = false,
|
||||||
|
isMeetingActionActive = false,
|
||||||
isBrowserOpen = false,
|
isBrowserOpen = false,
|
||||||
onToggleBrowser,
|
onToggleBrowser,
|
||||||
isSuggestedTopicsOpen = false,
|
isSuggestedTopicsOpen = false,
|
||||||
onOpenSuggestedTopics,
|
onOpenSuggestedTopics,
|
||||||
|
isBackgroundAgentsOpen = false,
|
||||||
|
onOpenBackgroundAgents,
|
||||||
...props
|
...props
|
||||||
}: SidebarContentPanelProps) {
|
}: SidebarContentPanelProps) {
|
||||||
const { activeSection, setActiveSection } = useSidebarSection()
|
const { activeSection, setActiveSection } = useSidebarSection()
|
||||||
|
|
@ -436,6 +507,10 @@ export function SidebarContentPanel({
|
||||||
const [loggingIn, setLoggingIn] = useState(false)
|
const [loggingIn, setLoggingIn] = useState(false)
|
||||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||||
const { billing } = useBilling(isRowboatConnected)
|
const { billing } = useBilling(isRowboatConnected)
|
||||||
|
const isMeetingQuickActionSelected = isMeetingActionActive
|
||||||
|
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen && !isMeetingQuickActionSelected
|
||||||
|
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
|
||||||
|
const isBackgroundAgentsQuickActionSelected = isBackgroundAgentsOpen && !isBrowserOpen
|
||||||
|
|
||||||
const handleRowboatLogin = useCallback(async () => {
|
const handleRowboatLogin = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -533,7 +608,12 @@ export function SidebarContentPanel({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenSearch}
|
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" />
|
<SearchIcon className="size-4" />
|
||||||
<span>Search</span>
|
<span>Search</span>
|
||||||
|
|
@ -546,9 +626,14 @@ export function SidebarContentPanel({
|
||||||
disabled={meetingState === 'connecting' || meetingState === 'stopping' || meetingSummarizing}
|
disabled={meetingState === 'connecting' || meetingState === 'stopping' || meetingSummarizing}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors disabled:pointer-events-none",
|
"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'
|
meetingState === 'recording'
|
||||||
? "text-red-500 hover:bg-sidebar-accent"
|
? "text-red-500"
|
||||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
: isMeetingQuickActionSelected
|
||||||
|
? "text-sidebar-accent-foreground"
|
||||||
|
: "text-sidebar-foreground/80 hover:text-sidebar-accent-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{meetingSummarizing || meetingState === 'connecting' ? (
|
{meetingSummarizing || meetingState === 'connecting' ? (
|
||||||
|
|
@ -575,7 +660,7 @@ export function SidebarContentPanel({
|
||||||
onClick={onToggleBrowser}
|
onClick={onToggleBrowser}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
"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"
|
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover: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}
|
onClick={onOpenSuggestedTopics}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
"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"
|
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover: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>
|
<span>Suggested Topics</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||||
import { X, Calendar, Video, ChevronDown, Mic } from 'lucide-react'
|
import { X, Calendar, Video, ChevronDown, Mic } from 'lucide-react'
|
||||||
import { blocks } from '@x/shared'
|
import { blocks } from '@x/shared'
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { extractConferenceLink } from '../lib/calendar-event'
|
||||||
|
|
||||||
function formatTime(dateStr: string): string {
|
function formatTime(dateStr: string): string {
|
||||||
const d = new Date(dateStr)
|
const d = new Date(dateStr)
|
||||||
|
|
@ -40,25 +41,6 @@ function getTimeRange(event: blocks.CalendarEvent): string {
|
||||||
return `${startTime} \u2013 ${endTime}`
|
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 {
|
interface ResolvedEvent {
|
||||||
event: blocks.CalendarEvent
|
event: blocks.CalendarEvent
|
||||||
loaded: blocks.CalendarEvent | null
|
loaded: blocks.CalendarEvent | null
|
||||||
|
|
|
||||||
|
|
@ -58,15 +58,29 @@ export function useAnalyticsIdentity() {
|
||||||
// Listen for OAuth connect/disconnect events to update identity
|
// Listen for OAuth connect/disconnect events to update identity
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||||
if (!event.success) return
|
if (event.provider !== 'rowboat') {
|
||||||
|
// Other providers: just toggle the connection flag
|
||||||
// If Rowboat provider connected, identify user
|
if (event.success) {
|
||||||
if (event.provider === 'rowboat' && event.userId) {
|
posthog.people.set({ [`${event.provider}_connected`]: true })
|
||||||
posthog.identify(event.userId)
|
}
|
||||||
posthog.people.set({ signed_in: 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
|
return cleanup
|
||||||
|
|
|
||||||
|
|
@ -38,16 +38,21 @@ export function useConnectors(active: boolean) {
|
||||||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Composio/Gmail state
|
// Composio Gmail/Calendar sync was removed. These flags are seeded false
|
||||||
const [useComposioForGoogle, setUseComposioForGoogle] = useState(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 [gmailConnected, setGmailConnected] = useState(false)
|
||||||
const [gmailLoading, setGmailLoading] = useState(true)
|
const [gmailLoading, setGmailLoading] = useState(false)
|
||||||
const [gmailConnecting, setGmailConnecting] = useState(false)
|
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||||
|
|
||||||
// Composio/Google Calendar state
|
const [useComposioForGoogleCalendar] = useState(false)
|
||||||
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
|
|
||||||
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
|
||||||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(false)
|
||||||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||||
|
|
||||||
// Load available providers on mount
|
// Load available providers on mount
|
||||||
|
|
@ -67,28 +72,7 @@ export function useConnectors(active: boolean) {
|
||||||
loadProviders()
|
loadProviders()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Re-check composio-for-google flags when active
|
// (Composio Gmail/Calendar flag-check effect removed — flags are constant false now.)
|
||||||
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])
|
|
||||||
|
|
||||||
// Load Granola config
|
// Load Granola config
|
||||||
const refreshGranolaConfig = useCallback(async () => {
|
const refreshGranolaConfig = useCallback(async () => {
|
||||||
|
|
@ -346,13 +330,22 @@ export function useConnectors(active: boolean) {
|
||||||
|
|
||||||
const handleConnect = useCallback(async (provider: string) => {
|
const handleConnect = useCallback(async (provider: string) => {
|
||||||
if (provider === 'google') {
|
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)
|
setGoogleClientIdDescription(undefined)
|
||||||
setGoogleClientIdOpen(true)
|
setGoogleClientIdOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await startConnect(provider)
|
await startConnect(provider)
|
||||||
}, [startConnect])
|
}, [startConnect, providerStates])
|
||||||
|
|
||||||
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
|
||||||
setGoogleCredentials(clientId, clientSecret)
|
setGoogleCredentials(clientId, clientSecret)
|
||||||
|
|
@ -485,19 +478,6 @@ export function useConnectors(active: boolean) {
|
||||||
toast.success(`Connected to ${displayName}`)
|
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()
|
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
|
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 => {
|
export const inferRunTitleFromMessage = (content: string): string | undefined => {
|
||||||
const { message } = parseAttachedFiles(content)
|
const { message } = parseAttachedFiles(content)
|
||||||
const normalized = message.replace(/\s+/g, ' ').trim()
|
const normalized = message.replace(/\s+/g, ' ').trim()
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,45 @@ import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import posthog from 'posthog-js'
|
||||||
import { PostHogProvider } from 'posthog-js/react'
|
import { PostHogProvider } from 'posthog-js/react'
|
||||||
import { ThemeProvider } from '@/contexts/theme-context'
|
import { ThemeProvider } from '@/contexts/theme-context'
|
||||||
|
|
||||||
const options = {
|
// Fetch the stable installation ID from main so renderer + main share one
|
||||||
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
|
// PostHog distinct_id. Falls back to PostHog's auto-generated anonymous ID
|
||||||
defaults: '2025-11-30',
|
// if the IPC call fails (rare — main is always up before renderer).
|
||||||
} as const
|
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(
|
const options = {
|
||||||
<StrictMode>
|
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
|
||||||
<PostHogProvider apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_KEY} options={options}>
|
defaults: '2025-11-30',
|
||||||
<ThemeProvider defaultTheme="system">
|
...(installationId ? { bootstrap: { distinctID: installationId } } : {}),
|
||||||
<App />
|
} as const
|
||||||
</ThemeProvider>
|
|
||||||
</PostHogProvider>
|
createRoot(document.getElementById('root')!).render(
|
||||||
</StrictMode>,
|
<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",
|
"openid-client": "^6.8.1",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
|
"posthog-node": "^4.18.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,11 @@ async function runAgent(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create a new run via core (resolves agent + default model+provider).
|
// 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}`);
|
console.log(`[AgentRunner] Created run ${run.id} for agent ${agentName}`);
|
||||||
|
|
||||||
// Add the starting message as a user message
|
// 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 { IAbortRegistry } from "../runs/abort-registry.js";
|
||||||
import { PrefixLogger } from "@x/shared";
|
import { PrefixLogger } from "@x/shared";
|
||||||
import { parse } from "yaml";
|
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 getNoteCreationRaw } from "../knowledge/note_creation.js";
|
||||||
import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
|
import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
|
||||||
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
|
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
|
||||||
|
|
@ -650,6 +652,8 @@ export class AgentState {
|
||||||
agentName: string | null = null;
|
agentName: string | null = null;
|
||||||
runModel: string | null = null;
|
runModel: string | null = null;
|
||||||
runProvider: string | null = null;
|
runProvider: string | null = null;
|
||||||
|
runUseCase: UseCase | null = null;
|
||||||
|
runSubUseCase: string | null = null;
|
||||||
messages: z.infer<typeof MessageList> = [];
|
messages: z.infer<typeof MessageList> = [];
|
||||||
lastAssistantMsg: z.infer<typeof AssistantMessage> | null = null;
|
lastAssistantMsg: z.infer<typeof AssistantMessage> | null = null;
|
||||||
subflowStates: Record<string, AgentState> = {};
|
subflowStates: Record<string, AgentState> = {};
|
||||||
|
|
@ -765,6 +769,8 @@ export class AgentState {
|
||||||
this.agentName = event.agentName;
|
this.agentName = event.agentName;
|
||||||
this.runModel = event.model;
|
this.runModel = event.model;
|
||||||
this.runProvider = event.provider;
|
this.runProvider = event.provider;
|
||||||
|
this.runUseCase = event.useCase ?? null;
|
||||||
|
this.runSubUseCase = event.subUseCase ?? null;
|
||||||
break;
|
break;
|
||||||
case "spawn-subflow":
|
case "spawn-subflow":
|
||||||
// Seed the subflow state with its agent so downstream loadAgent works.
|
// 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].agentName = event.agentName;
|
||||||
this.subflowStates[event.toolCallId].runModel = this.runModel;
|
this.subflowStates[event.toolCallId].runModel = this.runModel;
|
||||||
this.subflowStates[event.toolCallId].runProvider = this.runProvider;
|
this.subflowStates[event.toolCallId].runProvider = this.runProvider;
|
||||||
|
this.subflowStates[event.toolCallId].runUseCase = this.runUseCase;
|
||||||
|
this.subflowStates[event.toolCallId].runSubUseCase = this.runSubUseCase;
|
||||||
break;
|
break;
|
||||||
case "message":
|
case "message":
|
||||||
this.messages.push(event.message);
|
this.messages.push(event.message);
|
||||||
|
|
@ -881,6 +889,14 @@ export async function* streamAgent({
|
||||||
const model = provider.languageModel(modelId);
|
const model = provider.languageModel(modelId);
|
||||||
logger.log(`using model: ${modelId} (provider: ${state.runProvider})`);
|
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 loopCounter = 0;
|
||||||
let voiceInput = false;
|
let voiceInput = false;
|
||||||
let voiceOutput: 'summary' | 'full' | null = null;
|
let voiceOutput: 'summary' | 'full' | null = null;
|
||||||
|
|
@ -1114,6 +1130,13 @@ export async function* streamAgent({
|
||||||
instructionsWithDateTime,
|
instructionsWithDateTime,
|
||||||
tools,
|
tools,
|
||||||
signal,
|
signal,
|
||||||
|
{
|
||||||
|
useCase: state.runUseCase ?? "copilot_chat",
|
||||||
|
...(state.runSubUseCase ? { subUseCase: state.runSubUseCase } : {}),
|
||||||
|
agentName: state.agentName ?? undefined,
|
||||||
|
modelId,
|
||||||
|
providerName: state.runProvider!,
|
||||||
|
},
|
||||||
)) {
|
)) {
|
||||||
messageBuilder.ingest(event);
|
messageBuilder.ingest(event);
|
||||||
yield* processEvent({
|
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(
|
async function* streamLlm(
|
||||||
model: LanguageModel,
|
model: LanguageModel,
|
||||||
messages: z.infer<typeof MessageList>,
|
messages: z.infer<typeof MessageList>,
|
||||||
instructions: string,
|
instructions: string,
|
||||||
tools: ToolSet,
|
tools: ToolSet,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
analytics?: StreamLlmAnalytics,
|
||||||
): AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown> {
|
): AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown> {
|
||||||
const converted = convertFromMessages(messages);
|
const converted = convertFromMessages(messages);
|
||||||
console.log(`! SENDING payload to model: `, JSON.stringify(converted))
|
console.log(`! SENDING payload to model: `, JSON.stringify(converted))
|
||||||
|
|
@ -1277,6 +1309,16 @@ async function* streamLlm(
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case "finish-step":
|
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 {
|
yield {
|
||||||
type: "finish-step",
|
type: "finish-step",
|
||||||
usage: event.usage,
|
usage: event.usage,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,35 @@
|
||||||
import { bus } from "../runs/bus.js";
|
import { bus } from "../runs/bus.js";
|
||||||
import { fetchRun } from "../runs/runs.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.
|
* Extract the assistant's final text response from a run's log.
|
||||||
* @param runId
|
* @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
|
* Wait for a run to complete by listening for run-processing-end event
|
||||||
*/
|
*/
|
||||||
export async function waitForRunCompletion(runId: string): Promise<void> {
|
export async function waitForRunCompletion(
|
||||||
return new Promise(async (resolve) => {
|
runId: string,
|
||||||
const unsubscribe = await bus.subscribe('*', async (event) => {
|
opts: { throwOnError?: boolean } = {},
|
||||||
if (event.type === 'run-processing-end' && event.runId === runId) {
|
): Promise<RunRecord> {
|
||||||
unsubscribe();
|
return new Promise((resolve, reject) => {
|
||||||
resolve();
|
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.
|
**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.
|
**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)
|
## Learning About the User (save-to-memory)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,15 @@ export interface RuntimeContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getExecutionShell(platform: NodeJS.Platform = process.platform): string {
|
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 {
|
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`" + `
|
- page ` + "`url`" + ` and ` + "`title`" + `
|
||||||
- visible page text
|
- visible page text
|
||||||
- interactable elements with numbered ` + "`index`" + ` values
|
- interactable elements with numbered ` + "`index`" + ` values
|
||||||
4. Prefer acting on those numbered indices with ` + "`click`" + ` / ` + "`type`" + ` / ` + "`press`" + `.
|
- ` + "`suggestedSkills`" + ` — site-specific and interaction-specific skill hints for the current page
|
||||||
5. After each action, read the returned page snapshot before deciding the next step.
|
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
|
## Actions
|
||||||
|
|
||||||
|
|
@ -92,12 +94,23 @@ Wait for the page to settle, useful after async UI changes.
|
||||||
Parameters:
|
Parameters:
|
||||||
- ` + "`ms`" + `: milliseconds to wait (optional)
|
- ` + "`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
|
## Important Rules
|
||||||
|
|
||||||
- Prefer ` + "`read-page`" + ` before interacting.
|
- Prefer ` + "`read-page`" + ` before interacting.
|
||||||
- Prefer element ` + "`index`" + ` over CSS selectors.
|
- Prefer element ` + "`index`" + ` over CSS selectors.
|
||||||
- If the tool says the snapshot is stale, call ` + "`read-page`" + ` again.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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 mcpIntegrationSkill from "./mcp-integration/skill.js";
|
||||||
import meetingPrepSkill from "./meeting-prep/skill.js";
|
import meetingPrepSkill from "./meeting-prep/skill.js";
|
||||||
import organizeFilesSkill from "./organize-files/skill.js";
|
import organizeFilesSkill from "./organize-files/skill.js";
|
||||||
import backgroundAgentsSkill from "./background-agents/skill.js";
|
|
||||||
import createPresentationsSkill from "./create-presentations/skill.js";
|
import createPresentationsSkill from "./create-presentations/skill.js";
|
||||||
|
|
||||||
import appNavigationSkill from "./app-navigation/skill.js";
|
import appNavigationSkill from "./app-navigation/skill.js";
|
||||||
import browserControlSkill from "./browser-control/skill.js";
|
import browserControlSkill from "./browser-control/skill.js";
|
||||||
import composioIntegrationSkill from "./composio-integration/skill.js";
|
import composioIntegrationSkill from "./composio-integration/skill.js";
|
||||||
import tracksSkill from "./tracks/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 CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const CATALOG_PREFIX = "src/application/assistant/skills";
|
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.",
|
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,
|
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",
|
id: "builtin-tools",
|
||||||
title: "Builtin Tools Reference",
|
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.",
|
summary: "Control the embedded browser pane - open sites, inspect page state, and interact with indexed page elements.",
|
||||||
content: browserControlSkill,
|
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) => ({
|
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.
|
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.
|
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
|
## The Exact Text to Insert
|
||||||
|
|
||||||
Write it verbatim like this (including the blank line between fence and target):
|
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 { 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 { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
|
||||||
import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.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 type { ToolContext } from "./exec-tool.js";
|
||||||
import { generateText } from "ai";
|
import { generateText } from "ai";
|
||||||
import { createProvider } from "../../models/models.js";
|
import { createProvider } from "../../models/models.js";
|
||||||
import { getDefaultModelAndProvider, resolveProviderConfig } from "../../models/defaults.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 { isSignedIn } from "../../account/account.js";
|
||||||
import { getAccessToken } from "../../auth/tokens.js";
|
import { getAccessToken } from "../../auth/tokens.js";
|
||||||
import { API_URL } from "../../config/env.js";
|
import { API_URL } from "../../config/env.js";
|
||||||
import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js";
|
import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js";
|
||||||
import type { IBrowserControlService } from "../browser-control/service.js";
|
import type { IBrowserControlService } from "../browser-control/service.js";
|
||||||
|
import type { INotificationService } from "../notification/service.js";
|
||||||
// Parser libraries are loaded dynamically inside parseFile.execute()
|
// Parser libraries are loaded dynamically inside parseFile.execute()
|
||||||
// to avoid pulling pdfjs-dist's DOM polyfills into the main bundle.
|
// to avoid pulling pdfjs-dist's DOM polyfills into the main bundle.
|
||||||
// Import paths are computed so esbuild cannot statically resolve them.
|
// 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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
fileName,
|
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
|
// 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 COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/;
|
||||||
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
|
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
|
||||||
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
|
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
|
||||||
const EXECUTION_SHELL = getExecutionShell();
|
|
||||||
|
|
||||||
function sanitizeToken(token: string): string {
|
function sanitizeToken(token: string): string {
|
||||||
return token.trim().replace(/^['"()]+|['"()]+$/g, '');
|
return token.trim().replace(/^['"()]+|['"()]+$/g, '');
|
||||||
|
|
@ -84,11 +83,12 @@ export async function executeCommand(
|
||||||
}
|
}
|
||||||
): Promise<CommandResult> {
|
): Promise<CommandResult> {
|
||||||
try {
|
try {
|
||||||
|
const shell = getExecutionShell();
|
||||||
const { stdout, stderr } = await execPromise(command, {
|
const { stdout, stderr } = await execPromise(command, {
|
||||||
cwd: options?.cwd,
|
cwd: options?.cwd,
|
||||||
timeout: options?.timeout,
|
timeout: options?.timeout,
|
||||||
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
|
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
|
||||||
shell: EXECUTION_SHELL,
|
shell,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -161,8 +161,9 @@ export function executeCommandAbortable(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shell = getExecutionShell();
|
||||||
const proc = spawn(command, [], {
|
const proc = spawn(command, [], {
|
||||||
shell: EXECUTION_SHELL,
|
shell,
|
||||||
cwd: options?.cwd,
|
cwd: options?.cwd,
|
||||||
detached: process.platform !== 'win32', // Create process group on Unix
|
detached: process.platform !== 'win32', // Create process group on Unix
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
|
@ -272,11 +273,12 @@ export function executeCommandSync(
|
||||||
}
|
}
|
||||||
): CommandResult {
|
): CommandResult {
|
||||||
try {
|
try {
|
||||||
|
const shell = getExecutionShell();
|
||||||
const stdout = execSync(command, {
|
const stdout = execSync(command, {
|
||||||
cwd: options?.cwd,
|
cwd: options?.cwd,
|
||||||
timeout: options?.timeout,
|
timeout: options?.timeout,
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
shell: EXECUTION_SHELL,
|
shell,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
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(),
|
tokens: OAuthTokens.nullable().optional(),
|
||||||
clientId: z.string().nullable().optional(),
|
clientId: z.string().nullable().optional(),
|
||||||
clientSecret: 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(),
|
error: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,6 @@ async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||||
*/
|
*/
|
||||||
const ZComposioConfig = z.object({
|
const ZComposioConfig = z.object({
|
||||||
apiKey: z.string().optional(),
|
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>;
|
type ComposioConfig = z.infer<typeof ZComposioConfig>;
|
||||||
|
|
@ -106,24 +104,6 @@ export async function isConfigured(): Promise<boolean> {
|
||||||
return !!getApiKey();
|
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
|
* Make an API call to Composio
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
export const API_URL =
|
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 { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
|
||||||
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
|
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
|
||||||
import type { IBrowserControlService } from "../application/browser-control/service.js";
|
import type { IBrowserControlService } from "../application/browser-control/service.js";
|
||||||
|
import type { INotificationService } from "../application/notification/service.js";
|
||||||
|
|
||||||
const container = createContainer({
|
const container = createContainer({
|
||||||
injectionMode: InjectionMode.PROXY,
|
injectionMode: InjectionMode.PROXY,
|
||||||
|
|
@ -49,3 +50,9 @@ export function registerBrowserControlService(service: IBrowserControlService):
|
||||||
browserControlService: asValue(service),
|
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 { WorkDir } from '../config/config.js';
|
||||||
import { createRun, createMessage } from '../runs/runs.js';
|
import { createRun, createMessage } from '../runs/runs.js';
|
||||||
import { getKgModel } from '../models/defaults.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 { serviceLogger } from '../services/service_logger.js';
|
||||||
import { loadUserConfig, updateUserEmail } from '../config/user_config.js';
|
import { loadUserConfig, updateUserEmail } from '../config/user_config.js';
|
||||||
import { GoogleClientFactory } from './google-client-factory.js';
|
import { GoogleClientFactory } from './google-client-factory.js';
|
||||||
import { useComposioForGoogle, executeAction } from '../composio/client.js';
|
|
||||||
import { composioAccountsRepo } from '../composio/repo.js';
|
|
||||||
import {
|
import {
|
||||||
loadAgentNotesState,
|
loadAgentNotesState,
|
||||||
saveAgentNotesState,
|
saveAgentNotesState,
|
||||||
|
|
@ -199,30 +197,7 @@ async function ensureUserEmail(): Promise<string | null> {
|
||||||
return existing.email;
|
return existing.email;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try Composio (used when signed in or composio configured)
|
// Try direct Google OAuth (covers both BYOK and rowboat modes)
|
||||||
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 {
|
try {
|
||||||
const auth = await GoogleClientFactory.getClient();
|
const auth = await GoogleClientFactory.getClient();
|
||||||
if (auth) {
|
if (auth) {
|
||||||
|
|
@ -306,9 +281,14 @@ async function processAgentNotes(): Promise<void> {
|
||||||
const timestamp = new Date().toISOString();
|
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 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 createMessage(agentRun.id, message);
|
||||||
await waitForRunCompletion(agentRun.id);
|
await waitForRunCompletion(agentRun.id, { throwOnError: true });
|
||||||
|
|
||||||
// Mark everything as processed
|
// Mark everything as processed
|
||||||
for (const p of emailPaths) {
|
for (const p of emailPaths) {
|
||||||
|
|
@ -346,7 +326,16 @@ async function processAgentNotes(): Promise<void> {
|
||||||
runId: serviceRun.runId,
|
runId: serviceRun.runId,
|
||||||
level: 'error',
|
level: 'error',
|
||||||
message: 'Error processing agent notes',
|
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 { WorkDir } from '../config/config.js';
|
||||||
import { createRun, createMessage } from '../runs/runs.js';
|
import { createRun, createMessage } from '../runs/runs.js';
|
||||||
import { bus } from '../runs/bus.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 { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||||
import {
|
import {
|
||||||
loadState,
|
loadState,
|
||||||
|
|
@ -252,6 +252,8 @@ async function createNotesFromBatch(
|
||||||
// Create a run for the note creation agent
|
// Create a run for the note creation agent
|
||||||
const run = await createRun({
|
const run = await createRun({
|
||||||
agentId: NOTE_CREATION_AGENT,
|
agentId: NOTE_CREATION_AGENT,
|
||||||
|
useCase: 'knowledge_sync',
|
||||||
|
subUseCase: 'build_graph',
|
||||||
});
|
});
|
||||||
const suggestedTopicsContent = readSuggestedTopicsFile();
|
const suggestedTopicsContent = readSuggestedTopicsFile();
|
||||||
|
|
||||||
|
|
@ -310,8 +312,11 @@ async function createNotesFromBatch(
|
||||||
await createMessage(run.id, message);
|
await createMessage(run.id, message);
|
||||||
|
|
||||||
// Wait for the run to complete
|
// Wait for the run to complete
|
||||||
await waitForRunCompletion(run.id);
|
try {
|
||||||
unsubscribe();
|
await waitForRunCompletion(run.id, { throwOnError: true });
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
return { runId: run.id, notesCreated, notesModified };
|
return { runId: run.id, notesCreated, notesModified };
|
||||||
}
|
}
|
||||||
|
|
@ -426,7 +431,7 @@ async function buildGraphWithFiles(
|
||||||
runId: run.runId,
|
runId: run.runId,
|
||||||
level: 'error',
|
level: 'error',
|
||||||
message: `Error processing batch ${batchNumber}`,
|
message: `Error processing batch ${batchNumber}`,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: getErrorDetails(error),
|
||||||
context: { batchNumber },
|
context: { batchNumber },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -598,7 +603,7 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> {
|
||||||
runId: run.runId,
|
runId: run.runId,
|
||||||
level: 'error',
|
level: 'error',
|
||||||
message: `Error processing voice memo batch ${batchNumber}`,
|
message: `Error processing voice memo batch ${batchNumber}`,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: getErrorDetails(error),
|
||||||
context: { batchNumber },
|
context: { batchNumber },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,44 @@ import { getProviderConfig } from '../auth/providers.js';
|
||||||
import * as oauthClient from '../auth/oauth-client.js';
|
import * as oauthClient from '../auth/oauth-client.js';
|
||||||
import type { Configuration } from '../auth/oauth-client.js';
|
import type { Configuration } from '../auth/oauth-client.js';
|
||||||
import { OAuthTokens } from '../auth/types.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.
|
* Factory for creating and managing Google OAuth2Client instances.
|
||||||
* Handles caching, token refresh, and client reuse for Google API SDKs.
|
* 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 {
|
export class GoogleClientFactory {
|
||||||
private static readonly PROVIDER_NAME = 'google';
|
private static readonly PROVIDER_NAME = 'google';
|
||||||
private static cache: {
|
private static cache: {
|
||||||
|
mode: Mode | null;
|
||||||
config: Configuration | null;
|
config: Configuration | null;
|
||||||
client: OAuth2Client | null;
|
client: OAuth2Client | null;
|
||||||
tokens: OAuthTokens | null;
|
tokens: OAuthTokens | null;
|
||||||
clientId: string | null;
|
clientId: string | null;
|
||||||
clientSecret: string | null;
|
clientSecret: string | null;
|
||||||
} = {
|
} = {
|
||||||
|
mode: null,
|
||||||
config: null,
|
config: null,
|
||||||
client: null,
|
client: null,
|
||||||
tokens: null,
|
tokens: null,
|
||||||
|
|
@ -27,7 +51,14 @@ export class GoogleClientFactory {
|
||||||
clientSecret: null,
|
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 oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||||
const connection = await oauthRepo.read(this.PROVIDER_NAME);
|
const connection = await oauthRepo.read(this.PROVIDER_NAME);
|
||||||
if (!connection.clientId) {
|
if (!connection.clientId) {
|
||||||
|
|
@ -41,80 +72,116 @@ export class GoogleClientFactory {
|
||||||
* Get or create OAuth2Client, reusing cached instance when possible
|
* Get or create OAuth2Client, reusing cached instance when possible
|
||||||
*/
|
*/
|
||||||
static async getClient(): Promise<OAuth2Client | null> {
|
static async getClient(): Promise<OAuth2Client | null> {
|
||||||
|
if (this.refreshInFlight) {
|
||||||
|
return this.refreshInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
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) {
|
if (!tokens) {
|
||||||
this.clearCache();
|
this.clearCache();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize config cache if needed
|
// Mode flipped (e.g. user disconnected then reconnected differently) — invalidate.
|
||||||
try {
|
if (this.cache.mode && this.cache.mode !== mode) {
|
||||||
await this.initializeConfigCache();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[OAuth] Failed to initialize Google OAuth configuration:", error);
|
|
||||||
this.clearCache();
|
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)) {
|
if (oauthClient.isTokenExpired(tokens)) {
|
||||||
// Token expired, try to refresh
|
|
||||||
if (!tokens.refresh_token) {
|
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.' });
|
await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Missing refresh token. Please reconnect.' });
|
||||||
this.clearCache();
|
this.clearCache();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
this.refreshInFlight = this.refreshAndBuild(tokens, mode).finally(() => {
|
||||||
console.log(`[OAuth] Token expired, refreshing access token...`);
|
this.refreshInFlight = null;
|
||||||
const existingScopes = tokens.scopes;
|
});
|
||||||
const refreshedTokens = await oauthClient.refreshTokens(
|
return this.refreshInFlight;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reuse client if tokens haven't changed
|
// 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;
|
return this.cache.client;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new client with current tokens
|
// Build a fresh client for current tokens
|
||||||
console.log(`[OAuth] Creating new OAuth2Client instance`);
|
return this.buildAndCacheClient(tokens, mode);
|
||||||
this.cache.tokens = tokens;
|
}
|
||||||
if (!this.cache.clientId) {
|
|
||||||
const creds = await this.resolveCredentials();
|
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.clientId = creds.clientId;
|
||||||
this.cache.clientSecret = creds.clientSecret ?? null;
|
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)
|
* Clear cache (useful for testing or when credentials are revoked)
|
||||||
*/
|
*/
|
||||||
static clearCache(): void {
|
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.config = null;
|
||||||
this.cache.client = null;
|
this.cache.client = null;
|
||||||
this.cache.tokens = 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> {
|
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)) {
|
if (this.cache.config && this.cache.clientId === clientId && this.cache.clientSecret === (clientSecret ?? null)) {
|
||||||
return; // Already initialized for these credentials
|
return; // Already initialized for these credentials
|
||||||
|
|
@ -161,13 +229,13 @@ export class GoogleClientFactory {
|
||||||
this.clearCache();
|
this.clearCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[OAuth] Initializing Google OAuth configuration...`);
|
console.log('[OAuth] Initializing Google OAuth configuration...');
|
||||||
const providerConfig = await getProviderConfig(this.PROVIDER_NAME);
|
const providerConfig = await getProviderConfig(this.PROVIDER_NAME);
|
||||||
|
|
||||||
if (providerConfig.discovery.mode === 'issuer') {
|
if (providerConfig.discovery.mode === 'issuer') {
|
||||||
if (providerConfig.client.mode === 'static') {
|
if (providerConfig.client.mode === 'static') {
|
||||||
// Discover endpoints, use static client ID
|
// 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(
|
this.cache.config = await oauthClient.discoverConfiguration(
|
||||||
providerConfig.discovery.issuer,
|
providerConfig.discovery.issuer,
|
||||||
clientId,
|
clientId,
|
||||||
|
|
@ -175,7 +243,7 @@ export class GoogleClientFactory {
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// DCR mode - need existing registration
|
// 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 clientRepo = container.resolve<IClientRegistrationRepo>('clientRegistrationRepo');
|
||||||
const existingRegistration = await clientRepo.getClientRegistration(this.PROVIDER_NAME);
|
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"');
|
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(
|
this.cache.config = oauthClient.createStaticConfiguration(
|
||||||
providerConfig.discovery.authorizationEndpoint,
|
providerConfig.discovery.authorizationEndpoint,
|
||||||
providerConfig.discovery.tokenEndpoint,
|
providerConfig.discovery.tokenEndpoint,
|
||||||
|
|
@ -206,27 +274,33 @@ export class GoogleClientFactory {
|
||||||
|
|
||||||
this.cache.clientId = clientId;
|
this.cache.clientId = clientId;
|
||||||
this.cache.clientSecret = clientSecret ?? null;
|
this.cache.clientSecret = clientSecret ?? null;
|
||||||
console.log(`[OAuth] Google OAuth configuration initialized`);
|
console.log('[OAuth] Google OAuth configuration initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** BYOK OAuth2Client — has client_id + secret + refresh_token. */
|
||||||
* Create OAuth2Client from OAuthTokens
|
private static createByokClient(tokens: OAuthTokens, clientId: string, clientSecret?: string): OAuth2Client {
|
||||||
*/
|
const client = new OAuth2Client(clientId, clientSecret ?? undefined, undefined);
|
||||||
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
|
|
||||||
client.setCredentials({
|
client.setCredentials({
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
refresh_token: tokens.refresh_token || undefined,
|
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,
|
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;
|
return client;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type { IModelConfigRepo } from '../models/repo.js';
|
||||||
import { createProvider } from '../models/models.js';
|
import { createProvider } from '../models/models.js';
|
||||||
import { inlineTask } from '@x/shared';
|
import { inlineTask } from '@x/shared';
|
||||||
import { extractAgentResponse, waitForRunCompletion } from '../agents/utils.js';
|
import { extractAgentResponse, waitForRunCompletion } from '../agents/utils.js';
|
||||||
|
import { captureLlmUsage } from '../analytics/usage.js';
|
||||||
|
|
||||||
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
|
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
|
||||||
const INLINE_TASK_AGENT = 'inline_task_agent';
|
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)}..."`);
|
console.log(`[InlineTasks] Running task: "${task.instruction.slice(0, 80)}..."`);
|
||||||
|
|
||||||
try {
|
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 = [
|
const message = [
|
||||||
`Execute the following instruction from the note "${relativePath}":`,
|
`Execute the following instruction from the note "${relativePath}":`,
|
||||||
|
|
@ -548,7 +554,12 @@ export async function processRowboatInstruction(
|
||||||
scheduleLabel: string | null;
|
scheduleLabel: string | null;
|
||||||
response: 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 = [
|
const message = [
|
||||||
`Process the following @rowboat instruction from the note "${notePath}":`,
|
`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,
|
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();
|
let text = result.text.trim();
|
||||||
console.log('[classifySchedule] LLM response:', text);
|
console.log('[classifySchedule] LLM response:', text);
|
||||||
// Strip markdown code fences if the LLM wraps the JSON
|
// 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 { createRun, createMessage } from '../runs/runs.js';
|
||||||
import { getKgModel } from '../models/defaults.js';
|
import { getKgModel } from '../models/defaults.js';
|
||||||
import { bus } from '../runs/bus.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 { serviceLogger } from '../services/service_logger.js';
|
||||||
import { limitEventItems } from './limit_event_items.js';
|
import { limitEventItems } from './limit_event_items.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -73,6 +73,8 @@ async function labelEmailBatch(
|
||||||
const run = await createRun({
|
const run = await createRun({
|
||||||
agentId: LABELING_AGENT,
|
agentId: LABELING_AGENT,
|
||||||
model: await getKgModel(),
|
model: await getKgModel(),
|
||||||
|
useCase: 'knowledge_sync',
|
||||||
|
subUseCase: 'label_emails',
|
||||||
});
|
});
|
||||||
|
|
||||||
let message = `Label the following ${files.length} email files by prepending YAML frontmatter.\n\n`;
|
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 createMessage(run.id, message);
|
||||||
await waitForRunCompletion(run.id);
|
try {
|
||||||
unsubscribe();
|
await waitForRunCompletion(run.id, { throwOnError: true });
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
return { runId: run.id, filesEdited };
|
return { runId: run.id, filesEdited };
|
||||||
}
|
}
|
||||||
|
|
@ -173,6 +178,7 @@ export async function processUnlabeledEmails(concurrency: number = DEFAULT_CONCU
|
||||||
const totalBatches = batches.length;
|
const totalBatches = batches.length;
|
||||||
let totalEdited = 0;
|
let totalEdited = 0;
|
||||||
let hadError = false;
|
let hadError = false;
|
||||||
|
let failedBatches = 0;
|
||||||
|
|
||||||
// Process batches with concurrency limit
|
// Process batches with concurrency limit
|
||||||
for (let i = 0; i < batches.length; i += concurrency) {
|
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;
|
return result.filesEdited.size;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hadError = true;
|
hadError = true;
|
||||||
|
failedBatches++;
|
||||||
|
const errorDetails = getErrorDetails(error);
|
||||||
console.error(`[EmailLabeling] Error processing batch ${batchNumber}:`, error);
|
console.error(`[EmailLabeling] Error processing batch ${batchNumber}:`, error);
|
||||||
await serviceLogger.log({
|
await serviceLogger.log({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
service: run.service,
|
service: run.service,
|
||||||
runId: run.runId,
|
runId: run.runId,
|
||||||
level: 'error',
|
level: 'error',
|
||||||
message: `Error processing batch ${batchNumber}`,
|
message: `Email labeling batch ${batchNumber}/${totalBatches} failed`,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: errorDetails,
|
||||||
context: { batchNumber },
|
context: { batchNumber },
|
||||||
});
|
});
|
||||||
return 0;
|
return 0;
|
||||||
|
|
@ -236,12 +244,15 @@ export async function processUnlabeledEmails(concurrency: number = DEFAULT_CONCU
|
||||||
service: run.service,
|
service: run.service,
|
||||||
runId: run.runId,
|
runId: run.runId,
|
||||||
level: hadError ? 'error' : 'info',
|
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,
|
durationMs: Date.now() - run.startedAt,
|
||||||
outcome: hadError ? 'error' : 'ok',
|
outcome: hadError ? 'error' : 'ok',
|
||||||
summary: {
|
summary: {
|
||||||
totalEmails: unlabeled.length,
|
totalEmails: unlabeled.length,
|
||||||
filesLabeled: totalEdited,
|
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.**
|
**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
|
## Activity Summary
|
||||||
|
|
||||||
One line summarizing this source's relevance to the entity:
|
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}
|
**Last update:** {YYYY-MM-DD}
|
||||||
|
|
||||||
## Summary
|
## 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
|
## Connected to
|
||||||
- [[Organizations/{Organization}]] — works at
|
- [[Organizations/{Organization}]] — works at
|
||||||
|
|
@ -59,7 +59,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
|
||||||
**Last update:** {YYYY-MM-DD}
|
**Last update:** {YYYY-MM-DD}
|
||||||
|
|
||||||
## Summary
|
## 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
|
||||||
- [[People/{Person}]] — {role}
|
- [[People/{Person}]] — {role}
|
||||||
|
|
@ -93,7 +93,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
|
||||||
**Last update:** {YYYY-MM-DD}
|
**Last update:** {YYYY-MM-DD}
|
||||||
|
|
||||||
## Summary
|
## 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
|
||||||
- [[People/{Person}]] — {role}
|
- [[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 { createProvider } from '../models/models.js';
|
||||||
import { getDefaultModelAndProvider, getMeetingNotesModel, resolveProviderConfig } from '../models/defaults.js';
|
import { getDefaultModelAndProvider, getMeetingNotesModel, resolveProviderConfig } from '../models/defaults.js';
|
||||||
import { WorkDir } from '../config/config.js';
|
import { WorkDir } from '../config/config.js';
|
||||||
|
import { captureLlmUsage } from '../analytics/usage.js';
|
||||||
|
|
||||||
const CALENDAR_SYNC_DIR = path.join(WorkDir, 'calendar_sync');
|
const CALENDAR_SYNC_DIR = path.join(WorkDir, 'calendar_sync');
|
||||||
|
|
||||||
|
|
@ -157,5 +158,12 @@ export async function summarizeMeeting(transcript: string, meetingStartTime?: st
|
||||||
prompt,
|
prompt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
captureLlmUsage({
|
||||||
|
useCase: 'meeting_note',
|
||||||
|
model: modelId,
|
||||||
|
provider: providerName,
|
||||||
|
usage: result.usage,
|
||||||
|
});
|
||||||
|
|
||||||
return result.text.trim();
|
return result.text.trim();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,8 @@ import { OAuth2Client } from 'google-auth-library';
|
||||||
import { NodeHtmlMarkdown } from 'node-html-markdown'
|
import { NodeHtmlMarkdown } from 'node-html-markdown'
|
||||||
import { WorkDir } from '../config/config.js';
|
import { WorkDir } from '../config/config.js';
|
||||||
import { GoogleClientFactory } from './google-client-factory.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 { 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';
|
import { createEvent } from './track/events.js';
|
||||||
|
|
||||||
const MAX_EVENTS_IN_DIGEST = 50;
|
const MAX_EVENTS_IN_DIGEST = 50;
|
||||||
|
|
@ -138,7 +136,6 @@ async function publishCalendarSyncEvent(
|
||||||
const SYNC_DIR = path.join(WorkDir, 'calendar_sync');
|
const SYNC_DIR = path.join(WorkDir, 'calendar_sync');
|
||||||
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
|
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
|
||||||
const LOOKBACK_DAYS = 7;
|
const LOOKBACK_DAYS = 7;
|
||||||
const COMPOSIO_LOOKBACK_DAYS = 7;
|
|
||||||
const REQUIRED_SCOPES = [
|
const REQUIRED_SCOPES = [
|
||||||
'https://www.googleapis.com/auth/calendar.events.readonly',
|
'https://www.googleapis.com/auth/calendar.events.readonly',
|
||||||
'https://www.googleapis.com/auth/drive.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() {
|
export async function init() {
|
||||||
console.log("Starting Google Calendar & Notes Sync (TS)...");
|
console.log("Starting Google Calendar & Notes Sync (TS)...");
|
||||||
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
|
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
const composioMode = await useComposioForGoogleCalendar();
|
const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPES);
|
||||||
if (composioMode) {
|
if (!hasCredentials) {
|
||||||
const isConnected = composioAccountsRepo.isConnected('googlecalendar');
|
console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping...");
|
||||||
if (!isConnected) {
|
|
||||||
console.log('[Calendar] Google Calendar not connected via Composio. Sleeping...');
|
|
||||||
} else {
|
|
||||||
await performSyncComposio();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Check if credentials are available with required scopes
|
await performSync(SYNC_DIR, LOOKBACK_DAYS);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in main loop:", 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 { GoogleClientFactory } from './google-client-factory.js';
|
||||||
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||||
import { limitEventItems } from './limit_event_items.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';
|
import { createEvent } from './track/events.js';
|
||||||
|
|
||||||
// Configuration
|
// 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)) {
|
if (fs.existsSync(stateFile)) {
|
||||||
return JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
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) {
|
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 });
|
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;
|
let run: ServiceRunContext | null = null;
|
||||||
const ensureRun = async () => {
|
const ensureRun = async () => {
|
||||||
if (!run) {
|
if (!run) {
|
||||||
|
|
@ -255,8 +268,6 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pastDate = new Date();
|
|
||||||
pastDate.setDate(pastDate.getDate() - lookbackDays);
|
|
||||||
const dateQuery = pastDate.toISOString().split('T')[0].replace(/-/g, '/');
|
const dateQuery = pastDate.toISOString().split('T')[0].replace(/-/g, '/');
|
||||||
|
|
||||||
// Get History ID
|
// 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() {
|
export async function init() {
|
||||||
console.log("Starting Gmail Sync (TS)...");
|
console.log("Starting Gmail Sync (TS)...");
|
||||||
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
|
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
const composioMode = await useComposioForGoogle();
|
const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE);
|
||||||
if (composioMode) {
|
if (!hasCredentials) {
|
||||||
const isConnected = composioAccountsRepo.isConnected('gmail');
|
console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping...");
|
||||||
if (!isConnected) {
|
|
||||||
console.log('[Gmail] Gmail not connected via Composio. Sleeping...');
|
|
||||||
} else {
|
|
||||||
await performSyncComposio();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Check if credentials are available with required scopes
|
await performSync();
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in main loop:", 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 { createRun, createMessage } from '../runs/runs.js';
|
||||||
import { getKgModel } from '../models/defaults.js';
|
import { getKgModel } from '../models/defaults.js';
|
||||||
import { bus } from '../runs/bus.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 { serviceLogger } from '../services/service_logger.js';
|
||||||
import { limitEventItems } from './limit_event_items.js';
|
import { limitEventItems } from './limit_event_items.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -86,6 +86,8 @@ async function tagNoteBatch(
|
||||||
const run = await createRun({
|
const run = await createRun({
|
||||||
agentId: NOTE_TAGGING_AGENT,
|
agentId: NOTE_TAGGING_AGENT,
|
||||||
model: await getKgModel(),
|
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`;
|
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 createMessage(run.id, message);
|
||||||
await waitForRunCompletion(run.id);
|
try {
|
||||||
unsubscribe();
|
await waitForRunCompletion(run.id, { throwOnError: true });
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
return { runId: run.id, filesEdited };
|
return { runId: run.id, filesEdited };
|
||||||
}
|
}
|
||||||
|
|
@ -167,6 +172,7 @@ export async function processUntaggedNotes(): Promise<void> {
|
||||||
const totalBatches = Math.ceil(untagged.length / BATCH_SIZE);
|
const totalBatches = Math.ceil(untagged.length / BATCH_SIZE);
|
||||||
let totalEdited = 0;
|
let totalEdited = 0;
|
||||||
let hadError = false;
|
let hadError = false;
|
||||||
|
let failedBatches = 0;
|
||||||
|
|
||||||
for (let i = 0; i < untagged.length; i += BATCH_SIZE) {
|
for (let i = 0; i < untagged.length; i += BATCH_SIZE) {
|
||||||
const batchPaths = untagged.slice(i, 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`);
|
console.log(`[NoteTagging] Batch ${batchNumber}/${totalBatches} complete, ${result.filesEdited.size} files tagged`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hadError = true;
|
hadError = true;
|
||||||
|
failedBatches++;
|
||||||
|
const errorDetails = getErrorDetails(error);
|
||||||
console.error(`[NoteTagging] Error processing batch ${batchNumber}:`, error);
|
console.error(`[NoteTagging] Error processing batch ${batchNumber}:`, error);
|
||||||
await serviceLogger.log({
|
await serviceLogger.log({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
service: run.service,
|
service: run.service,
|
||||||
runId: run.runId,
|
runId: run.runId,
|
||||||
level: 'error',
|
level: 'error',
|
||||||
message: `Error processing batch ${batchNumber}`,
|
message: `Note tagging batch ${batchNumber}/${totalBatches} failed`,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: errorDetails,
|
||||||
context: { batchNumber },
|
context: { batchNumber },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -236,12 +244,15 @@ export async function processUntaggedNotes(): Promise<void> {
|
||||||
service: run.service,
|
service: run.service,
|
||||||
runId: run.runId,
|
runId: run.runId,
|
||||||
level: hadError ? 'error' : 'info',
|
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,
|
durationMs: Date.now() - run.startedAt,
|
||||||
outcome: hadError ? 'error' : 'ok',
|
outcome: hadError ? 'error' : 'ok',
|
||||||
summary: {
|
summary: {
|
||||||
totalNotes: untagged.length,
|
totalNotes: untagged.length,
|
||||||
notesTagged: totalEdited,
|
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;
|
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).
|
* 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
|
* 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');
|
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 type { KnowledgeEvent } from '@x/shared/dist/track-block.js';
|
||||||
import { createProvider } from '../../models/models.js';
|
import { createProvider } from '../../models/models.js';
|
||||||
import { getDefaultModelAndProvider, getTrackBlockModel, resolveProviderConfig } from '../../models/defaults.js';
|
import { getDefaultModelAndProvider, getTrackBlockModel, resolveProviderConfig } from '../../models/defaults.js';
|
||||||
|
import { captureLlmUsage } from '../../analytics/usage.js';
|
||||||
|
|
||||||
const log = new PrefixLogger('TrackRouting');
|
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.`;
|
- For each candidate, return BOTH trackId and filePath exactly as given. trackIds are not globally unique.`;
|
||||||
|
|
||||||
async function resolveModel() {
|
async function resolveModel() {
|
||||||
const model = await getTrackBlockModel();
|
const modelId = await getTrackBlockModel();
|
||||||
const { provider } = await getDefaultModelAndProvider();
|
const { provider } = await getDefaultModelAndProvider();
|
||||||
const config = await resolveProviderConfig(provider);
|
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 {
|
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)`);
|
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>();
|
const candidateKeys = new Set<string>();
|
||||||
|
|
||||||
for (let i = 0; i < filtered.length; i += BATCH_SIZE) {
|
for (let i = 0; i < filtered.length; i += BATCH_SIZE) {
|
||||||
const batch = filtered.slice(i, i + BATCH_SIZE);
|
const batch = filtered.slice(i, i + BATCH_SIZE);
|
||||||
try {
|
try {
|
||||||
const { object } = await generateObject({
|
const result = await generateObject({
|
||||||
model,
|
model,
|
||||||
system: ROUTING_SYSTEM_PROMPT,
|
system: ROUTING_SYSTEM_PROMPT,
|
||||||
prompt: buildRoutingPrompt(event, batch),
|
prompt: buildRoutingPrompt(event, batch),
|
||||||
schema: trackBlock.Pass1OutputSchema,
|
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));
|
candidateKeys.add(trackKey(c.trackId, c.filePath));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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.
|
- **\`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.
|
- **\`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.
|
- **\`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
|
# The Knowledge Graph
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,8 @@ export async function triggerTrackUpdate(
|
||||||
agentId: 'track-run',
|
agentId: 'track-run',
|
||||||
model,
|
model,
|
||||||
...(track.track.provider ? { provider: track.track.provider } : {}),
|
...(track.track.provider ? { provider: track.track.provider } : {}),
|
||||||
|
useCase: 'track_block',
|
||||||
|
subUseCase: 'run',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set lastRunAt and lastRunId immediately (before agent executes) so
|
// 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_MODEL = "gpt-5.4";
|
||||||
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
|
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";
|
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({
|
const run = await createRun({
|
||||||
agentId: agentName,
|
agentId: agentName,
|
||||||
model: await getKgModel(),
|
model: await getKgModel(),
|
||||||
|
useCase: 'knowledge_sync',
|
||||||
|
subUseCase: 'pre_built',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build trigger message with user context
|
// Build trigger message with user context
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import path from "path";
|
||||||
import fsp from "fs/promises";
|
import fsp from "fs/promises";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import readline from "readline";
|
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";
|
import { getDefaultModelAndProvider } from "../models/defaults.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -24,7 +24,13 @@ const LegacyStartEvent = StartEvent.extend({
|
||||||
});
|
});
|
||||||
const ReadRunEvent = RunEvent.or(LegacyStartEvent);
|
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 {
|
export interface IRunsRepo {
|
||||||
create(options: CreateRunRepoOptions): Promise<z.infer<typeof Run>>;
|
create(options: CreateRunRepoOptions): Promise<z.infer<typeof Run>>;
|
||||||
|
|
@ -187,6 +193,8 @@ export class FSRunsRepo implements IRunsRepo {
|
||||||
agentName: options.agentId,
|
agentName: options.agentId,
|
||||||
model: options.model,
|
model: options.model,
|
||||||
provider: options.provider,
|
provider: options.provider,
|
||||||
|
useCase: options.useCase,
|
||||||
|
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
||||||
subflow: [],
|
subflow: [],
|
||||||
ts,
|
ts,
|
||||||
};
|
};
|
||||||
|
|
@ -197,6 +205,8 @@ export class FSRunsRepo implements IRunsRepo {
|
||||||
agentId: options.agentId,
|
agentId: options.agentId,
|
||||||
model: options.model,
|
model: options.model,
|
||||||
provider: options.provider,
|
provider: options.provider,
|
||||||
|
useCase: options.useCase,
|
||||||
|
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
||||||
log: [start],
|
log: [start],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -230,6 +240,8 @@ export class FSRunsRepo implements IRunsRepo {
|
||||||
agentId: start.agentName,
|
agentId: start.agentName,
|
||||||
model: start.model,
|
model: start.model,
|
||||||
provider: start.provider,
|
provider: start.provider,
|
||||||
|
...(start.useCase ? { useCase: start.useCase } : {}),
|
||||||
|
...(start.subUseCase ? { subUseCase: start.subUseCase } : {}),
|
||||||
log: events,
|
log: events,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,15 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
|
||||||
const defaults = await getDefaultModelAndProvider();
|
const defaults = await getDefaultModelAndProvider();
|
||||||
const model = opts.model ?? agent.model ?? defaults.model;
|
const model = opts.model ?? agent.model ?? defaults.model;
|
||||||
const provider = opts.provider ?? agent.provider ?? defaults.provider;
|
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]);
|
await bus.publish(run.log[0]);
|
||||||
return run;
|
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({
|
export const BrowserControlResultSchema = z.object({
|
||||||
success: z.boolean(),
|
success: z.boolean(),
|
||||||
action: BrowserControlActionSchema,
|
action: BrowserControlActionSchema,
|
||||||
|
|
@ -123,6 +129,7 @@ export const BrowserControlResultSchema = z.object({
|
||||||
error: z.string().optional(),
|
error: z.string().optional(),
|
||||||
browser: BrowserStateSchema,
|
browser: BrowserStateSchema,
|
||||||
page: BrowserPageSnapshotSchema.optional(),
|
page: BrowserPageSnapshotSchema.optional(),
|
||||||
|
suggestedSkills: z.array(SuggestedBrowserSkillSchema).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type BrowserTabState = z.infer<typeof BrowserTabStateSchema>;
|
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 BrowserControlAction = z.infer<typeof BrowserControlActionSchema>;
|
||||||
export type BrowserControlInput = z.infer<typeof BrowserControlInputSchema>;
|
export type BrowserControlInput = z.infer<typeof BrowserControlInputSchema>;
|
||||||
export type BrowserControlResult = z.infer<typeof BrowserControlResultSchema>;
|
export type BrowserControlResult = z.infer<typeof BrowserControlResultSchema>;
|
||||||
|
export type SuggestedBrowserSkill = z.infer<typeof SuggestedBrowserSkillSchema>;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,13 @@ const ipcSchemas = {
|
||||||
electron: z.string(),
|
electron: z.string(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
'analytics:bootstrap': {
|
||||||
|
req: z.null(),
|
||||||
|
res: z.object({
|
||||||
|
installationId: z.string(),
|
||||||
|
apiUrl: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
'workspace:getRoot': {
|
'workspace:getRoot': {
|
||||||
req: z.null(),
|
req: z.null(),
|
||||||
res: z.object({
|
res: z.object({
|
||||||
|
|
@ -292,6 +299,28 @@ const ipcSchemas = {
|
||||||
}),
|
}),
|
||||||
res: z.null(),
|
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': {
|
'granola:getConfig': {
|
||||||
req: z.null(),
|
req: z.null(),
|
||||||
res: z.object({
|
res: z.object({
|
||||||
|
|
@ -400,16 +429,10 @@ const ipcSchemas = {
|
||||||
toolkits: z.array(z.string()),
|
toolkits: z.array(z.string()),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
'composio:use-composio-for-google': {
|
'migration:check-composio-google': {
|
||||||
req: z.null(),
|
req: z.null(),
|
||||||
res: z.object({
|
res: z.object({
|
||||||
enabled: z.boolean(),
|
shouldShow: z.boolean(),
|
||||||
}),
|
|
||||||
},
|
|
||||||
'composio:use-composio-for-google-calendar': {
|
|
||||||
req: z.null(),
|
|
||||||
res: z.object({
|
|
||||||
enabled: z.boolean(),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
'composio:didConnect': {
|
'composio:didConnect': {
|
||||||
|
|
@ -639,6 +662,35 @@ const ipcSchemas = {
|
||||||
error: z.string().optional(),
|
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
|
// Embedded browser (WebContentsView) channels
|
||||||
'browser:setBounds': {
|
'browser:setBounds': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,15 @@ export const StartEvent = BaseRunEvent.extend({
|
||||||
agentName: z.string(),
|
agentName: z.string(),
|
||||||
model: z.string(),
|
model: z.string(),
|
||||||
provider: 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({
|
export const SpawnSubFlowEvent = BaseRunEvent.extend({
|
||||||
|
|
@ -118,6 +127,13 @@ export const AskHumanResponsePayload = AskHumanResponseEvent.pick({
|
||||||
response: true,
|
response: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const UseCase = z.enum([
|
||||||
|
"copilot_chat",
|
||||||
|
"track_block",
|
||||||
|
"meeting_note",
|
||||||
|
"knowledge_sync",
|
||||||
|
]);
|
||||||
|
|
||||||
export const Run = z.object({
|
export const Run = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
|
|
@ -125,6 +141,8 @@ export const Run = z.object({
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
model: z.string(),
|
model: z.string(),
|
||||||
provider: z.string(),
|
provider: z.string(),
|
||||||
|
useCase: UseCase.optional(),
|
||||||
|
subUseCase: z.string().optional(),
|
||||||
log: z.array(RunEvent),
|
log: z.array(RunEvent),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -142,4 +160,6 @@ export const CreateRunOptions = z.object({
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
provider: 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:
|
pdf-parse:
|
||||||
specifier: ^2.4.5
|
specifier: ^2.4.5
|
||||||
version: 2.4.5
|
version: 2.4.5
|
||||||
|
posthog-node:
|
||||||
|
specifier: ^4.18.0
|
||||||
|
version: 4.18.0
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.3
|
specifier: ^19.2.3
|
||||||
version: 19.2.3
|
version: 19.2.3
|
||||||
|
|
@ -6471,6 +6474,10 @@ packages:
|
||||||
posthog-js@1.332.0:
|
posthog-js@1.332.0:
|
||||||
resolution: {integrity: sha512-w3+sL+IFK4mpfFmgTW7On8cR+z34pre+SOewx+eHZQSYF9RYqXsLIhrxagWbQKkowPd4tCwUHrkS1+VHsjnPqA==}
|
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:
|
postject@1.0.0-alpha.6:
|
||||||
resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==}
|
resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
@ -15203,6 +15210,12 @@ snapshots:
|
||||||
query-selector-shadow-dom: 1.0.1
|
query-selector-shadow-dom: 1.0.1
|
||||||
web-vitals: 4.2.4
|
web-vitals: 4.2.4
|
||||||
|
|
||||||
|
posthog-node@4.18.0:
|
||||||
|
dependencies:
|
||||||
|
axios: 1.13.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
postject@1.0.0-alpha.6:
|
postject@1.0.0-alpha.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
commander: 9.5.0
|
commander: 9.5.0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue