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,279 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import chalk from "chalk";
4
+ import { ClaudeCodeAdapter } from "../sources/claude-code.js";
5
+ import { VSCodeCopilotAdapter } from "../sources/vscode-copilot.js";
6
+ import { loadIndex, saveIndex, hasUnchanged, upsertEntry } from "../index-store.js";
7
+ import { writeSession } from "../writer.js";
8
+ import { readConfig, writeConfig } from "../config.js";
9
+ import { deviceBranchFromHostname } from "../device.js";
10
+ import { ensureRepo, commitAndPush, ensureDeviceBranch, fastForwardBranch } from "../git-ops.js";
11
+ import { migrateLegacyMainToDevice, migrateLegacyDataDir, migratedDataDirPaths } from "../migrate.js";
12
+ import { INDEX_REL } from "../repo-data-dir.js";
13
+ export async function runSync(opts) {
14
+ // One-shot migration: rename the newest legacy data dir (`.vibebook/`, else
15
+ // `.memvc/`) → `.memarium/` if present. Done before loadIndex so the read
16
+ // picks up the file at its new location.
17
+ const dataDirMig = await migrateLegacyDataDir(opts.repoPath);
18
+ if (dataDirMig.migrated) {
19
+ console.log(chalk.cyan(`Migrating: renamed legacy ${dataDirMig.from}/ → .memarium/ ${dataDirMig.viaGit ? "(via git mv; staged for next commit)" : "(non-git mode)"}`));
20
+ }
21
+ const adapters = [
22
+ new ClaudeCodeAdapter(opts.claudeRoot),
23
+ new VSCodeCopilotAdapter(opts.vscodeRoot),
24
+ ];
25
+ const idx = loadIndex(opts.repoPath);
26
+ let newCount = 0, skippedCount = 0;
27
+ const pathsWritten = [];
28
+ for (const adapter of adapters) {
29
+ for await (const d of adapter.discover()) {
30
+ let s;
31
+ try {
32
+ s = await d.load();
33
+ }
34
+ catch (err) {
35
+ console.log(chalk.yellow(`! skip ${d.sourcePath}: ${err.message}`));
36
+ continue;
37
+ }
38
+ if (hasUnchanged(idx, s.tool, s.sessionId, d.sourceMtimeMs, d.sourceSha256, opts.repoPath)) {
39
+ skippedCount++;
40
+ continue;
41
+ }
42
+ // Skip empty-shell sessions — VS Code creates a chatSessions/<id>.jsonl
43
+ // for every chat tab the user opens (even ones they immediately
44
+ // close), so we'd otherwise write a `1970-01-01/untitled__<id>.md`
45
+ // for each. Audit on 2026-05-23 found 142 such shells in Yue's repo.
46
+ if (s.messages.length === 0) {
47
+ skippedCount++;
48
+ continue;
49
+ }
50
+ const rel = writeSession(opts.repoPath, s, { includeReasoning: opts.includeReasoning });
51
+ pathsWritten.push(rel.md);
52
+ const entry = {
53
+ sessionId: s.sessionId,
54
+ shortId: s.shortId,
55
+ tool: s.tool,
56
+ project: s.project,
57
+ projectRaw: s.projectRaw,
58
+ startedAt: s.startedAt,
59
+ endedAt: s.endedAt,
60
+ nameSlug: s.nameSlug,
61
+ displayName: s.displayName,
62
+ relativePath: rel.md,
63
+ sourcePath: s.sourcePath,
64
+ sourceMtimeMs: d.sourceMtimeMs,
65
+ sourceSha256: d.sourceSha256,
66
+ };
67
+ upsertEntry(idx, entry);
68
+ newCount++;
69
+ console.log(chalk.green(`+ ${s.tool}/${s.project}/${s.nameSlug} (${s.shortId})`));
70
+ }
71
+ }
72
+ // 0.8.4: prune orphan index entries — entries whose source jsonl no
73
+ // longer exists on disk AND whose rendered .md is also gone. Pre-0.8.4,
74
+ // a session deleted from ~/.claude/projects/ (or its workspaceStorage
75
+ // counterpart) left its index entry forever; CI aggregate then logged
76
+ // "missing despite spool index; skipping" for each, eating noise but
77
+ // also losing the chance to clean up stale aggregated state on main.
78
+ let prunedIndex = 0;
79
+ for (const [key, e] of Object.entries(idx.entries)) {
80
+ if (!existsSync(e.sourcePath) && !existsSync(join(opts.repoPath, e.relativePath))) {
81
+ delete idx.entries[key];
82
+ prunedIndex++;
83
+ }
84
+ }
85
+ if (prunedIndex > 0) {
86
+ console.log(chalk.gray(` pruned ${prunedIndex} index entr${prunedIndex === 1 ? "y" : "ies"} (source jsonl gone)`));
87
+ }
88
+ saveIndex(opts.repoPath, idx);
89
+ let committed = false, pushed = false;
90
+ if (opts.push && opts.repoUrl && opts.deviceBranch) {
91
+ console.log(chalk.gray(`\nOpening repo at ${opts.repoPath}...`));
92
+ const git = await ensureRepo(opts.repoPath, opts.repoUrl);
93
+ const mig = await migrateLegacyMainToDevice(opts.repoPath, opts.deviceBranch);
94
+ if (mig.migrated) {
95
+ console.log(chalk.cyan(`Migrated legacy 'main' branch to '${opts.deviceBranch}'. 'main' is now unborn locally.`));
96
+ }
97
+ try {
98
+ await git.fetch();
99
+ }
100
+ catch { /* remote may be empty / offline */ }
101
+ console.log(chalk.gray(`Ensuring branch '${opts.deviceBranch}' is checked out...`));
102
+ await ensureDeviceBranch(git, opts.deviceBranch);
103
+ // Pull --rebase --autostash before committing, so CI's auto-commits on
104
+ // origin/<device> don't cause non-fast-forward push failures.
105
+ try {
106
+ const ff = await fastForwardBranch(git, opts.deviceBranch, (s) => console.log(chalk.gray(` ${s}`)));
107
+ if (!ff.pulled && ff.reason === "no-tracking") {
108
+ console.log(chalk.gray(` no remote ${opts.deviceBranch} yet — first push will create it`));
109
+ }
110
+ }
111
+ catch (err) {
112
+ const msg = err instanceof Error ? err.message : String(err);
113
+ console.log(chalk.red(`! could not sync local branch with origin: ${msg}`));
114
+ console.log(chalk.cyan(` Skipping push. Resolve in ${opts.repoPath} and re-run \`memarium sync\`.`));
115
+ return { newCount, skippedCount, pathsWritten, committed: false, pushed: false };
116
+ }
117
+ const all = [...pathsWritten, INDEX_REL];
118
+ if (dataDirMig.migrated && dataDirMig.viaGit) {
119
+ for (const p of migratedDataDirPaths(opts.repoPath))
120
+ all.push(p);
121
+ }
122
+ // P1 (0.8.1): make sure this device branch carries a copy of
123
+ // `.github/workflows/memarium-aggregate.yml`. GitHub Actions on `push`
124
+ // events reads the workflow definition from THE PUSHED BRANCH (not
125
+ // from the default branch), so without this the CI never triggers on
126
+ // freshly-init'd devices and main never gets aggregated. The file
127
+ // itself is identical to what `memarium workflow init` planted on
128
+ // main; we just clone-in the latest copy on every sync so device
129
+ // branches stay in sync with workflow updates too.
130
+ const workflowAdded = await ensureWorkflowFileFromMain(opts.repoPath, git);
131
+ if (workflowAdded)
132
+ all.push(".github/workflows/memarium-aggregate.yml");
133
+ // memory/ (typed-memory layer, 0.8.6): the plugin's `memory-write` writes
134
+ // memory/<type>/... + .memarium/index.memory.json into the working tree but
135
+ // doesn't push. Stage them here so they reach the device branch and CI's
136
+ // merge-books can aggregate memory cross-device. commitAndPush() no-ops if
137
+ // nothing actually changed.
138
+ if (existsSync(join(opts.repoPath, "memory")))
139
+ all.push("memory");
140
+ if (existsSync(join(opts.repoPath, ".memarium", "index.memory.json"))) {
141
+ all.push(".memarium/index.memory.json");
142
+ }
143
+ // entity index: memory/entities/ is already covered by staging
144
+ // "memory/" above; we just need to also push the entity index so that
145
+ // merge-books' anyEntityIndexSeen check fires on CI.
146
+ if (existsSync(join(opts.repoPath, ".memarium", "index.entity.json"))) {
147
+ all.push(".memarium/index.entity.json");
148
+ }
149
+ // qa index: memory/qa/ is already covered by staging "memory" above; we just
150
+ // need to also push the qa index so cross-device merge-books can union it.
151
+ if (existsSync(join(opts.repoPath, ".memarium", "index.qa.json"))) {
152
+ all.push(".memarium/index.qa.json");
153
+ }
154
+ console.log(chalk.gray(`Staging ${all.length} paths and committing...`));
155
+ const commitMsg = newCount > 0
156
+ ? `memarium sync: +${newCount} sessions${dataDirMig.migrated ? ` (+ rename ${dataDirMig.from}/→.memarium/)` : ""}`
157
+ : (dataDirMig.migrated ? `memarium: rename ${dataDirMig.from}/ → .memarium/` :
158
+ `memarium sync: +${newCount} sessions`);
159
+ const r = await commitAndPush(git, commitMsg, all, opts.deviceBranch, (stage) => console.log(chalk.gray(` ${stage}`)));
160
+ committed = r.committed;
161
+ pushed = r.pushed;
162
+ if (committed && !pushed) {
163
+ if (r.pushResult?.secretBlocked) {
164
+ console.log(chalk.red("\n Push blocked by GitHub secret-scanning (GH013). Your raw_sessions contain something that looks like a real secret (token, API key) — typically because past AI conversations included one verbatim."));
165
+ console.log(chalk.cyan(" Fix: remove the secret from the offending raw_sessions md (or rotate it and use GitHub's unblock URL), then re-sync. A private repo + push protection is the intended guard here."));
166
+ }
167
+ else {
168
+ console.log(chalk.yellow("Commit done, push failed or skipped."));
169
+ }
170
+ }
171
+ }
172
+ else if (opts.push && !opts.repoUrl) {
173
+ console.log(chalk.gray("Local-only mode (no remote URL configured); skipping commit/push."));
174
+ }
175
+ // P7 (0.8.0): refresh the read-only aggregated worktree so `list-sessions`
176
+ // / `resume` can see every sibling device's raw_sessions. Best-effort —
177
+ // if this fails (no remote, first push didn't create main yet, network
178
+ // issue), we just log and move on; the user's own sync still succeeded.
179
+ if (pushed && opts.repoUrl) {
180
+ const { refreshAggregatedWorktree } = await import("../aggregated-store.js");
181
+ const ok = await refreshAggregatedWorktree(opts.repoPath);
182
+ if (ok) {
183
+ console.log(chalk.gray(" refreshed aggregated worktree (~/.memarium/aggregated/)"));
184
+ }
185
+ else {
186
+ // Likely "main has nothing yet" on a fresh repo — CI hasn't run an
187
+ // aggregate yet. Stays quiet unless DEBUG; this is the expected path
188
+ // on first sync of a brand-new remote.
189
+ if (process.env.MEMARIUM_DEBUG) {
190
+ console.log(chalk.gray(" (aggregated worktree refresh skipped — no main yet?)"));
191
+ }
192
+ }
193
+ }
194
+ return { newCount, skippedCount, pathsWritten, committed, pushed };
195
+ }
196
+ /**
197
+ * Ensure `.github/workflows/memarium-aggregate.yml` exists in the device
198
+ * branch's working tree. GitHub Actions on `push` events reads the
199
+ * workflow file from the pushed branch — not from the default branch —
200
+ * so a device branch without this file silently never triggers CI. The
201
+ * "fresh init never aggregated" symptom on macmini 2026-05-25 was this
202
+ * exact path: `memarium workflow init` (0.5.3+) only writes the file to
203
+ * main, but the new device branch had no `.github/` directory at all.
204
+ *
205
+ * Strategy: best-effort fetch + restore-from-main on every sync. If main
206
+ * doesn't have the file yet (first push on a brand-new remote), silently
207
+ * skip — the user just needs to run `memarium workflow init` once.
208
+ *
209
+ * Returns true when the working tree file was created OR refreshed (the
210
+ * caller should stage it). Returns false when nothing to do or main has
211
+ * no workflow yet.
212
+ */
213
+ async function ensureWorkflowFileFromMain(repoPath, git) {
214
+ const wfRel = ".github/workflows/memarium-aggregate.yml";
215
+ const wfAbs = join(repoPath, wfRel);
216
+ let mainContent;
217
+ try {
218
+ await git.fetch("origin", "main");
219
+ mainContent = await git.show([`origin/main:${wfRel}`]);
220
+ }
221
+ catch (err) {
222
+ if (process.env.MEMARIUM_DEBUG) {
223
+ console.log(chalk.gray(` (workflow file: skip — ${err.message?.split("\n")[0]})`));
224
+ }
225
+ return false;
226
+ }
227
+ if (existsSync(wfAbs)) {
228
+ const localContent = readFileSync(wfAbs, "utf8");
229
+ if (localContent === mainContent)
230
+ return false; // already up to date
231
+ }
232
+ mkdirSync(join(repoPath, ".github", "workflows"), { recursive: true });
233
+ writeFileSync(wfAbs, mainContent);
234
+ return true;
235
+ }
236
+ /**
237
+ * Pure helper: returns a possibly-migrated copy of cfg with deviceBranch set
238
+ * from hostname when the input is missing/empty. `migrated` indicates whether
239
+ * a write-back is needed.
240
+ */
241
+ export function ensureDeviceBranchOnConfig(cfg) {
242
+ if (cfg.deviceBranch && cfg.deviceBranch.trim() !== "") {
243
+ return { migrated: false, cfg };
244
+ }
245
+ return {
246
+ migrated: true,
247
+ cfg: { ...cfg, deviceBranch: deviceBranchFromHostname() },
248
+ };
249
+ }
250
+ /**
251
+ * Loads ~/.memarium/config.json and applies any in-place migrations needed by
252
+ * current code (currently: deviceBranch self-heal). On migration, writes the
253
+ * fixed config back to disk.
254
+ */
255
+ export function readConfigWithMigration() {
256
+ const rawCfg = readConfig();
257
+ const heal = ensureDeviceBranchOnConfig(rawCfg);
258
+ if (heal.migrated) {
259
+ console.log(chalk.cyan(`Migrating: legacy config missing deviceBranch. Setting to "${heal.cfg.deviceBranch}" and saving to ~/.memarium/config.json.`));
260
+ writeConfig(heal.cfg);
261
+ }
262
+ return heal.cfg;
263
+ }
264
+ export async function syncCmd() {
265
+ const cfg = readConfigWithMigration();
266
+ const r = await runSync({
267
+ repoPath: cfg.repoPath,
268
+ push: true,
269
+ repoUrl: cfg.repoUrl,
270
+ deviceBranch: cfg.deviceBranch,
271
+ includeReasoning: cfg.includeReasoning,
272
+ });
273
+ console.log(chalk.bold(`\nSynced: +${r.newCount} new, ${r.skippedCount} unchanged`));
274
+ if (r.committed)
275
+ console.log(chalk.cyan(r.pushed ? "Pushed." : "Committed (push failed)."));
276
+ if (r.newCount > 0) {
277
+ console.log(chalk.cyan("\nNext: in Claude Code, run `/memarium` to digest into chronicle/topics/cards."));
278
+ }
279
+ }
@@ -0,0 +1,47 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import chalk from "chalk";
3
+ export async function upgradeCmd(opts = {}) {
4
+ console.log(chalk.cyan("memarium upgrade — refresh the npm CLI\n"));
5
+ // Refresh the npm-global CLI.
6
+ if (!opts.noCli) {
7
+ if (isLinkedDevInstall()) {
8
+ console.log(chalk.gray(" ✓ skipping npm install (memarium is npm-link'd from a dev checkout)"));
9
+ }
10
+ else {
11
+ console.log(chalk.cyan("→ npm install -g memarium@latest"));
12
+ const r = spawnSync("npm", ["install", "-g", "memarium@latest"], { stdio: "inherit" });
13
+ if (r.status === 0) {
14
+ console.log(chalk.green(" ✓ CLI refreshed"));
15
+ }
16
+ else {
17
+ console.log(chalk.yellow(" ! npm install failed. Common causes:\n" +
18
+ " - You're on a system Node — try `sudo npm install -g memarium@latest`\n" +
19
+ " - Or a stale nvm cache — try `nvm use --lts && npm install -g memarium@latest`"));
20
+ }
21
+ }
22
+ }
23
+ // Final summary — point users at the separate plugin update flow.
24
+ console.log("");
25
+ console.log(chalk.cyan("For the digest + recall plugin, run:"));
26
+ console.log(" /plugin update memarium (in any Claude Code session)");
27
+ }
28
+ /** Detect a development install — `npm link` puts the global memarium
29
+ * binary as a symlink to the user's local checkout. We don't want to
30
+ * blow that away with a tarball install. Strategy: ask npm where the
31
+ * global package lives, then check if it's a symlink. */
32
+ function isLinkedDevInstall() {
33
+ try {
34
+ const root = spawnSync("npm", ["root", "-g"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
35
+ if (root.status !== 0)
36
+ return false;
37
+ const globalRoot = root.stdout.trim();
38
+ if (!globalRoot)
39
+ return false;
40
+ const { lstatSync } = require("node:fs");
41
+ const stat = lstatSync(`${globalRoot}/memarium`);
42
+ return stat.isSymbolicLink();
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
@@ -0,0 +1,126 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import chalk from "chalk";
5
+ import { readConfig } from "../config.js";
6
+ import { migrateLegacyDataDir } from "../migrate.js";
7
+ import { ensureRepo, commitToMainViaWorktree } from "../git-ops.js";
8
+ /**
9
+ * Resolve the path to a bundled asset. The build emits to `dist/src/commands/`
10
+ * (because tsconfig has rootDir="." and includes both bin/ and src/), while
11
+ * dev runs from `src/commands/`. Probe both layouts plus npm-global ones.
12
+ */
13
+ function assetPath(rel) {
14
+ const here = dirname(fileURLToPath(import.meta.url));
15
+ const candidates = [
16
+ resolve(here, "..", "..", rel), // src/commands/
17
+ resolve(here, "..", "..", "..", rel), // dist/src/commands/
18
+ resolve(here, "..", "..", "..", "..", rel),
19
+ ];
20
+ for (const c of candidates) {
21
+ if (existsSync(c))
22
+ return c;
23
+ }
24
+ throw new Error(`memarium bundled asset not found: ${rel}. Tried:\n ${candidates.join("\n ")}\nIf you installed memarium from npm, please file an issue.`);
25
+ }
26
+ const WORKFLOW_REL = ".github/workflows/memarium-aggregate.yml";
27
+ const SCRIPT_REL = "scripts/merge-books.mjs";
28
+ /**
29
+ * Read the workflow yaml template and substitute the user's `bookLocale`
30
+ * into the `MEMARIUM_LOCALE` env line. Done at install time so the
31
+ * locale travels with the workflow on main (not pulled per-CI-run).
32
+ */
33
+ function renderWorkflowYaml(bookLocale) {
34
+ const raw = readFileSync(assetPath("assets/workflows/memarium-aggregate.yml"), "utf8");
35
+ return raw.replace("__MEMARIUM_LOCALE__", bookLocale);
36
+ }
37
+ /**
38
+ * `memarium workflow init` — install the CI aggregation workflow + merge
39
+ * script directly onto the **main** branch (where GitHub Actions actually
40
+ * reads them from), without touching the user's current device branch or
41
+ * working tree.
42
+ *
43
+ * Why main instead of the device branch: GitHub Actions only resolves
44
+ * workflow files from the default branch. If we wrote them to a device
45
+ * branch, every CI run would fail with MODULE_NOT_FOUND on the merge
46
+ * script (because main, where the workflow checks out, doesn't have it).
47
+ * That was the 0.5.0 → 0.5.2 cold-start bug.
48
+ *
49
+ * Local-only repos (no repoUrl) still get files written to the device
50
+ * branch's working tree for completeness, but won't push anywhere.
51
+ */
52
+ export async function workflowInitCmd(opts = {}) {
53
+ const cfg = readConfig();
54
+ // Local-only mode: write into the current working tree like before.
55
+ // Nothing to push — no CI matters in this mode anyway.
56
+ if (!cfg.repoUrl) {
57
+ const yamlTarget = join(cfg.repoPath, WORKFLOW_REL);
58
+ const scriptTarget = join(cfg.repoPath, SCRIPT_REL);
59
+ if ((existsSync(yamlTarget) || existsSync(scriptTarget)) && !opts.force) {
60
+ if (existsSync(yamlTarget))
61
+ console.log(chalk.yellow(`already exists: ${yamlTarget}`));
62
+ if (existsSync(scriptTarget))
63
+ console.log(chalk.yellow(`already exists: ${scriptTarget}`));
64
+ console.log(chalk.gray(" re-run with --force to overwrite"));
65
+ return;
66
+ }
67
+ mkdirSync(dirname(yamlTarget), { recursive: true });
68
+ writeFileSync(yamlTarget, renderWorkflowYaml(cfg.bookLocale));
69
+ mkdirSync(dirname(scriptTarget), { recursive: true });
70
+ writeFileSync(scriptTarget, readFileSync(assetPath("assets/scripts/merge-books.mjs"), "utf8"));
71
+ console.log(chalk.green(`workflow + script written under ${cfg.repoPath}`));
72
+ console.log(chalk.gray("Local-only mode: no remote configured — CI aggregation has no effect."));
73
+ return;
74
+ }
75
+ // Remote-mode: install on main via a temp worktree so we don't disturb the
76
+ // user's device branch or working tree.
77
+ if (opts.noPush) {
78
+ console.log(chalk.yellow(" --no-push is incompatible with the new workflow-init flow (which writes\n" +
79
+ " directly to origin/main via a temp worktree). To inspect files locally,\n" +
80
+ " see assets/workflows/memarium-aggregate.yml + assets/scripts/merge-books.mjs\n" +
81
+ " in the memarium npm package."));
82
+ return;
83
+ }
84
+ // Opportunistic: rename the newest legacy data dir (`.vibebook/`, else
85
+ // `.memvc/`) → `.memarium/` if the user skipped it on earlier syncs. This
86
+ // happens on the user's main working tree (device branch), independent of
87
+ // the main-side workflow push below.
88
+ const dataDirMig = await migrateLegacyDataDir(cfg.repoPath);
89
+ if (dataDirMig.migrated) {
90
+ console.log(chalk.green(`renamed legacy ${dataDirMig.from}/ → .memarium/ ${dataDirMig.viaGit ? "(via git mv)" : ""}`));
91
+ }
92
+ const git = await ensureRepo(cfg.repoPath, cfg.repoUrl);
93
+ console.log(chalk.cyan("Installing workflow + script on origin/main..."));
94
+ const r = await commitToMainViaWorktree(git, cfg.repoPath, async (worktreePath) => {
95
+ const yamlAbs = join(worktreePath, WORKFLOW_REL);
96
+ const scriptAbs = join(worktreePath, SCRIPT_REL);
97
+ const newYaml = renderWorkflowYaml(cfg.bookLocale);
98
+ const newScript = readFileSync(assetPath("assets/scripts/merge-books.mjs"), "utf8");
99
+ // If the file is already present on main and we're not --force, skip.
100
+ if (!opts.force && existsSync(yamlAbs) && existsSync(scriptAbs)) {
101
+ const existingYaml = readFileSync(yamlAbs, "utf8");
102
+ const existingScript = readFileSync(scriptAbs, "utf8");
103
+ if (existingYaml === newYaml && existingScript === newScript) {
104
+ // Nothing changes; tell the worktree commit step to no-op.
105
+ return [];
106
+ }
107
+ }
108
+ mkdirSync(dirname(yamlAbs), { recursive: true });
109
+ writeFileSync(yamlAbs, newYaml);
110
+ mkdirSync(dirname(scriptAbs), { recursive: true });
111
+ writeFileSync(scriptAbs, newScript);
112
+ return [WORKFLOW_REL, SCRIPT_REL];
113
+ }, "memarium: install / update CI aggregation workflow + merge-books script", (stage) => console.log(chalk.gray(` ${stage}`)));
114
+ if (r.committed && r.pushed) {
115
+ console.log(chalk.green(`\n✓ workflow + script pushed to origin/main`));
116
+ console.log(chalk.gray("The workflow fires on every push to a non-main branch."));
117
+ console.log(chalk.gray("Each device's `memarium sync` will trigger it; CI merges all device book/s into main."));
118
+ }
119
+ else if (!r.committed) {
120
+ console.log(chalk.gray("\nMain already has the latest workflow + script (no-op)."));
121
+ }
122
+ else {
123
+ console.log(chalk.yellow("\n! committed locally on the temp worktree but push failed."));
124
+ console.log(chalk.cyan(" Inspect ~/.memarium/session-repo, fetch origin/main, and retry."));
125
+ }
126
+ }
@@ -0,0 +1,98 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { z } from "zod";
6
+ // Resolved lazily (not module-level consts) so they always reflect the current
7
+ // HOME — important for tests that stub HOME, and correct for a CLI in general.
8
+ function configDir() { return join(homedir(), ".memarium"); }
9
+ function configPath() { return join(configDir(), "config.json"); }
10
+ /** One-shot: move the whole config dir from the old `~/.vibebook/` (project's
11
+ * pre-rename name) to `~/.memarium/` if the old one exists and the new one
12
+ * doesn't. Idempotent, best-effort. Covers config.json, session-repo/,
13
+ * aggregated/, usage/, local-proposals/ in one move, then rewrites the
14
+ * absolute `~/.vibebook/` paths stored inside config.json. Finally repairs
15
+ * the `aggregated/` git worktree, whose absolute back-link to session-repo the
16
+ * move staled (refreshAggregatedWorktree only rebuilds when `aggregated/.git`
17
+ * is ABSENT, so a dangling link would silently break cross-device recall).
18
+ * Runs before reads. */
19
+ export function migrateLegacyConfigDir() {
20
+ const legacy = join(homedir(), ".vibebook");
21
+ const dir = configDir();
22
+ if (existsSync(dir) || !existsSync(legacy))
23
+ return;
24
+ try {
25
+ renameSync(legacy, dir);
26
+ // Fix stored paths (repoPath etc.) that pointed into ~/.vibebook — both the
27
+ // expanded form (`/home/u/.vibebook`) and the literal-tilde form
28
+ // (`~/.vibebook`) that config.json may legally store.
29
+ const p = configPath();
30
+ let repoPath = join(dir, "session-repo");
31
+ if (existsSync(p)) {
32
+ const raw = readFileSync(p, "utf8");
33
+ const fixed = raw.split(legacy).join(dir).split("~/.vibebook").join("~/.memarium");
34
+ if (fixed !== raw)
35
+ writeFileSync(p, fixed);
36
+ try {
37
+ const parsed = JSON.parse(fixed);
38
+ if (parsed.repoPath)
39
+ repoPath = parsed.repoPath.replace(/^~(?=$|\/)/, homedir());
40
+ }
41
+ catch { /* keep default repoPath */ }
42
+ }
43
+ // Repair the read-only aggregated worktree's absolute links (both the
44
+ // worktree's `.git` file and the session-repo's admin `gitdir`) so a later
45
+ // `memarium sync` can still refresh it instead of silently failing.
46
+ // Bounded so a hung git can't stall the SessionStart migration path.
47
+ const agg = join(dir, "aggregated");
48
+ if (existsSync(agg)) {
49
+ spawnSync("git", ["-C", repoPath, "worktree", "repair", agg], { stdio: "ignore", timeout: 10_000 });
50
+ }
51
+ }
52
+ catch { /* best-effort */ }
53
+ }
54
+ /** Default cap on concurrent runner calls during the threading phase.
55
+ * claude-cli can comfortably handle 4 (each spawn is its own subprocess
56
+ * against the user's own Claude quota). anthropic-api also fine at 4. */
57
+ export const DEFAULT_THREADING_CONCURRENCY = 4;
58
+ /** Default attempts per threading batch before soft-failing it. */
59
+ export const DEFAULT_THREADING_MAX_ATTEMPTS = 3;
60
+ const Schema = z.object({
61
+ repoPath: z.string(),
62
+ repoUrl: z.string(),
63
+ deviceBranch: z.string().default(""),
64
+ runner: z.enum(["claude-cli", "anthropic-api"]).default("claude-cli"),
65
+ /** When true, the user opted into the CI book-aggregation workflow
66
+ * (scripts/merge-books.mjs runs on push to any non-main branch and
67
+ * merges device books into main). Purely informational — the workflow
68
+ * yaml + script live in the user's repo, not driven by this flag. */
69
+ enableAggregateCI: z.boolean().default(false),
70
+ /** When true, include the assistant's reasoning/thinking content in synced
71
+ * raw_sessions/*.md files. Improves digest quality (the summarizing LLM
72
+ * can see WHY the assistant chose a path) but can grow each md file by
73
+ * 30-100%. Recommended when summarizing with a 400K+ context model;
74
+ * recommended off when summarizing with a smaller model. Default: true. */
75
+ includeReasoning: z.boolean().default(true),
76
+ threadingConcurrency: z.number().int().positive().default(DEFAULT_THREADING_CONCURRENCY),
77
+ threadingMaxAttempts: z.number().int().positive().default(DEFAULT_THREADING_MAX_ATTEMPTS),
78
+ digestEnabled: z.boolean().default(true),
79
+ /** Cross-device path translation: source-prefix → this-machine-prefix.
80
+ * Used by `memarium resume` to rewrite jsonl paths from another machine
81
+ * into local paths. Set via `memarium config --map-path A=B`. */
82
+ pathMap: z.record(z.string()).optional(),
83
+ /** Locale for the rendered book pages (book/index.md, book/_meta/timeline.md,
84
+ * per-project index pages). Drives string tables in merge-books.mjs via
85
+ * the MEMARIUM_LOCALE env var the workflow yml exports. Default "en". */
86
+ bookLocale: z.enum(["en", "zh"]).default("en"),
87
+ });
88
+ export function configExists() { migrateLegacyConfigDir(); return existsSync(configPath()); }
89
+ export function readConfig() {
90
+ migrateLegacyConfigDir();
91
+ if (!existsSync(configPath()))
92
+ throw new Error("memarium not initialized. Run `memarium init <repoUrl>`.");
93
+ return Schema.parse(JSON.parse(readFileSync(configPath(), "utf8")));
94
+ }
95
+ export function writeConfig(cfg) {
96
+ mkdirSync(configDir(), { recursive: true });
97
+ writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + "\n");
98
+ }