@dreki-gg/pi-doc-search 0.2.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/.github/workflows/release.yml +46 -0
- package/.release-please-manifest.json +3 -0
- package/CHANGELOG.md +82 -0
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/bun.lock +382 -0
- package/extensions/doc-search/README.md +50 -0
- package/extensions/doc-search/api.ts +143 -0
- package/extensions/doc-search/cache.ts +415 -0
- package/extensions/doc-search/config.example.json +7 -0
- package/extensions/doc-search/config.ts +52 -0
- package/extensions/doc-search/curation.ts +192 -0
- package/extensions/doc-search/index.ts +6 -0
- package/extensions/doc-search/tools.ts +615 -0
- package/extensions/doc-search/types.ts +142 -0
- package/package.json +50 -0
- package/release-please-config.json +9 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Context7ApiError,
|
|
3
|
+
Context7ApiResult,
|
|
4
|
+
DocSearchSettings,
|
|
5
|
+
SearchResponse,
|
|
6
|
+
} from './types';
|
|
7
|
+
|
|
8
|
+
const CONTEXT7_API_BASE_URL = process.env.CONTEXT7_API_URL || 'https://context7.com/api';
|
|
9
|
+
|
|
10
|
+
function buildHeaders(settings: DocSearchSettings): HeadersInit {
|
|
11
|
+
const headers: Record<string, string> = {
|
|
12
|
+
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
|
|
13
|
+
'X-Context7-Source': 'pi-extension',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
if (settings.apiKey) headers.Authorization = `Bearer ${settings.apiKey}`;
|
|
17
|
+
return headers;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function parseErrorResponse(
|
|
21
|
+
response: Response,
|
|
22
|
+
settings: DocSearchSettings,
|
|
23
|
+
): Promise<Context7ApiError> {
|
|
24
|
+
let upstreamMessage: string | undefined;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const payload = (await response.json()) as { message?: string };
|
|
28
|
+
if (typeof payload?.message === 'string' && payload.message.trim()) {
|
|
29
|
+
upstreamMessage = payload.message.trim();
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// Ignore non-JSON error bodies.
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (response.status === 429) {
|
|
36
|
+
return {
|
|
37
|
+
kind: 'rate_limit',
|
|
38
|
+
status: response.status,
|
|
39
|
+
message: settings.apiKey
|
|
40
|
+
? 'Context7 rate limit exceeded. Retry later or use a higher-limit Context7 plan.'
|
|
41
|
+
: 'Context7 rate limit exceeded. Retry later or configure CONTEXT7_API_KEY for higher limits.',
|
|
42
|
+
upstreamMessage,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (response.status === 401) {
|
|
47
|
+
return {
|
|
48
|
+
kind: 'auth',
|
|
49
|
+
status: response.status,
|
|
50
|
+
message: 'Context7 API key appears invalid.',
|
|
51
|
+
upstreamMessage,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (response.status === 404) {
|
|
56
|
+
return {
|
|
57
|
+
kind: 'not_found',
|
|
58
|
+
status: response.status,
|
|
59
|
+
message: 'No Context7 documentation was found for that library identifier.',
|
|
60
|
+
upstreamMessage,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
kind: 'unknown',
|
|
66
|
+
status: response.status,
|
|
67
|
+
message: `Context7 request failed with status ${response.status}.`,
|
|
68
|
+
upstreamMessage,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function searchLibraries(
|
|
73
|
+
settings: DocSearchSettings,
|
|
74
|
+
params: { libraryName: string; query: string },
|
|
75
|
+
): Promise<Context7ApiResult<SearchResponse>> {
|
|
76
|
+
try {
|
|
77
|
+
const url = new URL(`${CONTEXT7_API_BASE_URL}/v2/libs/search`);
|
|
78
|
+
url.searchParams.set('query', params.query);
|
|
79
|
+
url.searchParams.set('libraryName', params.libraryName);
|
|
80
|
+
|
|
81
|
+
const response = await fetch(url, { headers: buildHeaders(settings) });
|
|
82
|
+
if (!response.ok) return { ok: false, error: await parseErrorResponse(response, settings) };
|
|
83
|
+
|
|
84
|
+
const payload = (await response.json()) as SearchResponse;
|
|
85
|
+
if (!payload || !Array.isArray(payload.results)) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
error: {
|
|
89
|
+
kind: 'invalid_response',
|
|
90
|
+
message: 'Context7 returned an invalid search response.',
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { ok: true, data: payload };
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
error: {
|
|
100
|
+
kind: 'network',
|
|
101
|
+
message: 'Unable to reach Context7 right now.',
|
|
102
|
+
upstreamMessage: error instanceof Error ? error.message : String(error),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function fetchLibraryDocs(
|
|
109
|
+
settings: DocSearchSettings,
|
|
110
|
+
params: { libraryId: string; query: string },
|
|
111
|
+
): Promise<Context7ApiResult<string>> {
|
|
112
|
+
try {
|
|
113
|
+
const url = new URL(`${CONTEXT7_API_BASE_URL}/v2/context`);
|
|
114
|
+
url.searchParams.set('libraryId', params.libraryId);
|
|
115
|
+
url.searchParams.set('query', params.query);
|
|
116
|
+
|
|
117
|
+
const response = await fetch(url, { headers: buildHeaders(settings) });
|
|
118
|
+
if (!response.ok) return { ok: false, error: await parseErrorResponse(response, settings) };
|
|
119
|
+
|
|
120
|
+
const text = await response.text();
|
|
121
|
+
if (!text.trim()) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
error: {
|
|
125
|
+
kind: 'not_found',
|
|
126
|
+
message:
|
|
127
|
+
'Context7 returned an empty documentation response. Try resolving the library again or refining the query.',
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { ok: true, data: text };
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
error: {
|
|
137
|
+
kind: 'network',
|
|
138
|
+
message: 'Unable to reach Context7 right now.',
|
|
139
|
+
upstreamMessage: error instanceof Error ? error.message : String(error),
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { withFileMutationQueue } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import type {
|
|
7
|
+
CacheLookup,
|
|
8
|
+
CacheSearchSelector,
|
|
9
|
+
DocSearchSettings,
|
|
10
|
+
DocCacheEntry,
|
|
11
|
+
DocCacheIndexEntry,
|
|
12
|
+
ResolveCacheEntry,
|
|
13
|
+
ResolveCacheIndexEntry,
|
|
14
|
+
} from './types';
|
|
15
|
+
|
|
16
|
+
type StringListMap = Record<string, string[]>;
|
|
17
|
+
type DocRefIndexMap = Record<string, DocCacheIndexEntry>;
|
|
18
|
+
|
|
19
|
+
type ResolvePaths = ReturnType<typeof getResolvePaths>;
|
|
20
|
+
type DocsPaths = ReturnType<typeof getDocsPaths>;
|
|
21
|
+
|
|
22
|
+
function hash(input: string): string {
|
|
23
|
+
return createHash('sha256').update(input).digest('hex').slice(0, 24);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeText(value?: string): string {
|
|
27
|
+
return (value ?? '').trim().toLowerCase();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function toIso(ms: number): string {
|
|
31
|
+
return new Date(ms).toISOString();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isFresh(expiresAt: string): boolean {
|
|
35
|
+
return new Date(expiresAt).getTime() > Date.now();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readJsonFile<T>(path: string, fallback: T): Promise<T> {
|
|
39
|
+
if (!existsSync(path)) return fallback;
|
|
40
|
+
try {
|
|
41
|
+
const raw = await readFile(path, 'utf8');
|
|
42
|
+
return (JSON.parse(raw) as T) ?? fallback;
|
|
43
|
+
} catch {
|
|
44
|
+
return fallback;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function atomicWriteJson(path: string, value: unknown): Promise<void> {
|
|
49
|
+
const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
50
|
+
await writeFile(tempPath, JSON.stringify(value, null, 2));
|
|
51
|
+
await rename(tempPath, path);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function uniqueStrings(values: string[]): string[] {
|
|
55
|
+
return Array.from(new Set(values));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function pushIndexValue(map: StringListMap, key: string | undefined, value: string) {
|
|
59
|
+
if (!key) return;
|
|
60
|
+
map[key] = uniqueStrings([...(map[key] ?? []), value]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getResolvePaths(settings: DocSearchSettings, objectKey: string) {
|
|
64
|
+
const base = join(settings.cacheDir, 'resolve');
|
|
65
|
+
const indexDir = join(base, 'index');
|
|
66
|
+
return {
|
|
67
|
+
base,
|
|
68
|
+
objectsDir: join(base, 'objects'),
|
|
69
|
+
indexDir,
|
|
70
|
+
allIndexPath: join(indexDir, 'all.json'),
|
|
71
|
+
byLibraryPath: join(indexDir, 'by-library.json'),
|
|
72
|
+
objectPath: join(base, 'objects', `${objectKey}.json`),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getDocsPaths(settings: DocSearchSettings, objectKey: string) {
|
|
77
|
+
const base = join(settings.cacheDir, 'docs');
|
|
78
|
+
const indexDir = join(base, 'index');
|
|
79
|
+
return {
|
|
80
|
+
base,
|
|
81
|
+
objectsDir: join(base, 'objects'),
|
|
82
|
+
indexDir,
|
|
83
|
+
allIndexPath: join(indexDir, 'all.json'),
|
|
84
|
+
byRefPath: join(indexDir, 'by-ref.json'),
|
|
85
|
+
byLibraryIdPath: join(indexDir, 'by-library-id.json'),
|
|
86
|
+
byLibraryNamePath: join(indexDir, 'by-library-name.json'),
|
|
87
|
+
byVersionPath: join(indexDir, 'by-version.json'),
|
|
88
|
+
objectPath: join(base, 'objects', `${objectKey}.json`),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function ensureResolveCacheDirs(settings: DocSearchSettings, objectKey: string) {
|
|
93
|
+
const paths = getResolvePaths(settings, objectKey);
|
|
94
|
+
await mkdir(paths.objectsDir, { recursive: true });
|
|
95
|
+
await mkdir(paths.indexDir, { recursive: true });
|
|
96
|
+
return paths;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function ensureDocsCacheDirs(settings: DocSearchSettings, objectKey: string) {
|
|
100
|
+
const paths = getDocsPaths(settings, objectKey);
|
|
101
|
+
await mkdir(paths.objectsDir, { recursive: true });
|
|
102
|
+
await mkdir(paths.indexDir, { recursive: true });
|
|
103
|
+
return paths;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveCacheKey(libraryName: string, query: string): string {
|
|
107
|
+
return `${normalizeText(libraryName)}::${normalizeText(query)}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function pruneObjectDirectory(objectsDir: string, validObjectKeys: Set<string>) {
|
|
111
|
+
if (!existsSync(objectsDir)) return;
|
|
112
|
+
const files = await readdir(objectsDir);
|
|
113
|
+
await Promise.all(
|
|
114
|
+
files
|
|
115
|
+
.filter((file) => file.endsWith('.json'))
|
|
116
|
+
.filter((file) => !validObjectKeys.has(file.replace(/\.json$/, '')))
|
|
117
|
+
.map((file) => rm(join(objectsDir, file), { force: true })),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function writeResolveIndexes(paths: ResolvePaths, all: ResolveCacheIndexEntry[]) {
|
|
122
|
+
const byLibrary: StringListMap = {};
|
|
123
|
+
for (const entry of all) {
|
|
124
|
+
pushIndexValue(byLibrary, entry.normalizedLibraryName, entry.objectKey);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await atomicWriteJson(paths.allIndexPath, all);
|
|
128
|
+
await atomicWriteJson(paths.byLibraryPath, byLibrary);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function writeDocsIndexes(paths: DocsPaths, all: DocCacheIndexEntry[]) {
|
|
132
|
+
const byRef: DocRefIndexMap = {};
|
|
133
|
+
const byLibraryId: StringListMap = {};
|
|
134
|
+
const byLibraryName: StringListMap = {};
|
|
135
|
+
const byVersion: StringListMap = {};
|
|
136
|
+
|
|
137
|
+
for (const entry of all) {
|
|
138
|
+
byRef[entry.docRef] = entry;
|
|
139
|
+
pushIndexValue(byLibraryId, entry.libraryId, entry.docRef);
|
|
140
|
+
pushIndexValue(byLibraryName, entry.normalizedLibraryName, entry.docRef);
|
|
141
|
+
pushIndexValue(byVersion, entry.libraryVersion, entry.docRef);
|
|
142
|
+
pushIndexValue(byVersion, entry.libraryVersionRaw, entry.docRef);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await atomicWriteJson(paths.allIndexPath, all);
|
|
146
|
+
await atomicWriteJson(paths.byRefPath, byRef);
|
|
147
|
+
await atomicWriteJson(paths.byLibraryIdPath, byLibraryId);
|
|
148
|
+
await atomicWriteJson(paths.byLibraryNamePath, byLibraryName);
|
|
149
|
+
await atomicWriteJson(paths.byVersionPath, byVersion);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function pruneResolveIndexes(paths: ResolvePaths, all: ResolveCacheIndexEntry[]) {
|
|
153
|
+
const seen = new Set<string>();
|
|
154
|
+
const nextAll = all
|
|
155
|
+
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
|
156
|
+
.filter((entry) => {
|
|
157
|
+
if (seen.has(entry.objectKey)) return false;
|
|
158
|
+
seen.add(entry.objectKey);
|
|
159
|
+
if (!isFresh(entry.expiresAt)) return false;
|
|
160
|
+
return existsSync(join(paths.objectsDir, `${entry.objectKey}.json`));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await pruneObjectDirectory(paths.objectsDir, new Set(nextAll.map((entry) => entry.objectKey)));
|
|
164
|
+
await writeResolveIndexes(paths, nextAll);
|
|
165
|
+
return nextAll;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function pruneDocsIndexes(paths: DocsPaths, all: DocCacheIndexEntry[]) {
|
|
169
|
+
const seen = new Set<string>();
|
|
170
|
+
const nextAll = all
|
|
171
|
+
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
|
172
|
+
.filter((entry) => {
|
|
173
|
+
if (seen.has(entry.docRef)) return false;
|
|
174
|
+
seen.add(entry.docRef);
|
|
175
|
+
if (!isFresh(entry.expiresAt)) return false;
|
|
176
|
+
return existsSync(join(paths.objectsDir, `${entry.objectKey}.json`));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await pruneObjectDirectory(paths.objectsDir, new Set(nextAll.map((entry) => entry.objectKey)));
|
|
180
|
+
await writeDocsIndexes(paths, nextAll);
|
|
181
|
+
return nextAll;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function readResolveIndexes(paths: ResolvePaths) {
|
|
185
|
+
const all = await readJsonFile<ResolveCacheIndexEntry[]>(paths.allIndexPath, []);
|
|
186
|
+
const pruned = await pruneResolveIndexes(paths, all);
|
|
187
|
+
const byLibrary = await readJsonFile<StringListMap>(paths.byLibraryPath, {});
|
|
188
|
+
return { all: pruned, byLibrary };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function readDocsIndexes(paths: DocsPaths) {
|
|
192
|
+
const all = await readJsonFile<DocCacheIndexEntry[]>(paths.allIndexPath, []);
|
|
193
|
+
const pruned = await pruneDocsIndexes(paths, all);
|
|
194
|
+
const byRef = await readJsonFile<DocRefIndexMap>(paths.byRefPath, {});
|
|
195
|
+
const byLibraryId = await readJsonFile<StringListMap>(paths.byLibraryIdPath, {});
|
|
196
|
+
const byLibraryName = await readJsonFile<StringListMap>(paths.byLibraryNamePath, {});
|
|
197
|
+
const byVersion = await readJsonFile<StringListMap>(paths.byVersionPath, {});
|
|
198
|
+
return { all: pruned, byRef, byLibraryId, byLibraryName, byVersion };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function buildDocRef(libraryId: string, effectiveQuery: string, page: number): string {
|
|
202
|
+
return `ctx7:docs:${hash(`${libraryId}::${effectiveQuery}::${page}`)}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function extractVersionInfo(libraryId: string): { raw?: string; normalized?: string } {
|
|
206
|
+
const parts = libraryId.split('/').filter(Boolean);
|
|
207
|
+
if (parts.length <= 2) return {};
|
|
208
|
+
const raw = parts.slice(2).join('/');
|
|
209
|
+
return {
|
|
210
|
+
raw,
|
|
211
|
+
normalized: raw.replace(/^v/, ''),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function getResolveCache(
|
|
216
|
+
settings: DocSearchSettings,
|
|
217
|
+
libraryName: string,
|
|
218
|
+
query: string,
|
|
219
|
+
): Promise<CacheLookup<ResolveCacheEntry>> {
|
|
220
|
+
const cacheKey = resolveCacheKey(libraryName, query);
|
|
221
|
+
const objectKey = hash(cacheKey);
|
|
222
|
+
const { objectPath } = getResolvePaths(settings, objectKey);
|
|
223
|
+
if (!existsSync(objectPath)) return { fresh: false };
|
|
224
|
+
|
|
225
|
+
const entry = await readJsonFile<ResolveCacheEntry | undefined>(objectPath, undefined);
|
|
226
|
+
if (!entry) return { fresh: false };
|
|
227
|
+
|
|
228
|
+
if (!isFresh(entry.expiresAt)) {
|
|
229
|
+
await rm(objectPath, { force: true });
|
|
230
|
+
return { fresh: false };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { entry, fresh: true };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function putResolveCache(
|
|
237
|
+
settings: DocSearchSettings,
|
|
238
|
+
params: { libraryName: string; query: string; results: ResolveCacheEntry['results'] },
|
|
239
|
+
): Promise<ResolveCacheEntry> {
|
|
240
|
+
const cacheKey = resolveCacheKey(params.libraryName, params.query);
|
|
241
|
+
const objectKey = hash(cacheKey);
|
|
242
|
+
const now = Date.now();
|
|
243
|
+
const entry: ResolveCacheEntry = {
|
|
244
|
+
kind: 'resolve',
|
|
245
|
+
cacheKey,
|
|
246
|
+
objectKey,
|
|
247
|
+
libraryName: params.libraryName,
|
|
248
|
+
normalizedLibraryName: normalizeText(params.libraryName),
|
|
249
|
+
query: params.query,
|
|
250
|
+
createdAt: toIso(now),
|
|
251
|
+
expiresAt: toIso(now + settings.resolveTtlMs),
|
|
252
|
+
results: params.results,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const paths = await ensureResolveCacheDirs(settings, objectKey);
|
|
256
|
+
const lockPath = join(settings.cacheDir, '.cache-mutation-lock');
|
|
257
|
+
|
|
258
|
+
await withFileMutationQueue(lockPath, async () => {
|
|
259
|
+
const { all } = await readResolveIndexes(paths);
|
|
260
|
+
const nextAll = all
|
|
261
|
+
.filter((item) => item.objectKey !== objectKey)
|
|
262
|
+
.concat({
|
|
263
|
+
kind: 'resolve',
|
|
264
|
+
cacheKey,
|
|
265
|
+
objectKey,
|
|
266
|
+
libraryName: entry.libraryName,
|
|
267
|
+
normalizedLibraryName: entry.normalizedLibraryName,
|
|
268
|
+
query: entry.query,
|
|
269
|
+
createdAt: entry.createdAt,
|
|
270
|
+
expiresAt: entry.expiresAt,
|
|
271
|
+
resultCount: entry.results.length,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await atomicWriteJson(paths.objectPath, entry);
|
|
275
|
+
await pruneResolveIndexes(paths, nextAll);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return entry;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function getDocCacheByRef(
|
|
282
|
+
settings: DocSearchSettings,
|
|
283
|
+
docRef: string,
|
|
284
|
+
): Promise<CacheLookup<DocCacheEntry>> {
|
|
285
|
+
const objectKey = hash(docRef);
|
|
286
|
+
const { objectPath } = getDocsPaths(settings, objectKey);
|
|
287
|
+
if (!existsSync(objectPath)) return { fresh: false };
|
|
288
|
+
|
|
289
|
+
const entry = await readJsonFile<DocCacheEntry | undefined>(objectPath, undefined);
|
|
290
|
+
if (!entry) return { fresh: false };
|
|
291
|
+
|
|
292
|
+
if (!isFresh(entry.expiresAt)) {
|
|
293
|
+
return { entry, fresh: false };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { entry, fresh: true };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function putDocCache(
|
|
300
|
+
settings: DocSearchSettings,
|
|
301
|
+
params: Omit<DocCacheEntry, 'kind' | 'objectKey' | 'createdAt' | 'expiresAt'>,
|
|
302
|
+
): Promise<DocCacheEntry> {
|
|
303
|
+
const objectKey = hash(params.docRef);
|
|
304
|
+
const now = Date.now();
|
|
305
|
+
const entry: DocCacheEntry = {
|
|
306
|
+
...params,
|
|
307
|
+
kind: 'docs',
|
|
308
|
+
objectKey,
|
|
309
|
+
createdAt: toIso(now),
|
|
310
|
+
expiresAt: toIso(now + settings.docsTtlMs),
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const paths = await ensureDocsCacheDirs(settings, objectKey);
|
|
314
|
+
const lockPath = join(settings.cacheDir, '.cache-mutation-lock');
|
|
315
|
+
|
|
316
|
+
await withFileMutationQueue(lockPath, async () => {
|
|
317
|
+
const { all } = await readDocsIndexes(paths);
|
|
318
|
+
const nextAll = all
|
|
319
|
+
.filter((item) => item.docRef !== entry.docRef)
|
|
320
|
+
.concat({
|
|
321
|
+
kind: 'docs',
|
|
322
|
+
docRef: entry.docRef,
|
|
323
|
+
objectKey,
|
|
324
|
+
libraryId: entry.libraryId,
|
|
325
|
+
libraryName: entry.libraryName,
|
|
326
|
+
normalizedLibraryName: entry.normalizedLibraryName,
|
|
327
|
+
libraryVersion: entry.libraryVersion,
|
|
328
|
+
libraryVersionRaw: entry.libraryVersionRaw,
|
|
329
|
+
query: entry.query,
|
|
330
|
+
topic: entry.topic,
|
|
331
|
+
effectiveQuery: entry.effectiveQuery,
|
|
332
|
+
page: entry.page,
|
|
333
|
+
createdAt: entry.createdAt,
|
|
334
|
+
expiresAt: entry.expiresAt,
|
|
335
|
+
rawLength: entry.rawText.length,
|
|
336
|
+
curatedLength: entry.curatedText.length,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
await atomicWriteJson(paths.objectPath, entry);
|
|
340
|
+
await pruneDocsIndexes(paths, nextAll);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
return entry;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function intersectDocRefs(
|
|
347
|
+
current: string[] | undefined,
|
|
348
|
+
next: string[] | undefined,
|
|
349
|
+
): string[] | undefined {
|
|
350
|
+
if (!next || next.length === 0) return current;
|
|
351
|
+
if (!current) return uniqueStrings(next);
|
|
352
|
+
const nextSet = new Set(next);
|
|
353
|
+
return current.filter((value) => nextSet.has(value));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export async function findDocCacheCandidates(
|
|
357
|
+
settings: DocSearchSettings,
|
|
358
|
+
selector: CacheSearchSelector,
|
|
359
|
+
): Promise<DocCacheIndexEntry[]> {
|
|
360
|
+
const paths = getDocsPaths(settings, 'placeholder');
|
|
361
|
+
const { all, byRef, byLibraryId, byLibraryName, byVersion } = await readDocsIndexes(paths);
|
|
362
|
+
|
|
363
|
+
if (selector.docRef) {
|
|
364
|
+
const exact = byRef[selector.docRef];
|
|
365
|
+
if (!exact) return [];
|
|
366
|
+
return [exact];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const normalizedLibraryName = normalizeText(selector.libraryName);
|
|
370
|
+
const normalizedVersion = normalizeText(selector.libraryVersion).replace(/^v/, '');
|
|
371
|
+
const normalizedQuery = normalizeText(selector.query);
|
|
372
|
+
const normalizedTopic = normalizeText(selector.topic);
|
|
373
|
+
|
|
374
|
+
let seededDocRefs: string[] | undefined;
|
|
375
|
+
if (selector.libraryId)
|
|
376
|
+
seededDocRefs = intersectDocRefs(seededDocRefs, byLibraryId[selector.libraryId]);
|
|
377
|
+
if (normalizedLibraryName)
|
|
378
|
+
seededDocRefs = intersectDocRefs(seededDocRefs, byLibraryName[normalizedLibraryName]);
|
|
379
|
+
if (normalizedVersion)
|
|
380
|
+
seededDocRefs = intersectDocRefs(seededDocRefs, byVersion[normalizedVersion]);
|
|
381
|
+
|
|
382
|
+
const candidatePool = seededDocRefs
|
|
383
|
+
? seededDocRefs.map((docRef) => byRef[docRef]).filter(Boolean)
|
|
384
|
+
: all;
|
|
385
|
+
|
|
386
|
+
return candidatePool
|
|
387
|
+
.filter((item) => (selector.libraryId ? item.libraryId === selector.libraryId : true))
|
|
388
|
+
.filter((item) => {
|
|
389
|
+
if (!normalizedLibraryName) return true;
|
|
390
|
+
return (
|
|
391
|
+
item.normalizedLibraryName === normalizedLibraryName ||
|
|
392
|
+
item.normalizedLibraryName.includes(normalizedLibraryName) ||
|
|
393
|
+
normalizedLibraryName.includes(item.normalizedLibraryName)
|
|
394
|
+
);
|
|
395
|
+
})
|
|
396
|
+
.filter((item) => {
|
|
397
|
+
if (!normalizedVersion) return true;
|
|
398
|
+
return (
|
|
399
|
+
item.libraryVersion === normalizedVersion ||
|
|
400
|
+
normalizeText(item.libraryVersionRaw).replace(/^v/, '') === normalizedVersion
|
|
401
|
+
);
|
|
402
|
+
})
|
|
403
|
+
.filter((item) => (normalizedQuery ? normalizeText(item.query) === normalizedQuery : true))
|
|
404
|
+
.filter((item) => (normalizedTopic ? normalizeText(item.topic) === normalizedTopic : true))
|
|
405
|
+
.filter((item) => (typeof selector.page === 'number' ? item.page === selector.page : true))
|
|
406
|
+
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export async function loadDocCacheObject(
|
|
410
|
+
settings: DocSearchSettings,
|
|
411
|
+
metadata: Pick<DocCacheIndexEntry, 'objectKey'>,
|
|
412
|
+
): Promise<DocCacheEntry | undefined> {
|
|
413
|
+
const { objectPath } = getDocsPaths(settings, metadata.objectKey);
|
|
414
|
+
return readJsonFile<DocCacheEntry | undefined>(objectPath, undefined);
|
|
415
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import type { DocSearchConfigFile, DocSearchSettings } from './types';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_RESOLVE_TTL_HOURS = 24 * 7;
|
|
8
|
+
export const DEFAULT_DOCS_TTL_HOURS = 24;
|
|
9
|
+
|
|
10
|
+
const extensionDir = join(getAgentDir(), 'extensions', 'doc-search');
|
|
11
|
+
const configPath = join(extensionDir, 'config.json');
|
|
12
|
+
const cacheDir = join(extensionDir, 'cache');
|
|
13
|
+
|
|
14
|
+
function normalizePositiveNumber(value: unknown, fallback: number): number {
|
|
15
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return fallback;
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readConfigFile(): Promise<{ config: DocSearchConfigFile; error?: string }> {
|
|
20
|
+
if (!existsSync(configPath)) return { config: {} };
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const raw = await readFile(configPath, 'utf8');
|
|
24
|
+
const parsed = JSON.parse(raw) as DocSearchConfigFile;
|
|
25
|
+
return { config: parsed ?? {} };
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return {
|
|
28
|
+
config: {},
|
|
29
|
+
error: error instanceof Error ? error.message : String(error),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function loadSettings(): Promise<DocSearchSettings> {
|
|
35
|
+
const { config, error } = await readConfigFile();
|
|
36
|
+
const apiKey = process.env.CONTEXT7_API_KEY?.trim() || config.apiKey?.trim() || undefined;
|
|
37
|
+
const resolveTtlHours = normalizePositiveNumber(
|
|
38
|
+
config.cache?.resolveTtlHours,
|
|
39
|
+
DEFAULT_RESOLVE_TTL_HOURS,
|
|
40
|
+
);
|
|
41
|
+
const docsTtlHours = normalizePositiveNumber(config.cache?.docsTtlHours, DEFAULT_DOCS_TTL_HOURS);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
extensionDir,
|
|
45
|
+
configPath,
|
|
46
|
+
cacheDir,
|
|
47
|
+
apiKey,
|
|
48
|
+
resolveTtlMs: resolveTtlHours * 60 * 60 * 1000,
|
|
49
|
+
docsTtlMs: docsTtlHours * 60 * 60 * 1000,
|
|
50
|
+
configError: error,
|
|
51
|
+
};
|
|
52
|
+
}
|