@nxisai/agent-harness 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +17 -5
  3. package/dist/harness/harness.d.ts +16 -0
  4. package/dist/harness/harness.d.ts.map +1 -1
  5. package/dist/harness/harness.js +44 -2
  6. package/dist/harness/harness.js.map +1 -1
  7. package/dist/harness/recommend.d.ts +46 -0
  8. package/dist/harness/recommend.d.ts.map +1 -0
  9. package/dist/harness/recommend.js +144 -0
  10. package/dist/harness/recommend.js.map +1 -0
  11. package/dist/harness/records.d.ts +70 -0
  12. package/dist/harness/records.d.ts.map +1 -0
  13. package/dist/harness/records.js +209 -0
  14. package/dist/harness/records.js.map +1 -0
  15. package/dist/harness/renderer.d.ts +11 -1
  16. package/dist/harness/renderer.d.ts.map +1 -1
  17. package/dist/harness/tools/action-tools.d.ts +10 -0
  18. package/dist/harness/tools/action-tools.d.ts.map +1 -1
  19. package/dist/harness/tools/action-tools.js +18 -0
  20. package/dist/harness/tools/action-tools.js.map +1 -1
  21. package/dist/harness/tools/site-pages.js +1 -1
  22. package/dist/index.d.ts +3 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/ui/agent-fullscreen.d.ts.map +1 -1
  27. package/dist/ui/agent-fullscreen.js +85 -73
  28. package/dist/ui/agent-fullscreen.js.map +1 -1
  29. package/dist/ui/result-pick.d.ts +52 -0
  30. package/dist/ui/result-pick.d.ts.map +1 -0
  31. package/dist/ui/result-pick.js +196 -0
  32. package/dist/ui/result-pick.js.map +1 -0
  33. package/package.json +1 -1
  34. package/src/harness/harness.ts +51 -2
  35. package/src/harness/recommend.ts +194 -0
  36. package/src/harness/records.ts +237 -0
  37. package/src/harness/renderer.ts +12 -1
  38. package/src/harness/tools/action-tools.ts +18 -0
  39. package/src/harness/tools/site-pages.ts +1 -1
  40. package/src/index.ts +3 -0
  41. package/src/ui/agent-fullscreen.ts +92 -63
  42. package/src/ui/result-pick.ts +233 -0
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The records a read tool returned, and what each one says as plain facts.
3
+ *
4
+ * A typed-retrieval result reaches the harness in many wrappings: structured
5
+ * content, JSON text, or the whole response serialized again by an extension
6
+ * in the loop. {@link extractRecords} digs the record list out of any of them.
7
+ * {@link recordFacts} then reads one schema.org record into the facts a
8
+ * person compares businesses by: whether it is open now, its rating, what it
9
+ * does, what reviewers said. Those facts are what a recommendation may cite,
10
+ * and nothing else.
11
+ *
12
+ * @module
13
+ */
14
+ import type { ModelContextToolResponse } from '../types.js';
15
+
16
+ type Obj = Record<string, unknown>;
17
+
18
+ const isObj = (v: unknown): v is Obj => !!v && typeof v === 'object' && !Array.isArray(v);
19
+
20
+ /**
21
+ * The records in a read tool's response, wherever they are.
22
+ *
23
+ * @param res - The response, or its text.
24
+ * @returns The records, as the server sent them; empty when there are none.
25
+ */
26
+ export function extractRecords(res: ModelContextToolResponse | string | null | undefined): Obj[] {
27
+ const sources: unknown[] = [];
28
+ // Every candidate payload, unwrapping response envelopes: structured
29
+ // content and each text part, parsed as JSON, possibly encoded twice.
30
+ const visit = (raw: unknown, depth = 0): void => {
31
+ if (raw == null || depth > 5) return;
32
+ if (typeof raw === 'string') {
33
+ const t = raw.trim();
34
+ if (!t) return;
35
+ try {
36
+ visit(JSON.parse(t), depth + 1);
37
+ } catch {
38
+ // Plain text: nothing to extract.
39
+ }
40
+ return;
41
+ }
42
+ if (typeof raw !== 'object') return;
43
+ sources.push(raw);
44
+ const v = raw as Obj;
45
+ if (v.structuredContent != null) visit(v.structuredContent, depth + 1);
46
+ if (Array.isArray(v.content)) {
47
+ for (const c of v.content) if (isObj(c)) visit(c.text, depth + 1);
48
+ }
49
+ };
50
+ visit(res);
51
+ for (const data of sources) {
52
+ const items = itemsFrom(data);
53
+ if (items.length) return items;
54
+ }
55
+ return [];
56
+ }
57
+
58
+ /** The record array within one parsed payload. */
59
+ function itemsFrom(data: unknown): Obj[] {
60
+ const objs = (arr: unknown[]) => arr.filter(isObj);
61
+ if (Array.isArray(data)) return objs(data);
62
+ if (!isObj(data)) return [];
63
+ for (const key of ['results', 'types', 'items', 'entities', 'data']) {
64
+ if (Array.isArray(data[key])) return objs(data[key] as unknown[]);
65
+ }
66
+ if (isObj(data.result)) return [data.result];
67
+ if (data['@id'] != null || data['@type'] != null || data.name != null) return [data];
68
+ return [];
69
+ }
70
+
71
+ // ── Opening hours ───────────────────────────────────────────────────────────
72
+
73
+ const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
74
+ const SHORT: Record<string, number> = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 };
75
+
76
+ /** Minutes after midnight: "07:30" → 450. */
77
+ function minutes(hhmm: unknown): number | null {
78
+ const m = String(hhmm ?? '').match(/^(\d{1,2}):(\d{2})/);
79
+ return m ? Number(m[1]) * 60 + Number(m[2]) : null;
80
+ }
81
+
82
+ /** "7am", "5:30pm", "noon". */
83
+ function clock(mins: number): string {
84
+ const h = Math.floor(mins / 60) % 24;
85
+ const m = mins % 60;
86
+ if (h === 12 && m === 0) return 'noon';
87
+ const suffix = h < 12 ? 'am' : 'pm';
88
+ const h12 = h % 12 || 12;
89
+ return m ? `${h12}:${String(m).padStart(2, '0')}${suffix}` : `${h12}${suffix}`;
90
+ }
91
+
92
+ /** Each day's opening spans, [opens, closes] in minutes; index 0 is Sunday. */
93
+ function weekOf(entity: Obj): [number, number][][] | null {
94
+ const week: [number, number][][] = DAYS.map(() => []);
95
+ let any = false;
96
+ const spec = entity.openingHoursSpecification;
97
+ for (const s of Array.isArray(spec) ? spec : spec ? [spec] : []) {
98
+ if (!isObj(s)) continue;
99
+ const opens = minutes(s.opens);
100
+ const closes = minutes(s.closes);
101
+ if (opens === null || closes === null) continue;
102
+ const days = Array.isArray(s.dayOfWeek) ? s.dayOfWeek : [s.dayOfWeek];
103
+ for (const d of days) {
104
+ const i = DAYS.findIndex((name) => String(d ?? '').replace(/^.*[/#]/, '').toLowerCase() === name.toLowerCase());
105
+ if (i >= 0) {
106
+ week[i].push([opens, closes]);
107
+ any = true;
108
+ }
109
+ }
110
+ }
111
+ // The compact form: "Mo-Fr 07:00-17:00", "Sa 08:00-13:00", "Mo-Su 00:00-23:59".
112
+ const compact = entity.openingHours;
113
+ for (const line of Array.isArray(compact) ? compact : compact ? String(compact).split(/[;,](?=\s*[A-Z][a-z])/) : []) {
114
+ const m = String(line).trim().match(/^([A-Za-z]{2})(?:-([A-Za-z]{2}))?\s+(\d{1,2}:\d{2})-(\d{1,2}:\d{2})$/);
115
+ if (!m) continue;
116
+ const from = SHORT[m[1].toLowerCase()];
117
+ const to = m[2] ? SHORT[m[2].toLowerCase()] : from;
118
+ if (from === undefined || to === undefined) continue;
119
+ for (let d = from; ; d = (d + 1) % 7) {
120
+ week[d].push([minutes(m[3])!, minutes(m[4])!]);
121
+ any = true;
122
+ if (d === to) break;
123
+ }
124
+ }
125
+ return any ? week : null;
126
+ }
127
+
128
+ /** Whether a business is open at a time, and how a visitor should read it. */
129
+ export interface OpenStatus {
130
+ open: boolean;
131
+ /** "Open now · 24/7", "Open now · until 5pm", "Opens 7am", "Opens Mon 7am". */
132
+ label: string;
133
+ /** Open every hour of every day. */
134
+ always: boolean;
135
+ }
136
+
137
+ /**
138
+ * Whether a record's business is open at `now`, read from its opening hours.
139
+ *
140
+ * @returns Null when the record states no hours this can read.
141
+ */
142
+ export function openStatus(entity: Obj, now: Date = new Date()): OpenStatus | null {
143
+ const week = weekOf(entity);
144
+ if (!week) return null;
145
+ const always = week.every((spans) => spans.some(([o, c]) => o === 0 && c >= 23 * 60 + 59));
146
+ if (always) return { open: true, label: 'Open now · 24/7', always: true };
147
+ const day = now.getDay();
148
+ const at = now.getHours() * 60 + now.getMinutes();
149
+ const current = week[day].find(([o, c]) => at >= o && at < c);
150
+ if (current) return { open: true, label: `Open now · until ${clock(current[1])}`, always: false };
151
+ // The next opening, today later or on a following day.
152
+ for (let ahead = 0; ahead < 7; ahead++) {
153
+ const d = (day + ahead) % 7;
154
+ const next = week[d].filter(([o]) => ahead > 0 || o > at).sort((a, b) => a[0] - b[0])[0];
155
+ if (!next) continue;
156
+ // Today or tomorrow reads as a time alone; further off, with the day.
157
+ return { open: false, label: ahead <= 1 ? `Opens ${clock(next[0])}` : `Opens ${DAYS[d].slice(0, 3)} ${clock(next[0])}`, always: false };
158
+ }
159
+ return { open: false, label: 'Closed', always: false };
160
+ }
161
+
162
+ // ── Facts ───────────────────────────────────────────────────────────────────
163
+
164
+ /** One record, as the facts a visitor compares by. */
165
+ export interface RecordFacts {
166
+ /** Its `@id`, else its name: what a pick refers to. */
167
+ key: string;
168
+ name: string;
169
+ type?: string;
170
+ status?: OpenStatus;
171
+ rating?: number;
172
+ reviewCount?: number;
173
+ phone?: string;
174
+ /** Services it offers, by name, with their one-line descriptions. */
175
+ services: { name: string; description?: string }[];
176
+ /** What it says it specializes in. */
177
+ knows: string[];
178
+ description?: string;
179
+ /** What reviewers wrote, with who wrote it. */
180
+ reviews: { text: string; author?: string }[];
181
+ /** The year it was founded. */
182
+ since?: number;
183
+ /** ZIP codes or places it serves. */
184
+ area: string[];
185
+ }
186
+
187
+ const str = (v: unknown): string => (v == null ? '' : String(Array.isArray(v) ? v[0] : v)).trim();
188
+ const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : v == null ? [] : [v]);
189
+
190
+ /** A schema.org record as plain facts. */
191
+ export function recordFacts(entity: Obj, now: Date = new Date()): RecordFacts {
192
+ const name = str(entity.name ?? entity.title ?? entity.headline) || 'This result';
193
+ const rating = isObj(entity.aggregateRating) ? Number(entity.aggregateRating.ratingValue) : NaN;
194
+ const count = isObj(entity.aggregateRating) ? Number(entity.aggregateRating.reviewCount ?? entity.aggregateRating.ratingCount) : NaN;
195
+ const services = list(entity.makesOffer)
196
+ .map((o) => (isObj(o) && isObj(o.itemOffered) ? o.itemOffered : isObj(o) ? o : null))
197
+ .filter(isObj)
198
+ .map((s) => ({ name: str(s.name), description: str(s.description) || undefined }))
199
+ .filter((s) => s.name);
200
+ const reviews = list(entity.review)
201
+ .filter(isObj)
202
+ .map((r) => ({ text: str(r.reviewBody ?? r.description), author: isObj(r.author) ? str(r.author.name) || undefined : str(r.author) || undefined }))
203
+ .filter((r) => r.text);
204
+ const founded = str(entity.foundingDate).match(/^(\d{4})/);
205
+ const status = openStatus(entity, now) ?? undefined;
206
+ return {
207
+ key: str(entity['@id']) || name,
208
+ name,
209
+ type: str(entity['@type']).replace(/^.*[/#]/, '') || undefined,
210
+ ...(status ? { status } : {}),
211
+ ...(Number.isFinite(rating) ? { rating } : {}),
212
+ ...(Number.isFinite(count) ? { reviewCount: count } : {}),
213
+ ...(str(entity.telephone) ? { phone: str(entity.telephone) } : {}),
214
+ services,
215
+ knows: list(entity.knowsAbout).map(str).filter(Boolean),
216
+ ...(str(entity.description) ? { description: str(entity.description) } : {}),
217
+ reviews,
218
+ ...(founded ? { since: Number(founded[1]) } : {}),
219
+ area: list(entity.areaServed).map((a) => (isObj(a) ? str(a.name ?? a.postalCode) : str(a))).filter(Boolean),
220
+ };
221
+ }
222
+
223
+ /** The facts as lines a model reads, and a recommendation is checked against. */
224
+ export function factLines(f: RecordFacts): string[] {
225
+ const lines: string[] = [];
226
+ if (f.type) lines.push(f.type);
227
+ if (f.status) lines.push(f.status.label);
228
+ if (f.rating !== undefined) lines.push(`Rated ${f.rating}${f.reviewCount !== undefined ? ` from ${f.reviewCount} reviews` : ''}`);
229
+ if (f.since) lines.push(`In business since ${f.since}`);
230
+ if (f.services.length) lines.push(`Services: ${f.services.map((s) => (s.description ? `${s.name} (${s.description})` : s.name)).join('; ')}`);
231
+ if (f.knows.length) lines.push(`Specializes in: ${f.knows.join(', ')}`);
232
+ if (f.description) lines.push(f.description);
233
+ for (const r of f.reviews.slice(0, 2)) lines.push(`Review: "${r.text}"${r.author ? ` (${r.author})` : ''}`);
234
+ if (f.area.length) lines.push(`Serves: ${f.area.join(', ')}`);
235
+ if (f.phone) lines.push(`Phone: ${f.phone}`);
236
+ return lines;
237
+ }
@@ -13,6 +13,7 @@ import type { ModelContextToolInfo, ModelContextToolResponse } from '../types.js
13
13
  import type { ClarificationPlan } from './plan.js';
14
14
  import type { Resolution, Slot } from './slots.js';
15
15
  import type { AgentStep } from './steps.js';
16
+ import type { ResultPick } from './recommend.js';
16
17
 
17
18
  /** One tool call and its response, for renderers that show their work. */
18
19
  export interface TraceEntry {
@@ -81,6 +82,16 @@ export interface StreamingBubble {
81
82
  finalize(text: string): void;
82
83
  }
83
84
 
85
+ /** What the harness adds to a search's results. */
86
+ export interface ResultsExtra {
87
+ /**
88
+ * The record the model recommends, with reasons the record backs. Absent
89
+ * when there was one result, the model gave no backed reason, or
90
+ * recommendations are off: show the results as a list.
91
+ */
92
+ pick?: ResultPick;
93
+ }
94
+
84
95
  /** A write tool with every value resolved, awaiting a human press. */
85
96
  export interface ConfirmRequest {
86
97
  /** The tool about to be called. */
@@ -162,7 +173,7 @@ export interface HarnessRenderer {
162
173
  confirm(req: ConfirmRequest): void;
163
174
 
164
175
  /** A read tool returned records. */
165
- results(tool: ModelContextToolInfo, res: ModelContextToolResponse): void;
176
+ results(tool: ModelContextToolInfo, res: ModelContextToolResponse, extra?: ResultsExtra): void;
166
177
  /** A write tool succeeded. */
167
178
  detail(tool: ModelContextToolInfo, values: Record<string, unknown>, text: string): void;
168
179
  /** A tool failed, or returned something that cannot be rendered. */
@@ -520,6 +520,24 @@ export function formOptions(tool: ModelContextToolInfo, values: Record<string, u
520
520
  return out;
521
521
  }
522
522
 
523
+ /**
524
+ * The site forms that can be sent to a business, by its name: the site tools
525
+ * on this page whose choices include it.
526
+ *
527
+ * @returns Each form's tool name and what it is called ("quote form").
528
+ */
529
+ export function formsFor(name: string): { tool: string; noun: string }[] {
530
+ const wanted = name.trim().toLowerCase();
531
+ if (!wanted) return [];
532
+ const out: { tool: string; noun: string }[] = [];
533
+ for (const [toolName, t] of built) {
534
+ if (t.choices?.some((c) => c.toLowerCase() === wanted)) {
535
+ out.push({ tool: toolName, noun: withoutSiteSuffix(toolName).replace(/[_-]+/g, ' ').trim().toLowerCase() });
536
+ }
537
+ }
538
+ return out;
539
+ }
540
+
523
541
  /** An option that only asks to choose: "Select…", "Choose a service…". */
524
542
  export const isPlaceholder = (value: string): boolean => /^\s*(select|choose|pick)\b/i.test(value) || /…\s*$|\.\.\.\s*$/.test(value);
525
543
 
@@ -194,7 +194,7 @@ export function pageForEntity(entity: Obj, pages: SitePage[], origin: string): S
194
194
  /**
195
195
  * The page an id formed from it names, or null.
196
196
  *
197
- * nxis-sdn-api forms a page entity's id as the page URL plus a fragment, so the page is the id with
197
+ * The NxisAI index forms a page entity's id as the page URL plus a fragment, so the page is the id with
198
198
  * the fragment removed.
199
199
  */
200
200
  function pageOfId(id: string | undefined, origin: string): string | null {
package/src/index.ts CHANGED
@@ -60,6 +60,7 @@ export type {
60
60
  HarnessRenderer,
61
61
  AskRequest,
62
62
  ConfirmRequest,
63
+ ResultsExtra,
63
64
  StreamingBubble,
64
65
  } from './harness/renderer.js';
65
66
 
@@ -172,6 +173,8 @@ export {
172
173
  } from './harness/tools/pending-action.js';
173
174
  export type { PendingAction, PendingVisit } from './harness/tools/pending-action.js';
174
175
  export { findEmbeddedAction, loadSiteActions, parseAction, type EmbeddedActionQuery } from './harness/tools/site-actions.js';
176
+ export { extractRecords, openStatus, recordFacts, factLines, type OpenStatus, type RecordFacts } from './harness/records.js';
177
+ export { recommend, grounded, RECOMMEND_TOOL, type ResultPick, type RecommendRequest } from './harness/recommend.js';
175
178
  export type { ConnectResult, ConnectOptions, SiteActionsOptions } from './harness/tools/source.js';
176
179
  export type { BridgeOptions } from './harness/tools/bridge.js';
177
180
 
@@ -43,6 +43,7 @@ import { connectTools } from '../harness/tools/source.js';
43
43
  import {
44
44
  chooserOf,
45
45
  formOptions,
46
+ formsFor,
46
47
  handoffFor,
47
48
  isSiteTool,
48
49
  withoutSiteSuffix,
@@ -59,6 +60,9 @@ import {
59
60
  type ActionItem,
60
61
  type ActionStep,
61
62
  } from './action-card.js';
63
+ import { resultsAnswer, RESULTS_ANSWER_CSS, type AnswerCard } from './result-pick.js';
64
+ import { extractRecords, openStatus, recordFacts } from '../harness/records.js';
65
+ import type { ResultPick } from '../harness/recommend.js';
62
66
  import { resumePendingAction } from '../harness/tools/resume.js';
63
67
  import { savePendingVisit, setConversationSource, takePendingVisit } from '../harness/tools/pending-action.js';
64
68
  import { pageForEntity, type SitePage } from '../harness/tools/site-pages.js';
@@ -329,6 +333,14 @@ interface ResultCard {
329
333
  id?: string;
330
334
  /** The page on this site the record came from, when it can be told. */
331
335
  page?: { url: string; label: string };
336
+ /** The rating and how many reviews it rests on. */
337
+ stars?: number;
338
+ reviews?: number;
339
+ /** Open or closed when the search ran: "Open now · 24/7", "Opens 7am". */
340
+ status?: { open: boolean; label: string };
341
+ phone?: string;
342
+ /** What the record states, short, for comparing it. */
343
+ facts?: string[];
332
344
  }
333
345
 
334
346
  type ThreadItem =
@@ -337,7 +349,7 @@ type ThreadItem =
337
349
  | { kind: 'trace'; name: string; args: Record<string, unknown>; text: string; isError: boolean; count?: number }
338
350
  | { kind: 'route'; agent: string; via: string }
339
351
  | { kind: 'detail'; title: string; note: string; rows: DetailRow[] }
340
- | { kind: 'results'; title: string; note: string; cards: ResultCard[] }
352
+ | { kind: 'results'; title: string; note: string; cards: ResultCard[]; pick?: ResultPick }
341
353
  | { kind: 'metrics'; rounds: MetricsRound[] }
342
354
  | { kind: 'form' }
343
355
  /** A turn the person called off. Kept so the thread explains its own gap. */
@@ -1646,9 +1658,9 @@ class NxisAgentFullscreen extends HTMLElement {
1646
1658
  ask: (req) => (this._usesCard(req.tool) ? this._cardAsk(req) : this._renderAsk(req)),
1647
1659
  confirm: (req) => (this._usesCard(req.tool) ? this._cardConfirm(req) : this._renderConfirm(req)),
1648
1660
  left: (tool, destination) => this._cardLeft(tool, destination),
1649
- results: (tool, res) => {
1661
+ results: (tool, res, extra) => {
1650
1662
  const conv = this._active();
1651
- const item = this._buildResultsItem(tool, res);
1663
+ const item = this._buildResultsItem(tool, res, extra?.pick);
1652
1664
  // A read that answers in words — "you are on the home page", "already open" — has no records
1653
1665
  // to draw, and an empty result card reading "No results found." contradicts the answer it
1654
1666
  // just gave. Say what it said.
@@ -2582,61 +2594,9 @@ class NxisAgentFullscreen extends HTMLElement {
2582
2594
  * return the first that yields records. Shapes covered: query → results[],
2583
2595
  * list_types → types[], get → result{}, or a bare entity/array. */
2584
2596
  private _extractItems(res: ModelContextToolResponse | string | null): Record<string, unknown>[] {
2585
- const sources: unknown[] = [];
2586
- // Collect every candidate payload, recursively unwrapping tool-response
2587
- // envelopes. `res` can be an object, a JSON string, or (with a WebMCP browser
2588
- // extension in the loop) the whole `{content, structuredContent}` envelope
2589
- // serialized to a string, possibly more than once. Diving into both
2590
- // `structuredContent` and each `content[].text` covers all of them.
2591
- const visit = (raw: unknown, depth = 0): void => {
2592
- if (raw == null || depth > 5) return;
2593
- const val: unknown = raw;
2594
- if (typeof val === 'string') {
2595
- const t = val.trim();
2596
- if (!t) return;
2597
- let parsed: unknown;
2598
- try {
2599
- parsed = JSON.parse(t);
2600
- } catch {
2601
- return; // plain (non-JSON) text — nothing to extract
2602
- }
2603
- // Parsed value may itself be an object/array OR another JSON string
2604
- // (double-encoded); recurse either way.
2605
- return visit(parsed, depth + 1);
2606
- }
2607
- if (!val || typeof val !== 'object') return;
2608
- sources.push(val);
2609
- const v = val as Record<string, unknown>;
2610
- if (v.structuredContent != null) visit(v.structuredContent, depth + 1);
2611
- if (Array.isArray(v.content)) {
2612
- for (const c of v.content) {
2613
- if (c && typeof c === 'object') visit((c as Record<string, unknown>).text, depth + 1);
2614
- }
2615
- }
2616
- };
2617
- visit(res);
2618
-
2619
- for (const data of sources) {
2620
- const items = this._itemsFrom(data);
2621
- if (items.length) return items;
2622
- }
2623
- console.warn('[nxis-mcp] no items extracted from tool result', { sourcesTried: sources.length, res });
2624
- return [];
2625
- }
2626
-
2627
- /** Find the record array within one parsed payload. */
2628
- private _itemsFrom(data: unknown): Record<string, unknown>[] {
2629
- const objs = (arr: unknown[]) =>
2630
- arr.filter((x) => x && typeof x === 'object') as Record<string, unknown>[];
2631
- if (Array.isArray(data)) return objs(data);
2632
- if (!data || typeof data !== 'object') return [];
2633
- const d = data as Record<string, unknown>;
2634
- for (const key of ['results', 'types', 'items', 'entities', 'data']) {
2635
- if (Array.isArray(d[key])) return objs(d[key] as unknown[]);
2636
- }
2637
- if (d.result && typeof d.result === 'object') return [d.result as Record<string, unknown>];
2638
- if (d['@id'] != null || d['@type'] != null || d.name != null) return [d];
2639
- return [];
2597
+ const items = extractRecords(res);
2598
+ if (!items.length) console.warn('[nxis-mcp] no items extracted from tool result', { res });
2599
+ return items;
2640
2600
  }
2641
2601
 
2642
2602
  /** Build a display card from a single record (a JSON-LD entity or a type row). */
@@ -2704,19 +2664,82 @@ class NxisAgentFullscreen extends HTMLElement {
2704
2664
  const id = entity['@id'] != null ? str(entity['@id']) : undefined;
2705
2665
  const found = pageForEntity(entity, this._sitePages, location.origin);
2706
2666
  const page = found ? { url: found.url, label: found.name } : undefined;
2707
- return { title, type, rating, emergency, sub, rows, id, ...(page ? { page } : {}) };
2667
+
2668
+ // What the answer view compares by, beyond the rating and hours its heading already shows.
2669
+ const facts = recordFacts(entity);
2670
+ const status = openStatus(entity) ?? undefined;
2671
+ const lines = [
2672
+ facts.services.length ? facts.services.slice(0, 3).map((x) => x.name).join(', ') : facts.knows.slice(0, 3).join(', '),
2673
+ facts.since ? `In business since ${facts.since}` : '',
2674
+ facts.reviews[0] ? `\u201c${facts.reviews[0].text}\u201d${facts.reviews[0].author ? ` \u2014 ${facts.reviews[0].author}` : ''}` : '',
2675
+ ].filter((l): l is string => !!l);
2676
+ return {
2677
+ title,
2678
+ type,
2679
+ rating,
2680
+ emergency,
2681
+ sub,
2682
+ rows,
2683
+ id,
2684
+ ...(page ? { page } : {}),
2685
+ ...(facts.rating !== undefined ? { stars: facts.rating } : {}),
2686
+ ...(facts.reviewCount !== undefined ? { reviews: facts.reviewCount } : {}),
2687
+ ...(status ? { status: { open: status.open, label: status.label } } : {}),
2688
+ ...(facts.phone ? { phone: facts.phone } : {}),
2689
+ facts: lines,
2690
+ };
2708
2691
  }
2709
2692
 
2710
2693
  private _buildResultsItem(
2711
2694
  tool: ModelContextToolInfo,
2712
2695
  res: ModelContextToolResponse,
2696
+ pick?: ResultPick,
2713
2697
  ): Extract<ThreadItem, { kind: 'results' }> {
2714
2698
  const items = this._extractItems(res);
2715
2699
  const cards = items.slice(0, 12).map((e) => this._entityCard(e));
2716
2700
  const note = !cards.length
2717
2701
  ? 'No results found.'
2718
2702
  : `${cards.length} result${cards.length === 1 ? '' : 's'}`;
2719
- return { kind: 'results', title: humanize(tool.name), note, cards };
2703
+ return { kind: 'results', title: humanize(tool.name), note, cards, ...(pick && pick.index < cards.length ? { pick } : {}) };
2704
+ }
2705
+
2706
+ /**
2707
+ * Results as an answer: the recommended pick with its reasons and the others
2708
+ * to compare, or a short list that opens per result. Records of things
2709
+ * only: a listing of types or counts keeps the plain cards.
2710
+ */
2711
+ private _answerEl(item: Extract<ThreadItem, { kind: 'results' }>): HTMLElement | null {
2712
+ // Records of things: a pick, or at least one result with a rating, hours, a phone or a page.
2713
+ // A sparse record beside them (an index entry with only a name) still shows, by name.
2714
+ const things = item.cards.filter((c) => c.stars !== undefined || c.status || c.phone || c.page);
2715
+ if (!item.pick && !things.length) return null;
2716
+ const cards: AnswerCard[] = item.cards.map((c) => {
2717
+ const form = formsFor(c.title)[0];
2718
+ const quote = form && /quote/.test(form.noun);
2719
+ return {
2720
+ title: c.title,
2721
+ stars: c.stars,
2722
+ reviews: c.reviews,
2723
+ status: c.status,
2724
+ phone: c.phone,
2725
+ page: c.page,
2726
+ facts: c.facts ?? [],
2727
+ ...(form
2728
+ ? {
2729
+ form: quote
2730
+ ? { label: 'Get a quote', message: `Get a quote from ${c.title}` }
2731
+ : { label: `Use their ${form.noun}`, message: `Start the ${form.noun} for ${c.title}` },
2732
+ }
2733
+ : {}),
2734
+ };
2735
+ });
2736
+ return resultsAnswer(cards, item.pick, {
2737
+ ask: (text) => {
2738
+ if (this._turnActive) return;
2739
+ this._els.input.value = text;
2740
+ void this._onSend();
2741
+ },
2742
+ });
2720
2743
  }
2721
2744
 
2722
2745
  /** Feature-card pattern: tinted icon chip, tags, tight Manrope headline, one
@@ -2777,9 +2800,12 @@ class NxisAgentFullscreen extends HTMLElement {
2777
2800
  * surface; a2ui="a2ui-results" opts this element back into it for comparison.
2778
2801
  */
2779
2802
  private _resultsEl(item: Extract<ThreadItem, { kind: 'results' }>): HTMLElement {
2780
- return this.getAttribute('a2ui') === 'a2ui-results'
2781
- ? this._resultsElA2ui(item)
2782
- : this._resultsElLegacy(item);
2803
+ if (this.getAttribute('a2ui') === 'a2ui-results') return this._resultsElA2ui(item);
2804
+ if (!this._classicPrompts && item.cards.length) {
2805
+ const answer = this._answerEl(item);
2806
+ if (answer) return answer;
2807
+ }
2808
+ return this._resultsElLegacy(item);
2783
2809
  }
2784
2810
 
2785
2811
  private _resultsElLegacy(item: Extract<ThreadItem, { kind: 'results' }>): HTMLElement {
@@ -4099,6 +4125,9 @@ class NxisAgentFullscreen extends HTMLElement {
4099
4125
  /* The action card: forms and handoffs as one message. */
4100
4126
  ${ACTION_CARD_CSS}
4101
4127
 
4128
+ /* Search results as an answer: a pick and why, or a short list. */
4129
+ ${RESULTS_ANSWER_CSS}
4130
+
4102
4131
  /* App-icon tile: chevron centered in a 56px tile, Soft Gray, hairline. */
4103
4132
  .launcher {
4104
4133
  position: fixed; right: 20px; bottom: 20px; z-index: 9998;