Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to the `agent-root` CLI are documented in this file. Format

## [Unreleased]

### Added

- `search` now falls back to the registry's hybrid semantic endpoint
(`/api/search`, BM25 + vector RRF) when keyword search returns no results.
Natural-language queries like "skill to pay in USDC" surface relevant records
under a "closest semantic matches" heading, each tagged by match type
(`{hybrid}`/`{vector}`/`{keyword}`). Add `--no-semantic` to opt out
(keyword-only). One request per failed search; `--all` unaffected. `--json`
gains a top-level `approximate` boolean.

## [0.3.0] 2026-05-20

First standalone release. Cut from the independent
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ OPTIONS
--quiet, -q Suppress non-essential output (spinners + notes)
--no-install Skip auto-install when resolving skill= records
--no-color Disable ANSI color (also auto-off in non-TTY)
--no-semantic Disable the search semantic-search fallback (keyword-only)
```

Flag names accept kebab-case (`--manifest-url`) or camelCase (`--manifestUrl`); use `--key=value` or `--key value`. Pass `--` to stop option parsing.
Expand Down Expand Up @@ -228,7 +229,14 @@ agent-root search doma --page 2 --limit 50
agent-root search doma --all --type skill
```

For scripting, `--json` returns the full envelope (`results`, `total`, `page`, `pages`, `limit`):
When a keyword search finds nothing, the CLI automatically falls back to the registry's semantic search (hybrid keyword + vector) and shows the closest matches, each tagged `{hybrid}`, `{vector}`, or `{keyword}`. Pass `--no-semantic` to disable it and keep keyword-only behavior:

```bash
agent-root search "skill to pay in USDC" # semantic match — no literal "usdc" needed
agent-root search "register domain" --no-semantic # keyword-only, no fallback
```

For scripting, `--json` returns the full envelope (`results`, `total`, `page`, `pages`, `limit`, `approximate`):

```bash
agent-root search doma --json | jq -r '.results[] | select(.type=="skill") | .address'
Expand Down
25 changes: 20 additions & 5 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,25 @@ agent-root search payments --json
```

Expected: exit `0`. Valid JSON. Top-level keys are
`results`, `total`, `page`, `pages`, `limit`. This is the full envelope,
not a bare array.
`results`, `total`, `page`, `pages`, `limit`, `approximate`. This is the full
envelope, not a bare array.

```bash
agent-root search doma --page 2 --limit 5 --json
```

Expected: exit `0`. `page=2`, `limit=5`, up to 5 entries in `results`.

```bash
agent-root search "skill to pay in USDC"
agent-root search "skill to pay in USDC" --no-semantic
```

Expected: both exit `0`. The first may print a
`// no exact match — closest semantic matches:` header with records tagged
`{hybrid}`/`{vector}`/`{keyword}` (the semantic fallback fires only when the
keyword search returns nothing); the second never shows that section.

### health

```bash
Expand Down Expand Up @@ -292,12 +302,17 @@ MONO=../agentroot/packages/cli/dist/bin/agentroot.js
# (cd ../agentroot && pnpm --filter agent-root build)

diff <(node $CLI resolve agentroot.io) <(node $MONO resolve agentroot.io)
diff <(node $CLI search payments --json) <(node $MONO search payments --json)
diff <(node $CLI list --json) <(node $MONO list --json)
```

Expected: zero differences for `resolve` and `search`. `list` output should
also match (same on-disk store).
Expected: zero differences for `resolve`. `list` output should also match
(same on-disk store).

> `search` is **intentionally no longer byte-for-byte** with the monorepo CLI:
> the standalone adds the semantic-search fallback (`/api/search`) plus an
> `approximate` field in the `--json` envelope, neither of which the monorepo
> `packages/cli` has. The standalone repo is the source of truth — do not diff
> `search` output against the monorepo.

For `install --json` parity (paths embed `$HOME` and `$PWD`, so normalize
those before diffing):
Expand Down
4 changes: 2 additions & 2 deletions src/cli/parse-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface ParsedArgs {
*/
const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
'all', 'project', 'help', 'json', 'force', 'yes', 'version', 'quiet',
'noInstall', 'noColor',
'noInstall', 'noColor', 'noSemantic',
]);

/**
Expand Down Expand Up @@ -42,7 +42,7 @@ const SHORT_ALIASES: Record<string, string> = {
* normalizes to `noInstall`).
*/
const POSITIVE_NO_FLAGS: ReadonlySet<string> = new Set([
'noInstall', 'noColor',
'noInstall', 'noColor', 'noSemantic',
]);

