fapony 0.1.2 → 0.2.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.
- package/README.md +94 -77
- package/fapony.ts +32 -1
- package/images/logo.png +0 -0
- package/images/logo.webp +0 -0
- package/images/logo@400.webp +0 -0
- package/images/sample.webp +0 -0
- package/images/summary.webp +0 -0
- package/package.json +5 -3
- package/skill/move-to-done/SKILL.md +18 -28
- package/skill/plan-with-pony/SKILL.md +8 -3
- package/src/analyze.ts +13 -1
- package/src/conventions-seed.ts +0 -1
- package/src/db/defaults.ts +1 -1
- package/src/db/getters.ts +3 -3
- package/src/db/store.ts +0 -75
- package/src/db/types.ts +1 -1
- package/src/debt.ts +169 -34
- package/src/digest/collect.ts +16 -0
- package/src/digest/text.ts +25 -0
- package/src/hook.ts +424 -16
- package/src/init-mem.ts +123 -37
- package/src/init.ts +51 -34
- package/src/install/claude.ts +7 -5
- package/src/install/opencode.ts +59 -5
- package/src/lint-baseline.ts +1 -7
- package/src/mcp/evidence.ts +1 -1
- package/src/mcp/tools/index.ts +97 -185
- package/src/mcp/tools/mem.ts +153 -11
- package/src/mcp/transport.ts +5 -13
- package/src/mem/commands/read.ts +448 -0
- package/src/mem/commands/where.ts +56 -0
- package/{templates → src}/mem/commands/write.ts +12 -5
- package/src/mem/index.ts +144 -0
- package/src/mem/store.ts +348 -0
- package/src/memory.ts +237 -40
- package/src/plan-seed.ts +20 -1
- package/src/review-seed.ts +19 -0
- package/src/setup.ts +4 -6
- package/src/stats/data.ts +34 -86
- package/src/stats/format.ts +8 -9
- package/src/stats/index.ts +0 -1
- package/templates/PLAN.md +1 -0
- package/src/mcp/tools/context.ts +0 -66
- package/src/mcp/tools/plans.ts +0 -255
- package/src/mcp/tools/stats.ts +0 -96
- package/templates/mem/commands/read.ts +0 -194
- package/templates/mem/commands/selftest.ts +0 -450
- package/templates/mem/mem.ts +0 -68
- package/templates/mem/store.ts +0 -285
- /package/{templates → src}/mem/commands/plan.ts +0 -0
- /package/{templates → src}/mem/commands/rotate.ts +0 -0
- /package/{templates → src}/mem/render.ts +0 -0
- /package/{templates → src}/mem/selectors.ts +0 -0
package/src/mem/store.ts
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// store.ts — types + config + read/write primitives for the append-only memory log
|
|
2
|
+
//
|
|
3
|
+
// Moved from templates/mem/store.ts (2026-09-19) as part of PLAN-agent-one-call chunk 1.
|
|
4
|
+
// The template was copied into every repo via `fapony init-mem`; now fapony owns the code
|
|
5
|
+
// and calls it directly via `fapony mem <sub>`. Path resolution is no longer based on
|
|
6
|
+
// import.meta.dir — initStore() receives the worktree explicitly from the CLI dispatch.
|
|
7
|
+
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import {
|
|
10
|
+
appendFileSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { hostname, networkInterfaces } from "node:os";
|
|
17
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
18
|
+
import { whereMemDir } from "../memory.js";
|
|
19
|
+
|
|
20
|
+
// --- types ---
|
|
21
|
+
|
|
22
|
+
type WorkKind = "next" | "bug" | "decision" | "note" | "hold";
|
|
23
|
+
|
|
24
|
+
type WorkRow = {
|
|
25
|
+
ts: string;
|
|
26
|
+
agent: string;
|
|
27
|
+
id: string;
|
|
28
|
+
kind: WorkKind;
|
|
29
|
+
text: string;
|
|
30
|
+
spec?: string;
|
|
31
|
+
files?: string[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type CloseRow = {
|
|
35
|
+
ts: string;
|
|
36
|
+
agent: string;
|
|
37
|
+
kind: "close";
|
|
38
|
+
ref: string;
|
|
39
|
+
text: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type ClaimRow = {
|
|
43
|
+
ts: string;
|
|
44
|
+
agent: string;
|
|
45
|
+
kind: "claim";
|
|
46
|
+
ref: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type ReleaseRow = {
|
|
50
|
+
ts: string;
|
|
51
|
+
agent: string;
|
|
52
|
+
kind: "release";
|
|
53
|
+
ref: string;
|
|
54
|
+
text?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type SyncedRow = {
|
|
58
|
+
ts: string;
|
|
59
|
+
agent: string;
|
|
60
|
+
kind: "synced";
|
|
61
|
+
spec: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type LogRow = WorkRow | CloseRow | ClaimRow | ReleaseRow | SyncedRow;
|
|
65
|
+
|
|
66
|
+
// --- identity ---
|
|
67
|
+
//
|
|
68
|
+
// `agent` goes in every row, `person` names the file. Both used to end in
|
|
69
|
+
// "unknown", which is not a fallback: two machines with no git and no $USER
|
|
70
|
+
// wrote the same filename and their rows interleaved with no way to tell them
|
|
71
|
+
// apart. Generic OS accounts collide the same way — `admin`/`user`/`owner` are
|
|
72
|
+
// what a fresh install offers, so treat them as absent rather than as a name.
|
|
73
|
+
// git user.email is unique by construction; the machine tag is the last resort,
|
|
74
|
+
// hashed because this file is committed and a raw MAC address is not ours to
|
|
75
|
+
// publish. user.email is only spawned when the cheap sources fail — initStore
|
|
76
|
+
// sits on the mem_add hot path (git config ≈ 12ms).
|
|
77
|
+
|
|
78
|
+
const GENERIC_NAMES = new Set([
|
|
79
|
+
"admin",
|
|
80
|
+
"user",
|
|
81
|
+
"owner",
|
|
82
|
+
"root",
|
|
83
|
+
"ubuntu",
|
|
84
|
+
"ec2-user",
|
|
85
|
+
"vagrant",
|
|
86
|
+
"dev",
|
|
87
|
+
"developer",
|
|
88
|
+
"node",
|
|
89
|
+
"unknown",
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
const usable = (v: string | null | undefined): string | null => {
|
|
93
|
+
const s = (v ?? "").trim();
|
|
94
|
+
return s && !GENERIC_NAMES.has(s.toLowerCase()) ? s : null;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// `env: process.env` is not redundant: Bun.spawnSync snapshots the environment
|
|
98
|
+
// at startup, so a variable set at runtime (GIT_CONFIG_GLOBAL in a test, MEM_*
|
|
99
|
+
// set by a wrapper) never reaches the child without passing it explicitly.
|
|
100
|
+
const gitConfig = (key: string, cwd: string): string | null =>
|
|
101
|
+
Bun.spawnSync(["git", "config", key], { cwd, env: process.env })
|
|
102
|
+
.stdout.toString()
|
|
103
|
+
.trim() || null;
|
|
104
|
+
|
|
105
|
+
function machineSeed(): string {
|
|
106
|
+
try {
|
|
107
|
+
const id = readFileSync("/etc/machine-id", "utf8").trim();
|
|
108
|
+
if (id) return id;
|
|
109
|
+
} catch {
|
|
110
|
+
// no /etc/machine-id (macOS) — fall through to the interface list
|
|
111
|
+
}
|
|
112
|
+
for (const ifaces of Object.values(networkInterfaces()))
|
|
113
|
+
for (const i of ifaces ?? [])
|
|
114
|
+
if (!i.internal && i.mac && i.mac !== "00:00:00:00:00:00") return i.mac;
|
|
115
|
+
return hostname();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const machineTag = (): string =>
|
|
119
|
+
`m-${createHash("sha256").update(machineSeed()).digest("hex").slice(0, 8)}`;
|
|
120
|
+
|
|
121
|
+
const slug = (v: string): string =>
|
|
122
|
+
v
|
|
123
|
+
.toLowerCase()
|
|
124
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
125
|
+
.replace(/^-+|-+$/g, "");
|
|
126
|
+
|
|
127
|
+
// --- mutable state (populated by initStore) ---
|
|
128
|
+
|
|
129
|
+
let root = "";
|
|
130
|
+
let dir = "";
|
|
131
|
+
let planDir = "";
|
|
132
|
+
let doneDir = "";
|
|
133
|
+
let agent = "";
|
|
134
|
+
let person = "";
|
|
135
|
+
let LOG = "";
|
|
136
|
+
let memCmd = "";
|
|
137
|
+
let planBase = "";
|
|
138
|
+
let app = "";
|
|
139
|
+
// Non-empty when the resolver found two or more app-scoped mem dirs and no
|
|
140
|
+
// single one at/above cwd — a write must die rather than start a third log.
|
|
141
|
+
let blockWrite = "";
|
|
142
|
+
|
|
143
|
+
// --- init ---
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Initialize the store for a given worktree. Must be called before using any
|
|
147
|
+
* export. The CLI dispatch layer calls this with the resolved worktree path.
|
|
148
|
+
*
|
|
149
|
+
* `overrideMemDir` (from --mem-dir flag) skips resolution entirely.
|
|
150
|
+
* Otherwise whereMemDir() walks up from cwd — the same dir the reader
|
|
151
|
+
* (src/memory.ts) uses, so writer and reader cannot drift apart.
|
|
152
|
+
*/
|
|
153
|
+
export function initStore(worktree: string, overrideMemDir?: string): void {
|
|
154
|
+
root = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
155
|
+
cwd: worktree,
|
|
156
|
+
})
|
|
157
|
+
.stdout.toString()
|
|
158
|
+
.trim();
|
|
159
|
+
if (!root) root = worktree;
|
|
160
|
+
|
|
161
|
+
// An explicit --mem-dir is a promise: never silently fall back to the
|
|
162
|
+
// default when the path is wrong, or the row lands in a log nobody reads.
|
|
163
|
+
if (overrideMemDir && !existsSync(overrideMemDir)) {
|
|
164
|
+
throw new Error(`--mem-dir path does not exist: ${overrideMemDir}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Resolution and the CLI's `mem where` share one function, so the path a row
|
|
168
|
+
// is written to is the path `mem where` reports.
|
|
169
|
+
blockWrite = "";
|
|
170
|
+
const resolved = whereMemDir(worktree, overrideMemDir);
|
|
171
|
+
if (resolved.step === "ambiguous") {
|
|
172
|
+
const list = (resolved.candidates ?? []).map((c) => ` ${c}`).join("\n");
|
|
173
|
+
blockWrite =
|
|
174
|
+
`two or more .fapony/.memory dirs under ${root} — refusing to guess:\n` +
|
|
175
|
+
`${list}\ncd into one, set paths.memDir, or pass --mem-dir`;
|
|
176
|
+
}
|
|
177
|
+
dir = resolved.dir ?? join(root, ".fapony", ".memory");
|
|
178
|
+
|
|
179
|
+
// planBase: the .fapony dir — plan/done/conventions live here.
|
|
180
|
+
// Derive from dir by going up from .fapony/.memory → .fapony
|
|
181
|
+
planBase = dirname(dir);
|
|
182
|
+
|
|
183
|
+
// app name for display — the directory containing .fapony
|
|
184
|
+
app = dirname(planBase);
|
|
185
|
+
|
|
186
|
+
// Read fapony.config.json
|
|
187
|
+
const configDir = planBase === `${root}/.fapony` ? root : dirname(planBase);
|
|
188
|
+
const configPaths = ((): Record<string, string> => {
|
|
189
|
+
try {
|
|
190
|
+
const raw = readFileSync(`${configDir}/fapony.config.json`, "utf8");
|
|
191
|
+
return (JSON.parse(raw)?.paths ?? {}) as Record<string, string>;
|
|
192
|
+
} catch {
|
|
193
|
+
return {};
|
|
194
|
+
}
|
|
195
|
+
})();
|
|
196
|
+
const fromConfig = (key: string): string | null =>
|
|
197
|
+
typeof configPaths[key] === "string"
|
|
198
|
+
? join(configDir, configPaths[key])
|
|
199
|
+
: null;
|
|
200
|
+
|
|
201
|
+
planDir = fromConfig("planDir") ?? `${planBase}/plan`;
|
|
202
|
+
|
|
203
|
+
// done/ sits beside plan/ (same depth, relative links survive)
|
|
204
|
+
doneDir =
|
|
205
|
+
fromConfig("doneDir") ??
|
|
206
|
+
(!existsSync(`${planBase}/done`) && existsSync(`${planDir}/done`)
|
|
207
|
+
? `${planDir}/done`
|
|
208
|
+
: `${planBase}/done`);
|
|
209
|
+
|
|
210
|
+
let emailCache: string | null | undefined;
|
|
211
|
+
const email = (): string | null =>
|
|
212
|
+
emailCache !== undefined
|
|
213
|
+
? emailCache
|
|
214
|
+
: (emailCache = gitConfig("user.email", root));
|
|
215
|
+
|
|
216
|
+
agent =
|
|
217
|
+
process.env.MEM_AGENT?.trim() ||
|
|
218
|
+
usable(process.env.USER) ||
|
|
219
|
+
usable(email()) ||
|
|
220
|
+
machineTag();
|
|
221
|
+
|
|
222
|
+
// filename = *person*, not client — naming by MEM_AGENT would collide
|
|
223
|
+
person = slug(
|
|
224
|
+
usable(gitConfig("user.name", root)) ||
|
|
225
|
+
usable(process.env.USER) ||
|
|
226
|
+
usable(email()) ||
|
|
227
|
+
machineTag(),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
LOG = `${dir}/log.${person}.jsonl`;
|
|
231
|
+
memCmd = `fapony mem`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- log file helpers ---
|
|
235
|
+
|
|
236
|
+
const isLogFile = (f: string): boolean =>
|
|
237
|
+
f === "log.jsonl" ||
|
|
238
|
+
(/^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f) &&
|
|
239
|
+
!/^log\.\d{4}-\d{2}-\d{2}\.jsonl$/.test(f));
|
|
240
|
+
|
|
241
|
+
const listLogs = (match: (f: string) => boolean): string[] =>
|
|
242
|
+
existsSync(dir)
|
|
243
|
+
? readdirSync(dir)
|
|
244
|
+
.filter(match)
|
|
245
|
+
.sort()
|
|
246
|
+
.map((f) => join(dir, f))
|
|
247
|
+
: [];
|
|
248
|
+
|
|
249
|
+
const logFiles = (): string[] => listLogs(isLogFile);
|
|
250
|
+
|
|
251
|
+
const archivedFiles = (): string[] =>
|
|
252
|
+
listLogs((f) => /^log\.\d{4}-\d{2}-\d{2}\.jsonl$/.test(f));
|
|
253
|
+
|
|
254
|
+
// --- core ---
|
|
255
|
+
|
|
256
|
+
const parseLog = (f: string): LogRow[] =>
|
|
257
|
+
readFileSync(f, "utf8")
|
|
258
|
+
.split("\n")
|
|
259
|
+
.filter(Boolean)
|
|
260
|
+
.flatMap((l: string, i: number) => {
|
|
261
|
+
try {
|
|
262
|
+
return [JSON.parse(l) as LogRow];
|
|
263
|
+
} catch {
|
|
264
|
+
console.error(`[mem] skipped ${basename(f)} line ${i + 1} (bad JSON)`);
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const byTs = (a: LogRow, b: LogRow) => a.ts.localeCompare(b.ts);
|
|
270
|
+
|
|
271
|
+
// The live view: what `rotate` counts and what open-work commands read. It must
|
|
272
|
+
// stay archive-free or rotate re-imports the rows it just moved out and never
|
|
273
|
+
// gets under its own threshold.
|
|
274
|
+
const rows = (): LogRow[] => logFiles().flatMap(parseLog).sort(byTs);
|
|
275
|
+
|
|
276
|
+
// The recall view: rotated rows included. A bug closed six months ago is exactly
|
|
277
|
+
// what `find` exists to surface — rotate shrinks the live file, it does not
|
|
278
|
+
// decide what is still worth remembering.
|
|
279
|
+
const allRows = (): LogRow[] =>
|
|
280
|
+
[...logFiles(), ...archivedFiles()].flatMap(parseLog).sort(byTs);
|
|
281
|
+
|
|
282
|
+
function put(r: Omit<WorkRow, "ts" | "agent">): void;
|
|
283
|
+
function put(r: Omit<CloseRow, "ts" | "agent">): void;
|
|
284
|
+
function put(r: Omit<ClaimRow, "ts" | "agent">): void;
|
|
285
|
+
function put(r: Omit<ReleaseRow, "ts" | "agent">): void;
|
|
286
|
+
function put(r: Omit<SyncedRow, "ts" | "agent">): void;
|
|
287
|
+
function put(
|
|
288
|
+
r:
|
|
289
|
+
| Omit<WorkRow, "ts" | "agent">
|
|
290
|
+
| Omit<CloseRow, "ts" | "agent">
|
|
291
|
+
| Omit<ClaimRow, "ts" | "agent">
|
|
292
|
+
| Omit<ReleaseRow, "ts" | "agent">
|
|
293
|
+
| Omit<SyncedRow, "ts" | "agent">,
|
|
294
|
+
) {
|
|
295
|
+
if (blockWrite) throw new Error(blockWrite);
|
|
296
|
+
mkdirSync(dir, { recursive: true });
|
|
297
|
+
appendFileSync(
|
|
298
|
+
LOG,
|
|
299
|
+
`${JSON.stringify({ ts: new Date().toISOString(), agent, ...r })}\n`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function nextId(all: LogRow[]): string {
|
|
304
|
+
const used = new Set(all.map((r) => ("id" in r ? r.id : "")));
|
|
305
|
+
let base = Date.now();
|
|
306
|
+
let id = base.toString(36);
|
|
307
|
+
while (used.has(id)) {
|
|
308
|
+
id = base.toString(36) + Math.random().toString(36).slice(2, 4);
|
|
309
|
+
base++;
|
|
310
|
+
}
|
|
311
|
+
return id;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const appendRaw = (path: string, r: LogRow): void =>
|
|
315
|
+
appendFileSync(path, `${JSON.stringify(r)}\n`);
|
|
316
|
+
|
|
317
|
+
const rel = (p: string) => relative(root, p) || ".";
|
|
318
|
+
|
|
319
|
+
const KINDS: WorkKind[] = ["next", "bug", "decision", "note", "hold"];
|
|
320
|
+
|
|
321
|
+
export type {
|
|
322
|
+
ClaimRow,
|
|
323
|
+
CloseRow,
|
|
324
|
+
LogRow,
|
|
325
|
+
ReleaseRow,
|
|
326
|
+
SyncedRow,
|
|
327
|
+
WorkKind,
|
|
328
|
+
WorkRow,
|
|
329
|
+
};
|
|
330
|
+
export {
|
|
331
|
+
agent,
|
|
332
|
+
allRows,
|
|
333
|
+
app,
|
|
334
|
+
appendRaw,
|
|
335
|
+
dir,
|
|
336
|
+
doneDir,
|
|
337
|
+
KINDS,
|
|
338
|
+
LOG,
|
|
339
|
+
memCmd,
|
|
340
|
+
nextId,
|
|
341
|
+
person,
|
|
342
|
+
planBase,
|
|
343
|
+
planDir,
|
|
344
|
+
put,
|
|
345
|
+
rel,
|
|
346
|
+
root,
|
|
347
|
+
rows,
|
|
348
|
+
};
|
package/src/memory.ts
CHANGED
|
@@ -3,37 +3,41 @@
|
|
|
3
3
|
// ponytail: dedupe close-command logic that was copy-pasted in run.ts + stop.ts
|
|
4
4
|
|
|
5
5
|
import { execSync } from "node:child_process";
|
|
6
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
type Config,
|
|
10
|
-
DEFAULT_MEMORY_ENTRY,
|
|
11
|
-
memoryEntry,
|
|
12
|
-
safetyDeny,
|
|
13
|
-
} from "./db/index.js";
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
7
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
8
|
+
import { type Config, loadConfig, safetyDeny } from "./db/index.js";
|
|
14
9
|
import { assertSafe } from "./safety.js";
|
|
15
10
|
import { templateArgs } from "./util.js";
|
|
16
11
|
|
|
17
|
-
/** Default memory commands —
|
|
12
|
+
/** Default memory commands — built into fapony via `fapony mem <sub>`. */
|
|
18
13
|
export const DEFAULT_MEMORY: Config["memory"] = {
|
|
19
|
-
claim: ["
|
|
20
|
-
close: ["
|
|
21
|
-
add: ["
|
|
22
|
-
kickoff: ["
|
|
14
|
+
claim: ["fapony", "mem", "claim", "{id}"],
|
|
15
|
+
close: ["fapony", "mem", "close", "{id}", "{msg}"],
|
|
16
|
+
add: ["fapony", "mem", "add", "{kind}", "{text}"],
|
|
17
|
+
kickoff: ["fapony", "mem", "kickoff"],
|
|
23
18
|
};
|
|
24
19
|
|
|
25
20
|
/**
|
|
26
21
|
* Returns the effective memory config:
|
|
27
22
|
* - explicit config.memory wins if set
|
|
28
|
-
* - fallback:
|
|
23
|
+
* - fallback: a mem log dir exists (any `.fapony/.memory/`) → DEFAULT_MEMORY
|
|
29
24
|
* - otherwise null (no memory)
|
|
25
|
+
*
|
|
26
|
+
* Since PLAN-agent-one-call the code is built into fapony (`fapony mem`), so the
|
|
27
|
+
* old gate on `.fapony/.memory/mem.ts` no longer means anything — that file is
|
|
28
|
+
* not scaffolded and `init-mem` deletes it. Gate on the dir the writer needs.
|
|
30
29
|
*/
|
|
31
30
|
export function resolveMemoryConfig(
|
|
32
31
|
config: Config,
|
|
33
32
|
worktree: string,
|
|
34
33
|
): Config["memory"] {
|
|
35
34
|
if (config.memory) return config.memory;
|
|
36
|
-
|
|
35
|
+
// A real log is not required for wiring: `fapony init` scaffolds an empty
|
|
36
|
+
// `.fapony/.memory/` and the writer creates the first log there. Resolution
|
|
37
|
+
// proper (above) still skips empty dirs, so it cannot pick the wrong one.
|
|
38
|
+
if (resolveMemDir(worktree) || walkUpForMemDir(worktree, true)) {
|
|
39
|
+
return DEFAULT_MEMORY;
|
|
40
|
+
}
|
|
37
41
|
return null;
|
|
38
42
|
}
|
|
39
43
|
|
|
@@ -125,36 +129,227 @@ interface RawMemRow {
|
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
+
* A directory is a mem dir only when it holds a `log.jsonl` or a named
|
|
133
|
+
* `log.<person>.jsonl` (rotated `log.YYYY-MM-DD.jsonl` archives count too).
|
|
134
|
+
* A `.fapony/.memory/` scaffolded empty by `fapony init` is not a candidate —
|
|
135
|
+
* otherwise it would shadow an ancestor that holds the real log (SPEC §1).
|
|
132
136
|
*/
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
function hasMemLogs(dir: string): boolean {
|
|
138
|
+
try {
|
|
139
|
+
return readdirSync(dir).some(
|
|
140
|
+
(f) => f === "log.jsonl" || /^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f),
|
|
141
|
+
);
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Physical path (symlinks resolved) so start and repo root compare like with
|
|
148
|
+
* like; falls back to a lexical resolve when the path does not exist yet. */
|
|
149
|
+
function physical(p: string): string {
|
|
150
|
+
try {
|
|
151
|
+
return realpathSync(p);
|
|
152
|
+
} catch {
|
|
153
|
+
return resolve(p);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** `git rev-parse --show-toplevel` from a directory, or null outside a repo. */
|
|
158
|
+
function gitRootOf(fromDir: string): string | null {
|
|
159
|
+
try {
|
|
160
|
+
const root = execSync("git rev-parse --show-toplevel", {
|
|
161
|
+
cwd: fromDir,
|
|
162
|
+
encoding: "utf-8",
|
|
163
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
164
|
+
}).trim();
|
|
165
|
+
return root || null;
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
139
169
|
}
|
|
140
170
|
|
|
141
171
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
172
|
+
* The lexical ancestor of `fromDir` that is the git repo root, or null outside
|
|
173
|
+
* a repo. `git rev-parse` returns a physical path (it resolves /var → /private/
|
|
174
|
+
* var on macOS), so the walk compares `physical(dir)` against it and returns the
|
|
175
|
+
* path in the caller's own lexical form — the returned dir must match what the
|
|
176
|
+
* caller passed in, not a canonicalized stranger.
|
|
146
177
|
*/
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
178
|
+
function repoRootOf(fromDir: string): string | null {
|
|
179
|
+
const start = resolve(fromDir);
|
|
180
|
+
const gitRoot = gitRootOf(start);
|
|
181
|
+
if (!gitRoot) return null;
|
|
182
|
+
const physicalRoot = physical(gitRoot);
|
|
183
|
+
let dir = start;
|
|
184
|
+
while (true) {
|
|
185
|
+
if (physical(dir) === physicalRoot) return dir;
|
|
186
|
+
const parent = dirname(dir);
|
|
187
|
+
if (parent === dir) return null;
|
|
188
|
+
dir = parent;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
152
191
|
|
|
153
|
-
|
|
154
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Locate the nearest `.fapony/.memory/` by walking up from `fromDir`.
|
|
194
|
+
*
|
|
195
|
+
* The walk stops at the git repo root (SPEC §1) — a mem dir in a parent
|
|
196
|
+
* checkout is never ours. Outside a repo it stops at the filesystem root.
|
|
197
|
+
* `acceptEmpty` is for the default-wiring check, which only needs the dir the
|
|
198
|
+
* writer will use (log or not); the resolver itself requires a real log.
|
|
199
|
+
*/
|
|
200
|
+
function walkUpForMemDir(fromDir: string, acceptEmpty: boolean): string | null {
|
|
201
|
+
const start = resolve(fromDir);
|
|
202
|
+
const boundary = repoRootOf(start) ?? "/";
|
|
203
|
+
let dir = start;
|
|
204
|
+
while (true) {
|
|
205
|
+
const candidate = join(dir, ".fapony", ".memory");
|
|
206
|
+
if (existsSync(candidate) && (acceptEmpty || hasMemLogs(candidate))) {
|
|
207
|
+
return candidate;
|
|
208
|
+
}
|
|
209
|
+
if (dir === boundary) break;
|
|
210
|
+
const parent = dirname(dir);
|
|
211
|
+
if (parent === dir) break;
|
|
212
|
+
dir = parent;
|
|
213
|
+
}
|
|
155
214
|
return null;
|
|
156
215
|
}
|
|
157
216
|
|
|
217
|
+
export interface MemDirResult {
|
|
218
|
+
dir: string | null;
|
|
219
|
+
step: "flag" | "config" | "walk-up" | "repo-root" | "ambiguous" | "none";
|
|
220
|
+
/** For step "ambiguous" — the sibling `.fapony/.memory/` dirs that hold logs. */
|
|
221
|
+
candidates?: string[];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Dirs that never hold a project mem log and are expensive to walk.
|
|
225
|
+
const SCAN_SKIP = new Set([
|
|
226
|
+
"node_modules",
|
|
227
|
+
".git",
|
|
228
|
+
"dist",
|
|
229
|
+
"build",
|
|
230
|
+
"coverage",
|
|
231
|
+
"vendor",
|
|
232
|
+
"out",
|
|
233
|
+
"target",
|
|
234
|
+
".next",
|
|
235
|
+
".turbo",
|
|
236
|
+
".cache",
|
|
237
|
+
]);
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Every `.fapony/.memory/` under `root` that holds a real log, bounded to a
|
|
241
|
+
* shallow walk. Used only to detect the ambiguous monorepo layout (SPEC §1
|
|
242
|
+
* fail example): if two or more app-scoped logs exist and nothing at/above cwd
|
|
243
|
+
* holds one, the caller must refuse rather than start a third log at the root.
|
|
244
|
+
*/
|
|
245
|
+
function findMemDirsUnder(root: string): string[] {
|
|
246
|
+
const found: string[] = [];
|
|
247
|
+
const walk = (dir: string, depth: number): void => {
|
|
248
|
+
if (depth > 5) return;
|
|
249
|
+
let entries: import("node:fs").Dirent[];
|
|
250
|
+
try {
|
|
251
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
252
|
+
} catch {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
for (const e of entries) {
|
|
256
|
+
if (!e.isDirectory()) continue;
|
|
257
|
+
if (e.name === ".fapony") {
|
|
258
|
+
const candidate = join(dir, ".fapony", ".memory");
|
|
259
|
+
if (hasMemLogs(candidate)) found.push(candidate);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (e.name.startsWith(".") || SCAN_SKIP.has(e.name)) continue;
|
|
263
|
+
walk(join(dir, e.name), depth + 1);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
walk(root, 0);
|
|
267
|
+
return found;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The single resolver: returns the mem dir and which step won.
|
|
272
|
+
*
|
|
273
|
+
* Resolution order (SPEC §1):
|
|
274
|
+
* 1. `--mem-dir <path>` (wins over everything)
|
|
275
|
+
* 2. `paths.memDir` in the **repo-root** `fapony.config.json` (relative to root)
|
|
276
|
+
* 3. walk up from cwd for the first `.fapony/.memory/` that holds a real log
|
|
277
|
+
* 4. `<repo root>/.fapony/.memory/`
|
|
278
|
+
*
|
|
279
|
+
* `.memory/` (outside `.fapony/`) and the monorepo app-guess (`wt-<app>`,
|
|
280
|
+
* `MEM_APP`) are both dead — they are not in the order and never return.
|
|
281
|
+
*/
|
|
282
|
+
function resolveMemDirFrom(
|
|
283
|
+
fromDir: string,
|
|
284
|
+
explicitOverride?: string,
|
|
285
|
+
): MemDirResult {
|
|
286
|
+
// Step 1: --mem-dir flag
|
|
287
|
+
if (explicitOverride) {
|
|
288
|
+
return {
|
|
289
|
+
dir: existsSync(explicitOverride) ? resolve(explicitOverride) : null,
|
|
290
|
+
step: "flag",
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const cwd = resolve(fromDir);
|
|
295
|
+
const root = repoRootOf(cwd);
|
|
296
|
+
|
|
297
|
+
// Step 2: paths.memDir in the repo-root config (FAPONY_CONFIG is not a
|
|
298
|
+
// path source here — spec §1 pins this to fapony.config.json)
|
|
299
|
+
const configDir = root ?? cwd;
|
|
300
|
+
try {
|
|
301
|
+
const configPath = join(configDir, "fapony.config.json");
|
|
302
|
+
if (existsSync(configPath)) {
|
|
303
|
+
const config = loadConfig(configPath);
|
|
304
|
+
if (config?.paths?.memDir) {
|
|
305
|
+
const absCfg = isAbsolute(config.paths.memDir)
|
|
306
|
+
? config.paths.memDir
|
|
307
|
+
: join(configDir, config.paths.memDir);
|
|
308
|
+
if (existsSync(absCfg)) return { dir: absCfg, step: "config" };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
} catch {
|
|
312
|
+
// no config or unreadable — continue
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Step 3: walk up from cwd, only a dir with a real log counts
|
|
316
|
+
const walked = walkUpForMemDir(cwd, false);
|
|
317
|
+
if (walked) return { dir: walked, step: "walk-up" };
|
|
318
|
+
|
|
319
|
+
// Guard (SPEC §1 fail example): nothing at/above cwd holds a log, but the repo
|
|
320
|
+
// has two or more app-scoped ones — refuse instead of silently creating a
|
|
321
|
+
// third log at the root that no app-scoped reader will ever see.
|
|
322
|
+
if (root) {
|
|
323
|
+
const candidates = findMemDirsUnder(root);
|
|
324
|
+
if (candidates.length >= 2) {
|
|
325
|
+
return { dir: null, step: "ambiguous", candidates };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Step 4: <repo root>/.fapony/.memory/ — where a new log is created
|
|
330
|
+
if (root) {
|
|
331
|
+
const rootDir = join(root, ".fapony", ".memory");
|
|
332
|
+
if (existsSync(rootDir)) return { dir: rootDir, step: "repo-root" };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return { dir: null, step: "none" };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function resolveMemDir(
|
|
339
|
+
worktree?: string,
|
|
340
|
+
explicitOverride?: string,
|
|
341
|
+
): string | null {
|
|
342
|
+
return resolveMemDirFrom(worktree ?? process.cwd(), explicitOverride).dir;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Resolve and report which step won — for `fapony mem where`. */
|
|
346
|
+
export function whereMemDir(
|
|
347
|
+
fromDir?: string,
|
|
348
|
+
explicitOverride?: string,
|
|
349
|
+
): MemDirResult {
|
|
350
|
+
return resolveMemDirFrom(fromDir ?? process.cwd(), explicitOverride);
|
|
351
|
+
}
|
|
352
|
+
|
|
158
353
|
/**
|
|
159
354
|
* Read every `log*.jsonl` row under the worktree's memory dir, newest first.
|
|
160
355
|
* `sinceIso` (exclusive) drops older rows; omit it to read the whole log.
|
|
@@ -168,11 +363,13 @@ export function readMemLog(
|
|
|
168
363
|
const dir = resolveMemDir(worktree);
|
|
169
364
|
if (!dir) return { rows: [], skipped: 0, filesFound: 0 };
|
|
170
365
|
|
|
171
|
-
//
|
|
366
|
+
// Every log*.jsonl, rotated archives (log.YYYY-MM-DD.jsonl) included. They
|
|
367
|
+
// used to be excluded, which meant the day a repo crossed the rotate
|
|
368
|
+
// threshold mem_find silently forgot everything already closed — the rows
|
|
369
|
+
// most worth recalling. Rotate exists to keep the live file small, not to
|
|
370
|
+
// decide what is still remembered.
|
|
172
371
|
const isLogFile = (f: string): boolean =>
|
|
173
|
-
f === "log.jsonl" ||
|
|
174
|
-
(/^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f) &&
|
|
175
|
-
!/^log\.\d{4}-\d{2}-\d{2}\.jsonl$/.test(f));
|
|
372
|
+
f === "log.jsonl" || /^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f);
|
|
176
373
|
|
|
177
374
|
let files: string[];
|
|
178
375
|
try {
|