Skip to content
Open
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
12 changes: 11 additions & 1 deletion packages/base/command.gts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import {
SearchCardsResult,
SearchCardSummaryField,
} from './commands/search-card-result';
import {
SearchEntriesInput,
SearchEntriesResult,
SearchEntrySummaryField,
} from './commands/search-entry-result';
import { eq, gt } from '@cardstack/boxel-ui/helpers';

export type ToolCallStatus = 'applied' | 'ready' | 'applying';
Expand Down Expand Up @@ -276,7 +281,9 @@ export class ScreenshotCardOutput extends CardDef {
<figure class='capture'>
{{#if capture.url}}
<img src={{capture.url}} alt={{capture.name}} />
<figcaption><a href={{capture.url}}>{{capture.url}}</a></figcaption>
<figcaption><a
href={{capture.url}}
>{{capture.url}}</a></figcaption>
{{/if}}
</figure>
{{/each}}
Expand Down Expand Up @@ -659,6 +666,9 @@ export {
SearchCardsByTypeAndTitleInput,
SearchCardsResult,
SearchCardSummaryField,
SearchEntriesInput,
SearchEntriesResult,
SearchEntrySummaryField,
};

export class RealmInfoField extends FieldDef {
Expand Down
77 changes: 77 additions & 0 deletions packages/base/commands/search-entry-result.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { IconSearchThick } from '@cardstack/boxel-ui/icons';
import {
CardDef,
Component,
FieldDef,
StringField,
contains,
containsMany,
field,
} from '../card-api';
import BooleanField from '../boolean';
import CodeRefField from '../code-ref';
import NumberField from '../number';
import { QueryField } from './search-card-result';

export class SearchEntriesInput extends CardDef {
static displayName = 'Search Entries';
static icon = IconSearchThick;
@field query = contains(QueryField);
// Realm URLs to search; empty means every realm the user can read.
@field realms = containsMany(StringField);
// 'cards' | 'files' | 'all'; the tool validates and defaults to 'all'.
@field scope = contains(StringField);
// Maximum rows returned; the tool defaults to 5 and clamps to 10.
@field limit = contains(NumberField);
}

export class SearchEntrySummaryField extends FieldDef {
static displayName = 'Search Entry Summary';
// The entry's URL — a card id or a file URL.
@field url = contains(StringField);
// 'card' | 'file', from the entry's item resource type.
@field kind = contains(StringField);
// Populated for Spec rows.
@field ref = contains(CodeRefField);
@field specType = contains(StringField);
@field cardTitle = contains(StringField);
@field cardDescription = contains(StringField);
// The file name — the display handle for file rows, which carry no
// cardTitle.
@field name = contains(StringField);
// Present only when the search sorted by full-text relevance.
@field matchRelevance = contains(NumberField);
// Full, untruncated readMe when the row carries one (e.g. a Spec).
@field readMe = contains(StringField);
}

export class SearchEntriesResult extends CardDef {
static displayName = 'Search Entries Result';
static icon = IconSearchThick;
@field results = containsMany(SearchEntrySummaryField);
// Total matches across the searched realms; `results` is one page of them.
@field total = contains(NumberField);
// True when a searched realm failed to answer: `results`/`total` then cover
// only the realms that responded.
@field incomplete = contains(BooleanField);
@field cardDescription = contains(StringField);

static embedded = class Embedded extends Component<typeof this> {
<template>
<div data-test-search-entries-result>
<p>{{@model.results.length}}
of
{{@model.total}}
results{{if @model.incomplete ' (incomplete: a realm failed)' ''}}</p>
<ol>
{{#each @model.results as |result|}}
<li data-test-search-entry={{result.url}}>
{{if result.cardTitle result.cardTitle result.name}}
({{result.url}})
</li>
{{/each}}
</ol>
</div>
</template>
};
}
3 changes: 3 additions & 0 deletions packages/host/app/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import * as SaveCardToolModule from './save-card';
import * as ScreenshotCardToolModule from './screenshot-card';
import * as SearchAndChooseToolModule from './search-and-choose';
import * as SearchCardsToolModule from './search-cards';
import * as SearchEntriesToolModule from './search-entries';
import * as SearchGoogleImagesToolModule from './search-google-images';
import * as SendAiAssistantMessageModule from './send-ai-assistant-message';
import * as SendRequestViaProxyToolModule from './send-request-via-proxy';
Expand Down Expand Up @@ -277,6 +278,7 @@ export function shimHostTools(virtualNetwork: VirtualNetwork) {
shimHostToolModule(virtualNetwork, 'save-card', SaveCardToolModule);
shimHostToolModule(virtualNetwork, 'serialize-card', SerializeCardToolModule);
shimHostToolModule(virtualNetwork, 'search-cards', SearchCardsToolModule);
shimHostToolModule(virtualNetwork, 'search-entries', SearchEntriesToolModule);
shimHostToolModule(
virtualNetwork,
'search-and-choose',
Expand Down Expand Up @@ -530,6 +532,7 @@ export const HostToolClasses: (typeof HostBaseTool<any, any>)[] = [
SearchAndChooseToolModule.default,
SearchCardsToolModule.SearchCardsByQueryTool,
SearchCardsToolModule.SearchCardsByTypeAndTitleTool,
SearchEntriesToolModule.default,
SearchGoogleImagesToolModule.default,
SendAiAssistantMessageModule.default,
SendBotTriggerEventToolModule.default,
Expand Down
14 changes: 10 additions & 4 deletions packages/host/app/tools/search-cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ export class SearchCardsByTypeAndTitleTool extends HostBaseTool<
typeof BaseToolModule.SearchCardsByTypeAndTitleInput,
typeof BaseToolModule.SearchCardsResult
> {
description = 'Search for card instances by type and/or title';
description =
'Search for live card instances by type and/or title when you need the ' +
'instances themselves — to attach, open, copy, or patch. For discovery ' +
'(finding what exists — card types, specs, listings, files), use the ' +
'search-entries tool instead.';

static actionVerb = 'Search';

Expand Down Expand Up @@ -58,9 +62,11 @@ export class SearchCardsByQueryTool extends HostBaseTool<
@service declare private realmServer: RealmServerService;

description =
'Propose a query to search for a card instance filtered by type. \
If a card was shared with you, always prioritize search based upon the card that was last shared. \
If you do not have information on card module and name, do the search using the `_cardType` attribute.';
'Search for live card instances by query when you need the instances ' +
'themselves — to attach, open, copy, or patch. If a card was shared with ' +
'you, always prioritize search based upon the card that was last shared. ' +
'For discovery (finding what exists — card types, specs, listings, ' +
'files, or anything reusable), use the search-entries tool instead.';

async getInputType() {
let commandModule = await this.loadToolModule();
Expand Down
220 changes: 220 additions & 0 deletions packages/host/app/tools/search-entries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { service } from '@ember/service';

import {
MATCH_RELEVANCE_SORT_KEY,
assertQuery,
collectPositiveMatchTerms,
excludeCardInstanceFileRows,
isFileMetaResource,
resourceIdentity,
searchEntryWireQueryFromQuery,
type CardResource,
type EntryCollectionDocument,
type FileMetaResource,
type Query,
type Saved,
type SearchEntryScope,
} from '@cardstack/runtime-common';

import HostBaseTool from '../lib/host-base-tool';
import { hasNarrowingPositiveTypeRef } from '../utils/search/query-builder';

import type StoreService from '../services/store';
import type * as BaseToolModule from '@cardstack/base/command';

const DEFAULT_LIMIT = 5;
const MAX_LIMIT = 10;

const SCOPES: SearchEntryScope[] = ['cards', 'files', 'all'];

// The fixed projection each row carries. Everything a discovery pass needs to
// judge a candidate rides the row itself (including the full readMe), so no
// per-hit follow-up read is required; the fieldset stays internal so a caller
// can never request full serializations that blow up the result size.
const PROJECTION_FIELDS = [
'item.cardTitle',
'item.cardDescription',
'item.specType',
'item.readMe',
'item.ref',
// `name` is the file-meta display handle — without it a file row's summary
// carries nothing but its URL. Card rows have no `name` attribute, so the
// sparse fieldset simply omits it there.
'item.name',
];

interface EntrySummary {
url: string;
kind: 'card' | 'file';
ref?: unknown;
specType?: string;
cardTitle?: string;
cardDescription?: string;
name?: string;
readMe?: string;
matchRelevance?: number;
}

function resolveScope(scope: string | undefined): SearchEntryScope {
if (scope == null || scope === '') {
return 'all';
}
if (!SCOPES.includes(scope as SearchEntryScope)) {
throw new Error(
`Invalid scope "${scope}": must be one of 'cards', 'files', 'all'`,
);
}
return scope as SearchEntryScope;
}

// Prepares a caller's card-rooted query for the entry endpoint: under the
// mixed 'all' scope a card matches both its instance row and its dual-indexed
// `.json` file row, so the filter gains the card-instance-file exclusion —
// unless it already carries a kind-narrowing positive type ref, which matches
// only one of the two. A filter with a positive full-text `matches` term and
// no explicit sort gains the relevance sort (the server rejects that sort
// without such a term, so it is never added unconditionally).
// `addedRelevanceSort` reports the sort addition: the tool re-sorts merged
// rows client-side only for an ordering it imposed itself, never over one the
// caller chose (a caller's own sort may legally include `_matchRelevance`).
export function composeSearchEntriesQuery(
query: Query,
scope: SearchEntryScope,
): { query: Query; addedRelevanceSort: boolean } {
let filter = query.filter;
if (scope === 'all' && !hasNarrowingPositiveTypeRef(filter)) {
filter = filter
? { every: [filter, excludeCardInstanceFileRows()] }
: excludeCardInstanceFileRows();
}
let sort = query.sort;
let addedRelevanceSort = false;
if (!sort && collectPositiveMatchTerms(query.filter).length > 0) {
sort = [{ by: MATCH_RELEVANCE_SORT_KEY, direction: 'desc' }];
addedRelevanceSort = true;
}
return {
query: {
...query,
...(filter ? { filter } : {}),
...(sort ? { sort } : {}),
},
addedRelevanceSort,
};
}

function summarizeEntries(doc: EntryCollectionDocument): EntrySummary[] {
let itemsByIdentity = new Map<
string,
CardResource<Saved> | FileMetaResource
>();
for (let resource of doc.included ?? []) {
if (resource.type === 'card' || resource.type === 'file-meta') {
itemsByIdentity.set(
resourceIdentity(resource.type, resource.id),
resource,
);
}
}
let summaries: EntrySummary[] = [];
for (let entry of doc.data) {
let itemRef = entry.relationships?.item?.data;
if (!entry.id || !itemRef) {
continue;
}
let item = itemsByIdentity.get(resourceIdentity(itemRef.type, itemRef.id));
if (!item) {
continue;
}
let attributes = (item.attributes ?? {}) as Record<string, unknown>;
summaries.push({
url: entry.id,
kind: isFileMetaResource(item) ? 'file' : 'card',
ref: attributes.ref,
specType: attributes.specType as string | undefined,
cardTitle: attributes.cardTitle as string | undefined,
cardDescription: attributes.cardDescription as string | undefined,
name: attributes.name as string | undefined,
readMe: attributes.readMe as string | undefined,
matchRelevance: entry.meta?._matchRelevance,
});
}
return summaries;
}

export default class SearchEntriesTool extends HostBaseTool<
typeof BaseToolModule.SearchEntriesInput,
typeof BaseToolModule.SearchEntriesResult
> {
@service declare private store: StoreService;

static actionVerb = 'Search';

description =
'Search across realms for existing cards, specs, listings, themes, and files — ' +
'the primary tool for discovery: always check what already exists before creating ' +
'anything new. Takes a card query (`filter` supporting `type`/`on`/`eq`/`contains`/' +
'`range`/`any`/`every`/`not` and full-text `matches`, plus optional `sort`), optional ' +
'`realms` (realm URLs; defaults to every realm you can read), optional `scope` ' +
"('cards' | 'files' | 'all', default 'all'), and optional `limit` (default " +
`${DEFAULT_LIMIT}, max ${MAX_LIMIT}). Returns lightweight entry summaries — url, ` +
'ref, specType, title, description, file name, full readMe, and full-text match ' +
'relevance — not live card instances. A result with `incomplete: true` is ' +
'partial: at least one searched realm failed to answer, so matches may be ' +
'missing and `total` undercounts. When you need instances to attach, open, or ' +
'patch, use the card-instance search tools instead.';

requireInputFields = ['query'];

async getInputType() {
let commandModule = await this.loadToolModule();
return commandModule.SearchEntriesInput;
}

protected async run(
input: BaseToolModule.SearchEntriesInput,
): Promise<BaseToolModule.SearchEntriesResult> {
assertQuery(input.query);
let scope = resolveScope(input.scope);
let limit = Math.min(
Math.max(Math.floor(input.limit ?? DEFAULT_LIMIT), 1),
MAX_LIMIT,
);

let { query, addedRelevanceSort } = composeSearchEntriesQuery(
input.query,
scope,
);
let wireQuery = searchEntryWireQueryFromQuery(query, {
fields: PROJECTION_FIELDS,
scope,
});
wireQuery.page = { ...wireQuery.page, size: limit };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Enforce the limit after the federated merge

When more than one realm is searched, page.size is applied independently by each realm and combineSearchEntryResults concatenates those pages, so the default limit: 5 can return five rows per readable realm rather than five rows total. Because the default targets every readable realm and each result can include a full readMe, this defeats the tool's output-size bound; trim the merged, re-ranked rows to limit before constructing the result.

Useful? React with 👍 / 👎.


let realms = input.realms?.length ? [...input.realms] : undefined;
let doc = await this.store.searchEntries(wireQuery, realms);

let rows = summarizeEntries(doc);
if (addedRelevanceSort) {
// The federated merge concatenates per-realm results without re-ranking
// across realms; relevance rides each entry so the merged page can be.
// Only the tool's own default ordering is re-imposed here — a caller's
// explicit sort (which may itself include `_matchRelevance`) stands.
Comment on lines +198 to +202

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 Preserve explicit sorts across realms

When query.sort is supplied with multiple realms, each realm sorts only its own page and the federated merge concatenates those pages in realm order; this branch deliberately re-ranks only the automatically added relevance sort. For example, an ascending title query can return Z from the first realm before A from the second, so the advertised optional sort is not honored for the tool's default multi-realm search; merge the per-realm pages according to the caller's sort or otherwise prevent this unsupported combination.

Useful? React with 👍 / 👎.

rows = [...rows].sort(
(a, b) => (b.matchRelevance ?? -1) - (a.matchRelevance ?? -1),
);
}

let commandModule = await this.loadToolModule();
let { SearchEntriesResult, SearchEntrySummaryField } = commandModule;
return new SearchEntriesResult({
results: rows.map((row) => new SearchEntrySummaryField(row)),
total: doc.meta.page.total,
// A realm that fails (or never resolves) during the federated fan-out is
// reported through this flag rather than a thrown error — the realms
// that answered still return; the flag keeps their partiality visible.
incomplete: doc.meta.incomplete === true,
cardDescription: `Query: ${JSON.stringify(input.query.filter ?? {})}`,
});
}
}
Loading
Loading