| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| /** |
| * Local thread / session search — bounded scan, no FTS5. |
| * |
| * Anchors: |
| * - Current behavior is pinned by the focused thread-search tests. |
| * - Contract: `@maka/core/search` (PR-SEARCH-0 + PR-SEARCH-1.5 `SearchResultTarget`). |
| * - Implementation lane greenlight: xuan msg `074714c7`. |
| * |
| * Scope (this module, PR-SEARCH-2): |
| * - Pure helper. Accepts an injected `ThreadSearchDeps` so unit tests can |
| * supply fake `listSessions` / `readMessages` without an Electron runtime. |
| * - Bounded substring scan over user-visible message types only: |
| * UserMessage / AssistantMessage / ToolCallMessage / ToolResultMessage. |
| * Excluded: SystemNoteMessage / TokenUsageMessage / TurnStateMessage / |
| * PermissionDecisionMessage. |
| * - Excludes sessions with `backend === 'fake'` (retired local simulation, |
| * plus the e2e fixtures that still seed it). Desktop also excludes archived |
| * sessions; Agent global history opts in to them explicitly. |
| * - Snippets are redacted via `@maka/core/redaction.redactSecrets()`. |
| * - `ToolResultMessage.content` is JSON-serialized for scan and capped to |
| * the first `TOOL_RESULT_SCAN_CAP_BYTES` bytes (worst-case bound). |
| * - Result limits come from `@maka/core/search.normalizeSearchLimit` |
| * (default 5, max `SEARCH_MAX_LIMIT=10`). |
| * - Total payload bytes (sum of snippets) capped at `TOTAL_PAYLOAD_CAP_BYTES`. |
| * - Per-result snippet capped at `SNIPPET_MAX_CODE_POINTS`. |
| * - Returns a success envelope containing `SearchResult[]` plus an explicit |
| * scan-truncation bit, with each result following the PR-SEARCH-0 shape and |
| * `source: 'thread'` and `target: { kind:'thread', sessionId, turnId? }` |
| * per PR-SEARCH-1.5, extended with stable message id, match kind, and |
| * timestamp anchors for Agent global search. `url` is left undefined |
| * (thread navigation does NOT use `maka://session`). |
| * |
| * Hard no-go (enforced by source gate at review): |
| * - No `fetch` / `XMLHttpRequest` / `new WebSocket` / `BrowserWindow`. |
| * - No `electron` imports — runs in main but stays Electron-agnostic via DI. |
| * - No FTS5 / SQLite / better-sqlite3. |
| * - No telemetry emission of query body. |
| * - No `maka://session` URI construction. |
| */ |
| |
| import { validateWorkspacePrivacyContext } from './incognito.js'; |
| import { redactSecrets } from './redaction.js'; |
| import { normalizeSearchLimit, normalizeSearchQuery } from './search.js'; |
| import type { SearchErrorReason, SearchResult, ThreadSearchMatchKind } from './search.js'; |
| import { collapseSessionRevisions } from './session-revisions.js'; |
| import type { SessionSummary, StoredMessage } from './session.js'; |
| |
| /** Max scan bytes per ToolResultMessage.content (JSON-serialized). */ |
| export const TOOL_RESULT_SCAN_CAP_BYTES = 10_240; |
| |
| /** Max code points retained in a result snippet. */ |
| export const SNIPPET_MAX_CODE_POINTS = 240; |
| |
| /** Half-window of snippet context characters on each side of the match. */ |
| export const SNIPPET_CONTEXT_HALF = 80; |
| |
| /** Cap on total snippet bytes (UTF-8) summed across all results. */ |
| export const TOTAL_PAYLOAD_CAP_BYTES = 64 * 1024; |
| |
| /** Max sessions scanned per query (newest first by lastMessageAt). */ |
| export const MAX_SESSIONS_SCANNED = 200; |
| |
| /** Max encoded bytes accepted for an opaque thread-search continuation. */ |
| export const THREAD_SEARCH_CURSOR_MAX_CHARS = 2_048; |
| |
| /** Returned source kind — locked to `'thread'` in v1. */ |
| export const THREAD_SOURCE = 'thread' as const; |
| |
| /** |
| * Pure dependency injection. Production wiring binds these to the real |
| * runtime; tests pass in-memory fakes. |
| * |
| * PR-SEARCH-2.5 (@xuan msg `2c55b975`): `getPrivacyContext` returns the |
| * Host-authority workspace privacy snapshot. Source is `unknown` |
| * because even though production wiring controls it, the helper |
| * itself MUST validate via `validateWorkspacePrivacyContext` — a |
| * future swap to a real authority (settings IPC etc.) must not bypass |
| * the validator. Renderer payloads MUST NOT reach this dep; production |
| * wiring binds it to a main-side authority only. |
| */ |
| export interface ThreadSearchDeps { |
| listSessions(): Promise<SessionSummary[]>; |
| readMessages(sessionId: string, abortSignal?: AbortSignal): Promise<StoredMessage[] | null>; |
| /** |
| * Host-authority workspace privacy snapshot. Returned as `unknown` |
| * deliberately — the helper validates the payload with |
| * `validateWorkspacePrivacyContext` before reading any field. Source |
| * MUST be Host-side (Runtime Host policy, Desktop settings authority, |
| * or workspace owner). Untrusted request payloads MUST NOT flow into this dep. |
| */ |
| getPrivacyContext(): Promise<unknown>; |
| } |
| |
| export interface ThreadSearchSuccess { |
| readonly ok: true; |
| readonly results: SearchResult[]; |
| readonly truncated: boolean; |
| /** Present only when another complete session-scan page is reachable. */ |
| readonly nextCursor?: string; |
| } |
| |
| interface ThreadSearchCursor { |
| readonly version: 1; |
| readonly query: string; |
| readonly lastMessageAt: number; |
| readonly sessionId: string; |
| } |
| |
| /** |
| * Shared API surface. Desktop IPC and Runtime Host Agent tools wrap this |
| * helper with their own authority-owned dependencies. |
| * |
| * Accepts `unknown` because the IPC payload crosses a process boundary — |
| * TypeScript's `SearchRequest` annotation in the handler is compile-time |
| * only. A renderer can send anything; malformed input must fail closed |
| * with an error envelope. Dependency adapters project ordinary I/O |
| * failures before calling this function. Same defense pattern as PR-MEMORY-1 |
| * `validateMemoryWriteRequest` and PR-UI-IPC-1 baseUrl normalize |
| * (@xuan msg `2f1aba55` fixup). |
| */ |
| export async function runThreadSearch( |
| request: unknown, |
| deps: ThreadSearchDeps, |
| options: { |
| readonly activeSessionId?: string; |
| readonly excludeSessionIds?: ReadonlySet<string>; |
| /** Desktop excludes archived tasks by default; Agent global history opts in explicitly. */ |
| readonly includeArchived?: boolean; |
| /** Keeps Agent global search from matching the user/tool text of its active turn. */ |
| readonly excludeTurnIds?: ReadonlySet<string>; |
| readonly abortSignal?: AbortSignal; |
| } = {}, |
| ): Promise<ThreadSearchSuccess | { ok: false; reason: SearchErrorReason; message: string }> { |
| if (options.abortSignal?.aborted) return abortedSearch(); |
| // L1: runtime shape guard. Renderer payload is untrusted across the |
| // IPC boundary. Null / non-object / missing fields → typed reject. |
| if (typeof request !== 'object' || request === null || Array.isArray(request)) { |
| return { ok: false, reason: 'invalid_query', message: 'search request must be an object' }; |
| } |
| const record = request as Record<string, unknown>; |
| |
| // L2: source enum gate — this module only handles `'thread'`. The |
| // shape check above already rejected non-objects, so reading |
| // `record.source` is safe. |
| if (record.source !== THREAD_SOURCE) { |
| return { ok: false, reason: 'disabled', message: 'thread search only handles source=thread' }; |
| } |
| |
| // L3: query / limit normalization via @maka/core helpers — single |
| // chokepoint, never bypass. Both already guard typeof + finite. |
| const queryResult = normalizeSearchQuery(record.query); |
| if (!queryResult.ok) { |
| return queryResult; |
| } |
| const limitResult = normalizeSearchLimit(record.limit); |
| if (!limitResult.ok) { |
| return limitResult; |
| } |
| |
| // Matching a secret-shaped query against raw history would expose a |
| // hit/no-hit membership oracle even if the returned snippet were redacted. |
| // Reject such queries before touching the history authority, and match every |
| // searchable field only after applying the same redaction projection. |
| const redactedQuery = redactSecrets(queryResult.value); |
| if (redactedQuery !== queryResult.value) { |
| return { |
| ok: false, |
| reason: 'invalid_query', |
| message: 'Search query contains credential material and cannot be searched.', |
| }; |
| } |
| const queryFolded = foldForMatch(redactedQuery); |
| const cursorResult = decodeThreadSearchCursor(record.cursor, queryFolded); |
| if (!cursorResult.ok) return cursorResult; |
| |
| // L4: privacy gate (PR-SEARCH-2.5 @xuan `2c55b975`). Host-owned |
| // privacy authority. Two early-return paths share the same |
| // `reason:'incognito_active'` to avoid an extra UI state: |
| // - active incognito (user toggled on): `incognitoActive === true` |
| // - malformed authority payload (system fail-closed): validator |
| // reject treated as if incognito were active |
| // Both paths MUST NOT touch `listSessions` / `readMessages`. |
| // Distinguishing message wording is kept for diagnostics; consumers |
| // can read `message` if they need to differentiate. |
| const privacyPayload = await deps.getPrivacyContext(); |
| if (options.abortSignal?.aborted) return abortedSearch(); |
| const privacyResult = validateWorkspacePrivacyContext(privacyPayload); |
| if (!privacyResult.ok) { |
| return { |
| ok: false, |
| reason: 'incognito_active', |
| message: 'Search is disabled because workspace privacy state could not be verified.', |
| }; |
| } |
| if (privacyResult.value.incognitoActive) { |
| return { |
| ok: false, |
| reason: 'incognito_active', |
| message: 'Search is disabled while incognito is active.', |
| }; |
| } |
| |
| const maxResults = limitResult.value; |
| |
| const eligibleSessions = collapseSessionRevisions( |
| await deps.listSessions(), |
| options.activeSessionId, |
| ) |
| // Exclude fake-backend sessions. The rail still shows them (marked stale) |
| // because they are task records, but their transcripts are simulator output; |
| // returning fabricated text as a hit on the user's own history is worse than |
| // returning nothing. Retiring the backend (#3211) did not make that content |
| // real, so the filter stays. |
| .filter( |
| (session) => |
| session.backend !== 'fake' && |
| (options.includeArchived === true || !session.isArchived) && |
| !options.excludeSessionIds?.has(session.id), |
| ) |
| // Newest first by lastMessageAt; secondary by id for determinism. |
| .sort((a, b) => { |
| const ts = (b.lastMessageAt ?? 0) - (a.lastMessageAt ?? 0); |
| if (ts !== 0) return ts; |
| return a.id.localeCompare(b.id); |
| }); |
| if (options.abortSignal?.aborted) return abortedSearch(); |
| const remainingSessions = cursorResult.value |
| ? eligibleSessions.filter((session) => sessionIsAfterCursor(session, cursorResult.value!)) |
| : eligibleSessions; |
| const sessions = remainingSessions.slice(0, MAX_SESSIONS_SCANNED); |
| const hasMoreSessions = remainingSessions.length > sessions.length; |
| |
| const results: SearchResult[] = []; |
| let totalBytes = 0; |
| let truncated = hasMoreSessions; |
| let scannedCompletePage = true; |
| |
| sessionScan: for (const session of sessions) { |
| if (options.abortSignal?.aborted) return abortedSearch(); |
| if (results.length >= maxResults) { |
| truncated = true; |
| scannedCompletePage = false; |
| break; |
| } |
| |
| const searchableTitle = redactSecrets(session.name); |
| const titleHit = findMatch(searchableTitle, queryFolded); |
| if (titleHit !== undefined) { |
| const snippet = capCodePoints( |
| buildSnippet(searchableTitle, titleHit, SNIPPET_CONTEXT_HALF), |
| SNIPPET_MAX_CODE_POINTS, |
| ); |
| const snippetBytes = Buffer.byteLength(snippet, 'utf8'); |
| if (totalBytes + snippetBytes > TOTAL_PAYLOAD_CAP_BYTES) { |
| truncated = true; |
| scannedCompletePage = false; |
| break; |
| } |
| totalBytes += snippetBytes; |
| results.push({ |
| source: THREAD_SOURCE, |
| title: searchableTitle, |
| summary: '任务标题', |
| snippet, |
| target: { |
| kind: 'thread', |
| sessionId: session.id, |
| matchKind: 'session_title', |
| }, |
| }); |
| if (results.length >= maxResults) { |
| truncated = true; |
| scannedCompletePage = false; |
| break; |
| } |
| } |
| |
| const messages = await deps.readMessages(session.id, options.abortSignal); |
| if (options.abortSignal?.aborted) return abortedSearch(); |
| if (!messages) continue; |
| |
| for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) { |
| if (messageIndex > 0 && messageIndex % 256 === 0) { |
| await new Promise<void>((resolve) => setImmediate(resolve)); |
| } |
| if (options.abortSignal?.aborted) return abortedSearch(); |
| const message = messages[messageIndex]!; |
| if (results.length >= maxResults) { |
| truncated = true; |
| scannedCompletePage = false; |
| break sessionScan; |
| } |
| |
| const turnId = (message as { turnId?: string }).turnId; |
| if (session.id === options.activeSessionId && turnId && options.excludeTurnIds?.has(turnId)) { |
| continue; |
| } |
| |
| const rawCandidate = collectSearchableText(message); |
| if (rawCandidate === undefined) continue; |
| const candidate = redactSecrets(rawCandidate); |
| |
| const hit = findMatch(candidate, queryFolded); |
| if (hit === undefined) continue; |
| |
| // Build the snippet, redact secrets, cap length. |
| const snippet = capCodePoints( |
| redactSecrets(buildSnippet(candidate, hit, SNIPPET_CONTEXT_HALF)), |
| SNIPPET_MAX_CODE_POINTS, |
| ); |
| |
| const snippetBytes = Buffer.byteLength(snippet, 'utf8'); |
| if (totalBytes + snippetBytes > TOTAL_PAYLOAD_CAP_BYTES) { |
| truncated = true; |
| scannedCompletePage = false; |
| break sessionScan; |
| } |
| totalBytes += snippetBytes; |
| |
| results.push({ |
| source: THREAD_SOURCE, |
| title: redactSecrets(session.name), |
| summary: formatSearchResultSummary(message), |
| snippet, |
| // PR-SEARCH-1.5: navigation target via discriminated union; no |
| // `url` field for thread results (maka://session is deferred). |
| target: { |
| kind: 'thread', |
| sessionId: session.id, |
| ...(turnId ? { turnId } : {}), |
| sequence: messageIndex, |
| messageId: message.id, |
| matchKind: threadSearchMatchKind(message), |
| messageTimestamp: message.ts, |
| }, |
| }); |
| } |
| } |
| |
| if (truncated && results.length > 0) { |
| results[results.length - 1] = { ...results[results.length - 1]!, truncated: true }; |
| } |
| |
| const lastSession = sessions.at(-1); |
| const nextCursor = |
| hasMoreSessions && scannedCompletePage && lastSession |
| ? encodeThreadSearchCursor({ |
| version: 1, |
| query: queryFolded, |
| lastMessageAt: sessionSortTime(lastSession), |
| sessionId: lastSession.id, |
| }) |
| : undefined; |
| return { ok: true, results, truncated, ...(nextCursor ? { nextCursor } : {}) }; |
| } |
| |
| function abortedSearch(): { ok: false; reason: 'aborted'; message: string } { |
| return { ok: false, reason: 'aborted', message: 'History search was aborted.' }; |
| } |
| |
| function decodeThreadSearchCursor( |
| input: unknown, |
| query: string, |
| ): |
| | { readonly ok: true; readonly value: ThreadSearchCursor | undefined } |
| | { readonly ok: false; readonly reason: 'invalid_query'; readonly message: string } { |
| if (input === undefined) return { ok: true, value: undefined }; |
| if ( |
| typeof input !== 'string' || |
| input.length === 0 || |
| input.length > THREAD_SEARCH_CURSOR_MAX_CHARS || |
| input.trim() !== input |
| ) { |
| return invalidThreadSearchCursor(); |
| } |
| try { |
| const decoded = Buffer.from(input, 'base64url').toString('utf8'); |
| if (Buffer.from(decoded, 'utf8').toString('base64url') !== input) { |
| return invalidThreadSearchCursor(); |
| } |
| const value: unknown = JSON.parse(decoded); |
| if (!value || typeof value !== 'object' || Array.isArray(value)) { |
| return invalidThreadSearchCursor(); |
| } |
| const cursor = value as Record<string, unknown>; |
| if ( |
| cursor.version !== 1 || |
| cursor.query !== query || |
| typeof cursor.lastMessageAt !== 'number' || |
| !Number.isFinite(cursor.lastMessageAt) || |
| typeof cursor.sessionId !== 'string' || |
| cursor.sessionId.length === 0 || |
| cursor.sessionId.length > 256 |
| ) { |
| return invalidThreadSearchCursor(); |
| } |
| return { |
| ok: true, |
| value: { |
| version: 1, |
| query, |
| lastMessageAt: cursor.lastMessageAt, |
| sessionId: cursor.sessionId, |
| }, |
| }; |
| } catch { |
| return invalidThreadSearchCursor(); |
| } |
| } |
| |
| function invalidThreadSearchCursor() { |
| return { |
| ok: false as const, |
| reason: 'invalid_query' as const, |
| message: 'Search cursor is invalid or belongs to another query.', |
| }; |
| } |
| |
| function encodeThreadSearchCursor(cursor: ThreadSearchCursor): string { |
| return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url'); |
| } |
| |
| function sessionSortTime(session: SessionSummary): number { |
| return session.lastMessageAt ?? 0; |
| } |
| |
| function sessionIsAfterCursor(session: SessionSummary, cursor: ThreadSearchCursor): boolean { |
| const timestamp = sessionSortTime(session); |
| return ( |
| timestamp < cursor.lastMessageAt || |
| (timestamp === cursor.lastMessageAt && session.id.localeCompare(cursor.sessionId) > 0) |
| ); |
| } |
| |
| /** Stable result classification shared by Desktop navigation and Agent tools. */ |
| export function threadSearchMatchKind(message: StoredMessage): ThreadSearchMatchKind { |
| switch (message.type) { |
| case 'user': |
| return 'user_message'; |
| case 'assistant': |
| return 'assistant_message'; |
| case 'tool_call': |
| return 'tool_intent'; |
| case 'tool_result': |
| return 'tool_result'; |
| case 'permission_decision': |
| case 'token_usage': |
| case 'turn_state': |
| case 'workhub_coordination': |
| case 'system_note': |
| throw new Error(`Message type ${message.type} is not searchable`); |
| } |
| } |
| |
| export function formatSearchResultSummary(message: StoredMessage): string { |
| switch (message.type) { |
| case 'user': |
| return '用户消息'; |
| case 'assistant': |
| return '助手回复'; |
| case 'tool_call': |
| return message.displayName |
| ? `工具调用:${message.displayName}` |
| : `工具调用:${message.toolName}`; |
| case 'tool_result': |
| return message.isError ? '工具结果:失败' : '工具结果:成功'; |
| case 'permission_decision': |
| return '权限记录'; |
| case 'token_usage': |
| return '用量记录'; |
| case 'turn_state': |
| return '回合状态'; |
| case 'workhub_coordination': |
| return 'WorkHub 协调记录'; |
| case 'system_note': |
| return '系统记录'; |
| } |
| } |
| |
| /** |
| * Extract user-visible answer text from a stored message. Returns `undefined` |
| * for excluded message kinds (system notes, token usage, turn state, |
| * permission decisions). This is the only "what counts as searchable |
| * transcript content" gate; adding new searchable surfaces requires |
| * extending this switch + a corresponding test. |
| * |
| * For ToolResultMessage, the `content` is JSON-serialized and capped |
| * at `TOOL_RESULT_SCAN_CAP_BYTES` so a 100 MB tool result doesn't |
| * inflate scan time. |
| */ |
| export function collectSearchableText(message: StoredMessage): string | undefined { |
| switch (message.type) { |
| case 'user': |
| // Prefer the human-facing view so skill-invocation envelopes do not |
| // dominate local search hits for what the user actually typed. |
| return message.displayText ?? message.text; |
| case 'assistant': |
| // Search result snippets are a transcript surface. Assistant |
| // reasoning/thinking may be rendered separately in the live chat, |
| // but it is not answer text and must not leak into local search. |
| return message.text; |
| case 'tool_call': |
| // PR-SEARCH-2 review fixup (@xuan `2f1aba55`): index ONLY |
| // `intent` — the user-visible description of what the tool call |
| // is doing. `toolName` (e.g. `Bash`) and `displayName` are |
| // internal labels and would let searches for `Bash` match every |
| // bash invocation regardless of intent. The PR-SEARCH-1 plan |
| // already locked `intent` as the only searchable field on |
| // `ToolCallMessage`; the previous draft over-indexed by mistake. |
| return message.intent && message.intent.length > 0 ? message.intent : undefined; |
| case 'tool_result': { |
| // Bounded JSON-serialize. The cap protects against pathological |
| // multi-MB tool outputs (file dumps, etc.). |
| let serialized: string; |
| try { |
| serialized = JSON.stringify(message.content); |
| } catch { |
| return undefined; |
| } |
| if (Buffer.byteLength(serialized, 'utf8') > TOOL_RESULT_SCAN_CAP_BYTES) { |
| // Truncate to the cap. Use byte-safe slice via Buffer. |
| const buf = Buffer.from(serialized, 'utf8').subarray(0, TOOL_RESULT_SCAN_CAP_BYTES); |
| return buf.toString('utf8'); |
| } |
| return serialized; |
| } |
| case 'permission_decision': |
| case 'token_usage': |
| case 'turn_state': |
| case 'workhub_coordination': |
| case 'system_note': |
| // Coordination records are rendered by WorkHub, but the reserved |
| // Coordination Session is intentionally outside general thread search. |
| // The remaining cases are not user-typed / not user-visible content. |
| return undefined; |
| } |
| } |
| |
| /** |
| * NFC + lowercase canonicalization for substring match. NOT a security |
| * boundary — purely for case-insensitive + composed-form matching. |
| * |
| * Public for tests; production callers use `runThreadSearch` only. |
| */ |
| export function foldForMatch(value: string): string { |
| return value.normalize('NFC').toLowerCase(); |
| } |
| |
| /** |
| * Find the index of the first occurrence of `queryFolded` in `text` |
| * (after the same fold operation). Returns the index in the original |
| * (unfolded) text — JS `String.prototype.toLowerCase()` preserves |
| * code-point indexing for ASCII and most CJK, which is what we need |
| * for snippet extraction. Returns `undefined` on no match. |
| */ |
| export function findMatch(text: string, queryFolded: string): number | undefined { |
| const folded = foldForMatch(text); |
| const idx = folded.indexOf(queryFolded); |
| return idx >= 0 ? idx : undefined; |
| } |
| |
| /** |
| * Extract a context window around the match. Pure substring + ellipsis |
| * marker; no HTML, no markup. Caller is responsible for redaction + |
| * length cap afterward. |
| */ |
| export function buildSnippet(text: string, matchIndex: number, halfWindow: number): string { |
| const start = Math.max(0, matchIndex - halfWindow); |
| const end = Math.min(text.length, matchIndex + halfWindow); |
| const prefix = start > 0 ? '…' : ''; |
| const suffix = end < text.length ? '…' : ''; |
| return prefix + text.slice(start, end).replace(/\s+/g, ' ').trim() + suffix; |
| } |
| |
| /** |
| * Cap a string to at most `maxCodePoints` code points. Uses |
| * `Array.from` so surrogate pairs (emoji) are not split. Appends |
| * an ellipsis when truncated. |
| */ |
| export function capCodePoints(value: string, maxCodePoints: number): string { |
| const codePoints = Array.from(value); |
| if (codePoints.length <= maxCodePoints) return value; |
| return codePoints.slice(0, maxCodePoints - 1).join('') + '…'; |
| } |