Skip to content

fix: Japanese searches skip the category and summary result tiers - #3363

Merged
Patrick-Erichsen merged 5 commits into
openclaw:mainfrom
Yigtwxx:fix/japanese-search-tokens
Aug 5, 2026
Merged

fix: Japanese searches skip the category and summary result tiers#3363
Patrick-Erichsen merged 5 commits into
openclaw:mainfrom
Yigtwxx:fix/japanese-search-tokens

Conversation

@Yigtwxx

@Yigtwxx Yigtwxx commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

Fixes an issue where searching in Japanese returns fewer skills than it should. A query
whose katakana contains the prolonged sound mark — which covers most of the loanword
vocabulary this registry is full of, データベース, サーバー, ユーザーインターフェース,
コンピューター — never reaches the category, topic and summary match tiers, and matches
only on name and slug.

The affected surfaces are skill search on /search, the skills and plugins catalog
search, and the search digest that backs the catalog index.

Why This Change Was Made

tokenize() delegates word segmentation to Intl.Segmenter, which handles Japanese
correctly. But before the segmenter runs, the text is split on a hand-written character
class, and that class omits (U+30FC) and (U+3005). Both are therefore treated as
separators, so the word is already in pieces by the time the segmenter sees it:

input tokenize() on main with this change
データベース ["デ", "タベ", "ス"] ["データベース"]
データベース管理 ["デ", "タベ", "ス", "管理"] ["データベース", "管理"]
ユーザーインターフェース ["ユ", "ザ", "インタ", "フェ", "ス"] ["ユーザー", "インターフェース"]
コンピューター ["コンピュ", "タ"] ["コンピューター"]
人々 ["人"] — the is dropped ["人々"]

Two things follow from that.

Exploratory search requires every query token to clear a three-character floor
(EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH, defined identically in convex/search.ts,
convex/skills.ts and convex/packages.ts, enforced by
matchesExploratoryTokenPrefixes). ["デ", "タベ", "ス"] cannot clear it, so rank tiers 2
and 3 — category/topic and summary — are unreachable for these queries.
["データベース"] clears it.

And getFirstSearchToken in convex/lib/skillSearchDigest.ts is tokenize(value)[0].
Its result is stored as normalizedDisplayNameFirstToken and normalizedSlugFirstToken,
which are indexed columns used as range-scan bounds in convex/search.ts. A skill named
データベース管理 indexes under the single character instead of データベース, so the
bound stops being selective.

The same file already disagrees with itself about this. detectCJKLanguage, 53 lines
below, counts katakana with the full Unicode block /[゠-ヿ]/, which does match
— that is how the text is correctly routed to the Japanese segmenter in the first
place. The pre-split then excludes the character the language detector just counted. This
change makes the pre-split agree with it.

I deliberately added only and rather than widening to the full katakana
block. Widening produces identical tokenize() output — Intl.Segmenter already reports
(U+30FB) as not word-like — but it would also pull , and into the
segmentCJKByChar fallback as standalone tokens. The narrow change avoids that.

Non-goals:

  • Replacing the hand-written ranges with Unicode property escapes. That is a larger
    change to a hot path and \p{Script=Katakana} does not match either, so it would
    not fix this on its own.
  • Re-indexing existing rows. The digest is recomputed on write; this only changes what
    future writes and queries produce.

User Impact

Japanese-language searches now reach the same result tiers as English and Chinese ones,
so a query like データベース can match a skill on its category, topics or summary rather
than only on its name. Japanese skills also index under their actual first word instead
of a single character.

Evidence

Base commit: a9d04bb0. The fix is not on main — both character classes there still
end ァ-ヺ가-힯.

Focused tests

convex/lib/searchText.test.ts already existed, but its only Japanese assertion was
expect(tokens.length).toBeGreaterThan(0), while the Chinese cases directly above it
assert real segmentation. This raises the Japanese cases to that standard and adds the
regressions. On the parent commit:

 FAIL  convex/lib/searchText.test.ts > searchText > CJK tokenization > keeps katakana words that contain a prolonged sound mark intact
AssertionError: expected [ 'デ', 'タベ', 'ス' ] to deeply equal [ 'データベース' ]
 FAIL  convex/lib/searchText.test.ts > searchText > CJK tokenization > keeps the iteration mark attached to the character it repeats
AssertionError: expected [ '人' ] to deeply equal [ '人々' ]
 FAIL  convex/lib/searchText.test.ts > searchText > CJK tokenization > lets katakana queries reach the exploratory match tiers
AssertionError: expected false to be true

 Test Files  1 failed (1)
      Tests  3 failed | 15 passed (18)

With the change:

 Test Files  1 passed (1)
      Tests  18 passed (18)

The 15 assertions that already passed still pass, including every Chinese, Korean and
ASCII case — tokenize("中文搜索"), tokenize("안녕하세요"), tokenize("React 组件开发")
and tokenize("Minimax Usage /minimax-usage") all produce identical output before and
after.

The two consequences, measured

matchesExploratoryTokenPrefixes(tokenize("データベース"), ["データベース管理ツール"], 3)
  on main          : false
  with this change : true

getFirstSearchToken("データベース管理")     // = tokenize(value)[0]
  on main          : "デ"
  with this change : "データベース"

The intra-file inconsistency, checked directly:

/[゠-ヿ]/.test("ー")   // detectCJKLanguage  -> true
/[ァ-ヺ]/.test("ー")   // CJK_RE on main     -> false

Deployment consequence: rows written before this tokenizer

skillSearchDigest.normalizedSlugFirstToken and normalizedDisplayNameFirstToken are
produced by this tokenizer (getFirstSearchToken is tokenize(value)[0]), and
convex/search.ts:1327-1364 uses them as range-index bounds. Those rows are recomputed
only when their skill is written, so after deploying this change an untouched row keeps
the old one-character token while the search computes the new longer one. The row stays
on disk and drops out of that first-token range lookup.

Measured on the samples this branch is about - two rows that do not move are kept in as
the control:

DRIFT  データベース管理        main="デ"        branch="データベース"
DRIFT  ユーザーインターフェース  main="ユ"        branch="ユーザー"
DRIFT  人々の記録             main="人"        branch="人々"
DRIFT  時々のレポート          main="時"        branch="時々"
DRIFT  コードレビュー          main="コ"        branch="コード"
DRIFT  サーバー監視ツール       main="サ"        branch="サーバー"
same   こんにちは世界          main="こんにちは"  branch="こんにちは"
same   中文文档管理            main="中文"       branch="中文"
drifted 6 / 8

