@apitap/core 1.6.4 → 1.7.1

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.
@@ -1,5 +1,5 @@
1
1
  // src/skill/search.ts
2
- import { listSkillFiles, readSkillFile } from './store.js';
2
+ import { ensureIndex } from './index.js';
3
3
 
4
4
  export interface SearchResult {
5
5
  domain: string;
@@ -13,20 +13,39 @@ export interface SearchResult {
13
13
  export interface SearchResponse {
14
14
  found: boolean;
15
15
  results?: SearchResult[];
16
+ summary?: string;
16
17
  suggestion?: string;
17
18
  }
18
19
 
20
+ /**
21
+ * Check if a search term matches a target string.
22
+ * Supports prefix matching: "payment" matches "payments", "pay" matches "payouts".
23
+ */
24
+ function termMatches(term: string, text: string): boolean {
25
+ // Check if any word in the text starts with the term (prefix match)
26
+ // Split on word boundaries: slashes, hyphens, underscores, dots, spaces
27
+ const words = text.split(/[\s/\-_.]+/);
28
+ for (const word of words) {
29
+ if (word.startsWith(term)) return true;
30
+ }
31
+ // Also check plain substring for multi-word or partial path matches
32
+ return text.includes(term);
33
+ }
34
+
19
35
  /**
20
36
  * Search skill files for endpoints matching a query.
37
+ * Uses the search index for sub-second results.
21
38
  * Matches against domain names, endpoint IDs, and endpoint paths.
22
- * Query terms are matched case-insensitively.
39
+ * Query terms are matched case-insensitively with prefix matching.
23
40
  */
24
41
  export async function searchSkills(
25
42
  query: string,
26
43
  skillsDir?: string,
27
44
  ): Promise<SearchResponse> {
28
- const summaries = await listSkillFiles(skillsDir);
29
- if (summaries.length === 0) {
45
+ const index = await ensureIndex(skillsDir);
46
+
47
+ const domainCount = Object.keys(index.domains).length;
48
+ if (domainCount === 0) {
30
49
  return {
31
50
  found: false,
32
51
  suggestion: 'No skill files found. Run `apitap capture <url>` to capture API traffic first.',
@@ -35,42 +54,42 @@ export async function searchSkills(
35
54
 
36
55
  const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
37
56
  const results: SearchResult[] = [];
57
+ const matchedDomains = new Set<string>();
38
58
 
39
- for (const summary of summaries) {
40
- const skill = await readSkillFile(summary.domain, skillsDir, { trustUnsigned: true });
41
- if (!skill) continue;
42
-
43
- const domainLower = skill.domain.toLowerCase();
59
+ for (const [domain, entry] of Object.entries(index.domains)) {
60
+ const domainLower = domain.toLowerCase();
44
61
 
45
- for (const ep of skill.endpoints) {
62
+ for (const ep of entry.endpoints) {
46
63
  const endpointIdLower = ep.id.toLowerCase();
47
64
  const pathLower = ep.path.toLowerCase();
48
65
  const methodLower = ep.method.toLowerCase();
49
66
 
50
- // Check if all query terms match against the combined searchable text
51
67
  const searchText = `${domainLower} ${endpointIdLower} ${pathLower} ${methodLower}`;
52
- const allMatch = terms.every(term => searchText.includes(term));
68
+ const allMatch = terms.every(term => termMatches(term, searchText));
53
69
 
54
70
  if (allMatch) {
71
+ matchedDomains.add(domain);
55
72
  results.push({
56
- domain: skill.domain,
73
+ domain,
57
74
  endpointId: ep.id,
58
75
  method: ep.method,
59
76
  path: ep.path,
60
- tier: ep.replayability?.tier ?? 'unknown',
61
- verified: ep.replayability?.verified ?? false,
77
+ tier: ep.tier ?? 'unknown',
78
+ verified: ep.verified ?? false,
62
79
  });
63
80
  }
64
81
  }
65
82
  }
66
83
 
67
84
  if (results.length === 0) {
68
- const domains = summaries.map(s => s.domain).join(', ');
85
+ const domains = Object.keys(index.domains).join(', ');
69
86
  return {
70
87
  found: false,
88
+ summary: '0 endpoints across 0 domains',
71
89
  suggestion: `No matches for "${query}". Available domains: ${domains}`,
72
90
  };
73
91
  }
74
92
 
75
- return { found: true, results };
93
+ const summary = `${results.length} endpoints across ${matchedDomains.size} domain${matchedDomains.size === 1 ? '' : 's'}`;
94
+ return { found: true, results, summary };
76
95
  }
@@ -4,6 +4,7 @@ import { join, dirname } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import type { SkillFile, SkillSummary } from '../types.js';
6
6
  import { validateSkillFile } from './validate.js';
7
+ import { updateIndex, ensureIndex } from './index.js';
7
8
 
8
9
  const DEFAULT_SKILLS_DIR = join(homedir(), '.apitap', 'skills');
9
10
 
@@ -37,6 +38,9 @@ export async function writeSkillFile(
37
38
  skill: SkillFile,
38
39
  skillsDir: string = DEFAULT_SKILLS_DIR,
39
40
  ): Promise<string> {
41
+ // Validate before writing — catch bad data at the source, not on read
42
+ validateSkillFile(skill);
43
+
40
44
  await mkdir(skillsDir, { recursive: true, mode: 0o700 });
41
45
  await ensureGitignore(skillsDir);
42
46
  const filePath = skillPath(skill.domain, skillsDir);
@@ -44,6 +48,26 @@ export async function writeSkillFile(
44
48
  const content = JSON.stringify(skill, null, 2) + '\n';
45
49
  await writeFile(tmpPath, content, { mode: 0o600 });
46
50
  await rename(tmpPath, filePath);
51
+
52
+ // Incrementally update the search index
53
+ try {
54
+ await updateIndex(
55
+ skill.domain,
56
+ skill.endpoints.map(ep => ({
57
+ id: ep.id,
58
+ method: ep.method,
59
+ path: ep.path,
60
+ ...(ep.replayability?.tier ? { tier: ep.replayability.tier } : {}),
61
+ ...(ep.replayability?.verified ? { verified: true } : {}),
62
+ })),
63
+ skill.provenance ?? 'unsigned',
64
+ skillsDir,
65
+ skill.capturedAt,
66
+ );
67
+ } catch {
68
+ // Index update failure should not block writes
69
+ }
70
+
47
71
  return filePath;
48
72
  }
49
73
 
@@ -111,33 +135,38 @@ export async function readSkillFile(
111
135
  }
112
136
  }
113
137
 
114
- export async function listSkillFiles(
138
+ /**
139
+ * Fault-tolerant wrapper around readSkillFile — returns null instead of
140
+ * throwing on validation errors, bad signatures, etc. ENOENT (missing
141
+ * file) also returns null. Use this when iterating many files where one
142
+ * bad file should not abort the whole operation.
143
+ */
144
+ export async function safeReadSkillFile(
145
+ domain: string,
115
146
  skillsDir: string = DEFAULT_SKILLS_DIR,
116
- ): Promise<SkillSummary[]> {
117
- let files: string[];
147
+ options?: Parameters<typeof readSkillFile>[2],
148
+ ): Promise<SkillFile | null> {
118
149
  try {
119
- files = await readdir(skillsDir);
120
- } catch (err: unknown) {
121
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
122
- throw err;
150
+ return await readSkillFile(domain, skillsDir, options);
151
+ } catch {
152
+ return null;
123
153
  }
154
+ }
124
155
 
156
+ export async function listSkillFiles(
157
+ skillsDir: string = DEFAULT_SKILLS_DIR,
158
+ ): Promise<SkillSummary[]> {
159
+ const index = await ensureIndex(skillsDir);
125
160
  const summaries: SkillSummary[] = [];
126
- const DOMAIN_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
127
- for (const file of files) {
128
- if (!file.endsWith('.json')) continue;
129
- const domain = file.replace(/\.json$/, '');
130
- if (!DOMAIN_RE.test(domain)) continue; // skip non-conforming filenames
131
- const skill = await readSkillFile(domain, skillsDir, { trustUnsigned: true });
132
- if (skill) {
133
- summaries.push({
134
- domain: skill.domain,
135
- skillFile: join(skillsDir, file),
136
- endpointCount: skill.endpoints.length,
137
- capturedAt: skill.capturedAt,
138
- provenance: skill.provenance ?? 'unsigned',
139
- });
140
- }
161
+
162
+ for (const [domain, entry] of Object.entries(index.domains)) {
163
+ summaries.push({
164
+ domain,
165
+ skillFile: join(skillsDir, `${domain}.json`),
166
+ endpointCount: entry.endpointCount,
167
+ capturedAt: entry.capturedAt || index.builtAt,
168
+ provenance: entry.provenance,
169
+ });
141
170
  }
142
171
 
143
172
  return summaries;