@asterxsk/kiln 0.1.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/LICENSE +21 -0
- package/README.md +170 -0
- package/agent/AGENTS.md +67 -0
- package/agent/README.md +5 -0
- package/agent/extensions/AGENTS.md +68 -0
- package/agent/extensions/ask-user/index.ts +418 -0
- package/agent/extensions/ask-user/install.ps1 +23 -0
- package/agent/extensions/ask-user/install.sh +21 -0
- package/agent/extensions/ask-user/package-lock.json +769 -0
- package/agent/extensions/ask-user/package.json +19 -0
- package/agent/extensions/ask-user/prompt.ts +45 -0
- package/agent/extensions/ask-user/tsconfig.json +7 -0
- package/agent/extensions/background-terminals/docs/implementation-guide.md +942 -0
- package/agent/extensions/background-terminals/index.ts +627 -0
- package/agent/extensions/background-terminals/install.ps1 +23 -0
- package/agent/extensions/background-terminals/install.sh +21 -0
- package/agent/extensions/background-terminals/manager.test.ts +735 -0
- package/agent/extensions/background-terminals/output.test.ts +109 -0
- package/agent/extensions/background-terminals/package-lock.json +769 -0
- package/agent/extensions/background-terminals/package.json +17 -0
- package/agent/extensions/background-terminals/prompt.test.ts +125 -0
- package/agent/extensions/background-terminals/ps.test.ts +82 -0
- package/agent/extensions/background-terminals/result-delivery.test.ts +44 -0
- package/agent/extensions/background-terminals/src/domain.ts +87 -0
- package/agent/extensions/background-terminals/src/manager.ts +907 -0
- package/agent/extensions/background-terminals/src/output.ts +84 -0
- package/agent/extensions/background-terminals/src/prompt.ts +142 -0
- package/agent/extensions/background-terminals/src/result-delivery.ts +27 -0
- package/agent/extensions/background-terminals/src/runtime.ts +36 -0
- package/agent/extensions/background-terminals/src/ui/output-view.ts +79 -0
- package/agent/extensions/background-terminals/src/ui/ps.ts +621 -0
- package/agent/extensions/background-terminals/tsconfig.json +7 -0
- package/agent/extensions/destructive/README.md +31 -0
- package/agent/extensions/destructive/index.ts +88 -0
- package/agent/extensions/destructive/install.ps1 +23 -0
- package/agent/extensions/destructive/install.sh +21 -0
- package/agent/extensions/file-search/index.spec.ts +443 -0
- package/agent/extensions/file-search/index.ts +459 -0
- package/agent/extensions/file-search/install.ps1 +23 -0
- package/agent/extensions/file-search/install.sh +21 -0
- package/agent/extensions/file-search/package-lock.json +2253 -0
- package/agent/extensions/file-search/package.json +23 -0
- package/agent/extensions/file-search/src/args.ts +122 -0
- package/agent/extensions/file-search/src/binaries.ts +422 -0
- package/agent/extensions/file-search/src/output.ts +126 -0
- package/agent/extensions/file-search/src/process.ts +146 -0
- package/agent/extensions/file-search/src/prompt.ts +52 -0
- package/agent/extensions/file-search/tsconfig.json +7 -0
- package/agent/extensions/goal/README.md +50 -0
- package/agent/extensions/goal/index.ts +155 -0
- package/agent/extensions/goal/install.ps1 +23 -0
- package/agent/extensions/goal/install.sh +21 -0
- package/agent/extensions/modelconf/PLAN.md +915 -0
- package/agent/extensions/modelconf/README.md +66 -0
- package/agent/extensions/modelconf/index.ts +296 -0
- package/agent/extensions/modelconf/install.ps1 +23 -0
- package/agent/extensions/modelconf/install.sh +21 -0
- package/agent/extensions/modelconf/package-lock.json +1809 -0
- package/agent/extensions/modelconf/package.json +13 -0
- package/agent/extensions/modelconf/src/fuzzy.ts +26 -0
- package/agent/extensions/modelconf/src/glob.ts +13 -0
- package/agent/extensions/modelconf/src/persistence.test.ts +46 -0
- package/agent/extensions/modelconf/src/persistence.ts +164 -0
- package/agent/extensions/modelconf/src/ui/ModelConfView.test.ts +75 -0
- package/agent/extensions/modelconf/src/ui/ModelConfView.ts +1101 -0
- package/agent/extensions/modelconf/tsconfig.json +15 -0
- package/agent/extensions/pi-web-access/CHANGELOG.md +690 -0
- package/agent/extensions/pi-web-access/LICENSE +21 -0
- package/agent/extensions/pi-web-access/README.md +470 -0
- package/agent/extensions/pi-web-access/SECURITY.md +5 -0
- package/agent/extensions/pi-web-access/activity.ts +101 -0
- package/agent/extensions/pi-web-access/auth-fetch.ts +148 -0
- package/agent/extensions/pi-web-access/banner.png +0 -0
- package/agent/extensions/pi-web-access/brightdata-unlocker.ts +272 -0
- package/agent/extensions/pi-web-access/chrome-cookies.ts +669 -0
- package/agent/extensions/pi-web-access/content-find.ts +139 -0
- package/agent/extensions/pi-web-access/credential-source.ts +191 -0
- package/agent/extensions/pi-web-access/data-uri-sanitize.ts +406 -0
- package/agent/extensions/pi-web-access/datalab-pdf-extract.ts +568 -0
- package/agent/extensions/pi-web-access/declared-web-links.ts +173 -0
- package/agent/extensions/pi-web-access/evidence/CONTRACT-EVIDENCE.md +496 -0
- package/agent/extensions/pi-web-access/evidence/contract-probe.mjs +140 -0
- package/agent/extensions/pi-web-access/exa.ts +526 -0
- package/agent/extensions/pi-web-access/extract.ts +1196 -0
- package/agent/extensions/pi-web-access/feature-config.ts +29 -0
- package/agent/extensions/pi-web-access/fetch-params.ts +111 -0
- package/agent/extensions/pi-web-access/gemini-adc.ts +298 -0
- package/agent/extensions/pi-web-access/gemini-api.ts +353 -0
- package/agent/extensions/pi-web-access/gemini-pdf-extract.ts +108 -0
- package/agent/extensions/pi-web-access/gemini-search.ts +21 -0
- package/agent/extensions/pi-web-access/gemini-url-context.ts +128 -0
- package/agent/extensions/pi-web-access/gemini-web-config.ts +101 -0
- package/agent/extensions/pi-web-access/gemini-web.ts +487 -0
- package/agent/extensions/pi-web-access/github-api.ts +197 -0
- package/agent/extensions/pi-web-access/github-extract.ts +746 -0
- package/agent/extensions/pi-web-access/github-issue-pr.ts +700 -0
- package/agent/extensions/pi-web-access/index.ts +1737 -0
- package/agent/extensions/pi-web-access/package-lock.json +5808 -0
- package/agent/extensions/pi-web-access/package.json +64 -0
- package/agent/extensions/pi-web-access/page-query.ts +96 -0
- package/agent/extensions/pi-web-access/pdf-extract.ts +409 -0
- package/agent/extensions/pi-web-access/pi-web-fetch-demo.mp4 +0 -0
- package/agent/extensions/pi-web-access/promise-try.d.ts +7 -0
- package/agent/extensions/pi-web-access/query-rewrite.ts +51 -0
- package/agent/extensions/pi-web-access/render-search-error.ts +170 -0
- package/agent/extensions/pi-web-access/rsc-extract.ts +338 -0
- package/agent/extensions/pi-web-access/search-types.ts +20 -0
- package/agent/extensions/pi-web-access/source-check.ts +282 -0
- package/agent/extensions/pi-web-access/ssrf-protection.ts +526 -0
- package/agent/extensions/pi-web-access/storage.ts +521 -0
- package/agent/extensions/pi-web-access/summary-model-scope.ts +125 -0
- package/agent/extensions/pi-web-access/test/auth-fetch.test.mjs +208 -0
- package/agent/extensions/pi-web-access/test/brightdata-unlocker.test.mjs +840 -0
- package/agent/extensions/pi-web-access/test/chrome-cookie-extraction.test.mjs +441 -0
- package/agent/extensions/pi-web-access/test/config-path.test.mjs +283 -0
- package/agent/extensions/pi-web-access/test/content-find.test.mjs +25 -0
- package/agent/extensions/pi-web-access/test/credential-source.test.mjs +118 -0
- package/agent/extensions/pi-web-access/test/data-uri-sanitize.test.mjs +210 -0
- package/agent/extensions/pi-web-access/test/datalab-pdf-extract.test.mjs +552 -0
- package/agent/extensions/pi-web-access/test/declared-web-links.test.mjs +212 -0
- package/agent/extensions/pi-web-access/test/fetch-answer-storage.test.mjs +40 -0
- package/agent/extensions/pi-web-access/test/fetch-cache-storage.test.mjs +334 -0
- package/agent/extensions/pi-web-access/test/fetch-content-domain-policy.test.mjs +95 -0
- package/agent/extensions/pi-web-access/test/fetch-modes.test.mjs +53 -0
- package/agent/extensions/pi-web-access/test/fetch-not-found-guidance.test.mjs +92 -0
- package/agent/extensions/pi-web-access/test/fetch-params.test.mjs +86 -0
- package/agent/extensions/pi-web-access/test/fetch-render-call.test.mjs +34 -0
- package/agent/extensions/pi-web-access/test/fetch-routing.test.mjs +173 -0
- package/agent/extensions/pi-web-access/test/gemini-adc-auth.test.mjs +257 -0
- package/agent/extensions/pi-web-access/test/gemini-api-transport.test.mjs +170 -0
- package/agent/extensions/pi-web-access/test/gemini-pdf-extract.test.mjs +133 -0
- package/agent/extensions/pi-web-access/test/gemini-web-cookie-opt-in.test.mjs +178 -0
- package/agent/extensions/pi-web-access/test/gemini-web-header-overflow.test.mjs +148 -0
- package/agent/extensions/pi-web-access/test/get-search-content.test.mjs +223 -0
- package/agent/extensions/pi-web-access/test/github-extract.test.mjs +378 -0
- package/agent/extensions/pi-web-access/test/github-issue-pr.test.mjs +565 -0
- package/agent/extensions/pi-web-access/test/inline-content-config.test.mjs +99 -0
- package/agent/extensions/pi-web-access/test/lazy-extract-load.test.mjs +118 -0
- package/agent/extensions/pi-web-access/test/local-video-oversize.test.mjs +52 -0
- package/agent/extensions/pi-web-access/test/package-typebox-dependency.test.mjs +50 -0
- package/agent/extensions/pi-web-access/test/page-query.test.mjs +51 -0
- package/agent/extensions/pi-web-access/test/pdf-config.test.mjs +140 -0
- package/agent/extensions/pi-web-access/test/pdf-extract.test.mjs +500 -0
- package/agent/extensions/pi-web-access/test/proxy-transport.test.mjs +286 -0
- package/agent/extensions/pi-web-access/test/query-rewrite.test.mjs +52 -0
- package/agent/extensions/pi-web-access/test/rsc-fallback.test.mjs +102 -0
- package/agent/extensions/pi-web-access/test/search-error-render.test.mjs +152 -0
- package/agent/extensions/pi-web-access/test/search-providers.test.mjs +274 -0
- package/agent/extensions/pi-web-access/test/source-check.test.mjs +179 -0
- package/agent/extensions/pi-web-access/test/ssrf-allow-ranges-config.test.mjs +205 -0
- package/agent/extensions/pi-web-access/test/ssrf-protection.test.mjs +456 -0
- package/agent/extensions/pi-web-access/test/summary-model-scope.test.mjs +106 -0
- package/agent/extensions/pi-web-access/test/tool-registration-config.test.mjs +182 -0
- package/agent/extensions/pi-web-access/test/web-search-answer-render.test.mjs +66 -0
- package/agent/extensions/pi-web-access/test/youtube-extract-errors.test.mjs +64 -0
- package/agent/extensions/pi-web-access/tsconfig.json +11 -0
- package/agent/extensions/pi-web-access/utils.ts +451 -0
- package/agent/extensions/pi-web-access/video-extract.ts +392 -0
- package/agent/extensions/pi-web-access/youtube-extract.ts +328 -0
- package/agent/extensions/shared/activity-status.ts +31 -0
- package/agent/extensions/shared/child-session.test.ts +270 -0
- package/agent/extensions/shared/child-session.ts +148 -0
- package/agent/extensions/shared/context-utilization.test.ts +48 -0
- package/agent/extensions/shared/context-utilization.ts +47 -0
- package/agent/extensions/shared/dashboard-state.ts +99 -0
- package/agent/extensions/shared/install.ps1 +23 -0
- package/agent/extensions/shared/install.sh +21 -0
- package/agent/extensions/shared/tool-call-timeout.test.ts +117 -0
- package/agent/extensions/shared/tool-call-timeout.ts +104 -0
- package/agent/extensions/skillsconf/README.md +74 -0
- package/agent/extensions/skillsconf/index.ts +92 -0
- package/agent/extensions/skillsconf/install.ps1 +23 -0
- package/agent/extensions/skillsconf/install.sh +21 -0
- package/agent/extensions/skillsconf/package-lock.json +1809 -0
- package/agent/extensions/skillsconf/package.json +18 -0
- package/agent/extensions/skillsconf/src/delete-skill.test.ts +64 -0
- package/agent/extensions/skillsconf/src/delete-skill.ts +45 -0
- package/agent/extensions/skillsconf/src/filter.test.ts +80 -0
- package/agent/extensions/skillsconf/src/filter.ts +74 -0
- package/agent/extensions/skillsconf/src/fuzzy.ts +26 -0
- package/agent/extensions/skillsconf/src/persistence.test.ts +72 -0
- package/agent/extensions/skillsconf/src/persistence.ts +169 -0
- package/agent/extensions/skillsconf/src/ui/SkillConfView.test.ts +337 -0
- package/agent/extensions/skillsconf/src/ui/SkillConfView.ts +758 -0
- package/agent/extensions/skillsconf/src/ui/text-input.ts +91 -0
- package/agent/extensions/skillsconf/src/ui/tui-helpers.ts +144 -0
- package/agent/extensions/skillsconf/tsconfig.json +15 -0
- package/agent/extensions/status line/index.ts +282 -0
- package/agent/extensions/status line/install.ps1 +23 -0
- package/agent/extensions/status line/install.sh +21 -0
- package/agent/extensions/subagents/by-the-way.test.ts +29 -0
- package/agent/extensions/subagents/claude.test.ts +119 -0
- package/agent/extensions/subagents/codex.test.ts +102 -0
- package/agent/extensions/subagents/context-usage.test.ts +107 -0
- package/agent/extensions/subagents/docs/design-plan.md +568 -0
- package/agent/extensions/subagents/docs/effect-v4-extension-guide.md +354 -0
- package/agent/extensions/subagents/docs/effect-v4-notes.md +571 -0
- package/agent/extensions/subagents/index.ts +779 -0
- package/agent/extensions/subagents/install.ps1 +23 -0
- package/agent/extensions/subagents/install.sh +21 -0
- package/agent/extensions/subagents/manager.test.ts +276 -0
- package/agent/extensions/subagents/package-lock.json +2244 -0
- package/agent/extensions/subagents/package.json +19 -0
- package/agent/extensions/subagents/result-delivery.test.ts +27 -0
- package/agent/extensions/subagents/src/backend.ts +73 -0
- package/agent/extensions/subagents/src/backends/claude.ts +701 -0
- package/agent/extensions/subagents/src/backends/codex.ts +1060 -0
- package/agent/extensions/subagents/src/backends/pi.ts +575 -0
- package/agent/extensions/subagents/src/backends/stub.ts +300 -0
- package/agent/extensions/subagents/src/by-the-way.ts +21 -0
- package/agent/extensions/subagents/src/domain.ts +253 -0
- package/agent/extensions/subagents/src/format.ts +74 -0
- package/agent/extensions/subagents/src/manager.ts +736 -0
- package/agent/extensions/subagents/src/prompt.ts +92 -0
- package/agent/extensions/subagents/src/result-delivery.ts +20 -0
- package/agent/extensions/subagents/src/runtime.ts +53 -0
- package/agent/extensions/subagents/src/ui/takeover.ts +583 -0
- package/agent/extensions/subagents/src/ui/transcript.ts +201 -0
- package/agent/extensions/subagents/takeover.test.ts +29 -0
- package/agent/extensions/subagents/tsconfig.json +7 -0
- package/agent/extensions/taste/index.ts +443 -0
- package/agent/extensions/taste/install.ps1 +23 -0
- package/agent/extensions/taste/install.sh +21 -0
- package/agent/extensions/todo/AGENTS.md +38 -0
- package/agent/extensions/todo/LICENSE +21 -0
- package/agent/extensions/todo/config.ts +55 -0
- package/agent/extensions/todo/index.ts +151 -0
- package/agent/extensions/todo/install.ps1 +23 -0
- package/agent/extensions/todo/install.sh +21 -0
- package/agent/extensions/todo/locales/de.json +17 -0
- package/agent/extensions/todo/locales/en.json +15 -0
- package/agent/extensions/todo/locales/es.json +17 -0
- package/agent/extensions/todo/locales/fr.json +17 -0
- package/agent/extensions/todo/locales/pt-BR.json +17 -0
- package/agent/extensions/todo/locales/pt.json +17 -0
- package/agent/extensions/todo/locales/ru.json +17 -0
- package/agent/extensions/todo/locales/uk.json +17 -0
- package/agent/extensions/todo/locales/zh.json +17 -0
- package/agent/extensions/todo/package-lock.json +3358 -0
- package/agent/extensions/todo/package.json +67 -0
- package/agent/extensions/todo/state/i18n-bridge.ts +64 -0
- package/agent/extensions/todo/state/invariants.ts +20 -0
- package/agent/extensions/todo/state/replay.ts +38 -0
- package/agent/extensions/todo/state/selectors.ts +107 -0
- package/agent/extensions/todo/state/state-reducer.ts +326 -0
- package/agent/extensions/todo/state/state.ts +18 -0
- package/agent/extensions/todo/state/store.ts +82 -0
- package/agent/extensions/todo/state/task-graph.ts +57 -0
- package/agent/extensions/todo/todo-overlay.ts +200 -0
- package/agent/extensions/todo/todo.ts +155 -0
- package/agent/extensions/todo/tool/response-envelope.ts +109 -0
- package/agent/extensions/todo/tool/types.ts +206 -0
- package/agent/extensions/todo/verify-ref-system.js +0 -0
- package/agent/extensions/todo/view/format.ts +177 -0
- package/agent/extensions/trim-context/README.md +54 -0
- package/agent/extensions/trim-context/index.ts +487 -0
- package/agent/extensions/trim-context/install.ps1 +23 -0
- package/agent/extensions/trim-context/install.sh +21 -0
- package/agent/install.ps1 +527 -0
- package/agent/install.sh +511 -0
- package/agent/keybindings.json +7 -0
- package/bin/kiln.js +359 -0
- package/package.json +21 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, type Stats, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { ExtractedContent } from "./extract.ts";
|
|
7
|
+
import type { SearchResult } from "./search-types.ts";
|
|
8
|
+
|
|
9
|
+
const CACHE_TTL_MS = 60 * 60 * 1000;
|
|
10
|
+
const FETCH_CACHE_DIR = "web-search-cache";
|
|
11
|
+
const FETCH_CACHE_VERSION = 1;
|
|
12
|
+
const CACHE_KEY_PATTERN = /^[A-Za-z0-9_-]+\.json$/;
|
|
13
|
+
const CACHE_TMP_PATTERN = /^[A-Za-z0-9_-]+\.json\.\d+\.\d+(?:\.[a-f0-9]{32})?\.tmp$/;
|
|
14
|
+
const CACHE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
15
|
+
const MAX_METADATA_TEXT = 8192;
|
|
16
|
+
const DEFAULT_CACHE_LIMITS = { maxEntries: 128, maxBytes: 128 * 1024 * 1024 };
|
|
17
|
+
const O_DIRECTORY = process.platform === "win32" ? 0 : (constants.O_DIRECTORY ?? 0);
|
|
18
|
+
const O_NOFOLLOW = process.platform === "win32" ? 0 : (constants.O_NOFOLLOW ?? 0);
|
|
19
|
+
|
|
20
|
+
interface FetchCacheLimits {
|
|
21
|
+
maxEntries: number;
|
|
22
|
+
maxBytes: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface CacheFile {
|
|
26
|
+
name: string;
|
|
27
|
+
size: number;
|
|
28
|
+
mtimeMs: number;
|
|
29
|
+
dev: number;
|
|
30
|
+
ino: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface QueryResultData {
|
|
34
|
+
query: string;
|
|
35
|
+
answer: string;
|
|
36
|
+
results: SearchResult[];
|
|
37
|
+
error: string | null;
|
|
38
|
+
provider?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface FetchCacheRef {
|
|
42
|
+
version: typeof FETCH_CACHE_VERSION;
|
|
43
|
+
key: string;
|
|
44
|
+
storedAt: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface StoredFetchUrlMetadata {
|
|
48
|
+
url: string;
|
|
49
|
+
title: string;
|
|
50
|
+
error: string | null;
|
|
51
|
+
contentLength: number;
|
|
52
|
+
mimeType?: string;
|
|
53
|
+
status?: number;
|
|
54
|
+
duration?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface StoredSearchData {
|
|
58
|
+
id: string;
|
|
59
|
+
type: "search" | "fetch" | "research";
|
|
60
|
+
timestamp: number;
|
|
61
|
+
queries?: QueryResultData[];
|
|
62
|
+
urls?: ExtractedContent[];
|
|
63
|
+
artifact?: unknown;
|
|
64
|
+
fetchCache?: FetchCacheRef;
|
|
65
|
+
urlMetadata?: StoredFetchUrlMetadata[];
|
|
66
|
+
fetchCacheError?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const storedResults = new Map<string, StoredSearchData>();
|
|
70
|
+
|
|
71
|
+
export function generateId(): string {
|
|
72
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Fetch cache lives inside the pi-web-access extension folder so it travels
|
|
76
|
+
// with the extension instead of the Pi config directory.
|
|
77
|
+
const EXTENSION_DIR = dirname(fileURLToPath(import.meta.url));
|
|
78
|
+
|
|
79
|
+
/** Explicit cache directory override (used by tests for isolation). */
|
|
80
|
+
const CACHE_DIR_OVERRIDE_ENV = "PI_WEB_ACCESS_CACHE_DIR";
|
|
81
|
+
|
|
82
|
+
export function getFetchCacheDir(): string {
|
|
83
|
+
const override = process.env[CACHE_DIR_OVERRIDE_ENV]?.trim();
|
|
84
|
+
if (override) return override;
|
|
85
|
+
return join(EXTENSION_DIR, FETCH_CACHE_DIR);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function fetchCachePath(key: string): string | null {
|
|
89
|
+
if (!CACHE_KEY_PATTERN.test(key)) return null;
|
|
90
|
+
return join(getFetchCacheDir(), key);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cacheKeyForId(id: string): string {
|
|
94
|
+
if (!CACHE_ID_PATTERN.test(id)) {
|
|
95
|
+
throw new Error(`Invalid fetched content cache id: ${id}`);
|
|
96
|
+
}
|
|
97
|
+
return `${id}.json`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function truncateMetadataText(value: string | undefined): string {
|
|
101
|
+
if (!value) return "";
|
|
102
|
+
return value.length > MAX_METADATA_TEXT ? `${value.slice(0, MAX_METADATA_TEXT)}...` : value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function metadataForUrls(urls: ExtractedContent[]): StoredFetchUrlMetadata[] {
|
|
106
|
+
return urls.map((url) => ({
|
|
107
|
+
url: truncateMetadataText(url.url),
|
|
108
|
+
title: truncateMetadataText(url.title),
|
|
109
|
+
error: url.error ? truncateMetadataText(url.error) : null,
|
|
110
|
+
contentLength: url.content.length,
|
|
111
|
+
...(url.mimeType ? { mimeType: truncateMetadataText(url.mimeType) } : {}),
|
|
112
|
+
...(typeof url.status === "number" ? { status: url.status } : {}),
|
|
113
|
+
...(typeof url.duration === "number" ? { duration: url.duration } : {}),
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isFetchCacheRef(value: unknown): value is FetchCacheRef {
|
|
118
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
119
|
+
const ref = value as Record<string, unknown>;
|
|
120
|
+
return ref.version === FETCH_CACHE_VERSION &&
|
|
121
|
+
typeof ref.key === "string" && CACHE_KEY_PATTERN.test(ref.key) &&
|
|
122
|
+
typeof ref.storedAt === "number" && Number.isFinite(ref.storedAt);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isStoredFetchUrlMetadata(value: unknown): value is StoredFetchUrlMetadata {
|
|
126
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
127
|
+
const meta = value as Record<string, unknown>;
|
|
128
|
+
return typeof meta.url === "string" &&
|
|
129
|
+
typeof meta.title === "string" &&
|
|
130
|
+
(meta.error === null || typeof meta.error === "string") &&
|
|
131
|
+
typeof meta.contentLength === "number" && Number.isFinite(meta.contentLength) && meta.contentLength >= 0 &&
|
|
132
|
+
(meta.mimeType === undefined || typeof meta.mimeType === "string") &&
|
|
133
|
+
(meta.status === undefined || typeof meta.status === "number") &&
|
|
134
|
+
(meta.duration === undefined || typeof meta.duration === "number");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isInlineFetchedUrl(value: unknown): value is ExtractedContent {
|
|
138
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
139
|
+
const url = value as Record<string, unknown>;
|
|
140
|
+
return typeof url.url === "string" &&
|
|
141
|
+
typeof url.title === "string" &&
|
|
142
|
+
typeof url.content === "string" &&
|
|
143
|
+
(url.error === null || typeof url.error === "string");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isInlineFetchData(data: StoredSearchData): data is StoredSearchData & { urls: ExtractedContent[] } {
|
|
147
|
+
return data.type === "fetch" && Array.isArray(data.urls) && data.urls.every(isInlineFetchedUrl);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function cacheLimits(limits?: Partial<FetchCacheLimits>): FetchCacheLimits {
|
|
151
|
+
const resolved = {
|
|
152
|
+
maxEntries: limits?.maxEntries ?? DEFAULT_CACHE_LIMITS.maxEntries,
|
|
153
|
+
maxBytes: limits?.maxBytes ?? DEFAULT_CACHE_LIMITS.maxBytes,
|
|
154
|
+
};
|
|
155
|
+
if (!Number.isFinite(resolved.maxEntries) || !Number.isInteger(resolved.maxEntries) || resolved.maxEntries <= 0 ||
|
|
156
|
+
!Number.isFinite(resolved.maxBytes) || !Number.isInteger(resolved.maxBytes) || resolved.maxBytes <= 0) {
|
|
157
|
+
throw new Error("Fetched content cache limits must be finite positive integers");
|
|
158
|
+
}
|
|
159
|
+
return resolved;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function enforceDirectoryMode(fd: number): void {
|
|
163
|
+
try {
|
|
164
|
+
fchmodSync(fd, 0o700);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (process.platform !== "win32") throw err;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function enforceFileMode(fd: number): void {
|
|
171
|
+
try {
|
|
172
|
+
fchmodSync(fd, 0o600);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (process.platform !== "win32") throw err;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function safeFetchCacheDir(create: true): string;
|
|
179
|
+
function safeFetchCacheDir(create: false): string | null;
|
|
180
|
+
function safeFetchCacheDir(create: boolean): string | null {
|
|
181
|
+
const dir = getFetchCacheDir();
|
|
182
|
+
if (create) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
183
|
+
let before: Stats;
|
|
184
|
+
try {
|
|
185
|
+
before = lstatSync(dir);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
if (!create && (err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
if (before.isSymbolicLink() || !before.isDirectory()) {
|
|
191
|
+
throw new Error("Fetched content cache path is not a safe directory");
|
|
192
|
+
}
|
|
193
|
+
if (process.platform === "win32") {
|
|
194
|
+
const after = lstatSync(dir);
|
|
195
|
+
if (after.isSymbolicLink() || !after.isDirectory() || after.dev !== before.dev || after.ino !== before.ino) {
|
|
196
|
+
throw new Error("Fetched content cache directory changed while securing it");
|
|
197
|
+
}
|
|
198
|
+
return dir;
|
|
199
|
+
}
|
|
200
|
+
let fd: number | null = null;
|
|
201
|
+
try {
|
|
202
|
+
fd = openSync(dir, constants.O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
|
|
203
|
+
const opened = fstatSync(fd);
|
|
204
|
+
if (!opened.isDirectory() || opened.dev !== before.dev || opened.ino !== before.ino) {
|
|
205
|
+
throw new Error("Fetched content cache directory changed while opening");
|
|
206
|
+
}
|
|
207
|
+
enforceDirectoryMode(fd);
|
|
208
|
+
closeSync(fd);
|
|
209
|
+
fd = null;
|
|
210
|
+
const after = lstatSync(dir);
|
|
211
|
+
if (after.isSymbolicLink() || !after.isDirectory() || after.dev !== before.dev || after.ino !== before.ino) {
|
|
212
|
+
throw new Error("Fetched content cache directory changed while securing it");
|
|
213
|
+
}
|
|
214
|
+
return dir;
|
|
215
|
+
} finally {
|
|
216
|
+
if (fd !== null) try { closeSync(fd); } catch {}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function openRegularFile(path: string): { fd: number; info: Stats } {
|
|
221
|
+
const before = lstatSync(path);
|
|
222
|
+
if (before.isSymbolicLink() || !before.isFile()) throw new Error("Fetched content cache entry is not a regular file");
|
|
223
|
+
const fd = openSync(path, constants.O_RDONLY | O_NOFOLLOW);
|
|
224
|
+
try {
|
|
225
|
+
const info = fstatSync(fd);
|
|
226
|
+
if (!info.isFile() || info.dev !== before.dev || info.ino !== before.ino) {
|
|
227
|
+
throw new Error("Fetched content cache entry changed while opening");
|
|
228
|
+
}
|
|
229
|
+
return { fd, info };
|
|
230
|
+
} catch (err) {
|
|
231
|
+
closeSync(fd);
|
|
232
|
+
throw err;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
type CacheUnlinkResult = "removed" | "missing" | "changed" | "error";
|
|
237
|
+
|
|
238
|
+
function unlinkCacheFile(dir: string, file: CacheFile): CacheUnlinkResult {
|
|
239
|
+
try {
|
|
240
|
+
const root = lstatSync(dir);
|
|
241
|
+
if (root.isSymbolicLink() || !root.isDirectory()) return "changed";
|
|
242
|
+
const path = join(dir, file.name);
|
|
243
|
+
let current: Stats;
|
|
244
|
+
try {
|
|
245
|
+
current = lstatSync(path);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
return (err as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "error";
|
|
248
|
+
}
|
|
249
|
+
if (current.isSymbolicLink() || !current.isFile() || current.dev !== file.dev || current.ino !== file.ino) return "changed";
|
|
250
|
+
try {
|
|
251
|
+
unlinkSync(path);
|
|
252
|
+
return "removed";
|
|
253
|
+
} catch (err) {
|
|
254
|
+
return (err as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "error";
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
return "error";
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function pruneFetchCache(now: number, limits: FetchCacheLimits, preferredKey?: string, reservation?: { key: string; bytes: number }): boolean {
|
|
262
|
+
let dir: string | null;
|
|
263
|
+
try {
|
|
264
|
+
dir = safeFetchCacheDir(false);
|
|
265
|
+
} catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
if (!dir) return true;
|
|
269
|
+
|
|
270
|
+
let entries: string[];
|
|
271
|
+
try {
|
|
272
|
+
entries = readdirSync(dir);
|
|
273
|
+
} catch {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const files: CacheFile[] = [];
|
|
278
|
+
for (const entry of entries) {
|
|
279
|
+
if (!CACHE_KEY_PATTERN.test(entry) && !CACHE_TMP_PATTERN.test(entry)) continue;
|
|
280
|
+
const path = join(dir, entry);
|
|
281
|
+
let opened: ReturnType<typeof openRegularFile>;
|
|
282
|
+
try {
|
|
283
|
+
opened = openRegularFile(path);
|
|
284
|
+
} catch {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
enforceFileMode(opened.fd);
|
|
289
|
+
} catch {
|
|
290
|
+
closeSync(opened.fd);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
closeSync(opened.fd);
|
|
294
|
+
const file = { name: entry, size: opened.info.size, mtimeMs: opened.info.mtimeMs, dev: opened.info.dev, ino: opened.info.ino };
|
|
295
|
+
if (now - file.mtimeMs >= CACHE_TTL_MS) {
|
|
296
|
+
const removed = unlinkCacheFile(dir, file);
|
|
297
|
+
if (removed !== "removed" && removed !== "missing") return false;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (CACHE_KEY_PATTERN.test(entry)) files.push(file);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
files.sort((a, b) => a.mtimeMs - b.mtimeMs || a.name.localeCompare(b.name));
|
|
304
|
+
const projectedUsage = () => {
|
|
305
|
+
const replaced = reservation ? files.find((file) => file.name === reservation.key) : undefined;
|
|
306
|
+
return {
|
|
307
|
+
entries: files.length + (reservation && !replaced ? 1 : 0),
|
|
308
|
+
bytes: files.reduce((total, file) => total + file.size, 0) + (reservation ? reservation.bytes - (replaced?.size ?? 0) : 0),
|
|
309
|
+
};
|
|
310
|
+
};
|
|
311
|
+
const attempted = new Set<string>();
|
|
312
|
+
let usage = projectedUsage();
|
|
313
|
+
while (usage.entries > limits.maxEntries || usage.bytes > limits.maxBytes) {
|
|
314
|
+
const index = files.findIndex((file) => file.name !== preferredKey && !attempted.has(file.name));
|
|
315
|
+
if (index < 0) break;
|
|
316
|
+
const file = files[index];
|
|
317
|
+
attempted.add(file.name);
|
|
318
|
+
const removed = unlinkCacheFile(dir, file);
|
|
319
|
+
if (removed === "removed" || removed === "missing") files.splice(index, 1);
|
|
320
|
+
usage = projectedUsage();
|
|
321
|
+
}
|
|
322
|
+
return usage.entries <= limits.maxEntries && usage.bytes <= limits.maxBytes;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function writeFetchCache(data: StoredSearchData & { urls: ExtractedContent[] }): FetchCacheRef {
|
|
326
|
+
const limits = DEFAULT_CACHE_LIMITS;
|
|
327
|
+
const serialized = JSON.stringify(data);
|
|
328
|
+
const size = Buffer.byteLength(serialized);
|
|
329
|
+
if (size > limits.maxBytes) throw new Error(`Fetched content cache entry exceeds ${limits.maxBytes} bytes`);
|
|
330
|
+
|
|
331
|
+
const dir = safeFetchCacheDir(true);
|
|
332
|
+
const key = cacheKeyForId(data.id);
|
|
333
|
+
if (!pruneFetchCache(Date.now(), limits, key, { key, bytes: size })) {
|
|
334
|
+
throw new Error("Fetched content cache could not reserve space for a new entry");
|
|
335
|
+
}
|
|
336
|
+
const finalPath = join(dir, key);
|
|
337
|
+
const tmpName = `${key}.${process.pid}.${Date.now()}.${randomBytes(16).toString("hex")}.tmp`;
|
|
338
|
+
const tmpPath = join(dir, tmpName);
|
|
339
|
+
let fd: number | null = null;
|
|
340
|
+
let tmpFile: CacheFile | null = null;
|
|
341
|
+
let renamed = false;
|
|
342
|
+
try {
|
|
343
|
+
fd = openSync(tmpPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | O_NOFOLLOW, 0o600);
|
|
344
|
+
const tmpInfo = fstatSync(fd);
|
|
345
|
+
tmpFile = { name: tmpName, size: tmpInfo.size, mtimeMs: tmpInfo.mtimeMs, dev: tmpInfo.dev, ino: tmpInfo.ino };
|
|
346
|
+
enforceFileMode(fd);
|
|
347
|
+
writeFileSync(fd, serialized, "utf8");
|
|
348
|
+
fsyncSync(fd);
|
|
349
|
+
closeSync(fd);
|
|
350
|
+
fd = null;
|
|
351
|
+
safeFetchCacheDir(false);
|
|
352
|
+
renameSync(tmpPath, finalPath);
|
|
353
|
+
renamed = true;
|
|
354
|
+
const written = lstatSync(finalPath);
|
|
355
|
+
if (!written.isFile() || written.dev !== tmpFile.dev || written.ino !== tmpFile.ino) {
|
|
356
|
+
throw new Error("Fetched content cache entry changed after writing");
|
|
357
|
+
}
|
|
358
|
+
if (!pruneFetchCache(Date.now(), limits, key)) {
|
|
359
|
+
throw new Error("Fetched content cache could not meet its limits after writing");
|
|
360
|
+
}
|
|
361
|
+
} catch (err) {
|
|
362
|
+
if (fd !== null) try { closeSync(fd); } catch {}
|
|
363
|
+
if (tmpFile) unlinkCacheFile(dir, { ...tmpFile, name: renamed ? key : tmpName });
|
|
364
|
+
throw err;
|
|
365
|
+
}
|
|
366
|
+
return { version: FETCH_CACHE_VERSION, key, storedAt: Date.now() };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function cacheWriteError(err: unknown): string {
|
|
370
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
371
|
+
return `Failed to write fetched content cache: ${message}`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function createFetchSessionData(data: StoredSearchData & { urls: ExtractedContent[] }, ref: FetchCacheRef | null, cacheError?: string): StoredSearchData {
|
|
375
|
+
return {
|
|
376
|
+
id: data.id,
|
|
377
|
+
type: "fetch",
|
|
378
|
+
timestamp: data.timestamp,
|
|
379
|
+
urlMetadata: metadataForUrls(data.urls),
|
|
380
|
+
...(ref ? { fetchCache: ref } : {}),
|
|
381
|
+
...(cacheError ? { fetchCacheError: truncateMetadataText(cacheError) } : {}),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function fetchUrlMetadata(data: StoredSearchData): StoredFetchUrlMetadata[] {
|
|
386
|
+
if (data.urlMetadata) return data.urlMetadata;
|
|
387
|
+
return isInlineFetchData(data) ? metadataForUrls(data.urls) : [];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function unavailableFetchData(data: StoredSearchData, reason: string): StoredSearchData {
|
|
391
|
+
return {
|
|
392
|
+
...data,
|
|
393
|
+
urls: fetchUrlMetadata(data).map((meta) => ({
|
|
394
|
+
url: meta.url,
|
|
395
|
+
title: meta.title,
|
|
396
|
+
content: "",
|
|
397
|
+
error: reason,
|
|
398
|
+
...(meta.mimeType ? { mimeType: meta.mimeType } : {}),
|
|
399
|
+
...(typeof meta.status === "number" ? { status: meta.status } : {}),
|
|
400
|
+
...(typeof meta.duration === "number" ? { duration: meta.duration } : {}),
|
|
401
|
+
})),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function readCachedFetchData(data: StoredSearchData, now = Date.now()): StoredSearchData {
|
|
406
|
+
if (data.type !== "fetch") return data;
|
|
407
|
+
if (now - data.timestamp >= CACHE_TTL_MS) {
|
|
408
|
+
return unavailableFetchData(data, "Cached fetched content is missing or expired");
|
|
409
|
+
}
|
|
410
|
+
if (isInlineFetchData(data)) return data;
|
|
411
|
+
if (!data.fetchCache) {
|
|
412
|
+
return unavailableFetchData(data, data.fetchCacheError ?? "Cached fetched content is unavailable");
|
|
413
|
+
}
|
|
414
|
+
const path = fetchCachePath(data.fetchCache.key);
|
|
415
|
+
if (!path) return unavailableFetchData(data, "Cached fetched content is missing or expired");
|
|
416
|
+
let fd: number | null = null;
|
|
417
|
+
try {
|
|
418
|
+
if (!safeFetchCacheDir(false)) return unavailableFetchData(data, "Cached fetched content is missing or expired");
|
|
419
|
+
const opened = openRegularFile(path);
|
|
420
|
+
fd = opened.fd;
|
|
421
|
+
enforceFileMode(fd);
|
|
422
|
+
const parsed: unknown = JSON.parse(readFileSync(fd, "utf8"));
|
|
423
|
+
if (!isValidStoredData(parsed) || parsed.type !== "fetch" || parsed.id !== data.id || !isInlineFetchData(parsed)) {
|
|
424
|
+
return unavailableFetchData(data, "Cached fetched content is invalid");
|
|
425
|
+
}
|
|
426
|
+
return { ...parsed, fetchCache: data.fetchCache, urlMetadata: data.urlMetadata };
|
|
427
|
+
} catch (err) {
|
|
428
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
429
|
+
return unavailableFetchData(data, "Cached fetched content is missing or expired");
|
|
430
|
+
}
|
|
431
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
432
|
+
return unavailableFetchData(data, `Cached fetched content could not be read: ${message}`);
|
|
433
|
+
} finally {
|
|
434
|
+
if (fd !== null) try { closeSync(fd); } catch {}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function pruneExpiredFetchCache(now = Date.now(), requestedLimits?: Partial<FetchCacheLimits>): void {
|
|
439
|
+
const limits = cacheLimits(requestedLimits);
|
|
440
|
+
try { pruneFetchCache(now, limits); } catch {}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function storeResult(id: string, data: StoredSearchData): void {
|
|
444
|
+
storedResults.set(id, data);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function storeFetchedContentResult(id: string, data: StoredSearchData & { type: "fetch"; urls: ExtractedContent[] }): StoredSearchData {
|
|
448
|
+
let ref: FetchCacheRef | null = null;
|
|
449
|
+
let cacheError: string | undefined;
|
|
450
|
+
try {
|
|
451
|
+
ref = writeFetchCache(data);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
cacheError = cacheWriteError(err);
|
|
454
|
+
}
|
|
455
|
+
storedResults.set(id, ref ? { ...data, fetchCache: ref, urlMetadata: metadataForUrls(data.urls) } : { ...data, fetchCacheError: cacheError });
|
|
456
|
+
return createFetchSessionData(data, ref, cacheError);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function getResult(id: string): StoredSearchData | null {
|
|
460
|
+
const data = storedResults.get(id);
|
|
461
|
+
if (!data) return null;
|
|
462
|
+
const loaded = readCachedFetchData(data);
|
|
463
|
+
if (loaded !== data) storedResults.set(id, loaded);
|
|
464
|
+
return loaded;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function getAllResults(): StoredSearchData[] {
|
|
468
|
+
return Array.from(storedResults.values());
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function deleteResult(id: string): boolean {
|
|
472
|
+
const data = storedResults.get(id);
|
|
473
|
+
if (data?.fetchCache) {
|
|
474
|
+
try {
|
|
475
|
+
const dir = safeFetchCacheDir(false);
|
|
476
|
+
const path = fetchCachePath(data.fetchCache.key);
|
|
477
|
+
if (dir && path) {
|
|
478
|
+
const info = lstatSync(path);
|
|
479
|
+
if (!info.isSymbolicLink() && info.isFile()) {
|
|
480
|
+
unlinkCacheFile(dir, { name: data.fetchCache.key, size: info.size, mtimeMs: info.mtimeMs, dev: info.dev, ino: info.ino });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
} catch {}
|
|
484
|
+
}
|
|
485
|
+
return storedResults.delete(id);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function clearResults(): void {
|
|
489
|
+
storedResults.clear();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function isValidStoredData(data: unknown): data is StoredSearchData {
|
|
493
|
+
if (!data || typeof data !== "object") return false;
|
|
494
|
+
const d = data as Record<string, unknown>;
|
|
495
|
+
if (typeof d.id !== "string" || !d.id) return false;
|
|
496
|
+
if (d.type !== "search" && d.type !== "fetch" && d.type !== "research") return false;
|
|
497
|
+
if (typeof d.timestamp !== "number") return false;
|
|
498
|
+
if (d.type === "search" && !Array.isArray(d.queries)) return false;
|
|
499
|
+
if (d.type === "fetch") {
|
|
500
|
+
if (Array.isArray(d.urls)) return d.urls.every(isInlineFetchedUrl);
|
|
501
|
+
if (!Array.isArray(d.urlMetadata) || !d.urlMetadata.every(isStoredFetchUrlMetadata)) return false;
|
|
502
|
+
return d.fetchCache === undefined ? d.fetchCacheError === undefined || typeof d.fetchCacheError === "string" : isFetchCacheRef(d.fetchCache);
|
|
503
|
+
}
|
|
504
|
+
if (d.type === "research" && (!d.artifact || typeof d.artifact !== "object")) return false;
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function restoreFromSession(ctx: ExtensionContext): void {
|
|
509
|
+
storedResults.clear();
|
|
510
|
+
const now = Date.now();
|
|
511
|
+
pruneExpiredFetchCache(now);
|
|
512
|
+
|
|
513
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
514
|
+
if (entry.type === "custom" && entry.customType === "web-search-results") {
|
|
515
|
+
const data = entry.data;
|
|
516
|
+
if (isValidStoredData(data) && now - data.timestamp < CACHE_TTL_MS) {
|
|
517
|
+
storedResults.set(data.id, data);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
6
|
+
|
|
7
|
+
export type SummaryThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
8
|
+
|
|
9
|
+
interface SummaryModelScopeContext {
|
|
10
|
+
cwd: string;
|
|
11
|
+
isProjectTrusted(): boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ModelLike {
|
|
15
|
+
provider: string;
|
|
16
|
+
id: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ModelRegistryLike<T extends ModelLike = ModelLike> {
|
|
20
|
+
find(provider: string, id: string): T | undefined;
|
|
21
|
+
getAvailable(): readonly T[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a model through its native provider or a provider that routes that model ID.
|
|
26
|
+
*
|
|
27
|
+
* The direct registry fallback preserves explicit/native model resolution when a
|
|
28
|
+
* provider's availability snapshot does not include the configured model. Callers
|
|
29
|
+
* continue to apply their existing enabled-model and authentication checks.
|
|
30
|
+
*/
|
|
31
|
+
export function findModelWithProviderRouting<T extends ModelLike>(
|
|
32
|
+
registry: ModelRegistryLike<T>,
|
|
33
|
+
provider: string,
|
|
34
|
+
id: string,
|
|
35
|
+
): T | undefined {
|
|
36
|
+
const available = registry.getAvailable();
|
|
37
|
+
const direct = available.find(model => model.provider === provider && model.id === id);
|
|
38
|
+
if (direct) return direct;
|
|
39
|
+
|
|
40
|
+
const routedId = `${provider}/${id}`;
|
|
41
|
+
// If multiple routers expose the same model ID, Pi's available-model ordering
|
|
42
|
+
// determines which route is selected. An explicit provider/model selector can
|
|
43
|
+
// select a specific route when that distinction matters.
|
|
44
|
+
const routed = available.find(model => model.id === routedId);
|
|
45
|
+
return routed ?? registry.find(provider, id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getAgentDir(): string {
|
|
49
|
+
return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readSettings(path: string): Record<string, unknown> {
|
|
53
|
+
if (!existsSync(path)) return {};
|
|
54
|
+
const raw = readFileSync(path, "utf8");
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
57
|
+
} catch (err) {
|
|
58
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
59
|
+
throw new Error(`Failed to parse ${path}: ${message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function loadEnabledModelPatterns(ctx: SummaryModelScopeContext): string[] | null {
|
|
64
|
+
const globalSettings = readSettings(join(getAgentDir(), "settings.json"));
|
|
65
|
+
const projectSettings = ctx.isProjectTrusted()
|
|
66
|
+
? readSettings(join(ctx.cwd, ".pi", "settings.json"))
|
|
67
|
+
: {};
|
|
68
|
+
const value = Object.hasOwn(projectSettings, "enabledModels")
|
|
69
|
+
? projectSettings.enabledModels
|
|
70
|
+
: globalSettings.enabledModels;
|
|
71
|
+
if (value === undefined) return null;
|
|
72
|
+
if (!Array.isArray(value)) throw new Error("enabledModels must be an array");
|
|
73
|
+
return value
|
|
74
|
+
.filter((item): item is string => typeof item === "string")
|
|
75
|
+
.map(item => item.trim())
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function summaryModelValue(model: ModelLike): string {
|
|
80
|
+
return `${model.provider}/${model.id}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function splitThinkingSuffix(value: string): { value: string; thinkingLevel?: SummaryThinkingLevel } {
|
|
84
|
+
const index = value.lastIndexOf(":");
|
|
85
|
+
if (index < 0) return { value };
|
|
86
|
+
const suffix = value.slice(index + 1);
|
|
87
|
+
return THINKING_LEVELS.has(suffix)
|
|
88
|
+
? { value: value.slice(0, index), thinkingLevel: suffix as SummaryThinkingLevel }
|
|
89
|
+
: { value };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function stripThinkingSuffix(pattern: string): string {
|
|
93
|
+
return splitThinkingSuffix(pattern).value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function globToRegExp(pattern: string): RegExp {
|
|
97
|
+
let source = "^";
|
|
98
|
+
for (const char of pattern) {
|
|
99
|
+
if (char === "*") {
|
|
100
|
+
source += ".*";
|
|
101
|
+
} else if (char === "?") {
|
|
102
|
+
source += ".";
|
|
103
|
+
} else {
|
|
104
|
+
source += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return new RegExp(`${source}$`, "i");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function modelMatchesEnabledPatterns(model: ModelLike, patterns: string[] | null): boolean {
|
|
111
|
+
if (patterns === null) return true;
|
|
112
|
+
const value = summaryModelValue(model).toLowerCase();
|
|
113
|
+
const id = model.id.toLowerCase();
|
|
114
|
+
for (const rawPattern of patterns) {
|
|
115
|
+
const pattern = stripThinkingSuffix(rawPattern.trim()).toLowerCase();
|
|
116
|
+
if (!pattern) continue;
|
|
117
|
+
if (pattern.includes("*") || pattern.includes("?")) {
|
|
118
|
+
const regex = globToRegExp(pattern);
|
|
119
|
+
if (regex.test(value) || regex.test(id)) return true;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (pattern === value || pattern === id) return true;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|