@folio-agent/handler 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ingest/generate.js +25 -0
- package/dist/ingest/github.d.ts +18 -0
- package/dist/ingest/github.js +106 -0
- package/dist/ingest/types.d.ts +13 -1
- package/dist/sync/github-cli.d.ts +8 -0
- package/dist/sync/github-cli.js +42 -0
- package/package.json +8 -7
package/dist/ingest/generate.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { collectGithubRepos, fetchGithubReadmes } from "./github.js";
|
|
2
3
|
import { createUrlMatcher } from "./glob.js";
|
|
3
4
|
import { htmlToText } from "./html-to-text.js";
|
|
4
5
|
import { readTextFile, scanDist, scanKnowledgeDir } from "./scan-dist.js";
|
|
@@ -19,9 +20,13 @@ export async function generateKnowledge(config) {
|
|
|
19
20
|
const matchesUrl = createUrlMatcher(config);
|
|
20
21
|
const pages = [];
|
|
21
22
|
const warnings = [];
|
|
23
|
+
const githubRepos = new Set();
|
|
22
24
|
const distFiles = await scanDist(config.distDir);
|
|
23
25
|
for (const file of distFiles.filter((f) => matchesUrl(f.urlPath))) {
|
|
24
26
|
const html = await readTextFile(file.absolutePath);
|
|
27
|
+
if (config.github)
|
|
28
|
+
for (const repo of collectGithubRepos(html))
|
|
29
|
+
githubRepos.add(repo);
|
|
25
30
|
const { title, text } = htmlToText(html);
|
|
26
31
|
pages.push({ url: file.urlPath, source: "dist", title, text });
|
|
27
32
|
}
|
|
@@ -44,6 +49,26 @@ export async function generateKnowledge(config) {
|
|
|
44
49
|
warnings.push(`articlesDir not found: ${config.zenn.articlesDir} — skipping zenn ingest`);
|
|
45
50
|
}
|
|
46
51
|
}
|
|
52
|
+
if (config.github) {
|
|
53
|
+
const { pages: githubPages, failures } = await fetchGithubReadmes(githubRepos);
|
|
54
|
+
pages.push(...githubPages);
|
|
55
|
+
if (failures.length > 0) {
|
|
56
|
+
// 初回のビルドではまだスナップショットが無いことがある。無ければ空として扱い、各リポを警告に回す
|
|
57
|
+
const snapshotPath = config.github.snapshotPath;
|
|
58
|
+
const snapshot = snapshotPath && (await pathExists(snapshotPath))
|
|
59
|
+
? JSON.parse(await readFile(snapshotPath, "utf-8"))
|
|
60
|
+
: [];
|
|
61
|
+
for (const failure of failures) {
|
|
62
|
+
const fallback = snapshot.find((p) => p.url === failure.repoUrl);
|
|
63
|
+
if (fallback) {
|
|
64
|
+
pages.push(fallback);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
warnings.push(`${failure.error} — skipping ${failure.repoUrl}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
47
72
|
pages.sort((a, b) => a.url.localeCompare(b.url));
|
|
48
73
|
const estimatedTokens = estimateTokens(pages.map((p) => p.text).join("\n"));
|
|
49
74
|
const threshold = config.tokenWarningThreshold ?? DEFAULT_TOKEN_WARNING_THRESHOLD;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { IngestConfig, KnowledgePage } from "./types.js";
|
|
2
|
+
/** Returns the canonical `https://github.com/<owner>/<repo>` for a repo link, or undefined. */
|
|
3
|
+
export declare function toGithubRepoUrl(href: string): string | undefined;
|
|
4
|
+
/** Collects GitHub repo URLs linked from `<a href>` in the given HTML, without duplicates. */
|
|
5
|
+
export declare function collectGithubRepos(html: string): string[];
|
|
6
|
+
/** Fetches the default README of a repo and turns it into a knowledge page. Throws on failure. */
|
|
7
|
+
export declare function fetchGithubReadme(repoUrl: string): Promise<KnowledgePage>;
|
|
8
|
+
export interface GithubFetchResult {
|
|
9
|
+
pages: KnowledgePage[];
|
|
10
|
+
failures: {
|
|
11
|
+
repoUrl: string;
|
|
12
|
+
error: string;
|
|
13
|
+
}[];
|
|
14
|
+
}
|
|
15
|
+
/** Fetches READMEs for all repos sequentially, separating successes from failures. */
|
|
16
|
+
export declare function fetchGithubReadmes(repoUrls: Iterable<string>): Promise<GithubFetchResult>;
|
|
17
|
+
/** Collects repos linked from the included (non-excluded) pages of `config.distDir`. */
|
|
18
|
+
export declare function collectGithubReposFromDist(config: IngestConfig): Promise<string[]>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { parse } from "node-html-parser";
|
|
2
|
+
import { createUrlMatcher } from "./glob.js";
|
|
3
|
+
import { readTextFile, scanDist } from "./scan-dist.js";
|
|
4
|
+
// リポではない github.com のトップレベルのパス。網羅はしない(取りこぼしは README 取得の 404 で warnings に出る)
|
|
5
|
+
const RESERVED_OWNERS = new Set([
|
|
6
|
+
"about",
|
|
7
|
+
"apps",
|
|
8
|
+
"collections",
|
|
9
|
+
"customer-stories",
|
|
10
|
+
"enterprise",
|
|
11
|
+
"events",
|
|
12
|
+
"explore",
|
|
13
|
+
"features",
|
|
14
|
+
"login",
|
|
15
|
+
"marketplace",
|
|
16
|
+
"new",
|
|
17
|
+
"notifications",
|
|
18
|
+
"orgs",
|
|
19
|
+
"pricing",
|
|
20
|
+
"pulls",
|
|
21
|
+
"issues",
|
|
22
|
+
"search",
|
|
23
|
+
"security",
|
|
24
|
+
"settings",
|
|
25
|
+
"signup",
|
|
26
|
+
"site",
|
|
27
|
+
"sponsors",
|
|
28
|
+
"team",
|
|
29
|
+
"topics",
|
|
30
|
+
"trending",
|
|
31
|
+
]);
|
|
32
|
+
const NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
33
|
+
/** Returns the canonical `https://github.com/<owner>/<repo>` for a repo link, or undefined. */
|
|
34
|
+
export function toGithubRepoUrl(href) {
|
|
35
|
+
let url;
|
|
36
|
+
try {
|
|
37
|
+
url = new URL(href);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
43
|
+
return undefined;
|
|
44
|
+
const host = url.hostname.toLowerCase();
|
|
45
|
+
if (host !== "github.com" && host !== "www.github.com")
|
|
46
|
+
return undefined;
|
|
47
|
+
const [owner, rawRepo] = url.pathname.split("/").filter(Boolean);
|
|
48
|
+
if (!owner || !rawRepo)
|
|
49
|
+
return undefined;
|
|
50
|
+
const repo = rawRepo.replace(/\.git$/, "");
|
|
51
|
+
if (!NAME_PATTERN.test(owner) || !NAME_PATTERN.test(repo) || repo === "." || repo === "..")
|
|
52
|
+
return undefined;
|
|
53
|
+
if (RESERVED_OWNERS.has(owner.toLowerCase()))
|
|
54
|
+
return undefined;
|
|
55
|
+
return `https://github.com/${owner}/${repo}`;
|
|
56
|
+
}
|
|
57
|
+
/** Collects GitHub repo URLs linked from `<a href>` in the given HTML, without duplicates. */
|
|
58
|
+
export function collectGithubRepos(html) {
|
|
59
|
+
const repos = new Set();
|
|
60
|
+
for (const anchor of parse(html).querySelectorAll("a[href]")) {
|
|
61
|
+
const repoUrl = toGithubRepoUrl(anchor.getAttribute("href") ?? "");
|
|
62
|
+
if (repoUrl)
|
|
63
|
+
repos.add(repoUrl);
|
|
64
|
+
}
|
|
65
|
+
return [...repos];
|
|
66
|
+
}
|
|
67
|
+
/** Fetches the default README of a repo and turns it into a knowledge page. Throws on failure. */
|
|
68
|
+
export async function fetchGithubReadme(repoUrl) {
|
|
69
|
+
const slug = repoUrl.replace(/^https:\/\/github\.com\//, "");
|
|
70
|
+
const headers = {
|
|
71
|
+
Accept: "application/vnd.github.raw",
|
|
72
|
+
"User-Agent": "folio-agent-ingest",
|
|
73
|
+
};
|
|
74
|
+
const token = process.env.GITHUB_TOKEN;
|
|
75
|
+
if (token)
|
|
76
|
+
headers.Authorization = `Bearer ${token}`;
|
|
77
|
+
const response = await fetch(`https://api.github.com/repos/${slug}/readme`, { headers });
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
throw new Error(`failed to fetch README for ${slug}: HTTP ${response.status}`);
|
|
80
|
+
}
|
|
81
|
+
const text = (await response.text()).trim();
|
|
82
|
+
return { url: repoUrl, source: "github", title: slug, text };
|
|
83
|
+
}
|
|
84
|
+
/** Fetches READMEs for all repos sequentially, separating successes from failures. */
|
|
85
|
+
export async function fetchGithubReadmes(repoUrls) {
|
|
86
|
+
const result = { pages: [], failures: [] };
|
|
87
|
+
for (const repoUrl of repoUrls) {
|
|
88
|
+
try {
|
|
89
|
+
result.pages.push(await fetchGithubReadme(repoUrl));
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
result.failures.push({ repoUrl, error: error instanceof Error ? error.message : String(error) });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
/** Collects repos linked from the included (non-excluded) pages of `config.distDir`. */
|
|
98
|
+
export async function collectGithubReposFromDist(config) {
|
|
99
|
+
const matchesUrl = createUrlMatcher(config);
|
|
100
|
+
const repos = new Set();
|
|
101
|
+
for (const file of (await scanDist(config.distDir)).filter((f) => matchesUrl(f.urlPath))) {
|
|
102
|
+
for (const repo of collectGithubRepos(await readTextFile(file.absolutePath)))
|
|
103
|
+
repos.add(repo);
|
|
104
|
+
}
|
|
105
|
+
return [...repos];
|
|
106
|
+
}
|
package/dist/ingest/types.d.ts
CHANGED
|
@@ -17,6 +17,11 @@ export interface IngestConfig {
|
|
|
17
17
|
* separate/private repo that isn't checked out).
|
|
18
18
|
*/
|
|
19
19
|
zennSnapshotPath?: string;
|
|
20
|
+
/**
|
|
21
|
+
* READMEs of GitHub repos linked from the included pages. Omit to keep ingest offline
|
|
22
|
+
* (no request is made to GitHub).
|
|
23
|
+
*/
|
|
24
|
+
github?: GithubIngestConfig;
|
|
20
25
|
/** Token threshold above which the CLI warns that CAG may no longer fit. */
|
|
21
26
|
tokenWarningThreshold?: number;
|
|
22
27
|
/** UI language captured by `folio-agent-init`. Ignored by ingest itself. */
|
|
@@ -38,7 +43,14 @@ export interface ZennIngestConfig {
|
|
|
38
43
|
/** Base used to build public article URLs, e.g. "https://zenn.dev/username/articles". No trailing slash. */
|
|
39
44
|
baseUrl: string;
|
|
40
45
|
}
|
|
41
|
-
export
|
|
46
|
+
export interface GithubIngestConfig {
|
|
47
|
+
/**
|
|
48
|
+
* Path to a snapshot JSON produced by `folio-agent-sync-github`. Used in place of repos
|
|
49
|
+
* whose README could not be fetched.
|
|
50
|
+
*/
|
|
51
|
+
snapshotPath?: string;
|
|
52
|
+
}
|
|
53
|
+
export type KnowledgeSource = "dist" | "knowledge" | "zenn" | "github";
|
|
42
54
|
export interface KnowledgePage {
|
|
43
55
|
url: string;
|
|
44
56
|
source: KnowledgeSource;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import type { KnowledgePage } from "../ingest/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Reads `configPath`, collects GitHub repos linked from the included pages of `distDir`,
|
|
5
|
+
* fetches their READMEs, and writes the successful ones to `outputPath` as `KnowledgePage[]`.
|
|
6
|
+
* Failed repos are reported on stderr and left out. Throws if `config.github` is not set.
|
|
7
|
+
*/
|
|
8
|
+
export declare function syncGithubSnapshot(configPath: string, outputPath: string): Promise<KnowledgePage[]>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { collectGithubReposFromDist, fetchGithubReadmes } from "../ingest/github.js";
|
|
6
|
+
/**
|
|
7
|
+
* Reads `configPath`, collects GitHub repos linked from the included pages of `distDir`,
|
|
8
|
+
* fetches their READMEs, and writes the successful ones to `outputPath` as `KnowledgePage[]`.
|
|
9
|
+
* Failed repos are reported on stderr and left out. Throws if `config.github` is not set.
|
|
10
|
+
*/
|
|
11
|
+
export async function syncGithubSnapshot(configPath, outputPath) {
|
|
12
|
+
const config = JSON.parse(await readFile(configPath, "utf-8"));
|
|
13
|
+
if (!config.github) {
|
|
14
|
+
throw new Error("config.github is not set");
|
|
15
|
+
}
|
|
16
|
+
const repos = await collectGithubReposFromDist(config);
|
|
17
|
+
const { pages, failures } = await fetchGithubReadmes(repos);
|
|
18
|
+
for (const failure of failures) {
|
|
19
|
+
console.error(`skipped ${failure.repoUrl}: ${failure.error}`);
|
|
20
|
+
}
|
|
21
|
+
await writeFile(outputPath, JSON.stringify(pages, null, 2));
|
|
22
|
+
return pages;
|
|
23
|
+
}
|
|
24
|
+
async function main() {
|
|
25
|
+
const configPath = process.argv[2];
|
|
26
|
+
const outputPath = process.argv[3];
|
|
27
|
+
if (!configPath || !outputPath) {
|
|
28
|
+
console.error("usage: folio-agent-sync-github <config.json> <output.json>");
|
|
29
|
+
console.error("Run after building the site: repos are collected from links in distDir.");
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const pages = await syncGithubSnapshot(configPath, outputPath);
|
|
34
|
+
console.log(`wrote ${pages.length} github page(s) -> ${outputPath}`);
|
|
35
|
+
}
|
|
36
|
+
// node_modules/.bin のシンボリックリンク経由でも動くよう realpath で比べる(sync/cli.ts と同じ理由)
|
|
37
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) {
|
|
38
|
+
main().catch((error) => {
|
|
39
|
+
console.error(error);
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
});
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@folio-agent/handler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Chat handler (LangGraph.js on Cloudflare Workers) + knowledge ingestion CLI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"bin": {
|
|
14
14
|
"folio-agent-ingest": "dist/ingest/cli.js",
|
|
15
15
|
"folio-agent-init": "dist/init/cli.js",
|
|
16
|
-
"folio-agent-sync-zenn": "dist/sync/cli.js"
|
|
16
|
+
"folio-agent-sync-zenn": "dist/sync/cli.js",
|
|
17
|
+
"folio-agent-sync-github": "dist/sync/github-cli.js"
|
|
17
18
|
},
|
|
18
19
|
"repository": {
|
|
19
20
|
"type": "git",
|
|
@@ -39,14 +40,14 @@
|
|
|
39
40
|
"prepublishOnly": "tsc -b --force"
|
|
40
41
|
},
|
|
41
42
|
"dependencies": {
|
|
42
|
-
"@google/genai": "^2.
|
|
43
|
-
"@langchain/langgraph": "^1.
|
|
44
|
-
"node-html-parser": "^9.0.
|
|
43
|
+
"@google/genai": "^2.31.0",
|
|
44
|
+
"@langchain/langgraph": "^1.5.2",
|
|
45
|
+
"node-html-parser": "^9.0.5",
|
|
45
46
|
"picomatch": "^4.0.7"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
|
-
"@cloudflare/workers-types": "^5.
|
|
49
|
+
"@cloudflare/workers-types": "^5.20261025.1",
|
|
49
50
|
"@types/picomatch": "^4.0.2",
|
|
50
|
-
"wrangler": "^4.
|
|
51
|
+
"wrangler": "^4.152.0"
|
|
51
52
|
}
|
|
52
53
|
}
|