blob: c459e07cd9a1481447fe2b4b85cccddd727fd5e1 [file]
export const SEARCH_QUERY_MAX_CHARS = 500;
export const SEARCH_DOMAIN_MAX_CHARS = 253;
export const SEARCH_URL_MAX_CHARS = 4096;
export const SEARCH_DEFAULT_LIMIT = 5;
export const SEARCH_MAX_LIMIT = 10;
const TRACKING_PARAM_PREFIXES = ['utm_'];
const TRACKING_PARAM_NAMES = new Set(['fbclid', 'gclid', 'yclid', 'mc_cid', 'mc_eid']);
export type SearchSourceKind = 'web' | 'web_fetch' | 'thread' | 'memory' | 'activity' | 'tool';
export type SearchProviderKind = 'disabled' | 'api' | 'browser_scrape' | 'local';
export type SearchErrorReason =
| 'disabled'
| 'missing_provider'
| 'missing_credentials'
| 'invalid_query'
| 'invalid_domain'
| 'invalid_url'
| 'blocked_scheme'
| 'blocked_domain'
| 'timeout'
| 'aborted'
| 'needs_human_browser'
| 'provider_error'
| 'parse_error'
// PR-SEARCH-2.5 (xuan msg `57ca05cd` + `a91c61c6`): incognito gate.
// Returned when the workspace is currently incognito and search is
// disabled by policy. ALSO returned when the workspace privacy
// authority returned a malformed snapshot (`validateWorkspacePrivacyContext`
// failed) — fail-closed behavior treats unverifiable state as
// incognito to preserve privacy. The two paths share this reason so
// consumers do not need an extra UI state; the `message` field
// distinguishes them when needed:
// - active: "Search is disabled while incognito is active."
// - malformed: "Search is disabled because workspace privacy state could not be verified."
// The state is user-visible (the user toggled incognito on, or the
// system failed closed), so exposing the reason is intentional —
// the data we don't expose is session content / result counts /
// snippets.
| 'incognito_active';
export type SearchSourceSnapshot =
| { kind: 'thread'; provider: 'local'; enabled: true }
| { kind: 'memory'; provider: 'local' | 'api'; enabled: boolean; queryRewriteEnabled?: boolean }
| { kind: 'activity'; provider: 'local'; enabled: boolean; permissionRequired: true }
| { kind: 'tool'; provider: 'local' | 'api'; enabled: boolean }
| { kind: 'web'; provider: SearchProviderKind; enabled: boolean; hasCredentials?: boolean }
| {
kind: 'web_fetch';
provider: 'browser_scrape' | 'api';
enabled: boolean;
authenticatedBrowser?: boolean;
};
export interface SearchRequest {
source: SearchSourceKind;
query: string;
limit: number;
allowedDomains?: string[];
blockedDomains?: string[];
includeMarkdown?: boolean;
refresh?: boolean;
}
export interface WebFetchRequest {
source: 'web_fetch';
url: string;
prompt?: string;
maxBytes: number;
refresh?: boolean;
}
/**
* Optional navigation target for a `SearchResult`.
*
* PR-SEARCH-1.5 (@xuan msg `772d8198`): a closed discriminated union so
* source-kind-specific identifiers (thread sessionId / turnId, future
* memory entry id, future activity timestamp range, etc.) stay typed and
* isolated. Adding a new variant is an explicit contract change.
*
* Today only `'thread'` exists. `web` / `web_fetch` results continue to
* use `SearchResult.url` for navigation; they do NOT need a `target`.
*
* Note: thread navigation deliberately does NOT use `maka://session/<id>`
* URIs — `packages/ui/src/maka-uri.ts:24` defers that scheme until a real
* session navigation contract exists. Consumers of `SearchResultTarget`
* route via the existing renderer-side session-pane state (sessionId →
* load session, turnId → scroll-into-view), NOT via a URL router.
*/
export type SearchResultTarget = { kind: 'thread'; sessionId: string; turnId?: string };
export interface SearchResult {
source: SearchSourceKind;
citationIndex?: number;
title: string;
url?: string;
/**
* Closed-union navigation target. Populated for source kinds whose
* navigation does NOT map to a URL — currently only `thread`. Future
* memory/activity variants extend this union without polluting the
* top-level shape.
*/
target?: SearchResultTarget;
snippet?: string;
summary?: string;
markdown?: string;
fetchedAt?: string;
cachedAt?: string;
truncated?: boolean;
wordCount?: number;
errorReason?: SearchErrorReason;
}
export interface SearchError {
ok: false;
reason: SearchErrorReason;
message: string;
}
export interface SearchOk<T> {
ok: true;
value: T;
}
export type SearchNormalizeResult<T> = SearchOk<T> | SearchError;
export function normalizeSearchQuery(input: unknown): SearchNormalizeResult<string> {
if (typeof input !== 'string') {
return invalid('invalid_query', 'Search query must be a string');
}
const value = input.trim();
if (value.length === 0) {
return invalid('invalid_query', 'Search query cannot be empty');
}
if (Array.from(value).length > SEARCH_QUERY_MAX_CHARS) {
return invalid(
'invalid_query',
`Search query must be ${SEARCH_QUERY_MAX_CHARS} characters or fewer`,
);
}
return { ok: true, value };
}
export function normalizeSearchLimit(
input: unknown,
options: { defaultValue?: number; max?: number } = {},
): SearchNormalizeResult<number> {
const defaultValue = options.defaultValue ?? SEARCH_DEFAULT_LIMIT;
const max = options.max ?? SEARCH_MAX_LIMIT;
if (input === undefined || input === null || input === '') {
return { ok: true, value: defaultValue };
}
if (typeof input !== 'number' || !Number.isFinite(input)) {
return invalid('invalid_query', 'Search limit must be a finite number');
}
const value = Math.trunc(input);
if (value < 1) {
return invalid('invalid_query', 'Search limit must be at least 1');
}
return { ok: true, value: Math.min(value, max) };
}
export function normalizeSearchDomain(input: unknown): SearchNormalizeResult<string> {
if (typeof input !== 'string') {
return invalid('invalid_domain', 'Search domain must be a string');
}
const trimmed = input.trim();
if (trimmed.length === 0) {
return invalid('invalid_domain', 'Search domain cannot be empty');
}
if (trimmed.length > SEARCH_DOMAIN_MAX_CHARS) {
return invalid(
'invalid_domain',
`Search domain must be ${SEARCH_DOMAIN_MAX_CHARS} characters or fewer`,
);
}
try {
const hostname = (
trimmed.includes('://') ? new URL(trimmed) : new URL(`https://${trimmed}`)
).hostname
.toLowerCase()
.replace(/\.$/, '');
if (!hostname || hostname.includes('..')) {
return invalid('invalid_domain', 'Search domain is invalid');
}
return { ok: true, value: hostname.startsWith('www.') ? hostname.slice(4) : hostname };
} catch {
return invalid('invalid_domain', 'Search domain is invalid');
}
}
export function normalizeSearchDomainList(input: unknown): SearchNormalizeResult<string[]> {
if (input === undefined || input === null) {
return { ok: true, value: [] };
}
if (!Array.isArray(input)) {
return invalid('invalid_domain', 'Search domains must be an array');
}
const domains: string[] = [];
const seen = new Set<string>();
for (const item of input) {
const result = normalizeSearchDomain(item);
if (!result.ok) {
return result;
}
if (!seen.has(result.value)) {
seen.add(result.value);
domains.push(result.value);
}
}
return { ok: true, value: domains };
}
export function searchDomainMatches(hostname: string, domains: readonly string[]): boolean {
const normalized = normalizeSearchDomain(hostname);
if (!normalized.ok) {
return false;
}
return domains.some(
(domain) => normalized.value === domain || normalized.value.endsWith(`.${domain}`),
);
}
export function normalizeSearchUrl(input: unknown): SearchNormalizeResult<string> {
if (typeof input !== 'string') {
return invalid('invalid_url', 'Search URL must be a string');
}
const trimmed = input.trim();
if (trimmed.length === 0) {
return invalid('invalid_url', 'Search URL cannot be empty');
}
if (trimmed.length > SEARCH_URL_MAX_CHARS) {
return invalid('invalid_url', `Search URL must be ${SEARCH_URL_MAX_CHARS} characters or fewer`);
}
let url: URL;
try {
url = new URL(trimmed);
} catch {
return invalid('invalid_url', 'Search URL is invalid');
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return invalid('blocked_scheme', 'Search URL must use http or https');
}
return { ok: true, value: stripSearchTrackingParams(url).toString() };
}
export function stripSearchTrackingParams(url: URL): URL {
const next = new URL(url.toString());
for (const key of Array.from(next.searchParams.keys())) {
const lower = key.toLowerCase();
if (
TRACKING_PARAM_NAMES.has(lower) ||
TRACKING_PARAM_PREFIXES.some((prefix) => lower.startsWith(prefix))
) {
next.searchParams.delete(key);
}
}
return next;
}
export function rewriteSearchQueryForFreshness(query: string, now: Date): string {
const trimmed = query.trim();
if (!hasFreshnessIntent(trimmed) || hasHistoricalIntent(trimmed)) {
return trimmed;
}
const year = String(now.getFullYear());
const yearPattern = /\b(?:19|20)\d{2}\b/g;
const years: string[] = trimmed.match(yearPattern) ?? [];
if (years.length === 0) {
return `${trimmed} ${year}`;
}
if (years.includes(year)) {
return trimmed;
}
return trimmed.replace(yearPattern, year);
}
function hasFreshnessIntent(query: string): boolean {
return /\b(today|latest|this year|current year|now|recent|breaking)\b|今天|最新|今年|当前年份|最近|近日/i.test(
query,
);
}
function hasHistoricalIntent(query: string): boolean {
return /\b(history|historical|archive|from|since|during|between|retrospective)\b|历史|回顾|档案|过去|往年/i.test(
query,
);
}
function invalid(reason: SearchErrorReason, message: string): SearchError {
return { ok: false, reason, message };
}