memarium 0.13.1

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/assets/scripts/merge-books.mjs +921 -0
  4. package/assets/workflows/memarium-aggregate.yml +66 -0
  5. package/dist/bin/memarium.js +6 -0
  6. package/dist/src/aggregated-store.js +95 -0
  7. package/dist/src/cli.js +175 -0
  8. package/dist/src/commands/cat.js +20 -0
  9. package/dist/src/commands/doctor.js +383 -0
  10. package/dist/src/commands/init-wizard.js +201 -0
  11. package/dist/src/commands/init.js +45 -0
  12. package/dist/src/commands/list.js +19 -0
  13. package/dist/src/commands/prune.js +108 -0
  14. package/dist/src/commands/resume/config-pathmap.js +38 -0
  15. package/dist/src/commands/resume/fuzzy-match.js +13 -0
  16. package/dist/src/commands/resume/list-sessions.js +54 -0
  17. package/dist/src/commands/resume/render-prompt.js +121 -0
  18. package/dist/src/commands/resume/resume.js +121 -0
  19. package/dist/src/commands/show.js +21 -0
  20. package/dist/src/commands/sync.js +279 -0
  21. package/dist/src/commands/upgrade.js +47 -0
  22. package/dist/src/commands/workflow.js +126 -0
  23. package/dist/src/config.js +98 -0
  24. package/dist/src/content-project-inference.js +185 -0
  25. package/dist/src/device.js +47 -0
  26. package/dist/src/digest/manifest.js +121 -0
  27. package/dist/src/digest/project-filter.js +32 -0
  28. package/dist/src/digest/session-signal.js +106 -0
  29. package/dist/src/digest/toc.js +127 -0
  30. package/dist/src/git-ops.js +359 -0
  31. package/dist/src/index-store.js +35 -0
  32. package/dist/src/migrate.js +72 -0
  33. package/dist/src/project-identity.js +139 -0
  34. package/dist/src/project-resolve.js +42 -0
  35. package/dist/src/prompts.js +87 -0
  36. package/dist/src/repo-data-dir.js +25 -0
  37. package/dist/src/slug.js +28 -0
  38. package/dist/src/sources/base.js +1 -0
  39. package/dist/src/sources/claude-code.js +294 -0
  40. package/dist/src/sources/vscode-copilot.js +400 -0
  41. package/dist/src/types.js +1 -0
  42. package/dist/src/writer.js +240 -0
  43. package/package.json +60 -0
