@fonlan/dsh-gitmemo 0.1.5 → 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.
package/lib/mem.js CHANGED
@@ -1,136 +1,376 @@
1
1
  /**
2
2
  * GitMemo core engine — git-backed long-term memory for coding agents.
3
3
  *
4
- * A faithful TypeScript port of gitmemo's `scripts/mem.sh`:
5
- * https://github.com/fonlan/gitmemo
6
- *
7
- * Memory lives in a local `.mem` Git repository at the project root.
8
- * Entries are markdown files under `.mem/entries/`, every action is a
9
- * commit (auditable/traceable), and the `.mem` branch is kept aligned with
10
- * the project's current branch on writes. Git is the only dependency.
4
+ * Mechanism:
5
+ * - `.mem` is a standalone git repository at the project root, permanently
6
+ * on a single `main` branch (no branch alignment with the code repo).
7
+ * - Entries are immutable markdown files under `.mem/entries/`, one file per
8
+ * ADD commit; corrections use `replace` (one commit deleting the old file
9
+ * and adding the new one), withdrawal uses `delete`.
10
+ * - Commit messages are structured: subject = title, body = summary, then a
11
+ * contiguous trailer block (`GitMemo-Type/Keyword/Digest/Deletes/Replaces/
12
+ * Search-Text/Legacy`). Search only reads commit messages via
13
+ * `git log --grep --fixed-strings`; entry bodies are never scanned.
14
+ * - All state transitions (init/write/delete/replace/migrate) run under a
15
+ * cross-process lock (sibling lock file + in-process mutex) with a short
16
+ * transaction journal for crash recovery. Git is the only dependency.
11
17
  *
12
18
  * @module dsh-gitmemo/mem
13
19
  */
