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.
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/assets/scripts/merge-books.mjs +921 -0
- package/assets/workflows/memarium-aggregate.yml +66 -0
- package/dist/bin/memarium.js +6 -0
- package/dist/src/aggregated-store.js +95 -0
- package/dist/src/cli.js +175 -0
- package/dist/src/commands/cat.js +20 -0
- package/dist/src/commands/doctor.js +383 -0
- package/dist/src/commands/init-wizard.js +201 -0
- package/dist/src/commands/init.js +45 -0
- package/dist/src/commands/list.js +19 -0
- package/dist/src/commands/prune.js +108 -0
- package/dist/src/commands/resume/config-pathmap.js +38 -0
- package/dist/src/commands/resume/fuzzy-match.js +13 -0
- package/dist/src/commands/resume/list-sessions.js +54 -0
- package/dist/src/commands/resume/render-prompt.js +121 -0
- package/dist/src/commands/resume/resume.js +121 -0
- package/dist/src/commands/show.js +21 -0
- package/dist/src/commands/sync.js +279 -0
- package/dist/src/commands/upgrade.js +47 -0
- package/dist/src/commands/workflow.js +126 -0
- package/dist/src/config.js +98 -0
- package/dist/src/content-project-inference.js +185 -0
- package/dist/src/device.js +47 -0
- package/dist/src/digest/manifest.js +121 -0
- package/dist/src/digest/project-filter.js +32 -0
- package/dist/src/digest/session-signal.js +106 -0
- package/dist/src/digest/toc.js +127 -0
- package/dist/src/git-ops.js +359 -0
- package/dist/src/index-store.js +35 -0
- package/dist/src/migrate.js +72 -0
- package/dist/src/project-identity.js +139 -0
- package/dist/src/project-resolve.js +42 -0
- package/dist/src/prompts.js +87 -0
- package/dist/src/repo-data-dir.js +25 -0
- package/dist/src/slug.js +28 -0
- package/dist/src/sources/base.js +1 -0
- package/dist/src/sources/claude-code.js +294 -0
- package/dist/src/sources/vscode-copilot.js +400 -0
- package/dist/src/types.js +1 -0
- package/dist/src/writer.js +240 -0
- 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
|
+
}
|