The completion guard added in a8d3c23 read `cacheMap.size > 0`, but
`cacheMap` is the tenant-wide map that `getWikiPage` also writes to on
every single-page fetch. One prior `openproject_get_wiki_page` call on
the same cache key therefore leaves the map non-empty before the probe
loop issues its first request, and a scan in which every probe fails
with a 500 inherits that entry and marks itself discovery-complete
anyway — permanently, for the process lifetime.
"Get a wiki page, then search" is an ordinary LLM tool sequence, so the
contaminated path is likely rather than exotic, and it lands back in
exactly the failure this branch exists to eliminate: wiki search returns
[] forever and the model tells the user their pages do not exist.
Completion is now judged on growth during the scan (`cacheMap.size >
cachedBefore`), captured before the probe loop. The cold-cache
behaviour is unchanged, since `cachedBefore` is then 0 and the condition
is equivalent — both existing F5b tests stay green.
A permanently-failing instance now re-probes on every search. That is
the accepted cost of retrying rather than caching a lie.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`query` on `openproject_list_work_package_activities` returns
`rankRecords(...)`, which drops everything below `DEFAULT_MIN_SCORE`, so
it filters as well as ranks. The schema description, README, and ADR-019
all said "rank". A model told "rank" passes a query to a list tool
expecting a reordered full history and then reasons over a silently
truncated timeline.
Behaviour is unchanged — filtering is the more useful behaviour and the
tests pin it. Only the wording is corrected, plus tests that keep the
wording from regressing. Also records the response-shape and pagination
changes from the preceding commits in ADR-019 and the README.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`getWikiPage`'s attachments fetch had a bare `catch` that swallowed
401/403/429 one function away from discovery code that correctly
rethrows them. During a 50-page discovery scan a rate limit was eaten
once per page and every page cached with `attachments: []`, then
reported to the model as fact.
The discovery probe loop incremented `consecutiveMisses` only on 404, so
a run where every probe failed with a 500 or a network error never
tripped the cutoff, never rethrew, and cached nothing — yet still ran
`discoveredCacheKeys.add(cacheKey)` unconditionally, marking discovery
complete. Every later wiki search then short-circuited on an empty
corpus and returned `[]` for the life of the process.
Discovery is now marked complete only when at least one page was cached
or the loop ended via the 404 cutoff. An all-errors scan satisfies
neither, so it retries; a genuinely empty wiki trips the cutoff and is
still marked complete rather than re-probed on every search.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three fixes to the meetings service:
- `offset` was documented as a 1-based page number but implemented as an
item index, so with `pageSize: 20` a request for page 2 returned items
2-21 — nineteen of the twenty the caller already had. This changes the
observable behaviour of `openproject_search_meetings`: paging now
returns disjoint pages. Shipping two identical pagination bugs with
only the work package one fixed would have been worse.
- `author` was added as a shallow field but never to the `matchType`
union, so `resolveMatchType` fell through and reported a meeting found
by a person's name as having matched on project name.
- `getMeeting` retained `.catch(() => undefined)` on its agenda-item
fetch, swallowing 401/403/429. This is the tool a model calls
immediately after a search hit, so an expired token would have been
reported as "this meeting has no agenda items".
Also drops the duplicate `items` array from `MeetingSearchPage`; the
`items` field on `PaginatedResult` and on `listMeetings` is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The broad candidate leg was replaced with an empty array in
`matchMode: "exact"`, so candidates came only from the server-side
`subject ~ query` filter and any work package whose match lived in a
description or a comment was unreachable. That inverts the parameter's
own guidance: exact mode is advertised for literal strings known
verbatim — error codes, ticket refs, commit SHAs — which are exactly the
strings that live in descriptions and comments rather than subjects.
Spec section 3.3 mandates two parallel requests with no exact-mode
carve-out.
Both legs now also page the candidate window in batches of 100 up to the
unchanged 250 cap, the way meeting search already does. Every tool shape
in this repo caps `pageSize` at 100 and OpenProject enforces a
server-side maximum, so a single `pageSize: 250` request could silently
return fewer candidates than the code claimed, with `response.total`
ignored so nothing detected it.
Also corrects `offset` on the search page from an item index to the
1-based page number its schema documents, and drops the duplicate
`items` array from `WorkPackageSearchPage` (kept on `PaginatedResult`
and the non-search list paths).
The existing `workPackageFixtureClient` returns every element for any
collection request and so cannot see a subject filter at all, which is
why this was invisible to the suite. New tests use a fixture that
honours the filter server-side; the old fixture is untouched.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The "results are stable" test queried "budget", a single-match query,
so the sort had nothing to reorder and would pass even with a broken
or removed ascending-id tie-break. Switch to "muller", where docs 1
and 5 tie on score, so the assertion actually exercises the tie-break.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adds optional query/matchMode to listWorkPackageActivities, ranking the
already-fetched activity list client-side via rankRecords (no extra API
calls). Exposes the same fields on the
openproject_list_work_package_activities tool. When no query is supplied,
every activity is returned exactly as before, and onlyComments continues
to filter independently.
Also fixes a defect in the brief's exact-mode test fixture: the specified
typo query "certificat" is a literal prefix substring of "certificate", so
byte-for-byte exact containment matching (scoreExact in src/search/rank.ts)
finds it regardless of typo tolerance, making the test unable to pass
against a correct implementation. Swapped in "certificatee" (not a
substring of "certificate") to preserve the test's intent without touching
the assertion. Updated one pre-existing test-tools.test.ts call site to
supply matchMode, now required by the extended args type.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The union candidate fetch wrapped both listWorkPackages() legs in an
unconditional .catch(() => ({ items: [] })), swallowing 401/403/429 and
OpenProjectAuthenticationError along with genuinely transient failures. An
expired token would silently degrade to "0 candidates", and the LLM would
report work packages as not found rather than surfacing the auth failure.
Replace the inline catches with a shared listCandidates() helper that uses
isFatalSearchError (already used by searchPipeline) to rethrow fatal errors
and only swallow non-fatal ones, preserving the property that a bad response
on one leg doesn't kill the other leg's candidates.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Registers openproject_search_work_packages (readOnly: true), taking the tool
registry from 18 to 19. searchWorkPackages now unions a precise server-side
subject filter with a broad recency-ordered candidate window, ranks via the
shared search pipeline, and deep-enriches top candidates with description and
comment text. Exact-mode requests skip the broad query entirely.
searchWorkPackages's return type changes from a flat PaginatedResult<WorkPackageSummary>
to WorkPackageSearchPage, nesting the summary under `workPackage` alongside
score, matchedFields, and an optional snippet.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Retain the page body from the formattable text field (already fetched
per-page, zero extra API calls) and rank title/text matches with
rankRecords instead of exact substring on title alone. searchWikiPages
now returns WikiPageSearchResult[] with score, snippet, and
matchedFields; empty-query behavior (project scoping + limit, no
ranking) is preserved. Adds a matchMode tool parameter defaulting to
fuzzy.
Updates the pre-existing tool-metadata assertion in tests/tools.test.ts
to match the new search_wiki_pages description introduced here.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Routes searchMeetings through the shared searchPipeline instead of a
hand-rolled Promise.all fan-out, fixing two live bugs: an expired token
on agenda-item fetch previously produced "no results" (swallowed via
.catch(() => undefined)) instead of propagating the auth error, and up
to 250 candidate meetings could fire simultaneous agenda-item requests.
The pipeline caps enrichment at 25 candidates with 8 in flight, and
isFatalSearchError now lets 401/403/429 propagate while other failures
degrade a single record and set `degraded`.
Also removes the old substring-based extractSnippet from
src/services/meetings.ts in favor of src/search/snippet.ts, and adds
matchMode ("fuzzy" | "exact") to SearchMeetingsParams and the
openproject_search_meetings tool schema.
Updated two pre-existing tests in tests/meetings.test.ts whose fixtures
assumed the old implementation's API surface: one didn't stub the
meeting-detail endpoint the pipeline's deep enrichment now also fetches
(for participants), and one assumed a 50-item candidate page size where
the new pipeline hardcodes 100. Updated one assertion in
tests/tools.test.ts that checked the old literal tool description.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adds searchPipeline, which ranks candidates shallowly at no API cost, then
enriches a bounded subset (DEFAULT_ENRICH_LIMIT=25, DEFAULT_CONCURRENCY=8)
and re-ranks, taking max(shallow, deep) so a strong title match is never
diluted by unrelated deep content. Auth and rate-limit errors abort the
whole search via isFatalSearchError instead of degrading silently; other
enrichment failures degrade one record and are counted. A recency backfill
spends leftover enrichment budget on the most recently updated candidates
so a deep-only match is reachable at all. Adds the src/search/ barrel
export.
Reconciles the previously uncommitted pipeline.ts (written against an
earlier, defective version of this task's brief) with the corrected brief:
mapWithConcurrency's phase-2 callback is now given an explicit
EnrichedCandidate return type so its `deep: TDeep | undefined` field is a
single type rather than an inferred union, which is what the `entry is
Pair` narrowing predicate requires under TS2677.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adds boundedLevenshtein with row-minimum early exit, token scoring
(exact / prefix / contains / shared-prefix / edit distance), and
coverage-weighted field aggregation scaled by BASE_SCALE so that only a
verbatim phrase match reaches 1.0.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>