maintenance.ts gains backfillSkillSearchDigestFirstTokens, a cursor-paginated
internal mutation that self-schedules until done, plus the admin-gated action that starts
it. It is written against the shape the file already uses for
backfillSkillSearchDigestModerationVerdicts directly above it: same args, same
clampInt(batchSize, 10, 200), same dryRun, same return record.

Batch spacing between backfill pages

skillSearchDigest is read by live catalog-search subscriptions, and
.agents/skills/clawhub-convex/SKILL.md:102 asks for a delay between backfill batches
that write reactively subscribed tables. The mutation takes an optional delayMs,
clamped the way the batch size already is, and hands it to the runAfter that schedules
the next page:

const delayMs = clampInt(args.delayMs ?? 500, 0, 60_000);

That follows repairLegacyPublisherOwnershipForUserHandler
(convex/maintenance.ts:3272), which is the one backfill in this file that already
spaces its own batches. Two assertions cover it - the 500 ms default, and an explicit
2000 ms plus the clamp that turns 600000 ms into 60000 ms. Restoring runAfter(0, ...)
turns both of them red:

control (unmutated)      : 37 passed (37)
mutant: runAfter(0, ...) : 2 failed - repairs digest rows whose stored first tokens
                           predate the tokenizer; spaces the next batch by the
                           requested delay and clamps it
restored                 : 37 passed (37)

Real Convex runtime: a pre-existing row, before and after

A browser capture cannot show this one. Search runs inside Convex, so a locally changed
tokenizer is not what a deployed frontend talks to. The equivalent real setup is
convex-test, which runs the actual Convex query engine and the real schema against an
in-memory database - the same harness convex/catalogFeed.runtime.test.ts and
convex/publishAttempts.runtime.test.ts already use.

convex/skillSearchDigestFirstTokens.runtime.test.ts inserts a real skills row and a
real skillSearchDigest row holding the pre-change token , then queries the index the
search actually queries (by_active_normalized_display_name_first_token) with the bounds
the search computes:

stored row  : normalizedDisplayNameFirstToken = "デ"
query bounds: gte "データベース"  lt "データベーソ"

recall before backfill : 0 rows
backfill               : { scanned: 1, patched: 1, missingSkills: 0 }
recall after backfill  : 1 row

The second run of the backfill over the same data reports patched: 0, so it is
idempotent and safe to re-run.

Live local Convex: the admin backfill path end to end

The runtime test above proves the index behavior. This is the same migration driven
through the CLI against a real Convex backend - an anonymous local deployment created by
bunx convex dev, cloud port 3210, site port 3211.

This transcript was recorded at 8d376a48, before the confirmation guard below existed.
The two dry runs at steps 4 and 7 behave identically today. The applying call at step 5
is now a preview as written and needs
{"batchSize":10,"delayMs":15000,"dryRun":false,"confirm":"backfill-skill-search-digest-first-tokens"}
to do what its output shows. What a page does once it starts - the paging, the 15 s
spacing, the idempotence - is what that commit leaves untouched.

fixtures/public-corpus/corpus.jsonl holds no record whose slug or display name carries
(U+30FC) or (U+3005) - zero of its 1250 rows - so the 13 seeded skills are
constructed: twelve Japanese display names carrying those marks, plus one Latin control.
They are seeded while a9d04bb0 is the deployed code, so their digest rows are written by
the old tokenizer, and this branch is deployed over them afterwards. Cursor strings are
cut from the output below; nothing else is edited.

## 4. dry run: how many stored rows the new tokenizer disagrees with
$ bunx convex run maintenance:backfillSkillSearchDigestFirstTokensInternal {"dryRun":true}
{
  "dryRun": true,
  "isDone": true,
  "missingSkills": 0,
  "patched": 12,
  "scanned": 13
}
## 5. one page, spaced 15s before the next one is scheduled
[11:02:59] starting page 1
$ bunx convex run maintenance:backfillSkillSearchDigestFirstTokensInternal {"batchSize":10,"delayMs":15000}
{
  "dryRun": false,
  "isDone": false,
  "missingSkills": 0,
  "patched": 10,
  "scanned": 10
}
## 6. poll until the scheduled continuation lands
[11:03:02] poll 01 -> "patched":2
[11:03:03] poll 02 -> "patched":2
[11:03:05] poll 03 -> "patched":2
[11:03:07] poll 04 -> "patched":2
[11:03:08] poll 05 -> "patched":2
[11:03:10] poll 06 -> "patched":2
[11:03:11] poll 07 -> "patched":2
[11:03:13] poll 08 -> "patched":2
[11:03:14] poll 09 -> "patched":2
[11:03:16] poll 10 -> "patched":2
[11:03:18] poll 11 -> "patched":0
## 7. idempotence: a further pass patches nothing
$ bunx convex run maintenance:backfillSkillSearchDigestFirstTokensInternal {"dryRun":true}
{
  "dryRun": true,
  "isDone": true,
  "missingSkills": 0,
  "patched": 0,
  "scanned": 13
}

Twelve of the thirteen stored rows disagree with the new tokenizer and the Latin control
does not. The first page patches ten and returns isDone: false; the remaining two stay
stale for the whole 15 s the scheduled continuation waits, then land between 11:03:16
and 11:03:18. A further dry run patches nothing, so a re-run is safe.

What this run does not show is a user-visible recall change. convex run search:searchSkills for データベース, サーバー, 人々 and Deploy, before and after
deploying this branch over the stale rows, returned the same one row each time. The
first-token range lookup is one of several candidate sources inside nativeSkillSearch,
and full-text search on displayName still matched on a catalog this small. The recall
this backfill restores is specific to that range lookup, which is what the runtime test
isolates.

The mirrored skills.sh catalog carries the same keys

skillsShMirrorDigests persists its own normalizedSlugFirstToken and
normalizedDisplayNameFirstToken, and convex/search.ts:1073-1099 range-scans both when
it collects external candidates. They come out of the same tokenizer this PR widens -
convex/skillsShMirror.ts had its own firstSearchToken, tokenize(value)[0] ?? normalizedSearchText(value) - so mirrored rows drift exactly the way native digest rows
do, and the native backfill above cannot reach them because it pages skillSearchDigest.