/**
Expand Down
8 changes: 8 additions & 0 deletions src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,25 @@ ${section('DESCRIPTION')}
filters narrow by record kind. Pagination is server-side; use --page /
--limit to walk results, or --all to collect every match (capped at 1000).

When a keyword search returns no results, the CLI falls back to the registry's
hybrid semantic search (/api/search, keyword + vector) and shows the closest
matches, each tagged {hybrid}, {vector}, or {keyword}. Pass --no-semantic to
disable this and keep keyword-only behavior. With --json, an 'approximate'
boolean indicates the results came from the semantic fallback.

${section('OPTIONS')}
--type <type> Filter by record type: agent, mcp, skill, a2a, payment
--page <N> Page number (1-indexed, default 1)
--limit <N> Per-page limit (1..100, default 20)
--all Fetch every page (hard-capped at 1000)
--no-semantic Disable the semantic-search fallback (keyword-only)
--json, -j Output as JSON envelope
--no-color Disable ANSI color
--quiet, -q Suppress progress chatter

${section('EXAMPLES')}
agentroot search billing
agentroot search "skill to pay in USDC" # semantic fallback when no keyword hit
agentroot search "register domain" --type skill
agentroot search doma --limit 5 --json | jq '.results[].address'

Expand Down
115 changes: 111 additions & 4 deletions src/commands/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ const ALL_MODE_HARD_CAP = 1000;
*/
const BARE_KEYWORD_TLDS: readonly string[] = ['.io', '.com'];

/**
* `/api/search` (semantic) caps `limit` at 50 — it returns a ranked top-N, not
* a browsable page. We never paginate it; one request per failed keyword search.
*/
const SEMANTIC_MAX_LIMIT = 50;

