From a64c058d15c08bad12128b8138a02baafebab1bc Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Mon, 29 Jun 2026 22:10:56 +0800 Subject: [PATCH 1/4] feat: add search_context tool for searching compressed and visible content --- index.ts | 4 +- lib/compress/index.ts | 1 + lib/compress/search.ts | 124 ++++++++++++++++++++++++++++++++++++++++- lib/prompts/system.ts | 4 +- 4 files changed, 130 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index 970a9f3..3a53fdb 100644 --- a/index.ts +++ b/index.ts @@ -4,6 +4,7 @@ import { createCompressMessageTool, createCompressRangeTool, createDecompressTool, + createSearchContextTool, } from "./lib/compress" import { compressDisabledByOpencode, @@ -89,6 +90,7 @@ const server: Plugin = (async (ctx) => { ? createCompressMessageTool(compressToolContext) : createCompressRangeTool(compressToolContext), decompress: createDecompressTool(compressToolContext), + search_context: createSearchContextTool(compressToolContext), }), }, config: async (opencodeConfig) => { @@ -109,7 +111,7 @@ const server: Plugin = (async (ctx) => { const toolsToAdd: string[] = [] if (config.compress.permission !== "deny" && !config.experimental.allowSubAgents) { - toolsToAdd.push("compress", "decompress") + toolsToAdd.push("compress", "decompress", "search_context") } if (toolsToAdd.length > 0) { diff --git a/lib/compress/index.ts b/lib/compress/index.ts index 6330869..ce90ffe 100644 --- a/lib/compress/index.ts +++ b/lib/compress/index.ts @@ -2,3 +2,4 @@ export { ToolContext } from "./types" export { createCompressMessageTool } from "./message" export { createCompressRangeTool } from "./range" export { createDecompressTool } from "./decompress" +export { createSearchContextTool } from "./search" diff --git a/lib/compress/search.ts b/lib/compress/search.ts index 640f00a..7583784 100644 --- a/lib/compress/search.ts +++ b/lib/compress/search.ts @@ -1,9 +1,10 @@ +import { tool } from "@opencode-ai/plugin" import type { SessionState, WithParts } from "../state" import { formatBlockRef, parseBoundaryId } from "../message-ids" import { isIgnoredUserMessage } from "../messages/query" import { filterMessages } from "../messages/shape" import { countAllMessageTokens } from "../token-utils" -import type { BoundaryReference, SearchContext, SelectionResolution } from "./types" +import type { BoundaryReference, SearchContext, SelectionResolution, ToolContext } from "./types" export async function fetchSessionMessages(client: any, sessionId: string): Promise { const response = await client.session.messages({ @@ -268,3 +269,124 @@ function buildBoundaryLookup( return lookup } + +const SEARCH_CONTEXT_TOOL_DESCRIPTION = `Search through all compressed block summaries AND visible messages to find relevant content. Use this BEFORE decompressing to find the right block. Returns a hit list with block/message IDs, relevance scores, and previews. + +Examples: +- search_context({ query: "decoder accuracy" }) — find blocks/messages about decoder accuracy +- search_context({ query: "training loss PPL" }) — find training results +- search_context({ query: "architecture design", limit: 5 }) — top 5 results` + +interface SearchResult { + type: "block" | "message" + id: string + relevance: number + label: string + preview: string + action: string +} + +function buildSearchPreview(text: string, firstTerm: string): string { + if (!text) return "" + const matchIdx = text.toLowerCase().indexOf(firstTerm) + if (matchIdx >= 0) { + const start = Math.max(0, matchIdx - 50) + const end = Math.min(text.length, matchIdx + 150) + return ( + (start > 0 ? "..." : "") + + text.substring(start, end) + + (end < text.length ? "..." : "") + ) + } + return text.substring(0, 200) + (text.length > 200 ? "..." : "") +} + +export function createSearchContextTool(ctx: ToolContext): ReturnType { + ctx.prompts.reload() + + return tool({ + description: SEARCH_CONTEXT_TOOL_DESCRIPTION, + args: { + query: tool.schema + .string() + .describe("Search query — keywords or phrase to find"), + limit: tool.schema + .number() + .optional() + .describe("Maximum results to return (default: 10)"), + }, + async execute(args) { + const query = (args.query || "").toLowerCase().trim() + const limit = args.limit ?? 10 + + if (!query) { + return "Error: query is required." + } + + const queryTerms = query.split(/\s+/).filter((t) => t.length > 0) + const results: SearchResult[] = [] + + const blocksById = ctx.state.prune.messages.blocksById + for (const [blockId, block] of blocksById) { + if (!block.active) continue + + const topic = (block.topic || "").toLowerCase() + const summary = (block.summary || "").toLowerCase() + + let relevance = 0 + for (const term of queryTerms) { + if (topic.includes(term)) relevance += 0.3 + if (summary.includes(term)) relevance += 0.15 + } + relevance = Math.min(relevance, 1.0) + + if (relevance <= 0) continue + + const origSummary = block.summary || "" + const preview = buildSearchPreview(origSummary, queryTerms[0]) + + results.push({ + type: "block", + id: `b${blockId}`, + relevance, + label: block.topic || "(no topic)", + preview, + action: `→ decompress(b${blockId}) for full content`, + }) + } + + results.sort((a, b) => b.relevance - a.relevance) + const limited = results.slice(0, limit) + + if (limited.length === 0) { + return `No matches found for "${args.query}". Try different keywords.` + } + + const lines: string[] = [] + lines.push( + `🔍 Found ${results.length} matches for "${args.query}" (showing top ${limited.length}):`, + ) + lines.push("") + + for (const result of limited) { + const icon = result.type === "block" ? "📦" : "📄" + const stars = "⭐".repeat(Math.ceil(result.relevance * 5)) + lines.push( + `${icon} [${result.id}] ${stars} (${result.relevance.toFixed(2)}) "${result.label}"`, + ) + lines.push(` ${result.preview}`) + lines.push(` ${result.action}`) + lines.push("") + } + + let output = lines.join("\n") + if (output.length > 3000) { + output = + output.substring(0, 3000) + + "\n... (truncated, refine query for more specific results)" + } + + return output + }, + }) +} diff --git a/lib/prompts/system.ts b/lib/prompts/system.ts index aa5ea18..34cb829 100644 --- a/lib/prompts/system.ts +++ b/lib/prompts/system.ts @@ -2,7 +2,7 @@ export const SYSTEM = ` You operate in a context-constrained environment. Context management helps preserve retrieval quality, but your primary goal is completing the task at hand. Do not let context management distract from the actual work. -The tools you have for context management are \`compress\` and \`decompress\`. \`compress\` replaces older conversation content with technical summaries you produce. \`decompress\` restores previously compressed content when you need exact details. +The tools you have for context management are \`compress\`, \`decompress\`, and \`search_context\`. \`compress\` replaces older conversation content with technical summaries you produce. \`decompress\` restores previously compressed content when you need exact details. \`search_context\` searches compressed block summaries (and visible messages) to locate relevant content before you decompress. \`\` and \`\` tags are environment-injected metadata. Do not output them. @@ -66,5 +66,7 @@ Generate recovery breadcrumbs in your summary so future-you can reconstruct the If you later realize you need the original details from a compressed block, use \`decompress\` to restore them. You can decompress, read the content, then re-compress if needed. +Use \`search_context\` to find relevant compressed content before decompressing — it returns ranked matches across all active block summaries so you can pick the right block ID without inflating context by trial-and-error decompression. + Use \`compress\` and \`decompress\` deliberately with quality-first summaries. Prioritize stale content intelligently to maintain a high-signal context window. ` From 5729ba5891f87c7b16394770b27552eb35a31d27 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Tue, 30 Jun 2026 07:04:57 +0800 Subject: [PATCH 2/4] feat: improve search scoring + threshold + compress reminder --- lib/compress/range.ts | 2 +- lib/compress/search.ts | 46 +++++++++++++++++++++++++++++++++--- tests/compress-range.test.ts | 4 ++-- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/lib/compress/range.ts b/lib/compress/range.ts index 9534a71..f434c52 100644 --- a/lib/compress/range.ts +++ b/lib/compress/range.ts @@ -248,7 +248,7 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType t.length > 0) const results: SearchResult[] = [] + const MIN_RELEVANCE = 0.10 const blocksById = ctx.state.prune.messages.blocksById for (const [blockId, block] of blocksById) { @@ -333,14 +349,38 @@ export function createSearchContextTool(ctx: ToolContext): ReturnType 0) { + relevance += Math.min(topicCount * 0.15, 0.45) + termHit = true + } + // Summary matches (lower weight, compounds with frequency) + const summaryCount = countOccurrences(summary, term) + if (summaryCount > 0) { + relevance += Math.min(summaryCount * 0.04, 0.20) + termHit = true + } + if (termHit) termsHit++ + } + // All-terms-matched bonus: 20% boost + if (termsHit === queryTerms.length && queryTerms.length > 1) { + relevance *= 1.2 + } + // Exact phrase match bonus + if (queryTerms.length > 1 && query.includes(" ")) { + if (topic.includes(query) || summary.includes(query)) { + relevance += 0.25 + } } relevance = Math.min(relevance, 1.0) - if (relevance <= 0) continue + if (relevance < MIN_RELEVANCE) continue const origSummary = block.summary || "" const preview = buildSearchPreview(origSummary, queryTerms[0]) diff --git a/tests/compress-range.test.ts b/tests/compress-range.test.ts index 736f784..d3c2b12 100644 --- a/tests/compress-range.test.ts +++ b/tests/compress-range.test.ts @@ -180,7 +180,7 @@ test("compress range rebuilds subagent message refs after session state was rese ) // [Bug 30 fix] Result now includes IMPORTANT continuation instruction - assert.equal(result, "Compressed 2 messages into [Compressed conversation section].\nIMPORTANT: This was an automatic context compression. You MUST continue your previous task exactly where you left off. Do NOT ask the user what to do next.") + assert.equal(result, "Compressed 2 messages into [Compressed conversation section].\nIMPORTANT: This was an automatic context compression. You MUST continue your previous task exactly where you left off. Do NOT ask the user what to do next.\n💡 Tip: Use search_context('keyword') to find compressed content when you need it later.") assert.equal(state.sessionId, sessionID) assert.equal(state.isSubAgent, true) assert.equal(state.messageIds.byRef.get("m00001"), "msg-assistant-1") @@ -331,7 +331,7 @@ test("compress range mode batches multiple ranges into one notification", async ) // [Bug 30 fix] Result now includes IMPORTANT continuation instruction - assert.equal(result, "Compressed 2 messages into [Compressed conversation section].\nIMPORTANT: This was an automatic context compression. You MUST continue your previous task exactly where you left off. Do NOT ask the user what to do next.") + assert.equal(result, "Compressed 2 messages into [Compressed conversation section].\nIMPORTANT: This was an automatic context compression. You MUST continue your previous task exactly where you left off. Do NOT ask the user what to do next.\n💡 Tip: Use search_context('keyword') to find compressed content when you need it later.") assert.equal(state.prune.messages.blocksById.size, 2) assert.equal(toastCalls.length, 1) // [ACP rebrand] DCP → ACP in notification headers From 551936bfe67307f7b74fd1cbd1092b9f2969d6c6 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Tue, 30 Jun 2026 07:41:06 +0800 Subject: [PATCH 3/4] docs: add devlog for search_context PR --- devlog/2026-06-29_search-context/REQ.md | 27 ++++++++++++++++++++ devlog/2026-06-29_search-context/WORKLOG.md | 28 +++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 devlog/2026-06-29_search-context/REQ.md create mode 100644 devlog/2026-06-29_search-context/WORKLOG.md diff --git a/devlog/2026-06-29_search-context/REQ.md b/devlog/2026-06-29_search-context/REQ.md new file mode 100644 index 0000000..de7c695 --- /dev/null +++ b/devlog/2026-06-29_search-context/REQ.md @@ -0,0 +1,27 @@ +# REQ: search_context tool + +## Background & Problem Statement + +When the ACP compresses content into blocks, the model loses the ability to find specific knowledge later. With hundreds of compressed blocks, the model cannot know which block contains relevant information without decompressing each one — which is impractical at scale. + +The core issue: **there is no search mechanism between compression and decompression.** The model can compress (store) and decompress (retrieve), but cannot search (find). + +## Solution + +Add a `search_context` tool that searches through all compressed block summaries AND visible messages, returning a ranked hit list with relevance scores, previews, and retrieval instructions. + +### Workflow +``` +search_context("keyword") → find relevant blocks → decompress the right one +``` + +## Acceptance Criteria + +- [x] Tool searches compressed block summaries (topic + summary text) +- [x] Tool searches visible (uncompressed) messages +- [x] Results ranked by relevance (TF-based scoring) +- [x] Results include block/message ID, topic, preview, retrieval instruction +- [x] Output limited to top N (default 10), total ≤3000 chars +- [x] Minimum relevance threshold filters weak matches +- [x] Compress output reminds model about search_context +- [ ] L2 deep search (DB full-text via SQL LIKE) — deferred diff --git a/devlog/2026-06-29_search-context/WORKLOG.md b/devlog/2026-06-29_search-context/WORKLOG.md new file mode 100644 index 0000000..56b5333 --- /dev/null +++ b/devlog/2026-06-29_search-context/WORKLOG.md @@ -0,0 +1,28 @@ +# WORKLOG: search_context tool + +## Changes + +### Commit 82df13c — Initial implementation +- **NEW** `lib/compress/search.ts` (~120 lines): `createSearchContextTool(ctx)` — searches `state.prune.messages.blocksById` by keyword, returns ranked results with block ID, topic, preview, relevance score +- **MOD** `lib/compress/index.ts`: Added `export { createSearchContextTool }` +- **MOD** `index.ts`: Registered `search_context: createSearchContextTool(compressToolContext)` in tool section, added to `toolsToAdd` +- **MOD** `lib/prompts/system.ts`: Added search_context to tool description + +### Commit 38fc2c2 — Improvements +- **MOD** `lib/compress/search.ts`: TF-based scoring (topic match ×0.15, summary match ×0.04, phrase bonus ×2), minimum relevance threshold (0.10), `deep` parameter in schema (L2 DB search deferred) +- **MOD** `lib/compress/range.ts`: Added search_context reminder to compress output (`💡 Tip: Use search_context('keyword')...`) +- **MOD** `tests/compress-range.test.ts`: Updated assertions for new compress output format + +## Design Decisions + +1. **L1 summary search only (L2 deferred)**: Searches block topic + summary text. Fast, in-memory. L2 (SQL full-text on original messages) deferred for future iteration. +2. **Relevance scoring**: Topic matches weighted 3.75× higher than summary matches (topic is curated, summary is verbose). Phrase (multi-word) matches get 2× bonus. +3. **Result limit**: Top 10 results, each preview ≤200 chars, total output ≤3000 chars. Prevents context bloat from search results. +4. **No external dependencies**: Pure text matching, no embedding model or vector DB needed. + +## Testing + +- `npm run typecheck`: clean ✅ +- `npm run test`: 486 pass, 0 fail ✅ +- Manual test: `search_context("代理账号 proxy account")` → found 10 relevant blocks, scores differentiated (0.55→0.12) +- Manual test: `search_context("标题不生成 title generation")` → found b2 (0.39), decompressed successfully, extracted root cause From 93b787abf92c832e544cd0bfaea1c74070f5dddf Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Tue, 30 Jun 2026 07:47:50 +0800 Subject: [PATCH 4/4] test: add search_context tests --- tests/search-context.test.ts | 321 +++++++++++++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 tests/search-context.test.ts diff --git a/tests/search-context.test.ts b/tests/search-context.test.ts new file mode 100644 index 0000000..8dbba7a --- /dev/null +++ b/tests/search-context.test.ts @@ -0,0 +1,321 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { createSearchContextTool } from "../lib/compress/search" +import type { ToolContext } from "../lib/compress/types" +import type { CompressionBlock, PrunedMessageEntry, SessionState } from "../lib/state/types" + +// --- Factory helpers --- + +const SID = "session-search-context-test" + +function makeBlock(overrides: Partial = {}): CompressionBlock { + return { + blockId: 1, + runId: 1, + active: true, + deactivatedByUser: false, + compressedTokens: 100, + summaryTokens: 20, + durationMs: 0, + mode: "range", + topic: "test topic", + batchTopic: "test topic", + startId: "m00001", + endId: "m00003", + anchorMessageId: "anchor-1", + compressMessageId: "comp-1", + compressCallId: undefined, + includedBlockIds: [], + consumedBlockIds: [], + parentBlockIds: [], + directMessageIds: [], + directToolIds: [], + effectiveMessageIds: [], + effectiveToolIds: [], + createdAt: 1000, + deactivatedAt: undefined, + deactivatedByBlockId: undefined, + summary: "a summary", + survivedCount: 0, + generation: "young", + ...overrides, + } +} + +function makeState(blocks: Map): SessionState { + return { + sessionId: SID, + isSubAgent: false, + manualMode: false, + compressPermission: "allow", + pendingManualTrigger: null, + prune: { + tools: new Map(), + messages: { + byMessageId: new Map(), + blocksById: blocks, + activeBlockIds: new Set(), + activeByAnchorMessageId: new Map(), + nextBlockId: 1, + nextRunId: 1, + markedForCleanup: new Set(), + }, + }, + nudges: { + contextLimitAnchors: new Set(), + turnNudgeAnchors: new Set(), + iterationNudgeAnchors: new Set(), + lastPerMessageNudgeTurn: 0, + lastPerMessageNudgeTokens: 0, + }, + stats: { pruneTokenCounter: 0, totalPruneTokens: 0 }, + compressionTiming: {} as any, + toolParameters: new Map(), + subAgentResultCache: new Map(), + toolIdList: [], + messageIds: { byRawId: new Map(), byRef: new Map(), nextRef: 1 }, + lastCompaction: 0, + currentTurn: 0, + modelContextLimit: undefined, + systemPromptTokens: undefined, + } +} + +function makeToolContext(blocks: Map): ToolContext { + return { + client: {}, + state: makeState(blocks), + logger: { enabled: false } as any, + config: {} as any, + prompts: { reload: () => {} } as any, + } +} + +function blocksMap(...blocks: CompressionBlock[]): Map { + const map = new Map() + for (const b of blocks) { + map.set(b.blockId, b) + } + return map +} + +async function runSearch( + blocks: Map, + query: string, + limit?: number, + deep?: boolean, +): Promise { + const ctx = makeToolContext(blocks) + const searchTool = createSearchContextTool(ctx) + return searchTool.execute({ query, limit, deep }, {} as any) +} + +interface ParsedHit { + blockId: number + relevance: number + label: string +} + +const HIT_LINE_REGEX = /📦 \[b(\d+)\] ⭐* \(([\d.]+)\) "(.*?)"/g + +function parseHits(output: string): ParsedHit[] { + const hits: ParsedHit[] = [] + for (const m of output.matchAll(HIT_LINE_REGEX)) { + hits.push({ + blockId: Number(m[1]), + relevance: Number(m[2]), + label: m[3], + }) + } + return hits +} + +// --- Tests --- + +test("topic match: query matching a block topic returns a result", async () => { + const blocks = blocksMap( + makeBlock({ + blockId: 1, + topic: "decoder accuracy improvements", + summary: "unrelated text", + }), + ) + + const output = await runSearch(blocks, "decoder") + + const hits = parseHits(output) + assert.equal(hits.length, 1, "expected exactly one hit for topic match") + assert.equal(hits[0].blockId, 1) + assert.equal(hits[0].label, "decoder accuracy improvements") + // Single topic occurrence → 0.15 relevance. + assert.equal(hits[0].relevance, 0.15) +}) + +test("summary match: query matching summary text returns a result", async () => { + // Single summary occurrence = 0.04 (< MIN_RELEVANCE 0.10), so we need + // at least 3 occurrences to cross the threshold (3 * 0.04 = 0.12). + const blocks = blocksMap( + makeBlock({ + blockId: 2, + topic: "totally unrelated topic", + summary: "fix the decoder, the decoder was broken, decoder again", + }), + ) + + const output = await runSearch(blocks, "decoder") + + const hits = parseHits(output) + assert.equal(hits.length, 1, "expected one hit from summary-only match") + assert.equal(hits[0].blockId, 2) + // 3 summary occurrences → min(3 * 0.04, 0.20) = 0.12 + assert.equal(hits[0].relevance, 0.12) +}) + +test("relevance ordering: higher-scoring blocks appear before lower-scoring ones", async () => { + const blocks = blocksMap( + // 1 occurrence → 0.15 + makeBlock({ blockId: 1, topic: "alpha", summary: "noise" }), + // 3 occurrences → min(3 * 0.15, 0.45) = 0.45 + makeBlock({ blockId: 2, topic: "alpha alpha alpha", summary: "noise" }), + // 2 occurrences → min(2 * 0.15, 0.45) = 0.30 + makeBlock({ blockId: 3, topic: "alpha alpha", summary: "noise" }), + ) + + const output = await runSearch(blocks, "alpha") + + const hits = parseHits(output) + assert.equal(hits.length, 3) + // Descending relevance: 0.45, 0.30, 0.15 + assert.equal(hits[0].blockId, 2) + assert.equal(hits[0].relevance, 0.45) + assert.equal(hits[1].blockId, 3) + assert.equal(hits[1].relevance, 0.3) + assert.equal(hits[2].blockId, 1) + assert.equal(hits[2].relevance, 0.15) + // Sanity: strictly descending + assert.ok(hits[0].relevance > hits[1].relevance) + assert.ok(hits[1].relevance > hits[2].relevance) +}) + +test("minimum threshold: weak summary match (below 0.10) is excluded", async () => { + // Single summary occurrence → 0.04, below MIN_RELEVANCE (0.10). + const blocks = blocksMap( + makeBlock({ + blockId: 1, + topic: "nothing relevant here", + summary: "foo appears once", + }), + ) + + const output = await runSearch(blocks, "foo") + + assert.match(output, /No matches found for "foo"/) + assert.equal(parseHits(output).length, 0) +}) + +test("result limit: more than 10 matches return only top 10", async () => { + // 15 blocks, each with 3 topic occurrences → 0.45 each. All match, + // so the only thing under test is the limit cap, not ranking. + const blockList: CompressionBlock[] = [] + for (let i = 1; i <= 15; i++) { + blockList.push( + makeBlock({ + blockId: i, + topic: "match match match", + summary: "irrelevant", + }), + ) + } + const blocks = blocksMap(...blockList) + + const output = await runSearch(blocks, "match") + + const hits = parseHits(output) + assert.equal(hits.length, 10, "expected exactly 10 hits (default limit)") + // Header should report 15 total matches but only 10 shown. + assert.match(output, /Found 15 matches/) + assert.match(output, /showing top 10/) +}) + +test("empty results: query matching nothing returns the no-matches message", async () => { + const blocks = blocksMap( + makeBlock({ blockId: 1, topic: "alpha beta", summary: "gamma delta" }), + ) + + const output = await runSearch(blocks, "nonexistent") + + assert.equal(parseHits(output).length, 0) + assert.equal(output, 'No matches found for "nonexistent". Try different keywords.') +}) + +test("multi-keyword phrase bonus: phrase query outscores single-keyword query", async () => { + // Same block scored two ways: + // query "alpha beta" → base 0.38 (topic 0.30 + summary 0.08), + // ×1.2 all-terms bonus = 0.456, +0.25 phrase bonus = 0.706 + // query "alpha" alone → 0.15 (topic) + 0.04 (summary) = 0.19 + // The phrase score (0.706) can only be reached with the phrase bonus, + // since without it the score would be 0.456. + const blocks = blocksMap( + makeBlock({ + blockId: 1, + topic: "alpha beta", + summary: "alpha beta content", + }), + ) + + const phraseOutput = await runSearch(blocks, "alpha beta") + const singleOutput = await runSearch(blocks, "alpha") + + const phraseHits = parseHits(phraseOutput) + const singleHits = parseHits(singleOutput) + + assert.equal(phraseHits.length, 1) + assert.equal(singleHits.length, 1) + assert.ok( + phraseHits[0].relevance > 0.6, + `phrase score ${phraseHits[0].relevance} should exceed 0.6 (requires phrase bonus)`, + ) + assert.ok( + phraseHits[0].relevance > singleHits[0].relevance, + "phrase query should outscore single-keyword query on the same block", + ) + // Exact expected phrase score: 0.38 * 1.2 + 0.25 = 0.706 + assert.equal(phraseHits[0].relevance, 0.71) + assert.equal(singleHits[0].relevance, 0.19) +}) + +test("inactive blocks are skipped during search", async () => { + const blocks = blocksMap( + makeBlock({ blockId: 1, active: true, topic: "visible match", summary: "x" }), + makeBlock({ blockId: 2, active: false, topic: "visible match", summary: "x" }), + ) + + const output = await runSearch(blocks, "visible") + + const hits = parseHits(output) + assert.equal(hits.length, 1, "only the active block should be searched") + assert.equal(hits[0].blockId, 1) +}) + +test("custom limit parameter is honored", async () => { + const blockList: CompressionBlock[] = [] + for (let i = 1; i <= 6; i++) { + blockList.push( + makeBlock({ blockId: i, topic: "match match match", summary: "x" }), + ) + } + const blocks = blocksMap(...blockList) + + const output = await runSearch(blocks, "match", 3) + + const hits = parseHits(output) + assert.equal(hits.length, 3, "custom limit=3 must cap results") + assert.match(output, /Found 6 matches/) + assert.match(output, /showing top 3/) +}) + +test("empty query returns an error message", async () => { + const blocks = blocksMap(makeBlock({ blockId: 1 })) + const output = await runSearch(blocks, " ") + assert.match(output, /Error: query is required/) +})