backfillSkillsShMirrorDigestFirstTokens is the mirror-side counterpart: same args, same
clampInt(batchSize, 10, 200), same delayMs, same dryRun, same admin-gated action.
The two copies of the first-token rule are now one - getMirrorFirstSearchToken in
convex/lib/skillSearchDigest.ts, next to the getFirstSearchToken the native digest
already used - so they cannot drift apart again.

Both halves are covered, and each is load-bearing:

control (unmutated)                              : 39 passed (39)
mutant A: stop recomputing the display-name token: 2 failed
mutant B: runAfter(0, ...) instead of delayMs     : 1 failed
restored                                         : 39 passed (39)

On the live local deployment the mutation reaches the real table - seeding the one
mirrored row devSeed:seedCanonicalSearchFixture creates and running the backfill over it
reports { scanned: 1, patched: 0 }, correct for a Latin display name. The drift case
itself is proved in convex/maintenance.test.ts rather than live: nothing in the local
dev-seed path produces a mirrored row with a Japanese display name, and the importer
protocol that would create one needs a run, a stored source page and a batch lease.

The no-Segmenter fallback

Admitting the two marks into CJK_RE also reaches segmentCJKByChar, the path taken when
Intl.Segmenter is missing. It emits one token per character, so the marks came out
standing alone - one-character tokens that exploratory matching discards at its
three-character floor. Measured before the fix:

segmentCJKByChar("データベース") -> ["デ","ー","タ","ベ","ー","ス"]
segmentCJKByChar("人々")       -> ["人","々"]

They are now attached to the character they extend (["デー","タ","ベー","ス"],
["人々"]), which is the same rule the widened class states. Removing that branch turns
exactly one test red.

Preview by default, confirmation to apply

Both backfills first landed here with dryRun defaulting to false, and their public
actions forward omitted arguments to the mutation unchanged. A bare
npx convex run maintenance:backfillSkillSearchDigestFirstTokens therefore patched
skillSearchDigest and scheduled every remaining page: a table-wide write against a
reactively subscribed table, started by a command that reads like an inspection.

They now follow the contract resyncPluginCatalogMetadataDigestsBatchInternal already
uses in this file:

const dryRun = args.dryRun !== false;
if (!dryRun && args.confirm !== SKILL_SEARCH_DIGEST_FIRST_TOKEN_BACKFILL_CONFIRM) {
  throw new ConvexError(
    `Pass confirm="${SKILL_SEARCH_DIGEST_FIRST_TOKEN_BACKFILL_CONFIRM}" to apply.`,
  );
}

Three details beyond copying that shape:

  • The scheduled continuation carries confirm with it. Without that, page two throws on
    the guard page one passed, and a confirmed run stalls after its first batch.
  • The native and mirror paths take different tokens
    (backfill-skill-search-digest-first-tokens and
    backfill-skills-sh-mirror-digest-first-tokens), so neither one starts the other
    table's rewrite.
  • A preview returns confirmRequired, so the token comes out of the dry run an operator
    already has to do rather than out of the source.

Four new tests cover it, two per path: omitted arguments preview without writing or
scheduling, and an apply with no token or the other path's token throws before the first
paginate. The three existing tests that exercise a real page now pass the token and
assert it in the runAfter payload. The runtime test drives the guard through a Convex
backend as well - the unconfirmed call reports patched: 1 and leaves the row holding
its stale , and only the confirmed call rewrites it.

Live local Convex: the guard itself

Same setup as the transcript above, rebuilt: an anonymous local deployment, thirteen
constructed rows seeded while a9d04bb0 is deployed so their digest rows carry the old
tokenizer's output, then this branch deployed over them. Eleven of the thirteen drift.
Cursor strings are cut; nothing else is edited.

## 1. omitted arguments
$ bunx convex run maintenance:backfillSkillSearchDigestFirstTokensInternal {}
{
  "confirmRequired": "backfill-skill-search-digest-first-tokens",
  "dryRun": true,
  "isDone": true,
  "missingSkills": 0,
  "patched": 11,
  "scanned": 13
}
## 2. apply with no token
$ bunx convex run maintenance:backfillSkillSearchDigestFirstTokensInternal {"dryRun":false}
Uncaught ConvexError: Pass confirm="backfill-skill-search-digest-first-tokens" to apply.
    at handler (../convex/maintenance.ts:2713:8)
exit=1
## 3. apply with the mirror path's token
$ ... {"dryRun":false,"confirm":"backfill-skills-sh-mirror-digest-first-tokens"}
Uncaught ConvexError: Pass confirm="backfill-skill-search-digest-first-tokens" to apply.
exit=1
## 4. neither rejected call wrote anything
$ bunx convex run maintenance:backfillSkillSearchDigestFirstTokensInternal {}
  "dryRun": true, "patched": 11, "scanned": 13
## 5. confirmed apply
$ ... {"dryRun":false,"confirm":"backfill-skill-search-digest-first-tokens"}
  "dryRun": false, "isDone": true, "missingSkills": 0, "patched": 11, "scanned": 13
## 6. idempotence
$ bunx convex run maintenance:backfillSkillSearchDigestFirstTokensInternal {}
  "dryRun": true, "patched": 0, "scanned": 13

The mirror action rejects the same way, including the native path's token, on a mirror
table this fixture leaves empty (scanned: 0), so that pair shows the guard rather than
a migration:

$ bunx convex run maintenance:backfillSkillsShMirrorDigestFirstTokensInternal {"dryRun":false}
Uncaught ConvexError: Pass confirm="backfill-skills-sh-mirror-digest-first-tokens" to apply.
    at handler (../convex/maintenance.ts:2815:8)
exit=1
$ ... {"dryRun":false,"confirm":"backfill-skill-search-digest-first-tokens"}
Uncaught ConvexError: Pass confirm="backfill-skills-sh-mirror-digest-first-tokens" to apply.
exit=1
$ ... {"dryRun":false,"confirm":"backfill-skills-sh-mirror-digest-first-tokens"}
  "dryRun": false, "isDone": true, "patched": 0, "scanned": 0

The token has to survive the scheduler or a confirmed run applies its first page and then
throws on the guard that page just passed. Re-seeded, then one confirmed page of ten with
the continuation 15 s out, polled by dry run:

