fapony 0.3.3 → 0.3.5
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/README.md +7 -1
- package/fapony.ts +3 -116
- package/package.json +5 -6
- package/skill/move-to-done/SKILL.md +3 -2
- package/src/adapters/cli.ts +128 -0
- package/src/adapters/hooks/compute-hint-impact.ts +107 -0
- package/src/adapters/hooks/context-data.ts +102 -0
- package/src/adapters/hooks/edit-hint.ts +195 -0
- package/src/adapters/hooks/git-autonomy.ts +178 -0
- package/src/adapters/hooks/index.ts +80 -0
- package/src/adapters/hooks/mv-guard.ts +52 -0
- package/src/adapters/hooks/read-hint.ts +383 -0
- package/src/adapters/hooks/session-start.ts +101 -0
- package/src/adapters/hooks/stop.ts +434 -0
- package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
- package/src/{mcp → adapters/mcp}/primitives.ts +2 -2
- package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
- package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
- package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
- package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
- package/src/adapters/mcp/types.ts +18 -0
- package/src/{mcp → adapters/mcp}/worktree.ts +1 -1
- package/src/analyze.ts +1 -1
- package/src/commands.ts +180 -0
- package/src/conventions-seed.ts +1 -1
- package/src/core/config.ts +199 -0
- package/src/core/debt-format.ts +107 -0
- package/src/core/debt-types.ts +79 -0
- package/src/core/defaults.ts +8 -0
- package/src/core/enums.ts +34 -0
- package/src/core/format.ts +33 -0
- package/src/core/hint-log.ts +68 -0
- package/src/core/hook-helpers.ts +31 -0
- package/src/core/mem-log.ts +357 -0
- package/src/core/parse.ts +71 -0
- package/src/core/pricing.ts +217 -0
- package/src/core/safety.ts +18 -0
- package/src/core/types.ts +142 -0
- package/src/core/util.ts +105 -0
- package/src/db/store.ts +2 -2
- package/src/debt/cli.ts +1 -1
- package/src/debt/format.ts +2 -107
- package/src/debt/load.ts +1 -1
- package/src/debt/promotion.ts +1 -1
- package/src/debt/types.ts +14 -79
- package/src/digest/collect.ts +4 -3
- package/src/gate.ts +2 -2
- package/src/gates.ts +1 -1
- package/src/hook.ts +70 -1424
- package/src/init-mem.ts +1 -1
- package/src/init.ts +1 -1
- package/src/install/claude.ts +17 -0
- package/src/install/opencode.ts +114 -0
- package/src/install.ts +12 -5
- package/src/lint-baseline.ts +1 -2
- package/src/map.ts +29 -8
- package/src/mem/commands/plan.ts +197 -42
- package/src/mem/commands/read.ts +53 -12
- package/src/mem/index.ts +60 -1
- package/src/mem/store.ts +5 -1
- package/src/memory.ts +26 -388
- package/src/parse.ts +9 -71
- package/src/price/fetch.ts +4 -16
- package/src/price/resolve.ts +12 -213
- package/src/report/cli.ts +3 -3
- package/src/safety.ts +2 -18
- package/src/seed/plan-seed.ts +126 -32
- package/src/seed/primitives.ts +1 -7
- package/src/session/types.ts +14 -128
- package/src/setup.ts +1 -1
- package/src/stats/data.ts +3 -3
- package/src/telemetry.ts +2 -2
- package/src/update.ts +1 -1
- package/src/usage/cache.ts +1 -2
- package/src/usage/cli.ts +1 -1
- package/src/usage/scan.ts +2 -1
- package/src/util.ts +10 -93
- package/src/web/html.ts +2 -33
- package/src/db/defaults.ts +0 -34
- package/src/db/getters.ts +0 -35
- package/src/db/index.ts +0 -7
- package/src/db/load.ts +0 -57
- package/src/db/types.ts +0 -77
- package/src/mcp/types.ts +0 -54
- package/src/test.ts +0 -2
- /package/src/{mcp → adapters/mcp}/tools/index.ts +0 -0
- /package/src/{mcp → adapters/mcp}/transport.ts +0 -0
package/src/memory.ts
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
|
-
// src/memory.ts — memory domain: config.memory.* shell adapter helpers
|
|
2
|
-
//
|
|
3
|
-
// ponytail: dedupe close-command logic that was copy-pasted in run.ts + stop.ts
|
|
1
|
+
// src/memory.ts — memory domain: config.memory.* shell adapter helpers.
|
|
2
|
+
// The shared mem-log reader now lives in src/core/mem-log.ts (PLAN-lib-layer chunk 2b).
|
|
4
3
|
|
|
5
4
|
import { execSync } from "node:child_process";
|
|
6
|
-
import {
|
|
7
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
8
|
-
import { CONFIG_FILENAME, DEFAULT_MEM_DIR, FAPONY_DIR } from "./db/defaults.js";
|
|
9
|
-
import { type Config, loadConfig, safetyDeny } from "./db/index.js";
|
|
5
|
+
import type { Config } from "./core/config.js";
|
|
10
6
|
import { assertSafe } from "./safety.js";
|
|
11
7
|
import { templateArgs } from "./util.js";
|
|
12
8
|
|
|
9
|
+
// Re-export mem-log reader for existing callers (gate, hook, digest, mcp, seed, mem).
|
|
10
|
+
export {
|
|
11
|
+
type MemDirResult,
|
|
12
|
+
type MemRow,
|
|
13
|
+
readMemLog,
|
|
14
|
+
readRecentMemDecisions,
|
|
15
|
+
resolveMemDir,
|
|
16
|
+
whereMemDir,
|
|
17
|
+
} from "./core/mem-log.js";
|
|
18
|
+
|
|
19
|
+
// Internal import used by resolveMemoryConfig below.
|
|
20
|
+
import {
|
|
21
|
+
resolveMemDir as _resolveMemDir,
|
|
22
|
+
walkUpForMemDir,
|
|
23
|
+
} from "./core/mem-log.js";
|
|
24
|
+
|
|
13
25
|
/** Default memory commands — built into fapony via `fapony mem <sub>`. */
|
|
14
26
|
export const DEFAULT_MEMORY: Config["memory"] = {
|
|
15
27
|
claim: ["fapony", "mem", "claim", "{id}"],
|
|
@@ -23,10 +35,6 @@ export const DEFAULT_MEMORY: Config["memory"] = {
|
|
|
23
35
|
* - explicit config.memory wins if set
|
|
24
36
|
* - fallback: a mem log dir exists (any `.fapony/.memory/`) → DEFAULT_MEMORY
|
|
25
37
|
* - otherwise null (no memory)
|
|
26
|
-
*
|
|
27
|
-
* Since PLAN-agent-one-call the code is built into fapony (`fapony mem`), so the
|
|
28
|
-
* old gate on `.fapony/.memory/mem.ts` no longer means anything — that file is
|
|
29
|
-
* not scaffolded and `init-mem` deletes it. Gate on the dir the writer needs.
|
|
30
38
|
*/
|
|
31
39
|
export function resolveMemoryConfig(
|
|
32
40
|
config: Config,
|
|
@@ -34,9 +42,8 @@ export function resolveMemoryConfig(
|
|
|
34
42
|
): Config["memory"] {
|
|
35
43
|
if (config.memory) return config.memory;
|
|
36
44
|
// A real log is not required for wiring: `fapony init` scaffolds an empty
|
|
37
|
-
// `.fapony/.memory/` and the writer creates the first log there.
|
|
38
|
-
|
|
39
|
-
if (resolveMemDir(worktree) || walkUpForMemDir(worktree, true)) {
|
|
45
|
+
// `.fapony/.memory/` and the writer creates the first log there.
|
|
46
|
+
if (_resolveMemDir(worktree) || walkUpForMemDir(worktree, true)) {
|
|
40
47
|
return DEFAULT_MEMORY;
|
|
41
48
|
}
|
|
42
49
|
return null;
|
|
@@ -52,7 +59,7 @@ export function closeMemory(
|
|
|
52
59
|
if (!mem) return;
|
|
53
60
|
try {
|
|
54
61
|
const cmd = templateArgs(mem.close, { id: memId, msg });
|
|
55
|
-
assertSafe(cmd,
|
|
62
|
+
assertSafe(cmd, config.safety?.deny ?? []);
|
|
56
63
|
execSync(cmd.join(" "), {
|
|
57
64
|
cwd: worktree,
|
|
58
65
|
stdio: "ignore",
|
|
@@ -67,16 +74,17 @@ export function claimMemory(
|
|
|
67
74
|
config: Config,
|
|
68
75
|
worktree: string,
|
|
69
76
|
memId: string,
|
|
77
|
+
timeoutMs = 15_000,
|
|
70
78
|
): boolean {
|
|
71
79
|
const mem = resolveMemoryConfig(config, worktree);
|
|
72
80
|
if (!mem) return false;
|
|
73
81
|
try {
|
|
74
82
|
const cmd = templateArgs(mem.claim, { id: memId });
|
|
75
|
-
assertSafe(cmd,
|
|
83
|
+
assertSafe(cmd, config.safety?.deny ?? []);
|
|
76
84
|
execSync(cmd.join(" "), {
|
|
77
85
|
cwd: worktree,
|
|
78
86
|
stdio: ["pipe", "pipe", "pipe"],
|
|
79
|
-
timeout:
|
|
87
|
+
timeout: timeoutMs,
|
|
80
88
|
});
|
|
81
89
|
return true;
|
|
82
90
|
} catch {
|
|
@@ -89,7 +97,7 @@ export function kickoffMemory(config: Config, worktree: string): string | null {
|
|
|
89
97
|
const mem = resolveMemoryConfig(config, worktree);
|
|
90
98
|
if (!mem?.kickoff) return null;
|
|
91
99
|
try {
|
|
92
|
-
assertSafe(mem.kickoff,
|
|
100
|
+
assertSafe(mem.kickoff, config.safety?.deny ?? []);
|
|
93
101
|
return execSync(mem.kickoff.join(" "), {
|
|
94
102
|
cwd: worktree,
|
|
95
103
|
encoding: "utf-8",
|
|
@@ -99,373 +107,3 @@ export function kickoffMemory(config: Config, worktree: string): string | null {
|
|
|
99
107
|
return null;
|
|
100
108
|
}
|
|
101
109
|
}
|
|
102
|
-
|
|
103
|
-
// --- mem log reading ---
|
|
104
|
-
//
|
|
105
|
-
// Shared by `fapony digest` and `project_health_context`: both need the same
|
|
106
|
-
// directory discovery (new `.fapony/.memory`, legacy `.memory/` fallback) and
|
|
107
|
-
// the same rotated-file handling. Kept here so the two callers cannot drift.
|
|
108
|
-
|
|
109
|
-
export interface MemRow {
|
|
110
|
-
ts: string;
|
|
111
|
-
agent: string;
|
|
112
|
-
kind: string;
|
|
113
|
-
text: string;
|
|
114
|
-
spec?: string;
|
|
115
|
-
id?: string;
|
|
116
|
-
ref?: string;
|
|
117
|
-
/** Files the row is about — written by `mem add --files` (PLAN-convention-debt chunk 3). */
|
|
118
|
-
files?: string[];
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
interface RawMemRow {
|
|
122
|
-
ts?: string;
|
|
123
|
-
agent?: string;
|
|
124
|
-
kind?: string;
|
|
125
|
-
text?: string;
|
|
126
|
-
spec?: string;
|
|
127
|
-
id?: string;
|
|
128
|
-
ref?: string;
|
|
129
|
-
files?: unknown;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* A directory is a mem dir only when it holds a `log.jsonl` or a named
|
|
134
|
-
* `log.<person>.jsonl` (rotated `log.YYYY-MM-DD.jsonl` archives count too).
|
|
135
|
-
* A `.fapony/.memory/` scaffolded empty by `fapony init` is not a candidate —
|
|
136
|
-
* otherwise it would shadow an ancestor that holds the real log (SPEC §1).
|
|
137
|
-
*/
|
|
138
|
-
function hasMemLogs(dir: string): boolean {
|
|
139
|
-
try {
|
|
140
|
-
return readdirSync(dir).some(
|
|
141
|
-
(f) => f === "log.jsonl" || /^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f),
|
|
142
|
-
);
|
|
143
|
-
} catch {
|
|
144
|
-
return false;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Physical path (symlinks resolved) so start and repo root compare like with
|
|
149
|
-
* like; falls back to a lexical resolve when the path does not exist yet. */
|
|
150
|
-
function physical(p: string): string {
|
|
151
|
-
try {
|
|
152
|
-
return realpathSync(p);
|
|
153
|
-
} catch {
|
|
154
|
-
return resolve(p);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** `git rev-parse --show-toplevel` from a directory, or null outside a repo. */
|
|
159
|
-
function gitRootOf(fromDir: string): string | null {
|
|
160
|
-
try {
|
|
161
|
-
const root = execSync("git rev-parse --show-toplevel", {
|
|
162
|
-
cwd: fromDir,
|
|
163
|
-
encoding: "utf-8",
|
|
164
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
165
|
-
}).trim();
|
|
166
|
-
return root || null;
|
|
167
|
-
} catch {
|
|
168
|
-
return null;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* The lexical ancestor of `fromDir` that is the git repo root, or null outside
|
|
174
|
-
* a repo. `git rev-parse` returns a physical path (it resolves /var → /private/
|
|
175
|
-
* var on macOS), so the walk compares `physical(dir)` against it and returns the
|
|
176
|
-
* path in the caller's own lexical form — the returned dir must match what the
|
|
177
|
-
* caller passed in, not a canonicalized stranger.
|
|
178
|
-
*/
|
|
179
|
-
function repoRootOf(fromDir: string): string | null {
|
|
180
|
-
const start = resolve(fromDir);
|
|
181
|
-
const gitRoot = gitRootOf(start);
|
|
182
|
-
if (!gitRoot) return null;
|
|
183
|
-
const physicalRoot = physical(gitRoot);
|
|
184
|
-
let dir = start;
|
|
185
|
-
while (true) {
|
|
186
|
-
if (physical(dir) === physicalRoot) return dir;
|
|
187
|
-
const parent = dirname(dir);
|
|
188
|
-
if (parent === dir) return null;
|
|
189
|
-
dir = parent;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Locate the nearest `.fapony/.memory/` by walking up from `fromDir`.
|
|
195
|
-
*
|
|
196
|
-
* The walk stops at the git repo root (SPEC §1) — a mem dir in a parent
|
|
197
|
-
* checkout is never ours. Outside a repo it stops at the filesystem root.
|
|
198
|
-
* `acceptEmpty` is for the default-wiring check, which only needs the dir the
|
|
199
|
-
* writer will use (log or not); the resolver itself requires a real log.
|
|
200
|
-
*/
|
|
201
|
-
function walkUpForMemDir(fromDir: string, acceptEmpty: boolean): string | null {
|
|
202
|
-
const start = resolve(fromDir);
|
|
203
|
-
const boundary = repoRootOf(start) ?? "/";
|
|
204
|
-
let dir = start;
|
|
205
|
-
while (true) {
|
|
206
|
-
const candidate = join(dir, DEFAULT_MEM_DIR);
|
|
207
|
-
if (existsSync(candidate) && (acceptEmpty || hasMemLogs(candidate))) {
|
|
208
|
-
return candidate;
|
|
209
|
-
}
|
|
210
|
-
if (dir === boundary) break;
|
|
211
|
-
const parent = dirname(dir);
|
|
212
|
-
if (parent === dir) break;
|
|
213
|
-
dir = parent;
|
|
214
|
-
}
|
|
215
|
-
return null;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
export interface MemDirResult {
|
|
219
|
-
dir: string | null;
|
|
220
|
-
step: "flag" | "config" | "walk-up" | "repo-root" | "ambiguous" | "none";
|
|
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
|
-
*/
|
|
228
|
-
candidates?: string[];
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Dirs that never hold a project mem log and are expensive to walk.
|
|
232
|
-
const SCAN_SKIP = new Set([
|
|
233
|
-
"node_modules",
|
|
234
|
-
".git",
|
|
235
|
-
"dist",
|
|
236
|
-
"build",
|
|
237
|
-
"coverage",
|
|
238
|
-
"vendor",
|
|
239
|
-
"out",
|
|
240
|
-
"target",
|
|
241
|
-
".next",
|
|
242
|
-
".turbo",
|
|
243
|
-
".cache",
|
|
244
|
-
]);
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Every `.fapony/.memory/` under `root` that holds a real log, bounded to a
|
|
248
|
-
* shallow walk. Used only to detect the ambiguous monorepo layout (SPEC §1
|
|
249
|
-
* fail example): if two or more app-scoped logs exist and nothing at/above cwd
|
|
250
|
-
* holds one, the caller must refuse rather than start a third log at the root.
|
|
251
|
-
*/
|
|
252
|
-
function findMemDirsUnder(root: string): string[] {
|
|
253
|
-
const found: string[] = [];
|
|
254
|
-
const walk = (dir: string, depth: number): void => {
|
|
255
|
-
if (depth > 5) return;
|
|
256
|
-
let entries: import("node:fs").Dirent[];
|
|
257
|
-
try {
|
|
258
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
259
|
-
} catch {
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
for (const e of entries) {
|
|
263
|
-
if (!e.isDirectory()) continue;
|
|
264
|
-
if (e.name === FAPONY_DIR) {
|
|
265
|
-
const candidate = join(dir, DEFAULT_MEM_DIR);
|
|
266
|
-
if (hasMemLogs(candidate)) found.push(candidate);
|
|
267
|
-
continue;
|
|
268
|
-
}
|
|
269
|
-
if (e.name.startsWith(".") || SCAN_SKIP.has(e.name)) continue;
|
|
270
|
-
walk(join(dir, e.name), depth + 1);
|
|
271
|
-
}
|
|
272
|
-
};
|
|
273
|
-
walk(root, 0);
|
|
274
|
-
return found;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* The single resolver: returns the mem dir and which step won.
|
|
279
|
-
*
|
|
280
|
-
* Resolution order (SPEC §1):
|
|
281
|
-
* 1. `--mem-dir <path>` (wins over everything)
|
|
282
|
-
* 2. `paths.memDir` in the **repo-root** `fapony.config.json` (relative to root)
|
|
283
|
-
* 3. walk up from cwd for the first `.fapony/.memory/` that holds a real log
|
|
284
|
-
* 4. `<repo root>/.fapony/.memory/`
|
|
285
|
-
*
|
|
286
|
-
* `.memory/` (outside `.fapony/`) and the monorepo app-guess (`wt-<app>`,
|
|
287
|
-
* `MEM_APP`) are both dead — they are not in the order and never return.
|
|
288
|
-
*/
|
|
289
|
-
function resolveMemDirFrom(
|
|
290
|
-
fromDir: string,
|
|
291
|
-
explicitOverride?: string,
|
|
292
|
-
): MemDirResult {
|
|
293
|
-
// Step 1: --mem-dir flag
|
|
294
|
-
if (explicitOverride) {
|
|
295
|
-
return {
|
|
296
|
-
dir: existsSync(explicitOverride) ? resolve(explicitOverride) : null,
|
|
297
|
-
step: "flag",
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const cwd = resolve(fromDir);
|
|
302
|
-
const root = repoRootOf(cwd);
|
|
303
|
-
|
|
304
|
-
// Step 2: paths.memDir in the repo-root config (FAPONY_CONFIG is not a
|
|
305
|
-
// path source here — spec §1 pins this to fapony.config.json)
|
|
306
|
-
const configDir = root ?? cwd;
|
|
307
|
-
try {
|
|
308
|
-
const configPath = join(configDir, CONFIG_FILENAME);
|
|
309
|
-
if (existsSync(configPath)) {
|
|
310
|
-
const config = loadConfig(configPath);
|
|
311
|
-
if (config?.paths?.memDir) {
|
|
312
|
-
const absCfg = isAbsolute(config.paths.memDir)
|
|
313
|
-
? config.paths.memDir
|
|
314
|
-
: join(configDir, config.paths.memDir);
|
|
315
|
-
if (existsSync(absCfg)) return { dir: absCfg, step: "config" };
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
} catch {
|
|
319
|
-
// no config or unreadable — continue
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Step 3: walk up from cwd, only a dir with a real log counts
|
|
323
|
-
const walked = walkUpForMemDir(cwd, false);
|
|
324
|
-
if (walked) return { dir: walked, step: "walk-up" };
|
|
325
|
-
|
|
326
|
-
// Guard (SPEC §1 fail example): nothing at/above cwd holds a log, but the repo
|
|
327
|
-
// has two or more app-scoped ones — refuse instead of silently creating a
|
|
328
|
-
// third log at the root that no app-scoped reader will ever see.
|
|
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 };
|
|
334
|
-
}
|
|
335
|
-
const outOfScope = candidates.length ? { candidates } : {};
|
|
336
|
-
|
|
337
|
-
// Step 4: <repo root>/.fapony/.memory/ — where a new log is created
|
|
338
|
-
if (root) {
|
|
339
|
-
const rootDir = join(root, DEFAULT_MEM_DIR);
|
|
340
|
-
if (existsSync(rootDir)) {
|
|
341
|
-
return { dir: rootDir, step: "repo-root", ...outOfScope };
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
return { dir: null, step: "none", ...outOfScope };
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
export function resolveMemDir(
|
|
349
|
-
worktree?: string,
|
|
350
|
-
explicitOverride?: string,
|
|
351
|
-
): string | null {
|
|
352
|
-
return resolveMemDirFrom(worktree ?? process.cwd(), explicitOverride).dir;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/** Resolve and report which step won — for `fapony mem where`. */
|
|
356
|
-
export function whereMemDir(
|
|
357
|
-
fromDir?: string,
|
|
358
|
-
explicitOverride?: string,
|
|
359
|
-
): MemDirResult {
|
|
360
|
-
return resolveMemDirFrom(fromDir ?? process.cwd(), explicitOverride);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* Read every `log*.jsonl` row under the worktree's memory dir, newest first.
|
|
365
|
-
* `sinceIso` (exclusive) drops older rows; omit it to read the whole log.
|
|
366
|
-
* Malformed/unreadable rows are counted, never thrown — a corrupt line must
|
|
367
|
-
* not take down the caller.
|
|
368
|
-
*/
|
|
369
|
-
export function readMemLog(
|
|
370
|
-
worktree: string,
|
|
371
|
-
sinceIso?: string,
|
|
372
|
-
): { rows: MemRow[]; skipped: number; filesFound: number } {
|
|
373
|
-
const dir = resolveMemDir(worktree);
|
|
374
|
-
if (!dir) return { rows: [], skipped: 0, filesFound: 0 };
|
|
375
|
-
|
|
376
|
-
// Every log*.jsonl, rotated archives (log.YYYY-MM-DD.jsonl) included. They
|
|
377
|
-
// used to be excluded, which meant the day a repo crossed the rotate
|
|
378
|
-
// threshold mem_find silently forgot everything already closed — the rows
|
|
379
|
-
// most worth recalling. Rotate exists to keep the live file small, not to
|
|
380
|
-
// decide what is still remembered.
|
|
381
|
-
const isLogFile = (f: string): boolean =>
|
|
382
|
-
f === "log.jsonl" || /^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f);
|
|
383
|
-
|
|
384
|
-
let files: string[];
|
|
385
|
-
try {
|
|
386
|
-
files = readdirSync(dir)
|
|
387
|
-
.filter(isLogFile)
|
|
388
|
-
.sort()
|
|
389
|
-
.map((f) => join(dir, f));
|
|
390
|
-
} catch {
|
|
391
|
-
return { rows: [], skipped: 0, filesFound: 0 };
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
if (files.length === 0) return { rows: [], skipped: 0, filesFound: 0 };
|
|
395
|
-
|
|
396
|
-
let skipped = 0;
|
|
397
|
-
const all: MemRow[] = [];
|
|
398
|
-
|
|
399
|
-
for (const file of files) {
|
|
400
|
-
let raw: string;
|
|
401
|
-
try {
|
|
402
|
-
raw = readFileSync(file, "utf-8");
|
|
403
|
-
} catch {
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
406
|
-
const lines = raw.split("\n").filter(Boolean);
|
|
407
|
-
for (const line of lines) {
|
|
408
|
-
let parsed: RawMemRow;
|
|
409
|
-
try {
|
|
410
|
-
parsed = JSON.parse(line) as RawMemRow;
|
|
411
|
-
} catch {
|
|
412
|
-
skipped++;
|
|
413
|
-
continue;
|
|
414
|
-
}
|
|
415
|
-
if (!parsed.ts || !parsed.kind) {
|
|
416
|
-
skipped++;
|
|
417
|
-
continue;
|
|
418
|
-
}
|
|
419
|
-
if (sinceIso && parsed.ts < sinceIso) continue;
|
|
420
|
-
all.push({
|
|
421
|
-
ts: parsed.ts,
|
|
422
|
-
agent: parsed.agent ?? "unknown",
|
|
423
|
-
kind: parsed.kind,
|
|
424
|
-
text: parsed.text ?? "",
|
|
425
|
-
spec: parsed.spec,
|
|
426
|
-
id: parsed.id,
|
|
427
|
-
ref: parsed.ref,
|
|
428
|
-
...(Array.isArray(parsed.files)
|
|
429
|
-
? { files: parsed.files.filter((f) => typeof f === "string") }
|
|
430
|
-
: {}),
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
all.sort((a, b) => b.ts.localeCompare(a.ts)); // newest first
|
|
436
|
-
return { rows: all, skipped, filesFound: files.length };
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* Decisions for the pre-edit context summary — newest first, capped at `limit`.
|
|
441
|
-
*
|
|
442
|
-
* `keywords` (usual suspects: the files about to be touched) are a
|
|
443
|
-
* *preference*, not a filter: decisions mentioning one surface first, but when
|
|
444
|
-
* nothing matches the newest decisions still come back. A summary that goes
|
|
445
|
-
* silent because a path never appeared in a decision is worse than a recent one.
|
|
446
|
-
*/
|
|
447
|
-
export function readRecentMemDecisions(
|
|
448
|
-
worktree: string,
|
|
449
|
-
limit: number,
|
|
450
|
-
keywords?: string[],
|
|
451
|
-
): MemRow[] {
|
|
452
|
-
let decisions: MemRow[];
|
|
453
|
-
try {
|
|
454
|
-
decisions = readMemLog(worktree).rows.filter((r) => r.kind === "decision");
|
|
455
|
-
} catch {
|
|
456
|
-
return [];
|
|
457
|
-
}
|
|
458
|
-
if (decisions.length === 0) return [];
|
|
459
|
-
|
|
460
|
-
const kws = (keywords ?? [])
|
|
461
|
-
.map((k) => k.toLowerCase())
|
|
462
|
-
.filter((k) => k.length > 0);
|
|
463
|
-
if (kws.length > 0) {
|
|
464
|
-
const hits = decisions.filter((r) => {
|
|
465
|
-
const hay = `${r.text}\n${r.spec ?? ""}`.toLowerCase();
|
|
466
|
-
return kws.some((k) => hay.includes(k));
|
|
467
|
-
});
|
|
468
|
-
if (hits.length > 0) return hits.slice(0, limit);
|
|
469
|
-
}
|
|
470
|
-
return decisions.slice(0, limit);
|
|
471
|
-
}
|
package/src/parse.ts
CHANGED
|
@@ -1,71 +1,9 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
| "pass-adequate"
|
|
11
|
-
| "pass"
|
|
12
|
-
| "fail"
|
|
13
|
-
| "uncertain";
|
|
14
|
-
|
|
15
|
-
export const VERDICT_GRADES: ReadonlySet<string> = new Set<VerdictGrade>([
|
|
16
|
-
"pass-excellent",
|
|
17
|
-
"pass-good",
|
|
18
|
-
"pass-adequate",
|
|
19
|
-
"pass",
|
|
20
|
-
"fail",
|
|
21
|
-
"uncertain",
|
|
22
|
-
]);
|
|
23
|
-
|
|
24
|
-
/** True when verdict belongs to the pass family (any pass-* variant). */
|
|
25
|
-
export function isPassFamily(v: string): v is VerdictGrade {
|
|
26
|
-
return v.startsWith("pass");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Locked qualityScore mapping — read-time only (never written to events).
|
|
31
|
-
* Values from SPEC-verdict-protocol §qualityScore (additive-only, never change).
|
|
32
|
-
*/
|
|
33
|
-
const SCORE_MAP: Record<VerdictGrade, number> = {
|
|
34
|
-
"pass-excellent": 5,
|
|
35
|
-
"pass-good": 4,
|
|
36
|
-
"pass-adequate": 3,
|
|
37
|
-
pass: 3,
|
|
38
|
-
fail: 0,
|
|
39
|
-
uncertain: 1,
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
export function qualityScore(grade: VerdictGrade): number {
|
|
43
|
-
return SCORE_MAP[grade] ?? 0;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface GateVerdict {
|
|
47
|
-
verdict: VerdictGrade;
|
|
48
|
-
note: string;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Read a verdict back from a stored gate event (`kind='gate'`).
|
|
53
|
-
* Gate events are JSON (`{verdict, note, round}` from gateOnce) — NOT
|
|
54
|
-
* reviewer stdout. Returns null for missing/unparseable data or unknown grades.
|
|
55
|
-
*/
|
|
56
|
-
export function parseGateEventData(data: string | null): GateVerdict | null {
|
|
57
|
-
if (!data) return null;
|
|
58
|
-
let parsed: unknown;
|
|
59
|
-
try {
|
|
60
|
-
parsed = JSON.parse(data);
|
|
61
|
-
} catch {
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
if (!parsed || typeof parsed !== "object") return null;
|
|
65
|
-
const { verdict, note } = parsed as { verdict?: unknown; note?: unknown };
|
|
66
|
-
if (typeof verdict !== "string" || !VERDICT_GRADES.has(verdict)) return null;
|
|
67
|
-
return {
|
|
68
|
-
verdict: verdict as VerdictGrade,
|
|
69
|
-
note: typeof note === "string" ? note : "",
|
|
70
|
-
};
|
|
71
|
-
}
|
|
1
|
+
// shim — moved to src/core/parse.ts
|
|
2
|
+
export {
|
|
3
|
+
type GateVerdict,
|
|
4
|
+
isPassFamily,
|
|
5
|
+
parseGateEventData,
|
|
6
|
+
qualityScore,
|
|
7
|
+
VERDICT_GRADES,
|
|
8
|
+
type VerdictGrade,
|
|
9
|
+
} from "./core/parse.js";
|
package/src/price/fetch.ts
CHANGED
|
@@ -13,26 +13,14 @@ import {
|
|
|
13
13
|
writeFileSync,
|
|
14
14
|
} from "node:fs";
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
|
-
import { faponyDir } from "../
|
|
17
|
-
import type {
|
|
16
|
+
import { type Config, faponyDir } from "../core/config.js";
|
|
17
|
+
import type { ModelRates, PriceTable } from "../core/types.js";
|
|
18
|
+
|
|
19
|
+
export type { ModelRates, PriceTable } from "../core/types.js";
|
|
18
20
|
|
|
19
21
|
const PRICES_FILENAME = "prices.json";
|
|
20
22
|
const MODELS_URL = "https://openrouter.ai/api/v1/models";
|
|
21
23
|
|
|
22
|
-
/** per-token rates (dollars) — every rate comes straight from OpenRouter, no guessing */
|
|
23
|
-
export interface ModelRates {
|
|
24
|
-
input: number;
|
|
25
|
-
output: number;
|
|
26
|
-
cacheRead: number;
|
|
27
|
-
/** null = the table does not provide it → use the input rate instead (see calcCost) */
|
|
28
|
-
cacheWrite: number | null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface PriceTable {
|
|
32
|
-
fetched_at: string;
|
|
33
|
-
models: Record<string, ModelRates>;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
24
|
export function pricesPath(config?: Config): string {
|
|
37
25
|
return join(faponyDir(config), PRICES_FILENAME);
|
|
38
26
|
}
|