From 846d54c7ebafce0e4faf173ffef65dabbc3c3078 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Jan 2026 16:02:00 +0000 Subject: [PATCH 01/16] feat(ai): add get_activity tool for workspace activity awareness Adds a new AI tool that allows agents to query recent workspace activity, enabling context-aware assistance and pulse/welcome messages. Features: - Per-drive activity grouping with drive metadata (name, prompt/description) - Time window filtering (1h, 24h, 7d, 30d, or since last visit) - Operation category filtering (content, permissions, membership) - Exclude own activity option for collaboration awareness - AI attribution tracking (which changes were AI-generated) - Detailed change diffs (previousValues/newValues) for understanding what changed - Contributor breakdown per drive The tool returns rich, contextualized data allowing the AI to: - Form intuition about ongoing work patterns - Generate informed welcome/pulse messages - Provide contextually relevant assistance --- apps/web/src/lib/ai/core/ai-tools.ts | 2 + apps/web/src/lib/ai/tools/activity-tools.ts | 503 ++++++++++++++++++++ 2 files changed, 505 insertions(+) create mode 100644 apps/web/src/lib/ai/tools/activity-tools.ts diff --git a/apps/web/src/lib/ai/core/ai-tools.ts b/apps/web/src/lib/ai/core/ai-tools.ts index 66200b44b..7bf5cee3c 100644 --- a/apps/web/src/lib/ai/core/ai-tools.ts +++ b/apps/web/src/lib/ai/core/ai-tools.ts @@ -6,6 +6,7 @@ import { taskManagementTools } from '../tools/task-management-tools'; import { agentTools } from '../tools/agent-tools'; import { agentCommunicationTools } from '../tools/agent-communication-tools'; import { webSearchTools } from '../tools/web-search-tools'; +import { activityTools } from '../tools/activity-tools'; /** * PageSpace AI Tools - Internal AI SDK tool implementations @@ -21,6 +22,7 @@ export const pageSpaceTools = { ...agentTools, ...agentCommunicationTools, ...webSearchTools, + ...activityTools, }; export type PageSpaceTools = typeof pageSpaceTools; \ No newline at end of file diff --git a/apps/web/src/lib/ai/tools/activity-tools.ts b/apps/web/src/lib/ai/tools/activity-tools.ts new file mode 100644 index 000000000..ff2634515 --- /dev/null +++ b/apps/web/src/lib/ai/tools/activity-tools.ts @@ -0,0 +1,503 @@ +import { tool } from 'ai'; +import { z } from 'zod'; +import { + db, + activityLogs, + drives, + sessions, + eq, + and, + desc, + gte, + ne, + isNull, + inArray, +} from '@pagespace/db'; +import { isUserDriveMember } from '@pagespace/lib'; +import { type ToolExecutionContext } from '../core'; + +/** + * Activity tools for AI agents + * + * Provides insight into recent workspace activity, enabling: + * - Context-aware assistance (what has the user been working on?) + * - Collaboration awareness (what have others changed?) + * - Pulse/welcome messages (what happened since last visit?) + */ + +// Operation categories for filtering +const CONTENT_OPERATIONS = ['create', 'update', 'delete', 'restore', 'move', 'trash', 'reorder'] as const; +const PERMISSION_OPERATIONS = ['permission_grant', 'permission_update', 'permission_revoke'] as const; +const MEMBERSHIP_OPERATIONS = ['member_add', 'member_remove', 'member_role_change', 'ownership_transfer'] as const; +const AUTH_OPERATIONS = ['login', 'logout', 'signup'] as const; + +type ContentOperation = typeof CONTENT_OPERATIONS[number]; +type PermissionOperation = typeof PERMISSION_OPERATIONS[number]; +type MembershipOperation = typeof MEMBERSHIP_OPERATIONS[number]; +type AuthOperation = typeof AUTH_OPERATIONS[number]; + +// Time window helpers +function getTimeWindowStart(window: string, lastVisitTime?: Date): Date { + const now = new Date(); + + switch (window) { + case '1h': + return new Date(now.getTime() - 60 * 60 * 1000); + case '24h': + return new Date(now.getTime() - 24 * 60 * 60 * 1000); + case '7d': + return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + case '30d': + return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + case 'last_visit': + // Fall back to 7 days if no last visit time + return lastVisitTime || new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + default: + return new Date(now.getTime() - 24 * 60 * 60 * 1000); + } +} + +// Get user's last active session time +async function getLastVisitTime(userId: string): Promise { + // Get the user's previous session (not the current one) + const previousSessions = await db + .select({ lastUsedAt: sessions.lastUsedAt }) + .from(sessions) + .where( + and( + eq(sessions.userId, userId), + eq(sessions.type, 'user'), + isNull(sessions.revokedAt) + ) + ) + .orderBy(desc(sessions.lastUsedAt)) + .limit(2); + + // If we have at least 2 sessions, return the second one's lastUsedAt + // (the first one is the current session) + if (previousSessions.length >= 2 && previousSessions[1]?.lastUsedAt) { + return previousSessions[1].lastUsedAt; + } + + // Fallback: look for the user's last login activity + const [lastLogin] = await db + .select({ timestamp: activityLogs.timestamp }) + .from(activityLogs) + .where( + and( + eq(activityLogs.userId, userId), + eq(activityLogs.operation, 'login') + ) + ) + .orderBy(desc(activityLogs.timestamp)) + .limit(1); + + return lastLogin?.timestamp; +} + +// Format activity for AI consumption +interface FormattedActivity { + id: string; + timestamp: string; + operation: string; + resourceType: string; + resourceTitle: string | null; + pageId: string | null; + actor: { + name: string | null; + email: string; + isCurrentUser: boolean; + }; + isAiGenerated: boolean; + aiInfo?: { + provider: string | null; + model: string | null; + }; + changes?: { + updatedFields: string[] | null; + previousValues: Record | null; + newValues: Record | null; + }; + metadata: Record | null; +} + +interface DriveActivityGroup { + drive: { + id: string; + name: string; + slug: string; + prompt: string | null; + }; + activities: FormattedActivity[]; + summary: { + totalChanges: number; + byOperation: Record; + byResourceType: Record; + contributors: Array<{ email: string; name: string | null; count: number }>; + aiGeneratedCount: number; + }; +} + +export const activityTools = { + /** + * Get recent activity across workspaces + * + * Use this to understand what has been happening in the workspace: + * - What the user has been working on + * - What others have changed (for collaboration awareness) + * - Changes since last visit (for pulse/welcome messages) + * + * Returns activities grouped by drive with rich context including: + * - Drive metadata (name, description/prompt for context) + * - Detailed change information (what fields changed, before/after values) + * - AI attribution (which changes were AI-generated) + * - Contributor summary + */ + get_activity: tool({ + description: `Get recent activity in the user's workspaces to understand what has changed. + +Use this tool to: +- Understand what the user has been working on recently +- See what collaborators have changed in shared workspaces +- Generate informed welcome/pulse messages about changes since last visit +- Get context before making suggestions or edits + +Returns activities grouped by drive with: +- Drive context (name, AI prompt/description) +- Detailed change diffs (what changed, previous vs new values) +- AI attribution (which changes were AI-generated) +- Contributor breakdown + +The AI should use this data to form intuition about ongoing work and provide contextually relevant assistance.`, + + inputSchema: z.object({ + since: z + .enum(['1h', '24h', '7d', '30d', 'last_visit']) + .default('24h') + .describe( + 'Time window for activity. Use "last_visit" for pulse messages to show changes since user was last active' + ), + + driveIds: z + .array(z.string()) + .optional() + .describe( + 'Specific drive IDs to fetch activity for. If not provided, fetches from all accessible drives' + ), + + excludeOwnActivity: z + .boolean() + .default(false) + .describe( + 'Set to true to only see what OTHER people (or AI) have changed. Useful for collaboration awareness and pulse messages' + ), + + includeAiChanges: z + .boolean() + .default(true) + .describe('Whether to include AI-generated changes in results'), + + operationCategories: z + .array(z.enum(['content', 'permissions', 'membership'])) + .optional() + .describe( + 'Filter by operation category. content = create/update/delete/move, permissions = permission changes, membership = member add/remove/role changes' + ), + + limit: z + .number() + .min(1) + .max(200) + .default(100) + .describe('Maximum total activities to return across all drives'), + + includeDiffs: z + .boolean() + .default(true) + .describe( + 'Include detailed change diffs (previousValues/newValues). Set to false for a lighter response' + ), + }), + + execute: async ( + { + since, + driveIds, + excludeOwnActivity, + includeAiChanges, + operationCategories, + limit, + includeDiffs, + }, + { experimental_context: context } + ) => { + const userId = (context as ToolExecutionContext)?.userId; + if (!userId) { + throw new Error('User authentication required'); + } + + try { + // Get last visit time if needed + let lastVisitTime: Date | undefined; + if (since === 'last_visit') { + lastVisitTime = await getLastVisitTime(userId); + } + + const timeWindowStart = getTimeWindowStart(since, lastVisitTime); + + // Get all drives the user has access to + let targetDriveIds: string[]; + + if (driveIds && driveIds.length > 0) { + // Verify access to specified drives + const accessChecks = await Promise.all( + driveIds.map(async (driveId) => ({ + driveId, + hasAccess: await isUserDriveMember(userId, driveId), + })) + ); + + const accessibleDrives = accessChecks.filter((c) => c.hasAccess); + const deniedDrives = accessChecks.filter((c) => !c.hasAccess); + + if (accessibleDrives.length === 0) { + throw new Error('No access to any of the specified drives'); + } + + targetDriveIds = accessibleDrives.map((c) => c.driveId); + + if (deniedDrives.length > 0) { + console.warn( + `User ${userId} denied access to drives: ${deniedDrives.map((d) => d.driveId).join(', ')}` + ); + } + } else { + // Get all drives user is a member of + const userDrives = await db + .select({ id: drives.id }) + .from(drives) + .where(eq(drives.isTrashed, false)); + + // Filter to only drives user has access to + const accessibleDriveIds: string[] = []; + for (const drive of userDrives) { + if (await isUserDriveMember(userId, drive.id)) { + accessibleDriveIds.push(drive.id); + } + } + + targetDriveIds = accessibleDriveIds; + } + + if (targetDriveIds.length === 0) { + return { + success: true, + driveGroups: [], + summary: { + totalActivities: 0, + timeWindow: since, + timeWindowStart: timeWindowStart.toISOString(), + lastVisitTime: lastVisitTime?.toISOString() || null, + }, + message: 'No accessible drives found', + }; + } + + // Build operation filter + let operationFilter: string[] = []; + if (operationCategories && operationCategories.length > 0) { + for (const category of operationCategories) { + switch (category) { + case 'content': + operationFilter.push(...CONTENT_OPERATIONS); + break; + case 'permissions': + operationFilter.push(...PERMISSION_OPERATIONS); + break; + case 'membership': + operationFilter.push(...MEMBERSHIP_OPERATIONS); + break; + } + } + } + + // Build query conditions + const conditions = [ + inArray(activityLogs.driveId, targetDriveIds), + gte(activityLogs.timestamp, timeWindowStart), + eq(activityLogs.isArchived, false), + ]; + + if (excludeOwnActivity) { + conditions.push(ne(activityLogs.userId, userId)); + } + + if (!includeAiChanges) { + conditions.push(eq(activityLogs.isAiGenerated, false)); + } + + if (operationFilter.length > 0) { + conditions.push( + inArray( + activityLogs.operation, + operationFilter as [string, ...string[]] + ) + ); + } + + // Fetch activities + const activities = await db.query.activityLogs.findMany({ + where: and(...conditions), + with: { + user: { + columns: { id: true, name: true, email: true }, + }, + drive: { + columns: { id: true, name: true, slug: true, drivePrompt: true }, + }, + }, + orderBy: [desc(activityLogs.timestamp)], + limit, + }); + + // Group activities by drive + const driveGroupsMap = new Map(); + + for (const activity of activities) { + if (!activity.driveId || !activity.drive) continue; + + let group = driveGroupsMap.get(activity.driveId); + if (!group) { + group = { + drive: { + id: activity.drive.id, + name: activity.drive.name, + slug: activity.drive.slug, + prompt: activity.drive.drivePrompt, + }, + activities: [], + summary: { + totalChanges: 0, + byOperation: {}, + byResourceType: {}, + contributors: [], + aiGeneratedCount: 0, + }, + }; + driveGroupsMap.set(activity.driveId, group); + } + + // Format activity + const formattedActivity: FormattedActivity = { + id: activity.id, + timestamp: activity.timestamp.toISOString(), + operation: activity.operation, + resourceType: activity.resourceType, + resourceTitle: activity.resourceTitle, + pageId: activity.pageId, + actor: { + name: activity.actorDisplayName || activity.user?.name || null, + email: activity.actorEmail, + isCurrentUser: activity.userId === userId, + }, + isAiGenerated: activity.isAiGenerated, + metadata: activity.metadata, + }; + + if (activity.isAiGenerated) { + formattedActivity.aiInfo = { + provider: activity.aiProvider, + model: activity.aiModel, + }; + } + + if (includeDiffs && (activity.updatedFields || activity.previousValues || activity.newValues)) { + formattedActivity.changes = { + updatedFields: activity.updatedFields || null, + previousValues: activity.previousValues || null, + newValues: activity.newValues || null, + }; + } + + group.activities.push(formattedActivity); + + // Update summary + group.summary.totalChanges++; + group.summary.byOperation[activity.operation] = + (group.summary.byOperation[activity.operation] || 0) + 1; + group.summary.byResourceType[activity.resourceType] = + (group.summary.byResourceType[activity.resourceType] || 0) + 1; + + if (activity.isAiGenerated) { + group.summary.aiGeneratedCount++; + } + } + + // Calculate contributors for each drive + for (const group of driveGroupsMap.values()) { + const contributorMap = new Map< + string, + { email: string; name: string | null; count: number } + >(); + + for (const activity of group.activities) { + const existing = contributorMap.get(activity.actor.email); + if (existing) { + existing.count++; + } else { + contributorMap.set(activity.actor.email, { + email: activity.actor.email, + name: activity.actor.name, + count: 1, + }); + } + } + + group.summary.contributors = Array.from(contributorMap.values()).sort( + (a, b) => b.count - a.count + ); + } + + // Convert to array and sort by activity count + const driveGroups = Array.from(driveGroupsMap.values()).sort( + (a, b) => b.summary.totalChanges - a.summary.totalChanges + ); + + // Calculate overall summary + const totalActivities = activities.length; + const totalAiGenerated = activities.filter((a) => a.isAiGenerated).length; + + return { + success: true, + driveGroups, + summary: { + totalActivities, + totalAiGenerated, + drivesWithActivity: driveGroups.length, + timeWindow: since, + timeWindowStart: timeWindowStart.toISOString(), + lastVisitTime: lastVisitTime?.toISOString() || null, + excludedOwnActivity: excludeOwnActivity, + }, + message: + totalActivities === 0 + ? `No activity found in the last ${since === 'last_visit' ? 'visit period' : since}` + : `Found ${totalActivities} activities across ${driveGroups.length} workspace(s)`, + nextSteps: + totalActivities > 0 + ? [ + 'Review the drive summaries to understand what has been happening', + 'Use read_page to examine specific pages that were modified', + 'Consider mentioning notable changes when greeting the user', + ] + : ['The workspace has been quiet - consider checking in with the user about their current work'], + }; + } catch (error) { + console.error('get_activity error:', error); + throw new Error( + `Failed to fetch activity: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + }, + }), +}; From 315a85a6fccc9cfc7451836c2ca4dbbe2b60fed4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Jan 2026 16:08:22 +0000 Subject: [PATCH 02/16] refactor(ai): optimize get_activity for context efficiency Key optimizations: - Deduplicate actors: activities reference actors by index instead of repeating full actor info on each activity - Compact field names: ts, op, res, ai instead of verbose names - Smart delta: content fields show length change only (not full text), title/boolean/number fields show full values - Remove verbose nextSteps/message fields (AI interprets raw data) - Flatten response structure Before: Each activity repeated full actor object After: actors[] array at top, activities use actorIdx Before: content diffs included full document text After: content diffs show { len: { from: 5000, to: 5200 } } --- apps/web/src/lib/ai/tools/activity-tools.ts | 258 ++++++++++---------- 1 file changed, 135 insertions(+), 123 deletions(-) diff --git a/apps/web/src/lib/ai/tools/activity-tools.ts b/apps/web/src/lib/ai/tools/activity-tools.ts index ff2634515..8d4f79406 100644 --- a/apps/web/src/lib/ai/tools/activity-tools.ts +++ b/apps/web/src/lib/ai/tools/activity-tools.ts @@ -95,49 +95,80 @@ async function getLastVisitTime(userId: string): Promise { return lastLogin?.timestamp; } -// Format activity for AI consumption -interface FormattedActivity { - id: string; - timestamp: string; - operation: string; - resourceType: string; - resourceTitle: string | null; +// Compact activity format optimized for AI context efficiency +interface CompactActivity { + ts: string; // ISO timestamp + op: string; // operation + res: string; // resourceType + title: string | null; // resourceTitle pageId: string | null; - actor: { - name: string | null; - email: string; - isCurrentUser: boolean; - }; - isAiGenerated: boolean; - aiInfo?: { - provider: string | null; - model: string | null; - }; - changes?: { - updatedFields: string[] | null; - previousValues: Record | null; - newValues: Record | null; - }; - metadata: Record | null; + actor: number; // index into actors array + ai?: string; // AI model if ai-generated (e.g., "gpt-4o") + fields?: string[]; // which fields changed + delta?: Record; } -interface DriveActivityGroup { +interface CompactDriveGroup { drive: { id: string; name: string; slug: string; - prompt: string | null; + context: string | null; // drivePrompt - gives AI context about the workspace purpose }; - activities: FormattedActivity[]; - summary: { - totalChanges: number; - byOperation: Record; - byResourceType: Record; - contributors: Array<{ email: string; name: string | null; count: number }>; - aiGeneratedCount: number; + activities: CompactActivity[]; + stats: { + total: number; + byOp: Record; + aiCount: number; }; } +interface CompactActor { + email: string; + name: string | null; + isYou: boolean; // is this the current user + count: number; // activity count +} + +// Helper to create compact delta from previousValues/newValues +function createCompactDelta( + updatedFields: string[] | null, + prev: Record | null, + next: Record | null +): Record | undefined { + if (!updatedFields || updatedFields.length === 0) return undefined; + + const delta: Record = {}; + + for (const field of updatedFields) { + const fromVal = prev?.[field]; + const toVal = next?.[field]; + + // For content/text fields, just show length change to save tokens + if (field === 'content' || field === 'systemPrompt' || field === 'drivePrompt') { + const fromLen = typeof fromVal === 'string' ? fromVal.length : 0; + const toLen = typeof toVal === 'string' ? toVal.length : 0; + if (fromLen !== toLen) { + delta[field] = { len: { from: fromLen, to: toLen } }; + } + } else if (field === 'title') { + // Title changes are small and meaningful - include full values + delta[field] = { from: fromVal, to: toVal }; + } else if (typeof fromVal === 'boolean' || typeof toVal === 'boolean') { + // Booleans are small + delta[field] = { from: fromVal, to: toVal }; + } else if (typeof fromVal === 'number' || typeof toVal === 'number') { + // Numbers are small + delta[field] = { from: fromVal, to: toVal }; + } else { + // For other fields, just note they changed + delta[field] = {}; + } + } + + return Object.keys(delta).length > 0 ? delta : undefined; +} + export const activityTools = { /** * Get recent activity across workspaces @@ -360,8 +391,33 @@ The AI should use this data to form intuition about ongoing work and provide con limit, }); - // Group activities by drive - const driveGroupsMap = new Map(); + // Build actor index for deduplication (saves tokens by not repeating actor info) + const actorMap = new Map(); + const actorsList: CompactActor[] = []; + + for (const activity of activities) { + const email = activity.actorEmail; + if (!actorMap.has(email)) { + const idx = actorsList.length; + const actor: CompactActor = { + email, + name: activity.actorDisplayName || activity.user?.name || null, + isYou: activity.userId === userId, + count: 0, + }; + actorsList.push(actor); + actorMap.set(email, { idx, name: actor.name, isYou: actor.isYou, count: 0 }); + } + actorMap.get(email)!.count++; + } + + // Update counts in actorsList + for (const actor of actorsList) { + actor.count = actorMap.get(actor.email)!.count; + } + + // Group activities by drive using compact format + const driveGroupsMap = new Map(); for (const activity of activities) { if (!activity.driveId || !activity.drive) continue; @@ -373,124 +429,80 @@ The AI should use this data to form intuition about ongoing work and provide con id: activity.drive.id, name: activity.drive.name, slug: activity.drive.slug, - prompt: activity.drive.drivePrompt, + context: activity.drive.drivePrompt, }, activities: [], - summary: { - totalChanges: 0, - byOperation: {}, - byResourceType: {}, - contributors: [], - aiGeneratedCount: 0, + stats: { + total: 0, + byOp: {}, + aiCount: 0, }, }; driveGroupsMap.set(activity.driveId, group); } - // Format activity - const formattedActivity: FormattedActivity = { - id: activity.id, - timestamp: activity.timestamp.toISOString(), - operation: activity.operation, - resourceType: activity.resourceType, - resourceTitle: activity.resourceTitle, + // Build compact activity + const actorIdx = actorMap.get(activity.actorEmail)!.idx; + const compact: CompactActivity = { + ts: activity.timestamp.toISOString(), + op: activity.operation, + res: activity.resourceType, + title: activity.resourceTitle, pageId: activity.pageId, - actor: { - name: activity.actorDisplayName || activity.user?.name || null, - email: activity.actorEmail, - isCurrentUser: activity.userId === userId, - }, - isAiGenerated: activity.isAiGenerated, - metadata: activity.metadata, + actor: actorIdx, }; - if (activity.isAiGenerated) { - formattedActivity.aiInfo = { - provider: activity.aiProvider, - model: activity.aiModel, - }; + // Add AI model if ai-generated (compact: just the model name) + if (activity.isAiGenerated && activity.aiModel) { + compact.ai = activity.aiModel; } - if (includeDiffs && (activity.updatedFields || activity.previousValues || activity.newValues)) { - formattedActivity.changes = { - updatedFields: activity.updatedFields || null, - previousValues: activity.previousValues || null, - newValues: activity.newValues || null, - }; + // Add compact delta if diffs requested + if (includeDiffs && activity.updatedFields) { + compact.fields = activity.updatedFields; + const delta = createCompactDelta( + activity.updatedFields, + activity.previousValues, + activity.newValues + ); + if (delta) { + compact.delta = delta; + } } - group.activities.push(formattedActivity); - - // Update summary - group.summary.totalChanges++; - group.summary.byOperation[activity.operation] = - (group.summary.byOperation[activity.operation] || 0) + 1; - group.summary.byResourceType[activity.resourceType] = - (group.summary.byResourceType[activity.resourceType] || 0) + 1; + group.activities.push(compact); + // Update stats + group.stats.total++; + group.stats.byOp[activity.operation] = + (group.stats.byOp[activity.operation] || 0) + 1; if (activity.isAiGenerated) { - group.summary.aiGeneratedCount++; - } - } - - // Calculate contributors for each drive - for (const group of driveGroupsMap.values()) { - const contributorMap = new Map< - string, - { email: string; name: string | null; count: number } - >(); - - for (const activity of group.activities) { - const existing = contributorMap.get(activity.actor.email); - if (existing) { - existing.count++; - } else { - contributorMap.set(activity.actor.email, { - email: activity.actor.email, - name: activity.actor.name, - count: 1, - }); - } + group.stats.aiCount++; } - - group.summary.contributors = Array.from(contributorMap.values()).sort( - (a, b) => b.count - a.count - ); } // Convert to array and sort by activity count const driveGroups = Array.from(driveGroupsMap.values()).sort( - (a, b) => b.summary.totalChanges - a.summary.totalChanges + (a, b) => b.stats.total - a.stats.total ); // Calculate overall summary const totalActivities = activities.length; const totalAiGenerated = activities.filter((a) => a.isAiGenerated).length; + // Compact response structure optimized for AI context efficiency return { - success: true, - driveGroups, - summary: { - totalActivities, - totalAiGenerated, - drivesWithActivity: driveGroups.length, - timeWindow: since, - timeWindowStart: timeWindowStart.toISOString(), - lastVisitTime: lastVisitTime?.toISOString() || null, - excludedOwnActivity: excludeOwnActivity, + ok: true, + actors: actorsList, // Deduplicated actor list - activities reference by index + drives: driveGroups, + meta: { + total: totalActivities, + aiTotal: totalAiGenerated, + window: since, + from: timeWindowStart.toISOString(), + lastVisit: lastVisitTime?.toISOString() || null, + excludedSelf: excludeOwnActivity, }, - message: - totalActivities === 0 - ? `No activity found in the last ${since === 'last_visit' ? 'visit period' : since}` - : `Found ${totalActivities} activities across ${driveGroups.length} workspace(s)`, - nextSteps: - totalActivities > 0 - ? [ - 'Review the drive summaries to understand what has been happening', - 'Use read_page to examine specific pages that were modified', - 'Consider mentioning notable changes when greeting the user', - ] - : ['The workspace has been quiet - consider checking in with the user about their current work'], }; } catch (error) { console.error('get_activity error:', error); From 254e8f6d4aea46ff0efafd703ffe84484899d53c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Jan 2026 16:17:23 +0000 Subject: [PATCH 03/16] feat(ai): add hard output limit with progressive degradation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add maxOutputChars param (default 20k chars ≈ 5k tokens) - Lower default activity limit from 100 to 50 - Progressive truncation when over limit: 1. Drop all deltas first 2. Drop oldest activities (from largest drives) 3. Drop entire drives if still over - Response includes truncated info so AI knows data was reduced Prevents runaway context consumption while preserving most useful data. --- apps/web/src/lib/ai/tools/activity-tools.ts | 92 +++++++++++++++++++-- 1 file changed, 85 insertions(+), 7 deletions(-) diff --git a/apps/web/src/lib/ai/tools/activity-tools.ts b/apps/web/src/lib/ai/tools/activity-tools.ts index 8d4f79406..de2854033 100644 --- a/apps/web/src/lib/ai/tools/activity-tools.ts +++ b/apps/web/src/lib/ai/tools/activity-tools.ts @@ -238,15 +238,22 @@ The AI should use this data to form intuition about ongoing work and provide con limit: z .number() .min(1) - .max(200) - .default(100) - .describe('Maximum total activities to return across all drives'), + .max(100) + .default(50) + .describe('Maximum activities to fetch (hard cap 100)'), + + maxOutputChars: z + .number() + .min(1000) + .max(50000) + .default(20000) + .describe('Hard limit on output size in chars (~4 chars/token). Default 20k chars ≈ 5k tokens'), includeDiffs: z .boolean() .default(true) .describe( - 'Include detailed change diffs (previousValues/newValues). Set to false for a lighter response' + 'Include change diffs. Set false for lighter response' ), }), @@ -258,6 +265,7 @@ The AI should use this data to form intuition about ongoing work and provide con includeAiChanges, operationCategories, limit, + maxOutputChars, includeDiffs, }, { experimental_context: context } @@ -490,10 +498,23 @@ The AI should use this data to form intuition about ongoing work and provide con const totalActivities = activities.length; const totalAiGenerated = activities.filter((a) => a.isAiGenerated).length; - // Compact response structure optimized for AI context efficiency - return { + // Build initial response + const response: { + ok: boolean; + actors: CompactActor[]; + drives: CompactDriveGroup[]; + meta: { + total: number; + aiTotal: number; + window: string; + from: string; + lastVisit: string | null; + excludedSelf: boolean; + truncated?: { droppedDeltas?: boolean; droppedActivities?: number }; + }; + } = { ok: true, - actors: actorsList, // Deduplicated actor list - activities reference by index + actors: actorsList, drives: driveGroups, meta: { total: totalActivities, @@ -504,6 +525,63 @@ The AI should use this data to form intuition about ongoing work and provide con excludedSelf: excludeOwnActivity, }, }; + + // Enforce output size limit with progressive degradation + let outputSize = JSON.stringify(response).length; + + // Step 1: If over limit, drop all deltas + if (outputSize > maxOutputChars) { + for (const group of response.drives) { + for (const activity of group.activities) { + delete activity.delta; + } + } + response.meta.truncated = { droppedDeltas: true }; + outputSize = JSON.stringify(response).length; + } + + // Step 2: If still over limit, drop oldest activities from each drive + if (outputSize > maxOutputChars) { + let droppedCount = 0; + const targetSize = maxOutputChars * 0.9; // Leave 10% buffer + + while (outputSize > targetSize) { + // Find drive with most activities and drop oldest + let maxDrive: CompactDriveGroup | null = null; + for (const group of response.drives) { + if (!maxDrive || group.activities.length > maxDrive.activities.length) { + maxDrive = group; + } + } + + if (!maxDrive || maxDrive.activities.length <= 1) break; + + // Drop oldest (last in array since sorted desc by timestamp) + maxDrive.activities.pop(); + maxDrive.stats.total = maxDrive.activities.length; + droppedCount++; + outputSize = JSON.stringify(response).length; + } + + if (droppedCount > 0) { + response.meta.truncated = { + ...response.meta.truncated, + droppedActivities: droppedCount, + }; + } + } + + // Step 3: If STILL over limit after dropping activities, drop entire drives + if (outputSize > maxOutputChars && response.drives.length > 1) { + while (outputSize > maxOutputChars && response.drives.length > 1) { + // Keep the drive with most activity, drop smallest + response.drives.sort((a, b) => b.stats.total - a.stats.total); + response.drives.pop(); + outputSize = JSON.stringify(response).length; + } + } + + return response; } catch (error) { console.error('get_activity error:', error); throw new Error( From 1f040fc8f91caa66f7a067a683deeaa2d32f3793 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Jan 2026 16:50:46 +0000 Subject: [PATCH 04/16] chore(ai): add get_activity to tool summary and tests - Add get_activity to tool filtering summary list - Add activity-tools.test.ts with auth/authz error path tests - Follows existing test patterns (scaffold note for happy path) --- apps/web/src/lib/ai/core/tool-filtering.ts | 1 + .../ai/tools/__tests__/activity-tools.test.ts | 81 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts diff --git a/apps/web/src/lib/ai/core/tool-filtering.ts b/apps/web/src/lib/ai/core/tool-filtering.ts index 59923d67a..2ed7461c9 100644 --- a/apps/web/src/lib/ai/core/tool-filtering.ts +++ b/apps/web/src/lib/ai/core/tool-filtering.ts @@ -107,6 +107,7 @@ export function getToolsSummary(isReadOnly: boolean, webSearchEnabled = true): { 'list_trash', 'list_agents', 'multi_drive_list_agents', + 'get_activity', // Search tools 'regex_search', 'glob_search', diff --git a/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts b/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts new file mode 100644 index 000000000..e7d6266b5 --- /dev/null +++ b/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock boundaries +vi.mock('@pagespace/lib', () => ({ + isUserDriveMember: vi.fn(), +})); + +import { activityTools } from '../activity-tools'; +import { isUserDriveMember } from '@pagespace/lib'; +import type { ToolExecutionContext } from '../../core'; + +const mockIsUserDriveMember = vi.mocked(isUserDriveMember); + +/** + * @scaffold - happy path coverage deferred + * + * These tests cover authentication and authorization error paths. + * Happy path tests (actual activity results, grouping, truncation) are deferred + * because they require either: + * - An ActivityRepository seam to avoid complex DB mocking, OR + * - Integration tests against a real database with seeded activity logs + * + * TODO: Add integration tests for: + * - Activity grouping by drive + * - Compact delta generation + * - Progressive truncation under size limits + */ +describe('activity-tools', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('get_activity', () => { + it('has correct tool definition', () => { + expect(activityTools.get_activity).toBeDefined(); + expect(activityTools.get_activity.description).toBeDefined(); + expect(activityTools.get_activity.description).toContain('activity'); + }); + + it('requires user authentication', async () => { + const context = { toolCallId: '1', messages: [], experimental_context: {} }; + + await expect( + activityTools.get_activity.execute!( + { since: '24h' }, + context + ) + ).rejects.toThrow('User authentication required'); + }); + + it('throws error when specified drive access denied', async () => { + mockIsUserDriveMember.mockResolvedValue(false); + + const context = { + toolCallId: '1', + messages: [], + experimental_context: { userId: 'user-123' } as ToolExecutionContext, + }; + + await expect( + activityTools.get_activity.execute!( + { since: '24h', driveIds: ['drive-1'] }, + context + ) + ).rejects.toThrow('No access to any of the specified drives'); + }); + + it('accepts valid time window options', () => { + // Verify the schema accepts all documented time windows + const schema = activityTools.get_activity.inputSchema; + expect(schema).toBeDefined(); + // Schema validation happens at runtime via Zod + }); + + it('has output size limit parameter', () => { + const schema = activityTools.get_activity.inputSchema; + expect(schema).toBeDefined(); + // maxOutputChars should be part of the schema + }); + }); +}); From 812b1c8dbf45ac3c9cfb5ddf44692d02dae234b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 19:20:27 +0000 Subject: [PATCH 05/16] fix(ai): address PR feedback for get_activity tool Fixes based on code review: 1. N+1 query fix (lines 313-329): - Replace loop calling isUserDriveMember per drive - Single query: join driveMembers + drives for membership - Parallel query for owned drives (ownerId = userId) - Combine with Set deduplication 2. Remove unused code (lines 32-37): - Remove AUTH_OPERATIONS constant (never used) - Remove unused type aliases 3. Simplify actor count tracking (lines 422-425): - Store actor reference directly in map - Count updates shared via reference, no second loop 4. Optimize JSON.stringify in truncation (lines 548-564): - Estimate avg activity size from initial serialization - Batch drops and only re-serialize periodically - Reduces O(n) serializations to O(n/batchSize) 5. Improve test assertions (line 79): - Replace placeholder tests with meaningful assertions - Verify schema is ZodObject type - Verify description contains expected use cases --- .../ai/tools/__tests__/activity-tools.test.ts | 19 +-- apps/web/src/lib/ai/tools/activity-tools.ts | 114 ++++++++++++------ 2 files changed, 86 insertions(+), 47 deletions(-) diff --git a/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts b/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts index e7d6266b5..523b6bca5 100644 --- a/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts +++ b/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts @@ -65,17 +65,22 @@ describe('activity-tools', () => { ).rejects.toThrow('No access to any of the specified drives'); }); - it('accepts valid time window options', () => { - // Verify the schema accepts all documented time windows + it('has expected input schema shape', () => { const schema = activityTools.get_activity.inputSchema; expect(schema).toBeDefined(); - // Schema validation happens at runtime via Zod + + // Verify schema is a Zod object with expected structure + // Using _def to access internal Zod schema properties + const def = (schema as { _def?: { typeName?: string } })._def; + expect(def?.typeName).toBe('ZodObject'); }); - it('has output size limit parameter', () => { - const schema = activityTools.get_activity.inputSchema; - expect(schema).toBeDefined(); - // maxOutputChars should be part of the schema + it('description explains use cases', () => { + const desc = activityTools.get_activity.description; + expect(desc).toContain('activity'); + expect(desc).toContain('workspace'); + // Should mention key use cases + expect(desc).toMatch(/collaborat|pulse|welcome|context/i); }); }); }); diff --git a/apps/web/src/lib/ai/tools/activity-tools.ts b/apps/web/src/lib/ai/tools/activity-tools.ts index de2854033..223e91541 100644 --- a/apps/web/src/lib/ai/tools/activity-tools.ts +++ b/apps/web/src/lib/ai/tools/activity-tools.ts @@ -4,9 +4,11 @@ import { db, activityLogs, drives, + driveMembers, sessions, eq, and, + or, desc, gte, ne, @@ -29,12 +31,6 @@ import { type ToolExecutionContext } from '../core'; const CONTENT_OPERATIONS = ['create', 'update', 'delete', 'restore', 'move', 'trash', 'reorder'] as const; const PERMISSION_OPERATIONS = ['permission_grant', 'permission_update', 'permission_revoke'] as const; const MEMBERSHIP_OPERATIONS = ['member_add', 'member_remove', 'member_role_change', 'ownership_transfer'] as const; -const AUTH_OPERATIONS = ['login', 'logout', 'signup'] as const; - -type ContentOperation = typeof CONTENT_OPERATIONS[number]; -type PermissionOperation = typeof PERMISSION_OPERATIONS[number]; -type MembershipOperation = typeof MEMBERSHIP_OPERATIONS[number]; -type AuthOperation = typeof AUTH_OPERATIONS[number]; // Time window helpers function getTimeWindowStart(window: string, lastVisitTime?: Date): Date { @@ -311,34 +307,51 @@ The AI should use this data to form intuition about ongoing work and provide con ); } } else { - // Get all drives user is a member of - const userDrives = await db - .select({ id: drives.id }) - .from(drives) - .where(eq(drives.isTrashed, false)); - - // Filter to only drives user has access to - const accessibleDriveIds: string[] = []; - for (const drive of userDrives) { - if (await isUserDriveMember(userId, drive.id)) { - accessibleDriveIds.push(drive.id); - } - } - - targetDriveIds = accessibleDriveIds; + // Single query to get all accessible drive IDs: + // 1. Drives user is a member of (via driveMembers) + // 2. Drives user owns (via drives.ownerId) + const [memberDrives, ownedDrives] = await Promise.all([ + db + .select({ driveId: driveMembers.driveId }) + .from(driveMembers) + .innerJoin(drives, eq(driveMembers.driveId, drives.id)) + .where( + and( + eq(driveMembers.userId, userId), + eq(drives.isTrashed, false) + ) + ), + db + .select({ id: drives.id }) + .from(drives) + .where( + and( + eq(drives.ownerId, userId), + eq(drives.isTrashed, false) + ) + ), + ]); + + // Combine and deduplicate drive IDs + const driveIdSet = new Set(); + for (const d of memberDrives) driveIdSet.add(d.driveId); + for (const d of ownedDrives) driveIdSet.add(d.id); + targetDriveIds = Array.from(driveIdSet); } if (targetDriveIds.length === 0) { return { - success: true, - driveGroups: [], - summary: { - totalActivities: 0, - timeWindow: since, - timeWindowStart: timeWindowStart.toISOString(), - lastVisitTime: lastVisitTime?.toISOString() || null, + ok: true, + actors: [], + drives: [], + meta: { + total: 0, + aiTotal: 0, + window: since, + from: timeWindowStart.toISOString(), + lastVisit: lastVisitTime?.toISOString() || null, + excludedSelf: excludeOwnActivity, }, - message: 'No accessible drives found', }; } @@ -400,28 +413,25 @@ The AI should use this data to form intuition about ongoing work and provide con }); // Build actor index for deduplication (saves tokens by not repeating actor info) - const actorMap = new Map(); + // Store actor reference directly so count updates are shared + const actorMap = new Map(); const actorsList: CompactActor[] = []; for (const activity of activities) { const email = activity.actorEmail; - if (!actorMap.has(email)) { - const idx = actorsList.length; + let entry = actorMap.get(email); + if (!entry) { const actor: CompactActor = { email, name: activity.actorDisplayName || activity.user?.name || null, isYou: activity.userId === userId, count: 0, }; + entry = { idx: actorsList.length, actor }; actorsList.push(actor); - actorMap.set(email, { idx, name: actor.name, isYou: actor.isYou, count: 0 }); + actorMap.set(email, entry); } - actorMap.get(email)!.count++; - } - - // Update counts in actorsList - for (const actor of actorsList) { - actor.count = actorMap.get(actor.email)!.count; + entry.actor.count++; } // Group activities by drive using compact format @@ -540,11 +550,24 @@ The AI should use this data to form intuition about ongoing work and provide con outputSize = JSON.stringify(response).length; } - // Step 2: If still over limit, drop oldest activities from each drive + // Step 2: If still over limit, drop oldest activities using batched approach + // to avoid expensive JSON.stringify on every single drop if (outputSize > maxOutputChars) { let droppedCount = 0; const targetSize = maxOutputChars * 0.9; // Leave 10% buffer + const totalActivityCount = response.drives.reduce((sum, g) => sum + g.activities.length, 0); + // Estimate avg chars per activity (avoid divide by zero) + const avgActivitySize = totalActivityCount > 0 + ? Math.ceil(outputSize / totalActivityCount) + : 200; + + // Estimate how many activities to drop + const excessChars = outputSize - targetSize; + const estimatedDrops = Math.ceil(excessChars / avgActivitySize); + const batchSize = Math.max(1, Math.min(10, Math.ceil(estimatedDrops / 5))); + + let dropsSinceLastCheck = 0; while (outputSize > targetSize) { // Find drive with most activities and drop oldest let maxDrive: CompactDriveGroup | null = null; @@ -560,6 +583,17 @@ The AI should use this data to form intuition about ongoing work and provide con maxDrive.activities.pop(); maxDrive.stats.total = maxDrive.activities.length; droppedCount++; + dropsSinceLastCheck++; + + // Only re-serialize periodically to check actual size + if (dropsSinceLastCheck >= batchSize) { + outputSize = JSON.stringify(response).length; + dropsSinceLastCheck = 0; + } + } + + // Final size check + if (dropsSinceLastCheck > 0) { outputSize = JSON.stringify(response).length; } From 57509a8b21e7f3dccf723c76c9322b3a4b041a0c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 19:58:50 +0000 Subject: [PATCH 06/16] fix(ai): address lint errors and truncation counter consistency Lint fixes: - Remove unused 'or' import from @pagespace/db - Change 'let operationFilter' to 'const' (array is mutated, not reassigned) Truncation counter consistency: - After truncation, recompute all derived counters: - Reset and recalculate actor counts from remaining activities - Recompute drive stats (total, byOp, aiCount) from remaining activities - Update meta.total and meta.aiTotal to reflect actual remaining count - Remove actors with zero count (all their activities were truncated) - Remap actor indices in activities when actors are removed --- apps/web/src/lib/ai/tools/activity-tools.ts | 65 ++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/ai/tools/activity-tools.ts b/apps/web/src/lib/ai/tools/activity-tools.ts index 223e91541..596373648 100644 --- a/apps/web/src/lib/ai/tools/activity-tools.ts +++ b/apps/web/src/lib/ai/tools/activity-tools.ts @@ -8,7 +8,6 @@ import { sessions, eq, and, - or, desc, gte, ne, @@ -356,7 +355,7 @@ The AI should use this data to form intuition about ongoing work and provide con } // Build operation filter - let operationFilter: string[] = []; + const operationFilter: string[] = []; if (operationCategories && operationCategories.length > 0) { for (const category of operationCategories) { switch (category) { @@ -615,6 +614,68 @@ The AI should use this data to form intuition about ongoing work and provide con } } + // Recompute all derived counters after truncation to ensure consistency + if (response.meta.truncated) { + // Reset actor counts + for (const actor of response.actors) { + actor.count = 0; + } + + // Recompute from remaining activities + let newTotal = 0; + let newAiTotal = 0; + + for (const group of response.drives) { + // Reset and recompute drive stats + group.stats.total = group.activities.length; + group.stats.byOp = {}; + group.stats.aiCount = 0; + + for (const activity of group.activities) { + // Update actor count + if (activity.actor < response.actors.length) { + response.actors[activity.actor].count++; + } + + // Update drive stats + group.stats.byOp[activity.op] = (group.stats.byOp[activity.op] || 0) + 1; + if (activity.ai) { + group.stats.aiCount++; + newAiTotal++; + } + + newTotal++; + } + } + + // Update meta totals + response.meta.total = newTotal; + response.meta.aiTotal = newAiTotal; + + // Remove actors with zero count (their activities were all truncated) + const activeActorIndices = new Map(); + const filteredActors: CompactActor[] = []; + for (let i = 0; i < response.actors.length; i++) { + if (response.actors[i].count > 0) { + activeActorIndices.set(i, filteredActors.length); + filteredActors.push(response.actors[i]); + } + } + + // Remap actor indices in activities if any actors were removed + if (filteredActors.length < response.actors.length) { + for (const group of response.drives) { + for (const activity of group.activities) { + const newIdx = activeActorIndices.get(activity.actor); + if (newIdx !== undefined) { + activity.actor = newIdx; + } + } + } + response.actors = filteredActors; + } + } + return response; } catch (error) { console.error('get_activity error:', error); From 9c755706dea44901e9ae37e3a0ae31785ac1eff4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 20:02:51 +0000 Subject: [PATCH 07/16] fix(ai): resolve TypeScript errors in activity tools Test file: - Add type assertion for execute params (Zod defaults applied at runtime) Main tool: - Fix inArray type: cast operationFilter to column's enum type --- apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts | 6 ++++-- apps/web/src/lib/ai/tools/activity-tools.ts | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts b/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts index 523b6bca5..aad6c7652 100644 --- a/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts +++ b/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts @@ -40,9 +40,10 @@ describe('activity-tools', () => { it('requires user authentication', async () => { const context = { toolCallId: '1', messages: [], experimental_context: {} }; + // Cast to any to bypass Zod's transformed type requirements - schema defaults are applied at runtime await expect( activityTools.get_activity.execute!( - { since: '24h' }, + { since: '24h' } as Parameters[0], context ) ).rejects.toThrow('User authentication required'); @@ -57,9 +58,10 @@ describe('activity-tools', () => { experimental_context: { userId: 'user-123' } as ToolExecutionContext, }; + // Cast to any to bypass Zod's transformed type requirements - schema defaults are applied at runtime await expect( activityTools.get_activity.execute!( - { since: '24h', driveIds: ['drive-1'] }, + { since: '24h', driveIds: ['drive-1'] } as Parameters[0], context ) ).rejects.toThrow('No access to any of the specified drives'); diff --git a/apps/web/src/lib/ai/tools/activity-tools.ts b/apps/web/src/lib/ai/tools/activity-tools.ts index 596373648..74013a6bf 100644 --- a/apps/web/src/lib/ai/tools/activity-tools.ts +++ b/apps/web/src/lib/ai/tools/activity-tools.ts @@ -388,10 +388,11 @@ The AI should use this data to form intuition about ongoing work and provide con } if (operationFilter.length > 0) { + // Cast to the column's enum type for type safety with inArray conditions.push( inArray( activityLogs.operation, - operationFilter as [string, ...string[]] + operationFilter as (typeof activityLogs.operation._.data)[] ) ); } From 6b966985bd6edbfad25f5205caa15b2cc4a9432b Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 16 Jan 2026 15:10:50 -0600 Subject: [PATCH 08/16] fix(ai): address CodeRabbit review feedback - Fix Zod v4 schema test to use .type instead of ._def.typeName - Add last-resort string truncation (Step 4) to enforce hard output cap when only one activity/drive remains with large strings - Add hardCapExceeded flag to truncated metadata --- .../ai/tools/__tests__/activity-tools.test.ts | 6 ++-- apps/web/src/lib/ai/tools/activity-tools.ts | 32 ++++++++++++++++++- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts b/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts index aad6c7652..83727f3ac 100644 --- a/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts +++ b/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts @@ -72,9 +72,9 @@ describe('activity-tools', () => { expect(schema).toBeDefined(); // Verify schema is a Zod object with expected structure - // Using _def to access internal Zod schema properties - const def = (schema as { _def?: { typeName?: string } })._def; - expect(def?.typeName).toBe('ZodObject'); + // In Zod v4, use .type instead of ._def.typeName + const schemaType = (schema as { type?: string })?.type; + expect(schemaType).toBe('object'); }); it('description explains use cases', () => { diff --git a/apps/web/src/lib/ai/tools/activity-tools.ts b/apps/web/src/lib/ai/tools/activity-tools.ts index 74013a6bf..ddcbae0a6 100644 --- a/apps/web/src/lib/ai/tools/activity-tools.ts +++ b/apps/web/src/lib/ai/tools/activity-tools.ts @@ -520,7 +520,7 @@ The AI should use this data to form intuition about ongoing work and provide con from: string; lastVisit: string | null; excludedSelf: boolean; - truncated?: { droppedDeltas?: boolean; droppedActivities?: number }; + truncated?: { droppedDeltas?: boolean; droppedActivities?: number; hardCapExceeded?: boolean }; }; } = { ok: true, @@ -615,6 +615,36 @@ The AI should use this data to form intuition about ongoing work and provide con } } + // Step 4: Last-resort string trimming to enforce hard cap + // This handles edge cases where a single drive/activity has very large strings + if (outputSize > maxOutputChars) { + const maxContextLen = 500; + const maxTitleLen = 200; + + for (const group of response.drives) { + // Truncate drive context + if (group.drive.context && group.drive.context.length > maxContextLen) { + group.drive.context = group.drive.context.slice(0, maxContextLen) + '…'; + } + // Truncate activity titles + for (const activity of group.activities) { + if (activity.title && activity.title.length > maxTitleLen) { + activity.title = activity.title.slice(0, maxTitleLen) + '…'; + } + } + } + + outputSize = JSON.stringify(response).length; + + // If still over after string truncation, record in truncated meta + if (outputSize > maxOutputChars) { + response.meta.truncated = { + ...response.meta.truncated, + hardCapExceeded: true, + }; + } + } + // Recompute all derived counters after truncation to ensure consistency if (response.meta.truncated) { // Reset actor counts From 4f3a3194bac68326076ed84611ace8c3fd3397e1 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 16 Jan 2026 15:19:03 -0600 Subject: [PATCH 09/16] fix(ai): resolve TypeScript error in activity-tools tests Use explicit any cast to avoid TS error with Parameters<> on optional execute function type. --- .../lib/ai/tools/__tests__/activity-tools.test.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts b/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts index 83727f3ac..20392917e 100644 --- a/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts +++ b/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts @@ -40,12 +40,9 @@ describe('activity-tools', () => { it('requires user authentication', async () => { const context = { toolCallId: '1', messages: [], experimental_context: {} }; - // Cast to any to bypass Zod's transformed type requirements - schema defaults are applied at runtime + // eslint-disable-next-line @typescript-eslint/no-explicit-any await expect( - activityTools.get_activity.execute!( - { since: '24h' } as Parameters[0], - context - ) + activityTools.get_activity.execute!({ since: '24h' } as any, context) ).rejects.toThrow('User authentication required'); }); @@ -58,12 +55,9 @@ describe('activity-tools', () => { experimental_context: { userId: 'user-123' } as ToolExecutionContext, }; - // Cast to any to bypass Zod's transformed type requirements - schema defaults are applied at runtime + // eslint-disable-next-line @typescript-eslint/no-explicit-any await expect( - activityTools.get_activity.execute!( - { since: '24h', driveIds: ['drive-1'] } as Parameters[0], - context - ) + activityTools.get_activity.execute!({ since: '24h', driveIds: ['drive-1'] } as any, context) ).rejects.toThrow('No access to any of the specified drives'); }); From 04f740ce3d125f1872a3e964066f7262d077cb3b Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 16 Jan 2026 15:23:56 -0600 Subject: [PATCH 10/16] fix(ai): resolve lint error with Record instead of any Use properly typed Record cast to avoid both TypeScript and ESLint errors for test input parameters. --- .../src/lib/ai/tools/__tests__/activity-tools.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts b/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts index 20392917e..19daece43 100644 --- a/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts +++ b/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts @@ -39,10 +39,11 @@ describe('activity-tools', () => { it('requires user authentication', async () => { const context = { toolCallId: '1', messages: [], experimental_context: {} }; + // Test input params - schema defaults are applied at runtime + const input = { since: '24h' } as Record; - // eslint-disable-next-line @typescript-eslint/no-explicit-any await expect( - activityTools.get_activity.execute!({ since: '24h' } as any, context) + activityTools.get_activity.execute!(input, context) ).rejects.toThrow('User authentication required'); }); @@ -54,10 +55,11 @@ describe('activity-tools', () => { messages: [], experimental_context: { userId: 'user-123' } as ToolExecutionContext, }; + // Test input params - schema defaults are applied at runtime + const input = { since: '24h', driveIds: ['drive-1'] } as Record; - // eslint-disable-next-line @typescript-eslint/no-explicit-any await expect( - activityTools.get_activity.execute!({ since: '24h', driveIds: ['drive-1'] } as any, context) + activityTools.get_activity.execute!(input, context) ).rejects.toThrow('No access to any of the specified drives'); }); From c0451b6d2548d116b1b9fafee4ddf2391856eb58 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 16 Jan 2026 15:30:55 -0600 Subject: [PATCH 11/16] fix(ai): use block eslint-disable for any type in tests Use block eslint-disable/enable comments around the any cast to satisfy both TypeScript compiler and ESLint rules. --- .../lib/ai/tools/__tests__/activity-tools.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts b/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts index 19daece43..ab2c3ff3c 100644 --- a/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts +++ b/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts @@ -39,12 +39,12 @@ describe('activity-tools', () => { it('requires user authentication', async () => { const context = { toolCallId: '1', messages: [], experimental_context: {} }; - // Test input params - schema defaults are applied at runtime - const input = { since: '24h' } as Record; + /* eslint-disable @typescript-eslint/no-explicit-any */ await expect( - activityTools.get_activity.execute!(input, context) + (activityTools.get_activity.execute as any)({ since: '24h' }, context) ).rejects.toThrow('User authentication required'); + /* eslint-enable @typescript-eslint/no-explicit-any */ }); it('throws error when specified drive access denied', async () => { @@ -55,12 +55,12 @@ describe('activity-tools', () => { messages: [], experimental_context: { userId: 'user-123' } as ToolExecutionContext, }; - // Test input params - schema defaults are applied at runtime - const input = { since: '24h', driveIds: ['drive-1'] } as Record; + /* eslint-disable @typescript-eslint/no-explicit-any */ await expect( - activityTools.get_activity.execute!(input, context) + (activityTools.get_activity.execute as any)({ since: '24h', driveIds: ['drive-1'] }, context) ).rejects.toThrow('No access to any of the specified drives'); + /* eslint-enable @typescript-eslint/no-explicit-any */ }); it('has expected input schema shape', () => { From 1490a3cae38fc1cc52eabff6febf3e40b57dca28 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 16 Jan 2026 15:37:06 -0600 Subject: [PATCH 12/16] fix(ai): properly type test inputs instead of using any Add ActivityToolInput type and createTestInput helper that provides all required schema fields with their default values, eliminating the need for eslint-disable comments. --- .../ai/tools/__tests__/activity-tools.test.ts | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts b/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts index ab2c3ff3c..61baaf00a 100644 --- a/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts +++ b/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts @@ -11,6 +11,29 @@ import type { ToolExecutionContext } from '../../core'; const mockIsUserDriveMember = vi.mocked(isUserDriveMember); +// Properly typed test input matching the Zod schema with defaults +type ActivityToolInput = { + since: '1h' | '24h' | '7d' | '30d' | 'last_visit'; + excludeOwnActivity: boolean; + includeAiChanges: boolean; + limit: number; + maxOutputChars: number; + includeDiffs: boolean; + driveIds?: string[]; + operationCategories?: ('content' | 'permissions' | 'membership')[]; +}; + +// Default values matching the Zod schema defaults +const createTestInput = (overrides: Partial = {}): ActivityToolInput => ({ + since: '24h', + excludeOwnActivity: false, + includeAiChanges: true, + limit: 50, + maxOutputChars: 20000, + includeDiffs: true, + ...overrides, +}); + /** * @scaffold - happy path coverage deferred * @@ -40,11 +63,9 @@ describe('activity-tools', () => { it('requires user authentication', async () => { const context = { toolCallId: '1', messages: [], experimental_context: {} }; - /* eslint-disable @typescript-eslint/no-explicit-any */ await expect( - (activityTools.get_activity.execute as any)({ since: '24h' }, context) + activityTools.get_activity.execute!(createTestInput(), context) ).rejects.toThrow('User authentication required'); - /* eslint-enable @typescript-eslint/no-explicit-any */ }); it('throws error when specified drive access denied', async () => { @@ -56,11 +77,9 @@ describe('activity-tools', () => { experimental_context: { userId: 'user-123' } as ToolExecutionContext, }; - /* eslint-disable @typescript-eslint/no-explicit-any */ await expect( - (activityTools.get_activity.execute as any)({ since: '24h', driveIds: ['drive-1'] }, context) + activityTools.get_activity.execute!(createTestInput({ driveIds: ['drive-1'] }), context) ).rejects.toThrow('No access to any of the specified drives'); - /* eslint-enable @typescript-eslint/no-explicit-any */ }); it('has expected input schema shape', () => { From 5c622b82c7fd3e83e5bf5a17fb8ea5194e39e505 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 16 Jan 2026 15:49:27 -0600 Subject: [PATCH 13/16] test(ai): add activityTools to aggregation test Include activityTools in the pageSpaceTools aggregation test to match the addition of activityTools to the tools registry. --- apps/web/src/lib/ai/core/__tests__/ai-tools.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/web/src/lib/ai/core/__tests__/ai-tools.test.ts b/apps/web/src/lib/ai/core/__tests__/ai-tools.test.ts index fa100273d..11bd50f62 100644 --- a/apps/web/src/lib/ai/core/__tests__/ai-tools.test.ts +++ b/apps/web/src/lib/ai/core/__tests__/ai-tools.test.ts @@ -72,6 +72,12 @@ vi.mock('../../tools/web-search-tools', () => ({ }, })); +vi.mock('../../tools/activity-tools', () => ({ + activityTools: { + get_activity: { name: 'get_activity', description: 'Get activity' }, + }, +})); + import { pageSpaceTools } from '../ai-tools'; import { driveTools } from '../../tools/drive-tools'; import { pageReadTools } from '../../tools/page-read-tools'; @@ -81,6 +87,7 @@ import { taskManagementTools } from '../../tools/task-management-tools'; import { agentTools } from '../../tools/agent-tools'; import { agentCommunicationTools } from '../../tools/agent-communication-tools'; import { webSearchTools } from '../../tools/web-search-tools'; +import { activityTools } from '../../tools/activity-tools'; describe('ai-tools', () => { describe('pageSpaceTools aggregation', () => { @@ -94,6 +101,7 @@ describe('ai-tools', () => { ...agentTools, ...agentCommunicationTools, ...webSearchTools, + ...activityTools, }); }); @@ -107,6 +115,7 @@ describe('ai-tools', () => { Object.keys(agentTools), Object.keys(agentCommunicationTools), Object.keys(webSearchTools), + Object.keys(activityTools), ]; const allKeys = moduleKeysets.flat(); From 53977ab6a02cde4a3bd86de1a71ca0d03d2cb880 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 16 Jan 2026 16:04:19 -0600 Subject: [PATCH 14/16] fix(ai): use instanceof z.ZodObject for schema check Replace unstable schema.type property check with proper instanceof z.ZodObject assertion for more reliable testing. --- apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts b/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts index 61baaf00a..298715147 100644 --- a/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts +++ b/apps/web/src/lib/ai/tools/__tests__/activity-tools.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { z } from 'zod'; // Mock boundaries vi.mock('@pagespace/lib', () => ({ @@ -86,10 +87,8 @@ describe('activity-tools', () => { const schema = activityTools.get_activity.inputSchema; expect(schema).toBeDefined(); - // Verify schema is a Zod object with expected structure - // In Zod v4, use .type instead of ._def.typeName - const schemaType = (schema as { type?: string })?.type; - expect(schemaType).toBe('object'); + // Verify schema is a Zod object using instanceof check + expect(schema).toBeInstanceOf(z.ZodObject); }); it('description explains use cases', () => { From 7bd3fe7a060111d9dbb66d373075e85287ffa88f Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 16 Jan 2026 16:41:27 -0600 Subject: [PATCH 15/16] fix(ai): address CodeRabbit review feedback for get_activity 1. Redact sensitive data from warning logs: Replace raw userId and driveId values in console.warn with counts only (deniedDriveCount, requestedDriveCount) 2. Include AI/system activity when excludeOwnActivity is true: SQL `ne(userId, value)` evaluates to NULL when userId is NULL, filtering out AI/system events. Add `or(ne(...), isNull(...))` to properly include activities with NULL userId per the tool description: "other people (or AI)" --- apps/web/src/lib/ai/tools/activity-tools.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/ai/tools/activity-tools.ts b/apps/web/src/lib/ai/tools/activity-tools.ts index ddcbae0a6..8a5a971a4 100644 --- a/apps/web/src/lib/ai/tools/activity-tools.ts +++ b/apps/web/src/lib/ai/tools/activity-tools.ts @@ -8,6 +8,7 @@ import { sessions, eq, and, + or, desc, gte, ne, @@ -301,9 +302,10 @@ The AI should use this data to form intuition about ongoing work and provide con targetDriveIds = accessibleDrives.map((c) => c.driveId); if (deniedDrives.length > 0) { - console.warn( - `User ${userId} denied access to drives: ${deniedDrives.map((d) => d.driveId).join(', ')}` - ); + console.warn('get_activity: access denied for some drives', { + deniedDriveCount: deniedDrives.length, + requestedDriveCount: driveIds.length, + }); } } else { // Single query to get all accessible drive IDs: @@ -380,7 +382,8 @@ The AI should use this data to form intuition about ongoing work and provide con ]; if (excludeOwnActivity) { - conditions.push(ne(activityLogs.userId, userId)); + // Include activities from other users OR system/AI activities with NULL userId + conditions.push(or(ne(activityLogs.userId, userId), isNull(activityLogs.userId))!); } if (!includeAiChanges) { From 7ec8cf189de9b26245c83e89e1f778da6605593f Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 16 Jan 2026 17:06:09 -0600 Subject: [PATCH 16/16] Fixed permissions pathing --- apps/processor/tsconfig.json | 1 + apps/web/.claude/ralph-loop.local.md | 9 --------- 2 files changed, 1 insertion(+), 9 deletions(-) delete mode 100644 apps/web/.claude/ralph-loop.local.md diff --git a/apps/processor/tsconfig.json b/apps/processor/tsconfig.json index 86eee5b83..1c657e88d 100644 --- a/apps/processor/tsconfig.json +++ b/apps/processor/tsconfig.json @@ -7,6 +7,7 @@ "baseUrl": ".", "paths": { "@pagespace/lib": ["../../packages/lib/dist/index"], + "@pagespace/lib/permissions": ["../../packages/lib/dist/permissions/index"], "@pagespace/lib/*": ["../../packages/lib/dist/*"], "@pagespace/db": ["../../packages/db/dist/index"], "@pagespace/db/*": ["../../packages/db/dist/*"] diff --git a/apps/web/.claude/ralph-loop.local.md b/apps/web/.claude/ralph-loop.local.md deleted file mode 100644 index e08530565..000000000 --- a/apps/web/.claude/ralph-loop.local.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -active: true -iteration: 4 -max_iterations: 0 -completion_promise: null -started_at: "2026-01-09T21:18:00Z" ---- - -keep verifying all github actions, reading pr comments, addressing them and respondeing them and updating the pr until all things pass 100% review