fapony 0.1.0 → 0.1.2

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.
@@ -112,6 +112,7 @@ export function cmdInstallOpencode(
112
112
  const skillsDir = claudeSkillsDir(getHome);
113
113
  reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
114
114
  installReadHintPlugin(dryRun, getHome);
115
+ installCommitHintPlugin(dryRun, getHome);
115
116
  return;
116
117
  }
117
118
 
@@ -134,6 +135,7 @@ export function cmdInstallOpencode(
134
135
  }
135
136
  console.log(computeDiff(before, after));
136
137
  installReadHintPlugin(dryRun, getHome);
138
+ installCommitHintPlugin(dryRun, getHome);
137
139
  return;
138
140
  }
139
141
 
@@ -154,6 +156,7 @@ export function cmdInstallOpencode(
154
156
  const skillsDir = claudeSkillsDir(getHome);
155
157
  reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
156
158
  installReadHintPlugin(dryRun, getHome);
159
+ installCommitHintPlugin(dryRun, getHome);
157
160
  }
158
161
 
159
162
  /**
@@ -164,35 +167,86 @@ export function cmdInstallOpencode(
164
167
  * throws, never dedupes ("you already read this" goes false after context
165
168
  * compaction — a hook that guesses wrong must never trap the agent).
166
169
  *
167
- * Logic lives in src/hook.ts (readHintFor) — the plugin imports it from the
168
- * install root (path baked at install time), same one-copy-per-client shape
169
- * as the skills symlinks: a git pull in INSTALL_ROOT updates every client.
170
- * Best-effort, same policy as the claude hooks: an existing file that isn't
171
- * fapony's is never overwritten, and a failure never fails the install.
170
+ * Logic lives in src/hook.ts (readHintFor + readContextLines) — the plugin
171
+ * imports it from the install root (path baked at install time), same
172
+ * one-copy-per-client shape as the skills symlinks: a git pull in
173
+ * INSTALL_ROOT updates every client. Mirrors Claude's cmdHookReadHint
174
+ * (PreToolUse), which appends both the size hint and the debt/mem context —
175
+ * OpenCode was missing the second half until now (only the size hint was
176
+ * wired), so a convention debt on the file you just opened, or a mem-log
177
+ * row about it, never reached OpenCode even though the detector already
178
+ * runs on every read. Best-effort, same policy as the claude hooks: an
179
+ * existing file that isn't fapony's is never overwritten, and a failure
180
+ * never fails the install.
172
181
  */
173
182
  export function readHintPluginSource(root: string): string {
174
183
  const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
175
184
  return `// fapony read hint — annotates full-file reads of large source files with a
176
- // factual review-seed pointer. Annotate only: never blocks, never dedupes.
185
+ // factual review-seed pointer, plus any convention debt or mem-log row about
186
+ // the file. Annotate only: never blocks, never dedupes.
177
187
  // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
178
- import { readHintFor } from ${hookModule};
188
+ import { readHintFor, readContextLines } from ${hookModule};
179
189
 
180
190
  export const FaponyReadHint = async ({ directory }) => {
181
191
  return {
182
192
  "tool.execute.after": async (input, output) => {
183
193
  try {
184
194
  if (input.tool !== "read") return;
195
+ const parts = [];
185
196
  const hint = readHintFor({
186
197
  filePath: input.args?.filePath,
187
198
  offset: input.args?.offset,
188
199
  limit: input.args?.limit,
189
200
  cwd: directory,
190
201
  });
202
+ if (hint) parts.push(hint);
203
+ for (const line of readContextLines(input.args?.filePath, directory)) {
204
+ parts.push(line);
205
+ }
206
+ if (parts.length > 0 && typeof output.output === "string") {
207
+ output.output = output.output + "\\n" + parts.join("\\n");
208
+ }
209
+ } catch {
210
+ // a hint must never break a read
211
+ }
212
+ },
213
+ };
214
+ };
215
+ `;
216
+ }
217
+
218
+ /**
219
+ * OpenCode plugin — the commit hint's in-process shape. `tool.execute.after`
220
+ * on the bash tool: when the command contains `git commit` and the worktree
221
+ * has ungraded commits, appends a nudge to the command output. Annotate only
222
+ * (no decision:block — OpenCode has no stop-hook equivalent).
223
+ *
224
+ * Logic lives in src/hook.ts (commitHintFor) — same one-copy-per-client shape
225
+ * as the read hint: a git pull in INSTALL_ROOT updates every client.
226
+ */
227
+ export function commitHintPluginSource(root: string): string {
228
+ const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
229
+ return `// fapony commit hint — annotates git commit bash commands with a
230
+ // verdict reminder when commits have no verdict filed yet.
231
+ // Annotate only: never blocks, never dedupes.
232
+ // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
233
+ import { commitHintFor } from ${hookModule};
234
+
235
+ export const FaponyCommitHint = async ({ directory }) => {
236
+ return {
237
+ "tool.execute.after": async (input, output) => {
238
+ try {
239
+ if (input.tool !== "bash") return;
240
+ const command = input.args?.command;
241
+ const hint = commitHintFor({
242
+ command,
243
+ cwd: directory,
244
+ });
191
245
  if (hint && typeof output.output === "string") {
192
246
  output.output = output.output + "\\n" + hint;
193
247
  }
194
248
  } catch {
195
- // a hint must never break a read
249
+ // a hint must never break a bash command
196
250
  }
197
251
  },
198
252
  };
@@ -232,3 +286,36 @@ function installReadHintPlugin(dryRun: boolean, getHome: () => string): void {
232
286
  ` read hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
233
287
  );