export interface SearchResult {
domain: string;
type: string;
Expand All @@ -44,6 +50,10 @@ export interface SearchResult {
transport?: string | null;
index?: string | null;
capabilities?: string[] | null;
/** Match provenance — set only on hits from the semantic (/api/search) tier. */
match?: 'hybrid' | 'vector' | 'keyword';
/** True only for semantic-tier hits (keyword search returned nothing). */
approximate?: boolean;
}

export interface SearchEnvelope {
Expand Down Expand Up @@ -77,6 +87,30 @@ interface RecordsApiResponse {
pages?: number;
}

/** A single hit from the registry's hybrid /api/search endpoint (BM25 + vector RRF). */
export interface SemanticSearchHit {
id: number;
record_id: string;
domain: string;
type: string;
name: string | null;
description: string | null;
manifest_url: string | null;
rrf_score: number;
bm25_rank: number | null;
vector_rank: number | null;
}

/** Response envelope from GET /api/search. */
export interface SemanticSearchResponse {
query: string;
type_filter: string | null;
results: SemanticSearchHit[];
total: number;
/** True when the embedder is unavailable; results are BM25-only. */
degraded: boolean;
}

type ManifestRecord = {
record_id?: unknown;
id?: unknown;
Expand Down Expand Up @@ -139,6 +173,63 @@ export function recordToSearchResult(row: RecordsApiRow): SearchResult {
};
}

/**
* Semantic fallback. Queries the registry's hybrid /api/search endpoint
* (BM25 + pgvector RRF) and maps the hits to approximate-tagged SearchResults.
* One request, no pagination; limit capped at SEMANTIC_MAX_LIMIT.
*
* Mirrors the silent-catch contract of fallbackFindSkills / fallbackManifestProbe:
* any failure resolves to [] so searchWithFallback continues down the chain.
* fetch() follows 3xx, so the apex->www redirect is handled transparently.
*/
export async function searchSemantic(query: string, typeFilter: string, flags: Record<string, unknown>): Promise<SearchResult[]> {
const limit = Math.min(clampLimit(flags['limit']), SEMANTIC_MAX_LIMIT);
const params = new URLSearchParams({ q: query, limit: String(limit) });
if (typeFilter) params.set('type', typeFilter);
try {
const data = await fetchJSON<SemanticSearchResponse>(`${getApiBase()}/api/search?${params.toString()}`);
const hits = Array.isArray(data.results) ? data.results : [];
return hits.map(semanticHitToSearchResult);
} catch {
// 429 (fetchJSON throws "HTTP 429 …"), 5xx, timeout, or network error —
// treat exactly like "no results" so the caller falls through to the legacy
// find-skills / manifest-probe tiers instead of crashing the run.
return [];
}
}

/**
* Map a /api/search hit onto the shared SearchResult shape, tagged `approximate`
* so the display + JSON layers can flag it. `verified` is true because the
* hybrid SQL only returns rows with status='active'.
*/
export function semanticHitToSearchResult(hit: SemanticSearchHit): SearchResult {
const recordId = String(hit.record_id ?? '');
return {
domain: String(hit.domain ?? ''),
type: String(hit.type ?? 'skill'),
id: recordId,
record_id: recordId,
name: hit.name ?? recordId,
description: hit.description ?? '',
address: `${hit.domain}/${recordId}`,
verified: true,
match: matchKind(hit),
approximate: true,
};
}

/**
* Classify a hybrid-search hit by which signal(s) ranked it, for an intuitive
* match-type tag. When the embedder is offline the registry returns BM25-only
* hits (vector_rank null), which classify as 'keyword' — the degraded signal.
*/
export function matchKind(hit: SemanticSearchHit): 'hybrid' | 'vector' | 'keyword' {
if (hit.bm25_rank !== null && hit.vector_rank !== null) return 'hybrid';
if (hit.vector_rank !== null) return 'vector';
return 'keyword';
}

interface FetchRecordsPageOpts {
query: string;
typeFilter: string;
Expand Down Expand Up @@ -272,9 +363,14 @@ async function fallbackManifestProbe(query: string, typeFilter: string): Promise
* returned nothing.
*/
export async function searchWithFallback(query: string, typeFilter: string, flags: Record<string, unknown>): Promise<SearchResult[]> {
void flags;
const env = await searchRecords(query, typeFilter, 1, DEFAULT_LIMIT);
if (env.results.length > 0) return env.results;
// Semantic tier: hybrid /api/search recall before the legacy fallbacks.
// Opt out with --no-semantic (parsed as flags.noSemantic).
if (!flags['noSemantic']) {
const semantic = await searchSemantic(query, typeFilter, flags);
if (semantic.length > 0) return semantic;
}
// Only chain into find-skills if the filter is open or explicitly skill,
// it only returns skills, so calling it for type=agent etc. is wasted I/O.
if (!typeFilter || typeFilter === 'skill') {
Expand All @@ -285,14 +381,22 @@ export async function searchWithFallback(query: string, typeFilter: string, flag
}

export function displayResults(results: SearchResult[]): void {
// Semantic-tier results are approximate; lead with a header so the user knows
// these are closest matches, not exact keyword hits.
if (results.some(r => r?.approximate)) {
console.log(` ${colors.dim('// no exact match — closest semantic matches:')}`);
console.log();
}
for (let i = 0; i < results.length; i++) {
const r = results[i];
if (!r) continue;
const typeLabel = RECORD_TYPES[r.type] ?? r.type;
const addr = r.address ?? `${r.domain}/${r.id ?? r.record_id}`;
const verified = r.verified ? colors.green(' ✓') : '';
// Match-type tag ({hybrid}/{vector}/{keyword}) appears only on semantic hits.
const matchTag = r.match ? ` ${colors.dim(`{${r.match}}`)}` : '';
const num = colors.dim(`${i + 1}.`);
console.log(` ${num} ${colors.bold(r.name ?? r.id ?? '')} ${colors.dim(`[${typeLabel}]`)} ${colors.dim(`(${addr})`)}${verified}`);
console.log(` ${num} ${colors.bold(r.name ?? r.id ?? '')} ${colors.dim(`[${typeLabel}]`)} ${colors.dim(`(${addr})`)}${verified}${matchTag}`);
if (r.description) {
console.log(` ${colors.dim(r.description)}`);
}
Expand Down Expand Up @@ -431,15 +535,18 @@ export async function cmdSearch(positional: string[], flags: Record<string, unkn
if (envelope.results.length === 0) {
spinner.warn({ text: 'No records found' });
if (flags['json']) {
console.log(JSON.stringify({ results: [], total: 0, page, pages: 0, limit }, null, 2));
console.log(JSON.stringify({ results: [], total: 0, page, pages: 0, limit, approximate: false }, null, 2));
}
return;
}

spinner.success({ text: envelope.results.length + ' result(s) found' });

if (flags['json']) {
console.log(JSON.stringify(envelope, null, 2));
// `approximate` is true when these results came from the semantic fallback
// tier — lets scripts distinguish exact keyword hits from closest matches.
const approximate = envelope.results.some(r => r.approximate === true);
console.log(JSON.stringify({ ...envelope, approximate }, null, 2));
return;
}

Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ ${colors.bold('OPTIONS')}
--quiet, -q Suppress non-essential output (spinners + notes)
--no-install Skip auto-install when resolving skill= records
--no-color Disable ANSI color (also auto-off in non-TTY)
--no-semantic Disable the search semantic-search fallback (keyword-only)

Flag names accept both kebab-case (--manifest-url) and camelCase (--manifestUrl).
Use --key=value or --key value. Use -- to end option parsing.
Expand All @@ -127,8 +128,9 @@ ${colors.bold('EXAMPLES')}
npx agent-root resolve doma.xyz
npx agent-root resolve doma.xyz/doma-protocol

${colors.dim('# Search the public registry')}
${colors.dim('# Search the public registry (semantic fallback on zero keyword hits)')}
npx agent-root search doma
npx agent-root search "skill to pay in USDC"
npx agent-root search "register domain" --type skill

${colors.dim('# Install a record')}
Expand Down
5 changes: 5 additions & 0 deletions tests/cli/parse-args-extra.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ describe('parseArgs additional branches', () => {
expect(r.flags['noColor']).toBe(true);
});

it('--no-semantic is treated as a positive boolean flag (noSemantic=true)', () => {
const r = parseArgs(['node', 'agentroot', 'search', 'usdc', '--no-semantic']);
expect(r.flags['noSemantic']).toBe(true);
});

it('--no-json sets json=false (POSIX negation)', () => {
const r = parseArgs(['node', 'agentroot', 'install', 'x.com', '--no-json']);
expect(r.flags['json']).toBe(false);
Expand Down
Loading
Loading