[22:07:35] page 1
$ ... {"batchSize":10,"delayMs":15000,"dryRun":false,"confirm":"backfill-skill-search-digest-first-tokens"}
  "dryRun": false, "isDone": false, "missingSkills": 0, "patched": 10, "scanned": 10
[22:07:39] poll 1 -> "patched":1
[22:07:41] poll 2 -> "patched":1
[22:07:43] poll 3 -> "patched":1
[22:07:45] poll 4 -> "patched":1
[22:07:47] poll 5 -> "patched":1
[22:07:49] poll 6 -> "patched":1
[22:07:51] poll 7 -> "patched":1
[22:07:53] poll 8 -> "patched":1
[22:07:55] poll 9 -> "patched":0
[22:07:57] poll 10 -> "patched":0

The eleventh row stays stale for the whole delay and then lands. A continuation that lost
the token would have thrown instead, and that row would still read "patched":1 at poll
10.

Rollout

Both backfills are admin-gated and are meant to run once after this deploys, native
first, then mirror. Both preview by default, so this form counts rows without writing:

npx convex run maintenance:backfillSkillSearchDigestFirstTokens --prod
npx convex run maintenance:backfillSkillsShMirrorDigestFirstTokens --prod

Applying takes the token that preview returns as confirmRequired:

npx convex run maintenance:backfillSkillSearchDigestFirstTokens \
  '{"dryRun": false, "confirm": "backfill-skill-search-digest-first-tokens"}' --prod
npx convex run maintenance:backfillSkillsShMirrorDigestFirstTokens \
  '{"dryRun": false, "confirm": "backfill-skills-sh-mirror-digest-first-tokens"}' --prod

Both accept {"delayMs": N} if the default 500 ms between pages is too tight. Both are
idempotent, so a re-run after a partial pass is safe.

CI

  • bun run lint, bun run deadcode:ci, bun run ci:types-build — all pass.
  • bunx tsc -p packages/schema/tsconfig.json --noEmit and
    bunx tsc -p packages/clawhub/tsconfig.json --noEmit — both clean.
  • bun run format:check fails on CLAUDE.md and
    .agents/skills/autoreview/CLAUDE.md. That is pre-existing: the same two files, and
    only those two, fail on main at a9d04bb0. The two files in this PR are formatted
    with oxfmt.
  • VITE_CONVEX_URL=https://example.invalid bun run ci:unit on this branch:
    14 failed | 431 passed | 1 skipped (446) files, 24 failed | 5790 passed | 9 skipped
    tests. On main at a9d04bb0: 14 failed | 430 passed | 1 skipped (445) files and
    24 failed | 5773 passed | 9 skipped tests. The sorted list of failing files is
    identical on both sides — they are the suites that shell out to bun, plus
    convex/lib/githubAccount.test.ts and src/routes/-management.test.tsx. The failure
    count is the same 24 on both sides. This branch adds one file and seventeen passing
    tests and introduces no new failure.

The Vercel check on this PR stays pending OpenClaw Foundation authorization, as it does
on every fork PR.

Written with AI assistance. I ran the focused tests, the drift measurement and the
Convex runtime proof above myself,
diffed the full unit suite against main, and can maintain this code.

The pre-split in tokenize() treats U+30FC (ー) and U+3005 (々) as separators, so
a katakana word is torn into fragments before Intl.Segmenter can segment it:
"データベース" tokenizes as ["デ", "タベ", "ス"]. Two consequences follow.

Exploratory search requires every query token to be at least three characters
(EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH in search.ts, skills.ts and packages.ts),
so katakana queries never reach the category, topic and summary tiers. And
getFirstSearchToken feeds normalizedDisplayNameFirstToken, an indexed range-scan
bound, which collapses to the single character "デ".

detectCJKLanguage in the same file already counts ー as katakana when it picks a
segmenter; the pre-split now agrees with it.
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

@Yigtwxx is attempting to deploy a commit to the OpenClaw Foundation Team on Vercel.

A member of the Team first needs to authorize it.

@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal backlog priority with limited blast radius. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. labels Aug 5, 2026
@clawsweeper

clawsweeper Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed August 5, 2026, 7:14 PM ET / 23:14 UTC.

ClawSweeper review

What this changes

The branch preserves Japanese prolonged and iteration marks during search tokenization and adds confirmed, rate-limited backfills for native and mirrored catalog search-index first tokens.

Regression provenance

Possible regression — probable (reproduction; reviewed change). No predecessor PR is attributed.

Merge readiness

⚠️ Ready for maintainer review - 3 items remain

Keep open for maintainer rollout approval: the tokenizer fix is sound, but existing native and mirrored digest rows require two authenticated production backfills after deployment. Likely related people: Patrick Erichsen (high confidence).

Priority: P2
Reviewed head: bdd16378da6bbd4a69159f3687154766811c7b31
Owner decision: Required. See Decision needed.

Review scores

Measure Result What it means
Overall readiness 🐚 platinum hermit (4/6) The focused root-cause repair, regression coverage, and real Convex proof are strong; production migration ownership remains a maintainer release decision.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (terminal): The PR includes after-fix local Convex runtime transcripts for confirmation, scheduling, and idempotence, plus a real-schema index test; the mirror drift case has focused coverage.
Patch quality 🐚 platinum hermit (4/6) No actionable review findings were identified.

Verification

Check Result Evidence
Real behavior Verified Sufficient (terminal): The PR includes after-fix local Convex runtime transcripts for confirmation, scheduling, and idempotence, plus a real-schema index test; the mirror drift case has focused coverage.
Evidence reviewed 5 items Current-main defect: Current main's CJK class and pre-split exclude U+30FC and U+3005, while Japanese language detection recognizes the katakana block; this fragments or drops those marks before segmentation.
Affected search contract: Native and mirrored searches use tokenizer-derived first-token range bounds, and exploratory matching rejects any query token shorter than the three-character floor.
Persisted compatibility surface: Both digest tables persist first-token fields; native fields are optional while mirror fields are required and both are indexed for catalog lookup.
Findings None None.
Security None None.

How this fits together

ClawHub catalog search tokenizes user queries and stored skill metadata, then uses those tokens for match tiers and indexed candidate retrieval. The proposed backfills update persisted digest tokens so existing catalog rows remain compatible with the new query tokenization.

