@folio-agent/handler 0.3.1 → 0.4.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.
@@ -11,7 +11,7 @@ export declare function buildChatGraph(deps: ChatGraphDeps): import("@langchain/
11
11
  route?: ChatRoute | undefined;
12
12
  overLimit?: boolean | undefined;
13
13
  answer?: string | undefined;
14
- }, "__start__" | "input_guard" | "route_message" | "generate" | "log", {
14
+ }, "__start__" | "generate" | "input_guard" | "log" | "route_message", {
15
15
  input: import("@langchain/langgraph").LastValue<string>;
16
16
  ip: import("@langchain/langgraph").LastValue<string>;
17
17
  route: import("@langchain/langgraph").LastValue<ChatRoute | undefined>;
@@ -24,18 +24,18 @@ export declare function buildChatGraph(deps: ChatGraphDeps): import("@langchain/
24
24
  overLimit: import("@langchain/langgraph").LastValue<boolean>;
25
25
  answer: import("@langchain/langgraph").LastValue<string | undefined>;
26
26
  }, import("@langchain/langgraph").StateDefinition, {
27
+ generate: {
28
+ answer: string;
29
+ };
27
30
  input_guard: {
28
31
  overLimit: true;
29
32
  answer: string;
30
33
  } | {
31
- overLimit: false;
32
34
  answer?: undefined;
35
+ overLimit: false;
33
36
  };
37
+ log: {};
34
38
  route_message: {
35
- route: "thoughts" | "works" | "inquiry";
39
+ route: "inquiry" | "thoughts" | "works";
36
40
  };
37
- generate: {
38
- answer: string;
39
- };
40
- log: {};
41
41
  }, unknown, unknown, []>;
@@ -1,13 +1,13 @@
1
1
  import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
2
2
  import { classifyRoute } from "./route.js";
3
3
  import { DEFAULT_LANGUAGE } from "./types.js";
4
- const RATE_LIMIT_REASON_TEXT = {
5
- ja: { daily: "本日の上限", shortWindow: "直近の質問数の上限" },
6
- en: { daily: "today's limit", shortWindow: "the recent question limit" },
7
- };
8
4
  const OVER_LIMIT_MESSAGE = {
9
- ja: (reasonText) => `${reasonText}に達しました。お急ぎの場合はContactからお問い合わせください。`,
10
- en: (reasonText) => `You've reached ${reasonText}. If it's urgent, please contact us via the Contact page.`,
5
+ ja: (reason, config) => reason === "short_window"
6
+ ? `${config.shortWindowMinutes}分間に${config.shortWindowMax}件までのご質問上限に達しました。少し時間を置いてから再度お試しください。お急ぎの場合はContactからお問い合わせください。`
7
+ : `${config.longWindowHours}時間に${config.longWindowMax}件までのご質問上限に達しました。時間を置いてから再度お試しください。お急ぎの場合はContactからお問い合わせください。`,
8
+ en: (reason, config) => reason === "short_window"
9
+ ? `You've reached the limit of ${config.shortWindowMax} questions per ${config.shortWindowMinutes} minutes. Please try again in a little while. If it's urgent, please contact us via the Contact page.`
10
+ : `You've reached the limit of ${config.longWindowMax} questions per ${config.longWindowHours} hours. Please try again later. If it's urgent, please contact us via the Contact page.`,
11
11
  };
12
12
  const GENERATION_FAILED_MESSAGE = {
13
13
  ja: "本日の受付上限に達したか、一時的な不具合が発生しています。恐れ入りますがContactからお問い合わせください。",
@@ -26,10 +26,9 @@ export function buildChatGraph(deps) {
26
26
  .addNode("input_guard", async (state) => {
27
27
  const result = await deps.checkRateLimit(state.ip);
28
28
  if (!result.allowed) {
29
- const reasonText = result.reason === "daily" ? RATE_LIMIT_REASON_TEXT[language].daily : RATE_LIMIT_REASON_TEXT[language].shortWindow;
30
29
  return {
31
30
  overLimit: true,
32
- answer: OVER_LIMIT_MESSAGE[language](reasonText),
31
+ answer: OVER_LIMIT_MESSAGE[language](result.reason, deps.rateLimitConfig),
33
32
  };
34
33
  }
35
34
  return { overLimit: false };
@@ -4,10 +4,12 @@ import { checkRateLimit } from "./rate-limit.js";
4
4
  import { DEFAULT_RATE_LIMIT_CONFIG } from "./types.js";
5
5
  const MAX_INPUT_LENGTH = 1000;
6
6
  export function createChatHandler(config) {
7
+ const rateLimitConfig = config.rateLimitConfig ?? DEFAULT_RATE_LIMIT_CONFIG;
7
8
  const graph = buildChatGraph({
8
- checkRateLimit: (ip) => checkRateLimit(config.db, ip, new Date(), config.rateLimitConfig ?? DEFAULT_RATE_LIMIT_CONFIG),
9
+ checkRateLimit: (ip) => checkRateLimit(config.db, ip, new Date(), rateLimitConfig),
9
10
  generateAnswer: config.generateAnswer,
10
11
  logChat: (entry) => logChat(config.db, entry),
12
+ rateLimitConfig,
11
13
  language: config.language,
12
14
  });
13
15
  return async (request) => {
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_RATE_LIMIT_CONFIG } from "./types.js";
2
2
  const MINUTE_MS = 60_000;
3
- const DAY_MS = 24 * 60 * MINUTE_MS;
3
+ const HOUR_MS = 60 * MINUTE_MS;
4
4
  async function countSince(db, ip, sinceIso) {
5
5
  const row = await db
6
6
  .prepare("SELECT COUNT(*) as count FROM chat_logs WHERE ip = ? AND created_at >= ? AND over_limit = 0")
@@ -14,10 +14,10 @@ export async function checkRateLimit(db, ip, now = new Date(), config = DEFAULT_
14
14
  if (shortCount >= config.shortWindowMax) {
15
15
  return { allowed: false, reason: "short_window" };
16
16
  }
17
- const dailyStart = new Date(now.getTime() - DAY_MS).toISOString();
18
- const dailyCount = await countSince(db, ip, dailyStart);
19
- if (dailyCount >= config.dailyMax) {
20
- return { allowed: false, reason: "daily" };
17
+ const longWindowStart = new Date(now.getTime() - config.longWindowHours * HOUR_MS).toISOString();
18
+ const longWindowCount = await countSince(db, ip, longWindowStart);
19
+ if (longWindowCount >= config.longWindowMax) {
20
+ return { allowed: false, reason: "long_window" };
21
21
  }
22
22
  return { allowed: true };
23
23
  }
@@ -4,10 +4,11 @@ export declare const DEFAULT_LANGUAGE: Language;
4
4
  export interface RateLimitConfig {
5
5
  shortWindowMinutes: number;
6
6
  shortWindowMax: number;
7
- dailyMax: number;
7
+ longWindowHours: number;
8
+ longWindowMax: number;
8
9
  }
9
10
  export declare const DEFAULT_RATE_LIMIT_CONFIG: RateLimitConfig;
10
- export type RateLimitReason = "short_window" | "daily";
11
+ export type RateLimitReason = "short_window" | "long_window";
11
12
  export interface RateLimitResult {
12
13
  allowed: boolean;
13
14
  reason?: RateLimitReason;
@@ -23,5 +24,6 @@ export interface ChatGraphDeps {
23
24
  checkRateLimit: (ip: string) => Promise<RateLimitResult>;
24
25
  generateAnswer: (input: string, route: ChatRoute) => Promise<string>;
25
26
  logChat: (entry: ChatLogEntry) => Promise<void>;
27
+ rateLimitConfig: RateLimitConfig;
26
28
  language?: Language;
27
29
  }
@@ -1,6 +1,7 @@
1
1
  export const DEFAULT_LANGUAGE = "ja";
2
2
  export const DEFAULT_RATE_LIMIT_CONFIG = {
3
3
  shortWindowMinutes: 10,
4
- shortWindowMax: 3,
5
- dailyMax: 10,
4
+ shortWindowMax: 6,
5
+ longWindowHours: 12,
6
+ longWindowMax: 12,
6
7
  };
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ export declare function main(): Promise<void>;
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
2
3
  import { readFile, writeFile } from "node:fs/promises";
4
+ import { fileURLToPath } from "node:url";
3
5
  import { generateKnowledge } from "./generate.js";
4
- async function main() {
6
+ export async function main() {
5
7
  const configPath = process.argv[2];
6
8
  const outputPath = process.argv[3];
7
9
  if (!configPath || !outputPath) {
@@ -17,7 +19,13 @@ async function main() {
17
19
  console.warn(`warning: ${warning}`);
18
20
  }
19
21
  }
20
- main().catch((error) => {
21
- console.error(error);
22
- process.exitCode = 1;
23
- });
22
+ // Only run when executed directly (e.g. `folio-agent-ingest ...`), not when imported by tests.
23
+ // Compares realpaths, not raw paths: npm's node_modules/.bin/ entries are symlinks, so
24
+ // process.argv[1] stays the symlink path while import.meta.url is already the resolved target
25
+ // (see sync/cli.ts for the fuller rationale).
26
+ if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) {
27
+ main().catch((error) => {
28
+ console.error(error);
29
+ process.exitCode = 1;
30
+ });
31
+ }
@@ -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,16 @@
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;
16
+ export declare function main(): Promise<void>;
package/dist/init/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, statSync } from "node:fs";
2
+ import { existsSync, realpathSync, 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;
@@ -84,7 +96,7 @@ function seedDefaults(previous) {
84
96
  apiRoutePath: undefined,
85
97
  };
86
98
  }
87
- async function main() {
99
+ export async function main() {
88
100
  const previousConfig = await readJson(CONFIG_PATH);
89
101
  const answers = await runWizard(seedDefaults(previousConfig));
90
102
  const text = CLI_TEXT[answers.language];
@@ -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,18 @@ 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
+ // Compares realpaths, not raw paths: npm's node_modules/.bin/ entries are symlinks, so
188
+ // process.argv[1] stays the symlink path while import.meta.url is already the resolved target
189
+ // (see sync/cli.ts for the fuller rationale).
190
+ if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) {
191
+ main().catch((error) => {
192
+ console.error(error);
193
+ process.exitCode = 1;
194
+ });
195
+ }
@@ -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,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 { scanZennArticles } from "../ingest/zenn.js";
6
+ /**
7
+ * Reads `config.zenn` from `configPath`, scans the configured articles/
8
+ * directory, and writes the resulting `KnowledgePage[]` to `outputPath` as
9
+ * JSON. Throws if `config.zenn` is not set.
10
+ */
11
+ export async function syncZennSnapshot(configPath, outputPath) {
12
+ const config = JSON.parse(await readFile(configPath, "utf-8"));
13
+ if (!config.zenn) {
14
+ throw new Error("config.zenn is not set");
15
+ }
16
+ const pages = await scanZennArticles(config.zenn);
17
+ await writeFile(outputPath, JSON.stringify(pages, null, 2));
18
+ return pages;
19
+ }
20
+ async function main() {
21
+ const configPath = process.argv[2];
22
+ const outputPath = process.argv[3];
23
+ if (!configPath || !outputPath) {
24
+ console.error("usage: folio-agent-sync-zenn <config.json> <output.json>");
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+ const pages = await syncZennSnapshot(configPath, outputPath);
29
+ console.log(`wrote ${pages.length} zenn page(s) -> ${outputPath}`);
30
+ }
31
+ // Only run when executed directly (e.g. `folio-agent-sync-zenn ...`), not
32
+ // when imported by tests. Compares realpaths: npm's node_modules/.bin/
33
+ // entries are symlinks, so process.argv[1] stays the symlink path while
34
+ // import.meta.url is already the resolved target — a plain string/URL
35
+ // comparison of the two (matching ingest/cli.ts as-is) never matches, and
36
+ // main() silently never runs when invoked via the published bin name.
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.1",
3
+ "version": "0.4.1",
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",
@@ -39,14 +40,14 @@
39
40
  },
40
41
  "dependencies": {
41
42
  "@clack/prompts": "^1.7.0",
42
- "@google/genai": "^2.10.0",
43
- "@langchain/langgraph": "^1.4.7",
44
- "node-html-parser": "^8.0.4",
43
+ "@google/genai": "^2.15.0",
44
+ "@langchain/langgraph": "^1.4.8",
45
+ "node-html-parser": "^9.0.1",
45
46
  "picomatch": "^4.0.5"
46
47
  },
47
48
  "devDependencies": {
48
- "@cloudflare/workers-types": "^5.20260705.1",
49
+ "@cloudflare/workers-types": "^5.20260801.1",
49
50
  "@types/picomatch": "^4.0.2",
50
- "wrangler": "^4.107.0"
51
+ "wrangler": "^4.118.0"
51
52
  }
52
53
  }