234
288
  }
289
+
290
+ function installCommitHintPlugin(dryRun: boolean, getHome: () => string): void {
291
+ const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
292
+ const pluginPath = join(pluginsDir, "fapony-commit-hint.ts");
293
+ if (existsSync(pluginPath)) {
294
+ let current = "";
295
+ try {
296
+ current = readFileSync(pluginPath, "utf-8");
297
+ } catch {
298
+ current = "";
299
+ }
300
+ if (current.includes("commitHintFor")) {
301
+ console.error(` commit hint: already installed — no change`);
302
+ return;
303
+ }
304
+ console.error(
305
+ ` commit hint: ${pluginPath} exists but isn't fapony's — not overwriting.`,
306
+ );
307
+ return;
308
+ }
309
+ if (!dryRun) {
310
+ try {
311
+ mkdirSync(pluginsDir, { recursive: true });
312
+ writeFileSync(pluginPath, commitHintPluginSource(INSTALL_ROOT), "utf-8");
313
+ } catch (e) {
314
+ console.error(` commit hint: failed to write — ${(e as Error).message}`);
315
+ return;
316
+ }
317
+ }
318
+ console.error(
319
+ ` commit hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
320
+ );
321
+ }
@@ -11,7 +11,7 @@ export const MCP_KEY = "fapony";
11
11
 
12
12
  export const MCP_CONFIG = {
13
13
  type: "local",
14
- command: ["bun", "run", "fapony.ts", "mcp"],
14
+ command: ["bun", "run", join(INSTALL_ROOT, "fapony.ts"), "mcp"],
15
15
  };
16
16
 
17
17
  /** ZCode uses the stdio MCP shape: command is a string, args is an array.
package/src/install.ts CHANGED
@@ -31,7 +31,11 @@ export {
31
31
  export { cmdInstallCodex } from "./install/codex.js";
32
32
  export { cmdInstallCursor } from "./install/cursor.js";
33
33
  export { detectClients } from "./install/detect.js";
34
- export { cmdInstallOpencode } from "./install/opencode.js";
34
+ export {
35
+ cmdInstallOpencode,
36
+ commitHintPluginSource,
37
+ readHintPluginSource,
38
+ } from "./install/opencode.js";
35
39
  export {
36
40
  agentsSkillsDir,
37
41
  claudeSkillsDir,
@@ -1,15 +1,15 @@
1
1
  // src/lint-baseline.ts — `fapony lint-baseline`: separate "was red before" from "I made it red".
2
2
  //
3
- // warning กับ error ที่แดงอยู่ก่อนหน้า agent เข้ามา ทำให้ agent ต้อง `fix all` ก่อน
4
- // แล้วงานจริงถูกกลบใน diff ก้อนเดียว (SPEC-convention-debt §4 — จากปากเจ้าของ:
5
- // "138 จุดใน ~40 ไฟล์ที่ไม่เกี่ยวกับงาน") ตัวเลขของรีโปที่แดงอยู่แล้ว 212 จุด
6
- // agent ที่ไม่ได้ทำอะไรผิดต้องเห็น **0**
3
+ // warnings and errors already red before the agent arrived force the agent to `fix all` first
4
+ // and then the real work is buried in one large diff (SPEC-convention-debt §4 — from the owner:
5
+ // "138 spots across ~40 files unrelated to the work") the repo's own red count is 212 —
6
+ // an agent that did nothing wrong must see **0**
7
7
  //
8
- // กลไกไม่รู้จัก eslint: รันคำสั่งที่รีโปบอก (--cmd หรือ evidence.json ชื่อ "lint"),
9
- // parse เป็นชุด `path:rule-id` (**ไม่ใช่เลขบรรทัด**บรรทัดขยับทุกครั้งที่แก้ไฟล์),
10
- // เก็บ baseline ไว้ที่ base_sha · ผลลัพธ์ไม่เข้า state.db (2 ตารางห้ามเพิ่ม) —
11
- // เป็นไฟล์ชั่วคราวใต้ state dir และถูกทิ้งเมื่อ --diff รายงานแล้ว · รายงานอย่างเดียว
12
- // ไม่ block — agent ตัดสินเองว่าจะแก้ของเก่าด้วยไหม
8
+ // Mechanismdoes not know eslint: runs the command the repo declares (--cmd or evidence.json named "lint"),
9
+ // parses into a set of `path:rule-id` (**not line numbers** lines shift every time a file is edited),
10
+ // stores the baseline at base_sha · results never enter state.db (2 tables, no additions) —
11
+ // it is a temporary file under the state dir and is discarded once --diff reports · report only
12
+ // never blocksthe agent decides whether to fix the old issues too
13
13
 
14
14
  import { execSync } from "node:child_process";
15
15
  import {
@@ -1,6 +1,6 @@
1
1
  // src/mcp/tools/mem.ts — mem_find: read-only search over the project's mem log
2
2
  //
3
- // "เขียนได้อ่านกลับได้" — the write side works from the CLI with no MCP
3
+ // "writablereadable back" — the write side works from the CLI with no MCP
4
4
  // (vela: 2,920 rows / 49 days); what was missing is "which rows are about the
5
5
  // files I am about to touch". Read-only over readMemLog (PLAN-mem-mcp chunk 2).
6
6
  //
@@ -86,11 +86,11 @@ export function toolFaponyStats(args: Record<string, unknown>): ToolResult {
86
86
  };
87
87
  }
88
88
 
89
- // json:true → StatsData ล้วน (SPEC-verdict-stats) — ห้ามแทรก text อื่น
89
+ // json:true → pure StatsData (SPEC-verdict-stats) — no other text may be inserted
90
90
  if (args.json === true) {
91
91
  return jsonResult(data);
92
92
  }
93
93
 
94
- // json:false → text เดียวกับ `fapony stats` — same formatter, raw (not JSON-wrapped)
94
+ // json:false → the same text as `fapony stats` — same formatter, raw (not JSON-wrapped)
95
95
  return { content: [{ type: "text", text: formatStatsText(data) }] };
96
96
  }
@@ -13,7 +13,7 @@ import {
13
13
  } from "../../session/index.js";
14
14
  import { jsonResult, type ToolResult } from "../types.js";
15
15
 
16
- /** สรุป list-price ต่อ client — additive ไม่แตะบรรทัดเดิม */
16
+ /** list-price summary per client — additive, leaves existing lines untouched */
17
17
  function imputationOf(
18
18
  result: PassiveUsageResult,
19
19
  prices: PriceTable | null,
@@ -45,7 +45,7 @@ function imputedTextLines(
45
45
  return [` ${label}list-price equivalent: ${parts.join(" · ")}`];
46
46
  }
47
47
 
48
- /** ราคา list ราย model ต่อท้ายชื่อรุ่นเฉพาะแถวที่ client ไม่บันทึก cost */
48
+ /** list price per model appended to the model name only rows where the client records no cost */
49
49
  function imputedSuffix(
50
50
  provider: string,
51
51
  model: string,
@@ -78,7 +78,7 @@ export function toolPassiveUsage(args: Record<string, unknown>): ToolResult {
78
78
  result: client.read(worktree, since, until, detail),
79
79
  }));
80
80
 
81
- // ราคา list จาก cache อย่างเดียวอ่านครั้งเดียวต่อ call ไม่ใช่ต่อ section
81
+ // list prices from the cache onlyread once per call, not per section
82
82
  const prices = loadPrices();
83
83
 
84
84
  if (args.json === true) {
@@ -79,22 +79,35 @@ export function toolVerdictSubmit(args: Record<string, unknown>): ToolResult {
79
79
  : "mcp-external";
80
80
  const resolvedPlan =
81
81
  typeof plan === "string" && plan ? plan.trim().toLowerCase() : null;
82
- let open = resolvedPlan
83
- ? findOpenRun(db, resolvedWorktree, resolvedPlan)
84
- : null;
85
- // Fallback: if no exact match and plan is non-null, try any open run with
86
- // plan=null. This lets a verdict with free-text intent bind to a run that
87
- // was created without a plan (the common "no PLAN file" flow).
88
- if (!open && resolvedPlan) {
89
- open = findOpenRunWithNullPlan(db, resolvedWorktree);
90
- }
91
- if (open) {
92
- resolvedRunId = open.id;
93
- } else {
94
- // worktree/plan let callers (e.g. move-to-done) attribute the verdict
95
- // so byReasonCode/bestPassing aggregate correctly instead of collapsing
96
- // into "mcp-external".
97
- resolvedRunId = newRun(db, resolvedWorktree, resolvedPlan, null, "mcp");
82
+ // BEGIN IMMEDIATE makes the find-then-create below atomic across
83
+ // concurrent MCP processes: without it, two agents closing the same
84
+ // worktree+plan at once can both see "no open run" and each insert a
85
+ // row. The write lock taken here blocks (busy_timeout, not throws) the
86
+ // second caller until the first commits, so it re-reads and finds the
87
+ // row already there instead of duplicating it.
88
+ db.run("BEGIN IMMEDIATE");
89
+ try {
90
+ let open = resolvedPlan
91
+ ? findOpenRun(db, resolvedWorktree, resolvedPlan)
92
+ : null;
93
+ // Fallback: if no exact match and plan is non-null, try any open run with
94
+ // plan=null. This lets a verdict with free-text intent bind to a run that
95
+ // was created without a plan (the common "no PLAN file" flow).
96
+ if (!open && resolvedPlan) {
97
+ open = findOpenRunWithNullPlan(db, resolvedWorktree);
98
+ }
99
+ if (open) {
100
+ resolvedRunId = open.id;
101
+ } else {
102
+ // worktree/plan let callers (e.g. move-to-done) attribute the verdict
103
+ // so byReasonCode/bestPassing aggregate correctly instead of collapsing
104
+ // into "mcp-external".
105
+ resolvedRunId = newRun(db, resolvedWorktree, resolvedPlan, null, "mcp");
106
+ }
107
+ db.run("COMMIT");
108
+ } catch (e) {
109
+ db.run("ROLLBACK");
110
+ throw e;
98
111
  }
99
112
  }
100
113
 
@@ -112,7 +112,7 @@ function writeStatuslineCache(toolResult: ToolResult): void {
112
112
 
113
113
  const MCP_PROTOCOL_VERSION = "2025-03-26";
114
114
  const SERVER_NAME = "fapony-handcheck";
115
- const SERVER_VERSION = "0.1.0";
115
+ const SERVER_VERSION = "0.1.2";
116
116
 
117
117
  // --- JSON-RPC dispatch ---
118
118
 
package/src/plan-seed.ts CHANGED
@@ -88,7 +88,7 @@ function scopeSourceFiles(root: string): string[] {
88
88
  // is about to plan. Headers only (title + shipped date) — pulling the bodies
89
89
  // in would recreate the reading task the plan exists to avoid.
90
90
  function renderPriorArt(cwd: string, config: Config, roots: string[]): string {
91
- const placeholder = "- _(agent เติม)_";
91
+ const placeholder = "- _(agent fills in)_";
92
92
  const keys = roots
93
93
  .map((r) => relative(cwd, r))
94
94
  .filter((r) => r !== "" && r !== ".");
@@ -124,7 +124,7 @@ function renderPriorArt(cwd: string, config: Config, roots: string[]): string {
124
124
  const shipped = content.match(/shipped\s+(\d{4}-\d{2}-\d{2})/)?.[1] ?? "";
125
125
  hits.push({
126
126
  shipped,
127
- line: `- ✅ ตัดสินไปแล้ว: [${n}](../${label}/${n}) — ${title}${shipped ? ` (shipped ${shipped})` : ""} \`(fapony plan-seed)\``,
127
+ line: `- ✅ Already decided: [${n}](../${label}/${n}) — ${title}${shipped ? ` (shipped ${shipped})` : ""} \`(fapony plan-seed)\``,
128
128
  });
129
129
  }
130
130
  }
@@ -137,7 +137,9 @@ function renderPriorArt(cwd: string, config: Config, roots: string[]): string {
137
137
  );
138
138
  const shown = hits.slice(0, MAX_PRIOR_ART).map((h) => h.line);
139
139
  if (hits.length > MAX_PRIOR_ART) {
140
- shown.push(`- … +${hits.length - MAX_PRIOR_ART} more ที่แตะ scope เดียวกัน`);
140
+ shown.push(
141
+ `- … +${hits.length - MAX_PRIOR_ART} more touching the same scope`,
142
+ );
141
143
  }
142
144
  shown.push(placeholder);
143
145
  return shown.join("\n");
@@ -185,49 +187,49 @@ kind: unit
185
187
  status: active
186
188
  ---
187
189
 
188
- # PLAN-${name} — (agent เติมชื่อเรื่อง)
190
+ # PLAN-${name} — (agent fills in a title)
189
191
 
190
- > **Status:** 🚧 in-progress · **Created:** (agent เติมวันที่)
192
+ > **Status:** 🚧 in-progress · **Created:** (agent fills in the date)
191
193
 
192
194
  ## TL;DR
193
- - **What:** (agent เติม) · **Why:** (agent เติม) · **Done when:** (agent เติม)
194
- - **Order:** (agent เติม)
195
+ - **What:** (agent fills in) · **Why:** (agent fills in) · **Done when:** (agent fills in)
196
+ - **Order:** (agent fills in)
195
197
  - **Progress:**
196
- - [ ] chunk 1 — (agent เติม)
198
+ - [ ] chunk 1 — (agent fills in)
197
199
 
198
200
  ## Context (fapony)
199
201
  ${contextFapony}
200
202
 
201
203
  ## 1. Goal (why)
202
- _(agent เติม)_
204
+ _(agent fills in)_
203
205
 
204
206
  ## 2. Scope (do / don't do)
205
- _(agent เติม)_
207
+ _(agent fills in)_
206
208
 
207
209
  ## 3. Done criteria (how we know it's finished)
208
- _(agent เติมต้อง verify ได้)_
210
+ _(agent fills in must be verifiable)_
209
211
 
210
212
  ## 4. Constraints / Hard rules (must not violate)
211
- _(agent เติม)_
213
+ _(agent fills in)_
212
214
 
213
215
  ## 5. Risks & Escape hatches (if it fails)
214
- _(agent เติม)_
216
+ _(agent fills in)_
215
217
 
216
218
  ## 6. Steps (what in which order)
217
- 1. _(agent เติมแต่ละขั้น verify ได้)_
219
+ 1. _(agent fills in each step must be verifiable)_
218
220
 
219
221
  ## 7. Examples
220
222
  ${
221
223
  specLink
222
- ? `→ ${specLink} (signature อยู่ spec ไม่ใช่ plan)`
223
- : "_(agent เติมหรือเพิ่ม SPEC ด้วย plan-seed --spec)_"
224
+ ? `→ ${specLink} (signatures live in the spec, not the plan)`
225
+ : "_(agent fills in or add a SPEC with plan-seed --spec)_"
224
226
  }
225
227
 
226
228
  ## 8. References
227
229
  ${priorArt}
228
230
 
229
231
  ## Context (agent)
230
- _(slot ว่าง — agent dump graph/code-summary ของตัวเอง)_
232
+ _(empty slot — the agent dumps its own graph/code-summary)_
231
233
  `;
232
234
  }
233
235
 
@@ -437,7 +439,7 @@ function specTemplate(
437
439
  // undershot the cap by that many lines (the 18k-SPEC failure mode). Cap the
438
440
  // index separately so the body's capLines has a bounded head to work with.
439
441
  const fixedHead = [
440
- `# SPEC-${name} — (agent เติมชื่อเรื่อง)`,
442
+ `# SPEC-${name} — (agent fills in a title)`,
441
443
  "",
442
444
  `> **Used by:** PLAN-${name} — signatures below come from a live source scan — re-seed after structural changes.`,
443
445
  ...(scopeEcho ? [`> **Scope:** ${scopeEcho}`] : []),
@@ -456,7 +458,7 @@ function specTemplate(
456
458
  );
457
459
  const head = [...fixedHead, ...cappedIndex, ""];
458
460
  const tail = [
459
- "## (agent เติม — wireframes / edge cases / API shapes ที่ plan อ้างถึง)",
461
+ "## (agent fills in — wireframes / edge cases / API shapes the plan references)",
460
462
  ];
461
463
  const bodyLines = chunks.flatMap((c) => [
462
464
  `## <a id="${c.slug}"></a>${c.title}`,
@@ -557,7 +559,7 @@ export function cmdPlanSeed(args: string[]): void {
557
559
  const planPath = join(planDirAbs, `PLAN-${name}.md`);
558
560
  if (existsSync(planPath)) {
559
561
  console.error(
560
- `${planPath} already exists — not overwriting. ใช้ชื่อใหม่ เช่น PLAN-${name}-v2`,
562
+ `${planPath} already exists — not overwriting. Use a new name, e.g. PLAN-${name}-v2`,
561
563
  );
562
564
  process.exit(1);
563
565
  }
@@ -571,7 +573,7 @@ export function cmdPlanSeed(args: string[]): void {
571
573
  const specPath = join(specDirAbs, `SPEC-${name}.md`);
572
574
  if (existsSync(specPath)) {
573
575
  console.error(
574
- `${specPath} already exists — not overwriting. ใช้ชื่อใหม่ เช่น SPEC-${name}-v2`,
576
+ `${specPath} already exists — not overwriting. Use a new name, e.g. SPEC-${name}-v2`,
575
577
  );
576
578
  process.exit(1);
577
579
  }
@@ -1,9 +1,9 @@
1
- // src/price/fetch.ts — ดึงตารางราคา OpenRouter + cache เป็น prices.json
1
+ // src/price/fetch.ts — fetch OpenRouter's price table + cache it as prices.json
2
2
  //
3
- // ราคาเป็น cache ไม่ใช่ state: เก็บที่ ~/.config/fapony/prices.json (เคารพ
4
- // FAPONY_STATE_DIR) ห้ามเพิ่มตาราง SQLite (กฎ DB Schema: 2 ตารางเท่านั้น)
5
- // refresh เกิดตอนคนสั่ง `fapony price-scan` เท่านั้น — query ไม่ fetch เอง
6
- // (วินัยเดียวกับ usage-scan) offline แล้วอ่าน cache เดิม
3
+ // prices are cache, not state: stored at ~/.config/fapony/prices.json (honors
4
+ // FAPONY_STATE_DIR) no new SQLite tables (DB Schema rule: 2 tables only)
5
+ // refresh happens only when someone runs `fapony price-scan` — a query never fetches on its own
6
+ // (same discipline as usage-scan) offline, read the existing cache
7
7
 
8
8
  import {
9
9
  existsSync,
@@ -19,12 +19,12 @@ import type { Config } from "../db/types.js";
19
19
  const PRICES_FILENAME = "prices.json";
20
20
  const MODELS_URL = "https://openrouter.ai/api/v1/models";
21
21
 
22
- /** เรตราย token (ดอลลาร์) — ทุกเรตมาจาก OpenRouter ตรง ไม่เดา */
22
+ /** per-token rates (dollars) — every rate comes straight from OpenRouter, no guessing */
23
23
  export interface ModelRates {
24
24
  input: number;
25
25
  output: number;
26
26
  cacheRead: number;
27
- /** null = ตารางไม่ให้มาใช้เรต input แทน (ดู calcCost) */
27
+ /** null = the table does not provide it use the input rate instead (see calcCost) */
28
28
  cacheWrite: number | null;
29
29
  }
30
30
 
@@ -37,7 +37,7 @@ export function pricesPath(config?: Config): string {
37
37
  return join(faponyDir(config), PRICES_FILENAME);
38
38
  }
39
39
 
40
- /** อ่าน cache — คืน null เมื่อไม่มีไฟล์หรือพัง (caller แสดง — + hint) */
40
+ /** Read the cache — null when the file is missing or broken (caller shows "" + hint) */
41
41
  export function loadPrices(config?: Config): PriceTable | null {
42
42
  const p = pricesPath(config);
43
43
  if (!existsSync(p)) return null;
@@ -53,7 +53,7 @@ export function loadPrices(config?: Config): PriceTable | null {
53
53
  }
54
54
  }
55
55
 
56
- /** เขียนแบบ atomic (tmp + rename) สร้าง state dir เมื่อยังไม่มี */
56
+ /** Write atomically (tmp + rename), create the state dir when absent */
57
57
  export function writePrices(table: PriceTable, config?: Config): void {
58
58
  const p = pricesPath(config);
59
59
  mkdirSync(dirname(p), { recursive: true });
@@ -72,14 +72,14 @@ function toRate(v: unknown): number | null {
72
72
  }
73
73
 
74
74
  /**
75
- * แปลง response ดิบของ OpenRouter เป็นตารางเรต (pure — เทสต์ได้โดยไม่ยิงเน็ต)
75
+ * Turn OpenRouter's raw response into a rate table (pure — testable without hitting the network)
76
76
  *
77
- * ฟิลด์จริง (ยืนยัน 2026-09-14, 445 models): pricing.prompt / .completion /
77
+ * Real fields (confirmed 2026-09-14, 445 models): pricing.prompt / .completion /
78
78
  * .input_cache_read / .input_cache_write (optional) / .input_cache_write_1h
79
- * (เรต 1h TTL ของบางรุ่นใช้เรต 5m มาตรฐานพอ เพราะ log ไม่บอก TTL)
80
- * เมิน pricing.web_search / pricing.overrides (tier ตาม min_prompt_tokens /
81
- * utc_days — ใช้ base rate แล้วประกาศข้อจำกัด) · id ขึ้นต้น ~ (alias) ตัดทิ้ง
82
- * id ลงท้าย :free / :batch เก็บตามนั้น (เป็นแถวราคาของมันเอง)
79
+ * (the 1h TTL rate of some models the standard 5m rate suffices because logs do not report TTL)
80
+ * ignore pricing.web_search / pricing.overrides (tiers based on min_prompt_tokens /
81
+ * utc_days — use the base rate and state the limitation) · ids starting with ~ (alias) are dropped
82
+ * ids ending :free / :batch are kept as-is (they are their own price rows)
83
83
  */
84
84
  export function parsePricesResponse(json: unknown): Record<string, ModelRates> {
85
85
  const out: Record<string, ModelRates> = {};
@@ -102,8 +102,8 @@ export function parsePricesResponse(json: unknown): Record<string, ModelRates> {
102
102
  }
103
103
 
104
104
  /**
105
- * รวมตารางใหม่เข้ากับ cache เดิม — merge ไม่ replace: id ที่หายไปจาก
106
- * response รอบนี้ (รุ่นเก่าหลุดตาราง) ต้องยังคิดราคาได้ด้วยเรตเดิม
105
+ * Merge the new table into the old cache — merge, not replace: an id missing from
106
+ * this response (an old model dropped from the table) must still be priced with its old rate
107
107
  */
108
108
  export function mergePriceTables(
109
109
  old: PriceTable | null,
@@ -115,7 +115,7 @@ export function mergePriceTables(
115
115
  };
116
116
  }
117
117
 
118
- /** ดึงตารางจาก OpenRouter (public, ไม่ต้อง auth) — fetcher แทรกได้ไว้เทสต์ */
118
+ /** Fetch the table from OpenRouter (public, no auth) — fetcher is injectable for tests */
119
119
  export async function fetchPriceTable(
120
120
  fetcher: typeof fetch = fetch,
121
121
  ): Promise<Record<string, ModelRates>> {
@@ -134,7 +134,7 @@ export async function cmdPriceScan(rawArgs: string[]): Promise<void> {
134
134
  fresh = await fetchPriceTable();
135
135
  } catch (err) {
136
136
  console.error(
137
- `fapony price-scan: fetch failed (${String(err)}) — cache เดิมยังอยู่ ใช้ราคาที่มีได้`,
137
+ `fapony price-scan: fetch failed (${String(err)}) — the existing cache is still here, use the prices you have`,
138
138
  );
139
139
  process.exit(1);
140
140
  }