flowchart LR
  A[Japanese query or skill name] --> B[Search tokenizer]
  B --> C[Search tokens]
  C --> D[Match tiers and index bounds]
  E[Stored catalog digests] --> F[Confirmed backfill]
  F --> D
  D --> G[Catalog search results]
Loading

Decision needed

Question Recommendation
Will a production operator run the native and skills.sh mirror first-token backfills immediately after this deploy and verify both dry runs reach zero remaining patches? Schedule the two backfills with the deploy: Merge with an operator-owned post-deploy preview, confirmed native run, confirmed mirror run, and final zero-drift verification.

Why: The changed tokenizer alters persisted index-bound values; code review cannot perform the authenticated production migration or decide whether a transient stale-index window is acceptable.

Before merge

  • Resolve merge risk (P1) - Existing native and mirrored rows retain old first-token values until authenticated operators run both confirmed backfills after deployment; during that window, affected rows can miss the new first-token range lookups.
  • Complete next step (P2) - No mechanical defect remains to route automatically; a maintainer must own the authenticated production migration and verification.
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Change size production +225/-11, tests +610/-2 across 7 files Most added code is targeted regression and migration coverage for a small tokenizer change with persisted-index consequences.

Merge-risk options

Maintainer options:

  1. Ship with operator-owned migration (recommended)
    Use the PR's dry-run, confirmation tokens, batch delay, and final zero-drift checks for both digest tables immediately after deployment.
  2. Accept a staged repair window
    Merge only if maintainers explicitly accept temporarily reduced first-token index recall for old Japanese rows until the backfills are run.
  3. Pause for rollout ownership
    Keep the PR open if no authenticated operator can own the two production backfills and verification.

Technical review

Best possible solution:

Merge the tokenizer repair only with an explicit production rollout that previews and then runs both confirmed digest backfills, followed by a production dry-run verifying no stale rows remain.

Do we have a high-confidence way to reproduce the issue?

Yes. Current main's source directly shows the two marks excluded before segmentation, and the supplied focused regression evidence demonstrates the resulting token and match-tier failure.

Is this the best way to solve the issue?

Yes. Repairing tokenization at its shared source and resynchronizing both native and mirrored persisted first-token fields is narrower and safer than weakening the three-character search threshold or relying on a partial candidate source.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 9009eae00355.

Labels

Label changes:

  • add rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • remove rating: 🦞 diamond lobster: Current PR rating is rating: 🐚 platinum hermit, so this older rating label is no longer current.

Label justifications:

  • P2: Japanese searches lose category and summary matching tiers, but the report establishes no outage, security breach, or data loss.
  • merge-risk: 🚨 compatibility: Current persisted first-token index values must be rewritten after the tokenizer changes to preserve first-token range lookup coverage.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): The PR includes after-fix local Convex runtime transcripts for confirmation, scheduling, and idempotence, plus a real-schema index test; the mirror drift case has focused coverage.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR includes after-fix local Convex runtime transcripts for confirmation, scheduling, and idempotence, plus a real-schema index test; the mirror drift case has focused coverage.

Evidence

What I checked:

  • Current-main defect: Current main's CJK class and pre-split exclude U+30FC and U+3005, while Japanese language detection recognizes the katakana block; this fragments or drops those marks before segmentation. (convex/lib/searchText.ts:1, 9009eae00355)
  • Affected search contract: Native and mirrored searches use tokenizer-derived first-token range bounds, and exploratory matching rejects any query token shorter than the three-character floor. (convex/search.ts:1036, 9009eae00355)
  • Persisted compatibility surface: Both digest tables persist first-token fields; native fields are optional while mirror fields are required and both are indexed for catalog lookup. (convex/schema.ts:1410, 9009eae00355)
  • Feature provenance: The search tokenizer, native digest, search retrieval, and mirror implementation trace to the same current-main introduction commit, making this a shared feature-boundary defect rather than a one-sided fix. (convex/lib/searchText.ts:1, 87ca030c30f3)
  • Current-main status: The PR head is not contained in current main, whose search tokenizer still has the defective character class. (convex/lib/searchText.ts:119, 9009eae00355)

Likely related people:

  • Patrick Erichsen: Current-main blame attributes the tokenizer, digest first-token fields, retrieval, and mirror token helper to the feature-introduction commit. (role: introduced current search and digest behavior; confidence: high; commits: 87ca030c30f3; files: convex/lib/searchText.ts, convex/lib/skillSearchDigest.ts, convex/search.ts)

Rank-up moves

Optional improvements that raise the rating; they are not merge blockers.

  • Assign an authenticated operator to preview, apply, and verify both production first-token backfills after deployment.

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (24 earlier review cycles; latest 8 shown)
  • reviewed 2026-08-05T16:04:04.557Z sha 8d376a4 :: needs changes before merge. :: [P2] Require confirmation before first-token backfills write
  • reviewed 2026-08-05T16:16:49.351Z sha 8d376a4 :: needs changes before merge. :: [P2] Require confirmation before first-token backfills write
  • reviewed 2026-08-05T17:43:31.792Z sha 8d376a4 :: needs changes before merge. :: [P2] Require explicit confirmation before backfills write
  • reviewed 2026-08-05T18:52:30.869Z sha bdd1637 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-05T18:56:40.998Z sha bdd1637 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-05T19:13:07.842Z sha bdd1637 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-05T20:25:11.787Z sha bdd1637 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-05T21:51:10.863Z sha bdd1637 :: needs maintainer review before merge. :: none

@Patrick-Erichsen Patrick-Erichsen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This changes Japanese tokenization so category and summary search tiers can use longer tokens. It changes the persisted prefix-index contract without migrating existing rows, and the supported no-Segmenter fallback still fails the stated behavior.

LOC: +29/-4 (2 files)

Findings: add an idempotent rate-controlled skillSearchDigest backfill for existing rows, and make/test the no-Intl.Segmenter path retain usable Japanese word runs rather than standalone marks or sub-three-character tokens.

Best-fix verdict: too narrow. Tokenizer correctness and derived-index migration are one deployment contract; changing only query/write logic leaves existing catalog rows inconsistent.

Alternatives considered: lazy rewrite on later skill mutation leaves search broken indefinitely; lowering the exploratory floor would weaken all languages instead of fixing Japanese token formation.

