kenn-io--agentsview
f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
618 行
22 KiB
Go
618 行
22 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
DefaultSearchLimit = 50
|
|
MaxSearchLimit = 500
|
|
snippetTokenLength = 32
|
|
)
|
|
|
|
// SystemMsgPrefixes lists non-goal content prefixes that identify
|
|
// system-injected user messages. These are excluded from search results even
|
|
// when the is_system column has not been backfilled (e.g. Claude sessions
|
|
// parsed before schema version 2). Keep in sync with the frontend list in
|
|
// frontend/src/lib/utils/messages.ts.
|
|
var SystemMsgPrefixes = []string{
|
|
"This session is being continued",
|
|
"[Request interrupted",
|
|
"<task-notification>",
|
|
"<command-message>",
|
|
"<command-name>",
|
|
"<local-command-",
|
|
"Stop hook feedback:",
|
|
}
|
|
|
|
const (
|
|
legacyGoalContextPrefix = "<goal_context>"
|
|
codexInternalContextTagPrefix = "<codex_internal_context"
|
|
goalContextSourceAttr = `source="goal"`
|
|
goalContextSourceAttrSQLPrefix = ` source="goal"`
|
|
)
|
|
|
|
var goalContextSourceAttrRe = regexp.MustCompile(`(?:^|\s)` +
|
|
regexp.QuoteMeta(goalContextSourceAttr) + `(?:\s|/|$)`)
|
|
|
|
// IsGoalContextPrefixed reports whether a user-role message is a legacy
|
|
// Codex /goal continuation wrapper that may already be stored in older
|
|
// archives or read-only stores.
|
|
func IsGoalContextPrefixed(content, role string) bool {
|
|
if role != "user" {
|
|
return false
|
|
}
|
|
trimmed := strings.TrimLeft(content, systemPrefixTrimCutset)
|
|
if strings.HasPrefix(trimmed, legacyGoalContextPrefix) {
|
|
return true
|
|
}
|
|
if strings.HasPrefix(trimmed, codexInternalContextTagPrefix) {
|
|
openTag, _, ok := strings.Cut(trimmed, ">")
|
|
return ok && goalContextSourceAttrRe.MatchString(openTag)
|
|
}
|
|
return false
|
|
}
|
|
|
|
type systemPrefixSQLDialect int
|
|
|
|
const (
|
|
systemPrefixSQLite systemPrefixSQLDialect = iota
|
|
systemPrefixPostgres
|
|
systemPrefixDuckDB
|
|
)
|
|
|
|
// SystemPrefixSQL returns a SQL clause that excludes user messages
|
|
// matching any system prefix. The column alias for content must be passed
|
|
// (e.g. "m.content" or "m2.content"). Uses case-sensitive substr and
|
|
// position checks instead of LIKE, which is case-insensitive on SQLite.
|
|
func SystemPrefixSQL(contentCol, roleCol string) string {
|
|
return systemPrefixSQL(contentCol, roleCol, systemPrefixSQLite)
|
|
}
|
|
|
|
// PostgresSystemPrefixSQL is the PostgreSQL form of SystemPrefixSQL.
|
|
func PostgresSystemPrefixSQL(contentCol, roleCol string) string {
|
|
return systemPrefixSQL(contentCol, roleCol, systemPrefixPostgres)
|
|
}
|
|
|
|
// DuckDBSystemPrefixSQL is the DuckDB form of SystemPrefixSQL.
|
|
func DuckDBSystemPrefixSQL(contentCol, roleCol string) string {
|
|
return systemPrefixSQL(contentCol, roleCol, systemPrefixDuckDB)
|
|
}
|
|
|
|
func systemPrefixSQL(
|
|
contentCol, roleCol string, dialect systemPrefixSQLDialect,
|
|
) string {
|
|
// LTRIM strips the same whitespace as Go's strings.TrimSpace,
|
|
// JS .trim(), and the parser's isSystem helpers: ASCII whitespace,
|
|
// BOM (U+FEFF), and Unicode
|
|
// spaces (U+0085, U+00A0, U+1680, U+2000–U+200A, U+2028,
|
|
// U+2029, U+202F, U+205F, U+3000). SQLite, PostgreSQL, and DuckDB
|
|
// handle multi-byte UTF-8 characters in the trim set correctly.
|
|
trimmed := systemPrefixSQLTrimmed(contentCol)
|
|
parts := make([]string, 0, len(SystemMsgPrefixes)+1)
|
|
for _, p := range SystemMsgPrefixes {
|
|
parts = append(parts, fmt.Sprintf(
|
|
"substr(%s, 1, %d) = '%s'", trimmed, len(p), p,
|
|
))
|
|
}
|
|
parts = append(parts, goalContextPrefixSQL(trimmed, dialect))
|
|
guard := ""
|
|
if dialect == systemPrefixSQLite {
|
|
guard = systemPrefixFirstCPGuardSQL(contentCol) + " AND "
|
|
}
|
|
return "NOT (" + roleCol + " = 'user' AND " + guard + "(" +
|
|
strings.Join(parts, " OR ") + "))"
|
|
}
|
|
|
|
// systemPrefixFirstCPGuardSQL builds a cheap prefilter implied by every
|
|
// prefix branch of systemPrefixSQL: for any branch to match, the raw
|
|
// content's first code point must be a trimmable whitespace character or the
|
|
// first character of one of the known prefixes. unicode() returns the first
|
|
// code point as an integer (NULL for empty content, COALESCEd to 0, which is
|
|
// never in the set), so rows with ordinary content skip the repeated
|
|
// LTRIM/prefix chain after one integer IN test. The guard is AND'ed inside
|
|
// the NOT(...), so a false guard reproduces exactly the all-branches-false
|
|
// result. SQLite-only for now: PG (ascii) and DuckDB (unicode) analogues
|
|
// need their own empty-string audits before the other dialects adopt it.
|
|
func systemPrefixFirstCPGuardSQL(contentCol string) string {
|
|
seen := make(map[rune]bool)
|
|
var cps []int
|
|
add := func(r rune) {
|
|
if !seen[r] {
|
|
seen[r] = true
|
|
cps = append(cps, int(r))
|
|
}
|
|
}
|
|
for _, p := range SystemMsgPrefixes {
|
|
add([]rune(p)[0])
|
|
}
|
|
add([]rune(legacyGoalContextPrefix)[0])
|
|
add([]rune(codexInternalContextTagPrefix)[0])
|
|
for _, r := range systemPrefixTrimCutset {
|
|
add(r)
|
|
}
|
|
sort.Ints(cps)
|
|
items := make([]string, len(cps))
|
|
for i, cp := range cps {
|
|
items[i] = strconv.Itoa(cp)
|
|
}
|
|
return "COALESCE(unicode(" + contentCol + "), 0) IN (" +
|
|
strings.Join(items, ", ") + ")"
|
|
}
|
|
|
|
func systemPrefixSQLTrimmed(contentCol string) string {
|
|
return "LTRIM(" + contentCol + ", ' \t\n\v\f\r" +
|
|
"\u0085\u00A0\u1680" +
|
|
"\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A" +
|
|
"\u2028\u2029\u202F\u205F\u3000\uFEFF')"
|
|
}
|
|
|
|
func goalContextPrefixSQL(trimmed string, dialect systemPrefixSQLDialect) string {
|
|
legacy := fmt.Sprintf("substr(%s, 1, %d) = '%s'",
|
|
trimmed, len(legacyGoalContextPrefix), legacyGoalContextPrefix)
|
|
current := fmt.Sprintf(
|
|
"(substr(%[1]s, 1, %[2]d) = '%[3]s' AND %[4]s)",
|
|
trimmed, len(codexInternalContextTagPrefix),
|
|
codexInternalContextTagPrefix,
|
|
goalContextSourceAttrSQL(openingTagSQL(trimmed, dialect), dialect),
|
|
)
|
|
return "(" + legacy + " OR " + current + ")"
|
|
}
|
|
|
|
func openingTagSQL(trimmed string, dialect systemPrefixSQLDialect) string {
|
|
return fmt.Sprintf("substr(%s, 1, %s)",
|
|
trimmed, sqlPosition(dialect, ">", trimmed))
|
|
}
|
|
|
|
func goalContextSourceAttrSQL(
|
|
openTag string, dialect systemPrefixSQLDialect,
|
|
) string {
|
|
normalized := openTag
|
|
for _, ws := range []string{"\t", "\n", "\v", "\f", "\r"} {
|
|
normalized = fmt.Sprintf("replace(%s, '%s', ' ')", normalized, ws)
|
|
}
|
|
checks := []string{
|
|
sqlContains(dialect, normalized, goalContextSourceAttrSQLPrefix+" "),
|
|
sqlContains(dialect, normalized, goalContextSourceAttrSQLPrefix+">"),
|
|
sqlContains(dialect, normalized, goalContextSourceAttrSQLPrefix+"/>"),
|
|
}
|
|
return "(" + strings.Join(checks, " OR ") + ")"
|
|
}
|
|
|
|
func sqlContains(
|
|
dialect systemPrefixSQLDialect, haystack, needle string,
|
|
) string {
|
|
return sqlPosition(dialect, needle, haystack) + " > 0"
|
|
}
|
|
|
|
func sqlPosition(
|
|
dialect systemPrefixSQLDialect, needle, haystack string,
|
|
) string {
|
|
quotedNeedle := "'" + needle + "'"
|
|
if dialect == systemPrefixPostgres {
|
|
return fmt.Sprintf("POSITION(%s IN %s)", quotedNeedle, haystack)
|
|
}
|
|
return fmt.Sprintf("instr(%s, %s)", haystack, quotedNeedle)
|
|
}
|
|
|
|
// systemPrefixTrimCutset is the leading-whitespace set SystemPrefixSQL's
|
|
// LTRIM strips: ASCII whitespace, BOM, and the Unicode spaces. Kept
|
|
// identical so the Go and SQL system-prefix checks agree.
|
|
const systemPrefixTrimCutset = " \t\n\v\f\r" +
|
|
"\u0085\u00A0\u1680" +
|
|
"\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A" +
|
|
"\u2028\u2029\u202F\u205F\u3000\uFEFF"
|
|
|
|
// IsSystemPrefixed reports whether a user-role message is a system-injected
|
|
// message identified by a SystemMsgPrefixes prefix. It is the Go equivalent
|
|
// of SystemPrefixSQL for callers that filter in Go rather than SQL: only
|
|
// user-role messages match, and leading whitespace is trimmed with the same
|
|
// cutset before the case-sensitive prefix comparison.
|
|
func IsSystemPrefixed(content, role string) bool {
|
|
if role != "user" {
|
|
return false
|
|
}
|
|
if IsGoalContextPrefixed(content, role) {
|
|
return true
|
|
}
|
|
trimmed := strings.TrimLeft(content, systemPrefixTrimCutset)
|
|
for _, p := range SystemMsgPrefixes {
|
|
if strings.HasPrefix(trimmed, p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SearchResult holds a session-level match with the best-ranked snippet.
|
|
type SearchResult struct {
|
|
SessionID string `json:"session_id"`
|
|
Project string `json:"project"`
|
|
Agent string `json:"agent"`
|
|
Name string `json:"name"`
|
|
Ordinal int `json:"ordinal"`
|
|
SessionEndedAt string `json:"session_ended_at"`
|
|
Snippet string `json:"snippet"`
|
|
Rank float64 `json:"rank"`
|
|
}
|
|
|
|
// SearchFilter specifies search parameters.
|
|
type SearchFilter struct {
|
|
Query string
|
|
Project string
|
|
Sort string // "relevance" (default) or "recency"
|
|
Cursor int // offset for pagination
|
|
Limit int
|
|
}
|
|
|
|
// SearchPage holds paginated search results.
|
|
type SearchPage struct {
|
|
Results []SearchResult `json:"results"`
|
|
NextCursor int `json:"next_cursor,omitempty"`
|
|
}
|
|
|
|
// Search performs FTS5 full-text search across messages, grouped by session,
|
|
// plus a LIKE-based search on session display names and first messages.
|
|
//
|
|
// Results come from two branches joined with UNION ALL:
|
|
//
|
|
// 1. FTS branch — message content matches. ROW_NUMBER() picks the single
|
|
// best-ranked message per session (rank ASC, ordinal ASC, rowid ASC).
|
|
// The outer JOIN messages_fts includes a MATCH clause to prevent segment
|
|
// duplicates. Ordinal is the matched message's ordinal (≥ 0).
|
|
//
|
|
// 2. Name branch — display_name / first_message LIKE matches that are NOT
|
|
// already covered by the FTS branch. Ordinal is -1 (no specific message
|
|
// to navigate to).
|
|
func (db *DB) Search(
|
|
ctx context.Context, f SearchFilter,
|
|
) (SearchPage, error) {
|
|
if f.Limit <= 0 || f.Limit > MaxSearchLimit {
|
|
f.Limit = DefaultSearchLimit
|
|
}
|
|
f.Query = PrepareFTSQuery(f.Query)
|
|
|
|
// ORDER BY for the outer query. FTS5 ranks are negative (lower = better),
|
|
// so rank ASC places message matches (negative rank) before name-only rows
|
|
// (rank=0.0). Within FTS results, match_pos ASC prefers earlier positions.
|
|
// julianday() normalises RFC3339Nano text to a numeric value, avoiding
|
|
// lexicographic misorderings from variable fractional-second precision
|
|
// (e.g. "…T12:00:00Z" vs "…T12:00:00.123Z"). SQLite NULLs sort smaller
|
|
// than any value, so julianday(NULL) DESC naturally places NULLs last.
|
|
orderBy := "rank ASC, match_pos ASC, julianday(session_ended_at) DESC, session_id ASC"
|
|
if f.Sort == "recency" {
|
|
orderBy = "julianday(session_ended_at) DESC, session_id ASC"
|
|
}
|
|
|
|
// innerWhere is used in three places: the ROW_NUMBER inner subquery,
|
|
// the outer MATCH re-filter, and the NOT IN subquery for the name branch.
|
|
innerWhere := []string{
|
|
"messages_fts MATCH ?",
|
|
"s2.deleted_at IS NULL",
|
|
"m2.is_system = 0",
|
|
SystemPrefixSQL("m2.content", "m2.role"),
|
|
}
|
|
ftsArgs := []any{f.Query} // args for one copy of innerWhere
|
|
|
|
nameProjectClause := ""
|
|
var nameProjectArgs []any
|
|
if f.Project != "" {
|
|
innerWhere = append(innerWhere, "s2.project = ?")
|
|
ftsArgs = append(ftsArgs, f.Project)
|
|
nameProjectClause = "AND s.project = ?"
|
|
nameProjectArgs = []any{f.Project}
|
|
}
|
|
|
|
innerWhereSQL := strings.Join(innerWhere, " AND ")
|
|
// Strip FTS quoting before substring operations. PrepareFTSQuery wraps
|
|
// each term in double quotes for FTS (e.g. "fix bug" → `"fix" "bug"`).
|
|
// LIKE and instr() must use the plain text form so name/content substring
|
|
// searches work correctly.
|
|
plainQuery := StripFTSQuotes(f.Query)
|
|
if plainQuery == "" {
|
|
return SearchPage{}, nil
|
|
}
|
|
likePattern := "%" + escapeLike(plainQuery) + "%"
|
|
|
|
// Build args in the order the SQL placeholders appear.
|
|
// Position 0 (? AS best_query in the ROW_NUMBER SELECT) is
|
|
// prepended after this block — see args2 below.
|
|
//
|
|
// pos | SQL clause | value
|
|
// ----+---------------------------------------------+------------
|
|
// 0 | SELECT ? AS best_query (ROW_NUMBER) | plainQuery ← prepended in args2
|
|
// 1 | WHERE messages_fts MATCH ? (ROW_NUMBER) | ftsArgs[0] (f.Query)
|
|
// [1+]| AND s2.project = ? (if project set) | ftsArgs[1] (f.Project)
|
|
// 2 | WHERE messages_fts MATCH ? (outer JOIN) | f.Query
|
|
// 3 | WHEN COALESCE(display_name,session_name) LIKE ? (CASE) | likePattern
|
|
// 4 | WHEN s.first_message LIKE ? (CASE) | likePattern
|
|
// 5 | WHERE COALESCE(display_name,session_name) LIKE ? (name WHERE) | likePattern
|
|
// 6 | WHERE s.first_message LIKE ? (name WHERE) | likePattern
|
|
// [7] | AND s.project = ? (name branch, optional) | f.Project
|
|
// 8 | WHERE messages_fts MATCH ? (NOT IN) | ftsArgs[0]
|
|
// [8+]| AND s2.project = ? (NOT IN, if set) | ftsArgs[1]
|
|
// 9 | LIMIT ? OFFSET ? | f.Limit+1, f.Cursor
|
|
args := make([]any, 0, len(ftsArgs)*2+6+len(nameProjectArgs))
|
|
args = append(args, ftsArgs...) // (1) ROW_NUMBER WHERE
|
|
args = append(args, f.Query) // (2) outer MATCH re-filter
|
|
args = append(args, likePattern) // (3) CASE COALESCE(display_name,session_name) LIKE
|
|
args = append(args, likePattern) // (4) CASE first_message LIKE
|
|
args = append(args, likePattern) // (5) name WHERE COALESCE(display_name,session_name) LIKE
|
|
args = append(args, likePattern) // (6) name WHERE first_message LIKE
|
|
args = append(args, nameProjectArgs...) // (7) optional name branch project
|
|
args = append(args, ftsArgs...) // (8) NOT IN WHERE
|
|
args = append(args, f.Limit+1, f.Cursor) // (9) LIMIT / OFFSET
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT session_id, project, agent, name,
|
|
session_ended_at, ordinal, snippet, rank, match_pos
|
|
FROM (
|
|
-- FTS branch: message content matches
|
|
SELECT m.session_id, s.project, s.agent,
|
|
COALESCE(s.display_name, s.session_name, s.first_message, '') AS name,
|
|
COALESCE(s.ended_at, s.started_at, '') AS session_ended_at,
|
|
best.best_ordinal AS ordinal,
|
|
snippet(messages_fts, 0, '<mark>', '</mark>',
|
|
'...', %d) AS snippet,
|
|
best.best_rank AS rank,
|
|
instr(LOWER(m.content), LOWER(best.best_query))
|
|
AS match_pos
|
|
FROM (
|
|
SELECT session_id, best_rowid, best_ordinal, best_rank, best_query
|
|
FROM (
|
|
SELECT m2.session_id,
|
|
messages_fts.rowid AS best_rowid,
|
|
m2.ordinal AS best_ordinal,
|
|
rank AS best_rank,
|
|
? AS best_query,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY m2.session_id
|
|
ORDER BY rank ASC, m2.ordinal ASC,
|
|
messages_fts.rowid ASC
|
|
) AS rn
|
|
FROM messages_fts
|
|
JOIN messages m2 ON messages_fts.rowid = m2.id
|
|
JOIN sessions s2 ON m2.session_id = s2.id
|
|
WHERE %s
|
|
)
|
|
WHERE rn = 1
|
|
) AS best
|
|
JOIN messages_fts ON messages_fts.rowid = best.best_rowid
|
|
JOIN messages m ON m.id = best.best_rowid
|
|
JOIN sessions s ON m.session_id = s.id
|
|
WHERE messages_fts MATCH ?
|
|
|
|
UNION ALL
|
|
|
|
-- Name branch: display_name / session_name / first_message matches not in FTS branch
|
|
SELECT s.id, s.project, s.agent,
|
|
COALESCE(s.display_name, s.session_name, s.first_message, '') AS name,
|
|
COALESCE(s.ended_at, s.started_at, '') AS session_ended_at,
|
|
-1 AS ordinal,
|
|
CASE
|
|
WHEN COALESCE(s.display_name, s.session_name) LIKE ? ESCAPE '\'
|
|
THEN COALESCE(s.display_name, s.session_name, '')
|
|
WHEN s.first_message LIKE ? ESCAPE '\'
|
|
THEN COALESCE(s.first_message, '')
|
|
ELSE COALESCE(s.display_name, s.session_name, s.first_message, '')
|
|
END AS snippet,
|
|
0.0 AS rank,
|
|
0 AS match_pos
|
|
FROM sessions s
|
|
WHERE (COALESCE(s.display_name, s.session_name) LIKE ? ESCAPE '\'
|
|
OR s.first_message LIKE ? ESCAPE '\')
|
|
AND s.deleted_at IS NULL
|
|
AND EXISTS (
|
|
SELECT 1 FROM messages mx
|
|
WHERE mx.session_id = s.id
|
|
AND mx.is_system = 0
|
|
AND `+SystemPrefixSQL("mx.content", "mx.role")+`
|
|
)
|
|
%s
|
|
AND s.id NOT IN (
|
|
SELECT m2.session_id
|
|
FROM messages_fts
|
|
JOIN messages m2 ON messages_fts.rowid = m2.id
|
|
JOIN sessions s2 ON m2.session_id = s2.id
|
|
WHERE %s
|
|
)
|
|
)
|
|
ORDER BY %s
|
|
LIMIT ? OFFSET ?`,
|
|
snippetTokenLength,
|
|
innerWhereSQL, // ROW_NUMBER inner WHERE (%s at rn subquery)
|
|
nameProjectClause, // optional project filter for name branch (%s)
|
|
innerWhereSQL, // NOT IN subquery WHERE (%s)
|
|
orderBy, // ORDER BY (%s)
|
|
)
|
|
|
|
// Replace the ROW_NUMBER inner subquery's ? for best_query with args
|
|
// re-ordered: the first innerWhere param (f.Query) was already included in
|
|
// ftsArgs above at position (1); best_query needs a second copy of f.Query
|
|
// injected at the right position. Re-build args with the extra copy.
|
|
//
|
|
// The inner subquery's SELECT has `? AS best_query` before the WHERE, so
|
|
// its ? comes before innerWhere's ?s. Rebuild:
|
|
args2 := make([]any, 0, len(args)+1)
|
|
args2 = append(args2, plainQuery) // best_query: plain text for instr()
|
|
args2 = append(args2, args...)
|
|
args = args2
|
|
|
|
rows, err := db.getReader().QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return SearchPage{}, fmt.Errorf("searching: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []SearchResult
|
|
for rows.Next() {
|
|
var r SearchResult
|
|
var matchPos int
|
|
if err := rows.Scan(
|
|
&r.SessionID, &r.Project, &r.Agent, &r.Name,
|
|
&r.SessionEndedAt, &r.Ordinal,
|
|
&r.Snippet, &r.Rank, &matchPos,
|
|
); err != nil {
|
|
return SearchPage{},
|
|
fmt.Errorf("scanning result: %w", err)
|
|
}
|
|
results = append(results, r)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return SearchPage{}, err
|
|
}
|
|
|
|
page := SearchPage{Results: results}
|
|
if len(results) > f.Limit {
|
|
page.Results = results[:f.Limit]
|
|
page.NextCursor = f.Cursor + f.Limit
|
|
}
|
|
return page, nil
|
|
}
|
|
|
|
// SearchSession performs a case-insensitive substring search within a single
|
|
// session's messages, returning matching ordinals in document order.
|
|
// This is used by the in-session find bar (analogous to browser Cmd+F).
|
|
// Both message content and tool-call result_content are searched so that
|
|
// matches inside tool output blocks are reachable. Only fields that the
|
|
// frontend renders and highlights are included to avoid phantom matches.
|
|
func (db *DB) SearchSession(
|
|
ctx context.Context, sessionID, query string,
|
|
) ([]int, error) {
|
|
if query == "" {
|
|
return nil, nil
|
|
}
|
|
// Use LIKE for substring semantics consistent with browser find-bar UX.
|
|
// SQLite LIKE is case-insensitive for ASCII by default.
|
|
// LEFT JOIN tool_calls so that a hit in result_content also surfaces
|
|
// the parent message ordinal; DISTINCT collapses multiple tool calls
|
|
// on the same message into a single result.
|
|
like := "%" + escapeLike(query) + "%"
|
|
rows, err := db.getReader().QueryContext(ctx,
|
|
`SELECT DISTINCT m.ordinal
|
|
FROM messages m
|
|
LEFT JOIN tool_calls tc ON tc.message_id = m.id
|
|
WHERE m.session_id = ?
|
|
AND m.is_system = 0
|
|
AND `+SystemPrefixSQL("m.content", "m.role")+`
|
|
AND (m.content LIKE ? ESCAPE '\'
|
|
OR tc.result_content LIKE ? ESCAPE '\')
|
|
ORDER BY m.ordinal ASC`,
|
|
sessionID, like, like,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("session search: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var ordinals []int
|
|
for rows.Next() {
|
|
var ord int
|
|
if err := rows.Scan(&ord); err != nil {
|
|
return nil, fmt.Errorf("scanning ordinal: %w", err)
|
|
}
|
|
ordinals = append(ordinals, ord)
|
|
}
|
|
return ordinals, rows.Err()
|
|
}
|
|
|
|
// PrepareFTSQuery turns a user's raw search input into a well-formed SQLite
|
|
// FTS5 MATCH expression. Each whitespace-separated term is wrapped in double
|
|
// quotes (with any embedded quote doubled, per FTS5 escaping), which makes
|
|
// punctuation literal inside the term and combines the terms under FTS5's
|
|
// implicit AND. Quoting is what prevents a single token containing an FTS5
|
|
// operator character (e.g. "error-401" or "status:500") from being parsed as
|
|
// query syntax and raising a malformed-query error (an HTTP 500).
|
|
//
|
|
// An empty/whitespace-only input is returned unchanged. An input the caller
|
|
// already opened with a double quote is treated as a deliberate FTS5 expression
|
|
// (including an explicit "exact phrase") and is passed through untouched, so
|
|
// exact-phrase matching remains opt-in via a leading quote.
|
|
//
|
|
// This is the single source of truth shared by the SQLite, PostgreSQL, and HTTP
|
|
// search paths so the same user query behaves identically across backends.
|
|
func PrepareFTSQuery(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" || strings.HasPrefix(raw, `"`) {
|
|
return raw
|
|
}
|
|
var b strings.Builder
|
|
for i, term := range strings.Fields(raw) {
|
|
if i > 0 {
|
|
b.WriteByte(' ')
|
|
}
|
|
b.WriteByte('"')
|
|
b.WriteString(strings.ReplaceAll(term, `"`, `""`))
|
|
b.WriteByte('"')
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// FTSTerms decomposes a PrepareFTSQuery output back into its individual terms,
|
|
// un-doubling escaped quotes inside quoted terms and collecting bare tokens. A
|
|
// multi-term AND query like `"error" "401"` yields ["error", "401"], a single
|
|
// quoted operator token `"error-401"` yields ["error-401"], and an explicit
|
|
// exact phrase `"fix bug"` yields a single ["fix bug"] term. This lets the
|
|
// substring backends (SQLite name-branch LIKE/instr, PostgreSQL ILIKE)
|
|
// reconstruct the same AND-of-terms vs. exact-phrase semantics the FTS engine
|
|
// applies, keeping behavior identical across backends.
|
|
func FTSTerms(v string) []string {
|
|
if !strings.Contains(v, `"`) {
|
|
if v = strings.TrimSpace(v); v == "" {
|
|
return nil
|
|
}
|
|
return strings.Fields(v)
|
|
}
|
|
var terms []string
|
|
var cur strings.Builder
|
|
inQuote := false
|
|
hasTerm := false
|
|
flush := func() {
|
|
if hasTerm {
|
|
terms = append(terms, cur.String())
|
|
cur.Reset()
|
|
hasTerm = false
|
|
}
|
|
}
|
|
for i := 0; i < len(v); i++ {
|
|
c := v[i]
|
|
switch {
|
|
case c == '"':
|
|
if inQuote && i+1 < len(v) && v[i+1] == '"' {
|
|
// Doubled quote inside a quoted term is a literal quote.
|
|
cur.WriteByte('"')
|
|
hasTerm = true
|
|
i++
|
|
continue
|
|
}
|
|
inQuote = !inQuote
|
|
hasTerm = true
|
|
case !inQuote && (c == ' ' || c == '\t' || c == '\n' || c == '\r'):
|
|
flush()
|
|
default:
|
|
cur.WriteByte(c)
|
|
hasTerm = true
|
|
}
|
|
}
|
|
flush()
|
|
return terms
|
|
}
|
|
|
|
// StripFTSQuotes reverses PrepareFTSQuery into a plain substring suitable for
|
|
// LIKE and instr() operations (name-branch matching, snippet centering). It
|
|
// rejoins the parsed FTS terms with single spaces. So `"unique" "phrase"`
|
|
// becomes "unique phrase", a single quoted token like `"error-401"` becomes
|
|
// "error-401", and an explicit phrase `"fix bug"` becomes "fix bug". Input with
|
|
// no quotes is returned unchanged.
|
|
func StripFTSQuotes(v string) string {
|
|
if !strings.Contains(v, `"`) {
|
|
return v
|
|
}
|
|
return strings.Join(FTSTerms(v), " ")
|
|
}
|