@appinternalleads/ui 0.3.0 → 0.4.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 (100) hide show
  1. package/dist/cjs/agent/base.js +25 -0
  2. package/dist/cjs/agent/base.js.map +1 -0
  3. package/dist/cjs/agent/catalog.js +0 -32
  4. package/dist/cjs/agent/catalog.js.map +1 -1
  5. package/dist/cjs/agent/core.js +22 -20
  6. package/dist/cjs/agent/core.js.map +1 -1
  7. package/dist/cjs/agent/index.js +6 -5
  8. package/dist/cjs/agent/index.js.map +1 -1
  9. package/dist/cjs/agent/search/document.js +50 -0
  10. package/dist/cjs/agent/search/document.js.map +1 -0
  11. package/dist/cjs/agent/search/embedding.js +107 -0
  12. package/dist/cjs/agent/search/embedding.js.map +1 -0
  13. package/dist/cjs/agent/search/index.js +25 -0
  14. package/dist/cjs/agent/search/index.js.map +1 -0
  15. package/dist/cjs/agent/search/lexical.js +28 -0
  16. package/dist/cjs/agent/search/lexical.js.map +1 -0
  17. package/dist/cjs/agent/search/normalize.js +15 -0
  18. package/dist/cjs/agent/search/normalize.js.map +1 -0
  19. package/dist/cjs/agent/search/search.js +123 -0
  20. package/dist/cjs/agent/search/search.js.map +1 -0
  21. package/dist/cjs/agent/search/searchIndex.js +120 -0
  22. package/dist/cjs/agent/search/searchIndex.js.map +1 -0
  23. package/dist/cjs/agent/search/types.js +8 -0
  24. package/dist/cjs/agent/search/types.js.map +1 -0
  25. package/dist/cjs/agent/search/weights.js +34 -0
  26. package/dist/cjs/agent/search/weights.js.map +1 -0
  27. package/dist/cjs/agent/search/wordvectors.json +1 -0
  28. package/dist/cjs/index.js +1 -1
  29. package/dist/cjs/index.js.map +1 -1
  30. package/dist/esm/agent/base.d.ts +11 -0
  31. package/dist/esm/agent/base.d.ts.map +1 -0
  32. package/dist/esm/agent/base.js +11 -0
  33. package/dist/esm/agent/base.js.map +1 -0
  34. package/dist/esm/agent/catalog.d.ts +0 -8
  35. package/dist/esm/agent/catalog.d.ts.map +1 -1
  36. package/dist/esm/agent/catalog.js +0 -31
  37. package/dist/esm/agent/catalog.js.map +1 -1
  38. package/dist/esm/agent/core.d.ts +6 -7
  39. package/dist/esm/agent/core.d.ts.map +1 -1
  40. package/dist/esm/agent/core.js +6 -7
  41. package/dist/esm/agent/core.js.map +1 -1
  42. package/dist/esm/agent/index.d.ts +6 -5
  43. package/dist/esm/agent/index.d.ts.map +1 -1
  44. package/dist/esm/agent/index.js +6 -5
  45. package/dist/esm/agent/index.js.map +1 -1
  46. package/dist/esm/agent/search/document.d.ts +6 -0
  47. package/dist/esm/agent/search/document.d.ts.map +1 -0
  48. package/dist/esm/agent/search/document.js +46 -0
  49. package/dist/esm/agent/search/document.js.map +1 -0
  50. package/dist/esm/agent/search/embedding.d.ts +31 -0
  51. package/dist/esm/agent/search/embedding.d.ts.map +1 -0
  52. package/dist/esm/agent/search/embedding.js +103 -0
  53. package/dist/esm/agent/search/embedding.js.map +1 -0
  54. package/dist/esm/agent/search/index.d.ts +10 -0
  55. package/dist/esm/agent/search/index.d.ts.map +1 -0
  56. package/dist/esm/agent/search/index.js +17 -0
  57. package/dist/esm/agent/search/index.js.map +1 -0
  58. package/dist/esm/agent/search/lexical.d.ts +3 -0
  59. package/dist/esm/agent/search/lexical.d.ts.map +1 -0
  60. package/dist/esm/agent/search/lexical.js +25 -0
  61. package/dist/esm/agent/search/lexical.js.map +1 -0
  62. package/dist/esm/agent/search/normalize.d.ts +13 -0
  63. package/dist/esm/agent/search/normalize.d.ts.map +1 -0
  64. package/dist/esm/agent/search/normalize.js +12 -0
  65. package/dist/esm/agent/search/normalize.js.map +1 -0
  66. package/dist/esm/agent/search/search.d.ts +42 -0
  67. package/dist/esm/agent/search/search.d.ts.map +1 -0
  68. package/dist/esm/agent/search/search.js +119 -0
  69. package/dist/esm/agent/search/search.js.map +1 -0
  70. package/dist/esm/agent/search/searchIndex.d.ts +42 -0
  71. package/dist/esm/agent/search/searchIndex.d.ts.map +1 -0
  72. package/dist/esm/agent/search/searchIndex.js +114 -0
  73. package/dist/esm/agent/search/searchIndex.js.map +1 -0
  74. package/dist/esm/agent/search/types.d.ts +67 -0
  75. package/dist/esm/agent/search/types.d.ts.map +1 -0
  76. package/dist/esm/agent/search/types.js +7 -0
  77. package/dist/esm/agent/search/types.js.map +1 -0
  78. package/dist/esm/agent/search/weights.d.ts +22 -0
  79. package/dist/esm/agent/search/weights.d.ts.map +1 -0
  80. package/dist/esm/agent/search/weights.js +31 -0
  81. package/dist/esm/agent/search/weights.js.map +1 -0
  82. package/dist/esm/agent/search/wordvectors.json +1 -0
  83. package/dist/esm/index.js +1 -1
  84. package/dist/esm/index.js.map +1 -1
  85. package/package.json +2 -1
  86. package/src/agent/base.ts +17 -0
  87. package/src/agent/catalog.ts +0 -32
  88. package/src/agent/core.ts +6 -14
  89. package/src/agent/index.ts +6 -5
  90. package/src/agent/search/document.ts +46 -0
  91. package/src/agent/search/embedding.ts +95 -0
  92. package/src/agent/search/index.ts +20 -0
  93. package/src/agent/search/lexical.ts +25 -0
  94. package/src/agent/search/normalize.ts +17 -0
  95. package/src/agent/search/search.ts +148 -0
  96. package/src/agent/search/searchIndex.ts +107 -0
  97. package/src/agent/search/types.ts +68 -0
  98. package/src/agent/search/weights.ts +32 -0
  99. package/src/index.ts +1 -1
  100. package/tools/ask/dist/components.mjs +348 -12