Code read: tokenizer/digest generation, prefix-index query path, fallback branch, and focused tests.

Remaining uncertainty: no real catalog/Convex search proof or rollout plan is supplied.

@Yigtwxx
Yigtwxx requested a review from a team as a code owner August 5, 2026 06:36
Widening the CJK class moves the first token of any name containing a
prolonged sound mark or an iteration mark. skillSearchDigest rows recompute
that field only when their skill is written, so already-stored rows keep the
old one-character token while search uses the new longer token as a range
index bound - the row stays on disk and out of recall.

Add a cursor-paginated resynchronization next to the existing digest backfills
in maintenance.ts, and stop the no-Segmenter fallback from emitting those two
marks as standalone tokens that exploratory matching discards.
@clawsweeper clawsweeper Bot added the merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. label Aug 5, 2026
The catalog search page subscribes to skillSearchDigest, so a backfill that
reschedules itself with no delay drives reactive re-reads back to back for the
whole run. .agents/skills/clawhub-convex/SKILL.md asks for a delay between
backfill batches that write reactively subscribed tables.

The delay is an optional argument clamped the same way the batch size is, and it
follows repairLegacyPublisherOwnershipForUserHandler, which is the one backfill
in this file that already spaces its batches.
@Yigtwxx

Yigtwxx commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

@Patrick-Erichsen both findings are addressed, the second half of the first one in a follow-up commit.

Idempotent, rate-controlled backfill for existing rows - fa8eb54d adds backfillSkillSearchDigestFirstTokens, a cursor-paginated internal mutation plus the admin-gated action that starts it, written against the shape backfillSkillSearchDigestModerationVerdicts directly above it uses. Idempotence is proved rather than asserted: convex/skillSearchDigestFirstTokens.runtime.test.ts runs the real Convex query engine against the real schema, inserts a skillSearchDigest row holding the pre-change token , and queries the index search actually queries with the bounds search actually computes - recall goes 0 rows -> backfill { scanned: 1, patched: 1, missingSkills: 0 } -> 1 row, and a second run over the same data reports patched: 0.

The rate-control half was missing and is now in 815df69a. skillSearchDigest is read by live catalog-search subscriptions, and .agents/skills/clawhub-convex/SKILL.md:102 asks for a delay between backfill batches that write reactively subscribed tables, so the mutation takes an optional delayMs clamped the way the batch size already is - clampInt(args.delayMs ?? 500, 0, 60_000) - and hands it to the runAfter that schedules the next page. That follows repairLegacyPublisherOwnershipForUserHandler at convex/maintenance.ts:3272, the one backfill in this file that already spaces its own batches. Restoring runAfter(0, ...) turns both of the covering assertions red.

No-Intl.Segmenter path - fa8eb54d also fixes segmentCJKByChar, which is the branch taken when Intl.Segmenter is missing. Widening CJK_RE alone left the two marks standing alone there, as one-character tokens that exploratory matching discards at its three-character floor:

before: segmentCJKByChar("データベース") -> ["デ","ー","タ","ベ","ー","ス"]
        segmentCJKByChar("人々")       -> ["人","々"]
after : ["デー","タ","ベー","ス"]
        ["人々"]

Each mark is attached to the character it extends, which is the rule the widened class states. Removing that branch turns exactly one test red.

Verification on 815df69a: bunx tsc --noEmit clean, bunx oxfmt --check clean on both changed files, and VITE_CONVEX_URL=https://example.invalid bun run ci:unit gives 14 failed | 431 passed | 1 skipped (446) files and 24 failed | 5784 passed | 9 skipped tests against 14 failed | 430 passed | 1 skipped (445) and 24 failed | 5773 passed | 9 skipped on main at a9d04bb0. The sorted list of the 14 failing files is identical on both sides.

One thing worth flagging rather than hiding: the delay now differs from the ten other self-continuing backfills in convex/maintenance.ts, which all schedule with runAfter(0, ...) - including backfillSkillSearchDigestModerationVerdictsInternal, which patches the same table. I followed the written guidance rather than the surrounding code, but if you would rather this match its neighbours I can drop it.

Also note that this branch now touches convex/maintenance.ts, which CODEOWNERS routes to @openclaw/openclaw-secops.

@clawsweeper clawsweeper Bot removed the merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. label Aug 5, 2026
@Yigtwxx

Yigtwxx commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

The live-deployment proof is now in the PR body under Evidence -> Live local Convex: the admin backfill path end to end.

It is the migration driven through the CLI against a real Convex backend - an anonymous local deployment from bunx convex dev, cloud port 3210, site port 3211 - not convex-test. Thirteen skills are seeded while a9d04bb0 is the deployed code, so their digest rows are written by the old tokenizer, and this branch is deployed over them afterwards:

$ bunx convex run maintenance:backfillSkillSearchDigestFirstTokensInternal {"dryRun":true}
{ "dryRun": true, "isDone": true, "missingSkills": 0, "patched": 12, "scanned": 13 }

[11:02:59] starting page 1
$ bunx convex run maintenance:backfillSkillSearchDigestFirstTokensInternal {"batchSize":10,"delayMs":15000}
{ "dryRun": false, "isDone": false, "missingSkills": 0, "patched": 10, "scanned": 10 }

[11:03:02] poll 01 -> "patched":2
[11:03:07] poll 04 -> "patched":2
[11:03:14] poll 09 -> "patched":2
[11:03:16] poll 10 -> "patched":2
[11:03:18] poll 11 -> "patched":0

$ bunx convex run maintenance:backfillSkillSearchDigestFirstTokensInternal {"dryRun":true}
{ "dryRun": true, "isDone": true, "missingSkills": 0, "patched": 0, "scanned": 13 }

Twelve of thirteen rows disagree with the new tokenizer; the Latin control does not. The first page patches ten and returns isDone: false, the remaining two stay stale for the whole 15 s the scheduled continuation waits, and a further dry run patches nothing. That is the throttling working on a real deployment rather than only in a mock.

