mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:32:08 +08:00
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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Speed up local session recall searches across large conversation histories.
|
||||
+173
-48
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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'))`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user