@@ -0,0 +1,359 @@
1
+ import { simpleGit } from "simple-git";
2
+ import { existsSync, mkdirSync, readdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ /**
7
+ * Expand a leading `~` to the user's home dir. (Shell expansion doesn't
8
+ * happen for prompt input — `~/edge` would be taken literally otherwise.)
9
+ */
10
+ export function expandHome(p) {
11
+ if (p === "~")
12
+ return homedir();
13
+ if (p.startsWith("~/"))
14
+ return join(homedir(), p.slice(2));
15
+ return p;
16
+ }
17
+ /**
18
+ * Clone with stdio inherited so git's terminal-based credential prompts
19
+ * (HTTPS username/password, SSH passphrase) actually reach the user instead
20
+ * of hanging forever on a piped FD that no one writes to. Also sets
21
+ * GIT_TERMINAL_PROMPT=0 when stdin isn't a TTY so the call fails fast in CI
22
+ * instead of hanging.
23
+ */
24
+ function cloneWithProgress(repoUrl, dest) {
25
+ return new Promise((resolve, reject) => {
26
+ const env = { ...process.env };
27
+ if (!process.stdin.isTTY)
28
+ env.GIT_TERMINAL_PROMPT = "0";
29
+ const p = spawn("git", ["clone", "--progress", repoUrl, dest], {
30
+ stdio: "inherit",
31
+ env,
32
+ });
33
+ p.on("error", reject);
34
+ p.on("close", (code) => {
35
+ if (code === 0)
36
+ resolve();
37
+ else
38
+ reject(new Error(`git clone exited with code ${code}. If this is an HTTPS URL needing auth, prefer SSH (git@github.com:...) or store a PAT via 'git config credential.helper'.`));
39
+ });
40
+ });
41
+ }
42
+ /**
43
+ * Make sure `localPath` contains the repo at `repoUrl`.
44
+ *
45
+ * - If `localPath` doesn't exist OR exists-and-empty → `git clone repoUrl
46
+ * localPath`. Returns kind:"cloned".
47
+ * - If `localPath` exists and contains `.git` → reuse. Returns kind:"existing"
48
+ * with `existingRemote` so the wizard can warn on URL mismatch. Does NOT
49
+ * change the existing remote.
50
+ * - If `localPath` exists, non-empty, no `.git` → throw with a friendly
51
+ * message; refuse to scribble inside an unrelated dir.
52
+ */
53
+ export async function materializeRepoAtPath(localPath, repoUrl) {
54
+ const path = resolve(expandHome(localPath));
55
+ if (!existsSync(path)) {
56
+ mkdirSync(path, { recursive: true });
57
+ await cloneWithProgress(repoUrl, path);
58
+ return { kind: "cloned" };
59
+ }
60
+ const entries = readdirSync(path);
61
+ if (entries.length === 0) {
62
+ await cloneWithProgress(repoUrl, path);
63
+ return { kind: "cloned" };
64
+ }
65
+ if (entries.includes(".git")) {
66
+ const git = simpleGit(path);
67
+ let remote = "";
68
+ try {
69
+ remote = (await git.getConfig("remote.origin.url")).value ?? "";
70
+ }
71
+ catch { /* no remote configured */ }
72
+ return { kind: "existing", existingRemote: remote };
73
+ }
74
+ throw new Error(`${path} is not empty and is not a git repo. Pick another path or empty this one first.`);
75
+ }
76
+ /**
77
+ * Adopt a non-git directory full of memarium-plugin output into a fresh git
78
+ * repo bound to `repoUrl`. Use case: user installed memarium-plugin first,
79
+ * which wrote `book/` and `raw_sessions/` under `~/.memarium/session-repo/`
80
+ * but never `git init`'d. Then the user installs the npm CLI and runs
81
+ * `memarium init` — the dir is non-empty but not git, so the strict
82
+ * materialize path refuses.
83
+ *
84
+ * Safety:
85
+ * - All existing files are preserved on disk; this never deletes or moves.
86
+ * - The fetch is "info-only" — we don't checkout any remote ref, so user
87
+ * files can't be overwritten by `main`.
88
+ * - The user lands on a brand-new branch `<deviceBranch>` with their
89
+ * existing files as the first commit. Next `memarium sync` will append
90
+ * normally; first push creates the branch upstream.
91
+ *
92
+ * The caller (init wizard) is responsible for prompting the user before
93
+ * calling this — adopting silently would surprise users.
94
+ */
95
+ export async function adoptPluginDir(localPath, repoUrl, deviceBranch) {
96
+ const path = resolve(expandHome(localPath));
97
+ if (!existsSync(path)) {
98
+ throw new Error(`adoptPluginDir: ${path} does not exist`);
99
+ }
100
+ if (existsSync(join(path, ".git"))) {
101
+ throw new Error(`adoptPluginDir: ${path} already has a .git dir; use materializeRepoAtPath instead`);
102
+ }
103
+ const git = simpleGit(path);
104
+ await git.init();
105
+ await git.addRemote("origin", repoUrl);
106
+ try {
107
+ // Fetch refs so the user has a reference to `origin/main` for merge
108
+ // later, but don't checkout anything — the working tree is exactly
109
+ // what the user (well, the plugin) put there, and we want to preserve
110
+ // it as the first commit on a fresh device branch.
111
+ await git.fetch("origin");
112
+ }
113
+ catch {
114
+ // Offline or bad URL — adopt continues; user can fetch later.
115
+ }
116
+ await git.checkoutLocalBranch(deviceBranch);
117
+ return { kind: "adopted" };
118
+ }
119
+ export async function ensureRepo(localPath, repoUrl) {
120
+ await materializeRepoAtPath(localPath, repoUrl);
121
+ const git = simpleGit(localPath);
122
+ if (!existsSync(join(localPath, ".git"))) {
123
+ await git.init();
124
+ await git.addRemote("origin", repoUrl).catch(() => { });
125
+ }
126
+ return git;
127
+ }
128
+ /**
129
+ * Make sure the working tree is on `branch`.
130
+ * Priority:
131
+ * 1. Local branch exists → checkout.
132
+ * 2. Remote `origin/<branch>` exists → checkout tracking branch.
133
+ * 3. Neither → create as orphan (empty history, no parent).
134
+ */
135
+ export async function ensureDeviceBranch(git, branch) {
136
+ const local = await git.branchLocal();
137
+ if (local.all.includes(branch)) {
138
+ if (local.current !== branch)
139
+ await git.checkout(branch);
140
+ return;
141
+ }
142
+ let remoteHas = false;
143
+ try {
144
+ const remote = await git.branch(["-r"]);
145
+ remoteHas = remote.all.includes(`origin/${branch}`);
146
+ }
147
+ catch { /* no remotes fetched yet */ }
148
+ if (remoteHas) {
149
+ await git.checkout(["-b", branch, "--track", `origin/${branch}`]);
150
+ return;
151
+ }
152
+ await git.checkout(["--orphan", branch]);
153
+ await git.raw(["rm", "-rf", "--cached", "--ignore-unmatch", "."]);
154
+ }
155
+ const SECRET_BLOCK_RE = /GH013|push protection|secret-scanning/i;
156
+ function pushWithProgress(cwd, branch) {
157
+ return new Promise((resolve) => {
158
+ const errBuf = [];
159
+ let bufLen = 0;
160
+ const p = spawn("git", ["push", "--progress", "--set-upstream", "origin", branch], { cwd, stdio: ["ignore", "inherit", "pipe"] });
161
+ p.stderr.on("data", (chunk) => {
162
+ // Tee to both terminal (so live progress still shows) AND buffer (so we
163
+ // can scan for GitHub push-protection markers after exit).
164
+ process.stderr.write(chunk);
165
+ const s = chunk.toString();
166
+ errBuf.push(s);
167
+ bufLen += s.length;
168
+ if (bufLen > 8192) {
169
+ const drop = errBuf.shift();
170
+ if (drop)
171
+ bufLen -= drop.length;
172
+ }
173
+ });
174
+ p.on("error", () => resolve({ ok: false, secretBlocked: false, stderrTail: errBuf.join("") }));
175
+ p.on("close", (code) => {
176
+ const tail = errBuf.join("");
177
+ resolve({
178
+ ok: code === 0,
179
+ secretBlocked: code !== 0 && SECRET_BLOCK_RE.test(tail),
180
+ stderrTail: tail.slice(-4096),
181
+ });
182
+ });
183
+ });
184
+ }
185
+ export async function commitAndPush(git, message, paths, branch, onProgress) {
186
+ if (paths.length === 0)
187
+ return { committed: false, pushed: false };
188
+ onProgress?.(`git add (${paths.length} paths)...`);
189
+ await git.add(paths);
190
+ const status = await git.status();
191
+ if (status.staged.length === 0)
192
+ return { committed: false, pushed: false };
193
+ onProgress?.(`git commit (${status.staged.length} staged)...`);
194
+ await git.commit(message);
195
+ onProgress?.(`git push origin ${branch} (live progress below):`);
196
+ const cwd = await git.revparse(["--show-toplevel"]).then((s) => s.trim());
197
+ const r = await pushWithProgress(cwd, branch);
198
+ return { committed: true, pushed: r.ok, pushResult: r };
199
+ }
200
+ /**
201
+ * Run `writeFiles()` on a temporary side-checkout of `main` (or create main
202
+ * as an orphan if it doesn't exist remotely), commit + push, then restore
203
+ * the caller's current branch. Used by `memarium workflow init` so CI
204
+ * workflow files land on main (where GitHub Actions actually reads them)
205
+ * without disturbing the user's device branch or working tree.
206
+ *
207
+ * Strategy: use `git worktree add` to materialize main into a temp dir.
208
+ * This avoids switching the user's primary working tree at all — their
209
+ * uncommitted changes, current HEAD, and checked-out branch are untouched.
210
+ *
211
+ * The provided `writeFiles(worktreePath)` callback receives the absolute
212
+ * path of the temp worktree and is responsible for writing the files there.
213
+ *
214
+ * On success returns `{ committed: true, pushed: true }`. On no-op (files
215
+ * already up to date) returns `{ committed: false, pushed: false }`. The
216
+ * temp worktree is always cleaned up.
217
+ */
218
+ export async function commitToMainViaWorktree(git, repoPath, writeFiles, commitMessage, onProgress) {
219
+ const { mkdtempSync, rmSync } = await import("node:fs");
220
+ const { tmpdir } = await import("node:os");
221
+ const { join } = await import("node:path");
222
+ // Fetch so we know whether origin/main exists.
223
+ try {
224
+ await git.fetch();
225
+ }
226
+ catch { /* offline */ }
227
+ const remoteMainExists = (await git.branch(["-r"]))
228
+ .all.some((b) => b === "origin/main");
229
+ const worktreePath = mkdtempSync(join(tmpdir(), "memarium-main-"));
230
+ // Use a unique temp branch name (not "main") to avoid the worktree conflict
231
+ // git raises if the user's primary working tree is currently on `main`:
232
+ // fatal: 'main' is already used by worktree at <repoPath>
233
+ // We push to origin/main via `HEAD:main` refspec instead of relying on a
234
+ // locally-named branch.
235
+ const tempBranch = `memarium-tmp-main-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
236
+ // simple-git doesn't expose worktree directly; use raw.
237
+ try {
238
+ if (remoteMainExists) {
239
+ onProgress?.(`creating temp worktree on main (via ${tempBranch})...`);
240
+ // -b <tempBranch> at origin/main: create the new branch in the worktree.
241
+ // This new branch ref lives in the primary repo (not the worktree only),
242
+ // so we delete it in finally.
243
+ await git.raw(["worktree", "add", "-b", tempBranch, worktreePath, "origin/main"]);
244
+ }
245
+ else {
246
+ // Brand-new repo with no main yet. Create an orphan branch (not named main).
247
+ onProgress?.(`creating orphan temp branch ${tempBranch} (no remote main yet)...`);
248
+ await git.raw(["worktree", "add", "--detach", worktreePath]);
249
+ const { simpleGit } = await import("simple-git");
250
+ const wgit = simpleGit(worktreePath);
251
+ await wgit.raw(["checkout", "--orphan", tempBranch]);
252
+ // Clear any inherited files from the parent ref.
253
+ await wgit.raw(["rm", "-rf", "."]).catch(() => undefined);
254
+ }
255
+ onProgress?.(`writing files into temp worktree...`);
256
+ const relPaths = await writeFiles(worktreePath);
257
+ const { simpleGit } = await import("simple-git");
258
+ const wgit = simpleGit(worktreePath);
259
+ await wgit.add(relPaths);
260
+ const status = await wgit.status();
261
+ if (status.staged.length === 0) {
262
+ onProgress?.(`nothing changed on main`);
263
+ return { committed: false, pushed: false };
264
+ }
265
+ onProgress?.(`git commit on ${tempBranch} (${status.staged.length} staged)...`);
266
+ await wgit.commit(commitMessage);
267
+ onProgress?.(`git push origin HEAD:main...`);
268
+ // Push the temp branch's HEAD to the remote `main` ref. This works
269
+ // whether or not we have a local `main` branch — we explicitly use
270
+ // a refspec.
271
+ try {
272
+ await wgit.raw(["push", "origin", `HEAD:main`]);
273
+ }
274
+ catch (err) {
275
+ throw new Error(`push origin HEAD:main failed: ${err.message}`);
276
+ }
277
+ return { committed: true, pushed: true };
278
+ }
279
+ finally {
280
+ // Always clean up the worktree AND the temp branch, even on failure.
281
+ // `git worktree remove` tolerates a missing dir; we follow it with a
282
+ // filesystem rm just in case. Then delete the temp branch ref so it
283
+ // doesn't leak into the user's repo.
284
+ try {
285
+ await git.raw(["worktree", "remove", "--force", worktreePath]);
286
+ }
287
+ catch { /* ignore */ }
288
+ try {
289
+ rmSync(worktreePath, { recursive: true, force: true });
290
+ }
291
+ catch { /* ignore */ }
292
+ try {
293
+ await git.branch(["-D", tempBranch]);
294
+ }
295
+ catch { /* ignore — never created */ }
296
+ }
297
+ }
298
+ /**
299
+ * Bring the local device branch in sync with origin before we try to push,
300
+ * so the GitHub Action's auto-commits don't cause non-fast-forward push
301
+ * failures on the next `memarium sync` / `digest` run.
302
+ *
303
+ * Sequence:
304
+ * 1. fetch origin
305
+ * 2. if no remote tracking ref exists → skip (fresh branch, nothing to pull)
306
+ * 3. try `pull --rebase --autostash` — handles both fast-forward and
307
+ * diverged-history cases, and auto-stashes any unstaged digest output
308
+ * sitting in the working tree
309
+ * 4. on rebase conflict: abort cleanly and throw a friendly error pointing
310
+ * the user at the repo path. We deliberately do NOT try to auto-resolve
311
+ * (digest output + rebased remote changes are too risky to merge blindly)
312
+ *
313
+ * Caller is expected to handle the thrown error — surface the path to the
314
+ * user and skip push for this run.
315
+ */
316
+ export async function fastForwardBranch(git, branch, onProgress) {
317
+ let hasRemote = false;
318
+ try {
319
+ const remotes = await git.getRemotes(false);
320
+ hasRemote = remotes.some((r) => r.name === "origin");
321
+ }
322
+ catch { /* ignore */ }
323
+ if (!hasRemote)
324
+ return { pulled: false, reason: "no-remote" };
325
+ onProgress?.(`git fetch origin...`);
326
+ try {
327
+ await git.fetch("origin", branch);
328
+ }
329
+ catch { /* upstream branch may not exist yet */ }
330
+ // Check whether origin/<branch> ref exists locally after the fetch.
331
+ let hasUpstream = false;
332
+ try {
333
+ const refs = await git.branch(["-r"]);
334
+ hasUpstream = refs.all.includes(`origin/${branch}`);
335
+ }
336
+ catch { /* ignore */ }
337
+ if (!hasUpstream)
338
+ return { pulled: false, reason: "no-tracking" };
339
+ onProgress?.(`git pull --rebase --autostash origin ${branch}...`);
340
+ try {
341
+ await git.raw(["pull", "--rebase", "--autostash", "origin", branch]);
342
+ return { pulled: true };
343
+ }
344
+ catch (err) {
345
+ // Rebase conflict (or autostash-pop conflict). Clean up so we leave the
346
+ // working tree in a sane state, then throw with actionable guidance.
347
+ try {
348
+ await git.raw(["rebase", "--abort"]);
349
+ }
350
+ catch { /* not in rebase */ }
351
+ try {
352
+ await git.raw(["stash", "pop"]);
353
+ }
354
+ catch { /* nothing to pop */ }
355
+ const msg = err instanceof Error ? err.message : String(err);
356
+ throw new Error(`Could not fast-forward / rebase '${branch}' onto origin/${branch}. ` +
357
+ `Resolve manually in the repo, then re-run. Original error:\n${msg}`);
358
+ }
359
+ }
@@ -0,0 +1,35 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { INDEX_REL, dataDirAbs } from "./repo-data-dir.js";
4
+ export function loadIndex(repoRoot) {
5
+ const p = join(repoRoot, INDEX_REL);
6
+ if (!existsSync(p))
7
+ return { version: 1, entries: {} };
8
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
9
+ if (parsed.version !== 1)
10
+ throw new Error(`unsupported index version: ${parsed.version}`);
11
+ return parsed;
12
+ }
13
+ export function saveIndex(repoRoot, idx) {
14
+ const p = join(repoRoot, INDEX_REL);
15
+ mkdirSync(dataDirAbs(repoRoot), { recursive: true });
16
+ writeFileSync(p, JSON.stringify(idx, null, 2) + "\n");
17
+ }
18
+ export function keyFor(tool, sessionId) {
19
+ return `${tool}:${sessionId}`;
20
+ }
21
+ export function upsertEntry(idx, entry) {
22
+ idx.entries[keyFor(entry.tool, entry.sessionId)] = entry;
23
+ }
24
+ export function hasUnchanged(idx, tool, sessionId, mtimeMs, sha256, repoRoot) {
25
+ const e = idx.entries[keyFor(tool, sessionId)];
26
+ if (!e || e.sourceMtimeMs !== mtimeMs || e.sourceSha256 !== sha256)
27
+ return false;
28
+ // The index can survive branch switches that leave raw_sessions/ incomplete
29
+ // — if the indexed file is missing from the working tree, treat it as stale
30
+ // so the entry gets re-extracted. Otherwise the new branch would never
31
+ // collect the file it's missing.
32
+ if (!existsSync(join(repoRoot, e.relativePath)))
33
+ return false;
34
+ return true;
35
+ }
@@ -0,0 +1,72 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { simpleGit } from "simple-git";
4
+ import { LEGACY_REPO_DATA_DIRS, REPO_DATA_DIR } from "./repo-data-dir.js";
5
+ /**
6
+ * One-shot migration for repos created before per-device-branches existed.
7
+ *
8
+ * If the local repo has a `main` branch but no `<device>` branch, rename
9
+ * main → <device> (preserving history) so the device branch becomes the
10
+ * new write target. `main` is left unborn on purpose — it will be re-created
11
+ * later (manually or by a future merge-to-main command) as the aggregate view.
12
+ *
13
+ * No-op when:
14
+ * - the device branch already exists (migration was already done, or a
15
+ * fresh clone is already on the right branch)
16
+ * - there is no `main` branch to rename
17
+ */
18
+ export async function migrateLegacyMainToDevice(repoPath, deviceBranch) {
19
+ const git = simpleGit(repoPath);
20
+ const local = await git.branchLocal();
21
+ if (local.all.includes(deviceBranch))
22
+ return { migrated: false };
23
+ if (!local.all.includes("main"))
24
+ return { migrated: false };
25
+ if (local.current !== "main")
26
+ await git.checkout("main");
27
+ await git.branch(["-m", "main", deviceBranch]);
28
+ return { migrated: true };
29
+ }
30
+ /**
31
+ * One-shot migration: rename an in-repo legacy data dir → `.memarium/`.
32
+ *
33
+ * The project was renamed memvc → vibebook → memarium. Any sync/digest run
34
+ * that finds a legacy dir (`.vibebook/`, else `.memvc/`) and no `.memarium/`
35
+ * moves it via `git mv` (so history follows) and stages it for the next
36
+ * commit. Returns the legacy dir it migrated from (for logging).
37
+ *
38
+ * No-op when:
39
+ * - the repo has no legacy dir (fresh repo, or migration already done)
40
+ * - the repo already has `.memarium/` (migration already done)
41
+ * - the repo isn't a git repo (we still do a non-git rename so non-pushing
42
+ * local-only mode works)
43
+ */
44
+ export async function migrateLegacyDataDir(repoPath) {
45
+ const target = join(repoPath, REPO_DATA_DIR);
46
+ if (existsSync(target))
47
+ return { migrated: false, viaGit: false };
48
+ const from = LEGACY_REPO_DATA_DIRS.find((d) => existsSync(join(repoPath, d)));
49
+ if (!from)
50
+ return { migrated: false, viaGit: false };
51
+ const isGitRepo = existsSync(join(repoPath, ".git"));
52
+ if (isGitRepo) {
53
+ const git = simpleGit(repoPath);
54
+ // git mv preserves history. Use the directory form; git stages every file
55
+ // under it. The result is staged but not committed — runSync's commit
56
+ // bundles it with the rest of the sync's paths.
57
+ await git.raw(["mv", from, REPO_DATA_DIR]);
58
+ return { migrated: true, viaGit: true, from };
59
+ }
60
+ // Non-git fallback: plain rename. Used by local-only mode + tests that
61
+ // never init a git repo.
62
+ const { renameSync } = await import("node:fs");
63
+ renameSync(join(repoPath, from), target);
64
+ return { migrated: true, viaGit: false, from };
65
+ }
66
+ /** Returns the list of repo-rooted paths a successful data-dir migration produces, suitable for `git add`. */
67
+ export function migratedDataDirPaths(repoPath) {
68
+ const dir = join(repoPath, REPO_DATA_DIR);
69
+ if (!existsSync(dir))
70
+ return [];
71
+ return readdirSync(dir).map((f) => `${REPO_DATA_DIR}/${f}`);
72
+ }
@@ -0,0 +1,139 @@
1
+ import { simpleGit } from "simple-git";
2
+ import { spawnSync } from "node:child_process";
3
+ import { projectSlugFromPath } from "./slug.js";
4
+ /**
5
+ * Canonical, path-INDEPENDENT project identity (P0a).
6
+ *
7
+ * Project memory must aggregate + recall correctly across devices, but the
8
+ * same repo can live at a different filesystem path per machine
9
+ * (`~/edge/memvc` vs `~/work/memvc` vs `~/projects/memvc`). The legacy slug =
10
+ * `projectSlugFromPath` (last two path segments) splits those into different
11
+ * projects, so raw_sessions folders / memory ids / book all diverge and never
12
+ * aggregate. Coding projects are git-maintained, and a repo's `origin` remote
13
+ * is identical on every clone — so the remote is the stable identity.
14
+ *
15
+ * `canonicalProjectId` collapses every remote URL form to one `host/path` id;
16
+ * `resolveProjectId` turns a cwd into a filesystem/index-safe slug, preferring
17
+ * the remote and falling back to the path slug when there's no git remote.
18
+ */
19
+ /**
20
+ * Normalize any git remote URL to a canonical `host/path` id, collapsing SSH
21
+ * SCP (`git@host:org/repo.git`), `https://`, `ssh://`, `git://`, and
22
+ * credentialed forms to the same value. Host is lowercased (clones don't drift
23
+ * in host case); path case is preserved (org/repo can be case-significant).
24
+ * Returns null for unparseable / local (no-host) remotes → caller falls back.
25
+ *
26
+ * git@github.com:june9593/memvc.git -> github.com/june9593/memvc
27
+ * https://github.com/june9593/memvc(.git)(/) -> github.com/june9593/memvc
28
+ * https://x-token:abc@github.com/o/r.git -> github.com/o/r
29
+ * ssh://git@gitlab.corp:22/grp/sub/proj.git -> gitlab.corp/grp/sub/proj
30
+ */
31
+ export function canonicalProjectId(remoteUrl) {
32
+ const s0 = (remoteUrl ?? "").trim();
33
+ if (!s0)
34
+ return null;
35
+ let host;
36
+ let path;
37
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(s0)) {
38
+ // scheme://[user[:pass]@]host[:port]/path
39
+ let rest = s0.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "");
40
+ rest = rest.replace(/^[^@/]+@/, ""); // strip credentials
41
+ const slash = rest.indexOf("/");
42
+ if (slash < 0)
43
+ return null;
44
+ host = rest.slice(0, slash);
45
+ path = rest.slice(slash + 1);
46
+ }
47
+ else {
48
+ // SCP-like: [user@]host:path (reject host:port/... which isn't SCP)
49
+ const m = s0.match(/^(?:[^@/]+@)?([^/:]+):(.+)$/);
50
+ if (!m)
51
+ return null; // local path / junk → fallback
52
+ if (/^\d+$/.test(m[2].split("/")[0]))
53
+ return null; // host:port/path, not SCP
54
+ host = m[1];
55
+ path = m[2];
56
+ }
57
+ host = host.replace(/:\d+$/, "").toLowerCase(); // strip port, lowercase host
58
+ path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
59
+ if (!host || !path)
60
+ return null;
61
+ return `${host}/${path}`;
62
+ }
63
+ /** Filesystem/index-safe slug from a remote URL, or null if not parseable.
64
+ * `github.com/june9593/memvc` -> `github.com-june9593-memvc`. */
65
+ export function projectSlugFromRemote(remoteUrl) {
66
+ const id = canonicalProjectId(remoteUrl);
67
+ if (!id)
68
+ return null;
69
+ return id.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
70
+ }
71
+ /**
72
+ * Resolve a cwd / repo-root to its stable project slug. Prefers the git
73
+ * `origin` remote (path-independent → aggregates across devices); falls back to
74
+ * the legacy `projectSlugFromPath` when there's no remote / not a git repo
75
+ * (a non-git project has no cross-device identity anyway). `getRemote` is
76
+ * injectable so the pure logic is testable without a real repo.
77
+ */
78
+ export async function resolveProjectId(cwdOrRoot, getRemote = defaultGetRemote) {
79
+ let remote = null;
80
+ try {
81
+ remote = await getRemote(cwdOrRoot);
82
+ }
83
+ catch {
84
+ remote = null;
85
+ }
86
+ const slug = projectSlugFromRemote(remote);
87
+ if (slug)
88
+ return { slug, source: "remote", canonical: canonicalProjectId(remote) };
89
+ return { slug: projectSlugFromPath(cwdOrRoot), source: "path", canonical: null };
90
+ }
91
+ /** Read `remote.origin.url` at `dir` (or any ancestor git repo). Null if none. */
92
+ async function defaultGetRemote(dir) {
93
+ try {
94
+ const v = (await simpleGit(dir).getConfig("remote.origin.url")).value;
95
+ return v && v.trim() ? v.trim() : null;
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ /** Synchronous remote read via `git config` — for sync call sites (the read
102
+ * chokepoint) where an async ripple isn't worth it. A short-timeout
103
+ * `spawnSync` shelling out to a CLI, the same pattern doctor.ts uses for its
104
+ * `--version` probes. */
105
+ function defaultGetRemoteSync(dir) {
106
+ const r = spawnSync("git", ["-C", dir, "config", "--get", "remote.origin.url"], {
107
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000,
108
+ });
109
+ if (r.status !== 0)
110
+ return null;
111
+ const v = (r.stdout ?? "").trim();
112
+ return v ? v : null;
113
+ }
114
+ /** Sync sibling of `resolveProjectId` — same remote-first, path-fallback logic. */
115
+ export function resolveProjectIdSync(cwdOrRoot, getRemote = defaultGetRemoteSync) {
116
+ let remote = null;
117
+ try {
118
+ remote = getRemote(cwdOrRoot);
119
+ }
120
+ catch {
121
+ remote = null;
122
+ }
123
+ const slug = projectSlugFromRemote(remote);
124
+ if (slug)
125
+ return { slug, source: "remote", canonical: canonicalProjectId(remote) };
126
+ return { slug: projectSlugFromPath(cwdOrRoot), source: "path", canonical: null };
127
+ }
128
+ const _slugCache = new Map();
129
+ /** `resolveProjectIdSync(dir).slug`, memoized per dir for one process. A sync
130
+ * run resolves the same project roots repeatedly (per session / per tool-use
131
+ * path); without this each call re-spawns `git config`. */
132
+ export function cachedProjectSlug(dir) {
133
+ const hit = _slugCache.get(dir);
134
+ if (hit !== undefined)
135
+ return hit;
136
+ const slug = resolveProjectIdSync(dir).slug;
137
+ _slugCache.set(dir, slug);
138
+ return slug;
139
+ }
@@ -0,0 +1,42 @@
1
+ import { loadIndex } from "./index-store.js";
2
+ import { projectSlugFromPath } from "./slug.js";
3
+ import { resolveProjectIdSync } from "./project-identity.js";
4
+ /**
5
+ * Reverse-lookup a project slug from an absolute cwd.
6
+ *
7
+ * 1. Resolve the stable project identity (git remote → slug; path slug when
8
+ * no remote — `resolveProjectIdSync`) and check the index has a session
9
+ * for it. The sync adapters compute the project the same way, so this is
10
+ * authoritative.
11
+ * 2. Also try the legacy path slug, so a repo whose data was written before
12
+ * the remote-identity switch (or a non-git project) still resolves.
13
+ * 3. Fall back to scanning index entries for one whose `projectRaw` === cwd.
14
+ *
15
+ * Returns null if none match — caller decides how to error.
16
+ */
17
+ export function resolveProjectFromCwd(cwd, repoPath) {
18
+ const indexFile = loadIndex(repoPath);
19
+ return resolveProjectFromCwdWithIndex(cwd, indexFile.entries);
20
+ }
21
+ /** Variant for when the caller already has the index loaded (avoid double-read). */
22
+ export function resolveProjectFromCwdWithIndex(cwd, entries) {
23
+ // Prefer the stable (remote-based) slug; fall back to the legacy path slug so
24
+ // un-migrated / non-git projects still resolve during the transition.
25
+ const candidates = [];
26
+ const remoteSlug = resolveProjectIdSync(cwd).slug;
27
+ candidates.push(remoteSlug);
28
+ const pathSlug = projectSlugFromPath(cwd);
29
+ if (pathSlug !== remoteSlug)
30
+ candidates.push(pathSlug);
31
+ for (const cand of candidates) {
32
+ for (const e of Object.values(entries)) {
33
+ if (e.project === cand)
34
+ return cand;
35
+ }
36
+ }
37
+ for (const e of Object.values(entries)) {
38
+ if (e.projectRaw === cwd)
39
+ return e.project;
40
+ }
41
+ return null;
42
+ }