Two things I would rather state than leave for you to find:

  • The seeded rows are constructed. fixtures/public-corpus/corpus.jsonl has no record whose slug or display name carries (U+30FC) or (U+3005) - zero of 1250 - so there was nothing in the real corpus to drift.
  • This run does not show a user-visible recall change. convex run search:searchSkills for データベース, サーバー, 人々 and Deploy returned the same one row before and after the tokenizer swap, because the first-token range lookup is one of several candidate sources in nativeSkillSearch and full-text search on displayName still matched on a catalog this small. The recall the backfill restores is specific to that range lookup, which is what convex/skillSearchDigestFirstTokens.runtime.test.ts isolates. I have softened the wording in the PR body accordingly - it now says the stale row drops out of the first-token range lookup rather than out of recall generally.

@clawsweeper clawsweeper Bot added proof: sufficient Contributor real behavior proof is sufficient. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Aug 5, 2026
The skills.sh mirror persists its own normalizedSlugFirstToken and
normalizedDisplayNameFirstToken, derived through the same tokenizer, and external
candidate search range-scans both. Widening the katakana class therefore strands
mirrored rows exactly the way it stranded native digest rows, and the previous
backfill only paged skillSearchDigest.

skillsShMirror.ts had its own copy of the first-token rule. Both callers now share
getMirrorFirstSearchToken so the two cannot drift apart again.
@Yigtwxx

Yigtwxx commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

The mirror finding is fixed in 8d376a48.

It is a real gap and the diagnosis is right: skillsShMirrorDigests persists its own normalizedSlugFirstToken and normalizedDisplayNameFirstToken, convex/search.ts:1073-1099 range-scans both when collecting external candidates, and convex/skillsShMirror.ts derived them through its own copy of the rule - tokenize(value)[0] ?? normalizedSearchText(value) - so widening the katakana class strands mirrored rows the same way it stranded native ones. The previous backfill paged skillSearchDigest only and could never reach them.

backfillSkillsShMirrorDigestFirstTokens is the mirror-side counterpart of the native backfill: same args, same clampInt(batchSize, 10, 200), same delayMs spacing, same dryRun, same admin-gated action wrapper.

The duplicated rule is also gone. getMirrorFirstSearchToken now lives in convex/lib/skillSearchDigest.ts beside the getFirstSearchToken the native digest already used, and skillsShMirror.ts imports it, so the two copies cannot drift apart again.

Both halves of the new mutation are load-bearing:

control (unmutated)                               : 39 passed (39)
mutant A: stop recomputing the display-name token : 2 failed
mutant B: runAfter(0, ...) instead of delayMs     : 1 failed
restored                                          : 39 passed (39)

Verification on 8d376a48: bunx tsc --noEmit clean, bun run lint and bun run deadcode:ci clean, bunx oxfmt --check clean on all four changed files, convex/skillsShMirror.test.ts convex/skillsShMirrorVisibility.test.ts convex/search.test.ts convex/lib/searchText.test.ts convex/skillSearchDigestFirstTokens.runtime.test.ts 156 passed, and bun run ci:unit gives 14 failed | 431 passed | 1 skipped (446) files and 24 failed | 5786 passed | 9 skipped tests against 14 failed | 430 passed | 1 skipped (445) and 24 failed | 5773 passed | 9 skipped on main at a9d04bb0, with an identical list of the 14 failing files.

One limit worth stating plainly: on the live local deployment I could only prove the mutation reaches the real table. Seeding the single mirrored row devSeed:seedCanonicalSearchFixture creates and running the backfill over it reports { scanned: 1, patched: 0 }, which is correct for a Latin display name. The drift case itself is covered by convex/maintenance.test.ts, not live, because nothing in the local dev-seed path produces a mirrored row with a Japanese display name and the importer protocol that would create one needs a run, a stored source page and a batch lease. If you would rather see it end to end, say so and I will add a small dev-seed fixture for a mirrored Japanese row.

@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Aug 5, 2026
@Yigtwxx

Yigtwxx commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

On the fallback finding - "Skip leading Japanese extenders in the fallback (P2) ... The Segmenter path drops such non-word segments" - I do not think that premise holds, so I have not made the change. The measurement, on the current head 8d376a48:

input        Intl.Segmenter (isWordLike)      segmentCJKByChar
ーデータ      ["ーデータ"]                      ["ー","デー","タ"]
々人         ["々","人"]                       ["々","人"]
ー人々の記録   ["ー","人々","の","記録"]           ["ー","人々","の","記","録"]
データベース   ["データベース"]                   ["デー","タ","ベー","ス"]

Same output on Node v22.20.0 (V8) and on Bun 1.3.14 (JSC).

Two things follow:

  1. The Segmenter does not drop unattached extenders. On ー人々の記録 it emits as its own segment with isWordLike === true, so segmentCJK keeps it. The fallback emitting alone there is parity, not divergence.
  2. On 々人 the two paths are already byte-identical. Skipping unattached extenders would turn the fallback into ["人"] and make it disagree with the Segmenter on the one input where they currently agree exactly.

Where the two really differ - データベース as ["データベース"] against ["デー","タ","ベー","ス"] - is the per-character nature of the fallback, which predates this PR and is what the fallback is for. This PR's change to it was narrower: before, and were excluded from CJK_RE entirely, so segmentCJKByChar("データベース") returned ["デ","ー","タ","ベ","ー","ス"] with the marks stranded as one-character tokens that exploratory matching discards at its three-character floor. They are now attached to the character they extend. A mark in leading position has no character to extend, and dropping it would lose the only signal a slug like ーデータ carries in that position.

Happy to be wrong here - if the intent is that the fallback should normalise more aggressively than the Segmenter rather than approximate it, that is a different and larger change than this PR, and I would rather do it separately than bolt it on.

On the remaining rollout item: both backfills are admin-gated and must be run once after deploy, native first, then mirror.

npx convex run maintenance:backfillSkillSearchDigestFirstTokens --prod
npx convex run maintenance:backfillSkillsShMirrorDigestFirstTokens --prod

Both accept {"dryRun": true} for a count-only pass and {"delayMs": N} if the default 500 ms between pages is too tight for production. I have added that to the PR body so it is not buried in a comment.

@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Aug 5, 2026
Both backfills defaulted dryRun to false, and their public admin actions
forward omitted arguments straight through. A bare
`npx convex run maintenance:backfillSkillSearchDigestFirstTokens` therefore
patched skillSearchDigest and scheduled every remaining page, against a table
catalog search subscribes to. An operator typo was an immediate production
apply rather than a preview.

