diff --git a/apps/desktop/src/main/__tests__/thread-search.test.ts b/apps/desktop/src/main/__tests__/thread-search.test.ts new file mode 100644 index 0000000000..4f1efd1213 --- /dev/null +++ b/apps/desktop/src/main/__tests__/thread-search.test.ts @@ -0,0 +1,632 @@ +/* + * 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. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import type { SessionSummary, StoredMessage } from '@maka/core/session'; +import { + SNIPPET_MAX_CODE_POINTS, + TOOL_RESULT_SCAN_CAP_BYTES, + capCodePoints, + collectSearchableText, + findMatch, + foldForMatch, + runThreadSearch, +} from '@maka/core/thread-search'; + +type Entry = { session: SessionSummary; messages: StoredMessage[] }; +type SearchOutcome = Awaited>; + +function session(overrides: Partial & { id: string }): SessionSummary { + return { + name: overrides.id, + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'ai-sdk', + llmConnectionSlug: 'anthropic', + connectionLocked: false, + model: 'claude-sonnet-4-5-20250929', + permissionMode: 'ask', + lastMessageAt: 1_700_000_000_000, + ...overrides, + }; +} + +function userMessage(text: string, turnId = 't1', id = 'u1'): StoredMessage { + return { type: 'user', id, turnId, ts: 1_700_000_000_000, text }; +} + +function assistantMessage( + text: string, + thinking?: string, + turnId = 't1', +): Extract { + return { + type: 'assistant', + id: 'a1', + turnId, + ts: 1_700_000_000_000, + text, + modelId: 'glm-4.7', + ...(thinking ? { thinking: { text: thinking } } : {}), + }; +} + +function toolCall(intent?: string): Extract { + return { + type: 'tool_call', + id: 'tc1', + turnId: 't1', + ts: 1_700_000_000_000, + toolName: 'Bash', + displayName: 'Shell command', + intent, + args: {}, + }; +} + +function toolResult(content: unknown, isError = false): Extract { + return { + type: 'tool_result', + id: 'tr1', + turnId: 't1', + ts: 1_700_000_000_000, + toolUseId: 'call1', + isError, + content: content as never, + }; +} + +function makeDeps(entries: Record, privacyPayload: unknown = { incognitoActive: false }) { + return { + async listSessions() { + return Object.values(entries).map((entry) => entry.session); + }, + async readMessages(sessionId: string) { + return entries[sessionId]?.messages ?? []; + }, + async getPrivacyContext() { + return privacyPayload; + }, + }; +} + +function expectResults(outcome: SearchOutcome) { + if (!outcome.ok) assert.fail(`expected results, got ${outcome.reason}`); + return outcome.results; +} + +describe('runThreadSearch', () => { + it('fails closed for malformed requests and unsupported sources', async () => { + const cases: Array<[unknown, 'invalid_query' | 'disabled']> = [ + [null, 'invalid_query'], + [undefined, 'invalid_query'], + ['hello', 'invalid_query'], + [[], 'invalid_query'], + [{ query: 'hello', limit: 5 }, 'disabled'], + [{ source: 'web', query: 'hello', limit: 5 }, 'disabled'], + [{ source: 'thread', limit: 5 }, 'invalid_query'], + [{ source: 'thread', query: 42, limit: 5 }, 'invalid_query'], + [{ source: 'thread', query: ' ', limit: 5 }, 'invalid_query'], + ]; + for (const [request, reason] of cases) { + const outcome = await runThreadSearch(request, makeDeps({})); + assert.equal(outcome.ok, false); + if (!outcome.ok) assert.equal(outcome.reason, reason); + } + }); + + it('clamps results to the shared maximum and marks truncation', async () => { + const entries: Record = {}; + for (let index = 0; index < 15; index++) { + const id = `s${String(index).padStart(2, '0')}`; + entries[id] = { + session: session({ id, lastMessageAt: 1_700_000_000_000 - index }), + messages: [userMessage('hello world')], + }; + } + const hits = expectResults( + await runThreadSearch({ source: 'thread', query: 'hello', limit: 50 }, makeDeps(entries)), + ); + assert.equal(hits.length, 10); + assert.equal(hits.at(-1)?.truncated, true); + }); + + it('continues beyond the session scan ceiling without gaps', async () => { + const entries: Record = {}; + for (let index = 0; index < 201; index += 1) { + const id = `session-${String(index).padStart(3, '0')}`; + entries[id] = { + // The stable id tie-breaker is part of the cursor contract. + session: session({ id, lastMessageAt: 10_000 }), + messages: index === 200 ? [userMessage('only-oldest-match')] : [], + }; + } + const first = await runThreadSearch( + { source: 'thread', query: 'only-oldest-match', limit: 5 }, + makeDeps(entries), + ); + assert.equal(first.ok, true); + if (!first.ok) return; + assert.deepEqual(first.results, []); + assert.equal(first.truncated, true); + assert.equal(typeof first.nextCursor, 'string'); + + const second = await runThreadSearch( + { + source: 'thread', + query: 'only-oldest-match', + limit: 5, + cursor: first.nextCursor, + }, + makeDeps(entries), + ); + assert.equal(second.ok, true); + if (!second.ok) return; + assert.deepEqual( + second.results.map((result) => + result.target?.kind === 'thread' ? result.target.sessionId : undefined, + ), + ['session-200'], + ); + assert.equal(second.truncated, false); + assert.equal(second.nextCursor, undefined); + + const mismatched = await runThreadSearch( + { source: 'thread', query: 'another-query', limit: 5, cursor: first.nextCursor }, + makeDeps(entries), + ); + assert.equal(mismatched.ok, false); + if (!mismatched.ok) assert.equal(mismatched.reason, 'invalid_query'); + }); + + it('checks cancellation between transcript reads', async () => { + const controller = new AbortController(); + let reads = 0; + const outcome = await runThreadSearch( + { source: 'thread', query: 'needle', limit: 5 }, + { + ...makeDeps({ + newest: { session: session({ id: 'newest', lastMessageAt: 2 }), messages: [] }, + older: { + session: session({ id: 'older', lastMessageAt: 1 }), + messages: [userMessage('needle')], + }, + }), + async readMessages(sessionId, signal) { + reads += 1; + assert.equal(signal, controller.signal); + if (sessionId === 'newest') controller.abort(); + return []; + }, + }, + { abortSignal: controller.signal }, + ); + assert.equal(outcome.ok, false); + if (!outcome.ok) assert.equal(outcome.reason, 'aborted'); + assert.equal(reads, 1); + }); + + it('yields to cancellation while scanning a large transcript', async () => { + const controller = new AbortController(); + const messages = Array.from({ length: 2_000 }, (_, index) => + userMessage(`ordinary message ${index}`, `turn-${index}`, `message-${index}`), + ); + setImmediate(() => controller.abort()); + const outcome = await runThreadSearch( + { source: 'thread', query: 'missing needle', limit: 5 }, + makeDeps({ large: { session: session({ id: 'large' }), messages } }), + { abortSignal: controller.signal }, + ); + assert.equal(outcome.ok, false); + if (!outcome.ok) assert.equal(outcome.reason, 'aborted'); + }); + + it('excludes the active Turn only inside the active Session', async () => { + const hits = expectResults( + await runThreadSearch( + { source: 'thread', query: 'copied text', limit: 10 }, + makeDeps({ + active: { + session: session({ id: 'active', lastMessageAt: 2 }), + messages: [userMessage('copied text active', 'shared-turn', 'active-message')], + }, + branch: { + session: session({ id: 'branch', lastMessageAt: 1 }), + messages: [userMessage('copied text branch', 'shared-turn', 'branch-message')], + }, + }), + { activeSessionId: 'active', excludeTurnIds: new Set(['shared-turn']) }, + ), + ); + assert.deepEqual( + hits.map((hit) => (hit.target?.kind === 'thread' ? hit.target.sessionId : undefined)), + ['branch'], + ); + }); + + it('redacts snippets and excludes fake-backend and archived sessions', async () => { + const hits = expectResults( + await runThreadSearch( + { source: 'thread', query: 'hello', limit: 5 }, + makeDeps({ + fake: { + session: session({ id: 'fake', backend: 'fake' }), + messages: [userMessage('hello from fixture')], + }, + // Archiving a task takes it out of the working set. It has no rail + // row to land on, so a hit inside it can only be opened from a + // surface that no longer exists; Settings manages it instead. + archived: { + session: session({ id: 'archived', isArchived: true }), + messages: [userMessage('hello from an archived task')], + }, + real: { + session: session({ id: 'real' }), + messages: [userMessage('hello sk-ant-test-secret-token-12345 world')], + }, + }), + ), + ); + assert.equal(hits.length, 1); + assert.equal(hits[0]?.target?.kind === 'thread' && hits[0].target.sessionId, 'real'); + assert.match(hits[0]?.snippet ?? '', /\[redacted\]/); + assert.equal(hits[0]?.snippet?.includes('sk-ant-test-secret-token-12345'), false); + }); + + it('matches only redacted projections and rejects secret-shaped queries', async () => { + const entries = { + title: { + session: session({ id: 'title', name: 'password=title-secret-value' }), + messages: [], + }, + message: { + session: session({ id: 'message' }), + messages: [userMessage('token=message-secret-value')], + }, + intent: { + session: session({ id: 'intent' }), + messages: [toolCall('api_key=intent-secret-value')], + }, + result: { + session: session({ id: 'result' }), + messages: [toolResult({ password: 'result-secret-value' })], + }, + }; + + for (const query of [ + 'title-secret-value', + 'title-wrong-value', + 'message-secret-value', + 'message-wrong-value', + 'intent-secret-value', + 'intent-wrong-value', + 'result-secret-value', + 'result-wrong-value', + ]) { + assert.deepEqual( + expectResults( + await runThreadSearch({ source: 'thread', query, limit: 10 }, makeDeps(entries)), + ), + [], + ); + } + + for (const query of [ + 'sk-ant-correctsecret12345678', + 'sk-ant-wrongsecret123456789', + ]) { + const outcome = await runThreadSearch( + { source: 'thread', query, limit: 5 }, + makeDeps(entries), + ); + assert.equal(outcome.ok, false); + if (!outcome.ok) assert.equal(outcome.reason, 'invalid_query'); + } + }); + + it('includes archived sessions only when the caller explicitly opts in', async () => { + const entries = { + archived: { + session: session({ id: 'archived', isArchived: true }), + messages: [userMessage('archived needle')], + }, + }; + assert.deepEqual( + expectResults( + await runThreadSearch( + { source: 'thread', query: 'archived needle', limit: 5 }, + makeDeps(entries), + ), + ), + [], + ); + const optedIn = expectResults( + await runThreadSearch( + { source: 'thread', query: 'archived needle', limit: 5 }, + makeDeps(entries), + { includeArchived: true }, + ), + ); + assert.equal(optedIn[0]?.target?.kind === 'thread' && optedIn[0].target.sessionId, 'archived'); + }); + + it('searches only the current committed conversation revision', async () => { + const hits = expectResults( + await runThreadSearch( + { source: 'thread', query: 'shared-match', limit: 10 }, + makeDeps({ + root: { + session: session({ id: 'root', lastMessageAt: 10 }), + messages: [userMessage('shared-match old version')], + }, + revision: { + session: session({ + id: 'revision', + revisionRootSessionId: 'root', + revisionParentSessionId: 'root', + revisionIndex: 2, + revisionState: 'committed', + lastMessageAt: 20, + }), + messages: [userMessage('shared-match current version')], + }, + preparing: { + session: session({ + id: 'preparing', + revisionRootSessionId: 'root', + revisionParentSessionId: 'revision', + revisionIndex: 3, + revisionState: 'preparing', + lastMessageAt: 30, + }), + messages: [userMessage('shared-match uncommitted version')], + }, + }), + ), + ); + assert.deepEqual( + hits.map((hit) => (hit.target?.kind === 'thread' ? hit.target.sessionId : undefined)), + ['revision'], + ); + }); + + it('returns navigable title and transcript results without synthetic URLs', async () => { + const entries = { + s1: { + session: session({ + id: 's1', + name: 'Maka roadmap sk-ant-test-secret-token-12345 planning', + }), + messages: [userMessage('diagnostic from user', 'turn-user')], + }, + }; + const titleHit = expectResults( + await runThreadSearch({ source: 'thread', query: 'roadmap', limit: 5 }, makeDeps(entries)), + )[0]!; + assert.deepEqual(titleHit.target, { + kind: 'thread', + sessionId: 's1', + matchKind: 'session_title', + }); + assert.equal(titleHit.summary, '任务标题'); + assert.equal(titleHit.url, undefined); + assert.match(titleHit.snippet ?? '', /\[redacted\]/); + assert.equal(titleHit.snippet?.includes('sk-ant-test-secret-token-12345'), false); + + const messageHit = expectResults( + await runThreadSearch({ source: 'thread', query: 'diagnostic', limit: 5 }, makeDeps(entries)), + )[0]!; + assert.deepEqual(messageHit.target, { + kind: 'thread', + sessionId: 's1', + turnId: 'turn-user', + sequence: 0, + messageId: 'u1', + matchKind: 'user_message', + messageTimestamp: 1_700_000_000_000, + }); + assert.equal(messageHit.summary, '用户消息'); + assert.equal(messageHit.url, undefined); + }); + + it('skips a transcript that its dependency could not read', async () => { + const entries = { + s1: { session: session({ id: 's1' }), messages: [] }, + }; + const deps = makeDeps(entries); + + assert.deepEqual( + expectResults( + await runThreadSearch( + { source: 'thread', query: 'diagnostic', limit: 5 }, + { ...deps, readMessages: async () => null }, + ), + ), + [], + ); + }); + + it('blocks active or unverifiable privacy state before scanning', async () => { + for (const privacyPayload of [ + { incognitoActive: true }, + null, + {}, + { incognitoActive: 'true' }, + 'invalid', + [], + ]) { + let listCalls = 0; + let readCalls = 0; + const base = makeDeps({}, privacyPayload); + const outcome = await runThreadSearch( + { source: 'thread', query: 'hello', limit: 5 }, + { + ...base, + async listSessions() { + listCalls++; + return []; + }, + async readMessages() { + readCalls++; + return []; + }, + }, + ); + assert.equal(outcome.ok, false); + if (!outcome.ok) { + assert.equal(outcome.reason, 'incognito_active'); + assert.match( + outcome.message, + privacyPayload && !Array.isArray(privacyPayload) && + typeof privacyPayload === 'object' && + (privacyPayload as { incognitoActive?: unknown }).incognitoActive === true + ? /incognito is active/ + : /could not be verified/, + ); + } + assert.deepEqual({ listCalls, readCalls }, { listCalls: 0, readCalls: 0 }); + } + }); +}); + +describe('thread search text projection', () => { + it('normalizes case and NFC and caps snippets by code point', () => { + assert.equal(foldForMatch('HELLO'), 'hello'); + assert.equal(foldForMatch('Héllo'), foldForMatch('Héllo')); + assert.equal(findMatch('hello world', foldForMatch('WORLD')), 6); + + const capped = capCodePoints(`${'a'.repeat(500)}🦊`, SNIPPET_MAX_CODE_POINTS); + assert.equal(Array.from(capped).length, SNIPPET_MAX_CODE_POINTS); + assert.ok(capped.endsWith('…')); + }); + + it('returns empty string for zero or negative cap (regression #5537)', () => { + assert.equal(capCodePoints('hello', 0), ''); + assert.equal(capCodePoints('hello', -2), ''); + assert.equal(capCodePoints('', 0), ''); + }); + + it('bounds and classifies serialized tool results', async () => { + assert.equal(collectSearchableText(toolResult({ result: 'short' })), '{"result":"short"}'); + const extracted = collectSearchableText(toolResult({ data: 'X'.repeat(100_000) })); + assert.ok(extracted); + assert.ok(Buffer.byteLength(extracted, 'utf8') <= TOOL_RESULT_SCAN_CAP_BYTES); + + const hits = expectResults( + await runThreadSearch( + { source: 'thread', query: 'short', limit: 5 }, + makeDeps({ + s1: { session: session({ id: 's1' }), messages: [toolResult({ result: 'short' })] }, + }), + ), + ); + assert.equal(hits[0]?.target?.matchKind, 'tool_result'); + assert.equal(hits[0]?.target?.messageId, 'tr1'); + }); + + it('indexes tool intent but not tool names or display names', async () => { + assert.equal(collectSearchableText(toolCall('check disk usage')), 'check disk usage'); + assert.equal(collectSearchableText(toolCall()), undefined); + + const entries = { + s1: { session: session({ id: 's1' }), messages: [toolCall('check disk usage on /var')] }, + }; + for (const query of ['Bash', 'Shell command']) { + assert.deepEqual( + expectResults( + await runThreadSearch({ source: 'thread', query, limit: 5 }, makeDeps(entries)), + ), + [], + ); + } + const hits = expectResults( + await runThreadSearch( + { source: 'thread', query: 'disk usage', limit: 5 }, + makeDeps(entries), + ), + ); + assert.equal(hits.length, 1); + assert.equal(hits[0]?.target?.matchKind, 'tool_intent'); + assert.equal(hits[0]?.target?.messageId, 'tc1'); + }); + + it('indexes assistant answers without exposing thinking', async () => { + const message = assistantMessage( + 'this is the visible answer', + 'private reasoning path: greeting me in Chinese', + ); + assert.equal(collectSearchableText(message), 'this is the visible answer'); + + const entries = { s1: { session: session({ id: 's1' }), messages: [message] } }; + assert.equal( + expectResults( + await runThreadSearch( + { source: 'thread', query: 'private reasoning', limit: 5 }, + makeDeps(entries), + ), + ).length, + 0, + ); + const visible = expectResults( + await runThreadSearch( + { source: 'thread', query: 'visible answer', limit: 5 }, + makeDeps(entries), + ), + ); + assert.equal(visible.length, 1); + assert.equal(visible[0]?.target?.matchKind, 'assistant_message'); + assert.equal(visible[0]?.target?.messageId, 'a1'); + assert.equal(visible[0]?.snippet?.includes('private reasoning'), false); + }); + + it('excludes system, token, turn-state, and permission records', () => { + const excluded: StoredMessage[] = [ + { + type: 'system_note', + id: 'sn1', + ts: 1, + kind: 'session_start', + data: { note: 'private' }, + }, + { type: 'token_usage', id: 'tk1', turnId: 't1', ts: 1, input: 1, output: 2 }, + { + type: 'turn_state', + id: 'ts1', + turnId: 't1', + ts: 1, + status: 'completed', + }, + { + type: 'permission_decision', + id: 'pd1', + turnId: 't1', + ts: 1, + toolUseId: 'call1', + toolName: 'Bash', + decision: 'allow', + }, + ]; + for (const message of excluded) assert.equal(collectSearchableText(message), undefined); + }); + +}); diff --git a/packages/core/src/thread-search.ts b/packages/core/src/thread-search.ts new file mode 100644 index 0000000000..c51dafc77b --- /dev/null +++ b/packages/core/src/thread-search.ts @@ -0,0 +1,604 @@ +/* + * 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; + readMessages(sessionId: string, abortSignal?: AbortSignal): Promise; + /** + * 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; +} + +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; + /** 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; + readonly abortSignal?: AbortSignal; + } = {}, +): Promise { + 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; + + // 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((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; + 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; + if (maxCodePoints <= 0) return ''; + return codePoints.slice(0, maxCodePoints - 1).join('') + '…'; +}