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:
Arjun 2026-03-16 23:20:08 +05:30 committed by arkml
parent 16b8975b00
commit 7e15c1231d
8 changed files with 114 additions and 25 deletions

View file

@ -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`;
});

View file

@ -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;

View file

@ -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-...]]

View file

@ -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 ──────────────────────────────────

View file

@ -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.
---

View file

@ -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}`);

View file

@ -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;
}