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,383 @@
1
+ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import chalk from "chalk";
6
+ import { isStableDeviceName } from "../device.js";
7
+ import { aggregatedPath } from "../aggregated-store.js";
8
+ export async function doctorCmd() {
9
+ const checks = [];
10
+ // 1. CLI on PATH
11
+ const cliVersion = readPathCliVersion();
12
+ if (cliVersion) {
13
+ checks.push({ name: "CLI on PATH", status: "ok", detail: `memarium ${cliVersion}` });
14
+ }
15
+ else {
16
+ checks.push({
17
+ name: "CLI on PATH", status: "fail",
18
+ detail: "memarium --version did not respond",
19
+ fix: "npm install -g memarium@latest",
20
+ });
21
+ }
22
+ // 1b. Multi-install footgun (0.8.4): a user can have `memarium` installed
23
+ // to both Homebrew's npm prefix AND nvm's prefix at the same time. `memarium
24
+ // upgrade` lands in whichever npm runs first, but shell `memarium` resolves
25
+ // by PATH order — so users routinely upgrade one install while continuing
26
+ // to run the other. Tell them which one wins and which to nuke.
27
+ const allInstalls = listAllPathInstalls();
28
+ if (allInstalls.length > 1) {
29
+ const lines = allInstalls
30
+ .map((i, idx) => ` ${idx === 0 ? "→" : " "} ${i.path} (${i.version ?? "?"})`)
31
+ .join("\n");
32
+ const losers = allInstalls.slice(1);
33
+ const fixCmd = losers
34
+ .map((i) => {
35
+ // Infer the npm prefix from the install path and recommend that
36
+ // npm to uninstall, so the user doesn't accidentally uninstall
37
+ // the one they wanted to keep.
38
+ const prefix = i.path.replace(/\/bin\/memarium$/, "");
39
+ return `${prefix}/bin/npm uninstall -g memarium`;
40
+ })
41
+ .join(" && ") + " && hash -r";
42
+ checks.push({
43
+ name: "PATH conflicts", status: "warn",
44
+ detail: `${allInstalls.length} memarium installs on PATH (shell uses the → marked one):\n${lines}`,
45
+ fix: fixCmd,
46
+ });
47
+ }
48
+ // 2. npm latest
49
+ const npmLatest = readNpmLatestVersion();
50
+ if (npmLatest) {
51
+ if (cliVersion && cliVersion !== npmLatest) {
52
+ checks.push({
53
+ name: "CLI vs npm latest", status: "warn",
54
+ detail: `local ${cliVersion} · npm ${npmLatest}`,
55
+ fix: "memarium upgrade",
56
+ });
57
+ }
58
+ else if (cliVersion) {
59
+ checks.push({ name: "CLI vs npm latest", status: "ok", detail: `${cliVersion} = ${npmLatest}` });
60
+ }
61
+ }
62
+ else {
63
+ checks.push({
64
+ name: "CLI vs npm latest", status: "info",
65
+ detail: "couldn't reach npm registry (offline?)",
66
+ });
67
+ }
68
+ // 3. Claude plugin (outside-in detection — the plugin lives in its own repo,
69
+ // cloned by Claude Code into ~/.claude/plugins/marketplaces/memarium-plugin/
70
+ // when the user runs `/plugin marketplace add june9593/memarium-plugin`.
71
+ // npm memarium itself does not install the plugin.)
72
+ const pluginMarketplacePath = join(homedir(), ".claude", "plugins", "marketplaces", "memarium-plugin");
73
+ if (existsSync(pluginMarketplacePath)) {
74
+ checks.push({
75
+ name: "Claude plugin", status: "ok",
76
+ detail: "memarium-plugin marketplace registered",
77
+ });
78
+ }
79
+ else {
80
+ checks.push({
81
+ name: "Claude plugin", status: "warn",
82
+ detail: "memarium-plugin (chronicle digest + recall) not installed",
83
+ fix: "/plugin marketplace add june9593/memarium-plugin && /plugin install memarium # optional — npm memarium handles cross-device sync; the plugin handles digest + recall",
84
+ });
85
+ }
86
+ // 4. Config + repoPath
87
+ const config = readConfigSafe();
88
+ if (!config) {
89
+ checks.push({
90
+ name: "memarium config", status: "fail",
91
+ detail: "~/.memarium/config.json missing",
92
+ fix: "memarium init",
93
+ });
94
+ }
95
+ else {
96
+ const repoExists = existsSync(config.repoPath);
97
+ const repoHasGit = repoExists && existsSync(join(config.repoPath, ".git"));
98
+ if (!repoExists) {
99
+ checks.push({
100
+ name: "Session repo", status: "fail",
101
+ detail: `repoPath ${config.repoPath} does not exist`,
102
+ fix: "memarium init # re-clone",
103
+ });
104
+ }
105
+ else if (!repoHasGit) {
106
+ checks.push({
107
+ name: "Session repo", status: "warn",
108
+ detail: `${config.repoPath} exists but is not a git repo`,
109
+ });
110
+ }
111
+ else {
112
+ checks.push({
113
+ name: "Session repo", status: "ok",
114
+ detail: `${config.repoPath} (${config.repoUrl || "local-only"})`,
115
+ });
116
+ }
117
+ // 4b. Device branch drift check. hostname() on macOS varies across
118
+ // networks (mDNS / corp DHCP / hotspot) — if init wrote the volatile
119
+ // value, each network creates a new branch and the spool fragments.
120
+ if (config.deviceBranch !== undefined) {
121
+ if (!isStableDeviceName(config.deviceBranch)) {
122
+ checks.push({
123
+ name: "Device branch", status: "warn",
124
+ detail: `'${config.deviceBranch}' looks like a volatile macOS hostname — sync may push to a new branch when you change networks`,
125
+ fix: `memarium config --device <stable-name> # e.g. 'mini2', 'work-laptop'`,
126
+ });
127
+ }
128
+ else {
129
+ checks.push({
130
+ name: "Device branch", status: "ok",
131
+ detail: config.deviceBranch,
132
+ });
133
+ }
134
+ }
135
+ // 5. 0.5.x spool residue check. memarium 0.6 only writes .md per session.
136
+ // Existing .raw.json and .jsonl from 0.5.x are dead weight; suggest cleanup.
137
+ if (repoExists) {
138
+ const residue = countResidue(config.repoPath);
139
+ if (residue.rawJsonCount + residue.jsonlCount > 0) {
140
+ const lines = [
141
+ `${residue.rawJsonCount} .raw.json files, ${residue.jsonlCount} .jsonl files in spool`,
142
+ `(memarium 0.6 only writes .md — these are 0.5.x residue)`,
143
+ ];
144
+ checks.push({
145
+ name: "0.5.x spool residue",
146
+ status: "warn",
147
+ detail: lines.join(" — "),
148
+ fix: `find "${join(config.repoPath, "raw_sessions")}" -name "*.jsonl" -delete && ` +
149
+ `find "${join(config.repoPath, "raw_sessions")}" -name "*.raw.json" -delete && ` +
150
+ `rm "${join(config.repoPath, ".memarium/index.json")}" && ` +
151
+ `memarium sync # regenerates index + new-format .md per session`,
152
+ });
153
+ }
154
+ // 5b. resume-forks.json residue (from 0.5.1 fork-tracking, removed in 0.6)
155
+ const forkRegPath = join(homedir(), ".memarium/resume-forks.json");
156
+ if (existsSync(forkRegPath)) {
157
+ checks.push({
158
+ name: "0.5.1 fork registry residue",
159
+ status: "warn",
160
+ detail: `~/.memarium/resume-forks.json exists (no longer used)`,
161
+ fix: `rm ${forkRegPath}`,
162
+ });
163
+ }
164
+ // 5c. Workflow residue on device branch. Pre-0.5.3 `memarium workflow
165
+ // init` wrote files to the user's device branch; from 0.5.3 they
166
+ // live on main only. Warn if the user still has stale copies on
167
+ // the device branch (we can't easily distinguish "I'm a 0.5.2
168
+ // user who already pushed these once" from "I'm a 0.5.3 user with
169
+ // a leftover commit", so we just always warn when both branches
170
+ // have them on disk).
171
+ const deviceYaml = join(config.repoPath, ".github/workflows/memarium-aggregate.yml");
172
+ const deviceScript = join(config.repoPath, "scripts/merge-books.mjs");
173
+ if (existsSync(deviceYaml) || existsSync(deviceScript)) {
174
+ checks.push({
175
+ name: "Workflow residue", status: "warn",
176
+ detail: "Found .github/workflows/memarium-aggregate.yml + scripts/merge-books.mjs on device-branch working tree — these belong on main only (since 0.5.3)",
177
+ fix: `cd "${config.repoPath}" && git rm -r .github/workflows scripts 2>/dev/null; git commit -m "remove workflow residue (lives on main since 0.5.3)" && git push`,
178
+ });
179
+ }
180
+ // 5d. Cross-device overlay freshness (P1). The plugin's recall/primer read
181
+ // ~/.memarium/aggregated (a read-only worktree of origin/main) for
182
+ // sibling-device memory (P0b, plugin 0.12). If it's missing or behind
183
+ // origin/main, cross-device recall silently misses sibling memory.
184
+ if (repoHasGit && config.repoUrl) {
185
+ const aggPath = aggregatedPath();
186
+ if (!existsSync(aggPath)) {
187
+ checks.push({
188
+ name: "Cross-device overlay", status: "warn",
189
+ detail: "~/.memarium/aggregated not set up — recall/primer see only this device's memory",
190
+ fix: "memarium sync # creates + refreshes the cross-device overlay",
191
+ });
192
+ }
193
+ else {
194
+ const overlayHead = gitRevParse(aggPath, "HEAD");
195
+ const originMain = gitRevParse(config.repoPath, "origin/main");
196
+ if (!overlayHead) {
197
+ // Dir exists but HEAD won't resolve → broken/orphaned worktree.
198
+ // The whole point of this check is to surface silently-broken
199
+ // cross-device recall, so don't mask it as ok.
200
+ checks.push({
201
+ name: "Cross-device overlay", status: "warn",
202
+ detail: "overlay present but its HEAD can't be resolved (broken/orphaned worktree) — cross-device recall may be silently broken",
203
+ fix: "memarium sync # rebuild the overlay",
204
+ });
205
+ }
206
+ else if (originMain && overlayHead !== originMain) {
207
+ checks.push({
208
+ name: "Cross-device overlay", status: "warn",
209
+ detail: "overlay is behind origin/main — cross-device recall may miss recent sibling-device memory",
210
+ fix: "memarium sync # refresh the overlay",
211
+ });
212
+ }
213
+ else {
214
+ checks.push({
215
+ name: "Cross-device overlay", status: "ok",
216
+ detail: originMain ? "present + in sync with origin/main" : "present",
217
+ });
218
+ }
219
+ }
220
+ }
221
+ }
222
+ }
223
+ // 6. memex (informational)
224
+ const memexVersion = readMemexVersion();
225
+ if (memexVersion) {
226
+ checks.push({
227
+ name: "Memex (optional)", status: "ok",
228
+ detail: `${memexVersion} — /memarium recall folds memex cards in automatically`,
229
+ });
230
+ }
231
+ else {
232
+ checks.push({
233
+ name: "Memex (optional)", status: "info",
234
+ detail: "not installed — atomic-card layer is unavailable",
235
+ fix: "npm install -g @touchskyer/memex # only if you want atomic cards",
236
+ });
237
+ }
238
+ // ---------- render ----------
239
+ const symbol = {
240
+ ok: chalk.green("✓"),
241
+ warn: chalk.yellow("!"),
242
+ fail: chalk.red("✗"),
243
+ info: chalk.gray("·"),
244
+ };
245
+ for (const c of checks) {
246
+ console.log(`${symbol[c.status]} ${c.name.padEnd(28)} ${c.detail}`);
247
+ }
248
+ const fixes = checks.filter((c) => c.fix);
249
+ if (fixes.length > 0) {
250
+ console.log(chalk.cyan("\nSuggested fixes:"));
251
+ const seen = new Set();
252
+ for (const c of fixes) {
253
+ if (seen.has(c.fix))
254
+ continue;
255
+ seen.add(c.fix);
256
+ console.log(` ${c.fix}`);
257
+ }
258
+ }
259
+ else {
260
+ console.log(chalk.green("\nAll checks passed."));
261
+ }
262
+ // Exit non-zero only if a `fail`. `warn` is on you to interpret.
263
+ const hasFail = checks.some((c) => c.status === "fail");
264
+ process.exit(hasFail ? 1 : 0);
265
+ }
266
+ // ---------- check primitives ----------
267
+ function readPathCliVersion() {
268
+ // Use --version (mapped to -v in 0.3.1+); fall back to -V which still
269
+ // works on older releases.
270
+ for (const flag of ["--version", "-V"]) {
271
+ const r = spawnSync("memarium", [flag], {
272
+ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 3000,
273
+ });
274
+ if (r.status === 0)
275
+ return r.stdout.trim();
276
+ }
277
+ return null;
278
+ }
279
+ /**
280
+ * Walk every PATH directory and return each `memarium` binary found plus its
281
+ * --version output. Multi-install footgun: a user can have npm installed
282
+ * to both Homebrew's npm prefix (/opt/homebrew/lib/node_modules/) AND nvm's
283
+ * (~/.nvm/versions/node/<v>/lib/node_modules/) at the same time. `memarium
284
+ * upgrade` installs to whichever npm is on PATH first, but the shell
285
+ * resolves `memarium` by PATH order — so users routinely upgrade one
286
+ * install while continuing to run the other. Bit Yue twice on 2026-05-25.
287
+ */
288
+ function listAllPathInstalls() {
289
+ const pathDirs = (process.env.PATH ?? "").split(":").filter(Boolean);
290
+ const seen = new Set();
291
+ const out = [];
292
+ for (const dir of pathDirs) {
293
+ const abs = join(dir, "memarium");
294
+ if (seen.has(abs) || !existsSync(abs))
295
+ continue;
296
+ seen.add(abs);
297
+ // Resolve symlink target so two PATH entries pointing at the same
298
+ // physical binary collapse into one row.
299
+ let real = abs;
300
+ try {
301
+ real = realpathSync(abs);
302
+ }
303
+ catch { /* fall through */ }
304
+ if (seen.has(real))
305
+ continue;
306
+ seen.add(real);
307
+ const r = spawnSync(abs, ["--version"], {
308
+ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 3000,
309
+ });
310
+ out.push({ path: abs, version: r.status === 0 ? r.stdout.trim() : null });
311
+ }
312
+ return out;
313
+ }
314
+ function readNpmLatestVersion() {
315
+ const r = spawnSync("npm", ["view", "memarium", "version"], {
316
+ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5000,
317
+ });
318
+ if (r.status !== 0)
319
+ return null;
320
+ const v = r.stdout.trim();
321
+ return v || null;
322
+ }
323
+ function readConfigSafe() {
324
+ const path = join(homedir(), ".memarium", "config.json");
325
+ if (!existsSync(path))
326
+ return null;
327
+ try {
328
+ return JSON.parse(readFileSync(path, "utf8"));
329
+ }
330
+ catch {
331
+ return null;
332
+ }
333
+ }
334
+ function countResidue(repoPath) {
335
+ const root = join(repoPath, "raw_sessions");
336
+ if (!existsSync(root))
337
+ return { rawJsonCount: 0, jsonlCount: 0 };
338
+ let rawJsonCount = 0;
339
+ let jsonlCount = 0;
340
+ const walk = (dir) => {
341
+ let entries;
342
+ try {
343
+ entries = readdirSync(dir, { withFileTypes: true });
344
+ }
345
+ catch {
346
+ return;
347
+ }
348
+ for (const e of entries) {
349
+ const p = join(dir, e.name);
350
+ if (e.isDirectory()) {
351
+ walk(p);
352
+ continue;
353
+ }
354
+ if (!e.isFile())
355
+ continue;
356
+ if (e.name.endsWith(".raw.json"))
357
+ rawJsonCount++;
358
+ else if (e.name.endsWith(".jsonl"))
359
+ jsonlCount++;
360
+ }
361
+ };
362
+ walk(root);
363
+ return { rawJsonCount, jsonlCount };
364
+ }
365
+ function readMemexVersion() {
366
+ const r = spawnSync("memex", ["--version"], {
367
+ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 3000,
368
+ });
369
+ if (r.status !== 0)
370
+ return null;
371
+ return r.stdout.trim() || null;
372
+ }
373
+ /** Resolve a git ref to a commit sha in `dir` (which shares .git with the
374
+ * session repo). Null when the ref doesn't resolve / not a repo. Offline-safe:
375
+ * reads local refs only, no network. */
376
+ function gitRevParse(dir, ref) {
377
+ const r = spawnSync("git", ["-C", dir, "rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
378
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000,
379
+ });
380
+ if (r.status !== 0)
381
+ return null;
382
+ return (r.stdout ?? "").trim() || null;
383
+ }
@@ -0,0 +1,201 @@
1
+ import { join, resolve } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import chalk from "chalk";
4
+ import { prompt, promptYesNo, closePrompts } from "../prompts.js";
5
+ import { materializeRepoAtPath, expandHome } from "../git-ops.js";
6
+ import { writeConfig, configExists, DEFAULT_THREADING_CONCURRENCY, DEFAULT_THREADING_MAX_ATTEMPTS, } from "../config.js";
7
+ import { deviceBranchFromHostname, isStableDeviceName } from "../device.js";
8
+ /**
9
+ * Returns the path the wizard will use when the user skips the path question.
10
+ * Fixed at `~/.memarium/session-repo` so the /memarium skill can detect
11
+ * "global mode" by cwd-equality without per-user configuration.
12
+ */
13
+ export function defaultLocalPath() {
14
+ return join(homedir(), ".memarium", "session-repo");
15
+ }
16
+ /**
17
+ * Run the interactive wizard. Returns answers; caller owns writing config +
18
+ * materializing the repo. Throws on user-invalid input that the loops can't
19
+ * recover from (caller catches and exits non-zero).
20
+ */
21
+ export async function runWizard() {
22
+ console.log(chalk.bold("\nmemarium init wizard\n"));
23
+ // Q0: sync to remote?
24
+ const syncToRemote = await promptYesNo(chalk.cyan("Q0") + " Sync to a remote git repo (GitHub etc.)? Choose 'no' for local-only.", true);
25
+ let repoUrl = "";
26
+ let localPath = defaultLocalPath();
27
+ if (syncToRemote) {
28
+ // Q1: repo URL
29
+ repoUrl = await prompt(chalk.cyan("Q1") + " Private git repo URL (e.g. git@github.com:you/work-memory.git)");
30
+ if (!repoUrl)
31
+ throw new Error("repo URL is required");
32
+ // Q2: local path
33
+ const rawPath = await prompt(chalk.cyan("Q2") + ` Where should the repo live locally? (recommend the default — the /memarium skill detects "global mode" by this exact path)`, localPath);
34
+ localPath = resolve(expandHome(rawPath));
35
+ }
36
+ else {
37
+ console.log(chalk.gray(" local-only mode: no remote URL, no push."));
38
+ }
39
+ // Q5 / Q6 / Q7 dropped in 0.6.1:
40
+ // - Q6 (enable CI aggregation): now defaults true when sync-to-remote
41
+ // (the CI workflow is small + universally useful; escape hatch is
42
+ // editing config.json's enableAggregateCI: false post-init).
43
+ // - Q7 (include reasoning): always true in 0.6+ — reasoning blocks are
44
+ // part of the context.md by design; truncation handles size.
45
+ // (v0.1 had a "claude model" question here. Removed in v0.2 because the
46
+ // LLM lives entirely in the user's Claude Code session now.)
47
+ // (v0.5: Q5 "digest enabled" and the original Q8 "recommend memex"
48
+ // dropped.)
49
+ const enableAggregateCI = syncToRemote;
50
+ // Q6 (was Q8 pre-0.6.1): device branch name. hostname() drifts on macOS
51
+ // (mDNS in home wifi, DHCP-given names on corp VPN, hotspot etc.) — each
52
+ // network creates a new device branch, fragmenting the spool. We strip
53
+ // common volatile suffixes (.local / .lan) from the default and warn if
54
+ // the cleaned name still looks volatile.
55
+ const hostnameDefault = stripVolatileSuffixes(deviceBranchFromHostname());
56
+ const stableLooking = isStableDeviceName(hostnameDefault);
57
+ const stableHint = stableLooking
58
+ ? "current hostname looks stable, can keep"
59
+ : "WARNING: current hostname looks like macOS drift (e.g. DHCP) — recommend overriding with a physical label like 'mini2' or 'work-laptop'";
60
+ const deviceBranch = (await prompt(chalk.cyan("Q6") + ` Stable device name for this machine's git branch? (${stableHint})`, hostnameDefault)).trim() || hostnameDefault;
61
+ return { repoUrl, localPath, enableAggregateCI, deviceBranch };
62
+ }
63
+ /** Strip common macOS-volatile suffixes from a hostname-derived branch name.
64
+ * `Mac-mini-2.local` → `Mac-mini-2`; `MIS-EV2-BB1.surfacescenarios.org` →
65
+ * `MIS-EV2-BB1` (the FQDN suffix is irrelevant for memarium's purposes; the
66
+ * identifying part of a personal machine name is the bare hostname). */
67
+ export function stripVolatileSuffixes(name) {
68
+ return name
69
+ .replace(/\.local$/i, "")
70
+ .replace(/\.lan$/i, "")
71
+ // Strip first .<dotted-suffix> on FQDN-shaped names; preserves multi-dot
72
+ // user-provided names like "yue.mini.2" by only touching the case where
73
+ // the suffix contains letters (DNS-style).
74
+ .replace(/\.[a-z][a-z0-9.-]*$/i, "");
75
+ }
76
+ /**
77
+ * Materialize repo + write config. Pure I/O, separated so wizard logic stays
78
+ * unit-testable.
79
+ */
80
+ export async function applyWizardAnswers(a) {
81
+ if (a.repoUrl) {
82
+ let mat;
83
+ try {
84
+ mat = await materializeRepoAtPath(a.localPath, a.repoUrl);
85
+ }
86
+ catch (err) {
87
+ // Plugin-first scenario: the user installed memarium-plugin before
88
+ // the npm CLI, so ~/.memarium/session-repo/ is non-empty (book/,
89
+ // raw_sessions/) but not a git repo. Offer to adopt it in place
90
+ // rather than asking the user to `rm -rf` their plugin data.
91
+ const msg = err.message;
92
+ if (msg.includes("is not empty and is not a git repo")) {
93
+ const adopt = await promptYesNo(chalk.yellow(`\n ${a.localPath} has data in it but isn't a git repo (looks like\n` +
94
+ ` memarium-plugin wrote it before the npm CLI was installed).\n` +
95
+ ` Adopt this directory: 'git init' + add origin '${a.repoUrl}' + create\n` +
96
+ ` branch '${a.deviceBranch}' with your existing files as its first commit?\n` +
97
+ ` (Nothing on disk is deleted or moved.)`), true);
98
+ if (!adopt)
99
+ throw err;
100
+ const { adoptPluginDir } = await import("../git-ops.js");
101
+ mat = await adoptPluginDir(a.localPath, a.repoUrl, a.deviceBranch);
102
+ console.log(chalk.gray(` adopted ${a.localPath} into new repo on branch '${a.deviceBranch}'`));
103
+ }
104
+ else {
105
+ throw err;
106
+ }
107
+ }
108
+ if (mat.kind === "existing" && mat.existingRemote && mat.existingRemote !== a.repoUrl) {
109
+ console.log(chalk.yellow(` warning: ${a.localPath} already has remote '${mat.existingRemote}', not '${a.repoUrl}'. Using existing.`));
110
+ }
111
+ else if (mat.kind === "cloned") {
112
+ console.log(chalk.gray(` cloned ${a.repoUrl} -> ${a.localPath}`));
113
+ }
114
+ else if (mat.kind === "existing") {
115
+ console.log(chalk.gray(` using existing repo at ${a.localPath}`));
116
+ }
117
+ // "adopted" already logged inline above.
118
+ }
119
+ else {
120
+ // Local-only mode: ensure the path exists as a plain git repo (no remote).
121
+ const { mkdirSync, existsSync } = await import("node:fs");
122
+ const { join } = await import("node:path");
123
+ const { simpleGit } = await import("simple-git");
124
+ mkdirSync(a.localPath, { recursive: true });
125
+ if (!existsSync(join(a.localPath, ".git"))) {
126
+ await simpleGit(a.localPath).init();
127
+ console.log(chalk.gray(` initialized local-only git repo at ${a.localPath}`));
128
+ }
129
+ else {
130
+ console.log(chalk.gray(` using existing repo at ${a.localPath}`));
131
+ }
132
+ }
133
+ const cfg = {
134
+ repoPath: a.localPath,
135
+ repoUrl: a.repoUrl,
136
+ deviceBranch: a.deviceBranch,
137
+ runner: "claude-cli",
138
+ enableAggregateCI: a.enableAggregateCI,
139
+ // 0.6+: reasoning blocks are always rendered into context.md (they're
140
+ // part of the content-block stream by design; truncation handles size).
141
+ // The schema retains the field but the wizard no longer asks; default true.
142
+ includeReasoning: true,
143
+ threadingConcurrency: DEFAULT_THREADING_CONCURRENCY,
144
+ threadingMaxAttempts: DEFAULT_THREADING_MAX_ATTEMPTS,
145
+ // digestEnabled retained at schema default (true) for downstream
146
+ // consumers; the wizard no longer prompts for it (v0.5).
147
+ digestEnabled: true,
148
+ // bookLocale defaults to "en" via schema; we set it inline to satisfy
149
+ // the strict TypeScript Config type, which doesn't see through z.default().
150
+ bookLocale: "en",
151
+ };
152
+ writeConfig(cfg);
153
+ console.log(chalk.green("\n✓ memarium configured."));
154
+ console.log(chalk.gray(` Config: ~/.memarium/config.json`));
155
+ if (!a.repoUrl) {
156
+ console.log(chalk.cyan(` local-only mode: sessions stay on this machine. To enable sync later, edit ~/.memarium/config.json and set "repoUrl".`));
157
+ return;
158
+ }
159
+ console.log("");
160
+ console.log(chalk.cyan("Try on this machine:"));
161
+ console.log(" memarium sync # extract local sessions + push to your device branch");
162
+ console.log("");
163
+ console.log(chalk.cyan("On ANOTHER machine after memarium init + memarium sync:"));
164
+ console.log(" memarium list-sessions --since 7d # see what's synced from elsewhere");
165
+ console.log(" cd <project-dir> && memarium resume <id> # spawn claude with that prior session's context");
166
+ console.log("");
167
+ console.log(chalk.cyan("For digest + recall (chronicles, topics, bookmark recall):"));
168
+ console.log(chalk.gray(" Install the Claude Code plugin:"));
169
+ console.log(" /plugin marketplace add june9593/memarium-plugin");
170
+ console.log(" /plugin install memarium");
171
+ if (a.enableAggregateCI) {
172
+ console.log("");
173
+ console.log(chalk.cyan("Installing CI aggregation workflow on origin/main..."));
174
+ try {
175
+ const { workflowInitCmd } = await import("./workflow.js");
176
+ await workflowInitCmd({});
177
+ }
178
+ catch (err) {
179
+ console.log(chalk.yellow(`! workflow auto-install failed: ${err.message}`));
180
+ console.log(chalk.gray(` You can retry later with: memarium workflow init`));
181
+ }
182
+ }
183
+ }
184
+ /** Top-level entry — composes wizard + apply, with cleanup. */
185
+ export async function runInitWizard() {
186
+ if (configExists()) {
187
+ const overwrite = await promptYesNo(chalk.yellow("memarium already initialized at ~/.memarium/config.json. Overwrite?"), false);
188
+ if (!overwrite) {
189
+ console.log(chalk.gray("aborted"));
190
+ closePrompts();
191
+ return;
192
+ }
193
+ }
194
+ try {
195
+ const answers = await runWizard();
196
+ await applyWizardAnswers(answers);
197
+ }
198
+ finally {
199
+ closePrompts();
200
+ }
201
+ }
@@ -0,0 +1,45 @@
1
+ import { writeConfig, DEFAULT_THREADING_CONCURRENCY, DEFAULT_THREADING_MAX_ATTEMPTS } from "../config.js";
2
+ import { materializeRepoAtPath } from "../git-ops.js";
3
+ import { deviceBranchFromHostname } from "../device.js";
4
+ import { join } from "node:path";
5
+ import chalk from "chalk";
6
+ /** Wizard mode kicks in when caller passed no flags AND no repoUrl. */
7
+ function isFlagMode(opts) {
8
+ return Boolean(opts.repoUrl || opts.localPath || opts.device || opts.digestEnabled === false);
9
+ }
10
+ export async function initCmd(opts) {
11
+ if (!isFlagMode(opts)) {
12
+ // No flags → interactive wizard.
13
+ const { runInitWizard } = await import("./init-wizard.js");
14
+ await runInitWizard();
15
+ return;
16
+ }
17
+ // Flag mode: non-interactive.
18
+ if (!opts.repoUrl) {
19
+ throw new Error("repoUrl is required in flag mode (or run `memarium init` with no args for the wizard)");
20
+ }
21
+ const localPath = opts.localPath ?? join(process.cwd(), ".memarium", "repo");
22
+ const mat = await materializeRepoAtPath(localPath, opts.repoUrl);
23
+ if (mat.kind === "existing" && mat.existingRemote && mat.existingRemote !== opts.repoUrl) {
24
+ console.log(chalk.yellow(`warning: ${localPath} already has remote '${mat.existingRemote}', not '${opts.repoUrl}'. Using existing.`));
25
+ }
26
+ const cfg = {
27
+ repoPath: localPath,
28
+ repoUrl: opts.repoUrl,
29
+ deviceBranch: opts.device ?? deviceBranchFromHostname(),
30
+ runner: "claude-cli",
31
+ enableAggregateCI: false,
32
+ includeReasoning: true,
33
+ threadingConcurrency: DEFAULT_THREADING_CONCURRENCY,
34
+ threadingMaxAttempts: DEFAULT_THREADING_MAX_ATTEMPTS,
35
+ digestEnabled: opts.digestEnabled !== false,
36
+ bookLocale: "en",
37
+ };
38
+ writeConfig(cfg);
39
+ console.log(chalk.green(`memarium initialized:`));
40
+ console.log(` repo: ${localPath}`);
41
+ console.log(` remote: ${opts.repoUrl}`);
42
+ console.log(` device branch: ${cfg.deviceBranch}`);
43
+ console.log(` digest enabled: ${cfg.digestEnabled}`);
44
+ console.log(chalk.cyan(`\n next: memarium sync → open Claude Code → /memarium`));
45
+ }
@@ -0,0 +1,19 @@
1
+ import chalk from "chalk";
2
+ import { readConfig } from "../config.js";
3
+ import { loadIndex } from "../index-store.js";
4
+ export async function listCmd(opts) {
5
+ const cfg = readConfig();
6
+ const idx = loadIndex(cfg.repoPath);
7
+ const rows = Object.values(idx.entries)
8
+ .filter((e) => !opts.tool || e.tool === opts.tool)
9
+ .filter((e) => !opts.project || e.project === opts.project)
10
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
11
+ if (rows.length === 0) {
12
+ console.log(chalk.gray("(no sessions)"));
13
+ return;
14
+ }
15
+ for (const e of rows) {
16
+ const date = e.startedAt.slice(0, 10);
17
+ console.log(`${chalk.gray(date)} ${chalk.cyan(e.tool)} ${chalk.yellow(e.project)} ${e.displayName} ${chalk.gray(e.shortId)}`);
18
+ }
19
+ }