Commit b19f1d9e6 for llama.cpp
commit b19f1d9e63797cc5de2755f7f4504e7612712da4
Author: Xuan-Son Nguyen <son@huggingface.co>
Date: Thu Nov 12 09:44:35 2026 +0100
ui: add search_chats tool (#32051)
* ui: add search_chats tool
* add from_timestamp / to_timestamp
* ui: search_chats review fixes
- keep search_chats disabled on import, copy tool policy on fork
- stream messages without attachment payloads, one transaction for time ranges
- keep the output within budget, cut whole messages from the older end
- reject impossible date-times, quote titles and attachment names
- typed readOnly tables, move localIsoString to formatters as formatLocalIso
Assisted-by: Claude (Opus 5.5 Medium)
---------
Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com>
diff --git a/tools/ui/src/lib/constants/index.ts b/tools/ui/src/lib/constants/index.ts
index 9d909018c..59504c094 100644
--- a/tools/ui/src/lib/constants/index.ts
+++ b/tools/ui/src/lib/constants/index.ts
@@ -74,3 +74,4 @@ export * from './working-directory.constants';
export * from './read-media';
export * from './get-datetime';
export * from './browser-info';
+export * from './search-chats';
diff --git a/tools/ui/src/lib/constants/search-chats.ts b/tools/ui/src/lib/constants/search-chats.ts
new file mode 100644
index 000000000..6ba1160e6
--- /dev/null
+++ b/tools/ui/src/lib/constants/search-chats.ts
@@ -0,0 +1,127 @@
+import {
+ BuiltInTool,
+ JsonSchemaType,
+ SearchChatsMessageSelection,
+ SearchChatsOrder,
+ ToolCallType,
+ ToolSource
+} from '$lib/enums';
+import type { OpenAIToolDefinition } from '$lib/types';
+
+export const SEARCH_CHATS_TOOL_NAME = BuiltInTool.BROWSER_SEARCH_CHATS;
+
+/** Key of the tool in the disabled tools lists, same format as toolsStore.toolKey */
+export const SEARCH_CHATS_TOOL_KEY = `${ToolSource.BROWSER}:${SEARCH_CHATS_TOOL_NAME}`;
+
+export const SEARCH_CHATS_OUTPUT_MAX_CHARS = 8192;
+
+export const SEARCH_CHATS_LIMIT_CHATS_DEFAULT = 10;
+export const SEARCH_CHATS_LIMIT_CHATS_MAX = 1000;
+
+export const SEARCH_CHATS_LIMIT_MESSAGES_DEFAULT = 5;
+export const SEARCH_CHATS_LIMIT_MESSAGES_MAX = 100;
+
+export const SEARCH_CHATS_MESSAGE_CHARS_DEFAULT = 600;
+export const SEARCH_CHATS_MESSAGE_CHARS_MIN = 50;
+export const SEARCH_CHATS_MESSAGE_CHARS_MAX = 4000;
+
+export const SEARCH_CHATS_MAX_PATTERNS = 10;
+export const SEARCH_CHATS_MAX_IDS = 100;
+
+/** Matching cost grows with pattern length times text length, and runs on the main thread */
+export const SEARCH_CHATS_PATTERN_MAX_CHARS = 200;
+export const SEARCH_CHATS_ID_MAX_CHARS = 200;
+
+/** A match past this share of the excerpt length moves the excerpt window to the match */
+export const SEARCH_CHATS_EXCERPT_RECENTER_RATIO = 0.6;
+
+/** Share of the excerpt shown before the match when the window is moved */
+export const SEARCH_CHATS_EXCERPT_LEAD_RATIO = 1 / 3;
+
+export const SEARCH_CHATS_ELLIPSIS = '...';
+
+export const SEARCH_CHATS_FIELD_SEPARATOR = ' | ';
+
+export const SEARCH_CHATS_CUT_NOTICE =
+ '[messages cut to fit: lower limit_messages or max_message_chars]';
+
+/** `2026-11-10`, read as local midnight */
+export const SEARCH_CHATS_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
+
+/** `2026-11-10T14:30`, optional seconds, fraction and offset; local time without an offset */
+export const SEARCH_CHATS_DATE_TIME_REGEX =
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})?$/;
+
+const DESCRIPTION = `Find and read the user's other saved chats (read-only). The current chat is hidden, you already see it.
+Returns chats, each with messages from what the user saw: user and assistant text only (tool output is skipped), numbered #1..#N in chat order. Dates are in the user's local time.
+- List chats: limit_messages=0 gives one line per chat (title, conv_id, date); raise limit_chats and page with offset_chats.
+- Find chats: by_matches=["%paris%", "%eiffel%"] matches the title or any message (case-insensitive, % = any text, _ = one char, no % means "contains"). A chat matches if any pattern does. For AND within a message use "%a%b%" (order matters, add "%b%a%" for either order).
+- Read chats: by_conv_ids=["<conv_id>"] with message_selection "latest" (default) or "earliest".
+- Time range: from_timestamp (inclusive) and to_timestamp (exclusive), ISO 8601 like 2026-11-10 or 2026-11-10T14:30, local time. Only messages in the range are searched and shown, and only chats with messages in it are returned. With from_timestamp, message_selection defaults to "earliest".
+Example, "what was my last question in the chat about paris?": by_matches=["%paris%"], message_selection="latest", limit_messages=4, then answer from the last user message.
+Example, "what did we talk about yesterday?": from_timestamp=<yesterday's date>, to_timestamp=<today's date>, limit_messages=2.`;
+
+export function buildSearchChatsToolDefinition(): OpenAIToolDefinition {
+ return {
+ function: {
+ description: DESCRIPTION,
+ name: SEARCH_CHATS_TOOL_NAME,
+ parameters: {
+ properties: {
+ by_conv_ids: {
+ description: 'Only these chats, by conv_id',
+ items: { type: JsonSchemaType.STRING },
+ type: JsonSchemaType.ARRAY
+ },
+ by_matches: {
+ description: `LIKE patterns matched against titles and message text, any of them, max ${SEARCH_CHATS_PATTERN_MAX_CHARS} chars each`,
+ items: { type: JsonSchemaType.STRING },
+ type: JsonSchemaType.ARRAY
+ },
+ from_timestamp: {
+ description:
+ 'Only messages at or after this time, ISO 8601, local time unless an offset is given',
+ type: JsonSchemaType.STRING
+ },
+ include_current: {
+ description: 'Also include the current chat, default false',
+ type: JsonSchemaType.BOOLEAN
+ },
+ limit_chats: {
+ description: `Max chats, default ${SEARCH_CHATS_LIMIT_CHATS_DEFAULT}, max ${SEARCH_CHATS_LIMIT_CHATS_MAX}`,
+ type: JsonSchemaType.NUMBER
+ },
+ limit_messages: {
+ description: `Messages per chat, default ${SEARCH_CHATS_LIMIT_MESSAGES_DEFAULT}, max ${SEARCH_CHATS_LIMIT_MESSAGES_MAX}; 0 lists chats only`,
+ type: JsonSchemaType.NUMBER
+ },
+ max_message_chars: {
+ description: `Characters shown per message, default ${SEARCH_CHATS_MESSAGE_CHARS_DEFAULT}, max ${SEARCH_CHATS_MESSAGE_CHARS_MAX}`,
+ type: JsonSchemaType.NUMBER
+ },
+ message_selection: {
+ description: `Which messages per chat; default "${SearchChatsMessageSelection.MATCHING}" with by_matches, else "${SearchChatsMessageSelection.EARLIEST}" with from_timestamp, else "${SearchChatsMessageSelection.LATEST}"`,
+ enum: Object.values(SearchChatsMessageSelection),
+ type: JsonSchemaType.STRING
+ },
+ offset_chats: {
+ description: 'Skip this many chats, for paging',
+ type: JsonSchemaType.NUMBER
+ },
+ order_timestamp: {
+ description: `Chat order by last activity, default "${SearchChatsOrder.DESC}" (newest first)`,
+ enum: Object.values(SearchChatsOrder),
+ type: JsonSchemaType.STRING
+ },
+ to_timestamp: {
+ description:
+ 'Only messages before this time (exclusive), same format as from_timestamp',
+ type: JsonSchemaType.STRING
+ }
+ },
+ type: JsonSchemaType.OBJECT
+ }
+ },
+ type: ToolCallType.FUNCTION
+ };
+}
diff --git a/tools/ui/src/lib/constants/tool-ui.constants.ts b/tools/ui/src/lib/constants/tool-ui.constants.ts
index b5c09a653..1078ebfbe 100644
--- a/tools/ui/src/lib/constants/tool-ui.constants.ts
+++ b/tools/ui/src/lib/constants/tool-ui.constants.ts
@@ -16,6 +16,7 @@ import {
FileSearch,
FileText,
Info,
+ MessagesSquare,
SearchCode,
Terminal
} from '@lucide/svelte';
@@ -34,6 +35,11 @@ export const TOOL_UI: Readonly<Record<BuiltInTool, ToolUiEntry>> = {
label: 'Run JavaScript',
source: ToolSource.BROWSER
},
+ [BuiltInTool.BROWSER_SEARCH_CHATS]: {
+ icon: MessagesSquare,
+ label: 'Search chats',
+ source: ToolSource.BROWSER
+ },
[BuiltInTool.SERVER_EDIT_FILE]: { icon: FilePen, label: 'Edit file', source: ToolSource.SERVER },
[BuiltInTool.SERVER_EXEC_SHELL_COMMAND]: {
icon: Terminal,
diff --git a/tools/ui/src/lib/enums/index.ts b/tools/ui/src/lib/enums/index.ts
index 3527100b4..134666eb5 100644
--- a/tools/ui/src/lib/enums/index.ts
+++ b/tools/ui/src/lib/enums/index.ts
@@ -112,6 +112,8 @@ export { KeyboardKey } from './keyboard.enums';
export {
BuiltInTool,
GlobSearchType,
+ SearchChatsMessageSelection,
+ SearchChatsOrder,
ToolSource,
ToolPermissionDecision,
ToolResponseField
diff --git a/tools/ui/src/lib/enums/mcp.enums.ts b/tools/ui/src/lib/enums/mcp.enums.ts
index fc358202b..6be96d8a2 100644
--- a/tools/ui/src/lib/enums/mcp.enums.ts
+++ b/tools/ui/src/lib/enums/mcp.enums.ts
@@ -54,6 +54,8 @@ export enum MCPContentType {
* JSON Schema types used in MCP tool definitions
*/
export enum JsonSchemaType {
+ ARRAY = 'array',
+ BOOLEAN = 'boolean',
NUMBER = 'number',
OBJECT = 'object',
STRING = 'string'
diff --git a/tools/ui/src/lib/enums/tools.enums.ts b/tools/ui/src/lib/enums/tools.enums.ts
index db55837a8..2bda68722 100644
--- a/tools/ui/src/lib/enums/tools.enums.ts
+++ b/tools/ui/src/lib/enums/tools.enums.ts
@@ -45,6 +45,7 @@ export enum BuiltInTool {
BROWSER_GET_DATETIME = 'get_datetime',
BROWSER_READ_MEDIA = 'read_media',
BROWSER_RUN_JAVASCRIPT = 'run_javascript',
+ BROWSER_SEARCH_CHATS = 'search_chats',
SERVER_EDIT_FILE = 'edit_file',
SERVER_EXEC_SHELL_COMMAND = 'exec_shell_command',
SERVER_FILE_GLOB_SEARCH = 'file_glob_search',
@@ -53,3 +54,16 @@ export enum BuiltInTool {
SERVER_READ_FILE = 'read_file',
SERVER_WRITE_FILE = 'write_file'
}
+
+/** Wire values of the `search_chats` `message_selection` parameter */
+export enum SearchChatsMessageSelection {
+ EARLIEST = 'earliest',
+ LATEST = 'latest',
+ MATCHING = 'matching'
+}
+
+/** Wire values of the `search_chats` `order_timestamp` parameter */
+export enum SearchChatsOrder {
+ ASC = 'asc',
+ DESC = 'desc'
+}
diff --git a/tools/ui/src/lib/services/database.service.ts b/tools/ui/src/lib/services/database.service.ts
index af9fad9a2..f11e98606 100644
--- a/tools/ui/src/lib/services/database.service.ts
+++ b/tools/ui/src/lib/services/database.service.ts
@@ -463,6 +463,10 @@ export class DatabaseService {
const newConv: DatabaseConversation = {
currNode: lastClonedMessage.id,
cwd: sourceConv.cwd,
+ disabledToolCategories: sourceConv.disabledToolCategories
+ ? [...sourceConv.disabledToolCategories]
+ : undefined,
+ disabledTools: sourceConv.disabledTools ? [...sourceConv.disabledTools] : undefined,
forkedFromConversationId: sourceConvId,
groupId: sourceConv.groupId,
id: newConvId,
@@ -614,6 +618,15 @@ export class DatabaseService {
);
}
+ /** Run `fn` in a readonly transaction over all tables. `fn` must await only Dexie calls, else the transaction commits early */
+ static async readOnly<T>(
+ fn: (tables: Pick<LlamaUiDatabase, keyof typeof IDXDB_TABLES>) => Promise<T>
+ ): Promise<T> {
+ return db.transaction('r', [db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]], () =>
+ fn(db)
+ );
+ }
+
/**
* Puts conversations into a group, or out of any group when `groupId` is null.
* A conversation that joins a group is unpinned.
diff --git a/tools/ui/src/lib/services/index.ts b/tools/ui/src/lib/services/index.ts
index a8072e55b..0e584520d 100644
--- a/tools/ui/src/lib/services/index.ts
+++ b/tools/ui/src/lib/services/index.ts
@@ -300,6 +300,14 @@ export { MCPService } from './mcp.service';
*/
export { SandboxService } from './sandbox.service';
+/**
+ * **SearchChatsService** - Read-only search over saved chats, executor of the search_chats browser tool
+ *
+ * @see buildSearchChatsToolDefinition in constants/search-chats - tool schema sent to the LLM
+ * @see utils/search-chats - parameter parsing, matching and formatting
+ */
+export { SearchChatsService } from './search-chats.service';
+
/**
* **RouterService** — Dynamic route URL construction utility
*
diff --git a/tools/ui/src/lib/services/migration.service.ts b/tools/ui/src/lib/services/migration.service.ts
index 6df50f45b..21e65b2f7 100644
--- a/tools/ui/src/lib/services/migration.service.ts
+++ b/tools/ui/src/lib/services/migration.service.ts
@@ -19,12 +19,14 @@ import {
LEGACY_AGENTIC_REGEX,
LEGACY_REASONING_TAGS,
NEW_TO_DEPRECATED_MAP,
+ SEARCH_CHATS_TOOL_KEY,
SETTINGS_KEYS,
STORAGE_APP_NAME,
STORAGE_APP_NAME_DEPRECATED
} from '$lib/constants';
import { BooleanString, MessageRole } from '$lib/enums';
import type { McpServerOverride } from '$lib/types/database';
+import { withSearchChatsDisabled } from '$lib/utils/search-chats';
import Dexie from 'dexie';
// Types
@@ -829,6 +831,42 @@ const mcpServerOverridesToToolPolicyMigration: Migration = {
);
}
};
+const SEARCH_CHATS_DEFAULT_DISABLED_MIGRATION_ID = 'search-chats-default-disabled-v1';
+const searchChatsDefaultDisabledMigration: Migration = {
+ description: 'Disable the search_chats tool by default, globally and in existing conversations',
+ id: SEARCH_CHATS_DEFAULT_DISABLED_MIGRATION_ID,
+
+ async run(): Promise<void> {
+ // tool policy is a deny-list, so a new tool is enabled everywhere unless its key is added to the defaults and to each conversation
+ let defaults: string[] = [];
+
+ try {
+ const raw = localStorage.getItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
+ const parsed: unknown = raw ? JSON.parse(raw) : [];
+
+ if (Array.isArray(parsed))
+ defaults = parsed.filter((k): k is string => typeof k === 'string');
+ } catch {
+ // unreadable defaults are replaced below
+ }
+
+ localStorage.setItem(
+ DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
+ JSON.stringify(withSearchChatsDisabled(defaults))
+ );
+
+ const db = await getDatabaseService();
+ const conversations = await db.getAllConversations();
+
+ for (const conv of conversations) {
+ if (conv.disabledTools?.includes(SEARCH_CHATS_TOOL_KEY)) continue;
+
+ await db.updateConversation(conv.id, {
+ disabledTools: withSearchChatsDisabled(conv.disabledTools)
+ });
+ }
+ }
+};
const migrations: Migration[] = [
localStorageMigration,
idxdbMigration,
@@ -840,7 +878,8 @@ const migrations: Migration[] = [
configTypesMigration,
renderKeysMigration,
modelOrgNameKeyMigration,
- mcpServerOverridesToToolPolicyMigration
+ mcpServerOverridesToToolPolicyMigration,
+ searchChatsDefaultDisabledMigration
];
export const MigrationService = {
diff --git a/tools/ui/src/lib/services/search-chats.service.ts b/tools/ui/src/lib/services/search-chats.service.ts
new file mode 100644
index 000000000..fe2fad1a0
--- /dev/null
+++ b/tools/ui/src/lib/services/search-chats.service.ts
@@ -0,0 +1,89 @@
+/**
+ * SearchChatsService - executor for the search_chats browser tool
+ *
+ * Reads conversations and messages in readonly transactions; matching and formatting live in utils/search-chats.
+ */
+
+import { DatabaseService } from './database.service';
+import type { ToolExecutionResult } from '$lib/types';
+import type { DatabaseConversation, DatabaseMessage } from '$lib/types/database';
+import {
+ parseSearchChatsParams,
+ searchChats,
+ SearchChatsError,
+ type SearchChatsTimeRange
+} from '$lib/utils/search-chats';
+
+function loadConversations(ids?: string[]): Promise<DatabaseConversation[]> {
+ return DatabaseService.readOnly(async ({ conversations }) => {
+ if (!ids) return conversations.toArray();
+
+ const found = await conversations.bulkGet(ids);
+
+ return found.filter((c): c is DatabaseConversation => !!c);
+ });
+}
+
+/** Attachment payloads (base64 media, file text) are dropped as rows stream in, only their names are kept */
+function withoutPayloads({ extra, ...message }: DatabaseMessage): DatabaseMessage {
+ return {
+ ...message,
+ extra: extra?.map(({ name, type }) => ({ name, type })) as DatabaseMessage['extra']
+ };
+}
+
+/** One transaction, so the range lookup and the read see the same data */
+function loadMessages(
+ convIds: string[] | null,
+ range?: SearchChatsTimeRange
+): Promise<DatabaseMessage[]> {
+ return DatabaseService.readOnly(async ({ messages }) => {
+ let ids = convIds;
+
+ if (range) {
+ const { from, to } = range;
+ const index = messages.where('timestamp');
+ const inRange =
+ from !== undefined && to !== undefined
+ ? index.between(from, to, true, false)
+ : from !== undefined
+ ? index.aboveOrEqual(from)
+ : index.below(to);
+ const active = new Set<string>();
+
+ await inRange.each((m) => active.add(m.convId));
+ ids = ids ? ids.filter((id) => active.has(id)) : [...active];
+ }
+
+ const rows: DatabaseMessage[] = [];
+
+ await (ids ? messages.where('convId').anyOf(ids) : messages.toCollection()).each((m) =>
+ rows.push(withoutPayloads(m))
+ );
+
+ return rows;
+ });
+}
+
+const loader = { messages: loadMessages };
+
+export class SearchChatsService {
+ /** `currentConversationId` is hidden from results unless the model sets include_current */
+ static async executeTool(
+ args: Record<string, unknown>,
+ currentConversationId?: string
+ ): Promise<ToolExecutionResult> {
+ try {
+ const params = parseSearchChatsParams(args);
+ const conversations = await loadConversations(params.byConvIds);
+ const content = await searchChats(conversations, params, loader, currentConversationId);
+
+ return { content, isError: false };
+ } catch (err) {
+ if (err instanceof SearchChatsError)
+ return { content: `Error: ${err.message}`, isError: true };
+
+ throw err;
+ }
+ }
+}
diff --git a/tools/ui/src/lib/stores/agentic/index.svelte.ts b/tools/ui/src/lib/stores/agentic/index.svelte.ts
index f22b6b954..bc5d38903 100644
--- a/tools/ui/src/lib/stores/agentic/index.svelte.ts
+++ b/tools/ui/src/lib/stores/agentic/index.svelte.ts
@@ -31,6 +31,7 @@ import {
import { ChatService } from '$lib/services';
import { ReadMediaService } from '$lib/services/read-media.service';
import { SandboxService } from '$lib/services/sandbox.service';
+import { SearchChatsService } from '$lib/services/search-chats.service';
import { ToolsService } from '$lib/services/tools.service';
// direct imports between stores, not via the barrel, to avoid circular deps
import { AgenticGates } from '$lib/stores/agentic/gates.svelte';
@@ -825,6 +826,8 @@ class AgenticStore {
executionResult = executeGetDatetimeTool();
} else if (toolName === BuiltInTool.SERVER_GET_INFO) {
executionResult = executeBrowserInfoTool();
+ } else if (toolName === BuiltInTool.BROWSER_SEARCH_CHATS) {
+ executionResult = await SearchChatsService.executeTool(args, conversationId);
} else if (toolName === BuiltInTool.BROWSER_READ_MEDIA) {
executionResult = await ReadMediaService.executeTool(
args,
diff --git a/tools/ui/src/lib/stores/conversations/index.svelte.ts b/tools/ui/src/lib/stores/conversations/index.svelte.ts
index e338ffcc3..f55f74c12 100644
--- a/tools/ui/src/lib/stores/conversations/index.svelte.ts
+++ b/tools/ui/src/lib/stores/conversations/index.svelte.ts
@@ -29,6 +29,7 @@ import {
findLeafNode,
generateConversationTitle
} from '$lib/utils';
+import { withSearchChatsDisabled } from '$lib/utils/search-chats';
import { SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
@@ -529,7 +530,13 @@ class ConversationsStore implements ConversationsPreferencesHost {
async importConversationsData(
data: ExportedConversations
): Promise<{ imported: DatabaseConversation[]; skipped: DatabaseConversation[] }> {
- const result = await DatabaseService.importConversations(data);
+ // exports can predate search_chats or come from another device, keep it off like for existing chats
+ const result = await DatabaseService.importConversations(
+ (Array.isArray(data) ? data : [data]).map(({ conv, messages }) => ({
+ conv: { ...conv, disabledTools: withSearchChatsDisabled(conv.disabledTools) },
+ messages
+ }))
+ );
await this.loadConversations();
diff --git a/tools/ui/src/lib/stores/tools.svelte.ts b/tools/ui/src/lib/stores/tools.svelte.ts
index 590955970..a97dae58a 100644
--- a/tools/ui/src/lib/stores/tools.svelte.ts
+++ b/tools/ui/src/lib/stores/tools.svelte.ts
@@ -12,6 +12,7 @@ import {
buildBrowserInfoToolDefinition,
buildGetDatetimeToolDefinition,
buildReadMediaToolDefinition,
+ buildSearchChatsToolDefinition,
DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY,
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
HOME_TILDE,
@@ -114,7 +115,9 @@ class ToolsStore {
get browserTools(): OpenAIToolDefinition[] {
const tools: OpenAIToolDefinition[] = [
buildGetDatetimeToolDefinition(),
- buildSandboxToolDefinition(!!settingsStore.config.symbolicMathEnabled)
+ buildSandboxToolDefinition(!!settingsStore.config.symbolicMathEnabled),
+ // disabled by default, see searchChatsDefaultDisabledMigration
+ buildSearchChatsToolDefinition()
];
const readMedia = this.readMediaTool();
diff --git a/tools/ui/src/lib/utils/formatters.ts b/tools/ui/src/lib/utils/formatters.ts
index 6a174cd48..ab0952277 100644
--- a/tools/ui/src/lib/utils/formatters.ts
+++ b/tools/ui/src/lib/utils/formatters.ts
@@ -132,6 +132,22 @@ export function formatRelativeTime(timestamp: number): string {
return new Date(timestamp).toLocaleDateString();
}
+function pad2(value: number): string {
+ return String(value).padStart(2, '0');
+}
+
+/** ISO 8601 in local time, e.g. `2026-08-17T14:05:09+02:00` */
+export function formatLocalIso(date: Date): string {
+ // getTimezoneOffset() counts minutes behind UTC, ISO 8601 counts them ahead
+ const offset = -date.getTimezoneOffset();
+ const sign = offset < 0 ? '-' : '+';
+ const absOffset = Math.abs(offset);
+ const day = `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
+ const time = `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
+
+ return `${day}T${time}${sign}${pad2(Math.floor(absOffset / 60))}:${pad2(absOffset % 60)}`;
+}
+
/**
* Format time as HH:MM:SS in 24-hour format
*
diff --git a/tools/ui/src/lib/utils/get-datetime.ts b/tools/ui/src/lib/utils/get-datetime.ts
index d971d728e..69e41ce0f 100644
--- a/tools/ui/src/lib/utils/get-datetime.ts
+++ b/tools/ui/src/lib/utils/get-datetime.ts
@@ -6,31 +6,16 @@
* @see buildGetDatetimeToolDefinition in constants/get-datetime.ts - tool schema sent to the LLM
*/
+import { formatLocalIso } from './formatters';
import type { ToolExecutionResult } from '$lib/types';
-function pad(value: number): string {
- return String(value).padStart(2, '0');
-}
-
-/** ISO 8601 in local time, e.g. `2026-08-17T14:05:09+02:00` */
-function localIsoString(date: Date): string {
- // getTimezoneOffset() counts minutes behind UTC, ISO 8601 counts them ahead
- const offset = -date.getTimezoneOffset();
- const sign = offset < 0 ? '-' : '+';
- const absOffset = Math.abs(offset);
- const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
- const time = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
-
- return `${day}T${time}${sign}${pad(Math.floor(absOffset / 60))}:${pad(absOffset % 60)}`;
-}
-
/** The `result` field keeps the shape the `get_datetime` renderer already reads. */
export function executeGetDatetimeTool(): ToolExecutionResult {
const now = new Date();
return {
content: JSON.stringify({
- result: localIsoString(now),
+ result: formatLocalIso(now),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
}),
isError: false
diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts
index f8de87faf..a7ed91bd8 100644
--- a/tools/ui/src/lib/utils/index.ts
+++ b/tools/ui/src/lib/utils/index.ts
@@ -99,6 +99,7 @@ export {
formatNumber,
formatJsonPretty,
formatTime,
+ formatLocalIso,
formatPerformanceTime,
formatRelativeTime,
formatAttachmentText
diff --git a/tools/ui/src/lib/utils/search-chats.ts b/tools/ui/src/lib/utils/search-chats.ts
new file mode 100644
index 000000000..b9811bb41
--- /dev/null
+++ b/tools/ui/src/lib/utils/search-chats.ts
@@ -0,0 +1,665 @@
+/**
+ * search_chats core: parameter parsing, matching and formatting. Data comes through a loader, so this has no IndexedDB access.
+ * Only user and assistant text on the visible branch is searched and shown, so tool output (e.g. older search results) cannot match.
+ */
+
+import { filterByLeafNodeId } from './branching';
+import { formatLocalIso } from './formatters';
+import {
+ SEARCH_CHATS_CUT_NOTICE,
+ SEARCH_CHATS_DATE_REGEX,
+ SEARCH_CHATS_DATE_TIME_REGEX,
+ SEARCH_CHATS_ELLIPSIS,
+ SEARCH_CHATS_EXCERPT_LEAD_RATIO,
+ SEARCH_CHATS_EXCERPT_RECENTER_RATIO,
+ SEARCH_CHATS_FIELD_SEPARATOR,
+ SEARCH_CHATS_ID_MAX_CHARS,
+ SEARCH_CHATS_LIMIT_CHATS_DEFAULT,
+ SEARCH_CHATS_LIMIT_CHATS_MAX,
+ SEARCH_CHATS_LIMIT_MESSAGES_DEFAULT,
+ SEARCH_CHATS_LIMIT_MESSAGES_MAX,
+ SEARCH_CHATS_MAX_IDS,
+ SEARCH_CHATS_MAX_PATTERNS,
+ SEARCH_CHATS_MESSAGE_CHARS_DEFAULT,
+ SEARCH_CHATS_MESSAGE_CHARS_MAX,
+ SEARCH_CHATS_MESSAGE_CHARS_MIN,
+ SEARCH_CHATS_OUTPUT_MAX_CHARS,
+ SEARCH_CHATS_PATTERN_MAX_CHARS,
+ SEARCH_CHATS_TOOL_KEY
+} from '$lib/constants';
+import { MessageRole, SearchChatsMessageSelection, SearchChatsOrder } from '$lib/enums';
+import type { DatabaseConversation, DatabaseMessage } from '$lib/types/database';
+
+export class SearchChatsError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'SearchChatsError';
+ }
+}
+
+export interface SearchChatsParams {
+ byConvIds?: string[];
+ byMatches?: string[];
+ /** Inclusive, ms since epoch */
+ fromTimestamp?: number;
+ /** Exclusive, ms since epoch */
+ toTimestamp?: number;
+ order: SearchChatsOrder;
+ limitChats: number;
+ offsetChats: number;
+ limitMessages: number;
+ messageSelection: SearchChatsMessageSelection;
+ maxMessageChars: number;
+ includeCurrent: boolean;
+}
+
+export interface SearchChatsTimeRange {
+ from?: number;
+ to?: number;
+}
+
+export interface SearchChatsLoader {
+ /** Messages of the given conversations (all for `null`); with `range`, only of conversations with a message in [from, to) */
+ messages(convIds: string[] | null, range?: SearchChatsTimeRange): Promise<DatabaseMessage[]>;
+}
+
+/** Tool policy with search_chats disabled, for rows that predate the tool */
+export function withSearchChatsDisabled(disabledTools: string[] = []): string[] {
+ return disabledTools.includes(SEARCH_CHATS_TOOL_KEY)
+ ? disabledTools
+ : [...disabledTools, SEARCH_CHATS_TOOL_KEY];
+}
+
+/** `text.slice(start, end)` without cutting a surrogate pair in half */
+function sliceText(text: string, start: number, end: number = text.length): string {
+ const isHigh = (i: number) => {
+ const code = text.charCodeAt(i);
+
+ return code >= 0xd800 && code <= 0xdbff;
+ };
+
+ if (start > 0 && isHigh(start - 1)) start++;
+
+ if (end > start && end < text.length && isHigh(end - 1)) end--;
+
+ return text.slice(start, Math.max(start, end));
+}
+
+function isCalendarDate(year: number, month: number, day: number): boolean {
+ const date = new Date(Date.UTC(year, month - 1, day));
+
+ return date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
+}
+
+function stringList(
+ raw: unknown,
+ name: string,
+ maxEntries: number,
+ maxChars: number
+): string[] | undefined {
+ if (raw === undefined || raw === null) return undefined;
+
+ const list = typeof raw === 'string' ? [raw] : raw;
+
+ if (!Array.isArray(list) || !list.every((v) => typeof v === 'string' && v.trim() !== '')) {
+ throw new SearchChatsError(`${name} must be an array of non-empty strings`);
+ }
+
+ if (list.length === 0) return undefined;
+
+ if (list.length > maxEntries) {
+ throw new SearchChatsError(`${name} takes at most ${maxEntries} entries`);
+ }
+
+ if (list.some((v) => v.length > maxChars)) {
+ throw new SearchChatsError(`${name} entries must be at most ${maxChars} characters`);
+ }
+
+ return list.map((v) => v.trim());
+}
+
+function integer(raw: unknown, name: string, min: number, max: number, fallback: number): number {
+ if (raw === undefined || raw === null || raw === '') return fallback;
+
+ const n = Number(raw);
+
+ if (!Number.isInteger(n) || n < min || n > max) {
+ throw new SearchChatsError(`${name} must be an integer from ${min} to ${max}`);
+ }
+
+ return n;
+}
+
+function oneOf<T extends string>(raw: unknown, name: string, options: T[], fallback: T): T {
+ if (raw === undefined || raw === null || raw === '') return fallback;
+
+ const value = String(raw).toLowerCase() as T;
+
+ if (!options.includes(value)) {
+ throw new SearchChatsError(`${name} must be one of: ${options.join(', ')}`);
+ }
+
+ return value;
+}
+
+/** ISO 8601 date or date-time; without an offset it is the user's local time */
+function timestamp(raw: unknown, name: string): number | undefined {
+ if (raw === undefined || raw === null || raw === '') return undefined;
+
+ const error = new SearchChatsError(
+ `${name} must be an ISO 8601 date or date-time, e.g. 2026-11-10 or 2026-11-10T14:30 (local time unless an offset is given)`
+ );
+
+ if (typeof raw !== 'string') throw error;
+
+ const value = raw.trim().replace(' ', 'T');
+ const isDate = SEARCH_CHATS_DATE_REGEX.test(value);
+
+ if (!isDate && !SEARCH_CHATS_DATE_TIME_REGEX.test(value)) throw error;
+
+ const [year, month, day] = value.slice(0, 10).split('-').map(Number);
+
+ // Date.parse rolls 2026-02-30T10:00 over to March, it does not reject it
+ if (!isCalendarDate(year, month, day)) throw error;
+
+ let ms: number;
+
+ if (isDate) {
+ // Date.parse reads a date-only string as UTC midnight, the user means local midnight
+ ms = new Date(year, month - 1, day).getTime();
+ } else {
+ const [hours, minutes, seconds = 0] = value
+ .slice(11, 19)
+ .split(/[:.Z+-]/)
+ .map(Number);
+
+ if (hours > 23 || minutes > 59 || seconds > 59) throw error;
+
+ ms = Date.parse(value);
+ }
+
+ if (Number.isNaN(ms)) throw error;
+
+ return ms;
+}
+
+/** Throws SearchChatsError with a message for the model */
+export function parseSearchChatsParams(raw: Record<string, unknown>): SearchChatsParams {
+ const byConvIds = stringList(
+ raw.by_conv_ids,
+ 'by_conv_ids',
+ SEARCH_CHATS_MAX_IDS,
+ SEARCH_CHATS_ID_MAX_CHARS
+ );
+ const byMatches = stringList(
+ raw.by_matches,
+ 'by_matches',
+ SEARCH_CHATS_MAX_PATTERNS,
+ SEARCH_CHATS_PATTERN_MAX_CHARS
+ );
+ const fromTimestamp = timestamp(raw.from_timestamp, 'from_timestamp');
+ const toTimestamp = timestamp(raw.to_timestamp, 'to_timestamp');
+
+ if (fromTimestamp !== undefined && toTimestamp !== undefined && toTimestamp <= fromTimestamp) {
+ throw new SearchChatsError('to_timestamp must be after from_timestamp');
+ }
+
+ const defaultSelection = byMatches
+ ? SearchChatsMessageSelection.MATCHING
+ : fromTimestamp !== undefined
+ ? SearchChatsMessageSelection.EARLIEST
+ : SearchChatsMessageSelection.LATEST;
+ const messageSelection = oneOf(
+ raw.message_selection,
+ 'message_selection',
+ Object.values(SearchChatsMessageSelection),
+ defaultSelection
+ );
+
+ if (messageSelection === SearchChatsMessageSelection.MATCHING && !byMatches) {
+ throw new SearchChatsError(
+ `message_selection "${SearchChatsMessageSelection.MATCHING}" needs by_matches; use "${SearchChatsMessageSelection.LATEST}" or "${SearchChatsMessageSelection.EARLIEST}"`
+ );
+ }
+
+ return {
+ byConvIds,
+ byMatches,
+ fromTimestamp,
+ includeCurrent: raw.include_current === true || raw.include_current === 'true',
+ limitChats: integer(
+ raw.limit_chats,
+ 'limit_chats',
+ 1,
+ SEARCH_CHATS_LIMIT_CHATS_MAX,
+ SEARCH_CHATS_LIMIT_CHATS_DEFAULT
+ ),
+ limitMessages: integer(
+ raw.limit_messages,
+ 'limit_messages',
+ 0,
+ SEARCH_CHATS_LIMIT_MESSAGES_MAX,
+ SEARCH_CHATS_LIMIT_MESSAGES_DEFAULT
+ ),
+ maxMessageChars: integer(
+ raw.max_message_chars,
+ 'max_message_chars',
+ SEARCH_CHATS_MESSAGE_CHARS_MIN,
+ SEARCH_CHATS_MESSAGE_CHARS_MAX,
+ SEARCH_CHATS_MESSAGE_CHARS_DEFAULT
+ ),
+ messageSelection,
+ offsetChats: integer(raw.offset_chats, 'offset_chats', 0, Number.MAX_SAFE_INTEGER, 0),
+ order: oneOf(
+ raw.order_timestamp,
+ 'order_timestamp',
+ Object.values(SearchChatsOrder),
+ SearchChatsOrder.DESC
+ ),
+ toTimestamp
+ };
+}
+
+/** SQL LIKE (`%` any run, `_` one char) without regex, so a pattern cannot backtrack exponentially */
+function like(text: string, pattern: string): boolean {
+ let si = 0;
+ let pi = 0;
+ let star = -1;
+ let mark = 0;
+
+ while (si < text.length) {
+ if (
+ pi < pattern.length &&
+ (pattern[pi] === '_' || (pattern[pi] !== '%' && pattern[pi] === text[si]))
+ ) {
+ si++;
+ pi++;
+ } else if (pi < pattern.length && pattern[pi] === '%') {
+ star = pi++;
+ mark = si;
+ } else if (star !== -1) {
+ pi = star + 1;
+ si = ++mark;
+ } else {
+ return false;
+ }
+ }
+
+ while (pi < pattern.length && pattern[pi] === '%') pi++;
+
+ return pi === pattern.length;
+}
+
+interface Matcher {
+ test(text: string): boolean;
+ /** Offset in `text` of the longest literal part of the first matching pattern, -1 if none */
+ locate(text: string): number;
+}
+
+function buildMatcher(patterns: string[]): Matcher {
+ // a pattern without % means "contains"
+ const compiled = patterns.map((p) => {
+ const lower = p.toLowerCase();
+ const pattern = lower.includes('%') ? lower : `%${lower}%`;
+ const needle = pattern
+ .split(/[%_]+/)
+ .reduce((best, part) => (part.length > best.length ? part : best), '');
+
+ return { needle, pattern };
+ });
+
+ return {
+ locate: (text) => {
+ const lower = text.toLowerCase();
+
+ // lowercasing can change the length (e.g. U+0130), then offsets do not map back
+ if (lower.length !== text.length) return -1;
+
+ const hit = compiled.find(({ pattern }) => like(lower, pattern));
+
+ if (!hit) return -1;
+
+ return hit.needle ? lower.indexOf(hit.needle) : 0;
+ },
+ test: (text) => {
+ const lower = text.toLowerCase();
+
+ return compiled.some(({ pattern }) => like(lower, pattern));
+ }
+ };
+}
+
+/** User and assistant text on the visible branch, in chat order */
+function visibleMessages(
+ conv: DatabaseConversation,
+ messages: DatabaseMessage[]
+): DatabaseMessage[] {
+ if (messages.length === 0) return [];
+
+ return filterByLeafNodeId(messages, conv.currNode ?? '').filter(
+ (m) =>
+ (m.role === MessageRole.USER || m.role === MessageRole.ASSISTANT) &&
+ !m.isSynthetic &&
+ typeof m.content === 'string' &&
+ m.content.trim() !== ''
+ );
+}
+
+function formatDate(ms: number): string {
+ const date = new Date(ms);
+
+ return Number.isNaN(date.getTime()) ? 'unknown' : formatLocalIso(date);
+}
+
+function excerpt(text: string, maxChars: number, matchAt: number): string {
+ if (text.length <= maxChars) return text;
+
+ if (matchAt > maxChars * SEARCH_CHATS_EXCERPT_RECENTER_RATIO) {
+ const lead = Math.floor(maxChars * SEARCH_CHATS_EXCERPT_LEAD_RATIO);
+ const start = Math.max(0, Math.min(matchAt - lead, text.length - maxChars));
+ const end = start + maxChars;
+ const before = start > 0 ? SEARCH_CHATS_ELLIPSIS : '';
+ const after = end < text.length ? `${SEARCH_CHATS_ELLIPSIS}[+${text.length - end} chars]` : '';
+
+ return `${before}${sliceText(text, start, end)}${after}`;
+ }
+
+ return `${sliceText(text, 0, maxChars)}${SEARCH_CHATS_ELLIPSIS}[+${text.length - maxChars} chars]`;
+}
+
+interface ChatHit {
+ conv: DatabaseConversation;
+ /** Visible messages of the whole chat, positions are counted on these */
+ messages: DatabaseMessage[];
+ /** Visible messages inside the time window (all of them without one) */
+ inRange: DatabaseMessage[];
+ titleMatched: boolean;
+ matched: DatabaseMessage[];
+}
+
+function formatMessage(
+ message: DatabaseMessage,
+ position: number,
+ params: SearchChatsParams,
+ matcher: Matcher | null
+): string {
+ const text = message.content.trim();
+ const matchAt = matcher ? matcher.locate(text) : -1;
+ const body = excerpt(text, params.maxMessageChars, matchAt).replace(/\n+/g, '\n ');
+ const files = (message.extra ?? []).map((e) => e.name).filter(Boolean);
+ // names are user data, quote them so they cannot pass as result structure
+ const attachments =
+ files.length > 0 ? ` [attachments: ${files.map((f) => JSON.stringify(f)).join(', ')}]` : '';
+
+ return `#${position} ${message.role}${SEARCH_CHATS_FIELD_SEPARATOR}${formatDate(message.timestamp)}: ${body}${attachments}`;
+}
+
+interface ChatBlock {
+ head: string[];
+ /** Formatted messages, in chat order */
+ messages: string[];
+ /** On a cut, keep the last messages (latest / matching) instead of the first */
+ keepTail: boolean;
+}
+
+const blockText = ({ head, messages }: ChatBlock) => [...head, ...messages].join('\n');
+
+/** Fit a chat block into `room` chars by dropping whole messages from the less wanted end */
+function cutChat(block: ChatBlock, room: number): string {
+ const ordered = block.keepTail ? [...block.messages].reverse() : block.messages;
+ const kept: string[] = [];
+ const head = block.head.join('\n');
+
+ let size = head.length + 1 + SEARCH_CHATS_CUT_NOTICE.length;
+
+ for (const line of ordered) {
+ if (size + 1 + line.length > room) break;
+
+ kept.push(line);
+ size += 1 + line.length;
+ }
+
+ if (block.keepTail) kept.reverse();
+
+ const lines = block.keepTail
+ ? [head, SEARCH_CHATS_CUT_NOTICE, ...kept]
+ : [head, ...kept, SEARCH_CHATS_CUT_NOTICE];
+ const text = lines.join('\n');
+
+ // even the head does not fit (very long title)
+ if (text.length > room) {
+ return `${sliceText(text, 0, Math.max(0, room - SEARCH_CHATS_CUT_NOTICE.length))}${SEARCH_CHATS_CUT_NOTICE}`;
+ }
+
+ return text;
+}
+
+function formatChat(
+ hit: ChatHit,
+ index: number,
+ params: SearchChatsParams,
+ matcher: Matcher | null,
+ timed: boolean
+): ChatBlock {
+ const { conv, inRange, matched, messages, titleMatched } = hit;
+ const summary = [`conv_id: ${conv.id}`, `updated ${formatDate(conv.lastModified)}`];
+ // titles are user data, JSON quoting escapes quotes and newlines
+ const title = JSON.stringify(conv.name ?? '');
+
+ if (params.limitMessages === 0) {
+ return {
+ head: [
+ `- ${title}${SEARCH_CHATS_FIELD_SEPARATOR}${summary.join(SEARCH_CHATS_FIELD_SEPARATOR)}`
+ ],
+ keepTail: false,
+ messages: []
+ };
+ }
+
+ const counts = timed
+ ? `${messages.length} messages, ${inRange.length} in the time range`
+ : `${messages.length} messages`;
+ const lines = [
+ `## Chat ${index}: ${title}`,
+ [...summary, counts].join(SEARCH_CHATS_FIELD_SEPARATOR)
+ ];
+ const n = params.limitMessages;
+
+ let picked: DatabaseMessage[];
+
+ if (params.messageSelection === SearchChatsMessageSelection.MATCHING && matched.length > 0) {
+ const where = [titleMatched ? 'title' : '', `${matched.length} message(s)`]
+ .filter(Boolean)
+ .join(', ');
+
+ picked = matched.slice(-n);
+ lines.push(`matched in: ${where}${matched.length > n ? ` (showing the last ${n})` : ''}`);
+ } else {
+ if (params.messageSelection === SearchChatsMessageSelection.MATCHING) {
+ lines.push('matched in: title only, showing the latest messages');
+ }
+
+ picked =
+ params.messageSelection === SearchChatsMessageSelection.EARLIEST
+ ? inRange.slice(0, n)
+ : inRange.slice(-n);
+ }
+
+ if (messages.length === 0) lines.push('(no messages)');
+
+ const position = new Map(messages.map((m, i) => [m.id, i + 1]));
+
+ return {
+ head: lines,
+ keepTail: params.messageSelection !== SearchChatsMessageSelection.EARLIEST,
+ messages: picked.map((m) => formatMessage(m, position.get(m.id)!, params, matcher))
+ };
+}
+
+function groupByConversation(messages: DatabaseMessage[]): Map<string, DatabaseMessage[]> {
+ const groups = new Map<string, DatabaseMessage[]>();
+
+ for (const m of messages) {
+ const group = groups.get(m.convId);
+
+ if (group) group.push(m);
+ else groups.set(m.convId, [m]);
+ }
+
+ return groups;
+}
+
+function describeQuery(params: SearchChatsParams): string {
+ const parts: string[] = [];
+
+ if (params.byMatches) parts.push(`matching ${JSON.stringify(params.byMatches)}`);
+
+ if (params.byConvIds) parts.push('by conv_id');
+
+ if (params.fromTimestamp !== undefined) parts.push(`from ${formatDate(params.fromTimestamp)}`);
+
+ if (params.toTimestamp !== undefined) parts.push(`before ${formatDate(params.toTimestamp)}`);
+
+ return parts.join(' ');
+}
+
+/**
+ * Run a search and render the result as text for the model.
+ * `conversations` holds the candidates: all conversations, or only those of by_conv_ids.
+ */
+export async function searchChats(
+ conversations: DatabaseConversation[],
+ params: SearchChatsParams,
+ loader: SearchChatsLoader,
+ currentConversationId?: string
+): Promise<string> {
+ const { byConvIds, fromTimestamp, toTimestamp } = params;
+ const timed = fromTimestamp !== undefined || toTimestamp !== undefined;
+ const notes: string[] = [];
+
+ let candidates = conversations;
+
+ if (byConvIds) {
+ const found = new Set(candidates.map((c) => c.id));
+ const missing = byConvIds.filter((id) => !found.has(id));
+
+ if (missing.length > 0) notes.push(`not found: ${missing.join(', ')}`);
+
+ candidates = candidates.filter((c) => byConvIds.includes(c.id));
+ }
+
+ if (currentConversationId && !params.includeCurrent) {
+ if (byConvIds?.includes(currentConversationId)) {
+ notes.push(
+ 'the current chat is hidden (you already see it); set include_current=true to read it'
+ );
+ }
+
+ candidates = candidates.filter((c) => c.id !== currentConversationId);
+ }
+
+ const matcher = params.byMatches ? buildMatcher(params.byMatches) : null;
+ const isInRange = (m: DatabaseMessage) =>
+ (fromTimestamp === undefined || m.timestamp >= fromTimestamp) &&
+ (toTimestamp === undefined || m.timestamp < toTimestamp);
+ const toHit = (conv: DatabaseConversation, raw: DatabaseMessage[]): ChatHit => {
+ const messages = visibleMessages(conv, raw);
+ const inRange = timed ? messages.filter(isInRange) : messages;
+ const matched = matcher ? inRange.filter((m) => matcher.test(m.content)) : [];
+
+ return { conv, inRange, matched, messages, titleMatched: !!matcher?.test(conv.name ?? '') };
+ };
+
+ let hits: ChatHit[];
+
+ if (matcher || timed) {
+ // which chats qualify depends on their messages, so read them before paging
+ const ids = byConvIds ? candidates.map((c) => c.id) : null;
+ const range = timed ? { from: fromTimestamp, to: toTimestamp } : undefined;
+ const byConv = groupByConversation(await loader.messages(ids, range));
+
+ hits = [];
+
+ for (const conv of candidates) {
+ const raw = byConv.get(conv.id) ?? [];
+
+ // cheap check on all messages before building the visible branch
+ if (
+ matcher &&
+ !matcher.test(conv.name ?? '') &&
+ !raw.some((m) => typeof m.content === 'string' && matcher.test(m.content))
+ ) {
+ continue;
+ }
+
+ const hit = toHit(conv, raw);
+
+ // with a time window, a chat needs visible messages inside it, a title match is not enough
+ if (timed && hit.inRange.length === 0) continue;
+
+ if (matcher && !hit.titleMatched && hit.matched.length === 0) continue;
+
+ hits.push(hit);
+ }
+ } else {
+ hits = candidates.map((conv) => toHit(conv, []));
+ }
+
+ const sign = params.order === SearchChatsOrder.ASC ? 1 : -1;
+
+ hits.sort((a, b) => sign * (a.conv.lastModified - b.conv.lastModified));
+
+ const total = hits.length;
+ const page = hits.slice(params.offsetChats, params.offsetChats + params.limitChats);
+
+ // plain listing or reading: only the chats on this page need their messages
+ if (!matcher && !timed && params.limitMessages > 0 && page.length > 0) {
+ const byConv = groupByConversation(await loader.messages(page.map((h) => h.conv.id)));
+
+ page.forEach((hit, i) => (page[i] = toHit(hit.conv, byConv.get(hit.conv.id) ?? [])));
+ }
+
+ const orderLabel = params.order === SearchChatsOrder.ASC ? 'oldest first' : 'newest first';
+ const range =
+ page.length === 0
+ ? `showing none of ${total}`
+ : `showing ${params.offsetChats + 1}-${params.offsetChats + page.length} of ${total}`;
+ const header = [
+ `${`${total} chat(s) ${describeQuery(params)}`.trim()} (${orderLabel}, ${range})`,
+ ...notes
+ ].join('\n');
+ const headerSeparator = '\n\n';
+ const separator = params.limitMessages === 0 ? '\n' : '\n\n';
+ const moreNote = (hidden: number, nextOffset: number) =>
+ `[${hidden} more chat(s) on this page not shown: use offset_chats=${nextOffset}, or lower limit_messages]`;
+ // room for the note in its longest form: both numbers at their largest
+ const noteRoom =
+ separator.length + moreNote(page.length, params.offsetChats + page.length).length;
+ const blocks: string[] = [];
+
+ let size = header.length;
+
+ for (const [i, hit] of page.entries()) {
+ const block = formatChat(hit, params.offsetChats + i + 1, params, matcher, timed);
+ const text = blockText(block);
+ const gap = blocks.length === 0 ? headerSeparator.length : separator.length;
+ const isLast = i === page.length - 1;
+ const limit = SEARCH_CHATS_OUTPUT_MAX_CHARS - (isLast ? 0 : noteRoom);
+
+ if (size + gap + text.length > limit) {
+ // always show at least part of the first chat
+ if (blocks.length === 0) blocks.push(cutChat(block, limit - size - gap));
+
+ break;
+ }
+
+ blocks.push(text);
+ size += gap + text.length;
+ }
+
+ if (blocks.length < page.length) {
+ blocks.push(moreNote(page.length - blocks.length, params.offsetChats + blocks.length));
+ }
+
+ return blocks.length > 0 ? `${header}${headerSeparator}${blocks.join(separator)}` : header;
+}
diff --git a/tools/ui/tests/client/search-chats-default-disabled-migration.svelte.test.ts b/tools/ui/tests/client/search-chats-default-disabled-migration.svelte.test.ts
new file mode 100644
index 000000000..802f795db
--- /dev/null
+++ b/tools/ui/tests/client/search-chats-default-disabled-migration.svelte.test.ts
@@ -0,0 +1,114 @@
+// search_chats starts disabled: tool policy is a deny-list, so the key must be added to the global defaults and to every existing conversation
+
+import { DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY, SEARCH_CHATS_TOOL_KEY } from '$lib/constants';
+import { DatabaseService } from '$lib/services/database.service';
+import { MigrationService } from '$lib/services/migration.service';
+import type { ExportedConversation } from '$lib/types/database';
+import { withSearchChatsDisabled } from '$lib/utils/search-chats';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+const MIGRATION_ID = 'search-chats-default-disabled-v1';
+
+async function runMigration() {
+ await MigrationService.getMigrations()
+ .find((m) => m.id === MIGRATION_ID)
+ ?.run();
+}
+
+const conversation = (id: string, disabledTools?: string[]) =>
+ ({
+ conv: { currNode: null, disabledTools, id, lastModified: 0, name: id },
+ messages: []
+ }) as unknown as ExportedConversation;
+
+function storedDefaults(): unknown {
+ return JSON.parse(localStorage.getItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY) ?? 'null');
+}
+
+describe('search_chats default-disabled migration', () => {
+ beforeEach(() => {
+ localStorage.removeItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
+ });
+
+ afterEach(async () => {
+ const conversations = await DatabaseService.getAllConversations();
+
+ await DatabaseService.bulkDeleteConversations(conversations.map((c) => c.id));
+ localStorage.removeItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
+ });
+
+ it('adds the tool to the global defaults, keeping existing entries', async () => {
+ localStorage.setItem(
+ DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
+ JSON.stringify(['server:exec_shell_command'])
+ );
+ await runMigration();
+ expect(storedDefaults()).toEqual(['server:exec_shell_command', SEARCH_CHATS_TOOL_KEY]);
+ });
+
+ it('creates the defaults on a fresh install', async () => {
+ await runMigration();
+ expect(storedDefaults()).toEqual([SEARCH_CHATS_TOOL_KEY]);
+ });
+
+ it('disables the tool in every existing conversation', async () => {
+ await DatabaseService.importConversations([
+ conversation('no-policy'),
+ conversation('with-policy', ['browser:run_javascript']),
+ conversation('already', [SEARCH_CHATS_TOOL_KEY])
+ ]);
+ await runMigration();
+
+ expect((await DatabaseService.getConversation('no-policy'))?.disabledTools).toEqual([
+ SEARCH_CHATS_TOOL_KEY
+ ]);
+ expect((await DatabaseService.getConversation('with-policy'))?.disabledTools).toEqual([
+ 'browser:run_javascript',
+ SEARCH_CHATS_TOOL_KEY
+ ]);
+ expect((await DatabaseService.getConversation('already'))?.disabledTools).toEqual([
+ SEARCH_CHATS_TOOL_KEY
+ ]);
+ });
+
+ it('keeps the tool policy of the source chat on fork', async () => {
+ await DatabaseService.importConversations([
+ {
+ conv: {
+ currNode: 'm1',
+ disabledTools: [SEARCH_CHATS_TOOL_KEY],
+ id: 'source',
+ lastModified: 0,
+ name: 'source'
+ },
+ messages: [
+ {
+ children: [],
+ content: 'hi',
+ convId: 'source',
+ id: 'm1',
+ parent: null,
+ role: 'user',
+ timestamp: 0,
+ type: 'text'
+ }
+ ]
+ } as unknown as ExportedConversation
+ ]);
+
+ const fork = await DatabaseService.forkConversation('source', 'm1', {
+ includeAttachments: false,
+ name: 'fork'
+ });
+
+ expect(fork.disabledTools).toEqual([SEARCH_CHATS_TOOL_KEY]);
+ });
+});
+
+describe('withSearchChatsDisabled', () => {
+ it('adds the key once, for imported rows with or without a policy', () => {
+ expect(withSearchChatsDisabled(undefined)).toEqual([SEARCH_CHATS_TOOL_KEY]);
+ expect(withSearchChatsDisabled(['a'])).toEqual(['a', SEARCH_CHATS_TOOL_KEY]);
+ expect(withSearchChatsDisabled([SEARCH_CHATS_TOOL_KEY])).toEqual([SEARCH_CHATS_TOOL_KEY]);
+ });
+});
diff --git a/tools/ui/tests/client/search-chats.service.svelte.test.ts b/tools/ui/tests/client/search-chats.service.svelte.test.ts
new file mode 100644
index 000000000..b29aaae3b
--- /dev/null
+++ b/tools/ui/tests/client/search-chats.service.svelte.test.ts
@@ -0,0 +1,113 @@
+// IndexedDB side of search_chats: loading, the current-chat filter and the readonly transaction.
+// Matching and formatting are covered by tests/unit/search-chats.test.ts.
+
+import { MessageRole, MessageType } from '$lib/enums';
+import { DatabaseService } from '$lib/services/database.service';
+import { SearchChatsService } from '$lib/services/search-chats.service';
+import type { ExportedConversation } from '$lib/types/database';
+import { formatLocalIso } from '$lib/utils/formatters';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+function makeConversation(
+ id: string,
+ name: string,
+ turns: [MessageRole, string][],
+ lastModified: number
+): ExportedConversation {
+ const messages = turns.map(([role, content], i) => ({
+ children: i + 1 < turns.length ? [`${id}-m${i + 1}`] : [],
+ content,
+ convId: id,
+ id: `${id}-m${i}`,
+ parent: i === 0 ? null : `${id}-m${i - 1}`,
+ role,
+ timestamp: lastModified + i,
+ type: MessageType.TEXT
+ }));
+
+ return {
+ conv: { currNode: messages.at(-1)!.id, id, lastModified, name },
+ messages
+ } as unknown as ExportedConversation;
+}
+
+beforeEach(async () => {
+ await DatabaseService.importConversations([
+ makeConversation(
+ 'paris',
+ "let's think about paris",
+ [
+ [MessageRole.USER, 'plan a weekend in Paris'],
+ [MessageRole.ASSISTANT, 'Day 1: Louvre'],
+ [MessageRole.USER, 'how much is a metro ticket?'],
+ [MessageRole.ASSISTANT, 'About 2 euros.']
+ ],
+ 1000
+ ),
+ makeConversation(
+ 'search',
+ 'list my chats',
+ [
+ [MessageRole.USER, 'list my chats'],
+ [MessageRole.TOOL, `=> [{"name":"let's think about paris"}]`],
+ [MessageRole.ASSISTANT, 'You have two chats.']
+ ],
+ 2000
+ )
+ ]);
+});
+
+afterEach(async () => {
+ const conversations = await DatabaseService.getAllConversations();
+
+ await DatabaseService.bulkDeleteConversations(conversations.map((conv) => conv.id));
+});
+
+describe('search_chats service', () => {
+ it('searches every stored message and hides the current chat', async () => {
+ const reply = await SearchChatsService.executeTool(
+ { by_matches: ['%paris%'], limit_messages: 1, message_selection: 'latest' },
+ 'search'
+ );
+
+ expect(reply.isError).toBe(false);
+ expect(reply.content).toContain('1 chat(s) matching ["%paris%"]');
+ expect(reply.content).toContain('#4 assistant');
+ expect(reply.content).toContain('About 2 euros.');
+ });
+
+ it('loads chats by id and reports missing ones', async () => {
+ const reply = await SearchChatsService.executeTool(
+ { by_conv_ids: ['paris', 'missing'], limit_messages: 1, message_selection: 'earliest' },
+ 'search'
+ );
+
+ expect(reply.content).toContain('not found: missing');
+ expect(reply.content).toContain('plan a weekend in Paris');
+ });
+
+ it('finds chats with messages in a time range through the timestamp index', async () => {
+ const reply = await SearchChatsService.executeTool(
+ { from_timestamp: formatLocalIso(new Date(2000)), limit_messages: 1 },
+ 'other'
+ );
+
+ expect(reply.content).toContain('1 chat(s) from');
+ expect(reply.content).toContain('conv_id: search');
+ expect(reply.content).toContain('#1 user');
+ });
+
+ it('returns parameter errors to the model', async () => {
+ const reply = await SearchChatsService.executeTool({ order_timestamp: 'esc' });
+
+ expect(reply.isError).toBe(true);
+ expect(reply.content).toBe('Error: order_timestamp must be one of: asc, desc');
+ });
+
+ it('reads through a transaction that refuses writes', async () => {
+ await expect(
+ DatabaseService.readOnly(({ messages }) => messages.delete('paris-m0'))
+ ).rejects.toThrow();
+ expect(await DatabaseService.getConversationMessages('paris')).toHaveLength(4);
+ });
+});
diff --git a/tools/ui/tests/unit/search-chats.test.ts b/tools/ui/tests/unit/search-chats.test.ts
new file mode 100644
index 000000000..9e12c665f
--- /dev/null
+++ b/tools/ui/tests/unit/search-chats.test.ts
@@ -0,0 +1,383 @@
+import { SEARCH_CHATS_OUTPUT_MAX_CHARS, SEARCH_CHATS_PATTERN_MAX_CHARS } from '$lib/constants';
+import { MessageRole, MessageType } from '$lib/enums';
+import type { DatabaseConversation, DatabaseMessage } from '$lib/types/database';
+import { formatLocalIso } from '$lib/utils/formatters';
+import { parseSearchChatsParams, searchChats, SearchChatsError } from '$lib/utils/search-chats';
+import { describe, expect, it } from 'vitest';
+
+type Turn = [role: 'user' | 'assistant' | 'tool', content: string];
+
+const conversations: DatabaseConversation[] = [];
+const messages: DatabaseMessage[] = [];
+// fixture times are in minutes, ISO timestamps only carry seconds
+const MINUTE = 60_000;
+const at = (minute: number) => formatLocalIso(new Date(minute * MINUTE));
+
+/** Add a linear chat whose messages are one minute apart from `startMinute` */
+function addChat(id: string, name: string, startMinute: number, turns: Turn[]): string[] {
+ const start = startMinute * MINUTE;
+ const root = `${id}-root`;
+ const ids = turns.map((_, i) => `${id}-${i + 1}`);
+
+ messages.push({
+ children: ids.slice(0, 1),
+ content: '',
+ convId: id,
+ id: root,
+ parent: null,
+ role: MessageRole.SYSTEM,
+ timestamp: start - MINUTE,
+ type: MessageType.ROOT
+ } as DatabaseMessage);
+
+ turns.forEach(([role, content], i) => {
+ messages.push({
+ children: ids.slice(i + 1, i + 2),
+ content,
+ convId: id,
+ id: ids[i],
+ parent: i === 0 ? root : ids[i - 1],
+ role: role as MessageRole,
+ timestamp: start + i * MINUTE,
+ type: MessageType.TEXT
+ } as DatabaseMessage);
+ });
+
+ const lastModified = start + (turns.length - 1) * MINUTE;
+
+ conversations.push({ currNode: ids.at(-1)!, id, lastModified, name } as DatabaseConversation);
+
+ return ids;
+}
+
+const parisIds = addChat('paris', "let's think about paris", 3000, [
+ ['user', 'plan a weekend in Paris'],
+ ['assistant', 'Day 1: Louvre. Day 2: Montmartre.'],
+ ['user', 'is the eiffel tower worth it?'],
+ ['assistant', 'Yes, book tickets early.'],
+ ['user', 'how much is a metro ticket?'],
+ ['assistant', 'About 2 euros.']
+]);
+
+// edited-away branch off the first assistant reply, never visible
+messages.push({
+ children: [],
+ content: 'old edit about versailles',
+ convId: 'paris',
+ id: 'paris-edit',
+ parent: parisIds[1],
+ role: MessageRole.USER,
+ timestamp: 3001 * MINUTE + 30_000,
+ type: MessageType.TEXT
+} as DatabaseMessage);
+
+// an earlier search session: its tool output names other chats
+addChat('search', 'list my chats', 4000, [
+ ['user', 'list my chats please'],
+ ['tool', `=> [{"name":"let's think about paris"},{"name":"what time is it in bulgaria"}]`],
+ ['assistant', 'You have a few chats.']
+]);
+
+addChat('bulgaria', 'what time is it in bulgaria', 2000, [
+ ['user', 'what time is it in bulgaria'],
+ ['assistant', 'It is 14:00 in Sofia.']
+]);
+
+addChat('current', 'this chat', 5000, [['user', 'find the chat where I talk about paris']]);
+
+const loads: (string[] | null)[] = [];
+const loader = {
+ async messages(ids: string[] | null, range?: { from?: number; to?: number }) {
+ loads.push(ids);
+
+ const rows = ids ? messages.filter((m) => ids.includes(m.convId)) : messages;
+
+ if (!range) return rows;
+
+ const { from, to } = range;
+ const active = new Set(
+ messages
+ .filter(
+ (m) =>
+ (from === undefined || m.timestamp >= from) && (to === undefined || m.timestamp < to)
+ )
+ .map((m) => m.convId)
+ );
+
+ return rows.filter((m) => active.has(m.convId));
+ }
+};
+
+async function search(args: Record<string, unknown>, current = 'current') {
+ const params = parseSearchChatsParams(args);
+ const candidates = params.byConvIds
+ ? conversations.filter((c) => params.byConvIds!.includes(c.id))
+ : conversations;
+
+ return searchChats(candidates, params, loader, current);
+}
+
+describe('search_chats: finding chats', () => {
+ it('answers "last question in the paris chat" in one call', async () => {
+ const out = await search({
+ by_matches: ['%paris%'],
+ limit_messages: 4,
+ message_selection: 'latest'
+ });
+
+ expect(out).toContain('1 chat(s) matching ["%paris%"] (newest first, showing 1-1 of 1)');
+ expect(out).toContain(`## Chat 1: "let's think about paris"`);
+ expect(out).toContain(`#5 user | ${at(3004)}: how much is a metro ticket?`);
+ expect(out).toContain('#6 assistant');
+ expect(out).not.toContain('#2 assistant');
+ });
+
+ it('ignores tool output and the current chat', async () => {
+ const out = await search({ by_matches: ['bulgaria'] });
+
+ // the search session mentions bulgaria in tool output only
+ expect(out).toContain('1 chat(s) matching');
+ expect(out).toContain('conv_id: bulgaria');
+ expect(out).not.toContain('conv_id: search');
+ });
+
+ it('shows the matching messages by default', async () => {
+ const out = await search({ by_matches: ['%eiffel%', 'metro'] });
+
+ expect(out).toContain('matched in: 2 message(s)');
+ expect(out).toContain('#3 user');
+ expect(out).toContain('#5 user');
+ expect(out).not.toContain('#1 user');
+ });
+
+ it('reports a title-only match and falls back to the latest messages', async () => {
+ const out = await search({ by_matches: ['%think about%'], limit_messages: 1 });
+
+ expect(out).toContain('matched in: title only, showing the latest messages');
+ expect(out).toContain('#6 assistant');
+ });
+
+ it('does not search edited-away branches', async () => {
+ expect(await search({ by_matches: ['versailles'] })).toContain('0 chat(s) matching');
+ });
+
+ it('supports _ wildcards and plain words as "contains"', async () => {
+ expect(await search({ by_matches: ['m_tro'] })).toContain('conv_id: paris');
+ expect(await search({ by_matches: 'SOFIA' })).toContain('conv_id: bulgaria');
+ });
+
+ it('moves the excerpt of a long message to the match', async () => {
+ const long = ` ${'x'.repeat(2000)} the secret word is paris ${'y'.repeat(2000)}`;
+
+ addChat('long', 'long chat', 1000, [['user', long]]);
+
+ const out = await search({
+ by_conv_ids: ['long'],
+ by_matches: ['secret word'],
+ max_message_chars: 100
+ });
+
+ expect(out).toMatch(/: \.\.\.x+ the secret word is paris y+\.\.\.\[\+\d+ chars\]/);
+
+ conversations.pop();
+ messages.splice(-2, 2);
+ });
+});
+
+describe('search_chats: listing and reading', () => {
+ it('lists chats as one line each without reading messages', async () => {
+ loads.length = 0;
+
+ const out = await search({ limit_chats: 999, limit_messages: 0 });
+
+ expect(out.split('\n')).toEqual([
+ '3 chat(s) (newest first, showing 1-3 of 3)',
+ '',
+ `- "list my chats" | conv_id: search | updated ${at(4002)}`,
+ `- "let's think about paris" | conv_id: paris | updated ${at(3005)}`,
+ `- "what time is it in bulgaria" | conv_id: bulgaria | updated ${at(2001)}`
+ ]);
+ expect(loads).toEqual([]);
+ });
+
+ it('reads messages only for the chats on the page', async () => {
+ loads.length = 0;
+ await search({ limit_chats: 2 });
+ expect(loads).toEqual([['search', 'paris']]);
+ });
+
+ it('pages and orders oldest first', async () => {
+ const out = await search({
+ limit_chats: 1,
+ limit_messages: 0,
+ offset_chats: 1,
+ order_timestamp: 'asc'
+ });
+
+ expect(out).toContain('(oldest first, showing 2-2 of 3)');
+ expect(out).toContain('conv_id: paris');
+ });
+
+ it('reads a chat by id from the start', async () => {
+ const out = await search({
+ by_conv_ids: ['paris'],
+ limit_messages: 2,
+ message_selection: 'earliest'
+ });
+
+ expect(out).toContain(`#1 user | ${at(3000)}: plan a weekend in Paris`);
+ expect(out).toContain('#2 assistant');
+ expect(out).not.toContain('#3 user');
+ });
+
+ it('reports unknown ids and hides the current chat unless asked', async () => {
+ const hidden = await search({ by_conv_ids: ['current', 'nope'] });
+
+ expect(hidden).toContain('not found: nope');
+ expect(hidden).toContain('the current chat is hidden');
+ expect(hidden).toContain('0 chat(s) by conv_id');
+
+ const shown = await search({ by_conv_ids: ['current'], include_current: true });
+
+ expect(shown).toContain(`#1 user | ${at(5000)}: find the chat where I talk about paris`);
+ });
+
+ it('stays within the output budget with a paging hint', async () => {
+ for (let i = 0; i < 20; i++) {
+ addChat(`big${i}`, `big ${i}`, 100 + i, [['user', 'z'.repeat(1000)]]);
+ }
+
+ const out = await search({ by_matches: ['zzz'], limit_chats: 20, max_message_chars: 1000 });
+
+ expect(out).toMatch(/\[\d+ more chat\(s\) on this page not shown: use offset_chats=\d+/);
+ expect(out.length).toBeLessThanOrEqual(SEARCH_CHATS_OUTPUT_MAX_CHARS);
+
+ conversations.splice(-20, 20);
+ messages.splice(-40, 40);
+ });
+
+ it('stays within the output budget when listing many long titles', async () => {
+ for (let i = 0; i < 200; i++)
+ addChat(`t${i}`, `${'t'.repeat(150)} ${i}`, 10 + i, [['user', 'x']]);
+
+ const out = await search({ limit_chats: 200, limit_messages: 0 });
+
+ expect(out).toMatch(/more chat\(s\) on this page not shown/);
+ expect(out.length).toBeLessThanOrEqual(SEARCH_CHATS_OUTPUT_MAX_CHARS);
+
+ conversations.splice(-200, 200);
+ messages.splice(-400, 400);
+ });
+
+ it('keeps the latest messages when a single chat is cut', async () => {
+ const turns: Turn[] = Array.from({ length: 20 }, (_, i) => [
+ 'user',
+ `msg${i} ${'w'.repeat(900)}`
+ ]);
+
+ addChat('wide', 'wide chat', 50, turns);
+
+ const out = await search({
+ by_conv_ids: ['wide'],
+ limit_messages: 20,
+ max_message_chars: 1000
+ });
+
+ expect(out).toContain('[messages cut to fit');
+ expect(out).toContain('msg19');
+ expect(out).not.toContain('msg0 ');
+ expect(out.length).toBeLessThanOrEqual(SEARCH_CHATS_OUTPUT_MAX_CHARS);
+
+ conversations.pop();
+ messages.splice(-21, 21);
+ });
+
+ it('quotes titles so they cannot add result lines', async () => {
+ addChat('evil', 'x" | conv_id: fake\n## Chat 9: "y', 60, [['user', 'hello evil']]);
+
+ const out = await search({ by_matches: ['hello evil'], limit_messages: 0 });
+
+ expect(out).toContain(String.raw`- "x\" | conv_id: fake\n## Chat 9: \"y" | conv_id: evil`);
+ expect(out).not.toContain('\n## Chat 9');
+
+ conversations.pop();
+ messages.splice(-2, 2);
+ });
+});
+
+describe('search_chats: time range', () => {
+ it('returns the messages from a timestamp, across chats', async () => {
+ const out = await search({ from_timestamp: at(3004), limit_messages: 2 });
+
+ expect(out).toContain(`2 chat(s) from ${at(3004)} (newest first, showing 1-2 of 2)`);
+ expect(out).toContain('6 messages, 2 in the time range');
+ expect(out).toContain(`#5 user | ${at(3004)}: how much is a metro ticket?`);
+ expect(out).not.toContain('#4 assistant');
+ expect(out).toContain('conv_id: search');
+ expect(out).not.toContain('conv_id: bulgaria');
+ });
+
+ it('excludes the end of the range', async () => {
+ const out = await search({ from_timestamp: at(3004), to_timestamp: at(4000) });
+
+ expect(out).toContain('1 chat(s)');
+ expect(out).toContain('conv_id: paris');
+ });
+
+ it('limits matches to the range, and a title match needs messages in it', async () => {
+ const ranged = await search({ by_matches: ['metro', 'eiffel'], from_timestamp: at(3003) });
+
+ expect(ranged).toContain('matched in: 1 message(s)');
+ expect(ranged).not.toContain('#3 user');
+
+ const titleOnly = await search({ by_matches: ['%think about%'], from_timestamp: at(4000) });
+
+ expect(titleOnly).toContain('0 chat(s)');
+ });
+
+ it('reads ISO dates as local time', () => {
+ const parse = (from_timestamp: string) =>
+ parseSearchChatsParams({ from_timestamp }).fromTimestamp;
+
+ expect(parse('2026-11-10')).toBe(new Date(2026, 10, 10).getTime());
+ expect(parse('2026-11-10T14:30')).toBe(new Date(2026, 10, 10, 14, 30).getTime());
+ expect(parse('2026-11-10T14:30Z')).toBe(Date.UTC(2026, 10, 10, 14, 30));
+ });
+
+ it('defaults to the earliest messages from the timestamp', () => {
+ const params = parseSearchChatsParams({ from_timestamp: '2026-11-10' });
+
+ expect(params.messageSelection).toBe('earliest');
+ });
+});
+
+describe('search_chats: parameters', () => {
+ it.each([
+ [{ message_selection: 'matching' }, 'needs by_matches'],
+ [{ limit_chats: 0 }, 'limit_chats must be an integer from 1 to 1000'],
+ [{ limit_messages: 1.5 }, 'limit_messages must be an integer'],
+ [{ order_timestamp: 'esc' }, 'order_timestamp must be one of: asc, desc'],
+ [{ by_matches: [1] }, 'by_matches must be an array of non-empty strings'],
+ [
+ { by_matches: ['x'.repeat(SEARCH_CHATS_PATTERN_MAX_CHARS + 1)] },
+ `by_matches entries must be at most ${SEARCH_CHATS_PATTERN_MAX_CHARS} characters`
+ ],
+ [{ message_selection: 'all' }, 'message_selection must be one of'],
+ [{ from_timestamp: 'yesterday' }, 'from_timestamp must be an ISO 8601 date'],
+ [{ from_timestamp: 1786962645195 }, 'from_timestamp must be an ISO 8601 date'],
+ [{ to_timestamp: '2026-02-30' }, 'to_timestamp must be an ISO 8601 date'],
+ [{ to_timestamp: '2026-02-30T10:00' }, 'to_timestamp must be an ISO 8601 date'],
+ [{ from_timestamp: '2026-11-10T24:30' }, 'from_timestamp must be an ISO 8601 date'],
+ [
+ { from_timestamp: '2026-11-10', to_timestamp: '2026-11-09' },
+ 'to_timestamp must be after from_timestamp'
+ ]
+ ])('rejects %j', (args, message) => {
+ expect(() => parseSearchChatsParams(args)).toThrow(SearchChatsError);
+ expect(() => parseSearchChatsParams(args)).toThrow(message);
+ });
+
+ it('accepts numbers sent as strings', () => {
+ expect(parseSearchChatsParams({ limit_chats: '3' }).limitChats).toBe(3);
+ });
+});