From 0efce425e71c6f32452e905c92a7e3251d643084 Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Thu, 14 May 2026 18:59:26 +0200 Subject: [PATCH 001/250] fix(matrix): restore @mentions in coherence thread chat Reuse the mention-aware chat composer in coherence thread messages and send resolved mention user IDs so @mentions work in thread/signal conversations like room chat. Co-authored-by: Cursor --- .../components/chat-message-input.tsx | 192 +++++++++++++++--- 1 file changed, 161 insertions(+), 31 deletions(-) diff --git a/packages/epics/src/coherence/components/chat-message-input.tsx b/packages/epics/src/coherence/components/chat-message-input.tsx index 40df8369ec..5f8ab4f477 100644 --- a/packages/epics/src/coherence/components/chat-message-input.tsx +++ b/packages/epics/src/coherence/components/chat-message-input.tsx @@ -1,14 +1,59 @@ 'use client'; import React from 'react'; +import { RoomStateEvent } from 'matrix-js-sdk'; import { useCoherenceMutationsWeb2Rsc, useJwt, useMatrix, } from '@hypha-platform/core/client'; -import { Button, ConfirmDialog, Input } from '@hypha-platform/ui'; -import { PaperPlaneIcon } from '@radix-ui/react-icons'; +import { Button, ConfirmDialog } from '@hypha-platform/ui'; import { useRouter } from 'next/navigation'; +import { + HumanChatPanelChatBar, + type ChatMentionCandidate, +} from '../../common/human-chat-panel'; +import { + matrixMemberDisplayLabel, + shortenMatrixIdForDisplay, +} from '../../common/human-chat-panel/matrix-room-member-display'; +import { + sanitizeMentionDisplayLabel, + wireComposerPlainForMatrixSend, +} from '../../common/human-chat-panel/human-chat-display-mention'; + +/** Sanitized labels shared by multiple members need a disambiguated composer token + map key. */ +function computeDuplicateSanitizedDisplayKeys( + mentionLabelByUserId: ReadonlyMap, +): Set { + const counts = new Map(); + for (const label of mentionLabelByUserId.values()) { + const key = sanitizeMentionDisplayLabel(label); + if (!key) continue; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + const duplicateKeys = new Set(); + for (const [key, count] of counts) { + if (count > 1) duplicateKeys.add(key); + } + return duplicateKeys; +} + +function disambiguatedMentionTokenKey( + userId: string, + displayLabel: string, + duplicateKeys: ReadonlySet, +): string { + let key = sanitizeMentionDisplayLabel(displayLabel); + if (!key) return ''; + if (!duplicateKeys.has(key)) return key; + const stem = shortenMatrixIdForDisplay(userId).replace(/^@/, '').trim(); + const short = + stem.length <= 26 ? stem : `${stem.slice(0, 12)}...${stem.slice(-8)}`; + key = sanitizeMentionDisplayLabel(`${displayLabel} (${short})`); + if (!key) return sanitizeMentionDisplayLabel(userId); + return key; +} export const ChatMessageInput = ({ roomId, @@ -21,18 +66,119 @@ export const ChatMessageInput = ({ }) => { const { client, sendMessage: sendMatrixMessage } = useMatrix(); const [input, setInput] = React.useState(''); + const [mentionMembershipEpoch, setMentionMembershipEpoch] = React.useState(0); const { jwt: authToken } = useJwt(); const { updateCoherenceBySlug } = useCoherenceMutationsWeb2Rsc(authToken); const router = useRouter(); + const mentionCandidates = React.useMemo((): ChatMentionCandidate[] => { + if (!client || !roomId) return []; + const room = client.getRoom(roomId); + if (!room) return []; + const currentUserId = client.getUserId(); + const list: ChatMentionCandidate[] = []; + for (const member of room.getJoinedMembers()) { + const userId = member.userId; + if (!userId) continue; + if (currentUserId && userId === currentUserId) continue; + list.push({ + userId, + displayLabel: matrixMemberDisplayLabel(member, userId), + }); + } + list.sort((a, b) => + a.displayLabel.localeCompare(b.displayLabel, undefined, { + sensitivity: 'base', + }), + ); + return list; + }, [client, roomId, mentionMembershipEpoch]); + + const mentionLabelByUserId = React.useMemo( + () => + new Map( + mentionCandidates.map((candidate) => [ + candidate.userId, + candidate.displayLabel, + ]), + ), + [mentionCandidates], + ); + + const duplicateSanitizedDisplayKeys = React.useMemo( + () => computeDuplicateSanitizedDisplayKeys(mentionLabelByUserId), + [mentionLabelByUserId], + ); + + /** Sanitized display label -> MXID for converting composer `@Name` tokens before Matrix send. */ + const mentionSanitizedLabelToUserId = React.useMemo(() => { + const mapped = new Map(); + for (const [userId, label] of mentionLabelByUserId) { + const key = disambiguatedMentionTokenKey( + userId, + label, + duplicateSanitizedDisplayKeys, + ); + if (!key) continue; + mapped.set(key, userId); + } + return mapped; + }, [duplicateSanitizedDisplayKeys, mentionLabelByUserId]); + + const getMentionComposerLabel = React.useCallback( + (member: ChatMentionCandidate, resolvedComposerLabel?: string) => { + const label = resolvedComposerLabel?.trim() || member.displayLabel; + const effectiveLabels = new Map(mentionLabelByUserId); + effectiveLabels.set(member.userId, label); + const duplicatesForPick = + computeDuplicateSanitizedDisplayKeys(effectiveLabels); + return ( + disambiguatedMentionTokenKey(member.userId, label, duplicatesForPick) || + label + ); + }, + [mentionLabelByUserId], + ); + + const mentionPickerEnabled = mentionCandidates.length > 0; + + React.useEffect(() => { + if (!client || !roomId) return; + const room = client.getRoom(roomId); + if (!room) return; + + const bumpMembership = (...args: unknown[]) => { + const state = args[1] as { roomId?: string } | undefined; + if (state?.roomId !== roomId) return; + setMentionMembershipEpoch((n) => n + 1); + }; + + client.on(RoomStateEvent.Members, bumpMembership); + client.on(RoomStateEvent.NewMember, bumpMembership); + return () => { + client.off(RoomStateEvent.Members, bumpMembership); + client.off(RoomStateEvent.NewMember, bumpMembership); + }; + }, [client, roomId]); + const sendMessage = React.useCallback(async () => { + const trimmed = input.trim(); + if (!trimmed || !roomId) return; try { - await sendMatrixMessage({ roomId, message: input.trim() }); + const { wirePlain, mentionUserIds } = wireComposerPlainForMatrixSend( + input, + mentionSanitizedLabelToUserId, + ); + await sendMatrixMessage({ + roomId, + message: wirePlain, + mentionUserIds, + }); setInput(''); } catch (error) { console.warn(error); } - }, [input, roomId, sendMatrixMessage]); + }, [input, mentionSanitizedLabelToUserId, roomId, sendMatrixMessage]); const handleArchive = React.useCallback(async () => { try { @@ -65,7 +211,7 @@ export const ChatMessageInput = ({ variant="outline" colorVariant="accent" className="grow" - onClick={(e) => { + onClick={() => { console.log('Propose Agreement clicked'); //TODO }} @@ -73,32 +219,16 @@ export const ChatMessageInput = ({ Propose Agreement -
-
- { - sendMessage(); - }} - > - - - } - onKeyDown={(e) => { - if (e.key === 'Enter') { - sendMessage(); - } - }} - onChange={(e) => setInput(e.target.value)} - /> -
+
+
); From dd7fddd0534c0a061a6501650058008f4b27a9cc Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Thu, 14 May 2026 19:35:59 +0200 Subject: [PATCH 002/250] fix(ui): separate left menu and AI panel mobile triggers Make the hamburger control the left menu overlay and add a dedicated visible sparkles trigger for opening the AI panel, preserving independent behavior for both actions. Co-authored-by: Cursor --- apps/web/src/app/layout.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 1a41fd9369..1644b0a83d 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -14,6 +14,7 @@ import { AuthProvider } from '@hypha-platform/authentication'; import { useAuthentication } from '@hypha-platform/authentication'; import { AiLeftPanel, + AiPanelTrigger, AiSidebarTrigger, GlobalCallDockProvider, PanelProviders, @@ -283,8 +284,9 @@ export default async function RootLayout({ closeMenuLabel={navCloseMenuLabel} leadingAction={ aiChatEnabled ? ( -
+
+
) : undefined } From c5eaead4dc9fe4bcf8b8e83a5363823d3f1eb84f Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Thu, 14 May 2026 20:08:08 +0200 Subject: [PATCH 003/250] fix(ui): restore responsive panel triggers and mobile sheet behavior Make the top-menu AI trigger visible on web, improve left hamburger responsiveness, and sync mobile sidebar sheet state so close/open controls behave reliably with proposal-style panel sizing. Co-authored-by: Cursor --- apps/web/src/app/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 1644b0a83d..0f3ce81bfb 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -284,7 +284,7 @@ export default async function RootLayout({ closeMenuLabel={navCloseMenuLabel} leadingAction={ aiChatEnabled ? ( -
+
From b0b8052c3ddb0a363613b59d9b229418c568f9dd Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 02:01:31 +0200 Subject: [PATCH 004/250] fix(ui): show AI magic trigger on desktop Keep the Sparkles AI trigger visible across breakpoints so users can open the AI panel outside mobile while preserving the compact-only sidebar menu toggle. Co-authored-by: Cursor --- apps/web/src/app/layout.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 0f3ce81bfb..ff06bf623f 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -285,7 +285,9 @@ export default async function RootLayout({ leadingAction={ aiChatEnabled ? (
- +
+ +
) : undefined From c14e758c709f9deed5ccb797338be5881a3dfec9 Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 02:34:23 +0200 Subject: [PATCH 005/250] feat(mcp): add space memory call artifact pipeline Add end-to-end support for ingesting call recordings/transcripts and generating discussion summaries, then surface all artifacts through org-memory APIs, MCP tools, and chat tools so AI workflows can read consolidated space memory. Co-authored-by: Cursor --- .../[spaceSlug]/call-artifacts/route.ts | 104 +++++ .../[spaceSlug]/discussion-summary/route.ts | 55 +++ packages/chat-server/src/tools/index.ts | 9 + .../src/tools/ingest-space-call-artifacts.ts | 85 ++++ .../src/tools/summarize-space-discussion.ts | 45 ++ .../src/governance/server/call-artifacts.ts | 393 ++++++++++++++++++ .../server/fetch-org-memory-asset.ts | 88 +++- .../server/get-org-memory-by-space-slug.ts | 58 ++- packages/core/src/governance/server/index.ts | 1 + .../org-memory/build-space-memory-items.ts | 62 ++- .../src/org-memory/org-memory-asset-key.ts | 14 +- .../org-memory/with-org-memory-asset-keys.ts | 21 +- .../get-org-memory-by-space-slug-schema.ts | 13 +- .../src/ingest-space-call-artifacts-schema.ts | 35 ++ packages/mcp-server/src/main.ts | 136 +++++- .../src/summarize-space-discussion-schema.ts | 13 + .../migrations/0049_space_call_artifacts.sql | 66 +++ .../migrations/meta/_journal.json | 7 + .../src/schema/call-artifacts.ts | 109 +++++ packages/storage-postgres/src/schema/index.ts | 9 + 20 files changed, 1313 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/app/api/v1/spaces/[spaceSlug]/call-artifacts/route.ts create mode 100644 apps/web/src/app/api/v1/spaces/[spaceSlug]/discussion-summary/route.ts create mode 100644 packages/chat-server/src/tools/ingest-space-call-artifacts.ts create mode 100644 packages/chat-server/src/tools/summarize-space-discussion.ts create mode 100644 packages/core/src/governance/server/call-artifacts.ts create mode 100644 packages/mcp-server/src/ingest-space-call-artifacts-schema.ts create mode 100644 packages/mcp-server/src/summarize-space-discussion-schema.ts create mode 100644 packages/storage-postgres/migrations/0049_space_call_artifacts.sql create mode 100644 packages/storage-postgres/src/schema/call-artifacts.ts diff --git a/apps/web/src/app/api/v1/spaces/[spaceSlug]/call-artifacts/route.ts b/apps/web/src/app/api/v1/spaces/[spaceSlug]/call-artifacts/route.ts new file mode 100644 index 0000000000..998bfa2b3c --- /dev/null +++ b/apps/web/src/app/api/v1/spaces/[spaceSlug]/call-artifacts/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { ingestSpaceCallArtifacts } from '@hypha-platform/core/server'; +import { db } from '@hypha-platform/storage-postgres'; + +const callArtifactIngestSchema = z.object({ + call_session_id: z.string().trim().min(1), + recording: z + .object({ + media_uri: z.string().trim().min(1), + mime_type: z.string().trim().optional(), + duration_seconds: z.number().int().nonnegative().optional(), + started_at: z.string().trim().optional(), + ended_at: z.string().trim().optional(), + storage_key: z.string().trim().optional(), + source: z.string().trim().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + }) + .optional(), + transcript: z + .object({ + language: z.string().trim().optional(), + text: z.string().trim().min(1), + summary: z.string().trim().optional(), + source: z.string().trim().optional(), + segments: z.array(z.record(z.string(), z.unknown())).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + }) + .optional(), +}); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ spaceSlug: string }> }, +) { + const { spaceSlug } = await params; + const ingestSecret = process.env.HYPHA_CALL_ARTIFACT_INGEST_SECRET?.trim(); + const suppliedSecret = + request.headers.get('x-hypha-ingest-secret')?.trim() ?? + request.headers + .get('authorization') + ?.replace(/^Bearer\s+/i, '') + .trim() ?? + ''; + if (!ingestSecret || suppliedSecret !== ingestSecret) { + return NextResponse.json( + { error: 'Unauthorized ingest request' }, + { status: 401 }, + ); + } + + try { + const body = await request.json(); + const parsed = callArtifactIngestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid payload', details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const result = await ingestSpaceCallArtifacts( + { + spaceSlug, + callSessionId: parsed.data.call_session_id, + recording: parsed.data.recording + ? { + mediaUri: parsed.data.recording.media_uri, + mimeType: parsed.data.recording.mime_type, + durationSeconds: parsed.data.recording.duration_seconds, + startedAt: parsed.data.recording.started_at, + endedAt: parsed.data.recording.ended_at, + storageKey: parsed.data.recording.storage_key, + source: parsed.data.recording.source, + metadata: parsed.data.recording.metadata, + } + : undefined, + transcript: parsed.data.transcript + ? { + language: parsed.data.transcript.language, + text: parsed.data.transcript.text, + summary: parsed.data.transcript.summary, + source: parsed.data.transcript.source, + segments: parsed.data.transcript.segments, + metadata: parsed.data.transcript.metadata, + } + : undefined, + }, + { db }, + ); + + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: 400 }); + } + + return NextResponse.json(result); + } catch (error) { + console.error('[call-artifacts] Failed to ingest call artifacts:', error); + return NextResponse.json( + { error: 'Failed to ingest call artifacts' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/v1/spaces/[spaceSlug]/discussion-summary/route.ts b/apps/web/src/app/api/v1/spaces/[spaceSlug]/discussion-summary/route.ts new file mode 100644 index 0000000000..00631aaa9f --- /dev/null +++ b/apps/web/src/app/api/v1/spaces/[spaceSlug]/discussion-summary/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { canConvertToBigInt } from '@hypha-platform/ui-utils'; +import { + createSpaceDiscussionSummary, + findSpaceBySlug, +} from '@hypha-platform/core/server'; +import { db } from '@hypha-platform/storage-postgres'; +import { checkSpaceAccess } from '@web/utils/check-space-access'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ spaceSlug: string }> }, +) { + const { spaceSlug } = await params; + try { + const space = await findSpaceBySlug({ slug: spaceSlug }, { db }); + if (!space) { + return NextResponse.json({ error: 'Space not found' }, { status: 404 }); + } + + if (space.web3SpaceId && canConvertToBigInt(space.web3SpaceId)) { + const { hasAccess, response } = await checkSpaceAccess( + request, + space.web3SpaceId as number, + ); + if (!hasAccess && response) return response; + } + + const authHeader = request.headers.get('authorization'); + const bearerMatch = authHeader?.match(/^Bearer\s+(.+)$/i); + const bearer = bearerMatch?.[1]?.trim() || undefined; + const result = await createSpaceDiscussionSummary( + { + spaceSlug, + authToken: bearer, + requestUrlForSessionMatrix: request.url, + }, + { db }, + ); + + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: 400 }); + } + return NextResponse.json(result); + } catch (error) { + console.error( + '[discussion-summary] Failed to summarize discussion:', + error, + ); + return NextResponse.json( + { error: 'Failed to summarize discussion' }, + { status: 500 }, + ); + } +} diff --git a/packages/chat-server/src/tools/index.ts b/packages/chat-server/src/tools/index.ts index d477489cd5..0ac8d719b5 100644 --- a/packages/chat-server/src/tools/index.ts +++ b/packages/chat-server/src/tools/index.ts @@ -4,6 +4,8 @@ import { createGetPeopleBySpaceSlugTool } from './get-people-by-space-slug'; import { createGetOrgMemoryBySpaceSlugTool } from './get-org-memory-by-space-slug'; import { createFetchOrgMemoryAssetTool } from './fetch-org-memory-asset'; import { createGetDocumentsBySpaceSlugTool } from './get-documents-by-space-slug'; +import { createSummarizeSpaceDiscussionTool } from './summarize-space-discussion'; +import { createIngestSpaceCallArtifactsTool } from './ingest-space-call-artifacts'; /** * All AI SDK tools exposed by the chat route. Add new tools here and in the @@ -25,6 +27,11 @@ export function createChatTools( requestUrlForSessionMatrix, ), get_documents_by_space_slug: createGetDocumentsBySpaceSlugTool(authToken), + summarize_space_discussion_by_slug: createSummarizeSpaceDiscussionTool( + authToken, + requestUrlForSessionMatrix, + ), + ingest_space_call_artifacts: createIngestSpaceCallArtifactsTool(), } as unknown as Record; } @@ -33,4 +40,6 @@ export { createGetPeopleBySpaceSlugTool } from './get-people-by-space-slug'; export { createGetOrgMemoryBySpaceSlugTool } from './get-org-memory-by-space-slug'; export { createGetDocumentsBySpaceSlugTool } from './get-documents-by-space-slug'; export { createFetchOrgMemoryAssetTool } from './fetch-org-memory-asset'; +export { createSummarizeSpaceDiscussionTool } from './summarize-space-discussion'; +export { createIngestSpaceCallArtifactsTool } from './ingest-space-call-artifacts'; export type { ChatRouteTool } from './types'; diff --git a/packages/chat-server/src/tools/ingest-space-call-artifacts.ts b/packages/chat-server/src/tools/ingest-space-call-artifacts.ts new file mode 100644 index 0000000000..aaeea8dfa8 --- /dev/null +++ b/packages/chat-server/src/tools/ingest-space-call-artifacts.ts @@ -0,0 +1,85 @@ +import { z } from 'zod'; +import { ingestSpaceCallArtifacts } from '@hypha-platform/core/server'; +import { db } from '@hypha-platform/storage-postgres'; +import type { ChatRouteTool } from './types'; +import { sanitizeSlug } from '../system-prompt'; + +export function createIngestSpaceCallArtifactsTool() { + const inputSchema = z.object({ + space_slug: z.string().trim().min(1), + call_session_id: z.string().trim().min(1), + recording: z + .object({ + media_uri: z.string().trim().min(1), + mime_type: z.string().trim().optional(), + duration_seconds: z.number().int().nonnegative().optional(), + started_at: z.string().trim().optional(), + ended_at: z.string().trim().optional(), + storage_key: z.string().trim().optional(), + source: z.string().trim().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + }) + .optional(), + transcript: z + .object({ + language: z.string().trim().optional(), + text: z.string().trim().min(1), + summary: z.string().trim().optional(), + source: z.string().trim().optional(), + segments: z.array(z.record(z.string(), z.unknown())).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + }) + .optional(), + }); + + return { + description: + 'Persist call recording and transcript artifacts into space memory for a call session. Use when external workers provide recording URLs or transcript text.', + inputSchema, + execute: async (args) => { + const parsed = inputSchema.safeParse(args); + if (!parsed.success) { + return { ok: false, error: parsed.error.message }; + } + const safe = sanitizeSlug(parsed.data.space_slug); + if (!safe) return { ok: false, error: 'Invalid space slug format' }; + + const result = await ingestSpaceCallArtifacts( + { + spaceSlug: safe, + callSessionId: parsed.data.call_session_id, + recording: parsed.data.recording + ? { + mediaUri: parsed.data.recording.media_uri, + mimeType: parsed.data.recording.mime_type, + durationSeconds: parsed.data.recording.duration_seconds, + startedAt: parsed.data.recording.started_at, + endedAt: parsed.data.recording.ended_at, + storageKey: parsed.data.recording.storage_key, + source: parsed.data.recording.source, + metadata: parsed.data.recording.metadata, + } + : undefined, + transcript: parsed.data.transcript + ? { + language: parsed.data.transcript.language, + text: parsed.data.transcript.text, + summary: parsed.data.transcript.summary, + source: parsed.data.transcript.source, + segments: parsed.data.transcript.segments, + metadata: parsed.data.transcript.metadata, + } + : undefined, + }, + { db }, + ); + + if (!result.ok) return { ok: false, error: result.error }; + return { + ok: true, + call_session_id: result.callSessionId, + space_id: result.spaceId, + }; + }, + } satisfies ChatRouteTool; +} diff --git a/packages/chat-server/src/tools/summarize-space-discussion.ts b/packages/chat-server/src/tools/summarize-space-discussion.ts new file mode 100644 index 0000000000..192e72e4b2 --- /dev/null +++ b/packages/chat-server/src/tools/summarize-space-discussion.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; +import { createSpaceDiscussionSummary } from '@hypha-platform/core/server'; +import { db } from '@hypha-platform/storage-postgres'; +import type { ChatRouteTool } from './types'; +import { sanitizeSlug } from '../system-prompt'; + +export function createSummarizeSpaceDiscussionTool( + authToken: string, + requestUrlForSessionMatrix?: string, +) { + const inputSchema = z.object({ + space_slug: z.string().trim().min(1), + }); + + return { + description: + 'Generate and persist a summary of recent space chat discussion. Use when user asks to summarize current discussion or produce a memory snapshot.', + inputSchema, + execute: async (args) => { + const parsed = inputSchema.safeParse(args); + if (!parsed.success) { + return { ok: false, error: parsed.error.message }; + } + const safe = sanitizeSlug(parsed.data.space_slug); + if (!safe) { + return { ok: false, error: 'Invalid space slug format' }; + } + const result = await createSpaceDiscussionSummary( + { + spaceSlug: safe, + authToken, + requestUrlForSessionMatrix, + }, + { db }, + ); + if (!result.ok) return { ok: false, error: result.error }; + return { + ok: true, + summary_id: result.summaryId, + message_count: result.messageCount, + participant_count: result.participantCount, + }; + }, + } satisfies ChatRouteTool; +} diff --git a/packages/core/src/governance/server/call-artifacts.ts b/packages/core/src/governance/server/call-artifacts.ts new file mode 100644 index 0000000000..2a286eeeaf --- /dev/null +++ b/packages/core/src/governance/server/call-artifacts.ts @@ -0,0 +1,393 @@ +import 'server-only'; + +import { and, desc, eq } from 'drizzle-orm'; +import { + spaceCallRecordings, + spaceCallTranscripts, + spaceDiscussionSummaries, +} from '@hypha-platform/storage-postgres'; +import type { DbConfig } from '../../server'; +import { findSpaceHostFieldsBySlug } from '../../space/server/queries'; + +export type IngestSpaceCallArtifactsInput = { + spaceSlug: string; + callSessionId: string; + recording?: { + mediaUri: string; + mimeType?: string; + durationSeconds?: number; + startedAt?: string; + endedAt?: string; + storageKey?: string; + source?: string; + metadata?: Record; + }; + transcript?: { + language?: string; + text: string; + summary?: string; + source?: string; + segments?: Array>; + metadata?: Record; + }; +}; + +export type SpaceCallArtifactIngestResult = + | { ok: true; spaceId: number; callSessionId: string } + | { ok: false; error: string }; + +type MatrixTimelineEvent = { + type?: string; + sender?: string; + content?: Record; + origin_server_ts?: number; +}; + +function summarizeTranscriptText(text: string): string { + const compact = text.replace(/\s+/g, ' ').trim(); + if (!compact) return ''; + if (compact.length <= 320) return compact; + const cut = compact.slice(0, 320); + const stop = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! ')); + return (stop > 120 ? cut.slice(0, stop + 1) : `${cut}...`).trim(); +} + +export async function ingestSpaceCallArtifacts( + input: IngestSpaceCallArtifactsInput, + { db }: DbConfig, +): Promise { + const spaceSlug = input.spaceSlug.trim(); + const callSessionId = input.callSessionId.trim(); + if (!spaceSlug || !callSessionId) { + return { ok: false, error: 'spaceSlug and callSessionId are required' }; + } + if (!input.recording && !input.transcript) { + return { + ok: false, + error: 'At least one of recording or transcript must be provided', + }; + } + + const host = await findSpaceHostFieldsBySlug({ slug: spaceSlug }, { db }); + if (!host) return { ok: false, error: 'Space not found' }; + + if (input.recording) { + const mediaUri = input.recording.mediaUri.trim(); + if (!mediaUri) + return { ok: false, error: 'recording.mediaUri is required' }; + await db + .insert(spaceCallRecordings) + .values({ + spaceId: host.id, + callSessionId, + mediaUri, + storageKey: input.recording.storageKey?.trim() || null, + mimeType: input.recording.mimeType?.trim() || 'video/webm', + durationSeconds: input.recording.durationSeconds ?? null, + startedAt: input.recording.startedAt?.trim() || null, + endedAt: input.recording.endedAt?.trim() || null, + source: input.recording.source?.trim() || 'ingest', + metadata: input.recording.metadata ?? {}, + }) + .onConflictDoUpdate({ + target: [ + spaceCallRecordings.spaceId, + spaceCallRecordings.callSessionId, + ], + set: { + mediaUri, + storageKey: input.recording.storageKey?.trim() || null, + mimeType: input.recording.mimeType?.trim() || 'video/webm', + durationSeconds: input.recording.durationSeconds ?? null, + startedAt: input.recording.startedAt?.trim() || null, + endedAt: input.recording.endedAt?.trim() || null, + source: input.recording.source?.trim() || 'ingest', + metadata: input.recording.metadata ?? {}, + updatedAt: new Date(), + }, + }); + } + + if (input.transcript) { + const transcriptText = input.transcript.text.trim(); + if (!transcriptText) { + return { ok: false, error: 'transcript.text is required' }; + } + await db + .insert(spaceCallTranscripts) + .values({ + spaceId: host.id, + callSessionId, + language: input.transcript.language?.trim() || 'und', + text: transcriptText, + summary: + input.transcript.summary?.trim() || + summarizeTranscriptText(transcriptText), + source: input.transcript.source?.trim() || 'stt', + segments: input.transcript.segments ?? [], + metadata: input.transcript.metadata ?? {}, + }) + .onConflictDoUpdate({ + target: [ + spaceCallTranscripts.spaceId, + spaceCallTranscripts.callSessionId, + ], + set: { + language: input.transcript.language?.trim() || 'und', + text: transcriptText, + summary: + input.transcript.summary?.trim() || + summarizeTranscriptText(transcriptText), + source: input.transcript.source?.trim() || 'stt', + segments: input.transcript.segments ?? [], + metadata: input.transcript.metadata ?? {}, + updatedAt: new Date(), + }, + }); + } + + return { ok: true, spaceId: host.id, callSessionId }; +} + +function extractPlainMessageText(content: Record | undefined) { + if (!content) return null; + const msgtype = content.msgtype; + if (msgtype !== 'm.text' && msgtype !== 'm.notice') return null; + const body = content.body; + if (typeof body !== 'string') return null; + const cleaned = body.replace(/\s+/g, ' ').trim(); + return cleaned.length > 0 ? cleaned : null; +} + +function summarizeDiscussion(messages: string[]): { + summary: string; + bullets: string[]; +} { + if (messages.length === 0) { + return { summary: 'No discussion messages found.', bullets: [] }; + } + const bullets = messages + .slice(-3) + .map((m) => (m.length > 220 ? `${m.slice(0, 217).trim()}...` : m)); + const joined = messages.join(' '); + const summary = summarizeTranscriptText(joined); + return { summary, bullets }; +} + +type MatrixChunkResponse = { + chunk?: MatrixTimelineEvent[]; + end?: string; +}; + +async function resolveMatrixAccessToken( + authToken: string | undefined, + requestUrlForSessionMatrix: string | undefined, +) { + const botToken = process.env.HYPHA_MATRIX_ORG_MEMORY_ACCESS_TOKEN?.trim(); + if (botToken) return botToken; + const sessionAuth = authToken?.trim(); + const sessionReqUrl = requestUrlForSessionMatrix?.trim(); + if (!sessionAuth || !sessionReqUrl) return null; + const { resolveUserMatrixAccessTokenForOrgMemory } = await import( + './resolve-user-matrix-access-token-for-org-memory' + ); + return ( + (await resolveUserMatrixAccessTokenForOrgMemory( + sessionAuth, + sessionReqUrl, + )) ?? null + ); +} + +async function fetchRoomDiscussionMessages( + roomId: string, + authToken: string | undefined, + requestUrlForSessionMatrix: string | undefined, + maxPages = 5, +): Promise<{ messages: string[]; participantCount: number }> { + const homeserver = process.env.NEXT_PUBLIC_MATRIX_HOMESERVER_URL?.replace( + /\/?$/, + '', + ); + if (!homeserver) return { messages: [], participantCount: 0 }; + const accessToken = await resolveMatrixAccessToken( + authToken, + requestUrlForSessionMatrix, + ); + if (!accessToken) return { messages: [], participantCount: 0 }; + + const senders = new Set(); + const messages: string[] = []; + let fromToken: string | undefined; + + for (let i = 0; i < maxPages; i++) { + const params = new URLSearchParams({ dir: 'b', limit: '100' }); + if (fromToken) params.set('from', fromToken); + let url = `${homeserver}/_matrix/client/v3/rooms/${encodeURIComponent( + roomId, + )}/messages?${params.toString()}`; + let res = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(15_000), + }); + if (res.status === 401) { + params.set('access_token', accessToken); + url = `${homeserver}/_matrix/client/v3/rooms/${encodeURIComponent( + roomId, + )}/messages?${params.toString()}`; + res = await fetch(url, { signal: AbortSignal.timeout(15_000) }); + } + if (!res.ok) break; + const body = (await res.json()) as MatrixChunkResponse; + const chunk = body.chunk ?? []; + for (const ev of chunk) { + if (ev.sender) senders.add(ev.sender); + const text = extractPlainMessageText(ev.content); + if (text) messages.push(text); + } + const next = typeof body.end === 'string' ? body.end : undefined; + if (!next || chunk.length === 0) break; + fromToken = next; + } + + return { messages: messages.reverse(), participantCount: senders.size }; +} + +export async function createSpaceDiscussionSummary( + { + spaceSlug, + source = 'heuristic', + authToken, + requestUrlForSessionMatrix, + }: { + spaceSlug: string; + source?: string; + authToken?: string; + requestUrlForSessionMatrix?: string; + }, + { db }: DbConfig, +): Promise< + | { + ok: true; + summaryId: number; + messageCount: number; + participantCount: number; + } + | { ok: false; error: string } +> { + const host = await findSpaceHostFieldsBySlug({ slug: spaceSlug }, { db }); + if (!host) return { ok: false, error: 'Space not found' }; + const roomId = host.chatRoomId?.trim(); + if (!roomId) return { ok: false, error: 'Space has no chat room' }; + + const { messages, participantCount } = await fetchRoomDiscussionMessages( + roomId, + authToken, + requestUrlForSessionMatrix, + ); + if (messages.length === 0) { + return { ok: false, error: 'No chat messages available for summary' }; + } + const { summary, bullets } = summarizeDiscussion(messages); + const [inserted] = await db + .insert(spaceDiscussionSummaries) + .values({ + spaceId: host.id, + matrixRoomId: roomId, + summary, + bullets, + messageCount: messages.length, + participantCount, + source, + metadata: {}, + windowStart: null, + windowEnd: null, + }) + .returning(); + + if (!inserted) return { ok: false, error: 'Failed to persist summary' }; + return { + ok: true, + summaryId: inserted.id, + messageCount: messages.length, + participantCount, + }; +} + +export async function listSpaceCallArtifactsBySpaceId( + spaceId: number, + { db }: DbConfig, +) { + const [recordings, transcripts, summaries] = await Promise.all([ + db + .select() + .from(spaceCallRecordings) + .where(eq(spaceCallRecordings.spaceId, spaceId)) + .orderBy(desc(spaceCallRecordings.createdAt)) + .limit(25), + db + .select() + .from(spaceCallTranscripts) + .where(eq(spaceCallTranscripts.spaceId, spaceId)) + .orderBy(desc(spaceCallTranscripts.createdAt)) + .limit(25), + db + .select() + .from(spaceDiscussionSummaries) + .where(eq(spaceDiscussionSummaries.spaceId, spaceId)) + .orderBy(desc(spaceDiscussionSummaries.createdAt)) + .limit(25), + ]); + return { recordings, transcripts, summaries }; +} + +export async function getSpaceCallArtifactById( + { + kind, + id, + spaceId, + }: { + kind: 'recording' | 'transcript' | 'discussion_summary'; + id: number; + spaceId: number; + }, + { db }: DbConfig, +) { + if (kind === 'recording') { + const [row] = await db + .select() + .from(spaceCallRecordings) + .where( + and( + eq(spaceCallRecordings.id, id), + eq(spaceCallRecordings.spaceId, spaceId), + ), + ) + .limit(1); + return row ?? null; + } + if (kind === 'transcript') { + const [row] = await db + .select() + .from(spaceCallTranscripts) + .where( + and( + eq(spaceCallTranscripts.id, id), + eq(spaceCallTranscripts.spaceId, spaceId), + ), + ) + .limit(1); + return row ?? null; + } + const [row] = await db + .select() + .from(spaceDiscussionSummaries) + .where( + and( + eq(spaceDiscussionSummaries.id, id), + eq(spaceDiscussionSummaries.spaceId, spaceId), + ), + ) + .limit(1); + return row ?? null; +} diff --git a/packages/core/src/governance/server/fetch-org-memory-asset.ts b/packages/core/src/governance/server/fetch-org-memory-asset.ts index 328a1d7a7d..ad2505c7cf 100644 --- a/packages/core/src/governance/server/fetch-org-memory-asset.ts +++ b/packages/core/src/governance/server/fetch-org-memory-asset.ts @@ -9,6 +9,12 @@ import { canConvertToBigInt } from '@hypha-platform/ui-utils'; import { HYPHA_MEDIA_BUNDLE_FIELD } from '../../matrix/rich-reply'; import { findAllDocumentsBySpaceSlugWithoutPagination } from './queries'; import { parseOrgMemoryAssetKey } from '../../org-memory/org-memory-asset-key'; +import { getSpaceCallArtifactById } from './call-artifacts'; +import type { + SpaceCallRecording, + SpaceCallTranscript, + SpaceDiscussionSummary, +} from '@hypha-platform/storage-postgres'; const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; // 2 MiB const DEFAULT_FETCH_TIMEOUT_MS = 25_000; @@ -559,7 +565,7 @@ export async function fetchOrgMemoryAsset( } fetchUrl = hit.url.trim(); filename = hit.filename; - } else { + } else if (key.k === 'm') { const matrixRoomId = host.chatRoomId?.trim() ?? ''; if (!matrixRoomId || key.r !== matrixRoomId) { return { @@ -639,6 +645,86 @@ export async function fetchOrgMemoryAsset( mxcParsed.mediaId, ); matrixAccessToken = tokenPack.token; + } else if (key.k === 'cr') { + const recording = (await getSpaceCallArtifactById( + { kind: 'recording', id: key.i, spaceId: host.id }, + { db }, + )) as SpaceCallRecording | null; + if (!recording) { + return { + access: 'ok', + result: { + ok: false, + error: 'Call recording not found for this space', + code: 'not_found', + }, + }; + } + filename = + recording.mediaUri.split('/').pop()?.trim() || + `recording-${recording.callSessionId}.webm`; + fetchUrl = recording.mediaUri; + } else if (key.k === 'ct') { + const transcript = (await getSpaceCallArtifactById( + { kind: 'transcript', id: key.i, spaceId: host.id }, + { db }, + )) as SpaceCallTranscript | null; + if (!transcript) { + return { + access: 'ok', + result: { + ok: false, + error: 'Call transcript not found for this space', + code: 'not_found', + }, + }; + } + const textPayload = transcript.text; + const { text, truncated } = truncateText(textPayload, MAX_TEXT_CHARS); + return { + access: 'ok', + result: buildSuccessText( + `call-transcript-${transcript.callSessionId}.txt`, + 'text/plain', + text, + truncated, + textPayload.length, + ), + }; + } else if (key.k === 'ds') { + const summary = (await getSpaceCallArtifactById( + { kind: 'discussion_summary', id: key.i, spaceId: host.id }, + { db }, + )) as SpaceDiscussionSummary | null; + if (!summary) { + return { + access: 'ok', + result: { + ok: false, + error: 'Discussion summary not found for this space', + code: 'not_found', + }, + }; + } + const bullets = Array.isArray(summary.bullets) + ? summary.bullets.filter( + (b: unknown): b is string => typeof b === 'string', + ) + : []; + const body = [summary.summary, ...bullets.map((b) => `- ${b}`)] + .join('\n') + .trim(); + const { text, truncated } = truncateText(body, MAX_TEXT_CHARS); + return { + access: 'ok', + result: buildSuccessText( + `discussion-summary-${summary.id}.md`, + 'text/markdown', + text, + truncated, + body.length, + ), + }; } if (!fetchUrl) { diff --git a/packages/core/src/governance/server/get-org-memory-by-space-slug.ts b/packages/core/src/governance/server/get-org-memory-by-space-slug.ts index 0a1b024ecd..df0d30706a 100644 --- a/packages/core/src/governance/server/get-org-memory-by-space-slug.ts +++ b/packages/core/src/governance/server/get-org-memory-by-space-slug.ts @@ -20,10 +20,16 @@ import { type HyphaMediaBundleItemWire, } from '../../matrix/rich-reply'; import { withOrgMemoryAssetKeys } from '../../org-memory/with-org-memory-asset-keys'; +import { listSpaceCallArtifactsBySpaceId } from './call-artifacts'; /** Aligned with MCP §8.1 / architecture org memory rows. */ export type OrgMemoryAsset = { - source: 'proposal_upload' | 'matrix_chat'; + source: + | 'proposal_upload' + | 'matrix_chat' + | 'call_recording' + | 'call_transcript' + | 'discussion_summary'; filename: string; /** Opaque key for `fetch_org_memory_asset` (MCP / Chat). */ asset_key?: string; @@ -38,6 +44,11 @@ export type OrgMemoryAsset = { document_state?: Document['state']; document_slug?: string; document_label?: string; + call_session_id?: string; + call_recording_id?: number; + call_transcript_id?: number; + discussion_summary_id?: number; + text_excerpt?: string; occurred_at: string; }; @@ -572,6 +583,7 @@ function filterAssetsBySearch( return assets.filter( (a) => a.filename.toLowerCase().includes(q) || + (a.text_excerpt?.toLowerCase().includes(q) ?? false) || (a.app_url?.toLowerCase().includes(q) ?? false) || (a.mxc_uri?.toLowerCase().includes(q) ?? false), ); @@ -723,7 +735,49 @@ export async function getOrgMemoryBySpaceSlug( } satisfies MatrixOrgMemoryFetchMeta, }; - let combined = [...proposalAssets, ...matrixAssets].sort((a, b) => + const callArtifacts = await listSpaceCallArtifactsBySpaceId(host.id, { db }); + const recordingAssets: OrgMemoryAsset[] = callArtifacts.recordings.map( + (r) => ({ + source: 'call_recording', + filename: + r.mediaUri.split('/').pop()?.trim() || + `call-recording-${r.callSessionId}.webm`, + app_url: r.mediaUri, + mime: r.mimeType, + occurred_at: r.createdAt.toISOString(), + call_session_id: r.callSessionId, + call_recording_id: r.id, + }), + ); + const transcriptAssets: OrgMemoryAsset[] = callArtifacts.transcripts.map( + (t) => ({ + source: 'call_transcript', + filename: `call-transcript-${t.callSessionId}.txt`, + mime: 'text/plain', + occurred_at: t.createdAt.toISOString(), + call_session_id: t.callSessionId, + call_transcript_id: t.id, + text_excerpt: t.summary ?? t.text.slice(0, 240), + }), + ); + const discussionSummaryAssets: OrgMemoryAsset[] = callArtifacts.summaries.map( + (s) => ({ + source: 'discussion_summary', + filename: `discussion-summary-${s.id}.md`, + mime: 'text/markdown', + occurred_at: s.createdAt.toISOString(), + discussion_summary_id: s.id, + text_excerpt: s.summary, + }), + ); + + let combined = [ + ...proposalAssets, + ...matrixAssets, + ...recordingAssets, + ...transcriptAssets, + ...discussionSummaryAssets, + ].sort((a, b) => a.occurred_at < b.occurred_at ? 1 : a.occurred_at > b.occurred_at ? -1 : 0, ); combined = filterAssetsBySearch(combined, assetsSearch); diff --git a/packages/core/src/governance/server/index.ts b/packages/core/src/governance/server/index.ts index 62c699758b..8a4be63834 100644 --- a/packages/core/src/governance/server/index.ts +++ b/packages/core/src/governance/server/index.ts @@ -5,3 +5,4 @@ export * from './get-org-memory-by-space-slug'; export * from './get-token-holdings-by-space-slug'; export * from './fetch-org-memory-asset'; export * from './resolve-document-proposal-status'; +export * from './call-artifacts'; diff --git a/packages/core/src/org-memory/build-space-memory-items.ts b/packages/core/src/org-memory/build-space-memory-items.ts index eb3fa575aa..12b3b8db0a 100644 --- a/packages/core/src/org-memory/build-space-memory-items.ts +++ b/packages/core/src/org-memory/build-space-memory-items.ts @@ -1,7 +1,12 @@ import type { Attachment, Document } from '../governance/types'; import { DocumentState } from '../governance/types'; -export type SpaceMemorySource = 'proposal_upload' | 'matrix_chat'; +export type SpaceMemorySource = + | 'proposal_upload' + | 'matrix_chat' + | 'call_recording' + | 'call_transcript' + | 'discussion_summary'; export type SpaceMemoryAssetKind = 'document' | 'image' | 'video' | 'other'; @@ -24,7 +29,12 @@ export type SpaceMemoryItem = { /** JSON shape of `org_memory_assets[]` from `/api/v1/spaces/.../org-memory` (dates are ISO strings). */ export type OrgMemoryAssetWire = { - source: 'proposal_upload' | 'matrix_chat'; + source: + | 'proposal_upload' + | 'matrix_chat' + | 'call_recording' + | 'call_transcript' + | 'discussion_summary'; filename: string; asset_key?: string; mime?: string; @@ -37,6 +47,9 @@ export type OrgMemoryAssetWire = { document_state?: string; document_slug?: string; document_label?: string; + call_session_id?: string; + discussion_summary_id?: number; + text_excerpt?: string; occurred_at: string; }; @@ -304,6 +317,51 @@ export function buildSpaceMemoryItemsFromOrgMemoryPayload( matrixEventId: ev || undefined, }, }); + continue; + } + + if (a.source === 'call_recording') { + const safeUrl = a.app_url ? normalizeHttpUrl(a.app_url) : null; + items.push({ + id: `call-recording:${a.call_session_id ?? a.filename}`, + name: a.filename?.trim() ? a.filename : 'Call recording', + url: + safeUrl ?? + `memory://call-recording/${a.call_session_id ?? 'unknown'}`, + kind: inferKindFromMime(a.mime, a.filename, a.app_url ?? ''), + source: 'call_recording', + uploadedAt, + context: { + documentId: 0, + documentTitle: a.call_session_id ?? '', + documentState: DocumentState.PROPOSAL, + }, + }); + continue; + } + + if (a.source === 'call_transcript' || a.source === 'discussion_summary') { + const syntheticId = + a.source === 'discussion_summary' + ? String(a.discussion_summary_id ?? a.filename) + : String(a.call_session_id ?? a.filename); + items.push({ + id: `${a.source}:${syntheticId}`, + name: a.filename?.trim() + ? a.filename + : a.source === 'discussion_summary' + ? 'Discussion summary' + : 'Call transcript', + url: `memory://${a.source}/${syntheticId}`, + kind: 'document', + source: a.source, + uploadedAt, + context: { + documentId: 0, + documentTitle: a.text_excerpt ?? '', + documentState: DocumentState.PROPOSAL, + }, + }); } } diff --git a/packages/core/src/org-memory/org-memory-asset-key.ts b/packages/core/src/org-memory/org-memory-asset-key.ts index 6e45b97082..ef26e10333 100644 --- a/packages/core/src/org-memory/org-memory-asset-key.ts +++ b/packages/core/src/org-memory/org-memory-asset-key.ts @@ -4,7 +4,10 @@ */ export type OrgMemoryAssetKeyPayload = | { k: 'p'; d: number; u: string } - | { k: 'm'; r: string; e: string; x: string }; + | { k: 'm'; r: string; e: string; x: string } + | { k: 'cr'; i: number } + | { k: 'ct'; i: number } + | { k: 'ds'; i: number }; function uint8ToBinaryString(bytes: Uint8Array): string { const chunk = 0x8000; @@ -66,5 +69,14 @@ export function parseOrgMemoryAssetKey( ) { return { k: 'm', r: o.r, e: o.e, x: o.x }; } + if (o.k === 'cr' && typeof o.i === 'number') { + return { k: 'cr', i: o.i }; + } + if (o.k === 'ct' && typeof o.i === 'number') { + return { k: 'ct', i: o.i }; + } + if (o.k === 'ds' && typeof o.i === 'number') { + return { k: 'ds', i: o.i }; + } return null; } diff --git a/packages/core/src/org-memory/with-org-memory-asset-keys.ts b/packages/core/src/org-memory/with-org-memory-asset-keys.ts index 217411d5a0..12ab942b04 100644 --- a/packages/core/src/org-memory/with-org-memory-asset-keys.ts +++ b/packages/core/src/org-memory/with-org-memory-asset-keys.ts @@ -5,12 +5,22 @@ import { /** Minimal shape for attaching stable fetch keys (matches org_memory_assets JSON). */ export type OrgMemoryAssetLike = { - source: 'proposal_upload' | 'matrix_chat'; + source: + | 'proposal_upload' + | 'matrix_chat' + | 'call_recording' + | 'call_transcript' + | 'discussion_summary'; app_url?: string; document_id?: number; matrix_room_id?: string; matrix_event_id?: string; mxc_uri?: string; + call_session_id?: string; + call_recording_id?: number; + call_transcript_id?: number; + discussion_summary_id?: number; + text_excerpt?: string; }; export function withOrgMemoryAssetKeys( @@ -32,6 +42,15 @@ export function withOrgMemoryAssetKeys( e: a.matrix_event_id, x: a.mxc_uri.trim(), }; + } else if (a.source === 'call_recording' && a.call_recording_id != null) { + payload = { k: 'cr', i: a.call_recording_id }; + } else if (a.source === 'call_transcript' && a.call_transcript_id != null) { + payload = { k: 'ct', i: a.call_transcript_id }; + } else if ( + a.source === 'discussion_summary' && + a.discussion_summary_id != null + ) { + payload = { k: 'ds', i: a.discussion_summary_id }; } else { payload = { k: 'p', d: a.document_id ?? 0, u: a.app_url?.trim() ?? '' }; } diff --git a/packages/mcp-server/src/get-org-memory-by-space-slug-schema.ts b/packages/mcp-server/src/get-org-memory-by-space-slug-schema.ts index 305abc1160..e5d5520133 100644 --- a/packages/mcp-server/src/get-org-memory-by-space-slug-schema.ts +++ b/packages/mcp-server/src/get-org-memory-by-space-slug-schema.ts @@ -17,7 +17,13 @@ export type GetOrgMemoryBySpaceSlugInput = z.infer< >; const orgMemoryAssetSchema = z.object({ - source: z.enum(['proposal_upload', 'matrix_chat']), + source: z.enum([ + 'proposal_upload', + 'matrix_chat', + 'call_recording', + 'call_transcript', + 'discussion_summary', + ]), filename: z.string(), asset_key: z.string().optional(), mime: z.string().optional(), @@ -30,6 +36,11 @@ const orgMemoryAssetSchema = z.object({ document_state: z.string().optional(), document_slug: z.string().optional(), document_label: z.string().optional(), + call_session_id: z.string().optional(), + call_recording_id: z.number().optional(), + call_transcript_id: z.number().optional(), + discussion_summary_id: z.number().optional(), + text_excerpt: z.string().optional(), occurred_at: z.string(), }); diff --git a/packages/mcp-server/src/ingest-space-call-artifacts-schema.ts b/packages/mcp-server/src/ingest-space-call-artifacts-schema.ts new file mode 100644 index 0000000000..8f20182e32 --- /dev/null +++ b/packages/mcp-server/src/ingest-space-call-artifacts-schema.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +export const ingestSpaceCallArtifactsInputSchema = z.object({ + space_slug: z.string().trim().min(1), + call_session_id: z.string().trim().min(1), + recording: z + .object({ + media_uri: z.string().trim().min(1), + mime_type: z.string().trim().optional(), + duration_seconds: z.number().int().nonnegative().optional(), + started_at: z.string().trim().optional(), + ended_at: z.string().trim().optional(), + storage_key: z.string().trim().optional(), + source: z.string().trim().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + }) + .optional(), + transcript: z + .object({ + language: z.string().trim().optional(), + text: z.string().trim().min(1), + summary: z.string().trim().optional(), + source: z.string().trim().optional(), + segments: z.array(z.record(z.string(), z.unknown())).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + }) + .optional(), +}); + +export const ingestSpaceCallArtifactsOutputSchema = z.object({ + ok: z.boolean(), + spaceId: z.number().optional(), + callSessionId: z.string().optional(), + error: z.string().optional(), +}); diff --git a/packages/mcp-server/src/main.ts b/packages/mcp-server/src/main.ts index d7c9f2bb92..baabdb4eab 100644 --- a/packages/mcp-server/src/main.ts +++ b/packages/mcp-server/src/main.ts @@ -10,6 +10,8 @@ import { getDocumentsBySpaceSlug, getOrgMemoryBySpaceSlug, fetchOrgMemoryAsset, + ingestSpaceCallArtifacts, + createSpaceDiscussionSummary, getSpaceMembersRoster, serializeSpaceMembersRosterDatesForJson, } from '@hypha-platform/core/server'; @@ -33,6 +35,14 @@ import { fetchOrgMemoryAssetInputSchema, fetchOrgMemoryAssetOutputSchema, } from './fetch-org-memory-asset-schema.js'; +import { + summarizeSpaceDiscussionInputSchema, + summarizeSpaceDiscussionOutputSchema, +} from './summarize-space-discussion-schema.js'; +import { + ingestSpaceCallArtifactsInputSchema, + ingestSpaceCallArtifactsOutputSchema, +} from './ingest-space-call-artifacts-schema.js'; import { buildMatrixDiagnosticHint } from './build-matrix-diagnostic-hint.js'; const server = new McpServer( @@ -42,7 +52,129 @@ const server = new McpServer( }, { instructions: - 'Hypha read-only tools: token holdings by space slug; space members by slug; org memory (roster + org_memory_assets with asset_key) by slug; fetch_org_memory_asset reads asset bytes (text/PDF; image/video/Office base64 in auto) with caps; documents in a space by slug.', + 'Hypha tools: token holdings by space slug; space members by slug; org memory (roster + org_memory_assets with asset_key) by slug; fetch_org_memory_asset reads asset bytes (text/PDF; image/video/Office base64 in auto) with caps; documents in a space by slug; summarize_space_discussion_by_slug for matrix chat summaries; ingest_space_call_artifacts to persist recording/transcript artifacts.', + }, +); + +server.registerTool( + 'summarize_space_discussion_by_slug', + { + description: + 'Create and persist a summary of recent matrix chat discussion for a space slug. Stores output in space discussion summaries so it appears in org memory.', + inputSchema: summarizeSpaceDiscussionInputSchema, + outputSchema: summarizeSpaceDiscussionOutputSchema, + }, + async (args) => { + const parsed = summarizeSpaceDiscussionInputSchema.safeParse(args); + if (!parsed.success) { + return { + content: [ + { type: 'text', text: `Invalid input: ${parsed.error.message}` }, + ], + isError: true, + }; + } + const result = await createSpaceDiscussionSummary( + { + spaceSlug: parsed.data.space_slug, + authToken: process.env.HYPHA_MCP_AUTH_TOKEN, + requestUrlForSessionMatrix: + process.env.HYPHA_MCP_MATRIX_REQUEST_URL?.trim() || + (process.env.VERCEL_URL?.trim() + ? `https://${process.env.VERCEL_URL.trim()}` + : undefined), + }, + { db }, + ); + const out = result.ok + ? { + ok: true, + summaryId: result.summaryId, + messageCount: result.messageCount, + participantCount: result.participantCount, + } + : { ok: false, error: result.error }; + return { + content: [ + { + type: 'text', + text: result.ok + ? `Stored discussion summary #${result.summaryId} from ${result.messageCount} messages.` + : `Failed to summarize discussion: ${result.error}`, + }, + ], + structuredContent: out, + ...(result.ok ? {} : { isError: true }), + }; + }, +); + +server.registerTool( + 'ingest_space_call_artifacts', + { + description: + 'Persist call recording and/or transcript metadata for a space and call session. Use this when external workers produce recording URLs or STT results.', + inputSchema: ingestSpaceCallArtifactsInputSchema, + outputSchema: ingestSpaceCallArtifactsOutputSchema, + }, + async (args) => { + const parsed = ingestSpaceCallArtifactsInputSchema.safeParse(args); + if (!parsed.success) { + return { + content: [ + { type: 'text', text: `Invalid input: ${parsed.error.message}` }, + ], + isError: true, + }; + } + const result = await ingestSpaceCallArtifacts( + { + spaceSlug: parsed.data.space_slug, + callSessionId: parsed.data.call_session_id, + recording: parsed.data.recording + ? { + mediaUri: parsed.data.recording.media_uri, + mimeType: parsed.data.recording.mime_type, + durationSeconds: parsed.data.recording.duration_seconds, + startedAt: parsed.data.recording.started_at, + endedAt: parsed.data.recording.ended_at, + storageKey: parsed.data.recording.storage_key, + source: parsed.data.recording.source, + metadata: parsed.data.recording.metadata, + } + : undefined, + transcript: parsed.data.transcript + ? { + language: parsed.data.transcript.language, + text: parsed.data.transcript.text, + summary: parsed.data.transcript.summary, + source: parsed.data.transcript.source, + segments: parsed.data.transcript.segments, + metadata: parsed.data.transcript.metadata, + } + : undefined, + }, + { db }, + ); + const out = result.ok + ? { + ok: true, + spaceId: result.spaceId, + callSessionId: result.callSessionId, + } + : { ok: false, error: result.error }; + return { + content: [ + { + type: 'text', + text: result.ok + ? `Ingested call artifacts for session ${result.callSessionId}.` + : `Failed to ingest call artifacts: ${result.error}`, + }, + ], + structuredContent: out, + ...(result.ok ? {} : { isError: true }), + }; }, ); @@ -272,7 +404,7 @@ server.registerTool( 'fetch_org_memory_asset', { description: - 'Read-only: fetch content for one org-memory asset after listing with get_org_memory_by_space_slug. Input: space_slug + asset_key from org_memory_assets[]. Proposal files: HTTPS fetch with same access as org memory. Matrix: server-side media download with HYPHA_MATRIX_ORG_MEMORY_ACCESS_TOKEN or session Matrix (HYPHA_MCP_AUTH_TOKEN + HYPHA_MCP_MATRIX_REQUEST_URL). return_mode auto: UTF-8 text + PDF text extraction + image/* as base64; text_only skips images; binary_as_base64 returns raw base64 for images and PDF. max_bytes caps download (default 2 MiB, max 4 MiB).', + 'Read-only: fetch content for one org-memory asset after listing with get_org_memory_by_space_slug. Input: space_slug + asset_key from org_memory_assets[]. Supports proposal files (HTTPS), matrix media (server-side download), call transcripts, and discussion summaries. return_mode auto: UTF-8 text + PDF text extraction + image/* as base64; text_only skips binary; binary_as_base64 returns raw base64 for image/video/pdf/office. max_bytes caps download (default 2 MiB, max 4 MiB).', inputSchema: fetchOrgMemoryAssetInputSchema, outputSchema: fetchOrgMemoryAssetOutputSchema, annotations: { diff --git a/packages/mcp-server/src/summarize-space-discussion-schema.ts b/packages/mcp-server/src/summarize-space-discussion-schema.ts new file mode 100644 index 0000000000..cd98c86718 --- /dev/null +++ b/packages/mcp-server/src/summarize-space-discussion-schema.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const summarizeSpaceDiscussionInputSchema = z.object({ + space_slug: z.string().trim().min(1), +}); + +export const summarizeSpaceDiscussionOutputSchema = z.object({ + ok: z.boolean(), + summaryId: z.number().optional(), + messageCount: z.number().optional(), + participantCount: z.number().optional(), + error: z.string().optional(), +}); diff --git a/packages/storage-postgres/migrations/0049_space_call_artifacts.sql b/packages/storage-postgres/migrations/0049_space_call_artifacts.sql new file mode 100644 index 0000000000..799f017a24 --- /dev/null +++ b/packages/storage-postgres/migrations/0049_space_call_artifacts.sql @@ -0,0 +1,66 @@ +CREATE TABLE IF NOT EXISTS "space_call_recordings" ( + "id" serial PRIMARY KEY NOT NULL, + "space_id" integer NOT NULL REFERENCES "spaces"("id") ON DELETE cascade, + "call_session_id" varchar(128) NOT NULL, + "media_uri" text NOT NULL, + "storage_key" text, + "mime_type" varchar(255) NOT NULL, + "duration_seconds" integer, + "started_at" text, + "ended_at" text, + "source" varchar(128) DEFAULT 'unknown' NOT NULL, + "metadata" jsonb, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS "space_call_recordings_space_session_unique" + ON "space_call_recordings" ("space_id", "call_session_id"); +CREATE INDEX IF NOT EXISTS "space_call_recordings_space_idx" + ON "space_call_recordings" ("space_id"); +CREATE INDEX IF NOT EXISTS "space_call_recordings_created_idx" + ON "space_call_recordings" ("created_at"); + +CREATE TABLE IF NOT EXISTS "space_call_transcripts" ( + "id" serial PRIMARY KEY NOT NULL, + "space_id" integer NOT NULL REFERENCES "spaces"("id") ON DELETE cascade, + "call_session_id" varchar(128) NOT NULL, + "language" varchar(32) DEFAULT 'und' NOT NULL, + "text" text NOT NULL, + "summary" text, + "source" varchar(128) DEFAULT 'unknown' NOT NULL, + "segments" jsonb, + "metadata" jsonb, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS "space_call_transcripts_space_session_unique" + ON "space_call_transcripts" ("space_id", "call_session_id"); +CREATE INDEX IF NOT EXISTS "space_call_transcripts_space_idx" + ON "space_call_transcripts" ("space_id"); +CREATE INDEX IF NOT EXISTS "space_call_transcripts_created_idx" + ON "space_call_transcripts" ("created_at"); + +CREATE TABLE IF NOT EXISTS "space_discussion_summaries" ( + "id" serial PRIMARY KEY NOT NULL, + "space_id" integer NOT NULL REFERENCES "spaces"("id") ON DELETE cascade, + "matrix_room_id" text NOT NULL, + "summary" text NOT NULL, + "bullets" jsonb DEFAULT '[]'::jsonb NOT NULL, + "message_count" integer DEFAULT 0 NOT NULL, + "participant_count" integer DEFAULT 0 NOT NULL, + "source" varchar(128) DEFAULT 'heuristic' NOT NULL, + "window_start" text, + "window_end" text, + "metadata" jsonb, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); + +CREATE INDEX IF NOT EXISTS "space_discussion_summaries_space_idx" + ON "space_discussion_summaries" ("space_id"); +CREATE INDEX IF NOT EXISTS "space_discussion_summaries_room_idx" + ON "space_discussion_summaries" ("matrix_room_id"); +CREATE INDEX IF NOT EXISTS "space_discussion_summaries_created_idx" + ON "space_discussion_summaries" ("created_at"); diff --git a/packages/storage-postgres/migrations/meta/_journal.json b/packages/storage-postgres/migrations/meta/_journal.json index 90b029caa1..1ff7865c67 100644 --- a/packages/storage-postgres/migrations/meta/_journal.json +++ b/packages/storage-postgres/migrations/meta/_journal.json @@ -344,6 +344,13 @@ "when": 1777800000000, "tag": "0048_ecosystem_logo_theme_variants", "breakpoints": true + }, + { + "idx": 49, + "version": "7", + "when": 1777900000000, + "tag": "0049_space_call_artifacts", + "breakpoints": true } ] } diff --git a/packages/storage-postgres/src/schema/call-artifacts.ts b/packages/storage-postgres/src/schema/call-artifacts.ts new file mode 100644 index 0000000000..1a0b89757b --- /dev/null +++ b/packages/storage-postgres/src/schema/call-artifacts.ts @@ -0,0 +1,109 @@ +import { + index, + integer, + jsonb, + pgTable, + serial, + text, + uniqueIndex, + varchar, +} from 'drizzle-orm/pg-core'; +import { InferInsertModel, InferSelectModel } from 'drizzle-orm'; +import { commonDateFields } from './shared'; +import { spaces } from './space'; + +export const spaceCallRecordings = pgTable( + 'space_call_recordings', + { + id: serial('id').primaryKey(), + spaceId: integer('space_id') + .notNull() + .references(() => spaces.id, { onDelete: 'cascade' }), + callSessionId: varchar('call_session_id', { length: 128 }).notNull(), + mediaUri: text('media_uri').notNull(), + storageKey: text('storage_key'), + mimeType: varchar('mime_type', { length: 255 }).notNull(), + durationSeconds: integer('duration_seconds'), + startedAt: text('started_at'), + endedAt: text('ended_at'), + source: varchar('source', { length: 128 }).notNull().default('unknown'), + metadata: jsonb('metadata').$type>(), + ...commonDateFields, + }, + (table) => [ + uniqueIndex('space_call_recordings_space_session_unique').on( + table.spaceId, + table.callSessionId, + ), + index('space_call_recordings_space_idx').on(table.spaceId), + index('space_call_recordings_created_idx').on(table.createdAt), + ], +); + +export const spaceCallTranscripts = pgTable( + 'space_call_transcripts', + { + id: serial('id').primaryKey(), + spaceId: integer('space_id') + .notNull() + .references(() => spaces.id, { onDelete: 'cascade' }), + callSessionId: varchar('call_session_id', { length: 128 }).notNull(), + language: varchar('language', { length: 32 }).notNull().default('und'), + text: text('text').notNull(), + summary: text('summary'), + source: varchar('source', { length: 128 }).notNull().default('unknown'), + segments: jsonb('segments').$type>>(), + metadata: jsonb('metadata').$type>(), + ...commonDateFields, + }, + (table) => [ + uniqueIndex('space_call_transcripts_space_session_unique').on( + table.spaceId, + table.callSessionId, + ), + index('space_call_transcripts_space_idx').on(table.spaceId), + index('space_call_transcripts_created_idx').on(table.createdAt), + ], +); + +export const spaceDiscussionSummaries = pgTable( + 'space_discussion_summaries', + { + id: serial('id').primaryKey(), + spaceId: integer('space_id') + .notNull() + .references(() => spaces.id, { onDelete: 'cascade' }), + matrixRoomId: text('matrix_room_id').notNull(), + summary: text('summary').notNull(), + bullets: jsonb('bullets').$type().notNull().default([]), + messageCount: integer('message_count').notNull().default(0), + participantCount: integer('participant_count').notNull().default(0), + source: varchar('source', { length: 128 }).notNull().default('heuristic'), + windowStart: text('window_start'), + windowEnd: text('window_end'), + metadata: jsonb('metadata').$type>(), + ...commonDateFields, + }, + (table) => [ + index('space_discussion_summaries_space_idx').on(table.spaceId), + index('space_discussion_summaries_room_idx').on(table.matrixRoomId), + index('space_discussion_summaries_created_idx').on(table.createdAt), + ], +); + +export type SpaceCallRecording = InferSelectModel; +export type NewSpaceCallRecording = InferInsertModel< + typeof spaceCallRecordings +>; + +export type SpaceCallTranscript = InferSelectModel; +export type NewSpaceCallTranscript = InferInsertModel< + typeof spaceCallTranscripts +>; + +export type SpaceDiscussionSummary = InferSelectModel< + typeof spaceDiscussionSummaries +>; +export type NewSpaceDiscussionSummary = InferInsertModel< + typeof spaceDiscussionSummaries +>; diff --git a/packages/storage-postgres/src/schema/index.ts b/packages/storage-postgres/src/schema/index.ts index 850aae8d40..ee503991c4 100644 --- a/packages/storage-postgres/src/schema/index.ts +++ b/packages/storage-postgres/src/schema/index.ts @@ -13,6 +13,11 @@ import { transfers } from './transfers'; import { coherences } from './coherence'; import { matrixUserLinks } from './matrix-user-link'; import { tokenUpdates, tokenUpdateRelations } from './token-updates'; +import { + spaceCallRecordings, + spaceCallTranscripts, + spaceDiscussionSummaries, +} from './call-artifacts'; export { SPACE_FLAGS } from './flags'; export { CATEGORIES } from './categories'; @@ -29,6 +34,7 @@ export * from './transfers'; export * from './coherence'; export * from './matrix-user-link'; export * from './token-updates'; +export * from './call-artifacts'; export const schema = { documents, @@ -48,4 +54,7 @@ export const schema = { matrixUserLinks, tokenUpdates, tokenUpdateRelations, + spaceCallRecordings, + spaceCallTranscripts, + spaceDiscussionSummaries, }; From 2b2b0ac1d9ea50cbdbf9ea3644d61948683c097a Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 02:36:13 +0200 Subject: [PATCH 006/250] feat(chat): route AI to call-memory MCP tools Update the chat system prompt and exports so Hypha AI explicitly uses discussion summarization and call artifact ingestion tools, and treats call recordings/transcripts as first-class org-memory assets. Co-authored-by: Cursor --- packages/chat-server/src/index.ts | 2 ++ packages/chat-server/src/system-prompt.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/chat-server/src/index.ts b/packages/chat-server/src/index.ts index b48f6de98e..69e904b95a 100644 --- a/packages/chat-server/src/index.ts +++ b/packages/chat-server/src/index.ts @@ -14,5 +14,7 @@ export { getSpaceBySlugTool, createGetPeopleBySpaceSlugTool, createGetOrgMemoryBySpaceSlugTool, + createSummarizeSpaceDiscussionTool, + createIngestSpaceCallArtifactsTool, } from './tools/index'; export type { ChatRouteTool } from './tools/types'; diff --git a/packages/chat-server/src/system-prompt.ts b/packages/chat-server/src/system-prompt.ts index 35f44a18f8..03460f814f 100644 --- a/packages/chat-server/src/system-prompt.ts +++ b/packages/chat-server/src/system-prompt.ts @@ -13,7 +13,7 @@ export function buildSystemPrompt(spaceSlug?: string | null): string { if (spaceSlug) { const safe = sanitizeSlug(spaceSlug); if (!safe) return BASE_SYSTEM_PROMPT; - return `${BASE_SYSTEM_PROMPT}\n\nThe user is currently viewing the space with slug "${safe}".\n\nTool choice:\n- get_space_by_slug: space profile and aggregate numbers only (title, description, member count, document count, subspace count). Use for "tell me about this space", stats, or overview — not for listing people or individual documents.\n- get_token_holdings_by_space_slug: token holdings transparency for a space (minted tokens, holder distribution, treasury slice, Other bucket for small holders). Use for "who holds tokens", "token distribution", "treasury holdings", "recipient split", and Home/Overview token chart questions — always with space_slug "${safe}".\n- get_org_memory_by_space_slug: organisation memory — same member roster as get_people_by_space_slug plus org_memory_assets (each row includes **asset_key** for follow-up fetch). Proposal attachments and lead images from documents; Matrix chat m.file/m.image when NEXT_PUBLIC_MATRIX_HOMESERVER_URL and chat_room_id are set, and either HYPHA_MATRIX_ORG_MEMORY_ACCESS_TOKEN is set **or** your call runs with the user's session so **used_session_matrix_token** can be true from their Human Chat Matrix link). When explaining missing Matrix files, read **matrix_fetch**: **skipped_reason** missing_homeserver_url → homeserver env not set; missing_access_token → neither bot token nor a resolvable session Matrix token; **session_matrix_token_unavailable** true → user has not completed Human Chat Matrix setup or token expired; missing_chat_room_id → no Matrix room on the space; if **attempted** and **http_status** 401/403 → token invalid or user not in room; if **events_in_chunk** > 0 but **media_events_yielded** 0 → recent chunk had no m.file/m.image. **access_token_configured** refers only to HYPHA_MATRIX_ORG_MEMORY_ACCESS_TOKEN; session Matrix can still work when it is false — **never** tell the user that Matrix org memory is impossible solely because that env var is unset; check **used_session_matrix_token** and **session_matrix_token_unavailable** first. Use assets_page / assets_page_size / assets_search to paginate or filter assets separately from the roster (page / page_size / searchTerm apply to members only). Use for space memory, org memory, Coherence / Space Memory, or "all files the space remembers" — always with space_slug "${safe}". Paginate assets until assets_pagination.has_next_page is false when the user needs every file.\n- fetch_org_memory_asset: **read/view asset content** for one row from get_org_memory_by_space_slug — pass space_slug "${safe}" and **asset_key** from org_memory_assets[]. **return_mode** auto: UTF-8 text files, **PDF text extraction** (not raw bytes), **images as data the model can see**; text_only skips binary images; binary_as_base64 for raw image/PDF base64. **max_bytes** defaults to 2 MiB. Use when the user wants summaries, quotes, or to **see** screenshot/image content — not for listing files (use get_org_memory_by_space_slug first).\n- get_people_by_space_slug: the full member roster with the same members payload as get_org_memory_by_space_slug in v1. Use for a plain member list, roster, names, or join dates without space-memory / org-memory framing — always with space_slug "${safe}".\n- get_documents_by_space_slug: paginated list of documents in the space (DB state: discussion/proposal/agreement; when source_chain is rpc, proposal outcome status on each row: accepted / rejected / onVoting for web3-linked proposals). Use for "what proposals", "list documents or agreements", "which are on voting", "search documents in this space", per-document governance fields (state, status, creator), and attachment URLs on document rows — always with space_slug "${safe}". If the user asks for all/every document in the space or every attachment/file across documents, call get_documents_by_space_slug repeatedly with page 2, 3, … until has_next_page is false, then merge results.\n\nIf the user asks about token distribution/holdings, prefer get_token_holdings_by_space_slug over get_space_by_slug. If the user asks about members in an org-memory or space-memory context, prefer get_org_memory_by_space_slug; for a plain roster question, get_people_by_space_slug is equivalent for the members slice in v1. If they ask about members as people or a list without that framing, you may call get_people_by_space_slug. If they ask for document/proposal lists or document details from the catalogue, use get_documents_by_space_slug, not get_space_by_slug. For members, never use get_space_by_slug alone. If the user asks to list every member in an org-memory context, paginate get_org_memory_by_space_slug until has_next_page is false, same as for documents.`; + return `${BASE_SYSTEM_PROMPT}\n\nThe user is currently viewing the space with slug "${safe}".\n\nTool choice:\n- get_space_by_slug: space profile and aggregate numbers only (title, description, member count, document count, subspace count). Use for "tell me about this space", stats, or overview — not for listing people or individual documents.\n- get_token_holdings_by_space_slug: token holdings transparency for a space (minted tokens, holder distribution, treasury slice, Other bucket for small holders). Use for "who holds tokens", "token distribution", "treasury holdings", "recipient split", and Home/Overview token chart questions — always with space_slug "${safe}".\n- get_org_memory_by_space_slug: organisation memory — same member roster as get_people_by_space_slug plus org_memory_assets (each row includes **asset_key** for follow-up fetch). Assets include proposal attachments, Matrix chat files/images, call recordings, call transcripts, and discussion summaries. When explaining missing Matrix files, read **matrix_fetch**: **skipped_reason** missing_homeserver_url → homeserver env not set; missing_access_token → neither bot token nor a resolvable session Matrix token; **session_matrix_token_unavailable** true → user has not completed Human Chat Matrix setup or token expired; missing_chat_room_id → no Matrix room on the space; if **attempted** and **http_status** 401/403 → token invalid or user not in room; if **events_in_chunk** > 0 but **media_events_yielded** 0 → recent chunk had no m.file/m.image. **access_token_configured** refers only to HYPHA_MATRIX_ORG_MEMORY_ACCESS_TOKEN; session Matrix can still work when it is false — **never** tell the user that Matrix org memory is impossible solely because that env var is unset; check **used_session_matrix_token** and **session_matrix_token_unavailable** first. Use assets_page / assets_page_size / assets_search to paginate or filter assets separately from the roster (page / page_size / searchTerm apply to members only). Use for space memory, org memory, Coherence / Space Memory, call memory, transcripts, recordings, and "all files the space remembers" — always with space_slug "${safe}". Paginate assets until assets_pagination.has_next_page is false when the user needs every file.\n- fetch_org_memory_asset: **read/view asset content** for one row from get_org_memory_by_space_slug — pass space_slug "${safe}" and **asset_key** from org_memory_assets[]. Supports proposal files, Matrix files, call transcripts, and discussion summaries. **return_mode** auto: UTF-8 text files, **PDF text extraction** (not raw bytes), **images as data the model can see**; text_only skips binary images; binary_as_base64 for raw image/PDF base64. **max_bytes** defaults to 2 MiB. Use when the user wants summaries, quotes, transcript text, or to **see** screenshot/image content — not for listing files (use get_org_memory_by_space_slug first).\n- summarize_space_discussion_by_slug: create and persist a new discussion summary from recent Matrix chat messages for the space. Use when the user asks to summarize discussion, generate meeting/chat recap, or refresh memory summary.\n- ingest_space_call_artifacts: persist call recording and transcript artifacts into space memory for a call session. Use for ingestion workflows when recording URL and/or transcript payload is provided.\n- get_people_by_space_slug: the full member roster with the same members payload as get_org_memory_by_space_slug in v1. Use for a plain member list, roster, names, or join dates without space-memory / org-memory framing — always with space_slug "${safe}".\n- get_documents_by_space_slug: paginated list of documents in the space (DB state: discussion/proposal/agreement; when source_chain is rpc, proposal outcome status on each row: accepted / rejected / onVoting for web3-linked proposals). Use for "what proposals", "list documents or agreements", "which are on voting", "search documents in this space", per-document governance fields (state, status, creator), and attachment URLs on document rows — always with space_slug "${safe}". If the user asks for all/every document in the space or every attachment/file across documents, call get_documents_by_space_slug repeatedly with page 2, 3, … until has_next_page is false, then merge results.\n\nIf the user asks about token distribution/holdings, prefer get_token_holdings_by_space_slug over get_space_by_slug. If the user asks about members in an org-memory or space-memory context, prefer get_org_memory_by_space_slug; for a plain roster question, get_people_by_space_slug is equivalent for the members slice in v1. If they ask about members as people or a list without that framing, you may call get_people_by_space_slug. If they ask for document/proposal lists or document details from the catalogue, use get_documents_by_space_slug, not get_space_by_slug. For members, never use get_space_by_slug alone. If the user asks to list every member in an org-memory context, paginate get_org_memory_by_space_slug until has_next_page is false, same as for documents.`; } return BASE_SYSTEM_PROMPT; } From ddcecf8e23ac9bbff771bdefa1a741102a305fe8 Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 02:38:44 +0200 Subject: [PATCH 007/250] docs(spec): detail safeThresholdPx 232 breakdown Document the 232px compact-header threshold components so implementers can validate and tune the free-space trigger while keeping hysteresis behavior explicit. Co-authored-by: Cursor --- .../mobile-responsive-panel-and-header-spec.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/requirements/mobile-responsive-panel-and-header-spec.md b/docs/requirements/mobile-responsive-panel-and-header-spec.md index 01b5c5829a..4cb825d973 100644 --- a/docs/requirements/mobile-responsive-panel-and-header-spec.md +++ b/docs/requirements/mobile-responsive-panel-and-header-spec.md @@ -73,7 +73,12 @@ Replace hard-only `max-width: 767px` top-bar switching with a collision-aware th #### Safe threshold definition -- `safeThresholdPx = 232` minimum (chat trigger + profile + spacing + tolerance). +- `safeThresholdPx = 232` minimum, with explicit breakdown: + - chat trigger touch target: `44px` + - profile avatar touch target: `44px` + - inter-control spacing: `8-16px` + - safety margin for intermediate controls, padding, and rounding: `128-136px` + - total: `44 + 44 + (8-16) + (128-136) = 232px` - If `freeSpacePx < safeThresholdPx`, compact mode = `true`. - Add 24px hysteresis to avoid flicker: - enter compact below `232` From 4a29113c2b62a8e54c19c023a6fda8eae851554a Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 02:39:06 +0200 Subject: [PATCH 008/250] docs(spec): add SSR and hydration guidance for compact header Specify conservative SSR defaults and client-only measurement rules so compact header state avoids hydration mismatch and layout shift across first render. Co-authored-by: Cursor --- .../mobile-responsive-panel-and-header-spec.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/requirements/mobile-responsive-panel-and-header-spec.md b/docs/requirements/mobile-responsive-panel-and-header-spec.md index 4cb825d973..d5ecadda74 100644 --- a/docs/requirements/mobile-responsive-panel-and-header-spec.md +++ b/docs/requirements/mobile-responsive-panel-and-header-spec.md @@ -105,6 +105,15 @@ Behavior: - evaluate in `ResizeObserver` + window resize, - apply hysteresis. +#### SSR and Hydration Handling + +- server-rendered initial state: `isCompactHeader = true` (conservative fallback to avoid overlap before measurement). +- initialize `ResizeObserver` and window resize listeners inside `useEffect` only; do not evaluate layout metrics on the server. +- treat `headerMetrics` as hydration-time values; apply hysteresis after first client measurement. +- avoid hydration/visual mismatch by either: + - rendering the conservative compact layout in SSR markup, or + - temporarily suppressing visible transition until measured (for example via short-lived `visibility`/`opacity` guard). + ## B. Refactor `MenuTop` to be Controlled File: `packages/ui/src/organisms/menu-top.tsx` From bfce0920fe515691bb86d74d0a414e5689e6b934 Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 02:39:47 +0200 Subject: [PATCH 009/250] docs(spec): add resize callback throttling guidance Capture debounce/throttle guidance for resize observers and window resize handlers so compact header logic avoids excessive callback churn while preserving hysteresis behavior. Co-authored-by: Cursor --- docs/requirements/mobile-responsive-panel-and-header-spec.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/requirements/mobile-responsive-panel-and-header-spec.md b/docs/requirements/mobile-responsive-panel-and-header-spec.md index d5ecadda74..bf94080ee4 100644 --- a/docs/requirements/mobile-responsive-panel-and-header-spec.md +++ b/docs/requirements/mobile-responsive-panel-and-header-spec.md @@ -103,7 +103,9 @@ Inputs: Behavior: - evaluate in `ResizeObserver` + window resize, +- debounce or throttle both callbacks (target around `~150ms`) to reduce CPU churn on active resize/orientation changes, - apply hysteresis. +- note: hysteresis prevents toggle flicker but does not limit callback frequency. #### SSR and Hydration Handling @@ -214,6 +216,8 @@ File: `packages/epics/src/common/panel-wrap-layout.tsx` (`HumanSidebarTrigger`, 4. Accessibility smoke: - keyboard navigation across header controls, - axe scan for duplicate-label or contrast regressions. +5. Performance smoke on mobile: + - validate resize/orientation handling with the chosen debounce/throttle value (`~150ms` target) to ensure no visible lag or jitter. ## Rollout Notes From 4868a3354c38e210b8b67f96ff9e6913cd19876a Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 02:40:18 +0200 Subject: [PATCH 010/250] docs(spec): enumerate mobile nav parity requirements List the exact MenuTop mobile links migrated into ConnectedButtonProfile.navItems and add ordering plus parity acceptance criteria so implementation remains behaviorally equivalent. Co-authored-by: Cursor --- .../mobile-responsive-panel-and-header-spec.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/requirements/mobile-responsive-panel-and-header-spec.md b/docs/requirements/mobile-responsive-panel-and-header-spec.md index bf94080ee4..fc3b7c5a73 100644 --- a/docs/requirements/mobile-responsive-panel-and-header-spec.md +++ b/docs/requirements/mobile-responsive-panel-and-header-spec.md @@ -148,6 +148,15 @@ Changes: Move mobile navigation links currently exposed by `MenuTop` fullscreen menu into `ConnectedButtonProfile.navItems` (already present) and verify parity. +- Canonical links to preserve (from `app/layout` navItems): + 1. `My Spaces` (`/${lang}/my-spaces`) + 2. `Network` (`/${lang}/network`) +- Target location/order: keep both entries in `ConnectedButtonProfile.navItems` in the same order as above unless product explicitly reprioritizes. +- Keep profile-account actions (`Settings`, `Sign out`, etc.) in the profile surface; they must not require the `MenuTop` fullscreen menu path. +- Reference implementation surfaces: + - `packages/ui/src/organisms/menu-top.tsx` (mobile fullscreen container) + - `apps/web/src/app/layout.tsx` (`ConnectedButtonProfile.navItems` source) + ## D. Panel Trigger and Sidebar Coordination File: `packages/epics/src/common/panel-wrap-layout.tsx` @@ -192,6 +201,10 @@ File: `packages/epics/src/common/panel-wrap-layout.tsx` (`HumanSidebarTrigger`, - Only one hamburger/menu affordance exists in compact header. - Profile avatar remains visible at all compact widths. - When right panel opens in compact mode, header remains non-overlapping and usable. +- Mobile navigation parity after migration: + - labels remain `My Spaces` and `Network`, + - routes/handlers match previous behavior, + - keyboard/tab order and ARIA labels remain valid. ### Adaptive Threshold From bc65a2bde0eed326c88ca1c4645685f7d03317d2 Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 02:40:50 +0200 Subject: [PATCH 011/250] docs(spec): require portrait and landscape viewport validation Extend manual viewport testing to include swapped-dimension landscape variants so compact header behavior is verified across common tablet and phone orientations. Co-authored-by: Cursor --- docs/requirements/mobile-responsive-panel-and-header-spec.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/requirements/mobile-responsive-panel-and-header-spec.md b/docs/requirements/mobile-responsive-panel-and-header-spec.md index fc3b7c5a73..102230c379 100644 --- a/docs/requirements/mobile-responsive-panel-and-header-spec.md +++ b/docs/requirements/mobile-responsive-panel-and-header-spec.md @@ -221,6 +221,7 @@ File: `packages/epics/src/common/panel-wrap-layout.tsx` (`HumanSidebarTrigger`, 1. Manual viewport tests: - 375x812, 390x844, 768x1024, 820x1180, 1024x768. + - For each size, also run the swapped-dimension landscape variant (812x375, 844x390, 1024x768, 1180x820, 768x1024) and verify compact-mode threshold behavior. 2. Manual interaction: - open/close left panel, right panel, profile menu in each viewport. 3. Add/update Playwright spec: From 07d5cc12db4127e392468d25a9e11bd8fac55bad Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 02:41:58 +0200 Subject: [PATCH 012/250] fix(i18n): localize coherence composer placeholder Replace the hardcoded coherence chat composer placeholder with the existing CoherenceTab translation key so user-facing text stays locale-aware. Co-authored-by: Cursor --- .../epics/src/coherence/components/chat-message-input.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/epics/src/coherence/components/chat-message-input.tsx b/packages/epics/src/coherence/components/chat-message-input.tsx index 5f8ab4f477..7a9e202022 100644 --- a/packages/epics/src/coherence/components/chat-message-input.tsx +++ b/packages/epics/src/coherence/components/chat-message-input.tsx @@ -9,6 +9,7 @@ import { } from '@hypha-platform/core/client'; import { Button, ConfirmDialog } from '@hypha-platform/ui'; import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; import { HumanChatPanelChatBar, type ChatMentionCandidate, @@ -64,6 +65,7 @@ export const ChatMessageInput = ({ coherenceSlug: string; closeUrl: string; }) => { + const t = useTranslations('CoherenceTab'); const { client, sendMessage: sendMatrixMessage } = useMatrix(); const [input, setInput] = React.useState(''); const [mentionMembershipEpoch, setMentionMembershipEpoch] = React.useState(0); @@ -224,7 +226,7 @@ export const ChatMessageInput = ({ value={input} onChange={setInput} onSend={sendMessage} - placeholder="Say something..." + placeholder={t('saySomething')} mentionCandidates={mentionCandidates} mentionPickerEnabled={mentionPickerEnabled} getMentionComposerLabel={getMentionComposerLabel} From b106e3847aaee992727e5b200d30bb24e1c2402a Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 02:43:09 +0200 Subject: [PATCH 013/250] fix(call): debounce floating dock geometry persistence Avoid hot-path localStorage writes during dock drag by debouncing geometry persistence and flushing the latest clamped geometry on unmount. Co-authored-by: Cursor --- .../src/common/global-call-dock-overlay.tsx | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/epics/src/common/global-call-dock-overlay.tsx b/packages/epics/src/common/global-call-dock-overlay.tsx index 2a47e3cbd0..9f359f0ac0 100644 --- a/packages/epics/src/common/global-call-dock-overlay.tsx +++ b/packages/epics/src/common/global-call-dock-overlay.tsx @@ -216,6 +216,8 @@ export function GlobalCallDockOverlay() { thumbnail: null, expanded: null, }); + const persistTimeoutRef = React.useRef(null); + const latestGeometryRef = React.useRef(geometry); const dragRef = React.useRef<{ pointerId: number; startX: number; @@ -271,9 +273,48 @@ export function GlobalCallDockOverlay() { React.useEffect(() => { if (!dockStorageHydrated) return; - persistDockGeometry(clampDockGeometry(geometry)); + const clamped = clampDockGeometry(geometry); + latestGeometryRef.current = clamped; + if (persistTimeoutRef.current != null) { + window.clearTimeout(persistTimeoutRef.current); + } + persistTimeoutRef.current = window.setTimeout(() => { + persistDockGeometry(clamped); + persistTimeoutRef.current = null; + }, 180); + return () => { + if (persistTimeoutRef.current != null) { + window.clearTimeout(persistTimeoutRef.current); + persistTimeoutRef.current = null; + } + }; }, [dockStorageHydrated, geometry]); + React.useEffect(() => { + return () => { + persistDockGeometry(clampDockGeometry(latestGeometryRef.current)); + }; + }, []); + + React.useEffect(() => { + const el = dockRef.current; + if (!el || dockMode === 'fullscreen') return; + const ro = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + const rect = entry.contentRect; + setGeometry((prev) => + clampDockGeometry({ + ...prev, + width: Math.round(rect.width), + height: Math.round(rect.height), + }), + ); + }); + ro.observe(el); + return () => ro.disconnect(); + }, [dockMode]); + React.useEffect(() => { if (dockMode === 'fullscreen') return; const onResize = () => { From 06a91f779ddda680fce58dafcaf14a5c994ad975 Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 02:46:21 +0200 Subject: [PATCH 014/250] fix(i18n): localize floating call dock labels Replace hardcoded floating dock copy and aria labels with translation keys, including localized member-count title and action labels across supported locales. Co-authored-by: Cursor --- packages/epics/src/common/global-call-dock-overlay.tsx | 2 ++ packages/i18n/src/messages/de.json | 9 ++++++++- packages/i18n/src/messages/en.json | 9 ++++++++- packages/i18n/src/messages/es.json | 9 ++++++++- packages/i18n/src/messages/fr.json | 9 ++++++++- packages/i18n/src/messages/pt.json | 9 ++++++++- 6 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/epics/src/common/global-call-dock-overlay.tsx b/packages/epics/src/common/global-call-dock-overlay.tsx index 9f359f0ac0..a9cc5c4f1c 100644 --- a/packages/epics/src/common/global-call-dock-overlay.tsx +++ b/packages/epics/src/common/global-call-dock-overlay.tsx @@ -12,6 +12,7 @@ import { import { useMatrix, useMe } from '@hypha-platform/core/client'; import { cn } from '@hypha-platform/ui-utils'; import { usePathname, useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; import { DEFAULT_CALL_FULL_VIEW_LAYOUT, HumanChatPanelCallBanner, @@ -152,6 +153,7 @@ export function GlobalCallDockOverlay() { const t = useTranslations('GlobalCallDock'); const router = useRouter(); const pathname = usePathname() ?? ''; + const t = useTranslations('HumanChatPanel'); const { client } = useMatrix(); const { person: me } = useMe(); const { diff --git a/packages/i18n/src/messages/de.json b/packages/i18n/src/messages/de.json index e38bd8a4d1..ba0a938ea5 100644 --- a/packages/i18n/src/messages/de.json +++ b/packages/i18n/src/messages/de.json @@ -2165,7 +2165,14 @@ "callLeftBannerDismiss": "Schließen", "callPaneResizeSharePeople": "Bildschirmfreigabe und Personen skalieren", "callPaneResizeShareStrip": "Bildschirmfreigabe und Filmstreifen skalieren", - "callPaneResizeSpeakerShare": "Sprecher*in und Bildschirmfreigabe skalieren" + "callPaneResizeSpeakerShare": "Sprecher*in und Bildschirmfreigabe skalieren", + "callDockTitleWithMembers": "Anruf - {count, plural, one {# Mitglied} other {# Mitglieder}}", + "callDockOpenCallSpace": "Anrufraum öffnen", + "callDockSpaceLabel": "Space", + "callDockMinimize": "Anruf minimieren", + "callDockExpand": "Anruf erweitern", + "callDockFullscreen": "Vollbild", + "callDockExitFullscreen": "Vollbild beenden" }, "CoherenceTab": { "signals": "Signale", diff --git a/packages/i18n/src/messages/en.json b/packages/i18n/src/messages/en.json index 210e6e24bf..bdc68bcb2d 100644 --- a/packages/i18n/src/messages/en.json +++ b/packages/i18n/src/messages/en.json @@ -2164,7 +2164,14 @@ "callLeftBannerDismiss": "Dismiss", "callPaneResizeSharePeople": "Resize screen share and people", "callPaneResizeShareStrip": "Resize screen share and filmstrip", - "callPaneResizeSpeakerShare": "Resize speaker and screen share" + "callPaneResizeSpeakerShare": "Resize speaker and screen share", + "callDockTitleWithMembers": "Call - {count, plural, one {# member} other {# members}}", + "callDockOpenCallSpace": "Open call space", + "callDockSpaceLabel": "Space", + "callDockMinimize": "Minimize call", + "callDockExpand": "Expand call", + "callDockFullscreen": "Fullscreen", + "callDockExitFullscreen": "Exit fullscreen" }, "CoherenceTab": { "signals": "Signals", diff --git a/packages/i18n/src/messages/es.json b/packages/i18n/src/messages/es.json index 32673dbb0f..2c58d1251c 100644 --- a/packages/i18n/src/messages/es.json +++ b/packages/i18n/src/messages/es.json @@ -2157,7 +2157,14 @@ "callLeftBannerDismiss": "Cerrar", "callPaneResizeSharePeople": "Redimensionar pantalla y personas", "callPaneResizeShareStrip": "Redimensionar pantalla y tira de vídeo", - "callPaneResizeSpeakerShare": "Redimensionar orador y pantalla" + "callPaneResizeSpeakerShare": "Redimensionar orador y pantalla", + "callDockTitleWithMembers": "Llamada - {count, plural, one {# miembro} other {# miembros}}", + "callDockOpenCallSpace": "Abrir espacio de llamada", + "callDockSpaceLabel": "Espacio", + "callDockMinimize": "Minimizar llamada", + "callDockExpand": "Expandir llamada", + "callDockFullscreen": "Pantalla completa", + "callDockExitFullscreen": "Salir de pantalla completa" }, "CoherenceTab": { "signals": "Señales", diff --git a/packages/i18n/src/messages/fr.json b/packages/i18n/src/messages/fr.json index 75b38a7103..f1f2edaff5 100644 --- a/packages/i18n/src/messages/fr.json +++ b/packages/i18n/src/messages/fr.json @@ -2157,7 +2157,14 @@ "callLeftBannerDismiss": "Fermer", "callPaneResizeSharePeople": "Redimensionner partage d’écran et personnes", "callPaneResizeShareStrip": "Redimensionner partage d’écran et film", - "callPaneResizeSpeakerShare": "Redimensionner l’orateur et le partage d’écran" + "callPaneResizeSpeakerShare": "Redimensionner l’orateur et le partage d’écran", + "callDockTitleWithMembers": "Appel - {count, plural, one {# membre} other {# membres}}", + "callDockOpenCallSpace": "Ouvrir l’espace d’appel", + "callDockSpaceLabel": "Espace", + "callDockMinimize": "Réduire l’appel", + "callDockExpand": "Agrandir l’appel", + "callDockFullscreen": "Plein écran", + "callDockExitFullscreen": "Quitter le plein écran" }, "CoherenceTab": { "signals": "Signaux", diff --git a/packages/i18n/src/messages/pt.json b/packages/i18n/src/messages/pt.json index 4f4ab064ea..e86f6eef4a 100644 --- a/packages/i18n/src/messages/pt.json +++ b/packages/i18n/src/messages/pt.json @@ -2157,7 +2157,14 @@ "callLeftBannerDismiss": "Fechar", "callPaneResizeSharePeople": "Redimensionar partilha e pessoas", "callPaneResizeShareStrip": "Redimensionar partilha e filme", - "callPaneResizeSpeakerShare": "Redimensionar orador e partilha de ecrã" + "callPaneResizeSpeakerShare": "Redimensionar orador e partilha de ecrã", + "callDockTitleWithMembers": "Chamada - {count, plural, one {# membro} other {# membros}}", + "callDockOpenCallSpace": "Abrir espaço da chamada", + "callDockSpaceLabel": "Espaço", + "callDockMinimize": "Minimizar chamada", + "callDockExpand": "Expandir chamada", + "callDockFullscreen": "Ecrã inteiro", + "callDockExitFullscreen": "Sair do ecrã inteiro" }, "CoherenceTab": { "signals": "Sinais", From 3c8871b95973715f3702d2ce7312db7e9462f067 Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 02:47:25 +0200 Subject: [PATCH 015/250] fix(call): keep local screen share outside panel preview only Limit local display-capture filtering to panel preview mode so full-view layouts retain local share visibility while still preventing recursive preview feedback. Co-authored-by: Cursor --- .../human-chat-panel-call-stage.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-call-stage.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-call-stage.tsx index 100a388e78..a2490c633b 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-call-stage.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-call-stage.tsx @@ -370,7 +370,7 @@ export function HumanChatPanelCallStage({ * Also ignore stale/non-live share tracks — those can leave fullscreen layouts * in a split state with no usable share frame rendered. */ - const shareFeeds = rawShareFeeds.filter((feed) => { + const previewShareFeeds = rawShareFeeds.filter((feed) => { const track = feed.stream.getVideoTracks()[0]; if (!track || track.readyState !== 'live') return false; if (feed.isVideoMuted()) return false; @@ -378,10 +378,23 @@ export function HumanChatPanelCallStage({ const displaySurface = track?.getSettings?.().displaySurface; return displaySurface !== 'browser'; }); + const shareFeeds = + layout === 'panel' + ? previewShareFeeds + : rawShareFeeds.filter((feed) => { + const track = feed.stream.getVideoTracks()[0]; + if (!track || track.readyState !== 'live') return false; + return !feed.isVideoMuted(); + }); + const hasRenderableRawShare = rawShareFeeds.some((feed) => { + const track = feed.stream.getVideoTracks()[0]; + if (!track || track.readyState !== 'live') return false; + return !feed.isVideoMuted(); + }); const hasRemotesOrShare = remoteUserMedia.length > 0 || missingRemoteUserIds.length > 0 || - shareFeeds.length > 0; + hasRenderableRawShare; const showLocalInMainGrid = rawShowLocalInMainGrid || (!hasRemotesOrShare && localUserMedia.length > 0); const showLocalPip = rawShowLocalPip && hasRemotesOrShare; From 375c604f0d396800922dde5cce23965e5214d6cf Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 02:48:38 +0200 Subject: [PATCH 016/250] fix(coherence): unlock count persistence after history load Mark coherence history as loaded immediately after initial load completion, including empty timelines, and gate count persistence on that flag to avoid stale totals. Co-authored-by: Cursor --- packages/epics/src/common/human-right-panel.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/epics/src/common/human-right-panel.tsx b/packages/epics/src/common/human-right-panel.tsx index a18e56d61d..a7d6c0b5aa 100644 --- a/packages/epics/src/common/human-right-panel.tsx +++ b/packages/epics/src/common/human-right-panel.tsx @@ -1501,6 +1501,7 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { setRoomId(targetRoomId); await matrixRef.current.loadRoomHistory(targetRoomId); if (cancelled) return; + hasLoadedCoherenceMessagesRef.current = true; const existing = matrixRef.current.getRoomMessages(targetRoomId); if (existing) { setMessages( @@ -1655,9 +1656,7 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { useEffect(() => { if (mode !== 'coherence') return; if (!isMatrixAvailable || !coherenceSlug || !roomId || isJoining) return; - // Avoid clobbering persisted counts with transient empty timeline snapshots. - if (!hasLoadedCoherenceMessagesRef.current && messages.length === 0) return; - hasLoadedCoherenceMessagesRef.current = true; + if (!hasLoadedCoherenceMessagesRef.current) return; updateCoherenceBySlugRef .current({ slug: coherenceSlug, From 1a0acd90acb7281f32cb0387d30fec87399337d5 Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 02:49:43 +0200 Subject: [PATCH 017/250] fix(call): use dialog primitive for full-view overlay Wrap the draggable call full-view container in Dialog/DialogContent to restore escape/focus management while preserving existing layout and controls. Co-authored-by: Cursor --- .../epics/src/common/human-right-panel.tsx | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/packages/epics/src/common/human-right-panel.tsx b/packages/epics/src/common/human-right-panel.tsx index a7d6c0b5aa..7a5a581126 100644 --- a/packages/epics/src/common/human-right-panel.tsx +++ b/packages/epics/src/common/human-right-panel.tsx @@ -2675,6 +2675,124 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
)} + {callUiEnabled && !showFloatingDock && canOpenCallFullView && ( + + +
+ +
+

+ {t('callFullView')} +

+

+ {t('callFullViewDescription')} +

+
+ {showCallLayoutMenuInFullView && ( +
+ +
+ )} +
+
+
+ +
+ {spaceCallScreenshareError && spaceCallState === 'connected' && ( +
+

+ {spaceCallScreenshareError === 'PERMISSION_DENIED' + ? t('callErrorPermission') + : t('callErrorScreenshare')} +

+ +
+ )} +
+ +
+
+
+
+ )} ); } From 94880a99826566d95c6540d9a155a96cd150793f Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 03:02:55 +0200 Subject: [PATCH 018/250] fix(rebase): clean up conflict artifacts after main sync Remove duplicate dock i18n bindings and drop an incompatible full-view block that no longer matches the current human-right-panel architecture on main. Co-authored-by: Cursor --- .../src/common/global-call-dock-overlay.tsx | 2 - .../epics/src/common/human-right-panel.tsx | 118 ------------------ 2 files changed, 120 deletions(-) diff --git a/packages/epics/src/common/global-call-dock-overlay.tsx b/packages/epics/src/common/global-call-dock-overlay.tsx index a9cc5c4f1c..9f359f0ac0 100644 --- a/packages/epics/src/common/global-call-dock-overlay.tsx +++ b/packages/epics/src/common/global-call-dock-overlay.tsx @@ -12,7 +12,6 @@ import { import { useMatrix, useMe } from '@hypha-platform/core/client'; import { cn } from '@hypha-platform/ui-utils'; import { usePathname, useRouter } from 'next/navigation'; -import { useTranslations } from 'next-intl'; import { DEFAULT_CALL_FULL_VIEW_LAYOUT, HumanChatPanelCallBanner, @@ -153,7 +152,6 @@ export function GlobalCallDockOverlay() { const t = useTranslations('GlobalCallDock'); const router = useRouter(); const pathname = usePathname() ?? ''; - const t = useTranslations('HumanChatPanel'); const { client } = useMatrix(); const { person: me } = useMe(); const { diff --git a/packages/epics/src/common/human-right-panel.tsx b/packages/epics/src/common/human-right-panel.tsx index 7a5a581126..a7d6c0b5aa 100644 --- a/packages/epics/src/common/human-right-panel.tsx +++ b/packages/epics/src/common/human-right-panel.tsx @@ -2675,124 +2675,6 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) {
)} - {callUiEnabled && !showFloatingDock && canOpenCallFullView && ( - - -
- -
-

- {t('callFullView')} -

-

- {t('callFullViewDescription')} -

-
- {showCallLayoutMenuInFullView && ( -
- -
- )} -
-
-
- -
- {spaceCallScreenshareError && spaceCallState === 'connected' && ( -
-

- {spaceCallScreenshareError === 'PERMISSION_DENIED' - ? t('callErrorPermission') - : t('callErrorScreenshare')} -

- -
- )} -
- -
-
-
-
- )} ); } From 022a19c97a3b5e5a260a37c25f9c2843f57b52d3 Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 03:04:35 +0200 Subject: [PATCH 019/250] feat(flags): enable space memory by default Flip the space memory feature flag fallback to on so coherence memory is available without requiring explicit opt-in. Co-authored-by: Cursor --- packages/feature-flags/src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/feature-flags/src/index.ts b/packages/feature-flags/src/index.ts index 8b7a875ea7..9ecd98956f 100644 --- a/packages/feature-flags/src/index.ts +++ b/packages/feature-flags/src/index.ts @@ -16,7 +16,7 @@ const parseBoolean = (value: string | undefined): boolean | undefined => { * - Language select: on * - AI panel: on * - Coherence/signals: on - * - Space memory: off + * - Space memory: on * - Human chat: on * * Toolbar overrides and `NEXT_PUBLIC_*` env values still take precedence. @@ -59,7 +59,7 @@ export const flagDefinitionsForDiscovery = { enableSpaceMemory: { key: 'enable-space-memory', defaultValue: - parseBoolean(process.env.NEXT_PUBLIC_ENABLE_SPACE_MEMORY) ?? false, + parseBoolean(process.env.NEXT_PUBLIC_ENABLE_SPACE_MEMORY) ?? true, description: 'Space Memory on Coherence tab. Opt in: HYPHA_ENABLE_SPACE_MEMORY cookie or NEXT_PUBLIC_ENABLE_SPACE_MEMORY=true', origin: 'hypha' as const, @@ -140,6 +140,6 @@ export async function getEnableSpaceMemory(): Promise { return getBooleanFlagFromToolbarOrEnv( 'enable-space-memory', process.env.NEXT_PUBLIC_ENABLE_SPACE_MEMORY, - false, + true, ); } From 6b3f1678f01837edde10b163584d7ebc0fa86331 Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 03:13:12 +0200 Subject: [PATCH 020/250] feat(ai): add web knowledge and space-memory ops automation Add a public web_search tool for left-panel AI and ship production ops endpoints plus runbook for scheduled space-memory refresh and health monitoring. Co-authored-by: Cursor --- .../api/v1/ops/space-memory/health/route.ts | 163 ++++++++++++++++++ .../space-memory/refresh-discussions/route.ts | 132 ++++++++++++++ .../space-memory-production-checklist.md | 113 ++++++++++++ packages/chat-server/src/system-prompt.ts | 2 +- packages/chat-server/src/tools/index.ts | 3 + packages/chat-server/src/tools/web-search.ts | 108 ++++++++++++ 6 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/api/v1/ops/space-memory/health/route.ts create mode 100644 apps/web/src/app/api/v1/ops/space-memory/refresh-discussions/route.ts create mode 100644 docs/operations/space-memory-production-checklist.md create mode 100644 packages/chat-server/src/tools/web-search.ts diff --git a/apps/web/src/app/api/v1/ops/space-memory/health/route.ts b/apps/web/src/app/api/v1/ops/space-memory/health/route.ts new file mode 100644 index 0000000000..18de8ecb93 --- /dev/null +++ b/apps/web/src/app/api/v1/ops/space-memory/health/route.ts @@ -0,0 +1,163 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq, gte, isNotNull, sql } from 'drizzle-orm'; +import { + db, + spaceCallRecordings, + spaceCallTranscripts, + spaceDiscussionSummaries, + spaces, +} from '@hypha-platform/storage-postgres'; + +function readOpsSecret(request: NextRequest): string { + return ( + request.headers.get('x-hypha-ops-secret')?.trim() ?? + request.headers + .get('authorization') + ?.replace(/^Bearer\s+/i, '') + .trim() ?? + '' + ); +} + +export async function GET(request: NextRequest) { + const configuredSecret = + process.env.HYPHA_SPACE_MEMORY_OPS_SECRET?.trim() ?? ''; + if (!configuredSecret) { + return NextResponse.json( + { error: 'HYPHA_SPACE_MEMORY_OPS_SECRET is not configured' }, + { status: 503 }, + ); + } + if (readOpsSecret(request) !== configuredSecret) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const now = Date.now(); + const since24h = new Date(now - 24 * 60 * 60 * 1000); + const since7d = new Date(now - 7 * 24 * 60 * 60 * 1000); + + const [ + spacesWithChatRow, + summaryTotalRow, + transcriptTotalRow, + recordingTotalRow, + summaries24h, + transcripts24h, + recordings24h, + summaries7d, + ] = await Promise.all([ + db + .select({ count: sql`count(*)` }) + .from(spaces) + .where(and(isNotNull(spaces.chatRoomId), eq(spaces.isArchived, false))), + db.select({ count: sql`count(*)` }).from(spaceDiscussionSummaries), + db.select({ count: sql`count(*)` }).from(spaceCallTranscripts), + db.select({ count: sql`count(*)` }).from(spaceCallRecordings), + db + .select({ count: sql`count(*)` }) + .from(spaceDiscussionSummaries) + .where(gte(spaceDiscussionSummaries.createdAt, since24h)), + db + .select({ count: sql`count(*)` }) + .from(spaceCallTranscripts) + .where(gte(spaceCallTranscripts.createdAt, since24h)), + db + .select({ count: sql`count(*)` }) + .from(spaceCallRecordings) + .where(gte(spaceCallRecordings.createdAt, since24h)), + db + .select({ count: sql`count(*)` }) + .from(spaceDiscussionSummaries) + .where(gte(spaceDiscussionSummaries.createdAt, since7d)), + ]); + + const summaries_total = Number(summaryTotalRow[0]?.count ?? 0); + const transcripts_total = Number(transcriptTotalRow[0]?.count ?? 0); + const recordings_total = Number(recordingTotalRow[0]?.count ?? 0); + const spaces_with_chat = Number(spacesWithChatRow[0]?.count ?? 0); + const summaries_last_24h = Number(summaries24h[0]?.count ?? 0); + const transcripts_last_24h = Number(transcripts24h[0]?.count ?? 0); + const recordings_last_24h = Number(recordings24h[0]?.count ?? 0); + const summaries_last_7d = Number(summaries7d[0]?.count ?? 0); + + const readiness = { + ops_secret_configured: Boolean(configuredSecret), + call_artifact_ingest_secret_configured: Boolean( + process.env.HYPHA_CALL_ARTIFACT_INGEST_SECRET?.trim(), + ), + matrix_homeserver_configured: Boolean( + process.env.NEXT_PUBLIC_MATRIX_HOMESERVER_URL?.trim(), + ), + matrix_bot_access_token_configured: Boolean( + process.env.HYPHA_MATRIX_ORG_MEMORY_ACCESS_TOKEN?.trim(), + ), + mcp_auth_token_configured: Boolean( + process.env.HYPHA_MCP_AUTH_TOKEN?.trim(), + ), + mcp_matrix_request_url_configured: Boolean( + process.env.HYPHA_MCP_MATRIX_REQUEST_URL?.trim() || + process.env.VERCEL_URL?.trim(), + ), + }; + + const alerts: Array<{ + level: 'warn' | 'critical'; + code: string; + message: string; + }> = []; + + if (!readiness.call_artifact_ingest_secret_configured) { + alerts.push({ + level: 'critical', + code: 'missing_ingest_secret', + message: + 'HYPHA_CALL_ARTIFACT_INGEST_SECRET is missing; call recordings/transcripts cannot be ingested securely.', + }); + } + if (!readiness.matrix_homeserver_configured) { + alerts.push({ + level: 'critical', + code: 'missing_matrix_homeserver', + message: + 'NEXT_PUBLIC_MATRIX_HOMESERVER_URL is missing; discussion summary generation from Matrix chat cannot run.', + }); + } + if (!readiness.matrix_bot_access_token_configured) { + alerts.push({ + level: 'warn', + code: 'missing_matrix_bot_token', + message: + 'HYPHA_MATRIX_ORG_MEMORY_ACCESS_TOKEN is missing; cron-style summary refresh may fail without user session tokens.', + }); + } + if (spaces_with_chat > 0 && summaries_last_7d === 0) { + alerts.push({ + level: 'warn', + code: 'no_recent_summaries', + message: + 'No discussion summaries were generated in the last 7 days for spaces with chat rooms.', + }); + } + + const status = alerts.some((a) => a.level === 'critical') + ? 'critical' + : alerts.length > 0 + ? 'warn' + : 'ok'; + + return NextResponse.json({ + status, + generated_at: new Date().toISOString(), + readiness, + metrics: { + spaces_with_chat, + summaries_total, + transcripts_total, + recordings_total, + summaries_last_24h, + transcripts_last_24h, + recordings_last_24h, + }, + alerts, + }); +} diff --git a/apps/web/src/app/api/v1/ops/space-memory/refresh-discussions/route.ts b/apps/web/src/app/api/v1/ops/space-memory/refresh-discussions/route.ts new file mode 100644 index 0000000000..92f4ff1c57 --- /dev/null +++ b/apps/web/src/app/api/v1/ops/space-memory/refresh-discussions/route.ts @@ -0,0 +1,132 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, desc, eq, isNotNull } from 'drizzle-orm'; +import { z } from 'zod'; +import { createSpaceDiscussionSummary } from '@hypha-platform/core/server'; +import { db, spaces } from '@hypha-platform/storage-postgres'; + +const refreshPayloadSchema = z.object({ + space_slugs: z.array(z.string().trim().min(1)).optional(), + limit: z.number().int().min(1).max(500).optional().default(100), + include_archived: z.boolean().optional().default(false), + dry_run: z.boolean().optional().default(false), +}); + +function readOpsSecret(request: NextRequest): string { + return ( + request.headers.get('x-hypha-ops-secret')?.trim() ?? + request.headers + .get('authorization') + ?.replace(/^Bearer\s+/i, '') + .trim() ?? + '' + ); +} + +async function readPayload(request: NextRequest) { + try { + const body = await request.json(); + return refreshPayloadSchema.safeParse(body ?? {}); + } catch { + return refreshPayloadSchema.safeParse({}); + } +} + +export async function POST(request: NextRequest) { + const configuredSecret = + process.env.HYPHA_SPACE_MEMORY_OPS_SECRET?.trim() ?? ''; + if (!configuredSecret) { + return NextResponse.json( + { error: 'HYPHA_SPACE_MEMORY_OPS_SECRET is not configured' }, + { status: 503 }, + ); + } + if (readOpsSecret(request) !== configuredSecret) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const parsedPayload = await readPayload(request); + if (!parsedPayload.success) { + return NextResponse.json( + { error: 'Invalid payload', details: parsedPayload.error.flatten() }, + { status: 400 }, + ); + } + const payload = parsedPayload.data; + + const targetSlugs = payload.space_slugs?.length + ? Array.from( + new Set(payload.space_slugs.map((slug) => slug.trim()).filter(Boolean)), + ) + : ( + await db + .select({ slug: spaces.slug, chatRoomId: spaces.chatRoomId }) + .from(spaces) + .where( + and( + isNotNull(spaces.chatRoomId), + payload.include_archived + ? undefined + : eq(spaces.isArchived, false), + ), + ) + .orderBy(desc(spaces.updatedAt)) + .limit(payload.limit) + ) + .filter((row) => Boolean(row.chatRoomId?.trim())) + .map((row) => row.slug); + + if (payload.dry_run) { + return NextResponse.json({ + ok: true, + dry_run: true, + target_count: targetSlugs.length, + target_slugs: targetSlugs, + }); + } + + const summaries: Array<{ + space_slug: string; + ok: boolean; + summary_id?: number; + message_count?: number; + participant_count?: number; + error?: string; + }> = []; + + for (const spaceSlug of targetSlugs) { + const result = await createSpaceDiscussionSummary( + { spaceSlug, source: 'cron' }, + { db }, + ); + if (result.ok) { + summaries.push({ + space_slug: spaceSlug, + ok: true, + summary_id: result.summaryId, + message_count: result.messageCount, + participant_count: result.participantCount, + }); + continue; + } + summaries.push({ + space_slug: spaceSlug, + ok: false, + error: result.error, + }); + } + + const success_count = summaries.filter((s) => s.ok).length; + const failure_count = summaries.length - success_count; + const status = failure_count === 0 ? 200 : 207; + + return NextResponse.json( + { + ok: failure_count === 0, + target_count: targetSlugs.length, + success_count, + failure_count, + results: summaries, + }, + { status }, + ); +} diff --git a/docs/operations/space-memory-production-checklist.md b/docs/operations/space-memory-production-checklist.md new file mode 100644 index 0000000000..8d160d74f8 --- /dev/null +++ b/docs/operations/space-memory-production-checklist.md @@ -0,0 +1,113 @@ +# Space Memory Production Checklist + +This checklist is for running Space Memory in production with reliable ingestion, +scheduled refresh, and actionable monitoring. + +## 1) Required Environment Variables + +Set these on the web deployment: + +- `HYPHA_SPACE_MEMORY_OPS_SECRET` + - Shared secret for ops endpoints: + - `GET /api/v1/ops/space-memory/health` + - `POST /api/v1/ops/space-memory/refresh-discussions` +- `HYPHA_CALL_ARTIFACT_INGEST_SECRET` + - Shared secret for call artifact ingestion endpoint: + - `POST /api/v1/spaces/[spaceSlug]/call-artifacts` +- `NEXT_PUBLIC_MATRIX_HOMESERVER_URL` + - Required for Matrix timeline/media fetch and discussion summaries. +- `HYPHA_MATRIX_ORG_MEMORY_ACCESS_TOKEN` + - Recommended bot/service token for unattended summary refresh jobs. + +Optional but useful: + +- `NEXT_PUBLIC_ENABLE_SPACE_MEMORY=true` + - Explicitly pins Space Memory to enabled (even though default now enables it). +- `HYPHA_MCP_AUTH_TOKEN` +- `HYPHA_MCP_MATRIX_REQUEST_URL` (or `VERCEL_URL`) + +## 2) Ingestion Pipeline (Recordings + Transcripts) + +Your recorder/STT worker must call: + +- `POST /api/v1/spaces/{spaceSlug}/call-artifacts` +- Header: + - `x-hypha-ingest-secret: $HYPHA_CALL_ARTIFACT_INGEST_SECRET` + +Minimum payload: + +```json +{ + "call_session_id": "room-2026-05-16T00:00:00Z", + "transcript": { "text": "..." } +} +``` + +Recommended payload includes both `recording` and `transcript`. + +## 3) Scheduled Discussion Refresh (Automation) + +Use the new ops endpoint: + +- `POST /api/v1/ops/space-memory/refresh-discussions` +- Header: + - `x-hypha-ops-secret: $HYPHA_SPACE_MEMORY_OPS_SECRET` + +Example body: + +```json +{ + "limit": 100, + "include_archived": false +} +``` + +Dry run: + +```json +{ + "dry_run": true, + "limit": 50 +} +``` + +### Suggested schedule + +- Every 30 minutes for active orgs. +- Every 2-4 hours for low-activity environments. + +## 4) Health + Alerting + +Use the new health endpoint: + +- `GET /api/v1/ops/space-memory/health` +- Header: + - `x-hypha-ops-secret: $HYPHA_SPACE_MEMORY_OPS_SECRET` + +The response includes: + +- `status`: `ok` | `warn` | `critical` +- `readiness`: env/token readiness +- `metrics`: recent ingestion/summaries +- `alerts`: machine-readable warnings/critical issues + +### Suggested alert rules + +- Page on `status = critical`. +- Warn if `alerts` contains `missing_matrix_bot_token`. +- Warn if `alerts` contains `no_recent_summaries` for > 24h. + +## 5) Pagination Discipline for “Everything” + +When querying memory via AI/MCP, always paginate until completion: + +- `assets_pagination.has_next_page === false` +- Document/member pagination likewise. + +## 6) Operational Verification (Go-Live) + +1. Call `/api/v1/ops/space-memory/health` and confirm no critical alerts. +2. Run refresh dry-run and verify expected target spaces. +3. Run real refresh and confirm `success_count > 0`. +4. Ingest one synthetic transcript and verify it appears in org memory. +5. Ask AI for “everything this space remembers” and validate multi-page retrieval. diff --git a/packages/chat-server/src/system-prompt.ts b/packages/chat-server/src/system-prompt.ts index 03460f814f..0650eb7ff6 100644 --- a/packages/chat-server/src/system-prompt.ts +++ b/packages/chat-server/src/system-prompt.ts @@ -13,7 +13,7 @@ export function buildSystemPrompt(spaceSlug?: string | null): string { if (spaceSlug) { const safe = sanitizeSlug(spaceSlug); if (!safe) return BASE_SYSTEM_PROMPT; - return `${BASE_SYSTEM_PROMPT}\n\nThe user is currently viewing the space with slug "${safe}".\n\nTool choice:\n- get_space_by_slug: space profile and aggregate numbers only (title, description, member count, document count, subspace count). Use for "tell me about this space", stats, or overview — not for listing people or individual documents.\n- get_token_holdings_by_space_slug: token holdings transparency for a space (minted tokens, holder distribution, treasury slice, Other bucket for small holders). Use for "who holds tokens", "token distribution", "treasury holdings", "recipient split", and Home/Overview token chart questions — always with space_slug "${safe}".\n- get_org_memory_by_space_slug: organisation memory — same member roster as get_people_by_space_slug plus org_memory_assets (each row includes **asset_key** for follow-up fetch). Assets include proposal attachments, Matrix chat files/images, call recordings, call transcripts, and discussion summaries. When explaining missing Matrix files, read **matrix_fetch**: **skipped_reason** missing_homeserver_url → homeserver env not set; missing_access_token → neither bot token nor a resolvable session Matrix token; **session_matrix_token_unavailable** true → user has not completed Human Chat Matrix setup or token expired; missing_chat_room_id → no Matrix room on the space; if **attempted** and **http_status** 401/403 → token invalid or user not in room; if **events_in_chunk** > 0 but **media_events_yielded** 0 → recent chunk had no m.file/m.image. **access_token_configured** refers only to HYPHA_MATRIX_ORG_MEMORY_ACCESS_TOKEN; session Matrix can still work when it is false — **never** tell the user that Matrix org memory is impossible solely because that env var is unset; check **used_session_matrix_token** and **session_matrix_token_unavailable** first. Use assets_page / assets_page_size / assets_search to paginate or filter assets separately from the roster (page / page_size / searchTerm apply to members only). Use for space memory, org memory, Coherence / Space Memory, call memory, transcripts, recordings, and "all files the space remembers" — always with space_slug "${safe}". Paginate assets until assets_pagination.has_next_page is false when the user needs every file.\n- fetch_org_memory_asset: **read/view asset content** for one row from get_org_memory_by_space_slug — pass space_slug "${safe}" and **asset_key** from org_memory_assets[]. Supports proposal files, Matrix files, call transcripts, and discussion summaries. **return_mode** auto: UTF-8 text files, **PDF text extraction** (not raw bytes), **images as data the model can see**; text_only skips binary images; binary_as_base64 for raw image/PDF base64. **max_bytes** defaults to 2 MiB. Use when the user wants summaries, quotes, transcript text, or to **see** screenshot/image content — not for listing files (use get_org_memory_by_space_slug first).\n- summarize_space_discussion_by_slug: create and persist a new discussion summary from recent Matrix chat messages for the space. Use when the user asks to summarize discussion, generate meeting/chat recap, or refresh memory summary.\n- ingest_space_call_artifacts: persist call recording and transcript artifacts into space memory for a call session. Use for ingestion workflows when recording URL and/or transcript payload is provided.\n- get_people_by_space_slug: the full member roster with the same members payload as get_org_memory_by_space_slug in v1. Use for a plain member list, roster, names, or join dates without space-memory / org-memory framing — always with space_slug "${safe}".\n- get_documents_by_space_slug: paginated list of documents in the space (DB state: discussion/proposal/agreement; when source_chain is rpc, proposal outcome status on each row: accepted / rejected / onVoting for web3-linked proposals). Use for "what proposals", "list documents or agreements", "which are on voting", "search documents in this space", per-document governance fields (state, status, creator), and attachment URLs on document rows — always with space_slug "${safe}". If the user asks for all/every document in the space or every attachment/file across documents, call get_documents_by_space_slug repeatedly with page 2, 3, … until has_next_page is false, then merge results.\n\nIf the user asks about token distribution/holdings, prefer get_token_holdings_by_space_slug over get_space_by_slug. If the user asks about members in an org-memory or space-memory context, prefer get_org_memory_by_space_slug; for a plain roster question, get_people_by_space_slug is equivalent for the members slice in v1. If they ask about members as people or a list without that framing, you may call get_people_by_space_slug. If they ask for document/proposal lists or document details from the catalogue, use get_documents_by_space_slug, not get_space_by_slug. For members, never use get_space_by_slug alone. If the user asks to list every member in an org-memory context, paginate get_org_memory_by_space_slug until has_next_page is false, same as for documents.`; + return `${BASE_SYSTEM_PROMPT}\n\nThe user is currently viewing the space with slug "${safe}".\n\nTool choice:\n- get_space_by_slug: space profile and aggregate numbers only (title, description, member count, document count, subspace count). Use for "tell me about this space", stats, or overview — not for listing people or individual documents.\n- get_token_holdings_by_space_slug: token holdings transparency for a space (minted tokens, holder distribution, treasury slice, Other bucket for small holders). Use for "who holds tokens", "token distribution", "treasury holdings", "recipient split", and Home/Overview token chart questions — always with space_slug "${safe}".\n- get_org_memory_by_space_slug: organisation memory — same member roster as get_people_by_space_slug plus org_memory_assets (each row includes **asset_key** for follow-up fetch). Assets include proposal attachments, Matrix chat files/images, call recordings, call transcripts, and discussion summaries. When explaining missing Matrix files, read **matrix_fetch**: **skipped_reason** missing_homeserver_url → homeserver env not set; missing_access_token → neither bot token nor a resolvable session Matrix token; **session_matrix_token_unavailable** true → user has not completed Human Chat Matrix setup or token expired; missing_chat_room_id → no Matrix room on the space; if **attempted** and **http_status** 401/403 → token invalid or user not in room; if **events_in_chunk** > 0 but **media_events_yielded** 0 → recent chunk had no m.file/m.image. **access_token_configured** refers only to HYPHA_MATRIX_ORG_MEMORY_ACCESS_TOKEN; session Matrix can still work when it is false — **never** tell the user that Matrix org memory is impossible solely because that env var is unset; check **used_session_matrix_token** and **session_matrix_token_unavailable** first. Use assets_page / assets_page_size / assets_search to paginate or filter assets separately from the roster (page / page_size / searchTerm apply to members only). Use for space memory, org memory, Coherence / Space Memory, call memory, transcripts, recordings, and "all files the space remembers" — always with space_slug "${safe}". Paginate assets until assets_pagination.has_next_page is false when the user needs every file.\n- fetch_org_memory_asset: **read/view asset content** for one row from get_org_memory_by_space_slug — pass space_slug "${safe}" and **asset_key** from org_memory_assets[]. Supports proposal files, Matrix files, call transcripts, and discussion summaries. **return_mode** auto: UTF-8 text files, **PDF text extraction** (not raw bytes), **images as data the model can see**; text_only skips binary images; binary_as_base64 for raw image/PDF base64. **max_bytes** defaults to 2 MiB. Use when the user wants summaries, quotes, transcript text, or to **see** screenshot/image content — not for listing files (use get_org_memory_by_space_slug first).\n- summarize_space_discussion_by_slug: create and persist a new discussion summary from recent Matrix chat messages for the space. Use when the user asks to summarize discussion, generate meeting/chat recap, or refresh memory summary.\n- ingest_space_call_artifacts: persist call recording and transcript artifacts into space memory for a call session. Use for ingestion workflows when recording URL and/or transcript payload is provided.\n- web_search: search the public web for external/world knowledge. Use for questions not answerable from Hypha tools alone (news, standards, third-party docs, global facts). Prefer Hypha tools for space-specific data; use web_search when the user asks for broader internet knowledge or Hypha data is insufficient.\n- get_people_by_space_slug: the full member roster with the same members payload as get_org_memory_by_space_slug in v1. Use for a plain member list, roster, names, or join dates without space-memory / org-memory framing — always with space_slug "${safe}".\n- get_documents_by_space_slug: paginated list of documents in the space (DB state: discussion/proposal/agreement; when source_chain is rpc, proposal outcome status on each row: accepted / rejected / onVoting for web3-linked proposals). Use for "what proposals", "list documents or agreements", "which are on voting", "search documents in this space", per-document governance fields (state, status, creator), and attachment URLs on document rows — always with space_slug "${safe}". If the user asks for all/every document in the space or every attachment/file across documents, call get_documents_by_space_slug repeatedly with page 2, 3, … until has_next_page is false, then merge results.\n\nIf the user asks about token distribution/holdings, prefer get_token_holdings_by_space_slug over get_space_by_slug. If the user asks about members in an org-memory or space-memory context, prefer get_org_memory_by_space_slug; for a plain roster question, get_people_by_space_slug is equivalent for the members slice in v1. If they ask about members as people or a list without that framing, you may call get_people_by_space_slug. If they ask for document/proposal lists or document details from the catalogue, use get_documents_by_space_slug, not get_space_by_slug. For members, never use get_space_by_slug alone. If the user asks to list every member in an org-memory context, paginate get_org_memory_by_space_slug until has_next_page is false, same as for documents. For external/world knowledge outside Hypha data, use web_search and cite returned sources.`; } return BASE_SYSTEM_PROMPT; } diff --git a/packages/chat-server/src/tools/index.ts b/packages/chat-server/src/tools/index.ts index 0ac8d719b5..ebba594bc1 100644 --- a/packages/chat-server/src/tools/index.ts +++ b/packages/chat-server/src/tools/index.ts @@ -6,6 +6,7 @@ import { createFetchOrgMemoryAssetTool } from './fetch-org-memory-asset'; import { createGetDocumentsBySpaceSlugTool } from './get-documents-by-space-slug'; import { createSummarizeSpaceDiscussionTool } from './summarize-space-discussion'; import { createIngestSpaceCallArtifactsTool } from './ingest-space-call-artifacts'; +import { webSearchTool } from './web-search'; /** * All AI SDK tools exposed by the chat route. Add new tools here and in the @@ -32,6 +33,7 @@ export function createChatTools( requestUrlForSessionMatrix, ), ingest_space_call_artifacts: createIngestSpaceCallArtifactsTool(), + web_search: webSearchTool, } as unknown as Record; } @@ -42,4 +44,5 @@ export { createGetDocumentsBySpaceSlugTool } from './get-documents-by-space-slug export { createFetchOrgMemoryAssetTool } from './fetch-org-memory-asset'; export { createSummarizeSpaceDiscussionTool } from './summarize-space-discussion'; export { createIngestSpaceCallArtifactsTool } from './ingest-space-call-artifacts'; +export { webSearchTool } from './web-search'; export type { ChatRouteTool } from './types'; diff --git a/packages/chat-server/src/tools/web-search.ts b/packages/chat-server/src/tools/web-search.ts new file mode 100644 index 0000000000..f5a4512615 --- /dev/null +++ b/packages/chat-server/src/tools/web-search.ts @@ -0,0 +1,108 @@ +import { z } from 'zod'; +import type { ChatRouteTool } from './types'; + +const WEB_SEARCH_TIMEOUT_MS = 10_000; + +const inputSchema = z.object({ + query: z + .string() + .trim() + .min(2) + .describe('Natural-language web search query.'), + max_results: z.number().int().min(1).max(10).optional().default(5), +}); + +type DuckDuckGoTopic = { + Text?: string; + FirstURL?: string; + Name?: string; + Topics?: DuckDuckGoTopic[]; +}; + +type DuckDuckGoResponse = { + Abstract?: string; + AbstractText?: string; + AbstractURL?: string; + Heading?: string; + RelatedTopics?: DuckDuckGoTopic[]; +}; + +function flattenTopics( + topics: DuckDuckGoTopic[] | undefined, +): DuckDuckGoTopic[] { + if (!topics || topics.length === 0) return []; + const out: DuckDuckGoTopic[] = []; + for (const topic of topics) { + if (topic.FirstURL) out.push(topic); + if (topic.Topics?.length) out.push(...flattenTopics(topic.Topics)); + } + return out; +} + +export const webSearchTool = { + description: + 'Search the public web for world knowledge and recent external information. Use when the user asks about topics outside Hypha space data (news, general facts, standards, third-party products, current events).', + inputSchema, + execute: async (args) => { + const parsed = inputSchema.safeParse(args); + if (!parsed.success) { + return { ok: false, error: parsed.error.message, query: '' }; + } + const { query, max_results } = parsed.data; + + const url = new URL('https://api.duckduckgo.com/'); + url.searchParams.set('q', query); + url.searchParams.set('format', 'json'); + url.searchParams.set('no_html', '1'); + url.searchParams.set('skip_disambig', '1'); + + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(WEB_SEARCH_TIMEOUT_MS), + }); + if (!response.ok) { + return { + ok: false, + query, + error: `Search request failed with status ${response.status}`, + }; + } + const body = (await response.json()) as DuckDuckGoResponse; + const related = flattenTopics(body.RelatedTopics); + const topResults = related.slice(0, max_results).map((topic) => ({ + title: + topic.Text?.split(' - ')[0]?.trim() || topic.Name?.trim() || 'Result', + url: topic.FirstURL ?? '', + snippet: topic.Text?.trim() || '', + source: 'duckduckgo', + })); + + const abstract = body.AbstractText?.trim(); + const abstractResult = + abstract && body.AbstractURL + ? [ + { + title: body.Heading?.trim() || 'Abstract', + url: body.AbstractURL, + snippet: abstract, + source: 'duckduckgo', + }, + ] + : []; + + const results = [...abstractResult, ...topResults].slice(0, max_results); + return { + ok: true, + query, + results, + fetched_at: new Date().toISOString(), + }; + } catch (error) { + return { + ok: false, + query, + error: error instanceof Error ? error.message : 'Unknown search error', + }; + } + }, +} satisfies ChatRouteTool; From 86dbf0fcb48237d7e16394b717f7ed6a3c0da0b0 Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 03:15:22 +0200 Subject: [PATCH 021/250] docs(spec): define signal-over-noise activity policy for AI Clarify which activity records should be excluded as telemetry/debug noise and codify the high-signal event requirements so space memory stays concise and useful for reasoning. Co-authored-by: Cursor --- .../mcp-get-people-by-space-slug-tech-spec.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/requirements/mcp-get-people-by-space-slug-tech-spec.md b/docs/requirements/mcp-get-people-by-space-slug-tech-spec.md index 02d625002e..c57100cbe2 100644 --- a/docs/requirements/mcp-get-people-by-space-slug-tech-spec.md +++ b/docs/requirements/mcp-get-people-by-space-slug-tech-spec.md @@ -68,6 +68,34 @@ For **space-type** members (another space’s treasury address in the on-chain l | Include “members of **child spaces**” of the active space | **Exclude.** Roster = members **of** the active space only. | | Child spaces appear only via `parent_id` | Child spaces are a **hierarchy** concern; **not** the same as “Space” badge members, which are **other spaces whose address is in the on-chain member set** for the host space. | +### 2.5 Activity-signal quality policy (AI context) + +This tool is roster-focused, but assistants often combine roster + activity context. +For AI quality and cost control, activity context linked to members MUST be **high signal** +and MUST exclude telemetry/debug noise. + +**Exclude as noise (do not model as activity facts):** + +- UI/session telemetry (clicks, scroll depth, panel open/close, viewport dimensions) +- low-level delivery mechanics (typing indicators, reconnect churn, read-receipt internals) +- transient presence chatter (heartbeat/ping noise, reconnect join/leave flaps) +- debug/trace internals (stack traces, retry internals, verbose transport logs) +- redundant snapshots (full-state repeats without meaningful semantic change) +- raw binary payloads as activity records (store URI/metadata/transcript, not media bytes) +- duplicate cross-channel copies without new context +- non-essential PII (device IDs, IPs, user-agent strings) unless explicitly approved + +**Keep as required activity intelligence (when included by sibling tools):** + +- actor + action + timestamp + canonical reference (`space_slug`, `doc slug`, `asset_key`, `call_session_id`) +- decision artifacts (proposal/vote/outcome/rationale) +- conversation intelligence (summary, key quotes, participant set) +- call intelligence (transcript, summary, action items, recording URI) +- meaningful document deltas (semantic revision events, not keystroke logs) +- task lifecycle transitions (opened/assigned/completed/blocked) + +Rule: if a record cannot help answer **what happened, why, and what changed**, it is out of scope for AI memory context. + --- ## 3) Functional requirements @@ -85,6 +113,7 @@ For **space-type** members (another space’s treasury address in the on-chain l | **FR-9** | The tool SHALL include **`asOf`** (ISO-8601 UTC timestamp of the read). | | **FR-10** | When the slug does not match a space, the tool SHALL return a **not-found** result; structured payload MUST match the convention used by `get_spaces`. | | **FR-11** | Invalid slug input SHALL fail validation **before** database or RPC access (same slug validation pattern as `get_spaces` / chat tools). | +| **FR-12** | Any optional member-activity metadata exposed by this tool or adjacent aggregators SHALL follow §2.5 signal policy: include only semantic activity events and exclude telemetry/debug noise. | --- @@ -215,6 +244,7 @@ The org-memory umbrella tool (**v1**) returns the **same `members` + `pagination | **NFR-1** | Deterministic date serialization: UTC ISO-8601 strings. | | **NFR-2** | Bounded `page_size`. | | **NFR-3** | No secrets in structured output. | +| **NFR-4** | Activity context quality: semantic, deduplicated, retrieval-ready references only; no telemetry/debug noise (§2.5). | --- @@ -225,6 +255,7 @@ The org-memory umbrella tool (**v1**) returns the **same `members` + `pagination - [ ] Each space entry includes full **space** profile fields for the member space. - [ ] `structuredContent` validates against the output schema. - [ ] Automated tests cover mixed rosters and pagination. +- [ ] Any activity context attached to members excludes noise classes in §2.5 and preserves canonical retrieval IDs. --- From cc07154cc7c1de4852dd9513d86a24e7df9ef27e Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Sat, 16 May 2026 03:23:55 +0200 Subject: [PATCH 022/250] feat(memory): improve space memory usability and signal quality guidance Revamp Space Memory into a clearer card-based experience with better filtering, context labels, and scanable summaries while aligning org-memory spec guidance to enforce high-signal activity context for AI. Co-authored-by: Cursor --- ...-get-org-memory-by-space-slug-tech-spec.md | 30 +++++++ .../coherence/components/memory-filters.tsx | 16 +++- .../components/space-memory-section.tsx | 90 +++++++++++++++---- .../components/space-memory-timeline-item.tsx | 51 +++++++---- 4 files changed, 147 insertions(+), 40 deletions(-) diff --git a/docs/requirements/mcp-get-org-memory-by-space-slug-tech-spec.md b/docs/requirements/mcp-get-org-memory-by-space-slug-tech-spec.md index 60e8132214..cf52fd835f 100644 --- a/docs/requirements/mcp-get-org-memory-by-space-slug-tech-spec.md +++ b/docs/requirements/mcp-get-org-memory-by-space-slug-tech-spec.md @@ -59,6 +59,33 @@ Implementers SHOULD reuse the **same** roster computation and serialization as ` - **Not** returning full governance document rows in **`get_org_memory_by_space_slug`** (use `get_documents_by_space_slug`). - **Not** inlining multi‑MiB binaries in **`get_org_memory_by_space_slug`** listing responses (use **`fetch_org_memory_asset`**). +### 2.5 Activity-signal quality policy (AI context) + +Because this tool is the AI memory umbrella, activity context MUST be high-signal and +exclude telemetry/debug noise that does not improve reasoning quality. + +**Exclude as noise (do not model as activity facts):** + +- UI/session telemetry (clicks, scroll depth, panel open/close, viewport dimensions) +- low-level delivery mechanics (typing indicators, reconnect churn, read-receipt internals) +- transient presence chatter (heartbeat/ping noise, reconnect join/leave flaps) +- debug/trace internals (stack traces, retry internals, verbose transport logs) +- redundant snapshots (full-state repeats without meaningful semantic change) +- raw binary payloads as activity records (store URI/metadata/transcript, not media bytes) +- duplicate cross-channel copies without new context +- non-essential PII (device IDs, IPs, user-agent strings) unless explicitly approved + +**Keep as required activity intelligence:** + +- actor + action + timestamp + canonical reference (`space_slug`, `doc slug`, `asset_key`, `call_session_id`) +- decision artifacts (proposal/vote/outcome/rationale) +- conversation intelligence (summary, key quotes, participant set) +- call intelligence (transcript, summary, action items, recording URI) +- meaningful document deltas (semantic revision events, not keystroke logs) +- task lifecycle transitions (opened/assigned/completed/blocked) + +Rule: if a record cannot help answer **what happened, why, and what changed**, it is out of scope for AI memory context. + --- ## 3) Functional requirements @@ -76,6 +103,7 @@ Implementers SHOULD reuse the **same** roster computation and serialization as ` | **FR-9** | Serialize all **`Date`** fields in nested objects as **ISO-8601 strings** via **`serializeSpaceMembersRosterDatesForJson`** before Zod validation. | | **FR-10** | After building the payload, the server SHALL validate **`structuredContent`** with **`getOrgMemoryBySpaceSlugOutputSchema`** (including **`org_memory_assets`** items and optional **`asset_key`**). | | **FR-15** | The system SHALL expose MCP tool **`fetch_org_memory_asset`** with **`readOnlyHint`** / **`idempotentHint`**, same **`checkSpaceAccessForSpace`** rules, **`fetchOrgMemoryAssetOutputSchema`** validation, and documented **`max_bytes`** / timeout behaviour (see §2.3). | +| **FR-16** | Any activity metadata surfaced through `org_memory_assets` or related enrichment SHALL comply with §2.5 signal policy: include semantic activity events and exclude telemetry/debug noise. | --- @@ -116,6 +144,7 @@ Update **`McpServer` `instructions`** string to mention the new tool. - **`HYPHA_MCP_AUTH_TOKEN`**: unchanged (Privy JWT). - **Rate limits:** same as roster (RPC for member addresses). - **Matrix fetch:** **`fetch_org_memory_asset`** MUST use server-side Matrix credentials for **`mxc_uri`** (never assume browser-only `mxcUrlToHttp`) — see [documents-and-media-overview §4.7](../architecture/documents-and-media-overview.md#47-mcp-and-hypha-chat-ai) and [mcp-get-documents-by-space-slug-tech-spec §8.2–8.3](./mcp-get-documents-by-space-slug-tech-spec.md#82-ai-opening-documents-images-and-video). +- **Data minimization for AI:** enforce §2.5 by excluding telemetry/debug/PII noise from memory records and summaries. --- @@ -131,6 +160,7 @@ Update **`McpServer` `instructions`** string to mention the new tool. | Output schema | Passes `getOrgMemoryBySpaceSlugOutputSchema.safeParse` | | **Step 3:** catalogue + Matrix row | At least one **`org_memory_assets`** item with **`source: matrix_chat`** and **`mxc_uri`**; restricted space without token → `isError` | | **Step 3:** proposal row in catalogue | Row with **`source`** proposal/upload and **`app_url`** or **`document_id`** | +| Activity noise policy | Activity records included in memory exclude noise classes in §2.5 and preserve canonical retrieval IDs | --- diff --git a/packages/epics/src/coherence/components/memory-filters.tsx b/packages/epics/src/coherence/components/memory-filters.tsx index 6091a8ede6..ca597986fd 100644 --- a/packages/epics/src/coherence/components/memory-filters.tsx +++ b/packages/epics/src/coherence/components/memory-filters.tsx @@ -42,15 +42,23 @@ export function MemoryFilters({ ]; return ( -
+
onFilterChange(value as MemoryFilterValue)} - className="w-full lg:w-auto" + className="w-full" > - + {tabItems.map((item) => ( - + {item.label} diff --git a/packages/epics/src/coherence/components/space-memory-section.tsx b/packages/epics/src/coherence/components/space-memory-section.tsx index 0e99334905..ef208e7ac0 100644 --- a/packages/epics/src/coherence/components/space-memory-section.tsx +++ b/packages/epics/src/coherence/components/space-memory-section.tsx @@ -10,6 +10,7 @@ import { DocumentState, EventType, RoomEvent, + SpaceMemoryItem, filterSpaceMemoryItems, useMatrix, useSpaceBySlug, @@ -107,6 +108,28 @@ export const SpaceMemorySection: FC = ({ | 'documentStates.agreement', ); + const contextLineForItem = React.useCallback( + (row: SpaceMemoryItem) => { + if (row.source === 'matrix_chat') { + return t('spaceMemoryContextMatrix'); + } + if (row.source === 'call_transcript') { + return t('spaceMemoryContextCallTranscript'); + } + if (row.source === 'call_recording') { + return t('spaceMemoryContextCallRecording'); + } + if (row.source === 'discussion_summary') { + return row.name; + } + return t('spaceMemoryContext', { + title: row.context.documentTitle || t('untitledDocument'), + state: stateLabel(row.context.documentState), + }); + }, + [stateLabel, t], + ); + const counts = React.useMemo( () => ({ @@ -161,14 +184,19 @@ export const SpaceMemorySection: FC = ({ className="flex w-full flex-col gap-4 py-2" aria-label={t('spaceMemory')} > -

- {t('spaceMemory')} - {typeof totalCount === 'number' ? ( - - | {Intl.NumberFormat().format(totalCount)} - - ) : null} -

+
+

+ {t('spaceMemory')} + {typeof totalCount === 'number' ? ( + + | {Intl.NumberFormat().format(totalCount)} + + ) : null} +

+

+ {t('spaceMemoryTimelineLabel')} +

+
= ({ counts={counts} showAiChatTab={showAiChatTab} /> +
+
+

+ {t('spaceMemoryGeneral')} +

+

+ {counts.general} +

+
+
+

+ {t('spaceMemoryProposals')} +

+

+ {counts.proposals} +

+
+
+

+ {t('spaceMemoryConversations')} +

+

+ {counts.conversations} +

+
+
+

+ {t('spaceMemoryAiChat')} +

+

+ {counts['ai-chat']} +

+
+
{error ? (
@@ -210,22 +272,14 @@ export const SpaceMemorySection: FC = ({ ) : ( <> diff --git a/packages/epics/src/coherence/components/space-memory-timeline-item.tsx b/packages/epics/src/coherence/components/space-memory-timeline-item.tsx index 8df680d2bc..0b347c3e61 100644 --- a/packages/epics/src/coherence/components/space-memory-timeline-item.tsx +++ b/packages/epics/src/coherence/components/space-memory-timeline-item.tsx @@ -29,6 +29,17 @@ function looksLikePdf(name: string, url: string): boolean { return /\.pdf(\?|#|$)/i.test(name) || /\.pdf(\?|#|$)/i.test(url); } +export function humanizeAssetName(name: string): string { + const trimmed = name.trim(); + if (!trimmed) return name; + const sanitized = trimmed + .replace(/[_-]+/g, ' ') + .replace(/[^\p{L}\p{N}\s.()]/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); + return sanitized || name; +} + function PdfPreview({ src, fallbackLabel, @@ -38,7 +49,7 @@ function PdfPreview({ }) { const canvasRef = React.useRef(null); const [renderState, setRenderState] = React.useState< - 'loading' | 'ready' | 'error' + 'loading' | 'ready' | 'iframe' | 'error' >('loading'); React.useEffect(() => { @@ -85,7 +96,8 @@ function PdfPreview({ } } catch { if (!cancelled) { - setRenderState('error'); + // Fallback to browser PDF renderer if canvas pipeline fails. + setRenderState('iframe'); } } } @@ -97,12 +109,23 @@ function PdfPreview({ }; }, [src]); + if (renderState === 'iframe') { + return ( +