Skip to content

feat(kap-server): add global message search with literal and live-session modes - #2321

Merged
sailist merged 5 commits into
MoonshotAI:mainfrom
sailist:feat/global-search-literal
Jul 28, 2026
Merged

feat(kap-server): add global message search with literal and live-session modes#2321
sailist merged 5 commits into
MoonshotAI:mainfrom
sailist:feat/global-search-literal

Conversation

@sailist

@sailist sailist commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Related Issue

No linked issue — the problem is explained below.

Problem

kimi-inspect (the dev inspector for kap-server) had no way to search message content: finding "which session discussed X" meant opening sessions one by one. A first-cut global search existed on a branch, but three limitations made it weak in practice:

  1. The inverted index only keeps ASCII words and CJK uni/bigrams — punctuation, math symbols, and emoji are dropped, and there are no term positions. Queries like C++, $\frac{a}{b}$, or **已通过** were unsearchable, and multi-character CJK queries produced scattered-character false positives.
  2. All queries go through the persisted minidb index, which lags behind live sessions (debounced incremental sync, index-building state) — freshly streamed text is not searchable right away.
  3. The chat view had no in-session search; jumping from a global search hit into the timeline was the only navigation aid.

What changed

1. Global message search API (POST /api/v1/search)

Cross-session full-text search over user messages, assistant text, and session titles, backed by a single minidb database at <home>/search-index (write-lock holder indexes; other processes open read-only and catch up via WAL). Cursor pagination with a query-conditions fingerprint, role/time/container filters, and an index-state report.

2. Literal substring search (minidb n-gram index)

  • minidb text indexes now accept an injectable tokenizer/queryTokenizer; a hashed 2/3-gram tokenizer (NFKC + lowercase, code-point windows, crc32 buckets) powers substring search. Tokenizer names persist in db.textindexes.json, backward compatible with existing definitions.
  • mode: 'literal' on the search API: n-gram candidates are confirmed against the original text, so hits carry zero false positives; a truncated candidate set is flagged incomplete: 'candidate_cap'. The <2-character minimum applies only to the index route (it is an n-gram constraint, not a matching constraint).
  • Semantics follow Elasticsearch's wildcard field (two-stage candidate + confirm), with three deliberate deviations documented in code: case-insensitive matching, code-point windows, explicit error on 1-character queries.

3. Live session route (in-memory transcript scan)

When container.session_id is provided and that session is live in the process (TranscriptService.forSessionLive), both terms and literal modes scan the in-memory transcript store (turn prompts + assistant text frames, full history via the existing backfill) instead of the index — freshly streamed text is searchable immediately. The response carries source: 'live' | 'index', also mixed into the page-token fingerprint so a mid-pagination route flip invalidates old tokens. Live-route errors never silently fall back to the index.

4. kimi-inspect UI

  • Search view: exact-match toggle (literal mode), source: live|index badge, candidate-truncation notice; hits navigate into the chat timeline (page-back + scroll + flash).
  • Chat view: in-chat session search bar (literal substring over container.session_id, usually served by the live route) with jump-to-hit navigation.

5. Dev tooling (unrelated, small)

The repo's dev server scripts (dev:server, dev:kap-server*) now set KIMI_CODE_DEV_SERVER=1; when it is set and dist-web is missing, kimi web starts the API server without the bundled web UI instead of failing at startup, so backend dev no longer requires a web build.

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue, or explained the problem above.
  • I have added tests that prove my feature works.
  • Ran gen-changesets skill, or this PR needs no changeset. (No changeset: kap-server REST/protocol changes consumed only by dev tooling — kimi-inspect — are not user-perceivable per the changeset rules; the dev-script change is repo-internal.)
  • Ran gen-docs skill, or this PR needs no doc update. (Dev-tooling only; root AGENTS.md project map updated instead.)