Both now follow the contract the plugin catalog-digest resync already uses:
preview unless dryRun is explicitly false, reject an apply whose confirm token
does not match, and carry that token into the scheduled continuation so the
run does not stall on its own guard after the first page. The native and
mirror paths take separate tokens, so neither unlocks the other.
@clawsweeper clawsweeper Bot removed the rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. label Aug 5, 2026
@Yigtwxx

Yigtwxx commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

The confirmation finding is fixed in bdd16378.

That one was mine to fix rather than argue with. dryRun defaulted to false on both new
backfills and both public actions forward omitted arguments to the mutation unchanged, so
npx convex run maintenance:backfillSkillSearchDigestFirstTokens --prod - a command that
reads like an inspection - patched skillSearchDigest and scheduled every remaining page.

Both now use the contract resyncPluginCatalogMetadataDigestsBatchInternal already has in
this file: const dryRun = args.dryRun !== false, and an apply whose confirm does not
match throws before the first paginate.

Three things beyond copying that shape:

  1. The scheduled continuation carries confirm. Without it a confirmed run applies
    page one and then throws on the guard page one just passed, which would be a worse
    failure than the one being fixed - a half-migrated index and no obvious signal.
  2. The two paths take separate tokens. backfill-skill-search-digest-first-tokens
    does not start the mirror rewrite, and a test pins that direction specifically.
  3. A preview returns confirmRequired. The token comes out of the dry run an operator
    already has to run, not out of the source.

Coverage: four new tests, two per path - omitted arguments preview without writing or
scheduling, and an apply with no token or the other path's token throws. The three
existing tests that drive a real page now pass the token and assert it in the runAfter
payload. The runtime test drives the guard through a Convex backend too: the unconfirmed
call reports patched: 1 and leaves the row holding its stale , and only the confirmed
call rewrites it.

VITE_CONVEX_URL=https://example.invalid bunx vitest run convex/maintenance.test.ts \
  convex/lib/searchText.test.ts convex/skillSearchDigestFirstTokens.runtime.test.ts
Test Files  3 passed (3)
     Tests  65 passed (65)

Reverting only convex/maintenance.ts and keeping the tests turns 7 of them red.

One thing I did not do: re-record the live local Convex transcript in the PR body. It was
taken at 8d376a48 and I have labelled it as such there. Its two dry runs behave
identically today; its applying call now needs the token to do what its output shows. The
guard sits in front of the paging, the 15 s spacing and the idempotence that transcript
exists to demonstrate, and none of that changed, so re-seeding a local deployment at the
old revision to reproduce it would not test anything the runtime case does not already
cover against a real backend. Say the word if you would rather have it re-recorded anyway.

The rollout section in the PR body now carries both forms, preview and apply.

@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. and removed status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. labels Aug 5, 2026
@Yigtwxx

Yigtwxx commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

I said above that I had not re-recorded the live transcript. I have now, for the guard
specifically - it turned out to be cheap. Full text is in the PR body under Live local
Convex: the guard itself
; the short version, on an anonymous local deployment with
thirteen rows seeded while a9d04bb0 was deployed and this branch deployed over them:

## 1. omitted arguments
$ bunx convex run maintenance:backfillSkillSearchDigestFirstTokensInternal {}
  "confirmRequired": "backfill-skill-search-digest-first-tokens",
  "dryRun": true, "patched": 11, "scanned": 13
## 2. apply with no token
$ ... {"dryRun":false}
Uncaught ConvexError: Pass confirm="backfill-skill-search-digest-first-tokens" to apply.
    at handler (../convex/maintenance.ts:2713:8)
## 3. apply with the mirror path's token
$ ... {"dryRun":false,"confirm":"backfill-skills-sh-mirror-digest-first-tokens"}
Uncaught ConvexError: Pass confirm="backfill-skill-search-digest-first-tokens" to apply.
## 4. neither rejected call wrote anything
$ ... {}   -> "patched": 11, "scanned": 13
## 5. confirmed apply
$ ... {"dryRun":false,"confirm":"backfill-skill-search-digest-first-tokens"}
  -> "dryRun": false, "patched": 11, "scanned": 13

The mirror action rejects the same way, including on the native path's token.

The part I most wanted off a mock is the scheduler. Re-seeded, one confirmed page of ten,
continuation 15 s out, polled by dry run:

[22:07:35] page 1 -> "isDone": false, "patched": 10, "scanned": 10
[22:07:39] poll 1 -> "patched":1
...
[22:07:53] poll 8 -> "patched":1
[22:07:55] poll 9 -> "patched":0
[22:07:57] poll 10 -> "patched":0

The eleventh row sits stale for the whole delay and then lands. A continuation that
dropped confirm would have thrown on the guard its own first page had just passed, and
that row would still read "patched":1 at poll 10 - so the propagation is what that
flip measures, not just the paging.

No code change since bdd16378; this is evidence only.

@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. and removed merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. labels Aug 5, 2026
@Patrick-Erichsen
Patrick-Erichsen merged commit cd09e33 into openclaw:main Aug 5, 2026
96 of 98 checks passed
@Yigtwxx

Yigtwxx commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for merging this.

One thing this PR cannot do for itself, flagged here so it does not get lost: the two
first-token backfills still need to be run once against production, native first, then
mirror. Until they are, rows written before this tokenizer keep their old one-character
token while the search computes the new longer one, so they drop out of the first-token
range lookups in convex/search.ts — the native ones at 1327-1364 and the mirrored ones
at 1073-1099.

Both preview by default, so this form counts the affected rows without writing anything
and returns the token needed to apply:

npx convex run maintenance:backfillSkillSearchDigestFirstTokens --prod
npx convex run maintenance:backfillSkillsShMirrorDigestFirstTokens --prod
npx convex run maintenance:backfillSkillSearchDigestFirstTokens \
  '{"dryRun": false, "confirm": "backfill-skill-search-digest-first-tokens"}' --prod
npx convex run maintenance:backfillSkillsShMirrorDigestFirstTokens \
  '{"dryRun": false, "confirm": "backfill-skills-sh-mirror-digest-first-tokens"}' --prod

Both accept {"delayMs": N} if the default 500 ms between pages is too tight for
production, both are idempotent, so a re-run after a partial pass is safe, and a final dry
run reporting patched: 0 is the check that nothing is left. @Patrick-Erichsen — happy to
help verify the counts if you want a second pair of eyes on the output.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. P2 Normal backlog priority with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants