mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-28 09:56:23 +02:00
meetings and knowledge improvements
- Limit Granola sync to 30-day lookback period - Move Granola and Fireflies sync dirs under knowledge/Meetings/ - Note creation agent links to source meeting notes in activity entries - Note creation agent links to Gmail threads via web URL - Add Meetings to note type definitions with recursive tag scanning - Tagging agent extracts meeting metadata (date, source, attendees, title, topic) - Clicking Knowledge with no tab open auto-opens Bases view Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
16b8975b00
commit
7e15c1231d
8 changed files with 114 additions and 25 deletions
|
|
@ -28,8 +28,8 @@ const NOTE_CREATION_AGENT = 'note_creation';
|
|||
const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
|
||||
const SOURCE_FOLDERS = [
|
||||
'gmail_sync',
|
||||
'fireflies_transcripts',
|
||||
'granola_notes',
|
||||
path.join('knowledge', 'Meetings', 'fireflies'),
|
||||
path.join('knowledge', 'Meetings', 'granola'),
|
||||
];
|
||||
|
||||
// Voice memos are now created directly in knowledge/Voice Memos/<date>/
|
||||
|
|
@ -192,7 +192,9 @@ async function createNotesFromBatch(
|
|||
// Add each file's content
|
||||
message += `# Source Files to Process\n\n`;
|
||||
files.forEach((file, idx) => {
|
||||
message += `## Source File ${idx + 1}: ${path.basename(file.path)}\n\n`;
|
||||
// Pass workspace-relative path so the agent can link back to meeting notes
|
||||
const relativePath = path.relative(WorkDir, file.path);
|
||||
message += `## Source File ${idx + 1}: ${relativePath}\n\n`;
|
||||
message += file.content;
|
||||
message += `\n\n---\n\n`;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,13 +17,14 @@ import {
|
|||
const GRANOLA_CLIENT_VERSION = '6.462.1';
|
||||
const GRANOLA_API_BASE = 'https://api.granola.ai';
|
||||
const GRANOLA_CONFIG_PATH = path.join(homedir(), 'Library', 'Application Support', 'Granola', 'supabase.json');
|
||||
const SYNC_DIR = path.join(WorkDir, 'granola_notes');
|
||||
const SYNC_DIR = path.join(WorkDir, 'knowledge', 'Meetings', 'granola');
|
||||
const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json');
|
||||
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
|
||||
const API_DELAY_MS = 1000; // 1 second delay between API calls
|
||||
const RATE_LIMIT_RETRY_DELAY_MS = 60 * 1000; // Wait 1 minute on rate limit
|
||||
const MAX_RETRIES = 3; // Maximum retries for rate-limited requests
|
||||
const MAX_BATCH_SIZE = 10; // Process max 10 documents per folder per sync
|
||||
const LOOKBACK_DAYS = 30; // Only sync documents from the last 30 days
|
||||
|
||||
// --- Wake Signal for Immediate Sync Trigger ---
|
||||
let wakeResolve: (() => void) | null = null;
|
||||
|
|
@ -370,6 +371,10 @@ async function syncNotes(): Promise<void> {
|
|||
let hasMore = true;
|
||||
const changedTitles: string[] = [];
|
||||
|
||||
// Calculate lookback cutoff date
|
||||
const lookbackCutoff = new Date();
|
||||
lookbackCutoff.setDate(lookbackCutoff.getDate() - LOOKBACK_DAYS);
|
||||
|
||||
// Fetch documents with pagination
|
||||
while (hasMore) {
|
||||
// Delay before API call (except first)
|
||||
|
|
@ -390,7 +395,16 @@ async function syncNotes(): Promise<void> {
|
|||
}
|
||||
|
||||
// Process each document
|
||||
let foundOldDoc = false;
|
||||
for (const doc of docsResponse.docs) {
|
||||
// Skip documents outside the lookback period
|
||||
const docDate = new Date(doc.created_at);
|
||||
if (docDate < lookbackCutoff) {
|
||||
console.log(`[Granola] Document "${doc.title}" is older than ${LOOKBACK_DAYS} days, stopping pagination`);
|
||||
foundOldDoc = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const docUpdatedAt = doc.updated_at || doc.created_at;
|
||||
const lastSyncedAt = state.syncedDocs[doc.id];
|
||||
|
||||
|
|
@ -407,8 +421,15 @@ async function syncNotes(): Promise<void> {
|
|||
|
||||
// Convert to markdown and save
|
||||
const markdown = documentToMarkdown(doc);
|
||||
const filename = `${doc.id}_${cleanFilename(docTitle)}.md`;
|
||||
const filePath = path.join(SYNC_DIR, filename);
|
||||
const dateDir = path.join(
|
||||
SYNC_DIR,
|
||||
String(docDate.getFullYear()),
|
||||
String(docDate.getMonth() + 1).padStart(2, '0'),
|
||||
String(docDate.getDate()).padStart(2, '0')
|
||||
);
|
||||
ensureDir(dateDir);
|
||||
const filename = `${cleanFilename(docTitle)}.md`;
|
||||
const filePath = path.join(dateDir, filename);
|
||||
|
||||
fs.writeFileSync(filePath, markdown);
|
||||
|
||||
|
|
@ -424,6 +445,12 @@ async function syncNotes(): Promise<void> {
|
|||
state.syncedDocs[doc.id] = docUpdatedAt;
|
||||
}
|
||||
|
||||
// Stop if we hit a document outside the lookback period
|
||||
if (foundOldDoc) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Move to next page
|
||||
offset += docsResponse.docs.length;
|
||||
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ workspace-readFile({ path: "{source_file}" })
|
|||
- Has \`Attendees:\` field
|
||||
- Has \`Meeting:\` title
|
||||
- Transcript format with speaker labels
|
||||
- Source file path is under \`knowledge/Meetings/\` (e.g. \`knowledge/Meetings/granola/...\` or \`knowledge/Meetings/fireflies/...\`)
|
||||
|
||||
**Email indicators:**
|
||||
- Has \`From:\` and \`To:\` fields
|
||||
|
|
@ -680,6 +681,16 @@ One line summarizing this source's relevance to the entity:
|
|||
**{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[links]]}
|
||||
\`\`\`
|
||||
|
||||
**For meetings:** Include a link to the source meeting note. Derive the wiki-link path from the source file path (strip the \`.md\` extension):
|
||||
\`\`\`
|
||||
**2025-01-15** (meeting): Discussed [[Projects/Acme Integration]] timeline with [[People/David Kim]]. See [[Meetings/granola/abc123_Weekly Sync]]
|
||||
\`\`\`
|
||||
|
||||
**For emails:** Include a Gmail web link to the thread. Extract the thread ID from the \`**Thread ID:**\` field in the email source file, then construct the URL as \`https://mail.google.com/mail/#inbox/{threadId}\`:
|
||||
\`\`\`
|
||||
**2025-01-15** (email): [[People/Sarah Chen]] sent pricing proposal for [[Projects/Acme Integration]]. [View thread](https://mail.google.com/mail/#inbox/18d5a3b2c1e4f567)
|
||||
\`\`\`
|
||||
|
||||
**For voice memos:** Include a link to the voice memo file using the Path field:
|
||||
\`\`\`
|
||||
**2025-01-15** (voice memo): Discussed [[Projects/Acme Integration]] timeline. See [[Voice Memos/2025-01-15/voice-memo-2025-01-15T10-30-00-000Z]]
|
||||
|
|
@ -687,11 +698,13 @@ One line summarizing this source's relevance to the entity:
|
|||
|
||||
**Important:** Use canonical names with absolute paths from resolution map in all summaries:
|
||||
\`\`\`
|
||||
# Correct (uses absolute paths):
|
||||
**2025-01-15** (meeting): [[People/Sarah Chen]] confirmed timeline with [[People/David Kim]]. Blocked on [[Topics/Security Compliance]].
|
||||
# Correct (uses absolute paths and source links):
|
||||
**2025-01-15** (meeting): [[People/Sarah Chen]] confirmed timeline with [[People/David Kim]]. Blocked on [[Topics/Security Compliance]]. See [[Meetings/fireflies/abc_Team Sync]]
|
||||
**2025-01-15** (email): [[People/Sarah Chen]] shared the contract draft. [View thread](https://mail.google.com/mail/#inbox/18d5a3b2c1e4f567)
|
||||
|
||||
# Incorrect (uses variants or relative links):
|
||||
# Incorrect (uses variants or relative links, missing source links):
|
||||
**2025-01-15** (meeting): Sarah confirmed timeline with David. Blocked on SOC 2.
|
||||
**2025-01-15** (email): Sarah shared the contract draft.
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
|
@ -888,6 +901,16 @@ ${renderNoteTypesBlock()}
|
|||
| Email (has create label) | Yes | Yes | Yes |
|
||||
| Email (only skip labels) | No (SKIP) | No | No |
|
||||
|
||||
**Meeting activity format:** Always include a link to the source meeting note:
|
||||
\`\`\`
|
||||
**2025-01-15** (meeting): Discussed project timeline with [[People/Sarah Chen]]. See [[Meetings/granola/abc123_Weekly Sync]]
|
||||
\`\`\`
|
||||
|
||||
**Email activity format:** Always include a Gmail web link using the Thread ID from the source:
|
||||
\`\`\`
|
||||
**2025-01-15** (email): [[People/Sarah Chen]] sent pricing proposal. [View thread](https://mail.google.com/mail/#inbox/18d5a3b2c1e4f567)
|
||||
\`\`\`
|
||||
|
||||
**Voice memo activity format:** Always include a link to the source voice memo:
|
||||
\`\`\`
|
||||
**2025-01-15** (voice memo): Discussed project timeline with [[People/Sarah Chen]]. See [[Voice Memos/2025-01-15/voice-memo-...]]
|
||||
|
|
|
|||
|
|
@ -153,6 +153,13 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
|
|||
extractionGuide:
|
||||
"Look for: topic name, keywords, related people/orgs/projects, decisions, key facts",
|
||||
},
|
||||
{
|
||||
type: "Meetings",
|
||||
folder: "Meetings",
|
||||
template: "",
|
||||
extractionGuide:
|
||||
"Look for: meeting title, date, attendees, source (granola or fireflies), duration, topics discussed",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Disk-backed config with mtime caching ──────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -16,14 +16,14 @@ tools:
|
|||
---
|
||||
# Task
|
||||
|
||||
You are a note tagging agent. Given a batch of knowledge notes (People, Organizations, Projects, Topics), you will classify each note and prepend YAML frontmatter with categorized tags and Info attributes.
|
||||
You are a note tagging agent. Given a batch of knowledge notes (People, Organizations, Projects, Topics, Meetings), you will classify each note and prepend YAML frontmatter with categorized tags and Info/metadata attributes.
|
||||
|
||||
# Instructions
|
||||
|
||||
1. For each note file provided in the message, read its content carefully.
|
||||
2. Determine the note type from its folder path (People/, Organizations/, Projects/, Topics/).
|
||||
2. Determine the note type from its folder path (People/, Organizations/, Projects/, Topics/, Meetings/).
|
||||
3. Classify the note using the Rowboat Tag System (Note Tags section) appended below.
|
||||
4. Extract attributes from the note's \`## Info\` section (or \`## About\` for Topics).
|
||||
4. Extract attributes from the note's \`## Info\` section (or \`## About\` for Topics). For Meetings, extract metadata from the note content and file path (see Meeting extraction rules below).
|
||||
5. Use \`workspace-edit\` to prepend YAML frontmatter to the file. The oldString should be the first line of the file (the \`# Title\` heading), and the newString should be the frontmatter followed by that same first line.
|
||||
6. If the note already has frontmatter (starts with \`---\`), skip it.
|
||||
|
||||
|
|
@ -97,6 +97,12 @@ Extract all \`**Key:** value\` fields from the \`## Info\` (or \`## About\`) sec
|
|||
- **Organizations**: type, industry, relationship, domain, aliases, first_met, last_seen
|
||||
- **Projects**: type, status, started, last_activity
|
||||
- **Topics** (from \`## About\`): keywords, aliases, first_mentioned, last_mentioned
|
||||
- **Meetings**: Extract from the note content and file path:
|
||||
- \`date\`: meeting date (from the file path \`Meetings/{source}/YYYY/MM/DD/\` or from \`created_at\`/\`Date:\` in content)
|
||||
- \`source\`: \`granola\` or \`fireflies\` (from the file path)
|
||||
- \`attendees\`: list of attendee names (from \`Attendees:\` field or participant list)
|
||||
- \`title\`: meeting title
|
||||
- \`topic\`: relevant topic tags based on meeting content
|
||||
|
||||
Note: For Organizations, the Info \`**Relationship:**\` field is separate from the \`relationship\` tag category. Include both — the Info field as \`info_relationship\` and the tag as \`relationship\`.
|
||||
|
||||
|
|
@ -122,7 +128,11 @@ Note: For Organizations, the Info \`**Relationship:**\` field is separate from t
|
|||
7. **For Topic notes**, include:
|
||||
- The relevant topic tag
|
||||
- Source tags
|
||||
8. **Only use tags from the Rowboat Tag System** — do not invent new tags.
|
||||
8. **For Meeting notes**, include:
|
||||
- \`source: meeting\`
|
||||
- Topic tags based on what was discussed
|
||||
- The \`date\`, \`attendees\`, and \`title\` fields extracted from content
|
||||
9. **Only use tags from the Rowboat Tag System** — do not invent new tags.
|
||||
9. Process all files in the batch. Do not skip any unless they already have frontmatter.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { serviceLogger, type ServiceRunContext } from '../services/service_logge
|
|||
import { limitEventItems } from './limit_event_items.js';
|
||||
|
||||
// Configuration
|
||||
const SYNC_DIR = path.join(WorkDir, 'fireflies_transcripts');
|
||||
const SYNC_DIR = path.join(WorkDir, 'knowledge', 'Meetings', 'fireflies');
|
||||
const SYNC_INTERVAL_MS = 30 * 60 * 1000; // Check every 30 minutes (reduced from 1 minute)
|
||||
const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json');
|
||||
const LOOKBACK_DAYS = 30; // Last 1 month
|
||||
|
|
@ -569,8 +569,16 @@ async function syncMeetings() {
|
|||
|
||||
// Convert to markdown and save
|
||||
const markdown = meetingToMarkdown(meetingData);
|
||||
const filename = `${meetingId}_${cleanFilename(meetingData.title || 'untitled')}.md`;
|
||||
const filePath = path.join(SYNC_DIR, filename);
|
||||
const meetingDate = new Date(meetingData.dateString || meetingData.date || Date.now());
|
||||
const dateDir = path.join(
|
||||
SYNC_DIR,
|
||||
String(meetingDate.getFullYear()),
|
||||
String(meetingDate.getMonth() + 1).padStart(2, '0'),
|
||||
String(meetingDate.getDate()).padStart(2, '0')
|
||||
);
|
||||
fs.mkdirSync(dateDir, { recursive: true });
|
||||
const filename = `${cleanFilename(meetingData.title || 'untitled')}.md`;
|
||||
const filePath = path.join(dateDir, filename);
|
||||
|
||||
fs.writeFileSync(filePath, markdown);
|
||||
console.log(`[Fireflies] Saved: ${filename}`);
|
||||
|
|
|
|||
|
|
@ -30,17 +30,17 @@ function getUntaggedNotes(state: NoteTaggingState): string[] {
|
|||
const untagged: string[] = [];
|
||||
const noteFolders = getNoteTypeDefinitions().map(d => d.folder);
|
||||
|
||||
for (const folder of noteFolders) {
|
||||
const folderPath = path.join(KNOWLEDGE_DIR, folder);
|
||||
if (!fs.existsSync(folderPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(folderPath);
|
||||
function scanDir(dir: string) {
|
||||
const entries = fs.readdirSync(dir);
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(folderPath, entry);
|
||||
const fullPath = path.join(dir, entry);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
scanDir(fullPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!stat.isFile() || !entry.endsWith('.md')) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -64,6 +64,14 @@ function getUntaggedNotes(state: NoteTaggingState): string[] {
|
|||
}
|
||||
}
|
||||
|
||||
for (const folder of noteFolders) {
|
||||
const folderPath = path.join(KNOWLEDGE_DIR, folder);
|
||||
if (!fs.existsSync(folderPath)) {
|
||||
continue;
|
||||
}
|
||||
scanDir(folderPath);
|
||||
}
|
||||
|
||||
return untagged;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue