fapony 0.2.1 → 0.3.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.
@@ -235,3 +235,74 @@ export function toolMemAdd(args: Record<string, unknown>): ToolResult {
235
235
  return errorResult(e instanceof Error ? e.message : String(e));
236
236
  }
237
237
  }
238
+
239
+ // --- mem_close ---
240
+ //
241
+ // A separate tool on purpose, not kind:"close" inside mem_add: a close row
242
+ // carries {ref, text} with no files[] and no spec, while mem_add requires a
243
+ // non-empty files[] (and a spec for hold) — folding them into one schema
244
+ // would make required fields depend on the value of another field, the shape
245
+ // models call wrong most often. Mirrors CLI `mem close <id> "<msg>"`
246
+ // (commands/write.ts cmdClose): the id must exist; the tombstone voids the
247
+ // claim by itself.
248
+
249
+ export interface MemCloseResult {
250
+ ref: string;
251
+ text: string;
252
+ ts: string;
253
+ }
254
+
255
+ export function memClose(args: {
256
+ worktree: string;
257
+ id: string;
258
+ text: string;
259
+ }): MemCloseResult {
260
+ if (!args.id.trim()) {
261
+ throw new Error("id is required");
262
+ }
263
+ if (!args.text.trim()) {
264
+ throw new Error("text is required and must not be empty");
265
+ }
266
+
267
+ initStore(args.worktree);
268
+ const all = rows();
269
+ if (!all.some((r) => "id" in r && r.id === args.id)) {
270
+ throw new Error(`no id "${args.id}" in the log`);
271
+ }
272
+
273
+ const ts = new Date().toISOString();
274
+ put({ kind: "close", ref: args.id, text: args.text });
275
+
276
+ return { ref: args.id, text: args.text, ts };
277
+ }
278
+
279
+ export function toolMemClose(args: Record<string, unknown>): ToolResult {
280
+ const worktree =
281
+ typeof args.worktree === "string" ? args.worktree.trim() : "";
282
+ if (!worktree) {
283
+ return errorResult(
284
+ "worktree is required and must be an absolute path " +
285
+ "(git rev-parse --show-toplevel)",
286
+ );
287
+ }
288
+ if (!worktree.startsWith("/")) {
289
+ return errorResult(`worktree must be an absolute path, got: ${worktree}`);
290
+ }
291
+
292
+ const id = typeof args.id === "string" ? args.id.trim() : "";
293
+ if (!id) {
294
+ return errorResult("id is required — the row id to close");
295
+ }
296
+
297
+ const text = typeof args.text === "string" ? args.text.trim() : "";
298
+ if (!text) {
299
+ return errorResult("text is required and must not be empty");
300
+ }
301
+
302
+ try {
303
+ const result = memClose({ worktree, id, text });
304
+ return jsonResult(result);
305
+ } catch (e) {
306
+ return errorResult(e instanceof Error ? e.message : String(e));
307
+ }
308
+ }
@@ -4,13 +4,12 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { createInterface } from "node:readline";
7
- import { mergeBytesByTool, type UsageDetail } from "../session/index.js";
8
7
  import { getServerSha } from "./primitives.js";
9
8
  import {
10
9
  TOOLS,
11
10
  toolMemAdd,
11
+ toolMemClose,
12
12
  toolMemFind,
13
- toolPassiveUsage,
14
13
  toolVerdictSubmit,
15
14
  } from "./tools/index.js";
16
15
  import { errorResult, type ToolResult } from "./types.js";
@@ -39,68 +38,18 @@ Skip it and every tool still answers correctly, on a thinner history.`;
39
38
  //
40
39
  // Written after every MCP tool call. The Claude Code statusline script reads
41
40
  // this file (< 1ms, no spawn, no db). Format: single line of text.
42
- // Only fapony_usage with detail:true produces meaningful data (bytes_by_tool,
43
- // aggregated across all clients — the bytes live on the claude_code
44
- // sub-object, never top-level); other tools write a minimal "fapony" marker.
41
+ // No remaining tool produces bytes_by_tool detail, so every call writes the
42
+ // minimal "fapony" marker — cross-client spend lives on the CLI now
43
+ // (`fapony usage-scan` + `fapony usage-web`), which the statusline cannot
44
+ // spawn (it must stay < 1ms).
45
45
 
46
46
  const STATUSLINE_PATH = join(homedir(), ".config", "fapony", "statusline");
47
47
 
48
- function writeStatuslineCache(toolResult: ToolResult): void {
48
+ function writeStatuslineCache(): void {
49
49
  try {
50
- // Extract bytes_by_tool from fapony_usage detail JSON response.
51
- let line = "fapony";
52
- if (
53
- toolResult &&
54
- typeof toolResult === "object" &&
55
- "content" in toolResult &&
56
- Array.isArray(toolResult.content)
57
- ) {
58
- for (const c of toolResult.content) {
59
- if (
60
- c &&
61
- typeof c === "object" &&
62
- c.type === "text" &&
63
- typeof c.text === "string"
64
- ) {
65
- // Try to extract bytes_by_tool from JSON text response.
66
- // Aggregated across all clients: the bytes live on the Claude Code
67
- // sub-object (claude_code.detail), never on the top-level detail,
68
- // so reading top-level alone would always miss.
69
- try {
70
- const parsed = JSON.parse(c.text) as {
71
- detail?: UsageDetail | null;
72
- zcode?: { detail?: UsageDetail | null } | null;
73
- claude_code?: { detail?: UsageDetail | null } | null;
74
- codex?: { detail?: UsageDetail | null } | null;
75
- };
76
- const bbt = mergeBytesByTool(
77
- parsed?.detail,
78
- parsed?.zcode?.detail,
79
- parsed?.claude_code?.detail,
80
- parsed?.codex?.detail,
81
- );
82
- const entries = Object.entries(bbt).sort((a, b) => b[1] - a[1]);
83
- const total = entries.reduce((s, e) => s + e[1], 0);
84
- if (total > 0) {
85
- // Format: "84.2k" for total, or "Read 42k · Grep 31k" for top tools.
86
- const fmt = (n: number) =>
87
- n >= 1024 ? `${(n / 1024).toFixed(1)}k` : `${Math.round(n)}`;
88
- if (entries.length <= 3) {
89
- line = `fapony ${entries.map((e) => `${e[0]} ${fmt(e[1])}`).join(" · ")}`;
90
- } else {
91
- line = `fapony ${fmt(total)}`;
92
- }
93
- }
94
- } catch {
95
- // Not JSON — that's fine, use default "fapony" marker.
96
- }
97
- break;
98
- }
99
- }
100
- }
101
50
  const dir = join(homedir(), ".config", "fapony");
102
51
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
103
- writeFileSync(STATUSLINE_PATH, line, "utf-8");
52
+ writeFileSync(STATUSLINE_PATH, "fapony", "utf-8");
104
53
  } catch {
105
54
  // Cache write is best-effort — never block MCP on it.
106
55
  }
@@ -110,7 +59,7 @@ function writeStatuslineCache(toolResult: ToolResult): void {
110
59
 
111
60
  const MCP_PROTOCOL_VERSION = "2025-03-26";
112
61
  const SERVER_NAME = "fapony-handcheck";
113
- const SERVER_VERSION = "0.2.1";
62
+ const SERVER_VERSION = "0.3.0";
114
63
 
115
64
  // --- JSON-RPC dispatch ---
116
65
 
@@ -152,20 +101,20 @@ function dispatchToolCall(params: {
152
101
  case "verdict_submit":
153
102
  result = toolVerdictSubmit(args);
154
103
  break;
155
- case "fapony_usage":
156
- result = toolPassiveUsage(args);
157
- break;
158
104
  case "mem_find":
159
105
  result = toolMemFind(args);
160
106
  break;
161
107
  case "mem_add":
162
108
  result = toolMemAdd(args);
163
109
  break;
110
+ case "mem_close":
111
+ result = toolMemClose(args);
112
+ break;
164
113
  default:
165
114
  return errorResult(`unknown tool: ${params.name}`);
166
115
  }
167
116
  // Write statusline cache after every tool call — best-effort, never blocks.
168
- writeStatuslineCache(result);
117
+ writeStatuslineCache();
169
118
  return result;
170
119
  }
171
120
 
@@ -2,10 +2,11 @@
2
2
 
3
3
  import { existsSync, readdirSync, readFileSync } from "node:fs";
4
4
  import { basename, join } from "node:path";
5
+ import { baselinePath, readEvidenceLintCmd } from "../../lint-baseline.js";
5
6
  import { doneLines, fmtClose, fmtRow, printOpenRows } from "../render.js";
6
7
  import { claimsOf, openRows, staleReport } from "../selectors.js";
7
8
  import type { CloseRow, WorkRow } from "../store.js";
8
- import { allRows, app, memCmd, planDir, rows } from "../store.js";
9
+ import { allRows, app, memCmd, planDir, root, rows } from "../store.js";
9
10
  import { planSweepCmd, shippedNotMoved } from "./plan.js";
10
11
  import { THRESHOLD } from "./rotate.js";
11
12
 
@@ -406,6 +407,22 @@ export const cmdKickoff = (a: string[]) => {
406
407
  });
407
408
  }
408
409
 
410
+ // group 4 — the lint baseline. "Is this red mine or was it already red" is
411
+ // already answered by `lint-baseline`, but only if it was captured before
412
+ // the work started — and nobody remembers at session open, which is what
413
+ // kickoff is. Only offered when the repo declared a lint command; without
414
+ // one the capture would just fail.
415
+ try {
416
+ if (readEvidenceLintCmd(root) && !existsSync(baselinePath(root))) {
417
+ suggestions.push({
418
+ text: "lint baseline ยังไม่ capture — แดงที่มีอยู่ก่อนจะถูกนับเป็นของคุณ",
419
+ run: "fapony lint-baseline --capture",
420
+ });
421
+ }
422
+ } catch {
423
+ // state dir unreadable — the baseline line is a convenience, never a gate
424
+ }
425
+
409
426
  // Print and optionally execute. Only a suggestion with a complete command is
410
427
  // runnable — a plan item is context, a bug's close is a template needing a message.
411
428
  if (suggestions.length) {
package/src/memory.ts CHANGED
@@ -218,7 +218,13 @@ function walkUpForMemDir(fromDir: string, acceptEmpty: boolean): string | null {
218
218
  export interface MemDirResult {
219
219
  dir: string | null;
220
220
  step: "flag" | "config" | "walk-up" | "repo-root" | "ambiguous" | "none";
221
- /** For step "ambiguous" — the sibling `.fapony/.memory/` dirs that hold logs. */
221
+ /**
222
+ * `.fapony/.memory/` dirs under the repo root that hold logs but are not in
223
+ * scope from `fromDir`. Set for "ambiguous" (two or more, the refusal) and
224
+ * also for "repo-root"/"none" (one) — a caller that reports "nothing
225
+ * recorded here" is lying when a single app-scoped log exists one level
226
+ * down, and the honest line needs the path to say where.
227
+ */
222
228
  candidates?: string[];
223
229
  }
224
230
 
@@ -320,20 +326,23 @@ function resolveMemDirFrom(
320
326
  // Guard (SPEC §1 fail example): nothing at/above cwd holds a log, but the repo
321
327
  // has two or more app-scoped ones — refuse instead of silently creating a
322
328
  // third log at the root that no app-scoped reader will ever see.
323
- if (root) {
324
- const candidates = findMemDirsUnder(root);
325
- if (candidates.length >= 2) {
326
- return { dir: null, step: "ambiguous", candidates };
327
- }
329
+ // One candidate is not ambiguous, so resolution is unchanged — but it still
330
+ // travels back with the result: out-of-scope is not the same as absent.
331
+ const candidates = root ? findMemDirsUnder(root) : [];
332
+ if (candidates.length >= 2) {
333
+ return { dir: null, step: "ambiguous", candidates };
328
334
  }
335
+ const outOfScope = candidates.length ? { candidates } : {};
329
336
 
330
337
  // Step 4: <repo root>/.fapony/.memory/ — where a new log is created
331
338
  if (root) {
332
339
  const rootDir = join(root, DEFAULT_MEM_DIR);
333
- if (existsSync(rootDir)) return { dir: rootDir, step: "repo-root" };
340
+ if (existsSync(rootDir)) {
341
+ return { dir: rootDir, step: "repo-root", ...outOfScope };
342
+ }
334
343
  }
335
344
 
336
- return { dir: null, step: "none" };
345
+ return { dir: null, step: "none", ...outOfScope };
337
346
  }
338
347
 
339
348
  export function resolveMemDir(
@@ -270,7 +270,7 @@ export function readDetailFromDb(
270
270
  .all(...filter.params) as DetailPerSessionToolRow[];
271
271
 
272
272
  // Context bytes per tool — summed `state.output` length. Same signal the
273
- // Claude Code/Codex readers derive from tool_result blocks, so `fapony_usage`
273
+ // Claude Code/Codex readers derive from tool_result blocks, so usage-scan
274
274
  // stops reporting zero bytes for the SQLite clients.
275
275
  const bytesRows = db
276
276
  .prepare(
@@ -2,8 +2,8 @@
2
2
  //
3
3
  // Adding a client: write its reader (src/session/<name>.ts, same
4
4
  // PassiveUsageResult shape as an existing one — SQLite or JSONL, whatever the
5
- // client actually stores), then add one entry below. usage-scan and
6
- // fapony_usage both iterate this list; neither needs any other change.
5
+ // client actually stores), then add one entry below. usage-scan iterates
6
+ // this list; nothing else needs any other change.
7
7
  //
8
8
  // This does not remove the real work of a new client (reverse-engineering
9
9
  // its storage format) — it only removes the "wire it into 2 call sites"
@@ -16,12 +16,10 @@ import type { PassiveUsageReader } from "./types.js";
16
16
  import { readZcodeUsage } from "./zcode.js";
17
17
 
18
18
  export interface ClientAdapter {
19
- /** cache `client` field and fapony_usage JSON key. */
19
+ /** usage-cache.jsonl `client` field and usage-scan label default. */
20
20
  key: string;
21
21
  /** usage-scan progress label — defaults to `key`. */
22
22
  scanLabel?: string;
23
- /** fapony_usage text-report section label — defaults to `key`. */
24
- reportLabel?: string;
25
23
  /** The one client whose totals lead the report as the unlabeled top-level summary. */
26
24
  primary?: boolean;
27
25
  read: PassiveUsageReader;
@@ -38,7 +36,6 @@ export const CLIENTS: ClientAdapter[] = [
38
36
  {
39
37
  key: "claude_code",
40
38
  scanLabel: "claude-code",
41
- reportLabel: "claude code",
42
39
  read: readClaudeCodeUsage,
43
40
  },
44
41
  { key: "codex", read: readCodexUsage },
package/src/setup.ts CHANGED
@@ -229,7 +229,7 @@ export async function cmdSetup(deps: SetupDeps = {}): Promise<void> {
229
229
  │ fapony install --platform claude │
230
230
  │ │
231
231
  │ 2. Ask your agent: │
232
- │ "Run fapony_stats and fapony_usage" │
232
+ │ "Find past decisions with mem_find" │
233
233
  │ │
234
234
  │ 3. Read a run's report: │
235
235
  │ fapony report <run-id> │