package/dist/esm/index.js CHANGED
@@ -16,6 +16,6 @@ export * from './widgets';
16
16
  nothing is implemented. See docs/architecture/COMPONENT-FAMILIES.md. */
17
17
  export * from './components';
18
18
  export * from './sheets';
19
- /* The agent component contract: search, describe, render/1, validation, result/1. */
19
+ /* The agent component contract for the renderer: AgentComponent, describe, render/1 validation, result/1. Search is in `@appinternalleads/ui/agent`. */
20
20
  export * from './agent';
21
21
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,cAAc,UAAU,CAAC;AACzB,cAAc,QAAQ,CAAC;AACvB,cAAc,UAAU,CAAC;AACzB,cAAc,MAAM,CAAC;AACrB,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACnE,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B;0EAC0E;AAC1E,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,qFAAqF;AACrF,cAAc,SAAS,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,cAAc,UAAU,CAAC;AACzB,cAAc,QAAQ,CAAC;AACvB,cAAc,UAAU,CAAC;AACzB,cAAc,MAAM,CAAC;AACrB,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACnE,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B;0EAC0E;AAC1E,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,wJAAwJ;AACxJ,cAAc,SAAS,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appinternalleads/ui",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "React + React Native component library. Components are generated from JSON; see SPEC.md.",
5
5
  "workspaces": [
6
6
  "site"
@@ -140,6 +140,7 @@
140
140
  "spec",
141
141
  "!src/**/*.test.*",
142
142
  "!src/**/*.stories.*",
143
+ "!src/agent/search/wordvectors.json",
143
144
  "tools/ask/mcp.mjs",
144
145
  "tools/ask/serve.mjs",
145
146
  "tools/ask/stale.mjs",
@@ -0,0 +1,17 @@
1
+ /**
2
+ * **The agent contract without search** — describe, `render/1` validation, `result/1`, the types.
3
+ * Small and React-free. Both entry points carry it: `@appinternalleads/ui/agent` (`./core`) adds
4
+ * `searchComponents`; the main entry adds `AgentComponent`. Search is not here on purpose — its
5
+ * word-vector table is 2.8 MB, and an app that renders components must not bundle it.
6
+ */
7
+ export {
8
+ AGENT_COMPONENTS, agentAvailability, describeComponent, isAgentComponent, listAgentComponents,
9
+ } from './catalog';
10
+ export { validateRenderRequest } from './validate';
11
+ export { RESULT_EVENTS, makeResult } from './result';
12
+ export {
13
+ CAPABILITY_SCHEMA, RENDER_SCHEMA, RESULT_SCHEMA,
14
+ type AgentButtonAction, type AgentCapability, type CapabilityAction, type CapabilityCard, type CapabilityEvent, type CapabilityKind,
15
+ type CopySpec as AgentCopySpec, type DataOrigin, type FieldSpec as AgentFieldSpec, type IssueCode, type IssueLayer, type Owner as AgentOwner,
16
+ type RenderCheck, type RenderIssue, type RenderPreview, type RenderRequest, type RenderResult, type Section as AgentSection,
17
+ } from './types';
@@ -98,35 +98,3 @@ export function agentAvailability(id: string): { component: string; agentEditabl
98
98
  }
99
99
  return { component: id, agentEditable: false, reason: 'No such component.' };
100
100
  }
101
-
102
- /* ── Search ────────────────────────────────────────────────────────────────────────────────── */
103
-
104
- const STOP = new Set('a an the of for to and or in on at by with my me i we our you your it is are be this that these those can could would should please make create show give get want need some any what how which who when from as into about'.split(' '));
105
- const stem = (w: string) => w.replace(/(ies)$/, 'y').replace(/(ing|ed|es|s)$/, '');
106
- const words = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N}\s'-]/gu, ' ').split(/[\s'-]+/).filter(w => w.length > 1 && !STOP.has(w)).map(stem);
107
-
108
- /**
109
- * **Intent → candidates.** A short list of cards, best first. Scored on the component's own
110
- * selection signals (a whole phrase in the request counts most), then its name and purpose, then
111
- * when to use it. Deterministic: the same words give the same order.
112
- */
113
- export function searchComponents(query: string, limit = 5): (CapabilityCard & { score: number })[] {
114
- const q = ` ${query.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').replace(/\s+/g, ' ')} `;
115
- const qw = new Set(words(query));
116
- const scored = [...CAPABILITIES.values()].map((c, order) => {
117
- let score = 0;
118
- for (const s of c.signals) {
119
- const phrase = ` ${s.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').replace(/\s+/g, ' ')} `;
120
- if (q.includes(phrase)) score += 6 + 2 * (phrase.trim().split(' ').length - 1);
121
- else score += words(s).filter(w => qw.has(w)).length * 2;
122
- }
123
- score += words(`${c.name} ${c.purpose}`).filter(w => qw.has(w)).length;
124
- score += new Set(words(c.useWhen.join(' ')).filter(w => qw.has(w))).size * 0.5;
125
- return { card: cardOf(c), score, order };
126
- });
127
- return scored
128
- .filter(s => s.score > 0)
129
- .sort((a, b) => b.score - a.score || a.order - b.order)
130
- .slice(0, limit)
131
- .map(s => ({ ...s.card, score: s.score }));
132
- }
package/src/agent/core.ts CHANGED
@@ -1,16 +1,8 @@
1
1
  /**
2
- * **The agent component contract, without React** — `@appinternalleads/ui/agent`. Search, describe,
3
- * `render/1` validation and `result/1`: what a backend or an MCP server needs, and all of it runs in
4
- * plain Node. The renderer, `AgentComponent`, is in the main entry. `docs/AGENT-COMPONENT-CONTRACT.md`.
2
+ * **`@appinternalleads/ui/agent` — the agent contract, with search, without React.** Search,
3
+ * describe, `render/1` validation and `result/1`: what a backend or an MCP server needs, all of it in
4
+ * plain Node. `searchComponents` lives only here (and its word-vector table with it); the renderer,
5
+ * `AgentComponent`, is in the main entry. `docs/AGENT-COMPONENT-CONTRACT.md`.
5
6
  */
6
- export {
7
- AGENT_COMPONENTS, agentAvailability, describeComponent, isAgentComponent, listAgentComponents, searchComponents,
8
- } from './catalog';
9
- export { validateRenderRequest } from './validate';
10
- export { RESULT_EVENTS, makeResult } from './result';
11
- export {
12
- CAPABILITY_SCHEMA, RENDER_SCHEMA, RESULT_SCHEMA,
13
- type AgentButtonAction, type AgentCapability, type CapabilityAction, type CapabilityCard, type CapabilityEvent, type CapabilityKind,
14
- type CopySpec as AgentCopySpec, type DataOrigin, type FieldSpec as AgentFieldSpec, type IssueCode, type IssueLayer, type Owner as AgentOwner,
15
- type RenderCheck, type RenderIssue, type RenderPreview, type RenderRequest, type RenderResult, type Section as AgentSection,
16
- } from './types';
7
+ export * from './base';
8
+ export { searchComponents } from './search';
@@ -1,9 +1,10 @@
1
1
  /**
2
- * **The agent component contract** — `docs/AGENT-COMPONENT-CONTRACT.md`.
2
+ * **The agent contract, as the main entry carries it** — for the app that renders: `AgentComponent`,
3
+ * `AGENT_RENDERABLE`, and the small React-free contract they use (describe, validate, results).
3
4
  *
4
- * search → describe → build a `render/1` → `validateRenderRequest` → `<AgentComponent>` → `result/1`.
5
- * Everything but `AgentComponent` is pure and serialisable, and is also its own entry point —
6
- * `@appinternalleads/ui/agent` (`./core`) — so a server or an MCP tool can use it without React.
5
+ * Not `searchComponents`: component search belongs to the agent, and its word-vector table must not
6
+ * reach an app's bundle. Import it from `@appinternalleads/ui/agent`. `tests/agent/boundary.test.ts`
7
+ * holds this line on the built package.
7
8
  */
8
- export * from './core';
9
+ export * from './base';
9
10
  export { AgentComponent, AGENT_RENDERABLE, type AgentComponentProps } from './AgentComponent';
@@ -0,0 +1,46 @@
1
+ import type { AgentCapability, FieldSpec } from '../types';
2
+ import type { FieldType, SearchDocument } from './types';
3
+
4
+ /**
5
+ * **A component's search document, derived from its capability.** It says what problem the
6
+ * component solves — purpose, when to use it, its selection signals, its case studies' scenarios,
7
+ * what a person can do with it, what it shows and what it reports — and, separately, what it is not
8
+ * for (`avoidWhen`). Nothing is written for search alone, so a new approved component is searchable
9
+ * the moment it has a capability. No source, no implementation, no runtime values.
10
+ */
11
+
12
+ /** "…statement over time — use budget-tracker or a list." → "…statement over time": the pointer to another component is advice, not meaning. */
13
+ function ownMeaning(text: string, ids: readonly string[]): string {
14
+ let t = text.replace(/\s[—–-]+\s*use\s.*$/i, '').replace(/:\s*use\s.*$/i, '');
15
+ for (const id of ids) t = t.split(id).join(' ');
16
+ return t.replace(/\s+/g, ' ').trim();
17
+ }
18
+
19
+ const describeData = (key: string, spec: FieldSpec): string => {
20
+ const words = key.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
21
+ return `${words}: ${spec.description}`;
22
+ };
23
+
24
+ export function searchDocumentOf(c: AgentCapability, order: number, allIds: readonly string[]): SearchDocument {
25
+ const positive: { type: FieldType; text: string }[] = [];
26
+ const add = (type: FieldType, text: string | undefined) => { const t = text && ownMeaning(text, allIds); if (t) positive.push({ type, text: t }); };
27
+ add('name', c.name);
28
+ add('purpose', c.purpose);
29
+ c.signals.forEach(s => add('signal', s));
30
+ c.useWhen.forEach(s => add('useWhen', s));
31
+ c.presets.forEach(p => add('scenario', `${p.title}. ${p.scenario}`));
32
+ c.actions.forEach(a => add('action', a.description));
33
+ c.states.forEach(s => add('state', s.description));
34
+ c.events.forEach(e => add('event', e.description));
35
+ for (const [k, spec] of Object.entries(c.data)) add('data', describeData(k, spec));
36
+ const negative = c.avoidWhen.map(t => ownMeaning(t, allIds)).filter(Boolean).map(text => ({ text }));
37
+ return { component: c.component, order, positive, negative };
38
+ }
39
+
40
+ /** FNV-1a over the documents: the catalogue's search version. Any change to what search reads changes it. */
41
+ export function versionOf(value: unknown): string {
42
+ const s = JSON.stringify(value);
43
+ let h = 0x811c9dc5;
44
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
45
+ return (h >>> 0).toString(16).padStart(8, '0');
46
+ }
@@ -0,0 +1,95 @@
1
+ import type { EmbeddingProvider } from './types';
2
+
3
+ /**
4
+ * **Embedding providers.** The search asks a provider for vectors and nothing else, so the one
5
+ * below — pretrained word vectors shipped in the package, no network — can be replaced by a hosted
6
+ * model without touching retrieval or ranking.
7
+ */
8
+
9
+ /** `bits`: 8 — int8 per value; 4 — two values per byte, each stored as value + 8 (−7…7). */
10
+ export type WordVectorTable = { dims: number; count: number; words: string; data: string; bits?: 4 | 8 };
11
+
12
+ const bytesOf = (b64: string): Uint8Array => {
13
+ if (typeof Buffer !== 'undefined') { const b = Buffer.from(b64, 'base64'); return new Uint8Array(b.buffer, b.byteOffset, b.length); }
14
+ const bin = atob(b64); const out = new Uint8Array(bin.length);
15
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
16
+ return out;
17
+ };
18
+ /* One signed value per entry, whatever the storage. Only directions matter (vectors are averaged and normalised), so no rescaling. */
19
+ function decode(t: WordVectorTable): Int8Array {
20
+ const bytes = bytesOf(t.data);
21
+ if (t.bits !== 4) return new Int8Array(bytes.buffer, bytes.byteOffset, bytes.length);
22
+ const out = new Int8Array(t.count * t.dims);
23
+ for (let i = 0; i < out.length; i++) out[i] = ((bytes[i >> 1]! >> ((i & 1) * 4)) & 15) - 8;
24
+ return out;
25
+ }
26
+
27
+ const unit = (v: Float32Array): Float32Array => {
28
+ let n = 0; for (const x of v) n += x * x;
29
+ n = Math.sqrt(n);
30
+ if (n > 0) for (let i = 0; i < v.length; i++) v[i]! /= n;
31
+ return v;
32
+ };
33
+
34
+ /**
35
+ * **Word vectors, averaged — offline and deterministic.** A text's vector is the average of its
36
+ * words' vectors, each weighted by how rare the word is (Arora et al.'s smooth inverse frequency,
37
+ * with the table's frequency order standing in for counts), so "the" counts for almost nothing and
38
+ * "receipt" for a lot. A word the table lacks is tried without a plural or tense ending; failing
39
+ * that it is skipped (lexical matching still sees it).
40
+ */
41
+ export function createWordVectorProvider(table: WordVectorTable, opts: { smoothing?: number } = {}): EmbeddingProvider {
42
+ let index: Map<string, number> | null = null;
43
+ let data: Int8Array | null = null;
44
+ const a = opts.smoothing ?? 1e-3;
45
+ /* Zipf: a word's share of text ∝ 1 / rank. H is the normaliser for the table's size. */
46
+ const H = Math.log(table.count) + 0.5772;
47
+ const load = () => {
48
+ if (index) return;
49
+ index = new Map(table.words.split(' ').map((w, i) => [w, i]));
50
+ data = decode(table);
51
+ };
52
+ const lookup = (w: string): number | undefined => {
53
+ const m = index!;
54
+ return m.get(w) ?? m.get(w.replace(/'s$/, '')) ?? m.get(w.replace(/ies$/, 'y')) ?? m.get(w.replace(/s$/, ''))
55
+ ?? m.get(w.replace(/ing$/, '')) ?? m.get(w.replace(/ing$/, 'e')) ?? m.get(w.replace(/ed$/, '')) ?? m.get(w.replace(/d$/, ''));
56
+ };
57
+ return {
58
+ id: `word-vectors:${table.dims}x${table.count}x${table.bits ?? 8}bit:sif-${a}`,
59
+ embed(texts) {
60
+ load();
61
+ return texts.map(text => {
62
+ const v = new Float32Array(table.dims);
63
+ for (const raw of text.toLowerCase().split(/[^\p{L}']+/u)) {
64
+ if (!raw) continue;
65
+ const r = lookup(raw);
66
+ if (r === undefined) continue;
67
+ const p = 1 / ((r + 1) * H);
68
+ const w = a / (a + p);
69
+ const off = r * table.dims;
70
+ for (let d = 0; d < table.dims; d++) v[d]! += w * data![off + d]!;
71
+ }
72
+ return unit(v);
73
+ });
74
+ },
75
+ };
76
+ }
77
+
78
+ /**
79
+ * **For tests: deterministic vectors, no model.** Each word hashes to a few dimensions, so equal words
80
+ * give equal vectors and different words mostly do not. It proves the machinery — indexing, ranking,
81
+ * filtering, fallback — not semantic quality.
82
+ */
83
+ export function createFakeEmbeddingProvider(dims = 64): EmbeddingProvider {
84
+ const hash = (s: string, seed: number) => { let h = 0x811c9dc5 ^ seed; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; };
85
+ return {
86
+ id: `fake:${dims}`,
87
+ embed(texts) {
88
+ return texts.map(t => {
89
+ const v = new Float32Array(dims);
90
+ for (const w of t.toLowerCase().split(/[^\p{L}\p{N}]+/u)) if (w) for (let s = 0; s < 3; s++) v[hash(w, s) % dims]! += (hash(w, s + 7) & 1) ? 1 : -1;
91
+ return unit(v);
92
+ });
93
+ },
94
+ };
95
+ }
@@ -0,0 +1,20 @@
1
+ import { createWordVectorProvider, type WordVectorTable } from './embedding';
2
+ import { createComponentSearch, type SearchCard } from './search';
3
+ import type { SearchOptions } from './types';
4
+ import { DEFAULT_WEIGHTS } from './weights';
5
+ import table from './wordvectors.json';
6
+
7
+ /**
8
+ * **`searchComponents` — the package's one search.** The MCP tool and the site's API call this; the
9
+ * hybrid engine behind it (`search.ts`) uses the offline word-vector provider shipped with the
10
+ * package, so it needs no network and gives the same answer every time.
11
+ */
12
+ const engine = createComponentSearch({ provider: createWordVectorProvider(table as WordVectorTable), weights: DEFAULT_WEIGHTS });
13
+
14
+ /** Intent → candidate cards, best first. `options.available` is the host's policy: only those components may be offered. */
15
+ export function searchComponents(query: string, limit = 5, options?: SearchOptions): SearchCard[] {
16
+ return engine.search(query, limit, options);
17
+ }
18
+
19
+ /** Development only: why each component scored what it did. Not part of the agent's result. */
20
+ export const explainSearch = (query: string, options?: SearchOptions) => engine.explain(query, options);
@@ -0,0 +1,25 @@
1
+ import type { AgentCapability } from '../types';
2
+
3
+ /**
4
+ * **Lexical scoring — the search as it shipped in 0.3.0, unchanged.** Whole selection-signal phrases
5
+ * in the request count most, then shared words with the signals, the name and purpose, then when to
6
+ * use it. It is one input to the hybrid ranking, and on its own it is the fallback whenever semantic
7
+ * retrieval is unavailable.
8
+ */
9
+ const STOP = new Set('a an the of for to and or in on at by with my me i we our you your it is are be this that these those can could would should please make create show give get want need some any what how which who when from as into about'.split(' '));
10
+ const stem = (w: string) => w.replace(/(ies)$/, 'y').replace(/(ing|ed|es|s)$/, '');
11
+ const words = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N}\s'-]/gu, ' ').split(/[\s'-]+/).filter(w => w.length > 1 && !STOP.has(w)).map(stem);
12
+
13
+ export function lexicalScore(c: AgentCapability, query: string): number {
14
+ const q = ` ${query.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').replace(/\s+/g, ' ')} `;
15
+ const qw = new Set(words(query));
16
+ let score = 0;
17
+ for (const s of c.signals) {
18
+ const phrase = ` ${s.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').replace(/\s+/g, ' ')} `;
19
+ if (q.includes(phrase)) score += 6 + 2 * (phrase.trim().split(' ').length - 1);
20
+ else score += words(s).filter(w => qw.has(w)).length * 2;
21
+ }
22
+ score += words(`${c.name} ${c.purpose}`).filter(w => qw.has(w)).length;
23
+ score += new Set(words(c.useWhen.join(' ')).filter(w => qw.has(w))).size * 0.5;
24
+ return score;
25
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * **Query normalisation** — whitespace, case, obvious punctuation and repeated words. Nothing that
3
+ * changes meaning: no synonyms, no spelling rewrites. The original stays available for lexical
4
+ * phrase matching and for debugging.
5
+ */
6
+ export type NormalizedQuery = { original: string; text: string; tokens: string[]; empty: boolean };
7
+
8
+ export function normalizeQuery(query: unknown): NormalizedQuery {
9
+ const original = typeof query === 'string' ? query : '';
10
+ const text = original.toLowerCase().normalize('NFKC').replace(/[^\p{L}\p{N}\s'-]/gu, ' ').replace(/\s+/g, ' ').trim();
11
+ const tokens: string[] = [];
12
+ for (const t of text.split(/[\s-]+/)) {
13
+ const w = t.replace(/^'+|'+$/g, '');
14
+ if (w && tokens[tokens.length - 1] !== w) tokens.push(w);
15
+ }
16
+ return { original, text: tokens.join(' '), tokens, empty: tokens.length === 0 };
17
+ }
@@ -0,0 +1,148 @@
1
+ import { AGENT_COMPONENTS, describeComponent, isAgentComponent } from '../catalog';
2
+ import type { AgentCapability, CapabilityCard } from '../types';
3
+ import { searchDocumentOf } from './document';
4
+ import { SearchIndex, cosine, indexVersion, type Centring, type IndexedDocument } from './searchIndex';
5
+ import { lexicalScore } from './lexical';
6
+ import { normalizeQuery, type NormalizedQuery } from './normalize';
7
+ import type { EmbeddingProvider, ScoreBreakdown, SearchDocument, SearchOptions, SearchWeights } from './types';
8
+
9
+ /**
10
+ * **Component search: intent → candidates.**
11
+ *
12
+ * normalizeQuery → retrieveCandidates (semantic + lexical) → applyEligibility → rankCandidates → cards
13
+ *
14
+ * Semantic similarity comes from an `EmbeddingProvider` over each component's search document
15
+ * (`document.ts`, derived from its capability); lexical similarity is 0.3.0's scorer. If semantic
16
+ * retrieval is unavailable — no provider, an index that fails to build, a query with no known
17
+ * words — the ranking is exactly the lexical one. Eligibility (approved, and allowed by the host)
18
+ * is applied after retrieval and is authoritative: relevance never makes a component usable.
19
+ * Ties break on catalogue order, so the same query and catalogue give the same list.
20
+ */
21
+
22
+ export type SearchCard = CapabilityCard & { score: number };
23
+
24
+ export type ComponentSearch = {
25
+ search(query: string, limit?: number, options?: SearchOptions): SearchCard[];
26
+ /** Development: every component's scores, best first — never part of the agent's result. */
27
+ explain(query: string, options?: SearchOptions): ScoreBreakdown[] & { mode: 'hybrid' | 'lexical'; version: string };
28
+ /** The index in use (null when searching lexically). */
29
+ index(): SearchIndex | null;
30
+ };
31
+
32
+ const round = (x: number) => Math.round(x * 1000) / 1000;
33
+ const cardOf = (c: AgentCapability): CapabilityCard => ({ component: c.component, name: c.name, kind: c.kind, purpose: c.purpose, signals: c.signals });
34
+
35
+ /** The approved catalogue, in order, and its search documents — derived once per module. */
36
+ let catalogCache: { caps: AgentCapability[]; docs: SearchDocument[] } | null = null;
37
+ export function searchCatalog(): { caps: AgentCapability[]; docs: SearchDocument[] } {
38
+ if (!catalogCache) {
39
+ const caps = AGENT_COMPONENTS.map(id => describeComponent(id)!);
40
+ catalogCache = { caps, docs: caps.map((c, i) => searchDocumentOf(c, i, AGENT_COMPONENTS)) };
41
+ }
42
+ return catalogCache;
43
+ }
44
+
45
+ export function createComponentSearch(opts: {
46
+ provider?: EmbeddingProvider | null;
47
+ weights: SearchWeights;
48
+ /** Where the documents come from — the approved catalogue by default. */
49
+ catalog?: () => { caps: AgentCapability[]; docs: SearchDocument[] };
50
+ }): ComponentSearch {
51
+ const catalog = opts.catalog ?? searchCatalog;
52
+ const w = opts.weights;
53
+ let built: SearchIndex | null = null;
54
+ let failed: string | null = null;
55
+
56
+ /* The index for the catalogue as it is now — rebuilt whenever its version (documents + provider) changes. */
57
+ const indexFor = (docs: readonly SearchDocument[]): SearchIndex | null => {
58
+ const provider = opts.provider;
59
+ if (!provider) return null;
60
+ const version = indexVersion(docs, provider);
61
+ if (built?.version === version) return built;
62
+ if (failed === version) return null;
63
+ try {
64
+ built = new SearchIndex(provider, version).build(docs);
65
+ return built;
66
+ } catch {
67
+ failed = version;
68
+ built = null;
69
+ return null;
70
+ }
71
+ };
72
+
73
+ const queryVector = (q: NormalizedQuery, index: SearchIndex): Float32Array | null => {
74
+ try {
75
+ const v = index.provider.embed([q.text])[0];
76
+ if (!v || v.length !== index.mean?.length || !v.some(x => x !== 0) || v.some(x => !Number.isFinite(x))) return null;
77
+ return v;
78
+ } catch { return null; }
79
+ };
80
+
81
+ function retrieveCandidates(q: NormalizedQuery) {
82
+ const { caps, docs } = catalog();
83
+ const index = q.empty ? null : indexFor(docs);
84
+ const qv = index ? queryVector(q, index) : null;
85
+ const centring = { mean: w.centre !== 'none' ? index?.mean ?? null : null, principal: w.centre === 'pc' ? index?.principal ?? null : null };
86
+ return {
87
+ mode: (qv ? 'hybrid' : 'lexical') as 'hybrid' | 'lexical',
88
+ version: index?.version ?? '',
89
+ candidates: caps.map((c, order) => {
90
+ const lexical = q.empty ? 0 : lexicalScore(c, q.original);
91
+ if (!qv || !index) return { cap: c, order, lexical, semantic: null as number | null, negative: null as number | null };
92
+ const e = index.entries.get(c.component);
93
+ if (!e) return { cap: c, order, lexical, semantic: null, negative: null };
94
+ const { pos, posMax } = fieldScore(qv, e, centring);
95
+ const neg = e.negative.length ? Math.max(...e.negative.map(n => cosine(qv, n, centring))) : 0;
96
+ return { cap: c, order, lexical, semantic: pos - w.negative * Math.max(0, neg - posMax), negative: neg };
97
+ }),
98
+ };
99
+ }
100
+
101
+ /* How close a query vector is to a component: the mean of its `topK` closest fields (weighted by kind), and the closest one. */
102
+ function fieldScore(qv: Float32Array, e: IndexedDocument, centring: Centring) {
103
+ const sims = e.positive.filter(p => w.fields[p.type] > 0).map(p => { const raw = cosine(qv, p.vector, centring); return { raw, weighted: w.fields[p.type] * raw }; });
104
+ const top = sims.map(x => x.weighted).sort((a, b) => b - a).slice(0, w.topK);
105
+ return { pos: top.length ? top.reduce((a, b) => a + b, 0) / top.length : 0, posMax: sims.length ? Math.max(...sims.map(x => x.raw)) : 0 };
106
+ }
107
+
108
+ const applyEligibility = <T extends { cap: AgentCapability }>(list: T[], options: SearchOptions = {}) => list.map(x => ({
109
+ ...x,
110
+ availability: isAgentComponent(x.cap.component) && (!options.available || options.available.includes(x.cap.component)) ? 1 : 0,
111
+ metadata: !options.kinds || options.kinds.includes(x.cap.kind) ? 1 : 0,
112
+ }));
113
+
114
+ function rankCandidates(query: string, options?: SearchOptions) {
115
+ const q = normalizeQuery(query);
116
+ const r = retrieveCandidates(q);
117
+ const scored = applyEligibility(r.candidates, options).map(x => ({
118
+ ...x,
119
+ final: r.mode === 'lexical' || x.semantic === null
120
+ ? x.lexical
121
+ : w.semantic * x.semantic + w.lexical * (x.lexical / (x.lexical + w.lexicalHalf)),
122
+ }));
123
+ scored.sort((a, b) => b.final - a.final || a.order - b.order);
124
+ return { ...r, scored };
125
+ }
126
+
127
+ return {
128
+ search(query, limit = 5, options) {
129
+ const r = rankCandidates(query, options);
130
+ const eligible = r.scored.filter(x => x.availability && x.metadata);
131
+ const kept = r.mode === 'lexical'
132
+ ? eligible.filter(x => x.final > 0)
133
+ : (() => { const best = eligible[0]?.final ?? 0; return eligible.filter(x => x.final >= w.minScore && (best <= 0 || x.final >= best * w.relative)); })();
134
+ return kept.slice(0, Math.max(0, limit)).map(x => ({ ...cardOf(x.cap), score: round(x.final) }));
135
+ },
136
+ explain(query, options) {
137
+ const r = rankCandidates(query, options);
138
+ const out = r.scored.map(x => ({
139
+ component: x.cap.component, semantic: x.semantic === null ? null : round(x.semantic), negative: x.negative === null ? null : round(x.negative),
140
+ lexical: round(x.lexical), metadata: x.metadata, availability: x.availability, final: round(x.final),
141
+ })) as ScoreBreakdown[] & { mode: 'hybrid' | 'lexical'; version: string };
142
+ out.mode = r.mode;
143
+ out.version = r.version;
144
+ return out;
145
+ },
146
+ index: () => indexFor(catalog().docs),
147
+ };
148
+ }
@@ -0,0 +1,107 @@
1
+ import { versionOf } from './document';
2
+ import type { EmbeddingProvider, FieldType, SearchDocument } from './types';
3
+
4
+ /**
5
+ * **The search index — derived, rebuildable, never the source of truth.** Each component's document
6
+ * fields as vectors, plus the catalogue mean (for centring). Its `version` is the documents' hash
7
+ * plus the provider's id: if the catalogue or the model changes, the version does, and a cached
8
+ * index for the old one is never served (`indexFor`).
9
+ */
10
+ export type IndexedDocument = {
11
+ component: string;
12
+ order: number;
13
+ positive: { type: FieldType; vector: Float32Array }[];
14
+ negative: Float32Array[];
15
+ };
16
+
17
+ export class SearchIndex {
18
+ readonly entries = new Map<string, IndexedDocument>();
19
+ mean: Float32Array | null = null;
20
+ /** The first principal component of the (centred) document vectors — the direction every text shares most (SIF's common-component removal). */
21
+ principal: Float32Array | null = null;
22
+ constructor(readonly provider: EmbeddingProvider, public version: string) {}
23
+
24
+ /** Throw away everything and index these documents. */
25
+ build(docs: readonly SearchDocument[]): this {
26
+ this.entries.clear();
27
+ for (const d of docs) this.put(d);
28
+ this.version = indexVersion(docs, this.provider);
29
+ return this.recentre();
30
+ }
31
+
32
+
33
+ /** Add or replace one component. */
34
+ upsert(doc: SearchDocument, allDocs?: readonly SearchDocument[]): this {
35
+ this.put(doc);
36
+ if (allDocs) this.version = indexVersion(allDocs, this.provider);
37
+ return this.recentre();
38
+ }
39
+
40
+ remove(component: string, allDocs?: readonly SearchDocument[]): this {
41
+ this.entries.delete(component);
42
+ if (allDocs) this.version = indexVersion(allDocs, this.provider);
43
+ return this.recentre();
44
+ }
45
+
46
+ private put(d: SearchDocument) {
47
+ const vectors = this.provider.embed([...d.positive.map(p => p.text), ...d.negative.map(n => n.text)]);
48
+ if (vectors.length !== d.positive.length + d.negative.length) throw new Error(`${this.provider.id} returned ${vectors.length} vectors for ${d.component}`);
49
+ this.entries.set(d.component, {
50
+ component: d.component,
51
+ order: d.order,
52
+ positive: d.positive.map((p, i) => ({ type: p.type, vector: vectors[i]! })),
53
+ negative: vectors.slice(d.positive.length),
54
+ });
55
+ }
56
+
57
+ /** The mean of every positive field vector: what all documents share, subtracted before comparing when centring. */
58
+ private recentre(): this {
59
+ let sum: Float32Array | null = null; let n = 0;
60
+ for (const e of this.entries.values()) for (const p of e.positive) {
61
+ sum ??= new Float32Array(p.vector.length);
62
+ for (let i = 0; i < sum.length; i++) sum[i]! += p.vector[i]!;
63
+ n++;
64
+ }
65
+ if (sum && n) for (let i = 0; i < sum.length; i++) sum[i]! /= n;
66
+ this.mean = sum;
67
+ this.principal = sum ? firstComponent([...this.entries.values()].flatMap(e => e.positive.map(p => p.vector)), sum) : null;
68
+ return this;
69
+ }
70
+ }
71
+
72
+ export const indexVersion = (docs: readonly SearchDocument[], provider: EmbeddingProvider) => `${versionOf(docs)}:${provider.id}`;
73
+
74
+ /** Power iteration — deterministic start, fixed iterations. */
75
+ function firstComponent(vectors: Float32Array[], mean: Float32Array): Float32Array {
76
+ const d = mean.length;
77
+ let u = new Float32Array(d).fill(1 / Math.sqrt(d));
78
+ for (let it = 0; it < 30; it++) {
79
+ const next = new Float32Array(d);
80
+ for (const v of vectors) {
81
+ let dot = 0; for (let i = 0; i < d; i++) dot += (v[i]! - mean[i]!) * u[i]!;
82
+ for (let i = 0; i < d; i++) next[i]! += dot * (v[i]! - mean[i]!);
83
+ }
84
+ let n = 0; for (const x of next) n += x * x; n = Math.sqrt(n) || 1;
85
+ for (let i = 0; i < d; i++) next[i]! /= n;
86
+ u = next;
87
+ }
88
+ return u;
89
+ }
90
+
91
+ /** What is compared: the vector less the shared mean and, if given, less its projection on the shared direction. */
92
+ export type Centring = { mean: Float32Array | null; principal: Float32Array | null };
93
+ function centred(v: Float32Array, c: Centring): Float32Array {
94
+ if (!c.mean && !c.principal) return v;
95
+ const out = new Float32Array(v.length);
96
+ for (let i = 0; i < v.length; i++) out[i] = v[i]! - (c.mean ? c.mean[i]! : 0);
97
+ if (c.principal) { let dot = 0; for (let i = 0; i < v.length; i++) dot += out[i]! * c.principal[i]!; for (let i = 0; i < v.length; i++) out[i]! -= dot * c.principal[i]!; }
98
+ return out;
99
+ }
100
+
101
+ /** Cosine similarity after centring. */
102
+ export function cosine(a: Float32Array, b: Float32Array, c: Centring = { mean: null, principal: null }): number {
103
+ const x = centred(a, c), y = centred(b, c);
104
+ let dot = 0, na = 0, nb = 0;
105
+ for (let i = 0; i < x.length; i++) { dot += x[i]! * y[i]!; na += x[i]! * x[i]!; nb += y[i]! * y[i]!; }
106
+ return na && nb ? dot / Math.sqrt(na * nb) : 0;
107
+ }