@folio-agent/handler 0.3.1 → 0.4.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.
@@ -1,11 +1,24 @@
1
+ import { readFile, stat } from "node:fs/promises";
1
2
  import { createUrlMatcher } from "./glob.js";
2
3
  import { htmlToText } from "./html-to-text.js";
3
4
  import { readTextFile, scanDist, scanKnowledgeDir } from "./scan-dist.js";
4
5
  import { DEFAULT_TOKEN_WARNING_THRESHOLD, estimateTokens } from "./token-count.js";
5
6
  import { scanZennArticles } from "./zenn.js";
7
+ async function pathExists(path) {
8
+ try {
9
+ await stat(path);
10
+ return true;
11
+ }
12
+ catch (error) {
13
+ if (error.code === "ENOENT")
14
+ return false;
15
+ throw error;
16
+ }
17
+ }
6
18
  export async function generateKnowledge(config) {
7
19
  const matchesUrl = createUrlMatcher(config);
8
20
  const pages = [];
21
+ const warnings = [];
9
22
  const distFiles = await scanDist(config.distDir);
10
23
  for (const file of distFiles.filter((f) => matchesUrl(f.urlPath))) {
11
24
  const html = await readTextFile(file.absolutePath);
@@ -20,12 +33,20 @@ export async function generateKnowledge(config) {
20
33
  }
21
34
  }
22
35
  if (config.zenn) {
23
- pages.push(...(await scanZennArticles(config.zenn)));
36
+ if (await pathExists(config.zenn.articlesDir)) {
37
+ pages.push(...(await scanZennArticles(config.zenn)));
38
+ }
39
+ else if (config.zennSnapshotPath) {
40
+ const snapshot = JSON.parse(await readFile(config.zennSnapshotPath, "utf-8"));
41
+ pages.push(...snapshot);
42
+ }
43
+ else {
44
+ warnings.push(`articlesDir not found: ${config.zenn.articlesDir} — skipping zenn ingest`);
45
+ }
24
46
  }
25
47
  pages.sort((a, b) => a.url.localeCompare(b.url));
26
48
  const estimatedTokens = estimateTokens(pages.map((p) => p.text).join("\n"));
27
49
  const threshold = config.tokenWarningThreshold ?? DEFAULT_TOKEN_WARNING_THRESHOLD;
28
- const warnings = [];
29
50
  if (estimatedTokens > threshold) {
30
51
  warnings.push(`estimated knowledge size (${estimatedTokens} tokens) exceeds the warning threshold (${threshold}). ` +
31
52
  "Consider narrowing include globs, or switching to a RAG-based approach.");
@@ -10,6 +10,13 @@ export interface IngestConfig {
10
10
  knowledgeDir?: string;
11
11
  /** Zenn CLI articles/ ingestion. Omit to skip Zenn articles entirely. */
12
12
  zenn?: ZennIngestConfig;
13
+ /**
14
+ * Path to a snapshot JSON produced by `folio-agent-sync-zenn`. Used as a
15
+ * fallback source of Zenn pages in environments where `zenn.articlesDir`
16
+ * cannot be read (e.g. CI, where the articles directory often comes from a
17
+ * separate/private repo that isn't checked out).
18
+ */
19
+ zennSnapshotPath?: string;
13
20
  /** Token threshold above which the CLI warns that CAG may no longer fit. */
14
21
  tokenWarningThreshold?: number;
15
22
  /** UI language captured by `folio-agent-init`. Ignored by ingest itself. */
@@ -1,2 +1,15 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ export interface DevVarsAndGitignorePlan {
3
+ nextDevVars: string | undefined;
4
+ gitignoreResult: {
5
+ content: string;
6
+ changed: boolean;
7
+ };
8
+ }
9
+ /**
10
+ * Computes what to write to `.dev.vars` and `.gitignore`. `.gitignore` protection for
11
+ * `.dev.vars` is unconditional — independent of whether a Gemini API key was entered this run —
12
+ * because a skipped key is commonly filled in by hand afterward per the wizard's own guidance
13
+ * (see `geminiApiKeySkipNote`), and by then the file must already be gitignored.
14
+ */
15
+ export declare function planDevVarsAndGitignore(geminiApiKey: string | undefined, devVarsContent: string, gitignoreContent: string): DevVarsAndGitignorePlan;
package/dist/init/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { existsSync, statSync } from "node:fs";
3
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  import * as clack from "@clack/prompts";
6
7
  import { DEFAULT_WIZARD_ANSWERS, runWizard } from "./questions.js";
7
8
  import { appendIngestToBuildScript, buildApiRouteTemplate, buildConfigJson, buildThemeCss, deriveEndpointPath, ensureGitignoreEntry, PUBLIC_DIR_NAME, resolveThemeCssPath, THEME_CSS_FILENAME, upsertDevVar, } from "./writers.js";
@@ -71,6 +72,17 @@ async function readJson(path) {
71
72
  async function readTextOrEmpty(path) {
72
73
  return existsSync(path) ? readFile(path, "utf-8") : "";
73
74
  }
75
+ /**
76
+ * Computes what to write to `.dev.vars` and `.gitignore`. `.gitignore` protection for
77
+ * `.dev.vars` is unconditional — independent of whether a Gemini API key was entered this run —
78
+ * because a skipped key is commonly filled in by hand afterward per the wizard's own guidance
79
+ * (see `geminiApiKeySkipNote`), and by then the file must already be gitignored.
80
+ */
81
+ export function planDevVarsAndGitignore(geminiApiKey, devVarsContent, gitignoreContent) {
82
+ const nextDevVars = geminiApiKey ? upsertDevVar(devVarsContent, "GEMINI_API_KEY", geminiApiKey) : undefined;
83
+ const gitignoreResult = ensureGitignoreEntry(gitignoreContent, DEV_VARS_PATH);
84
+ return { nextDevVars, gitignoreResult };
85
+ }
74
86
  function seedDefaults(previous) {
75
87
  if (!previous)
76
88
  return DEFAULT_WIZARD_ANSWERS;
@@ -107,10 +119,7 @@ async function main() {
107
119
  const scripts = pkg.scripts ?? {};
108
120
  const nextBuildScript = appendIngestToBuildScript(scripts.build, CONFIG_PATH, KNOWLEDGE_OUTPUT_PATH);
109
121
  const buildScriptChanged = nextBuildScript !== (scripts.build ?? "");
110
- const nextDevVars = answers.geminiApiKey
111
- ? upsertDevVar(await readTextOrEmpty(DEV_VARS_PATH), "GEMINI_API_KEY", answers.geminiApiKey)
112
- : undefined;
113
- const gitignoreResult = nextDevVars !== undefined ? ensureGitignoreEntry(await readTextOrEmpty(GITIGNORE_PATH), DEV_VARS_PATH) : undefined;
122
+ const { nextDevVars, gitignoreResult } = planDevVarsAndGitignore(answers.geminiApiKey, await readTextOrEmpty(DEV_VARS_PATH), await readTextOrEmpty(GITIGNORE_PATH));
114
123
  const summaryLines = [
115
124
  text.summaryConfigLine,
116
125
  text.summaryThemeLine(themeCssPath),
@@ -123,8 +132,8 @@ async function main() {
123
132
  ];
124
133
  if (nextDevVars !== undefined) {
125
134
  summaryLines.push(text.summaryDevVarsLine);
126
- summaryLines.push(gitignoreResult?.changed ? text.summaryGitignoreAppendedLine : text.summaryGitignoreAlreadyPresentLine);
127
135
  }
136
+ summaryLines.push(gitignoreResult.changed ? text.summaryGitignoreAppendedLine : text.summaryGitignoreAlreadyPresentLine);
128
137
  clack.note(summaryLines.join("\n"), text.summaryTitle);
129
138
  const shouldWrite = await clack.confirm({ message: text.confirmMessage, initialValue: true });
130
139
  if (clack.isCancel(shouldWrite) || !shouldWrite) {
@@ -143,9 +152,9 @@ async function main() {
143
152
  }
144
153
  if (nextDevVars !== undefined) {
145
154
  await writeFile(DEV_VARS_PATH, nextDevVars);
146
- if (gitignoreResult?.changed) {
147
- await writeFile(GITIGNORE_PATH, gitignoreResult.content);
148
- }
155
+ }
156
+ if (gitignoreResult.changed) {
157
+ await writeFile(GITIGNORE_PATH, gitignoreResult.content);
149
158
  }
150
159
  const endpoint = deriveEndpointPath(answers.apiRoutePath ?? DEFAULT_WIZARD_ANSWERS.apiRoutePath ?? "functions/api/chat.ts");
151
160
  const snippetLines = [
@@ -169,13 +178,15 @@ async function main() {
169
178
  }
170
179
  clack.note(introNotes.join("\n\n"));
171
180
  console.log(`\n${snippet}\n`);
172
- if (nextDevVars !== undefined) {
173
- clack.note(gitignoreResult?.changed ? text.gitignoreAppendedNote : text.gitignoreAlreadyPresentNote);
174
- }
181
+ clack.note(gitignoreResult.changed ? text.gitignoreAppendedNote : text.gitignoreAlreadyPresentNote);
175
182
  clack.note(text.d1Note);
176
183
  clack.outro(text.outroTitle);
177
184
  }
178
- main().catch((error) => {
179
- console.error(error);
180
- process.exitCode = 1;
181
- });
185
+ // Only run the wizard when executed directly (the bin entry point); importing this module for
186
+ // unit tests (e.g. `planDevVarsAndGitignore`) must not trigger the interactive CLI as a side effect.
187
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
188
+ main().catch((error) => {
189
+ console.error(error);
190
+ process.exitCode = 1;
191
+ });
192
+ }
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import type { KnowledgePage } from "../ingest/types.js";
3
+ /**
4
+ * Reads `config.zenn` from `configPath`, scans the configured articles/
5
+ * directory, and writes the resulting `KnowledgePage[]` to `outputPath` as
6
+ * JSON. Throws if `config.zenn` is not set.
7
+ */
8
+ export declare function syncZennSnapshot(configPath: string, outputPath: string): Promise<KnowledgePage[]>;
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { scanZennArticles } from "../ingest/zenn.js";
4
+ /**
5
+ * Reads `config.zenn` from `configPath`, scans the configured articles/
6
+ * directory, and writes the resulting `KnowledgePage[]` to `outputPath` as
7
+ * JSON. Throws if `config.zenn` is not set.
8
+ */
9
+ export async function syncZennSnapshot(configPath, outputPath) {
10
+ const config = JSON.parse(await readFile(configPath, "utf-8"));
11
+ if (!config.zenn) {
12
+ throw new Error("config.zenn is not set");
13
+ }
14
+ const pages = await scanZennArticles(config.zenn);
15
+ await writeFile(outputPath, JSON.stringify(pages, null, 2));
16
+ return pages;
17
+ }
18
+ async function main() {
19
+ const configPath = process.argv[2];
20
+ const outputPath = process.argv[3];
21
+ if (!configPath || !outputPath) {
22
+ console.error("usage: folio-agent-sync-zenn <config.json> <output.json>");
23
+ process.exitCode = 1;
24
+ return;
25
+ }
26
+ const pages = await syncZennSnapshot(configPath, outputPath);
27
+ console.log(`wrote ${pages.length} zenn page(s) -> ${outputPath}`);
28
+ }
29
+ // Only run when executed directly (e.g. `folio-agent-sync-zenn ...`), not
30
+ // when imported by tests.
31
+ if (import.meta.url === `file://${process.argv[1]}`) {
32
+ main().catch((error) => {
33
+ console.error(error);
34
+ process.exitCode = 1;
35
+ });
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@folio-agent/handler",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Chat handler (LangGraph.js on Cloudflare Workers) + knowledge ingestion CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,7 +12,8 @@
12
12
  ],
13
13
  "bin": {
14
14
  "folio-agent-ingest": "dist/ingest/cli.js",
15
- "folio-agent-init": "dist/init/cli.js"
15
+ "folio-agent-init": "dist/init/cli.js",
16
+ "folio-agent-sync-zenn": "dist/sync/cli.js"
16
17
  },
17
18
  "repository": {
18
19
  "type": "git",