mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-11 00:02:38 +02:00
feat: native desktop notifications + rowboat:// deep links
Adds INotificationService with an Electron implementation, plus a deep-link
dispatcher (rowboat://) for routing notification clicks back into the app.
Notifications:
- New `notify-user` skill + builtin tool. Title, message, optional primary
link, optional secondary actions. Supports https:// (opens in browser) and
rowboat:// (opens in app) targets.
- ElectronNotificationService holds strong refs to active Notification
instances so click handlers survive GC (otherwise macOS click silently
no-ops).
- Calendar meeting notifier fires 1-min warnings with "take notes" /
"join + take notes" actions backed by deep links.
Deep links (rowboat://):
- forge.config.cjs declares the protocol; main.ts wires single-instance
lock, setAsDefaultProtocolClient, open-url (mac), second-instance (win/
linux), and first-launch argv extraction.
- New deeplink.ts dispatcher with dispatchUrl(url): main-handled actions
(rowboat://action?type=...) vs renderer navigation (rowboat://open?...)
via app:openUrl IPC. Includes pending-URL buffering for first-launch
delivery before the renderer is ready.
- Renderer parseDeepLink supports file / chat / graph / task /
suggested-topics targets.
- New app:consumePendingDeepLink IPC for renderer one-time drain on mount.
Refactor: extractConferenceLink moved out of calendar-block.tsx into
shared lib/calendar-event.ts (used by both the block and the take-notes
deep-link handler)
This commit is contained in:
parent
9ed54e2b94
commit
1c2b2ac1fc
17 changed files with 712 additions and 20 deletions
|
|
@ -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.)',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
118
apps/x/apps/main/src/deeplink.ts
Normal file
118
apps/x/apps/main/src/deeplink.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
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 navigation vs action automatically.
|
||||||
|
* Use this from notification click handlers and other URL entry points.
|
||||||
|
*/
|
||||||
|
export function dispatchUrl(url: string): void {
|
||||||
|
if (parseAction(url)) {
|
||||||
|
void dispatchAction(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusWindow(win: BrowserWindow): void {
|
||||||
|
if (win.isMinimized()) win.restore();
|
||||||
|
win.show();
|
||||||
|
win.focus();
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ 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 { 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';
|
||||||
|
|
@ -417,6 +418,9 @@ 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 () => {
|
'analytics:bootstrap': async () => {
|
||||||
return {
|
return {
|
||||||
installationId: getInstallationId(),
|
installationId: getInstallationId(),
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ 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";
|
||||||
|
|
@ -34,10 +35,17 @@ 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,
|
||||||
|
dispatchDeepLink,
|
||||||
|
extractDeepLinkFromArgv,
|
||||||
|
setMainWindowForDeepLinks,
|
||||||
|
} from "./deeplink.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
|
@ -47,6 +55,43 @@ 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()) {
|
||||||
|
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) dispatchDeepLink(initialUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS sends URLs via 'open-url' (both first launch and while running).
|
||||||
|
app.on("open-url", (event, url) => {
|
||||||
|
event.preventDefault();
|
||||||
|
dispatchDeepLink(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) dispatchDeepLink(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.).
|
||||||
|
|
@ -165,6 +210,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();
|
||||||
|
|
@ -240,6 +288,7 @@ app.whenReady().then(async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
registerBrowserControlService(new ElectronBrowserControlService());
|
registerBrowserControlService(new ElectronBrowserControlService());
|
||||||
|
registerNotificationService(new ElectronNotificationService());
|
||||||
|
|
||||||
setupIpcHandlers();
|
setupIpcHandlers();
|
||||||
setupBrowserEventForwarding();
|
setupBrowserEventForwarding();
|
||||||
|
|
@ -298,6 +347,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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,6 +54,7 @@ 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 { 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'
|
||||||
|
|
@ -515,6 +516,45 @@ 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>&...
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
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' }
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Sidebar toggle (fixed position, top-left) */
|
/** Sidebar toggle (fixed position, top-left) */
|
||||||
function FixedSidebarToggle({
|
function FixedSidebarToggle({
|
||||||
leftInsetPx,
|
leftInsetPx,
|
||||||
|
|
@ -3050,6 +3090,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 }))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ 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";
|
||||||
|
|
@ -112,6 +113,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;
|
||||||
|
|
@ -29,6 +29,7 @@ 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.
|
||||||
|
|
@ -1526,4 +1527,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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -299,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({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue