Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`.

## 0.45.1 (2026-08-29)

This patch restores complete Notion database discovery across Memory and the built-in Notion MCP.

### Highlights

- Find and ingest pages inside directly shared Notion databases even when Notion search omits them.

### Patch changes

- Discover pages inside directly shared Notion databases in Memory and let agents resolve the database through the Notion MCP even when Notion search omits its rows.

## 0.45.0 (2026-08-27)

This release adds secure hosted trial inference and self-run Brain model options, expands GLM 5.3 support, and improves reliability across Fast sessions, pull-request reviews, Memory, and chat.
Expand Down
15 changes: 12 additions & 3 deletions apps/api/src/handlers/mcp/notion/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ function registerSearchTool(
{
title: 'Search Notion',
description:
'Search pages and data sources explicitly shared with the deployment Notion integration.',
'Search pages and data sources explicitly shared with the deployment Notion integration. Pages that live inside databases are often missing from search results: to find them, locate the data source (object_type "data_source") and list its rows with notion-query-data-sources.',
inputSchema: {
query: z.string().optional(),
object_type: z.enum(['page', 'data_source']).optional(),
Expand Down Expand Up @@ -118,10 +118,12 @@ function registerFetchTool(
{
title: 'Fetch Notion Content',
description:
'Fetch a page, data source, or block explicitly shared with the deployment Notion integration. Pages include enhanced Markdown content; blocks include one page of child blocks.',
'Fetch a page, database, data source, or block explicitly shared with the deployment Notion integration. Pages include enhanced Markdown content; databases list their data sources (query rows with notion-query-data-sources); blocks include one page of child blocks.',
inputSchema: {
id: nonEmptyStringSchema,
object_type: z.enum(['page', 'data_source', 'block']).default('page'),
object_type: z
.enum(['page', 'database', 'data_source', 'block'])
.default('page'),
include_transcript: z.boolean().optional(),
...paginationSchema,
},
Expand All @@ -136,6 +138,13 @@ function registerFetchTool(
page_size,
}) => {
const encodedId = encodeURIComponent(id);
if (objectType === 'database') {
const database = await notionApiRequestJson<Record<string, unknown>>({
config,
path: `databases/${encodedId}`,
});
return toMcpToolResult({ database });
}
if (objectType === 'data_source') {
const dataSource = await notionApiRequestJson<Record<string, unknown>>({
config,
Expand Down
96 changes: 95 additions & 1 deletion apps/bullmq/src/scheduled-jobs/__tests__/brain-notion.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

84 changes: 76 additions & 8 deletions apps/bullmq/src/scheduled-jobs/brain-collectors/notion-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,10 @@ const NOTION_MAX_SEARCH_REQUESTS_PER_PASS = 10;
* directly-shared content is guaranteed; children reachable through a shared
* parent may never appear (see
* https://developers.notion.com/reference/search-optimizations-and-limitations).
* After each sweep+reconcile cycle the collector therefore walks the block
* tree and data sources of every inventoried page, discovering
* inheritance-shared pages the search index missed.
* After each sweep+reconcile cycle the collector therefore enumerates every
* data source the integration can see and walks the block tree of every
* inventoried page, discovering inheritance-shared pages — database rows
* above all — that the search index missed.
*/
const NOTION_MAX_TRAVERSAL_REQUESTS_PER_PASS = 24;
const NOTION_TRAVERSAL_SEED_BATCH = 25;
Expand Down Expand Up @@ -761,6 +762,23 @@ export function buildNotionSearchBody(
};
}

function buildNotionDataSourceSearchBody(
cursor: string | null,
): Record<string, unknown> {
return {
filter: { property: 'object', value: 'data_source' },
page_size: NOTION_SEARCH_PAGE_SIZE,
...(cursor ? { start_cursor: cursor } : {}),
};
}

function isNotionSearchDataSource(
value: unknown,
): value is { object: 'data_source'; id: string } {
const record = asObject(value);
return !!record && record.object === 'data_source' && !!asString(record.id);
}

async function fetchNotionPage(
config: McpConnectionNotionConfig,
page: NotionSearchPage,
Expand Down Expand Up @@ -803,6 +821,10 @@ type NotionTraverseState = {
afterItemId: string;
/** Discovered containers awaiting expansion, bounded. */
pending: NotionTraverseNode[];
/** Search cursor for the shared data-source enumeration, when paused. */
dataSourceCursor?: string;
/** True once every directly-shared data source is enqueued this cycle. */
dataSourcesDone?: boolean;
};

type NotionScanCursor = {
Expand Down Expand Up @@ -918,11 +940,13 @@ function isNotionBlock(value: unknown): value is NotionBlock {

/**
* Walk the inventory's block trees and data sources, emitting pages the
* search-based sweep never surfaced. The seed side iterates
* brain_collector_items by durable id cursor (no queue growth); only
* discovered containers carry over between passes, bounded by
* NOTION_TRAVERSAL_MAX_PENDING. A pass ends when its request budget or page
* limit is spent; completion flips the scan back to idle.
* search-based sweep never surfaced. Directly-shared data sources are
* enumerated first via search (their rows inherit access but are the classic
* search-index gap), then the seed side iterates brain_collector_items by
* durable id cursor (no queue growth); only discovered containers carry over
* between passes, bounded by NOTION_TRAVERSAL_MAX_PENDING. A pass ends when
* its request budget or page limit is spent; completion flips the scan back
* to idle.
*/
export async function collectNotionTraversal(input: {
config: McpConnectionNotionConfig;
Expand All @@ -936,6 +960,8 @@ export async function collectNotionTraversal(input: {
pending: [],
};
let afterItemId = state.afterItemId;
let dataSourceCursor = state.dataSourceCursor ?? null;
let dataSourcesDone = state.dataSourcesDone === true;
const pending: NotionTraverseNode[] = [...state.pending];
const pages: CollectorPage[] = [];
const itemUpdates: CollectorItemUpdate[] = [];
Expand Down Expand Up @@ -1083,6 +1109,46 @@ export async function collectNotionTraversal(input: {
const node = pending.shift();

if (!node) {
if (!dataSourcesDone) {
// Rows of a database shared directly with the integration inherit
// access but rarely reach the search index, and no inventoried page
// holds them as child_database blocks — search is the only way to
// find those data sources at all.
requests++;
let found: NotionSearchResponse;
try {
found = await notionCollectorRequest<NotionSearchResponse>({
config: input.config,
path: 'search',
method: 'POST',
body: buildNotionDataSourceSearchBody(dataSourceCursor),
});
} catch (error) {
if (
dataSourceCursor &&
error instanceof NotionApiError &&
error.status === 400
) {
// Notion pagination cursors expire; restart the enumeration.
dataSourceCursor = null;
continue;
}
throw error;
}
for (const raw of found.results ?? []) {
if (isNotionSearchDataSource(raw)) {
pushPending({ kind: 'data_source', id: raw.id });
}
}
const nextCursor =
found.has_more && asString(found.next_cursor)
? found.next_cursor!.trim()
: null;
dataSourceCursor = nextCursor;
dataSourcesDone = nextCursor === null;
continue;
}

const batch = await listBrainCollectorItemsAfter(
db,
NOTION_PAGES_COLLECTOR_ID,
Expand Down Expand Up @@ -1259,6 +1325,8 @@ export async function collectNotionTraversal(input: {
traverse: {
afterItemId,
pending: pending.slice(0, NOTION_TRAVERSAL_MAX_PENDING),
...(dataSourceCursor ? { dataSourceCursor } : {}),
...(dataSourcesDone ? { dataSourcesDone: true } : {}),
},
},
),
Expand Down
5 changes: 4 additions & 1 deletion apps/docs/integrations/notion.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ When Memory is enabled, Roomote also backfills the pages shared with this
integration and keeps their Markdown snapshots current. Notion pages are
stored under the `notion/` namespace. New and edited pages are picked up on
regular Memory collector ticks, and a daily full sweep discovers older pages
that were newly shared without being edited. The same sweep replaces pages
that were newly shared without being edited. Because Notion's search index
does not reliably surface pages that live inside databases, the sweep also
enumerates every shared data source and walks page trees to capture database
rows and other inheritance-shared pages. The same sweep replaces pages
that are no longer shared with unavailable tombstones, so their former
content is no longer retained in Memory search results.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "roomote",
"version": "0.45.0",
"version": "0.45.1",
"license": "FCL-1.0-ALv2",
"packageManager": "pnpm@10.29.3",
"engines": {
Expand Down
Loading