fix(cli): speed up local recall searches (#12726)

* fix(cli): speed up local recall searches

* fix(cli): bound local recall scans

* fix(cli): make recall index initialization lazy
This commit is contained in:
Marius
2026-08-03 12:22:42 +02:00
committed by GitHub
parent 7d3f50c2e8
commit 2fbd380dfc
8 changed files with 391 additions and 174 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Speed up local session recall searches across large conversation histories.
+173 -48
View File
@@ -1,8 +1,10 @@
{
"version": "7",
"dialect": "sqlite",
"id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db",
"prevIds": ["abd2f920-b822-49af-b8a7-2e48367d424f"],
"id": "7a2d751a-b26c-4a89-9905-800e3f6a8b8a",
"prevIds": [
"169a0f0f-d58f-479f-b024-fa1c7b9a09db"
],
"ddl": [
{
"name": "workspace",
@@ -1511,9 +1513,13 @@
"table": "session_share"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1522,9 +1528,13 @@
"table": "workspace"
},
{
"columns": ["active_account_id"],
"columns": [
"active_account_id"
],
"tableTo": "account",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "SET NULL",
"nameExplicit": false,
@@ -1533,9 +1543,13 @@
"table": "account_state"
},
{
"columns": ["aggregate_id"],
"columns": [
"aggregate_id"
],
"tableTo": "event_sequence",
"columnsTo": ["aggregate_id"],
"columnsTo": [
"aggregate_id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1544,9 +1558,13 @@
"table": "event"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1555,9 +1573,13 @@
"table": "permission"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1566,9 +1588,13 @@
"table": "project_directory"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1577,9 +1603,13 @@
"table": "message"
},
{
"columns": ["message_id"],
"columns": [
"message_id"
],
"tableTo": "message",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1588,9 +1618,13 @@
"table": "part"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1599,9 +1633,13 @@
"table": "session_context_epoch"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1610,9 +1648,13 @@
"table": "session_input"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1621,9 +1663,13 @@
"table": "session_message"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1632,9 +1678,13 @@
"table": "session"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1643,9 +1693,13 @@
"table": "todo"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1654,133 +1708,174 @@
"table": "session_share"
},
{
"columns": ["email", "url"],
"columns": [
"email",
"url"
],
"nameExplicit": false,
"name": "control_account_pk",
"entityType": "pks",
"table": "control_account"
},
{
"columns": ["project_id", "directory"],
"columns": [
"project_id",
"directory"
],
"nameExplicit": false,
"name": "project_directory_pk",
"entityType": "pks",
"table": "project_directory"
},
{
"columns": ["session_id", "position"],
"columns": [
"session_id",
"position"
],
"nameExplicit": false,
"name": "todo_pk",
"entityType": "pks",
"table": "todo"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
"columns": ["name"],
"columns": [
"name"
],
"nameExplicit": false,
"name": "data_migration_pk",
"table": "data_migration",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "account_state_pk",
"table": "account_state",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "credential_pk",
"table": "credential",
"entityType": "pks"
},
{
"columns": ["aggregate_id"],
"columns": [
"aggregate_id"
],
"nameExplicit": false,
"name": "event_sequence_pk",
"table": "event_sequence",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "event_pk",
"table": "event",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "message_pk",
"table": "message",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "part_pk",
"table": "part",
"entityType": "pks"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "session_context_epoch_pk",
"table": "session_context_epoch",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_input_pk",
"table": "session_input",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_message_pk",
"table": "session_message",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "session_share_pk",
"table": "session_share",
@@ -1902,6 +1997,36 @@
"entityType": "indexes",
"table": "part"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
},
{
"value": "id",
"isExpression": false
},
{
"value": "message_id",
"isExpression": false
},
{
"value": "json_extract(\"data\", '$.type')",
"isExpression": true
},
{
"value": "CASE WHEN json_extract(\"data\", '$.type') = 'text' THEN coalesce(json_extract(\"data\", '$.text'), '') WHEN json_extract(\"data\", '$.type') = 'file' THEN trim(coalesce(json_extract(\"data\", '$.filename'), '') || ' ' || CASE WHEN coalesce(json_extract(\"data\", '$.url'), '') NOT LIKE 'data:%' THEN coalesce(json_extract(\"data\", '$.url'), '') ELSE '' END || ' ' || coalesce(json_extract(\"data\", '$.source.path'), '') || ' ' || coalesce(json_extract(\"data\", '$.source.name'), '') || ' ' || CASE WHEN coalesce(json_extract(\"data\", '$.source.uri'), '') NOT LIKE 'data:%' THEN coalesce(json_extract(\"data\", '$.source.uri'), '') ELSE '' END || ' ' || coalesce(json_extract(\"data\", '$.source.clientName'), '')) ELSE coalesce(json_extract(\"data\", '$.state.error'), '') END",
"isExpression": true
}
],
"isUnique": false,
"where": "(json_extract(\"part\".\"data\", '$.type') = 'text' AND coalesce(json_extract(\"part\".\"data\", '$.synthetic'), 0) = 0 AND coalesce(json_extract(\"part\".\"data\", '$.ignored'), 0) = 0) OR json_extract(\"part\".\"data\", '$.type') = 'file' OR (json_extract(\"part\".\"data\", '$.type') = 'tool' AND json_extract(\"part\".\"data\", '$.state.status') = 'error')",
"origin": "manual",
"name": "recall_part_search_idx",
"entityType": "indexes",
"table": "part"
},
{
"columns": [
{
@@ -2098,4 +2223,4 @@
}
],
"renames": []
}
}
+1
View File
@@ -245,6 +245,7 @@ export default {
yield* tx.run(`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
yield* tx.run(`CREATE INDEX \`recall_part_search_idx\` ON \`part\` (\`session_id\`,\`id\`,\`message_id\`,json_extract("data", '$.type'),CASE WHEN json_extract("data", '$.type') = 'text' THEN coalesce(json_extract("data", '$.text'), '') WHEN json_extract("data", '$.type') = 'file' THEN trim(coalesce(json_extract("data", '$.filename'), '') || ' ' || CASE WHEN coalesce(json_extract("data", '$.url'), '') NOT LIKE 'data:%' THEN coalesce(json_extract("data", '$.url'), '') ELSE '' END || ' ' || coalesce(json_extract("data", '$.source.path'), '') || ' ' || coalesce(json_extract("data", '$.source.name'), '') || ' ' || CASE WHEN coalesce(json_extract("data", '$.source.uri'), '') NOT LIKE 'data:%' THEN coalesce(json_extract("data", '$.source.uri'), '') ELSE '' END || ' ' || coalesce(json_extract("data", '$.source.clientName'), '')) ELSE coalesce(json_extract("data", '$.state.error'), '') END) WHERE json_valid("part"."data") AND ((json_extract("part"."data", '$.type') = 'text' AND coalesce(json_extract("part"."data", '$.synthetic'), 0) = 0 AND coalesce(json_extract("part"."data", '$.ignored'), 0) = 0) OR json_extract("part"."data", '$.type') = 'file' OR (json_extract("part"."data", '$.type') = 'tool' AND json_extract("part"."data", '$.state.status') = 'error'));`)
yield* tx.run(`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`)
yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`)
yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`)
@@ -0,0 +1,25 @@
import { sql } from "drizzle-orm"
import { index, type AnySQLiteColumn } from "drizzle-orm/sqlite-core"
export namespace RecallPartIndex {
export const createSql = `CREATE INDEX IF NOT EXISTS \`recall_part_search_idx\` ON \`part\` (\`session_id\`,\`id\`,\`message_id\`,json_extract("data", '$.type'),CASE WHEN json_extract("data", '$.type') = 'text' THEN coalesce(json_extract("data", '$.text'), '') WHEN json_extract("data", '$.type') = 'file' THEN trim(coalesce(json_extract("data", '$.filename'), '') || ' ' || CASE WHEN coalesce(json_extract("data", '$.url'), '') NOT LIKE 'data:%' THEN coalesce(json_extract("data", '$.url'), '') ELSE '' END || ' ' || coalesce(json_extract("data", '$.source.path'), '') || ' ' || coalesce(json_extract("data", '$.source.name'), '') || ' ' || CASE WHEN coalesce(json_extract("data", '$.source.uri'), '') NOT LIKE 'data:%' THEN coalesce(json_extract("data", '$.source.uri'), '') ELSE '' END || ' ' || coalesce(json_extract("data", '$.source.clientName'), '')) ELSE coalesce(json_extract("data", '$.state.error'), '') END) WHERE json_valid("part"."data") AND ((json_extract("part"."data", '$.type') = 'text' AND coalesce(json_extract("part"."data", '$.synthetic'), 0) = 0 AND coalesce(json_extract("part"."data", '$.ignored'), 0) = 0) OR json_extract("part"."data", '$.type') = 'file' OR (json_extract("part"."data", '$.type') = 'tool' AND json_extract("part"."data", '$.state.status') = 'error'));`
export function make(table: {
session_id: AnySQLiteColumn
id: AnySQLiteColumn
message_id: AnySQLiteColumn
data: AnySQLiteColumn
}) {
return index("recall_part_search_idx")
.on(
table.session_id,
table.id,
table.message_id,
sql`json_extract(${table.data}, '$.type')`,
sql`CASE WHEN json_extract(${table.data}, '$.type') = 'text' THEN coalesce(json_extract(${table.data}, '$.text'), '') WHEN json_extract(${table.data}, '$.type') = 'file' THEN trim(coalesce(json_extract(${table.data}, '$.filename'), '') || ' ' || CASE WHEN coalesce(json_extract(${table.data}, '$.url'), '') NOT LIKE 'data:%' THEN coalesce(json_extract(${table.data}, '$.url'), '') ELSE '' END || ' ' || coalesce(json_extract(${table.data}, '$.source.path'), '') || ' ' || coalesce(json_extract(${table.data}, '$.source.name'), '') || ' ' || CASE WHEN coalesce(json_extract(${table.data}, '$.source.uri'), '') NOT LIKE 'data:%' THEN coalesce(json_extract(${table.data}, '$.source.uri'), '') ELSE '' END || ' ' || coalesce(json_extract(${table.data}, '$.source.clientName'), '')) ELSE coalesce(json_extract(${table.data}, '$.state.error'), '') END`,
)
.where(
sql`json_valid(${table.data}) AND ((json_extract(${table.data}, '$.type') = 'text' AND coalesce(json_extract(${table.data}, '$.synthetic'), 0) = 0 AND coalesce(json_extract(${table.data}, '$.ignored'), 0) = 0) OR json_extract(${table.data}, '$.type') = 'file' OR (json_extract(${table.data}, '$.type') = 'tool' AND json_extract(${table.data}, '$.state.status') = 'error'))`,
)
}
}
+2
View File
@@ -13,6 +13,7 @@ import { WorkspaceV2 } from "../workspace"
import { Timestamps } from "../database/schema.sql"
import type { SystemContext } from "../system-context/index"
import { AgentV2 } from "../agent"
import { RecallPartIndex } from "../kilocode/session/recall-part-index" // kilocode_change
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
@@ -93,6 +94,7 @@ export const PartTable = sqliteTable(
(table) => [
index("part_message_id_id_idx").on(table.message_id, table.id),
index("part_session_idx").on(table.session_id),
RecallPartIndex.make(table), // kilocode_change
],
)
@@ -2,6 +2,7 @@ import path from "path"
import { eq, inArray, sql } from "drizzle-orm"
import { Effect } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { RecallPartIndex } from "@opencode-ai/core/kilocode/session/recall-part-index"
import type { MessageV2 } from "@/session/message-v2"
import { SessionTable } from "@opencode-ai/core/session/sql"
import type { MessageID, PartID, SessionID } from "@/session/schema"
@@ -11,26 +12,17 @@ import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
export namespace RecallSearch {
const BATCH = 128
const BATCH = 8_192
const PAGE_SIZE = 1_024
const SCAN_SIZE = 16_384
const MAX_QUERY = 256
const MAX_TERMS = 12
const MAX_SNIPPETS = 3
const SNIPPET_CHARS = 360
const SNIPPET_CONTEXT = 120
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" })
const ready = new WeakSet<object>()
const FIELDS_SQL = `
p.rowid AS rowid,
p.id AS partID,
p.session_id AS sessionID,
CASE
WHEN json_extract(p.data, '$.type') = 'text' THEN json_extract(m.data, '$.role')
WHEN json_extract(p.data, '$.type') = 'file' THEN 'reference'
ELSE 'error'
END AS source,
CASE
const TEXT_SQL = `CASE
WHEN json_extract(p.data, '$.type') = 'text' THEN coalesce(json_extract(p.data, '$.text'), '')
WHEN json_extract(p.data, '$.type') = 'file' THEN trim(
coalesce(json_extract(p.data, '$.filename'), '') || ' ' ||
@@ -43,74 +35,65 @@ export namespace RecallSearch {
coalesce(json_extract(p.data, '$.source.clientName'), '')
)
ELSE coalesce(json_extract(p.data, '$.state.error'), '')
END AS text`
END`
const FILTER_SQL = `
(json_extract(p.data, '$.type') = 'text'
AND json_extract(m.data, '$.role') IN ('user', 'assistant')
AND coalesce(json_extract(p.data, '$.synthetic'), 0) = 0
AND coalesce(json_extract(p.data, '$.ignored'), 0) = 0)
OR json_extract(p.data, '$.type') = 'file'
OR (json_extract(p.data, '$.type') = 'tool'
AND json_extract(p.data, '$.state.status') = 'error')`
const PART_FILTER_SQL = `
json_valid(p.data) AND (
(json_extract(p.data, '$.type') = 'text'
AND coalesce(json_extract(p.data, '$.synthetic'), 0) = 0
AND coalesce(json_extract(p.data, '$.ignored'), 0) = 0)
OR json_extract(p.data, '$.type') = 'file'
OR (json_extract(p.data, '$.type') = 'tool'
AND json_extract(p.data, '$.state.status') = 'error')
)`
const pageSql = (
export const query = (
ids: SessionID[],
cursor: { sessionID: SessionID | ""; rowid: number },
rowid: number,
partID: string,
sessionID: SessionID | "",
messageID: MessageID | "",
terms: string[],
cursor: { sessionID: SessionID | ""; partID: PartID | "" },
) => sql`
WITH page AS (
SELECT p.rowid, p.id, p.message_id, p.session_id, p.data
FROM part AS p INDEXED BY part_session_idx
WHERE p.session_id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`,`,
)})
AND (p.session_id > ${cursor.sessionID} OR (p.session_id = ${cursor.sessionID} AND p.rowid > ${cursor.rowid}))
AND p.rowid <= ${rowid}
AND p.id <= ${partID}
ORDER BY p.session_id, p.rowid
LIMIT ${SCAN_SIZE}
), found AS (
SELECT ${sql.raw(FIELDS_SQL)}
FROM page AS p
JOIN message AS m ON m.id = p.message_id
AND m.session_id = p.session_id
WHERE NOT (
m.session_id = ${sessionID} AND (
(json_extract(m.data, '$.role') = 'user' AND m.id >= ${messageID})
OR (json_extract(m.data, '$.role') = 'assistant' AND json_extract(m.data, '$.parentID') >= ${messageID})
)
)
AND (${sql.raw(FILTER_SQL)})
ORDER BY p.session_id, p.rowid
LIMIT ${PAGE_SIZE}
), next AS (
SELECT
CASE WHEN (SELECT count(*) FROM found) = ${PAGE_SIZE}
THEN (SELECT sessionID FROM found ORDER BY sessionID DESC, rowid DESC LIMIT 1)
ELSE (SELECT session_id FROM page ORDER BY session_id DESC, rowid DESC LIMIT 1)
END AS sessionID,
CASE WHEN (SELECT count(*) FROM found) = ${PAGE_SIZE}
THEN (SELECT rowid FROM found ORDER BY sessionID DESC, rowid DESC LIMIT 1)
ELSE (SELECT rowid FROM page ORDER BY session_id DESC, rowid DESC LIMIT 1)
END AS rowid
), meta AS (
SELECT next.sessionID, next.rowid, count(*) AS parts
FROM next
JOIN page AS p ON p.session_id < next.sessionID OR (p.session_id = next.sessionID AND p.rowid <= next.rowid)
WHERE next.sessionID IS NOT NULL
GROUP BY next.sessionID, next.rowid
)
SELECT rowid, partID, sessionID, source, text, 0 AS meta, 0 AS parts
FROM found
UNION ALL
SELECT rowid, NULL AS partID, sessionID, NULL AS source, NULL AS text, 1 AS meta, parts
FROM meta
ORDER BY meta, sessionID, rowid`
SELECT
p.id AS partID,
p.message_id AS messageID,
p.session_id AS sessionID,
json_extract(p.data, '$.type') AS kind,
${sql.raw(TEXT_SQL)} AS text
FROM part AS p
WHERE p.session_id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`,`,
)})
AND (p.session_id > ${cursor.sessionID} OR (p.session_id = ${cursor.sessionID} AND p.id > ${cursor.partID}))
AND (${sql.raw(PART_FILTER_SQL)})
AND (
${sql.join(
terms.map((term) => sql`instr(lower(${sql.raw(TEXT_SQL)}), ${term}) > 0`),
sql` OR `,
)}
OR ${sql.raw(TEXT_SQL)} GLOB ('*[^' || char(1) || '-' || char(127) || ']*')
)
ORDER BY p.session_id, p.id
LIMIT ${PAGE_SIZE}`
const ensure = (db: Database.Interface["db"]) =>
Effect.gen(function* () {
if (ready.has(db)) return
yield* db.run(sql.raw(RecallPartIndex.createSql)).pipe(
Effect.tap(() => Effect.sync(() => ready.add(db))),
Effect.catch((error) => Effect.logWarning("recall index unavailable", { error })),
)
})
const messageSql = (ids: MessageID[]) => sql`
SELECT
id,
json_extract(data, '$.role') AS role,
coalesce(json_extract(data, '$.parentID'), '') AS parentID
FROM message
WHERE id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`,`,
)})`
export type Source = "user" | "assistant" | "reference" | "error"
@@ -131,7 +114,7 @@ export namespace RecallSearch {
export type Output = {
results: Result[]
sessions: number
parts: number
candidates: number
}
type Candidate = Match & {
@@ -149,19 +132,21 @@ export namespace RecallSearch {
type Row = {
partID: PartID
messageID: MessageID
sessionID: SessionID
source: Source
kind: "text" | "file" | "tool"
text: string
}
type PageRow = {
rowid: number
partID: PartID | null
sessionID: SessionID
source: Source | null
text: string | null
meta: number
parts: number
type Hit = Row & {
mask: number
phrase: boolean
}
type MessageRow = {
id: MessageID
role: "user" | "assistant"
parentID: MessageID | ""
}
export const search = Effect.fn("RecallSearch.search")(function* (input: {
@@ -180,7 +165,7 @@ export namespace RecallSearch {
}
const roots = [...new Set(input.directories.map(Filesystem.resolve))]
if (roots.length === 0) return { results: [], sessions: 0, parts: 0 }
if (roots.length === 0) return { results: [], sessions: 0, candidates: 0 }
yield* abort(input.signal)
const { db } = yield* Database.Service
@@ -217,37 +202,28 @@ export namespace RecallSearch {
})
}
yield* abort(input.signal)
if (items.size === 0) return { results: [], sessions: 0, parts: 0 }
if (items.size === 0) return { results: [], sessions: 0, candidates: 0 }
yield* ensure(db)
const ids = [...items.keys()]
const rowid =
(yield* db.get<{ rowid: number | null }>(sql`SELECT max(rowid) AS rowid FROM part`).pipe(Effect.orDie))?.rowid ??
0
const partID =
(yield* db.get<{ id: string | null }>(sql`SELECT max(id) AS id FROM part`).pipe(Effect.orDie))?.id ?? ""
const ids = [...items.keys()].sort()
const excludeSessionID = input.excludeSessionID ?? ""
const excludeFromMessageID = input.excludeFromMessageID ?? ""
let parts = 0
let candidates = 0
const consume = (row: Row) => {
const consume = (row: Hit, source: Source) => {
const item = items.get(row.sessionID)
if (!item || !row.text) return
if (!item) return
const normalized = fold(row.text)
const matched = mask(normalized, parsed.terms)
if (matched === 0) return
item.mask |= matched
item.sourceMask[row.source] |= matched
const phrase = normalized.includes(parsed.phrase)
item.phrase = Math.max(item.phrase, phrase ? weight(row.source) : 0)
item.mask |= row.mask
item.sourceMask[source] |= row.mask
item.phrase = Math.max(item.phrase, row.phrase ? weight(source) : 0)
candidate(
item.candidates,
{
source: row.source,
source,
partID: row.partID,
mask: matched,
phrase,
mask: row.mask,
phrase: row.phrase,
},
() => excerpt(row.text, parsed),
)
@@ -256,21 +232,48 @@ export namespace RecallSearch {
for (let index = 0; index < ids.length; index += BATCH) {
yield* abort(input.signal)
const batch = ids.slice(index, index + BATCH)
let cursor = { sessionID: "" as SessionID | "", rowid: 0 }
while (cursor.rowid <= rowid) {
const found = yield* db
.all<PageRow>(pageSql(batch, cursor, rowid, partID, excludeSessionID, excludeFromMessageID))
.pipe(Effect.orDie)
let cursor = { sessionID: "" as SessionID | "", partID: "" as PartID | "" }
while (true) {
const live = cursor.sessionID ? batch.filter((id) => id >= cursor.sessionID) : batch
if (live.length === 0) break
const found = yield* db.all<Row>(query(live, parsed.terms, cursor)).pipe(Effect.orDie)
if (found.length === 0) break
const last = found.at(-1)!
cursor = { sessionID: last.sessionID, rowid: last.rowid }
parts += last.parts
candidates += found.length
const hits: Hit[] = []
for (const row of found) {
if (row.meta || !row.partID || !row.source) continue
consume({ partID: row.partID, sessionID: row.sessionID, source: row.source, text: row.text ?? "" })
if (!row.text) continue
const normalized = fold(row.text)
const matched = mask(normalized, parsed.terms)
if (matched === 0) continue
hits.push({ ...row, mask: matched, phrase: normalized.includes(parsed.phrase) })
}
const messages = new Map<MessageID, MessageRow>()
const messageIDs = [...new Set(hits.map((row) => row.messageID))]
for (let offset = 0; offset < messageIDs.length; offset += BATCH) {
const rows = yield* db
.all<MessageRow>(messageSql(messageIDs.slice(offset, offset + BATCH)))
.pipe(Effect.orDie)
for (const row of rows) messages.set(row.id, row)
}
for (const row of hits) {
const message = messages.get(row.messageID)
if (!message) continue
if (row.sessionID === excludeSessionID) {
if (message.role === "user" && message.id >= excludeFromMessageID) continue
if (message.role === "assistant" && message.parentID >= excludeFromMessageID) continue
}
if (row.kind === "text") {
if (message.role !== "user" && message.role !== "assistant") continue
consume(row, message.role)
continue
}
consume(row, row.kind === "file" ? "reference" : "error")
}
const last = found.at(-1)!
cursor = { sessionID: last.sessionID, partID: last.partID }
yield* pause
yield* abort(input.signal)
if (found.length < PAGE_SIZE) break
}
}
yield* pause
@@ -292,7 +295,7 @@ export namespace RecallSearch {
item,
),
sessions: items.size,
parts,
candidates,
}
})
+3 -3
View File
@@ -83,13 +83,13 @@ async function search(
}),
) // kilocode_change
const coverage = `Searched ${found.sessions} sessions and ${found.parts} transcript parts.`
const coverage = `Searched ${found.sessions} sessions and evaluated ${found.candidates} transcript candidates.`
const query = RecallSearch.inert(params.query)
if (found.results.length === 0) {
return {
title: `Search: "${query}" (no results)`,
output: RecallSearch.inert(`No sessions found matching "${params.query}". ${coverage}`),
metadata: { searchedSessions: found.sessions, searchedParts: found.parts },
metadata: { searchedSessions: found.sessions, candidateParts: found.candidates },
}
}
@@ -107,7 +107,7 @@ async function search(
return {
title: `Search: "${query}" (${found.results.length} results)`,
output: RecallSearch.inert(lines.join("\n")),
metadata: { searchedSessions: found.sessions, searchedParts: found.parts },
metadata: { searchedSessions: found.sessions, candidateParts: found.candidates },
}
}
@@ -9,7 +9,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import { eq, sql } from "drizzle-orm"
import { seedProject } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -68,6 +68,61 @@ function run(query: string, signal?: AbortSignal) {
signal,
})
}
it.instance(
"uses the recall covering index when available",
() =>
Effect.gen(function* () {
yield* seedProject
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Planner" })
const { db } = yield* Database.Service
const plan = yield* db
.all<{
detail: string
}>(sql`EXPLAIN QUERY PLAN ${RecallSearch.query([session.id], ["needle"], { sessionID: "", partID: "" })}`)
.pipe(Effect.orDie)
expect(plan.some((row) => row.detail.includes("recall_part_search_idx"))).toBe(true)
}),
{ git: true },
)
it.instance(
"recreates the recall index lazily after it is missing",
() =>
Effect.gen(function* () {
yield* seedProject
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Lazy index" })
yield* add(session.id, "user", { type: "text", text: "lazy index needle" })
const { db } = yield* Database.Service
yield* db.run(sql`DROP INDEX recall_part_search_idx`).pipe(Effect.orDie)
expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'recall_part_search_idx'`),
).toBeUndefined()
expect((yield* run("lazy index needle")).results.map((item) => item.id)).toEqual([session.id])
expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'recall_part_search_idx'`),
).toEqual({ name: "recall_part_search_idx" })
}),
{ git: true },
)
it.instance(
"continues searching when lazy index creation fails",
() =>
Effect.gen(function* () {
yield* seedProject
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Unavailable index" })
yield* add(session.id, "user", { type: "text", text: "fallback needle" })
const { db } = yield* Database.Service
yield* db.run(sql`DROP INDEX recall_part_search_idx`).pipe(Effect.orDie)
yield* db.run(sql`PRAGMA query_only = ON`).pipe(Effect.orDie)
expect((yield* run("fallback needle")).results.map((item) => item.id)).toEqual([session.id])
}),
{ git: true },
)
it.instance(
"searches titles and terms distributed across transcript messages",
() =>
@@ -282,7 +337,7 @@ it.instance(
const result = yield* run("last-session-needle")
expect(result.results).toHaveLength(1)
expect(result.sessions).toBe(143)
expect(result.parts).toBe(1_102)
expect(result.candidates).toBe(1)
}),
{ git: true },
)
@@ -301,7 +356,7 @@ it.instance(
text: `terminal ${"x".repeat(20_000)} terminal needle ${"y".repeat(20_000)}`,
})
for (let index = 0; index < 1_100; index++) {
yield* add(session.id, "user", { type: "text", text: `noise ${index}` })
yield* add(session.id, "user", { type: "text", text: `paged noise ${index}` })
}
expect((yield* run("job_id 100%")).results.map((item) => item.id)).toEqual([session.id])
@@ -311,11 +366,12 @@ it.instance(
const snippet = (yield* run("terminal needle")).results[0]?.matches[0]?.text ?? ""
expect(snippet).toContain("terminal needle")
expect(snippet.length).toBeLessThan(370)
expect((yield* run("paged noise")).results.map((item) => item.id)).toEqual([session.id])
const database = yield* Database.Service
const controller = new AbortController()
const pending = Effect.runPromise(
run("absent-needle", controller.signal).pipe(Effect.provideService(Database.Service, database)),
run("paged noise", controller.signal).pipe(Effect.provideService(Database.Service, database)),
)
queueMicrotask(() => controller.abort(new Error("cancelled recall search")))
const error = yield* Effect.promise(() => pending.catch((value: unknown) => value))