@apitap/core 1.6.3 → 1.7.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.
- package/README.md +166 -69
- package/dist/cli.js +25 -1
- package/dist/cli.js.map +1 -1
- package/dist/skill/index.d.ts +52 -0
- package/dist/skill/index.js +173 -0
- package/dist/skill/index.js.map +1 -0
- package/dist/skill/merge.js +21 -1
- package/dist/skill/merge.js.map +1 -1
- package/dist/skill/search.d.ts +1 -0
- package/dist/skill/search.js +12 -14
- package/dist/skill/search.js.map +1 -1
- package/dist/skill/store.d.ts +7 -0
- package/dist/skill/store.js +38 -25
- package/dist/skill/store.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +24 -1
- package/src/skill/index.ts +238 -0
- package/src/skill/merge.ts +23 -1
- package/src/skill/search.ts +13 -14
- package/src/skill/store.ts +51 -22
package/dist/skill/search.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// src/skill/search.ts
|
|
2
|
-
import {
|
|
2
|
+
import { ensureIndex } from './index.js';
|
|
3
3
|
/**
|
|
4
4
|
* Search skill files for endpoints matching a query.
|
|
5
|
+
* Uses the search index for sub-second results.
|
|
5
6
|
* Matches against domain names, endpoint IDs, and endpoint paths.
|
|
6
7
|
* Query terms are matched case-insensitively.
|
|
7
8
|
*/
|
|
8
9
|
export async function searchSkills(query, skillsDir) {
|
|
9
|
-
const
|
|
10
|
-
|
|
10
|
+
const index = await ensureIndex(skillsDir);
|
|
11
|
+
const domainCount = Object.keys(index.domains).length;
|
|
12
|
+
if (domainCount === 0) {
|
|
11
13
|
return {
|
|
12
14
|
found: false,
|
|
13
15
|
suggestion: 'No skill files found. Run `apitap capture <url>` to capture API traffic first.',
|
|
@@ -15,32 +17,28 @@ export async function searchSkills(query, skillsDir) {
|
|
|
15
17
|
}
|
|
16
18
|
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
17
19
|
const results = [];
|
|
18
|
-
for (const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
continue;
|
|
22
|
-
const domainLower = skill.domain.toLowerCase();
|
|
23
|
-
for (const ep of skill.endpoints) {
|
|
20
|
+
for (const [domain, entry] of Object.entries(index.domains)) {
|
|
21
|
+
const domainLower = domain.toLowerCase();
|
|
22
|
+
for (const ep of entry.endpoints) {
|
|
24
23
|
const endpointIdLower = ep.id.toLowerCase();
|
|
25
24
|
const pathLower = ep.path.toLowerCase();
|
|
26
25
|
const methodLower = ep.method.toLowerCase();
|
|
27
|
-
// Check if all query terms match against the combined searchable text
|
|
28
26
|
const searchText = `${domainLower} ${endpointIdLower} ${pathLower} ${methodLower}`;
|
|
29
27
|
const allMatch = terms.every(term => searchText.includes(term));
|
|
30
28
|
if (allMatch) {
|
|
31
29
|
results.push({
|
|
32
|
-
domain
|
|
30
|
+
domain,
|
|
33
31
|
endpointId: ep.id,
|
|
34
32
|
method: ep.method,
|
|
35
33
|
path: ep.path,
|
|
36
|
-
tier: ep.
|
|
37
|
-
verified: ep.
|
|
34
|
+
tier: ep.tier ?? 'unknown',
|
|
35
|
+
verified: ep.verified ?? false,
|
|
38
36
|
});
|
|
39
37
|
}
|
|
40
38
|
}
|
|
41
39
|
}
|
|
42
40
|
if (results.length === 0) {
|
|
43
|
-
const domains =
|
|
41
|
+
const domains = Object.keys(index.domains).join(', ');
|
|
44
42
|
return {
|
|
45
43
|
found: false,
|
|
46
44
|
suggestion: `No matches for "${query}". Available domains: ${domains}`,
|
package/dist/skill/search.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.js","sourceRoot":"","sources":["../../src/skill/search.ts"],"names":[],"mappings":"AAAA,sBAAsB;AACtB,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"search.js","sourceRoot":"","sources":["../../src/skill/search.ts"],"names":[],"mappings":"AAAA,sBAAsB;AACtB,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAiBzC;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,KAAa,EACb,SAAkB;IAElB,MAAM,KAAK,GAAG,MAAM,WAAW,CAAC,SAAS,CAAC,CAAC;IAE3C,MAAM,WAAW,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC;IACtD,IAAI,WAAW,KAAK,CAAC,EAAE,CAAC;QACtB,OAAO;YACL,KAAK,EAAE,KAAK;YACZ,UAAU,EAAE,gFAAgF;SAC7F,CAAC;IACJ,CAAC;IAED,MAAM,KAAK,GAAG,KAAK,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAC/D,MAAM,OAAO,GAAmB,EAAE,CAAC;IAEnC,KAAK,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;QAC5D,MAAM,WAAW,GAAG,MAAM,CAAC,WAAW,EAAE,CAAC;QAEzC,KAAK,MAAM,EAAE,IAAI,KAAK,CAAC,SAAS,EAAE,CAAC;YACjC,MAAM,eAAe,GAAG,EAAE,CAAC,EAAE,CAAC,WAAW,EAAE,CAAC;YAC5C,MAAM,SAAS,GAAG,EAAE,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC;YACxC,MAAM,WAAW,GAAG,EAAE,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC;YAE5C,MAAM,UAAU,GAAG,GAAG,WAAW,IAAI,eAAe,IAAI,SAAS,IAAI,WAAW,EAAE,CAAC;YACnF,MAAM,QAAQ,GAAG,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;YAEhE,IAAI,QAAQ,EAAE,CAAC;gBACb,OAAO,CAAC,IAAI,CAAC;oBACX,MAAM;oBACN,UAAU,EAAE,EAAE,CAAC,EAAE;oBACjB,MAAM,EAAE,EAAE,CAAC,MAAM;oBACjB,IAAI,EAAE,EAAE,CAAC,IAAI;oBACb,IAAI,EAAE,EAAE,CAAC,IAAI,IAAI,SAAS;oBAC1B,QAAQ,EAAE,EAAE,CAAC,QAAQ,IAAI,KAAK;iBAC/B,CAAC,CAAC;YACL,CAAC;QACH,CAAC;IACH,CAAC;IAED,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACzB,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACtD,OAAO;YACL,KAAK,EAAE,KAAK;YACZ,UAAU,EAAE,mBAAmB,KAAK,yBAAyB,OAAO,EAAE;SACvE,CAAC;IACJ,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;AAClC,CAAC"}
|
package/dist/skill/store.d.ts
CHANGED
|
@@ -6,4 +6,11 @@ export declare function readSkillFile(domain: string, skillsDir?: string, option
|
|
|
6
6
|
/** Allow loading unsigned files without throwing. Tampered signed files still reject. */
|
|
7
7
|
trustUnsigned?: boolean;
|
|
8
8
|
}): Promise<SkillFile | null>;
|
|
9
|
+
/**
|
|
10
|
+
* Fault-tolerant wrapper around readSkillFile — returns null instead of
|
|
11
|
+
* throwing on validation errors, bad signatures, etc. ENOENT (missing
|
|
12
|
+
* file) also returns null. Use this when iterating many files where one
|
|
13
|
+
* bad file should not abort the whole operation.
|
|
14
|
+
*/
|
|
15
|
+
export declare function safeReadSkillFile(domain: string, skillsDir?: string, options?: Parameters<typeof readSkillFile>[2]): Promise<SkillFile | null>;
|
|
9
16
|
export declare function listSkillFiles(skillsDir?: string): Promise<SkillSummary[]>;
|
package/dist/skill/store.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// src/skill/store.ts
|
|
2
|
-
import { readFile, writeFile, mkdir,
|
|
2
|
+
import { readFile, writeFile, mkdir, access, rename } from 'node:fs/promises';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { validateSkillFile } from './validate.js';
|
|
6
|
+
import { updateIndex, ensureIndex } from './index.js';
|
|
6
7
|
const DEFAULT_SKILLS_DIR = join(homedir(), '.apitap', 'skills');
|
|
7
8
|
const BASE_GITIGNORE = `# ApiTap — prevent accidental credential commits
|
|
8
9
|
auth.enc
|
|
@@ -28,6 +29,8 @@ async function ensureGitignore(skillsDir) {
|
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
31
|
export async function writeSkillFile(skill, skillsDir = DEFAULT_SKILLS_DIR) {
|
|
32
|
+
// Validate before writing — catch bad data at the source, not on read
|
|
33
|
+
validateSkillFile(skill);
|
|
31
34
|
await mkdir(skillsDir, { recursive: true, mode: 0o700 });
|
|
32
35
|
await ensureGitignore(skillsDir);
|
|
33
36
|
const filePath = skillPath(skill.domain, skillsDir);
|
|
@@ -35,6 +38,19 @@ export async function writeSkillFile(skill, skillsDir = DEFAULT_SKILLS_DIR) {
|
|
|
35
38
|
const content = JSON.stringify(skill, null, 2) + '\n';
|
|
36
39
|
await writeFile(tmpPath, content, { mode: 0o600 });
|
|
37
40
|
await rename(tmpPath, filePath);
|
|
41
|
+
// Incrementally update the search index
|
|
42
|
+
try {
|
|
43
|
+
await updateIndex(skill.domain, skill.endpoints.map(ep => ({
|
|
44
|
+
id: ep.id,
|
|
45
|
+
method: ep.method,
|
|
46
|
+
path: ep.path,
|
|
47
|
+
...(ep.replayability?.tier ? { tier: ep.replayability.tier } : {}),
|
|
48
|
+
...(ep.replayability?.verified ? { verified: true } : {}),
|
|
49
|
+
})), skill.provenance ?? 'unsigned', skillsDir, skill.capturedAt);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Index update failure should not block writes
|
|
53
|
+
}
|
|
38
54
|
return filePath;
|
|
39
55
|
}
|
|
40
56
|
export async function readSkillFile(domain, skillsDir = DEFAULT_SKILLS_DIR, options) {
|
|
@@ -90,34 +106,31 @@ export async function readSkillFile(domain, skillsDir = DEFAULT_SKILLS_DIR, opti
|
|
|
90
106
|
throw e;
|
|
91
107
|
}
|
|
92
108
|
}
|
|
93
|
-
|
|
94
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Fault-tolerant wrapper around readSkillFile — returns null instead of
|
|
111
|
+
* throwing on validation errors, bad signatures, etc. ENOENT (missing
|
|
112
|
+
* file) also returns null. Use this when iterating many files where one
|
|
113
|
+
* bad file should not abort the whole operation.
|
|
114
|
+
*/
|
|
115
|
+
export async function safeReadSkillFile(domain, skillsDir = DEFAULT_SKILLS_DIR, options) {
|
|
95
116
|
try {
|
|
96
|
-
|
|
117
|
+
return await readSkillFile(domain, skillsDir, options);
|
|
97
118
|
}
|
|
98
|
-
catch
|
|
99
|
-
|
|
100
|
-
return [];
|
|
101
|
-
throw err;
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
102
121
|
}
|
|
122
|
+
}
|
|
123
|
+
export async function listSkillFiles(skillsDir = DEFAULT_SKILLS_DIR) {
|
|
124
|
+
const index = await ensureIndex(skillsDir);
|
|
103
125
|
const summaries = [];
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (skill) {
|
|
113
|
-
summaries.push({
|
|
114
|
-
domain: skill.domain,
|
|
115
|
-
skillFile: join(skillsDir, file),
|
|
116
|
-
endpointCount: skill.endpoints.length,
|
|
117
|
-
capturedAt: skill.capturedAt,
|
|
118
|
-
provenance: skill.provenance ?? 'unsigned',
|
|
119
|
-
});
|
|
120
|
-
}
|
|
126
|
+
for (const [domain, entry] of Object.entries(index.domains)) {
|
|
127
|
+
summaries.push({
|
|
128
|
+
domain,
|
|
129
|
+
skillFile: join(skillsDir, `${domain}.json`),
|
|
130
|
+
endpointCount: entry.endpointCount,
|
|
131
|
+
capturedAt: entry.capturedAt || index.builtAt,
|
|
132
|
+
provenance: entry.provenance,
|
|
133
|
+
});
|
|
121
134
|
}
|
|
122
135
|
return summaries;
|
|
123
136
|
}
|
package/dist/skill/store.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/skill/store.ts"],"names":[],"mappings":"AAAA,qBAAqB;AACrB,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,KAAK,
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/skill/store.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apitap/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Intercept web API traffic during browsing. Generate portable skill files so AI agents can call APIs directly instead of scraping.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
package/src/cli.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { attach, parseDomainPatterns } from './capture/cdp-attach.js';
|
|
|
30
30
|
import { isOpenAPISpec, convertOpenAPISpec } from './skill/openapi-converter.js';
|
|
31
31
|
import { mergeSkillFile } from './skill/merge.js';
|
|
32
32
|
import { fetchApisGuruList, filterEntries, fetchSpec } from './skill/apis-guru.js';
|
|
33
|
+
import { buildIndex, removeFromIndex } from './skill/index.js';
|
|
33
34
|
|
|
34
35
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
35
36
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
@@ -92,6 +93,7 @@ function printUsage(): void {
|
|
|
92
93
|
apitap audit Audit stored skill files and credentials
|
|
93
94
|
apitap forget <domain> Remove skill file and credentials for a domain
|
|
94
95
|
apitap stats Show token savings report
|
|
96
|
+
apitap index build Rebuild search index (run after manual edits)
|
|
95
97
|
apitap extension install Register native messaging host for Chrome
|
|
96
98
|
|
|
97
99
|
Discover options:
|
|
@@ -367,7 +369,8 @@ async function handleShow(positional: string[], flags: Record<string, string | b
|
|
|
367
369
|
process.exit(1);
|
|
368
370
|
}
|
|
369
371
|
|
|
370
|
-
|
|
372
|
+
// Intentionally skip HMAC for browse-only — verification happens at replay time
|
|
373
|
+
const skill = await readSkillFile(domain, SKILLS_DIR, { verifySignature: false });
|
|
371
374
|
if (!skill) {
|
|
372
375
|
console.error(`Error: No skill file found for "${domain}". Run \`apitap capture\` first.`);
|
|
373
376
|
process.exit(1);
|
|
@@ -1599,6 +1602,7 @@ async function handleForget(positional: string[]): Promise<void> {
|
|
|
1599
1602
|
notFound = false;
|
|
1600
1603
|
await unlink(skillFilePath);
|
|
1601
1604
|
skillRemoved = true;
|
|
1605
|
+
try { await removeFromIndex(domain, skillsDir); } catch {}
|
|
1602
1606
|
} catch {}
|
|
1603
1607
|
|
|
1604
1608
|
// Remove stored auth
|
|
@@ -1628,6 +1632,22 @@ async function handleForget(positional: string[]): Promise<void> {
|
|
|
1628
1632
|
console.log(`Forgot ${domain} — ${parts.join(' and ')} removed`);
|
|
1629
1633
|
}
|
|
1630
1634
|
|
|
1635
|
+
async function handleIndex(positional: string[]): Promise<void> {
|
|
1636
|
+
const subcommand = positional[0];
|
|
1637
|
+
if (subcommand !== 'build') {
|
|
1638
|
+
console.error('Usage: apitap index build');
|
|
1639
|
+
console.error(' Force rebuild the search index from all skill files on disk.');
|
|
1640
|
+
console.error(' Run this after manually editing skill files outside of apitap commands.');
|
|
1641
|
+
process.exit(1);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
const skillsDir = SKILLS_DIR || join(APITAP_DIR, 'skills');
|
|
1645
|
+
console.log('\n Rebuilding search index...');
|
|
1646
|
+
const index = await buildIndex(skillsDir);
|
|
1647
|
+
const endpointCount = Object.values(index.domains).reduce((sum, d) => sum + d.endpointCount, 0);
|
|
1648
|
+
console.log(` Done: ${Object.keys(index.domains).length} domains, ${endpointCount.toLocaleString()} endpoints indexed\n`);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1631
1651
|
async function handleExtension(positional: string[], flags: Record<string, string | boolean>): Promise<void> {
|
|
1632
1652
|
const subcommand = positional[0];
|
|
1633
1653
|
|
|
@@ -1754,6 +1774,9 @@ async function main(): Promise<void> {
|
|
|
1754
1774
|
case 'attach':
|
|
1755
1775
|
await handleAttach(positional, flags);
|
|
1756
1776
|
break;
|
|
1777
|
+
case 'index':
|
|
1778
|
+
await handleIndex(positional);
|
|
1779
|
+
break;
|
|
1757
1780
|
default:
|
|
1758
1781
|
printUsage();
|
|
1759
1782
|
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// src/skill/index.ts
|
|
2
|
+
import { readFile, writeFile, readdir, rename } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
// --- Types ---
|
|
7
|
+
|
|
8
|
+
export interface IndexEndpoint {
|
|
9
|
+
id: string;
|
|
10
|
+
method: string;
|
|
11
|
+
path: string;
|
|
12
|
+
tier?: string;
|
|
13
|
+
verified?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface IndexDomain {
|
|
17
|
+
endpointCount: number;
|
|
18
|
+
provenance: 'self' | 'imported-signed' | 'imported' | 'unsigned';
|
|
19
|
+
capturedAt: string;
|
|
20
|
+
endpoints: IndexEndpoint[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface IndexFile {
|
|
24
|
+
version: number;
|
|
25
|
+
fileCount: number;
|
|
26
|
+
builtAt: string;
|
|
27
|
+
domains: Record<string, IndexDomain>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// --- Paths ---
|
|
31
|
+
|
|
32
|
+
const DEFAULT_SKILLS_DIR = join(homedir(), '.apitap', 'skills');
|
|
33
|
+
const INDEX_VERSION = 1;
|
|
34
|
+
|
|
35
|
+
function indexPath(skillsDir: string): string {
|
|
36
|
+
return join(skillsDir, '..', 'index.json');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- Read ---
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read the index file. Returns null if missing or unparseable.
|
|
43
|
+
*/
|
|
44
|
+
export async function readIndex(skillsDir: string = DEFAULT_SKILLS_DIR): Promise<IndexFile | null> {
|
|
45
|
+
try {
|
|
46
|
+
const content = await readFile(indexPath(skillsDir), 'utf-8');
|
|
47
|
+
const parsed = JSON.parse(content);
|
|
48
|
+
if (parsed.version !== INDEX_VERSION) return null;
|
|
49
|
+
return parsed as IndexFile;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- Stale detection ---
|
|
56
|
+
|
|
57
|
+
const STALE_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
58
|
+
|
|
59
|
+
async function countSkillFiles(skillsDir: string): Promise<number> {
|
|
60
|
+
try {
|
|
61
|
+
const files = await readdir(skillsDir);
|
|
62
|
+
return files.filter(f => f.endsWith('.json')).length;
|
|
63
|
+
} catch {
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface StaleCheck {
|
|
69
|
+
stale: boolean;
|
|
70
|
+
reason?: 'missing' | 'filecount-mismatch' | 'version-mismatch';
|
|
71
|
+
ageWarning?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if the index is stale. Returns { stale, reason, ageWarning }.
|
|
76
|
+
* ageWarning is true if the index is >24h old but fileCount matches (soft signal).
|
|
77
|
+
*/
|
|
78
|
+
export async function checkStale(
|
|
79
|
+
index: IndexFile | null,
|
|
80
|
+
skillsDir: string = DEFAULT_SKILLS_DIR,
|
|
81
|
+
): Promise<StaleCheck> {
|
|
82
|
+
if (!index) return { stale: true, reason: 'missing' };
|
|
83
|
+
if (index.version !== INDEX_VERSION) return { stale: true, reason: 'version-mismatch' };
|
|
84
|
+
|
|
85
|
+
const diskCount = await countSkillFiles(skillsDir);
|
|
86
|
+
if (diskCount !== index.fileCount) return { stale: true, reason: 'filecount-mismatch' };
|
|
87
|
+
|
|
88
|
+
const age = Date.now() - new Date(index.builtAt).getTime();
|
|
89
|
+
if (age > STALE_AGE_MS) return { stale: false, ageWarning: true };
|
|
90
|
+
|
|
91
|
+
return { stale: false };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- Build (full rebuild) ---
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Full rebuild of the index from all skill files on disk.
|
|
98
|
+
* No validation or HMAC checks — index is a read-only metadata cache.
|
|
99
|
+
*/
|
|
100
|
+
export async function buildIndex(skillsDir: string = DEFAULT_SKILLS_DIR): Promise<IndexFile> {
|
|
101
|
+
let files: string[];
|
|
102
|
+
try {
|
|
103
|
+
files = await readdir(skillsDir);
|
|
104
|
+
} catch {
|
|
105
|
+
files = [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
|
109
|
+
const domains: Record<string, IndexDomain> = {};
|
|
110
|
+
|
|
111
|
+
for (const file of jsonFiles) {
|
|
112
|
+
try {
|
|
113
|
+
const content = await readFile(join(skillsDir, file), 'utf-8');
|
|
114
|
+
const skill = JSON.parse(content);
|
|
115
|
+
const domain = file.replace(/\.json$/, '');
|
|
116
|
+
|
|
117
|
+
if (!skill.endpoints || !Array.isArray(skill.endpoints)) continue;
|
|
118
|
+
|
|
119
|
+
domains[domain] = {
|
|
120
|
+
endpointCount: skill.endpoints.length,
|
|
121
|
+
provenance: skill.provenance ?? 'unsigned',
|
|
122
|
+
capturedAt: skill.capturedAt ?? '',
|
|
123
|
+
endpoints: skill.endpoints.map((ep: any) => ({
|
|
124
|
+
id: ep.id ?? '',
|
|
125
|
+
method: ep.method ?? 'GET',
|
|
126
|
+
path: ep.path ?? '/',
|
|
127
|
+
...(ep.replayability?.tier ? { tier: ep.replayability.tier } : {}),
|
|
128
|
+
...(ep.replayability?.verified ? { verified: true } : {}),
|
|
129
|
+
})),
|
|
130
|
+
};
|
|
131
|
+
} catch {
|
|
132
|
+
// Skip unparseable files
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const index: IndexFile = {
|
|
137
|
+
version: INDEX_VERSION,
|
|
138
|
+
fileCount: jsonFiles.length,
|
|
139
|
+
builtAt: new Date().toISOString(),
|
|
140
|
+
domains,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
await writeIndexAtomic(index, skillsDir);
|
|
144
|
+
return index;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- Incremental update ---
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Update a single domain entry in the index after writeSkillFile().
|
|
151
|
+
* Increments fileCount only for genuinely new domains.
|
|
152
|
+
*/
|
|
153
|
+
export async function updateIndex(
|
|
154
|
+
domain: string,
|
|
155
|
+
endpoints: IndexEndpoint[],
|
|
156
|
+
provenance: string,
|
|
157
|
+
skillsDir: string = DEFAULT_SKILLS_DIR,
|
|
158
|
+
capturedAt: string = '',
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
const existing = await readIndex(skillsDir);
|
|
161
|
+
const index: IndexFile = existing ?? {
|
|
162
|
+
version: INDEX_VERSION,
|
|
163
|
+
fileCount: 0,
|
|
164
|
+
builtAt: '',
|
|
165
|
+
domains: {},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const isNew = !(domain in index.domains);
|
|
169
|
+
|
|
170
|
+
index.domains[domain] = {
|
|
171
|
+
endpointCount: endpoints.length,
|
|
172
|
+
provenance: (provenance ?? 'unsigned') as IndexDomain['provenance'],
|
|
173
|
+
capturedAt,
|
|
174
|
+
endpoints,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (isNew) {
|
|
178
|
+
index.fileCount += 1;
|
|
179
|
+
}
|
|
180
|
+
index.builtAt = new Date().toISOString();
|
|
181
|
+
|
|
182
|
+
await writeIndexAtomic(index, skillsDir);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- Remove from index ---
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Remove a domain from the index after forgetSkillFile().
|
|
189
|
+
*/
|
|
190
|
+
export async function removeFromIndex(
|
|
191
|
+
domain: string,
|
|
192
|
+
skillsDir: string = DEFAULT_SKILLS_DIR,
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
const existing = await readIndex(skillsDir);
|
|
195
|
+
if (!existing) return;
|
|
196
|
+
|
|
197
|
+
if (domain in existing.domains) {
|
|
198
|
+
delete existing.domains[domain];
|
|
199
|
+
existing.fileCount -= 1;
|
|
200
|
+
existing.builtAt = new Date().toISOString();
|
|
201
|
+
await writeIndexAtomic(existing, skillsDir);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// --- Ensure index (read with stale check + auto-rebuild) ---
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Read the index, rebuilding if stale or missing.
|
|
209
|
+
* Logs warnings to stderr for observability.
|
|
210
|
+
*/
|
|
211
|
+
export async function ensureIndex(skillsDir: string = DEFAULT_SKILLS_DIR): Promise<IndexFile> {
|
|
212
|
+
let index = await readIndex(skillsDir);
|
|
213
|
+
const staleCheck = await checkStale(index, skillsDir);
|
|
214
|
+
|
|
215
|
+
if (staleCheck.stale) {
|
|
216
|
+
if (staleCheck.reason === 'missing') {
|
|
217
|
+
process.stderr.write('Search index not found — rebuilding (this may take a moment)...\n');
|
|
218
|
+
} else {
|
|
219
|
+
process.stderr.write('Search index is stale — rebuilding...\n');
|
|
220
|
+
}
|
|
221
|
+
index = await buildIndex(skillsDir);
|
|
222
|
+
} else if (staleCheck.ageWarning) {
|
|
223
|
+
process.stderr.write(
|
|
224
|
+
"Search index is over 24h old — run 'apitap index build' if you've edited skill files manually\n",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return index!;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- Internal ---
|
|
232
|
+
|
|
233
|
+
async function writeIndexAtomic(index: IndexFile, skillsDir: string): Promise<void> {
|
|
234
|
+
const path = indexPath(skillsDir);
|
|
235
|
+
const tmpPath = `${path}.${process.pid}.tmp`;
|
|
236
|
+
await writeFile(tmpPath, JSON.stringify(index));
|
|
237
|
+
await rename(tmpPath, path);
|
|
238
|
+
}
|
package/src/skill/merge.ts
CHANGED
|
@@ -140,10 +140,14 @@ export function mergeSkillFile(
|
|
|
140
140
|
|
|
141
141
|
// --- Case: no existing file — create a new SkillFile from imported endpoints ---
|
|
142
142
|
if (existing === null) {
|
|
143
|
-
|
|
143
|
+
let endpoints = imported.map(ep => ({
|
|
144
144
|
...ep,
|
|
145
145
|
normalizedPath: normalizePath(ep.path),
|
|
146
146
|
}));
|
|
147
|
+
if (endpoints.length > 500) {
|
|
148
|
+
endpoints.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0));
|
|
149
|
+
endpoints = endpoints.slice(0, 500);
|
|
150
|
+
}
|
|
147
151
|
|
|
148
152
|
const skillFile: SkillFile = {
|
|
149
153
|
version: '1.2',
|
|
@@ -306,6 +310,24 @@ export function mergeSkillFile(
|
|
|
306
310
|
}
|
|
307
311
|
}
|
|
308
312
|
|
|
313
|
+
// Cap at MAX_ENDPOINTS after merge — keep captured/high-confidence first
|
|
314
|
+
const MAX_ENDPOINTS = 500;
|
|
315
|
+
if (resultEndpoints.length > MAX_ENDPOINTS) {
|
|
316
|
+
const overflow = resultEndpoints.length - MAX_ENDPOINTS;
|
|
317
|
+
resultEndpoints.sort((a, b) => {
|
|
318
|
+
// Captured endpoints always win over imported
|
|
319
|
+
const aIsCaptured = !a.endpointProvenance || a.endpointProvenance === 'captured';
|
|
320
|
+
const bIsCaptured = !b.endpointProvenance || b.endpointProvenance === 'captured';
|
|
321
|
+
if (aIsCaptured !== bIsCaptured) return aIsCaptured ? -1 : 1;
|
|
322
|
+
// Then by confidence descending
|
|
323
|
+
return (b.confidence ?? 0) - (a.confidence ?? 0);
|
|
324
|
+
});
|
|
325
|
+
resultEndpoints.length = MAX_ENDPOINTS;
|
|
326
|
+
process.stderr.write(
|
|
327
|
+
`[openapi-import] Warning: merged result has ${MAX_ENDPOINTS + overflow} endpoints, truncated to ${MAX_ENDPOINTS}\n`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
309
331
|
// Build updated import history
|
|
310
332
|
const prevHistory = existing.metadata.importHistory ?? [];
|
|
311
333
|
const newHistoryEntry = {
|
package/src/skill/search.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/skill/search.ts
|
|
2
|
-
import {
|
|
2
|
+
import { ensureIndex } from './index.js';
|
|
3
3
|
|
|
4
4
|
export interface SearchResult {
|
|
5
5
|
domain: string;
|
|
@@ -18,6 +18,7 @@ export interface SearchResponse {
|
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Search skill files for endpoints matching a query.
|
|
21
|
+
* Uses the search index for sub-second results.
|
|
21
22
|
* Matches against domain names, endpoint IDs, and endpoint paths.
|
|
22
23
|
* Query terms are matched case-insensitively.
|
|
23
24
|
*/
|
|
@@ -25,8 +26,10 @@ export async function searchSkills(
|
|
|
25
26
|
query: string,
|
|
26
27
|
skillsDir?: string,
|
|
27
28
|
): Promise<SearchResponse> {
|
|
28
|
-
const
|
|
29
|
-
|
|
29
|
+
const index = await ensureIndex(skillsDir);
|
|
30
|
+
|
|
31
|
+
const domainCount = Object.keys(index.domains).length;
|
|
32
|
+
if (domainCount === 0) {
|
|
30
33
|
return {
|
|
31
34
|
found: false,
|
|
32
35
|
suggestion: 'No skill files found. Run `apitap capture <url>` to capture API traffic first.',
|
|
@@ -36,36 +39,32 @@ export async function searchSkills(
|
|
|
36
39
|
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
37
40
|
const results: SearchResult[] = [];
|
|
38
41
|
|
|
39
|
-
for (const
|
|
40
|
-
const
|
|
41
|
-
if (!skill) continue;
|
|
42
|
-
|
|
43
|
-
const domainLower = skill.domain.toLowerCase();
|
|
42
|
+
for (const [domain, entry] of Object.entries(index.domains)) {
|
|
43
|
+
const domainLower = domain.toLowerCase();
|
|
44
44
|
|
|
45
|
-
for (const ep of
|
|
45
|
+
for (const ep of entry.endpoints) {
|
|
46
46
|
const endpointIdLower = ep.id.toLowerCase();
|
|
47
47
|
const pathLower = ep.path.toLowerCase();
|
|
48
48
|
const methodLower = ep.method.toLowerCase();
|
|
49
49
|
|
|
50
|
-
// Check if all query terms match against the combined searchable text
|
|
51
50
|
const searchText = `${domainLower} ${endpointIdLower} ${pathLower} ${methodLower}`;
|
|
52
51
|
const allMatch = terms.every(term => searchText.includes(term));
|
|
53
52
|
|
|
54
53
|
if (allMatch) {
|
|
55
54
|
results.push({
|
|
56
|
-
domain
|
|
55
|
+
domain,
|
|
57
56
|
endpointId: ep.id,
|
|
58
57
|
method: ep.method,
|
|
59
58
|
path: ep.path,
|
|
60
|
-
tier: ep.
|
|
61
|
-
verified: ep.
|
|
59
|
+
tier: ep.tier ?? 'unknown',
|
|
60
|
+
verified: ep.verified ?? false,
|
|
62
61
|
});
|
|
63
62
|
}
|
|
64
63
|
}
|
|
65
64
|
}
|
|
66
65
|
|
|
67
66
|
if (results.length === 0) {
|
|
68
|
-
const domains =
|
|
67
|
+
const domains = Object.keys(index.domains).join(', ');
|
|
69
68
|
return {
|
|
70
69
|
found: false,
|
|
71
70
|
suggestion: `No matches for "${query}". Available domains: ${domains}`,
|