14
- import { execFile, execFileSync } from "node:child_process";
15
- import { mkdir, writeFile, rm, copyFile } from "node:fs/promises";
16
- import { dirname, basename, join, resolve, sep, isAbsolute } from "node:path";
20
+ import { execFile } from "node:child_process";
21
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
22
+ import { access, mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
23
+ import { hostname } from "node:os";
24
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
25
+ /** Schema version stored in `.gitmemo-format`. */
26
+ export const GITMEMO_FORMAT_VERSION = "2";
27
+ /** Format marker file tracked in the .mem repo (content = schema version). */
28
+ export const FORMAT_FILE = ".gitmemo-format";
29
+ /** Entry directory inside the .mem repo. */
30
+ export const ENTRY_DIR = "entries";
31
+ /** Sibling lock file (usable before `.mem` exists). */
32
+ export const LOCK_FILE_NAME = ".mem.gitmemo.lock";
33
+ /** Sibling journal used to recover an interrupted migration directory swap. */
34
+ export const MIGRATION_JOURNAL_NAME = ".mem.gitmemo-migration.json";
35
+ /** Max entry content size. */
36
+ export const MAX_CONTENT_BYTES = 1024 * 1024;
37
+ /** Error type thrown by every gitmemo operation. */
38
+ export class GitMemoError extends Error {
39
+ }
40
+ const UNSAFE_CONTROL_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/;
41
+ const TRAILER_INJECT_RE = /^\s*GitMemo-[A-Za-z0-9-]+:/im;
17
42
  const COMMIT_RE = /^[0-9a-f]{40}$/;
18
- function git(dir, args, timeoutMs) {
43
+ const SEARCH_CACHE_LIMIT = 32;
44
+ /** Run one git subprocess (internal; exported for migrate/cli). */
45
+ export function git(dir, args, gitTimeoutMs, opts = {}) {
19
46
  return new Promise((resolvePromise, reject) => {
20
- execFile("git", ["-C", dir, ...args], { maxBuffer: 64 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
47
+ const child = execFile("git", ["-C", dir, ...args], {
48
+ maxBuffer: 128 * 1024 * 1024,
49
+ timeout: gitTimeoutMs,
50
+ env: { ...process.env, ...opts.env }
51
+ }, (error, stdout, stderr) => {
21
52
  if (error !== null) {
22
53
  const code = error.code;
23
54
  if (code === "ENOENT") {
24
- reject(new Error("gitmemo: git not found on PATH — gitmemo requires the git CLI"));
55
+ reject(new GitMemoError("gitmemo: git not found on PATH — gitmemo requires the git CLI"));
25
56
  }
26
57
  else {
27
58
  const message = (stderr ?? "").trim() || (error.message ?? String(error));
28
- reject(new Error("gitmemo: git " + (args[0] ?? "") + " failed: " + message));
59
+ reject(new GitMemoError("gitmemo: git " + (args[0] ?? "") + " failed: " + message));
29
60
  }
30
61
  return;
31
62
  }
32
63
  resolvePromise(stdout);
33
64
  });
65
+ if (opts.input !== undefined) {
66
+ child.stdin?.on("error", () => { });
67
+ child.stdin?.end(opts.input);
68
+ }
34
69
  });
35
70
  }
36
- /** Synchronous git runner for the session-start injection path (must not race the first prompt assembly). */
37
- function gitSync(dir, args, timeoutMs) {
71
+ async function gitOk(dir, args, gitTimeoutMs) {
38
72
  try {
39
- // stdio must be pinned: execFileSync forwards child stderr to the parent's
40
- // console unless stdio is set, leaking raw `git fatal:` noise at boot.
41
- return execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: timeoutMs, stdio: "pipe" });
73
+ await git(dir, args, gitTimeoutMs);
74
+ return true;
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ /**
81
+ * Commit a message via stdin, retrying once with a built-in identity when the
82
+ * ambient git configuration has no user.name/user.email (fresh machines,
83
+ * CI, tests). The retry only adds env defaults — it never overrides an
84
+ * explicitly configured identity.
85
+ */
86
+ export async function commitWithIdentity(memDir, message, gitTimeoutMs, opts = {}) {
87
+ const env = { ...process.env, ...opts.env };
88
+ try {
89
+ await git(memDir, ["commit", "-q", "-F", "-"], gitTimeoutMs, { input: message, env });
42
90
  }
43
91
  catch (error) {
44
- const code = error.code;
45
- if (code === "ENOENT") {
46
- throw new Error("gitmemo: git not found on PATH — gitmemo requires the git CLI");
92
+ if (/user\.name|user\.email|Please tell me who you are|unable to auto-detect email/i.test(String(error))) {
93
+ const fallback = {
94
+ GIT_AUTHOR_NAME: "dsh-gitmemo",
95
+ GIT_AUTHOR_EMAIL: "dsh-gitmemo@localhost",
96
+ GIT_COMMITTER_NAME: "dsh-gitmemo",
97
+ GIT_COMMITTER_EMAIL: "dsh-gitmemo@localhost"
98
+ };
99
+ await git(memDir, ["commit", "-q", "-F", "-"], gitTimeoutMs, { input: message, env: { ...env, ...fallback } });
100
+ }
101
+ else {
102
+ throw error;
47
103
  }
48
- const stderr = error.stderr?.toString().trim();
49
- throw new Error("gitmemo: git " + (args[0] ?? "") + " failed: " + (stderr || String(error)));
50
104
  }
105
+ return (await git(memDir, ["rev-parse", "HEAD"], gitTimeoutMs)).trim();
51
106
  }
52
- /** Parse one `git log --format=%H\t%s\t%cd --name-only` batch into records. */
53
- function parseLogBatch(output) {
54
- const records = [];
55
- let currentHash = "";
56
- let currentSubject = "";
57
- let currentDate = "";
58
- let currentFile = "";
59
- for (const rawLine of output.split("\n")) {
60
- const line = rawLine.replace(/\r$/, "");
61
- if (line.length === 0)
107
+ /** `git init` with an explicit `main` branch (falls back for old git). */
108
+ export async function gitInitMain(dir, gitTimeoutMs) {
109
+ try {
110
+ await git(dir, ["init", "-q", "-b", "main"], gitTimeoutMs);
111
+ }
112
+ catch {
113
+ await git(dir, ["init", "-q"], gitTimeoutMs);
114
+ await git(dir, ["symbolic-ref", "HEAD", "refs/heads/main"], gitTimeoutMs);
115
+ }
116
+ }
117
+ /**
118
+ * Resolve the code project root for a session working directory (plan 3.1):
119
+ * explicit config > git work-tree root > session cwd.
120
+ */
121
+ export async function resolveProjectRoot(cwd, gitTimeoutMs = 60000) {
122
+ try {
123
+ const out = (await git(cwd, ["rev-parse", "--show-toplevel"], gitTimeoutMs)).trim();
124
+ if (out.length > 0)
125
+ return out;
126
+ }
127
+ catch {
128
+ // not inside a git work tree
129
+ }
130
+ return cwd;
131
+ }
132
+ // ---------------------------------------------------------------------------
133
+ // text normalization / validation
134
+ // ---------------------------------------------------------------------------
135
+ /**
136
+ * Query/write normalization contract: NFKC + Unicode default case folding +
137
+ * whitespace collapsing to single ASCII spaces. Both query keywords and the
138
+ * `GitMemo-Search-Text` projections use this exact function, so the first
139
+ * grep stage never depends on the platform locale.
140
+ */
141
+ export function normalizeText(input) {
142
+ // JavaScript exposes Unicode case conversion but not CaseFolding.txt. NFKC
143
+ // removes compatibility variants; the per-code-point rules below cover the
144
+ // full-fold expansions and the small set where default folding intentionally
145
+ // differs from ordinary lowercasing (Cherokee, final sigma, ypogegrammeni,
146
+ // Cyrillic historic variants, and capital sharp S).
147
+ const folded = [...input.normalize("NFKC")].map((char) => {
148
+ const cp = char.codePointAt(0) ?? 0;
149
+ if (char === "ẞ")
150
+ return "ss";
151
+ if (char === "\u0345")
152
+ return "ι";
153
+ if ((cp >= 0x13a0 && cp <= 0x13ff) || (cp >= 0xab70 && cp <= 0xabbf))
154
+ return char.toUpperCase();
155
+ if (cp >= 0x1c80 && cp <= 0x1c88)
156
+ return char.toUpperCase().toLowerCase();
157
+ const upper = char.toUpperCase();
158
+ if ([...upper].length > 1)
159
+ return upper.toLowerCase();
160
+ return char.toLowerCase();
161
+ }).join("");
162
+ return folded
163
+ .replace(/ς/g, "σ")
164
+ .replace(/\s+/g, " ")
165
+ .trim();
166
+ }
167
+ export function normalizeKeyword(input) {
168
+ if (typeof input !== "string")
169
+ throw new GitMemoError("gitmemo: keyword must be a string");
170
+ const trimmed = input.trim();
171
+ if (trimmed.length === 0)
172
+ throw new GitMemoError("gitmemo: keyword must not be empty");
173
+ if (trimmed.length > 64)
174
+ throw new GitMemoError("gitmemo: each keyword must be at most 64 characters: " + trimmed);
175
+ assertNoControlChars(trimmed, "keyword");
176
+ assertSingleLine(trimmed, "keyword");
177
+ return normalizeText(trimmed);
178
+ }
179
+ function assertNoControlChars(value, field) {
180
+ if (UNSAFE_CONTROL_RE.test(value)) {
181
+ throw new GitMemoError(`gitmemo: ${field} contains unsafe control characters`);
182
+ }
183
+ }
184
+ function assertNoTrailerInjection(value, field) {
185
+ if (TRAILER_INJECT_RE.test(value)) {
186
+ throw new GitMemoError(`gitmemo: ${field} must not contain reserved GitMemo-* trailer lines`);
187
+ }
188
+ }
189
+ function assertSingleLine(value, field) {
190
+ if (/[\r\n]/.test(value))
191
+ throw new GitMemoError(`gitmemo: ${field} must be a single line`);
192
+ }
193
+ function dedupe(values) {
194
+ return [...new Set(values)];
195
+ }
196
+ /** Normalize a project-relative path; rejects absolute/escaping paths. */
197
+ function normalizeRelatedPath(input) {
198
+ const trimmed = input.trim();
199
+ if (trimmed.length === 0)
200
+ throw new GitMemoError("gitmemo: related_paths entries must not be empty");
201
+ if (isAbsolute(trimmed) || /^[A-Za-z]:[\\/]/.test(trimmed)) {
202
+ throw new GitMemoError("gitmemo: related_paths must be project-relative, got absolute path: " + trimmed);
203
+ }
204
+ if (UNSAFE_CONTROL_RE.test(trimmed)) {
205
+ throw new GitMemoError("gitmemo: related_paths contains unsafe control characters");
206
+ }
207
+ const segments = trimmed.replaceAll("\\", "/").split("/");
208
+ const stack = [];
209
+ for (const segment of segments) {
210
+ if (segment.length === 0 || segment === ".")
62
211
  continue;
63
- const fields = line.split("\t");
64
- if (COMMIT_RE.test(fields[0] ?? "")) {
65
- if (currentHash.length > 0) {
66
- records.push({ hash: currentHash, title: currentSubject, date: currentDate, file: currentFile });
67
- }
68
- currentHash = fields[0];
69
- currentSubject = fields[1] ?? "";
70
- currentDate = fields.slice(2).join(" ").trim();
71
- currentFile = "";
212
+ if (segment === "..") {
213
+ if (stack.length === 0)
214
+ throw new GitMemoError("gitmemo: related_paths escapes the project root: " + trimmed);
215
+ stack.pop();
72
216
  continue;
73
217
  }
74
- if (currentFile.length === 0 && line.startsWith("entries/") && line.endsWith(".md")) {
75
- currentFile = line;
218
+ stack.push(segment);
219
+ }
220
+ if (stack.length === 0)
221
+ throw new GitMemoError("gitmemo: related_paths resolves to the project root: " + trimmed);
222
+ return stack.join("/");
223
+ }
224
+ /**
225
+ * Validate a write/replace payload (plan 5.3). `legacy` relaxes the keyword
226
+ * minimum to 0 (legacy adapter and migration entries); everything else is
227
+ * enforced identically.
228
+ */
229
+ export function validateWriteInput(input, legacy = false) {
230
+ if (typeof input.title !== "string" || input.title.trim().length === 0) {
231
+ throw new GitMemoError("gitmemo: write requires a non-empty title");
232
+ }
233
+ const title = input.title.trim();
234
+ assertSingleLine(title, "title");
235
+ if (title.length > 200)
236
+ throw new GitMemoError("gitmemo: title must be at most 200 characters");
237
+ assertNoControlChars(title, "title");
238
+ assertNoTrailerInjection(title, "title");
239
+ if (typeof input.summary !== "string" || input.summary.trim().length === 0) {
240
+ throw new GitMemoError("gitmemo: write requires a non-empty summary");
241
+ }
242
+ const summary = input.summary.trim();
243
+ if (summary.length > 4000)
244
+ throw new GitMemoError("gitmemo: summary must be at most 4000 characters");
245
+ assertNoControlChars(summary, "summary");
246
+ assertNoTrailerInjection(summary, "summary");
247
+ if (typeof input.content !== "string" || input.content.trim().length === 0) {
248
+ throw new GitMemoError("gitmemo: write requires non-empty content");
249
+ }
250
+ const content = input.content;
251
+ if (Buffer.byteLength(content, "utf8") > MAX_CONTENT_BYTES) {
252
+ throw new GitMemoError("gitmemo: content must be at most 1 MiB");
253
+ }
254
+ assertNoControlChars(content, "content");
255
+ if (!Array.isArray(input.keywords))
256
+ throw new GitMemoError("gitmemo: keywords must be an array");
257
+ const rawKeywords = input.keywords.map((k) => (typeof k === "string" ? k.trim() : "")).filter((k) => k.length > 0);
258
+ const minKeywords = legacy ? 0 : 2;
259
+ if (rawKeywords.length < minKeywords || rawKeywords.length > 12) {
260
+ throw new GitMemoError(`gitmemo: keywords must contain ${minKeywords === 0 ? "0-12" : "2-12"} entries, got ${rawKeywords.length}`);
261
+ }
262
+ const keywords = [];
263
+ const seen = new Set();
264
+ for (const kw of rawKeywords) {
265
+ assertSingleLine(kw, "keywords");
266
+ assertNoTrailerInjection(kw, "keywords");
267
+ const normalized = normalizeKeyword(kw);
268
+ if (seen.has(normalized))
269
+ continue;
270
+ seen.add(normalized);
271
+ keywords.push(kw);
272
+ }
273
+ if (keywords.length < minKeywords) {
274
+ throw new GitMemoError(`gitmemo: keywords must contain ${minKeywords === 0 ? "0-12" : "2-12"} distinct entries after normalization, got ${keywords.length}`);
275
+ }
276
+ let related_branches = [];
277
+ if (input.related_branches !== undefined) {
278
+ if (!Array.isArray(input.related_branches))
279
+ throw new GitMemoError("gitmemo: related_branches must be an array");
280
+ if (input.related_branches.length > 32)
281
+ throw new GitMemoError("gitmemo: related_branches allows at most 32 entries");
282
+ for (const b of input.related_branches) {
283
+ const branch = typeof b === "string" ? b.trim() : "";
284
+ if (branch.length === 0 || branch.length > 256) {
285
+ throw new GitMemoError("gitmemo: related_branches entries must be 1-256 characters");
286
+ }
287
+ assertNoControlChars(branch, "related_branches");
288
+ if (/[\r\n]/.test(branch))
289
+ throw new GitMemoError("gitmemo: related_branches must not contain newlines");
290
+ related_branches.push(branch.normalize("NFKC"));
76
291
  }
292
+ related_branches = dedupe(related_branches);
77
293
  }
78
- if (currentHash.length > 0) {
79
- records.push({ hash: currentHash, title: currentSubject, date: currentDate, file: currentFile });
294
+ let related_paths = [];
295
+ if (input.related_paths !== undefined) {
296
+ if (!Array.isArray(input.related_paths))
297
+ throw new GitMemoError("gitmemo: related_paths must be an array");
298
+ if (input.related_paths.length > 128)
299
+ throw new GitMemoError("gitmemo: related_paths allows at most 128 entries");
300
+ for (const p of input.related_paths) {
301
+ const path = typeof p === "string" ? p : "";
302
+ if (path.length === 0 || path.length > 1024) {
303
+ throw new GitMemoError("gitmemo: related_paths entries must be 1-1024 characters");
304
+ }
305
+ related_paths.push(normalizeRelatedPath(path));
306
+ }
307
+ related_paths = dedupe(related_paths);
80
308
  }
81
- return records;
309
+ return { title, summary, keywords, content, related_branches, related_paths };
82
310
  }
83
- /** True when a path points inside (or at) the memory entries directory. */
84
- function isEntriesPath(memRoot, full) {
85
- const memEntries = join(memRoot, "entries") + sep;
86
- return full === join(memRoot, "entries") || full.startsWith(memEntries);
311
+ async function adaptLegacyWriteInput(input) {
312
+ const legacyInput = input;
313
+ if (legacyInput.file !== undefined) {
314
+ throw new GitMemoError("gitmemo: legacy mem_write `file` (in-place commit) is incompatible with immutable entries — use mem_replace");
315
+ }
316
+ const readPayload = async (path) => {
317
+ if (path === undefined || path.trim().length === 0)
318
+ return undefined;
319
+ return (await readFile(path, "utf8")).replace(/^\uFEFF/, "");
320
+ };
321
+ const summary = legacyInput.summary ?? legacyInput.body ?? (await readPayload(legacyInput.body_file));
322
+ const content = legacyInput.content ?? (await readPayload(legacyInput.content_file));
323
+ if (summary === undefined || content === undefined) {
324
+ throw new GitMemoError("gitmemo: legacy write cannot be converted deterministically — provide title plus summary/body and content/content_file");
325
+ }
326
+ return {
327
+ title: legacyInput.title,
328
+ summary,
329
+ keywords: legacyInput.keywords ?? [],
330
+ content,
331
+ related_branches: legacyInput.related_branches,
332
+ related_paths: legacyInput.related_paths
333
+ };
87
334
  }
335
+ // ---------------------------------------------------------------------------
336
+ // digest / filenames / markdown / commit messages
337
+ // ---------------------------------------------------------------------------
88
338
  /**
89
- * Convert a user-supplied path to an entries-relative path when possible.
90
- * Handles `.mem/entries/...`, `entries/...`, and real files whose
91
- * resolved path lives under <memDir>/entries (resolved against baseDir).
339
+ * Stable digest over the semantic payload only: title + summary +
340
+ * normalized+sorted keywords + content. Write time, code branch/SHA and
341
+ * related branches/paths are excluded, so the same conclusion never produces
342
+ * an exact duplicate because of when/where it was written, and a withdrawn
343
+ * conclusion can be re-written later.
92
344
  */
93
- function entryFileFromPath(path, memDir, baseDir) {
94
- if (path.length === 0)
95
- return undefined;
96
- const normalized = path.replaceAll("\\", "/");
97
- if (normalized.startsWith(".mem/entries/"))
98
- return normalized.slice(".mem/".length);
99
- if (normalized.startsWith("entries/"))
100
- return normalized;
101
- // A real file whose resolved path lives under <memDir>/entries.
102
- const abs = isAbsolute(path) ? path : resolve(baseDir, path);
103
- if (isEntriesPath(memDir, abs)) {
104
- return abs.slice(memDir.length + 1).split(sep).join("/");
105
- }
106
- return undefined;
107
- }
108
- /** Normalize an entry file argument to the shape accepted by git add. */
109
- function normalizeEntryFile(file, memDir, baseDir) {
110
- if (file.length === 0)
111
- return "";
112
- const direct = entryFileFromPath(file, memDir, baseDir);
113
- if (direct !== undefined)
114
- return direct;
115
- const normalized = file.replaceAll("\\", "/").replace(/^\.\//, "");
116
- if (normalized.startsWith("entries/") || normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized)) {
117
- return normalized;
118
- }
119
- return "entries/" + normalized;
120
- }
121
- /** Treat blank optional path arguments as omitted. */
122
- function blankToUndefined(value) {
123
- return value !== undefined && value.trim().length === 0 ? undefined : value;
124
- }
125
- /** Reject paths that could escape the memory repo. */
126
- function isSafeEntryPath(file) {
127
- if (file.startsWith("/") || file.includes("../") || file.endsWith("/..") || file.includes("\\") || file.includes(":")) {
128
- return false;
129
- }
130
- return true;
345
+ export function computeDigest(title, summary, keywords, content) {
346
+ const normalizedKeywords = [...new Set(keywords.map((k) => normalizeKeyword(k)))].sort();
347
+ const payload = JSON.stringify({ title, summary, keywords: normalizedKeywords, content });
348
+ return createHash("sha256").update(payload, "utf8").digest("hex");
131
349
  }
132
- /** Lowercase title -> kebab slug; falls back to "memory-entry". */
133
- function slugifyTitle(title) {
350
+ /** UTC millisecond timestamp for filenames: `20260823T113612.123Z`. */
351
+ export function utcMs(date) {
352
+ if (!(date instanceof Date) || Number.isNaN(date.getTime()))
353
+ return "19700101T000000.000Z";
354
+ const pad = (n) => String(n).padStart(2, "0");
355
+ return (String(date.getUTCFullYear()) +
356
+ pad(date.getUTCMonth() + 1) +
357
+ pad(date.getUTCDate()) +
358
+ "T" +
359
+ pad(date.getUTCHours()) +
360
+ pad(date.getUTCMinutes()) +
361
+ pad(date.getUTCSeconds()) +
362
+ "." +
363
+ String(date.getUTCMilliseconds()).padStart(3, "0") +
364
+ "Z");
365
+ }
366
+ /** ISO date for a committer timestamp; falls back to the epoch for out-of-range values. */
367
+ export function safeIsoDate(committerTime) {
368
+ const ms = committerTime * 1000;
369
+ if (!Number.isFinite(ms) || Math.abs(ms) > 8.64e15)
370
+ return new Date(0).toISOString();
371
+ return new Date(ms).toISOString();
372
+ }
373
+ export function slugifyTitle(title) {
134
374
  const slug = title
135
375
  .toLowerCase()
136
376
  .replace(/[^a-z0-9]+/g, "-")
@@ -138,435 +378,1167 @@ function slugifyTitle(title) {
138
378
  .replace(/-+$/, "");
139
379
  return slug.length > 0 ? slug : "memory-entry";
140
380
  }
141
- /** Current UTC timestamp in the filename format YYYYMMDDTHHMMSSZ. */
142
- function utcTimestamp() {
143
- const now = new Date();
144
- const pad = (n) => String(n).padStart(2, "0");
145
- return (String(now.getUTCFullYear()) + pad(now.getUTCMonth() + 1) + pad(now.getUTCDate()) +
146
- "T" + pad(now.getUTCHours()) + pad(now.getUTCMinutes()) + pad(now.getUTCSeconds()) + "Z");
147
- }
148
381
  /**
149
- * Git-backed long-term memory engine operating on one project root.
382
+ * Create an entry file under entries/ with an exclusive create; on collision
383
+ * append a numeric suffix. Never overwrites an existing file.
150
384
  */
151
- export class GitMemo {
152
- root;
153
- memDir;
154
- config;
155
- constructor(root, config = {}) {
156
- this.root = root;
157
- this.config = {
158
- memDirName: config.memDirName ?? ".mem",
159
- searchLimit: config.searchLimit ?? 20,
160
- branchAlign: config.branchAlign ?? true,
161
- gitTimeoutMs: config.gitTimeoutMs ?? 60000,
162
- maxSearchBatches: config.maxSearchBatches ?? 200
163
- };
164
- this.memDir = join(this.root, this.config.memDirName);
165
- }
166
- /** The current branch of a git work tree, "main" when detached/unavailable. */
167
- async currentBranch(dir) {
385
+ export async function createEntryFile(memDir, baseName, content) {
386
+ const dir = join(memDir, ENTRY_DIR);
387
+ await mkdir(dir, { recursive: true });
388
+ for (let attempt = 0;; attempt += 1) {
389
+ const name = attempt === 0 ? baseName : baseName.replace(/\.md$/, `-${attempt + 1}.md`);
390
+ const full = join(dir, name);
168
391
  try {
169
- const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"], this.config.gitTimeoutMs)).trim();
170
- if (branch.length === 0 || branch === "HEAD")
171
- return "main";
172
- return branch;
392
+ const handle = await open(full, "wx");
393
+ try {
394
+ await handle.writeFile(content, "utf8");
395
+ }
396
+ finally {
397
+ await handle.close();
398
+ }
399
+ return ENTRY_DIR + "/" + name;
173
400
  }
174
- catch {
175
- // Unborn HEAD (repo without commits yet): resolve the symbolic ref directly.
401
+ catch (error) {
402
+ if (error.code === "EEXIST")
403
+ continue;
404
+ throw error;
405
+ }
406
+ }
407
+ }
408
+ /**
409
+ * Journal an exact new-entry path before its first filesystem mutation, then
410
+ * create it exclusively. The transaction lock makes the existence probe and
411
+ * journal write deterministic for compliant writers; `wx` remains the final
412
+ * defence against external races.
413
+ */
414
+ async function createJournaledEntryFile(memDir, baseName, content, makeJournal) {
415
+ const dir = join(memDir, ENTRY_DIR);
416
+ await mkdir(dir, { recursive: true });
417
+ for (let attempt = 0;; attempt += 1) {
418
+ const name = attempt === 0 ? baseName : baseName.replace(/\.md$/, `-${attempt + 1}.md`);
419
+ const file = ENTRY_DIR + "/" + name;
420
+ const full = join(memDir, file);
421
+ if (await pathExists(full))
422
+ continue;
423
+ const journal = makeJournal(file);
424
+ await writeJournal(memDir, journal);
425
+ try {
426
+ const handle = await open(full, "wx");
176
427
  try {
177
- const sym = (await git(dir, ["symbolic-ref", "--short", "HEAD"], this.config.gitTimeoutMs)).trim();
178
- if (sym.length > 0 && sym !== "HEAD")
179
- return sym;
428
+ await handle.writeFile(content, "utf8");
180
429
  }
181
- catch {
182
- // not a git repo at all
430
+ finally {
431
+ await handle.close();
183
432
  }
184
- return "main";
433
+ return { file, journal };
434
+ }
435
+ catch (error) {
436
+ if (error.code === "EEXIST") {
437
+ await rm(journalPath(memDir), { force: true });
438
+ continue;
439
+ }
440
+ throw error;
185
441
  }
186
442
  }
187
- async projectBranch() {
188
- return this.currentBranch(this.root);
443
+ }
444
+ function yamlJson(value) {
445
+ return JSON.stringify(value);
446
+ }
447
+ /** YAML flow array with human-readable ", " separators. */
448
+ function yamlArray(values) {
449
+ return "[" + values.map((v) => JSON.stringify(v)).join(", ") + "]";
450
+ }
451
+ /** Build the full entry markdown (engine-generated front matter + body). */
452
+ export function buildEntryMarkdown(input, ctx, digest, date) {
453
+ void digest;
454
+ const lines = [
455
+ "---",
456
+ "gitmemo_version: " + yamlJson(GITMEMO_FORMAT_VERSION),
457
+ "date: " + yamlJson(date.toISOString()),
458
+ "code_branch: " + yamlJson(ctx.branch),
459
+ "code_commit: " + yamlJson(ctx.commit),
460
+ "related_branches: " + yamlArray(ctx.relatedBranches),
461
+ "related_paths: " + yamlArray(input.related_paths),
462
+ "keywords: " + yamlArray(input.keywords),
463
+ "---",
464
+ "",
465
+ "# " + input.title,
466
+ "",
467
+ "## Summary",
468
+ "",
469
+ input.summary,
470
+ "",
471
+ "## Final Outcome",
472
+ "",
473
+ input.content,
474
+ ""
475
+ ];
476
+ return lines.join("\n");
477
+ }
478
+ function searchTexts(title, summary, keywords) {
479
+ return [normalizeText(title), normalizeText(summary.replace(/\s+/g, " ")), ...keywords.map((k) => normalizeText(k))];
480
+ }
481
+ /** ADD commit message (plan 4.1). */
482
+ export function buildAddMessage(title, summary, keywords, digest, legacy = false) {
483
+ const lines = [title, "", summary, "", "GitMemo-Type: add"];
484
+ if (legacy)
485
+ lines.push("GitMemo-Legacy: true");
486
+ for (const kw of keywords)
487
+ lines.push("GitMemo-Keyword: " + kw);
488
+ lines.push("GitMemo-Digest: sha256:" + digest);
489
+ for (const text of searchTexts(title, summary, keywords))
490
+ lines.push("GitMemo-Search-Text: " + text);
491
+ return lines.join("\n") + "\n";
492
+ }
493
+ /** DELETE commit message (plan 4.2). */
494
+ export function buildDeleteMessage(entryName, reason, deletesHash) {
495
+ return [
496
+ "delete: withdraw " + entryName,
497
+ "",
498
+ reason,
499
+ "",
500
+ "GitMemo-Type: delete",
501
+ "GitMemo-Deletes: " + deletesHash
502
+ ].join("\n") + "\n";
503
+ }
504
+ /** REPLACE commit message (plan 4.3). */
505
+ export function buildReplaceMessage(title, summary, keywords, digest, replacesHash) {
506
+ const lines = [title, "", summary, "", "GitMemo-Type: replace", "GitMemo-Replaces: " + replacesHash];
507
+ for (const kw of keywords)
508
+ lines.push("GitMemo-Keyword: " + kw);
509
+ lines.push("GitMemo-Digest: sha256:" + digest);
510
+ for (const text of searchTexts(title, summary, keywords))
511
+ lines.push("GitMemo-Search-Text: " + text);
512
+ return lines.join("\n") + "\n";
513
+ }
514
+ /**
515
+ * Parse a commit message into logical search fields. The trailer block is the
516
+ * contiguous run at the end; everything between subject and the block is the
517
+ * summary. Malformed input degrades to `type: "unknown"` (legacy commits).
518
+ */
519
+ export function parseCommitMessage(raw) {
520
+ const lines = raw.replace(/\r\n/g, "\n").split("\n");
521
+ const subject = (lines[0] ?? "").trim();
522
+ let trailerStart = lines.length;
523
+ while (trailerStart > 1) {
524
+ const trimmed = lines[trailerStart - 1].trim();
525
+ if (trimmed.length === 0) {
526
+ trailerStart -= 1;
527
+ continue;
528
+ }
529
+ if (/^GitMemo-[A-Za-z0-9-]+:/.test(trimmed)) {
530
+ trailerStart -= 1;
531
+ continue;
532
+ }
533
+ break;
189
534
  }
190
- async memBranch() {
191
- return this.currentBranch(this.memDir);
535
+ const summary = lines
536
+ .slice(1, trailerStart)
537
+ .join("\n")
538
+ .replace(/\n{3,}/g, "\n\n")
539
+ .trim();
540
+ const trailers = new Map();
541
+ for (let i = trailerStart; i < lines.length; i += 1) {
542
+ const match = /^GitMemo-([A-Za-z0-9-]+): ?(.*)$/.exec(lines[i].trim());
543
+ if (match !== null) {
544
+ const list = trailers.get(match[1]) ?? [];
545
+ list.push(match[2]);
546
+ trailers.set(match[1], list);
547
+ }
192
548
  }
193
- /**
194
- * Initialize the memory repo when missing (git init + .gitkeep commit).
195
- * Idempotent; every other operation auto-initializes.
196
- */
197
- async init() {
198
- await ensureInit(this.memDir, this.config.gitTimeoutMs);
199
- return this.memDir;
549
+ const typeRaw = trailers.get("Type")?.[0] ?? "unknown";
550
+ const type = typeRaw === "add" || typeRaw === "replace" || typeRaw === "delete" ? typeRaw : "unknown";
551
+ return {
552
+ subject,
553
+ summary,
554
+ type,
555
+ keywords: trailers.get("Keyword") ?? [],
556
+ digest: trailers.get("Digest")?.[0]?.replace(/^sha256:/, "") || undefined,
557
+ deletes: trailers.get("Deletes")?.[0] || undefined,
558
+ replaces: trailers.get("Replaces")?.[0] || undefined,
559
+ legacy: trailers.get("Legacy")?.[0] === "true"
560
+ };
561
+ }
562
+ // ---------------------------------------------------------------------------
563
+ // git log parsing
564
+ // ---------------------------------------------------------------------------
565
+ function parseStatusTail(tail) {
566
+ const added = [];
567
+ const deleted = [];
568
+ for (const line of tail.split("\n")) {
569
+ const add = /^A\t(entries\/.+\.md)$/.exec(line);
570
+ if (add !== null) {
571
+ added.push(add[1]);
572
+ continue;
573
+ }
574
+ const del = /^D\t(entries\/.+\.md)$/.exec(line);
575
+ if (del !== null) {
576
+ deleted.push(del[1]);
577
+ continue;
578
+ }
579
+ const rename = /^R\d+\t(entries\/.+\.md)\t(entries\/.+\.md)$/.exec(line);
580
+ if (rename !== null) {
581
+ deleted.push(rename[1]);
582
+ added.push(rename[2]);
583
+ }
200
584
  }
201
- /** Align the memory repo branch with the project branch (create if needed). */
202
- async syncBranch() {
203
- if (!this.config.branchAlign)
204
- return this.memBranch();
205
- const repoBranch = await this.projectBranch();
206
- const memBranch = await this.memBranch();
207
- if (memBranch !== repoBranch) {
208
- try {
209
- await git(this.memDir, ["show-ref", "--verify", "--quiet", "refs/heads/" + repoBranch], this.config.gitTimeoutMs);
210
- await git(this.memDir, ["checkout", "-q", repoBranch], this.config.gitTimeoutMs);
211
- }
212
- catch {
213
- await git(this.memDir, ["checkout", "-q", "-b", repoBranch], this.config.gitTimeoutMs);
585
+ return { added, deleted };
586
+ }
587
+ /**
588
+ * Parse the NUL-delimited log stream
589
+ * (`--format=%x00%H%x00%ct%x00%s%x00%B%x00 --name-status --no-renames`).
590
+ * Every commit contributes exactly five NUL-separated tokens followed by the
591
+ * status tail, so records align rigidly regardless of newlines in bodies
592
+ * (git itself forbids NUL inside commit messages).
593
+ */
594
+ export function parseLogRecords(output) {
595
+ const tokens = output.split("\0");
596
+ const records = [];
597
+ for (let i = 1; i + 4 < tokens.length; i += 5) {
598
+ const hash = tokens[i];
599
+ if (!COMMIT_RE.test(hash))
600
+ continue;
601
+ const committerTime = Number(tokens[i + 1]);
602
+ if (!Number.isFinite(committerTime))
603
+ continue;
604
+ const body = tokens[i + 3] ?? "";
605
+ const { added, deleted } = parseStatusTail(tokens[i + 4] ?? "");
606
+ records.push({ hash, committerTime, subject: tokens[i + 2] ?? "", body, added, deleted, message: parseCommitMessage(body) });
607
+ }
608
+ return records;
609
+ }
610
+ /** Map every active entry file to its current create/replace commit (newest ADD/REPLACE wins). */
611
+ function claimedActiveEntries(records, active) {
612
+ const claimed = new Map();
613
+ const diagnostics = [];
614
+ for (const record of records) {
615
+ if (record.added.length !== 1) {
616
+ if (record.added.length > 1 && (record.message.type === "add" || record.message.type === "replace")) {
617
+ diagnostics.push(`commit ${record.hash} adds ${record.added.length} entries; expected exactly one`);
214
618
  }
619
+ continue;
215
620
  }
216
- return repoBranch;
621
+ const file = record.added[0];
622
+ if (!active.has(file) || claimed.has(file))
623
+ continue;
624
+ claimed.set(file, { hash: record.hash, digest: record.message.digest });
217
625
  }
218
- /**
219
- * Search past memories.
220
- * @param keywords - comma-separated keywords.
221
- * @param skip - pagination offset (default 0).
222
- * @param mode - "and" (strict), "or" (broad), or "auto" (AND first, OR fallback).
223
- */
224
- async search(keywords, skip = 0, mode = "auto") {
225
- const limit = this.config.searchLimit;
226
- await ensureInit(this.memDir, this.config.gitTimeoutMs);
227
- const keywordsList = keywords
228
- .split(",")
229
- .map((kw) => kw.trim())
230
- .filter((kw) => kw.length > 0);
231
- if (keywordsList.length === 0)
232
- throw new Error("gitmemo: search requires at least one non-empty keyword");
233
- if (!Number.isInteger(skip) || skip < 0)
234
- throw new Error("gitmemo: skip must be a non-negative integer");
235
- const normalizedMode = mode.toLowerCase();
236
- if (!["and", "or", "auto"].includes(normalizedMode)) {
237
- throw new Error("gitmemo: mode must be one of: and, or, auto");
238
- }
239
- const run = async (searchMode) => this.runSearchMode(keywordsList, searchMode, skip, limit);
240
- if (normalizedMode === "auto") {
241
- const andResults = await run("and");
242
- if (andResults.length >= 3)
243
- return { mode: "and", results: andResults };
244
- return { mode: "or", results: await run("or") };
245
- }
246
- return { mode: normalizedMode, results: await run(normalizedMode) };
247
- }
248
- async runSearchMode(keywords, mode, skip, limit) {
249
- const grepArgs = [];
250
- for (const kw of keywords)
251
- grepArgs.push("--grep=" + kw);
252
- if (mode === "and")
253
- grepArgs.push("--all-match");
254
- return this.scanLog(grepArgs, skip, limit);
626
+ return { claimed, diagnostics };
627
+ }
628
+ // ---------------------------------------------------------------------------
629
+ // repo state helpers
630
+ // ---------------------------------------------------------------------------
631
+ async function pathExists(path) {
632
+ try {
633
+ await access(path);
634
+ return true;
255
635
  }
256
- /**
257
- * List the most recent memory entries across all branches, newest first.
258
- * Used to seed a new session's system prompt with a compact overview.
259
- * @param limit - maximum number of entries to return (0 returns none).
260
- */
261
- async recent(limit) {
262
- if (!Number.isInteger(limit) || limit < 0)
263
- throw new Error("gitmemo: recent limit must be a non-negative integer");
264
- if (limit === 0)
265
- return [];
266
- // Read-only: never creates the .mem repo (session-start injection must not touch the workspace).
267
- return this.scanLog([], 0, limit);
268
- }
269
- /** Active (non-deleted) entry files at HEAD. */
270
- async activeEntries() {
636
+ catch {
637
+ return false;
638
+ }
639
+ }
640
+ function migrationJournalPath(root) {
641
+ return join(root, MIGRATION_JOURNAL_NAME);
642
+ }
643
+ export async function writeMigrationSwapJournal(root, journal) {
644
+ const target = migrationJournalPath(root);
645
+ const tmp = target + ".tmp-" + process.pid + "-" + randomBytes(4).toString("hex");
646
+ await writeFile(tmp, JSON.stringify(journal, null, 2) + "\n", "utf8");
647
+ try {
648
+ await rename(tmp, target);
649
+ }
650
+ finally {
651
+ await rm(tmp, { force: true }).catch(() => { });
652
+ }
653
+ }
654
+ /**
655
+ * Recover the narrow two-rename migration swap window before any operation is
656
+ * allowed to auto-initialize `.mem`. This prevents a crash between renames
657
+ * from being mistaken for a genuinely fresh project.
658
+ */
659
+ export async function recoverMigrationSwap(root, gitTimeoutMs) {
660
+ let journal;
661
+ try {
662
+ journal = JSON.parse(await readFile(migrationJournalPath(root), "utf8"));
663
+ }
664
+ catch (error) {
665
+ if (error.code === "ENOENT")
666
+ return;
667
+ throw new GitMemoError(`gitmemo: migration recovery journal is unreadable (${migrationJournalPath(root)}): ${String(error)}. ` +
668
+ "Inspect the journal and migration backup directories before continuing.");
669
+ }
670
+ const canonicalRoot = resolve(root);
671
+ const expectedTarget = join(canonicalRoot, ".mem");
672
+ const safeSibling = (path, prefix) => dirname(resolve(path)) === canonicalRoot && basename(path).startsWith(prefix);
673
+ if (resolve(journal.targetDir) !== expectedTarget ||
674
+ !safeSibling(journal.oldDir, ".mem.gitmemo-old-") ||
675
+ !safeSibling(journal.tmpDir, ".mem.gitmemo-tmp-")) {
676
+ throw new GitMemoError(`gitmemo: migration recovery journal contains unsafe paths (${migrationJournalPath(root)}); refusing automatic filesystem changes`);
677
+ }
678
+ const sourceExists = await pathExists(journal.sourceDir);
679
+ const targetExists = await pathExists(journal.targetDir);
680
+ const oldExists = await pathExists(journal.oldDir);
681
+ const tmpExists = await pathExists(journal.tmpDir);
682
+ const targetIsNew = targetExists && (await pathExists(join(journal.targetDir, ".git"))) &&
683
+ (await repoFormat(journal.targetDir, gitTimeoutMs, "HEAD")) === "new";
684
+ if (targetIsNew && resolve(journal.sourceDir) !== expectedTarget) {
685
+ // A non-default legacy source is never moved: migration only installs the
686
+ // newly built canonical `.mem`, so recovery finalizes without touching the
687
+ // source repository.
688
+ if (tmpExists && resolve(journal.tmpDir) !== expectedTarget)
689
+ await rm(journal.tmpDir, { recursive: true, force: true });
690
+ await rm(migrationJournalPath(root), { force: true });
691
+ return;
692
+ }
693
+ if (targetIsNew && oldExists) {
694
+ await rm(journal.oldDir, { recursive: true, force: true });
695
+ if (tmpExists && journal.tmpDir !== journal.targetDir)
696
+ await rm(journal.tmpDir, { recursive: true, force: true });
697
+ await rm(migrationJournalPath(root), { force: true });
698
+ return;
699
+ }
700
+ if (!sourceExists && oldExists && resolve(journal.sourceDir) === expectedTarget) {
701
+ await rename(journal.oldDir, journal.sourceDir);
702
+ if (tmpExists)
703
+ await rm(journal.tmpDir, { recursive: true, force: true });
704
+ await rm(migrationJournalPath(root), { force: true });
705
+ return;
706
+ }
707
+ if (sourceExists && (!targetExists || (journal.sourceDir === journal.targetDir && !oldExists))) {
708
+ if (tmpExists)
709
+ await rm(journal.tmpDir, { recursive: true, force: true });
710
+ await rm(migrationJournalPath(root), { force: true });
711
+ return;
712
+ }
713
+ throw new GitMemoError(`gitmemo: interrupted migration swap cannot be recovered automatically (${migrationJournalPath(root)}). ` +
714
+ `source=${journal.sourceDir}, target=${journal.targetDir}, backup=${journal.oldDir}, tmp=${journal.tmpDir}. ` +
715
+ "Inspect these directories and restore exactly one canonical .mem repository before deleting the migration journal.");
716
+ }
717
+ function sleep(ms) {
718
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
719
+ }
720
+ function legacyWarning(root) {
721
+ return ("gitmemo: .mem is in legacy format — search is read-only over the current branch. " +
722
+ `Run \`dsh-gitmemo migrate --project-root ${root} --dry-run\` to plan migration (then --apply).`);
723
+ }
724
+ /** Detect the repo format from `.gitmemo-format` at the given snapshot. */
725
+ export async function repoFormat(memDir, gitTimeoutMs, snapshot = "HEAD") {
726
+ try {
727
+ const out = (await git(memDir, ["show", snapshot + ":.gitmemo-format"], gitTimeoutMs)).trim();
728
+ return out === GITMEMO_FORMAT_VERSION ? "new" : "legacy";
729
+ }
730
+ catch {
731
+ return "legacy";
732
+ }
733
+ }
734
+ /** Resolve a ref/hash to a full commit hash or throw. */
735
+ export async function resolveCommit(memDir, ref, gitTimeoutMs) {
736
+ try {
737
+ return (await git(memDir, ["rev-parse", "--verify", ref + "^{commit}"], gitTimeoutMs)).trim();
738
+ }
739
+ catch {
740
+ throw new GitMemoError("gitmemo: no such commit: " + ref);
741
+ }
742
+ }
743
+ /** Entry files added by one commit (single ADD/REPLACE mapping). */
744
+ export async function addedFilesOf(memDir, hash, gitTimeoutMs) {
745
+ let output = "";
746
+ try {
747
+ output = await git(memDir, ["diff-tree", "--root", "--no-commit-id", "--name-status", "-r", hash, "--", ENTRY_DIR + "/"], gitTimeoutMs);
748
+ }
749
+ catch {
750
+ return [];
751
+ }
752
+ const added = [];
753
+ for (const line of output.split("\n")) {
754
+ const match = /^A\t(entries\/.+\.md)$/.exec(line);
755
+ if (match !== null)
756
+ added.push(match[1]);
757
+ }
758
+ return added;
759
+ }
760
+ /** ls-tree active set + full per-commit record map at a fixed snapshot. */
761
+ async function activeSnapshot(memDir, snapshot, gitTimeoutMs) {
762
+ const activeOut = await git(memDir, ["ls-tree", "-r", "--name-only", snapshot, "--", ENTRY_DIR + "/"], gitTimeoutMs);
763
+ const active = new Set(activeOut.split("\n").filter((line) => line.length > 0));
764
+ const logOut = await git(memDir, [
765
+ "log",
766
+ snapshot,
767
+ "--format=%x00%H%x00%ct%x00%s%x00%B%x00",
768
+ "--name-status",
769
+ "--no-renames",
770
+ "--",
771
+ ENTRY_DIR + "/"
772
+ ], gitTimeoutMs);
773
+ const records = parseLogRecords(logOut);
774
+ const { claimed, diagnostics } = claimedActiveEntries(records, active);
775
+ return { active, records, claimed, diagnostics };
776
+ }
777
+ /**
778
+ * Resolve the search snapshot. New-format repos use refs/heads/main; legacy
779
+ * repos (no format marker, or no main) fall back to the current HEAD,
780
+ * read-only. A caller-supplied snapshot must still be an ancestor of the
781
+ * current main, otherwise it is stale.
782
+ */
783
+ async function resolveSnapshot(memDir, gitTimeoutMs, root, input) {
784
+ let main = "";
785
+ try {
786
+ main = (await git(memDir, ["rev-parse", "--verify", "refs/heads/main^{commit}"], gitTimeoutMs)).trim();
787
+ }
788
+ catch {
789
+ main = "";
790
+ }
791
+ if (main.length === 0) {
792
+ let head = "";
271
793
  try {
272
- const output = await git(this.memDir, ["ls-tree", "-r", "--name-only", "HEAD", "--", "entries/"], this.config.gitTimeoutMs);
273
- return new Set(output.split("\n").filter((line) => line.length > 0));
794
+ head = (await git(memDir, ["rev-parse", "--verify", "HEAD^{commit}"], gitTimeoutMs)).trim();
274
795
  }
275
796
  catch {
276
- return new Set();
277
- }
278
- }
279
- /** Shared git-log scan: keyword-grep optional, active-entry filtering, skip/limit. */
280
- async scanLog(grepArgs, skip, limit) {
281
- const active = await this.activeEntries();
282
- const results = [];
283
- let rawSkip = 0;
284
- let remainingSkip = skip;
285
- const batchSize = 200;
286
- for (let batch = 0; batch < this.config.maxSearchBatches && results.length < limit; batch += 1) {
287
- const args = [
288
- "log",
289
- ...grepArgs,
290
- "-i",
291
- "--skip=" + rawSkip,
292
- "--max-count=" + batchSize,
293
- "--format=%H\t%s\t%cd",
294
- "--date=iso",
295
- "--name-only",
296
- "--all",
297
- "--",
298
- "entries/"
299
- ];
300
- let output = "";
301
- try {
302
- output = await git(this.memDir, args, this.config.gitTimeoutMs);
797
+ throw new GitMemoError("gitmemo: .mem has neither a main branch nor a HEAD commit — the repo is unreadable; run `dsh-gitmemo migrate` or re-initialize");
798
+ }
799
+ if ((await repoFormat(memDir, gitTimeoutMs, head)) === "new") {
800
+ throw new GitMemoError("gitmemo: new-format .mem repository is missing refs/heads/main — the repository is damaged; restore main explicitly before continuing");
801
+ }
802
+ if (input !== undefined) {
803
+ const full = await resolveCommit(memDir, input, gitTimeoutMs);
804
+ if (!(await gitOk(memDir, ["merge-base", "--is-ancestor", full, head], gitTimeoutMs))) {
805
+ throw new GitMemoError(`gitmemo: stale legacy snapshot ${input} is no longer part of the current HEAD history — restart pagination`);
303
806
  }
304
- catch {
305
- return results; // no history yet (or log failure) — nothing to search
807
+ return { snapshot: full, legacy: true, warning: legacyWarning(root) };
808
+ }
809
+ return { snapshot: head, legacy: true, warning: legacyWarning(root) };
810
+ }
811
+ const format = await repoFormat(memDir, gitTimeoutMs, main);
812
+ if (format !== "new") {
813
+ if (input !== undefined) {
814
+ const full = await resolveCommit(memDir, input, gitTimeoutMs);
815
+ if (!(await gitOk(memDir, ["merge-base", "--is-ancestor", full, main], gitTimeoutMs))) {
816
+ throw new GitMemoError(`gitmemo: stale legacy snapshot ${input} is no longer part of the current branch history — restart pagination`);
306
817
  }
307
- if (output.trim().length === 0)
818
+ return { snapshot: full, legacy: true, warning: legacyWarning(root) };
819
+ }
820
+ return { snapshot: main, legacy: true, warning: legacyWarning(root) };
821
+ }
822
+ if (input !== undefined) {
823
+ const full = await resolveCommit(memDir, input, gitTimeoutMs);
824
+ const ancestor = await gitOk(memDir, ["merge-base", "--is-ancestor", full, main], gitTimeoutMs);
825
+ if (!ancestor) {
826
+ throw new GitMemoError(`gitmemo: stale snapshot ${input} is no longer part of the current main history — re-run mem_search from the first page`);
827
+ }
828
+ return { snapshot: full, legacy: false };
829
+ }
830
+ return { snapshot: main, legacy: false };
831
+ }
832
+ /** Entry currently active at a snapshot, verified by its create/replace hash. */
833
+ async function activeEntryForHash(memDir, fullHash, snapshot, gitTimeoutMs) {
834
+ const body = await git(memDir, ["log", "-1", "--format=%B", fullHash], gitTimeoutMs);
835
+ const message = parseCommitMessage(body);
836
+ if (message.type !== "add" && message.type !== "replace") {
837
+ throw new GitMemoError(`gitmemo: commit ${fullHash} is not a gitmemo entry (add/replace) commit`);
838
+ }
839
+ const added = await addedFilesOf(memDir, fullHash, gitTimeoutMs);
840
+ if (added.length !== 1) {
841
+ throw new GitMemoError(`gitmemo: commit ${fullHash} maps to ${added.length} entry files — cannot resolve a single entry`);
842
+ }
843
+ const file = added[0];
844
+ const activeHash = (await git(memDir, ["log", "-1", "--format=%H", snapshot, "--", file], gitTimeoutMs)).trim();
845
+ if (activeHash !== fullHash) {
846
+ throw new GitMemoError(`gitmemo: commit ${fullHash} is not the active entry for ${file} (active: ${activeHash || "none"}) — use the hash returned by mem_search`);
847
+ }
848
+ return { file, message };
849
+ }
850
+ // ---------------------------------------------------------------------------
851
+ // locking
852
+ // ---------------------------------------------------------------------------
853
+ const processMutexes = new Map();
854
+ function withProcessMutex(key, fn) {
855
+ const previous = processMutexes.get(key) ?? Promise.resolve();
856
+ const next = previous.then(fn, fn);
857
+ processMutexes.set(key, next);
858
+ // Observe the chain without propagating: the caller owns the outcome, and a
859
+ // discarded rejection here would surface as an unhandledRejection.
860
+ const cleanup = () => {
861
+ if (processMutexes.get(key) === next)
862
+ processMutexes.delete(key);
863
+ };
864
+ next.then(cleanup, cleanup);
865
+ return next;
866
+ }
867
+ function isProcessAlive(pid) {
868
+ try {
869
+ process.kill(pid, 0);
870
+ return true;
871
+ }
872
+ catch (error) {
873
+ return error.code === "EPERM";
874
+ }
875
+ }
876
+ async function staleLock(lockPath, lockTimeoutMs) {
877
+ let info;
878
+ try {
879
+ info = JSON.parse(await readFile(lockPath, "utf8"));
880
+ }
881
+ catch {
882
+ return false;
883
+ }
884
+ if (typeof info.pid !== "number" || info.host !== hostname())
885
+ return false;
886
+ if (isProcessAlive(info.pid))
887
+ return false;
888
+ const started = Date.parse(info.startedAt);
889
+ if (Number.isNaN(started))
890
+ return false;
891
+ // Only clear locks that are both dead and safely beyond any plausible live
892
+ // transaction duration; never force-break a lock we cannot judge.
893
+ return Date.now() - started > Math.max(10000, lockTimeoutMs * 2);
894
+ }
895
+ /**
896
+ * Cross-process transaction lock (plan 8.1): exclusive create of a sibling
897
+ * lock file, backed by an in-process mutex on the same canonical key. Used by
898
+ * init/write/delete/replace/migrate; search/read take it briefly to resolve
899
+ * their immutable snapshot (read barrier) and release before scanning.
900
+ */
901
+ export async function withLock(root, opts, operation, fn) {
902
+ const key = resolve(root);
903
+ return withProcessMutex(key, async () => {
904
+ const lockPath = join(key, LOCK_FILE_NAME);
905
+ const operationId = randomUUID();
906
+ const deadline = Date.now() + opts.lockTimeoutMs;
907
+ const payload = JSON.stringify({ pid: process.pid, host: hostname(), startedAt: new Date().toISOString(), operation, operationId }, null, 2) + "\n";
908
+ for (;;) {
909
+ try {
910
+ const handle = await open(lockPath, "wx");
911
+ try {
912
+ await handle.writeFile(payload, "utf8");
913
+ }
914
+ finally {
915
+ await handle.close();
916
+ }
308
917
  break;
309
- const records = parseLogBatch(output);
310
- for (const record of records) {
311
- if (!this.acceptRecord(record.hash, record.title, record.file, active))
918
+ }
919
+ catch (error) {
920
+ if (error.code !== "EEXIST") {
921
+ throw new GitMemoError("gitmemo: cannot create lock file " + lockPath + ": " + String(error));
922
+ }
923
+ if (await staleLock(lockPath, opts.lockTimeoutMs)) {
924
+ await rm(lockPath, { force: true }).catch(() => { });
312
925
  continue;
313
- if (remainingSkip > 0) {
314
- remainingSkip -= 1;
315
926
  }
316
- else {
317
- results.push({ hash: record.hash, title: record.title, date: record.date });
318
- if (results.length >= limit)
319
- break;
927
+ if (Date.now() >= deadline) {
928
+ throw new GitMemoError(`gitmemo: timed out after ${opts.lockTimeoutMs}ms waiting for lock ${lockPath} (held by another gitmemo process). ` +
929
+ "If no other process is running, remove the stale lock file manually.");
320
930
  }
931
+ await sleep(40 + Math.floor(Math.random() * 60));
321
932
  }
322
- if (results.length >= limit)
323
- break;
324
- if (records.length < batchSize)
325
- break;
326
- rawSkip += batchSize;
327
933
  }
328
- return results;
329
- }
330
- /**
331
- * Synchronous variant of {@link recent} for the session-start injection
332
- * path: blocks until the memory repo is scanned so the system-prompt
333
- * context can be registered before the first request assembles.
334
- */
335
- recentSync(limit) {
336
- if (!Number.isInteger(limit) || limit < 0)
337
- throw new Error("gitmemo: recent limit must be a non-negative integer");
338
- if (limit === 0)
339
- return [];
340
- let active = new Set();
341
934
  try {
342
- const output = gitSync(this.memDir, ["ls-tree", "-r", "--name-only", "HEAD", "--", "entries/"], this.config.gitTimeoutMs);
343
- active = new Set(output.split("\n").filter((line) => line.length > 0));
935
+ return await fn();
344
936
  }
345
- catch {
346
- return []; // no memory repo yet
347
- }
348
- const results = [];
349
- const batchSize = 200;
350
- let records = [];
351
- for (let rawSkip = 0, batch = 0; batch < this.config.maxSearchBatches && results.length < limit; rawSkip += batchSize, batch += 1) {
352
- const args = [
353
- "log",
354
- "--skip=" + rawSkip,
355
- "--max-count=" + batchSize,
356
- "--format=%H\t%s\t%cd",
357
- "--date=iso",
358
- "--name-only",
359
- "--all",
360
- "--",
361
- "entries/"
362
- ];
363
- let output;
364
- try {
365
- output = gitSync(this.memDir, args, this.config.gitTimeoutMs);
366
- }
367
- catch {
368
- return results;
369
- }
370
- if (output.trim().length === 0)
371
- break;
372
- records = parseLogBatch(output);
373
- for (const record of records) {
374
- if (!this.acceptRecord(record.hash, record.title, record.file, active))
375
- continue;
376
- results.push({ hash: record.hash, title: record.title, date: record.date });
377
- if (results.length >= limit)
378
- break;
379
- }
380
- if (results.length >= limit || records.length < batchSize)
381
- break;
937
+ finally {
938
+ await rm(lockPath, { force: true }).catch(() => { });
382
939
  }
383
- return results;
940
+ });
941
+ }
942
+ // ---------------------------------------------------------------------------
943
+ // init / journal / cleanliness
944
+ // ---------------------------------------------------------------------------
945
+ function journalPath(memDir) {
946
+ return join(memDir, ".git", "gitmemo-transaction.json");
947
+ }
948
+ async function writeJournal(memDir, journal) {
949
+ const target = journalPath(memDir);
950
+ const tmp = target + ".tmp-" + process.pid + "-" + randomBytes(4).toString("hex");
951
+ await writeFile(tmp, JSON.stringify(journal, null, 2) + "\n", "utf8");
952
+ try {
953
+ await rename(tmp, target);
384
954
  }
385
- acceptRecord(hash, subject, file, active) {
386
- void hash;
387
- if (subject.startsWith("delete: remove"))
388
- return false;
389
- if (file.length === 0 || !active.has(file))
390
- return false;
391
- return true;
955
+ finally {
956
+ await rm(tmp, { force: true }).catch(() => { });
392
957
  }
393
- /** Resolve the entry file touched by a commit (first file under entries/). */
394
- async entryFileFromCommit(commitHash) {
395
- let output = "";
958
+ }
959
+ async function readJournal(memDir) {
960
+ try {
961
+ return JSON.parse(await readFile(journalPath(memDir), "utf8"));
962
+ }
963
+ catch (error) {
964
+ if (error.code === "ENOENT")
965
+ return undefined;
966
+ throw new GitMemoError(`gitmemo: transaction journal is unreadable (${journalPath(memDir)}): ${String(error)} — inspect it before continuing`);
967
+ }
968
+ }
969
+ function repairMessage(memDir, journal, reason) {
970
+ return ("gitmemo: interrupted transaction detected (" + journalPath(memDir) + "): " + reason + ". " +
971
+ "Automatic rollback is refused because the repository state does not match the journal. " +
972
+ "Inspect `git -C " + memDir + " status` and the journal, then either restore the repo to a clean state " +
973
+ "and delete the journal file, or finish the operation manually.");
974
+ }
975
+ /**
976
+ * Strict journal recovery (plan 8.3): only when HEAD still equals the
977
+ * journal's base and the worktree/index changes are exactly the planned
978
+ * paths is the rollback performed; anything else aborts with repair guidance.
979
+ */
980
+ export async function rollbackJournal(memDir, journal, gitTimeoutMs) {
981
+ const planned = new Set([...journal.add, ...journal.delete]);
982
+ const head = (await git(memDir, ["rev-parse", "HEAD"], gitTimeoutMs)).trim();
983
+ if (head !== journal.baseHead) {
984
+ // A commit may have succeeded immediately before journal cleanup failed or
985
+ // the process exited. Treat that exact, clean one-commit transition as
986
+ // completed instead of permanently blocking all future transactions.
987
+ const status = (await git(memDir, ["status", "--porcelain"], gitTimeoutMs)).trim();
988
+ const parent = await git(memDir, ["rev-parse", "HEAD^"], gitTimeoutMs).then((s) => s.trim()).catch(() => "");
989
+ const changed = (await git(memDir, ["diff-tree", "--no-commit-id", "--name-status", "-r", "--no-renames", "HEAD"], gitTimeoutMs))
990
+ .trim()
991
+ .split("\n")
992
+ .filter(Boolean)
993
+ .map((line) => {
994
+ const [statusCode, ...parts] = line.split("\t");
995
+ return { status: statusCode, path: parts.join("\t") };
996
+ });
997
+ const expected = [
998
+ ...journal.add.map((path) => ({ status: "A", path })),
999
+ ...journal.delete.map((path) => ({ status: "D", path }))
1000
+ ];
1001
+ const sort = (items) => [...items].sort((a, b) => a.path.localeCompare(b.path));
1002
+ const a = sort(changed);
1003
+ const e = sort(expected);
1004
+ if (status.length === 0 && parent === journal.baseHead && a.length === e.length && a.every((item, i) => item.status === e[i].status && item.path === e[i].path)) {
1005
+ await rm(journalPath(memDir), { force: true });
1006
+ return;
1007
+ }
1008
+ throw new GitMemoError(repairMessage(memDir, journal, `HEAD moved since the journal was written (${journal.baseHead} → ${head})`));
1009
+ }
1010
+ const [stagedOut, unstagedOut, untrackedOut] = await Promise.all([
1011
+ git(memDir, ["diff", "--cached", "--name-only", "--no-renames"], gitTimeoutMs),
1012
+ git(memDir, ["diff", "--name-only", "--no-renames"], gitTimeoutMs),
1013
+ git(memDir, ["ls-files", "--others", "--exclude-standard"], gitTimeoutMs)
1014
+ ]);
1015
+ const statusPaths = dedupe([stagedOut, unstagedOut, untrackedOut]
1016
+ .flatMap((out) => out.split("\n"))
1017
+ .filter((path) => path.length > 0));
1018
+ // A journal is deliberately written before the first file mutation. A
1019
+ // crash can therefore leave none or only a subset of the planned paths;
1020
+ // that is safe to roll back as long as no unknown path is present.
1021
+ if (statusPaths.some((p) => !planned.has(p))) {
1022
+ throw new GitMemoError(repairMessage(memDir, journal, "worktree/index changes do not match the journal plan"));
1023
+ }
1024
+ for (const path of journal.add) {
1025
+ await rm(join(memDir, path), { force: true }).catch(() => { });
1026
+ await git(memDir, ["rm", "-q", "--cached", "--ignore-unmatch", "--", path], gitTimeoutMs).catch(() => { });
1027
+ }
1028
+ for (const path of journal.delete) {
1029
+ await git(memDir, ["checkout", "-q", journal.baseHead, "--", path], gitTimeoutMs).catch(() => { });
1030
+ }
1031
+ const after = (await git(memDir, ["status", "--porcelain"], gitTimeoutMs)).trim();
1032
+ if (after.length > 0) {
1033
+ throw new GitMemoError(repairMessage(memDir, journal, "rollback left the worktree dirty — inspect manually"));
1034
+ }
1035
+ await rm(journalPath(memDir), { force: true });
1036
+ }
1037
+ async function recoverJournal(memDir, gitTimeoutMs) {
1038
+ const journal = await readJournal(memDir);
1039
+ if (journal !== undefined)
1040
+ await rollbackJournal(memDir, journal, gitTimeoutMs);
1041
+ }
1042
+ /** Refuse to run a transaction on a dirty .mem work tree (plan 8.2). */
1043
+ async function assertClean(memDir, gitTimeoutMs) {
1044
+ const status = (await git(memDir, ["status", "--porcelain"], gitTimeoutMs)).trim();
1045
+ if (status.length > 0) {
1046
+ throw new GitMemoError("gitmemo: .mem working tree is not clean — refusing to write. gitmemo never stashes, resets, or commits unknown changes. " +
1047
+ "Inspect with: git -C " + memDir + " status");
1048
+ }
1049
+ }
1050
+ async function assertOnMain(memDir, gitTimeoutMs) {
1051
+ let sym = "";
1052
+ try {
1053
+ sym = (await git(memDir, ["symbolic-ref", "-q", "HEAD"], gitTimeoutMs)).trim();
1054
+ }
1055
+ catch {
1056
+ sym = "";
1057
+ }
1058
+ if (sym !== "refs/heads/main") {
1059
+ throw new GitMemoError(`gitmemo: .mem HEAD is not on refs/heads/main (${sym || "unborn HEAD"}) — gitmemo requires its single main branch`);
1060
+ }
1061
+ }
1062
+ /** Write/delete/replace must run on the new format; legacy repos need migration. */
1063
+ async function assertNewFormat(memDir, gitTimeoutMs, root) {
1064
+ const format = await repoFormat(memDir, gitTimeoutMs);
1065
+ if (format !== "new") {
1066
+ throw new GitMemoError("gitmemo: .mem exists in legacy format — write operations are disabled. " +
1067
+ `Run \`dsh-gitmemo migrate --project-root ${root} --dry-run\` to plan migration, then --apply.`);
1068
+ }
1069
+ }
1070
+ /** Current code branch + HEAD SHA for entry metadata (plan 7.2/7.3). */
1071
+ async function codeContext(root, gitTimeoutMs) {
1072
+ try {
1073
+ const branch = (await git(root, ["rev-parse", "--abbrev-ref", "HEAD"], gitTimeoutMs)).trim();
1074
+ const commit = (await git(root, ["rev-parse", "HEAD"], gitTimeoutMs)).trim();
1075
+ if (branch === "HEAD")
1076
+ return { branch: "detached:" + commit, commit };
1077
+ return { branch: branch.length > 0 ? branch : "unknown", commit: COMMIT_RE.test(commit) ? commit : "unknown" };
1078
+ }
1079
+ catch {
1080
+ // Unborn HEAD (fresh repo without commits): resolve the symbolic ref directly.
396
1081
  try {
397
- output = await git(this.memDir, ["diff-tree", "--no-commit-id", "--name-only", "-r", commitHash, "--", "entries/"], this.config.gitTimeoutMs);
1082
+ const sym = (await git(root, ["symbolic-ref", "--short", "HEAD"], gitTimeoutMs)).trim();
1083
+ if (sym.length > 0)
1084
+ return { branch: sym, commit: "unknown" };
398
1085
  }
399
1086
  catch {
400
- return undefined;
1087
+ // not a git repo at all
401
1088
  }
402
- let file = output.split("\n").find((line) => line.startsWith("entries/") && line.endsWith(".md"));
403
- if (file === undefined) {
404
- try {
405
- output = await git(this.memDir, ["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", commitHash, "--", "entries/"], this.config.gitTimeoutMs);
406
- file = output.split("\n").find((line) => line.startsWith("entries/") && line.endsWith(".md"));
407
- }
408
- catch {
409
- return undefined;
410
- }
1089
+ return { branch: "unknown", commit: "unknown" };
1090
+ }
1091
+ }
1092
+ /** Verify the staged index equals exactly the transaction plan before commit. */
1093
+ async function assertStagedExactly(memDir, expected, gitTimeoutMs) {
1094
+ const out = await git(memDir, ["diff", "--cached", "--name-status", "--no-renames"], gitTimeoutMs);
1095
+ const actual = out
1096
+ .split("\n")
1097
+ .filter((line) => line.length > 0)
1098
+ .map((line) => {
1099
+ const [status, ...rest] = line.split("\t");
1100
+ return { status, path: rest.join("\t") };
1101
+ });
1102
+ const sorted = (list) => [...list].sort((a, b) => a.path.localeCompare(b.path));
1103
+ const a = sorted(actual);
1104
+ const e = sorted(expected);
1105
+ if (a.length !== e.length || a.some((x, i) => x.status !== e[i].status || x.path !== e[i].path)) {
1106
+ throw new GitMemoError("gitmemo: staged changes do not match the transaction plan — aborting before commit. Staged: " +
1107
+ JSON.stringify(actual) +
1108
+ ", planned: " +
1109
+ JSON.stringify(expected));
1110
+ }
1111
+ }
1112
+ async function assertParentNotTracking(root, memDir, gitTimeoutMs) {
1113
+ if (!(await gitOk(root, ["rev-parse", "--git-dir"], gitTimeoutMs)))
1114
+ return;
1115
+ const rel = relative(root, memDir).split(sep).join("/");
1116
+ const tracked = (await git(root, ["ls-files", "--", rel], gitTimeoutMs)).trim();
1117
+ if (tracked.length > 0) {
1118
+ throw new GitMemoError(`gitmemo: the parent repository already tracks ${rel} — gitmemo refuses to initialize inside a tracked directory. ` +
1119
+ `Remove it from version control first (git rm -r --cached ${rel}).`);
1120
+ }
1121
+ }
1122
+ /** Add `.mem/` and the lock file path to the parent repo's info/exclude. */
1123
+ async function excludeFromParent(root, gitTimeoutMs) {
1124
+ let gitPath = "";
1125
+ try {
1126
+ gitPath = (await git(root, ["rev-parse", "--git-path", "info/exclude"], gitTimeoutMs)).trim();
1127
+ }
1128
+ catch {
1129
+ return;
1130
+ }
1131
+ if (gitPath.length === 0)
1132
+ return;
1133
+ const excludePath = isAbsolute(gitPath) ? gitPath : join(root, gitPath);
1134
+ let existing = "";
1135
+ try {
1136
+ existing = await readFile(excludePath, "utf8");
1137
+ }
1138
+ catch {
1139
+ existing = "";
1140
+ }
1141
+ const lines = existing.split("\n");
1142
+ let changed = false;
1143
+ for (const wanted of [".mem/", LOCK_FILE_NAME, MIGRATION_JOURNAL_NAME, ".mem.gitmemo-old-*", ".mem.gitmemo-tmp-*"]) {
1144
+ if (!lines.includes(wanted)) {
1145
+ lines.push(wanted);
1146
+ changed = true;
411
1147
  }
412
- return file;
413
1148
  }
414
- /** Read one memory entry by commit hash. */
415
- async read(commitHash) {
416
- if (!/^[0-9a-f]{7,}$/.test(commitHash))
417
- throw new Error("gitmemo: read requires a commit hash");
418
- await ensureInit(this.memDir, this.config.gitTimeoutMs);
419
- const file = await this.entryFileFromCommit(commitHash);
420
- if (file === undefined)
421
- throw new Error("gitmemo: no entry file found in commit " + commitHash);
422
- const content = await git(this.memDir, ["show", commitHash + ":" + file], this.config.gitTimeoutMs);
423
- return { file, content };
1149
+ if (changed) {
1150
+ await writeFile(excludePath, lines.filter((l, i) => !(i === 0 && l === "" && lines.length > 1)).join("\n") + "\n", "utf8");
1151
+ }
1152
+ }
1153
+ /**
1154
+ * Initialize a new-format `.mem` repo (plan 8.4): build the whole repo in a
1155
+ * sibling temp directory (init commit carries `.gitmemo-format` + `.gitkeep`
1156
+ * on `main`), then atomically rename it into place. Never converts an
1157
+ * existing legacy repo. Idempotent for already-initialized repos.
1158
+ */
1159
+ export async function ensureInit(root, memDir, gitTimeoutMs) {
1160
+ await recoverMigrationSwap(root, gitTimeoutMs);
1161
+ if (await pathExists(join(memDir, ".git")))
1162
+ return;
1163
+ await assertParentNotTracking(root, memDir, gitTimeoutMs);
1164
+ if (await pathExists(memDir)) {
1165
+ throw new GitMemoError(`gitmemo: ${memDir} exists but is not a git repository — move it away or remove it, then retry`);
1166
+ }
1167
+ const tmp = join(root, `.mem.gitmemo-tmp-${process.pid}-${randomBytes(4).toString("hex")}`);
1168
+ await mkdir(join(tmp, ENTRY_DIR), { recursive: true });
1169
+ await gitInitMain(tmp, gitTimeoutMs);
1170
+ await writeFile(join(tmp, FORMAT_FILE), GITMEMO_FORMAT_VERSION + "\n", "utf8");
1171
+ await writeFile(join(tmp, ENTRY_DIR, ".gitkeep"), "", "utf8");
1172
+ await git(tmp, ["add", "-A", "--", FORMAT_FILE, ENTRY_DIR + "/.gitkeep"], gitTimeoutMs);
1173
+ await commitWithIdentity(tmp, "init: initialize memory repo (gitmemo format 2)", gitTimeoutMs);
1174
+ await rename(tmp, memDir);
1175
+ await excludeFromParent(root, gitTimeoutMs);
1176
+ }
1177
+ const searchCache = new Map();
1178
+ function cacheSearch(key, value) {
1179
+ searchCache.delete(key);
1180
+ searchCache.set(key, value);
1181
+ while (searchCache.size > SEARCH_CACHE_LIMIT) {
1182
+ const oldest = searchCache.keys().next().value;
1183
+ if (oldest === undefined)
1184
+ break;
1185
+ searchCache.delete(oldest);
1186
+ }
1187
+ }
1188
+ // ---------------------------------------------------------------------------
1189
+ // GitMemo engine
1190
+ // ---------------------------------------------------------------------------
1191
+ /**
1192
+ * Git-backed long-term memory engine operating on one project root.
1193
+ * Every operation auto-initializes a new-format repo; legacy repos are
1194
+ * read-only for search/read until migrated via the CLI.
1195
+ */
1196
+ export class GitMemo {
1197
+ root;
1198
+ memDir;
1199
+ config;
1200
+ constructor(root, config = {}) {
1201
+ this.root = resolve(root);
1202
+ const positiveInt = (value, fallback, name) => {
1203
+ if (value === undefined)
1204
+ return fallback;
1205
+ if (!Number.isInteger(value) || value <= 0) {
1206
+ throw new GitMemoError(`gitmemo: ${name} must be a positive integer`);
1207
+ }
1208
+ return value;
1209
+ };
1210
+ this.config = {
1211
+ searchLimit: positiveInt(config.searchLimit, 20, "searchLimit"),
1212
+ gitTimeoutMs: positiveInt(config.gitTimeoutMs, 60000, "gitTimeoutMs"),
1213
+ lockTimeoutMs: positiveInt(config.lockTimeoutMs, 30000, "lockTimeoutMs")
1214
+ };
1215
+ this.memDir = join(this.root, ".mem");
1216
+ }
1217
+ /** Initialize the memory repo when missing (idempotent). */
1218
+ async init() {
1219
+ await withLock(this.root, this.config, "init", async () => {
1220
+ await ensureInit(this.root, this.memDir, this.config.gitTimeoutMs);
1221
+ });
1222
+ return this.memDir;
424
1223
  }
425
1224
  /**
426
- * Write a memory entry.
427
- * Mirrors mem.sh write: commit a markdown entry under entries/ with an
428
- * optional body, aligning the .mem branch with the project branch first.
1225
+ * Search past memories (plan 6): fixed-string OR grep recall over commit
1226
+ * messages only, active-entry filtering, field-level matching and scoring,
1227
+ * snapshot-stable pagination, in-process LRU.
1228
+ *
1229
+ * @param keywords - 1-12 keywords (legacy callers may pass a comma-separated string).
1230
+ * @param options.skip - pagination offset.
1231
+ * @param options.snapshot - pass back the snapshot from the first page.
429
1232
  */
430
- async write(options) {
431
- await ensureInit(this.memDir, this.config.gitTimeoutMs);
432
- const { title, content } = options;
433
- const contentFile = blankToUndefined(options.contentFile);
434
- const file = blankToUndefined(options.file);
435
- const body = options.body;
436
- const bodyFile = blankToUndefined(options.bodyFile);
437
- if (title === undefined || title.trim().length === 0) {
438
- throw new Error("gitmemo: write requires --title");
439
- }
440
- if (body !== undefined && bodyFile !== undefined)
441
- throw new Error("gitmemo: use only one of body or body_file — pass the commit body inline via body and omit body_file");
442
- if (content !== undefined && contentFile !== undefined)
443
- throw new Error("gitmemo: use only one of content or contentFile");
444
- // Resolve optional payload paths against the engine root when relative.
445
- const resolvePayload = (p) => p === undefined ? undefined : (isAbsolute(p) ? p : resolve(this.root, p));
446
- const fullContentFile = resolvePayload(contentFile);
447
- const fullBodyFile = resolvePayload(bodyFile);
448
- if (fullContentFile !== undefined) {
449
- await fsAccess(fullContentFile).catch(() => {
450
- throw new Error("gitmemo: content file not found: " + fullContentFile);
451
- });
1233
+ async search(keywords, options = {}) {
1234
+ let normalized;
1235
+ let adapterWarning;
1236
+ if (typeof keywords === "string") {
1237
+ normalized = keywords
1238
+ .split(",")
1239
+ .map((k) => k.trim())
1240
+ .filter((k) => k.length > 0)
1241
+ .map((k) => normalizeKeyword(k));
1242
+ adapterWarning = "gitmemo: deprecation — mem_search.keywords as a comma-separated string; pass an array of 1-12 keywords";
452
1243
  }
453
- let resolvedBody = body;
454
- if (fullBodyFile !== undefined) {
455
- resolvedBody = await readFileSafe(fullBodyFile, "gitmemo: body file not found");
456
- }
457
- const directContentFile = fullContentFile !== undefined ? entryFileFromPath(fullContentFile, this.memDir, this.root) : undefined;
458
- let entryFile;
459
- if (file !== undefined) {
460
- entryFile = normalizeEntryFile(file, this.memDir, this.root);
461
- if (directContentFile !== undefined && entryFile !== directContentFile) {
462
- throw new Error("gitmemo: --file must match the existing .mem/entries path referenced by --content-file");
463
- }
1244
+ else {
1245
+ if (!Array.isArray(keywords))
1246
+ throw new GitMemoError("gitmemo: search keywords must be an array");
1247
+ normalized = keywords.map((k) => normalizeKeyword(k));
464
1248
  }
465
- else if (directContentFile !== undefined) {
466
- entryFile = directContentFile;
1249
+ normalized = dedupe(normalized);
1250
+ if (options.mode !== undefined) {
1251
+ adapterWarning = (adapterWarning ? adapterWarning + " " : "") +
1252
+ "gitmemo: deprecation — mem_search.mode is gone; recall is always OR";
467
1253
  }
468
- else if (content !== undefined || fullContentFile !== undefined) {
469
- entryFile = "entries/" + utcTimestamp() + "-" + slugifyTitle(title) + ".md";
1254
+ if (normalized.length < 1 || normalized.length > 12) {
1255
+ throw new GitMemoError("gitmemo: search requires 1-12 non-empty keywords");
470
1256
  }
471
- else {
472
- throw new Error("gitmemo: missing content — use content, contentFile, or a pre-written .mem/entries file via file");
473
- }
474
- if (!isSafeEntryPath(entryFile))
475
- throw new Error("gitmemo: invalid file path: " + entryFile);
476
- const finalFile = entryFile.endsWith(".md") ? entryFile : entryFile + ".md";
477
- await this.syncBranch();
478
- const fullPath = join(this.memDir, finalFile);
479
- let useExisting = false;
480
- let deleteContentFile = false;
481
- if (directContentFile !== undefined) {
482
- useExisting = true;
483
- }
484
- else if (content === undefined && fullContentFile === undefined) {
485
- // Commit a pre-written entry file in place.
486
- useExisting = true;
487
- }
488
- else if (fullContentFile !== undefined) {
489
- deleteContentFile = true;
490
- }
491
- if (useExisting) {
492
- // The file must exist at its final path (after branch sync).
493
- try {
494
- await fsAccess(fullPath);
495
- }
496
- catch {
497
- throw new Error("gitmemo: existing entry file not found after branch sync: " + fullPath);
1257
+ const skip = options.skip ?? 0;
1258
+ if (!Number.isInteger(skip) || skip < 0)
1259
+ throw new GitMemoError("gitmemo: skip must be a non-negative integer");
1260
+ const limit = this.config.searchLimit;
1261
+ const requestedSnapshot = typeof options.snapshot === "string" && options.snapshot.trim().length > 0
1262
+ ? options.snapshot.trim()
1263
+ : undefined;
1264
+ // Read barrier: resolve the immutable snapshot under the lock (creating
1265
+ // the repo when missing), then scan without holding it.
1266
+ const { snapshot, legacy, warning } = await withLock(this.root, this.config, "search", async () => {
1267
+ if (!(await pathExists(join(this.memDir, ".git")))) {
1268
+ await ensureInit(this.root, this.memDir, this.config.gitTimeoutMs);
498
1269
  }
1270
+ return resolveSnapshot(this.memDir, this.config.gitTimeoutMs, this.root, requestedSnapshot);
1271
+ });
1272
+ const cacheKey = this.memDir + "\x00" + snapshot + "\x00" + normalized.join("\x00");
1273
+ let computation = searchCache.get(cacheKey);
1274
+ if (computation === undefined) {
1275
+ computation = await this.computeSearch(snapshot, normalized, legacy);
1276
+ cacheSearch(cacheKey, computation);
499
1277
  }
500
- else {
501
- await mkdir(dirname(fullPath), { recursive: true });
502
- if (fullContentFile !== undefined) {
503
- await copyFile(fullContentFile, fullPath);
1278
+ const total = computation.hits.length;
1279
+ const next_skip = skip + limit < total ? skip + limit : null;
1280
+ const allWarnings = [adapterWarning, warning].filter((w) => w !== undefined).join(" ");
1281
+ return {
1282
+ snapshot,
1283
+ total,
1284
+ next_skip,
1285
+ results: computation.hits.slice(skip, skip + limit),
1286
+ ...(legacy ? { legacy: true } : {}),
1287
+ ...(allWarnings.length > 0 ? { warning: allWarnings } : {}),
1288
+ ...(computation.diagnostics.length > 0 ? { diagnostics: computation.diagnostics } : {})
1289
+ };
1290
+ }
1291
+ async computeSearch(snapshot, kws, legacy) {
1292
+ // New-format entries are immutable, so candidate commit + active ls-tree
1293
+ // is sufficient and keeps the normal search path to one grep log. Legacy
1294
+ // repos may have in-place edits and need a full-history claimed map.
1295
+ const legacySnapshot = legacy ? await activeSnapshot(this.memDir, snapshot, this.config.gitTimeoutMs) : undefined;
1296
+ const active = legacySnapshot?.active ?? new Set((await git(this.memDir, ["ls-tree", "-r", "--name-only", snapshot, "--", ENTRY_DIR + "/"], this.config.gitTimeoutMs)).split("\n").filter((line) => line.length > 0));
1297
+ const grepArgs = ["log", snapshot, "--fixed-strings"];
1298
+ for (const kw of kws)
1299
+ grepArgs.push("--grep=" + kw);
1300
+ grepArgs.push("--format=%x00%H%x00%ct%x00%s%x00%B%x00", "--name-status", "--no-renames", "--", ENTRY_DIR + "/");
1301
+ const output = await git(this.memDir, grepArgs, this.config.gitTimeoutMs);
1302
+ const candidates = parseLogRecords(output);
1303
+ const hits = [];
1304
+ const diagnostics = [...(legacySnapshot?.diagnostics ?? [])];
1305
+ const seenFiles = new Set();
1306
+ for (const record of candidates) {
1307
+ if (record.added.length !== 1) {
1308
+ if (record.added.length > 1)
1309
+ diagnostics.push(`commit ${record.hash} adds ${record.added.length} entries; skipped`);
1310
+ continue;
504
1311
  }
505
- else {
506
- await writeFile(fullPath, content ?? "", "utf8");
1312
+ const file = record.added[0];
1313
+ if (!active.has(file))
1314
+ continue;
1315
+ if (legacy && legacySnapshot?.claimed.get(file)?.hash !== record.hash)
1316
+ continue;
1317
+ if (seenFiles.has(file)) {
1318
+ diagnostics.push(`multiple matching commits claim active entry ${file}; older candidate ${record.hash} skipped`);
1319
+ continue;
507
1320
  }
508
- }
509
- await git(this.memDir, ["add", "--", finalFile], this.config.gitTimeoutMs);
510
- if (resolvedBody !== undefined && resolvedBody.trim().length > 0) {
511
- await git(this.memDir, ["commit", "-q", "-m", title, "-m", resolvedBody], this.config.gitTimeoutMs);
512
- }
513
- else {
514
- await git(this.memDir, ["commit", "-q", "-m", title], this.config.gitTimeoutMs);
515
- }
516
- const hash = (await git(this.memDir, ["rev-parse", "HEAD"], this.config.gitTimeoutMs)).trim();
517
- if (deleteContentFile && fullContentFile !== undefined) {
518
- await rm(fullContentFile, { force: true }).catch(() => {
519
- // Write succeeded; temp cleanup failure is only a warning.
1321
+ seenFiles.add(file);
1322
+ if (!legacy && record.message.type !== "add" && record.message.type !== "replace")
1323
+ continue;
1324
+ const fields = [record.message.subject, record.message.summary, ...record.message.keywords].map((f) => normalizeText(f));
1325
+ const matched = [];
1326
+ for (const kw of kws) {
1327
+ if (fields.some((field) => field.includes(kw)))
1328
+ matched.push(kw);
1329
+ }
1330
+ if (matched.length === 0)
1331
+ continue;
1332
+ hits.push({
1333
+ hash: record.hash,
1334
+ title: record.message.subject,
1335
+ date: safeIsoDate(record.committerTime),
1336
+ summary: record.message.summary,
1337
+ keywords: record.message.keywords,
1338
+ score: matched.length,
1339
+ matched_keywords: matched
520
1340
  });
521
1341
  }
522
- return { hash, file: finalFile };
1342
+ // plan 6.6: score desc, committer time desc, full hash desc (deterministic)
1343
+ hits.sort((a, b) => b.score - a.score || b.date.localeCompare(a.date) || (a.hash < b.hash ? 1 : a.hash > b.hash ? -1 : 0));
1344
+ return { hits, diagnostics };
523
1345
  }
524
- /** Delete a memory entry by commit hash. */
525
- async delete(commitHash) {
526
- if (!/^[0-9a-f]{7,}$/.test(commitHash))
527
- throw new Error("gitmemo: delete requires a commit hash");
528
- await ensureInit(this.memDir, this.config.gitTimeoutMs);
529
- const file = await this.entryFileFromCommit(commitHash);
530
- if (file === undefined)
531
- throw new Error("gitmemo: no entry file found in commit " + commitHash);
532
- try {
533
- await fsAccess(join(this.memDir, file));
534
- }
535
- catch {
536
- throw new Error("gitmemo: file already deleted: " + file);
537
- }
538
- await git(this.memDir, ["rm", "-q", "--", file], this.config.gitTimeoutMs);
539
- await git(this.memDir, ["commit", "-q", "-m", "delete: remove " + basename(file, ".md")], this.config.gitTimeoutMs);
540
- return file;
541
- }
542
- }
543
- /** Initialize the memory repo when missing. */
544
- export async function ensureInit(memDir, gitTimeoutMs = 60000) {
545
- let initialized = false;
546
- try {
547
- await fsAccess(join(memDir, ".git"));
548
- initialized = true;
1346
+ /**
1347
+ * Read one memory entry by create/replace commit hash. Historical entries
1348
+ * (deleted or replaced) stay readable for audit; `mem_read` accepts legacy
1349
+ * hashes too.
1350
+ */
1351
+ async read(commitHash) {
1352
+ if (!/^[0-9a-f]{7,64}$/.test(commitHash))
1353
+ throw new GitMemoError("gitmemo: read requires a commit hash");
1354
+ return withLock(this.root, this.config, "read", async () => {
1355
+ if (!(await pathExists(join(this.memDir, ".git")))) {
1356
+ await ensureInit(this.root, this.memDir, this.config.gitTimeoutMs);
1357
+ }
1358
+ const full = await resolveCommit(this.memDir, commitHash, this.config.gitTimeoutMs);
1359
+ const added = await addedFilesOf(this.memDir, full, this.config.gitTimeoutMs);
1360
+ if (added.length !== 1) {
1361
+ throw new GitMemoError("gitmemo: no entry file found in commit " + full);
1362
+ }
1363
+ const content = await git(this.memDir, ["show", full + ":" + added[0]], this.config.gitTimeoutMs);
1364
+ const body = await git(this.memDir, ["log", "-1", "--format=%B", full], this.config.gitTimeoutMs);
1365
+ const message = parseCommitMessage(body);
1366
+ const legacy = message.type === "unknown";
1367
+ return { hash: full, file: added[0], content, ...(legacy ? { legacy: true } : {}) };
1368
+ });
549
1369
  }
550
- catch {
551
- initialized = false;
1370
+ /**
1371
+ * Write a memory entry (plan 5.3). Entries are immutable: each write
1372
+ * creates a brand-new file `entries/<utc-ms>-<digest-prefix>-<slug>.md`
1373
+ * with one ADD commit. `legacy = true` relaxes the keyword minimum to 0
1374
+ * (compatibility adapter + migration reimports).
1375
+ */
1376
+ async write(input, legacy = false) {
1377
+ const adapted = legacy ? await adaptLegacyWriteInput(input) : input;
1378
+ const validated = validateWriteInput(adapted, legacy);
1379
+ const digest = computeDigest(validated.title, validated.summary, validated.keywords, validated.content);
1380
+ return withLock(this.root, this.config, "write", async () => {
1381
+ await ensureInit(this.root, this.memDir, this.config.gitTimeoutMs);
1382
+ await assertNewFormat(this.memDir, this.config.gitTimeoutMs, this.root);
1383
+ await recoverJournal(this.memDir, this.config.gitTimeoutMs);
1384
+ await assertClean(this.memDir, this.config.gitTimeoutMs);
1385
+ await assertOnMain(this.memDir, this.config.gitTimeoutMs);
1386
+ const ctx = await codeContext(this.root, this.config.gitTimeoutMs);
1387
+ const baseHead = (await git(this.memDir, ["rev-parse", "HEAD"], this.config.gitTimeoutMs)).trim();
1388
+ const { active, records, claimed } = await activeSnapshot(this.memDir, baseHead, this.config.gitTimeoutMs);
1389
+ void active;
1390
+ for (const entry of claimed.values()) {
1391
+ if (entry.digest === digest) {
1392
+ throw new GitMemoError("gitmemo: an active entry with the same digest already exists (identical title/summary/keywords/content) — " +
1393
+ "use mem_replace to update it, or mem_delete then write again");
1394
+ }
1395
+ }
1396
+ const relatedBranches = dedupe([...validated.related_branches, ctx.branch]);
1397
+ const markdown = buildEntryMarkdown({ ...validated, related_branches: relatedBranches }, { ...ctx, relatedBranches }, digest, new Date());
1398
+ const baseName = utcMs(new Date()) + "-" + digest.slice(0, 8) + "-" + slugifyTitle(validated.title) + ".md";
1399
+ const { file, journal } = await createJournaledEntryFile(this.memDir, baseName, markdown, (entryFile) => ({
1400
+ operationId: randomUUID(),
1401
+ operation: "write",
1402
+ phase: "prepared",
1403
+ baseHead,
1404
+ add: [entryFile],
1405
+ delete: [],
1406
+ digest,
1407
+ createdAt: new Date().toISOString()
1408
+ }));
1409
+ try {
1410
+ await git(this.memDir, ["add", "--", file], this.config.gitTimeoutMs);
1411
+ await assertStagedExactly(this.memDir, [{ status: "A", path: file }], this.config.gitTimeoutMs);
1412
+ const hash = await commitWithIdentity(this.memDir, buildAddMessage(validated.title, validated.summary, validated.keywords, digest, legacy), this.config.gitTimeoutMs);
1413
+ await rm(journalPath(this.memDir), { force: true });
1414
+ return { hash, file, ...(legacy ? { legacy: true } : {}) };
1415
+ }
1416
+ catch (error) {
1417
+ try {
1418
+ await rollbackJournal(this.memDir, journal, this.config.gitTimeoutMs);
1419
+ }
1420
+ catch (rollbackError) {
1421
+ throw new GitMemoError(String(error) + " — additionally, automatic rollback failed: " + String(rollbackError));
1422
+ }
1423
+ throw error;
1424
+ }
1425
+ });
552
1426
  }
553
- if (initialized)
554
- return;
555
- await mkdir(join(memDir, "entries"), { recursive: true });
556
- await git(memDir, ["init", "-q"], gitTimeoutMs);
557
- await writeFile(join(memDir, "entries", ".gitkeep"), "", "utf8");
558
- await git(memDir, ["add", "entries/.gitkeep"], gitTimeoutMs);
559
- await git(memDir, ["commit", "-q", "-m", "init: initialize memory repo"], gitTimeoutMs);
560
- }
561
- async function fsAccess(path) {
562
- await import("node:fs/promises").then((fs) => fs.access(path));
563
- }
564
- async function readFileSafe(path, errorPrefix) {
565
- try {
566
- return await import("node:fs/promises").then((fs) => fs.readFile(path, "utf8"));
1427
+ /**
1428
+ * Delete a memory entry (plan 5.4). Only withdraws the ACTIVE entry of a
1429
+ * create/replace hash; stale hashes are rejected.
1430
+ */
1431
+ async delete(input) {
1432
+ if (!/^[0-9a-f]{7,64}$/.test(input.commit_hash))
1433
+ throw new GitMemoError("gitmemo: delete requires a commit hash");
1434
+ if (typeof input.reason !== "string" || input.reason.trim().length === 0) {
1435
+ throw new GitMemoError("gitmemo: delete requires a reason");
1436
+ }
1437
+ const reason = input.reason.trim();
1438
+ if (reason.length > 4000)
1439
+ throw new GitMemoError("gitmemo: delete reason must be at most 4000 characters");
1440
+ assertNoControlChars(reason, "reason");
1441
+ assertNoTrailerInjection(reason, "reason");
1442
+ return withLock(this.root, this.config, "delete", async () => {
1443
+ await ensureInit(this.root, this.memDir, this.config.gitTimeoutMs);
1444
+ await assertNewFormat(this.memDir, this.config.gitTimeoutMs, this.root);
1445
+ await recoverJournal(this.memDir, this.config.gitTimeoutMs);
1446
+ await assertClean(this.memDir, this.config.gitTimeoutMs);
1447
+ await assertOnMain(this.memDir, this.config.gitTimeoutMs);
1448
+ const baseHead = (await git(this.memDir, ["rev-parse", "HEAD"], this.config.gitTimeoutMs)).trim();
1449
+ const full = await resolveCommit(this.memDir, input.commit_hash, this.config.gitTimeoutMs);
1450
+ const { file } = await activeEntryForHash(this.memDir, full, baseHead, this.config.gitTimeoutMs);
1451
+ const journal = {
1452
+ operationId: randomUUID(),
1453
+ operation: "delete",
1454
+ phase: "prepared",
1455
+ baseHead,
1456
+ add: [],
1457
+ delete: [file],
1458
+ createdAt: new Date().toISOString()
1459
+ };
1460
+ await writeJournal(this.memDir, journal);
1461
+ try {
1462
+ await git(this.memDir, ["rm", "-q", "--", file], this.config.gitTimeoutMs);
1463
+ await assertStagedExactly(this.memDir, [{ status: "D", path: file }], this.config.gitTimeoutMs);
1464
+ await commitWithIdentity(this.memDir, buildDeleteMessage(basename(file, ".md"), reason, full), this.config.gitTimeoutMs);
1465
+ await rm(journalPath(this.memDir), { force: true });
1466
+ return { file };
1467
+ }
1468
+ catch (error) {
1469
+ try {
1470
+ await rollbackJournal(this.memDir, journal, this.config.gitTimeoutMs);
1471
+ }
1472
+ catch (rollbackError) {
1473
+ throw new GitMemoError(String(error) + " — additionally, automatic rollback failed: " + String(rollbackError));
1474
+ }
1475
+ throw error;
1476
+ }
1477
+ });
567
1478
  }
568
- catch {
569
- throw new Error(errorPrefix + ": " + path);
1479
+ /**
1480
+ * Replace an active entry (plan 5.5): one transaction, one commit deletes
1481
+ * the old file and adds the new one (never delete-then-write). The active
1482
+ * digest dedupe excludes the entry being replaced, so replacing with the
1483
+ * same digest but updated non-digest metadata (related branches/paths) is
1484
+ * legal; colliding with any OTHER active entry is rejected.
1485
+ */
1486
+ async replace(input) {
1487
+ const validated = validateWriteInput(input);
1488
+ const digest = computeDigest(validated.title, validated.summary, validated.keywords, validated.content);
1489
+ return withLock(this.root, this.config, "replace", async () => {
1490
+ await ensureInit(this.root, this.memDir, this.config.gitTimeoutMs);
1491
+ await assertNewFormat(this.memDir, this.config.gitTimeoutMs, this.root);
1492
+ await recoverJournal(this.memDir, this.config.gitTimeoutMs);
1493
+ await assertClean(this.memDir, this.config.gitTimeoutMs);
1494
+ await assertOnMain(this.memDir, this.config.gitTimeoutMs);
1495
+ const ctx = await codeContext(this.root, this.config.gitTimeoutMs);
1496
+ const baseHead = (await git(this.memDir, ["rev-parse", "HEAD"], this.config.gitTimeoutMs)).trim();
1497
+ const full = await resolveCommit(this.memDir, input.commit_hash, this.config.gitTimeoutMs);
1498
+ const { file: oldFile } = await activeEntryForHash(this.memDir, full, baseHead, this.config.gitTimeoutMs);
1499
+ const { active, records, claimed } = await activeSnapshot(this.memDir, baseHead, this.config.gitTimeoutMs);
1500
+ void active;
1501
+ for (const [file, entry] of claimed) {
1502
+ if (file === oldFile)
1503
+ continue; // replacing entry excluded from dedupe
1504
+ if (entry.digest === digest) {
1505
+ throw new GitMemoError("gitmemo: another active entry uses the same digest (identical title/summary/keywords/content) — " +
1506
+ "mem_replace would create an exact duplicate");
1507
+ }
1508
+ }
1509
+ const relatedBranches = dedupe([...validated.related_branches, ctx.branch]);
1510
+ const markdown = buildEntryMarkdown({ ...validated, related_branches: relatedBranches }, { ...ctx, relatedBranches }, digest, new Date());
1511
+ const baseName = utcMs(new Date()) + "-" + digest.slice(0, 8) + "-" + slugifyTitle(validated.title) + ".md";
1512
+ const { file: newFile, journal } = await createJournaledEntryFile(this.memDir, baseName, markdown, (entryFile) => ({
1513
+ operationId: randomUUID(),
1514
+ operation: "replace",
1515
+ phase: "prepared",
1516
+ baseHead,
1517
+ add: [entryFile],
1518
+ delete: [oldFile],
1519
+ digest,
1520
+ createdAt: new Date().toISOString()
1521
+ }));
1522
+ try {
1523
+ await rm(join(this.memDir, oldFile), { force: true });
1524
+ await git(this.memDir, ["add", "-A", "--", oldFile, newFile], this.config.gitTimeoutMs);
1525
+ await assertStagedExactly(this.memDir, [
1526
+ { status: "D", path: oldFile },
1527
+ { status: "A", path: newFile }
1528
+ ], this.config.gitTimeoutMs);
1529
+ const hash = await commitWithIdentity(this.memDir, buildReplaceMessage(validated.title, validated.summary, validated.keywords, digest, full), this.config.gitTimeoutMs);
1530
+ await rm(journalPath(this.memDir), { force: true });
1531
+ return { hash, file: newFile };
1532
+ }
1533
+ catch (error) {
1534
+ try {
1535
+ await rollbackJournal(this.memDir, journal, this.config.gitTimeoutMs);
1536
+ }
1537
+ catch (rollbackError) {
1538
+ throw new GitMemoError(String(error) + " — additionally, automatic rollback failed: " + String(rollbackError));
1539
+ }
1540
+ throw error;
1541
+ }
1542
+ });
570
1543
  }
571
1544
  }
572
- export { isEntriesPath };