sailist added 4 commits July 28, 2026 14:49
Cross-session full-text search over user messages, assistant text and
session titles, backed by a minidb index under <home>/search-index with a
single-writer lock election and read-only WAL catch-up for other
processes. Hits carry transcript anchors (turn ordinal and step id) so
clients can jump straight to the matching turn or step.
The left rail gains a Search view over the global search endpoint, with
role and sort filters and cursor pagination. Clicking a hit switches to
the chat view and navigates to its session, agent, turn and step — the
channel pages the turn into the loaded window, scrolls it into view and
flashes the target briefly.
The repo's dev server scripts (dev:server, dev:kap-server,
dev:kap-server:multi, dev:server:restart) now set KIMI_CODE_DEV_SERVER=1.
When it is set and dist-web/index.html is missing, kimi web starts the
API server without the bundled web UI instead of failing at startup,
so backend dev no longer requires a kimi-web build.
- minidb: text indexes accept an injectable tokenizer/queryTokenizer,
  and a hashed 2/3-gram tokenizer (NFKC + lowercase, code-point
  windows) backs substring search; tokenizer names persist in
  db.textindexes.json with backward-compatible defaults
- /api/v1/search gains mode: 'literal' — n-gram candidates confirmed
  against the original text (zero false positives), with an
  'candidate_cap' incomplete flag when the candidate set truncates
- container.session_id queries against a session live in this process
  scan the in-memory transcript store instead of the index (both
  modes); the response's source: live|index field names the serving
  route and rides in the page-token fingerprint
- kimi-inspect: exact-match toggle and source badge in the search
  view, plus an in-chat session search bar with jump-to-hit navigation
@changeset-bot

changeset-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: b7f9ab7

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 28, 2026

Copy link
Copy Markdown
pnpm dlx https://pkg.pr.new/@moonshot-ai/kimi-code@b7f9ab7
npx https://pkg.pr.new/@moonshot-ai/kimi-code@b7f9ab7

commit: b7f9ab7

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 165d78eb9e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +238 to +248
if (m.role === 'user') {
const origin = m.origin;
const userTyped =
origin === null ||
origin === undefined ||
(typeof origin === 'object' && isUserTypedOrigin(origin as OriginLike));
if (userTyped) {
const text = textOfContent(m.content).trim();
if (text.length > 0) messages.push({ role: 'user', text, time });
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Index assistant append_message text

When replaying older/cold wire records where assistant replies are stored as context.append_message (the transcript cold fold handles these as assistant text frames in packages/transcript/src/history/groupTurns.ts), this branch only extracts user messages, so those assistant replies never enter the persisted search index and global search silently misses them. The search surface is repo-defined as covering assistant text, so this leaves searchable transcript content out of the index.

AGENTS.md reference: AGENTS.md:L28-L28

Useful? React with 👍 / 👎.

Comment on lines +1007 to +1013
const matched =
q.mode === 'literal'
? this.matchDocs(
q,
docs.map((value) => ({ value, score: 0 })),
)
: this.matchDocs(q, matchLiveTerms(q.termsQuery ?? [], docs));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor OR in live term searches

For POST /api/v1/search requests with container.session_id pointing at a live session and op: 'OR', this path still calls matchLiveTerms without the requested operator, and that helper requires every query term to be present. The same query falls back to db.search(..., { op: q.op }) when the session is not live, so OR searches return different and incorrectly narrower results depending only on whether the session is currently materialized.

Useful? React with 👍 / 👎.

Comment on lines +1161 to +1162
} else {
candidates = db.search(TEXT_INDEX_NAME, q.query, { op: q.op, limit: MAX_TEXT_HITS });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply filters before limiting term candidates

For common term-mode queries, db.search is capped at MAX_TEXT_HITS before the container/role/time filters and requested time sort are applied in matchDocs/toPage. In a large index, a scoped search such as one session or a recent time range can have its real matches beyond the first 100k score-ranked global candidates, causing an empty or incomplete page with no incomplete flag even though matching documents exist.

Useful? React with 👍 / 👎.

@sailist
sailist merged commit ceaa969 into MoonshotAI:main Jul 28, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant